Rindle

API index and search · Build metadata

Source snapshot

packages/room/src/mutation-tx.ts

Source revision 05d0bf2c2e56 · build details
Source revision: 05d0bf2c2e56.
TypeScript input SHA-256: aabe6cfcc4172b870d5e272142958e9ea8d8784c2aa23133156e5a7ee633318e
Generated 2026-09-04T23:58:25.590Z with TypeScript 6.0.3. Public TypeScript checks and declaration emit passed. Package runtime tests are separate.
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