@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/dist/index.d.mts +500 -1111
- package/dist/index.d.ts +500 -1111
- package/dist/index.js +1364 -279
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1359 -279
- package/dist/index.mjs.map +1 -1
- package/package.json +32 -6
- package/.turbo/turbo-build.log +0 -22
- package/CHANGELOG.md +0 -711
- package/src/engine.test.ts +0 -599
- package/src/engine.ts +0 -1548
- package/src/index.ts +0 -41
- package/src/kernel-factory.ts +0 -48
- package/src/metadata-facade.ts +0 -96
- package/src/plugin.integration.test.ts +0 -995
- package/src/plugin.ts +0 -534
- package/src/protocol-data.test.ts +0 -245
- package/src/protocol-discovery.test.ts +0 -213
- package/src/protocol-feed.test.ts +0 -303
- package/src/protocol-meta.test.ts +0 -440
- package/src/protocol.ts +0 -1235
- package/src/registry.test.ts +0 -494
- package/src/registry.ts +0 -716
- package/src/util.test.ts +0 -226
- package/src/util.ts +0 -219
- package/tsconfig.json +0 -10
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
|
-
}
|