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