@ultimat3/action 22.15.0 → 24.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/define-api.ts CHANGED
@@ -50,16 +50,28 @@ type Registered<TModule, TKind extends string> = {
50
50
  };
51
51
 
52
52
  /**
53
- * Intersect a tuple of module namespaces. `{ createPost } & { inviteMember }` is the map the
53
+ * Intersect a list of module namespaces. `{ createPost } & { inviteMember }` is the map the
54
54
  * typed client indexes, so `rpc<Api['actions']>()` knows every action without a codegen step.
55
+ *
56
+ * Never by recursion over the tuple. `Registered<Head> & Merge<Rest>` is not a tail call, so the
57
+ * compiler spent one level of its instantiation depth per MODULE and answered TS2589 at the 48th
58
+ * entry of one list — whatever the number of actions inside them (measured: 30 modules of 10 pass,
59
+ * 48 modules of 1 fail). The list is read as a union and intersected in one step, so the depth is
60
+ * constant and `client-scale-pins.ts` holds it there.
55
61
  */
56
62
  type Merge<TModules, TKind extends string> = [TModules] extends [undefined]
57
63
  ? EmptyModule
58
- : TModules extends readonly [infer THead, ...infer TRest]
59
- ? Registered<THead, TKind> & Merge<TRest, TKind>
60
- : TModules extends readonly []
61
- ? EmptyModule
62
- : Registered<TModules, TKind>;
64
+ : TModules extends readonly unknown[]
65
+ ? MergeAll<TModules[number], TKind>
66
+ : Registered<TModules, TKind>;
67
+
68
+ /** An empty list has no members to intersect — and `unknown` is not a map `rpc` accepts. */
69
+ type MergeAll<TModule, TKind extends string> = [TModule] extends [never]
70
+ ? EmptyModule
71
+ : Intersect<TModule extends unknown ? (map: Registered<TModule, TKind>) => void : never>;
72
+
73
+ /** A union of one-parameter functions infers that parameter as the intersection of them all. */
74
+ type Intersect<TFns> = [TFns] extends [(map: infer TMap) => void] ? TMap : never;
63
75
 
64
76
  type EmptyModule = Readonly<Record<never, never>>;
65
77
 
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The five idempotency failures, split out of `errors.ts` at its line ceiling. One subclass per
2
+ * The idempotency failures, split out of `errors.ts` at its line ceiling. One subclass per
3
3
  * stable code, exactly as there — the codes and their titles stay in `errors.ts`'s one
4
4
  * `registerErrorCodes` call, because a second registration is how two modules end up deciding a
5
5
  * title by load order.
@@ -77,7 +77,7 @@ export class IdempotencyNotSharedError extends UltimateError {
77
77
  // positional form is `unsafe`), so that line compiled and would have thrown on the first
78
78
  // reservation. The framework's own boot already installs this store; a host booting the
79
79
  // framework itself wraps the client it opened.
80
- fix: "the framework boot installs a shared store — reach this only from a host that boots it itself: setIdempotencyStore(postgresIdempotencyStore({ executor: { query: (text, values) => client.query({ text, values }) } })) from '@ultimat3/action', or drop the declaration to configureIdempotency({ scope: 'process' })",
80
+ fix: "the framework boot installs a shared store — reach this only from a host that boots it itself: setIdempotencyStore(postgresIdempotencyStore({ executor: { query: (text, values) => client.query({ text, values }) }, origin: () => client, reclaimAfterMs: requestDeadlineMs })) from '@ultimat3/action', or drop the declaration to configureIdempotency({ scope: 'process' })",
81
81
  meta: { storeScope: storeScope ?? null },
82
82
  });
83
83
  }
@@ -138,3 +138,37 @@ export class IdempotencyStatusUnknownError extends UltimateError {
138
138
  });
139
139
  }
140
140
  }
141
+
142
+ /**
143
+ * A mutator declared without `idempotent: true`. Refused at declaration: the client replays a
144
+ * mutator's write under one `Idempotency-Key` (a retry, an offline queue draining after a lost
145
+ * response), and `toRoute` reads that key only for an idempotent action — so the alternative is a
146
+ * toggle that flips back, with nothing in any log to say the handler ran twice.
147
+ */
148
+ export class MutatorNotIdempotentError extends UltimateError {
149
+ constructor() {
150
+ super({
151
+ code: 'X_MUTATOR_NOT_IDEMPOTENT',
152
+ cause:
153
+ 'a mutator is declared without idempotent: true, so a replay of its write under the same Idempotency-Key would run the server half a second time',
154
+ fix: 'add `idempotent: true` to the mutator definition',
155
+ });
156
+ }
157
+ }
158
+
159
+ /**
160
+ * A settlement inside the handler's transaction matched no record: the attempt ran past the
161
+ * request deadline, a retry reclaimed the key, and this one no longer owns it. THROWN, where the
162
+ * autocommit path only logs — nothing here has committed, so the throw rolls this attempt's
163
+ * writes back and the retry's are the only ones stored.
164
+ */
165
+ export class IdempotencyReservationLostError extends UltimateError {
166
+ constructor(key: string) {
167
+ super({
168
+ code: 'X_IDEMPOTENCY_RESERVATION_LOST',
169
+ cause: `idempotency key "${key}" was reclaimed by a retry while this attempt was still in flight past the request deadline, so its transaction is rolled back rather than committed beside the retry's`,
170
+ fix: 'resend this request with the same Idempotency-Key — the attempt that took the key over answers it, and this one was rolled back',
171
+ meta: { key },
172
+ });
173
+ }
174
+ }
package/src/errors.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Every failure @ultimat3/action can produce, one subclass per stable code so
3
3
  * callers `instanceof` a specific failure instead of string-matching a message.
4
- * The idempotency five live in `errors-idempotency.ts` — this file reached the line ceiling —
4
+ * The idempotency failures live in `errors-idempotency.ts` — this file reached the line ceiling —
5
5
  * and are re-exported here, so `./errors` stays the one import path for all of them.
6
6
  */
7
7
  import {
@@ -17,7 +17,7 @@ import {
17
17
  import type { SurfaceDenial } from '@ultimat3/policy';
18
18
  import type { ValidationIssue } from '@ultimat3/schema';
19
19
 
20
- // Re-exported, not re-declared: the five idempotency failures moved to their own file when this
20
+ // Re-exported, not re-declared: the idempotency failures moved to their own file when this
21
21
  // one reached the line ceiling, and every importer still reads them from `./errors`.
22
22
  export type { IdempotencyConflictReason, IdempotencyKeyProblem } from './errors-idempotency';
23
23
  export {
@@ -25,7 +25,9 @@ export {
25
25
  IdempotencyKeyInvalidError,
26
26
  IdempotencyNotSharedError,
27
27
  IdempotencyReplayedFailureError,
28
+ IdempotencyReservationLostError,
28
29
  IdempotencyStatusUnknownError,
30
+ MutatorNotIdempotentError,
29
31
  } from './errors-idempotency';
30
32
 
31
33
  /**
@@ -60,6 +62,9 @@ const OWNED_TITLES: Readonly<Record<string, string>> = {
60
62
  X_ACTION_PATH_STYLE_INVALID: "defineApi's http.pathStyle is not a known style",
61
63
  X_OPENAPI_CONFIG_INVALID: "defineApi's openapi block cannot produce a valid document",
62
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",
63
68
  };
64
69
 
65
70
  // One unconditional call: a presence guard would turn "another package claims one of these codes"
@@ -4,6 +4,7 @@
4
4
  * Shaped after `@ultimat3/http`'s `memoryRateLimitStore`, including the deliberate eviction order.
5
5
  */
6
6
  import { finiteCount, uuid } 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,6 +37,24 @@ 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 {
@@ -43,6 +64,8 @@ export class MemoryIdempotencyStore implements IdempotencyStore {
43
64
  readonly #maxKeys: number;
44
65
  readonly #evictTo: number;
45
66
  readonly #now: () => number;
67
+ readonly #reclaimAfterMs: () => number;
68
+ readonly #flights = new WeakMap<IdempotencyRecord, Flight>();
46
69
  readonly #records = new Map<string, IdempotencyRecord>();
47
70
  #lastSweepMs = Number.NEGATIVE_INFINITY;
48
71
 
@@ -65,6 +88,7 @@ export class MemoryIdempotencyStore implements IdempotencyStore {
65
88
  // Batched down to 90% so the eviction sort is paid once per 10% of the cap, not per write.
66
89
  this.#evictTo = Math.max(1, Math.floor(this.#maxKeys * 0.9));
67
90
  this.#now = options.now ?? ((): number => Date.now());
91
+ this.#reclaimAfterMs = options.reclaimAfterMs ?? requestDeadlineMs;
68
92
  }
69
93
 
70
94
  /** Records tracked right now — the bound, observable. */
@@ -77,7 +101,11 @@ export class MemoryIdempotencyStore implements IdempotencyStore {
77
101
  const existing = this.#records.get(key);
78
102
  // Expired is missing. A record past the window answers exactly as a first-ever key does, so
79
103
  // reclaiming it here is what makes the window mean something rather than being a comment.
80
- if (existing !== undefined && !this.#expired(existing, nowMs)) {
104
+ if (
105
+ existing !== undefined &&
106
+ !this.#expired(existing, nowMs) &&
107
+ !this.#abandoned(existing, nowMs)
108
+ ) {
81
109
  return Promise.resolve({ record: existing, created: false });
82
110
  }
83
111
  const record: IdempotencyRecord = {
@@ -89,10 +117,28 @@ export class MemoryIdempotencyStore implements IdempotencyStore {
89
117
  createdAt: nowMs,
90
118
  };
91
119
  this.#records.set(key, record);
120
+ this.#flights.set(record, { bound: liveTransaction() !== undefined, settling: false });
92
121
  this.#maintain(nowMs);
93
122
  return Promise.resolve({ record, created: true });
94
123
  }
95
124
 
125
+ /**
126
+ * An in-flight record past the request deadline whose settle was BOUND to a transaction: that
127
+ * settle lands with the commit or not at all, so the record being in flight proves nothing
128
+ * committed. An autocommit handler's record is never this — it may have written before it died.
129
+ */
130
+ #abandoned(record: IdempotencyRecord, nowMs: number): boolean {
131
+ const flight = this.#flights.get(record);
132
+ if (record.status !== 'in-flight' || flight === undefined) return false;
133
+ if (!flight.bound || flight.settling) return false;
134
+ const reclaimAfterMs = finiteCount(
135
+ 'MemoryIdempotencyStore',
136
+ 'reclaimAfterMs',
137
+ this.#reclaimAfterMs(),
138
+ );
139
+ return reclaimAfterMs > 0 && nowMs - record.createdAt >= reclaimAfterMs;
140
+ }
141
+
96
142
  /**
97
143
  * Both settlements are FENCED on the reservation's own `id` AND on `in-flight`, as
98
144
  * `SQL_IDEMPOTENCY_SETTLE` is and as `@ultimat3/jobs`' `SQL_ACK` is. A record past the window is
@@ -104,9 +150,29 @@ export class MemoryIdempotencyStore implements IdempotencyStore {
104
150
  */
105
151
  settle(key: string, value: unknown, reservationId: string): Promise<void> {
106
152
  const existing = this.#owned(key, reservationId);
107
- if (existing !== undefined) {
108
- this.#records.set(key, { ...existing, status: 'settled', value });
153
+ const tx = liveTransaction();
154
+ if (tx === undefined) {
155
+ if (existing !== undefined) this.#records.set(key, { ...existing, status: 'settled', value });
156
+ return Promise.resolve();
157
+ }
158
+ // Inside a transaction the record is settled BY the commit, as the Postgres store's is: a
159
+ // rollback leaves it in flight rather than replaying a success for rows nobody stored. And a
160
+ // settle that lost its reservation is thrown, because nothing has committed yet and the throw
161
+ // is what rolls this attempt back beside the retry that replaced it.
162
+ if (existing === undefined) {
163
+ return Promise.reject(new IdempotencyReservationLostError(key));
109
164
  }
165
+ const flight = this.#flights.get(existing);
166
+ if (flight !== undefined) flight.settling = true;
167
+ tx.onRollback(() => {
168
+ if (flight !== undefined) flight.settling = false;
169
+ });
170
+ tx.onCommit(() => {
171
+ // Still this reservation's: `settling` kept a reclaim away, and `release` may have dropped it.
172
+ if (this.#records.get(key) === existing) {
173
+ this.#records.set(key, { ...existing, status: 'settled', value });
174
+ }
175
+ });
110
176
  return Promise.resolve();
111
177
  }
112
178
 
@@ -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 type { PgExecutor } from '@ultimat3/core';
7
8
  import { finiteCount, logger, uuid } from '@ultimat3/core';
8
- import { IdempotencyStatusUnknownError } from './errors';
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,25 +37,37 @@ 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;
54
42
  `;
55
43
 
56
44
  /**
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.
45
+ * The reservation, atomic in one statement. The `do update` fires ONLY for a row that answers as a
46
+ * missing one, so a returned row always means this caller owns the reservation and must run the
47
+ * handler. No row back means a live record exists and belongs to someone else.
48
+ *
49
+ * Two rows answer as missing. One outside the window. And one still `in-flight` past the request
50
+ * deadline (`$6`) whose settlement was BOUND TO A TRANSACTION (`tx_bound`): that settle commits
51
+ * with the handler's writes or not at all, so the record being in flight proves nothing committed
52
+ * — the process died, or the unit of work rolled back. An autocommit handler's record is never
53
+ * reclaimed this way: "died before the write" and "wrote, then died before the settle" are one
54
+ * row, and re-running the second is the double charge.
61
55
  */
62
56
  export const SQL_IDEMPOTENCY_RESERVE = `
63
- insert into x_idempotency (key, id, request_hash, status)
64
- values ($1, $2, $3, 'in-flight')
57
+ insert into x_idempotency (key, id, request_hash, status, tx_bound)
58
+ values ($1, $2, $3, 'in-flight', $5)
65
59
  on conflict (key) do update
66
60
  set id = excluded.id,
67
61
  request_hash = excluded.request_hash,
68
62
  status = 'in-flight',
69
63
  value = null,
70
64
  failure = null,
65
+ tx_bound = excluded.tx_bound,
71
66
  created_at = now()
72
67
  where x_idempotency.created_at < now() - make_interval(secs => $4::double precision)
68
+ or (x_idempotency.status = 'in-flight'
69
+ and x_idempotency.tx_bound
70
+ and x_idempotency.created_at < now() - make_interval(secs => $6::double precision))
73
71
  returning key, id, request_hash, status, value, failure,
74
72
  (extract(epoch from created_at) * 1000)::bigint as created_at
75
73
  `;
@@ -125,7 +123,24 @@ interface IdempotencyRow {
125
123
 
126
124
  export interface PostgresIdempotencyStoreOptions {
127
125
  readonly executor: PgExecutor;
126
+ /**
127
+ * The client transactions on THIS database are opened on — `baseClient` from `@ultimat3/db` for
128
+ * the store the boot installs. Compared by identity with an open transaction's `origin`: the
129
+ * settle rides a transaction on this database and no other. One opened elsewhere
130
+ * (`withTransaction(fn, { client: shard })`) has no `x_idempotency` to settle in, so the record
131
+ * is settled on the pool, exactly as an autocommit handler's is. REQUIRED: a store that could
132
+ * not tell would send the settle to whichever database the handler happened to be writing.
133
+ */
134
+ readonly origin: () => object;
128
135
  readonly windowMs?: number | undefined;
136
+ /**
137
+ * The request deadline in milliseconds, read at every reservation: how long an in-flight record
138
+ * whose settlement rides a transaction is kept before a retry may take the key. REQUIRED and a
139
+ * function — the deadline is the app's (`requestTimeoutMs`), declared after this store is built,
140
+ * and a number restated here would silently disagree with it. `0` is "no deadline": nothing is
141
+ * reclaimed before the window. Never applies to an autocommit handler's record.
142
+ */
143
+ readonly reclaimAfterMs: () => number;
129
144
  /**
130
145
  * Injectable, exactly as `MemoryIdempotencyStoreOptions.now` is. The two stores are one seam and
131
146
  * a caller must be able to drive either from the same clock; a hardcoded `Date.now()` here made
@@ -148,7 +163,7 @@ export interface PostgresIdempotencyStore extends IdempotencyStore {
148
163
  /**
149
164
  * **The boot installs this for you — an app declares the scope and nothing else.**
150
165
  * `@ultimat3/cli`'s `startServices` builds a `PgExecutor` from the client it already resolved and
151
- * calls `setIdempotencyStore(postgresIdempotencyStore({ executor }))` before `loadApp`, so the
166
+ * calls `setIdempotencyStore(postgresIdempotencyStore({ executor, origin, reclaimAfterMs }))` before `loadApp`, so the
152
167
  * store is in place by the time `registerAction` evaluates a declaration against it. All an app
153
168
  * owes is the one line `x new` scaffolds into `apps/web/server.ts`:
154
169
  *
@@ -165,6 +180,8 @@ export interface PostgresIdempotencyStore extends IdempotencyStore {
165
180
  * setIdempotencyStore(
166
181
  * postgresIdempotencyStore({
167
182
  * executor: { query: (text, values) => client.query({ text, values }) },
183
+ * origin: () => client,
184
+ * reclaimAfterMs: requestDeadlineMs, // the app's `requestTimeoutMs`, from '@ultimat3/action'
168
185
  * }),
169
186
  * );
170
187
  * ```
@@ -181,7 +198,18 @@ export function postgresIdempotencyStore(
181
198
  1,
182
199
  );
183
200
  const windowSecs = windowMs / 1000;
201
+ // Screened on every read, like the window above: the value is the app's and arrives late. Never
202
+ // past the window, which already frees the record — and `0` means exactly that.
203
+ const reclaimSecs = (): number => {
204
+ const ms = finiteCount('postgresIdempotencyStore', 'reclaimAfterMs', options.reclaimAfterMs());
205
+ return (ms === 0 ? windowMs : Math.min(ms, windowMs)) / 1000;
206
+ };
184
207
  const exec = options.executor;
208
+ /** The executor of a transaction open on THIS database, or `undefined` — the pool settles. */
209
+ const boundTx = (): PgExecutor | undefined => {
210
+ const tx = liveTransaction();
211
+ return tx !== undefined && tx.origin === options.origin() ? tx.executor : undefined;
212
+ };
185
213
  const now = options.now ?? ((): number => Date.now());
186
214
 
187
215
  const fetch = async (key: string): Promise<IdempotencyRecord | undefined> => {
@@ -199,11 +227,16 @@ export function postgresIdempotencyStore(
199
227
  // back empty is a concurrent `release`/`purgeExpired` deleting the row between them, and a
200
228
  // caller losing that race twice is a store nobody should keep retrying against.
201
229
  for (let attempt = 0; attempt < 3; attempt += 1) {
230
+ // On the POOL, never the open transaction: a duplicate has to see the reservation now,
231
+ // and one that rolled back with the handler would let both attempts run. What rides the
232
+ // transaction is the SETTLE — and the row says so, which is what makes it reclaimable.
202
233
  const claimed = await exec.query<IdempotencyRow>(SQL_IDEMPOTENCY_RESERVE, [
203
234
  key,
204
235
  uuid(),
205
236
  requestHash,
206
237
  windowSecs,
238
+ boundTx() !== undefined,
239
+ reclaimSecs(),
207
240
  ]);
208
241
  const row = claimed[0];
209
242
  if (row !== undefined) return { record: toRecord(row), created: true };
@@ -226,14 +259,22 @@ export function postgresIdempotencyStore(
226
259
  },
227
260
 
228
261
  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');
262
+ const params = [key, JSON.stringify(value ?? null), reservationId];
263
+ const tx = boundTx();
264
+ if (tx === undefined) {
265
+ fenced(await exec.query(SQL_IDEMPOTENCY_SETTLE, params), key, reservationId, 'settle');
266
+ return;
267
+ }
268
+ // On the handler's OWN connection: the record is settled by the same COMMIT that makes the
269
+ // write durable, so a rollback leaves it in flight instead of replaying a success for rows
270
+ // that were never stored. And a settle that matches nothing here is THROWN, not logged —
271
+ // the key was taken over, nothing has committed yet, and the throw is what rolls it back.
272
+ const rows = await tx.query(SQL_IDEMPOTENCY_SETTLE, params);
273
+ if (rows.length === 0) throw new IdempotencyReservationLostError(key);
235
274
  },
236
275
 
276
+ // On the pool, whatever is open: the transaction this failure came out of is about to roll
277
+ // back, and the record of it has to outlive that.
237
278
  async fail(key, failure: IdempotencyFailure, reservationId): Promise<void> {
238
279
  const rows = await exec.query(SQL_IDEMPOTENCY_FAIL, [
239
280
  key,
@@ -260,7 +301,7 @@ export function postgresIdempotencyStore(
260
301
  }
261
302
 
262
303
  /**
263
- * Logged, never thrown. A settlement lands after the handler has committed, so raising here would
304
+ * Logged, never thrown — the AUTOCOMMIT path. A settlement lands after the handler has committed, so raising here would
264
305
  * turn a durable write into the caller's error — the rule `withIdempotency` already follows for a
265
306
  * store that refuses. An operator still has to see it: a fenced settle means this attempt's record
266
307
  * belongs to another reservation, and the value this attempt produced is stored nowhere.
@@ -226,6 +226,10 @@ export async function withIdempotency<T>(
226
226
  //
227
227
  // `record.id` is THIS reservation's, so a straggler from an attempt whose window has since
228
228
  // lapsed cannot land on the replacement that reclaimed the key.
229
+ //
230
+ // Inside a transaction the shared store settles on that transaction's connection, so "settled"
231
+ // and "the write is durable" are one COMMIT — and a settle that lost its reservation THROWS
232
+ // (`X_IDEMPOTENCY_RESERVATION_LOST`), which is the rollback of an attempt a retry replaced.
229
233
  await store.settle(key, value, record.id);
230
234
  return { value, replayed: false };
231
235
  }
package/src/index.ts CHANGED
@@ -19,12 +19,18 @@
19
19
  * before. `isSuperseded` is core's too: reading a fenced answer is the point of installing a
20
20
  * flight, and it should not cost a second import.
21
21
  */
22
+ /**
23
+ * The SHARED store, and the only one an app running more than one replica may install. The
24
+ * statements are exported beside it because the table is applied the way `SQL_JOBS_TABLE` is —
25
+ * by `x db up` in development and by the release-phase `ROLE=migrate` in production.
26
+ */
22
27
  export type {
23
28
  ClientFlight,
24
29
  ClientFlightOptions,
25
30
  ClientRetry,
26
31
  FlightKeyOptions,
27
32
  FlightPlan,
33
+ PgExecutor,
28
34
  WireAnswer,
29
35
  } from '@ultimat3/core';
30
36
  export {
@@ -130,8 +136,10 @@ export {
130
136
  IdempotencyKeyInvalidError,
131
137
  IdempotencyNotSharedError,
132
138
  IdempotencyReplayedFailureError,
139
+ IdempotencyReservationLostError,
133
140
  IdempotencyStatusUnknownError,
134
141
  InputInvalidError,
142
+ MutatorNotIdempotentError,
135
143
  OutputInvalidError,
136
144
  RemoteActionError,
137
145
  RpcFailedError,
@@ -189,13 +197,7 @@ export {
189
197
  DEFAULT_MAX_IDEMPOTENCY_KEYS,
190
198
  MemoryIdempotencyStore,
191
199
  } from './idempotency-memory';
192
- /**
193
- * The SHARED store, and the only one an app running more than one replica may install. The
194
- * statements are exported beside it because the table is applied the way `SQL_JOBS_TABLE` is —
195
- * by `x db up` in development and by the release-phase `ROLE=migrate` in production.
196
- */
197
200
  export type {
198
- PgExecutor,
199
201
  PostgresIdempotencyStore,
200
202
  PostgresIdempotencyStoreOptions,
201
203
  } from './idempotency-postgres';
@@ -214,7 +216,7 @@ export { actionName, invoke } from './invoke';
214
216
  export type { ActionJobHandle } from './job-handle';
215
217
  export { toJobHandle } from './job-handle';
216
218
  export type { JsonSchemaObject } from './json-schema';
217
- export { jsonSchemaOf, mcpSchemaOf } from './json-schema';
219
+ export { jsonSchemaOf } from './json-schema';
218
220
  export type { McpInvokeOptions, McpToolDescriptor } from './mcp-tool';
219
221
  export { isExposed, toMcpTool, toMcpTools } from './mcp-tool';
220
222
  export type {
@@ -237,6 +239,11 @@ export {
237
239
  completeOpenApi,
238
240
  mountOpenApi,
239
241
  } from './openapi-complete';
242
+ /**
243
+ * `@ultimat3/http`'s `hooks.explainMiss` for the action surface: a POST to an action's path under
244
+ * a `pathStyle` this app does not serve answers `X_CONTRACT_DRIFT` naming the style it does.
245
+ */
246
+ export { explainActionPathMiss } from './path-style-miss';
240
247
  export type { ActionPolicy, PolicySubject, Surface } from './policy-gate';
241
248
  /**
242
249
  * `policyCapability` is the display label; `policyPermissions` is what a report MATCHES on.
@@ -262,6 +269,7 @@ export {
262
269
  registerActions,
263
270
  resetRegistry,
264
271
  } from './registry';
272
+ export { requestDeadlineMs } from './request-deadline';
265
273
  /**
266
274
  * A mutator FACTORY, never a ninth primitive: `transition()` returns a `mutator`, so a move through
267
275
  * a state machine inherits the route, the OpenAPI operation, the typed client, the MCP tool, the job
package/src/invoke.ts CHANGED
@@ -268,8 +268,9 @@ async function perform(
268
268
  }
269
269
  // Only for a run that actually happened, and only through the gate. A replay ran no handler
270
270
  // and changed nothing the first call had not already busted — re-busting per retry re-purges
271
- // the CDN and re-queues ISR for a write nobody made. And the bust is post-commit either way,
272
- // so `bustAfterCommit` swallowing its own failure is what keeps a dead cache from turning a
271
+ // the CDN and re-queues ISR for a write nobody made. And the bust is post-COMMIT either way —
272
+ // invoked inside a transaction, it waits for the root commit and is dropped by a rollback — so
273
+ // `bustAfterCommit` swallowing its own failure is what keeps a dead cache from turning a
273
274
  // durable write into a failed action.
274
275
  if (wrote && def.cache !== undefined) await bustAfterCommit(name, def.cache.invalidates);
275
276
  return value;
@@ -1,10 +1,10 @@
1
1
  /**
2
- * Standard Schema -> JSON Schema, normalized to a plain record. OpenAPI, MCP
3
- * descriptors and the manifest all need the same object, produced the same way.
2
+ * Standard Schema -> JSON Schema, normalized to a plain record, for OpenAPI and the manifest. An
3
+ * MCP tool's schema is `@ultimat3/schema`'s `toWireSchema` — the one `tools/list` serves.
4
4
  */
5
5
 
6
6
  import type { StandardSchemaV1 } from '@ultimat3/schema';
7
- import { SchemaUnsupportedError, toJsonSchema, toMcpInputSchema } from '@ultimat3/schema';
7
+ import { SchemaUnsupportedError, toJsonSchema } from '@ultimat3/schema';
8
8
  import { isJsonObject, stableStringify } from './stable';
9
9
 
10
10
  export type JsonSchemaObject = Record<string, unknown>;
@@ -23,13 +23,8 @@ export function jsonSchemaOf(schema: StandardSchemaV1): JsonSchemaObject {
23
23
  return normalizeJsonSchema(() => toJsonSchema(schema));
24
24
  }
25
25
 
26
- /** Draft-07, no `$schema` — the exact shape an MCP `tools/list` entry needs. */
27
- export function mcpSchemaOf(schema: StandardSchemaV1): JsonSchemaObject {
28
- return normalizeJsonSchema(() => toMcpInputSchema(schema));
29
- }
30
-
31
26
  /**
32
- * The narrowing both projections share, and the refusal it earns: a converter that answered with
27
+ * The narrowing `jsonSchemaOf` applies, and the refusal it earns: a converter that answered with
33
28
  * something that is not a JSON object is the same failure by a quieter route, so it gets the same
34
29
  * shipped code rather than a permissive node. Exported for its own test and nothing else — it is
35
30
  * absent from `src/index.ts`, exactly as `sortSchema` is.
package/src/mcp-tool.ts CHANGED
@@ -5,9 +5,10 @@
5
5
  */
6
6
  import type { Ctx } from '@ultimat3/core';
7
7
  import { isMcpExposed } from '@ultimat3/core';
8
+ import { toWireOutputSchema, toWireSchema } from '@ultimat3/schema';
8
9
  import type { AnyAction } from './action';
9
10
  import { actionName, defOf, invoke } from './invoke';
10
- import { type JsonSchemaObject, mcpSchemaOf, sortSchema } from './json-schema';
11
+ import { type JsonSchemaObject, sortSchema } from './json-schema';
11
12
  import type { ActionPolicy } from './policy-gate';
12
13
  import { listActions } from './registry';
13
14
 
@@ -29,8 +30,14 @@ export interface McpToolDescriptor {
29
30
  * is what makes "an MCP call cannot reach a different authz path" checkable.
30
31
  */
31
32
  readonly policy: ActionPolicy;
33
+ /**
34
+ * What `@ultimat3/mcp`'s `tools/list` serves for this action — `@ultimat3/schema`'s wire subset,
35
+ * key-sorted — minus the reserved `idempotencyKey` argument an idempotent action's tool adds,
36
+ * which `invoke` here takes as an option instead.
37
+ */
32
38
  readonly inputSchema: JsonSchemaObject;
33
- readonly outputSchema: JsonSchemaObject;
39
+ /** The served `outputSchema`: structure only, and absent when `output` has no object root. */
40
+ readonly outputSchema?: JsonSchemaObject;
34
41
  invoke(input: unknown, options?: McpInvokeOptions): Promise<unknown>;
35
42
  }
36
43
 
@@ -47,13 +54,14 @@ export interface McpInvokeOptions {
47
54
  export function toMcpTool(target: AnyAction): McpToolDescriptor {
48
55
  const name = actionName(target);
49
56
  const def = defOf(target);
57
+ const output = toWireOutputSchema(def.output);
50
58
  return {
51
59
  name,
52
60
  description: def.mcp?.description ?? name,
53
61
  action: name,
54
62
  policy: def.policy,
55
- inputSchema: sortSchema(mcpSchemaOf(def.input)),
56
- outputSchema: sortSchema(mcpSchemaOf(def.output)),
63
+ inputSchema: sortSchema({ ...toWireSchema(def.input) }),
64
+ ...(output === undefined ? {} : { outputSchema: sortSchema({ ...output }) }),
57
65
  invoke: (input, options = {}) =>
58
66
  invoke(target, input, {
59
67
  surface: 'mcp',