@1agh/maude 0.29.0 → 0.30.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.
Files changed (99) hide show
  1. package/README.md +1 -1
  2. package/apps/studio/ai-banner.tsx +0 -1
  3. package/apps/studio/annotations-align.ts +149 -0
  4. package/apps/studio/annotations-bindings.ts +197 -0
  5. package/apps/studio/annotations-context-toolbar.tsx +436 -186
  6. package/apps/studio/annotations-groups.ts +270 -0
  7. package/apps/studio/annotations-layer.tsx +1708 -1660
  8. package/apps/studio/annotations-model.ts +2077 -0
  9. package/apps/studio/annotations-snap.ts +125 -0
  10. package/apps/studio/api.ts +208 -116
  11. package/apps/studio/bin/_png-playwright.mjs +1 -1
  12. package/apps/studio/bin/annotate.mjs +732 -0
  13. package/apps/studio/bin/annotate.sh +17 -0
  14. package/apps/studio/bin/read-annotations.mjs +152 -17
  15. package/apps/studio/build.ts +1 -1
  16. package/apps/studio/canvas-arrowheads.ts +78 -9
  17. package/apps/studio/canvas-cursors.ts +2 -0
  18. package/apps/studio/canvas-edit.ts +257 -7
  19. package/apps/studio/canvas-icons.tsx +105 -0
  20. package/apps/studio/canvas-shell.tsx +305 -8
  21. package/apps/studio/client/app.jsx +2812 -238
  22. package/apps/studio/client/index.html +1 -1
  23. package/apps/studio/client/styles/3-shell-maude.css +713 -11
  24. package/apps/studio/client/styles/4-components.css +39 -0
  25. package/apps/studio/client/styles/5-maude-overrides.css +16 -2
  26. package/apps/studio/client/tour/overlay.jsx +105 -17
  27. package/apps/studio/client/tour/usage-tour.js +23 -10
  28. package/apps/studio/commands/annotation-strokes-command.ts +1 -1
  29. package/apps/studio/commands/move-artboards-command.ts +1 -1
  30. package/apps/studio/comments-overlay.tsx +7 -5
  31. package/apps/studio/context-menu.tsx +4 -3
  32. package/apps/studio/contextual-toolbar.tsx +14 -0
  33. package/apps/studio/cursors-overlay.tsx +20 -20
  34. package/apps/studio/dist/client.bundle.js +3322 -602
  35. package/apps/studio/dist/comment-mount.js +95 -9
  36. package/apps/studio/dist/styles.css +2490 -167
  37. package/apps/studio/dom-selection.ts +115 -0
  38. package/apps/studio/export-dialog.tsx +3 -3
  39. package/apps/studio/history.ts +20 -3
  40. package/apps/studio/http.ts +124 -5
  41. package/apps/studio/input-router.tsx +7 -0
  42. package/apps/studio/inspect.ts +37 -8
  43. package/apps/studio/participants-chrome.tsx +1 -7
  44. package/apps/studio/server.ts +5 -2
  45. package/apps/studio/sync/agent.ts +231 -57
  46. package/apps/studio/sync/codec.ts +45 -0
  47. package/apps/studio/sync/cold-start.ts +158 -0
  48. package/apps/studio/sync/connection-state.ts +58 -2
  49. package/apps/studio/sync/index.ts +563 -221
  50. package/apps/studio/sync/journal.ts +190 -0
  51. package/apps/studio/sync/migrate-seed.ts +202 -44
  52. package/apps/studio/sync/projection.ts +22 -3
  53. package/apps/studio/sync/status.ts +15 -4
  54. package/apps/studio/test/activity.test.ts +1 -7
  55. package/apps/studio/test/annotate-write.test.ts +184 -0
  56. package/apps/studio/test/annotations-align.test.ts +88 -0
  57. package/apps/studio/test/annotations-bindings.test.ts +124 -0
  58. package/apps/studio/test/annotations-groups.test.ts +231 -0
  59. package/apps/studio/test/annotations-snap.test.ts +79 -0
  60. package/apps/studio/test/canvas-edit.test.ts +181 -1
  61. package/apps/studio/test/canvas-origin-gate.test.ts +8 -0
  62. package/apps/studio/test/collab-bridge.test.ts +0 -2
  63. package/apps/studio/test/collab-room.test.ts +2 -7
  64. package/apps/studio/test/csp-canvas-shell.test.ts +5 -0
  65. package/apps/studio/test/csrf-write-guard.test.ts +52 -0
  66. package/apps/studio/test/figjam-v3-model.test.ts +342 -0
  67. package/apps/studio/test/fixtures/figjam-v3-groups-bindings.svg +1 -0
  68. package/apps/studio/test/shared-doc-migrate.test.ts +224 -14
  69. package/apps/studio/test/sync-agent.test.ts +272 -2
  70. package/apps/studio/test/sync-codec.test.ts +65 -0
  71. package/apps/studio/test/sync-cold-start.test.ts +244 -0
  72. package/apps/studio/test/sync-connection-state.test.ts +68 -0
  73. package/apps/studio/test/sync-hardening.test.ts +0 -8
  74. package/apps/studio/test/sync-hubs-config.test.ts +0 -1
  75. package/apps/studio/test/sync-incident-replay.test.ts +211 -0
  76. package/apps/studio/test/sync-journal.test.ts +176 -0
  77. package/apps/studio/test/sync-runtime.test.ts +360 -5
  78. package/apps/studio/test/sync-status.test.ts +66 -0
  79. package/apps/studio/test/use-annotation-resize.test.ts +159 -0
  80. package/apps/studio/test/use-artboard-drag.test.ts +0 -1
  81. package/apps/studio/test/use-collab.test.ts +0 -0
  82. package/apps/studio/test/use-tool-mode.test.tsx +5 -2
  83. package/apps/studio/tool-palette.tsx +12 -2
  84. package/apps/studio/undo-hud.tsx +0 -1
  85. package/apps/studio/use-agent-presence.tsx +4 -2
  86. package/apps/studio/use-annotation-resize.tsx +484 -54
  87. package/apps/studio/use-annotation-selection.tsx +16 -4
  88. package/apps/studio/use-canvas-activity.tsx +2 -2
  89. package/apps/studio/use-collab.tsx +20 -15
  90. package/apps/studio/use-selection-set.tsx +9 -0
  91. package/apps/studio/use-tool-mode.tsx +5 -1
  92. package/apps/studio/whats-new.json +135 -10
  93. package/apps/studio/whats-new.schema.json +18 -2
  94. package/cli/commands/design-link.test.mjs +84 -0
  95. package/cli/commands/design.mjs +2 -1
  96. package/cli/commands/design.test.mjs +5 -1
  97. package/cli/lib/design-link.mjs +51 -1
  98. package/package.json +8 -8
  99. package/plugins/design/templates/_shell.html +28 -4
@@ -11,13 +11,13 @@
11
11
  // 100-event stress scenario from the plan without booting a real hub.
12
12
 
13
13
  import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
14
- import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
14
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
15
15
  import { tmpdir } from 'node:os';
16
16
  import { join } from 'node:path';
17
17
  import * as Y from 'yjs';
18
18
 
19
19
  import { type CanvasSyncAgent, createCanvasSyncAgent } from '../sync/agent.ts';
20
- import { applyHtmlToDoc, htmlFromDoc, Y_SYNC_TYPES } from '../sync/codec.ts';
20
+ import { applyHtmlToDoc, htmlFromDoc } from '../sync/codec.ts';
21
21
  import { createEchoGuard, hashBytes } from '../sync/echo-guard.ts';
22
22
  import { createFsReader } from '../sync/fs-mirror.ts';
23
23
 
@@ -303,3 +303,273 @@ describe('CanvasSyncAgent — stress: 100 rapid disk writes converge with no ech
303
303
  expect(docUpdates).toBeGreaterThan(0);
304
304
  });
305
305
  });
306
+
307
+ // ---------------------------------------------------------------------------
308
+ // DDR-102 — cold-start conflict protocol (journal-gated fast-forward, dual
309
+ // snapshot, newest-wins, comments id-union).
310
+ describe('CanvasSyncAgent — DDR-102 cold-start conflict protocol', () => {
311
+ interface SnapCall {
312
+ content: string;
313
+ reason: string;
314
+ }
315
+
316
+ function makeJournal(initial: Record<string, { bodyHash: string }> = {}) {
317
+ const entries = new Map(Object.entries(initial));
318
+ const records: Array<{ slug: string; hashes: Record<string, string | undefined> }> = [];
319
+ return {
320
+ journal: {
321
+ get: (slug: string) => entries.get(slug) ?? null,
322
+ record: (slug: string, hashes: { bodyHash?: string; cssHash?: string }) => {
323
+ records.push({ slug, hashes });
324
+ const prev = entries.get(slug);
325
+ entries.set(slug, { bodyHash: hashes.bodyHash ?? prev?.bodyHash ?? '' });
326
+ },
327
+ invalidateIfHubChanged: () => {},
328
+ flush: () => {},
329
+ stop: () => {},
330
+ size: () => entries.size,
331
+ },
332
+ records,
333
+ };
334
+ }
335
+
336
+ function protocolAgent(extra: {
337
+ journal?: ReturnType<typeof makeJournal>['journal'];
338
+ snapshots?: SnapCall[];
339
+ conflicts?: unknown[];
340
+ }) {
341
+ const a = createCanvasSyncAgent({
342
+ slug: 'screen',
343
+ doc: docB,
344
+ paths: paths(),
345
+ echoGuard: createEchoGuard(),
346
+ flushMs: 0,
347
+ journal: extra.journal,
348
+ snapshot: extra.snapshots
349
+ ? async (content, reason) => {
350
+ extra.snapshots?.push({ content, reason });
351
+ return `ts-${extra.snapshots?.length}`;
352
+ }
353
+ : undefined,
354
+ onConflict: (info) => {
355
+ extra.conflicts?.push(info);
356
+ },
357
+ });
358
+ a.start();
359
+ return a;
360
+ }
361
+
362
+ test('journal match → silent fast-forward: hub overwrites disk, NO snapshot, NO conflict', async () => {
363
+ const { setHtml } = await import('node:fs').then(() => ({ setHtml: writeFileSync }));
364
+ setHtml(paths().html, '<button>synced-v1</button>');
365
+ applyHtmlToDoc(docA, '<button>hub-v2</button>');
366
+
367
+ const { journal } = makeJournal({
368
+ screen: { bodyHash: hashBytes('<button>synced-v1</button>') },
369
+ });
370
+ const snapshots: SnapCall[] = [];
371
+ const conflicts: unknown[] = [];
372
+ agent = protocolAgent({ journal, snapshots, conflicts });
373
+ await agent.reconcile();
374
+
375
+ expect(readFileSync(paths().html, 'utf8')).toBe('<button>hub-v2</button>');
376
+ expect(snapshots).toHaveLength(0);
377
+ expect(conflicts).toHaveLength(0);
378
+ });
379
+
380
+ test('diverged + local newer → local wins, BOTH sides snapshotted, conflict recorded', async () => {
381
+ writeFileSync(paths().html, '<button>local-newer</button>');
382
+ // Hub doc carries different body with an OLDER bodyEditAt stamp.
383
+ docA.transact(() => {
384
+ applyHtmlToDoc(docA, '<button>hub-older</button>');
385
+ docA.getMap('syncMeta').set('bodyEditAt', Date.now() - 60_000);
386
+ });
387
+
388
+ const { journal } = makeJournal(); // no entry → divergence
389
+ const snapshots: SnapCall[] = [];
390
+ const conflicts: Array<{ kind: string; winner?: string; snapshots?: object }> = [];
391
+ agent = protocolAgent({ journal, snapshots, conflicts });
392
+ await agent.reconcile();
393
+
394
+ // Local body survives on disk AND is pushed up to the hub.
395
+ expect(readFileSync(paths().html, 'utf8')).toBe('<button>local-newer</button>');
396
+ expect(htmlFromDoc(docA)).toBe('<button>local-newer</button>');
397
+ // Dual snapshot: local first, hub second.
398
+ expect(snapshots.map((s) => s.reason)).toEqual(['pre-sync-local', 'pre-sync-hub']);
399
+ expect(snapshots[0].content).toBe('<button>local-newer</button>');
400
+ expect(snapshots[1].content).toBe('<button>hub-older</button>');
401
+ // Conflict entry carries winner + snapshot refs.
402
+ expect(conflicts).toHaveLength(1);
403
+ expect(conflicts[0].kind).toBe('cold-start-diverged');
404
+ expect(conflicts[0].winner).toBe('local');
405
+ expect(conflicts[0].snapshots).toBeDefined();
406
+ });
407
+
408
+ test('diverged + hub newer → hub wins, both sides snapshotted', async () => {
409
+ writeFileSync(paths().html, '<button>local-older</button>');
410
+ docA.transact(() => {
411
+ applyHtmlToDoc(docA, '<button>hub-newer</button>');
412
+ docA.getMap('syncMeta').set('bodyEditAt', Date.now() + 60_000);
413
+ });
414
+
415
+ const { journal } = makeJournal();
416
+ const snapshots: SnapCall[] = [];
417
+ const conflicts: Array<{ winner?: string }> = [];
418
+ agent = protocolAgent({ journal, snapshots, conflicts });
419
+ await agent.reconcile();
420
+
421
+ expect(readFileSync(paths().html, 'utf8')).toBe('<button>hub-newer</button>');
422
+ expect(snapshots.map((s) => s.reason)).toEqual(['pre-sync-local', 'pre-sync-hub']);
423
+ expect(conflicts[0].winner).toBe('hub');
424
+ });
425
+
426
+ test('DDR-064 empty-hub guard is bit-identical: seed-local-up, no snapshot, no conflict', async () => {
427
+ writeFileSync(paths().html, '<button>real-canvas</button>');
428
+ // docA/docB empty — fresh hub.
429
+ const { journal } = makeJournal();
430
+ const snapshots: SnapCall[] = [];
431
+ const conflicts: unknown[] = [];
432
+ agent = protocolAgent({ journal, snapshots, conflicts });
433
+ await agent.reconcile();
434
+
435
+ expect(readFileSync(paths().html, 'utf8')).toBe('<button>real-canvas</button>');
436
+ expect(htmlFromDoc(docA)).toBe('<button>real-canvas</button>');
437
+ expect(snapshots).toHaveLength(0);
438
+ expect(conflicts).toHaveLength(0);
439
+ });
440
+
441
+ test('journal checkpoints: doc→disk flush and disk→doc apply both record', async () => {
442
+ const { journal, records } = makeJournal();
443
+ agent = createCanvasSyncAgent({
444
+ slug: 'screen',
445
+ doc: docB,
446
+ paths: paths(),
447
+ echoGuard: createEchoGuard(),
448
+ flushMs: 0,
449
+ journal,
450
+ });
451
+ agent.start();
452
+
453
+ // Hub edit → disk write → checkpoint.
454
+ applyHtmlToDoc(docA, '<button>from-hub</button>');
455
+ await agent.flush();
456
+ expect(records.some((r) => r.hashes.bodyHash === hashBytes('<button>from-hub</button>'))).toBe(
457
+ true
458
+ );
459
+
460
+ // Local edit → doc apply → checkpoint.
461
+ const bytes = new TextEncoder().encode('<button>from-disk</button>');
462
+ agent.applyFromFs({ path: paths().html, bytes, hash: hashBytes(bytes) });
463
+ expect(records.some((r) => r.hashes.bodyHash === hashBytes(bytes))).toBe(true);
464
+ });
465
+
466
+ test('comments id-union: doc order first, local-only appended, both sides converge', async () => {
467
+ // Local file has c1 + c3; hub doc has c1 + c2.
468
+ const localComments = [
469
+ { id: 'c1', body: 'shared' },
470
+ { id: 'c3', body: 'local-only' },
471
+ ];
472
+ mkdirSync(join(dir, '_comments'), { recursive: true });
473
+ writeFileSync(paths().comments, `${JSON.stringify(localComments, null, 2)}\n`);
474
+ docA.transact(() => {
475
+ docA.getArray('comments').push([
476
+ { id: 'c1', body: 'shared' },
477
+ { id: 'c2', body: 'hub-only' },
478
+ ]);
479
+ });
480
+
481
+ agent = protocolAgent({});
482
+ await agent.reconcile();
483
+
484
+ const expectUnion = [
485
+ { id: 'c1', body: 'shared' },
486
+ { id: 'c2', body: 'hub-only' },
487
+ { id: 'c3', body: 'local-only' },
488
+ ];
489
+ // Doc (and the peer) hold the union…
490
+ expect(docA.getArray('comments').toArray()).toEqual(expectUnion);
491
+ // …and the disk file was rewritten to the union too.
492
+ expect(JSON.parse(readFileSync(paths().comments, 'utf8'))).toEqual(expectUnion);
493
+ });
494
+
495
+ test('comments union with empty hub doc keeps + pushes local comments', async () => {
496
+ const localComments = [{ id: 'c9', body: 'only-local' }];
497
+ mkdirSync(join(dir, '_comments'), { recursive: true });
498
+ writeFileSync(paths().comments, `${JSON.stringify(localComments, null, 2)}\n`);
499
+
500
+ agent = protocolAgent({});
501
+ await agent.reconcile();
502
+
503
+ expect(docA.getArray('comments').toArray()).toEqual(localComments);
504
+ expect(JSON.parse(readFileSync(paths().comments, 'utf8'))).toEqual(localComments);
505
+ });
506
+ });
507
+
508
+ // ---------------------------------------------------------------------------
509
+ // DDR-102 fail-closed (security review F1): a hub-wins resolution must NEVER
510
+ // overwrite local when the local snapshot didn't land — keep local + push up.
511
+ describe('CanvasSyncAgent — fail-closed on snapshot failure (DDR-102 F1)', () => {
512
+ function failClosedAgent(
513
+ snapshotImpl: (content: string, reason: string) => Promise<string | null>,
514
+ conflicts: unknown[]
515
+ ) {
516
+ const a = createCanvasSyncAgent({
517
+ slug: 'screen',
518
+ doc: docB,
519
+ paths: paths(),
520
+ echoGuard: createEchoGuard(),
521
+ flushMs: 0,
522
+ // No journal → every diff is divergence (conflict path).
523
+ snapshot: snapshotImpl,
524
+ onConflict: (info) => {
525
+ conflicts.push(info);
526
+ },
527
+ });
528
+ a.start();
529
+ return a;
530
+ }
531
+
532
+ test('hub newer BUT local snapshot returns null → REFUSE overwrite, keep local, push up', async () => {
533
+ writeFileSync(paths().html, '<button>local-work</button>');
534
+ // Hub doc carries a NEWER stamp so newest-wins would pick hub.
535
+ docA.transact(() => {
536
+ applyHtmlToDoc(docA, '<button>hub-stale</button>');
537
+ docA.getMap('syncMeta').set('bodyEditAt', Date.now() + 60_000);
538
+ });
539
+
540
+ const conflicts: Array<{ winner?: string; snapshotFailed?: boolean }> = [];
541
+ // Snapshot writer that FAILS (disk full / read-only _history): returns null.
542
+ agent = failClosedAgent(async () => null, conflicts);
543
+ await agent.reconcile();
544
+
545
+ // Local work survives on disk (NOT overwritten by hub-stale) …
546
+ expect(readFileSync(paths().html, 'utf8')).toBe('<button>local-work</button>');
547
+ // … and is pushed UP to the hub (nothing lost on either side).
548
+ expect(htmlFromDoc(docA)).toBe('<button>local-work</button>');
549
+ // The conflict is recorded with the degraded-snapshot flag + flipped winner.
550
+ expect(conflicts).toHaveLength(1);
551
+ expect(conflicts[0].winner).toBe('local');
552
+ expect(conflicts[0].snapshotFailed).toBe(true);
553
+ });
554
+
555
+ test('hub newer + local snapshot SUCCEEDS → normal hub-wins (control)', async () => {
556
+ writeFileSync(paths().html, '<button>local-work</button>');
557
+ docA.transact(() => {
558
+ applyHtmlToDoc(docA, '<button>hub-newer</button>');
559
+ docA.getMap('syncMeta').set('bodyEditAt', Date.now() + 60_000);
560
+ });
561
+
562
+ const conflicts: Array<{ winner?: string; snapshotFailed?: boolean }> = [];
563
+ // Only the local snapshot succeeds; hub snapshot can fail without changing
564
+ // the outcome (local is the loser when hub wins).
565
+ agent = failClosedAgent(
566
+ async (_c, reason) => (reason === 'pre-sync-local' ? 'ts-local' : null),
567
+ conflicts
568
+ );
569
+ await agent.reconcile();
570
+
571
+ expect(readFileSync(paths().html, 'utf8')).toBe('<button>hub-newer</button>');
572
+ expect(conflicts[0].winner).toBe('hub');
573
+ expect(conflicts[0].snapshotFailed).toBeUndefined();
574
+ });
575
+ });
@@ -12,8 +12,10 @@ import {
12
12
  applyAnnotationsToDoc,
13
13
  applyCommentsToDoc,
14
14
  applyHtmlToDoc,
15
+ bodyEditAtFromDoc,
15
16
  commentsFromDoc,
16
17
  htmlFromDoc,
18
+ stampBodyEdit,
17
19
  Y_SYNC_TYPES,
18
20
  } from '../sync/codec.ts';
19
21
 
@@ -163,3 +165,66 @@ describe('Annotations codec', () => {
163
165
  expect(doc.getMap<string>(Y_TYPES.annotations).get('svg')).toBe('<svg>x</svg>');
164
166
  });
165
167
  });
168
+
169
+ // syncMeta lane — DDR-102. The doc-side body-edit stamp the cold-start
170
+ // newest-wins decision reads. Dedicated Y.Map lane because meta's
171
+ // `last_modified` is in META_LOCAL_KEYS (per-machine, never syncs).
172
+ describe('syncMeta codec (DDR-102)', () => {
173
+ test('bodyEditAtFromDoc returns null on a fresh doc (older-peer interop)', () => {
174
+ const doc = new Y.Doc();
175
+ expect(bodyEditAtFromDoc(doc)).toBeNull();
176
+ });
177
+
178
+ test('stampBodyEdit → bodyEditAtFromDoc round-trip with explicit nowMs', () => {
179
+ const doc = new Y.Doc();
180
+ stampBodyEdit(doc, undefined, 1_700_000_000_000);
181
+ expect(bodyEditAtFromDoc(doc)).toBe(1_700_000_000_000);
182
+ const by = doc.getMap<unknown>(Y_SYNC_TYPES.syncMeta).get('by');
183
+ expect(typeof by).toBe('string');
184
+ expect((by as string).length).toBeGreaterThan(0);
185
+ expect((by as string).length).toBeLessThanOrEqual(32);
186
+ });
187
+
188
+ test('stampBodyEdit defaults to Date.now()', () => {
189
+ const doc = new Y.Doc();
190
+ const before = Date.now();
191
+ stampBodyEdit(doc);
192
+ const after = Date.now();
193
+ const at = bodyEditAtFromDoc(doc);
194
+ expect(at).not.toBeNull();
195
+ expect(at as number).toBeGreaterThanOrEqual(before);
196
+ expect(at as number).toBeLessThanOrEqual(after);
197
+ });
198
+
199
+ test('a hostile/garbage bodyEditAt value reads as null', () => {
200
+ const doc = new Y.Doc();
201
+ doc.getMap<unknown>(Y_SYNC_TYPES.syncMeta).set('bodyEditAt', 'not-a-number');
202
+ expect(bodyEditAtFromDoc(doc)).toBeNull();
203
+ doc.getMap<unknown>(Y_SYNC_TYPES.syncMeta).set('bodyEditAt', Number.POSITIVE_INFINITY);
204
+ expect(bodyEditAtFromDoc(doc)).toBeNull();
205
+ });
206
+
207
+ test('body apply + stamp wrapped in one transaction emits ONE update', () => {
208
+ const doc = new Y.Doc();
209
+ let updates = 0;
210
+ doc.on('update', () => {
211
+ updates++;
212
+ });
213
+ const origin = { agent: 'test' };
214
+ doc.transact(() => {
215
+ applyHtmlToDoc(doc, '<div>v1</div>', origin);
216
+ stampBodyEdit(doc, origin, 123);
217
+ }, origin);
218
+ expect(updates).toBe(1);
219
+ expect(htmlFromDoc(doc)).toBe('<div>v1</div>');
220
+ expect(bodyEditAtFromDoc(doc)).toBe(123);
221
+ });
222
+
223
+ test('the stamp syncs to a peer doc via update exchange', () => {
224
+ const a = new Y.Doc();
225
+ const b = new Y.Doc();
226
+ a.on('update', (u: Uint8Array) => Y.applyUpdate(b, u));
227
+ stampBodyEdit(a, undefined, 456);
228
+ expect(bodyEditAtFromDoc(b)).toBe(456);
229
+ });
230
+ });
@@ -0,0 +1,244 @@
1
+ // Cold-start decision matrix — hub-sync cold-start safety (DDR-102).
2
+ //
3
+ // Exhaustive table over (local: absent/empty/clean/diverged) × (doc:
4
+ // empty/same/different) × (journal: absent/match/stale), plus both winner
5
+ // directions and the null-timestamp fallbacks. Mirrors the table style of
6
+ // sync-codec.test.ts: every case is a row with explicit inputs + expected
7
+ // action.
8
+
9
+ import { describe, expect, test } from 'bun:test';
10
+
11
+ import { type ColdStartInput, decideColdStart } from '../sync/cold-start.ts';
12
+ import { hashBytes } from '../sync/echo-guard.ts';
13
+
14
+ const BODY_LOCAL = '<div>local work — a day of mascot edits</div>';
15
+ const BODY_HUB = '<div>hub state</div>';
16
+
17
+ /** Convenience: build an input with safe defaults, override per case. */
18
+ function input(over: Partial<ColdStartInput>): ColdStartInput {
19
+ return {
20
+ localBody: null,
21
+ docBody: '',
22
+ journalHash: null,
23
+ localMtimeMs: null,
24
+ docBodyEditAtMs: null,
25
+ ...over,
26
+ };
27
+ }
28
+
29
+ describe('decideColdStart — empty/absent sides', () => {
30
+ test('local absent + doc empty → noop', () => {
31
+ expect(decideColdStart(input({})).action).toBe('noop');
32
+ });
33
+
34
+ test('local empty-string + doc empty → noop', () => {
35
+ expect(decideColdStart(input({ localBody: '' })).action).toBe('noop');
36
+ });
37
+
38
+ test('whitespace-only local counts as empty (mirrors the trim guard)', () => {
39
+ const d = decideColdStart(input({ localBody: ' \n\t ', docBody: BODY_HUB }));
40
+ expect(d.action).toBe('materialize-hub');
41
+ });
42
+
43
+ test('local absent + doc non-empty → materialize-hub (clean first sync)', () => {
44
+ const d = decideColdStart(input({ docBody: BODY_HUB }));
45
+ expect(d.action).toBe('materialize-hub');
46
+ });
47
+
48
+ test('local empty + doc non-empty → materialize-hub', () => {
49
+ const d = decideColdStart(input({ localBody: '', docBody: BODY_HUB }));
50
+ expect(d.action).toBe('materialize-hub');
51
+ });
52
+
53
+ test('local non-empty + doc empty → seed-local-up (DDR-064 guard as a named case)', () => {
54
+ const d = decideColdStart(input({ localBody: BODY_LOCAL, docBody: '' }));
55
+ expect(d.action).toBe('seed-local-up');
56
+ });
57
+
58
+ test('local non-empty + whitespace-only doc → seed-local-up', () => {
59
+ const d = decideColdStart(input({ localBody: BODY_LOCAL, docBody: ' \n ' }));
60
+ expect(d.action).toBe('seed-local-up');
61
+ });
62
+
63
+ test('seed-local-up holds regardless of journal state (stale journal must not block it)', () => {
64
+ const d = decideColdStart(
65
+ input({ localBody: BODY_LOCAL, docBody: '', journalHash: 'stale-hash' })
66
+ );
67
+ expect(d.action).toBe('seed-local-up');
68
+ });
69
+ });
70
+
71
+ describe('decideColdStart — identical sides', () => {
72
+ test('local == doc → noop (caller records journal)', () => {
73
+ const d = decideColdStart(input({ localBody: BODY_HUB, docBody: BODY_HUB }));
74
+ expect(d.action).toBe('noop');
75
+ });
76
+
77
+ test('local == doc with a stale journal is still a noop (content equality wins)', () => {
78
+ const d = decideColdStart(
79
+ input({ localBody: BODY_HUB, docBody: BODY_HUB, journalHash: 'stale' })
80
+ );
81
+ expect(d.action).toBe('noop');
82
+ });
83
+ });
84
+
85
+ describe('decideColdStart — journal-gated fast-forward', () => {
86
+ test('local ≠ doc, journal matches hash(local) → fast-forward-hub (no conflict)', () => {
87
+ const d = decideColdStart(
88
+ input({
89
+ localBody: BODY_LOCAL,
90
+ docBody: BODY_HUB,
91
+ journalHash: hashBytes(BODY_LOCAL),
92
+ })
93
+ );
94
+ expect(d.action).toBe('fast-forward-hub');
95
+ expect(d.winner).toBeUndefined();
96
+ });
97
+
98
+ test('journal match overrides timestamps — even a NEWER local mtime fast-forwards', () => {
99
+ // mtime newer than the doc stamp, but the bytes are exactly what we last
100
+ // synced (e.g. touch / checkout changed mtime only) → not a conflict.
101
+ const d = decideColdStart(
102
+ input({
103
+ localBody: BODY_LOCAL,
104
+ docBody: BODY_HUB,
105
+ journalHash: hashBytes(BODY_LOCAL),
106
+ localMtimeMs: 2_000,
107
+ docBodyEditAtMs: 1_000,
108
+ })
109
+ );
110
+ expect(d.action).toBe('fast-forward-hub');
111
+ });
112
+ });
113
+
114
+ describe('decideColdStart — divergence (conflict + newest-wins)', () => {
115
+ test('journal ABSENT, local newer → conflict, winner=local (the incident shape)', () => {
116
+ const d = decideColdStart(
117
+ input({
118
+ localBody: BODY_LOCAL,
119
+ docBody: BODY_HUB,
120
+ localMtimeMs: 2_000,
121
+ docBodyEditAtMs: 1_000,
122
+ })
123
+ );
124
+ expect(d.action).toBe('conflict');
125
+ expect(d.winner).toBe('local');
126
+ });
127
+
128
+ test('journal ABSENT, doc newer → conflict, winner=hub', () => {
129
+ const d = decideColdStart(
130
+ input({
131
+ localBody: BODY_LOCAL,
132
+ docBody: BODY_HUB,
133
+ localMtimeMs: 1_000,
134
+ docBodyEditAtMs: 2_000,
135
+ })
136
+ );
137
+ expect(d.action).toBe('conflict');
138
+ expect(d.winner).toBe('hub');
139
+ });
140
+
141
+ test('journal STALE (≠ hash(local)), local newer → conflict, winner=local', () => {
142
+ const d = decideColdStart(
143
+ input({
144
+ localBody: BODY_LOCAL,
145
+ docBody: BODY_HUB,
146
+ journalHash: hashBytes('<div>some older synced state</div>'),
147
+ localMtimeMs: 5_000,
148
+ docBodyEditAtMs: 4_000,
149
+ })
150
+ );
151
+ expect(d.action).toBe('conflict');
152
+ expect(d.winner).toBe('local');
153
+ });
154
+
155
+ test('journal STALE, doc newer → conflict, winner=hub', () => {
156
+ const d = decideColdStart(
157
+ input({
158
+ localBody: BODY_LOCAL,
159
+ docBody: BODY_HUB,
160
+ journalHash: hashBytes('<div>some older synced state</div>'),
161
+ localMtimeMs: 4_000,
162
+ docBodyEditAtMs: 5_000,
163
+ })
164
+ );
165
+ expect(d.action).toBe('conflict');
166
+ expect(d.winner).toBe('hub');
167
+ });
168
+
169
+ test('journal matching hash(DOC) — not local — is still divergence', () => {
170
+ // The journal proves we once synced what the HUB now holds reverted to;
171
+ // local moved past it → local is unreconciled work, must conflict.
172
+ const d = decideColdStart(
173
+ input({
174
+ localBody: BODY_LOCAL,
175
+ docBody: BODY_HUB,
176
+ journalHash: hashBytes(BODY_HUB),
177
+ localMtimeMs: 2_000,
178
+ docBodyEditAtMs: 1_000,
179
+ })
180
+ );
181
+ expect(d.action).toBe('conflict');
182
+ expect(d.winner).toBe('local');
183
+ });
184
+
185
+ test('doc stamp NULL (older peer never stamped) → hub-wins fallback', () => {
186
+ const d = decideColdStart(
187
+ input({
188
+ localBody: BODY_LOCAL,
189
+ docBody: BODY_HUB,
190
+ localMtimeMs: 2_000,
191
+ docBodyEditAtMs: null,
192
+ })
193
+ );
194
+ expect(d.action).toBe('conflict');
195
+ expect(d.winner).toBe('hub');
196
+ });
197
+
198
+ test('local mtime NULL → hub-wins fallback', () => {
199
+ const d = decideColdStart(
200
+ input({
201
+ localBody: BODY_LOCAL,
202
+ docBody: BODY_HUB,
203
+ localMtimeMs: null,
204
+ docBodyEditAtMs: 2_000,
205
+ })
206
+ );
207
+ expect(d.action).toBe('conflict');
208
+ expect(d.winner).toBe('hub');
209
+ });
210
+
211
+ test('both timestamps NULL → hub-wins fallback', () => {
212
+ const d = decideColdStart(input({ localBody: BODY_LOCAL, docBody: BODY_HUB }));
213
+ expect(d.action).toBe('conflict');
214
+ expect(d.winner).toBe('hub');
215
+ });
216
+
217
+ test('timestamps TIED → hub-wins fallback', () => {
218
+ const d = decideColdStart(
219
+ input({
220
+ localBody: BODY_LOCAL,
221
+ docBody: BODY_HUB,
222
+ localMtimeMs: 1_000,
223
+ docBodyEditAtMs: 1_000,
224
+ })
225
+ );
226
+ expect(d.action).toBe('conflict');
227
+ expect(d.winner).toBe('hub');
228
+ });
229
+
230
+ test('every decision carries a human-readable reason', () => {
231
+ const cases: ColdStartInput[] = [
232
+ input({}),
233
+ input({ docBody: BODY_HUB }),
234
+ input({ localBody: BODY_LOCAL }),
235
+ input({ localBody: BODY_HUB, docBody: BODY_HUB }),
236
+ input({ localBody: BODY_LOCAL, docBody: BODY_HUB, journalHash: hashBytes(BODY_LOCAL) }),
237
+ input({ localBody: BODY_LOCAL, docBody: BODY_HUB }),
238
+ ];
239
+ for (const c of cases) {
240
+ const d = decideColdStart(c);
241
+ expect(d.reason.length).toBeGreaterThan(0);
242
+ }
243
+ });
244
+ });
@@ -144,3 +144,71 @@ describe('connection monitor', () => {
144
144
  expect(changes.length).toBe(before); // no offline transition fired
145
145
  });
146
146
  });
147
+
148
+ // DDR-102 — per-doc states + real lastSyncAt.
149
+ describe('connection monitor — per-doc states (DDR-102)', () => {
150
+ test('noteDocState rolls up into docs counts + rejectedSlugs', () => {
151
+ const { monitor } = makeMonitor();
152
+ monitor.noteDocState('ui-a', 'pending');
153
+ monitor.noteDocState('ui-b', 'connected');
154
+ monitor.noteDocState('ui-c', 'auth-rejected');
155
+ monitor.noteDocState('ui-d', 'auth-rejected');
156
+
157
+ const snap = monitor.snapshot();
158
+ expect(snap.docs).toEqual({ synced: 1, pending: 1, rejected: 2 });
159
+ expect(snap.rejectedSlugs?.sort()).toEqual(['ui-c', 'ui-d']);
160
+ });
161
+
162
+ test('doc state transitions update the rollup (rejected → connected after re-link)', () => {
163
+ const { monitor, changes } = makeMonitor();
164
+ monitor.noteDocState('ui-a', 'auth-rejected');
165
+ expect(monitor.snapshot().docs?.rejected).toBe(1);
166
+ monitor.noteDocState('ui-a', 'connected');
167
+ const snap = monitor.snapshot();
168
+ expect(snap.docs).toEqual({ synced: 1, pending: 0, rejected: 0 });
169
+ expect(snap.rejectedSlugs).toEqual([]);
170
+ // Each transition emitted (no-op repeats don't).
171
+ const emitted = changes.length;
172
+ monitor.noteDocState('ui-a', 'connected');
173
+ expect(changes.length).toBe(emitted);
174
+ });
175
+
176
+ test('rejectedSlugs caps at 20 while docs.rejected carries the true count', () => {
177
+ const { monitor } = makeMonitor();
178
+ for (let i = 0; i < 25; i++) monitor.noteDocState(`ui-${i}`, 'auth-rejected');
179
+ const snap = monitor.snapshot();
180
+ expect(snap.docs?.rejected).toBe(25);
181
+ expect(snap.rejectedSlugs).toHaveLength(20);
182
+ });
183
+
184
+ test('noteSyncActivity sets lastSyncAt to now and promotes a pending doc', () => {
185
+ const { clock, monitor } = makeMonitor();
186
+ monitor.noteDocState('ui-a', 'pending');
187
+ expect(monitor.snapshot().lastSyncAt).toBeNull();
188
+
189
+ clock.advance(5_000);
190
+ monitor.noteSyncActivity('ui-a');
191
+ const snap = monitor.snapshot();
192
+ expect(snap.lastSyncAt).toBe(clock.now());
193
+ expect(snap.docs?.synced).toBe(1);
194
+ expect(snap.docs?.pending).toBe(0);
195
+ });
196
+
197
+ test('noteSyncActivity does NOT resurrect an auth-rejected doc', () => {
198
+ const { monitor } = makeMonitor();
199
+ monitor.noteDocState('ui-a', 'auth-rejected');
200
+ monitor.noteSyncActivity('ui-a');
201
+ expect(monitor.snapshot().docs?.rejected).toBe(1);
202
+ expect(monitor.snapshot().lastSyncAt).not.toBeNull();
203
+ });
204
+
205
+ test('lastSyncAt advances on repeated activity (real activity, not just transitions)', () => {
206
+ const { clock, monitor } = makeMonitor();
207
+ monitor.noteDocState('ui-a', 'connected');
208
+ monitor.noteSyncActivity('ui-a');
209
+ const first = monitor.snapshot().lastSyncAt;
210
+ clock.advance(10_000);
211
+ monitor.noteSyncActivity('ui-a');
212
+ expect(monitor.snapshot().lastSyncAt).toBe((first as number) + 10_000);
213
+ });
214
+ });