@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/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
 
@@ -18,11 +19,13 @@ Owns the `action` + `mutator` primitives and their six projections. Tier 3.
18
19
  | `facade.ts` | the fluent surface — binds each projection to the action, re-implements none |
19
20
  | `mutator.ts` | action + optimistic `.local` twin + authoritative `.server` + `.conflict` |
20
21
  | `registry.ts` | export-name registration, collisions, `describeActions()` |
21
- | `define-api.ts` | `defineApi({ actions, mutators, queries, llm, jobs, tasks })` — the app's one boot call |
22
+ | `define-api.ts` | `defineApi({ actions, mutators, queries, llm, jobs, tasks })` — the app's one boot call, and the `Api` type the typed clients are shaped from |
23
+ | `client-scale-pins.ts` | compile-time pin: `rpc<Api['actions']>` over 300 actions in 100 modules, 100 reads in 50 |
22
24
  | `http.ts` | route projection (`enforcedBy: 'handler'`) + OpenAPI operation |
23
25
  | `openapi.ts` | deterministic OpenAPI 3.1 document |
24
26
  | `openapi-complete.ts` | the COMPLETE document (`defineApi({ openapi })`: info, servers, security schemes, 401/429 by the ROUTE's `meta.auth`) and a bearer mount's own cut |
25
27
  | `http-path.ts` | where an action is served: the app's `pathStyle` + a per-action `http.path` pin, read lazily by every projection |
28
+ | `path-style-miss.ts` | `explainActionPathMiss` — `@ultimat3/http`'s `hooks.explainMiss`: a POST under the style this app does not serve is `X_CONTRACT_DRIFT` naming the one it does |
26
29
  | `api-declaration.ts` | `defineApi({ http, openapi })` held process-wide for the boot and `x manifest` |
27
30
  | `errors-http.ts` | the three HTTP-declaration refusals (codes registered in `errors.ts`) |
28
31
  | `client.ts` | typed RPC client (browser-safe: no server imports) — dispatches through core's `clientTransport` |
@@ -41,7 +44,9 @@ Owns the `action` + `mutator` primitives and their six projections. Tier 3.
41
44
  | `idempotency-postgres.ts` | the SHARED store — one table, one `insert … on conflict` |
42
45
  | `deprecation.ts` | `Deprecation` + the RFC 9745/8594 render + the `deprecated_calls_total` counter |
43
46
  | `policy-gate.ts` | **the only** runtime edge to `@ultimat3/policy` (`errors.ts` takes `SurfaceDenial` as a type, which erases) |
44
- | `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` |
45
50
  | `audit.ts` | the audit seam: `AuditRecord`, `AuditSink`, the installed-sink store |
46
51
  | `audit-memory.ts` | the process default: a bounded ring that DROPS, and counts what it dropped |
47
52
  | `audit-postgres.ts` | the DURABLE sink — one append-only `x_audit` table, one insert per record |
@@ -77,12 +82,28 @@ Owns the `action` + `mutator` primitives and their six projections. Tier 3.
77
82
  `'resource'` stays the default (changing it moves every URL). `defineApi` sets the style FIRST and
78
83
  `configureActionPathStyle` re-derives every seated action (the module scan may have seated them
79
84
  under the default). `derivePath(name)` is pin-aware through `installPinLookup` — handed down by
80
- `registry.ts`, never imported up (cycle through `action.ts`). `rpc()` cannot see a pin: it takes
81
- `pathStyle`; `action.client()` holds the pin.
85
+ `registry.ts`, never imported up (cycle through `action.ts`). `rpc()` cannot see a pin;
86
+ `action.client()` holds it.
87
+ - **The style is stated ONCE, on the server.** A browser never restates it: the document carries
88
+ `<meta name="ultimate-path-style">` (render's `clientPathStyleTags`, written only for a
89
+ non-default style) and core's `actionPath(name)` reads it for every caller naming none — `rpc`,
90
+ realtime's `useMutation` and the outbox replay. `client.ts` passes `options.pathStyle` through
91
+ and adds NO default of its own. `ClientOptions.pathStyle` is for a caller with no document; a
92
+ wrong one is answered `X_CONTRACT_DRIFT` (404) by `explainActionPathMiss`, never
93
+ `X_ROUTE_NOT_FOUND`. A pinned action and a path under the SERVED style are never explained.
94
+ - **`Merge` in `define-api.ts` never recurses over the module list.** `Head & Merge<Rest>` is not
95
+ a tail call: it cost one instantiation level per MODULE and answered TS2589 at the 48th entry of
96
+ one list, whatever each exported (#534). The list is read as a union and intersected in one
97
+ step. `client-scale-pins.ts` is the build error; `client-scale.contract.test.ts` compiles real
98
+ `action()`/`query()` modules through `defineApi`'s `const` inference; `@ultimat3/query` pins
99
+ `QueryClient` itself.
82
100
  - **`defineApi({ openapi })` opts into the complete document; absent, `openapi.json`'s bytes are
83
101
  unchanged** — an upgrade must not make a committed contract stale by itself.
84
102
  - Registration names the action the app exported, in place. Naming an already-named action is the
85
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.
86
107
  - A mutator projects `.local`, `.server`, `.conflict` plus every action member — no aliases.
87
108
  `mutator.server()` calls the action's own callable (lands in `invoke`); `.local()` never leaves the
88
109
  client.
@@ -95,7 +116,8 @@ Owns the `action` + `mutator` primitives and their six projections. Tier 3.
95
116
  - `src/index.ts` re-exports `t` from `@ultimat3/schema` **verbatim** (`index.test.ts` asserts identity).
96
117
  - **`transition()` is a mutator factory that decides nothing about the machine**: entity's three
97
118
  codes propagate; `from` is REQUIRED (it is the UPDATE's predicate); `conflict: 'server-wins'` fixed;
98
- `audit` off unless declared.
119
+ `audit` off unless declared; `id` is the key `output.id` declares (`keySchemaOf`), never a fixed
120
+ `t.uuid`.
99
121
  - **A lookup table is read with `Object.hasOwn`** (`IRREGULAR` in `naming.ts`, `BY_FORMAT` in
100
122
  `sample-input.ts`) — caller- or provider-supplied keys.
101
123
 
@@ -168,7 +190,9 @@ Owns the `action` + `mutator` primitives and their six projections. Tier 3.
168
190
  | `rpc` | 18,097 B | 23,007 B | 18,119 B | 19,074 B |
169
191
  | `rpc` + `createClientFlight` | 23,903 B | 28,823 B | not measured | 25,197 B |
170
192
 
171
- Net against the pre-transport figure: +977 B, about the envelope decoder's size.
193
+ Net against the pre-transport figure: +977 B, about the envelope decoder's size. `As of 2026-10-01`
194
+ `rpc` is 19,671 B (19,490 B before the document's path-style stamp was read: +181 B, +45 gzipped)
195
+ and `rpc` + `createClientFlight` 25,954 B.
172
196
  - **The `sideEffects` array is load-bearing**: `errors.ts` runs `registerErrorCodes` at import. Never `false`.
173
197
 
174
198
  ## Invariants — idempotency
@@ -192,14 +216,30 @@ Owns the `action` + `mutator` primitives and their six projections. Tier 3.
192
216
  `configureIdempotency({ scope })`, compared by `assertIdempotencyScope` in `registerAction`
193
217
  (`X_IDEMPOTENCY_NOT_SHARED`). Default `'process'`.
194
218
  - **The memory store is bounded; `in-flight` records are the last evicted.** Never an LRU.
195
- - **`postgresIdempotencyStore` is the shared store** over a structural `PgExecutor` (no `action -> db`
196
- 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.
197
231
 
198
232
  ## Invariants — cache and audit
199
233
 
200
234
  - **The post-commit bust never fails the write** — `cache-gate.ts` (the only `invalidateTags` caller)
201
235
  absorbs a refusing fan-out and logs through core's `logger`, never rendering the tags. A replay
202
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`).
203
243
  - **The policy contract test asserts `ActionDeniedError` and sends valid input** (`sampleInput` from
204
244
  the input IR). Only `X_INPUT_INVALID` (and `X_AUDIT_SINK_MISSING`, the one refusal before the parse)
205
245
  becomes `X_CONTRACT_DRIFT`; everything else keeps its own code; a non-`UltimateError` is rethrown.
package/README.md CHANGED
@@ -113,8 +113,10 @@ export const client = rpc<Api['actions']>({ baseUrl: '/' });
113
113
  ```
114
114
 
115
115
  `Api['actions']` is the merged module type, so `client.publishPost` is typed from the
116
- declaration with no codegen step. `Api` is imported as a **type only**, which is what keeps
117
- a page's module graph free of any edge to a feature's implementation.
116
+ declaration with no codegen step — at any app size: `client-scale-pins.ts` compiles the idiom over
117
+ 300 actions in 100 modules on every `typecheck` (the limit was 47 modules in one list, #534).
118
+ `x new` writes this file as `apps/web/shared/browser-client.ts`. `Api` is imported as a **type
119
+ only**, which is what keeps a page's module graph free of any edge to a feature's implementation.
118
120
 
119
121
  Every call dispatches through `@ultimat3/core`'s `clientTransport` — the one browser HTTP function.
120
122
  It owns credentials, the JSON body, the `Idempotency-Key` header, the principal fence and the
@@ -157,8 +159,8 @@ await api.charge({ orderId }, { idempotencyKey: `charge:${orderId}`, retry: { at
157
159
  It shipped as a byte-identical copy in each; the copies are gone and every name is importable from
158
160
  this package exactly as before.
159
161
 
160
- Importing `rpc` alone from this package is **23,007 B** minified for the browser; adding
161
- `createClientFlight` is **28,823 B** (`As of 2026-09-22`; `CLAUDE.md` carries the before/after and
162
+ Importing `rpc` alone from this package is **19,671 B** minified for the browser; adding
163
+ `createClientFlight` is **25,954 B** (`As of 2026-10-01`; `CLAUDE.md` carries the before/after and
162
164
  what the delta is). `ClientFlight` is a TYPE inside `client.ts` and never a value, which is what
163
165
  keeps the second number off the first caller's bill.
164
166
 
@@ -182,9 +184,15 @@ One rule per app, declared once — `defineApi({ http: { pathStyle } })` — plu
182
184
  `/_x` (`X_ACTION_HTTP_PATH_INVALID`). A pin colliding with another path is
183
185
  `X_ACTION_PATH_DUPLICATE` at boot.
184
186
  - Every server-side reader agrees: `toRoute`, `toOpenApiOperation`, `describeAction`,
185
- `derivePath(name)` (pin-aware) and `actionHttpPath(action | name)`. The browser's `rpc()` derives
186
- from the name alone, so it is told the style: `rpc({ baseUrl, pathStyle: 'readable' })`; a pinned
187
- action is called through `action.client()`, which holds the pin.
187
+ `derivePath(name)` (pin-aware) and `actionHttpPath(action | name)`. A pinned action is called
188
+ through `action.client()`, which holds the pin.
189
+ - **A browser states no style.** The server stamps a non-default one into every document
190
+ (`<meta name="ultimate-path-style">`) and `rpc({ baseUrl: '' })` derives under it — as do
191
+ `useMutation` and the outbox replay. `'resource'` writes no tag.
192
+ - **A caller with no document passes it**: `rpc({ baseUrl, pathStyle: 'readable' })` in a script or
193
+ another service. The wrong one is answered by the server — 404 `X_CONTRACT_DRIFT`, naming the
194
+ style it serves and the path the action lives at — never a bare `X_ROUTE_NOT_FOUND`
195
+ (`explainActionPathMiss`, wired as `@ultimat3/http`'s `hooks.explainMiss`).
188
196
  - **The style is read when the path is derived, so derive it late.** The scan evaluates
189
197
  `apps/web/api/index.ts` before any other module, so a page or admin view already sees the declared
190
198
  style. A module the index itself imports runs before its `defineApi` call: a module-level
@@ -288,6 +296,13 @@ fan-out that refuses — an undeclared tag, `X_CACHE_TAG_UNKNOWN` — is one
288
296
  `action.invalidate.failed` log line and the entries expire by TTL. A replayed idempotent
289
297
  call busts nothing; the first call already did.
290
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
+
291
306
  Registering an action without `policy:` throws `X_ACTION_POLICY_MISSING`; there is
292
307
  no bypass flag. A look-alike that never came out of `action()` is `X_ACTION_FOREIGN`.
293
308
 
@@ -302,6 +317,7 @@ export const likePost = mutator({
302
317
  input: t.object({ postId: t.uuid }),
303
318
  output: PostLikes,
304
319
  policy: can('post:like'),
320
+ idempotent: true, // REQUIRED — a replayed write answers the first result (below)
305
321
  // Convergent, not incremental: `local` replays on every rebase, so applying it N times has to
306
322
  // equal applying it once — `likedByMe` is what makes the second application a no-op.
307
323
  local(tx, { postId }) {
@@ -333,6 +349,14 @@ denies is denied there exactly as over HTTP. `.local()` is the only half that sk
333
349
  the core, because it never leaves the client; keep it a pure function of `(tx, input)`
334
350
  — no I/O, no clock, no randomness — since every rebase replays it.
335
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
+
336
360
  `LocalTx` is the client write surface, implemented by `@ultimat3/realtime` over the page's
337
361
  record store — the SAME shape as that store's tx. Every table is addressed by **key**:
338
362
  `get(key)`, `all()`, `insert(key, row)`, `upsert(key, row)`, `update(key, patch | fn)`,
@@ -380,12 +404,13 @@ await moveOrder({ id, from: 'pending', to: 'paid' }, { ctx });
380
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 |
381
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 |
382
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 |
383
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` |
384
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 |
385
411
 
386
- `table` is typed structurally (`TransitionTarget`), not imported: `@ultimat3/action` holds no
387
- dependency edge on `@ultimat3/entity` — the tier table permits one, the manifest and the lockfile do
388
- 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.
389
414
 
390
415
  ## Determinism + idempotency
391
416
 
@@ -456,6 +481,7 @@ no record and runs the handler again:
456
481
  import {
457
482
  configureIdempotency,
458
483
  postgresIdempotencyStore,
484
+ requestDeadlineMs,
459
485
  setIdempotencyStore,
460
486
  } from '@ultimat3/action';
461
487
  import { db } from '@ultimat3/db';
@@ -470,6 +496,10 @@ const client = db();
470
496
  setIdempotencyStore(
471
497
  postgresIdempotencyStore({
472
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,
473
503
  }),
474
504
  );
475
505
  configureIdempotency({ scope: 'shared' });
@@ -506,6 +536,26 @@ Both stores fence on it AND on `in-flight` `As of 2026-08`, the way `@ultimat3/j
506
536
  so a straggler from the first attempt satisfied a status-only fence exactly and overwrote a live
507
537
  reservation.
508
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
+
509
559
  A `query` has none and never will: a read has nothing to be idempotent about.
510
560
 
511
561
  ## `deprecated:` — a compat window, not a version
@@ -677,7 +727,7 @@ setAuditSink({
677
727
  |---|---|
678
728
  | input schema rejects garbage | the invocation fails `X_INPUT_INVALID` — that code, not any failure |
679
729
  | policy denies an anonymous actor | the invocation fails with an `ActionDeniedError` |
680
- | 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 |
681
731
 
682
732
  The denial assertion sends an input synthesized from `input:`'s own schema — required keys
683
733
  only, formats included — because a payload the schema rejects never reaches a policy. It
@@ -708,8 +758,10 @@ never a pass — the assertion says which code got in the way and names `input:`
708
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` |
709
759
  | `X_IDEMPOTENCY_CONFLICT` | key reused with a new payload / still in flight | new key, or retry later |
710
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 |
711
- | `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 |
712
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 |
713
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 |
714
766
  | `X_CONTRACT_DRIFT` | client/server build skew, missing spec entry | reload / `x verify --only contract` |
715
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 |
@@ -754,13 +806,15 @@ a job boundary the class is gone and the `code` is what survives — match on th
754
806
  | `IdempotencyKeyInvalidError` | `X_IDEMPOTENCY_KEY_INVALID` | `src/errors-idempotency.ts` |
755
807
  | `IdempotencyNotSharedError` | `X_IDEMPOTENCY_NOT_SHARED` | `src/errors-idempotency.ts` |
756
808
  | `IdempotencyReplayedFailureError` | `X_IDEMPOTENCY_REPLAYED_FAILURE` | `src/errors-idempotency.ts` |
809
+ | `IdempotencyReservationLostError` | `X_IDEMPOTENCY_RESERVATION_LOST` | `src/errors-idempotency.ts` |
757
810
  | `IdempotencyStatusUnknownError` | `X_IDEMPOTENCY_STATUS_UNKNOWN` | `src/errors-idempotency.ts` |
758
811
  | `InputInvalidError` | `X_INPUT_INVALID` | `src/errors.ts` |
812
+ | `MutatorNotIdempotentError` | `X_MUTATOR_NOT_IDEMPOTENT` | `src/errors-idempotency.ts` |
759
813
  | `OutputInvalidError` | `X_OUTPUT_INVALID` | `src/errors.ts` |
760
814
  | `RemoteActionError` | the code the server sent, verbatim — `meta.origin: 'remote'` | `src/errors.ts` |
761
815
  | `RpcFailedError` | `X_RPC_FAILED` | `src/errors.ts` |
762
816
 
763
817
  ## Boundaries
764
818
 
765
- 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
766
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": "22.15.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",
@@ -22,6 +22,7 @@
22
22
  "files": [
23
23
  "src",
24
24
  "!src/**/*.test.ts",
25
+ "!src/**/*-fixture.ts",
25
26
  "CLAUDE.md",
26
27
  "README.md",
27
28
  "LICENSE"
@@ -34,11 +35,12 @@
34
35
  "test": "bun test"
35
36
  },
36
37
  "dependencies": {
37
- "@ultimat3/cache": "22.15.0",
38
- "@ultimat3/core": "22.15.0",
39
- "@ultimat3/entity": "22.15.0",
40
- "@ultimat3/http": "22.15.0",
41
- "@ultimat3/policy": "22.15.0",
42
- "@ultimat3/schema": "22.15.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"
43
45
  }
44
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);
@@ -0,0 +1,126 @@
1
+ // Compile-time pin: the documented `rpc<Api['actions']>` idiom at 300 actions in 100 modules.
2
+ // Source, not a `.test.ts`, for `type-pins.ts`'s reason — `tsc -b` never reads a test file. Types
3
+ // only: nothing here is emitted, and a regression is TS2589 in the `typecheck` step.
4
+ //
5
+ // The shape is the OBSERVED one (issue #534): the limit was never the action count, it was the
6
+ // number of MODULES in one `defineApi` list — 47 passed and 48 failed, whatever they exported.
7
+
8
+ import type { NumberSchema, ObjectSchema, StringSchema } from '@ultimat3/schema';
9
+ import type { Action } from './action';
10
+ import type { rpc } from './client';
11
+ import type { defineApi } from './define-api';
12
+
13
+ type Assert<T extends true> = T;
14
+
15
+ type Equals<A, B> = [A] extends [B] ? ([B] extends [A] ? true : false) : false;
16
+
17
+ type Verb = 'create' | 'update' | 'archive';
18
+
19
+ /** Distinct per module (`m7`), so no two modules collapse into one instantiation. */
20
+ type ScaleInput<N extends number> = ObjectSchema<
21
+ {
22
+ readonly id: StringSchema;
23
+ readonly title: StringSchema;
24
+ readonly nested: ObjectSchema<{ readonly label: StringSchema; readonly rank: NumberSchema }>;
25
+ } & { readonly [K in `m${N}`]: NumberSchema }
26
+ >;
27
+
28
+ type ScaleOutput<N extends number> = ObjectSchema<
29
+ { readonly id: StringSchema } & { readonly [K in `out${N}`]: StringSchema }
30
+ >;
31
+
32
+ /** A feature module as `import * as` sees one: its actions, and a helper that is not one. */
33
+ type ActionModule<N extends number> = {
34
+ readonly [K in `${Verb}Thing${N}`]: Action<ScaleInput<N>, ScaleOutput<N>>;
35
+ } & { readonly [K in `helper${N}`]: (value: number) => number };
36
+
37
+ /** `@ultimat3/query` is sideways, so a read is stood in for by the two members `Merge` reads. */
38
+ type QueryModule<N extends number> = {
39
+ readonly [K in `listRows${N}` | `oneRow${N}`]: { readonly kind: 'query'; readonly name: string };
40
+ };
41
+
42
+ /** `[Module<0>, …, Module<TCount - 1>]`. A tail call, so the list length costs no depth here. */
43
+ type ActionModules<
44
+ TCount extends number,
45
+ TAcc extends readonly unknown[] = [],
46
+ > = TAcc['length'] extends TCount
47
+ ? TAcc
48
+ : ActionModules<TCount, [...TAcc, ActionModule<TAcc['length']>]>;
49
+
50
+ type QueryModules<
51
+ TCount extends number,
52
+ TAcc extends readonly unknown[] = [],
53
+ > = TAcc['length'] extends TCount
54
+ ? TAcc
55
+ : QueryModules<TCount, [...TAcc, QueryModule<TAcc['length']>]>;
56
+
57
+ /** 100 modules of 3 actions, 50 modules of 2 reads — through `defineApi`'s own signature. */
58
+ type ScaleApi = ReturnType<
59
+ typeof defineApi<{
60
+ readonly actions: ActionModules<100>;
61
+ readonly queries: QueryModules<50>;
62
+ }>
63
+ >;
64
+
65
+ /** The idiom itself: its `ActionMap` constraint is where TS2589 was raised. */
66
+ type ScaleClient = ReturnType<typeof rpc<ScaleApi['actions']>>;
67
+
68
+ export type _EveryModuleReachesTheClient = Assert<
69
+ 'createThing0' | 'updateThing47' | 'archiveThing99' extends keyof ScaleClient ? true : false
70
+ >;
71
+
72
+ /** 300, counted: a `Merge` that dropped a module would still pass the three names above. */
73
+ export type _TheClientHasExactlyTheRegisteredActions = Assert<
74
+ Equals<keyof ScaleClient, ActionName>
75
+ >;
76
+
77
+ type ActionName = {
78
+ [N in keyof ActionModules<100> & `${number}`]: `${Verb}Thing${N}`;
79
+ }[keyof ActionModules<100> & `${number}`];
80
+
81
+ /** A helper exported beside the actions is not a method — the server never registered it. */
82
+ export type _AHelperIsNotAClientMethod = Assert<'helper0' extends keyof ScaleClient ? false : true>;
83
+
84
+ /** One call, both sides inferred from the LAST module's own schemas. */
85
+ export type _TheLastActionKeepsItsInput = Assert<
86
+ Equals<
87
+ Parameters<ScaleClient['archiveThing99']>[0],
88
+ {
89
+ readonly id: string;
90
+ readonly title: string;
91
+ readonly nested: { readonly label: string; readonly rank: number };
92
+ readonly m99: number;
93
+ }
94
+ >
95
+ >;
96
+
97
+ export type _TheLastActionKeepsItsOutput = Assert<
98
+ Equals<
99
+ Awaited<ReturnType<ScaleClient['archiveThing99']>>,
100
+ { readonly id: string; readonly out99: string }
101
+ >
102
+ >;
103
+
104
+ /** `Api['queries']` is the same `Merge`, and regresses with it. */
105
+ export type _EveryQueryModuleIsMerged = Assert<
106
+ Equals<
107
+ keyof ScaleApi['queries'],
108
+ {
109
+ [N in keyof QueryModules<50> & `${number}`]: `listRows${N}` | `oneRow${N}`;
110
+ }[keyof QueryModules<50> & `${number}`]
111
+ >
112
+ >;
113
+
114
+ /** No list at all, and an empty one, are both the empty map — never `unknown`. */
115
+ type EmptyApi = ReturnType<typeof defineApi<{ readonly actions: readonly [] }>>;
116
+
117
+ export type _AnEmptyListIsAnEmptyMap = Assert<
118
+ Equals<keyof EmptyApi['actions'] | keyof EmptyApi['queries'], never>
119
+ >;
120
+
121
+ export type _AnEmptyMapStillSatisfiesRpc = ReturnType<typeof rpc<EmptyApi['actions']>>;
122
+
123
+ /** One module handed over bare, not in a list — the other spelling `ApiModules` allows. */
124
+ type BareApi = ReturnType<typeof defineApi<{ readonly actions: ActionModule<7> }>>;
125
+
126
+ export type _ABareModuleIsRegisteredToo = Assert<Equals<keyof BareApi['actions'], `${Verb}Thing7`>>;
package/src/client.ts CHANGED
@@ -84,10 +84,13 @@ export interface ClientOptions {
84
84
  */
85
85
  readonly flight?: ClientFlight;
86
86
  /**
87
- * The server's `defineApi({ http: { pathStyle } })`, restated: the browser derives each URL from
88
- * the name and cannot read the server's registry. Absent is `'resource'`, the server's default.
89
- * A per-action `http.path` pin is honoured by `action.client()` (it holds the declaration); the
90
- * name-only `rpc()` proxy cannot see one, so a pinned action is called through its own client.
87
+ * The style of a server that is NOT the one that rendered this page — a script, another
88
+ * service. A browser states nothing: the server stamps its `defineApi({ http: { pathStyle } })`
89
+ * into the document and every path is derived under that. With no document and no value here it
90
+ * is `'resource'`, the server's default; a server reached under the wrong one answers
91
+ * `X_CONTRACT_DRIFT` naming the style it serves. A per-action `http.path` pin is honoured by
92
+ * `action.client()` (it holds the declaration); the name-only `rpc()` proxy cannot see one, so a
93
+ * pinned action is called through its own client.
91
94
  */
92
95
  readonly pathStyle?: ActionPathStyle;
93
96
  }
@@ -123,7 +126,9 @@ export function clientMethodFor<TInput extends StandardSchemaV1, TOutput extends
123
126
  options: ClientOptions,
124
127
  pinnedPath?: string,
125
128
  ): ClientMethod<TInput, TOutput> {
126
- const path = pinnedPath ?? actionPath(name, options.pathStyle ?? 'resource');
129
+ // No default here: for a caller naming no style, `actionPath` asks the document the server
130
+ // rendered, and that answer must be the same one `useMutation` and the outbox replay get.
131
+ const path = pinnedPath ?? actionPath(name, options.pathStyle);
127
132
  const url = `${options.baseUrl.replace(/\/+$/, '')}${path}`;
128
133
  const onResponse = (response: Response): void =>
129
134
  assertSameBuild(options.buildId, response.headers.get(BUILD_ID_HEADER), name);
@@ -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.