1
11
12import * as fc from "fast-check";
13import { describe, expect, it } from "vitest";
14import * as Y from "yjs";
15
16import {
17 BLOCK_CONTENT_SCHEMAS,
18 blockOperationSchema,
19 blockTypeSchema,
20 beforePosition,
21 comparePositions,
22 generatePosition,
23 type BlockOperation,
24 type BlockType,
25 type Position,
26} from "@factory/shared";
27
28import { diffBlocks } from "@factory/editor/codec";
29
30import {
31 applyBlockOperations,
32 applyBlockOperationsToYDoc,
33 base64ToBytes,
34 blockToYElement,
35 blocksToYDoc,
36 bytesToBase64,
37 decodeBase64Yjs,
38 diffBlockSets,
39 encodeDocAsUpdate,
40 encodeDocStateVector,
41 jsonEqual,
42 namedChild,
43 yDocToBlockList,
44 yDocToBlocks,
45 type BlockLike,
46} from "./codec.js";
47import { BLOCK_Y_MAPPINGS, mappingForType } from "./mapping.js";
48
49
50
51type Family = "text" | "code" | "bookmark" | "table" | "media" | "embed" | "structural" | "ref";
52
53const FAMILY_TYPES: ReadonlyMap<Family, BlockType[]> = new Map([
54 [
55 "text",
56 ["paragraph", "heading_1", "heading_2", "heading_3", "quote", "callout", "bulleted_list", "numbered_list", "todo", "toggle", "equation"],
57 ],
58 ["code", ["code"]],
59 ["bookmark", ["bookmark"]],
60 ["table", ["table"]],
61 ["media", ["image", "video", "audio", "file"]],
62 ["embed", ["embed"]],
63 ["structural", ["column", "column_list", "divider", "spacer"]],
64 ["ref", ["database", "linked_database", "chart", "form", "synced_block", "child_page", "child_database"]],
65]);
66
67const ALL_TYPES = blockTypeSchema.options;
68
69const TEXT_SAMPLES = [
70 "",
71 "hello",
72 "line one\nline two",
73 "unicode: café ☕ émojis 日本語",
74 "spaces double",
75];
76
77function familyFor(type: BlockType): Family {
78 for (const [kind, types] of FAMILY_TYPES) {
79 if (types.includes(type)) return kind;
80 }
81 throw new Error(`no family for ${type}`);
82}
83
84
85function mulberry32(seed: number): () => number {
86 let a = seed >>> 0;
87 return () => {
88 a = (a + 0x6d2b79f5) >>> 0;
89 let t = a;
90 t = Math.imul(t ^ (t >>> 15), t | 1);
91 t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
92 return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
93 };
94}
95
96
97function genContent(rng: () => number, type: BlockType): Record<string, unknown> {
98 const pick = <T>(arr: readonly T[]): T => arr[Math.floor(rng() * arr.length)] as T;
99 const kind = familyFor(type);
100 switch (kind) {
101 case "text":
102 return { text: pick(TEXT_SAMPLES) };
103 case "embed":
104
105
106 return { url: "https://example.com/embed" };
107 case "code":
108 return {
109 text: pick(TEXT_SAMPLES),
110 ...(rng() < 0.5 ? { language: pick(["ts", "python", "rust"]) } : {}),
111 };
112 case "bookmark":
113 return {
114 url: "https://example.com/page",
115 ...(rng() < 0.5 ? { title: "A bookmark" } : {}),
116 };
117 case "table": {
118 const rows = Array.from({ length: 1 + Math.floor(rng() * 3) }, () =>
119 Array.from({ length: 1 + Math.floor(rng() * 3) }, () => pick(TEXT_SAMPLES)),
120 );
121 return { rows, ...(rng() < 0.3 ? { width: 800 } : {}) };
122 }
123 case "media":
124 return {
125 fileId: `fil_${Math.floor(rng() * 1e6).toString(16)}`,
126 ...(rng() < 0.5 ? { caption: "a caption" } : {}),
127 ...(rng() < 0.3 ? { width: 1200 } : {}),
128 };
129 case "structural": {
130 switch (type) {
131 case "column":
132 return rng() < 0.5 ? { width: 300 } : {};
133 case "column_list":
134 return { direction: rng() < 0.5 ? "row" : "column" };
135 case "divider":
136 case "spacer":
137 return rng() < 0.5 ? { height: 24 } : {};
138 }
139 return {};
140 }
141 case "ref": {
142 const id = (prefix: string) => `${prefix}_${Math.floor(rng() * 1e6).toString(16)}`;
143 switch (type) {
144 case "database":
145 return { databaseId: id("db") };
146 case "child_database":
147 return { databaseId: id("db") };
148 case "linked_database":
149 return rng() < 0.5 ? { databaseId: id("db"), viewId: id("view") } : { databaseId: id("db") };
150 case "chart":
151 return { chartId: id("chart") };
152 case "form":
153 return { formId: id("form") };
154 case "synced_block":
155 return { sourceBlockId: id("blk") };
156 case "child_page":
157 return { pageId: id("page") };
158 }
159 return {};
160 }
161 }
162}
163
164
165function genForest(rng: () => number): BlockLike[] {
166 const n = 1 + Math.floor(rng() * 8);
167 const blocks: BlockLike[] = [];
168 let cursor = "";
169 for (let i = 0; i < n; i += 1) {
170 cursor = generatePosition(cursor === "" ? undefined : cursor);
171 const type = ALL_TYPES[Math.floor(rng() * ALL_TYPES.length)] as BlockType;
172 blocks.push({
173 id: `blk_${i}_${Math.floor(rng() * 1e9).toString(16)}`,
174 type,
175 content: genContent(rng, type),
176 parentId: null,
177 position: cursor,
178 ...(rng() < 0.3
179 ? { properties: { color: pickColor(rng), checked: rng() < 0.5 } }
180 : {}),
181 });
182 }
183 return blocks;
184}
185
186function pickColor(rng: () => number): string {
187 const colors = ["red", "blue", "green"];
188 return colors[Math.floor(rng() * colors.length)] as string;
189}
190
191function normalizeBlock(b: BlockLike) {
192 return {
193 id: b.id,
194 type: b.type,
195 content: sortKeys(b.content),
196 parentId: b.parentId,
197 position: b.position,
198 properties: b.properties !== undefined ? sortKeys(b.properties) : undefined,
199 };
200}
201
202function sortKeys(value: unknown): unknown {
203 if (Array.isArray(value)) return value.map(sortKeys);
204 if (typeof value === "object" && value !== null) {
205 return Object.fromEntries(
206 Object.entries(value as Record<string, unknown>)
207 .sort(([a], [b]) => a.localeCompare(b))
208 .map(([k, v]) => [k, sortKeys(v)]),
209 );
210 }
211 return value;
212}
213
214
225function shapeOf(blocks: readonly BlockLike[]): Record<string, { type: string; content: unknown; properties: unknown }> {
226 const byParent = new Map<string | null, BlockLike[]>();
227 for (const b of blocks) {
228 const list = byParent.get(b.parentId) ?? [];
229 list.push(b);
230 byParent.set(b.parentId, list);
231 }
232 const out: Record<string, { type: string; content: unknown; properties: unknown }> = {};
233 for (const [parentId, list] of byParent) {
234 list.sort((a, b) => comparePositions(a.position, b.position));
235 list.forEach((b, rank) => {
236 out[`${parentId ?? "ROOT"}::${rank}`] = {
237 type: b.type,
238 content: sortKeys(b.content),
239 properties: b.properties !== undefined ? sortKeys(b.properties) : undefined,
240 };
241 });
242 }
243 return out;
244}
245
246const mk = (
247 id: string,
248 pos: Position,
249 type: BlockType = "paragraph",
250 content: Record<string, unknown> = { text: `T${id}` },
251 extra: Partial<BlockLike> = {},
252): BlockLike => ({
253 id,
254 type,
255 content,
256 parentId: null,
257 position: pos,
258 ...extra,
259});
260
261
262
263describe("blocksToYDoc / yDocToBlockList round-trip", () => {
264 it("is lossless for 200 random forests (property)", () => {
265 fc.assert(
266 fc.property(fc.integer({ min: 1, max: 4096 }), (seed) => {
267 const rng = mulberry32(seed);
268 const forest = genForest(rng);
269 const doc = blocksToYDoc(forest, { pageId: "page_test" });
270 try {
271 const back = yDocToBlockList(doc);
272 expect(back.map(normalizeBlock)).toEqual(forest.map(normalizeBlock));
273 } finally {
274 doc.destroy();
275 }
276 }),
277 { numRuns: 200, seed: 0xc0ffee },
278 );
279 });
280
281 it("reads fragment order (the decode-time truth) and stamps positions", () => {
282 const a = mk("a", generatePosition());
283 const b = mk("b", generatePosition(a.position));
284 const doc = blocksToYDoc([a, b]);
285 try {
286 const list = yDocToBlockList(doc);
287 expect(list.map((x) => x.id)).toEqual(["a", "b"]);
288 expect(list.map((x) => x.position)).toEqual([a.position, b.position]);
289 } finally {
290 doc.destroy();
291 }
292 });
293
294 it("throws on duplicate ids and on a missing parent", () => {
295 const a = mk("a", "000");
296 expect(() => blocksToYDoc([a, { ...a }])).toThrow(/duplicate/);
297 const orphan = mk("o", "001", "paragraph", { text: "O" }, { parentId: "ghost" });
298 expect(() => blocksToYDoc([a, orphan])).toThrow(/parent/);
299 });
300});
301
302
303
304describe("yDocToBlocks (the persister's diff)", () => {
305 it("emits [] for an unchanged doc (idempotency)", () => {
306 const blocks = [mk("a", generatePosition()), mk("b", generatePosition("0001"))];
307 const doc = blocksToYDoc(blocks);
308 try {
309 expect(yDocToBlocks(doc, blocks)).toEqual([]);
310 } finally {
311 doc.destroy();
312 }
313 });
314
315 it("emits a single content-only update for a text change (minimal patch)", () => {
316 const prev = [mk("a", generatePosition())];
317 const doc = blocksToYDoc(prev);
318 try {
319 const frag = doc.getXmlFragment("blocks");
320 const el = frag.get(0) as Y.XmlElement;
321 (namedChild(el, "text") as Y.XmlText).insert(0, "edited");
322 const ops = yDocToBlocks(doc, prev);
323 expect(ops).toEqual([{ op: "update", id: "a", patch: { content: { text: "editedTa" } } }]);
324
325 const patch = (ops[0] as { patch: Record<string, unknown> }).patch;
326 expect(patch.position).toBeUndefined();
327 expect(patch.properties).toBeUndefined();
328 } finally {
329 doc.destroy();
330 }
331 });
332
333 it("emits a property-only update without content", () => {
334 const prev: BlockLike[] = [
335 mk("a", generatePosition(), "paragraph", { text: "x" }, { properties: { color: "red" } }),
336 ];
337 const doc = blocksToYDoc(prev);
338 try {
339 const frag = doc.getXmlFragment("blocks");
340 const el = frag.get(0) as Y.XmlElement;
341 el.setAttribute("p.color", JSON.stringify("green"));
342 const ops = yDocToBlocks(doc, prev);
343 expect(ops).toEqual([{ op: "update", id: "a", patch: { properties: { color: "green" } } }]);
344 } finally {
345 doc.destroy();
346 }
347 });
348
349 it("emits a one-anchor move (the wire contract) for a reorder", () => {
350 const p1 = generatePosition();
351 const p2 = generatePosition(p1);
352 const p3 = generatePosition(p2);
353 const prev = [mk("a", p1), mk("b", p2), mk("c", p3)];
354 const beforeP1 = beforePosition(p1);
355 const doc = blocksToYDoc(prev);
356 try {
357
358
359
360
361
362
363
364 const frag = doc.getXmlFragment("blocks");
365 const cBlock = prev[2]!;
366 const fresh = blockToYElement({
367 ...cBlock,
368 position: beforeP1,
369 });
370 frag.delete(2, 1);
371 frag.insert(0, [fresh]);
372 const ops = yDocToBlocks(doc, prev);
373
374
375
376
377
378
379 expect(ops.filter((o) => o.op === "move")).toEqual([{ op: "move", id: "c", after: "a" }]);
380
381 expect(ops.filter((o) => o.op === "update")).toEqual([]);
382 } finally {
383 doc.destroy();
384 }
385 });
386
387 it("emits a delete for a removed block (wire order: deletes first)", () => {
388 const p1 = generatePosition();
389 const p2 = generatePosition(p1);
390 const prev = [mk("a", p1), mk("b", p2)];
391 const doc = blocksToYDoc(prev);
392 try {
393 const frag = doc.getXmlFragment("blocks");
394 frag.delete(1, 1);
395 const ops = yDocToBlocks(doc, prev);
396 expect(ops).toEqual([{ op: "delete", id: "b" }]);
397 } finally {
398 doc.destroy();
399 }
400 });
401});
402
403
404
405describe("applyBlockOperations (plain) / applyBlockOperationsToYDoc (CRDT)", () => {
406 it("applies delete → update → move → insert in wire order", () => {
407 const p1 = generatePosition();
408 const p2 = generatePosition(p1);
409 const p3 = generatePosition(p2);
410 const blocks = [mk("a", p1), mk("b", p2), mk("c", p3)];
411 const ops: BlockOperation[] = [
412 { op: "delete", id: "c" },
413 { op: "update", id: "b", patch: { content: { text: "new b" } } },
414 { op: "move", id: "b", after: "a" },
415 {
416 op: "insert",
417 block: { type: "paragraph", content: { text: "z" }, position: generatePosition() },
418 after: "b",
419 },
420 ];
421 const plain = applyBlockOperations(blocks, ops);
422
423
424
425
426
427
428
429 expect(plain.map((b) => b.id)).toEqual([plain[0]?.id ?? "", "b", "a"]);
430 expect(plain[0]?.content).toEqual({ text: "z" });
431 expect(plain.find((x) => x.id === "b")?.content).toEqual({ text: "new b" });
432
433
434
435 const byPos = [...plain].sort((x, y) => comparePositions(x.position, y.position));
436 expect(byPos.map((b) => b.id)).toEqual(plain.map((b) => b.id));
437
438 expect(diffBlockSets(plain, plain)).toEqual([]);
439 });
440
441 it("the two appliers agree (Y.Doc vs plain block list) on a nested tree", () => {
442 const p1 = generatePosition();
443 const p2 = generatePosition(p1);
444 const p3 = generatePosition(p2);
445 const p4 = generatePosition(p3);
446 const p5 = generatePosition(p4);
447 const blocks: BlockLike[] = [
448 mk("a", p1),
449 mk("col", p2, "column", { width: 300 }),
450 mk("b", p3),
451 mk("c1", p4, "paragraph", { text: "C1" }, { parentId: "col" }),
452 mk("c2", p5, "paragraph", { text: "C2" }, { parentId: "col" }),
453 ];
454 const ops: BlockOperation[] = [
455 { op: "update", id: "a", patch: { content: { text: "edited" } } },
456 { op: "update", id: "b", patch: { properties: { color: "red" } } },
457 { op: "move", id: "c2", before: "c1" },
458 {
459 op: "insert",
460
461
462 block: { type: "paragraph", content: { text: "n" }, position: p3 },
463 before: "b",
464 },
465 { op: "move", id: "c1", before: "b" },
466 ];
467 const doc = blocksToYDoc(blocks);
468 try {
469 applyBlockOperationsToYDoc(doc, ops);
470 const fromDoc = yDocToBlockList(doc);
471 const fromPlain = applyBlockOperations(blocks, ops);
472
473
474
475
476 expect(shapeOf(fromDoc)).toEqual(shapeOf(fromPlain));
477
478 expect(fromDoc.find((b) => b.id === "c1")?.parentId).toBeNull();
479 } finally {
480 doc.destroy();
481 }
482 });
483});
484
485
486
487describe("two codecs agree (the load-bearing contract)", () => {
488 function toEditorBlocks(blocks: BlockLike[]) {
489 return blocks.map((b) => ({ ...b, version: 1 }));
490 }
491
492
494 function normOp(op: Record<string, unknown>): unknown {
495 if (op.op === "insert") {
496 const block = op.block as Record<string, unknown>;
497 return {
498 op: "insert",
499 block: {
500 type: block.type,
501 content: sortKeys(block.content),
502 properties: block.properties !== undefined ? sortKeys(block.properties) : undefined,
503 parentId: block.parentId ?? null,
504 position: block.position,
505 },
506 };
507 }
508 if (op.op === "update") {
509 const patch = op.patch as Record<string, unknown>;
510 return {
511 op: "update",
512 id: op.id,
513 patch: {
514 ...(patch.content !== undefined ? { content: sortKeys(patch.content) } : {}),
515 ...(patch.properties !== undefined ? { properties: sortKeys(patch.properties) } : {}),
516 ...(patch.position !== undefined ? { position: patch.position } : {}),
517 },
518 };
519 }
520 if (op.op === "move") {
521 return {
522 op: "move",
523 id: op.id,
524 anchor: (op.before as string | undefined) ?? (op.after as string | undefined) ?? null,
525 };
526 }
527 return { op: "delete", id: op.id };
528 }
529
530 it("diffBlocks (editor) and diffBlockSets (collaboration) emit identical wire ops", () => {
531 const p1 = generatePosition();
532 const p2 = generatePosition(p1);
533 const p3 = generatePosition(p2);
534 const p4 = generatePosition(p3);
535 const p5 = generatePosition(p4);
536 const prev: BlockLike[] = [
537 mk("a", p1),
538 mk("col", p2, "column", { width: 300 }),
539 mk("b", p3),
540 mk("c1", p4, "paragraph", { text: "C1" }, { parentId: "col" }),
541 mk("c2", p5, "paragraph", { text: "C2" }, { parentId: "col" }),
542 ];
543
544
545 const q1 = generatePosition();
546 const q2 = generatePosition(q1);
547 const q3 = generatePosition(q2);
548 const q4 = generatePosition(q3);
549 const q5 = generatePosition(q4);
550 const next: BlockLike[] = [
551 { id: "a", type: "paragraph", content: { text: "edited" }, parentId: null, position: q1 },
552 mk("col", q2, "column", { width: 300 }),
553 mk("d", q3),
554 mk("b", q4),
555 mk("c1", q5, "paragraph", { text: "C1" }),
556 ];
557
558 const editorOps = diffBlocks(toEditorBlocks(prev), toEditorBlocks(next)).operations;
559 const collabOps = diffBlockSets(prev, next);
560
561
562
563 for (const op of [...editorOps, ...collabOps]) {
564 expect(blockOperationSchema.safeParse(op).success).toBe(true);
565 }
566 expect(collabOps.map(normOp)).toEqual(editorOps.map(normOp));
567
568
569
570
571
572
573
574
575
576
577
578
579 const applied = applyBlockOperations(prev, editorOps);
580 expect(shapeOf(applied)).toEqual(shapeOf(next));
581 });
582
583 it("unchanged sets produce zero ops in both codecs", () => {
584 const prev = [mk("a", generatePosition()), mk("b", generatePosition("0001"))];
585 expect(diffBlocks(toEditorBlocks(prev), toEditorBlocks(prev)).operations).toEqual([]);
586 expect(diffBlockSets(prev, prev)).toEqual([]);
587 });
588
589 it("diff → apply round-trips the next state (100 random mutations, property)", () => {
590 fc.assert(
591 fc.property(fc.integer({ min: 1, max: 4096 }), (seed) => {
592 const rng = mulberry32(seed);
593 const base = genForest(rng);
594 if (base.length < 2) return;
595
596
597 const next = base.map((b) => ({ ...b, content: { ...b.content } as BlockLike["content"] }));
598 const target = next[Math.floor(rng() * next.length)] as BlockLike;
599 const mode = Math.floor(rng() * 5);
600 if (mode === 0) {
601 next.splice(next.indexOf(target), 1);
602 } else if (mode === 1) {
603
604
605
606
607
608
609
610 if (mappingForType(target.type).textSlots.includes("text")) {
611 target.content = {
612 ...target.content,
613 text: `edited ${Math.floor(rng() * 1e6)}`,
614 } as BlockLike["content"];
615 } else {
616 target.content = genContent(rng, target.type) as BlockLike["content"];
617 }
618 } else if (mode === 2) {
619 target.properties = { ...(target.properties ?? {}), color: "blue" };
620 } else {
621
622
623
624 const other = next[Math.floor(rng() * next.length)] as BlockLike;
625 if (other !== target) {
626 const tp = target.position;
627 target.position = other.position;
628 other.position = tp;
629 if (rng() < 0.5) target.parentId = other.parentId;
630 }
631 }
632 const ops = diffBlockSets(base, next);
633 for (const op of ops) {
634 expect(blockOperationSchema.safeParse(op).success).toBe(true);
635 }
636
637
638
639
640
641
642
643
644 const plain = applyBlockOperations(base, ops);
645 expect(shapeOf(plain)).toEqual(shapeOf(next));
646 const doc = blocksToYDoc(base);
647 try {
648 applyBlockOperationsToYDoc(doc, ops);
649 expect(shapeOf(yDocToBlockList(doc))).toEqual(shapeOf(next));
650 } finally {
651 doc.destroy();
652 }
653 }),
654 { numRuns: 100, seed: 0xbeef },
655 );
656 });
657});
658
659
660
661describe("base64 Yjs encoding", () => {
662 it("round-trips a full doc update (encodeDocAsUpdate / decodeBase64Yjs)", () => {
663 const doc = blocksToYDoc([mk("a", generatePosition(), "paragraph", { text: "state" })]);
664 try {
665 const b64 = encodeDocAsUpdate(doc);
666 const bytes = decodeBase64Yjs(b64);
667 const fresh = new Y.Doc();
668 try {
669 Y.applyUpdate(fresh, bytes);
670 expect(yDocToBlockList(fresh).map((b) => b.id)).toEqual(["a"]);
671 } finally {
672 fresh.destroy();
673 }
674 } finally {
675 doc.destroy();
676 }
677 });
678
679 it("round-trips arbitrary byte arrays (property) + state vector + empty", () => {
680 const doc = blocksToYDoc([mk("a", generatePosition(), "paragraph", { text: "x" })]);
681 try {
682 const sv = encodeDocStateVector(doc);
683 expect(base64ToBytes(sv).length).toBeGreaterThan(0);
684 expect(Array.from(base64ToBytes(bytesToBase64(new Uint8Array(0))))).toEqual([]);
685 } finally {
686 doc.destroy();
687 }
688 fc.assert(
689 fc.property(fc.uint8Array({ maxLength: 1000 }), (bytes) => {
690 expect(Array.from(base64ToBytes(bytesToBase64(bytes)))).toEqual(Array.from(bytes));
691 }),
692 { numRuns: 50, seed: 7 },
693 );
694 });
695});
696
697
698
699describe("mapping structural gate (BLOCK_Y_MAPPINGS vs the shared schemas)", () => {
700 it("covers exactly the shared block types", () => {
701 expect(Object.keys(BLOCK_Y_MAPPINGS).sort()).toEqual([...ALL_TYPES].sort());
702 });
703
704 it("textSlots + rows + attrKeys exactly partition each type's content keys", () => {
705 for (const type of ALL_TYPES) {
706 const mapping = mappingForType(type);
707 const schemaShape = Object.keys(
708 (BLOCK_CONTENT_SCHEMAS[type] as { shape?: Record<string, unknown> }).shape ?? {},
709 );
710 const mapped = [
711 ...mapping.textSlots,
712 ...(mapping.hasRows ? ["rows"] : []),
713 ...mapping.attrKeys,
714 ].sort();
715 expect(mapped).toEqual([...schemaShape].sort());
716 }
717 });
718
719 it("throws for an unknown block type", () => {
720 expect(() => mappingForType("not_a_type")).toThrow(/not in the Yjs mapping/);
721 });
722
723 it("jsonEqual is a deep deterministic equality", () => {
724 expect(jsonEqual({ a: [1, { b: 2 }] }, { a: [1, { b: 2 }] })).toBe(true);
725 expect(jsonEqual({ a: 1 }, { a: 2 })).toBe(false);
726 expect(jsonEqual({ a: undefined }, {})).toBe(false);
727 });
728});
729