@ultimat3/action 20.2.1 → 22.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/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
 
@@ -601,8 +623,8 @@ never a pass — the assertion says which code got in the way and names `input:`
601
623
  | `X_IDEMPOTENCY_NOT_SHARED` | `configureIdempotency({ scope: 'shared' })` over a per-process (or scope-less) store | install `postgresIdempotencyStore({ executor })` at boot |
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
- | `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 |
626
+ | `X_CONTRACT_DRIFT` | client/server build skew, missing spec entry | reload / `x verify --only contract` |
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 |
@@ -623,7 +645,34 @@ cannot read is dropped whole rather than half-kept, leaving `cause` (which still
623
645
  rejection) as the answer. It is exported for the island that posts with a plain `fetch` and holds
624
646
  the body itself.
625
647
 
648
+ ### Error classes
649
+
650
+ Every error class `src/index.ts` exports, for `instanceof` inside one process. Across a wire or
651
+ a job boundary the class is gone and the `code` is what survives — match on that.
652
+
653
+ | Class | Code | Declared in |
654
+ |---|---|---|
655
+ | `ActionDeniedError` | the policy denial's own code (`X_FORBIDDEN`, `X_UNAUTHENTICATED`, …), kept on `.denial` | `src/errors.ts` |
656
+ | `ActionDeprecationInvalidError` | `X_ACTION_DEPRECATION_INVALID` | `src/errors.ts` |
657
+ | `ActionDuplicateError` | `X_ACTION_DUPLICATE` | `src/errors.ts` |
658
+ | `ActionForeignError` | `X_ACTION_FOREIGN` | `src/errors.ts` |
659
+ | `ActionPathDuplicateError` | `X_ACTION_PATH_DUPLICATE` | `src/errors.ts` |
660
+ | `ActionPolicyMissingError` | `X_ACTION_POLICY_MISSING` | `src/errors.ts` |
661
+ | `ActionUnregisteredError` | `X_ACTION_UNREGISTERED` | `src/errors.ts` |
662
+ | `AuditSinkFailedError` | `X_AUDIT_SINK_FAILED` | `src/errors.ts` |
663
+ | `AuditSinkMissingError` | `X_AUDIT_SINK_MISSING` | `src/errors.ts` |
664
+ | `ContractDriftError` | `X_CONTRACT_DRIFT` | `src/errors.ts` |
665
+ | `IdempotencyConflictError` | `X_IDEMPOTENCY_CONFLICT` | `src/errors-idempotency.ts` |
666
+ | `IdempotencyKeyInvalidError` | `X_IDEMPOTENCY_KEY_INVALID` | `src/errors-idempotency.ts` |
667
+ | `IdempotencyNotSharedError` | `X_IDEMPOTENCY_NOT_SHARED` | `src/errors-idempotency.ts` |
668
+ | `IdempotencyReplayedFailureError` | `X_IDEMPOTENCY_REPLAYED_FAILURE` | `src/errors-idempotency.ts` |
669
+ | `IdempotencyStatusUnknownError` | `X_IDEMPOTENCY_STATUS_UNKNOWN` | `src/errors-idempotency.ts` |
670
+ | `InputInvalidError` | `X_INPUT_INVALID` | `src/errors.ts` |
671
+ | `OutputInvalidError` | `X_OUTPUT_INVALID` | `src/errors.ts` |
672
+ | `RemoteActionError` | the code the server sent, verbatim — `meta.origin: 'remote'` | `src/errors.ts` |
673
+ | `RpcFailedError` | `X_RPC_FAILED` | `src/errors.ts` |
674
+
626
675
  ## Boundaries
627
676
 
628
- Tier 3. Imports `@ultimat3/core`, `schema`, `cache`, `policy`, `http`. Never imports
677
+ Tier 3. Imports `@ultimat3/core`, `schema`, `cache`, `entity`, `policy`, `http`. Never imports
629
678
  `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.1",
3
+ "version": "22.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.1",
38
- "@ultimat3/core": "20.2.1",
39
- "@ultimat3/http": "20.2.1",
40
- "@ultimat3/policy": "20.2.1",
41
- "@ultimat3/schema": "20.2.1"
37
+ "@ultimat3/cache": "22.0.0",
38
+ "@ultimat3/core": "22.0.0",
39
+ "@ultimat3/entity": "22.0.0",
40
+ "@ultimat3/http": "22.0.0",
41
+ "@ultimat3/policy": "22.0.0",
42
+ "@ultimat3/schema": "22.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,
@@ -75,8 +75,3 @@ export function renderDeprecation(
75
75
  };
76
76
  return { ok: true, headers, meta };
77
77
  }
78
-
79
- /** Set on a response that already exists, so a redirect and a problem document carry them too. */
80
- export function applyHeaders(response: Response, headers: Readonly<Record<string, string>>): void {
81
- for (const [name, value] of Object.entries(headers)) response.headers.set(name, value);
82
- }
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,15 +6,21 @@
6
6
  */
7
7
 
8
8
  import { tagKeys } from '@ultimat3/cache';
9
- import { isMcpExposed, isUltimateError } from '@ultimat3/core';
9
+ import {
10
+ isMcpExposed,
11
+ RECORDS_OPENAPI_HEADER,
12
+ recordEnvelopeSchema,
13
+ withWriteOrigin,
14
+ writeDigest,
15
+ } from '@ultimat3/core';
10
16
  import type { Route, RouteMeta, UltimateRequest } from '@ultimat3/http';
11
17
  // `toBucket` is `@ultimat3/http`'s, not this package's: http owns `Bucket` and the limiter maths,
12
18
  // and `@ultimat3/query` needs the identical conversion while being the same tier as this one — so
13
19
  // a copy here would be a second answer to "what does this limit mean" for the read half.
14
- import { json, problem, redirect, takeRedirect, toBucket } from '@ultimat3/http';
20
+ import { json, redirect, takeRedirect, toBucket } from '@ultimat3/http';
15
21
  import type { ActionRateLimit, AnyAction } from './action';
16
22
  import type { Deprecation } from './deprecation';
17
- import { applyHeaders, recordDeprecatedCall, renderDeprecation } from './deprecation';
23
+ import { recordDeprecatedCall, renderDeprecation } from './deprecation';
18
24
  import { ActionDeprecationInvalidError } from './errors';
19
25
  import { actionName, defOf, invoke } from './invoke';
20
26
  import {
@@ -26,6 +32,7 @@ import {
26
32
  toOperationId,
27
33
  } from './naming';
28
34
  import { admitsAnonymous, policyCapability } from './policy-gate';
35
+ import { carriesRecords, recordResponse } from './record-wire';
29
36
  import { IDEMPOTENCY_HEADER } from './wire-headers';
30
37
 
31
38
  /**
@@ -37,6 +44,12 @@ export { BUILD_ID_HEADER, IDEMPOTENCY_HEADER } from './wire-headers';
37
44
 
38
45
  export const REPLAYED_HEADER = 'x-ultimate-replayed';
39
46
 
47
+ /** The digest a request's idempotency key names its write by; none for a missing or blank one. */
48
+ async function writeOriginOf(req: UltimateRequest): Promise<string | undefined> {
49
+ const key = req.header(IDEMPOTENCY_HEADER);
50
+ return key === null || key === '' ? undefined : await writeDigest(key);
51
+ }
52
+
40
53
  /**
41
54
  * `publishPost` -> `POST /api/posts/publish`. Derivation: the first camelCase word
42
55
  * is the verb, the rest is the resource with its last word pluralized and
@@ -49,43 +62,53 @@ export function toRoute(target: AnyAction): Route {
49
62
  // Rendered ONCE, at projection: a date that cannot become a header is a mount-time refusal,
50
63
  // not a surprise on the first request — the same rule `toBucket` follows for a rate limit.
51
64
  const sunsetting = deprecationHeadersFor(name, def.deprecated);
65
+ const enveloped = carriesRecords(target.output);
52
66
 
53
67
  const handler = async (req: UltimateRequest): Promise<Response> => {
54
- if (sunsetting !== undefined) recordDeprecatedCall('action', name);
55
- try {
56
- // The pipeline already parsed and size-capped the body; parsing it again here
57
- // would be a second, differently-behaved parser for the same bytes.
58
- const raw = await req.bodyRaw();
59
- const key = def.idempotent === true ? req.header(IDEMPOTENCY_HEADER) : null;
60
- let replayed = false;
61
- const result = await invoke(target, raw, {
68
+ if (sunsetting !== undefined) {
69
+ recordDeprecatedCall('action', name);
70
+ // On the CONTEXT, before anything can fail: the pipeline's `response` stage merges
71
+ // `ctx.headers` into whatever answers — this handler's 200 and the `error-map` stage's
72
+ // problem or error page alike. A client polling a deprecated endpoint that is currently
73
+ // 403ing still has to learn the endpoint is going away.
74
+ for (const [header, value] of Object.entries(sunsetting)) req.ctx.headers.set(header, value);
75
+ }
76
+ // No `catch`: an error — framework or not — takes the path every route's error takes. This
77
+ // used to answer `problem(error)` itself, so the `error-map` stage never saw it: no
78
+ // `onError`/`reportError` on a 5xx, no HTML error page or sign-in redirect for a browser, no
79
+ // `requestId`, no `Retry-After`.
80
+ //
81
+ // The pipeline already parsed and size-capped the body; parsing it again here
82
+ // would be a second, differently-behaved parser for the same bytes.
83
+ const raw = await req.bodyRaw();
84
+ const key = def.idempotent === true ? req.header(IDEMPOTENCY_HEADER) : null;
85
+ let replayed = false;
86
+ // The header NAMES the write whatever the declaration, idempotent or not: a page sends one
87
+ // with every mutation, and the `records` frames its rows produce carry the digest so that
88
+ // page can tell its own echo from somebody else's change (`@ultimat3/core`'s write origin).
89
+ const result = await withWriteOrigin(await writeOriginOf(req), () =>
90
+ invoke(target, raw, {
62
91
  surface: 'http',
63
92
  idempotencyKey: key,
64
93
  onReplay: () => {
65
94
  replayed = true;
66
95
  },
67
- });
68
- // The one thing an action's return value cannot say. `setRedirect()` inside the handler
69
- // is how a `<form method="post">` gets an answer a browser follows — a `Location` on the
70
- // 200 this used to always return is a header browsers ignore, so a JS-less form left the
71
- // reader staring at `{"ok":true}`. Only this projection honours it: a redirect is an HTTP
72
- // fact, and the MCP tool and the job handle share none of it.
73
- const to = takeRedirect(req.ctx);
74
- const response = to === undefined ? json(result) : redirect(to.location, to.status);
75
- if (key !== null) response.headers.set(REPLAYED_HEADER, replayed ? '1' : '0');
76
- // On the failure path too, below: a client polling a deprecated endpoint that is currently
77
- // 403ing still has to learn the endpoint is going away. Announcing it only on 200 hides the
78
- // sunset from exactly the callers most likely to be stale.
79
- if (sunsetting !== undefined) applyHeaders(response, sunsetting);
80
- return response;
81
- } catch (error) {
82
- // Framework errors carry their own code, status and fix line; anything else is
83
- // a bug and belongs to the server's error boundary, not to this route.
84
- if (!isUltimateError(error)) throw error;
85
- const response = problem(error);
86
- if (sunsetting !== undefined) applyHeaders(response, sunsetting);
87
- return response;
88
- }
96
+ }),
97
+ );
98
+ // The one thing an action's return value cannot say. `setRedirect()` inside the handler
99
+ // is how a `<form method="post">` gets an answer a browser follows — a `Location` on the
100
+ // 200 this used to always return is a header browsers ignore, so a JS-less form left the
101
+ // reader staring at `{"ok":true}`. Only this projection honours it: a redirect is an HTTP
102
+ // fact, and the MCP tool and the job handle share none of it.
103
+ const to = takeRedirect(req.ctx);
104
+ const response =
105
+ to !== undefined
106
+ ? redirect(to.location, to.status)
107
+ : enveloped
108
+ ? recordResponse(target.output, result)
109
+ : json(result);
110
+ if (key !== null) response.headers.set(REPLAYED_HEADER, replayed ? '1' : '0');
111
+ return response;
89
112
  };
90
113
 
91
114
  const meta: RouteMeta = {
@@ -148,6 +171,8 @@ export function toOpenApiOperation(target: AnyAction): OpenApiOperation {
148
171
  const path = derivePath(name);
149
172
  const idempotent = def.idempotent === true;
150
173
  const deprecation = deprecationMetaFor(name, def.deprecated);
174
+ const outputRef = schemaRef(outputSchemaName(name));
175
+ const enveloped = carriesRecords(target.output);
151
176
  return {
152
177
  operationId: toOperationId(name),
153
178
  tags: [path.resource],
@@ -159,10 +184,15 @@ export function toOpenApiOperation(target: AnyAction): OpenApiOperation {
159
184
  content: { 'application/json': { schema: { $ref: schemaRef(inputSchemaName(name)) } } },
160
185
  },
161
186
  responses: {
162
- '200': {
163
- description: 'ok',
164
- content: { 'application/json': { schema: { $ref: schemaRef(outputSchemaName(name)) } } },
165
- },
187
+ // Only an output that references an entity row changes shape: every other operation's
188
+ // bytes are the ones `x verify`'s contract diff already holds.
189
+ '200': enveloped
190
+ ? {
191
+ description: 'ok',
192
+ headers: RECORDS_OPENAPI_HEADER,
193
+ content: { 'application/json': { schema: recordEnvelopeSchema({ $ref: outputRef }) } },
194
+ }
195
+ : { description: 'ok', content: { 'application/json': { schema: { $ref: outputRef } } } },
166
196
  // BOTH, because they are two different failures and this operation published only one of
167
197
  // them while the route answered only the other. `X_INPUT_INVALID` is the body that parsed
168
198
  // 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';
package/src/invoke.ts CHANGED
@@ -253,7 +253,10 @@ async function perform(
253
253
  if (outcome.replayed) options.onReplay?.();
254
254
  trace.replayed = outcome.replayed;
255
255
  wrote = !outcome.replayed;
256
- value = outcome.value;
256
+ // A replay is parsed like a first call. The memory store hands back the live value and the
257
+ // postgres store `JSON.parse(JSON.stringify(v))` — a `Date` on the first call and a `string`
258
+ // on every retry served from Postgres — so the output schema is the one answer to its shape.
259
+ value = outcome.replayed ? validateOutput(def.output, outcome.value, name) : outcome.value;
257
260
  }
258
261
  // Only for a run that actually happened, and only through the gate. A replay ran no handler
259
262
  // and changed nothing the first call had not already busted — re-busting per retry re-purges
@@ -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
+ }