Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/objectql/CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.

CopilotAIMar 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changelog entry references sys_user, but system plugin objects in the sys namespace are registered as sys__user (FQN with double underscore). Updating the example name here would better match what users will actually see in logs/DB identifiers.

Suggested change
- 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.
- 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.

Copilot uses AI. Check for mistakes.
- 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
Expand Down
18 changes: 18 additions & 0 deletions packages/objectql/src/engine.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 {

CopilotAIMar 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getDriverForObject() catches and suppresses all errors from getDriver() without logging. This makes datasource misconfiguration (e.g., object bound to an unregistered datasource) hard to debug because callers only see undefined. Consider catching the error, logging it at debug/warn with objectName + message, and then returning undefined for tolerance.

Suggested change
}catch{
}catch(error){
this.logger?.warn?.(
`[ObjectQL] getDriverForObject: failed to resolve driver for object '${objectName}': ${
errorinstanceofError ? error.message : String(error)
}`,
);

Copilot uses AI. Check for mistakes.
return undefined;
}
}

/**
* Get a registered driver by datasource name.
* Alias matching @objectql/core datasource() API.
Expand Down
232 changes: 232 additions & 0 deletions packages/objectql/src/plugin.integration.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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' });
});

Expand DownExpand Up@@ -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();
});
});
});
60 changes: 60 additions & 0 deletions packages/objectql/src/plugin.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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).

CopilotAIMar 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The example object name in this comment uses sys_user, but objects in the sys namespace are registered as FQNs using the sys__<name> pattern (double underscore). Using sys__user here would avoid confusion when troubleshooting schema sync/table names.

Suggested change
// object registered by plugins (e.g., sys_user from plugin-auth).
// object registered by plugins (e.g., sys__user from plugin-auth).

Copilot uses AI. Check for mistakes.
await this.syncRegisteredSchemas(ctx);

// Register built-in audit hooks
this.registerAuditHooks(ctx);

Expand DownExpand Up@@ -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: unknown) {
ctx.logger.warn('Failed to sync schema for object', {
object: obj.name,
driver: driver.name,
error: e instanceof Error ? e.message : String(e),
});
Comment on lines +281 to +285

CopilotAIMar 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When a schema sync fails, the warn log only captures e.message (or String(e)), which drops stack traces for Error instances and makes diagnosis harder. Consider including stack (when available) in the metadata or logging the full error details in a structured way.

Copilot uses AI. Check for mistakes.
}
}

if (synced > 0 || skipped > 0) {
ctx.logger.info('Schema sync complete', { synced, skipped, total: allObjects.length });
}
Comment on lines +289 to +291

CopilotAIMar 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

syncRegisteredSchemas() only emits the final "Schema sync complete" info log when synced > 0 || skipped > 0. If all objects have a driver+syncSchema but every call throws, this produces no summary log even though work was attempted. Consider tracking a failed counter and always logging a summary (or at least logging when failed > 0) so operators can tell startup schema sync ran but encountered errors.

Copilot uses AI. Check for mistakes.
}

/**
* Load metadata from external metadata service into ObjectQL registry
* This enables ObjectQL to use file-based or remote metadata
Expand Down
8 changes: 8 additions & 0 deletions packages/spec/src/contracts/data-engine.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -72,4 +72,12 @@ export interface DriverInterface {
* Raw Execution
*/
execute?(command: any, params?: any, options?: DriverOptions): Promise<any>;

/**
* 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<void>;
}
Loading