@1agh/maude 0.29.0 → 0.31.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 (162) hide show
  1. package/README.md +6 -6
  2. package/apps/studio/acp/bridge.ts +285 -0
  3. package/apps/studio/acp/env.ts +48 -0
  4. package/apps/studio/acp/index.ts +132 -0
  5. package/apps/studio/acp/probe.ts +112 -0
  6. package/apps/studio/acp/transcript.ts +149 -0
  7. package/apps/studio/ai-banner.tsx +0 -1
  8. package/apps/studio/annotations-align.ts +149 -0
  9. package/apps/studio/annotations-bindings.ts +197 -0
  10. package/apps/studio/annotations-context-toolbar.tsx +436 -186
  11. package/apps/studio/annotations-groups.ts +270 -0
  12. package/apps/studio/annotations-layer.tsx +1714 -1661
  13. package/apps/studio/annotations-model.ts +2077 -0
  14. package/apps/studio/annotations-snap.ts +125 -0
  15. package/apps/studio/api.ts +433 -182
  16. package/apps/studio/bin/_png-playwright.mjs +1 -1
  17. package/apps/studio/bin/annotate.mjs +732 -0
  18. package/apps/studio/bin/annotate.sh +17 -0
  19. package/apps/studio/bin/chat-open.sh +44 -0
  20. package/apps/studio/bin/read-annotations.mjs +152 -17
  21. package/apps/studio/build.ts +1 -1
  22. package/apps/studio/canvas-arrowheads.ts +78 -9
  23. package/apps/studio/canvas-cursors.ts +2 -0
  24. package/apps/studio/canvas-edit.ts +257 -7
  25. package/apps/studio/canvas-icons.tsx +105 -0
  26. package/apps/studio/canvas-lib.tsx +112 -19
  27. package/apps/studio/canvas-list-watch.ts +177 -0
  28. package/apps/studio/canvas-shell.tsx +326 -9
  29. package/apps/studio/client/app.jsx +3579 -250
  30. package/apps/studio/client/canvas-url.js +5 -0
  31. package/apps/studio/client/github.js +99 -0
  32. package/apps/studio/client/index.html +1 -1
  33. package/apps/studio/client/panels/ChatPanel.jsx +770 -0
  34. package/apps/studio/client/panels/CollabModelInfographic.jsx +71 -0
  35. package/apps/studio/client/panels/CreateProject.jsx +334 -0
  36. package/apps/studio/client/panels/DiffView.jsx +590 -0
  37. package/apps/studio/client/panels/GitPanel.jsx +767 -0
  38. package/apps/studio/client/panels/IdentityBar.jsx +294 -0
  39. package/apps/studio/client/panels/OnboardingWizard.jsx +563 -0
  40. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +349 -0
  41. package/apps/studio/client/panels/acp-runtime.js +286 -0
  42. package/apps/studio/client/panels/chat-markdown.jsx +138 -0
  43. package/apps/studio/client/panels/git-grouping.js +86 -0
  44. package/apps/studio/client/styles/0-reset.css +4 -0
  45. package/apps/studio/client/styles/3-shell-maude.css +1326 -14
  46. package/apps/studio/client/styles/4-components.css +39 -0
  47. package/apps/studio/client/styles/5-maude-overrides.css +41 -2
  48. package/apps/studio/client/styles/6-acp-chat.css +771 -0
  49. package/apps/studio/client/styles/_index.css +2 -0
  50. package/apps/studio/client/tour/collab-tour.js +61 -0
  51. package/apps/studio/client/tour/overlay.jsx +116 -18
  52. package/apps/studio/client/tour/usage-tour.js +23 -10
  53. package/apps/studio/client/whats-new.jsx +25 -10
  54. package/apps/studio/collab/registry.ts +13 -0
  55. package/apps/studio/collab/room.ts +36 -0
  56. package/apps/studio/commands/annotation-strokes-command.ts +1 -1
  57. package/apps/studio/commands/move-artboards-command.ts +1 -1
  58. package/apps/studio/comments-overlay.tsx +7 -5
  59. package/apps/studio/context-menu.tsx +4 -3
  60. package/apps/studio/contextual-toolbar.tsx +14 -0
  61. package/apps/studio/cursors-overlay.tsx +37 -21
  62. package/apps/studio/dist/client.bundle.js +31795 -1829
  63. package/apps/studio/dist/comment-mount.js +97 -9
  64. package/apps/studio/dist/styles.css +7938 -1599
  65. package/apps/studio/dom-selection.ts +115 -0
  66. package/apps/studio/export-dialog.tsx +3 -3
  67. package/apps/studio/git/endpoints.ts +338 -0
  68. package/apps/studio/git/service.ts +1334 -0
  69. package/apps/studio/git/watch.ts +97 -0
  70. package/apps/studio/github/endpoints.ts +358 -0
  71. package/apps/studio/github/service.ts +231 -0
  72. package/apps/studio/github/token.ts +53 -0
  73. package/apps/studio/history.ts +20 -3
  74. package/apps/studio/hmr-broadcast.ts +9 -2
  75. package/apps/studio/http.ts +508 -6
  76. package/apps/studio/input-router.tsx +7 -0
  77. package/apps/studio/inspect.ts +37 -8
  78. package/apps/studio/participants-chrome.tsx +70 -16
  79. package/apps/studio/paths.ts +12 -0
  80. package/apps/studio/scaffold-design.ts +57 -0
  81. package/apps/studio/server.ts +70 -4
  82. package/apps/studio/sync/agent.ts +311 -57
  83. package/apps/studio/sync/codec.ts +69 -0
  84. package/apps/studio/sync/cold-start.ts +198 -0
  85. package/apps/studio/sync/connection-state.ts +58 -2
  86. package/apps/studio/sync/hub-link.ts +137 -0
  87. package/apps/studio/sync/index.ts +563 -221
  88. package/apps/studio/sync/journal.ts +190 -0
  89. package/apps/studio/sync/migrate-seed.ts +202 -44
  90. package/apps/studio/sync/projection.ts +22 -3
  91. package/apps/studio/sync/status.ts +15 -4
  92. package/apps/studio/test/acp-bridge.test.ts +127 -0
  93. package/apps/studio/test/acp-env.test.ts +65 -0
  94. package/apps/studio/test/acp-origin-gate.test.ts +78 -0
  95. package/apps/studio/test/acp-transcript.test.ts +112 -0
  96. package/apps/studio/test/activity.test.ts +1 -7
  97. package/apps/studio/test/annotate-write.test.ts +184 -0
  98. package/apps/studio/test/annotations-align.test.ts +88 -0
  99. package/apps/studio/test/annotations-bindings.test.ts +124 -0
  100. package/apps/studio/test/annotations-groups.test.ts +231 -0
  101. package/apps/studio/test/annotations-snap.test.ts +79 -0
  102. package/apps/studio/test/canvas-create-api.test.ts +72 -0
  103. package/apps/studio/test/canvas-edit.test.ts +181 -1
  104. package/apps/studio/test/canvas-list-watch.test.ts +322 -0
  105. package/apps/studio/test/canvas-meta-api.test.ts +161 -27
  106. package/apps/studio/test/canvas-origin-gate.test.ts +43 -0
  107. package/apps/studio/test/chat-markdown.test.tsx +58 -0
  108. package/apps/studio/test/collab-bridge.test.ts +0 -2
  109. package/apps/studio/test/collab-room.test.ts +2 -7
  110. package/apps/studio/test/collab-session-survival.test.tsx +176 -0
  111. package/apps/studio/test/csp-canvas-shell.test.ts +5 -0
  112. package/apps/studio/test/csrf-write-guard.test.ts +78 -0
  113. package/apps/studio/test/editing-presence.test.ts +103 -0
  114. package/apps/studio/test/figjam-v3-model.test.ts +342 -0
  115. package/apps/studio/test/fixtures/figjam-v3-groups-bindings.svg +1 -0
  116. package/apps/studio/test/fixtures/mock-acp-agent.mjs +45 -0
  117. package/apps/studio/test/git-api.test.ts +0 -0
  118. package/apps/studio/test/git-branches.test.ts +106 -0
  119. package/apps/studio/test/git-grouping.test.ts +106 -0
  120. package/apps/studio/test/git-watch.test.ts +97 -0
  121. package/apps/studio/test/github-api.test.ts +465 -0
  122. package/apps/studio/test/hub-link.test.ts +69 -0
  123. package/apps/studio/test/participants-chrome.test.ts +36 -1
  124. package/apps/studio/test/shared-doc-migrate.test.ts +224 -14
  125. package/apps/studio/test/sync-agent.test.ts +272 -2
  126. package/apps/studio/test/sync-codec.test.ts +65 -0
  127. package/apps/studio/test/sync-cold-seed-dedup.test.ts +187 -0
  128. package/apps/studio/test/sync-cold-start.test.ts +304 -0
  129. package/apps/studio/test/sync-connection-state.test.ts +68 -0
  130. package/apps/studio/test/sync-hardening.test.ts +0 -8
  131. package/apps/studio/test/sync-hubs-config.test.ts +0 -1
  132. package/apps/studio/test/sync-incident-replay.test.ts +211 -0
  133. package/apps/studio/test/sync-journal.test.ts +176 -0
  134. package/apps/studio/test/sync-runtime.test.ts +360 -5
  135. package/apps/studio/test/sync-status.test.ts +66 -0
  136. package/apps/studio/test/tour-overlay.test.tsx +18 -0
  137. package/apps/studio/test/use-annotation-resize.test.ts +159 -0
  138. package/apps/studio/test/use-artboard-drag.test.ts +0 -1
  139. package/apps/studio/test/use-collab.test.ts +0 -0
  140. package/apps/studio/test/use-tool-mode.test.tsx +5 -2
  141. package/apps/studio/tool-palette.tsx +30 -11
  142. package/apps/studio/undo-hud.tsx +0 -1
  143. package/apps/studio/use-agent-presence.tsx +4 -2
  144. package/apps/studio/use-annotation-resize.tsx +484 -54
  145. package/apps/studio/use-annotation-selection.tsx +16 -4
  146. package/apps/studio/use-canvas-activity.tsx +2 -2
  147. package/apps/studio/use-chrome-visibility.tsx +66 -0
  148. package/apps/studio/use-collab.tsx +434 -202
  149. package/apps/studio/use-selection-set.tsx +9 -0
  150. package/apps/studio/use-tool-mode.tsx +5 -1
  151. package/apps/studio/whats-new.json +208 -10
  152. package/apps/studio/whats-new.schema.json +18 -2
  153. package/apps/studio/ws.ts +44 -1
  154. package/cli/commands/design-link.test.mjs +84 -0
  155. package/cli/commands/design.mjs +3 -1
  156. package/cli/commands/design.test.mjs +5 -1
  157. package/cli/lib/design-link.mjs +51 -1
  158. package/cli/lib/gitignore-block.mjs +16 -3
  159. package/cli/lib/gitignore-block.test.mjs +13 -1
  160. package/package.json +11 -9
  161. package/plugins/design/dependencies.json +17 -0
  162. package/plugins/design/templates/_shell.html +58 -12
@@ -0,0 +1,211 @@
1
+ // Incident replay — the 2026-06-11 `ui-maskot` data loss (DDR-102).
2
+ //
3
+ // The shape that ate a day of work: peer A seeded a STALE body to the hub;
4
+ // peer B booted later holding a NEWER divergent local file and no journal
5
+ // (first link on that machine). v1.1 hub-wins overwrote B's disk with A's
6
+ // stale body; the only trace was a conflict notice. This test replays the
7
+ // exact shape against the REAL journal + REAL history snapshots and asserts
8
+ // the DDR-102 outcome: B loses nothing.
9
+ //
10
+ // Scenario 2 is the everyday case that must stay silent: B's local matches
11
+ // what this machine last synced (journal hash) → the hub being ahead is a
12
+ // clean fast-forward, no snapshots, no conflict noise.
13
+
14
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
15
+ import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
16
+ import { tmpdir } from 'node:os';
17
+ import { join } from 'node:path';
18
+ import * as Y from 'yjs';
19
+
20
+ import type { Context } from '../context.ts';
21
+ import { createHistory } from '../history.ts';
22
+ import { createCanvasSyncAgent } from '../sync/agent.ts';
23
+ import { applyHtmlToDoc, htmlFromDoc, stampBodyEdit } from '../sync/codec.ts';
24
+ import { createEchoGuard, hashBytes } from '../sync/echo-guard.ts';
25
+ import { loadJournal, type SyncJournal } from '../sync/journal.ts';
26
+
27
+ const STALE_BODY = '<div class="maskot">stale 2551-byte version that seeded the hub</div>';
28
+ const NEWER_BODY = '<div class="maskot">a full day of mascot work — 6 kB of it</div>';
29
+
30
+ let dir: string; // peer B's design root
31
+ let journal: SyncJournal;
32
+
33
+ function paths() {
34
+ return {
35
+ html: join(dir, 'ui', 'maskot.html'),
36
+ comments: join(dir, '_comments', 'ui-maskot.json'),
37
+ annotations: join(dir, 'ui-maskot.annotations.svg'),
38
+ };
39
+ }
40
+
41
+ /** Minimal ctx for the REAL history lib (snapshots under <dir>/_history). */
42
+ function historyCtx(): Context {
43
+ return {
44
+ paths: {
45
+ repoRoot: dir,
46
+ designRel: '.',
47
+ designRoot: dir,
48
+ historyDir: join(dir, '_history'),
49
+ },
50
+ } as unknown as Context;
51
+ }
52
+
53
+ beforeEach(() => {
54
+ dir = mkdtempSync(join(tmpdir(), 'incident-replay-'));
55
+ mkdirSync(join(dir, 'ui'), { recursive: true });
56
+ journal = loadJournal(dir, { flushMs: 0 });
57
+ });
58
+
59
+ afterEach(() => {
60
+ journal.stop();
61
+ rmSync(dir, { recursive: true, force: true });
62
+ });
63
+
64
+ /** Build peer B's agent against `doc` with the REAL journal + history. */
65
+ function makeAgentB(doc: Y.Doc, conflicts: unknown[]) {
66
+ const history = createHistory(historyCtx());
67
+ const agent = createCanvasSyncAgent({
68
+ slug: 'ui-maskot',
69
+ doc,
70
+ paths: paths(),
71
+ echoGuard: createEchoGuard(),
72
+ flushMs: 0,
73
+ journal,
74
+ snapshot: async (content, reason) => {
75
+ try {
76
+ const snap = await history.writeSnapshot('ui/maskot.html', content, reason);
77
+ return snap.ts;
78
+ } catch {
79
+ return null;
80
+ }
81
+ },
82
+ onConflict: (info) => {
83
+ conflicts.push(info);
84
+ },
85
+ });
86
+ agent.start();
87
+ return agent;
88
+ }
89
+
90
+ describe('incident replay — ui-maskot cold-start divergence (DDR-102)', () => {
91
+ test('peer B (newer divergent local, no journal) loses NOTHING: winner=local, dual snapshot, hub gets B body', async () => {
92
+ // Peer A seeded the hub with the stale body an hour ago (older stamp).
93
+ const hubDoc = new Y.Doc();
94
+ hubDoc.transact(() => {
95
+ applyHtmlToDoc(hubDoc, STALE_BODY);
96
+ stampBodyEdit(hubDoc, undefined, Date.now() - 60 * 60 * 1000);
97
+ });
98
+
99
+ // Peer B holds a NEWER divergent local file (mtime = now) and has never
100
+ // synced on this machine (journal absent).
101
+ writeFileSync(paths().html, NEWER_BODY);
102
+
103
+ const conflicts: Array<{
104
+ kind?: string;
105
+ winner?: string;
106
+ snapshots?: { local?: string; hub?: string };
107
+ }> = [];
108
+ const agent = makeAgentB(hubDoc, conflicts);
109
+ await agent.reconcile();
110
+ agent.stop();
111
+
112
+ // 1) B's file is byte-identical on disk — the day of work survived.
113
+ expect(readFileSync(paths().html, 'utf8')).toBe(NEWER_BODY);
114
+ // 2) Winner = local (newer mtime beats the hub's older stamp).
115
+ expect(conflicts).toHaveLength(1);
116
+ expect(conflicts[0].kind).toBe('cold-start-diverged');
117
+ expect(conflicts[0].winner).toBe('local');
118
+ // 3) BOTH versions snapshotted to _history/ui-maskot/ (real files).
119
+ expect(conflicts[0].snapshots?.local).toBeDefined();
120
+ expect(conflicts[0].snapshots?.hub).toBeDefined();
121
+ const snapDir = join(dir, '_history', 'ui-maskot');
122
+ const blobs = readdirSync(snapDir).filter((f) => f.endsWith('.html'));
123
+ const contents = blobs.map((f) => readFileSync(join(snapDir, f), 'utf8'));
124
+ expect(contents).toContain(NEWER_BODY);
125
+ expect(contents).toContain(STALE_BODY);
126
+ // 4) The hub doc now carries B's body (seeded up, stamped).
127
+ expect(htmlFromDoc(hubDoc)).toBe(NEWER_BODY);
128
+ // 5) The journal checkpointed — the NEXT boot is a clean fast-forward.
129
+ expect(journal.get('ui-maskot')?.bodyHash).toBe(hashBytes(NEWER_BODY));
130
+ });
131
+
132
+ test('peer B with a CLEAN journal fast-forwards silently: no snapshot, no conflict', async () => {
133
+ // B previously synced STALE_BODY on this machine (journal checkpoint),
134
+ // disk still holds exactly that; the hub then moved ahead.
135
+ writeFileSync(paths().html, STALE_BODY);
136
+ journal.record('ui-maskot', { bodyHash: hashBytes(STALE_BODY) });
137
+
138
+ const hubDoc = new Y.Doc();
139
+ hubDoc.transact(() => {
140
+ applyHtmlToDoc(hubDoc, NEWER_BODY);
141
+ stampBodyEdit(hubDoc);
142
+ });
143
+
144
+ const conflicts: unknown[] = [];
145
+ const agent = makeAgentB(hubDoc, conflicts);
146
+ await agent.reconcile();
147
+ agent.stop();
148
+
149
+ // Disk fast-forwarded to hub state…
150
+ expect(readFileSync(paths().html, 'utf8')).toBe(NEWER_BODY);
151
+ // …with zero conflict noise and zero history spam.
152
+ expect(conflicts).toHaveLength(0);
153
+ const snapDir = join(dir, '_history', 'ui-maskot');
154
+ let blobs: string[] = [];
155
+ try {
156
+ blobs = readdirSync(snapDir);
157
+ } catch {
158
+ /* dir never created — exactly the point */
159
+ }
160
+ expect(blobs).toHaveLength(0);
161
+ // Journal advanced to the new state.
162
+ expect(journal.get('ui-maskot')?.bodyHash).toBe(hashBytes(NEWER_BODY));
163
+ });
164
+
165
+ test('boot order REVERSED (B first, then A): A fast-forwards to B state, nothing lost either way', async () => {
166
+ // B boots first against an empty hub → DDR-064 seed-local-up.
167
+ writeFileSync(paths().html, NEWER_BODY);
168
+ const hubDoc = new Y.Doc();
169
+ const conflictsB: unknown[] = [];
170
+ const agentB = makeAgentB(hubDoc, conflictsB);
171
+ await agentB.reconcile();
172
+ agentB.stop();
173
+ expect(htmlFromDoc(hubDoc)).toBe(NEWER_BODY);
174
+ expect(conflictsB).toHaveLength(0); // empty-hub seed is not a conflict
175
+
176
+ // Peer A (separate design root + journal) holds the stale body it synced
177
+ // before — its journal matches its disk → clean fast-forward to B's state.
178
+ const dirA = mkdtempSync(join(tmpdir(), 'incident-replay-A-'));
179
+ mkdirSync(join(dirA, 'ui'), { recursive: true });
180
+ try {
181
+ writeFileSync(join(dirA, 'ui', 'maskot.html'), STALE_BODY);
182
+ const journalA = loadJournal(dirA, { flushMs: 0 });
183
+ journalA.record('ui-maskot', { bodyHash: hashBytes(STALE_BODY) });
184
+ const conflictsA: unknown[] = [];
185
+ const agentA = createCanvasSyncAgent({
186
+ slug: 'ui-maskot',
187
+ doc: hubDoc,
188
+ paths: {
189
+ html: join(dirA, 'ui', 'maskot.html'),
190
+ comments: join(dirA, '_comments', 'ui-maskot.json'),
191
+ annotations: join(dirA, 'ui-maskot.annotations.svg'),
192
+ },
193
+ echoGuard: createEchoGuard(),
194
+ flushMs: 0,
195
+ journal: journalA,
196
+ onConflict: (info) => {
197
+ conflictsA.push(info);
198
+ },
199
+ });
200
+ agentA.start();
201
+ await agentA.reconcile();
202
+ agentA.stop();
203
+ journalA.stop();
204
+
205
+ expect(readFileSync(join(dirA, 'ui', 'maskot.html'), 'utf8')).toBe(NEWER_BODY);
206
+ expect(conflictsA).toHaveLength(0);
207
+ } finally {
208
+ rmSync(dirA, { recursive: true, force: true });
209
+ }
210
+ });
211
+ });
@@ -0,0 +1,176 @@
1
+ // Sync journal unit tests — hub-sync cold-start safety (DDR-102).
2
+ //
3
+ // The journal is the divergence detector's memory: it records, per slug, the
4
+ // content hash this machine last reconciled disk↔doc. These tests cover the
5
+ // load/record/flush/invalidate API, the corrupt-file → absent posture, and
6
+ // the per-hub invalidation rule.
7
+
8
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
9
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
10
+ import { tmpdir } from 'node:os';
11
+ import { join } from 'node:path';
12
+
13
+ import { hashBytes } from '../sync/echo-guard.ts';
14
+ import { journalPath, loadJournal, type SyncJournal } from '../sync/journal.ts';
15
+
16
+ let dir: string;
17
+ let journal: SyncJournal | null = null;
18
+
19
+ beforeEach(() => {
20
+ dir = mkdtempSync(join(tmpdir(), 'sync-journal-'));
21
+ journal = null;
22
+ });
23
+
24
+ afterEach(() => {
25
+ journal?.stop();
26
+ rmSync(dir, { recursive: true, force: true });
27
+ });
28
+
29
+ describe('loadJournal — basic API', () => {
30
+ test('get returns null for a never-recorded slug', () => {
31
+ journal = loadJournal(dir, { flushMs: 0 });
32
+ expect(journal.get('ui-maskot')).toBeNull();
33
+ });
34
+
35
+ test('record + get round-trip with bodyHash and optional cssHash', () => {
36
+ journal = loadJournal(dir, { flushMs: 0, now: () => 42 });
37
+ const bodyHash = hashBytes('<div>v1</div>');
38
+ journal.record('ui-maskot', { bodyHash });
39
+ expect(journal.get('ui-maskot')).toEqual({ bodyHash, at: 42 });
40
+
41
+ const cssHash = hashBytes('.a{}');
42
+ journal.record('ui-maskot', { bodyHash, cssHash });
43
+ expect(journal.get('ui-maskot')).toEqual({ bodyHash, cssHash, at: 42 });
44
+ });
45
+
46
+ test('flush persists to <designRoot>/_state/sync-journal.json, creating _state/ on demand', () => {
47
+ journal = loadJournal(dir, { flushMs: 0 });
48
+ expect(existsSync(join(dir, '_state'))).toBe(false);
49
+ journal.record('ui-a', { bodyHash: 'h1' });
50
+
51
+ const file = journalPath(dir);
52
+ expect(existsSync(file)).toBe(true);
53
+ const parsed = JSON.parse(readFileSync(file, 'utf8'));
54
+ expect(parsed.slugs['ui-a'].bodyHash).toBe('h1');
55
+ expect(typeof parsed.updatedAt).toBe('number');
56
+ });
57
+
58
+ test('a fresh journal instance reads back what a previous one persisted', () => {
59
+ const first = loadJournal(dir, { flushMs: 0, now: () => 7 });
60
+ first.record('ui-a', { bodyHash: 'h1' });
61
+ first.stop();
62
+
63
+ journal = loadJournal(dir);
64
+ expect(journal.get('ui-a')).toEqual({ bodyHash: 'h1', at: 7 });
65
+ expect(journal.size()).toBe(1);
66
+ });
67
+
68
+ test('debounces rapid records into one write', async () => {
69
+ let writes = 0;
70
+ journal = loadJournal(dir, {
71
+ flushMs: 20,
72
+ writer: () => {
73
+ writes++;
74
+ },
75
+ });
76
+ for (let i = 0; i < 10; i++) journal.record('ui-a', { bodyHash: `h${i}` });
77
+ expect(writes).toBe(0); // still pending
78
+ await new Promise((res) => setTimeout(res, 60));
79
+ expect(writes).toBe(1);
80
+ });
81
+
82
+ test('stop() flushes the pending debounce', () => {
83
+ let written: string | null = null;
84
+ journal = loadJournal(dir, {
85
+ flushMs: 10_000,
86
+ writer: (_p, bytes) => {
87
+ written = bytes;
88
+ },
89
+ });
90
+ journal.record('ui-a', { bodyHash: 'h1' });
91
+ expect(written).toBeNull();
92
+ journal.stop();
93
+ expect(written).not.toBeNull();
94
+ expect(JSON.parse(written as unknown as string).slugs['ui-a'].bodyHash).toBe('h1');
95
+ });
96
+ });
97
+
98
+ describe('loadJournal — corrupt / hostile file posture', () => {
99
+ test('corrupt JSON → treated as absent, never throws', () => {
100
+ const file = journalPath(dir);
101
+ // Persist a valid journal first (creates _state/), then corrupt it.
102
+ journal = loadJournal(dir, { flushMs: 0 });
103
+ journal.record('ui-a', { bodyHash: 'h1' });
104
+ journal.stop();
105
+
106
+ writeFileSync(file, '{not json');
107
+ journal = loadJournal(dir);
108
+ expect(journal.get('ui-a')).toBeNull();
109
+ expect(journal.size()).toBe(0);
110
+ });
111
+
112
+ test('wrong-shape JSON (array / non-object slugs / bad entries) → absent entries', () => {
113
+ const file = journalPath(dir);
114
+ journal = loadJournal(dir, { flushMs: 0 });
115
+ journal.record('seed', { bodyHash: 'x' }); // creates _state/
116
+ journal.stop();
117
+
118
+ writeFileSync(file, JSON.stringify([1, 2, 3]));
119
+ expect(loadJournal(dir).size()).toBe(0);
120
+
121
+ writeFileSync(file, JSON.stringify({ hubUrl: 1, slugs: { 'ui-a': { bodyHash: 99 } } }));
122
+ const j = loadJournal(dir);
123
+ expect(j.get('ui-a')).toBeNull();
124
+ expect(j.size()).toBe(0);
125
+ });
126
+
127
+ test('writer failure is swallowed (never throws into the sync path)', () => {
128
+ journal = loadJournal(dir, {
129
+ flushMs: 0,
130
+ writer: () => {
131
+ throw new Error('disk full');
132
+ },
133
+ });
134
+ expect(() => journal?.record('ui-a', { bodyHash: 'h1' })).not.toThrow();
135
+ expect(journal.get('ui-a')?.bodyHash).toBe('h1'); // in-memory state intact
136
+ });
137
+ });
138
+
139
+ describe('loadJournal — per-hub invalidation', () => {
140
+ test('first invalidateIfHubChanged adopts the URL without wiping', () => {
141
+ journal = loadJournal(dir, { flushMs: 0 });
142
+ journal.record('ui-a', { bodyHash: 'h1' });
143
+ journal.invalidateIfHubChanged('https://hub.example.com');
144
+ expect(journal.get('ui-a')?.bodyHash).toBe('h1');
145
+ });
146
+
147
+ test('same hub URL on a later boot keeps entries', () => {
148
+ const first = loadJournal(dir, { flushMs: 0 });
149
+ first.invalidateIfHubChanged('https://hub.example.com');
150
+ first.record('ui-a', { bodyHash: 'h1' });
151
+ first.stop();
152
+
153
+ journal = loadJournal(dir, { flushMs: 0 });
154
+ journal.invalidateIfHubChanged('https://hub.example.com');
155
+ expect(journal.get('ui-a')?.bodyHash).toBe('h1');
156
+ });
157
+
158
+ test('a DIFFERENT hub URL wipes every entry (hashes are per-hub)', () => {
159
+ const first = loadJournal(dir, { flushMs: 0 });
160
+ first.invalidateIfHubChanged('https://hub-one.example.com');
161
+ first.record('ui-a', { bodyHash: 'h1' });
162
+ first.record('ui-b', { bodyHash: 'h2' });
163
+ first.stop();
164
+
165
+ journal = loadJournal(dir, { flushMs: 0 });
166
+ journal.invalidateIfHubChanged('https://hub-two.example.com');
167
+ expect(journal.get('ui-a')).toBeNull();
168
+ expect(journal.get('ui-b')).toBeNull();
169
+ expect(journal.size()).toBe(0);
170
+
171
+ // …and the wipe is persisted.
172
+ const parsed = JSON.parse(readFileSync(journalPath(dir), 'utf8'));
173
+ expect(parsed.hubUrl).toBe('https://hub-two.example.com');
174
+ expect(Object.keys(parsed.slugs)).toHaveLength(0);
175
+ });
176
+ });