@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.
- package/dist/{EventTransport-CbUKDzZZ.d.mts → EventTransport-CiicKz2Z.d.mts} +1 -1
- package/dist/{EventTransport-DSqD_Xsg.mjs → EventTransport-DZWDsaPa.mjs} +22 -17
- package/dist/{HookSystem-CMF94SEA.mjs → HookSystem-yjEgn7Cm.mjs} +2 -2
- package/dist/{QueryCache-DFA-QrU9.d.mts → QueryCache--5uAuYPW.d.mts} +7 -3
- package/dist/{ResourceRegistry-MGpXoxHZ.mjs → ResourceRegistry-DJxHUpE4.mjs} +44 -13
- package/dist/acquire-BozJ-9yt.mjs +35 -0
- package/dist/actionPermissions-5N04zBL4.mjs +772 -0
- package/dist/audit/index.d.mts +46 -8
- package/dist/audit/index.mjs +48 -26
- package/dist/auth/audit.d.mts +8 -8
- package/dist/auth/audit.mjs +1 -1
- package/dist/auth/index.d.mts +46 -16
- package/dist/auth/index.mjs +134 -156
- package/dist/auth/redis-session.d.mts +4 -5
- package/dist/auth/redis-session.mjs +1 -1
- package/dist/{authorizationDecision-DawKGoXb.mjs → authorizationDecision-wX3SA9w_.mjs} +7 -102
- package/dist/{base-CxrV1LmG.d.mts → base-CZyixEQR.d.mts} +27 -5
- package/dist/boundedMap-DWiKW7lP.mjs +57 -0
- package/dist/{buildHandler-CvxKOc0m.mjs → buildHandler-Bswu_vNb.mjs} +4 -4
- package/dist/cache/index.d.mts +31 -43
- package/dist/cache/index.mjs +4 -4
- package/dist/{caching-DbCBxlgw.mjs → caching-Drp68DIS.mjs} +3 -3
- package/dist/claimed-DYvQoZLv.mjs +8 -0
- package/dist/cleanup/index.d.mts +18 -22
- package/dist/cleanup/index.mjs +27 -73
- package/dist/cli/commands/describe.d.mts +7 -5
- package/dist/cli/commands/describe.mjs +7 -4
- package/dist/cli/commands/docs.d.mts +2 -3
- package/dist/cli/commands/docs.mjs +3 -3
- package/dist/cli/commands/doctor.d.mts +2 -3
- package/dist/cli/commands/doctor.mjs +1 -1
- package/dist/cli/commands/generate.mjs +2 -3
- package/dist/cli/commands/init.mjs +2 -3
- package/dist/cli/commands/introspect.d.mts +2 -3
- package/dist/cli/commands/introspect.mjs +2 -2
- package/dist/cli/index.mjs +3 -3
- package/dist/{constants-D5Ubn1fe.mjs → constants-B2QHb6u6.mjs} +4 -2
- package/dist/context/index.d.mts +11 -14
- package/dist/context/index.mjs +18 -38
- package/dist/core/index.d.mts +3 -3
- package/dist/core/index.mjs +8 -8
- package/dist/{core-5lazhva9.mjs → core-COcqo0Cp.mjs} +15 -5
- package/dist/{createActionRouter-DNqoaK81.mjs → createActionRouter-zG13rpPJ.mjs} +97 -39
- package/dist/{createAggregationRouter-BcbQJf10.mjs → createAggregationRouter-CWRz96Vr.mjs} +11 -8
- package/dist/{preloadResources-f3boenVZ.mjs → createApp-BGLYtErt.mjs} +305 -394
- package/dist/{createMcpServer-CbDWkZ1T.d.mts → createMcpServer-DSztofa9.d.mts} +1 -1
- package/dist/{defineAggregation-D_eJjm5A.mjs → defineAggregation--3xguLkk.mjs} +1 -1
- package/dist/{defineResource-DxkMTjRn.mjs → defineResource-gUp9ug66.mjs} +118 -1024
- package/dist/discovery/index.d.mts +6 -7
- package/dist/discovery/index.mjs +1 -1
- package/dist/docs/index.d.mts +11 -5
- package/dist/docs/index.mjs +4 -4
- package/dist/{elevation-CkvG0LG2.mjs → elevation-BG4gnMsJ.mjs} +41 -12
- package/dist/{elevation-BXCfVcNG.d.mts → elevation-BW2J3tNj.d.mts} +1 -2
- package/dist/encryption/index.d.mts +5 -5
- package/dist/encryption/index.mjs +3 -3
- package/dist/{errorHandler-D1GHKGlV.mjs → errorHandler-COHw3N1b.mjs} +26 -5
- package/dist/{errors-BH7YU_9O.mjs → errors-CW4anJBy.mjs} +58 -23
- package/dist/{errors-BGdVwP25.d.mts → errors-JYc9vwqE.d.mts} +9 -0
- package/dist/{eventPlugin-B52abJ8W.d.mts → eventPlugin-BtmA4-3P.d.mts} +65 -9
- package/dist/{eventPlugin-zZY-unhr.mjs → eventPlugin-BweHAVws.mjs} +82 -21
- package/dist/{subscribe-helpers-DatDkcuG.mjs → eventTypes-N4-omQFR.mjs} +151 -17
- package/dist/events/index.d.mts +43 -18
- package/dist/events/index.mjs +49 -53
- package/dist/events/transports/redis-stream-entry.d.mts +1 -1
- package/dist/events/transports/redis-stream-entry.mjs +139 -157
- package/dist/events/transports/redis.d.mts +5 -6
- package/dist/events/transports/redis.mjs +2 -2
- package/dist/factory/index.d.mts +42 -27
- package/dist/factory/index.mjs +213 -32
- package/dist/{fastify-augmentation-Cq2nHVaW.d.mts → fastify-augmentation-DRlCIWE9.d.mts} +11 -25
- package/dist/{fields-BHrkoF5y.mjs → fields-A5crhsJb.mjs} +1 -1
- package/dist/{fields-I_EeRWND.d.mts → fields-BBSpf8pS.d.mts} +1 -1
- package/dist/filter-merge-DGM-GQ1J.mjs +99 -0
- package/dist/{generate-BLRf2m3y.mjs → generate-BXKo2Gc8.mjs} +2 -2
- package/dist/{headers-BIXWKrzQ.mjs → headers-DKZQKL-Z.mjs} +2 -2
- package/dist/health-BYAwA6YS.mjs +22 -0
- package/dist/{health-ZRHeDHXH.mjs → health-CrqUp3sz.mjs} +115 -55
- package/dist/hooks/index.d.mts +2 -2
- package/dist/hooks/index.mjs +2 -3
- package/dist/idempotency/index.d.mts +36 -10
- package/dist/idempotency/index.mjs +230 -111
- package/dist/idempotency/redis.d.mts +1 -1
- package/dist/idempotency/redis.mjs +77 -12
- package/dist/{index-D4u76eQW.d.mts → index-Beg3aNnK.d.mts} +23 -101
- package/dist/{preloadResources-CjS2OfpU.d.mts → index-BkV55WlV.d.mts} +37 -38
- package/dist/{index-BHdZD8zK.d.mts → index-Bxriq3so.d.mts} +84 -21
- package/dist/index-CUJvoyw3.d.mts +510 -0
- package/dist/{index-CbJiYrfv.d.mts → index-Chgz3d0i.d.mts} +1 -1
- package/dist/{index-DHbyPo5j.d.mts → index-WH7xgvxS.d.mts} +304 -254
- package/dist/index.d.mts +9 -9
- package/dist/index.mjs +11 -11
- package/dist/{init-CpLkjU18.mjs → init-BPzK5Lnr.mjs} +1 -1
- package/dist/integrations/event-gateway.d.mts +11 -7
- package/dist/integrations/event-gateway.mjs +4 -4
- package/dist/integrations/index.d.mts +3 -3
- package/dist/integrations/index.mjs +1 -1
- package/dist/integrations/jobs/index.d.mts +1 -1
- package/dist/integrations/jobs/index.mjs +18 -5
- package/dist/integrations/mcp/index.d.mts +102 -89
- package/dist/integrations/mcp/index.mjs +19 -4
- package/dist/integrations/mcp/testing.d.mts +8 -9
- package/dist/integrations/mcp/testing.mjs +2 -2
- package/dist/integrations/streamline.d.mts +85 -144
- package/dist/integrations/streamline.mjs +131 -55
- package/dist/integrations/webhooks.d.mts +83 -143
- package/dist/integrations/webhooks.mjs +36 -80
- package/dist/integrations/websocket-pushref-redis.d.mts +5 -6
- package/dist/integrations/websocket-pushref-redis.mjs +1 -1
- package/dist/integrations/websocket-redis.d.mts +5 -6
- package/dist/integrations/websocket-redis.mjs +1 -1
- package/dist/integrations/websocket.d.mts +3 -3
- package/dist/integrations/websocket.mjs +2 -2
- package/dist/{interface-DEWNOMza.d.mts → interface-JJzwH4Ro.d.mts} +34 -2
- package/dist/{jsonBody-ELR9lfeU.mjs → jsonBody-YIq-vGLv.mjs} +2 -2
- package/dist/{keys-cpZCtcIE.mjs → keys-CpoiZ8hU.mjs} +7 -18
- package/dist/{loadResources-BPfWiI5s.mjs → loadResources-6jO52-RR.mjs} +3 -3
- package/dist/{loadResourcesFromEntry-ixtfL3J2.mjs → loadResourcesFromEntry-MP8xEppu.mjs} +2 -2
- package/dist/logger/index.d.mts +9 -17
- package/dist/logger/index.mjs +2 -153
- package/dist/logger-D4V1zGLI.mjs +160 -0
- package/dist/{mcpPlugin-BX5PujI0.mjs → mcpPlugin-srPc7ItN.mjs} +446 -170
- package/dist/{memory-Iotz0cCx.mjs → memory-CTa5pOCW.mjs} +36 -34
- package/dist/{metrics-NMsmpRmY.mjs → metrics-DsZrvnpB.mjs} +39 -72
- package/dist/middleware/index.d.mts +5 -5
- package/dist/middleware/index.mjs +3 -3
- package/dist/migrations/index.d.mts +13 -13
- package/dist/migrations/index.mjs +2 -2
- package/dist/{module-DpLJG2_N.mjs → module--L0iBIZz.mjs} +8 -8
- package/dist/mongodb-memory-server-CHhYSq5y.mjs +35539 -0
- package/dist/mongoose-CYJCLyi-.mjs +67451 -0
- package/dist/multiTenant-CAtYlW6H.d.mts +104 -0
- package/dist/{multipartBody-DtRWM6_U.mjs → multipartBody-Cc7eZ9cq.mjs} +46 -7
- package/dist/node-D8UZv_8c.mjs +41236 -0
- package/dist/{openapi-CmFf3yq_.mjs → openapi-CMegfsm8.mjs} +48 -10
- package/dist/outbox-admin/index.d.mts +5 -5
- package/dist/outbox-admin/index.mjs +6 -18
- package/dist/{outbox-module-D2z5WkDY.mjs → outbox-module-Ryk270jd.mjs} +203 -33
- package/dist/{outbox-module-BHdUnuuK.d.mts → outbox-module-gZCHoE4H.d.mts} +79 -6
- package/dist/permissions/index.d.mts +5 -4
- package/dist/permissions/index.mjs +8 -6
- package/dist/{permissions-D8TBH44E.mjs → permissions-C5ijIz5Q.mjs} +149 -33
- package/dist/{pipe-FMWNheSO.mjs → pipe-D81zMtWs.mjs} +2 -2
- package/dist/pipeline/index.d.mts +7 -7
- package/dist/pipeline/index.mjs +2 -2
- package/dist/plan-CJGMKoui.mjs +565 -0
- package/dist/{BaseController-CYEwjyjN.mjs → plugin-DALd9dcF.mjs} +1269 -346
- package/dist/plugins/index.d.mts +17 -14
- package/dist/plugins/index.mjs +44 -22
- package/dist/plugins/response-cache.d.mts +5 -6
- package/dist/plugins/response-cache.mjs +8 -14
- package/dist/plugins/tracing-entry.mjs +2 -2
- package/dist/{pluralize-CXWZIzTv.mjs → pluralize-COUNYo3d.mjs} +1 -1
- package/dist/presets/filesUpload.d.mts +8 -8
- package/dist/presets/filesUpload.mjs +5 -5
- package/dist/presets/index.d.mts +35 -40
- package/dist/presets/index.mjs +4 -4
- package/dist/presets/multiTenant.d.mts +2 -107
- package/dist/presets/multiTenant.mjs +22 -162
- package/dist/presets/search.d.mts +9 -10
- package/dist/presets/search.mjs +2 -2
- package/dist/{presets-ZJSPgmT2.mjs → presets-CONf2Qem.mjs} +99 -107
- package/dist/{projection-BzIgzoKZ.mjs → projection-wFoLqdss.mjs} +2 -2
- package/dist/{pushref-registry-Dov9l4qE.d.mts → pushref-registry-UxCkoA4C.d.mts} +1 -1
- package/dist/{queryCachePlugin-DZHPOm9p.mjs → queryCachePlugin-BATECpyh.mjs} +12 -14
- package/dist/{queryCachePlugin-BzqxconG.d.mts → queryCachePlugin-C2lZjTxk.d.mts} +1 -1
- package/dist/{rateLimitKey-BvOvcBDi.mjs → rateLimitKey-DVEZ_UpV.mjs} +1 -1
- package/dist/{realtime-8tIQWZz4.mjs → realtime-xyubMKSB.mjs} +25 -21
- package/dist/{redis-Bzi1y3F9.d.mts → redis-BDgv9Glw.d.mts} +17 -24
- package/dist/redis-stream-BMk-bpQS.d.mts +219 -0
- package/dist/redisShapes-CQksY67q.d.mts +55 -0
- package/dist/registry/index.d.mts +15 -10
- package/dist/registry/index.mjs +4 -4
- package/dist/{registry-DNkgGQcK.mjs → registry-Bdzg-hdm.mjs} +13 -10
- package/dist/{renewingLease-yF8BdW4b.mjs → renewingLease-BFJxGPuh.mjs} +1 -1
- package/dist/{replyHelpers-CNdvMfM4.mjs → replyHelpers-Bnc6lJyy.mjs} +2 -2
- package/dist/{requestContext-cDkAUr-P.mjs → requestContext-C10UeFe5.mjs} +1 -1
- package/dist/requirement-Bu50QALH.mjs +35 -0
- package/dist/requirement-CbiLdQ1n.d.mts +22 -0
- package/dist/resourceRateLimit-Wf6d6FL9.d.mts +30 -0
- package/dist/resourceRateLimit-urWPJxrt.mjs +180 -0
- package/dist/rolldown-runtime-MYrLNGnq.mjs +45 -0
- package/dist/routeValidator-DHtpzrzk.mjs +70 -0
- package/dist/{runtimeCapabilities-CkhS6jn6.mjs → runtimeCapabilities-DIMKMV2G.mjs} +1 -1
- package/dist/{schedules-j6uXXAeJ.mjs → schedules-CKTs5ys8.mjs} +46 -37
- package/dist/{schemaConverter-BVA7cFRG.mjs → schemaConverter-BVjkQ5DD.mjs} +2 -2
- package/dist/{schemaIR-CTvPDoPB.mjs → schemaIR-BA0Hb21Y.mjs} +2 -2
- package/dist/schemas/index.d.mts +20 -10
- package/dist/schemas/index.mjs +8 -3
- package/dist/scim/index.d.mts +13 -13
- package/dist/scim/index.mjs +2 -2
- package/dist/scope/index.d.mts +4 -4
- package/dist/scope/index.mjs +6 -6
- package/dist/{scope-BUi1xV1I.mjs → scope-DINaj7Ax.mjs} +4 -4
- package/dist/shutdown-LpLMH8T1.mjs +59 -0
- package/dist/{simpleEqualityMatcher-C9bzq2gO.mjs → simpleEqualityMatcher-DbwIM1f1.mjs} +1 -1
- package/dist/{sse-BeqVKQKu.mjs → sse-BzJFPX_1.mjs} +11 -15
- package/dist/{sseStream-Ppzo-WUH.mjs → sseStream-DQChzGTs.mjs} +2 -2
- package/dist/{store-helpers-dW71XVWL.mjs → store-helpers-Coj9RhOZ.mjs} +1 -1
- package/dist/{streaming-IcRFjWQU.mjs → streaming-JzB5CAga.mjs} +1 -1
- package/dist/subscriberIdentity-DeyppIif.mjs +28 -0
- package/dist/tenancy-DxV8OuX2.mjs +43 -0
- package/dist/tenantPreparation-CBvvAcAj.mjs +67 -0
- package/dist/testing/cleanupStoreContract.d.mts +4 -5
- package/dist/testing/cleanupStoreContract.mjs +1 -1
- package/dist/testing/index.d.mts +215 -197
- package/dist/testing/index.mjs +289 -214
- package/dist/testing/outboxStoreContract.d.mts +4 -5
- package/dist/testing/outboxStoreContract.mjs +137 -2
- package/dist/testing/requiredPorts.d.mts +16 -0
- package/dist/testing/requiredPorts.mjs +65 -0
- package/dist/testing/storageContract.d.mts +4 -5
- package/dist/testing/storageContract.mjs +1 -1
- package/dist/timing-CntavTlR.mjs +46 -0
- package/dist/{transactionContext-CPbdlnVw.mjs → transactionContext-orvnGu4F.mjs} +1 -1
- package/dist/{typeGuards-BLjPgVBm.mjs → typeGuards-kEaP_WmO.mjs} +1 -1
- package/dist/types/index.d.mts +4 -4
- package/dist/types/index.mjs +1 -1
- package/dist/types/storage.mjs +1 -1
- package/dist/{types-DfVDvkdP.d.mts → types-BJ-2XqVF.d.mts} +189 -204
- package/dist/{types-CWQn9-IC.d.mts → types-D3Ixsh00.d.mts} +22 -9
- package/dist/{types-CgenB1gv.mjs → types-fvO6Ovzx.mjs} +4 -4
- package/dist/usage/index.d.mts +5 -6
- package/dist/usage/index.mjs +5 -5
- package/dist/{userHelpers-ChRvkuSZ.mjs → userHelpers-C76zD5pa.mjs} +1 -1
- package/dist/utils/index.d.mts +125 -264
- package/dist/utils/index.mjs +13 -12
- package/dist/{utils-CxcctrAh.mjs → utils-BpkVZ8rB.mjs} +315 -404
- package/dist/{validate-DrYWnOzI.mjs → validate-CWp-lsrm.mjs} +6 -14
- package/dist/{versioning-D6JIK8Tk.d.mts → versioning-Bsb51gGT.d.mts} +28 -13
- package/dist/{versioning-DKkT4hNJ.mjs → versioning-CHhL9P3o.mjs} +4 -4
- package/dist/{websocket-BOXVggMf.d.mts → websocket-Cs4awVyD.d.mts} +1 -1
- package/dist/{workScope-CxKqoIFn.mjs → workScope-n9a7d_lZ.mjs} +2 -2
- package/package.json +57 -59
- package/skills/arc/SKILL.md +13 -4
- package/skills/arc/references/auth.md +8 -5
- package/skills/arc/references/integrations.md +1 -1
- package/skills/arc/references/mcp.md +8 -6
- package/skills/arc/references/multi-tenancy.md +19 -14
- package/skills/arc/references/production.md +27 -10
- package/dist/actionPermissions-BoPGQ5-4.mjs +0 -350
- package/dist/index-CFfLKMH4.d.mts +0 -290
- package/dist/interface-YzIbF7Ii.mjs +0 -17
- package/dist/redis-stream-BPKscsTB.d.mts +0 -260
- package/dist/registerRoute-Bsnk6NiZ.mjs +0 -364
- package/dist/rolldown-runtime-eysyzFAF.mjs +0 -15
- package/dist/sync/changelog.d.mts +0 -3
- package/dist/sync/changelog.mjs +0 -3
|
@@ -1,10 +1,15 @@
|
|
|
1
|
-
/*! @classytic/arc v2.
|
|
2
|
-
import {
|
|
3
|
-
import { n as
|
|
4
|
-
import { t as
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
270
|
-
*
|
|
271
|
-
*
|
|
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
|
-
*
|
|
288
|
-
*
|
|
289
|
-
*
|
|
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",
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
|
379
|
-
|
|
380
|
-
|
|
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
|
-
*
|
|
393
|
-
*
|
|
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
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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}:${
|
|
417
|
-
const
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
reply.
|
|
421
|
-
reply
|
|
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 (
|
|
523
|
+
if (acquisition.status === "locked") {
|
|
427
524
|
reply.code(409).header("Retry-After", retryAfterSeconds.toString()).send({
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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
|
-
|
|
448
|
-
|
|
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
|
|
562
|
+
if (!fullKey) return;
|
|
451
563
|
const statusCode = reply.statusCode;
|
|
452
|
-
if (statusCode < 200 || statusCode >= 300) return
|
|
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
|
-
|
|
465
|
-
|
|
466
|
-
body
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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
|
|
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,
|
|
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-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
74
|
-
const lockKeys = await this.scanByPrefix(this.
|
|
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
|
-
|
|
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.
|
|
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();
|