API index and search · Build metadata
Source snapshot
packages/client/src/view.ts
1// The JS ArrayView — the materialized, typed result tree a backend's ChangeEvent stream2// folds into (WASM-CLIENT-DESIGN.md §7, §8). A faithful port of the View's `apply_change`3// (src/view.rs / src/flat_receiver.rs), the §6 receiver contract of FLAT-CHANGES-DESIGN.md:4// descend the path with the in-view gate, then Add / Remove / Edit (rc + the edit-move5// ghost/adjusted-position/merge protocol) located by value-driven binary search.6//7// Reads are cheap and reference-stable: each node memoizes its projected output (`out`),8// invalidated only along the root→mutation spine, so `.data` re-projects only what changed9// and untouched subtrees keep their object identity (React/Solid memoize on it).1011import { compareRows } from "./compare.ts";12import type { ColType, FlatChange, FlatOp, QueryId, ResultType, WireNode, WireSchema, WireValue } from "./types.ts";1314/** Per-level column types (parallel to the WireSchema), used to JSON.parse json columns on15 * projection. Built by the Store from the typed schema; absent ⇒ no parsing (bare values). */16export interface ViewTypes {17 columnTypes: ColType[];18 rels: Record<number, ViewTypes>;19}2021/** The phase of an {@link ArrayView.onChanges} delivery: the initial hydrate `snapshot` vs a later22 * incremental `batch` — the same distinction a narrator draws (`ChangeEvent` `snapshot`/`batch`). */23export type ChangePhase = "snapshot" | "batch";2425/** A per-view change listener ({@link ArrayView.onChanges}): the net `FlatChange[]` this view folded,26 * the {@link ChangePhase} it arrived on, and the view's `WireSchema` (the position→name source for27 * `resolveChange`). This is the DIFF the data channel ({@link ArrayView.subscribe}) discards — the28 * seam a narrator drives off. The `schema` is passed (not closed over) because the first `snapshot`29 * fires synchronously inside `materialize`, before the caller holds the view handle. */30export type ViewChangeListener = (changes: FlatChange[], phase: ChangePhase, schema: WireSchema) => void;3132/** The public ArrayView contract `materialize()` returns. */33export interface ArrayView<R> {34 /** The current materialized result (reference-stable where data is unchanged). */35 readonly data: readonly R[];36 /** The engine query id the Store assigned this view (1:1 with the view). The same `qid` the raw37 * change stream ({@link Store.subscribeChanges}) tags each frame with, so a consumer can38 * correlate this query with its `ChangeEvent`s (e.g. to bind a narrator) straight off39 * `materialize(query).qid` — no separate handle needed. */40 readonly qid: QueryId;41 /** The query's view `WireSchema` (the position→name source for {@link resolveChange}), captured42 * from its `hello` frame. `null` while PENDING — a remote backend's `hello` arrives async; an43 * in-process backend (wasm/replica) populates it synchronously during `materialize`. */44 readonly schema: WireSchema | null;45 /** The query's SERVER-CHANNEL state (`unknown` while loading, `complete` once server-authoritative;46 * the `error` variant is reserved and currently unproduced). A pending optimistic mutation no47 * longer moves this — it is a separate axis (FOLDED-MUTATIONS-DESIGN §7). `complete` for backends48 * with no server lifecycle. Changes notify subscribers. */49 readonly resultType: ResultType;50 /** Subscribe; fires immediately with the current data, then after each applied batch (and after51 * a {@link resultType} change — re-read `resultType` in the listener). */52 subscribe(listener: (data: readonly R[]) => void): () => void;53 /** Subscribe to this view's folded CHANGE stream — the `FlatChange[]` it applies, NET of no-op54 * cycles (a rebase's balanced `remove`+`add` / edit round-trip cancels, so a correctly predicted55 * optimistic write delivers nothing here). Carries the diff {@link subscribe} throws away; the56 * per-view seam a narrator rides. Does NOT replay on subscribe — attach via57 * `store.materialize(query, { onChanges })` to catch a synchronous backend's first snapshot.58 * A view with any change listener also enriches its own `remove` ops with the evicted subtree59 * (per-view, no global opt-in). Returns a detach function. */60 onChanges(listener: ViewChangeListener): () => void;61 /** Tear down + stop receiving updates. */62 destroy(): void;63}6465/** What a top-level `.one()` query materializes to: the single row (or `null`), not an array.66 * A thin adapter over a {@link FlatArrayView} — all the folding is shared; only the result67 * boundary unwraps (`data[0] ?? null`). Reference identity of the row is preserved. */68export interface SingularArrayView<R> {69 /** The single current row, or `null` when the query matches nothing. */70 readonly data: R | null;71 /** The engine query id — see {@link ArrayView.qid}. */72 readonly qid: QueryId;73 /** The query's view `WireSchema` — see {@link ArrayView.schema}. */74 readonly schema: WireSchema | null;75 /** The query's lifecycle state — see {@link ArrayView.resultType}. */76 readonly resultType: ResultType;77 /** Subscribe; fires immediately with the current row, then after each applied batch (and after78 * a {@link resultType} change). */79 subscribe(listener: (data: R | null) => void): () => void;80 /** Subscribe to the view's folded CHANGE stream — see {@link ArrayView.onChanges}. The changes81 * are the same positional `FlatChange`s as the plural view (a `.one()` is just the list capped to82 * one), so a narrator resolves them identically. */83 onChanges(listener: ViewChangeListener): () => void;84 /** Tear down + stop receiving updates. */85 destroy(): void;86}8788/** Wrap a plural {@link FlatArrayView} as a {@link SingularArrayView} for a `.one()` query89 * (the engine caps it to `limit = 1`, so the top list holds at most one node). */90export class SingularView<R> implements SingularArrayView<R> {91 private readonly inner: ArrayView<R>;9293 constructor(inner: ArrayView<R>) {94 this.inner = inner;95 }9697 get data(): R | null {98 return this.inner.data[0] ?? null;99 }100101 get qid(): QueryId {102 return this.inner.qid;103 }104105 get schema(): WireSchema | null {106 return this.inner.schema;107 }108109 get resultType(): ResultType {110 return this.inner.resultType;111 }112113 subscribe(listener: (data: R | null) => void): () => void {114 return this.inner.subscribe((d) => listener(d[0] ?? null));115 }116117 onChanges(listener: ViewChangeListener): () => void {118 return this.inner.onChanges(listener);119 }120121 destroy(): void {122 this.inner.destroy();123 }124}125126/** An internal reconstruction node: the row, multi-path refcount, per-slot child lists,127 * and a memoized projected output (`null` ⇒ stale, rebuilt on next read). */128interface Node {129 row: WireValue[];130 rc: number;131 rels: Node[][];132 out: unknown;133}134135function binarySearch(136 list: Node[],137 row: WireValue[],138 sort: [number, boolean][],139): { found: boolean; index: number } {140 let lo = 0;141 let hi = list.length;142 while (lo < hi) {143 const mid = (lo + hi) >>> 1;144 const c = compareRows(list[mid].row, row, sort);145 if (c < 0) lo = mid + 1;146 else if (c > 0) hi = mid;147 else return { found: true, index: mid };148 }149 return { found: false, index: lo };150}151152/** Build a fresh node (rc = 1) with its inline subtree, mirroring `init_rels_for_new_entry`:153 * one list per declared slot; fill in-view slots from the payload (children inserted in the154 * order shipped — NEVER re-sorted — folding same-sort-key dups to rc += 1). */155function buildNode(wnode: WireNode, schema: WireSchema): Node {156 const rels: Node[][] = schema.relationships.map(() => []);157 for (const { rel, children } of wnode.rels) {158 const child = schema.relationships[rel]?.child;159 if (!child) continue; // join-only / gating slot — not materialized160 const built: Node[] = [];161 for (const c of children) {162 const at = binarySearch(built, c.row, child.sort);163 if (at.found) built[at.index].rc += 1;164 else built.splice(at.index, 0, buildNode(c, child));165 }166 rels[rel] = built;167 }168 return { row: wnode.row, rc: 1, rels, out: null };169}170171function cloneNode(n: Node): Node {172 return { row: n.row.slice(), rc: n.rc, rels: n.rels.map((r) => r.map(cloneNode)), out: null };173}174175/** Reconstruct a positional {@link WireNode} (row + its populated child slots) from a maintained176 * {@link Node} — the inverse of {@link buildNode}. Used to attach a removed subtree to a `remove`177 * op so a change consumer (a narrator) can resolve its nested subs, which a bare positional remove178 * (just the leaving row) cannot carry. Only in-view (`child !== null`), non-empty slots are emitted,179 * matching how an `add` node ships only populated relationships. */180function toWireNode(node: Node, schema: WireSchema): WireNode {181 const rels: { rel: number; children: WireNode[] }[] = [];182 for (const rel of schema.relationships) {183 if (rel.child === null) continue;184 const childList = node.rels[rel.slot] ?? [];185 if (childList.length === 0) continue;186 rels.push({ rel: rel.slot, children: childList.map((c) => toWireNode(c, rel.child as WireSchema)) });187 }188 return { row: node.row, rels };189}190191/** Elementwise equality over positional bare-cell rows — THE row comparator for every JS-side192 * diff (views, aggregate heads, the persistence mirror), exported so no caller grows a drifted193 * private copy. Per cell: `===` keeps `-0 === 0` (the engine's key semantics treat them equal);194 * the `Object.is` arm makes a NaN cell equal itself (under `!==` alone, a NaN-bearing row never195 * matches any copy of itself, so every diff re-emits it as a spurious edit forever). */196export function rowsEqual(a: WireValue[], b: WireValue[]): boolean {197 if (a.length !== b.length) return false;198 for (let i = 0; i < a.length; i++) {199 if (a[i] !== b[i] && !Object.is(a[i], b[i])) return false;200 }201 return true;202}203204/** JSON.stringify for keying a row, except the numbers JSON folds together — NaN/±Infinity (→ null)205 * and -0 (→ 0) — get a sentinel encoding, so two rows that `rowsEqual` (Object.is) distinguishes206 * never share a net key (a `remove [1, Infinity]` must not cancel an `add [1, null]`). */207function keyRow(row: WireValue[]): string {208 return JSON.stringify(row, (_k, v: unknown) =>209 typeof v === "number" && (!Number.isFinite(v) || Object.is(v, -0)) ? [" num", Object.is(v, -0) ? "-0" : String(v)] : v,210 );211}212213/** The key a subtractive net matches an op on: the path (parent locators, one hop per level) plus the214 * row bytes. Two ops with the same key touch the same row at the same tree position. */215function opRowKey(path: FlatChange["path"], row: WireValue[]): string {216 let k = "";217 for (const seg of path) k += seg.rel + ":" + keyRow(seg.parentRow) + "/";218 return k + "|" + keyRow(row);219}220221/** Walk `path` down `schema` to the changed level's `WireSchema` — `null` when a hop crosses a222 * gating (join-only) slot; such ops never fold, so they never reach the net. */223function levelSchemaAt(schema: WireSchema, path: FlatChange["path"]): WireSchema | null {224 let level: WireSchema = schema;225 for (const seg of path) {226 const child = level.relationships[seg.rel]?.child;227 if (!child) return null;228 level = child;229 }230 return level;231}232233/** Deep structural equality of two canonical (maintained-order) subtrees: rows via Object.is,234 * per-slot children pairwise, rc included — a dup-path child only equals an equally-duplicated one. */235function nodesEqual(a: Node, b: Node): boolean {236 if (a.rc !== b.rc || !rowsEqual(a.row, b.row) || a.rels.length !== b.rels.length) return false;237 for (let s = 0; s < a.rels.length; s++) {238 const ra = a.rels[s];239 const rb = b.rels[s];240 if (ra.length !== rb.length) return false;241 for (let i = 0; i < ra.length; i++) if (!nodesEqual(ra[i], rb[i])) return false;242 }243 return true;244}245246/** Cancel no-op cycles in a folded batch SUBTRACTIVELY — the safe half of the removed engine247 * coalescer (OPTIMISTIC-WRITES-DESIGN §3, and `wasm/db.rs` "Step 2 of the removal"). A rebase248 * re-invokes a still-pending prediction, emitting a balanced `remove R`+`add R` (or `edit b→a`+249 * `edit a→b`) that nets to nothing; so a correctly predicted optimistic write delivers ZERO changes250 * to a narrator, and only genuine (mispredicted / other-client) changes survive. We ONLY drop exact251 * inverse pairs — byte-identical row at the same path AND a structurally identical subtree: a row252 * that leaves and re-enters within one batch carrying a CHANGED child set is NOT an inverse (the253 * child never got its own event while the row was out — the re-add is its only carrier), so that254 * pair survives. Survivors pass through UNMODIFIED: we never rewrite a locator, reorder, or255 * reconstruct a gated subtree (the reconstructive netting that shipped four correctness bugs and256 * was pulled from the engine). Because this feeds prose, not a locate-by-value receiver, even an257 * imperfect net is at worst a spurious line, never a corrupt view — and every conservative bail258 * here (unknown subtree, gated level) errs on the spurious-line side, never the missed-line side.259 * Order-preserving for survivors; returns the input array unchanged when nothing cancels. */260function netChanges(changes: FlatChange[], schema: WireSchema): FlatChange[] {261 if (changes.length < 2) return changes;262 const alive = new Array<boolean>(changes.length).fill(true);263 const removes = new Map<string, number[]>(); // key → indices of not-yet-cancelled removes264 const adds = new Map<string, number[]>(); // key → indices of not-yet-cancelled adds265 const edits = new Map<string, number[]>(); // pathKey+old+new → indices of not-yet-cancelled edits266 // The subtree an add/remove carries, canonicalized to maintained (sorted, dup-folded) form so a267 // shipped-order `add` node and a maintained-order enriched `remove` node compare structurally.268 // Built ONLY when two ops collide on the row key — the common no-collision delivery never builds269 // one — and memoized per op index. `null` ⇒ subtree unknown (a gated level, or a remove that was270 // not enriched); conservative: an unknown subtree never cancels, so we err toward a spurious line.271 const subCache = new Map<number, Node | null>();272 const subtreeOf = (i: number): Node | null => {273 let s = subCache.get(i);274 if (s !== undefined) return s;275 const { path, op } = changes[i];276 const level = levelSchemaAt(schema, path);277 // A remove reaching the net actually evicted its node (only applied ops are delivered) and so was278 // enriched with its subtree on `op.node`; an add always carries `op.node`. Either is canonicalized.279 const wnode = op.tag === "add" ? op.node : op.tag === "remove" ? op.node : undefined;280 s = level && wnode ? buildNode(wnode, level) : null;281 subCache.set(i, s);282 return s;283 };284 // Take the LATEST pending opposite at the same key whose subtree also matches (LIFO, like the285 // plain `take` below, so nested cycles unwind innermost-first).286 const takeInverse = (m: Map<string, number[]>, k: string, i: number): number | undefined => {287 const arr = m.get(k);288 if (!arr || arr.length === 0) return undefined;289 const mine = subtreeOf(i);290 if (mine === null) return undefined;291 for (let x = arr.length - 1; x >= 0; x--) {292 const theirs = subtreeOf(arr[x]);293 if (theirs !== null && nodesEqual(mine, theirs)) {294 const j = arr[x];295 arr.splice(x, 1);296 return j;297 }298 }299 return undefined;300 };301 const take = (m: Map<string, number[]>, k: string): number | undefined => {302 const arr = m.get(k);303 return arr && arr.length ? arr.pop() : undefined;304 };305 const put = (m: Map<string, number[]>, k: string, i: number): void => {306 const arr = m.get(k);307 if (arr) arr.push(i);308 else m.set(k, [i]);309 };310 let cancelled = false;311 for (let i = 0; i < changes.length; i++) {312 const { path, op } = changes[i];313 if (op.tag === "remove") {314 const k = opRowKey(path, op.row);315 const j = takeInverse(adds, k, i);316 if (j !== undefined) {317 alive[i] = alive[j] = false;318 cancelled = true;319 } else put(removes, k, i);320 } else if (op.tag === "add") {321 const k = opRowKey(path, op.node.row);322 const j = takeInverse(removes, k, i);323 if (j !== undefined) {324 alive[i] = alive[j] = false;325 cancelled = true;326 } else put(adds, k, i);327 } else {328 // edit: cancel against a prior INVERSE edit (a→b then b→a) at the same path. The old/new329 // bytes are part of the map key, so the inverse lookup is O(1) instead of a rescan.330 const pk = opRowKey(path, []);331 const oldS = keyRow(op.old);332 const newS = keyRow(op.new);333 const j = take(edits, pk + "