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