Skip to content

Commit 59858ea

Browse files
authored
Merge pull request #942 from objectstack-ai/copilot/fix-schema-sync-issues
2 parents 0bc7b0c + 65563f1 commit 59858ea

5 files changed

Lines changed: 321 additions & 0 deletions

File tree

packages/objectql/CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44

55
### Patch Changes
66

7+
- 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.
8+
- Added `getDriverForObject(objectName)` public method to `ObjectQL` engine for resolving the responsible driver for a given object.
9+
- Added optional `syncSchema` method to `DriverInterface` contract, aligning it with the full `IDataDriver` protocol.
710
- @objectstack/spec@3.2.8
811
- @objectstack/core@3.2.8
912
- @objectstack/types@3.2.8

packages/objectql/src/engine.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1164,6 +1164,24 @@ export class ObjectQL implements IDataEngine {
11641164
return this.drivers.get(name);
11651165
}
11661166

1167+
/**
1168+
* Get the driver responsible for the given object.
1169+
*
1170+
* Resolves datasource binding from the object's schema definition,
1171+
* falling back to the default driver. This is a public version of
1172+
* the internal getDriver() used by CRUD operations.
1173+
*
1174+
* @param objectName - FQN or short name of the registered object.
1175+
* @returns The resolved DriverInterface, or undefined if no driver is available.
1176+
*/
1177+
getDriverForObject(objectName: string): DriverInterface | undefined {
1178+
try {
1179+
return this.getDriver(objectName);
1180+
} catch {
1181+
return undefined;
1182+
}
1183+
}
1184+
11671185
/**
11681186
* Get a registered driver by datasource name.
11691187
* Alias matching @objectql/core datasource() API.

packages/objectql/src/plugin.integration.test.ts

Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@
33
import { describe, it, expect, beforeEach } from 'vitest';
44
import { ObjectKernel } from '@objectstack/core';
55
import { ObjectQLPlugin } from '../src/plugin';
6+
import { SchemaRegistry } from '../src/registry';
67
import { ObjectSchema } from '@objectstack/spec/data';
78

89
describe('ObjectQLPlugin - Metadata Service Integration', () => {
910
let kernel: ObjectKernel;
1011

1112
beforeEach(() => {
13+
SchemaRegistry.reset();
1214
kernel = new ObjectKernel({ logLevel: 'silent' });
1315
});
1416

@@ -237,4 +239,234 @@ describe('ObjectQLPlugin - Metadata Service Integration', () => {
237239
// We can verify by checking if ObjectQL detected external service
238240
});
239241
});
242+
243+
describe('Schema Sync on Start', () => {
244+
it('should call syncSchema for each registered object after init', async () => {
245+
// Arrange - driver that tracks syncSchema calls
246+
const synced: Array<{ object: string; schema: any }> = [];
247+
const mockDriver = {
248+
name: 'sync-driver',
249+
version: '1.0.0',
250+
connect: async () => {},
251+
disconnect: async () => {},
252+
find: async () => [],
253+
findOne: async () => null,
254+
create: async (_o: string, d: any) => d,
255+
update: async (_o: string, _i: any, d: any) => d,
256+
delete: async () => true,
257+
syncSchema: async (object: string, schema: any) => {
258+
synced.push({ object, schema });
259+
},
260+
};
261+
262+
// Plugin that registers objects and a driver
263+
await kernel.use({
264+
name: 'mock-driver-plugin',
265+
type: 'driver',
266+
version: '1.0.0',
267+
init: async (ctx) => {
268+
ctx.registerService('driver.sync', mockDriver);
269+
},
270+
});
271+
272+
const appManifest = {
273+
id: 'com.test.auth',
274+
name: 'auth',
275+
namespace: 'sys',
276+
version: '1.0.0',
277+
objects: [
278+
{
279+
name: 'user',
280+
label: 'User',
281+
fields: {
282+
name: { name: 'name', label: 'Name', type: 'text' },
283+
},
284+
},
285+
{
286+
name: 'role',
287+
label: 'Role',
288+
fields: {
289+
title: { name: 'title', label: 'Title', type: 'text' },
290+
},
291+
},
292+
],
293+
};
294+
295+
await kernel.use({
296+
name: 'mock-app-plugin',
297+
type: 'app',
298+
version: '1.0.0',
299+
init: async (ctx) => {
300+
ctx.registerService('app.auth', appManifest);
301+
},
302+
});
303+
304+
const plugin = new ObjectQLPlugin();
305+
await kernel.use(plugin);
306+
307+
// Act
308+
await kernel.bootstrap();
309+
310+
// Assert - syncSchema should have been called for each object
311+
const syncedObjects = synced.map((s) => s.object).sort();
312+
expect(syncedObjects).toContain('sys__user');
313+
expect(syncedObjects).toContain('sys__role');
314+
expect(synced.length).toBeGreaterThanOrEqual(2);
315+
});
316+
317+
it('should tolerate drivers without syncSchema', async () => {
318+
// Arrange - driver without syncSchema
319+
const mockDriver = {
320+
name: 'no-sync-driver',
321+
version: '1.0.0',
322+
connect: async () => {},
323+
disconnect: async () => {},
324+
find: async () => [],
325+
findOne: async () => null,
326+
create: async (_o: string, d: any) => d,
327+
update: async (_o: string, _i: any, d: any) => d,
328+
delete: async () => true,
329+
// No syncSchema method
330+
};
331+
332+
await kernel.use({
333+
name: 'mock-driver-plugin',
334+
type: 'driver',
335+
version: '1.0.0',
336+
init: async (ctx) => {
337+
ctx.registerService('driver.nosync', mockDriver);
338+
},
339+
});
340+
341+
const appManifest = {
342+
id: 'com.test.simple',
343+
name: 'simple',
344+
namespace: 'test',
345+
version: '1.0.0',
346+
objects: [
347+
{
348+
name: 'item',
349+
label: 'Item',
350+
fields: {
351+
title: { name: 'title', label: 'Title', type: 'text' },
352+
},
353+
},
354+
],
355+
};
356+
357+
await kernel.use({
358+
name: 'mock-app-plugin',
359+
type: 'app',
360+
version: '1.0.0',
361+
init: async (ctx) => {
362+
ctx.registerService('app.simple', appManifest);
363+
},
364+
});
365+
366+
const plugin = new ObjectQLPlugin();
367+
await kernel.use(plugin);
368+
369+
// Act & Assert - should not throw
370+
await expect(kernel.bootstrap()).resolves.not.toThrow();
371+
});
372+
373+
it('should tolerate syncSchema failures per object without aborting', async () => {
374+
// Arrange - driver where syncSchema fails for one object
375+
const synced: string[] = [];
376+
const mockDriver = {
377+
name: 'fail-driver',
378+
version: '1.0.0',
379+
connect: async () => {},
380+
disconnect: async () => {},
381+
find: async () => [],
382+
findOne: async () => null,
383+
create: async (_o: string, d: any) => d,
384+
update: async (_o: string, _i: any, d: any) => d,
385+
delete: async () => true,
386+
syncSchema: async (object: string) => {
387+
if (object.includes('bad')) {
388+
throw new Error('sync failed for bad object');
389+
}
390+
synced.push(object);
391+
},
392+
};
393+
394+
await kernel.use({
395+
name: 'mock-driver-plugin',
396+
type: 'driver',
397+
version: '1.0.0',
398+
init: async (ctx) => {
399+
ctx.registerService('driver.fail', mockDriver);
400+
},
401+
});
402+
403+
const appManifest = {
404+
id: 'com.test.mixed',
405+
name: 'mixed',
406+
namespace: 'mix',
407+
version: '1.0.0',
408+
objects: [
409+
{
410+
name: 'good',
411+
label: 'Good',
412+
fields: { a: { name: 'a', label: 'A', type: 'text' } },
413+
},
414+
{
415+
name: 'bad',
416+
label: 'Bad',
417+
fields: { b: { name: 'b', label: 'B', type: 'text' } },
418+
},
419+
],
420+
};
421+
422+
await kernel.use({
423+
name: 'mock-app-plugin',
424+
type: 'app',
425+
version: '1.0.0',
426+
init: async (ctx) => {
427+
ctx.registerService('app.mixed', appManifest);
428+
},
429+
});
430+
431+
const plugin = new ObjectQLPlugin();
432+
await kernel.use(plugin);
433+
434+
// Act - should not throw despite one object failing
435+
await expect(kernel.bootstrap()).resolves.not.toThrow();
436+
437+
// Assert - the good object should still have been synced
438+
expect(synced).toContain('mix__good');
439+
});
440+
441+
it('should work without any registered objects', async () => {
442+
// Arrange - no objects, just a driver
443+
const mockDriver = {
444+
name: 'empty-driver',
445+
version: '1.0.0',
446+
connect: async () => {},
447+
disconnect: async () => {},
448+
find: async () => [],
449+
findOne: async () => null,
450+
create: async (_o: string, d: any) => d,
451+
update: async (_o: string, _i: any, d: any) => d,
452+
delete: async () => true,
453+
syncSchema: async () => {},
454+
};
455+
456+
await kernel.use({
457+
name: 'mock-driver-plugin',
458+
type: 'driver',
459+
version: '1.0.0',
460+
init: async (ctx) => {
461+
ctx.registerService('driver.empty', mockDriver);
462+
},
463+
});
464+
465+
const plugin = new ObjectQLPlugin();
466+
await kernel.use(plugin);
467+
468+
// Act & Assert - should not throw
469+
await expect(kernel.bootstrap()).resolves.not.toThrow();
470+
});
471+
});
240472
});

packages/objectql/src/plugin.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,11 @@ export class ObjectQLPlugin implements Plugin {
119119
// Initialize drivers (calls driver.connect() which sets up persistence)
120120
await this.ql?.init();
121121

122+
// Sync all registered object schemas to database
123+
// This ensures tables/collections are created or updated for every
124+
// object registered by plugins (e.g., sys_user from plugin-auth).
125+
await this.syncRegisteredSchemas(ctx);
126+
122127
// Register built-in audit hooks
123128
this.registerAuditHooks(ctx);
124129

@@ -231,6 +236,61 @@ export class ObjectQLPlugin implements Plugin {
231236
ctx.logger.debug('Tenant isolation middleware registered');
232237
}
233238

239+
/**
240+
* Synchronize all registered object schemas to the database.
241+
*
242+
* Iterates every object in the SchemaRegistry and calls the
243+
* responsible driver's `syncSchema()` for each one. This is
244+
* idempotent — drivers must tolerate repeated calls without
245+
* duplicating tables or erroring out.
246+
*
247+
* Drivers that do not implement `syncSchema` are silently skipped.
248+
*/
249+
private async syncRegisteredSchemas(ctx: PluginContext) {
250+
if (!this.ql) return;
251+
252+
const allObjects = this.ql.registry?.getAllObjects?.() ?? [];
253+
if (allObjects.length === 0) return;
254+
255+
let synced = 0;
256+
let skipped = 0;
257+
258+
for (const obj of allObjects) {
259+
const driver = this.ql.getDriverForObject(obj.name);
260+
if (!driver) {
261+
ctx.logger.debug('No driver available for object, skipping schema sync', {
262+
object: obj.name,
263+
});
264+
skipped++;
265+
continue;
266+
}
267+
268+
if (typeof driver.syncSchema !== 'function') {
269+
ctx.logger.debug('Driver does not support syncSchema, skipping', {
270+
object: obj.name,
271+
driver: driver.name,
272+
});
273+
skipped++;
274+
continue;
275+
}
276+
277+
try {
278+
await driver.syncSchema(obj.name, obj);
279+
synced++;
280+
} catch (e: unknown) {
281+
ctx.logger.warn('Failed to sync schema for object', {
282+
object: obj.name,
283+
driver: driver.name,
284+
error: e instanceof Error ? e.message : String(e),
285+
});
286+
}
287+
}
288+
289+
if (synced > 0 || skipped > 0) {
290+
ctx.logger.info('Schema sync complete', { synced, skipped, total: allObjects.length });
291+
}
292+
}
293+
234294
/**
235295
* Load metadata from external metadata service into ObjectQL registry
236296
* This enables ObjectQL to use file-based or remote metadata

packages/spec/src/contracts/data-engine.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,4 +72,12 @@ export interface DriverInterface {
7272
* Raw Execution
7373
*/
7474
execute?(command: any, params?: any, options?: DriverOptions): Promise<any>;
75+
76+
/**
77+
* Synchronize the database schema with the Object definition.
78+
* Idempotent: creates tables if missing, adds columns, updates indexes.
79+
*
80+
* @see IDataDriver.syncSchema in data-driver.ts for the full contract.
81+
*/
82+
syncSchema?(object: string, schema: unknown, options?: DriverOptions): Promise<void>;
7583
}

0 commit comments

Comments
 (0)