@ultimat3/core 20.2.1 → 22.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +189 -523
- package/README.md +62 -1
- package/package.json +6 -3
- package/src/actor.ts +14 -0
- package/src/address-class.ts +143 -0
- package/src/async-state.ts +19 -0
- package/src/canonical-json.ts +24 -1
- package/src/client-dispatch.ts +163 -0
- package/src/client-flight.ts +22 -3
- 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/config-count.ts +19 -0
- package/src/config-fixes.ts +23 -0
- package/src/config-merge.ts +36 -0
- package/src/config.ts +122 -65
- package/src/conflict-policy.ts +48 -0
- package/src/context.ts +12 -1
- package/src/core-error-codes.ts +78 -0
- package/src/dev-secrets.ts +45 -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/exports/secrets.ts +1 -0
- package/src/generation-fence.ts +9 -2
- package/src/host-rules.ts +71 -0
- package/src/image/exif-orientation.ts +40 -0
- package/src/image/probe.ts +13 -1
- package/src/in-process-fetch.ts +39 -0
- package/src/index.ts +80 -30
- package/src/iso-date.ts +5 -0
- package/src/lifecycle-grace.ts +44 -0
- package/src/lifecycle-signals.ts +35 -0
- package/src/lifecycle.ts +44 -36
- package/src/logger.ts +22 -3
- package/src/measurement-actor.ts +52 -0
- package/src/metrics-text.ts +10 -2
- package/src/otlp-metric-exporter.ts +39 -12
- package/src/otlp-span-exporter.ts +38 -15
- 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/secrets-store.ts +51 -5
- package/src/service.ts +9 -0
- package/src/source-mask.ts +30 -0
- package/src/telemetry.ts +3 -0
- package/src/type-pins.ts +9 -0
- package/src/write-digest.ts +29 -0
- package/src/write-origin.ts +31 -0
- package/src/result.ts +0 -78
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The records envelope: an action's or a query's answer plus the entity rows it touched, so the
|
|
3
|
+
* page's one store adopts them on the way past. Used ONLY behind `RECORDS_HEADER`, which is what
|
|
4
|
+
* keeps an output with no entity rows byte-identical on the wire. Pure and schema-free — the rows
|
|
5
|
+
* are validated by whoever owns the sink, never here.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Row } from './conflict-policy';
|
|
9
|
+
import { UltimateError } from './errors';
|
|
10
|
+
import { isJsonObject } from './json-object';
|
|
11
|
+
|
|
12
|
+
/** Set to `1` on a response whose body is a `RecordEnvelope` rather than the bare output. */
|
|
13
|
+
export const RECORDS_HEADER = 'x-ultimate-records';
|
|
14
|
+
|
|
15
|
+
/** One record type's rows, keyed by record key — the browser cannot derive a key without the entity. */
|
|
16
|
+
export type RecordRows = Readonly<Record<string, Row>>;
|
|
17
|
+
|
|
18
|
+
export interface RecordEnvelope<T = unknown> {
|
|
19
|
+
readonly data: T;
|
|
20
|
+
/** Rows to adopt: record type -> record key -> row. */
|
|
21
|
+
readonly records?: Readonly<Record<string, RecordRows>>;
|
|
22
|
+
/** Record keys to drop, keyed by the entity's record type. */
|
|
23
|
+
readonly removed?: Readonly<Record<string, readonly string[]>>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The server half. An empty `removed` is omitted, so the envelope never claims a deletion. */
|
|
27
|
+
export function encodeRecordEnvelope<T>(
|
|
28
|
+
data: T,
|
|
29
|
+
records: Readonly<Record<string, RecordRows>>,
|
|
30
|
+
removed?: Readonly<Record<string, readonly string[]>>,
|
|
31
|
+
): RecordEnvelope<T> {
|
|
32
|
+
const hasRemoved = removed !== undefined && Object.keys(removed).length > 0;
|
|
33
|
+
return hasRemoved ? { data, records, removed } : { data, records };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The client half: a parsed body, checked for shape and copied onto null-prototype maps — a
|
|
38
|
+
* record type and a record key are server data, and `JSON.parse` mints `__proto__` as a real own
|
|
39
|
+
* key, which assigned onto a plain object would replace its prototype.
|
|
40
|
+
*/
|
|
41
|
+
export function decodeRecordEnvelope(body: unknown): RecordEnvelope {
|
|
42
|
+
if (!isJsonObject(body) || !Object.hasOwn(body, 'data')) {
|
|
43
|
+
throw envelopeInvalid('the body', 'is not an object with an own "data" member');
|
|
44
|
+
}
|
|
45
|
+
const records = byType(body['records'], 'records', (rows, at) => {
|
|
46
|
+
if (!isJsonObject(rows)) throw envelopeInvalid(at, 'is not an object of rows by key');
|
|
47
|
+
return nullProto(
|
|
48
|
+
Object.entries(rows).map(([key, row], index): [string, Row] => {
|
|
49
|
+
if (!isJsonObject(row)) throw envelopeInvalid(`${at} row #${index}`, 'is not an object');
|
|
50
|
+
return [key, row];
|
|
51
|
+
}),
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
const removed = byType(body['removed'], 'removed', (keys, at) => {
|
|
55
|
+
if (!Array.isArray(keys)) throw envelopeInvalid(at, 'is not an array');
|
|
56
|
+
const list: unknown[] = keys;
|
|
57
|
+
const bad = list.findIndex((key) => typeof key !== 'string');
|
|
58
|
+
if (bad !== -1) throw envelopeInvalid(`${at} key #${bad}`, 'is not a string');
|
|
59
|
+
return list as string[];
|
|
60
|
+
});
|
|
61
|
+
return {
|
|
62
|
+
data: body['data'],
|
|
63
|
+
...(records === undefined ? {} : { records }),
|
|
64
|
+
...(removed === undefined ? {} : { removed }),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function byType<T>(
|
|
69
|
+
value: unknown,
|
|
70
|
+
member: string,
|
|
71
|
+
read: (group: unknown, at: string) => T,
|
|
72
|
+
): Readonly<Record<string, T>> | undefined {
|
|
73
|
+
if (value === undefined) return undefined;
|
|
74
|
+
if (!isJsonObject(value)) throw envelopeInvalid(`"${member}"`, 'is not an object');
|
|
75
|
+
return nullProto(
|
|
76
|
+
Object.entries(value).map(([type, group], index): [string, T] => [
|
|
77
|
+
type,
|
|
78
|
+
read(group, `"${member}" entry #${index}`),
|
|
79
|
+
]),
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function nullProto<T>(entries: readonly (readonly [string, T])[]): Record<string, T> {
|
|
84
|
+
const out = Object.create(null) as Record<string, T>;
|
|
85
|
+
for (const [key, value] of entries) {
|
|
86
|
+
Object.defineProperty(out, key, { value, enumerable: true });
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Positions only, never the value: the body is whatever answered, and may carry anything. */
|
|
92
|
+
function envelopeInvalid(where: string, what: string): UltimateError {
|
|
93
|
+
return new UltimateError({
|
|
94
|
+
code: 'X_CLIENT_RECORD_ENVELOPE_INVALID',
|
|
95
|
+
cause: `a response marked ${RECORDS_HEADER}: 1 carried a body where ${where} ${what}`,
|
|
96
|
+
fix: 'build the body with encodeRecordEnvelope() from @ultimat3/core in the handler that set the header, or stop setting the header',
|
|
97
|
+
});
|
|
98
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ONE per-tab client handle, and the seam records flow through into it. Every island bundle
|
|
3
|
+
* carries its own copy of `@ultimat3/core`, so a module-scope singleton here would be one store
|
|
4
|
+
* PER ISLAND; the handle lives on `globalThis` under one `Symbol.for` key instead — the pattern
|
|
5
|
+
* `ultimate.error` already uses — and every copy resolves the same object.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ClientScope } from './client-scope';
|
|
9
|
+
import { CLIENT_SCOPE_META } from './page-meta';
|
|
10
|
+
import type { PendingRecords } from './pending-records';
|
|
11
|
+
import { pendingRecords } from './pending-records';
|
|
12
|
+
import type { RecordRows } from './record-envelope';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Where decoded records land. `@ultimat3/realtime` installs the store. `adopt` MUST be idempotent
|
|
16
|
+
* per `recordType + key`: a deduped read hands one answer to several callers and each adopts it.
|
|
17
|
+
*/
|
|
18
|
+
export interface RecordSink {
|
|
19
|
+
/** `rows` is record key -> row: the key is the server's, the browser cannot derive it. */
|
|
20
|
+
adopt(recordType: string, rows: RecordRows): void;
|
|
21
|
+
remove(recordType: string, keys: readonly string[]): void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface PageClient {
|
|
25
|
+
/**
|
|
26
|
+
* The record store — `undefined` until realtime installs one. ASSIGNING it is the one install
|
|
27
|
+
* path: records answered before then are held (browser pages only) and adopted into the new
|
|
28
|
+
* store once, on assignment. With no `document` (SSR, a test, a server) they are dropped.
|
|
29
|
+
*/
|
|
30
|
+
store: RecordSink | undefined;
|
|
31
|
+
/** The page's one socket — typed by `@ultimat3/realtime`, which fills it. */
|
|
32
|
+
socket: unknown;
|
|
33
|
+
/** Who the page is acting for right now. Read-only here: `rescope()` is the one writer. */
|
|
34
|
+
readonly scope: ClientScope;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The mutable half `client-scope.ts` owns. On the handle, so every module copy shares it. */
|
|
38
|
+
export interface ScopeCell {
|
|
39
|
+
current: ClientScope;
|
|
40
|
+
readonly listeners: Set<(next: ClientScope, prev: ClientScope) => void>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Headers every outbound request carries, or nothing. See `outbound-headers.ts`. */
|
|
44
|
+
export type OutboundHeaders = () => Readonly<Record<string, string>>;
|
|
45
|
+
|
|
46
|
+
interface PageClientHandle extends PageClient {
|
|
47
|
+
readonly scopeCell: ScopeCell;
|
|
48
|
+
/** The early-records buffer, or `undefined` where there is no page to install a store on. */
|
|
49
|
+
readonly pending: PendingRecords | undefined;
|
|
50
|
+
outboundHeaders: OutboundHeaders | undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const HANDLE_KEY: unique symbol = Symbol.for('ultimate.client');
|
|
54
|
+
|
|
55
|
+
type HandleHost = { [HANDLE_KEY]?: PageClientHandle };
|
|
56
|
+
|
|
57
|
+
/** Get-or-create. The only `globalThis` write in the client seam, and it happens on first call. */
|
|
58
|
+
export function pageClient(): PageClient {
|
|
59
|
+
return handle();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The scope cell behind `pageClient().scope`. Internal: `client-scope.ts` is its one caller. */
|
|
63
|
+
export function scopeCell(): ScopeCell {
|
|
64
|
+
return handle().scopeCell;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Where decoded records go RIGHT NOW: the installed store, else the browser's pending buffer, else
|
|
69
|
+
* nowhere. Internal: `client-transport.ts` is its one caller.
|
|
70
|
+
*/
|
|
71
|
+
export function recordSink(): RecordSink | undefined {
|
|
72
|
+
const client = handle();
|
|
73
|
+
return client.store ?? client.pending;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The early-records buffer, for `rescope()` to clear. Internal. */
|
|
77
|
+
export function heldRecords(): PendingRecords | undefined {
|
|
78
|
+
return handle().pending;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The outbound-header slot. Internal: `outbound-headers.ts` writes it, the dispatch reads it. */
|
|
82
|
+
export function outboundSlot(): { outboundHeaders: OutboundHeaders | undefined } {
|
|
83
|
+
return handle();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The principal the server rendered into the page, read ONCE, when the handle is created. Three
|
|
88
|
+
* answers: the tag's content, `null` for an empty tag (anonymous), `undefined` for no tag or no
|
|
89
|
+
* `document` at all (unscoped — nobody's page). A later change is `rescope()`'s, never a re-read.
|
|
90
|
+
*/
|
|
91
|
+
function renderedPrincipal(): string | null | undefined {
|
|
92
|
+
const doc: { querySelector?: (selector: string) => { content?: unknown } | null } | undefined =
|
|
93
|
+
Reflect.get(globalThis, 'document');
|
|
94
|
+
const tag = doc?.querySelector?.(`meta[name="${CLIENT_SCOPE_META}"]`);
|
|
95
|
+
if (tag === undefined || tag === null) return undefined;
|
|
96
|
+
return typeof tag.content === 'string' && tag.content !== '' ? tag.content : null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function handle(): PageClientHandle {
|
|
100
|
+
const host = globalThis as HandleHost;
|
|
101
|
+
const existing = host[HANDLE_KEY];
|
|
102
|
+
if (existing !== undefined) return existing;
|
|
103
|
+
const cell: ScopeCell = {
|
|
104
|
+
current: { principal: renderedPrincipal(), epoch: 0 },
|
|
105
|
+
listeners: new Set(),
|
|
106
|
+
};
|
|
107
|
+
const pending = Reflect.has(globalThis, 'document') ? pendingRecords() : undefined;
|
|
108
|
+
let store: RecordSink | undefined;
|
|
109
|
+
const created: PageClientHandle = {
|
|
110
|
+
socket: undefined,
|
|
111
|
+
scopeCell: cell,
|
|
112
|
+
pending,
|
|
113
|
+
outboundHeaders: undefined,
|
|
114
|
+
get store(): RecordSink | undefined {
|
|
115
|
+
return store;
|
|
116
|
+
},
|
|
117
|
+
set store(next: RecordSink | undefined) {
|
|
118
|
+
store = next;
|
|
119
|
+
if (next !== undefined) pending?.drainInto(next);
|
|
120
|
+
},
|
|
121
|
+
get scope(): ClientScope {
|
|
122
|
+
return cell.current;
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
// Non-enumerable, so a test harness or a devtools dump walking `globalThis` never serialises
|
|
126
|
+
// the store through it.
|
|
127
|
+
Object.defineProperty(host, HANDLE_KEY, { value: created, configurable: true });
|
|
128
|
+
return created;
|
|
129
|
+
}
|
|
@@ -23,7 +23,7 @@ export const SCHEMA_ERROR_CODE_TITLES: Readonly<Record<string, string>> = Object
|
|
|
23
23
|
),
|
|
24
24
|
);
|
|
25
25
|
|
|
26
|
-
// Registered here rather than in `error-codes.ts`'s `CORE_CODE_TITLES` because core does not own
|
|
26
|
+
// Registered here rather than in `core-error-codes.ts`'s `CORE_CODE_TITLES` because core does not own
|
|
27
27
|
// these codes — `@ultimat3/schema` does — and `registerErrorCodes` is the one mechanism that
|
|
28
28
|
// raises `X_ERROR_CODE_DUPLICATE` if a package that DOES own one of them ever tries to register it
|
|
29
29
|
// too, which pins ownership even though the registration happens here.
|
package/src/secrets-errors.ts
CHANGED
|
@@ -30,7 +30,7 @@ const SECRETS_ERROR_TITLES: Readonly<Record<SecretsErrorCode, string>> = {
|
|
|
30
30
|
X_SECRETS_PLAINTEXT_INVALID: 'the decrypted secrets are not a flat map of env values',
|
|
31
31
|
};
|
|
32
32
|
|
|
33
|
-
// Registered here rather than in `error-codes.ts`'s `CORE_CODE_TITLES` because these codes and the
|
|
33
|
+
// Registered here rather than in `core-error-codes.ts`'s `CORE_CODE_TITLES` because these codes and the
|
|
34
34
|
// module that throws them ship together: `registerErrorCodes` is the documented way a set of codes
|
|
35
35
|
// joins the registry, and it raises `X_ERROR_CODE_DUPLICATE` if anything else ever claims one.
|
|
36
36
|
registerErrorCodes(
|
package/src/secrets-store.ts
CHANGED
|
@@ -6,9 +6,11 @@
|
|
|
6
6
|
// `node:fs` sync, by necessity twice over: Bun.write takes no mode, and a world-readable master key
|
|
7
7
|
// is the whole failure this file exists to prevent — and `installSecrets()` runs once, at boot,
|
|
8
8
|
// before the process is serving anything, so there is nothing for an async read to overlap with.
|
|
9
|
-
|
|
9
|
+
// `renameSync` because Bun has no atomic-replace primitive, and `rmSync` to clear the temp file.
|
|
10
|
+
import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
10
11
|
// Bun exposes no path-join primitive.
|
|
11
12
|
import { join } from 'node:path';
|
|
13
|
+
import { isUltimateError } from './errors';
|
|
12
14
|
import type { SecretValues } from './secrets';
|
|
13
15
|
import { masterKeyId, openSecrets, parseMasterKey, sealSecrets } from './secrets';
|
|
14
16
|
import { SecretsFileMissingError, SecretsKeyMissingError } from './secrets-errors';
|
|
@@ -36,6 +38,38 @@ type EnvRecord = Record<string, string | undefined>;
|
|
|
36
38
|
export const secretsPath = (root: string): string => join(root, SECRETS_FILE);
|
|
37
39
|
export const masterKeyPath = (root: string): string => join(root, SECRETS_KEY_FILE);
|
|
38
40
|
export const secretsFileExists = (root: string): boolean => existsSync(secretsPath(root));
|
|
41
|
+
/**
|
|
42
|
+
* Where `x secrets rotate` STAGES a new key before sealing the committed file with it; the rename
|
|
43
|
+
* that makes it live comes last. One spelling, here, for the CLI and the runtime alike.
|
|
44
|
+
*/
|
|
45
|
+
export const stagedMasterKeyPath = (root: string): string => `${masterKeyPath(root)}.next`;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The committed file, opened with `key` — or, when that key no longer opens it and a rotation left
|
|
49
|
+
* a staged key, with the staged one. A crash between the seal and the rename leaves exactly that
|
|
50
|
+
* state, and a process booting then could not read its own secrets. READ-ONLY: finishing the
|
|
51
|
+
* rotation (the rename) is `x secrets`'s recovery, never a booting process's — two replicas racing
|
|
52
|
+
* a rename is how a key gets lost. An env key is never second-guessed: the platform owns it.
|
|
53
|
+
*/
|
|
54
|
+
async function openWithStaged(
|
|
55
|
+
root: string,
|
|
56
|
+
key: MasterKeyRef,
|
|
57
|
+
): Promise<{ readonly values: SecretValues; readonly key: MasterKeyRef }> {
|
|
58
|
+
try {
|
|
59
|
+
return { values: await readSecretsFile(root, key), key };
|
|
60
|
+
} catch (error) {
|
|
61
|
+
const staged = stagedMasterKeyPath(root);
|
|
62
|
+
const mismatch = isUltimateError(error) && error.code === 'X_SECRETS_KEY_MISMATCH';
|
|
63
|
+
if (!mismatch || key.source !== 'file' || !existsSync(staged)) throw error;
|
|
64
|
+
const candidate: MasterKeyRef = {
|
|
65
|
+
hex: readFileSync(staged, 'utf-8').trim(),
|
|
66
|
+
source: 'file',
|
|
67
|
+
at: staged,
|
|
68
|
+
};
|
|
69
|
+
// The staged key's own mismatch when neither opens it — the file's problem, not the key's.
|
|
70
|
+
return { values: await readSecretsFile(root, candidate), key: candidate };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
39
73
|
|
|
40
74
|
/**
|
|
41
75
|
* Env var first, key file second. That order is what makes one image run everywhere: a container
|
|
@@ -83,10 +117,23 @@ export async function writeSecretsFile(
|
|
|
83
117
|
return path;
|
|
84
118
|
}
|
|
85
119
|
|
|
86
|
-
/**
|
|
120
|
+
/**
|
|
121
|
+
* Write the master key at 0600. Callers must have made the ignore rule true first.
|
|
122
|
+
*
|
|
123
|
+
* Through a fresh temp file renamed over the target, never a write in place: `mode` applies only
|
|
124
|
+
* when a write CREATES the file, so rotating over a key that was 0644 left the new key 0644. The
|
|
125
|
+
* rename is also atomic — a reader sees the old key or the new one, never half of either.
|
|
126
|
+
*/
|
|
87
127
|
export function writeMasterKeyFile(root: string, keyHex: string): string {
|
|
88
128
|
const path = masterKeyPath(root);
|
|
89
|
-
|
|
129
|
+
const temp = `${path}.${crypto.randomUUID()}.tmp`;
|
|
130
|
+
try {
|
|
131
|
+
writeFileSync(temp, `${keyHex}\n`, { encoding: 'utf-8', mode: SECRETS_KEY_MODE, flag: 'wx' });
|
|
132
|
+
renameSync(temp, path);
|
|
133
|
+
} catch (error) {
|
|
134
|
+
rmSync(temp, { force: true });
|
|
135
|
+
throw error;
|
|
136
|
+
}
|
|
90
137
|
return path;
|
|
91
138
|
}
|
|
92
139
|
|
|
@@ -149,8 +196,7 @@ export async function installSecrets(
|
|
|
149
196
|
skipped: [],
|
|
150
197
|
};
|
|
151
198
|
}
|
|
152
|
-
const key = requireMasterKey(root, env);
|
|
153
|
-
const values = await readSecretsFile(root, key);
|
|
199
|
+
const { values, key } = await openWithStaged(root, requireMasterKey(root, env));
|
|
154
200
|
const installed: string[] = [];
|
|
155
201
|
const skipped: string[] = [];
|
|
156
202
|
for (const [name, value] of Object.entries(values)) {
|
package/src/service.ts
CHANGED
|
@@ -64,3 +64,12 @@ export function installedServices(ctx: CtxFacts): ServiceBag {
|
|
|
64
64
|
export function resetServices(): void {
|
|
65
65
|
factories.clear();
|
|
66
66
|
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The names `installedServices` would build, without building them. `@ultimat3/http` binds each as
|
|
70
|
+
* a lazy member of a request context, because the actor a request's services must close over is
|
|
71
|
+
* only known once its `auth` stage has run — after the context exists.
|
|
72
|
+
*/
|
|
73
|
+
export function registeredServiceNames(): readonly string[] {
|
|
74
|
+
return [...factories.keys()];
|
|
75
|
+
}
|
package/src/source-mask.ts
CHANGED
|
@@ -27,11 +27,41 @@ export function endOfLiteral(text: string, from: number): number {
|
|
|
27
27
|
for (let i = from + 1; i < text.length; i += 1) {
|
|
28
28
|
if (text[i] === '\\') i += 1;
|
|
29
29
|
else if (text[i] === quote) return i + 1;
|
|
30
|
+
else if (spansLines && text[i] === '$' && text[i + 1] === '{')
|
|
31
|
+
i = endOfInterpolation(text, i + 2) - 1;
|
|
30
32
|
else if (!spansLines && text[i] === '\n') return from + 1;
|
|
31
33
|
}
|
|
32
34
|
return spansLines ? text.length : from + 1;
|
|
33
35
|
}
|
|
34
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Index just past the `}` closing a template's `${` whose body starts at `from`. The body is CODE:
|
|
39
|
+
* braces nest, and a string or a template inside it is skipped whole — a nested template's backtick
|
|
40
|
+
* read as the outer one's close desynced every literal after it, and `scripts/guards-doc.ts` lost
|
|
41
|
+
* every `code:` below the nesting to the gate. Comments in the body are skipped the same way.
|
|
42
|
+
*/
|
|
43
|
+
function endOfInterpolation(text: string, from: number): number {
|
|
44
|
+
let depth = 1;
|
|
45
|
+
let i = from;
|
|
46
|
+
while (i < text.length) {
|
|
47
|
+
const ch = text[i] as string;
|
|
48
|
+
if (ch === '/' && (text[i + 1] === '/' || text[i + 1] === '*')) {
|
|
49
|
+
const line = text[i + 1] === '/';
|
|
50
|
+
const end = line ? text.indexOf('\n', i) : text.indexOf('*/', i + 2);
|
|
51
|
+
i = end === -1 ? text.length : line ? end : end + 2;
|
|
52
|
+
} else if (QUOTES.has(ch)) i = endOfLiteral(text, i);
|
|
53
|
+
else if (ch === '{') {
|
|
54
|
+
depth += 1;
|
|
55
|
+
i += 1;
|
|
56
|
+
} else if (ch === '}') {
|
|
57
|
+
depth -= 1;
|
|
58
|
+
i += 1;
|
|
59
|
+
if (depth === 0) return i;
|
|
60
|
+
} else i += 1;
|
|
61
|
+
}
|
|
62
|
+
return text.length;
|
|
63
|
+
}
|
|
64
|
+
|
|
35
65
|
/**
|
|
36
66
|
* Whether the `/` at `at` opens a regex rather than divides — the call no scanner without a parser
|
|
37
67
|
* avoids. A regex cannot follow what ends an expression: an identifier that is not one of the words
|
package/src/telemetry.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { tryUseContext } from './context';
|
|
|
8
8
|
import { renderThrowable } from './error-render';
|
|
9
9
|
import { isUltimateError } from './errors';
|
|
10
10
|
import { isSpanId, isTraceId, spanId as newSpanId, traceId as newTraceId } from './ids';
|
|
11
|
+
import { installTraceHeaders } from './outbound-headers';
|
|
11
12
|
import { defaultSampler, resetDefaultSampler, type Sampler } from './sampler';
|
|
12
13
|
|
|
13
14
|
export type SpanKind = 'internal' | 'server' | 'client' | 'producer' | 'consumer';
|
|
@@ -194,6 +195,8 @@ function inboundParent(parent: SpanContext | undefined): SpanContext | undefined
|
|
|
194
195
|
}
|
|
195
196
|
|
|
196
197
|
export function startSpan(name: string, options?: StartSpanOptions): Span {
|
|
198
|
+
// A live span is what gives an outbound typed call a trace to continue; see the module.
|
|
199
|
+
installTraceHeaders();
|
|
197
200
|
const parent = options?.parent ?? currentSpanContext();
|
|
198
201
|
const inbound = inboundParent(parent);
|
|
199
202
|
const attributes: Record<string, AttributeValue> = { ...(options?.attributes ?? {}) };
|
package/src/type-pins.ts
CHANGED
|
@@ -132,6 +132,15 @@ type _RealtimeConfigCarriesNoDeadField = Assert<
|
|
|
132
132
|
Extract<keyof RealtimeConfig, DeadRealtimeField> extends never ? true : false
|
|
133
133
|
>;
|
|
134
134
|
|
|
135
|
+
/**
|
|
136
|
+
* `'redis'` accepted, built by nothing, and booted whichever bus `NATS_URL` chose — removed in
|
|
137
|
+
* 22.0.0 when `selectTransport` began building what `transport` says. Re-adding it to the union is
|
|
138
|
+
* a type that promises a bus the framework does not have.
|
|
139
|
+
*/
|
|
140
|
+
type _RealtimeTransportHasNoRedis = Assert<
|
|
141
|
+
'redis' extends RealtimeConfig['transport'] ? false : true
|
|
142
|
+
>;
|
|
143
|
+
|
|
135
144
|
/** And the input side with it — `Input<RealtimeConfig>` is what an `app.config.ts` writes. */
|
|
136
145
|
type _RealtimeInputCarriesNoDeadField = Assert<
|
|
137
146
|
Extract<keyof NonNullable<AppConfigInput['realtime']>, DeadRealtimeField> extends never
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// A write's public name: the digest of the idempotency key the page sent it under. The server
|
|
2
|
+
// stamps it on every `records` frame the write produced; the page computes the same digest from
|
|
3
|
+
// its own key, so it can tell its own echo from somebody else's change. A digest, never the key:
|
|
4
|
+
// a frame fans out to every member of the channel, and the key is only the writer's to show.
|
|
5
|
+
|
|
6
|
+
/** Hex characters kept from the SHA-256: 128 bits, no collision between two writes in flight. */
|
|
7
|
+
export const WRITE_DIGEST_LENGTH = 32;
|
|
8
|
+
|
|
9
|
+
const WRITE_DIGEST_SHAPE = /^[0-9a-f]{32}$/;
|
|
10
|
+
|
|
11
|
+
/** Whether `value` is a write digest as this module mints one — what a decoder admits. */
|
|
12
|
+
export function isWriteDigest(value: unknown): value is string {
|
|
13
|
+
return typeof value === 'string' && WRITE_DIGEST_SHAPE.test(value);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* SHA-256 of the key, hex, first `WRITE_DIGEST_LENGTH` characters. `undefined` where the runtime
|
|
18
|
+
* has no `crypto.subtle` — a browser page served over plain HTTP from a host that is not
|
|
19
|
+
* `localhost`. The page then names none of its writes and every frame is a plain merge, which is
|
|
20
|
+
* the behaviour before frames named their write: a flicker at worst, never a wrong row.
|
|
21
|
+
*/
|
|
22
|
+
export async function writeDigest(key: string): Promise<string | undefined> {
|
|
23
|
+
const subtle: SubtleCrypto | undefined = globalThis.crypto?.subtle;
|
|
24
|
+
if (subtle === undefined) return undefined;
|
|
25
|
+
const bytes = new Uint8Array(await subtle.digest('SHA-256', new TextEncoder().encode(key)));
|
|
26
|
+
let hex = '';
|
|
27
|
+
for (const byte of bytes) hex += byte.toString(16).padStart(2, '0');
|
|
28
|
+
return hex.slice(0, WRITE_DIGEST_LENGTH);
|
|
29
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// The write in flight on the server: the digest of the idempotency key the request that is
|
|
2
|
+
// running arrived with. `@ultimat3/action`'s HTTP projection opens the scope; the layers that turn
|
|
3
|
+
// a committed row into a `records` frame read it — entity's row observer in-process, and the
|
|
4
|
+
// Postgres driver, which writes it into the WAL for the replicator to find. Server-only: it rides
|
|
5
|
+
// core's one `AsyncLocalStorage` seam, which a browser bundle does not have.
|
|
6
|
+
|
|
7
|
+
import { asyncContext } from './async-context';
|
|
8
|
+
import { isWriteDigest } from './write-digest';
|
|
9
|
+
|
|
10
|
+
const origin = asyncContext<string>('the write origin');
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The `pg_logical_emit_message` prefix a keyed write's transaction opens with, its content the
|
|
14
|
+
* digest. `@ultimat3/entity`'s Postgres driver writes it; `@ultimat3/realtime`'s replication stream
|
|
15
|
+
* reads it and names every change of that transaction. One constant, so the two cannot drift.
|
|
16
|
+
*/
|
|
17
|
+
export const WRITE_ORIGIN_WAL_PREFIX = 'ultimate.write';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Run `fn` as the write named `digest` (`writeDigest(key)`). Anything but a digest runs `fn`
|
|
21
|
+
* unnamed — the scope is a label on frames, never a gate, so a malformed one costs the page its
|
|
22
|
+
* echo match and nothing else.
|
|
23
|
+
*/
|
|
24
|
+
export function withWriteOrigin<T>(digest: string | undefined, fn: () => T): T {
|
|
25
|
+
return isWriteDigest(digest) ? origin.run(digest, fn) : fn();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The digest of the write this code runs inside, or `undefined` outside every keyed write. */
|
|
29
|
+
export function currentWriteOrigin(): string | undefined {
|
|
30
|
+
return origin.get();
|
|
31
|
+
}
|
package/src/result.ts
DELETED
|
@@ -1,78 +0,0 @@
|
|
|
1
|
-
// Single responsibility: `Result<T, E>` for boundaries where throwing is wrong —
|
|
2
|
-
// validation seams, driver probes, CLI commands that must render `--json` either way.
|
|
3
|
-
|
|
4
|
-
import { toUltimateError, type UltimateError } from './errors';
|
|
5
|
-
|
|
6
|
-
export interface Ok<T> {
|
|
7
|
-
readonly ok: true;
|
|
8
|
-
readonly value: T;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
export interface Err<E> {
|
|
12
|
-
readonly ok: false;
|
|
13
|
-
readonly error: E;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export type Result<T, E = UltimateError> = Ok<T> | Err<E>;
|
|
17
|
-
|
|
18
|
-
export function ok<T>(value: T): Ok<T> {
|
|
19
|
-
return { ok: true, value };
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export function err<E>(error: E): Err<E> {
|
|
23
|
-
return { ok: false, error };
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export function isOk<T, E>(result: Result<T, E>): result is Ok<T> {
|
|
27
|
-
return result.ok;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export function isErr<T, E>(result: Result<T, E>): result is Err<E> {
|
|
31
|
-
return !result.ok;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export function map<T, E, U>(result: Result<T, E>, fn: (value: T) => U): Result<U, E> {
|
|
35
|
-
return result.ok ? ok(fn(result.value)) : result;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export function mapErr<T, E, F>(result: Result<T, E>, fn: (error: E) => F): Result<T, F> {
|
|
39
|
-
return result.ok ? result : err(fn(result.error));
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export function unwrapOr<T, E>(result: Result<T, E>, fallback: T): T {
|
|
43
|
-
return result.ok ? result.value : fallback;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/** Throws the contained error — use only where a throw is genuinely correct. */
|
|
47
|
-
export function unwrap<T, E>(result: Result<T, E>): T {
|
|
48
|
-
if (result.ok) return result.value;
|
|
49
|
-
throw toUltimateError(result.error);
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
export function tryCatch<T>(fn: () => Promise<T>): Promise<Result<T, UltimateError>>;
|
|
53
|
-
export function tryCatch<T>(fn: () => T): Result<T, UltimateError>;
|
|
54
|
-
export function tryCatch<T>(
|
|
55
|
-
fn: () => T | Promise<T>,
|
|
56
|
-
): Result<T, UltimateError> | Promise<Result<T, UltimateError>> {
|
|
57
|
-
try {
|
|
58
|
-
const value = fn();
|
|
59
|
-
if (isPromiseLike(value)) {
|
|
60
|
-
return value.then(
|
|
61
|
-
(resolved) => ok(resolved),
|
|
62
|
-
(reason: unknown) => err(toUltimateError(reason)),
|
|
63
|
-
);
|
|
64
|
-
}
|
|
65
|
-
return ok(value);
|
|
66
|
-
} catch (thrown) {
|
|
67
|
-
return err(toUltimateError(thrown));
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
function isPromiseLike<T>(value: T | Promise<T>): value is Promise<T> {
|
|
72
|
-
return (
|
|
73
|
-
typeof value === 'object' &&
|
|
74
|
-
value !== null &&
|
|
75
|
-
'then' in value &&
|
|
76
|
-
typeof (value as { then: unknown }).then === 'function'
|
|
77
|
-
);
|
|
78
|
-
}
|