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 row

The 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.

ts
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