@loro-dev/sqlite-riverrun
v0.2.0
Published
Single-node SQLite-backed Durable Streams dev server for local CRDT development.
Downloads
158
Readme
@loro-dev/sqlite-riverrun
Single-node SQLite-backed Durable Streams dev server for local CRDT development.
This package powers loro dev. It provides a scoped local runtime that is protocol-compatible with the agreed Durable Streams subset for:
- stream create / append / read / head / delete
- snapshot locate / read / write
- bootstrap
- long-poll live reads
- SSE live reads, including the binary Riverrun extension
- ephemeral channels backed by Loro
EphemeralStoresemantics (presence, cursors, awareness)
It is intentionally not a production simulator.
Scope
This package is designed for:
- local application development
- protocol integration testing
- CRDT debugging
- lightweight embedded usage from other TypeScript tools
It does not implement:
- auth
- quota or metering
- explicit bucket lifecycle APIs
- multi-node replication
- producer idempotency headers
Semantics
- Buckets are implicit namespaces.
PUT /ds/:bucket/:streamcreates the bucket namespace as needed. - Streams have a fixed
Content-Typechosen at create time. POSTmay omitContent-Type; when present it must match the stream type.- Snapshot creation advances the earliest readable offset and forces older reads to return
410 Gone. /bootstrapreturnsmultipart/mixedwith the snapshot part first, then update parts.
Ephemeral channels
The server implements section 4 of specs/ds-extensions.md. Each ?ephemeral=<channel> (or ?awareness=<channel> alias) on a stream URL maps to an in-memory Loro EphemeralStore so that writes carry incremental updates and new subscribers bootstrap from the materialized state.
POST {stream_url}?ephemeral=<channel>applies the body to the channel store viaEphemeralStore.apply(bytes). Returns204on success,400for malformed bodies,404when the underlying bucket or stream does not exist.GET {stream_url}?ephemeral=<channel>&live=sseopens a single bootstrap-then-live SSE stream:- The subscriber is registered first.
- The server sends exactly one
event: bootstrapcarrying the base64-encodedencodeAll()of the channel store (empty bytes when the channel has never been written). - Subsequent live writes are forwarded verbatim as
event: dataframes in arrival order.
- Offsets, cursors,
live=long-poll, andcontrolevents are rejected with400 Bad Requestper spec. - Channel state is in-memory only — restarting the dev server drops every ephemeral channel; durable streams in SQLite are unaffected.
- Passing both
?ephemeral=and?awareness=in the same request returns400 Bad Request. - Deleting a stream or its enclosing bucket terminates every open ephemeral subscription on it. A recreated stream with the same id starts from an empty channel and existing subscribers do not survive the deletion.
The same primitive is exported as a standalone class so embedders can wire it into their own routing:
import { EphemeralChannelStore } from "@loro-dev/sqlite-riverrun";
const channels = new EphemeralChannelStore();
const key = `${bucket}/${stream}?ephemeral=${name}`;
channels.apply(key, incomingUpdateBytes); // throws on malformed bytes
const unsubscribe = channels.subscribe(key, (event) => {
if (event.kind === "data") {
// forward `event.update` to live SSE subscribers as-is
} else {
// event.kind === "terminate" — the channel was hard-evicted
// (e.g. the owning stream was deleted). Close the SSE response.
}
});
const bootstrap = channels.bootstrap(key); // encodeAll() of the merged state
// Soft-clear (subscribers stay attached, future writes resume normally):
channels.deleteByPrefix(`${bucket}/${stream}?ephemeral=`);
// Hard-evict (subscribers receive `terminate` then detach):
channels.terminateByPrefix(`${bucket}/${stream}?ephemeral=`);Clients can use the existing EphemeralStoreAdaptor from @loro-dev/streams-crdt against the dev server with no other changes.
Minimal usage
import { startDevServer } from "@loro-dev/sqlite-riverrun";
const server = await startDevServer({
host: "127.0.0.1",
port: 8787,
dbPath: "./data/dev.sqlite",
});
console.log(server.baseUrl);
await server.close();Limits and defaults
- SQLite journal mode is forced to
WAL. - The default protocol is
h2cfor cleartext HTTP/2 multiplexing. Setprotocol: "http1"for legacy clients. - Live watch fanout is process-local only.
- Long-poll cursors are opaque and not guaranteed to survive process restarts.
- Stream IDs are validated against the current
ds-crdtconstraints.
