npm package: @sovereignbase/convergent-replicated-struct
Description: State-based CRDT for fixed-key object structs with per-field overwrite tracking.
Latest version: 1.0.1

[![npm version](https://img.shields.io/npm/v/@sovereignbase/convergent-replicated-struct)](https://www.npmjs.com/package/@sovereignbase/convergent-replicated-struct)
[![CI](https://github.com/sovereignbase/convergent-replicated-struct/actions/workflows/ci.yaml/badge.svg?branch=master)](https://github.com/sovereignbase/convergent-replicated-struct/actions/workflows/ci.yaml)
[![codecov](https://codecov.io/gh/sovereignbase/convergent-replicated-struct/branch/master/graph/badge.svg)](https://codecov.io/gh/sovereignbase/convergent-replicated-struct)
[![license](https://img.shields.io/npm/l/@sovereignbase/convergent-replicated-struct)](LICENSE)

# convergent-replicated-struct

State-based CRDT for fixed-key object structs with per-field overwrite tracking.

## Compatibility

- Runtimes: Node >= 20; modern browsers; Bun; Deno; Cloudflare Workers; Edge Runtime.
- Module format: ESM + CommonJS.
- Required globals / APIs: `EventTarget`, `CustomEvent`, `structuredClone`.
- TypeScript: bundled types.

## Goals

- Fixed-key replica shape defined by a default struct.
- One visible value per field at any time.
- Malformed ingress is ignored during hydration and merge instead of crashing the replica.
- `read()`, `values()`, `entries()`, snapshots, deltas, and change payloads are detached with `structuredClone`.
- Explicit `acknowledge()` and `garbageCollect()` APIs for overwrite-history compaction.
- Consistent behavior across Node, browsers, and worker/edge runtimes.

## Installation

```sh
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-struct
```

## Usage

### Copy-paste example

```ts
import { OOStruct } from '@sovereignbase/convergent-replicated-struct'

type TodoStruct = {
  title: string
  count: number
  meta: { done: boolean }
  tags: string[]
}

const alice = OOStruct.create<TodoStruct>({
  title: '',
  count: 0,
  meta: { done: false },
  tags: [],
})
const bob = OOStruct.create<TodoStruct>({
  title: '',
  count: 0,
  meta: { done: false },
  tags: [],
})

alice.addEventListener('delta', (event) => bob.merge(event.detail))
alice.update('title', 'hello world')
alice.update('meta', { done: true })

console.log(bob.read('title')) // "hello world"
console.log(bob.read('meta')) // { done: true }
```

### Hydrating from a snapshot

```ts
import {
  OOStruct,
  type OOStructSnapshot,
} from '@sovereignbase/convergent-replicated-struct'

type DraftStruct = {
  title: string
  count: number
}

const source = new OOStruct<DraftStruct>({
  title: '',
  count: 0,
})
let snapshot!: OOStructSnapshot<DraftStruct>

source.addEventListener(
  'snapshot',
  (event) => {
    snapshot = event.detail
  },
  { once: true }
)

source.update('title', 'draft')
source.snapshot()

const restored = OOStruct.create<DraftStruct>(
  {
    title: '',
    count: 0,
  },
  snapshot
)

console.log(restored.entries()) // [['title', 'draft'], ['count', 0]]
```

### Event channels

```ts
import { OOStruct } from '@sovereignbase/convergent-replicated-struct'

const replica = new OOStruct({
  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)
})
```

### Acknowledgements and garbage collection

```ts
import {
  OOStruct,
  type OOStructAck,
} from '@sovereignbase/convergent-replicated-struct'

type CounterStruct = {
  title: string
  count: number
}

const left = new OOStruct<CounterStruct>({
  title: '',
  count: 0,
})
const right = new OOStruct<CounterStruct>({
  title: '',
  count: 0,
})

const frontiers: Array<OOStructAck<CounterStruct>> = []

left.addEventListener(
  'ack',
  (event) => {
    frontiers.push(event.detail)
  },
  { once: true }
)

right.addEventListener(
  'ack',
  (event) => {
    frontiers.push(event.detail)
  },
  { once: true }
)

left.acknowledge()
right.acknowledge()

left.garbageCollect(frontiers)
right.garbageCollect(frontiers)
```

## Runtime behavior

### Validation and errors

Local API misuse throws `OOStructError` with stable error codes:

- `DEFAULTS_NOT_CLONEABLE`
- `VALUE_NOT_CLONEABLE`
- `VALUE_TYPE_MISMATCH`

Hydration and merge are ingress-tolerant: malformed top-level payloads, unknown keys, malformed field entries, invalid UUIDs, invalid overwrite members, and mismatched runtime kinds are ignored instead of throwing.

### Safety and copying semantics

- Constructor defaults must be `structuredClone`-compatible.
- `read()`, `values()`, and `entries()` return detached clones.
- `delta`, `change`, and `snapshot` event payloads are detached from live state.
- `update()` stores a cloned value, so later caller-side mutation does not mutate replica state through shared references.

### Convergence and compaction

- The convergence guarantee is the resolved live struct state.
- Internal overwrite history may differ between replicas after acknowledgement-based garbage collection while the resolved live state still converges.
- `garbageCollect()` compacts overwritten identifiers that are below the smallest acknowledgement frontier for a key while preserving the active predecessor link.

## Tests

- Suite: unit, integration, and end-to-end runtime tests.
- Node test runner: `node --test` for unit and integration suites.
- Coverage: `c8` with 100% statements / branches / functions / lines on built `dist/**/*.js`.
- E2E runtimes: Node ESM, Node CJS, Bun ESM, Bun CJS, Deno ESM, Cloudflare Workers ESM, Edge Runtime ESM.
- Browser E2E: Chromium, Firefox, WebKit, mobile Chrome, mobile Safari via Playwright.
- Current status: `npm run test` passes on Node 22.14.0 (`win32 x64`).

## Benchmarks

How it was run:

```sh
node benchmark/bench.js
```

Environment: Node 22.14.0 (`win32 x64`)

| Benchmark                     | Result                    |
| ----------------------------- | ------------------------- |
| constructor empty             | 44,359 ops/s (2254.3 ms)  |
| constructor hydrate x64       | 19,610 ops/s (255.0 ms)   |
| constructor hydrate x256      | 8,088 ops/s (247.3 ms)    |
| constructor hydrate x1024     | 1,724 ops/s (290.0 ms)    |
| create() empty                | 49,874 ops/s (2005.1 ms)  |
| create() hydrate x256         | 6,886 ops/s (290.4 ms)    |
| read primitive                | 846,289 ops/s (236.3 ms)  |
| read object                   | 298,983 ops/s (668.9 ms)  |
| read array                    | 278,710 ops/s (717.6 ms)  |
| keys()                        | 32,349,896 ops/s (6.2 ms) |
| values()                      | 103,489 ops/s (966.3 ms)  |
| entries()                     | 110,300 ops/s (906.6 ms)  |
| snapshot()                    | 65,513 ops/s (305.3 ms)   |
| acknowledge()                 | 536,890 ops/s (93.1 ms)   |
| update string                 | 29,547 ops/s (1692.2 ms)  |
| update number                 | 30,591 ops/s (1634.5 ms)  |
| update object                 | 22,114 ops/s (2261.0 ms)  |
| update array                  | 24,763 ops/s (2019.1 ms)  |
| delete(key)                   | 8,352 ops/s (5986.8 ms)   |
| delete() reset all            | 6,836 ops/s (2925.5 ms)   |
| merge direct successor        | 32,541 ops/s (1536.5 ms)  |
| merge stale conflict          | 30,995 ops/s (645.3 ms)   |
| merge hydrate snapshot x256   | 5,748 ops/s (869.9 ms)    |
| merge noop duplicate          | 7,576 ops/s (6600.1 ms)   |
| garbageCollect() x512 history | 3,111 ops/s (1607.0 ms)   |
| add/remove listener roundtrip | 49,005 ops/s (4081.2 ms)  |
| update with listeners         | 25,120 ops/s (1194.3 ms)  |
| merge with listeners          | 31,649 ops/s (631.9 ms)   |

Results vary by machine, runtime version, and payload shape.

## License

Apache-2.0