@ultimat3/action 20.2.0 → 21.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 +61 -10
- package/README.md +33 -11
- package/package.json +7 -6
- package/src/client.ts +59 -85
- package/src/errors.ts +34 -2
- package/src/http.ts +45 -13
- package/src/index.ts +1 -4
- package/src/mutator-clock.ts +22 -0
- package/src/mutator.ts +41 -52
- package/src/naming.ts +9 -54
- package/src/record-wire.ts +32 -0
- package/src/transition.ts +3 -3
- package/src/type-pins.ts +16 -0
- package/src/wire-headers.ts +5 -2
package/CLAUDE.md
CHANGED
|
@@ -5,6 +5,7 @@ Owns the `action` + `mutator` primitives and their six projections. Tier 3.
|
|
|
5
5
|
## Boundary
|
|
6
6
|
|
|
7
7
|
- May import: `core`, `schema` (t0), `cache`, `i18n`, `time` (t1), `entity`, `policy`, `http` (t2).
|
|
8
|
+
`entity` is a real edge since 21.0.0 (`record-wire.ts` → `hasEntityRows`/`rowsOf`), downward 3→2.
|
|
8
9
|
- Never import: `query`, `jobs`, `realtime` (sideways), or any tier 4-5 package.
|
|
9
10
|
- Never re-implement authz, validation or caching — call `policy`, `schema`, `cache`.
|
|
10
11
|
|
|
@@ -20,7 +21,8 @@ Owns the `action` + `mutator` primitives and their six projections. Tier 3.
|
|
|
20
21
|
| `define-api.ts` | `defineApi({ actions, mutators, queries, llm, jobs, tasks })` — the app's one boot call |
|
|
21
22
|
| `http.ts` | route projection (`enforcedBy: 'handler'`) + OpenAPI operation |
|
|
22
23
|
| `openapi.ts` | deterministic OpenAPI 3.1 document |
|
|
23
|
-
| `client.ts` | typed RPC client (browser-safe: no server imports) |
|
|
24
|
+
| `client.ts` | typed RPC client (browser-safe: no server imports) — dispatches through core's `clientTransport` |
|
|
25
|
+
| `record-wire.ts` | the record envelope on the HTTP projection: `carriesRecords` (from the output schema), the enveloped 200, and its OpenAPI shape. Server-only — `client.ts` never imports it |
|
|
24
26
|
| `wire-issues.ts` | the ONE reader of a problem document's `issues` member — an untrusted array back into `@ultimat3/schema`'s `ValidationIssue` shape |
|
|
25
27
|
| `transition.ts` | `transition()`: a MUTATOR factory over one entity column's state machine. Declares no error code — entity's three propagate |
|
|
26
28
|
| — | opt-in flight control is **`@ultimat3/core`**'s `client-flight.ts` + `client-wire.ts`, re-exported from `src/index.ts`. There is no local copy and must not be one |
|
|
@@ -46,6 +48,32 @@ Owns the `action` + `mutator` primitives and their six projections. Tier 3.
|
|
|
46
48
|
|
|
47
49
|
## Invariants
|
|
48
50
|
|
|
51
|
+
- **Every browser call goes through `@ultimat3/core`'s `clientTransport`, `As of 2026-09-22`.**
|
|
52
|
+
`client.ts` hands it the method, the URL (core's `actionPath`, the one path rule — `naming.ts`'s
|
|
53
|
+
`derivePath`/`pluralize`/`splitWords` are core's too, re-exported), body, headers, signal,
|
|
54
|
+
idempotency key, flight and this call's `retry`; the transport owns credentials, the JSON body,
|
|
55
|
+
the idempotency header, envelope decode, adoption into `pageClient().store`, the principal fence
|
|
56
|
+
and the terminal `fetch`. Only what the action alone knows rides in as hooks: `onResponse` (the
|
|
57
|
+
build-id check, `X_CONTRACT_DRIFT`) and `decodeError` (`RemoteActionError` for a body naming a
|
|
58
|
+
framework code; `undefined` otherwise, so the transport answers `X_CLIENT_TRANSPORT_FAILED` — the
|
|
59
|
+
same code a query gets for the same failure). `X_RPC_FAILED` / `RpcFailedError` stay registered
|
|
60
|
+
and exported — a shipped code never changes — and nothing in the framework throws them since
|
|
61
|
+
21.0.0.
|
|
62
|
+
`retry` is computed here — `{ attempts: 1 }` unless the call carries an idempotency key — so a
|
|
63
|
+
flight-wide retry never re-sends an unkeyed write.
|
|
64
|
+
- **The record envelope is derived from the output schema, decided per ACTION, never per
|
|
65
|
+
response.** `carriesRecords(output)` is `@ultimat3/entity`'s `hasEntityRows`, evaluated once in
|
|
66
|
+
`toRoute`; an enveloped action answers `{ data, records }` under `x-ultimate-records: 1` even
|
|
67
|
+
when this call returned no row, so one operation has one wire shape and one OpenAPI schema. An
|
|
68
|
+
output with no entity row is byte-identical on the wire and in the spec — `record-wire.test.ts`
|
|
69
|
+
pins both, the bytes as `JSON.stringify(output)`. The envelope is HTTP-only: MCP, `.job()` and a
|
|
70
|
+
direct call still answer the bare output.
|
|
71
|
+
- **`conflict` is `@ultimat3/core`'s `ConflictPolicy`, over ROWS.** `custom(merge)` builds
|
|
72
|
+
`{ kind: 'custom', merge(localRow, serverRow) }`; the output-shaped `CustomConflict`,
|
|
73
|
+
`Conflict<T>`, `strategyOf` and this package's `resolveConflict` are deleted (21.0.0) — core's
|
|
74
|
+
`resolveConflict` is the one resolver, and its `last-write-wins` compares the server's
|
|
75
|
+
`updatedAt` rather than preferring the local side. `strategyOf` survives as a private helper for
|
|
76
|
+
`describeMutator()`.
|
|
49
77
|
- **`X_INPUT_INVALID` carries the rejections TWICE, and they are one value.** The flattened line
|
|
50
78
|
stays in `cause` — it is what an operator reads in a log and what a non-form caller sees — and
|
|
51
79
|
`meta.issues` carries the same list structured, so a client rebuilding a form knows WHICH field
|
|
@@ -190,6 +218,12 @@ Owns the `action` + `mutator` primitives and their six projections. Tier 3.
|
|
|
190
218
|
What this does NOT close: an anonymous actor is one identity, so anonymous callers of a public
|
|
191
219
|
idempotent action still share a key space — nothing at this tier can tell two apart, and keying
|
|
192
220
|
on an IP or a cookie would break the retry the header exists to serve.
|
|
221
|
+
- **The `idempotency-key` header also NAMES the write, on every action, idempotent or not.**
|
|
222
|
+
`http.ts` runs `invoke` inside `withWriteOrigin(writeDigest(key))` (`@ultimat3/core`), so the
|
|
223
|
+
rows the handler writes reach a channel's `records` frame carrying that digest, and the page
|
|
224
|
+
that sent the key recognises its own echo (see `packages/realtime/CLAUDE.md`). It is a label and
|
|
225
|
+
never a gate: a missing or blank header names nothing, and the blank-header refusal above still
|
|
226
|
+
belongs to `def.idempotent` alone. `http.test.ts`, "the write a request names".
|
|
193
227
|
- **Both stores FENCE a settlement on the reservation `id` AND on `in-flight`**, as
|
|
194
228
|
`@ultimat3/jobs`' `SQL_ACK` fences on `id = $1 and state = 'running'`. A reservation whose window
|
|
195
229
|
lapsed is reclaimed by the next caller (`on conflict … do update`), so a straggler from the first
|
|
@@ -304,7 +338,8 @@ Owns the `action` + `mutator` primitives and their six projections. Tier 3.
|
|
|
304
338
|
still separates them only for a package that declared its own `docs:`, which is why the branch
|
|
305
339
|
stays. The
|
|
306
340
|
code must be `X_SCREAMING_SNAKE` to be taken at all — `typeof code === 'string'` accepted `""`
|
|
307
|
-
from a gateway — and anything else is `
|
|
341
|
+
from a gateway — and anything else is core's `X_CLIENT_TRANSPORT_FAILED` (it was
|
|
342
|
+
`RpcFailedError` until 21.0.0).
|
|
308
343
|
`docs` and `type` travel to `remoteDocs` as an ordered pair, not `docs ?? type`: preference is
|
|
309
344
|
not selection, and picking the preferred slot on presence alone let one `javascript:` string
|
|
310
345
|
bury a perfectly good `type` the same response had already offered.
|
|
@@ -548,14 +583,30 @@ Owns the `action` + `mutator` primitives and their six projections. Tier 3.
|
|
|
548
583
|
promises. It lives in `packages/core/src/client-flight.ts` now; the tests that pin it from this
|
|
549
584
|
side still drive it through this package's own client.
|
|
550
585
|
- **`ClientFlight` is a TYPE inside `client.ts` and never a value.** That erasure is the entire
|
|
551
|
-
tree-shaking story:
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
`
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
586
|
+
tree-shaking story: a caller who wants a plain typed call must not pay for the fence, the dedup
|
|
587
|
+
map or the retry loop. Never import `createClientFlight` for a VALUE from `client.ts` —
|
|
588
|
+
`ClientFlight` and `ClientRetry` are `import type` from `@ultimat3/core` and must stay that way.
|
|
589
|
+
**Measured, `bun build --target=browser --minify`, one entry importing from `@ultimat3/action`,
|
|
590
|
+
`As of 2026-09-22`:**
|
|
591
|
+
|
|
592
|
+
| Entry | before (HEAD `98d16d84`) | onto `clientTransport` | trace headers moved to core's outbound slot | As of 2026-09-23 |
|
|
593
|
+
|---|---|---|---|---|
|
|
594
|
+
| `rpc` | 18,097 B | 23,007 B | 18,119 B | 19,074 B |
|
|
595
|
+
| `rpc` + `createClientFlight` | 23,903 B | 28,823 B | not measured | 25,197 B |
|
|
596
|
+
|
|
597
|
+
This is the ONE table for these figures; `packages/core/CLAUDE.md` points here. The slot column
|
|
598
|
+
is the `outbound-headers.ts` change (core's `traceHeaders()` left the browser path; its own
|
|
599
|
+
"before" read 23,164 B, the transport column re-measured on a later tree). The last column is
|
|
600
|
+
one entry importing `packages/action/src/index.ts` by path, same flags.
|
|
601
|
+
|
|
602
|
+
The 14,759 B this file used to quote does not reproduce on the tree it was checked against
|
|
603
|
+
(18,097 B); the numbers above are both measured the same way, same day. The growth is +4,910 B,
|
|
604
|
+
of which the envelope decoder is ~1,020 B (`decodeRecordEnvelope` added to a
|
|
605
|
+
`UltimateError`+`problemOf`+`traceHeaders` entry: 13,059 → 14,079 B). The rest is core's
|
|
606
|
+
transport graph — the scope fence, the dispatch module, and core's shared problem decoder, which
|
|
607
|
+
now answers a body with no framework code (`X_CLIENT_TRANSPORT_FAILED`) behind `decodeError`. That is OVER the plan's bound ("must not grow beyond the envelope decoder") and is
|
|
608
|
+
reported, not hidden. After the slot moved telemetry out, the net against the pre-transport figure
|
|
609
|
+
is +977 B (18,097 → 19,074 B, As of 2026-09-23), about the envelope decoder's size.
|
|
559
610
|
- **The `sideEffects` array is what makes the barrel shakable, and it is load-bearing** (`As of
|
|
560
611
|
2026-08-23`). Declaring nothing meant a bundler had to assume every module ran at import, so
|
|
561
612
|
`import { rpc } from '@ultimat3/action'` was 43,104 B and `import { queryClient } from
|
package/README.md
CHANGED
|
@@ -116,6 +116,21 @@ export const client = rpc<Api['actions']>({ baseUrl: '/' });
|
|
|
116
116
|
declaration with no codegen step. `Api` is imported as a **type only**, which is what keeps
|
|
117
117
|
a page's module graph free of any edge to a feature's implementation.
|
|
118
118
|
|
|
119
|
+
Every call dispatches through `@ultimat3/core`'s `clientTransport` — the one browser HTTP function.
|
|
120
|
+
It owns credentials, the JSON body, the `Idempotency-Key` header, the principal fence and the
|
|
121
|
+
record envelope. The method still resolves to the action's output and nothing else.
|
|
122
|
+
|
|
123
|
+
### Records — derived from the output schema
|
|
124
|
+
|
|
125
|
+
An action whose `output:` references an entity row (`posts.$schema`, at any depth — inside an
|
|
126
|
+
object, an array, `.nullable()`) answers `{ data, records }` with `x-ultimate-records: 1`, and the
|
|
127
|
+
transport adopts `records` into the page's one record store on the way past. There is no
|
|
128
|
+
`records:` option: the envelope is derived from the schema, never declared. It is decided **per
|
|
129
|
+
action**, from the schema — an output that only *may* carry a row is enveloped even when this call
|
|
130
|
+
returned none, so one operation has one wire shape. Every other action's body is byte-identical to
|
|
131
|
+
what it was before the envelope existed, and its OpenAPI operation is unchanged; an enveloped one
|
|
132
|
+
documents `data`, `records` and `removed`, and the header, generated from the declaration.
|
|
133
|
+
|
|
119
134
|
### Flight control — `createClientFlight`, opt-in
|
|
120
135
|
|
|
121
136
|
Same object as `@ultimat3/query`'s, installed the same way (`rpc({ baseUrl, flight })`), and the
|
|
@@ -142,11 +157,10 @@ await api.charge({ orderId }, { idempotencyKey: `charge:${orderId}`, retry: { at
|
|
|
142
157
|
It shipped as a byte-identical copy in each; the copies are gone and every name is importable from
|
|
143
158
|
this package exactly as before.
|
|
144
159
|
|
|
145
|
-
Importing `rpc` alone from this package is **
|
|
146
|
-
`createClientFlight` is **
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
though `sideEffects` names it (issue #273), which is the size of the schema error titles.
|
|
160
|
+
Importing `rpc` alone from this package is **23,007 B** minified for the browser; adding
|
|
161
|
+
`createClientFlight` is **28,823 B** (`As of 2026-09-22`; `CLAUDE.md` carries the before/after and
|
|
162
|
+
what the delta is). `ClientFlight` is a TYPE inside `client.ts` and never a value, which is what
|
|
163
|
+
keeps the second number off the first caller's bill.
|
|
150
164
|
|
|
151
165
|
## Path derivation
|
|
152
166
|
|
|
@@ -217,10 +231,15 @@ export const likePost = mutator({
|
|
|
217
231
|
p.likedByMe ? {} : { likedByMe: true, likeCount: p.likeCount + 1 });
|
|
218
232
|
},
|
|
219
233
|
async server(ctx, { postId }) { return ctx.posts.like(postId); },
|
|
220
|
-
conflict: 'server-wins', // | 'last-write-wins' | custom(
|
|
234
|
+
conflict: 'server-wins', // | 'last-write-wins' | custom((localRow, serverRow) => row)
|
|
221
235
|
});
|
|
222
236
|
```
|
|
223
237
|
|
|
238
|
+
`conflict` is `@ultimat3/core`'s `ConflictPolicy` — the one vocabulary realtime's rebase reads.
|
|
239
|
+
`custom(merge)` receives the local **row** and the server **row** (the store is row-shaped) and
|
|
240
|
+
returns the row that survives; `resolveConflict(policy, local, server)` is core's, not this
|
|
241
|
+
package's.
|
|
242
|
+
|
|
224
243
|
The projected surface carries the same three names the declaration used, on top of
|
|
225
244
|
every action member above:
|
|
226
245
|
|
|
@@ -236,9 +255,12 @@ denies is denied there exactly as over HTTP. `.local()` is the only half that sk
|
|
|
236
255
|
the core, because it never leaves the client; keep it a pure function of `(tx, input)`
|
|
237
256
|
— no I/O, no clock, no randomness — since every rebase replays it.
|
|
238
257
|
|
|
239
|
-
`LocalTx` is the client write surface
|
|
240
|
-
|
|
241
|
-
|
|
258
|
+
`LocalTx` is the client write surface, implemented by `@ultimat3/realtime` over the page's
|
|
259
|
+
record store — the SAME shape as that store's tx. Every table is addressed by **key**:
|
|
260
|
+
`get(key)`, `all()`, `insert(key, row)`, `upsert(key, row)`, `update(key, patch | fn)`,
|
|
261
|
+
`delete(key)`. The key is the caller's because the browser holds no entity schema and cannot
|
|
262
|
+
derive a primary key — an optimistic insert names the key its server twin will answer under.
|
|
263
|
+
Type your tables once: `declare module '@ultimat3/action' { interface LocalTables { posts: PostRow } }`.
|
|
242
264
|
|
|
243
265
|
## `transition()` — a mutator factory over a state machine
|
|
244
266
|
|
|
@@ -602,7 +624,7 @@ never a pass — the assertion says which code got in the way and names `input:`
|
|
|
602
624
|
| `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 |
|
|
603
625
|
| `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 |
|
|
604
626
|
| `X_CONTRACT_DRIFT` | client/server build skew, missing spec entry | reload / `x verify --contract` |
|
|
605
|
-
| `X_RPC_FAILED` | non-`problem+json` failure
|
|
627
|
+
| `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 |
|
|
606
628
|
| `X_ACTION_UNREGISTERED` | projected before `registerActions()` ran | register at boot |
|
|
607
629
|
| `X_AUDIT_SINK_MISSING` | `audit: true` and no sink installed — raised before the input parse | `setAuditSink(yourSink)` at boot |
|
|
608
630
|
| `X_AUDIT_SINK_FAILED` | the sink refused the record for an attempt that **succeeded** | fix the sink — then retry the same `Idempotency-Key` if this call carried one, else reconcile by hand |
|
|
@@ -625,5 +647,5 @@ the body itself.
|
|
|
625
647
|
|
|
626
648
|
## Boundaries
|
|
627
649
|
|
|
628
|
-
Tier 3. Imports `@ultimat3/core`, `schema`, `cache`, `policy`, `http`. Never imports
|
|
650
|
+
Tier 3. Imports `@ultimat3/core`, `schema`, `cache`, `entity`, `policy`, `http`. Never imports
|
|
629
651
|
`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": "21.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",
|
|
@@ -34,10 +34,11 @@
|
|
|
34
34
|
"test": "bun test"
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@ultimat3/cache": "
|
|
38
|
-
"@ultimat3/core": "
|
|
39
|
-
"@ultimat3/
|
|
40
|
-
"@ultimat3/
|
|
41
|
-
"@ultimat3/
|
|
37
|
+
"@ultimat3/cache": "21.0.0",
|
|
38
|
+
"@ultimat3/core": "21.0.0",
|
|
39
|
+
"@ultimat3/entity": "21.0.0",
|
|
40
|
+
"@ultimat3/http": "21.0.0",
|
|
41
|
+
"@ultimat3/policy": "21.0.0",
|
|
42
|
+
"@ultimat3/schema": "21.0.0"
|
|
42
43
|
}
|
|
43
44
|
}
|
package/src/client.ts
CHANGED
|
@@ -8,14 +8,25 @@
|
|
|
8
8
|
* not pay a byte for any of them — an `import type` is erased and the value import would not be.
|
|
9
9
|
* Dedup is deliberately unreachable from this file — a mutation may never join another mutation,
|
|
10
10
|
* and the way that is guaranteed is that `keyFor` is never called here.
|
|
11
|
+
*
|
|
12
|
+
* Every call dispatches through core's `clientTransport`: credentials, the JSON body, the
|
|
13
|
+
* idempotency header, the record envelope and its adoption into the page store, and the principal
|
|
14
|
+
* fence are decided there once. This file adds only what the action alone knows — the build-id
|
|
15
|
+
* check (`onResponse`) and the action-named error decode (`decodeError`). The caller still gets exactly the action's output — the envelope
|
|
16
|
+
* never reaches the return type.
|
|
11
17
|
*/
|
|
12
|
-
import type { ClientFlight, ClientRetry,
|
|
13
|
-
import {
|
|
18
|
+
import type { ClientFlight, ClientRetry, FetchLike, UltimateError } from '@ultimat3/core';
|
|
19
|
+
import {
|
|
20
|
+
actionPath,
|
|
21
|
+
clientTransport,
|
|
22
|
+
FRAMEWORK_CODE,
|
|
23
|
+
isJsonObject,
|
|
24
|
+
problemOf,
|
|
25
|
+
} from '@ultimat3/core';
|
|
14
26
|
import type { InferInput, InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
|
|
15
27
|
import type { Action } from './action';
|
|
16
|
-
import { ContractDriftError, RemoteActionError
|
|
17
|
-
import {
|
|
18
|
-
import { BUILD_ID_HEADER, IDEMPOTENCY_HEADER } from './wire-headers';
|
|
28
|
+
import { ContractDriftError, RemoteActionError } from './errors';
|
|
29
|
+
import { BUILD_ID_HEADER } from './wire-headers';
|
|
19
30
|
import { issuesFromWire } from './wire-issues';
|
|
20
31
|
|
|
21
32
|
/**
|
|
@@ -52,7 +63,8 @@ export type ClientMethod<TIn extends StandardSchemaV1, TOut extends StandardSche
|
|
|
52
63
|
options?: CallOptions,
|
|
53
64
|
) => Promise<InferOutput<TOut>>;
|
|
54
65
|
|
|
55
|
-
|
|
66
|
+
/** Core's one declaration, re-exported by name so `import type { FetchLike }` keeps resolving. */
|
|
67
|
+
export type { FetchLike } from '@ultimat3/core';
|
|
56
68
|
|
|
57
69
|
export interface ClientOptions {
|
|
58
70
|
readonly baseUrl: string;
|
|
@@ -61,8 +73,8 @@ export interface ClientOptions {
|
|
|
61
73
|
readonly buildId?: string;
|
|
62
74
|
readonly headers?: Readonly<Record<string, string>>;
|
|
63
75
|
/**
|
|
64
|
-
* Opt-in flight control — `createClientFlight({ … })`. Absent, a call is one
|
|
65
|
-
* else, which is what every caller written before this option existed already gets.
|
|
76
|
+
* Opt-in flight control — `createClientFlight({ … })`. Absent, a call is one dispatch and
|
|
77
|
+
* nothing else, which is what every caller written before this option existed already gets.
|
|
66
78
|
*/
|
|
67
79
|
readonly flight?: ClientFlight;
|
|
68
80
|
}
|
|
@@ -97,84 +109,44 @@ export function clientMethodFor<TInput extends StandardSchemaV1, TOutput extends
|
|
|
97
109
|
name: string,
|
|
98
110
|
options: ClientOptions,
|
|
99
111
|
): ClientMethod<TInput, TOutput> {
|
|
100
|
-
const
|
|
101
|
-
const
|
|
112
|
+
const url = `${options.baseUrl.replace(/\/+$/, '')}${actionPath(name)}`;
|
|
113
|
+
const onResponse = (response: Response): void =>
|
|
114
|
+
assertSameBuild(options.buildId, response.headers.get(BUILD_ID_HEADER), name);
|
|
115
|
+
const decodeError = (status: number, text: string): UltimateError | undefined =>
|
|
116
|
+
toUltimateError(text, status, name);
|
|
102
117
|
// Erased at the wire seam; the response type is this action's by construction.
|
|
103
118
|
return (input, callOptions = {}) =>
|
|
104
|
-
|
|
119
|
+
clientTransport({
|
|
120
|
+
method: 'POST',
|
|
121
|
+
url,
|
|
122
|
+
body: input ?? {},
|
|
123
|
+
headers: headersFor(options),
|
|
124
|
+
signal: callOptions.signal,
|
|
125
|
+
idempotencyKey: callOptions.idempotencyKey,
|
|
126
|
+
flight: options.flight,
|
|
127
|
+
// Only alongside a key: a retried mutation with no key is a second write, not a second
|
|
128
|
+
// attempt, so the flight's own policy is overridden with one attempt rather than inherited.
|
|
129
|
+
// With a key, an absent per-call policy is `undefined`, which the flight reads as "mine".
|
|
130
|
+
retry: callOptions.idempotencyKey === undefined ? ONCE : callOptions.retry,
|
|
131
|
+
onResponse,
|
|
132
|
+
decodeError,
|
|
133
|
+
fetchImpl: options.fetch,
|
|
134
|
+
}) as Promise<InferOutput<TOutput>>;
|
|
105
135
|
}
|
|
106
136
|
|
|
107
137
|
/** One attempt, and no retry at all. What a mutation carrying no idempotency key is allowed. */
|
|
108
138
|
const ONCE: ClientRetry = { attempts: 1 };
|
|
109
139
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
const url = `${base}${derivePath(name).path}`;
|
|
119
|
-
const body = JSON.stringify(input ?? {});
|
|
120
|
-
const dispatch = (signal: AbortSignal | undefined): Promise<WireAnswer> =>
|
|
121
|
-
postOnce(doFetch, url, body, options, name, callOptions, signal ?? callOptions.signal);
|
|
122
|
-
|
|
123
|
-
const flight = options.flight;
|
|
124
|
-
const answer =
|
|
125
|
-
flight === undefined
|
|
126
|
-
? await dispatch(undefined)
|
|
127
|
-
: await flight.run({
|
|
128
|
-
// `undefined`, unconditionally: a mutation may never join another mutation, and the
|
|
129
|
-
// enforcement is that this file never calls `flight.keyFor`.
|
|
130
|
-
key: undefined,
|
|
131
|
-
// NEVER aborted. A fence bump and a deadline both mean "this answer no longer matters";
|
|
132
|
-
// closing the socket does not un-commit the write, it only destroys the one chance this
|
|
133
|
-
// caller had of learning whether it landed.
|
|
134
|
-
abortable: false,
|
|
135
|
-
retry: callOptions.idempotencyKey === undefined ? ONCE : (callOptions.retry ?? ONCE),
|
|
136
|
-
run: dispatch,
|
|
137
|
-
});
|
|
138
|
-
if (answer.status === 204) return undefined;
|
|
139
|
-
return JSON.parse(answer.text) as unknown;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/** One dispatch. Everything above it decides how many times this happens; it decides none. */
|
|
143
|
-
async function postOnce(
|
|
144
|
-
doFetch: FetchLike,
|
|
145
|
-
url: string,
|
|
146
|
-
body: string,
|
|
147
|
-
options: ClientOptions,
|
|
148
|
-
name: string,
|
|
149
|
-
callOptions: CallOptions,
|
|
150
|
-
signal: AbortSignal | undefined,
|
|
151
|
-
): Promise<WireAnswer> {
|
|
152
|
-
const headers: Record<string, string> = {
|
|
153
|
-
'content-type': 'application/json',
|
|
154
|
-
// Before the caller's headers, so an explicit `traceparent` still wins. Without this a
|
|
155
|
-
// service-to-service hop started a fresh root trace on the other side, which makes "which of
|
|
156
|
-
// my downstreams is slow" unanswerable across every Ultimate-to-Ultimate call.
|
|
157
|
-
...traceHeaders(),
|
|
158
|
-
...options.headers,
|
|
159
|
-
};
|
|
140
|
+
/**
|
|
141
|
+
* The caller's headers plus the build id. The trace and the request budget are NOT here: the
|
|
142
|
+
* transport adds them server-side from its outbound-header slot (`@ultimat3/core`'s
|
|
143
|
+
* `outbound-headers.ts`), before these, so an explicit `traceparent` still wins — and a browser,
|
|
144
|
+
* which never has a trace, no longer bundles telemetry to learn that.
|
|
145
|
+
*/
|
|
146
|
+
function headersFor(options: ClientOptions): Readonly<Record<string, string>> {
|
|
147
|
+
const headers: Record<string, string> = { ...options.headers };
|
|
160
148
|
if (options.buildId !== undefined) headers[BUILD_ID_HEADER] = options.buildId;
|
|
161
|
-
|
|
162
|
-
headers[IDEMPOTENCY_HEADER] = callOptions.idempotencyKey;
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
const init: RequestInit = {
|
|
166
|
-
method: 'POST',
|
|
167
|
-
headers,
|
|
168
|
-
body,
|
|
169
|
-
...(signal === undefined ? {} : { signal }),
|
|
170
|
-
};
|
|
171
|
-
const response = await doFetch(url, init);
|
|
172
|
-
assertSameBuild(options.buildId, response.headers.get(BUILD_ID_HEADER), name);
|
|
173
|
-
// Read as TEXT once: a `Response` body is a single-use stream, so the failure path and the
|
|
174
|
-
// answer path cannot both have it.
|
|
175
|
-
const text = response.status === 204 ? '' : await response.text();
|
|
176
|
-
if (!response.ok) throw toUltimateError(text, response.status, name);
|
|
177
|
-
return { status: response.status, text };
|
|
149
|
+
return headers;
|
|
178
150
|
}
|
|
179
151
|
|
|
180
152
|
/**
|
|
@@ -198,17 +170,19 @@ function assertSameBuild(
|
|
|
198
170
|
* `application/problem+json` back into the error the server threw. The code rides along
|
|
199
171
|
* verbatim — carrying one is the point of the document — but it is a code this bundle may never
|
|
200
172
|
* have registered, so the result is a `RemoteActionError`: marked remote-origin, and linked only
|
|
201
|
-
* to a page that exists.
|
|
202
|
-
*
|
|
173
|
+
* to a page that exists.
|
|
174
|
+
*
|
|
175
|
+
* A body naming no framework code is a proxy answering rather than the app, and that answer is
|
|
176
|
+
* `undefined` here: the transport's shared decode makes it `X_CLIENT_TRANSPORT_FAILED`, the SAME
|
|
177
|
+
* code a query gets for the same failure. `X_RPC_FAILED` was this branch until 21.0.0; the code
|
|
178
|
+
* stays registered (shipped codes never change) and nothing in the framework throws it now.
|
|
203
179
|
*/
|
|
204
|
-
function toUltimateError(text: string, status: number, name: string): UltimateError {
|
|
180
|
+
function toUltimateError(text: string, status: number, name: string): UltimateError | undefined {
|
|
205
181
|
// `problemOf` is total — a gateway's HTML, an empty body and a truncated stream all answer `{}`,
|
|
206
|
-
// which carries no `code` and therefore
|
|
182
|
+
// which carries no `code` and therefore falls through to the transport's decode.
|
|
207
183
|
const body = problemOf(text);
|
|
208
184
|
const code = body['code'];
|
|
209
|
-
if (typeof code !== 'string' || !FRAMEWORK_CODE.test(code))
|
|
210
|
-
return new RpcFailedError(name, status);
|
|
211
|
-
}
|
|
185
|
+
if (typeof code !== 'string' || !FRAMEWORK_CODE.test(code)) return undefined;
|
|
212
186
|
return new RemoteActionError({
|
|
213
187
|
action: name,
|
|
214
188
|
status,
|
package/src/errors.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
ERROR_DOCS_URL,
|
|
10
10
|
hasErrorCode,
|
|
11
11
|
registerErrorCodes,
|
|
12
|
+
renderFixShellArg,
|
|
12
13
|
retryForStatus,
|
|
13
14
|
UltimateError,
|
|
14
15
|
} from '@ultimat3/core';
|
|
@@ -50,6 +51,8 @@ const OWNED_TITLES: Readonly<Record<string, string>> = {
|
|
|
50
51
|
'a retried Idempotency-Key replays a first attempt that failed after it may have committed',
|
|
51
52
|
X_IDEMPOTENCY_STATUS_UNKNOWN: 'an idempotency record holds a status this build cannot read',
|
|
52
53
|
X_INPUT_INVALID: 'input failed schema validation',
|
|
54
|
+
X_MUTATOR_CLOCK_MISSING:
|
|
55
|
+
"a mutator declares conflict: 'last-write-wins' and its entity has no number clock column",
|
|
53
56
|
X_OUTPUT_INVALID: 'a handler returned a value its output schema rejects',
|
|
54
57
|
X_RPC_FAILED: 'an RPC call failed without a problem+json body',
|
|
55
58
|
};
|
|
@@ -301,7 +304,7 @@ function remoteDocs(code: string, sent: readonly (string | undefined)[] = []): s
|
|
|
301
304
|
* policy decision — but a code the server owns is one this bundle may never have registered, so
|
|
302
305
|
* the error says where it came from rather than passing as locally declared: `name` marks it in
|
|
303
306
|
* a stack trace and `meta.origin` marks it in `--json`, the dev overlay and the error reporter.
|
|
304
|
-
*
|
|
307
|
+
* A body naming no framework code is core's `X_CLIENT_TRANSPORT_FAILED`, as it is for a query.
|
|
305
308
|
*/
|
|
306
309
|
export class RemoteActionError extends UltimateError {
|
|
307
310
|
override readonly name = 'RemoteActionError';
|
|
@@ -336,7 +339,12 @@ export class RemoteActionError extends UltimateError {
|
|
|
336
339
|
}
|
|
337
340
|
}
|
|
338
341
|
|
|
339
|
-
/**
|
|
342
|
+
/**
|
|
343
|
+
* A non-`problem+json` failure — a proxy, not our server, answered. **No framework code throws this
|
|
344
|
+
* since 21.0.0**: the typed client answers that failure with core's `X_CLIENT_TRANSPORT_FAILED`, the
|
|
345
|
+
* code a query gets. Kept exported and `X_RPC_FAILED` kept registered because a shipped code never
|
|
346
|
+
* changes and an app may still construct or match it.
|
|
347
|
+
*/
|
|
340
348
|
export class RpcFailedError extends UltimateError {
|
|
341
349
|
constructor(name: string, status: number) {
|
|
342
350
|
super({
|
|
@@ -401,6 +409,30 @@ export class AuditSinkFailedError extends UltimateError {
|
|
|
401
409
|
}
|
|
402
410
|
}
|
|
403
411
|
|
|
412
|
+
/**
|
|
413
|
+
* `conflict: 'last-write-wins'` with nothing to compare. Refused at declaration, because the
|
|
414
|
+
* alternative is a policy that reads as "newest wins" and resolves to the server row every time.
|
|
415
|
+
*/
|
|
416
|
+
export class MutatorClockMissingError extends UltimateError {
|
|
417
|
+
constructor(entity: string | undefined, clock: string) {
|
|
418
|
+
super(
|
|
419
|
+
entity === undefined
|
|
420
|
+
? {
|
|
421
|
+
code: 'X_MUTATOR_CLOCK_MISSING',
|
|
422
|
+
cause: `a mutator declares conflict: 'last-write-wins' and its output carries no entity row, so there is no ${clock} to compare`,
|
|
423
|
+
fix: "return the entity row from the mutator's output, or declare conflict: 'server-wins'",
|
|
424
|
+
meta: { clock },
|
|
425
|
+
}
|
|
426
|
+
: {
|
|
427
|
+
code: 'X_MUTATOR_CLOCK_MISSING',
|
|
428
|
+
cause: `a mutator declares conflict: 'last-write-wins' and entity ${entity} has no number ${clock} column the server writes, so the server row would win every time`,
|
|
429
|
+
fix: `add ${clock} (a number, epoch ms, written by the server) to ${renderFixShellArg(entity, '<entity>')}, or declare conflict: 'server-wins'`,
|
|
430
|
+
meta: { entity, clock },
|
|
431
|
+
},
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
404
436
|
export class ContractDriftError extends UltimateError {
|
|
405
437
|
constructor(cause: string, fix: string) {
|
|
406
438
|
super({ code: 'X_CONTRACT_DRIFT', cause, fix });
|
package/src/http.ts
CHANGED
|
@@ -6,7 +6,14 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { tagKeys } from '@ultimat3/cache';
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
isMcpExposed,
|
|
11
|
+
isUltimateError,
|
|
12
|
+
RECORDS_OPENAPI_HEADER,
|
|
13
|
+
recordEnvelopeSchema,
|
|
14
|
+
withWriteOrigin,
|
|
15
|
+
writeDigest,
|
|
16
|
+
} from '@ultimat3/core';
|
|
10
17
|
import type { Route, RouteMeta, UltimateRequest } from '@ultimat3/http';
|
|
11
18
|
// `toBucket` is `@ultimat3/http`'s, not this package's: http owns `Bucket` and the limiter maths,
|
|
12
19
|
// and `@ultimat3/query` needs the identical conversion while being the same tier as this one — so
|
|
@@ -26,6 +33,7 @@ import {
|
|
|
26
33
|
toOperationId,
|
|
27
34
|
} from './naming';
|
|
28
35
|
import { admitsAnonymous, policyCapability } from './policy-gate';
|
|
36
|
+
import { carriesRecords, recordResponse } from './record-wire';
|
|
29
37
|
import { IDEMPOTENCY_HEADER } from './wire-headers';
|
|
30
38
|
|
|
31
39
|
/**
|
|
@@ -37,6 +45,12 @@ export { BUILD_ID_HEADER, IDEMPOTENCY_HEADER } from './wire-headers';
|
|
|
37
45
|
|
|
38
46
|
export const REPLAYED_HEADER = 'x-ultimate-replayed';
|
|
39
47
|
|
|
48
|
+
/** The digest a request's idempotency key names its write by; none for a missing or blank one. */
|
|
49
|
+
async function writeOriginOf(req: UltimateRequest): Promise<string | undefined> {
|
|
50
|
+
const key = req.header(IDEMPOTENCY_HEADER);
|
|
51
|
+
return key === null || key === '' ? undefined : await writeDigest(key);
|
|
52
|
+
}
|
|
53
|
+
|
|
40
54
|
/**
|
|
41
55
|
* `publishPost` -> `POST /api/posts/publish`. Derivation: the first camelCase word
|
|
42
56
|
* is the verb, the rest is the resource with its last word pluralized and
|
|
@@ -49,6 +63,7 @@ export function toRoute(target: AnyAction): Route {
|
|
|
49
63
|
// Rendered ONCE, at projection: a date that cannot become a header is a mount-time refusal,
|
|
50
64
|
// not a surprise on the first request — the same rule `toBucket` follows for a rate limit.
|
|
51
65
|
const sunsetting = deprecationHeadersFor(name, def.deprecated);
|
|
66
|
+
const enveloped = carriesRecords(target.output);
|
|
52
67
|
|
|
53
68
|
const handler = async (req: UltimateRequest): Promise<Response> => {
|
|
54
69
|
if (sunsetting !== undefined) recordDeprecatedCall('action', name);
|
|
@@ -58,20 +73,30 @@ export function toRoute(target: AnyAction): Route {
|
|
|
58
73
|
const raw = await req.bodyRaw();
|
|
59
74
|
const key = def.idempotent === true ? req.header(IDEMPOTENCY_HEADER) : null;
|
|
60
75
|
let replayed = false;
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
76
|
+
// The header NAMES the write whatever the declaration, idempotent or not: a page sends one
|
|
77
|
+
// with every mutation, and the `records` frames its rows produce carry the digest so that
|
|
78
|
+
// page can tell its own echo from somebody else's change (`@ultimat3/core`'s write origin).
|
|
79
|
+
const result = await withWriteOrigin(await writeOriginOf(req), () =>
|
|
80
|
+
invoke(target, raw, {
|
|
81
|
+
surface: 'http',
|
|
82
|
+
idempotencyKey: key,
|
|
83
|
+
onReplay: () => {
|
|
84
|
+
replayed = true;
|
|
85
|
+
},
|
|
86
|
+
}),
|
|
87
|
+
);
|
|
68
88
|
// The one thing an action's return value cannot say. `setRedirect()` inside the handler
|
|
69
89
|
// is how a `<form method="post">` gets an answer a browser follows — a `Location` on the
|
|
70
90
|
// 200 this used to always return is a header browsers ignore, so a JS-less form left the
|
|
71
91
|
// reader staring at `{"ok":true}`. Only this projection honours it: a redirect is an HTTP
|
|
72
92
|
// fact, and the MCP tool and the job handle share none of it.
|
|
73
93
|
const to = takeRedirect(req.ctx);
|
|
74
|
-
const response =
|
|
94
|
+
const response =
|
|
95
|
+
to !== undefined
|
|
96
|
+
? redirect(to.location, to.status)
|
|
97
|
+
: enveloped
|
|
98
|
+
? recordResponse(target.output, result)
|
|
99
|
+
: json(result);
|
|
75
100
|
if (key !== null) response.headers.set(REPLAYED_HEADER, replayed ? '1' : '0');
|
|
76
101
|
// On the failure path too, below: a client polling a deprecated endpoint that is currently
|
|
77
102
|
// 403ing still has to learn the endpoint is going away. Announcing it only on 200 hides the
|
|
@@ -148,6 +173,8 @@ export function toOpenApiOperation(target: AnyAction): OpenApiOperation {
|
|
|
148
173
|
const path = derivePath(name);
|
|
149
174
|
const idempotent = def.idempotent === true;
|
|
150
175
|
const deprecation = deprecationMetaFor(name, def.deprecated);
|
|
176
|
+
const outputRef = schemaRef(outputSchemaName(name));
|
|
177
|
+
const enveloped = carriesRecords(target.output);
|
|
151
178
|
return {
|
|
152
179
|
operationId: toOperationId(name),
|
|
153
180
|
tags: [path.resource],
|
|
@@ -159,10 +186,15 @@ export function toOpenApiOperation(target: AnyAction): OpenApiOperation {
|
|
|
159
186
|
content: { 'application/json': { schema: { $ref: schemaRef(inputSchemaName(name)) } } },
|
|
160
187
|
},
|
|
161
188
|
responses: {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
189
|
+
// Only an output that references an entity row changes shape: every other operation's
|
|
190
|
+
// bytes are the ones `x verify`'s contract diff already holds.
|
|
191
|
+
'200': enveloped
|
|
192
|
+
? {
|
|
193
|
+
description: 'ok',
|
|
194
|
+
headers: RECORDS_OPENAPI_HEADER,
|
|
195
|
+
content: { 'application/json': { schema: recordEnvelopeSchema({ $ref: outputRef }) } },
|
|
196
|
+
}
|
|
197
|
+
: { description: 'ok', content: { 'application/json': { schema: { $ref: outputRef } } } },
|
|
166
198
|
// BOTH, because they are two different failures and this operation published only one of
|
|
167
199
|
// them while the route answered only the other. `X_INPUT_INVALID` is the body that parsed
|
|
168
200
|
// and failed THIS action's declared schema — the primitive's own code, identical over MCP,
|
package/src/index.ts
CHANGED
|
@@ -195,9 +195,6 @@ export { jsonSchemaOf, mcpSchemaOf } from './json-schema';
|
|
|
195
195
|
export type { McpInvokeOptions, McpToolDescriptor } from './mcp-tool';
|
|
196
196
|
export { isExposed, toMcpTool, toMcpTools } from './mcp-tool';
|
|
197
197
|
export type {
|
|
198
|
-
Conflict,
|
|
199
|
-
CustomConflict,
|
|
200
|
-
LocalRow,
|
|
201
198
|
LocalTable,
|
|
202
199
|
LocalTableName,
|
|
203
200
|
LocalTables,
|
|
@@ -206,7 +203,7 @@ export type {
|
|
|
206
203
|
MutatorDef,
|
|
207
204
|
MutatorDescriptor,
|
|
208
205
|
} from './mutator';
|
|
209
|
-
export { custom, isMutator, mutator
|
|
206
|
+
export { custom, isMutator, mutator } from './mutator';
|
|
210
207
|
export type { ActionPath } from './naming';
|
|
211
208
|
export { derivePath, inputSchemaName, outputSchemaName, pluralize } from './naming';
|
|
212
209
|
export type { BuildOpenApiOptions, OpenApiDocument, OpenApiInfo } from './openapi';
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The declaration-time proof `conflict: 'last-write-wins'` needs: every entity row the mutator
|
|
3
|
+
* answers carries a NUMBER clock column the server writes (`updatedAt`, epoch ms). Without one,
|
|
4
|
+
* core's `resolveConflict` can never prove the local row newer and the server row wins every time —
|
|
5
|
+
* `'last-write-wins'` silently became `'server-wins'`, which `examples/dummy`'s `setTheme` shipped.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { projectionsIn } from '@ultimat3/entity';
|
|
9
|
+
import { MutatorClockMissingError } from './errors';
|
|
10
|
+
|
|
11
|
+
/** The field `resolveConflict` compares by default — the one rebase reads with no option set. */
|
|
12
|
+
export const CLOCK_FIELD = 'updatedAt';
|
|
13
|
+
|
|
14
|
+
/** Throws `X_MUTATOR_CLOCK_MISSING` unless every entity row in `output` has a number clock. */
|
|
15
|
+
export function assertConflictClock(output: unknown): void {
|
|
16
|
+
const entities = projectionsIn(output);
|
|
17
|
+
if (entities.length === 0) throw new MutatorClockMissingError(undefined, CLOCK_FIELD);
|
|
18
|
+
for (const projection of entities) {
|
|
19
|
+
const clock = projection.schema.properties?.[CLOCK_FIELD];
|
|
20
|
+
if (clock?.kind !== 'number') throw new MutatorClockMissingError(projection.type, CLOCK_FIELD);
|
|
21
|
+
}
|
|
22
|
+
}
|
package/src/mutator.ts
CHANGED
|
@@ -5,18 +5,13 @@
|
|
|
5
5
|
* contract tests for free, and its authz is the same single evaluation.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import type { Ctx } from '@ultimat3/core';
|
|
9
|
-
import { assertNever } from '@ultimat3/core';
|
|
8
|
+
import type { ConflictPolicy, Ctx, Row } from '@ultimat3/core';
|
|
10
9
|
import type { InferInput, InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
|
|
11
10
|
import type { Action, ActionCache, ActionDef, ActionDescriptor, ActionMcp } from './action';
|
|
12
11
|
import { action, isAction } from './action';
|
|
12
|
+
import { assertConflictClock } from './mutator-clock';
|
|
13
13
|
import type { ActionPolicy } from './policy-gate';
|
|
14
14
|
|
|
15
|
-
/** Minimum shape of a locally-stored row: an id the local twin can address. */
|
|
16
|
-
export interface LocalRow {
|
|
17
|
-
readonly id: string;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
15
|
/**
|
|
21
16
|
* Augmented by the app so `tx.posts` is typed:
|
|
22
17
|
*
|
|
@@ -33,34 +28,47 @@ export interface LocalTables {
|
|
|
33
28
|
|
|
34
29
|
export type LocalTableName = Exclude<keyof LocalTables, '~ultimate'>;
|
|
35
30
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
31
|
+
/**
|
|
32
|
+
* One table as a mutator's `local` half sees it — the SAME shape as `@ultimat3/realtime`'s store tx
|
|
33
|
+
* (`record-tx.ts`), so a twin typed against this runs against the page's record store unchanged.
|
|
34
|
+
* Rows are addressed by KEY, never by a column: the browser holds no entity schema and cannot know
|
|
35
|
+
* a primary key, so an optimistic insert names the key its server twin will answer under.
|
|
36
|
+
*/
|
|
37
|
+
export interface LocalTable<TRow extends object = Row> {
|
|
38
|
+
get(key: string): TRow | undefined;
|
|
39
|
+
all(): readonly TRow[];
|
|
40
|
+
insert(key: string, row: TRow): void;
|
|
41
|
+
/** Merged over what the table holds; an `undefined` field leaves the column alone. */
|
|
42
|
+
upsert(key: string, row: TRow): void;
|
|
43
|
+
/** Changed fields only — or a function returning them. A no-op for a key the table does not hold. */
|
|
44
|
+
update(key: string, patch: Partial<TRow> | ((row: TRow) => Partial<TRow>)): void;
|
|
45
|
+
delete(key: string): void;
|
|
40
46
|
}
|
|
41
47
|
|
|
42
48
|
/**
|
|
43
|
-
* The client-side write surface a mutator's `local()` gets.
|
|
44
|
-
*
|
|
49
|
+
* The client-side write surface a mutator's `local()` gets. `@ultimat3/realtime` implements it over
|
|
50
|
+
* the page's record store; tests implement it over a Map.
|
|
45
51
|
*/
|
|
46
52
|
export type LocalTx = {
|
|
47
|
-
readonly [K in LocalTableName]: LocalTable<Extract<LocalTables[K],
|
|
53
|
+
readonly [K in LocalTableName]: LocalTable<Extract<LocalTables[K], object>>;
|
|
48
54
|
} & {
|
|
49
55
|
/** Escape hatch for generated code that only knows the table name as a string. */
|
|
50
|
-
table<TRow extends
|
|
56
|
+
table<TRow extends object = Row>(name: string): LocalTable<TRow>;
|
|
51
57
|
};
|
|
52
58
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
59
|
+
/**
|
|
60
|
+
* `conflict: custom(merge)` — core's row-shaped `ConflictPolicy`, built. `merge` receives the local
|
|
61
|
+
* ROW and the server ROW, because the client store is row-shaped: the output-shaped variant this
|
|
62
|
+
* replaced was dropped silently by realtime's rebase, which only ever had rows to hand it.
|
|
63
|
+
*
|
|
64
|
+
* `TRow` is the app's declared row shape, a caller-side annotation only — at rebase the resolver
|
|
65
|
+
* hands over whatever the store holds for that record, which is the entity's row.
|
|
66
|
+
*/
|
|
67
|
+
export function custom<TRow extends object = Row>(
|
|
68
|
+
merge: (local: TRow, server: TRow) => TRow,
|
|
69
|
+
): ConflictPolicy {
|
|
70
|
+
// The one widening in the vocabulary: core's policy is over `Row`, the app's merge over its row.
|
|
71
|
+
return { kind: 'custom', merge: merge as unknown as (local: Row, server: Row) => Row };
|
|
64
72
|
}
|
|
65
73
|
|
|
66
74
|
export interface MutatorDef<TInput extends StandardSchemaV1, TOutput extends StandardSchemaV1> {
|
|
@@ -84,7 +92,7 @@ export interface MutatorDef<TInput extends StandardSchemaV1, TOutput extends Sta
|
|
|
84
92
|
ctx: Ctx,
|
|
85
93
|
input: InferOutput<TInput>,
|
|
86
94
|
): Promise<InferOutput<TOutput>> | InferOutput<TOutput>;
|
|
87
|
-
readonly conflict:
|
|
95
|
+
readonly conflict: ConflictPolicy;
|
|
88
96
|
}
|
|
89
97
|
|
|
90
98
|
export type MutatorDescriptor = Omit<ActionDescriptor, 'kind'> & {
|
|
@@ -102,7 +110,7 @@ export interface Mutator<
|
|
|
102
110
|
* Renaming it here would silently turn every mutator back into a plain action downstream.
|
|
103
111
|
*/
|
|
104
112
|
readonly isMutator: true;
|
|
105
|
-
readonly conflict:
|
|
113
|
+
readonly conflict: ConflictPolicy;
|
|
106
114
|
/**
|
|
107
115
|
* Applied on the client before the server round trip, and replayed on every
|
|
108
116
|
* rebase — so it must stay a pure function of `(tx, input)`: no I/O, no clock,
|
|
@@ -133,6 +141,8 @@ export function mutator<TInput extends StandardSchemaV1, TOutput extends Standar
|
|
|
133
141
|
...(def.audit === undefined ? {} : { audit: def.audit }),
|
|
134
142
|
handle: ({ input, ctx }) => def.server(ctx, input),
|
|
135
143
|
};
|
|
144
|
+
// Before the action is built: a policy that cannot do what it says is refused at declaration.
|
|
145
|
+
if (def.conflict === 'last-write-wins') assertConflictClock(def.output);
|
|
136
146
|
return wrap(def, action(actionDef));
|
|
137
147
|
}
|
|
138
148
|
|
|
@@ -174,28 +184,7 @@ function wrap<TInput extends StandardSchemaV1, TOutput extends StandardSchemaV1>
|
|
|
174
184
|
return self;
|
|
175
185
|
}
|
|
176
186
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
return typeof conflict === 'string' ? conflict : conflict.strategy;
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
/**
|
|
184
|
-
* Rebase decision for @ultimat3/realtime: which value survives when the local
|
|
185
|
-
* twin and the server disagree.
|
|
186
|
-
*/
|
|
187
|
-
export function resolveConflict<TOutput>(
|
|
188
|
-
conflict: Conflict<TOutput>,
|
|
189
|
-
local: TOutput,
|
|
190
|
-
server: TOutput,
|
|
191
|
-
): TOutput {
|
|
192
|
-
if (typeof conflict !== 'string') return conflict.merge(local, server);
|
|
193
|
-
switch (conflict) {
|
|
194
|
-
case 'server-wins':
|
|
195
|
-
return server;
|
|
196
|
-
case 'last-write-wins':
|
|
197
|
-
return local;
|
|
198
|
-
default:
|
|
199
|
-
return assertNever(conflict);
|
|
200
|
-
}
|
|
187
|
+
/** The descriptor's name for a policy — the manifest and `x actions describe` print this. */
|
|
188
|
+
function strategyOf(conflict: ConflictPolicy): MutatorDescriptor['conflict'] {
|
|
189
|
+
return typeof conflict === 'string' ? conflict : conflict.kind;
|
|
201
190
|
}
|
package/src/naming.ts
CHANGED
|
@@ -5,70 +5,25 @@
|
|
|
5
5
|
* derived by nothing — it is the export name verbatim.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
person: 'people',
|
|
11
|
-
child: 'children',
|
|
12
|
-
man: 'men',
|
|
13
|
-
woman: 'women',
|
|
14
|
-
datum: 'data',
|
|
15
|
-
index: 'indexes',
|
|
16
|
-
entry: 'entries',
|
|
17
|
-
};
|
|
18
|
-
|
|
19
|
-
export interface ActionPath {
|
|
20
|
-
/** First camelCase word, kebab-cased. `publishPost` -> `publish`. */
|
|
21
|
-
readonly verb: string;
|
|
22
|
-
/** Remaining words, last one pluralized, kebab-cased. `publishPost` -> `posts`. */
|
|
23
|
-
readonly resource: string;
|
|
24
|
-
/** `POST /api/<resource>/<verb>`. */
|
|
25
|
-
readonly path: string;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/** camelCase / PascalCase / SCREAMING_SNAKE -> lowercase words. */
|
|
29
|
-
export function splitWords(name: string): string[] {
|
|
30
|
-
return name
|
|
31
|
-
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
32
|
-
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
|
|
33
|
-
.split(/[\s_-]+/)
|
|
34
|
-
.filter((word) => word.length > 0)
|
|
35
|
-
.map((word) => word.toLowerCase());
|
|
36
|
-
}
|
|
8
|
+
import type { ActionRoute } from '@ultimat3/core';
|
|
9
|
+
import { actionRoute, splitWords } from '@ultimat3/core';
|
|
37
10
|
|
|
38
11
|
/**
|
|
39
|
-
*
|
|
40
|
-
*
|
|
12
|
+
* The path rule is `@ultimat3/core`'s `client-paths.ts` — the typed client, the route and the
|
|
13
|
+
* spec derive one URL from one function. Re-exported by name so `@ultimat3/action`'s public
|
|
14
|
+
* `derivePath` / `pluralize` / `splitWords` / `ActionPath` keep resolving; never re-declared here.
|
|
41
15
|
*/
|
|
42
|
-
export
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
// which keeps `toString` out of reach and lets `constructor` straight through. `pluralize` is
|
|
46
|
-
// public API returning `string`, and `derivePath` publishes what it answers as the action's
|
|
47
|
-
// HTTP path, its OpenAPI `paths` key and its `tags` entry.
|
|
48
|
-
if (Object.hasOwn(IRREGULAR, word)) return IRREGULAR[word] ?? word;
|
|
49
|
-
if (word.endsWith('s')) return word;
|
|
50
|
-
if (/(x|z|ch|sh)$/.test(word)) return `${word}es`;
|
|
51
|
-
if (/[^aeiou]y$/.test(word)) return `${word.slice(0, -1)}ies`;
|
|
52
|
-
return `${word}s`;
|
|
53
|
-
}
|
|
16
|
+
export { pluralize, splitWords } from '@ultimat3/core';
|
|
17
|
+
|
|
18
|
+
export type ActionPath = ActionRoute;
|
|
54
19
|
|
|
55
20
|
/**
|
|
56
21
|
* `publishPost` -> POST /api/posts/publish
|
|
57
22
|
* `updateUserProfile` -> POST /api/user-profiles/update
|
|
58
|
-
* `likePost` -> POST /api/posts/like
|
|
59
23
|
* `checkout` -> POST /api/checkouts/invoke (single-word fallback)
|
|
60
24
|
*/
|
|
61
25
|
export function derivePath(name: string): ActionPath {
|
|
62
|
-
|
|
63
|
-
const head = words[0] ?? 'invoke';
|
|
64
|
-
if (words.length < 2) {
|
|
65
|
-
const resource = pluralize(head);
|
|
66
|
-
return { verb: 'invoke', resource, path: `/api/${resource}/invoke` };
|
|
67
|
-
}
|
|
68
|
-
const nouns = words.slice(1);
|
|
69
|
-
const last = nouns[nouns.length - 1] ?? head;
|
|
70
|
-
const resource = [...nouns.slice(0, -1), pluralize(last)].join('-');
|
|
71
|
-
return { verb: head, resource, path: `/api/${resource}/${head}` };
|
|
26
|
+
return actionRoute(name);
|
|
72
27
|
}
|
|
73
28
|
|
|
74
29
|
// There is deliberately no `toToolName`. An MCP tool name is the export name verbatim — the one
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The record envelope, on the action's HTTP projection only. Derived from the output schema — an
|
|
3
|
+
* action whose output references an entity row answers `{ data, records }` under
|
|
4
|
+
* `x-ultimate-records: 1`; every other action's body is byte-identical to what it always was.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { encodeRecordEnvelope, RECORDS_HEADER } from '@ultimat3/core';
|
|
8
|
+
import { hasEntityRows, rowsOf } from '@ultimat3/entity';
|
|
9
|
+
import { json } from '@ultimat3/http';
|
|
10
|
+
import type { StandardSchemaV1 } from '@ultimat3/schema';
|
|
11
|
+
|
|
12
|
+
/** The header value that says "this body is an envelope". Presence alone is not enough. */
|
|
13
|
+
const ENVELOPED = '1';
|
|
14
|
+
|
|
15
|
+
// The OpenAPI half — the 200 body and its header — is core's `recordEnvelopeSchema` and
|
|
16
|
+
// `RECORDS_OPENAPI_HEADER`, shared with `@ultimat3/query` so both projections describe one shape.
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Decided ONCE per action, at projection, from the schema — never per response from the data.
|
|
20
|
+
* A body whose shape depended on whether this call happened to return rows would need two
|
|
21
|
+
* OpenAPI shapes for one operation; a schema-derived answer needs exactly one.
|
|
22
|
+
*/
|
|
23
|
+
export function carriesRecords(output: StandardSchemaV1): boolean {
|
|
24
|
+
return hasEntityRows(output);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The 200 for an action that carries records: the envelope, and the header that names it. */
|
|
28
|
+
export function recordResponse(output: StandardSchemaV1, result: unknown): Response {
|
|
29
|
+
const response = json(encodeRecordEnvelope(result, rowsOf(output, result)));
|
|
30
|
+
response.headers.set(RECORDS_HEADER, ENVELOPED);
|
|
31
|
+
return response;
|
|
32
|
+
}
|
package/src/transition.ts
CHANGED
|
@@ -18,7 +18,7 @@ import type {
|
|
|
18
18
|
StringSchema,
|
|
19
19
|
} from '@ultimat3/schema';
|
|
20
20
|
import { t } from '@ultimat3/schema';
|
|
21
|
-
import { type
|
|
21
|
+
import { type Mutator, mutator } from './mutator';
|
|
22
22
|
import type { ActionPolicy } from './policy-gate';
|
|
23
23
|
|
|
24
24
|
/**
|
|
@@ -126,9 +126,9 @@ export function transition<
|
|
|
126
126
|
const input = valuesOf(raw);
|
|
127
127
|
// `as Partial<…>`: a computed key widens to an index signature, which is never assignable to
|
|
128
128
|
// a `Partial` of a type parameter. `def.column` is `keyof Row`, so the shape is a real one.
|
|
129
|
-
tx.table<Row
|
|
129
|
+
tx.table<Row>(def.localTable).update(input.id, {
|
|
130
130
|
[def.column]: input.to,
|
|
131
|
-
} as Partial<Row
|
|
131
|
+
} as Partial<Row>);
|
|
132
132
|
},
|
|
133
133
|
// No cast on `from`/`to`: they are the enum's own union, which is `Row[K]`. And no legality
|
|
134
134
|
// check here — `X_STATE_TRANSITION_ILLEGAL`, `X_STATE_CONFLICT` and `X_STATE_UNDECLARED` are
|
package/src/type-pins.ts
CHANGED
|
@@ -3,10 +3,12 @@
|
|
|
3
3
|
// type-level claim written in one can never fail. This module emits nothing and exports nothing
|
|
4
4
|
// anybody imports — a regression here is a build error, the only enforcement that counts.
|
|
5
5
|
|
|
6
|
+
import type { Row } from '@ultimat3/core';
|
|
6
7
|
import type { StandardSchemaV1 } from '@ultimat3/schema';
|
|
7
8
|
import type { Action, AnyAction } from './action';
|
|
8
9
|
import type { ClientMethod } from './client';
|
|
9
10
|
import type { ActionJobHandle } from './job-handle';
|
|
11
|
+
import type { LocalTable } from './mutator';
|
|
10
12
|
|
|
11
13
|
/** Fails to compile when `T` is anything but `true`. The whole mechanism. */
|
|
12
14
|
type Assert<T extends true> = T;
|
|
@@ -43,3 +45,17 @@ export type _ErasedClientIsNotASupertype = Assert<
|
|
|
43
45
|
? false
|
|
44
46
|
: true
|
|
45
47
|
>;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A mutator's `tx` table is `@ultimat3/realtime`'s store tx, member for member — the store is what
|
|
51
|
+
* a twin runs against, so a member here it lacks is a twin that typechecks and throws. Pinned by
|
|
52
|
+
* the member set because realtime (tier 3, sideways) cannot be imported to compare the types.
|
|
53
|
+
*/
|
|
54
|
+
export type _LocalTableIsTheStoreTxShape = Assert<
|
|
55
|
+
Equals<keyof LocalTable, 'get' | 'all' | 'insert' | 'upsert' | 'update' | 'delete'>
|
|
56
|
+
>;
|
|
57
|
+
|
|
58
|
+
/** Addressed by KEY: an insert names the key its server twin answers under, never a column. */
|
|
59
|
+
export type _LocalInsertTakesTheKey = Assert<
|
|
60
|
+
Equals<Parameters<LocalTable['insert']>, [key: string, row: Row]>
|
|
61
|
+
>;
|
package/src/wire-headers.ts
CHANGED
|
@@ -13,5 +13,8 @@
|
|
|
13
13
|
*/
|
|
14
14
|
export const BUILD_ID_HEADER = 'x-ultimate-build';
|
|
15
15
|
|
|
16
|
-
/**
|
|
17
|
-
|
|
16
|
+
/**
|
|
17
|
+
* RFC 9110's spelling, lower-cased. Core's, re-exported by name: `clientTransport` sets it and
|
|
18
|
+
* this package's route reads it, so a second declaration here would be two answers to one header.
|
|
19
|
+
*/
|
|
20
|
+
export { IDEMPOTENCY_HEADER } from '@ultimat3/core';
|