@objectstack/objectql 4.0.1 → 4.0.2

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.1",
3
+ "version": "4.0.2",
4
4
  "license": "Apache-2.0",
5
5
  "description": "Isomorphic ObjectQL Engine for ObjectStack",
6
6
  "main": "dist/index.js",
@@ -13,9 +13,9 @@
13
13
  }
14
14
  },
15
15
  "dependencies": {
16
- "@objectstack/core": "4.0.1",
17
- "@objectstack/spec": "4.0.1",
18
- "@objectstack/types": "4.0.1"
16
+ "@objectstack/core": "4.0.2",
17
+ "@objectstack/spec": "4.0.2",
18
+ "@objectstack/types": "4.0.2"
19
19
  },
20
20
  "devDependencies": {
21
21
  "typescript": "^6.0.2",
@@ -15,7 +15,7 @@ describe('ObjectQLPlugin - Metadata Service Integration', () => {
15
15
  });
16
16
 
17
17
  describe('Simple Mode (ObjectQL-only)', () => {
18
- it('should register ObjectQL as metadata service provider', async () => {
18
+ it('should register objectql, data, and protocol services', async () => {
19
19
  // Arrange
20
20
  const plugin = new ObjectQLPlugin();
21
21
  await kernel.use(plugin);
@@ -23,16 +23,15 @@ describe('ObjectQLPlugin - Metadata Service Integration', () => {
23
23
  // Act
24
24
  await kernel.bootstrap();
25
25
 
26
- // Assert
27
- const metadataService = kernel.getService('metadata');
28
- expect(metadataService).toBeDefined();
29
-
30
- // ObjectQL registers a MetadataFacade as the metadata service;
31
- // it is separate from (but backed by the same registry as) the objectql service.
26
+ // Assert — ObjectQL no longer registers metadata (kernel provides fallback)
32
27
  const objectql = kernel.getService('objectql');
33
28
  expect(objectql).toBeDefined();
34
- // metadata and objectql are distinct service instances
35
- expect(metadataService).not.toBe(objectql);
29
+ expect(kernel.getService('data')).toBeDefined();
30
+ expect(kernel.getService('protocol')).toBeDefined();
31
+ // metadata is provided by kernel's core fallback, not ObjectQL
32
+ const metadataService = kernel.getService('metadata');
33
+ expect(metadataService).toBeDefined();
34
+ expect((metadataService as any)._fallback).toBe(true);
36
35
  });
37
36
 
38
37
  it('should serve in-memory metadata definitions', async () => {
@@ -65,7 +64,7 @@ describe('ObjectQLPlugin - Metadata Service Integration', () => {
65
64
  });
66
65
 
67
66
  describe('Service Registration', () => {
68
- it('should register objectql, data, and protocol services', async () => {
67
+ it('should register manifest service', async () => {
69
68
  // Arrange
70
69
  const plugin = new ObjectQLPlugin();
71
70
  await kernel.use(plugin);
@@ -77,6 +76,7 @@ describe('ObjectQLPlugin - Metadata Service Integration', () => {
77
76
  expect(kernel.getService('objectql')).toBeDefined();
78
77
  expect(kernel.getService('data')).toBeDefined();
79
78
  expect(kernel.getService('protocol')).toBeDefined();
79
+ expect(kernel.getService('manifest')).toBeDefined();
80
80
  });
81
81
 
82
82
  it('should respect existing metadata service', async () => {
@@ -146,8 +146,71 @@ describe('ObjectQLPlugin - Metadata Service Integration', () => {
146
146
  expect(objectql.drivers?.has('mock-driver')).toBe(true);
147
147
  });
148
148
 
149
- it('should discover and register apps from kernel services', async () => {
149
+ it('should register apps via manifest service', async () => {
150
150
  // Arrange
151
+ const plugin = new ObjectQLPlugin();
152
+ await kernel.use(plugin);
153
+
154
+ // Plugin that uses the manifest service directly
155
+ await kernel.use({
156
+ name: 'mock-app-plugin',
157
+ type: 'app',
158
+ version: '1.0.0',
159
+ dependencies: ['com.objectstack.engine.objectql'],
160
+ init: async (ctx) => {
161
+ ctx.getService<{ register(m: any): void }>('manifest').register({
162
+ id: 'test-app',
163
+ name: 'test_app',
164
+ version: '1.0.0',
165
+ type: 'app',
166
+ apps: [{ name: 'Test App' }],
167
+ });
168
+ }
169
+ });
170
+
171
+ // Act
172
+ await kernel.bootstrap();
173
+
174
+ // Assert
175
+ const objectql = kernel.getService('objectql') as any;
176
+ expect(objectql.registry).toBeDefined();
177
+ const apps = objectql.registry.getAllApps();
178
+ expect(apps.some((a: any) => a.name === 'Test App')).toBe(true);
179
+ });
180
+
181
+ it('should register manifests from start() phase via manifest service', async () => {
182
+ // Arrange — simulates SetupPlugin's pattern (registers in start, not init)
183
+ const plugin = new ObjectQLPlugin();
184
+ await kernel.use(plugin);
185
+
186
+ await kernel.use({
187
+ name: 'late-registerer',
188
+ type: 'standard',
189
+ version: '1.0.0',
190
+ dependencies: ['com.objectstack.engine.objectql'],
191
+ init: async () => {},
192
+ start: async (ctx) => {
193
+ ctx.getService<{ register(m: any): void }>('manifest').register({
194
+ id: 'late-app',
195
+ name: 'late_app',
196
+ version: '1.0.0',
197
+ type: 'plugin',
198
+ apps: [{ name: 'Late App' }],
199
+ });
200
+ }
201
+ });
202
+
203
+ // Act
204
+ await kernel.bootstrap();
205
+
206
+ // Assert
207
+ const objectql = kernel.getService('objectql') as any;
208
+ const apps = objectql.registry.getAllApps();
209
+ expect(apps.some((a: any) => a.name === 'Late App')).toBe(true);
210
+ });
211
+
212
+ it('should still discover apps registered via legacy app.* convention', async () => {
213
+ // Arrange — legacy pattern for backward compatibility
151
214
  const mockApp = {
152
215
  manifest: {
153
216
  id: 'test-app',
@@ -172,9 +235,8 @@ describe('ObjectQLPlugin - Metadata Service Integration', () => {
172
235
  // Act
173
236
  await kernel.bootstrap();
174
237
 
175
- // Assert
238
+ // Assert — legacy pattern still works
176
239
  const objectql = kernel.getService('objectql') as any;
177
- // App should be registered (check via registry or apps list)
178
240
  expect(objectql.registry).toBeDefined();
179
241
  });
180
242
  });
package/src/plugin.ts CHANGED
@@ -1,7 +1,6 @@
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
 
@@ -33,45 +32,24 @@ export class ObjectQLPlugin implements Plugin {
33
32
 
34
33
  // Register as provider for Core Kernel Services
35
34
  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
35
 
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
36
  ctx.registerService('data', this.ql); // ObjectQL implements IDataEngine
71
-
72
- ctx.logger.info('ObjectQL engine registered', {
73
- services: ['objectql', 'data'],
74
- metadataProvider: metadataProvider
37
+
38
+ // Register manifest service for direct app/package registration.
39
+ // Plugins call ctx.getService('manifest').register(manifestData)
40
+ // instead of the legacy ctx.registerService('app.<id>', manifestData) convention.
41
+ const ql = this.ql;
42
+ ctx.registerService('manifest', {
43
+ register: (manifest: any) => {
44
+ ql.registerApp(manifest);
45
+ ctx.logger.debug('Manifest registered via manifest service', {
46
+ id: manifest.id || manifest.name
47
+ });
48
+ }
49
+ });
50
+
51
+ ctx.logger.info('ObjectQL engine registered', {
52
+ services: ['objectql', 'data', 'manifest'],
75
53
  });
76
54
 
77
55
  // Register Protocol Implementation
@@ -86,16 +64,14 @@ export class ObjectQLPlugin implements Plugin {
86
64
 
87
65
  start = async (ctx: PluginContext) => {
88
66
  ctx.logger.info('ObjectQL engine starting...');
89
-
90
- // Check if we should load from external metadata service
67
+
68
+ // Sync from external metadata service (e.g. MetadataPlugin) if available
91
69
  try {
92
70
  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) {
71
+ if (metadataService && typeof metadataService.loadMany === 'function' && this.ql) {
95
72
  await this.loadMetadataFromService(metadataService, ctx);
96
73
  }
97
74
  } catch (e: any) {
98
- // No external metadata service or error accessing it
99
75
  ctx.logger.debug('No external metadata service to sync from');
100
76
  }
101
77
 
@@ -109,9 +85,13 @@ export class ObjectQLPlugin implements Plugin {
109
85
  ctx.logger.debug('Discovered and registered driver service', { serviceName: name });
110
86
  }
111
87
  if (name.startsWith('app.')) {
112
- // Register App
88
+ // Legacy fallback: discover app.* services (DEPRECATED)
89
+ ctx.logger.warn(
90
+ `[DEPRECATED] Service "${name}" uses legacy app.* convention. ` +
91
+ `Migrate to ctx.getService('manifest').register(data).`
92
+ );
113
93
  this.ql.registerApp(service); // service is Manifest
114
- ctx.logger.debug('Discovered and registered app service', { serviceName: name });
94
+ ctx.logger.debug('Discovered and registered app service (legacy)', { serviceName: name });
115
95
  }
116
96
  }
117
97
  }
package/src/protocol.ts CHANGED
@@ -185,19 +185,22 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
185
185
  };
186
186
  }
187
187
 
188
- async getMetaItems(request: { type: string }) {
189
- let items = SchemaRegistry.listItems(request.type);
188
+ async getMetaItems(request: { type: string; packageId?: string }) {
189
+ const { packageId } = request;
190
+ let items = SchemaRegistry.listItems(request.type, packageId);
190
191
  // Normalize singular/plural: REST uses singular ('app') but registry may store as plural ('apps')
191
192
  if (items.length === 0) {
192
193
  const alt = request.type.endsWith('s') ? request.type.slice(0, -1) : request.type + 's';
193
- items = SchemaRegistry.listItems(alt);
194
+ items = SchemaRegistry.listItems(alt, packageId);
194
195
  }
195
196
 
196
197
  // Fallback to database if registry is empty for this type
197
198
  if (items.length === 0) {
198
199
  try {
200
+ const whereClause: any = { type: request.type, state: 'active' };
201
+ if (packageId) whereClause._packageId = packageId;
199
202
  const allRecords = await this.engine.find('sys_metadata', {
200
- where: { type: request.type, state: 'active' }
203
+ where: whereClause
201
204
  });
202
205
  if (allRecords && allRecords.length > 0) {
203
206
  items = allRecords.map((record: any) => {
@@ -235,7 +238,7 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
235
238
  };
236
239
  }
237
240
 
238
- async getMetaItem(request: { type: string, name: string }) {
241
+ async getMetaItem(request: { type: string, name: string, packageId?: string }) {
239
242
  let item = SchemaRegistry.getItem(request.type, request.name);
240
243
  // Normalize singular/plural
241
244
  if (item === undefined) {
@@ -481,10 +484,11 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
481
484
  }
482
485
 
483
486
  const records = await this.engine.find(request.object, options);
487
+ // Spec: FindDataResponseSchema — only `records` is returned.
488
+ // OData `value` adaptation (if needed) is handled in the HTTP dispatch layer.
484
489
  return {
485
490
  object: request.object,
486
- value: records, // OData compatibility
487
- records, // Legacy
491
+ records,
488
492
  total: records.length,
489
493
  hasMore: false
490
494
  };
@@ -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
  // ==========================================
package/src/registry.ts CHANGED
@@ -144,8 +144,8 @@ export class SchemaRegistry {
144
144
  /** FQN → Merged ServiceObject (cached, invalidated on changes) */
145
145
  private static mergedObjectCache = new Map<string, ServiceObject>();
146
146
 
147
- /** Namespace → PackageId (ensures namespace uniqueness) */
148
- private static namespaceRegistry = new Map<string, string>();
147
+ /** Namespace → Set<PackageId> (multiple packages can share a namespace) */
148
+ private static namespaceRegistry = new Map<string, Set<string>>();
149
149
 
150
150
  // ==========================================
151
151
  // Generic metadata storage (non-object types)
@@ -160,22 +160,17 @@ export class SchemaRegistry {
160
160
 
161
161
  /**
162
162
  * Register a namespace for a package.
163
- * Enforces namespace uniqueness within the instance.
164
- *
165
- * @throws Error if namespace is already registered to a different package
163
+ * Multiple packages can share the same namespace (e.g. 'sys').
166
164
  */
167
165
  static registerNamespace(namespace: string, packageId: string): void {
168
166
  if (!namespace) return;
169
-
170
- const existing = this.namespaceRegistry.get(namespace);
171
- if (existing && existing !== packageId) {
172
- throw new Error(
173
- `Namespace "${namespace}" is already registered to package "${existing}". ` +
174
- `Package "${packageId}" cannot use the same namespace.`
175
- );
167
+
168
+ let owners = this.namespaceRegistry.get(namespace);
169
+ if (!owners) {
170
+ owners = new Set();
171
+ this.namespaceRegistry.set(namespace, owners);
176
172
  }
177
-
178
- this.namespaceRegistry.set(namespace, packageId);
173
+ owners.add(packageId);
179
174
  this.log(`[Registry] Registered namespace: ${namespace} → ${packageId}`);
180
175
  }
181
176
 
@@ -183,18 +178,32 @@ export class SchemaRegistry {
183
178
  * Unregister a namespace when a package is uninstalled.
184
179
  */
185
180
  static unregisterNamespace(namespace: string, packageId: string): void {
186
- const existing = this.namespaceRegistry.get(namespace);
187
- if (existing === packageId) {
188
- this.namespaceRegistry.delete(namespace);
189
- this.log(`[Registry] Unregistered namespace: ${namespace}`);
181
+ const owners = this.namespaceRegistry.get(namespace);
182
+ if (owners) {
183
+ owners.delete(packageId);
184
+ if (owners.size === 0) {
185
+ this.namespaceRegistry.delete(namespace);
186
+ }
187
+ this.log(`[Registry] Unregistered namespace: ${namespace} ← ${packageId}`);
190
188
  }
191
189
  }
192
190
 
193
191
  /**
194
- * Get the package that owns a namespace.
192
+ * Get the packages that use a namespace.
195
193
  */
196
194
  static getNamespaceOwner(namespace: string): string | undefined {
197
- return this.namespaceRegistry.get(namespace);
195
+ const owners = this.namespaceRegistry.get(namespace);
196
+ if (!owners || owners.size === 0) return undefined;
197
+ // Return the first registered package for backwards compatibility
198
+ return owners.values().next().value;
199
+ }
200
+
201
+ /**
202
+ * Get all packages that share a namespace.
203
+ */
204
+ static getNamespaceOwners(namespace: string): string[] {
205
+ const owners = this.namespaceRegistry.get(namespace);
206
+ return owners ? Array.from(owners) : [];
198
207
  }
199
208
 
200
209
  // ==========================================
@@ -541,7 +550,7 @@ export class SchemaRegistry {
541
550
  if (type === 'object' || type === 'objects') {
542
551
  return this.getAllObjects(packageId) as unknown as T[];
543
552
  }
544
-
553
+
545
554
  const items = Array.from(this.metadata.get(type)?.values() || []) as T[];
546
555
  if (packageId) {
547
556
  return items.filter((item: any) => item._packageId === packageId);