@ultimat3/action 24.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/src/errors.ts CHANGED
@@ -9,13 +9,18 @@ import {
9
9
  deniedCallerFix,
10
10
  ERROR_DOCS_URL,
11
11
  hasErrorCode,
12
- registerErrorCodes,
13
12
  renderFixShellArg,
14
13
  retryForStatus,
15
14
  UltimateError,
15
+ withStatedDelay,
16
16
  } from '@ultimat3/core';
17
17
  import type { SurfaceDenial } from '@ultimat3/policy';
18
18
  import type { ValidationIssue } from '@ultimat3/schema';
19
+ // The titles are `error-titles.ts`, imported bare: this module is then side-effect-free and the
20
+ // array does not list it, so a chunk keeps only the classes it constructs, and a chunk that
21
+ // constructs one still registers every title first — anchored by use. The barrel imports it bare
22
+ // too, for the browser that rebuilds a SERVER's refusal by code and constructs nothing from here.
23
+ import './error-titles';
19
24
 
20
25
  // Re-exported, not re-declared: the idempotency failures moved to their own file when this
21
26
  // one reached the line ceiling, and every importer still reads them from `./errors`.
@@ -27,52 +32,10 @@ export {
27
32
  IdempotencyReplayedFailureError,
28
33
  IdempotencyReservationLostError,
29
34
  IdempotencyStatusUnknownError,
35
+ IdempotentReplayRedactedError,
30
36
  MutatorNotIdempotentError,
31
37
  } from './errors-idempotency';
32
38
 
33
- /**
34
- * Titles for the framework-wide code table — every one of them owned by this package.
35
- * `X_INPUT_INVALID` and `X_RPC_FAILED` are action's: an action is where an input schema is
36
- * enforced and where the typed client speaks, and `@ultimat3/query` only throws them.
37
- * Authz codes are absent on purpose — `ActionDeniedError` re-uses the policy decision's code.
38
- */
39
- const OWNED_TITLES: Readonly<Record<string, string>> = {
40
- X_ACTION_DUPLICATE: 'two actions are registered under one name',
41
- X_AUDIT_SINK_FAILED: 'an audited action ran and the audit sink refused its record',
42
- X_AUDIT_SINK_MISSING: 'an action declares audit: true and no audit sink is installed',
43
- X_ACTION_DEPRECATION_INVALID: 'an action declares a deprecation whose dates cannot be rendered',
44
- X_ACTION_PATH_DUPLICATE: 'two actions derive one HTTP path',
45
- X_ACTION_FOREIGN: 'a value that is not an action was projected as one',
46
- X_ACTION_POLICY_MISSING: 'an action was registered without a policy',
47
- X_ACTION_UNREGISTERED: 'an action was projected before it was registered',
48
- X_CONTRACT_DRIFT: 'client and server disagree about the contract',
49
- X_IDEMPOTENCY_CONFLICT: 'idempotency key reused with a different payload or still in flight',
50
- X_IDEMPOTENCY_KEY_INVALID: 'an Idempotency-Key was sent that cannot identify one request',
51
- X_IDEMPOTENCY_NOT_SHARED:
52
- 'idempotency is declared fleet-wide and the installed store is per-process',
53
- X_IDEMPOTENCY_REPLAYED_FAILURE:
54
- 'a retried Idempotency-Key replays a first attempt that failed after it may have committed',
55
- X_IDEMPOTENCY_STATUS_UNKNOWN: 'an idempotency record holds a status this build cannot read',
56
- X_INPUT_INVALID: 'input failed schema validation',
57
- X_MUTATOR_CLOCK_MISSING:
58
- "a mutator declares conflict: 'last-write-wins' and its entity has no number clock column",
59
- X_OUTPUT_INVALID: 'a handler returned a value its output schema rejects',
60
- X_RPC_FAILED: 'an RPC call failed without a problem+json body',
61
- X_ACTION_HTTP_PATH_INVALID: "an action's pinned http.path is not a static lowercase path",
62
- X_ACTION_PATH_STYLE_INVALID: "defineApi's http.pathStyle is not a known style",
63
- X_OPENAPI_CONFIG_INVALID: "defineApi's openapi block cannot produce a valid document",
64
- X_ACTION_PATH_DERIVED_EARLY: 'a path was derived before pathStyle changed',
65
- X_MUTATOR_NOT_IDEMPOTENT: 'a mutator is declared without idempotent: true',
66
- X_IDEMPOTENCY_RESERVATION_LOST:
67
- "an idempotent action's reservation was taken over before its transaction could settle it",
68
- };
69
-
70
- // One unconditional call: a presence guard would turn "another package claims one of these codes"
71
- // from an X_ERROR_CODE_DUPLICATE at import into whichever module loaded first deciding the title.
72
- registerErrorCodes(
73
- Object.fromEntries(Object.entries(OWNED_TITLES).map(([code, title]) => [code, { title }])),
74
- );
75
-
76
39
  /** Thrown when a projection needs a name the action does not have yet. */
77
40
  export class ActionUnregisteredError extends UltimateError {
78
41
  constructor() {
@@ -129,7 +92,7 @@ export function denialReason(denial: SurfaceDenial): string {
129
92
  }
130
93
 
131
94
  /**
132
- * An authz denial, thrown by `guard()`. The code and reason come from the policy
95
+ * An authz denial, thrown by `guardAction()`. The code and reason come from the policy
133
96
  * decision — this package never invents an authz code — and the surface-shaped
134
97
  * denial rides along for projections that render it themselves.
135
98
  */
@@ -224,7 +187,7 @@ export class InputInvalidError extends UltimateError {
224
187
  super({
225
188
  code: 'X_INPUT_INVALID',
226
189
  cause: `input for ${subject} "${name}" failed validation: ${detail}`,
227
- fix: `x actions describe ${name} --json # prints the expected input schema`,
190
+ fix: `x actions describe ${renderFixShellArg(name, '<action-name>')} --json # prints the expected input schema`,
228
191
  // A remote caller holds the published schema — the MCP `inputSchema`, the OpenAPI request
229
192
  // body — and cannot run `x actions describe`.
230
193
  callerFix:
@@ -245,7 +208,7 @@ export class OutputInvalidError extends UltimateError {
245
208
  super({
246
209
  code: 'X_OUTPUT_INVALID',
247
210
  cause: `action "${name}" returned a value its output schema rejects: ${detail}`,
248
- fix: `x actions describe ${name} --json # compare the handler's return against \`output:\``,
211
+ fix: `x actions describe ${renderFixShellArg(name, '<action-name>')} --json # compare the handler's return against \`output:\``,
249
212
  });
250
213
  }
251
214
  }
@@ -294,6 +257,13 @@ export interface RemoteFailure {
294
257
  * so an island reads `error.meta.sessionId` where it used to run a regex over `cause`.
295
258
  */
296
259
  readonly meta?: Readonly<Record<string, unknown>> | undefined;
260
+ /**
261
+ * The document's `title`, already read by core's `remoteTitleOf` — used only when this bundle
262
+ * registered no title for `code`, which is every http refusal in an island.
263
+ */
264
+ readonly title?: string | undefined;
265
+ /** The delay the answer's `Retry-After` named, in seconds (core's `retryAfterSecondsOf`). */
266
+ readonly retryAfterSeconds?: number | undefined;
297
267
  }
298
268
 
299
269
  /** A link, not a string the server happened to put in a field the overlay renders as an href. */
@@ -346,19 +316,27 @@ export class RemoteActionError extends UltimateError {
346
316
  // this build may never have heard of. `UltimateError` otherwise fills `retry` from
347
317
  // `retryFor(code)`, which fails closed — so a 503 out of a typed call announced itself as
348
318
  // `terminal` on the one field the framework promises a client never has to infer.
349
- retry: retryForStatus(failure.code, failure.status),
350
- meta: {
351
- // The server's declared keys FIRST, so the four this class owns win a collision: a server
352
- // meta naming `status` would otherwise overwrite the one a report reads the HTTP status
353
- // from, and `origin: 'remote'` is what marks the code as one this bundle never declared.
354
- ...failure.meta,
355
- origin: 'remote',
356
- action: failure.action,
357
- status: failure.status,
358
- // Absent rather than `undefined`: `meta` is rendered into `--json` and the error reporter,
359
- // and a null member reads as "the server sent an empty list" rather than "it sent none".
360
- ...(failure.issues === undefined ? {} : { issues: failure.issues }),
361
- },
319
+ // A 429 or 503 that stated a delay is `retry-after` whoever registered the code, and the
320
+ // delay rides on `meta.retryAfterSeconds`, the one key core's `statedDelayMs` reads.
321
+ retry: retryForStatus(failure.code, failure.status, failure.retryAfterSeconds),
322
+ remoteTitle: failure.title,
323
+ // Core's `withStatedDelay`, the rule `problemError` applies too: a `retryAfterSeconds` off the
324
+ // body is dropped and only the header's is carried — the server never writes one there.
325
+ meta: withStatedDelay(
326
+ {
327
+ // The server's declared keys FIRST, so the four this class owns win a collision: a
328
+ // server meta naming `status` would otherwise overwrite the one a report reads the HTTP
329
+ // status from, and `origin: 'remote'` marks the code as one this bundle never declared.
330
+ ...failure.meta,
331
+ origin: 'remote',
332
+ action: failure.action,
333
+ status: failure.status,
334
+ // Absent rather than `undefined`: `meta` is rendered into `--json` and the error
335
+ // reporter, and a null member reads as "the server sent an empty list".
336
+ ...(failure.issues === undefined ? {} : { issues: failure.issues }),
337
+ },
338
+ failure.retryAfterSeconds,
339
+ ),
362
340
  });
363
341
  this.status = failure.status;
364
342
  }
@@ -375,7 +353,7 @@ export class RpcFailedError extends UltimateError {
375
353
  super({
376
354
  code: 'X_RPC_FAILED',
377
355
  cause: `${name} returned HTTP ${status} without a problem+json body`,
378
- fix: `check the gateway in front of the app, then: x actions describe ${name} --json`,
356
+ fix: `check the gateway in front of the app, then: x actions describe ${renderFixShellArg(name, '<action-name>')} --json`,
379
357
  retry: retryForStatus('X_RPC_FAILED', status),
380
358
  });
381
359
  }
@@ -393,7 +371,7 @@ export class AuditSinkMissingError extends UltimateError {
393
371
  super({
394
372
  code: 'X_AUDIT_SINK_MISSING',
395
373
  cause: `action "${action}" declares \`audit: true\` and no audit sink is installed`,
396
- fix: "call setAuditSink(yourSink) from '@ultimat3/action' at boot, before registerActions()",
374
+ fix: "call setAuditSink(yourSink) from '@ultimat3/core' at boot, before registerActions()",
397
375
  });
398
376
  }
399
377
  }
package/src/facade.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  /**
2
- * The fluent surface: every projection reachable as a method on the action itself,
3
- * `publishPost.tool()` rather than `toMcpTool(publishPost)`, and every declared
4
- * field lifted off `def` so app code never reaches through `.def`. The projection
5
- * functions stay exported for the framework's own call sites — this file only
6
- * binds them to the action, it never re-implements one.
2
+ * The fluent surface: every projection this package owns reachable as a method on the action
3
+ * itself, `publishPost.openapi()` rather than `toOpenApiOperation(publishPost)`, and every
4
+ * declared field lifted off `def` so app code never reaches through `.def`. The projection
5
+ * functions stay exported for the framework's own call sites — this file only binds them to the
6
+ * action, it never re-implements one. There is no `.tool()`: the MCP tool is `@ultimat3/mcp`'s
7
+ * one projection (`toolFrom(publishPost)`), a tier this package cannot import.
7
8
  */
8
9
 
9
10
  import type { InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
@@ -13,7 +14,6 @@ import { contractTestsFor } from './contract-test';
13
14
  import { toOpenApiOperation } from './http';
14
15
  import { actionName, invoke } from './invoke';
15
16
  import { toJobHandle } from './job-handle';
16
- import { toMcpTool } from './mcp-tool';
17
17
 
18
18
  /**
19
19
  * `self` is a thunk on purpose: the façade is attached while the action is still
@@ -33,7 +33,6 @@ export function facadeFor<TInput extends StandardSchemaV1, TOutput extends Stand
33
33
  // the output type is this action's by construction.
34
34
  as: (actor, input, options) =>
35
35
  invoke(self(), input, { ...options, actor }) as Promise<InferOutput<TOutput>>,
36
- tool: () => toMcpTool(self()),
37
36
  openapi: () => toOpenApiOperation(self()),
38
37
  client: (options) => clientMethodFor(actionName(self()), options, def.http?.path),
39
38
  job: () => toJobHandle(self()),
package/src/http-path.ts CHANGED
@@ -69,7 +69,7 @@ export function setActionPathStyle(next: unknown): ActionPathStyle {
69
69
  return style;
70
70
  }
71
71
 
72
- /** Test seam, beside `resetRegistry`. */
72
+ /** Test seam, beside `resetActions`. */
73
73
  /**
74
74
  * Forget which paths were handed out, keeping the style. `@ultimat3/testing` calls it between two
75
75
  * test files of one worker (22.7, no `--isolate`): a path an earlier FILE derived is not one this
package/src/http.ts CHANGED
@@ -6,10 +6,15 @@
6
6
  */
7
7
 
8
8
  import { tagKeys } from '@ultimat3/cache';
9
+ import type { Deprecation } from '@ultimat3/core';
9
10
  import {
11
+ IDEMPOTENCY_HEADER,
10
12
  isMcpExposed,
11
13
  RECORDS_OPENAPI_HEADER,
14
+ recordDeprecatedCall,
12
15
  recordEnvelopeSchema,
16
+ renderDeprecation,
17
+ useContext,
13
18
  withWriteOrigin,
14
19
  writeDigest,
15
20
  } from '@ultimat3/core';
@@ -17,10 +22,9 @@ import type { Route, RouteMeta, UltimateRequest } from '@ultimat3/http';
17
22
  // `toBucket` is `@ultimat3/http`'s, not this package's: http owns `Bucket` and the limiter maths,
18
23
  // and `@ultimat3/query` needs the identical conversion while being the same tier as this one — so
19
24
  // a copy here would be a second answer to "what does this limit mean" for the read half.
20
- import { json, redirect, takeRedirect, toBucket } from '@ultimat3/http';
25
+ import { jsonResponse, publishRateLimit, redirect, takeRedirect, toBucket } from '@ultimat3/http';
26
+ import { admitsAnonymous, policyCapability, policyPermissions } from '@ultimat3/policy';
21
27
  import type { ActionRateLimit, AnyAction } from './action';
22
- import type { Deprecation } from './deprecation';
23
- import { recordDeprecatedCall, renderDeprecation } from './deprecation';
24
28
  import { ActionDeprecationInvalidError } from './errors';
25
29
  import { actionPathStyle, servedActionRoute } from './http-path';
26
30
  import { actionName, defOf, invoke } from './invoke';
@@ -31,16 +35,7 @@ import {
31
35
  schemaRef,
32
36
  toOperationId,
33
37
  } from './naming';
34
- import { admitsAnonymous, policyCapability, policyPermissions } from './policy-gate';
35
38
  import { carriesRecords, recordResponse } from './record-wire';
36
- import { IDEMPOTENCY_HEADER } from './wire-headers';
37
-
38
- /**
39
- * Re-exported, never re-declared. The two strings moved to `wire-headers.ts` so `client.ts` can
40
- * name them without pulling this file's server graph into a browser bundle; every importer of
41
- * `./http` still reads them from here, which is what kept the move from touching a call site.
42
- */
43
- export { BUILD_ID_HEADER, IDEMPOTENCY_HEADER } from './wire-headers';
44
39
 
45
40
  export const REPLAYED_HEADER = 'x-ultimate-replayed';
46
41
 
@@ -90,6 +85,9 @@ function projectRoute(
90
85
  // Rendered ONCE, at projection: a date that cannot become a header is a mount-time refusal,
91
86
  // not a surprise on the first request — the same rule `toBucket` follows for a rate limit.
92
87
  const sunsetting = deprecationHeadersFor(name, def.deprecated);
88
+ // Same rule, for a limit the limiter could not run on: refused at mount (`X_RATE_LIMIT_INVALID`),
89
+ // never on the first request that tries to spend it.
90
+ if (def.rateLimit !== undefined) toBucket(name, def.rateLimit);
93
91
  const enveloped = carriesRecords(target.output);
94
92
 
95
93
  const handler = async (req: UltimateRequest): Promise<Response> => {
@@ -121,6 +119,9 @@ function projectRoute(
121
119
  onReplay: () => {
122
120
  replayed = true;
123
121
  },
122
+ clientAddress: req.ctx.ip,
123
+ onRateLimit: (decision) =>
124
+ publishRateLimit(req.ctx, decision, useContext().now().getTime()),
124
125
  }),
125
126
  );
126
127
  // The one thing an action's return value cannot say. `setRedirect()` inside the handler
@@ -134,7 +135,7 @@ function projectRoute(
134
135
  ? redirect(to.location, to.status)
135
136
  : enveloped
136
137
  ? recordResponse(target.output, result)
137
- : json(result);
138
+ : jsonResponse(result);
138
139
  if (key !== null) response.headers.set(REPLAYED_HEADER, replayed ? '1' : '0');
139
140
  return response;
140
141
  };
@@ -154,6 +155,9 @@ function projectRoute(
154
155
  // stage deciding first would decide from `row: null` — a denial for the row's own
155
156
  // author, from an authz system that never saw the row.
156
157
  enforcedBy: 'handler',
158
+ // Which primitive this is, for `@ultimat3/http`'s bucket check: a `rateLimit.buckets.<name>`
159
+ // named after an action limits nothing since 25.0.0, and is refused rather than ignored.
160
+ primitive: 'action',
157
161
  // `input` stays ABSENT, deliberately, exactly as `@ultimat3/query`'s `toQueryRoute` leaves it.
158
162
  // Setting it hands the action's schema to the pipeline's `body` stage, which throws
159
163
  // `bodyInvalid` — so this route answered `422 X_BODY_INVALID` for every malformed body while
@@ -167,12 +171,11 @@ function projectRoute(
167
171
  // failing a plain `route.ts`'s own schema, where no primitive owns the input.
168
172
  cache: { mode: 'no-store', tags: tagKeys(def.cache?.invalidates ?? []) },
169
173
  tags: [operationTagOf(target)],
170
- // Name AND numbers. The name alone selected a bucket the limiter's table never held, so
171
- // `bucketFor` fell through to `default` — 120 burst for an action that declared 5. The
172
- // numbers ride along and `withRouteBuckets` registers them at construction.
173
- ...(def.rateLimit === undefined
174
- ? {}
175
- : { rateLimit: name, rateLimitBucket: toBucket(name, def.rateLimit) }),
174
+ // `invoke` spends the declared bucket for every surface, so the stage spends no caller bucket
175
+ // for this route — not the action's (that was the second, HTTP-only enforcement point MCP,
176
+ // the agent tool and `.job()` never reached) and not `default` (a ceiling an action declaring
177
+ // more than it never had). The tenant allowance is still the stage's.
178
+ ...(def.rateLimit === undefined ? {} : { rateLimitedBy: 'handler' as const }),
176
179
  ...(def.mcp?.description === undefined ? {} : { description: def.mcp.description }),
177
180
  };
178
181
 
@@ -254,7 +257,7 @@ export function toOpenApiOperation(target: AnyAction): OpenApiOperation {
254
257
  invalidates: tagKeys(def.cache?.invalidates ?? []),
255
258
  // The tool name an agent would call, or `null` when there is no tool. `!== false` here
256
259
  // advertised one for every action, so an agent reading the spec asked for a tool the MCP
257
- // catalog never listed — `isMcpExposed` is the same answer `toMcpTool` gives. The NAME was
260
+ // catalog never listed — `isMcpExposed` is the same answer `@ultimat3/mcp` gives. The NAME was
258
261
  // the second half of the same defect: `toToolName` published `publish_post` while
259
262
  // `@ultimat3/mcp` served `publishPost`, so a spec-reading agent called a tool that does not
260
263
  // exist. Verbatim, and never derived — this is a published contract, not a label.
@@ -3,7 +3,7 @@
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
7
  import { IdempotencyReservationLostError } from './errors-idempotency';
8
8
  import type {
9
9
  IdempotencyFailure,
@@ -60,6 +60,7 @@ interface Flight {
60
60
  export class MemoryIdempotencyStore implements IdempotencyStore {
61
61
  /** Declared, never inferred: this map is this process's and nothing else can reach it. */
62
62
  readonly scope: IdempotencyScope = 'process';
63
+ readonly keepsRedaction = true as const;
63
64
  readonly windowMs: number;
64
65
  readonly #maxKeys: number;
65
66
  readonly #evictTo: number;
@@ -74,13 +75,13 @@ export class MemoryIdempotencyStore implements IdempotencyStore {
74
75
  // size and `now - at > windowMs` is false for every record — a table with no cap, holding keys
75
76
  // that never expire, out of a `Math.max` that reads like a guard.
76
77
  this.windowMs = finiteCount(
77
- 'MemoryIdempotencyStore',
78
+ 'memoryIdempotencyStore',
78
79
  'windowMs',
79
80
  options.windowMs ?? DEFAULT_IDEMPOTENCY_WINDOW_MS,
80
81
  1,
81
82
  );
82
83
  this.#maxKeys = finiteCount(
83
- 'MemoryIdempotencyStore',
84
+ 'memoryIdempotencyStore',
84
85
  'maxKeys',
85
86
  options.maxKeys ?? DEFAULT_MAX_IDEMPOTENCY_KEYS,
86
87
  1,
@@ -109,7 +110,7 @@ export class MemoryIdempotencyStore implements IdempotencyStore {
109
110
  return Promise.resolve({ record: existing, created: false });
110
111
  }
111
112
  const record: IdempotencyRecord = {
112
- id: uuid(),
113
+ id: uuidV7(),
113
114
  key,
114
115
  requestHash,
115
116
  status: 'in-flight',
@@ -132,7 +133,7 @@ export class MemoryIdempotencyStore implements IdempotencyStore {
132
133
  if (record.status !== 'in-flight' || flight === undefined) return false;
133
134
  if (!flight.bound || flight.settling) return false;
134
135
  const reclaimAfterMs = finiteCount(
135
- 'MemoryIdempotencyStore',
136
+ 'memoryIdempotencyStore',
136
137
  'reclaimAfterMs',
137
138
  this.#reclaimAfterMs(),
138
139
  );
@@ -148,11 +149,18 @@ export class MemoryIdempotencyStore implements IdempotencyStore {
148
149
  * again — which is why the id is half the fence. Both stores fence, or the guarantee is
149
150
  * whichever store the deployment happens to install.
150
151
  */
151
- settle(key: string, value: unknown, reservationId: string): Promise<void> {
152
+ settle(key: string, value: unknown, reservationId: string, redacted: boolean): Promise<void> {
152
153
  const existing = this.#owned(key, reservationId);
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
+ });
153
161
  const tx = liveTransaction();
154
162
  if (tx === undefined) {
155
- if (existing !== undefined) this.#records.set(key, { ...existing, status: 'settled', value });
163
+ if (existing !== undefined) this.#records.set(key, settled(existing));
156
164
  return Promise.resolve();
157
165
  }
158
166
  // Inside a transaction the record is settled BY the commit, as the Postgres store's is: a
@@ -170,7 +178,7 @@ export class MemoryIdempotencyStore implements IdempotencyStore {
170
178
  tx.onCommit(() => {
171
179
  // Still this reservation's: `settling` kept a reclaim away, and `release` may have dropped it.
172
180
  if (this.#records.get(key) === existing) {
173
- this.#records.set(key, { ...existing, status: 'settled', value });
181
+ this.#records.set(key, settled(existing));
174
182
  }
175
183
  });
176
184
  return Promise.resolve();
@@ -235,3 +243,13 @@ export class MemoryIdempotencyStore implements IdempotencyStore {
235
243
  // becomes sweepable the moment it ages past the window.
236
244
  }
237
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
+ }
@@ -5,7 +5,7 @@
5
5
  * Statements are spelled out so an agent can run the exact one it saw in a log.
6
6
  */
7
7
  import type { PgExecutor } from '@ultimat3/core';
8
- import { finiteCount, logger, uuid } from '@ultimat3/core';
8
+ import { finiteCount, logger, uuidV7 } from '@ultimat3/core';
9
9
  import { IdempotencyReservationLostError, IdempotencyStatusUnknownError } from './errors';
10
10
  import type {
11
11
  IdempotencyFailure,
@@ -39,6 +39,8 @@ create table if not exists x_idempotency (
39
39
  create index if not exists x_idempotency_created_at_idx on x_idempotency (created_at);
40
40
 
41
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;
42
44
  `;
43
45
 
44
46
  /**
@@ -62,18 +64,19 @@ on conflict (key) do update
62
64
  status = 'in-flight',
63
65
  value = null,
64
66
  failure = null,
67
+ redacted = false,
65
68
  tx_bound = excluded.tx_bound,
66
69
  created_at = now()
67
70
  where x_idempotency.created_at < now() - make_interval(secs => $4::double precision)
68
71
  or (x_idempotency.status = 'in-flight'
69
72
  and x_idempotency.tx_bound
70
73
  and x_idempotency.created_at < now() - make_interval(secs => $6::double precision))
71
- returning key, id, request_hash, status, value, failure,
74
+ returning key, id, request_hash, status, value, failure, redacted,
72
75
  (extract(epoch from created_at) * 1000)::bigint as created_at
73
76
  `;
74
77
 
75
78
  export const SQL_IDEMPOTENCY_GET = `
76
- select key, id, request_hash, status, value, failure,
79
+ select key, id, request_hash, status, value, failure, redacted,
77
80
  (extract(epoch from created_at) * 1000)::bigint as created_at
78
81
  from x_idempotency
79
82
  where key = $1
@@ -93,13 +96,15 @@ select key, id, request_hash, status, value, failure,
93
96
  * the refusal observable: an update matching no row is indistinguishable from one that matched.
94
97
  */
95
98
  export const SQL_IDEMPOTENCY_SETTLE = `
96
- 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
97
101
  where key = $1 and id = $3::uuid and status = 'in-flight'
98
102
  returning key
99
103
  `;
100
104
 
101
105
  export const SQL_IDEMPOTENCY_FAIL = `
102
- 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
103
108
  where key = $1 and id = $3::uuid and status = 'in-flight'
104
109
  returning key
105
110
  `;
@@ -117,6 +122,8 @@ interface IdempotencyRow {
117
122
  readonly status: string;
118
123
  readonly value: unknown;
119
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;
120
127
  /** `bigint`, which every Postgres client hands back as a string. */
121
128
  readonly created_at: number | string;
122
129
  }
@@ -220,6 +227,7 @@ export function postgresIdempotencyStore(
220
227
 
221
228
  return {
222
229
  scope: 'shared',
230
+ keepsRedaction: true,
223
231
  windowMs,
224
232
 
225
233
  async reserve(key, requestHash): Promise<IdempotencyReservation> {
@@ -232,7 +240,7 @@ export function postgresIdempotencyStore(
232
240
  // transaction is the SETTLE — and the row says so, which is what makes it reclaimable.
233
241
  const claimed = await exec.query<IdempotencyRow>(SQL_IDEMPOTENCY_RESERVE, [
234
242
  key,
235
- uuid(),
243
+ uuidV7(),
236
244
  requestHash,
237
245
  windowSecs,
238
246
  boundTx() !== undefined,
@@ -247,7 +255,7 @@ export function postgresIdempotencyStore(
247
255
  // that matters, so this is the honest answer: the caller sees the in-flight refusal.
248
256
  return {
249
257
  record: {
250
- id: uuid(),
258
+ id: uuidV7(),
251
259
  key,
252
260
  requestHash,
253
261
  status: 'in-flight',
@@ -258,8 +266,10 @@ export function postgresIdempotencyStore(
258
266
  };
259
267
  },
260
268
 
261
- async settle(key, value, reservationId): Promise<void> {
262
- const params = [key, JSON.stringify(value ?? null), reservationId];
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];
263
273
  const tx = boundTx();
264
274
  if (tx === undefined) {
265
275
  fenced(await exec.query(SQL_IDEMPOTENCY_SETTLE, params), key, reservationId, 'settle');
@@ -338,6 +348,7 @@ function toRecord(row: IdempotencyRow): IdempotencyRecord {
338
348
  status: row.status,
339
349
  value: row.value,
340
350
  ...(failure === undefined ? {} : { failure }),
351
+ ...(row.redacted === true ? { redacted: true } : {}),
341
352
  createdAt: Number(row.created_at),
342
353
  };
343
354
  }