Runtime
Offline-first sync
Keep a local copy of a collection that works offline — pull an incremental changefeed (with delete tombstones), stay live over SSE, and queue writes while disconnected, flushing them on reconnect with last-write-wins conflict resolution.
backlex can keep a local-first copy of a collection: reads and writes hit a
local store immediately, a background loop pulls server changes, and writes made
while offline flush automatically when the connection returns. It’s built from
two server primitives plus a client sync module.
Server: the changefeed
GET /api/items/{slug}/changes?since=<cursor>&limit=<n>→ { "data": [ …rows… ], "cursor": "<opaque>", "hasMore": true|false }- Returns rows whose
updated_atis past the cursor, keyset-paginated on(updated_at, id)— no skipped or duplicated rows across pages. - Includes soft-deleted tombstones (marked
_deleted: true) so a client can drop rows that were deleted while it was away. (Soft-delete bumpsupdated_at, so deletes always reach the feed — the collection must havesoftDelete: truefor deletes to be observable; a hard delete leaves no tombstone.) - Permission-aware — same row/field rules and draft-hiding as the list endpoint.
- Omit
sincefor a full initial sync; pass the previous response’scursorfor the incremental delta.
Per-item history is also exposed:
GET /api/items/{slug}/{id}/revisions → { "data": [ …snapshots, newest first… ] }Client: sync
import { createClient, indexedDbStore } from "backlex";const backlex = createClient({ url, apiKey });
const notes = backlex.sync({ collection: "notes", store: indexedDbStore({ collection: "notes" }), // persists across reloads onChange: () => render(), // re-render on any local change});
await notes.start(); // pull → go live (SSE) → auto-flush on reconnect
// local-first reads (synchronous-feeling, served from the store)await notes.getAll();await notes.get(id);
// local-first writes — applied optimistically, queued, flushed when onlineconst tempId = await notes.create({ title: "Draft" }); // temp id until it landsawait notes.update(id, { title: "Edited" });await notes.remove(id);
await notes.flush(); // force a queue flushnotes.stop(); // stop live updatesHow it behaves
- Pull drains the changefeed from the saved cursor and applies each row to the store (upsert live rows, delete tombstones), persisting the new cursor.
- Live subscribes to
items:<collection>(SSE) and applies events as they arrive, so the store stays fresh between pulls. - Offline writes apply to the local store immediately and enqueue.
createuses a temporarytmp_…id; on flush, the server-assigned row replaces it. - Flush sends the queue through the batch endpoint in one
request and reconciles results; unconfirmed ops stay queued to retry. It runs
automatically on
create/update/deletewhen online and on the browseronlineevent. - Conflicts resolve last-write-wins by
updated_at, except a row with a pending local write is held (not overwritten by a pull) until it flushes.
Stores
memoryStore()— non-persistent; works in any runtime (and in tests).indexedDbStore({ collection, dbName? })— persists across reloads in the browser.- Bring your own by implementing the
SyncStoreinterface (rows + meta + the write queue) for React Native / SQLite / etc.
Limits & notes
- Delete detection requires
softDelete: trueon the collection (tombstones). - Conflict resolution is last-write-wins; field-level merge is not built in.
- The changefeed needs an
updated_atcolumn (managed collections always have one; adopted tables can alias it).