@objectstack/objectql 4.0.1 → 4.0.3

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": "4.0.1",
3
+ "version": "4.0.3",
4
4
  "license": "Apache-2.0",
5
5
  "description": "Isomorphic ObjectQL Engine for ObjectStack",
6
6
  "main": "dist/index.js",
@@ -13,13 +13,13 @@
13
13
  }
14
14
  },
15
15
  "dependencies": {
16
- "@objectstack/core": "4.0.1",
17
- "@objectstack/spec": "4.0.1",
18
- "@objectstack/types": "4.0.1"
16
+ "@objectstack/core": "4.0.3",
17
+ "@objectstack/spec": "4.0.3",
18
+ "@objectstack/types": "4.0.3"
19
19
  },
20
20
  "devDependencies": {
21
21
  "typescript": "^6.0.2",
22
- "vitest": "^4.1.2"
22
+ "vitest": "^4.1.4"
23
23
  },
24
24
  "scripts": {
25
25
  "build": "tsup --config ../../tsup.config.ts",
package/src/engine.ts CHANGED
@@ -1,17 +1,19 @@
1
1
  // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
2
 
3
3
  import { QueryAST, HookContext, ServiceObject } from '@objectstack/spec/data';
4
- import {
4
+ import {
5
5
  EngineQueryOptions,
6
- DataEngineInsertOptions,
7
- EngineUpdateOptions,
6
+ DataEngineInsertOptions,
7
+ EngineUpdateOptions,
8
8
  EngineDeleteOptions,
9
9
  EngineAggregateOptions,
10
- EngineCountOptions
10
+ EngineCountOptions
11
11
  } from '@objectstack/spec/data';
12
12
  import { ExecutionContext, ExecutionContextSchema } from '@objectstack/spec/kernel';
13
13
  import { DriverInterface, IDataEngine, Logger, createLogger } from '@objectstack/core';
14
14
  import { CoreServiceName } from '@objectstack/spec/system';
15
+ import { IRealtimeService, RealtimeEventPayload } from '@objectstack/spec/contracts';
16
+ import { pluralToSingular } from '@objectstack/spec/shared';
15
17
  import { SchemaRegistry } from './registry.js';
16
18
 
17
19
  export type HookHandler = (context: HookContext) => Promise<void> | void;
@@ -69,7 +71,7 @@ export class ObjectQL implements IDataEngine {
69
71
  private drivers = new Map<string, DriverInterface>();
70
72
  private defaultDriver: string | null = null;
71
73
  private logger: Logger;
72
-
74
+
73
75
  // Per-object hooks with priority support
74
76
  private hooks: Map<string, HookEntry[]> = new Map([
75
77
  ['beforeFind', []], ['afterFind', []],
@@ -86,10 +88,13 @@ export class ObjectQL implements IDataEngine {
86
88
 
87
89
  // Action registry: key = "objectName:actionName"
88
90
  private actions = new Map<string, { handler: (ctx: any) => Promise<any> | any; package?: string }>();
89
-
91
+
90
92
  // Host provided context additions (e.g. Server router)
91
93
  private hostContext: Record<string, any> = {};
92
94
 
95
+ // Realtime service for event publishing
96
+ private realtimeService?: IRealtimeService;
97
+
93
98
  constructor(hostContext: Record<string, any> = {}) {
94
99
  this.hostContext = hostContext;
95
100
  // Use provided logger or create a new one
@@ -391,7 +396,7 @@ export class ObjectQL implements IDataEngine {
391
396
  for (const item of items) {
392
397
  const itemName = item.name || item.id;
393
398
  if (itemName) {
394
- SchemaRegistry.registerItem(key, item, 'name' as any, id);
399
+ SchemaRegistry.registerItem(pluralToSingular(key), item, 'name' as any, id);
395
400
  }
396
401
  }
397
402
  }
@@ -497,7 +502,7 @@ export class ObjectQL implements IDataEngine {
497
502
  for (const item of items) {
498
503
  const itemName = item.name || item.id;
499
504
  if (itemName) {
500
- SchemaRegistry.registerItem(key, item, 'name' as any, ownerId);
505
+ SchemaRegistry.registerItem(pluralToSingular(key), item, 'name' as any, ownerId);
501
506
  }
502
507
  }
503
508
  }
@@ -514,8 +519,8 @@ export class ObjectQL implements IDataEngine {
514
519
  }
515
520
 
516
521
  this.drivers.set(driver.name, driver);
517
- this.logger.info('Registered driver', {
518
- driverName: driver.name,
522
+ this.logger.info('Registered driver', {
523
+ driverName: driver.name,
519
524
  version: driver.version
520
525
  });
521
526
 
@@ -525,6 +530,17 @@ export class ObjectQL implements IDataEngine {
525
530
  }
526
531
  }
527
532
 
533
+ /**
534
+ * Set the realtime service for publishing data change events.
535
+ * Should be called after kernel resolves the realtime service.
536
+ *
537
+ * @param service - An IRealtimeService instance for event publishing
538
+ */
539
+ setRealtimeService(service: IRealtimeService): void {
540
+ this.realtimeService = service;
541
+ this.logger.info('RealtimeService configured for data events');
542
+ }
543
+
528
544
  /**
529
545
  * Helper to get object definition
530
546
  */
@@ -594,14 +610,24 @@ export class ObjectQL implements IDataEngine {
594
610
  drivers: Array.from(this.drivers.keys())
595
611
  });
596
612
 
613
+ const failedDrivers: string[] = [];
597
614
  for (const [name, driver] of this.drivers) {
598
615
  try {
599
616
  await driver.connect();
600
617
  this.logger.info('Driver connected successfully', { driverName: name });
601
618
  } catch (e) {
619
+ failedDrivers.push(name);
602
620
  this.logger.error('Failed to connect driver', e as Error, { driverName: name });
603
621
  }
604
622
  }
623
+
624
+ if (failedDrivers.length > 0) {
625
+ this.logger.warn(
626
+ `${failedDrivers.length} of ${this.drivers.size} driver(s) failed initial connect. ` +
627
+ `Operations may recover via lazy reconnection or fail at query time.`,
628
+ { failedDrivers }
629
+ );
630
+ }
605
631
 
606
632
  this.logger.info('ObjectQL engine initialization complete');
607
633
  }
@@ -873,6 +899,42 @@ export class ObjectQL implements IDataEngine {
873
899
  hookContext.result = result;
874
900
  await this.triggerHooks('afterInsert', hookContext);
875
901
 
902
+ // Publish data.record.created event to realtime service
903
+ if (this.realtimeService) {
904
+ try {
905
+ if (Array.isArray(result)) {
906
+ // Bulk insert - publish event for each record
907
+ for (const record of result) {
908
+ const event: RealtimeEventPayload = {
909
+ type: 'data.record.created',
910
+ object,
911
+ payload: {
912
+ recordId: record.id,
913
+ after: record,
914
+ },
915
+ timestamp: new Date().toISOString(),
916
+ };
917
+ await this.realtimeService.publish(event);
918
+ }
919
+ this.logger.debug(`Published ${result.length} data.record.created events`, { object });
920
+ } else {
921
+ const event: RealtimeEventPayload = {
922
+ type: 'data.record.created',
923
+ object,
924
+ payload: {
925
+ recordId: result.id,
926
+ after: result,
927
+ },
928
+ timestamp: new Date().toISOString(),
929
+ };
930
+ await this.realtimeService.publish(event);
931
+ this.logger.debug('Published data.record.created event', { object, recordId: result.id });
932
+ }
933
+ } catch (error) {
934
+ this.logger.warn('Failed to publish data event', { object, error });
935
+ }
936
+ }
937
+
876
938
  return hookContext.result;
877
939
  } catch (e) {
878
940
  this.logger.error('Insert operation failed', e as Error, { object });
@@ -927,6 +989,29 @@ export class ObjectQL implements IDataEngine {
927
989
  hookContext.event = 'afterUpdate';
928
990
  hookContext.result = result;
929
991
  await this.triggerHooks('afterUpdate', hookContext);
992
+
993
+ // Publish data.record.updated event to realtime service
994
+ if (this.realtimeService) {
995
+ try {
996
+ const resultId = (typeof result === 'object' && result && 'id' in result) ? (result as any).id : undefined;
997
+ const recordId = String(hookContext.input.id || resultId || '');
998
+ const event: RealtimeEventPayload = {
999
+ type: 'data.record.updated',
1000
+ object,
1001
+ payload: {
1002
+ recordId,
1003
+ changes: hookContext.input.data,
1004
+ after: result,
1005
+ },
1006
+ timestamp: new Date().toISOString(),
1007
+ };
1008
+ await this.realtimeService.publish(event);
1009
+ this.logger.debug('Published data.record.updated event', { object, recordId });
1010
+ } catch (error) {
1011
+ this.logger.warn('Failed to publish data event', { object, error });
1012
+ }
1013
+ }
1014
+
930
1015
  return hookContext.result;
931
1016
  } catch (e) {
932
1017
  this.logger.error('Update operation failed', e as Error, { object });
@@ -980,6 +1065,27 @@ export class ObjectQL implements IDataEngine {
980
1065
  hookContext.event = 'afterDelete';
981
1066
  hookContext.result = result;
982
1067
  await this.triggerHooks('afterDelete', hookContext);
1068
+
1069
+ // Publish data.record.deleted event to realtime service
1070
+ if (this.realtimeService) {
1071
+ try {
1072
+ const resultId = (typeof result === 'object' && result && 'id' in result) ? (result as any).id : undefined;
1073
+ const recordId = String(hookContext.input.id || resultId || '');
1074
+ const event: RealtimeEventPayload = {
1075
+ type: 'data.record.deleted',
1076
+ object,
1077
+ payload: {
1078
+ recordId,
1079
+ },
1080
+ timestamp: new Date().toISOString(),
1081
+ };
1082
+ await this.realtimeService.publish(event);
1083
+ this.logger.debug('Published data.record.deleted event', { object, recordId });
1084
+ } catch (error) {
1085
+ this.logger.warn('Failed to publish data event', { object, error });
1086
+ }
1087
+ }
1088
+
983
1089
  return hookContext.result;
984
1090
  } catch (e) {
985
1091
  this.logger.error('Delete operation failed', e as Error, { object });
@@ -15,7 +15,7 @@ describe('ObjectQLPlugin - Metadata Service Integration', () => {
15
15
  });
16
16
 
17
17
  describe('Simple Mode (ObjectQL-only)', () => {
18
- it('should register ObjectQL as metadata service provider', async () => {
18
+ it('should register objectql, data, and protocol services', async () => {
19
19
  // Arrange
20
20
  const plugin = new ObjectQLPlugin();
21
21
  await kernel.use(plugin);
@@ -23,16 +23,15 @@ describe('ObjectQLPlugin - Metadata Service Integration', () => {
23
23
  // Act
24
24
  await kernel.bootstrap();
25
25
 
26
- // Assert
27
- const metadataService = kernel.getService('metadata');
28
- expect(metadataService).toBeDefined();
29
-
30
- // ObjectQL registers a MetadataFacade as the metadata service;
31
- // it is separate from (but backed by the same registry as) the objectql service.
26
+ // Assert — ObjectQL no longer registers metadata (kernel provides fallback)
32
27
  const objectql = kernel.getService('objectql');
33
28
  expect(objectql).toBeDefined();
34
- // metadata and objectql are distinct service instances
35
- expect(metadataService).not.toBe(objectql);
29
+ expect(kernel.getService('data')).toBeDefined();
30
+ expect(kernel.getService('protocol')).toBeDefined();
31
+ // metadata is provided by kernel's core fallback, not ObjectQL
32
+ const metadataService = kernel.getService('metadata');
33
+ expect(metadataService).toBeDefined();
34
+ expect((metadataService as any)._fallback).toBe(true);
36
35
  });
37
36
 
38
37
  it('should serve in-memory metadata definitions', async () => {
@@ -65,7 +64,7 @@ describe('ObjectQLPlugin - Metadata Service Integration', () => {
65
64
  });
66
65
 
67
66
  describe('Service Registration', () => {
68
- it('should register objectql, data, and protocol services', async () => {
67
+ it('should register manifest service', async () => {
69
68
  // Arrange
70
69
  const plugin = new ObjectQLPlugin();
71
70
  await kernel.use(plugin);
@@ -77,6 +76,7 @@ describe('ObjectQLPlugin - Metadata Service Integration', () => {
77
76
  expect(kernel.getService('objectql')).toBeDefined();
78
77
  expect(kernel.getService('data')).toBeDefined();
79
78
  expect(kernel.getService('protocol')).toBeDefined();
79
+ expect(kernel.getService('manifest')).toBeDefined();
80
80
  });
81
81
 
82
82
  it('should respect existing metadata service', async () => {
@@ -146,8 +146,71 @@ describe('ObjectQLPlugin - Metadata Service Integration', () => {
146
146
  expect(objectql.drivers?.has('mock-driver')).toBe(true);
147
147
  });
148
148
 
149
- it('should discover and register apps from kernel services', async () => {
149
+ it('should register apps via manifest service', async () => {
150
150
  // Arrange
151
+ const plugin = new ObjectQLPlugin();
152
+ await kernel.use(plugin);
153
+
154
+ // Plugin that uses the manifest service directly
155
+ await kernel.use({
156
+ name: 'mock-app-plugin',
157
+ type: 'app',
158
+ version: '1.0.0',
159
+ dependencies: ['com.objectstack.engine.objectql'],
160
+ init: async (ctx) => {
161
+ ctx.getService<{ register(m: any): void }>('manifest').register({
162
+ id: 'test-app',
163
+ name: 'test_app',
164
+ version: '1.0.0',
165
+ type: 'app',
166
+ apps: [{ name: 'Test App' }],
167
+ });
168
+ }
169
+ });
170
+
171
+ // Act
172
+ await kernel.bootstrap();
173
+
174
+ // Assert
175
+ const objectql = kernel.getService('objectql') as any;
176
+ expect(objectql.registry).toBeDefined();
177
+ const apps = objectql.registry.getAllApps();
178
+ expect(apps.some((a: any) => a.name === 'Test App')).toBe(true);
179
+ });
180
+
181
+ it('should register manifests from start() phase via manifest service', async () => {
182
+ // Arrange — simulates SetupPlugin's pattern (registers in start, not init)
183
+ const plugin = new ObjectQLPlugin();
184
+ await kernel.use(plugin);
185
+
186
+ await kernel.use({
187
+ name: 'late-registerer',
188
+ type: 'standard',
189
+ version: '1.0.0',
190
+ dependencies: ['com.objectstack.engine.objectql'],
191
+ init: async () => {},
192
+ start: async (ctx) => {
193
+ ctx.getService<{ register(m: any): void }>('manifest').register({
194
+ id: 'late-app',
195
+ name: 'late_app',
196
+ version: '1.0.0',
197
+ type: 'plugin',
198
+ apps: [{ name: 'Late App' }],
199
+ });
200
+ }
201
+ });
202
+
203
+ // Act
204
+ await kernel.bootstrap();
205
+
206
+ // Assert
207
+ const objectql = kernel.getService('objectql') as any;
208
+ const apps = objectql.registry.getAllApps();
209
+ expect(apps.some((a: any) => a.name === 'Late App')).toBe(true);
210
+ });
211
+
212
+ it('should still discover apps registered via legacy app.* convention', async () => {
213
+ // Arrange — legacy pattern for backward compatibility
151
214
  const mockApp = {
152
215
  manifest: {
153
216
  id: 'test-app',
@@ -172,9 +235,8 @@ describe('ObjectQLPlugin - Metadata Service Integration', () => {
172
235
  // Act
173
236
  await kernel.bootstrap();
174
237
 
175
- // Assert
238
+ // Assert — legacy pattern still works
176
239
  const objectql = kernel.getService('objectql') as any;
177
- // App should be registered (check via registry or apps list)
178
240
  expect(objectql.registry).toBeDefined();
179
241
  });
180
242
  });
@@ -760,4 +822,174 @@ describe('ObjectQLPlugin - Metadata Service Integration', () => {
760
822
  expect(singleCalls).toContain('nb__item');
761
823
  });
762
824
  });
825
+
826
+ describe('Cold-Start Metadata Restoration', () => {
827
+ it('should restore metadata from sys_metadata via protocol.loadMetaFromDb on start', async () => {
828
+ // Arrange — a driver whose find() returns persisted metadata records
829
+ const findCalls: Array<{ object: string; query: any }> = [];
830
+ const mockDriver = {
831
+ name: 'restore-driver',
832
+ version: '1.0.0',
833
+ connect: async () => {},
834
+ disconnect: async () => {},
835
+ find: async (object: string, query: any) => {
836
+ findCalls.push({ object, query });
837
+ if (object === 'sys_metadata') {
838
+ return [
839
+ {
840
+ id: '1',
841
+ type: 'apps',
842
+ name: 'custom_crm',
843
+ state: 'active',
844
+ metadata: JSON.stringify({ name: 'custom_crm', label: 'Custom CRM' }),
845
+ },
846
+ {
847
+ id: '2',
848
+ type: 'object',
849
+ name: 'invoice',
850
+ state: 'active',
851
+ metadata: JSON.stringify({
852
+ name: 'invoice',
853
+ label: 'Invoice',
854
+ fields: { amount: { name: 'amount', label: 'Amount', type: 'number' } },
855
+ }),
856
+ packageId: 'user_pkg',
857
+ },
858
+ ];
859
+ }
860
+ return [];
861
+ },
862
+ findOne: async () => null,
863
+ create: async (_o: string, d: any) => d,
864
+ update: async (_o: string, _i: any, d: any) => d,
865
+ delete: async () => true,
866
+ syncSchema: async () => {},
867
+ };
868
+
869
+ await kernel.use({
870
+ name: 'mock-restore-driver',
871
+ type: 'driver',
872
+ version: '1.0.0',
873
+ init: async (ctx) => {
874
+ ctx.registerService('driver.restore', mockDriver);
875
+ },
876
+ });
877
+
878
+ const plugin = new ObjectQLPlugin();
879
+ await kernel.use(plugin);
880
+
881
+ // Act
882
+ await kernel.bootstrap();
883
+
884
+ // Assert — sys_metadata should have been queried
885
+ const metaQuery = findCalls.find((c) => c.object === 'sys_metadata');
886
+ expect(metaQuery).toBeDefined();
887
+ expect(metaQuery!.query.where).toEqual({ state: 'active' });
888
+
889
+ // Assert — items should be restored into the registry
890
+ const registry = (kernel.getService('objectql') as any).registry;
891
+ expect(registry.getAllApps()).toContainEqual({
892
+ name: 'custom_crm',
893
+ label: 'Custom CRM',
894
+ });
895
+ });
896
+
897
+ it('should not throw when protocol.loadMetaFromDb fails (graceful degradation)', async () => {
898
+ // Arrange — driver that throws on find('sys_metadata')
899
+ const mockDriver = {
900
+ name: 'failing-db-driver',
901
+ version: '1.0.0',
902
+ connect: async () => {},
903
+ disconnect: async () => {},
904
+ find: async (object: string) => {
905
+ if (object === 'sys_metadata') {
906
+ throw new Error('SQLITE_ERROR: no such table: sys_metadata');
907
+ }
908
+ return [];
909
+ },
910
+ findOne: async () => null,
911
+ create: async (_o: string, d: any) => d,
912
+ update: async (_o: string, _i: any, d: any) => d,
913
+ delete: async () => true,
914
+ syncSchema: async () => {},
915
+ };
916
+
917
+ await kernel.use({
918
+ name: 'mock-fail-driver',
919
+ type: 'driver',
920
+ version: '1.0.0',
921
+ init: async (ctx) => {
922
+ ctx.registerService('driver.faildb', mockDriver);
923
+ },
924
+ });
925
+
926
+ const plugin = new ObjectQLPlugin();
927
+ await kernel.use(plugin);
928
+
929
+ // Act & Assert — should not throw
930
+ await expect(kernel.bootstrap()).resolves.not.toThrow();
931
+ });
932
+
933
+ it('should restore metadata before syncRegisteredSchemas so restored objects get table sync', async () => {
934
+ // Arrange — track the order of operations
935
+ const operations: string[] = [];
936
+ const mockDriver = {
937
+ name: 'order-driver',
938
+ version: '1.0.0',
939
+ connect: async () => {},
940
+ disconnect: async () => {},
941
+ find: async (object: string) => {
942
+ if (object === 'sys_metadata') {
943
+ operations.push('loadMetaFromDb');
944
+ return [
945
+ {
946
+ id: '1',
947
+ type: 'object',
948
+ name: 'restored_obj',
949
+ state: 'active',
950
+ metadata: JSON.stringify({
951
+ name: 'restored_obj',
952
+ label: 'Restored Object',
953
+ fields: { title: { name: 'title', label: 'Title', type: 'text' } },
954
+ }),
955
+ packageId: 'user_pkg',
956
+ },
957
+ ];
958
+ }
959
+ return [];
960
+ },
961
+ findOne: async () => null,
962
+ create: async (_o: string, d: any) => d,
963
+ update: async (_o: string, _i: any, d: any) => d,
964
+ delete: async () => true,
965
+ syncSchema: async (object: string) => {
966
+ operations.push(`syncSchema:${object}`);
967
+ },
968
+ };
969
+
970
+ await kernel.use({
971
+ name: 'mock-order-driver',
972
+ type: 'driver',
973
+ version: '1.0.0',
974
+ init: async (ctx) => {
975
+ ctx.registerService('driver.order', mockDriver);
976
+ },
977
+ });
978
+
979
+ const plugin = new ObjectQLPlugin();
980
+ await kernel.use(plugin);
981
+
982
+ // Act
983
+ await kernel.bootstrap();
984
+
985
+ // Assert — loadMetaFromDb must appear before any syncSchema call
986
+ const loadIdx = operations.indexOf('loadMetaFromDb');
987
+ expect(loadIdx).toBeGreaterThanOrEqual(0);
988
+
989
+ const firstSync = operations.findIndex((op) => op.startsWith('syncSchema:'));
990
+ if (firstSync >= 0) {
991
+ expect(loadIdx).toBeLessThan(firstSync);
992
+ }
993
+ });
994
+ });
763
995
  });