offline
Offline replicas of Webda models: clients sync query-scoped sets of objects, edit them offline and push them back. Conflicts are detected with a per-object revision (
_rev) and merged client-side with@webda/versioning.
- Server:
SyncService(@webda/offline) - Client:
OfflineClient(@webda/offline/client, runs in browsers, React Native, Electron and Node)
Install
pnpm add @webda/offline
Concepts
- Scopes: a client syncs
{ model, query }sets, e.g.{ model: "MyApp/Task", query: "owner = 'me'" }. Every object is filtered by the model read permission (canAct(ctx, "get")). - Revisions: synced models carry
_rev, incremented by theSyncServiceon every write. - Change log: the
SyncChangemodel records each write; clients pull the changes after their cursor. Entries older thanretentionare pruned, a client with an older cursor resyncs its scopes. - Conflicts: a push carries the revision the change was made on. When the server moved on, the client merges with
@webda/versioning'smerge3: edits of different fields merge automatically, edits of the same value are conflicts. Multi-line strings merge line by line: only overlapping line edits conflict (kind: "line").
Server
import type { Syncable } from "@webda/offline";
/** @WebdaModel */
export class Task extends CoreModel implements Syncable {
title: string;
owner: string;
_rev?: number;
}
SyncService.init fails when a listed model has no _rev attribute: implement Syncable on every synced model.
"sync": {
"type": "Webda/SyncService",
"models": ["MyApp/Task"],
"retention": "30d"
}
| Parameter | Default | Description |
|---|---|---|
models | Models to sync, each must implement Syncable | |
retention | 30d | Age of the pruned change-log entries |
maxScopes | 20 | Scopes accepted per request |
maxQueryLength | 1024 | Length of a scope query |
pageSize | 500 | Change-log entries or objects per Sync.Pull / Sync.Snapshot page (caps the limit asked) |
maxMutations | 100 | Mutations accepted per Sync.Push: keep it at least the clients' pushBatchSize |
overlap | 5s | Window re-read by the next pull, covering clock skew between servers |
watchDebounce | 250 | Debounce of the Sync.Watch hints, in ms |
watchKeepAlive | 25000 | Heartbeat interval of an idle Sync.Watch, in ms (0 disables them) |
Durations (retention, overlap) are <integer><unit> with the unit ms, s, m, h or d (parseDuration).
The Sync.Watch heartbeats ({ cursor, heartbeat: true }, ignored by clients) let the transport notice a closed client
and release its watcher; keeping the stream open through proxies is the REST transport's own keep-alive.
Map Webda/SyncChange to a store like any model. Operations: Sync.Pull, Sync.Snapshot, Sync.Push, Sync.Watch
(POST /sync/pull, /sync/snapshot, /sync/push, /sync/watch), subject to operation permissions and IAM policies.
The routes are relative to the REST transport url: with "url": "/api/", they are /api/sync/... and the client
baseUrl must end with /api.
Scope queries
A scope query is a WebdaQL filter, empty to sync the whole model. Requests are rejected with a 400 when there is no
scope, more than maxScopes, a model the service does not sync, a query longer than maxQueryLength, a syntax error,
a DELETE / UPDATE statement or a field list, a private field, or a LIMIT, OFFSET or ORDER BY.
Production notes
- Index
SyncChange.seq(every pull is a range query on it) andSyncChange.mutationId(every push looks its mutation up to answer a replay) in the store backingWebda/SyncChange. - Each write to a synced model costs one
SyncChangeinsert. Each update or patch also costs an extra read to compute the next_rev(patches made bySync.Pushexcepted), and each partial update without an increment (e.g.setAttribute) an extraincrementAttributeswrite to bump it. _revrestarts at 1 when a key is deleted and created again: clients always load the object a pull or snapshot returns, whatever its revision.- Retention is computed from the clock at every pull: a cursor older than
retentionresyncs on every instance, whichever of them pruned. A failing prune is logged and retried hourly.
Client
import { OfflineClient, IndexedDBStorage, HttpTransport } from "@webda/offline/client";
const client = new OfflineClient({
storage: new IndexedDBStorage("myapp"),
transport: new HttpTransport({ baseUrl: "https://api.example.com", headers: async () => ({ Authorization: token }) }),
scopes: [{ model: "MyApp/Task", query: "owner = 'me'" }],
onConflict: "manual"
});
await client.start();
const tasks = client.collection<Task>("MyApp/Task");
await tasks.create({ title: "Write docs" });
tasks.on("change", evt => render());
| Option | Default | Description |
|---|---|---|
storage | Local storage, see Storage and transport | |
transport | Server access, see Storage and transport | |
scopes | Scopes to sync, see Scopes | |
onConflict | "manual" | See Conflicts |
syncInterval | 30000 | Milliseconds between automatic syncs, 0 disables the timer |
primaryKeys | ["uuid"] per model | Key fields per model |
versioning | {} | @webda/versioning config (array identity, string strategies) |
retry | { base: 1000, max: 60000 } | Exponential backoff (ms) of failed syncs and of watch reconnections |
pushBatchSize | 100 | Mutations per push request, at most the server maxMutations |
Collections
client.collection<T>(model) reads and writes the local replica; every write is pushed on the next sync.
create(data)adds an object, generating auuidkey unless you provide the key fields. It throws when the key already exists; re-creating an object deleted locally is an update.patch(key, data)merges fields. It throws when the object is missing or has an open conflict.delete(key),get(key)(undefinedwhen missing or deleted) andquery(filter), a WebdaQL filter evaluated on the local objects (every object when empty).on("change", fn)listens to this model's changes and returns an unsubscribe function. The event is{ ref, object, origin },objectbeingnullon deletion andorigin"local"or"remote".
The client itself emits change (every model), status, conflict (a conflict left open) and error.
Lifecycle
client.start()syncs once, then on thesyncIntervaltimer, on the browseronlineevent and on everySync.Watchhint. A failed sync is retried with an exponential backoff. The watch stream reconnects with the same backoff (reset once a connection delivered an event); a401,403or404stops it and is emitted aserror(polling goes on).client.stop()clears the timers, aborts the watch stream and removes theonlinelistener.client.sync()pushes then pulls; concurrent calls share one run. When the server refuses the push (any non-network status, e.g.403for a read-only user) the pull still runs, then the push error is thrown.client.statusisidle,syncing,offlineorerror; every change is emitted asstatus, every failure aserror.offlineis set for errors without a status (network failures, but also a failing local storage),errorfor server refusals. Anerrorstatus recovers by itself on the next successful sync: fix the cause (sign in again, grant the permission...) and callclient.sync()or wait for the retry.- A pull resyncs at most once: a server whose resync cursor is itself too old (a retention shorter than its own
overlap, a broken proxy...) makes the sync fail with a
ResyncLoopError(status500, reported aserror) after one snapshot pass instead of snapshotting every scope forever.
Scopes
client.setScopes(scopes) replaces the scopes: the cursor is reset, the next sync resyncs every scope, and synced
records of models no scope covers any more are dropped (local changes are kept). Explicit scopes are persisted and win
over the constructor scopes across restarts, until the app is started with different constructor scopes: those then
replace them. Any change of the effective scopes, including a new app version shipping new constructor scopes, runs
the same resync.
Records and keys
Local creates generate a uuid key on the client (or use the key fields you provide). The server keeps that key: a
create on a key already used by an object the user cannot read is rejected with KEY_IN_USE, so prefer random keys
over guessable ones. On a key used by a readable object, the create is a conflict.
Each local record has a state: synced, created, dirty, deleted, conflict or error. A mutation rejected by
the server (see Push errors) leaves the record in error with its { code, message }; it is not pushed
again until it is edited (back to dirty) or discarded.
client.discard(ref) drops the local changes of an object (typically in error or conflict state) and goes back to
the last server value. A local create is forgotten, unless it was already sent: the server may hold it, so the next
sync deletes it there.
Conflicts
onConflict is "manual" (default), "server-wins", "client-wins" or an async function receiving the conflict and
returning the resolutions (a Map), or "defer" to leave it open. Open conflicts are emitted as conflict, listed by
client.conflicts() and settled with client.resolve(ref, resolutions):
for (const conflict of await client.conflicts()) {
// conflict.result.conflicts: [{ path: "/title", kind: "value", base, ours, theirs }]
await client.resolve(conflict.ref, new Map([["/title", { choose: "ours" }]]));
}
A conflict kind is value, line (multi-line string), array-item or delete-modify. A resolution is
{ choose: "ours" | "theirs" | "base" }, { value } or { text } (the edited text of a line conflict, e.g. rendered
with toGitMarkers from @webda/versioning). resolve() throws unless every conflict path has a resolution.
A whole-object conflict (delete-modify: deleted on one side, edited on the other) has the path "":
{ choose: "ours" } keeps the local version (re-creating or re-deleting it), { choose: "theirs" } accepts the server
side. Editing an object that no longer exists on the server is such a conflict; deleting it succeeds.
Push errors
A rejected mutation carries one of these codes: NOT_SYNCED (model not handled by the service), INVALID (malformed
mutation or unknown op), INVALID_KEY, KEY_IN_USE (create on an existing key the user cannot read), NOT_FOUND
(an object the user cannot read: indistinguishable from a missing one), FORBIDDEN, VALIDATION (model validation
failed) and INTERNAL.
Storage and transport
IndexedDBStorage(name, factory = globalThis.indexedDB)persists the replica in an IndexedDB database (pass afactorywhereindexedDBis not global).MemoryStoragekeeps it in memory only, for tests or ephemeral clients.HttpTransport({ baseUrl, headers, fetch, paths })calls the REST operations:headersis a sync or async function returning extra headers,fetcha customfetchimplementation andpathsoverrides thepull,push,snapshotorwatchroutes.Sync.Watchis read as a server-sent event stream.- Other backends implement the exported
StorageAdapter(records and metadata,putRecordsmust be atomic) andTransport(pull,push,snapshotand an optionalwatch) interfaces. A transport throws aTransportError(status, message), with status0for network errors.
Encrypted local replica
EncryptedStorage wraps any StorageAdapter and encrypts the record data at rest with a random AES-256-GCM data key.
The data key is stored only wrapped (AES-KW), once per KeyProvider. PasskeyKeyProvider derives the wrapping key
from a passkey through the WebAuthn PRF extension, so the replica can only be read after the user verifies with the
passkey.
import {
OfflineClient,
IndexedDBStorage,
EncryptedStorage,
PasskeyKeyProvider,
HttpTransport
} from "@webda/offline/client";
const storage = new EncryptedStorage(new IndexedDBStorage("myapp"));
const provider = new PasskeyKeyProvider({ credentialId, label: "myapp-v1" });
// Prompts the user; the client is created only once the storage is unlocked
await storage.unlock(provider);
const client = new OfflineClient({ storage, transport, scopes });
await client.start();
// When the data is closed (sign-out, idle timeout...)
client.stop();
storage.lock();
While the storage is locked reading or writing records fails with an EncryptedStorageError (LOCKED). Other codes are
DECRYPT (wrong key or tampered data), NO_KEY (no wrapped key for this provider), LAST_PROVIDER and RESERVED
(the __encryption. meta namespace belongs to the storage).
Registering a passkey
Create the credential with the PRF extension, then keep its credential id (base64url) for the provider:
const cred = await navigator.credentials.create({
publicKey: {
/* rp, user, challenge, pubKeyCredParams... */
authenticatorSelection: { userVerification: "required", residentKey: "preferred" },
extensions: PasskeyKeyProvider.prfRegistrationExtension("myapp-v1")
}
});
The first unlock(provider) on an empty storage generates the data key and wraps it for that provider. The same
label must be used at registration and at every unlock: it is the PRF input and the HKDF info.
Several passkeys
A device can accept several passkeys (a second authenticator, a backup). While unlocked, add or remove providers:
await storage.addProvider(new PasskeyKeyProvider({ credentialId: other, label: "myapp-v1" })); // prompts for `other`
await storage.removeProvider("passkey:" + other);
await storage.providers(); // ids of the registered providers
removeProvider refuses to remove the last provider (LAST_PROVIDER), so the data cannot be locked away by mistake.
Recovery
The local data is a replica of the server. If every provider is lost, nothing can be recovered locally: call
storage.clear() (allowed while locked, it removes the records, the wrapped keys and the sync metadata), register a new passkey,
unlock it and let the client resync.
Limits
- Only record data is encrypted. Record ids, model references and states (synced, pending, conflicted...) remain readable by anyone with access to the IndexedDB database.
- The data is in clear in memory while the storage is unlocked. Locking drops the data key, but cannot wipe memory already handed to the application.
- A
KeyProvidermust return a non-extractable AES-KWCryptoKey, so the key-encryption key never leaves the browser's crypto layer. - PRF support varies between browsers, platforms and authenticators. Check
await PasskeyKeyProvider.isSupported()and fall back when it is false, for example to online-only with no local replica. Afalseis not final on every browser, asunlockis the authority and throwsPasskeyKeyProviderError(PRF_UNSUPPORTED). - A user cancel currently surfaces as the browser's
NotAllowedError, not as aPasskeyKeyProviderError.
Limitations
_revis strictly checked only onSync.Pushwrites. Concurrent non-push server writes may produce a duplicate revision number; data is not lost, as pull compares content and push applies deltas onto the current server object.- Removing an attribute offline is applied by patching it to
undefined. This is verified on the memory store only: other stores may handleundefinedin a patch differently. - Offline creates keep their client-generated key.
deleteMany/updateManyemit no per-object event: callsyncService.touch(model, keys)or clients only see those changes after a resync.Sync.Watchhints only cover writes on the same server instance; polling keeps clients correct.- No relation following, binary attachments or cross-object transactions.