Code Walkthrough · branch feat/pluggable-db-backends

Pluggable DB backends, line by line

The actual code that fixes the off-the-shelf onboarding bug and makes sqlitepglitepostgres first-class on every path. We'll go: the bug → the resolver → the one shared opener → backend-aware reads → migration safety → the test that proves it.

99
smithers unit tests pass
26
resolver matrix cases
2
real round-trip e2e (sqlite + pglite)
7
review bugs found & fixed
1 · The bug

Write and read disagreed about the backend

  • Run wrote a SQLite store (seeded workflows use the sync, sqlite-only createSmithers).
  • Read defaulted to pglite and the read path physically only opened bun:sqlite.
  • So a fresh init → run → ps threw SMITHERS_MIGRATION_REQUIRED, and a pglite run was invisible (ps → []).

The two smells, in one file each:

apps/cli/src/find-db.js — read path, before

// every read command opened bun:sqlite, no matter what
export async function openSmithersDb(dbPath) {
  const { Database } = await import("bun:sqlite");
  const sqlite = new Database(dbPath); // ← always sqlite
  const db = drizzle(sqlite);
  ...
}

resolveSmithersBackendChoice.js — default, before

const backend = explicitBackend ?? envBackend ?? configBackend ?? "pglite";
// default pglite, but reads can't open pglite → migration tripwire fires
2 · The shape of the fix

One resolved backend, opened once, used by every path

run / up ps · inspect output · chat gateway migrate openSmithersStore() resolve once → open that backend SQLitedefault PGliteexplicit Postgrescloud
  • One function, openSmithersStore(), in packages/smithers.
  • It calls the resolver once, then opens exactly that backend and returns a SmithersDb adapter + a cleanup.
  • Reads, runs, gateway, migrate all go through it — the write/read split is gone.
  • SQLite is the local default; pglite/postgres are explicit. No more disagreement.
3 · The resolver — precedence

Default flips to SQLite; explicit choices still win

resolveSmithersBackendChoice.js :330

const backend = explicitBackend ?? envBackend ?? configBackend
              ?? markerBackend ?? "sqlite";
const source  = explicitBackend ? "options"
              : envBackend     ? "env"
              : configBackend  ? "config"
              : markerBackend  ? "marker"
              :                  "default";

Precedence: a flag (--backend), then SMITHERS_BACKEND, then smithers.config.ts, then a workspace .smithers/backend.json marker, then the new default — sqlite. The lightest local store: no socket, no pg client, no port, no migration on a clean install.

  • Why sqlite, not pglite? Electric (the only thing that needs Postgres) can't source from pglite — so pglite buys nothing locally and costs a socket. Research-confirmed.
  • Seeded workflows already write sqlite via createSmithers → now read & write agree with zero churn.
  • --backend pglite|postgres / env / config still select those, fully.
3 · The resolver — don't touch Postgres you weren't asked to

A review fix: ambient DATABASE_URL must not phone home

resolveSmithersBackendChoice.js :336

const shouldInspectPostgres =
  backend === "postgres" ||
  Boolean(opts.connection || explicitPostgresConnectionString);

const postgresStore = shouldInspectPostgres
  ? await inspectPostgresStore({
      connectionString: explicitPostgresConnectionString
        ?? env.SMITHERS_POSTGRES_URL ?? env.DATABASE_URL, ... })
  : emptyProbe;

The bug the reviewer caught: the first cut probed Postgres whenever an ambient DATABASE_URL was set — a near-universal dev env var. That meant every smithers ps on a default-sqlite repo would open a pg connection, and a firewalled host could hang it. Now we only probe Postgres when the resolved backend is postgres, or you passed an explicit connection.

  • Probes are cheap + local for sqlite/pglite (file + .smithers/pg/PG_VERSION).
  • Postgres is the only network probe — so it's strictly opt-in.
  • Caught by the adversarial review, now has a regression test.
3 · The resolver — never silently hide runs

The store with run history is authoritative

resolveSmithersBackendChoice.js :345 + :362 + :373

// 1) collect every store that actually has runs
const populated = [
  ...(sqliteStore.runCount  > 0 ? [{ backend:"sqlite", ... }] : []),
  ...(pgliteStore.runCount  > 0 ? [{ backend:"pglite", ... }] : []),
  ...(postgresStore.runCount> 0 ? [{ backend:"postgres",...}] : []),
];
// 2) legacy sqlite + non-sqlite default, no marker → migrate
if ((backend==="pglite"||backend==="postgres") && sqliteStore.runCount>0 && !marker)
  throw migrationRequiredError(...);
// 3) resolved backend differs from where the runs are → fail loud
const authoritative = populated[0];
if (authoritative && authoritative.backend !== backend)
  throw divergenceError({ sourceBackend: authoritative.backend, ... });
  • Two stores both populated → SMITHERS_BACKEND_CONFLICT, never a silent pick.
  • Symmetric: it's not "sqlite is legacy" — it's "wherever the runs are wins."
  • Clean install (no runs anywhere) → sqlite default, no prompt.
  • Resolution is pure — a read never writes a marker.
4 · The shared opener

Resolve once, open exactly that store

packages/smithers/src/openSmithersStore.js :90

export async function openSmithersStore(opts = {}) {
  const mode = opts.mode ?? "write";
  return retryNotFound(async () => {
    const choice = await resolveSmithersBackendChoice(opts);
    if (mode === "read") assertReadStoreExists(choice, opts);
    if (choice.backend === "sqlite") {           // sync, zero-socket
      const opened = await openSqliteStore(choice.dbPath);
      return { choice, adapter: opened.adapter, db: opened.db, ... };
    }
    const api = await openSmithersBackend({}, opts); // pglite/postgres
    if (mode === "read") await assertRunsTableExists(api.db);
    return { choice, adapter: new SmithersDb(api.db), db: api.db,
             cleanup: async () => { await api.close?.(); } };
  }, opts.wait);
}
  • sqlite → fast synchronous open (bun:sqlite).
  • pglite/postgres → the async factory, with a real cleanup that close()s the socket/connection (no leaks).
  • mode:"read" asserts the store actually exists + has the run table, so reads fail clearly instead of silently empty.
  • retryNotFound lets a reader wait for a run that's just starting.
5 · Backend-aware reads

The read path now reads what was written

apps/cli/src/find-db.js — findAndOpenDb, after

export async function findAndOpenDb(from, opts) {
  const opened = await openSmithersStore({
    cwd: from ?? process.cwd(),
    mode: "read",
    wait: opts,
  });
  return { adapter: opened.adapter, dbPath: opened.dbPath, cleanup: opened.cleanup };
}

Every read command — ps, inspect, output, chat, the scheduler, the TUI — flows through this. Point it at a pglite or postgres workspace and it opens that store. The old "always bun:sqlite" line is gone, and so is the silent-empty failure mode.

  • The migration gate stays — but only as a genuine "you have old data elsewhere, migrate" signal, never on a clean install.
  • --backend sqlite / a marker / migrated.json all suppress it correctly (existing tests stay green).
6 · Migration safety — the bug the green tests missed

Reverse migration was silently corrupting the store

Before — pglite/postgres → sqlite rebuilt tables column-only

// CREATE TABLE from information_schema columns
// → no PRIMARY KEY, no indexes; row-count test still passed ✅😱

migrateSmithersStore.js :778 — after

sqlite = new Database(dbPath);
ensureSmithersTables(drizzle(sqlite)); // canonical schema FIRST (PK + indexes)
await prepareSqliteTarget(sqlite, pgConn, tables); // extras IF NOT EXISTS
for (const table of tables)
  tableResults.push(await copyPgTableToSqlite(pgConn, sqlite, table, ...));
// then verify row counts match, per table, or throw
  • The old reverse path produced a SQLite store with no primary key on _smithers_runs — duplicate run rows were accepted, and the engine's upserts would corrupt state.
  • It's the spec's named launch-gate direction (pglite→sqlite), and the test only checked row counts.
  • Fix: build the canonical schema first, then copy rows in.
  • New regression test asserts the PK exists, indexes exist, and a duplicate run_id is rejected.
7 · The proof

A real round-trip e2e — the test that should always have existed

apps/cli/tests/sqlite-default-roundtrip.e2e.test.js

test("default sqlite init/run/read round-trip needs no migration prompt", () => {
  const repo = createTempRepo();
  writeTestWorkflow(repo, ".smithers/workflows/default-roundtrip.tsx");

  const run = runSmithers(["workflow","run","default-roundtrip", ...]);
  expect(run.exitCode).toBe(0);
  expect(run.stderr).not.toContain("SMITHERS_MIGRATION_REQUIRED");

  const ps = runSmithers(["ps"], ...);
  expect(JSON.stringify(ps.json)).toContain("sqlite-default-roundtrip");

  const inspect = runSmithers(["inspect","sqlite-default-roundtrip"], ...);
  expect(inspect.json.run.id).toBe("sqlite-default-roundtrip");

  const output = runSmithers(["output","sqlite-default-roundtrip","write-result"]);
  expect(output.stdout).toContain("fixture workflow ran");
});
  • Spawns the real CLI (no mocks), a compute-only workflow (no agent → CI-safe).
  • Asserts the run is actually visible through ps / inspect / output.
  • A sibling pglite-roundtrip.e2e.test.js does the same on pglite and asserts smithers.db is absent + .smithers/pg/PG_VERSION present.
  • This is the coverage gap that let the original bug ship.
7.5 · How the UI stays live

A SQLite running in the browser, synced from the backend

UI · TanStack DB browser SQLite (OPFS / wa-sqlite) gateway-client · SyncTransport ONE client protocol (WS / RPC) · writes via actions/RPC LOCAL · gateway WS backing reads through SmithersDb → SQLite or PGlite · NO Electric CLOUD · Electric backing gateway tails Electric shapes ← real Postgres · same frames out The browser never chooses gateway-vs-Electric, and never talks to Electric directly.
  • The UI's live data lives in a browser SQLite exposed as TanStack DB collections.
  • It's fed by one client protocol — the gateway's SyncTransport (WS/RPC). The UI code is identical local vs cloud.
  • What differs is a server-side backing: local reads the engine store directly; cloud tails Electric and re-emits the same frames.
  • Writes always go through gateway actions/RPC — Electric is read-only.
7.6 · The constraint that shaped it

Electric needs Postgres — so it lives server-side

  • Electric's source must be a real Postgres (logical replication). Primary-source confirmed.
  • PGlite cannot be an Electric source today (the enabling work is an unmerged PR) — so "Electric everywhere" is impossible.
  • But the gateway WS path reads through SmithersDb, so it syncs the browser DB from any backend — sqlite, pglite, or postgres.
  • So Electric becomes a server-side optimization for cloud Postgres, not a client choice. One UI path, every scenario.

Compatibility matrix — what feeds the browser SQLite

deployment   engine backend   sync backing      Electric?
local        SQLite           gateway WS        no
local        PGlite           gateway WS        no
local        Postgres         gateway WS        no
cloud        Postgres         Electric (svr)    yes
cloud        PGlite           gateway WS        no  (no Electric src)
any          —  browser never connects to Electric directly

Status: the storage layer (this changeset) is done. This sync layer is Track B — the client already has the SyncTransport seam and both gateway + Electric collection sources; the remaining work is to collapse the client to one path, move Electric behind the gateway, and test every cell of this matrix end-to-end (local + cloud). That work + its test plan is being designed next.

8 · Coverage & what's next

Where it stands

resolver matrix: source × backend × store-state × markers × conflict (26 cases)
boundary cases: corrupt/locked/stray/multiple db, uninitialized .smithers/pg
migration: every direction + structural validity + mid-copy failure surfaces real error
real init→run→read e2e on sqlite & pglite
  • Reviewed adversarially by opus + an independent codex pass — they caught 2 HIGH + 4 MEDIUM bugs the green tests missed; all fixed + regression-tested.
  • Next: Postgres CI via docker-compose (real PG round-trip + migration), full-gate green incl. unrelated pre-existing failures, then a codex deep review for release sign-off.
  • Deferred (Track B): the gateway SyncBacking refactor — one client sync path, Electric quarantined server-side.
1 / 11
PLUGGABLE DB · CODE WALKTHROUGH · ← → / space