Rindle

API index and search · Build metadata

Source snapshot

packages/client/src/resolve.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// Positional → named change resolution: the inverse of the wire encoder.2//3// A backend ships per-query `FlatChange`s (types.ts): `{ path: PathSeg[], op: add|remove|edit }`,4// where `path` indexes into the view's relationship tree and rows are POSITIONAL cells. The view5// (view.ts) folds those into the materialized tree positionally; this module instead LIFTS one6// change out of positional/indexed wire form into NAMED rows, resolving it against the query's OWN7// `WireSchema` (the per-subscription view schema the engine ships once on its `hello` frame): per8// level the column NAMES in wire order, and the relationships in SLOT order (a gating exists/notExists9// slot is `child: null`; a `countAs` slot carries a `project` annotation). So `path[i].rel` indexes10// `schema.relationships[i]` directly, and a positional `row` names against `schema.columns` — both11// authoritative, straight from the engine.12//13// Resolving against the hello schema (rather than re-deriving positions from the query `Ast`) means14// we assume NOTHING about slot ordering and NOTHING about column order: it stays correct under15// `.select()` projections and any future slot layout. The result — named rows + an aggregate's exact16// new value — is what a higher layer (e.g. @rindle/narrator) renders into prose, but it is broadly17// useful to anything consuming a `FlatChange` (logging, devtools, change-driven overlays).18//19// NOTE on aggregates (`countAs`): the slot's `project.col` names the EXACT count cell in the child20// row, so a count change reports the exact new value (and previous, on an edit) — not a best-effort21// guess. A remove of the aggregate row means the count fell to its identity (`project.identity`).2223import type { FlatChange, PathSeg, WireNode, WireRel, WireSchema, WireValue } from "./types.ts";2425/** A row named against its level's wire columns. */26export type NamedRow = Record<string, WireValue>;2728/** One resolved change: a `FlatChange` lifted out of positional/indexed wire form into names,29 *  using the query's `WireSchema` (from `hello`) as the sole position→name source. */30export interface ResolvedChange {31  /** Relationship-alias chain from the query root to the changed level (`[]` ⇒ the root rows). */32  aliasChain: string[];33  /** The alias of the changed level (`""` ⇒ root), i.e. the last of `aliasChain`. */34  alias: string;35  op: "add" | "remove" | "edit";36  /** The affected row, named. For `edit` this is the NEW row; see `old` for the prior one. */37  row: NamedRow;38  /** The prior row, named (present only for `edit`). */39  old?: NamedRow;40  /** The PARENT row (named), for a nested/aggregate change — e.g. the `ticket_type` whose `sold`41   *  count moved. Taken from the path's last `parentRow`; absent for a root-level change. */42  parent?: NamedRow;43  /** Set when the changed level is a `countAs`/aggregate slot. The value is EXACT — read from the44   *  slot's projected count column (`WireRel.project.col`). */45  aggregate?: { alias: string; value: WireValue; previous?: WireValue };46  /** The raw node whose children a consumer can dig a named sub-row out of (via {@link subRow}). On47   *  an `add` the engine always ships it; on a `remove` it is present only when the consumer opted48   *  into the removed subtree (see the `op` mapping below). */49  node?: WireNode;50  /** The changed level's `WireSchema` — used by {@link subRow} to resolve a named sub of `node`. */51  levelSchema: WireSchema;52}5354/** Name positional `cells` against a level's wire `columns` (insertion = wire order). */55function nameRow(cells: WireValue[] | undefined, cols: string[]): NamedRow {56  const out: NamedRow = {};57  if (!cells) return out;58  for (let i = 0; i < cols.length; i++) out[cols[i]] = cells[i] ?? null;59  return out;60}6162/** Walk a `path` from the root `WireSchema` down the relationship tree. Returns the reached level,63 *  the alias chain, and the LAST relationship traversed (its `project` marks an aggregate slot).64 *  `null` if a hop addresses an unknown or gating (`child: null`) slot. */65function descend(66  root: WireSchema,67  path: PathSeg[],68): { level: WireSchema; lastRel: WireRel | null; aliasChain: string[] } | null {69  let level = root;70  let lastRel: WireRel | null = null;71  const aliasChain: string[] = [];72  for (const seg of path) {73    const rel = level.relationships[seg.rel];74    if (!rel || !rel.child) return null; // unknown / gating slot — not a materialized level75    lastRel = rel;76    level = rel.child;77    aliasChain.push(rel.name);78  }79  return { level, lastRel, aliasChain };80}8182/** Lift one `FlatChange` into a {@link ResolvedChange} against the query's `WireSchema`, or `null`83 *  if its path doesn't resolve to a materialized level. */84export function resolveChange(schema: WireSchema, change: FlatChange): ResolvedChange | null {85  const here = descend(schema, change.path);86  if (!here) return null;87  const { level, lastRel, aliasChain } = here;88  const cols = level.columns;89  const alias = aliasChain.length ? aliasChain[aliasChain.length - 1] : "";90  const base: Omit<ResolvedChange, "op" | "row"> = { aliasChain, alias, levelSchema: level };91  // The parent row (for a nested/aggregate change) sits in the last path seg, named against the92  // level one hop up.93  if (change.path.length) {94    const up = descend(schema, change.path.slice(0, -1));95    const parentCells = change.path[change.path.length - 1].parentRow;96    if (up) base.parent = nameRow(parentCells, up.level.columns);97  }9899  // A `countAs` slot carries a scalar `project` annotation on the relationship we descended through:100  // `project.col` is the exact count cell, `project.identity` the empty value (0 for count).101  const proj = lastRel?.project ?? null;102  const agg = (cells: WireValue[] | undefined): WireValue => (proj && cells ? (cells[proj.col] ?? null) : null);103104  const op = change.op;105  if (op.tag === "add") {106    const r: ResolvedChange = { ...base, op: "add", row: nameRow(op.node.row, cols), node: op.node };107    if (proj) r.aggregate = { alias, value: agg(op.node.row) };108    return r;109  }110  if (op.tag === "remove") {111    const r: ResolvedChange = { ...base, op: "remove", row: nameRow(op.row, cols) };112    // The removed subtree rides along only when the consumer opted into it (the ArrayView attaches113    // it client-side — `Store.subscribeChanges(_, { removedSubtree: true })`). When present, `subRow`114    // resolves a removed row's nested subs exactly as on an `add`; absent, a remove is row-only.115    if (op.node) r.node = op.node;116    if (proj) r.aggregate = { alias, value: proj.identity };117    return r;118  }119  // edit120  const r: ResolvedChange = { ...base, op: "edit", row: nameRow(op.new, cols), old: nameRow(op.old, cols) };121  if (proj) r.aggregate = { alias, value: agg(op.new), previous: agg(op.old) };122  return r;123}124125/** Read a named sub-row off a change's `node` by relationship alias (e.g. the `guest` under an126 *  `rsvp`) — the `add` node, or a `remove`'s subtree when the consumer opted into it. The alias →127 *  wire slot mapping comes from the changed level's `WireSchema`. `null` when no node rode along. */128export function subRow(rc: ResolvedChange, alias: string): NamedRow | null {129  if (!rc.node) return null;130  const rel = rc.levelSchema.relationships.find((r) => r.name === alias);131  if (!rel || !rel.child) return null;132  const slot = rc.node.rels.find((s) => s.rel === rel.slot);133  const child = slot?.children[0];134  if (!child) return null;135  return nameRow(child.row, rel.child.columns);136}137