@classytic/arc 2.32.0 → 2.34.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 (193) hide show
  1. package/README.md +1 -1
  2. package/bin/arc.js +58 -17
  3. package/dist/{BaseController-Dk34dxhI.mjs → BaseController-CbkuhNI2.mjs} +329 -35
  4. package/dist/{EventTransport-2uVV34WU.d.mts → EventTransport-BEVP50ka.d.mts} +16 -0
  5. package/dist/{EventTransport-BV10D0oG.mjs → EventTransport-tq4CEB-q.mjs} +39 -2
  6. package/dist/{HookSystem-vimw5D7Z.mjs → HookSystem-DqXkMXkB.mjs} +2 -2
  7. package/dist/{ResourceRegistry-BVr7PgA0.mjs → ResourceRegistry-DoALUlOO.mjs} +3 -2
  8. package/dist/{actionPermissions-Dti7ebyv.mjs → actionPermissions-CUeCmpUd.mjs} +1 -1
  9. package/dist/audit/index.d.mts +2 -2
  10. package/dist/audit/index.mjs +2 -2
  11. package/dist/auth/audit.mjs +1 -1
  12. package/dist/auth/index.d.mts +2 -2
  13. package/dist/auth/index.mjs +39 -7
  14. package/dist/auth/redis-session.mjs +1 -1
  15. package/dist/{authorizationDecision-__T3EJDA.mjs → authorizationDecision-CMMLWmuZ.mjs} +6 -2
  16. package/dist/{types-B7XeLEgK.d.mts → base-Csjc2oF-.d.mts} +74 -74
  17. package/dist/{buildHandler-Dn7rzUJo.mjs → buildHandler-BrdYrG_1.mjs} +4 -4
  18. package/dist/cache/index.mjs +4 -4
  19. package/dist/{caching-DFJqHyFs.mjs → caching-BcGmvnHh.mjs} +2 -2
  20. package/dist/cleanup/index.d.mts +17 -5
  21. package/dist/cleanup/index.mjs +240 -7
  22. package/dist/cli/commands/describe.d.mts +1 -1
  23. package/dist/cli/commands/describe.mjs +3 -3
  24. package/dist/cli/commands/docs.mjs +3 -3
  25. package/dist/cli/commands/doctor.mjs +1 -1
  26. package/dist/cli/commands/generate.mjs +2 -2
  27. package/dist/cli/commands/init.mjs +2 -2
  28. package/dist/cli/commands/introspect.mjs +2 -2
  29. package/dist/cli/index.mjs +3 -3
  30. package/dist/{constants-J99LGTJI.mjs → constants-BxCoxmiN.mjs} +1 -1
  31. package/dist/context/index.mjs +3 -3
  32. package/dist/core/index.d.mts +3 -3
  33. package/dist/core/index.mjs +9 -8
  34. package/dist/{core-BwwmNSp7.mjs → core-BYKa8wHu.mjs} +3 -3
  35. package/dist/{createActionRouter-DkJZWo0m.mjs → createActionRouter-D6T0jWhS.mjs} +8 -8
  36. package/dist/{createAggregationRouter-B1exOVD8.mjs → createAggregationRouter-QLV2PrWP.mjs} +7 -7
  37. package/dist/{createApp-Bv0m-Q6_.mjs → createApp-zR-DEesr.mjs} +128 -24
  38. package/dist/{defineAggregation-gX4No1gt.mjs → defineAggregation-Nv-DwfOf.mjs} +1 -1
  39. package/dist/{defineResource-XFPPUu2c.mjs → defineResource-ZhuLonLm.mjs} +297 -23
  40. package/dist/discovery/index.mjs +1 -1
  41. package/dist/docs/index.d.mts +1 -1
  42. package/dist/docs/index.mjs +3 -3
  43. package/dist/{elevation-DNXXxgjl.mjs → elevation-B0tlY8y1.mjs} +4 -4
  44. package/dist/encryption/index.mjs +3 -3
  45. package/dist/{errorHandler-B2AZUJUj.mjs → errorHandler-C7xBqHsI.mjs} +42 -5
  46. package/dist/{errorHandler-CpYUJmPA.d.mts → errorHandler-DI5AN2b5.d.mts} +14 -3
  47. package/dist/{errors-Bh1KK5ps.mjs → errors-Cd_yy7aW.mjs} +1 -1
  48. package/dist/{eventPlugin-GF5nnBIq.d.mts → eventPlugin-DSNEZWfV.d.mts} +29 -1
  49. package/dist/{eventPlugin-Dk_TyXeE.mjs → eventPlugin-Dar8qRaj.mjs} +65 -9
  50. package/dist/events/index.d.mts +5 -351
  51. package/dist/events/index.mjs +5 -780
  52. package/dist/events/transports/redis-stream-entry.d.mts +1 -1
  53. package/dist/events/transports/redis-stream-entry.mjs +1 -1
  54. package/dist/events/transports/redis.d.mts +1 -1
  55. package/dist/events/transports/redis.mjs +1 -1
  56. package/dist/factory/index.d.mts +20 -5
  57. package/dist/factory/index.mjs +7 -7
  58. package/dist/{fastify-augmentation-BppG5bMT.d.mts → fastify-augmentation-DVsTAy6X.d.mts} +3 -3
  59. package/dist/{fields-DEi65pGj.mjs → fields-CojeUkS4.mjs} +1 -1
  60. package/dist/{generate-CRSG0jTv.mjs → generate-DiB0nHPR.mjs} +2 -2
  61. package/dist/{headers-BXjQYbeC.mjs → headers-CveNO9gR.mjs} +2 -2
  62. package/dist/{health-5XnTa2uo.mjs → health-CasG2A28.mjs} +1 -1
  63. package/dist/hooks/index.d.mts +1 -1
  64. package/dist/hooks/index.mjs +2 -2
  65. package/dist/idempotency/index.d.mts +45 -1
  66. package/dist/idempotency/index.mjs +90 -9
  67. package/dist/idempotency/redis.mjs +1 -1
  68. package/dist/{index-zq6VrjaB.d.mts → index-B-DvUzsr.d.mts} +12 -2
  69. package/dist/{index-CpzxqePp.d.mts → index-BcR2bFZQ.d.mts} +707 -373
  70. package/dist/{index-ZWhLTp_s.d.mts → index-C5NnMEKF.d.mts} +1 -1
  71. package/dist/{index-DXlHwvuE.d.mts → index-CFAJEWCX.d.mts} +18 -5
  72. package/dist/{index-C-481Zf2.d.mts → index-ugr1tQlW.d.mts} +17 -1
  73. package/dist/index.d.mts +6 -6
  74. package/dist/index.mjs +13 -12
  75. package/dist/{init-BbfP8aky.mjs → init-mdJ-v4Xp.mjs} +18 -12
  76. package/dist/integrations/event-gateway.d.mts +1 -1
  77. package/dist/integrations/event-gateway.mjs +2 -2
  78. package/dist/integrations/index.d.mts +2 -2
  79. package/dist/integrations/index.mjs +1 -1
  80. package/dist/integrations/jobs/index.d.mts +1 -1
  81. package/dist/integrations/jobs/index.mjs +99 -97
  82. package/dist/integrations/mcp/index.d.mts +12 -3
  83. package/dist/integrations/mcp/index.mjs +2 -2
  84. package/dist/integrations/mcp/testing.d.mts +1 -1
  85. package/dist/integrations/mcp/testing.mjs +2 -2
  86. package/dist/integrations/streamline.mjs +3 -3
  87. package/dist/integrations/webhooks.d.mts +124 -1
  88. package/dist/integrations/webhooks.mjs +180 -2
  89. package/dist/integrations/websocket-pushref-redis.mjs +1 -1
  90. package/dist/integrations/websocket-redis.mjs +1 -1
  91. package/dist/integrations/websocket.mjs +1 -1
  92. package/dist/{interface-BP4IJCBO.mjs → interface-DznJrm9R.mjs} +1 -1
  93. package/dist/{jsonBody-DDSR2HFl.mjs → jsonBody-CfGH-GxO.mjs} +2 -2
  94. package/dist/{keys-B-HBTGXl.mjs → keys-WeKvY1dE.mjs} +1 -1
  95. package/dist/{loadResources-DisEAmkm.mjs → loadResources-Bj-aSsXi.mjs} +2 -2
  96. package/dist/{loadResourcesFromEntry-D1MJp13f.mjs → loadResourcesFromEntry-DwaxNcE2.mjs} +2 -2
  97. package/dist/logger/index.mjs +1 -1
  98. package/dist/{mcpPlugin-CpfVpKHE.mjs → mcpPlugin-DX3PHD7K.mjs} +60 -21
  99. package/dist/{memory-DsuWnj4T.mjs → memory-qYSB7rMQ.mjs} +2 -2
  100. package/dist/{metrics-DdQL8syd.mjs → metrics-D7uchLjN.mjs} +2 -2
  101. package/dist/middleware/index.d.mts +1 -1
  102. package/dist/middleware/index.mjs +3 -3
  103. package/dist/migrations/index.mjs +2 -2
  104. package/dist/{module-Br81am6m.mjs → module-CwKsgZXp.mjs} +1 -1
  105. package/dist/{multipartBody-BnkBLN7v.mjs → multipartBody-4w-S3-PU.mjs} +1 -1
  106. package/dist/{openapi-DIRzvxC0.mjs → openapi-CkV5mQ53.mjs} +6 -6
  107. package/dist/outbox-admin/index.d.mts +2 -2
  108. package/dist/outbox-admin/index.mjs +4 -4
  109. package/dist/outbox-module-C1Bs_FXF.d.mts +382 -0
  110. package/dist/outbox-module-D1QfHqO4.mjs +846 -0
  111. package/dist/permissions/index.d.mts +2 -2
  112. package/dist/permissions/index.mjs +5 -5
  113. package/dist/{permissions-Dl_m0NCe.mjs → permissions-CP47jdi7.mjs} +25 -6
  114. package/dist/{pipe-ELHCP3zj.mjs → pipe-BzzoEhHj.mjs} +2 -2
  115. package/dist/pipeline/index.d.mts +1 -1
  116. package/dist/pipeline/index.mjs +2 -2
  117. package/dist/{pipeline-CPA-jx0M.mjs → pipeline-q69DZzgG.mjs} +6 -6
  118. package/dist/plugins/index.d.mts +6 -6
  119. package/dist/plugins/index.mjs +16 -16
  120. package/dist/plugins/response-cache.mjs +2 -2
  121. package/dist/plugins/tracing-entry.mjs +2 -2
  122. package/dist/{pluralize-C2ZkKbsn.mjs → pluralize-B7xf8qZF.mjs} +1 -1
  123. package/dist/{preloadResources-CDs87fTw.mjs → preloadResources-BkyqeTDz.mjs} +1 -1
  124. package/dist/{preloadResources-T1hmlTEZ.d.mts → preloadResources-g07ldtzh.d.mts} +30 -7
  125. package/dist/presets/filesUpload.d.mts +2 -2
  126. package/dist/presets/filesUpload.mjs +5 -5
  127. package/dist/presets/index.d.mts +2 -2
  128. package/dist/presets/index.mjs +2 -2
  129. package/dist/presets/multiTenant.d.mts +1 -1
  130. package/dist/presets/multiTenant.mjs +3 -3
  131. package/dist/presets/search.d.mts +2 -2
  132. package/dist/presets/search.mjs +2 -2
  133. package/dist/{presets-Dntd_pUO.mjs → presets-BAPijK_A.mjs} +4 -4
  134. package/dist/projection-BKM0Ui-s.mjs +42 -0
  135. package/dist/{queryCachePlugin-Dhps-Ldt.mjs → queryCachePlugin-DhsZ0ZIc.mjs} +49 -7
  136. package/dist/{rateLimit-C7mmAGs3.mjs → rateLimit-D4-ASwsI.mjs} +2 -2
  137. package/dist/{rateLimitKey-CccEFBfk.mjs → rateLimitKey-XngqVNEV.mjs} +1 -1
  138. package/dist/{realtime-DVvVZZFz.mjs → realtime-G1TC6Yzc.mjs} +9 -9
  139. package/dist/{redis-stream-qN6A-RFv.d.mts → redis-stream-mwLWlfHG.d.mts} +1 -1
  140. package/dist/{registerRoute-B_QrjYZS.mjs → registerRoute-Ba8v0PCa.mjs} +4 -4
  141. package/dist/registry/index.d.mts +3 -3
  142. package/dist/registry/index.mjs +3 -3
  143. package/dist/{registry-DqUmzA9W.mjs → registry-CLxCR5LF.mjs} +2 -2
  144. package/dist/{renewingLease-BpP9wwj7.mjs → renewingLease-BnWtbA2U.mjs} +1 -1
  145. package/dist/{replyHelpers-DS-P0b32.mjs → replyHelpers-BSBVCwL3.mjs} +2 -2
  146. package/dist/repositoryFilter-BANpz6BX.mjs +78 -0
  147. package/dist/{requestContext-B6vQF-sy.mjs → requestContext-uYE35JxI.mjs} +1 -1
  148. package/dist/{rolldown-runtime-BhflRrBs.mjs → rolldown-runtime-DNg-KNCk.mjs} +1 -1
  149. package/dist/runtimeCapabilities-Dm88DaTW.mjs +49 -0
  150. package/dist/{schedules-C2Q3uv2d.mjs → schedules-CAcZgia0.mjs} +3 -3
  151. package/dist/{schemaConverter-Crjvczi_.mjs → schemaConverter-XH1RZRyu.mjs} +1 -1
  152. package/dist/{schemaIR-CnyKK_1j.mjs → schemaIR-CDvclAew.mjs} +2 -2
  153. package/dist/schemas/index.mjs +1 -1
  154. package/dist/scim/index.mjs +2 -2
  155. package/dist/scope/index.mjs +5 -5
  156. package/dist/{scope-afz_Ieek.mjs → scope-BaWcqBvr.mjs} +4 -4
  157. package/dist/{simpleEqualityMatcher-C4KiMWUa.mjs → simpleEqualityMatcher-Se8VrsaP.mjs} +1 -1
  158. package/dist/{sse-B9P4Y-UI.mjs → sse-Cf0jUHsh.mjs} +5 -5
  159. package/dist/{sseStream-B3zb_d1h.mjs → sseStream-Bvpi-UXQ.mjs} +2 -2
  160. package/dist/{store-helpers-DmEMHlPQ.mjs → store-helpers-B88p99Z2.mjs} +1 -1
  161. package/dist/{streaming-DPZdQmDz.mjs → streaming-DsT1B5zJ.mjs} +1 -1
  162. package/dist/{subscribe-helpers-sp5xTDVA.mjs → subscribe-helpers-CLIOHsOK.mjs} +2 -2
  163. package/dist/sync/changelog.mjs +1 -1
  164. package/dist/testing/cleanupStoreContract.d.mts +1 -1
  165. package/dist/testing/cleanupStoreContract.mjs +3 -1
  166. package/dist/testing/index.d.mts +24 -12
  167. package/dist/testing/index.mjs +24 -12
  168. package/dist/testing/outboxStoreContract.mjs +1 -1
  169. package/dist/testing/storageContract.mjs +1 -1
  170. package/dist/{transactionContext-BvLKvOIL.mjs → transactionContext-BYtWnwAS.mjs} +1 -1
  171. package/dist/{typeGuards-B_5-hKjJ.mjs → typeGuards-jjKJDN13.mjs} +1 -1
  172. package/dist/types/index.d.mts +3 -3
  173. package/dist/types/index.mjs +1 -1
  174. package/dist/types/storage.mjs +1 -1
  175. package/dist/{types-CT5vu6M0.d.mts → types-BcTumAz_.d.mts} +41 -7
  176. package/dist/{types-CwcUQ610.d.mts → types-BfC83KsP.d.mts} +15 -1
  177. package/dist/{types-DXYX7dIR.mjs → types-CoqIRrkY.mjs} +4 -4
  178. package/dist/{types-C4QE8qnm.d.mts → types-DVLddu2q.d.mts} +144 -1
  179. package/dist/usage/index.d.mts +1 -1
  180. package/dist/usage/index.mjs +0 -0
  181. package/dist/{userHelpers-BNsOXzOd.mjs → userHelpers-CWF5pbZD.mjs} +1 -1
  182. package/dist/utils/index.d.mts +57 -5
  183. package/dist/utils/index.mjs +12 -11
  184. package/dist/{utils-BtOha_Sp.mjs → utils-h1V_sT-t.mjs} +38 -7
  185. package/dist/{validate-PMCPR9El.mjs → validate-CdQlz52F.mjs} +2 -2
  186. package/dist/{versioning-D_rhHfZm.d.mts → versioning-DEs-_hFi.d.mts} +1 -1
  187. package/dist/{versioning-CKv2jz_U.mjs → versioning-rZbyLVD-.mjs} +3 -3
  188. package/package.json +17 -16
  189. package/skills/arc/SKILL.md +96 -4
  190. package/skills/arc/references/api-reference.md +19 -0
  191. package/skills/arc/references/production.md +2 -2
  192. package/dist/projection-CjHhM5W1.mjs +0 -18
  193. package/dist/repositoryFilter-DotlxG3L.mjs +0 -45
@@ -69,6 +69,14 @@ await app.listen({ port: 8040, host: '0.0.0.0' });
69
69
 
70
70
  **Boot order (fixed):** `beforeBoot` → modules resolve → `plugins` → `bootstrap[]` → `resources` → `afterResources` → `onReady`.
71
71
 
72
+ **`role` — one image, N deployments (2.33).** `createApp({ role })` declares what a process is FOR, so the same codebase ships as api / worker / relay / scheduler without per-deployment builds:
73
+
74
+ ```typescript
75
+ createApp({ role: process.env.ARC_ROLE as 'api' | 'worker' | 'relay' | 'scheduler' | 'all' })
76
+ ```
77
+
78
+ `api` and `worker` mount NO module schedule arms; `relay` mounts only `kind: 'relay'` arms (the outbox relay, durable webhook delivery); `scheduler` mounts all of them. Declaring `relay`/`scheduler` together with `resources`/`resourceDir` is BOOT-FATAL — a relay process must not serve routes. Default `all` is the pre-2.33 behaviour, unchanged. `createWorker()` forwards `role`; without one it still runs everything non-HTTP.
79
+
72
80
  `beforeBoot()` (2.21) runs before Fastify exists and before module thunks / resource files import — connect the DB there instead of dynamic-import ordering hacks.
73
81
 
74
82
  For async-booted engines, pass `resources` as a factory so it runs after `bootstrap[]`:
@@ -162,7 +170,7 @@ defineResource({ name: 'health', customRoutesOnly: true, routes: [...] });
162
170
  defineResource({ name: 'pipeline', adapter, defaultLimit: 200, maxLimit: 500 });
163
171
  ```
164
172
 
165
- 400s on `?limit=` violations include the cap: `message: "Query parameter 'limit' must be <= 500 (got 800) (cap is 500)"` + `meta: { field: 'limit', cap: 500 }`.
173
+ A `?limit=` above the cap is **clamped to it**, not rejected (2.33 — see Query parsing). The effective cap rides in the OpenAPI parameter description.
166
174
 
167
175
  ## fieldRules
168
176
 
@@ -304,12 +312,18 @@ Decorates `app.authenticate` / `app.optionalAuthenticate` / `app.authorize`.
304
312
  ## Authoritative gotchas
305
313
 
306
314
  - **Field-write reject (default).** Requests carrying non-writable fields → 403 with denied list. Opt into silent strip: `defineResource({ onFieldWriteDenied: 'strip' })`.
315
+ - **Immutable writes are STRIPPED, not refused (default).** `PATCH {"type":"rent"}` on an `immutable` field returns 200 with the value unchanged. Opt into 403: `defineResource({ onImmutableWrite: 'reject' })`, or `ARC_STRICT_IMMUTABLE_WRITES=true`. Separate from `onFieldWriteDenied` because a full-object PATCH echoing an immutable field back UNCHANGED is legitimate, and the sanitizer has no stored document to tell that from a real change — turn it on if you send partial patches.
316
+ - **Log redaction is REPLACED, not merged.** Setting `logger.redact` makes yours the whole policy; arc's defaults stop applying. Superset them: `import { DEFAULT_LOGGER_REDACT_PATHS } from '@classytic/arc/factory'` and spread it into your list, rather than hand-copying (a copy drifts — one host's had lost `*.passwordHash`).
317
+ - **Error disclosure derives from `preset`, falling back to the environment.** `includeStack` and `exposeInternalMessages` are both derived, and host keys MERGE over them — stating one no longer resets the other. Direct `errorHandlerPlugin` registration skips the preset derivation entirely.
307
318
  - **multiTenant injects org on UPDATE.** Body `organizationId` is overwritten with caller's scope (closes tenant-hop).
308
319
  - **`request.user`** is `Record<string, unknown> | undefined`. Guard with `if (req.user)` on public routes.
309
320
  - **Arc reads singular `user.role`** (string, comma-separated, or array). Don't put a plural `roles` field on the model.
310
321
  - **`verifySignature(body, …)`** throws on parsed body — pass `req.rawBody`.
311
322
  - **MCP tools fail-closed.** A resource action without per-action perm + no `actionPermissions` + no `permissions.update` fallback → throws at tool generation. Declare `allowPublic()` to opt into unauthenticated.
312
323
  - **Better Auth roles.** BA stores `member.role = "admin,recruiter"` (comma-separated). Arc splits into `scope.orgRoles = ['admin', 'recruiter']`; `?role=admin` won't match — use `role[like]=admin`.
324
+ - **Read-only repositories refuse write ROUTES at boot (2.33).** Kits' Better Auth overlays are sealed by default (`capabilities.readOnly`) — BA owns hashing, session revocation, org cascades. Mounting `create`/`update`/`delete` over one is boot-fatal: disable those routes, or pass the kit's `unsafeWritable: true` for administrative repair only. Normal identity mutations go through `auth.api`.
325
+ - **Arc closes only stores/transports it BUILT (2.33).** One you hand to `idempotencyPlugin` / `eventPlugin` / `queryCachePlugin` stays yours to close — arc used to close every one, which wiped a co-resident app's live idempotency records.
326
+ - **Idempotency self-checks its store at boot (2.33).** A probe write must read back under its own key and NOT under another; registration throws otherwise. Catches a filter-stripping repository (a pathless Mongoose schema under `strictQuery: true` strips filters to `{}`), which otherwise replays cached responses across keys AND users. `selfCheck: false` to disable; the adapter's per-read identity checks remain.
313
327
 
314
328
  ## routeGuards + defineGuard
315
329
 
@@ -369,6 +383,28 @@ defineResource({
369
383
  });
370
384
  ```
371
385
 
386
+ **An unknown filter key is DROPPED by default, and dropping WIDENS.** `?filters[status]=pending`
387
+ against a parser that does not whitelist `status` returns 200 with every row unfiltered — the
388
+ caller asked to narrow and got a broader set. Refuse instead:
389
+
390
+ ```typescript
391
+ new QueryParser({ allowedFilterFields: [...], strictFilterFields: true }) // 400 ValidationError
392
+ ```
393
+
394
+ Off by default (an unconditional throw would reject requests that work today). Fleet-wide via
395
+ `ARC_STRICT_QUERY_PARAMS=true`; an explicit `false` beats the env. Only the FILTER whitelist —
396
+ a dropped sort or operator degrades ordering, it does not widen the row set.
397
+
398
+ **`?limit` above the cap CLAMPS, it does not 400** (2.33). The parser and `QueryResolver` both
399
+ clamp, and the querystring schema no longer emits `maximum` — it used to reject first, making the
400
+ documented clamp unreachable and turning a benign over-ask into a 400 that callers render as an
401
+ empty list. The cap is in the parameter description instead, so OpenAPI still shows it.
402
+
403
+ **One cap, one owner.** Page size is capped by the parser, the repository's pagination engine and
404
+ arc — lowest wins silently. Arc defers to the parser's `maxLimit` when the host states none, and
405
+ WARNS at boot when the parser allows more than the repository (`pagination-cap-mismatch`).
406
+ Configure both to the same number.
407
+
372
408
  ## Aggregations
373
409
 
374
410
  `GET /:prefix/aggregations/:name` per entry. Portable `$match → $group → $project → $sort → $limit` against `repo.aggregate(req, options)` — same shape across kits.
@@ -453,6 +489,31 @@ defineResource({
453
489
 
454
490
  POST/PATCH/DELETE bumps resource version. Response header: `x-cache: HIT | STALE | MISS`. `runtime: 'distributed'` requires `stores.queryCache: RedisCacheStore`.
455
491
 
492
+ ## Transactional writes (2.33)
493
+
494
+ `transactional: true` runs the PERSISTENCE step inside `withTransaction`. Write verbs receive the tx-bound repository plus `ctx.uow` — the join point for work outside the repository:
495
+
496
+ ```typescript
497
+ defineResource({
498
+ name: 'order',
499
+ adapter,
500
+ transactional: true,
501
+ writes: {
502
+ create: async (data, ctx) => {
503
+ const order = await ctx.repository.create(data); // tx-bound
504
+ await outbox.store(event, { session: ctx.uow?.session }); // SAME transaction
505
+ return order;
506
+ },
507
+ },
508
+ });
509
+ ```
510
+
511
+ - Hooks stay OUTSIDE the retry — before/after run once; only persistence re-runs.
512
+ - Transient conflicts re-run with jittered backoff; `VersionConflictError` surfaces (409), never retried.
513
+ - **Retry authority is the KIT's.** mongokit declares `transactionRetry: 'managed'` (its driver already retries internally) and is called exactly once; sqlitekit declares `'caller'` and gets the envelope's loop. Arc never stacks a second policy.
514
+ - **BOOT-FATAL** without `withTransaction`, and at REGISTRATION when the repository reports `capabilities.transactions !== true` (standalone Mongo, D1, unconfirmed topology). The capability is checked at registration, not at `defineResource()`, because that usually runs at module import — before `beforeBoot()` connects.
515
+ - `ctx.uow.session` is empty on connection-bound backends (SQLite); the tx-bound repo is the join point there.
516
+
456
517
  ## Events
457
518
 
458
519
  CRUD events auto-emit: `{resource}.created` / `{resource}.updated` / `{resource}.deleted`. Manual publish:
@@ -464,13 +525,27 @@ await app.events.subscribe('order.*', async (event) => { … });
464
525
 
465
526
  Transports: Memory · Redis Pub/Sub (fire-and-forget) · Redis Streams (durable, consumer groups, DLQ). Event types live in `@classytic/primitives/events` (`createEvent`, `createChildEvent`, `matchEventPattern`, …); arc re-exports the runtime `MemoryEventTransport` only.
466
527
 
467
- **Outbox** (at-least-once via transactional outbox):
528
+ **Outbox** (at-least-once via transactional outbox) — prefer the MODULE, so the store is a slot other modules depend on rather than a value threaded by hand:
529
+
530
+ ```typescript
531
+ createApp({ modules: [createOutboxModule({ store: repositoryAsOutboxStore(outboxRepo) })] });
532
+ // Dev: createOutboxModule({ store: new MemoryOutboxStore() })
533
+ ```
534
+
535
+ The relay arm is `kind: 'relay'`, so `role: 'relay'` deployments drain it. It resolves the RAW transport, never the `fastify.events` facade (the facade fails open, which would acknowledge rows whose publish failed) — no transport is boot-fatal. **Fencing (2.33):** stores that support `claimPendingFenced` mint a token in the claim CAS; a relay that lost its lease mid-batch cannot ack or re-schedule a row a successor already owns. Feature-detected end to end.
536
+
537
+ **Durable webhooks (2.33)** — delivery survives crash and deploy, using the outbox machinery rather than a second queue:
468
538
 
469
539
  ```typescript
470
- const outbox = new EventOutbox({ repository: outboxRepo, transport });
471
- // Dev: { store: new MemoryOutboxStore(), transport }
540
+ const store = repositoryAsOutboxStore(deliveryRepo);
541
+ createApp({
542
+ modules: [createDurableWebhookModule({ store })], // relay does the signed POST
543
+ plugins: async (f) => f.register(webhookPlugin, { durable: { store } }), // enqueue, don't POST
544
+ });
472
545
  ```
473
546
 
547
+ One row per (event × subscription), deterministic id so an at-least-once upstream enqueues each delivery exactly once. Transient failures (network/timeout/429/5xx) retry with backoff; 4xx dead-letters on the FIRST attempt. `durable` + `retry` together is a registration error.
548
+
474
549
  Full event recipes (retry, DLQ, dual-publish warnings, idempotency keys) → [references/events.md](references/events.md).
475
550
 
476
551
  ## Hooks
@@ -652,6 +727,23 @@ await app.audit.custom('order', req.params.id, 'refund', { reason }, { user });
652
727
 
653
728
  `@classytic/arc/usage` — `usagePlugin` decorates `fastify.usage` with per-actor, per-UTC-month counters (`record`/`summary`/`period`/`actorOf`; actor chain org → user → client → ip). Auto-tracks `api.requests` (default on) + `api.egress.bytes` (opt-in); recording is fail-safe (a throwing `UsageStore` never fails a request; `MemoryUsageStore` built in, Redis/kit backends are ~5 lines). Pair with `createApp({ rateLimit: { plan: { resolve, limits: { free: { max: 60 }, enterprise: false }, default: 'free' } } })` for per-plan ceilings — `false` = unlimited, unknown/throwing resolvers fall back to `default` then global `max`, and buckets follow the tenant key chain by default. Caveat: the limiter runs before route auth, so `resolve` sees the raw request.
654
729
 
730
+ ## Runtime capability registry (2.33)
731
+
732
+ Under `runtime: 'distributed'`, subsystems holding process-local state must declare it — the end-of-boot audit FAILS the app on undeclared memory state, naming every violator:
733
+
734
+ ```typescript
735
+ import { declareRuntimeCapability } from '@classytic/arc/utils';
736
+
737
+ declareRuntimeCapability(fastify, {
738
+ subsystem: 'billing.sequence-cache',
739
+ durability: 'memory', // 'memory' | 'shared'
740
+ accepted: true, // per-process BY DESIGN (logged, never fatal)
741
+ detail: 'short-TTL cache; correctness from TTL, not shared state',
742
+ });
743
+ ```
744
+
745
+ `createApp`'s constructor-time guard only sees what it is passed; state a host wires inside `plugins()` was invisible to it and lived on a checklist. This makes the checklist enforcement. Arc's webhooks plugin declares its default in-memory store.
746
+
655
747
  ## Adapters
656
748
 
657
749
  Cross-framework adapter contract lives in `@classytic/repo-core/adapter`. Every kit-specific adapter ships from its kit's `/adapter` subpath; arc itself has zero kit-bound adapters.
@@ -126,8 +126,27 @@ import {
126
126
  import { tracingPlugin } from '@classytic/arc/plugins/tracing';
127
127
  import { auditPlugin } from '@classytic/arc/audit';
128
128
  import { idempotencyPlugin } from '@classytic/arc/idempotency';
129
+ // Runtime capability registry (2.33) — declare process-local state so a
130
+ // `runtime: 'distributed'` boot fails on undeclared memory state:
131
+ import { declareRuntimeCapability } from '@classytic/arc/utils';
129
132
  ```
130
133
 
134
+ **Idempotency store — the one schema mistake that matters.** Backing the store
135
+ with a repository requires the key path to survive the filter. A Mongoose
136
+ schema with NO declared paths under a global `strictQuery: true` has EVERY
137
+ filter key stripped, so `getOne({ _id: key })` becomes `getOne({})`: one row
138
+ absorbs every key and responses replay across keys AND users. Declare the path
139
+ and pin the option on the schema:
140
+
141
+ ```typescript
142
+ new Schema({ _id: String }, { strict: false, strictQuery: false, timestamps: false })
143
+ // NOT `new Schema({}, { _id: false, strict: false })`
144
+ ```
145
+
146
+ The plugin's boot self-check (default on, `selfCheck: false` to disable) probes
147
+ for exactly this and refuses to register; the adapter also throws
148
+ `IdempotencyStoreMisconfiguredError` on any cross-key read at runtime.
149
+
131
150
  ## Integrations
132
151
 
133
152
  ```typescript
@@ -292,7 +292,7 @@ field masking. Client = plain `EventSource` with `?token=`. No replay — reconn
292
292
  refetches the list. Choosing: per-user resource views → realtime; bidirectional/geo
293
293
  → `integrations/websocket` (its CRUD bridge is org-coarse — never expose
294
294
  row-permissioned resources through it); event firehose → sse. Massive fan-out
295
- (viral feeds, live scores) → publish once to an edge layer (Ably/Centrifugo/CF)
295
+ (viral feeds, live scores) → publish once to a dedicated edge fan-out layer
296
296
  via `app.events.subscribe(...)`; full tiering guide: docs/production-ops/realtime.mdx.
297
297
 
298
298
  ```typescript
@@ -618,7 +618,7 @@ presets: [bulkPreset({ operations: ['createMany', 'updateMany'], maxCreateItems:
618
618
  ## Compensating Transaction
619
619
 
620
620
  In-process rollback primitive — runs steps in order, compensates in reverse on failure.
621
- For distributed sagas across services, use Temporal, Inngest, or Streamline.
621
+ For distributed sagas across services, use `@classytic/streamline` or a dedicated workflow orchestrator.
622
622
 
623
623
  ```typescript
624
624
  import { withCompensation } from '@classytic/arc/utils';
@@ -1,18 +0,0 @@
1
- /*! @classytic/arc v2.32.0 | MIT | https://classytic.com */
2
- import { c as getOrgId, g as getUserId, x as isMember } from "./types-DXYX7dIR.mjs";
3
- //#region src/scope/projection.ts
4
- /**
5
- * Compute the request-scope projection. Returns `undefined` when no
6
- * scope is attached (public / unscoped routes) so hosts can idiomatically
7
- * write `ctx.scope?.organizationId` without a double-null check.
8
- */
9
- function buildRequestScopeProjection(scope) {
10
- if (!scope) return void 0;
11
- return {
12
- organizationId: getOrgId(scope),
13
- userId: getUserId(scope),
14
- orgRoles: isMember(scope) ? scope.orgRoles : void 0
15
- };
16
- }
17
- //#endregion
18
- export { buildRequestScopeProjection as t };
@@ -1,45 +0,0 @@
1
- /*! @classytic/arc v2.32.0 | MIT | https://classytic.com */
2
- import { arcLog } from "./logger/index.mjs";
3
- import { policyRecordToFilter } from "@classytic/repo-core/filter";
4
- //#region src/core/repositoryFilter.ts
5
- const log = arcLog("repository-filter");
6
- let warnedUnconvertible = false;
7
- /** True for a `{ $op: ... }` operator object (all keys `$`-prefixed). */
8
- function isOperatorObject(value) {
9
- if (value === null || typeof value !== "object") return false;
10
- if (Array.isArray(value) || value instanceof Date) return false;
11
- const keys = Object.keys(value);
12
- return keys.length > 0 && keys.some((k) => k.startsWith("$"));
13
- }
14
- /**
15
- * Does this compound filter contain any `$`-operator (top-level logical like
16
- * `$and`/`$or`, or a field operator object like `{ $in: [...] }`)? If not, it is
17
- * pure flat equality and portable as-is.
18
- */
19
- function filterHasDollarOperator(record) {
20
- for (const [key, value] of Object.entries(record)) {
21
- if (key.startsWith("$")) return true;
22
- if (isOperatorObject(value)) return true;
23
- }
24
- return false;
25
- }
26
- /**
27
- * Normalize a compound query/policy filter for the repository layer. Flat
28
- * equality records pass through unchanged; anything carrying `$`-operators is
29
- * converted to the portable repo-core {@link Filter} IR so it compiles
30
- * identically on Mongo, SQLite, Postgres, and custom adapters.
31
- */
32
- function toRepositoryFilter(record) {
33
- if (!filterHasDollarOperator(record)) return record;
34
- try {
35
- return policyRecordToFilter(record);
36
- } catch (err) {
37
- if (!warnedUnconvertible) {
38
- warnedUnconvertible = true;
39
- log.warn(`A \`\$\`-operator filter could not be normalized to portable Filter IR (operator outside arc's policy dialect). Passing the raw record to the repository — MongoKit handles it; non-Mongo kits may not. (${err instanceof Error ? err.message : String(err)})`);
40
- }
41
- return record;
42
- }
43
- }
44
- //#endregion
45
- export { toRepositoryFilter as t };