From d3119caa1c0669f589c4994e3fe8ceb953900e46 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Mar 2026 06:06:47 +0000 Subject: [PATCH 1/3] Initial plan From 5e5482c886275a1327257ce2c1d5062e6d71eaad Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Mar 2026 06:16:03 +0000 Subject: [PATCH 2/3] feat(objectql): auto-sync registered object schemas to database on startup ObjectQLPlugin.start() now iterates all objects in SchemaRegistry after driver.connect() and calls driver.syncSchema() for each one. This ensures plugin-registered objects (e.g. sys_user from plugin-auth) have their database tables created/updated automatically. - Add syncRegisteredSchemas() to ObjectQLPlugin with per-object error tolerance - Add getDriverForObject() public method to ObjectQL engine - Add optional syncSchema to DriverInterface (aligns with IDataDriver) - Add integration tests for sync behavior, error tolerance, and edge cases Closes #940 Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> Agent-Logs-Url: https://github.com/objectstack-ai/spec/sessions/a6a76ea8-5b90-40cb-88c8-26a9cb27cc6b --- packages/objectql/CHANGELOG.md | 3 + packages/objectql/src/engine.ts | 18 ++ .../objectql/src/plugin.integration.test.ts | 232 ++++++++++++++++++ packages/objectql/src/plugin.ts | 60 +++++ packages/spec/src/contracts/data-engine.ts | 8 + 5 files changed, 321 insertions(+) diff --git a/packages/objectql/CHANGELOG.md b/packages/objectql/CHANGELOG.md index 21b3c4681b..f7a3d1decc 100644 --- a/packages/objectql/CHANGELOG.md +++ b/packages/objectql/CHANGELOG.md @@ -4,6 +4,9 @@ ### Patch Changes +- Auto-sync all registered object schemas to database on startup: `ObjectQLPlugin.start()` now iterates every object in `SchemaRegistry` and calls `driver.syncSchema()` after driver connections are established. This ensures tables for plugin-registered objects (e.g. `sys_user` from plugin-auth) are created or updated automatically. +- Added `getDriverForObject(objectName)` public method to `ObjectQL` engine for resolving the responsible driver for a given object. +- Added optional `syncSchema` method to `DriverInterface` contract, aligning it with the full `IDataDriver` protocol. - @objectstack/spec@3.2.8 - @objectstack/core@3.2.8 - @objectstack/types@3.2.8 diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 72063fe873..437a6db941 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -1164,6 +1164,24 @@ export class ObjectQL implements IDataEngine { return this.drivers.get(name); } + /** + * Get the driver responsible for the given object. + * + * Resolves datasource binding from the object's schema definition, + * falling back to the default driver. This is a public version of + * the internal getDriver() used by CRUD operations. + * + * @param objectName - FQN or short name of the registered object. + * @returns The resolved DriverInterface, or undefined if no driver is available. + */ + getDriverForObject(objectName: string): DriverInterface | undefined { + try { + return this.getDriver(objectName); + } catch { + return undefined; + } + } + /** * Get a registered driver by datasource name. * Alias matching @objectql/core datasource() API. diff --git a/packages/objectql/src/plugin.integration.test.ts b/packages/objectql/src/plugin.integration.test.ts index 857cad50e7..0f508037cc 100644 --- a/packages/objectql/src/plugin.integration.test.ts +++ b/packages/objectql/src/plugin.integration.test.ts @@ -3,12 +3,14 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { ObjectKernel } from '@objectstack/core'; import { ObjectQLPlugin } from '../src/plugin'; +import { SchemaRegistry } from '../src/registry'; import { ObjectSchema } from '@objectstack/spec/data'; describe('ObjectQLPlugin - Metadata Service Integration', () => { let kernel: ObjectKernel; beforeEach(() => { + SchemaRegistry.reset(); kernel = new ObjectKernel({ logLevel: 'silent' }); }); @@ -237,4 +239,234 @@ describe('ObjectQLPlugin - Metadata Service Integration', () => { // We can verify by checking if ObjectQL detected external service }); }); + + describe('Schema Sync on Start', () => { + it('should call syncSchema for each registered object after init', async () => { + // Arrange - driver that tracks syncSchema calls + const synced: Array<{ object: string; schema: any }> = []; + const mockDriver = { + name: 'sync-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 }); + }, + }; + + // Plugin that registers objects and a driver + await kernel.use({ + name: 'mock-driver-plugin', + type: 'driver', + version: '1.0.0', + init: async (ctx) => { + ctx.registerService('driver.sync', mockDriver); + }, + }); + + const appManifest = { + id: 'com.test.auth', + name: 'auth', + namespace: 'sys', + version: '1.0.0', + objects: [ + { + name: 'user', + label: 'User', + fields: { + name: { name: 'name', label: 'Name', type: 'text' }, + }, + }, + { + name: 'role', + label: 'Role', + fields: { + title: { name: 'title', label: 'Title', type: 'text' }, + }, + }, + ], + }; + + await kernel.use({ + name: 'mock-app-plugin', + type: 'app', + version: '1.0.0', + init: async (ctx) => { + ctx.registerService('app.auth', appManifest); + }, + }); + + const plugin = new ObjectQLPlugin(); + await kernel.use(plugin); + + // Act + await kernel.bootstrap(); + + // Assert - syncSchema should have been called for each object + const syncedObjects = synced.map((s) => s.object).sort(); + expect(syncedObjects).toContain('sys__user'); + expect(syncedObjects).toContain('sys__role'); + expect(synced.length).toBeGreaterThanOrEqual(2); + }); + + it('should tolerate drivers without syncSchema', async () => { + // Arrange - driver without syncSchema + const mockDriver = { + name: 'no-sync-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, + // No syncSchema method + }; + + await kernel.use({ + name: 'mock-driver-plugin', + type: 'driver', + version: '1.0.0', + init: async (ctx) => { + ctx.registerService('driver.nosync', mockDriver); + }, + }); + + const appManifest = { + id: 'com.test.simple', + name: 'simple', + namespace: 'test', + version: '1.0.0', + objects: [ + { + name: 'item', + label: 'Item', + fields: { + title: { name: 'title', label: 'Title', type: 'text' }, + }, + }, + ], + }; + + await kernel.use({ + name: 'mock-app-plugin', + type: 'app', + version: '1.0.0', + init: async (ctx) => { + ctx.registerService('app.simple', appManifest); + }, + }); + + const plugin = new ObjectQLPlugin(); + await kernel.use(plugin); + + // Act & Assert - should not throw + await expect(kernel.bootstrap()).resolves.not.toThrow(); + }); + + it('should tolerate syncSchema failures per object without aborting', async () => { + // Arrange - driver where syncSchema fails for one object + const synced: string[] = []; + const mockDriver = { + name: 'fail-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) => { + if (object.includes('bad')) { + throw new Error('sync failed for bad object'); + } + synced.push(object); + }, + }; + + await kernel.use({ + name: 'mock-driver-plugin', + type: 'driver', + version: '1.0.0', + init: async (ctx) => { + ctx.registerService('driver.fail', mockDriver); + }, + }); + + const appManifest = { + id: 'com.test.mixed', + name: 'mixed', + namespace: 'mix', + version: '1.0.0', + objects: [ + { + name: 'good', + label: 'Good', + fields: { a: { name: 'a', label: 'A', type: 'text' } }, + }, + { + name: 'bad', + label: 'Bad', + fields: { b: { name: 'b', label: 'B', type: 'text' } }, + }, + ], + }; + + await kernel.use({ + name: 'mock-app-plugin', + type: 'app', + version: '1.0.0', + init: async (ctx) => { + ctx.registerService('app.mixed', appManifest); + }, + }); + + const plugin = new ObjectQLPlugin(); + await kernel.use(plugin); + + // Act - should not throw despite one object failing + await expect(kernel.bootstrap()).resolves.not.toThrow(); + + // Assert - the good object should still have been synced + expect(synced).toContain('mix__good'); + }); + + it('should work without any registered objects', async () => { + // Arrange - no objects, just a driver + const mockDriver = { + name: 'empty-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 () => {}, + }; + + await kernel.use({ + name: 'mock-driver-plugin', + type: 'driver', + version: '1.0.0', + init: async (ctx) => { + ctx.registerService('driver.empty', mockDriver); + }, + }); + + const plugin = new ObjectQLPlugin(); + await kernel.use(plugin); + + // Act & Assert - should not throw + await expect(kernel.bootstrap()).resolves.not.toThrow(); + }); + }); }); diff --git a/packages/objectql/src/plugin.ts b/packages/objectql/src/plugin.ts index e3ec30653a..045e899b9b 100644 --- a/packages/objectql/src/plugin.ts +++ b/packages/objectql/src/plugin.ts @@ -119,6 +119,11 @@ export class ObjectQLPlugin implements Plugin { // Initialize drivers (calls driver.connect() which sets up persistence) await this.ql?.init(); + // Sync all registered object schemas to database + // This ensures tables/collections are created or updated for every + // object registered by plugins (e.g., sys_user from plugin-auth). + await this.syncRegisteredSchemas(ctx); + // Register built-in audit hooks this.registerAuditHooks(ctx); @@ -231,6 +236,61 @@ export class ObjectQLPlugin implements Plugin { ctx.logger.debug('Tenant isolation middleware registered'); } + /** + * Synchronize all registered object schemas to the database. + * + * Iterates every object in the SchemaRegistry and calls the + * responsible driver's `syncSchema()` for each one. This is + * idempotent — drivers must tolerate repeated calls without + * duplicating tables or erroring out. + * + * Drivers that do not implement `syncSchema` are silently skipped. + */ + private async syncRegisteredSchemas(ctx: PluginContext) { + if (!this.ql) return; + + const allObjects = this.ql.registry?.getAllObjects?.() ?? []; + if (allObjects.length === 0) return; + + let synced = 0; + let skipped = 0; + + for (const obj of allObjects) { + const driver = this.ql.getDriverForObject(obj.name); + if (!driver) { + ctx.logger.debug('No driver available for object, skipping schema sync', { + object: obj.name, + }); + skipped++; + continue; + } + + if (typeof driver.syncSchema !== 'function') { + ctx.logger.debug('Driver does not support syncSchema, skipping', { + object: obj.name, + driver: driver.name, + }); + skipped++; + continue; + } + + try { + await driver.syncSchema(obj.name, obj); + synced++; + } catch (e: any) { + ctx.logger.warn('Failed to sync schema for object', { + object: obj.name, + driver: driver.name, + error: e.message, + }); + } + } + + if (synced > 0 || skipped > 0) { + ctx.logger.info('Schema sync complete', { synced, skipped, total: allObjects.length }); + } + } + /** * Load metadata from external metadata service into ObjectQL registry * This enables ObjectQL to use file-based or remote metadata diff --git a/packages/spec/src/contracts/data-engine.ts b/packages/spec/src/contracts/data-engine.ts index aa8862e868..ceed5f3ac0 100644 --- a/packages/spec/src/contracts/data-engine.ts +++ b/packages/spec/src/contracts/data-engine.ts @@ -72,4 +72,12 @@ export interface DriverInterface { * Raw Execution */ execute?(command: any, params?: any, options?: DriverOptions): Promise; + + /** + * Synchronize the database schema with the Object definition. + * Idempotent: creates tables if missing, adds columns, updates indexes. + * + * @see IDataDriver.syncSchema in data-driver.ts for the full contract. + */ + syncSchema?(object: string, schema: unknown, options?: DriverOptions): Promise; } From 65563f13303be37d358fd3db3315bbbb14554323 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Mar 2026 06:18:46 +0000 Subject: [PATCH 3/3] fix: use unknown instead of any for error type in syncRegisteredSchemas Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> Agent-Logs-Url: https://github.com/objectstack-ai/spec/sessions/a6a76ea8-5b90-40cb-88c8-26a9cb27cc6b --- packages/objectql/src/plugin.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/objectql/src/plugin.ts b/packages/objectql/src/plugin.ts index 045e899b9b..f37923f37d 100644 --- a/packages/objectql/src/plugin.ts +++ b/packages/objectql/src/plugin.ts @@ -277,11 +277,11 @@ export class ObjectQLPlugin implements Plugin { try { await driver.syncSchema(obj.name, obj); synced++; - } catch (e: any) { + } catch (e: unknown) { ctx.logger.warn('Failed to sync schema for object', { object: obj.name, driver: driver.name, - error: e.message, + error: e instanceof Error ? e.message : String(e), }); } }