@classytic/arc 2.41.0 → 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.
- package/dist/{EventTransport-CbUKDzZZ.d.mts → EventTransport-CiicKz2Z.d.mts} +1 -1
- package/dist/{EventTransport-n7qO5Cz3.mjs → EventTransport-tzA7CAB4.mjs} +22 -17
- package/dist/{HookSystem-CxvZBo77.mjs → HookSystem-B6UaKRbF.mjs} +2 -2
- package/dist/{QueryCache-DFA-QrU9.d.mts → QueryCache--5uAuYPW.d.mts} +7 -3
- package/dist/{ResourceRegistry-Cu7uTJp9.mjs → ResourceRegistry-CuCARu2B.mjs} +44 -13
- package/dist/actionPermissions-BotX1YOa.mjs +776 -0
- package/dist/audit/index.d.mts +43 -5
- package/dist/audit/index.mjs +48 -26
- package/dist/auth/audit.mjs +1 -1
- package/dist/auth/index.d.mts +43 -13
- package/dist/auth/index.mjs +130 -155
- package/dist/auth/redis-session.mjs +1 -1
- package/dist/{authorizationDecision-2YY_Kaj1.mjs → authorizationDecision-DQYrkl5f.mjs} +7 -102
- package/dist/{base-CxrV1LmG.d.mts → base-CZyixEQR.d.mts} +27 -5
- package/dist/boundedMap-BULzfXYU.mjs +57 -0
- package/dist/{buildHandler-CUhjGHGZ.mjs → buildHandler-CRQ8EcX5.mjs} +4 -4
- package/dist/cache/index.d.mts +22 -34
- package/dist/cache/index.mjs +4 -4
- package/dist/{caching-DsWDjIxx.mjs → caching-Cc2M1DwO.mjs} +3 -3
- package/dist/claimed-B10RTfkM.mjs +8 -0
- package/dist/cleanup/index.d.mts +6 -10
- package/dist/cleanup/index.mjs +27 -73
- package/dist/cli/commands/describe.d.mts +4 -2
- package/dist/cli/commands/describe.mjs +7 -4
- package/dist/cli/commands/docs.mjs +3 -3
- package/dist/cli/commands/doctor.mjs +1 -1
- package/dist/cli/commands/generate.mjs +2 -2
- package/dist/cli/commands/init.mjs +2 -2
- package/dist/cli/commands/introspect.mjs +2 -2
- package/dist/cli/index.mjs +3 -3
- package/dist/{constants-BxLgNFF7.mjs → constants-Cu7Mx7_U.mjs} +4 -2
- package/dist/context/index.d.mts +2 -5
- 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-iNGqYerO.mjs → core-Cb0LjccA.mjs} +5 -4
- package/dist/{createActionRouter-BBlbPs3k.mjs → createActionRouter-xjqonMRO.mjs} +87 -39
- package/dist/{createAggregationRouter-Dv5SuK2x.mjs → createAggregationRouter-zRYWw_5D.mjs} +11 -8
- package/dist/{preloadResources-Bbp4QJ8V.mjs → createApp-D-953ZAq.mjs} +299 -390
- package/dist/{createMcpServer-CbDWkZ1T.d.mts → createMcpServer-C9cxS8zt.d.mts} +1 -1
- package/dist/{defineAggregation-UyeurGkp.mjs → defineAggregation-Bm8RjMbq.mjs} +1 -1
- package/dist/{defineResource-DzGT-2LP.mjs → defineResource-DIC7JZGU.mjs} +118 -1024
- package/dist/discovery/index.mjs +1 -1
- package/dist/docs/index.d.mts +10 -4
- package/dist/docs/index.mjs +4 -4
- package/dist/{elevation-BXCfVcNG.d.mts → elevation-BW2J3tNj.d.mts} +1 -2
- package/dist/{elevation-BF-vhGxY.mjs → elevation-DG8SrLN-.mjs} +41 -12
- package/dist/encryption/index.mjs +3 -3
- package/dist/{errorHandler-CIMkCOWm.mjs → errorHandler-BnfDviL1.mjs} +26 -5
- package/dist/{errors-C_YhELhe.mjs → errors-CtrywMHP.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-CmXaPWLv.mjs → eventPlugin-Q6BhzHzs.mjs} +82 -21
- package/dist/{subscribe-helpers-AVYPgqTl.mjs → eventTypes-Cdhm6ZlV.mjs} +151 -17
- package/dist/events/index.d.mts +33 -8
- 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 +1 -1
- package/dist/events/transports/redis.mjs +2 -2
- package/dist/factory/index.d.mts +32 -17
- package/dist/factory/index.mjs +213 -32
- package/dist/{fastify-augmentation-Cq2nHVaW.d.mts → fastify-augmentation-DhUJ-mvo.d.mts} +11 -25
- package/dist/{fields-I_EeRWND.d.mts → fields-BBSpf8pS.d.mts} +1 -1
- package/dist/{fields-BTyPLHK0.mjs → fields-ToPYpMqS.mjs} +1 -1
- package/dist/filter-merge-IpGywvym.mjs +99 -0
- package/dist/{generate-D_YmiCNf.mjs → generate-DRTkLlIl.mjs} +2 -2
- package/dist/{headers-CNECDE6q.mjs → headers-BbkIF6vA.mjs} +2 -2
- package/dist/health-D-Ft6JM6.mjs +22 -0
- package/dist/{health-CxQzbJmr.mjs → health-DqbsG-_w.mjs} +115 -55
- package/dist/hooks/index.d.mts +2 -2
- package/dist/hooks/index.mjs +2 -2
- package/dist/idempotency/index.d.mts +31 -7
- package/dist/idempotency/index.mjs +155 -98
- package/dist/idempotency/redis.d.mts +1 -1
- package/dist/idempotency/redis.mjs +48 -11
- package/dist/{index-BHdZD8zK.d.mts → index-Bxriq3so.d.mts} +84 -21
- package/dist/{preloadResources-CjS2OfpU.d.mts → index-C8zVGYsk.d.mts} +37 -38
- package/dist/{index-D4u76eQW.d.mts → index-CRoPtISf.d.mts} +23 -73
- package/dist/{index-CbJiYrfv.d.mts → index-Chgz3d0i.d.mts} +1 -1
- package/dist/index-JdbZAIsm.d.mts +510 -0
- package/dist/{index-DHbyPo5j.d.mts → index-mqaqZNpe.d.mts} +253 -254
- package/dist/index.d.mts +8 -8
- package/dist/index.mjs +11 -11
- package/dist/{init-Y2MlSN9n.mjs → init-B_oUYDXI.mjs} +1 -1
- package/dist/integrations/event-gateway.d.mts +8 -3
- 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 +72 -59
- package/dist/integrations/mcp/index.mjs +19 -4
- package/dist/integrations/mcp/testing.d.mts +2 -2
- package/dist/integrations/mcp/testing.mjs +2 -2
- package/dist/integrations/streamline.d.mts +78 -136
- package/dist/integrations/streamline.mjs +116 -54
- package/dist/integrations/webhooks.d.mts +60 -120
- package/dist/integrations/webhooks.mjs +36 -80
- package/dist/integrations/websocket-pushref-redis.d.mts +1 -1
- package/dist/integrations/websocket-pushref-redis.mjs +1 -1
- package/dist/integrations/websocket-redis.d.mts +1 -1
- 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-C0395yUN.d.mts} +13 -1
- package/dist/{jsonBody-Cq9KYl2Z.mjs → jsonBody-B4YVGcql.mjs} +2 -2
- package/dist/{keys-Cmv4cBRa.mjs → keys-B2KtgMLJ.mjs} +7 -18
- package/dist/{loadResources-CblF7xt2.mjs → loadResources-Dn4lalw2.mjs} +3 -3
- package/dist/{loadResourcesFromEntry-99fIMC3p.mjs → loadResourcesFromEntry-3sLYbuse.mjs} +2 -2
- package/dist/logger/index.d.mts +1 -8
- package/dist/logger/index.mjs +3 -153
- package/dist/logger-DXjfIqjV.mjs +160 -0
- package/dist/{mcpPlugin-xF3Gw-HW.mjs → mcpPlugin-C08HalN5.mjs} +433 -170
- package/dist/{memory-CpDZ9NPw.mjs → memory-9Cp7611C.mjs} +36 -34
- package/dist/{metrics-BaKnq8ag.mjs → metrics-CWI11eRu.mjs} +39 -72
- package/dist/middleware/index.d.mts +2 -2
- package/dist/middleware/index.mjs +3 -3
- package/dist/migrations/index.mjs +2 -2
- package/dist/{module-BkQEesNQ.mjs → module-BSvOQC7E.mjs} +8 -8
- package/dist/multiTenant-cY0FkDiA.d.mts +104 -0
- package/dist/{multipartBody-C92aJ8am.mjs → multipartBody-CXM2a-Dy.mjs} +46 -7
- package/dist/{openapi-C3LFRzPq.mjs → openapi-DT_G4VhY.mjs} +48 -10
- package/dist/outbox-admin/index.d.mts +3 -3
- package/dist/outbox-admin/index.mjs +6 -18
- package/dist/{outbox-module-dsCyLxXH.mjs → outbox-module-Bjgzl6tg.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-DiHXy8Ck.mjs → permissions-Cm0uYHvd.mjs} +149 -33
- package/dist/{pipe-CBVmUJ-j.mjs → pipe-BqeGT6UO.mjs} +2 -2
- package/dist/pipeline/index.d.mts +2 -2
- package/dist/pipeline/index.mjs +2 -2
- package/dist/plan-FVR6LP59.mjs +565 -0
- package/dist/{BaseController-DYJRZ0EJ.mjs → plugin-BccET1tL.mjs} +1256 -344
- package/dist/plugins/index.d.mts +14 -11
- package/dist/plugins/index.mjs +44 -22
- package/dist/plugins/response-cache.mjs +8 -14
- package/dist/plugins/tracing-entry.mjs +2 -2
- package/dist/{pluralize-2_U6QZQg.mjs → pluralize-CVIOcgMF.mjs} +1 -1
- package/dist/presets/filesUpload.d.mts +2 -2
- package/dist/presets/filesUpload.mjs +5 -5
- package/dist/presets/index.d.mts +26 -31
- 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 +2 -2
- package/dist/presets/search.mjs +2 -2
- package/dist/{presets-BdlugYTc.mjs → presets-Cpvj6fGO.mjs} +99 -107
- package/dist/{projection-Uvgp-EEj.mjs → projection-Ce4aQhQk.mjs} +2 -2
- package/dist/{pushref-registry-Dov9l4qE.d.mts → pushref-registry-UxCkoA4C.d.mts} +1 -1
- package/dist/{queryCachePlugin-BzqxconG.d.mts → queryCachePlugin-C2lZjTxk.d.mts} +1 -1
- package/dist/{queryCachePlugin-B5F09bYr.mjs → queryCachePlugin-CFWNH8aT.mjs} +12 -14
- package/dist/{rateLimitKey-CK-EU2L8.mjs → rateLimitKey-C4O-kPm0.mjs} +1 -1
- package/dist/{realtime-FnnD0n3Z.mjs → realtime-DmPE9S54.mjs} +25 -21
- package/dist/redis-stream-BMk-bpQS.d.mts +219 -0
- package/dist/{redis-Bzi1y3F9.d.mts → redis-vLvtcGy_.d.mts} +13 -24
- package/dist/redisShapes-CQksY67q.d.mts +55 -0
- package/dist/registry/index.d.mts +9 -4
- package/dist/registry/index.mjs +4 -4
- package/dist/{registry-D3y8VEJ1.mjs → registry-Bcz9G1bh.mjs} +10 -8
- package/dist/{renewingLease-B3VYh5aU.mjs → renewingLease-qODa7KJQ.mjs} +1 -1
- package/dist/{replyHelpers-DPHsdw-5.mjs → replyHelpers-CG_VtVgS.mjs} +2 -2
- package/dist/{requestContext-cSVKnYPk.mjs → requestContext-Dsu9P1bV.mjs} +1 -1
- package/dist/requirement-CbiLdQ1n.d.mts +22 -0
- package/dist/requirement-KDdoRcOP.mjs +35 -0
- package/dist/resourceRateLimit-Wf6d6FL9.d.mts +30 -0
- package/dist/resourceRateLimit-dSjVr1pk.mjs +180 -0
- package/dist/{rolldown-runtime-DjDuyglE.mjs → rolldown-runtime-D1w9Y1VJ.mjs} +1 -1
- package/dist/routeValidator-D36SW8qt.mjs +70 -0
- package/dist/{runtimeCapabilities-DSA_dH1_.mjs → runtimeCapabilities-0tccjRlj.mjs} +1 -1
- package/dist/{schedules-BktJb3Y3.mjs → schedules-C-OpmHBq.mjs} +46 -37
- package/dist/{schemaConverter-BLCSmNbr.mjs → schemaConverter-BORek3O-.mjs} +2 -2
- package/dist/{schemaIR-K16Bb5BX.mjs → schemaIR-C7Vbp71I.mjs} +2 -2
- package/dist/schemas/index.d.mts +10 -0
- package/dist/schemas/index.mjs +8 -3
- package/dist/scim/index.mjs +2 -2
- package/dist/scope/index.d.mts +2 -2
- package/dist/scope/index.mjs +6 -6
- package/dist/{scope-jSxuBTFM.mjs → scope-DQthe88b.mjs} +4 -4
- package/dist/shutdown-MqA81z_P.mjs +59 -0
- package/dist/{simpleEqualityMatcher-BiOnyflP.mjs → simpleEqualityMatcher-auW8QjXN.mjs} +1 -1
- package/dist/{sse-CH7Slxc1.mjs → sse-CYzQBVFe.mjs} +11 -15
- package/dist/{sseStream-CN-Lh1Tu.mjs → sseStream-B5_Afiqn.mjs} +2 -2
- package/dist/{store-helpers-9DX9-1j_.mjs → store-helpers-BOzk-CuX.mjs} +1 -1
- package/dist/{streaming-dvppnxsr.mjs → streaming-DjTQhZrN.mjs} +1 -1
- package/dist/subscriberIdentity-CW39uA8G.mjs +28 -0
- package/dist/tenancy--ed9eH_z.mjs +43 -0
- package/dist/tenantPreparation-BKon6Yox.mjs +67 -0
- package/dist/testing/cleanupStoreContract.mjs +1 -1
- package/dist/testing/index.d.mts +193 -175
- package/dist/testing/index.mjs +286 -212
- package/dist/testing/outboxStoreContract.mjs +137 -2
- package/dist/testing/requiredPorts.d.mts +17 -0
- package/dist/testing/requiredPorts.mjs +65 -0
- package/dist/testing/storageContract.mjs +1 -1
- package/dist/timing-qczZ0YC-.mjs +46 -0
- package/dist/{transactionContext-DCWMI6hV.mjs → transactionContext-DjFkrNG6.mjs} +1 -1
- package/dist/{typeGuards-D7tHFdH6.mjs → typeGuards-CySF12G-.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-CWQn9-IC.d.mts → types-B4Aq4Sdj.d.mts} +22 -9
- package/dist/{types-DfVDvkdP.d.mts → types-BJ-2XqVF.d.mts} +189 -204
- package/dist/{types-CDy3-zw1.mjs → types-Bk9NOgOC.mjs} +4 -4
- package/dist/usage/index.d.mts +2 -3
- package/dist/usage/index.mjs +5 -5
- package/dist/{userHelpers-DDMOZ_Pm.mjs → userHelpers-aklawRaK.mjs} +1 -1
- package/dist/utils/index.d.mts +84 -223
- package/dist/utils/index.mjs +13 -12
- package/dist/{utils-B-zj_b__.mjs → utils-ec1oTo1n.mjs} +315 -404
- package/dist/{validate-R0G_JrnK.mjs → validate-9yzHC1e2.mjs} +6 -14
- package/dist/{versioning-D6JIK8Tk.d.mts → versioning-Bsb51gGT.d.mts} +28 -13
- package/dist/{versioning-DuVWcu-F.mjs → versioning-fhhWHV74.mjs} +4 -4
- package/dist/{websocket-BOXVggMf.d.mts → websocket-Cs4awVyD.d.mts} +1 -1
- package/dist/{workScope-BhsWde4k.mjs → workScope-zpPyge4t.mjs} +2 -2
- package/package.json +578 -574
- 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-DPwPWPOV.mjs +0 -350
- package/dist/index-CFfLKMH4.d.mts +0 -290
- package/dist/interface-BIpgzbZl.mjs +0 -17
- package/dist/redis-stream-BPKscsTB.d.mts +0 -260
- package/dist/registerRoute-DRsSHPSM.mjs +0 -364
- package/dist/sync/changelog.d.mts +0 -3
- package/dist/sync/changelog.mjs +0 -3
|
@@ -1,10 +1,14 @@
|
|
|
1
|
-
/*! @classytic/arc v2.
|
|
2
|
-
import {
|
|
3
|
-
import { n as
|
|
4
|
-
import { t as
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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]`.
|
|
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
|
-
*
|
|
278
|
-
*
|
|
279
|
-
*
|
|
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",
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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;
|
|
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
|
-
*
|
|
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.
|
|
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
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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}:${
|
|
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
|
|
420
|
-
reply
|
|
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
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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
|
-
|
|
448
|
-
|
|
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
|
|
501
|
+
if (!fullKey) return;
|
|
451
502
|
const statusCode = reply.statusCode;
|
|
452
|
-
if (statusCode < 200 || statusCode >= 300) return
|
|
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
|
-
|
|
465
|
-
|
|
466
|
-
body
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
await store.set(fullKey,
|
|
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,
|
|
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-
|
|
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.
|
|
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.
|
|
74
|
-
const lockKeys = await this.scanByPrefix(this.
|
|
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
|
-
|
|
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.
|
|
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();
|