web based infinite canvas
at main 538 lines 19 kB view raw
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}