@classytic/arc 2.41.1 → 2.43.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 (230) hide show
  1. package/dist/{EventTransport-CbUKDzZZ.d.mts → EventTransport-CiicKz2Z.d.mts} +1 -1
  2. package/dist/{EventTransport-DSqD_Xsg.mjs → EventTransport-tzA7CAB4.mjs} +22 -17
  3. package/dist/{HookSystem-CMF94SEA.mjs → HookSystem-B6UaKRbF.mjs} +2 -2
  4. package/dist/{QueryCache-DFA-QrU9.d.mts → QueryCache--5uAuYPW.d.mts} +7 -3
  5. package/dist/{ResourceRegistry-MGpXoxHZ.mjs → ResourceRegistry-CuCARu2B.mjs} +44 -13
  6. package/dist/actionPermissions-BotX1YOa.mjs +776 -0
  7. package/dist/audit/index.d.mts +43 -5
  8. package/dist/audit/index.mjs +48 -26
  9. package/dist/auth/audit.mjs +1 -1
  10. package/dist/auth/index.d.mts +43 -13
  11. package/dist/auth/index.mjs +130 -155
  12. package/dist/auth/redis-session.mjs +1 -1
  13. package/dist/{authorizationDecision-DawKGoXb.mjs → authorizationDecision-DQYrkl5f.mjs} +7 -102
  14. package/dist/{base-CxrV1LmG.d.mts → base-CZyixEQR.d.mts} +27 -5
  15. package/dist/boundedMap-BULzfXYU.mjs +57 -0
  16. package/dist/{buildHandler-CvxKOc0m.mjs → buildHandler-CRQ8EcX5.mjs} +4 -4
  17. package/dist/cache/index.d.mts +22 -34
  18. package/dist/cache/index.mjs +4 -4
  19. package/dist/{caching-DbCBxlgw.mjs → caching-Cc2M1DwO.mjs} +3 -3
  20. package/dist/claimed-B10RTfkM.mjs +8 -0
  21. package/dist/cleanup/index.d.mts +6 -10
  22. package/dist/cleanup/index.mjs +27 -73
  23. package/dist/cli/commands/describe.d.mts +4 -2
  24. package/dist/cli/commands/describe.mjs +7 -4
  25. package/dist/cli/commands/docs.mjs +3 -3
  26. package/dist/cli/commands/doctor.mjs +1 -1
  27. package/dist/cli/commands/generate.mjs +2 -2
  28. package/dist/cli/commands/init.mjs +2 -2
  29. package/dist/cli/commands/introspect.mjs +2 -2
  30. package/dist/cli/index.mjs +3 -3
  31. package/dist/{constants-D5Ubn1fe.mjs → constants-Cu7Mx7_U.mjs} +4 -2
  32. package/dist/context/index.d.mts +2 -5
  33. package/dist/context/index.mjs +18 -38
  34. package/dist/core/index.d.mts +3 -3
  35. package/dist/core/index.mjs +8 -8
  36. package/dist/{core-5lazhva9.mjs → core-Cb0LjccA.mjs} +5 -4
  37. package/dist/{createActionRouter-DNqoaK81.mjs → createActionRouter-xjqonMRO.mjs} +87 -39
  38. package/dist/{createAggregationRouter-BcbQJf10.mjs → createAggregationRouter-zRYWw_5D.mjs} +11 -8
  39. package/dist/{preloadResources-f3boenVZ.mjs → createApp-D-953ZAq.mjs} +299 -390
  40. package/dist/{createMcpServer-CbDWkZ1T.d.mts → createMcpServer-C9cxS8zt.d.mts} +1 -1
  41. package/dist/{defineAggregation-D_eJjm5A.mjs → defineAggregation-Bm8RjMbq.mjs} +1 -1
  42. package/dist/{defineResource-DxkMTjRn.mjs → defineResource-DIC7JZGU.mjs} +118 -1024
  43. package/dist/discovery/index.mjs +1 -1
  44. package/dist/docs/index.d.mts +10 -4
  45. package/dist/docs/index.mjs +4 -4
  46. package/dist/{elevation-BXCfVcNG.d.mts → elevation-BW2J3tNj.d.mts} +1 -2
  47. package/dist/{elevation-CkvG0LG2.mjs → elevation-DG8SrLN-.mjs} +41 -12
  48. package/dist/encryption/index.mjs +3 -3
  49. package/dist/{errorHandler-D1GHKGlV.mjs → errorHandler-BnfDviL1.mjs} +26 -5
  50. package/dist/{errors-BH7YU_9O.mjs → errors-CtrywMHP.mjs} +58 -23
  51. package/dist/{errors-BGdVwP25.d.mts → errors-JYc9vwqE.d.mts} +9 -0
  52. package/dist/{eventPlugin-B52abJ8W.d.mts → eventPlugin-BtmA4-3P.d.mts} +65 -9
  53. package/dist/{eventPlugin-zZY-unhr.mjs → eventPlugin-Q6BhzHzs.mjs} +82 -21
  54. package/dist/{subscribe-helpers-DatDkcuG.mjs → eventTypes-Cdhm6ZlV.mjs} +151 -17
  55. package/dist/events/index.d.mts +33 -8
  56. package/dist/events/index.mjs +49 -53
  57. package/dist/events/transports/redis-stream-entry.d.mts +1 -1
  58. package/dist/events/transports/redis-stream-entry.mjs +139 -157
  59. package/dist/events/transports/redis.d.mts +1 -1
  60. package/dist/events/transports/redis.mjs +2 -2
  61. package/dist/factory/index.d.mts +32 -17
  62. package/dist/factory/index.mjs +213 -32
  63. package/dist/{fastify-augmentation-Cq2nHVaW.d.mts → fastify-augmentation-DhUJ-mvo.d.mts} +11 -25
  64. package/dist/{fields-I_EeRWND.d.mts → fields-BBSpf8pS.d.mts} +1 -1
  65. package/dist/{fields-BHrkoF5y.mjs → fields-ToPYpMqS.mjs} +1 -1
  66. package/dist/filter-merge-IpGywvym.mjs +99 -0
  67. package/dist/{generate-BLRf2m3y.mjs → generate-DRTkLlIl.mjs} +2 -2
  68. package/dist/{headers-BIXWKrzQ.mjs → headers-BbkIF6vA.mjs} +2 -2
  69. package/dist/health-D-Ft6JM6.mjs +22 -0
  70. package/dist/{health-ZRHeDHXH.mjs → health-DqbsG-_w.mjs} +115 -55
  71. package/dist/hooks/index.d.mts +2 -2
  72. package/dist/hooks/index.mjs +2 -2
  73. package/dist/idempotency/index.d.mts +31 -7
  74. package/dist/idempotency/index.mjs +155 -98
  75. package/dist/idempotency/redis.d.mts +1 -1
  76. package/dist/idempotency/redis.mjs +48 -11
  77. package/dist/{index-BHdZD8zK.d.mts → index-Bxriq3so.d.mts} +84 -21
  78. package/dist/{preloadResources-CjS2OfpU.d.mts → index-C8zVGYsk.d.mts} +37 -38
  79. package/dist/{index-D4u76eQW.d.mts → index-CRoPtISf.d.mts} +23 -73
  80. package/dist/{index-CbJiYrfv.d.mts → index-Chgz3d0i.d.mts} +1 -1
  81. package/dist/index-JdbZAIsm.d.mts +510 -0
  82. package/dist/{index-DHbyPo5j.d.mts → index-mqaqZNpe.d.mts} +253 -254
  83. package/dist/index.d.mts +8 -8
  84. package/dist/index.mjs +11 -11
  85. package/dist/{init-CpLkjU18.mjs → init-B_oUYDXI.mjs} +1 -1
  86. package/dist/integrations/event-gateway.d.mts +8 -3
  87. package/dist/integrations/event-gateway.mjs +4 -4
  88. package/dist/integrations/index.d.mts +3 -3
  89. package/dist/integrations/index.mjs +1 -1
  90. package/dist/integrations/jobs/index.d.mts +1 -1
  91. package/dist/integrations/jobs/index.mjs +18 -5
  92. package/dist/integrations/mcp/index.d.mts +72 -59
  93. package/dist/integrations/mcp/index.mjs +19 -4
  94. package/dist/integrations/mcp/testing.d.mts +2 -2
  95. package/dist/integrations/mcp/testing.mjs +2 -2
  96. package/dist/integrations/streamline.d.mts +78 -136
  97. package/dist/integrations/streamline.mjs +116 -54
  98. package/dist/integrations/webhooks.d.mts +60 -120
  99. package/dist/integrations/webhooks.mjs +36 -80
  100. package/dist/integrations/websocket-pushref-redis.d.mts +1 -1
  101. package/dist/integrations/websocket-pushref-redis.mjs +1 -1
  102. package/dist/integrations/websocket-redis.d.mts +1 -1
  103. package/dist/integrations/websocket-redis.mjs +1 -1
  104. package/dist/integrations/websocket.d.mts +3 -3
  105. package/dist/integrations/websocket.mjs +2 -2
  106. package/dist/{interface-DEWNOMza.d.mts → interface-C0395yUN.d.mts} +13 -1
  107. package/dist/{jsonBody-ELR9lfeU.mjs → jsonBody-B4YVGcql.mjs} +2 -2
  108. package/dist/{keys-cpZCtcIE.mjs → keys-B2KtgMLJ.mjs} +7 -18
  109. package/dist/{loadResources-BPfWiI5s.mjs → loadResources-Dn4lalw2.mjs} +3 -3
  110. package/dist/{loadResourcesFromEntry-ixtfL3J2.mjs → loadResourcesFromEntry-3sLYbuse.mjs} +2 -2
  111. package/dist/logger/index.d.mts +1 -8
  112. package/dist/logger/index.mjs +3 -153
  113. package/dist/logger-DXjfIqjV.mjs +160 -0
  114. package/dist/{mcpPlugin-BX5PujI0.mjs → mcpPlugin-C08HalN5.mjs} +433 -170
  115. package/dist/{memory-Iotz0cCx.mjs → memory-9Cp7611C.mjs} +36 -34
  116. package/dist/{metrics-NMsmpRmY.mjs → metrics-CWI11eRu.mjs} +39 -72
  117. package/dist/middleware/index.d.mts +2 -2
  118. package/dist/middleware/index.mjs +3 -3
  119. package/dist/migrations/index.mjs +2 -2
  120. package/dist/{module-DpLJG2_N.mjs → module-BSvOQC7E.mjs} +8 -8
  121. package/dist/multiTenant-cY0FkDiA.d.mts +104 -0
  122. package/dist/{multipartBody-DtRWM6_U.mjs → multipartBody-CXM2a-Dy.mjs} +46 -7
  123. package/dist/{openapi-CmFf3yq_.mjs → openapi-DT_G4VhY.mjs} +48 -10
  124. package/dist/outbox-admin/index.d.mts +3 -3
  125. package/dist/outbox-admin/index.mjs +6 -18
  126. package/dist/{outbox-module-D2z5WkDY.mjs → outbox-module-Bjgzl6tg.mjs} +203 -33
  127. package/dist/{outbox-module-BHdUnuuK.d.mts → outbox-module-gZCHoE4H.d.mts} +79 -6
  128. package/dist/permissions/index.d.mts +5 -4
  129. package/dist/permissions/index.mjs +8 -6
  130. package/dist/{permissions-D8TBH44E.mjs → permissions-Cm0uYHvd.mjs} +149 -33
  131. package/dist/{pipe-FMWNheSO.mjs → pipe-BqeGT6UO.mjs} +2 -2
  132. package/dist/pipeline/index.d.mts +2 -2
  133. package/dist/pipeline/index.mjs +2 -2
  134. package/dist/plan-FVR6LP59.mjs +565 -0
  135. package/dist/{BaseController-CYEwjyjN.mjs → plugin-BccET1tL.mjs} +1256 -345
  136. package/dist/plugins/index.d.mts +14 -11
  137. package/dist/plugins/index.mjs +44 -22
  138. package/dist/plugins/response-cache.mjs +8 -14
  139. package/dist/plugins/tracing-entry.mjs +2 -2
  140. package/dist/{pluralize-CXWZIzTv.mjs → pluralize-CVIOcgMF.mjs} +1 -1
  141. package/dist/presets/filesUpload.d.mts +2 -2
  142. package/dist/presets/filesUpload.mjs +5 -5
  143. package/dist/presets/index.d.mts +26 -31
  144. package/dist/presets/index.mjs +4 -4
  145. package/dist/presets/multiTenant.d.mts +2 -107
  146. package/dist/presets/multiTenant.mjs +22 -162
  147. package/dist/presets/search.d.mts +2 -2
  148. package/dist/presets/search.mjs +2 -2
  149. package/dist/{presets-ZJSPgmT2.mjs → presets-Cpvj6fGO.mjs} +99 -107
  150. package/dist/{projection-BzIgzoKZ.mjs → projection-Ce4aQhQk.mjs} +2 -2
  151. package/dist/{pushref-registry-Dov9l4qE.d.mts → pushref-registry-UxCkoA4C.d.mts} +1 -1
  152. package/dist/{queryCachePlugin-BzqxconG.d.mts → queryCachePlugin-C2lZjTxk.d.mts} +1 -1
  153. package/dist/{queryCachePlugin-DZHPOm9p.mjs → queryCachePlugin-CFWNH8aT.mjs} +12 -14
  154. package/dist/{rateLimitKey-BvOvcBDi.mjs → rateLimitKey-C4O-kPm0.mjs} +1 -1
  155. package/dist/{realtime-8tIQWZz4.mjs → realtime-DmPE9S54.mjs} +25 -21
  156. package/dist/redis-stream-BMk-bpQS.d.mts +219 -0
  157. package/dist/{redis-Bzi1y3F9.d.mts → redis-vLvtcGy_.d.mts} +13 -24
  158. package/dist/redisShapes-CQksY67q.d.mts +55 -0
  159. package/dist/registry/index.d.mts +9 -4
  160. package/dist/registry/index.mjs +4 -4
  161. package/dist/{registry-DNkgGQcK.mjs → registry-Bcz9G1bh.mjs} +10 -8
  162. package/dist/{renewingLease-yF8BdW4b.mjs → renewingLease-qODa7KJQ.mjs} +1 -1
  163. package/dist/{replyHelpers-CNdvMfM4.mjs → replyHelpers-CG_VtVgS.mjs} +2 -2
  164. package/dist/{requestContext-cDkAUr-P.mjs → requestContext-Dsu9P1bV.mjs} +1 -1
  165. package/dist/requirement-CbiLdQ1n.d.mts +22 -0
  166. package/dist/requirement-KDdoRcOP.mjs +35 -0
  167. package/dist/resourceRateLimit-Wf6d6FL9.d.mts +30 -0
  168. package/dist/resourceRateLimit-dSjVr1pk.mjs +180 -0
  169. package/dist/{rolldown-runtime-eysyzFAF.mjs → rolldown-runtime-D1w9Y1VJ.mjs} +1 -1
  170. package/dist/routeValidator-D36SW8qt.mjs +70 -0
  171. package/dist/{runtimeCapabilities-CkhS6jn6.mjs → runtimeCapabilities-0tccjRlj.mjs} +1 -1
  172. package/dist/{schedules-j6uXXAeJ.mjs → schedules-C-OpmHBq.mjs} +46 -37
  173. package/dist/{schemaConverter-BVA7cFRG.mjs → schemaConverter-BORek3O-.mjs} +2 -2
  174. package/dist/{schemaIR-CTvPDoPB.mjs → schemaIR-C7Vbp71I.mjs} +2 -2
  175. package/dist/schemas/index.d.mts +10 -0
  176. package/dist/schemas/index.mjs +8 -3
  177. package/dist/scim/index.mjs +2 -2
  178. package/dist/scope/index.d.mts +2 -2
  179. package/dist/scope/index.mjs +6 -6
  180. package/dist/{scope-BUi1xV1I.mjs → scope-DQthe88b.mjs} +4 -4
  181. package/dist/shutdown-MqA81z_P.mjs +59 -0
  182. package/dist/{simpleEqualityMatcher-C9bzq2gO.mjs → simpleEqualityMatcher-auW8QjXN.mjs} +1 -1
  183. package/dist/{sse-BeqVKQKu.mjs → sse-CYzQBVFe.mjs} +11 -15
  184. package/dist/{sseStream-Ppzo-WUH.mjs → sseStream-B5_Afiqn.mjs} +2 -2
  185. package/dist/{store-helpers-dW71XVWL.mjs → store-helpers-BOzk-CuX.mjs} +1 -1
  186. package/dist/{streaming-IcRFjWQU.mjs → streaming-DjTQhZrN.mjs} +1 -1
  187. package/dist/subscriberIdentity-CW39uA8G.mjs +28 -0
  188. package/dist/tenancy--ed9eH_z.mjs +43 -0
  189. package/dist/tenantPreparation-BKon6Yox.mjs +67 -0
  190. package/dist/testing/cleanupStoreContract.mjs +1 -1
  191. package/dist/testing/index.d.mts +193 -175
  192. package/dist/testing/index.mjs +286 -212
  193. package/dist/testing/outboxStoreContract.mjs +137 -2
  194. package/dist/testing/requiredPorts.d.mts +17 -0
  195. package/dist/testing/requiredPorts.mjs +65 -0
  196. package/dist/testing/storageContract.mjs +1 -1
  197. package/dist/timing-qczZ0YC-.mjs +46 -0
  198. package/dist/{transactionContext-CPbdlnVw.mjs → transactionContext-DjFkrNG6.mjs} +1 -1
  199. package/dist/{typeGuards-BLjPgVBm.mjs → typeGuards-CySF12G-.mjs} +1 -1
  200. package/dist/types/index.d.mts +4 -4
  201. package/dist/types/index.mjs +1 -1
  202. package/dist/types/storage.mjs +1 -1
  203. package/dist/{types-CWQn9-IC.d.mts → types-B4Aq4Sdj.d.mts} +22 -9
  204. package/dist/{types-DfVDvkdP.d.mts → types-BJ-2XqVF.d.mts} +189 -204
  205. package/dist/{types-CgenB1gv.mjs → types-Bk9NOgOC.mjs} +4 -4
  206. package/dist/usage/index.d.mts +2 -3
  207. package/dist/usage/index.mjs +5 -5
  208. package/dist/{userHelpers-ChRvkuSZ.mjs → userHelpers-aklawRaK.mjs} +1 -1
  209. package/dist/utils/index.d.mts +84 -223
  210. package/dist/utils/index.mjs +13 -12
  211. package/dist/{utils-CxcctrAh.mjs → utils-ec1oTo1n.mjs} +315 -404
  212. package/dist/{validate-DrYWnOzI.mjs → validate-9yzHC1e2.mjs} +6 -14
  213. package/dist/{versioning-D6JIK8Tk.d.mts → versioning-Bsb51gGT.d.mts} +28 -13
  214. package/dist/{versioning-DKkT4hNJ.mjs → versioning-fhhWHV74.mjs} +4 -4
  215. package/dist/{websocket-BOXVggMf.d.mts → websocket-Cs4awVyD.d.mts} +1 -1
  216. package/dist/{workScope-CxKqoIFn.mjs → workScope-zpPyge4t.mjs} +2 -2
  217. package/package.json +16 -12
  218. package/skills/arc/SKILL.md +13 -4
  219. package/skills/arc/references/auth.md +8 -5
  220. package/skills/arc/references/integrations.md +1 -1
  221. package/skills/arc/references/mcp.md +8 -6
  222. package/skills/arc/references/multi-tenancy.md +19 -14
  223. package/skills/arc/references/production.md +27 -10
  224. package/dist/actionPermissions-BoPGQ5-4.mjs +0 -350
  225. package/dist/index-CFfLKMH4.d.mts +0 -290
  226. package/dist/interface-YzIbF7Ii.mjs +0 -17
  227. package/dist/redis-stream-BPKscsTB.d.mts +0 -260
  228. package/dist/registerRoute-Bsnk6NiZ.mjs +0 -364
  229. package/dist/sync/changelog.d.mts +0 -3
  230. package/dist/sync/changelog.mjs +0 -3
@@ -1,10 +1,14 @@
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.43.0 | MIT | https://classytic.com */
2
+ import { d as createDomainError } from "../errors-CtrywMHP.mjs";
3
+ import { n as requireSingleHeaderValue } from "../headers-BbkIF6vA.mjs";
4
+ import { i as requestRequiresIdempotencyKey, n as idempotencyConfigProblem, r as requestDedupedByHandler, t as declaresIdempotencyFence } from "../requirement-KDdoRcOP.mjs";
5
+ import { n as createSafeGetOne, t as createIsDuplicateKeyError } from "../store-helpers-BOzk-CuX.mjs";
6
+ import { n as createIdempotencyResult, r as operationIdentity, t as resolveIdempotencyTiming } from "../timing-qczZ0YC-.mjs";
7
+ import { ARC_ERROR_CODES } from "@classytic/repo-core/errors";
5
8
  import { and, eq, exists, gt, lt, or, startsWith } from "@classytic/repo-core/filter";
6
9
  import { createHash } from "node:crypto";
7
10
  import fp from "fastify-plugin";
11
+ import { IdempotencyError, fingerprintRequest } from "@classytic/repo-core/idempotency";
8
12
  import { update } from "@classytic/repo-core/update";
9
13
  //#region src/idempotency/repository-idempotency-adapter.ts
10
14
  /**
@@ -64,13 +68,15 @@ function repositoryAsIdempotencyStore(repository, defaultTtlMs) {
64
68
  const doc = await safeGetOne(eq(idField, key));
65
69
  if (!doc) return void 0;
66
70
  assertKeyIdentity("get", doc, key);
67
- if (!doc.result) return void 0;
71
+ if (!isCompleted(doc)) return void 0;
68
72
  if (new Date(doc.expiresAt) < /* @__PURE__ */ new Date()) return void 0;
69
73
  return {
70
74
  key,
75
+ ...doc.result.requestFingerprint ? { requestFingerprint: doc.result.requestFingerprint } : {},
71
76
  statusCode: doc.result.statusCode,
72
77
  headers: doc.result.headers,
73
78
  body: doc.result.body,
79
+ ...doc.result.bodyEncoding ? { bodyEncoding: doc.result.bodyEncoding } : {},
74
80
  createdAt: new Date(doc.createdAt),
75
81
  expiresAt: new Date(doc.expiresAt)
76
82
  };
@@ -81,7 +87,9 @@ function repositoryAsIdempotencyStore(repository, defaultTtlMs) {
81
87
  result: {
82
88
  statusCode: result.statusCode,
83
89
  headers: result.headers,
84
- body: result.body
90
+ body: result.body,
91
+ ...result.bodyEncoding ? { bodyEncoding: result.bodyEncoding } : {},
92
+ ...result.requestFingerprint ? { requestFingerprint: result.requestFingerprint } : {}
85
93
  },
86
94
  createdAt: result.createdAt,
87
95
  expiresAt: result.expiresAt
@@ -94,6 +102,22 @@ function repositoryAsIdempotencyStore(repository, defaultTtlMs) {
94
102
  });
95
103
  if (doc) assertKeyIdentity("set", doc, key);
96
104
  },
105
+ async setIfLocked(key, requestId, result) {
106
+ const doc = await r.findOneAndUpdate(and(eq(idField, key), eq("lock.requestId", requestId), gt("lock.expiresAt", /* @__PURE__ */ new Date())), update({ set: {
107
+ result: {
108
+ statusCode: result.statusCode,
109
+ headers: result.headers,
110
+ body: result.body,
111
+ ...result.requestFingerprint ? { requestFingerprint: result.requestFingerprint } : {},
112
+ ...result.bodyEncoding ? { bodyEncoding: result.bodyEncoding } : {}
113
+ },
114
+ createdAt: result.createdAt,
115
+ expiresAt: result.expiresAt
116
+ } }), { returnDocument: "after" });
117
+ if (!doc) return false;
118
+ assertKeyIdentity("setIfLocked", doc, key);
119
+ return true;
120
+ },
97
121
  async tryLock(key, requestId, ttlMs) {
98
122
  const now = /* @__PURE__ */ new Date();
99
123
  const lockExpiresAt = new Date(now.getTime() + ttlMs);
@@ -135,11 +159,12 @@ function repositoryAsIdempotencyStore(repository, defaultTtlMs) {
135
159
  return (await r.deleteMany(startsWith(idField, prefix, "sensitive"))).deletedCount ?? 0;
136
160
  },
137
161
  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;
162
+ const doc = await safeGetOne(and(startsWith(idField, prefix, "sensitive"), exists("result.statusCode", true), gt("expiresAt", /* @__PURE__ */ new Date())));
163
+ if (!doc || !isCompleted(doc)) return void 0;
140
164
  if (!String(doc[idField] ?? "").startsWith(prefix)) throw new IdempotencyStoreMisconfiguredError("findByPrefix", prefix, doc[idField]);
141
165
  return {
142
166
  key: String(doc[idField] ?? prefix),
167
+ ...doc.result.requestFingerprint ? { requestFingerprint: doc.result.requestFingerprint } : {},
143
168
  statusCode: doc.result.statusCode,
144
169
  headers: doc.result.headers,
145
170
  body: doc.result.body,
@@ -150,6 +175,13 @@ function repositoryAsIdempotencyStore(repository, defaultTtlMs) {
150
175
  async close() {}
151
176
  };
152
177
  }
178
+ /**
179
+ * A row holds a completed response only when it has a status code. A lock-only row can read back
180
+ * with `result: {}` (a nested path in a mongoose schema); replaying that would answer nothing.
181
+ */
182
+ function isCompleted(doc) {
183
+ return typeof doc.result?.statusCode === "number";
184
+ }
153
185
  //#endregion
154
186
  //#region src/idempotency/stores/memory.ts
155
187
  var MemoryIdempotencyStore = class {
@@ -178,12 +210,21 @@ var MemoryIdempotencyStore = class {
178
210
  return result;
179
211
  }
180
212
  async set(key, result) {
213
+ this.writeResult(key, result);
214
+ }
215
+ writeResult(key, result) {
181
216
  if (this.results.size >= this.maxEntries) this.evictOldest();
182
217
  this.results.set(key, {
183
218
  ...result,
184
219
  key
185
220
  });
186
221
  }
222
+ async setIfLocked(key, requestId, result) {
223
+ const lock = this.locks.get(key);
224
+ if (!lock || lock.requestId !== requestId || lock.expiresAt.getTime() <= Date.now()) return false;
225
+ this.writeResult(key, result);
226
+ return true;
227
+ }
187
228
  async tryLock(key, requestId, ttlMs) {
188
229
  const existing = this.locks.get(key);
189
230
  if (existing) {
@@ -266,42 +307,23 @@ var MemoryIdempotencyStore = class {
266
307
  //#endregion
267
308
  //#region src/idempotency/idempotencyPlugin.ts
268
309
  /**
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]`.
310
+ * Completion replay for mutations. Authentication, permission and tenant preparation must
311
+ * precede the middleware: principal/scope identity isolates keys; fingerprints reject reuse
312
+ * with changed input. Built-in stores fence completion against an expired/replaced lock.
276
313
  *
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.
286
- *
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).
314
+ * A crash between the business write and completion, or a handler outliving its lease, can
315
+ * repeat effects. Use domain idempotency (`dedup: 'handler'`) with a shared transaction or
316
+ * intrinsic uniqueness where replay protection alone is insufficient. See wiki/plugins.md.
290
317
  */
291
318
  const HEADER_IDEMPOTENCY_REPLAYED = "x-idempotency-replayed";
292
319
  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
320
  const idempotencyPlugin = async (fastify, opts = {}) => {
300
- const { enabled = false, headerName = "idempotency-key", ttlMs = 864e5, lockTimeoutMs = 3e4, methods = [
321
+ const { enabled = false, headerName = "idempotency-key", methods = [
301
322
  "POST",
302
323
  "PUT",
303
324
  "PATCH"
304
325
  ], include, exclude, repository, store: explicitStore, retryAfterSeconds = 1, namespace, selfCheck = true } = opts;
326
+ const { ttlMs, lockTimeoutMs } = resolveIdempotencyTiming(opts.ttlMs, opts.lockTimeoutMs);
305
327
  const store = repository ? repositoryAsIdempotencyStore(repository, ttlMs) : explicitStore ?? new MemoryIdempotencyStore({ ttlMs });
306
328
  /**
307
329
  * Who owns the store's lifecycle — arc closes ONLY what arc built.
@@ -319,6 +341,10 @@ const idempotencyPlugin = async (fastify, opts = {}) => {
319
341
  * (host-owned) is never touched.
320
342
  */
321
343
  const ownsStore = !explicitStore;
344
+ if (enabled && (typeof store.setIfLocked !== "function" || store.supportsFencedCompletion === false)) {
345
+ if (!opts.allowUnfencedCompletion) throw new Error("[arc idempotency] Store must implement atomic setIfLocked completion fencing. Upgrade the store or explicitly allowUnfencedCompletion during migration.");
346
+ fastify.log.warn("Idempotency completion is unfenced: an expired request can overwrite another owner's response. Upgrade the custom store's setIfLocked implementation.");
347
+ }
322
348
  if (enabled && selfCheck) {
323
349
  const probeSuffix = createHash("sha256").update(`${process.pid}:${Date.now()}:${Math.random()}`).digest("hex").slice(0, 12);
324
350
  const probeKeyA = `__arc_idempotency_selfcheck__:${probeSuffix}:a`;
@@ -337,6 +363,10 @@ const idempotencyPlugin = async (fastify, opts = {}) => {
337
363
  }
338
364
  }
339
365
  if (!enabled) {
366
+ fastify.addHook("onRoute", (route) => {
367
+ if (!declaresIdempotencyFence(route.config)) return;
368
+ 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.`);
369
+ });
340
370
  fastify.decorate("idempotency", {
341
371
  invalidate: async () => {},
342
372
  has: async () => false,
@@ -346,6 +376,7 @@ const idempotencyPlugin = async (fastify, opts = {}) => {
346
376
  fastify.decorateRequest("idempotencyKey", void 0);
347
377
  fastify.decorateRequest("idempotencyReplayed", false);
348
378
  fastify.decorateRequest("_idempotencyFullKey", void 0);
379
+ fastify.decorateRequest("_idempotencyFingerprint", void 0);
349
380
  fastify.log?.debug?.("Idempotency plugin disabled");
350
381
  return;
351
382
  }
@@ -353,10 +384,12 @@ const idempotencyPlugin = async (fastify, opts = {}) => {
353
384
  fastify.decorateRequest("idempotencyKey", void 0);
354
385
  fastify.decorateRequest("idempotencyReplayed", false);
355
386
  fastify.decorateRequest("_idempotencyFullKey", void 0);
387
+ fastify.decorateRequest("_idempotencyFingerprint", void 0);
356
388
  /**
357
389
  * Check if this request should use idempotency
358
390
  */
359
391
  function shouldApplyIdempotency(request) {
392
+ if (requestRequiresIdempotencyKey(request)) return true;
360
393
  if (!methodSet.has(request.method)) return false;
361
394
  const url = request.url;
362
395
  if (exclude?.some((pattern) => pattern.test(url))) return false;
@@ -364,76 +397,90 @@ const idempotencyPlugin = async (fastify, opts = {}) => {
364
397
  return true;
365
398
  }
366
399
  /**
367
- * Normalize body for consistent hashing (sort keys recursively).
400
+ * The OPERATION this key names — namespace, method, URL and caller. The body is NOT part of
401
+ * it: the same key with a different body is the same operation asked twice, which is refused
402
+ * (see `bodyFingerprint`), never executed again under a fresh storage key.
368
403
  *
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>"`.
404
+ * Must run AFTER auth has populated `request.user`, or every caller shares `anon`.
377
405
  */
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;
406
+ function operationIdentity$1(request) {
407
+ const user = request.user;
408
+ return operationIdentity(`${request.method}:${request.url}`, request.scope, user?.id ?? user?._id, namespace);
390
409
  }
391
410
  /**
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.
411
+ * `fingerprintRequest` from `@classytic/repo-core/idempotency` — the ONE body fingerprint
412
+ * every layer uses. A body too deep to fingerprint is refused, not truncated.
398
413
  */
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");
414
+ function bodyFingerprint(request) {
415
+ try {
416
+ return fingerprintRequest(request.body ?? null);
417
+ } catch (err) {
418
+ if (err instanceof IdempotencyError && err.code === "UNFINGERPRINTABLE") throw createDomainError(ARC_ERROR_CODES.IDEMPOTENCY_BODY_UNFINGERPRINTABLE, err.message, 400);
419
+ throw err;
406
420
  }
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}`;
421
+ }
422
+ function replay(request, reply, cached, fingerprint) {
423
+ 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);
424
+ request.idempotencyReplayed = true;
425
+ reply.header(HEADER_IDEMPOTENCY_REPLAYED, "true");
426
+ reply.header(HEADER_IDEMPOTENCY_KEY, request.idempotencyKey);
427
+ for (const [key, value] of Object.entries(cached.headers)) {
428
+ const name = key.toLowerCase();
429
+ const liveBudget = name.startsWith("x-ratelimit-") || name.startsWith("ratelimit-") || name === "retry-after";
430
+ if (!name.startsWith("x-idempotency") && !(liveBudget && reply.hasHeader(name))) reply.header(key, value);
431
+ }
432
+ reply.code(cached.statusCode).send(cached.bodyEncoding === "base64" && typeof cached.body === "string" ? Buffer.from(cached.body, "base64") : cached.body);
410
433
  }
411
434
  const idempotencyMiddleware = async (request, reply) => {
412
- if (!shouldApplyIdempotency(request)) return;
413
435
  const idempotencyKey = requireSingleHeaderValue(request.headers, headerName)?.trim();
436
+ 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);
437
+ if (requestDedupedByHandler(request)) {
438
+ if (idempotencyKey) request.idempotencyKey = idempotencyKey;
439
+ return;
440
+ }
441
+ if (!shouldApplyIdempotency(request)) return;
414
442
  if (!idempotencyKey) return;
415
443
  request.idempotencyKey = idempotencyKey;
416
- const fullKey = `${idempotencyKey}:${getRequestFingerprint(request)}`;
444
+ const fullKey = `${idempotencyKey}:${operationIdentity$1(request)}`;
445
+ const requestFingerprint = bodyFingerprint(request);
417
446
  const cached = await store.get(fullKey);
418
447
  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;
448
+ replay(request, reply, cached, requestFingerprint);
449
+ return reply;
425
450
  }
426
451
  if (!await store.tryLock(fullKey, request.id, lockTimeoutMs)) {
427
452
  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
453
+ code: ARC_ERROR_CODES.IDEMPOTENCY_CONFLICT,
454
+ message: "Request with this idempotency key is already in progress",
455
+ status: 409,
456
+ meta: { retryAfter: retryAfterSeconds }
431
457
  });
432
- return;
458
+ return reply;
433
459
  }
434
460
  request._idempotencyFullKey = fullKey;
461
+ request._idempotencyFingerprint = requestFingerprint;
462
+ try {
463
+ const completed = await store.get(fullKey);
464
+ if (completed) {
465
+ await store.unlock(fullKey, request.id);
466
+ request._idempotencyFullKey = void 0;
467
+ replay(request, reply, completed, requestFingerprint);
468
+ return reply;
469
+ }
470
+ } catch (err) {
471
+ await store.unlock(fullKey, request.id);
472
+ request._idempotencyFullKey = void 0;
473
+ throw err;
474
+ }
435
475
  reply.header(HEADER_IDEMPOTENCY_KEY, idempotencyKey);
436
476
  };
477
+ fastify.addHook("onRoute", (route) => {
478
+ const problem = idempotencyConfigProblem(route.config);
479
+ if (problem) throw new Error(`[arc] ${String(route.method)} ${route.url}: config.idempotency: ${problem}.`);
480
+ if (!declaresIdempotencyFence(route.config)) return;
481
+ if ((Array.isArray(route.preHandler) ? route.preHandler : [route.preHandler]).includes(idempotencyMiddleware)) return;
482
+ 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].`);
483
+ });
437
484
  fastify.decorate("idempotency", {
438
485
  invalidate: async (key) => {
439
486
  await store.deleteByPrefix(`${key}:`);
@@ -442,14 +489,18 @@ const idempotencyPlugin = async (fastify, opts = {}) => {
442
489
  return !!await store.findByPrefix(`${key}:`);
443
490
  },
444
491
  middleware: idempotencyMiddleware,
445
- store
492
+ store,
493
+ allowUnfencedCompletion: opts.allowUnfencedCompletion === true,
494
+ ttlMs,
495
+ lockTimeoutMs
446
496
  });
447
- fastify.addHook("preSerialization", async (request, reply, payload) => {
448
- if (request.idempotencyReplayed) return payload;
497
+ const recordedResponses = /* @__PURE__ */ new WeakSet();
498
+ async function recordResponse(request, reply, payload, binary = false) {
499
+ if (request.idempotencyReplayed || recordedResponses.has(request)) return;
449
500
  const fullKey = request._idempotencyFullKey;
450
- if (!fullKey) return payload;
501
+ if (!fullKey) return;
451
502
  const statusCode = reply.statusCode;
452
- if (statusCode < 200 || statusCode >= 300) return payload;
503
+ if (statusCode < 200 || statusCode >= 300) return;
453
504
  const headersToCache = {};
454
505
  const excludeHeaders = /* @__PURE__ */ new Set([
455
506
  "content-length",
@@ -461,14 +512,20 @@ const idempotencyPlugin = async (fastify, opts = {}) => {
461
512
  ]);
462
513
  const rawHeaders = reply.getHeaders();
463
514
  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);
515
+ const body = binary && Buffer.isBuffer(payload) ? payload.toString("base64") : payload;
516
+ const completion = {
517
+ ...createIdempotencyResult(statusCode, body, headersToCache, ttlMs, request._idempotencyFingerprint),
518
+ ...binary ? { bodyEncoding: "base64" } : {}
519
+ };
520
+ if (store.setIfLocked && store.supportsFencedCompletion !== false) {
521
+ 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);
522
+ } else await store.set(fullKey, completion);
523
+ recordedResponses.add(request);
524
+ }
525
+ fastify.addHook("onSend", async (request, reply, payload) => {
526
+ if (!request._idempotencyFullKey || request.idempotencyReplayed || recordedResponses.has(request) || reply.statusCode < 200 || reply.statusCode >= 300) return payload;
527
+ 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.");
528
+ await recordResponse(request, reply, payload, Buffer.isBuffer(payload));
472
529
  return payload;
473
530
  });
474
531
  fastify.addHook("onResponse", async (request) => {
@@ -491,4 +548,4 @@ var idempotencyPlugin_default = fp(idempotencyPlugin, {
491
548
  fastify: "5.x"
492
549
  });
493
550
  //#endregion
494
- export { IdempotencyStoreMisconfiguredError, MemoryIdempotencyStore, createIdempotencyResult, idempotencyPlugin_default as idempotencyPlugin, idempotencyPlugin as idempotencyPluginFn, repositoryAsIdempotencyStore };
551
+ 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-vLvtcGy_.mjs";
3
3
  export { type IoredisLike, type RedisClient, RedisIdempotencyStore, type RedisIdempotencyStoreOptions, type UpstashRedisLike, ioredisAsIdempotencyClient, upstashAsIdempotencyClient };
@@ -1,32 +1,50 @@
1
- /*! @classytic/arc v2.41.1 | MIT | https://classytic.com */
1
+ /*! @classytic/arc v2.43.0 | MIT | https://classytic.com */
2
+ import { createHash } from "node:crypto";
2
3
  //#region src/idempotency/stores/redis.ts
4
+ /**
5
+ * Redis Idempotency Store
6
+ *
7
+ * Durable idempotency store using Redis.
8
+ * Suitable for multi-instance deployments.
9
+ *
10
+ * @example
11
+ * import { createClient } from 'redis';
12
+ * import { RedisIdempotencyStore } from '@classytic/arc/idempotency';
13
+ *
14
+ * const redis = createClient({ url: process.env.REDIS_URL });
15
+ * await redis.connect();
16
+ *
17
+ * await fastify.register(idempotencyPlugin, {
18
+ * store: new RedisIdempotencyStore({ client: redis }),
19
+ * });
20
+ */
3
21
  var RedisIdempotencyStore = class {
4
22
  name = "redis";
23
+ supportsFencedCompletion;
5
24
  client;
6
25
  prefix;
7
26
  lockPrefix;
8
27
  ttlMs;
9
28
  constructor(options) {
10
29
  this.client = options.client;
30
+ this.supportsFencedCompletion = typeof options.client.eval === "function";
11
31
  this.prefix = options.prefix ?? "idem:";
12
32
  this.lockPrefix = options.lockPrefix ?? "idem:lock:";
33
+ 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
34
  this.ttlMs = options.ttlMs ?? 864e5;
14
35
  }
15
36
  resultKey(key) {
16
- return `${this.prefix}${key}`;
37
+ return `${this.prefix}${key}:{${createHash("sha256").update(key).digest("hex")}}`;
17
38
  }
18
39
  lockKey(key) {
19
- return `${this.lockPrefix}${key}`;
40
+ return `${this.lockPrefix}${key}:{${createHash("sha256").update(key).digest("hex")}}`;
20
41
  }
21
42
  async get(key) {
22
43
  const data = await this.client.get(this.resultKey(key));
23
44
  if (!data) return void 0;
24
45
  try {
25
46
  const result = JSON.parse(data);
26
- if (new Date(result.expiresAt) < /* @__PURE__ */ new Date()) {
27
- await this.delete(key);
28
- return;
29
- }
47
+ if (new Date(result.expiresAt) < /* @__PURE__ */ new Date()) return;
30
48
  return {
31
49
  ...result,
32
50
  createdAt: new Date(result.createdAt),
@@ -51,6 +69,20 @@ var RedisIdempotencyStore = class {
51
69
  NX: true
52
70
  }) === "OK";
53
71
  }
72
+ async setIfLocked(key, requestId, result) {
73
+ if (typeof this.client.eval !== "function") throw new Error("Redis idempotency fenced completion requires eval; proxy eval through your Redis client adapter.");
74
+ const ttlMs = new Date(result.expiresAt).getTime() - Date.now();
75
+ if (ttlMs <= 0) return false;
76
+ const saved = await this.client.eval(`
77
+ if redis.call("get", KEYS[1]) ~= ARGV[1] then return 0 end
78
+ redis.call("set", KEYS[2], ARGV[2], "PX", ARGV[3])
79
+ return 1
80
+ `, 2, this.lockKey(key), this.resultKey(key), requestId, JSON.stringify({
81
+ key,
82
+ ...result
83
+ }), ttlMs);
84
+ return Number(saved) === 1;
85
+ }
54
86
  async unlock(key, requestId) {
55
87
  const luaScript = `
56
88
  if redis.call("get", KEYS[1]) == ARGV[1] then
@@ -70,14 +102,19 @@ var RedisIdempotencyStore = class {
70
102
  await this.client.del([this.resultKey(key), this.lockKey(key)]);
71
103
  }
72
104
  async deleteByPrefix(prefix) {
73
- const resultKeys = await this.scanByPrefix(this.resultKey(prefix));
74
- const lockKeys = await this.scanByPrefix(this.lockKey(prefix));
105
+ const resultKeys = await this.scanByPrefix(`${this.prefix}${prefix}`);
106
+ const lockKeys = await this.scanByPrefix(`${this.lockPrefix}${prefix}`);
75
107
  const allKeys = [...resultKeys, ...lockKeys];
76
108
  if (allKeys.length === 0) return 0;
77
- return this.client.del(allKeys);
109
+ let removed = 0;
110
+ for (let offset = 0; offset < allKeys.length; offset += 100) {
111
+ const counts = await Promise.all(allKeys.slice(offset, offset + 100).map((key) => this.client.del(key)));
112
+ removed += counts.reduce((sum, count) => sum + count, 0);
113
+ }
114
+ return removed;
78
115
  }
79
116
  async findByPrefix(prefix) {
80
- const keys = await this.scanByPrefix(this.resultKey(prefix));
117
+ const keys = await this.scanByPrefix(`${this.prefix}${prefix}`);
81
118
  if (keys.length === 0) return void 0;
82
119
  const BATCH_SIZE = 10;
83
120
  const now = /* @__PURE__ */ new Date();