@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/CLAUDE.md +169 -546
- package/README.md +61 -12
- package/package.json +7 -6
- package/src/client.ts +59 -85
- package/src/deprecation.ts +0 -5
- package/src/errors.ts +34 -2
- package/src/http.ts +67 -37
- package/src/index.ts +1 -4
- package/src/invoke.ts +4 -1
- package/src/mutator-clock.ts +22 -0
- package/src/mutator.ts +76 -59
- 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/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
|
|
|
@@ -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
|
|
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": "
|
|
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": "
|
|
38
|
-
"@ultimat3/core": "
|
|
39
|
-
"@ultimat3/
|
|
40
|
-
"@ultimat3/
|
|
41
|
-
"@ultimat3/
|
|
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,
|
|
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/deprecation.ts
CHANGED
|
@@ -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
|
-
*
|
|
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,15 +6,21 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { tagKeys } from '@ultimat3/cache';
|
|
9
|
-
import {
|
|
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,
|
|
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 {
|
|
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)
|
|
55
|
-
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|