1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998 | /**
* Implementation-agnostic doubles + scripted scenarios.
*
* Every function here is driven by BOTH `test/vectors/generate.mjs` (against the
* TypeScript build, to produce the fixtures) and `test/*.test.mjs` (against the
* committed ClojureScript dist, to check them). Nothing in this file knows
* which implementation it is running: the classes come in as arguments and
* private members are reached through a `priv(obj, name)` adapter — `__t[name]`
* for the TS build (see helpers/expose.mjs), `obj["_" + name]` for the port.
*
* `Date.now` is pinned while a script runs, so timings (beacon cadence, stale
* sweep, stranger drop, redial backoff) land in the transcript as exact numbers.
*/
// ---------------------------------------------------------------- clock ----
const realNow = Date.now;
let fakeNow = null;
export function withClock(t0, fn) {
fakeNow = t0;
Date.now = () => fakeNow;
const tick = (ms) => (fakeNow += ms);
try {
return fn(tick, () => fakeNow);
} finally {
Date.now = realNow;
fakeNow = null;
}
}
/** Async variant — the scripts are promise-based. */
export async function withClockAsync(t0, fn) {
fakeNow = t0;
Date.now = () => fakeNow;
const tick = (ms) => (fakeNow += ms);
try {
return await fn(tick, () => fakeNow);
} finally {
Date.now = realNow;
fakeNow = null;
}
}
/** Silence Math.random so the jittered redial backoff is reproducible. */
export function withFixedRandom(value, fn) {
const real = Math.random;
Math.random = () => value;
try {
return fn();
} finally {
Math.random = real;
}
}
// -------------------------------------------------------------- libp2p ----
/**
* The slice of libp2p that lib/net.ts actually touches. Connections are plain
* records the script pushes/pops; every call the code makes is recorded so the
* transcript covers side effects (dial addresses, hangUps, published topics,
* opened streams) and not just resulting state.
*/
export function fakeLibp2p(myId) {
const calls = [];
const conns = [];
const listeners = new Map();
const handlers = new Map();
const psListeners = new Map();
const node = {
peerId: { toString: () => myId },
getConnections: () => conns.slice(),
getPeers: () => conns.map((c) => c.remotePeer),
dial: async (ma) => {
calls.push(["dial", String(ma)]);
return { remotePeer: { toString: () => "dialed" } };
},
dialProtocol: async (target, protocol) => {
calls.push(["dialProtocol", String(target), protocol]);
if (node.failDialProtocol) throw new Error("unreachable");
return { status: "open", id: `s${calls.length}` };
},
hangUp: async (peer) => {
calls.push(["hangUp", String(peer)]);
},
handle: async (protocol) => {
calls.push(["handle", protocol]);
handlers.set(protocol, true);
},
addEventListener: (evt, fn) => {
if (!listeners.has(evt)) listeners.set(evt, []);
listeners.get(evt).push(fn);
},
stop: async () => calls.push(["stop"]),
services: {
ping: { ping: async () => 1 },
pubsub: {
subscribe: (t) => calls.push(["subscribe", t]),
publish: async (t, bytes) => {
calls.push(["publish", t, bytes.length]);
return { recipients: [] };
},
addEventListener: (evt, fn) => {
if (!psListeners.has(evt)) psListeners.set(evt, []);
psListeners.get(evt).push(fn);
},
},
},
};
return {
node,
calls,
/** Add an open connection; `addr` decides direct (/webrtc) vs relayed. */
connect(peer, addr) {
conns.push({
remotePeer: { toString: () => peer },
status: "open",
remoteAddr: { toString: () => addr },
});
},
disconnect(peer) {
for (let i = conns.length - 1; i >= 0; i--) if (conns[i].remotePeer.toString() === peer) conns.splice(i, 1);
},
connOf(peer, addr, status = "open") {
return { remotePeer: { toString: () => peer }, status, remoteAddr: { toString: () => addr } };
},
};
}
const te = new TextEncoder();
/** Let the fire-and-forget promise chains inside the code under test settle. */
export const tickMicro = async (n = 8) => {
for (let i = 0; i < n; i++) await Promise.resolve();
};
// ------------------------------------------------------------ net script ----
const RELAY = "/dns4/signal.example/tcp/443/tls/ws/p2p/12D3KooRELAY";
/**
* The membership state machine end to end (PROTOCOL.md §4a/§4b): a stranger
* that never proves membership, a member proving it with a directed beacon, the
* WebRTC upgrade, name changes, `bye`, the stale sweep and the stranger drop.
*/
export async function netScript({ Net, RoomCrypto, priv }) {
const T0 = 1_700_000_000_000;
return withClockAsync(T0, async (tick) => {
const log = [];
const rcMe = await RoomCrypto.create("s".repeat(43), "chat.example/v2", "12D3KooME");
const rcMember = await RoomCrypto.create("s".repeat(43), "chat.example/v2", "12D3KooMEMBER");
const rcOther = await RoomCrypto.create("z".repeat(43), "chat.example/v2", "12D3KooOTHER");
const hub = fakeLibp2p("12D3KooME");
const events = [];
const ev = {
peerState: (p, s, n) => events.push(["peerState", p, s, n ?? null]),
peerGone: (p) => events.push(["peerGone", p]),
message: (f, p) => events.push(["message", f, p]),
binary: (f, d) => events.push(["binary", f, [...d]]),
peerReady: (p) => events.push(["peerReady", p]),
status: (u) => events.push(["status", u]),
};
const net = new Net(hub.node, rcMe, ev, () => "me", RELAY);
const p = (name) => priv(net, name);
// debugState carries `name: undefined` for unnamed peers, which JSON drops
// on one side and can surface as null on the other — flatten it explicitly.
const snap = (label) =>
log.push({
label,
events: events.splice(0),
state: net.debugState().map((r) => [r.id, r.state, r.name ?? null, !!r.ready, !!r.verified, r.conns]),
open: net.openPeers(),
});
snap("fresh");
// Discovery is the entry point that starts the stranger-drop clock (#recFor).
// A stranger is dialed over the BARE circuit only — never /webrtc (§4b).
await p("dialPeer")("12D3KooSTRANGER");
hub.connect("12D3KooSTRANGER", `${RELAY}/p2p-circuit/p2p/12D3KooSTRANGER`);
p("onConnChange")(hub.connOf("12D3KooSTRANGER", `${RELAY}/p2p-circuit/p2p/12D3KooSTRANGER`));
await tickMicro();
snap("stranger-connected");
// ... and a foreign-room beacon it sends does not decrypt
const foreign = await rcOther.sealMsg({ kind: "presence", op: "beacon", name: "nope", ts: Date.now() });
await p("onTopicMessage")("12D3KooSTRANGER", te.encode(JSON.stringify(foreign)));
snap("stranger-foreign-beacon");
tick(21_000);
p("sweep")();
snap("stranger-swept");
// a real member: discovered, dialed over the bare circuit, then proves it
await p("dialPeer")("12D3KooMEMBER");
hub.connect("12D3KooMEMBER", `${RELAY}/p2p-circuit/p2p/12D3KooMEMBER`);
p("onConnChange")(hub.connOf("12D3KooMEMBER", `${RELAY}/p2p-circuit/p2p/12D3KooMEMBER`));
await tickMicro();
snap("member-circuit");
const beacon = await rcMember.sealMsg({ kind: "presence", op: "beacon", name: "friend", ts: Date.now() });
await p("onTopicMessage")("12D3KooMEMBER", te.encode(JSON.stringify(beacon)));
await tickMicro();
snap("member-verified");
// WebRTC upgrade lands
hub.disconnect("12D3KooMEMBER");
hub.connect("12D3KooMEMBER", `${RELAY}/p2p-circuit/webrtc/p2p/12D3KooMEMBER`);
p("onConnChange")(hub.connOf("12D3KooMEMBER", `${RELAY}/p2p-circuit/webrtc/p2p/12D3KooMEMBER`));
await tickMicro();
snap("member-direct");
// a directed app message from the member (frame kind 0x00)
tick(1000);
const env = await rcMember.sealMsg({ kind: "hello", name: "friend" });
const jsonBody = te.encode(JSON.stringify(env));
const frame0 = new Uint8Array(1 + jsonBody.length);
frame0[0] = 0x00;
frame0.set(jsonBody, 1);
await p("onMsgFrame")("12D3KooMEMBER", frame0);
await tickMicro();
snap("member-directed-json");
// a directed binary payload (frame kind 0x01)
const sealed = new Uint8Array(await rcMember.sealMsgBinary(new Uint8Array([1, 2, 3, 4])));
const frame1 = new Uint8Array(1 + sealed.length);
frame1[0] = 0x01;
frame1.set(sealed, 1);
await p("onMsgFrame")("12D3KooMEMBER", frame1);
snap("member-directed-binary");
// garbage frames: too short, unknown kind, undecryptable body
await p("onMsgFrame")("12D3KooMEMBER", new Uint8Array([0x00]));
await p("onMsgFrame")("12D3KooMEMBER", new Uint8Array([0x7f, 1, 2, 3]));
await p("onMsgFrame")("12D3KooMEMBER", new Uint8Array([0x00, 123, 34, 125]));
snap("member-garbage-frames");
// name change over the topic
tick(11_000);
const renamed = await rcMember.sealMsg({ kind: "presence", op: "beacon", name: "renamed", ts: Date.now() });
await p("onTopicMessage")("12D3KooMEMBER", te.encode(JSON.stringify(renamed)));
await tickMicro();
snap("member-renamed");
// stale: no decryptable traffic for 31 s
tick(31_000);
p("sweep")();
snap("member-stale");
// a second member says bye explicitly
hub.connect("12D3KooBYE", `${RELAY}/p2p-circuit/webrtc/p2p/12D3KooBYE`);
const rcBye = await RoomCrypto.create("s".repeat(43), "chat.example/v2", "12D3KooBYE");
const hi = await rcBye.sealMsg({ kind: "presence", op: "beacon", name: "leaver", ts: Date.now() });
await p("onTopicMessage")("12D3KooBYE", te.encode(JSON.stringify(hi)));
await tickMicro();
snap("bye-peer-joined");
const bye = await rcBye.sealMsg({ kind: "presence", op: "bye", name: "leaver", ts: Date.now() });
await p("onTopicMessage")("12D3KooBYE", te.encode(JSON.stringify(bye)));
snap("bye-peer-left");
return { transcript: log, calls: hub.calls.map((c) => c.map(String)) };
});
}
/** Relay lifecycle: ensureRelay, ping-driven hangUp, jittered backoff. */
export async function relayScript({ Net, RoomCrypto, priv }) {
const T0 = 1_700_000_000_000;
return withClockAsync(T0, async (tick) => {
const out = [];
const rc = await RoomCrypto.create("s".repeat(43), "chat.example/v2", "12D3KooME");
const hub = fakeLibp2p("12D3KooME");
const status = [];
const net = new Net(hub.node, rc, {
peerState() {}, peerGone() {}, message() {}, binary() {}, peerReady() {},
status: (u) => status.push(u),
}, () => "me", RELAY);
const p = (name) => priv(net, name);
out.push({ label: "relayPeer", value: net.relayUp });
p("ensureRelay")();
await Promise.resolve();
out.push({ label: "after-dial", status: status.splice(0), calls: hub.calls.splice(0) });
hub.connect("12D3KooRELAY", `${RELAY}`);
p("ensureRelay")();
out.push({ label: "already-connected", status: status.splice(0), calls: hub.calls.splice(0), up: net.relayUp });
hub.disconnect("12D3KooRELAY");
p("onConnChange")(hub.connOf("12D3KooRELAY", RELAY, "closed"));
out.push({ label: "relay-lost", status: status.splice(0), up: net.relayUp });
// Backoff sequence. Math.random is pinned at 0.5 → delay = base * 1.0, and
// the fake setTimeout returns 0 — falsy, so #redialTimer never blocks the
// next schedule and one loop walks the whole 500·2ⁿ (cap 15 s) ladder.
// (That falsy-0 detail is exactly the JS-truthiness trap a CLJS port has to
// reproduce: `0` is truthy in Clojure, so the guard must test JS truthiness.)
// A FRESH Net: the one above already has a live redial timer pending from
// #onConnChange, and #redialTimer is exactly what blocks a second schedule.
const hub2 = fakeLibp2p("12D3KooME2");
const net2 = new Net(hub2.node, rc, {
peerState() {}, peerGone() {}, message() {}, binary() {}, peerReady() {}, status() {},
}, () => "me", RELAY);
const delays = withFixedRandom(0.5, () => {
const seen = [];
const realSetTimeout = globalThis.setTimeout;
globalThis.setTimeout = (_fn, ms) => {
seen.push(ms);
return 0;
};
try {
for (let i = 0; i < 8; i++) priv(net2, "scheduleRedial")();
} finally {
globalThis.setTimeout = realSetTimeout;
}
return seen;
});
out.push({ label: "backoff", delays });
tick(0);
return out;
});
}
// ------------------------------------------------------------- orbit/helia ----
/**
* The slice of @orbitdb/core + helia that lib/log.ts touches. Entries are plain
* records; `storage` and `blockstore` are Maps. Enough to drive the entry
* projection, the author binding, the local-reachability pre-check and the
* image DAG walk without a network or a real IPFS node.
*/
export function fakeStack({ entries = [], identities = {}, blocks = new Map() } = {}) {
const calls = [];
const joined = new Set(entries.map((e) => e.hash));
const db = {
address: "/orbitdb/zdpuFAKE",
name: "fake",
add: async (value) => {
calls.push(["add", JSON.stringify(value)]);
return `hash-${calls.length}`;
},
log: {
iterator: ({ amount } = {}) => ({
// newest -> oldest, exactly what the oplog iterator yields
[Symbol.asyncIterator]() {
const list = entries.slice().reverse().slice(0, amount ?? entries.length);
let i = 0;
return { next: async () => (i < list.length ? { value: list[i++], done: false } : { done: true }) };
},
}),
has: async (hash) => joined.has(hash),
joinEntry: async (entry) => {
calls.push(["joinEntry", entry.hash]);
if (entry.hash === "REJECT") throw new Error("access denied");
if (joined.has(entry.hash)) return false;
joined.add(entry.hash);
entries.push(entry);
return true;
},
storage: {
get: async (hash) => blocks.get(hash),
put: async (hash, bytes) => {
calls.push(["storage.put", hash, bytes.length]);
blocks.set(hash, bytes);
},
},
},
sync: { start: async () => calls.push(["sync.start"]), stop: async () => {} },
events: { on: (evt) => calls.push(["events.on", evt]), off: () => {} },
close: async () => calls.push(["db.close"]),
drop: async () => calls.push(["db.drop"]),
};
const orbitdb = {
id: "orbit-fake",
// `hash` is a base58btc CID over the record's dag-cbor block, exactly the
// form myIdentityBlock() re-parses (an unparseable one must yield null).
identity: {
id: "myAuthor",
publicKey: "myKey",
hash: "zdj7WWeQ43G6JJvLWQWZpyHuAMq6uYWRjkBXFad11vE2LHhQ7",
bytes: new Uint8Array([9, 9]),
},
identities: { getIdentity: async (h) => identities[h] },
stop: async () => calls.push(["orbitdb.stop"]),
};
const store = new Map(); // cid string -> bytes
const pins = new Set();
const helia = {
blockstore: {
has: async (cid) => store.has(cid.toString()),
get: async (cid) => store.get(cid.toString()),
put: async (cid, bytes) => {
calls.push(["blockstore.put", cid.toString(), bytes.length]);
store.set(cid.toString(), bytes);
},
},
pins: {
add: (cid) => asyncGen([cid], () => calls.push(["pins.add", cid.toString()]) && pins.add(cid.toString())),
rm: (cid) => asyncGen([cid], () => calls.push(["pins.rm", cid.toString()]) && pins.delete(cid.toString())),
},
gc: async () => calls.push(["gc"]),
logger: { forComponent: () => Object.assign(() => {}, { error: () => {}, trace: () => {}, enabled: false }) },
};
return { db, orbitdb, helia, calls, store, pins, entries, joined };
}
function asyncGen(items, onStart) {
return {
[Symbol.asyncIterator]() {
onStart?.();
let i = 0;
return { next: async () => (i < items.length ? { value: items[i++], done: false } : { done: true }) };
},
};
}
const mkEntry = (o) => ({ next: [], refs: [], clock: { id: "c", time: 0 }, v: 2, ...o });
/**
* lib/log's SIX private seams, driven directly.
*
* `logScript` below covers the public surface and destructures `priv` without
* ever calling it, so until this script existed `_emit`, `_onUpdate`,
* `_sweepUnseen`, `_authorOf`, `_canJoinLocally` and `_identityLocal` were
* reachable from the test harness and exercised by nothing inside this repo.
* chat drives `_onUpdate` through its own log-effects vector, but chat consumes
* a sha-pinned install — a regression here would surface one repo downstream,
* after a kit release, or not at all.
*
* Every property below is one whose failure is silent: a wrong answer, not a
* throw. They are stated where they are asserted.
*/
export async function logSeamsScript({ RoomLog, RoomCrypto, makeLogEncryption, priv }) {
const out = [];
const say = (label, value) => out.push({ label, value });
// A real base58btc CID: identity-local parses the ref as one before it can ask
// the blockstore, so a made-up string exercises the parse-failure path. The
// blockstore is keyed by `cid.toString()`, which is base32 and NOT the ref
// string — seeding under the ref itself silently stores nothing findable.
const { CID } = await import("multiformats/cid");
const { base58btc } = await import("multiformats/bases/base58");
const CID_LOCAL = "zdj7WWeQ43G6JJvLWQWZpyHuAMq6uYWRjkBXFad11vE2LHhQ7";
const CID_REMOTE = "zdj7WbXGDGDy8UfBQhLdTGtEbxKxsEXSNBhrJZLZFVYMLBQmC";
const storeKey = (ref) => CID.parse(ref, base58btc).toString();
const rc = await RoomCrypto.create("s".repeat(43), "chat.example/v2", "12D3KooME");
const encryption = await makeLogEncryption(rc);
const imgCipher = await rc.imgCipher();
const mk = (o) => ({ next: [], refs: [], clock: { id: "c", time: 0 }, v: 2, ...o });
const chat = (hash, time, text, extra = {}) =>
mk({ hash, clock: { id: "c", time }, identity: "idOK", key: "deviceKeyA",
payload: { op: "ADD", key: null, value: { t: "chat", ts: time * 10, name: "a", text } }, ...extra });
const identities = {
idOK: { id: "b64urlPub", publicKey: "deviceKeyA", type: "sueta" },
idForged: { id: "victimPub", publicKey: "deviceKeyVICTIM", type: "sueta" },
idPublicKey: { id: "devIdB", publicKey: "deviceKeyB", type: "publickey" },
idWeird: { id: "whoPub", publicKey: "deviceKeyC", type: "ethereum" },
// idMissing is deliberately absent -> getIdentity resolves undefined
};
// count the two network-ish lookups the seams are supposed to avoid repeating
let getIdentityCalls = 0, blockstoreHasCalls = 0;
const build = (entries) => {
const stack = fakeStack({ entries, identities });
const realGet = stack.orbitdb.identities.getIdentity;
stack.orbitdb.identities.getIdentity = async (h) => { getIdentityCalls++; return realGet(h); };
const realHas = stack.helia.blockstore.has;
stack.helia.blockstore.has = async (cid) => { blockstoreHasCalls++; return realHas(cid); };
const log = new RoomLog(stack.db, stack.orbitdb, stack.helia, imgCipher, encryption);
return { log, stack };
};
const settle = async (n = 40) => { for (let i = 0; i < n; i++) await Promise.resolve(); };
// ---- _emit --------------------------------------------------------------
// `_seen` is marked BEFORE the author lookup awaits, so two calls for one
// hash issued without awaiting between them still emit once. Marking after
// the await instead would emit twice and nothing else would notice.
{
const { log } = build([]);
const emitted = [];
log.onEntry = (e) => emitted.push(e);
const emit = priv(log, "emit");
const e1 = chat("e1", 1, "one");
emit(e1); emit(e1); // neither awaited
await settle();
say("emit-reentrant-same-hash", { emitted: emitted.map((e) => e.hash), rows: emitted.length });
say("emit-row-shape", emitted[0] ?? null);
emitted.length = 0;
await emit(e1); // a third, now fully settled
say("emit-after-settled-duplicate", emitted.length);
emitted.length = 0;
await emit(mk({ hash: "eNOOP", identity: "idOK", key: "deviceKeyA", payload: { op: "ADD", value: 42 } }));
await emit(mk({ hash: "eNOT", identity: "idOK", key: "deviceKeyA", payload: { op: "ADD", value: { ts: 1 } } }));
await emit(mk({ identity: "idOK", key: "deviceKeyA", payload: { op: "ADD", value: { t: "chat", ts: 1 } } }));
say("emit-drops-untyped-and-hashless", emitted.length);
}
// ---- _sweepUnseen -------------------------------------------------------
// The oplog iterator yields newest→oldest; the sweep reverses it, because the
// app must fold state oldest-first. A dropped `.reverse()` still emits every
// entry, just backwards — silent, and fatal to any Lamport-ordered fold.
{
const { log } = build([chat("s1", 1, "one"), chat("s2", 2, "two"), chat("s3", 3, "three")]);
const emitted = [];
log.onEntry = (e) => emitted.push(e.hash);
await priv(log, "sweepUnseen")();
await settle();
say("sweepUnseen-order-oldest-first", emitted);
}
// ---- _onUpdate ----------------------------------------------------------
// Two properties: it returns undefined (its sibling emitUnseen returns the
// chain, and lib/mailbox depends on the difference), and the `_seen` test is
// read at RUN time — two updates queued back to back, the second linking to
// the first, must produce ONE sweep.
{
const entries = [chat("u1", 1, "one"), chat("u2", 2, "two", { next: ["u1"] })];
const { log, stack } = build(entries.slice());
let sweeps = 0;
const realIter = stack.db.log.iterator;
stack.db.log.iterator = (o) => { sweeps++; return realIter(o); };
const emitted = [];
log.onEntry = (e) => emitted.push(e.hash);
const onUpdate = priv(log, "onUpdate");
const ret = onUpdate(entries[1]);
say("onUpdate-return", ret === undefined ? "undefined" : typeof ret);
await settle();
const sweepsAfterFirst = sweeps;
const u3 = chat("u3", 3, "three");
const u4 = chat("u4", 4, "four", { next: ["u3"] });
sweeps = 0;
onUpdate(u3); onUpdate(u4); // queued back to back, neither awaited
await settle();
say("onUpdate-sweeps", { forBranchJoin: sweepsAfterFirst, forQueuedPair: sweeps });
say("onUpdate-emitted", emitted);
}
// ---- _authorOf ----------------------------------------------------------
// The binding only counts when the entry's SIGNING KEY is the referenced
// identity's key. Every row here is a way for a forged or untrusted author to
// be believed, which is exactly the failure that produces no error at all.
{
const { log } = build([]);
const authorOf = priv(log, "authorOf");
const rows = {};
rows.matchingKey = await authorOf(mk({ hash: "a1", identity: "idOK", key: "deviceKeyA" }));
rows.forgedKey = await authorOf(mk({ hash: "a2", identity: "idForged", key: "deviceKeyATTACKER" }));
rows.publickeyType = await authorOf(mk({ hash: "a3", identity: "idPublicKey", key: "deviceKeyB" }));
rows.untrustedType = await authorOf(mk({ hash: "a4", identity: "idWeird", key: "deviceKeyC" }));
rows.noIdentityRef = await authorOf(mk({ hash: "a5", key: "deviceKeyA" }));
rows.noSigningKey = await authorOf(mk({ hash: "a6", identity: "idOK" }));
say("authorOf-table", rows);
// FAILURES ARE CACHED TOO: an unresolvable ref must not be re-fetched once
// per entry that names it. Without the negative cache this is 3 lookups.
getIdentityCalls = 0;
for (const h of ["b1", "b2", "b3"]) await authorOf(mk({ hash: h, identity: "idMissing", key: "k" }));
say("authorOf-unresolvable-lookups", getIdentityCalls);
getIdentityCalls = 0;
for (const h of ["c1", "c2", "c3"]) await authorOf(mk({ hash: h, identity: "idOK", key: "deviceKeyA" }));
// and a RESOLVABLE ref is cached the same way: the table above already
// resolved idOK, so three more entries naming it cost zero lookups
say("authorOf-resolvable-lookups-when-cached", getIdentityCalls);
}
// ---- _identityLocal -----------------------------------------------------
// "Resolvable without the network." A false positive here sends joinEntry
// into a 30 s bitswap timeout; a false negative silently refuses a join.
{
const { log, stack } = build([]);
const identityLocal = priv(log, "identityLocal");
const rows = {};
rows.noRef = await identityLocal(mk({ hash: "i1" }));
rows.unparseable = await identityLocal(mk({ hash: "i2", identity: "not-a-cid" }));
rows.notInStore = await identityLocal(mk({ hash: "i3", identity: CID_LOCAL }));
stack.store.set(storeKey(CID_LOCAL), new Uint8Array([1, 2, 3]));
rows.inStore = await identityLocal(mk({ hash: "i4", identity: CID_LOCAL }));
say("identityLocal-table", rows);
// a ref already in the author cache short-circuits BEFORE the blockstore
await priv(log, "authorOf")(mk({ hash: "i5", identity: "idOK", key: "deviceKeyA" }));
blockstoreHasCalls = 0;
rows.cachedRef = await identityLocal(mk({ hash: "i6", identity: "idOK" }));
say("identityLocal-cached-ref", { local: rows.cachedRef, blockstoreLookups: blockstoreHasCalls });
}
// ---- _canJoinLocally ----------------------------------------------------
// The traversal is next ∪ refs, stopping at entries already in the log, and
// EVERY hop's identity must be local — one remote hop makes the whole join
// remote.
{
const inLog = [chat("k1", 1, "one")];
const { log, stack } = build(inLog);
stack.store.set(storeKey(CID_LOCAL), new Uint8Array([1, 2, 3]));
const canJoin = priv(log, "canJoinLocally");
const rows = {};
// hop set empty: only the entry itself, identity local
rows.selfOnlyLocal = await canJoin(mk({ hash: "k2", identity: CID_LOCAL }));
// identity not local -> false without traversing
rows.selfNotLocal = await canJoin(mk({ hash: "k3", identity: CID_REMOTE }));
// hop already in the log: traversal stops there, so it stays local
rows.hopAlreadyInLog = await canJoin(mk({ hash: "k4", identity: CID_LOCAL, next: ["k1"] }));
// a hop that is neither in the log nor resolvable
rows.hopUnknown = await canJoin(mk({ hash: "k5", identity: CID_LOCAL, refs: ["k9"] }));
say("canJoinLocally-table", rows);
}
return out;
}
/**
* The log's projection and verification surface: how a stored entry becomes a
* LogEntryMeta, when the author id survives the signer↔identity binding, the
* sweep for stragglers behind a branch join, and the ingest result table.
*/
export async function logScript({ RoomLog, RoomCrypto, makeLogEncryption, priv }) {
const out = [];
const rc = await RoomCrypto.create("l".repeat(43), "chat.example/v2", "12D3KooME");
const encryption = await makeLogEncryption(rc);
const imgCipher = await rc.imgCipher();
const identities = {
// a sueta identity whose publicKey matches the entry key -> author survives
idOK: { id: "b64urlPub", publicKey: "deviceKeyA", type: "sueta" },
// same identity referenced by an entry signed with a DIFFERENT key -> ""
idForged: { id: "victimPub", publicKey: "deviceKeyVICTIM", type: "sueta" },
// OrbitDB's own default provider
idPublicKey: { id: "devIdB", publicKey: "deviceKeyB", type: "publickey" },
// an unknown provider type is not trusted for authorship
idWeird: { id: "whoPub", publicKey: "deviceKeyC", type: "ethereum" },
};
const entries = [
mkEntry({ hash: "e1", identity: "idOK", key: "deviceKeyA", clock: { id: "c", time: 1 },
payload: { op: "ADD", key: null, value: { t: "chat", ts: 10, name: "a", text: "one" } } }),
mkEntry({ hash: "e2", identity: "idForged", key: "deviceKeyATTACKER", clock: { id: "c", time: 2 },
payload: { op: "ADD", key: null, value: { t: "chat", ts: 20, name: "victim", text: "forged" } } }),
mkEntry({ hash: "e3", identity: "idPublicKey", key: "deviceKeyB", clock: { id: "c", time: 3 },
payload: { op: "ADD", key: null, value: { t: "react", ts: 30, name: "b", target: "e1", emoji: "🔥", op: "add" } } }),
mkEntry({ hash: "e4", identity: "idWeird", key: "deviceKeyC", clock: { id: "c", time: 4 },
payload: { op: "ADD", key: null, value: { t: "name", ts: 40, name: "c" } } }),
// payload not wrapped by the events DB (defensive branch)
mkEntry({ hash: "e5", identity: "idOK", key: "deviceKeyA", clock: { id: "c", time: 5 },
payload: { t: "call", ts: 50, name: "a" } }),
// non-object / typeless payloads must be dropped, not emitted
mkEntry({ hash: "e6", identity: "idOK", key: "deviceKeyA", payload: { op: "ADD", value: 42 } }),
mkEntry({ hash: "e7", identity: "idOK", key: "deviceKeyA", payload: { op: "ADD", value: { ts: 1 } } }),
// no identity ref at all, and an identity ref with no signing key
mkEntry({ hash: "e8", key: "deviceKeyA", payload: { op: "ADD", value: { t: "chat", ts: 80, name: "x", text: "anon" } } }),
mkEntry({ hash: "e9", identity: "idOK", payload: { op: "ADD", value: { t: "chat", ts: 90, name: "y", text: "nokey" } } }),
];
const stack = fakeStack({ entries: entries.slice(), identities });
const log = new RoomLog(stack.db, stack.orbitdb, stack.helia, imgCipher, encryption);
const emitted = [];
log.onEntry = (e) => emitted.push({ hash: e.hash, from: e.from, clock: e.clock, op: e.op });
log.onError = (err) => emitted.push({ error: String(err.message ?? err) });
out.push({ label: "address", value: log.address });
out.push({ label: "myAuthorId", value: log.myAuthorId });
const n = await log.loadTail(500);
out.push({ label: "loadTail", count: n, emitted: emitted.splice(0) });
// second pass emits nothing: #seen dedupes by hash
const n2 = await log.loadTail(500);
out.push({ label: "loadTail-again", count: n2, emitted: emitted.splice(0) });
// entryMeta: the display-only projection for an entry that cannot join yet
const unjoinable = mkEntry({ hash: "eX", identity: "idOK", key: "deviceKeyA", clock: { id: "c", time: 7 },
payload: { op: "ADD", value: { t: "chat", ts: 70, name: "a", text: "pending" } } });
out.push({ label: "entryMeta-identity-not-local", value: await log.entryMeta(unjoinable) });
out.push({ label: "entryMeta-bad-payload", value: await log.entryMeta(mkEntry({ hash: "eY", payload: null })) });
// ingest: duplicate / deferred / rejected / joined
const { CID } = await import("multiformats/cid");
const { base58btc } = await import("multiformats/bases/base58");
// An entry whose identity ref is neither a stored block nor already in the
// author cache is DEFERRED — never fetched over bitswap with a 30 s timeout.
const unknownIdCid = "zdj7WWeQ43G6JJvLWQWZpyHuAMq6uYWRjkBXFad11vE2LHhQ7";
out.push({ label: "ingest-duplicate", value: await log.ingestEntry(entries[0]) });
out.push({
label: "ingest-deferred-identity-block-absent",
value: await log.ingestEntry(mkEntry({ ...unjoinable, hash: "eDEF", identity: unknownIdCid })),
});
// an identity already resolved this session (author cache) counts as local
out.push({ label: "ingest-joined-identity-cached", value: await log.ingestEntry(unjoinable) });
// ... and so does one whose block is in the local blockstore. NOTE the
// blockstore is keyed by cid.toString() — the DEFAULT (base32) encoding —
// while the entry's identity ref is base58btc: the code parses with
// base58btc and looks up by the re-encoded string, which is what a real
// Helia blockstore does too.
const idCid = CID.parse(unknownIdCid, base58btc);
stack.store.set(idCid.toString(), new Uint8Array([1]));
out.push({
label: "ingest-joined-identity-stored",
value: await log.ingestEntry(mkEntry({ ...unjoinable, hash: "eZ", identity: unknownIdCid })),
});
out.push({
label: "ingest-rejected",
value: await log.ingestEntry(mkEntry({ hash: "REJECT", identity: unknownIdCid, key: "k" })),
});
// an entry with no identity ref at all is deferred, never joined
out.push({ label: "ingest-no-identity-ref", value: await log.ingestEntry(mkEntry({ hash: "eNOID", key: "k" })) });
await log.emitUnseen();
out.push({ label: "emitUnseen", emitted: emitted.splice(0) });
// append + the onAppended hook (which must never fail an append)
const hooks = [];
log.onAppended = (h) => {
hooks.push(h);
throw new Error("mailbox is down");
};
out.push({ label: "append", hash: await log.append({ t: "chat", ts: 1, name: "me", text: "hi" }), hooks: hooks.slice() });
// sealedEntryBytes / putEntryBlock / hasEntry
await log.putEntryBlock("blk1", new Uint8Array([7, 7, 7]));
out.push({ label: "sealedEntryBytes", value: [...((await log.sealedEntryBytes("blk1")) ?? [])] });
out.push({ label: "sealedEntryBytes-missing", value: await log.sealedEntryBytes("nope") });
out.push({ label: "hasEntry", yes: await log.hasEntry("e1"), no: await log.hasEntry("nope") });
// myIdentityBlock: hash is parsed as base58btc
const mib = log.myIdentityBlock();
out.push({ label: "myIdentityBlock", cid: mib?.cid?.toString() ?? null, bytes: [...(mib?.bytes ?? [])] });
// image DAG walk: root + raw leaves is fine, root + dag-pb child is refused
const dagPb = await import("@ipld/dag-pb");
const { sha256 } = await import("multiformats/hashes/sha2");
const raw = { code: 0x55, name: "raw" };
const leafBytes = new Uint8Array([1, 2, 3]);
const leafCid = CID.createV1(raw.code, await sha256.digest(leafBytes));
const rootBytes = dagPb.encode({ Data: undefined, Links: [{ Hash: leafCid, Name: "", Tsize: 3 }] });
const rootCid = CID.createV1(0x70, await sha256.digest(rootBytes));
stack.store.set(leafCid.toString(), leafBytes);
stack.store.set(rootCid.toString(), rootBytes);
const dag = await log.imageDagBlocks(rootCid.toString());
out.push({ label: "imageDagBlocks", order: dag.map((b) => b.cid.toString()), rootLast: dag.at(-1)?.cid?.toString() === rootCid.toString() });
out.push({ label: "imageDagBlocks-missing", value: await log.imageDagBlocks(leafCid.toString().replace(/.$/, "a")) });
// a two-level dag-pb DAG is refused outright (partial DAGs must not ship)
const midBytes = dagPb.encode({ Data: undefined, Links: [{ Hash: rootCid, Name: "", Tsize: 3 }] });
const midCid = CID.createV1(0x70, await sha256.digest(midBytes));
stack.store.set(midCid.toString(), midBytes);
out.push({ label: "imageDagBlocks-too-deep", value: await log.imageDagBlocks(midCid.toString()) });
out.push({ label: "pinImageIfLocal", value: await log.pinImageIfLocal(rootCid.toString()) });
out.push({ label: "pinImageIfLocal-absent", value: await log.pinImageIfLocal(midCid.toString()) });
// images: seal -> unixfs -> read back is covered by the round-trip test; here
// only the raw block plumbing (no unixfs) is pinned
out.push({ label: "rawBlock", value: [...((await log.rawBlock(leafCid)) ?? [])] });
await log.putRawBlock(CID.parse(leafCid.toString()), new Uint8Array([4, 5]));
await log.putIdentityBlock(CID.parse(leafCid.toString()), new Uint8Array([6]));
await log.pruneImages(new Set([rootCid.toString()]), [rootCid.toString(), leafCid.toString()]);
await log.close();
out.push({ label: "calls", value: stack.calls.map((c) => c.map(String)) });
void base58btc;
void priv;
return out;
}
// ----------------------------------------------------------- chat / board ----
/** The reaction fold, message projection and history ordering (§5). */
export async function chatScript({ ChatClient, priv }) {
const out = [];
const client = new ChatClient(null, null, "ROOMID", "me", null, "chat.example/v2");
const events = [];
client.on("message", (m) => events.push(["message", m.id, m.name, m.text ?? null, reactionsOf(m)]));
client.on("reaction", (t, e, by, op) => events.push(["reaction", t, e, by, op]));
client.on("peer", (id, p) => events.push(["peer", id, p.state, p.name ?? null, p.ident?.pub ?? null]));
client.on("peerGone", (id) => events.push(["peerGone", id]));
const apply = priv(client, "applyEntry");
const E = (hash, from, clock, op) => ({ hash, from, clock, op });
// out-of-order arrival: a reaction lands before the message it targets, then
// the message folds the pending reaction in
apply(E("r1", "authorB", 5, { t: "react", ts: 5, name: "b", target: "m1", emoji: "🔥", op: "add" }));
out.push({ label: "reaction-before-message", events: events.splice(0) });
apply(E("m1", "authorA", 1, { t: "chat", ts: 100, name: "a", text: "hello" }));
out.push({ label: "message-folds-pending-reaction", events: events.splice(0) });
// last-op-wins per (author, target, emoji) by (clock, hash)
apply(E("r2", "authorB", 6, { t: "react", ts: 6, name: "b", target: "m1", emoji: "🔥", op: "remove" }));
out.push({ label: "reaction-remove-newer-clock", events: events.splice(0), history: hist(client) });
apply(E("r3", "authorB", 4, { t: "react", ts: 4, name: "b", target: "m1", emoji: "🔥", op: "add" }));
out.push({ label: "reaction-stale-clock-ignored", events: events.splice(0), history: hist(client) });
apply(E("r0", "authorB", 6, { t: "react", ts: 6, name: "b", target: "m1", emoji: "🔥", op: "add" }));
out.push({ label: "reaction-same-clock-lower-hash-wins", events: events.splice(0), history: hist(client) });
// an unattributed author ("" from the binding check) never lands in a set
apply(E("r4", "", 9, { t: "react", ts: 9, name: "?", target: "m1", emoji: "👍", op: "add" }));
out.push({ label: "reaction-empty-author", events: events.splice(0), history: hist(client) });
// img + thread + ordering by (ts, id)
apply(E("m3", "authorA", 2, { t: "img", ts: 300, name: "a", cid: "bafyIMG", mime: "image/webp", bytes: 12, w: 4, h: 5, thread: "m1" }));
apply(E("m2", "authorC", 3, { t: "chat", ts: 200, name: "c", text: "second", thread: "m1" }));
apply(E("m0", "authorC", 3, { t: "chat", ts: 100, name: "c", text: "tie-with-m1" }));
out.push({ label: "img-and-threads", events: events.splice(0), history: hist(client) });
// ops the projector ignores
apply(E("n1", "authorA", 7, { t: "name", ts: 700, name: "renamed" }));
apply(E("c1", "authorA", 8, { t: "call", ts: 800, name: "a" }));
apply(E("l1", "authorA", 9, { t: "legacy", ts: 900, msgs: [] }));
out.push({ label: "ignored-ops", events: events.splice(0), historyLen: client.history().length });
// live payloads: hello/name shape gating
const onLive = priv(client, "onLive");
onLive("peer1", { kind: "hello", name: " a very long name that exceeds the forty character clamp " });
onLive("peer2", { kind: "name", name: "bee" });
onLive("peer3", { kind: "draw", id: "x" });
onLive("peer4", null);
onLive("peer5", { kind: "hello" });
out.push({ label: "live-payloads", events: events.splice(0), peers: peersOf(client) });
const onPeerState = priv(client, "onPeerState");
onPeerState("peer2", "direct", "beeName");
onPeerState("peer9", "relayed", undefined);
out.push({ label: "peer-state", events: events.splice(0), peers: peersOf(client) });
return out;
}
const reactionsOf = (m) => [...m.reactions.entries()].map(([e, by]) => [e, [...by].sort()]).sort();
const hist = (c) => c.history().map((m) => [m.id, m.ts, m.text ?? `img:${m.img?.cid}`, m.thread ?? null, reactionsOf(m)]);
const peersOf = (c) => [...c.peers().entries()].map(([id, p]) => [id, p.state, p.name ?? null, p.ident?.pub ?? null, p.ident?.fp?.emoji ?? null]).sort();
/** The board element fold: add/edit/del, terminal tombstones, stroke encoding. */
export async function boardScript({ BoardClient, priv }) {
const out = [];
const client = new BoardClient(null, null, "ROOMID", "me");
const events = [];
client.on("element", (el, from) => events.push(["element", el.id, el.k, from]));
client.on("edited", (id, patch) => events.push(["edited", id, patch]));
client.on("deleted", (ids) => events.push(["deleted", ids]));
const apply = priv(client, "applyEntry");
const E = (hash, from, op) => ({ hash, from, clock: 0, op });
apply(E("a1", "authorA", { t: "add", ts: 1, name: "a", el: { id: "s1", k: "stroke", x: 1, y: 2, pts: [3, 4], w: 2, c: 0 } }));
apply(E("a2", "authorB", { t: "add", ts: 2, name: "b", el: { id: "t1", k: "text", x: 5, y: 6, text: "hi", size: 16, c: 3 } }));
out.push({ label: "adds", events: events.splice(0), elements: els(client) });
apply(E("e1", "authorA", { t: "edit", ts: 3, name: "a", id: "s1", p: { w: 8, c: 5 } }));
apply(E("e2", "authorA", { t: "edit", ts: 4, name: "a", id: "missing", p: { w: 1 } }));
apply(E("e3", "authorA", { t: "edit", ts: 5, name: "a", id: "t1", p: null }));
out.push({ label: "edits", events: events.splice(0), elements: els(client) });
apply(E("d1", "authorB", { t: "del", ts: 6, name: "b", ids: ["s1"] }));
out.push({ label: "delete", events: events.splice(0), elements: els(client) });
// tombstones are terminal: a re-add of a deleted id is ignored forever
apply(E("a3", "authorA", { t: "add", ts: 7, name: "a", el: { id: "s1", k: "stroke", x: 0, y: 0, pts: [], w: 1, c: 1 } }));
out.push({ label: "readd-after-delete", events: events.splice(0), elements: els(client) });
apply(E("d2", "authorB", { t: "del", ts: 8, name: "b" }));
apply(E("a4", "authorA", { t: "add", ts: 9, name: "a", el: { k: "stroke" } }));
apply(E("a5", "authorA", { t: "add", ts: 10, name: "a" }));
apply(E("u1", "authorA", { t: "wat", ts: 11 }));
out.push({ label: "malformed-ops", events: events.splice(0), elements: els(client) });
// ---- fold policy: convergence under replication order --------------------
//
// Everything above runs on ONE client with clock 0 throughout and never
// duplicates an add or races two edits, so it pins the fold that predates
// these rules unchanged. Everything below gets a FRESH client per label,
// because the point is what happens to the same entry SET fed in different
// arrival orders — a fold that depends on arrival order makes two bots
// disagree with each other and with every browser.
//
// `duplicate-add-earliest-wins` and `edit-lww-per-field` are the browser's
// own scripts, entry for entry (board/client/test/helpers/app-fakes.mjs,
// projectorScript; pinned there in test/vectors/projector.json), so the two
// independent implementations are held against the same scenarios. The
// `-ascending` / `-reversed` / `-asc` / `-desc` siblings are the same entries
// permuted, and MUST fold to the same elements.
// structuredClone is NOT decoration: the client stores `op.el` BY REFERENCE
// and an edit mutates that very object, so replaying a shared entry array in
// a second script would feed it the FIRST script's mutated element. Caught
// only by eyeballing the generated vector (a fresh note came out carrying a
// previous script's `text:"t"`) — dev/docs/CLJS.md's "snapshot by value,
// never by reference", in its fixture-construction form.
const fold = (label, entries) => {
const c = new BoardClient(null, null, "ROOMID", "me");
const ev = [];
c.on("element", (el, from) => ev.push(["element", el.id, el.k, from]));
c.on("edited", (id, patch) => ev.push(["edited", id, patch]));
c.on("deleted", (ids) => ev.push(["deleted", ids]));
const ap = priv(c, "applyEntry");
for (const e of entries) ap(structuredClone(e));
out.push({ label, events: ev, elements: els(c) });
};
const EC = (hash, clock, op) => ({ hash, from: "authorA", clock, op });
// the browser's `note(EL(1), over)`: every field the edits touch already
// exists, so no patch can INTRODUCE a key — key insertion order (and hence
// the JSON bytes of elements()) stays arrival-order-independent too
const ADD = (hash, clock, over) =>
EC(hash, clock, {
t: "add", ts: 1, name: "a",
el: { id: "x1", k: "note", x: 0, y: 0, w: 100, h: 100, text: "", c: 0, ...over },
});
const ED = (hash, clock, p) => EC(hash, clock, { t: "edit", ts: 1, name: "a", id: "x1", p });
// Rule 1 — duplicate add for a known id: the EARLIEST (clock, hash) wins.
// (5,"hz") lands first, (2,"ha") supersedes it, (9,"hb") is ignored.
const dupAdds = [
ADD("hz", 5, { text: "late" }),
ADD("ha", 2, { text: "early" }),
ADD("hb", 9, { text: "ignored" }),
];
fold("duplicate-add-earliest-wins", dupAdds);
fold("duplicate-add-earliest-wins-ascending", [dupAdds[1], dupAdds[0], dupAdds[2]]);
// identical (clock, hash) re-ingested: idempotent — the existing record is
// kept (that is what the `<=` in the comparison buys), so no second event and
// no second element. The third entry proves it is the (clock, hash) and not
// object identity that decides: same key, different payload, still ignored.
fold("duplicate-add-idempotent", [
ADD("ha", 2, { text: "early" }),
ADD("ha", 2, { text: "early" }),
ADD("ha", 2, { text: "same-clock-and-hash-different-payload" }),
]);
// equal clock, differing hash — ascending hash wins from either direction
fold("duplicate-add-equal-clock-hash-desc", [ADD("hb", 5, { text: "hb" }), ADD("ha", 5, { text: "ha" })]);
fold("duplicate-add-equal-clock-hash-asc", [ADD("ha", 5, { text: "ha" }), ADD("hb", 5, { text: "hb" })]);
// Rule 2 — racing edits fold per (element id, field). The browser's script:
// h2 sets x and c; h3 is older so it loses x but wins `text` (nobody set it);
// h9 has the same clock and a greater hash, so it takes x; h0 has the same
// clock and a smaller hash, so it loses. => x 7, c 3, text "t"
const lwwAdd = ADD("h1", 1, {});
const lwwEdits = [
ED("h2", 5, { x: 50, c: 3 }),
ED("h3", 2, { x: 999, text: "t" }),
ED("h9", 5, { x: 7 }),
ED("h0", 5, { x: -7 }),
];
// the add stays first in both orders: unlike the browser, this client has NO
// pending buffer for an edit that arrives before its add (it drops it), so
// permuting the add too would test that divergence, not this rule
fold("edit-lww-per-field", [lwwAdd, ...lwwEdits]);
fold("edit-lww-per-field-reversed", [lwwAdd, ...[...lwwEdits].reverse()]);
// two edits to DIFFERENT fields, both orders: "A moves it while B recolours
// it" must converge to BOTH effects, whichever arrives first
fold("edit-two-fields-older-first", [lwwAdd, ED("hA", 2, { x: 10 }), ED("hB", 3, { c: 5 })]);
fold("edit-two-fields-newer-first", [lwwAdd, ED("hB", 3, { c: 5 }), ED("hA", 2, { x: 10 })]);
// two edits to the SAME field, both orders: the (clock, hash)-latest wins
fold("edit-same-field-older-first", [lwwAdd, ED("hA", 2, { x: 10 }), ED("hB", 3, { x: 30 })]);
fold("edit-same-field-newer-first", [lwwAdd, ED("hB", 3, { x: 30 }), ED("hA", 2, { x: 10 })]);
// equal clock, differing hash, both orders: greater hash wins
fold("edit-equal-clock-hash-asc", [lwwAdd, ED("ha", 4, { x: 11 }), ED("hb", 4, { x: 22 })]);
fold("edit-equal-clock-hash-desc", [lwwAdd, ED("hb", 4, { x: 22 }), ED("ha", 4, { x: 11 })]);
// ---- receive-side sanitisation: PINS TODAY'S (ABSENT) BEHAVIOUR ----------
//
// This client clamps the colour token when it SENDS (drawStroke/addText) and
// sanitises nothing when it RECEIVES; the browser does the opposite — its
// tools send the local style raw and every receiver rebuilds the element
// through ops/sanitize-element and ops/sanitize-patch. So a hostile or merely
// sloppy browser's element lands in a bot's elements() unclamped while every
// canvas in the room shows the clamped one. This vector is the visible record
// of that divergence, NOT an endorsement: the id below is a real 22-char
// element id so the browser would accept the element, and the browser's
// sanitisers would yield
// {id, k:"stroke", x:1e7, y:0, pts:[1,2], w:0.1, c:7} (junk dropped)
// then fold the patch as {c:4} (3.7 rounds; "not-a-number" and nope dropped).
// What is pinned here instead is x 1e300, w -5, c 3.7, junk kept, plus the
// string `w` and the unknown `nope` — every one of them a live divergence.
const HOSTILE_ID = "elemid01AAAAAAAAAAAAAA";
fold("receive-unsanitised", [
EC("s1", 1, {
t: "add", ts: 1, name: "b",
el: { id: HOSTILE_ID, k: "stroke", x: 1e300, y: 0, pts: [1, 2], w: -5, c: 99, junk: "kept" },
}),
EC("s2", 2, { t: "edit", ts: 2, name: "b", id: HOSTILE_ID, p: { c: 3.7, w: "not-a-number", nope: 1 } }),
]);
return out;
}
const els = (c) => c.elements().map((e) => JSON.parse(JSON.stringify(e))).sort((a, b) => (a.id < b.id ? -1 : 1));
|