@rindle/devtools 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +58 -0
- package/dist/ast.d.ts +10 -0
- package/dist/ast.d.ts.map +1 -0
- package/dist/ast.js +73 -0
- package/dist/ast.js.map +1 -0
- package/dist/core.d.ts +59 -0
- package/dist/core.d.ts.map +1 -0
- package/dist/core.js +351 -0
- package/dist/core.js.map +1 -0
- package/dist/global.d.ts +22 -0
- package/dist/global.d.ts.map +1 -0
- package/dist/global.js +60 -0
- package/dist/global.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/types.d.ts +137 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +5 -0
- package/dist/types.js.map +1 -0
- package/package.json +39 -0
- package/src/ast.ts +66 -0
- package/src/core.ts +388 -0
- package/src/global.ts +79 -0
- package/src/index.ts +30 -0
- package/src/types.ts +160 -0
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@rindle/devtools",
|
|
3
|
+
"version": "0.1.6",
|
|
4
|
+
"license": "Apache-2.0",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/rindle-sh/rindle.git",
|
|
8
|
+
"directory": "packages/devtools"
|
|
9
|
+
},
|
|
10
|
+
"type": "module",
|
|
11
|
+
"description": "Framework-agnostic in-browser devtools core for Rindle: mutation timeline, queries inspector, and delta stream built over the client's existing read-only seams (DEBUG-TOOLS-BROWSER-DESIGN.md). Dev-only; no DOM assumptions.",
|
|
12
|
+
"main": "./dist/index.js",
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"sideEffects": false,
|
|
15
|
+
"exports": {
|
|
16
|
+
".": {
|
|
17
|
+
"@rindle/source": "./src/index.ts",
|
|
18
|
+
"types": "./dist/index.d.ts",
|
|
19
|
+
"default": "./dist/index.js"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"dist",
|
|
24
|
+
"src",
|
|
25
|
+
"README.md"
|
|
26
|
+
],
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@rindle/client": "0.1.6"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@types/node": "^22.10.0",
|
|
32
|
+
"typescript": "^5.7.0"
|
|
33
|
+
},
|
|
34
|
+
"scripts": {
|
|
35
|
+
"build": "tsc -p tsconfig.build.json",
|
|
36
|
+
"typecheck": "tsc --noEmit",
|
|
37
|
+
"test": "tsc --noEmit && node --conditions=@rindle/source --test test/*.test.ts"
|
|
38
|
+
}
|
|
39
|
+
}
|
package/src/ast.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Read-only AST helpers for the queries inspector (DEBUG-TOOLS-BROWSER-DESIGN §4.2): the table
|
|
2
|
+
// footprint (for the pending-axis intersection) and a one-line human summary. Pure functions over
|
|
3
|
+
// the `@rindle/client` `Ast` — no engine, no side effects.
|
|
4
|
+
|
|
5
|
+
import type { Ast, Condition, ValuePosition } from "@rindle/client";
|
|
6
|
+
|
|
7
|
+
/** Every base table this query can read: the root, each `related` subquery, and any correlated
|
|
8
|
+
* subquery in the `where` tree (recursively). Mirrors the optimistic backend's `queryTables`
|
|
9
|
+
* derivation (DEBUG-TOOLS-BROWSER-DESIGN §4.2 — a `count(comments)` subquery names `comment`, so a
|
|
10
|
+
* comment mutation flips this query's pending axis). */
|
|
11
|
+
export function collectTables(ast: Ast, into: Set<string> = new Set()): Set<string> {
|
|
12
|
+
into.add(ast.table);
|
|
13
|
+
for (const r of ast.related ?? []) collectTables(r.subquery, into);
|
|
14
|
+
if (ast.where) collectTablesFromCondition(ast.where, into);
|
|
15
|
+
return into;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function collectTablesFromCondition(cond: Condition, into: Set<string>): void {
|
|
19
|
+
if (cond.type === "and" || cond.type === "or") {
|
|
20
|
+
for (const c of cond.conditions) collectTablesFromCondition(c, into);
|
|
21
|
+
} else if (cond.type === "correlatedSubquery") {
|
|
22
|
+
collectTables(cond.related.subquery, into);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A compact, one-line description of a query AST for the inspector header — best-effort and
|
|
27
|
+
* truncation-friendly (the full AST is available for a collapsible pretty-print alongside). */
|
|
28
|
+
export function summarizeAst(ast: Ast): string {
|
|
29
|
+
const parts: string[] = [ast.alias ? `${ast.table} as ${ast.alias}` : ast.table];
|
|
30
|
+
if (ast.aggregate) parts.push(ast.aggregate === "count" ? "count(*)" : ast.aggregate);
|
|
31
|
+
if (ast.select?.length) parts.push(`select(${ast.select.join(", ")})`);
|
|
32
|
+
if (ast.where) parts.push(`where(${summarizeCondition(ast.where)})`);
|
|
33
|
+
if (ast.orderBy?.length) parts.push(`order(${ast.orderBy.map(([f, d]) => `${f} ${d}`).join(", ")})`);
|
|
34
|
+
if (ast.limit !== undefined) parts.push(`limit ${ast.limit}`);
|
|
35
|
+
if (ast.one) parts.push("one");
|
|
36
|
+
if (ast.related?.length) {
|
|
37
|
+
const rels = ast.related.map((r) => {
|
|
38
|
+
const sub = r.subquery;
|
|
39
|
+
const tag = sub.aggregate ? `${sub.alias ?? sub.table}(count)` : (sub.alias ?? sub.table);
|
|
40
|
+
return tag;
|
|
41
|
+
});
|
|
42
|
+
parts.push(`{ ${rels.join(", ")} }`);
|
|
43
|
+
}
|
|
44
|
+
return parts.join(" ");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function summarizeCondition(cond: Condition): string {
|
|
48
|
+
switch (cond.type) {
|
|
49
|
+
case "simple":
|
|
50
|
+
return `${valueStr(cond.left)} ${cond.op} ${valueStr(cond.right)}`;
|
|
51
|
+
case "and":
|
|
52
|
+
return cond.conditions.map(summarizeCondition).join(" AND ");
|
|
53
|
+
case "or":
|
|
54
|
+
return `(${cond.conditions.map(summarizeCondition).join(" OR ")})`;
|
|
55
|
+
case "correlatedSubquery":
|
|
56
|
+
return `${cond.op} ${cond.related.subquery.table}`;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function valueStr(v: ValuePosition): string {
|
|
61
|
+
if (v.type === "column") return v.name;
|
|
62
|
+
const lit = v.value;
|
|
63
|
+
if (typeof lit === "string") return JSON.stringify(lit);
|
|
64
|
+
if (Array.isArray(lit)) return `[${lit.length}]`;
|
|
65
|
+
return String(lit);
|
|
66
|
+
}
|
package/src/core.ts
ADDED
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
// DevtoolsCore — the framework-agnostic engine (DEBUG-TOOLS-BROWSER-DESIGN.md §6.1). It attaches to
|
|
2
|
+
// a running client through the read-only seams (`Store.__attachDevtools` / `__inspect`, and the
|
|
3
|
+
// optimistic backend's `__inspect`), then maintains the §4 read-model: the mutation TIMELINE
|
|
4
|
+
// (reconstructed by diffing successive pending-stack snapshots), the QUERIES inspector, and the
|
|
5
|
+
// DELTA stream (off the raw per-query `ChangeEvent` tap). No DOM, no app-code changes, no hot path.
|
|
6
|
+
|
|
7
|
+
import { collectTables, summarizeAst } from "./ast.ts";
|
|
8
|
+
import type {
|
|
9
|
+
ChangeEvent,
|
|
10
|
+
DeltaEntry,
|
|
11
|
+
DeltaKind,
|
|
12
|
+
DevtoolsBackend,
|
|
13
|
+
DevtoolsCoreOptions,
|
|
14
|
+
DevtoolsState,
|
|
15
|
+
DevtoolsStore,
|
|
16
|
+
DevtoolsTarget,
|
|
17
|
+
FlatOp,
|
|
18
|
+
OptimisticInspect,
|
|
19
|
+
QueryEntry,
|
|
20
|
+
QueryId,
|
|
21
|
+
ResultType,
|
|
22
|
+
TimelineEntry,
|
|
23
|
+
} from "./types.ts";
|
|
24
|
+
|
|
25
|
+
/** Narrow a candidate backend to the optimistic capability (the `__inspect` probe, §6.2). */
|
|
26
|
+
function asOptimisticBackend(backend: unknown): DevtoolsBackend | undefined {
|
|
27
|
+
return backend && typeof (backend as DevtoolsBackend).__inspect === "function"
|
|
28
|
+
? (backend as DevtoolsBackend)
|
|
29
|
+
: undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const EMPTY_STATE: DevtoolsState = {
|
|
33
|
+
timeline: [],
|
|
34
|
+
queries: [],
|
|
35
|
+
deltas: [],
|
|
36
|
+
optimistic: undefined,
|
|
37
|
+
capabilities: { optimistic: false },
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export class DevtoolsCore {
|
|
41
|
+
private readonly store: DevtoolsStore;
|
|
42
|
+
private readonly backend?: DevtoolsBackend;
|
|
43
|
+
private readonly detachStore: () => void;
|
|
44
|
+
|
|
45
|
+
private readonly timelineCap: number;
|
|
46
|
+
private readonly deltaCap: number;
|
|
47
|
+
private readonly sampleRows: number;
|
|
48
|
+
private readonly autoFlush: boolean;
|
|
49
|
+
private readonly now: () => number;
|
|
50
|
+
private readonly pollHandle?: ReturnType<typeof setInterval>;
|
|
51
|
+
|
|
52
|
+
// Timeline state: entries by stable id + an insertion-ordered id list (newest last).
|
|
53
|
+
private readonly timeline = new Map<string, TimelineEntry>();
|
|
54
|
+
private readonly order: string[] = [];
|
|
55
|
+
/** The pending keys seen at the previous recompute — the diff basis for lifecycle transitions. */
|
|
56
|
+
private prevPendingKeys = new Set<string>();
|
|
57
|
+
|
|
58
|
+
// Delta ring.
|
|
59
|
+
private deltas: DeltaEntry[] = [];
|
|
60
|
+
private deltaSeq = 0;
|
|
61
|
+
/** Set by a `batch` delta, consumed by the next recompute: did view churn coincide with a
|
|
62
|
+
* confirmation this turn? (the §4.1 snap-back heuristic). */
|
|
63
|
+
private churnSeen = false;
|
|
64
|
+
|
|
65
|
+
private queries: QueryEntry[] = [];
|
|
66
|
+
private optimistic?: OptimisticInspect;
|
|
67
|
+
|
|
68
|
+
private readonly listeners = new Set<() => void>();
|
|
69
|
+
private dirty = false;
|
|
70
|
+
private flushScheduled = false;
|
|
71
|
+
private snapshot: DevtoolsState = EMPTY_STATE;
|
|
72
|
+
/** Optional deregistration from the global hub (set by {@link attachDevtools}). */
|
|
73
|
+
onDetach?: () => void;
|
|
74
|
+
|
|
75
|
+
constructor(target: DevtoolsTarget, opts: DevtoolsCoreOptions = {}) {
|
|
76
|
+
this.store = target.store;
|
|
77
|
+
this.backend = asOptimisticBackend(target.backend);
|
|
78
|
+
this.timelineCap = opts.timelineCap ?? 200;
|
|
79
|
+
this.deltaCap = opts.deltaCap ?? 500;
|
|
80
|
+
this.sampleRows = opts.sampleRows ?? 25;
|
|
81
|
+
this.autoFlush = opts.autoFlush ?? true;
|
|
82
|
+
this.now = opts.now ?? (() => Date.now());
|
|
83
|
+
|
|
84
|
+
this.detachStore = this.store.__attachDevtools({
|
|
85
|
+
onDelta: (qid, ev) => this.onDelta(qid, ev),
|
|
86
|
+
onResultType: (qid, rt) => this.onResultType(qid, rt),
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const pollMs = opts.pollMs ?? 0;
|
|
90
|
+
if (pollMs > 0) {
|
|
91
|
+
this.pollHandle = setInterval(() => this.refresh(), pollMs);
|
|
92
|
+
// Don't keep a Node process alive on the poll timer (no-op in the browser).
|
|
93
|
+
(this.pollHandle as { unref?: () => void }).unref?.();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Seed the initial snapshot from whatever the app already holds (e.g. queries mounted before
|
|
97
|
+
// the pane attached).
|
|
98
|
+
this.refresh();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// --- public surface ----------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
/** The current read-model. Reference-stable between updates (rebuilt only on recompute), so it is
|
|
104
|
+
* safe to feed a `useSyncExternalStore`-style binding. */
|
|
105
|
+
getState(): DevtoolsState {
|
|
106
|
+
return this.snapshot;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Subscribe to updates; fires immediately with the current state, then after each recompute. */
|
|
110
|
+
subscribe(listener: () => void): () => void {
|
|
111
|
+
this.listeners.add(listener);
|
|
112
|
+
listener();
|
|
113
|
+
return () => {
|
|
114
|
+
this.listeners.delete(listener);
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Force a synchronous recompute (also used by the safety-net poll and by tests). */
|
|
119
|
+
refresh(): void {
|
|
120
|
+
this.dirty = true;
|
|
121
|
+
this.flush();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Clear the delta stream ring (a panel "clear" affordance). */
|
|
125
|
+
clearDeltas(): void {
|
|
126
|
+
this.deltas = [];
|
|
127
|
+
this.refresh();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Drop every SETTLED timeline row (confirmed/dropped), keeping live pending ones + the deltas. */
|
|
131
|
+
clearHistory(): void {
|
|
132
|
+
for (let i = this.order.length - 1; i >= 0; i--) {
|
|
133
|
+
const id = this.order[i];
|
|
134
|
+
const e = this.timeline.get(id);
|
|
135
|
+
if (e && e.state !== "pending") {
|
|
136
|
+
this.timeline.delete(id);
|
|
137
|
+
this.order.splice(i, 1);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
this.refresh();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Detach from the client: stop the poll, drop the store tap, deregister from the global hub. */
|
|
144
|
+
detach(): void {
|
|
145
|
+
if (this.pollHandle) clearInterval(this.pollHandle);
|
|
146
|
+
this.detachStore();
|
|
147
|
+
this.onDetach?.();
|
|
148
|
+
this.listeners.clear();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// --- event taps --------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
private onDelta(qid: QueryId, ev: ChangeEvent): void {
|
|
154
|
+
this.pushDelta(qid, ev);
|
|
155
|
+
// A `batch` carrying real changes is view churn — the signal the snap-back heuristic keys on.
|
|
156
|
+
if (ev.type === "batch" && ev.events.length > 0) this.churnSeen = true;
|
|
157
|
+
this.markDirty();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
private onResultType(_qid: QueryId, _rt: ResultType): void {
|
|
161
|
+
this.markDirty();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private markDirty(): void {
|
|
165
|
+
this.dirty = true;
|
|
166
|
+
if (this.autoFlush && !this.flushScheduled) {
|
|
167
|
+
this.flushScheduled = true;
|
|
168
|
+
queueMicrotask(() => {
|
|
169
|
+
this.flushScheduled = false;
|
|
170
|
+
this.flush();
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
private flush(): void {
|
|
176
|
+
if (!this.dirty) return;
|
|
177
|
+
this.dirty = false;
|
|
178
|
+
this.recompute();
|
|
179
|
+
for (const l of this.listeners) l();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// --- recompute ---------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
private recompute(): void {
|
|
185
|
+
let opt: OptimisticInspect | undefined;
|
|
186
|
+
if (this.backend) {
|
|
187
|
+
try {
|
|
188
|
+
opt = this.backend.__inspect();
|
|
189
|
+
} catch {
|
|
190
|
+
opt = undefined; // a malformed/throwing dev hook never breaks the pane
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (opt) this.reconcileTimeline(opt);
|
|
194
|
+
this.rebuildQueries(opt);
|
|
195
|
+
this.optimistic = opt;
|
|
196
|
+
this.churnSeen = false;
|
|
197
|
+
this.snapshot = {
|
|
198
|
+
timeline: this.order.map((id) => this.timeline.get(id)).filter((e): e is TimelineEntry => !!e),
|
|
199
|
+
queries: this.queries,
|
|
200
|
+
deltas: this.deltas.slice(),
|
|
201
|
+
optimistic: opt,
|
|
202
|
+
capabilities: { optimistic: !!this.backend },
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Reconstruct the fork/rebase lifecycle by diffing this pending snapshot against the previous
|
|
207
|
+
* one (DEBUG-TOOLS-BROWSER-DESIGN §4.1): new keys → invoked; vanished keys → confirmed (mid ≤
|
|
208
|
+
* confirmedLmid) or dropped; a fold's `f:<foldKey>` → `m:<mid>` transition is linked, not double
|
|
209
|
+
* counted. */
|
|
210
|
+
private reconcileTimeline(opt: OptimisticInspect): void {
|
|
211
|
+
const now = this.now();
|
|
212
|
+
const cur = opt.pending;
|
|
213
|
+
const curKeys = new Set(cur.map((p) => p.key));
|
|
214
|
+
|
|
215
|
+
const removed: string[] = [];
|
|
216
|
+
for (const k of this.prevPendingKeys) if (!curKeys.has(k)) removed.push(k);
|
|
217
|
+
const added = cur.filter((p) => !this.prevPendingKeys.has(p.key));
|
|
218
|
+
|
|
219
|
+
// Fold-flush linking: an `f:<foldKey>` that vanished as an `m:<mid>` of the same (name, args)
|
|
220
|
+
// appeared is one logical mutation crossing the wire — relabel in place (FOLDED-MUTATIONS §4.1).
|
|
221
|
+
const linkedF = new Set<string>();
|
|
222
|
+
const linkedM = new Set<string>();
|
|
223
|
+
for (const fkey of removed) {
|
|
224
|
+
if (!fkey.startsWith("f:")) continue;
|
|
225
|
+
const fEntry = this.timeline.get(fkey);
|
|
226
|
+
if (!fEntry) continue;
|
|
227
|
+
const match = added.find(
|
|
228
|
+
(p) => p.key.startsWith("m:") && !linkedM.has(p.key) && p.name === fEntry.name && argsEqual(p.args, fEntry.args),
|
|
229
|
+
);
|
|
230
|
+
if (!match) continue;
|
|
231
|
+
this.renameEntry(fkey, match.key);
|
|
232
|
+
const e = this.timeline.get(match.key);
|
|
233
|
+
if (e) {
|
|
234
|
+
e.mid = match.mid;
|
|
235
|
+
e.tables = match.tables;
|
|
236
|
+
e.folded = true;
|
|
237
|
+
// A flushed fold has already left `folds`, so the m:<mid> snapshot carries no `fold` window —
|
|
238
|
+
// keep the one captured while it was debouncing and just mark it flushed.
|
|
239
|
+
const window = match.fold ?? e.fold;
|
|
240
|
+
e.fold = window ? { ...window, flushed: true } : undefined;
|
|
241
|
+
}
|
|
242
|
+
linkedF.add(fkey);
|
|
243
|
+
linkedM.add(match.key);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Freshly invoked entries (not the m: side of a link).
|
|
247
|
+
for (const p of added) {
|
|
248
|
+
if (linkedM.has(p.key) || this.timeline.has(p.key)) continue;
|
|
249
|
+
this.addEntry({
|
|
250
|
+
id: p.key,
|
|
251
|
+
mid: p.mid,
|
|
252
|
+
name: p.name,
|
|
253
|
+
args: p.args,
|
|
254
|
+
tables: p.tables,
|
|
255
|
+
state: "pending",
|
|
256
|
+
folded: !!p.fold,
|
|
257
|
+
fold: p.fold,
|
|
258
|
+
invokedAt: now,
|
|
259
|
+
reconciledWithChurn: false,
|
|
260
|
+
affectedQueries: [],
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Refresh still-pending entries (a rebase re-invoke can change touched tables / args / mid).
|
|
265
|
+
for (const p of cur) {
|
|
266
|
+
const e = this.timeline.get(p.key);
|
|
267
|
+
if (!e || e.state !== "pending") continue;
|
|
268
|
+
e.mid = p.mid;
|
|
269
|
+
e.args = p.args;
|
|
270
|
+
e.tables = p.tables;
|
|
271
|
+
if (p.fold) {
|
|
272
|
+
e.folded = true;
|
|
273
|
+
e.fold = p.fold;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Settle vanished entries (those not consumed by a fold link).
|
|
278
|
+
for (const k of removed) {
|
|
279
|
+
if (linkedF.has(k)) continue;
|
|
280
|
+
const e = this.timeline.get(k);
|
|
281
|
+
if (!e || e.state !== "pending") continue;
|
|
282
|
+
const confirmed = e.mid != null && e.mid <= opt.confirmedLmid;
|
|
283
|
+
e.state = confirmed ? "confirmed" : "dropped";
|
|
284
|
+
e.settledAt = now;
|
|
285
|
+
e.reconciledWithChurn = confirmed && this.churnSeen;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
this.prevPendingKeys = curKeys;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
private rebuildQueries(opt: OptimisticInspect | undefined): void {
|
|
292
|
+
const pendingTables = new Set(opt?.pendingTables ?? []);
|
|
293
|
+
const queries: QueryEntry[] = this.store.__inspect(this.sampleRows).queries.map((q) => {
|
|
294
|
+
const tables = [...collectTables(q.ast)];
|
|
295
|
+
return {
|
|
296
|
+
qid: q.qid,
|
|
297
|
+
ast: q.ast,
|
|
298
|
+
table: q.ast.table,
|
|
299
|
+
summary: summarizeAst(q.ast),
|
|
300
|
+
tables,
|
|
301
|
+
resultType: q.resultType,
|
|
302
|
+
rowCount: q.rowCount,
|
|
303
|
+
sample: q.sample,
|
|
304
|
+
pending: tables.some((t) => pendingTables.has(t)),
|
|
305
|
+
};
|
|
306
|
+
});
|
|
307
|
+
this.queries = queries;
|
|
308
|
+
|
|
309
|
+
// Recompute each timeline entry's affected queries against the CURRENT live views.
|
|
310
|
+
if (this.timeline.size > 0) {
|
|
311
|
+
for (const e of this.timeline.values()) {
|
|
312
|
+
const touched = new Set(e.tables);
|
|
313
|
+
e.affectedQueries = queries.filter((q) => q.tables.some((t) => touched.has(t))).map((q) => q.qid);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// --- delta ring --------------------------------------------------------------
|
|
319
|
+
|
|
320
|
+
private pushDelta(qid: QueryId, ev: ChangeEvent): void {
|
|
321
|
+
if (ev.type === "hello") {
|
|
322
|
+
this.appendDelta(qid, "hello", 0, `hello · ${ev.schema.columns.length} col${ev.schema.columns.length === 1 ? "" : "s"}`);
|
|
323
|
+
} else if (ev.type === "snapshot") {
|
|
324
|
+
const n = ev.adds.length;
|
|
325
|
+
this.appendDelta(qid, "snapshot", 0, `snapshot · +${n} row${n === 1 ? "" : "s"}${ev.last ? "" : " (chunk)"}`);
|
|
326
|
+
} else {
|
|
327
|
+
for (const ch of ev.events) {
|
|
328
|
+
const depth = ch.path.length;
|
|
329
|
+
this.appendDelta(qid, ch.op.tag, depth, describeOp(ch.op) + (depth > 0 ? ` (child @${depth})` : ""));
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
private appendDelta(qid: QueryId, kind: DeltaKind, depth: number, label: string): void {
|
|
335
|
+
this.deltas.push({ seq: this.deltaSeq++, at: this.now(), qid, kind, depth, label });
|
|
336
|
+
if (this.deltas.length > this.deltaCap) this.deltas.splice(0, this.deltas.length - this.deltaCap);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// --- timeline map bookkeeping ------------------------------------------------
|
|
340
|
+
|
|
341
|
+
private addEntry(entry: TimelineEntry): void {
|
|
342
|
+
this.timeline.set(entry.id, entry);
|
|
343
|
+
this.order.push(entry.id);
|
|
344
|
+
// Cap: evict the oldest SETTLED rows first; never drop a live pending row.
|
|
345
|
+
while (this.order.length > this.timelineCap) {
|
|
346
|
+
const idx = this.order.findIndex((id) => this.timeline.get(id)?.state !== "pending");
|
|
347
|
+
if (idx < 0) break;
|
|
348
|
+
const [evicted] = this.order.splice(idx, 1);
|
|
349
|
+
this.timeline.delete(evicted);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
private renameEntry(oldId: string, newId: string): void {
|
|
354
|
+
const e = this.timeline.get(oldId);
|
|
355
|
+
if (!e) return;
|
|
356
|
+
this.timeline.delete(oldId);
|
|
357
|
+
e.id = newId;
|
|
358
|
+
this.timeline.set(newId, e);
|
|
359
|
+
const i = this.order.indexOf(oldId);
|
|
360
|
+
if (i >= 0) this.order[i] = newId;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Structural args equality for fold-flush linking (args are JSON-serializable mutator inputs). */
|
|
365
|
+
function argsEqual(a: unknown, b: unknown): boolean {
|
|
366
|
+
if (a === b) return true;
|
|
367
|
+
try {
|
|
368
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
369
|
+
} catch {
|
|
370
|
+
return false;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function describeOp(op: FlatOp): string {
|
|
375
|
+
if (op.tag === "add") return `add ${rowPreview(op.node.row)}`;
|
|
376
|
+
if (op.tag === "remove") return `remove ${rowPreview(op.row)}`;
|
|
377
|
+
return `edit ${rowPreview(op.old)} → ${rowPreview(op.new)}`;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function rowPreview(row: readonly unknown[]): string {
|
|
381
|
+
const head = row.slice(0, 3).map(cellStr).join(", ");
|
|
382
|
+
return `[${head}${row.length > 3 ? ", …" : ""}]`;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function cellStr(v: unknown): string {
|
|
386
|
+
if (typeof v === "string") return v.length > 16 ? `"${v.slice(0, 15)}…"` : `"${v}"`;
|
|
387
|
+
return String(v);
|
|
388
|
+
}
|
package/src/global.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// The opt-in global registry (DEBUG-TOOLS-BROWSER-DESIGN.md §6.2). The client core stays clean —
|
|
2
|
+
// no global mutable singleton lives in `@rindle/client`/`@rindle/optimistic`. Instead a dev build
|
|
3
|
+
// calls `attachDevtools(app)`, which constructs a {@link DevtoolsCore} and registers it on
|
|
4
|
+
// `globalThis.__RINDLE_DEVTOOLS__`, the same discovery pattern TanStack/Redux DevTools use. A panel
|
|
5
|
+
// finds the hub with {@link getDevtoolsHub} and subscribes — so it works whether it mounts before or
|
|
6
|
+
// after the app attaches.
|
|
7
|
+
|
|
8
|
+
import { DevtoolsCore } from "./core.ts";
|
|
9
|
+
import type { DevtoolsCoreOptions, DevtoolsTarget } from "./types.ts";
|
|
10
|
+
|
|
11
|
+
const GLOBAL_KEY = "__RINDLE_DEVTOOLS__";
|
|
12
|
+
|
|
13
|
+
/** The discovery surface a panel binds to: the set of attached cores + a change subscription. */
|
|
14
|
+
export interface DevtoolsHub {
|
|
15
|
+
readonly version: number;
|
|
16
|
+
readonly cores: readonly DevtoolsCore[];
|
|
17
|
+
/** Fires whenever a core attaches or detaches (so a panel can pick one up). */
|
|
18
|
+
subscribe(listener: () => void): () => void;
|
|
19
|
+
/** Register a core; returns its deregistration function. */
|
|
20
|
+
register(core: DevtoolsCore): () => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
class Hub implements DevtoolsHub {
|
|
24
|
+
readonly version = 1;
|
|
25
|
+
readonly cores: DevtoolsCore[] = [];
|
|
26
|
+
private readonly listeners = new Set<() => void>();
|
|
27
|
+
|
|
28
|
+
subscribe(listener: () => void): () => void {
|
|
29
|
+
this.listeners.add(listener);
|
|
30
|
+
return () => {
|
|
31
|
+
this.listeners.delete(listener);
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
register(core: DevtoolsCore): () => void {
|
|
36
|
+
this.cores.push(core);
|
|
37
|
+
this.emit();
|
|
38
|
+
return () => {
|
|
39
|
+
const i = this.cores.indexOf(core);
|
|
40
|
+
if (i >= 0) {
|
|
41
|
+
this.cores.splice(i, 1);
|
|
42
|
+
this.emit();
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
private emit(): void {
|
|
48
|
+
for (const l of this.listeners) l();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function globalSlot(): Record<typeof GLOBAL_KEY, Hub | undefined> {
|
|
53
|
+
return globalThis as unknown as Record<typeof GLOBAL_KEY, Hub | undefined>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Get (creating on first use) the global devtools hub. A panel calls this to discover attached
|
|
57
|
+
* clients; it is safe to call before any `attachDevtools`. */
|
|
58
|
+
export function getDevtoolsHub(): DevtoolsHub {
|
|
59
|
+
const slot = globalSlot();
|
|
60
|
+
return (slot[GLOBAL_KEY] ??= new Hub());
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The most recently attached core, or `undefined` — the common single-app convenience a panel uses. */
|
|
64
|
+
export function getDevtoolsCore(): DevtoolsCore | undefined {
|
|
65
|
+
const cores = getDevtoolsHub().cores;
|
|
66
|
+
return cores[cores.length - 1];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Attach a devtools pane to a running client (DEBUG-TOOLS-BROWSER-DESIGN §6.2). Call this ONLY in a
|
|
70
|
+
* dev build (e.g. behind `import.meta.env.DEV` with a dynamic `import("@rindle/devtools")`) so the
|
|
71
|
+
* pane, its core, and this registration tree-shake out of production. Returns the {@link DevtoolsCore}
|
|
72
|
+
* (also discoverable via {@link getDevtoolsHub}); call `core.detach()` to unwind. */
|
|
73
|
+
export function attachDevtools(target: DevtoolsTarget, opts?: DevtoolsCoreOptions): DevtoolsCore {
|
|
74
|
+
// Default the safety-net poll on for real apps (a fold's debounced flush / a pending flip on an
|
|
75
|
+
// already-`complete` query move state with no event); tests construct DevtoolsCore directly.
|
|
76
|
+
const core = new DevtoolsCore(target, { pollMs: 500, ...opts });
|
|
77
|
+
core.onDetach = getDevtoolsHub().register(core);
|
|
78
|
+
return core;
|
|
79
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// @rindle/devtools — the framework-agnostic in-browser devtools core (DEBUG-TOOLS-BROWSER-DESIGN.md).
|
|
2
|
+
// Attaches to a running client over the read-only seams and maintains the §4 read-model: a mutation
|
|
3
|
+
// TIMELINE (the fork/rebase loop made visible), a QUERIES inspector, and a DELTA stream. Dev-only;
|
|
4
|
+
// pair it with a panel (`@rindle/react-devtools`) or read `getDevtoolsCore().getState()` directly.
|
|
5
|
+
|
|
6
|
+
export { DevtoolsCore } from "./core.ts";
|
|
7
|
+
export { attachDevtools, getDevtoolsCore, getDevtoolsHub } from "./global.ts";
|
|
8
|
+
export type { DevtoolsHub } from "./global.ts";
|
|
9
|
+
|
|
10
|
+
export { collectTables, summarizeAst } from "./ast.ts";
|
|
11
|
+
|
|
12
|
+
export type {
|
|
13
|
+
DeltaEntry,
|
|
14
|
+
DeltaKind,
|
|
15
|
+
DevtoolsBackend,
|
|
16
|
+
DevtoolsCoreOptions,
|
|
17
|
+
DevtoolsState,
|
|
18
|
+
DevtoolsStore,
|
|
19
|
+
DevtoolsTarget,
|
|
20
|
+
FoldInspect,
|
|
21
|
+
MutationState,
|
|
22
|
+
OptimisticInspect,
|
|
23
|
+
PendingInspect,
|
|
24
|
+
QueryEntry,
|
|
25
|
+
TimelineEntry,
|
|
26
|
+
} from "./types.ts";
|
|
27
|
+
|
|
28
|
+
// Re-export the client types a panel or a custom attach target needs, so it can depend on
|
|
29
|
+
// `@rindle/devtools` alone.
|
|
30
|
+
export type { Ast, ChangeEvent, QueryId, ResultType, StoreDevObserver, StoreInspect } from "@rindle/client";
|