Convergent Replicated Map (CR-Map), a delta CRDT for dynamic non-empty string-keyed maps.
Read the specification:
- Runtimes: Node >= 20, modern browsers, Bun, Deno, Cloudflare Workers, Edge Runtime.
- Module format: ESM + CommonJS.
- Required globals / APIs:
EventTarget,CustomEvent,structuredClone. - TypeScript: bundled types.
- Deterministic convergence of the live map projection under asynchronous gossip delivery.
- Consistent behavior across Node, browsers, worker, and edge runtimes.
- Garbage collection possibility without breaking live-view convergence.
- Event-driven API.
npm install @sovereignbase/convergent-replicated-map
# or
pnpm add @sovereignbase/convergent-replicated-map
# or
yarn add @sovereignbase/convergent-replicated-map
# or
bun add @sovereignbase/convergent-replicated-map
# or
deno add jsr:@sovereignbase/convergent-replicated-map
# or
vlt install jsr:@sovereignbase/convergent-replicated-mapimport { CRMap } from '@sovereignbase/convergent-replicated-map'
type EntryValue = {
label: string
version: number
}
const KEY_A = '019d81fd-a1e9-76dd-aaf0-f4dd2ac2accc'
const KEY_B = '019d81fd-a1ea-75cf-b513-f35976cefc93'
const left = new CRMap<EntryValue>()
const right = new CRMap<EntryValue>()
left.addEventListener('delta', (event) => {
right.merge(event.detail)
})
left.set(KEY_A, { label: 'alpha', version: 1 })
left.set(KEY_B, { label: 'beta', version: 1 })
console.log(right.get(KEY_A)?.label) // 'alpha'
console.log(right.entries())import {
CRMap,
type CRMapSnapshot,
} from '@sovereignbase/convergent-replicated-map'
type EntryValue = {
label: string
version: number
}
const KEY_A = '019d81fd-a1e9-76dd-aaf0-f4dd2ac2accc'
const source = new CRMap<EntryValue>()
let snapshot!: CRMapSnapshot<string, EntryValue>
source.addEventListener(
'snapshot',
(event) => {
snapshot = event.detail
},
{ once: true }
)
source.set(KEY_A, { label: 'draft', version: 1 })
source.snapshot()
const restored = new CRMap<EntryValue>(snapshot)
console.log(restored.get(KEY_A)?.label) // 'draft'import { CRMap } from '@sovereignbase/convergent-replicated-map'
const KEY_A = '019d81fd-a1e9-76dd-aaf0-f4dd2ac2accc'
const map = new CRMap<string>()
map.addEventListener('delta', (event) => {
console.log('delta', event.detail)
})
map.addEventListener('change', (event) => {
console.log('change', event.detail)
})
map.addEventListener('snapshot', (event) => {
console.log('snapshot', event.detail)
})
map.addEventListener('ack', (event) => {
console.log('ack', event.detail)
})
map.set(KEY_A, 'draft')
map.delete(KEY_A)
map.snapshot()
map.acknowledge()import { CRMap } from '@sovereignbase/convergent-replicated-map'
const KEY_A = '019d81fd-a1e9-76dd-aaf0-f4dd2ac2accc'
const KEY_B = '019d81fd-a1ea-75cf-b513-f35976cefc93'
const map = new CRMap<string>()
map.set(KEY_A, 'alpha')
map.set(KEY_B, 'beta')
const serialized = JSON.stringify(map)
const restored = new CRMap<string>(JSON.parse(serialized))
for (const [key, value] of map) {
console.log(key, value)
}
map.forEach((value, key, target) => {
console.log(key, value, target.size)
})
console.log(map.keys())
console.log(map.values())
console.log(map.entries())
console.log(restored.get(KEY_A)) // 'alpha'This example assumes your map values are JSON-compatible. For general
structuredClone-compatible values such as Date, Map, or BigInt, persist
snapshots with a structured-clone-capable store or an application-level codec
instead of plain JSON.stringify / JSON.parse.
get(), for...of, values(), entries(), and forEach() return detached
copies of visible values. Mutating those returned values does not mutate the
underlying replica state.
import { CRMap, type CRMapAck } from '@sovereignbase/convergent-replicated-map'
const KEY_A = '019d81fd-a1e9-76dd-aaf0-f4dd2ac2accc'
const left = new CRMap<string>()
const right = new CRMap<string>()
const frontiers = new Map<string, CRMapAck>()
left.addEventListener('delta', (event) => right.merge(event.detail))
right.addEventListener('delta', (event) => left.merge(event.detail))
left.addEventListener('ack', (event) => {
frontiers.set('left', event.detail)
})
right.addEventListener('ack', (event) => {
frontiers.set('right', event.detail)
})
left.set(KEY_A, 'draft')
left.set(KEY_A, 'published')
left.delete(KEY_A)
left.acknowledge()
right.acknowledge()
left.garbageCollect([...frontiers.values()])
right.garbageCollect([...frontiers.values()])If you need to build your own string-keyed CRDT binding instead of using the
high-level CRMap class, the package also exports the core CRUD and MAGS
functions together with the replica and payload types.
Those low-level exports let you build custom map abstractions, protocol
wrappers, or framework-specific bindings while preserving the same convergence
rules as the default CRMap binding.
import {
__create,
__update,
__merge,
__snapshot,
type CRMapDelta,
type CRMapSnapshot,
} from '@sovereignbase/convergent-replicated-map'
const KEY_A = '019d81fd-a1e9-76dd-aaf0-f4dd2ac2accc'
const replica = __create<string>()
const local = __update(KEY_A, 'draft', replica)
if (local) {
const outgoing: CRMapDelta<string, string> = local.delta
const remoteChange = __merge(outgoing, replica)
console.log(remoteChange)
}
const snapshot: CRMapSnapshot<string, string> = __snapshot(replica)
console.log(snapshot)The exports are grouped as follows:
__create,__read,__update,__deletefor local replica mutations.__merge,__acknowledge,__garbageCollect,__snapshotfor gossip, compaction, and serialization.CRMapwhen you want the default event-driven class API.
Low-level exports and invalid public keyed mutations can throw CRMapError
with stable error codes:
INVALID_KEYVALUE_NOT_CLONEABLE
Ingress stays tolerant:
- malformed top-level merge payloads are ignored
- malformed snapshot values are dropped during hydration
- invalid UUIDs, invalid keys, and malformed entries are ignored
- duplicate identical deltas are idempotent
- stale same-key contenders can trigger a reply delta instead of mutating live state
- Snapshots are serializable full-state payloads with
valuesandtombstones. - Deltas are serializable partial snapshot payloads with
valuesandtombstones. changeis a minimal key-indexed visible patch where deleted keys map toundefined.toJSON()returns a detached serializable snapshot.JSON.stringify()andtoString()are only reliable when map values are JSON-compatible.get(),for...of,values(),entries(), andforEach()expose detached copies of visible values rather than mutable references into replica state.keys(),set(),delete(),clear(),merge(),snapshot(),acknowledge(), andgarbageCollect()all operate on the live map projection.
- The convergence target is the visible map projection, not identical internal tombstone sets.
- Same-key conflict resolution follows this order:
- a direct descendant wins
- the same UUID can advance via a larger predecessor
- otherwise the larger UUIDv7 wins
- When local state already dominates an incoming contender, merge emits a reply delta instead of silently discarding that information.
- Tombstones remain until acknowledgement frontiers make them safe to collect.
- Garbage collection compacts tombstones below the smallest valid acknowledgement frontier while preserving active predecessor links.
npm run testWhat the current test suite covers:
- Coverage on built
dist/**/*.js:100%statements,100%branches,100%functions, and100%lines viac8. - Public
CRMapsurface: constructor,get(),has(),set(),delete(),clear(), iteration,forEach(), events, and JSON / inspect behavior. - Core edge paths and hostile ingress handling for
__create,__read,__update,__delete,__merge,__snapshot,__acknowledge, and__garbageCollect. - Snapshot hydration edge cases for duplicate key contenders, tombstoned entries, malformed values, and stale helper-index cleanup.
- Integration convergence stress for:
- local CRUD live-view semantics
- snapshot hydration independent of entry order
- merge idempotency for duplicate deltas
- acknowledgement and garbage collection recovery
- deterministic shuffled snapshot exchange
- queued delta delivery with reply deltas and replica restarts
25aggressive deterministic convergence scenarios
- End-to-end runtime matrix for:
- Node ESM
- Node CJS
- Bun ESM
- Bun CJS
- Deno ESM
- Cloudflare Workers ESM
- Edge Runtime ESM
- Browsers via Playwright: Chromium, Firefox, WebKit, mobile Chrome, mobile Safari
- Current status:
npm run testpasses on Nodev22.14.0(win32 x64).
npm run benchLast measured on Node v22.14.0 (win32 x64):
| group | scenario | n | ops | ms | ms/op | ops/sec |
|---|---|---|---|---|---|---|
crud |
create / hydrate snapshot |
5,000 | 250 | 18,018.35 | 72.07 | 13.87 |
crud |
read / primitive key hit |
5,000 | 250 | 0.66 | 0.00 | 380,575.43 |
crud |
read / object key hit |
5,000 | 250 | 1.41 | 0.01 | 177,468.59 |
crud |
read / missing key |
5,000 | 250 | 0.83 | 0.00 | 300,516.89 |
crud |
update / overwrite string |
5,000 | 250 | 3.50 | 0.01 | 71,332.78 |
crud |
update / overwrite object |
5,000 | 250 | 23.60 | 0.09 | 10,594.43 |
crud |
delete / single key |
5,000 | 250 | 6.66 | 0.03 | 37,509.94 |
crud |
delete / clear all |
5,000 | 250 | 1,118.90 | 4.48 | 223.43 |
mags |
snapshot |
5,000 | 250 | 8,610.49 | 34.44 | 29.03 |
mags |
acknowledge |
5,000 | 250 | 1,557.81 | 6.23 | 160.48 |
mags |
garbage collect |
5,000 | 250 | 679.80 | 2.72 | 367.75 |
mags |
merge ordered deltas |
5,000 | 250 | 9.36 | 0.04 | 26,708.55 |
mags |
merge direct successor |
5,000 | 250 | 3.77 | 0.02 | 66,256.76 |
mags |
merge shuffled gossip |
5,000 | 250 | 17.35 | 0.07 | 14,408.64 |
mags |
merge stale conflict |
5,000 | 250 | 6.17 | 0.02 | 40,548.21 |
class |
constructor / hydrate snapshot |
5,000 | 250 | 17,015.31 | 68.06 | 14.69 |
class |
get / primitive key |
5,000 | 250 | 1.46 | 0.01 | 171,797.69 |
class |
get / object key |
5,000 | 250 | 2.61 | 0.01 | 95,928.78 |
class |
has / live key |
5,000 | 250 | 0.54 | 0.00 | 464,338.78 |
class |
keys() |
5,000 | 250 | 13.49 | 0.05 | 18,538.16 |
class |
values() |
5,000 | 250 | 6,861.52 | 27.45 | 36.44 |
class |
entries() |
5,000 | 250 | 6,839.57 | 27.36 | 36.55 |
class |
set / string |
5,000 | 250 | 5.46 | 0.02 | 45,782.51 |
class |
set / object |
5,000 | 250 | 9.07 | 0.04 | 27,556.71 |
class |
delete(key) |
5,000 | 250 | 4.41 | 0.02 | 56,695.77 |
class |
clear() |
5,000 | 250 | 889.96 | 3.56 | 280.91 |
class |
snapshot |
5,000 | 250 | 7,679.73 | 30.72 | 32.55 |
class |
acknowledge |
5,000 | 250 | 1,592.53 | 6.37 | 156.98 |
class |
garbage collect |
5,000 | 250 | 987.88 | 3.95 | 253.07 |
class |
merge ordered deltas |
5,000 | 250 | 37.22 | 0.15 | 6,716.31 |
class |
merge direct successor |
5,000 | 250 | 5.78 | 0.02 | 43,232.40 |
class |
merge shuffled gossip |
5,000 | 250 | 14.78 | 0.06 | 16,920.24 |
Apache-2.0