@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.
- package/README.md +6 -6
- package/apps/studio/acp/bridge.ts +285 -0
- package/apps/studio/acp/env.ts +48 -0
- package/apps/studio/acp/index.ts +132 -0
- package/apps/studio/acp/probe.ts +112 -0
- package/apps/studio/acp/transcript.ts +149 -0
- package/apps/studio/ai-banner.tsx +0 -1
- package/apps/studio/annotations-align.ts +149 -0
- package/apps/studio/annotations-bindings.ts +197 -0
- package/apps/studio/annotations-context-toolbar.tsx +436 -186
- package/apps/studio/annotations-groups.ts +270 -0
- package/apps/studio/annotations-layer.tsx +1714 -1661
- package/apps/studio/annotations-model.ts +2077 -0
- package/apps/studio/annotations-snap.ts +125 -0
- package/apps/studio/api.ts +433 -182
- package/apps/studio/bin/_png-playwright.mjs +1 -1
- package/apps/studio/bin/annotate.mjs +732 -0
- package/apps/studio/bin/annotate.sh +17 -0
- package/apps/studio/bin/chat-open.sh +44 -0
- package/apps/studio/bin/read-annotations.mjs +152 -17
- package/apps/studio/build.ts +1 -1
- package/apps/studio/canvas-arrowheads.ts +78 -9
- package/apps/studio/canvas-cursors.ts +2 -0
- package/apps/studio/canvas-edit.ts +257 -7
- package/apps/studio/canvas-icons.tsx +105 -0
- package/apps/studio/canvas-lib.tsx +112 -19
- package/apps/studio/canvas-list-watch.ts +177 -0
- package/apps/studio/canvas-shell.tsx +326 -9
- package/apps/studio/client/app.jsx +3579 -250
- package/apps/studio/client/canvas-url.js +5 -0
- package/apps/studio/client/github.js +99 -0
- package/apps/studio/client/index.html +1 -1
- package/apps/studio/client/panels/ChatPanel.jsx +770 -0
- package/apps/studio/client/panels/CollabModelInfographic.jsx +71 -0
- package/apps/studio/client/panels/CreateProject.jsx +334 -0
- package/apps/studio/client/panels/DiffView.jsx +590 -0
- package/apps/studio/client/panels/GitPanel.jsx +767 -0
- package/apps/studio/client/panels/IdentityBar.jsx +294 -0
- package/apps/studio/client/panels/OnboardingWizard.jsx +563 -0
- package/apps/studio/client/panels/RepoBranchSwitcher.jsx +349 -0
- package/apps/studio/client/panels/acp-runtime.js +286 -0
- package/apps/studio/client/panels/chat-markdown.jsx +138 -0
- package/apps/studio/client/panels/git-grouping.js +86 -0
- package/apps/studio/client/styles/0-reset.css +4 -0
- package/apps/studio/client/styles/3-shell-maude.css +1326 -14
- package/apps/studio/client/styles/4-components.css +39 -0
- package/apps/studio/client/styles/5-maude-overrides.css +41 -2
- package/apps/studio/client/styles/6-acp-chat.css +771 -0
- package/apps/studio/client/styles/_index.css +2 -0
- package/apps/studio/client/tour/collab-tour.js +61 -0
- package/apps/studio/client/tour/overlay.jsx +116 -18
- package/apps/studio/client/tour/usage-tour.js +23 -10
- package/apps/studio/client/whats-new.jsx +25 -10
- package/apps/studio/collab/registry.ts +13 -0
- package/apps/studio/collab/room.ts +36 -0
- package/apps/studio/commands/annotation-strokes-command.ts +1 -1
- package/apps/studio/commands/move-artboards-command.ts +1 -1
- package/apps/studio/comments-overlay.tsx +7 -5
- package/apps/studio/context-menu.tsx +4 -3
- package/apps/studio/contextual-toolbar.tsx +14 -0
- package/apps/studio/cursors-overlay.tsx +37 -21
- package/apps/studio/dist/client.bundle.js +31795 -1829
- package/apps/studio/dist/comment-mount.js +97 -9
- package/apps/studio/dist/styles.css +7938 -1599
- package/apps/studio/dom-selection.ts +115 -0
- package/apps/studio/export-dialog.tsx +3 -3
- package/apps/studio/git/endpoints.ts +338 -0
- package/apps/studio/git/service.ts +1334 -0
- package/apps/studio/git/watch.ts +97 -0
- package/apps/studio/github/endpoints.ts +358 -0
- package/apps/studio/github/service.ts +231 -0
- package/apps/studio/github/token.ts +53 -0
- package/apps/studio/history.ts +20 -3
- package/apps/studio/hmr-broadcast.ts +9 -2
- package/apps/studio/http.ts +508 -6
- package/apps/studio/input-router.tsx +7 -0
- package/apps/studio/inspect.ts +37 -8
- package/apps/studio/participants-chrome.tsx +70 -16
- package/apps/studio/paths.ts +12 -0
- package/apps/studio/scaffold-design.ts +57 -0
- package/apps/studio/server.ts +70 -4
- package/apps/studio/sync/agent.ts +311 -57
- package/apps/studio/sync/codec.ts +69 -0
- package/apps/studio/sync/cold-start.ts +198 -0
- package/apps/studio/sync/connection-state.ts +58 -2
- package/apps/studio/sync/hub-link.ts +137 -0
- package/apps/studio/sync/index.ts +563 -221
- package/apps/studio/sync/journal.ts +190 -0
- package/apps/studio/sync/migrate-seed.ts +202 -44
- package/apps/studio/sync/projection.ts +22 -3
- package/apps/studio/sync/status.ts +15 -4
- package/apps/studio/test/acp-bridge.test.ts +127 -0
- package/apps/studio/test/acp-env.test.ts +65 -0
- package/apps/studio/test/acp-origin-gate.test.ts +78 -0
- package/apps/studio/test/acp-transcript.test.ts +112 -0
- package/apps/studio/test/activity.test.ts +1 -7
- package/apps/studio/test/annotate-write.test.ts +184 -0
- package/apps/studio/test/annotations-align.test.ts +88 -0
- package/apps/studio/test/annotations-bindings.test.ts +124 -0
- package/apps/studio/test/annotations-groups.test.ts +231 -0
- package/apps/studio/test/annotations-snap.test.ts +79 -0
- package/apps/studio/test/canvas-create-api.test.ts +72 -0
- package/apps/studio/test/canvas-edit.test.ts +181 -1
- package/apps/studio/test/canvas-list-watch.test.ts +322 -0
- package/apps/studio/test/canvas-meta-api.test.ts +161 -27
- package/apps/studio/test/canvas-origin-gate.test.ts +43 -0
- package/apps/studio/test/chat-markdown.test.tsx +58 -0
- package/apps/studio/test/collab-bridge.test.ts +0 -2
- package/apps/studio/test/collab-room.test.ts +2 -7
- package/apps/studio/test/collab-session-survival.test.tsx +176 -0
- package/apps/studio/test/csp-canvas-shell.test.ts +5 -0
- package/apps/studio/test/csrf-write-guard.test.ts +78 -0
- package/apps/studio/test/editing-presence.test.ts +103 -0
- package/apps/studio/test/figjam-v3-model.test.ts +342 -0
- package/apps/studio/test/fixtures/figjam-v3-groups-bindings.svg +1 -0
- package/apps/studio/test/fixtures/mock-acp-agent.mjs +45 -0
- package/apps/studio/test/git-api.test.ts +0 -0
- package/apps/studio/test/git-branches.test.ts +106 -0
- package/apps/studio/test/git-grouping.test.ts +106 -0
- package/apps/studio/test/git-watch.test.ts +97 -0
- package/apps/studio/test/github-api.test.ts +465 -0
- package/apps/studio/test/hub-link.test.ts +69 -0
- package/apps/studio/test/participants-chrome.test.ts +36 -1
- package/apps/studio/test/shared-doc-migrate.test.ts +224 -14
- package/apps/studio/test/sync-agent.test.ts +272 -2
- package/apps/studio/test/sync-codec.test.ts +65 -0
- package/apps/studio/test/sync-cold-seed-dedup.test.ts +187 -0
- package/apps/studio/test/sync-cold-start.test.ts +304 -0
- package/apps/studio/test/sync-connection-state.test.ts +68 -0
- package/apps/studio/test/sync-hardening.test.ts +0 -8
- package/apps/studio/test/sync-hubs-config.test.ts +0 -1
- package/apps/studio/test/sync-incident-replay.test.ts +211 -0
- package/apps/studio/test/sync-journal.test.ts +176 -0
- package/apps/studio/test/sync-runtime.test.ts +360 -5
- package/apps/studio/test/sync-status.test.ts +66 -0
- package/apps/studio/test/tour-overlay.test.tsx +18 -0
- package/apps/studio/test/use-annotation-resize.test.ts +159 -0
- package/apps/studio/test/use-artboard-drag.test.ts +0 -1
- package/apps/studio/test/use-collab.test.ts +0 -0
- package/apps/studio/test/use-tool-mode.test.tsx +5 -2
- package/apps/studio/tool-palette.tsx +30 -11
- package/apps/studio/undo-hud.tsx +0 -1
- package/apps/studio/use-agent-presence.tsx +4 -2
- package/apps/studio/use-annotation-resize.tsx +484 -54
- package/apps/studio/use-annotation-selection.tsx +16 -4
- package/apps/studio/use-canvas-activity.tsx +2 -2
- package/apps/studio/use-chrome-visibility.tsx +66 -0
- package/apps/studio/use-collab.tsx +434 -202
- package/apps/studio/use-selection-set.tsx +9 -0
- package/apps/studio/use-tool-mode.tsx +5 -1
- package/apps/studio/whats-new.json +208 -10
- package/apps/studio/whats-new.schema.json +18 -2
- package/apps/studio/ws.ts +44 -1
- package/cli/commands/design-link.test.mjs +84 -0
- package/cli/commands/design.mjs +3 -1
- package/cli/commands/design.test.mjs +5 -1
- package/cli/lib/design-link.mjs +51 -1
- package/cli/lib/gitignore-block.mjs +16 -3
- package/cli/lib/gitignore-block.test.mjs +13 -1
- package/package.json +11 -9
- package/plugins/design/dependencies.json +17 -0
- package/plugins/design/templates/_shell.html +58 -12
|
@@ -12,7 +12,8 @@ import * as Y from 'yjs';
|
|
|
12
12
|
|
|
13
13
|
import { createPersistence, Y_TYPES } from '../collab/persistence.ts';
|
|
14
14
|
import type { Context } from '../context.ts';
|
|
15
|
-
import { applyCommentsToDoc, applyHtmlToDoc } from '../sync/codec.ts';
|
|
15
|
+
import { applyCommentsToDoc, applyCssToDoc, applyHtmlToDoc } from '../sync/codec.ts';
|
|
16
|
+
import { hashBytes } from '../sync/echo-guard.ts';
|
|
16
17
|
import { docIsEmpty, migrateSeed } from '../sync/migrate-seed.ts';
|
|
17
18
|
|
|
18
19
|
let dir: string;
|
|
@@ -43,28 +44,28 @@ describe('docIsEmpty', () => {
|
|
|
43
44
|
});
|
|
44
45
|
|
|
45
46
|
describe('migrateSeed — adopt (hub was empty)', () => {
|
|
46
|
-
test('seeds an empty doc from local files and reports local-adopt', () => {
|
|
47
|
+
test('seeds an empty doc from local files and reports local-adopt', async () => {
|
|
47
48
|
const p = paths();
|
|
48
49
|
writeFileSync(p.html, '<main>local body</main>');
|
|
49
50
|
writeFileSync(p.comments, JSON.stringify([{ id: 'c1', text: 'local' }]));
|
|
50
51
|
|
|
51
52
|
const doc = new Y.Doc();
|
|
52
|
-
const result = migrateSeed({ slug: 's', doc, paths: p });
|
|
53
|
+
const result = await migrateSeed({ slug: 's', doc, paths: p });
|
|
53
54
|
|
|
54
55
|
expect(result).toBe('local-adopt');
|
|
55
56
|
expect(doc.getText('html').toString()).toBe('<main>local body</main>');
|
|
56
57
|
expect(doc.getArray(Y_TYPES.comments).toArray()).toEqual([{ id: 'c1', text: 'local' }]);
|
|
57
58
|
});
|
|
58
59
|
|
|
59
|
-
test('empty doc + no local files → "empty" (nothing to seed)', () => {
|
|
60
|
+
test('empty doc + no local files → "empty" (nothing to seed)', async () => {
|
|
60
61
|
const doc = new Y.Doc();
|
|
61
|
-
expect(migrateSeed({ slug: 's', doc, paths: paths() })).toBe('empty');
|
|
62
|
+
expect(await migrateSeed({ slug: 's', doc, paths: paths() })).toBe('empty');
|
|
62
63
|
expect(docIsEmpty(doc)).toBe(true);
|
|
63
64
|
});
|
|
64
65
|
});
|
|
65
66
|
|
|
66
67
|
describe('migrateSeed — idempotent (no duplication on re-run)', () => {
|
|
67
|
-
test('adopting twice leaves comments un-duplicated', () => {
|
|
68
|
+
test('adopting twice leaves comments un-duplicated', async () => {
|
|
68
69
|
const p = paths();
|
|
69
70
|
writeFileSync(
|
|
70
71
|
p.comments,
|
|
@@ -75,12 +76,12 @@ describe('migrateSeed — idempotent (no duplication on re-run)', () => {
|
|
|
75
76
|
);
|
|
76
77
|
|
|
77
78
|
const doc = new Y.Doc();
|
|
78
|
-
expect(migrateSeed({ slug: 's', doc, paths: p })).toBe('local-adopt');
|
|
79
|
+
expect(await migrateSeed({ slug: 's', doc, paths: p })).toBe('local-adopt');
|
|
79
80
|
expect(doc.getArray(Y_TYPES.comments).length).toBe(2);
|
|
80
81
|
|
|
81
82
|
// Second run (e.g. server restart): the doc is now non-empty → hub-wins
|
|
82
83
|
// branch → no re-adopt → still exactly 2 comments, byte-identical.
|
|
83
|
-
expect(migrateSeed({ slug: 's', doc, paths: p })).toBe('hub-wins');
|
|
84
|
+
expect(await migrateSeed({ slug: 's', doc, paths: p })).toBe('hub-wins');
|
|
84
85
|
expect(doc.getArray(Y_TYPES.comments).toArray()).toEqual([
|
|
85
86
|
{ id: 'c1', text: 'x' },
|
|
86
87
|
{ id: 'c2', text: 'y' },
|
|
@@ -123,7 +124,7 @@ describe('room file-seed disable (the other half of the Risk-1 fix)', () => {
|
|
|
123
124
|
});
|
|
124
125
|
|
|
125
126
|
describe('migrateSeed — hub-wins (the duplication trap avoided)', () => {
|
|
126
|
-
test('a doc already holding hub comments is NOT merged with the local file → no dup', () => {
|
|
127
|
+
test('a doc already holding hub comments is NOT merged with the local file → no dup', async () => {
|
|
127
128
|
const p = paths();
|
|
128
129
|
// Local file (e.g. copied via git) holds the SAME logical comment id…
|
|
129
130
|
writeFileSync(p.comments, JSON.stringify([{ id: 'c1', text: 'from disk' }]));
|
|
@@ -132,14 +133,15 @@ describe('migrateSeed — hub-wins (the duplication trap avoided)', () => {
|
|
|
132
133
|
const doc = new Y.Doc();
|
|
133
134
|
applyCommentsToDoc(doc, [{ id: 'c1', text: 'from hub' }], 'hub');
|
|
134
135
|
|
|
135
|
-
const result = migrateSeed({ slug: 's', doc, paths: p });
|
|
136
|
+
const result = await migrateSeed({ slug: 's', doc, paths: p });
|
|
136
137
|
|
|
137
|
-
// Hub wins; the local file is NOT pushed as a second item — exactly ONE c1
|
|
138
|
+
// Hub wins; the local file is NOT pushed as a second item — exactly ONE c1
|
|
139
|
+
// (DDR-102 id-union keeps the doc's version for a shared id).
|
|
138
140
|
expect(result).toBe('hub-wins');
|
|
139
141
|
expect(doc.getArray(Y_TYPES.comments).toArray()).toEqual([{ id: 'c1', text: 'from hub' }]);
|
|
140
142
|
});
|
|
141
143
|
|
|
142
|
-
test('snapshots divergent local files to historyDir before the hub-wins overwrite', () => {
|
|
144
|
+
test('snapshots divergent local files to historyDir before the hub-wins overwrite', async () => {
|
|
143
145
|
const p = paths();
|
|
144
146
|
writeFileSync(p.html, '<main>LOCAL divergent</main>');
|
|
145
147
|
writeFileSync(p.comments, JSON.stringify([{ id: 'local' }]));
|
|
@@ -148,12 +150,220 @@ describe('migrateSeed — hub-wins (the duplication trap avoided)', () => {
|
|
|
148
150
|
applyHtmlToDoc(doc, '<main>HUB</main>', 'hub'); // doc holds hub state
|
|
149
151
|
|
|
150
152
|
const historyDir = join(dir, '_history', 's');
|
|
151
|
-
|
|
153
|
+
// DDR-102: this divergence (no journal, no doc stamp) resolves
|
|
154
|
+
// conflict-hub-wins — the legacy whole-set safety copy still happens.
|
|
155
|
+
const result = await migrateSeed({ slug: 's', doc, paths: p, historyDir });
|
|
152
156
|
|
|
153
|
-
expect(result).toBe('hub-wins');
|
|
157
|
+
expect(result).toBe('conflict-hub-wins');
|
|
154
158
|
// The pre-migration snapshot captured the local files for rollback.
|
|
155
159
|
const snapped = readdirSync(join(historyDir, 'pre-shared-doc-migration'));
|
|
156
160
|
expect(snapped).toContain('screen.html');
|
|
157
161
|
expect(snapped).toContain('screen.comments.json');
|
|
158
162
|
});
|
|
159
163
|
});
|
|
164
|
+
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
// DDR-102 — the shared-doc path consumes the same cold-start decision table:
|
|
167
|
+
// journal-gated fast-forward, dual snapshot + newest-wins on divergence,
|
|
168
|
+
// comments id-union.
|
|
169
|
+
describe('migrateSeed — DDR-102 divergence matrix', () => {
|
|
170
|
+
function makeJournal(initial: Record<string, { bodyHash: string }> = {}) {
|
|
171
|
+
const entries = new Map(Object.entries(initial));
|
|
172
|
+
return {
|
|
173
|
+
get: (slug: string) => entries.get(slug) ?? null,
|
|
174
|
+
record: (slug: string, hashes: { bodyHash?: string; cssHash?: string }) => {
|
|
175
|
+
const prev = entries.get(slug);
|
|
176
|
+
entries.set(slug, { bodyHash: hashes.bodyHash ?? prev?.bodyHash ?? '' });
|
|
177
|
+
},
|
|
178
|
+
invalidateIfHubChanged: () => {},
|
|
179
|
+
flush: () => {},
|
|
180
|
+
stop: () => {},
|
|
181
|
+
size: () => entries.size,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
test('journal match → fast-forward (hub keeps), no snapshot, no conflict', async () => {
|
|
186
|
+
const p = paths();
|
|
187
|
+
writeFileSync(p.html, '<main>synced-v1</main>');
|
|
188
|
+
const doc = new Y.Doc();
|
|
189
|
+
applyHtmlToDoc(doc, '<main>hub-v2</main>', 'hub');
|
|
190
|
+
|
|
191
|
+
const journal = makeJournal({ s: { bodyHash: hashBytes('<main>synced-v1</main>') } });
|
|
192
|
+
const snapshots: string[] = [];
|
|
193
|
+
const conflicts: unknown[] = [];
|
|
194
|
+
const result = await migrateSeed({
|
|
195
|
+
slug: 's',
|
|
196
|
+
doc,
|
|
197
|
+
paths: p,
|
|
198
|
+
journal,
|
|
199
|
+
snapshot: async (_c, reason) => {
|
|
200
|
+
snapshots.push(reason);
|
|
201
|
+
return 'ts';
|
|
202
|
+
},
|
|
203
|
+
onConflict: (info) => {
|
|
204
|
+
conflicts.push(info);
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
expect(result).toBe('hub-wins');
|
|
209
|
+
expect(doc.getText('html').toString()).toBe('<main>hub-v2</main>'); // doc untouched
|
|
210
|
+
expect(snapshots).toHaveLength(0);
|
|
211
|
+
expect(conflicts).toHaveLength(0);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test('diverged + local newer → conflict-local-wins: doc REBUILT from local in one MIGRATION tx', async () => {
|
|
215
|
+
const p = paths();
|
|
216
|
+
writeFileSync(p.html, '<main>local-newer</main>');
|
|
217
|
+
writeFileSync(p.css, '.local{}');
|
|
218
|
+
const doc = new Y.Doc();
|
|
219
|
+
doc.transact(() => {
|
|
220
|
+
applyHtmlToDoc(doc, '<main>hub-older</main>', 'hub');
|
|
221
|
+
applyCssToDoc(doc, '.hub{}', 'hub');
|
|
222
|
+
doc.getMap('syncMeta').set('bodyEditAt', Date.now() - 60_000);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
const snapshots: Array<{ content: string; reason: string }> = [];
|
|
226
|
+
const conflicts: Array<{ winner?: string; snapshots?: { local?: string; hub?: string } }> = [];
|
|
227
|
+
const result = await migrateSeed({
|
|
228
|
+
slug: 's',
|
|
229
|
+
doc,
|
|
230
|
+
paths: p,
|
|
231
|
+
journal: makeJournal(),
|
|
232
|
+
snapshot: async (content, reason) => {
|
|
233
|
+
snapshots.push({ content, reason });
|
|
234
|
+
return `ts-${snapshots.length}`;
|
|
235
|
+
},
|
|
236
|
+
onConflict: (info) => {
|
|
237
|
+
conflicts.push(info);
|
|
238
|
+
},
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
expect(result).toBe('conflict-local-wins');
|
|
242
|
+
expect(doc.getText('html').toString()).toBe('<main>local-newer</main>');
|
|
243
|
+
// css follows the body winner.
|
|
244
|
+
expect(doc.getText('css').toString()).toBe('.local{}');
|
|
245
|
+
// Dual snapshot captured BOTH pre-resolution versions.
|
|
246
|
+
expect(snapshots.map((s) => s.reason)).toEqual(['pre-sync-local', 'pre-sync-hub']);
|
|
247
|
+
expect(snapshots[0].content).toBe('<main>local-newer</main>');
|
|
248
|
+
expect(snapshots[1].content).toBe('<main>hub-older</main>');
|
|
249
|
+
expect(conflicts[0].winner).toBe('local');
|
|
250
|
+
expect(conflicts[0].snapshots).toEqual({ local: 'ts-1', hub: 'ts-2' });
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test('diverged + hub newer (stamp) → conflict-hub-wins: doc untouched', async () => {
|
|
254
|
+
const p = paths();
|
|
255
|
+
writeFileSync(p.html, '<main>local-older</main>');
|
|
256
|
+
const doc = new Y.Doc();
|
|
257
|
+
doc.transact(() => {
|
|
258
|
+
applyHtmlToDoc(doc, '<main>hub-newer</main>', 'hub');
|
|
259
|
+
doc.getMap('syncMeta').set('bodyEditAt', Date.now() + 60_000);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
const conflicts: Array<{ winner?: string }> = [];
|
|
263
|
+
const result = await migrateSeed({
|
|
264
|
+
slug: 's',
|
|
265
|
+
doc,
|
|
266
|
+
paths: p,
|
|
267
|
+
journal: makeJournal(),
|
|
268
|
+
onConflict: (info) => {
|
|
269
|
+
conflicts.push(info);
|
|
270
|
+
},
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
expect(result).toBe('conflict-hub-wins');
|
|
274
|
+
expect(doc.getText('html').toString()).toBe('<main>hub-newer</main>');
|
|
275
|
+
expect(conflicts[0].winner).toBe('hub');
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test('doc holds comments but NO body + local body → body-seed-up (gap closed)', async () => {
|
|
279
|
+
const p = paths();
|
|
280
|
+
writeFileSync(p.html, '<main>only-local-body</main>');
|
|
281
|
+
const doc = new Y.Doc();
|
|
282
|
+
doc.getArray(Y_TYPES.comments).push([{ id: 'h1' }]); // doc non-empty, body empty
|
|
283
|
+
|
|
284
|
+
const result = await migrateSeed({ slug: 's', doc, paths: p, journal: makeJournal() });
|
|
285
|
+
|
|
286
|
+
expect(result).toBe('body-seed-up');
|
|
287
|
+
expect(doc.getText('html').toString()).toBe('<main>only-local-body</main>');
|
|
288
|
+
// Hub comments survive untouched.
|
|
289
|
+
expect(doc.getArray(Y_TYPES.comments).toArray()).toEqual([{ id: 'h1' }]);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test('comments id-union: local-only comments survive a hub-wins cutover', async () => {
|
|
293
|
+
const p = paths();
|
|
294
|
+
writeFileSync(p.html, '<main>same</main>');
|
|
295
|
+
writeFileSync(
|
|
296
|
+
p.comments,
|
|
297
|
+
JSON.stringify([
|
|
298
|
+
{ id: 'c1', text: 'shared' },
|
|
299
|
+
{ id: 'c3', text: 'local-only' },
|
|
300
|
+
])
|
|
301
|
+
);
|
|
302
|
+
const doc = new Y.Doc();
|
|
303
|
+
doc.transact(() => {
|
|
304
|
+
applyHtmlToDoc(doc, '<main>same</main>', 'hub');
|
|
305
|
+
applyCommentsToDoc(
|
|
306
|
+
doc,
|
|
307
|
+
[
|
|
308
|
+
{ id: 'c1', text: 'shared (hub copy wins)' },
|
|
309
|
+
{ id: 'c2', text: 'hub-only' },
|
|
310
|
+
],
|
|
311
|
+
'hub'
|
|
312
|
+
);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
const result = await migrateSeed({ slug: 's', doc, paths: p });
|
|
316
|
+
|
|
317
|
+
expect(result).toBe('hub-wins');
|
|
318
|
+
expect(doc.getArray(Y_TYPES.comments).toArray()).toEqual([
|
|
319
|
+
{ id: 'c1', text: 'shared (hub copy wins)' },
|
|
320
|
+
{ id: 'c2', text: 'hub-only' },
|
|
321
|
+
{ id: 'c3', text: 'local-only' },
|
|
322
|
+
]);
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
// DDR-102 fail-closed (security review F1) — the shared-doc path must also
|
|
327
|
+
// refuse a hub-wins cutover when the local snapshot didn't land.
|
|
328
|
+
describe('migrateSeed — fail-closed on snapshot failure (DDR-102 F1)', () => {
|
|
329
|
+
function makeJournal() {
|
|
330
|
+
const entries = new Map<string, { bodyHash: string }>();
|
|
331
|
+
return {
|
|
332
|
+
get: (slug: string) => entries.get(slug) ?? null,
|
|
333
|
+
record: (slug: string, h: { bodyHash?: string; cssHash?: string }) =>
|
|
334
|
+
entries.set(slug, { bodyHash: h.bodyHash ?? entries.get(slug)?.bodyHash ?? '' }),
|
|
335
|
+
invalidateIfHubChanged: () => {},
|
|
336
|
+
flush: () => {},
|
|
337
|
+
stop: () => {},
|
|
338
|
+
size: () => entries.size,
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
test('hub newer BUT local snapshot null → rebuild doc from local (no overwrite), conflict-local-wins', async () => {
|
|
343
|
+
const p = paths();
|
|
344
|
+
writeFileSync(p.html, '<main>local-work</main>');
|
|
345
|
+
const doc = new Y.Doc();
|
|
346
|
+
doc.transact(() => {
|
|
347
|
+
applyHtmlToDoc(doc, '<main>hub-stale</main>', 'hub');
|
|
348
|
+
doc.getMap('syncMeta').set('bodyEditAt', Date.now() + 60_000); // hub would win
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
const conflicts: Array<{ winner?: string; snapshotFailed?: boolean }> = [];
|
|
352
|
+
const result = await migrateSeed({
|
|
353
|
+
slug: 's',
|
|
354
|
+
doc,
|
|
355
|
+
paths: p,
|
|
356
|
+
journal: makeJournal(),
|
|
357
|
+
snapshot: async () => null, // _history write fails
|
|
358
|
+
onConflict: (info) => {
|
|
359
|
+
conflicts.push(info);
|
|
360
|
+
},
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
// Doc rebuilt from local → projection would write local back (no loss).
|
|
364
|
+
expect(result).toBe('conflict-local-wins');
|
|
365
|
+
expect(doc.getText('html').toString()).toBe('<main>local-work</main>');
|
|
366
|
+
expect(conflicts[0].winner).toBe('local');
|
|
367
|
+
expect(conflicts[0].snapshotFailed).toBe(true);
|
|
368
|
+
});
|
|
369
|
+
});
|
|
@@ -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
|
|
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
|
+
});
|