@classytic/arc 2.41.1 → 2.44.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 (248) hide show
  1. package/dist/{EventTransport-CbUKDzZZ.d.mts → EventTransport-CiicKz2Z.d.mts} +1 -1
  2. package/dist/{EventTransport-DSqD_Xsg.mjs → EventTransport-DZWDsaPa.mjs} +22 -17
  3. package/dist/{HookSystem-CMF94SEA.mjs → HookSystem-yjEgn7Cm.mjs} +2 -2
  4. package/dist/{QueryCache-DFA-QrU9.d.mts → QueryCache--5uAuYPW.d.mts} +7 -3
  5. package/dist/{ResourceRegistry-MGpXoxHZ.mjs → ResourceRegistry-DJxHUpE4.mjs} +44 -13
  6. package/dist/acquire-BozJ-9yt.mjs +35 -0
  7. package/dist/actionPermissions-5N04zBL4.mjs +772 -0
  8. package/dist/audit/index.d.mts +46 -8
  9. package/dist/audit/index.mjs +48 -26
  10. package/dist/auth/audit.d.mts +8 -8
  11. package/dist/auth/audit.mjs +1 -1
  12. package/dist/auth/index.d.mts +46 -16
  13. package/dist/auth/index.mjs +134 -156
  14. package/dist/auth/redis-session.d.mts +4 -5
  15. package/dist/auth/redis-session.mjs +1 -1
  16. package/dist/{authorizationDecision-DawKGoXb.mjs → authorizationDecision-wX3SA9w_.mjs} +7 -102
  17. package/dist/{base-CxrV1LmG.d.mts → base-CZyixEQR.d.mts} +27 -5
  18. package/dist/boundedMap-DWiKW7lP.mjs +57 -0
  19. package/dist/{buildHandler-CvxKOc0m.mjs → buildHandler-Bswu_vNb.mjs} +4 -4
  20. package/dist/cache/index.d.mts +31 -43
  21. package/dist/cache/index.mjs +4 -4
  22. package/dist/{caching-DbCBxlgw.mjs → caching-Drp68DIS.mjs} +3 -3
  23. package/dist/claimed-DYvQoZLv.mjs +8 -0
  24. package/dist/cleanup/index.d.mts +18 -22
  25. package/dist/cleanup/index.mjs +27 -73
  26. package/dist/cli/commands/describe.d.mts +7 -5
  27. package/dist/cli/commands/describe.mjs +7 -4
  28. package/dist/cli/commands/docs.d.mts +2 -3
  29. package/dist/cli/commands/docs.mjs +3 -3
  30. package/dist/cli/commands/doctor.d.mts +2 -3
  31. package/dist/cli/commands/doctor.mjs +1 -1
  32. package/dist/cli/commands/generate.mjs +2 -3
  33. package/dist/cli/commands/init.mjs +2 -3
  34. package/dist/cli/commands/introspect.d.mts +2 -3
  35. package/dist/cli/commands/introspect.mjs +2 -2
  36. package/dist/cli/index.mjs +3 -3
  37. package/dist/{constants-D5Ubn1fe.mjs → constants-B2QHb6u6.mjs} +4 -2
  38. package/dist/context/index.d.mts +11 -14
  39. package/dist/context/index.mjs +18 -38
  40. package/dist/core/index.d.mts +3 -3
  41. package/dist/core/index.mjs +8 -8
  42. package/dist/{core-5lazhva9.mjs → core-COcqo0Cp.mjs} +15 -5
  43. package/dist/{createActionRouter-DNqoaK81.mjs → createActionRouter-zG13rpPJ.mjs} +97 -39
  44. package/dist/{createAggregationRouter-BcbQJf10.mjs → createAggregationRouter-CWRz96Vr.mjs} +11 -8
  45. package/dist/{preloadResources-f3boenVZ.mjs → createApp-BGLYtErt.mjs} +305 -394
  46. package/dist/{createMcpServer-CbDWkZ1T.d.mts → createMcpServer-DSztofa9.d.mts} +1 -1
  47. package/dist/{defineAggregation-D_eJjm5A.mjs → defineAggregation--3xguLkk.mjs} +1 -1
  48. package/dist/{defineResource-DxkMTjRn.mjs → defineResource-gUp9ug66.mjs} +118 -1024
  49. package/dist/discovery/index.d.mts +6 -7
  50. package/dist/discovery/index.mjs +1 -1
  51. package/dist/docs/index.d.mts +11 -5
  52. package/dist/docs/index.mjs +4 -4
  53. package/dist/{elevation-CkvG0LG2.mjs → elevation-BG4gnMsJ.mjs} +41 -12
  54. package/dist/{elevation-BXCfVcNG.d.mts → elevation-BW2J3tNj.d.mts} +1 -2
  55. package/dist/encryption/index.d.mts +5 -5
  56. package/dist/encryption/index.mjs +3 -3
  57. package/dist/{errorHandler-D1GHKGlV.mjs → errorHandler-COHw3N1b.mjs} +26 -5
  58. package/dist/{errors-BH7YU_9O.mjs → errors-CW4anJBy.mjs} +58 -23
  59. package/dist/{errors-BGdVwP25.d.mts → errors-JYc9vwqE.d.mts} +9 -0
  60. package/dist/{eventPlugin-B52abJ8W.d.mts → eventPlugin-BtmA4-3P.d.mts} +65 -9
  61. package/dist/{eventPlugin-zZY-unhr.mjs → eventPlugin-BweHAVws.mjs} +82 -21
  62. package/dist/{subscribe-helpers-DatDkcuG.mjs → eventTypes-N4-omQFR.mjs} +151 -17
  63. package/dist/events/index.d.mts +43 -18
  64. package/dist/events/index.mjs +49 -53
  65. package/dist/events/transports/redis-stream-entry.d.mts +1 -1
  66. package/dist/events/transports/redis-stream-entry.mjs +139 -157
  67. package/dist/events/transports/redis.d.mts +5 -6
  68. package/dist/events/transports/redis.mjs +2 -2
  69. package/dist/factory/index.d.mts +42 -27
  70. package/dist/factory/index.mjs +213 -32
  71. package/dist/{fastify-augmentation-Cq2nHVaW.d.mts → fastify-augmentation-DRlCIWE9.d.mts} +11 -25
  72. package/dist/{fields-BHrkoF5y.mjs → fields-A5crhsJb.mjs} +1 -1
  73. package/dist/{fields-I_EeRWND.d.mts → fields-BBSpf8pS.d.mts} +1 -1
  74. package/dist/filter-merge-DGM-GQ1J.mjs +99 -0
  75. package/dist/{generate-BLRf2m3y.mjs → generate-BXKo2Gc8.mjs} +2 -2
  76. package/dist/{headers-BIXWKrzQ.mjs → headers-DKZQKL-Z.mjs} +2 -2
  77. package/dist/health-BYAwA6YS.mjs +22 -0
  78. package/dist/{health-ZRHeDHXH.mjs → health-CrqUp3sz.mjs} +115 -55
  79. package/dist/hooks/index.d.mts +2 -2
  80. package/dist/hooks/index.mjs +2 -3
  81. package/dist/idempotency/index.d.mts +36 -10
  82. package/dist/idempotency/index.mjs +230 -111
  83. package/dist/idempotency/redis.d.mts +1 -1
  84. package/dist/idempotency/redis.mjs +77 -12
  85. package/dist/{index-D4u76eQW.d.mts → index-Beg3aNnK.d.mts} +23 -101
  86. package/dist/{preloadResources-CjS2OfpU.d.mts → index-BkV55WlV.d.mts} +37 -38
  87. package/dist/{index-BHdZD8zK.d.mts → index-Bxriq3so.d.mts} +84 -21
  88. package/dist/index-CUJvoyw3.d.mts +510 -0
  89. package/dist/{index-CbJiYrfv.d.mts → index-Chgz3d0i.d.mts} +1 -1
  90. package/dist/{index-DHbyPo5j.d.mts → index-WH7xgvxS.d.mts} +304 -254
  91. package/dist/index.d.mts +9 -9
  92. package/dist/index.mjs +11 -11
  93. package/dist/{init-CpLkjU18.mjs → init-BPzK5Lnr.mjs} +1 -1
  94. package/dist/integrations/event-gateway.d.mts +11 -7
  95. package/dist/integrations/event-gateway.mjs +4 -4
  96. package/dist/integrations/index.d.mts +3 -3
  97. package/dist/integrations/index.mjs +1 -1
  98. package/dist/integrations/jobs/index.d.mts +1 -1
  99. package/dist/integrations/jobs/index.mjs +18 -5
  100. package/dist/integrations/mcp/index.d.mts +102 -89
  101. package/dist/integrations/mcp/index.mjs +19 -4
  102. package/dist/integrations/mcp/testing.d.mts +8 -9
  103. package/dist/integrations/mcp/testing.mjs +2 -2
  104. package/dist/integrations/streamline.d.mts +85 -144
  105. package/dist/integrations/streamline.mjs +131 -55
  106. package/dist/integrations/webhooks.d.mts +83 -143
  107. package/dist/integrations/webhooks.mjs +36 -80
  108. package/dist/integrations/websocket-pushref-redis.d.mts +5 -6
  109. package/dist/integrations/websocket-pushref-redis.mjs +1 -1
  110. package/dist/integrations/websocket-redis.d.mts +5 -6
  111. package/dist/integrations/websocket-redis.mjs +1 -1
  112. package/dist/integrations/websocket.d.mts +3 -3
  113. package/dist/integrations/websocket.mjs +2 -2
  114. package/dist/{interface-DEWNOMza.d.mts → interface-JJzwH4Ro.d.mts} +34 -2
  115. package/dist/{jsonBody-ELR9lfeU.mjs → jsonBody-YIq-vGLv.mjs} +2 -2
  116. package/dist/{keys-cpZCtcIE.mjs → keys-CpoiZ8hU.mjs} +7 -18
  117. package/dist/{loadResources-BPfWiI5s.mjs → loadResources-6jO52-RR.mjs} +3 -3
  118. package/dist/{loadResourcesFromEntry-ixtfL3J2.mjs → loadResourcesFromEntry-MP8xEppu.mjs} +2 -2
  119. package/dist/logger/index.d.mts +9 -17
  120. package/dist/logger/index.mjs +2 -153
  121. package/dist/logger-D4V1zGLI.mjs +160 -0
  122. package/dist/{mcpPlugin-BX5PujI0.mjs → mcpPlugin-srPc7ItN.mjs} +446 -170
  123. package/dist/{memory-Iotz0cCx.mjs → memory-CTa5pOCW.mjs} +36 -34
  124. package/dist/{metrics-NMsmpRmY.mjs → metrics-DsZrvnpB.mjs} +39 -72
  125. package/dist/middleware/index.d.mts +5 -5
  126. package/dist/middleware/index.mjs +3 -3
  127. package/dist/migrations/index.d.mts +13 -13
  128. package/dist/migrations/index.mjs +2 -2
  129. package/dist/{module-DpLJG2_N.mjs → module--L0iBIZz.mjs} +8 -8
  130. package/dist/mongodb-memory-server-CHhYSq5y.mjs +35539 -0
  131. package/dist/mongoose-CYJCLyi-.mjs +67451 -0
  132. package/dist/multiTenant-CAtYlW6H.d.mts +104 -0
  133. package/dist/{multipartBody-DtRWM6_U.mjs → multipartBody-Cc7eZ9cq.mjs} +46 -7
  134. package/dist/node-D8UZv_8c.mjs +41236 -0
  135. package/dist/{openapi-CmFf3yq_.mjs → openapi-CMegfsm8.mjs} +48 -10
  136. package/dist/outbox-admin/index.d.mts +5 -5
  137. package/dist/outbox-admin/index.mjs +6 -18
  138. package/dist/{outbox-module-D2z5WkDY.mjs → outbox-module-Ryk270jd.mjs} +203 -33
  139. package/dist/{outbox-module-BHdUnuuK.d.mts → outbox-module-gZCHoE4H.d.mts} +79 -6
  140. package/dist/permissions/index.d.mts +5 -4
  141. package/dist/permissions/index.mjs +8 -6
  142. package/dist/{permissions-D8TBH44E.mjs → permissions-C5ijIz5Q.mjs} +149 -33
  143. package/dist/{pipe-FMWNheSO.mjs → pipe-D81zMtWs.mjs} +2 -2
  144. package/dist/pipeline/index.d.mts +7 -7
  145. package/dist/pipeline/index.mjs +2 -2
  146. package/dist/plan-CJGMKoui.mjs +565 -0
  147. package/dist/{BaseController-CYEwjyjN.mjs → plugin-DALd9dcF.mjs} +1269 -346
  148. package/dist/plugins/index.d.mts +17 -14
  149. package/dist/plugins/index.mjs +44 -22
  150. package/dist/plugins/response-cache.d.mts +5 -6
  151. package/dist/plugins/response-cache.mjs +8 -14
  152. package/dist/plugins/tracing-entry.mjs +2 -2
  153. package/dist/{pluralize-CXWZIzTv.mjs → pluralize-COUNYo3d.mjs} +1 -1
  154. package/dist/presets/filesUpload.d.mts +8 -8
  155. package/dist/presets/filesUpload.mjs +5 -5
  156. package/dist/presets/index.d.mts +35 -40
  157. package/dist/presets/index.mjs +4 -4
  158. package/dist/presets/multiTenant.d.mts +2 -107
  159. package/dist/presets/multiTenant.mjs +22 -162
  160. package/dist/presets/search.d.mts +9 -10
  161. package/dist/presets/search.mjs +2 -2
  162. package/dist/{presets-ZJSPgmT2.mjs → presets-CONf2Qem.mjs} +99 -107
  163. package/dist/{projection-BzIgzoKZ.mjs → projection-wFoLqdss.mjs} +2 -2
  164. package/dist/{pushref-registry-Dov9l4qE.d.mts → pushref-registry-UxCkoA4C.d.mts} +1 -1
  165. package/dist/{queryCachePlugin-DZHPOm9p.mjs → queryCachePlugin-BATECpyh.mjs} +12 -14
  166. package/dist/{queryCachePlugin-BzqxconG.d.mts → queryCachePlugin-C2lZjTxk.d.mts} +1 -1
  167. package/dist/{rateLimitKey-BvOvcBDi.mjs → rateLimitKey-DVEZ_UpV.mjs} +1 -1
  168. package/dist/{realtime-8tIQWZz4.mjs → realtime-xyubMKSB.mjs} +25 -21
  169. package/dist/{redis-Bzi1y3F9.d.mts → redis-BDgv9Glw.d.mts} +17 -24
  170. package/dist/redis-stream-BMk-bpQS.d.mts +219 -0
  171. package/dist/redisShapes-CQksY67q.d.mts +55 -0
  172. package/dist/registry/index.d.mts +15 -10
  173. package/dist/registry/index.mjs +4 -4
  174. package/dist/{registry-DNkgGQcK.mjs → registry-Bdzg-hdm.mjs} +13 -10
  175. package/dist/{renewingLease-yF8BdW4b.mjs → renewingLease-BFJxGPuh.mjs} +1 -1
  176. package/dist/{replyHelpers-CNdvMfM4.mjs → replyHelpers-Bnc6lJyy.mjs} +2 -2
  177. package/dist/{requestContext-cDkAUr-P.mjs → requestContext-C10UeFe5.mjs} +1 -1
  178. package/dist/requirement-Bu50QALH.mjs +35 -0
  179. package/dist/requirement-CbiLdQ1n.d.mts +22 -0
  180. package/dist/resourceRateLimit-Wf6d6FL9.d.mts +30 -0
  181. package/dist/resourceRateLimit-urWPJxrt.mjs +180 -0
  182. package/dist/rolldown-runtime-MYrLNGnq.mjs +45 -0
  183. package/dist/routeValidator-DHtpzrzk.mjs +70 -0
  184. package/dist/{runtimeCapabilities-CkhS6jn6.mjs → runtimeCapabilities-DIMKMV2G.mjs} +1 -1
  185. package/dist/{schedules-j6uXXAeJ.mjs → schedules-CKTs5ys8.mjs} +46 -37
  186. package/dist/{schemaConverter-BVA7cFRG.mjs → schemaConverter-BVjkQ5DD.mjs} +2 -2
  187. package/dist/{schemaIR-CTvPDoPB.mjs → schemaIR-BA0Hb21Y.mjs} +2 -2
  188. package/dist/schemas/index.d.mts +20 -10
  189. package/dist/schemas/index.mjs +8 -3
  190. package/dist/scim/index.d.mts +13 -13
  191. package/dist/scim/index.mjs +2 -2
  192. package/dist/scope/index.d.mts +4 -4
  193. package/dist/scope/index.mjs +6 -6
  194. package/dist/{scope-BUi1xV1I.mjs → scope-DINaj7Ax.mjs} +4 -4
  195. package/dist/shutdown-LpLMH8T1.mjs +59 -0
  196. package/dist/{simpleEqualityMatcher-C9bzq2gO.mjs → simpleEqualityMatcher-DbwIM1f1.mjs} +1 -1
  197. package/dist/{sse-BeqVKQKu.mjs → sse-BzJFPX_1.mjs} +11 -15
  198. package/dist/{sseStream-Ppzo-WUH.mjs → sseStream-DQChzGTs.mjs} +2 -2
  199. package/dist/{store-helpers-dW71XVWL.mjs → store-helpers-Coj9RhOZ.mjs} +1 -1
  200. package/dist/{streaming-IcRFjWQU.mjs → streaming-JzB5CAga.mjs} +1 -1
  201. package/dist/subscriberIdentity-DeyppIif.mjs +28 -0
  202. package/dist/tenancy-DxV8OuX2.mjs +43 -0
  203. package/dist/tenantPreparation-CBvvAcAj.mjs +67 -0
  204. package/dist/testing/cleanupStoreContract.d.mts +4 -5
  205. package/dist/testing/cleanupStoreContract.mjs +1 -1
  206. package/dist/testing/index.d.mts +215 -197
  207. package/dist/testing/index.mjs +289 -214
  208. package/dist/testing/outboxStoreContract.d.mts +4 -5
  209. package/dist/testing/outboxStoreContract.mjs +137 -2
  210. package/dist/testing/requiredPorts.d.mts +16 -0
  211. package/dist/testing/requiredPorts.mjs +65 -0
  212. package/dist/testing/storageContract.d.mts +4 -5
  213. package/dist/testing/storageContract.mjs +1 -1
  214. package/dist/timing-CntavTlR.mjs +46 -0
  215. package/dist/{transactionContext-CPbdlnVw.mjs → transactionContext-orvnGu4F.mjs} +1 -1
  216. package/dist/{typeGuards-BLjPgVBm.mjs → typeGuards-kEaP_WmO.mjs} +1 -1
  217. package/dist/types/index.d.mts +4 -4
  218. package/dist/types/index.mjs +1 -1
  219. package/dist/types/storage.mjs +1 -1
  220. package/dist/{types-DfVDvkdP.d.mts → types-BJ-2XqVF.d.mts} +189 -204
  221. package/dist/{types-CWQn9-IC.d.mts → types-D3Ixsh00.d.mts} +22 -9
  222. package/dist/{types-CgenB1gv.mjs → types-fvO6Ovzx.mjs} +4 -4
  223. package/dist/usage/index.d.mts +5 -6
  224. package/dist/usage/index.mjs +5 -5
  225. package/dist/{userHelpers-ChRvkuSZ.mjs → userHelpers-C76zD5pa.mjs} +1 -1
  226. package/dist/utils/index.d.mts +125 -264
  227. package/dist/utils/index.mjs +13 -12
  228. package/dist/{utils-CxcctrAh.mjs → utils-BpkVZ8rB.mjs} +315 -404
  229. package/dist/{validate-DrYWnOzI.mjs → validate-CWp-lsrm.mjs} +6 -14
  230. package/dist/{versioning-D6JIK8Tk.d.mts → versioning-Bsb51gGT.d.mts} +28 -13
  231. package/dist/{versioning-DKkT4hNJ.mjs → versioning-CHhL9P3o.mjs} +4 -4
  232. package/dist/{websocket-BOXVggMf.d.mts → websocket-Cs4awVyD.d.mts} +1 -1
  233. package/dist/{workScope-CxKqoIFn.mjs → workScope-n9a7d_lZ.mjs} +2 -2
  234. package/package.json +57 -59
  235. package/skills/arc/SKILL.md +13 -4
  236. package/skills/arc/references/auth.md +8 -5
  237. package/skills/arc/references/integrations.md +1 -1
  238. package/skills/arc/references/mcp.md +8 -6
  239. package/skills/arc/references/multi-tenancy.md +19 -14
  240. package/skills/arc/references/production.md +27 -10
  241. package/dist/actionPermissions-BoPGQ5-4.mjs +0 -350
  242. package/dist/index-CFfLKMH4.d.mts +0 -290
  243. package/dist/interface-YzIbF7Ii.mjs +0 -17
  244. package/dist/redis-stream-BPKscsTB.d.mts +0 -260
  245. package/dist/registerRoute-Bsnk6NiZ.mjs +0 -364
  246. package/dist/rolldown-runtime-eysyzFAF.mjs +0 -15
  247. package/dist/sync/changelog.d.mts +0 -3
  248. package/dist/sync/changelog.mjs +0 -3
@@ -1,10 +1,15 @@
1
- /*! @classytic/arc v2.41.1 | MIT | https://classytic.com */
2
- import { n as requireSingleHeaderValue } from "../headers-BIXWKrzQ.mjs";
3
- import { n as createSafeGetOne, t as createIsDuplicateKeyError } from "../store-helpers-dW71XVWL.mjs";
4
- import { t as createIdempotencyResult } from "../interface-YzIbF7Ii.mjs";
1
+ /*! @classytic/arc v2.44.0 | MIT | https://classytic.com */
2
+ import { d as createDomainError } from "../errors-CW4anJBy.mjs";
3
+ import { n as requireSingleHeaderValue } from "../headers-DKZQKL-Z.mjs";
4
+ import { i as requestRequiresIdempotencyKey, n as idempotencyConfigProblem, r as requestDedupedByHandler, t as declaresIdempotencyFence } from "../requirement-Bu50QALH.mjs";
5
+ import { n as createSafeGetOne, t as createIsDuplicateKeyError } from "../store-helpers-Coj9RhOZ.mjs";
6
+ import { n as createIdempotencyResult, r as operationIdentity, t as resolveIdempotencyTiming } from "../timing-CntavTlR.mjs";
7
+ import { n as acquireIdempotencyKey, r as releaseIdempotencyLock } from "../acquire-BozJ-9yt.mjs";
8
+ import { ARC_ERROR_CODES } from "@classytic/repo-core/errors";
5
9
  import { and, eq, exists, gt, lt, or, startsWith } from "@classytic/repo-core/filter";
6
10
  import { createHash } from "node:crypto";
7
11
  import fp from "fastify-plugin";
12
+ import { IdempotencyError, fingerprintRequest } from "@classytic/repo-core/idempotency";
8
13
  import { update } from "@classytic/repo-core/update";
9
14
  //#region src/idempotency/repository-idempotency-adapter.ts
10
15
  /**
@@ -60,20 +65,12 @@ function repositoryAsIdempotencyStore(repository, defaultTtlMs) {
60
65
  }
61
66
  return {
62
67
  name: "repository",
68
+ completionReleasesLock: true,
63
69
  async get(key) {
64
70
  const doc = await safeGetOne(eq(idField, key));
65
71
  if (!doc) return void 0;
66
72
  assertKeyIdentity("get", doc, key);
67
- if (!doc.result) return void 0;
68
- if (new Date(doc.expiresAt) < /* @__PURE__ */ new Date()) return void 0;
69
- return {
70
- key,
71
- statusCode: doc.result.statusCode,
72
- headers: doc.result.headers,
73
- body: doc.result.body,
74
- createdAt: new Date(doc.createdAt),
75
- expiresAt: new Date(doc.expiresAt)
76
- };
73
+ return completedResult(doc, key);
77
74
  },
78
75
  async set(key, result) {
79
76
  const doc = await r.findOneAndUpdate(eq(idField, key), update({
@@ -81,7 +78,9 @@ function repositoryAsIdempotencyStore(repository, defaultTtlMs) {
81
78
  result: {
82
79
  statusCode: result.statusCode,
83
80
  headers: result.headers,
84
- body: result.body
81
+ body: result.body,
82
+ ...result.bodyEncoding ? { bodyEncoding: result.bodyEncoding } : {},
83
+ ...result.requestFingerprint ? { requestFingerprint: result.requestFingerprint } : {}
85
84
  },
86
85
  createdAt: result.createdAt,
87
86
  expiresAt: result.expiresAt
@@ -94,6 +93,67 @@ function repositoryAsIdempotencyStore(repository, defaultTtlMs) {
94
93
  });
95
94
  if (doc) assertKeyIdentity("set", doc, key);
96
95
  },
96
+ async setIfLocked(key, requestId, result) {
97
+ const doc = await r.findOneAndUpdate(and(eq(idField, key), eq("lock.requestId", requestId), gt("lock.expiresAt", /* @__PURE__ */ new Date())), update({
98
+ set: {
99
+ result: {
100
+ statusCode: result.statusCode,
101
+ headers: result.headers,
102
+ body: result.body,
103
+ ...result.requestFingerprint ? { requestFingerprint: result.requestFingerprint } : {},
104
+ ...result.bodyEncoding ? { bodyEncoding: result.bodyEncoding } : {}
105
+ },
106
+ createdAt: result.createdAt,
107
+ expiresAt: result.expiresAt
108
+ },
109
+ unset: ["lock"]
110
+ }), { returnDocument: "after" });
111
+ if (!doc) return false;
112
+ assertKeyIdentity("setIfLocked", doc, key);
113
+ return true;
114
+ },
115
+ async acquire(key, requestId, lockTtlMs) {
116
+ for (let attempt = 0; attempt < 2; attempt++) {
117
+ const now = /* @__PURE__ */ new Date();
118
+ let doc = null;
119
+ try {
120
+ doc = await r.findOneAndUpdate(and(eq(idField, key), or(exists("result.statusCode", false), lt("expiresAt", now)), or(exists("lock", false), lt("lock.expiresAt", now))), update({
121
+ set: {
122
+ lock: {
123
+ requestId,
124
+ expiresAt: new Date(now.getTime() + lockTtlMs)
125
+ },
126
+ expiresAt: new Date(now.getTime() + defaultTtlMs)
127
+ },
128
+ unset: ["result"],
129
+ setOnInsert: {
130
+ [idField]: key,
131
+ createdAt: now
132
+ }
133
+ }), {
134
+ upsert: true,
135
+ returnDocument: "after"
136
+ });
137
+ } catch (err) {
138
+ if (!isDuplicateKeyError(err)) throw err;
139
+ }
140
+ if (doc) {
141
+ assertKeyIdentity("acquire", doc, key);
142
+ return { status: "acquired" };
143
+ }
144
+ const existing = await safeGetOne(eq(idField, key));
145
+ if (existing) {
146
+ assertKeyIdentity("acquire", existing, key);
147
+ const result = completedResult(existing, key);
148
+ if (result) return {
149
+ status: "completed",
150
+ result
151
+ };
152
+ if (existing.lock && new Date(existing.lock.expiresAt) > /* @__PURE__ */ new Date()) return { status: "locked" };
153
+ }
154
+ }
155
+ return { status: "locked" };
156
+ },
97
157
  async tryLock(key, requestId, ttlMs) {
98
158
  const now = /* @__PURE__ */ new Date();
99
159
  const lockExpiresAt = new Date(now.getTime() + ttlMs);
@@ -135,11 +195,12 @@ function repositoryAsIdempotencyStore(repository, defaultTtlMs) {
135
195
  return (await r.deleteMany(startsWith(idField, prefix, "sensitive"))).deletedCount ?? 0;
136
196
  },
137
197
  async findByPrefix(prefix) {
138
- const doc = await safeGetOne(and(startsWith(idField, prefix, "sensitive"), exists("result", true), gt("expiresAt", /* @__PURE__ */ new Date())));
139
- if (!doc?.result) return void 0;
198
+ const doc = await safeGetOne(and(startsWith(idField, prefix, "sensitive"), exists("result.statusCode", true), gt("expiresAt", /* @__PURE__ */ new Date())));
199
+ if (!doc || !isCompleted(doc)) return void 0;
140
200
  if (!String(doc[idField] ?? "").startsWith(prefix)) throw new IdempotencyStoreMisconfiguredError("findByPrefix", prefix, doc[idField]);
141
201
  return {
142
202
  key: String(doc[idField] ?? prefix),
203
+ ...doc.result.requestFingerprint ? { requestFingerprint: doc.result.requestFingerprint } : {},
143
204
  statusCode: doc.result.statusCode,
144
205
  headers: doc.result.headers,
145
206
  body: doc.result.body,
@@ -150,10 +211,33 @@ function repositoryAsIdempotencyStore(repository, defaultTtlMs) {
150
211
  async close() {}
151
212
  };
152
213
  }
214
+ /** The row's unexpired completed response, or `undefined`. */
215
+ function completedResult(doc, key) {
216
+ if (!isCompleted(doc)) return void 0;
217
+ if (new Date(doc.expiresAt) < /* @__PURE__ */ new Date()) return void 0;
218
+ return {
219
+ key,
220
+ ...doc.result.requestFingerprint ? { requestFingerprint: doc.result.requestFingerprint } : {},
221
+ statusCode: doc.result.statusCode,
222
+ headers: doc.result.headers,
223
+ body: doc.result.body,
224
+ ...doc.result.bodyEncoding ? { bodyEncoding: doc.result.bodyEncoding } : {},
225
+ createdAt: new Date(doc.createdAt),
226
+ expiresAt: new Date(doc.expiresAt)
227
+ };
228
+ }
229
+ /**
230
+ * A row holds a completed response only when it has a status code. A lock-only row can read back
231
+ * with `result: {}` (a nested path in a mongoose schema); replaying that would answer nothing.
232
+ */
233
+ function isCompleted(doc) {
234
+ return typeof doc.result?.statusCode === "number";
235
+ }
153
236
  //#endregion
154
237
  //#region src/idempotency/stores/memory.ts
155
238
  var MemoryIdempotencyStore = class {
156
239
  name = "memory";
240
+ completionReleasesLock = true;
157
241
  results = /* @__PURE__ */ new Map();
158
242
  locks = /* @__PURE__ */ new Map();
159
243
  ttlMs;
@@ -178,12 +262,41 @@ var MemoryIdempotencyStore = class {
178
262
  return result;
179
263
  }
180
264
  async set(key, result) {
265
+ this.writeResult(key, result);
266
+ }
267
+ writeResult(key, result) {
181
268
  if (this.results.size >= this.maxEntries) this.evictOldest();
182
269
  this.results.set(key, {
183
270
  ...result,
184
271
  key
185
272
  });
186
273
  }
274
+ async setIfLocked(key, requestId, result) {
275
+ const lock = this.locks.get(key);
276
+ if (!lock || lock.requestId !== requestId || lock.expiresAt.getTime() <= Date.now()) return false;
277
+ this.writeResult(key, result);
278
+ this.locks.delete(key);
279
+ return true;
280
+ }
281
+ async acquire(key, requestId, lockTtlMs) {
282
+ const result = this.results.get(key);
283
+ if (result) {
284
+ if (Date.now() <= new Date(result.expiresAt).getTime()) return {
285
+ status: "completed",
286
+ result
287
+ };
288
+ this.results.delete(key);
289
+ }
290
+ const lock = this.locks.get(key);
291
+ if (lock && Date.now() <= new Date(lock.expiresAt).getTime()) return { status: "locked" };
292
+ this.locks.set(key, {
293
+ key,
294
+ requestId,
295
+ lockedAt: /* @__PURE__ */ new Date(),
296
+ expiresAt: new Date(Date.now() + lockTtlMs)
297
+ });
298
+ return { status: "acquired" };
299
+ }
187
300
  async tryLock(key, requestId, ttlMs) {
188
301
  const existing = this.locks.get(key);
189
302
  if (existing) {
@@ -266,42 +379,23 @@ var MemoryIdempotencyStore = class {
266
379
  //#endregion
267
380
  //#region src/idempotency/idempotencyPlugin.ts
268
381
  /**
269
- * Idempotency — duplicate-request protection for mutations.
270
- *
271
- * AUTH ORDER MATTERS: `idempotency.middleware` must run AFTER authentication
272
- * in the preHandler chain, so the fingerprint includes the real caller and a
273
- * key cannot replay another user's response. `createCrudRouter` wires this for
274
- * mutation routes; custom routes do it themselves:
275
- * `preHandler: [fastify.authenticate, fastify.idempotency.middleware]`.
276
- *
277
- * GUARANTEE — best-effort effectively-once, NOT exactly-once. A distributed
278
- * lock makes concurrent same-key requests 409, the key includes a body+caller
279
- * fingerprint (a different body under one key is its own operation), and a
280
- * completed result replays. The business effect is not exactly-once, because
281
- * the domain write and the idempotency record are different systems:
282
- * - a crash after the DB write but before the record (preSerialization) lets a
283
- * retry re-execute;
284
- * - a handler outrunning `lockTimeoutMs` can overlap its own retry — size that
285
- * above the slowest protected handler.
382
+ * Completion replay for mutations. Authentication, permission and tenant preparation must
383
+ * precede the middleware: principal/scope identity isolates keys; fingerprints reject reuse
384
+ * with changed input. Built-in stores fence completion against an expired/replaced lock.
286
385
  *
287
- * Where re-execution is unacceptable: write the record in the SAME transaction
288
- * as the business write (the store contract is exported), or make the handler
289
- * naturally idempotent (upsert, compare-and-set).
386
+ * A crash between the business write and completion, or a handler outliving its lease, can
387
+ * repeat effects. Use domain idempotency (`dedup: 'handler'`) with a shared transaction or
388
+ * intrinsic uniqueness where replay protection alone is insufficient. See wiki/plugins.md.
290
389
  */
291
390
  const HEADER_IDEMPOTENCY_REPLAYED = "x-idempotency-replayed";
292
391
  const HEADER_IDEMPOTENCY_KEY = "x-idempotency-key";
293
- /**
294
- * Cap recursion depth in body fingerprinting. Real APIs almost never exceed
295
- * ~10 levels; the cap is generous so well-formed payloads are unaffected,
296
- * yet low enough to stop a `{"a":{"a":{...thousands deep...}}}` stack bomb.
297
- */
298
- const MAX_FINGERPRINT_DEPTH = 32;
299
392
  const idempotencyPlugin = async (fastify, opts = {}) => {
300
- const { enabled = false, headerName = "idempotency-key", ttlMs = 864e5, lockTimeoutMs = 3e4, methods = [
393
+ const { enabled = false, headerName = "idempotency-key", methods = [
301
394
  "POST",
302
395
  "PUT",
303
396
  "PATCH"
304
397
  ], include, exclude, repository, store: explicitStore, retryAfterSeconds = 1, namespace, selfCheck = true } = opts;
398
+ const { ttlMs, lockTimeoutMs } = resolveIdempotencyTiming(opts.ttlMs, opts.lockTimeoutMs);
305
399
  const store = repository ? repositoryAsIdempotencyStore(repository, ttlMs) : explicitStore ?? new MemoryIdempotencyStore({ ttlMs });
306
400
  /**
307
401
  * Who owns the store's lifecycle — arc closes ONLY what arc built.
@@ -319,6 +413,10 @@ const idempotencyPlugin = async (fastify, opts = {}) => {
319
413
  * (host-owned) is never touched.
320
414
  */
321
415
  const ownsStore = !explicitStore;
416
+ if (enabled && (typeof store.setIfLocked !== "function" || store.supportsFencedCompletion === false)) {
417
+ if (!opts.allowUnfencedCompletion) throw new Error("[arc idempotency] Store must implement atomic setIfLocked completion fencing. Upgrade the store or explicitly allowUnfencedCompletion during migration.");
418
+ fastify.log.warn("Idempotency completion is unfenced: an expired request can overwrite another owner's response. Upgrade the custom store's setIfLocked implementation.");
419
+ }
322
420
  if (enabled && selfCheck) {
323
421
  const probeSuffix = createHash("sha256").update(`${process.pid}:${Date.now()}:${Math.random()}`).digest("hex").slice(0, 12);
324
422
  const probeKeyA = `__arc_idempotency_selfcheck__:${probeSuffix}:a`;
@@ -337,6 +435,10 @@ const idempotencyPlugin = async (fastify, opts = {}) => {
337
435
  }
338
436
  }
339
437
  if (!enabled) {
438
+ fastify.addHook("onRoute", (route) => {
439
+ if (!declaresIdempotencyFence(route.config)) return;
440
+ throw new Error(`[arc] ${String(route.method)} ${route.url} declares config.idempotency.required, but the idempotency plugin is disabled — its key would be accepted and never deduplicated. Enable idempotencyPlugin({ enabled: true }) or drop the declaration.`);
441
+ });
340
442
  fastify.decorate("idempotency", {
341
443
  invalidate: async () => {},
342
444
  has: async () => false,
@@ -346,6 +448,7 @@ const idempotencyPlugin = async (fastify, opts = {}) => {
346
448
  fastify.decorateRequest("idempotencyKey", void 0);
347
449
  fastify.decorateRequest("idempotencyReplayed", false);
348
450
  fastify.decorateRequest("_idempotencyFullKey", void 0);
451
+ fastify.decorateRequest("_idempotencyFingerprint", void 0);
349
452
  fastify.log?.debug?.("Idempotency plugin disabled");
350
453
  return;
351
454
  }
@@ -353,10 +456,12 @@ const idempotencyPlugin = async (fastify, opts = {}) => {
353
456
  fastify.decorateRequest("idempotencyKey", void 0);
354
457
  fastify.decorateRequest("idempotencyReplayed", false);
355
458
  fastify.decorateRequest("_idempotencyFullKey", void 0);
459
+ fastify.decorateRequest("_idempotencyFingerprint", void 0);
356
460
  /**
357
461
  * Check if this request should use idempotency
358
462
  */
359
463
  function shouldApplyIdempotency(request) {
464
+ if (requestRequiresIdempotencyKey(request)) return true;
360
465
  if (!methodSet.has(request.method)) return false;
361
466
  const url = request.url;
362
467
  if (exclude?.some((pattern) => pattern.test(url))) return false;
@@ -364,76 +469,77 @@ const idempotencyPlugin = async (fastify, opts = {}) => {
364
469
  return true;
365
470
  }
366
471
  /**
367
- * Normalize body for consistent hashing (sort keys recursively).
472
+ * The OPERATION this key names — namespace, method, URL and caller. The body is NOT part of
473
+ * it: the same key with a different body is the same operation asked twice, which is refused
474
+ * (see `bodyFingerprint`), never executed again under a fresh storage key.
368
475
  *
369
- * Recursion is bounded by `MAX_FINGERPRINT_DEPTH` to defeat a deep-nesting
370
- * DoS: an attacker who sends a 1 MB JSON body of nested `{"a":{"a":...}}`
371
- * would otherwise blow the call stack inside this hot path. The cap
372
- * exceeds any sensible API shape (real bodies are <10 levels deep) while
373
- * still being well under V8's default stack budget. At the limit we
374
- * substitute a sentinel string so the hash still differs from siblings
375
- * but recursion stops — the request is fingerprinted as if everything
376
- * beyond the cap were `"<truncated>"`.
476
+ * Must run AFTER auth has populated `request.user`, or every caller shares `anon`.
377
477
  */
378
- function normalizeBody(obj, depth = 0) {
379
- if (obj === null || typeof obj !== "object") return obj;
380
- if (depth >= MAX_FINGERPRINT_DEPTH) try {
381
- return `<deep:${createHash("sha256").update(JSON.stringify(obj)).digest("hex").slice(0, 16)}>`;
382
- } catch {
383
- return "<truncated>";
384
- }
385
- if (Array.isArray(obj)) return obj.map((item) => normalizeBody(item, depth + 1));
386
- const sorted = {};
387
- const keys = Object.keys(obj).sort();
388
- for (const key of keys) sorted[key] = normalizeBody(obj[key], depth + 1);
389
- return sorted;
478
+ function operationIdentity$1(request) {
479
+ const user = request.user;
480
+ return operationIdentity(`${request.method}:${request.url}`, request.scope, user?.id ?? user?._id, namespace);
390
481
  }
391
482
  /**
392
- * Generate a fingerprint for the request (for key generation).
393
- * Includes caller identity so the same idempotency key from different
394
- * users doesn't replay one user's response to another.
395
- *
396
- * IMPORTANT: This must be called AFTER auth has populated request.user,
397
- * otherwise userId falls back to 'anon' and cross-user replay is possible.
483
+ * `fingerprintRequest` from `@classytic/repo-core/idempotency` — the ONE body fingerprint
484
+ * every layer uses. A body too deep to fingerprint is refused, not truncated.
398
485
  */
399
- function getRequestFingerprint(request) {
400
- let bodyHash = "nobody";
401
- if (request.body && typeof request.body === "object") {
402
- const normalized = normalizeBody(request.body);
403
- const bodyString = JSON.stringify(normalized);
404
- bodyHash = createHash("sha256").update(bodyString).digest("hex").substring(0, 16);
405
- if (request.log?.debug) request.log.debug({ bodyHash }, "Generated body hash");
486
+ function bodyFingerprint(request) {
487
+ try {
488
+ return fingerprintRequest(request.body ?? null);
489
+ } catch (err) {
490
+ if (err instanceof IdempotencyError && err.code === "UNFINGERPRINTABLE") throw createDomainError(ARC_ERROR_CODES.IDEMPOTENCY_BODY_UNFINGERPRINTABLE, err.message, 400);
491
+ throw err;
406
492
  }
407
- const user = request.user;
408
- const userId = user?.id ?? user?._id ?? "anon";
409
- return `${namespace ? `n=${namespace}:` : ""}${request.method}:${request.url}:${bodyHash}:u=${userId}`;
493
+ }
494
+ function replay(request, reply, cached, fingerprint) {
495
+ if (cached.requestFingerprint && cached.requestFingerprint !== fingerprint) throw createDomainError(ARC_ERROR_CODES.IDEMPOTENCY_KEY_REUSED, `Idempotency key '${request.idempotencyKey}' was already used for a different request body. Use a new key for a new request.`, 422);
496
+ request.idempotencyReplayed = true;
497
+ reply.header(HEADER_IDEMPOTENCY_REPLAYED, "true");
498
+ reply.header(HEADER_IDEMPOTENCY_KEY, request.idempotencyKey);
499
+ for (const [key, value] of Object.entries(cached.headers)) {
500
+ const name = key.toLowerCase();
501
+ const liveBudget = name.startsWith("x-ratelimit-") || name.startsWith("ratelimit-") || name === "retry-after";
502
+ if (!name.startsWith("x-idempotency") && !(liveBudget && reply.hasHeader(name))) reply.header(key, value);
503
+ }
504
+ reply.code(cached.statusCode).send(cached.bodyEncoding === "base64" && typeof cached.body === "string" ? Buffer.from(cached.body, "base64") : cached.body);
410
505
  }
411
506
  const idempotencyMiddleware = async (request, reply) => {
412
- if (!shouldApplyIdempotency(request)) return;
413
507
  const idempotencyKey = requireSingleHeaderValue(request.headers, headerName)?.trim();
508
+ if (!idempotencyKey && requestRequiresIdempotencyKey(request)) throw createDomainError(ARC_ERROR_CODES.IDEMPOTENCY_KEY_REQUIRED, `This operation requires an '${headerName}' header, stable across retries of the same request.`, 400);
509
+ if (requestDedupedByHandler(request)) {
510
+ if (idempotencyKey) request.idempotencyKey = idempotencyKey;
511
+ return;
512
+ }
513
+ if (!shouldApplyIdempotency(request)) return;
414
514
  if (!idempotencyKey) return;
415
515
  request.idempotencyKey = idempotencyKey;
416
- const fullKey = `${idempotencyKey}:${getRequestFingerprint(request)}`;
417
- const cached = await store.get(fullKey);
418
- if (cached) {
419
- request.idempotencyReplayed = true;
420
- reply.header(HEADER_IDEMPOTENCY_REPLAYED, "true");
421
- reply.header(HEADER_IDEMPOTENCY_KEY, idempotencyKey);
422
- for (const [key, value] of Object.entries(cached.headers)) if (!key.startsWith("x-idempotency")) reply.header(key, value);
423
- reply.code(cached.statusCode).send(cached.body);
424
- return;
516
+ const fullKey = `${idempotencyKey}:${operationIdentity$1(request)}`;
517
+ const requestFingerprint = bodyFingerprint(request);
518
+ const acquisition = await acquireIdempotencyKey(store, fullKey, request.id, lockTimeoutMs);
519
+ if (acquisition.status === "completed") {
520
+ replay(request, reply, acquisition.result, requestFingerprint);
521
+ return reply;
425
522
  }
426
- if (!await store.tryLock(fullKey, request.id, lockTimeoutMs)) {
523
+ if (acquisition.status === "locked") {
427
524
  reply.code(409).header("Retry-After", retryAfterSeconds.toString()).send({
428
- error: "Request with this idempotency key is already in progress",
429
- code: "IDEMPOTENCY_CONFLICT",
430
- retryAfter: retryAfterSeconds
525
+ code: ARC_ERROR_CODES.IDEMPOTENCY_CONFLICT,
526
+ message: "Request with this idempotency key is already in progress",
527
+ status: 409,
528
+ meta: { retryAfter: retryAfterSeconds }
431
529
  });
432
- return;
530
+ return reply;
433
531
  }
434
532
  request._idempotencyFullKey = fullKey;
533
+ request._idempotencyFingerprint = requestFingerprint;
435
534
  reply.header(HEADER_IDEMPOTENCY_KEY, idempotencyKey);
436
535
  };
536
+ fastify.addHook("onRoute", (route) => {
537
+ const problem = idempotencyConfigProblem(route.config);
538
+ if (problem) throw new Error(`[arc] ${String(route.method)} ${route.url}: config.idempotency: ${problem}.`);
539
+ if (!declaresIdempotencyFence(route.config)) return;
540
+ if ((Array.isArray(route.preHandler) ? route.preHandler : [route.preHandler]).includes(idempotencyMiddleware)) return;
541
+ throw new Error(`[arc] ${String(route.method)} ${route.url} declares config.idempotency.required but its preHandler does not run fastify.idempotency.middleware, so a retry would execute twice. Add it after authentication: preHandler: [fastify.authenticate, fastify.idempotency.middleware].`);
542
+ });
437
543
  fastify.decorate("idempotency", {
438
544
  invalidate: async (key) => {
439
545
  await store.deleteByPrefix(`${key}:`);
@@ -442,14 +548,20 @@ const idempotencyPlugin = async (fastify, opts = {}) => {
442
548
  return !!await store.findByPrefix(`${key}:`);
443
549
  },
444
550
  middleware: idempotencyMiddleware,
445
- store
551
+ store,
552
+ allowUnfencedCompletion: opts.allowUnfencedCompletion === true,
553
+ ttlMs,
554
+ lockTimeoutMs
446
555
  });
447
- fastify.addHook("preSerialization", async (request, reply, payload) => {
448
- if (request.idempotencyReplayed) return payload;
556
+ const recordedResponses = /* @__PURE__ */ new WeakSet();
557
+ /** Completed through `setIfLocked` — the lock is already gone when the store says so. */
558
+ const fencedCompletions = /* @__PURE__ */ new WeakSet();
559
+ async function recordResponse(request, reply, payload, binary = false) {
560
+ if (request.idempotencyReplayed || recordedResponses.has(request)) return;
449
561
  const fullKey = request._idempotencyFullKey;
450
- if (!fullKey) return payload;
562
+ if (!fullKey) return;
451
563
  const statusCode = reply.statusCode;
452
- if (statusCode < 200 || statusCode >= 300) return payload;
564
+ if (statusCode < 200 || statusCode >= 300) return;
453
565
  const headersToCache = {};
454
566
  const excludeHeaders = /* @__PURE__ */ new Set([
455
567
  "content-length",
@@ -461,21 +573,28 @@ const idempotencyPlugin = async (fastify, opts = {}) => {
461
573
  ]);
462
574
  const rawHeaders = reply.getHeaders();
463
575
  for (const [key, value] of Object.entries(rawHeaders)) if (!excludeHeaders.has(key.toLowerCase()) && typeof value === "string") headersToCache[key] = value;
464
- let body;
465
- try {
466
- body = typeof payload === "string" ? JSON.parse(payload) : payload;
467
- } catch {
468
- body = payload;
469
- }
470
- const result = createIdempotencyResult(statusCode, body, headersToCache, ttlMs);
471
- await store.set(fullKey, result);
576
+ const body = binary && Buffer.isBuffer(payload) ? payload.toString("base64") : payload;
577
+ const completion = {
578
+ ...createIdempotencyResult(statusCode, body, headersToCache, ttlMs, request._idempotencyFingerprint),
579
+ ...binary ? { bodyEncoding: "base64" } : {}
580
+ };
581
+ if (store.setIfLocked && store.supportsFencedCompletion !== false) {
582
+ if (!await store.setIfLocked(fullKey, request.id, completion)) throw createDomainError(ARC_ERROR_CODES.IDEMPOTENCY_CONFLICT, "This request lost its idempotency lease before completion; its effect may have been applied. Retry the same key to retrieve the current owner's result.", 409);
583
+ fencedCompletions.add(request);
584
+ } else await store.set(fullKey, completion);
585
+ recordedResponses.add(request);
586
+ }
587
+ fastify.addHook("onSend", async (request, reply, payload) => {
588
+ if (!request._idempotencyFullKey || request.idempotencyReplayed || recordedResponses.has(request) || reply.statusCode < 200 || reply.statusCode >= 300) return payload;
589
+ if (payload !== null && payload !== void 0 && typeof payload !== "string" && !Buffer.isBuffer(payload)) throw new Error("[arc idempotency] Streaming responses require config.idempotency.dedup: 'handler' and a domain-owned completion record.");
590
+ await recordResponse(request, reply, payload, Buffer.isBuffer(payload));
472
591
  return payload;
473
592
  });
474
593
  fastify.addHook("onResponse", async (request) => {
475
594
  if (request.idempotencyReplayed) return;
476
595
  const fullKey = request._idempotencyFullKey;
477
596
  if (!fullKey) return;
478
- await store.unlock(fullKey, request.id);
597
+ await releaseIdempotencyLock(store, fullKey, request.id, fencedCompletions.has(request));
479
598
  });
480
599
  fastify.addHook("onClose", async () => {
481
600
  if (ownsStore) await store.close?.();
@@ -491,4 +610,4 @@ var idempotencyPlugin_default = fp(idempotencyPlugin, {
491
610
  fastify: "5.x"
492
611
  });
493
612
  //#endregion
494
- export { IdempotencyStoreMisconfiguredError, MemoryIdempotencyStore, createIdempotencyResult, idempotencyPlugin_default as idempotencyPlugin, idempotencyPlugin as idempotencyPluginFn, repositoryAsIdempotencyStore };
613
+ export { IdempotencyStoreMisconfiguredError, MemoryIdempotencyStore, createIdempotencyResult, idempotencyPlugin_default as idempotencyPlugin, repositoryAsIdempotencyStore };
@@ -1,3 +1,3 @@
1
1
 
2
- import { a as UpstashRedisLike, i as RedisIdempotencyStoreOptions, n as RedisClient, o as ioredisAsIdempotencyClient, r as RedisIdempotencyStore, s as upstashAsIdempotencyClient, t as IoredisLike } from "../redis-Bzi1y3F9.mjs";
2
+ import { a as UpstashRedisLike, i as RedisIdempotencyStoreOptions, n as RedisClient, o as ioredisAsIdempotencyClient, r as RedisIdempotencyStore, s as upstashAsIdempotencyClient, t as IoredisLike } from "../redis-BDgv9Glw.mjs";
3
3
  export { type IoredisLike, type RedisClient, RedisIdempotencyStore, type RedisIdempotencyStoreOptions, type UpstashRedisLike, ioredisAsIdempotencyClient, upstashAsIdempotencyClient };
@@ -1,32 +1,56 @@
1
- /*! @classytic/arc v2.41.1 | MIT | https://classytic.com */
1
+ /*! @classytic/arc v2.44.0 | MIT | https://classytic.com */
2
+ import { t as acquireBySteps } from "../acquire-BozJ-9yt.mjs";
3
+ import { createHash } from "node:crypto";
2
4
  //#region src/idempotency/stores/redis.ts
5
+ /**
6
+ * Redis Idempotency Store
7
+ *
8
+ * Durable idempotency store using Redis.
9
+ * Suitable for multi-instance deployments.
10
+ *
11
+ * @example
12
+ * import { createClient } from 'redis';
13
+ * import { RedisIdempotencyStore } from '@classytic/arc/idempotency';
14
+ *
15
+ * const redis = createClient({ url: process.env.REDIS_URL });
16
+ * await redis.connect();
17
+ *
18
+ * await fastify.register(idempotencyPlugin, {
19
+ * store: new RedisIdempotencyStore({ client: redis }),
20
+ * });
21
+ */
3
22
  var RedisIdempotencyStore = class {
4
23
  name = "redis";
24
+ supportsFencedCompletion;
25
+ /** The fenced completion script deletes the lock; it needs `eval`, as fencing does. */
26
+ completionReleasesLock;
5
27
  client;
6
28
  prefix;
7
29
  lockPrefix;
8
30
  ttlMs;
9
31
  constructor(options) {
10
32
  this.client = options.client;
33
+ this.supportsFencedCompletion = typeof options.client.eval === "function";
34
+ this.completionReleasesLock = this.supportsFencedCompletion;
11
35
  this.prefix = options.prefix ?? "idem:";
12
36
  this.lockPrefix = options.lockPrefix ?? "idem:lock:";
37
+ if (this.prefix.match(/\{([^}]+)\}/)?.[1] !== this.lockPrefix.match(/\{([^}]+)\}/)?.[1]) throw new Error("Redis idempotency prefixes must use the same Redis Cluster hash tag or neither may use one.");
13
38
  this.ttlMs = options.ttlMs ?? 864e5;
14
39
  }
15
40
  resultKey(key) {
16
- return `${this.prefix}${key}`;
41
+ return `${this.prefix}${key}:{${createHash("sha256").update(key).digest("hex")}}`;
17
42
  }
18
43
  lockKey(key) {
19
- return `${this.lockPrefix}${key}`;
44
+ return `${this.lockPrefix}${key}:{${createHash("sha256").update(key).digest("hex")}}`;
20
45
  }
21
46
  async get(key) {
22
- const data = await this.client.get(this.resultKey(key));
47
+ return this.parseResult(await this.client.get(this.resultKey(key)));
48
+ }
49
+ parseResult(data) {
23
50
  if (!data) return void 0;
24
51
  try {
25
52
  const result = JSON.parse(data);
26
- if (new Date(result.expiresAt) < /* @__PURE__ */ new Date()) {
27
- await this.delete(key);
28
- return;
29
- }
53
+ if (new Date(result.expiresAt) < /* @__PURE__ */ new Date()) return;
30
54
  return {
31
55
  ...result,
32
56
  createdAt: new Date(result.createdAt),
@@ -51,6 +75,42 @@ var RedisIdempotencyStore = class {
51
75
  NX: true
52
76
  }) === "OK";
53
77
  }
78
+ async acquire(key, requestId, lockTtlMs) {
79
+ const client = this.client;
80
+ if (typeof client.eval !== "function") return acquireBySteps(this, key, requestId, lockTtlMs);
81
+ const reply = await client.eval(`
82
+ local done = redis.call("get", KEYS[2])
83
+ if done then return {1, done} end
84
+ if redis.call("set", KEYS[1], ARGV[1], "NX", "PX", ARGV[2]) then return {2} end
85
+ return {0}
86
+ `, 2, this.lockKey(key), this.resultKey(key), requestId, Math.max(1, Math.ceil(lockTtlMs)));
87
+ const tag = Number(reply?.[0]);
88
+ if (tag === 2) return { status: "acquired" };
89
+ if (tag === 1) {
90
+ const result = this.parseResult(reply?.[1] ?? null);
91
+ if (result) return {
92
+ status: "completed",
93
+ result
94
+ };
95
+ return await this.tryLock(key, requestId, lockTtlMs) ? { status: "acquired" } : { status: "locked" };
96
+ }
97
+ return { status: "locked" };
98
+ }
99
+ async setIfLocked(key, requestId, result) {
100
+ if (typeof this.client.eval !== "function") throw new Error("Redis idempotency fenced completion requires eval; proxy eval through your Redis client adapter.");
101
+ const ttlMs = new Date(result.expiresAt).getTime() - Date.now();
102
+ if (ttlMs <= 0) return false;
103
+ const saved = await this.client.eval(`
104
+ if redis.call("get", KEYS[1]) ~= ARGV[1] then return 0 end
105
+ redis.call("set", KEYS[2], ARGV[2], "PX", ARGV[3])
106
+ redis.call("del", KEYS[1])
107
+ return 1
108
+ `, 2, this.lockKey(key), this.resultKey(key), requestId, JSON.stringify({
109
+ key,
110
+ ...result
111
+ }), ttlMs);
112
+ return Number(saved) === 1;
113
+ }
54
114
  async unlock(key, requestId) {
55
115
  const luaScript = `
56
116
  if redis.call("get", KEYS[1]) == ARGV[1] then
@@ -70,14 +130,19 @@ var RedisIdempotencyStore = class {
70
130
  await this.client.del([this.resultKey(key), this.lockKey(key)]);
71
131
  }
72
132
  async deleteByPrefix(prefix) {
73
- const resultKeys = await this.scanByPrefix(this.resultKey(prefix));
74
- const lockKeys = await this.scanByPrefix(this.lockKey(prefix));
133
+ const resultKeys = await this.scanByPrefix(`${this.prefix}${prefix}`);
134
+ const lockKeys = await this.scanByPrefix(`${this.lockPrefix}${prefix}`);
75
135
  const allKeys = [...resultKeys, ...lockKeys];
76
136
  if (allKeys.length === 0) return 0;
77
- return this.client.del(allKeys);
137
+ let removed = 0;
138
+ for (let offset = 0; offset < allKeys.length; offset += 100) {
139
+ const counts = await Promise.all(allKeys.slice(offset, offset + 100).map((key) => this.client.del(key)));
140
+ removed += counts.reduce((sum, count) => sum + count, 0);
141
+ }
142
+ return removed;
78
143
  }
79
144
  async findByPrefix(prefix) {
80
- const keys = await this.scanByPrefix(this.resultKey(prefix));
145
+ const keys = await this.scanByPrefix(`${this.prefix}${prefix}`);
81
146
  if (keys.length === 0) return void 0;
82
147
  const BATCH_SIZE = 10;
83
148
  const now = /* @__PURE__ */ new Date();