@ultimat3/action 11.1.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 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 only — the hash form is `@ultimat3/core`'s `canonicalJson`/`fingerprint` |
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.1.0",
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.1.0",
35
- "@ultimat3/core": "11.1.0",
36
- "@ultimat3/http": "11.1.0",
37
- "@ultimat3/policy": "11.1.0",
38
- "@ultimat3/schema": "11.1.0"
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 { currentSpanContext, traceparent } from '@ultimat3/core';
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 { isJsonObject } from './stable';
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: JSON.stringify(input ?? {}),
115
- ...(callOptions.signal === undefined ? {} : { signal: callOptions.signal }),
167
+ body,
168
+ ...(signal === undefined ? {} : { signal }),
116
169
  };
117
- const response = await doFetch(`${base}${derivePath(name).path}`, init);
170
+ const response = await doFetch(url, init);
118
171
  assertSameBuild(options.buildId, response.headers.get(BUILD_ID_HEADER), name);
119
- if (!response.ok) throw await toUltimateError(response, name);
120
- if (response.status === 204) return undefined;
121
- const body: unknown = await response.json();
122
- return body;
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
- async function toUltimateError(response: Response, name: string): Promise<UltimateError> {
178
- const body: unknown = await response.json().catch(() => null);
179
- if (!isJsonObject(body)) return new RpcFailedError(name, response.status);
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, response.status);
209
+ return new RpcFailedError(name, status);
183
210
  }
184
211
  return new RemoteActionError({
185
212
  action: name,
186
- status: response.status,
213
+ status,
187
214
  code,
188
- cause: stringOr(body['cause'] ?? body['detail'], `${name} failed with ${response.status}`),
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
- export type JsonObject = Record<string, unknown>;
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 function isJsonObject(value: unknown): value is JsonObject {
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';