@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/.turbo/turbo-build.log +11 -11
- package/CHANGELOG.md +23 -0
- package/dist/index.d.mts +60 -68
- package/dist/index.d.ts +60 -68
- package/dist/index.js +336 -84
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +336 -84
- package/dist/index.mjs.map +1 -1
- package/package.json +5 -5
- package/src/engine.ts +116 -10
- package/src/plugin.integration.test.ts +245 -13
- package/src/plugin.ts +174 -46
- package/src/protocol.ts +110 -25
- package/src/registry.test.ts +27 -16
- package/src/registry.ts +34 -25
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@objectstack/objectql",
|
|
3
|
-
"version": "4.0.
|
|
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.
|
|
17
|
-
"@objectstack/spec": "4.0.
|
|
18
|
-
"@objectstack/types": "4.0.
|
|
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.
|
|
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
|
|
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
|
-
|
|
35
|
-
expect(
|
|
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
|
|
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
|
|
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
|
});
|