@ultimat3/core 20.2.1 → 21.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -13,12 +13,12 @@ is a change to every package.
13
13
  | Rendering the 3-line format | nothing to remember — `UltimateError`'s CONSTRUCTOR escapes `code`, `title`, `cause`, `fix` and `docs` with `singleLine()`. Call it yourself only when you render a shape this class never built, e.g. a `Finding` |
14
14
  | A value a CALLER supplied | `describeValue()` — shape, never content. `renderCauseValue` is safe against throwing, not against leaking |
15
15
  | Reading a caught value | `renderThrowable()` / `isThrownError()` / `stringField()`; never `error.message`, `error instanceof Error` or `typeof error.code === 'string'` directly — the probe throws before the renderer runs |
16
- | New code | add to `CORE_CODE_TITLES` in `error-codes.ts`, else the title is auto-humanised |
16
+ | New code | add to `CORE_CODE_TITLES` in `core-error-codes.ts` — a side-effect anchor the barrel bare-imports, so `UltimateError` alone (2,353 B, from 5,265 B) never carries the table; an untitled code renders humanised |
17
17
  | Where an error points | `ERROR_DOCS_URL` — one constant, never a per-code URL. `docs:` is omitted at every construction site and resolved from the registry |
18
18
  | Time | take a `Clock`; `Date.now()` / `new Date()` only inside `clock.ts` |
19
19
  | Context | never thread `ctx` as a parameter — `useContext()` |
20
20
  | A value ambient across an `await` | `asyncContext<T>(subject)` from `async-context.ts`, in **every** package — never `new AsyncLocalStorage` |
21
- | Exports | add to `src/index.ts` explicitly; no `export *`. Three subjects that each span a dozen modules arrive through `src/exports/` — every name is still written out in `index.ts`, so the public surface is one file to read |
21
+ | Exports | add to `src/index.ts` explicitly; no `export *`. ONE subpath, `@ultimat3/core/page` (`src/page.ts`): the page handle, the principal fence and the page's shared names, re-exported by the barrel too — a browser module imports them there because any barrel import retains the error registry (~7.6 kB) through the anchored `schema-error-codes.ts`. `page-bundle.test.ts` pins the retained module set and the 1.5 kB ceiling; nothing that constructs an error may join it. Three subjects that each span a dozen modules arrive through `src/exports/` — every name is still written out in `index.ts`, so the public surface is one file to read |
22
22
  | Files | < 200 LOC, 500 hard ceiling, one responsibility, `kebab-case.ts`, test beside source |
23
23
  | Type claims | `type-pins.ts`, never a `.test.ts` — `tsconfig.json` excludes tests, so `tsc` never reads one |
24
24
 
@@ -136,6 +136,10 @@ shape against a locally declared sample interface for exactly that reason.
136
136
  | which HTTP statuses are worth repeating | `retryable-status.ts` | `>= 500` plus 408, 409, 425, 429 — the two byte-identical copies' set |
137
137
  | how long this request has left | `request-budget.ts` (`Ctx.deadlineAt`, `REQUEST_TIMEOUT_HEADER`) | `@ultimat3/http`'s `startDeadline` is the one production writer of the instant; `traceHeaders()` is the one writer of the header. It lives here because the READER is tier 2 and the WRITER is a typed client in tier 0, and a second literal for the header name is a propagation that stops working the day either string is edited. A spent budget sends nothing rather than `0` — the far side ignores anything under 1ms and falls back to its own, which is the failure the header exists to prevent, one hop later |
138
138
  | the five above, composed into one typed-client call | `client-flight.ts` + `client-wire.ts` | `@ultimat3/action` and `@ultimat3/query` both project a typed client and are both tier 3, so neither could import the other: it shipped as a byte-identical 288-line + 85-line copy in each, policed by a `client-twin.test.ts` in both. Both packages re-export these names, so their public surface is unchanged. Declares NO code of its own — `X_SUPERSEDED`, `X_TIMEOUT` and `X_FLIGHT_GATE_OVERLOADED` are already here |
139
+ | the browser's one HTTP function, its records envelope, the per-tab handle and the principal fence | `client-transport.ts`, `client-dispatch.ts`, `client-problem.ts`, `client-paths.ts`, `record-envelope.ts`, `record-sink.ts`, `client-scope.ts` | plan 101, 21.0.0. `pageClient()` is the ONE `globalThis` write (`Symbol.for('ultimate.client')`) — a module-scope singleton would be one store per island bundle, which `record-sink.test.ts` reproduces by importing the module twice. The scope's listeners live ON the handle for the same reason. `clientTransport` must never value-import `createClientFlight` or `traceHeaders()` (sizes in its header). `isSuperseded` answers both `X_SUPERSEDED` and `X_CLIENT_SCOPE_CHANGED` — one reader, never a second predicate. The fence does NOT `bump()` a caller's flight: a flight's fence refuses writes too, and a write across a rescope must resolve. A write never dedupes, idempotency key or not; a read dedupes only with a caller's `flight` — there is no default flight, decided 2026-09-22. Trace and budget headers reach the transport through an OUTBOUND SLOT on the page handle that `runWithContext` and `startSpan` fill (`outbound-headers.ts`) — never a `traceHeaders()` call in a client: that put 12.9 kB of telemetry/context/logger into every island for a header a browser never has. Measured browser-minified through the barrel, As of 2026-09-22, before → after the slot: `clientTransport` 13,328 → 13,571 B, `pageClient` 7,964 → 8,139 B; re-measured As of 2026-09-23 (one entry importing `packages/core/src/index.ts` by path): 14,405 B and 8,853 B. `rpc`'s figures live in ONE table, [`packages/action/CLAUDE.md`](../action/CLAUDE.md) (the `ClientFlight` bullet), and `queryClient`'s in [`packages/query/CLAUDE.md`](../query/CLAUDE.md) — never restated here. The floor under all of them is the error-code registry (~7.6 kB) the anchored `schema-error-codes.ts` pulls into ANY barrel import; `pageClient` from its own module is 332 B. `pageClient()` reads its initial principal from `<meta name="ultimate-scope">` (`CLIENT_SCOPE_META`, which `@ultimat3/render` imports) once, at creation — THREE states: content = that principal, empty = `null` (anonymous), no tag or no `document` = `undefined` (UNSCOPED: a page rendered for nobody; nothing is persisted under it) |
140
+ | a write's public name | `write-digest.ts` (`writeDigest`, `isWriteDigest`, on `./page` too) + `write-origin.ts` (`withWriteOrigin`, `currentWriteOrigin`, `WRITE_ORIGIN_WAL_PREFIX`, server-only) | the SHA-256 of an idempotency key, 32 hex. `@ultimat3/action`'s HTTP projection opens the scope, `@ultimat3/entity` carries it onto a row change and into the WAL, `@ultimat3/realtime` stamps it on a `records` frame and the page matches its own. Tier 0 because all four read it and none may import another. A malformed value runs the work unnamed: the scope is a label, never a gate |
141
+ | which row survives a conflict | `conflict-policy.ts` (`ConflictPolicy`, `resolveConflict`, `Row`) | the one vocabulary `action`'s mutator and `realtime`'s rebase both read |
142
+ | the four shapes of an async region | `async-state.ts` (`AsyncState`) | moved from `@ultimat3/ui`; tier 0 so `realtime` can return it and `ui` can render it |
139
143
  | is this `unknown` a keyed record? | `json-object.ts` | `isJsonObject`, which was the same three terms in both those packages' `stable.ts`. A `Date` and a class instance PASS: it narrows a shape, it does not certify provenance |
140
144
  | a value that must not be printed | `secret.ts` | redacted by VALUE; `revealSecret()` is the one way out, on purpose greppable |
141
145
  | an `Intl` formatter cache, and the screen in front of it | `intl-cache.ts` (`cachedFormatter`, `canonicalLocale`, `assertLocale`, `MAX_CACHED_FORMATTERS`, `MAX_LOCALE_EXCERPT`) | a locale and a zone arrive from a request header, so the tag must be REFUSED when it is not a tag (`X_LOCALE_INVALID`), the key must be canonical AND the cache bounded — never a second copy of any of the three. The refusal is bounded too: the `cause` quotes back at most `MAX_LOCALE_EXCERPT` (35, RFC 5646 §4.4.1) code points and says so when it cut, and the whole tag rides in `meta.locale` — a `cause` reaches the 400 body and the log line, where a value with no key has nothing a redactor can address |
package/README.md CHANGED
@@ -22,6 +22,13 @@ Zero dependencies, zero `@ultimat3/*` imports.
22
22
  | whether an answer still applies — `X_SUPERSEDED` | `generation-fence.ts` |
23
23
  | the five composed into one typed-client call — dedup, fence, retry, deadline, ceiling | `client-flight.ts` |
24
24
  | what a typed client puts on the wire and reads back off it | `client-wire.ts` |
25
+ | the ONE browser HTTP function — credentials, JSON, the error decode, records, the fence | `client-transport.ts` + `client-dispatch.ts` + `client-problem.ts` |
26
+ | the ONE URL rule for actions and queries | `client-paths.ts` |
27
+ | the records envelope an answer carries behind `x-ultimate-records: 1` | `record-envelope.ts` |
28
+ | the per-tab page handle (`globalThis[Symbol.for('ultimate.client')]`) records land in | `record-sink.ts` |
29
+ | which principal the page acts for, and the epoch that moves when it changes | `client-scope.ts` |
30
+ | which row survives a conflict — `server-wins`, `last-write-wins`, `custom` | `conflict-policy.ts` |
31
+ | the four shapes an async region can be in | `async-state.ts` |
25
32
  | is this `unknown` a keyed record? | `json-object.ts` |
26
33
  | typed env validated at boot | `env.ts` |
27
34
  | `.env.example` rendered from that schema, and its drift check | `env-example.ts` |
@@ -562,6 +569,22 @@ cause and a runnable `fix:` with the fact that something was retried, which no r
562
569
  `classifyThrown` and `statedDelayMs` live in `error-retry.ts`, one import away from the table they
563
570
  read; `@ultimat3/jobs` re-exports both rather than keeping a second pair.
564
571
 
572
+ ## One browser seam — transport, records, page handle, principal fence
573
+
574
+ | Export | The one answer | The question it settles |
575
+ |---|---|---|
576
+ | `clientTransport({ method, url, body?, rawBody?, headers?, signal?, idempotencyKey?, flight?, fresh?, retry?, onResponse?, decodeError?, onEnvelope?, fetchImpl? })` | every browser request | `credentials: 'same-origin'`, JSON in and out, the `idempotency-key` header, a non-2xx `problem+json` back into the server's code (`meta.origin: 'remote'`) unless `decodeError` answers first, a network `TypeError` into `X_CLIENT_TRANSPORT_FAILED`. A GET is abortable on `rescope()` and deduped only when a `flight` is passed (`fresh` refuses to join); any other method is never deduped and never aborted by the fence. `rawBody` goes out verbatim with no default header and resolves `undefined`. `onResponse` sees headers before the body is read. `onEnvelope` sees the decoded records envelope after adoption — the one way to learn the order of `records[type]`. `fetchImpl` defaults to `globalThis.fetch`, read at call time |
577
+ | `actionPath(name)`, `actionRoute(name)`, `queryPath(name)`, `QUERY_PATH_PREFIX`, `splitWords`, `pluralize` | the one URL rule | `publishPost` → `/api/posts/publish`, `liveFeed` → `/_x/query/live-feed`. Tier 0 so `action`, `query` and `realtime` derive one URL with no sideways import |
578
+ | `RECORDS_HEADER`, `encodeRecordEnvelope`, `decodeRecordEnvelope`, `RecordRows` | `{ data, records?: { [type]: { [key]: Row } }, removed?: { [type]: key[] } }`, only behind `x-ultimate-records: 1` | how an answer carries entity rows without changing the wire of an answer that has none. A malformed envelope is `X_CLIENT_RECORD_ENVELOPE_INVALID` |
579
+ | `pageClient()` → `{ store, socket, scope }`, `RecordSink` | one handle per TAB, not per module copy | every island bundle carries its own core; the handle lives on `globalThis` under one `Symbol.for` key so all of them resolve the same store. No store installed = records dropped |
580
+ | `rescope(principal)`, `onRescope(fn)`, `isSuperseded(error)` | the principal fence | a principal change bumps the epoch and notifies synchronously; reads in flight reject `X_CLIENT_SCOPE_CHANGED`, writes complete but their records are not adopted. `isSuperseded` answers true for it and for `X_SUPERSEDED` |
581
+ | `resolveConflict(policy, local, server, { clockField? })`, `ConflictPolicy`, `Row` | one conflict vocabulary, over ROWS | `last-write-wins` keeps the local row only when its numeric clock field (default `updatedAt`) is newer; no provable clock = the server's row |
582
+ | `AsyncState<T>` | `pending \| refreshing \| ready \| failed` | the type `realtime` produces and `ui` renders; tier 0 because neither may import the other |
583
+
584
+ `clientTransport` does not value-import `createClientFlight` or `traceHeaders()` — measured sizes
585
+ are in its file header. A server-side caller that propagates a trace passes `traceHeaders()` in
586
+ `headers` itself.
587
+
565
588
  ## One image pipeline, everywhere
566
589
 
567
590
  ```ts
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "@ultimat3/core",
3
- "version": "20.2.1",
3
+ "version": "21.0.0",
4
4
  "description": "Ultimate's foundation: errors, context, env, config, clock, ids, logging, telemetry, lifecycle",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "sideEffects": [
8
8
  "./src/context.ts",
9
+ "./src/core-error-codes.ts",
9
10
  "./src/lifecycle-errors.ts",
10
11
  "./src/schema-error-codes.ts",
11
12
  "./src/secrets-errors.ts"
@@ -20,11 +21,13 @@
20
21
  "provenance": true
21
22
  },
22
23
  "exports": {
23
- ".": "./src/index.ts"
24
+ ".": "./src/index.ts",
25
+ "./page": "./src/page.ts"
24
26
  },
25
27
  "files": [
26
28
  "src",
27
29
  "!src/**/*.test.ts",
30
+ "!src/**/*-fixture.ts",
28
31
  "CLAUDE.md",
29
32
  "README.md",
30
33
  "LICENSE"
@@ -37,6 +40,6 @@
37
40
  "test": "bun test"
38
41
  },
39
42
  "dependencies": {
40
- "@ultimat3/schema": "20.2.1"
43
+ "@ultimat3/schema": "21.0.0"
41
44
  }
42
45
  }
package/src/actor.ts CHANGED
@@ -105,6 +105,14 @@ export interface Actor {
105
105
  * one the customer issued. Every surface that renders an actor renders this with it.
106
106
  */
107
107
  readonly onBehalfOf?: ActorOrigin | undefined;
108
+ /**
109
+ * The actor's SAVED language and IANA zone — the `user` rung of `@ultimat3/i18n`'s
110
+ * `resolveLocale` and `@ultimat3/time`'s `resolveTimeZone`. Whoever authenticates sets them from
111
+ * the user record; `@ultimat3/http` re-resolves `ctx.locale` / `ctx.tz` with them once the actor
112
+ * is known, so a cookie the reader chose still wins exactly where those owners say it does.
113
+ */
114
+ readonly locale?: string | undefined;
115
+ readonly tz?: string | undefined;
108
116
  }
109
117
 
110
118
  export interface ActorInit {
@@ -117,6 +125,9 @@ export interface ActorInit {
117
125
  readonly facts?: ActorFactMap | undefined;
118
126
  /** For a session that already recorded an impersonation; `impersonate()` sets it otherwise. */
119
127
  readonly onBehalfOf?: ActorOrigin | undefined;
128
+ /** Saved preferences — see `Actor.locale`. */
129
+ readonly locale?: string | undefined;
130
+ readonly tz?: string | undefined;
120
131
  }
121
132
 
122
133
  const NO_FACTS: ActorFactMap = Object.freeze({});
@@ -143,6 +154,9 @@ function build(kind: ActorKind, init: ActorInit): Actor {
143
154
  permissions: Object.freeze([...(init.permissions ?? [])]),
144
155
  facts: Object.freeze({ ...init.facts }),
145
156
  onBehalfOf: init.onBehalfOf === undefined ? undefined : Object.freeze({ ...init.onBehalfOf }),
157
+ // Absent keys, not `undefined` ones: an actor is compared and serialised in tests and logs.
158
+ ...(init.locale === undefined ? {} : { locale: init.locale }),
159
+ ...(init.tz === undefined ? {} : { tz: init.tz }),
146
160
  });
147
161
  }
148
162
 
@@ -0,0 +1,19 @@
1
+ /**
2
+ * The four shapes an async region can be in — pending, refreshing, ready, failed. Tier 0 because
3
+ * every client layer produces or consumes one: `@ultimat3/realtime`'s `useQuery` returns it (tier 3)
4
+ * and `@ultimat3/ui`'s `asyncBranch` renders it (tier 4), and neither may import the other's way.
5
+ */
6
+
7
+ /**
8
+ * A STATE, never a query: a live-query accessor, a `createResource` and a plain signal all arrive
9
+ * at a region as the same four shapes.
10
+ *
11
+ * `refreshing` is the one that makes search feel fast — it CARRIES the previous data, so a refetch
12
+ * re-renders what is already on screen instead of tearing it down to a skeleton. `pending` holds
13
+ * no data, which is what makes "no results" before the first answer unconstructible.
14
+ */
15
+ export type AsyncState<T> =
16
+ | { readonly status: 'pending' }
17
+ | { readonly status: 'refreshing'; readonly data: T }
18
+ | { readonly status: 'ready'; readonly data: T }
19
+ | { readonly status: 'failed'; readonly error: unknown };
@@ -0,0 +1,163 @@
1
+ /**
2
+ * One dispatch of a `clientTransport` request: the `RequestInit`, the fence's abort for a read,
3
+ * the network fault's code, and the ok/error split — nothing about how many times it happens
4
+ * (the flight's) or what the answer means (the transport's).
5
+ */
6
+
7
+ import type { ClientFlight, ClientRetry } from './client-flight';
8
+ import { problemError, transportFailed } from './client-problem';
9
+ import { onRescope } from './client-scope';
10
+ import { scopeChanged } from './client-scope-error';
11
+ import type { UltimateError } from './errors';
12
+ import type { RecordEnvelope } from './record-envelope';
13
+ import { RECORDS_HEADER } from './record-envelope';
14
+ import { outboundSlot, pageClient } from './record-sink';
15
+
16
+ export type FetchLike = (input: string, init: RequestInit) => Promise<Response>;
17
+
18
+ /** The header `@ultimat3/action`'s server reads to replay rather than re-run a write. */
19
+ export const IDEMPOTENCY_HEADER = 'idempotency-key';
20
+
21
+ export interface TransportRequest {
22
+ readonly method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
23
+ readonly url: string;
24
+ /** JSON-encoded, with `content-type: application/json`. */
25
+ readonly body?: unknown;
26
+ /**
27
+ * Sent VERBATIM instead of `body` — bytes to a signed URL. No default header is added (a signed
28
+ * request may cover them), and the answer is not decoded: the call resolves `undefined`.
29
+ */
30
+ readonly rawBody?: BodyInit | undefined;
31
+ /** Spread LAST, so an explicit value wins over every default. */
32
+ readonly headers?: Readonly<Record<string, string>> | undefined;
33
+ readonly signal?: AbortSignal | undefined;
34
+ readonly idempotencyKey?: string | undefined;
35
+ /** Dedup, retry, deadline and a concurrency ceiling — opt-in; a read dedupes only with one. */
36
+ readonly flight?: ClientFlight | undefined;
37
+ /** Refuse to join a read that left before this call did. Meaningful only with a `flight`. */
38
+ readonly fresh?: boolean | undefined;
39
+ /** This call's retry override, handed to the `flight`. */
40
+ readonly retry?: ClientRetry | undefined;
41
+ /**
42
+ * The decoded records envelope, after its records were adopted — only when the answer carried
43
+ * `x-ultimate-records: 1` and belongs to the current principal. What a caller reads to know the
44
+ * ORDER of `records[type]`, which the store (keyed, unordered) cannot give it back.
45
+ */
46
+ readonly onEnvelope?: ((envelope: RecordEnvelope) => void) | undefined;
47
+ /** Sees every response before its body is read — a header check may throw its own refusal. */
48
+ readonly onResponse?: ((response: Response) => void | Promise<void>) | undefined;
49
+ /** A non-2xx answer as the caller's own error; `undefined` falls back to the shared decode. */
50
+ readonly decodeError?: ((status: number, text: string) => UltimateError | undefined) | undefined;
51
+ /** Default `globalThis.fetch`, read at call time — never captured at module scope. */
52
+ readonly fetchImpl?: FetchLike | undefined;
53
+ }
54
+
55
+ /** One dispatch's answer: immutable TEXT, so a deduped read's joiners each parse their own. */
56
+ export interface Answer {
57
+ readonly text: string;
58
+ readonly enveloped: boolean;
59
+ }
60
+
61
+ /** Called, never captured: a browser's `fetch` throws `Illegal invocation` when detached. */
62
+ const browserFetch: FetchLike = (input, init) => globalThis.fetch(input, init);
63
+
64
+ export async function dispatch(
65
+ req: TransportRequest,
66
+ read: boolean,
67
+ issued: number,
68
+ flightSignal: AbortSignal | undefined,
69
+ ): Promise<Answer> {
70
+ // A read gets its own controller so the fence can abort it; a write never does, and only its
71
+ // caller's own signal reaches it (a flight never hands a write one).
72
+ const fence = read ? abortable([req.signal, flightSignal]) : undefined;
73
+ const signal = fence === undefined ? req.signal : fence.signal;
74
+ try {
75
+ const response = await onTheWire(req, read, () =>
76
+ (req.fetchImpl ?? browserFetch)(req.url, initOf(req, signal)),
77
+ );
78
+ await req.onResponse?.(response);
79
+ // Read as TEXT once: a body is a single-use stream, and the failure path wants it too.
80
+ const text = response.status === 204 ? '' : await onTheWire(req, read, () => response.text());
81
+ if (!response.ok) {
82
+ throw (
83
+ req.decodeError?.(response.status, text) ?? problemError(response.status, text, req.url)
84
+ );
85
+ }
86
+ return { text, enveloped: response.headers.get(RECORDS_HEADER) === '1' };
87
+ } catch (error) {
88
+ const current = pageClient().scope.epoch;
89
+ if (read && current !== issued) throw scopeChanged(req.url, issued, current);
90
+ throw error;
91
+ } finally {
92
+ fence?.release();
93
+ }
94
+ }
95
+
96
+ /**
97
+ * The network's half of a dispatch, and only that half. `fetch` — and a body read cut mid-stream —
98
+ * rejects with a bare `TypeError` when no response arrived; that is `failure: 'network'`. A caller's
99
+ * `onResponse` or `decodeError` throwing a `TypeError` is a bug in the caller, and classifying it
100
+ * as the network made it retryable, so a flight re-sent the request for it. An abort is a decision
101
+ * and passes through untouched.
102
+ */
103
+ async function onTheWire<T>(
104
+ req: TransportRequest,
105
+ read: boolean,
106
+ work: () => Promise<T>,
107
+ ): Promise<T> {
108
+ try {
109
+ return await work();
110
+ } catch (error) {
111
+ if (!(error instanceof TypeError)) throw error;
112
+ throw transportFailed(
113
+ 'network',
114
+ `${req.method} ${req.url} produced no response — the network refused or dropped it`,
115
+ read || req.idempotencyKey !== undefined ? 'retryable' : undefined,
116
+ { url: req.url, method: req.method },
117
+ error,
118
+ );
119
+ }
120
+ }
121
+
122
+ function initOf(req: TransportRequest, signal: AbortSignal | undefined): RequestInit {
123
+ const raw = req.rawBody !== undefined;
124
+ const headers: Record<string, string> = raw ? {} : { accept: 'application/json' };
125
+ if (req.body !== undefined && !raw) headers['content-type'] = 'application/json';
126
+ if (req.idempotencyKey !== undefined) headers[IDEMPOTENCY_HEADER] = req.idempotencyKey;
127
+ const body = raw ? req.rawBody : req.body === undefined ? undefined : JSON.stringify(req.body);
128
+ // Server-side only: the slot is filled by `runWithContext`/`startSpan` (`outbound-headers.ts`),
129
+ // and a signed raw upload never carries it — an extra header can break the signature.
130
+ const outbound = raw ? undefined : outboundSlot().outboundHeaders?.();
131
+ return {
132
+ method: req.method,
133
+ credentials: 'same-origin',
134
+ headers: { ...headers, ...outbound, ...req.headers },
135
+ ...(body === undefined ? {} : { body }),
136
+ ...(signal === undefined ? {} : { signal }),
137
+ };
138
+ }
139
+
140
+ /**
141
+ * A read's controller: aborted by `rescope()`, by the caller's signal or by the flight's. Every
142
+ * listener comes off in `release`, so a long-lived caller signal does not collect one per request.
143
+ */
144
+ function abortable(sources: readonly (AbortSignal | undefined)[]): {
145
+ readonly signal: AbortSignal;
146
+ release(): void;
147
+ } {
148
+ const controller = new AbortController();
149
+ const detach: (() => void)[] = [onRescope(() => controller.abort())];
150
+ for (const source of sources) {
151
+ if (source === undefined) continue;
152
+ if (source.aborted) controller.abort(source.reason);
153
+ const forward = (): void => controller.abort(source.reason);
154
+ source.addEventListener('abort', forward, { once: true });
155
+ detach.push(() => source.removeEventListener('abort', forward));
156
+ }
157
+ return {
158
+ signal: controller.signal,
159
+ release: (): void => {
160
+ for (const off of detach) off();
161
+ },
162
+ };
163
+ }
@@ -61,6 +61,12 @@ export function isTransientFailure(error: unknown): boolean {
61
61
  return isNetworkRejection(error);
62
62
  }
63
63
 
64
+ /** Only a classification somebody declared — for work that already classified its own wire. */
65
+ function isDeclaredTransient(error: unknown): boolean {
66
+ const declared = classifyThrown(error);
67
+ return declared !== undefined && declared !== 'terminal';
68
+ }
69
+
64
70
  /**
65
71
  * A dispatch that never produced a response. `fetch` rejects with a plain `TypeError` when the
66
72
  * network is down, DNS fails or the connection drops mid-body — the one unclassified throw a
@@ -89,6 +95,13 @@ export interface FlightPlan<T> {
89
95
  run(signal: AbortSignal | undefined, attempt: number): Promise<T>;
90
96
  /** Overrides the flight's policy for this one call. */
91
97
  readonly retry?: ClientRetry | undefined;
98
+ /**
99
+ * `true` when `run` already turns every wire failure into a classified error, as
100
+ * `clientTransport` does. An UNclassified throw is then the caller's own — a hook's `TypeError`
101
+ * — and is never sent again, where the default would read it as a network rejection. A
102
+ * flight-wide `transient` still wins: that is the app's own answer.
103
+ */
104
+ readonly classified?: boolean | undefined;
92
105
  }
93
106
 
94
107
  export interface ClientFlightOptions {
@@ -151,6 +164,8 @@ export function createClientFlight(options: ClientFlightOptions = {}): ClientFli
151
164
  schedule(done, ms);
152
165
  }));
153
166
  const transient = options.transient ?? isTransientFailure;
167
+ const transientFor = (plan: FlightPlan<unknown>): ((error: unknown) => boolean) =>
168
+ plan.classified === true && options.transient === undefined ? isDeclaredTransient : transient;
154
169
  const deadlineMs = options.deadlineMs;
155
170
  const flights = createSingleFlight({ deadlineMs, schedule });
156
171
  const gate: FlightGate | undefined =
@@ -169,12 +184,13 @@ export function createClientFlight(options: ClientFlightOptions = {}): ClientFli
169
184
  // and a foreign `TypeError` again. The original value is rethrown below, unwrapped — wrapping
170
185
  // it would replace a code, a cause and a runnable `fix:` with the fact that something retried.
171
186
  let stopped: { readonly error: unknown } | undefined;
187
+ const sendAgain = transientFor(plan);
172
188
  const answer = await retry<T | typeof STOPPED>(
173
189
  async (count) => {
174
190
  try {
175
191
  return await plan.run(signal, count);
176
192
  } catch (error) {
177
- if (transient(error)) throw error;
193
+ if (sendAgain(error)) throw error;
178
194
  stopped = { error };
179
195
  return STOPPED;
180
196
  }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * The ONE URL rule for the two HTTP primitives: an action's export name derives `POST
3
+ * /api/<resource>/<verb>`, a query's derives `GET /_x/query/<kebab>`. Tier 0 and pure string math,
4
+ * so `action`, `query` and `realtime` (all tier 3) derive the same URL with no sideways import and
5
+ * a browser bundle pays a few hundred bytes for it. Moved verbatim from both packages' `naming.ts`.
6
+ */
7
+
8
+ /** Irregular plurals we actually hit in domain models. A `Map`: the key is a caller's word. */
9
+ const IRREGULAR: ReadonlyMap<string, string> = new Map([
10
+ ['person', 'people'],
11
+ ['child', 'children'],
12
+ ['man', 'men'],
13
+ ['woman', 'women'],
14
+ ['datum', 'data'],
15
+ ['index', 'indexes'],
16
+ ['entry', 'entries'],
17
+ ]);
18
+
19
+ /** Every read is served under one prefix, so a router can claim it in one rule. */
20
+ export const QUERY_PATH_PREFIX = '/_x/query';
21
+
22
+ export interface ActionRoute {
23
+ /** First camelCase word, kebab-cased. `publishPost` -> `publish`. */
24
+ readonly verb: string;
25
+ /** Remaining words, last one pluralized, kebab-cased. `publishPost` -> `posts`. */
26
+ readonly resource: string;
27
+ /** `/api/<resource>/<verb>`. */
28
+ readonly path: string;
29
+ }
30
+
31
+ /** camelCase / PascalCase / SCREAMING_SNAKE -> lowercase words. */
32
+ export function splitWords(name: string): string[] {
33
+ return name
34
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
35
+ .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
36
+ .split(/[\s_-]+/)
37
+ .filter((word) => word.length > 0)
38
+ .map((word) => word.toLowerCase());
39
+ }
40
+
41
+ /**
42
+ * Naive-on-purpose English pluralizer. A word that already ends in `s` is left alone, so
43
+ * `publishPosts` and `publishPost` agree on the `posts` resource.
44
+ */
45
+ export function pluralize(word: string): string {
46
+ const irregular = IRREGULAR.get(word);
47
+ if (irregular !== undefined) return irregular;
48
+ if (word.endsWith('s')) return word;
49
+ if (/(x|z|ch|sh)$/.test(word)) return `${word}es`;
50
+ if (/[^aeiou]y$/.test(word)) return `${word.slice(0, -1)}ies`;
51
+ return `${word}s`;
52
+ }
53
+
54
+ /**
55
+ * `publishPost` -> `/api/posts/publish`, `updateUserProfile` -> `/api/user-profiles/update`,
56
+ * `checkout` -> `/api/checkouts/invoke` (single-word fallback).
57
+ */
58
+ export function actionRoute(name: string): ActionRoute {
59
+ const words = splitWords(name);
60
+ const head = words[0] ?? 'invoke';
61
+ if (words.length < 2) {
62
+ const resource = pluralize(head);
63
+ return { verb: 'invoke', resource, path: `/api/${resource}/invoke` };
64
+ }
65
+ const nouns = words.slice(1);
66
+ const last = nouns[nouns.length - 1] ?? head;
67
+ const resource = [...nouns.slice(0, -1), pluralize(last)].join('-');
68
+ return { verb: head, resource, path: `/api/${resource}/${head}` };
69
+ }
70
+
71
+ /** The path an action is POSTed to — `actionRoute(name).path`. */
72
+ export function actionPath(name: string): string {
73
+ return actionRoute(name).path;
74
+ }
75
+
76
+ /** `liveFeed` -> `/_x/query/live-feed`, read with `GET …?orgId=…`. */
77
+ export function queryPath(name: string): string {
78
+ return `${QUERY_PATH_PREFIX}/${splitWords(name).join('-')}`;
79
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * A browser request's failure, as the error it is: a non-2xx `problem+json` answer back into the
3
+ * `UltimateError` the server threw, and a dispatch that produced no usable answer into
4
+ * `X_CLIENT_TRANSPORT_FAILED`. The one decoder `clientTransport` owns, so every client surface
5
+ * reads a failure off the wire the same way.
6
+ */
7
+
8
+ import { FRAMEWORK_CODE, problemOf, retryForStatus } from './client-wire';
9
+ import { renderFixShellArg } from './error-render';
10
+ import type { ErrorRetry } from './error-retry';
11
+ import { UltimateError } from './errors';
12
+ import { isJsonObject } from './json-object';
13
+
14
+ /** An absolute HTTP(S) link, or nothing: a server's `docs` is data and may be `javascript:`. */
15
+ const HTTP_URL = /^https?:\/\/[^\s]+$/;
16
+
17
+ /**
18
+ * A non-2xx answer. A body naming a framework code IS the server's error, carried verbatim and
19
+ * marked `origin: 'remote'` — the code may be one this bundle never registered. Anything else is
20
+ * a proxy or a gateway answering instead of the app.
21
+ */
22
+ export function problemError(status: number, text: string, url: string): UltimateError {
23
+ const body = problemOf(text);
24
+ const code = body['code'];
25
+ if (typeof code !== 'string' || !FRAMEWORK_CODE.test(code)) {
26
+ return transportFailed(
27
+ 'status',
28
+ `${url} answered HTTP ${status} without a problem+json body naming a framework code`,
29
+ retryForStatus('X_CLIENT_TRANSPORT_FAILED', status),
30
+ { url, status },
31
+ );
32
+ }
33
+ const docs = [body['docs'], body['type']].find(
34
+ (value): value is string => typeof value === 'string' && HTTP_URL.test(value),
35
+ );
36
+ return new UltimateError({
37
+ code,
38
+ cause: text1(body['cause']) ?? text1(body['detail']) ?? `${url} failed with HTTP ${status}`,
39
+ fix: text1(body['fix']) ?? `x errors explain ${renderFixShellArg(code, '<code>')} --json`,
40
+ retry: retryForStatus(code, status),
41
+ // The server's declared keys FIRST, so the three this decoder owns win a collision.
42
+ meta: { ...serverMeta(body['meta'], body['issues']), origin: 'remote', status, url },
43
+ ...(docs === undefined ? {} : { docs }),
44
+ });
45
+ }
46
+
47
+ /**
48
+ * WHICH way no usable answer came back, on `meta.failure` — what a caller branches on, since the
49
+ * code is the same for all three: `network` (no response at all — the one an offline outbox
50
+ * queues), `status` (a non-2xx no framework code explained) and `body` (a 2xx that is not JSON).
51
+ */
52
+ export type TransportFailure = 'network' | 'status' | 'body';
53
+
54
+ /**
55
+ * No usable answer: the network refused, the body stream broke, or a 2xx body was not JSON.
56
+ * `retry` is the caller's to state — a read is always safe to send again, an unkeyed write never.
57
+ */
58
+ export function transportFailed(
59
+ failure: TransportFailure,
60
+ cause: string,
61
+ retry: ErrorRetry | undefined,
62
+ meta: Readonly<Record<string, unknown>>,
63
+ sourceError?: unknown,
64
+ ): UltimateError {
65
+ return new UltimateError({
66
+ code: 'X_CLIENT_TRANSPORT_FAILED',
67
+ cause,
68
+ fix: 'check the network and the gateway in front of the app with x doctor --json, then retry — a write with no idempotencyKey may already have landed',
69
+ ...(retry === undefined ? {} : { retry }),
70
+ meta: { ...meta, failure },
71
+ ...(sourceError === undefined ? {} : { sourceError }),
72
+ });
73
+ }
74
+
75
+ /**
76
+ * The document's `meta`, copied own key by own key: `JSON.parse` mints `__proto__` as a real own
77
+ * key, and assigned onto a plain object it would replace the prototype. `issues` rides along
78
+ * unparsed for the caller whose schema can read it.
79
+ */
80
+ function serverMeta(meta: unknown, issues: unknown): Record<string, unknown> {
81
+ const out: Record<string, unknown> = {};
82
+ if (isJsonObject(meta)) {
83
+ for (const [key, member] of Object.entries(meta)) {
84
+ if (key === '__proto__') continue;
85
+ Object.defineProperty(out, key, { value: member, writable: true, enumerable: true });
86
+ }
87
+ }
88
+ if (Array.isArray(issues)) out['issues'] = issues;
89
+ return out;
90
+ }
91
+
92
+ function text1(value: unknown): string | undefined {
93
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
94
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The refusal a read in flight across `rescope()` rejects with — `X_CLIENT_SCOPE_CHANGED`. Its own
3
+ * module, imported only by the transport: `client-scope.ts` is what `pageClient()` and every
4
+ * `onRescope` subscriber reach, and constructing an `UltimateError` there put core's error registry
5
+ * into a store-only island that never makes a request.
6
+ */
7
+
8
+ import { UltimateError } from './errors';
9
+
10
+ /** Never a UI error: `isSuperseded(error)` answers true for it, and a caller renders nothing. */
11
+ export function scopeChanged(subject: string, issued: number, current: number): UltimateError {
12
+ return new UltimateError({
13
+ code: 'X_CLIENT_SCOPE_CHANGED',
14
+ cause: `${subject} was issued for client scope epoch ${issued} and the page is now at ${current}, so its answer belongs to the previous principal`,
15
+ fix: 'discard this answer and read again — test it with isSuperseded(error) from @ultimat3/core and render nothing for it',
16
+ meta: { subject, issued, current },
17
+ });
18
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * The principal fence: which principal the page is acting for, and an epoch that moves exactly
3
+ * when that changes. A response, frame or persisted row from the previous principal must never
4
+ * land in the next one's store, so every client layer reads this one signal.
5
+ *
6
+ * Subscriber contract (`onRescope`): called SYNCHRONOUSLY, once per real change, in registration
7
+ * order, before `rescope()` returns — so by the time a sign-in action's caller continues, every
8
+ * read was aborted, the store wiped its non-persisted records, the socket dropped its channels
9
+ * and the persister switched scope. A throwing subscriber does not stop the others; the first
10
+ * throw is re-raised after all have run. The TRIGGER is not core's: the page bootstrap calls
11
+ * `rescope()` with the principal the server rendered, and sign-in / sign-out call it on success.
12
+ */
13
+
14
+ import type { ScopeCell } from './record-sink';
15
+ import { heldRecords, scopeCell } from './record-sink';
16
+
17
+ export interface ClientScope {
18
+ /**
19
+ * An opaque principal id; `null` for a page rendered for an anonymous visitor; `undefined` for
20
+ * an UNSCOPED page — one rendered for nobody (a shared, cacheable document carries no scope
21
+ * meta). Nothing is persisted while unscoped (plan 101 slice 12): there is no principal to key
22
+ * the rows by, and guessing one is how one visitor's cache restores into another's.
23
+ */
24
+ readonly principal: string | null | undefined;
25
+ /** Bumped once per principal change. Work captured at another epoch is not this scope's. */
26
+ readonly epoch: number;
27
+ }
28
+
29
+ /** Move the page to `principal`. Same principal = no-op: no epoch bump, no notification. */
30
+ export function rescope(principal: string | null): void {
31
+ const cell: ScopeCell = scopeCell();
32
+ const prev = cell.current;
33
+ if (prev.principal === principal) return;
34
+ const next: ClientScope = Object.freeze({ principal, epoch: prev.epoch + 1 });
35
+ cell.current = next;
36
+ // Held early records belong to the principal that just left; they never reach the next store.
37
+ heldRecords()?.clear();
38
+ let failure: { readonly error: unknown } | undefined;
39
+ // A snapshot: a subscriber that unsubscribes (or subscribes) mid-notify changes the NEXT round.
40
+ for (const listener of [...cell.listeners]) {
41
+ try {
42
+ listener(next, prev);
43
+ } catch (error) {
44
+ failure ??= { error };
45
+ }
46
+ }
47
+ if (failure !== undefined) throw failure.error;
48
+ }
49
+
50
+ /** Subscribe to principal changes. Returns the unsubscribe. */
51
+ export function onRescope(fn: (next: ClientScope, prev: ClientScope) => void): () => void {
52
+ const listeners = scopeCell().listeners;
53
+ // Wrapped, so the same function registered twice is two subscriptions with two unsubscribes.
54
+ const entry = (next: ClientScope, prev: ClientScope): void => fn(next, prev);
55
+ listeners.add(entry);
56
+ return (): void => {
57
+ listeners.delete(entry);
58
+ };
59
+ }