@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.
@@ -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, uuid } from '@ultimat3/core';
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
- 'MemoryIdempotencyStore',
78
+ 'memoryIdempotencyStore',
55
79
  'windowMs',
56
80
  options.windowMs ?? DEFAULT_IDEMPOTENCY_WINDOW_MS,
57
81
  1,
58
82
  );
59
83
  this.#maxKeys = finiteCount(
60
- 'MemoryIdempotencyStore',
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 (existing !== undefined && !this.#expired(existing, nowMs)) {
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: uuid(),
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
- if (existing !== undefined) {
108
- this.#records.set(key, { ...existing, status: 'settled', value });
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 { finiteCount, logger, uuid } from '@ultimat3/core';
8
- import { IdempotencyStatusUnknownError } from './errors';
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 already outside
58
- * the window — which answers as a missing one — so a returned row always means this caller owns
59
- * the reservation and must run the handler. No row back means a live record exists and belongs to
60
- * someone else.
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
- returning key, id, request_hash, status, value, failure,
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
- uuid(),
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: uuid(),
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
- async settle(key, value, reservationId): Promise<void> {
229
- const rows = await exec.query(SQL_IDEMPOTENCY_SETTLE, [
230
- key,
231
- JSON.stringify(value ?? null),
232
- reservationId,
233
- ]);
234
- fenced(rows, key, reservationId, 'settle');
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
+ }