@objectstack/objectql 4.0.3 → 4.0.5

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 DELETED
@@ -1,1235 +0,0 @@
1
- // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
-
3
- import { ObjectStackProtocol } from '@objectstack/spec/api';
4
- import { IDataEngine } from '@objectstack/core';
5
- import type {
6
- BatchUpdateRequest,
7
- BatchUpdateResponse,
8
- UpdateManyDataRequest,
9
- DeleteManyDataRequest
10
- } from '@objectstack/spec/api';
11
- import type { MetadataCacheRequest, MetadataCacheResponse, ServiceInfo, ApiRoutes, WellKnownCapabilities } from '@objectstack/spec/api';
12
- import type { IFeedService } from '@objectstack/spec/contracts';
13
- import { parseFilterAST, isFilterAST } from '@objectstack/spec/data';
14
- import { PLURAL_TO_SINGULAR, SINGULAR_TO_PLURAL } from '@objectstack/spec/shared';
15
-
16
- // We import SchemaRegistry directly since this class lives in the same package
17
- import { SchemaRegistry } from './registry.js';
18
-
19
- /**
20
- * Simple hash function for ETag generation (browser-compatible)
21
- * Uses a basic hash algorithm instead of crypto.createHash
22
- */
23
- function simpleHash(str: string): string {
24
- let hash = 0;
25
- for (let i = 0; i < str.length; i++) {
26
- const char = str.charCodeAt(i);
27
- hash = ((hash << 5) - hash) + char;
28
- hash = hash & hash; // Convert to 32bit integer
29
- }
30
- return Math.abs(hash).toString(16);
31
- }
32
-
33
- /**
34
- * Service Configuration for Discovery
35
- * Maps service names to their routes and plugin providers
36
- */
37
- const SERVICE_CONFIG: Record<string, { route: string; plugin: string }> = {
38
- auth: { route: '/api/v1/auth', plugin: 'plugin-auth' },
39
- automation: { route: '/api/v1/automation', plugin: 'plugin-automation' },
40
- cache: { route: '/api/v1/cache', plugin: 'plugin-redis' },
41
- queue: { route: '/api/v1/queue', plugin: 'plugin-bullmq' },
42
- job: { route: '/api/v1/jobs', plugin: 'job-scheduler' },
43
- ui: { route: '/api/v1/ui', plugin: 'ui-plugin' },
44
- workflow: { route: '/api/v1/workflow', plugin: 'plugin-workflow' },
45
- realtime: { route: '/api/v1/realtime', plugin: 'plugin-realtime' },
46
- notification: { route: '/api/v1/notifications', plugin: 'plugin-notifications' },
47
- ai: { route: '/api/v1/ai', plugin: 'plugin-ai' },
48
- i18n: { route: '/api/v1/i18n', plugin: 'service-i18n' },
49
- graphql: { route: '/graphql', plugin: 'plugin-graphql' }, // GraphQL uses /graphql by convention (not versioned REST)
50
- 'file-storage': { route: '/api/v1/storage', plugin: 'plugin-storage' },
51
- search: { route: '/api/v1/search', plugin: 'plugin-search' },
52
- };
53
-
54
- export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
55
- private engine: IDataEngine;
56
- private getServicesRegistry?: () => Map<string, any>;
57
- private getFeedService?: () => IFeedService | undefined;
58
-
59
- constructor(engine: IDataEngine, getServicesRegistry?: () => Map<string, any>, getFeedService?: () => IFeedService | undefined) {
60
- this.engine = engine;
61
- this.getServicesRegistry = getServicesRegistry;
62
- this.getFeedService = getFeedService;
63
- }
64
-
65
- private requireFeedService(): IFeedService {
66
- const svc = this.getFeedService?.();
67
- if (!svc) {
68
- throw new Error('Feed service not available. Install and register service-feed to enable feed operations.');
69
- }
70
- return svc;
71
- }
72
-
73
- async getDiscovery() {
74
- // Get registered services from kernel if available
75
- const registeredServices = this.getServicesRegistry ? this.getServicesRegistry() : new Map();
76
-
77
- // Build dynamic service info with proper typing
78
- const services: Record<string, ServiceInfo> = {
79
- // --- Kernel-provided (objectql is an example kernel implementation) ---
80
- metadata: { enabled: true, status: 'available' as const, route: '/api/v1/meta', provider: 'objectql' },
81
- data: { enabled: true, status: 'available' as const, route: '/api/v1/data', provider: 'objectql' },
82
- analytics: { enabled: true, status: 'available' as const, route: '/api/v1/analytics', provider: 'objectql' },
83
- };
84
-
85
- // Check which services are actually registered
86
- for (const [serviceName, config] of Object.entries(SERVICE_CONFIG)) {
87
- if (registeredServices.has(serviceName)) {
88
- // Service is registered and available
89
- services[serviceName] = {
90
- enabled: true,
91
- status: 'available' as const,
92
- route: config.route,
93
- provider: config.plugin,
94
- };
95
- } else {
96
- // Service is not registered
97
- services[serviceName] = {
98
- enabled: false,
99
- status: 'unavailable' as const,
100
- message: `Install ${config.plugin} to enable`,
101
- };
102
- }
103
- }
104
-
105
- // Build routes from services — a flat convenience map for client routing
106
- const serviceToRouteKey: Record<string, keyof ApiRoutes> = {
107
- auth: 'auth',
108
- automation: 'automation',
109
- ui: 'ui',
110
- workflow: 'workflow',
111
- realtime: 'realtime',
112
- notification: 'notifications',
113
- ai: 'ai',
114
- i18n: 'i18n',
115
- graphql: 'graphql',
116
- 'file-storage': 'storage',
117
- };
118
-
119
- const optionalRoutes: Partial<ApiRoutes> = {
120
- analytics: '/api/v1/analytics',
121
- };
122
-
123
- // Add routes for available plugin services
124
- for (const [serviceName, config] of Object.entries(SERVICE_CONFIG)) {
125
- if (registeredServices.has(serviceName)) {
126
- const routeKey = serviceToRouteKey[serviceName];
127
- if (routeKey) {
128
- optionalRoutes[routeKey] = config.route;
129
- }
130
- }
131
- }
132
-
133
- // Add feed service status
134
- if (registeredServices.has('feed')) {
135
- services['feed'] = {
136
- enabled: true,
137
- status: 'available' as const,
138
- route: '/api/v1/data',
139
- provider: 'service-feed',
140
- };
141
- } else {
142
- services['feed'] = {
143
- enabled: false,
144
- status: 'unavailable' as const,
145
- message: 'Install service-feed to enable',
146
- };
147
- }
148
-
149
- const routes: ApiRoutes = {
150
- data: '/api/v1/data',
151
- metadata: '/api/v1/meta',
152
- ...optionalRoutes,
153
- };
154
-
155
- // Build well-known capabilities from registered services.
156
- // DiscoverySchema defines capabilities as Record<string, { enabled, features?, description? }>
157
- // (hierarchical format). We also keep a flat WellKnownCapabilities for backward compat.
158
- const wellKnown: WellKnownCapabilities = {
159
- feed: registeredServices.has('feed'),
160
- comments: registeredServices.has('feed'),
161
- automation: registeredServices.has('automation'),
162
- cron: registeredServices.has('job'),
163
- search: registeredServices.has('search'),
164
- export: registeredServices.has('automation') || registeredServices.has('queue'),
165
- chunkedUpload: registeredServices.has('file-storage'),
166
- };
167
-
168
- // Convert flat booleans → hierarchical capability objects
169
- const capabilities: Record<string, { enabled: boolean; description?: string }> = {};
170
- for (const [key, enabled] of Object.entries(wellKnown)) {
171
- capabilities[key] = { enabled };
172
- }
173
-
174
- return {
175
- version: '1.0',
176
- apiName: 'ObjectStack API',
177
- routes,
178
- services,
179
- capabilities,
180
- };
181
- }
182
-
183
- async getMetaTypes() {
184
- const schemaTypes = SchemaRegistry.getRegisteredTypes();
185
-
186
- // Also include types from MetadataService (runtime-registered: agent, tool, etc.)
187
- let runtimeTypes: string[] = [];
188
- try {
189
- const services = this.getServicesRegistry?.();
190
- const metadataService = services?.get('metadata');
191
- if (metadataService && typeof metadataService.getRegisteredTypes === 'function') {
192
- runtimeTypes = await metadataService.getRegisteredTypes();
193
- }
194
- } catch {
195
- // MetadataService not available
196
- }
197
-
198
- const allTypes = Array.from(new Set([...schemaTypes, ...runtimeTypes]));
199
- return { types: allTypes };
200
- }
201
-
202
- async getMetaItems(request: { type: string; packageId?: string }) {
203
- const { packageId } = request;
204
- let items = SchemaRegistry.listItems(request.type, packageId);
205
- // Normalize singular/plural using explicit mapping
206
- if (items.length === 0) {
207
- const alt = PLURAL_TO_SINGULAR[request.type] ?? SINGULAR_TO_PLURAL[request.type];
208
- if (alt) items = SchemaRegistry.listItems(alt, packageId);
209
- }
210
-
211
- // Fallback to database if registry is empty for this type
212
- if (items.length === 0) {
213
- try {
214
- const whereClause: any = { type: request.type, state: 'active' };
215
- if (packageId) whereClause._packageId = packageId;
216
- const allRecords = await this.engine.find('sys_metadata', {
217
- where: whereClause
218
- });
219
- if (allRecords && allRecords.length > 0) {
220
- items = allRecords.map((record: any) => {
221
- const data = typeof record.metadata === 'string'
222
- ? JSON.parse(record.metadata)
223
- : record.metadata;
224
- // Hydrate back into registry
225
- SchemaRegistry.registerItem(request.type, data, 'name' as any);
226
- return data;
227
- });
228
- } else {
229
- // Try alternate type name in DB using explicit mapping
230
- const alt = PLURAL_TO_SINGULAR[request.type] ?? SINGULAR_TO_PLURAL[request.type];
231
- if (alt) {
232
- const altRecords = await this.engine.find('sys_metadata', {
233
- where: { type: alt, state: 'active' }
234
- });
235
- if (altRecords && altRecords.length > 0) {
236
- items = altRecords.map((record: any) => {
237
- const data = typeof record.metadata === 'string'
238
- ? JSON.parse(record.metadata)
239
- : record.metadata;
240
- SchemaRegistry.registerItem(request.type, data, 'name' as any);
241
- return data;
242
- });
243
- }
244
- }
245
- }
246
- } catch {
247
- // DB not available, return registry results (empty)
248
- }
249
- }
250
-
251
- // Merge with MetadataService (runtime-registered items: agents, tools, etc.)
252
- try {
253
- const services = this.getServicesRegistry?.();
254
- const metadataService = services?.get('metadata');
255
- if (metadataService && typeof metadataService.list === 'function') {
256
- const runtimeItems = await metadataService.list(request.type);
257
- if (runtimeItems && runtimeItems.length > 0) {
258
- // Merge, avoiding duplicates by name
259
- const itemMap = new Map<string, any>();
260
- for (const item of items) {
261
- const entry = item as any;
262
- if (entry && typeof entry === 'object' && 'name' in entry) {
263
- itemMap.set(entry.name, entry);
264
- }
265
- }
266
- for (const item of runtimeItems) {
267
- const entry = item as any;
268
- if (entry && typeof entry === 'object' && 'name' in entry) {
269
- itemMap.set(entry.name, entry);
270
- }
271
- }
272
- items = Array.from(itemMap.values());
273
- }
274
- }
275
- } catch {
276
- // MetadataService not available or doesn't support this type
277
- }
278
-
279
- return {
280
- type: request.type,
281
- items
282
- };
283
- }
284
-
285
- async getMetaItem(request: { type: string, name: string, packageId?: string }) {
286
- let item = SchemaRegistry.getItem(request.type, request.name);
287
- // Normalize singular/plural using explicit mapping
288
- if (item === undefined) {
289
- const alt = PLURAL_TO_SINGULAR[request.type] ?? SINGULAR_TO_PLURAL[request.type];
290
- if (alt) item = SchemaRegistry.getItem(alt, request.name);
291
- }
292
-
293
- // Fallback to database if not in registry
294
- if (item === undefined) {
295
- try {
296
- const record = await this.engine.findOne('sys_metadata', {
297
- where: { type: request.type, name: request.name, state: 'active' }
298
- });
299
- if (record) {
300
- item = typeof record.metadata === 'string'
301
- ? JSON.parse(record.metadata)
302
- : record.metadata;
303
- // Hydrate back into registry for next time
304
- SchemaRegistry.registerItem(request.type, item, 'name' as any);
305
- } else {
306
- // Try alternate type name using explicit mapping
307
- const alt = PLURAL_TO_SINGULAR[request.type] ?? SINGULAR_TO_PLURAL[request.type];
308
- if (alt) {
309
- const altRecord = await this.engine.findOne('sys_metadata', {
310
- where: { type: alt, name: request.name, state: 'active' }
311
- });
312
- if (altRecord) {
313
- item = typeof altRecord.metadata === 'string'
314
- ? JSON.parse(altRecord.metadata)
315
- : altRecord.metadata;
316
- // Hydrate back into registry for next time
317
- SchemaRegistry.registerItem(request.type, item, 'name' as any);
318
- }
319
- }
320
- }
321
- } catch {
322
- // DB not available, return undefined
323
- }
324
- }
325
-
326
- // Fallback to MetadataService for runtime-registered items (agents, tools, etc.)
327
- if (item === undefined) {
328
- try {
329
- const services = this.getServicesRegistry?.();
330
- const metadataService = services?.get('metadata');
331
- if (metadataService && typeof metadataService.get === 'function') {
332
- item = await metadataService.get(request.type, request.name);
333
- }
334
- } catch {
335
- // MetadataService not available
336
- }
337
- }
338
-
339
- return {
340
- type: request.type,
341
- name: request.name,
342
- item
343
- };
344
- }
345
-
346
- async getUiView(request: { object: string, type: 'list' | 'form' }) {
347
- const schema = SchemaRegistry.getObject(request.object);
348
- if (!schema) throw new Error(`Object ${request.object} not found`);
349
-
350
- const fields = schema.fields || {};
351
- const fieldKeys = Object.keys(fields);
352
-
353
- if (request.type === 'list') {
354
- // Intelligent Column Selection
355
- // 1. Always include 'name' or name-like fields
356
- // 2. Limit to 6 columns by default
357
- const priorityFields = ['name', 'title', 'label', 'subject', 'email', 'status', 'type', 'category', 'created_at'];
358
-
359
- let columns = fieldKeys.filter(k => priorityFields.includes(k));
360
-
361
- // If few priority fields, add others until 5
362
- if (columns.length < 5) {
363
- const remaining = fieldKeys.filter(k => !columns.includes(k) && k !== 'id' && !fields[k].hidden);
364
- columns = [...columns, ...remaining.slice(0, 5 - columns.length)];
365
- }
366
-
367
- // Sort columns by priority then alphabet or schema order
368
- // For now, just keep them roughly in order they appear in schema or priority list
369
-
370
- return {
371
- list: {
372
- type: 'grid' as const,
373
- object: request.object,
374
- label: schema.label || schema.name,
375
- columns: columns.map(f => ({
376
- field: f,
377
- label: fields[f]?.label || f,
378
- sortable: true
379
- })),
380
- sort: fields['created_at'] ? ([{ field: 'created_at', order: 'desc' }] as any) : undefined,
381
- searchableFields: columns.slice(0, 3) // Make first few textual columns searchable
382
- }
383
- };
384
- } else {
385
- // Form View Generation
386
- // Simple single-section layout for now
387
- const formFields = fieldKeys
388
- .filter(k => k !== 'id' && k !== 'created_at' && k !== 'updated_at' && !fields[k].hidden)
389
- .map(f => ({
390
- field: f,
391
- label: fields[f]?.label,
392
- required: fields[f]?.required,
393
- readonly: fields[f]?.readonly,
394
- type: fields[f]?.type,
395
- // Default to 2 columns for most, 1 for textareas
396
- colSpan: (fields[f]?.type === 'textarea' || fields[f]?.type === 'html') ? 2 : 1
397
- }));
398
-
399
- return {
400
- form: {
401
- type: 'simple' as const,
402
- object: request.object,
403
- label: `Edit ${schema.label || schema.name}`,
404
- sections: [
405
- {
406
- label: 'General Information',
407
- columns: 2 as const,
408
- collapsible: false,
409
- collapsed: false,
410
- fields: formFields
411
- }
412
- ]
413
- }
414
- };
415
- }
416
- }
417
-
418
- async findData(request: { object: string, query?: any }) {
419
- const options: any = { ...request.query };
420
-
421
- // ====================================================================
422
- // Normalize legacy params → QueryAST standard (where/fields/orderBy/offset/expand)
423
- // ====================================================================
424
-
425
- // Numeric fields — normalize top → limit, skip → offset
426
- if (options.top != null) {
427
- options.limit = Number(options.top);
428
- delete options.top;
429
- }
430
- if (options.skip != null) {
431
- options.offset = Number(options.skip);
432
- delete options.skip;
433
- }
434
- if (options.limit != null) options.limit = Number(options.limit);
435
- if (options.offset != null) options.offset = Number(options.offset);
436
-
437
- // Select → fields: comma-separated string → array
438
- if (typeof options.select === 'string') {
439
- options.fields = options.select.split(',').map((s: string) => s.trim()).filter(Boolean);
440
- } else if (Array.isArray(options.select)) {
441
- options.fields = options.select;
442
- }
443
- if (options.select !== undefined) delete options.select;
444
-
445
- // Sort/orderBy → orderBy: string → SortNode[] array
446
- const sortValue = options.orderBy ?? options.sort;
447
- if (typeof sortValue === 'string') {
448
- const parsed = sortValue.split(',').map((part: string) => {
449
- const trimmed = part.trim();
450
- if (trimmed.startsWith('-')) {
451
- return { field: trimmed.slice(1), order: 'desc' as const };
452
- }
453
- const [field, order] = trimmed.split(/\s+/);
454
- return { field, order: (order?.toLowerCase() === 'desc' ? 'desc' : 'asc') as 'asc' | 'desc' };
455
- }).filter((s: any) => s.field);
456
- options.orderBy = parsed;
457
- } else if (Array.isArray(sortValue)) {
458
- options.orderBy = sortValue;
459
- }
460
- delete options.sort;
461
-
462
- // Filter/filters/$filter → where: normalize all filter aliases
463
- const filterValue = options.filter ?? options.filters ?? options.$filter ?? options.where;
464
- delete options.filter;
465
- delete options.filters;
466
- delete options.$filter;
467
-
468
- if (filterValue !== undefined) {
469
- let parsedFilter = filterValue;
470
- // JSON string → object
471
- if (typeof parsedFilter === 'string') {
472
- try { parsedFilter = JSON.parse(parsedFilter); } catch { /* keep as-is */ }
473
- }
474
- // Filter AST array → FilterCondition object
475
- if (isFilterAST(parsedFilter)) {
476
- parsedFilter = parseFilterAST(parsedFilter);
477
- }
478
- options.where = parsedFilter;
479
- }
480
-
481
- // Populate/expand/$expand → expand (Record<string, QueryAST>)
482
- const populateValue = options.populate;
483
- const expandValue = options.$expand ?? options.expand;
484
- const expandNames: string[] = [];
485
- if (typeof populateValue === 'string') {
486
- expandNames.push(...populateValue.split(',').map((s: string) => s.trim()).filter(Boolean));
487
- } else if (Array.isArray(populateValue)) {
488
- expandNames.push(...populateValue);
489
- }
490
- if (!expandNames.length && expandValue) {
491
- if (typeof expandValue === 'string') {
492
- expandNames.push(...expandValue.split(',').map((s: string) => s.trim()).filter(Boolean));
493
- } else if (Array.isArray(expandValue)) {
494
- expandNames.push(...expandValue);
495
- }
496
- }
497
- delete options.populate;
498
- delete options.$expand;
499
- // Clean up non-object expand (e.g. string) BEFORE the Record conversion
500
- // below, so that populate-derived names can create the expand Record even
501
- // when a legacy string expand was also present.
502
- if (typeof options.expand !== 'object' || options.expand === null) {
503
- delete options.expand;
504
- }
505
- // Only set expand if not already an object (advanced usage)
506
- if (expandNames.length > 0 && !options.expand) {
507
- options.expand = {} as Record<string, any>;
508
- for (const rel of expandNames) {
509
- options.expand[rel] = { object: rel };
510
- }
511
- }
512
-
513
- // Boolean fields
514
- for (const key of ['distinct', 'count']) {
515
- if (options[key] === 'true') options[key] = true;
516
- else if (options[key] === 'false') options[key] = false;
517
- }
518
-
519
- // Flat field filters: REST-style query params like ?id=abc&status=open
520
- // After extracting all known query parameters, any remaining keys are
521
- // treated as implicit field-level equality filters merged into `where`.
522
- const knownParams = new Set([
523
- 'top', 'limit', 'offset',
524
- 'orderBy',
525
- 'fields',
526
- 'where',
527
- 'expand',
528
- 'distinct', 'count',
529
- 'aggregations', 'groupBy',
530
- 'search', 'context', 'cursor',
531
- ]);
532
- if (!options.where) {
533
- const implicitFilters: Record<string, unknown> = {};
534
- for (const key of Object.keys(options)) {
535
- if (!knownParams.has(key)) {
536
- implicitFilters[key] = options[key];
537
- delete options[key];
538
- }
539
- }
540
- if (Object.keys(implicitFilters).length > 0) {
541
- options.where = implicitFilters;
542
- }
543
- }
544
-
545
- const records = await this.engine.find(request.object, options);
546
- // Spec: FindDataResponseSchema — only `records` is returned.
547
- // OData `value` adaptation (if needed) is handled in the HTTP dispatch layer.
548
- return {
549
- object: request.object,
550
- records,
551
- total: records.length,
552
- hasMore: false
553
- };
554
- }
555
-
556
- async getData(request: { object: string, id: string, expand?: string | string[], select?: string | string[] }) {
557
- const queryOptions: any = {
558
- where: { id: request.id }
559
- };
560
-
561
- // Support fields for single-record retrieval
562
- if (request.select) {
563
- queryOptions.fields = typeof request.select === 'string'
564
- ? request.select.split(',').map((s: string) => s.trim()).filter(Boolean)
565
- : request.select;
566
- }
567
-
568
- // Support expand for single-record retrieval
569
- if (request.expand) {
570
- const expandNames = typeof request.expand === 'string'
571
- ? request.expand.split(',').map((s: string) => s.trim()).filter(Boolean)
572
- : request.expand;
573
- queryOptions.expand = {} as Record<string, any>;
574
- for (const rel of expandNames) {
575
- queryOptions.expand[rel] = { object: rel };
576
- }
577
- }
578
-
579
- const result = await this.engine.findOne(request.object, queryOptions);
580
- if (result) {
581
- return {
582
- object: request.object,
583
- id: request.id,
584
- record: result
585
- };
586
- }
587
- throw new Error(`Record ${request.id} not found in ${request.object}`);
588
- }
589
-
590
- async createData(request: { object: string, data: any }) {
591
- const result = await this.engine.insert(request.object, request.data);
592
- return {
593
- object: request.object,
594
- id: result.id,
595
- record: result
596
- };
597
- }
598
-
599
- async updateData(request: { object: string, id: string, data: any }) {
600
- // Adapt: update(obj, id, data) -> update(obj, data, options)
601
- const result = await this.engine.update(request.object, request.data, { where: { id: request.id } });
602
- return {
603
- object: request.object,
604
- id: request.id,
605
- record: result
606
- };
607
- }
608
-
609
- async deleteData(request: { object: string, id: string }) {
610
- // Adapt: delete(obj, id) -> delete(obj, options)
611
- await this.engine.delete(request.object, { where: { id: request.id } });
612
- return {
613
- object: request.object,
614
- id: request.id,
615
- success: true
616
- };
617
- }
618
-
619
- // ==========================================
620
- // Metadata Caching
621
- // ==========================================
622
-
623
- async getMetaItemCached(request: { type: string, name: string, cacheRequest?: MetadataCacheRequest }): Promise<MetadataCacheResponse> {
624
- try {
625
- let item = SchemaRegistry.getItem(request.type, request.name);
626
-
627
- // Normalize singular/plural using explicit mapping
628
- if (!item) {
629
- const alt = PLURAL_TO_SINGULAR[request.type] ?? SINGULAR_TO_PLURAL[request.type];
630
- if (alt) item = SchemaRegistry.getItem(alt, request.name);
631
- }
632
-
633
- // Fallback to MetadataService (e.g. agents, tools registered in MetadataManager)
634
- if (!item) {
635
- try {
636
- const services = this.getServicesRegistry?.();
637
- const metadataService = services?.get('metadata');
638
- if (metadataService && typeof metadataService.get === 'function') {
639
- item = await metadataService.get(request.type, request.name);
640
- }
641
- } catch {
642
- // MetadataService not available
643
- }
644
- }
645
-
646
- if (!item) {
647
- throw new Error(`Metadata item ${request.type}/${request.name} not found`);
648
- }
649
-
650
- // Calculate ETag (simple hash of the stringified metadata)
651
- const content = JSON.stringify(item);
652
- const hash = simpleHash(content);
653
- const etag = { value: hash, weak: false };
654
-
655
- // Check If-None-Match header
656
- if (request.cacheRequest?.ifNoneMatch) {
657
- const clientEtag = request.cacheRequest.ifNoneMatch.replace(/^"(.*)"$/, '$1').replace(/^W\/"(.*)"$/, '$1');
658
- if (clientEtag === hash) {
659
- // Return 304 Not Modified
660
- return {
661
- notModified: true,
662
- etag,
663
- };
664
- }
665
- }
666
-
667
- // Return full metadata with cache headers
668
- return {
669
- data: item,
670
- etag,
671
- lastModified: new Date().toISOString(),
672
- cacheControl: {
673
- directives: ['public', 'max-age'],
674
- maxAge: 3600, // 1 hour
675
- },
676
- notModified: false,
677
- };
678
- } catch (error: any) {
679
- throw error;
680
- }
681
- }
682
-
683
- // ==========================================
684
- // Batch Operations
685
- // ==========================================
686
-
687
- async batchData(request: { object: string, request: BatchUpdateRequest }): Promise<BatchUpdateResponse> {
688
- const { object, request: batchReq } = request;
689
- const { operation, records, options } = batchReq;
690
- const results: Array<{ id?: string; success: boolean; error?: string; record?: any }> = [];
691
- let succeeded = 0;
692
- let failed = 0;
693
-
694
- for (const record of records) {
695
- try {
696
- switch (operation) {
697
- case 'create': {
698
- const created = await this.engine.insert(object, record.data || record);
699
- results.push({ id: created.id, success: true, record: created });
700
- succeeded++;
701
- break;
702
- }
703
- case 'update': {
704
- if (!record.id) throw new Error('Record id is required for update');
705
- const updated = await this.engine.update(object, record.data || {}, { where: { id: record.id } });
706
- results.push({ id: record.id, success: true, record: updated });
707
- succeeded++;
708
- break;
709
- }
710
- case 'upsert': {
711
- // Try update first, then create if not found
712
- if (record.id) {
713
- try {
714
- const existing = await this.engine.findOne(object, { where: { id: record.id } });
715
- if (existing) {
716
- const updated = await this.engine.update(object, record.data || {}, { where: { id: record.id } });
717
- results.push({ id: record.id, success: true, record: updated });
718
- } else {
719
- const created = await this.engine.insert(object, { id: record.id, ...(record.data || {}) });
720
- results.push({ id: created.id, success: true, record: created });
721
- }
722
- } catch {
723
- const created = await this.engine.insert(object, { id: record.id, ...(record.data || {}) });
724
- results.push({ id: created.id, success: true, record: created });
725
- }
726
- } else {
727
- const created = await this.engine.insert(object, record.data || record);
728
- results.push({ id: created.id, success: true, record: created });
729
- }
730
- succeeded++;
731
- break;
732
- }
733
- case 'delete': {
734
- if (!record.id) throw new Error('Record id is required for delete');
735
- await this.engine.delete(object, { where: { id: record.id } });
736
- results.push({ id: record.id, success: true });
737
- succeeded++;
738
- break;
739
- }
740
- default:
741
- results.push({ id: record.id, success: false, error: `Unknown operation: ${operation}` });
742
- failed++;
743
- }
744
- } catch (err: any) {
745
- results.push({ id: record.id, success: false, error: err.message });
746
- failed++;
747
- if (options?.atomic) {
748
- // Abort remaining operations on first failure in atomic mode
749
- break;
750
- }
751
- if (!options?.continueOnError) {
752
- break;
753
- }
754
- }
755
- }
756
-
757
- return {
758
- success: failed === 0,
759
- operation,
760
- total: records.length,
761
- succeeded,
762
- failed,
763
- results: options?.returnRecords !== false ? results : results.map(r => ({ id: r.id, success: r.success, error: r.error })),
764
- } as BatchUpdateResponse;
765
- }
766
-
767
- async createManyData(request: { object: string, records: any[] }): Promise<any> {
768
- const records = await this.engine.insert(request.object, request.records);
769
- return {
770
- object: request.object,
771
- records,
772
- count: records.length
773
- };
774
- }
775
-
776
- async updateManyData(request: UpdateManyDataRequest): Promise<BatchUpdateResponse> {
777
- const { object, records, options } = request;
778
- const results: Array<{ id?: string; success: boolean; error?: string; record?: any }> = [];
779
- let succeeded = 0;
780
- let failed = 0;
781
-
782
- for (const record of records) {
783
- try {
784
- const updated = await this.engine.update(object, record.data, { where: { id: record.id } });
785
- results.push({ id: record.id, success: true, record: updated });
786
- succeeded++;
787
- } catch (err: any) {
788
- results.push({ id: record.id, success: false, error: err.message });
789
- failed++;
790
- if (!options?.continueOnError) {
791
- break;
792
- }
793
- }
794
- }
795
-
796
- return {
797
- success: failed === 0,
798
- operation: 'update',
799
- total: records.length,
800
- succeeded,
801
- failed,
802
- results,
803
- } as BatchUpdateResponse;
804
- }
805
-
806
- async analyticsQuery(request: any): Promise<any> {
807
- // Map AnalyticsQuery (cube-style) to engine aggregation.
808
- // cube name maps to object name; measures → aggregations; dimensions → groupBy.
809
- const { query, cube } = request;
810
- const object = cube;
811
-
812
- // Build groupBy from dimensions
813
- const groupBy = query.dimensions || [];
814
-
815
- // Build aggregations from measures
816
- // Measures can be simple field names like "count" or "field_name.sum"
817
- // Or cube-defined measure names. We support: field.function or just function(field).
818
- const aggregations: Array<{ field: string; method: string; alias: string }> = [];
819
- if (query.measures) {
820
- for (const measure of query.measures) {
821
- // Support formats: "count", "amount.sum", "revenue.avg"
822
- if (measure === 'count' || measure === 'count_all') {
823
- aggregations.push({ field: '*', method: 'count', alias: 'count' });
824
- } else if (measure.includes('.')) {
825
- const [field, method] = measure.split('.');
826
- aggregations.push({ field, method, alias: `${field}_${method}` });
827
- } else {
828
- // Treat as count of the field
829
- aggregations.push({ field: measure, method: 'sum', alias: measure });
830
- }
831
- }
832
- }
833
-
834
- // Build filter from analytics filters
835
- let filter: any = undefined;
836
- if (query.filters && query.filters.length > 0) {
837
- const conditions: any[] = query.filters.map((f: any) => {
838
- const op = this.mapAnalyticsOperator(f.operator);
839
- if (f.values && f.values.length === 1) {
840
- return { [f.member]: { [op]: f.values[0] } };
841
- } else if (f.values && f.values.length > 1) {
842
- return { [f.member]: { $in: f.values } };
843
- }
844
- return { [f.member]: { [op]: true } };
845
- });
846
- filter = conditions.length === 1 ? conditions[0] : { $and: conditions };
847
- }
848
-
849
- // Execute via engine.aggregate (which delegates to driver.find with groupBy/aggregations)
850
- const rows = await this.engine.aggregate(object, {
851
- where: filter,
852
- groupBy: groupBy.length > 0 ? groupBy : undefined,
853
- aggregations: aggregations.length > 0
854
- ? aggregations.map(a => ({ function: a.method as any, field: a.field, alias: a.alias }))
855
- : [{ function: 'count' as any, alias: 'count' }],
856
- });
857
-
858
- // Build field metadata
859
- const fields = [
860
- ...groupBy.map((d: string) => ({ name: d, type: 'string' })),
861
- ...aggregations.map(a => ({ name: a.alias, type: 'number' })),
862
- ];
863
-
864
- return {
865
- success: true,
866
- data: {
867
- rows,
868
- fields,
869
- },
870
- };
871
- }
872
-
873
- async getAnalyticsMeta(request: any): Promise<any> {
874
- // Auto-generate cube metadata from registered objects in SchemaRegistry.
875
- // Each object becomes a cube; number fields → measures; other fields → dimensions.
876
- const objects = SchemaRegistry.listItems('object');
877
- const cubeFilter = request?.cube;
878
-
879
- const cubes: any[] = [];
880
- for (const obj of objects) {
881
- const schema = obj as any;
882
- if (cubeFilter && schema.name !== cubeFilter) continue;
883
-
884
- const measures: Record<string, any> = {};
885
- const dimensions: Record<string, any> = {};
886
- const fields = schema.fields || {};
887
-
888
- // Always add a count measure
889
- measures['count'] = {
890
- name: 'count',
891
- label: 'Count',
892
- type: 'count',
893
- sql: '*',
894
- };
895
-
896
- for (const [fieldName, fieldDef] of Object.entries(fields)) {
897
- const fd = fieldDef as any;
898
- const fieldType = fd.type || 'text';
899
-
900
- if (['number', 'currency', 'percent'].includes(fieldType)) {
901
- // Numeric fields become both measures and dimensions
902
- measures[`${fieldName}_sum`] = {
903
- name: `${fieldName}_sum`,
904
- label: `${fd.label || fieldName} (Sum)`,
905
- type: 'sum',
906
- sql: fieldName,
907
- };
908
- measures[`${fieldName}_avg`] = {
909
- name: `${fieldName}_avg`,
910
- label: `${fd.label || fieldName} (Avg)`,
911
- type: 'avg',
912
- sql: fieldName,
913
- };
914
- dimensions[fieldName] = {
915
- name: fieldName,
916
- label: fd.label || fieldName,
917
- type: 'number',
918
- sql: fieldName,
919
- };
920
- } else if (['date', 'datetime'].includes(fieldType)) {
921
- dimensions[fieldName] = {
922
- name: fieldName,
923
- label: fd.label || fieldName,
924
- type: 'time',
925
- sql: fieldName,
926
- granularities: ['day', 'week', 'month', 'quarter', 'year'],
927
- };
928
- } else if (['boolean'].includes(fieldType)) {
929
- dimensions[fieldName] = {
930
- name: fieldName,
931
- label: fd.label || fieldName,
932
- type: 'boolean',
933
- sql: fieldName,
934
- };
935
- } else {
936
- // text, select, lookup, etc. → dimension
937
- dimensions[fieldName] = {
938
- name: fieldName,
939
- label: fd.label || fieldName,
940
- type: 'string',
941
- sql: fieldName,
942
- };
943
- }
944
- }
945
-
946
- cubes.push({
947
- name: schema.name,
948
- title: schema.label || schema.name,
949
- description: schema.description,
950
- sql: schema.name,
951
- measures,
952
- dimensions,
953
- public: true,
954
- });
955
- }
956
-
957
- return {
958
- success: true,
959
- data: { cubes },
960
- };
961
- }
962
-
963
- private mapAnalyticsOperator(op: string): string {
964
- const map: Record<string, string> = {
965
- equals: '$eq',
966
- notEquals: '$ne',
967
- contains: '$contains',
968
- notContains: '$notContains',
969
- gt: '$gt',
970
- gte: '$gte',
971
- lt: '$lt',
972
- lte: '$lte',
973
- set: '$ne',
974
- notSet: '$eq',
975
- };
976
- return map[op] || '$eq';
977
- }
978
-
979
- async triggerAutomation(_request: any): Promise<any> {
980
- throw new Error('triggerAutomation requires plugin-automation service. Install and register a plugin that provides the "automation" service.');
981
- }
982
-
983
- async deleteManyData(request: DeleteManyDataRequest): Promise<any> {
984
- // This expects deleting by IDs.
985
- return this.engine.delete(request.object, {
986
- where: { id: { $in: request.ids } },
987
- ...request.options
988
- });
989
- }
990
-
991
- async saveMetaItem(request: { type: string, name: string, item?: any }) {
992
- if (!request.item) {
993
- throw new Error('Item data is required');
994
- }
995
-
996
- // 1. Always update the in-memory registry (runtime cache)
997
- SchemaRegistry.registerItem(request.type, request.item, 'name');
998
-
999
- // 2. Persist to database via data engine
1000
- try {
1001
- const now = new Date().toISOString();
1002
- // Check if record exists
1003
- const existing = await this.engine.findOne('sys_metadata', {
1004
- where: { type: request.type, name: request.name }
1005
- });
1006
-
1007
- if (existing) {
1008
- await this.engine.update('sys_metadata', {
1009
- metadata: JSON.stringify(request.item),
1010
- updated_at: now,
1011
- version: (existing.version || 0) + 1,
1012
- }, {
1013
- where: { id: existing.id }
1014
- });
1015
- } else {
1016
- // Use crypto.randomUUID() when available (modern browsers and Node ≥ 14.17);
1017
- // fall back to a time+random ID for older or restricted environments.
1018
- const id = typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
1019
- ? crypto.randomUUID()
1020
- : `meta_${Date.now()}_${Math.random().toString(36).slice(2)}`;
1021
- await this.engine.insert('sys_metadata', {
1022
- id,
1023
- name: request.name,
1024
- type: request.type,
1025
- scope: 'platform',
1026
- metadata: JSON.stringify(request.item),
1027
- state: 'active',
1028
- version: 1,
1029
- created_at: now,
1030
- updated_at: now,
1031
- });
1032
- }
1033
-
1034
- return {
1035
- success: true,
1036
- message: 'Saved to database and registry'
1037
- };
1038
- } catch (dbError: any) {
1039
- // DB write failed but in-memory registry was updated — degrade gracefully
1040
- console.warn(`[Protocol] DB persistence failed for ${request.type}/${request.name}: ${dbError.message}`);
1041
- return {
1042
- success: true,
1043
- message: 'Saved to memory registry (DB persistence unavailable)',
1044
- warning: dbError.message
1045
- };
1046
- }
1047
- }
1048
-
1049
- /**
1050
- * Hydrate SchemaRegistry from the database on startup.
1051
- * Loads all active metadata records and registers them in the in-memory registry.
1052
- * Safe to call repeatedly — idempotent (latest DB record wins).
1053
- */
1054
- async loadMetaFromDb(): Promise<{ loaded: number; errors: number }> {
1055
- let loaded = 0;
1056
- let errors = 0;
1057
- try {
1058
- const records = await this.engine.find('sys_metadata', {
1059
- where: { state: 'active' }
1060
- });
1061
- for (const record of records) {
1062
- try {
1063
- const data = typeof record.metadata === 'string'
1064
- ? JSON.parse(record.metadata)
1065
- : record.metadata;
1066
- // Normalize DB type to singular (DB may store legacy plural forms)
1067
- const normalizedType = PLURAL_TO_SINGULAR[record.type] ?? record.type;
1068
- if (normalizedType === 'object') {
1069
- SchemaRegistry.registerObject(data as any, record.packageId || 'sys_metadata');
1070
- } else {
1071
- SchemaRegistry.registerItem(normalizedType, data, 'name' as any);
1072
- }
1073
- loaded++;
1074
- } catch (e) {
1075
- errors++;
1076
- console.warn(`[Protocol] Failed to hydrate ${record.type}/${record.name}: ${e instanceof Error ? e.message : String(e)}`);
1077
- }
1078
- }
1079
- } catch (e: any) {
1080
- console.warn(`[Protocol] DB hydration skipped: ${e.message}`);
1081
- }
1082
- return { loaded, errors };
1083
- }
1084
-
1085
- // ==========================================
1086
- // Feed Operations
1087
- // ==========================================
1088
-
1089
- async listFeed(request: any): Promise<any> {
1090
- const svc = this.requireFeedService();
1091
- const result = await svc.listFeed({
1092
- object: request.object,
1093
- recordId: request.recordId,
1094
- filter: request.type,
1095
- limit: request.limit,
1096
- cursor: request.cursor,
1097
- });
1098
- return { success: true, data: result };
1099
- }
1100
-
1101
- async createFeedItem(request: any): Promise<any> {
1102
- const svc = this.requireFeedService();
1103
- const item = await svc.createFeedItem({
1104
- object: request.object,
1105
- recordId: request.recordId,
1106
- type: request.type,
1107
- actor: { type: 'user', id: 'current_user' },
1108
- body: request.body,
1109
- mentions: request.mentions,
1110
- parentId: request.parentId,
1111
- visibility: request.visibility,
1112
- });
1113
- return { success: true, data: item };
1114
- }
1115
-
1116
- async updateFeedItem(request: any): Promise<any> {
1117
- const svc = this.requireFeedService();
1118
- const item = await svc.updateFeedItem(request.feedId, {
1119
- body: request.body,
1120
- mentions: request.mentions,
1121
- visibility: request.visibility,
1122
- });
1123
- return { success: true, data: item };
1124
- }
1125
-
1126
- async deleteFeedItem(request: any): Promise<any> {
1127
- const svc = this.requireFeedService();
1128
- await svc.deleteFeedItem(request.feedId);
1129
- return { success: true, data: { feedId: request.feedId } };
1130
- }
1131
-
1132
- async addReaction(request: any): Promise<any> {
1133
- const svc = this.requireFeedService();
1134
- const reactions = await svc.addReaction(request.feedId, request.emoji, 'current_user');
1135
- return { success: true, data: { reactions } };
1136
- }
1137
-
1138
- async removeReaction(request: any): Promise<any> {
1139
- const svc = this.requireFeedService();
1140
- const reactions = await svc.removeReaction(request.feedId, request.emoji, 'current_user');
1141
- return { success: true, data: { reactions } };
1142
- }
1143
-
1144
- async pinFeedItem(request: any): Promise<any> {
1145
- const svc = this.requireFeedService();
1146
- const item = await svc.getFeedItem(request.feedId);
1147
- if (!item) throw new Error(`Feed item ${request.feedId} not found`);
1148
- // IFeedService doesn't have dedicated pin/unpin — use updateFeedItem to persist pin state
1149
- await svc.updateFeedItem(request.feedId, { visibility: item.visibility });
1150
- return { success: true, data: { feedId: request.feedId, pinned: true, pinnedAt: new Date().toISOString() } };
1151
- }
1152
-
1153
- async unpinFeedItem(request: any): Promise<any> {
1154
- const svc = this.requireFeedService();
1155
- const item = await svc.getFeedItem(request.feedId);
1156
- if (!item) throw new Error(`Feed item ${request.feedId} not found`);
1157
- await svc.updateFeedItem(request.feedId, { visibility: item.visibility });
1158
- return { success: true, data: { feedId: request.feedId, pinned: false } };
1159
- }
1160
-
1161
- async starFeedItem(request: any): Promise<any> {
1162
- const svc = this.requireFeedService();
1163
- const item = await svc.getFeedItem(request.feedId);
1164
- if (!item) throw new Error(`Feed item ${request.feedId} not found`);
1165
- // IFeedService doesn't have dedicated star/unstar — verify item exists then return state
1166
- await svc.updateFeedItem(request.feedId, { visibility: item.visibility });
1167
- return { success: true, data: { feedId: request.feedId, starred: true, starredAt: new Date().toISOString() } };
1168
- }
1169
-
1170
- async unstarFeedItem(request: any): Promise<any> {
1171
- const svc = this.requireFeedService();
1172
- const item = await svc.getFeedItem(request.feedId);
1173
- if (!item) throw new Error(`Feed item ${request.feedId} not found`);
1174
- await svc.updateFeedItem(request.feedId, { visibility: item.visibility });
1175
- return { success: true, data: { feedId: request.feedId, starred: false } };
1176
- }
1177
-
1178
- async searchFeed(request: any): Promise<any> {
1179
- const svc = this.requireFeedService();
1180
- // Search delegates to listFeed with filter since IFeedService doesn't have a dedicated search
1181
- const result = await svc.listFeed({
1182
- object: request.object,
1183
- recordId: request.recordId,
1184
- filter: request.type,
1185
- limit: request.limit,
1186
- cursor: request.cursor,
1187
- });
1188
- // Filter by query text in body
1189
- const queryLower = (request.query || '').toLowerCase();
1190
- const filtered = result.items.filter((item: any) =>
1191
- item.body?.toLowerCase().includes(queryLower)
1192
- );
1193
- return { success: true, data: { items: filtered, total: filtered.length, hasMore: false } };
1194
- }
1195
-
1196
- async getChangelog(request: any): Promise<any> {
1197
- const svc = this.requireFeedService();
1198
- // Changelog retrieves field_change type feed items
1199
- const result = await svc.listFeed({
1200
- object: request.object,
1201
- recordId: request.recordId,
1202
- filter: 'changes_only',
1203
- limit: request.limit,
1204
- cursor: request.cursor,
1205
- });
1206
- const entries = result.items.map((item: any) => ({
1207
- id: item.id,
1208
- object: item.object,
1209
- recordId: item.recordId,
1210
- actor: item.actor,
1211
- changes: item.changes || [],
1212
- timestamp: item.createdAt,
1213
- source: item.source,
1214
- }));
1215
- return { success: true, data: { entries, total: result.total, nextCursor: result.nextCursor, hasMore: result.hasMore } };
1216
- }
1217
-
1218
- async feedSubscribe(request: any): Promise<any> {
1219
- const svc = this.requireFeedService();
1220
- const subscription = await svc.subscribe({
1221
- object: request.object,
1222
- recordId: request.recordId,
1223
- userId: 'current_user',
1224
- events: request.events,
1225
- channels: request.channels,
1226
- });
1227
- return { success: true, data: subscription };
1228
- }
1229
-
1230
- async feedUnsubscribe(request: any): Promise<any> {
1231
- const svc = this.requireFeedService();
1232
- const unsubscribed = await svc.unsubscribe(request.object, request.recordId, 'current_user');
1233
- return { success: true, data: { object: request.object, recordId: request.recordId, unsubscribed } };
1234
- }
1235
- }