@ultimat3/action 1.2.0 → 3.0.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.
@@ -0,0 +1,148 @@
1
+ /**
2
+ * The default idempotency store: process memory, bounded and swept. Correct for one web process
3
+ * and for tests, and refused at registration under a `shared` declaration — its `scope` says so.
4
+ * Shaped after `@ultimat3/http`'s `memoryRateLimitStore`, including the deliberate eviction order.
5
+ */
6
+ import { uuid } from '@ultimat3/core';
7
+ import type {
8
+ IdempotencyFailure,
9
+ IdempotencyRecord,
10
+ IdempotencyReservation,
11
+ IdempotencyScope,
12
+ IdempotencyStore,
13
+ } from './idempotency';
14
+
15
+ /**
16
+ * How long a key is remembered. A day is the window every payment API this shape exists to serve
17
+ * publishes, and it is a *bound*, not a promise of forever: a caller retrying two days later is
18
+ * making a new request, and the store says so by treating the record as missing.
19
+ */
20
+ export const DEFAULT_IDEMPOTENCY_WINDOW_MS = 24 * 60 * 60 * 1000;
21
+
22
+ /**
23
+ * Hard bound on tracked keys. A key is `action:caller-supplied-string`, so its cardinality is the
24
+ * write rate, not the user count — at 500 idempotent writes a second an unbounded map is 43M
25
+ * immortal entries a day and an OOM. At ~250 bytes an entry this cap is a few megabytes, held.
26
+ */
27
+ export const DEFAULT_MAX_IDEMPOTENCY_KEYS = 10_000;
28
+
29
+ /** An idle store still sweeps this often, so a burst's records do not sit until the next one. */
30
+ const SWEEP_EVERY_MS = 60_000;
31
+
32
+ export interface MemoryIdempotencyStoreOptions {
33
+ readonly windowMs?: number | undefined;
34
+ readonly maxKeys?: number | undefined;
35
+ /** Injectable so a test can age a record without sleeping. */
36
+ readonly now?: (() => number) | undefined;
37
+ }
38
+
39
+ export class MemoryIdempotencyStore implements IdempotencyStore {
40
+ /** Declared, never inferred: this map is this process's and nothing else can reach it. */
41
+ readonly scope: IdempotencyScope = 'process';
42
+ readonly windowMs: number;
43
+ readonly #maxKeys: number;
44
+ readonly #evictTo: number;
45
+ readonly #now: () => number;
46
+ readonly #records = new Map<string, IdempotencyRecord>();
47
+ #lastSweepMs = Number.NEGATIVE_INFINITY;
48
+
49
+ constructor(options: MemoryIdempotencyStoreOptions = {}) {
50
+ this.windowMs = Math.max(1, Math.floor(options.windowMs ?? DEFAULT_IDEMPOTENCY_WINDOW_MS));
51
+ this.#maxKeys = Math.max(1, Math.floor(options.maxKeys ?? DEFAULT_MAX_IDEMPOTENCY_KEYS));
52
+ // Batched down to 90% so the eviction sort is paid once per 10% of the cap, not per write.
53
+ this.#evictTo = Math.max(1, Math.floor(this.#maxKeys * 0.9));
54
+ this.#now = options.now ?? ((): number => Date.now());
55
+ }
56
+
57
+ /** Records tracked right now — the bound, observable. */
58
+ get size(): number {
59
+ return this.#records.size;
60
+ }
61
+
62
+ reserve(key: string, requestHash: string): Promise<IdempotencyReservation> {
63
+ const nowMs = this.#now();
64
+ const existing = this.#records.get(key);
65
+ // Expired is missing. A record past the window answers exactly as a first-ever key does, so
66
+ // reclaiming it here is what makes the window mean something rather than being a comment.
67
+ if (existing !== undefined && !this.#expired(existing, nowMs)) {
68
+ return Promise.resolve({ record: existing, created: false });
69
+ }
70
+ const record: IdempotencyRecord = {
71
+ id: uuid(),
72
+ key,
73
+ requestHash,
74
+ status: 'in-flight',
75
+ value: undefined,
76
+ createdAt: nowMs,
77
+ };
78
+ this.#records.set(key, record);
79
+ this.#maintain(nowMs);
80
+ return Promise.resolve({ record, created: true });
81
+ }
82
+
83
+ /**
84
+ * Both settlements are FENCED on `in-flight`, as `SQL_IDEMPOTENCY_SETTLE` is and as
85
+ * `@ultimat3/jobs`' `SQL_ACK` is: a record past the window is reclaimed by the next caller, so a
86
+ * straggler from the reservation before it would otherwise overwrite a record it no longer owns
87
+ * and the next replay would answer one request with another's value. Both stores fence, or the
88
+ * guarantee is whichever store the deployment happens to install.
89
+ */
90
+ settle(key: string, value: unknown): Promise<void> {
91
+ const existing = this.#records.get(key);
92
+ if (existing?.status === 'in-flight') {
93
+ this.#records.set(key, { ...existing, status: 'settled', value });
94
+ }
95
+ return Promise.resolve();
96
+ }
97
+
98
+ fail(key: string, failure: IdempotencyFailure): Promise<void> {
99
+ const existing = this.#records.get(key);
100
+ if (existing?.status === 'in-flight') {
101
+ this.#records.set(key, { ...existing, status: 'failed', value: undefined, failure });
102
+ }
103
+ return Promise.resolve();
104
+ }
105
+
106
+ release(key: string): Promise<void> {
107
+ this.#records.delete(key);
108
+ return Promise.resolve();
109
+ }
110
+
111
+ get(key: string): Promise<IdempotencyRecord | undefined> {
112
+ const record = this.#records.get(key);
113
+ if (record === undefined || this.#expired(record, this.#now()))
114
+ return Promise.resolve(undefined);
115
+ return Promise.resolve(record);
116
+ }
117
+
118
+ #expired(record: IdempotencyRecord, nowMs: number): boolean {
119
+ return nowMs - record.createdAt >= this.windowMs;
120
+ }
121
+
122
+ /**
123
+ * Sweep, then evict — and the eviction order is part of the guarantee. Expired records go for
124
+ * free, because they already answer as missing. Only if that is not enough does the cap take
125
+ * live state, and then **`in-flight` records are the last to go**: one of those is the
126
+ * reservation that stops a concurrent duplicate from running the handler a second time, so
127
+ * dropping it is the double charge this store exists to prevent. That is the mirror of
128
+ * `memoryRateLimitStore` evicting the fullest bucket first — never swap either for an LRU.
129
+ */
130
+ #maintain(nowMs: number): void {
131
+ if (this.#records.size <= this.#maxKeys && nowMs - this.#lastSweepMs < SWEEP_EVERY_MS) return;
132
+ this.#lastSweepMs = nowMs;
133
+ for (const [key, record] of this.#records) {
134
+ if (this.#expired(record, nowMs)) this.#records.delete(key);
135
+ }
136
+ if (this.#records.size <= this.#maxKeys) return;
137
+ const settled = [...this.#records.entries()]
138
+ .filter(([, record]) => record.status !== 'in-flight')
139
+ .sort((a, b) => a[1].createdAt - b[1].createdAt);
140
+ for (const [key] of settled) {
141
+ if (this.#records.size <= this.#evictTo) break;
142
+ this.#records.delete(key);
143
+ }
144
+ // If nothing settled is left the map may still exceed the cap. That is deliberate and it is
145
+ // bounded by in-flight concurrency, not by the write rate — and every one of those records
146
+ // becomes sweepable the moment it ages past the window.
147
+ }
148
+ }
@@ -0,0 +1,271 @@
1
+ /**
2
+ * The shared idempotency store: one Postgres table, `insert … on conflict` for the atomicity.
3
+ * This is the store the memory default's own comment has promised since the primitive shipped —
4
+ * without it, `replicas: 3` means a retry that lands elsewhere re-runs a committed handler.
5
+ * Statements are spelled out so an agent can run the exact one it saw in a log.
6
+ */
7
+ import { logger, uuid } from '@ultimat3/core';
8
+ import type {
9
+ IdempotencyFailure,
10
+ IdempotencyRecord,
11
+ IdempotencyReservation,
12
+ IdempotencyScope,
13
+ IdempotencyStatus,
14
+ IdempotencyStore,
15
+ } from './idempotency';
16
+ import { DEFAULT_IDEMPOTENCY_WINDOW_MS } from './idempotency-memory';
17
+
18
+ /**
19
+ * The one thing this store needs from the DB layer, declared structurally rather than imported.
20
+ * `@ultimat3/jobs` declares the same shape for the same reason: neither package owns the other's
21
+ * connection, and neither depends on a database package.
22
+ *
23
+ * **`Bun.sql` does not satisfy it** — verified against Bun 1.3.14: `Bun.sql.query` is `undefined`.
24
+ * `Bun.sql` is a tagged template whose positional form is `unsafe`, so `{ executor: Bun.sql }`
25
+ * would `TypeError` on the first reservation, which is the one call path that must not fail open.
26
+ * What satisfies it is a client that already speaks `(text, values)`, wrapped in one line —
27
+ * `@ultimat3/cli`'s `pgExecutorFor(client)` over `@ultimat3/db`'s `DbClient.query({ text, values })`
28
+ * is the framework's own — or a transaction handle, which is a client on its own connection.
29
+ */
30
+ export interface PgExecutor {
31
+ query<R>(sql: string, params: readonly unknown[]): Promise<readonly R[]>;
32
+ }
33
+
34
+ /**
35
+ * The store's ONE install point, applied the way `SQL_JOBS_TABLE` is — by the boot, not by an app
36
+ * migration: `startQueue` runs both on every start, so `x dev`, the container's `web`/`worker` and
37
+ * the release-phase `ROLE=migrate` all apply it. `create table if not exists` is a no-op against a
38
+ * database that already has it, so a new column is added by `alter table … add column if not
39
+ * exists` and never by editing the `create`.
40
+ */
41
+ export const SQL_IDEMPOTENCY_TABLE = `
42
+ create table if not exists x_idempotency (
43
+ key text primary key,
44
+ id uuid not null,
45
+ request_hash text not null,
46
+ status text not null default 'in-flight',
47
+ value jsonb,
48
+ failure jsonb,
49
+ created_at timestamptz not null default now()
50
+ );
51
+
52
+ create index if not exists x_idempotency_created_at_idx on x_idempotency (created_at);
53
+ `;
54
+
55
+ /**
56
+ * The reservation, atomic in one statement. The `do update` fires ONLY for a row already outside
57
+ * the window — which answers as a missing one — so a returned row always means this caller owns
58
+ * the reservation and must run the handler. No row back means a live record exists and belongs to
59
+ * someone else.
60
+ */
61
+ export const SQL_IDEMPOTENCY_RESERVE = `
62
+ insert into x_idempotency (key, id, request_hash, status)
63
+ values ($1, $2, $3, 'in-flight')
64
+ on conflict (key) do update
65
+ set id = excluded.id,
66
+ request_hash = excluded.request_hash,
67
+ status = 'in-flight',
68
+ value = null,
69
+ failure = null,
70
+ created_at = now()
71
+ where x_idempotency.created_at < now() - make_interval(secs => $4::double precision)
72
+ returning key, id, request_hash, status, value, failure,
73
+ (extract(epoch from created_at) * 1000)::bigint as created_at
74
+ `;
75
+
76
+ export const SQL_IDEMPOTENCY_GET = `
77
+ select key, id, request_hash, status, value, failure,
78
+ (extract(epoch from created_at) * 1000)::bigint as created_at
79
+ from x_idempotency
80
+ where key = $1
81
+ and created_at >= now() - make_interval(secs => $2::double precision)
82
+ `;
83
+
84
+ /**
85
+ * `and status = 'in-flight'` is a FENCE, not a filter — the one `@ultimat3/jobs`' `SQL_ACK` carries
86
+ * as `and state = 'running'`, for the same failure. A reservation whose window lapsed is reclaimed
87
+ * by the next caller (`do update` above), so a straggler from the first one arriving afterwards
88
+ * overwrote a record it no longer owned: the next replay under that key answered a retry with a
89
+ * value produced for a different request. `returning key` is what makes the refusal observable —
90
+ * an update matching no row is indistinguishable from one that matched, otherwise.
91
+ */
92
+ export const SQL_IDEMPOTENCY_SETTLE = `
93
+ update x_idempotency set status = 'settled', value = $2::jsonb, failure = null
94
+ where key = $1 and status = 'in-flight'
95
+ returning key
96
+ `;
97
+
98
+ export const SQL_IDEMPOTENCY_FAIL = `
99
+ update x_idempotency set status = 'failed', value = null, failure = $2::jsonb
100
+ where key = $1 and status = 'in-flight'
101
+ returning key
102
+ `;
103
+
104
+ export const SQL_IDEMPOTENCY_RELEASE = `delete from x_idempotency where key = $1`;
105
+
106
+ export const SQL_IDEMPOTENCY_PURGE = `
107
+ delete from x_idempotency where created_at < now() - make_interval(secs => $1::double precision)
108
+ `;
109
+
110
+ interface IdempotencyRow {
111
+ readonly key: string;
112
+ readonly id: string;
113
+ readonly request_hash: string;
114
+ readonly status: string;
115
+ readonly value: unknown;
116
+ readonly failure: unknown;
117
+ /** `bigint`, which every Postgres client hands back as a string. */
118
+ readonly created_at: number | string;
119
+ }
120
+
121
+ export interface PostgresIdempotencyStoreOptions {
122
+ readonly executor: PgExecutor;
123
+ readonly windowMs?: number | undefined;
124
+ }
125
+
126
+ export interface PostgresIdempotencyStore extends IdempotencyStore {
127
+ readonly scope: IdempotencyScope;
128
+ readonly windowMs: number;
129
+ /**
130
+ * Delete every record past the window, and answer how many. The table is the one part of this
131
+ * store that does not bound itself — Postgres forgets nothing on its own — so an app runs this
132
+ * from a `task` on whatever cadence its write rate deserves.
133
+ */
134
+ purgeExpired(): Promise<number>;
135
+ }
136
+
137
+ /**
138
+ * **The boot installs this for you — an app declares the scope and nothing else.**
139
+ * `@ultimat3/cli`'s `startServices` builds a `PgExecutor` from the client it already resolved and
140
+ * calls `setIdempotencyStore(postgresIdempotencyStore({ executor }))` before `loadApp`, so the
141
+ * store is in place by the time `registerAction` evaluates a declaration against it. All an app
142
+ * owes is the one line `x new` scaffolds into `apps/web/server.ts`:
143
+ *
144
+ * ```ts
145
+ * configureIdempotency({ scope: 'shared' });
146
+ * ```
147
+ *
148
+ * Installing one by hand is for a host that boots the framework itself, and it needs a real
149
+ * `PgExecutor` — never `Bun.sql`, which has no `.query`. Wrap the client this process already
150
+ * opened, so a second pool is not opened against a URL the boot resolved once:
151
+ *
152
+ * ```ts
153
+ * const client = db();
154
+ * setIdempotencyStore(
155
+ * postgresIdempotencyStore({
156
+ * executor: { query: (text, values) => client.query({ text, values }) },
157
+ * }),
158
+ * );
159
+ * ```
160
+ */
161
+ export function postgresIdempotencyStore(
162
+ options: PostgresIdempotencyStoreOptions,
163
+ ): PostgresIdempotencyStore {
164
+ const windowMs = Math.max(1, Math.floor(options.windowMs ?? DEFAULT_IDEMPOTENCY_WINDOW_MS));
165
+ const windowSecs = windowMs / 1000;
166
+ const exec = options.executor;
167
+
168
+ const fetch = async (key: string): Promise<IdempotencyRecord | undefined> => {
169
+ const rows = await exec.query<IdempotencyRow>(SQL_IDEMPOTENCY_GET, [key, windowSecs]);
170
+ const row = rows[0];
171
+ return row === undefined ? undefined : toRecord(row);
172
+ };
173
+
174
+ return {
175
+ scope: 'shared',
176
+ windowMs,
177
+
178
+ async reserve(key, requestHash): Promise<IdempotencyReservation> {
179
+ // A bounded loop, not a `while (true)`: the only way the insert and the read can both come
180
+ // back empty is a concurrent `release`/`purgeExpired` deleting the row between them, and a
181
+ // caller losing that race twice is a store nobody should keep retrying against.
182
+ for (let attempt = 0; attempt < 3; attempt += 1) {
183
+ const claimed = await exec.query<IdempotencyRow>(SQL_IDEMPOTENCY_RESERVE, [
184
+ key,
185
+ uuid(),
186
+ requestHash,
187
+ windowSecs,
188
+ ]);
189
+ const row = claimed[0];
190
+ if (row !== undefined) return { record: toRecord(row), created: true };
191
+ const existing = await fetch(key);
192
+ if (existing !== undefined) return { record: existing, created: false };
193
+ }
194
+ // Reported as a fresh reservation rather than a throw would be wrong in the one direction
195
+ // that matters, so this is the honest answer: the caller sees the in-flight refusal.
196
+ return {
197
+ record: {
198
+ id: uuid(),
199
+ key,
200
+ requestHash,
201
+ status: 'in-flight',
202
+ value: undefined,
203
+ createdAt: Date.now(),
204
+ },
205
+ created: false,
206
+ };
207
+ },
208
+
209
+ async settle(key, value): Promise<void> {
210
+ const rows = await exec.query(SQL_IDEMPOTENCY_SETTLE, [key, JSON.stringify(value ?? null)]);
211
+ fenced(rows, key, 'settle');
212
+ },
213
+
214
+ async fail(key, failure: IdempotencyFailure): Promise<void> {
215
+ const rows = await exec.query(SQL_IDEMPOTENCY_FAIL, [key, JSON.stringify(failure)]);
216
+ fenced(rows, key, 'fail');
217
+ },
218
+
219
+ async release(key): Promise<void> {
220
+ await exec.query(SQL_IDEMPOTENCY_RELEASE, [key]);
221
+ },
222
+
223
+ get: fetch,
224
+
225
+ async purgeExpired(): Promise<number> {
226
+ const rows = await exec.query<{ readonly key: string }>(
227
+ `${SQL_IDEMPOTENCY_PURGE} returning key`,
228
+ [windowSecs],
229
+ );
230
+ return rows.length;
231
+ },
232
+ };
233
+ }
234
+
235
+ /**
236
+ * Logged, never thrown. A settlement lands after the handler has committed, so raising here would
237
+ * turn a durable write into the caller's error — the rule `withIdempotency` already follows for a
238
+ * store that refuses. An operator still has to see it: a fenced settle means this attempt's record
239
+ * belongs to another reservation, and the value this attempt produced is stored nowhere.
240
+ */
241
+ function fenced(rows: readonly unknown[], key: string, statement: 'settle' | 'fail'): void {
242
+ if (rows.length > 0) return;
243
+ logger.warn('action.idempotency.settlement-fenced', { key, statement });
244
+ }
245
+
246
+ function toRecord(row: IdempotencyRow): IdempotencyRecord {
247
+ const failure = toFailure(row.failure);
248
+ return {
249
+ id: row.id,
250
+ key: row.key,
251
+ requestHash: row.request_hash,
252
+ status: row.status as IdempotencyStatus,
253
+ value: row.value,
254
+ ...(failure === undefined ? {} : { failure }),
255
+ createdAt: Number(row.created_at),
256
+ };
257
+ }
258
+
259
+ /** `jsonb` comes back as parsed JSON, so this is a shape check and never a second parse. */
260
+ function toFailure(value: unknown): IdempotencyFailure | undefined {
261
+ if (typeof value !== 'object' || value === null) return undefined;
262
+ const record = value as Record<string, unknown>;
263
+ const code = record['code'];
264
+ const cause = record['cause'];
265
+ const fix = record['fix'];
266
+ if (typeof code !== 'string' || typeof cause !== 'string' || typeof fix !== 'string') {
267
+ return undefined;
268
+ }
269
+ const docs = record['docs'];
270
+ return { code, cause, fix, ...(typeof docs === 'string' ? { docs } : {}) };
271
+ }
@@ -1,19 +1,50 @@
1
1
  /**
2
- * Idempotency for actions marked `idempotent`. A retried key replays the first
3
- * response; a concurrent duplicate is refused rather than run twice, because a
2
+ * Idempotency for actions marked `idempotent`: the store seam, where the deployment declares
3
+ * what it needs of one, and the replay-or-run gate. A retried key replays the first OUTCOME —
4
+ * a value or a failure — and a concurrent duplicate is refused rather than run twice, because a
4
5
  * double charge is worse than a 409.
5
6
  */
6
- import { uuid } from '@ultimat3/core';
7
- import { IdempotencyConflictError } from './errors';
7
+ import { isUltimateError, logger } from '@ultimat3/core';
8
+ import {
9
+ IdempotencyConflictError,
10
+ IdempotencyNotSharedError,
11
+ IdempotencyReplayedFailureError,
12
+ } from './errors';
13
+ import { MemoryIdempotencyStore } from './idempotency-memory';
8
14
  import { fingerprint } from './stable';
9
15
 
16
+ /**
17
+ * Where a store's records live. Declared by the driver, never inferred — the same rule
18
+ * `@ultimat3/http`'s `RateLimitStore.scope` follows, and for a worse failure: N replicas each
19
+ * holding their own records means the retry that lands on replica B has never seen the key, so
20
+ * the handler runs a second time and the card is charged twice. Silently, with `x verify` green.
21
+ */
22
+ export type IdempotencyScope = 'process' | 'shared';
23
+
24
+ /**
25
+ * What a failed attempt left behind, flat enough to survive a database round trip. An `Error`
26
+ * object cannot: the record outlives the process that produced it, so the replay is rebuilt from
27
+ * the four fields `UltimateError` already promises rather than from a serialized stack.
28
+ */
29
+ export interface IdempotencyFailure {
30
+ readonly code: string;
31
+ readonly cause: string;
32
+ readonly fix: string;
33
+ readonly docs?: string | undefined;
34
+ }
35
+
36
+ export type IdempotencyStatus = 'in-flight' | 'settled' | 'failed';
37
+
10
38
  export interface IdempotencyRecord {
11
39
  readonly id: string;
12
40
  readonly key: string;
13
41
  /** Fingerprint of the parsed input — a reused key with a new payload is a bug. */
14
42
  readonly requestHash: string;
15
- readonly status: 'in-flight' | 'settled';
43
+ readonly status: IdempotencyStatus;
16
44
  readonly value: unknown;
45
+ /** Present exactly when `status === 'failed'` — what the replay re-throws. */
46
+ readonly failure?: IdempotencyFailure | undefined;
47
+ /** Epoch milliseconds. The store's own dedupe window is measured from here. */
17
48
  readonly createdAt: number;
18
49
  }
19
50
 
@@ -24,53 +55,58 @@ export interface IdempotencyReservation {
24
55
  }
25
56
 
26
57
  export interface IdempotencyStore {
58
+ /**
59
+ * Where these records live. Optional only so an existing external implementation still
60
+ * type-checks; an absent scope cannot be checked, so `assertIdempotencyScope` refuses it
61
+ * exactly as it refuses a wrong one — the same rule `RateLimiter.buckets` follows.
62
+ */
63
+ readonly scope?: IdempotencyScope | undefined;
64
+ /**
65
+ * How long a key is remembered. Outside it a record answers as a missing one, which is the
66
+ * only thing that keeps the store bounded: a key is caller-supplied, so an unbounded store
67
+ * is one immortal entry per write, forever.
68
+ */
69
+ readonly windowMs?: number | undefined;
27
70
  /** Atomically create-or-fetch the record for `key`. The atomicity is the point. */
28
71
  reserve(key: string, requestHash: string): Promise<IdempotencyReservation>;
29
72
  settle(key: string, value: unknown): Promise<void>;
30
- /** Drop a reservation whose handler threw, so a retry can run. */
73
+ /**
74
+ * Settle a FAILURE, so the retry replays it instead of re-running a handler that may already
75
+ * have committed. Optional so an existing store still type-checks — and when it is absent the
76
+ * gate leaves the reservation standing rather than releasing it, because refusing the retry is
77
+ * the safe answer and re-running it is the double charge.
78
+ */
79
+ fail?(key: string, failure: IdempotencyFailure): Promise<void>;
80
+ /** Drop a reservation, so a retry can run. Only ever correct BEFORE the handler starts. */
31
81
  release(key: string): Promise<void>;
32
82
  get(key: string): Promise<IdempotencyRecord | undefined>;
33
83
  }
34
84
 
35
85
  /**
36
- * Default store: process memory. Correct for one web process and for tests;
37
- * production swaps in a Postgres-backed store behind the same interface (a
38
- * single `insert ... on conflict do nothing returning` gives the same atomicity).
86
+ * What the DEPLOYMENT requires of a store, against what the store provides. Two halves checked
87
+ * once at registration, exactly as `RateLimitConfig.scope` is checked against `RateLimitStore`.
39
88
  */
40
- export class MemoryIdempotencyStore implements IdempotencyStore {
41
- readonly #records = new Map<string, IdempotencyRecord>();
42
-
43
- async reserve(key: string, requestHash: string): Promise<IdempotencyReservation> {
44
- const existing = this.#records.get(key);
45
- if (existing !== undefined) return { record: existing, created: false };
46
- const record: IdempotencyRecord = {
47
- id: uuid(),
48
- key,
49
- requestHash,
50
- status: 'in-flight',
51
- value: undefined,
52
- createdAt: Date.now(),
53
- };
54
- this.#records.set(key, record);
55
- return { record, created: true };
56
- }
89
+ export interface IdempotencyConfig {
90
+ readonly scope: IdempotencyScope;
91
+ }
57
92
 
58
- async settle(key: string, value: unknown): Promise<void> {
59
- const existing = this.#records.get(key);
60
- if (existing === undefined) return;
61
- this.#records.set(key, { ...existing, status: 'settled', value });
62
- }
93
+ /**
94
+ * One process is the only thing a framework can promise without being told; an app that runs
95
+ * more than one says so, and brings the store that makes it true.
96
+ */
97
+ export const DEFAULT_IDEMPOTENCY_CONFIG: IdempotencyConfig = Object.freeze({ scope: 'process' });
63
98
 
64
- async release(key: string): Promise<void> {
65
- this.#records.delete(key);
66
- }
99
+ let config: IdempotencyConfig = DEFAULT_IDEMPOTENCY_CONFIG;
100
+ let defaultStore: IdempotencyStore = new MemoryIdempotencyStore();
67
101
 
68
- async get(key: string): Promise<IdempotencyRecord | undefined> {
69
- return this.#records.get(key);
70
- }
102
+ /** Declared at boot, before `registerActions()`. Nothing infers a replica count. */
103
+ export function configureIdempotency(next: IdempotencyConfig): void {
104
+ config = next;
71
105
  }
72
106
 
73
- let defaultStore: IdempotencyStore = new MemoryIdempotencyStore();
107
+ export function idempotencyConfig(): IdempotencyConfig {
108
+ return config;
109
+ }
74
110
 
75
111
  export function setIdempotencyStore(store: IdempotencyStore): void {
76
112
  defaultStore = store;
@@ -80,9 +116,26 @@ export function getIdempotencyStore(): IdempotencyStore {
80
116
  return defaultStore;
81
117
  }
82
118
 
83
- /** Keys are namespaced per action: the same key under two actions is two keys. */
84
- export function idempotencyKeyFor(actionName: string, key: string): string {
85
- return `${actionName}:${key}`;
119
+ /** Test-only. A process configures its idempotency once at boot and never reconfigures it. */
120
+ export function resetIdempotency(): void {
121
+ config = DEFAULT_IDEMPOTENCY_CONFIG;
122
+ defaultStore = new MemoryIdempotencyStore();
123
+ }
124
+
125
+ /**
126
+ * Boot, never the first request — `registerAction` calls it, which every registration path funnels
127
+ * through and which necessarily runs before a route is mounted. A per-process store under a
128
+ * `'shared'` declaration is not a smaller guarantee, it is no guarantee: the retry that lands on
129
+ * another replica finds no record and re-runs the handler, so an `idempotent: true` action charges
130
+ * twice with nothing in any log to say it did. A store that declares no scope is refused too —
131
+ * what cannot be shown to be shared is not assumed to be.
132
+ */
133
+ export function assertIdempotencyScope(
134
+ declared: IdempotencyConfig = idempotencyConfig(),
135
+ store: IdempotencyStore = getIdempotencyStore(),
136
+ ): void {
137
+ if (declared.scope !== 'shared') return;
138
+ if (store.scope !== 'shared') throw new IdempotencyNotSharedError(store.scope);
86
139
  }
87
140
 
88
141
  export interface IdempotentOutcome<T> {
@@ -91,9 +144,17 @@ export interface IdempotentOutcome<T> {
91
144
  }
92
145
 
93
146
  /**
94
- * Replay-or-run. Four outcomes: fresh run, replay of a settled record,
95
- * X_IDEMPOTENCY_CONFLICT for a payload mismatch, X_IDEMPOTENCY_CONFLICT for a
96
- * duplicate that is still in flight.
147
+ * Replay-or-run. Five outcomes: fresh run, replay of a settled record, replay of a FAILED record,
148
+ * X_IDEMPOTENCY_CONFLICT for a payload mismatch, X_IDEMPOTENCY_CONFLICT for a duplicate still in
149
+ * flight.
150
+ *
151
+ * **Everything `run()` throws is treated as possibly-committed.** `guard()` and `validateInput`
152
+ * both run before this gate is reached (`invoke.ts`), so by the time `run` is called the only
153
+ * things left are the handler and `validateOutput` — and the second of those throws *after* the
154
+ * first has committed. Releasing the reservation there is what turned a rounding change in an
155
+ * output schema into a second charge: `X_OUTPUT_INVALID` dropped the record, and the client's
156
+ * automatic retry re-ran a handler that had already taken the money. So the failure is SETTLED
157
+ * and replayed, and `release` is reserved for a pre-handler failure — of which this gate has none.
97
158
  */
98
159
  export async function withIdempotency<T>(
99
160
  store: IdempotencyStore,
@@ -108,15 +169,63 @@ export async function withIdempotency<T>(
108
169
  }
109
170
  if (!created) {
110
171
  if (record.status === 'in-flight') throw new IdempotencyConflictError(key, 'in-flight');
172
+ if (record.status === 'failed') throw new IdempotencyReplayedFailureError(key, record.failure);
111
173
  // The stored value is the previous return of this very handler.
112
174
  return { value: record.value as T, replayed: true };
113
175
  }
176
+ let value: T;
114
177
  try {
115
- const value = await run();
116
- await store.settle(key, value);
117
- return { value, replayed: false };
178
+ value = await run();
118
179
  } catch (error) {
119
- await store.release(key);
180
+ await settleFailure(store, key, error);
120
181
  throw error;
121
182
  }
183
+ // Outside the `try` on purpose: a `settle` that refuses is itself post-commit, and the record
184
+ // stays in flight rather than being released — a retry then gets a 409 it can act on instead of
185
+ // re-running a handler that has already committed.
186
+ await store.settle(key, value);
187
+ return { value, replayed: false };
188
+ }
189
+
190
+ /**
191
+ * Record the failure, and never let recording it replace the failure itself. A store that refuses
192
+ * here would otherwise surface as the caller's error, hiding the `X_OUTPUT_INVALID` or the
193
+ * handler's own throw that is the thing worth reading — the same rule `auditThrew` follows.
194
+ */
195
+ async function settleFailure(store: IdempotencyStore, key: string, error: unknown): Promise<void> {
196
+ if (store.fail === undefined) {
197
+ // Deliberately NOT `release`. A store with no failure slot cannot say "this already ran", and
198
+ // the retry-safe reading of that is "refuse the retry", not "run it again".
199
+ logger.warn('action.idempotency.failure-unrecorded', { key });
200
+ return;
201
+ }
202
+ try {
203
+ await store.fail(key, failureOf(error));
204
+ } catch (sinkError) {
205
+ // Never the error's own text: rendering an `unknown` into a message is the second throw this
206
+ // branch exists to prevent. The logger takes it as a field and shapes it itself.
207
+ logger.error('action.idempotency.fail-refused', { key, error: sinkError });
208
+ }
209
+ }
210
+
211
+ /**
212
+ * A throw, flattened to the four fields a replay is rebuilt from. A non-`UltimateError` — a `row:`
213
+ * loader's `TypeError`, a driver's own error — carries no stable code, so the record takes
214
+ * `X_IDEMPOTENCY_REPLAYED_FAILURE` and says exactly that rather than inventing a code an app
215
+ * might match on.
216
+ */
217
+ function failureOf(error: unknown): IdempotencyFailure {
218
+ if (!isUltimateError(error)) {
219
+ return {
220
+ code: 'X_IDEMPOTENCY_REPLAYED_FAILURE',
221
+ cause: 'the first attempt under this key threw a value that is not an UltimateError',
222
+ fix: 'read the first attempt in the logs, then send a fresh Idempotency-Key once the cause is fixed',
223
+ };
224
+ }
225
+ return {
226
+ code: error.code,
227
+ cause: error.cause,
228
+ fix: error.fix,
229
+ docs: error.docs,
230
+ };
122
231
  }