@cosmicdrift/kumiko-framework 0.200.1 → 0.202.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 (47) hide show
  1. package/package.json +7 -3
  2. package/src/__tests__/entity-list-limits.integration.test.ts +84 -0
  3. package/src/api/__tests__/api-constants-completeness.test.ts +63 -0
  4. package/src/api/__tests__/api.test.ts +116 -1
  5. package/src/api/__tests__/batch.integration.test.ts +53 -0
  6. package/src/api/__tests__/body-limit.test.ts +90 -0
  7. package/src/api/__tests__/server-jwt-ttl.test.ts +2 -2
  8. package/src/api/api-constants.ts +44 -7
  9. package/src/api/auth-middleware.ts +19 -3
  10. package/src/api/index.ts +1 -0
  11. package/src/api/route-registrars.ts +19 -21
  12. package/src/api/routes.ts +47 -1
  13. package/src/api/server.ts +1 -1
  14. package/src/db/__tests__/unchecked-system-db.test.ts +66 -0
  15. package/src/db/tenant-db.ts +46 -2
  16. package/src/engine/__tests__/boot-validator-detail-for.test.ts +82 -0
  17. package/src/engine/__tests__/build-app-schema.test.ts +25 -0
  18. package/src/engine/boot-validator/detail-screens.ts +35 -0
  19. package/src/engine/boot-validator/index.ts +2 -0
  20. package/src/engine/entity-handlers.ts +8 -1
  21. package/src/engine/feature-ast/__tests__/fixtures/cross-file-patch-const/constants.ts +2 -0
  22. package/src/engine/feature-ast/__tests__/fixtures/cross-file-patch-const/feature.ts +8 -0
  23. package/src/engine/feature-ast/__tests__/patch.test.ts +156 -0
  24. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +155 -0
  25. package/src/engine/feature-ast/extractors/events.ts +5 -3
  26. package/src/engine/feature-ast/extractors/round3.ts +5 -3
  27. package/src/engine/feature-ast/extractors/round5.ts +5 -4
  28. package/src/engine/feature-ast/extractors/shared.ts +29 -4
  29. package/src/engine/feature-ast/patch.ts +48 -21
  30. package/src/engine/feature-ast/patterns.ts +18 -0
  31. package/src/engine/feature-ast/render.ts +19 -6
  32. package/src/engine/index.ts +1 -0
  33. package/src/files/__tests__/files.integration.test.ts +97 -1
  34. package/src/files/file-routes.ts +10 -2
  35. package/src/files/types.ts +72 -0
  36. package/src/http/__tests__/egress-real-endpoint.integration.test.ts +37 -0
  37. package/src/http/__tests__/egress.test.ts +440 -0
  38. package/src/http/__tests__/policy.test.ts +125 -0
  39. package/src/http/egress.ts +158 -0
  40. package/src/http/index.ts +2 -0
  41. package/src/http/policy.ts +193 -0
  42. package/src/pipeline/__tests__/ctx-systemdb.integration.test.ts +44 -8
  43. package/src/pipeline/__tests__/dispatcher.test.ts +23 -4
  44. package/src/pipeline/__tests__/redis-pipeline.integration.test.ts +116 -22
  45. package/src/pipeline/dispatch-batch.ts +11 -5
  46. package/src/pipeline/dispatch-shared.ts +42 -16
  47. package/src/pipeline/idempotency.ts +91 -30
@@ -22,37 +22,44 @@ describe("idempotency guard", () => {
22
22
  const tenantA = "00000000-0000-4000-8000-00000000000a";
23
23
  const userA = "00000000-0000-4000-8000-0000000000a1";
24
24
 
25
- test("returns null for new request", async () => {
25
+ test("returns acquired for new request", async () => {
26
26
  const guard = createIdempotencyGuard(testRedis.redis);
27
27
  const result = await guard.check(tenantA, userA, "req-new-123");
28
- expect(result).toBeNull();
28
+ expect(result.status).toBe("acquired");
29
29
  });
30
30
 
31
31
  test("returns cached result for duplicate request", async () => {
32
32
  const guard = createIdempotencyGuard(testRedis.redis);
33
33
  const requestId = "req-dup-456";
34
34
 
35
- await guard.store(tenantA, userA, requestId, { isSuccess: true, data: { id: 1 } });
35
+ const acquired = await guard.check(tenantA, userA, requestId);
36
+ if (acquired.status !== "acquired") throw new Error("expected to acquire the lock");
37
+ await guard.store(tenantA, userA, requestId, acquired.token, {
38
+ isSuccess: true,
39
+ data: { id: 1 },
40
+ });
36
41
  const cached = await guard.check(tenantA, userA, requestId);
37
42
 
38
- expect(cached).not.toBeNull();
39
- if (!cached) throw new Error("expected cached value");
40
- expect(JSON.parse(cached)).toEqual({ isSuccess: true, data: { id: 1 } });
43
+ expect(cached.status).toBe("cached");
44
+ if (cached.status !== "cached") throw new Error("expected cached value");
45
+ expect(JSON.parse(cached.result)).toEqual({ isSuccess: true, data: { id: 1 } });
41
46
  });
42
47
 
43
48
  test("expires after TTL", async () => {
44
49
  const guard = createIdempotencyGuard(testRedis.redis, { ttlSeconds: 1 });
45
50
  const requestId = "req-ttl-789";
46
51
 
47
- await guard.store(tenantA, userA, requestId, { done: true });
52
+ const acquired = await guard.check(tenantA, userA, requestId);
53
+ if (acquired.status !== "acquired") throw new Error("expected to acquire the lock");
54
+ await guard.store(tenantA, userA, requestId, acquired.token, { done: true });
48
55
 
49
56
  // Should exist immediately
50
- expect(await guard.check(tenantA, userA, requestId)).not.toBeNull();
57
+ expect((await guard.check(tenantA, userA, requestId)).status).toBe("cached");
51
58
 
52
59
  // Wait for expiry
53
60
  await new Promise((r) => setTimeout(r, 1100));
54
61
 
55
- expect(await guard.check(tenantA, userA, requestId)).toBeNull();
62
+ expect((await guard.check(tenantA, userA, requestId)).status).toBe("acquired");
56
63
  });
57
64
 
58
65
  test("parallel check(): second caller waits for the first's store() instead of racing", async () => {
@@ -65,7 +72,8 @@ describe("idempotency guard", () => {
65
72
 
66
73
  // Request #1 starts — claims the in-progress lock.
67
74
  const first = await guard.check(tenantA, userA, requestId);
68
- expect(first).toBeNull(); // got the lock
75
+ expect(first.status).toBe("acquired");
76
+ if (first.status !== "acquired") throw new Error("expected to acquire the lock");
69
77
 
70
78
  // Request #2 runs concurrently — must block until #1 stores a result.
71
79
  const secondPromise = guard.check(tenantA, userA, requestId);
@@ -80,12 +88,17 @@ describe("idempotency guard", () => {
80
88
  expect(quickResult.done).toBe(false);
81
89
 
82
90
  // Request #1 finishes.
83
- await guard.store(tenantA, userA, requestId, { isSuccess: true, data: { id: 99 } });
91
+ await guard.store(tenantA, userA, requestId, first.token, {
92
+ isSuccess: true,
93
+ data: { id: 99 },
94
+ });
84
95
 
85
- // Request #2 should now see the stored result, not null no duplicate work.
96
+ // Request #2 should now see the stored result, not a fresh acquisition
97
+ // no duplicate work.
86
98
  const second = await secondPromise;
87
- expect(second).not.toBeNull();
88
- expect(JSON.parse(second as string)).toEqual({ isSuccess: true, data: { id: 99 } });
99
+ expect(second.status).toBe("cached");
100
+ if (second.status !== "cached") throw new Error("expected cached value");
101
+ expect(JSON.parse(second.result)).toEqual({ isSuccess: true, data: { id: 99 } });
89
102
  });
90
103
 
91
104
  test("crashed handler: pending marker expires, next caller reclaims the lock", async () => {
@@ -97,11 +110,11 @@ describe("idempotency guard", () => {
97
110
  const requestId = "req-crashed";
98
111
 
99
112
  const first = await guard.check(tenantA, userA, requestId);
100
- expect(first).toBeNull(); // we acquired the lock, then "crash" — never call store()
113
+ expect(first.status).toBe("acquired"); // we acquired the lock, then "crash" — never call store()
101
114
 
102
115
  // After the pending-TTL lapses, a retry should be allowed to take over.
103
116
  const second = await guard.check(tenantA, userA, requestId);
104
- expect(second).toBeNull(); // reclaimed
117
+ expect(second.status).toBe("acquired"); // reclaimed
105
118
  });
106
119
 
107
120
  test("same requestId from different tenant/user does not hit the same cache entry", async () => {
@@ -112,22 +125,103 @@ describe("idempotency guard", () => {
112
125
 
113
126
  // Tenant A / user A owns the request and stores its result.
114
127
  const firstCheck = await guard.check(tenantA, userA, requestId);
115
- expect(firstCheck).toBeNull();
116
- await guard.store(tenantA, userA, requestId, { isSuccess: true, data: { tenant: "A" } });
128
+ expect(firstCheck.status).toBe("acquired");
129
+ if (firstCheck.status !== "acquired") throw new Error("expected to acquire the lock");
130
+ await guard.store(tenantA, userA, requestId, firstCheck.token, {
131
+ isSuccess: true,
132
+ data: { tenant: "A" },
133
+ });
117
134
 
118
135
  // Same requestId, different tenant+user: must be treated as a fresh
119
136
  // request, not see tenant A's cached/pending state.
120
137
  const otherTenantCheck = await guard.check(tenantB, userB, requestId);
121
- expect(otherTenantCheck).toBeNull();
138
+ expect(otherTenantCheck.status).toBe("acquired");
122
139
 
123
140
  // Different user, same tenant: also isolated.
124
141
  const otherUserCheck = await guard.check(tenantA, userB, requestId);
125
- expect(otherUserCheck).toBeNull();
142
+ expect(otherUserCheck.status).toBe("acquired");
126
143
 
127
144
  // Tenant A's own result is still retrievable and unaffected.
128
145
  const ownResult = await guard.check(tenantA, userA, requestId);
129
- expect(ownResult).not.toBeNull();
130
- expect(JSON.parse(ownResult as string)).toEqual({ isSuccess: true, data: { tenant: "A" } });
146
+ expect(ownResult.status).toBe("cached");
147
+ if (ownResult.status !== "cached") throw new Error("expected cached value");
148
+ expect(JSON.parse(ownResult.result)).toEqual({ isSuccess: true, data: { tenant: "A" } });
149
+ });
150
+
151
+ test("bug 1 — wait shorter than the pending lock no longer forces a duplicate re-run", async () => {
152
+ // Same inverted ratio as the pre-fix defaults (waitTimeoutMs < pendingTtl),
153
+ // scaled to sub-second so the test stays fast. Pre-fix, the internal
154
+ // waitTimeoutMs was trusted as-is: the waiter gives up at 100ms and
155
+ // reports "acquired" even though request #1 is still legitimately
156
+ // running and stores its result 200ms later — the double-execute bug.
157
+ // Post-fix, waitTimeoutMs is clamped to stay above pendingTtl, so the
158
+ // waiter keeps polling and observes the real result instead.
159
+ const guard = createIdempotencyGuard(testRedis.redis, {
160
+ pendingTtlSeconds: 1,
161
+ waitTimeoutMs: 100,
162
+ pollIntervalMs: 20,
163
+ });
164
+ const requestId = "req-bug1-inverted-timeout";
165
+
166
+ const first = await guard.check(tenantA, userA, requestId);
167
+ expect(first.status).toBe("acquired");
168
+ if (first.status !== "acquired") throw new Error("expected to acquire the lock");
169
+
170
+ // Request #1 is "slow" — stores well after the old 100ms wait window,
171
+ // but well within pendingTtl (1s).
172
+ const storeAfterDelay = (async () => {
173
+ await new Promise((r) => setTimeout(r, 300));
174
+ await guard.store(tenantA, userA, requestId, first.token, {
175
+ isSuccess: true,
176
+ data: { id: "slow-handler" },
177
+ });
178
+ })();
179
+
180
+ const second = await guard.check(tenantA, userA, requestId);
181
+ await storeAfterDelay;
182
+
183
+ // Must observe request #1's real result — never a second "acquired".
184
+ expect(second.status).toBe("cached");
185
+ if (second.status !== "cached") throw new Error("expected cached value, not a re-run");
186
+ expect(JSON.parse(second.result)).toEqual({ isSuccess: true, data: { id: "slow-handler" } });
187
+ });
188
+
189
+ test("bug 2 (window B) — a reclaimed lock's fresh result survives the original owner's stale store()", async () => {
190
+ const guard = createIdempotencyGuard(testRedis.redis, {
191
+ pendingTtlSeconds: 1, // expire fast so we can force a reclaim quickly
192
+ waitTimeoutMs: 6_000,
193
+ pollIntervalMs: 20,
194
+ });
195
+ const requestId = "req-bug2-window-b";
196
+
197
+ // Original owner acquires, then "hangs" (never stores) past pendingTtl.
198
+ const original = await guard.check(tenantA, userA, requestId);
199
+ expect(original.status).toBe("acquired");
200
+ if (original.status !== "acquired") throw new Error("expected to acquire the lock");
201
+
202
+ // Let the lock expire, then a second run reclaims it and finishes fast.
203
+ await new Promise((r) => setTimeout(r, 1100));
204
+ const reclaimer = await guard.check(tenantA, userA, requestId);
205
+ expect(reclaimer.status).toBe("acquired");
206
+ if (reclaimer.status !== "acquired") throw new Error("expected to reclaim the lock");
207
+ await guard.store(tenantA, userA, requestId, reclaimer.token, {
208
+ isSuccess: true,
209
+ data: { owner: "reclaimer" },
210
+ });
211
+
212
+ // The original (now-stale) run finally "finishes" and tries to store its
213
+ // own, outdated result using its original token.
214
+ await guard.store(tenantA, userA, requestId, original.token, {
215
+ isSuccess: true,
216
+ data: { owner: "original-stale" },
217
+ });
218
+
219
+ // The reclaimer's fresh result must survive — the stale store() must be
220
+ // a no-op, not a silent overwrite.
221
+ const final = await guard.check(tenantA, userA, requestId);
222
+ expect(final.status).toBe("cached");
223
+ if (final.status !== "cached") throw new Error("expected cached value");
224
+ expect(JSON.parse(final.result)).toEqual({ isSuccess: true, data: { owner: "reclaimer" } });
131
225
  });
132
226
  });
133
227
 
@@ -29,20 +29,26 @@ export async function runBatch(
29
29
 
30
30
  // Idempotency: if the same requestId has already been processed, return the
31
31
  // cached result without re-executing. The cache holds the full BatchResult.
32
+ // idempotencyToken is only set when we actually acquired the lock — the
33
+ // corrupted-cache fallthrough below leaves it unset, so finalize() skips
34
+ // store() rather than writing over an entry it never owned.
35
+ let idempotencyToken: string | undefined;
32
36
  if (requestId && idempotency) {
33
- const cached = await idempotency.check(user.tenantId, user.id, requestId);
34
- if (cached) {
35
- const parsed = parseJsonSafe<BatchResult | null>(cached, null);
37
+ const checked = await idempotency.check(user.tenantId, user.id, requestId);
38
+ if (checked.status === "cached") {
39
+ const parsed = parseJsonSafe<BatchResult | null>(checked.result, null);
36
40
  if (parsed) return parsed;
37
41
  // corrupted cache entry — treat as miss, let the request re-run
42
+ } else {
43
+ idempotencyToken = checked.token;
38
44
  }
39
45
  }
40
46
 
41
47
  // Wrap return paths: cache the final result under requestId so retries get
42
48
  // the same answer (both success and failure results are cached).
43
49
  const finalize = async (result: BatchResult): Promise<BatchResult> => {
44
- if (requestId && idempotency) {
45
- await idempotency.store(user.tenantId, user.id, requestId, result);
50
+ if (requestId && idempotency && idempotencyToken) {
51
+ await idempotency.store(user.tenantId, user.id, requestId, idempotencyToken, result);
46
52
  }
47
53
  return result;
48
54
  };
@@ -157,19 +157,23 @@ async function appendDomainEvent(
157
157
 
158
158
  // r.systemScope() handlers must go through ctx.systemDb's guarded methods
159
159
  // (assertTenantMatch / acknowledgeCrossTenant) instead of plain ctx.db —
160
- // "system" mode has no tenant filter at all, so silent ctx.db use there is a
161
- // cross-tenant leak. HandlerContext.db has no way to become optional per
162
- // handler (isSystem is a runtime-only registry lookup, not a type-level
163
- // discriminant), so instead of omitting the key we hand back a Proxy that
164
- // throws on first touch, naming the handler and pointing at ctx.systemDb.
165
- function createSystemScopedDbGuard(handlerType: string): TenantDb {
160
+ // "system" mode has no tenant filter at all, so silent ctx.db or
161
+ // ctx.dbOutsideTransaction use there is a cross-tenant leak. HandlerContext
162
+ // has no way to make either field optional per handler (isSystem is a
163
+ // runtime-only registry lookup, not a type-level discriminant), so instead
164
+ // of omitting the key we hand back a Proxy that throws on first touch,
165
+ // naming the handler and pointing at the matching ctx.systemDb escape hatch.
166
+ function createSystemScopedDbGuard(
167
+ handlerType: string,
168
+ fieldName: "db" | "dbOutsideTransaction",
169
+ hint: string,
170
+ ): TenantDb {
166
171
  return new Proxy({} as TenantDb, {
167
172
  get(_target, prop) {
168
173
  throw new InternalError({
169
174
  message:
170
- `Handler "${handlerType}" is r.systemScope()'d — ctx.db is unavailable ` +
171
- `(it would be unfiltered across every tenant). Use ` +
172
- `ctx.systemDb.assertTenantMatch(...) or ctx.systemDb.acknowledgeCrossTenant(...) ` +
175
+ `Handler "${handlerType}" is r.systemScope()'d — ctx.${fieldName} is unavailable ` +
176
+ `(it would be unfiltered across every tenant). Use ${hint} ` +
173
177
  `instead (attempted to read "${String(prop)}").`,
174
178
  });
175
179
  },
@@ -204,19 +208,41 @@ export async function buildHandlerContext(
204
208
  // the client has disconnected — handlers with many sequential queries skip
205
209
  // the rest of the chain instead of burning DB-CPU for results no one reads.
206
210
  const db = dbSource ? buildTenantScopedDb(dbSource, reqCtx?.signal) : undefined;
207
- const systemDb = isSystem && db ? createUncheckedSystemDb(db) : undefined;
208
- // Exposed as ctx.db below — the internal `db` above stays the real,
209
- // working TenantDb for this function's own use (config/derivatives
210
- // accessors, systemDb construction).
211
- const exposedDb = isSystem && db ? createSystemScopedDbGuard(type) : db;
212
211
  // Unbound pool, tenant-scoped like `db` but never tx-bound — writes
213
212
  // through it survive a rollback of the handler's own transaction. No
214
213
  // AbortSignal here: a client disconnect must not abort a durability write
215
- // that is meant to outlive the request.
214
+ // that is meant to outlive the request. Computed before `systemDb` below
215
+ // so its guarded escape hatch (ctx.systemDb.outsideTransaction) can wrap
216
+ // this same TenantDb instead of a second, independently-built one.
216
217
  const outsideTxSource = resolveDbSource(ctx, undefined);
217
- const dbOutsideTransaction = outsideTxSource
218
+ const rawDbOutsideTransaction = outsideTxSource
218
219
  ? buildTenantScopedDb(outsideTxSource, undefined)
219
220
  : undefined;
221
+ const systemDb =
222
+ isSystem && db ? createUncheckedSystemDb(db, rawDbOutsideTransaction) : undefined;
223
+ // Exposed as ctx.db below — the internal `db` above stays the real,
224
+ // working TenantDb for this function's own use (config/derivatives
225
+ // accessors, systemDb construction).
226
+ const exposedDb =
227
+ isSystem && db
228
+ ? createSystemScopedDbGuard(
229
+ type,
230
+ "db",
231
+ "ctx.systemDb.assertTenantMatch(...) or ctx.systemDb.acknowledgeCrossTenant(...)",
232
+ )
233
+ : db;
234
+ // Same fail-closed treatment as `exposedDb` — a system handler reaching
235
+ // ctx.dbOutsideTransaction directly would be a second, unguarded door to
236
+ // the same cross-tenant access ctx.db closes off above.
237
+ const dbOutsideTransaction =
238
+ isSystem && rawDbOutsideTransaction
239
+ ? createSystemScopedDbGuard(
240
+ type,
241
+ "dbOutsideTransaction",
242
+ "ctx.systemDb.outsideTransaction.assertTenantMatch(...) or " +
243
+ "ctx.systemDb.outsideTransaction.acknowledgeCrossTenant(...)",
244
+ )
245
+ : rawDbOutsideTransaction;
220
246
  const log = context.log?.child({
221
247
  handler: type,
222
248
  tenantId: user.tenantId,
@@ -1,14 +1,31 @@
1
1
  import type Redis from "ioredis";
2
+ import { InternalError } from "../errors";
3
+ import { generateId } from "../utils";
2
4
  import { RedisKeys } from "./redis-keys";
3
5
 
6
+ // Discriminated so a truthy "acquired" object can never be misread as a
7
+ // cache hit by a callsite doing `if (result)` — the caller must switch on
8
+ // `status`.
9
+ export type IdempotencyCheckResult =
10
+ | { readonly status: "cached"; readonly result: string }
11
+ | { readonly status: "acquired"; readonly token: string };
12
+
4
13
  export type IdempotencyGuard = {
5
- check(tenantId: string, userId: string, requestId: string): Promise<string | null>;
6
- store(tenantId: string, userId: string, requestId: string, result: unknown): Promise<void>;
14
+ check(tenantId: string, userId: string, requestId: string): Promise<IdempotencyCheckResult>;
15
+ store(
16
+ tenantId: string,
17
+ userId: string,
18
+ requestId: string,
19
+ token: string,
20
+ result: unknown,
21
+ ): Promise<void>;
7
22
  };
8
23
 
9
- // Sentinel stored under the key while the handler is running. A second
10
- // request that sees this waits for the real result instead of racing.
11
- const PENDING_MARKER = "__pending__";
24
+ // Sentinel prefix stored under the key while the handler is running. Each
25
+ // acquisition appends a unique token so store() can later prove it still
26
+ // owns the lock it started with (see storeScript below) instead of blindly
27
+ // overwriting whatever is currently there.
28
+ const PENDING_PREFIX = "__pending__:";
12
29
 
13
30
  export function createIdempotencyGuard(
14
31
  redis: Redis,
@@ -25,15 +42,40 @@ export function createIdempotencyGuard(
25
42
  // handler doesn't permanently block retries, long enough to cover normal
26
43
  // batch durations.
27
44
  const pendingTtl = options.pendingTtlSeconds ?? 30;
28
- const waitTimeoutMs = options.waitTimeoutMs ?? 25_000;
45
+ // Must stay comfortably above pendingTtl: a wait shorter than the lock's
46
+ // own TTL makes a retry give up and re-run the handler while the original
47
+ // call is still legitimately in flight — the exact case this lock exists
48
+ // to prevent. Clamped rather than trusted so a misconfigured explicit
49
+ // option can't reintroduce that inversion.
50
+ const waitTimeoutMs = Math.max(options.waitTimeoutMs ?? 35_000, pendingTtl * 1000 + 5_000);
29
51
  const pollIntervalMs = options.pollIntervalMs ?? 50;
30
52
  const prefix = RedisKeys.idempotency;
31
53
 
54
+ // Atomic compare-and-swap: only persist the result if the key still holds
55
+ // the exact pending token this run acquired. If a parallel retry reclaimed
56
+ // an expired lock in the meantime, this is a no-op — the reclaiming run
57
+ // owns the key now and will persist the authoritative result itself.
58
+ const storeScript = `
59
+ if redis.call("get", KEYS[1]) == ARGV[1] then
60
+ redis.call("set", KEYS[1], ARGV[2], "EX", ARGV[3])
61
+ return 1
62
+ else
63
+ return 0
64
+ end
65
+ `;
66
+
67
+ async function tryAcquire(key: string): Promise<string | null> {
68
+ const token = `${PENDING_PREFIX}${generateId()}`;
69
+ const acquired = await redis.set(key, token, "EX", pendingTtl, "NX");
70
+ return acquired === "OK" ? token : null;
71
+ }
72
+
32
73
  return {
33
74
  // Returns:
34
- // null — caller owns the in-progress lock, proceed to run the handler
35
- // and then call store() with the real result.
36
- // string — serialized cached result from a concurrent or prior request.
75
+ // { status: "acquired", token } — caller owns the in-progress lock,
76
+ // proceed to run the handler and then call store() with this token.
77
+ // { status: "cached", result } — serialized result from a concurrent
78
+ // or prior request; do not run the handler.
37
79
  //
38
80
  // The old behaviour (pure GET + SET-NX-store) let two parallel requests
39
81
  // both see a cache miss, both execute side-effects, and only one persist
@@ -42,40 +84,59 @@ export function createIdempotencyGuard(
42
84
  async check(tenantId, userId, requestId) {
43
85
  const key = `${prefix}${tenantId}:${userId}:${requestId}`;
44
86
 
45
- // Try to acquire the in-progress lock.
46
- const acquired = await redis.set(key, PENDING_MARKER, "EX", pendingTtl, "NX");
47
- if (acquired === "OK") return null;
87
+ const token = await tryAcquire(key);
88
+ if (token) return { status: "acquired", token };
48
89
 
49
90
  // Lost the race. Poll until the lock holder stores a result, or the
50
91
  // lock expires (handler crashed) and we can try again.
51
92
  const deadline = Date.now() + waitTimeoutMs;
52
93
  while (Date.now() < deadline) {
53
94
  const value = await redis.get(key);
54
- if (value === null) {
55
- // Lock expired before a result was stored — try to claim it
56
- // ourselves and proceed as the new owner.
57
- const reclaimed = await redis.set(key, PENDING_MARKER, "EX", pendingTtl, "NX");
58
- if (reclaimed === "OK") return null;
95
+ if (value === null || value.startsWith(PENDING_PREFIX)) {
96
+ if (value === null) {
97
+ // Lock expired before a result was stored — try to claim it
98
+ // ourselves and proceed as the new owner.
99
+ const reclaimed = await tryAcquire(key);
100
+ if (reclaimed) return { status: "acquired", token: reclaimed };
101
+ }
102
+ await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
59
103
  continue;
60
104
  }
61
- if (value !== PENDING_MARKER) return value;
62
- await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
105
+ return { status: "cached", result: value };
63
106
  }
64
107
 
65
- // Gave up waiting. Treat as a fresh request forces the caller to
66
- // re-run the handler rather than hang indefinitely.
67
- return null;
108
+ // Gave up waiting. By construction waitTimeoutMs > pendingTtl, so the
109
+ // lock must have expired already make one last claim attempt rather
110
+ // than silently reporting ownership we never acquired (that would
111
+ // reintroduce the double-execute bug). If even that loses the race,
112
+ // fail loudly instead of running the handler a second time.
113
+ const finalValue = await redis.get(key);
114
+ if (finalValue !== null && !finalValue.startsWith(PENDING_PREFIX)) {
115
+ return { status: "cached", result: finalValue };
116
+ }
117
+ const lastResort = await tryAcquire(key);
118
+ if (lastResort) return { status: "acquired", token: lastResort };
119
+ throw new InternalError({
120
+ message: `idempotency lock contention: gave up waiting for requestId ${requestId}`,
121
+ });
68
122
  },
69
123
 
70
- async store(tenantId, userId, requestId, result) {
71
- // Overwrite the pending marker with the real result. Plain SET (no NX)
72
- // on purpose: we own the lock; writing the result is the final step.
73
- await redis.set(
74
- `${prefix}${tenantId}:${userId}:${requestId}`,
124
+ async store(tenantId, userId, requestId, token, result) {
125
+ const key = `${prefix}${tenantId}:${userId}:${requestId}`;
126
+ // @cast-boundary db-operator Lua EVAL return type is untyped in ioredis
127
+ const written = (await redis.eval(
128
+ storeScript,
129
+ 1,
130
+ key,
131
+ token,
75
132
  JSON.stringify(result),
76
- "EX",
77
- ttl,
78
- );
133
+ String(ttl),
134
+ )) as number;
135
+ // written === 0 means another process reclaimed this key after our
136
+ // lock expired and is (or already did) persist its own result —
137
+ // silently skipping here is the fix: the old code did an unconditional
138
+ // SET and could stomp that fresher result with our stale one.
139
+ void written;
79
140
  },
80
141
  };
81
142
  }