@objectstack/objectql 4.0.3 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@objectstack/objectql",
3
- "version": "4.0.3",
3
+ "version": "4.0.4",
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.3",
17
- "@objectstack/spec": "4.0.3",
18
- "@objectstack/types": "4.0.3"
16
+ "@objectstack/core": "4.0.4",
17
+ "@objectstack/spec": "4.0.4",
18
+ "@objectstack/types": "4.0.4"
19
19
  },
20
20
  "devDependencies": {
21
21
  "typescript": "^6.0.2",
@@ -0,0 +1,181 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ import { describe, it, expect, beforeEach } from 'vitest';
4
+ import { ObjectQL } from './engine.js';
5
+ import { SchemaRegistry } from './registry.js';
6
+
7
+ // Mock driver for testing
8
+ const createMockDriver = (name: string) => ({
9
+ name,
10
+ version: '1.0.0',
11
+ supports: {},
12
+ connect: async () => {},
13
+ disconnect: async () => {},
14
+ checkHealth: async () => true,
15
+ find: async () => [],
16
+ findOne: async () => null,
17
+ create: async (obj: string, data: any) => ({ id: '1', ...data }),
18
+ update: async (obj: string, id: string, data: any) => ({ id, ...data }),
19
+ delete: async () => true,
20
+ count: async () => 0,
21
+ bulkCreate: async () => [],
22
+ bulkUpdate: async () => [],
23
+ bulkDelete: async () => {},
24
+ execute: async () => ({}),
25
+ findStream: async function* () {},
26
+ upsert: async (obj: string, data: any) => ({ id: '1', ...data }),
27
+ beginTransaction: async () => ({}),
28
+ commit: async () => {},
29
+ rollback: async () => {},
30
+ syncSchema: async () => {},
31
+ });
32
+
33
+ describe('DatasourceMapping', () => {
34
+ let engine: ObjectQL;
35
+
36
+ beforeEach(() => {
37
+ engine = new ObjectQL();
38
+ SchemaRegistry.reset();
39
+ });
40
+
41
+ it('should route objects by namespace', async () => {
42
+ const memoryDriver = createMockDriver('memory');
43
+ const tursoDriver = createMockDriver('turso');
44
+
45
+ engine.registerDriver(memoryDriver);
46
+ engine.registerDriver(tursoDriver, true); // default
47
+
48
+ // Configure mapping: crm namespace → memory
49
+ engine.setDatasourceMapping([
50
+ { namespace: 'crm', datasource: 'memory' },
51
+ ]);
52
+
53
+ // Register an object in crm namespace
54
+ SchemaRegistry.registerObject(
55
+ {
56
+ name: 'account',
57
+ fields: { name: { type: 'text' } },
58
+ },
59
+ 'com.example.crm',
60
+ 'crm',
61
+ 'own'
62
+ );
63
+
64
+ // Test that it uses memory driver
65
+ const result = await engine.insert('account', { name: 'Test Account' });
66
+ expect(result).toBeDefined();
67
+ expect(result.name).toBe('Test Account');
68
+ });
69
+
70
+ it('should route objects by pattern', async () => {
71
+ const memoryDriver = createMockDriver('memory');
72
+ const tursoDriver = createMockDriver('turso');
73
+
74
+ engine.registerDriver(memoryDriver);
75
+ engine.registerDriver(tursoDriver, true);
76
+
77
+ // Configure mapping: sys_* pattern → turso
78
+ engine.setDatasourceMapping([
79
+ { objectPattern: 'sys_*', datasource: 'turso' },
80
+ { default: true, datasource: 'memory' },
81
+ ]);
82
+
83
+ // Register system objects
84
+ SchemaRegistry.registerObject(
85
+ {
86
+ name: 'sys_user',
87
+ fields: { username: { type: 'text' } },
88
+ },
89
+ 'com.objectstack.system',
90
+ 'system',
91
+ 'own'
92
+ );
93
+
94
+ const result = await engine.insert('sys_user', { username: 'admin' });
95
+ expect(result).toBeDefined();
96
+ });
97
+
98
+ it('should respect priority order', async () => {
99
+ const memoryDriver = createMockDriver('memory');
100
+ const tursoDriver = createMockDriver('turso');
101
+
102
+ engine.registerDriver(memoryDriver);
103
+ engine.registerDriver(tursoDriver);
104
+
105
+ // Higher priority rule should win
106
+ engine.setDatasourceMapping([
107
+ { namespace: 'crm', datasource: 'memory', priority: 100 },
108
+ { namespace: 'crm', datasource: 'turso', priority: 50 }, // Lower number = higher priority
109
+ ]);
110
+
111
+ SchemaRegistry.registerObject(
112
+ {
113
+ name: 'account',
114
+ fields: { name: { type: 'text' } },
115
+ },
116
+ 'com.example.crm',
117
+ 'crm',
118
+ 'own'
119
+ );
120
+
121
+ // Should use turso (priority 50) not memory (priority 100)
122
+ const result = await engine.insert('account', { name: 'Test' });
123
+ expect(result).toBeDefined();
124
+ });
125
+
126
+ it('should fallback to default rule', async () => {
127
+ const memoryDriver = createMockDriver('memory');
128
+ const tursoDriver = createMockDriver('turso');
129
+
130
+ engine.registerDriver(memoryDriver);
131
+ engine.registerDriver(tursoDriver);
132
+
133
+ engine.setDatasourceMapping([
134
+ { namespace: 'auth', datasource: 'turso' },
135
+ { default: true, datasource: 'memory' },
136
+ ]);
137
+
138
+ // Register object in different namespace
139
+ SchemaRegistry.registerObject(
140
+ {
141
+ name: 'task',
142
+ fields: { title: { type: 'text' } },
143
+ },
144
+ 'com.example.todo',
145
+ 'todo',
146
+ 'own'
147
+ );
148
+
149
+ // Should use memory (default)
150
+ const result = await engine.insert('task', { title: 'Do something' });
151
+ expect(result).toBeDefined();
152
+ });
153
+
154
+ it('should prefer object explicit datasource over mapping', async () => {
155
+ const memoryDriver = createMockDriver('memory');
156
+ const tursoDriver = createMockDriver('turso');
157
+
158
+ engine.registerDriver(memoryDriver);
159
+ engine.registerDriver(tursoDriver);
160
+
161
+ engine.setDatasourceMapping([
162
+ { namespace: 'crm', datasource: 'memory' },
163
+ ]);
164
+
165
+ // Object explicitly sets datasource
166
+ SchemaRegistry.registerObject(
167
+ {
168
+ name: 'account',
169
+ datasource: 'turso', // Explicit override
170
+ fields: { name: { type: 'text' } },
171
+ },
172
+ 'com.example.crm',
173
+ 'crm',
174
+ 'own'
175
+ );
176
+
177
+ // Should use turso (explicit) not memory (mapping)
178
+ const result = await engine.insert('account', { name: 'Test' });
179
+ expect(result).toBeDefined();
180
+ });
181
+ });
@@ -6,6 +6,7 @@ import type { IDataDriver } from '@objectstack/spec/contracts';
6
6
  // Mock the SchemaRegistry to avoid side effects between tests
7
7
  vi.mock('./registry', () => {
8
8
  const mockObjects = new Map();
9
+ const mockContributors = new Map();
9
10
  return {
10
11
  SchemaRegistry: {
11
12
  getObject: vi.fn((name) => mockObjects.get(name)),
@@ -13,8 +14,18 @@ vi.mock('./registry', () => {
13
14
  registerObject: vi.fn((obj, packageId, namespace, ownership, priority) => {
14
15
  const fqn = namespace ? `${namespace}__${obj.name}` : obj.name;
15
16
  mockObjects.set(fqn, { ...obj, name: fqn });
17
+ // Also track contributors for getObjectOwner
18
+ if (!mockContributors.has(fqn)) {
19
+ mockContributors.set(fqn, []);
20
+ }
21
+ const contributors = mockContributors.get(fqn);
22
+ contributors.push({ packageId, namespace, ownership, priority, definition: obj });
16
23
  return fqn;
17
24
  }),
25
+ getObjectOwner: vi.fn((fqn) => {
26
+ const contributors = mockContributors.get(fqn);
27
+ return contributors?.find(c => c.ownership === 'own');
28
+ }),
18
29
  registerNamespace: vi.fn(),
19
30
  registerKind: vi.fn(),
20
31
  registerItem: vi.fn(),
@@ -25,7 +36,10 @@ vi.mock('./registry', () => {
25
36
  enabled: true,
26
37
  installedAt: new Date().toISOString(),
27
38
  })),
28
- reset: vi.fn(() => mockObjects.clear()),
39
+ reset: vi.fn(() => {
40
+ mockObjects.clear();
41
+ mockContributors.clear();
42
+ }),
29
43
  metadata: {
30
44
  get: vi.fn(() => mockObjects) // Expose for verification if needed
31
45
  }
package/src/engine.ts CHANGED
@@ -72,6 +72,19 @@ export class ObjectQL implements IDataEngine {
72
72
  private defaultDriver: string | null = null;
73
73
  private logger: Logger;
74
74
 
75
+ // Datasource mapping rules (imported from defineStack)
76
+ private datasourceMapping: Array<{
77
+ namespace?: string;
78
+ package?: string;
79
+ objectPattern?: string;
80
+ default?: boolean;
81
+ datasource: string;
82
+ priority?: number;
83
+ }> = [];
84
+
85
+ // Package manifests registry (for defaultDatasource lookup)
86
+ private manifests = new Map<string, any>();
87
+
75
88
  // Per-object hooks with priority support
76
89
  private hooks: Map<string, HookEntry[]> = new Map([
77
90
  ['beforeFind', []], ['afterFind', []],
@@ -308,6 +321,11 @@ export class ObjectQL implements IDataEngine {
308
321
  const namespace = manifest.namespace as string | undefined;
309
322
  this.logger.debug('Registering package manifest', { id, namespace });
310
323
 
324
+ // Store manifest for defaultDatasource lookup
325
+ if (id) {
326
+ this.manifests.set(id, manifest);
327
+ }
328
+
311
329
  // 1. Register the Package (manifest + lifecycle state)
312
330
  SchemaRegistry.installPackage(manifest);
313
331
  this.logger.debug('Installed Package', { id: manifest.id, name: manifest.name, namespace });
@@ -571,36 +589,138 @@ export class ObjectQL implements IDataEngine {
571
589
 
572
590
  /**
573
591
  * Helper to get the target driver
592
+ *
593
+ * Resolution priority (first match wins):
594
+ * 1. Object's explicit `datasource` field (if not 'default')
595
+ * 2. DatasourceMapping rules (namespace/package/pattern matching)
596
+ * 3. Package's `defaultDatasource` from manifest
597
+ * 4. Global default driver
574
598
  */
575
599
  private getDriver(objectName: string): DriverInterface {
576
600
  const object = SchemaRegistry.getObject(objectName);
577
-
578
- // 1. If object definition exists, check for explicit datasource
579
- if (object) {
580
- const datasourceName = object.datasource || 'default';
581
-
582
- // If configured for 'default', try to find the default driver
583
- if (datasourceName === 'default') {
584
- if (this.defaultDriver && this.drivers.has(this.defaultDriver)) {
585
- return this.drivers.get(this.defaultDriver)!;
586
- }
587
- } else {
588
- // Specific datasource requested
589
- if (this.drivers.has(datasourceName)) {
590
- return this.drivers.get(datasourceName)!;
601
+
602
+ // 1. Object's explicit datasource field (highest priority)
603
+ if (object?.datasource && object.datasource !== 'default') {
604
+ if (this.drivers.has(object.datasource)) {
605
+ return this.drivers.get(object.datasource)!;
606
+ }
607
+ throw new Error(`[ObjectQL] Datasource '${object.datasource}' configured for object '${objectName}' is not registered.`);
608
+ }
609
+
610
+ // 2. Check datasourceMapping rules
611
+ const mappedDatasource = this.resolveDatasourceFromMapping(objectName, object);
612
+ if (mappedDatasource && this.drivers.has(mappedDatasource)) {
613
+ this.logger.debug('Resolved datasource from mapping', {
614
+ object: objectName,
615
+ datasource: mappedDatasource
616
+ });
617
+ return this.drivers.get(mappedDatasource)!;
618
+ }
619
+
620
+ // 3. Check package's defaultDatasource
621
+ // Use the object's FQN name (from getObject) for ownership lookup
622
+ const fqn = object?.name || objectName;
623
+ const owner = SchemaRegistry.getObjectOwner(fqn);
624
+ if (owner?.packageId) {
625
+ const manifest = this.manifests.get(owner.packageId);
626
+ if (manifest?.defaultDatasource && manifest.defaultDatasource !== 'default') {
627
+ if (this.drivers.has(manifest.defaultDatasource)) {
628
+ this.logger.debug('Resolved datasource from package manifest', {
629
+ object: objectName,
630
+ package: owner.packageId,
631
+ datasource: manifest.defaultDatasource
632
+ });
633
+ return this.drivers.get(manifest.defaultDatasource)!;
591
634
  }
592
- throw new Error(`[ObjectQL] Datasource '${datasourceName}' configured for object '${objectName}' is not registered.`);
593
635
  }
594
636
  }
595
637
 
596
- // 2. Fallback for ad-hoc objects or missing definitions
597
- if (this.defaultDriver) {
638
+ // 4. Fallback to global default driver
639
+ if (this.defaultDriver && this.drivers.has(this.defaultDriver)) {
598
640
  return this.drivers.get(this.defaultDriver)!;
599
641
  }
600
642
 
601
643
  throw new Error(`[ObjectQL] No driver available for object '${objectName}'`);
602
644
  }
603
645
 
646
+ /**
647
+ * Resolve datasource from mapping rules
648
+ *
649
+ * Rules are evaluated in order (or by priority if specified).
650
+ * First matching rule wins.
651
+ */
652
+ private resolveDatasourceFromMapping(
653
+ objectName: string,
654
+ object?: any
655
+ ): string | null {
656
+ if (!this.datasourceMapping || this.datasourceMapping.length === 0) {
657
+ return null;
658
+ }
659
+
660
+ // Sort rules by priority if any have priority set
661
+ const sortedRules = [...this.datasourceMapping].sort((a, b) => {
662
+ const aPriority = a.priority ?? 1000;
663
+ const bPriority = b.priority ?? 1000;
664
+ return aPriority - bPriority;
665
+ });
666
+
667
+ for (const rule of sortedRules) {
668
+ // 1. Match by namespace
669
+ if (rule.namespace && object?.namespace === rule.namespace) {
670
+ return rule.datasource;
671
+ }
672
+
673
+ // 2. Match by package ID
674
+ if (rule.package && object?.packageId === rule.package) {
675
+ return rule.datasource;
676
+ }
677
+
678
+ // 3. Match by object name pattern (glob-style)
679
+ if (rule.objectPattern && this.matchPattern(objectName, rule.objectPattern)) {
680
+ return rule.datasource;
681
+ }
682
+
683
+ // 4. Default fallback rule
684
+ if (rule.default) {
685
+ return rule.datasource;
686
+ }
687
+ }
688
+
689
+ return null;
690
+ }
691
+
692
+ /**
693
+ * Simple glob pattern matching
694
+ * Supports * (any chars) and ? (single char)
695
+ */
696
+ private matchPattern(objectName: string, pattern: string): boolean {
697
+ const regexPattern = pattern
698
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&') // Escape regex special chars
699
+ .replace(/\*/g, '.*') // * → .*
700
+ .replace(/\?/g, '.'); // ? → .
701
+
702
+ const regex = new RegExp(`^${regexPattern}$`);
703
+ return regex.test(objectName);
704
+ }
705
+
706
+ /**
707
+ * Set datasource mapping rules
708
+ * Called by ObjectQLPlugin during bootstrap
709
+ */
710
+ setDatasourceMapping(rules: Array<{
711
+ namespace?: string;
712
+ package?: string;
713
+ objectPattern?: string;
714
+ default?: boolean;
715
+ datasource: string;
716
+ priority?: number;
717
+ }>) {
718
+ this.datasourceMapping = rules;
719
+ this.logger.info('Datasource mapping rules configured', {
720
+ ruleCount: rules.length
721
+ });
722
+ }
723
+
604
724
  /**
605
725
  * Initialize the engine and all registered drivers
606
726
  */
package/src/protocol.ts CHANGED
@@ -253,7 +253,14 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
253
253
  const services = this.getServicesRegistry?.();
254
254
  const metadataService = services?.get('metadata');
255
255
  if (metadataService && typeof metadataService.list === 'function') {
256
- const runtimeItems = await metadataService.list(request.type);
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
+ }
257
264
  if (runtimeItems && runtimeItems.length > 0) {
258
265
  // Merge, avoiding duplicates by name
259
266
  const itemMap = new Map<string, any>();