UNPKG

@casual-simulation/aux-common

Version:
222 lines 8.44 kB
/* CasualOS is a set of web-based tools designed to facilitate the creation of real-time, multi-user, context-aware interactive experiences. * * Copyright (c) 2019-2025 Casual Simulation, Inc. * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see <https://www.gnu.org/licenses/>. */ import { applyUpdate, encodeStateAsUpdate, transact } from 'yjs'; import * as idb from 'lib0/indexeddb'; import { BehaviorSubject, filter, firstValueFrom, map } from 'rxjs'; import { v4 as uuid } from 'uuid'; const customStoreName = 'custom'; const updatesStoreName = 'updates'; export const PREFERRED_TRIM_SIZE = 500; export const fetchUpdates = async (idbPersistence, beforeApplyUpdatesCallback = () => { }, afterApplyUpdatesCallback = () => { }) => { const [updatesStore] = idb.transact( /** @type {IDBDatabase} */ idbPersistence.db, [updatesStoreName]); // , 'readonly') const updates = await idb.getAll(updatesStore, idb.createIDBKeyRangeLowerBound(idbPersistence.dbref, false)); if (!idbPersistence.destroyed) { beforeApplyUpdatesCallback(updatesStore); transact(idbPersistence.doc, () => { updates.forEach((val) => applyUpdate(idbPersistence.doc, val)); }, idbPersistence, false); afterApplyUpdatesCallback(updatesStore); } const lastKey = await idb.getLastKey(updatesStore); idbPersistence.dbref = lastKey + 1; const cnt = await idb.count(updatesStore); idbPersistence.dbsize = cnt; return updatesStore; }; export const storeState = (idbPersistence, forceStore = true) => fetchUpdates(idbPersistence).then((updatesStore) => { if (forceStore || idbPersistence.dbsize >= PREFERRED_TRIM_SIZE) { idb.addAutoKey(updatesStore, encodeStateAsUpdate(idbPersistence.doc)) .then(() => idb.del(updatesStore, idb.createIDBKeyRangeUpperBound(idbPersistence.dbref, true))) .then(() => idb.count(updatesStore).then((cnt) => { idbPersistence.dbsize = cnt; })); } }); export const clearDocument = (name) => idb.deleteDB(name); /** * Defines a class that is able to persist a Yjs document to IndexedDB. */ export class YjsIndexedDBPersistence { get whenSynced() { return this._whenSynced; } get storeTimeout() { return this._storeTimeout; } set storeTimeout(val) { this._storeTimeout = val; } get onSyncChanged() { return this._onSyncChanged; } get synced() { return this._onSyncChanged.value; } get destroyed() { return this._destroyed; } get doc() { return this._doc; } get dbref() { return this._dbref; } set dbref(val) { this._dbref = val; } get dbsize() { return this._dbsize; } set dbsize(val) { this._dbsize = val; } constructor(name, doc, options) { var _a, _b; this._doc = doc; this._name = name; this._dbref = 0; this._dbsize = 0; this._destroyed = false; this.db = null; this._id = (_a = options === null || options === void 0 ? void 0 : options.id) !== null && _a !== void 0 ? _a : uuid(); this._onSyncChanged = new BehaviorSubject(false); this._db = idb.openDB(name, (db) => idb.createStores(db, [ ['updates', { autoIncrement: true }], ['custom'], ])); const broadcastChanges = (_b = options === null || options === void 0 ? void 0 : options.broadcastChanges) !== null && _b !== void 0 ? _b : false; // this._encryptionKey = options?.encryptionKey ?? null; this._channel = broadcastChanges ? new BroadcastChannel(`yjs/${name}`) : null; this._whenSynced = firstValueFrom(this._onSyncChanged.pipe(filter((sync) => sync), map(() => undefined))); this._initPromise = this._initDb(); this._storeTimeout = 1000; this._storeTimeoutId = null; if (this._channel) { this._channel.addEventListener('message', (event) => { if (event.data.type === 'update' && event.data.id !== this._id) { this._onSyncChanged.next(false); this._fetchUpdates(); } }); } this._storeUpdate = this._storeUpdate.bind(this); this.destroy = this.destroy.bind(this); doc.on('update', this._storeUpdate); doc.on('destroy', this.destroy); } async waitForInit() { await this._initPromise; } async _initDb() { const db = await this._db; this.db = db; await this._fetchUpdates(); } async _fetchUpdates() { await this._db; if (this._destroyed) { return; } const beforeApplyUpdatesCallback = (updatesStore) => idb.addAutoKey(updatesStore, encodeStateAsUpdate(this.doc)); const afterApplyUpdatesCallback = () => { if (this._destroyed) return this; this._onSyncChanged.next(true); }; await fetchUpdates(this, beforeApplyUpdatesCallback, afterApplyUpdatesCallback); } _storeUpdate(update, origin) { if (this.db && origin !== this) { const [updatesStore] = idb.transact(this.db, [updatesStoreName]); idb.addAutoKey(updatesStore, update); if (++this._dbsize >= PREFERRED_TRIM_SIZE) { // debounce store call if (this._storeTimeoutId !== null) { clearTimeout(this._storeTimeoutId); } this._storeTimeoutId = setTimeout(() => { storeState(this, false); this._storeTimeoutId = null; }, this._storeTimeout); } if (this._channel) { this._channel.postMessage({ type: 'update', id: this._id, }); } } } async destroy() { if (this._storeTimeoutId) { clearTimeout(this._storeTimeoutId); } if (this._channel) { this._channel.close(); this._channel = null; } this._doc.off('update', this._storeUpdate); this._doc.off('destroy', this.destroy); this._destroyed = true; const db = await this._db; db.close(); } /** * Destroys this instance and removes all data from indexeddb. */ async clearData() { await this.destroy(); idb.deleteDB(this._name); } /** * Retrieves a value from the IndexedDB database. * @param key - The key of the value to retrieve. * @returns A promise that resolves with the retrieved value. */ async get(key) { const db = await this._db; const [custom] = idb.transact(db, [customStoreName], 'readonly'); return idb.get(custom, key); } /** * Sets a value in the IndexedDB database. * @param key The key to use for the value. Can be a string, number, ArrayBuffer, or Date. * @param value The value to store in the database. * @returns A Promise that resolves when the value has been successfully stored. */ async set(key, value) { const db = await this._db; const [custom] = idb.transact(db, [customStoreName]); return idb.put(custom, value, key); } /** * Deletes the value associated with the given key from the custom store. * @param key The key of the value to delete. * @returns A promise that resolves when the value has been deleted. */ async del(key) { const db = await this._db; const [custom] = idb.transact(db, [customStoreName]); return idb.del(custom, key); } } //# sourceMappingURL=YjsIndexedDBPersistence.js.map