Skip to content

Part 3 — reading and writing

const status = await engine.openGraph('g:acme', { create: true });
// { key: 'ok' | 'waiting' | 'none', epoch: number, rotationRequired: boolean }
key means
ok this device holds the key; read and write freely
waiting the graph has a key, this device has not been granted it yet
none nobody has created a key for this graph

Draw waiting as “sealed — another device holds the key”, never as empty. An empty screen for a graph full of data is the worst thing a local-first app can show.

{ create: true } initialises epoch 1 if key is none. Use it for a graph this member owns (u, or a group they just made). For a graph somebody else owns, open without it and wait for the grant.

const record = await engine.put('g:acme', {
kind: 'note',
data: { body: 'hello' },
});
// → { id: 'r_...', kind: 'note', data: {...}, at: 1767..., by: 'sam' }
await engine.put('g:acme', { id: record.id, kind: 'note', data: { body: 'edited' } });
await engine.putMany('g:acme', [
{ kind: 'task', data: { title: 'one', done: false } },
{ kind: 'task', data: { title: 'two', done: false } },
]);
await engine.remove('g:acme', record.id); // a tombstone, not a hole
await engine.removeMany('g:acme', [id1, id2]);

Writes are local and synchronous from the app’s point of view: the store write happens, the id is queued, and the push goes in the background. put throws only for a genuine programming error — a record that fails your own schema, a record over 512KB, a graph whose key has not arrived, or a closed engine.

by defaults to this member and is proven, not claimed — a receiver checks the signature against the roster and the claimed author against the signing device. Setting by to somebody else produces a record every other device rejects.

await engine.get('g:acme', id); // GraphRecord | null (tombstones included)
await engine.list('g:acme'); // non-deleted, newest first
await engine.listKind('g:acme', 'task'); // typed by the registry
await engine.search('g:acme', 'quarterly'); // case-insensitive substring
await engine.searchAll('quarterly'); // every open graph

All local, all instant, none of them touch the network. listKind is typed from the registry, so records[0].data.done is a boolean. get returns tombstones too — check record.deleted when it matters.

Search is a substring scan today. FTS5 is a small change against the SQL store and a bigger one against IndexedDB — see ROADMAP.md.

engine.startAutoSync(30_000); // every 30s, plus on write
engine.stopAutoSync();
await engine.sync(); // every open graph, now
await engine.syncGraph('g:acme'); // one graph, now
await engine.flush(); // "everything I wrote is really pushed"
await engine.poke('g:acme'); // "something changed upstream" — from realtime

flush() is the one to await in a test or before closing a window. It syncs until there is genuinely nothing left: no run in flight, no graph marked dirty by a write that landed mid-sync, no queued record.

await engine.close(); // waits for in-flight work, then releases the store

After close, writes throw and syncs are no-ops (timers and listeners can still fire once on their way out).