From 656f2879f605a7c5fefcfa4ac90f57b79df5322f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Mar 2026 07:32:03 +0000 Subject: [PATCH 1/2] fix: resolve FQN/tableName mismatch for database schema sync and add MySQL database auto-creation The core issue was a naming mismatch between: - FQN in SchemaRegistry: sys__user (double underscore) - Physical table name from ObjectSchema.create(): sys_user (single underscore) - Table name expected by auth adapter (SystemObjectName): sys_user Changes: 1. SchemaRegistry.getObject() - Added fallback to match by tableName property 2. ObjectQL.resolveObjectName() - Returns tableName instead of FQN 3. ObjectQLPlugin.syncRegisteredSchemas() - Uses tableName for DDL operations 4. SqlDriver.ensureDatabaseExists() - Extended for MySQL support 5. SqlDriver.createDatabase() - Added MySQL-specific database creation Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> Agent-Logs-Url: https://github.com/objectstack-ai/spec/sessions/79aaa91e-29db-42ee-8a83-2041d708a8f7 --- packages/objectql/src/engine.ts | 5 +- .../objectql/src/plugin.integration.test.ts | 79 +++++++++++++++++++ packages/objectql/src/plugin.ts | 8 +- packages/objectql/src/registry.test.ts | 25 ++++++ packages/objectql/src/registry.ts | 19 ++++- .../driver-sql/src/sql-driver-schema.test.ts | 19 +++++ packages/plugins/driver-sql/src/sql-driver.ts | 50 +++++++++--- 7 files changed, 191 insertions(+), 14 deletions(-) diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index d40004d5d..eb8049d81 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -545,7 +545,10 @@ export class ObjectQL implements IDataEngine { private resolveObjectName(name: string): string { const schema = SchemaRegistry.getObject(name); if (schema) { - return schema.name; // FQN from registry (e.g., 'todo__task') + // Prefer the physical table name (e.g., 'sys_user') over the FQN + // (e.g., 'sys__user'). ObjectSchema.create() auto-derives tableName + // as {namespace}_{name} which matches the storage convention. + return (schema as any).tableName || schema.name; } return name; // Ad-hoc object, keep as-is } diff --git a/packages/objectql/src/plugin.integration.test.ts b/packages/objectql/src/plugin.integration.test.ts index 0f508037c..b18e8e2f4 100644 --- a/packages/objectql/src/plugin.integration.test.ts +++ b/packages/objectql/src/plugin.integration.test.ts @@ -468,5 +468,84 @@ describe('ObjectQLPlugin - Metadata Service Integration', () => { // Act & Assert - should not throw await expect(kernel.bootstrap()).resolves.not.toThrow(); }); + + it('should use tableName for syncSchema when objects have auto-derived tableName', async () => { + // Arrange - driver that tracks syncSchema calls + const synced: Array<{ object: string; schema: any }> = []; + const mockDriver = { + name: 'table-name-driver', + version: '1.0.0', + connect: async () => {}, + disconnect: async () => {}, + find: async () => [], + findOne: async () => null, + create: async (_o: string, d: any) => d, + update: async (_o: string, _i: any, d: any) => d, + delete: async () => true, + syncSchema: async (object: string, schema: any) => { + synced.push({ object, schema }); + }, + }; + + await kernel.use({ + name: 'mock-driver-plugin', + type: 'driver', + version: '1.0.0', + init: async (ctx) => { + ctx.registerService('driver.table-name', mockDriver); + }, + }); + + // Objects with tableName (simulating ObjectSchema.create() output) + const appManifest = { + id: 'com.test.system', + name: 'system', + namespace: 'sys', + version: '1.0.0', + objects: [ + { + name: 'user', + label: 'User', + namespace: 'sys', + tableName: 'sys_user', + fields: { + email: { name: 'email', label: 'Email', type: 'text' }, + }, + }, + { + name: 'session', + label: 'Session', + namespace: 'sys', + tableName: 'sys_session', + fields: { + token: { name: 'token', label: 'Token', type: 'text' }, + }, + }, + ], + }; + + await kernel.use({ + name: 'mock-app-plugin', + type: 'app', + version: '1.0.0', + init: async (ctx) => { + ctx.registerService('app.system', appManifest); + }, + }); + + const plugin = new ObjectQLPlugin(); + await kernel.use(plugin); + + // Act + await kernel.bootstrap(); + + // Assert - syncSchema should use tableName (single underscore) not FQN (double underscore) + const syncedNames = synced.map((s) => s.object).sort(); + expect(syncedNames).toContain('sys_user'); + expect(syncedNames).toContain('sys_session'); + // Should NOT contain double-underscore FQN + expect(syncedNames).not.toContain('sys__user'); + expect(syncedNames).not.toContain('sys__session'); + }); }); }); diff --git a/packages/objectql/src/plugin.ts b/packages/objectql/src/plugin.ts index f37923f37..de616ce66 100644 --- a/packages/objectql/src/plugin.ts +++ b/packages/objectql/src/plugin.ts @@ -274,12 +274,18 @@ export class ObjectQLPlugin implements Plugin { continue; } + // Use the physical table name (e.g., 'sys_user') for DDL operations + // instead of the FQN (e.g., 'sys__user'). ObjectSchema.create() + // auto-derives tableName as {namespace}_{name}. + const tableName = (obj as any).tableName || obj.name; + try { - await driver.syncSchema(obj.name, obj); + await driver.syncSchema(tableName, { ...obj, name: tableName }); synced++; } catch (e: unknown) { ctx.logger.warn('Failed to sync schema for object', { object: obj.name, + tableName, driver: driver.name, error: e instanceof Error ? e.message : String(e), }); diff --git a/packages/objectql/src/registry.test.ts b/packages/objectql/src/registry.test.ts index 7a260e62b..c4eb2811c 100644 --- a/packages/objectql/src/registry.test.ts +++ b/packages/objectql/src/registry.test.ts @@ -187,6 +187,31 @@ describe('SchemaRegistry', () => { expect(SchemaRegistry.getObject('task')).toBeDefined(); }); + it('should resolve by tableName (protocol name fallback)', () => { + // Simulates ObjectSchema.create() which auto-derives tableName + // as {namespace}_{name} (single underscore) + const obj = { name: 'user', tableName: 'sys_user', namespace: 'sys', fields: {} }; + SchemaRegistry.registerObject(obj as any, 'com.objectstack.system', 'sys', 'own'); + + // FQN is 'sys__user' (double underscore) + expect(SchemaRegistry.getObject('sys__user')).toBeDefined(); + + // Protocol name 'sys_user' (single underscore) should also resolve + const resolved = SchemaRegistry.getObject('sys_user'); + expect(resolved).toBeDefined(); + expect(resolved?.name).toBe('sys__user'); + expect((resolved as any).tableName).toBe('sys_user'); + }); + + it('should resolve by tableName for any namespace', () => { + const obj = { name: 'account', tableName: 'crm_account', namespace: 'crm', fields: {} }; + SchemaRegistry.registerObject(obj as any, 'com.crm', 'crm', 'own'); + + // FQN: 'crm__account', tableName: 'crm_account' + expect(SchemaRegistry.getObject('crm__account')).toBeDefined(); + expect(SchemaRegistry.getObject('crm_account')).toBeDefined(); + }); + it('should cache merged objects', () => { const obj = { name: 'cached', fields: {} }; SchemaRegistry.registerObject(obj as any, 'com.test', 'test', 'own'); diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index f2bb00698..d37e4d8e9 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -314,8 +314,14 @@ export class SchemaRegistry { } /** - * Get object by name (FQN or short name with fallback scan). - * For compatibility, tries exact match first, then scans for suffix match. + * Get object by name (FQN, short name, or physical table name). + * + * Resolution order: + * 1. Exact FQN match (e.g., 'crm__account') + * 2. Short name fallback (e.g., 'account' → 'crm__account') + * 3. Physical table name match (e.g., 'sys_user' → 'sys__user') + * ObjectSchema.create() auto-derives tableName as {namespace}_{name}, + * which uses a single underscore — different from the FQN double underscore. */ static getObject(name: string): ServiceObject | undefined { // Direct FQN lookup @@ -331,6 +337,15 @@ export class SchemaRegistry { } } + // Fallback: match by physical table name (e.g., 'sys_user' → FQN 'sys__user') + // This bridges the gap between protocol names (SystemObjectName) and FQN. + for (const fqn of this.objectContributors.keys()) { + const resolved = this.resolveObject(fqn); + if (resolved && (resolved as any).tableName === name) { + return resolved; + } + } + return undefined; } diff --git a/packages/plugins/driver-sql/src/sql-driver-schema.test.ts b/packages/plugins/driver-sql/src/sql-driver-schema.test.ts index 9fbea6a39..fad1e833a 100644 --- a/packages/plugins/driver-sql/src/sql-driver-schema.test.ts +++ b/packages/plugins/driver-sql/src/sql-driver-schema.test.ts @@ -243,4 +243,23 @@ describe('SqlDriver Schema Sync (SQLite)', () => { expect(row.email).toBe('test@example.com'); expect(row.office_loc).toEqual({ lat: 10, lng: 20 }); }); + + it('should skip ensureDatabaseExists for SQLite (no-op)', async () => { + // SQLite auto-creates database files, so ensureDatabaseExists should be a no-op + // This test verifies that initObjects works normally for SQLite without errors + const objects = [ + { + name: 'db_check_test', + fields: { + value: { type: 'string' }, + }, + }, + ]; + + // Should not throw — SQLite skips ensureDatabaseExists + await driver.initObjects(objects); + + const exists = await knexInstance.schema.hasTable('db_check_test'); + expect(exists).toBe(true); + }); }); diff --git a/packages/plugins/driver-sql/src/sql-driver.ts b/packages/plugins/driver-sql/src/sql-driver.ts index bc2410726..423e6f6bb 100644 --- a/packages/plugins/driver-sql/src/sql-driver.ts +++ b/packages/plugins/driver-sql/src/sql-driver.ts @@ -995,12 +995,22 @@ export class SqlDriver implements IDataDriver { // ── Database helpers ──────────────────────────────────────────────────────── protected async ensureDatabaseExists() { - if (!this.isPostgres) return; + // SQLite auto-creates database files — no need to check + if (this.isSqlite) return; + + // Only PostgreSQL and MySQL support programmatic database creation + if (!this.isPostgres && !this.isMysql) return; try { await this.knex.raw('SELECT 1'); } catch (e: any) { - if (e.code === '3D000') { + // PostgreSQL: '3D000' = database does not exist + // MySQL: 'ER_BAD_DB_ERROR' (errno 1049) = unknown database + if ( + e.code === '3D000' || + e.code === 'ER_BAD_DB_ERROR' || + e.errno === 1049 + ) { await this.createDatabase(); } else { throw e; @@ -1014,19 +1024,39 @@ export class SqlDriver implements IDataDriver { let dbName = ''; const adminConfig = { ...config }; - if (typeof connection === 'string') { - const url = new URL(connection); - dbName = url.pathname.slice(1); - url.pathname = '/postgres'; - adminConfig.connection = url.toString(); + if (this.isPostgres) { + // PostgreSQL: connect to the 'postgres' maintenance database + if (typeof connection === 'string') { + const url = new URL(connection); + dbName = url.pathname.slice(1); + url.pathname = '/postgres'; + adminConfig.connection = url.toString(); + } else { + dbName = connection.database; + adminConfig.connection = { ...connection, database: 'postgres' }; + } + } else if (this.isMysql) { + // MySQL: connect without specifying a database + if (typeof connection === 'string') { + const url = new URL(connection); + dbName = url.pathname.slice(1); + url.pathname = '/'; + adminConfig.connection = url.toString(); + } else { + dbName = connection.database; + adminConfig.connection = { ...connection, database: undefined }; + } } else { - dbName = connection.database; - adminConfig.connection = { ...connection, database: 'postgres' }; + return; // Unsupported dialect for auto-creation } const adminKnex = knex(adminConfig); try { - await adminKnex.raw(`CREATE DATABASE "${dbName}"`); + if (this.isPostgres) { + await adminKnex.raw(`CREATE DATABASE "${dbName}"`); + } else if (this.isMysql) { + await adminKnex.raw(`CREATE DATABASE IF NOT EXISTS \`${dbName}\``); + } } finally { await adminKnex.destroy(); } From 2c55849f4e945dfc831fe5d50af6b0eb20d2444e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Mar 2026 07:51:05 +0000 Subject: [PATCH 2/2] refactor: address code review feedback - remove type assertions, fix MySQL config handling - Removed unnecessary `(... as any)` type casts since ServiceObject already includes tableName - Fixed syncRegisteredSchemas to pass original obj instead of spreading with name override - Fixed MySQL createDatabase to use destructuring instead of setting database: undefined Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> Agent-Logs-Url: https://github.com/objectstack-ai/spec/sessions/79aaa91e-29db-42ee-8a83-2041d708a8f7 --- CHANGELOG.md | 20 +++++++++++++++++++ packages/objectql/src/engine.ts | 2 +- packages/objectql/src/plugin.ts | 4 ++-- packages/objectql/src/registry.ts | 2 +- packages/plugins/driver-sql/src/sql-driver.ts | 3 ++- 5 files changed, 26 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 69e4299c6..e63d74901 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed +- **Login API error — database tables not created** — Fixed a critical naming mismatch between + the FQN (Fully Qualified Name) used by SchemaRegistry (e.g., `sys__user` with double underscore) + and the physical table name derived by `ObjectSchema.create()` (e.g., `sys_user` with single + underscore). `syncRegisteredSchemas()` now uses the `tableName` property from object definitions + for DDL operations, ensuring tables are created with the correct physical name that matches + what the auth adapter and `SystemObjectName` constants expect. +- **`SchemaRegistry.getObject()` — protocol name resolution** — Added a third fallback that + matches objects by their `tableName` property (e.g., `getObject('sys_user')` now correctly + finds the object registered as FQN `sys__user`). This bridges protocol-layer names + (`SystemObjectName.USER = 'sys_user'`) with the registry's FQN naming convention. +- **`ObjectQL.resolveObjectName()` — physical table name** — Now returns `schema.tableName` + (the physical table/collection name) instead of `schema.name` (the FQN) when available, + ensuring driver SQL queries target the correct table. +- **`SqlDriver.ensureDatabaseExists()` — multi-driver support** — Extended database + auto-creation to support MySQL (error code `ER_BAD_DB_ERROR` / errno 1049) alongside + PostgreSQL (error code `3D000`). SQLite is explicitly skipped (auto-creates files). +- **`SqlDriver.createDatabase()` — MySQL support** — Added MySQL-specific logic that + connects without a database specified and uses `CREATE DATABASE IF NOT EXISTS`. + ### Added - **`@objectstack/driver-turso` plugin** — Migrated and standardized the Turso/libSQL driver from `@objectql/driver-turso` into `packages/plugins/driver-turso/`. The driver **extends** `SqlDriver` diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index eb8049d81..8fb597872 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -548,7 +548,7 @@ export class ObjectQL implements IDataEngine { // Prefer the physical table name (e.g., 'sys_user') over the FQN // (e.g., 'sys__user'). ObjectSchema.create() auto-derives tableName // as {namespace}_{name} which matches the storage convention. - return (schema as any).tableName || schema.name; + return schema.tableName || schema.name; } return name; // Ad-hoc object, keep as-is } diff --git a/packages/objectql/src/plugin.ts b/packages/objectql/src/plugin.ts index de616ce66..4442852e4 100644 --- a/packages/objectql/src/plugin.ts +++ b/packages/objectql/src/plugin.ts @@ -277,10 +277,10 @@ export class ObjectQLPlugin implements Plugin { // Use the physical table name (e.g., 'sys_user') for DDL operations // instead of the FQN (e.g., 'sys__user'). ObjectSchema.create() // auto-derives tableName as {namespace}_{name}. - const tableName = (obj as any).tableName || obj.name; + const tableName = obj.tableName || obj.name; try { - await driver.syncSchema(tableName, { ...obj, name: tableName }); + await driver.syncSchema(tableName, obj); synced++; } catch (e: unknown) { ctx.logger.warn('Failed to sync schema for object', { diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index d37e4d8e9..0a5e75e9a 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -341,7 +341,7 @@ export class SchemaRegistry { // This bridges the gap between protocol names (SystemObjectName) and FQN. for (const fqn of this.objectContributors.keys()) { const resolved = this.resolveObject(fqn); - if (resolved && (resolved as any).tableName === name) { + if (resolved?.tableName === name) { return resolved; } } diff --git a/packages/plugins/driver-sql/src/sql-driver.ts b/packages/plugins/driver-sql/src/sql-driver.ts index 423e6f6bb..084822707 100644 --- a/packages/plugins/driver-sql/src/sql-driver.ts +++ b/packages/plugins/driver-sql/src/sql-driver.ts @@ -1044,7 +1044,8 @@ export class SqlDriver implements IDataDriver { adminConfig.connection = url.toString(); } else { dbName = connection.database; - adminConfig.connection = { ...connection, database: undefined }; + const { database: _db, ...rest } = connection; + adminConfig.connection = rest; } } else { return; // Unsupported dialect for auto-creation