Skip to content

Commit 4375cd4

Browse files
ryanrasticlaude
andcommitted
Live queries for SQLite / Durable Objects
Extend .live to sqlite (better-sqlite3, Durable Objects' SqlStorage) with a minimal delta from the pg design: the Bus (reverse index, subscribe + backfill, lifecycle) is dialect-shared; only the event source differs. - Event source: pg keeps the shadow-events table + polling loop. sqlite has NO shadow table and no polling — the runtime is a synchronous single writer, so capture pushes events into the new `Bus.ingest()` in the same tick as the mutation, entirely in memory. Cursors are integer seqs embedded in the snapshot Cursor shape ({seq+1, seq+1, ∅}), so the existing MVCC `visible()` test doubles as the seq comparison and the subscribe-time backfill check precisely covers the register race. - Capture (live/sqlite/capture.ts): opted-in tables (`transformer: SqliteLiveCapture.makeTransformer()`) get json_object image columns appended to RETURNING — insert→after, delete→before, update→after — plus a pre-SELECT for update before-images (sqlite RETURNING can't see OLD). Runs via a new optional `Driver.executeSync` in one synchronous block with the mutation: no awaits between statements, so single-writer atomicity replaces the transaction. - Extractor portability: accept bare `Param` literals as anchors (sqlite binds primitives as `?`, not pg's `CAST($n AS T)`); dialect-aware text casts; decoy aliases so extractor CTEs never shadow their own table (sqlite resolves a CTE name inside its body — "circular reference"). - live/canonical.ts: shared value-rendering contract; collapses integral REALs so `'1.0'`-bound anchors match `'1'` stored INTEGERs (drivers bind every JS number as REAL). A parity test hangs on divergence. - Transport intentionally deferred: `.live()` stays a local AsyncIterable on both dialects (already streams over exoeval). Cap'n Web prototype learnings (by-ref callbacks vs map recorder, dup()/dispose lifecycle, parked-await teardown) are recorded in ISSUES.md #13. Tested end-to-end on better-sqlite3 (zero timers — dispatch is sync) and via a fake SqlStorage through DoSqliteDriver; pg suite unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 8d8998b commit 4375cd4

19 files changed

Lines changed: 870 additions & 159 deletions

src/database.ts

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { ExecuteFn, Driver, QueryResult } from "./drivers/types";
1+
import { type ExecuteFn, type Driver, isSyncDriver, type QueryResult } from "./drivers/types";
22
import type { Fromable, RowType, RowTypeToTsType } from "./builder/query";
33
import { QueryBuilder, hydrateRows } from "./builder/query";
44
import { deserializeRows } from "./util";
@@ -10,9 +10,10 @@ import { InsertBuilder } from "./builder/insert";
1010
import { UpdateBuilder } from "./builder/update";
1111
import { DeleteBuilder } from "./builder/delete";
1212
import { Bus, type Subscription, type BusOptions } from "./live/bus";
13-
import { eventsTableSqlStatements } from "./live/events-ddl";
13+
import { eventsTableSqlStatements } from "./live/pg/events-ddl";
1414
import { runLiveIteration } from "./live/extractor";
1515
import { parseSnapshot } from "./live/snapshot";
16+
import { executeMutationWithCapture, type MutationBuilder, type MutationOp } from "./live/sqlite/capture";
1617
import type { DialectName } from "./builder/sql";
1718

1819
export type TransactionIsolation = "read committed" | "repeatable read" | "serializable";
@@ -131,10 +132,34 @@ export class Connection<C = undefined> {
131132
get dialect() { return this.driver.dialect; }
132133

133134
#exec(query: Sql): Promise<QueryResult> {
135+
const captured = this.#tryCaptureMutation(query);
136+
if (captured) {
137+
return Promise.resolve(captured);
138+
}
134139
const compiled = compile(query, { database: this.database });
135140
return (this.#boundExecute ?? this.driver.execute.bind(this.driver))(compiled);
136141
}
137142

143+
// Route mutation builders through the sqlite live capture path when the
144+
// bus is running. Pool-backed connections only: inside a transaction the
145+
// driver-level sync path would escape the txn's bound execute (and
146+
// sqlite live doesn't use transactions anyway).
147+
#tryCaptureMutation(query: Sql): QueryResult | undefined {
148+
const bus = this.#bus;
149+
const driver = this.driver;
150+
// startLive() guarantees a SyncDriver whenever a sqlite bus is running;
151+
// the guard here is for the type narrowing.
152+
if (!bus || this.database.dialect === "postgres" || this.#boundExecute || !isSyncDriver(driver)) {
153+
return undefined;
154+
}
155+
const op: MutationOp | undefined =
156+
query instanceof InsertBuilder ? "insert"
157+
: query instanceof UpdateBuilder ? "update"
158+
: query instanceof DeleteBuilder ? "delete"
159+
: undefined;
160+
return op && executeMutationWithCapture(op, query as MutationBuilder, this.database, driver, bus);
161+
}
162+
138163
// Overload resolution matches top-to-bottom and stops on the first
139164
// match — list specific builders before the general `Sql` fallback, or
140165
// every QueryBuilder/Insert/... call resolves as `Sql` → QueryResult.
@@ -254,17 +279,31 @@ export class Connection<C = undefined> {
254279
// --- Live queries ---
255280
#bus: Bus | undefined;
256281

282+
// Pg-only schema setup (the `_typegres_live_events` shadow table). On
283+
// sqlite there is nothing to install — capture rides on RETURNING and
284+
// events stay in memory (see Bus.ingest).
257285
async installLiveEvents(): Promise<void> {
286+
if (this.database.dialect !== "postgres") {
287+
return;
288+
}
258289
for (const stmt of eventsTableSqlStatements(this.database)) {
259290
await this.driver.execute(compile(stmt, { database: this.database }));
260291
}
261292
}
262293

294+
// `intervalMs` only applies on pg (poll cadence); sqlite dispatch is
295+
// synchronous with each mutation.
263296
async startLive(opts: BusOptions = {}): Promise<void> {
264297
if (this.#boundExecute) {
265298
throw new Error("startLive() must be called on a pool-backed Connection, not inside a transaction");
266299
}
267300
if (this.#bus) { throw new Error("Live bus already started"); }
301+
if (this.database.dialect !== "postgres" && !isSyncDriver(this.driver)) {
302+
throw new Error(
303+
"sqlite live requires a SyncDriver (SqliteDriver, DoSqliteDriver) — " +
304+
"capture must run pre-image read + mutation + dispatch without awaits between them",
305+
);
306+
}
268307
this.#bus = new Bus(this, opts);
269308
await this.#bus.start();
270309
}
@@ -291,8 +330,13 @@ export class Connection<C = undefined> {
291330
let currentSub: Subscription | undefined;
292331
try {
293332
while (true) {
333+
// sqlite: read the seq cursor BEFORE the iteration's queries, so
334+
// any event ingested during them is "not yet seen" and trips the
335+
// subscribe-time backfill check. pg captures its cursor inside
336+
// the iteration's REPEATABLE READ txn instead.
337+
const preCursor = this.database.dialect === "postgres" ? undefined : bus.sqliteCursor();
294338
const { rows, cursor, predicateSet } = await runLiveIteration(this, query);
295-
currentSub = bus.subscribe(parseSnapshot(cursor), predicateSet);
339+
currentSub = bus.subscribe(cursor !== undefined ? parseSnapshot(cursor) : preCursor!, predicateSet);
296340
yield rows as any;
297341
if (currentSub) {
298342
try {

src/drivers/do.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { CompiledSql } from "../builder/sql";
2-
import type { Driver, ExecuteFn, QueryResult } from "./types";
2+
import type { ExecuteFn, QueryResult, SyncDriver } from "./types";
33
import { normalizeRow, stripMatchedOuterParens } from "./shared-sqlite";
44

55
// Duck-typed Cloudflare SqlStorage — no @cloudflare/workers-types dependency.
@@ -16,15 +16,18 @@ export interface SqlStorageLike {
1616
// callers / the dialect must not emit bigint bindings for this backend;
1717
// - results are native JS values, normalized to strings for deserialize;
1818
// - blobs come back as ArrayBuffer (better-sqlite3 gives Uint8Array/Buffer).
19-
export class DoSqliteDriver implements Driver {
19+
export class DoSqliteDriver implements SyncDriver {
2020
readonly dialect = "sqlite" as const;
2121

2222
constructor(private readonly sql: SqlStorageLike) {}
2323

24-
execute: ExecuteFn = ({ text, values }: CompiledSql): Promise<QueryResult> => {
24+
execute: ExecuteFn = (compiled: CompiledSql): Promise<QueryResult> =>
25+
Promise.resolve(this.executeSync(compiled));
26+
27+
executeSync = ({ text, values }: CompiledSql): QueryResult => {
2528
const query = stripMatchedOuterParens(text);
2629
const rows = this.sql.exec(query, ...values).toArray().map(normalizeRow);
27-
return Promise.resolve({ rows });
30+
return { rows };
2831
};
2932

3033
runInSingleConnection = <T>(cb: (execute: ExecuteFn) => Promise<T>): Promise<T> =>

src/drivers/sqlite.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
import type { CompiledSql } from "../builder/sql";
22
import type { DialectName } from "../builder/sql";
33
import type BetterSqlite3 from "better-sqlite3";
4-
import type { Driver, ExecuteFn, QueryResult } from "./types";
4+
import type { ExecuteFn, QueryResult, SyncDriver } from "./types";
55
import { normalizeRow, stripMatchedOuterParens } from "./shared-sqlite";
66

77
// better-sqlite3 adapter. Synchronous under the hood; wrapped in
88
// Promise.resolve for the async Driver contract. `better-sqlite3` is an
99
// optional peer (see package.json).
10-
export class SqliteDriver implements Driver {
10+
export class SqliteDriver implements SyncDriver {
1111
readonly dialect: DialectName = "sqlite";
1212

1313
static async create(
@@ -22,11 +22,15 @@ export class SqliteDriver implements Driver {
2222

2323
private constructor(private db: BetterSqlite3.Database) {}
2424

25-
async execute({ text, values }: CompiledSql): Promise<QueryResult> {
25+
async execute(compiled: CompiledSql): Promise<QueryResult> {
26+
return this.executeSync(compiled);
27+
}
28+
29+
executeSync = ({ text, values }: CompiledSql): QueryResult => {
2630
// QueryBuilder.bind() wraps statements in `(...)` for subquery splicing;
2731
// SQLite refuses top-level parenthesized statements — unwrap one matched pair.
2832
return this.runOne(stripMatchedOuterParens(text), values);
29-
}
33+
};
3034

3135
private runOne(text: string, values: readonly unknown[]): QueryResult {
3236
const stmt = this.db.prepare(text);

src/drivers/types.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,26 @@ export type QueryResult = { rows: { [key: string]: string | null }[] };
1414
// hand the query to its underlying pool/wasm and normalize the result rows.
1515
export type ExecuteFn = (sql: CompiledSql) => Promise<QueryResult>;
1616

17+
export type ExecuteSyncFn = (sql: CompiledSql) => QueryResult;
18+
1719
export interface Driver {
1820
readonly dialect: DialectName;
1921
execute: ExecuteFn;
22+
// Synchronous execute for drivers whose engine is sync under the hood
23+
// (better-sqlite3, Durable Object SqlStorage). The sqlite live backend
24+
// requires it: mutation capture runs pre-image read + write + dispatch
25+
// in one synchronous block so no other task can interleave between the
26+
// statements — the single-writer substitute for a transaction.
27+
executeSync?: ExecuteSyncFn;
2028
runInSingleConnection<T>(cb: (execute: ExecuteFn) => Promise<T>): Promise<T>;
2129
close(): Promise<void>;
2230
}
31+
32+
// A driver that guarantees the synchronous path. Sqlite drivers implement
33+
// this; code that needs the sync invariant (live capture) takes SyncDriver
34+
// so the requirement lives in the signature, not a runtime check.
35+
export interface SyncDriver extends Driver {
36+
executeSync: ExecuteSyncFn;
37+
}
38+
39+
export const isSyncDriver = (d: Driver): d is SyncDriver => d.executeSync !== undefined;

src/index.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,14 @@ export { Table } from "./table";
1111
export { Relation } from "./relation";
1212
export { sql, Sql } from "./builder/sql";
1313
export { QueryBuilder } from "./builder/query";
14-
export { TypegresLiveEvents } from "./live/events";
14+
export { TypegresLiveEvents } from "./live/pg/events";
15+
export { SqliteLiveCapture } from "./live/sqlite/capture";
1516
export { expose } from "./exoeval/tool";
1617
export type { ToolFunction } from "./exoeval/tool";
1718
export { RpcClient, inMemoryChannel, safeStringify } from "./exoeval/rpc";
1819
export type { RawChannel } from "./exoeval/rpc";
1920
export type { Config } from "./config";
20-
export type { Driver, ExecuteFn, QueryResult } from "./drivers/types";
21+
export type { Driver, SyncDriver, ExecuteFn, ExecuteSyncFn, QueryResult } from "./drivers/types";
2122

2223
import type { Connection } from "./database";
2324
import { Database } from "./database";

src/live/ISSUES.md

Lines changed: 103 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -11,32 +11,30 @@ A query is live-able iff **every table reference has at least one top-level
1111
Otherwise arbitrary: `JOIN`, `LEFT JOIN`, `GROUP BY`, `HAVING`,
1212
subqueries — fine. The extractor only inspects top-level `WHERE`/`ON`.
1313

14-
## Open
15-
16-
1. **Public API gap: live mode isn't actually consumable from the package yet.**
17-
The npm entrypoint exports `Database`, `db.startLive()`, and `db.live()`,
18-
but not the setup pieces users need to make live mode work:
19-
- `TypegresLiveEvents.makeTransformer()` to opt tables into event capture
20-
- `TypegresLiveEvents.createTableSql()` to create `_typegres_live_events`
21-
22-
`package.json` also uses explicit export maps, so consumers can't rely on
23-
a deep import escape hatch. Result: the live subsystem exists, but from a
24-
package user's perspective it's effectively private / unusable.
14+
The Bus (index, subscribe/backfill, lifecycle) is dialect-shared; only
15+
the **event source** differs. pg (`pg/`): shadow events table + polling +
16+
MVCC snapshot cursors. sqlite (`sqlite/`): no shadow table and no polling
17+
— the runtime is a synchronous single writer (better-sqlite3, a Durable
18+
Object's SqlStorage), so RETURNING-image capture pushes events into
19+
`Bus.ingest()` in the same tick as the mutation, entirely in memory;
20+
cursors are integer seqs embedded in the snapshot `Cursor` shape so the
21+
MVCC `visible()` test doubles as the seq comparison. The scope rule above
22+
applies to both dialects.
2523

26-
Fix options: export `TypegresLiveEvents`; expose a higher-level
27-
`enableLive()` helper; or intentionally keep the whole feature internal
28-
until the setup surface is ready.
24+
## Open
2925

30-
2. **`stopLive()` leaves active subscriptions wedged.**
31-
Shutting down the bus stops polling, but today it does not drain existing
32-
subscriptions or settle their `wait` promises. Any async iterator parked in
33-
`await currentSub.wait` can hang forever if `db.stopLive()` is called while
34-
it's waiting. The old Bus instance also keeps stale subscription/index state
35-
alive until GC.
26+
1. **Public API gap, narrowed but open.** `TypegresLiveEvents` and
27+
`SqliteLiveCapture` are exported from the root entrypoint now, so tables
28+
can opt into capture on both backends. Still missing: a friendlier setup
29+
surface — `conn.installLiveEvents()` covers the pg DDL but a higher-level
30+
`enableLive()` (install + startLive + per-table opt-in in one place)
31+
would remove the three-step ceremony.
3632

37-
`Bus.stop()` should explicitly resolve/reject/unsubscribe outstanding subs,
38-
and live iterators should observe shutdown as completion or error rather
39-
than hanging.
33+
2. ~~**`stopLive()` leaves active subscriptions wedged.**~~ Resolved:
34+
`Bus.stop()` cancels outstanding subscriptions (rejects parked `wait`s
35+
with AbortError; the live generator observes shutdown as clean
36+
completion). Covered by the sqlite test "stopLive releases a parked
37+
consumer cleanly".
4038

4139
3. **More `.live()` tests.** Five today (insert/update/delete/join/not-started).
4240
Missing: backfill-from-buffer (subscribe returns `undefined`, caller
@@ -82,21 +80,59 @@ subqueries — fine. The extractor only inspects top-level `WHERE`/`ON`.
8280
perf footnote: an atomic-swap that diffs old vs. new and only mutates
8381
leaves that changed would be cheaper for stable predicate sets.
8482

85-
9. **Text canonicalization landmines** — both extractor and events
86-
transformer canonicalize values via `col::text`; bus matches by string
87-
equality. Symmetric *as long as both sides agree on text form*. Three
88-
pg types drift:
89-
- **`timestamptz`**`::text` honors session `TimeZone`; same instant,
90-
different text in different sessions.
91-
- **`citext`** — case-insensitive at compare time but `::text` preserves
92-
case; equal values can produce non-equal text.
93-
- **`numeric`** — trailing zeros / scale survive `::text`;
94-
`1.0::numeric` and `1::numeric` compare equal but differ as text.
95-
96-
No live test exercises any of these today; a user query against one of
97-
these column types may silently miss matches. Fix range: canonicalizing
98-
casts (`lower()` / `extract(epoch from …)`) up to a binary comparator
99-
in the bus.
83+
9. **Text canonicalization landmines** — the matcher replays SQL `=`
84+
*outside* SQL: it lifts predicate values out of the query and compares
85+
canonical text in JS. That loses every normalization `=` would have
86+
applied (operator resolution, pg implicit casts, sqlite affinity /
87+
numeric cross-class comparison), so the invariant the whole scheme
88+
rests on is: **the canonical rendering must be constant on each SQL
89+
equality class** — two `=`-equal values that render differently are a
90+
silently *missed wakeup* (always a false negative; conflating unequal
91+
values merely causes a harmless spurious rerun).
92+
93+
`::text` / `CAST(x AS text)` satisfies this for the common types
94+
(int, text, uuid, bool, float8) when both sides render through the
95+
same type's output function. Known drift:
96+
97+
**pg** (pre-existing, unfixed):
98+
- **`numeric`** — scale survives `::text`: `1 = 1.0 = 1.00` but
99+
`'1'` / `'1.0'` / `'1.00'` (verified). `trim_scale()` (pg13+)
100+
normalizes. This is the most likely real-world miss.
101+
- **`timestamptz`**`::text` honors session `TimeZone`/`DateStyle`;
102+
event images render in the *writer's* session, extractor values in
103+
the live connection's session — differing GUCs break matching.
104+
Same-pool same-settings holds in practice but is undocumented.
105+
- **`citext`** / non-binary collations — equal at compare time,
106+
case-preserving as text.
107+
108+
**sqlite** (as of the sqlite backend):
109+
- **Fixed**: drivers bind every JS number as REAL while
110+
integer-affinity columns store INTEGER (`'1.0'` vs `'1'`);
111+
`canonical.ts` collapses integral REALs before the text cast.
112+
Root cause: sqlite operators live on root `Any`, which binds bare
113+
`?` params (`sql-value.ts``CAST(? AS any)` would be wrong), so
114+
unlike pg's `TypedParam` nothing re-normalizes the binding class.
115+
A parity test hangs-on-timeout if the render sites ever diverge.
116+
- **Open (contrived)**: TEXT-affinity column vs number param —
117+
SQL applies TEXT affinity to REAL `1.0` → matches stored `'1.0'`,
118+
but the collapsed anchor renders `'1'` → miss. Reachable because
119+
root-`Any.eq` accepts any primitive.
120+
- **Harmless**: no-affinity columns distinguish integer `1` from
121+
text `'1'`; canonical text conflates them → spurious rerun only.
122+
123+
Fix directions (complementary, roughly in order of leverage):
124+
1. **Typed casts in the sqlite operator codegen** — per-type arg slots
125+
so `eq` emits `CAST(? AS INTEGER)` etc., mimicking compare-time
126+
affinity at the binding layer. Fixes the anchor at the source
127+
(including the TEXT-affinity edge) and would let `canonical.ts`'s
128+
CASE collapse be deleted. Correct independent of live.
129+
2. **Per-type canonical-render hook** on the type classes (all three
130+
render sites hold typed values): `Numeric → trim_scale(x)::text`,
131+
`timestamptz → UTC-normalized`, sqlite classes → their affinity
132+
semantics (or JS-side `String()` canonicalization, which collapses
133+
`1.0`/`1` for free). Subsumes `canonical.ts` and fixes pg numeric.
134+
3. Status quo: `canonical.ts`'s dialect-level rendering — works for
135+
the common types, locked by tests, ad-hoc.
100136

101137
10. **Unsupported predicate forms produce a generic "unrooted" error**
102138
instead of a clear "this predicate form isn't supported" message —
@@ -135,7 +171,10 @@ subqueries — fine. The extractor only inspects top-level `WHERE`/`ON`.
135171
correctly qualified.
136172

137173
12. **WAL / logical-replication ingestion as an alternative to the shadow
138-
table** — today every mutation gains a `_typegres_live_events` insert
174+
table** (pg; the sqlite backend already has no shadow table — its
175+
analog of this gap is that RETURNING-image capture also only sees
176+
builder-routed writes, where per-table triggers would catch raw SQL
177+
too) — today every pg mutation gains a `_typegres_live_events` insert
139178
in the same statement. That captures only writes that go through
140179
typegres builders, costs an extra row per mutation, and grows a table
141180
the user has to manage. A WAL-backed mode (logical decoding via a
@@ -144,3 +183,28 @@ subqueries — fine. The extractor only inspects top-level `WHERE`/`ON`.
144183
replication permissions, per-table `REPLICA IDENTITY` config, slot
145184
monitoring (a stalled consumer holds WAL forever). Worth offering as
146185
a swappable backend for clients who prefer it.
186+
187+
13. **Cap'n Web streaming surface (deferred; learnings recorded).**
188+
`.live()` is a local AsyncIterable on both dialects (already
189+
wire-consumable via exoeval's streaming); a capnweb push surface —
190+
`subscribe(conn, cb)` driving the generator with `await cb(rows)`
191+
backpressure — was prototyped and works, deferred to keep the live
192+
core minimal. What the prototype established, so the next attempt
193+
starts from knowledge:
194+
- capnweb's `map` recorder serializes any captured plain function as a
195+
nested record-replay closure (async ones are rejected outright:
196+
"RPC closures cannot be async functions"). Passing a callback **by
197+
reference** requires a plain RPC call — build the refined query
198+
capability via `doRpc`, then call `subscribe` directly on the stub.
199+
- An argument stub is auto-released when the call frame returns
200+
("RpcImportHook was already disposed" on the first push). The callee
201+
must `dup()` the callback on entry and dispose the retained handle
202+
on unsubscribe — which is exactly the pin/unpin-a-Durable-Object
203+
lifecycle, and what the harness leak guard verifies.
204+
- A driver loop that always has a `next()` in flight parks the live
205+
generator at `await sub.wait` — a non-yield suspension `.return()`
206+
cannot interrupt. `Connection.live()` needs an AbortSignal (or
207+
equivalent) that `cancel()`s the in-flight Subscription so
208+
unsubscribe can wake a parked consumer.
209+
- Client-side, capnweb invokes an exported callback with args as
210+
RpcPromises — the client cb must resolve them.

0 commit comments

Comments
 (0)