stable

Collaborative app

Wire CRDT merge, cross-device push/pull replication, live presence, and multi-user grants onto an encrypted store so every device converges on the same result.

Audience: collaborative editors, live cursors, shared whiteboards, multi-device personal apps — two or more people editing the same documents at once, with every device converging on the same merged result and the server never seeing a word of the content. Bundle: core + withCrdt() + withSync() + withTeam() + withSession(). Live query updates (collection.query().live()) are part of the core — no opt-in needed.

Verified by: the runnable test showcases/src/recipe-realtime-crdt.recipe.test.ts in the documentation repository covers the Yjs getYDoc/putYDoc round-trip, concurrent-edit CRDT convergence via Y.applyUpdate, and the error thrown when withCrdt() is omitted. Push/pull replication and team grants (the sections below) are covered separately by showcases/src/15-with-sync.showcase.test.ts and showcases/src/06-multi-user.showcase.test.ts. Presence, conflict-resolution strategies, and session-policy enforcement are not exercised by an automated test in this repository.

noy-db is an encrypted document store: records are encrypted inside your app's process before they reach any storage backend, so the backend only ever holds ciphertext. This recipe adds the collaborative layer on top: CRDTs (conflict-free replicated data types — data structures where concurrent edits merge deterministically instead of overwriting each other), replication between devices via push()/pull(), live presence ("who else is looking at this?"), and multi-user access grants. If this is your first noy-db app, read Recipe 1 — Personal encrypted notebook first; this recipe builds on the same core.

What this gets you

ServiceWhat it adds
withCrdt() from @noy-db/hub/crdtConflict-free merge for collections declared with crdt: 'lww-map', 'rga', or 'yjs'
withSync() from @noy-db/hub/syncdb.push() / db.pull() replication against a remote store, plus collection.presence()
withTeam() from @noy-db/hub/teamMulti-user access: db.grant() / db.revoke() / db.listUsers()
withSession() from @noy-db/hub/sessionsessionPolicy enforcement: idle and absolute timeouts, lock-on-background
withHistory() from @noy-db/hub/history(Optional) an audit trail of every change — not used below

Reactive UI updates need no service at all: collection.query().live() returns a live query that re-runs on every mutation, and the framework bindings (@noy-db/in-react, @noy-db/in-vue, @noy-db/in-pinia, …) build on it.

Setup

Run this same setup on every device. The local store keeps the app offline-first; the sync target is the remote the devices replicate through — it only ever receives ciphertext.

import { createNoydb } from '@noy-db/hub'
import { withCrdt } from '@noy-db/hub/crdt'
import { withSync } from '@noy-db/hub/sync'
import { withTeam } from '@noy-db/hub/team'
import { withSession } from '@noy-db/hub/session'
import { toBrowserIdb } from '@noy-db/to-browser-idb'
import { toCloudflareR2 } from '@noy-db/to-cloudflare-r2'

// The shared remote — any store adapter works (S3, Postgres, a synced
// file directory, …). Cloudflare R2 shown here.
// Never put R2 keys in browser code. The callback fetches short-lived
// `kind: 'aws'` credentials from your own server, which holds the real keys;
// the store re-invokes it near `expiresAt`.
const remote = toCloudflareR2({
  accountId: 'your-cloudflare-account-id',
  bucket: 'collab-app',
  credentials: async () => (await fetch('/api/r2-credentials')).json(),
})

const db = await createNoydb({
  store: toBrowserIdb(),                // local, offline-first cache
  sync: { store: remote, role: 'sync-peer' }, // bidirectional push/pull
  user: 'alice',
  secret: 'correct-horse-battery-staple',  // secret — derives the encryption keys

  crdtStrategy: withCrdt(),
  syncStrategy: withSync(),
  teamStrategy: withTeam(),
  sessionStrategy: withSession(),

  sessionPolicy: {
    idleTimeoutMs: 15 * 60_000,        // lock after 15 min idle
    absoluteTimeoutMs: 8 * 60 * 60_000, // …and after 8 h regardless
    lockOnBackground: true,             // lock when the tab is backgrounded
  },
})

Each service is a tree-shakeable opt-in: if you skip a *Strategy entry, its code stays out of your bundle, and calling its surface throws an error that names the subpath to import.

Define a CRDT-backed collection

For rich-text collaboration, use the Yjs binding from @noy-db/in-yjs. yjsCollection() wraps a crdt: 'yjs' collection and stores each document as an encrypted Yjs update — the storage backend sees neither the text nor its structure.

import { yjsCollection, yText, yMap } from '@noy-db/in-yjs'

const vault = await db.openVault('shared')

const docs = yjsCollection(vault, 'docs', {
  yFields: { body: yText(), meta: yMap() },
})

const doc = await docs.getYDoc('doc-1')   // fresh empty Y.Doc if the record is new
doc.getText('body').insert(0, 'Hello, world')
doc.getMap('meta').set('author', 'alice')
await docs.putYDoc('doc-1', doc)

const reloaded = await docs.getYDoc('doc-1')
console.log(reloaded.getText('body').toString())
// → "Hello, world"
console.log(reloaded.getMap('meta').get('author'))
// → "alice"

Without withCrdt(), the first putYDoc() on a crdt: 'yjs' collection rejects with an error pointing at withCrdt() from @noy-db/hub/crdt — features you have not opted into fail loudly.

For structured (non-rich-text) records, the two other CRDT modes need no extra package: declare vault.collection<T>('name', { crdt: 'lww-map' }) (last-writer-wins per field) or { crdt: 'rga' } (replicated growable array).

Two collaborators edit concurrently — the merge converges

Two people fork the same document, edit independently, and exchange Yjs update buffers. The CRDT guarantees both end up with the identical merged text — no lost writes, no manual conflict dialog.

import * as Y from 'yjs'

// Seed a baseline both collaborators fork from.
const baseline = await docs.getYDoc('doc-shared')
baseline.getText('body').insert(0, 'Hello')
await docs.putYDoc('doc-shared', baseline)

// Alice — appends.
const docA = await docs.getYDoc('doc-shared')
docA.getText('body').insert(docA.getText('body').length, ' from Alice')

// Bob — independently prepends.
const docB = await docs.getYDoc('doc-shared')
docB.getText('body').insert(0, 'Hi! ')

// Merge by exchanging update buffers — the CRDT contract.
Y.applyUpdate(docA, Y.encodeStateAsUpdate(docB))
Y.applyUpdate(docB, Y.encodeStateAsUpdate(docA))

console.log(docA.getText('body').toString())
// → "Hi! Hello from Alice"
console.log(docA.getText('body').toString() === docB.getText('body').toString())
// → true

// Persist the merged state — it round-trips through encryption intact.
await docs.putYDoc('doc-shared', docA)

docs.applyUpdate(id, update) does the same merge against the stored state in one call: it loads the record, applies the incoming update buffer, and persists the merged result.

Replicate between devices — push and pull

Each device runs the setup above against the same remote. db.push(vault) uploads locally-changed records; db.pull(vault) downloads remote changes.

// Device A
const docA2 = await docs.getYDoc('doc-1')
docA2.getText('body').insert(0, 'Edited on device A. ')
await docs.putYDoc('doc-1', docA2)

// put() marks the record dirty on a microtask — let it settle before pushing.
await new Promise((resolve) => setTimeout(resolve, 0))

console.log(await db.push('shared'))
// → { pushed: 1, conflicts: [], errors: [] }
// Device B (same setup, same remote)
console.log(await db.pull('shared'))
// → { pulled: 1, conflicts: [], errors: [] }

const latest = await docs.getYDoc('doc-1')
console.log(latest.getText('body').toString())
// → "Edited on device A. Hello, world"

If both devices changed the same record before syncing, the conflicts array is non-empty and the conflict rules below decide the winner.

Live presence — see who else is here

collection.presence() (enabled by withSync()) gives you an encrypted ephemeral channel for awareness data — cursors, selections, "Bob is typing…".

const presence = vault
  .collection('docs')
  .presence<{ cursor: { x: number; y: number } }>()

// Broadcast my cursor (call again whenever it moves).
await presence.update({ cursor: { x: 120, y: 340 } })

// Subscribe to peers — the callback receives all active peers,
// excluding yourself and anyone stale (default: silent for 30 s).
const unsubscribe = presence.subscribe((peers) => {
  for (const peer of peers) {
    console.log(`${peer.userId}: cursor at`, peer.payload.cursor)
  }
})
// → "bob: cursor at { x: 512, y: 88 }"   (fires when bob calls update())

// When the document view unmounts:
unsubscribe()
presence.stop()

The presence channel key is derived (via HKDF) from the collection's DEK — the data-encryption key that encrypts the collection's records (see Enclave for the full key hierarchy). Two consequences:

  • The remote store never sees which user is doing what — presence payloads are ciphertext to it.
  • Revoked users automatically lose presence access on the next DEK rotation.

Transport adapts to the store: adapters that implement pub/sub (presencePublish / presenceSubscribe) get real-time delivery; all others fall back to polling a reserved _presence_<collection> collection (default poll interval: 5 s, tunable via presence({ pollIntervalMs, staleMs })).

Granting access to a collaborator

db.grant() (enabled by withTeam()) wraps the vault's keys for a new user, who then unlocks with their own secret.

await db.grant('shared', {
  userId: 'bob',
  displayName: 'Bob',
  role: 'operator',
  secret: 'a-secret-bob-will-change',
  permissions: { docs: 'rw' },   // per-collection: 'rw' or 'ro'
})

console.log((await db.listUsers('shared')).map((u) => u.userId))
// → ["alice", "bob"]

Roles are owner, admin, custodian, operator, viewer, client. db.grant() does not check the new secret's strength by default — it accepts whatever string you pass for secret. If your app needs a minimum-strength policy at invite time, validate the secret yourself before calling grant().

Bob unlocks the same vault with his own credentials:

const bobDb = await createNoydb({
  store: toBrowserIdb(),
  sync: { store: remote, role: 'sync-peer' },
  user: 'bob',
  secret: 'a-secret-bob-will-change',
  crdtStrategy: withCrdt(),
  syncStrategy: withSync(),
})

Secrets are only one unlock path — the @noy-db/on-* packages add WebAuthn, OIDC, magic links, TOTP, and more (see the on- family).

Conflicts — what wins when both sides changed?

Two knobs, at two levels. Both operate on the envelope — the single encrypted blob a record is stored as (see Stores); "higher version wins" etc. below means the envelope, not the decrypted fields.

Db-level conflict (type ConflictStrategy) applies to every collection unless overridden. Default: 'version'.

ValueMeaning
'version' (default)the envelope with the higher version counter _v wins
'local-wins'keep the local envelope
'remote-wins'keep the remote envelope
(conflict) => 'local' | 'remote'decide per conflict

Collection-level conflictPolicy (type ConflictPolicy<T>) overrides the db-level strategy for one collection:

interface Task {
  id: string
  title: string
  done: boolean
}

const tasks = vault.collection<Task>('tasks', {
  conflictPolicy: 'last-writer-wins',
})
ValueMeaning
'last-writer-wins'higher timestamp _ts wins
'first-writer-wins'lower version _v wins (the earlier write is preserved)
'manual'emits a sync:conflict event with a resolve(winner) callback — your UI decides
(local, remote) => Ta pure, synchronous custom merge over the decrypted records

CRDT collections resolve conflicts by merging instead of picking a winner — with one asymmetry:

  • crdt: 'lww-map' and crdt: 'rga' merge automatically inside the sync engine when pull() hits a conflict. No writes are lost.
  • crdt: 'yjs' cannot be merged by the sync engine (the core deliberately does not bundle the yjs package), so a conflicting envelope falls back to higher-_v-wins. Converge in the client instead, by exchanging Yjs updates as shown above (Y.applyUpdate or docs.applyUpdate(id, update)).

Limits & gotchas

  • put() marks records dirty on a microtask — pushing in the same tick as the write can miss it. Yield once (await new Promise((r) => setTimeout(r, 0))) before an immediate push().
  • Yjs merges happen in your app, not in the sync engine — plan for a "pull, merge, push" loop on each client (see above).
  • Presence is ephemeral by design: peers disappear after staleMs (default 30 s) without an update(). Call update() on a heartbeat if mere page-open should count as present.
  • db.grant() without withTeam() throws TeamNotEnabledError; collection.presence() and db.push()/db.pull() without withSync() throw with a pointer at @noy-db/hub/sync.

See also