Repository: yjs/y-indexeddb Branch: master Commit: ff468b5e9cb3 Files: 12 Total size: 16.5 KB Directory structure: gitextract_7i4vyn17/ ├── .github/ │ └── workflows/ │ └── node.js.yml ├── .gitignore ├── .markdownlint.json ├── LICENSE ├── README.md ├── index.html ├── package.json ├── rollup.config.js ├── src/ │ └── y-indexeddb.js ├── tests/ │ ├── index.js │ └── y-indexeddb.tests.js └── tsconfig.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/workflows/node.js.yml ================================================ # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions name: Node.js CI on: push: branches: [ master ] pull_request: branches: [ master ] jobs: build: runs-on: ubuntu-latest strategy: matrix: node-version: [16.x, 18.x] steps: - uses: actions/checkout@v2 - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v1 with: node-version: ${{ matrix.node-version }} - run: npm ci - run: npm run build --if-present - run: npm test ================================================ FILE: .gitignore ================================================ node_modules dist ================================================ FILE: .markdownlint.json ================================================ { "default": true, "no-inline-html": false } ================================================ FILE: LICENSE ================================================ The MIT License (MIT) Copyright (c) 2014 - Kevin Jahns . - Chair of Computer Science 5 (Databases & Information Systems), RWTH Aachen University, Germany Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # y-indexeddb > IndexedDB database provider for Yjs. [Documentation](https://docs.yjs.dev/ecosystem/database-provider/y-indexeddb) Use the IndexedDB database adapter to store your shared data persistently in the browser. The next time you join the session, your changes will still be there. * Minimizes the amount of data exchanged between server and client * Makes offline editing possible ## Getting Started You find the complete documentation published online: [API documentation](https://docs.yjs.dev/ecosystem/database-provider/y-indexeddb). ```sh npm i --save y-indexeddb ``` ```js const provider = new IndexeddbPersistence(docName, ydoc) provider.on('synced', () => { console.log('content from the database is loaded') }) ``` ## API
provider = new IndexeddbPersistence(docName: string, ydoc: Y.Doc)
Create a y-indexeddb persistence provider. Specify docName as a unique string that identifies this document. In most cases, you want to use the same identifier that is used as the room-name in the connection provider.
provider.on('synced', function(idbPersistence: IndexeddbPersistence))
The "synced" event is fired when the connection to the database has been established and all available content has been loaded. The event is also fired if no content is found for the given doc name.
provider.set(key: any, value: any): Promise<any>
Set a custom property on the provider instance. You can use this to store relevant meta-information for the persisted document. However, the content will not be synced with other peers.
provider.get(key: any): Promise>any<
Retrieve a stored value.
provider.del(key: any): Promise>undefined<
Delete a stored value.
provider.destroy(): Promise
Close the connection to the database and stop syncing the document. This method is automatically called when the Yjs document is destroyed (e.g. ydoc.destroy()).
provider.clearData(): Promise
Destroy this database and remove the stored document and all related meta-information from the database.
## License Yjs is licensed under the [MIT License](./LICENSE). ================================================ FILE: index.html ================================================ Testing y-indexeddb ================================================ FILE: package.json ================================================ { "name": "y-indexeddb", "version": "9.0.12", "description": "IndexedDB database adapter for Yjs", "type": "module", "main": "./dist/y-indexeddb.cjs", "module": "./src/y-indexeddb.js", "types": "./dist/src/y-indexeddb.d.ts", "sideEffects": false, "funding": { "type": "GitHub Sponsors ❤", "url": "https://github.com/sponsors/dmonad" }, "scripts": { "clean": "rm -rf dist", "test": "npm run lint", "dist": "rollup -c", "lint": "markdownlint README.md && standard && tsc", "preversion": "npm run clean && npm run lint && npm run dist", "debug": "concurrently 'rollup -wc' 'http-server -o .'" }, "files": [ "dist/*", "src/*" ], "exports": { ".": { "types": "./dist/src/y-indexeddb.d.ts", "module": "./src/y-indexeddb.js", "import": "./src/y-indexeddb.js", "require": "./dist/y-indexeddb.cjs", "default": "./src/y-indexeddb.js" }, "./package.json": "./package.json" }, "standard": { "ignore": [ "/dist", "/node_modules", "/docs" ] }, "repository": { "type": "git", "url": "git+https://github.com/yjs/y-indexeddb.git" }, "keywords": [ "Yjs", "CRDT", "offline", "shared editing", "collaboration", "concurrency" ], "author": "Kevin Jahns ", "license": "MIT", "bugs": { "url": "https://github.com/yjs/y-indexeddb/issues" }, "homepage": "https://yjs.dev", "dependencies": { "lib0": "^0.2.74" }, "devDependencies": { "@rollup/plugin-commonjs": "^11.1.0", "@rollup/plugin-node-resolve": "^7.1.3", "concurrently": "^3.6.1", "http-server": "^0.12.3", "jsdoc": "^3.6.6", "markdownlint-cli": "^0.19.0", "rollup": "^1.32.1", "standard": "^11.0.1", "typescript": "^5.0.4", "y-protocols": "^1.0.1", "yjs": "^13.4.7" }, "peerDependencies": { "yjs": "^13.0.0" }, "engines": { "npm": ">=8.0.0", "node": ">=16.0.0" } } ================================================ FILE: rollup.config.js ================================================ import resolve from '@rollup/plugin-node-resolve' import commonjs from '@rollup/plugin-commonjs' export default [{ input: './tests/index.js', output: { file: './dist/test.js', format: 'iife', sourcemap: true }, plugins: [ resolve({ mainFields: ['module', 'browser', 'main'] }), commonjs() ] }, { input: './src/y-indexeddb.js', output: { name: 'Y', file: 'dist/y-indexeddb.cjs', format: 'cjs', sourcemap: true }, external: id => /^(lib0|yjs)\//.test(id) }] ================================================ FILE: src/y-indexeddb.js ================================================ import * as Y from 'yjs' import * as idb from 'lib0/indexeddb' import * as promise from 'lib0/promise' import { Observable } from 'lib0/observable' const customStoreName = 'custom' const updatesStoreName = 'updates' export const PREFERRED_TRIM_SIZE = 500 /** * @param {IndexeddbPersistence} idbPersistence * @param {function(IDBObjectStore):void} [beforeApplyUpdatesCallback] * @param {function(IDBObjectStore):void} [afterApplyUpdatesCallback] */ export const fetchUpdates = (idbPersistence, beforeApplyUpdatesCallback = () => {}, afterApplyUpdatesCallback = () => {}) => { const [updatesStore] = idb.transact(/** @type {IDBDatabase} */ (idbPersistence.db), [updatesStoreName]) // , 'readonly') return idb.getAll(updatesStore, idb.createIDBKeyRangeLowerBound(idbPersistence._dbref, false)).then(updates => { if (!idbPersistence._destroyed) { beforeApplyUpdatesCallback(updatesStore) Y.transact(idbPersistence.doc, () => { updates.forEach(val => Y.applyUpdate(idbPersistence.doc, val)) }, idbPersistence, false) afterApplyUpdatesCallback(updatesStore) } }) .then(() => idb.getLastKey(updatesStore).then(lastKey => { idbPersistence._dbref = lastKey + 1 })) .then(() => idb.count(updatesStore).then(cnt => { idbPersistence._dbsize = cnt })) .then(() => updatesStore) } /** * @param {IndexeddbPersistence} idbPersistence * @param {boolean} forceStore */ export const storeState = (idbPersistence, forceStore = true) => fetchUpdates(idbPersistence) .then(updatesStore => { if (forceStore || idbPersistence._dbsize >= PREFERRED_TRIM_SIZE) { idb.addAutoKey(updatesStore, Y.encodeStateAsUpdate(idbPersistence.doc)) .then(() => idb.del(updatesStore, idb.createIDBKeyRangeUpperBound(idbPersistence._dbref, true))) .then(() => idb.count(updatesStore).then(cnt => { idbPersistence._dbsize = cnt })) } }) /** * @param {string} name */ export const clearDocument = name => idb.deleteDB(name) /** * @extends Observable */ export class IndexeddbPersistence extends Observable { /** * @param {string} name * @param {Y.Doc} doc */ constructor (name, doc) { super() this.doc = doc this.name = name this._dbref = 0 this._dbsize = 0 this._destroyed = false /** * @type {IDBDatabase|null} */ this.db = null this.synced = false this._db = idb.openDB(name, db => idb.createStores(db, [ ['updates', { autoIncrement: true }], ['custom'] ]) ) /** * @type {Promise} */ this.whenSynced = promise.create(resolve => this.on('synced', () => resolve(this))) this._db.then(db => { this.db = db /** * @param {IDBObjectStore} updatesStore */ const beforeApplyUpdatesCallback = (updatesStore) => idb.addAutoKey(updatesStore, Y.encodeStateAsUpdate(doc)) const afterApplyUpdatesCallback = () => { if (this._destroyed) return this this.synced = true this.emit('synced', [this]) } fetchUpdates(this, beforeApplyUpdatesCallback, afterApplyUpdatesCallback) }) /** * Timeout in ms until data is merged and persisted in idb. */ this._storeTimeout = 1000 /** * @type {any} */ this._storeTimeoutId = null /** * @param {Uint8Array} update * @param {any} origin */ this._storeUpdate = (update, origin) => { if (this.db && origin !== this) { const [updatesStore] = idb.transact(/** @type {IDBDatabase} */ (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) } } } doc.on('update', this._storeUpdate) this.destroy = this.destroy.bind(this) doc.on('destroy', this.destroy) } destroy () { if (this._storeTimeoutId) { clearTimeout(this._storeTimeoutId) } this.doc.off('update', this._storeUpdate) this.doc.off('destroy', this.destroy) this._destroyed = true return this._db.then(db => { db.close() }) } /** * Destroys this instance and removes all data from indexeddb. * * @return {Promise} */ clearData () { return this.destroy().then(() => { idb.deleteDB(this.name) }) } /** * @param {String | number | ArrayBuffer | Date} key * @return {Promise} */ get (key) { return this._db.then(db => { const [custom] = idb.transact(db, [customStoreName], 'readonly') return idb.get(custom, key) }) } /** * @param {String | number | ArrayBuffer | Date} key * @param {String | number | ArrayBuffer | Date} value * @return {Promise} */ set (key, value) { return this._db.then(db => { const [custom] = idb.transact(db, [customStoreName]) return idb.put(custom, value, key) }) } /** * @param {String | number | ArrayBuffer | Date} key * @return {Promise} */ del (key) { return this._db.then(db => { const [custom] = idb.transact(db, [customStoreName]) return idb.del(custom, key) }) } } ================================================ FILE: tests/index.js ================================================ import * as indexeddb from './y-indexeddb.tests.js' import { runTests } from 'lib0/testing.js' import { isBrowser, isNode } from 'lib0/environment.js' import * as log from 'lib0/logging.js' if (isBrowser) { log.createVConsole(document.body) } runTests({ indexeddb }).then(success => { /* istanbul ignore next */ if (isNode) { process.exit(success ? 0 : 1) } }) ================================================ FILE: tests/y-indexeddb.tests.js ================================================ import * as Y from 'yjs' import { IndexeddbPersistence, clearDocument, PREFERRED_TRIM_SIZE, fetchUpdates } from '../src/y-indexeddb.js' import * as t from 'lib0/testing.js' import * as promise from 'lib0/promise.js' /** * @param {t.TestCase} tc */ export const testPerf = async tc => { await t.measureTimeAsync('time to create a y-indexeddb instance', async () => { const ydoc = new Y.Doc() const provider = new IndexeddbPersistence(tc.testName, ydoc) await provider.whenSynced provider.destroy() }) } /** * @param {t.TestCase} tc */ export const testIdbUpdateAndMerge = async tc => { await clearDocument(tc.testName) const doc1 = new Y.Doc() const arr1 = doc1.getArray('t') const doc2 = new Y.Doc() const arr2 = doc2.getArray('t') arr1.insert(0, [0]) const persistence1 = new IndexeddbPersistence(tc.testName, doc1) persistence1._storeTimeout = 0 await persistence1.whenSynced arr1.insert(0, [1]) const persistence2 = new IndexeddbPersistence(tc.testName, doc2) persistence2._storeTimeout = 0 let calledObserver = false // @ts-ignore arr2.observe((event, tr) => { t.assert(!tr.local) t.assert(tr.origin === persistence2) calledObserver = true }) await persistence2.whenSynced t.assert(calledObserver) t.assert(arr2.length === 2) for (let i = 2; i < PREFERRED_TRIM_SIZE + 1; i++) { arr1.insert(i, [i]) } await promise.wait(100) await fetchUpdates(persistence2) t.assert(arr2.length === PREFERRED_TRIM_SIZE + 1) t.assert(persistence1._dbsize === 1) // wait for dbsize === 0. db should be concatenated } /** * @param {t.TestCase} tc */ export const testIdbConcurrentMerge = async tc => { await clearDocument(tc.testName) const doc1 = new Y.Doc() const arr1 = doc1.getArray('t') const doc2 = new Y.Doc() const arr2 = doc2.getArray('t') arr1.insert(0, [0]) const persistence1 = new IndexeddbPersistence(tc.testName, doc1) persistence1._storeTimeout = 0 await persistence1.whenSynced arr1.insert(0, [1]) const persistence2 = new IndexeddbPersistence(tc.testName, doc2) persistence2._storeTimeout = 0 await persistence2.whenSynced t.assert(arr2.length === 2) arr1.insert(0, ['left']) for (let i = 0; i < PREFERRED_TRIM_SIZE + 1; i++) { arr1.insert(i, [i]) } arr2.insert(0, ['right']) for (let i = 0; i < PREFERRED_TRIM_SIZE + 1; i++) { arr2.insert(i, [i]) } await promise.wait(100) await fetchUpdates(persistence1) await fetchUpdates(persistence2) t.assert(persistence1._dbsize < 10) t.assert(persistence2._dbsize < 10) t.compareArrays(arr1.toArray(), arr2.toArray()) } /** * @param {t.TestCase} tc */ export const testMetaStorage = async tc => { await clearDocument(tc.testName) const ydoc = new Y.Doc() const persistence = new IndexeddbPersistence(tc.testName, ydoc) persistence.set('a', 4) persistence.set(4, 'meta!') // @ts-ignore persistence.set('obj', { a: 4 }) const resA = await persistence.get('a') t.assert(resA === 4) const resB = await persistence.get(4) t.assert(resB === 'meta!') const resC = await persistence.get('obj') t.compareObjects(resC, { a: 4 }) } /** * @param {t.TestCase} tc */ export const testEarlyDestroy = async tc => { let hasbeenSyced = false const ydoc = new Y.Doc() const indexDBProvider = new IndexeddbPersistence(tc.testName, ydoc) indexDBProvider.on('synced', () => { hasbeenSyced = true }) indexDBProvider.destroy() await new Promise((resolve) => setTimeout(resolve, 500)) t.assert(!hasbeenSyced) } ================================================ FILE: tsconfig.json ================================================ { "compilerOptions": { "target": "es2018", "lib": ["es2018", "dom"], "allowJs": true, "checkJs": true, "declaration": true, "declarationMap": true, "emitDeclarationOnly": true, "strict": true, "noImplicitAny": true, "moduleResolution": "node", "outDir": "./dist" }, "include": ["./src/**/*", "./tests/**/*"], "exclude": ["../lib0/**/*", "node_modules/**/*", "dist", "dist/**/*.js"] }