build · oxidb v0.25.21 0 entries on disk
The /dev/oxide

A build log on shipping OxiDB — notes, post-mortems, and the occasional flame war about JSON parsing, pressed straight onto an embedded engine running inside this process.

posts/0014.md · 2026-04-29

change streams — onSnapshot without polling

hero image for: change streams — onSnapshot without polling
asset · bucket: blog-images · key: 3a61ef4c755e00dbb4ca0514.jpg

Every successful write — insert, update, delete, `commit_tx` — emits a `ChangeEvent` to a process-wide broker. Subscribers receive a stream of events filtered by collection, document id, or a small predicate. No polling, no `LISTEN/NOTIFY` ceremony, no Postgres trigger.

The broker is a single `tokio::sync::broadcast` channel wrapped in a per-collection filter map. New subscriber → `subscribe(filter)` returns a `WatchHandle` that holds an mpsc receiver. Drop the handle, the slot frees. Internal capacity is a ring; if a subscriber falls behind, it gets a `Lagged(n)` event and resubscribes from the new head. No silent missed events.

**Resume tokens.** Each event carries an opaque `resume_id`. Pass it back to `watch(filter, resume_from=...)` to pick up where you left off after a process restart. The token is a monotonically increasing 64-bit counter stamped at WAL append time, so it survives crash recovery.

**Wire surface.** Three transports, same event shape:

* WebSocket — JS SDK consumes this. Subscribe-by-collection, multiplex on one socket.

* Server protocol `watch` op — Python / Go / .NET clients.

* In-process — Swift embedded mode (`addMutationObserver`) and the Python embedded wrapper observe directly via the same broker, no socket required.

Event types: `insert`, `insert_many`, `update`, `delete`, `commit_tx`. Each carries the collection, the doc id, the timestamp, the before/after fragment (for updates), and a metadata bag where the SDK can pin per-watch state.