@classytic/arc 2.31.0 → 2.33.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 (194) hide show
  1. package/README.md +1 -1
  2. package/bin/arc.js +58 -17
  3. package/dist/{BaseController-De7GqHck.mjs → BaseController-CaQC5EXX.mjs} +298 -35
  4. package/dist/{EventTransport-2uVV34WU.d.mts → EventTransport-BEVP50ka.d.mts} +16 -0
  5. package/dist/{EventTransport-9mlTgqzE.mjs → EventTransport-tq4CEB-q.mjs} +39 -2
  6. package/dist/{HookSystem-BcmPyq5I.mjs → HookSystem-DqXkMXkB.mjs} +2 -2
  7. package/dist/{ResourceRegistry-qq0mn2s9.mjs → ResourceRegistry-DoALUlOO.mjs} +18 -15
  8. package/dist/{actionPermissions-BE5NwaQk.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 +44 -11
  14. package/dist/auth/redis-session.mjs +1 -1
  15. package/dist/{authorizationDecision-ChvUQGG1.mjs → authorizationDecision-B3mP_QTy.mjs} +2 -2
  16. package/dist/{types-B7XeLEgK.d.mts → base-Csjc2oF-.d.mts} +74 -74
  17. package/dist/{buildHandler-CDrCJzmB.mjs → buildHandler-DLE9xX-V.mjs} +6 -5
  18. package/dist/cache/index.mjs +4 -4
  19. package/dist/{caching-BFHA6Scf.mjs → caching-BcGmvnHh.mjs} +2 -2
  20. package/dist/cleanup/index.d.mts +52 -18
  21. package/dist/cleanup/index.mjs +281 -32
  22. package/dist/cli/commands/describe.d.mts +1 -1
  23. package/dist/cli/commands/describe.mjs +4 -6
  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-D3Dv4LAV.mjs → constants-BxCoxmiN.mjs} +1 -1
  31. package/dist/context/index.mjs +3 -3
  32. package/dist/core/index.d.mts +2 -2
  33. package/dist/core/index.mjs +7 -7
  34. package/dist/{core-BGpIxINN.mjs → core-dxhcHhUD.mjs} +3 -3
  35. package/dist/{createActionRouter-CDXEdasl.mjs → createActionRouter-DVpoA0do.mjs} +14 -11
  36. package/dist/{createAggregationRouter-KrQDzx-k.mjs → createAggregationRouter-B_B48JZ-.mjs} +10 -8
  37. package/dist/{createApp-Df89BEni.mjs → createApp-CLGegqiG.mjs} +287 -62
  38. package/dist/{defineAggregation-DbPPS8RI.mjs → defineAggregation-Nv-DwfOf.mjs} +1 -1
  39. package/dist/{defineResource-Bv6EtN1x.mjs → defineResource-DR9vsUYy.mjs} +318 -35
  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-D2l1mh4g.mjs → elevation-B0tlY8y1.mjs} +4 -4
  44. package/dist/encryption/index.mjs +5 -4
  45. package/dist/{errorHandler-C5NeD6bT.mjs → errorHandler-C7xBqHsI.mjs} +42 -5
  46. package/dist/{errorHandler-CpYUJmPA.d.mts → errorHandler-DI5AN2b5.d.mts} +14 -3
  47. package/dist/{errors-Cj42AntO.mjs → errors-Cd_yy7aW.mjs} +1 -1
  48. package/dist/{eventPlugin-GF5nnBIq.d.mts → eventPlugin-DSNEZWfV.d.mts} +29 -1
  49. package/dist/{eventPlugin-CkffEihm.mjs → eventPlugin-Dar8qRaj.mjs} +68 -11
  50. package/dist/events/index.d.mts +18 -297
  51. package/dist/events/index.mjs +7 -758
  52. package/dist/events/transports/redis-stream-entry.d.mts +1 -1
  53. package/dist/events/transports/redis-stream-entry.mjs +14 -10
  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 +131 -8
  58. package/dist/fastify-augmentation-DVsTAy6X.d.mts +179 -0
  59. package/dist/{fields-CB5Cuzk9.mjs → fields-CojeUkS4.mjs} +6 -11
  60. package/dist/{generate-EcA6ui4i.mjs → generate-DiB0nHPR.mjs} +2 -2
  61. package/dist/{headers-3zvE0rM3.mjs → headers-CveNO9gR.mjs} +2 -2
  62. package/dist/{health-DjZYjM7H.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 +95 -11
  67. package/dist/idempotency/redis.mjs +1 -1
  68. package/dist/{index-CEJNZFtX.d.mts → index-B-DvUzsr.d.mts} +82 -3
  69. package/dist/{index-CpzxqePp.d.mts → index-BcR2bFZQ.d.mts} +707 -373
  70. package/dist/{index-Bc2ox8_U.d.mts → index-Brin0ODU.d.mts} +4 -4
  71. package/dist/{index-CFThn1K9.d.mts → index-C5NnMEKF.d.mts} +70 -4
  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 +11 -11
  75. package/dist/{init-BEiNZBsJ.mjs → init-mdJ-v4Xp.mjs} +24 -16
  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 +100 -98
  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 +38 -35
  92. package/dist/{interface-DYzM0q-u.mjs → interface-DznJrm9R.mjs} +1 -1
  93. package/dist/{jsonBody-BYbLDi5D.mjs → jsonBody-CfGH-GxO.mjs} +2 -2
  94. package/dist/{keys-C0_pa1tY.mjs → keys-WeKvY1dE.mjs} +1 -1
  95. package/dist/{loadResources-yawi9tAE.mjs → loadResources-Bj-aSsXi.mjs} +4 -3
  96. package/dist/{loadResourcesFromEntry-BAjYZonx.mjs → loadResourcesFromEntry-DwaxNcE2.mjs} +2 -2
  97. package/dist/logger/index.mjs +1 -1
  98. package/dist/{mcpPlugin-B_es0hiF.mjs → mcpPlugin-aJg5TqHi.mjs} +76 -36
  99. package/dist/{memory-DR6wpa9F.mjs → memory-qYSB7rMQ.mjs} +5 -5
  100. package/dist/{metrics-CUdw_wLB.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 +3 -3
  104. package/dist/{module-0pFJA6YW.mjs → module-CwKsgZXp.mjs} +1 -1
  105. package/dist/{multipartBody-wOgj_fnj.mjs → multipartBody-4w-S3-PU.mjs} +3 -3
  106. package/dist/{openapi-B2L4js1v.mjs → openapi-BN5cQaO2.mjs} +6 -6
  107. package/dist/outbox-admin/index.d.mts +85 -0
  108. package/dist/outbox-admin/index.mjs +115 -0
  109. package/dist/outbox-module-CXB79bvt.mjs +839 -0
  110. package/dist/outbox-module-D-VFq37T.d.mts +368 -0
  111. package/dist/permissions/index.d.mts +3 -3
  112. package/dist/permissions/index.mjs +6 -6
  113. package/dist/{permissions-L--QDAir.mjs → permissions-BAfwgBxc.mjs} +120 -8
  114. package/dist/{pipe-DRDNsI2T.mjs → pipe-BzzoEhHj.mjs} +3 -5
  115. package/dist/pipeline/index.d.mts +1 -1
  116. package/dist/pipeline/index.mjs +2 -2
  117. package/dist/{pipeline-UPQFatit.mjs → pipeline-q69DZzgG.mjs} +17 -15
  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-O0FZgESl.mjs → pluralize-B7xf8qZF.mjs} +1 -1
  123. package/dist/{preloadResources-Dz6BjzM6.mjs → preloadResources-BkyqeTDz.mjs} +1 -1
  124. package/dist/{preloadResources-CyoJMSiO.d.mts → preloadResources-g07ldtzh.d.mts} +30 -7
  125. package/dist/presets/filesUpload.d.mts +2 -2
  126. package/dist/presets/filesUpload.mjs +6 -6
  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-C4-g4_4P.mjs → presets-D-h0uWtP.mjs} +6 -5
  134. package/dist/projection-BKM0Ui-s.mjs +42 -0
  135. package/dist/{queryCachePlugin-BZr8v46E.mjs → queryCachePlugin-DhsZ0ZIc.mjs} +50 -8
  136. package/dist/{rateLimit-CMPY5Qms.mjs → rateLimit-D4-ASwsI.mjs} +2 -2
  137. package/dist/{rateLimitKey-Dn5JNmkj.mjs → rateLimitKey-XngqVNEV.mjs} +1 -1
  138. package/dist/{realtime-C5NoxWFY.mjs → realtime-CTkv3O59.mjs} +10 -10
  139. package/dist/{redis-stream-qN6A-RFv.d.mts → redis-stream-mwLWlfHG.d.mts} +1 -1
  140. package/dist/{registerRoute-CBN84fc7.mjs → registerRoute-DNnd3vkz.mjs} +10 -8
  141. package/dist/registry/index.d.mts +3 -3
  142. package/dist/registry/index.mjs +3 -3
  143. package/dist/{registry-4213U4zb.mjs → registry-CLxCR5LF.mjs} +2 -2
  144. package/dist/{renewingLease-Dll8mGgc.mjs → renewingLease-BnWtbA2U.mjs} +1 -1
  145. package/dist/{replyHelpers-DIiZKGUt.mjs → replyHelpers-BSBVCwL3.mjs} +2 -2
  146. package/dist/{repositoryFilter-f9eAfq_l.mjs → repositoryFilter-DKny7PDn.mjs} +1 -1
  147. package/dist/{requestContext-2TxXOJ7T.mjs → requestContext-uYE35JxI.mjs} +1 -1
  148. package/dist/{rolldown-runtime-CUsjNX9R.mjs → rolldown-runtime-DNg-KNCk.mjs} +1 -1
  149. package/dist/runtimeCapabilities-Dm88DaTW.mjs +49 -0
  150. package/dist/{schedules-DdtlICTf.mjs → schedules-CAcZgia0.mjs} +3 -3
  151. package/dist/{schemaConverter-D--wdJM-.mjs → schemaConverter-XH1RZRyu.mjs} +1 -1
  152. package/dist/{schemaIR-Bm2enbnh.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-DHx5Fous.mjs → scope-BaWcqBvr.mjs} +4 -4
  157. package/dist/{simpleEqualityMatcher-DjQJ7FKA.mjs → simpleEqualityMatcher-Se8VrsaP.mjs} +1 -1
  158. package/dist/{sse-CUTo9y0X.mjs → sse-Cf0jUHsh.mjs} +7 -6
  159. package/dist/{sseStream-B_5XcVVd.mjs → sseStream-Bvpi-UXQ.mjs} +2 -2
  160. package/dist/{store-helpers-D1MP_gRG.mjs → store-helpers-B88p99Z2.mjs} +1 -1
  161. package/dist/{streaming-PSZwi8JV.mjs → streaming-DsT1B5zJ.mjs} +1 -1
  162. package/dist/{subscribe-helpers-C0Tb2aUp.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 +12 -6
  166. package/dist/testing/index.d.mts +52 -13
  167. package/dist/testing/index.mjs +110 -40
  168. package/dist/testing/outboxStoreContract.d.mts +11 -2
  169. package/dist/testing/outboxStoreContract.mjs +110 -21
  170. package/dist/testing/storageContract.mjs +11 -6
  171. package/dist/{transactionContext-dAfBVUrk.mjs → transactionContext-BYtWnwAS.mjs} +1 -1
  172. package/dist/{typeGuards-B0ryPo3I.mjs → typeGuards-jjKJDN13.mjs} +1 -1
  173. package/dist/types/index.d.mts +3 -3
  174. package/dist/types/index.mjs +1 -1
  175. package/dist/types/storage.mjs +1 -1
  176. package/dist/{types-CBgvkp8O.d.mts → types-BcTumAz_.d.mts} +128 -18
  177. package/dist/{types-CwcUQ610.d.mts → types-BfC83KsP.d.mts} +15 -1
  178. package/dist/{types-Cys0ko9Z.mjs → types-CoqIRrkY.mjs} +4 -4
  179. package/dist/{types-C4QE8qnm.d.mts → types-DVLddu2q.d.mts} +144 -1
  180. package/dist/usage/index.d.mts +1 -1
  181. package/dist/usage/index.mjs +0 -0
  182. package/dist/{userHelpers-ChYJ6CdK.mjs → userHelpers-CWF5pbZD.mjs} +1 -1
  183. package/dist/utils/index.d.mts +57 -5
  184. package/dist/utils/index.mjs +12 -11
  185. package/dist/{utils-vL8BrDE2.mjs → utils-h1V_sT-t.mjs} +38 -7
  186. package/dist/{validate-_KbifpHF.mjs → validate-CdQlz52F.mjs} +2 -2
  187. package/dist/{versioning-D_rhHfZm.d.mts → versioning-DEs-_hFi.d.mts} +1 -1
  188. package/dist/{versioning-CHDTor3P.mjs → versioning-rZbyLVD-.mjs} +4 -4
  189. package/package.json +31 -20
  190. package/skills/arc/SKILL.md +96 -4
  191. package/skills/arc/references/api-reference.md +19 -0
  192. package/skills/arc/references/production.md +2 -2
  193. package/dist/fastify-augmentation-zh0_AYHa.d.mts +0 -85
  194. package/dist/projection-BHw6xlOQ.mjs +0 -18
@@ -1,15 +1,17 @@
1
- /*! @classytic/arc v2.31.0 | MIT | https://classytic.com */
2
- import { h as SYSTEM_FIELDS, o as DEFAULT_TENANT_FIELD } from "./constants-D3Dv4LAV.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-ChvUQGG1.mjs";
5
- import { d as createDomainError, f as createError, i as NotFoundError, p as isArcError, r as ForbiddenError } from "./errors-Cj42AntO.mjs";
6
- import { n as getUserRoles, t as getUserId } from "./userHelpers-ChYJ6CdK.mjs";
7
- import { b as isElevated, c as getOrgId, n as PUBLIC_SCOPE, x as isMember } from "./types-Cys0ko9Z.mjs";
8
- import { t as simpleEqualityMatcher } from "./simpleEqualityMatcher-DjQJ7FKA.mjs";
9
- import { t as toRepositoryFilter } from "./repositoryFilter-f9eAfq_l.mjs";
10
- import { i as resolveEffectiveRoles, n as applyFieldWritePermissions } from "./fields-CB5Cuzk9.mjs";
11
- import { g as ArcQueryParser, n as scheduleBackground } from "./utils-vL8BrDE2.mjs";
12
- import { t as buildQueryKey } from "./keys-C0_pa1tY.mjs";
4
+ import { a as conjoinPolicyFilters } from "./authorizationDecision-B3mP_QTy.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 { t as toRepositoryFilter } from "./repositoryFilter-DKny7PDn.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,18 +280,25 @@ 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;
287
296
  if (!isElevated(scope)) {
288
297
  const fieldPerms = arcContext?.arc?.fields;
289
298
  if (fieldPerms) {
290
- const effectiveRoles = resolveEffectiveRoles(getUserRoles(req.user), isMember(scope) ? scope.orgRoles : []);
299
+ const globalRoles = getUserRoles(req.user);
300
+ const orgRoles = isMember(scope) ? scope.orgRoles : [];
301
+ const effectiveRoles = resolveEffectiveRoles(globalRoles, orgRoles);
291
302
  const { body: filtered, deniedFields } = applyFieldWritePermissions(sanitized, fieldPerms, effectiveRoles);
292
303
  if (deniedFields.length > 0 && this.onFieldWriteDenied === "reject") throw new ForbiddenError(`Not permitted to write field${deniedFields.length === 1 ? "" : "s"}: ${deniedFields.join(", ")}`);
293
304
  sanitized = filtered;
@@ -361,6 +372,73 @@ function buildTenantRepoOptions(req, tenantField, arcContext) {
361
372
  return out;
362
373
  }
363
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
+ /**
364
442
  * Resolve the repository primary key for mutation calls.
365
443
  *
366
444
  * When the resource declares a custom `idField` (slug, jobId, UUID), the
@@ -632,23 +710,44 @@ const log = arcLog("query");
632
710
  */
633
711
  let notedDeniedJoins = false;
634
712
  function noteDeniedJoins(kind) {
635
- if (notedDeniedJoins || process.env.NODE_ENV === "production") return;
713
+ if (notedDeniedJoins || isProductionEnv(process.env.NODE_ENV)) return;
636
714
  notedDeniedJoins = true;
637
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)`);
638
716
  }
639
717
  var QueryResolver = class {
640
718
  queryParser;
641
719
  maxLimit;
720
+ /** Set only when the HOST passed one — it outranks any parser's cap, at any time. */
721
+ explicitMaxLimit;
642
722
  defaultLimit;
643
723
  /** `undefined` means "no default sort" (caller passed `false`). */
644
724
  defaultSort;
725
+ explicitDefaultSort;
645
726
  schemaOptions;
646
727
  tenantField;
647
728
  constructor(config = {}) {
648
729
  this.queryParser = config.queryParser ?? getDefaultQueryParser();
649
- 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;
650
748
  this.defaultLimit = config.defaultLimit ?? 20;
651
749
  this.defaultSort = config.defaultSort === false ? void 0 : config.defaultSort ?? "-createdAt";
750
+ this.explicitDefaultSort = config.defaultSort !== void 0 && config.defaultSort !== false;
652
751
  this.schemaOptions = config.schemaOptions ?? {};
653
752
  this.tenantField = config.tenantField !== void 0 ? config.tenantField : DEFAULT_TENANT_FIELD;
654
753
  }
@@ -660,6 +759,42 @@ var QueryResolver = class {
660
759
  */
661
760
  setParser(parser) {
662
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;
663
798
  }
664
799
  /**
665
800
  * Resolve a request into parsed query options -- ONE parse per request.
@@ -671,7 +806,7 @@ var QueryResolver = class {
671
806
  delete parsed.filters?._policyFilters;
672
807
  const limit = Math.min(Math.max(1, parsed.limit || this.defaultLimit), this.maxLimit);
673
808
  const page = parsed.after ? void 0 : parsed.page ? Math.max(1, parsed.page) : 1;
674
- const sortString = parsed.sort ? Object.entries(parsed.sort).map(([k, v]) => v === -1 ? `-${k}` : k).join(",") : this.defaultSort;
809
+ const sortString = parsed.sort ? Object.entries(parsed.sort).map(([k, v]) => v === -1 ? `-${k}` : k).join(",") : this.permittedDefaultSort();
675
810
  const rawSelect = parsed.select ?? req.query?.select;
676
811
  const policyFilters = arcContext?._policyFilters;
677
812
  let filters = conjoinPolicyFilters(parsed.filters, policyFilters);
@@ -836,11 +971,38 @@ var BaseCrudController = class {
836
971
  */
837
972
  _defaultSort;
838
973
  _onFieldWriteDenied;
974
+ _onImmutableWrite;
975
+ /**
976
+ * Domain commands bound to the write slots (`ResourceWrites`). Consulted at
977
+ * the PERSISTENCE step only — everything upstream of it (sanitizer, tenant
978
+ * injection, actor stamp, hook sandwich) runs exactly as it does without
979
+ * one, which is the entire point of the seam.
980
+ */
981
+ _writes;
982
+ /** Transactional write envelope — see `ResourceConfig.transactional`. */
983
+ _transactional = false;
839
984
  constructor(repository, options = {}) {
840
985
  this.repository = repository;
841
986
  this.schemaOptions = options.schemaOptions ?? {};
842
987
  this.queryParser = options.queryParser ?? getDefaultQueryParser();
843
- this.maxLimit = options.maxLimit ?? 100;
988
+ /**
989
+ * The parser's cap is consulted BEFORE the framework default.
990
+ *
991
+ * A resource that hands us a parser has already stated how large a page it
992
+ * can serve — a bounded catalog like a chart of accounts declares 1000 so
993
+ * pickers can read it whole. Defaulting straight to 100 here silently
994
+ * overrode that: the resource asked for 1000, `QueryResolver` received a
995
+ * `maxLimit` of 100 that was indistinguishable from a host deliberately
996
+ * choosing 100, and the list came back truncated with a `200`. be-prod
997
+ * served 100 of 696 accounts that way, and every account picker in the
998
+ * product was short by 85% with nothing anywhere to report it.
999
+ *
1000
+ * Order matters and is the whole point: an explicit `options.maxLimit` from
1001
+ * the host still wins over the parser, and the parser wins over this
1002
+ * framework floor. A general default must only ever fill a gap — never
1003
+ * outrank something specific that was actually stated.
1004
+ */
1005
+ this.maxLimit = options.maxLimit ?? this.queryParser?.maxLimit ?? 100;
844
1006
  this.defaultLimit = options.defaultLimit ?? 20;
845
1007
  this.resourceName = options.resourceName;
846
1008
  this.tenantField = options.tenantField !== void 0 ? options.tenantField : DEFAULT_TENANT_FIELD;
@@ -850,6 +1012,9 @@ var BaseCrudController = class {
850
1012
  if (options.presetFields) this._presetFields = options.presetFields;
851
1013
  this._defaultSort = options.defaultSort;
852
1014
  this._onFieldWriteDenied = options.onFieldWriteDenied;
1015
+ this._onImmutableWrite = options.onImmutableWrite;
1016
+ this._writes = options.writes;
1017
+ this._transactional = options.transactional === true;
853
1018
  this.accessControl = new AccessControl({
854
1019
  tenantField: this.tenantField,
855
1020
  idField: this.idField,
@@ -857,7 +1022,8 @@ var BaseCrudController = class {
857
1022
  });
858
1023
  this.bodySanitizer = new BodySanitizer({
859
1024
  schemaOptions: this.schemaOptions,
860
- onFieldWriteDenied: this._onFieldWriteDenied
1025
+ onFieldWriteDenied: this._onFieldWriteDenied,
1026
+ onImmutableWrite: this._onImmutableWrite
861
1027
  });
862
1028
  this.queryResolver = new QueryResolver({
863
1029
  queryParser: this.queryParser,
@@ -869,9 +1035,21 @@ var BaseCrudController = class {
869
1035
  });
870
1036
  this.list = this.list.bind(this);
871
1037
  this.get = this.get.bind(this);
872
- this.create = this.create.bind(this);
873
- this.update = this.update.bind(this);
874
- this.delete = this.delete.bind(this);
1038
+ /**
1039
+ * The write methods are bound here, which means EVERY instance carries an
1040
+ * own `create`/`update`/`delete` property — so "does it have an own
1041
+ * property" cannot distinguish arc's binding from a host's override.
1042
+ *
1043
+ * Marking what arc installed makes the difference observable. A class-field
1044
+ * override (`update = async (req) => …`) initialises AFTER `super()`
1045
+ * returns and therefore REPLACES this bound method outright — the mark goes
1046
+ * with it, which is exactly the signal `warnOnWriteMethodOverride` needs.
1047
+ * A prototype-method override binds through here and stays marked; that
1048
+ * shape is caught by the prototype comparison instead.
1049
+ */
1050
+ this.create = markArcBoundWrite(this.create.bind(this));
1051
+ this.update = markArcBoundWrite(this.update.bind(this));
1052
+ this.delete = markArcBoundWrite(this.delete.bind(this));
875
1053
  }
876
1054
  /**
877
1055
  * Swap the controller's query parser. Mutates the existing `QueryResolver`
@@ -937,6 +1115,10 @@ var BaseCrudController = class {
937
1115
  rebuildBodySanitizer = true;
938
1116
  rebuildQueryResolver = true;
939
1117
  }
1118
+ if (options.onImmutableWrite !== void 0) {
1119
+ this._onImmutableWrite = options.onImmutableWrite;
1120
+ rebuildBodySanitizer = true;
1121
+ }
940
1122
  if (options.onFieldWriteDenied !== void 0) {
941
1123
  this._onFieldWriteDenied = options.onFieldWriteDenied;
942
1124
  rebuildBodySanitizer = true;
@@ -956,6 +1138,8 @@ var BaseCrudController = class {
956
1138
  }
957
1139
  if (options.cache !== void 0) this._cacheConfig = options.cache;
958
1140
  if (options.presetFields !== void 0) this._presetFields = options.presetFields;
1141
+ if (options.writes !== void 0) this._writes = options.writes;
1142
+ if (options.transactional !== void 0) this._transactional = options.transactional === true;
959
1143
  if (rebuildAccessControl) this.accessControl = new AccessControl({
960
1144
  tenantField: this.tenantField,
961
1145
  idField: this.idField,
@@ -963,7 +1147,8 @@ var BaseCrudController = class {
963
1147
  });
964
1148
  if (rebuildBodySanitizer) this.bodySanitizer = new BodySanitizer({
965
1149
  schemaOptions: this.schemaOptions,
966
- onFieldWriteDenied: this._onFieldWriteDenied
1150
+ onFieldWriteDenied: this._onFieldWriteDenied,
1151
+ onImmutableWrite: this._onImmutableWrite
967
1152
  });
968
1153
  if (rebuildQueryResolver) this.queryResolver = new QueryResolver({
969
1154
  queryParser: this.queryParser,
@@ -1103,6 +1288,52 @@ var BaseCrudController = class {
1103
1288
  return executeHookedOp(this.hookOpContext(req), args, executor);
1104
1289
  }
1105
1290
  /**
1291
+ * Run a write's PERSISTENCE step, transactionally when the resource asked.
1292
+ *
1293
+ * Plain mode hands `fn` the live repository. Transactional mode runs it
1294
+ * through repo-core's `retryingTransaction`: the callback receives the
1295
+ * TX-BOUND repository (so declared verbs and repository calls join the same
1296
+ * transaction), transient conflicts re-run `fn` with jittered bounded
1297
+ * backoff, everything else — including `VersionConflictError` — surfaces
1298
+ * once. Hooks stay OUTSIDE: before/around ran already and must not re-run;
1299
+ * after-hooks run post-commit, which they already do by running after this.
1300
+ */
1301
+ runWritePersistence(fn) {
1302
+ if (!this._transactional) return fn(this.repository);
1303
+ return retryingTransaction(this.repository, (txRepo, uow) => fn(txRepo, uow));
1304
+ }
1305
+ /** Assemble the `WriteContext` a `create` verb receives. */
1306
+ writeContext(req, repo = this.repository, uow) {
1307
+ return {
1308
+ req,
1309
+ repository: repo,
1310
+ ...uow !== void 0 ? { uow } : {}
1311
+ };
1312
+ }
1313
+ /**
1314
+ * Assemble the `MutationWriteContext` an `update` / `delete` verb receives.
1315
+ *
1316
+ * `existing` is the document arc ALREADY loaded to run its permission and
1317
+ * tenant checks — `loadMutableTarget` throws before any verb runs when it
1318
+ * cannot produce one, which is why the context types it required. Handing
1319
+ * it over rather than letting the verb re-fetch is deliberate: a second
1320
+ * read can observe a different document than the one the request was
1321
+ * authorised against.
1322
+ *
1323
+ * `id` is the REPOSITORY PRIMARY KEY (`repoId`), never the raw route param —
1324
+ * see the note on `MutationWriteContext.id`. Callers pass `resolveRepoId`'s
1325
+ * output.
1326
+ */
1327
+ mutationWriteContext(req, id, existing, repo = this.repository, uow) {
1328
+ return {
1329
+ req,
1330
+ repository: repo,
1331
+ id,
1332
+ existing,
1333
+ ...uow !== void 0 ? { uow } : {}
1334
+ };
1335
+ }
1336
+ /**
1106
1337
  * Run `executeAfter` for the given op + data. No-op when hooks aren't
1107
1338
  * wired or `resourceName` isn't set. Caller passes the data shape it
1108
1339
  * wants downstream after-handlers to receive — typically the result for
@@ -1338,11 +1569,21 @@ var BaseCrudController = class {
1338
1569
  const item = await this.runHookedOpUntilResult(req, {
1339
1570
  op: "create",
1340
1571
  input: data
1341
- }, async (processed) => this.repository.create(processed, {
1572
+ }, async (processed) => this.runWritePersistence((repo, uow) => this._writes?.create ? this._writes.create(processed, this.writeContext(req, repo, uow)) : repo.create(processed, {
1342
1573
  user,
1343
1574
  context: arcContext,
1344
1575
  ...this.tenantRepoOptions(req)
1345
- }));
1576
+ })));
1577
+ /**
1578
+ * A create COMMAND must return the created document — enforced, because
1579
+ * the alternative is quietly worse than any failure: a `201` carrying
1580
+ * `undefined` data, after-hooks (audit, events, cache invalidation) fed a
1581
+ * non-document, and a client that believes the write happened with no way
1582
+ * to address what it created. TypeScript types the contract, but arc is a
1583
+ * JavaScript runtime framework and a JS command returning nothing is one
1584
+ * missing `return` away.
1585
+ */
1586
+ 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" });
1346
1587
  await this.runAfterHook(req, "create", item);
1347
1588
  return {
1348
1589
  data: item,
@@ -1365,15 +1606,27 @@ var BaseCrudController = class {
1365
1606
  op: "update",
1366
1607
  input: data,
1367
1608
  meta: hookMeta
1368
- }, async (processed) => this.repository.update(repoId, processed, {
1609
+ }, 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, {
1369
1610
  user,
1370
1611
  context: arcContext,
1371
1612
  ...this.tenantRepoOptions(req)
1372
- }));
1373
- if (!item) this.throwNotFound("NOT_FOUND");
1374
- await this.runAfterHook(req, "update", item, hookMeta);
1613
+ })));
1614
+ /**
1615
+ * A declared verb signals "not found" by THROWING, so reaching this line
1616
+ * means the command succeeded. Translating its `void` return into a 404
1617
+ * would report failure for a write that happened — the repository's
1618
+ * `null`-means-miss contract is the repository's, not a domain command's.
1619
+ * Re-read so the response still carries the document.
1620
+ */
1621
+ const resolved = this._writes?.update ? item ?? await this.repository.getById(repoId, {
1622
+ user,
1623
+ context: arcContext,
1624
+ ...this.tenantRepoOptions(req)
1625
+ }) : item;
1626
+ if (!resolved) this.throwNotFound("NOT_FOUND");
1627
+ await this.runAfterHook(req, "update", resolved, hookMeta);
1375
1628
  return {
1376
- data: item,
1629
+ data: resolved,
1377
1630
  status: 200,
1378
1631
  meta: { message: "Updated successfully" }
1379
1632
  };
@@ -1389,15 +1642,15 @@ var BaseCrudController = class {
1389
1642
  input: existing,
1390
1643
  meta: hookMeta,
1391
1644
  pipeProcessedData: false
1392
- }, async () => this.repository.delete(repoId, {
1645
+ }, async () => this.runWritePersistence((repo, uow) => this._writes?.delete ? this._writes.delete(repoId, this.mutationWriteContext(req, repoId, existing, repo, uow)) : repo.delete(repoId, {
1393
1646
  user,
1394
1647
  context: arcContext,
1395
1648
  ...this.tenantRepoOptions(req),
1396
1649
  ...deleteMode ? { mode: deleteMode } : {}
1397
- }));
1398
- if (!result) this.throwNotFound("NOT_FOUND");
1650
+ })));
1651
+ if (!result && !this._writes?.delete) this.throwNotFound("NOT_FOUND");
1399
1652
  await this.runAfterHook(req, "delete", existing, hookMeta);
1400
- const deleteResult = result;
1653
+ const deleteResult = result ?? {};
1401
1654
  return {
1402
1655
  data: {
1403
1656
  message: deleteResult.message || "Deleted successfully",
@@ -1408,6 +1661,15 @@ var BaseCrudController = class {
1408
1661
  };
1409
1662
  }
1410
1663
  };
1664
+ /**
1665
+ * This class's write methods dispatch declared write verbs (`_writes`), so
1666
+ * `defineResource` may accept a `writes` declaration for controllers built on
1667
+ * it. One mark on the prototype — inherited by every subclass and mixin
1668
+ * composition, zero per-instance cost. A duck-typed `configure()` is NOT this
1669
+ * proof: it shows an options channel exists, not that anything dispatches
1670
+ * `writes` out of it.
1671
+ */
1672
+ markWriteVerbCapable(BaseCrudController.prototype);
1411
1673
  //#endregion
1412
1674
  //#region src/core/mixins/bulk.ts
1413
1675
  /**
@@ -1638,7 +1900,8 @@ function SlugMixin(Base) {
1638
1900
  } else throw createError(501, "Slug lookup not implemented — repository needs getBySlug() or getOne()");
1639
1901
  if (!this.accessControl.validateItemAccess(item, req)) {
1640
1902
  const code = item ? "POLICY_FILTERED" : "NOT_FOUND";
1641
- const err = new NotFoundError(this.resourceName ?? "Resource");
1903
+ const resource = this.resourceName ?? "Resource";
1904
+ const err = new NotFoundError(resource);
1642
1905
  err.details = {
1643
1906
  ...err.details ?? {},
1644
1907
  code
@@ -1749,4 +2012,4 @@ function TreeMixin(Base) {
1749
2012
  */
1750
2013
  var BaseController = class extends SoftDeleteMixin(TreeMixin(SlugMixin(BulkMixin(BaseCrudController)))) {};
1751
2014
  //#endregion
1752
- 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 };
2015
+ 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.31.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.31.0 | MIT | https://classytic.com */
2
- import { t as buildRequestScopeProjection } from "./projection-BHw6xlOQ.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