Rindle

API index and search · Build metadata

Source snapshot

packages/devtools/src/core.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// DevtoolsCore — the framework-agnostic engine (DEBUG-TOOLS-BROWSER-DESIGN.md §6.1). It attaches to2// a running client through the read-only seams (`Store.subscribeChanges` / `subscribeResultType` /3// `__inspect`, and the optimistic backend's `__inspect`), then maintains the §4 read-model: the4// mutation TIMELINE5// (reconstructed by diffing successive pending-stack snapshots), the QUERIES inspector, and the6// DELTA stream (preferentially off authoritative backend/server frames, with the Store's raw7// per-query `ChangeEvent` tap as the local fallback). No DOM, no app-code changes, no hot path.89import { collectTables, summarizeAst } from "./ast.ts";10import type {11  BackendServerDelta,12  ChangeEvent,13  DeltaEntry,14  DeltaKind,15  DevtoolsBackend,16  DevtoolsCoreOptions,17  DevtoolsState,18  DevtoolsStore,19  DevtoolsTarget,20  FlatOp,21  NormalizedOp,22  OptimisticInspect,23  QueryEntry,24  QueryId,25  ResultType,26  TimelineEntry,27} from "./types.ts";2829/** Narrow a candidate backend to the optional devtools capability surface (§6.2). */30function asDevtoolsBackend(backend: unknown): DevtoolsBackend | undefined {31  if (!backend || typeof backend !== "object") return undefined;32  const b = backend as DevtoolsBackend;33  return typeof b.__inspect === "function" || typeof b.__attachDevtoolsServerDeltas === "function" ? b : undefined;34}3536const EMPTY_STATE: DevtoolsState = {37  timeline: [],38  queries: [],39  deltas: [],40  optimistic: undefined,41  capabilities: { optimistic: false },42};4344export class DevtoolsCore {45  private readonly store: DevtoolsStore;46  private readonly backend?: DevtoolsBackend;47  private readonly detachStore: () => void;48  private readonly detachServerDeltas?: () => void;49  private readonly hasServerDeltaTap: boolean;5051  private readonly timelineCap: number;52  private readonly deltaCap: number;53  private readonly sampleRows: number;54  private readonly autoFlush: boolean;55  private readonly now: () => number;56  private readonly pollHandle?: ReturnType<typeof setInterval>;5758  // Timeline state: entries by stable id + an insertion-ordered id list (newest last).59  private readonly timeline = new Map<string, TimelineEntry>();60  private readonly order: string[] = [];61  /** The pending keys seen at the previous recompute — the diff basis for lifecycle transitions. */62  private prevPendingKeys = new Set<string>();6364  // Delta ring.65  private deltas: DeltaEntry[] = [];66  private deltaSeq = 0;67  /** Set by a `batch` delta, consumed by the next recompute: did view churn coincide with a68   *  confirmation this turn? (the §4.1 snap-back heuristic). */69  private churnSeen = false;7071  private queries: QueryEntry[] = [];72  private optimistic?: OptimisticInspect;7374  private readonly listeners = new Set<() => void>();75  private dirty = false;76  private flushScheduled = false;77  private snapshot: DevtoolsState = EMPTY_STATE;78  /** Optional deregistration from the global hub (set by {@link attachDevtools}). */79  onDetach?: () => void;8081  constructor(target: DevtoolsTarget, opts: DevtoolsCoreOptions = {}) {82    this.store = target.store;83    this.backend = asDevtoolsBackend(target.backend);84    this.timelineCap = opts.timelineCap ?? 200;85    this.deltaCap = opts.deltaCap ?? 500;86    this.sampleRows = opts.sampleRows ?? 25;87    this.autoFlush = opts.autoFlush ?? true;88    this.now = opts.now ?? (() => Date.now());8990    this.detachServerDeltas = this.attachServerDeltas();91    this.hasServerDeltaTap = !!this.detachServerDeltas;9293    // The post-fold delta + resultType taps off the Store's supported subscription seams (the same94    // `subscribeChanges` an app narrates off — no private back door). Both fire AFTER the view folds,95    // which is the timing the snap-back/churn heuristics need.96    const detachDeltas = this.store.subscribeChanges((qid, ev) => this.onStoreDelta(qid, ev));97    const detachResultType = this.store.subscribeResultType((qid, rt) => this.onResultType(qid, rt));98    this.detachStore = () => {99      detachDeltas();100      detachResultType();101    };102103    const pollMs = opts.pollMs ?? 0;104    if (pollMs > 0) {105      this.pollHandle = setInterval(() => this.refresh(), pollMs);106      // Don't keep a Node process alive on the poll timer (no-op in the browser).107      (this.pollHandle as { unref?: () => void }).unref?.();108    }109110    // Seed the initial snapshot from whatever the app already holds (e.g. queries mounted before111    // the pane attached).112    this.refresh();113  }114115  // --- public surface ----------------------------------------------------------116117  /** The current read-model. Reference-stable between updates (rebuilt only on recompute), so it is118   *  safe to feed a `useSyncExternalStore`-style binding. */119  getState(): DevtoolsState {120    return this.snapshot;121  }122123  /** Subscribe to updates; fires immediately with the current state, then after each recompute. */124  subscribe(listener: () => void): () => void {125    this.listeners.add(listener);126    listener();127    return () => {128      this.listeners.delete(listener);129    };130  }131132  /** Force a synchronous recompute (also used by the safety-net poll and by tests). */133  refresh(): void {134    this.dirty = true;135    this.flush();136  }137138  /** Clear the delta stream ring (a panel "clear" affordance). */139  clearDeltas(): void {140    this.deltas = [];141    this.refresh();142  }143144  /** Drop every SETTLED timeline row (confirmed/dropped), keeping live pending ones + the deltas. */145  clearHistory(): void {146    for (let i = this.order.length - 1; i >= 0; i--) {147      const id = this.order[i];148      const e = this.timeline.get(id);149      if (e && e.state !== "pending") {150        this.timeline.delete(id);151        this.order.splice(i, 1);152      }153    }154    this.refresh();155  }156157  /** Detach from the client: stop the poll, drop the store tap, deregister from the global hub. */158  detach(): void {159    if (this.pollHandle) clearInterval(this.pollHandle);160    this.detachStore();161    this.detachServerDeltas?.();162    this.onDetach?.();163    this.listeners.clear();164  }165166  // --- event taps --------------------------------------------------------------167168  private attachServerDeltas(): (() => void) | undefined {169    try {170      return this.backend?.__attachDevtoolsServerDeltas?.({171        onServerDelta: (qid, ev) => this.onServerDelta(qid, ev),172      });173    } catch {174      return undefined;175    }176  }177178  private onStoreDelta(qid: QueryId, ev: ChangeEvent): void {179    // Prefer the authoritative backend/server stream for the visible Deltas pane. The Store tap is180    // still observed for timeline churn: it fires after the view folded the event, which is the181    // signal the snap-back heuristic needs.182    if (!this.hasServerDeltaTap) this.pushChangeDelta(qid, ev);183    // A `batch` carrying real changes is view churn — the signal the snap-back heuristic keys on.184    if (ev.type === "batch" && ev.events.length > 0) this.churnSeen = true;185    this.markDirty();186  }187188  private onServerDelta(qid: QueryId, ev: BackendServerDelta): void {189    this.pushServerDelta(qid, ev);190    this.markDirty();191  }192193  private onResultType(_qid: QueryId, _rt: ResultType): void {194    this.markDirty();195  }196197  private markDirty(): void {198    this.dirty = true;199    if (this.autoFlush && !this.flushScheduled) {200      this.flushScheduled = true;201      queueMicrotask(() => {202        this.flushScheduled = false;203        this.flush();204      });205    }206  }207208  private flush(): void {209    if (!this.dirty) return;210    this.dirty = false;211    this.recompute();212    for (const l of this.listeners) l();213  }214215  // --- recompute ---------------------------------------------------------------216217  private recompute(): void {218    let opt: OptimisticInspect | undefined;219    if (this.backend?.__inspect) {220      try {221        opt = this.backend.__inspect();222      } catch {223        opt = undefined; // a malformed/throwing dev hook never breaks the pane224      }225    }226    if (opt) this.reconcileTimeline(opt);227    this.rebuildQueries(opt);228    this.optimistic = opt;229    this.churnSeen = false;230    this.snapshot = {231      timeline: this.order.map((id) => this.timeline.get(id)).filter((e): e is TimelineEntry => !!e),232      queries: this.queries,233      deltas: this.deltas.slice(),234      optimistic: opt,235      capabilities: { optimistic: typeof this.backend?.__inspect === "function" },236    };237  }238239  /** Reconstruct the fork/rebase lifecycle by diffing this pending snapshot against the previous240   *  one (DEBUG-TOOLS-BROWSER-DESIGN §4.1): new keys → invoked; vanished keys → confirmed (mid ≤241   *  confirmedLmid) or dropped; a fold's `f:<foldKey>` → `m:<mid>` transition is linked, not double242   *  counted. */243  private reconcileTimeline(opt: OptimisticInspect): void {244    const now = this.now();245    const cur = opt.pending;246    const curKeys = new Set(cur.map((p) => p.key));247248    const removed: string[] = [];249    for (const k of this.prevPendingKeys) if (!curKeys.has(k)) removed.push(k);250    const added = cur.filter((p) => !this.prevPendingKeys.has(p.key));251252    // Fold-flush linking: an `f:<foldKey>` that vanished as an `m:<mid>` of the same (name, args)253    // appeared is one logical mutation crossing the wire — relabel in place (FOLDED-MUTATIONS §4.1).254    const linkedF = new Set<string>();255    const linkedM = new Set<string>();256    for (const fkey of removed) {257      if (!fkey.startsWith("f:")) continue;258      const fEntry = this.timeline.get(fkey);259      if (!fEntry) continue;260      const match = added.find(261        (p) => p.key.startsWith("m:") && !linkedM.has(p.key) && p.name === fEntry.name && argsEqual(p.args, fEntry.args),262      );263      if (!match) continue;264      this.renameEntry(fkey, match.key);265      const e = this.timeline.get(match.key);266      if (e) {267        e.mid = match.mid;268        e.tables = match.tables;269        e.folded = true;270        // A flushed fold has already left `folds`, so the m:<mid> snapshot carries no `fold` window —271        // keep the one captured while it was debouncing and just mark it flushed.272        const window = match.fold ?? e.fold;273        e.fold = window ? { ...window, flushed: true } : undefined;274      }275      linkedF.add(fkey);276      linkedM.add(match.key);277    }278279    // Freshly invoked entries (not the m: side of a link).280    for (const p of added) {281      if (linkedM.has(p.key) || this.timeline.has(p.key)) continue;282      this.addEntry({283        id: p.key,284        mid: p.mid,285        name: p.name,286        args: p.args,287        tables: p.tables,288        state: "pending",289        folded: !!p.fold,290        fold: p.fold,291        invokedAt: now,292        reconciledWithChurn: false,293        affectedQueries: [],294      });295    }296297    // Refresh still-pending entries (a rebase re-invoke can change touched tables / args / mid).298    for (const p of cur) {299      const e = this.timeline.get(p.key);300      if (!e || e.state !== "pending") continue;301      e.mid = p.mid;302      e.args = p.args;303      e.tables = p.tables;304      if (p.fold) {305        e.folded = true;306        e.fold = p.fold;307      }308    }309310    // Settle vanished entries (those not consumed by a fold link).311    for (const k of removed) {312      if (linkedF.has(k)) continue;313      const e = this.timeline.get(k);314      if (!e || e.state !== "pending") continue;315      const confirmed = e.mid != null && e.mid <= opt.confirmedLmid;316      e.state = confirmed ? "confirmed" : "dropped";317      e.settledAt = now;318      e.reconciledWithChurn = confirmed && this.churnSeen;319    }320321    this.prevPendingKeys = curKeys;322  }323324  private rebuildQueries(opt: OptimisticInspect | undefined): void {325    const pendingTables = new Set(opt?.pendingTables ?? []);326    const queries: QueryEntry[] = this.store.__inspect(this.sampleRows).queries.map((q) => {327      const tables = [...collectTables(q.ast)];328      return {329        qid: q.qid,330        ast: q.ast,331        table: q.ast.table,332        summary: summarizeAst(q.ast),333        tables,334        resultType: q.resultType,335        rowCount: q.rowCount,336        sample: q.sample,337        pending: tables.some((t) => pendingTables.has(t)),338      };339    });340    this.queries = queries;341342    // Recompute each timeline entry's affected queries against the CURRENT live views.343    if (this.timeline.size > 0) {344      for (const e of this.timeline.values()) {345        const touched = new Set(e.tables);346        e.affectedQueries = queries.filter((q) => q.tables.some((t) => touched.has(t))).map((q) => q.qid);347      }348    }349  }350351  // --- delta ring --------------------------------------------------------------352353  private pushServerDelta(qid: QueryId, ev: BackendServerDelta): void {354    if (ev.format === "flat") {355      this.pushChangeDelta(qid, ev.event);356      return;357    }358    this.pushNormalizedDelta(qid, ev.event);359  }360361  private pushChangeDelta(qid: QueryId, ev: ChangeEvent): void {362    if (ev.type === "hello") {363      this.appendDelta(qid, "hello", 0, `hello · ${ev.schema.columns.length} col${ev.schema.columns.length === 1 ? "" : "s"}`);364    } else if (ev.type === "snapshot") {365      const n = ev.adds.length;366      this.appendDelta(qid, "snapshot", 0, `snapshot · +${n} row${n === 1 ? "" : "s"}${ev.last ? "" : " (chunk)"}`);367    } else {368      for (const ch of ev.events) {369        const depth = ch.path.length;370        this.appendDelta(qid, ch.op.tag, depth, describeOp(ch.op) + (depth > 0 ? `  (child @${depth})` : ""));371      }372    }373  }374375  private pushNormalizedDelta(qid: QueryId, ev: Extract<BackendServerDelta, { format: "normalized" }>["event"]): void {376    if (ev.type === "hello") {377      this.appendDelta(qid, "hello", 0, `server hello · ${ev.tables.length} table${ev.tables.length === 1 ? "" : "s"}`);378    } else if (ev.type === "snapshot") {379      const n = ev.ops.length;380      this.appendDelta(qid, "snapshot", 0, `server snapshot · ${n} op${n === 1 ? "" : "s"}${cvLabel(ev.cv)}`);381    } else {382      for (const op of ev.ops) this.appendDelta(qid, op.op, 0, describeNormalizedOp(op) + cvLabel(ev.cv));383    }384  }385386  private appendDelta(qid: QueryId, kind: DeltaKind, depth: number, label: string): void {387    this.deltas.push({ seq: this.deltaSeq++, at: this.now(), qid, kind, depth, label });388    if (this.deltas.length > this.deltaCap) this.deltas.splice(0, this.deltas.length - this.deltaCap);389  }390391  // --- timeline map bookkeeping ------------------------------------------------392393  private addEntry(entry: TimelineEntry): void {394    this.timeline.set(entry.id, entry);395    this.order.push(entry.id);396    // Cap: evict the oldest SETTLED rows first; never drop a live pending row.397    while (this.order.length > this.timelineCap) {398      const idx = this.order.findIndex((id) => this.timeline.get(id)?.state !== "pending");399      if (idx < 0) break;400      const [evicted] = this.order.splice(idx, 1);401      this.timeline.delete(evicted);402    }403  }404405  private renameEntry(oldId: string, newId: string): void {406    const e = this.timeline.get(oldId);407    if (!e) return;408    this.timeline.delete(oldId);409    e.id = newId;410    this.timeline.set(newId, e);411    const i = this.order.indexOf(oldId);412    if (i >= 0) this.order[i] = newId;413  }414}415416/** Structural args equality for fold-flush linking (args are JSON-serializable mutator inputs). */417function argsEqual(a: unknown, b: unknown): boolean {418  if (a === b) return true;419  try {420    return JSON.stringify(a) === JSON.stringify(b);421  } catch {422    return false;423  }424}425426function describeOp(op: FlatOp): string {427  if (op.tag === "add") return `add ${rowPreview(op.node.row)}`;428  if (op.tag === "remove") return `remove ${rowPreview(op.row)}`;429  return `edit ${rowPreview(op.old)} → ${rowPreview(op.new)}`;430}431432function describeNormalizedOp(op: NormalizedOp): string {433  if (op.op === "add") return `add ${op.table} ${rowPreview(op.row)}`;434  if (op.op === "remove") return `remove ${op.table} ${rowPreview(op.row)}`;435  return `edit ${op.table} ${rowPreview(op.old)} → ${rowPreview(op.new)}`;436}437438function cvLabel(cv: number | undefined): string {439  return cv === undefined ? "" : ` · cv ${cv}`;440}441442function rowPreview(row: readonly unknown[]): string {443  const head = row.slice(0, 3).map(cellStr).join(", ");444  return `[${head}${row.length > 3 ? ", …" : ""}]`;445}446447function cellStr(v: unknown): string {448  if (typeof v === "string") return v.length > 16 ? `"${v.slice(0, 15)}…"` : `"${v}"`;449  return String(v);450}451