@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
@@ -1,15 +1,17 @@
1
- /*! @classytic/arc v2.32.0 | MIT | https://classytic.com */
2
- import { h as SYSTEM_FIELDS, o as DEFAULT_TENANT_FIELD } from "./constants-J99LGTJI.mjs";
1
+ /*! @classytic/arc v2.34.0 | MIT | https://classytic.com */
2
+ import { h as SYSTEM_FIELDS, o as DEFAULT_TENANT_FIELD } from "./constants-BxCoxmiN.mjs";
3
3
  import { arcLog } from "./logger/index.mjs";
4
- import { a as conjoinPolicyFilters } from "./authorizationDecision-__T3EJDA.mjs";
5
- import { d as createDomainError, f as createError, i as NotFoundError, p as isArcError, r as ForbiddenError } from "./errors-Bh1KK5ps.mjs";
6
- import { n as getUserRoles, t as getUserId } from "./userHelpers-BNsOXzOd.mjs";
7
- import { b as isElevated, c as getOrgId, n as PUBLIC_SCOPE, x as isMember } from "./types-DXYX7dIR.mjs";
8
- import { t as simpleEqualityMatcher } from "./simpleEqualityMatcher-C4KiMWUa.mjs";
9
- import { t as toRepositoryFilter } from "./repositoryFilter-DotlxG3L.mjs";
10
- import { i as resolveEffectiveRoles, n as applyFieldWritePermissions } from "./fields-DEi65pGj.mjs";
11
- import { g as ArcQueryParser, n as scheduleBackground } from "./utils-BtOha_Sp.mjs";
12
- import { t as buildQueryKey } from "./keys-B-HBTGXl.mjs";
4
+ import { a as conjoinPolicyFilters } from "./authorizationDecision-CMMLWmuZ.mjs";
5
+ import { d as createDomainError, f as createError, i as NotFoundError, p as isArcError, r as ForbiddenError } from "./errors-Cd_yy7aW.mjs";
6
+ import { n as getUserRoles, t as getUserId } from "./userHelpers-CWF5pbZD.mjs";
7
+ import { b as isElevated, c as getOrgId, n as PUBLIC_SCOPE, x as isMember } from "./types-CoqIRrkY.mjs";
8
+ import { t as simpleEqualityMatcher } from "./simpleEqualityMatcher-Se8VrsaP.mjs";
9
+ import { n as toRepositoryFilter } from "./repositoryFilter-BANpz6BX.mjs";
10
+ import { i as resolveEffectiveRoles, n as applyFieldWritePermissions } from "./fields-CojeUkS4.mjs";
11
+ import { g as ArcQueryParser, n as scheduleBackground } from "./utils-h1V_sT-t.mjs";
12
+ import { t as buildQueryKey } from "./keys-WeKvY1dE.mjs";
13
+ import { retryingTransaction } from "@classytic/repo-core/repository";
14
+ import { isProductionEnv } from "@classytic/primitives/environment";
13
15
  //#region src/core/AccessControl.ts
14
16
  const log$1 = arcLog("access-control");
15
17
  var AccessControl = class {
@@ -259,9 +261,11 @@ const DEFAULT_FIELD_WRITE_DENIAL_POLICY = "reject";
259
261
  var BodySanitizer = class {
260
262
  schemaOptions;
261
263
  onFieldWriteDenied;
264
+ onImmutableWrite;
262
265
  constructor(config) {
263
266
  this.schemaOptions = config.schemaOptions;
264
267
  this.onFieldWriteDenied = config.onFieldWriteDenied ?? "reject";
268
+ this.onImmutableWrite = config.onImmutableWrite ?? (process.env.ARC_STRICT_IMMUTABLE_WRITES === "true" ? "reject" : "strip");
265
269
  }
266
270
  /**
267
271
  * Strip readonly and system-managed fields from request body.
@@ -276,11 +280,16 @@ var BodySanitizer = class {
276
280
  const scopeForRules = req ? (meta ?? req.metadata)?._scope ?? PUBLIC_SCOPE : void 0;
277
281
  const scopeIsElevated = scopeForRules ? isElevated(scopeForRules) : false;
278
282
  const fieldRules = this.schemaOptions.fieldRules ?? {};
283
+ const immutableAttempts = [];
279
284
  for (const [field, rules] of Object.entries(fieldRules)) {
280
285
  const bypass = Boolean(rules.preserveForElevated) && scopeIsElevated;
281
286
  if ((rules.systemManaged || rules.readonly) && !bypass) delete sanitized[field];
282
- if (_operation === "update" && (rules.immutable || rules.immutableAfterCreate) && !bypass) delete sanitized[field];
287
+ if (_operation === "update" && (rules.immutable || rules.immutableAfterCreate) && !bypass) {
288
+ if (Object.hasOwn(sanitized, field)) immutableAttempts.push(field);
289
+ delete sanitized[field];
290
+ }
283
291
  }
292
+ if (immutableAttempts.length > 0 && this.onImmutableWrite === "reject") throw new ForbiddenError(`Cannot modify immutable field${immutableAttempts.length === 1 ? "" : "s"}: ${immutableAttempts.join(", ")}`);
284
293
  if (req) {
285
294
  const arcContext = meta ?? req.metadata;
286
295
  const scope = arcContext?._scope ?? PUBLIC_SCOPE;
@@ -363,6 +372,73 @@ function buildTenantRepoOptions(req, tenantField, arcContext) {
363
372
  return out;
364
373
  }
365
374
  /**
375
+ * Marks a write method ARC installed.
376
+ *
377
+ * The constructor binds `create`/`update`/`delete`, so EVERY controller owns
378
+ * all three and the bound function is a fresh object per instance —
379
+ * `Object.hasOwn` and identity comparison both say nothing about overrides.
380
+ * Provenance does: a class field initialises after `super()` and REPLACES the
381
+ * bound method, mark and all.
382
+ *
383
+ * `Symbol.for` so it still matches across two arc copies in one graph.
384
+ */
385
+ const ARC_BOUND_WRITE = Symbol.for("arc.boundWriteMethod");
386
+ /**
387
+ * Marks a controller PROTOTYPE whose write methods dispatch declared verbs.
388
+ * `defineResource` refuses `writes` without it: a duck-typed `configure()`
389
+ * proves an options channel exists, not that anything dispatches from it.
390
+ * Lives on the prototype — one mark per class, zero per-instance cost.
391
+ */
392
+ const ARC_WRITE_VERB_CAPABLE = Symbol.for("arc.writeVerbCapable");
393
+ /** Tag a bound write method as arc's own. Returns the same function. */
394
+ function markArcBoundWrite(fn) {
395
+ Object.defineProperty(fn, ARC_BOUND_WRITE, {
396
+ value: true,
397
+ enumerable: false,
398
+ configurable: true
399
+ });
400
+ return fn;
401
+ }
402
+ /** Did arc install this write method, as opposed to the host replacing it? */
403
+ function isArcBoundWrite(fn) {
404
+ return typeof fn === "function" && Reflect.get(fn, ARC_BOUND_WRITE) === true;
405
+ }
406
+ /** Tag a controller class's prototype as write-verb capable. */
407
+ function markWriteVerbCapable(prototype) {
408
+ Object.defineProperty(prototype, ARC_WRITE_VERB_CAPABLE, {
409
+ value: true,
410
+ enumerable: false,
411
+ configurable: true
412
+ });
413
+ }
414
+ /** Do this controller's write methods dispatch declared write verbs? */
415
+ function isWriteVerbCapable(controller) {
416
+ return typeof controller === "object" && controller !== null && Reflect.get(controller, ARC_WRITE_VERB_CAPABLE) === true;
417
+ }
418
+ /**
419
+ * Which of `ops` did the host override?
420
+ *
421
+ * THE single implementation — the boot-fatal reachability check and the
422
+ * field-protection warn both consume it, so they cannot disagree.
423
+ *
424
+ * Two shapes: a prototype method (found by walking to the capability-marked
425
+ * prototype — every layer between is host-authored) and a class field / ctor
426
+ * assignment (found by provenance, since arc owns an own-property for all
427
+ * three regardless). Arc's own mixins define no write methods; a test pins it.
428
+ */
429
+ function detectOverriddenWriteSlots(controller, ops) {
430
+ const instance = controller;
431
+ return ops.filter((op) => {
432
+ if (typeof instance[op] === "function" && !isArcBoundWrite(instance[op])) return true;
433
+ let proto = Object.getPrototypeOf(controller);
434
+ while (proto && !Object.hasOwn(proto, ARC_WRITE_VERB_CAPABLE)) {
435
+ if (Object.hasOwn(proto, op)) return true;
436
+ proto = Object.getPrototypeOf(proto);
437
+ }
438
+ return false;
439
+ });
440
+ }
441
+ /**
366
442
  * Resolve the repository primary key for mutation calls.
367
443
  *
368
444
  * When the resource declares a custom `idField` (slug, jobId, UUID), the
@@ -634,23 +710,44 @@ const log = arcLog("query");
634
710
  */
635
711
  let notedDeniedJoins = false;
636
712
  function noteDeniedJoins(kind) {
637
- if (notedDeniedJoins || process.env.NODE_ENV === "production") return;
713
+ if (notedDeniedJoins || isProductionEnv(process.env.NODE_ENV)) return;
638
714
  notedDeniedJoins = true;
639
715
  log.warn(`client requested ${kind} but no allowlist is configured — DENIED (2.24 default flip; it was allow-all). Set schemaOptions.query.allowedPopulate / allowedLookups to enable client-driven joins. (dev-only note, shown once)`);
640
716
  }
641
717
  var QueryResolver = class {
642
718
  queryParser;
643
719
  maxLimit;
720
+ /** Set only when the HOST passed one — it outranks any parser's cap, at any time. */
721
+ explicitMaxLimit;
644
722
  defaultLimit;
645
723
  /** `undefined` means "no default sort" (caller passed `false`). */
646
724
  defaultSort;
725
+ explicitDefaultSort;
647
726
  schemaOptions;
648
727
  tenantField;
649
728
  constructor(config = {}) {
650
729
  this.queryParser = config.queryParser ?? getDefaultQueryParser();
651
- this.maxLimit = config.maxLimit ?? 100;
730
+ /**
731
+ * Precedence: explicit config → the PARSER's own cap → framework default.
732
+ *
733
+ * The middle term is the fix. A resource that writes
734
+ * `new QueryParser({ maxLimit: 1000 })` has already answered "how large may a page
735
+ * be?", and arc previously ignored that and applied 100 anyway. Three layers each
736
+ * capping independently, lowest wins, no signal — a chart-of-accounts picker
737
+ * returned 100 of 696 rows and rendered "No accounts found", while the resource
738
+ * AND the repository were both configured for 1000.
739
+ *
740
+ * Note it reads `config.queryParser`, NOT `this.queryParser`. The fallback default
741
+ * parser declares its own generous cap, so deferring to it would silently raise the
742
+ * ceiling from 100 to 1000 on every endpoint that never configured one — a widening
743
+ * nobody asked for. Only a parser the resource EXPLICITLY supplied counts as an
744
+ * answer; a test pins exactly this.
745
+ */
746
+ this.explicitMaxLimit = config.maxLimit;
747
+ this.maxLimit = config.maxLimit ?? config.queryParser?.maxLimit ?? 100;
652
748
  this.defaultLimit = config.defaultLimit ?? 20;
653
749
  this.defaultSort = config.defaultSort === false ? void 0 : config.defaultSort ?? "-createdAt";
750
+ this.explicitDefaultSort = config.defaultSort !== void 0 && config.defaultSort !== false;
654
751
  this.schemaOptions = config.schemaOptions ?? {};
655
752
  this.tenantField = config.tenantField !== void 0 ? config.tenantField : DEFAULT_TENANT_FIELD;
656
753
  }
@@ -662,18 +759,88 @@ var QueryResolver = class {
662
759
  */
663
760
  setParser(parser) {
664
761
  this.queryParser = parser;
762
+ /**
763
+ * The swapped-in parser's cap applies too — swapping is how most resources supply
764
+ * theirs.
765
+ *
766
+ * Deferring only in the constructor was not enough: `setQueryParser()` mutates the
767
+ * resolver in place so captured references stay valid, so a resource that declares
768
+ * `new QueryParser({ maxLimit: 1000 })` arrives HERE, not through `config`. The
769
+ * constructor-only version of this fix looked right, passed its unit tests, and
770
+ * changed nothing at runtime — the chart of accounts still served 100 of 696.
771
+ *
772
+ * An explicit `config.maxLimit` still wins; that is a host decision and outranks a
773
+ * package default either way.
774
+ */
775
+ if (this.explicitMaxLimit === void 0 && parser.maxLimit !== void 0) this.maxLimit = parser.maxLimit;
776
+ }
777
+ /**
778
+ * The default sort, but only when the parser permits the field.
779
+ *
780
+ * A resource declaring `allowedSortFields` has stated exactly what may be
781
+ * sorted on. Arc then applied its OWN default of `-createdAt` on top, and
782
+ * mongokit 3.31 REJECTS a sort outside the allowlist rather than dropping it —
783
+ * so every list call on such a resource answered 400, `Blocked sort field not
784
+ * in allowlist: createdAt`. REST and MCP alike; the endpoint was unusable, and
785
+ * nothing the caller sent could avoid it.
786
+ *
787
+ * A general default must never outrank a specific declaration, so the
788
+ * FRAMEWORK default withdraws. A HOST-declared `defaultSort` is left alone —
789
+ * that contradiction is its author's to resolve, and `defineResource` reports
790
+ * it as a boot diagnostic instead of arc silently picking a winner.
791
+ */
792
+ permittedDefaultSort() {
793
+ const sort = this.defaultSort;
794
+ if (sort === void 0 || this.explicitDefaultSort) return sort;
795
+ const allowed = this.queryParser.allowedSortFields;
796
+ if (!allowed || allowed.length === 0) return sort;
797
+ return sort.split(",").map((s) => s.trim().replace(/^-/, "")).filter(Boolean).every((f) => allowed.includes(f)) ? sort : void 0;
665
798
  }
666
799
  /**
667
800
  * Resolve a request into parsed query options -- ONE parse per request.
668
801
  * Combines what was previously _buildContext + _parseQueryOptions + _applyFilters.
669
802
  */
670
803
  resolve(req, meta) {
671
- const parsed = this.queryParser.parse(req.query);
804
+ /**
805
+ * Strip `_policyFilters` BEFORE the parse, not after.
806
+ *
807
+ * **Reachable only when a CLIENT puts it in the URL.** Trusted policy is a
808
+ * request DECORATION (`request._policyFilters`, set by the permission
809
+ * middleware) that `createRequestContext` lifts into `metadata` — it never
810
+ * enters `req.query`, which is Fastify's parsed querystring. So the key
811
+ * arriving here is always user-supplied, always inert (the authoritative
812
+ * copy is read from `arcContext` below), and must simply not reach the
813
+ * parser.
814
+ *
815
+ * The previous `delete` ran on `parsed.filters` — AFTER
816
+ * `queryParser.parse()` — so on the one path that needed it, it could
817
+ * never run: a KIT parser validates every key against the resource's
818
+ * `allowedFilterFields` and throws during the parse, before the cleanup
819
+ * line. Arc's own parser skips the key via `RESERVED_QUERY_PARAMS`, so the
820
+ * two parsers disagreed on the same request: arc ignored a probe, kits
821
+ * 400'd it.
822
+ *
823
+ * Stripping here makes them agree, and agrees with arc. Nothing is
824
+ * weakened — the client's value was already inert — but note the trade:
825
+ * under a kit parser `?_policyFilters=…` used to be a loud 400 and is now
826
+ * silently dropped. The alternative, teaching every kit's reserved-key list
827
+ * about an arc-internal key, spreads one framework's leak across the
828
+ * ecosystem for the same outcome.
829
+ *
830
+ * Copy only when present: the hot path allocates nothing, and `req.query`
831
+ * is never mutated (it is shared with the rest of the request).
832
+ */
833
+ const rawQuery = req.query;
834
+ let query = rawQuery;
835
+ if (rawQuery && "_policyFilters" in rawQuery) {
836
+ const { _policyFilters: _ignored, ...rest } = rawQuery;
837
+ query = rest;
838
+ }
839
+ const parsed = this.queryParser.parse(query);
672
840
  const arcContext = meta ?? req.metadata;
673
- delete parsed.filters?._policyFilters;
674
841
  const limit = Math.min(Math.max(1, parsed.limit || this.defaultLimit), this.maxLimit);
675
842
  const page = parsed.after ? void 0 : parsed.page ? Math.max(1, parsed.page) : 1;
676
- const sortString = parsed.sort ? Object.entries(parsed.sort).map(([k, v]) => v === -1 ? `-${k}` : k).join(",") : this.defaultSort;
843
+ const sortString = parsed.sort ? Object.entries(parsed.sort).map(([k, v]) => v === -1 ? `-${k}` : k).join(",") : this.permittedDefaultSort();
677
844
  const rawSelect = parsed.select ?? req.query?.select;
678
845
  const policyFilters = arcContext?._policyFilters;
679
846
  let filters = conjoinPolicyFilters(parsed.filters, policyFilters);
@@ -838,11 +1005,38 @@ var BaseCrudController = class {
838
1005
  */
839
1006
  _defaultSort;
840
1007
  _onFieldWriteDenied;
1008
+ _onImmutableWrite;
1009
+ /**
1010
+ * Domain commands bound to the write slots (`ResourceWrites`). Consulted at
1011
+ * the PERSISTENCE step only — everything upstream of it (sanitizer, tenant
1012
+ * injection, actor stamp, hook sandwich) runs exactly as it does without
1013
+ * one, which is the entire point of the seam.
1014
+ */
1015
+ _writes;
1016
+ /** Transactional write envelope — see `ResourceConfig.transactional`. */
1017
+ _transactional = false;
841
1018
  constructor(repository, options = {}) {
842
1019
  this.repository = repository;
843
1020
  this.schemaOptions = options.schemaOptions ?? {};
844
1021
  this.queryParser = options.queryParser ?? getDefaultQueryParser();
845
- this.maxLimit = options.maxLimit ?? 100;
1022
+ /**
1023
+ * The parser's cap is consulted BEFORE the framework default.
1024
+ *
1025
+ * A resource that hands us a parser has already stated how large a page it
1026
+ * can serve — a bounded catalog like a chart of accounts declares 1000 so
1027
+ * pickers can read it whole. Defaulting straight to 100 here silently
1028
+ * overrode that: the resource asked for 1000, `QueryResolver` received a
1029
+ * `maxLimit` of 100 that was indistinguishable from a host deliberately
1030
+ * choosing 100, and the list came back truncated with a `200`. be-prod
1031
+ * served 100 of 696 accounts that way, and every account picker in the
1032
+ * product was short by 85% with nothing anywhere to report it.
1033
+ *
1034
+ * Order matters and is the whole point: an explicit `options.maxLimit` from
1035
+ * the host still wins over the parser, and the parser wins over this
1036
+ * framework floor. A general default must only ever fill a gap — never
1037
+ * outrank something specific that was actually stated.
1038
+ */
1039
+ this.maxLimit = options.maxLimit ?? this.queryParser?.maxLimit ?? 100;
846
1040
  this.defaultLimit = options.defaultLimit ?? 20;
847
1041
  this.resourceName = options.resourceName;
848
1042
  this.tenantField = options.tenantField !== void 0 ? options.tenantField : DEFAULT_TENANT_FIELD;
@@ -852,6 +1046,9 @@ var BaseCrudController = class {
852
1046
  if (options.presetFields) this._presetFields = options.presetFields;
853
1047
  this._defaultSort = options.defaultSort;
854
1048
  this._onFieldWriteDenied = options.onFieldWriteDenied;
1049
+ this._onImmutableWrite = options.onImmutableWrite;
1050
+ this._writes = options.writes;
1051
+ this._transactional = options.transactional === true;
855
1052
  this.accessControl = new AccessControl({
856
1053
  tenantField: this.tenantField,
857
1054
  idField: this.idField,
@@ -859,7 +1056,8 @@ var BaseCrudController = class {
859
1056
  });
860
1057
  this.bodySanitizer = new BodySanitizer({
861
1058
  schemaOptions: this.schemaOptions,
862
- onFieldWriteDenied: this._onFieldWriteDenied
1059
+ onFieldWriteDenied: this._onFieldWriteDenied,
1060
+ onImmutableWrite: this._onImmutableWrite
863
1061
  });
864
1062
  this.queryResolver = new QueryResolver({
865
1063
  queryParser: this.queryParser,
@@ -871,9 +1069,21 @@ var BaseCrudController = class {
871
1069
  });
872
1070
  this.list = this.list.bind(this);
873
1071
  this.get = this.get.bind(this);
874
- this.create = this.create.bind(this);
875
- this.update = this.update.bind(this);
876
- this.delete = this.delete.bind(this);
1072
+ /**
1073
+ * The write methods are bound here, which means EVERY instance carries an
1074
+ * own `create`/`update`/`delete` property — so "does it have an own
1075
+ * property" cannot distinguish arc's binding from a host's override.
1076
+ *
1077
+ * Marking what arc installed makes the difference observable. A class-field
1078
+ * override (`update = async (req) => …`) initialises AFTER `super()`
1079
+ * returns and therefore REPLACES this bound method outright — the mark goes
1080
+ * with it, which is exactly the signal `warnOnWriteMethodOverride` needs.
1081
+ * A prototype-method override binds through here and stays marked; that
1082
+ * shape is caught by the prototype comparison instead.
1083
+ */
1084
+ this.create = markArcBoundWrite(this.create.bind(this));
1085
+ this.update = markArcBoundWrite(this.update.bind(this));
1086
+ this.delete = markArcBoundWrite(this.delete.bind(this));
877
1087
  }
878
1088
  /**
879
1089
  * Swap the controller's query parser. Mutates the existing `QueryResolver`
@@ -939,6 +1149,10 @@ var BaseCrudController = class {
939
1149
  rebuildBodySanitizer = true;
940
1150
  rebuildQueryResolver = true;
941
1151
  }
1152
+ if (options.onImmutableWrite !== void 0) {
1153
+ this._onImmutableWrite = options.onImmutableWrite;
1154
+ rebuildBodySanitizer = true;
1155
+ }
942
1156
  if (options.onFieldWriteDenied !== void 0) {
943
1157
  this._onFieldWriteDenied = options.onFieldWriteDenied;
944
1158
  rebuildBodySanitizer = true;
@@ -958,6 +1172,8 @@ var BaseCrudController = class {
958
1172
  }
959
1173
  if (options.cache !== void 0) this._cacheConfig = options.cache;
960
1174
  if (options.presetFields !== void 0) this._presetFields = options.presetFields;
1175
+ if (options.writes !== void 0) this._writes = options.writes;
1176
+ if (options.transactional !== void 0) this._transactional = options.transactional === true;
961
1177
  if (rebuildAccessControl) this.accessControl = new AccessControl({
962
1178
  tenantField: this.tenantField,
963
1179
  idField: this.idField,
@@ -965,7 +1181,8 @@ var BaseCrudController = class {
965
1181
  });
966
1182
  if (rebuildBodySanitizer) this.bodySanitizer = new BodySanitizer({
967
1183
  schemaOptions: this.schemaOptions,
968
- onFieldWriteDenied: this._onFieldWriteDenied
1184
+ onFieldWriteDenied: this._onFieldWriteDenied,
1185
+ onImmutableWrite: this._onImmutableWrite
969
1186
  });
970
1187
  if (rebuildQueryResolver) this.queryResolver = new QueryResolver({
971
1188
  queryParser: this.queryParser,
@@ -1105,6 +1322,52 @@ var BaseCrudController = class {
1105
1322
  return executeHookedOp(this.hookOpContext(req), args, executor);
1106
1323
  }
1107
1324
  /**
1325
+ * Run a write's PERSISTENCE step, transactionally when the resource asked.
1326
+ *
1327
+ * Plain mode hands `fn` the live repository. Transactional mode runs it
1328
+ * through repo-core's `retryingTransaction`: the callback receives the
1329
+ * TX-BOUND repository (so declared verbs and repository calls join the same
1330
+ * transaction), transient conflicts re-run `fn` with jittered bounded
1331
+ * backoff, everything else — including `VersionConflictError` — surfaces
1332
+ * once. Hooks stay OUTSIDE: before/around ran already and must not re-run;
1333
+ * after-hooks run post-commit, which they already do by running after this.
1334
+ */
1335
+ runWritePersistence(fn) {
1336
+ if (!this._transactional) return fn(this.repository);
1337
+ return retryingTransaction(this.repository, (txRepo, uow) => fn(txRepo, uow));
1338
+ }
1339
+ /** Assemble the `WriteContext` a `create` verb receives. */
1340
+ writeContext(req, repo = this.repository, uow) {
1341
+ return {
1342
+ req,
1343
+ repository: repo,
1344
+ ...uow !== void 0 ? { uow } : {}
1345
+ };
1346
+ }
1347
+ /**
1348
+ * Assemble the `MutationWriteContext` an `update` / `delete` verb receives.
1349
+ *
1350
+ * `existing` is the document arc ALREADY loaded to run its permission and
1351
+ * tenant checks — `loadMutableTarget` throws before any verb runs when it
1352
+ * cannot produce one, which is why the context types it required. Handing
1353
+ * it over rather than letting the verb re-fetch is deliberate: a second
1354
+ * read can observe a different document than the one the request was
1355
+ * authorised against.
1356
+ *
1357
+ * `id` is the REPOSITORY PRIMARY KEY (`repoId`), never the raw route param —
1358
+ * see the note on `MutationWriteContext.id`. Callers pass `resolveRepoId`'s
1359
+ * output.
1360
+ */
1361
+ mutationWriteContext(req, id, existing, repo = this.repository, uow) {
1362
+ return {
1363
+ req,
1364
+ repository: repo,
1365
+ id,
1366
+ existing,
1367
+ ...uow !== void 0 ? { uow } : {}
1368
+ };
1369
+ }
1370
+ /**
1108
1371
  * Run `executeAfter` for the given op + data. No-op when hooks aren't
1109
1372
  * wired or `resourceName` isn't set. Caller passes the data shape it
1110
1373
  * wants downstream after-handlers to receive — typically the result for
@@ -1340,11 +1603,21 @@ var BaseCrudController = class {
1340
1603
  const item = await this.runHookedOpUntilResult(req, {
1341
1604
  op: "create",
1342
1605
  input: data
1343
- }, async (processed) => this.repository.create(processed, {
1606
+ }, async (processed) => this.runWritePersistence((repo, uow) => this._writes?.create ? this._writes.create(processed, this.writeContext(req, repo, uow)) : repo.create(processed, {
1344
1607
  user,
1345
1608
  context: arcContext,
1346
1609
  ...this.tenantRepoOptions(req)
1347
- }));
1610
+ })));
1611
+ /**
1612
+ * A create COMMAND must return the created document — enforced, because
1613
+ * the alternative is quietly worse than any failure: a `201` carrying
1614
+ * `undefined` data, after-hooks (audit, events, cache invalidation) fed a
1615
+ * non-document, and a client that believes the write happened with no way
1616
+ * to address what it created. TypeScript types the contract, but arc is a
1617
+ * JavaScript runtime framework and a JS command returning nothing is one
1618
+ * missing `return` away.
1619
+ */
1620
+ if (this._writes?.create && item == null) throw createError(500, `writes.create for resource "${this.resourceName ?? "unknown"}" returned ${item === null ? "null" : "undefined"} — a create command must return the created document. Signal failure by throwing a typed error; a nullish return would otherwise answer 201 with no document.`, { code: "WRITE_VERB_CONTRACT_VIOLATION" });
1348
1621
  await this.runAfterHook(req, "create", item);
1349
1622
  return {
1350
1623
  data: item,
@@ -1367,15 +1640,27 @@ var BaseCrudController = class {
1367
1640
  op: "update",
1368
1641
  input: data,
1369
1642
  meta: hookMeta
1370
- }, async (processed) => this.repository.update(repoId, processed, {
1643
+ }, async (processed) => this.runWritePersistence((repo, uow) => this._writes?.update ? this._writes.update(repoId, processed, this.mutationWriteContext(req, repoId, existing, repo, uow)) : repo.update(repoId, processed, {
1371
1644
  user,
1372
1645
  context: arcContext,
1373
1646
  ...this.tenantRepoOptions(req)
1374
- }));
1375
- if (!item) this.throwNotFound("NOT_FOUND");
1376
- await this.runAfterHook(req, "update", item, hookMeta);
1647
+ })));
1648
+ /**
1649
+ * A declared verb signals "not found" by THROWING, so reaching this line
1650
+ * means the command succeeded. Translating its `void` return into a 404
1651
+ * would report failure for a write that happened — the repository's
1652
+ * `null`-means-miss contract is the repository's, not a domain command's.
1653
+ * Re-read so the response still carries the document.
1654
+ */
1655
+ const resolved = this._writes?.update ? item ?? await this.repository.getById(repoId, {
1656
+ user,
1657
+ context: arcContext,
1658
+ ...this.tenantRepoOptions(req)
1659
+ }) : item;
1660
+ if (!resolved) this.throwNotFound("NOT_FOUND");
1661
+ await this.runAfterHook(req, "update", resolved, hookMeta);
1377
1662
  return {
1378
- data: item,
1663
+ data: resolved,
1379
1664
  status: 200,
1380
1665
  meta: { message: "Updated successfully" }
1381
1666
  };
@@ -1391,15 +1676,15 @@ var BaseCrudController = class {
1391
1676
  input: existing,
1392
1677
  meta: hookMeta,
1393
1678
  pipeProcessedData: false
1394
- }, async () => this.repository.delete(repoId, {
1679
+ }, async () => this.runWritePersistence((repo, uow) => this._writes?.delete ? this._writes.delete(repoId, this.mutationWriteContext(req, repoId, existing, repo, uow)) : repo.delete(repoId, {
1395
1680
  user,
1396
1681
  context: arcContext,
1397
1682
  ...this.tenantRepoOptions(req),
1398
1683
  ...deleteMode ? { mode: deleteMode } : {}
1399
- }));
1400
- if (!result) this.throwNotFound("NOT_FOUND");
1684
+ })));
1685
+ if (!result && !this._writes?.delete) this.throwNotFound("NOT_FOUND");
1401
1686
  await this.runAfterHook(req, "delete", existing, hookMeta);
1402
- const deleteResult = result;
1687
+ const deleteResult = result ?? {};
1403
1688
  return {
1404
1689
  data: {
1405
1690
  message: deleteResult.message || "Deleted successfully",
@@ -1410,6 +1695,15 @@ var BaseCrudController = class {
1410
1695
  };
1411
1696
  }
1412
1697
  };
1698
+ /**
1699
+ * This class's write methods dispatch declared write verbs (`_writes`), so
1700
+ * `defineResource` may accept a `writes` declaration for controllers built on
1701
+ * it. One mark on the prototype — inherited by every subclass and mixin
1702
+ * composition, zero per-instance cost. A duck-typed `configure()` is NOT this
1703
+ * proof: it shows an options channel exists, not that anything dispatches
1704
+ * `writes` out of it.
1705
+ */
1706
+ markWriteVerbCapable(BaseCrudController.prototype);
1413
1707
  //#endregion
1414
1708
  //#region src/core/mixins/bulk.ts
1415
1709
  /**
@@ -1752,4 +2046,4 @@ function TreeMixin(Base) {
1752
2046
  */
1753
2047
  var BaseController = class extends SoftDeleteMixin(TreeMixin(SlugMixin(BulkMixin(BaseCrudController)))) {};
1754
2048
  //#endregion
1755
- export { BulkMixin as a, collectReadBlockedFields as c, DEFAULT_FIELD_WRITE_DENIAL_POLICY as d, AccessControl as f, SlugMixin as i, isFieldReadable as l, TreeMixin as n, BaseCrudController as o, SoftDeleteMixin as r, QueryResolver as s, BaseController as t, BodySanitizer as u };
2049
+ export { BulkMixin as a, collectReadBlockedFields as c, isWriteVerbCapable as d, BodySanitizer as f, SlugMixin as i, isFieldReadable as l, AccessControl as m, TreeMixin as n, BaseCrudController as o, DEFAULT_FIELD_WRITE_DENIAL_POLICY as p, SoftDeleteMixin as r, QueryResolver as s, BaseController as t, detectOverriddenWriteSlots as u };
@@ -4,6 +4,21 @@ import { DeadLetteredEvent, DomainEvent, DomainEvent as DomainEvent$1, EventHand
4
4
  interface MemoryEventTransportOptions {
5
5
  /** Logger for error/warning messages (default: console) */
6
6
  logger?: EventLogger;
7
+ /**
8
+ * What a THROWING subscriber does to `publish()`.
9
+ *
10
+ * - `'log'` (default) — logged, `publish()` still resolves. The
11
+ * fire-and-forget bus contract: one broken analytics subscriber must not
12
+ * fail a publisher's request.
13
+ * - `'throw'` — REJECTS with an `AggregateError` after running every
14
+ * handler. Required for at-least-once PROCESSING: with this transport
15
+ * `publish()` IS the handling, so a swallowed error reads to an outbox
16
+ * relay as delivery and the row is acknowledged, never retried.
17
+ *
18
+ * Retry redelivers to ALL matching handlers (no per-handler cursor), so
19
+ * handlers must be idempotent. Every handler runs before the rejection.
20
+ */
21
+ onHandlerError?: "log" | "throw";
7
22
  }
8
23
  /**
9
24
  * In-memory event transport (default).
@@ -16,6 +31,7 @@ declare class MemoryEventTransport implements EventTransport {
16
31
  readonly name = "memory";
17
32
  private handlers;
18
33
  private logger;
34
+ private onHandlerError;
19
35
  constructor(options?: MemoryEventTransportOptions);
20
36
  publish(event: DomainEvent): Promise<void>;
21
37
  /**
@@ -1,4 +1,4 @@
1
- /*! @classytic/arc v2.32.0 | MIT | https://classytic.com */
1
+ /*! @classytic/arc v2.34.0 | MIT | https://classytic.com */
2
2
  import { createEvent, matchEventPattern } from "@classytic/primitives/events";
3
3
  //#region src/events/EventTransport.ts
4
4
  /**
@@ -45,6 +45,29 @@ import { createEvent, matchEventPattern } from "@classytic/primitives/events";
45
45
  * public `events/index.ts` barrel stays clean.
46
46
  */
47
47
  /**
48
+ * Decorator key holding the RAW `EventTransport` an app registered.
49
+ *
50
+ * `fastify.events` is a request-facing FACADE — different signature
51
+ * (`publish(type, payload, meta?)`) and fail-open by design — so machinery
52
+ * that must observe publish failure (the outbox relay) resolves the transport
53
+ * through this key instead. `Symbol.for` so it still matches across two arc
54
+ * copies in one dependency graph; deliberately NOT on the public
55
+ * `EventsDecorator` surface.
56
+ */
57
+ const ARC_EVENT_TRANSPORT = Symbol.for("arc.eventTransport");
58
+ /**
59
+ * Read the raw transport an app registered, or `undefined` when the events
60
+ * plugin was never registered (or predates this key).
61
+ *
62
+ * Callers decide what absence means: the outbox treats it as "no transport",
63
+ * which is a boot-time error rather than a silent no-op relay.
64
+ */
65
+ function resolveEventTransport(fastify) {
66
+ if (typeof fastify !== "object" || fastify === null) return void 0;
67
+ const found = Reflect.get(fastify, ARC_EVENT_TRANSPORT);
68
+ return typeof found === "object" && found !== null ? found : void 0;
69
+ }
70
+ /**
48
71
  * In-memory event transport (default).
49
72
  *
50
73
  * Events are delivered synchronously within the process. Not suitable for
@@ -55,17 +78,31 @@ var MemoryEventTransport = class {
55
78
  name = "memory";
56
79
  handlers = /* @__PURE__ */ new Map();
57
80
  logger;
81
+ onHandlerError;
58
82
  constructor(options) {
59
83
  this.logger = options?.logger ?? console;
84
+ this.onHandlerError = options?.onHandlerError ?? "log";
60
85
  }
61
86
  async publish(event) {
62
87
  const allHandlers = /* @__PURE__ */ new Set();
63
88
  for (const [pattern, handlers] of this.handlers) if (matchEventPattern(pattern, event.type)) for (const h of handlers) allHandlers.add(h);
89
+ const failures = [];
64
90
  for (const handler of allHandlers) try {
65
91
  await handler(event);
66
92
  } catch (err) {
67
93
  this.logger.error(`[EventTransport] Handler error for ${event.type}:`, err);
94
+ failures.push(err);
68
95
  }
96
+ /**
97
+ * Rejecting is what lets an OUTBOX see the failure. With this transport,
98
+ * `publish()` IS the handling — synchronous and in-process — so resolving
99
+ * after a handler threw tells the relay the event was delivered, and the
100
+ * row is acknowledged and never retried. Measured: handler throws once,
101
+ * `relayBatch()` reports `relayed: 1, publishFailed: 0`, and the next tick
102
+ * finds nothing. A broker-backed transport has no such ambiguity — publish
103
+ * means "durably handed off" and redelivery is the consumer group's job.
104
+ */
105
+ if (failures.length > 0 && this.onHandlerError === "throw") throw new AggregateError(failures, `[EventTransport] ${failures.length} handler(s) failed for ${event.type}`);
69
106
  }
70
107
  /**
71
108
  * Reference `publishMany` implementation — delegates to `publish()` in order.
@@ -101,4 +138,4 @@ var MemoryEventTransport = class {
101
138
  }
102
139
  };
103
140
  //#endregion
104
- export { createEvent as n, MemoryEventTransport as t };
141
+ export { resolveEventTransport as i, MemoryEventTransport as n, createEvent as r, ARC_EVENT_TRANSPORT as t };
@@ -1,5 +1,5 @@
1
- /*! @classytic/arc v2.32.0 | MIT | https://classytic.com */
2
- import { t as buildRequestScopeProjection } from "./projection-CjHhM5W1.mjs";
1
+ /*! @classytic/arc v2.34.0 | MIT | https://classytic.com */
2
+ import { t as buildRequestScopeProjection } from "./projection-BKM0Ui-s.mjs";
3
3
  //#region src/hooks/HookSystem.ts
4
4
  /**
5
5
  * Hook System
@@ -1,5 +1,5 @@
1
- /*! @classytic/arc v2.32.0 | MIT | https://classytic.com */
2
- import "./constants-J99LGTJI.mjs";
1
+ /*! @classytic/arc v2.34.0 | MIT | https://classytic.com */
2
+ import "./constants-BxCoxmiN.mjs";
3
3
  //#region src/registry/ResourceRegistry.ts
4
4
  /**
5
5
  * Resource Registry
@@ -60,6 +60,7 @@ var ResourceRegistry = class {
60
60
  summary: r.summary,
61
61
  description: r.description,
62
62
  permissions: r.permissions,
63
+ capability: r.capability,
63
64
  raw: r.rawHandler !== void 0,
64
65
  schema: r.schema
65
66
  })),
@@ -1,4 +1,4 @@
1
- /*! @classytic/arc v2.32.0 | MIT | https://classytic.com */
1
+ /*! @classytic/arc v2.34.0 | MIT | https://classytic.com */
2
2
  //#region src/core/actionPermissions.ts
3
3
  /**
4
4
  * Return the effective `PermissionCheck` for a single action, or `undefined`