@sovereignbase/convergent-replicated-struct 1.0.1 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,27 +1,25 @@
1
- [![npm version](https://img.shields.io/npm/v/@sovereignbase/convergent-replicated-struct)](https://www.npmjs.com/package/@sovereignbase/convergent-replicated-struct)
1
+ [![npm version](https://img.shields.io/npm/v/@sovereignbase/convergent-replicated-list)](https://www.npmjs.com/package/@sovereignbase/convergent-replicated-struct)
2
2
  [![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)
3
3
  [![codecov](https://codecov.io/gh/sovereignbase/convergent-replicated-struct/branch/master/graph/badge.svg)](https://codecov.io/gh/sovereignbase/convergent-replicated-struct)
4
4
  [![license](https://img.shields.io/npm/l/@sovereignbase/convergent-replicated-struct)](LICENSE)
5
5
 
6
6
  # convergent-replicated-struct
7
7
 
8
- State-based CRDT for fixed-key object structs with per-field overwrite tracking.
8
+ Convergent Replicated Struct (CR-Struct), a delta CRDT for an fixed-key object structs.
9
9
 
10
10
  ## Compatibility
11
11
 
12
- - Runtimes: Node >= 20; modern browsers; Bun; Deno; Cloudflare Workers; Edge Runtime.
12
+ - Runtimes: Node >= 20, modern browsers, Bun, Deno, Cloudflare Workers, Edge Runtime.
13
13
  - Module format: ESM + CommonJS.
14
14
  - Required globals / APIs: `EventTarget`, `CustomEvent`, `structuredClone`.
15
15
  - TypeScript: bundled types.
16
16
 
17
17
  ## Goals
18
18
 
19
- - Fixed-key replica shape defined by a default struct.
20
- - One visible value per field at any time.
21
- - Malformed ingress is ignored during hydration and merge instead of crashing the replica.
22
- - `read()`, `values()`, `entries()`, snapshots, deltas, and change payloads are detached with `structuredClone`.
23
- - Explicit `acknowledge()` and `garbageCollect()` APIs for overwrite-history compaction.
24
- - Consistent behavior across Node, browsers, and worker/edge runtimes.
19
+ - Deterministic convergence of the live struct projection under asynchronous gossip delivery.
20
+ - Consistent behavior across Node, browsers, worker, and edge runtimes.
21
+ - Garbage collection possibility without breaking live-view convergence.
22
+ - Event-driven API
25
23
 
26
24
  ## Installation
27
25
 
@@ -44,42 +42,59 @@ vlt install jsr:@sovereignbase/convergent-replicated-struct
44
42
  ### Copy-paste example
45
43
 
46
44
  ```ts
47
- import { OOStruct } from '@sovereignbase/convergent-replicated-struct'
45
+ import {
46
+ CRStruct,
47
+ type CRStructSnapshot,
48
+ } from '@sovereignbase/convergent-replicated-struct'
49
+
50
+ type MetaStruct = {
51
+ done: boolean
52
+ }
48
53
 
49
54
  type TodoStruct = {
50
55
  title: string
51
56
  count: number
52
- meta: { done: boolean }
57
+ meta: CRStructSnapshot<MetaStruct>
53
58
  tags: string[]
54
59
  }
55
60
 
56
- const alice = OOStruct.create<TodoStruct>({
61
+ const aliceMeta = new CRStruct<MetaStruct>({done: false})
62
+
63
+ const alice = new CRStruct<TodoStruct>({
57
64
  title: '',
58
65
  count: 0,
59
- meta: { done: false },
66
+ meta: aliceMeta.toJSON()
60
67
  tags: [],
61
68
  })
62
- const bob = OOStruct.create<TodoStruct>({
69
+
70
+ const bobMeta = new CRStruct<MetaStruct>({done: false})
71
+
72
+ const bob = new CRStruct<TodoStruct>({
63
73
  title: '',
64
74
  count: 0,
65
- meta: { done: false },
75
+ meta: bobMeta.toJSON()
66
76
  tags: [],
67
77
  })
68
78
 
69
- alice.addEventListener('delta', (event) => bob.merge(event.detail))
70
- alice.update('title', 'hello world')
71
- alice.update('meta', { done: true })
79
+ alice.addEventListener('delta', (event) => {
80
+ bob.merge(event.detail)
81
+ })
82
+
83
+ aliceMeta.done = true
84
+
85
+ alice.title = 'hello world'
86
+ alice.meta = aliceMeta.toJSON()
72
87
 
73
- console.log(bob.read('title')) // "hello world"
74
- console.log(bob.read('meta')) // { done: true }
88
+ console.log(bob.title) // 'hello world'
89
+ console.log(bobMeta.done) // true
75
90
  ```
76
91
 
77
92
  ### Hydrating from a snapshot
78
93
 
79
94
  ```ts
80
95
  import {
81
- OOStruct,
82
- type OOStructSnapshot,
96
+ CRStruct,
97
+ type CRStructSnapshot,
83
98
  } from '@sovereignbase/convergent-replicated-struct'
84
99
 
85
100
  type DraftStruct = {
@@ -87,29 +102,22 @@ type DraftStruct = {
87
102
  count: number
88
103
  }
89
104
 
90
- const source = new OOStruct<DraftStruct>({
105
+ const source = new CRStruct<DraftStruct>({
91
106
  title: '',
92
107
  count: 0,
93
108
  })
94
- let snapshot!: OOStructSnapshot<DraftStruct>
95
-
96
- source.addEventListener(
97
- 'snapshot',
98
- (event) => {
99
- snapshot = event.detail
100
- },
101
- { once: true }
102
- )
109
+ let snapshot!: CRStructSnapshot<DraftStruct>
110
+
111
+ source.addEventListener('snapshot', (event) => {
112
+ localStorage.setItem('snapshot', JSON.stringify(event.detail))
113
+ })
103
114
 
104
- source.update('title', 'draft')
115
+ source.title = 'draft'
105
116
  source.snapshot()
106
117
 
107
- const restored = OOStruct.create<DraftStruct>(
108
- {
109
- title: '',
110
- count: 0,
111
- },
112
- snapshot
118
+ const restored = new CRStruct<DraftStruct>(
119
+ { title: '', count: 0 },
120
+ JSON.parse(localStorage.getItem('snapshot'))
113
121
  )
114
122
 
115
123
  console.log(restored.entries()) // [['title', 'draft'], ['count', 0]]
@@ -118,9 +126,9 @@ console.log(restored.entries()) // [['title', 'draft'], ['count', 0]]
118
126
  ### Event channels
119
127
 
120
128
  ```ts
121
- import { OOStruct } from '@sovereignbase/convergent-replicated-struct'
129
+ import { CRStruct } from '@sovereignbase/convergent-replicated-struct'
122
130
 
123
- const replica = new OOStruct({
131
+ const replica = new CRStruct({
124
132
  name: '',
125
133
  count: 0,
126
134
  })
@@ -140,14 +148,44 @@ replica.addEventListener('ack', (event) => {
140
148
  replica.addEventListener('snapshot', (event) => {
141
149
  console.log('snapshot', event.detail)
142
150
  })
151
+
152
+ replica.name = 'alice'
153
+ delete replica.name
154
+ replica.snapshot()
155
+ replica.acknowledge()
143
156
  ```
144
157
 
158
+ ### Iteration and JSON serialization
159
+
160
+ ```ts
161
+ import { CRStruct } from '@sovereignbase/convergent-replicated-struct'
162
+
163
+ const struct = new CRStruct({
164
+ givenName: '',
165
+ familyName: '',
166
+ })
167
+
168
+ struct.givenName = 'Jori'
169
+ struct.familyName = 'Lehtinen'
170
+
171
+ for (const key in struct) console.log(key)
172
+ for (const [key, val] of struct) console.log(key, val)
173
+ console.log(struct.keys())
174
+ console.log(struct.values())
175
+ console.log(struct.entries())
176
+ console.log(struct.clone())
177
+ ```
178
+
179
+ Direct property reads, `for...of`, `values()`, `entries()`, and `clone()`
180
+ return detached copies of visible values. Mutating those returned values does
181
+ not mutate the underlying replica state.
182
+
145
183
  ### Acknowledgements and garbage collection
146
184
 
147
185
  ```ts
148
186
  import {
149
- OOStruct,
150
- type OOStructAck,
187
+ CRStruct,
188
+ type CRStructAck,
151
189
  } from '@sovereignbase/convergent-replicated-struct'
152
190
 
153
191
  type CounterStruct = {
@@ -155,116 +193,209 @@ type CounterStruct = {
155
193
  count: number
156
194
  }
157
195
 
158
- const left = new OOStruct<CounterStruct>({
196
+ const alice = new CRStruct<CounterStruct>({
159
197
  title: '',
160
198
  count: 0,
161
199
  })
162
- const right = new OOStruct<CounterStruct>({
200
+ const bob = new CRStruct<CounterStruct>({
163
201
  title: '',
164
202
  count: 0,
165
203
  })
166
204
 
167
- const frontiers: Array<OOStructAck<CounterStruct>> = []
205
+ const frontiers = new Map<string, CRStructAck<CounterStruct>>()
168
206
 
169
- left.addEventListener(
170
- 'ack',
171
- (event) => {
172
- frontiers.push(event.detail)
173
- },
174
- { once: true }
175
- )
207
+ alice.addEventListener('delta', (event) => {
208
+ bob.merge(event.detail)
209
+ })
176
210
 
177
- right.addEventListener(
178
- 'ack',
179
- (event) => {
180
- frontiers.push(event.detail)
181
- },
182
- { once: true }
183
- )
211
+ bob.addEventListener('delta', (event) => {
212
+ alice.merge(event.detail)
213
+ })
214
+
215
+ alice.addEventListener('ack', (event) => {
216
+ frontiers.set('alice', event.detail)
217
+ })
218
+
219
+ bob.addEventListener('ack', (event) => {
220
+ frontiers.set('bob', event.detail)
221
+ })
184
222
 
185
- left.acknowledge()
186
- right.acknowledge()
223
+ alice.title = 'x'
224
+ alice.title = 'y'
225
+ delete alice.title
187
226
 
188
- left.garbageCollect(frontiers)
189
- right.garbageCollect(frontiers)
227
+ alice.acknowledge()
228
+ bob.acknowledge()
229
+
230
+ alice.garbageCollect([...frontiers.values()])
231
+ bob.garbageCollect([...frontiers.values()])
232
+ ```
233
+
234
+ ### Advanced exports
235
+
236
+ If you need to build your own fixed-key CRDT binding instead of using the
237
+ high-level `CRStruct` class, the package also exports the core CRUD and MAGS
238
+ functions together with the replica and payload types.
239
+
240
+ Those low-level exports let you build custom struct abstractions, protocol
241
+ wrappers, or framework-specific bindings while preserving the same convergence
242
+ rules as the default `CRStruct` binding.
243
+
244
+ ```ts
245
+ import {
246
+ __create,
247
+ __update,
248
+ __merge,
249
+ __snapshot,
250
+ type CRStructDelta,
251
+ type CRStructSnapshot,
252
+ } from '@sovereignbase/convergent-replicated-struct'
253
+
254
+ type DraftStruct = {
255
+ title: string
256
+ count: number
257
+ }
258
+
259
+ const defaults: DraftStruct = {
260
+ title: '',
261
+ count: 0,
262
+ }
263
+
264
+ const replica = __create(defaults)
265
+ const local = __update('title', 'draft', replica)
266
+
267
+ if (local) {
268
+ const outgoing: CRStructDelta<DraftStruct> = local.delta
269
+ const remoteChange = __merge(outgoing, replica)
270
+
271
+ console.log(remoteChange)
272
+ }
273
+
274
+ const snapshot: CRStructSnapshot<DraftStruct> = __snapshot(replica)
275
+ console.log(snapshot)
190
276
  ```
191
277
 
278
+ The intended split is:
279
+
280
+ - `__create`, `__read`, `__update`, `__delete` for local replica mutations.
281
+ - `__merge`, `__acknowledge`, `__garbageCollect`, `__snapshot` for gossip,
282
+ compaction, and serialization.
283
+ - `CRStruct` when you want the default event-driven class API.
284
+
192
285
  ## Runtime behavior
193
286
 
194
287
  ### Validation and errors
195
288
 
196
- Local API misuse throws `OOStructError` with stable error codes:
289
+ Low-level exports can throw `CRStructError` with stable error codes:
197
290
 
198
291
  - `DEFAULTS_NOT_CLONEABLE`
199
292
  - `VALUE_NOT_CLONEABLE`
200
293
  - `VALUE_TYPE_MISMATCH`
201
294
 
202
- 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.
295
+ Ingress stays tolerant:
296
+
297
+ - malformed top-level merge payloads are ignored
298
+ - malformed snapshot values are dropped during hydration
299
+ - unknown keys are ignored
300
+ - invalid UUIDs and malformed field entries are ignored
301
+ - mismatched runtime kinds do not break live-state convergence
203
302
 
204
303
  ### Safety and copying semantics
205
304
 
206
- - Constructor defaults must be `structuredClone`-compatible.
207
- - `read()`, `values()`, and `entries()` return detached clones.
208
- - `delta`, `change`, and `snapshot` event payloads are detached from live state.
209
- - `update()` stores a cloned value, so later caller-side mutation does not mutate replica state through shared references.
305
+ - Snapshots are serializable full-state payloads keyed by field name.
306
+ - Deltas are serializable gossip payloads keyed by field name.
307
+ - `change` is a minimal field-keyed visible patch.
308
+ - `toJSON()` returns a detached serializable snapshot.
309
+ - Direct property reads, `for...of`, `values()`, `entries()`, and `clone()`
310
+ expose detached copies of visible values rather than mutable references into
311
+ replica state.
312
+ - Property assignment, `delete`, `clear()`, `merge()`, `snapshot()`,
313
+ `acknowledge()`, and `garbageCollect()` all operate on the live struct
314
+ projection.
210
315
 
211
316
  ### Convergence and compaction
212
317
 
213
- - The convergence guarantee is the resolved live struct state.
214
- - Internal overwrite history may differ between replicas after acknowledgement-based garbage collection while the resolved live state still converges.
215
- - `garbageCollect()` compacts overwritten identifiers that are below the smallest acknowledgement frontier for a key while preserving the active predecessor link.
318
+ - The convergence target is the live struct projection, not identical internal
319
+ tombstone sets.
320
+ - Tombstones remain until acknowledgement frontiers make them safe to collect.
321
+ - Garbage collection compacts overwritten identifiers below the smallest valid
322
+ acknowledgement frontier for a field while preserving the active predecessor
323
+ link.
324
+ - Internal overwrite history may differ between replicas after
325
+ acknowledgement-based garbage collection while the resolved live struct still
326
+ converges.
216
327
 
217
328
  ## Tests
218
329
 
219
- - Suite: unit, integration, and end-to-end runtime tests.
220
- - Node test runner: `node --test` for unit and integration suites.
221
- - Coverage: `c8` with 100% statements / branches / functions / lines on built `dist/**/*.js`.
222
- - E2E runtimes: Node ESM, Node CJS, Bun ESM, Bun CJS, Deno ESM, Cloudflare Workers ESM, Edge Runtime ESM.
223
- - Browser E2E: Chromium, Firefox, WebKit, mobile Chrome, mobile Safari via Playwright.
224
- - Current status: `npm run test` passes on Node 22.14.0 (`win32 x64`).
330
+ ```sh
331
+ npm run test
332
+ ```
225
333
 
226
- ## Benchmarks
334
+ What the current test suite covers:
335
+
336
+ - Coverage on built `dist/**/*.js`: `100%` statements, `100%` branches,
337
+ `100%` functions, and `100%` lines via `c8`.
338
+ - Public `CRStruct` surface: proxy property access, deletes, `clear()`,
339
+ iteration, events, and JSON / inspect behavior.
340
+ - Core edge paths and hostile ingress handling for `__create`, `__read`,
341
+ `__update`, `__delete`, `__merge`, `__snapshot`, `__acknowledge`, and
342
+ `__garbageCollect`.
343
+ - Snapshot hydration independent of field order, acknowledgement and garbage
344
+ collection recovery, and deterministic multi-replica gossip scenarios.
345
+ - End-to-end runtime matrix for:
346
+ - Node ESM
347
+ - Node CJS
348
+ - Bun ESM
349
+ - Bun CJS
350
+ - Deno ESM
351
+ - Cloudflare Workers ESM
352
+ - Edge Runtime ESM
353
+ - Browsers via Playwright: Chromium, Firefox, WebKit, mobile Chrome, mobile Safari
354
+ - Current status: `npm run test` passes on Node `v22.14.0` (`win32 x64`).
227
355
 
228
- How it was run:
356
+ ## Benchmarks
229
357
 
230
358
  ```sh
231
- node benchmark/bench.js
359
+ npm run bench
232
360
  ```
233
361
 
234
- Environment: Node 22.14.0 (`win32 x64`)
235
-
236
- | Benchmark | Result |
237
- | ----------------------------- | ------------------------- |
238
- | constructor empty | 44,359 ops/s (2254.3 ms) |
239
- | constructor hydrate x64 | 19,610 ops/s (255.0 ms) |
240
- | constructor hydrate x256 | 8,088 ops/s (247.3 ms) |
241
- | constructor hydrate x1024 | 1,724 ops/s (290.0 ms) |
242
- | create() empty | 49,874 ops/s (2005.1 ms) |
243
- | create() hydrate x256 | 6,886 ops/s (290.4 ms) |
244
- | read primitive | 846,289 ops/s (236.3 ms) |
245
- | read object | 298,983 ops/s (668.9 ms) |
246
- | read array | 278,710 ops/s (717.6 ms) |
247
- | keys() | 32,349,896 ops/s (6.2 ms) |
248
- | values() | 103,489 ops/s (966.3 ms) |
249
- | entries() | 110,300 ops/s (906.6 ms) |
250
- | snapshot() | 65,513 ops/s (305.3 ms) |
251
- | acknowledge() | 536,890 ops/s (93.1 ms) |
252
- | update string | 29,547 ops/s (1692.2 ms) |
253
- | update number | 30,591 ops/s (1634.5 ms) |
254
- | update object | 22,114 ops/s (2261.0 ms) |
255
- | update array | 24,763 ops/s (2019.1 ms) |
256
- | delete(key) | 8,352 ops/s (5986.8 ms) |
257
- | delete() reset all | 6,836 ops/s (2925.5 ms) |
258
- | merge direct successor | 32,541 ops/s (1536.5 ms) |
259
- | merge stale conflict | 30,995 ops/s (645.3 ms) |
260
- | merge hydrate snapshot x256 | 5,748 ops/s (869.9 ms) |
261
- | merge noop duplicate | 7,576 ops/s (6600.1 ms) |
262
- | garbageCollect() x512 history | 3,111 ops/s (1607.0 ms) |
263
- | add/remove listener roundtrip | 49,005 ops/s (4081.2 ms) |
264
- | update with listeners | 25,120 ops/s (1194.3 ms) |
265
- | merge with listeners | 31,649 ops/s (631.9 ms) |
266
-
267
- Results vary by machine, runtime version, and payload shape.
362
+ The benchmark runner currently uses:
363
+
364
+ - `HISTORY_DEPTH = 5_000`
365
+ - `RUN_TIMES = 250`
366
+ - output columns: `group`, `scenario`, `n`, `ops`, `ms`, `ms/op`, `ops/sec`
367
+
368
+ Last measured on Node `v22.14.0` (`win32 x64`):
369
+
370
+ | group | scenario | n | ops | ms | ms/op | ops/sec |
371
+ | ------- | -------------------------------- | ----: | --: | -----: | ----: | ---------: |
372
+ | `crud` | `create / hydrate snapshot` | 5,000 | 250 | 714.80 | 2.86 | 349.75 |
373
+ | `crud` | `read / primitive field` | 5,000 | 250 | 0.55 | 0.00 | 450,531.63 |
374
+ | `crud` | `read / object field` | 5,000 | 250 | 0.83 | 0.00 | 301,568.15 |
375
+ | `crud` | `update / overwrite string` | 5,000 | 250 | 5.77 | 0.02 | 43,291.54 |
376
+ | `crud` | `update / overwrite object` | 5,000 | 250 | 4.79 | 0.02 | 52,198.61 |
377
+ | `crud` | `delete / reset single field` | 5,000 | 250 | 3.67 | 0.01 | 68,162.61 |
378
+ | `crud` | `delete / reset all fields` | 5,000 | 250 | 18.86 | 0.08 | 13,253.95 |
379
+ | `mags` | `snapshot` | 5,000 | 250 | 7.80 | 0.03 | 32,062.38 |
380
+ | `mags` | `acknowledge` | 5,000 | 250 | 39.72 | 0.16 | 6,294.04 |
381
+ | `mags` | `garbage collect` | 5,000 | 250 | 260.93 | 1.04 | 958.12 |
382
+ | `mags` | `merge ordered deltas` | 5,000 | 250 | 204.53 | 0.82 | 1,222.32 |
383
+ | `mags` | `merge direct successor` | 5,000 | 250 | 1.46 | 0.01 | 171,385.48 |
384
+ | `mags` | `merge shuffled gossip` | 5,000 | 250 | 263.91 | 1.06 | 947.29 |
385
+ | `mags` | `merge stale conflict` | 5,000 | 250 | 2.11 | 0.01 | 118,315.19 |
386
+ | `class` | `constructor / hydrate snapshot` | 5,000 | 250 | 781.32 | 3.13 | 319.97 |
387
+ | `class` | `property read / primitive` | 5,000 | 250 | 0.45 | 0.00 | 559,659.73 |
388
+ | `class` | `property read / object` | 5,000 | 250 | 0.95 | 0.00 | 262,687.82 |
389
+ | `class` | `property write / string` | 5,000 | 250 | 5.04 | 0.02 | 49,613.02 |
390
+ | `class` | `property write / object` | 5,000 | 250 | 8.57 | 0.03 | 29,157.24 |
391
+ | `class` | `delete property` | 5,000 | 250 | 4.80 | 0.02 | 52,128.95 |
392
+ | `class` | `clear()` | 5,000 | 250 | 15.35 | 0.06 | 16,283.14 |
393
+ | `class` | `snapshot` | 5,000 | 250 | 9.49 | 0.04 | 26,356.29 |
394
+ | `class` | `acknowledge` | 5,000 | 250 | 45.49 | 0.18 | 5,495.59 |
395
+ | `class` | `garbage collect` | 5,000 | 250 | 162.70 | 0.65 | 1,536.53 |
396
+ | `class` | `merge ordered deltas` | 5,000 | 250 | 193.20 | 0.77 | 1,293.98 |
397
+ | `class` | `merge direct successor` | 5,000 | 250 | 2.90 | 0.01 | 86,331.93 |
398
+ | `class` | `merge shuffled gossip` | 5,000 | 250 | 264.43 | 1.06 | 945.44 |
268
399
 
269
400
  ## License
270
401