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
FenecErrornaming 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, atext @hashfield, 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-Keyit 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 answers | The write |
|---|---|
| 2xx | landed: it leaves the queue, its promise resolves |
nothing (the network failed), 408, 429, 5xx | kept 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 |
401 | kept; nothing is sent until a new token (tokenProvider, setToken) |
507 naming a tenant's quota of bytes or rows | refused: put back, its promise rejects, onRefused is told with e.quota, and the writes behind it go on |
any other 4xx | refused: 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 answeredOffline, 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; withoutnavigator.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.
bytesfields cannot be synced.- The server has the last word over the local copy.
- A write that picks its rows —
orderandlimitbefore anupdateor adelete, 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.
bytesfields 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. Withselect, 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
@ttllets 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' });