@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,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Records answered before the page's store exists. The store is installed by the first realtime
|
|
3
|
+
* hook, and an action or query can answer earlier than that — so in a BROWSER page the handle
|
|
4
|
+
* holds the latest row per `type:key` (and the keys removed since) here, and hands them to the
|
|
5
|
+
* store once, when it is installed. Bounded by the keys themselves: a row seen twice is one entry.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Row } from './conflict-policy';
|
|
9
|
+
import type { RecordRows } from './record-envelope';
|
|
10
|
+
import type { RecordSink } from './record-sink';
|
|
11
|
+
|
|
12
|
+
export interface PendingRecords extends RecordSink {
|
|
13
|
+
/** Adopts everything held into `sink`, then forgets it — a second drain adopts nothing. */
|
|
14
|
+
drainInto(sink: RecordSink): void;
|
|
15
|
+
/** Forgets everything held: the principal changed, and these rows were the previous one's. */
|
|
16
|
+
clear(): void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function pendingRecords(): PendingRecords {
|
|
20
|
+
const rows = new Map<string, Map<string, Row>>();
|
|
21
|
+
const removed = new Map<string, Set<string>>();
|
|
22
|
+
const group = <T>(table: Map<string, T>, type: string, make: () => T): T => {
|
|
23
|
+
const found = table.get(type);
|
|
24
|
+
if (found !== undefined) return found;
|
|
25
|
+
const created = make();
|
|
26
|
+
table.set(type, created);
|
|
27
|
+
return created;
|
|
28
|
+
};
|
|
29
|
+
return {
|
|
30
|
+
adopt(type: string, incoming: RecordRows): void {
|
|
31
|
+
const held = group(rows, type, () => new Map<string, Row>());
|
|
32
|
+
for (const [key, row] of Object.entries(incoming)) {
|
|
33
|
+
held.set(key, row);
|
|
34
|
+
removed.get(type)?.delete(key);
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
remove(type: string, keys: readonly string[]): void {
|
|
38
|
+
const gone = group(removed, type, () => new Set<string>());
|
|
39
|
+
for (const key of keys) {
|
|
40
|
+
rows.get(type)?.delete(key);
|
|
41
|
+
gone.add(key);
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
drainInto(sink: RecordSink): void {
|
|
45
|
+
for (const [type, held] of rows) {
|
|
46
|
+
if (held.size > 0) sink.adopt(type, Object.fromEntries(held));
|
|
47
|
+
}
|
|
48
|
+
for (const [type, gone] of removed) {
|
|
49
|
+
if (gone.size > 0) sink.remove(type, [...gone]);
|
|
50
|
+
}
|
|
51
|
+
this.clear();
|
|
52
|
+
},
|
|
53
|
+
clear(): void {
|
|
54
|
+
rows.clear();
|
|
55
|
+
removed.clear();
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ONE OpenAPI description of the records envelope, for every operation that answers one —
|
|
3
|
+
* `@ultimat3/action`'s and `@ultimat3/query`'s projections both build their 200 from it. The rule
|
|
4
|
+
* it serves: an operation decides ONCE, from its schema, whether it answers `{ data, records }`,
|
|
5
|
+
* so each operation has exactly one body shape and one header, never a `oneOf` over "sometimes".
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { RECORDS_HEADER } from './record-envelope';
|
|
9
|
+
|
|
10
|
+
/** The 200 body: the operation's own answer under `data`, the rows it carried beside it. */
|
|
11
|
+
export function recordEnvelopeSchema(
|
|
12
|
+
data: Readonly<Record<string, unknown>>,
|
|
13
|
+
): Record<string, unknown> {
|
|
14
|
+
return {
|
|
15
|
+
type: 'object',
|
|
16
|
+
required: ['data', 'records'],
|
|
17
|
+
properties: {
|
|
18
|
+
data,
|
|
19
|
+
records: {
|
|
20
|
+
type: 'object',
|
|
21
|
+
description:
|
|
22
|
+
'Entity rows in `data`, keyed by record type and then by record key, for the client record store. May be empty.',
|
|
23
|
+
additionalProperties: { type: 'object', additionalProperties: { type: 'object' } },
|
|
24
|
+
},
|
|
25
|
+
removed: {
|
|
26
|
+
type: 'object',
|
|
27
|
+
description: 'Record keys the client store must drop, keyed by record type.',
|
|
28
|
+
additionalProperties: { type: 'array', items: { type: 'string' } },
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The header beside that body. Always `1` on an operation that answers the envelope at all. */
|
|
35
|
+
export const RECORDS_OPENAPI_HEADER: Readonly<Record<string, unknown>> = Object.freeze({
|
|
36
|
+
[RECORDS_HEADER]: {
|
|
37
|
+
description: 'Always `1`: the body is a record envelope and the operation answer is `data`.',
|
|
38
|
+
required: true,
|
|
39
|
+
schema: { type: 'string', enum: ['1'] },
|
|
40
|
+
},
|
|
41
|
+
});
|
|
@@ -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/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/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 ?? {}) };
|
|
@@ -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
|
+
}
|