@objectql/core 1.1.0 → 1.3.0

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.
Files changed (72) hide show
  1. package/CHANGELOG.md +15 -6
  2. package/dist/action.d.ts +7 -0
  3. package/dist/action.js +23 -0
  4. package/dist/action.js.map +1 -0
  5. package/dist/app.d.ts +28 -0
  6. package/dist/app.js +211 -0
  7. package/dist/app.js.map +1 -0
  8. package/dist/driver.d.ts +2 -17
  9. package/dist/driver.js +52 -0
  10. package/dist/driver.js.map +1 -1
  11. package/dist/hook.d.ts +8 -0
  12. package/dist/hook.js +25 -0
  13. package/dist/hook.js.map +1 -0
  14. package/dist/index.d.ts +8 -25
  15. package/dist/index.js +8 -141
  16. package/dist/index.js.map +1 -1
  17. package/dist/loader.d.ts +9 -4
  18. package/dist/loader.js +206 -9
  19. package/dist/loader.js.map +1 -1
  20. package/dist/object.d.ts +3 -0
  21. package/dist/object.js +28 -0
  22. package/dist/object.js.map +1 -0
  23. package/dist/plugin.d.ts +2 -0
  24. package/dist/plugin.js +56 -0
  25. package/dist/plugin.js.map +1 -0
  26. package/dist/remote.d.ts +8 -0
  27. package/dist/remote.js +43 -0
  28. package/dist/remote.js.map +1 -0
  29. package/dist/repository.d.ts +3 -5
  30. package/dist/repository.js +107 -112
  31. package/dist/repository.js.map +1 -1
  32. package/jest.config.js +3 -0
  33. package/package.json +11 -7
  34. package/src/action.ts +40 -0
  35. package/src/app.ts +257 -0
  36. package/src/driver.ts +51 -21
  37. package/src/hook.ts +42 -0
  38. package/src/index.ts +8 -158
  39. package/src/loader.ts +184 -9
  40. package/src/object.ts +26 -0
  41. package/src/plugin.ts +53 -0
  42. package/src/remote.ts +50 -0
  43. package/src/repository.ts +123 -127
  44. package/test/action.test.ts +58 -0
  45. package/test/fixtures/project.action.js +8 -0
  46. package/test/hook.test.ts +60 -0
  47. package/test/loader.test.ts +1 -8
  48. package/test/metadata.test.ts +1 -1
  49. package/test/mock-driver.ts +1 -1
  50. package/test/remote.test.ts +119 -0
  51. package/test/repository.test.ts +42 -49
  52. package/test/utils.ts +54 -0
  53. package/tsconfig.json +7 -3
  54. package/tsconfig.tsbuildinfo +1 -1
  55. package/README.md +0 -53
  56. package/dist/metadata.d.ts +0 -104
  57. package/dist/metadata.js +0 -3
  58. package/dist/metadata.js.map +0 -1
  59. package/dist/query.d.ts +0 -10
  60. package/dist/query.js +0 -3
  61. package/dist/query.js.map +0 -1
  62. package/dist/registry.d.ts +0 -4
  63. package/dist/registry.js +0 -8
  64. package/dist/registry.js.map +0 -1
  65. package/dist/types.d.ts +0 -83
  66. package/dist/types.js +0 -6
  67. package/dist/types.js.map +0 -1
  68. package/src/metadata.ts +0 -143
  69. package/src/query.ts +0 -11
  70. package/src/registry.ts +0 -6
  71. package/src/types.ts +0 -115
  72. package/test/fixtures/project.action.ts +0 -6
package/src/repository.ts CHANGED
@@ -1,7 +1,4 @@
1
- import { ObjectQLContext, IObjectQL, HookContext, HookFunction } from './types';
2
- import { ObjectConfig, FieldConfig } from './metadata';
3
- import { Driver } from './driver';
4
- import { UnifiedQuery, FilterCriterion } from './query';
1
+ import { ObjectQLContext, IObjectQL, ObjectConfig, Driver, UnifiedQuery, HookContext, ActionContext, HookAPI, RetrievalHookContext, MutationHookContext, UpdateHookContext } from '@objectql/types';
5
2
 
6
3
  export class ObjectRepository {
7
4
  constructor(
@@ -31,97 +28,52 @@ export class ObjectRepository {
31
28
  return obj;
32
29
  }
33
30
 
34
- // === Hook Execution Logic ===
35
- private async executeHook(
36
- hookName: keyof import('./metadata').ObjectListeners,
37
- op: HookContext['op'],
38
- dataOrQuery: any
39
- ) {
40
- if (this.context.ignoreTriggers) return;
41
-
42
- const obj = this.getSchema();
43
- if (!obj.listeners || !obj.listeners[hookName]) return;
44
-
45
- const hookFn = obj.listeners[hookName] as HookFunction;
46
-
47
- // Construct HookContext
48
- const hookContext: HookContext = {
49
- ctx: this.context,
50
- entity: this.objectName,
51
- op: op,
52
- utils: {
53
- restrict: (criterion: FilterCriterion) => {
54
- if (op !== 'find' && op !== 'count') {
55
- throw new Error('utils.restrict is only available in query operations');
56
- }
57
- const query = dataOrQuery as UnifiedQuery;
58
- if (!query.filters) {
59
- query.filters = [criterion];
60
- } else {
61
- // Enclose existing filters in implicit AND group by array structure logic or explicit 'and'
62
- // Implementation depends on how driver parses.
63
- // Safe approach: filters = [ [criterion], 'and', [existing] ] or similar.
64
- // For simplicity assuming array of terms means AND:
65
- query.filters.push(criterion);
66
- }
67
- }
68
- },
69
- getPreviousDoc: async () => {
70
- // For update/delete, we might need the ID to find the doc.
71
- // If doc has ID, use it.
72
- // This is simplistic; usually 'update' takes 'id', we need to capture it from arguments.
73
- if (op === 'create') return undefined;
74
- if (dataOrQuery._id || dataOrQuery.id) {
75
- return this.findOne(dataOrQuery._id || dataOrQuery.id);
76
- }
77
- return undefined;
78
- }
31
+ private getHookAPI(): HookAPI {
32
+ return {
33
+ find: (obj, q) => this.context.object(obj).find(q),
34
+ findOne: (obj, id) => this.context.object(obj).findOne(id),
35
+ count: (obj, q) => this.context.object(obj).count(q),
36
+ create: (obj, data) => this.context.object(obj).create(data),
37
+ update: (obj, id, data) => this.context.object(obj).update(id, data),
38
+ delete: (obj, id) => this.context.object(obj).delete(id)
79
39
  };
80
-
81
- if (op === 'find' || op === 'count' || op === 'aggregate') {
82
- hookContext.query = dataOrQuery;
83
- } else {
84
- hookContext.doc = dataOrQuery;
85
- }
86
-
87
- // Pass ID manually if needed or attach to doc?
88
- // For strictness, getPreviousDoc needs the ID passed to the operation.
89
- // We'll rely on "doc" having the data being processed.
90
-
91
- await hookFn(hookContext);
92
40
  }
93
41
 
94
42
  async find(query: UnifiedQuery = {}): Promise<any[]> {
95
- // Hooks: beforeFind
96
- await this.executeHook('beforeFind', 'find', query);
43
+ const hookCtx: RetrievalHookContext = {
44
+ ...this.context,
45
+ objectName: this.objectName,
46
+ operation: 'find',
47
+ api: this.getHookAPI(),
48
+ state: {},
49
+ query
50
+ };
51
+ await this.app.triggerHook('beforeFind', this.objectName, hookCtx);
97
52
 
98
- // TODO: Apply basic filters like spaceId (could be done via a default generic hook too)
99
- const results = await this.getDriver().find(this.objectName, query, this.getOptions());
53
+ // TODO: Apply basic filters like spaceId
54
+ const results = await this.getDriver().find(this.objectName, hookCtx.query || {}, this.getOptions());
100
55
 
101
- // Hooks: afterFind
102
- // Not implemented in spec fully iterate results? usually for single doc or metadata
103
- // For performance, afterFind on list is rare or costly.
104
- if (this.getSchema().listeners?.afterFind && !this.context.ignoreTriggers) {
105
- const hookFn = this.getSchema().listeners!.afterFind!;
106
- // Executing per result or once? Spec says "HookContext" has "doc".
107
- // If finding list, might not match signature.
108
- // Implemented per-item for now (caution: performance).
109
- /*
110
- for (const item of results) {
111
- await this.executeHookForDoc('afterFind', 'find', item);
112
- }
113
- */
114
- }
115
- return results;
56
+ hookCtx.result = results;
57
+ await this.app.triggerHook('afterFind', this.objectName, hookCtx);
58
+
59
+ return hookCtx.result as any[];
116
60
  }
117
61
 
118
62
  async findOne(idOrQuery: string | number | UnifiedQuery): Promise<any> {
119
63
  if (typeof idOrQuery === 'string' || typeof idOrQuery === 'number') {
120
- // Convert ID lookup to standard query to reuse 'find' hooks?
121
- // Or treat as specific op.
122
- // Let's rely on simple driver call but maybe wrap in object for hook consistency if needed.
123
- // For now, simple implementation:
124
- return this.getDriver().findOne(this.objectName, idOrQuery, undefined, this.getOptions());
64
+ const hookCtx: RetrievalHookContext = {
65
+ ...this.context,
66
+ objectName: this.objectName,
67
+ operation: 'find',
68
+ api: this.getHookAPI(), state: {}, query: { _id: idOrQuery }
69
+ };
70
+ await this.app.triggerHook('beforeFind', this.objectName, hookCtx);
71
+
72
+ const result = await this.getDriver().findOne(this.objectName, idOrQuery, hookCtx.query, this.getOptions());
73
+
74
+ hookCtx.result = result;
75
+ await this.app.triggerHook('afterFind', this.objectName, hookCtx);
76
+ return hookCtx.result;
125
77
  } else {
126
78
  const results = await this.find(idOrQuery);
127
79
  return results[0] || null;
@@ -129,60 +81,95 @@ export class ObjectRepository {
129
81
  }
130
82
 
131
83
  async count(filters: any): Promise<number> {
132
- // Can wrap filters in a query object for hook
133
- const query: UnifiedQuery = { filters };
134
- await this.executeHook('beforeFind', 'count', query); // Reusing beforeFind logic often?
135
- return this.getDriver().count(this.objectName, query.filters, this.getOptions());
136
- }
84
+ const hookCtx: RetrievalHookContext = {
85
+ ...this.context,
86
+ objectName: this.objectName,
87
+ operation: 'count',
88
+ api: this.getHookAPI(),
89
+ state: {},
90
+ query: filters
91
+ };
92
+ await this.app.triggerHook('beforeCount', this.objectName, hookCtx);
137
93
 
138
- async create(doc: any): Promise<any> {
139
- const obj = this.getSchema();
140
- if (this.context.userId) doc.created_by = this.context.userId;
141
- if (this.context.spaceId) doc.space_id = this.context.spaceId;
94
+ const result = await this.getDriver().count(this.objectName, hookCtx.query, this.getOptions());
142
95
 
143
- await this.executeHook('beforeCreate', 'create', doc);
96
+ hookCtx.result = result;
97
+ await this.app.triggerHook('afterCount', this.objectName, hookCtx);
98
+ return hookCtx.result as number;
99
+ }
144
100
 
145
- const result = await this.getDriver().create(this.objectName, doc, this.getOptions());
101
+ async create(doc: any): Promise<any> {
102
+ const hookCtx: MutationHookContext = {
103
+ ...this.context,
104
+ objectName: this.objectName,
105
+ operation: 'create',
106
+ state: {},
107
+ api: this.getHookAPI(),
108
+ data: doc
109
+ };
110
+ await this.app.triggerHook('beforeCreate', this.objectName, hookCtx);
111
+ const finalDoc = hookCtx.data || doc;
146
112
 
147
- await this.executeHook('afterCreate', 'create', result);
148
- return result;
113
+ const obj = this.getSchema();
114
+ if (this.context.userId) finalDoc.created_by = this.context.userId;
115
+ if (this.context.spaceId) finalDoc.space_id = this.context.spaceId;
116
+
117
+ const result = await this.getDriver().create(this.objectName, finalDoc, this.getOptions());
118
+
119
+ hookCtx.result = result;
120
+ await this.app.triggerHook('afterCreate', this.objectName, hookCtx);
121
+ return hookCtx.result;
149
122
  }
150
123
 
151
124
  async update(id: string | number, doc: any, options?: any): Promise<any> {
152
- // Attach ID to doc for hook context to know which record
153
- const docWithId = { ...doc, _id: id, id: id };
154
-
155
- await this.executeHook('beforeUpdate', 'update', docWithId);
156
-
157
- // Remove ID before sending to driver if driver doesn't like it in $set
158
- const { _id, id: _id2, ...cleanDoc } = docWithId;
125
+ const hookCtx: UpdateHookContext = {
126
+ ...this.context,
127
+ objectName: this.objectName,
128
+ operation: 'update',
129
+ state: {},
130
+ api: this.getHookAPI(),
131
+ id,
132
+ data: doc,
133
+ isModified: (field) => hookCtx.data ? Object.prototype.hasOwnProperty.call(hookCtx.data, field) : false
134
+ };
135
+ await this.app.triggerHook('beforeUpdate', this.objectName, hookCtx);
159
136
 
160
- const result = await this.getDriver().update(this.objectName, id, cleanDoc, this.getOptions(options));
137
+ const result = await this.getDriver().update(this.objectName, id, hookCtx.data, this.getOptions(options));
161
138
 
162
- // Result might be count or doc depending on driver.
163
- // If we want the updated doc for afterUpdate, we might need to fetch it if driver defaults to count.
164
- // Assuming result is the doc or we just pass the patch.
165
- await this.executeHook('afterUpdate', 'update', docWithId);
166
- return result;
139
+ hookCtx.result = result;
140
+ await this.app.triggerHook('afterUpdate', this.objectName, hookCtx);
141
+ return hookCtx.result;
167
142
  }
168
143
 
169
144
  async delete(id: string | number): Promise<any> {
170
- const docWithId = { _id: id, id: id };
171
- await this.executeHook('beforeDelete', 'delete', docWithId);
145
+ const hookCtx: MutationHookContext = {
146
+ ...this.context,
147
+ objectName: this.objectName,
148
+ operation: 'delete',
149
+ state: {},
150
+ api: this.getHookAPI(),
151
+ id
152
+ };
153
+ await this.app.triggerHook('beforeDelete', this.objectName, hookCtx);
172
154
 
173
155
  const result = await this.getDriver().delete(this.objectName, id, this.getOptions());
174
156
 
175
- await this.executeHook('afterDelete', 'delete', docWithId);
176
- return result;
177
- } async aggregate(query: any): Promise<any> {
157
+ hookCtx.result = result;
158
+ await this.app.triggerHook('afterDelete', this.objectName, hookCtx);
159
+ return hookCtx.result;
160
+ }
161
+
162
+ async aggregate(query: any): Promise<any> {
178
163
  const driver = this.getDriver();
179
164
  if (!driver.aggregate) throw new Error("Driver does not support aggregate");
165
+
180
166
  return driver.aggregate(this.objectName, query, this.getOptions());
181
167
  }
182
168
 
183
169
  async distinct(field: string, filters?: any): Promise<any[]> {
184
170
  const driver = this.getDriver();
185
171
  if (!driver.distinct) throw new Error("Driver does not support distinct");
172
+
186
173
  return driver.distinct(this.objectName, field, filters, this.getOptions());
187
174
  }
188
175
 
@@ -193,8 +180,8 @@ export class ObjectRepository {
193
180
  }
194
181
 
195
182
  async createMany(data: any[]): Promise<any> {
196
- // TODO: Triggers per record?
197
183
  const driver = this.getDriver();
184
+
198
185
  if (!driver.createMany) {
199
186
  // Fallback
200
187
  const results = [];
@@ -218,15 +205,24 @@ export class ObjectRepository {
218
205
  return driver.deleteMany(this.objectName, filters, this.getOptions());
219
206
  }
220
207
 
221
- async call(actionName: string, params: any): Promise<any> {
222
- const obj = this.getSchema();
223
- const action = obj.actions?.[actionName];
224
- if (!action) {
225
- throw new Error(`Action '${actionName}' not found on object '${this.objectName}'`);
226
- }
227
- if (action.handler) {
228
- return action.handler(this.context, params);
229
- }
230
- throw new Error(`Action '${actionName}' has no handler`);
208
+ async execute(actionName: string, id: string | number | undefined, params: any): Promise<any> {
209
+ const api: HookAPI = {
210
+ find: (obj, q) => this.context.object(obj).find(q),
211
+ findOne: (obj, id) => this.context.object(obj).findOne(id),
212
+ count: (obj, q) => this.context.object(obj).count(q),
213
+ create: (obj, data) => this.context.object(obj).create(data),
214
+ update: (obj, id, data) => this.context.object(obj).update(id, data),
215
+ delete: (obj, id) => this.context.object(obj).delete(id)
216
+ };
217
+
218
+ const ctx: ActionContext = {
219
+ ...this.context,
220
+ objectName: this.objectName,
221
+ actionName,
222
+ id,
223
+ input: params,
224
+ api
225
+ };
226
+ return await this.app.executeAction(this.objectName, actionName, ctx);
231
227
  }
232
228
  }
@@ -0,0 +1,58 @@
1
+ import { ObjectQL } from '../src';
2
+ import { MockDriver } from './utils';
3
+
4
+ describe('ObjectQL Actions', () => {
5
+ let app: ObjectQL;
6
+ let driver: MockDriver;
7
+
8
+ beforeEach(async () => {
9
+ driver = new MockDriver();
10
+ app = new ObjectQL({
11
+ datasources: {
12
+ default: driver
13
+ },
14
+ objects: {
15
+ 'invoice': {
16
+ name: 'invoice',
17
+ fields: {
18
+ amount: { type: 'number' },
19
+ status: { type: 'text' }
20
+ },
21
+ actions: {
22
+ 'pay': {
23
+ label: 'Pay Invoice',
24
+ params: {
25
+ method: { type: 'text' }
26
+ }
27
+ }
28
+ }
29
+ }
30
+ }
31
+ });
32
+ await app.init();
33
+ });
34
+
35
+ it('should register and execute an action', async () => {
36
+ const repo = app.createContext({}).object('invoice');
37
+
38
+ let actionCalled = false;
39
+ app.registerAction('invoice', 'pay', async (ctx) => {
40
+ actionCalled = true;
41
+ expect(ctx.objectName).toBe('invoice');
42
+ expect(ctx.actionName).toBe('pay');
43
+ expect(ctx.id).toBe('inv-123');
44
+ expect(ctx.input.method).toBe('credit_card');
45
+ return { success: true, paid: true };
46
+ });
47
+
48
+ const result = await repo.execute('pay', 'inv-123', { method: 'credit_card' });
49
+
50
+ expect(actionCalled).toBe(true);
51
+ expect(result.success).toBe(true);
52
+ });
53
+
54
+ it('should throw error if action not registered', async () => {
55
+ const repo = app.createContext({}).object('invoice');
56
+ await expect(repo.execute('refund', '1', {})).rejects.toThrow("Action 'refund' not found for object 'invoice'");
57
+ });
58
+ });
@@ -0,0 +1,8 @@
1
+ module.exports = {
2
+ listenTo: 'project',
3
+ closeProject: {
4
+ handler: async function(ctx) {
5
+ return { success: true };
6
+ }
7
+ }
8
+ };
@@ -0,0 +1,60 @@
1
+ import { ObjectQL } from '../src';
2
+ import { MockDriver } from './utils';
3
+
4
+ describe('ObjectQL Hooks', () => {
5
+ let app: ObjectQL;
6
+ let driver: MockDriver;
7
+
8
+ beforeEach(async () => {
9
+ driver = new MockDriver();
10
+ app = new ObjectQL({
11
+ datasources: {
12
+ default: driver
13
+ },
14
+ objects: {
15
+ 'post': {
16
+ name: 'post',
17
+ fields: {
18
+ title: { type: 'text' },
19
+ status: { type: 'text' }
20
+ }
21
+ }
22
+ }
23
+ });
24
+ await app.init();
25
+ });
26
+
27
+ it('should trigger beforeFind and modify query', async () => {
28
+ const repo = app.createContext({}).object('post');
29
+
30
+ let hookTriggered = false;
31
+ app.on('beforeFind', 'post', async (ctx) => {
32
+ hookTriggered = true;
33
+ (ctx as any).query = { ...(ctx as any).query, filters: [['status', '=', 'published']] };
34
+ });
35
+
36
+ // Mock driver find to check query
37
+ const spyFind = jest.spyOn(driver, 'find');
38
+
39
+ await repo.find({});
40
+
41
+ expect(hookTriggered).toBe(true);
42
+ expect(spyFind).toHaveBeenCalledWith('post', { filters: [['status', '=', 'published']] }, expect.any(Object));
43
+ });
44
+
45
+ it('should trigger afterCreate and return result', async () => {
46
+ const repo = app.createContext({ userId: 'u1' }).object('post');
47
+
48
+ app.on('afterCreate', 'post', async (ctx) => {
49
+ if (ctx.result) {
50
+ ctx.result.augmented = true;
51
+ }
52
+ });
53
+
54
+ const created = await repo.create({ title: 'New Post' });
55
+
56
+ expect(created.id).toBeDefined();
57
+ expect(created.created_by).toBe('u1');
58
+ expect(created.augmented).toBe(true);
59
+ });
60
+ });
@@ -11,12 +11,5 @@ describe('Loader', () => {
11
11
  expect(configs['project'].fields).toBeDefined();
12
12
  expect(configs['project'].fields.name).toBeDefined();
13
13
  });
14
-
15
- it('should load actions from .action.ts files', () => {
16
- const fixturesDir = path.join(__dirname, 'fixtures');
17
- const configs = loadObjectConfigs(fixturesDir);
18
- expect(configs['project'].actions).toBeDefined();
19
- expect(configs['project'].actions!.closeProject).toBeDefined();
20
- expect(typeof configs['project'].actions!.closeProject.handler).toBe('function');
21
- });
22
14
  });
15
+
@@ -1,5 +1,5 @@
1
1
  import { ObjectQL } from '../src/index';
2
- import { ObjectConfig } from '../src/metadata';
2
+ import { ObjectConfig } from '@objectql/types';
3
3
  import * as fs from 'fs';
4
4
  import * as path from 'path';
5
5
  import * as yaml from 'js-yaml';
@@ -1,4 +1,4 @@
1
- import { Driver } from '../src/driver';
1
+ import { Driver } from '@objectql/types';
2
2
 
3
3
  export class MockDriver implements Driver {
4
4
  private data: Record<string, any[]> = {};
@@ -0,0 +1,119 @@
1
+
2
+ import { ObjectQL } from '../src';
3
+ import { ObjectConfig } from '@objectql/types';
4
+
5
+ describe('ObjectQL Remote Federation', () => {
6
+ let originalFetch: any;
7
+
8
+ beforeAll(() => {
9
+ originalFetch = global.fetch;
10
+ });
11
+
12
+ afterAll(() => {
13
+ global.fetch = originalFetch;
14
+ });
15
+
16
+ it('should load remote objects and proxy queries', async () => {
17
+ // 1. Mock Fetch
18
+ const mockFetch = jest.fn();
19
+ global.fetch = mockFetch;
20
+
21
+ const remoteUrl = 'http://remote-service:3000';
22
+
23
+ // Mock Responses
24
+ mockFetch.mockImplementation(async (url: string, options: any) => {
25
+ // A. Metadata List
26
+ if (url === `${remoteUrl}/api/metadata/objects`) {
27
+ return {
28
+ ok: true,
29
+ json: async () => ({
30
+ objects: [
31
+ { name: 'remote_user', label: 'Remote User' }
32
+ ]
33
+ })
34
+ };
35
+ }
36
+
37
+ // B. Object Detail
38
+ if (url === `${remoteUrl}/api/metadata/objects/remote_user`) {
39
+ return {
40
+ ok: true,
41
+ json: async () => ({
42
+ name: 'remote_user',
43
+ fields: {
44
+ name: { type: 'text' },
45
+ email: { type: 'text' }
46
+ }
47
+ } as ObjectConfig)
48
+ };
49
+ }
50
+
51
+ // C. Data Query (find)
52
+ if (url === `${remoteUrl}/api/objectql`) {
53
+ const body = JSON.parse(options.body);
54
+ if (body.op === 'find' && body.object === 'remote_user') {
55
+ return {
56
+ ok: true,
57
+ json: async () => ({
58
+ data: [
59
+ { id: 1, name: 'Alice', email: 'alice@example.com' }
60
+ ]
61
+ })
62
+ };
63
+ }
64
+ }
65
+
66
+ return { ok: false, status: 404 };
67
+ });
68
+
69
+ // 2. Init ObjectQL with remotes
70
+ const app = new ObjectQL({
71
+ remotes: [remoteUrl]
72
+ });
73
+
74
+ await app.init();
75
+
76
+ // 3. Verify Schema is loaded
77
+ const config = app.getObject('remote_user');
78
+ expect(config).toBeDefined();
79
+ expect(config?.datasource).toBe(`remote:${remoteUrl}`);
80
+
81
+ // 4. Verify Query is proxied
82
+ // Note: 'object()' is on Context, not App. We need to create a context first.
83
+ const ctx = app.createContext({});
84
+ const users = await ctx.object('remote_user').find();
85
+
86
+ expect(users).toHaveLength(1);
87
+ expect(users[0].name).toBe('Alice');
88
+
89
+ // Verify fetch was called correctly
90
+ expect(mockFetch).toHaveBeenCalledTimes(3);
91
+ // 1. api/metadata/objects -> List
92
+ // 2. api/metadata/objects/remote_user -> Detail
93
+ // 3. api/objectql -> Query
94
+ });
95
+
96
+ it('should handle remote errors gracefully', async () => {
97
+ const mockFetch = jest.fn();
98
+ global.fetch = mockFetch;
99
+ const remoteUrl = 'http://broken-service:3000';
100
+
101
+ // Mock Failure
102
+ mockFetch.mockResolvedValue({
103
+ ok: false,
104
+ status: 500,
105
+ statusText: 'Internal Server Error'
106
+ });
107
+
108
+ const app = new ObjectQL({
109
+ remotes: [remoteUrl]
110
+ });
111
+
112
+ // Should not throw, just log warning (which we can spy on if we want, but preventing crash is key)
113
+ await expect(app.init()).resolves.not.toThrow();
114
+
115
+ // Object shouldn't exist
116
+ const config = app.getObject('remote_user');
117
+ expect(config).toBeUndefined();
118
+ });
119
+ });