@objectstack/rest 7.9.0 → 8.0.1
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.cjs +245 -10
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +33 -1
- package/dist/index.d.ts +33 -1
- package/dist/index.js +245 -10
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
package/dist/index.d.cts
CHANGED
|
@@ -218,6 +218,15 @@ declare class RestServer {
|
|
|
218
218
|
private routeManager;
|
|
219
219
|
private kernelManager?;
|
|
220
220
|
private envRegistry?;
|
|
221
|
+
/**
|
|
222
|
+
* Short-TTL cache for `hostname → environmentId` (P1-4). `resolveByHostname`
|
|
223
|
+
* is a control-plane lookup (typically a DB query) that otherwise runs on
|
|
224
|
+
* *every* unscoped request; caching it — including negative results, so
|
|
225
|
+
* unknown hosts don't hammer the registry — removes that per-request cost.
|
|
226
|
+
* The TTL is short so a newly-bound hostname becomes routable quickly.
|
|
227
|
+
*/
|
|
228
|
+
private readonly hostnameCache;
|
|
229
|
+
private readonly hostnameCacheTtlMs;
|
|
221
230
|
private defaultEnvironmentIdProvider?;
|
|
222
231
|
private authServiceProvider?;
|
|
223
232
|
private objectQLProvider?;
|
|
@@ -227,7 +236,8 @@ declare class RestServer {
|
|
|
227
236
|
private approvalsServiceProvider?;
|
|
228
237
|
private sharingRulesServiceProvider?;
|
|
229
238
|
private i18nServiceProvider?;
|
|
230
|
-
|
|
239
|
+
private analyticsServiceProvider?;
|
|
240
|
+
constructor(server: IHttpServer, protocol: ObjectStackProtocol, config?: RestServerConfig, kernelManager?: RestKernelManager, envRegistry?: RestEnvRegistry, defaultEnvironmentIdProvider?: () => string | undefined, authServiceProvider?: (environmentId?: string) => Promise<any | undefined>, objectQLProvider?: (environmentId?: string) => Promise<any | undefined>, emailServiceProvider?: (environmentId?: string) => Promise<any | undefined>, sharingServiceProvider?: (environmentId?: string) => Promise<any | undefined>, reportsServiceProvider?: (environmentId?: string) => Promise<any | undefined>, approvalsServiceProvider?: (environmentId?: string) => Promise<any | undefined>, sharingRulesServiceProvider?: (environmentId?: string) => Promise<any | undefined>, i18nServiceProvider?: (environmentId?: string) => Promise<any | undefined>, analyticsServiceProvider?: (environmentId?: string) => Promise<any | undefined>);
|
|
231
241
|
/**
|
|
232
242
|
* Resolve the protocol for a given request. When `environmentId` is present
|
|
233
243
|
* and a KernelManager is wired, fetch the per-project kernel's
|
|
@@ -250,6 +260,14 @@ declare class RestServer {
|
|
|
250
260
|
* (and any other client) speak a single, uniform URL family without
|
|
251
261
|
* duplicating route logic for the platform surface.
|
|
252
262
|
*/
|
|
263
|
+
/**
|
|
264
|
+
* Cached wrapper around `envRegistry.resolveByHostname` (P1-4). Returns the
|
|
265
|
+
* cached result while fresh; on a miss it queries the registry and caches the
|
|
266
|
+
* outcome (positive *and* negative) for {@link hostnameCacheTtlMs}. Registry
|
|
267
|
+
* errors are not cached so a transient control-plane blip self-heals on the
|
|
268
|
+
* next request.
|
|
269
|
+
*/
|
|
270
|
+
private resolveHostnameCached;
|
|
253
271
|
private resolveProtocol;
|
|
254
272
|
/**
|
|
255
273
|
* Resolve the i18n service for the request's project (or control plane
|
|
@@ -484,6 +502,20 @@ declare class RestServer {
|
|
|
484
502
|
* when no sharing service is configured so a deployment without the
|
|
485
503
|
* `@objectstack/plugin-sharing` plugin fails cleanly.
|
|
486
504
|
*/
|
|
505
|
+
/**
|
|
506
|
+
* ADR-0021 — analytics dataset preview/query endpoint.
|
|
507
|
+
*
|
|
508
|
+
* POST {basePath}/analytics/dataset/query
|
|
509
|
+
* body: { dataset?: <inline Dataset>, datasetName?: string, selection: DatasetSelection }
|
|
510
|
+
*
|
|
511
|
+
* Compiles the dataset (an inline draft for Studio preview, or a saved one
|
|
512
|
+
* by name) and runs the selection through the analytics service's
|
|
513
|
+
* `queryDataset`, threading the request ExecutionContext so tenant/RLS
|
|
514
|
+
* scoping (ADR-0021 D-C) applies. Returns 501 when no analytics service
|
|
515
|
+
* (or one without `queryDataset`) is configured, so a deployment without
|
|
516
|
+
* `@objectstack/service-analytics` fails cleanly.
|
|
517
|
+
*/
|
|
518
|
+
private registerAnalyticsEndpoints;
|
|
487
519
|
private registerSharingEndpoints;
|
|
488
520
|
/**
|
|
489
521
|
* Register sharing-rule endpoints (M10.17). Mirrors the existing
|
package/dist/index.d.ts
CHANGED
|
@@ -218,6 +218,15 @@ declare class RestServer {
|
|
|
218
218
|
private routeManager;
|
|
219
219
|
private kernelManager?;
|
|
220
220
|
private envRegistry?;
|
|
221
|
+
/**
|
|
222
|
+
* Short-TTL cache for `hostname → environmentId` (P1-4). `resolveByHostname`
|
|
223
|
+
* is a control-plane lookup (typically a DB query) that otherwise runs on
|
|
224
|
+
* *every* unscoped request; caching it — including negative results, so
|
|
225
|
+
* unknown hosts don't hammer the registry — removes that per-request cost.
|
|
226
|
+
* The TTL is short so a newly-bound hostname becomes routable quickly.
|
|
227
|
+
*/
|
|
228
|
+
private readonly hostnameCache;
|
|
229
|
+
private readonly hostnameCacheTtlMs;
|
|
221
230
|
private defaultEnvironmentIdProvider?;
|
|
222
231
|
private authServiceProvider?;
|
|
223
232
|
private objectQLProvider?;
|
|
@@ -227,7 +236,8 @@ declare class RestServer {
|
|
|
227
236
|
private approvalsServiceProvider?;
|
|
228
237
|
private sharingRulesServiceProvider?;
|
|
229
238
|
private i18nServiceProvider?;
|
|
230
|
-
|
|
239
|
+
private analyticsServiceProvider?;
|
|
240
|
+
constructor(server: IHttpServer, protocol: ObjectStackProtocol, config?: RestServerConfig, kernelManager?: RestKernelManager, envRegistry?: RestEnvRegistry, defaultEnvironmentIdProvider?: () => string | undefined, authServiceProvider?: (environmentId?: string) => Promise<any | undefined>, objectQLProvider?: (environmentId?: string) => Promise<any | undefined>, emailServiceProvider?: (environmentId?: string) => Promise<any | undefined>, sharingServiceProvider?: (environmentId?: string) => Promise<any | undefined>, reportsServiceProvider?: (environmentId?: string) => Promise<any | undefined>, approvalsServiceProvider?: (environmentId?: string) => Promise<any | undefined>, sharingRulesServiceProvider?: (environmentId?: string) => Promise<any | undefined>, i18nServiceProvider?: (environmentId?: string) => Promise<any | undefined>, analyticsServiceProvider?: (environmentId?: string) => Promise<any | undefined>);
|
|
231
241
|
/**
|
|
232
242
|
* Resolve the protocol for a given request. When `environmentId` is present
|
|
233
243
|
* and a KernelManager is wired, fetch the per-project kernel's
|
|
@@ -250,6 +260,14 @@ declare class RestServer {
|
|
|
250
260
|
* (and any other client) speak a single, uniform URL family without
|
|
251
261
|
* duplicating route logic for the platform surface.
|
|
252
262
|
*/
|
|
263
|
+
/**
|
|
264
|
+
* Cached wrapper around `envRegistry.resolveByHostname` (P1-4). Returns the
|
|
265
|
+
* cached result while fresh; on a miss it queries the registry and caches the
|
|
266
|
+
* outcome (positive *and* negative) for {@link hostnameCacheTtlMs}. Registry
|
|
267
|
+
* errors are not cached so a transient control-plane blip self-heals on the
|
|
268
|
+
* next request.
|
|
269
|
+
*/
|
|
270
|
+
private resolveHostnameCached;
|
|
253
271
|
private resolveProtocol;
|
|
254
272
|
/**
|
|
255
273
|
* Resolve the i18n service for the request's project (or control plane
|
|
@@ -484,6 +502,20 @@ declare class RestServer {
|
|
|
484
502
|
* when no sharing service is configured so a deployment without the
|
|
485
503
|
* `@objectstack/plugin-sharing` plugin fails cleanly.
|
|
486
504
|
*/
|
|
505
|
+
/**
|
|
506
|
+
* ADR-0021 — analytics dataset preview/query endpoint.
|
|
507
|
+
*
|
|
508
|
+
* POST {basePath}/analytics/dataset/query
|
|
509
|
+
* body: { dataset?: <inline Dataset>, datasetName?: string, selection: DatasetSelection }
|
|
510
|
+
*
|
|
511
|
+
* Compiles the dataset (an inline draft for Studio preview, or a saved one
|
|
512
|
+
* by name) and runs the selection through the analytics service's
|
|
513
|
+
* `queryDataset`, threading the request ExecutionContext so tenant/RLS
|
|
514
|
+
* scoping (ADR-0021 D-C) applies. Returns 501 when no analytics service
|
|
515
|
+
* (or one without `queryDataset`) is configured, so a deployment without
|
|
516
|
+
* `@objectstack/service-analytics` fails cleanly.
|
|
517
|
+
*/
|
|
518
|
+
private registerAnalyticsEndpoints;
|
|
487
519
|
private registerSharingEndpoints;
|
|
488
520
|
/**
|
|
489
521
|
* Register sharing-rule endpoints (M10.17). Mirrors the existing
|
package/dist/index.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
// src/rest-server.ts
|
|
2
|
+
import { resolveApiKeyPrincipal } from "@objectstack/core";
|
|
3
|
+
|
|
1
4
|
// src/route-manager.ts
|
|
2
5
|
var RouteManager = class {
|
|
3
6
|
constructor(server) {
|
|
@@ -268,6 +271,32 @@ function mapDataError(error, object) {
|
|
|
268
271
|
}
|
|
269
272
|
};
|
|
270
273
|
}
|
|
274
|
+
const unknownColumn = /has no column named\s+["'`]?([a-z0-9_]+)/i.exec(raw) || /no such column:\s*["'`]?([a-z0-9_.]+)/i.exec(raw) || /unknown column\s+["'`]([a-z0-9_]+)["'`]/i.exec(raw) || /column\s+["'`]([a-z0-9_]+)["'`]\s+of relation\s+\S+\s+does not exist/i.exec(raw);
|
|
275
|
+
if (unknownColumn) {
|
|
276
|
+
const field = unknownColumn[1]?.split(".").pop();
|
|
277
|
+
return {
|
|
278
|
+
status: 400,
|
|
279
|
+
body: {
|
|
280
|
+
error: field ? `Unknown field '${field}'${object ? ` on object '${object}'` : ""}` : "Request references a field that does not exist",
|
|
281
|
+
code: "INVALID_FIELD",
|
|
282
|
+
...field ? { field } : {},
|
|
283
|
+
...object ? { object } : {}
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
const notNull = /not null constraint failed:\s*\S*?\.([a-z0-9_]+)/i.exec(raw) || /null value in column\s+["'`]([a-z0-9_]+)["'`]/i.exec(raw) || /column\s+["'`]([a-z0-9_]+)["'`]\s+cannot be null/i.exec(raw);
|
|
288
|
+
if (notNull) {
|
|
289
|
+
const field = notNull[1];
|
|
290
|
+
return {
|
|
291
|
+
status: 400,
|
|
292
|
+
body: {
|
|
293
|
+
error: `${field} is required`,
|
|
294
|
+
code: "VALIDATION_FAILED",
|
|
295
|
+
fields: [{ field, code: "required", message: `${field} is required` }],
|
|
296
|
+
...object ? { object } : {}
|
|
297
|
+
}
|
|
298
|
+
};
|
|
299
|
+
}
|
|
271
300
|
const looksLikeUnknownObject = lower.includes("no such table") || lower.includes("relation") && lower.includes("does not exist") || lower.includes("table not found") || lower.includes("unknown object") || lower.includes("object not found") || lower.includes("no driver available") || object !== void 0 && lower.includes(`'${object.toLowerCase()}'`) && lower.includes("not");
|
|
272
301
|
if (looksLikeUnknownObject) {
|
|
273
302
|
return {
|
|
@@ -405,7 +434,16 @@ function rowsToCsv(fields, rows, includeHeader) {
|
|
|
405
434
|
return lines.join("\r\n") + (lines.length > 0 ? "\r\n" : "");
|
|
406
435
|
}
|
|
407
436
|
var RestServer = class {
|
|
408
|
-
constructor(server, protocol, config = {}, kernelManager, envRegistry, defaultEnvironmentIdProvider, authServiceProvider, objectQLProvider, emailServiceProvider, sharingServiceProvider, reportsServiceProvider, approvalsServiceProvider, sharingRulesServiceProvider, i18nServiceProvider) {
|
|
437
|
+
constructor(server, protocol, config = {}, kernelManager, envRegistry, defaultEnvironmentIdProvider, authServiceProvider, objectQLProvider, emailServiceProvider, sharingServiceProvider, reportsServiceProvider, approvalsServiceProvider, sharingRulesServiceProvider, i18nServiceProvider, analyticsServiceProvider) {
|
|
438
|
+
/**
|
|
439
|
+
* Short-TTL cache for `hostname → environmentId` (P1-4). `resolveByHostname`
|
|
440
|
+
* is a control-plane lookup (typically a DB query) that otherwise runs on
|
|
441
|
+
* *every* unscoped request; caching it — including negative results, so
|
|
442
|
+
* unknown hosts don't hammer the registry — removes that per-request cost.
|
|
443
|
+
* The TTL is short so a newly-bound hostname becomes routable quickly.
|
|
444
|
+
*/
|
|
445
|
+
this.hostnameCache = /* @__PURE__ */ new Map();
|
|
446
|
+
this.hostnameCacheTtlMs = 3e4;
|
|
409
447
|
/**
|
|
410
448
|
* Lazily load the OpenAPI spec JSON shipped by @objectstack/spec.
|
|
411
449
|
* Cached after first read. Resilient to missing files / parse errors
|
|
@@ -426,6 +464,7 @@ var RestServer = class {
|
|
|
426
464
|
this.approvalsServiceProvider = approvalsServiceProvider;
|
|
427
465
|
this.sharingRulesServiceProvider = sharingRulesServiceProvider;
|
|
428
466
|
this.i18nServiceProvider = i18nServiceProvider;
|
|
467
|
+
this.analyticsServiceProvider = analyticsServiceProvider;
|
|
429
468
|
}
|
|
430
469
|
/**
|
|
431
470
|
* Resolve the protocol for a given request. When `environmentId` is present
|
|
@@ -449,13 +488,28 @@ var RestServer = class {
|
|
|
449
488
|
* (and any other client) speak a single, uniform URL family without
|
|
450
489
|
* duplicating route logic for the platform surface.
|
|
451
490
|
*/
|
|
491
|
+
/**
|
|
492
|
+
* Cached wrapper around `envRegistry.resolveByHostname` (P1-4). Returns the
|
|
493
|
+
* cached result while fresh; on a miss it queries the registry and caches the
|
|
494
|
+
* outcome (positive *and* negative) for {@link hostnameCacheTtlMs}. Registry
|
|
495
|
+
* errors are not cached so a transient control-plane blip self-heals on the
|
|
496
|
+
* next request.
|
|
497
|
+
*/
|
|
498
|
+
async resolveHostnameCached(host) {
|
|
499
|
+
const now = Date.now();
|
|
500
|
+
const hit = this.hostnameCache.get(host);
|
|
501
|
+
if (hit && hit.expiresAt > now) return hit.value;
|
|
502
|
+
const result = await this.envRegistry.resolveByHostname(host) ?? null;
|
|
503
|
+
this.hostnameCache.set(host, { value: result, expiresAt: now + this.hostnameCacheTtlMs });
|
|
504
|
+
return result;
|
|
505
|
+
}
|
|
452
506
|
async resolveProtocol(environmentId, req) {
|
|
453
507
|
if (environmentId === "platform") return this.protocol;
|
|
454
508
|
if (!environmentId && req && this.envRegistry && this.kernelManager) {
|
|
455
509
|
const host = this.extractHostname(req);
|
|
456
510
|
if (host) {
|
|
457
511
|
try {
|
|
458
|
-
const result = await this.
|
|
512
|
+
const result = await this.resolveHostnameCached(host);
|
|
459
513
|
if (result?.environmentId) environmentId = result.environmentId;
|
|
460
514
|
} catch {
|
|
461
515
|
}
|
|
@@ -499,7 +553,7 @@ var RestServer = class {
|
|
|
499
553
|
const host = this.extractHostname(req);
|
|
500
554
|
if (host) {
|
|
501
555
|
try {
|
|
502
|
-
const result = await this.
|
|
556
|
+
const result = await this.resolveHostnameCached(host);
|
|
503
557
|
if (result?.environmentId) environmentId = result.environmentId;
|
|
504
558
|
} catch {
|
|
505
559
|
}
|
|
@@ -570,7 +624,7 @@ var RestServer = class {
|
|
|
570
624
|
const host = this.extractHostname(req);
|
|
571
625
|
if (host) {
|
|
572
626
|
try {
|
|
573
|
-
const result = await this.
|
|
627
|
+
const result = await this.resolveHostnameCached(host);
|
|
574
628
|
if (result?.environmentId) environmentId = result.environmentId;
|
|
575
629
|
} catch {
|
|
576
630
|
}
|
|
@@ -624,13 +678,27 @@ var RestServer = class {
|
|
|
624
678
|
} else {
|
|
625
679
|
return void 0;
|
|
626
680
|
}
|
|
627
|
-
const session = await api.getSession({ headers });
|
|
628
|
-
if (!session?.user?.id) return void 0;
|
|
629
|
-
const userId = session.user.id;
|
|
630
|
-
const tenantId = session.session?.activeOrganizationId ?? void 0;
|
|
631
681
|
const permissions = [];
|
|
632
682
|
const systemPermissions = [];
|
|
633
683
|
const roles = [];
|
|
684
|
+
let identityQl;
|
|
685
|
+
if (kernel) identityQl = await kernel.getServiceAsync("objectql").catch(() => void 0);
|
|
686
|
+
if (!identityQl && this.objectQLProvider) {
|
|
687
|
+
identityQl = await this.objectQLProvider(environmentId).catch(() => void 0);
|
|
688
|
+
}
|
|
689
|
+
let userId;
|
|
690
|
+
let tenantId;
|
|
691
|
+
const keyPrincipal = await resolveApiKeyPrincipal(identityQl, headers).catch(() => void 0);
|
|
692
|
+
if (keyPrincipal) {
|
|
693
|
+
userId = keyPrincipal.userId;
|
|
694
|
+
tenantId = keyPrincipal.tenantId;
|
|
695
|
+
for (const s of keyPrincipal.scopes) if (!permissions.includes(s)) permissions.push(s);
|
|
696
|
+
} else {
|
|
697
|
+
const session = await api.getSession({ headers });
|
|
698
|
+
if (!session?.user?.id) return void 0;
|
|
699
|
+
userId = session.user.id;
|
|
700
|
+
tenantId = session.session?.activeOrganizationId ?? void 0;
|
|
701
|
+
}
|
|
634
702
|
try {
|
|
635
703
|
let ql;
|
|
636
704
|
if (kernel) {
|
|
@@ -1041,6 +1109,7 @@ var RestServer = class {
|
|
|
1041
1109
|
this.registerSharingRuleEndpoints(bp);
|
|
1042
1110
|
this.registerReportsEndpoints(bp);
|
|
1043
1111
|
this.registerApprovalsEndpoints(bp);
|
|
1112
|
+
this.registerAnalyticsEndpoints(bp);
|
|
1044
1113
|
if (this.config.api.enableCrud) {
|
|
1045
1114
|
this.registerCrudEndpoints(bp);
|
|
1046
1115
|
}
|
|
@@ -1081,6 +1150,13 @@ var RestServer = class {
|
|
|
1081
1150
|
if (this.config.api.enableUi) {
|
|
1082
1151
|
discovery.routes.ui = `${realBase}/ui`;
|
|
1083
1152
|
}
|
|
1153
|
+
const mcpEnabled = globalThis?.process?.env?.OS_MCP_SERVER_ENABLED === "true";
|
|
1154
|
+
if (mcpEnabled) {
|
|
1155
|
+
const unscopedBase = isScoped ? basePath.replace(/\/(environments|projects)\/:environmentId$/, "") : basePath;
|
|
1156
|
+
discovery.routes.mcp = `${unscopedBase}/mcp`;
|
|
1157
|
+
} else {
|
|
1158
|
+
delete discovery.routes.mcp;
|
|
1159
|
+
}
|
|
1084
1160
|
if (discovery.routes.auth) {
|
|
1085
1161
|
const unscopedBase = isScoped ? basePath.replace(/\/projects\/:environmentId$/, "") : basePath;
|
|
1086
1162
|
discovery.routes.auth = `${unscopedBase}/auth`;
|
|
@@ -2830,6 +2906,89 @@ var RestServer = class {
|
|
|
2830
2906
|
* when no sharing service is configured so a deployment without the
|
|
2831
2907
|
* `@objectstack/plugin-sharing` plugin fails cleanly.
|
|
2832
2908
|
*/
|
|
2909
|
+
/**
|
|
2910
|
+
* ADR-0021 — analytics dataset preview/query endpoint.
|
|
2911
|
+
*
|
|
2912
|
+
* POST {basePath}/analytics/dataset/query
|
|
2913
|
+
* body: { dataset?: <inline Dataset>, datasetName?: string, selection: DatasetSelection }
|
|
2914
|
+
*
|
|
2915
|
+
* Compiles the dataset (an inline draft for Studio preview, or a saved one
|
|
2916
|
+
* by name) and runs the selection through the analytics service's
|
|
2917
|
+
* `queryDataset`, threading the request ExecutionContext so tenant/RLS
|
|
2918
|
+
* scoping (ADR-0021 D-C) applies. Returns 501 when no analytics service
|
|
2919
|
+
* (or one without `queryDataset`) is configured, so a deployment without
|
|
2920
|
+
* `@objectstack/service-analytics` fails cleanly.
|
|
2921
|
+
*/
|
|
2922
|
+
registerAnalyticsEndpoints(basePath) {
|
|
2923
|
+
const isScoped = basePath.includes("/environments/:environmentId");
|
|
2924
|
+
const resolveService = async (environmentId) => {
|
|
2925
|
+
if (!this.analyticsServiceProvider) return void 0;
|
|
2926
|
+
try {
|
|
2927
|
+
return await this.analyticsServiceProvider(environmentId);
|
|
2928
|
+
} catch {
|
|
2929
|
+
return void 0;
|
|
2930
|
+
}
|
|
2931
|
+
};
|
|
2932
|
+
this.routeManager.register({
|
|
2933
|
+
method: "POST",
|
|
2934
|
+
path: `${basePath}/analytics/dataset/query`,
|
|
2935
|
+
handler: async (req, res) => {
|
|
2936
|
+
try {
|
|
2937
|
+
const environmentId = isScoped ? req.params?.environmentId : void 0;
|
|
2938
|
+
const context = await this.resolveExecCtx(environmentId, req);
|
|
2939
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
2940
|
+
const svc = await resolveService(environmentId);
|
|
2941
|
+
if (!svc || typeof svc.queryDataset !== "function") {
|
|
2942
|
+
return res.status(501).json({
|
|
2943
|
+
code: "NOT_IMPLEMENTED",
|
|
2944
|
+
message: "Analytics dataset query is not available on this deployment (no analytics service with queryDataset)."
|
|
2945
|
+
});
|
|
2946
|
+
}
|
|
2947
|
+
const body = req.body ?? {};
|
|
2948
|
+
const selection = body.selection;
|
|
2949
|
+
if (!selection || !Array.isArray(selection.measures) || selection.measures.length === 0) {
|
|
2950
|
+
return res.status(400).json({
|
|
2951
|
+
code: "VALIDATION_FAILED",
|
|
2952
|
+
message: "body.selection.measures must be a non-empty array of measure names."
|
|
2953
|
+
});
|
|
2954
|
+
}
|
|
2955
|
+
let dataset = body.dataset;
|
|
2956
|
+
if (!dataset && body.datasetName) {
|
|
2957
|
+
const p = await this.resolveProtocol(environmentId, req);
|
|
2958
|
+
const items = await p.getMetaItems?.({ type: "dataset" }).catch(() => null);
|
|
2959
|
+
const list = Array.isArray(items?.items) ? items.items : Array.isArray(items) ? items : [];
|
|
2960
|
+
dataset = list.find((d) => d?.name === body.datasetName);
|
|
2961
|
+
if (!dataset) {
|
|
2962
|
+
return res.status(404).json({ code: "NOT_FOUND", message: `Dataset "${body.datasetName}" not found.` });
|
|
2963
|
+
}
|
|
2964
|
+
}
|
|
2965
|
+
if (!dataset) {
|
|
2966
|
+
return res.status(400).json({ code: "VALIDATION_FAILED", message: "Provide body.dataset (inline) or body.datasetName." });
|
|
2967
|
+
}
|
|
2968
|
+
try {
|
|
2969
|
+
const { DatasetSchema } = await import("@objectstack/spec/ui");
|
|
2970
|
+
dataset = DatasetSchema.parse(dataset);
|
|
2971
|
+
} catch (verr) {
|
|
2972
|
+
return res.status(400).json({
|
|
2973
|
+
code: "VALIDATION_FAILED",
|
|
2974
|
+
message: "Invalid dataset definition.",
|
|
2975
|
+
detail: String(verr?.message ?? verr).slice(0, 1e3)
|
|
2976
|
+
});
|
|
2977
|
+
}
|
|
2978
|
+
const result = await svc.queryDataset(dataset, selection, context ?? void 0);
|
|
2979
|
+
res.json(result);
|
|
2980
|
+
} catch (error) {
|
|
2981
|
+
const msg = String(error?.message ?? error ?? "");
|
|
2982
|
+
if (/not declared in the dataset|not backed by a declared relationship|not supported by the v1 dataset runtime|read-scope-sql/.test(msg)) {
|
|
2983
|
+
return res.status(400).json({ code: "DATASET_INVALID", message: msg.slice(0, 1e3) });
|
|
2984
|
+
}
|
|
2985
|
+
logError("[REST] Analytics dataset query error:", error);
|
|
2986
|
+
res.status(500).json({ code: "ANALYTICS_QUERY_FAILED", error: msg.slice(0, 500) });
|
|
2987
|
+
}
|
|
2988
|
+
},
|
|
2989
|
+
metadata: { summary: "Run a semantic-layer dataset (preview/query)", tags: ["analytics"] }
|
|
2990
|
+
});
|
|
2991
|
+
}
|
|
2833
2992
|
registerSharingEndpoints(basePath) {
|
|
2834
2993
|
const { crud } = this.config;
|
|
2835
2994
|
const dataPath = `${basePath}${crud.dataPrefix}`;
|
|
@@ -3371,11 +3530,13 @@ var RestServer = class {
|
|
|
3371
3530
|
return;
|
|
3372
3531
|
}
|
|
3373
3532
|
const q = req.query ?? {};
|
|
3533
|
+
const rawApprover = q.approverId ?? q.approver_id;
|
|
3534
|
+
const approverIds = (Array.isArray(rawApprover) ? rawApprover : rawApprover != null ? [rawApprover] : []).flatMap((s) => String(s).split(",")).map((s) => s.trim()).filter(Boolean);
|
|
3374
3535
|
const rows = await svc.listRequests({
|
|
3375
3536
|
object: q.object,
|
|
3376
3537
|
recordId: q.recordId ?? q.record_id,
|
|
3377
3538
|
status: q.status,
|
|
3378
|
-
approverId:
|
|
3539
|
+
approverId: approverIds.length ? approverIds : void 0,
|
|
3379
3540
|
submitterId: q.submitterId ?? q.submitter_id
|
|
3380
3541
|
}, context ?? {});
|
|
3381
3542
|
res.json({ data: rows });
|
|
@@ -3470,6 +3631,73 @@ var RestServer = class {
|
|
|
3470
3631
|
const dataPath = `${basePath}${crud.dataPrefix}`;
|
|
3471
3632
|
const isScoped = basePath.includes("/environments/:environmentId");
|
|
3472
3633
|
const operations = batch.operations;
|
|
3634
|
+
this.routeManager.register({
|
|
3635
|
+
method: "POST",
|
|
3636
|
+
path: `${basePath}/batch`,
|
|
3637
|
+
handler: async (req, res) => {
|
|
3638
|
+
try {
|
|
3639
|
+
const environmentId = isScoped ? req.params?.environmentId : void 0;
|
|
3640
|
+
const context = await this.resolveExecCtx(environmentId, req);
|
|
3641
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
3642
|
+
const ql = this.objectQLProvider ? await this.objectQLProvider(environmentId) : void 0;
|
|
3643
|
+
if (!ql || typeof ql.transaction !== "function") {
|
|
3644
|
+
res.status(501).json({ error: "Transactional batch not supported by this runtime" });
|
|
3645
|
+
return;
|
|
3646
|
+
}
|
|
3647
|
+
const ops = Array.isArray(req.body?.operations) ? req.body.operations : [];
|
|
3648
|
+
const max = batch.maxBatchSize ?? 200;
|
|
3649
|
+
if (ops.length === 0) {
|
|
3650
|
+
res.json({ results: [] });
|
|
3651
|
+
return;
|
|
3652
|
+
}
|
|
3653
|
+
if (ops.length > max) {
|
|
3654
|
+
res.status(400).json({ error: `Batch too large (max ${max})` });
|
|
3655
|
+
return;
|
|
3656
|
+
}
|
|
3657
|
+
const resolveRefs = (data, out) => {
|
|
3658
|
+
if (!data || typeof data !== "object") return data;
|
|
3659
|
+
const result = Array.isArray(data) ? [] : {};
|
|
3660
|
+
for (const [k, v] of Object.entries(data)) {
|
|
3661
|
+
if (v && typeof v === "object" && "$ref" in v) {
|
|
3662
|
+
const ref = out[v.$ref];
|
|
3663
|
+
result[k] = (ref && (ref.id ?? ref._id)) ?? null;
|
|
3664
|
+
} else {
|
|
3665
|
+
result[k] = v;
|
|
3666
|
+
}
|
|
3667
|
+
}
|
|
3668
|
+
return result;
|
|
3669
|
+
};
|
|
3670
|
+
const results = await ql.transaction(async (trxCtx) => {
|
|
3671
|
+
const out = [];
|
|
3672
|
+
for (const op of ops) {
|
|
3673
|
+
const action = String(op?.action || "create");
|
|
3674
|
+
const object = String(op?.object || "");
|
|
3675
|
+
if (!object) throw new Error("Each operation requires an `object`");
|
|
3676
|
+
const data = resolveRefs(op.data, out);
|
|
3677
|
+
if (action === "create") {
|
|
3678
|
+
out.push(await ql.insert(object, data, { context: trxCtx }));
|
|
3679
|
+
} else if (action === "update") {
|
|
3680
|
+
const id = op.id ?? data?.id;
|
|
3681
|
+
out.push(await ql.update(object, { ...data, id }, { context: trxCtx }));
|
|
3682
|
+
} else if (action === "delete") {
|
|
3683
|
+
out.push(await ql.delete(object, { where: { id: op.id }, context: trxCtx }));
|
|
3684
|
+
} else {
|
|
3685
|
+
throw new Error(`Unknown batch action: ${action}`);
|
|
3686
|
+
}
|
|
3687
|
+
}
|
|
3688
|
+
return out;
|
|
3689
|
+
}, context);
|
|
3690
|
+
res.json({ results });
|
|
3691
|
+
} catch (error) {
|
|
3692
|
+
logError("[REST] Unhandled error:", error);
|
|
3693
|
+
sendError(res, error);
|
|
3694
|
+
}
|
|
3695
|
+
},
|
|
3696
|
+
metadata: {
|
|
3697
|
+
summary: "Cross-object transactional batch (atomic create/update/delete across objects)",
|
|
3698
|
+
tags: ["data", "batch"]
|
|
3699
|
+
}
|
|
3700
|
+
});
|
|
3473
3701
|
if (batch.enableBatchEndpoint && this.protocol.batchData) {
|
|
3474
3702
|
this.routeManager.register({
|
|
3475
3703
|
method: "POST",
|
|
@@ -3869,6 +4097,13 @@ function createRestApiPlugin(config = {}) {
|
|
|
3869
4097
|
return void 0;
|
|
3870
4098
|
}
|
|
3871
4099
|
};
|
|
4100
|
+
const analyticsServiceProvider = async (_environmentId) => {
|
|
4101
|
+
try {
|
|
4102
|
+
return ctx.getService("analytics");
|
|
4103
|
+
} catch {
|
|
4104
|
+
return void 0;
|
|
4105
|
+
}
|
|
4106
|
+
};
|
|
3872
4107
|
if (!server) {
|
|
3873
4108
|
ctx.logger.warn(`RestApiPlugin: HTTP Server service '${serverService}' not found. REST routes skipped.`);
|
|
3874
4109
|
return;
|
|
@@ -3879,7 +4114,7 @@ function createRestApiPlugin(config = {}) {
|
|
|
3879
4114
|
}
|
|
3880
4115
|
ctx.logger.info("Hydrating REST API from Protocol...");
|
|
3881
4116
|
try {
|
|
3882
|
-
const restServer = new RestServer(server, protocol, config.api, kernelManager, envRegistry, defaultEnvironmentIdProvider, authServiceProvider, objectQLProvider, emailServiceProvider, sharingServiceProvider, reportsServiceProvider, approvalsServiceProvider, sharingRulesServiceProvider, i18nServiceProvider);
|
|
4117
|
+
const restServer = new RestServer(server, protocol, config.api, kernelManager, envRegistry, defaultEnvironmentIdProvider, authServiceProvider, objectQLProvider, emailServiceProvider, sharingServiceProvider, reportsServiceProvider, approvalsServiceProvider, sharingRulesServiceProvider, i18nServiceProvider, analyticsServiceProvider);
|
|
3883
4118
|
restServer.registerRoutes();
|
|
3884
4119
|
ctx.logger.info("REST API successfully registered");
|
|
3885
4120
|
} catch (err) {
|