@objectstack/objectql 4.0.2 → 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 +10 -10
- package/CHANGELOG.md +14 -0
- package/dist/index.d.mts +38 -6
- package/dist/index.d.ts +38 -6
- package/dist/index.js +292 -32
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +292 -32
- package/dist/index.mjs.map +1 -1
- package/package.json +5 -5
- package/src/engine.ts +116 -10
- package/src/plugin.integration.test.ts +170 -0
- package/src/plugin.ts +149 -1
- package/src/protocol.ts +100 -19
- package/src/registry.test.ts +11 -11
- package/src/registry.ts +4 -4
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 });
|
|
@@ -822,4 +822,174 @@ describe('ObjectQLPlugin - Metadata Service Integration', () => {
|
|
|
822
822
|
expect(singleCalls).toContain('nb__item');
|
|
823
823
|
});
|
|
824
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
|
+
});
|
|
825
995
|
});
|
package/src/plugin.ts
CHANGED
|
@@ -6,6 +6,25 @@ import { Plugin, PluginContext } from '@objectstack/core';
|
|
|
6
6
|
|
|
7
7
|
export type { Plugin, PluginContext };
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Protocol extension for DB-based metadata hydration.
|
|
11
|
+
* `loadMetaFromDb` is implemented by ObjectStackProtocolImplementation but
|
|
12
|
+
* is NOT (yet) part of the canonical ObjectStackProtocol wire-contract in
|
|
13
|
+
* `@objectstack/spec`, since it is a server-side bootstrap concern only.
|
|
14
|
+
*/
|
|
15
|
+
interface ProtocolWithDbRestore {
|
|
16
|
+
loadMetaFromDb(): Promise<{ loaded: number; errors: number }>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Type guard — checks whether the service exposes `loadMetaFromDb`. */
|
|
20
|
+
function hasLoadMetaFromDb(service: unknown): service is ProtocolWithDbRestore {
|
|
21
|
+
return (
|
|
22
|
+
typeof service === 'object' &&
|
|
23
|
+
service !== null &&
|
|
24
|
+
typeof (service as Record<string, unknown>)['loadMetaFromDb'] === 'function'
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
9
28
|
export class ObjectQLPlugin implements Plugin {
|
|
10
29
|
name = 'com.objectstack.engine.objectql';
|
|
11
30
|
type = 'objectql';
|
|
@@ -94,22 +113,47 @@ export class ObjectQLPlugin implements Plugin {
|
|
|
94
113
|
ctx.logger.debug('Discovered and registered app service (legacy)', { serviceName: name });
|
|
95
114
|
}
|
|
96
115
|
}
|
|
116
|
+
|
|
117
|
+
// Bridge realtime service from kernel service registry to ObjectQL.
|
|
118
|
+
// RealtimeServicePlugin registers as 'realtime' service during init().
|
|
119
|
+
// This enables ObjectQL to publish data change events.
|
|
120
|
+
try {
|
|
121
|
+
const realtimeService = ctx.getService('realtime');
|
|
122
|
+
if (realtimeService && typeof realtimeService === 'object' && 'publish' in realtimeService) {
|
|
123
|
+
ctx.logger.info('[ObjectQLPlugin] Bridging realtime service to ObjectQL for event publishing');
|
|
124
|
+
this.ql.setRealtimeService(realtimeService as any);
|
|
125
|
+
}
|
|
126
|
+
} catch (e: any) {
|
|
127
|
+
ctx.logger.debug('[ObjectQLPlugin] No realtime service found — data events will not be published', {
|
|
128
|
+
error: e.message,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
97
131
|
}
|
|
98
132
|
|
|
99
133
|
// Initialize drivers (calls driver.connect() which sets up persistence)
|
|
100
134
|
await this.ql?.init();
|
|
101
135
|
|
|
136
|
+
// Restore persisted metadata from sys_metadata table.
|
|
137
|
+
// This hydrates SchemaRegistry with objects/views/apps that were saved
|
|
138
|
+
// via protocol.saveMetaItem() in a previous session, ensuring custom
|
|
139
|
+
// schemas survive cold starts and redeployments.
|
|
140
|
+
await this.restoreMetadataFromDb(ctx);
|
|
141
|
+
|
|
102
142
|
// Sync all registered object schemas to database
|
|
103
143
|
// This ensures tables/collections are created or updated for every
|
|
104
144
|
// object registered by plugins (e.g., sys_user from plugin-auth).
|
|
105
145
|
await this.syncRegisteredSchemas(ctx);
|
|
106
146
|
|
|
147
|
+
// Bridge all SchemaRegistry objects to metadata service
|
|
148
|
+
// This ensures AI tools and other IMetadataService consumers can see all objects
|
|
149
|
+
await this.bridgeObjectsToMetadataService(ctx);
|
|
150
|
+
|
|
107
151
|
// Register built-in audit hooks
|
|
108
152
|
this.registerAuditHooks(ctx);
|
|
109
153
|
|
|
110
154
|
// Register tenant isolation middleware
|
|
111
155
|
this.registerTenantMiddleware(ctx);
|
|
112
|
-
|
|
156
|
+
|
|
113
157
|
ctx.logger.info('ObjectQL engine started', {
|
|
114
158
|
driversRegistered: this.ql?.['drivers']?.size || 0,
|
|
115
159
|
objectsRegistered: this.ql?.registry?.getAllObjects?.()?.length || 0
|
|
@@ -332,6 +376,110 @@ export class ObjectQLPlugin implements Plugin {
|
|
|
332
376
|
}
|
|
333
377
|
}
|
|
334
378
|
|
|
379
|
+
/**
|
|
380
|
+
* Restore persisted metadata from the database (sys_metadata) on startup.
|
|
381
|
+
*
|
|
382
|
+
* Calls `protocol.loadMetaFromDb()` to bulk-load all active metadata
|
|
383
|
+
* records (objects, views, apps, etc.) into the in-memory SchemaRegistry.
|
|
384
|
+
* This closes the persistence loop so that user-created schemas survive
|
|
385
|
+
* kernel cold starts and redeployments.
|
|
386
|
+
*
|
|
387
|
+
* Gracefully degrades when:
|
|
388
|
+
* - The protocol service is unavailable (e.g., in-memory-only mode).
|
|
389
|
+
* - `loadMetaFromDb` is not implemented by the protocol shim.
|
|
390
|
+
* - The underlying driver/table does not exist yet (first-run scenario).
|
|
391
|
+
*/
|
|
392
|
+
private async restoreMetadataFromDb(ctx: PluginContext): Promise<void> {
|
|
393
|
+
// Phase 1: Resolve protocol service (separate from DB I/O for clearer diagnostics)
|
|
394
|
+
let protocol: ProtocolWithDbRestore;
|
|
395
|
+
try {
|
|
396
|
+
const service = ctx.getService('protocol');
|
|
397
|
+
if (!service || !hasLoadMetaFromDb(service)) {
|
|
398
|
+
ctx.logger.debug('Protocol service does not support loadMetaFromDb, skipping DB restore');
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
protocol = service;
|
|
402
|
+
} catch (e: unknown) {
|
|
403
|
+
ctx.logger.debug('Protocol service unavailable, skipping DB restore', {
|
|
404
|
+
error: e instanceof Error ? e.message : String(e),
|
|
405
|
+
});
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Phase 2: DB hydration (loads into SchemaRegistry)
|
|
410
|
+
try {
|
|
411
|
+
const { loaded, errors } = await protocol.loadMetaFromDb();
|
|
412
|
+
|
|
413
|
+
if (loaded > 0 || errors > 0) {
|
|
414
|
+
ctx.logger.info('Metadata restored from database to SchemaRegistry', { loaded, errors });
|
|
415
|
+
} else {
|
|
416
|
+
ctx.logger.debug('No persisted metadata found in database');
|
|
417
|
+
}
|
|
418
|
+
} catch (e: unknown) {
|
|
419
|
+
// Non-fatal: first-run or in-memory driver may not have sys_metadata yet
|
|
420
|
+
ctx.logger.debug('DB metadata restore failed (non-fatal)', {
|
|
421
|
+
error: e instanceof Error ? e.message : String(e),
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Bridge all SchemaRegistry objects to the metadata service.
|
|
428
|
+
*
|
|
429
|
+
* This ensures objects registered by plugins and loaded from sys_metadata
|
|
430
|
+
* are visible to AI tools and other consumers that query IMetadataService.
|
|
431
|
+
*
|
|
432
|
+
* Runs after both restoreMetadataFromDb() and syncRegisteredSchemas() to
|
|
433
|
+
* catch all objects in the SchemaRegistry regardless of their source.
|
|
434
|
+
*/
|
|
435
|
+
private async bridgeObjectsToMetadataService(ctx: PluginContext): Promise<void> {
|
|
436
|
+
try {
|
|
437
|
+
const metadataService = ctx.getService<any>('metadata');
|
|
438
|
+
if (!metadataService || typeof metadataService.register !== 'function') {
|
|
439
|
+
ctx.logger.debug('Metadata service unavailable for bridging, skipping');
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
if (!this.ql?.registry) {
|
|
444
|
+
ctx.logger.debug('SchemaRegistry unavailable for bridging, skipping');
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const objects = this.ql.registry.getAllObjects();
|
|
449
|
+
let bridged = 0;
|
|
450
|
+
|
|
451
|
+
for (const obj of objects) {
|
|
452
|
+
try {
|
|
453
|
+
// Check if object is already in metadata service to avoid duplicates
|
|
454
|
+
const existing = await metadataService.getObject(obj.name);
|
|
455
|
+
if (!existing) {
|
|
456
|
+
// Register object that exists in SchemaRegistry but not in metadata service
|
|
457
|
+
await metadataService.register('object', obj.name, obj);
|
|
458
|
+
bridged++;
|
|
459
|
+
}
|
|
460
|
+
} catch (e: unknown) {
|
|
461
|
+
ctx.logger.debug('Failed to bridge object to metadata service', {
|
|
462
|
+
object: obj.name,
|
|
463
|
+
error: e instanceof Error ? e.message : String(e),
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
if (bridged > 0) {
|
|
469
|
+
ctx.logger.info('Bridged objects from SchemaRegistry to metadata service', {
|
|
470
|
+
count: bridged,
|
|
471
|
+
total: objects.length
|
|
472
|
+
});
|
|
473
|
+
} else {
|
|
474
|
+
ctx.logger.debug('No objects needed bridging (all already in metadata service)');
|
|
475
|
+
}
|
|
476
|
+
} catch (e: unknown) {
|
|
477
|
+
ctx.logger.debug('Failed to bridge objects to metadata service', {
|
|
478
|
+
error: e instanceof Error ? e.message : String(e),
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
335
483
|
/**
|
|
336
484
|
* Load metadata from external metadata service into ObjectQL registry
|
|
337
485
|
* This enables ObjectQL to use file-based or remote metadata
|