@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@objectstack/objectql",
3
- "version": "4.0.2",
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.2",
17
- "@objectstack/spec": "4.0.2",
18
- "@objectstack/types": "4.0.2"
16
+ "@objectstack/core": "4.0.3",
17
+ "@objectstack/spec": "4.0.3",
18
+ "@objectstack/types": "4.0.3"
19
19
  },
20
20
  "devDependencies": {
21
21
  "typescript": "^6.0.2",
22
- "vitest": "^4.1.2"
22
+ "vitest": "^4.1.4"
23
23
  },
24
24
  "scripts": {
25
25
  "build": "tsup --config ../../tsup.config.ts",
package/src/engine.ts CHANGED
@@ -1,17 +1,19 @@
1
1
  // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
2
 
3
3
  import { QueryAST, HookContext, ServiceObject } from '@objectstack/spec/data';
4
- import {
4
+ import {
5
5
  EngineQueryOptions,
6
- DataEngineInsertOptions,
7
- EngineUpdateOptions,
6
+ DataEngineInsertOptions,
7
+ EngineUpdateOptions,
8
8
  EngineDeleteOptions,
9
9
  EngineAggregateOptions,
10
- EngineCountOptions
10
+ EngineCountOptions
11
11
  } from '@objectstack/spec/data';
12
12
  import { ExecutionContext, ExecutionContextSchema } from '@objectstack/spec/kernel';
13
13
  import { DriverInterface, IDataEngine, Logger, createLogger } from '@objectstack/core';
14
14
  import { CoreServiceName } from '@objectstack/spec/system';
15
+ import { IRealtimeService, RealtimeEventPayload } from '@objectstack/spec/contracts';
16
+ import { pluralToSingular } from '@objectstack/spec/shared';
15
17
  import { SchemaRegistry } from './registry.js';
16
18
 
17
19
  export type HookHandler = (context: HookContext) => Promise<void> | void;
@@ -69,7 +71,7 @@ export class ObjectQL implements IDataEngine {
69
71
  private drivers = new Map<string, DriverInterface>();
70
72
  private defaultDriver: string | null = null;
71
73
  private logger: Logger;
72
-
74
+
73
75
  // Per-object hooks with priority support
74
76
  private hooks: Map<string, HookEntry[]> = new Map([
75
77
  ['beforeFind', []], ['afterFind', []],
@@ -86,10 +88,13 @@ export class ObjectQL implements IDataEngine {
86
88
 
87
89
  // Action registry: key = "objectName:actionName"
88
90
  private actions = new Map<string, { handler: (ctx: any) => Promise<any> | any; package?: string }>();
89
-
91
+
90
92
  // Host provided context additions (e.g. Server router)
91
93
  private hostContext: Record<string, any> = {};
92
94
 
95
+ // Realtime service for event publishing
96
+ private realtimeService?: IRealtimeService;
97
+
93
98
  constructor(hostContext: Record<string, any> = {}) {
94
99
  this.hostContext = hostContext;
95
100
  // Use provided logger or create a new one
@@ -391,7 +396,7 @@ export class ObjectQL implements IDataEngine {
391
396
  for (const item of items) {
392
397
  const itemName = item.name || item.id;
393
398
  if (itemName) {
394
- SchemaRegistry.registerItem(key, item, 'name' as any, id);
399
+ SchemaRegistry.registerItem(pluralToSingular(key), item, 'name' as any, id);
395
400
  }
396
401
  }
397
402
  }
@@ -497,7 +502,7 @@ export class ObjectQL implements IDataEngine {
497
502
  for (const item of items) {
498
503
  const itemName = item.name || item.id;
499
504
  if (itemName) {
500
- SchemaRegistry.registerItem(key, item, 'name' as any, ownerId);
505
+ SchemaRegistry.registerItem(pluralToSingular(key), item, 'name' as any, ownerId);
501
506
  }
502
507
  }
503
508
  }
@@ -514,8 +519,8 @@ export class ObjectQL implements IDataEngine {
514
519
  }
515
520
 
516
521
  this.drivers.set(driver.name, driver);
517
- this.logger.info('Registered driver', {
518
- driverName: driver.name,
522
+ this.logger.info('Registered driver', {
523
+ driverName: driver.name,
519
524
  version: driver.version
520
525
  });
521
526
 
@@ -525,6 +530,17 @@ export class ObjectQL implements IDataEngine {
525
530
  }
526
531
  }
527
532
 
533
+ /**
534
+ * Set the realtime service for publishing data change events.
535
+ * Should be called after kernel resolves the realtime service.
536
+ *
537
+ * @param service - An IRealtimeService instance for event publishing
538
+ */
539
+ setRealtimeService(service: IRealtimeService): void {
540
+ this.realtimeService = service;
541
+ this.logger.info('RealtimeService configured for data events');
542
+ }
543
+
528
544
  /**
529
545
  * Helper to get object definition
530
546
  */
@@ -594,14 +610,24 @@ export class ObjectQL implements IDataEngine {
594
610
  drivers: Array.from(this.drivers.keys())
595
611
  });
596
612
 
613
+ const failedDrivers: string[] = [];
597
614
  for (const [name, driver] of this.drivers) {
598
615
  try {
599
616
  await driver.connect();
600
617
  this.logger.info('Driver connected successfully', { driverName: name });
601
618
  } catch (e) {
619
+ failedDrivers.push(name);
602
620
  this.logger.error('Failed to connect driver', e as Error, { driverName: name });
603
621
  }
604
622
  }
623
+
624
+ if (failedDrivers.length > 0) {
625
+ this.logger.warn(
626
+ `${failedDrivers.length} of ${this.drivers.size} driver(s) failed initial connect. ` +
627
+ `Operations may recover via lazy reconnection or fail at query time.`,
628
+ { failedDrivers }
629
+ );
630
+ }
605
631
 
606
632
  this.logger.info('ObjectQL engine initialization complete');
607
633
  }
@@ -873,6 +899,42 @@ export class ObjectQL implements IDataEngine {
873
899
  hookContext.result = result;
874
900
  await this.triggerHooks('afterInsert', hookContext);
875
901
 
902
+ // Publish data.record.created event to realtime service
903
+ if (this.realtimeService) {
904
+ try {
905
+ if (Array.isArray(result)) {
906
+ // Bulk insert - publish event for each record
907
+ for (const record of result) {
908
+ const event: RealtimeEventPayload = {
909
+ type: 'data.record.created',
910
+ object,
911
+ payload: {
912
+ recordId: record.id,
913
+ after: record,
914
+ },
915
+ timestamp: new Date().toISOString(),
916
+ };
917
+ await this.realtimeService.publish(event);
918
+ }
919
+ this.logger.debug(`Published ${result.length} data.record.created events`, { object });
920
+ } else {
921
+ const event: RealtimeEventPayload = {
922
+ type: 'data.record.created',
923
+ object,
924
+ payload: {
925
+ recordId: result.id,
926
+ after: result,
927
+ },
928
+ timestamp: new Date().toISOString(),
929
+ };
930
+ await this.realtimeService.publish(event);
931
+ this.logger.debug('Published data.record.created event', { object, recordId: result.id });
932
+ }
933
+ } catch (error) {
934
+ this.logger.warn('Failed to publish data event', { object, error });
935
+ }
936
+ }
937
+
876
938
  return hookContext.result;
877
939
  } catch (e) {
878
940
  this.logger.error('Insert operation failed', e as Error, { object });
@@ -927,6 +989,29 @@ export class ObjectQL implements IDataEngine {
927
989
  hookContext.event = 'afterUpdate';
928
990
  hookContext.result = result;
929
991
  await this.triggerHooks('afterUpdate', hookContext);
992
+
993
+ // Publish data.record.updated event to realtime service
994
+ if (this.realtimeService) {
995
+ try {
996
+ const resultId = (typeof result === 'object' && result && 'id' in result) ? (result as any).id : undefined;
997
+ const recordId = String(hookContext.input.id || resultId || '');
998
+ const event: RealtimeEventPayload = {
999
+ type: 'data.record.updated',
1000
+ object,
1001
+ payload: {
1002
+ recordId,
1003
+ changes: hookContext.input.data,
1004
+ after: result,
1005
+ },
1006
+ timestamp: new Date().toISOString(),
1007
+ };
1008
+ await this.realtimeService.publish(event);
1009
+ this.logger.debug('Published data.record.updated event', { object, recordId });
1010
+ } catch (error) {
1011
+ this.logger.warn('Failed to publish data event', { object, error });
1012
+ }
1013
+ }
1014
+
930
1015
  return hookContext.result;
931
1016
  } catch (e) {
932
1017
  this.logger.error('Update operation failed', e as Error, { object });
@@ -980,6 +1065,27 @@ export class ObjectQL implements IDataEngine {
980
1065
  hookContext.event = 'afterDelete';
981
1066
  hookContext.result = result;
982
1067
  await this.triggerHooks('afterDelete', hookContext);
1068
+
1069
+ // Publish data.record.deleted event to realtime service
1070
+ if (this.realtimeService) {
1071
+ try {
1072
+ const resultId = (typeof result === 'object' && result && 'id' in result) ? (result as any).id : undefined;
1073
+ const recordId = String(hookContext.input.id || resultId || '');
1074
+ const event: RealtimeEventPayload = {
1075
+ type: 'data.record.deleted',
1076
+ object,
1077
+ payload: {
1078
+ recordId,
1079
+ },
1080
+ timestamp: new Date().toISOString(),
1081
+ };
1082
+ await this.realtimeService.publish(event);
1083
+ this.logger.debug('Published data.record.deleted event', { object, recordId });
1084
+ } catch (error) {
1085
+ this.logger.warn('Failed to publish data event', { object, error });
1086
+ }
1087
+ }
1088
+
983
1089
  return hookContext.result;
984
1090
  } catch (e) {
985
1091
  this.logger.error('Delete operation failed', e as Error, { object });
@@ -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