@classytic/arc 2.15.0 → 2.15.4

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/README.md CHANGED
@@ -155,6 +155,50 @@ Custom checks return `{ granted, reason?, filters?, scope? }` — `filters` prop
155
155
 
156
156
  ---
157
157
 
158
+ ## Aggregations
159
+
160
+ Add `aggregations: { … }` to a resource and arc registers `GET /:prefix/aggregations/:name` per entry. Each runs a portable `$match → $group → $project → $sort → $limit` pipeline against the kit's `repo.aggregate(req, options)` — same shape across mongokit / sqlitekit / prismakit, so dashboards work unchanged across backends.
161
+
162
+ ```typescript
163
+ import { defineResource, defineAggregation } from '@classytic/arc';
164
+
165
+ defineResource({
166
+ name: 'transaction',
167
+ adapter,
168
+ presets: [multiTenantPreset({ tenantField: 'organizationId' })],
169
+ permissions: { list: canViewRevenue() },
170
+
171
+ aggregations: {
172
+ byPaymentMethod: defineAggregation({
173
+ groupBy: 'method',
174
+ measures: { total: 'sum:amount', count: 'count' },
175
+ sort: { total: -1 },
176
+ cache: { staleTime: 60, swr: true, tags: ['revenue'] },
177
+ permissions: canViewRevenue(),
178
+ }),
179
+ byDay: defineAggregation({
180
+ dateBuckets: { day: { field: 'createdAt', interval: 'day' } },
181
+ groupBy: 'flow',
182
+ measures: { total: 'sum:amount', count: 'count' },
183
+ requireDateRange: { field: 'createdAt', maxRangeDays: 365 },
184
+ cache: { staleTime: 60, swr: true, tags: ['revenue'] },
185
+ permissions: canViewRevenue(),
186
+ }),
187
+ },
188
+ });
189
+ ```
190
+
191
+ Caller filters via query string compose with the declaration:
192
+
193
+ ```
194
+ GET /api/transactions/aggregations/byPaymentMethod?status=verified
195
+ GET /api/transactions/aggregations/byDay?createdAt[gte]=2026-01-01&createdAt[lt]=2026-02-01
196
+ ```
197
+
198
+ Tenant scope flows through `repo.aggregate(req, options)` — the kit's multi-tenant plugin handles type-coercion (string → ObjectId for mongokit `fieldType: 'objectId'`, UUID/text for sqlitekit, etc.). Arc itself stays out of the filter slot because it's DB-agnostic. Safety guards on the declaration: `requireFilters`, `requireDateRange { maxRangeDays }`, `maxGroups`. SWR cache + tag invalidation tie aggregations to CRUD writes. Every aggregation auto-exports as an MCP tool with the same permissions and filter validation.
199
+
200
+ ---
201
+
158
202
  ## Authentication
159
203
 
160
204
  Discriminated union on `type`:
@@ -701,6 +701,7 @@ var BaseCrudController = class {
701
701
  if (presetFields && typeof presetFields === "object") {
702
702
  for (const [key, value] of Object.entries(presetFields)) if (value != null && out[key] == null) out[key] = value;
703
703
  }
704
+ if (this.tenantField && out[this.tenantField] == null && scope && isElevated(scope)) out.bypassTenant = true;
704
705
  const userId = getUserId(req.user);
705
706
  if (userId) out.userId = userId;
706
707
  if (req.user) out.user = req.user;
@@ -78,10 +78,8 @@ function adapterSupportsAggregate(repo) {
78
78
  }
79
79
  /** Compile to canonical `AggRequest` for `repo.aggregate()` at request time. */
80
80
  function compileAggRequest(normalized, callerFilter, tenantOptions) {
81
- const baseFilter = normalized.compiled.filter ?? {};
82
81
  const filter = {
83
- ...extractTenantFilter(tenantOptions),
84
- ...baseFilter,
82
+ ...normalized.compiled.filter ?? {},
85
83
  ...callerFilter
86
84
  };
87
85
  const executionHints = buildExecutionHints(normalized.base);
@@ -374,21 +372,6 @@ function assertFieldAllowed(context, ref, input) {
374
372
  }
375
373
  if (blockedFields.has(ref)) throw new ArcAggregationConfigError(`Resource "${resourceName}" aggregation "${aggregationName}" references field "${ref}" in ${context}, but the field is blocked from aggregation (\`hidden: true\` or \`aggregable: false\` in schemaOptions.fieldRules). Aggregating hidden fields would leak cardinality information.`);
376
374
  }
377
- function extractTenantFilter(tenantOptions) {
378
- const out = {};
379
- const optionOnlyKeys = new Set([
380
- "userId",
381
- "user",
382
- "session",
383
- "requestId"
384
- ]);
385
- for (const [key, value] of Object.entries(tenantOptions)) {
386
- if (optionOnlyKeys.has(key)) continue;
387
- if (value === void 0 || value === null) continue;
388
- out[key] = value;
389
- }
390
- return out;
391
- }
392
375
  //#endregion
393
376
  //#region src/core/aggregation/buildHandler.ts
394
377
  /**
@@ -444,7 +427,7 @@ async function executeAggregation(normalized, deps, ctx) {
444
427
  };
445
428
  let result;
446
429
  try {
447
- result = await repo.aggregate(aggReq);
430
+ result = await repo.aggregate(aggReq, tenantOptions);
448
431
  } catch (err) {
449
432
  return mapAggregateError(err, aggregationName);
450
433
  }
@@ -376,20 +376,29 @@ function packageJsonTemplate(config) {
376
376
  test: "vitest run",
377
377
  "test:watch": "vitest"
378
378
  };
379
+ const imports = config.typescript ? {
380
+ "#config/*": "./dist/config/*",
381
+ "#shared/*": "./dist/shared/*",
382
+ "#resources/*": "./dist/resources/*",
383
+ "#plugins/*": "./dist/plugins/*",
384
+ "#services/*": "./dist/services/*",
385
+ "#lib/*": "./dist/lib/*",
386
+ "#utils/*": "./dist/utils/*"
387
+ } : {
388
+ "#config/*": "./src/config/*",
389
+ "#shared/*": "./src/shared/*",
390
+ "#resources/*": "./src/resources/*",
391
+ "#plugins/*": "./src/plugins/*",
392
+ "#services/*": "./src/services/*",
393
+ "#lib/*": "./src/lib/*",
394
+ "#utils/*": "./src/utils/*"
395
+ };
379
396
  return JSON.stringify({
380
397
  name: config.name,
381
398
  version: "1.0.0",
382
399
  type: "module",
383
400
  main: config.typescript ? "dist/index.js" : "src/index.js",
384
- imports: {
385
- "#config/*": "./src/config/*",
386
- "#shared/*": "./src/shared/*",
387
- "#resources/*": "./src/resources/*",
388
- "#plugins/*": "./src/plugins/*",
389
- "#services/*": "./src/services/*",
390
- "#lib/*": "./src/lib/*",
391
- "#utils/*": "./src/utils/*"
392
- },
401
+ imports,
393
402
  scripts,
394
403
  dependencies,
395
404
  devDependencies,
@@ -1,5 +1,5 @@
1
1
  import { a as DEFAULT_SORT, c as HOOK_OPERATIONS, d as MAX_REGEX_LENGTH, f as MAX_SEARCH_LENGTH, h as SYSTEM_FIELDS, i as DEFAULT_MAX_LIMIT, l as HOOK_PHASES, m as RESERVED_QUERY_PARAMS, n as DEFAULT_ID_FIELD, o as DEFAULT_TENANT_FIELD, p as MUTATION_OPERATIONS, r as DEFAULT_LIMIT, s as DEFAULT_UPDATE_METHOD, t as CRUD_OPERATIONS, u as MAX_FILTER_DEPTH } from "../constants-Cxde4rpC.mjs";
2
- import { a as BulkMixin, c as collectReadBlockedFields, d as AccessControl, i as SlugMixin, l as isFieldReadable, n as TreeMixin, o as BaseCrudController, r as SoftDeleteMixin, s as QueryResolver, t as BaseController, u as BodySanitizer } from "../BaseController-Cn8KykUn.mjs";
2
+ import { a as BulkMixin, c as collectReadBlockedFields, d as AccessControl, i as SlugMixin, l as isFieldReadable, n as TreeMixin, o as BaseCrudController, r as SoftDeleteMixin, s as QueryResolver, t as BaseController, u as BodySanitizer } from "../BaseController-dx3m2J8V.mjs";
3
3
  import { _ as getControllerContext, g as createRequestContext, h as createFastifyHandler, m as createCrudHandlers, v as getControllerScope, y as sendControllerResponse } from "../routerShared-DrOa-26E.mjs";
4
- import { a as defineResource, c as createPermissionMiddleware, i as defineResourceVariants, l as defineAggregation, n as getEntityIdField, o as ResourceDefinition, r as getEntityQuery, s as createCrudRouter, t as getEntityId } from "../core--_Dnl7n-.mjs";
4
+ import { a as defineResource, c as createPermissionMiddleware, i as defineResourceVariants, l as defineAggregation, n as getEntityIdField, o as ResourceDefinition, r as getEntityQuery, s as createCrudRouter, t as getEntityId } from "../core-CvmOqEms.mjs";
5
5
  export { AccessControl, BaseController, BaseCrudController, BodySanitizer, BulkMixin, CRUD_OPERATIONS, DEFAULT_ID_FIELD, DEFAULT_LIMIT, DEFAULT_MAX_LIMIT, DEFAULT_SORT, DEFAULT_TENANT_FIELD, DEFAULT_UPDATE_METHOD, HOOK_OPERATIONS, HOOK_PHASES, MAX_FILTER_DEPTH, MAX_REGEX_LENGTH, MAX_SEARCH_LENGTH, MUTATION_OPERATIONS, QueryResolver, RESERVED_QUERY_PARAMS, ResourceDefinition, SYSTEM_FIELDS, SlugMixin, SoftDeleteMixin, TreeMixin, collectReadBlockedFields, createCrudHandlers, createCrudRouter, createFastifyHandler, createPermissionMiddleware, createRequestContext, defineAggregation, defineResource, defineResourceVariants, getControllerContext, getControllerScope, getEntityId, getEntityIdField, getEntityQuery, isFieldReadable, sendControllerResponse };
@@ -1,7 +1,7 @@
1
1
  import { s as DEFAULT_UPDATE_METHOD, t as CRUD_OPERATIONS } from "./constants-Cxde4rpC.mjs";
2
2
  import { arcLog } from "./logger/index.mjs";
3
3
  import { A as assertValidConfig, l as getDefaultCrudSchemas } from "./utils-_h9B3c57.mjs";
4
- import { t as BaseController } from "./BaseController-Cn8KykUn.mjs";
4
+ import { t as BaseController } from "./BaseController-dx3m2J8V.mjs";
5
5
  import { t as applyPresets } from "./presets-BbkjdPeH.mjs";
6
6
  import { n as convertRouteSchema, t as convertOpenApiSchemas } from "./schemaConverter-De34B1ZG.mjs";
7
7
  import { t as hasEvents } from "./typeGuards-BzkXkvVv.mjs";
@@ -840,6 +840,7 @@ function normalizeListQuerySchema(listQuerySchema) {
840
840
  for (const key of Object.keys(normalizedProps)) normalizedProps[key] = NORMALIZED_PROPS[key] ?? {};
841
841
  }
842
842
  return {
843
+ type: "object",
843
844
  ...listQuerySchema,
844
845
  ...normalizedProps ? { properties: normalizedProps } : {},
845
846
  additionalProperties: listQuerySchema.additionalProperties ?? true
@@ -997,7 +998,7 @@ function buildResourcePlugin(resource) {
997
998
  });
998
999
  }
999
1000
  if (resource.aggregations && Object.keys(resource.aggregations).length > 0) {
1000
- const { createAggregationRouter } = await import("./createAggregationRouter-DhR-Ofiz.mjs");
1001
+ const { createAggregationRouter } = await import("./createAggregationRouter-B0bPDf5b.mjs");
1001
1002
  const repoForAgg = resource.controller?.repository;
1002
1003
  const buildOptions = (req) => {
1003
1004
  const ctrl = resource.controller;
@@ -1014,7 +1015,8 @@ function buildResourcePlugin(resource) {
1014
1015
  permissions: resource.permissions,
1015
1016
  routeGuards: resource.routeGuards,
1016
1017
  repository: repoForAgg,
1017
- buildOptions
1018
+ buildOptions,
1019
+ middlewares: resource.middlewares?.aggregations
1018
1020
  });
1019
1021
  }
1020
1022
  if (resource.events && Object.keys(resource.events).length > 0) typedInstance.log?.debug?.(`Resource '${resource.name}' defined ${Object.keys(resource.events).length} events`);
@@ -1,13 +1,13 @@
1
1
  import { f as createError, l as UnauthorizedError, r as ForbiddenError } from "./errors-j4aJm1Wg.mjs";
2
2
  import { c as buildPreHandlerChain, f as resolveRouterPluginMw, i as buildAuthMiddleware, l as buildRateLimitConfig, p as selectPluginMw, r as buildArcDecorator } from "./routerShared-DrOa-26E.mjs";
3
- import { r as validateAggregations, t as buildAggregationHandler } from "./buildHandler-jSZ6Fdvi.mjs";
3
+ import { r as validateAggregations, t as buildAggregationHandler } from "./buildHandler-CcFOpJLh.mjs";
4
4
  //#region src/core/aggregation/createAggregationRouter.ts
5
5
  /**
6
6
  * Register one Fastify route per aggregation. No-op when the map is
7
7
  * empty — same convention `createActionRouter` follows.
8
8
  */
9
9
  function createAggregationRouter(fastify, config) {
10
- const { tag, resourceName, aggregations, fields: fieldPermissions, schemaOptions, permissions: resourcePermissions, routeGuards = [], repository, buildOptions } = config;
10
+ const { tag, resourceName, aggregations, fields: fieldPermissions, schemaOptions, permissions: resourcePermissions, routeGuards = [], repository, buildOptions, middlewares = [] } = config;
11
11
  if (!aggregations || Object.keys(aggregations).length === 0) return;
12
12
  const normalized = validateAggregations(resourceName, aggregations, schemaOptions);
13
13
  const arcDecorator = buildArcDecorator({
@@ -23,7 +23,8 @@ function createAggregationRouter(fastify, config) {
23
23
  arcDecorator,
24
24
  routeGuards,
25
25
  repository,
26
- buildOptions
26
+ buildOptions,
27
+ middlewares
27
28
  });
28
29
  fastify.log?.debug?.({
29
30
  aggregations: normalized.map((a) => a.name),
@@ -31,7 +32,7 @@ function createAggregationRouter(fastify, config) {
31
32
  }, `[createAggregationRouter] registered ${normalized.length} aggregation route(s)`);
32
33
  }
33
34
  function registerOne(fastify, normalized, ctx) {
34
- const { tag, arcDecorator, routeGuards, repository, buildOptions } = ctx;
35
+ const { tag, arcDecorator, routeGuards, repository, buildOptions, middlewares } = ctx;
35
36
  const { name } = normalized;
36
37
  const config = normalized.base;
37
38
  const authMw = buildAuthMiddleware(fastify, config.permissions);
@@ -50,7 +51,8 @@ function registerOne(fastify, normalized, ctx) {
50
51
  authMw,
51
52
  permissionMw,
52
53
  pluginMw: selectPluginMw("GET", resolveRouterPluginMw(fastify, false)),
53
- routeGuards
54
+ routeGuards,
55
+ customMws: middlewares
54
56
  });
55
57
  const rateLimitConfig = buildRateLimitConfig(config.rateLimit ? {
56
58
  max: config.rateLimit.max,
@@ -230,8 +230,9 @@ async function registerArcPlugins(fastify, config, trackPlugin, modules) {
230
230
  trackPlugin("arc-request-id");
231
231
  }
232
232
  if (config.arcPlugins?.health !== false) {
233
- await fastify.register(healthPlugin);
234
- trackPlugin("arc-health");
233
+ const healthOpts = typeof config.arcPlugins?.health === "object" && config.arcPlugins.health !== null ? config.arcPlugins.health : {};
234
+ await fastify.register(healthPlugin, healthOpts);
235
+ trackPlugin("arc-health", healthOpts);
235
236
  }
236
237
  if (config.arcPlugins?.gracefulShutdown !== false) {
237
238
  await fastify.register(gracefulShutdownPlugin);
@@ -707,9 +708,65 @@ async function registerUtilityPlugins(fastify, config) {
707
708
  */
708
709
  var createApp_exports = /* @__PURE__ */ __exportAll({
709
710
  ArcFactory: () => ArcFactory,
710
- createApp: () => createApp
711
+ DEFAULT_LOGGER_REDACT_PATHS: () => DEFAULT_LOGGER_REDACT_PATHS,
712
+ createApp: () => createApp,
713
+ resolveLoggerConfig: () => resolveLoggerConfig
711
714
  });
712
715
  const MEMORY_STORE_NAMES = new Set(["memory", "memory-cache"]);
716
+ /**
717
+ * Default redact paths layered into Fastify's pino logger when the host
718
+ * doesn't supply a `logger.redact` of their own. Covers the common token /
719
+ * cookie / password leak surfaces — `Authorization` and `Cookie` headers
720
+ * (and their case-variants because Node lower-cases incoming headers but
721
+ * outgoing logs may carry either), API-key headers, and any nested
722
+ * `password` / `token` / `secret` / `apiKey` fields anywhere in the log
723
+ * tree.
724
+ *
725
+ * Hosts that need NO redaction (test fixtures, security audits) opt out
726
+ * with `logger: { redact: [] }`. Hosts that need MORE redaction supply
727
+ * their own `redact` and arc steps aside — this default never overrides
728
+ * an explicit setting. (2.15.1)
729
+ */
730
+ const DEFAULT_LOGGER_REDACT_PATHS = [
731
+ "req.headers.authorization",
732
+ "req.headers[\"x-api-key\"]",
733
+ "req.headers[\"x-internal-api-key\"]",
734
+ "req.headers.cookie",
735
+ "req.headers[\"set-cookie\"]",
736
+ "res.headers[\"set-cookie\"]",
737
+ "*.password",
738
+ "*.passwordHash",
739
+ "*.token",
740
+ "*.accessToken",
741
+ "*.refreshToken",
742
+ "*.secret",
743
+ "*.apiKey"
744
+ ];
745
+ /**
746
+ * Resolve the host's `logger` option, layering safe redact defaults
747
+ * when the host hasn't supplied any. Returns the value Fastify expects
748
+ * for its `logger` server option.
749
+ *
750
+ * Three branches:
751
+ * - `logger === false` → pass through (no logger).
752
+ * - `logger === undefined` → enable with default redact paths.
753
+ * - `logger === true` → enable with default redact paths.
754
+ * - `logger` is an object → respect explicit `redact` (host wins);
755
+ * otherwise inject defaults.
756
+ */
757
+ function resolveLoggerConfig(logger) {
758
+ if (logger === false) return false;
759
+ if (logger === true || logger === void 0) return { redact: [...DEFAULT_LOGGER_REDACT_PATHS] };
760
+ if (typeof logger === "object" && logger !== null) {
761
+ const objLogger = logger;
762
+ if (objLogger.redact !== void 0) return logger;
763
+ return {
764
+ ...objLogger,
765
+ redact: [...DEFAULT_LOGGER_REDACT_PATHS]
766
+ };
767
+ }
768
+ return logger;
769
+ }
713
770
  function validateAuthOptions(options) {
714
771
  const authConfig = options.auth;
715
772
  if (authConfig === false || !authConfig) return;
@@ -766,14 +823,17 @@ async function createApp(options) {
766
823
  ...options
767
824
  };
768
825
  const fastify = Fastify({
769
- logger: config.logger ?? true,
826
+ logger: resolveLoggerConfig(config.logger),
770
827
  trustProxy: config.trustProxy ?? false,
771
828
  pluginTimeout: config.pluginTimeout ?? 1e4,
829
+ ...config.bodyLimit !== void 0 ? { bodyLimit: config.bodyLimit } : {},
830
+ allowErrorHandlerOverride: true,
772
831
  routerOptions: { querystringParser: (str) => qs.parse(str) },
773
832
  ajv: { customOptions: {
774
833
  coerceTypes: true,
775
834
  useDefaults: true,
776
835
  removeAdditional: false,
836
+ strictTypes: false,
777
837
  keywords: ["example", ...config.ajv?.keywords ?? []]
778
838
  } }
779
839
  });
@@ -1,5 +1,5 @@
1
- import { a as CustomPluginAuthOption, c as RawBodyOptions, d as ResourceLike, f as ResourceModule, i as CustomAuthenticatorOption, l as UnderPressureOptions, n as BetterAuthOption, o as JwtAuthOption, p as loadResources, r as CreateAppOptions, s as MultipartOptions, t as AuthOption, u as LoadResourcesOptions } from "../types-3YTpuLZ1.mjs";
2
- import { FastifyInstance } from "fastify";
1
+ import { a as CustomPluginAuthOption, c as RawBodyOptions, d as ResourceLike, f as ResourceModule, i as CustomAuthenticatorOption, l as UnderPressureOptions, n as BetterAuthOption, o as JwtAuthOption, p as loadResources, r as CreateAppOptions, s as MultipartOptions, t as AuthOption, u as LoadResourcesOptions } from "../types-DrBaUwyV.mjs";
2
+ import { FastifyInstance, FastifyServerOptions } from "fastify";
3
3
 
4
4
  //#region src/factory/createApp.d.ts
5
5
  /**
@@ -1,4 +1,4 @@
1
- import { a as edgePreset, c as testingPreset, i as developmentPreset, n as createApp, o as getPreset, s as productionPreset, t as ArcFactory } from "../createApp-BarYhXCZ.mjs";
1
+ import { a as edgePreset, c as testingPreset, i as developmentPreset, n as createApp, o as getPreset, s as productionPreset, t as ArcFactory } from "../createApp-PFegs47-.mjs";
2
2
  import { t as loadResources } from "../loadResources-DBMQg_Aj.mjs";
3
3
  //#region src/factory/edge.ts
4
4
  /**
package/dist/index.mjs CHANGED
@@ -1,11 +1,11 @@
1
1
  import { a as DEFAULT_SORT, c as HOOK_OPERATIONS, d as MAX_REGEX_LENGTH, f as MAX_SEARCH_LENGTH, h as SYSTEM_FIELDS, i as DEFAULT_MAX_LIMIT, l as HOOK_PHASES, m as RESERVED_QUERY_PARAMS, n as DEFAULT_ID_FIELD, o as DEFAULT_TENANT_FIELD, p as MUTATION_OPERATIONS, r as DEFAULT_LIMIT, s as DEFAULT_UPDATE_METHOD, t as CRUD_OPERATIONS, u as MAX_FILTER_DEPTH } from "./constants-Cxde4rpC.mjs";
2
2
  import { d as createDomainError, i as NotFoundError, l as UnauthorizedError, r as ForbiddenError, t as ArcError, u as ValidationError } from "./errors-j4aJm1Wg.mjs";
3
3
  import { t as getUserId } from "./utils-_h9B3c57.mjs";
4
- import { a as BulkMixin, i as SlugMixin, n as TreeMixin, o as BaseCrudController, r as SoftDeleteMixin, t as BaseController } from "./BaseController-Cn8KykUn.mjs";
4
+ import { a as BulkMixin, i as SlugMixin, n as TreeMixin, o as BaseCrudController, r as SoftDeleteMixin, t as BaseController } from "./BaseController-dx3m2J8V.mjs";
5
5
  import { C as allowPublic, D as requireAuth, O as requireOwnership, S as allOf, T as denyAll, _ as requireOrgMembership, a as presets_exports, b as requireServiceScope, c as readOnly, d as applyFieldWritePermissions, f as fields, g as requireOrgInScope, h as createOrgPermissions, i as ownerWithAdminBypass, j as when, k as requireRoles, m as createDynamicPermissionMatrix, n as authenticated, o as publicRead, r as fullPublic, s as publicReadAdminWrite, t as adminOnly, u as applyFieldReadPermissions, v as requireOrgRole, w as anyOf, x as requireTeamMembership, y as requireScopeContext } from "./permissions-ohQyv50e.mjs";
6
6
  import { v as getControllerScope } from "./routerShared-DrOa-26E.mjs";
7
- import { a as defineResource, i as defineResourceVariants, l as defineAggregation, o as ResourceDefinition } from "./core--_Dnl7n-.mjs";
7
+ import { a as defineResource, i as defineResourceVariants, l as defineAggregation, o as ResourceDefinition } from "./core-CvmOqEms.mjs";
8
8
  //#region src/index.ts
9
- const version = "2.15.0";
9
+ const version = "2.15.4";
10
10
  //#endregion
11
11
  export { ArcError, BaseController, BaseCrudController, BulkMixin, CRUD_OPERATIONS, DEFAULT_ID_FIELD, DEFAULT_LIMIT, DEFAULT_MAX_LIMIT, DEFAULT_SORT, DEFAULT_TENANT_FIELD, DEFAULT_UPDATE_METHOD, ForbiddenError, HOOK_OPERATIONS, HOOK_PHASES, MAX_FILTER_DEPTH, MAX_REGEX_LENGTH, MAX_SEARCH_LENGTH, MUTATION_OPERATIONS, NotFoundError, RESERVED_QUERY_PARAMS, ResourceDefinition, SYSTEM_FIELDS, SlugMixin, SoftDeleteMixin, TreeMixin, UnauthorizedError, ValidationError, adminOnly, allOf, allowPublic, anyOf, applyFieldReadPermissions, applyFieldWritePermissions, authenticated, createDomainError, createDynamicPermissionMatrix, createOrgPermissions, defineAggregation, defineResource, defineResourceVariants, denyAll, fields, fullPublic, getControllerScope, getUserId, ownerWithAdminBypass, presets_exports as permissions, publicRead, publicReadAdminWrite, readOnly, requireAuth, requireOrgInScope, requireOrgMembership, requireOrgRole, requireOwnership, requireRoles, requireScopeContext, requireServiceScope, requireTeamMembership, version, when };
@@ -1,4 +1,4 @@
1
- import { n as fieldRulesToZod, r as createMcpServer, t as resourceToTools } from "../../resourceToTools-D4pWzVGA.mjs";
1
+ import { n as fieldRulesToZod, r as createMcpServer, t as resourceToTools } from "../../resourceToTools-tFYUNmM0.mjs";
2
2
  import { createHash, randomUUID } from "node:crypto";
3
3
  import fp from "fastify-plugin";
4
4
  //#region src/integrations/mcp/defineTool.ts
@@ -1,4 +1,4 @@
1
- import { r as createMcpServer, t as resourceToTools } from "../../resourceToTools-D4pWzVGA.mjs";
1
+ import { r as createMcpServer, t as resourceToTools } from "../../resourceToTools-tFYUNmM0.mjs";
2
2
  //#region src/integrations/mcp/testing.ts
3
3
  /**
4
4
  * @classytic/arc/mcp/testing — MCP Test Utilities
@@ -50,7 +50,40 @@ interface WorkflowLike {
50
50
  }; /** Repository — used by the list-runs endpoint to query workflow_runs. */
51
51
  repository?: {
52
52
  getAll(params: Record<string, unknown>, options?: Record<string, unknown>): Promise<unknown>;
53
+ /**
54
+ * Tenant-scoped lookup by id. Used by the DELETE handler for a
55
+ * defense-in-depth pre-flight: streamline 2.3.3's `wf.get(runId)` /
56
+ * `engine.get` does NOT accept tenant options, so a cross-tenant
57
+ * runId can leak data through the engine path. Going through the
58
+ * repository here means mongokit's tenant-filter plugin scopes the
59
+ * read — cross-tenant requests get a clean 404 and DELETEs only
60
+ * touch rows the caller actually owns.
61
+ */
62
+ getById?(id: string, options?: Record<string, unknown>): Promise<WorkflowRunLike | null>;
63
+ /**
64
+ * Hard-delete a run by id. Routed through mongokit's inherited
65
+ * `Repository.delete()` so multi-tenant scope + audit/cache plugins
66
+ * fire. Wired into `DELETE /:workflowId/runs/:runId` — operator
67
+ * escape hatch for dead-lettered or stuck rows.
68
+ */
69
+ delete?(id: string, options?: Record<string, unknown>): Promise<unknown>;
53
70
  };
71
+ /**
72
+ * Streamline >= 2.3.2 — explicit deploy-time index sync (TTL on
73
+ * terminal runs + tenant compounds). When the host configured
74
+ * `createContainer({ retention })`, arc's app-level deploy hook
75
+ * should call `await container.syncRetentionIndexes()` after
76
+ * `mongoose.connect`. Optional so older streamline versions
77
+ * (and partial mocks) still satisfy the structural shape.
78
+ */
79
+ syncRetentionIndexes?: () => Promise<void>;
80
+ /**
81
+ * Streamline >= 2.3.2 — stop background sweepers and release timers.
82
+ * Arc's `onClose` hook below calls this on every workflow's container
83
+ * during graceful shutdown so SIGTERM doesn't leave the stale-run
84
+ * sweeper running. Optional + idempotent.
85
+ */
86
+ dispose?: () => void;
54
87
  };
55
88
  }
56
89
  interface WorkflowRunLike {
@@ -67,7 +100,36 @@ interface WorkflowRunLike {
67
100
  stepLogs?: unknown[];
68
101
  createdAt?: Date;
69
102
  updatedAt?: Date;
103
+ /**
104
+ * Streamline >= 2.3.3 — pinned definition version (semver) the run
105
+ * started under. Hosts surfacing a "stuck on old version" UI read this
106
+ * to decide whether to nudge a migration. Optional for back-compat
107
+ * with runs created before 2.3.3.
108
+ */
109
+ definitionVersion?: string;
110
+ /**
111
+ * Streamline >= 2.3.3 — count of stale-recovery / sweeper transitions
112
+ * applied to this run. Sweeper dead-letters once this hits
113
+ * `RetentionOptions.maxStaleRecoveries`; UIs can highlight runs trending
114
+ * toward dead-letter.
115
+ */
116
+ recoveryAttempts?: number;
70
117
  }
118
+ /**
119
+ * Streamline >= 2.3.3 dead-letter discriminator. The run.status stays
120
+ * `'failed'`; the discrimination is `error.code`:
121
+ * - `'stale_heartbeat'` — sweeper terminated; transient crash signal.
122
+ * - `'dead_lettered'` — exceeded `maxStaleRecoveries`; permanent.
123
+ * - `'VERSION_MISMATCH'` — engine deployed a step graph the run can't
124
+ * resume against; admin must rewind / migrate / cancel.
125
+ *
126
+ * Hosts switch on `error.code` for dashboards / alerting.
127
+ */
128
+ declare const STREAMLINE_FAILURE_CODES: {
129
+ readonly STALE_HEARTBEAT: "stale_heartbeat";
130
+ readonly DEAD_LETTERED: "dead_lettered";
131
+ readonly VERSION_MISMATCH: "VERSION_MISMATCH";
132
+ };
71
133
  interface StreamlinePluginOptions {
72
134
  /** Array of workflows created with createWorkflow() */
73
135
  workflows: WorkflowLike[];
@@ -176,7 +238,14 @@ declare const STREAMLINE_BUS_EVENTS: readonly ["step:started", "step:completed",
176
238
  * the run is still active after them.
177
239
  */
178
240
  declare const STREAMLINE_TERMINAL_EVENTS: readonly ["workflow:completed", "workflow:failed", "workflow:cancelled"];
179
- /** Pluggable streamline integration for Arc */
241
+ /**
242
+ * Pluggable streamline integration for Arc.
243
+ *
244
+ * Wrapped in `fastify-plugin` so Fastify treats `options.prefix` as a
245
+ * plain plugin option (NOT an encapsulation prefix). Without the wrapper,
246
+ * Fastify would prepend `options.prefix` to every route, then the plugin
247
+ * code would prepend it again — the duplicate-prefix bug.
248
+ */
180
249
  declare const streamlinePlugin: FastifyPluginAsync<StreamlinePluginOptions>;
181
250
  //#endregion
182
- export { STREAMLINE_BUS_EVENTS, STREAMLINE_TERMINAL_EVENTS, StreamlinePluginOptions, WorkflowLike, WorkflowRunLike, WorkflowStartOptions, streamlinePlugin };
251
+ export { STREAMLINE_BUS_EVENTS, STREAMLINE_FAILURE_CODES, STREAMLINE_TERMINAL_EVENTS, StreamlinePluginOptions, WorkflowLike, WorkflowRunLike, WorkflowStartOptions, streamlinePlugin };
@@ -1,6 +1,22 @@
1
1
  import { f as createError, i as NotFoundError, r as ForbiddenError } from "../errors-j4aJm1Wg.mjs";
2
+ import fp from "fastify-plugin";
2
3
  //#region src/integrations/streamline.ts
3
4
  /**
5
+ * Streamline >= 2.3.3 dead-letter discriminator. The run.status stays
6
+ * `'failed'`; the discrimination is `error.code`:
7
+ * - `'stale_heartbeat'` — sweeper terminated; transient crash signal.
8
+ * - `'dead_lettered'` — exceeded `maxStaleRecoveries`; permanent.
9
+ * - `'VERSION_MISMATCH'` — engine deployed a step graph the run can't
10
+ * resume against; admin must rewind / migrate / cancel.
11
+ *
12
+ * Hosts switch on `error.code` for dashboards / alerting.
13
+ */
14
+ const STREAMLINE_FAILURE_CODES = {
15
+ STALE_HEARTBEAT: "stale_heartbeat",
16
+ DEAD_LETTERED: "dead_lettered",
17
+ VERSION_MISMATCH: "VERSION_MISMATCH"
18
+ };
19
+ /**
4
20
  * Full event list published on a streamline workflow's internal `eventBus`
5
21
  * (tracks streamline 2.3's `EventPayloadMap` in
6
22
  * `@classytic/streamline/src/core/events.ts`).
@@ -44,6 +60,7 @@ const STREAMLINE_TERMINAL_EVENTS = [
44
60
  ];
45
61
  const streamlinePluginImpl = async (fastify, options) => {
46
62
  const { workflows, prefix = "/workflows", auth = true, bridgeEvents = true, enableStreaming = false, enableHookEndpoint = false, tenantResolver, bypassTenantResolver, permissions: perms } = options;
63
+ const routeScope = prefix;
47
64
  const bridgeBus = options.bridgeBusEvents ?? false;
48
65
  const registry = /* @__PURE__ */ new Map();
49
66
  for (const wf of workflows) {
@@ -70,7 +87,7 @@ const streamlinePluginImpl = async (fastify, options) => {
70
87
  return check(request);
71
88
  };
72
89
  for (const [id, wf] of registry) {
73
- const routePrefix = `${prefix}/${id}`;
90
+ const routePrefix = `${routeScope}/${id}`;
74
91
  fastify.post(`${routePrefix}/start`, { preHandler: authPreHandler }, async (request, reply) => {
75
92
  if (!await checkPerm("start", request)) throw new ForbiddenError();
76
93
  const { input, meta, idempotencyKey, priority } = request.body ?? {};
@@ -161,6 +178,24 @@ const streamlinePluginImpl = async (fastify, options) => {
161
178
  const { runId } = request.params;
162
179
  return await wf.engine.execute(runId);
163
180
  });
181
+ const deleteRepo = wf.container?.repository;
182
+ const repoDeleteFn = deleteRepo?.delete;
183
+ const repoGetByIdFn = deleteRepo?.getById;
184
+ if (repoDeleteFn && repoGetByIdFn) fastify.delete(`${routePrefix}/runs/:runId`, { preHandler: authPreHandler }, async (request, reply) => {
185
+ if (!await checkPerm("cancel", request)) throw new ForbiddenError();
186
+ const { runId } = request.params;
187
+ const tenantOpts = resolveTenantOpts(request);
188
+ const repoOpts = {
189
+ ...tenantOpts.tenantId !== void 0 ? { tenantId: tenantOpts.tenantId } : {},
190
+ ...tenantOpts.bypassTenant ? { bypassTenant: true } : {}
191
+ };
192
+ if (!await repoGetByIdFn(runId, repoOpts)) throw new NotFoundError(`Workflow run ${runId} not found`);
193
+ try {
194
+ await wf.cancel(runId);
195
+ } catch {}
196
+ await repoDeleteFn(runId, repoOpts);
197
+ return reply.status(204).send();
198
+ });
164
199
  if (wf.engine.waitFor) fastify.get(`${routePrefix}/runs/:runId/wait`, { preHandler: authPreHandler }, async (request, _reply) => {
165
200
  if (!await checkPerm("get", request)) throw new ForbiddenError();
166
201
  const { runId } = request.params;
@@ -239,7 +274,7 @@ const streamlinePluginImpl = async (fastify, options) => {
239
274
  }
240
275
  if (enableHookEndpoint) {
241
276
  let resumeHookFn;
242
- fastify.post(`${prefix}/hooks/:token`, { preHandler: authPreHandler }, async (request, _reply) => {
277
+ fastify.post(`${routeScope}/hooks/:token`, { preHandler: authPreHandler }, async (request, _reply) => {
243
278
  if (!resumeHookFn) resumeHookFn = (await import("@classytic/streamline")).resumeHook;
244
279
  const { token } = request.params;
245
280
  const result = await resumeHookFn(token, request.body);
@@ -249,7 +284,7 @@ const streamlinePluginImpl = async (fastify, options) => {
249
284
  };
250
285
  });
251
286
  }
252
- fastify.get(prefix, { preHandler: authPreHandler }, async () => {
287
+ fastify.get(routeScope || "/", { preHandler: authPreHandler }, async () => {
253
288
  return Array.from(registry.entries()).map(([id, wf]) => ({
254
289
  id,
255
290
  name: wf.definition.name ?? id,
@@ -257,10 +292,23 @@ const streamlinePluginImpl = async (fastify, options) => {
257
292
  }));
258
293
  });
259
294
  fastify.addHook("onClose", async () => {
260
- for (const wf of registry.values()) wf.shutdown?.();
295
+ for (const wf of registry.values()) {
296
+ wf.shutdown?.();
297
+ wf.container?.dispose?.();
298
+ }
261
299
  });
262
300
  };
263
- /** Pluggable streamline integration for Arc */
264
- const streamlinePlugin = streamlinePluginImpl;
301
+ /**
302
+ * Pluggable streamline integration for Arc.
303
+ *
304
+ * Wrapped in `fastify-plugin` so Fastify treats `options.prefix` as a
305
+ * plain plugin option (NOT an encapsulation prefix). Without the wrapper,
306
+ * Fastify would prepend `options.prefix` to every route, then the plugin
307
+ * code would prepend it again — the duplicate-prefix bug.
308
+ */
309
+ const streamlinePlugin = fp(streamlinePluginImpl, {
310
+ name: "streamline-routes",
311
+ fastify: "5.x"
312
+ });
265
313
  //#endregion
266
- export { STREAMLINE_BUS_EVENTS, STREAMLINE_TERMINAL_EVENTS, streamlinePlugin };
314
+ export { STREAMLINE_BUS_EVENTS, STREAMLINE_FAILURE_CODES, STREAMLINE_TERMINAL_EVENTS, streamlinePlugin };
@@ -1,6 +1,6 @@
1
1
  import { On as HookSystem, Q as MiddlewareConfig, Wt as AnyRecord, ft as RouteSchemaOptions, lt as RouteDefinition, tt as PresetHook, z as ResourceRegistry } from "../index-BswOSJCE.mjs";
2
2
  import { t as ExternalOpenApiPaths } from "../externalPaths-BD5nw6St.mjs";
3
- import { a as MetricsCollector, c as metricsPlugin, d as ssePlugin, f as CachingOptions, h as cachingPlugin, i as MetricEntry, l as SSEOptions, m as _default$1, n as _default$7, o as MetricsOptions, p as CachingRule, r as versioningPlugin, s as _default$4, t as VersioningOptions, u as _default$6 } from "../versioning-DTTvc80y.mjs";
3
+ import { _ as HealthOptions, a as MetricsCollector, c as metricsPlugin, d as ssePlugin, f as CachingOptions, g as HealthCheck, h as cachingPlugin, i as MetricEntry, l as SSEOptions, m as _default$1, n as _default$7, o as MetricsOptions, p as CachingRule, r as versioningPlugin, s as _default$4, t as VersioningOptions, u as _default$6, v as _default$3, y as healthPlugin } from "../versioning-hmkPcDlX.mjs";
4
4
  import { i as errorHandlerPlugin, n as ErrorMapper, r as defaultIsDuplicateKeyError, t as ErrorHandlerOptions } from "../errorHandler-DFr45ZG4.mjs";
5
5
  import { t as TracingOptions } from "../tracing-QJVprktp.mjs";
6
6
  import { PaginatedResult } from "@classytic/repo-core/pagination";
@@ -137,39 +137,6 @@ declare module "fastify" {
137
137
  }
138
138
  declare const _default$2: FastifyPluginAsync<GracefulShutdownOptions>;
139
139
  //#endregion
140
- //#region src/plugins/health.d.ts
141
- declare module "fastify" {
142
- interface FastifyRequest {
143
- _startTime?: number;
144
- }
145
- }
146
- interface HealthCheck {
147
- /** Name of the dependency */
148
- name: string;
149
- /** Function that returns true if healthy, false otherwise */
150
- check: () => Promise<boolean> | boolean;
151
- /** Optional timeout in ms (default: 5000) */
152
- timeout?: number;
153
- /** Whether this check is critical for readiness (default: true) */
154
- critical?: boolean;
155
- }
156
- interface HealthOptions {
157
- /** Route prefix (default: '/_health') */
158
- prefix?: string;
159
- /** Health check dependencies */
160
- checks?: HealthCheck[];
161
- /** Enable metrics endpoint (default: false) */
162
- metrics?: boolean;
163
- /** Custom metrics collector function */
164
- metricsCollector?: () => Promise<string> | string;
165
- /** Version info to include in responses */
166
- version?: string;
167
- /** Collect HTTP request metrics (default: true if metrics enabled) */
168
- collectHttpMetrics?: boolean;
169
- }
170
- declare const healthPlugin: FastifyPluginAsync<HealthOptions>;
171
- declare const _default$3: FastifyPluginAsync<HealthOptions>;
172
- //#endregion
173
140
  //#region src/plugins/replyHelpers.d.ts
174
141
  declare module "fastify" {
175
142
  interface FastifyReply {
@@ -58,7 +58,7 @@ try {
58
58
  function createTracerProvider(options) {
59
59
  if (!isAvailable || !NodeTracerProvider || !BatchSpanProcessor || !OTLPTraceExporter) return null;
60
60
  const { serviceName = "@classytic/arc", serviceVersion, exporterUrl = "http://localhost:4318/v1/traces" } = options;
61
- const resolvedVersion = serviceVersion ?? "2.15.0";
61
+ const resolvedVersion = serviceVersion ?? "2.15.4";
62
62
  const exporter = new OTLPTraceExporter({ url: exporterUrl });
63
63
  const provider = new NodeTracerProvider({ resource: { attributes: {
64
64
  "service.name": serviceName,
@@ -185,7 +185,8 @@ function multiTenantPreset(options = {}) {
185
185
  get: [getFilter("get")],
186
186
  create: [tenantInjection],
187
187
  update: [getFilter("update"), tenantInjection],
188
- delete: [getFilter("delete")]
188
+ delete: [getFilter("delete")],
189
+ aggregations: [strictTenantFilter]
189
190
  }
190
191
  };
191
192
  }
@@ -1,9 +1,9 @@
1
1
  import { p as isArcError } from "./errors-j4aJm1Wg.mjs";
2
- import { t as BaseController } from "./BaseController-Cn8KykUn.mjs";
2
+ import { t as BaseController } from "./BaseController-dx3m2J8V.mjs";
3
3
  import { L as normalizePermissionResult } from "./permissions-ohQyv50e.mjs";
4
4
  import { t as executePipeline } from "./pipe-Zr0KXjQe.mjs";
5
5
  import { u as resolvePipelineSteps } from "./routerShared-DrOa-26E.mjs";
6
- import { n as executeAggregation, r as validateAggregations } from "./buildHandler-jSZ6Fdvi.mjs";
6
+ import { n as executeAggregation, r as validateAggregations } from "./buildHandler-CcFOpJLh.mjs";
7
7
  import { t as resolveActionPermission } from "./actionPermissions-CyUkQu6O.mjs";
8
8
  import { i as shouldRejectAdditionalProperties, r as schemaIRToZodShape, t as normalizeSchemaIR } from "./schemaIR-lYhC2gE5.mjs";
9
9
  import { t as pluralize } from "./pluralize-DQgqgifU.mjs";
@@ -1,5 +1,5 @@
1
1
  import { V as ResourceDefinition, Wt as AnyRecord } from "../index-BswOSJCE.mjs";
2
- import { d as ResourceLike, r as CreateAppOptions } from "../types-3YTpuLZ1.mjs";
2
+ import { d as ResourceLike, r as CreateAppOptions } from "../types-DrBaUwyV.mjs";
3
3
  import { StorageContractSetup, StorageContractSetupResult, runStorageContract } from "./storageContract.mjs";
4
4
  import { FastifyInstance, FastifyServerOptions } from "fastify";
5
5
  import { Mock } from "vitest";
@@ -1070,7 +1070,7 @@ function pickDefaultAuth(authMode, callerAuth) {
1070
1070
  };
1071
1071
  }
1072
1072
  async function createTestApp(options = {}) {
1073
- const { createApp } = await import("../createApp-BarYhXCZ.mjs").then((n) => n.r);
1073
+ const { createApp } = await import("../createApp-PFegs47-.mjs").then((n) => n.r);
1074
1074
  const { resources = [], db = "in-memory", connectMongoose = false, authMode = "jwt", defaultOrgId, plugins, auth: callerAuth, ...appOptions } = options;
1075
1075
  let dbHandle;
1076
1076
  let dbUri;
@@ -5,7 +5,7 @@ import { a as EventTransport } from "./EventTransport-CT_52aWU.mjs";
5
5
  import { t as ExternalOpenApiPaths } from "./externalPaths-BD5nw6St.mjs";
6
6
  import { r as QueryCachePluginOptions } from "./queryCachePlugin-CqMdLI2-.mjs";
7
7
  import { t as EventPluginOptions } from "./eventPlugin-qXpqTebY.mjs";
8
- import { f as CachingOptions, l as SSEOptions, o as MetricsOptions, t as VersioningOptions } from "./versioning-DTTvc80y.mjs";
8
+ import { _ as HealthOptions, f as CachingOptions, l as SSEOptions, o as MetricsOptions, t as VersioningOptions } from "./versioning-hmkPcDlX.mjs";
9
9
  import { t as ErrorHandlerOptions } from "./errorHandler-DFr45ZG4.mjs";
10
10
  import { r as IdempotencyStore } from "./interface-DfLGcus7.mjs";
11
11
  import { FastifyInstance, FastifyPluginAsync, FastifyReply, FastifyRequest, FastifyServerOptions } from "fastify";
@@ -488,6 +488,16 @@ interface CreateAppOptions {
488
488
  trustProxy?: boolean;
489
489
  /** Fastify plugin/onReady timeout in ms (default: 10_000). Raise for slow boot work (index materialisation, WAL replay, external warm-up). */
490
490
  pluginTimeout?: number;
491
+ /**
492
+ * Maximum JSON body size in bytes. Pass-through to Fastify's
493
+ * server-level `bodyLimit` option; default is Fastify's 1 MiB
494
+ * (1_048_576 bytes). Raise for hosts shipping bulk-import / CSV ingest
495
+ * / JSON-RPC batch endpoints — without this, Fastify rejects oversized
496
+ * payloads with `FST_ERR_CTP_BODY_TOO_LARGE` (413) before any route
497
+ * handler runs. File uploads on `multipart` routes are governed
498
+ * separately by `multipart.limits.fileSize`. (2.15.1)
499
+ */
500
+ bodyLimit?: number;
491
501
  /**
492
502
  * Auth configuration
493
503
  *
@@ -578,8 +588,34 @@ interface CreateAppOptions {
578
588
  rawBody?: RawBodyOptions | false;
579
589
  /** Enable Arc plugins (requestId, health, gracefulShutdown, events, caching, sse) */
580
590
  arcPlugins?: {
581
- /** Request ID tracking (default: true) */requestId?: boolean; /** Health endpoints (default: true) */
582
- health?: boolean; /** Graceful shutdown handling (default: true) */
591
+ /** Request ID tracking (default: true) */requestId?: boolean;
592
+ /**
593
+ * Health endpoints (default: true).
594
+ *
595
+ * Three forms:
596
+ * - `true` (default) — register Arc's health plugin with no extra checks
597
+ * (`/_health/live` always 200, `/_health/ready` 200 unless explicit
598
+ * readiness probes are added later).
599
+ * - `false` — disable Arc's health plugin entirely; the host registers
600
+ * its own (or none).
601
+ * - `{ checks: HealthCheck[] }` — register Arc's health plugin AND
602
+ * attach the supplied readiness probes (Mongo connectivity, engine
603
+ * warmup, queue connectivity, etc.). Closes the pre-2.15.1 hole
604
+ * where adding checks meant `health: false` + manual re-registration.
605
+ *
606
+ * @example
607
+ * ```typescript
608
+ * arcPlugins: {
609
+ * health: {
610
+ * checks: [
611
+ * { name: 'mongo', check: async () => mongoose.connection.readyState === 1 },
612
+ * { name: 'catalog-engine', check: async () => catalog.isReady() },
613
+ * ],
614
+ * },
615
+ * }
616
+ * ```
617
+ */
618
+ health?: boolean | HealthOptions; /** Graceful shutdown handling (default: true) */
583
619
  gracefulShutdown?: boolean; /** Emit events for CRUD operations (default: true) */
584
620
  emitEvents?: boolean;
585
621
  /**
@@ -1,6 +1,39 @@
1
1
  import { n as DomainEvent } from "./EventTransport-CT_52aWU.mjs";
2
2
  import { FastifyPluginAsync, FastifyRequest } from "fastify";
3
3
 
4
+ //#region src/plugins/health.d.ts
5
+ declare module "fastify" {
6
+ interface FastifyRequest {
7
+ _startTime?: number;
8
+ }
9
+ }
10
+ interface HealthCheck {
11
+ /** Name of the dependency */
12
+ name: string;
13
+ /** Function that returns true if healthy, false otherwise */
14
+ check: () => Promise<boolean> | boolean;
15
+ /** Optional timeout in ms (default: 5000) */
16
+ timeout?: number;
17
+ /** Whether this check is critical for readiness (default: true) */
18
+ critical?: boolean;
19
+ }
20
+ interface HealthOptions {
21
+ /** Route prefix (default: '/_health') */
22
+ prefix?: string;
23
+ /** Health check dependencies */
24
+ checks?: HealthCheck[];
25
+ /** Enable metrics endpoint (default: false) */
26
+ metrics?: boolean;
27
+ /** Custom metrics collector function */
28
+ metricsCollector?: () => Promise<string> | string;
29
+ /** Version info to include in responses */
30
+ version?: string;
31
+ /** Collect HTTP request metrics (default: true if metrics enabled) */
32
+ collectHttpMetrics?: boolean;
33
+ }
34
+ declare const healthPlugin: FastifyPluginAsync<HealthOptions>;
35
+ declare const _default$4: FastifyPluginAsync<HealthOptions>;
36
+ //#endregion
4
37
  //#region src/plugins/caching.d.ts
5
38
  interface CachingRule {
6
39
  /** Path prefix to match (e.g., '/api/products') */
@@ -114,4 +147,4 @@ declare module "fastify" {
114
147
  declare const versioningPlugin: FastifyPluginAsync<VersioningOptions>;
115
148
  declare const _default: FastifyPluginAsync<VersioningOptions>;
116
149
  //#endregion
117
- export { MetricsCollector as a, metricsPlugin as c, ssePlugin as d, CachingOptions as f, cachingPlugin as h, MetricEntry as i, SSEOptions as l, _default$3 as m, _default as n, MetricsOptions as o, CachingRule as p, versioningPlugin as r, _default$1 as s, VersioningOptions as t, _default$2 as u };
150
+ export { HealthOptions as _, MetricsCollector as a, metricsPlugin as c, ssePlugin as d, CachingOptions as f, HealthCheck as g, cachingPlugin as h, MetricEntry as i, SSEOptions as l, _default$3 as m, _default as n, MetricsOptions as o, CachingRule as p, versioningPlugin as r, _default$1 as s, VersioningOptions as t, _default$2 as u, _default$4 as v, healthPlugin as y };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classytic/arc",
3
- "version": "2.15.0",
3
+ "version": "2.15.4",
4
4
  "description": "Resource-oriented backend framework for Fastify - clean, minimal, powerful, tree-shakable",
5
5
  "type": "module",
6
6
  "exports": {
@@ -245,9 +245,9 @@
245
245
  "node": ">=22"
246
246
  },
247
247
  "peerDependencies": {
248
- "@classytic/primitives": ">=0.4.0",
249
- "@classytic/repo-core": ">=0.4.0",
250
- "@classytic/streamline": ">=2.3.0",
248
+ "@classytic/primitives": ">=0.5.0",
249
+ "@classytic/repo-core": ">=0.4.1",
250
+ "@classytic/streamline": ">=2.3.3",
251
251
  "@fastify/cors": ">=11.0.0",
252
252
  "@fastify/helmet": ">=13.0.0",
253
253
  "@fastify/jwt": ">=10.0.0",
@@ -357,11 +357,11 @@
357
357
  "@better-auth/mongo-adapter": "^1.6.9",
358
358
  "@biomejs/biome": "^2.4.11",
359
359
  "@classytic/dev-tools": "^0.2.0",
360
- "@classytic/mongokit": "^3.13.0",
360
+ "@classytic/mongokit": "^3.13.2",
361
361
  "@classytic/primitives": "^0.4.0",
362
- "@classytic/repo-core": "^0.4.0",
363
- "@classytic/sqlitekit": "^0.3.0",
364
- "@classytic/streamline": "^2.3.0",
362
+ "@classytic/repo-core": "^0.4.1",
363
+ "@classytic/sqlitekit": "^0.3.1",
364
+ "@classytic/streamline": "^2.3.3",
365
365
  "@fastify/cors": "^11.2.0",
366
366
  "@fastify/helmet": "^13.0.2",
367
367
  "@fastify/jwt": "^10.0.0",
@@ -420,4 +420,4 @@
420
420
  "type": "git",
421
421
  "url": "https://github.com/classytic/arc.git"
422
422
  }
423
- }
423
+ }
@@ -27,7 +27,7 @@ progressive_disclosure:
27
27
  entry_point:
28
28
  summary: "Resource-oriented Fastify framework: defineResource(), presets, permissions, QueryCache, events, multi-tenant, OpenAPI, MCP"
29
29
  when_to_use: "Building REST APIs with Fastify, resource CRUD, authentication, presets, caching, events, or production deployment"
30
- quick_start: "1. arc init my-api --mongokit --jwt --ts 2. defineResource({ name, adapter, presets, permissions }) 3. createApp({ preset: 'production', resources, auth })"
30
+ quick_start: "1. npx @classytic/arc init my-api --mongokit --better-auth --single --ts 2. defineResource({ name, adapter, presets, permissions }) 3. createApp({ preset: 'production', resources, auth })"
31
31
  ---
32
32
 
33
33
  # @classytic/arc
@@ -39,11 +39,11 @@ One `defineResource()` call → REST + auth + permissions + events + cache + Ope
39
39
  ## Scaffold a project
40
40
 
41
41
  ```bash
42
- npx @classytic/arc@latest init my-api --mongokit --jwt --ts
42
+ npx @classytic/arc@latest init my-api --mongokit --better-auth --single --ts
43
43
  cd my-api && npm install && npm run dev
44
44
  ```
45
45
 
46
- Flags: `--mongokit | --custom`, `--jwt | --better-auth`, `--single | --multi`, `--ts | --js`. The scaffold seeds full `dependencies` + `devDependencies` so `npm install` works without the CLI's pre-pass.
46
+ Flags: `--mongokit | --custom`, `--better-auth | --jwt`, `--single | --multi`, `--ts | --js`, `--edge`, `--force`, `--skip-install`. Defaults: `--mongokit --better-auth --single --ts`. The scaffold seeds full `dependencies` + `devDependencies` so `npm install` works without the CLI's pre-pass.
47
47
 
48
48
  ## createApp()
49
49
 
@@ -639,6 +639,96 @@ defineResource({
639
639
 
640
640
  MCP auto-derives `filterableFields` from `queryParser`.
641
641
 
642
+ ## Aggregations — dashboards in declarative form
643
+
644
+ Add `aggregations: { … }` to a resource and Arc registers `GET
645
+ /{prefix}/aggregations/:name` per entry. Each runs a portable `$match
646
+ → $group → $project → $sort → $limit` pipeline against the kit's
647
+ `repo.aggregate(req, options)` — same shape across mongokit /
648
+ sqlitekit / prismakit, so dashboards work unchanged across backends.
649
+
650
+ ```typescript
651
+ import { defineResource, defineAggregation } from '@classytic/arc';
652
+
653
+ defineResource({
654
+ name: 'transaction',
655
+ adapter,
656
+ presets: [multiTenantPreset({ tenantField: 'organizationId' })],
657
+ permissions: { list: canViewRevenue() },
658
+
659
+ aggregations: {
660
+ byPaymentMethod: defineAggregation({
661
+ groupBy: 'method',
662
+ measures: { total: 'sum:amount', count: 'count' },
663
+ sort: { total: -1 },
664
+ cache: { staleTime: 60, swr: true, tags: ['revenue'] },
665
+ permissions: canViewRevenue(),
666
+ }),
667
+ byFlow: defineAggregation({
668
+ groupBy: 'flow',
669
+ measures: { total: 'sum:amount', count: 'count' },
670
+ cache: { staleTime: 60, swr: true, tags: ['revenue'] },
671
+ permissions: canViewRevenue(),
672
+ }),
673
+ byDay: defineAggregation({
674
+ dateBuckets: { day: { field: 'createdAt', interval: 'day' } },
675
+ groupBy: 'flow',
676
+ measures: { total: 'sum:amount', count: 'count' },
677
+ sort: { day: 1 },
678
+ requireDateRange: { field: 'createdAt', maxRangeDays: 365 },
679
+ cache: { staleTime: 60, swr: true, tags: ['revenue'] },
680
+ permissions: canViewRevenue(),
681
+ }),
682
+ },
683
+ });
684
+ ```
685
+
686
+ **Tenant scope flows through the second arg, NOT the filter.** Arc is
687
+ DB-agnostic — type-coercion (string → ObjectId for mongokit
688
+ `fieldType: 'objectId'`, UUID/text for sqlitekit, etc.) belongs to the
689
+ kit. Arc threads `tenantOptions` to `repo.aggregate(req, options)`;
690
+ the kit's multi-tenant plugin reads `context.organizationId`, casts
691
+ correctly, and merges into the request. Authors never inject the
692
+ tenant key into `aggReq.filter` themselves.
693
+
694
+ **2.15.3 — `multiTenantPreset` now wires `/aggregations/:name`.** Pre-2.15.3
695
+ the preset only scoped CRUD; aggregation routes leaked across orgs for any
696
+ caller whose `scope.kind !== 'member'`. Adding `multiTenantPreset({ tenantField: 'organizationId' })`
697
+ now emits an `aggregations` middleware slot alongside the five CRUD slots, so
698
+ member callers see only their org and `kind: 'elevated'` callers WITHOUT a
699
+ target org get `bypassTenant: true` (platform-admin cross-tenant dashboards).
700
+ **Kit config note:** set `scope: true` (or `scope: { fieldType: 'objectId' }`)
701
+ on revenue/order/etc. engines — the pre-2.15.2 advice to use `scope: false`
702
+ "to avoid double-scoping with arc" is no longer correct; arc 2.15.2+
703
+ deliberately leaves `aggReq.filter` clean and relies on the kit. Required
704
+ peers: `@classytic/repo-core ≥ 0.4.1`, `@classytic/mongokit ≥ 3.13.2`,
705
+ `@classytic/sqlitekit ≥ 0.3.1`.
706
+
707
+ **Caller filters via query string compose with `groupBy` / measures:**
708
+
709
+ ```
710
+ GET /api/transactions/aggregations/byPaymentMethod?status=verified
711
+ GET /api/transactions/aggregations/byDay?createdAt[gte]=2026-01-01&createdAt[lt]=2026-02-01
712
+ ```
713
+
714
+ **Safety guards on the declaration:**
715
+ - `requireDateRange: { field, maxRangeDays }` — bounded range mandatory; kills accidental all-time scans
716
+ - `requireFilters: ['orgId']` — mandatory scope keys
717
+ - `maxGroups: 1000` — post-execution row cap; 422 on overflow
718
+
719
+ **Cache invalidation:** writes through resource CRUD bump the
720
+ matching tag. Aggregations cached with the same `tags` invalidate
721
+ together. SWR mode serves stale immediately while revalidating in
722
+ background.
723
+
724
+ **MCP auto-export:** every aggregation surfaces as an MCP tool
725
+ named `{resource}_aggregations_{name}` with the same permission gate
726
+ and filter validation as the HTTP route.
727
+
728
+ For backends without `repo.aggregate` (custom adapters), declare a
729
+ `materialized` hook on the aggregation — Arc routes through it
730
+ instead of the kit and returns the same `{ rows }` envelope.
731
+
642
732
  ## QueryCache
643
733
 
644
734
  TanStack Query-style server cache, stale-while-revalidate, auto-invalidation on mutations.
@@ -756,14 +846,17 @@ Full testing recipes → [references/testing.md](references/testing.md).
756
846
 
757
847
  ## CLI
758
848
 
849
+ The bin is `arc` (registered by `@classytic/arc`). Outside an arc project use `npx @classytic/arc <cmd>`; inside one (devDep installed) bare `arc` resolves through `node_modules/.bin`.
850
+
759
851
  ```bash
760
- arc init my-api --mongokit --jwt --ts # scaffold (also: --custom, --better-auth, --multi)
761
- arc generate resource product # generate a resource
762
- arc generate resource product --mcp # + MCP tools file
763
- arc generate mcp analytics # standalone MCP tools file
764
- arc docs ./openapi.json --entry ./dist/index.js # emit OpenAPI
765
- arc introspect --entry ./dist/index.js
766
- arc doctor
852
+ npx @classytic/arc init my-api --mongokit --better-auth --single --ts # scaffold (also: --custom, --jwt, --multi, --js, --edge)
853
+ npx @classytic/arc generate resource product # generate a resource (alias: arc g r product)
854
+ npx @classytic/arc generate resource product --mcp # + MCP tools file
855
+ npx @classytic/arc generate mcp analytics # standalone MCP tools file
856
+ npx @classytic/arc docs ./openapi.json --entry ./dist/index.js # emit OpenAPI
857
+ npx @classytic/arc introspect --entry ./dist/index.js
858
+ npx @classytic/arc describe ./dist/resources.js --json # JSON metadata for AI agents
859
+ npx @classytic/arc doctor
767
860
  ```
768
861
 
769
862
  Set `"mcp": true` in `.arcrc` to always generate `.mcp.ts` alongside resources.
@@ -894,6 +894,49 @@ Then check whether the hook body sets headers (`reply.header(`, `reply.headers[`
894
894
 
895
895
  ---
896
896
 
897
+ ## §33. Hand-rolled aggregation routes when `aggregations: { … }` would do 🟠
898
+
899
+ **Detection:**
900
+ ```
901
+ pattern: "Model\\.aggregate\\(\\["
902
+ output_mode: content
903
+ ```
904
+ Also: `\\.aggregate\\(\\s*\\[`, `\\$group\\b`, `\\$match\\b` inside resource handler files. Plus inline mongoose pipeline imports inside route handlers (vs. inside the kit's repository).
905
+
906
+ **Anti-pattern:**
907
+ ```typescript
908
+ // In a custom GET /aggregate/byPaymentMethod route:
909
+ const orgId = getOrgId(getScope(req));
910
+ const rows = await Transaction.aggregate([
911
+ { $match: { organizationId: new mongoose.Types.ObjectId(orgId), status: 'verified' } },
912
+ { $group: { _id: '$method', total: { $sum: '$amount' }, count: { $sum: 1 } } },
913
+ { $sort: { total: -1 } },
914
+ ]);
915
+ return reply.send({ rows });
916
+ ```
917
+
918
+ **Why high:** loses every cross-cutting feature arc's aggregation router gives for free — per-aggregation permissions, OpenAPI schema, MCP tool export, SWR cache + tag invalidation, `requireFilters` / `requireDateRange` safety guards, `maxGroups` cap, multi-tenant scope without manual `ObjectId` casting. The hand-rolled version also drifts: every new bucket needs a new route, no central declaration, no unified shape across kits.
919
+
920
+ **Fix:** declare `aggregations: { name: defineAggregation({ groupBy, measures, sort, cache, permissions }) }` on the resource. Arc registers `GET /:prefix/aggregations/:name` per entry, threads `tenantOptions` to `repo.aggregate(req, options)`, and the kit's multi-tenant plugin handles type-coercion. Type-coercion (string → ObjectId / UUID / text) is **the kit's responsibility, not the framework's** — never inject the tenant key into `aggReq.filter` from arc-host code.
921
+
922
+ ```typescript
923
+ import { defineAggregation } from '@classytic/arc';
924
+
925
+ aggregations: {
926
+ byPaymentMethod: defineAggregation({
927
+ groupBy: 'method',
928
+ measures: { total: 'sum:amount', count: 'count' },
929
+ sort: { total: -1 },
930
+ cache: { staleTime: 60, swr: true, tags: ['revenue'] },
931
+ permissions: canViewRevenue(),
932
+ }),
933
+ }
934
+ ```
935
+
936
+ For backends without `repo.aggregate`, declare a `materialized` hook on the aggregation — the router calls it instead of the kit and keeps the same `{ rows }` envelope, permission gate, and cache contract.
937
+
938
+ ---
939
+
897
940
  ## Detection checklist (run-order)
898
941
 
899
942
  Run sweeps in this order — early hits often invalidate later context:
@@ -909,3 +952,4 @@ Run sweeps in this order — early hits often invalidate later context:
909
952
  9. §14, §18, §19 — preset adoption + custom controller wiring
910
953
  10. §11, §12, §13, §29, §30, §31 — style and edges
911
954
  11. §32a–§32g — canonical-contract drift (pagination / events / tenant / errors / adapter imports), missing `requireOrgId` accessors, missing `schemaGenerator`, kit-specific adapter imports from arc (3.0 break)
955
+ 12. §33 — hand-rolled `Model.aggregate([...])` in resource routes vs declarative `aggregations: { … }`
@@ -246,6 +246,33 @@ Modes: `memory` (default) | `distributed` (`stores.queryCache: RedisCacheStore`)
246
246
 
247
247
  ---
248
248
 
249
+ ## Aggregations (declarative dashboards)
250
+
251
+ ```typescript
252
+ import { defineAggregation } from '@classytic/arc';
253
+
254
+ aggregations: {
255
+ byMethod: defineAggregation({
256
+ groupBy: 'method',
257
+ measures: { total: 'sum:amount', count: 'count' },
258
+ sort: { total: -1 },
259
+ cache: { staleTime: 60, swr: true, tags: ['revenue'] },
260
+ permissions: canViewRevenue(),
261
+ }),
262
+ byDay: defineAggregation({
263
+ dateBuckets: { day: { field: 'createdAt', interval: 'day' } },
264
+ groupBy: 'flow',
265
+ measures: { total: 'sum:amount' },
266
+ requireDateRange: { field: 'createdAt', maxRangeDays: 365 },
267
+ permissions: canViewRevenue(),
268
+ }),
269
+ }
270
+ ```
271
+
272
+ Registers `GET /:prefix/aggregations/:name` per entry. Same permissions, OpenAPI, MCP tool, cache + tag invalidation as CRUD. Tenant flows via the kit's multi-tenant plugin (string → ObjectId casting handled by the kit, **not** by hand). Caller filters via query string (`?status=verified&createdAt[gte]=...`) compose with the declaration. Safety: `requireFilters`, `requireDateRange`, `maxGroups`. **Anti-pattern:** custom routes calling `Model.aggregate([...])` directly — see anti-patterns §33.
273
+
274
+ ---
275
+
249
276
  ## CLI
250
277
 
251
278
  ```bash