Collaborative app
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
| Service | What it adds |
|---|---|
withCrdt() from @noy-db/hub/crdt | Conflict-free merge for collections declared with crdt: 'lww-map', 'rga', or 'yjs' |
withSync() from @noy-db/hub/sync | db.push() / db.pull() replication against a remote store, plus collection.presence() |
withTeam() from @noy-db/hub/team | Multi-user access: db.grant() / db.revoke() / db.listUsers() |
withSession() from @noy-db/hub/session | sessionPolicy 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'.
| Value | Meaning |
|---|---|
'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',
})
| Value | Meaning |
|---|---|
'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) => T | a 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'andcrdt: 'rga'merge automatically inside the sync engine whenpull()hits a conflict. No writes are lost.crdt: 'yjs'cannot be merged by the sync engine (the core deliberately does not bundle theyjspackage), so a conflicting envelope falls back to higher-_v-wins. Converge in the client instead, by exchanging Yjs updates as shown above (Y.applyUpdateordocs.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 immediatepush().- 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 anupdate(). Callupdate()on a heartbeat if mere page-open should count as present. db.grant()withoutwithTeam()throwsTeamNotEnabledError;collection.presence()anddb.push()/db.pull()withoutwithSync()throw with a pointer at@noy-db/hub/sync.
See also
- crdt — the CRDT service page (modes, API reference)
- sync — replication, sync policies, conflict events
- team — grants, roles, revocation, key rotation
- session — session policies and timeouts
- Enclave — the key hierarchy (KEK, keyring, DEK)
14-with-crdt.showcase.test.ts— runnable Yjs interop walkthrough15-with-sync.showcase.test.ts— runnable push/pull walkthrough- Services — the full opt-in service catalog