API index and search · Build metadata
Source snapshot
packages/client/src/resolve.ts
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