From d27821d5f83116700c4ae7d4dfe5b025953057fd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Feb 2026 02:00:01 +0000 Subject: [PATCH 1/6] Initial plan From 28c2ae6b4a12ea651739dcdf3d3a96e68120ddc9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Feb 2026 02:04:03 +0000 Subject: [PATCH 2/6] fix: guard import.meta.glob with runtime check for Node.js compatibility In core/project.ts, wrap the import.meta.glob() call with a typeof check so that SCHEMA_MODULES defaults to an empty object when running outside of Vite (e.g., in Node.js scripts, CLI tools). Also add a null check for the module lookup in getCollectionSchema() to prevent TypeError when the schema module is not found. Co-authored-by: jeremydw <646525+jeremydw@users.noreply.github.com> --- packages/root-cms/core/project.ts | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/packages/root-cms/core/project.ts b/packages/root-cms/core/project.ts index c41501e9..94c816f9 100644 --- a/packages/root-cms/core/project.ts +++ b/packages/root-cms/core/project.ts @@ -1,8 +1,10 @@ /** * Loads various files or configurations from the project. * - * NOTE: This file needs to be loaded through vite's ssrLoadModule so that - * `import.meta.glob()` calls are resolved. + * NOTE: When loaded through Vite's ssrLoadModule, `import.meta.glob()` calls + * are resolved. In Node.js environments (e.g. scripts, CLI tools), + * `import.meta.glob` is not available and SCHEMA_MODULES defaults to an empty + * object. */ import * as schema from './schema.js'; @@ -11,15 +13,18 @@ export interface SchemaModule { default: schema.Schema; } -export const SCHEMA_MODULES = import.meta.glob( - [ - '/**/*.schema.ts', - '!/appengine/**/*.schema.ts', - '!/functions/**/*.schema.ts', - '!/gae/**/*.schema.ts', - ], - {eager: true} -); +export const SCHEMA_MODULES: Record = + typeof import.meta.glob === 'function' + ? import.meta.glob( + [ + '/**/*.schema.ts', + '!/appengine/**/*.schema.ts', + '!/functions/**/*.schema.ts', + '!/gae/**/*.schema.ts', + ], + {eager: true} + ) + : {}; /** * Returns a map of all `schema.ts` files defined in the project as @@ -79,7 +84,7 @@ export function getCollectionSchema( const fileId = `/collections/${collectionId}.schema.ts`; const module = SCHEMA_MODULES[fileId]; - if (!module.default) { + if (!module || !module.default) { console.warn(`collection schema not exported in: ${fileId}`); return null; } From 89de079aede81e316a9acab55bf42654f3911ed4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Feb 2026 02:11:30 +0000 Subject: [PATCH 3/6] feat: add Node.js filesystem fallback for schema loading in getCollection When import.meta.glob is unavailable (Node.js scripts, CLI tools), getCollection() now falls back to directly importing the schema file from disk using rootConfig.rootDir. This enables saveDraftData() with validate: true to work outside of Vite environments. Co-authored-by: jeremydw <646525+jeremydw@users.noreply.github.com> --- packages/root-cms/core/client.test.ts | 16 +++++++++++++++ packages/root-cms/core/client.ts | 29 ++++++++++++++++++++++++++- 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/packages/root-cms/core/client.test.ts b/packages/root-cms/core/client.test.ts index 892938a4..ff684523 100644 --- a/packages/root-cms/core/client.test.ts +++ b/packages/root-cms/core/client.test.ts @@ -102,6 +102,22 @@ describe('RootCMSClient Validation', () => { expect(result).toBeNull(); }); + + it('falls back to filesystem import when getCollectionSchema returns null', async () => { + const {RootCMSClient} = await import('./client.js'); + const client = new RootCMSClient(mockRootConfig); + + // Simulate Node.js environment where import.meta.glob is unavailable, + // so getCollectionSchema returns null. + mockGetCollectionSchema.mockResolvedValue(null); + + // rootDir is '/test' which doesn't have real schema files, so the + // fallback import should also fail gracefully and return null. + const result = await client.getCollection('Pages'); + + expect(mockGetCollectionSchema).toHaveBeenCalledWith('Pages'); + expect(result).toBeNull(); + }); }); describe('saveDraftData with validation', () => { diff --git a/packages/root-cms/core/client.ts b/packages/root-cms/core/client.ts index cdb619df..ca2a773f 100644 --- a/packages/root-cms/core/client.ts +++ b/packages/root-cms/core/client.ts @@ -1,4 +1,6 @@ import crypto from 'node:crypto'; +import path from 'node:path'; +import {pathToFileURL} from 'node:url'; import {type Plugin, type RootConfig} from '@blinkk/root'; import {App} from 'firebase-admin/app'; import { @@ -325,7 +327,32 @@ export class RootCMSClient { // 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); + const result = await project.getCollectionSchema(collectionId); + if (result) { + return result; + } + + // Fallback for Node.js environments where import.meta.glob is not + // available (e.g., CLI tools, migration scripts). Directly import + // the schema file from disk using the rootDir from the config. + const rootDir = this.rootConfig.rootDir; + if (rootDir) { + const schemaPath = path.resolve( + rootDir, + `collections/${collectionId}.schema.ts` + ); + try { + const mod = await import(pathToFileURL(schemaPath).href); + if (mod.default) { + const collection = mod.default as Collection; + collection.id = collectionId; + return collection; + } + } catch (e) { + // Schema file not found or failed to load. + } + } + return null; } /** From 107e0276931d40c0b5c67a0c458fa89eeb88d12b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Feb 2026 02:13:39 +0000 Subject: [PATCH 4/6] refine: add existsSync check and console.warn for schema fallback Check if the schema file exists before attempting dynamic import to avoid noisy errors. Add console.warn when a schema file exists but fails to load, aiding in debugging. Co-authored-by: jeremydw <646525+jeremydw@users.noreply.github.com> --- packages/root-cms/core/client.ts | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/packages/root-cms/core/client.ts b/packages/root-cms/core/client.ts index ca2a773f..9580c69e 100644 --- a/packages/root-cms/core/client.ts +++ b/packages/root-cms/core/client.ts @@ -1,4 +1,5 @@ import crypto from 'node:crypto'; +import {existsSync} from 'node:fs'; import path from 'node:path'; import {pathToFileURL} from 'node:url'; import {type Plugin, type RootConfig} from '@blinkk/root'; @@ -341,15 +342,18 @@ export class RootCMSClient { rootDir, `collections/${collectionId}.schema.ts` ); - try { - const mod = await import(pathToFileURL(schemaPath).href); - if (mod.default) { - const collection = mod.default as Collection; - collection.id = collectionId; - return collection; + if (existsSync(schemaPath)) { + try { + const mod = await import(pathToFileURL(schemaPath).href); + if (mod.default) { + const collection = mod.default as Collection; + collection.id = collectionId; + return collection; + } + } catch (e) { + // Schema file exists but failed to load. + console.warn(`failed to load schema from ${schemaPath}:`, e); } - } catch (e) { - // Schema file not found or failed to load. } } return null; From 44ad5ebbf002c3c0d8bbb1b82979b18a164656de Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Feb 2026 04:10:17 +0000 Subject: [PATCH 5/6] refactor: move filesystem fallback from client.ts into getCollectionSchema Move the Node.js filesystem fallback for schema loading from client.ts:getCollection() into project.ts:getCollectionSchema(). The function now accepts an optional {rootDir} parameter and falls back to importing schema files from disk when import.meta.glob is unavailable. - project.ts: getCollectionSchema() is now async, accepts optional {rootDir} for filesystem fallback - client.ts: getCollection() passes rootDir to getCollectionSchema(), removed filesystem fallback code and unused imports - client.test.ts: Updated mock assertions to verify rootDir is passed - project.integration.test.ts: Added tests for getCollectionSchema in both Vite (SCHEMA_MODULES) and non-Vite (filesystem) environments - testdata/collections/TestPages.schema.ts: Test fixture for filesystem fallback integration test Co-authored-by: jeremydw <646525+jeremydw@users.noreply.github.com> --- packages/root-cms/core/client.test.ts | 16 ++--- packages/root-cms/core/client.ts | 35 +---------- .../root-cms/core/project.integration.test.ts | 50 ++++++++++++++++ packages/root-cms/core/project.ts | 59 +++++++++++++++---- .../testdata/collections/TestPages.schema.ts | 15 +++++ 5 files changed, 124 insertions(+), 51 deletions(-) create mode 100644 packages/root-cms/core/testdata/collections/TestPages.schema.ts diff --git a/packages/root-cms/core/client.test.ts b/packages/root-cms/core/client.test.ts index ff684523..3cf40f89 100644 --- a/packages/root-cms/core/client.test.ts +++ b/packages/root-cms/core/client.test.ts @@ -88,7 +88,9 @@ describe('RootCMSClient Validation', () => { const result = await client.getCollection('TestCollection'); - expect(mockGetCollectionSchema).toHaveBeenCalledWith('TestCollection'); + expect(mockGetCollectionSchema).toHaveBeenCalledWith('TestCollection', { + rootDir: '/test', + }); expect(result).toEqual(testSchema); }); @@ -103,19 +105,19 @@ describe('RootCMSClient Validation', () => { expect(result).toBeNull(); }); - it('falls back to filesystem import when getCollectionSchema returns null', async () => { + it('passes rootDir to getCollectionSchema for filesystem fallback', async () => { const {RootCMSClient} = await import('./client.js'); const client = new RootCMSClient(mockRootConfig); - // Simulate Node.js environment where import.meta.glob is unavailable, - // so getCollectionSchema returns null. mockGetCollectionSchema.mockResolvedValue(null); - // rootDir is '/test' which doesn't have real schema files, so the - // fallback import should also fail gracefully and return null. const result = await client.getCollection('Pages'); - expect(mockGetCollectionSchema).toHaveBeenCalledWith('Pages'); + // Verify rootDir is passed so getCollectionSchema can use the + // filesystem fallback in non-Vite environments. + expect(mockGetCollectionSchema).toHaveBeenCalledWith('Pages', { + rootDir: '/test', + }); expect(result).toBeNull(); }); }); diff --git a/packages/root-cms/core/client.ts b/packages/root-cms/core/client.ts index 9580c69e..ef4b2769 100644 --- a/packages/root-cms/core/client.ts +++ b/packages/root-cms/core/client.ts @@ -1,7 +1,4 @@ import crypto from 'node:crypto'; -import {existsSync} from 'node:fs'; -import path from 'node:path'; -import {pathToFileURL} from 'node:url'; import {type Plugin, type RootConfig} from '@blinkk/root'; import {App} from 'firebase-admin/app'; import { @@ -328,35 +325,9 @@ export class RootCMSClient { // 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'); - const result = await project.getCollectionSchema(collectionId); - if (result) { - return result; - } - - // Fallback for Node.js environments where import.meta.glob is not - // available (e.g., CLI tools, migration scripts). Directly import - // the schema file from disk using the rootDir from the config. - const rootDir = this.rootConfig.rootDir; - if (rootDir) { - const schemaPath = path.resolve( - rootDir, - `collections/${collectionId}.schema.ts` - ); - if (existsSync(schemaPath)) { - try { - const mod = await import(pathToFileURL(schemaPath).href); - if (mod.default) { - const collection = mod.default as Collection; - collection.id = collectionId; - return collection; - } - } catch (e) { - // Schema file exists but failed to load. - console.warn(`failed to load schema from ${schemaPath}:`, e); - } - } - } - return null; + return await project.getCollectionSchema(collectionId, { + rootDir: this.rootConfig.rootDir, + }); } /** diff --git a/packages/root-cms/core/project.integration.test.ts b/packages/root-cms/core/project.integration.test.ts index 4eaf056c..14b31ccd 100644 --- a/packages/root-cms/core/project.integration.test.ts +++ b/packages/root-cms/core/project.integration.test.ts @@ -6,9 +6,13 @@ * circular dependency issues when schema files import from '@blinkk/root-cms'. */ +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; import {describe, it, expect} from 'vitest'; import * as schema from './schema.js'; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + describe('Production Bundle Integration', () => { it('should export schema namespace without circular dependency errors', async () => { // Import the core module (which is the main entry point) @@ -98,6 +102,52 @@ describe('Production Bundle Integration', () => { }); }); +describe('getCollectionSchema', () => { + it('returns null when schema not found in SCHEMA_MODULES and no rootDir', async () => { + const projectModule = await import('./project.js'); + + // Without rootDir, should return null for non-existent collection. + const result = await projectModule.getCollectionSchema('NonExistent'); + expect(result).toBeNull(); + }); + + it('returns null when schema not found and rootDir has no matching file', async () => { + const projectModule = await import('./project.js'); + + const result = await projectModule.getCollectionSchema('NonExistent', { + rootDir: '/nonexistent/path', + }); + expect(result).toBeNull(); + }); + + it('loads schema from filesystem when rootDir is provided', async () => { + const projectModule = await import('./project.js'); + + // Use the testdata directory as rootDir. + const testRootDir = path.resolve(__dirname, 'testdata'); + const result = await projectModule.getCollectionSchema('TestPages', { + rootDir: testRootDir, + }); + + expect(result).not.toBeNull(); + expect(result!.name).toBe('TestPages'); + expect(result!.id).toBe('TestPages'); + expect(result!.fields).toHaveLength(2); + expect(result!.fields[0].id).toBe('title'); + expect(result!.fields[0].type).toBe('string'); + expect(result!.fields[1].id).toBe('count'); + expect(result!.fields[1].type).toBe('number'); + }); + + it('throws error for invalid collection id', async () => { + const projectModule = await import('./project.js'); + + await expect( + projectModule.getCollectionSchema('invalid/id') + ).rejects.toThrow('invalid collection id'); + }); +}); + describe('SchemaPattern Resolution', () => { it('should export glob function from core module', async () => { const coreModule = await import('./core.js'); diff --git a/packages/root-cms/core/project.ts b/packages/root-cms/core/project.ts index 94c816f9..9a14fa5a 100644 --- a/packages/root-cms/core/project.ts +++ b/packages/root-cms/core/project.ts @@ -4,9 +4,13 @@ * NOTE: When loaded through Vite's ssrLoadModule, `import.meta.glob()` calls * are resolved. In Node.js environments (e.g. scripts, CLI tools), * `import.meta.glob` is not available and SCHEMA_MODULES defaults to an empty - * object. + * object. In that case, `getCollectionSchema()` falls back to importing the + * schema file directly from disk using the provided `rootDir`. */ +import {existsSync} from 'node:fs'; +import path from 'node:path'; +import {pathToFileURL} from 'node:url'; import * as schema from './schema.js'; export interface SchemaModule { @@ -74,26 +78,57 @@ export function resolveOneOfPatterns(schemaObj: schema.Schema): schema.Schema { /** * Returns a collection's schema definition as defined in * `/collections/.schema.ts`. + * + * In Vite environments, schemas are loaded from `SCHEMA_MODULES` (populated + * by `import.meta.glob`). In Node.js environments, if `rootDir` is provided, + * falls back to importing the schema file directly from disk. */ -export function getCollectionSchema( - collectionId: string -): schema.Collection | null { +export async function getCollectionSchema( + collectionId: string, + options?: {rootDir?: string} +): Promise { if (!testValidCollectionId(collectionId)) { throw new Error(`invalid collection id: ${collectionId}`); } const fileId = `/collections/${collectionId}.schema.ts`; const module = SCHEMA_MODULES[fileId]; - if (!module || !module.default) { - console.warn(`collection schema not exported in: ${fileId}`); - return null; + if (module && module.default) { + const collection = module.default as schema.Collection; + collection.id = collectionId; + return convertOneOfTypes(collection); } - const collection = module.default as schema.Collection; - collection.id = collectionId; - // Convert `schema.oneOf()` object types to an array of strings and move the - // type schema to `collection.types`. - return convertOneOfTypes(collection); + // Fallback for Node.js environments where import.meta.glob is not + // available (e.g., CLI tools, migration scripts). Directly import + // the schema file from disk using the provided rootDir. + const rootDir = options?.rootDir; + if (rootDir) { + const schemaPath = path.resolve( + rootDir, + `collections/${collectionId}.schema.ts` + ); + if (existsSync(schemaPath)) { + try { + const mod = await import(pathToFileURL(schemaPath).href); + if (mod.default) { + const collection = mod.default as schema.Collection; + collection.id = collectionId; + return collection; + } + } catch (e) { + // Schema file exists but failed to load. + console.warn(`failed to load schema from ${schemaPath}:`, e); + } + } + } + + if (!module) { + console.warn(`collection schema not found: ${fileId}`); + } else { + console.warn(`collection schema not exported in: ${fileId}`); + } + return null; } function testValidCollectionId(id: string): boolean { diff --git a/packages/root-cms/core/testdata/collections/TestPages.schema.ts b/packages/root-cms/core/testdata/collections/TestPages.schema.ts new file mode 100644 index 00000000..98b312f5 --- /dev/null +++ b/packages/root-cms/core/testdata/collections/TestPages.schema.ts @@ -0,0 +1,15 @@ +/** + * Test schema fixture for integration tests. + * Uses relative imports to avoid dependency on the built package. + */ +import {collection, string, number} from '../../schema.js'; + +export default collection({ + name: 'TestPages', + description: 'Test pages for integration tests.', + url: '/test/[slug]', + fields: [ + string({id: 'title', label: 'Title'}), + number({id: 'count', label: 'Count'}), + ], +}); From 7f1d397d6c9c4fd24308359018751e39c901c084 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Feb 2026 04:12:13 +0000 Subject: [PATCH 6/6] fix: apply convertOneOfTypes to filesystem-loaded schemas Ensure schemas loaded via the filesystem fallback path are processed through convertOneOfTypes(), matching the behavior of schemas loaded from SCHEMA_MODULES. Co-authored-by: jeremydw <646525+jeremydw@users.noreply.github.com> --- packages/root-cms/core/project.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/root-cms/core/project.ts b/packages/root-cms/core/project.ts index 9a14fa5a..3271b46e 100644 --- a/packages/root-cms/core/project.ts +++ b/packages/root-cms/core/project.ts @@ -114,7 +114,7 @@ export async function getCollectionSchema( if (mod.default) { const collection = mod.default as schema.Collection; collection.id = collectionId; - return collection; + return convertOneOfTypes(collection); } } catch (e) { // Schema file exists but failed to load.