@objectstack/objectql 4.0.2 → 4.0.4
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 +23 -0
- package/dist/index.d.mts +72 -6
- package/dist/index.d.ts +72 -6
- package/dist/index.js +385 -43
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +385 -43
- package/dist/index.mjs.map +1 -1
- package/package.json +5 -5
- package/src/datasource-mapping.test.ts +181 -0
- package/src/engine.test.ts +15 -1
- package/src/engine.ts +253 -27
- package/src/plugin.integration.test.ts +170 -0
- package/src/plugin.ts +149 -1
- package/src/protocol.ts +107 -19
- package/src/registry.test.ts +11 -11
- package/src/registry.ts +4 -4
|
@@ -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
|
package/src/protocol.ts
CHANGED
|
@@ -2,15 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
import { ObjectStackProtocol } from '@objectstack/spec/api';
|
|
4
4
|
import { IDataEngine } from '@objectstack/core';
|
|
5
|
-
import type {
|
|
6
|
-
BatchUpdateRequest,
|
|
7
|
-
BatchUpdateResponse,
|
|
5
|
+
import type {
|
|
6
|
+
BatchUpdateRequest,
|
|
7
|
+
BatchUpdateResponse,
|
|
8
8
|
UpdateManyDataRequest,
|
|
9
9
|
DeleteManyDataRequest
|
|
10
10
|
} from '@objectstack/spec/api';
|
|
11
11
|
import type { MetadataCacheRequest, MetadataCacheResponse, ServiceInfo, ApiRoutes, WellKnownCapabilities } from '@objectstack/spec/api';
|
|
12
12
|
import type { IFeedService } from '@objectstack/spec/contracts';
|
|
13
13
|
import { parseFilterAST, isFilterAST } from '@objectstack/spec/data';
|
|
14
|
+
import { PLURAL_TO_SINGULAR, SINGULAR_TO_PLURAL } from '@objectstack/spec/shared';
|
|
14
15
|
|
|
15
16
|
// We import SchemaRegistry directly since this class lives in the same package
|
|
16
17
|
import { SchemaRegistry } from './registry.js';
|
|
@@ -180,18 +181,31 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
|
|
|
180
181
|
}
|
|
181
182
|
|
|
182
183
|
async getMetaTypes() {
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
184
|
+
const schemaTypes = SchemaRegistry.getRegisteredTypes();
|
|
185
|
+
|
|
186
|
+
// Also include types from MetadataService (runtime-registered: agent, tool, etc.)
|
|
187
|
+
let runtimeTypes: string[] = [];
|
|
188
|
+
try {
|
|
189
|
+
const services = this.getServicesRegistry?.();
|
|
190
|
+
const metadataService = services?.get('metadata');
|
|
191
|
+
if (metadataService && typeof metadataService.getRegisteredTypes === 'function') {
|
|
192
|
+
runtimeTypes = await metadataService.getRegisteredTypes();
|
|
193
|
+
}
|
|
194
|
+
} catch {
|
|
195
|
+
// MetadataService not available
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const allTypes = Array.from(new Set([...schemaTypes, ...runtimeTypes]));
|
|
199
|
+
return { types: allTypes };
|
|
186
200
|
}
|
|
187
201
|
|
|
188
202
|
async getMetaItems(request: { type: string; packageId?: string }) {
|
|
189
203
|
const { packageId } = request;
|
|
190
204
|
let items = SchemaRegistry.listItems(request.type, packageId);
|
|
191
|
-
// Normalize singular/plural
|
|
205
|
+
// Normalize singular/plural using explicit mapping
|
|
192
206
|
if (items.length === 0) {
|
|
193
|
-
const alt = request.type
|
|
194
|
-
items = SchemaRegistry.listItems(alt, packageId);
|
|
207
|
+
const alt = PLURAL_TO_SINGULAR[request.type] ?? SINGULAR_TO_PLURAL[request.type];
|
|
208
|
+
if (alt) items = SchemaRegistry.listItems(alt, packageId);
|
|
195
209
|
}
|
|
196
210
|
|
|
197
211
|
// Fallback to database if registry is empty for this type
|
|
@@ -212,8 +226,9 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
|
|
|
212
226
|
return data;
|
|
213
227
|
});
|
|
214
228
|
} else {
|
|
215
|
-
// Try alternate type name in DB
|
|
216
|
-
const alt = request.type
|
|
229
|
+
// Try alternate type name in DB using explicit mapping
|
|
230
|
+
const alt = PLURAL_TO_SINGULAR[request.type] ?? SINGULAR_TO_PLURAL[request.type];
|
|
231
|
+
if (alt) {
|
|
217
232
|
const altRecords = await this.engine.find('sys_metadata', {
|
|
218
233
|
where: { type: alt, state: 'active' }
|
|
219
234
|
});
|
|
@@ -226,12 +241,48 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
|
|
|
226
241
|
return data;
|
|
227
242
|
});
|
|
228
243
|
}
|
|
244
|
+
}
|
|
229
245
|
}
|
|
230
246
|
} catch {
|
|
231
247
|
// DB not available, return registry results (empty)
|
|
232
248
|
}
|
|
233
249
|
}
|
|
234
250
|
|
|
251
|
+
// Merge with MetadataService (runtime-registered items: agents, tools, etc.)
|
|
252
|
+
try {
|
|
253
|
+
const services = this.getServicesRegistry?.();
|
|
254
|
+
const metadataService = services?.get('metadata');
|
|
255
|
+
if (metadataService && typeof metadataService.list === 'function') {
|
|
256
|
+
let runtimeItems = await metadataService.list(request.type);
|
|
257
|
+
// When filtering by packageId, only include runtime items that
|
|
258
|
+
// belong to the requested package. MetadataService.list() returns
|
|
259
|
+
// items from ALL packages, so we must filter here to respect the
|
|
260
|
+
// package scope requested by the caller (e.g., Studio sidebar).
|
|
261
|
+
if (packageId && runtimeItems && runtimeItems.length > 0) {
|
|
262
|
+
runtimeItems = runtimeItems.filter((item: any) => item?._packageId === packageId);
|
|
263
|
+
}
|
|
264
|
+
if (runtimeItems && runtimeItems.length > 0) {
|
|
265
|
+
// Merge, avoiding duplicates by name
|
|
266
|
+
const itemMap = new Map<string, any>();
|
|
267
|
+
for (const item of items) {
|
|
268
|
+
const entry = item as any;
|
|
269
|
+
if (entry && typeof entry === 'object' && 'name' in entry) {
|
|
270
|
+
itemMap.set(entry.name, entry);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
for (const item of runtimeItems) {
|
|
274
|
+
const entry = item as any;
|
|
275
|
+
if (entry && typeof entry === 'object' && 'name' in entry) {
|
|
276
|
+
itemMap.set(entry.name, entry);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
items = Array.from(itemMap.values());
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
} catch {
|
|
283
|
+
// MetadataService not available or doesn't support this type
|
|
284
|
+
}
|
|
285
|
+
|
|
235
286
|
return {
|
|
236
287
|
type: request.type,
|
|
237
288
|
items
|
|
@@ -240,10 +291,10 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
|
|
|
240
291
|
|
|
241
292
|
async getMetaItem(request: { type: string, name: string, packageId?: string }) {
|
|
242
293
|
let item = SchemaRegistry.getItem(request.type, request.name);
|
|
243
|
-
// Normalize singular/plural
|
|
294
|
+
// Normalize singular/plural using explicit mapping
|
|
244
295
|
if (item === undefined) {
|
|
245
|
-
const alt = request.type
|
|
246
|
-
item = SchemaRegistry.getItem(alt, request.name);
|
|
296
|
+
const alt = PLURAL_TO_SINGULAR[request.type] ?? SINGULAR_TO_PLURAL[request.type];
|
|
297
|
+
if (alt) item = SchemaRegistry.getItem(alt, request.name);
|
|
247
298
|
}
|
|
248
299
|
|
|
249
300
|
// Fallback to database if not in registry
|
|
@@ -259,8 +310,9 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
|
|
|
259
310
|
// Hydrate back into registry for next time
|
|
260
311
|
SchemaRegistry.registerItem(request.type, item, 'name' as any);
|
|
261
312
|
} else {
|
|
262
|
-
// Try alternate type name
|
|
263
|
-
const alt = request.type
|
|
313
|
+
// Try alternate type name using explicit mapping
|
|
314
|
+
const alt = PLURAL_TO_SINGULAR[request.type] ?? SINGULAR_TO_PLURAL[request.type];
|
|
315
|
+
if (alt) {
|
|
264
316
|
const altRecord = await this.engine.findOne('sys_metadata', {
|
|
265
317
|
where: { type: alt, name: request.name, state: 'active' }
|
|
266
318
|
});
|
|
@@ -271,12 +323,26 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
|
|
|
271
323
|
// Hydrate back into registry for next time
|
|
272
324
|
SchemaRegistry.registerItem(request.type, item, 'name' as any);
|
|
273
325
|
}
|
|
326
|
+
}
|
|
274
327
|
}
|
|
275
328
|
} catch {
|
|
276
329
|
// DB not available, return undefined
|
|
277
330
|
}
|
|
278
331
|
}
|
|
279
332
|
|
|
333
|
+
// Fallback to MetadataService for runtime-registered items (agents, tools, etc.)
|
|
334
|
+
if (item === undefined) {
|
|
335
|
+
try {
|
|
336
|
+
const services = this.getServicesRegistry?.();
|
|
337
|
+
const metadataService = services?.get('metadata');
|
|
338
|
+
if (metadataService && typeof metadataService.get === 'function') {
|
|
339
|
+
item = await metadataService.get(request.type, request.name);
|
|
340
|
+
}
|
|
341
|
+
} catch {
|
|
342
|
+
// MetadataService not available
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
280
346
|
return {
|
|
281
347
|
type: request.type,
|
|
282
348
|
name: request.name,
|
|
@@ -563,7 +629,27 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
|
|
|
563
629
|
|
|
564
630
|
async getMetaItemCached(request: { type: string, name: string, cacheRequest?: MetadataCacheRequest }): Promise<MetadataCacheResponse> {
|
|
565
631
|
try {
|
|
566
|
-
|
|
632
|
+
let item = SchemaRegistry.getItem(request.type, request.name);
|
|
633
|
+
|
|
634
|
+
// Normalize singular/plural using explicit mapping
|
|
635
|
+
if (!item) {
|
|
636
|
+
const alt = PLURAL_TO_SINGULAR[request.type] ?? SINGULAR_TO_PLURAL[request.type];
|
|
637
|
+
if (alt) item = SchemaRegistry.getItem(alt, request.name);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// Fallback to MetadataService (e.g. agents, tools registered in MetadataManager)
|
|
641
|
+
if (!item) {
|
|
642
|
+
try {
|
|
643
|
+
const services = this.getServicesRegistry?.();
|
|
644
|
+
const metadataService = services?.get('metadata');
|
|
645
|
+
if (metadataService && typeof metadataService.get === 'function') {
|
|
646
|
+
item = await metadataService.get(request.type, request.name);
|
|
647
|
+
}
|
|
648
|
+
} catch {
|
|
649
|
+
// MetadataService not available
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
567
653
|
if (!item) {
|
|
568
654
|
throw new Error(`Metadata item ${request.type}/${request.name} not found`);
|
|
569
655
|
}
|
|
@@ -984,10 +1070,12 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
|
|
|
984
1070
|
const data = typeof record.metadata === 'string'
|
|
985
1071
|
? JSON.parse(record.metadata)
|
|
986
1072
|
: record.metadata;
|
|
987
|
-
|
|
1073
|
+
// Normalize DB type to singular (DB may store legacy plural forms)
|
|
1074
|
+
const normalizedType = PLURAL_TO_SINGULAR[record.type] ?? record.type;
|
|
1075
|
+
if (normalizedType === 'object') {
|
|
988
1076
|
SchemaRegistry.registerObject(data as any, record.packageId || 'sys_metadata');
|
|
989
1077
|
} else {
|
|
990
|
-
SchemaRegistry.registerItem(
|
|
1078
|
+
SchemaRegistry.registerItem(normalizedType, data, 'name' as any);
|
|
991
1079
|
}
|
|
992
1080
|
loaded++;
|
|
993
1081
|
} catch (e) {
|
package/src/registry.test.ts
CHANGED
|
@@ -353,17 +353,17 @@ describe('SchemaRegistry', () => {
|
|
|
353
353
|
describe('Generic Metadata', () => {
|
|
354
354
|
it('should register and retrieve generic items', () => {
|
|
355
355
|
const item = { name: 'test_action', type: 'custom' };
|
|
356
|
-
SchemaRegistry.registerItem('
|
|
357
|
-
|
|
358
|
-
const retrieved = SchemaRegistry.getItem('
|
|
356
|
+
SchemaRegistry.registerItem('action', item, 'name', 'com.pkg');
|
|
357
|
+
|
|
358
|
+
const retrieved = SchemaRegistry.getItem('action', 'test_action');
|
|
359
359
|
expect(retrieved).toEqual(item);
|
|
360
360
|
});
|
|
361
361
|
|
|
362
362
|
it('should list items by type with package filter', () => {
|
|
363
|
-
SchemaRegistry.registerItem('
|
|
364
|
-
SchemaRegistry.registerItem('
|
|
365
|
-
|
|
366
|
-
const filtered = SchemaRegistry.listItems('
|
|
363
|
+
SchemaRegistry.registerItem('action', { name: 'a1' }, 'name', 'com.pkg1');
|
|
364
|
+
SchemaRegistry.registerItem('action', { name: 'a2' }, 'name', 'com.pkg2');
|
|
365
|
+
|
|
366
|
+
const filtered = SchemaRegistry.listItems('action', 'com.pkg1');
|
|
367
367
|
expect(filtered).toHaveLength(1);
|
|
368
368
|
});
|
|
369
369
|
});
|
|
@@ -396,12 +396,12 @@ describe('SchemaRegistry', () => {
|
|
|
396
396
|
describe('Reset', () => {
|
|
397
397
|
it('should clear all state', () => {
|
|
398
398
|
SchemaRegistry.registerObject({ name: 'obj', fields: {} } as any, 'com.pkg', 'pkg', 'own');
|
|
399
|
-
SchemaRegistry.registerItem('
|
|
400
|
-
|
|
399
|
+
SchemaRegistry.registerItem('action', { name: 'act' }, 'name');
|
|
400
|
+
|
|
401
401
|
SchemaRegistry.reset();
|
|
402
|
-
|
|
402
|
+
|
|
403
403
|
expect(SchemaRegistry.getAllObjects()).toHaveLength(0);
|
|
404
|
-
expect(SchemaRegistry.listItems('
|
|
404
|
+
expect(SchemaRegistry.listItems('action')).toHaveLength(0);
|
|
405
405
|
});
|
|
406
406
|
});
|
|
407
407
|
|
package/src/registry.ts
CHANGED
|
@@ -485,7 +485,7 @@ export class SchemaRegistry {
|
|
|
485
485
|
if (type === 'object') {
|
|
486
486
|
return ObjectSchema.parse(item);
|
|
487
487
|
}
|
|
488
|
-
if (type === '
|
|
488
|
+
if (type === 'app') {
|
|
489
489
|
return AppSchema.parse(item);
|
|
490
490
|
}
|
|
491
491
|
if (type === 'package') {
|
|
@@ -664,15 +664,15 @@ export class SchemaRegistry {
|
|
|
664
664
|
// ==========================================
|
|
665
665
|
|
|
666
666
|
static registerApp(app: any, packageId?: string) {
|
|
667
|
-
this.registerItem('
|
|
667
|
+
this.registerItem('app', app, 'name', packageId);
|
|
668
668
|
}
|
|
669
669
|
|
|
670
670
|
static getApp(name: string): any {
|
|
671
|
-
return this.getItem('
|
|
671
|
+
return this.getItem('app', name);
|
|
672
672
|
}
|
|
673
673
|
|
|
674
674
|
static getAllApps(): any[] {
|
|
675
|
-
return this.listItems('
|
|
675
|
+
return this.listItems('app');
|
|
676
676
|
}
|
|
677
677
|
|
|
678
678
|
// ==========================================
|