@classytic/arc 2.20.0 → 2.22.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.
Files changed (143) hide show
  1. package/dist/{BaseController-D-MHZ27_.mjs → BaseController-C6FlRWxF.mjs} +2 -2
  2. package/dist/{EventTransport-IEn7Tn-Q.d.mts → EventTransport-QmY7A258.d.mts} +0 -1
  3. package/dist/{QueryCache-TRfXau3W.d.mts → QueryCache-Z1B38koh.d.mts} +0 -1
  4. package/dist/{actionPermissions-C9RMiIpc.mjs → actionPermissions-ze70xXJi.mjs} +1 -1
  5. package/dist/audit/index.d.mts +9 -5
  6. package/dist/auth/audit.d.mts +7 -4
  7. package/dist/auth/audit.mjs +5 -0
  8. package/dist/auth/index.d.mts +35 -14
  9. package/dist/auth/index.mjs +6 -3
  10. package/dist/auth/redis-session.d.mts +1 -2
  11. package/dist/cache/index.d.mts +2 -3
  12. package/dist/cli/commands/describe.d.mts +1 -2
  13. package/dist/cli/commands/docs.mjs +1 -1
  14. package/dist/cli/commands/init.mjs +1 -1
  15. package/dist/cli/index.mjs +1 -1
  16. package/dist/context/index.d.mts +2 -2
  17. package/dist/core/index.d.mts +3 -3
  18. package/dist/core/index.mjs +4 -4
  19. package/dist/{core-CWhyGkiM.mjs → core-b6fgFgnd.mjs} +129 -17
  20. package/dist/{createActionRouter-HlQso0jO.mjs → createActionRouter-h1cEEaEX.mjs} +10 -6
  21. package/dist/{createAggregationRouter-5LzPzgw1.mjs → createAggregationRouter-B1C0kvTL.mjs} +3 -3
  22. package/dist/{createApp-D6uwzCKu.mjs → createApp-Ci7yG5gv.mjs} +390 -192
  23. package/dist/discovery/index.d.mts +0 -1
  24. package/dist/docs/index.d.mts +1 -2
  25. package/dist/docs/index.mjs +1 -1
  26. package/dist/{elevation-xGUBq6p_.d.mts → elevation-C9s5hFP_.d.mts} +0 -1
  27. package/dist/encryption/index.d.mts +2 -144
  28. package/dist/encryption/index.mjs +9 -5
  29. package/dist/{errorHandler-Dxf6P_kV.mjs → errorHandler-BTzXSwIg.mjs} +24 -3
  30. package/dist/{errorHandler-BtUUDPEa.d.mts → errorHandler-C6yv4ZTW.d.mts} +13 -1
  31. package/dist/{errors-DOHkjQlD.d.mts → errors-DHcHVAgk.d.mts} +0 -1
  32. package/dist/{eventPlugin-DbBFd3x_.d.mts → eventPlugin-A6EbthXR.d.mts} +10 -6
  33. package/dist/events/index.d.mts +9 -6
  34. package/dist/events/transports/redis-stream-entry.d.mts +1 -1
  35. package/dist/events/transports/redis.d.mts +1 -2
  36. package/dist/factory/index.d.mts +30 -9
  37. package/dist/factory/index.mjs +35 -4
  38. package/dist/{fastify-augmentation-CCQJEDgh.d.mts → fastify-augmentation-9_fuetB4.d.mts} +2 -3
  39. package/dist/fields-D3lhgzhD.d.mts +121 -0
  40. package/dist/health-ScyjIVo5.mjs +202 -0
  41. package/dist/hooks/index.d.mts +1 -1
  42. package/dist/idempotency/index.d.mts +4 -3
  43. package/dist/idempotency/index.mjs +2 -0
  44. package/dist/idempotency/redis.d.mts +1 -1
  45. package/dist/{types-BHMKlMBU.d.mts → index-BEu4wjbL.d.mts} +241 -80
  46. package/dist/{index-DUa8WHz3.d.mts → index-D8MKYGDa.d.mts} +88 -7
  47. package/dist/{index-_--GyzcO.d.mts → index-DbtgCOCj.d.mts} +1324 -1228
  48. package/dist/{index-CdFkIMEd.d.mts → index-ogIHvOjo.d.mts} +36 -7
  49. package/dist/index.d.mts +8 -7
  50. package/dist/index.mjs +6 -6
  51. package/dist/{init-YH_nslQR.mjs → init-DleK3p9f.mjs} +272 -139
  52. package/dist/integrations/event-gateway.d.mts +2 -3
  53. package/dist/integrations/index.d.mts +2 -2
  54. package/dist/integrations/jobs.d.mts +0 -1
  55. package/dist/integrations/mcp/index.d.mts +6 -7
  56. package/dist/integrations/mcp/index.mjs +1 -1
  57. package/dist/integrations/mcp/testing.d.mts +1 -2
  58. package/dist/integrations/mcp/testing.mjs +1 -1
  59. package/dist/integrations/streamline.d.mts +2 -2
  60. package/dist/integrations/webhooks.d.mts +8 -5
  61. package/dist/integrations/webhooks.mjs +9 -5
  62. package/dist/integrations/websocket-pushref-redis.d.mts +1 -2
  63. package/dist/integrations/websocket-redis.d.mts +1 -2
  64. package/dist/integrations/websocket.d.mts +2 -2
  65. package/dist/jsonBody-BlzAoGCe.mjs +43 -0
  66. package/dist/{loadResources-B8UmpITt.mjs → loadResources-DpU8pfjV.mjs} +4 -3
  67. package/dist/logger/index.d.mts +26 -1
  68. package/dist/logger/index.mjs +39 -1
  69. package/dist/{mcpPlugin-CuW6L_uD.mjs → mcpPlugin-ByA2tf6D.mjs} +8 -4
  70. package/dist/middleware/index.d.mts +1 -2
  71. package/dist/{module-JYH9V0I8.d.mts → module-B4u10iOJ.d.mts} +71 -1
  72. package/dist/{openapi-Dq6RbEGK.mjs → openapi-BRpMZD7C.mjs} +3 -3
  73. package/dist/permissions/index.d.mts +4 -3
  74. package/dist/permissions/index.mjs +2 -2
  75. package/dist/{permissions-CgbOLJZG.mjs → permissions-BJMuKMl7.mjs} +33 -1
  76. package/dist/pipeline/index.d.mts +1 -2
  77. package/dist/plugins/index.d.mts +96 -7
  78. package/dist/plugins/index.mjs +86 -268
  79. package/dist/plugins/response-cache.d.mts +5 -3
  80. package/dist/plugins/response-cache.mjs +1 -0
  81. package/dist/plugins/tracing-entry.d.mts +1 -1
  82. package/dist/plugins/tracing-entry.mjs +7 -4
  83. package/dist/presets/filesUpload.d.mts +4 -5
  84. package/dist/presets/filesUpload.mjs +1 -1
  85. package/dist/presets/index.d.mts +1 -2
  86. package/dist/presets/index.mjs +1 -1
  87. package/dist/presets/multiTenant.d.mts +1 -2
  88. package/dist/presets/search.d.mts +2 -3
  89. package/dist/presets/search.mjs +1 -1
  90. package/dist/{presets-DWsYQdbV.mjs → presets-gw0ciIUD.mjs} +1 -1
  91. package/dist/{pushref-registry-DnCqL3aa.d.mts → pushref-registry-BbcnpNTm.d.mts} +4 -2
  92. package/dist/{queryCachePlugin-xzFyKAjT.d.mts → queryCachePlugin-CfYWq-Ak.d.mts} +1 -2
  93. package/dist/rateLimitKey-BoWRH3e7.mjs +14 -0
  94. package/dist/{redis-C3W0qbz8.d.mts → redis-UDtLaNj6.d.mts} +0 -1
  95. package/dist/{redis-stream-DsP1vqZB.d.mts → redis-stream-iVR2l7aS.d.mts} +1 -2
  96. package/dist/{registerRoute-DGSREwPw.mjs → registerRoute-xHc42fwB.mjs} +38 -18
  97. package/dist/registry/index.d.mts +2 -2
  98. package/dist/requestId-5BhTVzBv.mjs +113 -0
  99. package/dist/schemaConverter-Ds5reSGs.mjs +168 -0
  100. package/dist/{schemaIR-ajetw3Fv.mjs → schemaIR-B-6SPe7g.mjs} +2 -2
  101. package/dist/schemas/index.d.mts +0 -1
  102. package/dist/scim/index.d.mts +0 -1
  103. package/dist/scim/index.mjs +6 -7
  104. package/dist/scope/index.d.mts +2 -3
  105. package/dist/scope/index.mjs +2 -76
  106. package/dist/scope-k15RXfcq.mjs +64 -0
  107. package/dist/{sessionManager-BXG-HGwG.d.mts → sessionManager-BHOM3N_0.d.mts} +10 -6
  108. package/dist/{storage-Co_PQpeQ.d.mts → storage-DLUZ04kK.d.mts} +8 -4
  109. package/dist/testing/index.d.mts +83 -4
  110. package/dist/testing/index.mjs +74 -4
  111. package/dist/testing/storageContract.d.mts +1 -2
  112. package/dist/{tracing-D8jlGXP7.d.mts → tracing-CsGKQJGg.d.mts} +0 -1
  113. package/dist/types/index.d.mts +4 -4
  114. package/dist/types/storage.d.mts +1 -1
  115. package/dist/{types-C9b2HR5W.d.mts → types-Bc42CUyk.d.mts} +1 -2
  116. package/dist/types-Cc91nXuA.d.mts +132 -0
  117. package/dist/{fields-C7ahdGYf.d.mts → types-Csxga7d3.d.mts} +2 -123
  118. package/dist/{types-LNebCoJx.d.mts → types-DH0GupI7.d.mts} +8 -4
  119. package/dist/usage/index.d.mts +142 -0
  120. package/dist/usage/index.mjs +0 -0
  121. package/dist/utils/index.d.mts +220 -144
  122. package/dist/utils/index.mjs +3 -3
  123. package/dist/{utils-ChjYQpjn.mjs → utils-FhJ88TJE.mjs} +129 -39
  124. package/dist/{versioning-D_eBCfNr.d.mts → versioning-DLwklPlG.d.mts} +1 -7
  125. package/dist/{websocket-MlYlrYq0.d.mts → websocket-uuWqM6UY.d.mts} +5 -4
  126. package/llms.txt +39 -0
  127. package/package.json +453 -447
  128. package/skills/arc/SKILL.md +14 -8
  129. package/skills/arc/references/api-reference.md +34 -0
  130. package/skills/arc/references/auth.md +21 -4
  131. package/skills/arc/references/mcp.md +1 -1
  132. package/skills/arc/references/production.md +44 -2
  133. package/skills/arc-code-review/SKILL.md +1 -1
  134. package/skills/arc-code-review/references/anti-patterns.md +71 -18
  135. package/skills/arc-code-review/references/arc-cheatsheet.md +15 -2
  136. package/skills/arc-code-review/references/migration-recipes.md +7 -7
  137. package/skills/arc-code-review/references/mongokit-migration.md +4 -3
  138. package/skills/arc-module-publishing/SKILL.md +243 -0
  139. package/skills/arc-module-publishing/references/authoring-modules.md +327 -0
  140. package/skills/arc-module-publishing/references/package-manifest.md +157 -0
  141. package/skills/arc-module-publishing/references/publishing-workflow.md +182 -0
  142. package/dist/preloadResources-CHw3_oA_.mjs +0 -45
  143. package/dist/schemaConverter-U0MnO9vd.mjs +0 -110
@@ -1,14 +1,16 @@
1
- import { a as QueryCacheConfig } from "./QueryCache-TRfXau3W.mjs";
2
- import { i as RequestScope } from "./types-LNebCoJx.mjs";
3
- import { c as PermissionCheck, d as UserBase, n as FieldPermissionMap } from "./fields-C7ahdGYf.mjs";
4
- import { n as DomainEvent } from "./EventTransport-IEn7Tn-Q.mjs";
1
+ import { a as QueryCacheConfig } from "./QueryCache-Z1B38koh.mjs";
2
+ import { i as RequestScope } from "./types-DH0GupI7.mjs";
3
+ import { i as UserBase, t as PermissionCheck } from "./types-Csxga7d3.mjs";
4
+ import "./elevation-C9s5hFP_.mjs";
5
+ import { n as FieldPermissionMap } from "./fields-D3lhgzhD.mjs";
6
+ import { n as EncryptionDirective } from "./types-Cc91nXuA.mjs";
7
+ import { n as DomainEvent } from "./EventTransport-QmY7A258.mjs";
5
8
  import { KeysetPaginationResult, OffsetPaginationResult } from "@classytic/repo-core/pagination";
6
9
  import { FastifyInstance, FastifyPluginAsync, FastifyReply, FastifyRequest, RouteHandlerMethod, RouteHandlerMethod as RouteHandlerMethod$1 } from "fastify";
7
10
  import { DataAdapter, RepositoryLike } from "@classytic/repo-core/adapter";
8
11
  import { AggDateBucket, AggMeasure, AggTopN, QueryOptions, StandardRepo, TenantPurgeStrategy } from "@classytic/repo-core/repository";
9
12
  import { LookupSpec } from "@classytic/repo-core/lookup";
10
13
  import { FieldRule, SchemaBuilderOptions } from "@classytic/repo-core/schema";
11
-
12
14
  //#region src/core/BodySanitizer.d.ts
13
15
  /**
14
16
  * Policy for handling fields the caller lacks write permission for.
@@ -1082,7 +1084,8 @@ type ObjectId = string | {
1082
1084
  * The actual user structure is defined by your app's auth system.
1083
1085
  */
1084
1086
  type UserLike = UserBase & {
1085
- /** User email (optional) */email?: string;
1087
+ /** User email (optional) */
1088
+ email?: string;
1086
1089
  };
1087
1090
  interface UserOrganization {
1088
1091
  userId: string;
@@ -1229,11 +1232,17 @@ interface AuthPluginOptions {
1229
1232
  * utilities are available in the authenticator context.
1230
1233
  */
1231
1234
  jwt?: {
1232
- /** JWT secret (required for JWT features) */secret: string; /** Access token expiry (default: '15m') */
1233
- expiresIn?: string; /** Refresh token secret (defaults to main secret) */
1234
- refreshSecret?: string; /** Refresh token expiry (default: '7d') */
1235
- refreshExpiresIn?: string; /** Additional `@fastify/jwt` sign options */
1236
- sign?: Record<string, unknown>; /** Additional `@fastify/jwt` verify options */
1235
+ /** JWT secret (required for JWT features) */
1236
+ secret: string;
1237
+ /** Access token expiry (default: '15m') */
1238
+ expiresIn?: string;
1239
+ /** Refresh token secret (defaults to main secret) */
1240
+ refreshSecret?: string;
1241
+ /** Refresh token expiry (default: '7d') */
1242
+ refreshExpiresIn?: string;
1243
+ /** Additional `@fastify/jwt` sign options */
1244
+ sign?: Record<string, unknown>;
1245
+ /** Additional `@fastify/jwt` verify options */
1237
1246
  verify?: Record<string, unknown>;
1238
1247
  };
1239
1248
  /**
@@ -1297,733 +1306,586 @@ interface AuthPluginOptions {
1297
1306
  strictTokenType?: boolean;
1298
1307
  }
1299
1308
  //#endregion
1300
- //#region src/pipeline/types.d.ts
1301
- /**
1302
- * Pipeline context passed to guards, transforms, and interceptors.
1303
- * Extends IRequestContext with pipeline-specific metadata.
1304
- */
1305
- interface PipelineContext extends IRequestContext {
1306
- /** Resource name being accessed */
1307
- resource: string;
1308
- /** CRUD operation being performed */
1309
- operation: "list" | "get" | "create" | "update" | "delete" | string;
1310
- }
1311
- /**
1312
- * Which operations a pipeline step applies to.
1313
- * If omitted, applies to ALL operations.
1314
- */
1315
- type OperationFilter = Array<"list" | "get" | "create" | "update" | "delete" | string>;
1316
- /**
1317
- * Guard — boolean check that short-circuits on failure.
1318
- * Return true to proceed, throw to deny.
1319
- */
1320
- interface Guard {
1321
- readonly _type: "guard";
1322
- readonly name: string;
1323
- readonly operations?: OperationFilter;
1324
- handler(ctx: PipelineContext): boolean | Promise<boolean>;
1325
- }
1326
- /**
1327
- * Transform — modifies request data before the handler.
1328
- * Returns modified context (or mutates in place).
1329
- */
1330
- interface Transform {
1331
- readonly _type: "transform";
1332
- readonly name: string;
1333
- readonly operations?: OperationFilter;
1334
- handler(ctx: PipelineContext): PipelineContext | undefined | Promise<PipelineContext | undefined>;
1335
- }
1336
- /**
1337
- * Next function passed to interceptors — calls the handler (or next interceptor).
1338
- */
1339
- type NextFunction = () => Promise<IControllerResponse<unknown>>;
1309
+ //#region src/types/handlers.d.ts
1340
1310
  /**
1341
- * Intercept — wraps handler execution (before + after pattern).
1311
+ * Minimal server accessor — exposes safe, read-only server decorators.
1312
+ * Allows controller handlers to publish events, log, and audit
1313
+ * without switching to `raw: true`.
1342
1314
  */
1343
- interface Interceptor {
1344
- readonly _type: "interceptor";
1345
- readonly name: string;
1346
- readonly operations?: OperationFilter;
1347
- handler(ctx: PipelineContext, next: NextFunction): Promise<IControllerResponse<unknown>>;
1315
+ interface ServerAccessor {
1316
+ /** Event bus — publish domain events from any handler */
1317
+ events?: {
1318
+ publish: <T>(type: string, payload: T, meta?: Partial<Record<string, unknown>>) => Promise<void>;
1319
+ };
1320
+ /** Audit logger — log custom audit entries */
1321
+ audit?: {
1322
+ create: (resource: string, documentId: string, data: Record<string, unknown>, context?: Record<string, unknown>) => Promise<void>;
1323
+ update: (resource: string, documentId: string, before: Record<string, unknown>, after: Record<string, unknown>, context?: Record<string, unknown>) => Promise<void>;
1324
+ delete: (resource: string, documentId: string, data: Record<string, unknown>, context?: Record<string, unknown>) => Promise<void>;
1325
+ custom: (resource: string, documentId: string, action: string, data?: Record<string, unknown>, context?: Record<string, unknown>) => Promise<void>;
1326
+ };
1327
+ /** Logger — structured logging */
1328
+ log?: {
1329
+ info: (...args: unknown[]) => void;
1330
+ warn: (...args: unknown[]) => void;
1331
+ error: (...args: unknown[]) => void;
1332
+ debug: (...args: unknown[]) => void;
1333
+ };
1334
+ /** QueryCache — stale-while-revalidate data cache */
1335
+ queryCache?: {
1336
+ get: <T>(key: string) => Promise<{
1337
+ data: T;
1338
+ status: "fresh" | "stale" | "miss";
1339
+ }>;
1340
+ set: <T>(key: string, data: T, config: {
1341
+ staleTime?: number;
1342
+ gcTime?: number;
1343
+ tags?: string[];
1344
+ }) => Promise<void>;
1345
+ getResourceVersion: (resource: string) => Promise<number>;
1346
+ bumpResourceVersion: (resource: string) => Promise<void>;
1347
+ };
1348
1348
  }
1349
- type PipelineStep = Guard | Transform | Interceptor;
1350
- /**
1351
- * Pipeline configuration — can be a flat array or per-operation map.
1352
- */
1353
- type PipelineConfig = PipelineStep[] | {
1354
- list?: PipelineStep[];
1355
- get?: PipelineStep[];
1356
- create?: PipelineStep[];
1357
- update?: PipelineStep[];
1358
- delete?: PipelineStep[];
1359
- [operation: string]: PipelineStep[] | undefined;
1360
- };
1361
- //#endregion
1362
- //#region src/core/aggregation/types.d.ts
1363
1349
  /**
1364
- * Sugar for measures: `'count'` / `'count:field'` / `'sum:price'` /
1365
- * `'avg:rating'` / `'min:created'` / `'max:updated'` /
1366
- * `'countDistinct:userId'` / `'percentile:latency:0.95'`.
1350
+ * Request context passed to controller handlers.
1367
1351
  *
1368
- * Compiles to the canonical `AggMeasure` IR at boot. Hosts who want
1369
- * the full IR can pass an `AggMeasure` object directly.
1352
+ * **Generic parameters** (all default to safe permissive types so existing code keeps working):
1353
+ * - `TBody` — request body shape (default: `unknown`)
1354
+ * - `TParams` — route param shape (default: `Record<string, string>`)
1355
+ * - `TQuery` — query string shape (default: `Record<string, unknown>`)
1356
+ * - `TUser` — authenticated user shape (default: `UserBase`)
1357
+ * - `TMetadata` — internal metadata shape (default: `Record<string, unknown>`;
1358
+ * override with `ArcInternalMetadata` or your own augmentation when you
1359
+ * need typed access to `_scope`, `_policyFilters`, custom hook context, etc.)
1370
1360
  *
1371
- * **Percentile.** `'percentile:<field>:<p>'` where `p` is a numeric
1372
- * literal in `[0, 1]` (e.g. `'percentile:latency:0.95'` for P95).
1373
- * Mongokit (≥3.13) compiles to `$percentile`; sqlitekit throws
1374
- * `UnsupportedOperationError` (no native percentile in SQLite).
1361
+ * @example
1362
+ * ```typescript
1363
+ * // Untyped (default) — req.body is `unknown`, must be narrowed
1364
+ * async create(req: IRequestContext) {
1365
+ * const data = req.body as Partial<Product>;
1366
+ * return { success: true, data: await productRepo.create(data) };
1367
+ * }
1368
+ *
1369
+ * // Typed body — req.body is `CreateProductInput`, narrowing not needed
1370
+ * async create(req: IRequestContext<CreateProductInput>) {
1371
+ * return { success: true, data: await productRepo.create(req.body) };
1372
+ * }
1373
+ *
1374
+ * // Fully typed — body, route params, query, and metadata
1375
+ * async update(
1376
+ * req: IRequestContext<
1377
+ * Partial<Product>,
1378
+ * { id: string },
1379
+ * { fields?: string },
1380
+ * ArcInternalMetadata
1381
+ * >,
1382
+ * ) {
1383
+ * const fields = req.query.fields?.split(',');
1384
+ * const orgId = req.metadata?._scope ? getOrgId(req.metadata._scope) : undefined;
1385
+ * return { success: true, data: await productRepo.update(req.params.id, req.body) };
1386
+ * }
1387
+ * ```
1375
1388
  */
1376
- type AggMeasureShorthand = "count" | `count:${string}` | `countDistinct:${string}` | `sum:${string}` | `avg:${string}` | `min:${string}` | `max:${string}` | `percentile:${string}:${number}`;
1377
- /** Either canonical IR or shorthand string — both compile to `AggMeasure`. */
1378
- type AggMeasureInput = AggMeasure | AggMeasureShorthand;
1379
1389
  /**
1380
- * Cache config for an aggregation. Translates directly to the kit-side
1381
- * `CacheOptions` (TanStack-shaped) which the unified
1382
- * `@classytic/repo-core/cache` plugin reads from `req.cache`.
1390
+ * First-class projection of `request._scope` for controller handlers.
1383
1391
  *
1384
- * Caching only fires when the kit's repo has the `cachePlugin` wired —
1385
- * arc declares the policy; the kit handles SWR + tag invalidation +
1386
- * version-bump on writes. Hosts without the plugin installed silently
1387
- * fall through to a non-cached call.
1392
+ * **v2.10.6:** previously, pulling tenant/user/role info from inside a
1393
+ * controller override meant digging through `req.metadata._scope` and
1394
+ * calling `getOrgId(scope)` / `getUserId(user)` manually. This projection
1395
+ * lifts the two fields most hosts need directly onto `req` so cross-kit
1396
+ * controller code reads:
1397
+ *
1398
+ * ```ts
1399
+ * async create(req: IRequestContext) {
1400
+ * const flowCtx = { organizationId: req.scope?.organizationId, actorId: req.scope?.userId };
1401
+ * }
1402
+ * ```
1403
+ *
1404
+ * Full scope shape (discriminated union of `member` / `service` / `elevated` / `public`)
1405
+ * still lives on `req.metadata._scope` for code that needs to branch on
1406
+ * `scope.kind` — this projection just surfaces the two keys every
1407
+ * tenant-scoped resource reaches for.
1388
1408
  */
1389
- interface AggregationCacheConfig {
1390
- /** Seconds the entry is fresh — no revalidation while inside this window. */
1391
- staleTime?: number;
1392
- /** Seconds the entry stays in cache past stale before eviction. Default: 60. */
1393
- gcTime?: number;
1409
+ interface RequestScopeProjection {
1410
+ /** Tenant the caller is scoped to (org member, service key bound to an org, or elevated admin's target org). */
1411
+ organizationId?: string;
1412
+ /** Caller's user id when authenticated — undefined for public / service-only scopes. */
1413
+ userId?: string;
1414
+ /** Org-level roles (e.g. `['admin', 'warehouse-manager']`) — separate from global `user.roles`. */
1415
+ orgRoles?: string[];
1416
+ }
1417
+ interface IRequestContext<TBody = unknown, TParams extends Record<string, string> = Record<string, string>, TQuery extends Record<string, unknown> = Record<string, unknown>, TUser extends UserBase = UserBase, TMetadata extends Record<string, unknown> = Record<string, unknown>> {
1418
+ /** Route parameters (e.g., { id: '123' }) */
1419
+ params: TParams;
1420
+ /** Query string parameters */
1421
+ query: TQuery;
1422
+ /** Request body */
1423
+ body: TBody;
1424
+ /** Authenticated user or null */
1425
+ user: TUser | null;
1426
+ /** Request headers */
1427
+ headers: Record<string, string | undefined>;
1428
+ /** Organization ID (for multi-tenant apps) */
1429
+ organizationId?: string;
1430
+ /** Team ID (for team-scoped resources) */
1431
+ teamId?: string;
1394
1432
  /**
1395
- * Stale-while-revalidate. When stale entries serve immediately and a
1396
- * background refresh updates the cache. Default: `true` for
1397
- * aggregations (dashboards almost always benefit from stale-serve).
1433
+ * First-class tenant/user scope projection — lifted from `metadata._scope`
1434
+ * so controller overrides don't have to dig through Arc internals.
1435
+ * See {@link RequestScopeProjection}. `undefined` for routes that run
1436
+ * without auth / scope resolution.
1398
1437
  */
1399
- swr?: boolean;
1438
+ scope?: RequestScopeProjection;
1400
1439
  /**
1401
- * Group invalidation tags. Pass to `repo.cache?.invalidateByTags(tags)`
1402
- * after a write to clear matching entries. The model name is
1403
- * auto-tagged by the plugin — you only declare cross-cutting tags
1404
- * (e.g. `'pricing'` to invalidate every aggregation that depends on
1405
- * pricing across multiple resources).
1440
+ * Organization/auth context from middleware.
1441
+ * Contains orgRoles, orgScope, organizationId, and any custom fields
1442
+ * set by the auth adapter or org-scope plugin.
1443
+ *
1444
+ * @example
1445
+ * ```typescript
1446
+ * async create(req: IRequestContext) {
1447
+ * const roles = req.context?.orgRoles ?? [];
1448
+ * if (roles.includes('manager')) { ... }
1449
+ * }
1450
+ * ```
1406
1451
  */
1407
- tags?: readonly string[];
1452
+ context?: RequestContext;
1453
+ /**
1454
+ * Internal metadata (includes context + Arc internals like `_policyFilters`,
1455
+ * `_scope`, `log`). Type as `ArcInternalMetadata` for typed access to Arc's
1456
+ * built-in fields, or supply your own interface to layer custom fields.
1457
+ */
1458
+ metadata?: TMetadata;
1459
+ /**
1460
+ * Fastify server accessor — publish events, log, and audit
1461
+ * from any handler without switching to `raw: true`.
1462
+ *
1463
+ * @example
1464
+ * ```typescript
1465
+ * async reschedule(req: IRequestContext) {
1466
+ * const result = await repo.reschedule(req.params.id, req.body);
1467
+ * await req.server?.events?.publish('interview.rescheduled', { data: result });
1468
+ * return { data: result };
1469
+ * }
1470
+ * ```
1471
+ */
1472
+ server?: ServerAccessor;
1408
1473
  }
1409
1474
  /**
1410
- * Per-aggregation rate limit. Layers on top of any global rate limit
1411
- * via `@fastify/rate-limit` route-level config.
1475
+ * Controller response shape — the success-path return from any handler.
1476
+ *
1477
+ * Errors throw `ArcError` (or any `HttpError`-shaped class); the global
1478
+ * error handler catches them and emits an `ErrorContract`. There is no
1479
+ * `success` discriminator on the response — HTTP status is the wire
1480
+ * discriminator (2xx = data, 4xx/5xx = ErrorContract).
1412
1481
  */
1413
- interface AggregationRateLimit {
1414
- /** Max requests per window. */
1415
- max: number;
1416
- /** Window in milliseconds. */
1417
- windowMs: number;
1482
+ interface IControllerResponse<T = unknown> {
1483
+ /** Response payload — emitted directly to the wire (no envelope wrap). */
1484
+ data: T;
1485
+ /** HTTP status code. Defaults to 200. */
1486
+ status?: number;
1487
+ /** Custom response headers (e.g. X-Total-Count, Link, ETag). */
1488
+ headers?: Record<string, string>;
1489
+ /** Top-level metadata merged into list-shaped responses (e.g. `{ took }`). */
1490
+ meta?: Record<string, unknown>;
1418
1491
  }
1419
1492
  /**
1420
- * Required date-range narrowing. Caller MUST send a bounded range on
1421
- * this field, and the range MUST NOT exceed `maxRangeDays`.
1493
+ * Controller handler — Arc's standard pattern.
1422
1494
  *
1423
- * Prevents "all-time" scans on billion-row collections — the single
1424
- * biggest performance footgun for live aggregation endpoints.
1495
+ * Receives a request context object, returns IControllerResponse.
1496
+ * Use with `raw: false` in routes.
1497
+ *
1498
+ * **Generic parameters:**
1499
+ * - `TResponse` — shape of `IControllerResponse.data` (default: `unknown`)
1500
+ * - `TBody` — shape of `req.body` (default: `unknown`)
1501
+ * - `TParams` — shape of `req.params` (default: `Record<string, string>`)
1502
+ * - `TQuery` — shape of `req.query` (default: `Record<string, unknown>`)
1503
+ *
1504
+ * Backward-compatible: `ControllerHandler<Product>` still works (only the
1505
+ * response data is typed); add more generics as needed when you want
1506
+ * type-safe access to the request body, params, or query string.
1507
+ *
1508
+ * @example
1509
+ * ```typescript
1510
+ * // Untyped req — body is unknown, must be narrowed
1511
+ * const createProduct: ControllerHandler<Product> = async (req) => {
1512
+ * const product = await productRepo.create(req.body as Partial<Product>);
1513
+ * return { data: product, status: 201 };
1514
+ * };
1515
+ *
1516
+ * // Fully typed — body, params, query, and response all inferred
1517
+ * const updateProduct: ControllerHandler<
1518
+ * Product,
1519
+ * Partial<Product>,
1520
+ * { id: string },
1521
+ * { upsert?: string }
1522
+ * > = async (req) => {
1523
+ * const upsert = req.query.upsert === "true";
1524
+ * const product = await productRepo.update(req.params.id, req.body, { upsert });
1525
+ * return { data: product };
1526
+ * };
1527
+ *
1528
+ * routes: [{
1529
+ * method: 'POST',
1530
+ * path: '/products',
1531
+ * handler: createProduct,
1532
+ * permissions: requireAuth(),
1533
+ * raw: false, // Arc wraps this into Fastify handler
1534
+ * }]
1535
+ * ```
1425
1536
  */
1426
- interface AggregationDateRangeRequirement {
1427
- /** Field whose range the caller must narrow (e.g. `'createdAt'`). */
1428
- field: string;
1429
- /**
1430
- * Cap on the queryable range. A request asking for >N days is
1431
- * rejected 400. Omit for "any range, but bounded" (lower + upper
1432
- * required, no cap).
1433
- */
1434
- maxRangeDays?: number;
1435
- }
1537
+ type ControllerHandler<TResponse = unknown, TBody = unknown, TParams extends Record<string, string> = Record<string, string>, TQuery extends Record<string, unknown> = Record<string, unknown>> = (req: IRequestContext<TBody, TParams, TQuery>) => Promise<IControllerResponse<TResponse>>;
1436
1538
  /**
1437
- * Boot-time index hint. Arc warns when the kit's schema doesn't have
1438
- * an index whose leading keys match — flags the misconfig before
1439
- * traffic hits the DB.
1539
+ * Fastify native handler
1440
1540
  *
1441
- * Documented intent, NOT runtime-enforced. Kits with their own index
1442
- * introspection (mongokit reads Mongoose schema, sqlitekit reads
1443
- * Drizzle indexes) can act on the hint; kits without introspection
1444
- * silently accept it.
1541
+ * Standard Fastify request/reply pattern.
1542
+ * Use with `raw: true` in routes.
1543
+ *
1544
+ * @example
1545
+ * ```typescript
1546
+ * const downloadFile: FastifyHandler = async (request, reply) => {
1547
+ * const file = await getFile(request.params.id);
1548
+ * reply.header('Content-Type', file.mimeType);
1549
+ * return reply.send(file.buffer);
1550
+ * };
1551
+ *
1552
+ * routes: [{
1553
+ * method: 'GET',
1554
+ * path: '/files/:id/download',
1555
+ * handler: downloadFile,
1556
+ * permissions: requireAuth(),
1557
+ * raw: true, // Use as-is, no wrapping
1558
+ * }]
1559
+ * ```
1445
1560
  */
1446
- interface AggregationIndexHint {
1447
- /** Leading-key columns the host expects the planner to use. */
1448
- leadingKeys: readonly string[];
1561
+ type FastifyHandler<RouteGeneric extends Record<string, unknown> = Record<string, unknown>> = (request: FastifyRequest<RouteGeneric>, reply: FastifyReply) => Promise<unknown> | unknown;
1562
+ /**
1563
+ * Union type for route handlers
1564
+ */
1565
+ type RouteHandler = ControllerHandler | FastifyHandler;
1566
+ /**
1567
+ * Controller interface for CRUD operations (strict).
1568
+ *
1569
+ * `list`'s return type aligns with repo-core's `MinimalRepo.getAll()`
1570
+ * contract — the kit MAY return an offset envelope, a keyset envelope,
1571
+ * or a raw array. Arc's `BaseController` forwards the kit's response
1572
+ * verbatim; consumers narrow on shape
1573
+ * (`Array.isArray(data)` → bare array, presence of `total` → offset,
1574
+ * presence of `nextCursor` → keyset).
1575
+ */
1576
+ interface IController<TDoc = unknown> {
1577
+ list(req: IRequestContext): Promise<IControllerResponse<unknown>>;
1578
+ get(req: IRequestContext): Promise<IControllerResponse<TDoc>>;
1579
+ create(req: IRequestContext): Promise<IControllerResponse<TDoc>>;
1580
+ update(req: IRequestContext): Promise<IControllerResponse<TDoc>>;
1581
+ delete(req: IRequestContext): Promise<IControllerResponse<{
1582
+ message: string;
1583
+ }>>;
1449
1584
  }
1450
1585
  /**
1451
- * Runtime context passed to the `materialized` hook.
1586
+ * Flexible controller interface — accepts controllers with any handler style,
1587
+ * including class instances with extra methods / private fields.
1452
1588
  *
1453
- * The hook returns pre-computed data instead of running the live
1454
- * aggregation. Hosts use this for ultra-frequent dashboards backed by
1455
- * rollup tables maintained out-of-band (cron / CDC).
1589
+ * **v2.10.6:** the previous `[key: string]: unknown` index signature made
1590
+ * real class instances fail structural assignment (`new ScrapController()`
1591
+ * needed a `as unknown as ControllerLike` cast, because class instances
1592
+ * don't have an index signature). Dropped — arc only invokes the CRUD
1593
+ * methods at runtime, so the rest of the shape is the caller's concern.
1594
+ *
1595
+ * The five CRUD slots stay optional so partial controllers (e.g. read-only)
1596
+ * assign too. Additional domain methods on the controller are allowed by
1597
+ * construction (they're just not part of this contract).
1456
1598
  */
1457
- interface AggregationMaterializedContext {
1458
- /** Compiled filter (host base + tenant + caller). */
1459
- filter: AnyRecord;
1460
- /** Tenant id when the resource is tenant-scoped. */
1461
- orgId?: string;
1462
- /** Authenticated user id, when present. */
1463
- userId?: string;
1464
- /** Fastify request id for tracing. */
1465
- requestId?: string;
1466
- /** Raw URL query params (post-validation). */
1467
- query: Record<string, unknown>;
1599
+ interface ControllerLike {
1600
+ list?: unknown;
1601
+ get?: unknown;
1602
+ create?: unknown;
1603
+ update?: unknown;
1604
+ delete?: unknown;
1468
1605
  }
1469
- /** Materialized hook return shape — same envelope as `AggResult`. */
1470
- interface AggregationMaterializedResult<TRow = AnyRecord> {
1471
- rows: readonly TRow[];
1606
+ //#endregion
1607
+ //#region src/types/resource/rate-limit.d.ts
1608
+ /**
1609
+ * Resource-level rate-limit shape — shared by `ResourceConfig.rateLimit`
1610
+ * and per-route `RouteDefinition.rateLimit` overrides.
1611
+ */
1612
+ interface RateLimitConfig {
1613
+ /** Maximum number of requests allowed within the time window */
1614
+ max: number;
1615
+ /** Time window for rate limiting (e.g., '1 minute', '15 seconds') */
1616
+ timeWindow: string;
1617
+ }
1618
+ //#endregion
1619
+ //#region src/types/resource/routes.d.ts
1620
+ /** HTTP methods for custom routes. */
1621
+ type RouteMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
1622
+ /** MCP tool configuration for a route or action. */
1623
+ interface RouteMcpConfig {
1624
+ /** Override auto-generated tool description */
1625
+ readonly description?: string;
1626
+ /** MCP tool annotations */
1627
+ readonly annotations?: {
1628
+ readonly readOnlyHint?: boolean;
1629
+ readonly destructiveHint?: boolean;
1630
+ readonly idempotentHint?: boolean;
1631
+ readonly openWorldHint?: boolean;
1632
+ };
1472
1633
  }
1473
1634
  /**
1474
- * Single named aggregation declaration. Composes into `AggRequest` at
1475
- * request time, with safety knobs layered on at the arc-handler level.
1635
+ * Route definition — single custom-route shape (user-facing + internal).
1636
+ *
1637
+ * - `handler: 'string'` → controller method → full Arc pipeline + MCP tool
1638
+ * - `handler: function` → inline handler → full Arc pipeline + MCP tool
1639
+ * - `raw: true` → raw Fastify handler → no pipeline, no MCP by default
1476
1640
  */
1477
- interface AggregationConfig {
1478
- /**
1479
- * Pre-aggregate filter on the BASE rows (before lookups). Always
1480
- * ANDed with auto-injected tenant scope + caller URL-narrowing
1481
- * filters. Use for host-defined invariants (e.g. `archived: false`).
1482
- */
1483
- filter?: AnyRecord;
1641
+ interface RouteDefinition {
1642
+ readonly method: RouteMethod;
1643
+ /** Path relative to resource prefix */
1644
+ readonly path: string;
1484
1645
  /**
1485
- * Cross-table joins. Each `LookupSpec` reuses the IR
1486
- * `@classytic/repo-core/lookup` defines for `lookupPopulate()`.
1487
- * Same compile path the kit already ships.
1646
+ * Route handler.
1647
+ * - String: controller method name (Arc pipeline) — runtime lookup, no TS coverage on typos.
1648
+ * - Function without `raw: true`: receives IRequestContext, returns IControllerResponse (Arc pipeline)
1649
+ * - Function with `raw: true`: raw Fastify handler `(request, reply)`
1488
1650
  *
1489
- * **Kit support is incremental.** A kit's `aggregate()` may not yet
1490
- * compile lookups — boot validation against the adapter version
1491
- * surfaces this loud, so hosts pin the kit major they need.
1492
- */
1493
- lookups?: readonly LookupSpec[];
1494
- /**
1495
- * Group key(s). Dotted paths into joined aliases supported when
1496
- * `lookups` is set: `'category.parent'` groups by the joined
1497
- * `category` row's `parent` field.
1651
+ * Prefer `controllerMethod` (2.16) over the string form when you want the
1652
+ * compiler to catch typos. `handler` stays optional when `controllerMethod`
1653
+ * is set — exactly one of the two must be declared.
1498
1654
  */
1499
- groupBy?: string | readonly string[];
1655
+ readonly handler?: string | ControllerHandler | RouteHandlerMethod | ((request: FastifyRequest<Record<string, unknown>>, reply: FastifyReply) => unknown);
1500
1656
  /**
1501
- * Time-bucket group keys for time-series aggregations. Each entry
1502
- * promotes a date column into a synthetic group key bucketed at
1503
- * the chosen interval. The map key becomes a column on the output
1504
- * row holding the canonical ISO-shaped bucket label
1505
- * (`'2026-04'` for month, `'2026-W15'` for ISO week, etc.).
1657
+ * Typed function-reference handler (2.16). Receives the live controller
1658
+ * instance and returns the method to invoke — TypeScript catches typos
1659
+ * and surfaces autocomplete on the controller's method names. Prefer
1660
+ * this over the string `handler` form for any host that uses a typed
1661
+ * controller subclass.
1506
1662
  *
1507
- * Bucketed keys participate in grouping the same way `groupBy`
1508
- * columns do — `sort: { month: 1 }`, `having`, pagination, and
1509
- * `topN.partitionBy` all treat them as first-class.
1663
+ * Resolved ONCE at route-registration time (the same path string
1664
+ * handlers go through), so there's no per-request lookup overhead.
1665
+ * The returned method is bound to the controller before dispatch.
1510
1666
  *
1511
- * Aliases must NOT collide with a `groupBy` field name or measure
1512
- * alias — boot validation throws on collision.
1667
+ * Mutually exclusive with `handler` — passing both throws at boot
1668
+ * with a clear "pick one" message. The runtime contract on what the
1669
+ * returned function accepts mirrors the `handler` rules: a plain
1670
+ * `ControllerHandler` for pipeline routes, a raw Fastify handler when
1671
+ * the route is `raw: true`.
1513
1672
  *
1514
- * @example "Daily revenue for the last quarter"
1515
- * ```ts
1516
- * dailyRevenue: defineAggregation({
1517
- * dateBuckets: { day: { field: 'createdAt', interval: 'day' } },
1518
- * measures: { revenue: 'sum:totalPrice' },
1519
- * sort: { day: 1 },
1520
- * permissions: requireRoles(['admin']),
1521
- * }),
1522
- * ```
1673
+ * Typed loosely (`(controller: unknown) => …`) at the type-system
1674
+ * boundary because `RouteDefinition` is not generic over the
1675
+ * controller shape — host code annotates the parameter to opt in:
1523
1676
  *
1524
- * @example "15-minute traffic buckets (custom-bin form)"
1677
+ * @example
1525
1678
  * ```ts
1526
- * traffic: defineAggregation({
1527
- * dateBuckets: {
1528
- * slot: { field: 'ts', interval: { every: 15, unit: 'minute' } },
1529
- * },
1530
- * measures: { hits: 'count' },
1531
- * permissions: allowPublic(),
1532
- * }),
1679
+ * class PostController extends BaseController<Post> {
1680
+ * async getStats(ctx: IRequestContext) { … }
1681
+ * }
1682
+ *
1683
+ * defineResource({
1684
+ * controller: new PostController(repo),
1685
+ * routes: [{
1686
+ * method: 'GET',
1687
+ * path: '/stats',
1688
+ * controllerMethod: (c: PostController) => c.getStats,
1689
+ * // ^^^^^^^^^^^^^^ TS catches typos here
1690
+ * permissions: requireAuth(),
1691
+ * }],
1692
+ * });
1533
1693
  * ```
1534
1694
  */
1535
- dateBuckets?: Record<string, AggDateBucket>;
1536
- /** Named aggregations. At least one entry required. */
1537
- measures: Record<string, AggMeasureInput>;
1538
- /**
1539
- * Post-aggregate filter referencing measure aliases.
1540
- * Example: `{ revenue: { gt: 1000 } }` → `HAVING revenue > 1000`.
1541
- */
1542
- having?: AnyRecord;
1543
- /** Order grouped rows by groupBy field, measure alias, or joined-alias path. */
1544
- sort?: Record<string, 1 | -1>;
1695
+ readonly controllerMethod?: (controller: unknown) => ControllerHandler | RouteHandlerMethod;
1696
+ /** Permission check — REQUIRED */
1697
+ readonly permissions: PermissionCheck;
1545
1698
  /**
1546
- * Static hard cap on result rows. Always applied at the IR level
1547
- * (LIMIT / `$limit`), regardless of URL params. Use when the
1548
- * aggregation has a fixed-size shape ("top 10 categories"). Mutually
1549
- * exclusive with `defaultLimit` / `maxLimit` — pick the static form
1550
- * OR the URL-driven form, not both.
1699
+ * Raw mode — bypasses Arc pipeline. Handler receives raw Fastify
1700
+ * request/reply. Default: false.
1551
1701
  */
1552
- limit?: number;
1702
+ readonly raw?: boolean;
1703
+ /** Logical operation name (pipeline keys, MCP tool naming). */
1704
+ readonly operation?: string;
1705
+ /** OpenAPI summary */
1706
+ readonly summary?: string;
1707
+ /** OpenAPI description */
1708
+ readonly description?: string;
1709
+ /** OpenAPI tags */
1710
+ readonly tags?: string[];
1711
+ /** Route-level middleware */
1712
+ readonly preHandler?: RouteHandlerMethod[] | ((fastify: FastifyInstance) => RouteHandlerMethod[]);
1713
+ /** Pre-auth handlers (run before authentication) */
1714
+ readonly preAuth?: RouteHandlerMethod[];
1553
1715
  /**
1554
- * URL-driven row limit — applied when the caller passes `?limit=N`.
1555
- * When set, the auto-route reads `query.limit`, parses it as a
1556
- * positive integer, caps it at `maxLimit` (or the framework default
1557
- * if `maxLimit` is unset), and writes the result into `AggRequest.limit`.
1558
- * When the caller omits `?limit`, this default applies.
1716
+ * Opt this custom route into the multiTenant preset's tenant scoping.
1559
1717
  *
1560
- * **DoS defense.** Auto-generated `/aggregations/:name` routes today
1561
- * have no upper bound on response size. A `groupBy: 'userId'` on a
1562
- * 10M-user table without `defaultLimit` returns a 10M-row JSON. Setting
1563
- * `defaultLimit: 100, maxLimit: 1000` bounds the response shape while
1564
- * letting power users request up to 1000 rows when they need them.
1718
+ * When `true` AND the resource declares `multiTenantPreset` (or
1719
+ * `flexibleMultiTenantPreset`), arc prepends the preset's tenant
1720
+ * filter + injection middleware to this route's chain — the same
1721
+ * pair `update` gets. The handler can then read the resolved tenant
1722
+ * via `getOrgId(req.scope)` / `req._tenantFields` (or, for write
1723
+ * routes, off `req.body` after injection) without re-implementing
1724
+ * the scope read + header fallback + 400 boilerplate.
1565
1725
  *
1566
- * Mutually exclusive with the static `limit` — boot error if both set.
1567
- * Setting `defaultLimit` without `maxLimit` uses the framework
1568
- * default cap (`AGG_FRAMEWORK_MAX_LIMIT = 1000`) — explicit is better.
1726
+ * Throws at boot if set when no multiTenant preset is wired — the
1727
+ * misconfiguration would otherwise be silently insecure (read routes
1728
+ * returning every tenant's rows).
1729
+ *
1730
+ * @default false
1569
1731
  */
1570
- defaultLimit?: number;
1732
+ readonly tenantScope?: boolean;
1571
1733
  /**
1572
- * Hard ceiling for URL-driven `?limit=N`. Only meaningful when
1573
- * `defaultLimit` is set; ignored otherwise. Defaults to
1574
- * `AGG_FRAMEWORK_MAX_LIMIT` (1000) when omitted but `defaultLimit` is
1575
- * present. Boot validation: must be ≥ `defaultLimit`.
1734
+ * SSE streaming mode. Two handler shapes work:
1735
+ *
1736
+ * - Write to `reply.raw` directly (NDJSON, custom SSE — historical contract).
1737
+ * - Return a Web `ReadableStream` (Vercel AI SDK's `result.toUIMessageStream()`,
1738
+ * `fetch().body`, etc.). 2.17.1+ auto-pipes these through
1739
+ * `pipeUIMessageStreamToReply()` so each chunk is JSON-encoded into
1740
+ * an SSE `data:` frame and client-disconnect cancels the source.
1741
+ * Pre-2.17.1 this crashed with `chunk must be a string or Buffer`.
1576
1742
  */
1577
- maxLimit?: number;
1743
+ readonly streamResponse?: boolean;
1578
1744
  /**
1579
- * Top-N-per-group filter. Keeps only the top `limit` rows per
1580
- * partition, ranked by `sortBy`. The classic "top 3 products per
1581
- * category" / "top 5 customers per region" dashboard primitive.
1582
- *
1583
- * Composes with `having` / `sort` — applies AFTER group + measures +
1584
- * having, so `partitionBy` and `sortBy` may reference groupBy fields,
1585
- * dateBucket aliases, or measure aliases. The top-level `sort` orders
1586
- * the final row set across partitions.
1745
+ * INTERNAL — marker stamped by built-in presets (softDelete, tree, …)
1746
+ * onto routes they emit. Read by the MCP collision detector
1747
+ * (`createMcpServer.resolveToolCollisions`) to attribute the route
1748
+ * source ("preset:softDelete vs user `actions.restore`") and to
1749
+ * auto-namespace the preset side when a user-authored tool legitimately
1750
+ * shadows it. Not part of the public surface — hosts must not set this.
1587
1751
  *
1588
- * **Per-kit support.** Mongokit compiles to `$setWindowFields` (Mongo 5+,
1589
- * runs in-engine — scales). Sqlitekit post-processes in JS (fine for
1590
- * typical dashboards; prefer mongokit for >100k groups). See
1591
- * `AggTopN` for full semantics.
1752
+ * @internal
1592
1753
  */
1593
- topN?: AggTopN;
1754
+ readonly _presetSource?: string;
1594
1755
  /**
1595
- * Permission check. **REQUIRED.** Aggregations are read-shape but
1596
- * different from list (different threat model — measures may expose
1597
- * cardinality info even when individual rows are hidden). Boot error
1598
- * if missing.
1756
+ * Apply the resource's `fields` (field-level write permissions) to this
1757
+ * custom route's request body.
1758
+ *
1759
+ * Default: `true` for body-bearing methods (POST/PUT/PATCH), `false`
1760
+ * otherwise. Set to `false` to opt out — handler accepts the body as
1761
+ * supplied with no field-write filtering. The resource-level
1762
+ * `onFieldWriteDenied: 'reject' | 'strip'` setting governs how denials
1763
+ * are surfaced.
1764
+ *
1765
+ * `raw: true` routes ALWAYS bypass this — they've opted out of the
1766
+ * pipeline entirely.
1599
1767
  */
1600
- permissions: PermissionCheck;
1768
+ readonly fieldWrite?: boolean;
1601
1769
  /**
1602
- * DB-level execution cap (ms). Mongokit threads to `maxTimeMS`;
1603
- * sqlitekit threads to per-statement timeout where supported.
1604
- * Default: kit's default (typically none).
1770
+ * Per-route rate limit. A custom route is its own Fastify route, so this
1771
+ * overrides the resource-level `rateLimit` for THIS endpoint only:
1772
+ *
1773
+ * - omitted → inherit the resource-level limit (or app default)
1774
+ * - `false` → disable rate limiting on this route
1775
+ * - `{ max, timeWindow }` → apply this specific limit
1776
+ *
1777
+ * Requires `@fastify/rate-limit` registered (arc's factory wires it by
1778
+ * default). Use for an expensive `/export` or an auth-sensitive `/reset`
1779
+ * that needs a tighter cap than the resource's read-heavy CRUD, or `false`
1780
+ * on an internal health/webhook route that must not be throttled.
1781
+ *
1782
+ * Note: this is per-route because each custom route is a distinct endpoint.
1783
+ * `actions` share ONE `POST /:id/action` mount by design, so a per-ACTION
1784
+ * limit can't be expressed here — throttle a specific action by promoting
1785
+ * it to a dedicated `routes:` entry, or rely on the resource-level limit.
1605
1786
  */
1606
- timeout?: number;
1787
+ readonly rateLimit?: RateLimitConfig | false;
1607
1788
  /**
1608
- * Reject 422 if the result row count exceeds this cap. Better than
1609
- * silent truncation — caller knows the dashboard is incomplete.
1610
- * Default: no cap (use `limit` for truncation semantics).
1789
+ * Fastify route schema. Each slot (`body`, `querystring`, `params`,
1790
+ * `headers`, `response[status]`) accepts a plain JSON Schema object
1791
+ * **or** a Zod v4 schema — Arc auto-converts via `convertRouteSchema`.
1611
1792
  */
1612
- maxGroups?: number;
1793
+ readonly schema?: {
1794
+ body?: unknown;
1795
+ querystring?: unknown;
1796
+ params?: unknown;
1797
+ headers?: unknown;
1798
+ response?: Record<number | string, unknown>;
1799
+ [key: string]: unknown;
1800
+ };
1613
1801
  /**
1614
- * Caller MUST provide filters on these fields (else 400 at request).
1615
- * Use to require a tenant-side narrowing the host can't infer
1616
- * (segment id, customer id, etc.).
1802
+ * MCP tool generation:
1803
+ * - omitted/true: auto-generate (non-raw routes only)
1804
+ * - false: skip MCP
1805
+ * - object: explicit config
1617
1806
  */
1618
- requireFilters?: readonly string[];
1807
+ readonly mcp?: boolean | RouteMcpConfig;
1619
1808
  /**
1620
- * Caller MUST send a bounded date range on this field. Prevents
1621
- * all-time scans on billion-row collections.
1809
+ * MCP handler for raw routes — parallel entry point for MCP without
1810
+ * changing the HTTP handler.
1622
1811
  */
1623
- requireDateRange?: AggregationDateRangeRequirement;
1812
+ readonly mcpHandler?: (input: Record<string, unknown>) => Promise<{
1813
+ content: Array<{
1814
+ type: string;
1815
+ text: string;
1816
+ }>;
1817
+ isError?: boolean;
1818
+ }>;
1819
+ }
1820
+ //#endregion
1821
+ //#region src/types/resource/actions.d.ts
1822
+ /**
1823
+ * Action handler function for state transitions. Receives the resource
1824
+ * ID, action-specific data, and the request.
1825
+ */
1826
+ type ActionHandlerFn = (id: string, data: Record<string, unknown>, req: RequestWithExtras) => Promise<unknown>;
1827
+ /**
1828
+ * Full action configuration with handler, permissions, and schema.
1829
+ *
1830
+ * Rate limiting is intentionally NOT per-action: every action shares one
1831
+ * `POST /:id/action` (or `POST /action`) mount, so Fastify's per-route limit
1832
+ * can't distinguish them. Actions inherit the resource-level `rateLimit`; to
1833
+ * throttle a specific operation, promote it to a `routes:` entry (which
1834
+ * supports `RouteDefinition.rateLimit`).
1835
+ */
1836
+ interface ActionDefinition {
1837
+ readonly handler: ActionHandlerFn;
1838
+ /** Per-action permission (overrides resource-level `actionPermissions`) */
1839
+ readonly permissions?: PermissionCheck;
1624
1840
  /**
1625
- * Documented index expectation. Arc warns at boot when the kit
1626
- * exposes index introspection and no matching index exists. NOT
1627
- * runtime-enforced — purely a misconfig signal.
1841
+ * JSON Schema or Zod v4 schema for action-specific body fields.
1842
+ *
1843
+ * Typed `unknown` (not `Record<string, unknown>`) so Zod class instances
1844
+ * — `ZodObject<...>` carries no string index signature — assign without
1845
+ * a cast. Same convention as `RouteDefinition.schema.body` / `customSchemas`.
1846
+ * Runtime feature-detects via `convertRouteSchema` / `toJsonSchema`.
1628
1847
  */
1629
- indexHint?: AggregationIndexHint;
1848
+ readonly schema?: unknown;
1849
+ /** Description for OpenAPI docs and MCP tool */
1850
+ readonly description?: string;
1630
1851
  /**
1631
- * Per-aggregation cache. Tenant-scoped keys; invalidates with the
1632
- * resource's cache namespace + any explicit `tags`.
1852
+ * Whether this action needs an entity id from the URL.
1853
+ *
1854
+ * - `true` (default) — mounts under `POST /<prefix>/:id/action`. The `id`
1855
+ * path param is required; handler receives it as the first argument.
1856
+ * Use for actions that operate on an existing entity: `approve`,
1857
+ * `dispatch`, `cancel`, `archive`.
1858
+ * - `false` — mounts under `POST /<prefix>/action` (no `:id` segment).
1859
+ * Handler receives an empty-string first argument. Use for
1860
+ * collection-level actions that create / search / bulk-mutate:
1861
+ * `propose` (creates a new entity), `search` (returns rows), `bulk`.
1862
+ *
1863
+ * 2.15.5: previously every action had to live under `:id/action`, forcing
1864
+ * `propose`-style actions to swallow a meaningless URL parameter and
1865
+ * making the auto-generated MCP tool advertise an `id` field agents
1866
+ * had no value for. Setting `id: false` mounts the action at the
1867
+ * resource root and drops `id` from the MCP tool's input shape.
1633
1868
  */
1634
- cache?: AggregationCacheConfig;
1869
+ readonly id?: boolean;
1635
1870
  /**
1636
- * Per-route rate limit. Wired to `@fastify/rate-limit` when the
1637
- * plugin is registered.
1871
+ * MCP tool generation:
1872
+ * - omitted/true: auto-generate
1873
+ * - false: skip
1874
+ * - object: explicit config
1638
1875
  */
1639
- rateLimit?: AggregationRateLimit;
1640
- /**
1641
- * Pre-computed read replacement. When set, arc skips
1642
- * `repo.aggregate()` and calls this function instead. Same wire
1643
- * shape, same permissions, different data source.
1644
- *
1645
- * Use for homepage-counter dashboards backed by host-managed rollup
1646
- * tables (cron / CDC). The hook receives the compiled context
1647
- * (filter + scope) so the host can route the lookup to the right
1648
- * pre-aggregated bucket.
1649
- */
1650
- materialized?: (ctx: AggregationMaterializedContext) => Promise<AggregationMaterializedResult>;
1651
- /** Optional summary rendered in OpenAPI + MCP tool description. */
1652
- summary?: string;
1653
- /** Optional longer description for OpenAPI / MCP. */
1654
- description?: string;
1655
- /**
1656
- * MCP tool generation control. Mirrors `actions[name].mcp` semantics.
1657
- *
1658
- * - `undefined` (default) — generate an MCP tool for this aggregation
1659
- * - `false` — skip MCP tool generation (REST route still works)
1660
- * - `{ description?, annotations? }` — generate with overrides
1661
- */
1662
- mcp?: boolean | {
1663
- readonly description?: string;
1664
- readonly annotations?: Record<string, unknown>;
1665
- };
1666
- }
1667
- /** Map of name → declaration. Keys become URL segments under `/aggregations/<name>`. */
1668
- type AggregationsMap = Record<string, AggregationConfig>;
1669
- //#endregion
1670
- //#region src/types/handlers.d.ts
1671
- /**
1672
- * Minimal server accessor — exposes safe, read-only server decorators.
1673
- * Allows controller handlers to publish events, log, and audit
1674
- * without switching to `raw: true`.
1675
- */
1676
- interface ServerAccessor {
1677
- /** Event bus — publish domain events from any handler */
1678
- events?: {
1679
- publish: <T>(type: string, payload: T, meta?: Partial<Record<string, unknown>>) => Promise<void>;
1680
- };
1681
- /** Audit logger — log custom audit entries */
1682
- audit?: {
1683
- create: (resource: string, documentId: string, data: Record<string, unknown>, context?: Record<string, unknown>) => Promise<void>;
1684
- update: (resource: string, documentId: string, before: Record<string, unknown>, after: Record<string, unknown>, context?: Record<string, unknown>) => Promise<void>;
1685
- delete: (resource: string, documentId: string, data: Record<string, unknown>, context?: Record<string, unknown>) => Promise<void>;
1686
- custom: (resource: string, documentId: string, action: string, data?: Record<string, unknown>, context?: Record<string, unknown>) => Promise<void>;
1687
- };
1688
- /** Logger — structured logging */
1689
- log?: {
1690
- info: (...args: unknown[]) => void;
1691
- warn: (...args: unknown[]) => void;
1692
- error: (...args: unknown[]) => void;
1693
- debug: (...args: unknown[]) => void;
1694
- };
1695
- /** QueryCache — stale-while-revalidate data cache */
1696
- queryCache?: {
1697
- get: <T>(key: string) => Promise<{
1698
- data: T;
1699
- status: "fresh" | "stale" | "miss";
1700
- }>;
1701
- set: <T>(key: string, data: T, config: {
1702
- staleTime?: number;
1703
- gcTime?: number;
1704
- tags?: string[];
1705
- }) => Promise<void>;
1706
- getResourceVersion: (resource: string) => Promise<number>;
1707
- bumpResourceVersion: (resource: string) => Promise<void>;
1708
- };
1709
- }
1710
- /**
1711
- * Request context passed to controller handlers.
1712
- *
1713
- * **Generic parameters** (all default to safe permissive types so existing code keeps working):
1714
- * - `TBody` — request body shape (default: `unknown`)
1715
- * - `TParams` — route param shape (default: `Record<string, string>`)
1716
- * - `TQuery` — query string shape (default: `Record<string, unknown>`)
1717
- * - `TUser` — authenticated user shape (default: `UserBase`)
1718
- * - `TMetadata` — internal metadata shape (default: `Record<string, unknown>`;
1719
- * override with `ArcInternalMetadata` or your own augmentation when you
1720
- * need typed access to `_scope`, `_policyFilters`, custom hook context, etc.)
1721
- *
1722
- * @example
1723
- * ```typescript
1724
- * // Untyped (default) — req.body is `unknown`, must be narrowed
1725
- * async create(req: IRequestContext) {
1726
- * const data = req.body as Partial<Product>;
1727
- * return { success: true, data: await productRepo.create(data) };
1728
- * }
1729
- *
1730
- * // Typed body — req.body is `CreateProductInput`, narrowing not needed
1731
- * async create(req: IRequestContext<CreateProductInput>) {
1732
- * return { success: true, data: await productRepo.create(req.body) };
1733
- * }
1734
- *
1735
- * // Fully typed — body, route params, query, and metadata
1736
- * async update(
1737
- * req: IRequestContext<
1738
- * Partial<Product>,
1739
- * { id: string },
1740
- * { fields?: string },
1741
- * ArcInternalMetadata
1742
- * >,
1743
- * ) {
1744
- * const fields = req.query.fields?.split(',');
1745
- * const orgId = req.metadata?._scope ? getOrgId(req.metadata._scope) : undefined;
1746
- * return { success: true, data: await productRepo.update(req.params.id, req.body) };
1747
- * }
1748
- * ```
1749
- */
1750
- /**
1751
- * First-class projection of `request._scope` for controller handlers.
1752
- *
1753
- * **v2.10.6:** previously, pulling tenant/user/role info from inside a
1754
- * controller override meant digging through `req.metadata._scope` and
1755
- * calling `getOrgId(scope)` / `getUserId(user)` manually. This projection
1756
- * lifts the two fields most hosts need directly onto `req` so cross-kit
1757
- * controller code reads:
1758
- *
1759
- * ```ts
1760
- * async create(req: IRequestContext) {
1761
- * const flowCtx = { organizationId: req.scope?.organizationId, actorId: req.scope?.userId };
1762
- * }
1763
- * ```
1764
- *
1765
- * Full scope shape (discriminated union of `member` / `service` / `elevated` / `public`)
1766
- * still lives on `req.metadata._scope` for code that needs to branch on
1767
- * `scope.kind` — this projection just surfaces the two keys every
1768
- * tenant-scoped resource reaches for.
1769
- */
1770
- interface RequestScopeProjection {
1771
- /** Tenant the caller is scoped to (org member, service key bound to an org, or elevated admin's target org). */
1772
- organizationId?: string;
1773
- /** Caller's user id when authenticated — undefined for public / service-only scopes. */
1774
- userId?: string;
1775
- /** Org-level roles (e.g. `['admin', 'warehouse-manager']`) — separate from global `user.roles`. */
1776
- orgRoles?: string[];
1777
- }
1778
- interface IRequestContext<TBody = unknown, TParams extends Record<string, string> = Record<string, string>, TQuery extends Record<string, unknown> = Record<string, unknown>, TUser extends UserBase = UserBase, TMetadata extends Record<string, unknown> = Record<string, unknown>> {
1779
- /** Route parameters (e.g., { id: '123' }) */
1780
- params: TParams;
1781
- /** Query string parameters */
1782
- query: TQuery;
1783
- /** Request body */
1784
- body: TBody;
1785
- /** Authenticated user or null */
1786
- user: TUser | null;
1787
- /** Request headers */
1788
- headers: Record<string, string | undefined>;
1789
- /** Organization ID (for multi-tenant apps) */
1790
- organizationId?: string;
1791
- /** Team ID (for team-scoped resources) */
1792
- teamId?: string;
1793
- /**
1794
- * First-class tenant/user scope projection — lifted from `metadata._scope`
1795
- * so controller overrides don't have to dig through Arc internals.
1796
- * See {@link RequestScopeProjection}. `undefined` for routes that run
1797
- * without auth / scope resolution.
1798
- */
1799
- scope?: RequestScopeProjection;
1800
- /**
1801
- * Organization/auth context from middleware.
1802
- * Contains orgRoles, orgScope, organizationId, and any custom fields
1803
- * set by the auth adapter or org-scope plugin.
1804
- *
1805
- * @example
1806
- * ```typescript
1807
- * async create(req: IRequestContext) {
1808
- * const roles = req.context?.orgRoles ?? [];
1809
- * if (roles.includes('manager')) { ... }
1810
- * }
1811
- * ```
1812
- */
1813
- context?: RequestContext;
1814
- /**
1815
- * Internal metadata (includes context + Arc internals like `_policyFilters`,
1816
- * `_scope`, `log`). Type as `ArcInternalMetadata` for typed access to Arc's
1817
- * built-in fields, or supply your own interface to layer custom fields.
1818
- */
1819
- metadata?: TMetadata;
1820
- /**
1821
- * Fastify server accessor — publish events, log, and audit
1822
- * from any handler without switching to `raw: true`.
1823
- *
1824
- * @example
1825
- * ```typescript
1826
- * async reschedule(req: IRequestContext) {
1827
- * const result = await repo.reschedule(req.params.id, req.body);
1828
- * await req.server?.events?.publish('interview.rescheduled', { data: result });
1829
- * return { data: result };
1830
- * }
1831
- * ```
1832
- */
1833
- server?: ServerAccessor;
1834
- }
1835
- /**
1836
- * Controller response shape — the success-path return from any handler.
1837
- *
1838
- * Errors throw `ArcError` (or any `HttpError`-shaped class); the global
1839
- * error handler catches them and emits an `ErrorContract`. There is no
1840
- * `success` discriminator on the response — HTTP status is the wire
1841
- * discriminator (2xx = data, 4xx/5xx = ErrorContract).
1842
- */
1843
- interface IControllerResponse<T = unknown> {
1844
- /** Response payload — emitted directly to the wire (no envelope wrap). */
1845
- data: T;
1846
- /** HTTP status code. Defaults to 200. */
1847
- status?: number;
1848
- /** Custom response headers (e.g. X-Total-Count, Link, ETag). */
1849
- headers?: Record<string, string>;
1850
- /** Top-level metadata merged into list-shaped responses (e.g. `{ took }`). */
1851
- meta?: Record<string, unknown>;
1852
- }
1853
- /**
1854
- * Controller handler — Arc's standard pattern.
1855
- *
1856
- * Receives a request context object, returns IControllerResponse.
1857
- * Use with `raw: false` in routes.
1858
- *
1859
- * **Generic parameters:**
1860
- * - `TResponse` — shape of `IControllerResponse.data` (default: `unknown`)
1861
- * - `TBody` — shape of `req.body` (default: `unknown`)
1862
- * - `TParams` — shape of `req.params` (default: `Record<string, string>`)
1863
- * - `TQuery` — shape of `req.query` (default: `Record<string, unknown>`)
1864
- *
1865
- * Backward-compatible: `ControllerHandler<Product>` still works (only the
1866
- * response data is typed); add more generics as needed when you want
1867
- * type-safe access to the request body, params, or query string.
1868
- *
1869
- * @example
1870
- * ```typescript
1871
- * // Untyped req — body is unknown, must be narrowed
1872
- * const createProduct: ControllerHandler<Product> = async (req) => {
1873
- * const product = await productRepo.create(req.body as Partial<Product>);
1874
- * return { data: product, status: 201 };
1875
- * };
1876
- *
1877
- * // Fully typed — body, params, query, and response all inferred
1878
- * const updateProduct: ControllerHandler<
1879
- * Product,
1880
- * Partial<Product>,
1881
- * { id: string },
1882
- * { upsert?: string }
1883
- * > = async (req) => {
1884
- * const upsert = req.query.upsert === "true";
1885
- * const product = await productRepo.update(req.params.id, req.body, { upsert });
1886
- * return { data: product };
1887
- * };
1888
- *
1889
- * routes: [{
1890
- * method: 'POST',
1891
- * path: '/products',
1892
- * handler: createProduct,
1893
- * permissions: requireAuth(),
1894
- * raw: false, // Arc wraps this into Fastify handler
1895
- * }]
1896
- * ```
1897
- */
1898
- type ControllerHandler<TResponse = unknown, TBody = unknown, TParams extends Record<string, string> = Record<string, string>, TQuery extends Record<string, unknown> = Record<string, unknown>> = (req: IRequestContext<TBody, TParams, TQuery>) => Promise<IControllerResponse<TResponse>>;
1899
- /**
1900
- * Fastify native handler
1901
- *
1902
- * Standard Fastify request/reply pattern.
1903
- * Use with `raw: true` in routes.
1904
- *
1905
- * @example
1906
- * ```typescript
1907
- * const downloadFile: FastifyHandler = async (request, reply) => {
1908
- * const file = await getFile(request.params.id);
1909
- * reply.header('Content-Type', file.mimeType);
1910
- * return reply.send(file.buffer);
1911
- * };
1912
- *
1913
- * routes: [{
1914
- * method: 'GET',
1915
- * path: '/files/:id/download',
1916
- * handler: downloadFile,
1917
- * permissions: requireAuth(),
1918
- * raw: true, // Use as-is, no wrapping
1919
- * }]
1920
- * ```
1921
- */
1922
- type FastifyHandler<RouteGeneric extends Record<string, unknown> = Record<string, unknown>> = (request: FastifyRequest<RouteGeneric>, reply: FastifyReply) => Promise<unknown> | unknown;
1923
- /**
1924
- * Union type for route handlers
1925
- */
1926
- type RouteHandler = ControllerHandler | FastifyHandler;
1927
- /**
1928
- * Controller interface for CRUD operations (strict).
1929
- *
1930
- * `list`'s return type aligns with repo-core's `MinimalRepo.getAll()`
1931
- * contract — the kit MAY return an offset envelope, a keyset envelope,
1932
- * or a raw array. Arc's `BaseController` forwards the kit's response
1933
- * verbatim; consumers narrow on shape
1934
- * (`Array.isArray(data)` → bare array, presence of `total` → offset,
1935
- * presence of `nextCursor` → keyset).
1936
- */
1937
- interface IController<TDoc = unknown> {
1938
- list(req: IRequestContext): Promise<IControllerResponse<unknown>>;
1939
- get(req: IRequestContext): Promise<IControllerResponse<TDoc>>;
1940
- create(req: IRequestContext): Promise<IControllerResponse<TDoc>>;
1941
- update(req: IRequestContext): Promise<IControllerResponse<TDoc>>;
1942
- delete(req: IRequestContext): Promise<IControllerResponse<{
1943
- message: string;
1944
- }>>;
1945
- }
1946
- /**
1947
- * Flexible controller interface — accepts controllers with any handler style,
1948
- * including class instances with extra methods / private fields.
1949
- *
1950
- * **v2.10.6:** the previous `[key: string]: unknown` index signature made
1951
- * real class instances fail structural assignment (`new ScrapController()`
1952
- * needed a `as unknown as ControllerLike` cast, because class instances
1953
- * don't have an index signature). Dropped — arc only invokes the CRUD
1954
- * methods at runtime, so the rest of the shape is the caller's concern.
1955
- *
1956
- * The five CRUD slots stay optional so partial controllers (e.g. read-only)
1957
- * assign too. Additional domain methods on the controller are allowed by
1958
- * construction (they're just not part of this contract).
1959
- */
1960
- interface ControllerLike {
1961
- list?: unknown;
1962
- get?: unknown;
1963
- create?: unknown;
1964
- update?: unknown;
1965
- delete?: unknown;
1876
+ readonly mcp?: boolean | RouteMcpConfig;
1966
1877
  }
1878
+ /** Action config: bare handler function OR full ActionDefinition. */
1879
+ type ActionEntry = ActionHandlerFn | ActionDefinition;
1880
+ /** Actions configuration map. */
1881
+ type ActionsMap = Record<string, ActionEntry>;
1967
1882
  //#endregion
1968
- //#region src/types/resource.d.ts
1969
- /** Standard controller type alias for CRUD operations. */
1970
- type CrudController<TDoc> = IController<TDoc>;
1971
- /**
1972
- * Tenant-cleanup declaration on a resource — compliance-grade strategy
1973
- * for what happens to this resource's rows when the owning organization
1974
- * is deleted. Surfaces in arc's org-delete cascade runner and in
1975
- * registry introspection (audit scripts ask "what happens to this
1976
- * resource on org delete?").
1977
- *
1978
- * Strategy variants:
1979
- * - `hard` — permanent removal (GDPR right-to-be-forgotten).
1980
- * - `soft` — recoverable; pair with TTL for eventual hard-purge.
1981
- * - `anonymize` — keep the row (legal retention) but clear PII
1982
- * (HIPAA / PCI / SOX-compatible).
1983
- * - `skip` — explicit opt-out with mandatory `reason`.
1984
- *
1985
- * See {@link ResourceConfig.onTenantDelete} for the full decision tree.
1986
- */
1987
- interface OnTenantDeleteConfig {
1988
- /**
1989
- * What to do with rows whose `tenantField` matches the deleted org.
1990
- * Discriminated union from `@classytic/repo-core/repository` — every
1991
- * kit's `purgeByField` consumes the same shape.
1992
- */
1993
- strategy: TenantPurgeStrategy;
1994
- /**
1995
- * Resources are processed in ascending `priority` order. Use to land
1996
- * leaf data (logs, events) before aggregate references. Default `100`.
1997
- */
1998
- priority?: number;
1999
- /**
2000
- * Rows per chunk for the underlying `purgeByField` call — bounds
2001
- * lock contention + replication-log pressure on very large tenants.
2002
- * Default kit-specific (~1000).
2003
- */
2004
- batchSize?: number;
2005
- }
1883
+ //#region src/types/resource/cache.d.ts
2006
1884
  /**
2007
- * Resolved tenant-purge declaration — what arc actually runs when
2008
- * `cascadeDeleteForOrganization` fires. Computed once at boot from the
2009
- * resource's `onTenantDelete` declaration.
2010
- *
2011
- * Exposed via `ResourceDefinition.resolvedTenantPurge` and
2012
- * `getCascadingResourcesWithMetadata(registry)` so audit tooling can
2013
- * answer "is this resource really going to hard-delete?" without
2014
- * reading the source.
2015
- */
2016
- interface ResolvedTenantPurge {
2017
- readonly strategy: TenantPurgeStrategy;
2018
- readonly priority: number;
2019
- readonly batchSize?: number;
2020
- /**
2021
- * Where the strategy came from — `'declared'` (host wrote
2022
- * `onTenantDelete` explicitly) or `'disabled'` (no declaration —
2023
- * runner skips this resource).
2024
- */
2025
- readonly source: "declared" | "disabled";
2026
- }
1885
+ * Per-resource QueryCache configuration — stale-while-revalidate
1886
+ * windows, per-operation overrides, and tag-based cross-resource
1887
+ * invalidation.
1888
+ */
2027
1889
  /**
2028
1890
  * Per-resource cache configuration for QueryCache. Enables
2029
1891
  * stale-while-revalidate, auto-invalidation on mutations, and
@@ -2053,531 +1915,461 @@ interface ResourceCacheConfig {
2053
1915
  /** Disable caching for this resource */
2054
1916
  disabled?: boolean;
2055
1917
  }
2056
- interface RateLimitConfig {
2057
- /** Maximum number of requests allowed within the time window */
2058
- max: number;
2059
- /** Time window for rate limiting (e.g., '1 minute', '15 seconds') */
2060
- timeWindow: string;
1918
+ //#endregion
1919
+ //#region src/pipeline/types.d.ts
1920
+ /**
1921
+ * Pipeline context passed to guards, transforms, and interceptors.
1922
+ * Extends IRequestContext with pipeline-specific metadata.
1923
+ */
1924
+ interface PipelineContext extends IRequestContext {
1925
+ /** Resource name being accessed */
1926
+ resource: string;
1927
+ /** CRUD operation being performed */
1928
+ operation: "list" | "get" | "create" | "update" | "delete" | string;
2061
1929
  }
2062
1930
  /**
2063
- * Per-field rule — arc's extension of repo-core's 4-field `FieldRule` floor
2064
- * (`immutable`, `immutableAfterCreate`, `systemManaged`, `optional`) with
2065
- * the constraint / UI / security bits arc layers on top.
2066
- *
2067
- * Kept structurally compatible with `@classytic/repo-core/schema`'s
2068
- * `FieldRule` so arc's `fieldRules: Record<string, ArcFieldRule>` flows
2069
- * into mongokit's / sqlitekit's `buildCrudSchemasFromModel(..., options)`
2070
- * without a cast. See `RouteSchemaOptions` JSDoc for the full rationale.
1931
+ * Which operations a pipeline step applies to.
1932
+ * If omitted, applies to ALL operations.
2071
1933
  */
2072
- interface ArcFieldRule extends FieldRule {
2073
- /**
2074
- * When `true`, bypass the `systemManaged` / `readonly` / `immutable`
2075
- * strip in `BodySanitizer` for callers whose request scope is
2076
- * `elevated`. Lets platform admins stamp the value from the request
2077
- * body — needed for cross-tenant admin writes where the tenant field
2078
- * is the only way to pick a target org.
2079
- *
2080
- * Auto-set by `defineResource` on the configured `tenantField`. Hosts
2081
- * can set it manually on other fields (e.g. `createdBy`) if they want
2082
- * elevation-only override semantics for those too.
2083
- *
2084
- * Has no effect when `isElevated(scope)` is false — member and
2085
- * service callers continue to have the field stripped.
2086
- */
2087
- preserveForElevated?: boolean;
2088
- hidden?: boolean;
2089
- /**
2090
- * Aggregation visibility override. By default, only `hidden: true`
2091
- * blocks a field from `groupBy` / `measures.field` / `sort` / `dateBuckets`
2092
- * — that's the genuine cardinality-leak guard (the value is omitted from
2093
- * list/get responses, so exposing it via aggregation would reveal data
2094
- * the client can't otherwise see).
2095
- *
2096
- * `systemManaged: true` does **not** block aggregation — it's a write
2097
- * rule, not a visibility rule. Server-stamped fields like `createdAt`,
2098
- * `status`, or plugin-generated handles are visible in every list
2099
- * response and should aggregate freely.
2100
- *
2101
- * Use this flag to override the default:
2102
- *
2103
- * - `aggregable: false` — explicit deny, even on visible fields. Useful
2104
- * when a value is exposed per-row but the cardinality across rows is
2105
- * itself sensitive (e.g. `email` is visible in `get/:id` to admins
2106
- * but you don't want a public-readable agg of email distributions).
2107
- * - `aggregable: true` — escape hatch on `hidden` fields. Lets you
2108
- * aggregate a hidden column when you're sure cardinality leakage
2109
- * isn't a concern (e.g. `internalScore` hidden from list, but a
2110
- * committee-only `byScore` agg is fine).
2111
- *
2112
- * Defaults to `undefined` (use the `hidden`-only rule).
2113
- */
2114
- aggregable?: boolean;
2115
- /** String minimum length — auto-maps to OpenAPI `minLength` and MCP tool schema */
2116
- minLength?: number;
2117
- /** String maximum length — auto-maps to OpenAPI `maxLength` and MCP tool schema */
2118
- maxLength?: number;
2119
- /** Number minimum — auto-maps to OpenAPI `minimum` and MCP tool schema */
2120
- min?: number;
2121
- /** Number maximum — auto-maps to OpenAPI `maximum` and MCP tool schema */
2122
- max?: number;
2123
- /** Regex pattern — auto-maps to OpenAPI `pattern` and MCP tool schema */
2124
- pattern?: string;
2125
- /** Allowed values — auto-maps to OpenAPI `enum` and MCP tool schema */
2126
- enum?: ReadonlyArray<string | number>;
2127
- /**
2128
- * When `true`, widen the JSON Schema `type` of this field to also
2129
- * accept `null`. Mirrors Zod's `.nullable()` at the arc config layer
2130
- * for kit-generated schemas that don't carry the flag end-to-end
2131
- * (e.g. Zod → Mongoose → mongokit drops `.nullable()` because
2132
- * Mongoose has no first-class nullable marker unless `default: null`
2133
- * is also set).
2134
- *
2135
- * Applied post-kit by `mergeFieldRuleConstraints`: if the adapter
2136
- * emitted `{ type: 'string', enum: [...] }` for a field arc should
2137
- * accept null for, the merge widens it to
2138
- * `{ type: ['string', 'null'], enum: [...] }` — draft-7 tuple form
2139
- * AJV 8 validates natively.
2140
- *
2141
- * No-op when the property already declares `type: [...,'null']` or
2142
- * an `anyOf: [..., { type: 'null' }]` branch — arc never fights the
2143
- * kit's own output.
2144
- */
2145
- nullable?: boolean;
2146
- /** Human-readable description — auto-maps to OpenAPI `description` */
2147
- description?: string;
2148
- [key: string]: unknown;
1934
+ type OperationFilter = Array<"list" | "get" | "create" | "update" | "delete" | string>;
1935
+ /**
1936
+ * Guard — boolean check that short-circuits on failure.
1937
+ * Return true to proceed, throw to deny.
1938
+ */
1939
+ interface Guard {
1940
+ readonly _type: "guard";
1941
+ readonly name: string;
1942
+ readonly operations?: OperationFilter;
1943
+ handler(ctx: PipelineContext): boolean | Promise<boolean>;
2149
1944
  }
2150
1945
  /**
2151
- * Schema-shaping options for a resource.
2152
- *
2153
- * Extends `@classytic/repo-core/schema`'s `SchemaBuilderOptions` so every
2154
- * kit-generator callback typed against the repo-core contract
2155
- * (mongokit's `buildCrudSchemasFromModel`, sqlitekit's
2156
- * `buildCrudSchemasFromTable`, prismakit's equivalent) accepts arc's
2157
- * options bag directly — no `as SchemaBuilderOptions` / `Parameters<...>[1]`
2158
- * cast at the host wiring site.
1946
+ * Transform — modifies request data before the handler.
1947
+ * Returns modified context (or mutates in place).
1948
+ */
1949
+ interface Transform {
1950
+ readonly _type: "transform";
1951
+ readonly name: string;
1952
+ readonly operations?: OperationFilter;
1953
+ handler(ctx: PipelineContext): PipelineContext | undefined | Promise<PipelineContext | undefined>;
1954
+ }
1955
+ /**
1956
+ * Next function passed to interceptors — calls the handler (or next interceptor).
1957
+ */
1958
+ type NextFunction = () => Promise<IControllerResponse<unknown>>;
1959
+ /**
1960
+ * Intercept — wraps handler execution (before + after pattern).
1961
+ */
1962
+ interface Interceptor {
1963
+ readonly _type: "interceptor";
1964
+ readonly name: string;
1965
+ readonly operations?: OperationFilter;
1966
+ handler(ctx: PipelineContext, next: NextFunction): Promise<IControllerResponse<unknown>>;
1967
+ }
1968
+ type PipelineStep = Guard | Transform | Interceptor;
1969
+ /**
1970
+ * Pipeline configuration — can be a flat array or per-operation map.
1971
+ */
1972
+ type PipelineConfig = PipelineStep[] | {
1973
+ list?: PipelineStep[];
1974
+ get?: PipelineStep[];
1975
+ create?: PipelineStep[];
1976
+ update?: PipelineStep[];
1977
+ delete?: PipelineStep[];
1978
+ [operation: string]: PipelineStep[] | undefined;
1979
+ };
1980
+ //#endregion
1981
+ //#region src/core/aggregation/types.d.ts
1982
+ /**
1983
+ * Sugar for measures: `'count'` / `'count:field'` / `'sum:price'` /
1984
+ * `'avg:rating'` / `'min:created'` / `'max:updated'` /
1985
+ * `'countDistinct:userId'` / `'percentile:latency:0.95'`.
2159
1986
  *
2160
- * Inherited from `SchemaBuilderOptions`:
2161
- * - `strictAdditionalProperties` — emit `additionalProperties: false`
2162
- * - `dateAs` — `'date'` vs `'datetime'` ISO rendering
2163
- * - `softRequiredFields` — stay in `properties`, drop from `required[]`
2164
- * - `create: { omitFields, requiredOverrides, optionalOverrides, schemaOverrides }`
2165
- * - `update: { omitFields, requireAtLeastOne }`
2166
- * - `query: { filterableFields }` (kit-native filter declaration)
2167
- * - `openApiExtensions` — emit `x-*` vendor keywords for docgen
1987
+ * Compiles to the canonical `AggMeasure` IR at boot. Hosts who want
1988
+ * the full IR can pass an `AggMeasure` object directly.
2168
1989
  *
2169
- * Arc adds:
2170
- * - `fieldRules` with the richer `ArcFieldRule` per-entry shape
2171
- * (preserveForElevated, minLength/maxLength/min/max/pattern, enum,
2172
- * nullable, description) — arc's extensions are applied post-kit by
2173
- * `mergeFieldRuleConstraints`; the kit only sees the repo-core floor.
2174
- * - `hiddenFields` / `readonlyFields` / `requiredFields` / `optionalFields`
2175
- * / `excludeFields` — arc-only convenience lists that predate fieldRules.
2176
- * Keep using them if they're already in place; new code should prefer
2177
- * `fieldRules` for per-field control.
2178
- * - `filterableFields: string[]` — top-level list arc's MCP layer auto-
2179
- * derives from `QueryParser.allowedFilterFields`. Distinct from the
2180
- * inherited `query.filterableFields: Record<...>` which feeds the kit's
2181
- * list-query schema; nothing stops a resource from using both.
1990
+ * **Percentile.** `'percentile:<field>:<p>'` where `p` is a numeric
1991
+ * literal in `[0, 1]` (e.g. `'percentile:latency:0.95'` for P95).
1992
+ * Mongokit (≥3.13) compiles to `$percentile`; sqlitekit throws
1993
+ * `UnsupportedOperationError` (no native percentile in SQLite).
1994
+ */
1995
+ type AggMeasureShorthand = "count" | `count:${string}` | `countDistinct:${string}` | `sum:${string}` | `avg:${string}` | `min:${string}` | `max:${string}` | `percentile:${string}:${number}`;
1996
+ /** Either canonical IR or shorthand string — both compile to `AggMeasure`. */
1997
+ type AggMeasureInput = AggMeasure | AggMeasureShorthand;
1998
+ /**
1999
+ * Cache config for an aggregation. Translates directly to the kit-side
2000
+ * `CacheOptions` (TanStack-shaped) which the unified
2001
+ * `@classytic/repo-core/cache` plugin reads from `req.cache`.
2182
2002
  *
2183
- * **Why extend rather than duplicate**: mongokit's
2184
- * `buildCrudSchemasFromModel(model, options: SchemaBuilderOptions)` is the
2185
- * canonical callback shape. Before the extension, hosts wrote
2186
- * `Parameters<typeof buildCrudSchemasFromModel>[1]` or
2187
- * `as SchemaBuilderOptions` at every wiring site — a defensive cast with
2188
- * no runtime effect. Extension locks the structural relationship at the
2189
- * type layer so the cast is compile-verified gone.
2003
+ * Caching only fires when the kit's repo has the `cachePlugin` wired —
2004
+ * arc declares the policy; the kit handles SWR + tag invalidation +
2005
+ * version-bump on writes. Hosts without the plugin installed silently
2006
+ * fall through to a non-cached call.
2190
2007
  */
2191
- interface RouteSchemaOptions extends SchemaBuilderOptions {
2192
- hiddenFields?: string[];
2193
- readonlyFields?: string[];
2194
- requiredFields?: string[];
2195
- optionalFields?: string[];
2196
- excludeFields?: string[];
2197
- /**
2198
- * Fields allowed for filtering in list operations. MCP auto-derives
2199
- * from `QueryParser.allowedFilterFields` when not set explicitly.
2200
- *
2201
- * Distinct from the inherited `query.filterableFields: Record<...>`
2202
- * from `SchemaBuilderOptions` — that entry feeds the kit's list-query
2203
- * JSON Schema; this one is arc's MCP-auto-derivation list.
2204
- */
2205
- filterableFields?: string[];
2206
- /**
2207
- * Per-field rules. Richer than repo-core's `FieldRules` — arc adds
2208
- * `preserveForElevated`, constraint hints (`minLength`, `enum`,
2209
- * `nullable`, etc.), and `description` on top of the four-flag floor
2210
- * (`immutable`, `immutableAfterCreate`, `systemManaged`, `optional`).
2211
- *
2212
- * Structurally compatible: `Record<string, ArcFieldRule>` is assignable
2213
- * to repo-core's `Record<string, FieldRule>` since `ArcFieldRule extends
2214
- * FieldRule`. Kits see only the floor; arc's extensions are applied
2215
- * post-kit by `mergeFieldRuleConstraints`.
2216
- */
2217
- fieldRules?: Record<string, ArcFieldRule>;
2218
- /**
2219
- * Query-time security whitelists + the kit's `filterableFields`.
2220
- *
2221
- * Extends repo-core's `SchemaBuilderOptions['query']` with arc-specific
2222
- * runtime features that `QueryResolver` reads at request time:
2223
- *
2224
- * - **`allowedPopulate`** — populate-path whitelist consumed by
2225
- * `QueryResolver.sanitizePopulate` / `sanitizeAdvancedPopulate`.
2226
- * When set, only paths in the list pass through; everything else is
2227
- * stripped silently. Shrinks the auto-wired `?populate=` attack surface.
2228
- *
2229
- * - **`allowedLookups`** — lookup-collection whitelist consumed by
2230
- * `QueryResolver.sanitizeLookups`. When set, only the listed
2231
- * collections may be `$lookup`'d into the pipeline.
2232
- *
2233
- * Both are pre-2.11.2 runtime features — the type was missing them, so
2234
- * hosts wrote `as Record<string, unknown>` at every call site. Arc's own
2235
- * `QueryResolver` had to cast its own input via `as AnyRecord` for the
2236
- * same reason. Adding the type dropped both casts.
2008
+ interface AggregationCacheConfig {
2009
+ /** Seconds the entry is fresh — no revalidation while inside this window. */
2010
+ staleTime?: number;
2011
+ /** Seconds the entry stays in cache past stale before eviction. Default: 60. */
2012
+ gcTime?: number;
2013
+ /**
2014
+ * Stale-while-revalidate. When stale entries serve immediately and a
2015
+ * background refresh updates the cache. Default: `true` for
2016
+ * aggregations (dashboards almost always benefit from stale-serve).
2237
2017
  */
2238
- query?: SchemaBuilderOptions["query"] & {
2239
- /**
2240
- * Populate-path whitelist. When set, `QueryResolver.sanitizePopulate`
2241
- * strips any `?populate=<path>` not in this list. Omit to allow all
2242
- * paths the kit recognizes.
2243
- */
2244
- allowedPopulate?: string[];
2245
- /**
2246
- * Lookup-collection whitelist for `QueryResolver.sanitizeLookups`.
2247
- * When set, only the listed collections may be `$lookup`'d. Omit to
2248
- * disable the whitelist (kit-level rules still apply).
2249
- */
2250
- allowedLookups?: string[];
2251
- };
2252
- }
2253
- interface FieldRule$1 {
2254
- field: string;
2255
- required?: boolean;
2256
- readonly?: boolean;
2257
- hidden?: boolean;
2018
+ swr?: boolean;
2019
+ /**
2020
+ * Group invalidation tags. Pass to `repo.cache?.invalidateByTags(tags)`
2021
+ * after a write to clear matching entries. The model name is
2022
+ * auto-tagged by the plugin — you only declare cross-cutting tags
2023
+ * (e.g. `'pricing'` to invalidate every aggregation that depends on
2024
+ * pricing across multiple resources).
2025
+ */
2026
+ tags?: readonly string[];
2258
2027
  }
2259
2028
  /**
2260
- * CRUD route schemas (Fastify native format). Each slot accepts a plain
2261
- * JSON Schema object **or** a Zod v4 schema — Arc's `convertRouteSchema`
2262
- * feature-detects at runtime. Slot values are typed `unknown` so
2263
- * class-based Zod schemas assign without casts.
2029
+ * Per-aggregation rate limit. Layers on top of any global rate limit
2030
+ * via `@fastify/rate-limit` route-level config.
2264
2031
  */
2265
- interface CrudSchemas {
2266
- /** GET / — list */
2267
- list?: {
2268
- querystring?: unknown;
2269
- response?: Record<number, unknown>;
2270
- [key: string]: unknown;
2271
- };
2272
- /** GET /:id — get one */
2273
- get?: {
2274
- params?: unknown;
2275
- response?: Record<number, unknown>;
2276
- [key: string]: unknown;
2277
- };
2278
- /** POST / — create */
2279
- create?: {
2280
- body?: unknown;
2281
- response?: Record<number, unknown>;
2282
- [key: string]: unknown;
2283
- };
2284
- /** PATCH /:id — update */
2285
- update?: {
2286
- params?: unknown;
2287
- body?: unknown;
2288
- response?: Record<number, unknown>;
2289
- [key: string]: unknown;
2290
- };
2291
- /** DELETE /:id — delete */
2292
- delete?: {
2293
- params?: unknown;
2294
- response?: Record<number, unknown>;
2295
- [key: string]: unknown;
2296
- };
2297
- [key: string]: unknown;
2032
+ interface AggregationRateLimit {
2033
+ /** Max requests per window. */
2034
+ max: number;
2035
+ /** Window in milliseconds. */
2036
+ windowMs: number;
2298
2037
  }
2299
- interface OpenApiSchemas {
2300
- entity?: unknown;
2301
- createBody?: unknown;
2302
- updateBody?: unknown;
2303
- params?: unknown;
2304
- listQuery?: unknown;
2038
+ /**
2039
+ * Required date-range narrowing. Caller MUST send a bounded range on
2040
+ * this field, and the range MUST NOT exceed `maxRangeDays`.
2041
+ *
2042
+ * Prevents "all-time" scans on billion-row collections — the single
2043
+ * biggest performance footgun for live aggregation endpoints.
2044
+ */
2045
+ interface AggregationDateRangeRequirement {
2046
+ /** Field whose range the caller must narrow (e.g. `'createdAt'`). */
2047
+ field: string;
2305
2048
  /**
2306
- * Explicit response schema for OpenAPI documentation. Auto-generated
2307
- * from `createBody` if omitted. Does NOT affect Fastify serialization.
2049
+ * Cap on the queryable range. A request asking for >N days is
2050
+ * rejected 400. Omit for "any range, but bounded" (lower + upper
2051
+ * required, no cap).
2308
2052
  */
2309
- response?: unknown;
2310
- [key: string]: unknown;
2311
- }
2312
- type CrudRouteKey = "list" | "get" | "create" | "update" | "delete";
2313
- interface MiddlewareConfig {
2314
- list?: MiddlewareHandler[];
2315
- get?: MiddlewareHandler[];
2316
- create?: MiddlewareHandler[];
2317
- update?: MiddlewareHandler[];
2318
- delete?: MiddlewareHandler[];
2319
- [key: string]: MiddlewareHandler[] | undefined;
2053
+ maxRangeDays?: number;
2320
2054
  }
2321
- /** HTTP methods for custom routes. */
2322
- type RouteMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
2323
- /** MCP tool configuration for a route or action. */
2324
- interface RouteMcpConfig {
2325
- /** Override auto-generated tool description */
2326
- readonly description?: string;
2327
- /** MCP tool annotations */
2328
- readonly annotations?: {
2329
- readonly readOnlyHint?: boolean;
2330
- readonly destructiveHint?: boolean;
2331
- readonly idempotentHint?: boolean;
2332
- readonly openWorldHint?: boolean;
2333
- };
2055
+ /**
2056
+ * Boot-time index hint. Arc warns when the kit's schema doesn't have
2057
+ * an index whose leading keys match — flags the misconfig before
2058
+ * traffic hits the DB.
2059
+ *
2060
+ * Documented intent, NOT runtime-enforced. Kits with their own index
2061
+ * introspection (mongokit reads Mongoose schema, sqlitekit reads
2062
+ * Drizzle indexes) can act on the hint; kits without introspection
2063
+ * silently accept it.
2064
+ */
2065
+ interface AggregationIndexHint {
2066
+ /** Leading-key columns the host expects the planner to use. */
2067
+ leadingKeys: readonly string[];
2334
2068
  }
2335
2069
  /**
2336
- * Route definition — single custom-route shape (user-facing + internal).
2070
+ * Runtime context passed to the `materialized` hook.
2337
2071
  *
2338
- * - `handler: 'string'` → controller method → full Arc pipeline + MCP tool
2339
- * - `handler: function` → inline handler → full Arc pipeline + MCP tool
2340
- * - `raw: true` → raw Fastify handler → no pipeline, no MCP by default
2072
+ * The hook returns pre-computed data instead of running the live
2073
+ * aggregation. Hosts use this for ultra-frequent dashboards backed by
2074
+ * rollup tables maintained out-of-band (cron / CDC).
2341
2075
  */
2342
- interface RouteDefinition {
2343
- readonly method: RouteMethod;
2344
- /** Path relative to resource prefix */
2345
- readonly path: string;
2076
+ interface AggregationMaterializedContext {
2077
+ /** Compiled filter (host base + tenant + caller). */
2078
+ filter: AnyRecord;
2079
+ /** Tenant id when the resource is tenant-scoped. */
2080
+ orgId?: string;
2081
+ /** Authenticated user id, when present. */
2082
+ userId?: string;
2083
+ /** Fastify request id for tracing. */
2084
+ requestId?: string;
2085
+ /** Raw URL query params (post-validation). */
2086
+ query: Record<string, unknown>;
2087
+ }
2088
+ /** Materialized hook return shape — same envelope as `AggResult`. */
2089
+ interface AggregationMaterializedResult<TRow = AnyRecord> {
2090
+ rows: readonly TRow[];
2091
+ }
2092
+ /**
2093
+ * Single named aggregation declaration. Composes into `AggRequest` at
2094
+ * request time, with safety knobs layered on at the arc-handler level.
2095
+ */
2096
+ interface AggregationConfig {
2346
2097
  /**
2347
- * Route handler.
2348
- * - String: controller method name (Arc pipeline) — runtime lookup, no TS coverage on typos.
2349
- * - Function without `raw: true`: receives IRequestContext, returns IControllerResponse (Arc pipeline)
2350
- * - Function with `raw: true`: raw Fastify handler `(request, reply)`
2351
- *
2352
- * Prefer `controllerMethod` (2.16) over the string form when you want the
2353
- * compiler to catch typos. `handler` stays optional when `controllerMethod`
2354
- * is set — exactly one of the two must be declared.
2098
+ * Pre-aggregate filter on the BASE rows (before lookups). Always
2099
+ * ANDed with auto-injected tenant scope + caller URL-narrowing
2100
+ * filters. Use for host-defined invariants (e.g. `archived: false`).
2355
2101
  */
2356
- readonly handler?: string | ControllerHandler | RouteHandlerMethod | ((request: FastifyRequest<Record<string, unknown>>, reply: FastifyReply) => unknown);
2102
+ filter?: AnyRecord;
2357
2103
  /**
2358
- * Typed function-reference handler (2.16). Receives the live controller
2359
- * instance and returns the method to invoke — TypeScript catches typos
2360
- * and surfaces autocomplete on the controller's method names. Prefer
2361
- * this over the string `handler` form for any host that uses a typed
2362
- * controller subclass.
2104
+ * Cross-table joins. Each `LookupSpec` reuses the IR
2105
+ * `@classytic/repo-core/lookup` defines for `lookupPopulate()`.
2106
+ * Same compile path the kit already ships.
2363
2107
  *
2364
- * Resolved ONCE at route-registration time (the same path string
2365
- * handlers go through), so there's no per-request lookup overhead.
2366
- * The returned method is bound to the controller before dispatch.
2108
+ * **Kit support is incremental.** A kit's `aggregate()` may not yet
2109
+ * compile lookups — boot validation against the adapter version
2110
+ * surfaces this loud, so hosts pin the kit major they need.
2111
+ */
2112
+ lookups?: readonly LookupSpec[];
2113
+ /**
2114
+ * Group key(s). Dotted paths into joined aliases supported when
2115
+ * `lookups` is set: `'category.parent'` groups by the joined
2116
+ * `category` row's `parent` field.
2117
+ */
2118
+ groupBy?: string | readonly string[];
2119
+ /**
2120
+ * Time-bucket group keys for time-series aggregations. Each entry
2121
+ * promotes a date column into a synthetic group key bucketed at
2122
+ * the chosen interval. The map key becomes a column on the output
2123
+ * row holding the canonical ISO-shaped bucket label
2124
+ * (`'2026-04'` for month, `'2026-W15'` for ISO week, etc.).
2367
2125
  *
2368
- * Mutually exclusive with `handler` — passing both throws at boot
2369
- * with a clear "pick one" message. The runtime contract on what the
2370
- * returned function accepts mirrors the `handler` rules: a plain
2371
- * `ControllerHandler` for pipeline routes, a raw Fastify handler when
2372
- * the route is `raw: true`.
2126
+ * Bucketed keys participate in grouping the same way `groupBy`
2127
+ * columns do — `sort: { month: 1 }`, `having`, pagination, and
2128
+ * `topN.partitionBy` all treat them as first-class.
2373
2129
  *
2374
- * Typed loosely (`(controller: unknown) => …`) at the type-system
2375
- * boundary because `RouteDefinition` is not generic over the
2376
- * controller shape — host code annotates the parameter to opt in:
2130
+ * Aliases must NOT collide with a `groupBy` field name or measure
2131
+ * alias — boot validation throws on collision.
2377
2132
  *
2378
- * @example
2133
+ * @example "Daily revenue for the last quarter"
2379
2134
  * ```ts
2380
- * class PostController extends BaseController<Post> {
2381
- * async getStats(ctx: IRequestContext) { … }
2382
- * }
2135
+ * dailyRevenue: defineAggregation({
2136
+ * dateBuckets: { day: { field: 'createdAt', interval: 'day' } },
2137
+ * measures: { revenue: 'sum:totalPrice' },
2138
+ * sort: { day: 1 },
2139
+ * permissions: requireRoles(['admin']),
2140
+ * }),
2141
+ * ```
2383
2142
  *
2384
- * defineResource({
2385
- * controller: new PostController(repo),
2386
- * routes: [{
2387
- * method: 'GET',
2388
- * path: '/stats',
2389
- * controllerMethod: (c: PostController) => c.getStats,
2390
- * // ^^^^^^^^^^^^^^ TS catches typos here
2391
- * permissions: requireAuth(),
2392
- * }],
2393
- * });
2143
+ * @example "15-minute traffic buckets (custom-bin form)"
2144
+ * ```ts
2145
+ * traffic: defineAggregation({
2146
+ * dateBuckets: {
2147
+ * slot: { field: 'ts', interval: { every: 15, unit: 'minute' } },
2148
+ * },
2149
+ * measures: { hits: 'count' },
2150
+ * permissions: allowPublic(),
2151
+ * }),
2394
2152
  * ```
2395
2153
  */
2396
- readonly controllerMethod?: (controller: unknown) => ControllerHandler | RouteHandlerMethod;
2397
- /** Permission check — REQUIRED */
2398
- readonly permissions: PermissionCheck;
2154
+ dateBuckets?: Record<string, AggDateBucket>;
2155
+ /** Named aggregations. At least one entry required. */
2156
+ measures: Record<string, AggMeasureInput>;
2399
2157
  /**
2400
- * Raw mode — bypasses Arc pipeline. Handler receives raw Fastify
2401
- * request/reply. Default: false.
2158
+ * Post-aggregate filter referencing measure aliases.
2159
+ * Example: `{ revenue: { gt: 1000 } }` → `HAVING revenue > 1000`.
2402
2160
  */
2403
- readonly raw?: boolean;
2404
- /** Logical operation name (pipeline keys, MCP tool naming). */
2405
- readonly operation?: string;
2406
- /** OpenAPI summary */
2407
- readonly summary?: string;
2408
- /** OpenAPI description */
2409
- readonly description?: string;
2410
- /** OpenAPI tags */
2411
- readonly tags?: string[];
2412
- /** Route-level middleware */
2413
- readonly preHandler?: RouteHandlerMethod[] | ((fastify: FastifyInstance) => RouteHandlerMethod[]);
2414
- /** Pre-auth handlers (run before authentication) */
2415
- readonly preAuth?: RouteHandlerMethod[];
2161
+ having?: AnyRecord;
2162
+ /** Order grouped rows by groupBy field, measure alias, or joined-alias path. */
2163
+ sort?: Record<string, 1 | -1>;
2416
2164
  /**
2417
- * Opt this custom route into the multiTenant preset's tenant scoping.
2418
- *
2419
- * When `true` AND the resource declares `multiTenantPreset` (or
2420
- * `flexibleMultiTenantPreset`), arc prepends the preset's tenant
2421
- * filter + injection middleware to this route's chain — the same
2422
- * pair `update` gets. The handler can then read the resolved tenant
2423
- * via `getOrgId(req.scope)` / `req._tenantFields` (or, for write
2424
- * routes, off `req.body` after injection) without re-implementing
2425
- * the scope read + header fallback + 400 boilerplate.
2426
- *
2427
- * Throws at boot if set when no multiTenant preset is wired — the
2428
- * misconfiguration would otherwise be silently insecure (read routes
2429
- * returning every tenant's rows).
2430
- *
2431
- * @default false
2165
+ * Static hard cap on result rows. Always applied at the IR level
2166
+ * (LIMIT / `$limit`), regardless of URL params. Use when the
2167
+ * aggregation has a fixed-size shape ("top 10 categories"). Mutually
2168
+ * exclusive with `defaultLimit` / `maxLimit` — pick the static form
2169
+ * OR the URL-driven form, not both.
2432
2170
  */
2433
- readonly tenantScope?: boolean;
2171
+ limit?: number;
2434
2172
  /**
2435
- * SSE streaming mode. Two handler shapes work:
2173
+ * URL-driven row limit — applied when the caller passes `?limit=N`.
2174
+ * When set, the auto-route reads `query.limit`, parses it as a
2175
+ * positive integer, caps it at `maxLimit` (or the framework default
2176
+ * if `maxLimit` is unset), and writes the result into `AggRequest.limit`.
2177
+ * When the caller omits `?limit`, this default applies.
2436
2178
  *
2437
- * - Write to `reply.raw` directly (NDJSON, custom SSE — historical contract).
2438
- * - Return a Web `ReadableStream` (Vercel AI SDK's `result.toUIMessageStream()`,
2439
- * `fetch().body`, etc.). 2.17.1+ auto-pipes these through
2440
- * `pipeUIMessageStreamToReply()` so each chunk is JSON-encoded into
2441
- * an SSE `data:` frame and client-disconnect cancels the source.
2442
- * Pre-2.17.1 this crashed with `chunk must be a string or Buffer`.
2179
+ * **DoS defense.** Auto-generated `/aggregations/:name` routes today
2180
+ * have no upper bound on response size. A `groupBy: 'userId'` on a
2181
+ * 10M-user table without `defaultLimit` returns a 10M-row JSON. Setting
2182
+ * `defaultLimit: 100, maxLimit: 1000` bounds the response shape while
2183
+ * letting power users request up to 1000 rows when they need them.
2184
+ *
2185
+ * Mutually exclusive with the static `limit` — boot error if both set.
2186
+ * Setting `defaultLimit` without `maxLimit` uses the framework
2187
+ * default cap (`AGG_FRAMEWORK_MAX_LIMIT = 1000`) — explicit is better.
2443
2188
  */
2444
- readonly streamResponse?: boolean;
2189
+ defaultLimit?: number;
2445
2190
  /**
2446
- * INTERNAL — marker stamped by built-in presets (softDelete, tree, …)
2447
- * onto routes they emit. Read by the MCP collision detector
2448
- * (`createMcpServer.resolveToolCollisions`) to attribute the route
2449
- * source ("preset:softDelete vs user `actions.restore`") and to
2450
- * auto-namespace the preset side when a user-authored tool legitimately
2451
- * shadows it. Not part of the public surface — hosts must not set this.
2452
- *
2453
- * @internal
2191
+ * Hard ceiling for URL-driven `?limit=N`. Only meaningful when
2192
+ * `defaultLimit` is set; ignored otherwise. Defaults to
2193
+ * `AGG_FRAMEWORK_MAX_LIMIT` (1000) when omitted but `defaultLimit` is
2194
+ * present. Boot validation: must be ≥ `defaultLimit`.
2454
2195
  */
2455
- readonly _presetSource?: string;
2196
+ maxLimit?: number;
2456
2197
  /**
2457
- * Apply the resource's `fields` (field-level write permissions) to this
2458
- * custom route's request body.
2198
+ * Top-N-per-group filter. Keeps only the top `limit` rows per
2199
+ * partition, ranked by `sortBy`. The classic "top 3 products per
2200
+ * category" / "top 5 customers per region" dashboard primitive.
2459
2201
  *
2460
- * Default: `true` for body-bearing methods (POST/PUT/PATCH), `false`
2461
- * otherwise. Set to `false` to opt out — handler accepts the body as
2462
- * supplied with no field-write filtering. The resource-level
2463
- * `onFieldWriteDenied: 'reject' | 'strip'` setting governs how denials
2464
- * are surfaced.
2202
+ * Composes with `having` / `sort` — applies AFTER group + measures +
2203
+ * having, so `partitionBy` and `sortBy` may reference groupBy fields,
2204
+ * dateBucket aliases, or measure aliases. The top-level `sort` orders
2205
+ * the final row set across partitions.
2465
2206
  *
2466
- * `raw: true` routes ALWAYS bypass this — they've opted out of the
2467
- * pipeline entirely.
2207
+ * **Per-kit support.** Mongokit compiles to `$setWindowFields` (Mongo 5+,
2208
+ * runs in-engine — scales). Sqlitekit post-processes in JS (fine for
2209
+ * typical dashboards; prefer mongokit for >100k groups). See
2210
+ * `AggTopN` for full semantics.
2468
2211
  */
2469
- readonly fieldWrite?: boolean;
2212
+ topN?: AggTopN;
2470
2213
  /**
2471
- * Per-route rate limit. A custom route is its own Fastify route, so this
2472
- * overrides the resource-level `rateLimit` for THIS endpoint only:
2473
- *
2474
- * - omitted → inherit the resource-level limit (or app default)
2475
- * - `false` → disable rate limiting on this route
2476
- * - `{ max, timeWindow }` → apply this specific limit
2477
- *
2478
- * Requires `@fastify/rate-limit` registered (arc's factory wires it by
2479
- * default). Use for an expensive `/export` or an auth-sensitive `/reset`
2480
- * that needs a tighter cap than the resource's read-heavy CRUD, or `false`
2481
- * on an internal health/webhook route that must not be throttled.
2482
- *
2483
- * Note: this is per-route because each custom route is a distinct endpoint.
2484
- * `actions` share ONE `POST /:id/action` mount by design, so a per-ACTION
2485
- * limit can't be expressed here — throttle a specific action by promoting
2486
- * it to a dedicated `routes:` entry, or rely on the resource-level limit.
2214
+ * Permission check. **REQUIRED.** Aggregations are read-shape but
2215
+ * different from list (different threat model — measures may expose
2216
+ * cardinality info even when individual rows are hidden). Boot error
2217
+ * if missing.
2487
2218
  */
2488
- readonly rateLimit?: RateLimitConfig | false;
2219
+ permissions: PermissionCheck;
2489
2220
  /**
2490
- * Fastify route schema. Each slot (`body`, `querystring`, `params`,
2491
- * `headers`, `response[status]`) accepts a plain JSON Schema object
2492
- * **or** a Zod v4 schema — Arc auto-converts via `convertRouteSchema`.
2221
+ * DB-level execution cap (ms). Mongokit threads to `maxTimeMS`;
2222
+ * sqlitekit threads to per-statement timeout where supported.
2223
+ * Default: kit's default (typically none).
2493
2224
  */
2494
- readonly schema?: {
2495
- body?: unknown;
2496
- querystring?: unknown;
2497
- params?: unknown;
2498
- headers?: unknown;
2499
- response?: Record<number | string, unknown>;
2500
- [key: string]: unknown;
2501
- };
2225
+ timeout?: number;
2502
2226
  /**
2503
- * MCP tool generation:
2504
- * - omitted/true: auto-generate (non-raw routes only)
2505
- * - false: skip MCP
2506
- * - object: explicit config
2227
+ * Reject 422 if the result row count exceeds this cap. Better than
2228
+ * silent truncation — caller knows the dashboard is incomplete.
2229
+ * Default: no cap (use `limit` for truncation semantics).
2507
2230
  */
2508
- readonly mcp?: boolean | RouteMcpConfig;
2231
+ maxGroups?: number;
2509
2232
  /**
2510
- * MCP handler for raw routes — parallel entry point for MCP without
2511
- * changing the HTTP handler.
2233
+ * Caller MUST provide filters on these fields (else 400 at request).
2234
+ * Use to require a tenant-side narrowing the host can't infer
2235
+ * (segment id, customer id, etc.).
2512
2236
  */
2513
- readonly mcpHandler?: (input: Record<string, unknown>) => Promise<{
2514
- content: Array<{
2515
- type: string;
2516
- text: string;
2517
- }>;
2518
- isError?: boolean;
2519
- }>;
2237
+ requireFilters?: readonly string[];
2238
+ /**
2239
+ * Caller MUST send a bounded date range on this field. Prevents
2240
+ * all-time scans on billion-row collections.
2241
+ */
2242
+ requireDateRange?: AggregationDateRangeRequirement;
2243
+ /**
2244
+ * Documented index expectation. Arc warns at boot when the kit
2245
+ * exposes index introspection and no matching index exists. NOT
2246
+ * runtime-enforced — purely a misconfig signal.
2247
+ */
2248
+ indexHint?: AggregationIndexHint;
2249
+ /**
2250
+ * Per-aggregation cache. Tenant-scoped keys; invalidates with the
2251
+ * resource's cache namespace + any explicit `tags`.
2252
+ */
2253
+ cache?: AggregationCacheConfig;
2254
+ /**
2255
+ * Per-route rate limit. Wired to `@fastify/rate-limit` when the
2256
+ * plugin is registered.
2257
+ */
2258
+ rateLimit?: AggregationRateLimit;
2259
+ /**
2260
+ * Pre-computed read replacement. When set, arc skips
2261
+ * `repo.aggregate()` and calls this function instead. Same wire
2262
+ * shape, same permissions, different data source.
2263
+ *
2264
+ * Use for homepage-counter dashboards backed by host-managed rollup
2265
+ * tables (cron / CDC). The hook receives the compiled context
2266
+ * (filter + scope) so the host can route the lookup to the right
2267
+ * pre-aggregated bucket.
2268
+ */
2269
+ materialized?: (ctx: AggregationMaterializedContext) => Promise<AggregationMaterializedResult>;
2270
+ /** Optional summary rendered in OpenAPI + MCP tool description. */
2271
+ summary?: string;
2272
+ /** Optional longer description for OpenAPI / MCP. */
2273
+ description?: string;
2274
+ /**
2275
+ * MCP tool generation control. Mirrors `actions[name].mcp` semantics.
2276
+ *
2277
+ * - `undefined` (default) — generate an MCP tool for this aggregation
2278
+ * - `false` — skip MCP tool generation (REST route still works)
2279
+ * - `{ description?, annotations? }` — generate with overrides
2280
+ */
2281
+ mcp?: boolean | {
2282
+ readonly description?: string;
2283
+ readonly annotations?: Record<string, unknown>;
2284
+ };
2520
2285
  }
2286
+ /** Map of name → declaration. Keys become URL segments under `/aggregations/<name>`. */
2287
+ type AggregationsMap = Record<string, AggregationConfig>;
2288
+ //#endregion
2289
+ //#region src/types/resource/events.d.ts
2521
2290
  /**
2522
- * Action handler function for state transitions. Receives the resource
2523
- * ID, action-specific data, and the request.
2291
+ * Domain-event declaration on a resource — name, optional handler, and
2292
+ * payload schema for the `events:` block of a resource definition.
2524
2293
  */
2525
- type ActionHandlerFn = (id: string, data: Record<string, unknown>, req: RequestWithExtras) => Promise<unknown>;
2294
+ interface EventDefinition {
2295
+ name: string;
2296
+ /** Optional handler — events are published via `fastify.events.publish()`. */
2297
+ handler?: (data: unknown) => Promise<void> | void;
2298
+ /**
2299
+ * JSON Schema or Zod v4 schema for event payload. Typed `unknown` so Zod
2300
+ * class instances assign without a cast (same convention as
2301
+ * `ActionDefinition.schema` and `RouteDefinition.schema`).
2302
+ */
2303
+ schema?: unknown;
2304
+ description?: string;
2305
+ }
2306
+ //#endregion
2307
+ //#region src/types/resource/extensions.d.ts
2526
2308
  /**
2527
- * Full action configuration with handler, permissions, and schema.
2309
+ * `ResourceExtensions` — the typed plugin-extension namespace plugins
2310
+ * augment via declaration merging (`declare module "@classytic/arc/types"`).
2311
+ */
2312
+ /**
2313
+ * Plugin extension namespace — arc's typed escape hatch for attaching
2314
+ * declarative, per-resource config that **plugins** read at request time.
2315
+ *
2316
+ * **External** plugins augment it via TypeScript declaration merging
2317
+ * (`declare module "@classytic/arc/types"`) to register their own typed
2318
+ * slice — exactly the model Fastify uses for `FastifyInstance` decorators.
2319
+ * The host gets full autocomplete + compile-time checking on the matching
2320
+ * `extensions` block, and a typo (`extensions: { encyrption: … }`) is a
2321
+ * type error rather than a silent no-op.
2322
+ *
2323
+ * **First-party** slices (arc's own subpath plugins) are declared inline
2324
+ * below with type-only imports instead of `declare module`. Not a style
2325
+ * choice: arc bundles its declarations, and a relative module augmentation
2326
+ * (`declare module "../types/resource.js"`) survives bundling as an
2327
+ * unresolvable specifier — the merge silently never happens for consumers,
2328
+ * so the key would be a compile ERROR downstream while working inside this
2329
+ * repo. Inline declaration is bundler-proof; the type-only import is erased
2330
+ * at runtime, so the plugin stays fully opt-in.
2331
+ *
2332
+ * **The "React for backend" composition model.** `defineResource()` is the
2333
+ * declarative component, `extensions` is its typed props bag, and plugins
2334
+ * are the providers that consume those props at the framework boundary.
2335
+ * Adding a cross-cutting capability becomes *additive* — a plugin ships a
2336
+ * subpath + a one-line `declare module` augmentation, with no new
2337
+ * first-class `ResourceConfig` field and no core release per feature.
2338
+ *
2339
+ * Arc threads the declared `extensions` onto every generated route's
2340
+ * Fastify `config` (see `createCrudRouter`), so a plugin reads its slice
2341
+ * at request time via `request.routeOptions.config.arcExtensions` without
2342
+ * re-deriving which resource a route belongs to.
2343
+ *
2344
+ * @example An external plugin package registers its slice
2345
+ * ```ts
2346
+ * declare module "@classytic/arc/types" {
2347
+ * interface ResourceExtensions {
2348
+ * myPlugin?: import("my-arc-plugin").MyPluginDirective;
2349
+ * }
2350
+ * }
2351
+ * ```
2528
2352
  *
2529
- * Rate limiting is intentionally NOT per-action: every action shares one
2530
- * `POST /:id/action` (or `POST /action`) mount, so Fastify's per-route limit
2531
- * can't distinguish them. Actions inherit the resource-level `rateLimit`; to
2532
- * throttle a specific operation, promote it to a `routes:` entry (which
2533
- * supports `RouteDefinition.rateLimit`).
2353
+ * @example A host declares it on a resource
2354
+ * ```ts
2355
+ * defineResource({
2356
+ * name: "payment",
2357
+ * extensions: { encryption: { mode: "fields", fields: ["cardNumber", "cvv"] } },
2358
+ * });
2359
+ * ```
2534
2360
  */
2535
- interface ActionDefinition {
2536
- readonly handler: ActionHandlerFn;
2537
- /** Per-action permission (overrides resource-level `actionPermissions`) */
2538
- readonly permissions?: PermissionCheck;
2539
- /**
2540
- * JSON Schema or Zod v4 schema for action-specific body fields.
2541
- *
2542
- * Typed `unknown` (not `Record<string, unknown>`) so Zod class instances
2543
- * — `ZodObject<...>` carries no string index signature — assign without
2544
- * a cast. Same convention as `RouteDefinition.schema.body` / `customSchemas`.
2545
- * Runtime feature-detects via `convertRouteSchema` / `toJsonSchema`.
2546
- */
2547
- readonly schema?: unknown;
2548
- /** Description for OpenAPI docs and MCP tool */
2549
- readonly description?: string;
2550
- /**
2551
- * Whether this action needs an entity id from the URL.
2552
- *
2553
- * - `true` (default) — mounts under `POST /<prefix>/:id/action`. The `id`
2554
- * path param is required; handler receives it as the first argument.
2555
- * Use for actions that operate on an existing entity: `approve`,
2556
- * `dispatch`, `cancel`, `archive`.
2557
- * - `false` — mounts under `POST /<prefix>/action` (no `:id` segment).
2558
- * Handler receives an empty-string first argument. Use for
2559
- * collection-level actions that create / search / bulk-mutate:
2560
- * `propose` (creates a new entity), `search` (returns rows), `bulk`.
2561
- *
2562
- * 2.15.5: previously every action had to live under `:id/action`, forcing
2563
- * `propose`-style actions to swallow a meaningless URL parameter and
2564
- * making the auto-generated MCP tool advertise an `id` field agents
2565
- * had no value for. Setting `id: false` mounts the action at the
2566
- * resource root and drops `id` from the MCP tool's input shape.
2567
- */
2568
- readonly id?: boolean;
2361
+ interface ResourceExtensions {
2569
2362
  /**
2570
- * MCP tool generation:
2571
- * - omitted/true: auto-generate
2572
- * - false: skip
2573
- * - object: explicit config
2363
+ * Encrypt this resource's responses — full-body JWE or specific fields.
2364
+ * Read at request time by `@classytic/arc/encryption`'s `encryptionPlugin`
2365
+ * from `request.routeOptions.config.arcExtensions.encryption`; inert (and
2366
+ * dependency-free — the import is type-only) unless that plugin is
2367
+ * registered.
2574
2368
  */
2575
- readonly mcp?: boolean | RouteMcpConfig;
2369
+ encryption?: EncryptionDirective;
2576
2370
  }
2577
- /** Action config: bare handler function OR full ActionDefinition. */
2578
- type ActionEntry = ActionHandlerFn | ActionDefinition;
2579
- /** Actions configuration map. */
2580
- type ActionsMap = Record<string, ActionEntry>;
2371
+ //#endregion
2372
+ //#region src/types/resource/hooks.d.ts
2581
2373
  /**
2582
2374
  * Hook context passed to resource-level hook handlers. Mirrors
2583
2375
  * HookSystem's HookContext but with a simpler API for inline use.
@@ -2612,52 +2404,317 @@ interface ResourceHookContext {
2612
2404
  /** Authenticated user or null */
2613
2405
  user?: UserBase;
2614
2406
  /**
2615
- * Full typed request context — includes `_scope`, `_policyFilters`,
2616
- * `arc` metadata. Use `ctx.scope` for the common tenant/user projection;
2617
- * reach for `ctx.context` when you need `_scope.kind` branching or
2618
- * custom fields set by your auth adapter.
2407
+ * Full typed request context — includes `_scope`, `_policyFilters`,
2408
+ * `arc` metadata. Use `ctx.scope` for the common tenant/user projection;
2409
+ * reach for `ctx.context` when you need `_scope.kind` branching or
2410
+ * custom fields set by your auth adapter.
2411
+ */
2412
+ context?: AnyRecord;
2413
+ /**
2414
+ * First-class projection of request scope — `{ organizationId?, userId?, orgRoles? }`.
2415
+ * Populated for every scoped request so multi-tenant hooks don't have to
2416
+ * drill into `context._scope.organizationId` themselves. Matches the
2417
+ * identically-named field on `IRequestContext` (v2.10.6) so the same
2418
+ * read pattern works in controllers and hooks.
2419
+ */
2420
+ scope?: {
2421
+ organizationId?: string;
2422
+ userId?: string;
2423
+ orgRoles?: string[];
2424
+ };
2425
+ /** Additional metadata (e.g. `{ id, existing }` for update/delete) */
2426
+ meta?: AnyRecord;
2427
+ }
2428
+ /**
2429
+ * Inline lifecycle hooks on a resource definition. Wired into the
2430
+ * HookSystem automatically — same pipeline as presets and app-level hooks.
2431
+ *
2432
+ * @example
2433
+ * ```typescript
2434
+ * defineResource({
2435
+ * name: 'chat',
2436
+ * hooks: {
2437
+ * afterCreate: async (ctx) => { analytics.track('chat.created', { id: ctx.data._id }); },
2438
+ * beforeDelete: async (ctx) => {
2439
+ * if (ctx.data.isProtected) throw new Error('Cannot delete protected chat');
2440
+ * },
2441
+ * },
2442
+ * });
2443
+ * ```
2444
+ */
2445
+ interface ResourceHooks {
2446
+ beforeCreate?: (ctx: ResourceHookContext) => Promise<AnyRecord | undefined> | AnyRecord | undefined;
2447
+ afterCreate?: (ctx: ResourceHookContext) => Promise<void> | void;
2448
+ beforeUpdate?: (ctx: ResourceHookContext) => Promise<AnyRecord | undefined> | AnyRecord | undefined;
2449
+ afterUpdate?: (ctx: ResourceHookContext) => Promise<void> | void;
2450
+ beforeDelete?: (ctx: ResourceHookContext) => Promise<void> | void;
2451
+ afterDelete?: (ctx: ResourceHookContext) => Promise<void> | void;
2452
+ }
2453
+ //#endregion
2454
+ //#region src/types/resource/fields.d.ts
2455
+ /**
2456
+ * Per-field rule — arc's extension of repo-core's 4-field `FieldRule` floor
2457
+ * (`immutable`, `immutableAfterCreate`, `systemManaged`, `optional`) with
2458
+ * the constraint / UI / security bits arc layers on top.
2459
+ *
2460
+ * Kept structurally compatible with `@classytic/repo-core/schema`'s
2461
+ * `FieldRule` so arc's `fieldRules: Record<string, ArcFieldRule>` flows
2462
+ * into mongokit's / sqlitekit's `buildCrudSchemasFromModel(..., options)`
2463
+ * without a cast. See `RouteSchemaOptions` JSDoc for the full rationale.
2464
+ */
2465
+ interface ArcFieldRule extends FieldRule {
2466
+ /**
2467
+ * When `true`, bypass the `systemManaged` / `readonly` / `immutable`
2468
+ * strip in `BodySanitizer` for callers whose request scope is
2469
+ * `elevated`. Lets platform admins stamp the value from the request
2470
+ * body — needed for cross-tenant admin writes where the tenant field
2471
+ * is the only way to pick a target org.
2472
+ *
2473
+ * Auto-set by `defineResource` on the configured `tenantField`. Hosts
2474
+ * can set it manually on other fields (e.g. `createdBy`) if they want
2475
+ * elevation-only override semantics for those too.
2476
+ *
2477
+ * Has no effect when `isElevated(scope)` is false — member and
2478
+ * service callers continue to have the field stripped.
2479
+ */
2480
+ preserveForElevated?: boolean;
2481
+ hidden?: boolean;
2482
+ /**
2483
+ * Aggregation visibility override. By default, only `hidden: true`
2484
+ * blocks a field from `groupBy` / `measures.field` / `sort` / `dateBuckets`
2485
+ * — that's the genuine cardinality-leak guard (the value is omitted from
2486
+ * list/get responses, so exposing it via aggregation would reveal data
2487
+ * the client can't otherwise see).
2488
+ *
2489
+ * `systemManaged: true` does **not** block aggregation — it's a write
2490
+ * rule, not a visibility rule. Server-stamped fields like `createdAt`,
2491
+ * `status`, or plugin-generated handles are visible in every list
2492
+ * response and should aggregate freely.
2493
+ *
2494
+ * Use this flag to override the default:
2495
+ *
2496
+ * - `aggregable: false` — explicit deny, even on visible fields. Useful
2497
+ * when a value is exposed per-row but the cardinality across rows is
2498
+ * itself sensitive (e.g. `email` is visible in `get/:id` to admins
2499
+ * but you don't want a public-readable agg of email distributions).
2500
+ * - `aggregable: true` — escape hatch on `hidden` fields. Lets you
2501
+ * aggregate a hidden column when you're sure cardinality leakage
2502
+ * isn't a concern (e.g. `internalScore` hidden from list, but a
2503
+ * committee-only `byScore` agg is fine).
2504
+ *
2505
+ * Defaults to `undefined` (use the `hidden`-only rule).
2506
+ */
2507
+ aggregable?: boolean;
2508
+ /** String minimum length — auto-maps to OpenAPI `minLength` and MCP tool schema */
2509
+ minLength?: number;
2510
+ /** String maximum length — auto-maps to OpenAPI `maxLength` and MCP tool schema */
2511
+ maxLength?: number;
2512
+ /** Number minimum — auto-maps to OpenAPI `minimum` and MCP tool schema */
2513
+ min?: number;
2514
+ /** Number maximum — auto-maps to OpenAPI `maximum` and MCP tool schema */
2515
+ max?: number;
2516
+ /** Regex pattern — auto-maps to OpenAPI `pattern` and MCP tool schema */
2517
+ pattern?: string;
2518
+ /** Allowed values — auto-maps to OpenAPI `enum` and MCP tool schema */
2519
+ enum?: ReadonlyArray<string | number>;
2520
+ /**
2521
+ * When `true`, widen the JSON Schema `type` of this field to also
2522
+ * accept `null`. Mirrors Zod's `.nullable()` at the arc config layer
2523
+ * for kit-generated schemas that don't carry the flag end-to-end
2524
+ * (e.g. Zod → Mongoose → mongokit drops `.nullable()` because
2525
+ * Mongoose has no first-class nullable marker unless `default: null`
2526
+ * is also set).
2527
+ *
2528
+ * Applied post-kit by `mergeFieldRuleConstraints`: if the adapter
2529
+ * emitted `{ type: 'string', enum: [...] }` for a field arc should
2530
+ * accept null for, the merge widens it to
2531
+ * `{ type: ['string', 'null'], enum: [...] }` — draft-7 tuple form
2532
+ * AJV 8 validates natively.
2533
+ *
2534
+ * No-op when the property already declares `type: [...,'null']` or
2535
+ * an `anyOf: [..., { type: 'null' }]` branch — arc never fights the
2536
+ * kit's own output.
2537
+ */
2538
+ nullable?: boolean;
2539
+ /** Human-readable description — auto-maps to OpenAPI `description` */
2540
+ description?: string;
2541
+ [key: string]: unknown;
2542
+ }
2543
+ interface FieldRule$1 {
2544
+ field: string;
2545
+ required?: boolean;
2546
+ readonly?: boolean;
2547
+ hidden?: boolean;
2548
+ }
2549
+ //#endregion
2550
+ //#region src/types/resource/schemas.d.ts
2551
+ /**
2552
+ * Schema-shaping options for a resource.
2553
+ *
2554
+ * Extends `@classytic/repo-core/schema`'s `SchemaBuilderOptions` so every
2555
+ * kit-generator callback typed against the repo-core contract
2556
+ * (mongokit's `buildCrudSchemasFromModel`, sqlitekit's
2557
+ * `buildCrudSchemasFromTable`, prismakit's equivalent) accepts arc's
2558
+ * options bag directly — no `as SchemaBuilderOptions` / `Parameters<...>[1]`
2559
+ * cast at the host wiring site.
2560
+ *
2561
+ * Inherited from `SchemaBuilderOptions`:
2562
+ * - `strictAdditionalProperties` — emit `additionalProperties: false`
2563
+ * - `dateAs` — `'date'` vs `'datetime'` ISO rendering
2564
+ * - `softRequiredFields` — stay in `properties`, drop from `required[]`
2565
+ * - `create: { omitFields, requiredOverrides, optionalOverrides, schemaOverrides }`
2566
+ * - `update: { omitFields, requireAtLeastOne }`
2567
+ * - `query: { filterableFields }` (kit-native filter declaration)
2568
+ * - `openApiExtensions` — emit `x-*` vendor keywords for docgen
2569
+ *
2570
+ * Arc adds:
2571
+ * - `fieldRules` with the richer `ArcFieldRule` per-entry shape
2572
+ * (preserveForElevated, minLength/maxLength/min/max/pattern, enum,
2573
+ * nullable, description) — arc's extensions are applied post-kit by
2574
+ * `mergeFieldRuleConstraints`; the kit only sees the repo-core floor.
2575
+ * - `hiddenFields` / `readonlyFields` / `requiredFields` / `optionalFields`
2576
+ * / `excludeFields` — arc-only convenience lists that predate fieldRules.
2577
+ * Keep using them if they're already in place; new code should prefer
2578
+ * `fieldRules` for per-field control.
2579
+ * - `filterableFields: string[]` — top-level list arc's MCP layer auto-
2580
+ * derives from `QueryParser.allowedFilterFields`. Distinct from the
2581
+ * inherited `query.filterableFields: Record<...>` which feeds the kit's
2582
+ * list-query schema; nothing stops a resource from using both.
2583
+ *
2584
+ * **Why extend rather than duplicate**: mongokit's
2585
+ * `buildCrudSchemasFromModel(model, options: SchemaBuilderOptions)` is the
2586
+ * canonical callback shape. Before the extension, hosts wrote
2587
+ * `Parameters<typeof buildCrudSchemasFromModel>[1]` or
2588
+ * `as SchemaBuilderOptions` at every wiring site — a defensive cast with
2589
+ * no runtime effect. Extension locks the structural relationship at the
2590
+ * type layer so the cast is compile-verified gone.
2591
+ */
2592
+ interface RouteSchemaOptions extends SchemaBuilderOptions {
2593
+ hiddenFields?: string[];
2594
+ readonlyFields?: string[];
2595
+ requiredFields?: string[];
2596
+ optionalFields?: string[];
2597
+ excludeFields?: string[];
2598
+ /**
2599
+ * Fields allowed for filtering in list operations. MCP auto-derives
2600
+ * from `QueryParser.allowedFilterFields` when not set explicitly.
2601
+ *
2602
+ * Distinct from the inherited `query.filterableFields: Record<...>`
2603
+ * from `SchemaBuilderOptions` — that entry feeds the kit's list-query
2604
+ * JSON Schema; this one is arc's MCP-auto-derivation list.
2605
+ */
2606
+ filterableFields?: string[];
2607
+ /**
2608
+ * Per-field rules. Richer than repo-core's `FieldRules` — arc adds
2609
+ * `preserveForElevated`, constraint hints (`minLength`, `enum`,
2610
+ * `nullable`, etc.), and `description` on top of the four-flag floor
2611
+ * (`immutable`, `immutableAfterCreate`, `systemManaged`, `optional`).
2612
+ *
2613
+ * Structurally compatible: `Record<string, ArcFieldRule>` is assignable
2614
+ * to repo-core's `Record<string, FieldRule>` since `ArcFieldRule extends
2615
+ * FieldRule`. Kits see only the floor; arc's extensions are applied
2616
+ * post-kit by `mergeFieldRuleConstraints`.
2619
2617
  */
2620
- context?: AnyRecord;
2618
+ fieldRules?: Record<string, ArcFieldRule>;
2621
2619
  /**
2622
- * First-class projection of request scope — `{ organizationId?, userId?, orgRoles? }`.
2623
- * Populated for every scoped request so multi-tenant hooks don't have to
2624
- * drill into `context._scope.organizationId` themselves. Matches the
2625
- * identically-named field on `IRequestContext` (v2.10.6) so the same
2626
- * read pattern works in controllers and hooks.
2620
+ * Query-time security whitelists + the kit's `filterableFields`.
2621
+ *
2622
+ * Extends repo-core's `SchemaBuilderOptions['query']` with arc-specific
2623
+ * runtime features that `QueryResolver` reads at request time:
2624
+ *
2625
+ * - **`allowedPopulate`** — populate-path whitelist consumed by
2626
+ * `QueryResolver.sanitizePopulate` / `sanitizeAdvancedPopulate`.
2627
+ * When set, only paths in the list pass through; everything else is
2628
+ * stripped silently. Shrinks the auto-wired `?populate=` attack surface.
2629
+ *
2630
+ * - **`allowedLookups`** — lookup-collection whitelist consumed by
2631
+ * `QueryResolver.sanitizeLookups`. When set, only the listed
2632
+ * collections may be `$lookup`'d into the pipeline.
2633
+ *
2634
+ * Both are pre-2.11.2 runtime features — the type was missing them, so
2635
+ * hosts wrote `as Record<string, unknown>` at every call site. Arc's own
2636
+ * `QueryResolver` had to cast its own input via `as AnyRecord` for the
2637
+ * same reason. Adding the type dropped both casts.
2627
2638
  */
2628
- scope?: {
2629
- organizationId?: string;
2630
- userId?: string;
2631
- orgRoles?: string[];
2639
+ query?: SchemaBuilderOptions["query"] & {
2640
+ /**
2641
+ * Populate-path whitelist. When set, `QueryResolver.sanitizePopulate`
2642
+ * strips any `?populate=<path>` not in this list. Omit to allow all
2643
+ * paths the kit recognizes.
2644
+ */
2645
+ allowedPopulate?: string[];
2646
+ /**
2647
+ * Lookup-collection whitelist for `QueryResolver.sanitizeLookups`.
2648
+ * When set, only the listed collections may be `$lookup`'d. Omit to
2649
+ * disable the whitelist (kit-level rules still apply).
2650
+ */
2651
+ allowedLookups?: string[];
2632
2652
  };
2633
- /** Additional metadata (e.g. `{ id, existing }` for update/delete) */
2634
- meta?: AnyRecord;
2635
2653
  }
2636
2654
  /**
2637
- * Inline lifecycle hooks on a resource definition. Wired into the
2638
- * HookSystem automatically — same pipeline as presets and app-level hooks.
2639
- *
2640
- * @example
2641
- * ```typescript
2642
- * defineResource({
2643
- * name: 'chat',
2644
- * hooks: {
2645
- * afterCreate: async (ctx) => { analytics.track('chat.created', { id: ctx.data._id }); },
2646
- * beforeDelete: async (ctx) => {
2647
- * if (ctx.data.isProtected) throw new Error('Cannot delete protected chat');
2648
- * },
2649
- * },
2650
- * });
2651
- * ```
2655
+ * CRUD route schemas (Fastify native format). Each slot accepts a plain
2656
+ * JSON Schema object **or** a Zod v4 schema — Arc's `convertRouteSchema`
2657
+ * feature-detects at runtime. Slot values are typed `unknown` so
2658
+ * class-based Zod schemas assign without casts.
2652
2659
  */
2653
- interface ResourceHooks {
2654
- beforeCreate?: (ctx: ResourceHookContext) => Promise<AnyRecord | undefined> | AnyRecord | undefined;
2655
- afterCreate?: (ctx: ResourceHookContext) => Promise<void> | void;
2656
- beforeUpdate?: (ctx: ResourceHookContext) => Promise<AnyRecord | undefined> | AnyRecord | undefined;
2657
- afterUpdate?: (ctx: ResourceHookContext) => Promise<void> | void;
2658
- beforeDelete?: (ctx: ResourceHookContext) => Promise<void> | void;
2659
- afterDelete?: (ctx: ResourceHookContext) => Promise<void> | void;
2660
+ interface CrudSchemas {
2661
+ /** GET / — list */
2662
+ list?: {
2663
+ querystring?: unknown;
2664
+ response?: Record<number, unknown>;
2665
+ [key: string]: unknown;
2666
+ };
2667
+ /** GET /:id — get one */
2668
+ get?: {
2669
+ params?: unknown;
2670
+ response?: Record<number, unknown>;
2671
+ [key: string]: unknown;
2672
+ };
2673
+ /** POST / — create */
2674
+ create?: {
2675
+ body?: unknown;
2676
+ response?: Record<number, unknown>;
2677
+ [key: string]: unknown;
2678
+ };
2679
+ /** PATCH /:id — update */
2680
+ update?: {
2681
+ params?: unknown;
2682
+ body?: unknown;
2683
+ response?: Record<number, unknown>;
2684
+ [key: string]: unknown;
2685
+ };
2686
+ /** DELETE /:id — delete */
2687
+ delete?: {
2688
+ params?: unknown;
2689
+ response?: Record<number, unknown>;
2690
+ [key: string]: unknown;
2691
+ };
2692
+ [key: string]: unknown;
2693
+ }
2694
+ interface OpenApiSchemas {
2695
+ entity?: unknown;
2696
+ createBody?: unknown;
2697
+ updateBody?: unknown;
2698
+ params?: unknown;
2699
+ listQuery?: unknown;
2700
+ /**
2701
+ * Explicit response schema for OpenAPI documentation. Auto-generated
2702
+ * from `createBody` if omitted. Does NOT affect Fastify serialization.
2703
+ */
2704
+ response?: unknown;
2705
+ [key: string]: unknown;
2706
+ }
2707
+ type CrudRouteKey = "list" | "get" | "create" | "update" | "delete";
2708
+ interface MiddlewareConfig {
2709
+ list?: MiddlewareHandler[];
2710
+ get?: MiddlewareHandler[];
2711
+ create?: MiddlewareHandler[];
2712
+ update?: MiddlewareHandler[];
2713
+ delete?: MiddlewareHandler[];
2714
+ [key: string]: MiddlewareHandler[] | undefined;
2660
2715
  }
2716
+ //#endregion
2717
+ //#region src/types/resource/presets.d.ts
2661
2718
  interface PresetHook {
2662
2719
  operation: "create" | "update" | "delete" | "read" | "list";
2663
2720
  phase: "before" | "after";
@@ -2674,18 +2731,68 @@ interface PresetResult {
2674
2731
  hooks?: PresetHook[];
2675
2732
  }
2676
2733
  type PresetFunction = (config: ResourceConfig) => PresetResult;
2677
- interface EventDefinition {
2678
- name: string;
2679
- /** Optional handler — events are published via `fastify.events.publish()`. */
2680
- handler?: (data: unknown) => Promise<void> | void;
2734
+ //#endregion
2735
+ //#region src/types/resource/tenant.d.ts
2736
+ /**
2737
+ * Tenant-cleanup declaration on a resource — compliance-grade strategy
2738
+ * for what happens to this resource's rows when the owning organization
2739
+ * is deleted. Surfaces in arc's org-delete cascade runner and in
2740
+ * registry introspection (audit scripts ask "what happens to this
2741
+ * resource on org delete?").
2742
+ *
2743
+ * Strategy variants:
2744
+ * - `hard` — permanent removal (GDPR right-to-be-forgotten).
2745
+ * - `soft` — recoverable; pair with TTL for eventual hard-purge.
2746
+ * - `anonymize` — keep the row (legal retention) but clear PII
2747
+ * (HIPAA / PCI / SOX-compatible).
2748
+ * - `skip` — explicit opt-out with mandatory `reason`.
2749
+ *
2750
+ * See {@link ResourceConfig.onTenantDelete} for the full decision tree.
2751
+ */
2752
+ interface OnTenantDeleteConfig {
2681
2753
  /**
2682
- * JSON Schema or Zod v4 schema for event payload. Typed `unknown` so Zod
2683
- * class instances assign without a cast (same convention as
2684
- * `ActionDefinition.schema` and `RouteDefinition.schema`).
2754
+ * What to do with rows whose `tenantField` matches the deleted org.
2755
+ * Discriminated union from `@classytic/repo-core/repository` — every
2756
+ * kit's `purgeByField` consumes the same shape.
2685
2757
  */
2686
- schema?: unknown;
2687
- description?: string;
2758
+ strategy: TenantPurgeStrategy;
2759
+ /**
2760
+ * Resources are processed in ascending `priority` order. Use to land
2761
+ * leaf data (logs, events) before aggregate references. Default `100`.
2762
+ */
2763
+ priority?: number;
2764
+ /**
2765
+ * Rows per chunk for the underlying `purgeByField` call — bounds
2766
+ * lock contention + replication-log pressure on very large tenants.
2767
+ * Default kit-specific (~1000).
2768
+ */
2769
+ batchSize?: number;
2770
+ }
2771
+ /**
2772
+ * Resolved tenant-purge declaration — what arc actually runs when
2773
+ * `cascadeDeleteForOrganization` fires. Computed once at boot from the
2774
+ * resource's `onTenantDelete` declaration.
2775
+ *
2776
+ * Exposed via `ResourceDefinition.resolvedTenantPurge` and
2777
+ * `getCascadingResourcesWithMetadata(registry)` so audit tooling can
2778
+ * answer "is this resource really going to hard-delete?" without
2779
+ * reading the source.
2780
+ */
2781
+ interface ResolvedTenantPurge {
2782
+ readonly strategy: TenantPurgeStrategy;
2783
+ readonly priority: number;
2784
+ readonly batchSize?: number;
2785
+ /**
2786
+ * Where the strategy came from — `'declared'` (host wrote
2787
+ * `onTenantDelete` explicitly) or `'disabled'` (no declaration —
2788
+ * runner skips this resource).
2789
+ */
2790
+ readonly source: "declared" | "disabled";
2688
2791
  }
2792
+ //#endregion
2793
+ //#region src/types/resource/config.d.ts
2794
+ /** Standard controller type alias for CRUD operations. */
2795
+ type CrudController<TDoc> = IController<TDoc>;
2689
2796
  /** Resource-level permissions — only `PermissionCheck` functions allowed. */
2690
2797
  interface ResourcePermissions {
2691
2798
  list?: PermissionCheck;
@@ -2694,48 +2801,6 @@ interface ResourcePermissions {
2694
2801
  update?: PermissionCheck;
2695
2802
  delete?: PermissionCheck;
2696
2803
  }
2697
- /**
2698
- * Plugin extension namespace — arc's typed escape hatch for attaching
2699
- * declarative, per-resource config that **plugins** read at request time.
2700
- *
2701
- * This interface is intentionally **empty in core**. Each optional plugin
2702
- * augments it via TypeScript declaration merging to register its own typed
2703
- * slice — exactly the model Fastify uses for `FastifyInstance` decorators.
2704
- * A host that never installs a plugin never sees its key; a host that does
2705
- * gets full autocomplete + compile-time checking on the matching
2706
- * `extensions` block, and a typo (`extensions: { encyrption: … }`) is a
2707
- * type error rather than a silent no-op.
2708
- *
2709
- * **The "React for backend" composition model.** `defineResource()` is the
2710
- * declarative component, `extensions` is its typed props bag, and plugins
2711
- * are the providers that consume those props at the framework boundary.
2712
- * Adding a cross-cutting capability becomes *additive* — a plugin ships a
2713
- * subpath + a one-line `declare module` augmentation, with no new
2714
- * first-class `ResourceConfig` field and no core release per feature.
2715
- *
2716
- * Arc threads the declared `extensions` onto every generated route's
2717
- * Fastify `config` (see `createCrudRouter`), so a plugin reads its slice
2718
- * at request time via `request.routeOptions.config.arcExtensions` without
2719
- * re-deriving which resource a route belongs to.
2720
- *
2721
- * @example A plugin registers its slice (in `@classytic/arc/encryption`)
2722
- * ```ts
2723
- * declare module "@classytic/arc/types" {
2724
- * interface ResourceExtensions {
2725
- * encryption?: import("@classytic/arc/encryption").EncryptionDirective;
2726
- * }
2727
- * }
2728
- * ```
2729
- *
2730
- * @example A host declares it on a resource
2731
- * ```ts
2732
- * defineResource({
2733
- * name: "payment",
2734
- * extensions: { encryption: { mode: "fields", fields: ["cardNumber", "cvv"] } },
2735
- * });
2736
- * ```
2737
- */
2738
- interface ResourceExtensions {}
2739
2804
  interface ResourceConfig<TDoc = AnyRecord> {
2740
2805
  name: string;
2741
2806
  displayName?: string;
@@ -2935,7 +3000,7 @@ interface ResourceConfig<TDoc = AnyRecord> {
2935
3000
  * });
2936
3001
  * ```
2937
3002
  */
2938
- crud?: false | { [K in CrudRouteKey]?: boolean };
3003
+ crud?: false | { [K in CrudRouteKey]?: boolean; };
2939
3004
  /**
2940
3005
  * Field name used for multi-tenant scoping (default: 'organizationId').
2941
3006
  * Override to match your schema: 'workspaceId', 'tenantId', etc.
@@ -3166,6 +3231,28 @@ interface ResourceConfig<TDoc = AnyRecord> {
3166
3231
  audit?: boolean | {
3167
3232
  operations?: ("create" | "update" | "delete")[];
3168
3233
  };
3234
+ /**
3235
+ * Per-record change timeline (2.22) — `history: true` adds
3236
+ * `GET /:prefix/:id/history`, serving this record's audit entries
3237
+ * (who/when/what-changed, newest first, `?limit=&offset=`). Implies
3238
+ * `audit: true` (the flag auto-enables it when unset) and requires
3239
+ * `auditPlugin` to be registered — the route answers 503 with
3240
+ * `history.audit_unavailable` otherwise.
3241
+ *
3242
+ * Change history exposes before/after snapshots, so the gate defaults
3243
+ * STRICTER than reads: the resource's `update` permission, falling back
3244
+ * to `get`, falling back to authenticated-only via the route's normal
3245
+ * auth. Override with the object form:
3246
+ *
3247
+ * ```ts
3248
+ * defineResource({ name: 'order', history: true });
3249
+ * defineResource({ name: 'payment', history: { permissions: requireRoles(['auditor']), limit: 100 } });
3250
+ * ```
3251
+ */
3252
+ history?: boolean | {
3253
+ permissions?: PermissionCheck;
3254
+ limit?: number;
3255
+ };
3169
3256
  /**
3170
3257
  * Typed plugin-extension namespace — declarative per-resource config that
3171
3258
  * arc plugins read at request time. Empty by default; each installed
@@ -3626,7 +3713,8 @@ type FastifyWithDecorators = FastifyInstance & {
3626
3713
  arc?: ArcDecorator;
3627
3714
  events?: EventsDecorator;
3628
3715
  authenticate?: (request: FastifyRequest, reply: FastifyReply) => Promise<void>;
3629
- optionalAuthenticate?: (request: FastifyRequest, reply: FastifyReply) => Promise<void>; /** Organization-scoped filtering — from `multiTenant` preset */
3716
+ optionalAuthenticate?: (request: FastifyRequest, reply: FastifyReply) => Promise<void>;
3717
+ /** Organization-scoped filtering — from `multiTenant` preset */
3630
3718
  organizationScoped?: (options?: {
3631
3719
  required?: boolean;
3632
3720
  }) => RouteHandlerMethod;
@@ -3991,8 +4079,10 @@ interface RegistryEntry extends ResourceMetadata {
3991
4079
  */
3992
4080
  actions?: Array<{
3993
4081
  readonly name: string;
3994
- readonly description?: string; /** Raw per-action schema (JSON Schema, Zod v4, or legacy field map) */
3995
- readonly schema?: unknown; /** Per-action permission check (if different from resource-level `actionPermissions`) */
4082
+ readonly description?: string;
4083
+ /** Raw per-action schema (JSON Schema, Zod v4, or legacy field map) */
4084
+ readonly schema?: unknown;
4085
+ /** Per-action permission check (if different from resource-level `actionPermissions`) */
3996
4086
  readonly permissions?: PermissionCheck;
3997
4087
  /**
3998
4088
  * 2.15.5: mount point — `true` (default) for `POST /:id/action`,
@@ -4001,7 +4091,8 @@ interface RegistryEntry extends ResourceMetadata {
4001
4091
  * resource manifests so callers can pick the right URL without
4002
4092
  * reading the action definition itself.
4003
4093
  */
4004
- readonly id?: boolean; /** MCP tool generation flag — `false` to skip, object for overrides */
4094
+ readonly id?: boolean;
4095
+ /** MCP tool generation flag — `false` to skip, object for overrides */
4005
4096
  readonly mcp?: boolean | {
4006
4097
  readonly description?: string;
4007
4098
  readonly annotations?: Record<string, unknown>;
@@ -4028,13 +4119,17 @@ interface RegistryEntry extends ResourceMetadata {
4028
4119
  readonly summary?: string;
4029
4120
  readonly description?: string;
4030
4121
  readonly permissions: PermissionCheck;
4031
- readonly groupBy?: string | readonly string[]; /** Measure aliases keyed to their op-tag (e.g. `'count'`, `'sum:price'`). */
4032
- readonly measures: Readonly<Record<string, string>>; /** Lookup alias names (`as` or `from`) — used by OpenAPI to know which dotted-path output keys nest. */
4033
- readonly lookupAliases: readonly string[]; /** Whether the aggregation requires a date range — surfaced in docs. */
4122
+ readonly groupBy?: string | readonly string[];
4123
+ /** Measure aliases keyed to their op-tag (e.g. `'count'`, `'sum:price'`). */
4124
+ readonly measures: Readonly<Record<string, string>>;
4125
+ /** Lookup alias names (`as` or `from`) — used by OpenAPI to know which dotted-path output keys nest. */
4126
+ readonly lookupAliases: readonly string[];
4127
+ /** Whether the aggregation requires a date range — surfaced in docs. */
4034
4128
  readonly requireDateRange?: {
4035
4129
  field: string;
4036
4130
  maxRangeDays?: number;
4037
- }; /** Whether the aggregation requires named filters — surfaced in docs. */
4131
+ };
4132
+ /** Whether the aggregation requires named filters — surfaced in docs. */
4038
4133
  readonly requireFilters?: readonly string[];
4039
4134
  /**
4040
4135
  * Static result-row cap (no URL override). Mutually exclusive with
@@ -4052,7 +4147,8 @@ interface RegistryEntry extends ResourceMetadata {
4052
4147
  * URL-driven limit ceiling (only meaningful with `defaultLimit`).
4053
4148
  * OpenAPI docs render this as the `maximum` constraint on `?limit`.
4054
4149
  */
4055
- readonly maxLimit?: number; /** MCP tool generation flag — `false` to skip, object for overrides. */
4150
+ readonly maxLimit?: number;
4151
+ /** MCP tool generation flag — `false` to skip, object for overrides. */
4056
4152
  readonly mcp?: boolean | {
4057
4153
  readonly description?: string;
4058
4154
  readonly annotations?: Record<string, unknown>;
@@ -4141,4 +4237,4 @@ interface ValidateOptions {
4141
4237
  strict?: boolean;
4142
4238
  }
4143
4239
  //#endregion
4144
- export { OnTenantDeleteConfig as $, UserOrganization as $t, RequestIdOptions as A, ArcCreateResult as An, AggregationMaterializedResult as At, defineResource as B, BodySanitizerConfig as Bn, Transform as Bt, RequestContext as C, afterDelete as Cn, AggMeasureInput as Ct, HealthCheck as D, beforeUpdate as Dn, AggregationDateRangeRequirement as Dt, GracefulShutdownOptions as E, beforeDelete as En, AggregationConfig as Et, FastifyWithDecorators as F, BaseControllerOptions as Fn, NextFunction as Ft, ActionsMap as G, JwtContext as Gt, ActionDefinition as H, AuthPluginOptions as Ht, MiddlewareHandler as I, ControllerConfigurableOptions as In, OperationFilter as It, CrudRouteKey as J, ApiResponse as Jt, ArcFieldRule as K, TokenPair as Kt, RequestWithExtras as L, ControllerConstructionOptions as Ln, PipelineConfig as Lt, EventsDecorator as M, ArcGetResult as Mn, AggregationsMap as Mt, FastifyRequestExtras as N, ArcListResult as Nn, Guard as Nt, HealthOptions as O, createHookSystem as On, AggregationIndexHint as Ot, FastifyWithAuth as P, ArcUpdateResult as Pn, Interceptor as Pt, MiddlewareConfig as Q, UserLike as Qt, RegisterOptions as R, ListResult as Rn, PipelineContext as Rt, QueryParserInterface as S, afterCreate as Sn, RouteHandler as St, CrudRouterOptions as T, beforeCreate as Tn, AggregationCacheConfig as Tt, ActionEntry as U, Authenticator as Ut, ResourceDefinition as V, AuthHelpers as Vt, ActionHandlerFn as W, AuthenticatorContext as Wt, EventDefinition as X, JWTPayload as Xt, CrudSchemas as Y, ArcRequest as Yt, FieldRule$1 as Z, ObjectId as Zt, ControllerQueryOptions as _, HookOperation as _n, ControllerLike as _t, InferAdapterDoc as a, SlugExt as an, ResolvedTenantPurge as at, ParsedQuery as b, HookSystem as bn, IControllerResponse as bt, TypedController as c, BulkMixin as cn, ResourceExtensions as ct, PaginationResult as d, QueryResolverConfig as dn, ResourcePermissions as dt, BaseController as en, OpenApiSchemas as et, IntrospectionData as f, AccessControl as fn, RouteDefinition as ft, ArcInternalMetadata as g, HookHandler as gn, ControllerHandler as gt, ResourceMetadata as h, HookContext as hn, RouteSchemaOptions as ht, ValidationResult as i, TreeMixin as in, RateLimitConfig as it, ArcDecorator as j, ArcDeleteResult as jn, AggregationRateLimit as jt, IntrospectionPluginOptions as k, defineHook as kn, AggregationMaterializedContext as kt, TypedRepository as l, BaseCrudController as ln, ResourceHookContext as lt, RegistryStats as m, DefineHookOptions as mn, RouteMethod as mt, ConfigError as n, SoftDeleteMixin as nn, PresetHook as nt, InferDocType as o, SlugMixin as on, ResourceCacheConfig as ot, RegistryEntry as p, AccessControlConfig as pn, RouteMcpConfig as pt, CrudController as q, AnyRecord as qt, ValidateOptions as r, TreeExt as rn, PresetResult as rt, InferResourceDoc as s, BulkExt as sn, ResourceConfig as st, RouteHandlerMethod$1 as t, SoftDeleteExt as tn, PresetFunction as tt, TypedResourceConfig as u, QueryResolver as un, ResourceHooks as ut, LookupOption as v, HookPhase as vn, FastifyHandler as vt, ServiceContext as w, afterUpdate as wn, AggMeasureShorthand as wt, PopulateOption as x, HookSystemOptions as xn, IRequestContext as xt, OwnershipCheck as y, HookRegistration as yn, IController as yt, ResourceRegistry as z, BodySanitizer as zn, PipelineStep as zt };
4240
+ export { OpenApiSchemas as $, UserOrganization as $t, RequestIdOptions as A, ArcCreateResult as An, RouteDefinition as At, defineResource as B, BodySanitizerConfig as Bn, RouteHandler as Bt, RequestContext as C, afterDelete as Cn, PipelineStep as Ct, HealthCheck as D, beforeUpdate as Dn, ActionEntry as Dt, GracefulShutdownOptions as E, beforeDelete as En, ActionDefinition as Et, FastifyWithDecorators as F, BaseControllerOptions as Fn, ControllerLike as Ft, OnTenantDeleteConfig as G, JwtContext as Gt, CrudController as H, AuthPluginOptions as Ht, MiddlewareHandler as I, ControllerConfigurableOptions as In, FastifyHandler as It, PresetHook as J, ApiResponse as Jt, ResolvedTenantPurge as K, TokenPair as Kt, RequestWithExtras as L, ControllerConstructionOptions as Ln, IController as Lt, EventsDecorator as M, ArcGetResult as Mn, RouteMethod as Mt, FastifyRequestExtras as N, ArcListResult as Nn, RateLimitConfig as Nt, HealthOptions as O, createHookSystem as On, ActionHandlerFn as Ot, FastifyWithAuth as P, ArcUpdateResult as Pn, ControllerHandler as Pt, MiddlewareConfig as Q, UserLike as Qt, RegisterOptions as R, ListResult as Rn, IControllerResponse as Rt, QueryParserInterface as S, afterCreate as Sn, PipelineContext as St, CrudRouterOptions as T, beforeCreate as Tn, ResourceCacheConfig as Tt, ResourceConfig as U, Authenticator as Ut, ResourceDefinition as V, AuthHelpers as Vt, ResourcePermissions as W, AuthenticatorContext as Wt, CrudRouteKey as X, JWTPayload as Xt, PresetResult as Y, ArcRequest as Yt, CrudSchemas as Z, ObjectId as Zt, ControllerQueryOptions as _, HookOperation as _n, Guard as _t, InferAdapterDoc as a, SlugExt as an, ResourceExtensions as at, ParsedQuery as b, HookSystem as bn, OperationFilter as bt, TypedController as c, BulkMixin as cn, AggMeasureShorthand as ct, PaginationResult as d, QueryResolverConfig as dn, AggregationDateRangeRequirement as dt, BaseController as en, RouteSchemaOptions as et, IntrospectionData as f, AccessControl as fn, AggregationIndexHint as ft, ArcInternalMetadata as g, HookHandler as gn, AggregationsMap as gt, ResourceMetadata as h, HookContext as hn, AggregationRateLimit as ht, ValidationResult as i, TreeMixin as in, ResourceHooks as it, ArcDecorator as j, ArcDeleteResult as jn, RouteMcpConfig as jt, IntrospectionPluginOptions as k, defineHook as kn, ActionsMap as kt, TypedRepository as l, BaseCrudController as ln, AggregationCacheConfig as lt, RegistryStats as m, DefineHookOptions as mn, AggregationMaterializedResult as mt, ConfigError as n, SoftDeleteMixin as nn, FieldRule$1 as nt, InferDocType as o, SlugMixin as on, EventDefinition as ot, RegistryEntry as p, AccessControlConfig as pn, AggregationMaterializedContext as pt, PresetFunction as q, AnyRecord as qt, ValidateOptions as r, TreeExt as rn, ResourceHookContext as rt, InferResourceDoc as s, BulkExt as sn, AggMeasureInput as st, RouteHandlerMethod$1 as t, SoftDeleteExt as tn, ArcFieldRule as tt, TypedResourceConfig as u, QueryResolver as un, AggregationConfig as ut, LookupOption as v, HookPhase as vn, Interceptor as vt, ServiceContext as w, afterUpdate as wn, Transform as wt, PopulateOption as x, HookSystemOptions as xn, PipelineConfig as xt, OwnershipCheck as y, HookRegistration as yn, NextFunction as yt, ResourceRegistry as z, BodySanitizer as zn, IRequestContext as zt };