Sync

Keep a copy of your data in the browser or an app that reads without the network, writes instantly and stays up to date with the server.

Start a replica

Call sync with the server and the rows you want. Then query it with the same builder you use anywhere else.

import { sync } from './fenec.js';
import type { FenecSchema } from './fenec-schema.js';   // fenec types, over the server's file

const db = await sync<FenecSchema>({
  url: 'http://127.0.0.1:8080',
  shapes: [{ collection: 'tasks', where: { status: 'open' }, key: 'key' }],
});
await db.ready();

// read: from the local copy, no network
const rows = await db.from('tasks').where('priority', '>=', 3).rows();

// live query: re-runs on every change
const stop = db.live(db.from('tasks').order('priority', 'desc'), draw);

// write: to the local copy first, instantly, then to the server
await db.from('tasks').insert({ title: 'new', status: 'open' });

The replica loads fenec.wasm, the full module, unless given another, so its near walks a graph.

What sync opens, and where a query binds

A copy on the client, changes streaming from the server, optimistic writes. In fenecdb that is not a library — it is an endpoint and a transport, because the same engine already runs on both sides.

With no module given, sync opens ./fenec.wasm beside the page, its collation data from collate/ beside it; wasm takes another URL, the bytes or a compiled module, and local a database already open.

The query code does not change. db.from(...) is the same builder producing the same FenecQL; the only difference is where it is bound.

The server owns the schema

A replica takes its collections as the server declares them. Code that declares its tables (Schema in code) hands them over too, and they are checked against the server's, never applied:

import { sync } from './fenec.js';
import { todos } from './tables.js';

const db = await sync({
  url: 'https://todos.example.com',
  schema: { todos },
  shapes: [{ collection: todos, key: 'key' }],
});
  • What the code declares must be there. A collection, a field or an index the server lacks, or one of another type, refuses the open: a FenecError naming each difference.
  • What the server holds beside it is its own. A field or a collection the code does not declare is fine: an older app keeps working while the server moves ahead.
  • Nothing is applied, not even an addition. The schema changes where it lives, on the server: its owner's migrations, a deploy step's connect(url, { schema, migrations, migrate: true }).
How the schema is compared

The comparison is the server's engine (POST /_schema/plan?mode=follow), the same that checks a page's own database at an open, so the two say the same things in the same words. A replica reconciles rows by its server's field order and types, which is why it never declares a collection of its own: a field added in the page and not on the server could not be decoded either way.

Native apps sync too

An iOS, Android or Flutter app keeps its replica in a file on the device, with the same shapes, the same optimistic writes and the same catching up -- and its writes wait in the file while the server cannot be reached. Opening the file as a replica is the one line that changes: Fenec.sync in Swift and Kotlin, Fenec.openSynced in Dart (Sync with a server).

The stream carries state, not transactions

The server sends the current state of each row that changed, not a log of every write. That is what a replica needs; it is not an audit log.

Why the stream is a clock, not a log

The classic route is a transaction log: every write is an event and the subscriber replays them. fenecdb sends the current state of what changed instead — the stream is a clock, not a record. That is right for a replica and wrong for anyone who wanted an audit log.

A shape is a set, not a window

A shape picks the rows of one collection the client keeps, by a filter the server applies. It cannot be "the latest 100": order, limit, offset and count are refused.

Why a shape refuses order, limit and offset

The whole database is in memory and the WASM32 address space is 4 GB, so a client cannot pull an entire collection. A subscription is a subset and the filter is applied on the server.

order, limit, offset and count are refused in a subscription with a 400. A shape like "the latest 100 rows" looks correct but is not: when a new row arrives the oldest has to drop out, and an incremental diff cannot express that. An explicit error beats a silently wrong stream.

The shape condition uses the ?field=op.value form, so there is no or group and no function call. The reason is escaping: a free where= would turn into string concatenation, exactly what the builder avoids. Escaping goes to the transport via URLSearchParams and types go to the server, leaving no injection surface.

One collection per shape. The seed has to mean "this is the whole of this collection", or the cleanup step becomes ambiguous.

Optimistic writes

A write shows up locally at once, then goes to the server. It is kept until the server answers: if the server refuses it, the local change is undone; if the server cannot be reached, it waits and goes again.

const p = db.from('tasks').insert({ title: 'new', status: 'open' });
await db.from('tasks').where('title', 'new').first();  // already here
await p;                                               // the server has it
  • Inserts need a key, a text @hash field, to match the local row with the server's. Without one, an insert waits for the server.
  • Updates and deletes need no key: they work by the filter, by id or anything else -- a row an insert made before the server answered included.
  • Writes go one at a time, in order, each under an Idempotency-Key it keeps until it is answered.
  • An increment lands from every replica. update({ n: inc(1) }) is worked out over the replica's row at once and sent as the statement, which the server works out again over its own: two replicas that each increment offline leave the server 2 up, and both replicas come to its sum.
How an optimistic write is applied and undone

A write is three steps and the order matters: first gather what undoes the local change, then apply it locally, then queue it for the server. If the server refuses, the local side rolls back fully — no network needed, the undo is already in hand. The queue and the undo are kept in the replica itself, in collections of the sync's own (_sync_queue, _sync_temps, _sync_shapes with the cursors), as an app's file keeps them (Sync with a server).

Everything up to the first await is synchronous. An async function body runs synchronously until its first await, so the optimistic row is local before the caller awaits the promise. A single microtask in between would have broken the "visible instantly" promise.

Reconciliation by key. The id space belongs to the server and the client cannot know it in advance, so an optimistic row gets a temporary id starting at 252 — far from the server's range and below Number.MAX_SAFE_INTEGER. What matches the two is a business key: when a row with the same key arrives from the subscription, the temporary one is dropped. An update or a delete that reaches a row the server has not sent yet is sent a second way as well, by that key -- set tasks {..} where key in [..] beside the statement -- since the temporary id is the page's alone. An insert the shape does not hold leaves its temporary row once the stream is past the change the server answered it at (Fenec-Seq).

An insert into a keyless shape is not applied optimistically, unless it names its id. With nothing to reconcile, the row would stay a local duplicate; one round trip of latency beats a silent copy. key must be text @hash.

Writes go one at a time because a write that landed after one written after it would leave the server holding the earlier. The promise a write returns settles with the server's answer, so it waits through an outage; the row is in the replica from the start, and nothing needs to await it.

Refusals, retries and offline

Only a refusal puts a write back. Anything else that keeps the server from answering keeps the write, and it goes again until the server answers it -- once, whatever the network did meanwhile.

The server answersThe write
2xxlanded: it leaves the queue, its promise resolves
nothing (the network failed), 408, 429, 5xxkept and sent again under its key, after 250 ms doubling to 15 s -- or after the Retry-After the server names, where that is longer: a tenant's quota, a tenant being moved or failed over
401kept; nothing is sent until a new token (tokenProvider, setToken)
507 naming a tenant's quota of bytes or rowsrefused: put back, its promise rejects, onRefused is told with e.quota, and the writes behind it go on
any other 4xxrefused: put back, its promise rejects, onRefused is told
import { sync } from './fenec.js';

const db = await sync({
  url: 'https://api.example.com',
  shapes: [{ collection: 'tasks', key: 'key' }],
  persist: 'tasks',                                // writes survive a reload
  tokenProvider: () => auth.refresh(),             // asked on a 401
  onRefused: (e) => toast(e.quota ? 'This workspace is full' : `${e.status}: ${e.message}`),
});
await db.pushed();                                 // every write answered
Offline, reload, backoff, quota and failover

Once, by the key. A write whose answer was lost -- the request reached the server, the response never came back -- is sent again under the Idempotency-Key it was first sent with, and the server answers it as it answered the first time (Idempotency) rather than make the row twice.

Offline. A write made while the server cannot be reached waits in the queue; the page's online event, or setOnline(true), sends it at once, the backoff forgotten -- unless the server said when to come back (below) -- and offline or setOnline(false) closes the streams. Writes left from before go before the streams open again, so what they bring holds them.

A reload. With persist -- or a local kept in a file (openFile) -- the queue is stored with the replica the moment a write is made, and a page opened again sends what it had not, under the same keys. Without either, a closed page takes its unsent writes with it.

Backoff. 250 ms, doubling to 15 s, with 30% jitter on top so a server back up is not met by every client at once; a stream that cannot reconnect waits the same way, and starts the count again once it opens.

A tenant's quota. A tenant node holds each tenant to its quotas (Quotas), and names the one that refused a request in its body, which the replica reads. A 507 of bytes or rows is a refusal: waiting makes no room, deleting does, and a write that only deletes always lands -- held, the write would hold every write behind it for good, the delete that would make its room among them. A 507 naming no quota is the server's own ceiling (--max-memory) or a proxy's, and is waited out as a 5xx is. A 429 of streams, held, requests, writes or write_bytes is waited out for its retry_after_ms -- the wait to the millisecond, a bucket refilling a row in 2 ms where Retry-After rounds it up to a second -- or its retry_after, the seconds Retry-After says, where no milliseconds are given, where that is longer than the backoff, an hour at most, with the jitter on top, since every client the quota refused in one moment is told the same wait; a stream refused so waits the same way, its shape not connected meanwhile, and status() names the quota (quota) beside the error. setOnline(true) forgets the backoff but not the server's word: the network says nothing of a tenant's quota.

A move or a failover. A node answers a write to a tenant it is moving, or no longer holds, 503 with "retry_after": 1 beside its Retry-After (Moving a tenant); a standby router whose directory has not arrived answers a stream so too. The write is kept and goes again through the router after that second where it is longer than the backoff, as after a quota's 429: the node's word for how long a move holds its writes, never the backoff's guess under it.

Put back in order. A refused write's rows are written back as they stood before it. A later write to the same rows, still waiting, is not replayed on top; the server's answer to it, and the change it sends, has the last word.

Live queries

db.live(query, fn) runs the query again whenever a collection it reads changes, and hands fn the new rows. Running it again on the local copy takes under a millisecond.

A database in the page with no server has the same live: see State in the page. Moving from one to the other leaves the code that reads unchanged.

Why live queries re-run instead of updating

There is no incremental maintenance and none is needed. The local side is an indexed database, so re-running the query from scratch is already under a millisecond — the differential dataflow that a Map-backed collection requires buys nothing here.

It would not even be correct for near. A single insert can change the entire top-k ordering, and there is no incremental HNSW maintenance that is both cheap and correct — the machinery would have to be disabled for fenecdb's headline feature.

Invalidation is at collection granularity: the change stream says which collections changed, the live queries bound to them re-run, and all of it coalesces into one frame. Anything finer, like intersecting id sets, would cost more than the local query itself. A query built with db.from knows the collections it reads, each lookup's included; a FenecQL text names them with { collections }, or runs again after every write.

A database in the page alone runs its live queries in a microtask after the task that wrote, not at the next frame: its writes are the page's own, and nothing lands in chunks. Both use the same code.

Batches

db.batch makes several writes one: one request, one key. They land on the server together or not at all, and on a refusal the local copy is put back too.

await db.batch(async (t) => {
  await t.from('tasks').insert({ title: 'a', status: 'open' });
  await t.from('tasks').where('key', 'x').update({ status: 'closed' });
});
How a batch lands, and rolls back on both sides

The batch is one block on the server: its statements run in order under a single write lock, and their writes land together, as one record, or not at all. The gain is one round trip instead of N, no other writer slipping in between, and nothing half done.

Both sides roll back. A batch that is refused lands nothing on the server, and the local side puts back exactly what it applied optimistically, the last write first, so the two stay one state. A batch whose answer did not come is kept and sent again, whole, as any write is.

Multiple tabs share one stream

Open tabs elect one leader that holds the connection to the server, passes changes to the others and sends every tab's writes, one queue for all.

  • Turn it off with leader: false; without navigator.locks, as in Node, it is off already.
How tabs elect a leader

Every tab has its own wasm instance. If each opened its own subscription there would be N copies and N connections, so a single leader is elected with navigator.locks; the others receive the same batches over BroadcastChannel and apply them to their own local copies. The apply path is identical — only the transport differs.

Writes go through the leader, so one queue keeps them in order. A tab applies its own write to its own copy at once, as the leader does, and hands it to the leader, which keeps it in its queue -- stored with its replica when it persists -- sends it under its key and tells every tab the answer; the tab that made the write lands it or puts it back in its copy. When the leader goes, the lock passes to the next tab, which takes over the queue its predecessor stored and is handed every other tab's unanswered writes again; the key makes a write sent twice land once.

Dropped connections and the horizon

A client that reconnects gets only what changed since it left. If it was away too long, the server sends it a fresh copy instead.

--changes <n>    entries in the ring (default 4096, 24 bytes per entry)
How far behind a client may fall

A client returns with ?since=<cursor> and asks for no seed. If the cursor has fallen behind the ring, the server reseeds by itself rather than silently sending incomplete events.

That number is directly how far behind a subscriber may fall. At 100 writes per second, 4096 entries is roughly a 40 second window. With persist, the image and the cursor are written to IndexedDB, so a reopened tab is not reseeded from scratch — unless its cursor has fallen past the horizon.

A seed writes the shape over the replica and deletes what it does not hold, keeping the rows of writes the server has not answered -- and of those it answered after the seed was taken -- whose copies come later. Written over rather than cleared first, a row that stayed keeps its vector's node.

The server's schema changed. A change says so ("schema": true), or rows come with a field the replica has not got: the replica asks GET /collections again, makes the collection again as the server now declares it, its rows kept in the fields that are left, and is sent the whole shape. A schema declared in code is checked against the server's again, and a difference goes to onError.

When the server restarts, the horizon is set to the counter: a database loaded from a file has no history, only its present state. A cursor standing exactly there, after a quiet restart, gets an empty response; anything behind it is reseeded.

Cost

The server's change ring is about 96 KB at the default size, and subscriptions do not poll.

What the change ring costs

At the default --changes 4096 the ring is about 96 KB and costs one push per entry on the write path. Subscriptions do not poll — see the HTTP endpoint for why — and are counted separately from ordinary requests under --http-max-streams.

Limits

A few things sync does not do, or does differently from what you might expect.

  • A shape is not a security boundary on its own; use a per-user token with scoped access.
  • bytes fields cannot be synced.
  • The server has the last word over the local copy.
  • A write that picks its rows — order and limit before an update or a delete, or { returning }, a job queue's claim — is refused on a synced collection: the replica would pick and answer its own rows, and the server its own. Send it to the server itself (db.remote).
What a shape does not guarantee
  • A shape alone is not a security boundary. Under the server's own token, a changed id that does not match the shape is sent as a deletion: harmless locally, but which ids changed leaks. A per-user token with scoped access hides both: the rows outside its rules are filtered out, and it hears only of rows it was sent.
  • bytes fields cannot be synced. In JSON they become number arrays with no way back. That is a limit of the HTTP surface, not something sync introduces.
  • In a projected shape the other fields read as null. With select, the local row carries only those fields — which is all the client knows anyway.
  • batch() does not nest and the calls inside it return no result: writes pile up and go in a single request.
  • Rolling back needs no network, but the server has the last word. A rolled-back local state is overwritten by the server's in the next batch.
  • Rows a @ttl lets go of leave a replica's reads at once -- the replica's collection has the server's @ttl -- and arrive as deletes when the server's sweep takes them.

Persisting the replica

Add persist to keep the copy, its place in the stream and the writes the server has not answered in IndexedDB, so a reopened tab starts from there instead of downloading everything again, and sends what it had not.

import { sync } from './fenec.js';
import type { FenecSchema } from './fenec-schema.js';

const db = await sync<FenecSchema>({
  url: 'https://api.example.com',
  shapes: [{ collection: 'tasks', where: { status: 'open' }, key: 'key' }],
  persist: 'tasks',           // the replica, its cursors and its unsent writes
});
await db.ready();

db.live(db.from('tasks').order('priority', 'desc'), draw);
await db.from('tasks').insert({ title: 'new', status: 'open' });