1
23
24import { diffBlocks } from "./codec.js";
25import type {
26 AutosaveConfig,
27 BlockDto,
28 ConflictInfo,
29 EditorBlockOperation,
30 SaveState,
31} from "./types.js";
32
33const DEFAULT_DEBOUNCE_MS = 800;
34const DEFAULT_MAX_BATCH_OPS = 200;
35
36
40export class BlockConflictError extends Error {
41 constructor(
42 readonly blockId: string,
43 readonly actualVersion: number,
44 ) {
45 super(`409 CONFLICT on block ${blockId} (server version ${actualVersion})`);
46 this.name = "BlockConflictError";
47 }
48}
49
50type BlockLike = Pick<BlockDto, "id" | "type" | "content" | "properties" | "parentId" | "position">;
51
52function replaceById(
53 blocks: readonly BlockDto[],
54 id: string,
55 replacement: BlockDto,
56): BlockDto[] {
57 return blocks.map((b) => (b.id === id ? replacement : b));
58}
59
60function findBlock(blocks: readonly BlockDto[], id: string): BlockDto | undefined {
61 return blocks.find((b) => b.id === id);
62}
63
64
69function buildRebased(fresh: BlockDto, local: BlockDto): BlockDto {
70
71
72
73 return {
74 id: fresh.id,
75 type: local.type,
76 content: local.content,
77 parentId: fresh.parentId,
78 position: fresh.position,
79 version: fresh.version,
80 ...(local.properties !== undefined
81 ? { properties: local.properties }
82 : fresh.properties !== undefined
83 ? { properties: fresh.properties }
84 : {}),
85 };
86}
87
88
93export class AutosaveController {
94 private readonly config: AutosaveConfig;
95 private baseline: BlockDto[];
96 private current: BlockDto[];
97 private timer: ReturnType<typeof globalThis.setTimeout> | null = null;
98 private state: SaveState = "idle";
99 private flushing = false;
100 private flushAgain = false;
101 private disposed = false;
102
103 constructor(
104 initialBlocks: readonly BlockDto[],
105 config: AutosaveConfig,
106 ) {
107 this.config = config;
108 this.baseline = [...initialBlocks];
109 this.current = [...initialBlocks];
110 }
111
112
113 get saveState(): SaveState {
114 return this.state;
115 }
116
117
118 pendingCount(): number {
119 return this.changedIds().length;
120 }
121
122
126 setState(blocks: readonly BlockDto[]): void {
127 if (this.disposed) return;
128 this.current = [...blocks];
129 if (this.pendingCount() === 0) {
130 if (this.timer !== null) {
131 this.clear(this.timer);
132 this.timer = null;
133 }
134 this.setStateSafe("idle");
135 return;
136 }
137 this.setStateSafe("dirty");
138 if (this.timer !== null) return;
139 if (this.flushing) {
140
141
142 this.flushAgain = true;
143 return;
144 }
145 const debounceMs = this.config.debounceMs ?? DEFAULT_DEBOUNCE_MS;
146 this.timer = this.set(
147 () => {
148 this.timer = null;
149 void this.flush();
150 },
151 debounceMs,
152 );
153 }
154
155
156 async flush(): Promise<void> {
157 if (this.disposed) return;
158 if (this.flushing) {
159 this.flushAgain = true;
160 return;
161 }
162 if (this.timer !== null) {
163 this.clear(this.timer);
164 this.timer = null;
165 }
166 this.flushing = true;
167 try {
168 await this.flushOnce();
169 } finally {
170 this.flushing = false;
171 if (this.flushAgain) {
172 this.flushAgain = false;
173 await this.flush();
174 }
175 }
176 }
177
178
179 async dispose(): Promise<void> {
180 if (this.disposed) return;
181 await this.flush();
182 this.disposed = true;
183 if (this.timer !== null) {
184 this.clear(this.timer);
185 this.timer = null;
186 }
187 }
188
189
190
191 private changedIds(): string[] {
192 const base = new Map<string, BlockLike>();
193 for (const b of this.baseline) base.set(b.id, b);
194 const now = new Map<string, BlockLike>();
195 for (const b of this.current) now.set(b.id, b);
196 const changed: string[] = [];
197 for (const [id, b] of now) {
198 const p = base.get(id);
199 if (p === undefined) {
200 changed.push(id);
201 continue;
202 }
203 if (p.position !== b.position || p.parentId !== b.parentId || p.content !== b.content) {
204 changed.push(id);
205 }
206 }
207 for (const id of base.keys()) {
208 if (!now.has(id)) changed.push(id);
209 }
210 return changed;
211 }
212
213 private async flushOnce(): Promise<void> {
214 const baseline = this.baseline;
215 const current = this.current;
216 const diff = diffBlocks(baseline, current);
217 if (diff.operations.length === 0) {
218 this.baseline = [...current];
219 this.setStateSafe("saved");
220 return;
221 }
222
223 this.setStateSafe("saving");
224 const maxBatch = this.config.maxBatchOps ?? DEFAULT_MAX_BATCH_OPS;
225 const chunks = this.chunk(diff.operations, maxBatch);
226
227 for (const chunk of chunks) {
228 try {
229 await this.config.transport.sendBatch(chunk);
230 } catch (error) {
231 if (error instanceof BlockConflictError) {
232 const resolved = await this.rebaseAndRetry(error, chunk);
233 if (!resolved) return;
234 continue;
235 }
236 this.setStateSafe("error");
237 this.config.onError?.(error);
238 return;
239 }
240 }
241
242
243 this.baseline = [...this.current];
244 this.setStateSafe("saved");
245 }
246
247
252 private async rebaseAndRetry(
253 conflict: BlockConflictError,
254 chunk: readonly EditorBlockOperation[],
255 ): Promise<boolean> {
256 let fresh: BlockDto;
257 try {
258 fresh = await this.config.transport.fetchBlock(conflict.blockId);
259 } catch (error) {
260 this.setStateSafe("error");
261 this.config.onError?.(error);
262 return true;
263 }
264
265
266
267
268
269 const local = findBlock(this.current, conflict.blockId);
270 const rebased: BlockDto =
271 local === undefined
272 ? fresh
273 : buildRebased(fresh, local);
274
275 const apply = (blocks: readonly BlockDto[]): BlockDto[] =>
276 replaceById(blocks, conflict.blockId, rebased);
277 this.baseline = apply(this.baseline);
278 this.current = apply(this.current);
279
280
281
282 try {
283 await this.config.transport.sendBatch(chunk);
284 this.baseline = [...this.current];
285 this.setStateSafe("saved");
286 return true;
287 } catch {
288 const info: ConflictInfo = {
289 id: conflict.blockId,
290 actualVersion: fresh.version,
291 };
292 this.setStateSafe("conflict");
293 this.config.onConflict?.(info, true);
294 return false;
295 }
296 }
297
298 private chunk<T>(ops: readonly T[], size: number): T[][] {
299 const out: T[][] = [];
300 for (let i = 0; i < ops.length; i += size) {
301 out.push(ops.slice(i, i + size));
302 }
303 return out;
304 }
305
306 private setStateSafe(state: SaveState): void {
307 if (state === this.state) return;
308 this.state = state;
309 this.config.onStateChange?.(state);
310 }
311
312 private set(fn: () => void, ms: number): ReturnType<typeof globalThis.setTimeout> {
313 const set = this.config.setTimeout ?? globalThis.setTimeout;
314 return set(fn, ms) as ReturnType<typeof globalThis.setTimeout>;
315 }
316
317 private clear(handle: ReturnType<typeof globalThis.setTimeout>): void {
318 const clear = this.config.clearTimeout ?? globalThis.clearTimeout;
319 clear(handle);
320 }
321}
322