UNPKG

@tldraw/editor

Version:

tldraw infinite canvas SDK (editor).

331 lines (291 loc) • 9.59 kB
import { RecordsDiff, SerializedSchema, SerializedStore } from '@tldraw/store' import { TLRecord, TLStoreSchema } from '@tldraw/tlschema' import { assert, getFromLocalStorage, noop, setInLocalStorage } from '@tldraw/utils' import { IDBPDatabase, IDBPTransaction, deleteDB, openDB } from 'idb' import { TLSessionStateSnapshot } from '../../config/TLSessionStateSnapshot' // DO NOT CHANGE THESE WITHOUT ADDING MIGRATION LOGIC. DOING SO WOULD WIPE ALL EXISTING DATA. const STORE_PREFIX = 'TLDRAW_DOCUMENT_v2' const LEGACY_ASSET_STORE_PREFIX = 'TLDRAW_ASSET_STORE_v1' const dbNameIndexKey = 'TLDRAW_DB_NAME_INDEX_v2' /** @internal */ export const Table = { Records: 'records', Schema: 'schema', SessionState: 'session_state', Assets: 'assets', } as const /** @internal */ export type StoreName = (typeof Table)[keyof typeof Table] async function openLocalDb(persistenceKey: string) { const storeId = STORE_PREFIX + persistenceKey addDbName(storeId) return await openDB<StoreName>(storeId, 4, { upgrade(database) { if (!database.objectStoreNames.contains(Table.Records)) { database.createObjectStore(Table.Records) } if (!database.objectStoreNames.contains(Table.Schema)) { database.createObjectStore(Table.Schema) } if (!database.objectStoreNames.contains(Table.SessionState)) { database.createObjectStore(Table.SessionState) } if (!database.objectStoreNames.contains(Table.Assets)) { database.createObjectStore(Table.Assets) } }, }) } async function migrateLegacyAssetDbIfNeeded(persistenceKey: string) { const databases = window.indexedDB.databases ? (await window.indexedDB.databases()).map((db) => db.name) : getAllIndexDbNames() const oldStoreId = LEGACY_ASSET_STORE_PREFIX + persistenceKey const existing = databases.find((dbName) => dbName === oldStoreId) if (!existing) return const oldAssetDb = await openDB<StoreName>(oldStoreId, 1, { upgrade(database) { if (!database.objectStoreNames.contains('assets')) { database.createObjectStore('assets') } }, }) if (!oldAssetDb.objectStoreNames.contains('assets')) return const oldTx = oldAssetDb.transaction(['assets'], 'readonly') const oldAssetStore = oldTx.objectStore('assets') const oldAssetsKeys = await oldAssetStore.getAllKeys() const oldAssets = await Promise.all( oldAssetsKeys.map(async (key) => [key, await oldAssetStore.get(key)] as const) ) await oldTx.done const newDb = await openLocalDb(persistenceKey) const newTx = newDb.transaction([Table.Assets], 'readwrite') const newAssetTable = newTx.objectStore(Table.Assets) for (const [key, value] of oldAssets) { newAssetTable.put(value, key) } await newTx.done oldAssetDb.close() newDb.close() await deleteDB(oldStoreId) } interface LoadResult { records: TLRecord[] schema?: SerializedSchema sessionStateSnapshot?: TLSessionStateSnapshot | null } interface SessionStateSnapshotRow { id: string snapshot: TLSessionStateSnapshot updatedAt: number } /** @internal */ export class LocalIndexedDb { private getDbPromise: Promise<IDBPDatabase<StoreName>> private isClosed = false private pendingTransactionSet = new Set<Promise<unknown>>() /** @internal */ static connectedInstances = new Set<LocalIndexedDb>() constructor(persistenceKey: string) { LocalIndexedDb.connectedInstances.add(this) this.getDbPromise = (async () => { await migrateLegacyAssetDbIfNeeded(persistenceKey) return await openLocalDb(persistenceKey) })() } private getDb() { return this.getDbPromise } /** * Wait for any pending transactions to be completed. Useful for tests. * * @internal */ pending(): Promise<void> { return Promise.allSettled([this.getDbPromise, ...this.pendingTransactionSet]).then(noop) } async close() { if (this.isClosed) return this.isClosed = true await this.pending() ;(await this.getDb()).close() LocalIndexedDb.connectedInstances.delete(this) } private tx<Names extends StoreName[], Mode extends IDBTransactionMode, T>( mode: Mode, names: Names, cb: (tx: IDBPTransaction<StoreName, Names, Mode>) => Promise<T> ): Promise<T> { const txPromise = (async () => { assert(!this.isClosed, 'db is closed') const db = await this.getDb() const tx = db.transaction(names, mode) // need to add a catch here early to prevent unhandled promise rejection // during react-strict-mode where this tx.done promise can be rejected // before we have a chance to await on it const done = tx.done.catch((e: unknown) => { if (!this.isClosed) { throw e } }) try { return await cb(tx) } finally { if (!this.isClosed) { await done } else { tx.abort() } } })() this.pendingTransactionSet.add(txPromise) txPromise.finally(() => this.pendingTransactionSet.delete(txPromise)) return txPromise } async load({ sessionId }: { sessionId?: string } = {}) { return await this.tx( 'readonly', [Table.Records, Table.Schema, Table.SessionState], async (tx) => { const recordsStore = tx.objectStore(Table.Records) const schemaStore = tx.objectStore(Table.Schema) const sessionStateStore = tx.objectStore(Table.SessionState) let sessionStateSnapshot = sessionId ? ((await sessionStateStore.get(sessionId)) as SessionStateSnapshotRow | undefined) ?.snapshot : null if (!sessionStateSnapshot) { // get the most recent session state const all = (await sessionStateStore.getAll()) as SessionStateSnapshotRow[] sessionStateSnapshot = all.sort((a, b) => a.updatedAt - b.updatedAt).pop()?.snapshot } const result = { records: await recordsStore.getAll(), schema: await schemaStore.get(Table.Schema), sessionStateSnapshot, } satisfies LoadResult return result } ) } async storeChanges({ schema, changes, sessionId, sessionStateSnapshot, }: { schema: TLStoreSchema changes: RecordsDiff<any> sessionId?: string | null sessionStateSnapshot?: TLSessionStateSnapshot | null }) { await this.tx('readwrite', [Table.Records, Table.Schema, Table.SessionState], async (tx) => { const recordsStore = tx.objectStore(Table.Records) const schemaStore = tx.objectStore(Table.Schema) const sessionStateStore = tx.objectStore(Table.SessionState) for (const [id, record] of Object.entries(changes.added)) { await recordsStore.put(record, id) } for (const [_prev, updated] of Object.values(changes.updated)) { await recordsStore.put(updated, updated.id) } for (const id of Object.keys(changes.removed)) { await recordsStore.delete(id) } schemaStore.put(schema.serialize(), Table.Schema) if (sessionStateSnapshot && sessionId) { sessionStateStore.put( { snapshot: sessionStateSnapshot, updatedAt: Date.now(), id: sessionId, } satisfies SessionStateSnapshotRow, sessionId ) } else if (sessionStateSnapshot || sessionId) { console.error('sessionStateSnapshot and instanceId must be provided together') } }) } async storeSnapshot({ schema, snapshot, sessionId, sessionStateSnapshot, }: { schema: TLStoreSchema snapshot: SerializedStore<any> sessionId?: string | null sessionStateSnapshot?: TLSessionStateSnapshot | null }) { await this.tx('readwrite', [Table.Records, Table.Schema, Table.SessionState], async (tx) => { const recordsStore = tx.objectStore(Table.Records) const schemaStore = tx.objectStore(Table.Schema) const sessionStateStore = tx.objectStore(Table.SessionState) await recordsStore.clear() for (const [id, record] of Object.entries(snapshot)) { await recordsStore.put(record, id) } schemaStore.put(schema.serialize(), Table.Schema) if (sessionStateSnapshot && sessionId) { sessionStateStore.put( { snapshot: sessionStateSnapshot, updatedAt: Date.now(), id: sessionId, } satisfies SessionStateSnapshotRow, sessionId ) } else if (sessionStateSnapshot || sessionId) { console.error('sessionStateSnapshot and instanceId must be provided together') } }) } async pruneSessions() { await this.tx('readwrite', [Table.SessionState], async (tx) => { const sessionStateStore = tx.objectStore(Table.SessionState) const all = (await sessionStateStore.getAll()).sort((a, b) => a.updatedAt - b.updatedAt) if (all.length < 10) { await tx.done return } const toDelete = all.slice(0, all.length - 10) for (const { id } of toDelete) { await sessionStateStore.delete(id) } }) } async getAsset(assetId: string): Promise<File | undefined> { return await this.tx('readonly', [Table.Assets], async (tx) => { const assetsStore = tx.objectStore(Table.Assets) return await assetsStore.get(assetId) }) } async storeAsset(assetId: string, blob: File) { await this.tx('readwrite', [Table.Assets], async (tx) => { const assetsStore = tx.objectStore(Table.Assets) await assetsStore.put(blob, assetId) }) } async removeAssets(assetId: string[]) { await this.tx('readwrite', [Table.Assets], async (tx) => { const assetsStore = tx.objectStore(Table.Assets) for (const id of assetId) { await assetsStore.delete(id) } }) } } /** @internal */ export function getAllIndexDbNames(): string[] { const result = JSON.parse(getFromLocalStorage(dbNameIndexKey) || '[]') ?? [] if (!Array.isArray(result)) { return [] } return result } function addDbName(name: string) { const all = new Set(getAllIndexDbNames()) all.add(name) setInLocalStorage(dbNameIndexKey, JSON.stringify([...all])) }