@objectstack/objectql 3.3.0 → 4.0.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.
package/src/plugin.ts CHANGED
@@ -175,7 +175,7 @@ export class ObjectQLPlugin implements Plugin {
175
175
  if (hookCtx.input?.id && !hookCtx.previous) {
176
176
  try {
177
177
  const existing = await this.ql!.findOne(hookCtx.object, {
178
- filter: { id: hookCtx.input.id }
178
+ where: { id: hookCtx.input.id }
179
179
  });
180
180
  if (existing) {
181
181
  hookCtx.previous = existing;
@@ -191,7 +191,7 @@ export class ObjectQLPlugin implements Plugin {
191
191
  if (hookCtx.input?.id && !hookCtx.previous) {
192
192
  try {
193
193
  const existing = await this.ql!.findOne(hookCtx.object, {
194
- filter: { id: hookCtx.input.id }
194
+ where: { id: hookCtx.input.id }
195
195
  });
196
196
  if (existing) {
197
197
  hookCtx.previous = existing;
@@ -239,9 +239,13 @@ export class ObjectQLPlugin implements Plugin {
239
239
  /**
240
240
  * Synchronize all registered object schemas to the database.
241
241
  *
242
- * Iterates every object in the SchemaRegistry and calls the
243
- * responsible driver's `syncSchema()` for each one. This is
244
- * idempotent drivers must tolerate repeated calls without
242
+ * Groups objects by their responsible driver, then:
243
+ * - If the driver advertises `supports.batchSchemaSync` and implements
244
+ * `syncSchemasBatch()`, submits all schemas in a single call (reducing
245
+ * network round-trips for remote drivers like Turso).
246
+ * - Otherwise falls back to sequential `syncSchema()` per object.
247
+ *
248
+ * This is idempotent — drivers must tolerate repeated calls without
245
249
  * duplicating tables or erroring out.
246
250
  *
247
251
  * Drivers that do not implement `syncSchema` are silently skipped.
@@ -255,6 +259,9 @@ export class ObjectQLPlugin implements Plugin {
255
259
  let synced = 0;
256
260
  let skipped = 0;
257
261
 
262
+ // Group objects by driver for potential batch optimization
263
+ const driverGroups = new Map<any, Array<{ obj: any; tableName: string }>>();
264
+
258
265
  for (const obj of allObjects) {
259
266
  const driver = this.ql.getDriverForObject(obj.name);
260
267
  if (!driver) {
@@ -274,21 +281,69 @@ export class ObjectQLPlugin implements Plugin {
274
281
  continue;
275
282
  }
276
283
 
277
- // Use the physical table name (e.g., 'sys_user') for DDL operations
278
- // instead of the FQN (e.g., 'sys__user'). ObjectSchema.create()
279
- // auto-derives tableName as {namespace}_{name}.
280
284
  const tableName = obj.tableName || obj.name;
281
285
 
282
- try {
283
- await driver.syncSchema(tableName, obj);
284
- synced++;
285
- } catch (e: unknown) {
286
- ctx.logger.warn('Failed to sync schema for object', {
287
- object: obj.name,
288
- tableName,
289
- driver: driver.name,
290
- error: e instanceof Error ? e.message : String(e),
291
- });
286
+ let group = driverGroups.get(driver);
287
+ if (!group) {
288
+ group = [];
289
+ driverGroups.set(driver, group);
290
+ }
291
+ group.push({ obj, tableName });
292
+ }
293
+
294
+ // Process each driver group
295
+ for (const [driver, entries] of driverGroups) {
296
+ // Batch path: driver supports batch schema sync
297
+ if (
298
+ driver.supports?.batchSchemaSync &&
299
+ typeof driver.syncSchemasBatch === 'function'
300
+ ) {
301
+ const batchPayload = entries.map((e) => ({
302
+ object: e.tableName,
303
+ schema: e.obj,
304
+ }));
305
+ try {
306
+ await driver.syncSchemasBatch(batchPayload);
307
+ synced += entries.length;
308
+ ctx.logger.debug('Batch schema sync succeeded', {
309
+ driver: driver.name,
310
+ count: entries.length,
311
+ });
312
+ } catch (e: unknown) {
313
+ ctx.logger.warn('Batch schema sync failed, falling back to sequential', {
314
+ driver: driver.name,
315
+ error: e instanceof Error ? e.message : String(e),
316
+ });
317
+ // Fallback: sequential sync for this driver's objects
318
+ for (const { obj, tableName } of entries) {
319
+ try {
320
+ await driver.syncSchema(tableName, obj);
321
+ synced++;
322
+ } catch (seqErr: unknown) {
323
+ ctx.logger.warn('Failed to sync schema for object', {
324
+ object: obj.name,
325
+ tableName,
326
+ driver: driver.name,
327
+ error: seqErr instanceof Error ? seqErr.message : String(seqErr),
328
+ });
329
+ }
330
+ }
331
+ }
332
+ } else {
333
+ // Sequential path: no batch support
334
+ for (const { obj, tableName } of entries) {
335
+ try {
336
+ await driver.syncSchema(tableName, obj);
337
+ synced++;
338
+ } catch (e: unknown) {
339
+ ctx.logger.warn('Failed to sync schema for object', {
340
+ object: obj.name,
341
+ tableName,
342
+ driver: driver.name,
343
+ error: e instanceof Error ? e.message : String(e),
344
+ });
345
+ }
346
+ }
292
347
  }
293
348
  }
294
349
 
@@ -25,87 +25,88 @@ describe('ObjectStackProtocolImplementation - Data Operations', () => {
25
25
  // ═══════════════════════════════════════════════════════════════
26
26
 
27
27
  describe('findData', () => {
28
- it('should normalize expand string to populate array', async () => {
29
- await protocol.findData({ object: 'order_item', query: { expand: 'order,product' } });
28
+ it('should normalize $expand (OData) string to expand Record', async () => {
29
+ await protocol.findData({ object: 'order_item', query: { $expand: 'order,product' } });
30
30
 
31
31
  expect(mockEngine.find).toHaveBeenCalledWith(
32
32
  'order_item',
33
33
  expect.objectContaining({
34
- populate: ['order', 'product'],
34
+ expand: { order: { object: 'order' }, product: { object: 'product' } },
35
35
  }),
36
36
  );
37
- // expand should be deleted from options
37
+ // $expand should be deleted from options
38
38
  const callArgs = mockEngine.find.mock.calls[0][1];
39
- expect(callArgs.expand).toBeUndefined();
40
39
  expect(callArgs.$expand).toBeUndefined();
41
40
  });
42
41
 
43
- it('should normalize $expand (OData) to populate array', async () => {
42
+ it('should normalize $expand (OData) with different fields to expand Record', async () => {
44
43
  await protocol.findData({ object: 'task', query: { $expand: 'assignee,project' } });
45
44
 
46
45
  expect(mockEngine.find).toHaveBeenCalledWith(
47
46
  'task',
48
47
  expect.objectContaining({
49
- populate: ['assignee', 'project'],
48
+ expand: { assignee: { object: 'assignee' }, project: { object: 'project' } },
50
49
  }),
51
50
  );
52
51
  });
53
52
 
54
- it('should pass populate array as-is if already an array', async () => {
53
+ it('should normalize populate array to expand Record', async () => {
55
54
  await protocol.findData({ object: 'task', query: { populate: ['assignee'] } });
56
55
 
57
56
  expect(mockEngine.find).toHaveBeenCalledWith(
58
57
  'task',
59
58
  expect.objectContaining({
60
- populate: ['assignee'],
59
+ expand: { assignee: { object: 'assignee' } },
61
60
  }),
62
61
  );
63
62
  });
64
63
 
65
- it('should normalize populate string to array', async () => {
64
+ it('should normalize populate string to expand Record', async () => {
66
65
  await protocol.findData({ object: 'task', query: { populate: 'assignee,project' } });
67
66
 
68
67
  expect(mockEngine.find).toHaveBeenCalledWith(
69
68
  'task',
70
69
  expect.objectContaining({
71
- populate: ['assignee', 'project'],
70
+ expand: { assignee: { object: 'assignee' }, project: { object: 'project' } },
72
71
  }),
73
72
  );
74
73
  });
75
74
 
76
- it('should prefer explicit populate over expand', async () => {
75
+ it('should prefer populate names over expand string when both provided', async () => {
77
76
  await protocol.findData({
78
77
  object: 'task',
79
78
  query: { populate: ['assignee'], expand: 'project' },
80
79
  });
81
80
 
82
- // populate takes precedence; expand is not converted
83
- expect(mockEngine.find).toHaveBeenCalledWith(
84
- 'task',
85
- expect.objectContaining({
86
- populate: ['assignee'],
87
- }),
88
- );
81
+ // populate names take precedence; the non-object expand string is
82
+ // cleaned up first, then populate-derived names create the Record.
83
+ const callArgs = mockEngine.find.mock.calls[0][1];
84
+ expect(callArgs.populate).toBeUndefined();
85
+ expect(callArgs.$expand).toBeUndefined();
86
+ expect(callArgs.expand).toEqual({ assignee: { object: 'assignee' } });
89
87
  });
90
88
 
91
- it('should normalize expand array to populate array', async () => {
92
- await protocol.findData({ object: 'task', query: { expand: ['owner', 'team'] } });
89
+ it('should pass expand Record object through as-is', async () => {
90
+ await protocol.findData({
91
+ object: 'task',
92
+ query: { expand: { owner: { object: 'owner' }, team: { object: 'team' } } },
93
+ });
93
94
 
94
95
  expect(mockEngine.find).toHaveBeenCalledWith(
95
96
  'task',
96
97
  expect.objectContaining({
97
- populate: ['owner', 'team'],
98
+ expand: { owner: { object: 'owner' }, team: { object: 'team' } },
98
99
  }),
99
100
  );
100
101
  });
101
102
 
102
- it('should normalize select string to array', async () => {
103
+ it('should normalize select string to fields array', async () => {
103
104
  await protocol.findData({ object: 'task', query: { select: 'name,status,assignee' } });
104
105
 
105
106
  expect(mockEngine.find).toHaveBeenCalledWith(
106
107
  'task',
107
108
  expect.objectContaining({
108
- select: ['name', 'status', 'assignee'],
109
+ fields: ['name', 'status', 'assignee'],
109
110
  }),
110
111
  );
111
112
  });
@@ -116,8 +117,8 @@ describe('ObjectStackProtocolImplementation - Data Operations', () => {
116
117
  expect(mockEngine.find).toHaveBeenCalledWith(
117
118
  'task',
118
119
  expect.objectContaining({
119
- top: 10,
120
- skip: 20,
120
+ limit: 10,
121
+ offset: 20,
121
122
  }),
122
123
  );
123
124
  });
@@ -148,7 +149,7 @@ describe('ObjectStackProtocolImplementation - Data Operations', () => {
148
149
  // ═══════════════════════════════════════════════════════════════
149
150
 
150
151
  describe('getData', () => {
151
- it('should convert expand string to populate array', async () => {
152
+ it('should convert expand string to expand Record', async () => {
152
153
  mockEngine.findOne.mockResolvedValue({ id: 'oi_1', name: 'Item 1' });
153
154
 
154
155
  await protocol.getData({ object: 'order_item', id: 'oi_1', expand: 'order,product' });
@@ -156,13 +157,13 @@ describe('ObjectStackProtocolImplementation - Data Operations', () => {
156
157
  expect(mockEngine.findOne).toHaveBeenCalledWith(
157
158
  'order_item',
158
159
  expect.objectContaining({
159
- filter: { id: 'oi_1' },
160
- populate: ['order', 'product'],
160
+ where: { id: 'oi_1' },
161
+ expand: { order: { object: 'order' }, product: { object: 'product' } },
161
162
  }),
162
163
  );
163
164
  });
164
165
 
165
- it('should convert expand array to populate array', async () => {
166
+ it('should convert expand array to expand Record', async () => {
166
167
  mockEngine.findOne.mockResolvedValue({ id: 't1' });
167
168
 
168
169
  await protocol.getData({ object: 'task', id: 't1', expand: ['assignee', 'project'] });
@@ -170,12 +171,13 @@ describe('ObjectStackProtocolImplementation - Data Operations', () => {
170
171
  expect(mockEngine.findOne).toHaveBeenCalledWith(
171
172
  'task',
172
173
  expect.objectContaining({
173
- populate: ['assignee', 'project'],
174
+ where: { id: 't1' },
175
+ expand: { assignee: { object: 'assignee' }, project: { object: 'project' } },
174
176
  }),
175
177
  );
176
178
  });
177
179
 
178
- it('should convert select string to array', async () => {
180
+ it('should convert select string to fields array', async () => {
179
181
  mockEngine.findOne.mockResolvedValue({ id: 't1', name: 'Test' });
180
182
 
181
183
  await protocol.getData({ object: 'task', id: 't1', select: 'name,status' });
@@ -183,12 +185,13 @@ describe('ObjectStackProtocolImplementation - Data Operations', () => {
183
185
  expect(mockEngine.findOne).toHaveBeenCalledWith(
184
186
  'task',
185
187
  expect.objectContaining({
186
- select: ['name', 'status'],
188
+ where: { id: 't1' },
189
+ fields: ['name', 'status'],
187
190
  }),
188
191
  );
189
192
  });
190
193
 
191
- it('should pass both expand and select together', async () => {
194
+ it('should pass both expand and fields together', async () => {
192
195
  mockEngine.findOne.mockResolvedValue({ id: 'oi_1' });
193
196
 
194
197
  await protocol.getData({
@@ -201,9 +204,9 @@ describe('ObjectStackProtocolImplementation - Data Operations', () => {
201
204
  expect(mockEngine.findOne).toHaveBeenCalledWith(
202
205
  'order_item',
203
206
  expect.objectContaining({
204
- filter: { id: 'oi_1' },
205
- populate: ['order'],
206
- select: ['name', 'total'],
207
+ where: { id: 'oi_1' },
208
+ expand: { order: { object: 'order' } },
209
+ fields: ['name', 'total'],
207
210
  }),
208
211
  );
209
212
  });
@@ -215,7 +218,7 @@ describe('ObjectStackProtocolImplementation - Data Operations', () => {
215
218
 
216
219
  expect(mockEngine.findOne).toHaveBeenCalledWith(
217
220
  'task',
218
- { filter: { id: 't1' } },
221
+ { where: { id: 't1' } },
219
222
  );
220
223
  });
221
224
 
@@ -92,7 +92,7 @@ describe('ObjectStackProtocolImplementation - Dynamic Service Discovery', () =>
92
92
 
93
93
  // Core services should always be available
94
94
  expect(discovery.services.metadata.enabled).toBe(true);
95
- expect(discovery.services.metadata.status).toBe('degraded');
95
+ expect(discovery.services.metadata.status).toBe('available');
96
96
  expect(discovery.services.data.enabled).toBe(true);
97
97
  expect(discovery.services.data.status).toBe('available');
98
98
  expect(discovery.services.analytics.enabled).toBe(true);
@@ -148,14 +148,14 @@ describe('ObjectStackProtocolImplementation - Dynamic Service Discovery', () =>
148
148
  expect(discovery.capabilities).toBeDefined();
149
149
  // workflow is registered but doesn't map to a well-known capability directly
150
150
  expect(discovery.services.workflow.enabled).toBe(true);
151
- // All well-known capabilities should be false since workflow doesn't map to any
152
- expect(discovery.capabilities!.feed).toBe(false);
153
- expect(discovery.capabilities!.comments).toBe(false);
154
- expect(discovery.capabilities!.automation).toBe(false);
155
- expect(discovery.capabilities!.cron).toBe(false);
156
- expect(discovery.capabilities!.search).toBe(false);
157
- expect(discovery.capabilities!.export).toBe(false);
158
- expect(discovery.capabilities!.chunkedUpload).toBe(false);
151
+ // All well-known capabilities should be disabled since workflow doesn't map to any
152
+ expect(discovery.capabilities!.feed).toEqual({ enabled: false });
153
+ expect(discovery.capabilities!.comments).toEqual({ enabled: false });
154
+ expect(discovery.capabilities!.automation).toEqual({ enabled: false });
155
+ expect(discovery.capabilities!.cron).toEqual({ enabled: false });
156
+ expect(discovery.capabilities!.search).toEqual({ enabled: false });
157
+ expect(discovery.capabilities!.export).toEqual({ enabled: false });
158
+ expect(discovery.capabilities!.chunkedUpload).toEqual({ enabled: false });
159
159
  });
160
160
 
161
161
  it('should set all capabilities to false when no services are registered', async () => {
@@ -163,13 +163,13 @@ describe('ObjectStackProtocolImplementation - Dynamic Service Discovery', () =>
163
163
  const discovery = await protocol.getDiscovery();
164
164
 
165
165
  expect(discovery.capabilities).toBeDefined();
166
- expect(discovery.capabilities!.feed).toBe(false);
167
- expect(discovery.capabilities!.comments).toBe(false);
168
- expect(discovery.capabilities!.automation).toBe(false);
169
- expect(discovery.capabilities!.cron).toBe(false);
170
- expect(discovery.capabilities!.search).toBe(false);
171
- expect(discovery.capabilities!.export).toBe(false);
172
- expect(discovery.capabilities!.chunkedUpload).toBe(false);
166
+ expect(discovery.capabilities!.feed).toEqual({ enabled: false });
167
+ expect(discovery.capabilities!.comments).toEqual({ enabled: false });
168
+ expect(discovery.capabilities!.automation).toEqual({ enabled: false });
169
+ expect(discovery.capabilities!.cron).toEqual({ enabled: false });
170
+ expect(discovery.capabilities!.search).toEqual({ enabled: false });
171
+ expect(discovery.capabilities!.export).toEqual({ enabled: false });
172
+ expect(discovery.capabilities!.chunkedUpload).toEqual({ enabled: false });
173
173
  });
174
174
 
175
175
  it('should dynamically set capabilities based on registered services', async () => {
@@ -182,13 +182,13 @@ describe('ObjectStackProtocolImplementation - Dynamic Service Discovery', () =>
182
182
  protocol = new ObjectStackProtocolImplementation(engine, () => mockServices);
183
183
  const discovery = await protocol.getDiscovery();
184
184
 
185
- expect(discovery.capabilities!.feed).toBe(true);
186
- expect(discovery.capabilities!.comments).toBe(true);
187
- expect(discovery.capabilities!.automation).toBe(true);
188
- expect(discovery.capabilities!.cron).toBe(false);
189
- expect(discovery.capabilities!.search).toBe(true);
190
- expect(discovery.capabilities!.export).toBe(true);
191
- expect(discovery.capabilities!.chunkedUpload).toBe(true);
185
+ expect(discovery.capabilities!.feed).toEqual({ enabled: true });
186
+ expect(discovery.capabilities!.comments).toEqual({ enabled: true });
187
+ expect(discovery.capabilities!.automation).toEqual({ enabled: true });
188
+ expect(discovery.capabilities!.cron).toEqual({ enabled: false });
189
+ expect(discovery.capabilities!.search).toEqual({ enabled: true });
190
+ expect(discovery.capabilities!.export).toEqual({ enabled: true });
191
+ expect(discovery.capabilities!.chunkedUpload).toEqual({ enabled: true });
192
192
  });
193
193
 
194
194
  it('should enable cron capability when job service is registered', async () => {
@@ -198,7 +198,7 @@ describe('ObjectStackProtocolImplementation - Dynamic Service Discovery', () =>
198
198
  protocol = new ObjectStackProtocolImplementation(engine, () => mockServices);
199
199
  const discovery = await protocol.getDiscovery();
200
200
 
201
- expect(discovery.capabilities!.cron).toBe(true);
201
+ expect(discovery.capabilities!.cron).toEqual({ enabled: true });
202
202
  });
203
203
 
204
204
  it('should enable export capability when queue service is registered', async () => {
@@ -208,6 +208,6 @@ describe('ObjectStackProtocolImplementation - Dynamic Service Discovery', () =>
208
208
  protocol = new ObjectStackProtocolImplementation(engine, () => mockServices);
209
209
  const discovery = await protocol.getDiscovery();
210
210
 
211
- expect(discovery.capabilities!.export).toBe(true);
211
+ expect(discovery.capabilities!.export).toEqual({ enabled: true });
212
212
  });
213
213
  });