Convergent Replicated Struct (CR-Struct), a delta CRDT for an fixed-key object structs.
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 struct 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-struct
# or
pnpm add @sovereignbase/convergent-replicated-struct
# or
yarn add @sovereignbase/convergent-replicated-struct
# or
bun add @sovereignbase/convergent-replicated-struct
# or
deno add jsr:@sovereignbase/convergent-replicated-struct
# or
vlt install jsr:@sovereignbase/convergent-replicated-structimport {
CRStruct,
type CRStructSnapshot,
} from '@sovereignbase/convergent-replicated-struct'
type MetaStruct = {
done: boolean
}
type TodoStruct = {
title: string
count: number
meta: CRStructSnapshot<MetaStruct>
tags: string[]
}
const aliceMeta = new CRStruct<MetaStruct>({ done: false })
const alice = new CRStruct<TodoStruct>({
title: '',
count: 0,
meta: aliceMeta.toJSON(),
tags: [],
})
const bobMeta = new CRStruct<MetaStruct>({ done: false })
const bob = new CRStruct<TodoStruct>({
title: '',
count: 0,
meta: bobMeta.toJSON(),
tags: [],
})
alice.addEventListener('delta', (event) => {
bob.merge(event.detail)
})
bob.addEventListener('change', (event) => {
if (event.detail.meta) bobMeta.merge(event.detail.meta)
})
aliceMeta.done = true
alice.title = 'hello world'
alice.meta = aliceMeta.toJSON()
console.log(bob.title) // 'hello world'
console.log(bobMeta.done) // trueimport {
CRStruct,
type CRStructSnapshot,
} from '@sovereignbase/convergent-replicated-struct'
type DraftStruct = {
title: string
count: number
}
const source = new CRStruct<DraftStruct>({
title: '',
count: 0,
})
let snapshot!: CRStructSnapshot<DraftStruct>
source.addEventListener('snapshot', (event) => {
localStorage.setItem('snapshot', JSON.stringify(event.detail))
})
source.title = 'draft'
source.snapshot()
const restored = new CRStruct<DraftStruct>(
{ title: '', count: 0 },
JSON.parse(localStorage.getItem('snapshot'))
)
console.log(restored.entries()) // [['title', 'draft'], ['count', 0]]This localStorage example assumes your field 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.
import { CRStruct } from '@sovereignbase/convergent-replicated-struct'
const replica = new CRStruct({
name: '',
count: 0,
})
replica.addEventListener('delta', (event) => {
console.log('delta', event.detail)
})
replica.addEventListener('change', (event) => {
console.log('change', event.detail)
})
replica.addEventListener('ack', (event) => {
console.log('ack', event.detail)
})
replica.addEventListener('snapshot', (event) => {
console.log('snapshot', event.detail)
})
replica.name = 'alice'
delete replica.name
replica.snapshot()
replica.acknowledge()import { CRStruct } from '@sovereignbase/convergent-replicated-struct'
const struct = new CRStruct({
givenName: '',
familyName: '',
})
struct.givenName = 'Jori'
struct.familyName = 'Lehtinen'
for (const key in struct) console.log(key)
for (const [key, val] of struct) console.log(key, val)
console.log(struct.keys())
console.log(struct.values())
console.log(struct.entries())
console.log(struct.clone())Direct property reads, for...of, values(), entries(), and clone()
return detached copies of visible values. Mutating those returned values does
not mutate the underlying replica state.
import {
CRStruct,
type CRStructAck,
} from '@sovereignbase/convergent-replicated-struct'
type CounterStruct = {
title: string
count: number
}
const alice = new CRStruct<CounterStruct>({
title: '',
count: 0,
})
const bob = new CRStruct<CounterStruct>({
title: '',
count: 0,
})
const frontiers = new Map<string, CRStructAck<CounterStruct>>()
alice.addEventListener('delta', (event) => {
bob.merge(event.detail)
})
bob.addEventListener('delta', (event) => {
alice.merge(event.detail)
})
alice.addEventListener('ack', (event) => {
frontiers.set('alice', event.detail)
})
bob.addEventListener('ack', (event) => {
frontiers.set('bob', event.detail)
})
alice.title = 'x'
alice.title = 'y'
delete alice.title
alice.acknowledge()
bob.acknowledge()
alice.garbageCollect([...frontiers.values()])
bob.garbageCollect([...frontiers.values()])If you need to build your own fixed-key CRDT binding instead of using the
high-level CRStruct 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 struct abstractions, protocol
wrappers, or framework-specific bindings while preserving the same convergence
rules as the default CRStruct binding.
import {
__create,
__update,
__merge,
__snapshot,
type CRStructDelta,
type CRStructSnapshot,
} from '@sovereignbase/convergent-replicated-struct'
type DraftStruct = {
title: string
count: number
}
const defaults: DraftStruct = {
title: '',
count: 0,
}
const source = __create(defaults)
const target = __create(defaults)
const local = __update('title', 'draft', source)
if (local) {
const outgoing: CRStructDelta<DraftStruct> = local.delta
const remoteChange = __merge(outgoing, target)
console.log(remoteChange)
}
const snapshot: CRStructSnapshot<DraftStruct> = __snapshot(target)
console.log(snapshot)The intended split is:
__create,__read,__update,__deletefor local replica mutations.__merge,__acknowledge,__garbageCollect,__snapshotfor gossip, compaction, and serialization.CRStructwhen you want the default event-driven class API.
Low-level exports and invalid public field writes can throw CRStructError
with stable error codes:
DEFAULTS_NOT_CLONEABLEVALUE_NOT_CLONEABLEVALUE_TYPE_MISMATCH
Ingress stays tolerant:
- malformed top-level merge payloads are ignored
- malformed snapshot values are dropped during hydration
- unknown keys are ignored
- invalid UUIDs and malformed field entries are ignored
- mismatched runtime kinds do not break live-state convergence
- Snapshots are detached structured-clone payloads keyed by field name.
- Deltas are detached structured-clone gossip payloads keyed by field name.
changeis a minimal field-keyed visible patch.toJSON()returns a detached structured-clone snapshot.JSON.stringify()andtoString()are only reliable when field values are JSON-compatible.- Direct property reads,
for...of,values(),entries(), andclone()expose detached copies of visible values rather than mutable references into replica state. - Property assignment,
delete,clear(),merge(),snapshot(),acknowledge(), andgarbageCollect()all operate on the live struct projection.
- The convergence target is the live struct projection, not identical internal tombstone sets.
- Tombstones remain until acknowledgement frontiers make them safe to collect.
- Garbage collection compacts overwritten identifiers below the smallest valid acknowledgement frontier for a field while preserving the active predecessor link.
- Internal overwrite history may differ between replicas after acknowledgement-based garbage collection while the resolved live struct still converges.
npm run testWhat the current test suite covers:
- Coverage on built
dist/**/*.js:100%statements,100%branches,100%functions, and100%lines viac8. - Public
CRStructsurface: proxy property access, deletes,clear(), iteration, events, and JSON / inspect behavior. - Core edge paths and hostile ingress handling for
__create,__read,__update,__delete,__merge,__snapshot,__acknowledge, and__garbageCollect. - Snapshot hydration independent of field order, acknowledgement and garbage collection recovery, and deterministic multi-replica gossip 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 | 714.80 | 2.86 | 349.75 |
crud |
read / primitive field |
5,000 | 250 | 0.55 | 0.00 | 450,531.63 |
crud |
read / object field |
5,000 | 250 | 0.83 | 0.00 | 301,568.15 |
crud |
update / overwrite string |
5,000 | 250 | 5.77 | 0.02 | 43,291.54 |
crud |
update / overwrite object |
5,000 | 250 | 4.79 | 0.02 | 52,198.61 |
crud |
delete / reset single field |
5,000 | 250 | 3.67 | 0.01 | 68,162.61 |
crud |
delete / reset all fields |
5,000 | 250 | 18.86 | 0.08 | 13,253.95 |
mags |
snapshot |
5,000 | 250 | 7.80 | 0.03 | 32,062.38 |
mags |
acknowledge |
5,000 | 250 | 39.72 | 0.16 | 6,294.04 |
mags |
garbage collect |
5,000 | 250 | 260.93 | 1.04 | 958.12 |
mags |
merge ordered deltas |
5,000 | 250 | 204.53 | 0.82 | 1,222.32 |
mags |
merge direct successor |
5,000 | 250 | 1.46 | 0.01 | 171,385.48 |
mags |
merge shuffled gossip |
5,000 | 250 | 263.91 | 1.06 | 947.29 |
mags |
merge stale conflict |
5,000 | 250 | 2.11 | 0.01 | 118,315.19 |
class |
constructor / hydrate snapshot |
5,000 | 250 | 781.32 | 3.13 | 319.97 |
class |
property read / primitive |
5,000 | 250 | 0.45 | 0.00 | 559,659.73 |
class |
property read / object |
5,000 | 250 | 0.95 | 0.00 | 262,687.82 |
class |
property write / string |
5,000 | 250 | 5.04 | 0.02 | 49,613.02 |
class |
property write / object |
5,000 | 250 | 8.57 | 0.03 | 29,157.24 |
class |
delete property |
5,000 | 250 | 4.80 | 0.02 | 52,128.95 |
class |
clear() |
5,000 | 250 | 15.35 | 0.06 | 16,283.14 |
class |
snapshot |
5,000 | 250 | 9.49 | 0.04 | 26,356.29 |
class |
acknowledge |
5,000 | 250 | 45.49 | 0.18 | 5,495.59 |
class |
garbage collect |
5,000 | 250 | 162.70 | 0.65 | 1,536.53 |
class |
merge ordered deltas |
5,000 | 250 | 193.20 | 0.77 | 1,293.98 |
class |
merge direct successor |
5,000 | 250 | 2.90 | 0.01 | 86,331.93 |
class |
merge shuffled gossip |
5,000 | 250 | 264.43 | 1.06 | 945.44 |
Apache-2.0