@rindle/room 0.5.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.
Binary file
@@ -0,0 +1,45 @@
1
+ /* tslint:disable */
2
+ /* eslint-disable */
3
+ export const memory: WebAssembly.Memory;
4
+ export const __rindle_room_wasm_start: () => void;
5
+ export const __wbg_wasmroom_free: (a: number, b: number) => void;
6
+ export const wasmroom_ack: (a: number, b: number, c: number) => [number, number, number, number];
7
+ export const wasmroom_ackedLmid: (a: number, b: number, c: number) => number;
8
+ export const wasmroom_appliedMid: (a: number, b: number, c: number) => number;
9
+ export const wasmroom_apply: (a: number, b: number, c: number) => [number, number, number, number];
10
+ export const wasmroom_beginFlush: (a: number) => [number, number, number, number];
11
+ export const wasmroom_beginMutation: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number) => [number, number, number, number];
12
+ export const wasmroom_commitAll: (a: number) => [number, number];
13
+ export const wasmroom_commitMutation: (a: number) => [number, number, number, number];
14
+ export const wasmroom_cv: (a: number) => [number, number];
15
+ export const wasmroom_dirtyLen: (a: number) => number;
16
+ export const wasmroom_durableLmid: (a: number, b: number, c: number) => number;
17
+ export const wasmroom_enableWrites: (a: number, b: number, c: number) => [number, number];
18
+ export const wasmroom_enableWritesV2: (a: number, b: number, c: number) => [number, number];
19
+ export const wasmroom_epoch: (a: number) => number;
20
+ export const wasmroom_flushConflict: (a: number, b: number, c: number) => [number, number, number, number];
21
+ export const wasmroom_flushInFlight: (a: number) => number;
22
+ export const wasmroom_flushOk: (a: number) => [number, number, number, number];
23
+ export const wasmroom_headCv: (a: number) => number;
24
+ export const wasmroom_isLive: (a: number) => number;
25
+ export const wasmroom_isPoisoned: (a: number) => number;
26
+ export const wasmroom_lmidSubscribe: (a: number, b: number, c: number, d: number, e: number, f: number, g: number) => [number, number, number, number];
27
+ export const wasmroom_materializationCount: (a: number) => number;
28
+ export const wasmroom_open: (a: number, b: number, c: number) => [number, number, number];
29
+ export const wasmroom_pendingLen: (a: number) => number;
30
+ export const wasmroom_rejectMutation: (a: number, b: number, c: number, d: number, e: number) => [number, number];
31
+ export const wasmroom_seedDurable: (a: number, b: number, c: number) => [number, number, number, number];
32
+ export const wasmroom_subscribe: (a: number, b: number, c: number, d: number, e: number, f: number, g: number) => [number, number, number, number];
33
+ export const wasmroom_subscriberCount: (a: number) => number;
34
+ export const wasmroom_sweep: (a: number, b: number) => [number, number, number];
35
+ export const wasmroom_txAdd: (a: number, b: number, c: number, d: number, e: number) => [number, number];
36
+ export const wasmroom_txEdit: (a: number, b: number, c: number, d: number, e: number) => [number, number];
37
+ export const wasmroom_txGet: (a: number, b: number, c: number, d: number, e: number) => [number, number, number, number];
38
+ export const wasmroom_txRemove: (a: number, b: number, c: number, d: number, e: number) => [number, number];
39
+ export const wasmroom_unsubscribe: (a: number, b: number, c: number, d: number) => [number, number, number];
40
+ export const __wbindgen_free: (a: number, b: number, c: number) => void;
41
+ export const __wbindgen_malloc: (a: number, b: number) => number;
42
+ export const __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
43
+ export const __wbindgen_externrefs: WebAssembly.Table;
44
+ export const __externref_table_dealloc: (a: number) => void;
45
+ export const __wbindgen_start: () => void;
@@ -0,0 +1,127 @@
1
+ // The room's **write-authority seam** (RINDLE-REALTIME-DESIGN.md §5.3.1): the three
2
+ // calls a flushing room makes against the app's authority — claim a placement epoch at
3
+ // boot (§2.5), probe the durable lmids before journal replay (§3.3, T2's
4
+ // committed-before-crash row), and apply one journaled batch. In every deployment shape
5
+ // the counterpart is the API server's `/apply-row-change-txn` host ([`httpAuthority`]);
6
+ // the P3 gate drives the same interface against an in-process mock, which is what keeps
7
+ // the contract host-independent.
8
+ //
9
+ // The apply takes the **exact composed body string** the shell journaled — never a
10
+ // re-serialized object. One flush id names one immutable byte body forever (§5.3
11
+ // step 4): a retry, a crash-replay resubmission, and the first send all put identical
12
+ // bytes on the wire, which is what makes the authority's `batch_hash` identity check
13
+ // (§8.3, T6) meaningful.
14
+
15
+ /** One CAS rejection in the authority's `409 conflict` body: the row's authoritative
16
+ * current image (`null` = absent), keyed by table + pk cells. */
17
+ export interface AuthorityConflict {
18
+ table: string;
19
+ pk: unknown[];
20
+ current: unknown[] | null;
21
+ }
22
+
23
+ /** The apply outcome. Anything else — network failure, a 5xx — is a **throw**: plain
24
+ * errors retry (same body, same id; dedup absorbs a landed retry), errors marked
25
+ * `fatal: true` (an identity mismatch — same id, different body — or a malformed
26
+ * refusal) kill the incarnation loudly instead. */
27
+ export type AuthorityApplyResult =
28
+ | { kind: "ok"; applied: boolean; cv?: number }
29
+ | { kind: "conflict"; conflicts: AuthorityConflict[] }
30
+ | { kind: "fenced"; currentEpoch?: number };
31
+
32
+ export interface RoomAuthority {
33
+ /** Claim the placement epoch for `doc` (§2.5) — once per shell process, at boot,
34
+ * AFTER resubmitting the previous incarnation's unconfirmed batches (their recorded
35
+ * epochs must still be current to land). */
36
+ claimEpoch(doc: string): Promise<number>;
37
+ /** The authority's ledger lmid per client (0 = none) — the boot probe that lets
38
+ * journal replay absorb already-durable mutations as dedup. */
39
+ lmids(doc: string, clients: string[]): Promise<Record<string, number>>;
40
+ /** Apply one batch: `body` is the exact journaled `/apply-row-change-txn` body
41
+ * string, sent verbatim. */
42
+ applyRowChangeTxn(body: string): Promise<AuthorityApplyResult>;
43
+ }
44
+
45
+ /** An apply error the shell must NOT retry (retrying cannot help; something is wrong
46
+ * with the batch or the room, not the network). */
47
+ export interface FatalAuthorityError extends Error {
48
+ fatal: true;
49
+ }
50
+
51
+ export function fatalAuthorityError(message: string): FatalAuthorityError {
52
+ const e = new Error(message) as FatalAuthorityError;
53
+ e.fatal = true;
54
+ return e;
55
+ }
56
+
57
+ export interface HttpAuthorityOptions {
58
+ /** The API server's apply endpoint (e.g. `http://…/api/rindle/apply-row-change-txn`). */
59
+ applyUrl: string;
60
+ /** The epoch-claim endpoint (e.g. `http://…/api/rindle/claim-room-epoch`). */
61
+ claimUrl: string;
62
+ /** The lmid-probe endpoint (e.g. `http://…/api/rindle/room-lmids`). */
63
+ lmidsUrl: string;
64
+ /** Extra headers on every call — the epoch-bound flush credential rides here. */
65
+ headers?: Record<string, string>;
66
+ fetch?: typeof fetch;
67
+ }
68
+
69
+ /** The HTTP [`RoomAuthority`]: the API-server host as the room's sole counterpart
70
+ * (§5.3.1). Maps `409 {error:"fenced"}` / `409 {error:"conflict"}` to their results,
71
+ * a `500` mentioning a batch-identity mismatch to a fatal error, and anything else
72
+ * non-OK to a retryable throw. */
73
+ export function httpAuthority(opts: HttpAuthorityOptions): RoomAuthority {
74
+ const doFetch = opts.fetch ?? fetch;
75
+ const headers = { "content-type": "application/json", ...(opts.headers ?? {}) };
76
+ const post = async (url: string, body: string): Promise<Response> =>
77
+ doFetch(url, { method: "POST", headers, body });
78
+ return {
79
+ async claimEpoch(doc) {
80
+ const res = await post(opts.claimUrl, JSON.stringify({ doc }));
81
+ if (!res.ok) {
82
+ throw new Error(`claim-room-epoch failed: ${res.status} ${await res.text()}`);
83
+ }
84
+ const out = (await res.json()) as { epoch?: number };
85
+ if (typeof out.epoch !== "number") {
86
+ throw fatalAuthorityError("claim-room-epoch returned no epoch");
87
+ }
88
+ return out.epoch;
89
+ },
90
+ async lmids(doc, clients) {
91
+ const res = await post(opts.lmidsUrl, JSON.stringify({ doc, clients }));
92
+ if (!res.ok) {
93
+ throw new Error(`room-lmids failed: ${res.status} ${await res.text()}`);
94
+ }
95
+ const out = (await res.json()) as { lmids?: Record<string, number> };
96
+ return out.lmids ?? {};
97
+ },
98
+ async applyRowChangeTxn(body) {
99
+ const res = await post(opts.applyUrl, body);
100
+ if (res.status === 409) {
101
+ const out = (await res.json()) as {
102
+ error?: string;
103
+ currentEpoch?: number;
104
+ conflicts?: AuthorityConflict[];
105
+ };
106
+ if (out.error === "fenced") {
107
+ return { kind: "fenced", currentEpoch: out.currentEpoch };
108
+ }
109
+ if (out.error === "conflict") {
110
+ return { kind: "conflict", conflicts: out.conflicts ?? [] };
111
+ }
112
+ throw new Error(`apply-row-change-txn 409: ${JSON.stringify(out)}`);
113
+ }
114
+ if (!res.ok) {
115
+ const text = await res.text();
116
+ // The §8.3 identity check: same flush id, different body — OUR bug, loud,
117
+ // never retried (a retry re-sends the same mismatched bytes forever).
118
+ if (res.status === 500 && text.includes("batch identity")) {
119
+ throw fatalAuthorityError(`apply-row-change-txn: ${text}`);
120
+ }
121
+ throw new Error(`apply-row-change-txn failed: ${res.status} ${text}`);
122
+ }
123
+ const out = (await res.json()) as { applied?: boolean; cv?: number };
124
+ return { kind: "ok", applied: out.applied === true, cv: out.cv };
125
+ },
126
+ };
127
+ }
package/src/index.ts ADDED
@@ -0,0 +1,49 @@
1
+ // @rindle/room — the Rindle Realtime room (P0 read-only follower + P1 lease-gated
2
+ // downstream + P2 shared optimistic writes).
3
+ //
4
+ // Four layers, deliberately separable:
5
+ // - `initRoomWasm` / `WasmRoom` (src/wasm.ts): the wasm room core — rindle-room-wasm
6
+ // compiled to `pkg/`, host-neutral (the Durable Object shell runs the SAME artifact).
7
+ // - `mintRoomToken` / `verifyRoomToken` (src/token.ts): the §10.1 self-authorizing
8
+ // signed lease token — minted by the API server (the authority), verified by any
9
+ // shell via WebCrypto (Node and Workers alike).
10
+ // - `MutationTx` / `RoomJournal` (src/mutation-tx.ts, src/journal.ts): the §5.1 write
11
+ // plane's two seams — the §4.2 mutator interface (a shared app registry drops in
12
+ // verbatim) and the pluggable durable sidecar an ack means (§8.1).
13
+ // - `createRoomShell` (src/shell.ts): the Node test shell — upstream ws subscriber of a
14
+ // live rindled, downstream ws server speaking the `@rindle/remote` protocol verbatim,
15
+ // gated by the tokens, with lease-TTL enforcement, the `/revoke` control plane, and
16
+ // the journaled write path.
17
+
18
+ export { initRoomWasm, WasmRoom } from "./wasm.ts";
19
+ export { createRoomShell } from "./shell.ts";
20
+ export type {
21
+ DownstreamOptions,
22
+ RoomScopeSpec,
23
+ RoomShell,
24
+ RoomShellOptions,
25
+ UpstreamOptions,
26
+ WritesOptions,
27
+ } from "./shell.ts";
28
+ export { journalEntryOutcome, memoryJournal } from "./journal.ts";
29
+ export type { RoomFlushRecord, RoomJournal, RoomJournalEntry } from "./journal.ts";
30
+ export { fatalAuthorityError, httpAuthority } from "./authority.ts";
31
+ export type {
32
+ AuthorityApplyResult,
33
+ AuthorityConflict,
34
+ HttpAuthorityOptions,
35
+ RoomAuthority,
36
+ } from "./authority.ts";
37
+ export type {
38
+ KeyedRow,
39
+ MutationTx,
40
+ RoomMutator,
41
+ RoomMutatorCtx,
42
+ WireValue,
43
+ } from "./mutation-tx.ts";
44
+ export { mintRoomToken, verifyRoomToken, RoomTokenError, scopeSpecsHash } from "./token.ts";
45
+ export type {
46
+ MintRoomTokenOptions,
47
+ RoomTokenPayload,
48
+ VerifyRoomTokenOptions,
49
+ } from "./token.ts";
package/src/journal.ts ADDED
@@ -0,0 +1,110 @@
1
+ // The room's pluggable journal (RINDLE-REALTIME-DESIGN.md §2.2, §5.1, §8.1): the
2
+ // durable sidecar an **ack** means. The shell appends mutation ENVELOPES under a group
3
+ // commit and advances the author's lmid row only after `append` resolves — so `acked`
4
+ // survives a room-process crash by construction, replayed by re-invoking the mutators
5
+ // against the freshly re-subscribed base (§3.3; recovery is re-invocation, never effect
6
+ // replay). Each host brings its own durability: the Durable Object shell backs this
7
+ // with per-object transactional storage (P4); the Node test shell defaults to
8
+ // `memoryJournal`, whose durability class is exactly "the shell process" — which is
9
+ // what makes T2's crash matrix a plain test: hand ONE journal to a second shell and
10
+ // the restart-with-journal failure class runs in-process.
11
+ //
12
+ // With P3's write-behind the journal ALSO holds the built flush batches (§5.3 step 4):
13
+ // the exact body bytes are appended BEFORE the first send and dropped once the
14
+ // authority confirms, so a crash-replay resubmits the same immutable body — one flush
15
+ // id names one byte body forever (the §8.3/T6 identity property). Mutation entries
16
+ // still grow without bound here; bounded retention (truncate ≤ the durable watermark)
17
+ // is a host-journal concern (P4's storage journal).
18
+
19
+ /** One journaled mutation: the wire envelope plus its recorded outcome. Replay applies
20
+ * the OUTCOME — a non-applied (`rejected`/`deopt`) entry consumes its mid without
21
+ * running the mutator. */
22
+ export interface RoomJournalEntry {
23
+ clientID: string;
24
+ mid: number;
25
+ name: string;
26
+ args: unknown;
27
+ /** The connection's authenticated subject at push time (the lease token's `sub`,
28
+ * shell-stamped — managed-writes §3.3): recovery is re-invocation, so identity is
29
+ * an input that must survive the crash. `""` = an entry journaled before the
30
+ * identity plane existed (mutators see it as unauthenticated). */
31
+ sub: string;
32
+ /** The recorded verdict (H-iv-b): `"applied"` replays by RE-INVOKING the mutator
33
+ * (§3.3 — and against a moved base the re-invocation may legitimately reject or
34
+ * DEOPT; the journal record is never rewritten, the shell's recorded-outcome map
35
+ * reflects what the replaying incarnation produced); `"rejected"` (final — authz /
36
+ * validation / unknown mutator) and `"deopt"` (the §3.3 commit gate refused; the
37
+ * client was told to re-route the mutation) both replay as a consumed-mid-no-effect
38
+ * WITHOUT running anything — re-judging a deopt could invent effects the client
39
+ * already re-routed elsewhere. Absent = a legacy pre-H-iv-b entry: read through
40
+ * {@link journalEntryOutcome}. */
41
+ outcome?: "applied" | "rejected" | "deopt";
42
+ /** Legacy pre-H-iv-b flag, superseded by {@link outcome} but still WRITTEN (`true`)
43
+ * alongside BOTH non-applied outcomes: a legacy reader replays either kind as a
44
+ * consumed-mid-no-effect, which is exactly right. */
45
+ rejected?: boolean;
46
+ }
47
+
48
+ /** The ONE reading rule for an entry's verdict across journal generations: `outcome`
49
+ * when present, else the legacy `rejected` flag, else applied. */
50
+ export function journalEntryOutcome(entry: RoomJournalEntry): "applied" | "rejected" | "deopt" {
51
+ return entry.outcome ?? (entry.rejected === true ? "rejected" : "applied");
52
+ }
53
+
54
+ /** One journaled flush batch: the room's flush-stream position, the placement epoch it
55
+ * was built under, and the EXACT `/apply-row-change-txn` body string — resubmitted
56
+ * verbatim, never rebuilt (§5.3 step 4). */
57
+ export interface RoomFlushRecord {
58
+ seq: number;
59
+ epoch: number;
60
+ body: string;
61
+ }
62
+
63
+ export interface RoomJournal {
64
+ /** Append `entries` durably, in order. Resolving is the ack gate (§8.1): the shell
65
+ * advances lmid rows only after this resolves. A rejection is fatal to the
66
+ * incarnation — an ack that might not survive must never be sent. */
67
+ append(entries: RoomJournalEntry[]): Promise<void>;
68
+ /** Every entry ever appended, in append order — the boot-time replay source. */
69
+ replay(): Promise<RoomJournalEntry[]>;
70
+ /** Journal one built flush batch, BEFORE its first send. Same durability contract
71
+ * as `append`: a rejection is fatal (an unjournaled batch must never reach the
72
+ * wire — a retry could otherwise rebuild different bytes under the same id). */
73
+ appendFlush(record: RoomFlushRecord): Promise<void>;
74
+ /** The authority settled flush `seq` (committed, deduped, or dead) — drop it. */
75
+ confirmFlush(seq: number): Promise<void>;
76
+ /** Unconfirmed flush records in seq order, plus the highest seq ever appended
77
+ * (0 = none) — the boot-time resubmission source and the seq seed. */
78
+ replayFlushes(): Promise<{ records: RoomFlushRecord[]; maxSeq: number }>;
79
+ }
80
+
81
+ /** An in-process journal: survives incarnations within one shell process (and, handed
82
+ * to a second shell, a simulated process crash — the T2 harness). Not durable beyond
83
+ * the process, by definition. */
84
+ export function memoryJournal(): RoomJournal {
85
+ const log: RoomJournalEntry[] = [];
86
+ const flushes = new Map<number, RoomFlushRecord>();
87
+ let maxSeq = 0;
88
+ return {
89
+ append(entries) {
90
+ log.push(...entries);
91
+ return Promise.resolve();
92
+ },
93
+ replay() {
94
+ return Promise.resolve([...log]);
95
+ },
96
+ appendFlush(record) {
97
+ flushes.set(record.seq, record);
98
+ maxSeq = Math.max(maxSeq, record.seq);
99
+ return Promise.resolve();
100
+ },
101
+ confirmFlush(seq) {
102
+ flushes.delete(seq);
103
+ return Promise.resolve();
104
+ },
105
+ replayFlushes() {
106
+ const records = [...flushes.values()].sort((a, b) => a.seq - b.seq);
107
+ return Promise.resolve({ records, maxSeq });
108
+ },
109
+ };
110
+ }
@@ -0,0 +1,275 @@
1
+ // The room-side MutationTx (RINDLE-REALTIME-DESIGN.md §5.1; OPTIMISTIC-WRITES-DESIGN.md
2
+ // §4.2): the write handle a room mutator runs against, structurally identical to
3
+ // `@rindle/optimistic`'s client MutationTx — the whole point of §4.2's "two registries,
4
+ // one interface" is that an app can register its client mutators in the room VERBATIM
5
+ // (`mutators` from a shared app-def typechecks against `RoomMutator` as-is). Declared
6
+ // locally rather than imported so @rindle/room does not depend on the browser client
7
+ // stack; TS structural typing keeps the two in lockstep at the app's call site.
8
+ //
9
+ // Backing: the wasm room's staged transaction (txGet/txAdd/txEdit/txRemove). Only
10
+ // concrete cells cross the JSON boundary — `undefined` ("leave unchanged") in the
11
+ // positional `edit` and the partial keyed `update` is resolved HERE against the
12
+ // effective row (`txGet`: live head under this tx's own staged writes), the same merge
13
+ // the browser WriteTxn does at its staging boundary. Reads are read-your-writes by
14
+ // construction. Every refusal below (unknown column, width, presence) throws — the
15
+ // shell catches a mutator throw and turns the whole mutation into a reject.
16
+
17
+ import type { WasmRoom } from "./wasm.ts";
18
+
19
+ /** A bare wire cell (the client stack's `WireValue`). */
20
+ export type WireValue = number | string | boolean | null;
21
+ /** A row keyed by column name. */
22
+ export type KeyedRow = Record<string, WireValue>;
23
+
24
+ /** The §4.2 write handle. Keyed methods are schema-checked; positional methods are the
25
+ * raw wire shape (cells in schema column order, pk cells in `primaryKey` order).
26
+ * `query` is not available in room mutators yet (it throws) — typed so a client
27
+ * registry that uses it still registers, and fails loudly at run time. */
28
+ export interface MutationTx {
29
+ /** Read one row by primary key (e.g. `tx.row("issue", { id: 1 })`). */
30
+ row(table: string, pk: KeyedRow): KeyedRow | undefined;
31
+ /** Insert a FULL row (every column named; missing or unknown columns throw). */
32
+ insert(table: string, row: KeyedRow): void;
33
+ /** Update the row identified by the pk columns; only the named non-pk columns
34
+ * change. A missing row is a NO-OP (rebase-friendly). */
35
+ update(table: string, row: KeyedRow): void;
36
+ /** Insert, or fully replace when the pk already exists (a FULL row, like insert). */
37
+ upsert(table: string, row: KeyedRow): void;
38
+ /** Delete the row identified by the pk columns. A missing row is a NO-OP. */
39
+ delete(table: string, pk: KeyedRow): void;
40
+ /** NOT SUPPORTED in room mutators yet — throws. (Typed to accept the client
41
+ * builder's queries so shared registries typecheck.) */
42
+ query(query: { ast(): unknown }): never;
43
+ // --- positional (the wire shape) ---
44
+ get(table: string, pk: WireValue[]): WireValue[] | undefined;
45
+ add(table: string, row: WireValue[]): void;
46
+ remove(table: string, row: WireValue[]): void;
47
+ edit(table: string, oldRow: WireValue[], newRow: (WireValue | undefined)[]): void;
48
+ }
49
+
50
+ /** The ambient authorization context a room mutator runs under (managed-writes design
51
+ * §3.2). Shell-stamped from the connection's DO-verified lease token — NEVER
52
+ * client-supplied — so per-row/per-field rules checked against it are trustworthy.
53
+ * The shape deliberately matches the shared-generator drivers' `ctx.user`, so one
54
+ * registry body runs identically in all three homes (client / api-server / room). */
55
+ export interface RoomMutatorCtx {
56
+ /** The authenticated subject (the lease token's `sub`). `""` on replay of an entry
57
+ * journaled before the identity plane — treat as unauthenticated. */
58
+ user: string;
59
+ }
60
+
61
+ /** A room mutator: deterministic, replayable — re-invoked on journal replay against
62
+ * the freshly re-subscribed base (§3.3), so the same purity rules as a client
63
+ * mutator apply (no clock, no randomness, a pure function of `(base, args, ctx)`).
64
+ * An auth check against `ctx` re-runs on replay against the freshly rebuilt base —
65
+ * a mutation that passed before a crash can replay as rejected if the permission
66
+ * row changed in between; that is §3.3's intended rebase behavior. */
67
+ export type RoomMutator = (tx: MutationTx, args: never, ctx: RoomMutatorCtx) => void;
68
+
69
+ /**
70
+ * Tag an error as an ENVIRONMENT shortfall (H-iv-b): the room lacks a capability the
71
+ * mutation needs (today: `tx.query`), which is a verdict about the ROOM, not the
72
+ * mutation — the shell classifies it as a DEOPT (the client re-routes the mutation to
73
+ * the daemon stream, where the capability exists) instead of a FINAL rejection (which
74
+ * would drop the mutation). Contrast a validation/authz throw: re-routing can't help
75
+ * those, so they stay `rejected`.
76
+ */
77
+ export function environmentShortfall(message: string): Error {
78
+ const e = new Error(message);
79
+ (e as { roomEnvironmentShortfall?: boolean }).roomEnvironmentShortfall = true;
80
+ return e;
81
+ }
82
+
83
+ /** Whether `e` was tagged by {@link environmentShortfall}. */
84
+ export function isEnvironmentShortfall(e: unknown): boolean {
85
+ return (
86
+ (e as { roomEnvironmentShortfall?: boolean } | null)?.roomEnvironmentShortfall === true
87
+ );
88
+ }
89
+
90
+ /**
91
+ * Guard against the two mutator shapes that would corrupt silently instead of failing loudly.
92
+ * A `shared(...)` GENERATOR registered verbatim returns an un-iterated generator — zero writes,
93
+ * acked as applied (data loss); an ASYNC mutator runs synchronously only to its first `await`,
94
+ * so later writes land OUTSIDE the committed transaction. Both shells call this on the
95
+ * mutator's return value inside their try/reject path, so either shape becomes an explicit
96
+ * rejection with a pointed message. (An adapter that DRIVES a shared generator registry against
97
+ * the room tx is future work — managed-writes design §8.)
98
+ */
99
+ export function assertSyncMutatorReturn(returned: unknown, name: string): void {
100
+ if (returned === undefined || returned === null) return;
101
+ const r = returned as { next?: unknown; then?: unknown };
102
+ if (typeof r.then === "function") {
103
+ throw new Error(
104
+ `mutator \`${name}\` returned a promise — room mutators must be synchronous ` +
105
+ `(writes after an \`await\` would land outside the transaction)`,
106
+ );
107
+ }
108
+ if (typeof r.next === "function") {
109
+ throw new Error(
110
+ `mutator \`${name}\` returned a generator — a shared(...) registry cannot register ` +
111
+ `verbatim as room mutators (nothing would drive it; zero writes would be acked). ` +
112
+ `Write plain synchronous (tx, args, ctx) mutators for the room bundle.`,
113
+ );
114
+ }
115
+ }
116
+
117
+ /** One table's positional shape, parsed from the upstream hello. */
118
+ export interface TableShape {
119
+ columns: string[];
120
+ /** Indices into `columns`. */
121
+ primaryKey: number[];
122
+ }
123
+
124
+ function shapeOf(shapes: Map<string, TableShape>, table: string): TableShape {
125
+ const s = shapes.get(table);
126
+ if (!s) throw new Error(`unknown table \`${table}\``);
127
+ return s;
128
+ }
129
+
130
+ /** pk cells (primaryKey order) from a keyed probe — every pk column must be named. */
131
+ function keyedPk(shape: TableShape, table: string, pk: KeyedRow): WireValue[] {
132
+ return shape.primaryKey.map((c) => {
133
+ const name = shape.columns[c];
134
+ const v = pk[name];
135
+ if (v === undefined) {
136
+ throw new Error(`missing primary-key column \`${name}\` for \`${table}\``);
137
+ }
138
+ return v;
139
+ });
140
+ }
141
+
142
+ function assertKnownColumns(shape: TableShape, table: string, row: KeyedRow): void {
143
+ for (const name of Object.keys(row)) {
144
+ if (!shape.columns.includes(name)) {
145
+ throw new Error(`unknown column \`${name}\` for \`${table}\``);
146
+ }
147
+ }
148
+ }
149
+
150
+ /** Build the MutationTx for one open wasm transaction. Valid only while that
151
+ * transaction is open — the shell creates one per mutation and never retains it. */
152
+ export function mutationTx(room: WasmRoom, shapes: Map<string, TableShape>): MutationTx {
153
+ const getRow = (table: string, pkCells: WireValue[]): WireValue[] | undefined => {
154
+ const text = room.txGet(table, JSON.stringify(pkCells));
155
+ return text === undefined ? undefined : (JSON.parse(text) as WireValue[]);
156
+ };
157
+ /** Resolve `undefined` cells ("leave unchanged") against the effective row; the
158
+ * row must exist. Only concrete cells may cross the JSON boundary — a stringified
159
+ * `undefined` would silently become `null`. */
160
+ const resolveCells = (
161
+ table: string,
162
+ shape: TableShape,
163
+ cells: (WireValue | undefined)[],
164
+ ): WireValue[] | undefined => {
165
+ if (cells.length !== shape.columns.length) {
166
+ throw new Error(`row width does not match the schema of \`${table}\``);
167
+ }
168
+ const pkCells = shape.primaryKey.map((c) => {
169
+ const v = cells[c];
170
+ if (v === undefined) {
171
+ throw new Error(`primary-key cells must be concrete (\`${table}\`)`);
172
+ }
173
+ return v;
174
+ });
175
+ const current = getRow(table, pkCells);
176
+ if (cells.every((v) => v !== undefined)) return cells as WireValue[];
177
+ if (current === undefined) return undefined;
178
+ return cells.map((v, i) => (v === undefined ? current[i] : v)) as WireValue[];
179
+ };
180
+
181
+ return {
182
+ row(table, pk) {
183
+ const shape = shapeOf(shapes, table);
184
+ const cells = getRow(table, keyedPk(shape, table, pk));
185
+ if (cells === undefined) return undefined;
186
+ const out: KeyedRow = {};
187
+ shape.columns.forEach((name, i) => (out[name] = cells[i]));
188
+ return out;
189
+ },
190
+ insert(table, row) {
191
+ const shape = shapeOf(shapes, table);
192
+ assertKnownColumns(shape, table, row);
193
+ const cells = shape.columns.map((name) => {
194
+ const v = row[name];
195
+ if (v === undefined) {
196
+ throw new Error(`insert of \`${table}\` is missing column \`${name}\``);
197
+ }
198
+ return v;
199
+ });
200
+ room.txAdd(table, JSON.stringify(cells));
201
+ },
202
+ update(table, row) {
203
+ const shape = shapeOf(shapes, table);
204
+ assertKnownColumns(shape, table, row);
205
+ const pkCells = keyedPk(shape, table, row);
206
+ const current = getRow(table, pkCells);
207
+ if (current === undefined) return; // rebase-friendly no-op
208
+ const cells = shape.columns.map((name, i) => {
209
+ const v = row[name];
210
+ return v === undefined ? current[i] : v;
211
+ });
212
+ room.txEdit(table, JSON.stringify(cells));
213
+ },
214
+ upsert(table, row) {
215
+ const shape = shapeOf(shapes, table);
216
+ assertKnownColumns(shape, table, row);
217
+ const cells = shape.columns.map((name) => {
218
+ const v = row[name];
219
+ if (v === undefined) {
220
+ throw new Error(`upsert of \`${table}\` is missing column \`${name}\``);
221
+ }
222
+ return v;
223
+ });
224
+ const pkCells = keyedPk(shape, table, row);
225
+ if (getRow(table, pkCells) === undefined) {
226
+ room.txAdd(table, JSON.stringify(cells));
227
+ } else {
228
+ room.txEdit(table, JSON.stringify(cells));
229
+ }
230
+ },
231
+ delete(table, pk) {
232
+ const shape = shapeOf(shapes, table);
233
+ const pkCells = keyedPk(shape, table, pk);
234
+ if (getRow(table, pkCells) === undefined) return; // rebase-friendly no-op
235
+ room.txRemove(table, JSON.stringify(pkCells));
236
+ },
237
+ query() {
238
+ // An environment shortfall, not a mutation verdict: the shell classifies this
239
+ // throw as a DEOPT so the client re-routes to the daemon stream (H-iv-b).
240
+ throw environmentShortfall("tx.query is not supported in room mutators yet");
241
+ },
242
+ get(table, pk) {
243
+ shapeOf(shapes, table);
244
+ return getRow(table, pk);
245
+ },
246
+ add(table, row) {
247
+ const shape = shapeOf(shapes, table);
248
+ if (row.some((v) => v === undefined)) {
249
+ throw new Error(`add of \`${table}\`: cells must be concrete`);
250
+ }
251
+ if (row.length !== shape.columns.length) {
252
+ throw new Error(`row width does not match the schema of \`${table}\``);
253
+ }
254
+ room.txAdd(table, JSON.stringify(row));
255
+ },
256
+ remove(table, row) {
257
+ const shape = shapeOf(shapes, table);
258
+ if (row.length !== shape.columns.length) {
259
+ throw new Error(`row width does not match the schema of \`${table}\``);
260
+ }
261
+ room.txRemove(table, JSON.stringify(shape.primaryKey.map((c) => row[c])));
262
+ },
263
+ edit(table, _oldRow, newRow) {
264
+ // The authority composes `old` from its own effective read (the wasm side);
265
+ // the caller's oldRow is its *prediction* of old — unused here, kept in the
266
+ // signature for client-registry compatibility.
267
+ const shape = shapeOf(shapes, table);
268
+ const resolved = resolveCells(table, shape, newRow);
269
+ if (resolved === undefined) {
270
+ throw new Error(`no row with that primary key in \`${table}\``);
271
+ }
272
+ room.txEdit(table, JSON.stringify(resolved));
273
+ },
274
+ };
275
+ }