stable

crdt

Conflict-free replicated types: declare `crdt: 'lww-map' | 'rga' | 'yjs'` per collection and concurrent edits merge deterministically.
Subpath@noy-db/hub/crdt
FactorywithCrdt()
Layerwith-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 to status and device B's edit to body both 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 crdt shape 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):

ExportSignaturePurpose
buildLwwMapState(record, previous, now: string) => LwwMapStateFold a new record into an existing per-field state; fields absent from record are preserved.
buildRgaState(items, previous, idGen: () => string) => RgaStateFold a new array into an existing RGA state; unmatched existing items are tombstoned.
mergeCrdtStates(local, remote) => CrdtStateMerge 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) => unknownState → 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:

ExportPurpose
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.
TypesYjsCollectionOptions, 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-map timestamps are wall-clock. Each field's ts is 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 use yjs mode 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 use yjs.
  • rga matches 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 yjs mode, core keeps the envelope with the higher version — true Yjs merging happens in your app by exchanging updates (Y.applyUpdate, or YjsCollection.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 >= 13 is a peer dependency of @noy-db/in-yjs — your app installs it.

See also