@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/src/plugin.ts CHANGED
@@ -1,12 +1,30 @@
1
1
  // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
2
 
3
3
  import { ObjectQL } from './engine.js';
4
- import { MetadataFacade } from './metadata-facade.js';
5
4
  import { ObjectStackProtocolImplementation } from './protocol.js';
6
5
  import { Plugin, PluginContext } from '@objectstack/core';
7
6
 
8
7
  export type { Plugin, PluginContext };
9
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
+
10
28
  export class ObjectQLPlugin implements Plugin {
11
29
  name = 'com.objectstack.engine.objectql';
12
30
  type = 'objectql';
@@ -33,45 +51,24 @@ export class ObjectQLPlugin implements Plugin {
33
51
 
34
52
  // Register as provider for Core Kernel Services
35
53
  ctx.registerService('objectql', this.ql);
36
-
37
- // Register MetadataFacade as metadata service (unless external service exists)
38
- let hasMetadata = false;
39
- let metadataProvider = 'objectql';
40
- try {
41
- if (ctx.getService('metadata')) {
42
- hasMetadata = true;
43
- metadataProvider = 'external';
44
- }
45
- } catch (e: any) {
46
- // Ignore errors during check (e.g. "Service is async")
47
- }
48
54
 
49
- if (!hasMetadata) {
50
- try {
51
- const metadataFacade = new MetadataFacade();
52
- ctx.registerService('metadata', metadataFacade);
53
- ctx.logger.info('MetadataFacade registered as metadata service', {
54
- mode: 'in-memory',
55
- features: ['registry', 'fast-lookup']
56
- });
57
- } catch (e: any) {
58
- // Ignore if already registered (race condition or async mis-detection)
59
- if (!e.message?.includes('already registered')) {
60
- throw e;
61
- }
62
- }
63
- } else {
64
- ctx.logger.info('External metadata service detected', {
65
- provider: metadataProvider,
66
- mode: 'will-sync-in-start-phase'
67
- });
68
- }
69
-
70
55
  ctx.registerService('data', this.ql); // ObjectQL implements IDataEngine
71
-
72
- ctx.logger.info('ObjectQL engine registered', {
73
- services: ['objectql', 'data'],
74
- metadataProvider: metadataProvider
56
+
57
+ // Register manifest service for direct app/package registration.
58
+ // Plugins call ctx.getService('manifest').register(manifestData)
59
+ // instead of the legacy ctx.registerService('app.<id>', manifestData) convention.
60
+ const ql = this.ql;
61
+ ctx.registerService('manifest', {
62
+ register: (manifest: any) => {
63
+ ql.registerApp(manifest);
64
+ ctx.logger.debug('Manifest registered via manifest service', {
65
+ id: manifest.id || manifest.name
66
+ });
67
+ }
68
+ });
69
+
70
+ ctx.logger.info('ObjectQL engine registered', {
71
+ services: ['objectql', 'data', 'manifest'],
75
72
  });
76
73
 
77
74
  // Register Protocol Implementation
@@ -86,16 +83,14 @@ export class ObjectQLPlugin implements Plugin {
86
83
 
87
84
  start = async (ctx: PluginContext) => {
88
85
  ctx.logger.info('ObjectQL engine starting...');
89
-
90
- // Check if we should load from external metadata service
86
+
87
+ // Sync from external metadata service (e.g. MetadataPlugin) if available
91
88
  try {
92
89
  const metadataService = ctx.getService('metadata') as any;
93
- // Only sync if metadata service is external (not our own MetadataFacade)
94
- if (metadataService && !(metadataService instanceof MetadataFacade) && this.ql) {
90
+ if (metadataService && typeof metadataService.loadMany === 'function' && this.ql) {
95
91
  await this.loadMetadataFromService(metadataService, ctx);
96
92
  }
97
93
  } catch (e: any) {
98
- // No external metadata service or error accessing it
99
94
  ctx.logger.debug('No external metadata service to sync from');
100
95
  }
101
96
 
@@ -109,27 +104,56 @@ export class ObjectQLPlugin implements Plugin {
109
104
  ctx.logger.debug('Discovered and registered driver service', { serviceName: name });
110
105
  }
111
106
  if (name.startsWith('app.')) {
112
- // Register App
107
+ // Legacy fallback: discover app.* services (DEPRECATED)
108
+ ctx.logger.warn(
109
+ `[DEPRECATED] Service "${name}" uses legacy app.* convention. ` +
110
+ `Migrate to ctx.getService('manifest').register(data).`
111
+ );
113
112
  this.ql.registerApp(service); // service is Manifest
114
- ctx.logger.debug('Discovered and registered app service', { serviceName: name });
113
+ ctx.logger.debug('Discovered and registered app service (legacy)', { serviceName: name });
114
+ }
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);
115
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
+ });
116
130
  }
117
131
  }
118
132
 
119
133
  // Initialize drivers (calls driver.connect() which sets up persistence)
120
134
  await this.ql?.init();
121
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
+
122
142
  // Sync all registered object schemas to database
123
143
  // This ensures tables/collections are created or updated for every
124
144
  // object registered by plugins (e.g., sys_user from plugin-auth).
125
145
  await this.syncRegisteredSchemas(ctx);
126
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
+
127
151
  // Register built-in audit hooks
128
152
  this.registerAuditHooks(ctx);
129
153
 
130
154
  // Register tenant isolation middleware
131
155
  this.registerTenantMiddleware(ctx);
132
-
156
+
133
157
  ctx.logger.info('ObjectQL engine started', {
134
158
  driversRegistered: this.ql?.['drivers']?.size || 0,
135
159
  objectsRegistered: this.ql?.registry?.getAllObjects?.()?.length || 0
@@ -352,6 +376,110 @@ export class ObjectQLPlugin implements Plugin {
352
376
  }
353
377
  }
354
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
+
355
483
  /**
356
484
  * Load metadata from external metadata service into ObjectQL registry
357
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,24 +181,40 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
180
181
  }
181
182
 
182
183
  async getMetaTypes() {
183
- return {
184
- types: SchemaRegistry.getRegisteredTypes()
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
- async getMetaItems(request: { type: string }) {
189
- let items = SchemaRegistry.listItems(request.type);
190
- // Normalize singular/plural: REST uses singular ('app') but registry may store as plural ('apps')
202
+ async getMetaItems(request: { type: string; packageId?: string }) {
203
+ const { packageId } = request;
204
+ let items = SchemaRegistry.listItems(request.type, packageId);
205
+ // Normalize singular/plural using explicit mapping
191
206
  if (items.length === 0) {
192
- const alt = request.type.endsWith('s') ? request.type.slice(0, -1) : request.type + 's';
193
- items = SchemaRegistry.listItems(alt);
207
+ const alt = PLURAL_TO_SINGULAR[request.type] ?? SINGULAR_TO_PLURAL[request.type];
208
+ if (alt) items = SchemaRegistry.listItems(alt, packageId);
194
209
  }
195
210
 
196
211
  // Fallback to database if registry is empty for this type
197
212
  if (items.length === 0) {
198
213
  try {
214
+ const whereClause: any = { type: request.type, state: 'active' };
215
+ if (packageId) whereClause._packageId = packageId;
199
216
  const allRecords = await this.engine.find('sys_metadata', {
200
- where: { type: request.type, state: 'active' }
217
+ where: whereClause
201
218
  });
202
219
  if (allRecords && allRecords.length > 0) {
203
220
  items = allRecords.map((record: any) => {
@@ -209,8 +226,9 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
209
226
  return data;
210
227
  });
211
228
  } else {
212
- // Try alternate type name in DB
213
- const alt = request.type.endsWith('s') ? request.type.slice(0, -1) : request.type + 's';
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) {
214
232
  const altRecords = await this.engine.find('sys_metadata', {
215
233
  where: { type: alt, state: 'active' }
216
234
  });
@@ -223,24 +241,53 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
223
241
  return data;
224
242
  });
225
243
  }
244
+ }
226
245
  }
227
246
  } catch {
228
247
  // DB not available, return registry results (empty)
229
248
  }
230
249
  }
231
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
+ const runtimeItems = await metadataService.list(request.type);
257
+ if (runtimeItems && runtimeItems.length > 0) {
258
+ // Merge, avoiding duplicates by name
259
+ const itemMap = new Map<string, any>();
260
+ for (const item of items) {
261
+ const entry = item as any;
262
+ if (entry && typeof entry === 'object' && 'name' in entry) {
263
+ itemMap.set(entry.name, entry);
264
+ }
265
+ }
266
+ for (const item of runtimeItems) {
267
+ const entry = item as any;
268
+ if (entry && typeof entry === 'object' && 'name' in entry) {
269
+ itemMap.set(entry.name, entry);
270
+ }
271
+ }
272
+ items = Array.from(itemMap.values());
273
+ }
274
+ }
275
+ } catch {
276
+ // MetadataService not available or doesn't support this type
277
+ }
278
+
232
279
  return {
233
280
  type: request.type,
234
281
  items
235
282
  };
236
283
  }
237
284
 
238
- async getMetaItem(request: { type: string, name: string }) {
285
+ async getMetaItem(request: { type: string, name: string, packageId?: string }) {
239
286
  let item = SchemaRegistry.getItem(request.type, request.name);
240
- // Normalize singular/plural
287
+ // Normalize singular/plural using explicit mapping
241
288
  if (item === undefined) {
242
- const alt = request.type.endsWith('s') ? request.type.slice(0, -1) : request.type + 's';
243
- item = SchemaRegistry.getItem(alt, request.name);
289
+ const alt = PLURAL_TO_SINGULAR[request.type] ?? SINGULAR_TO_PLURAL[request.type];
290
+ if (alt) item = SchemaRegistry.getItem(alt, request.name);
244
291
  }
245
292
 
246
293
  // Fallback to database if not in registry
@@ -256,8 +303,9 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
256
303
  // Hydrate back into registry for next time
257
304
  SchemaRegistry.registerItem(request.type, item, 'name' as any);
258
305
  } else {
259
- // Try alternate type name
260
- const alt = request.type.endsWith('s') ? request.type.slice(0, -1) : request.type + 's';
306
+ // Try alternate type name using explicit mapping
307
+ const alt = PLURAL_TO_SINGULAR[request.type] ?? SINGULAR_TO_PLURAL[request.type];
308
+ if (alt) {
261
309
  const altRecord = await this.engine.findOne('sys_metadata', {
262
310
  where: { type: alt, name: request.name, state: 'active' }
263
311
  });
@@ -268,12 +316,26 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
268
316
  // Hydrate back into registry for next time
269
317
  SchemaRegistry.registerItem(request.type, item, 'name' as any);
270
318
  }
319
+ }
271
320
  }
272
321
  } catch {
273
322
  // DB not available, return undefined
274
323
  }
275
324
  }
276
325
 
326
+ // Fallback to MetadataService for runtime-registered items (agents, tools, etc.)
327
+ if (item === undefined) {
328
+ try {
329
+ const services = this.getServicesRegistry?.();
330
+ const metadataService = services?.get('metadata');
331
+ if (metadataService && typeof metadataService.get === 'function') {
332
+ item = await metadataService.get(request.type, request.name);
333
+ }
334
+ } catch {
335
+ // MetadataService not available
336
+ }
337
+ }
338
+
277
339
  return {
278
340
  type: request.type,
279
341
  name: request.name,
@@ -481,10 +543,11 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
481
543
  }
482
544
 
483
545
  const records = await this.engine.find(request.object, options);
546
+ // Spec: FindDataResponseSchema — only `records` is returned.
547
+ // OData `value` adaptation (if needed) is handled in the HTTP dispatch layer.
484
548
  return {
485
549
  object: request.object,
486
- value: records, // OData compatibility
487
- records, // Legacy
550
+ records,
488
551
  total: records.length,
489
552
  hasMore: false
490
553
  };
@@ -559,7 +622,27 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
559
622
 
560
623
  async getMetaItemCached(request: { type: string, name: string, cacheRequest?: MetadataCacheRequest }): Promise<MetadataCacheResponse> {
561
624
  try {
562
- const item = SchemaRegistry.getItem(request.type, request.name);
625
+ let item = SchemaRegistry.getItem(request.type, request.name);
626
+
627
+ // Normalize singular/plural using explicit mapping
628
+ if (!item) {
629
+ const alt = PLURAL_TO_SINGULAR[request.type] ?? SINGULAR_TO_PLURAL[request.type];
630
+ if (alt) item = SchemaRegistry.getItem(alt, request.name);
631
+ }
632
+
633
+ // Fallback to MetadataService (e.g. agents, tools registered in MetadataManager)
634
+ if (!item) {
635
+ try {
636
+ const services = this.getServicesRegistry?.();
637
+ const metadataService = services?.get('metadata');
638
+ if (metadataService && typeof metadataService.get === 'function') {
639
+ item = await metadataService.get(request.type, request.name);
640
+ }
641
+ } catch {
642
+ // MetadataService not available
643
+ }
644
+ }
645
+
563
646
  if (!item) {
564
647
  throw new Error(`Metadata item ${request.type}/${request.name} not found`);
565
648
  }
@@ -980,10 +1063,12 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
980
1063
  const data = typeof record.metadata === 'string'
981
1064
  ? JSON.parse(record.metadata)
982
1065
  : record.metadata;
983
- if (record.type === 'object') {
1066
+ // Normalize DB type to singular (DB may store legacy plural forms)
1067
+ const normalizedType = PLURAL_TO_SINGULAR[record.type] ?? record.type;
1068
+ if (normalizedType === 'object') {
984
1069
  SchemaRegistry.registerObject(data as any, record.packageId || 'sys_metadata');
985
1070
  } else {
986
- SchemaRegistry.registerItem(record.type, data, 'name' as any);
1071
+ SchemaRegistry.registerItem(normalizedType, data, 'name' as any);
987
1072
  }
988
1073
  loaded++;
989
1074
  } catch (e) {
@@ -53,11 +53,15 @@ describe('SchemaRegistry', () => {
53
53
  }).not.toThrow();
54
54
  });
55
55
 
56
- it('should throw on namespace conflict', () => {
57
- SchemaRegistry.registerNamespace('crm', 'com.example.crm');
58
- expect(() => {
59
- SchemaRegistry.registerNamespace('crm', 'com.other.crm');
60
- }).toThrow(/already registered/);
56
+ it('should allow multiple packages to share a namespace', () => {
57
+ SchemaRegistry.registerNamespace('sys', 'com.objectstack.auth');
58
+ SchemaRegistry.registerNamespace('sys', 'com.objectstack.security');
59
+ // First registered package returned for backwards compat
60
+ expect(SchemaRegistry.getNamespaceOwner('sys')).toBe('com.objectstack.auth');
61
+ expect(SchemaRegistry.getNamespaceOwners('sys')).toEqual([
62
+ 'com.objectstack.auth',
63
+ 'com.objectstack.security',
64
+ ]);
61
65
  });
62
66
 
63
67
  it('should unregister namespace', () => {
@@ -65,6 +69,13 @@ describe('SchemaRegistry', () => {
65
69
  SchemaRegistry.unregisterNamespace('crm', 'com.example.crm');
66
70
  expect(SchemaRegistry.getNamespaceOwner('crm')).toBeUndefined();
67
71
  });
72
+
73
+ it('should keep namespace when one of multiple packages unregisters', () => {
74
+ SchemaRegistry.registerNamespace('sys', 'com.objectstack.auth');
75
+ SchemaRegistry.registerNamespace('sys', 'com.objectstack.setup');
76
+ SchemaRegistry.unregisterNamespace('sys', 'com.objectstack.setup');
77
+ expect(SchemaRegistry.getNamespaceOwner('sys')).toBe('com.objectstack.auth');
78
+ });
68
79
  });
69
80
 
70
81
  // ==========================================
@@ -342,17 +353,17 @@ describe('SchemaRegistry', () => {
342
353
  describe('Generic Metadata', () => {
343
354
  it('should register and retrieve generic items', () => {
344
355
  const item = { name: 'test_action', type: 'custom' };
345
- SchemaRegistry.registerItem('actions', item, 'name', 'com.pkg');
346
-
347
- const retrieved = SchemaRegistry.getItem('actions', 'test_action');
356
+ SchemaRegistry.registerItem('action', item, 'name', 'com.pkg');
357
+
358
+ const retrieved = SchemaRegistry.getItem('action', 'test_action');
348
359
  expect(retrieved).toEqual(item);
349
360
  });
350
361
 
351
362
  it('should list items by type with package filter', () => {
352
- SchemaRegistry.registerItem('actions', { name: 'a1' }, 'name', 'com.pkg1');
353
- SchemaRegistry.registerItem('actions', { name: 'a2' }, 'name', 'com.pkg2');
354
-
355
- const filtered = SchemaRegistry.listItems('actions', 'com.pkg1');
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');
356
367
  expect(filtered).toHaveLength(1);
357
368
  });
358
369
  });
@@ -385,12 +396,12 @@ describe('SchemaRegistry', () => {
385
396
  describe('Reset', () => {
386
397
  it('should clear all state', () => {
387
398
  SchemaRegistry.registerObject({ name: 'obj', fields: {} } as any, 'com.pkg', 'pkg', 'own');
388
- SchemaRegistry.registerItem('actions', { name: 'act' }, 'name');
389
-
399
+ SchemaRegistry.registerItem('action', { name: 'act' }, 'name');
400
+
390
401
  SchemaRegistry.reset();
391
-
402
+
392
403
  expect(SchemaRegistry.getAllObjects()).toHaveLength(0);
393
- expect(SchemaRegistry.listItems('actions')).toHaveLength(0);
404
+ expect(SchemaRegistry.listItems('action')).toHaveLength(0);
394
405
  });
395
406
  });
396
407