diff --git a/packages/root-cms/core/client.test.ts b/packages/root-cms/core/client.test.ts new file mode 100644 index 000000000..6f6861c1d --- /dev/null +++ b/packages/root-cms/core/client.test.ts @@ -0,0 +1,365 @@ +import {RootConfig} from '@blinkk/root'; +import {describe, it, expect, beforeEach, vi} from 'vitest'; +import * as schema from './schema.js'; + +// Mock Firebase Admin. +vi.mock('firebase-admin/app', () => ({ + getApp: vi.fn(), + initializeApp: vi.fn(), + applicationDefault: vi.fn(), +})); + +vi.mock('firebase-admin/firestore', () => ({ + getFirestore: vi.fn(() => ({ + doc: vi.fn(() => ({ + get: vi.fn(() => ({exists: false, data: () => ({})})), + set: vi.fn(), + })), + })), + Timestamp: { + now: vi.fn(() => ({toMillis: () => 1234567890})), + }, + FieldValue: {}, +})); + +// Mock project module. +vi.mock('./project.js', () => ({ + getCollectionSchema: vi.fn(), +})); + +describe('RootCMSClient Validation', () => { + let mockRootConfig: RootConfig; + let mockGetCollectionSchema: any; + + beforeEach(async () => { + // Reset mocks. + vi.clearAllMocks(); + + // Setup mock root config. + mockRootConfig = { + rootDir: '/test', + plugins: [ + { + name: 'root-cms', + getConfig: () => ({ + id: 'test-project', + firebaseConfig: { + apiKey: 'test', + authDomain: 'test', + projectId: 'test-project', + storageBucket: 'test', + }, + }), + getFirebaseApp: vi.fn(), + getFirestore: vi.fn(() => ({ + doc: vi.fn(() => ({ + get: vi.fn(() => ({exists: false, data: () => ({})})), + set: vi.fn(), + })), + })), + } as any, + ], + } as any; + + // Import after mocks are set up. + const projectModule = await import('./project.js'); + mockGetCollectionSchema = projectModule.getCollectionSchema as any; + }); + + describe('getCollection', () => { + it('returns collection schema when it exists', async () => { + const {RootCMSClient} = await import('./client.js'); + const client = new RootCMSClient(mockRootConfig); + + const testSchema = schema.define({ + name: 'TestCollection', + fields: [schema.string({id: 'title'})], + }); + + mockGetCollectionSchema.mockResolvedValue(testSchema); + + const result = await client.getCollection('TestCollection'); + + expect(mockGetCollectionSchema).toHaveBeenCalledWith('TestCollection'); + expect(result).toEqual(testSchema); + }); + + it('returns null when collection does not exist', async () => { + const {RootCMSClient} = await import('./client.js'); + const client = new RootCMSClient(mockRootConfig); + + mockGetCollectionSchema.mockResolvedValue(null); + + const result = await client.getCollection('NonExistent'); + + expect(result).toBeNull(); + }); + }); + + describe('saveDraftData with validation', () => { + it('validates and saves successfully with valid data', async () => { + const {RootCMSClient} = await import('./client.js'); + const client = new RootCMSClient(mockRootConfig); + + const testSchema = schema.define({ + name: 'Pages', + fields: [schema.string({id: 'title'}), schema.number({id: 'count'})], + }); + + mockGetCollectionSchema.mockResolvedValue(testSchema); + + const validData = { + title: 'Test Page', + count: 42, + }; + + // Should not throw. + await expect( + client.saveDraftData('Pages/test', validData, { + validate: true, + }) + ).resolves.not.toThrow(); + }); + + it('throws error with validation details when data is invalid', async () => { + const {RootCMSClient} = await import('./client.js'); + const client = new RootCMSClient(mockRootConfig); + + const testSchema = schema.define({ + name: 'Pages', + fields: [schema.string({id: 'title'}), schema.number({id: 'count'})], + }); + + mockGetCollectionSchema.mockResolvedValue(testSchema); + + const invalidData = { + title: 123, // Should be string. + count: 'invalid', // Should be number. + }; + + await expect( + client.saveDraftData('Pages/test', invalidData, { + validate: true, + }) + ).rejects.toThrow(/Validation failed for Pages\/test/); + }); + + it('throws error when collection schema not found', async () => { + const {RootCMSClient} = await import('./client.js'); + const client = new RootCMSClient(mockRootConfig); + + mockGetCollectionSchema.mockResolvedValue(null); + + await expect( + client.saveDraftData( + 'Pages/test', + {title: 'Test'}, + { + validate: true, + } + ) + ).rejects.toThrow(/Collection schema not found for: Pages/); + }); + + it('skips validation when validate option is false', async () => { + const {RootCMSClient} = await import('./client.js'); + const client = new RootCMSClient(mockRootConfig); + + const invalidData = { + title: 123, // Invalid but validation is off. + }; + + // Should not call getCollectionSchema. + await client.saveDraftData('Pages/test', invalidData, { + validate: false, + }); + + expect(mockGetCollectionSchema).not.toHaveBeenCalled(); + }); + + it('skips validation when validate option is not provided (backward compatibility)', async () => { + const {RootCMSClient} = await import('./client.js'); + const client = new RootCMSClient(mockRootConfig); + + const invalidData = { + title: 123, // Invalid but validation is off by default. + }; + + // Should not call getCollectionSchema. + await client.saveDraftData('Pages/test', invalidData); + + expect(mockGetCollectionSchema).not.toHaveBeenCalled(); + }); + }); + + describe('updateDraftData', () => { + it('updates a simple field path', async () => { + const {RootCMSClient} = await import('./client.js'); + const client = new RootCMSClient(mockRootConfig); + + // Mock getRawDoc to return existing data. + const mockGetRawDoc = vi.fn().mockResolvedValue({ + sys: {}, + fields: { + title: 'Old Title', + count: 10, + }, + }); + client.getRawDoc = mockGetRawDoc as any; + + const mockSetRawDoc = vi.fn(); + client.setRawDoc = mockSetRawDoc as any; + + await client.updateDraftData('Pages/test', 'title', 'New Title'); + + // Verify the document was saved with updated title. + expect(mockSetRawDoc).toHaveBeenCalled(); + const savedData = mockSetRawDoc.mock.calls[0][2]; + expect(savedData.fields.title).toBe('New Title'); + expect(savedData.fields.count).toBe(10); // Unchanged. + }); + + it('updates a nested field path', async () => { + const {RootCMSClient} = await import('./client.js'); + const client = new RootCMSClient(mockRootConfig); + + const mockGetRawDoc = vi.fn().mockResolvedValue({ + sys: {}, + fields: { + hero: { + title: 'Old Hero Title', + subtitle: 'Subtitle', + }, + }, + }); + client.getRawDoc = mockGetRawDoc as any; + + const mockSetRawDoc = vi.fn(); + client.setRawDoc = mockSetRawDoc as any; + + await client.updateDraftData( + 'Pages/test', + 'hero.title', + 'New Hero Title' + ); + + const savedData = mockSetRawDoc.mock.calls[0][2]; + expect(savedData.fields.hero.title).toBe('New Hero Title'); + expect(savedData.fields.hero.subtitle).toBe('Subtitle'); // Unchanged. + }); + + it('updates an array item by index', async () => { + const {RootCMSClient} = await import('./client.js'); + const client = new RootCMSClient(mockRootConfig); + + const mockGetRawDoc = vi.fn().mockResolvedValue({ + sys: {}, + fields: { + content: { + _array: ['item1', 'item2'], + item1: {text: 'First item'}, + item2: {text: 'Second item'}, + }, + }, + }); + client.getRawDoc = mockGetRawDoc as any; + + const mockSaveDraftData = vi.fn(); + client.saveDraftData = mockSaveDraftData as any; + + await client.updateDraftData( + 'Pages/test', + 'content.0.text', + 'Updated first item' + ); + + // Verify saveDraftData was called with the updated data. + expect(mockSaveDraftData).toHaveBeenCalled(); + const savedData = mockSaveDraftData.mock.calls[0][1]; + expect(savedData.content[0].text).toBe('Updated first item'); + expect(savedData.content[1].text).toBe('Second item'); + }); + + it('validates after update when validate: true', async () => { + const {RootCMSClient} = await import('./client.js'); + const client = new RootCMSClient(mockRootConfig); + + const testSchema = schema.define({ + name: 'Pages', + fields: [schema.string({id: 'title'}), schema.number({id: 'count'})], + }); + + mockGetCollectionSchema.mockResolvedValue(testSchema); + + const mockGetRawDoc = vi.fn().mockResolvedValue({ + sys: {}, + fields: { + title: 'Old Title', + count: 10, + }, + }); + client.getRawDoc = mockGetRawDoc as any; + + const mockSetRawDoc = vi.fn(); + client.setRawDoc = mockSetRawDoc as any; + + // Valid update. + await client.updateDraftData('Pages/test', 'title', 'New Title', { + validate: true, + }); + + expect(mockSetRawDoc).toHaveBeenCalled(); + }); + + it('throws validation error when update results in invalid document', async () => { + const {RootCMSClient} = await import('./client.js'); + const client = new RootCMSClient(mockRootConfig); + + const testSchema = schema.define({ + name: 'Pages', + fields: [schema.string({id: 'title'}), schema.number({id: 'count'})], + }); + + mockGetCollectionSchema.mockResolvedValue(testSchema); + + const mockGetRawDoc = vi.fn().mockResolvedValue({ + sys: {}, + fields: { + title: 'Old Title', + count: 10, + }, + }); + client.getRawDoc = mockGetRawDoc as any; + + // Try to set count to invalid value. + await expect( + client.updateDraftData('Pages/test', 'count', 'invalid', { + validate: true, + }) + ).rejects.toThrow(/Validation failed for Pages\/test/); + }); + + it('skips validation when validate: false', async () => { + const {RootCMSClient} = await import('./client.js'); + const client = new RootCMSClient(mockRootConfig); + + const mockGetRawDoc = vi.fn().mockResolvedValue({ + sys: {}, + fields: { + title: 'Old Title', + }, + }); + client.getRawDoc = mockGetRawDoc as any; + + const mockSetRawDoc = vi.fn(); + client.setRawDoc = mockSetRawDoc as any; + + await client.updateDraftData('Pages/test', 'title', 123, { + validate: false, + }); + + expect(mockGetCollectionSchema).not.toHaveBeenCalled(); + expect(mockSetRawDoc).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/root-cms/core/client.ts b/packages/root-cms/core/client.ts index 55f83a6be..927a84801 100644 --- a/packages/root-cms/core/client.ts +++ b/packages/root-cms/core/client.ts @@ -1,5 +1,5 @@ import crypto from 'node:crypto'; -import {Plugin, RootConfig} from '@blinkk/root'; +import {type Plugin, type RootConfig} from '@blinkk/root'; import {App} from 'firebase-admin/app'; import { FieldValue, @@ -9,7 +9,10 @@ import { WriteBatch, } from 'firebase-admin/firestore'; import {CMSPlugin} from './plugin.js'; +import {Collection} from './schema.js'; import {TranslationsManager} from './translations-manager.js'; +import {validateFields} from './validation.js'; +import {setValueAtPath} from './values.js'; export interface Doc { /** The id of the doc, e.g. "Pages/foo-bar". */ @@ -99,6 +102,20 @@ export interface SaveDraftOptions { * Email of user modifying the doc. If blank, defaults to `root-cms-client`. */ modifiedBy?: string; + + /** + * Whether to validate fieldsData against the collection schema before saving. + * If validation fails, an error will be thrown with details about the validation errors. + */ + validate?: boolean; +} + +export interface UpdateDraftOptions { + /** + * Whether to validate the updated field against the collection schema. + * If validation fails, an error will be thrown with details about the validation errors. + */ + validate?: boolean; } export interface ListDocsOptions { @@ -286,6 +303,17 @@ export class RootCMSClient { return this.db.doc(docPath); } + /** + * Returns a collection's schema definition as defined in + * `/collections/.schema.ts`. + */ + async getCollection(collectionId: string): Promise { + // Lazy load the project module to minimize the amount of code loaded + // when the client is initialized (the project module loads all schema files). + const project = await import('./project.js'); + return await project.getCollectionSchema(collectionId); + } + /** * Saves draft data to a doc. * @@ -298,6 +326,24 @@ export class RootCMSClient { options?: SaveDraftOptions ) { const {collection, slug} = parseDocId(docId); + + // Validate fieldsData if requested. + if (options?.validate) { + const collectionSchema = await this.getCollection(collection); + if (!collectionSchema) { + throw new Error( + `Collection schema not found for: ${collection}. Unable to validate.` + ); + } + const errors = validateFields(fieldsData, collectionSchema); + if (errors.length > 0) { + const errorMessages = errors + .map((err) => ` - ${err.path}: ${err.message}`) + .join('\n'); + throw new Error(`Validation failed for ${docId}:\n${errorMessages}`); + } + } + const draftDoc = (await this.getRawDoc(collection, slug, {mode: 'draft'})) || {}; const draftSys = draftDoc.sys || {}; @@ -320,6 +366,39 @@ export class RootCMSClient { await this.setRawDoc(collection, slug, data, {mode: 'draft'}); } + /** + * Updates a specific field path in a draft doc. + * + * This allows partial updates to nested fields without replacing the entire document. + * For example: `updateDraftData('Pages/home', 'hero.title', 'New Title')` + * + * @param docId - The document ID (e.g., 'Pages/home') + * @param path - JSON path to the field (e.g., 'hero.title' or 'content.0.text') + * @param fieldValue - The value to set at the specified path + * @param options - Update options including validation + */ + async updateDraftData( + docId: string, + path: string, + fieldValue: any, + options?: UpdateDraftOptions + ) { + const {collection, slug} = parseDocId(docId); + + // Get current draft doc. + const draftDoc = + (await this.getRawDoc(collection, slug, {mode: 'draft'})) || {}; + const fieldsData = unmarshalData(draftDoc.fields || {}); + + // Set the value at the specified path. + setValueAtPath(fieldsData, path, fieldValue); + + // Save the updated document using saveDraftData. + await this.saveDraftData(docId, fieldsData, { + validate: options?.validate, + }); + } + /** * Prefer `saveDraftData('Pages/foo', data)`. Only use this if you know what * you're doing. diff --git a/packages/root-cms/core/validation.test.ts b/packages/root-cms/core/validation.test.ts new file mode 100644 index 000000000..9ca6bea73 --- /dev/null +++ b/packages/root-cms/core/validation.test.ts @@ -0,0 +1,996 @@ +import {expect, test, vi} from 'vitest'; + +import * as schema from './schema.js'; +import {validateFields} from './validation.js'; + +test('validates string fields', () => { + const testSchema = schema.define({ + name: 'TestSchema', + fields: [schema.string({id: 'title'}), schema.string({id: 'description'})], + }); + + // Valid data. + expect( + validateFields({title: 'Hello', description: 'World'}, testSchema) + ).toMatchInlineSnapshot('[]'); + + // Invalid data - number instead of string. + expect(validateFields({title: 123}, testSchema)).toMatchInlineSnapshot(` + [ + { + "expected": "string", + "message": "Expected string, received number", + "path": "title", + "received": "number", + }, + ] + `); + + // Partial validation - only validate provided fields. + expect(validateFields({title: 'Hello'}, testSchema)).toMatchInlineSnapshot( + '[]' + ); + + // Null value is acceptable. + expect(validateFields({title: null}, testSchema)).toMatchInlineSnapshot(` + [ + { + "expected": "string", + "message": "Expected string, received null", + "path": "title", + "received": "null", + }, + ] + `); +}); + +test('validates number fields', () => { + const testSchema = schema.define({ + name: 'TestSchema', + fields: [schema.number({id: 'count'}), schema.number({id: 'price'})], + }); + + // Valid data. + expect( + validateFields({count: 42, price: 99.99}, testSchema) + ).toMatchInlineSnapshot('[]'); + + // Invalid data - string instead of number. + expect(validateFields({count: '42'}, testSchema)).toMatchInlineSnapshot(` + [ + { + "expected": "number", + "message": "Expected number, received string", + "path": "count", + "received": "string", + }, + ] + `); + + // Invalid data - NaN. + expect(validateFields({count: NaN}, testSchema)).toMatchInlineSnapshot(` + [ + { + "expected": "number", + "message": "Expected number, received nan", + "path": "count", + "received": "nan", + }, + ] + `); +}); + +test('validates boolean fields', () => { + const testSchema = schema.define({ + name: 'TestSchema', + fields: [ + schema.boolean({id: 'published'}), + schema.boolean({id: 'featured'}), + ], + }); + + // Valid data. + expect( + validateFields({published: true, featured: false}, testSchema) + ).toMatchInlineSnapshot('[]'); + + // Invalid data - string instead of boolean. + expect(validateFields({published: 'true'}, testSchema)) + .toMatchInlineSnapshot(` + [ + { + "expected": "boolean", + "message": "Expected boolean, received string", + "path": "published", + "received": "string", + }, + ] + `); +}); + +test('validates date and datetime fields', () => { + const testSchema = schema.define({ + name: 'TestSchema', + fields: [ + schema.date({id: 'publishDate'}), + schema.datetime({id: 'createdAt'}), + ], + }); + + // Valid data - Firestore timestamp format. + expect( + validateFields( + { + publishDate: { + type: 'firestore/timestamp/1.0', + seconds: 1763658000, + nanoseconds: 0, + }, + createdAt: { + type: 'firestore/timestamp/1.0', + seconds: 1763675872, + nanoseconds: 777000000, + }, + }, + testSchema + ) + ).toMatchInlineSnapshot('[]'); + + // Valid data - minimal (type is optional). + expect( + validateFields( + { + publishDate: { + seconds: 1763658000, + nanoseconds: 0, + }, + }, + testSchema + ) + ).toMatchInlineSnapshot('[]'); + + // Invalid data - string instead of object. + expect(validateFields({publishDate: '2023-01-01'}, testSchema)) + .toMatchInlineSnapshot(` + [ + { + "expected": "object", + "message": "Expected object, received string", + "path": "publishDate", + "received": "string", + }, + ] + `); + + // Invalid data - missing required fields. + expect( + validateFields({publishDate: {type: 'firestore/timestamp/1.0'}}, testSchema) + ).toMatchInlineSnapshot(` + [ + { + "expected": "number", + "message": "Required", + "path": "publishDate.seconds", + "received": "undefined", + }, + { + "expected": "number", + "message": "Required", + "path": "publishDate.nanoseconds", + "received": "undefined", + }, + ] + `); +}); + +test('validates select fields', () => { + const testSchema = schema.define({ + name: 'TestSchema', + fields: [ + schema.select({ + id: 'category', + options: ['news', 'blog', 'event'], + }), + ], + }); + + // Valid data. + expect(validateFields({category: 'news'}, testSchema)).toMatchInlineSnapshot( + '[]' + ); + + // Invalid data - number instead of string. + expect(validateFields({category: 123}, testSchema)).toMatchInlineSnapshot(` + [ + { + "expected": "string", + "message": "Expected string, received number", + "path": "category", + "received": "number", + }, + ] + `); +}); + +test('validates multiselect fields', () => { + const testSchema = schema.define({ + name: 'TestSchema', + fields: [ + schema.multiselect({ + id: 'tags', + options: ['typescript', 'javascript', 'python'], + }), + ], + }); + + // Valid data. + expect( + validateFields({tags: ['typescript', 'javascript']}, testSchema) + ).toMatchInlineSnapshot('[]'); + + // Invalid data - string instead of array. + expect(validateFields({tags: 'typescript'}, testSchema)) + .toMatchInlineSnapshot(` + [ + { + "expected": "array", + "message": "Expected array, received string", + "path": "tags", + "received": "string", + }, + ] + `); + + // Invalid data - array with non-string items. + expect(validateFields({tags: ['typescript', 123]}, testSchema)) + .toMatchInlineSnapshot(` + [ + { + "expected": "string", + "message": "Expected string, received number", + "path": "tags.1", + "received": "number", + }, + ] + `); +}); + +test('validates image fields', () => { + const testSchema = schema.define({ + name: 'TestSchema', + fields: [schema.image({id: 'thumbnail'}), schema.image({id: 'hero'})], + }); + + // Valid data. + expect( + validateFields( + {thumbnail: {src: '/image.jpg', alt: 'Alt text'}}, + testSchema + ) + ).toMatchInlineSnapshot('[]'); + + // Valid data - without alt. + expect( + validateFields({thumbnail: {src: '/image.jpg'}}, testSchema) + ).toMatchInlineSnapshot('[]'); + + // Invalid data - string instead of object. + expect(validateFields({thumbnail: '/image.jpg'}, testSchema)) + .toMatchInlineSnapshot(` + [ + { + "expected": "object", + "message": "Expected object, received string", + "path": "thumbnail", + "received": "string", + }, + ] + `); + + // Invalid data - missing src. + expect(validateFields({thumbnail: {alt: 'Alt text'}}, testSchema)) + .toMatchInlineSnapshot(` + [ + { + "expected": "string", + "message": "Required", + "path": "thumbnail.src", + "received": "undefined", + }, + ] + `); + + // Invalid data - invalid alt type. + expect(validateFields({thumbnail: {src: '/image.jpg', alt: 123}}, testSchema)) + .toMatchInlineSnapshot(` + [ + { + "expected": "string", + "message": "Expected string, received number", + "path": "thumbnail.alt", + "received": "number", + }, + ] + `); +}); + +test('validates file fields', () => { + const testSchema = schema.define({ + name: 'TestSchema', + fields: [schema.file({id: 'document'})], + }); + + // Valid data. + expect( + validateFields({document: {src: '/doc.pdf'}}, testSchema) + ).toMatchInlineSnapshot('[]'); + + // Invalid data - array instead of object. + expect(validateFields({document: ['/doc.pdf']}, testSchema)) + .toMatchInlineSnapshot(` + [ + { + "expected": "object", + "message": "Expected object, received array", + "path": "document", + "received": "array", + }, + ] + `); +}); + +test('validates object fields', () => { + const testSchema = schema.define({ + name: 'TestSchema', + fields: [ + schema.object({ + id: 'meta', + fields: [ + schema.string({id: 'title'}), + schema.string({id: 'description'}), + ], + }), + ], + }); + + // Valid data. + expect( + validateFields({meta: {title: 'Hello', description: 'World'}}, testSchema) + ).toMatchInlineSnapshot('[]'); + + // Invalid data - string instead of object. + expect(validateFields({meta: 'not an object'}, testSchema)) + .toMatchInlineSnapshot(` + [ + { + "expected": "object", + "message": "Expected object, received string", + "path": "meta", + "received": "string", + }, + ] + `); + + // Invalid data - nested field has wrong type. + expect(validateFields({meta: {title: 123}}, testSchema)) + .toMatchInlineSnapshot(` + [ + { + "expected": "string", + "message": "Expected string, received number", + "path": "meta.title", + "received": "number", + }, + ] + `); + + // Partial validation - only validate provided nested fields. + expect( + validateFields({meta: {title: 'Hello'}}, testSchema) + ).toMatchInlineSnapshot('[]'); +}); + +test('validates array fields with object items', () => { + const testSchema = schema.define({ + name: 'TestSchema', + fields: [ + schema.array({ + id: 'items', + of: schema.object({ + fields: [ + schema.string({id: 'name'}), + schema.number({id: 'quantity'}), + ], + }), + }), + ], + }); + + // Valid data. + expect( + validateFields( + { + items: [ + {name: 'Apple', quantity: 5}, + {name: 'Orange', quantity: 3}, + ], + }, + testSchema + ) + ).toMatchInlineSnapshot('[]'); + + // Invalid data - string instead of array. + expect(validateFields({items: 'not an array'}, testSchema)) + .toMatchInlineSnapshot(` + [ + { + "expected": "array", + "message": "Expected array, received string", + "path": "items", + "received": "string", + }, + ] + `); + + // Invalid data - array item has wrong type. + expect( + validateFields({items: [{name: 'Apple', quantity: 'five'}]}, testSchema) + ).toMatchInlineSnapshot(` + [ + { + "expected": "number", + "message": "Expected number, received string", + "path": "items.0.quantity", + "received": "string", + }, + ] + `); + + // Multiple errors in array. + expect( + validateFields( + { + items: [ + {name: 123, quantity: 'five'}, + {name: 'Orange', quantity: 3}, + ], + }, + testSchema + ) + ).toMatchInlineSnapshot(` + [ + { + "expected": "string", + "message": "Expected string, received number", + "path": "items.0.name", + "received": "number", + }, + { + "expected": "number", + "message": "Expected number, received string", + "path": "items.0.quantity", + "received": "string", + }, + ] + `); +}); + +test('validates array fields with image items', () => { + const testSchema = schema.define({ + name: 'TestSchema', + fields: [ + schema.array({ + id: 'gallery', + of: schema.image({}), + }), + ], + }); + + // Valid data. + expect( + validateFields( + {gallery: [{src: '/img1.jpg'}, {src: '/img2.jpg', alt: 'Image 2'}]}, + testSchema + ) + ).toMatchInlineSnapshot('[]'); + + // Invalid data - missing src in array item. + expect(validateFields({gallery: [{alt: 'Image'}]}, testSchema)) + .toMatchInlineSnapshot(` + [ + { + "expected": "string", + "message": "Required", + "path": "gallery.0.src", + "received": "undefined", + }, + ] + `); +}); + +test('validates oneOf fields', () => { + const ImageBlock = schema.define({ + name: 'ImageBlock', + fields: [schema.image({id: 'image'})], + }); + + const TextBlock = schema.define({ + name: 'TextBlock', + fields: [schema.string({id: 'text'})], + }); + + const testSchema = schema.define({ + name: 'TestSchema', + fields: [ + schema.oneOf({ + id: 'content', + types: [ImageBlock, TextBlock], + }), + ], + }); + + // Valid data - ImageBlock. + expect( + validateFields( + {content: {_type: 'ImageBlock', image: {src: '/img.jpg'}}}, + testSchema + ) + ).toMatchInlineSnapshot('[]'); + + // Valid data - TextBlock. + expect( + validateFields({content: {_type: 'TextBlock', text: 'Hello'}}, testSchema) + ).toMatchInlineSnapshot('[]'); + + // Invalid data - missing _type. + expect(validateFields({content: {text: 'Hello'}}, testSchema)) + .toMatchInlineSnapshot(` + [ + { + "expected": "valid discriminator value", + "message": "Invalid discriminator value. Expected 'ImageBlock' | 'TextBlock'", + "path": "content._type", + "received": undefined, + }, + ] + `); + + // Invalid data - unknown type. + expect( + validateFields( + {content: {_type: 'UnknownBlock', text: 'Hello'}}, + testSchema + ) + ).toMatchInlineSnapshot(` + [ + { + "expected": "valid discriminator value", + "message": "Invalid discriminator value. Expected 'ImageBlock' | 'TextBlock'", + "path": "content._type", + "received": "UnknownBlock", + }, + ] + `); + + // Invalid data - wrong field type for the selected type. + expect(validateFields({content: {_type: 'TextBlock', text: 123}}, testSchema)) + .toMatchInlineSnapshot(` + [ + { + "expected": "string", + "message": "Expected string, received number", + "path": "content.text", + "received": "number", + }, + ] + `); + + // Invalid data - string instead of object. + expect(validateFields({content: 'ImageBlock'}, testSchema)) + .toMatchInlineSnapshot(` + [ + { + "expected": "object", + "message": "Expected object, received string", + "path": "content", + "received": "string", + }, + ] + `); +}); + +test('validates richtext fields', () => { + const testSchema = schema.define({ + name: 'TestSchema', + fields: [schema.richtext({id: 'body'})], + }); + + // Valid data. + expect( + validateFields( + { + body: { + version: '2.31.0', + time: { + type: 'firestore/timestamp/1.0', + seconds: 1763675872, + nanoseconds: 777000000, + }, + blocks: [{type: 'paragraph', data: {text: 'Hello'}}], + }, + }, + testSchema + ) + ).toMatchInlineSnapshot('[]'); + + // Valid data - minimal (only blocks required). + expect( + validateFields( + { + body: { + blocks: [{type: 'paragraph', data: {text: 'Hello'}}], + }, + }, + testSchema + ) + ).toMatchInlineSnapshot('[]'); + + // Invalid data - string instead of object. + expect(validateFields({body: 'Hello world'}, testSchema)) + .toMatchInlineSnapshot(` + [ + { + "expected": "object", + "message": "Expected object, received string", + "path": "body", + "received": "string", + }, + ] + `); + + // Invalid data - missing blocks. + expect(validateFields({body: {version: '2.31.0'}}, testSchema)) + .toMatchInlineSnapshot(` + [ + { + "expected": "array", + "message": "Required", + "path": "body.blocks", + "received": "undefined", + }, + ] + `); + + // Invalid data - blocks missing required type field. + expect( + validateFields({body: {blocks: [{data: {text: 'Hello'}}]}}, testSchema) + ).toMatchInlineSnapshot(` + [ + { + "expected": "string", + "message": "Required", + "path": "body.blocks.0.type", + "received": "undefined", + }, + ] + `); +}); + +test('validates reference fields', () => { + const testSchema = schema.define({ + name: 'TestSchema', + fields: [schema.reference({id: 'author'})], + }); + + // Valid data. + expect( + validateFields( + { + author: { + id: 'Authors/john-doe', + collection: 'Authors', + slug: 'john-doe', + }, + }, + testSchema + ) + ).toMatchInlineSnapshot('[]'); + + // Invalid data - string instead of object. + expect(validateFields({author: 'Authors/john-doe'}, testSchema)) + .toMatchInlineSnapshot(` + [ + { + "expected": "object", + "message": "Expected object, received string", + "path": "author", + "received": "string", + }, + ] + `); + + // Invalid data - missing required fields. + expect(validateFields({author: {id: 'Authors/john-doe'}}, testSchema)) + .toMatchInlineSnapshot(` + [ + { + "expected": "string", + "message": "Required", + "path": "author.collection", + "received": "undefined", + }, + { + "expected": "string", + "message": "Required", + "path": "author.slug", + "received": "undefined", + }, + ] + `); +}); + +test('validates references fields', () => { + const testSchema = schema.define({ + name: 'TestSchema', + fields: [schema.references({id: 'relatedPosts'})], + }); + + // Valid data. + expect( + validateFields( + { + relatedPosts: [ + {id: 'Posts/post-1', collection: 'Posts', slug: 'post-1'}, + {id: 'Posts/post-2', collection: 'Posts', slug: 'post-2'}, + ], + }, + testSchema + ) + ).toMatchInlineSnapshot('[]'); + + // Invalid data - string instead of array. + expect(validateFields({relatedPosts: 'Posts/post-1'}, testSchema)) + .toMatchInlineSnapshot(` + [ + { + "expected": "array", + "message": "Expected array, received string", + "path": "relatedPosts", + "received": "string", + }, + ] + `); + + // Invalid data - array with non-object items. + expect( + validateFields({relatedPosts: ['Posts/post-1', 'Posts/post-2']}, testSchema) + ).toMatchInlineSnapshot(` + [ + { + "expected": "object", + "message": "Expected object, received string", + "path": "relatedPosts.0", + "received": "string", + }, + { + "expected": "object", + "message": "Expected object, received string", + "path": "relatedPosts.1", + "received": "string", + }, + ] + `); +}); + +test('validates complex nested structures', () => { + const testSchema = schema.define({ + name: 'BlogPost', + fields: [ + schema.object({ + id: 'meta', + fields: [schema.string({id: 'title'}), schema.image({id: 'image'})], + }), + schema.array({ + id: 'content', + of: schema.oneOf({ + types: [ + schema.define({ + name: 'TextBlock', + fields: [schema.string({id: 'text'})], + }), + schema.define({ + name: 'ImageBlock', + fields: [schema.image({id: 'image'})], + }), + ], + }), + }), + ], + }); + + // Valid data. + expect( + validateFields( + { + meta: {title: 'Hello', image: {src: '/img.jpg'}}, + content: [ + {_type: 'TextBlock', text: 'Hello'}, + {_type: 'ImageBlock', image: {src: '/img.jpg'}}, + ], + }, + testSchema + ) + ).toMatchInlineSnapshot('[]'); + + // Invalid nested fields. + expect( + validateFields( + { + meta: {title: 123, image: '/img.jpg'}, + content: [ + {_type: 'TextBlock', text: 123}, + {_type: 'ImageBlock', image: '/img.jpg'}, + ], + }, + testSchema + ) + ).toMatchInlineSnapshot(` + [ + { + "expected": "string", + "message": "Expected string, received number", + "path": "meta.title", + "received": "number", + }, + { + "expected": "object", + "message": "Expected object, received string", + "path": "meta.image", + "received": "string", + }, + { + "expected": "string", + "message": "Expected string, received number", + "path": "content.0.text", + "received": "number", + }, + { + "expected": "object", + "message": "Expected object, received string", + "path": "content.1.image", + "received": "string", + }, + ] + `); +}); + +test('validates nested oneOf fields', () => { + const ButtonBlock = schema.define({ + name: 'ButtonBlock', + fields: [schema.string({id: 'label'}), schema.string({id: 'url'})], + }); + + const VideoBlock = schema.define({ + name: 'VideoBlock', + fields: [schema.string({id: 'videoId'})], + }); + + const Section = schema.define({ + name: 'Section', + fields: [ + schema.array({ + id: 'items', + of: schema.oneOf({ + types: [ButtonBlock, VideoBlock], + }), + }), + ], + }); + + const testSchema = schema.define({ + name: 'Page', + fields: [ + schema.array({ + id: 'content', + of: schema.oneOf({types: [Section]}), + }), + ], + }); + + // Valid deep structure. + expect( + validateFields( + { + content: [ + { + _type: 'Section', + items: [ + {_type: 'ButtonBlock', label: 'Click me', url: '/'}, + {_type: 'VideoBlock', videoId: '123'}, + ], + }, + ], + }, + testSchema + ) + ).toMatchInlineSnapshot('[]'); + + // Invalid deep structure - wrong discriminator. + expect( + validateFields( + { + content: [ + { + _type: 'Section', + items: [{_type: 'InvalidBlock', label: 'Click me', url: '/'}], + }, + ], + }, + testSchema + ) + ).toMatchInlineSnapshot(` + [ + { + "expected": "valid discriminator value", + "message": "Invalid discriminator value. Expected 'ButtonBlock' | 'VideoBlock'", + "path": "content.0.items.0._type", + "received": "InvalidBlock", + }, + ] + `); +}); + +test('fallback validation for unknown types', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const testSchema = schema.define({ + name: 'TestSchema', + fields: [ + // @ts-expect-error - explicitly testing unknown type + {id: 'raw', type: 'unknown-type'}, + ], + }); + + // Since we don't know the type, we accept anything. + expect(validateFields({raw: 'anything'}, testSchema)).toMatchInlineSnapshot( + '[]' + ); + expect(warnSpy).toHaveBeenCalledWith('Unknown field type: unknown-type'); + + warnSpy.mockRestore(); +}); + +test('partial validation', () => { + const testSchema = schema.define({ + name: 'TestSchema', + fields: [schema.string({id: 'title'}), schema.string({id: 'description'})], + }); + + // Partial data is valid. + expect(validateFields({title: 'Hello'}, testSchema)).toMatchInlineSnapshot( + '[]' + ); + expect( + validateFields({description: 'World'}, testSchema) + ).toMatchInlineSnapshot('[]'); +}); + +test('partial validation ignores missing fields', () => { + const testSchema = schema.define({ + name: 'TestSchema', + fields: [schema.string({id: 'title'}), schema.number({id: 'count'})], + }); + + // Validate errors only for present fields. + expect(validateFields({title: 'Hello', count: 'not-a-number'}, testSchema)) + .toMatchInlineSnapshot(` + [ + { + "expected": "number", + "message": "Expected number, received string", + "path": "count", + "received": "string", + }, + ] + `); +}); diff --git a/packages/root-cms/core/validation.ts b/packages/root-cms/core/validation.ts new file mode 100644 index 000000000..e6d7b2384 --- /dev/null +++ b/packages/root-cms/core/validation.ts @@ -0,0 +1,401 @@ +import type { + ArrayField, + FieldWithId, + ObjectField, + OneOfField, + Schema, +} from './schema.js'; + +/** + * Represents a validation error for a field. + */ +export interface ValidationError { + /** The path to the field, e.g., "meta.title" or "content.blocks[0].text". */ + path: string; + /** Human-readable error message. */ + message: string; + /** Expected type or value. */ + expected?: string; + /** Actual value received. */ + received?: any; +} + +/** + * Validates field data against a schema. + * + * This function validates `fieldsData` against the provided `schema`. It + * supports both partial validation (validates only fields present in the data) + * and full document validation. + * + * @param fieldsData - The data to validate. + * @param schema - The schema defining the expected structure. + * @returns An array of validation errors. Empty array if validation passes. + */ +export function validateFields( + fieldsData: any, + schema: Schema +): ValidationError[] { + // Handle null or undefined data. + if (fieldsData === null || fieldsData === undefined) { + return []; + } + + // Validate that fieldsData is an object. + if (typeof fieldsData !== 'object' || Array.isArray(fieldsData)) { + return [ + { + path: '', + message: 'Expected object for fields data', + expected: 'object', + received: getType(fieldsData), + }, + ]; + } + + const errors: ValidationError[] = []; + + for (const field of schema.fields) { + if (!field.id) { + continue; + } + + // Only validate if the field is present in the data (partial validation). + // Note: We might want to support required fields in the future. + if (!(field.id in fieldsData)) { + continue; + } + + const value = fieldsData[field.id]; + errors.push(...validateValue(value, field, field.id)); + } + + return errors; +} + +/** + * Validates a single value against a field definition. + */ +function validateValue( + value: any, + field: FieldWithId, + path: string +): ValidationError[] { + // Handle undefined (optional) values. + // Note: null is considered a value and should be validated against the type. + if (value === undefined) { + return []; + } + + switch (field.type) { + case 'string': + case 'select': // Select is stored as a string. + if (typeof value !== 'string') { + return [createError(path, 'string', value)]; + } + return []; + + case 'number': + if (typeof value !== 'number') { + return [createError(path, 'number', value)]; + } + if (isNaN(value)) { + return [createError(path, 'number', value)]; + } + return []; + + case 'boolean': + if (typeof value !== 'boolean') { + return [createError(path, 'boolean', value)]; + } + return []; + + case 'date': + case 'datetime': { + // Basic check for object structure matching Firestore Timestamp-like or object with seconds. + if (typeof value !== 'object' || Array.isArray(value)) { + return [createError(path, 'object', value)]; + } + + const errors: ValidationError[] = []; + const seconds = value.seconds; + const nanoseconds = value.nanoseconds; + + if (seconds === undefined) { + errors.push({ + path: `${path}.seconds`, + message: 'Required', + expected: 'number', + received: 'undefined', + }); + } else if (typeof seconds !== 'number') { + errors.push(createError(`${path}.seconds`, 'number', seconds)); + } + + if (nanoseconds === undefined) { + errors.push({ + path: `${path}.nanoseconds`, + message: 'Required', + expected: 'number', + received: 'undefined', + }); + } else if (typeof nanoseconds !== 'number') { + errors.push(createError(`${path}.nanoseconds`, 'number', nanoseconds)); + } + + return errors; + } + + case 'multiselect': + if (!Array.isArray(value)) { + return [createError(path, 'array', value)]; + } + return value.flatMap((item, index) => { + if (typeof item !== 'string') { + return [createError(`${path}.${index}`, 'string', item)]; + } + return []; + }); + + case 'image': + case 'file': { + if (typeof value !== 'object' || Array.isArray(value)) { + return [createError(path, 'object', value)]; + } + const errors: ValidationError[] = []; + if (value.src === undefined) { + errors.push({ + path: `${path}.src`, + message: 'Required', + expected: 'string', + received: 'undefined', + }); + } else if (typeof value.src !== 'string') { + errors.push(createError(`${path}.src`, 'string', value.src)); + } + + // alt is optional, but if present must be string + if (value.alt !== undefined && typeof value.alt !== 'string') { + errors.push(createError(`${path}.alt`, 'string', value.alt)); + } + return errors; + } + + case 'object': { + if (typeof value !== 'object' || Array.isArray(value)) { + return [createError(path, 'object', value)]; + } + const objectField = field as ObjectField; + const errors: ValidationError[] = []; + for (const nestedField of objectField.fields) { + if (!nestedField.id || !(nestedField.id in value)) { + continue; + } + errors.push( + ...validateValue( + value[nestedField.id], + nestedField, + `${path}.${nestedField.id}` + ) + ); + } + return errors; + } + + case 'array': { + if (!Array.isArray(value)) { + return [createError(path, 'array', value)]; + } + const arrayField = field as ArrayField; + const itemField = arrayField.of as FieldWithId; + // Synthesize an 'id' for validation context if missing, though it's not used in path construction directly here. + return value.flatMap((item, index) => { + return validateValue(item, itemField, `${path}.${index}`); + }); + } + + case 'oneof': { + if (typeof value !== 'object' || Array.isArray(value)) { + return [createError(path, 'object', value)]; + } + + const oneOfField = field as OneOfField; + const typeName = value._type; + + // Build a map of available types. + const typeMap = new Map(); + const typeNames: string[] = []; + + for (const type of oneOfField.types) { + if (typeof type === 'string') { + // Type references are not fully supported for validation here without lookup. + // Instead, just store the name. + typeNames.push(type); + continue; + } + typeMap.set(type.name, type); + typeNames.push(type.name); + } + + // 1. Check if _type matches one of the allowed schemas. + if (!typeNames.includes(typeName)) { + // Create a formatted list of expected types roughly matching Zod's enum output styles or just listing them. + // Previous error was: "Invalid discriminator value. Expected 'ImageBlock' | 'TextBlock'" + const expectedStr = typeNames.map((t) => `'${t}'`).join(' | '); + return [ + { + path: `${path}._type`, + message: `Invalid discriminator value. Expected ${expectedStr}`, + expected: 'valid discriminator value', + received: typeName, + }, + ]; + } + + // 2. Validate against the matched schema fields. + const matchedSchema = typeMap.get(typeName); + if (!matchedSchema) { + // Should not happen given the check above, unless it was a string reference we skipped. + return []; + } + + const errors: ValidationError[] = []; + for (const nestedField of matchedSchema.fields) { + if (!nestedField.id || !(nestedField.id in value)) { + continue; + } + errors.push( + ...validateValue( + value[nestedField.id], + nestedField, + `${path}.${nestedField.id}` + ) + ); + } + return errors; + } + + case 'richtext': { + if (typeof value !== 'object' || Array.isArray(value)) { + return [createError(path, 'object', value)]; + } + // Schema: { version?: string, time?: object, blocks: array } + const errors: ValidationError[] = []; + + if (value.blocks === undefined) { + errors.push({ + path: `${path}.blocks`, + message: 'Required', + expected: 'array', + received: 'undefined', + }); + } else if (!Array.isArray(value.blocks)) { + errors.push(createError(`${path}.blocks`, 'array', value.blocks)); + } else { + // Validate blocks structure: each block must have { type: string, data: any } + value.blocks.forEach((block: any, index: number) => { + if (typeof block !== 'object' || block === null) { + errors.push( + createError(`${path}.blocks.${index}`, 'object', block) + ); + return; + } + if (block.type === undefined) { + errors.push({ + path: `${path}.blocks.${index}.type`, + message: 'Required', + expected: 'string', + received: 'undefined', + }); + } else if (typeof block.type !== 'string') { + errors.push( + createError(`${path}.blocks.${index}.type`, 'string', block.type) + ); + } + }); + } + return errors; + } + + case 'reference': { + // Schema: { id, collection, slug } all required strings + if (typeof value !== 'object' || Array.isArray(value)) { + return [createError(path, 'object', value)]; + } + const errors: ValidationError[] = []; + const requiredFields = ['id', 'collection', 'slug']; + for (const req of requiredFields) { + if (value[req] === undefined) { + errors.push({ + path: `${path}.${req}`, + message: 'Required', + expected: 'string', + received: 'undefined', + }); + } else if (typeof value[req] !== 'string') { + errors.push(createError(`${path}.${req}`, 'string', value[req])); + } + } + return errors; + } + + case 'references': { + if (!Array.isArray(value)) { + return [createError(path, 'array', value)]; + } + return value.flatMap((item, index) => { + if (typeof item !== 'object' || item === null || Array.isArray(item)) { + return [createError(`${path}.${index}`, 'object', item)]; + } + // Each item acts like a reference (id, collection, slug). + const errors: ValidationError[] = []; + const requiredFields = ['id', 'collection', 'slug']; + for (const req of requiredFields) { + if (item[req] === undefined) { + errors.push({ + path: `${path}.${index}.${req}`, + message: 'Required', + expected: 'string', + received: 'undefined', + }); + } else if (typeof item[req] !== 'string') { + errors.push( + createError(`${path}.${index}.${req}`, 'string', item[req]) + ); + } + } + return errors; + }); + } + + default: + console.warn(`Unknown field type: ${(field as any).type}`); + return []; + } +} + +/** + * Helper to determine the type string of a value for error messages. + */ +function getType(value: any): string { + if (value === null) return 'null'; + if (Array.isArray(value)) return 'array'; + if (typeof value === 'number' && Number.isNaN(value)) return 'nan'; + return typeof value; +} + +/** + * Helper to create a standard validation error. + */ +function createError( + path: string, + expected: string, + receivedValue: any +): ValidationError { + const received = getType(receivedValue); + return { + path, + message: `Expected ${expected}, received ${received}`, + expected, + received, + }; +} diff --git a/packages/root-cms/core/values.ts b/packages/root-cms/core/values.ts new file mode 100644 index 000000000..f318797a9 --- /dev/null +++ b/packages/root-cms/core/values.ts @@ -0,0 +1,30 @@ +/** + * Sets a value at a JSON path in an object. + * Supports dot notation for nested objects and array indices. + * Examples: + * setValueAtPath(obj, 'title', 'New Title') + * setValueAtPath(obj, 'hero.title', 'New Title') + * setValueAtPath(obj, 'content.0.text', 'First item') + */ +export function setValueAtPath(obj: any, path: string, value: any): void { + const keys = path.split('.'); + let current = obj; + + for (let i = 0; i < keys.length - 1; i++) { + const key = keys[i]; + const nextKey = keys[i + 1]; + + // Check if next key is an array index. + const isNextKeyArrayIndex = /^\d+$/.test(nextKey); + + if (!(key in current)) { + // Create object or array based on next key. + current[key] = isNextKeyArrayIndex ? [] : {}; + } + + current = current[key]; + } + + const lastKey = keys[keys.length - 1]; + current[lastKey] = value; +} diff --git a/packages/root-cms/package.json b/packages/root-cms/package.json index 336333c0b..c099078de 100644 --- a/packages/root-cms/package.json +++ b/packages/root-cms/package.json @@ -85,8 +85,7 @@ "jsonwebtoken": "9.0.2", "kleur": "4.1.5", "sirv": "2.0.3", - "tiny-glob": "0.2.9", - "zod": "3.23.8" + "tiny-glob": "0.2.9" }, "//": "NOTE(stevenle): due to compat issues with mantine and preact, mantine is pinned to v4.2.12", "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index de16355ec..4785bb922 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -366,7 +366,7 @@ importers: version: 1.18.0(@google-cloud/firestore@7.11.3)(firebase-admin@13.5.0)(firebase@12.2.1)(genkit@1.18.0) '@genkit-ai/vertexai': specifier: 1.18.0 - version: 1.18.0(genkit@1.18.0)(zod@3.23.8) + version: 1.18.0(genkit@1.18.0) '@google-cloud/firestore': specifier: 7.11.3 version: 7.11.3 @@ -409,9 +409,6 @@ importers: tiny-glob: specifier: 0.2.9 version: 0.2.9 - zod: - specifier: 3.23.8 - version: 3.23.8 devDependencies: '@babel/core': specifier: 7.17.9 @@ -2570,7 +2567,7 @@ packages: dev: false optional: true - /@genkit-ai/vertexai@1.18.0(genkit@1.18.0)(zod@3.23.8): + /@genkit-ai/vertexai@1.18.0(genkit@1.18.0): resolution: {integrity: sha512-la6ykLKIryNZv1yXCi3aadVTxQEvbJKDV48S1DaNabLcPsrqNMm0tvriHNUpceWGiJf5yy6O7XgKJfZSfe65Yg==} peerDependencies: genkit: ^1.18.0 @@ -2579,12 +2576,12 @@ packages: '@anthropic-ai/vertex-sdk': 0.4.3 '@google-cloud/aiplatform': 3.35.0 '@google-cloud/vertexai': 1.10.0 - '@mistralai/mistralai-gcp': 1.5.0(zod@3.23.8) + '@mistralai/mistralai-gcp': 1.5.0 genkit: 1.18.0(@google-cloud/firestore@7.11.3)(firebase-admin@13.5.0)(firebase@12.2.1) google-auth-library: 9.15.1 googleapis: 140.0.1 node-fetch: 3.3.2 - openai: 4.104.0(zod@3.23.8) + openai: 4.104.0 optionalDependencies: '@google-cloud/bigquery': 7.9.4 firebase-admin: 13.5.0 @@ -3615,27 +3612,26 @@ packages: '@types/gapi.client.discovery-v1': 0.0.4 dev: true - /@maxim_mazurok/gapi.client.drive-v3@0.1.20251119: - resolution: {integrity: sha512-7vJHAPYcH5QwrXf7BBWHah3fQ8yUPSnW+LBjPu/W8OPoeNJNLnRB6u6ShqZkROsxxK16/Bo3UFvahM3htAsbWA==} + /@maxim_mazurok/gapi.client.drive-v3@0.1.20251201: + resolution: {integrity: sha512-qa9DQ7uOs4f1x3TimrkZbB+vyrYhbx0VMsFvCkAuuoyLCof5/XESCg/ump9W9+ST7blYcufXNc+iOUiJ4dMIlw==} dependencies: '@types/gapi.client': 1.0.8 '@types/gapi.client.discovery-v1': 0.0.4 dev: true - /@maxim_mazurok/gapi.client.sheets-v4@0.1.20251117: - resolution: {integrity: sha512-1RnPjMyFnCp23596bwEranbTCym37b4IxBGjkKINv3RbyJ6l2RYIOgt7iug+EWQZgkLg0aJ+KzIOARfNM9Q2+Q==} + /@maxim_mazurok/gapi.client.sheets-v4@0.1.20251203: + resolution: {integrity: sha512-RAEJbh4QGLTTtWxuzQKHtEU3qtbkDpWOTLcbqdAorCfQwHrcC2NCLyl00GpRRltdz0i6R9a+smKjL32rsWrvpQ==} dependencies: '@types/gapi.client': 1.0.8 '@types/gapi.client.discovery-v1': 0.0.4 dev: true - /@mistralai/mistralai-gcp@1.5.0(zod@3.23.8): + /@mistralai/mistralai-gcp@1.5.0: resolution: {integrity: sha512-KUv4GziIN8do4gmPe7T85gpYW1o2Q89e0hs8PQfZhFRMYz7uYPwxHyVI5UaxWlHFcmAvyxfaOAH0OuCC38Hb6g==} peerDependencies: zod: '>= 3' dependencies: google-auth-library: 9.15.1 - zod: 3.23.8 transitivePeerDependencies: - encoding - supports-color @@ -5591,13 +5587,13 @@ packages: /@types/gapi.client.drive-v3@0.0.4: resolution: {integrity: sha512-jE37dJ0EzAdY0aJPFOp20xmec/aO0P4HtUIA9k07RMPyedFDOcuMlSac1r0PklwQdgXF7BHaMoObNHNAnwSQUQ==} dependencies: - '@maxim_mazurok/gapi.client.drive-v3': 0.1.20251119 + '@maxim_mazurok/gapi.client.drive-v3': 0.1.20251201 dev: true /@types/gapi.client.sheets-v4@0.0.4: resolution: {integrity: sha512-6kTJ7aDMAElfdQV1XzVJmZWjgbibpa84DMuKuaN8Cwqci/dkglPyHXKvsGrRugmuYvgFYr35AQqwz6j3q8R0dw==} dependencies: - '@maxim_mazurok/gapi.client.sheets-v4': 0.1.20251117 + '@maxim_mazurok/gapi.client.sheets-v4': 0.1.20251203 dev: true /@types/gapi.client@1.0.8: @@ -12450,7 +12446,7 @@ packages: is-wsl: 2.2.0 dev: false - /openai@4.104.0(zod@3.23.8): + /openai@4.104.0: resolution: {integrity: sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA==} hasBin: true peerDependencies: @@ -12469,7 +12465,6 @@ packages: form-data-encoder: 1.7.2 formdata-node: 4.4.1 node-fetch: 2.7.0 - zod: 3.23.8 transitivePeerDependencies: - encoding dev: false @@ -16356,10 +16351,6 @@ packages: zod: 3.25.76 dev: true - /zod@3.23.8: - resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==} - dev: false - /zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}