stable

sync

Replication engine — dirty tracking, push/pull between a local store and remote targets, conflict resolution — plus live presence over an encrypted channel.
Subpath@noy-db/hub/sync
FactorywithSync()
Layerwith-sync
ClusterG — 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 (or sync: without the strategy) throws on the first openVault().
  • 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.

Declaring 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 },
  },
})
HalfModes
pushmanual · on-change (after every local write, respecting minIntervalMs) · debounce (after debounceMs of quiet, default 30_000) · interval
pullmanual · 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()
StateWhat 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' — and PullResult.errors accumulates without throwing, so a partial failure is invisible to a try/catch. So does a pull that throws, and one skipped by the role gate on a backup/archive target. Completeness is never claimed by a target that never read.
  • A collection the sequence never names is absent from the map. undefined means "no claim made", which is never a reason to gate a UI.
  • phase is { index, total } (1-based) while the sequence runs and null once it drains. Both readiness and phase are absent entirely for non-phased policies.
Rungs 3 and 4 — 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

MethodReturnsPurpose
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)SyncStatusCurrent 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 bagFieldMeaning
PushOptionscollections?: string[]Push only these collections; omit to push everything dirty.
PullOptionscollections?: string[]Pull only these collections; omit to pull everything.
modifiedSince?: stringOnly pull records with _ts strictly after this ISO timestamp.
PresenceHandleOpts (via presence(opts))staleMs?: numberHow long before a peer is considered gone. Default 30_000.
pollIntervalMs?: numberPoll interval for the storage-poll fallback. Default 5_000.

PresenceHandle<P>

MethodReturnsPurpose
update(payload: P)Promise<void>Encrypt and publish your presence payload.
subscribe(cb: (peers: PresencePeer<P>[]) => void)() => voidRegister for peer updates; returns an unsubscribe function.
stop()voidRelease 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? } — resolve is only present under conflictPolicy: '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, suppressed carries the envelope it overwrote (also emitted per enforcement as the sync:erasure event). 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-level conflict option, default 'version') — 'local-wins' | 'remote-wins' | 'version' | ((conflict: Conflict) => 'local' | 'remote')
  • ConflictPolicy<T> (collection-level conflictPolicy option) — '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: ... without syncStrategy: withSync() throws on the first openVault() call, pointing at @noy-db/hub/sync.
  • db.push(vault) / db.pull(vault) on a vault with no sync target configured throw ("No sync adapter configured. Pass a \sync` adapter to createNoydb()."`).
  • collection.presence() always requires syncStrategy: withSync() — even a vault with no remote sync target 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/conflictPolicy strategies above
  • team — db.grant/db.revoke/db.rotate manage 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 to pollIntervalMs) for working everywhere.
  • db.push/db.pull only touch the vault's primary target (the first 'sync-peer', or the first target if none is a peer). Reach 'backup'/'archive' targets via db.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