@ultimat3/action 23.0.0 → 25.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 +91 -42
- package/README.md +167 -47
- package/package.json +10 -9
- package/src/action.ts +25 -69
- package/src/api-declaration.ts +1 -1
- package/src/audit-gate.ts +3 -4
- package/src/audit-memory.ts +5 -3
- package/src/audit-postgres.ts +16 -8
- package/src/audit.ts +7 -106
- package/src/cache-gate.ts +25 -6
- package/src/client.ts +18 -6
- package/src/contract-test.ts +50 -14
- package/src/error-titles.ts +51 -0
- package/src/errors-http.ts +3 -1
- package/src/errors-idempotency.ts +57 -5
- package/src/errors.ts +44 -61
- package/src/facade.ts +6 -7
- package/src/http-path.ts +1 -1
- package/src/http.ts +23 -20
- package/src/idempotency-memory.ts +92 -8
- package/src/idempotency-postgres.ts +91 -39
- package/src/idempotency-redact.ts +140 -0
- package/src/idempotency.ts +58 -7
- package/src/index.ts +37 -88
- package/src/invoke.ts +57 -21
- package/src/job-handle.ts +5 -3
- package/src/json-schema.ts +6 -10
- package/src/mutator.ts +14 -4
- package/src/naming.ts +2 -3
- package/src/openapi-complete.ts +16 -1
- package/src/openapi.ts +25 -2
- package/src/policy-gate.ts +8 -51
- package/src/rate-limit-gate.ts +114 -0
- package/src/record-wire.ts +2 -2
- package/src/registry.ts +5 -4
- package/src/request-deadline.ts +25 -0
- package/src/stable.ts +0 -7
- package/src/transition.ts +35 -9
- package/src/tx-scope.ts +57 -0
- package/src/type-pins.ts +98 -3
- package/src/deprecation.ts +0 -77
- package/src/mcp-tool.ts +0 -87
- package/src/wire-headers.ts +0 -20
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
* and for tests, and refused at registration under a `shared` declaration — its `scope` says so.
|
|
4
4
|
* Shaped after `@ultimat3/http`'s `memoryRateLimitStore`, including the deliberate eviction order.
|
|
5
5
|
*/
|
|
6
|
-
import { finiteCount,
|
|
6
|
+
import { finiteCount, uuidV7 } from '@ultimat3/core';
|
|
7
|
+
import { IdempotencyReservationLostError } from './errors-idempotency';
|
|
7
8
|
import type {
|
|
8
9
|
IdempotencyFailure,
|
|
9
10
|
IdempotencyRecord,
|
|
@@ -11,6 +12,8 @@ import type {
|
|
|
11
12
|
IdempotencyScope,
|
|
12
13
|
IdempotencyStore,
|
|
13
14
|
} from './idempotency';
|
|
15
|
+
import { requestDeadlineMs } from './request-deadline';
|
|
16
|
+
import { liveTransaction } from './tx-scope';
|
|
14
17
|
|
|
15
18
|
/**
|
|
16
19
|
* How long a key is remembered. A day is the window every payment API this shape exists to serve
|
|
@@ -34,15 +37,36 @@ export interface MemoryIdempotencyStoreOptions {
|
|
|
34
37
|
readonly maxKeys?: number | undefined;
|
|
35
38
|
/** Injectable so a test can age a record without sleeping. */
|
|
36
39
|
readonly now?: (() => number) | undefined;
|
|
40
|
+
/**
|
|
41
|
+
* The request deadline, read at every reservation — how long an in-flight record whose
|
|
42
|
+
* settlement rides a transaction is kept before a retry may take the key. Default: the app's
|
|
43
|
+
* own `requestTimeoutMs` (`requestDeadlineMs`). `0` is "no deadline".
|
|
44
|
+
*/
|
|
45
|
+
readonly reclaimAfterMs?: (() => number) | undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* What an in-flight record's transaction has to do with it — the memory twin of the Postgres
|
|
50
|
+
* store's `tx_bound` column and of the row lock its settle holds until COMMIT. Keyed by the record
|
|
51
|
+
* object: a settled or failed record is a new object, so these never outlive the flight.
|
|
52
|
+
*/
|
|
53
|
+
interface Flight {
|
|
54
|
+
/** Reserved inside a transaction: its settle commits with the write or not at all. */
|
|
55
|
+
readonly bound: boolean;
|
|
56
|
+
/** The settle ran and its transaction has not ended yet — nobody may reclaim it now. */
|
|
57
|
+
settling: boolean;
|
|
37
58
|
}
|
|
38
59
|
|
|
39
60
|
export class MemoryIdempotencyStore implements IdempotencyStore {
|
|
40
61
|
/** Declared, never inferred: this map is this process's and nothing else can reach it. */
|
|
41
62
|
readonly scope: IdempotencyScope = 'process';
|
|
63
|
+
readonly keepsRedaction = true as const;
|
|
42
64
|
readonly windowMs: number;
|
|
43
65
|
readonly #maxKeys: number;
|
|
44
66
|
readonly #evictTo: number;
|
|
45
67
|
readonly #now: () => number;
|
|
68
|
+
readonly #reclaimAfterMs: () => number;
|
|
69
|
+
readonly #flights = new WeakMap<IdempotencyRecord, Flight>();
|
|
46
70
|
readonly #records = new Map<string, IdempotencyRecord>();
|
|
47
71
|
#lastSweepMs = Number.NEGATIVE_INFINITY;
|
|
48
72
|
|
|
@@ -51,13 +75,13 @@ export class MemoryIdempotencyStore implements IdempotencyStore {
|
|
|
51
75
|
// size and `now - at > windowMs` is false for every record — a table with no cap, holding keys
|
|
52
76
|
// that never expire, out of a `Math.max` that reads like a guard.
|
|
53
77
|
this.windowMs = finiteCount(
|
|
54
|
-
'
|
|
78
|
+
'memoryIdempotencyStore',
|
|
55
79
|
'windowMs',
|
|
56
80
|
options.windowMs ?? DEFAULT_IDEMPOTENCY_WINDOW_MS,
|
|
57
81
|
1,
|
|
58
82
|
);
|
|
59
83
|
this.#maxKeys = finiteCount(
|
|
60
|
-
'
|
|
84
|
+
'memoryIdempotencyStore',
|
|
61
85
|
'maxKeys',
|
|
62
86
|
options.maxKeys ?? DEFAULT_MAX_IDEMPOTENCY_KEYS,
|
|
63
87
|
1,
|
|
@@ -65,6 +89,7 @@ export class MemoryIdempotencyStore implements IdempotencyStore {
|
|
|
65
89
|
// Batched down to 90% so the eviction sort is paid once per 10% of the cap, not per write.
|
|
66
90
|
this.#evictTo = Math.max(1, Math.floor(this.#maxKeys * 0.9));
|
|
67
91
|
this.#now = options.now ?? ((): number => Date.now());
|
|
92
|
+
this.#reclaimAfterMs = options.reclaimAfterMs ?? requestDeadlineMs;
|
|
68
93
|
}
|
|
69
94
|
|
|
70
95
|
/** Records tracked right now — the bound, observable. */
|
|
@@ -77,11 +102,15 @@ export class MemoryIdempotencyStore implements IdempotencyStore {
|
|
|
77
102
|
const existing = this.#records.get(key);
|
|
78
103
|
// Expired is missing. A record past the window answers exactly as a first-ever key does, so
|
|
79
104
|
// reclaiming it here is what makes the window mean something rather than being a comment.
|
|
80
|
-
if (
|
|
105
|
+
if (
|
|
106
|
+
existing !== undefined &&
|
|
107
|
+
!this.#expired(existing, nowMs) &&
|
|
108
|
+
!this.#abandoned(existing, nowMs)
|
|
109
|
+
) {
|
|
81
110
|
return Promise.resolve({ record: existing, created: false });
|
|
82
111
|
}
|
|
83
112
|
const record: IdempotencyRecord = {
|
|
84
|
-
id:
|
|
113
|
+
id: uuidV7(),
|
|
85
114
|
key,
|
|
86
115
|
requestHash,
|
|
87
116
|
status: 'in-flight',
|
|
@@ -89,10 +118,28 @@ export class MemoryIdempotencyStore implements IdempotencyStore {
|
|
|
89
118
|
createdAt: nowMs,
|
|
90
119
|
};
|
|
91
120
|
this.#records.set(key, record);
|
|
121
|
+
this.#flights.set(record, { bound: liveTransaction() !== undefined, settling: false });
|
|
92
122
|
this.#maintain(nowMs);
|
|
93
123
|
return Promise.resolve({ record, created: true });
|
|
94
124
|
}
|
|
95
125
|
|
|
126
|
+
/**
|
|
127
|
+
* An in-flight record past the request deadline whose settle was BOUND to a transaction: that
|
|
128
|
+
* settle lands with the commit or not at all, so the record being in flight proves nothing
|
|
129
|
+
* committed. An autocommit handler's record is never this — it may have written before it died.
|
|
130
|
+
*/
|
|
131
|
+
#abandoned(record: IdempotencyRecord, nowMs: number): boolean {
|
|
132
|
+
const flight = this.#flights.get(record);
|
|
133
|
+
if (record.status !== 'in-flight' || flight === undefined) return false;
|
|
134
|
+
if (!flight.bound || flight.settling) return false;
|
|
135
|
+
const reclaimAfterMs = finiteCount(
|
|
136
|
+
'memoryIdempotencyStore',
|
|
137
|
+
'reclaimAfterMs',
|
|
138
|
+
this.#reclaimAfterMs(),
|
|
139
|
+
);
|
|
140
|
+
return reclaimAfterMs > 0 && nowMs - record.createdAt >= reclaimAfterMs;
|
|
141
|
+
}
|
|
142
|
+
|
|
96
143
|
/**
|
|
97
144
|
* Both settlements are FENCED on the reservation's own `id` AND on `in-flight`, as
|
|
98
145
|
* `SQL_IDEMPOTENCY_SETTLE` is and as `@ultimat3/jobs`' `SQL_ACK` is. A record past the window is
|
|
@@ -102,11 +149,38 @@ export class MemoryIdempotencyStore implements IdempotencyStore {
|
|
|
102
149
|
* again — which is why the id is half the fence. Both stores fence, or the guarantee is
|
|
103
150
|
* whichever store the deployment happens to install.
|
|
104
151
|
*/
|
|
105
|
-
settle(key: string, value: unknown, reservationId: string): Promise<void> {
|
|
152
|
+
settle(key: string, value: unknown, reservationId: string, redacted: boolean): Promise<void> {
|
|
106
153
|
const existing = this.#owned(key, reservationId);
|
|
107
|
-
|
|
108
|
-
|
|
154
|
+
// Spread only when set, so an unredacted record keeps the exact shape it always had.
|
|
155
|
+
const settled = (from: IdempotencyRecord): IdempotencyRecord => ({
|
|
156
|
+
...from,
|
|
157
|
+
status: 'settled',
|
|
158
|
+
value,
|
|
159
|
+
...(redacted ? { redacted: true } : {}),
|
|
160
|
+
});
|
|
161
|
+
const tx = liveTransaction();
|
|
162
|
+
if (tx === undefined) {
|
|
163
|
+
if (existing !== undefined) this.#records.set(key, settled(existing));
|
|
164
|
+
return Promise.resolve();
|
|
165
|
+
}
|
|
166
|
+
// Inside a transaction the record is settled BY the commit, as the Postgres store's is: a
|
|
167
|
+
// rollback leaves it in flight rather than replaying a success for rows nobody stored. And a
|
|
168
|
+
// settle that lost its reservation is thrown, because nothing has committed yet and the throw
|
|
169
|
+
// is what rolls this attempt back beside the retry that replaced it.
|
|
170
|
+
if (existing === undefined) {
|
|
171
|
+
return Promise.reject(new IdempotencyReservationLostError(key));
|
|
109
172
|
}
|
|
173
|
+
const flight = this.#flights.get(existing);
|
|
174
|
+
if (flight !== undefined) flight.settling = true;
|
|
175
|
+
tx.onRollback(() => {
|
|
176
|
+
if (flight !== undefined) flight.settling = false;
|
|
177
|
+
});
|
|
178
|
+
tx.onCommit(() => {
|
|
179
|
+
// Still this reservation's: `settling` kept a reclaim away, and `release` may have dropped it.
|
|
180
|
+
if (this.#records.get(key) === existing) {
|
|
181
|
+
this.#records.set(key, settled(existing));
|
|
182
|
+
}
|
|
183
|
+
});
|
|
110
184
|
return Promise.resolve();
|
|
111
185
|
}
|
|
112
186
|
|
|
@@ -169,3 +243,13 @@ export class MemoryIdempotencyStore implements IdempotencyStore {
|
|
|
169
243
|
// becomes sweepable the moment it ages past the window.
|
|
170
244
|
}
|
|
171
245
|
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* The one way to build the memory store — the twin of `postgresIdempotencyStore()`. The class
|
|
249
|
+
* stays a type in the barrel (`X_FACTORY_NAME_SPELLING`), so `new` is never a second spelling.
|
|
250
|
+
*/
|
|
251
|
+
export function memoryIdempotencyStore(
|
|
252
|
+
options: MemoryIdempotencyStoreOptions = {},
|
|
253
|
+
): MemoryIdempotencyStore {
|
|
254
|
+
return new MemoryIdempotencyStore(options);
|
|
255
|
+
}
|
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
* without it, `replicas: 3` means a retry that lands elsewhere re-runs a committed handler.
|
|
5
5
|
* Statements are spelled out so an agent can run the exact one it saw in a log.
|
|
6
6
|
*/
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
7
|
+
import type { PgExecutor } from '@ultimat3/core';
|
|
8
|
+
import { finiteCount, logger, uuidV7 } from '@ultimat3/core';
|
|
9
|
+
import { IdempotencyReservationLostError, IdempotencyStatusUnknownError } from './errors';
|
|
9
10
|
import type {
|
|
10
11
|
IdempotencyFailure,
|
|
11
12
|
IdempotencyRecord,
|
|
@@ -15,22 +16,7 @@ import type {
|
|
|
15
16
|
} from './idempotency';
|
|
16
17
|
import { IDEMPOTENCY_STATUSES, isIdempotencyStatus } from './idempotency';
|
|
17
18
|
import { DEFAULT_IDEMPOTENCY_WINDOW_MS } from './idempotency-memory';
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* The one thing this store needs from the DB layer, declared structurally rather than imported.
|
|
21
|
-
* `@ultimat3/jobs` declares the same shape for the same reason: neither package owns the other's
|
|
22
|
-
* connection, and neither depends on a database package.
|
|
23
|
-
*
|
|
24
|
-
* **`Bun.sql` does not satisfy it** — verified against Bun 1.3.14: `Bun.sql.query` is `undefined`.
|
|
25
|
-
* `Bun.sql` is a tagged template whose positional form is `unsafe`, so `{ executor: Bun.sql }`
|
|
26
|
-
* would `TypeError` on the first reservation, which is the one call path that must not fail open.
|
|
27
|
-
* What satisfies it is a client that already speaks `(text, values)`, wrapped in one line —
|
|
28
|
-
* `@ultimat3/cli`'s `pgExecutorFor(client)` over `@ultimat3/db`'s `DbClient.query({ text, values })`
|
|
29
|
-
* is the framework's own — or a transaction handle, which is a client on its own connection.
|
|
30
|
-
*/
|
|
31
|
-
export interface PgExecutor {
|
|
32
|
-
query<R>(sql: string, params: readonly unknown[]): Promise<readonly R[]>;
|
|
33
|
-
}
|
|
19
|
+
import { liveTransaction } from './tx-scope';
|
|
34
20
|
|
|
35
21
|
/**
|
|
36
22
|
* The store's ONE install point, applied the way `SQL_JOBS_TABLE` is — by the boot, not by an app
|
|
@@ -51,31 +37,46 @@ create table if not exists x_idempotency (
|
|
|
51
37
|
);
|
|
52
38
|
|
|
53
39
|
create index if not exists x_idempotency_created_at_idx on x_idempotency (created_at);
|
|
40
|
+
|
|
41
|
+
alter table x_idempotency add column if not exists tx_bound boolean not null default false;
|
|
42
|
+
|
|
43
|
+
alter table x_idempotency add column if not exists redacted boolean not null default false;
|
|
54
44
|
`;
|
|
55
45
|
|
|
56
46
|
/**
|
|
57
|
-
* The reservation, atomic in one statement. The `do update` fires ONLY for a row
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
47
|
+
* The reservation, atomic in one statement. The `do update` fires ONLY for a row that answers as a
|
|
48
|
+
* missing one, so a returned row always means this caller owns the reservation and must run the
|
|
49
|
+
* handler. No row back means a live record exists and belongs to someone else.
|
|
50
|
+
*
|
|
51
|
+
* Two rows answer as missing. One outside the window. And one still `in-flight` past the request
|
|
52
|
+
* deadline (`$6`) whose settlement was BOUND TO A TRANSACTION (`tx_bound`): that settle commits
|
|
53
|
+
* with the handler's writes or not at all, so the record being in flight proves nothing committed
|
|
54
|
+
* — the process died, or the unit of work rolled back. An autocommit handler's record is never
|
|
55
|
+
* reclaimed this way: "died before the write" and "wrote, then died before the settle" are one
|
|
56
|
+
* row, and re-running the second is the double charge.
|
|
61
57
|
*/
|
|
62
58
|
export const SQL_IDEMPOTENCY_RESERVE = `
|
|
63
|
-
insert into x_idempotency (key, id, request_hash, status)
|
|
64
|
-
values ($1, $2, $3, 'in-flight')
|
|
59
|
+
insert into x_idempotency (key, id, request_hash, status, tx_bound)
|
|
60
|
+
values ($1, $2, $3, 'in-flight', $5)
|
|
65
61
|
on conflict (key) do update
|
|
66
62
|
set id = excluded.id,
|
|
67
63
|
request_hash = excluded.request_hash,
|
|
68
64
|
status = 'in-flight',
|
|
69
65
|
value = null,
|
|
70
66
|
failure = null,
|
|
67
|
+
redacted = false,
|
|
68
|
+
tx_bound = excluded.tx_bound,
|
|
71
69
|
created_at = now()
|
|
72
70
|
where x_idempotency.created_at < now() - make_interval(secs => $4::double precision)
|
|
73
|
-
|
|
71
|
+
or (x_idempotency.status = 'in-flight'
|
|
72
|
+
and x_idempotency.tx_bound
|
|
73
|
+
and x_idempotency.created_at < now() - make_interval(secs => $6::double precision))
|
|
74
|
+
returning key, id, request_hash, status, value, failure, redacted,
|
|
74
75
|
(extract(epoch from created_at) * 1000)::bigint as created_at
|
|
75
76
|
`;
|
|
76
77
|
|
|
77
78
|
export const SQL_IDEMPOTENCY_GET = `
|
|
78
|
-
select key, id, request_hash, status, value, failure,
|
|
79
|
+
select key, id, request_hash, status, value, failure, redacted,
|
|
79
80
|
(extract(epoch from created_at) * 1000)::bigint as created_at
|
|
80
81
|
from x_idempotency
|
|
81
82
|
where key = $1
|
|
@@ -95,13 +96,15 @@ select key, id, request_hash, status, value, failure,
|
|
|
95
96
|
* the refusal observable: an update matching no row is indistinguishable from one that matched.
|
|
96
97
|
*/
|
|
97
98
|
export const SQL_IDEMPOTENCY_SETTLE = `
|
|
98
|
-
update x_idempotency set status = 'settled', value = $2::jsonb, failure = null
|
|
99
|
+
update x_idempotency set status = 'settled', value = $2::jsonb, failure = null,
|
|
100
|
+
redacted = $4::boolean
|
|
99
101
|
where key = $1 and id = $3::uuid and status = 'in-flight'
|
|
100
102
|
returning key
|
|
101
103
|
`;
|
|
102
104
|
|
|
103
105
|
export const SQL_IDEMPOTENCY_FAIL = `
|
|
104
|
-
update x_idempotency set status = 'failed', value = null, failure = $2::jsonb
|
|
106
|
+
update x_idempotency set status = 'failed', value = null, failure = $2::jsonb,
|
|
107
|
+
redacted = false
|
|
105
108
|
where key = $1 and id = $3::uuid and status = 'in-flight'
|
|
106
109
|
returning key
|
|
107
110
|
`;
|
|
@@ -119,13 +122,32 @@ interface IdempotencyRow {
|
|
|
119
122
|
readonly status: string;
|
|
120
123
|
readonly value: unknown;
|
|
121
124
|
readonly failure: unknown;
|
|
125
|
+
/** Absent on a row read by a statement that predates the column — read as `false`. */
|
|
126
|
+
readonly redacted?: boolean | undefined;
|
|
122
127
|
/** `bigint`, which every Postgres client hands back as a string. */
|
|
123
128
|
readonly created_at: number | string;
|
|
124
129
|
}
|
|
125
130
|
|
|
126
131
|
export interface PostgresIdempotencyStoreOptions {
|
|
127
132
|
readonly executor: PgExecutor;
|
|
133
|
+
/**
|
|
134
|
+
* The client transactions on THIS database are opened on — `baseClient` from `@ultimat3/db` for
|
|
135
|
+
* the store the boot installs. Compared by identity with an open transaction's `origin`: the
|
|
136
|
+
* settle rides a transaction on this database and no other. One opened elsewhere
|
|
137
|
+
* (`withTransaction(fn, { client: shard })`) has no `x_idempotency` to settle in, so the record
|
|
138
|
+
* is settled on the pool, exactly as an autocommit handler's is. REQUIRED: a store that could
|
|
139
|
+
* not tell would send the settle to whichever database the handler happened to be writing.
|
|
140
|
+
*/
|
|
141
|
+
readonly origin: () => object;
|
|
128
142
|
readonly windowMs?: number | undefined;
|
|
143
|
+
/**
|
|
144
|
+
* The request deadline in milliseconds, read at every reservation: how long an in-flight record
|
|
145
|
+
* whose settlement rides a transaction is kept before a retry may take the key. REQUIRED and a
|
|
146
|
+
* function — the deadline is the app's (`requestTimeoutMs`), declared after this store is built,
|
|
147
|
+
* and a number restated here would silently disagree with it. `0` is "no deadline": nothing is
|
|
148
|
+
* reclaimed before the window. Never applies to an autocommit handler's record.
|
|
149
|
+
*/
|
|
150
|
+
readonly reclaimAfterMs: () => number;
|
|
129
151
|
/**
|
|
130
152
|
* Injectable, exactly as `MemoryIdempotencyStoreOptions.now` is. The two stores are one seam and
|
|
131
153
|
* a caller must be able to drive either from the same clock; a hardcoded `Date.now()` here made
|
|
@@ -148,7 +170,7 @@ export interface PostgresIdempotencyStore extends IdempotencyStore {
|
|
|
148
170
|
/**
|
|
149
171
|
* **The boot installs this for you — an app declares the scope and nothing else.**
|
|
150
172
|
* `@ultimat3/cli`'s `startServices` builds a `PgExecutor` from the client it already resolved and
|
|
151
|
-
* calls `setIdempotencyStore(postgresIdempotencyStore({ executor }))` before `loadApp`, so the
|
|
173
|
+
* calls `setIdempotencyStore(postgresIdempotencyStore({ executor, origin, reclaimAfterMs }))` before `loadApp`, so the
|
|
152
174
|
* store is in place by the time `registerAction` evaluates a declaration against it. All an app
|
|
153
175
|
* owes is the one line `x new` scaffolds into `apps/web/server.ts`:
|
|
154
176
|
*
|
|
@@ -165,6 +187,8 @@ export interface PostgresIdempotencyStore extends IdempotencyStore {
|
|
|
165
187
|
* setIdempotencyStore(
|
|
166
188
|
* postgresIdempotencyStore({
|
|
167
189
|
* executor: { query: (text, values) => client.query({ text, values }) },
|
|
190
|
+
* origin: () => client,
|
|
191
|
+
* reclaimAfterMs: requestDeadlineMs, // the app's `requestTimeoutMs`, from '@ultimat3/action'
|
|
168
192
|
* }),
|
|
169
193
|
* );
|
|
170
194
|
* ```
|
|
@@ -181,7 +205,18 @@ export function postgresIdempotencyStore(
|
|
|
181
205
|
1,
|
|
182
206
|
);
|
|
183
207
|
const windowSecs = windowMs / 1000;
|
|
208
|
+
// Screened on every read, like the window above: the value is the app's and arrives late. Never
|
|
209
|
+
// past the window, which already frees the record — and `0` means exactly that.
|
|
210
|
+
const reclaimSecs = (): number => {
|
|
211
|
+
const ms = finiteCount('postgresIdempotencyStore', 'reclaimAfterMs', options.reclaimAfterMs());
|
|
212
|
+
return (ms === 0 ? windowMs : Math.min(ms, windowMs)) / 1000;
|
|
213
|
+
};
|
|
184
214
|
const exec = options.executor;
|
|
215
|
+
/** The executor of a transaction open on THIS database, or `undefined` — the pool settles. */
|
|
216
|
+
const boundTx = (): PgExecutor | undefined => {
|
|
217
|
+
const tx = liveTransaction();
|
|
218
|
+
return tx !== undefined && tx.origin === options.origin() ? tx.executor : undefined;
|
|
219
|
+
};
|
|
185
220
|
const now = options.now ?? ((): number => Date.now());
|
|
186
221
|
|
|
187
222
|
const fetch = async (key: string): Promise<IdempotencyRecord | undefined> => {
|
|
@@ -192,6 +227,7 @@ export function postgresIdempotencyStore(
|
|
|
192
227
|
|
|
193
228
|
return {
|
|
194
229
|
scope: 'shared',
|
|
230
|
+
keepsRedaction: true,
|
|
195
231
|
windowMs,
|
|
196
232
|
|
|
197
233
|
async reserve(key, requestHash): Promise<IdempotencyReservation> {
|
|
@@ -199,11 +235,16 @@ export function postgresIdempotencyStore(
|
|
|
199
235
|
// back empty is a concurrent `release`/`purgeExpired` deleting the row between them, and a
|
|
200
236
|
// caller losing that race twice is a store nobody should keep retrying against.
|
|
201
237
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
238
|
+
// On the POOL, never the open transaction: a duplicate has to see the reservation now,
|
|
239
|
+
// and one that rolled back with the handler would let both attempts run. What rides the
|
|
240
|
+
// transaction is the SETTLE — and the row says so, which is what makes it reclaimable.
|
|
202
241
|
const claimed = await exec.query<IdempotencyRow>(SQL_IDEMPOTENCY_RESERVE, [
|
|
203
242
|
key,
|
|
204
|
-
|
|
243
|
+
uuidV7(),
|
|
205
244
|
requestHash,
|
|
206
245
|
windowSecs,
|
|
246
|
+
boundTx() !== undefined,
|
|
247
|
+
reclaimSecs(),
|
|
207
248
|
]);
|
|
208
249
|
const row = claimed[0];
|
|
209
250
|
if (row !== undefined) return { record: toRecord(row), created: true };
|
|
@@ -214,7 +255,7 @@ export function postgresIdempotencyStore(
|
|
|
214
255
|
// that matters, so this is the honest answer: the caller sees the in-flight refusal.
|
|
215
256
|
return {
|
|
216
257
|
record: {
|
|
217
|
-
id:
|
|
258
|
+
id: uuidV7(),
|
|
218
259
|
key,
|
|
219
260
|
requestHash,
|
|
220
261
|
status: 'in-flight',
|
|
@@ -225,15 +266,25 @@ export function postgresIdempotencyStore(
|
|
|
225
266
|
};
|
|
226
267
|
},
|
|
227
268
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
269
|
+
// `value` is the resting copy `withIdempotency` redacted; `redacted` is kept beside it so the
|
|
270
|
+
// replay is refused by a column, never by searching the stored JSON for a marker string.
|
|
271
|
+
async settle(key, value, reservationId, redacted): Promise<void> {
|
|
272
|
+
const params = [key, JSON.stringify(value ?? null), reservationId, redacted];
|
|
273
|
+
const tx = boundTx();
|
|
274
|
+
if (tx === undefined) {
|
|
275
|
+
fenced(await exec.query(SQL_IDEMPOTENCY_SETTLE, params), key, reservationId, 'settle');
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
// On the handler's OWN connection: the record is settled by the same COMMIT that makes the
|
|
279
|
+
// write durable, so a rollback leaves it in flight instead of replaying a success for rows
|
|
280
|
+
// that were never stored. And a settle that matches nothing here is THROWN, not logged —
|
|
281
|
+
// the key was taken over, nothing has committed yet, and the throw is what rolls it back.
|
|
282
|
+
const rows = await tx.query(SQL_IDEMPOTENCY_SETTLE, params);
|
|
283
|
+
if (rows.length === 0) throw new IdempotencyReservationLostError(key);
|
|
235
284
|
},
|
|
236
285
|
|
|
286
|
+
// On the pool, whatever is open: the transaction this failure came out of is about to roll
|
|
287
|
+
// back, and the record of it has to outlive that.
|
|
237
288
|
async fail(key, failure: IdempotencyFailure, reservationId): Promise<void> {
|
|
238
289
|
const rows = await exec.query(SQL_IDEMPOTENCY_FAIL, [
|
|
239
290
|
key,
|
|
@@ -260,7 +311,7 @@ export function postgresIdempotencyStore(
|
|
|
260
311
|
}
|
|
261
312
|
|
|
262
313
|
/**
|
|
263
|
-
* Logged, never thrown. A settlement lands after the handler has committed, so raising here would
|
|
314
|
+
* Logged, never thrown — the AUTOCOMMIT path. A settlement lands after the handler has committed, so raising here would
|
|
264
315
|
* turn a durable write into the caller's error — the rule `withIdempotency` already follows for a
|
|
265
316
|
* store that refuses. An operator still has to see it: a fenced settle means this attempt's record
|
|
266
317
|
* belongs to another reservation, and the value this attempt produced is stored nowhere.
|
|
@@ -297,6 +348,7 @@ function toRecord(row: IdempotencyRow): IdempotencyRecord {
|
|
|
297
348
|
status: row.status,
|
|
298
349
|
value: row.value,
|
|
299
350
|
...(failure === undefined ? {} : { failure }),
|
|
351
|
+
...(row.redacted === true ? { redacted: true } : {}),
|
|
300
352
|
createdAt: Number(row.created_at),
|
|
301
353
|
};
|
|
302
354
|
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One job: the copy of an idempotent answer that may be kept AT REST — every value under a key
|
|
3
|
+
* core's `isRedactedKey` names, every `Secret` box, and everything the walk cannot vouch for
|
|
4
|
+
* replaced by `[redacted]`. The rule is core's table, the one `audit-input.ts` and the logger ask.
|
|
5
|
+
*/
|
|
6
|
+
import { isRedactedKey, isSecret, REDACTED } from '@ultimat3/core';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* How deep the walk inspects. Past it the subtree is REDACTED, never kept: what was not looked at
|
|
10
|
+
* cannot be shown to hold no credential, and the record of it outlives the request by a day. An
|
|
11
|
+
* output schema deep enough to reach this is a recursive one, and its replay is refused rather
|
|
12
|
+
* than served with a credential in it.
|
|
13
|
+
*/
|
|
14
|
+
export const IDEMPOTENCY_REDACT_MAX_DEPTH = 32;
|
|
15
|
+
|
|
16
|
+
export interface RestingAnswer {
|
|
17
|
+
/** What the store may write down. The ORIGINAL reference when nothing was redacted. */
|
|
18
|
+
readonly value: unknown;
|
|
19
|
+
/** True when anything was replaced — the record's flag, so a replay never scans for a string. */
|
|
20
|
+
readonly redacted: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface WalkState {
|
|
24
|
+
redacted: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The answer as it may rest. Plain data — objects whose prototype is `Object.prototype` or
|
|
29
|
+
* `null`, arrays, `Date`, primitives — with no redacted key comes back as the SAME reference, so
|
|
30
|
+
* the common case stores and replays exactly what it did before.
|
|
31
|
+
*
|
|
32
|
+
* **Fail closed on everything else.** The Postgres store writes `JSON.stringify(value)`, which
|
|
33
|
+
* runs app code this walk never saw: a `toJSON` (on a class, a plain object, an array, even a
|
|
34
|
+
* function) may return `{ apiKey }`, and a getter read a second time may return something else.
|
|
35
|
+
* So a value with a callable `toJSON`, a `Map`, a `Set`, a class instance or a `Date` subclass is
|
|
36
|
+
* `[redacted]` and the record flagged — its replay is refused, which is the honest answer for an
|
|
37
|
+
* answer this walk could not judge. An own getter is read ONCE and the subtree copied, so the
|
|
38
|
+
* serializer reads the value that was judged, not a fresh one. `toJSON` is never called. A CYCLE
|
|
39
|
+
* is cut the same way — its back-reference becomes `[redacted]` — so both stores keep one
|
|
40
|
+
* serializable copy; a value repeated as siblings is not a cycle and is kept.
|
|
41
|
+
*/
|
|
42
|
+
export function restingAnswer(value: unknown): RestingAnswer {
|
|
43
|
+
const state: WalkState = { redacted: false };
|
|
44
|
+
const out = walk(value, 0, new Set(), state);
|
|
45
|
+
return { value: out, redacted: state.redacted };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function redact(state: WalkState): string {
|
|
49
|
+
state.redacted = true;
|
|
50
|
+
return REDACTED;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** `JSON.stringify` asks for `toJSON` on any object — a function included. */
|
|
54
|
+
function hasToJson(value: object): boolean {
|
|
55
|
+
return typeof (value as { toJSON?: unknown }).toJSON === 'function';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A `Date` whose `toJSON` is the built-in one: its own prototype, no own override. */
|
|
59
|
+
function isPlainDate(value: object): boolean {
|
|
60
|
+
return Object.getPrototypeOf(value) === Date.prototype && !Object.hasOwn(value, 'toJSON');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function isPlainObject(value: object): boolean {
|
|
64
|
+
const proto: unknown = Object.getPrototypeOf(value);
|
|
65
|
+
return proto === Object.prototype || proto === null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function walk(value: unknown, depth: number, ancestors: Set<object>, state: WalkState): unknown {
|
|
69
|
+
if (typeof value === 'function') return hasToJson(value) ? redact(state) : value;
|
|
70
|
+
if (typeof value !== 'object' || value === null) return value;
|
|
71
|
+
if (isSecret(value)) return redact(state);
|
|
72
|
+
if (isPlainDate(value)) return value;
|
|
73
|
+
// A back-reference fails closed: kept, `JSON.stringify` throws in the Postgres store after the
|
|
74
|
+
// handler committed, while the memory store would accept it — two answers for one record.
|
|
75
|
+
if (ancestors.has(value)) return redact(state);
|
|
76
|
+
const array = Array.isArray(value) && Object.getPrototypeOf(value) === Array.prototype;
|
|
77
|
+
if (hasToJson(value) || !(array || isPlainObject(value))) return redact(state);
|
|
78
|
+
if (depth >= IDEMPOTENCY_REDACT_MAX_DEPTH) return redact(state);
|
|
79
|
+
ancestors.add(value);
|
|
80
|
+
try {
|
|
81
|
+
return array
|
|
82
|
+
? walkArray(value as readonly unknown[], depth, ancestors, state)
|
|
83
|
+
: walkObject(value, depth, ancestors, state);
|
|
84
|
+
} finally {
|
|
85
|
+
ancestors.delete(value);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* One read of an own property: a data property's value, or its getter called exactly once.
|
|
91
|
+
* `copy` says the subtree must be copied for the read to be the one the serializer sees.
|
|
92
|
+
*/
|
|
93
|
+
function readOnce(target: object, key: string | number): { value: unknown; copy: boolean } {
|
|
94
|
+
const descriptor = Object.getOwnPropertyDescriptor(target, key);
|
|
95
|
+
if (descriptor === undefined) return { value: undefined, copy: false };
|
|
96
|
+
if (descriptor.get !== undefined) return { value: descriptor.get.call(target), copy: true };
|
|
97
|
+
return { value: descriptor.value, copy: false };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function walkArray(
|
|
101
|
+
value: readonly unknown[],
|
|
102
|
+
depth: number,
|
|
103
|
+
ancestors: Set<object>,
|
|
104
|
+
state: WalkState,
|
|
105
|
+
): unknown {
|
|
106
|
+
let copy: unknown[] | undefined;
|
|
107
|
+
// Indexed, not `forEach`: a hole is visited (as `undefined`) and kept in its place.
|
|
108
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
109
|
+
const read = readOnce(value, index);
|
|
110
|
+
const next = walk(read.value, depth + 1, ancestors, state);
|
|
111
|
+
if (read.copy || next !== read.value) copy ??= value.slice(0, index);
|
|
112
|
+
copy?.push(next);
|
|
113
|
+
}
|
|
114
|
+
return copy ?? value;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function walkObject(
|
|
118
|
+
value: object,
|
|
119
|
+
depth: number,
|
|
120
|
+
ancestors: Set<object>,
|
|
121
|
+
state: WalkState,
|
|
122
|
+
): unknown {
|
|
123
|
+
let changed = false;
|
|
124
|
+
const out: Record<string, unknown> = {};
|
|
125
|
+
// OWN enumerable string keys: what `JSON.stringify` writes and what the Postgres store keeps.
|
|
126
|
+
for (const key of Object.keys(value)) {
|
|
127
|
+
const read = readOnce(value, key);
|
|
128
|
+
const item = read.value;
|
|
129
|
+
// The key decides before the value is walked, so a credential under a redacted name is never
|
|
130
|
+
// read further. `null` and `undefined` under one are KEPT: they hold no credential, and
|
|
131
|
+
// redacting them would refuse the replay of a `{ resetToken: null }` that never carried one.
|
|
132
|
+
const next =
|
|
133
|
+
item !== undefined && item !== null && isRedactedKey(key)
|
|
134
|
+
? redact(state)
|
|
135
|
+
: walk(item, depth + 1, ancestors, state);
|
|
136
|
+
if (read.copy || next !== item) changed = true;
|
|
137
|
+
out[key] = next;
|
|
138
|
+
}
|
|
139
|
+
return changed ? out : value;
|
|
140
|
+
}
|