web based infinite canvas
1import Dexie from "dexie";
2import {
3 type BindingRecord,
4 BindingRecord as BindingOps,
5 createId,
6 type Document,
7 type PageRecord,
8 PageRecord as PageOps,
9 type ShapeRecord,
10 ShapeRecord as ShapeOps,
11} from "../model";
12import type { BoardMeta, DocRepo, Timestamp } from "./repo";
13import type { BoardInspectorData, BoardStats, MigrationInfo, SchemaInfo } from "./stats";
14import { BoardStatsOps, getPendingMigrations } from "./stats";
15
16export type PageRow = PageRecord & { boardId: string; updatedAt: Timestamp };
17
18export type ShapeRow = ShapeRecord & { boardId: string; updatedAt: Timestamp };
19
20export type BindingRow = BindingRecord & { boardId: string; updatedAt: Timestamp };
21
22export type MetaRow = { key: string; value: unknown };
23
24export type MigrationRow = { id: string; appliedAt: Timestamp };
25
26export type DocOrder = {
27 pageIds: string[];
28 /** Optional per-page shape order overrides */
29 shapeOrder?: Record<string, string[]>;
30};
31
32export type DocPatch = {
33 upserts?: { pages?: PageRecord[]; shapes?: ShapeRecord[]; bindings?: BindingRecord[] };
34 deletes?: { pageIds?: string[]; shapeIds?: string[]; bindingIds?: string[] };
35 order?: Partial<DocOrder>;
36};
37
38export type LoadedDoc = {
39 pages: Record<string, PageRecord>;
40 shapes: Record<string, ShapeRecord>;
41 bindings: Record<string, BindingRecord>;
42 order: DocOrder;
43};
44
45export type BoardExport = { board: BoardMeta; doc: Document; order: DocOrder };
46
47export type PersistenceSink = { enqueueDocPatch(boardId: string, patch: DocPatch): void; flush(): Promise<void> };
48
49export type PersistenceSinkOptions = { debounceMs?: number };
50
51export interface PersistentDocRepo extends DocRepo {
52 loadDoc(boardId: string): Promise<LoadedDoc>;
53 applyDocPatch(boardId: string, patch: DocPatch): Promise<void>;
54 exportBoard(boardId: string): Promise<BoardExport>;
55 importBoard(snapshot: BoardExport): Promise<string>;
56}
57
58export type WebRepoOptions = { now?: () => Timestamp };
59
60type DexieLike = Pick<Dexie, "table" | "transaction">;
61
62const DEFAULT_BOARD_NAME = "Untitled Board";
63
64const PAGE_ORDER_META_PREFIX = "page-order:";
65const SHAPE_ORDER_META_PREFIX = "shape-order:";
66
67const pageOrderKey = (boardId: string) => `${PAGE_ORDER_META_PREFIX}${boardId}`;
68const shapeOrderKey = (boardId: string) => `${SHAPE_ORDER_META_PREFIX}${boardId}`;
69
70/**
71 * Create a Dexie-backed persistent DocRepo used by the web app.
72 */
73export function createWebDocRepo(database: DexieLike, options?: WebRepoOptions): PersistentDocRepo {
74 const now = () => options?.now?.() ?? Date.now();
75
76 const boards = () => database.table<BoardMeta>("boards");
77 const pages = () => database.table<PageRow>("pages");
78 const shapes = () => database.table<ShapeRow>("shapes");
79 const bindings = () => database.table<BindingRow>("bindings");
80 const meta = () => database.table<MetaRow>("meta");
81
82 async function listBoards(): Promise<BoardMeta[]> {
83 return boards().orderBy("updatedAt").reverse().toArray();
84 }
85
86 async function createBoard(name: string): Promise<string> {
87 const boardId = createId("board");
88 const timestamp = now();
89 const page = PageOps.create("Page 1");
90 const pageRow: PageRow = { ...page, boardId, updatedAt: timestamp };
91
92 await database.transaction("rw", boards(), pages(), meta(), async () => {
93 await boards().add({ id: boardId, name: name || DEFAULT_BOARD_NAME, createdAt: timestamp, updatedAt: timestamp });
94 await pages().add(pageRow);
95 await meta().put({ key: pageOrderKey(boardId), value: [page.id] });
96 await meta().put({ key: shapeOrderKey(boardId), value: { [page.id]: [...page.shapeIds] } });
97 });
98
99 return boardId;
100 }
101
102 async function renameBoard(boardId: string, name: string): Promise<void> {
103 await boards().update(boardId, { name, updatedAt: now() });
104 }
105
106 async function deleteBoard(boardId: string): Promise<void> {
107 await database.transaction("rw", [boards(), pages(), shapes(), bindings(), meta()], async () => {
108 const pageKeys = (await pages().where("boardId").equals(boardId).toArray()).map((row) =>
109 [row.boardId, row.id] as [string, string]
110 );
111 const shapeKeys = (await shapes().where("boardId").equals(boardId).toArray()).map((row) =>
112 [row.boardId, row.id] as [string, string]
113 );
114 const bindingKeys = (await bindings().where("boardId").equals(boardId).toArray()).map((row) =>
115 [row.boardId, row.id] as [string, string]
116 );
117
118 await boards().delete(boardId);
119 if (pageKeys.length > 0) await pages().bulkDelete(pageKeys);
120 if (shapeKeys.length > 0) await shapes().bulkDelete(shapeKeys);
121 if (bindingKeys.length > 0) await bindings().bulkDelete(bindingKeys);
122 await meta().delete(pageOrderKey(boardId));
123 await meta().delete(shapeOrderKey(boardId));
124 });
125 }
126
127 async function loadDoc(boardId: string): Promise<LoadedDoc> {
128 const pageRows = await pages().where("boardId").equals(boardId).toArray();
129 const [shapeRows, bindingRows, order] = await Promise.all([
130 shapes().where("boardId").equals(boardId).toArray(),
131 bindings().where("boardId").equals(boardId).toArray(),
132 loadOrder(boardId, pageRows),
133 ]);
134
135 const docPages: Record<string, PageRecord> = {};
136 for (const row of pageRows) {
137 docPages[row.id] = clonePageRow(row);
138 }
139
140 const docShapes: Record<string, ShapeRecord> = {};
141 for (const row of shapeRows) {
142 docShapes[row.id] = cloneShapeRow(row);
143 }
144
145 const docBindings: Record<string, BindingRecord> = {};
146 for (const row of bindingRows) {
147 docBindings[row.id] = cloneBindingRow(row);
148 }
149
150 return { pages: docPages, shapes: docShapes, bindings: docBindings, order };
151 }
152
153 async function loadOrder(boardId: string, fallbackPages: PageRow[]): Promise<DocOrder> {
154 const pageOrderRow = await meta().get(pageOrderKey(boardId));
155 const shapeOrderRow = await meta().get(shapeOrderKey(boardId));
156 const fallbackPageIds = fallbackPages.map((row) => row.id);
157 const fallbackShapeOrder = shapeOrderFromPageRows(fallbackPages);
158
159 return {
160 pageIds: (pageOrderRow?.value as string[] | undefined) ?? fallbackPageIds,
161 shapeOrder: (shapeOrderRow?.value as Record<string, string[]> | undefined) ?? fallbackShapeOrder,
162 };
163 }
164
165 async function applyDocPatch(boardId: string, patch: DocPatch): Promise<void> {
166 const timestamp = now();
167
168 await database.transaction("rw", [boards(), pages(), shapes(), bindings(), meta()], async () => {
169 const pageDeleteKeys = patch.deletes?.pageIds?.map((id) => [boardId, id] as [string, string]) ?? [];
170 const shapeDeleteKeys = patch.deletes?.shapeIds?.map((id) => [boardId, id] as [string, string]) ?? [];
171 const bindingDeleteKeys = patch.deletes?.bindingIds?.map((id) => [boardId, id] as [string, string]) ?? [];
172
173 if (pageDeleteKeys.length > 0) await pages().bulkDelete(pageDeleteKeys);
174 if (shapeDeleteKeys.length > 0) await shapes().bulkDelete(shapeDeleteKeys);
175 if (bindingDeleteKeys.length > 0) await bindings().bulkDelete(bindingDeleteKeys);
176
177 const upsertPages =
178 patch.upserts?.pages?.map((page) => ({ ...PageOps.clone(page), boardId, updatedAt: timestamp })) ?? [];
179 const upsertShapes =
180 patch.upserts?.shapes?.map((shape) => ({ ...ShapeOps.clone(shape), boardId, updatedAt: timestamp })) ?? [];
181 const upsertBindings =
182 patch.upserts?.bindings?.map((binding) => ({ ...BindingOps.clone(binding), boardId, updatedAt: timestamp }))
183 ?? [];
184
185 if (upsertPages.length > 0) await pages().bulkPut(upsertPages);
186 if (upsertShapes.length > 0) await shapes().bulkPut(upsertShapes);
187 if (upsertBindings.length > 0) await bindings().bulkPut(upsertBindings);
188
189 if (patch.order?.pageIds) {
190 await meta().put({ key: pageOrderKey(boardId), value: [...patch.order.pageIds] });
191 }
192
193 if (patch.order?.shapeOrder) {
194 await meta().put({ key: shapeOrderKey(boardId), value: patch.order.shapeOrder });
195 }
196
197 await boards().update(boardId, { updatedAt: timestamp });
198 });
199 }
200
201 async function exportBoard(boardId: string): Promise<BoardExport> {
202 const board = await boards().get(boardId);
203 if (!board) {
204 throw new Error(`Board ${boardId} not found`);
205 }
206
207 const { pages, shapes, bindings, order } = await loadDoc(boardId);
208 const doc: Document = { pages, shapes, bindings };
209 return { board, doc, order };
210 }
211
212 async function importBoard(snapshot: BoardExport): Promise<string> {
213 const boardId = snapshot.board.id ?? createId("board");
214 const timestamp = now();
215 const board: BoardMeta = {
216 id: boardId,
217 name: snapshot.board.name || DEFAULT_BOARD_NAME,
218 createdAt: snapshot.board.createdAt ?? timestamp,
219 updatedAt: timestamp,
220 };
221
222 await database.transaction("rw", [boards(), pages(), shapes(), bindings(), meta()], async () => {
223 await boards().put(board);
224
225 const pageRows = Object.values(snapshot.doc.pages).map((page) => ({
226 ...PageOps.clone(page),
227 boardId,
228 updatedAt: timestamp,
229 }));
230 const shapeRows = Object.values(snapshot.doc.shapes).map((shape) => ({
231 ...ShapeOps.clone(shape),
232 boardId,
233 updatedAt: timestamp,
234 }));
235 const bindingRows = Object.values(snapshot.doc.bindings).map((binding) => ({
236 ...BindingOps.clone(binding),
237 boardId,
238 updatedAt: timestamp,
239 }));
240
241 if (pageRows.length > 0) await pages().bulkPut(pageRows);
242 if (shapeRows.length > 0) await shapes().bulkPut(shapeRows);
243 if (bindingRows.length > 0) await bindings().bulkPut(bindingRows);
244
245 const order = snapshot.order ?? deriveDocOrderFromDocument(snapshot.doc);
246 await meta().put({ key: pageOrderKey(boardId), value: order.pageIds });
247 await meta().put({ key: shapeOrderKey(boardId), value: order.shapeOrder ?? {} });
248 });
249
250 return boardId;
251 }
252
253 async function openBoard(boardId: string): Promise<void> {
254 const exists = await boards().get(boardId);
255 if (!exists) {
256 throw new Error(`Board ${boardId} not found`);
257 }
258 }
259
260 return {
261 listBoards,
262 createBoard,
263 openBoard,
264 renameBoard,
265 deleteBoard,
266 loadDoc,
267 applyDocPatch,
268 exportBoard,
269 importBoard,
270 };
271}
272
273/**
274 * Compute a patch between two documents. Current implementation sends the full snapshot (upsert all rows).
275 */
276export function diffDoc(before: Document, after: Document): DocPatch {
277 const patch: DocPatch = {};
278
279 const deletedPages = difference(Object.keys(before.pages), Object.keys(after.pages));
280 const deletedShapes = difference(Object.keys(before.shapes), Object.keys(after.shapes));
281 const deletedBindings = difference(Object.keys(before.bindings), Object.keys(after.bindings));
282
283 if (deletedPages.length > 0 || deletedShapes.length > 0 || deletedBindings.length > 0) {
284 patch.deletes = {};
285 if (deletedPages.length > 0) patch.deletes.pageIds = deletedPages;
286 if (deletedShapes.length > 0) patch.deletes.shapeIds = deletedShapes;
287 if (deletedBindings.length > 0) patch.deletes.bindingIds = deletedBindings;
288 }
289
290 const pageUpserts = Object.values(after.pages).map((page) => PageOps.clone(page));
291 const shapeUpserts = Object.values(after.shapes).map((shape) => ShapeOps.clone(shape));
292 const bindingUpserts = Object.values(after.bindings).map((binding) => BindingOps.clone(binding));
293
294 if (pageUpserts.length > 0 || shapeUpserts.length > 0 || bindingUpserts.length > 0) {
295 patch.upserts = {};
296 if (pageUpserts.length > 0) patch.upserts.pages = pageUpserts;
297 if (shapeUpserts.length > 0) patch.upserts.shapes = shapeUpserts;
298 if (bindingUpserts.length > 0) patch.upserts.bindings = bindingUpserts;
299 }
300
301 patch.order = deriveDocOrderFromDocument(after);
302
303 return patch;
304}
305
306/**
307 * Batch doc patches and flush them with a debounce to cut down on Dexie writes.
308 */
309export function createPersistenceSink(repo: PersistentDocRepo, options?: PersistenceSinkOptions): PersistenceSink {
310 const debounceMs = options?.debounceMs ?? 200;
311 let pendingBoardId: string | null = null;
312 let pendingPatch: DocPatch | null = null;
313 let timer: ReturnType<typeof setTimeout> | null = null;
314 let inflight: Promise<void> | null = null;
315
316 const scheduleFlush = () => {
317 if (timer) {
318 clearTimeout(timer);
319 }
320 timer = setTimeout(() => {
321 timer = null;
322 void flush();
323 }, debounceMs);
324 };
325
326 const resetPending = () => {
327 pendingBoardId = null;
328 pendingPatch = null;
329 if (timer) {
330 clearTimeout(timer);
331 timer = null;
332 }
333 };
334
335 async function flush(): Promise<void> {
336 if (inflight) {
337 await inflight;
338 return;
339 }
340
341 if (!pendingBoardId || !pendingPatch || isPatchEmpty(pendingPatch)) {
342 resetPending();
343 return;
344 }
345
346 const boardId = pendingBoardId;
347 const patch = pendingPatch;
348 resetPending();
349
350 inflight = repo.applyDocPatch(boardId, patch).finally(() => {
351 inflight = null;
352 });
353
354 await inflight;
355 }
356
357 function enqueueDocPatch(boardId: string, patch: DocPatch): void {
358 if (!boardId) {
359 throw new Error("boardId is required to persist edits");
360 }
361
362 if (pendingBoardId && pendingBoardId !== boardId) {
363 void flush();
364 }
365
366 pendingBoardId = boardId;
367 pendingPatch = clonePatch(patch);
368 if (!isPatchEmpty(pendingPatch)) {
369 scheduleFlush();
370 }
371 }
372
373 return { enqueueDocPatch, flush };
374}
375
376function clonePageRow(row: PageRow): PageRecord {
377 const { boardId: _boardId, updatedAt: _updatedAt, ...rest } = row;
378 return PageOps.clone(rest);
379}
380
381function cloneShapeRow(row: ShapeRow): ShapeRecord {
382 const { boardId: _boardId, updatedAt: _updatedAt, ...rest } = row;
383 return ShapeOps.clone(rest as ShapeRecord);
384}
385
386function cloneBindingRow(row: BindingRow): BindingRecord {
387 const { boardId: _boardId, updatedAt: _updatedAt, ...rest } = row;
388 return BindingOps.clone(rest);
389}
390
391function difference(before: string[], after: string[]): string[] {
392 const afterSet = new Set(after);
393 return before.filter((id) => !afterSet.has(id));
394}
395
396function deriveDocOrderFromDocument(doc: Document): DocOrder {
397 return { pageIds: Object.keys(doc.pages), shapeOrder: shapeOrderFromPagesRecords(doc.pages) };
398}
399
400function shapeOrderFromPagesRecords(pages: Record<string, PageRecord>): Record<string, string[]> {
401 return Object.fromEntries(Object.values(pages).map((page) => [page.id, [...page.shapeIds]]));
402}
403
404function shapeOrderFromPageRows(rows: PageRow[]): Record<string, string[]> {
405 return Object.fromEntries(rows.map((row) => [row.id, [...row.shapeIds]]));
406}
407
408function clonePatch(patch: DocPatch): DocPatch {
409 const cloned: DocPatch = {};
410
411 if (patch.upserts) {
412 cloned.upserts = {};
413 if (patch.upserts.pages) cloned.upserts.pages = patch.upserts.pages.map((page) => PageOps.clone(page));
414 if (patch.upserts.shapes) cloned.upserts.shapes = patch.upserts.shapes.map((shape) => ShapeOps.clone(shape));
415 if (patch.upserts.bindings) {
416 cloned.upserts.bindings = patch.upserts.bindings.map((binding) => BindingOps.clone(binding));
417 }
418 if (!cloned.upserts.pages && !cloned.upserts.shapes && !cloned.upserts.bindings) {
419 delete cloned.upserts;
420 }
421 }
422
423 if (patch.deletes) {
424 cloned.deletes = {};
425 if (patch.deletes.pageIds) cloned.deletes.pageIds = [...patch.deletes.pageIds];
426 if (patch.deletes.shapeIds) cloned.deletes.shapeIds = [...patch.deletes.shapeIds];
427 if (patch.deletes.bindingIds) cloned.deletes.bindingIds = [...patch.deletes.bindingIds];
428 if (!cloned.deletes.pageIds?.length && !cloned.deletes.shapeIds?.length && !cloned.deletes.bindingIds?.length) {
429 delete cloned.deletes;
430 }
431 }
432
433 if (patch.order) {
434 const pageIds = patch.order.pageIds ? [...patch.order.pageIds] : undefined;
435 const shapeOrder = cloneShapeOrderMap(patch.order.shapeOrder);
436 if (pageIds || shapeOrder) {
437 cloned.order = {};
438 if (pageIds) {
439 cloned.order.pageIds = pageIds;
440 }
441 if (shapeOrder) {
442 cloned.order.shapeOrder = shapeOrder;
443 }
444 }
445 }
446
447 return cloned;
448}
449
450function cloneShapeOrderMap(shapeOrder?: Record<string, string[]>): Record<string, string[]> | undefined {
451 if (!shapeOrder) {
452 return undefined;
453 }
454
455 return Object.fromEntries(Object.entries(shapeOrder).map(([pageId, shapeIds]) => [pageId, [...shapeIds]]));
456}
457
458function isPatchEmpty(patch: DocPatch): boolean {
459 const hasUpserts = Boolean(patch.upserts?.pages?.length)
460 || Boolean(patch.upserts?.shapes?.length)
461 || Boolean(patch.upserts?.bindings?.length);
462
463 const hasDeletes = Boolean(patch.deletes?.pageIds?.length)
464 || Boolean(patch.deletes?.shapeIds?.length)
465 || Boolean(patch.deletes?.bindingIds?.length);
466
467 const hasOrder = Boolean(patch.order?.pageIds?.length)
468 || Boolean(patch.order?.shapeOrder && Object.keys(patch.order.shapeOrder).length > 0);
469
470 return !(hasUpserts || hasDeletes || hasOrder);
471}
472
473/**
474 * Fetch board statistics for a given board.
475 */
476export async function getBoardStats(database: DexieLike, boardId: string): Promise<BoardStats> {
477 const pages = database.table<PageRow>("pages");
478 const shapes = database.table<ShapeRow>("shapes");
479 const bindings = database.table<BindingRow>("bindings");
480 const boards = database.table<BoardMeta>("boards");
481
482 const [pageCount, shapeCount, bindingCount, board] = await Promise.all([
483 pages.where("boardId").equals(boardId).count(),
484 shapes.where("boardId").equals(boardId).count(),
485 bindings.where("boardId").equals(boardId).count(),
486 boards.get(boardId),
487 ]);
488
489 const allRows = await Promise.all([
490 pages.where("boardId").equals(boardId).toArray(),
491 shapes.where("boardId").equals(boardId).toArray(),
492 bindings.where("boardId").equals(boardId).toArray(),
493 ]);
494
495 const docSizeBytes = JSON.stringify({ pages: allRows[0], shapes: allRows[1], bindings: allRows[2] }).length;
496
497 return BoardStatsOps.create({
498 pageCount,
499 shapeCount,
500 bindingCount,
501 docSizeBytes,
502 lastUpdated: board?.updatedAt ?? 0,
503 });
504}
505
506/**
507 * Fetch schema information from the database.
508 */
509export async function getSchemaInfo(database: Dexie): Promise<SchemaInfo> {
510 return { declaredVersion: database.verno, installedVersion: database.verno };
511}
512
513/**
514 * Fetch applied migrations from the migrations table.
515 */
516export async function getAppliedMigrations(database: DexieLike): Promise<MigrationInfo[]> {
517 const migrations = database.table<MigrationRow>("migrations");
518 return migrations.orderBy("appliedAt").toArray();
519}
520
521/**
522 * Fetch complete inspector data for a board including stats, schema, and migrations.
523 */
524export async function getBoardInspectorData(
525 database: Dexie,
526 boardId: string,
527 knownMigrationIds: string[],
528): Promise<BoardInspectorData> {
529 const [stats, schema, migrations] = await Promise.all([
530 getBoardStats(database, boardId),
531 getSchemaInfo(database),
532 getAppliedMigrations(database),
533 ]);
534
535 const pendingMigrations = getPendingMigrations(knownMigrationIds, migrations);
536
537 return { stats, schema, migrations, pendingMigrations };
538}