@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/protocol.ts CHANGED
@@ -76,7 +76,7 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
76
76
  // Build dynamic service info with proper typing
77
77
  const services: Record<string, ServiceInfo> = {
78
78
  // --- Kernel-provided (objectql is an example kernel implementation) ---
79
- metadata: { enabled: true, status: 'degraded' as const, route: '/api/v1/meta', provider: 'objectql', message: 'In-memory registry only; DB persistence not yet implemented' },
79
+ metadata: { enabled: true, status: 'available' as const, route: '/api/v1/meta', provider: 'objectql' },
80
80
  data: { enabled: true, status: 'available' as const, route: '/api/v1/data', provider: 'objectql' },
81
81
  analytics: { enabled: true, status: 'available' as const, route: '/api/v1/analytics', provider: 'objectql' },
82
82
  };
@@ -151,8 +151,10 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
151
151
  ...optionalRoutes,
152
152
  };
153
153
 
154
- // Build well-known capabilities from registered services
155
- const capabilities: WellKnownCapabilities = {
154
+ // Build well-known capabilities from registered services.
155
+ // DiscoverySchema defines capabilities as Record<string, { enabled, features?, description? }>
156
+ // (hierarchical format). We also keep a flat WellKnownCapabilities for backward compat.
157
+ const wellKnown: WellKnownCapabilities = {
156
158
  feed: registeredServices.has('feed'),
157
159
  comments: registeredServices.has('feed'),
158
160
  automation: registeredServices.has('automation'),
@@ -162,6 +164,12 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
162
164
  chunkedUpload: registeredServices.has('file-storage'),
163
165
  };
164
166
 
167
+ // Convert flat booleans → hierarchical capability objects
168
+ const capabilities: Record<string, { enabled: boolean; description?: string }> = {};
169
+ for (const [key, enabled] of Object.entries(wellKnown)) {
170
+ capabilities[key] = { enabled };
171
+ }
172
+
165
173
  return {
166
174
  version: '1.0',
167
175
  apiName: 'ObjectStack API',
@@ -184,6 +192,43 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
184
192
  const alt = request.type.endsWith('s') ? request.type.slice(0, -1) : request.type + 's';
185
193
  items = SchemaRegistry.listItems(alt);
186
194
  }
195
+
196
+ // Fallback to database if registry is empty for this type
197
+ if (items.length === 0) {
198
+ try {
199
+ const allRecords = await this.engine.find('sys_metadata', {
200
+ where: { type: request.type, state: 'active' }
201
+ });
202
+ if (allRecords && allRecords.length > 0) {
203
+ items = allRecords.map((record: any) => {
204
+ const data = typeof record.metadata === 'string'
205
+ ? JSON.parse(record.metadata)
206
+ : record.metadata;
207
+ // Hydrate back into registry
208
+ SchemaRegistry.registerItem(request.type, data, 'name' as any);
209
+ return data;
210
+ });
211
+ } else {
212
+ // Try alternate type name in DB
213
+ const alt = request.type.endsWith('s') ? request.type.slice(0, -1) : request.type + 's';
214
+ const altRecords = await this.engine.find('sys_metadata', {
215
+ where: { type: alt, state: 'active' }
216
+ });
217
+ if (altRecords && altRecords.length > 0) {
218
+ items = altRecords.map((record: any) => {
219
+ const data = typeof record.metadata === 'string'
220
+ ? JSON.parse(record.metadata)
221
+ : record.metadata;
222
+ SchemaRegistry.registerItem(request.type, data, 'name' as any);
223
+ return data;
224
+ });
225
+ }
226
+ }
227
+ } catch {
228
+ // DB not available, return registry results (empty)
229
+ }
230
+ }
231
+
187
232
  return {
188
233
  type: request.type,
189
234
  items
@@ -197,6 +242,38 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
197
242
  const alt = request.type.endsWith('s') ? request.type.slice(0, -1) : request.type + 's';
198
243
  item = SchemaRegistry.getItem(alt, request.name);
199
244
  }
245
+
246
+ // Fallback to database if not in registry
247
+ if (item === undefined) {
248
+ try {
249
+ const record = await this.engine.findOne('sys_metadata', {
250
+ where: { type: request.type, name: request.name, state: 'active' }
251
+ });
252
+ if (record) {
253
+ item = typeof record.metadata === 'string'
254
+ ? JSON.parse(record.metadata)
255
+ : record.metadata;
256
+ // Hydrate back into registry for next time
257
+ SchemaRegistry.registerItem(request.type, item, 'name' as any);
258
+ } else {
259
+ // Try alternate type name
260
+ const alt = request.type.endsWith('s') ? request.type.slice(0, -1) : request.type + 's';
261
+ const altRecord = await this.engine.findOne('sys_metadata', {
262
+ where: { type: alt, name: request.name, state: 'active' }
263
+ });
264
+ if (altRecord) {
265
+ item = typeof altRecord.metadata === 'string'
266
+ ? JSON.parse(altRecord.metadata)
267
+ : altRecord.metadata;
268
+ // Hydrate back into registry for next time
269
+ SchemaRegistry.registerItem(request.type, item, 'name' as any);
270
+ }
271
+ }
272
+ } catch {
273
+ // DB not available, return undefined
274
+ }
275
+ }
276
+
200
277
  return {
201
278
  type: request.type,
202
279
  name: request.name,
@@ -279,17 +356,31 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
279
356
  async findData(request: { object: string, query?: any }) {
280
357
  const options: any = { ...request.query };
281
358
 
282
- // Numeric fields
283
- if (options.top != null) options.top = Number(options.top);
284
- if (options.skip != null) options.skip = Number(options.skip);
359
+ // ====================================================================
360
+ // Normalize legacy params QueryAST standard (where/fields/orderBy/offset/expand)
361
+ // ====================================================================
362
+
363
+ // Numeric fields — normalize top → limit, skip → offset
364
+ if (options.top != null) {
365
+ options.limit = Number(options.top);
366
+ delete options.top;
367
+ }
368
+ if (options.skip != null) {
369
+ options.offset = Number(options.skip);
370
+ delete options.skip;
371
+ }
285
372
  if (options.limit != null) options.limit = Number(options.limit);
373
+ if (options.offset != null) options.offset = Number(options.offset);
286
374
 
287
- // Select: comma-separated string → array
375
+ // Select → fields: comma-separated string → array
288
376
  if (typeof options.select === 'string') {
289
- options.select = options.select.split(',').map((s: string) => s.trim()).filter(Boolean);
377
+ options.fields = options.select.split(',').map((s: string) => s.trim()).filter(Boolean);
378
+ } else if (Array.isArray(options.select)) {
379
+ options.fields = options.select;
290
380
  }
381
+ if (options.select !== undefined) delete options.select;
291
382
 
292
- // Sort/orderBy: string → sort array (e.g. "name asc,created_at desc" or "name,-created_at")
383
+ // Sort/orderBy → orderBy: string → SortNode[] array
293
384
  const sortValue = options.orderBy ?? options.sort;
294
385
  if (typeof sortValue === 'string') {
295
386
  const parsed = sortValue.split(',').map((part: string) => {
@@ -300,45 +391,62 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
300
391
  const [field, order] = trimmed.split(/\s+/);
301
392
  return { field, order: (order?.toLowerCase() === 'desc' ? 'desc' : 'asc') as 'asc' | 'desc' };
302
393
  }).filter((s: any) => s.field);
303
- options.sort = parsed;
304
- delete options.orderBy;
394
+ options.orderBy = parsed;
395
+ } else if (Array.isArray(sortValue)) {
396
+ options.orderBy = sortValue;
305
397
  }
398
+ delete options.sort;
306
399
 
307
- // Filter: normalize `filter`/`filters` (plural) → `filter` (singular, canonical)
308
- // Accept both `filter` and `filters` for backward compatibility.
309
- if (options.filters !== undefined && options.filter === undefined) {
310
- options.filter = options.filters;
311
- }
400
+ // Filter/filters/$filter → where: normalize all filter aliases
401
+ const filterValue = options.filter ?? options.filters ?? options.$filter ?? options.where;
402
+ delete options.filter;
312
403
  delete options.filters;
404
+ delete options.$filter;
313
405
 
314
- // Filter: JSON string → object
315
- if (typeof options.filter === 'string') {
316
- try { options.filter = JSON.parse(options.filter); } catch { /* keep as-is */ }
317
- }
318
-
319
- // Filter AST array → FilterCondition object
320
- // Converts ["and", ["field", "=", "val"], ...] to { $and: [{ field: "val" }, ...] }
321
- if (isFilterAST(options.filter)) {
322
- options.filter = parseFilterAST(options.filter);
323
- }
324
-
325
- // Populate: comma-separated string → array
326
- if (typeof options.populate === 'string') {
327
- options.populate = options.populate.split(',').map((s: string) => s.trim()).filter(Boolean);
406
+ if (filterValue !== undefined) {
407
+ let parsedFilter = filterValue;
408
+ // JSON string object
409
+ if (typeof parsedFilter === 'string') {
410
+ try { parsedFilter = JSON.parse(parsedFilter); } catch { /* keep as-is */ }
411
+ }
412
+ // Filter AST array FilterCondition object
413
+ if (isFilterAST(parsedFilter)) {
414
+ parsedFilter = parseFilterAST(parsedFilter);
415
+ }
416
+ options.where = parsedFilter;
328
417
  }
329
418
 
330
- // ExpandPopulate: normalize $expand/expand to populate array
331
- // Supports OData $expand, REST expand, and JSON-RPC expand parameters
419
+ // Populate/expand/$expand → expand (Record<string, QueryAST>)
420
+ const populateValue = options.populate;
332
421
  const expandValue = options.$expand ?? options.expand;
333
- if (expandValue && !options.populate) {
422
+ const expandNames: string[] = [];
423
+ if (typeof populateValue === 'string') {
424
+ expandNames.push(...populateValue.split(',').map((s: string) => s.trim()).filter(Boolean));
425
+ } else if (Array.isArray(populateValue)) {
426
+ expandNames.push(...populateValue);
427
+ }
428
+ if (!expandNames.length && expandValue) {
334
429
  if (typeof expandValue === 'string') {
335
- options.populate = expandValue.split(',').map((s: string) => s.trim()).filter(Boolean);
430
+ expandNames.push(...expandValue.split(',').map((s: string) => s.trim()).filter(Boolean));
336
431
  } else if (Array.isArray(expandValue)) {
337
- options.populate = expandValue;
432
+ expandNames.push(...expandValue);
338
433
  }
339
434
  }
435
+ delete options.populate;
340
436
  delete options.$expand;
341
- delete options.expand;
437
+ // Clean up non-object expand (e.g. string) BEFORE the Record conversion
438
+ // below, so that populate-derived names can create the expand Record even
439
+ // when a legacy string expand was also present.
440
+ if (typeof options.expand !== 'object' || options.expand === null) {
441
+ delete options.expand;
442
+ }
443
+ // Only set expand if not already an object (advanced usage)
444
+ if (expandNames.length > 0 && !options.expand) {
445
+ options.expand = {} as Record<string, any>;
446
+ for (const rel of expandNames) {
447
+ options.expand[rel] = { object: rel };
448
+ }
449
+ }
342
450
 
343
451
  // Boolean fields
344
452
  for (const key of ['distinct', 'count']) {
@@ -348,20 +456,18 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
348
456
 
349
457
  // Flat field filters: REST-style query params like ?id=abc&status=open
350
458
  // After extracting all known query parameters, any remaining keys are
351
- // treated as implicit field-level equality filters. This is the standard
352
- // REST convention used by the client when serializing simple filter maps
353
- // (e.g. `{ filters: { id: "..." } }` → `?id=...`).
459
+ // treated as implicit field-level equality filters merged into `where`.
354
460
  const knownParams = new Set([
355
- 'top', 'skip', 'limit', 'offset',
356
- 'sort', 'orderBy',
357
- 'select', 'fields',
358
- 'filter', 'filters', '$filter',
359
- 'populate', 'expand', '$expand',
461
+ 'top', 'limit', 'offset',
462
+ 'orderBy',
463
+ 'fields',
464
+ 'where',
465
+ 'expand',
360
466
  'distinct', 'count',
361
467
  'aggregations', 'groupBy',
362
- 'search', 'context',
468
+ 'search', 'context', 'cursor',
363
469
  ]);
364
- if (!options.filter) {
470
+ if (!options.where) {
365
471
  const implicitFilters: Record<string, unknown> = {};
366
472
  for (const key of Object.keys(options)) {
367
473
  if (!knownParams.has(key)) {
@@ -370,14 +476,14 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
370
476
  }
371
477
  }
372
478
  if (Object.keys(implicitFilters).length > 0) {
373
- options.filter = implicitFilters;
479
+ options.where = implicitFilters;
374
480
  }
375
481
  }
376
482
 
377
483
  const records = await this.engine.find(request.object, options);
378
484
  return {
379
485
  object: request.object,
380
- value: records, // OData compaibility
486
+ value: records, // OData compatibility
381
487
  records, // Legacy
382
488
  total: records.length,
383
489
  hasMore: false
@@ -386,21 +492,25 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
386
492
 
387
493
  async getData(request: { object: string, id: string, expand?: string | string[], select?: string | string[] }) {
388
494
  const queryOptions: any = {
389
- filter: { id: request.id }
495
+ where: { id: request.id }
390
496
  };
391
497
 
392
- // Support select for single-record retrieval
498
+ // Support fields for single-record retrieval
393
499
  if (request.select) {
394
- queryOptions.select = typeof request.select === 'string'
500
+ queryOptions.fields = typeof request.select === 'string'
395
501
  ? request.select.split(',').map((s: string) => s.trim()).filter(Boolean)
396
502
  : request.select;
397
503
  }
398
504
 
399
505
  // Support expand for single-record retrieval
400
506
  if (request.expand) {
401
- queryOptions.populate = typeof request.expand === 'string'
507
+ const expandNames = typeof request.expand === 'string'
402
508
  ? request.expand.split(',').map((s: string) => s.trim()).filter(Boolean)
403
509
  : request.expand;
510
+ queryOptions.expand = {} as Record<string, any>;
511
+ for (const rel of expandNames) {
512
+ queryOptions.expand[rel] = { object: rel };
513
+ }
404
514
  }
405
515
 
406
516
  const result = await this.engine.findOne(request.object, queryOptions);
@@ -425,7 +535,7 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
425
535
 
426
536
  async updateData(request: { object: string, id: string, data: any }) {
427
537
  // Adapt: update(obj, id, data) -> update(obj, data, options)
428
- const result = await this.engine.update(request.object, request.data, { filter: { id: request.id } });
538
+ const result = await this.engine.update(request.object, request.data, { where: { id: request.id } });
429
539
  return {
430
540
  object: request.object,
431
541
  id: request.id,
@@ -435,7 +545,7 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
435
545
 
436
546
  async deleteData(request: { object: string, id: string }) {
437
547
  // Adapt: delete(obj, id) -> delete(obj, options)
438
- await this.engine.delete(request.object, { filter: { id: request.id } });
548
+ await this.engine.delete(request.object, { where: { id: request.id } });
439
549
  return {
440
550
  object: request.object,
441
551
  id: request.id,
@@ -509,7 +619,7 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
509
619
  }
510
620
  case 'update': {
511
621
  if (!record.id) throw new Error('Record id is required for update');
512
- const updated = await this.engine.update(object, record.data || {}, { filter: { id: record.id } });
622
+ const updated = await this.engine.update(object, record.data || {}, { where: { id: record.id } });
513
623
  results.push({ id: record.id, success: true, record: updated });
514
624
  succeeded++;
515
625
  break;
@@ -518,9 +628,9 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
518
628
  // Try update first, then create if not found
519
629
  if (record.id) {
520
630
  try {
521
- const existing = await this.engine.findOne(object, { filter: { id: record.id } });
631
+ const existing = await this.engine.findOne(object, { where: { id: record.id } });
522
632
  if (existing) {
523
- const updated = await this.engine.update(object, record.data || {}, { filter: { id: record.id } });
633
+ const updated = await this.engine.update(object, record.data || {}, { where: { id: record.id } });
524
634
  results.push({ id: record.id, success: true, record: updated });
525
635
  } else {
526
636
  const created = await this.engine.insert(object, { id: record.id, ...(record.data || {}) });
@@ -539,7 +649,7 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
539
649
  }
540
650
  case 'delete': {
541
651
  if (!record.id) throw new Error('Record id is required for delete');
542
- await this.engine.delete(object, { filter: { id: record.id } });
652
+ await this.engine.delete(object, { where: { id: record.id } });
543
653
  results.push({ id: record.id, success: true });
544
654
  succeeded++;
545
655
  break;
@@ -588,7 +698,7 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
588
698
 
589
699
  for (const record of records) {
590
700
  try {
591
- const updated = await this.engine.update(object, record.data, { filter: { id: record.id } });
701
+ const updated = await this.engine.update(object, record.data, { where: { id: record.id } });
592
702
  results.push({ id: record.id, success: true, record: updated });
593
703
  succeeded++;
594
704
  } catch (err: any) {
@@ -655,11 +765,11 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
655
765
 
656
766
  // Execute via engine.aggregate (which delegates to driver.find with groupBy/aggregations)
657
767
  const rows = await this.engine.aggregate(object, {
658
- filter,
768
+ where: filter,
659
769
  groupBy: groupBy.length > 0 ? groupBy : undefined,
660
770
  aggregations: aggregations.length > 0
661
- ? aggregations.map(a => ({ field: a.field, method: a.method as any, alias: a.alias }))
662
- : [{ field: '*', method: 'count' as any, alias: 'count' }],
771
+ ? aggregations.map(a => ({ function: a.method as any, field: a.field, alias: a.alias }))
772
+ : [{ function: 'count' as any, alias: 'count' }],
663
773
  });
664
774
 
665
775
  // Build field metadata
@@ -790,7 +900,7 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
790
900
  async deleteManyData(request: DeleteManyDataRequest): Promise<any> {
791
901
  // This expects deleting by IDs.
792
902
  return this.engine.delete(request.object, {
793
- filter: { id: { $in: request.ids } },
903
+ where: { id: { $in: request.ids } },
794
904
  ...request.options
795
905
  });
796
906
  }
@@ -799,12 +909,92 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
799
909
  if (!request.item) {
800
910
  throw new Error('Item data is required');
801
911
  }
802
- // Default implementation saves to Memory Registry
912
+
913
+ // 1. Always update the in-memory registry (runtime cache)
803
914
  SchemaRegistry.registerItem(request.type, request.item, 'name');
804
- return {
805
- success: true,
806
- message: 'Saved to memory registry'
807
- };
915
+
916
+ // 2. Persist to database via data engine
917
+ try {
918
+ const now = new Date().toISOString();
919
+ // Check if record exists
920
+ const existing = await this.engine.findOne('sys_metadata', {
921
+ where: { type: request.type, name: request.name }
922
+ });
923
+
924
+ if (existing) {
925
+ await this.engine.update('sys_metadata', {
926
+ metadata: JSON.stringify(request.item),
927
+ updated_at: now,
928
+ version: (existing.version || 0) + 1,
929
+ }, {
930
+ where: { id: existing.id }
931
+ });
932
+ } else {
933
+ // Use crypto.randomUUID() when available (modern browsers and Node ≥ 14.17);
934
+ // fall back to a time+random ID for older or restricted environments.
935
+ const id = typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
936
+ ? crypto.randomUUID()
937
+ : `meta_${Date.now()}_${Math.random().toString(36).slice(2)}`;
938
+ await this.engine.insert('sys_metadata', {
939
+ id,
940
+ name: request.name,
941
+ type: request.type,
942
+ scope: 'platform',
943
+ metadata: JSON.stringify(request.item),
944
+ state: 'active',
945
+ version: 1,
946
+ created_at: now,
947
+ updated_at: now,
948
+ });
949
+ }
950
+
951
+ return {
952
+ success: true,
953
+ message: 'Saved to database and registry'
954
+ };
955
+ } catch (dbError: any) {
956
+ // DB write failed but in-memory registry was updated — degrade gracefully
957
+ console.warn(`[Protocol] DB persistence failed for ${request.type}/${request.name}: ${dbError.message}`);
958
+ return {
959
+ success: true,
960
+ message: 'Saved to memory registry (DB persistence unavailable)',
961
+ warning: dbError.message
962
+ };
963
+ }
964
+ }
965
+
966
+ /**
967
+ * Hydrate SchemaRegistry from the database on startup.
968
+ * Loads all active metadata records and registers them in the in-memory registry.
969
+ * Safe to call repeatedly — idempotent (latest DB record wins).
970
+ */
971
+ async loadMetaFromDb(): Promise<{ loaded: number; errors: number }> {
972
+ let loaded = 0;
973
+ let errors = 0;
974
+ try {
975
+ const records = await this.engine.find('sys_metadata', {
976
+ where: { state: 'active' }
977
+ });
978
+ for (const record of records) {
979
+ try {
980
+ const data = typeof record.metadata === 'string'
981
+ ? JSON.parse(record.metadata)
982
+ : record.metadata;
983
+ if (record.type === 'object') {
984
+ SchemaRegistry.registerObject(data as any, record.packageId || 'sys_metadata');
985
+ } else {
986
+ SchemaRegistry.registerItem(record.type, data, 'name' as any);
987
+ }
988
+ loaded++;
989
+ } catch (e) {
990
+ errors++;
991
+ console.warn(`[Protocol] Failed to hydrate ${record.type}/${record.name}: ${e instanceof Error ? e.message : String(e)}`);
992
+ }
993
+ }
994
+ } catch (e: any) {
995
+ console.warn(`[Protocol] DB hydration skipped: ${e.message}`);
996
+ }
997
+ return { loaded, errors };
808
998
  }
809
999
 
810
1000
  // ==========================================
package/tsconfig.json CHANGED
@@ -4,6 +4,7 @@
4
4
  "exclude": ["node_modules", "dist", "**/*.test.ts"],
5
5
  "compilerOptions": {
6
6
  "outDir": "dist",
7
- "rootDir": "src"
7
+ "rootDir": "src",
8
+ "types": ["node"]
8
9
  }
9
10
  }