Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 7 additions & 6 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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.
#
Expand All @@ -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.
Expand All @@ -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
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

```
Expand Down
14 changes: 8 additions & 6 deletions src/main.jsx → src/app.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 ?? "";

Expand Down Expand Up @@ -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) => (
<Box>
<TitleBar stats={stats} status={status} frozen={frozen} pinned={pinned} />
<Box height="1fr" overflow="hidden">
Expand All @@ -178,5 +180,5 @@ const Root = (size) => (
</Box>
);

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.
30 changes: 30 additions & 0 deletions src/json.js
Original file line number Diff line number Diff line change
@@ -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));
},
});
}
17 changes: 17 additions & 0 deletions src/main.js
Original file line number Diff line number Diff line change
@@ -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
67 changes: 41 additions & 26 deletions src/probes/sqlite.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? "",
Expand All @@ -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,
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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;
}

Expand All @@ -199,7 +189,7 @@ export const statements = from((state) => {
} else {
cur.set(stmtId, newExec(sql, comm, pid, tid));
}
win.prepares++;
onActivity("prepares");
return;
}

Expand All @@ -216,19 +206,18 @@ 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;
const ns = Number(ev.latency_ns);
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;
Expand All @@ -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 = () => {
Expand All @@ -259,7 +274,7 @@ export const statements = from((state) => {

return () => {
clearInterval(h);
sub.then((s) => s.unsubscribe());
detach();
};
}, []);

Expand Down