@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 +6 -2
- package/README.md +23 -0
- package/package.json +6 -3
- package/src/actor.ts +14 -0
- package/src/async-state.ts +19 -0
- package/src/client-dispatch.ts +163 -0
- package/src/client-flight.ts +17 -1
- package/src/client-paths.ts +79 -0
- package/src/client-problem.ts +94 -0
- package/src/client-scope-error.ts +18 -0
- package/src/client-scope.ts +59 -0
- package/src/client-transport.ts +86 -0
- package/src/conflict-policy.ts +48 -0
- package/src/context.ts +12 -1
- package/src/core-error-codes.ts +77 -0
- package/src/error-codes.ts +17 -66
- package/src/error-retry.ts +3 -0
- package/src/exports/error-contract.ts +2 -2
- package/src/generation-fence.ts +9 -2
- package/src/index.ts +54 -1
- package/src/outbound-headers.ts +16 -0
- package/src/outbox-drain.ts +14 -0
- package/src/page-meta.ts +41 -0
- package/src/page.ts +52 -0
- package/src/pending-records.ts +58 -0
- package/src/record-envelope-openapi.ts +41 -0
- package/src/record-envelope.ts +98 -0
- package/src/record-sink.ts +129 -0
- package/src/schema-error-codes.ts +1 -1
- package/src/secrets-errors.ts +1 -1
- package/src/service.ts +9 -0
- package/src/telemetry.ts +3 -0
- package/src/write-digest.ts +29 -0
- package/src/write-origin.ts +31 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ONE browser HTTP function. Every client surface — `rpc()`, `queryClient()`, a signed upload,
|
|
3
|
+
* the store's refetch — sends through here, so credentials, headers, the error decode, the records
|
|
4
|
+
* envelope and the principal fence are decided once. A GET is a read: abortable on `rescope()`,
|
|
5
|
+
* deduped when a `ClientFlight` is supplied. Anything else is a write: never deduped, never
|
|
6
|
+
* aborted by the fence, and its records never adopted across one.
|
|
7
|
+
*
|
|
8
|
+
* `createClientFlight` is NOT imported here at value level — a caller passes one, and only then
|
|
9
|
+
* does its graph enter the bundle. Neither is `traceHeaders()`: the trace and budget headers come
|
|
10
|
+
* from an outbound slot that `runWithContext`/`startSpan` fill SERVER-side (`outbound-headers.ts`),
|
|
11
|
+
* so a browser, which never has either, carries zero bytes of telemetry, context or logger.
|
|
12
|
+
*
|
|
13
|
+
* Measured `bun build --target=browser --minify` through the barrel, As of 2026-09-22 (before →
|
|
14
|
+
* after the outbound slot): `rpc` 23,164 → 18,119 B; `queryClient` 24,100 → 22,897 B (the rest is
|
|
15
|
+
* `@ultimat3/query`'s anchored `registry.ts`); `clientTransport` 13,571 B; `pageClient` 8,139 B;
|
|
16
|
+
* `UltimateError` 7,679 B. ~7.6 kB of every barrel import is the error-code registry that the
|
|
17
|
+
* anchored `schema-error-codes.ts` registers into — `pageClient` straight from its module is 332 B.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { Answer, TransportRequest } from './client-dispatch';
|
|
21
|
+
import { dispatch } from './client-dispatch';
|
|
22
|
+
import { transportFailed } from './client-problem';
|
|
23
|
+
import { scopeChanged } from './client-scope-error';
|
|
24
|
+
import type { RecordEnvelope } from './record-envelope';
|
|
25
|
+
import { decodeRecordEnvelope } from './record-envelope';
|
|
26
|
+
import { pageClient, recordSink } from './record-sink';
|
|
27
|
+
|
|
28
|
+
const ONCE = { attempts: 1 } as const;
|
|
29
|
+
|
|
30
|
+
export async function clientTransport<T = unknown>(req: TransportRequest): Promise<T> {
|
|
31
|
+
const read = req.method === 'GET';
|
|
32
|
+
const issued = pageClient().scope.epoch;
|
|
33
|
+
const flight = req.flight;
|
|
34
|
+
const answer: Answer =
|
|
35
|
+
flight === undefined
|
|
36
|
+
? await dispatch(req, read, issued, undefined)
|
|
37
|
+
: await flight.run({
|
|
38
|
+
// A mutation never joins another mutation, idempotency key or not: the key is for the
|
|
39
|
+
// server's replay, and sharing one dispatch would hide the second intent from it.
|
|
40
|
+
key: read ? flight.keyFor(req.url, { signal: req.signal, fresh: req.fresh }) : undefined,
|
|
41
|
+
abortable: read,
|
|
42
|
+
// A stream is read once, so a second attempt re-sends a body that is already spent —
|
|
43
|
+
// and fails as the network would, until the attempts run out. One attempt, always.
|
|
44
|
+
retry: req.rawBody instanceof ReadableStream ? ONCE : req.retry,
|
|
45
|
+
run: (signal) => dispatch(req, read, issued, signal),
|
|
46
|
+
// `dispatch` makes every wire failure `X_CLIENT_TRANSPORT_FAILED`; a bare throw that
|
|
47
|
+
// reaches the flight is a caller hook's, never the network's.
|
|
48
|
+
classified: true,
|
|
49
|
+
});
|
|
50
|
+
const current = pageClient().scope.epoch;
|
|
51
|
+
// A read that raced the abort still belongs to the previous principal.
|
|
52
|
+
if (read && current !== issued) throw scopeChanged(req.url, issued, current);
|
|
53
|
+
if (req.rawBody !== undefined) return undefined as T;
|
|
54
|
+
const envelope = unwrap(answer, req.url, read);
|
|
55
|
+
// A write that crossed a rescope HAS landed, so its caller is told — but its rows are the
|
|
56
|
+
// previous principal's, and the new scope's store never sees them.
|
|
57
|
+
if (current === issued) {
|
|
58
|
+
adopt(envelope);
|
|
59
|
+
if (answer.enveloped) req.onEnvelope?.(envelope);
|
|
60
|
+
}
|
|
61
|
+
return envelope.data as T;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function unwrap(answer: Answer, url: string, read: boolean): RecordEnvelope {
|
|
65
|
+
let body: unknown;
|
|
66
|
+
try {
|
|
67
|
+
body = answer.text === '' ? undefined : (JSON.parse(answer.text) as unknown);
|
|
68
|
+
} catch (error) {
|
|
69
|
+
throw transportFailed(
|
|
70
|
+
'body',
|
|
71
|
+
`${url} answered 2xx with a body that is not JSON — a proxy answered instead of the app`,
|
|
72
|
+
read ? 'retryable' : undefined,
|
|
73
|
+
{ url },
|
|
74
|
+
error,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
return answer.enveloped ? decodeRecordEnvelope(body) : { data: body };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Adopt, then remove: a key in both is gone, never resurrected by the same answer. */
|
|
81
|
+
function adopt(envelope: RecordEnvelope): void {
|
|
82
|
+
const sink = recordSink();
|
|
83
|
+
if (sink === undefined) return;
|
|
84
|
+
for (const [type, rows] of Object.entries(envelope.records ?? {})) sink.adopt(type, rows);
|
|
85
|
+
for (const [type, keys] of Object.entries(envelope.removed ?? {})) sink.remove(type, keys);
|
|
86
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ONE conflict vocabulary: which row survives when an optimistic local write and the server's
|
|
3
|
+
* answer disagree. Row-shaped because the client store is — a merge over a mutator's OUTPUT had
|
|
4
|
+
* nowhere to land, and realtime's rebase silently dropped it. Tier 0 so `action` and `realtime`
|
|
5
|
+
* (both tier 3) name the same type.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** One record as the client store holds it: a JSON object, keyed by field name. */
|
|
9
|
+
export type Row = Readonly<Record<string, unknown>>;
|
|
10
|
+
|
|
11
|
+
export type ConflictPolicy =
|
|
12
|
+
| 'server-wins'
|
|
13
|
+
| 'last-write-wins'
|
|
14
|
+
| { readonly kind: 'custom'; readonly merge: (local: Row, server: Row) => Row };
|
|
15
|
+
|
|
16
|
+
export interface ResolveConflictOptions {
|
|
17
|
+
/**
|
|
18
|
+
* The field `last-write-wins` compares — a finite number (epoch ms) the SERVER wrote. Default
|
|
19
|
+
* `updatedAt`, the name realtime's rebase has always read.
|
|
20
|
+
*/
|
|
21
|
+
readonly clockField?: string | undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The surviving row. `last-write-wins` keeps the local row only when its clock is provably newer
|
|
26
|
+
* by the server's own field; a missing or non-numeric clock on either side is no proof, so the
|
|
27
|
+
* server's row stands — the store must never keep a guess over an answer.
|
|
28
|
+
*/
|
|
29
|
+
export function resolveConflict(
|
|
30
|
+
policy: ConflictPolicy,
|
|
31
|
+
local: Row,
|
|
32
|
+
server: Row,
|
|
33
|
+
options: ResolveConflictOptions = {},
|
|
34
|
+
): Row {
|
|
35
|
+
if (typeof policy !== 'string') return policy.merge(local, server);
|
|
36
|
+
if (policy === 'server-wins') return server;
|
|
37
|
+
const field = options.clockField ?? 'updatedAt';
|
|
38
|
+
const localAt = clockOf(local, field);
|
|
39
|
+
const serverAt = clockOf(server, field);
|
|
40
|
+
return localAt !== undefined && serverAt !== undefined && localAt > serverAt ? local : server;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function clockOf(row: Row, field: string): number | undefined {
|
|
44
|
+
// Own keys only: a field named `constructor` must not read `Object.prototype`'s.
|
|
45
|
+
if (!Object.hasOwn(row, field)) return undefined;
|
|
46
|
+
const value = row[field];
|
|
47
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
48
|
+
}
|
package/src/context.ts
CHANGED
|
@@ -37,6 +37,7 @@ import { UltimateError } from './errors';
|
|
|
37
37
|
import { finiteOption } from './finite-option';
|
|
38
38
|
import { traceId as newTraceId, uuid } from './ids';
|
|
39
39
|
import { type Logger, logger as rootLogger, setLoggerContextFields } from './logger';
|
|
40
|
+
import { installTraceHeaders } from './outbound-headers';
|
|
40
41
|
import { type Role, resolveRole } from './roles';
|
|
41
42
|
import { installedServices, isManagedService } from './service';
|
|
42
43
|
|
|
@@ -130,6 +131,13 @@ export interface CtxInit {
|
|
|
130
131
|
/** Epoch ms. `@ultimat3/http`'s `startDeadline` is the one production writer. */
|
|
131
132
|
readonly deadlineAt?: number | undefined;
|
|
132
133
|
readonly services?: ServiceBag | undefined;
|
|
134
|
+
/**
|
|
135
|
+
* `false` installs no `defineService` factory — only `services` — and leaves the registered
|
|
136
|
+
* ones to the caller. `@ultimat3/http` is that caller: its context exists before the `auth`
|
|
137
|
+
* stage names the actor, and a service built here would act as anonymous for the whole request.
|
|
138
|
+
* Default `true`.
|
|
139
|
+
*/
|
|
140
|
+
readonly installServices?: boolean | undefined;
|
|
133
141
|
}
|
|
134
142
|
|
|
135
143
|
/**
|
|
@@ -184,7 +192,8 @@ export function createContext(init: CtxInit = {}): Ctx {
|
|
|
184
192
|
// wins over an auto-installed one of the same name — a test's hand-built mock overrides the
|
|
185
193
|
// real thing on purpose.
|
|
186
194
|
const preview: CtxFacts = Object.freeze({ ...explicit, ...fields, services: explicit });
|
|
187
|
-
const
|
|
195
|
+
const installed = init.installServices === false ? {} : installedServices(preview);
|
|
196
|
+
const services: ServiceBag = Object.freeze({ ...installed, ...explicit });
|
|
188
197
|
const ctx = {
|
|
189
198
|
// Services ride ON the context, not only under `ctx.services`: `CtxServices` exists to be
|
|
190
199
|
// augmented, so `ctx.posts` has to BE the service. Spread first, so a service that collides
|
|
@@ -208,6 +217,8 @@ export function createContext(init: CtxInit = {}): Ctx {
|
|
|
208
217
|
}
|
|
209
218
|
|
|
210
219
|
export function runWithContext<T>(ctx: Ctx, fn: () => T): T {
|
|
220
|
+
// A request scope is what gives an outbound typed call a budget to forward; see the module.
|
|
221
|
+
installTraceHeaders();
|
|
211
222
|
return requestContext.run(ctx, fn);
|
|
212
223
|
}
|
|
213
224
|
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Single responsibility: core's OWN error-code titles, registered at import. A side-effect
|
|
2
|
+
// anchor, bare-imported by the barrel — the same shape as `schema-error-codes.ts` — so the table
|
|
3
|
+
// rides every barrel import and none of the light paths: `UltimateError` alone no longer carries
|
|
4
|
+
// 40 titles into a browser island that throws one code. Listed in `SIDE_EFFECTS_ANCHORS`.
|
|
5
|
+
|
|
6
|
+
import type { ErrorCodeDescriptor } from './error-codes';
|
|
7
|
+
import { descriptor, registerCoreErrorCodes } from './error-codes';
|
|
8
|
+
|
|
9
|
+
/** Codes owned by `@ultimat3/core`. Every other package calls `registerErrorCodes()`. */
|
|
10
|
+
const CORE_CODE_TITLES = {
|
|
11
|
+
X_ABORTED: 'operation aborted',
|
|
12
|
+
X_ASYNC_CONTEXT_UNAVAILABLE: 'async context unavailable',
|
|
13
|
+
// The browser seam's three (`client-transport.ts`, `record-envelope.ts`, `client-scope.ts`).
|
|
14
|
+
X_CLIENT_RECORD_ENVELOPE_INVALID: 'a response marked as a records envelope has the wrong shape',
|
|
15
|
+
X_CLIENT_SCOPE_CHANGED: 'the page changed principal while this request was in flight',
|
|
16
|
+
X_CLIENT_TRANSPORT_FAILED: 'a browser request got no answer from the app',
|
|
17
|
+
X_CONFIG_INVALID: 'app.config.ts is invalid',
|
|
18
|
+
X_CURSOR_INVALID: 'pagination cursor is malformed, tampered with or from another query',
|
|
19
|
+
X_CURSOR_SECRET_DEV: 'cursors are signed with the shipped development key',
|
|
20
|
+
X_DRAINING: 'process is draining and refuses new work',
|
|
21
|
+
X_ENV_EXAMPLE_DRIFT: '.env.example does not declare every variable the schema requires',
|
|
22
|
+
X_ENV_MISSING: 'required environment variables are missing or invalid',
|
|
23
|
+
X_ENVIRONMENT_INVALID: 'ULTIMATE_ENV is not a known environment',
|
|
24
|
+
X_ERROR_CODE_DUPLICATE: 'error code registered twice',
|
|
25
|
+
X_ERROR_REPORTER_DSN_INVALID: 'the error monitor DSN is malformed',
|
|
26
|
+
X_ERROR_RETRY_INVALID: 'error retry classification is unknown or already claimed',
|
|
27
|
+
// Core's own, and deliberately NOT `@ultimat3/http`'s `X_OVERLOADED`: that code is owned by a
|
|
28
|
+
// tier-2 package, and a tier-0 gate borrowing it upward is an import core may not make. The two
|
|
29
|
+
// read alike to an operator and the fix lines say which ceiling to widen.
|
|
30
|
+
X_FLIGHT_GATE_OVERLOADED: 'a concurrency gate is at its ceiling and its queue is full',
|
|
31
|
+
X_ID_INVALID: 'value is not a valid id',
|
|
32
|
+
X_IMAGE_DECODE_FAILED: 'image bytes are malformed, truncated or internally inconsistent',
|
|
33
|
+
X_IMAGE_TOO_LARGE: 'image exceeds the pipeline pixel ceiling',
|
|
34
|
+
X_IMAGE_UNSUPPORTED: 'the built-in image pipeline cannot read or write this format',
|
|
35
|
+
X_INTERNAL: 'unexpected internal framework error',
|
|
36
|
+
X_INVARIANT: 'invariant violated',
|
|
37
|
+
// Owned here rather than by `@ultimat3/time`, which declared it until 16.x: `@ultimat3/money`
|
|
38
|
+
// needs the same screen and tier 1 may not import sideways. One code, one declaration.
|
|
39
|
+
X_LOCALE_INVALID: 'not a well-formed BCP 47 tag',
|
|
40
|
+
X_METRIC_CARDINALITY:
|
|
41
|
+
'a metric exceeded its series ceiling and is folding into one overflow series',
|
|
42
|
+
X_METRIC_NAME_INVALID:
|
|
43
|
+
'metric name is malformed, or redeclared with a different kind, bounds or observer',
|
|
44
|
+
X_METRIC_VALUE_INVALID: 'metric value is not recordable',
|
|
45
|
+
X_NO_CONTEXT: 'no request context is active',
|
|
46
|
+
X_NOT_IMPLEMENTED: 'this driver does not implement the requested feature',
|
|
47
|
+
X_OTLP_ENDPOINT_INVALID: 'the OTLP collector endpoint is missing or malformed',
|
|
48
|
+
// Its own code rather than the endpoint's, because a title is what an agent reads first:
|
|
49
|
+
// `x errors explain X_OTLP_ENDPOINT_INVALID` would send it to inspect a variable that is fine.
|
|
50
|
+
X_OTLP_HEADERS_INVALID: 'OTEL_EXPORTER_OTLP_HEADERS is malformed',
|
|
51
|
+
X_OTLP_PROTOCOL_UNSUPPORTED: 'the OTLP protocol requested is not OTLP/HTTP JSON',
|
|
52
|
+
X_READINESS_CHECK_DUPLICATE: 'a readiness check name is registered twice',
|
|
53
|
+
X_REGISTRAR_CONFLICT: 'two different registrars are loaded for one primitive kind',
|
|
54
|
+
X_REGISTRAR_MISSING: 'no registrar is loaded for a primitive kind',
|
|
55
|
+
X_ROLE_INVALID: 'ROLE is not a known runtime role',
|
|
56
|
+
X_SERVICE_DUPLICATE: 'a service name is registered twice',
|
|
57
|
+
X_SERVICE_MISSING: 'service is not registered on the request context',
|
|
58
|
+
X_SHUTDOWN_TIMEOUT: 'graceful shutdown exceeded its deadline',
|
|
59
|
+
X_SUPERSEDED: 'a later generation superseded this work',
|
|
60
|
+
X_TELEMETRY_SAMPLER_ARG_INVALID: 'the trace sampling ratio is not a number between 0 and 1',
|
|
61
|
+
// Core's, though core does not throw it — the twin of `X_ABORTED`, and `@ultimat3/http` already
|
|
62
|
+
// calls it "borrowed (core's concept)" in `HTTP_BORROWED_ERROR_CODES`. A deadline that expired
|
|
63
|
+
// and a caller that went away are one pair of facts, so they are titled and classified in one
|
|
64
|
+
// place rather than by whichever package happened to raise one first.
|
|
65
|
+
X_TIMEOUT: 'operation exceeded its deadline',
|
|
66
|
+
X_UNREACHABLE: 'unreachable branch was reached',
|
|
67
|
+
} as const;
|
|
68
|
+
|
|
69
|
+
export type CoreErrorCode = keyof typeof CORE_CODE_TITLES;
|
|
70
|
+
|
|
71
|
+
export const CORE_ERROR_CODES: Readonly<Record<CoreErrorCode, ErrorCodeDescriptor>> = Object.freeze(
|
|
72
|
+
Object.fromEntries(
|
|
73
|
+
Object.entries(CORE_CODE_TITLES).map(([code, title]) => [code, descriptor({ title })]),
|
|
74
|
+
) as Record<CoreErrorCode, ErrorCodeDescriptor>,
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
registerCoreErrorCodes(CORE_ERROR_CODES);
|
package/src/error-codes.ts
CHANGED
|
@@ -28,75 +28,26 @@ export interface ErrorCodeEntry extends ErrorCodeDescriptor {
|
|
|
28
28
|
*/
|
|
29
29
|
export const ERROR_DOCS_URL = 'https://github.com/developerz-ai/ultimate/wiki/Error-Codes';
|
|
30
30
|
|
|
31
|
-
|
|
32
|
-
const CORE_CODE_TITLES = {
|
|
33
|
-
X_ABORTED: 'operation aborted',
|
|
34
|
-
X_ASYNC_CONTEXT_UNAVAILABLE: 'async context unavailable',
|
|
35
|
-
X_CONFIG_INVALID: 'app.config.ts is invalid',
|
|
36
|
-
X_CURSOR_INVALID: 'pagination cursor is malformed, tampered with or from another query',
|
|
37
|
-
X_CURSOR_SECRET_DEV: 'cursors are signed with the shipped development key',
|
|
38
|
-
X_DRAINING: 'process is draining and refuses new work',
|
|
39
|
-
X_ENV_EXAMPLE_DRIFT: '.env.example does not declare every variable the schema requires',
|
|
40
|
-
X_ENV_MISSING: 'required environment variables are missing or invalid',
|
|
41
|
-
X_ENVIRONMENT_INVALID: 'ULTIMATE_ENV is not a known environment',
|
|
42
|
-
X_ERROR_CODE_DUPLICATE: 'error code registered twice',
|
|
43
|
-
X_ERROR_REPORTER_DSN_INVALID: 'the error monitor DSN is malformed',
|
|
44
|
-
X_ERROR_RETRY_INVALID: 'error retry classification is unknown or already claimed',
|
|
45
|
-
// Core's own, and deliberately NOT `@ultimat3/http`'s `X_OVERLOADED`: that code is owned by a
|
|
46
|
-
// tier-2 package, and a tier-0 gate borrowing it upward is an import core may not make. The two
|
|
47
|
-
// read alike to an operator and the fix lines say which ceiling to widen.
|
|
48
|
-
X_FLIGHT_GATE_OVERLOADED: 'a concurrency gate is at its ceiling and its queue is full',
|
|
49
|
-
X_ID_INVALID: 'value is not a valid id',
|
|
50
|
-
X_IMAGE_DECODE_FAILED: 'image bytes are malformed, truncated or internally inconsistent',
|
|
51
|
-
X_IMAGE_TOO_LARGE: 'image exceeds the pipeline pixel ceiling',
|
|
52
|
-
X_IMAGE_UNSUPPORTED: 'the built-in image pipeline cannot read or write this format',
|
|
53
|
-
X_INTERNAL: 'unexpected internal framework error',
|
|
54
|
-
X_INVARIANT: 'invariant violated',
|
|
55
|
-
// Owned here rather than by `@ultimat3/time`, which declared it until 16.x: `@ultimat3/money`
|
|
56
|
-
// needs the same screen and tier 1 may not import sideways. One code, one declaration.
|
|
57
|
-
X_LOCALE_INVALID: 'not a well-formed BCP 47 tag',
|
|
58
|
-
X_METRIC_CARDINALITY:
|
|
59
|
-
'a metric exceeded its series ceiling and is folding into one overflow series',
|
|
60
|
-
X_METRIC_NAME_INVALID:
|
|
61
|
-
'metric name is malformed, or redeclared with a different kind, bounds or observer',
|
|
62
|
-
X_METRIC_VALUE_INVALID: 'metric value is not recordable',
|
|
63
|
-
X_NO_CONTEXT: 'no request context is active',
|
|
64
|
-
X_NOT_IMPLEMENTED: 'this driver does not implement the requested feature',
|
|
65
|
-
X_OTLP_ENDPOINT_INVALID: 'the OTLP collector endpoint is missing or malformed',
|
|
66
|
-
// Its own code rather than the endpoint's, because a title is what an agent reads first:
|
|
67
|
-
// `x errors explain X_OTLP_ENDPOINT_INVALID` would send it to inspect a variable that is fine.
|
|
68
|
-
X_OTLP_HEADERS_INVALID: 'OTEL_EXPORTER_OTLP_HEADERS is malformed',
|
|
69
|
-
X_OTLP_PROTOCOL_UNSUPPORTED: 'the OTLP protocol requested is not OTLP/HTTP JSON',
|
|
70
|
-
X_READINESS_CHECK_DUPLICATE: 'a readiness check name is registered twice',
|
|
71
|
-
X_REGISTRAR_CONFLICT: 'two different registrars are loaded for one primitive kind',
|
|
72
|
-
X_REGISTRAR_MISSING: 'no registrar is loaded for a primitive kind',
|
|
73
|
-
X_ROLE_INVALID: 'ROLE is not a known runtime role',
|
|
74
|
-
X_SERVICE_DUPLICATE: 'a service name is registered twice',
|
|
75
|
-
X_SERVICE_MISSING: 'service is not registered on the request context',
|
|
76
|
-
X_SHUTDOWN_TIMEOUT: 'graceful shutdown exceeded its deadline',
|
|
77
|
-
X_SUPERSEDED: 'a later generation superseded this work',
|
|
78
|
-
X_TELEMETRY_SAMPLER_ARG_INVALID: 'the trace sampling ratio is not a number between 0 and 1',
|
|
79
|
-
// Core's, though core does not throw it — the twin of `X_ABORTED`, and `@ultimat3/http` already
|
|
80
|
-
// calls it "borrowed (core's concept)" in `HTTP_BORROWED_ERROR_CODES`. A deadline that expired
|
|
81
|
-
// and a caller that went away are one pair of facts, so they are titled and classified in one
|
|
82
|
-
// place rather than by whichever package happened to raise one first.
|
|
83
|
-
X_TIMEOUT: 'operation exceeded its deadline',
|
|
84
|
-
X_UNREACHABLE: 'unreachable branch was reached',
|
|
85
|
-
} as const;
|
|
86
|
-
|
|
87
|
-
export type CoreErrorCode = keyof typeof CORE_CODE_TITLES;
|
|
88
|
-
|
|
89
|
-
function descriptor(declaration: ErrorCodeDeclaration): ErrorCodeDescriptor {
|
|
31
|
+
export function descriptor(declaration: ErrorCodeDeclaration): ErrorCodeDescriptor {
|
|
90
32
|
return Object.freeze({ title: declaration.title, docs: declaration.docs ?? ERROR_DOCS_URL });
|
|
91
33
|
}
|
|
92
34
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
35
|
+
/**
|
|
36
|
+
* Empty at load, on purpose: core's own titles live in `core-error-codes.ts`, which the barrel
|
|
37
|
+
* bare-imports as a side-effect anchor. A browser module that constructs an `UltimateError` from a
|
|
38
|
+
* light path pays for the class and this lookup, not a 40-row table — an untitled code renders
|
|
39
|
+
* through `humanize`, the trade `@ultimat3/realtime` already made for its own titles.
|
|
40
|
+
*/
|
|
41
|
+
const registry = new Map<string, ErrorCodeDescriptor>();
|
|
98
42
|
|
|
99
|
-
|
|
43
|
+
/** What `resetErrorCodes` restores: the codes `registerCoreErrorCodes` installed. */
|
|
44
|
+
const coreCodes = new Map<string, ErrorCodeDescriptor>();
|
|
45
|
+
|
|
46
|
+
/** `core-error-codes.ts`'s one call. Registered like any package's, and remembered for a reset. */
|
|
47
|
+
export function registerCoreErrorCodes(codes: Readonly<Record<string, ErrorCodeDescriptor>>): void {
|
|
48
|
+
registerErrorCodes(codes);
|
|
49
|
+
for (const [code, value] of Object.entries(codes)) coreCodes.set(code, value);
|
|
50
|
+
}
|
|
100
51
|
|
|
101
52
|
/**
|
|
102
53
|
* Register a package's codes. Throws `X_ERROR_CODE_DUPLICATE` on collision so two packages
|
|
@@ -145,7 +96,7 @@ export function listErrorCodes(): readonly ErrorCodeEntry[] {
|
|
|
145
96
|
/** Test-only: drop everything a package registered, keeping core's codes. */
|
|
146
97
|
export function resetErrorCodes(): void {
|
|
147
98
|
registry.clear();
|
|
148
|
-
for (const [code, value] of
|
|
99
|
+
for (const [code, value] of coreCodes) registry.set(code, value);
|
|
149
100
|
}
|
|
150
101
|
|
|
151
102
|
/**
|
package/src/error-retry.ts
CHANGED
|
@@ -58,6 +58,9 @@ const CORE_ERROR_RETRY: ReadonlyMap<string, ErrorRetry> = new Map(
|
|
|
58
58
|
// re-run produces the same refusal by construction, so an UNCLASSIFIED reading would spend a
|
|
59
59
|
// job's whole retry policy proving that the world has still moved on.
|
|
60
60
|
X_SUPERSEDED: 'terminal',
|
|
61
|
+
// The principal fence's twin of `X_SUPERSEDED`, listed for the same reason: re-sending a read
|
|
62
|
+
// from the previous principal's scope is refused identically every time.
|
|
63
|
+
X_CLIENT_SCOPE_CHANGED: 'terminal',
|
|
61
64
|
} as const),
|
|
62
65
|
);
|
|
63
66
|
|
|
@@ -3,14 +3,14 @@
|
|
|
3
3
|
// built with, and the retry classification a code carries. One group because a code, its title,
|
|
4
4
|
// its rendering and its retry class are one contract; `index.ts` re-exports every name explicitly.
|
|
5
5
|
|
|
6
|
+
export type { CoreErrorCode } from '../core-error-codes';
|
|
7
|
+
export { CORE_ERROR_CODES } from '../core-error-codes';
|
|
6
8
|
export type {
|
|
7
|
-
CoreErrorCode,
|
|
8
9
|
ErrorCodeDeclaration,
|
|
9
10
|
ErrorCodeDescriptor,
|
|
10
11
|
ErrorCodeEntry,
|
|
11
12
|
} from '../error-codes';
|
|
12
13
|
export {
|
|
13
|
-
CORE_ERROR_CODES,
|
|
14
14
|
describeErrorCode,
|
|
15
15
|
ERROR_DOCS_URL,
|
|
16
16
|
errorCodeSnapshot,
|
package/src/generation-fence.ts
CHANGED
|
@@ -51,7 +51,14 @@ function superseded(subject: string, issued: number, current: number): UltimateE
|
|
|
51
51
|
});
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
/**
|
|
54
|
+
/**
|
|
55
|
+
* Whether a caught value is a supersession — this fence's refusal, or `X_CLIENT_SCOPE_CHANGED`,
|
|
56
|
+
* the principal fence's (`client-scope.ts`). One reader for both, because a caller does the same
|
|
57
|
+
* thing with either: drop the answer and render nothing. Never `error.code`.
|
|
58
|
+
*/
|
|
55
59
|
export function isSuperseded(error: unknown): boolean {
|
|
56
|
-
return
|
|
60
|
+
return (
|
|
61
|
+
isUltimateError(error) &&
|
|
62
|
+
(error.code === 'X_SUPERSEDED' || error.code === 'X_CLIENT_SCOPE_CHANGED')
|
|
63
|
+
);
|
|
57
64
|
}
|
package/src/index.ts
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
// are declared side-effecting too and are deliberately NOT anchored: each is reached by whatever
|
|
15
15
|
// uses it, and anchoring `context.ts` alone measured +3,485 B on a browser chunk for a provider a
|
|
16
16
|
// browser can never fire.
|
|
17
|
+
import './core-error-codes';
|
|
17
18
|
import './schema-error-codes';
|
|
18
19
|
|
|
19
20
|
export type {
|
|
@@ -43,10 +44,14 @@ export {
|
|
|
43
44
|
export { APP_VERSION_KEY, appVersion, DEFAULT_APP_VERSION } from './app-version';
|
|
44
45
|
export { assert, assertNever, type InvariantOptions, invariant } from './assert';
|
|
45
46
|
export { type AsyncContext, asyncContext } from './async-context';
|
|
47
|
+
/** The four shapes an async region can be in — produced by `realtime`, rendered by `ui`. */
|
|
48
|
+
export type { AsyncState } from './async-state';
|
|
46
49
|
export type { BackoffCurve, BackoffOptions, JitterMode, Random } from './backoff';
|
|
47
50
|
export { backoffDelay } from './backoff';
|
|
48
51
|
export { CACHE_TIERS, type CacheTierName } from './cache-vocabulary';
|
|
49
52
|
export { canonicalJson, fingerprint } from './canonical-json';
|
|
53
|
+
export type { FetchLike, TransportRequest } from './client-dispatch';
|
|
54
|
+
export { IDEMPOTENCY_HEADER } from './client-dispatch';
|
|
50
55
|
/**
|
|
51
56
|
* Flight control for a typed client, and OPT-IN by construction: `@ultimat3/action`'s and
|
|
52
57
|
* `@ultimat3/query`'s `client.ts` each name `ClientFlight` as a TYPE only, so a caller that never
|
|
@@ -61,6 +66,23 @@ export type {
|
|
|
61
66
|
FlightPlan,
|
|
62
67
|
} from './client-flight';
|
|
63
68
|
export { createClientFlight, DEFAULT_CLIENT_RETRY, isTransientFailure } from './client-flight';
|
|
69
|
+
export type { ActionRoute } from './client-paths';
|
|
70
|
+
export {
|
|
71
|
+
actionPath,
|
|
72
|
+
actionRoute,
|
|
73
|
+
pluralize,
|
|
74
|
+
QUERY_PATH_PREFIX,
|
|
75
|
+
queryPath,
|
|
76
|
+
splitWords,
|
|
77
|
+
} from './client-paths';
|
|
78
|
+
export type { TransportFailure } from './client-problem';
|
|
79
|
+
/**
|
|
80
|
+
* The browser seam (plan 101): ONE HTTP function, the records envelope it decodes, the per-tab
|
|
81
|
+
* page handle records land in, and the principal fence every client layer subscribes to.
|
|
82
|
+
*/
|
|
83
|
+
export type { ClientScope } from './client-scope';
|
|
84
|
+
export { onRescope, rescope } from './client-scope';
|
|
85
|
+
export { clientTransport } from './client-transport';
|
|
64
86
|
/** What a typed client puts on the wire. `retryForStatus` is what fills a failure's `retry`. */
|
|
65
87
|
export type { WireAnswer } from './client-wire';
|
|
66
88
|
export { FRAMEWORK_CODE, problemOf, retryForStatus, traceHeaders } from './client-wire';
|
|
@@ -86,6 +108,8 @@ export type {
|
|
|
86
108
|
export { defineConfig, INBOX_RETENTION_KEYS } from './config';
|
|
87
109
|
export type { PwaColors, PwaConfig, PwaOfflineConfig, PwaSchemeColors } from './config-pwa';
|
|
88
110
|
export { PWA_COLOR_KEYS, PWA_SCHEMES } from './config-pwa';
|
|
111
|
+
export type { ConflictPolicy, ResolveConflictOptions, Row } from './conflict-policy';
|
|
112
|
+
export { resolveConflict } from './conflict-policy';
|
|
89
113
|
export type { Ctx, CtxFacts, CtxInit, CtxPatch, CtxServices, ServiceBag } from './context';
|
|
90
114
|
export {
|
|
91
115
|
createContext,
|
|
@@ -521,7 +545,24 @@ export type { Direction } from './locale-direction';
|
|
|
521
545
|
export { directionOf, isRtl } from './locale-direction';
|
|
522
546
|
export { isMcpExposed, type McpExposureDeclaration } from './mcp-exposure';
|
|
523
547
|
export { nearestName } from './nearest-name';
|
|
548
|
+
/** The message pwa's `sw.js` posts and realtime's outbox listens for. */
|
|
549
|
+
export { OUTBOX_DRAIN_MESSAGE, type OutboxDrainMessage } from './outbox-drain';
|
|
550
|
+
/** The `<meta name>`s render writes and the page client, realtime and pwa read. */
|
|
551
|
+
export {
|
|
552
|
+
APP_UPDATE_MESSAGE,
|
|
553
|
+
CLIENT_BUILD_META,
|
|
554
|
+
CLIENT_PERSIST_META,
|
|
555
|
+
CLIENT_SCOPE_HEADER,
|
|
556
|
+
CLIENT_SCOPE_META,
|
|
557
|
+
CLIENT_SYNC_META,
|
|
558
|
+
CLIENT_SYNC_WORKER_META,
|
|
559
|
+
} from './page-meta';
|
|
524
560
|
export { type CappedBody, readWithinLimit } from './read-capped';
|
|
561
|
+
export type { RecordEnvelope, RecordRows } from './record-envelope';
|
|
562
|
+
export { decodeRecordEnvelope, encodeRecordEnvelope, RECORDS_HEADER } from './record-envelope';
|
|
563
|
+
export { RECORDS_OPENAPI_HEADER, recordEnvelopeSchema } from './record-envelope-openapi';
|
|
564
|
+
export type { PageClient, RecordSink } from './record-sink';
|
|
565
|
+
export { pageClient } from './record-sink';
|
|
525
566
|
export type {
|
|
526
567
|
ModuleRegistrar,
|
|
527
568
|
PrimitiveFactory,
|
|
@@ -551,7 +592,13 @@ export { DEFAULT_ROLE, isRole, ROLE_INFO, ROLES, resolveRole } from './roles';
|
|
|
551
592
|
export type { HydrateStrategy, OfflineStrategy, RenderMode } from './route-vocabulary';
|
|
552
593
|
export { HYDRATE_STRATEGIES, OFFLINE_STRATEGIES, RENDER_MODES } from './route-vocabulary';
|
|
553
594
|
export { safeUrl, URL_ATTRIBUTES } from './safe-url';
|
|
554
|
-
export {
|
|
595
|
+
export {
|
|
596
|
+
defineService,
|
|
597
|
+
installedServices,
|
|
598
|
+
registeredServiceNames,
|
|
599
|
+
resetServices,
|
|
600
|
+
type ServiceFactory,
|
|
601
|
+
} from './service';
|
|
555
602
|
export type { FlightJoin, Scheduler, SingleFlight, SingleFlightOptions } from './single-flight';
|
|
556
603
|
export { createSingleFlight } from './single-flight';
|
|
557
604
|
export { endOfLiteral, maskLiterals, QUOTES, stripComments } from './source-mask';
|
|
@@ -584,3 +631,9 @@ export {
|
|
|
584
631
|
webhookSignature,
|
|
585
632
|
webhookSigningString,
|
|
586
633
|
} from './webhook-signature';
|
|
634
|
+
/**
|
|
635
|
+
* A write's public name — the digest of its idempotency key — and the server scope that carries it
|
|
636
|
+
* from `@ultimat3/action`'s HTTP projection to the layers that stamp it on a `records` frame.
|
|
637
|
+
*/
|
|
638
|
+
export { isWriteDigest, WRITE_DIGEST_LENGTH, writeDigest } from './write-digest';
|
|
639
|
+
export { currentWriteOrigin, WRITE_ORIGIN_WAL_PREFIX, withWriteOrigin } from './write-origin';
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The trace and request-budget headers a SERVER-side typed call carries onward, installed into the
|
|
3
|
+
* transport's slot by the code that makes them meaningful — `runWithContext` and `startSpan` — and
|
|
4
|
+
* never at import. A browser runs neither, so its slot stays empty and `clientTransport` carries
|
|
5
|
+
* zero bytes of telemetry, context or logger: that graph was 12.9 kB of every island that called
|
|
6
|
+
* `rpc()` or `queryClient()`, for a header a browser never has a value for.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { traceHeaders } from './client-wire';
|
|
10
|
+
import { outboundSlot } from './record-sink';
|
|
11
|
+
|
|
12
|
+
/** Idempotent: the first call fills the slot, every later one is a property read. */
|
|
13
|
+
export function installTraceHeaders(): void {
|
|
14
|
+
const slot = outboundSlot();
|
|
15
|
+
if (slot.outboundHeaders === undefined) slot.outboundHeaders = traceHeaders;
|
|
16
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one message a service worker posts to its window clients when the platform says the
|
|
3
|
+
* connection is back: "drain your outbox now". Tier 0 because the SENDER is `@ultimat3/pwa`'s
|
|
4
|
+
* emitted `sw.js` (tier 4) and the LISTENER is `@ultimat3/realtime`'s outbox (tier 3) — one literal,
|
|
5
|
+
* one home, never a string each side retypes.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** `event.data.type` of the drain message. */
|
|
9
|
+
export const OUTBOX_DRAIN_MESSAGE = 'x-outbox-drain';
|
|
10
|
+
|
|
11
|
+
/** The whole message. It carries nothing else: the outbox lives in the page, not in the worker. */
|
|
12
|
+
export interface OutboxDrainMessage {
|
|
13
|
+
readonly type: typeof OUTBOX_DRAIN_MESSAGE;
|
|
14
|
+
}
|
package/src/page-meta.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `<meta name>`s a rendered document hands its page client. Tier 0 because the WRITER is
|
|
3
|
+
* `@ultimat3/render` (tier 4) and the READERS are core's `pageClient()`, `@ultimat3/realtime`'s
|
|
4
|
+
* socket host (tier 3) and `@ultimat3/pwa`'s skew check — one literal per fact, never retyped.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** The page's principal: content = that principal, empty = anonymous, absent = unscoped. */
|
|
8
|
+
export const CLIENT_SCOPE_META = 'ultimate-scope';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The same scope on the RESPONSE of a private document, so a service worker — which never parses
|
|
12
|
+
* HTML — can partition its offline pages by principal. Present only where the meta is; its value
|
|
13
|
+
* is the meta's (`''` for the anonymous visitor).
|
|
14
|
+
*/
|
|
15
|
+
export const CLIENT_SCOPE_HEADER = 'x-ultimate-scope';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The build id the document was rendered by. `x-ultimate-build` — the same spelling as the header,
|
|
19
|
+
* and the one `@ultimat3/pwa` shipped as `BUILD_ID_META`, so no shipped reader changes.
|
|
20
|
+
*/
|
|
21
|
+
export const CLIENT_BUILD_META = 'x-ultimate-build';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* `event.data.type` of the message a service worker posts to its windows when a newer build is
|
|
25
|
+
* waiting (`{ type, to: <build id> }`). Sent by `@ultimat3/pwa`'s emitted `sw.js`, read by an app's
|
|
26
|
+
* update banner — one literal both import, beside the build id it is about.
|
|
27
|
+
*/
|
|
28
|
+
export const APP_UPDATE_MESSAGE = 'AppUpdateAvailable';
|
|
29
|
+
|
|
30
|
+
/** Where the page's one socket dials: `/_x/sync`, or the deployment's absolute `SYNC_URL`. */
|
|
31
|
+
export const CLIENT_SYNC_META = 'ultimate-sync';
|
|
32
|
+
|
|
33
|
+
/** The worker script that hosts the socket; absent when the app has no realtime. */
|
|
34
|
+
export const CLIENT_SYNC_WORKER_META = 'ultimate-sync-worker';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The record types the client keeps on disk — the entities registered with `persist: true`,
|
|
38
|
+
* comma-separated (`post,comment`). The browser holds no entity declarations, so this is the one
|
|
39
|
+
* place it learns which types `@ultimat3/realtime`'s persister may write. Absent = none.
|
|
40
|
+
*/
|
|
41
|
+
export const CLIENT_PERSIST_META = 'ultimate-persist';
|
package/src/page.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@ultimat3/core/page` — the ONE light entry for browser code: the page handle, the principal
|
|
3
|
+
* fence, the names a document, a service worker and an island agree on, and `UltimateError` with
|
|
4
|
+
* the render helper a refusal's `fix:` needs. What it never carries is a titles TABLE: any barrel
|
|
5
|
+
* import retains the anchored `core-error-codes.ts` and `schema-error-codes.ts`, and a browser
|
|
6
|
+
* that loaded no table titles a code from its name. The barrel re-exports every name here;
|
|
7
|
+
* `page-bundle.test.ts` fails if this entry ever grows a table back.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// The client seam's own functions, for the hooks a browser island calls: the one transport, the
|
|
11
|
+
// URL rule, the principal-supersession reader, and the small helpers realtime's browser half uses.
|
|
12
|
+
// Each reaches no titles table — `page-bundle.test.ts` builds all of them together to prove it.
|
|
13
|
+
export { invariant } from './assert';
|
|
14
|
+
export type { AsyncState } from './async-state';
|
|
15
|
+
export type { JitterMode, Random } from './backoff';
|
|
16
|
+
export { backoffDelay } from './backoff';
|
|
17
|
+
export type { FetchLike, TransportRequest } from './client-dispatch';
|
|
18
|
+
export { IDEMPOTENCY_HEADER } from './client-dispatch';
|
|
19
|
+
export { actionPath, queryPath, splitWords } from './client-paths';
|
|
20
|
+
export type { ClientScope } from './client-scope';
|
|
21
|
+
export { onRescope, rescope } from './client-scope';
|
|
22
|
+
export { clientTransport } from './client-transport';
|
|
23
|
+
export { type Clock, systemClock } from './clock';
|
|
24
|
+
export type { ConflictPolicy, Row } from './conflict-policy';
|
|
25
|
+
// Registry-free: it merges two rows and throws nothing, so the page's record store settles a
|
|
26
|
+
// write under a conflict policy without loading the error table.
|
|
27
|
+
export { resolveConflict } from './conflict-policy';
|
|
28
|
+
export { renderFixShellArg, renderThrowable, stringField } from './error-render';
|
|
29
|
+
export { classifyThrown } from './error-retry';
|
|
30
|
+
export type { UltimateErrorInit } from './errors';
|
|
31
|
+
export { isUltimateError, UltimateError } from './errors';
|
|
32
|
+
export { finiteCount, finiteOption } from './finite-option';
|
|
33
|
+
export { isSuperseded } from './generation-fence';
|
|
34
|
+
export { uuid } from './ids';
|
|
35
|
+
export { isJsonObject } from './json-object';
|
|
36
|
+
export type { OutboxDrainMessage } from './outbox-drain';
|
|
37
|
+
export { OUTBOX_DRAIN_MESSAGE } from './outbox-drain';
|
|
38
|
+
export {
|
|
39
|
+
APP_UPDATE_MESSAGE,
|
|
40
|
+
CLIENT_BUILD_META,
|
|
41
|
+
CLIENT_PERSIST_META,
|
|
42
|
+
CLIENT_SCOPE_HEADER,
|
|
43
|
+
CLIENT_SCOPE_META,
|
|
44
|
+
CLIENT_SYNC_META,
|
|
45
|
+
CLIENT_SYNC_WORKER_META,
|
|
46
|
+
} from './page-meta';
|
|
47
|
+
export type { RecordEnvelope, RecordRows } from './record-envelope';
|
|
48
|
+
export { decodeRecordEnvelope, RECORDS_HEADER } from './record-envelope';
|
|
49
|
+
export type { PageClient, RecordSink } from './record-sink';
|
|
50
|
+
export { pageClient } from './record-sink';
|
|
51
|
+
// A write's public name, so the page's store can recognise the `records` frame its own write made.
|
|
52
|
+
export { isWriteDigest, WRITE_DIGEST_LENGTH, writeDigest } from './write-digest';
|