@objectstack/objectql 3.3.0 → 3.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@objectstack/objectql",
3
- "version": "3.3.0",
3
+ "version": "3.3.1",
4
4
  "license": "Apache-2.0",
5
5
  "description": "Isomorphic ObjectQL Engine for ObjectStack",
6
6
  "main": "dist/index.js",
@@ -13,9 +13,9 @@
13
13
  }
14
14
  },
15
15
  "dependencies": {
16
- "@objectstack/core": "3.3.0",
17
- "@objectstack/spec": "3.3.0",
18
- "@objectstack/types": "3.3.0"
16
+ "@objectstack/core": "3.3.1",
17
+ "@objectstack/spec": "3.3.1",
18
+ "@objectstack/types": "3.3.1"
19
19
  },
20
20
  "devDependencies": {
21
21
  "typescript": "^5.0.0",
@@ -547,5 +547,217 @@ describe('ObjectQLPlugin - Metadata Service Integration', () => {
547
547
  expect(syncedNames).not.toContain('sys__user');
548
548
  expect(syncedNames).not.toContain('sys__session');
549
549
  });
550
+
551
+ it('should use syncSchemasBatch when driver supports batchSchemaSync', async () => {
552
+ // Arrange - driver that supports batch schema sync
553
+ const batchCalls: Array<{ object: string; schema: any }[]> = [];
554
+ const singleCalls: Array<{ object: string; schema: any }> = [];
555
+ const mockDriver = {
556
+ name: 'batch-driver',
557
+ version: '1.0.0',
558
+ supports: { batchSchemaSync: true },
559
+ connect: async () => {},
560
+ disconnect: async () => {},
561
+ find: async () => [],
562
+ findOne: async () => null,
563
+ create: async (_o: string, d: any) => d,
564
+ update: async (_o: string, _i: any, d: any) => d,
565
+ delete: async () => true,
566
+ syncSchema: async (object: string, schema: any) => {
567
+ singleCalls.push({ object, schema });
568
+ },
569
+ syncSchemasBatch: async (schemas: Array<{ object: string; schema: any }>) => {
570
+ batchCalls.push(schemas);
571
+ },
572
+ };
573
+
574
+ await kernel.use({
575
+ name: 'mock-batch-driver-plugin',
576
+ type: 'driver',
577
+ version: '1.0.0',
578
+ init: async (ctx) => {
579
+ ctx.registerService('driver.batch', mockDriver);
580
+ },
581
+ });
582
+
583
+ const appManifest = {
584
+ id: 'com.test.batchapp',
585
+ name: 'batchapp',
586
+ namespace: 'bat',
587
+ version: '1.0.0',
588
+ objects: [
589
+ {
590
+ name: 'alpha',
591
+ label: 'Alpha',
592
+ fields: { a: { name: 'a', label: 'A', type: 'text' } },
593
+ },
594
+ {
595
+ name: 'beta',
596
+ label: 'Beta',
597
+ fields: { b: { name: 'b', label: 'B', type: 'text' } },
598
+ },
599
+ {
600
+ name: 'gamma',
601
+ label: 'Gamma',
602
+ fields: { c: { name: 'c', label: 'C', type: 'text' } },
603
+ },
604
+ ],
605
+ };
606
+
607
+ await kernel.use({
608
+ name: 'mock-batch-app-plugin',
609
+ type: 'app',
610
+ version: '1.0.0',
611
+ init: async (ctx) => {
612
+ ctx.registerService('app.batchapp', appManifest);
613
+ },
614
+ });
615
+
616
+ const plugin = new ObjectQLPlugin();
617
+ await kernel.use(plugin);
618
+
619
+ // Act
620
+ await kernel.bootstrap();
621
+
622
+ // Assert - syncSchemasBatch should have been called once with all objects
623
+ expect(batchCalls.length).toBe(1);
624
+ const batchedObjects = batchCalls[0].map((s) => s.object).sort();
625
+ expect(batchedObjects).toContain('bat__alpha');
626
+ expect(batchedObjects).toContain('bat__beta');
627
+ expect(batchedObjects).toContain('bat__gamma');
628
+ // syncSchema should NOT have been called individually
629
+ expect(singleCalls.length).toBe(0);
630
+ });
631
+
632
+ it('should fall back to sequential syncSchema when batch fails', async () => {
633
+ // Arrange - driver where batch fails
634
+ const singleCalls: Array<{ object: string; schema: any }> = [];
635
+ const mockDriver = {
636
+ name: 'fallback-driver',
637
+ version: '1.0.0',
638
+ supports: { batchSchemaSync: true },
639
+ connect: async () => {},
640
+ disconnect: async () => {},
641
+ find: async () => [],
642
+ findOne: async () => null,
643
+ create: async (_o: string, d: any) => d,
644
+ update: async (_o: string, _i: any, d: any) => d,
645
+ delete: async () => true,
646
+ syncSchema: async (object: string, schema: any) => {
647
+ singleCalls.push({ object, schema });
648
+ },
649
+ syncSchemasBatch: async () => {
650
+ throw new Error('batch not supported at runtime');
651
+ },
652
+ };
653
+
654
+ await kernel.use({
655
+ name: 'mock-fallback-driver-plugin',
656
+ type: 'driver',
657
+ version: '1.0.0',
658
+ init: async (ctx) => {
659
+ ctx.registerService('driver.fallback', mockDriver);
660
+ },
661
+ });
662
+
663
+ const appManifest = {
664
+ id: 'com.test.fallback',
665
+ name: 'fallback',
666
+ namespace: 'fb',
667
+ version: '1.0.0',
668
+ objects: [
669
+ {
670
+ name: 'one',
671
+ label: 'One',
672
+ fields: { x: { name: 'x', label: 'X', type: 'text' } },
673
+ },
674
+ {
675
+ name: 'two',
676
+ label: 'Two',
677
+ fields: { y: { name: 'y', label: 'Y', type: 'text' } },
678
+ },
679
+ ],
680
+ };
681
+
682
+ await kernel.use({
683
+ name: 'mock-fallback-app-plugin',
684
+ type: 'app',
685
+ version: '1.0.0',
686
+ init: async (ctx) => {
687
+ ctx.registerService('app.fallback', appManifest);
688
+ },
689
+ });
690
+
691
+ const plugin = new ObjectQLPlugin();
692
+ await kernel.use(plugin);
693
+
694
+ // Act - should not throw
695
+ await expect(kernel.bootstrap()).resolves.not.toThrow();
696
+
697
+ // Assert - sequential fallback should have been used
698
+ const syncedObjects = singleCalls.map((s) => s.object).sort();
699
+ expect(syncedObjects).toContain('fb__one');
700
+ expect(syncedObjects).toContain('fb__two');
701
+ });
702
+
703
+ it('should not use batch when driver does not support batchSchemaSync', async () => {
704
+ // Arrange - driver without batch support (but with syncSchema)
705
+ const singleCalls: string[] = [];
706
+ const mockDriver = {
707
+ name: 'nobatch-driver',
708
+ version: '1.0.0',
709
+ connect: async () => {},
710
+ disconnect: async () => {},
711
+ find: async () => [],
712
+ findOne: async () => null,
713
+ create: async (_o: string, d: any) => d,
714
+ update: async (_o: string, _i: any, d: any) => d,
715
+ delete: async () => true,
716
+ syncSchema: async (object: string) => {
717
+ singleCalls.push(object);
718
+ },
719
+ };
720
+
721
+ await kernel.use({
722
+ name: 'mock-nobatch-driver-plugin',
723
+ type: 'driver',
724
+ version: '1.0.0',
725
+ init: async (ctx) => {
726
+ ctx.registerService('driver.nobatch', mockDriver);
727
+ },
728
+ });
729
+
730
+ const appManifest = {
731
+ id: 'com.test.nobatch',
732
+ name: 'nobatch',
733
+ namespace: 'nb',
734
+ version: '1.0.0',
735
+ objects: [
736
+ {
737
+ name: 'item',
738
+ label: 'Item',
739
+ fields: { z: { name: 'z', label: 'Z', type: 'text' } },
740
+ },
741
+ ],
742
+ };
743
+
744
+ await kernel.use({
745
+ name: 'mock-nobatch-app-plugin',
746
+ type: 'app',
747
+ version: '1.0.0',
748
+ init: async (ctx) => {
749
+ ctx.registerService('app.nobatch', appManifest);
750
+ },
751
+ });
752
+
753
+ const plugin = new ObjectQLPlugin();
754
+ await kernel.use(plugin);
755
+
756
+ // Act
757
+ await kernel.bootstrap();
758
+
759
+ // Assert - sequential syncSchema should have been used
760
+ expect(singleCalls).toContain('nb__item');
761
+ });
550
762
  });
551
763
  });
package/src/plugin.ts CHANGED
@@ -239,9 +239,13 @@ export class ObjectQLPlugin implements Plugin {
239
239
  /**
240
240
  * Synchronize all registered object schemas to the database.
241
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
242
+ * Groups objects by their responsible driver, then:
243
+ * - If the driver advertises `supports.batchSchemaSync` and implements
244
+ * `syncSchemasBatch()`, submits all schemas in a single call (reducing
245
+ * network round-trips for remote drivers like Turso).
246
+ * - Otherwise falls back to sequential `syncSchema()` per object.
247
+ *
248
+ * This is idempotent — drivers must tolerate repeated calls without
245
249
  * duplicating tables or erroring out.
246
250
  *
247
251
  * Drivers that do not implement `syncSchema` are silently skipped.
@@ -255,6 +259,9 @@ export class ObjectQLPlugin implements Plugin {
255
259
  let synced = 0;
256
260
  let skipped = 0;
257
261
 
262
+ // Group objects by driver for potential batch optimization
263
+ const driverGroups = new Map<any, Array<{ obj: any; tableName: string }>>();
264
+
258
265
  for (const obj of allObjects) {
259
266
  const driver = this.ql.getDriverForObject(obj.name);
260
267
  if (!driver) {
@@ -274,21 +281,69 @@ export class ObjectQLPlugin implements Plugin {
274
281
  continue;
275
282
  }
276
283
 
277
- // Use the physical table name (e.g., 'sys_user') for DDL operations
278
- // instead of the FQN (e.g., 'sys__user'). ObjectSchema.create()
279
- // auto-derives tableName as {namespace}_{name}.
280
284
  const tableName = obj.tableName || obj.name;
281
285
 
282
- try {
283
- await driver.syncSchema(tableName, obj);
284
- synced++;
285
- } catch (e: unknown) {
286
- ctx.logger.warn('Failed to sync schema for object', {
287
- object: obj.name,
288
- tableName,
289
- driver: driver.name,
290
- error: e instanceof Error ? e.message : String(e),
291
- });
286
+ let group = driverGroups.get(driver);
287
+ if (!group) {
288
+ group = [];
289
+ driverGroups.set(driver, group);
290
+ }
291
+ group.push({ obj, tableName });
292
+ }
293
+
294
+ // Process each driver group
295
+ for (const [driver, entries] of driverGroups) {
296
+ // Batch path: driver supports batch schema sync
297
+ if (
298
+ driver.supports?.batchSchemaSync &&
299
+ typeof driver.syncSchemasBatch === 'function'
300
+ ) {
301
+ const batchPayload = entries.map((e) => ({
302
+ object: e.tableName,
303
+ schema: e.obj,
304
+ }));
305
+ try {
306
+ await driver.syncSchemasBatch(batchPayload);
307
+ synced += entries.length;
308
+ ctx.logger.debug('Batch schema sync succeeded', {
309
+ driver: driver.name,
310
+ count: entries.length,
311
+ });
312
+ } catch (e: unknown) {
313
+ ctx.logger.warn('Batch schema sync failed, falling back to sequential', {
314
+ driver: driver.name,
315
+ error: e instanceof Error ? e.message : String(e),
316
+ });
317
+ // Fallback: sequential sync for this driver's objects
318
+ for (const { obj, tableName } of entries) {
319
+ try {
320
+ await driver.syncSchema(tableName, obj);
321
+ synced++;
322
+ } catch (seqErr: unknown) {
323
+ ctx.logger.warn('Failed to sync schema for object', {
324
+ object: obj.name,
325
+ tableName,
326
+ driver: driver.name,
327
+ error: seqErr instanceof Error ? seqErr.message : String(seqErr),
328
+ });
329
+ }
330
+ }
331
+ }
332
+ } else {
333
+ // Sequential path: no batch support
334
+ for (const { obj, tableName } of entries) {
335
+ try {
336
+ await driver.syncSchema(tableName, obj);
337
+ synced++;
338
+ } catch (e: unknown) {
339
+ ctx.logger.warn('Failed to sync schema for object', {
340
+ object: obj.name,
341
+ tableName,
342
+ driver: driver.name,
343
+ error: e instanceof Error ? e.message : String(e),
344
+ });
345
+ }
346
+ }
292
347
  }
293
348
  }
294
349