API index and search · Build metadata
Source snapshot
packages/room/src/mutation-tx.ts
1// The room-side MutationTx (RINDLE-REALTIME-DESIGN.md §5.1; OPTIMISTIC-WRITES-DESIGN.md2// §4.2): the write handle a room mutator runs against, structurally identical to3// `@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 VERBATIM5// (`mutators` from a shared app-def typechecks against `RoomMutator` as-is). Declared6// locally rather than imported so @rindle/room does not depend on the browser client7// 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). Only10// concrete cells cross the JSON boundary — `undefined` ("leave unchanged") in the11// positional `edit` and the partial keyed `update` is resolved HERE against the12// effective row (`txGet`: live head under this tx's own staged writes), the same merge13// the browser WriteTxn does at its staging boundary. Reads are read-your-writes by14// construction. Every refusal below (unknown column, width, presence) throws — the15// shell catches a mutator throw and turns the whole mutation into a reject.1617import type { WasmRoom } from "./wasm.ts";1819/** A bare wire cell (the client stack's `WireValue`). */20export type WireValue = number | string | boolean | null;21/** A row keyed by column name. */22export type KeyedRow = Record<string, WireValue>;2324/** The §4.2 write handle. Keyed methods are schema-checked; positional methods are the25 * 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 client27 * registry that uses it still registers, and fails loudly at run time. */28export 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 columns34 * 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 client41 * 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}4950/** The ambient authorization context a room mutator runs under (managed-writes design51 * §3.2). Shell-stamped from the connection's DO-verified lease token — NEVER52 * 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 one54 * registry body runs identically in all three homes (client / api-server / room). */55export interface RoomMutatorCtx {56 /** The authenticated subject (the lease token's `sub`). `""` on replay of an entry57 * journaled before the identity plane — treat as unauthenticated. */58 user: string;59}6061/** A room mutator: deterministic, replayable — re-invoked on journal replay against62 * the freshly re-subscribed base (§3.3), so the same purity rules as a client63 * 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 permission66 * row changed in between; that is §3.3's intended rebase behavior. */67export type RoomMutator = (tx: MutationTx, args: never, ctx: RoomMutatorCtx) => void;6869/**70 * Tag an error as an ENVIRONMENT shortfall (H-iv-b): the room lacks a capability the71 * mutation needs (today: `tx.query`), which is a verdict about the ROOM, not the72 * mutation — the shell classifies it as a DEOPT (the client re-routes the mutation to73 * the daemon stream, where the capability exists) instead of a FINAL rejection (which74 * would drop the mutation). Contrast a validation/authz throw: re-routing can't help75 * those, so they stay `rejected`.76 */77export function environmentShortfall(message: string): Error {78 const e = new Error(message);79 (e as { roomEnvironmentShortfall?: boolean }).roomEnvironmentShortfall = true;80 return e;81}8283/** Whether `e` was tagged by {@link environmentShortfall}. */84export function isEnvironmentShortfall(e: unknown): boolean {85 return (86 (e as { roomEnvironmentShortfall?: boolean } | null)?.roomEnvironmentShortfall === true87 );88}8990/**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 the95 * mutator's return value inside their try/reject path, so either shape becomes an explicit96 * rejection with a pointed message. (An adapter that DRIVES a shared generator registry against97 * the room tx is future work — managed-writes design §8.)98 */99export 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}116117/** One table's positional shape, parsed from the upstream hello. */118export interface TableShape {119 columns: string[];120 /** Indices into `columns`. */121 primaryKey: number[];122}123124function 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}129130/** pk cells (primaryKey order) from a keyed probe — every pk column must be named. */131function 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}141142function 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}149150/** Build the MutationTx for one open wasm transaction. Valid only while that151 * transaction is open — the shell creates one per mutation and never retains it. */152export 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; the158 * row must exist. Only concrete cells may cross the JSON boundary — a stringified159 * `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 };180181 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-op208 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-op235 room.txRemove(table, JSON.stringify(pkCells));236 },237 query() {238 // An environment shortfall, not a mutation verdict: the shell classifies this239 // 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 the266 // 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}276