Remote / foldkit-remote-drizzle
Cloudflare D1
The same reads run over D1, through drizzle-orm/d1, with no adapter
changes: collection pages (a correlated limit per parent), row-value
membership, LIKE … ESCAPE, COLLATE "C" search, keyset queries with
NULLS FIRST/NULLS LAST, and returning are all statements D1 accepts.
test/d1.test.ts proves the core paths against a miniflare binding, which
rejects what production D1 rejects; databaseLayer(drizzle(env.DB)) is the
only wiring.
Declared writes run there too: a revision, a precondition, an increment and an
invariant are one statement, as everywhere. A ledger
works with no transaction, since D1 offers none (its Drizzle database has a
transaction, whose BEGIN D1 refuses):
read the record of (scope, operation, row)
found, same request -> answered from it; nothing is written
found, other request -> refused ("was recorded for another request")
none -> one batch: prune expired records, write the row,
record it where the write changed a rowThe batch cannot read between its statements, so the record is decided in SQL:
insert … select … where changes() > 0, holding the row as the write left it
(json_object over the columns it answers, read back through those columns, so
a boolean is a boolean again). A write that misses records nothing. Two copies
sent at once both find no record; D1 runs their batches one after the other,
and the second meets the first's record, either as the ledger's primary key
(its whole batch rolls back, row write included) or as a write that misses (a
create of the id the first made). Either way it reads the record again and is
answered from it. Retention, pruning and the request digest are as on SQLite.
A column the answer holds may not be a blob: json_object refuses one, and
the write fails rather than landing unrecorded.
Live data on Workers
One Worker request cannot wake another's stream, so a LiveHub fed by a
write reaches nobody there. pollLive is a LiveSource that polls the
database from inside the subscriber's own request instead, re-reading each
subscribed row through the Source as a read answers the subscriber
(RemoteServer.readAs: the Source's authorize and filter applied) and
sending a patch for what moved. A member the subscriber may not read is never
sent, and a row it can no longer read is sent as deleted. A list the page
reads live (Data.query(…, { live: true })) is diffed too, so a row written
elsewhere joins or leaves it: the handler admits the list and runs its
registered query as the subscriber, with the page's window, and pollLive
compares each listed row by the fields the read selects (a paged relation is
not polled). Over a guarded Entity, poll through the guarded
Source the server mounts: pollLive names it as the Source it reads, and
GuardServer.validate accepts it there and refuses it over any other.
import { pollLive, rowChangeLog, rowLog } from 'foldkit-remote-drizzle'
const TodoLive = pollLive(TodoSource, { changes: rowLog(Db.Todo) })
// A migration holds these statements, one per line.
const statements = rowChangeLog(Db.Todo)
// The page: the list, read live while a Surface shows it. It needs a `first`.
Data.query(AllTodos, {}, { select: TodoRow, first: 50, live: true })A stream reads each list silently when it opens, then sends
ConnectionInvalidate once, so a page read before the stream was open (or
while it was down) asks again.
Without changes, every tick (every, 250 milliseconds by default) reads
every subscribed row, in one read of every field any of them asks, and each
live list's window. rowChangeLog(Db.Todo) is the SQLite statements for a
table of the rows each write touched and the triggers that fill it, so every
path that writes the table is logged. With changes: rowLog(Db.Todo) a tick
reads only the rows logged since the last one, which on a quiet tick is none.
A log that cannot be read, or that was trimmed past the last entry read
(keep, 1000 by default), falls back to reading everything. A list whose read
fails is asked again from the same log entry on the next tick, so its rows
still arrive; a subscribed row's failed read ends the stream, and the client's
reconnect reads again. A live list's window is run again on every tick that
may have moved it (every tick without a log; with one, a tick the log names a
row of the Entity), so a row joins by the query's own filter and window, not by
being readable, and the rows read are only those that join, were logged, or
leave. A row that leaves the window while the subscriber can still read it is
removed from the list (ConnectionRemove) and keeps its row, patched with what
changed; one gone, or no longer readable, is deleted too. A write to another
table that moves a row in or out of a list (a path, an exists) is seen on the
next tick the log names a row of this one, or on a full re-read. rowLog
reads at most batch entries a tick (500 by default); the rest wait for the
next. rowChangeLog refuses a Postgres table: a server there can push.
examples/cloudflare runs this on D1, and its test/migrations.test.ts keeps
its migration equal to rowChangeLog.
License
MIT. The keyset-cursor and projection logic adapts
fate's Drizzle integration (MIT, Copyright
(c) 2025 Nakazawa Tech); see THIRD_PARTY_NOTICES.md.
See also
- Server-derived state — why Remote requirements exist and who owns server-derived state.
foldkit-remote— client requirements, normalized Model, subscriptions, mutations, and live data.foldkit-remote-server— Source authorization, normalization, handlers, and live infrastructure.examples/kitchen-sink— Remote + Drizzle + RemoteServer in one executable trace.