@objectstack/rest 7.9.0 → 8.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
- 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>);
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
- 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>);
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.envRegistry.resolveByHostname(host);
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.envRegistry.resolveByHostname(host);
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.envRegistry.resolveByHostname(host);
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: q.approverId ?? q.approver_id,
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) {