@objectstack/objectql 3.2.9 → 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.2.9",
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.2.9",
17
- "@objectstack/spec": "3.2.9",
18
- "@objectstack/types": "3.2.9"
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",
@@ -1,7 +1,7 @@
1
1
  import { describe, it, expect, vi, beforeEach } from 'vitest';
2
2
  import { ObjectQL } from './engine';
3
3
  import { SchemaRegistry } from './registry';
4
- import { DriverInterface } from '@objectstack/spec/data';
4
+ import type { IDataDriver } from '@objectstack/spec/contracts';
5
5
 
6
6
  // Mock the SchemaRegistry to avoid side effects between tests
7
7
  vi.mock('./registry', () => {
@@ -35,8 +35,8 @@ vi.mock('./registry', () => {
35
35
 
36
36
  describe('ObjectQL Engine', () => {
37
37
  let engine: ObjectQL;
38
- let mockDriver: DriverInterface;
39
- let mockDriver2: DriverInterface;
38
+ let mockDriver: IDataDriver;
39
+ let mockDriver2: IDataDriver;
40
40
 
41
41
  beforeEach(() => {
42
42
  // Clear Registry Mocks
@@ -54,7 +54,7 @@ describe('ObjectQL Engine', () => {
54
54
  delete: vi.fn(),
55
55
  count: vi.fn(),
56
56
  capabilities: {} as any // Simplified
57
- } as unknown as DriverInterface;
57
+ } as unknown as IDataDriver;
58
58
 
59
59
  mockDriver2 = {
60
60
  name: 'mongo',
@@ -67,7 +67,7 @@ describe('ObjectQL Engine', () => {
67
67
  delete: vi.fn(),
68
68
  count: vi.fn(),
69
69
  capabilities: {} as any
70
- } as unknown as DriverInterface;
70
+ } as unknown as IDataDriver;
71
71
 
72
72
  engine = new ObjectQL();
73
73
  });
package/src/engine.ts CHANGED
@@ -545,7 +545,10 @@ export class ObjectQL implements IDataEngine {
545
545
  private resolveObjectName(name: string): string {
546
546
  const schema = SchemaRegistry.getObject(name);
547
547
  if (schema) {
548
- return schema.name; // FQN from registry (e.g., 'todo__task')
548
+ // Prefer the physical table name (e.g., 'sys_user') over the FQN
549
+ // (e.g., 'sys__user'). ObjectSchema.create() auto-derives tableName
550
+ // as {namespace}_{name} which matches the storage convention.
551
+ return schema.tableName || schema.name;
549
552
  }
550
553
  return name; // Ad-hoc object, keep as-is
551
554
  }
@@ -898,7 +901,7 @@ export class ObjectQL implements IDataEngine {
898
901
  result = await Promise.all((hookContext.input.data as any[]).map((item: any) => driver.create(object, item, hookContext.input.options as any)));
899
902
  }
900
903
  } else {
901
- result = await driver.create(object, hookContext.input.data, hookContext.input.options as any);
904
+ result = await driver.create(object, hookContext.input.data as Record<string, unknown>, hookContext.input.options as any);
902
905
  }
903
906
 
904
907
  hookContext.event = 'afterInsert';
@@ -949,10 +952,10 @@ export class ObjectQL implements IDataEngine {
949
952
  try {
950
953
  let result;
951
954
  if (hookContext.input.id) {
952
- result = await driver.update(object, hookContext.input.id as string, hookContext.input.data, hookContext.input.options as any);
955
+ result = await driver.update(object, hookContext.input.id as string, hookContext.input.data as Record<string, unknown>, hookContext.input.options as any);
953
956
  } else if (options?.multi && driver.updateMany) {
954
957
  const ast = this.toQueryAST(object, { filter: options.filter });
955
- result = await driver.updateMany(object, ast, hookContext.input.data, hookContext.input.options as any);
958
+ result = await driver.updateMany(object, ast, hookContext.input.data as Record<string, unknown>, hookContext.input.options as any);
956
959
  } else {
957
960
  throw new Error('Update requires an ID or options.multi=true');
958
961
  }
@@ -468,5 +468,296 @@ describe('ObjectQLPlugin - Metadata Service Integration', () => {
468
468
  // Act & Assert - should not throw
469
469
  await expect(kernel.bootstrap()).resolves.not.toThrow();
470
470
  });
471
+
472
+ it('should use tableName for syncSchema when objects have auto-derived tableName', async () => {
473
+ // Arrange - driver that tracks syncSchema calls
474
+ const synced: Array<{ object: string; schema: any }> = [];
475
+ const mockDriver = {
476
+ name: 'table-name-driver',
477
+ version: '1.0.0',
478
+ connect: async () => {},
479
+ disconnect: async () => {},
480
+ find: async () => [],
481
+ findOne: async () => null,
482
+ create: async (_o: string, d: any) => d,
483
+ update: async (_o: string, _i: any, d: any) => d,
484
+ delete: async () => true,
485
+ syncSchema: async (object: string, schema: any) => {
486
+ synced.push({ object, schema });
487
+ },
488
+ };
489
+
490
+ await kernel.use({
491
+ name: 'mock-driver-plugin',
492
+ type: 'driver',
493
+ version: '1.0.0',
494
+ init: async (ctx) => {
495
+ ctx.registerService('driver.table-name', mockDriver);
496
+ },
497
+ });
498
+
499
+ // Objects with tableName (simulating ObjectSchema.create() output)
500
+ const appManifest = {
501
+ id: 'com.test.system',
502
+ name: 'system',
503
+ namespace: 'sys',
504
+ version: '1.0.0',
505
+ objects: [
506
+ {
507
+ name: 'user',
508
+ label: 'User',
509
+ namespace: 'sys',
510
+ tableName: 'sys_user',
511
+ fields: {
512
+ email: { name: 'email', label: 'Email', type: 'text' },
513
+ },
514
+ },
515
+ {
516
+ name: 'session',
517
+ label: 'Session',
518
+ namespace: 'sys',
519
+ tableName: 'sys_session',
520
+ fields: {
521
+ token: { name: 'token', label: 'Token', type: 'text' },
522
+ },
523
+ },
524
+ ],
525
+ };
526
+
527
+ await kernel.use({
528
+ name: 'mock-app-plugin',
529
+ type: 'app',
530
+ version: '1.0.0',
531
+ init: async (ctx) => {
532
+ ctx.registerService('app.system', appManifest);
533
+ },
534
+ });
535
+
536
+ const plugin = new ObjectQLPlugin();
537
+ await kernel.use(plugin);
538
+
539
+ // Act
540
+ await kernel.bootstrap();
541
+
542
+ // Assert - syncSchema should use tableName (single underscore) not FQN (double underscore)
543
+ const syncedNames = synced.map((s) => s.object).sort();
544
+ expect(syncedNames).toContain('sys_user');
545
+ expect(syncedNames).toContain('sys_session');
546
+ // Should NOT contain double-underscore FQN
547
+ expect(syncedNames).not.toContain('sys__user');
548
+ expect(syncedNames).not.toContain('sys__session');
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
+ });
471
762
  });
472
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,15 +281,69 @@ export class ObjectQLPlugin implements Plugin {
274
281
  continue;
275
282
  }
276
283
 
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
- });
284
+ const tableName = obj.tableName || obj.name;
285
+
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
+ }
286
347
  }
287
348
  }
288
349
 
@@ -187,6 +187,31 @@ describe('SchemaRegistry', () => {
187
187
  expect(SchemaRegistry.getObject('task')).toBeDefined();
188
188
  });
189
189
 
190
+ it('should resolve by tableName (protocol name fallback)', () => {
191
+ // Simulates ObjectSchema.create() which auto-derives tableName
192
+ // as {namespace}_{name} (single underscore)
193
+ const obj = { name: 'user', tableName: 'sys_user', namespace: 'sys', fields: {} };
194
+ SchemaRegistry.registerObject(obj as any, 'com.objectstack.system', 'sys', 'own');
195
+
196
+ // FQN is 'sys__user' (double underscore)
197
+ expect(SchemaRegistry.getObject('sys__user')).toBeDefined();
198
+
199
+ // Protocol name 'sys_user' (single underscore) should also resolve
200
+ const resolved = SchemaRegistry.getObject('sys_user');
201
+ expect(resolved).toBeDefined();
202
+ expect(resolved?.name).toBe('sys__user');
203
+ expect((resolved as any).tableName).toBe('sys_user');
204
+ });
205
+
206
+ it('should resolve by tableName for any namespace', () => {
207
+ const obj = { name: 'account', tableName: 'crm_account', namespace: 'crm', fields: {} };
208
+ SchemaRegistry.registerObject(obj as any, 'com.crm', 'crm', 'own');
209
+
210
+ // FQN: 'crm__account', tableName: 'crm_account'
211
+ expect(SchemaRegistry.getObject('crm__account')).toBeDefined();
212
+ expect(SchemaRegistry.getObject('crm_account')).toBeDefined();
213
+ });
214
+
190
215
  it('should cache merged objects', () => {
191
216
  const obj = { name: 'cached', fields: {} };
192
217
  SchemaRegistry.registerObject(obj as any, 'com.test', 'test', 'own');
package/src/registry.ts CHANGED
@@ -314,8 +314,14 @@ export class SchemaRegistry {
314
314
  }
315
315
 
316
316
  /**
317
- * Get object by name (FQN or short name with fallback scan).
318
- * For compatibility, tries exact match first, then scans for suffix match.
317
+ * Get object by name (FQN, short name, or physical table name).
318
+ *
319
+ * Resolution order:
320
+ * 1. Exact FQN match (e.g., 'crm__account')
321
+ * 2. Short name fallback (e.g., 'account' → 'crm__account')
322
+ * 3. Physical table name match (e.g., 'sys_user' → 'sys__user')
323
+ * ObjectSchema.create() auto-derives tableName as {namespace}_{name},
324
+ * which uses a single underscore — different from the FQN double underscore.
319
325
  */
320
326
  static getObject(name: string): ServiceObject | undefined {
321
327
  // Direct FQN lookup
@@ -331,6 +337,15 @@ export class SchemaRegistry {
331
337
  }
332
338
  }
333
339
 
340
+ // Fallback: match by physical table name (e.g., 'sys_user' → FQN 'sys__user')
341
+ // This bridges the gap between protocol names (SystemObjectName) and FQN.
342
+ for (const fqn of this.objectContributors.keys()) {
343
+ const resolved = this.resolveObject(fqn);
344
+ if (resolved?.tableName === name) {
345
+ return resolved;
346
+ }
347
+ }
348
+
334
349
  return undefined;
335
350
  }
336
351