1import { describe, expect, it, vi } from "vitest";
2
3import { AutosaveController, BlockConflictError } from "./autosave.js";
4import type { BlocksTransport, BlockDto, SaveState } from "./types.js";
5
6function block(
7 id: string,
8 position: string,
9 content: Record<string, unknown> = { text: id },
10 extra?: { parentId?: string | null; properties?: Record<string, unknown>; version?: number },
11): BlockDto {
12 return {
13 id,
14 type: "paragraph",
15 content,
16 ...(extra?.properties !== undefined ? { properties: extra.properties } : {}),
17 parentId: extra?.parentId ?? null,
18 position,
19 version: extra?.version ?? 1,
20 };
21}
22
23interface FakeTimer {
24 set: (fn: () => void, ms: number) => number;
25 clear: (id: number) => void;
26}
27
28function fakeTimers(): { timers: FakeTimer; advance: (ms: number) => void; now: () => number } {
29 let clock = 0;
30 let nextId = 1;
31 const pending = new Map<number, { at: number; fn: () => void }>();
32 const timers: FakeTimer = {
33 set(fn: () => void, ms: number) {
34 const id = nextId++;
35 pending.set(id, { at: clock + ms, fn });
36 return id;
37 },
38 clear(id: number) {
39 pending.delete(id);
40 },
41 };
42 const advance = (ms: number): void => {
43 const target = clock + ms;
44
45 const sorted = [...pending.entries()].sort((a, b) => a[1].at - b[1].at);
46 for (const [id, entry] of sorted) {
47 if (entry.at <= target) {
48 pending.delete(id);
49 entry.fn();
50 }
51 }
52 clock = target;
53 };
54 return { timers, advance, now: () => clock };
55}
56
57function makeTransport() {
58 const sent: unknown[][] = [];
59 const fetches: string[] = [];
60 let onConflict: ((id: string, version: number) => void) | null = null;
61 let fetchImpl: ((id: string) => BlockDto) | null = null;
62 const setOnConflict = (fn: (id: string, version: number) => void): void => {
63 onConflict = fn;
64 };
65 const setFetchImpl = (fn: (id: string) => BlockDto): void => {
66 fetchImpl = fn;
67 };
68
69
70
71 const transport: BlocksTransport = {
72 async sendBatch(operations: readonly unknown[]) {
73 sent.push([...operations]);
74 if (onConflict !== null) {
75 const up = operations.find(
76 (o) => (o as { op?: string }).op === "update",
77 ) as { id?: string } | undefined;
78 if (up?.id !== undefined) onConflict(up.id, 99);
79 }
80 throw new BlockConflictError("never", 0);
81 },
82 async fetchBlock(id: string) {
83 fetches.push(id);
84 if (fetchImpl === null) {
85 throw new Error("no fetch impl");
86 }
87 return fetchImpl(id);
88 },
89 };
90 return { sent, fetches, setOnConflict, setFetchImpl, transport };
91}
92
93describe("AutosaveController", () => {
94 it("debounces 800 ms and emits one batch with the correct ops", async () => {
95 const { timers, advance } = fakeTimers();
96 const initial = [block("a", "1"), block("b", "2", { text: "B" })];
97 const states: SaveState[] = [];
98 const t = makeTransport();
99 t.transport.sendBatch = async (operations: readonly unknown[]) => {
100 t.sent.push([...operations]);
101 };
102 const controller = new AutosaveController(initial, {
103 pageId: "p1",
104 transport: t.transport,
105 onStateChange: (s) => {
106 states.push(s);
107 },
108 setTimeout: (fn: () => void, ms?: number) => {
109 const id = timers.set(fn, ms ?? 0);
110 return id as unknown as ReturnType<typeof globalThis.setTimeout>;
111 },
112 clearTimeout: (handle) => {
113 timers.clear(handle as unknown as number);
114 },
115 });
116
117 controller.setState([block("a", "1"), block("b", "2", { text: "B2" })]);
118 expect(controller.saveState).toBe("dirty");
119 expect(t.sent).toHaveLength(0);
120
121 advance(799);
122 expect(t.sent).toHaveLength(0);
123
124 advance(1);
125
126 await new Promise((r) => setTimeout(r, 0));
127 expect(t.sent).toHaveLength(1);
128 expect(t.sent[0]).toEqual([{ op: "update", id: "b", patch: { content: { text: "B2" } } }]);
129 expect(controller.saveState).toBe("saved");
130 expect(states).toEqual(["dirty", "saving", "saved"]);
131 });
132
133 it("no debounce re-arm when state unchanged", async () => {
134 const { timers, advance } = fakeTimers();
135 const initial = [block("a", "1")];
136 const t = makeTransport();
137 t.transport.sendBatch = async (ops: readonly unknown[]) => {
138 t.sent.push([...ops]);
139 };
140 const controller = new AutosaveController(initial, {
141 pageId: "p1",
142 transport: t.transport,
143 setTimeout: (fn: () => void, ms?: number) => {
144 const id = timers.set(fn, ms ?? 0);
145 return id as unknown as ReturnType<typeof globalThis.setTimeout>;
146 },
147 clearTimeout: (handle) => {
148 timers.clear(handle as unknown as number);
149 },
150 });
151 controller.setState(initial);
152 expect(controller.saveState).toBe("idle");
153 advance(10_000);
154 expect(t.sent).toHaveLength(0);
155 });
156
157 it("resolves a 409 by rebasing (our content, server placement) and retrying once", async () => {
158 const t = makeTransport();
159 let conflictCount = 0;
160 t.transport.sendBatch = async (operations: readonly unknown[]) => {
161 t.sent.push([...operations]);
162 conflictCount += 1;
163 if (conflictCount === 1) {
164 throw new BlockConflictError("b", 99);
165 }
166
167 };
168 t.setFetchImpl((id) => block(id, "SERVER_POS", { text: "remote" }, { version: 99 }));
169 const states: SaveState[] = [];
170 const controller = new AutosaveController([block("a", "1"), block("b", "2", { text: "B" })], {
171 pageId: "p1",
172 transport: t.transport,
173 onStateChange: (s) => states.push(s),
174 });
175 controller.setState([block("a", "1"), block("b", "2", { text: "B-mine" })]);
176 await controller.flush();
177
178 expect(t.sent).toHaveLength(2);
179
180 expect(controller.saveState).toBe("saved");
181
182 expect(states).toContain("saving");
183 expect(states).toContain("saved");
184 });
185
186 it("surfaces onConflict after a second 409 (never silently overwrites)", async () => {
187 const t = makeTransport();
188 t.transport.sendBatch = async (operations: readonly unknown[]) => {
189 t.sent.push([...operations]);
190 throw new BlockConflictError("b", 99);
191 };
192 t.setFetchImpl((id) => block(id, "SP", { text: "remote" }, { version: 99 }));
193 const conflicts: string[] = [];
194 const controller = new AutosaveController([block("b", "2", { text: "B" })], {
195 pageId: "p1",
196 transport: t.transport,
197 onConflict: (info) => {
198 conflicts.push(info.id);
199 },
200 });
201 controller.setState([block("b", "2", { text: "B2" })]);
202 await controller.flush();
203 expect(conflicts).toEqual(["b"]);
204 expect(controller.saveState).toBe("conflict");
205 expect(t.sent).toHaveLength(2);
206 });
207
208 it("chunks a large flush into 200-op batches", async () => {
209 const t = makeTransport();
210 let n = 0;
211 t.transport.sendBatch = async (ops: readonly unknown[]) => {
212 t.sent.push([...ops]);
213 n += ops.length;
214 };
215 const initial: BlockDto[] = [];
216 const next: BlockDto[] = [];
217 for (let i = 0; i < 450; i += 1) {
218 next.push(block(`n${i}`, String(i), { text: `x${i}` }));
219 }
220 const controller = new AutosaveController(initial, {
221 pageId: "p1",
222 transport: t.transport,
223 maxBatchOps: 200,
224 });
225 controller.setState(next);
226 await controller.flush();
227 expect(t.sent.map((c) => c.length)).toEqual([200, 200, 50]);
228 expect(n).toBe(450);
229 });
230});
231