@ultimat3/action 11.0.0 → 11.2.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 +59 -1
- package/README.md +32 -0
- package/package.json +9 -6
- package/src/client.ts +72 -45
- package/src/errors.ts +7 -0
- package/src/http.ts +8 -4
- package/src/index.ts +26 -0
- package/src/stable.ts +7 -4
- package/src/wire-headers.ts +17 -0
package/CLAUDE.md
CHANGED
|
@@ -21,6 +21,8 @@ Owns the `action` + `mutator` primitives and their six projections. Tier 3.
|
|
|
21
21
|
| `http.ts` | route projection (`enforcedBy: 'handler'`) + OpenAPI operation |
|
|
22
22
|
| `openapi.ts` | deterministic OpenAPI 3.1 document |
|
|
23
23
|
| `client.ts` | typed RPC client (browser-safe: no server imports) |
|
|
24
|
+
| — | 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 |
|
|
25
|
+
| `wire-headers.ts` | `BUILD_ID_HEADER` + `IDEMPOTENCY_HEADER`, and nothing else. Their own module so `client.ts` can name them without importing `http.ts` |
|
|
24
26
|
| `mcp-tool.ts` | MCP descriptor, same `invoke` |
|
|
25
27
|
| `job-handle.ts` | the `.job()` projection: an action as a queueable payload. **Not** consumed by `@ultimat3/jobs` — see Invariants |
|
|
26
28
|
| `contract-test.ts` | assertions `x g action` emits |
|
|
@@ -35,7 +37,7 @@ Owns the `action` + `mutator` primitives and their six projections. Tier 3.
|
|
|
35
37
|
| `audit.ts` | the audit seam: `AuditRecord`, `AuditSink`, the memory sink, the installed-sink store |
|
|
36
38
|
| `audit-gate.ts` | **the only** file that calls a sink, and where the two failure policies live |
|
|
37
39
|
| `type-pins.ts` | compile-time assertions `tsc` checks — what the erased view projects, and why `client()` is not part of it |
|
|
38
|
-
| `naming.ts`, `validate.ts`, `json-schema.ts`, `stable.ts` | pure helpers. `stable.ts` is the DOCUMENT serializer
|
|
40
|
+
| `naming.ts`, `validate.ts`, `json-schema.ts`, `stable.ts` | pure helpers. `stable.ts` is the DOCUMENT serializer plus a re-export of core's `isJsonObject` — the hash form is `@ultimat3/core`'s `canonicalJson`/`fingerprint` |
|
|
39
41
|
|
|
40
42
|
## Invariants
|
|
41
43
|
|
|
@@ -435,6 +437,62 @@ Owns the `action` + `mutator` primitives and their six projections. Tier 3.
|
|
|
435
437
|
same-named helpers overwrite each other with no `X_ACTION_DUPLICATE` to raise. The type does the
|
|
436
438
|
same filter, so `rpc<Api['actions']>()` offers only what registered.
|
|
437
439
|
- `rpc` is the only name for the map-wide typed client. There is no `createClient` alias.
|
|
440
|
+
- **Flight control is `@ultimat3/core`'s, and this package RE-EXPORTS it.** `client-flight.ts` and
|
|
441
|
+
`client-wire.ts` shipped here and in the other tier-3 client package as byte-identical copies —
|
|
442
|
+
288 and 85 lines — kept in step by a `client-twin.test.ts` in each. A test that makes drift LOUD
|
|
443
|
+
is not the same as a file that cannot drift, and this package's own thesis is that duplication is
|
|
444
|
+
the defect. Both files import nothing but tier 0, which was always the argument for where they
|
|
445
|
+
belong; the one blocker was `isJsonObject`, now `@ultimat3/core`'s `json-object.ts`, re-exported
|
|
446
|
+
from `./stable` here. `createClientFlight`, `DEFAULT_CLIENT_RETRY`, `isTransientFailure`,
|
|
447
|
+
`isSuperseded`, `ClientFlight`, `ClientFlightOptions`, `ClientRetry`, `FlightKeyOptions`,
|
|
448
|
+
`FlightPlan` and `WireAnswer` are all still importable from `@ultimat3/action` — the same names,
|
|
449
|
+
and now literally the same objects the other package exports. Never re-declare one here; the
|
|
450
|
+
fix for anything wrong with the pipeline is an edit in `packages/core/src/client-flight.ts`.
|
|
451
|
+
- **Every mechanism underneath the flight is `@ultimat3/core`'s, the pipeline included.**
|
|
452
|
+
`createSingleFlight` for dedup, `createFence`/`isSuperseded` for supersession, `createFlightGate`
|
|
453
|
+
for the ceiling, `retry` + `backoffDelay` for the schedule, `isRetryableStatus` for the status
|
|
454
|
+
table, `X_TIMEOUT` for the deadline — and `createClientFlight`, which composes them. This package
|
|
455
|
+
declares NO new error code for any of it; never add a second curve, a second fence or a private
|
|
456
|
+
retry loop here, and `bun run flight-copies` is what says so.
|
|
457
|
+
- **`isTransientFailure` INVERTS `retryDecision`'s unclassified default, and the inversion must
|
|
458
|
+
survive** (`As of 2026-08-23`). `retryDecision` sends a throw nobody classified again until the
|
|
459
|
+
attempts run out; `@ultimat3/ai` and `@ultimat3/db` each refused the executor outright over it.
|
|
460
|
+
The client keeps the executor and supplies a predicate instead: a declared
|
|
461
|
+
`retryable`/`retry-after`, plus a dispatch that produced no response at all (`fetch` rejecting
|
|
462
|
+
with a plain `TypeError`), and nothing else — a caller's own `AbortError` and a foreign value are
|
|
463
|
+
terminal. The loop is stopped by RESOLVING to a private sentinel rather than by throwing, so the
|
|
464
|
+
original value still reaches the caller unwrapped, which is the property `retry`'s own header
|
|
465
|
+
promises. It lives in `packages/core/src/client-flight.ts` now; the tests that pin it from this
|
|
466
|
+
side still drive it through this package's own client.
|
|
467
|
+
- **`ClientFlight` is a TYPE inside `client.ts` and never a value.** That erasure is the entire
|
|
468
|
+
tree-shaking story: `rpc` alone is 14,759 B minified for the browser and `queryClient` alone is
|
|
469
|
+
12,755 B, against 20,292 B / 17,912 B with `createClientFlight` imported beside them — ±376 B run
|
|
470
|
+
to run, which is `Bun.build` 1.4.0 dropping core's `schema-error-codes.ts` (issue #273). A caller
|
|
471
|
+
who wants a plain typed fetch must not pay for the fence, the dedup map or the retry loop —
|
|
472
|
+
`packages/cli/src/templates/resource-form-island.ts` and `examples/dummy`'s contact-sales island
|
|
473
|
+
both write a bare `fetch` today because that bill used to be unavoidable. Never import
|
|
474
|
+
`createClientFlight` for a VALUE from `client.ts` — `ClientFlight` and `ClientRetry` are
|
|
475
|
+
`import type` from `@ultimat3/core` and must stay that way.
|
|
476
|
+
- **The `sideEffects` array is what makes the barrel shakable, and it is load-bearing** (`As of
|
|
477
|
+
2026-08-23`). Declaring nothing meant a bundler had to assume every module ran at import, so
|
|
478
|
+
`import { rpc } from '@ultimat3/action'` was 43,104 B and `import { queryClient } from
|
|
479
|
+
'@ultimat3/query'` was 40,859 B — three times the deep-import cost, through the ONLY specifier
|
|
480
|
+
the `exports` map offers. The arrays are the ones `bun run scripts/side-effects.ts --explain
|
|
481
|
+
--json` measures, and they must stay that: `errors.ts` runs `registerErrorCodes` at import in both
|
|
482
|
+
packages, and query's `registry.ts` runs `registerPrimitiveRegistrar('query', …)` — drop either
|
|
483
|
+
and a bundled app loses its error titles or throws `X_REGISTRAR_MISSING`. Never `false`.
|
|
484
|
+
- **A retried mutation is gated on an `Idempotency-Key`, and the gate is silent narrowing rather
|
|
485
|
+
than a refusal** (`As of 2026-08-23`). `CallOptions.retry` is honoured only alongside
|
|
486
|
+
`idempotencyKey`; without one the call is narrowed to a single attempt. A second POST with no key
|
|
487
|
+
is a second WRITE, and nothing at this seam can tell a lost answer from a lost request. A refusal
|
|
488
|
+
was the other candidate and was rejected: `retry:` may be set once on the flight for a whole
|
|
489
|
+
client, and turning every keyless call in an app into a thrown error would make the flight
|
|
490
|
+
un-installable. `client-flight.test.ts` pins both halves, and the header on every attempt.
|
|
491
|
+
- **A fence never aborts a write, and `client.ts` never calls `flight.keyFor`.** The first because
|
|
492
|
+
closing a mutation's socket does not un-commit it — it only destroys the one chance the caller had
|
|
493
|
+
of learning whether it landed, so `abortable: false` is unconditional and the caller still gets
|
|
494
|
+
`X_SUPERSEDED` for the ANSWER. The second is how "a mutation may never join another mutation" is
|
|
495
|
+
enforced: there is no dedup path to reach from here, even with a principal installed.
|
|
438
496
|
- **`registerAction` guards the derived PATH as well as the name.** `X_ACTION_DUPLICATE` only ever
|
|
439
497
|
asked about the name, so `archiveOrder` and `archiveOrders` — one route, by `pluralize`'s
|
|
440
498
|
deliberate "a trailing `s` is already plural" rule — both registered and both projected: the
|
package/README.md
CHANGED
|
@@ -116,6 +116,38 @@ 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
|
+
### Flight control — `createClientFlight`, opt-in
|
|
120
|
+
|
|
121
|
+
Same object as `@ultimat3/query`'s, installed the same way (`rpc({ baseUrl, flight })`), and the
|
|
122
|
+
write half is the half made of refusals:
|
|
123
|
+
|
|
124
|
+
| Rule | Why |
|
|
125
|
+
|---|---|
|
|
126
|
+
| a mutation **never** joins another mutation | `client.ts` never calls `flight.keyFor`, so there is no dedup path to reach; two writes are two writes |
|
|
127
|
+
| a fence bump **never** aborts a write | closing the socket does not un-commit it, it only destroys the one chance this caller had of learning whether it landed. The caller still gets `X_SUPERSEDED` — the answer is retired, the request is not |
|
|
128
|
+
| `retry` is honoured only alongside an `idempotencyKey` | a retried POST without one is a second write. Without a key the call is narrowed to a single attempt, silently and by construction |
|
|
129
|
+
| the same `Idempotency-Key` rides every attempt | that is what makes the retry a retry rather than a duplicate |
|
|
130
|
+
|
|
131
|
+
```ts
|
|
132
|
+
declare const api: { charge: (input: { orderId: string }, options?: {
|
|
133
|
+
idempotencyKey?: string; retry?: { attempts: number };
|
|
134
|
+
}) => Promise<unknown> };
|
|
135
|
+
declare const orderId: string;
|
|
136
|
+
|
|
137
|
+
await api.charge({ orderId }, { idempotencyKey: `charge:${orderId}`, retry: { attempts: 3 } });
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
`createClientFlight` is **`@ultimat3/core`'s**, re-exported here: it is the same object
|
|
141
|
+
`@ultimat3/query` re-exports, because both packages are tier 3 and neither may import the other.
|
|
142
|
+
It shipped as a byte-identical copy in each; the copies are gone and every name is importable from
|
|
143
|
+
this package exactly as before.
|
|
144
|
+
|
|
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.
|
|
150
|
+
|
|
119
151
|
## Path derivation
|
|
120
152
|
|
|
121
153
|
First camelCase word is the verb; the rest is the resource, last word pluralized,
|
package/package.json
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/action",
|
|
3
|
-
"version": "11.
|
|
3
|
+
"version": "11.2.0",
|
|
4
4
|
"description": "The action primitive: one declaration projected to route, OpenAPI, client, MCP tool, job handle, tests",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
|
+
"sideEffects": [
|
|
8
|
+
"./src/errors.ts"
|
|
9
|
+
],
|
|
7
10
|
"repository": {
|
|
8
11
|
"type": "git",
|
|
9
12
|
"url": "git+https://github.com/developerz-ai/ultimate.git",
|
|
@@ -31,10 +34,10 @@
|
|
|
31
34
|
"test": "bun test"
|
|
32
35
|
},
|
|
33
36
|
"dependencies": {
|
|
34
|
-
"@ultimat3/cache": "11.
|
|
35
|
-
"@ultimat3/core": "11.
|
|
36
|
-
"@ultimat3/http": "11.
|
|
37
|
-
"@ultimat3/policy": "11.
|
|
38
|
-
"@ultimat3/schema": "11.
|
|
37
|
+
"@ultimat3/cache": "11.2.0",
|
|
38
|
+
"@ultimat3/core": "11.2.0",
|
|
39
|
+
"@ultimat3/http": "11.2.0",
|
|
40
|
+
"@ultimat3/policy": "11.2.0",
|
|
41
|
+
"@ultimat3/schema": "11.2.0"
|
|
39
42
|
}
|
|
40
43
|
}
|
package/src/client.ts
CHANGED
|
@@ -2,15 +2,20 @@
|
|
|
2
2
|
* Projection 3: the typed RPC client. Types come from the action map, paths from
|
|
3
3
|
* the same pure derivation the server uses, so a renamed or mistyped action is a
|
|
4
4
|
* compile error in a Solid component — not a 404 at runtime.
|
|
5
|
+
*
|
|
6
|
+
* `ClientFlight` is a TYPE here and never a value: the fence, the retry loop and the deadline are
|
|
7
|
+
* `@ultimat3/core`'s `client-flight.ts`, so a caller that never calls `createClientFlight` does
|
|
8
|
+
* not pay a byte for any of them — an `import type` is erased and the value import would not be.
|
|
9
|
+
* Dedup is deliberately unreachable from this file — a mutation may never join another mutation,
|
|
10
|
+
* and the way that is guaranteed is that `keyFor` is never called here.
|
|
5
11
|
*/
|
|
6
|
-
import type { UltimateError } from '@ultimat3/core';
|
|
7
|
-
import {
|
|
12
|
+
import type { ClientFlight, ClientRetry, UltimateError, WireAnswer } from '@ultimat3/core';
|
|
13
|
+
import { FRAMEWORK_CODE, problemOf, traceHeaders } from '@ultimat3/core';
|
|
8
14
|
import type { InferInput, InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
|
|
9
15
|
import type { Action } from './action';
|
|
10
16
|
import { ContractDriftError, RemoteActionError, RpcFailedError } from './errors';
|
|
11
|
-
import { BUILD_ID_HEADER, IDEMPOTENCY_HEADER } from './http';
|
|
12
17
|
import { derivePath } from './naming';
|
|
13
|
-
import {
|
|
18
|
+
import { BUILD_ID_HEADER, IDEMPOTENCY_HEADER } from './wire-headers';
|
|
14
19
|
|
|
15
20
|
/**
|
|
16
21
|
* Loose constraint on purpose: a map of concrete `Action<In, Out>` values must be
|
|
@@ -26,6 +31,12 @@ export type ActionMap = Record<string, ActionLike>;
|
|
|
26
31
|
export interface CallOptions {
|
|
27
32
|
readonly idempotencyKey?: string;
|
|
28
33
|
readonly signal?: AbortSignal;
|
|
34
|
+
/**
|
|
35
|
+
* Retry THIS call. Honoured only alongside an `idempotencyKey`, and silently narrowed to one
|
|
36
|
+
* attempt without one — a retried mutation with no key is a second write, not a second attempt,
|
|
37
|
+
* and the framework has no way to tell a lost answer from a lost request.
|
|
38
|
+
*/
|
|
39
|
+
readonly retry?: ClientRetry;
|
|
29
40
|
}
|
|
30
41
|
|
|
31
42
|
/** `api.publishPost({ postId })` with both sides of the schema inferred. */
|
|
@@ -48,6 +59,11 @@ export interface ClientOptions {
|
|
|
48
59
|
/** Sent on every call; a differing server build id raises X_CONTRACT_DRIFT. */
|
|
49
60
|
readonly buildId?: string;
|
|
50
61
|
readonly headers?: Readonly<Record<string, string>>;
|
|
62
|
+
/**
|
|
63
|
+
* Opt-in flight control — `createClientFlight({ … })`. Absent, a call is one `fetch` and nothing
|
|
64
|
+
* else, which is what every caller written before this option existed already gets.
|
|
65
|
+
*/
|
|
66
|
+
readonly flight?: ClientFlight;
|
|
51
67
|
}
|
|
52
68
|
|
|
53
69
|
/**
|
|
@@ -87,6 +103,9 @@ export function clientMethodFor<TInput extends StandardSchemaV1, TOutput extends
|
|
|
87
103
|
call(doFetch, base, options, name, input, callOptions) as Promise<InferOutput<TOutput>>;
|
|
88
104
|
}
|
|
89
105
|
|
|
106
|
+
/** One attempt, and no retry at all. What a mutation carrying no idempotency key is allowed. */
|
|
107
|
+
const ONCE: ClientRetry = { attempts: 1 };
|
|
108
|
+
|
|
90
109
|
async function call(
|
|
91
110
|
doFetch: FetchLike,
|
|
92
111
|
base: string,
|
|
@@ -95,6 +114,40 @@ async function call(
|
|
|
95
114
|
input: unknown,
|
|
96
115
|
callOptions: CallOptions,
|
|
97
116
|
): Promise<unknown> {
|
|
117
|
+
const url = `${base}${derivePath(name).path}`;
|
|
118
|
+
const body = JSON.stringify(input ?? {});
|
|
119
|
+
const dispatch = (signal: AbortSignal | undefined): Promise<WireAnswer> =>
|
|
120
|
+
postOnce(doFetch, url, body, options, name, callOptions, signal ?? callOptions.signal);
|
|
121
|
+
|
|
122
|
+
const flight = options.flight;
|
|
123
|
+
const answer =
|
|
124
|
+
flight === undefined
|
|
125
|
+
? await dispatch(undefined)
|
|
126
|
+
: await flight.run({
|
|
127
|
+
// `undefined`, unconditionally: a mutation may never join another mutation, and the
|
|
128
|
+
// enforcement is that this file never calls `flight.keyFor`.
|
|
129
|
+
key: undefined,
|
|
130
|
+
// NEVER aborted. A fence bump and a deadline both mean "this answer no longer matters";
|
|
131
|
+
// closing the socket does not un-commit the write, it only destroys the one chance this
|
|
132
|
+
// caller had of learning whether it landed.
|
|
133
|
+
abortable: false,
|
|
134
|
+
retry: callOptions.idempotencyKey === undefined ? ONCE : (callOptions.retry ?? ONCE),
|
|
135
|
+
run: dispatch,
|
|
136
|
+
});
|
|
137
|
+
if (answer.status === 204) return undefined;
|
|
138
|
+
return JSON.parse(answer.text) as unknown;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** One dispatch. Everything above it decides how many times this happens; it decides none. */
|
|
142
|
+
async function postOnce(
|
|
143
|
+
doFetch: FetchLike,
|
|
144
|
+
url: string,
|
|
145
|
+
body: string,
|
|
146
|
+
options: ClientOptions,
|
|
147
|
+
name: string,
|
|
148
|
+
callOptions: CallOptions,
|
|
149
|
+
signal: AbortSignal | undefined,
|
|
150
|
+
): Promise<WireAnswer> {
|
|
98
151
|
const headers: Record<string, string> = {
|
|
99
152
|
'content-type': 'application/json',
|
|
100
153
|
// Before the caller's headers, so an explicit `traceparent` still wins. Without this a
|
|
@@ -111,36 +164,16 @@ async function call(
|
|
|
111
164
|
const init: RequestInit = {
|
|
112
165
|
method: 'POST',
|
|
113
166
|
headers,
|
|
114
|
-
body
|
|
115
|
-
...(
|
|
167
|
+
body,
|
|
168
|
+
...(signal === undefined ? {} : { signal }),
|
|
116
169
|
};
|
|
117
|
-
const response = await doFetch(
|
|
170
|
+
const response = await doFetch(url, init);
|
|
118
171
|
assertSameBuild(options.buildId, response.headers.get(BUILD_ID_HEADER), name);
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/** A `traceparent` is `00-<32 hex>-<16 hex>-<2 hex>`, and nothing else may be sent as one. */
|
|
126
|
-
const TRACE_ID = /^[0-9a-f]{32}$/;
|
|
127
|
-
const SPAN_ID = /^[0-9a-f]{16}$/;
|
|
128
|
-
|
|
129
|
-
/**
|
|
130
|
-
* The current trace, as the W3C header — or nothing at all. `currentSpanContext()` answers with
|
|
131
|
-
* an empty `spanId` when a request context exists but no span is active, and `00-<trace>--01` is
|
|
132
|
-
* a header every collector drops, so an incomplete context sends none. In a browser there is no
|
|
133
|
-
* ambient context and this is always empty, which is also what keeps a cross-origin GET from
|
|
134
|
-
* acquiring a CORS preflight it did not have.
|
|
135
|
-
*
|
|
136
|
-
* `@ultimat3/query`'s client carries the twin of this function: both are tier 3, so neither may
|
|
137
|
-
* import the other.
|
|
138
|
-
*/
|
|
139
|
-
function traceHeaders(): Record<string, string> {
|
|
140
|
-
const context = currentSpanContext();
|
|
141
|
-
if (context === undefined) return {};
|
|
142
|
-
if (!TRACE_ID.test(context.traceId) || !SPAN_ID.test(context.spanId)) return {};
|
|
143
|
-
return { traceparent: traceparent(context) };
|
|
172
|
+
// Read as TEXT once: a `Response` body is a single-use stream, so the failure path and the
|
|
173
|
+
// answer path cannot both have it.
|
|
174
|
+
const text = response.status === 204 ? '' : await response.text();
|
|
175
|
+
if (!response.ok) throw toUltimateError(text, response.status, name);
|
|
176
|
+
return { status: response.status, text };
|
|
144
177
|
}
|
|
145
178
|
|
|
146
179
|
/**
|
|
@@ -160,13 +193,6 @@ function assertSameBuild(
|
|
|
160
193
|
);
|
|
161
194
|
}
|
|
162
195
|
|
|
163
|
-
/**
|
|
164
|
-
* A framework code, spelled the one way codes are spelled. `typeof code === 'string'` alone
|
|
165
|
-
* accepted `""` and `"error"` — a gateway's JSON body became an `UltimateError` whose code
|
|
166
|
-
* nothing in the framework or the app declares, rendering `: ` under a humanised title.
|
|
167
|
-
*/
|
|
168
|
-
const FRAMEWORK_CODE = /^X_[A-Z0-9]+(?:_[A-Z0-9]+)*$/;
|
|
169
|
-
|
|
170
196
|
/**
|
|
171
197
|
* `application/problem+json` back into the error the server threw. The code rides along
|
|
172
198
|
* verbatim — carrying one is the point of the document — but it is a code this bundle may never
|
|
@@ -174,18 +200,19 @@ const FRAMEWORK_CODE = /^X_[A-Z0-9]+(?:_[A-Z0-9]+)*$/;
|
|
|
174
200
|
* to a page that exists. A body naming no framework code is a proxy answering rather than the
|
|
175
201
|
* app, which is what `RpcFailedError` already says.
|
|
176
202
|
*/
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
203
|
+
function toUltimateError(text: string, status: number, name: string): UltimateError {
|
|
204
|
+
// `problemOf` is total — a gateway's HTML, an empty body and a truncated stream all answer `{}`,
|
|
205
|
+
// which carries no `code` and therefore lands on `RpcFailedError` exactly as before.
|
|
206
|
+
const body = problemOf(text);
|
|
180
207
|
const code = body['code'];
|
|
181
208
|
if (typeof code !== 'string' || !FRAMEWORK_CODE.test(code)) {
|
|
182
|
-
return new RpcFailedError(name,
|
|
209
|
+
return new RpcFailedError(name, status);
|
|
183
210
|
}
|
|
184
211
|
return new RemoteActionError({
|
|
185
212
|
action: name,
|
|
186
|
-
status
|
|
213
|
+
status,
|
|
187
214
|
code,
|
|
188
|
-
cause: stringOr(body['cause'] ?? body['detail'], `${name} failed with ${
|
|
215
|
+
cause: stringOr(body['cause'] ?? body['detail'], `${name} failed with ${status}`),
|
|
189
216
|
fix: stringOr(body['fix'], `x actions describe ${name} --json`),
|
|
190
217
|
// RFC-9457's `type` IS a documentation URI, so a server that sends no `docs` extension has
|
|
191
218
|
// still offered one. Both travel, in preference order: `??` picked `docs` on presence alone,
|
package/src/errors.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
ERROR_DOCS_URL,
|
|
10
10
|
hasErrorCode,
|
|
11
11
|
registerErrorCodes,
|
|
12
|
+
retryForStatus,
|
|
12
13
|
UltimateError,
|
|
13
14
|
} from '@ultimat3/core';
|
|
14
15
|
import type { SurfaceDenial } from '@ultimat3/policy';
|
|
@@ -280,6 +281,11 @@ export class RemoteActionError extends UltimateError {
|
|
|
280
281
|
cause: failure.cause,
|
|
281
282
|
fix: failure.fix,
|
|
282
283
|
docs: remoteDocs(failure.code, failure.docs),
|
|
284
|
+
// The status is what says "send it again", and only where nobody has classified the code
|
|
285
|
+
// this build may never have heard of. `UltimateError` otherwise fills `retry` from
|
|
286
|
+
// `retryFor(code)`, which fails closed — so a 503 out of a typed call announced itself as
|
|
287
|
+
// `terminal` on the one field the framework promises a client never has to infer.
|
|
288
|
+
retry: retryForStatus(failure.code, failure.status),
|
|
283
289
|
meta: { origin: 'remote', action: failure.action, status: failure.status },
|
|
284
290
|
});
|
|
285
291
|
this.status = failure.status;
|
|
@@ -293,6 +299,7 @@ export class RpcFailedError extends UltimateError {
|
|
|
293
299
|
code: 'X_RPC_FAILED',
|
|
294
300
|
cause: `${name} returned HTTP ${status} without a problem+json body`,
|
|
295
301
|
fix: `check the gateway in front of the app, then: x actions describe ${name} --json`,
|
|
302
|
+
retry: retryForStatus('X_RPC_FAILED', status),
|
|
296
303
|
});
|
|
297
304
|
}
|
|
298
305
|
}
|
package/src/http.ts
CHANGED
|
@@ -26,11 +26,15 @@ import {
|
|
|
26
26
|
toOperationId,
|
|
27
27
|
} from './naming';
|
|
28
28
|
import { admitsAnonymous, policyCapability } from './policy-gate';
|
|
29
|
+
import { IDEMPOTENCY_HEADER } from './wire-headers';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Re-exported, never re-declared. The two strings moved to `wire-headers.ts` so `client.ts` can
|
|
33
|
+
* name them without pulling this file's server graph into a browser bundle; every importer of
|
|
34
|
+
* `./http` still reads them from here, which is what kept the move from touching a call site.
|
|
35
|
+
*/
|
|
36
|
+
export { BUILD_ID_HEADER, IDEMPOTENCY_HEADER } from './wire-headers';
|
|
29
37
|
|
|
30
|
-
/** Matches `HttpConfig.buildIdHeader`; the pipeline reads it into `ctx.clientBuildId` — the
|
|
31
|
-
* CLIENT's claim, never `ctx.buildId`, which is the build this process serves. */
|
|
32
|
-
export const BUILD_ID_HEADER = 'x-ultimate-build';
|
|
33
|
-
export const IDEMPOTENCY_HEADER = 'idempotency-key';
|
|
34
38
|
export const REPLAYED_HEADER = 'x-ultimate-replayed';
|
|
35
39
|
|
|
36
40
|
/**
|
package/src/index.ts
CHANGED
|
@@ -7,6 +7,32 @@
|
|
|
7
7
|
* framework died; there is exactly one here, structurally.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Flight control for the typed client, and OPT-IN by construction: `client.ts` names `ClientFlight`
|
|
12
|
+
* as a TYPE only, so a caller that never mentions `createClientFlight` pays nothing for the fence,
|
|
13
|
+
* the dedup map or the retry loop. Every mechanism underneath is `@ultimat3/core`'s — one fence,
|
|
14
|
+
* one flight map, one gate, one backoff curve for the whole framework — and so is the pipeline
|
|
15
|
+
* itself: it shipped as a byte-identical copy here and in `@ultimat3/query`, and
|
|
16
|
+
* two tier-3 packages may not import each other, so the one copy lives at tier 0.
|
|
17
|
+
*
|
|
18
|
+
* Re-exported rather than re-declared, so every name is importable from this package exactly as
|
|
19
|
+
* before. `isSuperseded` is core's too: reading a fenced answer is the point of installing a
|
|
20
|
+
* flight, and it should not cost a second import.
|
|
21
|
+
*/
|
|
22
|
+
export type {
|
|
23
|
+
ClientFlight,
|
|
24
|
+
ClientFlightOptions,
|
|
25
|
+
ClientRetry,
|
|
26
|
+
FlightKeyOptions,
|
|
27
|
+
FlightPlan,
|
|
28
|
+
WireAnswer,
|
|
29
|
+
} from '@ultimat3/core';
|
|
30
|
+
export {
|
|
31
|
+
createClientFlight,
|
|
32
|
+
DEFAULT_CLIENT_RETRY,
|
|
33
|
+
isSuperseded,
|
|
34
|
+
isTransientFailure,
|
|
35
|
+
} from '@ultimat3/core';
|
|
10
36
|
/**
|
|
11
37
|
* `toBucket` is `@ultimat3/http`'s — http owns `Bucket` and the limiter maths, and `action` and
|
|
12
38
|
* `query` are the same tier, so a copy in either is a second answer for the other. Re-exported
|
package/src/stable.ts
CHANGED
|
@@ -9,11 +9,14 @@
|
|
|
9
9
|
* published. Ordinary payloads are byte-identical between the two; `stable.test.ts` pins that.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
/**
|
|
13
|
+
* `@ultimat3/core`'s, re-exported so `./stable` stays this package's one import path for the JSON
|
|
14
|
+
* helpers. It was declared here AND identically in `@ultimat3/query`'s own `stable.ts`, which is
|
|
15
|
+
* the duplication `client-wire.ts` moving to tier 0 made unnecessary.
|
|
16
|
+
*/
|
|
17
|
+
export { isJsonObject } from '@ultimat3/core';
|
|
13
18
|
|
|
14
|
-
export
|
|
15
|
-
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
16
|
-
}
|
|
19
|
+
export type JsonObject = Record<string, unknown>;
|
|
17
20
|
|
|
18
21
|
/** JSON with object keys sorted at every depth. No timestamps, no insertion-order leaks. */
|
|
19
22
|
export function stableStringify(value: unknown, indent = 0): string {
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two request headers the typed client and the HTTP projection both name. Their own module and
|
|
3
|
+
* not `http.ts`'s, because `client.ts` needs exactly these two strings: importing them from there
|
|
4
|
+
* dragged `@ultimat3/http`, `@ultimat3/cache`, `@ultimat3/policy` and the whole `invoke` runtime
|
|
5
|
+
* into every browser bundle that called `rpc()` — 42,204 B against the read client's 11,977 B,
|
|
6
|
+
* measured, which is why two islands in this repo write a bare `fetch` instead of importing one.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Matches `HttpConfig.buildIdHeader`; the pipeline reads it into `ctx.clientBuildId` — the
|
|
11
|
+
* CLIENT's claim, never `ctx.buildId`, which is the build this process serves. A mismatch the
|
|
12
|
+
* client sees on the way back is `X_CONTRACT_DRIFT`.
|
|
13
|
+
*/
|
|
14
|
+
export const BUILD_ID_HEADER = 'x-ultimate-build';
|
|
15
|
+
|
|
16
|
+
/** RFC 9110's spelling, lower-cased, as `Headers` normalises it. */
|
|
17
|
+
export const IDEMPOTENCY_HEADER = 'idempotency-key';
|