@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 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 `RpcFailedError`, which is what that code means.
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: `rpc` alone is 14,759 B minified for the browser and `queryClient` alone is
552
- 12,755 B, against 20,292 B / 17,912 B with `createClientFlight` imported beside them — ±376 B run
553
- to run, which is `Bun.build` 1.4.0 dropping core's `schema-error-codes.ts` (issue #273). A caller
554
- who wants a plain typed fetch must not pay for the fence, the dedup map or the retry loop —
555
- `packages/cli/src/templates/resource-form-island.ts` and `examples/dummy`'s contact-sales island
556
- both write a bare `fetch` today because that bill used to be unavoidable. Never import
557
- `createClientFlight` for a VALUE from `client.ts` — `ClientFlight` and `ClientRetry` are
558
- `import type` from `@ultimat3/core` and must stay that way.
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 **14,759 B** minified for the browser; adding
146
- `createClientFlight` is **20,292 B**. `ClientFlight` is a TYPE inside `client.ts` and never a
147
- value, which is what keeps the second number off the first caller's bill. Expect ±376 B run to
148
- run — `Bun.build` 1.4.0 drops `@ultimat3/core`'s `schema-error-codes.ts` from some builds even
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(merge)
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 (`@ultimat3/realtime` implements it over OPFS
240
- SQLite). Type your tables once: `declare module '@ultimat3/action' { interface
241
- LocalTables { posts: PostRow } }`.
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, or a body naming no `X_` code | check the gateway |
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": "20.2.0",
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": "20.2.0",
38
- "@ultimat3/core": "20.2.0",
39
- "@ultimat3/http": "20.2.0",
40
- "@ultimat3/policy": "20.2.0",
41
- "@ultimat3/schema": "20.2.0"
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, UltimateError, WireAnswer } from '@ultimat3/core';
13
- import { FRAMEWORK_CODE, isJsonObject, problemOf, traceHeaders } from '@ultimat3/core';
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, RpcFailedError } from './errors';
17
- import { derivePath } from './naming';
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
- export type FetchLike = (input: string, init: RequestInit) => Promise<Response>;
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 `fetch` and nothing
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 doFetch: FetchLike = options.fetch ?? ((input, init) => fetch(input, init));
101
- const base = options.baseUrl.replace(/\/+$/, '');
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
- call(doFetch, base, options, name, input, callOptions) as Promise<InferOutput<TOutput>>;
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
- async function call(
111
- doFetch: FetchLike,
112
- base: string,
113
- options: ClientOptions,
114
- name: string,
115
- input: unknown,
116
- callOptions: CallOptions,
117
- ): Promise<unknown> {
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
- if (callOptions.idempotencyKey !== undefined) {
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. A body naming no framework code is a proxy answering rather than the
202
- * app, which is what `RpcFailedError` already says.
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 lands on `RpcFailedError` exactly as before.
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
- * `RpcFailedError` stays the answer when no framework code came back at all.
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
- /** The client got a non-`problem+json` failure — a proxy, not our server, answered. */
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 { isMcpExposed, isUltimateError } from '@ultimat3/core';
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
- const result = await invoke(target, raw, {
62
- surface: 'http',
63
- idempotencyKey: key,
64
- onReplay: () => {
65
- replayed = true;
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 = to === undefined ? json(result) : redirect(to.location, to.status);
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
- '200': {
163
- description: 'ok',
164
- content: { 'application/json': { schema: { $ref: schemaRef(outputSchemaName(name)) } } },
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, resolveConflict, strategyOf } from './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
- export interface LocalTable<TRow extends LocalRow> {
37
- insert(row: TRow): void;
38
- update(id: string, patch: Partial<TRow> | ((row: TRow) => Partial<TRow>)): void;
39
- delete(id: string): void;
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. @ultimat3/realtime
44
- * implements it over OPFS SQLite; tests implement it over a Map.
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], LocalRow>>;
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 LocalRow>(name: string): LocalTable<TRow>;
56
+ table<TRow extends object = Row>(name: string): LocalTable<TRow>;
51
57
  };
52
58
 
53
- export interface CustomConflict<TOutput> {
54
- readonly strategy: 'custom';
55
- merge(local: TOutput, server: TOutput): TOutput;
56
- }
57
-
58
- export type Conflict<TOutput> = 'server-wins' | 'last-write-wins' | CustomConflict<TOutput>;
59
-
60
- export function custom<TOutput>(
61
- merge: (local: TOutput, server: TOutput) => TOutput,
62
- ): CustomConflict<TOutput> {
63
- return { strategy: 'custom', merge };
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: Conflict<InferOutput<TOutput>>;
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: Conflict<InferOutput<TOutput>>;
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
- export function strategyOf<TOutput>(
178
- conflict: Conflict<TOutput>,
179
- ): 'server-wins' | 'last-write-wins' | 'custom' {
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
- /** Irregular plurals we actually hit in domain models. Extend deliberately, not eagerly. */
9
- const IRREGULAR: Readonly<Record<string, string>> = {
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
- * Naive-on-purpose English pluralizer. A word that already ends in `s` is left
40
- * alone, so `publishPosts` and `publishPost` agree on the `posts` resource.
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 function pluralize(word: string): string {
43
- // `Object.hasOwn`, never a truthiness check on the read: `IRREGULAR['constructor']` is the
44
- // `Object` FUNCTION off the prototype chain, not `undefined`, and `splitWords` lowercases —
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
- const words = splitWords(name);
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 LocalRow, type Mutator, mutator } from './mutator';
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 & LocalRow>(def.localTable).update(input.id, {
129
+ tx.table<Row>(def.localTable).update(input.id, {
130
130
  [def.column]: input.to,
131
- } as Partial<Row & LocalRow>);
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
+ >;
@@ -13,5 +13,8 @@
13
13
  */
14
14
  export const BUILD_ID_HEADER = 'x-ultimate-build';
15
15
 
16
- /** RFC 9110's spelling, lower-cased, as `Headers` normalises it. */
17
- export const IDEMPOTENCY_HEADER = 'idempotency-key';
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';