@lazily-hub/lazily-js 0.4.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/src/index.js ADDED
@@ -0,0 +1,1109 @@
1
+ const textEncoder = new TextEncoder();
2
+ const textDecoder = new TextDecoder();
3
+
4
+ function assertObject(value, name) {
5
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
6
+ throw new TypeError(`${name} must be an object`);
7
+ }
8
+ return value;
9
+ }
10
+
11
+ function assertTagged(value, name) {
12
+ const object = assertObject(value, name);
13
+ const entries = Object.entries(object);
14
+ if (entries.length !== 1) {
15
+ throw new TypeError(`${name} must be externally tagged`);
16
+ }
17
+ return entries[0];
18
+ }
19
+
20
+ function assertInteger(value, name) {
21
+ if (!Number.isSafeInteger(value) || value < 0) {
22
+ throw new TypeError(`${name} must be a non-negative safe integer`);
23
+ }
24
+ return value;
25
+ }
26
+
27
+ function bytesFromWire(value) {
28
+ if (!Array.isArray(value)) {
29
+ throw new TypeError("byte payload must be an array");
30
+ }
31
+ return value.map((byte, index) => {
32
+ if (!Number.isInteger(byte) || byte < 0 || byte > 255) {
33
+ throw new TypeError(`byte payload[${index}] must be in 0..255`);
34
+ }
35
+ return byte;
36
+ });
37
+ }
38
+
39
+ function bytesOf(value) {
40
+ if (value instanceof Uint8Array) {
41
+ return Array.from(value);
42
+ }
43
+ return bytesFromWire(value);
44
+ }
45
+
46
+ // NodeKey: optional wire-stable "/"-joined keyed address (protocol.md § NodeKey).
47
+ // JSON omits the field when absent; a missing field decodes to null.
48
+ const NODE_KEY_MAX_BYTES = 1024;
49
+ const NODE_KEY_MAX_SEGMENTS = 32;
50
+
51
+ function normalizeNodeKey(key) {
52
+ if (key === undefined || key === null) {
53
+ return null;
54
+ }
55
+ if (typeof key !== "string") {
56
+ throw new TypeError("NodeKey must be a string");
57
+ }
58
+ if (key === "") {
59
+ throw new TypeError("NodeKey must not be empty");
60
+ }
61
+ if (textEncoder.encode(key).length > NODE_KEY_MAX_BYTES) {
62
+ throw new TypeError(`NodeKey must be <= ${NODE_KEY_MAX_BYTES} bytes`);
63
+ }
64
+ const segments = key.split("/");
65
+ if (segments.length > NODE_KEY_MAX_SEGMENTS) {
66
+ throw new TypeError(`NodeKey must have <= ${NODE_KEY_MAX_SEGMENTS} segments`);
67
+ }
68
+ if (segments.some((segment) => segment === "")) {
69
+ throw new TypeError(
70
+ "NodeKey must not contain empty segments (leading/trailing/double '/')",
71
+ );
72
+ }
73
+ return key;
74
+ }
75
+
76
+ export class ShmBlobRef {
77
+ constructor({ offset, len, generation, epoch, checksum }) {
78
+ this.offset = assertInteger(offset, "offset");
79
+ this.len = assertInteger(len, "len");
80
+ this.generation = assertInteger(generation, "generation");
81
+ this.epoch = assertInteger(epoch, "epoch");
82
+ this.checksum = assertInteger(checksum, "checksum");
83
+ Object.freeze(this);
84
+ }
85
+
86
+ toWire() {
87
+ return {
88
+ offset: this.offset,
89
+ len: this.len,
90
+ generation: this.generation,
91
+ epoch: this.epoch,
92
+ checksum: this.checksum,
93
+ };
94
+ }
95
+
96
+ static fromWire(value) {
97
+ const object = assertObject(value, "ShmBlobRef");
98
+ return new ShmBlobRef({
99
+ offset: object.offset,
100
+ len: object.len,
101
+ generation: object.generation,
102
+ epoch: object.epoch,
103
+ checksum: object.checksum,
104
+ });
105
+ }
106
+ }
107
+
108
+ export class NodeStatePayload {
109
+ constructor(bytes) {
110
+ this.bytes = Object.freeze(bytesOf(bytes));
111
+ Object.freeze(this);
112
+ }
113
+
114
+ toWire() {
115
+ return { Payload: [...this.bytes] };
116
+ }
117
+ }
118
+
119
+ export class NodeStateSharedBlob {
120
+ constructor(blob) {
121
+ this.blob = blob instanceof ShmBlobRef ? blob : ShmBlobRef.fromWire(blob);
122
+ Object.freeze(this);
123
+ }
124
+
125
+ toWire() {
126
+ return { SharedBlob: this.blob.toWire() };
127
+ }
128
+ }
129
+
130
+ export class NodeStateOpaque {
131
+ toWire() {
132
+ return "Opaque";
133
+ }
134
+ }
135
+
136
+ export const NodeState = Object.freeze({
137
+ payload(bytes) {
138
+ return new NodeStatePayload(bytes);
139
+ },
140
+ sharedBlob(blob) {
141
+ return new NodeStateSharedBlob(blob);
142
+ },
143
+ opaque() {
144
+ return new NodeStateOpaque();
145
+ },
146
+ fromWire(value) {
147
+ if (typeof value === "string") {
148
+ if (value !== "Opaque") {
149
+ throw new TypeError(`unknown NodeState unit variant: ${value}`);
150
+ }
151
+ return new NodeStateOpaque();
152
+ }
153
+
154
+ const [tag, body] = assertTagged(value, "NodeState");
155
+ switch (tag) {
156
+ case "Payload":
157
+ return new NodeStatePayload(body);
158
+ case "SharedBlob":
159
+ return new NodeStateSharedBlob(ShmBlobRef.fromWire(body));
160
+ case "Opaque":
161
+ return new NodeStateOpaque();
162
+ default:
163
+ throw new TypeError(`unknown NodeState variant: ${tag}`);
164
+ }
165
+ },
166
+ });
167
+
168
+ export class IpcValueInline {
169
+ constructor(bytes) {
170
+ this.bytes = Object.freeze(bytesOf(bytes));
171
+ Object.freeze(this);
172
+ }
173
+
174
+ toWire() {
175
+ return { Inline: [...this.bytes] };
176
+ }
177
+ }
178
+
179
+ export class IpcValueSharedBlob {
180
+ constructor(blob) {
181
+ this.blob = blob instanceof ShmBlobRef ? blob : ShmBlobRef.fromWire(blob);
182
+ Object.freeze(this);
183
+ }
184
+
185
+ toWire() {
186
+ return { SharedBlob: this.blob.toWire() };
187
+ }
188
+ }
189
+
190
+ export const IpcValue = Object.freeze({
191
+ inline(bytes) {
192
+ return new IpcValueInline(bytes);
193
+ },
194
+ sharedBlob(blob) {
195
+ return new IpcValueSharedBlob(blob);
196
+ },
197
+ of(value) {
198
+ if (value instanceof IpcValueInline || value instanceof IpcValueSharedBlob) {
199
+ return value;
200
+ }
201
+ if (value instanceof ShmBlobRef) {
202
+ return new IpcValueSharedBlob(value);
203
+ }
204
+ return new IpcValueInline(value);
205
+ },
206
+ fromWire(value) {
207
+ const [tag, body] = assertTagged(value, "IpcValue");
208
+ switch (tag) {
209
+ case "Inline":
210
+ return new IpcValueInline(body);
211
+ case "SharedBlob":
212
+ return new IpcValueSharedBlob(ShmBlobRef.fromWire(body));
213
+ default:
214
+ throw new TypeError(`unknown IpcValue variant: ${tag}`);
215
+ }
216
+ },
217
+ });
218
+
219
+ export class NodeSnapshot {
220
+ constructor(node, typeTag, state, key = null) {
221
+ this.node = assertInteger(node, "node");
222
+ this.typeTag = String(typeTag);
223
+ this.state = state;
224
+ this.key = normalizeNodeKey(key);
225
+ Object.freeze(this);
226
+ }
227
+
228
+ toWire() {
229
+ const wire = {
230
+ node: this.node,
231
+ type_tag: this.typeTag,
232
+ state: this.state.toWire(),
233
+ };
234
+ if (this.key !== null) {
235
+ wire.key = this.key;
236
+ }
237
+ return wire;
238
+ }
239
+
240
+ static payload(node, typeTag, bytes, key) {
241
+ return new NodeSnapshot(node, typeTag, NodeState.payload(bytes), key);
242
+ }
243
+
244
+ static sharedBlob(node, typeTag, blob, key) {
245
+ return new NodeSnapshot(node, typeTag, NodeState.sharedBlob(blob), key);
246
+ }
247
+
248
+ static opaque(node, typeTag, key) {
249
+ return new NodeSnapshot(node, typeTag, NodeState.opaque(), key);
250
+ }
251
+
252
+ static fromWire(value) {
253
+ const object = assertObject(value, "NodeSnapshot");
254
+ return new NodeSnapshot(
255
+ object.node,
256
+ object.type_tag,
257
+ NodeState.fromWire(object.state),
258
+ object.key ?? null,
259
+ );
260
+ }
261
+ }
262
+
263
+ export class EdgeSnapshot {
264
+ constructor(dependent, dependency) {
265
+ this.dependent = assertInteger(dependent, "dependent");
266
+ this.dependency = assertInteger(dependency, "dependency");
267
+ Object.freeze(this);
268
+ }
269
+
270
+ toWire() {
271
+ return {
272
+ dependent: this.dependent,
273
+ dependency: this.dependency,
274
+ };
275
+ }
276
+
277
+ isReadableBy(permissions, peer) {
278
+ return (
279
+ permissions.canRead(peer, this.dependent) &&
280
+ permissions.canRead(peer, this.dependency)
281
+ );
282
+ }
283
+
284
+ static fromWire(value) {
285
+ const object = assertObject(value, "EdgeSnapshot");
286
+ return new EdgeSnapshot(object.dependent, object.dependency);
287
+ }
288
+ }
289
+
290
+ export class Snapshot {
291
+ constructor({ epoch, nodes = [], edges = [], roots = [] }) {
292
+ this.epoch = assertInteger(epoch, "epoch");
293
+ this.nodes = Object.freeze([...nodes]);
294
+ this.edges = Object.freeze([...edges]);
295
+ this.roots = Object.freeze(roots.map((node) => assertInteger(node, "root")));
296
+ Object.freeze(this);
297
+ }
298
+
299
+ toWire() {
300
+ return {
301
+ epoch: this.epoch,
302
+ nodes: this.nodes.map((node) => node.toWire()),
303
+ edges: this.edges.map((edge) => edge.toWire()),
304
+ roots: [...this.roots],
305
+ };
306
+ }
307
+
308
+ filterReadable(permissions, peer) {
309
+ return new Snapshot({
310
+ epoch: this.epoch,
311
+ nodes: this.nodes.filter((node) => permissions.canRead(peer, node.node)),
312
+ edges: this.edges.filter((edge) => edge.isReadableBy(permissions, peer)),
313
+ roots: permissions.filterReadable(peer, this.roots),
314
+ });
315
+ }
316
+
317
+ static fromWire(value) {
318
+ const object = assertObject(value, "Snapshot");
319
+ return new Snapshot({
320
+ epoch: object.epoch,
321
+ nodes: (object.nodes ?? []).map((node) => NodeSnapshot.fromWire(node)),
322
+ edges: (object.edges ?? []).map((edge) => EdgeSnapshot.fromWire(edge)),
323
+ roots: object.roots ?? [],
324
+ });
325
+ }
326
+ }
327
+
328
+ class DeltaOpBase {
329
+ targetReadable() {
330
+ throw new Error("DeltaOp.targetReadable must be implemented by subclasses");
331
+ }
332
+ }
333
+
334
+ export class DeltaOpCellSet extends DeltaOpBase {
335
+ constructor(node, payload) {
336
+ super();
337
+ this.node = assertInteger(node, "node");
338
+ this.payload = IpcValue.of(payload);
339
+ Object.freeze(this);
340
+ }
341
+
342
+ toWire() {
343
+ return { CellSet: { node: this.node, payload: this.payload.toWire() } };
344
+ }
345
+
346
+ targetReadable(permissions, peer) {
347
+ return permissions.canRead(peer, this.node);
348
+ }
349
+ }
350
+
351
+ export class DeltaOpSlotValue extends DeltaOpBase {
352
+ constructor(node, payload) {
353
+ super();
354
+ this.node = assertInteger(node, "node");
355
+ this.payload = IpcValue.of(payload);
356
+ Object.freeze(this);
357
+ }
358
+
359
+ toWire() {
360
+ return { SlotValue: { node: this.node, payload: this.payload.toWire() } };
361
+ }
362
+
363
+ targetReadable(permissions, peer) {
364
+ return permissions.canRead(peer, this.node);
365
+ }
366
+ }
367
+
368
+ export class DeltaOpInvalidate extends DeltaOpBase {
369
+ constructor(node) {
370
+ super();
371
+ this.node = assertInteger(node, "node");
372
+ Object.freeze(this);
373
+ }
374
+
375
+ toWire() {
376
+ return { Invalidate: { node: this.node } };
377
+ }
378
+
379
+ targetReadable(permissions, peer) {
380
+ return permissions.canRead(peer, this.node);
381
+ }
382
+ }
383
+
384
+ export class DeltaOpNodeAdd extends DeltaOpBase {
385
+ constructor(node, typeTag, state, key = null) {
386
+ super();
387
+ this.node = assertInteger(node, "node");
388
+ this.typeTag = String(typeTag);
389
+ this.state = state;
390
+ this.key = normalizeNodeKey(key);
391
+ Object.freeze(this);
392
+ }
393
+
394
+ toWire() {
395
+ const wire = {
396
+ node: this.node,
397
+ type_tag: this.typeTag,
398
+ state: this.state.toWire(),
399
+ };
400
+ if (this.key !== null) {
401
+ wire.key = this.key;
402
+ }
403
+ return { NodeAdd: wire };
404
+ }
405
+
406
+ targetReadable(permissions, peer) {
407
+ return permissions.canRead(peer, this.node);
408
+ }
409
+ }
410
+
411
+ export class DeltaOpNodeRemove extends DeltaOpBase {
412
+ constructor(node) {
413
+ super();
414
+ this.node = assertInteger(node, "node");
415
+ Object.freeze(this);
416
+ }
417
+
418
+ toWire() {
419
+ return { NodeRemove: { node: this.node } };
420
+ }
421
+
422
+ targetReadable(permissions, peer) {
423
+ return permissions.canRead(peer, this.node);
424
+ }
425
+ }
426
+
427
+ export class DeltaOpEdgeAdd extends DeltaOpBase {
428
+ constructor(dependent, dependency) {
429
+ super();
430
+ this.dependent = assertInteger(dependent, "dependent");
431
+ this.dependency = assertInteger(dependency, "dependency");
432
+ Object.freeze(this);
433
+ }
434
+
435
+ toWire() {
436
+ return { EdgeAdd: { dependent: this.dependent, dependency: this.dependency } };
437
+ }
438
+
439
+ targetReadable(permissions, peer) {
440
+ return (
441
+ permissions.canRead(peer, this.dependent) &&
442
+ permissions.canRead(peer, this.dependency)
443
+ );
444
+ }
445
+ }
446
+
447
+ export class DeltaOpEdgeRemove extends DeltaOpBase {
448
+ constructor(dependent, dependency) {
449
+ super();
450
+ this.dependent = assertInteger(dependent, "dependent");
451
+ this.dependency = assertInteger(dependency, "dependency");
452
+ Object.freeze(this);
453
+ }
454
+
455
+ toWire() {
456
+ return {
457
+ EdgeRemove: { dependent: this.dependent, dependency: this.dependency },
458
+ };
459
+ }
460
+
461
+ targetReadable(permissions, peer) {
462
+ return (
463
+ permissions.canRead(peer, this.dependent) &&
464
+ permissions.canRead(peer, this.dependency)
465
+ );
466
+ }
467
+ }
468
+
469
+ export const DeltaOp = Object.freeze({
470
+ cellSet(node, payload) {
471
+ return new DeltaOpCellSet(node, payload);
472
+ },
473
+ slotValue(node, payload) {
474
+ return new DeltaOpSlotValue(node, payload);
475
+ },
476
+ invalidate(node) {
477
+ return new DeltaOpInvalidate(node);
478
+ },
479
+ nodeAdd(node, typeTag, state, key) {
480
+ return new DeltaOpNodeAdd(node, typeTag, state, key);
481
+ },
482
+ nodeRemove(node) {
483
+ return new DeltaOpNodeRemove(node);
484
+ },
485
+ edgeAdd(dependent, dependency) {
486
+ return new DeltaOpEdgeAdd(dependent, dependency);
487
+ },
488
+ edgeRemove(dependent, dependency) {
489
+ return new DeltaOpEdgeRemove(dependent, dependency);
490
+ },
491
+ fromWire(value) {
492
+ const [tag, body] = assertTagged(value, "DeltaOp");
493
+ const object = assertObject(body, tag);
494
+ switch (tag) {
495
+ case "CellSet":
496
+ return new DeltaOpCellSet(object.node, IpcValue.fromWire(object.payload));
497
+ case "SlotValue":
498
+ return new DeltaOpSlotValue(object.node, IpcValue.fromWire(object.payload));
499
+ case "Invalidate":
500
+ return new DeltaOpInvalidate(object.node);
501
+ case "NodeAdd":
502
+ return new DeltaOpNodeAdd(
503
+ object.node,
504
+ object.type_tag,
505
+ NodeState.fromWire(object.state),
506
+ object.key ?? null,
507
+ );
508
+ case "NodeRemove":
509
+ return new DeltaOpNodeRemove(object.node);
510
+ case "EdgeAdd":
511
+ return new DeltaOpEdgeAdd(object.dependent, object.dependency);
512
+ case "EdgeRemove":
513
+ return new DeltaOpEdgeRemove(object.dependent, object.dependency);
514
+ default:
515
+ throw new TypeError(`unknown DeltaOp variant: ${tag}`);
516
+ }
517
+ },
518
+ });
519
+
520
+ export const DeltaApplyStatusKind = Object.freeze({
521
+ Apply: "apply",
522
+ ResyncRequired: "resync_required",
523
+ });
524
+
525
+ export class DeltaApplyStatus {
526
+ constructor(kind, fields = {}) {
527
+ this.kind = kind;
528
+ Object.assign(this, fields);
529
+ Object.freeze(this);
530
+ }
531
+
532
+ get isApply() {
533
+ return this.kind === DeltaApplyStatusKind.Apply;
534
+ }
535
+
536
+ get isResyncRequired() {
537
+ return this.kind === DeltaApplyStatusKind.ResyncRequired;
538
+ }
539
+
540
+ static apply() {
541
+ return new DeltaApplyStatus(DeltaApplyStatusKind.Apply);
542
+ }
543
+
544
+ static resyncRequired(lastEpoch, baseEpoch, epoch) {
545
+ return new DeltaApplyStatus(DeltaApplyStatusKind.ResyncRequired, {
546
+ lastEpoch,
547
+ baseEpoch,
548
+ epoch,
549
+ });
550
+ }
551
+ }
552
+
553
+ export class Delta {
554
+ constructor({ baseEpoch, epoch, ops = [] }) {
555
+ this.baseEpoch = assertInteger(baseEpoch, "baseEpoch");
556
+ this.epoch = assertInteger(epoch, "epoch");
557
+ this.ops = Object.freeze([...ops]);
558
+ Object.freeze(this);
559
+ }
560
+
561
+ isNextAfter(lastEpoch) {
562
+ return this.baseEpoch === lastEpoch && this.epoch === this.baseEpoch + 1;
563
+ }
564
+
565
+ applyStatus(lastEpoch) {
566
+ if (this.isNextAfter(lastEpoch)) {
567
+ return DeltaApplyStatus.apply();
568
+ }
569
+ return DeltaApplyStatus.resyncRequired(lastEpoch, this.baseEpoch, this.epoch);
570
+ }
571
+
572
+ filterReadable(permissions, peer) {
573
+ return new Delta({
574
+ baseEpoch: this.baseEpoch,
575
+ epoch: this.epoch,
576
+ ops: this.ops.filter((op) => op.targetReadable(permissions, peer)),
577
+ });
578
+ }
579
+
580
+ toWire() {
581
+ return {
582
+ base_epoch: this.baseEpoch,
583
+ epoch: this.epoch,
584
+ ops: this.ops.map((op) => op.toWire()),
585
+ };
586
+ }
587
+
588
+ static next(baseEpoch, ops = []) {
589
+ return new Delta({ baseEpoch, epoch: baseEpoch + 1, ops });
590
+ }
591
+
592
+ static fromWire(value) {
593
+ const object = assertObject(value, "Delta");
594
+ return new Delta({
595
+ baseEpoch: object.base_epoch,
596
+ epoch: object.epoch,
597
+ ops: (object.ops ?? []).map((op) => DeltaOp.fromWire(op)),
598
+ });
599
+ }
600
+ }
601
+
602
+ // A frontier entry is the (peer, WireStamp) tuple lazily-rs carries as
603
+ // Vec<(u64, WireStamp)>; serde emits it as a 2-element JSON array [peer, stamp].
604
+ function frontierEntryFromWire(value) {
605
+ if (!Array.isArray(value) || value.length !== 2) {
606
+ throw new TypeError("frontier entry must be a [peer, WireStamp] pair");
607
+ }
608
+ return Object.freeze({
609
+ peer: assertInteger(value[0], "frontier peer"),
610
+ stamp: WireStamp.fromWire(value[1]),
611
+ });
612
+ }
613
+
614
+ function frontierEntryOf(entry) {
615
+ if (entry && typeof entry === "object" && !Array.isArray(entry)) {
616
+ if ("peer" in entry) {
617
+ return Object.freeze({
618
+ peer: assertInteger(entry.peer, "frontier peer"),
619
+ stamp:
620
+ entry.stamp instanceof WireStamp
621
+ ? entry.stamp
622
+ : WireStamp.fromWire(entry.stamp),
623
+ });
624
+ }
625
+ }
626
+ return frontierEntryFromWire(entry);
627
+ }
628
+
629
+ export class WireStamp {
630
+ constructor({ wallTime, logical, peer }) {
631
+ this.wallTime = assertInteger(wallTime, "wallTime");
632
+ this.logical = assertInteger(logical, "logical");
633
+ this.peer = assertInteger(peer, "peer");
634
+ Object.freeze(this);
635
+ }
636
+
637
+ toWire() {
638
+ return {
639
+ wall_time: this.wallTime,
640
+ logical: this.logical,
641
+ peer: this.peer,
642
+ };
643
+ }
644
+
645
+ static fromWire(value) {
646
+ const object = assertObject(value, "WireStamp");
647
+ return new WireStamp({
648
+ wallTime: object.wall_time,
649
+ logical: object.logical,
650
+ peer: object.peer,
651
+ });
652
+ }
653
+ }
654
+
655
+ export class CrdtOp {
656
+ constructor(node, stamp, state, key = null) {
657
+ this.node = assertInteger(node, "node");
658
+ this.stamp = stamp instanceof WireStamp ? stamp : WireStamp.fromWire(stamp);
659
+ this.state = IpcValue.of(state);
660
+ // CrdtOp mirrors lazily-rs's derived serde (no skip_serializing_if), so a
661
+ // keyless op serializes `key: null` — unlike NodeSnapshot/NodeAdd, which
662
+ // omit the field. normalizeNodeKey still enforces bounds when a key is set.
663
+ this.key = normalizeNodeKey(key);
664
+ Object.freeze(this);
665
+ }
666
+
667
+ toWire() {
668
+ return {
669
+ node: this.node,
670
+ key: this.key,
671
+ stamp: this.stamp.toWire(),
672
+ state: this.state.toWire(),
673
+ };
674
+ }
675
+
676
+ targetReadable(permissions, peer) {
677
+ return permissions.canRead(peer, this.node);
678
+ }
679
+
680
+ static keyed(node, key, stamp, state) {
681
+ return new CrdtOp(node, stamp, state, key);
682
+ }
683
+
684
+ static fromWire(value) {
685
+ const object = assertObject(value, "CrdtOp");
686
+ return new CrdtOp(
687
+ object.node,
688
+ WireStamp.fromWire(object.stamp),
689
+ IpcValue.fromWire(object.state),
690
+ object.key ?? null,
691
+ );
692
+ }
693
+ }
694
+
695
+ export class CrdtSync {
696
+ constructor({ frontier = [], ops = [] }) {
697
+ this.frontier = Object.freeze(frontier.map(frontierEntryOf));
698
+ this.ops = Object.freeze([...ops]);
699
+ Object.freeze(this);
700
+ }
701
+
702
+ filterReadable(permissions, peer) {
703
+ // The frontier advertisement names peers and stamps, not node content, and
704
+ // the receiver needs the whole frontier to compute a sound watermark — so
705
+ // it is retained in full while ops are omitted (not redacted), like Delta.
706
+ return new CrdtSync({
707
+ frontier: this.frontier,
708
+ ops: this.ops.filter((op) => op.targetReadable(permissions, peer)),
709
+ });
710
+ }
711
+
712
+ toWire() {
713
+ return {
714
+ frontier: this.frontier.map((entry) => [
715
+ entry.peer,
716
+ entry.stamp.toWire(),
717
+ ]),
718
+ ops: this.ops.map((op) => op.toWire()),
719
+ };
720
+ }
721
+
722
+ static fromWire(value) {
723
+ const object = assertObject(value, "CrdtSync");
724
+ return new CrdtSync({
725
+ frontier: (object.frontier ?? []).map(frontierEntryFromWire),
726
+ ops: (object.ops ?? []).map((op) => CrdtOp.fromWire(op)),
727
+ });
728
+ }
729
+ }
730
+
731
+ export class IpcMessage {
732
+ constructor(kind, value) {
733
+ this.kind = kind;
734
+ this.snapshot = undefined;
735
+ this.delta = undefined;
736
+ this.crdtSync = undefined;
737
+ if (kind === "Snapshot") {
738
+ this.snapshot = value;
739
+ } else if (kind === "Delta") {
740
+ this.delta = value;
741
+ } else if (kind === "CrdtSync") {
742
+ this.crdtSync = value;
743
+ } else {
744
+ throw new TypeError(`unknown IpcMessage kind: ${kind}`);
745
+ }
746
+ Object.freeze(this);
747
+ }
748
+
749
+ get isSnapshot() {
750
+ return this.kind === "Snapshot";
751
+ }
752
+
753
+ get isDelta() {
754
+ return this.kind === "Delta";
755
+ }
756
+
757
+ get isCrdtSync() {
758
+ return this.kind === "CrdtSync";
759
+ }
760
+
761
+ toWire() {
762
+ if (this.kind === "Snapshot") {
763
+ return { Snapshot: this.snapshot.toWire() };
764
+ }
765
+ if (this.kind === "Delta") {
766
+ return { Delta: this.delta.toWire() };
767
+ }
768
+ return { CrdtSync: this.crdtSync.toWire() };
769
+ }
770
+
771
+ encodeJson() {
772
+ return textEncoder.encode(JSON.stringify(this.toWire()));
773
+ }
774
+
775
+ static snapshot(snapshot) {
776
+ return new IpcMessage("Snapshot", snapshot);
777
+ }
778
+
779
+ static delta(delta) {
780
+ return new IpcMessage("Delta", delta);
781
+ }
782
+
783
+ static crdtSync(crdtSync) {
784
+ return new IpcMessage("CrdtSync", crdtSync);
785
+ }
786
+
787
+ static fromWire(value) {
788
+ const [tag, body] = assertTagged(value, "IpcMessage");
789
+ switch (tag) {
790
+ case "Snapshot":
791
+ return IpcMessage.snapshot(Snapshot.fromWire(body));
792
+ case "Delta":
793
+ return IpcMessage.delta(Delta.fromWire(body));
794
+ case "CrdtSync":
795
+ return IpcMessage.crdtSync(CrdtSync.fromWire(body));
796
+ default:
797
+ throw new TypeError(`unknown IpcMessage variant: ${tag}`);
798
+ }
799
+ }
800
+
801
+ static decodeJson(data) {
802
+ const text =
803
+ data instanceof Uint8Array ? textDecoder.decode(data) : String(data);
804
+ return IpcMessage.fromWire(JSON.parse(text));
805
+ }
806
+ }
807
+
808
+ // Capability negotiation (protocol.md § Capability Negotiation). Every
809
+ // non-local session starts with this compatibility handshake, exchanged before
810
+ // any `Snapshot` or `Delta` flows. If peers disagree on `protocol_major_version`,
811
+ // `codec`, `ordered_reliable`, or required features, they fail closed.
812
+ export const PROTOCOL_ID = "lazily-ipc";
813
+ export const PROTOCOL_MAJOR_VERSION = 1;
814
+
815
+ export const Codec = Object.freeze({
816
+ Json: "json",
817
+ Bincode: "bincode",
818
+ Postcard: "postcard",
819
+ });
820
+
821
+ // FFI message-kind discriminant (schemas/ffi.json § LazilyFfiMessageKind). The
822
+ // JSON representation is normative; CrdtSync = 3 is required of the discriminant.
823
+ export const LazilyFfiMessageKind = Object.freeze({
824
+ Unknown: 0,
825
+ Snapshot: 1,
826
+ Delta: 2,
827
+ CrdtSync: 3,
828
+ });
829
+
830
+ // The FFI status codes mirror schemas/ffi.json § LazilyFfiStatus.
831
+ export const LazilyFfiStatus = Object.freeze({
832
+ Ok: 0,
833
+ Empty: 1,
834
+ NullPointer: 2,
835
+ InvalidMessage: 3,
836
+ EncodeFailed: 4,
837
+ Panic: 5,
838
+ });
839
+
840
+ function assertString(value, name) {
841
+ if (typeof value !== "string") {
842
+ throw new TypeError(`${name} must be a string`);
843
+ }
844
+ return value;
845
+ }
846
+
847
+ function assertBoolean(value, name) {
848
+ if (typeof value !== "boolean") {
849
+ throw new TypeError(`${name} must be a boolean`);
850
+ }
851
+ return value;
852
+ }
853
+
854
+ function assertStringArray(value, name) {
855
+ if (!Array.isArray(value)) {
856
+ throw new TypeError(`${name} must be an array of strings`);
857
+ }
858
+ return value.map((item, index) => {
859
+ if (typeof item !== "string") {
860
+ throw new TypeError(`${name}[${index}] must be a string`);
861
+ }
862
+ return item;
863
+ });
864
+ }
865
+
866
+ export class SessionHandshake {
867
+ constructor(fields) {
868
+ const obj = assertObject(fields, "SessionHandshake");
869
+ this.protocolId = assertString(obj.protocol_id, "protocol_id");
870
+ this.protocolMajorVersion = assertInteger(
871
+ obj.protocol_major_version,
872
+ "protocol_major_version",
873
+ );
874
+ this.codec = assertString(obj.codec, "codec");
875
+ this.maxFrameSize = assertInteger(obj.max_frame_size, "max_frame_size");
876
+ this.fragmentationSupported = assertBoolean(
877
+ obj.fragmentation_supported,
878
+ "fragmentation_supported",
879
+ );
880
+ this.orderedReliable = assertBoolean(obj.ordered_reliable, "ordered_reliable");
881
+ this.peerId = assertInteger(obj.peer_id, "peer_id");
882
+ this.sessionId = assertString(obj.session_id, "session_id");
883
+ this.features = Object.freeze(assertStringArray(obj.features ?? [], "features"));
884
+ Object.freeze(this);
885
+ }
886
+
887
+ toWire() {
888
+ return {
889
+ protocol_id: this.protocolId,
890
+ protocol_major_version: this.protocolMajorVersion,
891
+ codec: this.codec,
892
+ max_frame_size: this.maxFrameSize,
893
+ fragmentation_supported: this.fragmentationSupported,
894
+ ordered_reliable: this.orderedReliable,
895
+ peer_id: this.peerId,
896
+ session_id: this.sessionId,
897
+ features: [...this.features],
898
+ };
899
+ }
900
+
901
+ encodeJson() {
902
+ return textEncoder.encode(JSON.stringify(this.toWire()));
903
+ }
904
+
905
+ // Fail-closed compatibility check (protocol.md § Capability Negotiation).
906
+ // Peers are compatible only when they agree on `protocol_id`,
907
+ // `protocol_major_version`, `codec`, and `ordered_reliable`, and `other`
908
+ // offers every feature this side requires.
909
+ checkCompatible(other, requiredFeatures = []) {
910
+ if (this.protocolId !== PROTOCOL_ID) {
911
+ return { ok: false, field: "protocol_id", reason: `expected "${PROTOCOL_ID}"` };
912
+ }
913
+ if (other.protocolId !== PROTOCOL_ID) {
914
+ return { ok: false, field: "protocol_id", reason: `peer is not ${PROTOCOL_ID}` };
915
+ }
916
+ if (this.protocolMajorVersion !== other.protocolMajorVersion) {
917
+ return {
918
+ ok: false,
919
+ field: "protocol_major_version",
920
+ reason: `${this.protocolMajorVersion} != ${other.protocolMajorVersion}`,
921
+ };
922
+ }
923
+ if (this.codec !== other.codec) {
924
+ return { ok: false, field: "codec", reason: `${this.codec} != ${other.codec}` };
925
+ }
926
+ if (this.orderedReliable !== other.orderedReliable) {
927
+ return {
928
+ ok: false,
929
+ field: "ordered_reliable",
930
+ reason: `${this.orderedReliable} != ${other.orderedReliable}`,
931
+ };
932
+ }
933
+ const offered = new Set(other.features);
934
+ for (const required of requiredFeatures) {
935
+ if (!offered.has(required)) {
936
+ return {
937
+ ok: false,
938
+ field: "features",
939
+ reason: `peer does not offer required feature "${required}"`,
940
+ };
941
+ }
942
+ }
943
+ return { ok: true };
944
+ }
945
+
946
+ static fromWire(value) {
947
+ return new SessionHandshake(assertObject(value, "SessionHandshake"));
948
+ }
949
+
950
+ static decodeJson(data) {
951
+ const text =
952
+ data instanceof Uint8Array ? textDecoder.decode(data) : String(data);
953
+ return SessionHandshake.fromWire(JSON.parse(text));
954
+ }
955
+ }
956
+
957
+ // lazily-js binding-level conformance declaration. The conformance matrix lists
958
+ // each layer as MUST; a binding that structurally cannot host a layer MUST
959
+ // advertise the omission (capability negotiation / fail-closed) rather than stay
960
+ // silent. lazily-js runs on browser/Worker JS — a platform with no shared
961
+ // in-process address space — so it declares the C-ABI FFI carve-out
962
+ // (`ffi = none`) per protocol.md § "C-ABI FFI is required". Every other MUST
963
+ // layer is shipped: the reactive core, keyed collections, the semantic tree,
964
+ // the sequence + text CRDTs, IPC, state machine, state charts, permissions, and
965
+ // capability negotiation.
966
+ export const FfiCapability = Object.freeze({
967
+ Host: "host",
968
+ None: "none",
969
+ });
970
+
971
+ export const BINDING_CAPABILITIES = Object.freeze({
972
+ binding: "lazily-js",
973
+ // C-ABI FFI: carve-out — browser/Worker JS cannot host a native in-process ABI.
974
+ // Must NOT be advertised as embeddable; still exposes the full state plane
975
+ // (incl. CrdtSync) over IPC/WebSocket/WebRTC.
976
+ ffi: FfiCapability.None,
977
+ // Reactive core (Cell/Slot/Effect/Signal): shipped.
978
+ reactive_core: true,
979
+ // Async reactive context: optional (async.md: "A binding MAY omit it entirely").
980
+ async_context: false,
981
+ // Shipped MUST surfaces:
982
+ ipc: true,
983
+ crdt: true,
984
+ collections: { cellmap: true, celltree: true, reconcile: true },
985
+ sem_tree: true,
986
+ seq_crdt: true,
987
+ text_crdt: true,
988
+ stable_id: true,
989
+ state_machine: true,
990
+ state_charts: true,
991
+ permissions: true,
992
+ capability_negotiation: true,
993
+ // Optional (MAY) transports — not bridged by this binding:
994
+ signaling: false,
995
+ webrtc: false,
996
+ });
997
+
998
+ export const OpKind = Object.freeze({
999
+ Read: "read",
1000
+ Write: "write",
1001
+ TriggerEffect: "trigger_effect",
1002
+ });
1003
+
1004
+ export class RemoteOp {
1005
+ constructor(kind, node) {
1006
+ this.kind = kind;
1007
+ this.node = assertInteger(node, "node");
1008
+ Object.freeze(this);
1009
+ }
1010
+
1011
+ static read(node) {
1012
+ return new RemoteOp(OpKind.Read, node);
1013
+ }
1014
+
1015
+ static write(node) {
1016
+ return new RemoteOp(OpKind.Write, node);
1017
+ }
1018
+
1019
+ static triggerEffect(node) {
1020
+ return new RemoteOp(OpKind.TriggerEffect, node);
1021
+ }
1022
+ }
1023
+
1024
+ export class PermissionDenied extends Error {
1025
+ constructor(peer, op) {
1026
+ super(`peer ${peer} denied ${op.kind} on node ${op.node}`);
1027
+ this.name = "PermissionDenied";
1028
+ this.peer = peer;
1029
+ this.op = op;
1030
+ }
1031
+ }
1032
+
1033
+ export class PeerPermissions {
1034
+ #peers = new Map();
1035
+
1036
+ allow(peer, op) {
1037
+ const peerId = assertInteger(peer, "peer");
1038
+ let peerPerms = this.#peers.get(peerId);
1039
+ if (!peerPerms) {
1040
+ peerPerms = new Map();
1041
+ this.#peers.set(peerId, peerPerms);
1042
+ }
1043
+ let nodes = peerPerms.get(op.kind);
1044
+ if (!nodes) {
1045
+ nodes = new Set();
1046
+ peerPerms.set(op.kind, nodes);
1047
+ }
1048
+ const had = nodes.has(op.node);
1049
+ nodes.add(op.node);
1050
+ return !had;
1051
+ }
1052
+
1053
+ allowMany(peer, kind, nodes) {
1054
+ for (const node of nodes) {
1055
+ this.allow(peer, new RemoteOp(kind, node));
1056
+ }
1057
+ }
1058
+
1059
+ revoke(peer, op) {
1060
+ const peerPerms = this.#peers.get(peer);
1061
+ const nodes = peerPerms?.get(op.kind);
1062
+ if (!nodes?.delete(op.node)) {
1063
+ return false;
1064
+ }
1065
+ this.#prune(peer);
1066
+ return true;
1067
+ }
1068
+
1069
+ revokePeer(peer) {
1070
+ return this.#peers.delete(peer);
1071
+ }
1072
+
1073
+ isAllowed(peer, op) {
1074
+ return this.#peers.get(peer)?.get(op.kind)?.has(op.node) === true;
1075
+ }
1076
+
1077
+ canRead(peer, node) {
1078
+ return this.isAllowed(peer, RemoteOp.read(node));
1079
+ }
1080
+
1081
+ check(peer, op) {
1082
+ if (!this.isAllowed(peer, op)) {
1083
+ throw new PermissionDenied(peer, op);
1084
+ }
1085
+ }
1086
+
1087
+ filterReadable(peer, nodes) {
1088
+ return [...nodes].filter((node) => this.canRead(peer, node));
1089
+ }
1090
+
1091
+ peerCount() {
1092
+ return this.#peers.size;
1093
+ }
1094
+
1095
+ #prune(peer) {
1096
+ const peerPerms = this.#peers.get(peer);
1097
+ if (!peerPerms) {
1098
+ return;
1099
+ }
1100
+ for (const [kind, nodes] of peerPerms.entries()) {
1101
+ if (nodes.size === 0) {
1102
+ peerPerms.delete(kind);
1103
+ }
1104
+ }
1105
+ if (peerPerms.size === 0) {
1106
+ this.#peers.delete(peer);
1107
+ }
1108
+ }
1109
+ }