diff --git a/Makefile b/Makefile index 9c96809..a6bbcc6 100644 --- a/Makefile +++ b/Makefile @@ -28,9 +28,10 @@ all: bpf bundle # Bundle the entry with the vendored esbuild. esbuild honors tsconfig `paths` # (so `@/` resolves at bundle time), while `yeet:*` builtins and `*.bpf.o` -# objects stay external. The bundle is written to src/index.jsx, which the -# entry ladder prefers over src/main.jsx — so once built, that is what runs. -# The .jsx extension keeps the bundle eligible for component auto-mount. +# objects stay external. The bundle is written to src/index.js, which the +# entry ladder prefers over src/main.js — so once built, that is what runs. +# The entry is plain .js (it mounts explicitly) so `yeet run -T` can force +# the headless JSON mode; see src/main.js. # Compiled BPF objects in bin/ are loaded by path at runtime, never imported, # so they are not bundled. # @@ -41,10 +42,10 @@ all: bpf bundle ESBUILD_FLAGS := --bundle --format=esm --platform=neutral \ --main-fields=module,main --conditions=import,module \ --define:import.meta.main=false \ - --outfile=src/index.jsx --jsx=automatic --jsx-import-source=yeet:tui + --outfile=src/index.js --jsx=automatic --jsx-import-source=yeet:tui bundle: | toolchain - $(ESBUILD) src/main.jsx $(ESBUILD_FLAGS) '--external:yeet:*' '--external:*.bpf.o' + $(ESBUILD) src/main.js $(ESBUILD_FLAGS) '--external:yeet:*' '--external:*.bpf.o' # Post-generation finalize: initialize a git repository with the vendored git # (fetched via `vendored-git`). Idempotent — skipped if this is already a repo. @@ -62,6 +63,6 @@ postgen: | vendored-git fi clean: clean-bpf - rm -rf node_modules dist src/index.jsx + rm -rf node_modules dist src/index.js src/index.jsx .PHONY: all bundle clean postgen diff --git a/README.md b/README.md index 1b3d032..2da86c2 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,23 @@ The feed follows the newest statement by default; scroll or pause and it holds s | `p` | pause / resume the feed | | `q` / `Esc` | quit | +## JSON output (headless) + +Pipe the feed into anything: when no terminal is allocated (output piped, or +forced with `yeet run -T`), sqlitefeed emits **one JSON object per completed +execution** on stdout instead of rendering the dashboard — the same +correlated records the TUI shows (SQL + bound values + rows + latency + rc), +machine-readable. Status lines go to stderr. + +```sh +yeet run -T . | jq 'select(.rc != 101)' # only the non-DONE outcomes +yeet run -T . | your-checker --stdin # e.g. semantic linting of live SQL +``` + +```json +{"process":"python3","pid":34412,"tid":34412,"sql":"INSERT OR IGNORE INTO users (username, email, age, score) VALUES (?, ?, ?, ?)","params":[{"idx":1,"type":"text","value":"alice5866"},{"idx":2,"type":"text","value":"alice5866@example.com"},{"idx":3,"type":"int","value":"27"},{"idx":4,"type":"real","value":"?real"}],"rows":0,"steps":1,"rc":101,"total_ns":3100000,"max_ns":3100000} +``` + ## What you're looking at ``` diff --git a/src/main.jsx b/src/app.jsx similarity index 93% rename from src/main.jsx rename to src/app.jsx index a950a55..9045a07 100644 --- a/src/main.jsx +++ b/src/app.jsx @@ -13,7 +13,7 @@ * imported through the `@/` source alias and composed here. This file also owns * the view state (scroll offset + fuzzy filter) and all keyboard input. */ -import { Box, computed, mount, signal } from "yeet:tui"; +import { Box, computed, signal } from "yeet:tui"; import { statements, stats, status } from "@/probes/sqlite.js"; import { fuzzyMatch, haystack } from "@/lib/fuzzy.js"; import { rowHeight, rcIsError } from "@/lib/format.js"; @@ -105,7 +105,9 @@ const togglePause = () => { }; // ── input ──────────────────────────────────────────────────────────────────── -tty.on("keydown", (e) => { +// Registered only when a terminal is allocated: this module is also imported +// by the headless JSON path (src/main.js), where the `tty` global is absent. +if (typeof tty !== "undefined") tty.on("keydown", (e) => { const code = e.code; const key = e.key ?? ""; @@ -148,11 +150,11 @@ tty.on("keydown", (e) => { if (k === "g") return toNewest(); }); -tty.on("wheel", (e) => move(e.deltaY > 0 ? 3 : -3)); +if (typeof tty !== "undefined") tty.on("wheel", (e) => move(e.deltaY > 0 ? 3 : -3)); // `size` is the terminal's reactive size signal; the body reads it to reflow // (and to budget how many statement rows fit above the footer). -const Root = (size) => ( +export const Root = (size) => ( @@ -178,5 +180,5 @@ const Root = (size) => ( ); -mount(Root); -await new Promise(() => {}); // keep the script alive; the TUI owns the screen +// Mounted by src/main.js when a tty is allocated; the headless path +// (no tty / `yeet run -T`) streams JSON instead — see src/json.js. diff --git a/src/json.js b/src/json.js new file mode 100644 index 0000000..9e8a783 --- /dev/null +++ b/src/json.js @@ -0,0 +1,30 @@ +/* Headless mode: every completed, correlated execution as one JSON line on + * stdout — the same records the dashboard renders, machine-readable. + * + * yeet run -T . | jq 'select(.rc != 101)' + * + * Status and attach errors go to stderr so stdout stays pure JSON lines. + */ +import { attachStatements } from "./probes/sqlite.js"; + +export function startJson() { + attachStatements({ + onStatus: (t) => console.error(`[sqlitefeed] ${t}`), + onStatement: (s) => { + const rec = { + process: s.comm, + pid: s.pid, + tid: s.tid, + sql: s.sql, + params: s.params.map((p) => ({ idx: p.idx, type: p.type, value: p.text })), + rows: s.rows, + steps: s.steps, + rc: s.lastRc, + total_ns: Number(s.totalNs), + max_ns: Number(s.maxNs), + }; + if (s.errmsg) rec.error = s.errmsg; + console.log(JSON.stringify(rec)); + }, + }); +} diff --git a/src/main.js b/src/main.js new file mode 100644 index 0000000..00eb97f --- /dev/null +++ b/src/main.js @@ -0,0 +1,17 @@ +/* sqlitefeed entry. + * + * yeetd installs the `tty` global only when a terminal is allocated; when the + * output is piped (or forced headless with `yeet run -T`) it is absent. Branch + * on that: interactive gets the dashboard, headless gets one JSON object per + * completed statement on stdout — same probes, same correlation, two sinks. + */ +import { mount } from "yeet:tui"; +import { Root } from "./app.jsx"; +import { startJson } from "./json.js"; + +if (typeof tty === "undefined") { + startJson(); +} else { + mount(Root); +} +await new Promise(() => {}); // keep the script alive diff --git a/src/probes/sqlite.js b/src/probes/sqlite.js index bd258a7..dbbb42a 100644 --- a/src/probes/sqlite.js +++ b/src/probes/sqlite.js @@ -94,19 +94,13 @@ export const stats = signal({ tracked: 0, prepareRate: 0, stepRate: 0, rowRate: // crash-handling boundary 1). export const status = signal("starting…"); -// The reactive model is an append-only LOG of completed executions, not a -// mutable per-statement aggregate. A statement is one row per EXECUTION: it's -// assembled in-flight (prepare → bind* → step*), then FROZEN into the log the -// moment it finishes and never touched again — so a row already on screen never -// changes or jumps. New executions unshift at the top. -export const statements = from((state) => { - const log = []; // completed executions, newest first — immutable once pushed - let logId = 0; // monotonic row id - let total = 0; // cumulative executions seen (title-bar counter) +// Attach the probes and stream every completed execution to `onStatement`. +// Shared by the TUI signal below and the headless JSON mode (src/json.js). +// Callbacks: onStatement(row), onStatus(text), onActivity("prepares"|"steps"|"rows"). +// Returns a detach function. +export function attachStatements({ onStatement, onStatus = () => {}, onActivity = () => {} }) { const cur = new Map(); // stmtId → the execution currently being assembled const sqlByStmt = new Map(); // stmtId → last known SQL (survives cached reuse) - let dirty = false; // did the log change since the last publish? - const win = { prepares: 0, steps: 0, rows: 0 }; // reset each window const newExec = (sql, comm, pid, tid) => ({ sql: sql ?? "", @@ -123,14 +117,13 @@ export const statements = from((state) => { maxNs: 0, }); - // Freeze an in-flight execution into an immutable log row. + // Freeze an in-flight execution into an immutable row. const finalize = (e) => { // If it stepped but we never saw a terminal code (it was superseded by a // reset+rerun of a cached statement), it did complete — infer DONE rather // than showing the initial OK. const rc = e.lastRc === 0 && e.steps > 0 ? SQLITE_DONE : e.lastRc; - log.unshift({ - id: ++logId, + onStatement({ isExec: e.isExec, sql: e.sql, comm: e.comm, @@ -145,9 +138,6 @@ export const statements = from((state) => { totalNs: e.totalNs, avgNs: e.steps ? e.totalNs / e.steps : 0, }); - if (log.length > CAP) log.length = CAP; // bound scrollback - total++; - dirty = true; }; // Begin a new execution for a stmt, flushing any lingering one first (e.g. a @@ -164,7 +154,7 @@ export const statements = from((state) => { const ctl = load(); const sub = ctl .then((c) => { - status.set("tracing"); + onStatus("tracing"); return new RingBuf(c, "sql_events").subscribe((w) => { const ev = unwrap(w); const stmtId = `${ev.stmt}`; // BigInt → string key @@ -180,7 +170,7 @@ export const statements = from((state) => { e.maxNs = Number(ev.latency_ns); e.errmsg = cstr(ev.errmsg); finalize(e); - win.prepares++; + onActivity("prepares"); return; } @@ -199,7 +189,7 @@ export const statements = from((state) => { } else { cur.set(stmtId, newExec(sql, comm, pid, tid)); } - win.prepares++; + onActivity("prepares"); return; } @@ -216,8 +206,7 @@ export const statements = from((state) => { } if (ev.kind === EV.STEP) { - let e = cur.get(stmtId); - if (!e) e = startExec(stmtId, comm, pid, tid); + const e = cur.get(stmtId) ?? startExec(stmtId, comm, pid, tid); e.comm = comm; e.pid = pid; e.tid = tid; @@ -225,10 +214,10 @@ export const statements = from((state) => { e.steps++; e.totalNs += ns; if (ns > e.maxNs) e.maxNs = ns; - win.steps++; + onActivity("steps"); if (ev.rc === SQLITE_ROW) { e.rows++; - win.rows++; + onActivity("rows"); } else { // DONE or error → the execution is complete; freeze it. e.lastRc = ev.rc; @@ -239,7 +228,33 @@ export const statements = from((state) => { } }); }) - .catch((e) => status.set(`probe failed: ${e?.message ?? e}`)); + .catch((e) => onStatus(`probe failed: ${e?.message ?? e}`)); + + return () => sub.then((s) => s?.unsubscribe?.()); +} + +// The reactive model is an append-only LOG of completed executions, not a +// mutable per-statement aggregate. A statement is one row per EXECUTION: it's +// assembled in-flight (prepare → bind* → step*), then FROZEN into the log the +// moment it finishes and never touched again — so a row already on screen never +// changes or jumps. New executions unshift at the top. +export const statements = from((state) => { + const log = []; // completed executions, newest first — immutable once pushed + let logId = 0; // monotonic row id + let total = 0; // cumulative executions seen (title-bar counter) + let dirty = false; // did the log change since the last publish? + const win = { prepares: 0, steps: 0, rows: 0 }; // reset each window + + const detach = attachStatements({ + onStatus: (t) => status.set(t), + onActivity: (k) => win[k]++, + onStatement: (row) => { + log.unshift({ id: ++logId, ...row }); + if (log.length > CAP) log.length = CAP; // bound scrollback + total++; + dirty = true; + }, + }); const secs = WINDOW_MS / 1000; const publish = () => { @@ -259,7 +274,7 @@ export const statements = from((state) => { return () => { clearInterval(h); - sub.then((s) => s.unsubscribe()); + detach(); }; }, []);