@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 +28 -5
- package/README.md +52 -6
- package/package.json +8 -7
- package/src/audit-memory.ts +2 -0
- package/src/audit-postgres.ts +1 -2
- package/src/cache-gate.ts +25 -6
- package/src/contract-test.ts +35 -11
- package/src/errors-idempotency.ts +36 -2
- package/src/errors.ts +7 -2
- package/src/idempotency-memory.ts +69 -3
- package/src/idempotency-postgres.ts +72 -31
- package/src/idempotency.ts +4 -0
- package/src/index.ts +10 -7
- package/src/invoke.ts +3 -2
- package/src/json-schema.ts +4 -9
- package/src/mcp-tool.ts +12 -4
- package/src/mutator.ts +13 -2
- package/src/openapi.ts +25 -2
- package/src/registry.ts +4 -3
- package/src/request-deadline.ts +25 -0
- package/src/transition.ts +35 -9
- package/src/tx-scope.ts +57 -0
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-
|
|
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
|
|
213
|
-
|
|
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
|
|
395
|
-
|
|
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
|
|
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": "
|
|
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": "
|
|
39
|
-
"@ultimat3/core": "
|
|
40
|
-
"@ultimat3/
|
|
41
|
-
"@ultimat3/
|
|
42
|
-
"@ultimat3/
|
|
43
|
-
"@ultimat3/
|
|
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
|
}
|
package/src/audit-memory.ts
CHANGED
|
@@ -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. */
|
package/src/audit-postgres.ts
CHANGED
|
@@ -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
|
-
*
|
|
4
|
-
* one logged failure instead of a failed action
|
|
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
|
|
14
|
-
*
|
|
15
|
-
* is not that case: `invalidateTags` absorbs those into `report.errors
|
|
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);
|
package/src/contract-test.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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 (
|
|
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
|
-
|
|
108
|
-
|
|
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
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
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
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
reservationId,
|
|
233
|
-
|
|
234
|
-
|
|
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.
|
package/src/idempotency.ts
CHANGED
|
@@ -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
|
|
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-
|
|
272
|
-
//
|
|
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;
|
package/src/json-schema.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Standard Schema -> JSON Schema, normalized to a plain record
|
|
3
|
-
*
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
|
|
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(
|
|
56
|
-
outputSchema: sortSchema(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
27
|
-
*
|
|
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
|
|
73
|
-
*
|
|
74
|
-
*
|
|
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:
|
|
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
|
+
}
|
package/src/tx-scope.ts
ADDED
|
@@ -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
|
+
}
|