@@ -11,32 +11,30 @@ A query is live-able iff **every table reference has at least one top-level
1111Otherwise arbitrary: ` JOIN ` , ` LEFT JOIN ` , ` GROUP BY ` , ` HAVING ` ,
1212subqueries — 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
41393 . ** 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
10113710 . ** 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
13717312 . ** 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