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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
invocations on Vercel. The browser MSW mock kernel remains unchanged (InMemoryDriver).

### Added
- **Batch schema sync for remote DDL in kernel bootstrap** — `ObjectQLPlugin.syncRegisteredSchemas()`
now groups objects by driver and uses `syncSchemasBatch()` when the driver advertises
`supports.batchSchemaSync = true`. This reduces the number of remote DDL round-trips from
roughly N×(2–3) individual calls (introspection + optional PRAGMA + DDL write per object)
to a small constant number of batched `client.batch()` calls, cutting cold-start times from
58+ seconds to under 10 seconds for 100+ objects on remote drivers (e.g. Turso cloud).
Falls back to sequential `syncSchema()` per object for drivers without batch support or if the
batched calls fail at runtime. Added `batchSchemaSync` capability flag to `DriverCapabilitiesSchema`,
optional `syncSchemasBatch()` to `IDataDriver`, and `RemoteTransport.syncSchemasBatch()` using
`@libsql/client`'s `batch()` API.
- **`@objectstack/driver-turso` — dual transport architecture** — TursoDriver now supports three
transport modes: `local`, `replica`, and `remote`. Remote mode (`url: 'libsql://...'`) enables
pure cloud-only queries via `@libsql/client` SDK (HTTP/WebSocket) without requiring a local
Expand Down
212 changes: 212 additions & 0 deletions packages/objectql/src/plugin.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -547,5 +547,217 @@ describe('ObjectQLPlugin - Metadata Service Integration', () => {
expect(syncedNames).not.toContain('sys__user');
expect(syncedNames).not.toContain('sys__session');
});

it('should use syncSchemasBatch when driver supports batchSchemaSync', async () => {
// Arrange - driver that supports batch schema sync
const batchCalls: Array<{ object: string; schema: any }[]> = [];
const singleCalls: Array<{ object: string; schema: any }> = [];
const mockDriver = {
name: 'batch-driver',
version: '1.0.0',
supports: { batchSchemaSync: true },
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) => {
singleCalls.push({ object, schema });
},
syncSchemasBatch: async (schemas: Array<{ object: string; schema: any }>) => {
batchCalls.push(schemas);
},
};

await kernel.use({
name: 'mock-batch-driver-plugin',
type: 'driver',
version: '1.0.0',
init: async (ctx) => {
ctx.registerService('driver.batch', mockDriver);
},
});

const appManifest = {
id: 'com.test.batchapp',
name: 'batchapp',
namespace: 'bat',
version: '1.0.0',
objects: [
{
name: 'alpha',
label: 'Alpha',
fields: { a: { name: 'a', label: 'A', type: 'text' } },
},
{
name: 'beta',
label: 'Beta',
fields: { b: { name: 'b', label: 'B', type: 'text' } },
},
{
name: 'gamma',
label: 'Gamma',
fields: { c: { name: 'c', label: 'C', type: 'text' } },
},
],
};

await kernel.use({
name: 'mock-batch-app-plugin',
type: 'app',
version: '1.0.0',
init: async (ctx) => {
ctx.registerService('app.batchapp', appManifest);
},
});

const plugin = new ObjectQLPlugin();
await kernel.use(plugin);

// Act
await kernel.bootstrap();

// Assert - syncSchemasBatch should have been called once with all objects
expect(batchCalls.length).toBe(1);
const batchedObjects = batchCalls[0].map((s) => s.object).sort();
expect(batchedObjects).toContain('bat__alpha');
expect(batchedObjects).toContain('bat__beta');
expect(batchedObjects).toContain('bat__gamma');
// syncSchema should NOT have been called individually
expect(singleCalls.length).toBe(0);
});

it('should fall back to sequential syncSchema when batch fails', async () => {
// Arrange - driver where batch fails
const singleCalls: Array<{ object: string; schema: any }> = [];
const mockDriver = {
name: 'fallback-driver',
version: '1.0.0',
supports: { batchSchemaSync: true },
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) => {
singleCalls.push({ object, schema });
},
syncSchemasBatch: async () => {
throw new Error('batch not supported at runtime');
},
};

await kernel.use({
name: 'mock-fallback-driver-plugin',
type: 'driver',
version: '1.0.0',
init: async (ctx) => {
ctx.registerService('driver.fallback', mockDriver);
},
});

const appManifest = {
id: 'com.test.fallback',
name: 'fallback',
namespace: 'fb',
version: '1.0.0',
objects: [
{
name: 'one',
label: 'One',
fields: { x: { name: 'x', label: 'X', type: 'text' } },
},
{
name: 'two',
label: 'Two',
fields: { y: { name: 'y', label: 'Y', type: 'text' } },
},
],
};

await kernel.use({
name: 'mock-fallback-app-plugin',
type: 'app',
version: '1.0.0',
init: async (ctx) => {
ctx.registerService('app.fallback', appManifest);
},
});

const plugin = new ObjectQLPlugin();
await kernel.use(plugin);

// Act - should not throw
await expect(kernel.bootstrap()).resolves.not.toThrow();

// Assert - sequential fallback should have been used
const syncedObjects = singleCalls.map((s) => s.object).sort();
expect(syncedObjects).toContain('fb__one');
expect(syncedObjects).toContain('fb__two');
});

it('should not use batch when driver does not support batchSchemaSync', async () => {
// Arrange - driver without batch support (but with syncSchema)
const singleCalls: string[] = [];
const mockDriver = {
name: 'nobatch-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) => {
singleCalls.push(object);
},
};

await kernel.use({
name: 'mock-nobatch-driver-plugin',
type: 'driver',
version: '1.0.0',
init: async (ctx) => {
ctx.registerService('driver.nobatch', mockDriver);
},
});

const appManifest = {
id: 'com.test.nobatch',
name: 'nobatch',
namespace: 'nb',
version: '1.0.0',
objects: [
{
name: 'item',
label: 'Item',
fields: { z: { name: 'z', label: 'Z', type: 'text' } },
},
],
};

await kernel.use({
name: 'mock-nobatch-app-plugin',
type: 'app',
version: '1.0.0',
init: async (ctx) => {
ctx.registerService('app.nobatch', appManifest);
},
});

const plugin = new ObjectQLPlugin();
await kernel.use(plugin);

// Act
await kernel.bootstrap();

// Assert - sequential syncSchema should have been used
expect(singleCalls).toContain('nb__item');
});
});
});
87 changes: 71 additions & 16 deletions packages/objectql/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,9 +239,13 @@ export class ObjectQLPlugin implements Plugin {
/**
* 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
* Groups objects by their responsible driver, then:
* - If the driver advertises `supports.batchSchemaSync` and implements
* `syncSchemasBatch()`, submits all schemas in a single call (reducing
* network round-trips for remote drivers like Turso).
* - Otherwise falls back to sequential `syncSchema()` per object.
*
* This is idempotent — drivers must tolerate repeated calls without
* duplicating tables or erroring out.
*
* Drivers that do not implement `syncSchema` are silently skipped.
Expand All @@ -255,6 +259,9 @@ export class ObjectQLPlugin implements Plugin {
let synced = 0;
let skipped = 0;

// Group objects by driver for potential batch optimization
const driverGroups = new Map<any, Array<{ obj: any; tableName: string }>>();

for (const obj of allObjects) {
const driver = this.ql.getDriverForObject(obj.name);
if (!driver) {
Expand All @@ -274,21 +281,69 @@ 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.tableName || obj.name;

try {
await driver.syncSchema(tableName, obj);
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),
});
let group = driverGroups.get(driver);
if (!group) {
group = [];
driverGroups.set(driver, group);
}
group.push({ obj, tableName });
}

// Process each driver group
for (const [driver, entries] of driverGroups) {
// Batch path: driver supports batch schema sync
if (
driver.supports?.batchSchemaSync &&
typeof driver.syncSchemasBatch === 'function'
) {
const batchPayload = entries.map((e) => ({
object: e.tableName,
schema: e.obj,
}));
try {
await driver.syncSchemasBatch(batchPayload);
synced += entries.length;
ctx.logger.debug('Batch schema sync succeeded', {
driver: driver.name,
count: entries.length,
});
} catch (e: unknown) {
ctx.logger.warn('Batch schema sync failed, falling back to sequential', {
driver: driver.name,
error: e instanceof Error ? e.message : String(e),
});
// Fallback: sequential sync for this driver's objects
for (const { obj, tableName } of entries) {
try {
await driver.syncSchema(tableName, obj);
synced++;
} catch (seqErr: unknown) {
ctx.logger.warn('Failed to sync schema for object', {
object: obj.name,
tableName,
driver: driver.name,
error: seqErr instanceof Error ? seqErr.message : String(seqErr),
});
}
}
}
} else {
// Sequential path: no batch support
for (const { obj, tableName } of entries) {
try {
await driver.syncSchema(tableName, obj);
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),
});
}
}
}
}

Expand Down
1 change: 1 addition & 0 deletions packages/plugins/driver-memory/src/memory-driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ export class InMemoryDriver implements IDataDriver {

// Schema Management
schemaSync: true, // Implemented via syncSchema()
batchSchemaSync: false,
migrations: false,
indexes: false,

Expand Down
1 change: 1 addition & 0 deletions packages/plugins/driver-sql/src/sql-driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ export class SqlDriver implements IDataDriver {

// Schema Management
schemaSync: true,
batchSchemaSync: false,
migrations: false,
indexes: false,

Expand Down
Loading