sync
| Subpath | @noy-db/hub/sync |
| Factory | withSync() |
| Layer | with-sync |
| Cluster | G — Collaboration & Auth |
| LOC cost | ~856 (off-bundle when not opted in) |
What it does
Sync replicates a vault — noy-db's end-to-end-encrypted document store (see Vaults & collections) — between a local store and one or more remote stores. It tracks which records changed locally since the last push ("dirty tracking"), sends only those changes to the remote, pulls back whatever the remote has that's newer, and resolves conflicts when both sides changed the same record. Everything that crosses the wire is the same encrypted envelope your collections already write (see Core 02 — Enclave), so a remote store you don't fully trust — a cloud bucket, a peer's device — never sees plaintext. On top of replication, sync also exposes presence: short-lived "who's looking at this collection right now" awareness, broadcast over an ephemeral channel whose key is derived from the collection's data-encryption key (its DEK — see Core 02 — Enclave) via HKDF, a one-way key-derivation function. When the store adapter supports real-time pub/sub, the whole presence message — user ID and payload together — travels encrypted under that key, so the adapter can't identify users either. Without pub/sub support, presence falls back to polling records written to the store, and that fallback writes the user ID in the clear (only the payload is encrypted) — see the storage-poll note below.
When you need it
- Multi-device single-user apps (laptop + phone sharing one vault)
- Multi-user collaboration where each device keeps a local cache plus a shared cloud target
- Active backup / archive replication to a second store
- Live "who's viewing this" presence in a collaborative UI
Prerequisites
- A shared sync adapter — pass a store both devices can reach as the
sync:option;withSync()without it (orsync:without the strategy) throws on the firstopenVault(). - For real-time presence: the adapter must implement
presencePublish/presenceSubscribe;collection.presence()also always requires the strategy itself.
Opt in
import { createNoydb } from '@noy-db/hub'
import { withSync } from '@noy-db/hub/sync'
const db = await createNoydb({
store: localStore, // local cache — see Core 05: Stores
sync: remoteStore, // primary sync target
user: 'alice',
secret: 'alice-secret-2026',
syncStrategy: withSync(),
})
const vault = await db.openVault('notes')
Multiple targets — a bidirectional peer plus a push-only archive — pass an array of
{ store, role } pairs instead of a bare store:
const db = await createNoydb({
store: localStore,
sync: [
{ store: peerStore, role: 'sync-peer' }, // bidirectional
{ store: archiveStore, role: 'archive' }, // push-only
],
user: 'alice',
secret: 'alice-secret-2026',
syncStrategy: withSync(),
})
Roles: 'sync-peer' pushes and pulls; 'backup' and 'archive' only ever receive pushes — they
never feed data back into the local store. This holds even when a 'backup'/'archive' target
ends up elected as the primary (a config with no 'sync-peer' at all): since 0.3.0-pre.8,
db.pull() against such a primary is a no-op returning an empty result, and db.sync() runs
push-only against it — a backup sink is never pulled from.
Usage
Push, pull, and status
interface Note { title: string; body: string }
const notes = vault.collection<Note>('notes')
await notes.put('n1', { title: 'Grocery list', body: 'eggs, milk' })
const pushResult = await db.push('notes')
{ "pushed": 1, "conflicts": [], "errors": [], "erasures": [] }
db.push(vault, options?) sends only dirty records — ones written locally since the last
successful push — to the vault's primary sync target (the first 'sync-peer', or the first
configured target if none is marked 'sync-peer'). db.pull(vault, options?) is the mirror: it
reads the remote's full snapshot and applies anything newer than the local copy.
const pullResult = await db.pull('notes')
{ "pulled": 3, "conflicts": [], "errors": [], "erasures": [] }
Check what's outstanding at any time with db.syncStatus(vault) — it reads the engine's tracked
dirty log, no network call:
const status = db.syncStatus('notes')
{ "dirty": 0, "lastPush": "2026-07-08T10:02:00.000Z", "lastPull": "2026-07-08T10:02:05.000Z", "online": true }
db.sync(vault, options?) is a convenience that pulls then pushes in one call and, unlike
push/pull on their own, fans out to every configured target: 'sync-peer' targets get a full
pull+push, 'backup'/'archive' targets get push-only.
const { pull, push } = await db.sync('notes')
{
"pull": { "pulled": 3, "conflicts": [], "errors": [], "erasures": [] },
"push": { "pushed": 1, "conflicts": [], "errors": [], "erasures": [] }
}
The automation ladder
Everything above is rung 1 — explicit: replication happens when you call push, pull or
sync, and at no other time. That is the whole behavior you get from sync: alone, and it is
where you stay unless you do the one thing below.
syncPolicy is the opt-in. noy-db resolves a policy from the store category —
per-record stores get INDEXED_STORE_POLICY, pod stores get POD_STORE_POLICY — but resolving
one does not start anything. The scheduler starts only for a policy you declared, via
createNoydb({ syncPolicy }) or a per-target policy. Fall back to the resolved default and you
stay on rung 1 forever.That distinction is deliberate rather than an oversight. Starting automation on the resolved
default was tried and rejected: push: 'on-change' fires an unawaited push, which races anything
else touching the remote, so turning it on by inference made previously deterministic behavior
racy — including a conflict-resolution outcome.
Rung 2 — declare a policy
const db = await createNoydb({
user: 'me',
store: /* … */,
sync: remote,
syncPolicy: {
push: { mode: 'on-change', minIntervalMs: 0, onUnload: true },
pull: { mode: 'interval', intervalMs: 60_000 },
},
})
| Half | Modes |
|---|---|
push | manual · on-change (after every local write, respecting minIntervalMs) · debounce (after debounceMs of quiet, default 30_000) · interval |
pull | manual · interval (intervalMs, default 60_000) · on-focus (tab regains visibility) · phased |
push.onUnload (default true for non-manual modes) forces a final push on pagehide /
visibilitychange → hidden in browsers and beforeExit in Node. The escape hatch — automation
off, but declared rather than inferred — is
{ push: { mode: 'manual' }, pull: { mode: 'manual' } }.
Scheduler-initiated pulls are role-gated. Only a 'sync-peer' target auto-pulls; a 'backup'
or 'archive' target with an interval pull policy does not, because a backup sink has no
business feeding data back. An explicit db.pull() still pulls for every role.
Rung 3 — phased pull
pull: { mode: 'phased', sequence: ['clients', 'invoices', 'attachments'] }
Collections are pulled one at a time, in the order given — each phase an ordinary
pull({ collections: [name] }). It is strictly sequential; running phases concurrently would
defeat the prioritisation that is the entire point. A failing phase does not abort the ones behind
it. When the sequence drains, the scheduler settles into steady state — intervalMs if you gave
one, idle if you didn't — so bootstrap and steady-state sync are one flow rather than two APIs.
sequence entries must be non-empty and unique, and sequence is rejected unless
mode: 'phased'.
Rung 4 — readiness
Under a phased pull, a get() miss is ambiguous: the record may not exist, or its collection may
simply not have arrived yet. Readiness is how you tell those apart.
const inv = await invoices.get('inv-1')
const { readiness } = db.syncStatus('acme')
if (inv === null && readiness?.get('invoices') !== 'live') showSkeleton()
else if (inv === null) showNotFound()
| State | What it licenses |
|---|---|
'cold' | Not pulled yet, or its phase did not finish cleanly — a miss proves nothing |
'pulling' | Phase in flight. Never terminal: it always resolves to 'live' or back to 'cold', so a stuck 'pulling' can't leave a permanent skeleton |
'live' | Phase completed cleanly, so a miss is a real absence |
Three rules are easy to get wrong:
- Only a clean phase reaches
'live'. A pull that reports errors leaves the collection'cold'— andPullResult.errorsaccumulates without throwing, so a partial failure is invisible to atry/catch. So does a pull that throws, and one skipped by the role gate on abackup/archivetarget. Completeness is never claimed by a target that never read. - A collection the sequence never names is absent from the map.
undefinedmeans "no claim made", which is never a reason to gate a UI. phaseis{ index, total }(1-based) while the sequence runs andnullonce it drains. Bothreadinessandphaseare absent entirely for non-phased policies.
mode: 'phased' and readiness — arrived in 0.4.0-pre.10, and so did the
scheduler running at all: below that version startScheduler() had no callers and
notifyChange() returned at its if (!this.started) guard, so no policy ran automatically.
Every published version from 0.4.0-pre.10 on has all four rungs — the rung you get is decided by
the hub version you install, not by which dist-tag you install from.Deletes converge as delete markers
Since 0.3.0-pre.7, collection.delete() on a synced vault
no longer physically removes the envelope. It writes a version-ordered delete marker instead —
an envelope with _del: true and an empty body — so the delete travels through push/pull like
any other write and an offline peer can no longer resurrect a deleted record by pushing its stale
copy back. Reads treat a marker as absent (get() returns null), and a legitimate re-create at a
higher version still resurrects the id — guaranteed non-resurrection remains
forget's job, not delete()'s. A concurrent same-version delete-vs-edit
resolves through the collection's conflict resolver when one is configured; without one, the
delete wins. Local-only (non-synced) collections keep physical deletes — nothing changes there.
The marker rides the store contract as an optional _del field on the encrypted envelope (see
Stores) — additive, and every to-* store round-trips it (it is part of the
shared adapter-conformance suite). Markers accumulate until explicitly reclaimed: the
periods service purges a closed period's markers locally with
vault.freezePeriod() and sweeps them off push-only sync targets with
vault.purgePeriodTargets().
Reserved dictionary collections (_dict_*, backing lookup fields) participate
in sync the same way since 0.3.0-pre.9: dictionary writes enter the dirty log, pull() applies
them through the ordinary path, and dictionary deletes travel as the same delete markers — see
lookup → reserved-tier sync.
Erasures are terminal
A record erased with forget is crypto-shredded down to a tombstone, and since
0.3.0-pre.7 sync treats that tombstone as terminal for its record id: pull() never
overwrites a tombstone with a live remote envelope regardless of version — it re-asserts the shred
outward instead — and push() asserts tombstones unconditionally, bypassing conflict resolvers
entirely (an erasure cannot be overruled by a merge function). Forget tombstones also enter the
sync dirty log, so the shred itself propagates on the next push(). Every suppressed live envelope
is reported on the erasures array of the push/pull result (the ErasureEnforcement shape under
Types below) and through the sync:erasure event:
db.on('sync:erasure', (e) => {
console.log(`${e.direction}: erased ${e.collection}/${e.id} won over a live edit`)
})
Sync-applied writes also refresh the collection's in-memory caches, so a reader in the same
session sees pull results immediately — and never a decrypted residue of a shredded record. On
vaults using derivations, materialized views,
or computed fields, a batched dispatch wave at the end of each pull()/push()
recomputes affected derived targets exactly once per target (since 0.3.0-pre.9 — see the
dependency graph on the via page).
Conflict resolution
When both sides changed the same record, push/pull resolve the conflict instead of failing.
The database-level default is conflict: 'version' — whichever side has the higher internal
record version wins:
const db = await createNoydb({
store: localStore, sync: remoteStore, user: 'alice', secret: 'alice-secret-2026',
syncStrategy: withSync(),
conflict: 'remote-wins', // or 'local-wins', or (conflict) => 'local' | 'remote'
})
Override per collection with conflictPolicy, which offers richer choices — a custom function here
receives and returns the record content directly, not just a side:
const notes = vault.collection<Note>('notes', {
conflictPolicy: 'last-writer-wins', // or 'first-writer-wins', 'manual', or (local, remote) => merged
})
Every resolved conflict is also reported on the conflicts array of the PushResult/PullResult
shown above, with both sides' record version:
{
"vault": "notes", "collection": "notes", "id": "n1",
"localVersion": 2, "remoteVersion": 3,
"local": "{ encrypted envelope }", "remote": "{ encrypted envelope }"
}
Same-version ties converge (since hub 0.5.0). When a push CAS conflict resolves local-wins and the winning envelope's _v does not already exceed the remote's — the concurrent-edit tie — the engine re-stamps the winner at remote._v + 1 and mirrors the advanced envelope into the local store (noy-db#936). The losing peer's next pull() then sees a genuine version advance and adopts the winner through the ordinary pull path, instead of reporting pulled: 0 while both peers hold different content at the same _v. (A 'merged' resolution already stamped max(local, remote) + 1.)
A delete marker does not always beat a concurrent edit. For an ordinary delete marker (from
delete() — distinct from a terminal forget tombstone, which is never overruled), whether the
delete or the edit wins depends on the policy. A custom-function conflictPolicy and the CRDT
modes 'lww-map' / 'rga' can never keep an edit over a delete: the shared resolver checks for a
deleted side first and returns it unconditionally, before the merge function or the CRDT merge runs.
'last-writer-wins', 'first-writer-wins', 'manual', and CRDT mode 'yjs' (a raw version
compare, no decrypt) do not special-case delete markers — so a higher-versioned edit can win
over the delete under those.
Presence
collection.presence<P>(opts?) returns a handle scoped to one collection. update() announces
your own payload; subscribe() receives everyone else's:
interface Cursor { path: string; action: 'viewing' | 'editing' }
const presence = notes.presence<Cursor>()
const unsubscribe = presence.subscribe((peers) => {
console.log(peers)
})
await presence.update({ path: 'notes/n1', action: 'editing' })
[
{ "userId": "bob", "payload": { "path": "notes/n1", "action": "viewing" }, "lastSeen": "2026-07-08T10:03:40.000Z" }
]
Call unsubscribe() to stop receiving updates, and presence.stop() to release the handle
entirely (stops polling, drops the pub/sub listener, clears subscribers). When the store adapter
implements presencePublish/presenceSubscribe, presence is real-time; otherwise it falls back to
polling a reserved _presence_<collection> collection on the sync target (or the local store if
none is configured) every pollIntervalMs (default 5,000 ms). Each polled record stores userId
and lastSeen as plain fields alongside the encrypted payload — the storage-poll fallback trades
adapter-side user-ID privacy for working on any adapter, unlike the pub/sub path above.
API reference
| Method | Returns | Purpose |
|---|---|---|
db.push(vault, options?) | Promise<PushResult> | Send dirty local records to the vault's primary sync target. |
db.pull(vault, options?) | Promise<PullResult> | Apply newer remote records to the local store. |
db.sync(vault, options?) | Promise<{ pull: PullResult, push: PushResult }> | Pull then push against the primary target; also pushes to every 'backup'/'archive' target and syncs every other 'sync-peer'. |
db.syncStatus(vault) | SyncStatus | Current dirty count and last push/pull timestamps. |
collection.presence<P>(opts?) | PresenceHandle<P> | A presence channel scoped to this collection. opts: { staleMs?, pollIntervalMs? }. |
Options
| Option bag | Field | Meaning |
|---|---|---|
PushOptions | collections?: string[] | Push only these collections; omit to push everything dirty. |
PullOptions | collections?: string[] | Pull only these collections; omit to pull everything. |
modifiedSince?: string | Only pull records with _ts strictly after this ISO timestamp. | |
PresenceHandleOpts (via presence(opts)) | staleMs?: number | How long before a peer is considered gone. Default 30_000. |
pollIntervalMs?: number | Poll interval for the storage-poll fallback. Default 5_000. |
PresenceHandle<P>
| Method | Returns | Purpose |
|---|---|---|
update(payload: P) | Promise<void> | Encrypt and publish your presence payload. |
subscribe(cb: (peers: PresencePeer<P>[]) => void) | () => void | Register for peer updates; returns an unsubscribe function. |
stop() | void | Release the handle — stops polling/listening, clears subscribers. |
Types
PushResult—{ pushed: number, conflicts: Conflict[], errors: Error[], erasures: ErasureEnforcement[] }PullResult—{ pulled: number, conflicts: Conflict[], errors: Error[], erasures: ErasureEnforcement[] }SyncStatus—{ dirty: number, lastPush: string | null, lastPull: string | null, online: boolean }Conflict—{ vault, collection, id, local, remote, localVersion, remoteVersion, resolve? }—resolveis only present underconflictPolicy: 'manual'ErasureEnforcement—{ vault, collection, id, tombstone, suppressed, direction: 'pull' | 'push' }— reported whenever push/pull encounters a record erased with forget on one side and a live edit on the other; the tombstone always wins,suppressedcarries the envelope it overwrote (also emitted per enforcement as thesync:erasureevent). Empty ([]) unless a sync run actually re-asserts an erasure.PresencePeer<P>—{ userId: string, payload: P, lastSeen: string }SyncTarget—{ store, role: 'sync-peer' | 'backup' | 'archive', policy?, label? }ConflictStrategy(db-levelconflictoption, default'version') —'local-wins' | 'remote-wins' | 'version' | ((conflict: Conflict) => 'local' | 'remote')ConflictPolicy<T>(collection-levelconflictPolicyoption) —'last-writer-wins' | 'first-writer-wins' | 'manual' | ((local: T, remote: T) => T)
Lower-level exports
SyncEngine, SyncTransaction, and PresenceHandle — the classes withSync() constructs — are
also exported from @noy-db/hub/team for advanced consumers that want to construct them directly
instead of going through createNoydb / collection.presence().
Behavior when NOT opted in
- Configuring
sync: ...withoutsyncStrategy: withSync()throws on the firstopenVault()call, pointing at@noy-db/hub/sync. db.push(vault)/db.pull(vault)on a vault with nosynctarget configured throw ("No sync adapter configured. Pass a \sync` adapter to createNoydb()."`).collection.presence()always requiressyncStrategy: withSync()— even a vault with no remotesynctarget can use presence (it falls back to polling the local store), but without the strategy it throws.
Pairs well with
- crdt — sync defers to CRDT merge semantics for collections that opt in; conflicts
resolve inside the CRDT merge instead of the
conflict/conflictPolicystrategies above - team —
db.grant/db.revoke/db.rotatemanage vault membership independently of this service; multi-user vaults typically pair the two so every device can push/pull the shared collections - session — long-lived sessions suit always-on, frequently-syncing devices
Edge cases & limits
- Presence keys are derived from the collection DEK via HKDF — revoked users lose presence access on the next DEK rotation, with no change needed to the presence code itself.
- Real-time presence needs a store adapter that implements
presencePublish/presenceSubscribe; adapters that don't fall back to polling, trading latency (up topollIntervalMs) for working everywhere. db.push/db.pullonly touch the vault's primary target (the first'sync-peer', or the first target if none is a peer). Reach'backup'/'archive'targets viadb.sync(vault).- When the elected primary is not a
'sync-peer',db.pull()returns an empty result without touching the target — the role→direction policy applies to the primary the same way it always applied to the secondary fan-out. - Delete markers are how deletes converge, not how space is reclaimed — a heavily-deleting synced
vault should close periods and run
vault.freezePeriod()/vault.purgePeriodTargets()(periods) to physically purge converged markers.
See also
- Vaults & collections — the vault/collection model sync replicates
- Core 02 — Enclave — the encrypted envelope format sync moves as-is
- crdt · team · session — related services
- Services overview · Orientation map
- Proven by showcase
15-with-sync.