@ultimat3/action 1.1.0 → 2.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.
- package/CLAUDE.md +387 -0
- package/README.md +271 -10
- package/package.json +7 -6
- package/src/action.ts +81 -7
- package/src/audit-gate.ts +78 -0
- package/src/audit.ts +123 -0
- package/src/cache-gate.ts +32 -0
- package/src/client.ts +67 -13
- package/src/contract-test.ts +82 -13
- package/src/deprecation.ts +82 -0
- package/src/errors.ts +276 -3
- package/src/http.ts +90 -13
- package/src/idempotency-key.ts +47 -0
- package/src/idempotency-memory.ts +148 -0
- package/src/idempotency-postgres.ts +271 -0
- package/src/idempotency.ts +157 -48
- package/src/index.ts +81 -5
- package/src/invoke.ts +155 -10
- package/src/job-handle.ts +22 -3
- package/src/json-schema.ts +17 -10
- package/src/mcp-tool.ts +18 -4
- package/src/mutator.ts +8 -0
- package/src/naming.ts +7 -7
- package/src/policy-gate.ts +14 -2
- package/src/registry.ts +52 -1
- package/src/sample-input.ts +177 -0
- package/src/stable.ts +55 -20
- package/src/type-pins.ts +45 -0
- package/src/tags.ts +0 -17
|
@@ -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
|
+
}
|
package/src/idempotency.ts
CHANGED
|
@@ -1,19 +1,50 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Idempotency for actions marked `idempotent
|
|
3
|
-
*
|
|
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 {
|
|
7
|
-
import {
|
|
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:
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
37
|
-
*
|
|
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
|
|
41
|
-
readonly
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
}
|
|
99
|
+
let config: IdempotencyConfig = DEFAULT_IDEMPOTENCY_CONFIG;
|
|
100
|
+
let defaultStore: IdempotencyStore = new MemoryIdempotencyStore();
|
|
67
101
|
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
84
|
-
export function
|
|
85
|
-
|
|
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.
|
|
95
|
-
* X_IDEMPOTENCY_CONFLICT for a payload mismatch, X_IDEMPOTENCY_CONFLICT for a
|
|
96
|
-
*
|
|
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
|
-
|
|
116
|
-
await store.settle(key, value);
|
|
117
|
-
return { value, replayed: false };
|
|
178
|
+
value = await run();
|
|
118
179
|
} catch (error) {
|
|
119
|
-
await store
|
|
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
|
}
|