@tldraw/editor
Version:
tldraw infinite canvas SDK (editor).
256 lines (255 loc) • 8.49 kB
JavaScript
import { assert, getFromLocalStorage, noop, setInLocalStorage } from "@tldraw/utils";
import { deleteDB, openDB } from "idb";
const STORE_PREFIX = "TLDRAW_DOCUMENT_v2";
const LEGACY_ASSET_STORE_PREFIX = "TLDRAW_ASSET_STORE_v1";
const dbNameIndexKey = "TLDRAW_DB_NAME_INDEX_v2";
const Table = {
Records: "records",
Schema: "schema",
SessionState: "session_state",
Assets: "assets"
};
async function openLocalDb(persistenceKey) {
const storeId = STORE_PREFIX + persistenceKey;
addDbName(storeId);
return await openDB(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) {
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(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)])
);
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);
}
class LocalIndexedDb {
getDbPromise;
isClosed = false;
pendingTransactionSet = /* @__PURE__ */ new Set();
/** @internal */
static connectedInstances = /* @__PURE__ */ new Set();
constructor(persistenceKey) {
LocalIndexedDb.connectedInstances.add(this);
this.getDbPromise = (async () => {
await migrateLegacyAssetDbIfNeeded(persistenceKey);
return await openLocalDb(persistenceKey);
})();
}
getDb() {
return this.getDbPromise;
}
/**
* Wait for any pending transactions to be completed. Useful for tests.
*
* @internal
*/
pending() {
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);
}
tx(mode, names, cb) {
const txPromise = (async () => {
assert(!this.isClosed, "db is closed");
const db = await this.getDb();
const tx = db.transaction(names, mode);
const done = tx.done.catch((e) => {
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 } = {}) {
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))?.snapshot : null;
if (!sessionStateSnapshot) {
const all = await sessionStateStore.getAll();
sessionStateSnapshot = all.sort((a, b) => a.updatedAt - b.updatedAt).pop()?.snapshot;
}
const result = {
records: await recordsStore.getAll(),
schema: await schemaStore.get(Table.Schema),
sessionStateSnapshot
};
return result;
}
);
}
async storeChanges({
schema,
changes,
sessionId,
sessionStateSnapshot
}) {
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
},
sessionId
);
} else if (sessionStateSnapshot || sessionId) {
console.error("sessionStateSnapshot and instanceId must be provided together");
}
});
}
async storeSnapshot({
schema,
snapshot,
sessionId,
sessionStateSnapshot
}) {
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
},
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) {
return await this.tx("readonly", [Table.Assets], async (tx) => {
const assetsStore = tx.objectStore(Table.Assets);
return await assetsStore.get(assetId);
});
}
async storeAsset(assetId, blob) {
await this.tx("readwrite", [Table.Assets], async (tx) => {
const assetsStore = tx.objectStore(Table.Assets);
await assetsStore.put(blob, assetId);
});
}
async removeAssets(assetId) {
await this.tx("readwrite", [Table.Assets], async (tx) => {
const assetsStore = tx.objectStore(Table.Assets);
for (const id of assetId) {
await assetsStore.delete(id);
}
});
}
}
function getAllIndexDbNames() {
const result = JSON.parse(getFromLocalStorage(dbNameIndexKey) || "[]") ?? [];
if (!Array.isArray(result)) {
return [];
}
return result;
}
function addDbName(name) {
const all = new Set(getAllIndexDbNames());
all.add(name);
setInLocalStorage(dbNameIndexKey, JSON.stringify([...all]));
}
export {
LocalIndexedDb,
Table,
getAllIndexDbNames
};
//# sourceMappingURL=LocalIndexedDb.mjs.map