@ultimat3/action 23.0.0 → 25.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +91 -42
- package/README.md +167 -47
- package/package.json +10 -9
- package/src/action.ts +25 -69
- package/src/api-declaration.ts +1 -1
- package/src/audit-gate.ts +3 -4
- package/src/audit-memory.ts +5 -3
- package/src/audit-postgres.ts +16 -8
- package/src/audit.ts +7 -106
- package/src/cache-gate.ts +25 -6
- package/src/client.ts +18 -6
- package/src/contract-test.ts +50 -14
- package/src/error-titles.ts +51 -0
- package/src/errors-http.ts +3 -1
- package/src/errors-idempotency.ts +57 -5
- package/src/errors.ts +44 -61
- package/src/facade.ts +6 -7
- package/src/http-path.ts +1 -1
- package/src/http.ts +23 -20
- package/src/idempotency-memory.ts +92 -8
- package/src/idempotency-postgres.ts +91 -39
- package/src/idempotency-redact.ts +140 -0
- package/src/idempotency.ts +58 -7
- package/src/index.ts +37 -88
- package/src/invoke.ts +57 -21
- package/src/job-handle.ts +5 -3
- package/src/json-schema.ts +6 -10
- package/src/mutator.ts +14 -4
- package/src/naming.ts +2 -3
- package/src/openapi-complete.ts +16 -1
- package/src/openapi.ts +25 -2
- package/src/policy-gate.ts +8 -51
- package/src/rate-limit-gate.ts +114 -0
- package/src/record-wire.ts +2 -2
- package/src/registry.ts +5 -4
- package/src/request-deadline.ts +25 -0
- package/src/stable.ts +0 -7
- package/src/transition.ts +35 -9
- package/src/tx-scope.ts +57 -0
- package/src/type-pins.ts +98 -3
- package/src/deprecation.ts +0 -77
- package/src/mcp-tool.ts +0 -87
- package/src/wire-headers.ts +0 -20
package/README.md
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
# @ultimat3/action ⚡
|
|
2
2
|
|
|
3
|
-
One declaration → six artifacts.
|
|
3
|
+
One declaration → six artifacts. Five are projected here; the MCP tool is `@ultimat3/mcp`'s
|
|
4
|
+
(tier 4), from the same declaration.
|
|
4
5
|
|
|
5
6
|
| # | Artifact | Reach it with | Guarantees |
|
|
6
7
|
|---|---|---|---|
|
|
7
8
|
| 1 | HTTP route `POST /api/<resource>/<verb>` | `toRoute(publishPost)` — the server mounts it | policy + validation + idempotency + invalidation, non-optional |
|
|
8
9
|
| 2 | OpenAPI 3.1 operation + document | `publishPost.openapi()` / `buildOpenApi()` | byte-stable output, diffed by `x verify` |
|
|
9
10
|
| 3 | Typed RPC client | `publishPost.client({ baseUrl })` / `rpc<Api['actions']>()` | server typo = compile error in Solid |
|
|
10
|
-
| 4 | MCP tool | `publishPost
|
|
11
|
+
| 4 | MCP tool | `toolFrom(publishPost)` from `@ultimat3/mcp` — what `tools/list` serves | *identical* policy evaluation to the route: it calls this package's `invoke` with `surface: 'mcp'` |
|
|
11
12
|
| 5 | Job handle | `publishPost.job()` | enqueue durable work, no rewrite |
|
|
12
13
|
| 6 | Contract tests | `publishPost.contract()` | garbage rejected, anonymous denied, spec present |
|
|
13
14
|
|
|
@@ -22,16 +23,17 @@ publishPost.output // the declared output schema
|
|
|
22
23
|
publishPost.policy // the one policy object
|
|
23
24
|
publishPost.mcp // { expose, description }, as declared
|
|
24
25
|
await publishPost.as(actor, { postId }) // run as someone, one execution path
|
|
25
|
-
publishPost.tool() // MCP descriptor
|
|
26
26
|
publishPost.openapi() // OpenAPI operation
|
|
27
27
|
publishPost.client({ baseUrl }) // typed RPC method
|
|
28
28
|
publishPost.job() // durable-work handle
|
|
29
29
|
publishPost.contract() // the three generated assertions
|
|
30
30
|
```
|
|
31
31
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
32
|
+
There is no `.tool()` (`As of 25.0.0`): it was a second MCP projection built a tier below the
|
|
33
|
+
one `@ultimat3/mcp` serves, and the two disagreed. The tool an agent is shown is
|
|
34
|
+
`toolFrom(publishPost)`, and its every call lands in this package's `invoke` — the one
|
|
35
|
+
policy object, so an MCP call cannot reach a different authz path. `.as()` keeps the
|
|
36
|
+
surrounding context whole and swaps only the actor: impersonation, not a second context.
|
|
35
37
|
|
|
36
38
|
## Declare
|
|
37
39
|
|
|
@@ -133,7 +135,7 @@ returned none, so one operation has one wire shape. Every other action's body is
|
|
|
133
135
|
what it was before the envelope existed, and its OpenAPI operation is unchanged; an enveloped one
|
|
134
136
|
documents `data`, `records` and `removed`, and the header, generated from the declaration.
|
|
135
137
|
|
|
136
|
-
### Flight control — `
|
|
138
|
+
### Flight control — `clientFlight`, opt-in
|
|
137
139
|
|
|
138
140
|
Same object as `@ultimat3/query`'s, installed the same way (`rpc({ baseUrl, flight })`), and the
|
|
139
141
|
write half is the half made of refusals:
|
|
@@ -154,13 +156,14 @@ declare const orderId: string;
|
|
|
154
156
|
await api.charge({ orderId }, { idempotencyKey: `charge:${orderId}`, retry: { attempts: 3 } });
|
|
155
157
|
```
|
|
156
158
|
|
|
157
|
-
`
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
159
|
+
`clientFlight` is **`@ultimat3/core`'s**, and imported from there — `import {
|
|
160
|
+
clientFlight } from '@ultimat3/core'`. It shipped as a byte-identical copy here and in
|
|
161
|
+
`@ultimat3/query` (both tier 3, neither may import the other); the copies are gone, and since 25.0.0
|
|
162
|
+
so are the re-exports: one value, one import path (`X_HELPER_COPY`, `bun run flight-copies`). The
|
|
163
|
+
types `ClientFlight` and `ClientRetry` stay re-exported, because `rpc`'s options name them.
|
|
161
164
|
|
|
162
165
|
Importing `rpc` alone from this package is **19,671 B** minified for the browser; adding
|
|
163
|
-
`
|
|
166
|
+
`clientFlight` (measured through this barrel, before 25.0.0 moved the import to core) is **25,954 B** (`As of 2026-10-01`; `CLAUDE.md` carries the before/after and
|
|
164
167
|
what the delta is). `ClientFlight` is a TYPE inside `client.ts` and never a value, which is what
|
|
165
168
|
keeps the second number off the first caller's bill.
|
|
166
169
|
|
|
@@ -252,14 +255,14 @@ Under the default style:
|
|
|
252
255
|
| `likePost` | `POST /api/posts/like` | `likePost` |
|
|
253
256
|
| `checkout` (single word) | `POST /api/checkouts/invoke` | `checkout` |
|
|
254
257
|
|
|
255
|
-
**One name,
|
|
258
|
+
**One name, three surfaces** — `openapi.json`'s `x-ultimate.mcpTool`,
|
|
256
259
|
`describeAction().mcp.tool` (what `x actions describe --json`, `x actions list --json`, the
|
|
257
260
|
`actions.describe` dev MCP tool and the `/_x` Routes panel show) and the catalog `@ultimat3/mcp`
|
|
258
|
-
serves. It was two until 2026-08: a `toToolName()` here snake_cased the
|
|
261
|
+
serves. It was two until 2026-08: a `toToolName()` here snake_cased the published ones to
|
|
259
262
|
`publish_post` while the server answered only `publishPost`, so an agent that read the published
|
|
260
263
|
contract called a tool that does not exist. `toToolName` is **deleted**, not deprecated — a second
|
|
261
|
-
derivation is a second name. `mcp-
|
|
262
|
-
|
|
264
|
+
derivation is a second name. `mcp-surface.test.ts` here and `@ultimat3/mcp`'s
|
|
265
|
+
`cross-surface.test.ts` are what keep it that way.
|
|
263
266
|
|
|
264
267
|
`x.manifest.json` is **not** one of the four: `ActionFact.mcp` is `{ expose, description? }`, so
|
|
265
268
|
the manifest never carried a tool name and was never wrong about one.
|
|
@@ -277,7 +280,7 @@ no `.def`. A second authz path cannot be written without deleting that store.
|
|
|
277
280
|
|
|
278
281
|
| Stage | Failure |
|
|
279
282
|
|---|---|
|
|
280
|
-
| policy, actor half (`
|
|
283
|
+
| policy, actor half (`guardActionBeforeInput`) | `X_UNAUTHENTICATED` / `X_FORBIDDEN` for a caller the policy refuses whatever the input — never a 400 describing the schema of an operation they may not call |
|
|
281
284
|
| parse input | `X_INPUT_INVALID` |
|
|
282
285
|
| evaluate policy | the policy's own code — `X_UNAUTHENTICATED` (401), `X_FORBIDDEN` (403) |
|
|
283
286
|
| handle | whatever the handler throws |
|
|
@@ -296,13 +299,20 @@ fan-out that refuses — an undeclared tag, `X_CACHE_TAG_UNKNOWN` — is one
|
|
|
296
299
|
`action.invalidate.failed` log line and the entries expire by TTL. A replayed idempotent
|
|
297
300
|
call busts nothing; the first call already did.
|
|
298
301
|
|
|
302
|
+
**After the COMMIT, not after the handler** (`As of 2026-10-02`). An action invoked inside a
|
|
303
|
+
`withTransaction` — an app's own unit of work, or another action's — hands its bust to the ROOT
|
|
304
|
+
transaction's `onCommit`: it fires once the commit is answered and never for a rollback or an
|
|
305
|
+
aborted transaction (`X_DB_TRANSACTION_ABORTED`). Busting at the handler's return let a
|
|
306
|
+
concurrent read refill the cache from the pre-commit rows, which then stayed for the TTL. With no
|
|
307
|
+
transaction open the bust runs when the handler returns, as it always did.
|
|
308
|
+
|
|
299
309
|
Registering an action without `policy:` throws `X_ACTION_POLICY_MISSING`; there is
|
|
300
310
|
no bypass flag. A look-alike that never came out of `action()` is `X_ACTION_FOREIGN`.
|
|
301
311
|
|
|
302
312
|
## mutator = action + local twin
|
|
303
313
|
|
|
304
|
-
`mutator()` is built **on top of** `action()`. A mutator IS an action, so it gets
|
|
305
|
-
|
|
314
|
+
`mutator()` is built **on top of** `action()`. A mutator IS an action, so it gets every
|
|
315
|
+
projection an action has; it adds `local(tx, input)` for the optimistic write and a `conflict`
|
|
306
316
|
strategy for the rebase.
|
|
307
317
|
|
|
308
318
|
```ts
|
|
@@ -310,6 +320,7 @@ export const likePost = mutator({
|
|
|
310
320
|
input: t.object({ postId: t.uuid }),
|
|
311
321
|
output: PostLikes,
|
|
312
322
|
policy: can('post:like'),
|
|
323
|
+
idempotent: true, // REQUIRED — a replayed write answers the first result (below)
|
|
313
324
|
// Convergent, not incremental: `local` replays on every rebase, so applying it N times has to
|
|
314
325
|
// equal applying it once — `likedByMe` is what makes the second application a no-op.
|
|
315
326
|
local(tx, { postId }) {
|
|
@@ -341,6 +352,14 @@ denies is denied there exactly as over HTTP. `.local()` is the only half that sk
|
|
|
341
352
|
the core, because it never leaves the client; keep it a pure function of `(tx, input)`
|
|
342
353
|
— no I/O, no clock, no randomness — since every rebase replays it.
|
|
343
354
|
|
|
355
|
+
**`idempotent: true` is required on every mutator** — a compile error without it, and
|
|
356
|
+
`X_MUTATOR_NOT_IDEMPOTENT` at declaration for a caller the compiler never saw. A mutator's write
|
|
357
|
+
is replayed by construction: `useMutation` retries, and the offline queue drains after a dropped
|
|
358
|
+
response, under ONE `Idempotency-Key`. The server reads that key only for an idempotent action, so
|
|
359
|
+
without the declaration the replay ran `server` a second time — a toggle flipped back. It is
|
|
360
|
+
declared, never defaulted, because the replay is answered from the idempotency store and which
|
|
361
|
+
store that is (below) is the app's to decide. `transition()` declares it for you.
|
|
362
|
+
|
|
344
363
|
`LocalTx` is the client write surface, implemented by `@ultimat3/realtime` over the page's
|
|
345
364
|
record store — the SAME shape as that store's tx. Every table is addressed by **key**:
|
|
346
365
|
`get(key)`, `all()`, `insert(key, row)`, `upsert(key, row)`, `update(key, patch | fn)`,
|
|
@@ -388,12 +407,13 @@ await moveOrder({ id, from: 'pending', to: 'paid' }, { ctx });
|
|
|
388
407
|
| **`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
408
|
| 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
409
|
| `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 |
|
|
410
|
+
| `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 |
|
|
411
|
+
| `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
412
|
| `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
413
|
| `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
414
|
|
|
394
|
-
`table` is typed structurally (`TransitionTarget`), not imported
|
|
395
|
-
|
|
396
|
-
not — and a real `Table` satisfies the seam as written.
|
|
415
|
+
`table` is typed structurally (`TransitionTarget`), not imported — a real `Table` satisfies the
|
|
416
|
+
seam as written.
|
|
397
417
|
|
|
398
418
|
## Determinism + idempotency
|
|
399
419
|
|
|
@@ -410,17 +430,43 @@ import type { ActionRateLimit } from '@ultimat3/action';
|
|
|
410
430
|
const rateLimit: ActionRateLimit = { limit: 5, windowMs: 600_000 };
|
|
411
431
|
```
|
|
412
432
|
|
|
413
|
-
One declaration,
|
|
414
|
-
|
|
415
|
-
`
|
|
433
|
+
One declaration, one bucket, every surface. `invoke` spends it — so the HTTP route, the MCP tool,
|
|
434
|
+
the in-app agent's tool call and `.job()` draw on the **same** bucket, and the third call to a
|
|
435
|
+
`limit: 2` action is refused `X_RATE_LIMITED` whichever door it came through. A direct in-process
|
|
436
|
+
call (`surface: 'server'` — a service, a seed, a test) is app code, not a caller, and spends nothing.
|
|
437
|
+
|
|
438
|
+
| Fact | Answer |
|
|
439
|
+
|---|---|
|
|
440
|
+
| whose bucket | the caller's: actor → org → connection address (`@ultimat3/http`'s `rateLimitSpends`). An anonymous caller with no address (MCP, a job) shares one bucket |
|
|
441
|
+
| where it is counted | `@ultimat3/http`'s installed store (`installRateLimitStore()`) — the slot `@ultimat3/query` spends from too; the boot fills it with the SAME instance it gives `httpServer({ rateLimitStore })`, on every role. Default: one process' memory |
|
|
442
|
+
| what HTTP answers | `RateLimit-*` from the bucket closest to refusing, and on a refusal `429` + `Retry-After` — the pipeline's own shape |
|
|
443
|
+
| a retry | an admitted attempt spends one token; a refused one spends nothing. A job refused `X_RATE_LIMITED` is rescheduled for its `Retry-After` WITHOUT counting an attempt, so a backlog is delayed, never dead-lettered |
|
|
444
|
+
| an idempotent replay | spends nothing: the stored answer is served, never a 429 |
|
|
445
|
+
| who a job run is | its actor, else its tenant (`org:<orgId>`); a run with neither shares one `job:unattributed` bucket, never the anonymous callers' |
|
|
446
|
+
| OpenAPI | `x-ultimate.rateLimit`, and with `defineApi({ openapi })` the `RateLimit-*` headers and `429` |
|
|
447
|
+
|
|
448
|
+
```ts
|
|
449
|
+
import type { PgExecutor } from '@ultimat3/core';
|
|
450
|
+
import { installRateLimitStore, postgresRateLimitStore } from '@ultimat3/http';
|
|
451
|
+
|
|
452
|
+
declare const executor: PgExecutor; // the pool this boot already opened
|
|
453
|
+
|
|
454
|
+
// Boot, before the first call — the one store every surface and every replica counts in.
|
|
455
|
+
// `x dev` and a container boot do this themselves, and `httpServer({ rateLimitStore })` adopts
|
|
456
|
+
// the store it is handed; a process with no server (a worker of your own) installs it here.
|
|
457
|
+
const rateLimitStore = postgresRateLimitStore({ executor });
|
|
458
|
+
installRateLimitStore(rateLimitStore);
|
|
459
|
+
```
|
|
460
|
+
|
|
416
461
|
`toBucket` is the only conversion — `capacity: limit`, `refillPerSecond: limit / (windowMs / 1000)`
|
|
417
462
|
— so the published numbers and the enforced ones cannot differ. It lives in `@ultimat3/http`,
|
|
418
|
-
beside `Bucket` and the limiter maths, and is re-exported here
|
|
419
|
-
conversion and is the same tier, so a
|
|
420
|
-
other. A pair the limiter cannot run on is `X_RATE_LIMIT_INVALID`, at projection.
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
463
|
+
beside `Bucket` and the limiter maths, and is imported from there — never re-exported here (25.0.0
|
|
464
|
+
dropped that second path): `@ultimat3/query` needs the same conversion and is the same tier, so a
|
|
465
|
+
copy in either package would be a second answer for the other. A pair the limiter cannot run on is `X_RATE_LIMIT_INVALID`, at projection. The route says
|
|
466
|
+
`rateLimitedBy: 'handler'`, so the pipeline's stage spends no caller bucket for it — not `default`,
|
|
467
|
+
which would cap an action declaring more than it — and only the tenant allowance. An
|
|
468
|
+
`http.rateLimit.buckets.<actionName>` entry is a different table: it neither conflicts with nor
|
|
469
|
+
loosens what the action declared.
|
|
424
470
|
|
|
425
471
|
## `idempotent:` — and where its records live
|
|
426
472
|
|
|
@@ -438,12 +484,47 @@ interpolation broke. Omit the header to run un-keyed; the published `maxLength:
|
|
|
438
484
|
by the same refusal. An anonymous caller has no identity to narrow to, so anonymous callers of a
|
|
439
485
|
public idempotent action still share a key space: a UUID key is what keeps them apart.
|
|
440
486
|
|
|
441
|
-
**A failed first attempt is replayed too, not re-run.** `
|
|
487
|
+
**A failed first attempt is replayed too, not re-run.** `guardAction()` and the input parse both happen
|
|
442
488
|
*before* the idempotency gate, so everything it can see throw is post-authorization and possibly
|
|
443
489
|
post-commit: a handler that took the money and then failed its own `output:` schema is the case.
|
|
444
490
|
The reservation is settled as a FAILURE and the retry re-throws it under the first attempt's own
|
|
445
491
|
code. Releasing it there is what made idempotency the cause of a double charge.
|
|
446
492
|
|
|
493
|
+
**A credential in the answer is never kept at rest.** The record lives a day, and a mutator is
|
|
494
|
+
always idempotent — so a "create an API key" mutator would otherwise leave the plaintext key in
|
|
495
|
+
`x_idempotency.value`. On settle, every value under a key `@ultimat3/core`'s `isRedactedKey` names
|
|
496
|
+
(the table the logger and the audit row use, extended by `defineEnv({ secret: true })`) and every
|
|
497
|
+
`Secret` box is stored as `[redacted]`, at any depth, and the record is flagged `redacted`. The
|
|
498
|
+
first caller still gets the whole answer. A replay of a flagged record is
|
|
499
|
+
`X_IDEMPOTENT_REPLAY_REDACTED` (409) rather than `[redacted]` served as the answer: the first call
|
|
500
|
+
RAN, so read its effect with a query — a fresh key runs the mutation again. An answer with no such
|
|
501
|
+
key is stored and replayed exactly as before — plain data (plain objects, arrays, `Date`,
|
|
502
|
+
primitives) comes back as the same reference. **What the walk cannot vouch for is redacted too**:
|
|
503
|
+
a value with a `toJSON` method, a `Map`, a `Set`, a class instance or a `Date` subclass, because
|
|
504
|
+
`JSON.stringify` would run app code the walk never saw; an own getter is read once and the stored
|
|
505
|
+
copy holds that read. A custom `IdempotencyStore` receives the redacted copy, must declare
|
|
506
|
+
`keepsRedaction: true` (a store without it is a compile error), and must keep `settle`'s required
|
|
507
|
+
fourth argument and hand it back as `IdempotencyRecord.redacted`:
|
|
508
|
+
|
|
509
|
+
```ts
|
|
510
|
+
import type { IdempotencyStore } from '@ultimat3/action';
|
|
511
|
+
|
|
512
|
+
// A store that wraps another — a metrics or tracing decorator — passes the flag through.
|
|
513
|
+
export function counted(inner: IdempotencyStore, onSettle: () => void): IdempotencyStore {
|
|
514
|
+
return {
|
|
515
|
+
scope: inner.scope,
|
|
516
|
+
keepsRedaction: true,
|
|
517
|
+
reserve: (key, requestHash) => inner.reserve(key, requestHash),
|
|
518
|
+
settle: (key, value, reservationId, redacted) => {
|
|
519
|
+
onSettle();
|
|
520
|
+
return inner.settle(key, value, reservationId, redacted);
|
|
521
|
+
},
|
|
522
|
+
release: (key) => inner.release(key),
|
|
523
|
+
get: (key) => inner.get(key),
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
```
|
|
527
|
+
|
|
447
528
|
**The stored payload fingerprint is keyed.** `requestHash` is `keyedFingerprint(input, …)` from
|
|
448
529
|
`@ultimat3/core`: `h1:<key id>:<HMAC-SHA-256/128>` under a key derived from the app's signing
|
|
449
530
|
secret (`ULTIMATE_CURSOR_SECRET` / `configureCursorSigning`), never a bare hash — the row lives a
|
|
@@ -464,6 +545,7 @@ no record and runs the handler again:
|
|
|
464
545
|
import {
|
|
465
546
|
configureIdempotency,
|
|
466
547
|
postgresIdempotencyStore,
|
|
548
|
+
requestDeadlineMs,
|
|
467
549
|
setIdempotencyStore,
|
|
468
550
|
} from '@ultimat3/action';
|
|
469
551
|
import { db } from '@ultimat3/db';
|
|
@@ -478,6 +560,10 @@ const client = db();
|
|
|
478
560
|
setIdempotencyStore(
|
|
479
561
|
postgresIdempotencyStore({
|
|
480
562
|
executor: { query: (text, values) => client.query({ text, values }) },
|
|
563
|
+
// The client transactions on THIS database open on: a settle rides one of those and no other.
|
|
564
|
+
origin: () => client,
|
|
565
|
+
// The app's `requestTimeoutMs` (`configureHttp`), read per reservation — never a number here.
|
|
566
|
+
reclaimAfterMs: requestDeadlineMs,
|
|
481
567
|
}),
|
|
482
568
|
);
|
|
483
569
|
configureIdempotency({ scope: 'shared' });
|
|
@@ -489,15 +575,16 @@ opens. The table is `SQL_IDEMPOTENCY_TABLE`, applied the way `SQL_JOBS_TABLE` is
|
|
|
489
575
|
development, the release-phase `ROLE=migrate` in production. `postgresIdempotencyStore(...)
|
|
490
576
|
.purgeExpired()` is the sweep — Postgres forgets nothing on its own, so run it from a `task`.
|
|
491
577
|
|
|
492
|
-
**A plain mutating `route` can use the same gate.** `withIdempotency`, `
|
|
493
|
-
`
|
|
494
|
-
|
|
578
|
+
**A plain mutating `route` can use the same gate.** `withIdempotency`, `idempotencyKeyFor` and
|
|
579
|
+
`getIdempotencyStore` are public here and `IDEMPOTENCY_HEADER` is `@ultimat3/core`'s (imported from
|
|
580
|
+
core — 25.0.0 dropped this package's re-export, and `BUILD_ID_HEADER`'s with it), so a route that is
|
|
581
|
+
not an action reserves and replays through the one implementation rather than growing a second:
|
|
495
582
|
|
|
496
583
|
```ts
|
|
497
584
|
const key = req.header(IDEMPOTENCY_HEADER);
|
|
498
585
|
// No header is the caller declining idempotency; a BLANK one is not, and
|
|
499
586
|
// `idempotencyKeyFor` refuses it below rather than filing a record everyone shares.
|
|
500
|
-
if (key === null) return
|
|
587
|
+
if (key === null) return jsonResponse(await refund(input));
|
|
501
588
|
const outcome = await withIdempotency(
|
|
502
589
|
getIdempotencyStore(),
|
|
503
590
|
// Namespaced by action AND actor: otherwise two routes share one caller's key, and two
|
|
@@ -514,12 +601,32 @@ Both stores fence on it AND on `in-flight` `As of 2026-08`, the way `@ultimat3/j
|
|
|
514
601
|
so a straggler from the first attempt satisfied a status-only fence exactly and overwrote a live
|
|
515
602
|
reservation.
|
|
516
603
|
|
|
604
|
+
**Inside a transaction the settlement commits with the write** (`As of 2026-10-02`, BOTH stores —
|
|
605
|
+
`idempotency-parity.test.ts` runs one set of cases over memory and Postgres). An action invoked
|
|
606
|
+
inside `withTransaction` settles with that transaction: on its own connection (Postgres), or at
|
|
607
|
+
its `onCommit` (memory).
|
|
608
|
+
|
|
609
|
+
| The unit of work | The record | A retry |
|
|
610
|
+
|---|---|---|
|
|
611
|
+
| commits | `settled`, by the same `COMMIT` | replays the first result |
|
|
612
|
+
| 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 |
|
|
613
|
+
| dies mid-flight | still `in-flight` | the same |
|
|
614
|
+
| 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 |
|
|
615
|
+
|
|
616
|
+
| Rule | Why |
|
|
617
|
+
|---|---|
|
|
618
|
+
| 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 |
|
|
619
|
+
| 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 |
|
|
620
|
+
| the reservation and a recorded failure always go through the pool | a duplicate sees the first at once, and a failure outlives its rollback |
|
|
621
|
+
| `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 |
|
|
622
|
+
| 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 |
|
|
623
|
+
|
|
517
624
|
A `query` has none and never will: a read has nothing to be idempotent about.
|
|
518
625
|
|
|
519
626
|
## `deprecated:` — a compat window, not a version
|
|
520
627
|
|
|
521
628
|
```ts
|
|
522
|
-
import type { Deprecation } from '@ultimat3/
|
|
629
|
+
import type { Deprecation } from '@ultimat3/core';
|
|
523
630
|
|
|
524
631
|
// The `deprecated:` key of an `action()`.
|
|
525
632
|
const deprecated: Deprecation = {
|
|
@@ -536,6 +643,9 @@ operation, and a `deprecated_calls_total{primitive,name}` counter — which is t
|
|
|
536
643
|
answer "is anyone still calling it?" before deleting it. A date that cannot be rendered is
|
|
537
644
|
`X_ACTION_DEPRECATION_INVALID` at projection, not on the first request.
|
|
538
645
|
|
|
646
|
+
`Deprecation`, `renderDeprecation` and `recordDeprecatedCall` are `@ultimat3/core`'s and are
|
|
647
|
+
imported from there — this package does not re-export them (`As of 25.0.0`).
|
|
648
|
+
|
|
539
649
|
**Versioning itself is deliberately absent, and will stay absent.** Running `v1` and `v2` of one
|
|
540
650
|
action side by side is two deployments behind one ingress — axiom 7's answer, costing this package
|
|
541
651
|
no router feature, no path prefix and no second registry. What ships is the window: a date, a
|
|
@@ -549,7 +659,7 @@ price change are not the same event, and the framework is not the thing that kno
|
|
|
549
659
|
them your business has to keep.
|
|
550
660
|
|
|
551
661
|
```ts
|
|
552
|
-
import { setAuditSink } from '@ultimat3/
|
|
662
|
+
import { setAuditSink } from '@ultimat3/core';
|
|
553
663
|
|
|
554
664
|
setAuditSink({
|
|
555
665
|
async write(record) { await record.ctx.db.auditRows.insert(myRow(record)); },
|
|
@@ -561,14 +671,19 @@ What the framework supplies is what it genuinely knows:
|
|
|
561
671
|
| Field | |
|
|
562
672
|
|---|---|
|
|
563
673
|
| `at` | when the attempt began, from `ctx.now()` — an instant, never a rendering |
|
|
564
|
-
| `
|
|
565
|
-
| `surface` | `server` \| `http` \| `mcp` \| `job` — the same price change over MCP is not the same event |
|
|
674
|
+
| `name` / `primitive` / `mutator` | the registered name; `'action'` (or `'query'` for an audited read); and whether `mutator()` built it. `name` and `primitive` are required, and the `action` alias of `name` is gone (`As of 25.0.0`) — `postgresAuditSink` still files `name` in the `x_audit.action` column |
|
|
675
|
+
| `surface` | `server` \| `http` \| `mcp` \| `job` (and `live` on a read) — the same price change over MCP is not the same event |
|
|
566
676
|
| `ctx` | the whole context: actor, `requestId`, `traceId`, locale, and the services a sink needs to write a row |
|
|
567
677
|
| `input` | the **parsed** input, or `undefined` when the parse is what failed — never the raw payload |
|
|
568
678
|
| `idempotencyKey` / `replayed` | the namespaced key, and whether this was a call rather than a write |
|
|
569
679
|
| `outcome` | `allowed` \| `denied` \| `failed` |
|
|
570
680
|
| `failure` | the `X_*` code and the thrown value, on every outcome but `allowed` |
|
|
571
681
|
|
|
682
|
+
**One contract, one sink, shared with reads.** `AuditRecord`, `AuditSink` and the installed slot
|
|
683
|
+
are `@ultimat3/core`'s, and so is the one way to install it: `setAuditSink` from `@ultimat3/core`
|
|
684
|
+
(this package re-exports the two types, never the slot — `As of 25.0.0`) installs the sink `query({ audit: true })` writes to as well (`@ultimat3/query`'s README). A sink
|
|
685
|
+
tells the two apart by `record.primitive`.
|
|
686
|
+
|
|
572
687
|
What it does **not** supply: an audit entity, a retention policy, a hash chain, a subject index,
|
|
573
688
|
or an opinion on what "who" means under impersonation. Four apps model those four ways; shipping
|
|
574
689
|
one would make three of them wrong.
|
|
@@ -587,7 +702,8 @@ is `setAuditSink(memoryAuditSink())`, and nothing at that call site says the res
|
|
|
587
702
|
|
|
588
703
|
```ts
|
|
589
704
|
// apps/web/server.ts — the app owns the connection, so the app installs the sink
|
|
590
|
-
import { postgresAuditSink
|
|
705
|
+
import { postgresAuditSink } from '@ultimat3/action';
|
|
706
|
+
import { setAuditSink } from '@ultimat3/core';
|
|
591
707
|
import { db } from '@ultimat3/db';
|
|
592
708
|
|
|
593
709
|
const client = db();
|
|
@@ -609,7 +725,7 @@ the actor, the correlation ids, the idempotency key — and the parsed `input` a
|
|
|
609
725
|
through **core's own** `isRedactedKey` table, the one `defineEnv({ secret: true })` extends. So a
|
|
610
726
|
value that renders `[redacted]` in a log line cannot be plaintext in the audit table, and a
|
|
611
727
|
boxed `Secret` is redacted by value wherever its key sits. What never reaches a column: the `Ctx`
|
|
612
|
-
itself (`
|
|
728
|
+
itself (`ctxOf` spreads every installed service onto it, and an HTTP surface's is a
|
|
613
729
|
`RequestContext` carrying the caller's `Authorization` and `Cookie`), and the thrown value behind
|
|
614
730
|
a failure — the row keeps `failure.code`, never the throwable.
|
|
615
731
|
|
|
@@ -617,7 +733,7 @@ The table has **no purge**, deliberately, and it is the one framework table that
|
|
|
617
733
|
stale idempotency row is meaningless while a stale audit row *is* the record, and "how long" is a
|
|
618
734
|
legal answer that differs per app. Pruning or partitioning `x_audit` is yours.
|
|
619
735
|
|
|
620
|
-
A denial is recorded because `invoke` wraps the whole path — `
|
|
736
|
+
A denial is recorded because `invoke` wraps the whole path — `guardAction` throws **before** `handle`,
|
|
621
737
|
so nothing you could write around your own handler would ever see one. That is the reason this
|
|
622
738
|
lives in the framework and the row does not.
|
|
623
739
|
|
|
@@ -668,7 +784,7 @@ setAuditSink({
|
|
|
668
784
|
const prev = await chainHead(ctx); // hash-chained: the app's choice
|
|
669
785
|
await ctx.db.auditRows.insert({
|
|
670
786
|
orgId: orgOf(ctx.actor), // tenancy: derived from the actor
|
|
671
|
-
subjectId: subjectOf(record.
|
|
787
|
+
subjectId: subjectOf(record.name, record.input), // queryable by subject: the app's index
|
|
672
788
|
actorId: impersonatorOf(ctx.actor) ?? ctx.actor.id,
|
|
673
789
|
at: record.at, outcome: record.outcome, code: record.failure?.code ?? null,
|
|
674
790
|
prevHash: prev, hash: await sha256(prev, record),
|
|
@@ -685,7 +801,7 @@ setAuditSink({
|
|
|
685
801
|
|---|---|
|
|
686
802
|
| input schema rejects garbage | the invocation fails `X_INPUT_INVALID` — that code, not any failure |
|
|
687
803
|
| policy denies an anonymous actor | the invocation fails with an `ActionDeniedError` |
|
|
688
|
-
| OpenAPI document contains its operation | the
|
|
804
|
+
| 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
805
|
|
|
690
806
|
The denial assertion sends an input synthesized from `input:`'s own schema — required keys
|
|
691
807
|
only, formats included — because a payload the schema rejects never reaches a policy. It
|
|
@@ -716,8 +832,10 @@ never a pass — the assertion says which code got in the way and names `input:`
|
|
|
716
832
|
| `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
833
|
| `X_IDEMPOTENCY_CONFLICT` | key reused with a new payload / still in flight | new key, or retry later |
|
|
718
834
|
| `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 |
|
|
835
|
+
| `X_IDEMPOTENCY_NOT_SHARED` | `configureIdempotency({ scope: 'shared' })` over a per-process (or scope-less) store | install `postgresIdempotencyStore({ executor, origin, reclaimAfterMs })` at boot |
|
|
720
836
|
| `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 |
|
|
837
|
+
| `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` |
|
|
838
|
+
| `X_MUTATOR_NOT_IDEMPOTENT` | `mutator()` declared without `idempotent: true` | add `idempotent: true` to the definition |
|
|
721
839
|
| `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
840
|
| `X_CONTRACT_DRIFT` | client/server build skew, missing spec entry | reload / `x verify --only contract` |
|
|
723
841
|
| `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 +880,15 @@ a job boundary the class is gone and the `code` is what survives — match on th
|
|
|
762
880
|
| `IdempotencyKeyInvalidError` | `X_IDEMPOTENCY_KEY_INVALID` | `src/errors-idempotency.ts` |
|
|
763
881
|
| `IdempotencyNotSharedError` | `X_IDEMPOTENCY_NOT_SHARED` | `src/errors-idempotency.ts` |
|
|
764
882
|
| `IdempotencyReplayedFailureError` | `X_IDEMPOTENCY_REPLAYED_FAILURE` | `src/errors-idempotency.ts` |
|
|
883
|
+
| `IdempotencyReservationLostError` | `X_IDEMPOTENCY_RESERVATION_LOST` | `src/errors-idempotency.ts` |
|
|
765
884
|
| `IdempotencyStatusUnknownError` | `X_IDEMPOTENCY_STATUS_UNKNOWN` | `src/errors-idempotency.ts` |
|
|
766
885
|
| `InputInvalidError` | `X_INPUT_INVALID` | `src/errors.ts` |
|
|
886
|
+
| `MutatorNotIdempotentError` | `X_MUTATOR_NOT_IDEMPOTENT` | `src/errors-idempotency.ts` |
|
|
767
887
|
| `OutputInvalidError` | `X_OUTPUT_INVALID` | `src/errors.ts` |
|
|
768
888
|
| `RemoteActionError` | the code the server sent, verbatim — `meta.origin: 'remote'` | `src/errors.ts` |
|
|
769
889
|
| `RpcFailedError` | `X_RPC_FAILED` | `src/errors.ts` |
|
|
770
890
|
|
|
771
891
|
## Boundaries
|
|
772
892
|
|
|
773
|
-
Tier 3. Imports `@ultimat3/core`, `schema`, `cache`, `entity`, `policy`, `http`. Never imports
|
|
893
|
+
Tier 3. Imports `@ultimat3/core`, `schema`, `cache`, `db`, `entity`, `policy`, `http`. Never imports
|
|
774
894
|
`query`, `jobs`, `realtime` (same tier) or anything above it — those import *this*.
|
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/action",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "25.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",
|
|
7
7
|
"sideEffects": [
|
|
8
|
-
"./src/
|
|
8
|
+
"./src/error-titles.ts"
|
|
9
9
|
],
|
|
10
10
|
"repository": {
|
|
11
11
|
"type": "git",
|
|
@@ -28,18 +28,19 @@
|
|
|
28
28
|
"LICENSE"
|
|
29
29
|
],
|
|
30
30
|
"engines": {
|
|
31
|
-
"bun": ">=1.4.
|
|
31
|
+
"bun": ">=1.4.2"
|
|
32
32
|
},
|
|
33
33
|
"scripts": {
|
|
34
34
|
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
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": "25.0.0",
|
|
39
|
+
"@ultimat3/core": "25.0.0",
|
|
40
|
+
"@ultimat3/db": "25.0.0",
|
|
41
|
+
"@ultimat3/entity": "25.0.0",
|
|
42
|
+
"@ultimat3/http": "25.0.0",
|
|
43
|
+
"@ultimat3/policy": "25.0.0",
|
|
44
|
+
"@ultimat3/schema": "25.0.0"
|
|
44
45
|
}
|
|
45
46
|
}
|