crdt
| Subpath | @noy-db/hub/crdt |
| Factory | withCrdt() |
| Layer | with-commit |
What it does
When the same record is edited on two devices while both are offline, an ordinary last-write-wins store silently drops one of the edits. The crdt service replaces the record body with a CRDT (conflict-free replicated data type) — a data structure whose merge is deterministic and order-independent — so concurrent edits from two devices combine instead of overwriting each other. You pick a merge shape per collection:
lww-map— last-writer-wins per field: each field carries its own write timestamp, so device A's edit tostatusand device B's edit tobodyboth survive.rga— a replicated growable array for ordered lists: elements get stable ids, removals become tombstones, and concurrent inserts/removals union cleanly.yjs— full Yjs documents (collaborative rich text, maps, arrays) stored encrypted, via the companion package@noy-db/in-yjs.
The encrypted envelope wraps the CRDT state, not the resolved record — the storage
backend only ever sees ciphertext (see Core 02 — Enclave).
collection.get(id) returns the resolved snapshot; collection.getRaw(id) returns the
underlying state. The whole service costs ~221 lines of code and is off-bundle when you
don't opt in.
Prerequisites
- A
crdtshape declared per collection ('lww-map' | 'rga' | 'yjs') — the factory enables the machinery; each collection picks its merge shape. - For the
'yjs'shape: the companion package@noy-db/in-yjs. - For cross-device replication:
syncStrategy: withSync()plus a shared sync adapter both devices can reach — merging is local, replication is sync's job.
Opt in
Pass crdtStrategy: withCrdt() to createNoydb:
import { createNoydb } from '@noy-db/hub'
import { withCrdt } from '@noy-db/hub/crdt'
import { withSync } from '@noy-db/hub/sync'
import { toMemory } from '@noy-db/to-memory'
const db = await createNoydb({
store: toMemory(), // any NoydbStore works
user: 'alice',
secret: 'correct horse battery staple',
crdtStrategy: withCrdt(),
syncStrategy: withSync(), // only needed for cross-device replication
})
const vault = await db.openVault('travel')
Then declare the mode per collection:
interface Note { title?: string; body?: string; status?: string }
const notes = vault.collection<Note>('notes', { crdt: 'lww-map' })
Usage
Per-field merge with lww-map
In lww-map mode every field is an independent last-writer-wins register. A put may
carry only the fields it changes — fields absent from the new record keep their existing
value and timestamp:
await notes.put('n1', { title: 'Trip to Chiang Mai', body: 'First draft', status: 'draft' })
await notes.put('n1', { status: 'reviewed' }) // partial put — only this field advances
await notes.get('n1')
// → { status: 'reviewed', title: 'Trip to Chiang Mai', body: 'First draft' }
getRaw(id) exposes the stored state — each field with its value and the ISO timestamp
of its last write:
await notes.getRaw('n1')
{
"_crdt": "lww-map",
"fields": {
"status": { "v": "reviewed", "ts": "2026-07-08T09:31:12.844Z" },
"title": { "v": "Trip to Chiang Mai", "ts": "2026-07-08T09:31:12.802Z" },
"body": { "v": "First draft", "ts": "2026-07-08T09:31:12.802Z" }
}
}
With sync enabled, the same merge runs automatically when two devices push conflicting versions: the collection registers a CRDT conflict resolver with the sync engine, and for each field the entry with the higher timestamp wins.
const remote = toMemory() // the shared sync target both devices reach
const laptop = await createNoydb({
store: toMemory(), sync: remote, user: 'alice', encrypt: false,
crdtStrategy: withCrdt(), syncStrategy: withSync(),
})
const phone = await createNoydb({
store: toMemory(), sync: remote, user: 'alice', encrypt: false,
crdtStrategy: withCrdt(), syncStrategy: withSync(),
})
// encrypt: false — testing only, NO privacy guarantee: the stores hold plaintext.
// It keeps this two-instance example self-contained; moving keys between real
// devices is the team service's job (grants flow over sync).
const laptopNotes = (await laptop.openVault('travel'))
.collection<Note>('notes', { crdt: 'lww-map' })
const phoneNotes = (await phone.openVault('travel'))
.collection<Note>('notes', { crdt: 'lww-map' })
// The laptop writes the note and pushes it
await laptopNotes.put('n1', { title: 'Trip to Chiang Mai', body: 'First draft', status: 'draft' })
await laptop.push('travel')
// The phone pulls it, goes offline, and edits only `body`
await phone.pull('travel')
await phoneNotes.put('n1', { body: 'Phone draft — updated on the train' })
// Meanwhile the laptop — also offline — edits only `status`
await laptopNotes.put('n1', { status: 'reviewed' })
// Both come back online
await phone.push('travel')
await laptop.push('travel') // conflict detected → merged per field
await phone.pull('travel')
await laptopNotes.get('n1')
// → { title: 'Trip to Chiang Mai', body: 'Phone draft — updated on the train', status: 'reviewed' }
await phoneNotes.get('n1')
// → the same record — both devices converged, neither edit was lost
Ordered lists with rga
In rga mode the record is an array. Elements get stable ids (ULIDs) on first insertion;
writing the array without an element tombstones it instead of deleting it, so concurrent
removals and inserts from two devices union without duplicating or resurrecting items:
const tags = vault.collection<string[]>('tags', { crdt: 'rga' })
await tags.put('rec1', ['alpha', 'beta', 'gamma'])
await tags.put('rec1', ['alpha', 'gamma']) // remove 'beta' by writing the list without it
await tags.get('rec1')
// → ['alpha', 'gamma']
await tags.getRaw('rec1')
{
"_crdt": "rga",
"items": [
{ "nid": "01JZGJ3V9GQ2N9W3H4X5T6K7R8", "v": "alpha" },
{ "nid": "01JZGJ3V9SQ0F2B7C8D9E0A1B2", "v": "gamma" },
{ "nid": "01JZGJ3V9J4D6E8F0G2H4J6K8M", "v": "beta" }
],
"tombstones": ["01JZGJ3V9J4D6E8F0G2H4J6K8M"]
}
Tombstoned items stay in items to preserve ordering for cross-device merges; the
resolved snapshot filters them out. Re-inserting an equal value reuses its existing id,
so element identity is stable across devices.
Collaborative documents with yjs
For rich-text and structured collaborative editing, use
Yjs through the companion package @noy-db/in-yjs (requires yjs >= 13
as a peer dependency). yjsCollection wraps a crdt: 'yjs' collection with
getYDoc / putYDoc; the encrypted envelope stores the encoded Yjs update:
import * as Y from 'yjs'
import { yjsCollection, yText, yMap } from '@noy-db/in-yjs'
const docs = yjsCollection(vault, 'docs', {
yFields: { body: yText(), meta: yMap() },
})
const doc = await docs.getYDoc('doc-1')
doc.getText('body').insert(0, 'Hello')
doc.getMap('meta').set('author', 'alice')
await docs.putYDoc('doc-1', doc)
const reloaded = await docs.getYDoc('doc-1')
reloaded.getText('body').toString() // → 'Hello'
reloaded.getMap('meta').get('author') // → 'alice'
Two collaborators forking from the same baseline converge by exchanging Yjs updates — the standard Yjs contract, preserved through encrypted storage:
const docA = await docs.getYDoc('doc-1')
docA.getText('body').insert(docA.getText('body').length, ' from Alice')
const docB = await docs.getYDoc('doc-1')
docB.getText('body').insert(0, 'Hi! ')
Y.applyUpdate(docA, Y.encodeStateAsUpdate(docB))
Y.applyUpdate(docB, Y.encodeStateAsUpdate(docA))
docA.getText('body').toString() // → 'Hi! Hello from Alice'
docB.getText('body').toString() // → 'Hi! Hello from Alice' — converged
await docs.putYDoc('doc-1', docA)
API
withCrdt()
function withCrdt(): CrdtStrategy
Builds the active CRDT strategy. Pass it as crdtStrategy to createNoydb — this is the
only wiring step. Takes no options.
vault.collection(name, { crdt })
vault.collection<T>(name, { crdt: 'lww-map' | 'rga' | 'yjs' })
Declares the collection's CRDT mode at construction. T is the resolved snapshot type:
a record object for lww-map, an array for rga, and string (the base64-encoded Yjs
update) for yjs. When sync is also enabled, the collection registers
a CRDT conflict resolver with the sync engine automatically.
collection.getRaw(id)
collection.getRaw(id: string): Promise<CrdtState | null>
Returns the raw CRDT state for a record (null if absent). Only available on collections
declared with a crdt option — on a plain collection it throws. Use it to inspect merge
state or to feed external merge tooling; normal reads go through get(id), which returns
the resolved snapshot.
Merge helpers
The building blocks withCrdt() wires in are also exported for advanced use (custom
strategies, offline merge tooling):
| Export | Signature | Purpose |
|---|---|---|
buildLwwMapState | (record, previous, now: string) => LwwMapState | Fold a new record into an existing per-field state; fields absent from record are preserved. |
buildRgaState | (items, previous, idGen: () => string) => RgaState | Fold a new array into an existing RGA state; unmatched existing items are tombstoned. |
mergeCrdtStates | (local, remote) => CrdtState | Merge two concurrent states of the same mode. For lww-map, the higher ts wins per field (ties favor local); for rga, items and tombstones union. For yjs it returns local unchanged — core cannot merge Yjs bytes. |
resolveCrdtSnapshot | (state) => unknown | State → user-facing snapshot: field map for lww-map, live-item array for rga, base64 update string for yjs. |
import { mergeCrdtStates, resolveCrdtSnapshot } from '@noy-db/hub/crdt'
const merged = mergeCrdtStates(localState, remoteState)
resolveCrdtSnapshot(merged)
// → the converged record snapshot
Types
type CrdtMode = 'lww-map' | 'rga' | 'yjs'
type CrdtState = LwwMapState | RgaState | YjsState
interface LwwMapState {
_crdt: 'lww-map'
fields: Record<string, { v: unknown; ts: string }> // ts = ISO timestamp of last write
}
interface RgaState {
_crdt: 'rga'
items: Array<{ nid: string; v: unknown }> // nid = stable ULID per element
tombstones: string[] // nids of removed elements
}
interface YjsState {
_crdt: 'yjs'
update: string // base64(Y.encodeStateAsUpdate(doc))
}
@noy-db/in-yjs
The Yjs bridge is a separate package of the in- family (framework/library bindings —
see the in- family), with yjs >= 13 as a peer dependency:
| Export | Purpose |
|---|---|
yjsCollection(vault, name, { yFields }) | Create a YjsCollection wrapping a crdt: 'yjs' collection. |
YjsCollection.getYDoc(id) | Decode the stored update into a Y.Doc (fresh doc with declared yFields initialised if the record doesn't exist). |
YjsCollection.putYDoc(id, doc) | Encode the doc's state as a Yjs update and store it encrypted. |
YjsCollection.applyUpdate(id, update) | Read the current doc, apply a raw Uint8Array update, persist the merged state. |
YjsCollection.delete(id) / has(id) | Delete / existence check. |
yText() / yMap() / yArray() | Field descriptors declaring which Y.Text / Y.Map / Y.Array roots getYDoc initialises. |
| Types | YjsCollectionOptions, YjsSnapshot, YFieldDescriptor, YTextDescriptor, YMapDescriptor, YArrayDescriptor. |
Behavior when NOT opted in
- A collection declared with
crdt: '…'throws on its first CRDT operation:CRDT mode requires the CRDT strategy. Import { withCrdt } from "@noy-db/hub/crdt" and pass it to createNoydb({ crdtStrategy: withCrdt() }). - The sync engine's CRDT merge path throws the same way when a conflict reaches a CRDT collection.
- Plain (non-CRDT) collections work unchanged, and none of the ~221 LOC ships in your bundle.
Limits & gotchas
lww-maptimestamps are wall-clock. Each field'stsis the writing device's ISO timestamp, compared lexicographically; ties favor the local side during a sync merge. Significant clock skew between devices can make the "wrong" edit win a field — keep device clocks sane, or useyjsmode where merge is clock-independent.- Whole-field granularity in
lww-map. Two devices editing the same field concurrently still resolve to one winner — per-field LWW merges across fields, not within one. For character-level text merging useyjs. rgamatches elements by value. Existing items are matched to new array elements by deep equality, so equal duplicates share identity across writes. Tombstoned items are retained in the state to preserve ordering, so heavily-churned lists grow over time.- Core never merges Yjs bytes. On a sync conflict in
yjsmode, core keeps the envelope with the higher version — true Yjs merging happens in your app by exchanging updates (Y.applyUpdate, orYjsCollection.applyUpdate), as shown above. - Yjs state grows with edit history. The stored update encodes the document's full history; consider periodically writing a fresh doc for hot documents.
yjs >= 13is a peer dependency of@noy-db/in-yjs— your app installs it.
See also
- Services overview · Recipe — Real-time collaborative app
- sync — the replication engine that triggers CRDT merges on push/pull conflicts
- live — reactive subscriptions; CRDT-merged updates surface through live queries
- history — CRDT collections snapshot the prior version on every merge write
- Core 02 — Enclave — why the store only ever sees the encrypted CRDT state
- Proven by showcase
14-with-crdt.