@ultimat3/action 23.0.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/CLAUDE.md CHANGED
@@ -4,8 +4,9 @@ Owns the `action` + `mutator` primitives and their six projections. Tier 3.
4
4
 
5
5
  ## Boundary
6
6
 
7
- - May import: `core`, `schema` (t0), `cache`, `i18n`, `time` (t1), `entity`, `policy`, `http` (t2).
7
+ - May import: `core`, `schema` (t0), `cache`, `db`, `i18n`, `time` (t1), `entity`, `policy`, `http` (t2).
8
8
  `entity` is a real edge since 21.0.0 (`record-wire.ts` → `hasEntityRows`/`rowsOf`), downward 3→2.
9
+ `db` is one since 2026-10-02, downward 3→1, through **`tx-scope.ts` only** (`currentTx`).
9
10
  - Never import: `query`, `jobs`, `realtime` (sideways), or any tier 4-5 package.
10
11
  - Never re-implement authz, validation or caching — call `policy`, `schema`, `cache`.
11
12
 
@@ -43,7 +44,9 @@ Owns the `action` + `mutator` primitives and their six projections. Tier 3.
43
44
  | `idempotency-postgres.ts` | the SHARED store — one table, one `insert … on conflict` |
44
45
  | `deprecation.ts` | `Deprecation` + the RFC 9745/8594 render + the `deprecated_calls_total` counter |
45
46
  | `policy-gate.ts` | **the only** runtime edge to `@ultimat3/policy` (`errors.ts` takes `SurfaceDenial` as a type, which erases) |
46
- | `cache-gate.ts` | the post-commit bust — **the only** file that calls `invalidateTags` |
47
+ | `cache-gate.ts` | the post-COMMIT bust — **the only** file that calls `invalidateTags` |
48
+ | `tx-scope.ts` | the open transaction — **the only** file that imports `@ultimat3/db` (`tx-scope.test.ts`): `openCommitScope` (the bust) and `liveTransaction` (the settle) |
49
+ | `request-deadline.ts` | `requestDeadlineMs` — the app's `requestTimeoutMs`, resolved by http's own `defineHttpConfig` |
47
50
  | `audit.ts` | the audit seam: `AuditRecord`, `AuditSink`, the installed-sink store |
48
51
  | `audit-memory.ts` | the process default: a bounded ring that DROPS, and counts what it dropped |
49
52
  | `audit-postgres.ts` | the DURABLE sink — one append-only `x_audit` table, one insert per record |
@@ -98,6 +101,9 @@ Owns the `action` + `mutator` primitives and their six projections. Tier 3.
98
101
  unchanged** — an upgrade must not make a committed contract stale by itself.
99
102
  - Registration names the action the app exported, in place. Naming an already-named action is the
100
103
  only case that twins.
104
+ - **A mutator declares `idempotent: true` or does not exist**: required by `MutatorDef`, and
105
+ `X_MUTATOR_NOT_IDEMPOTENT` at `mutator()` for anything else. The client replays a mutator's write
106
+ under one key; `transition()` and `x g mutator` declare it. Never a default.
101
107
  - A mutator projects `.local`, `.server`, `.conflict` plus every action member — no aliases.
102
108
  `mutator.server()` calls the action's own callable (lands in `invoke`); `.local()` never leaves the
103
109
  client.
@@ -110,7 +116,8 @@ Owns the `action` + `mutator` primitives and their six projections. Tier 3.
110
116
  - `src/index.ts` re-exports `t` from `@ultimat3/schema` **verbatim** (`index.test.ts` asserts identity).
111
117
  - **`transition()` is a mutator factory that decides nothing about the machine**: entity's three
112
118
  codes propagate; `from` is REQUIRED (it is the UPDATE's predicate); `conflict: 'server-wins'` fixed;
113
- `audit` off unless declared.
119
+ `audit` off unless declared; `id` is the key `output.id` declares (`keySchemaOf`), never a fixed
120
+ `t.uuid`.
114
121
  - **A lookup table is read with `Object.hasOwn`** (`IRREGULAR` in `naming.ts`, `BY_FORMAT` in
115
122
  `sample-input.ts`) — caller- or provider-supplied keys.
116
123
 
@@ -209,14 +216,30 @@ Owns the `action` + `mutator` primitives and their six projections. Tier 3.
209
216
  `configureIdempotency({ scope })`, compared by `assertIdempotencyScope` in `registerAction`
210
217
  (`X_IDEMPOTENCY_NOT_SHARED`). Default `'process'`.
211
218
  - **The memory store is bounded; `in-flight` records are the last evicted.** Never an LRU.
212
- - **`postgresIdempotencyStore` is the shared store** over a structural `PgExecutor` (no `action -> db`
213
- edge); the reservation is ONE `insert … on conflict` statement. The CLI boot installs it.
219
+ - **`postgresIdempotencyStore` is the shared store** over `@ultimat3/core`'s structural `PgExecutor`; the
220
+ reservation is ONE `insert … on conflict` statement. The CLI boot installs it.
221
+ - **Inside a transaction the SETTLE commits with the write, on BOTH stores** (`liveTransaction()`):
222
+ Postgres on the handler's connection, memory at `onCommit`. The reservation and `fail` stay
223
+ immediate. A tx-bound settle that matches no record THROWS `X_IDEMPOTENCY_RESERVATION_LOST` (the
224
+ rollback of an attempt a retry replaced); the autocommit one only logs. Only a tx-bound
225
+ `in-flight` record is reclaimed past the deadline — never an autocommit one.
226
+ `idempotency-parity.test.ts` (memory + PGlite), `idempotency-postgres.live.test.ts`.
227
+ - **The deadline is `reclaimAfterMs: () => number`, REQUIRED on the Postgres store** and read per
228
+ reservation — the app's `requestTimeoutMs` through `requestDeadlineMs` (`request-deadline.ts`,
229
+ http's own default, never a number here). **`origin: () => object` is required too**: a
230
+ transaction on another database settles on the pool. A FINISHED scope's handle binds nothing.
214
231
 
215
232
  ## Invariants — cache and audit
216
233
 
217
234
  - **The post-commit bust never fails the write** — `cache-gate.ts` (the only `invalidateTags` caller)
218
235
  absorbs a refusing fan-out and logs through core's `logger`, never rendering the tags. A replay
219
236
  skips the bust.
237
+ - **The bust waits for the ROOT commit** when a transaction is open (`openCommitScope().onCommit`):
238
+ dropped on rollback, never fired before the rows are durable. A straggler past its scope gets
239
+ `DbTx.onCommit`'s own answer, as entity's row observer does. `cache-gate-tx.test.ts` (PGlite).
240
+ - **The OpenAPI contract assertion reads the registry-wide document** and compares `operationId`
241
+ — one built from the action alone could not fail. **`Problem` is `toProblem`'s members**, no
242
+ `code` pattern (`openapi-problem.test.ts`).
220
243
  - **The policy contract test asserts `ActionDeniedError` and sends valid input** (`sampleInput` from
221
244
  the input IR). Only `X_INPUT_INVALID` (and `X_AUDIT_SINK_MISSING`, the one refusal before the parse)
222
245
  becomes `X_CONTRACT_DRIFT`; everything else keeps its own code; a non-`UltimateError` is rethrown.
package/README.md CHANGED
@@ -296,6 +296,13 @@ fan-out that refuses — an undeclared tag, `X_CACHE_TAG_UNKNOWN` — is one
296
296
  `action.invalidate.failed` log line and the entries expire by TTL. A replayed idempotent
297
297
  call busts nothing; the first call already did.
298
298
 
299
+ **After the COMMIT, not after the handler** (`As of 2026-10-02`). An action invoked inside a
300
+ `withTransaction` — an app's own unit of work, or another action's — hands its bust to the ROOT
301
+ transaction's `onCommit`: it fires once the commit is answered and never for a rollback or an
302
+ aborted transaction (`X_DB_TRANSACTION_ABORTED`). Busting at the handler's return let a
303
+ concurrent read refill the cache from the pre-commit rows, which then stayed for the TTL. With no
304
+ transaction open the bust runs when the handler returns, as it always did.
305
+
299
306
  Registering an action without `policy:` throws `X_ACTION_POLICY_MISSING`; there is
300
307
  no bypass flag. A look-alike that never came out of `action()` is `X_ACTION_FOREIGN`.
301
308
 
@@ -310,6 +317,7 @@ export const likePost = mutator({
310
317
  input: t.object({ postId: t.uuid }),
311
318
  output: PostLikes,
312
319
  policy: can('post:like'),
320
+ idempotent: true, // REQUIRED — a replayed write answers the first result (below)
313
321
  // Convergent, not incremental: `local` replays on every rebase, so applying it N times has to
314
322
  // equal applying it once — `likedByMe` is what makes the second application a no-op.
315
323
  local(tx, { postId }) {
@@ -341,6 +349,14 @@ denies is denied there exactly as over HTTP. `.local()` is the only half that sk
341
349
  the core, because it never leaves the client; keep it a pure function of `(tx, input)`
342
350
  — no I/O, no clock, no randomness — since every rebase replays it.
343
351
 
352
+ **`idempotent: true` is required on every mutator** — a compile error without it, and
353
+ `X_MUTATOR_NOT_IDEMPOTENT` at declaration for a caller the compiler never saw. A mutator's write
354
+ is replayed by construction: `useMutation` retries, and the offline queue drains after a dropped
355
+ response, under ONE `Idempotency-Key`. The server reads that key only for an idempotent action, so
356
+ without the declaration the replay ran `server` a second time — a toggle flipped back. It is
357
+ declared, never defaulted, because the replay is answered from the idempotency store and which
358
+ store that is (below) is the app's to decide. `transition()` declares it for you.
359
+
344
360
  `LocalTx` is the client write surface, implemented by `@ultimat3/realtime` over the page's
345
361
  record store — the SAME shape as that store's tx. Every table is addressed by **key**:
346
362
  `get(key)`, `all()`, `insert(key, row)`, `upsert(key, row)`, `update(key, patch | fn)`,
@@ -388,12 +404,13 @@ await moveOrder({ id, from: 'pending', to: 'paid' }, { ctx });
388
404
  | **`from` is required, and never defaulted or inferred** | it rides in the UPDATE's own predicate, so the state observed and the state written are one decision under the row's lock. Measured on the mechanism underneath: twenty concurrent moves at one row gave 14 winners with a read-then-check-then-write and **1 winner plus 19 refusals** with `from` in the predicate. Anything that supplies `from` for the caller is the lost update coming back |
389
405
  | the states are the **input schema**, not a `t.string` | the union survives into `InferOutput`, so the typed client refuses a typo at **compile** time, the MCP tool's `inputSchema` and the OpenAPI component both publish the legal set, and a bad state is `X_INPUT_INVALID` before a database is touched |
390
406
  | `conflict: 'server-wins'`, not overridable | the server is the half that REFUSED the move; a local twin winning the rebase would leave the client showing a state the database rejected |
407
+ | `id` is the entity's **key**, read off `output.id` | a uuid key stays `t.uuid` (a malformed one is `X_INPUT_INVALID` before a database reads it); a `text()` key keeps its length and a `bigint()` key its digits pattern — never a fixed `t.uuid`, which refused every move on an entity keyed by anything else. An `output` that does not carry the key as `id` passes the key's schema as `id:`; with neither, the id is a uuid |
408
+ | `idempotent: true`, always | a move replayed under its `Idempotency-Key` answers the first outcome, not an `X_STATE_CONFLICT` for a move that already happened |
391
409
  | `audit` is **off** unless the app says so | `audit: true` with no sink installed is `X_AUDIT_SINK_MISSING`, raised before the input parse — an on-by-default audit would make every `transition()` refuse until an unrelated decision was made. What the row is kept for, and for how long, is the same compliance question that kept a purge out of `postgresAuditSink` |
392
410
  | `X_STATE_TRANSITION_ILLEGAL`, `X_STATE_CONFLICT` and `X_STATE_UNDECLARED` propagate untouched | they are `@ultimat3/entity`'s. A second error class over one failure is a second path |
393
411
 
394
- `table` is typed structurally (`TransitionTarget`), not imported: `@ultimat3/action` holds no
395
- dependency edge on `@ultimat3/entity` — the tier table permits one, the manifest and the lockfile do
396
- not — and a real `Table` satisfies the seam as written.
412
+ `table` is typed structurally (`TransitionTarget`), not imported — a real `Table` satisfies the
413
+ seam as written.
397
414
 
398
415
  ## Determinism + idempotency
399
416
 
@@ -464,6 +481,7 @@ no record and runs the handler again:
464
481
  import {
465
482
  configureIdempotency,
466
483
  postgresIdempotencyStore,
484
+ requestDeadlineMs,
467
485
  setIdempotencyStore,
468
486
  } from '@ultimat3/action';
469
487
  import { db } from '@ultimat3/db';
@@ -478,6 +496,10 @@ const client = db();
478
496
  setIdempotencyStore(
479
497
  postgresIdempotencyStore({
480
498
  executor: { query: (text, values) => client.query({ text, values }) },
499
+ // The client transactions on THIS database open on: a settle rides one of those and no other.
500
+ origin: () => client,
501
+ // The app's `requestTimeoutMs` (`configureHttp`), read per reservation — never a number here.
502
+ reclaimAfterMs: requestDeadlineMs,
481
503
  }),
482
504
  );
483
505
  configureIdempotency({ scope: 'shared' });
@@ -514,6 +536,26 @@ Both stores fence on it AND on `in-flight` `As of 2026-08`, the way `@ultimat3/j
514
536
  so a straggler from the first attempt satisfied a status-only fence exactly and overwrote a live
515
537
  reservation.
516
538
 
539
+ **Inside a transaction the settlement commits with the write** (`As of 2026-10-02`, BOTH stores —
540
+ `idempotency-parity.test.ts` runs one set of cases over memory and Postgres). An action invoked
541
+ inside `withTransaction` settles with that transaction: on its own connection (Postgres), or at
542
+ its `onCommit` (memory).
543
+
544
+ | The unit of work | The record | A retry |
545
+ |---|---|---|
546
+ | commits | `settled`, by the same `COMMIT` | replays the first result |
547
+ | rolls back after the handler returned | still `in-flight` — never `settled` for rows nobody stored | `X_IDEMPOTENCY_CONFLICT` until the request deadline, then it runs |
548
+ | dies mid-flight | still `in-flight` | the same |
549
+ | outlives the deadline and a retry takes the key | the retry's | the slow attempt's settle is refused `X_IDEMPOTENCY_RESERVATION_LOST`, which rolls its transaction back — one write |
550
+
551
+ | Rule | Why |
552
+ |---|---|
553
+ | the deadline is `reclaimAfterMs: () => number`, **required** on the Postgres store | it is the app's `requestTimeoutMs`, declared (`configureHttp`) after the boot built the store — so it is read per reservation, and `requestDeadlineMs` is the reader. `0` is "no deadline": nothing is reclaimed before the window. The memory store defaults to the same reader |
554
+ | it frees only a record whose settle was BOUND to a transaction (`x_idempotency.tx_bound`) | a handler with no transaction around it keeps the old rule — reserve, run and settle are three commits, "died before the write" and "wrote, then died before the settle" are one row, and that row answers 409 for the whole window rather than risk the second charge |
555
+ | the reservation and a recorded failure always go through the pool | a duplicate sees the first at once, and a failure outlives its rollback |
556
+ | `origin: () => client` is **required** on the Postgres store | a transaction opened on ANOTHER database (`withTransaction(fn, { client: shard })`) has no `x_idempotency`; its record is settled on the pool, as an autocommit handler's is |
557
+ | a finished transaction binds nothing | a promise chain the body forgot to await still finds the scope's handle; the settle then runs at once (`liveTxConnection`), never as a commit hook that a rollback already dropped |
558
+
517
559
  A `query` has none and never will: a read has nothing to be idempotent about.
518
560
 
519
561
  ## `deprecated:` — a compat window, not a version
@@ -685,7 +727,7 @@ setAuditSink({
685
727
  |---|---|
686
728
  | input schema rejects garbage | the invocation fails `X_INPUT_INVALID` — that code, not any failure |
687
729
  | policy denies an anonymous actor | the invocation fails with an `ActionDeniedError` |
688
- | OpenAPI document contains its operation | the derived path is in `buildOpenApi()` |
730
+ | OpenAPI document contains its operation | the action's path, in the document built from the WHOLE registry (plus this action when a test drives an unregistered `.named()` twin), is this action's operation — a second registered action deriving the same route fails it |
689
731
 
690
732
  The denial assertion sends an input synthesized from `input:`'s own schema — required keys
691
733
  only, formats included — because a payload the schema rejects never reaches a policy. It
@@ -716,8 +758,10 @@ never a pass — the assertion says which code got in the way and names `input:`
716
758
  | `X_INPUT_INVALID` | input failed the Standard Schema. Carries the rejections **twice**: the flattened line in `cause`, and the structured list in `meta.issues` — one value rendered two ways, `As of 2026-08-24` | `x actions describe <name> --json` |
717
759
  | `X_IDEMPOTENCY_CONFLICT` | key reused with a new payload / still in flight | new key, or retry later |
718
760
  | `X_IDEMPOTENCY_KEY_INVALID` | `Idempotency-Key:` sent blank (`Headers.get()` answers `''`, not `null`) or past 255 characters | send one unique value per request, or omit the header |
719
- | `X_IDEMPOTENCY_NOT_SHARED` | `configureIdempotency({ scope: 'shared' })` over a per-process (or scope-less) store | install `postgresIdempotencyStore({ executor })` at boot |
761
+ | `X_IDEMPOTENCY_NOT_SHARED` | `configureIdempotency({ scope: 'shared' })` over a per-process (or scope-less) store | install `postgresIdempotencyStore({ executor, origin, reclaimAfterMs })` at boot |
720
762
  | `X_IDEMPOTENCY_REPLAYED_FAILURE` | a retried key replays a first attempt that failed and carried no framework code of its own | read the first attempt, then send a fresh key |
763
+ | `X_IDEMPOTENCY_RESERVATION_LOST` | a settle inside the handler's transaction matched no record: the attempt outlived the request deadline and a retry took the key. This attempt's transaction rolls back | resend with the same `Idempotency-Key` |
764
+ | `X_MUTATOR_NOT_IDEMPOTENT` | `mutator()` declared without `idempotent: true` | add `idempotent: true` to the definition |
721
765
  | `X_IDEMPOTENCY_STATUS_UNKNOWN` | `x_idempotency.status` holds a word this build has no branch for — written by a newer deploy | finish the rollout onto the build that writes it, then reconcile those requests — never DELETE the rows, which frees the key to run an already-committed action a second time |
722
766
  | `X_CONTRACT_DRIFT` | client/server build skew, missing spec entry | reload / `x verify --only contract` |
723
767
  | `X_RPC_FAILED` | registered, thrown by nothing since 21.0.0 — a non-`problem+json` failure is core's `X_CLIENT_TRANSPORT_FAILED` now, as for a query | match `X_CLIENT_TRANSPORT_FAILED` instead |
@@ -762,13 +806,15 @@ a job boundary the class is gone and the `code` is what survives — match on th
762
806
  | `IdempotencyKeyInvalidError` | `X_IDEMPOTENCY_KEY_INVALID` | `src/errors-idempotency.ts` |
763
807
  | `IdempotencyNotSharedError` | `X_IDEMPOTENCY_NOT_SHARED` | `src/errors-idempotency.ts` |
764
808
  | `IdempotencyReplayedFailureError` | `X_IDEMPOTENCY_REPLAYED_FAILURE` | `src/errors-idempotency.ts` |
809
+ | `IdempotencyReservationLostError` | `X_IDEMPOTENCY_RESERVATION_LOST` | `src/errors-idempotency.ts` |
765
810
  | `IdempotencyStatusUnknownError` | `X_IDEMPOTENCY_STATUS_UNKNOWN` | `src/errors-idempotency.ts` |
766
811
  | `InputInvalidError` | `X_INPUT_INVALID` | `src/errors.ts` |
812
+ | `MutatorNotIdempotentError` | `X_MUTATOR_NOT_IDEMPOTENT` | `src/errors-idempotency.ts` |
767
813
  | `OutputInvalidError` | `X_OUTPUT_INVALID` | `src/errors.ts` |
768
814
  | `RemoteActionError` | the code the server sent, verbatim — `meta.origin: 'remote'` | `src/errors.ts` |
769
815
  | `RpcFailedError` | `X_RPC_FAILED` | `src/errors.ts` |
770
816
 
771
817
  ## Boundaries
772
818
 
773
- Tier 3. Imports `@ultimat3/core`, `schema`, `cache`, `entity`, `policy`, `http`. Never imports
819
+ Tier 3. Imports `@ultimat3/core`, `schema`, `cache`, `db`, `entity`, `policy`, `http`. Never imports
774
820
  `query`, `jobs`, `realtime` (same tier) or anything above it — those import *this*.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/action",
3
- "version": "23.0.0",
3
+ "version": "24.0.0",
4
4
  "description": "The action primitive: one declaration projected to route, OpenAPI, client, MCP tool, job handle, tests",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -35,11 +35,12 @@
35
35
  "test": "bun test"
36
36
  },
37
37
  "dependencies": {
38
- "@ultimat3/cache": "23.0.0",
39
- "@ultimat3/core": "23.0.0",
40
- "@ultimat3/entity": "23.0.0",
41
- "@ultimat3/http": "23.0.0",
42
- "@ultimat3/policy": "23.0.0",
43
- "@ultimat3/schema": "23.0.0"
38
+ "@ultimat3/cache": "24.0.0",
39
+ "@ultimat3/core": "24.0.0",
40
+ "@ultimat3/db": "24.0.0",
41
+ "@ultimat3/entity": "24.0.0",
42
+ "@ultimat3/http": "24.0.0",
43
+ "@ultimat3/policy": "24.0.0",
44
+ "@ultimat3/schema": "24.0.0"
44
45
  }
45
46
  }
@@ -34,6 +34,8 @@ export interface MemoryAuditSinkOptions {
34
34
  * here: a non-zero count on a real deployment is the sink saying it is the wrong one.
35
35
  */
36
36
  export interface MemoryAuditSink extends AuditSink {
37
+ /** Synchronous here: the window is an array, so nothing is left for a caller to await. */
38
+ write(record: AuditRecord): void;
37
39
  /** The retained window, oldest first. A copy — the log cannot be mutated through it. */
38
40
  records(): readonly AuditRecord[];
39
41
  /** Retained right now — the bound, observable. */
@@ -5,11 +5,10 @@
5
5
  * production. Statements are spelled out so an agent can run the exact one it saw in a log.
6
6
  */
7
7
 
8
- import type { Actor } from '@ultimat3/core';
8
+ import type { Actor, PgExecutor } from '@ultimat3/core';
9
9
  import { uuid } from '@ultimat3/core';
10
10
  import type { AuditRecord, AuditSink } from './audit';
11
11
  import { auditableInput } from './audit-input';
12
- import type { PgExecutor } from './idempotency-postgres';
13
12
 
14
13
  /**
15
14
  * Applied by the boot, never by an app migration — the rule `SQL_IDEMPOTENCY_TABLE` follows, and
package/src/cache-gate.ts CHANGED
@@ -1,22 +1,41 @@
1
1
  /**
2
2
  * The post-commit cache bust — the only file in this package that calls `invalidateTags`.
3
- * The handler has already committed by the time it runs, so a fan-out that refuses degrades to
4
- * one logged failure instead of a failed action: the stale entries expire by TTL, and the caller
5
- * keeps the write it already made.
3
+ * Post-COMMIT, not post-handler: inside a transaction it waits for the root commit. A fan-out that
4
+ * refuses degrades to one logged failure instead of a failed action — the stale entries expire by
5
+ * TTL, and the caller keeps the write it already made.
6
6
  */
7
7
 
8
8
  import type { CacheTag, InvalidationReport } from '@ultimat3/cache';
9
9
  import { invalidateTags } from '@ultimat3/cache';
10
10
  import { logger } from '@ultimat3/core';
11
+ import { type CommitScope, openCommitScope } from './tx-scope';
11
12
 
12
13
  /**
13
- * Fan `tags` out and never throw. `undefined` back means the fan-out itself refused and nothing
14
- * was cleared — an undeclared tag (`X_CACHE_TAG_UNKNOWN`) is the one an app hits. One dead tier
15
- * is not that case: `invalidateTags` absorbs those into `report.errors` and still reports.
14
+ * Fan `tags` out and never throw. `undefined` back means nothing was cleared BY THIS CALL: the
15
+ * fan-out refused — an undeclared tag (`X_CACHE_TAG_UNKNOWN`) is the one an app hits — or it was
16
+ * deferred. One dead tier is not that case: `invalidateTags` absorbs those into `report.errors`.
17
+ *
18
+ * Inside a transaction (`scope`, the ambient one unless a caller passes its own) the handler's rows
19
+ * are not durable when it returns. Busting there let a concurrent read refill the cache with the
20
+ * pre-commit rows, which then stayed for the TTL — and a rollback busted for a write nobody made.
21
+ * So the bust is handed to the ROOT commit, as `@ultimat3/entity`'s row observer hands its report:
22
+ * `onCommit` never fires for a transaction that rolled back or whose COMMIT was lost.
16
23
  */
17
24
  export async function bustAfterCommit(
18
25
  action: string,
19
26
  tags: readonly CacheTag[],
27
+ scope: CommitScope | undefined = openCommitScope(),
28
+ ): Promise<InvalidationReport | undefined> {
29
+ if (scope === undefined) return bust(action, tags);
30
+ // Not awaited, and it cannot be: a commit effect is synchronous and runs after the transaction
31
+ // is durable. `bust` never rejects, so there is no rejection for the missing await to lose.
32
+ scope.onCommit(() => void bust(action, tags));
33
+ return undefined;
34
+ }
35
+
36
+ async function bust(
37
+ action: string,
38
+ tags: readonly CacheTag[],
20
39
  ): Promise<InvalidationReport | undefined> {
21
40
  try {
22
41
  return await invalidateTags(tags);
@@ -8,10 +8,13 @@ import type { Ctx } from '@ultimat3/core';
8
8
  import { createContext, isUltimateError, logger } from '@ultimat3/core';
9
9
  import type { AnyAction } from './action';
10
10
  import { ActionDeniedError, ContractDriftError } from './errors';
11
+ import { toOpenApiOperation } from './http';
12
+ import { servedActionRoute } from './http-path';
11
13
  import { actionName, invoke } from './invoke';
12
- import { derivePath } from './naming';
13
14
  import { buildOpenApi } from './openapi';
15
+ import { listActions } from './registry';
14
16
  import { describeSampleGap, sampleGaps, sampleInput } from './sample-input';
17
+ import { isJsonObject } from './stable';
15
18
  import { validateInput } from './validate';
16
19
 
17
20
  export interface ContractTest {
@@ -74,20 +77,41 @@ export function contractTestsFor(
74
77
  },
75
78
  {
76
79
  name: `${name}: OpenAPI document contains its operation`,
77
- run: async () => {
78
- const document = buildOpenApi({ actions: [target] });
79
- const path = derivePath(name).path;
80
- if (document.paths[path] === undefined) {
81
- throw new ContractDriftError(
82
- `OpenAPI document has no entry for ${path}`,
83
- 'x verify --json # the contract suite is a step of it',
84
- );
85
- }
86
- },
80
+ run: async () => assertDocumented(target, name),
87
81
  },
88
82
  ];
89
83
  }
90
84
 
85
+ /**
86
+ * Against the document the APP publishes — the whole registry, plus this action when a test drives
87
+ * a `.named()` twin nothing seated — never one built from this action alone: that one holds its
88
+ * own path by construction, so the assertion could not fail. What can go wrong is in the rest of
89
+ * the registry: a second action deriving the same route, whose operation is the one published.
90
+ */
91
+ function assertDocumented(target: AnyAction, name: string): void {
92
+ const seated = listActions();
93
+ const actions = seated.some((other) => other.name === name) ? seated : [...seated, target];
94
+ const { path } = servedActionRoute(name);
95
+ const published = buildOpenApi({ actions }).paths[path];
96
+ const owner = operationIdAt(published);
97
+ if (owner === toOpenApiOperation(target).operationId) return;
98
+ throw new ContractDriftError(
99
+ owner === undefined
100
+ ? `OpenAPI document has no entry for ${path}`
101
+ : `OpenAPI document serves ${path} as ${owner}, so ${name} is not in the published contract`,
102
+ owner === undefined
103
+ ? 'x verify --json # the contract suite is a step of it'
104
+ : `x actions describe ${name} --json # then rename it, or pin its own route with http: { path } in the ${name} definition`,
105
+ );
106
+ }
107
+
108
+ /** `paths[path].post.operationId`, read off a value typed `unknown` — narrowed, never cast. */
109
+ function operationIdAt(entry: unknown): string | undefined {
110
+ if (!isJsonObject(entry) || !isJsonObject(entry['post'])) return undefined;
111
+ const id = entry['post']['operationId'];
112
+ return typeof id === 'string' ? id : undefined;
113
+ }
114
+
91
115
  /**
92
116
  * A `pattern` cannot be inverted, so the framework cannot build a payload for
93
117
  * `t.string.pattern(...)` — but the pattern IS in the IR, so it knows that BEFORE it invokes.
@@ -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 {
@@ -267,6 +269,7 @@ export {
267
269
  registerActions,
268
270
  resetRegistry,
269
271
  } from './registry';
272
+ export { requestDeadlineMs } from './request-deadline';
270
273
  /**
271
274
  * A mutator FACTORY, never a ninth primitive: `transition()` returns a `mutator`, so a move through
272
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',
package/src/mutator.ts CHANGED
@@ -18,6 +18,7 @@ import type {
18
18
  } from './action';
19
19
  import { action, isAction } from './action';
20
20
  import type { Deprecation } from './deprecation';
21
+ import { MutatorNotIdempotentError } from './errors';
21
22
  import type { ActionHttp } from './http-path';
22
23
  import { assertConflictClock } from './mutator-clock';
23
24
  import type { ActionPolicy } from './policy-gate';
@@ -97,7 +98,14 @@ export interface MutatorDef<
97
98
  readonly deprecated?: Deprecation;
98
99
  /** Same key, same meaning as an action's: `http: { path }` pins the URL. */
99
100
  readonly http?: ActionHttp;
100
- readonly idempotent?: boolean;
101
+ /**
102
+ * REQUIRED, and only `true`. A mutator's write is replayed under one `Idempotency-Key` —
103
+ * `useMutation`'s retry, the offline queue's drain after a dropped response — and the server
104
+ * reads that key only for an idempotent action. Declared rather than defaulted because the
105
+ * replay is answered from the idempotency store, and which store that is (`configureIdempotency`)
106
+ * is the app's decision to see. Omitted: a compile error, and `X_MUTATOR_NOT_IDEMPOTENT`.
107
+ */
108
+ readonly idempotent: true;
101
109
  /**
102
110
  * The row a row-level `policy` decides about — an action's `row`, and dropped on the way into
103
111
  * the action until 2026-09-23, so such a policy received `row === null` and denied every call.
@@ -158,6 +166,9 @@ export function mutator<
158
166
  TOutput extends StandardSchemaV1,
159
167
  TRow = unknown,
160
168
  >(def: MutatorDef<TInput, TOutput, TRow>): Mutator<TInput, TOutput> {
169
+ // First, and for a caller the compiler never saw: a replay that runs the server half twice is
170
+ // silent, so the declaration is refused where it is written rather than on the second POST.
171
+ if (def.idempotent !== true) throw new MutatorNotIdempotentError();
161
172
  const row = def.row;
162
173
  const actionDef: ActionDef<TInput, TOutput, TRow> = {
163
174
  input: def.input,
@@ -169,7 +180,7 @@ export function mutator<
169
180
  ...(def.deprecated === undefined ? {} : { deprecated: def.deprecated }),
170
181
  ...(def.http === undefined ? {} : { http: def.http }),
171
182
  ...(row === undefined ? {} : { row: (args: ActionRowArgs<TInput>) => row.call(def, args) }),
172
- ...(def.idempotent === undefined ? {} : { idempotent: def.idempotent }),
183
+ idempotent: true,
173
184
  ...(def.audit === undefined ? {} : { audit: def.audit }),
174
185
  handle: ({ input, ctx }) => def.server(ctx, input),
175
186
  };
package/src/openapi.ts CHANGED
@@ -76,7 +76,12 @@ function compareByName(a: AnyAction, b: AnyAction): number {
76
76
  return a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
77
77
  }
78
78
 
79
- /** RFC 9457 + the Ultimate error contract (code / cause / fix / docs). */
79
+ /**
80
+ * RFC 9457 + the Ultimate error contract, member for member what `@ultimat3/http`'s `toProblem`
81
+ * serves (`openapi-problem.test.ts` compares the two). `code` carries no `pattern`: `factsOf`
82
+ * serves whatever string an app's own throwable holds, and a published pattern the server breaks
83
+ * makes a generated client reject the very document it was written to read.
84
+ */
80
85
  const PROBLEM_SCHEMA: JsonSchemaObject = {
81
86
  type: 'object',
82
87
  required: ['type', 'title', 'status', 'code'],
@@ -85,9 +90,27 @@ const PROBLEM_SCHEMA: JsonSchemaObject = {
85
90
  title: { type: 'string' },
86
91
  status: { type: 'integer' },
87
92
  detail: { type: 'string' },
88
- code: { type: 'string', pattern: '^X_[A-Z0-9_]+$' },
93
+ instance: { type: 'string' },
94
+ code: { type: 'string' },
89
95
  cause: { type: 'string' },
90
96
  fix: { type: 'string' },
91
97
  docs: { type: 'string', format: 'uri' },
98
+ requestId: { type: 'string' },
99
+ // Absent when the failure produced none — never `[]`, which would claim "validated clean".
100
+ issues: {
101
+ type: 'array',
102
+ items: {
103
+ type: 'object',
104
+ required: ['path', 'expected', 'received', 'message'],
105
+ properties: {
106
+ path: { type: 'string' },
107
+ expected: { type: 'string' },
108
+ received: { type: 'string' },
109
+ message: { type: 'string' },
110
+ },
111
+ },
112
+ },
113
+ // Only the keys the code declared through `registerProblemMeta`; its shape is per code.
114
+ meta: { type: 'object' },
92
115
  },
93
116
  };
package/src/registry.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  import type { ActionPathStyle } from '@ultimat3/core';
8
- import { SchemaUnsupportedError } from '@ultimat3/schema';
8
+ import { SchemaUnsupportedError, toWireSchema } from '@ultimat3/schema';
9
9
  import type { ActionDescriptor, AnyAction } from './action';
10
10
  import { isAction, nameAction } from './action';
11
11
  import { resetApiDeclaration } from './api-declaration';
@@ -19,7 +19,7 @@ import {
19
19
  } from './http-path';
20
20
  import { assertIdempotencyScope } from './idempotency';
21
21
  import { defOf } from './invoke';
22
- import { jsonSchemaOf, mcpSchemaOf } from './json-schema';
22
+ import { jsonSchemaOf } from './json-schema';
23
23
  import type { ActionPath } from './naming';
24
24
  import { seatedActions } from './registry-store';
25
25
 
@@ -111,7 +111,8 @@ function assertProjectable(name: string, target: AnyAction): void {
111
111
  for (const field of ['input', 'output'] as const) {
112
112
  try {
113
113
  jsonSchemaOf(target[field]);
114
- mcpSchemaOf(target[field]);
114
+ // The schema `.tool()` and `tools/list` publish — the one an MCP client reads.
115
+ toWireSchema(target[field]);
115
116
  } catch {
116
117
  // The thrown value is deliberately not rendered into the cause: it is the provider's, of
117
118
  // unknown shape, and this package's own two facts — which action, which field — are the
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The app's request deadline — how long an idempotency record bound to a transaction may stay in
3
+ * flight before a retry takes the key. Read from `@ultimat3/http`'s own declaration and resolved
4
+ * by its own `defineHttpConfig`, so the default and its screen are never restated here.
5
+ */
6
+ import { configuredHttp, defineHttpConfig } from '@ultimat3/http';
7
+
8
+ let resolved: { readonly declared: number | undefined; readonly ms: number } | undefined;
9
+
10
+ /**
11
+ * `configureHttp({ requestTimeoutMs })`, else http's default; `0` is "no deadline". Read on every
12
+ * call because the app declares it at module scope AFTER the boot has built its stores — resolved
13
+ * once per declared value, since `defineHttpConfig` screens a whole config to answer one field.
14
+ */
15
+ export function requestDeadlineMs(): number {
16
+ const declared = configuredHttp()?.requestTimeoutMs;
17
+ if (resolved === undefined || resolved.declared !== declared) {
18
+ const config = defineHttpConfig({
19
+ ...(declared === undefined ? {} : { requestTimeoutMs: declared }),
20
+ rateLimit: { enabled: false, scope: 'process' },
21
+ });
22
+ resolved = { declared, ms: config.requestTimeoutMs };
23
+ }
24
+ return resolved.ms;
25
+ }
package/src/transition.ts CHANGED
@@ -17,15 +17,14 @@ import type {
17
17
  StandardSchemaV1,
18
18
  StringSchema,
19
19
  } from '@ultimat3/schema';
20
- import { t } from '@ultimat3/schema';
20
+ import { nodeOf, t } from '@ultimat3/schema';
21
21
  import { type Mutator, mutator } from './mutator';
22
22
  import type { ActionPolicy } from './policy-gate';
23
23
 
24
24
  /**
25
25
  * The one method this factory calls, declared structurally: `@ultimat3/entity`'s `Table.transition`
26
- * satisfies it as written. Structural and not an import because `@ultimat3/action` holds no
27
- * dependency edge on `@ultimat3/entity` — the tier table permits one (2 is below 3), the manifest
28
- * and the lockfile do not — the same trade `@ultimat3/db`'s `entity-shape.ts` makes one tier down.
26
+ * satisfies it as written. Structural because it is all this file needs of a table — one method —
27
+ * and a fake that implements it is the whole test seam; the package does import entity elsewhere.
29
28
  *
30
29
  * `id` is a plain `string` rather than entity's `IdOf<Row>`: that alias "collapses to `string` for
31
30
  * every unbranded entity" by its own account, and a branded one still satisfies this because a
@@ -69,16 +68,22 @@ export interface TransitionDef<
69
68
  * tool's `inputSchema` carries it, the typed client refuses a typo at COMPILE time, and a
70
69
  * misspelled state is `X_INPUT_INVALID` before the request reaches a database.
71
70
  *
72
- * It is the one thing restated from the column's own declaration, and the reason is a boundary:
73
- * reading the machine off the entity needs `@ultimat3/entity` as a real dependency of this
74
- * package. Listing a SUBSET refuses a legal move at the input schema — loud, and the fix is the
75
- * enum in the refusal.
71
+ * It is the one thing restated from the column's own declaration: `table` is resolved per request
72
+ * (`(ctx) => …`), and the input schema is needed at declaration, before any table exists to read
73
+ * a machine off. Listing a SUBSET refuses a legal move at the input schema — loud, and the fix is
74
+ * the enum in the refusal.
76
75
  */
77
76
  readonly states: readonly [S, ...S[]];
78
77
  /** The local store's name for this entity — what the optimistic twin patches. */
79
78
  readonly localTable: string;
80
79
  /** The projection the caller gets back. Unknown keys are dropped by the parse, so a `$view` works. */
81
80
  readonly output: TOutput;
81
+ /**
82
+ * The key's schema, for an `output` that does not carry the key as `id`. Omitted — the usual
83
+ * case — it is read off `output.id` (`keySchemaOf`), which for an entity `$view` IS the key
84
+ * column's own shape.
85
+ */
86
+ readonly id?: StringSchema;
82
87
  readonly policy: ActionPolicy;
83
88
  /**
84
89
  * OFF unless the app says otherwise, and deliberately not `?? true`.
@@ -115,9 +120,12 @@ export function transition<
115
120
  // can reach these two callbacks.
116
121
  const valuesOf = (raw: unknown): TransitionValues<S> => raw as TransitionValues<S>;
117
122
  return mutator({
118
- input: t.object({ id: t.uuid, from: state, to: state }),
123
+ input: t.object({ id: def.id ?? keySchemaOf(def.output), from: state, to: state }),
119
124
  output: def.output,
120
125
  policy: def.policy,
126
+ // A compare-and-set move replayed under its key answers the first outcome, never a second
127
+ // `X_STATE_CONFLICT` for a move that already happened.
128
+ idempotent: true,
121
129
  ...(def.audit === undefined ? {} : { audit: def.audit }),
122
130
  // Never overridable: the server is the half that REFUSED the move, and a local twin that won
123
131
  // the rebase would leave the client showing a state the database rejected.
@@ -142,3 +150,21 @@ export function transition<
142
150
  },
143
151
  });
144
152
  }
153
+
154
+ /**
155
+ * The `id` a move takes, from the key the OUTPUT declares — never a fixed `t.uuid`, which made
156
+ * every call on an entity keyed by `text()` or `bigint()` `X_INPUT_INVALID` while the mechanism
157
+ * underneath addresses any single-column key. An entity `$view` builds its `id` node from the key
158
+ * column, so the bounds copied here are that column's: a uuid stays a uuid (and a malformed one is
159
+ * still refused before a database reads it), a `bigint()` keeps its digits pattern, a `text()` its
160
+ * length. Never empty: an empty string names no row. An output with no string `id` keeps the
161
+ * framework's default key type, `uuid().primaryKey()`.
162
+ */
163
+ function keySchemaOf(output: StandardSchemaV1): StringSchema {
164
+ const key = nodeOf(output)?.properties?.['id'];
165
+ if (key === undefined || key.kind !== 'string' || key.format === 'uuid') return t.uuid;
166
+ let schema = t.string.min(Math.max(1, key.minLength ?? 1));
167
+ if (key.maxLength !== undefined) schema = schema.max(key.maxLength);
168
+ if (key.pattern !== undefined) schema = schema.pattern(new RegExp(key.pattern, key.patternFlags));
169
+ return schema;
170
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * The open database transaction, as this package needs it — **the only** file here that imports
3
+ * `@ultimat3/db` (`tx-scope.test.ts` holds that). When is a write durable (`openCommitScope`, the
4
+ * cache bust), and is a transaction really OPEN, on which client (`liveTransaction`, idempotency).
5
+ */
6
+
7
+ // Type-only: `idempotency-postgres.ts` imports this module, and a runtime edge would be a cycle.
8
+ import type { PgExecutor } from '@ultimat3/core';
9
+ import { currentTx, liveTxConnection } from '@ultimat3/db';
10
+
11
+ /** The half of a transaction a deferred effect needs. `@ultimat3/db`'s `DbTx` satisfies it. */
12
+ export interface CommitScope {
13
+ /** Fired once the ROOT transaction has committed; dropped on rollback or a lost COMMIT. */
14
+ onCommit(effect: () => void): void;
15
+ }
16
+
17
+ /**
18
+ * The transaction this call runs inside, or `undefined`. `currentTx()` on purpose, FINISHED scopes
19
+ * included: a promise chain the body forgot to await still finds the handle after the scope
20
+ * closed, and `onCommit` on it runs the effect at once after a COMMIT and drops it after a
21
+ * ROLLBACK — the answer `@ultimat3/entity`'s row observer gets from the same call, so a late bust
22
+ * and a late change report never disagree about one write.
23
+ */
24
+ export function openCommitScope(): CommitScope | undefined {
25
+ return currentTx();
26
+ }
27
+
28
+ /** A transaction that is still OPEN: statements sent now are inside it. */
29
+ export interface LiveTransaction extends CommitScope {
30
+ /** The client it was opened on — which DATABASE this is, compared by identity. */
31
+ readonly origin: object;
32
+ /** Fired when the scope rolls back; never after a commit. */
33
+ onRollback(undo: () => void): void;
34
+ /** `(text, values)` on the transaction's OWN connection. */
35
+ readonly executor: PgExecutor;
36
+ }
37
+
38
+ /**
39
+ * The open transaction, or `undefined` — outside one, and ALSO for a finished scope's handle. A
40
+ * settlement bound to a transaction that already ended is bound to nothing: it would run as its
41
+ * own commit while the record claimed otherwise, and that record is the one a retry may reclaim.
42
+ *
43
+ * The fragment is assembled by hand, exactly as `@ultimat3/cli`'s `pgExecutorFor` does: the caller
44
+ * wrote the `$1..$n` text itself and hands over already-bound values.
45
+ */
46
+ export function liveTransaction(): LiveTransaction | undefined {
47
+ const tx = currentTx();
48
+ if (tx === undefined || liveTxConnection() === undefined) return undefined;
49
+ return {
50
+ origin: tx.origin,
51
+ onCommit: (effect) => tx.onCommit(effect),
52
+ onRollback: (undo) => tx.onRollback(undo),
53
+ executor: {
54
+ query: <R>(text: string, values: readonly unknown[]) => tx.query<R>({ text, values }),
55
+ },
56
+ };
57
+ }