├── with-async-ittr.d.ts ├── .gitignore ├── .prettierrc.json ├── assets └── demo.gif ├── size-tests ├── with-async-ittr.js └── main.js ├── with-async-ittr.js ├── with-async-ittr.cjs ├── test ├── missing-types.d.ts ├── index.html ├── tsconfig.json ├── index.ts ├── live-query.ts ├── sync-server.js ├── computed-stores.ts ├── utils.ts ├── open.ts ├── iterate.ts └── sync-manager.ts ├── src ├── tsconfig.json ├── index.ts ├── util.ts ├── constants.ts ├── live-query.ts ├── database-extras.ts ├── async-iterators.ts ├── wrap-idb-value.ts ├── sync-manager.ts └── entry.ts ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ └── publish.yml ├── generic-tsconfig.json ├── LICENSE ├── lib ├── size-report.mjs └── simple-ts.js ├── package.json ├── CHANGELOG.md ├── rollup.config.js └── README.md /with-async-ittr.d.ts: -------------------------------------------------------------------------------- 1 | export * from './build'; 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .ts-tmp 3 | build 4 | tmp 5 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } 5 | -------------------------------------------------------------------------------- /assets/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darrachequesne/synceddb/HEAD/assets/demo.gif -------------------------------------------------------------------------------- /size-tests/with-async-ittr.js: -------------------------------------------------------------------------------- 1 | import { openDB } from '../with-async-ittr'; 2 | a(openDB); 3 | -------------------------------------------------------------------------------- /with-async-ittr.js: -------------------------------------------------------------------------------- 1 | export * from './build/index.js'; 2 | import './build/async-iterators.js'; 3 | -------------------------------------------------------------------------------- /with-async-ittr.cjs: -------------------------------------------------------------------------------- 1 | module.exports = require('./build/index.cjs'); 2 | require('./build/async-iterators.cjs'); 3 | -------------------------------------------------------------------------------- /test/missing-types.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'chai/chai' { 2 | var chai: typeof import('chai'); 3 | export default chai; 4 | } 5 | -------------------------------------------------------------------------------- /src/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../generic-tsconfig.json", 3 | "compilerOptions": { 4 | "lib": ["esnext", "dom"] 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /test/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 | -------------------------------------------------------------------------------- /test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../generic-tsconfig.json", 3 | "compilerOptions": { 4 | "esModuleInterop": true, 5 | "lib": ["esnext", "dom"] 6 | }, 7 | "references": [{ "path": "../src" }] 8 | } 9 | -------------------------------------------------------------------------------- /size-tests/main.js: -------------------------------------------------------------------------------- 1 | import { openDB, SyncManager, LiveQuery, createComputedStore } from '../build/index'; 2 | a(openDB); 3 | new SyncManager(null, "url"); 4 | new LiveQuery(() => Promise.resolve(), []); 5 | createComputedStore(); 6 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './entry.js'; 2 | import './database-extras.js'; 3 | export { SyncManager } from './sync-manager.js'; 4 | export { LiveQuery } from './live-query.js'; 5 | export { createComputedStore } from './wrap-idb-value.js'; 6 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | export type Constructor = new (...args: any[]) => any; 2 | export type Func = (...args: any[]) => any; 3 | 4 | export const instanceOfAny = ( 5 | object: any, 6 | constructors: Constructor[], 7 | ): boolean => constructors.some((c) => object instanceof c); 8 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const LOCAL_OFFSETS_STORE = '_local_offsets'; 2 | export const LOCAL_CHANGES_STORE = '_local_changes'; 3 | export const IGNORED_STORES = [LOCAL_OFFSETS_STORE, LOCAL_CHANGES_STORE]; 4 | export const VERSION_ATTRIBUTE = 'version'; 5 | export const CHANGE_EVENT_NAME = 'synceddbchange'; 6 | export const BROADCAST_CHANNEL_NAME = 'synceddb'; 7 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | -------------------------------------------------------------------------------- /generic-tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2019", 4 | "downlevelIteration": true, 5 | "module": "esnext", 6 | "strict": true, 7 | "moduleResolution": "node", 8 | "outDir": ".ts-tmp", 9 | "composite": true, 10 | "declarationMap": false, 11 | "baseUrl": "./", 12 | "rootDir": "./", 13 | "allowSyntheticDefaultImports": true, 14 | "noUnusedLocals": true, 15 | "sourceMap": false 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | ISC License (ISC) 2 | 3 | Copyright (c) 2022, Damien Arrachequesne 4 | Copyright (c) 2016-2022, Jake Archibald 5 | 6 | Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. 7 | 8 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 9 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | # reference: https://docs.npmjs.com/generating-provenance-statements 2 | 3 | name: Publish 4 | 5 | on: 6 | push: 7 | tags: 8 | - '*' 9 | 10 | jobs: 11 | publish: 12 | runs-on: ubuntu-latest 13 | permissions: 14 | contents: read 15 | id-token: write 16 | 17 | steps: 18 | - name: Checkout repository 19 | uses: actions/checkout@v4 20 | 21 | - name: Use Node.js 20 22 | uses: actions/setup-node@v4 23 | with: 24 | node-version: 20 25 | registry-url: 'https://registry.npmjs.org' 26 | 27 | - name: Install dependencies 28 | run: npm ci 29 | 30 | - name: Build package 31 | run: npm run build 32 | 33 | - name: Publish package 34 | run: npm publish --provenance --access public 35 | env: 36 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 37 | -------------------------------------------------------------------------------- /test/index.ts: -------------------------------------------------------------------------------- 1 | // Since this library proxies IDB, I haven't retested all of IDB. I've tried to cover parts of the 2 | // library that behave differently to IDB, or may cause accidental differences. 3 | 4 | import 'mocha/mocha'; 5 | import { deleteDatabase } from './utils'; 6 | mocha.setup('tdd'); 7 | 8 | function loadScript(url: string): Promise { 9 | return new Promise((resolve, reject) => { 10 | const script = document.createElement('script'); 11 | script.type = 'module'; 12 | script.src = url; 13 | script.onload = () => resolve(); 14 | script.onerror = () => reject(Error('Script load error')); 15 | document.body.appendChild(script); 16 | }); 17 | } 18 | 19 | (async function () { 20 | const edgeCompat = navigator.userAgent.includes('Edge/'); 21 | 22 | if (!edgeCompat) await loadScript('./open.js'); 23 | await loadScript('./main.js'); 24 | if (!edgeCompat) await loadScript('./iterate.js'); 25 | await loadScript('./sync-manager.js'); 26 | await loadScript('./live-query.js'); 27 | await loadScript('./computed-stores.js'); 28 | await deleteDatabase(); 29 | mocha.run(); 30 | })(); 31 | -------------------------------------------------------------------------------- /lib/size-report.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2019 Google Inc. All Rights Reserved. 3 | * Licensed under the Apache License, Version 2.0 (the "License"); 4 | * you may not use this file except in compliance with the License. 5 | * You may obtain a copy of the License at 6 | * http://www.apache.org/licenses/LICENSE-2.0 7 | * Unless required by applicable law or agreed to in writing, software 8 | * distributed under the License is distributed on an "AS IS" BASIS, 9 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | * See the License for the specific language governing permissions and 11 | * limitations under the License. 12 | */ 13 | import { promisify } from 'util'; 14 | import { brotliCompress } from 'zlib'; 15 | import { promises as fsp } from 'fs'; 16 | 17 | import glob from 'glob'; 18 | import filesize from 'filesize'; 19 | 20 | const globP = promisify(glob); 21 | const brCompress = promisify(brotliCompress); 22 | 23 | (async function () { 24 | const paths = await globP('tmp/size-tests/*.js'); 25 | const entryPromises = paths.map(async (path) => { 26 | const br = await brCompress(await fsp.readFile(path)); 27 | return [path, filesize(br.length), br.length]; 28 | }); 29 | 30 | for await (const entry of entryPromises) console.log(...entry); 31 | })(); 32 | -------------------------------------------------------------------------------- /src/live-query.ts: -------------------------------------------------------------------------------- 1 | import { CHANGE_EVENT_NAME } from './constants.js'; 2 | import { UpdateEvent } from './wrap-idb-value.js'; 3 | 4 | function noop() {} 5 | 6 | export class LiveQuery { 7 | private isRunning: boolean = false; 8 | private shouldRerun: boolean = false; 9 | 10 | constructor( 11 | private readonly dependencies: string[], 12 | private readonly provider: () => Promise, 13 | ) { 14 | this.handleUpdateEvent = this.handleUpdateEvent.bind(this); 15 | addEventListener(CHANGE_EVENT_NAME, this.handleUpdateEvent); 16 | } 17 | 18 | private handleUpdateEvent(evt: Event) { 19 | if (!(evt instanceof UpdateEvent)) { 20 | return; 21 | } 22 | const isImpacted = this.dependencies.some((dependency) => { 23 | return evt.impactedStores.includes(dependency); 24 | }); 25 | if (!isImpacted) { 26 | return; 27 | } 28 | if (this.isRunning) { 29 | this.shouldRerun = true; 30 | } else { 31 | this.run(); 32 | } 33 | } 34 | 35 | public run() { 36 | this.isRunning = true; 37 | this.shouldRerun = false; 38 | 39 | return this.provider() 40 | .catch(noop) 41 | .finally(() => { 42 | this.isRunning = false; 43 | if (this.shouldRerun) { 44 | this.run(); 45 | } 46 | }); 47 | } 48 | 49 | public close() { 50 | removeEventListener(CHANGE_EVENT_NAME, this.handleUpdateEvent); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "synceddb", 3 | "version": "0.2.0", 4 | "description": "Sync your IndexedDB database with a remote REST API", 5 | "main": "./build/index.cjs", 6 | "module": "./build/index.js", 7 | "types": "./build/index.d.ts", 8 | "exports": { 9 | ".": { 10 | "types": "./build/index.d.ts", 11 | "module": "./build/index.js", 12 | "import": "./build/index.js", 13 | "default": "./build/index.cjs" 14 | }, 15 | "./with-async-ittr": { 16 | "types": "./with-async-ittr.d.ts", 17 | "module": "./with-async-ittr.js", 18 | "import": "./with-async-ittr.js", 19 | "default": "./with-async-ittr.cjs" 20 | }, 21 | "./build/*": "./build/*", 22 | "./package.json": "./package.json" 23 | }, 24 | "files": [ 25 | "build/**", 26 | "with-*", 27 | "CHANGELOG.md" 28 | ], 29 | "type": "module", 30 | "scripts": { 31 | "build": "PRODUCTION=1 rollup -c && node --experimental-modules lib/size-report.mjs", 32 | "dev": "rollup -c --watch", 33 | "prepack": "npm run build" 34 | }, 35 | "repository": { 36 | "type": "git", 37 | "url": "git://github.com/darrachequesne/synceddb.git" 38 | }, 39 | "author": "Damien Arrachequesne", 40 | "license": "ISC", 41 | "devDependencies": { 42 | "@rollup/plugin-commonjs": "^22.0.0", 43 | "@rollup/plugin-node-resolve": "^13.3.0", 44 | "@types/chai": "^4.2.22", 45 | "@types/estree": "^0.0.51", 46 | "@types/mocha": "^9.0.0", 47 | "chai": "^4.3.4", 48 | "conditional-type-checks": "^1.0.5", 49 | "del": "^6.0.0", 50 | "filesize": "^9.0.8", 51 | "glob": "^8.0.3", 52 | "mocha": "^10.0.0", 53 | "prettier": "^2.4.1", 54 | "rollup": "^2.75.6", 55 | "rollup-plugin-terser": "^7.0.2", 56 | "tsd": "^0.21.0", 57 | "typescript": "^4.7.3" 58 | }, 59 | "keywords": [ 60 | "indexeddb", 61 | "idb", 62 | "offline-first" 63 | ] 64 | } 65 | -------------------------------------------------------------------------------- /src/database-extras.ts: -------------------------------------------------------------------------------- 1 | import { Func } from './util.js'; 2 | import { replaceTraps } from './wrap-idb-value.js'; 3 | import { IDBPDatabase, IDBPIndex } from './entry.js'; 4 | 5 | const readMethods = ['get', 'getKey', 'getAll', 'getAllKeys', 'count']; 6 | const writeMethods = ['put', 'add', 'delete', 'clear']; 7 | const cachedMethods = new Map(); 8 | 9 | function getMethod( 10 | target: any, 11 | prop: string | number | symbol, 12 | ): Func | undefined { 13 | if ( 14 | !( 15 | target instanceof IDBDatabase && 16 | !(prop in target) && 17 | typeof prop === 'string' 18 | ) 19 | ) { 20 | return; 21 | } 22 | 23 | if (cachedMethods.get(prop)) return cachedMethods.get(prop); 24 | 25 | const targetFuncName: string = prop.replace(/FromIndex$/, ''); 26 | const useIndex = prop !== targetFuncName; 27 | const isWrite = writeMethods.includes(targetFuncName); 28 | 29 | if ( 30 | // Bail if the target doesn't exist on the target. Eg, getAll isn't in Edge. 31 | !(targetFuncName in (useIndex ? IDBIndex : IDBObjectStore).prototype) || 32 | !(isWrite || readMethods.includes(targetFuncName)) 33 | ) { 34 | return; 35 | } 36 | 37 | const method = async function ( 38 | this: IDBPDatabase, 39 | storeName: string, 40 | ...args: any[] 41 | ) { 42 | // isWrite ? 'readwrite' : undefined gzipps better, but fails in Edge :( 43 | const tx = this.transaction(storeName, isWrite ? 'readwrite' : 'readonly'); 44 | let target: 45 | | typeof tx.store 46 | | IDBPIndex = 47 | tx.store; 48 | if (useIndex) target = target.index(args.shift()); 49 | 50 | // Must reject if op rejects. 51 | // If it's a write operation, must reject if tx.done rejects. 52 | // Must reject with op rejection first. 53 | // Must resolve with op value. 54 | // Must handle both promises (no unhandled rejections) 55 | return ( 56 | await Promise.all([ 57 | (target as any)[targetFuncName](...args), 58 | isWrite && tx.done, 59 | ]) 60 | )[0]; 61 | }; 62 | 63 | cachedMethods.set(prop, method); 64 | return method; 65 | } 66 | 67 | replaceTraps((oldTraps) => ({ 68 | ...oldTraps, 69 | get: (target, prop, receiver) => 70 | getMethod(target, prop) || oldTraps.get!(target, prop, receiver), 71 | has: (target, prop) => 72 | !!getMethod(target, prop) || oldTraps.has!(target, prop), 73 | })); 74 | -------------------------------------------------------------------------------- /test/live-query.ts: -------------------------------------------------------------------------------- 1 | import 'mocha/mocha'; 2 | import chai from 'chai/chai'; 3 | import { IDBPDatabase, LiveQuery } from '../src/'; 4 | import { 5 | deleteDatabase, 6 | openDBWithCustomSchema, 7 | sleep, 8 | CustomDBSchema, 9 | } from './utils'; 10 | 11 | const { assert } = chai; 12 | 13 | suite.only('LiveQuery', () => { 14 | let db: IDBPDatabase; 15 | let query: LiveQuery; 16 | let count: number; 17 | 18 | setup(async () => { 19 | db = await openDBWithCustomSchema(); 20 | 21 | count = 0; 22 | 23 | query = new LiveQuery(['object-store'], () => { 24 | count++; 25 | 26 | return Promise.resolve(42); 27 | }); 28 | }); 29 | 30 | teardown('Close DB', async () => { 31 | if (db) db.close(); 32 | query.close(); 33 | await deleteDatabase(); 34 | }); 35 | 36 | test('onupdate called after run', async () => { 37 | await query.run(); 38 | 39 | assert.equal(count, 1); 40 | }); 41 | 42 | test('onupdate called after readwrite transaction', async () => { 43 | await db.add('object-store', { 44 | id: 1, 45 | title: 'val1', 46 | date: new Date(), 47 | }); 48 | 49 | assert.equal(count, 1); 50 | }); 51 | 52 | test('onupdate called once after readwrite transaction', async () => { 53 | const transaction = db.transaction('object-store', 'readwrite'); 54 | 55 | transaction.store.add({ 56 | id: 1, 57 | title: 'val1', 58 | date: new Date(), 59 | }); 60 | 61 | transaction.store.put({ 62 | id: 2, 63 | title: 'val2', 64 | date: new Date(), 65 | }); 66 | 67 | transaction.store.delete(3); 68 | 69 | await transaction.done; 70 | await sleep(50); 71 | 72 | assert.equal(count, 1); 73 | }); 74 | 75 | test('onupdate not called after readonly transaction', async () => { 76 | await db.getAll('object-store'); 77 | 78 | assert.equal(count, 0); 79 | }); 80 | 81 | test('onupdate not called after operation on another store', async () => { 82 | await db.add('products', { 83 | code: '123', 84 | }); 85 | 86 | assert.equal(count, 0); 87 | }); 88 | 89 | test('onupdate not called after close', async () => { 90 | await db.add('object-store', { 91 | id: 1, 92 | title: 'val1', 93 | date: new Date(), 94 | }); 95 | 96 | assert.equal(count, 1); 97 | 98 | query.close(); 99 | 100 | await db.add('object-store', { 101 | id: 2, 102 | title: 'val2', 103 | date: new Date(), 104 | }); 105 | 106 | assert.equal(count, 1); 107 | }); 108 | }); 109 | -------------------------------------------------------------------------------- /src/async-iterators.ts: -------------------------------------------------------------------------------- 1 | import { instanceOfAny, Func } from './util.js'; 2 | import { replaceTraps, reverseTransformCache, unwrap } from './wrap-idb-value.js'; 3 | import { IDBPObjectStore, IDBPIndex, IDBPCursor } from './entry.js'; 4 | 5 | const advanceMethodProps = ['continue', 'continuePrimaryKey', 'advance']; 6 | const methodMap: { [s: string]: Func } = {}; 7 | const advanceResults = new WeakMap>(); 8 | const ittrProxiedCursorToOriginalProxy = new WeakMap(); 9 | 10 | const cursorIteratorTraps: ProxyHandler = { 11 | get(target, prop) { 12 | if (!advanceMethodProps.includes(prop as string)) return target[prop]; 13 | 14 | let cachedFunc = methodMap[prop as string]; 15 | 16 | if (!cachedFunc) { 17 | cachedFunc = methodMap[prop as string] = function ( 18 | this: IDBPCursor, 19 | ...args: any 20 | ) { 21 | advanceResults.set( 22 | this, 23 | (ittrProxiedCursorToOriginalProxy.get(this) as any)[prop](...args), 24 | ); 25 | }; 26 | } 27 | 28 | return cachedFunc; 29 | }, 30 | }; 31 | 32 | async function* iterate( 33 | this: IDBPObjectStore | IDBPIndex | IDBPCursor, 34 | ...args: any[] 35 | ): AsyncIterableIterator { 36 | // tslint:disable-next-line:no-this-assignment 37 | let cursor: typeof this | null = this; 38 | 39 | if (!(cursor instanceof IDBCursor)) { 40 | cursor = await (cursor as IDBPObjectStore | IDBPIndex).openCursor(...args); 41 | } 42 | 43 | if (!cursor) return; 44 | 45 | cursor = cursor as IDBPCursor; 46 | const proxiedCursor = new Proxy(cursor, cursorIteratorTraps); 47 | ittrProxiedCursorToOriginalProxy.set(proxiedCursor, cursor); 48 | // Map this double-proxy back to the original, so other cursor methods work. 49 | reverseTransformCache.set(proxiedCursor, unwrap(cursor)); 50 | 51 | while (cursor) { 52 | yield proxiedCursor; 53 | // If one of the advancing methods was not called, call continue(). 54 | cursor = await (advanceResults.get(proxiedCursor) || cursor.continue()); 55 | advanceResults.delete(proxiedCursor); 56 | } 57 | } 58 | 59 | function isIteratorProp(target: any, prop: number | string | symbol) { 60 | return ( 61 | (prop === Symbol.asyncIterator && 62 | instanceOfAny(target, [IDBIndex, IDBObjectStore, IDBCursor])) || 63 | (prop === 'iterate' && instanceOfAny(target, [IDBIndex, IDBObjectStore])) 64 | ); 65 | } 66 | 67 | replaceTraps((oldTraps) => ({ 68 | ...oldTraps, 69 | get(target, prop, receiver) { 70 | if (isIteratorProp(target, prop)) return iterate; 71 | return oldTraps.get!(target, prop, receiver); 72 | }, 73 | has(target, prop) { 74 | return isIteratorProp(target, prop) || oldTraps.has!(target, prop); 75 | }, 76 | })); 77 | -------------------------------------------------------------------------------- /test/sync-server.js: -------------------------------------------------------------------------------- 1 | import { createServer } from 'http'; 2 | 3 | const ITEMS = [ 4 | { 5 | id: 1, 6 | version: 1, 7 | label: 'lorem1', 8 | updatedAt: new Date('2000-01-01T00:00:00.000Z'), 9 | }, 10 | { 11 | id: 2, 12 | version: 1, 13 | label: 'lorem2', 14 | updatedAt: new Date('2000-01-02T00:00:00.000Z'), 15 | }, 16 | { 17 | id: 3, 18 | version: -1, 19 | label: 'this is a tombstone', 20 | updatedAt: new Date('2000-01-03T00:00:00.000Z'), 21 | }, 22 | ]; 23 | 24 | const PRODUCTS = [ 25 | { 26 | code: '123', 27 | version: 1, 28 | label: 'lorem1', 29 | lastUpdateDate: new Date('2000-02-01T00:00:00.000Z'), 30 | }, 31 | { 32 | code: '456', 33 | version: 1, 34 | label: 'lorem2', 35 | lastUpdateDate: new Date('2000-02-02T00:00:00.000Z'), 36 | }, 37 | ]; 38 | 39 | const server = createServer(async (req, res) => { 40 | res.setHeader('Access-Control-Allow-Origin', '*'); 41 | 42 | console.log(`${req.method} ${req.url}`); 43 | 44 | const url = new URL(`http://localhost${req.url}`); 45 | 46 | if (req.method === 'GET' && url.pathname === '/key-val-store/foo') { 47 | res.writeHead(200, { 'content-type': 'application/json' }); 48 | res.write( 49 | JSON.stringify({ 50 | version: 1, 51 | label: 'bar', 52 | }), 53 | ); 54 | return res.end(); 55 | } else if (req.method === 'PUT' && url.pathname === '/key-val-store/foo') { 56 | return res.writeHead(204).end(); 57 | } 58 | 59 | switch (req.method) { 60 | case 'OPTIONS': 61 | res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE'); 62 | res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); 63 | res.writeHead(204).end(); 64 | break; 65 | case 'GET': 66 | res.writeHead(200, { 'content-type': 'application/json' }); 67 | res.write( 68 | JSON.stringify({ 69 | data: req.url.startsWith('/company-products') ? PRODUCTS : ITEMS, 70 | hasMore: false, 71 | }), 72 | ); 73 | res.end(); 74 | break; 75 | case 'POST': 76 | res.writeHead(201).end(); 77 | break; 78 | case 'PUT': 79 | case 'DELETE': 80 | if (req.url === '/object-store/6') { 81 | res.writeHead(404).end(); 82 | } else if (req.url === '/object-store/7') { 83 | res.writeHead(409); 84 | res.write( 85 | JSON.stringify({ 86 | id: 6, 87 | version: 3, 88 | label: 'lorem6 remote', 89 | updatedAt: new Date('2000-01-06T00:00:00.000Z'), 90 | }), 91 | ); 92 | res.end(); 93 | } else { 94 | res.writeHead(204).end(); 95 | } 96 | break; 97 | default: 98 | res.writeHead(404).end(); 99 | } 100 | }); 101 | 102 | server.listen(4000); 103 | -------------------------------------------------------------------------------- /test/computed-stores.ts: -------------------------------------------------------------------------------- 1 | import 'mocha/mocha'; 2 | import chai from 'chai/chai'; 3 | import type { IDBPDatabase } from '../src/'; 4 | import { createComputedStore } from '../src/'; 5 | import { 6 | deleteDatabase, 7 | openDBWithCustomSchema, 8 | CustomDBSchema, 9 | } from './utils'; 10 | 11 | const { assert } = chai; 12 | 13 | suite.only('Computed stores', () => { 14 | let db: IDBPDatabase; 15 | 16 | setup(async () => { 17 | db = await openDBWithCustomSchema(); 18 | }); 19 | 20 | teardown('Close DB', async () => { 21 | if (db) db.close(); 22 | await deleteDatabase(); 23 | }); 24 | 25 | test('init computed store', async () => { 26 | await db.add('object-store', { 27 | id: 1, 28 | title: 'lorem', 29 | date: new Date(), 30 | }); 31 | 32 | await createComputedStore( 33 | db, 34 | 'object-store-computed', 35 | 'object-store', 36 | [], 37 | async (tx, change) => { 38 | assert.equal(change.operation, 'add'); 39 | assert.equal(change.storeName, 'object-store'); 40 | assert.equal(change.key, 1); 41 | assert.equal(change.value.title, 'lorem'); 42 | 43 | const computed = change.value; 44 | computed.title = computed.title.split('').reverse().join(''); 45 | tx.objectStore('object-store-computed').add(computed); 46 | }, 47 | ); 48 | 49 | const val = await db.get('object-store-computed', 1); 50 | 51 | assert.ok(val); 52 | assert.equal(val?.id, 1); 53 | assert.equal(val?.title, 'merol'); 54 | }); 55 | 56 | test('update computed store', async () => { 57 | await createComputedStore( 58 | db, 59 | 'object-store-computed', 60 | 'object-store', 61 | [], 62 | async (tx, change) => { 63 | const store = tx.objectStore('object-store-computed'); 64 | switch (change.operation) { 65 | case 'add': 66 | store.add(change.value); 67 | break; 68 | case 'put': 69 | store.put(change.value); 70 | break; 71 | case 'delete': 72 | store.delete(change.key); 73 | break; 74 | } 75 | }, 76 | ); 77 | 78 | await db.add('object-store', { 79 | id: 2, 80 | title: 'lorem', 81 | date: new Date(), 82 | }); 83 | 84 | const val = await db.get('object-store-computed', 2); 85 | 86 | assert.ok(val); 87 | assert.equal(val!.title, 'lorem'); 88 | 89 | await db.put('object-store', { 90 | id: 2, 91 | title: 'ipsum', 92 | date: new Date(), 93 | }); 94 | 95 | const val2 = await db.get('object-store-computed', 2); 96 | 97 | assert.ok(val2); 98 | assert.equal(val2!.title, 'ipsum'); 99 | 100 | await db.delete('object-store', 2); 101 | 102 | const val3 = await db.get('object-store-computed', 2); 103 | 104 | assert.notExists(val3); 105 | }); 106 | }); 107 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # History 2 | 3 | | Version | Release date | 4 | |--------------------------|--------------| 5 | | [0.2.0](#020-2025-04-23) | April 2025 | 6 | | [0.1.1](#011-2024-10-08) | October 2024 | 7 | | [0.1.0](#010-2024-10-07) | October 2024 | 8 | | [0.0.2](#002-2022-03-26) | March 2022 | 9 | | [0.0.1](#001-2022-03-25) | March 2022 | 10 | 11 | # Release notes 12 | 13 | ## [0.2.0](https://github.com/darrachequesne/synceddb/compare/0.1.1...0.2.0) (2025-04-23) 14 | 15 | Based on [`idb@7.0.2`](https://github.com/jakearchibald/idb/releases/tag/v7.0.2) (Jun 2022). 16 | 17 | ### Bug Fixes 18 | 19 | * fix cjs async ittr entry file ([32e66ec](https://github.com/darrachequesne/synceddb/commit/32e66ecf27e6a0e14ac3fecf0159f1a227ec971d)) (cherry-picked from origin) 20 | * **ts:** `moduleResolution: node12` compat ([a392065](https://github.com/darrachequesne/synceddb/commit/a39206507aa6731645e2fdbe2c1a3b814afa18df)) (cherry-picked from origin) 21 | 22 | 23 | ### Features 24 | 25 | * **ts:** add DB types to the SyncManager class ([393fe86](https://github.com/darrachequesne/synceddb/commit/393fe8630c4d832d3f1e2210677af99e10554c81)) 26 | * implement computed stores ([b25b03a](https://github.com/darrachequesne/synceddb/commit/b25b03a80839eead8d84c48e455f0ec3df123ed9)) 27 | 28 | 29 | 30 | ## [0.1.1](https://github.com/darrachequesne/synceddb/compare/0.1.0...0.1.1) (2024-10-08) 31 | 32 | 33 | ### Bug Fixes 34 | 35 | * include object stores without keyPath in the fetch loop ([66c927c](https://github.com/darrachequesne/synceddb/commit/66c927c442261f7b74106fd9520f22f1c0b279be)) 36 | 37 | 38 | 39 | ## [0.1.0](https://github.com/darrachequesne/synceddb/compare/0.0.2...0.1.0) (2024-10-07) 40 | 41 | 42 | ### Features 43 | 44 | * add support for object stores without keyPath ([b59b095](https://github.com/darrachequesne/synceddb/commit/b59b095326d7b71a86ce73f961cdac5b32db59d1)) 45 | * update the format of the default search params ([3ffd2f4](https://github.com/darrachequesne/synceddb/commit/3ffd2f4c441b7e44d2319e61b506e8dbb1664793)) 46 | 47 | 48 | ### BREAKING CHANGES 49 | 50 | * The format of the default search params is updated: 51 | 52 | Before: `?sort=updated_at:asc&size=100&after=2000-01-01T00:00:00.000Z,123` 53 | 54 | After: `?sort=updated_at:asc&size=100&after=2000-01-01T00:00:00.000Z&after_id=123` 55 | 56 | 57 | 58 | ## [0.0.2](https://github.com/darrachequesne/synceddb/compare/0.0.1...0.0.2) (2022-03-26) 59 | 60 | 61 | ### Bug Fixes 62 | 63 | * add missing Content-Type header ([6903318](https://github.com/darrachequesne/synceddb/commit/69033182d28a7948cf184f15aab999cd3f14020a)) 64 | * prevent infinite loop when pushing updates ([0a94d53](https://github.com/darrachequesne/synceddb/commit/0a94d53212a512873518efa52a46978eada75da5)) 65 | 66 | 67 | 68 | ## [0.0.1](https://github.com/darrachequesne/synceddb/releases/tag/0.0.1) (2022-03-25) 69 | 70 | Based on [`idb@7.0.0`](https://github.com/jakearchibald/idb/releases/tag/v7.0.0) (November 2021). 71 | 72 | ### Features 73 | 74 | * add SyncManager and LiveQuery features ([dab36fb](https://github.com/darrachequesne/idb/commit/dab36fb1000bc40d70988d5292f434601fa9fff0)) 75 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import { promises as fsp } from 'fs'; 2 | import { promisify } from 'util'; 3 | import { basename } from 'path'; 4 | 5 | import { terser } from 'rollup-plugin-terser'; 6 | import resolve from '@rollup/plugin-node-resolve'; 7 | import commonjs from '@rollup/plugin-commonjs'; 8 | import del from 'del'; 9 | import glob from 'glob'; 10 | 11 | import simpleTS from './lib/simple-ts'; 12 | 13 | const globP = promisify(glob); 14 | 15 | export default async function ({ watch }) { 16 | await del(['.ts-tmp', 'build', 'tmp']); 17 | 18 | const builds = []; 19 | 20 | // Main 21 | builds.push({ 22 | plugins: [simpleTS('test', { watch })], 23 | input: ['src/index.ts', 'src/async-iterators.ts'], 24 | output: [ 25 | { 26 | dir: 'build/', 27 | format: 'esm', 28 | entryFileNames: '[name].js', 29 | chunkFileNames: '[name].js', 30 | }, 31 | { 32 | dir: 'build/', 33 | format: 'cjs', 34 | entryFileNames: '[name].cjs', 35 | chunkFileNames: '[name].cjs', 36 | }, 37 | ], 38 | }); 39 | 40 | // Minified iife 41 | builds.push({ 42 | input: 'build/index.js', 43 | plugins: [ 44 | terser({ 45 | compress: { ecma: 2019 }, 46 | }), 47 | ], 48 | output: { 49 | file: 'build/umd.js', 50 | format: 'umd', 51 | esModule: false, 52 | name: 'synceddb', 53 | }, 54 | }); 55 | 56 | // Minified iife including iteration 57 | builds.push({ 58 | input: './with-async-ittr.js', 59 | plugins: [ 60 | terser({ 61 | compress: { ecma: 2019 }, 62 | }), 63 | ], 64 | output: { 65 | file: 'build/umd-with-async-ittr.js', 66 | format: 'umd', 67 | esModule: false, 68 | name: 'synceddb', 69 | }, 70 | }); 71 | 72 | // Tests 73 | if (!process.env.PRODUCTION) { 74 | builds.push({ 75 | plugins: [ 76 | simpleTS('test', { noBuild: true }), 77 | resolve(), 78 | commonjs(), 79 | { 80 | async generateBundle() { 81 | this.emitFile({ 82 | type: 'asset', 83 | source: await fsp.readFile('test/index.html'), 84 | fileName: 'index.html', 85 | }); 86 | }, 87 | }, 88 | ], 89 | input: [ 90 | 'test/index.ts', 91 | 'test/main.ts', 92 | 'test/open.ts', 93 | 'test/iterate.ts', 94 | 'test/sync-manager.ts', 95 | 'test/live-query.ts', 96 | 'test/computed-stores.ts', 97 | ], 98 | output: { 99 | dir: 'build/test', 100 | format: 'esm', 101 | }, 102 | }); 103 | } 104 | 105 | builds.push( 106 | ...(await globP('size-tests/*.js').then((paths) => 107 | paths.map((path) => ({ 108 | input: path, 109 | plugins: [ 110 | terser({ 111 | compress: { ecma: 2020 }, 112 | }), 113 | ], 114 | output: [ 115 | { 116 | file: `tmp/size-tests/${basename(path)}`, 117 | format: 'esm', 118 | }, 119 | ], 120 | })), 121 | )), 122 | ); 123 | 124 | return builds; 125 | } 126 | -------------------------------------------------------------------------------- /test/utils.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DBSchema, 3 | IDBPDatabase, 4 | openDB, 5 | DeleteDBCallbacks, 6 | deleteDB, 7 | } from '../src/'; 8 | 9 | export interface ObjectStoreValue { 10 | id: number; 11 | title: string; 12 | date: Date; 13 | } 14 | 15 | export interface TestDBSchema extends DBSchema { 16 | 'key-val-store': { 17 | key: string; 18 | value: number; 19 | }; 20 | 'object-store': { 21 | value: ObjectStoreValue; 22 | key: number; 23 | indexes: { date: Date; title: string }; 24 | }; 25 | } 26 | 27 | export interface CustomDBSchema extends TestDBSchema { 28 | products: { 29 | value: { 30 | code: string; 31 | }; 32 | key: string; 33 | }; 34 | 'object-store-computed': { 35 | value: ObjectStoreValue; 36 | key: number; 37 | }; 38 | } 39 | 40 | export const dbName = 'test-db'; 41 | let version = 0; 42 | 43 | export function getNextVersion(): number { 44 | version += 1; 45 | return version; 46 | } 47 | 48 | let dbWithSchemaCreated = false; 49 | 50 | export function openDBWithSchema(): Promise> { 51 | if (dbWithSchemaCreated) return openDB(dbName, version); 52 | dbWithSchemaCreated = true; 53 | return openDB(dbName, getNextVersion(), { 54 | upgrade(db) { 55 | db.createObjectStore('key-val-store'); 56 | const store = db.createObjectStore('object-store', { keyPath: 'id' }); 57 | store.createIndex('date', 'date'); 58 | store.createIndex('title', 'title'); 59 | }, 60 | }); 61 | } 62 | 63 | export function openDBWithCustomSchema(): Promise< 64 | IDBPDatabase 65 | > { 66 | if (dbWithSchemaCreated) return openDB(dbName, version); 67 | dbWithSchemaCreated = true; 68 | return openDB(dbName, getNextVersion(), { 69 | upgrade(db) { 70 | db.createObjectStore('key-val-store'); 71 | db.createObjectStore('object-store', { keyPath: 'id' }); 72 | db.createObjectStore('products', { keyPath: 'code' }); 73 | db.createObjectStore('object-store-computed', { keyPath: 'id' }); 74 | }, 75 | }); 76 | } 77 | 78 | let dbWithDataCreated = false; 79 | 80 | export async function openDBWithData() { 81 | if (dbWithDataCreated) return openDB(dbName, version); 82 | dbWithDataCreated = true; 83 | const db = await openDBWithSchema(); 84 | const tx = db.transaction(['key-val-store', 'object-store'], 'readwrite'); 85 | const keyStore = tx.objectStore('key-val-store'); 86 | const objStore = tx.objectStore('object-store'); 87 | keyStore.put(123, 'foo'); 88 | keyStore.put(456, 'bar'); 89 | keyStore.put(789, 'hello'); 90 | objStore.put({ 91 | id: 1, 92 | title: 'Article 1', 93 | date: new Date('2019-01-04'), 94 | }); 95 | objStore.put({ 96 | id: 2, 97 | title: 'Article 2', 98 | date: new Date('2019-01-03'), 99 | }); 100 | objStore.put({ 101 | id: 3, 102 | title: 'Article 3', 103 | date: new Date('2019-01-02'), 104 | }); 105 | objStore.put({ 106 | id: 4, 107 | title: 'Article 4', 108 | date: new Date('2019-01-01'), 109 | }); 110 | return db; 111 | } 112 | 113 | export function deleteDatabase(callbacks: DeleteDBCallbacks = {}) { 114 | version = 0; 115 | dbWithSchemaCreated = false; 116 | dbWithDataCreated = false; 117 | return deleteDB(dbName, callbacks); 118 | } 119 | 120 | export function sleep(duration: number) { 121 | return new Promise((resolve) => setTimeout(resolve, duration)); 122 | } 123 | -------------------------------------------------------------------------------- /lib/simple-ts.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2019 Google Inc. All Rights Reserved. 3 | * Licensed under the Apache License, Version 2.0 (the "License"); 4 | * you may not use this file except in compliance with the License. 5 | * You may obtain a copy of the License at 6 | * http://www.apache.org/licenses/LICENSE-2.0 7 | * Unless required by applicable law or agreed to in writing, software 8 | * distributed under the License is distributed on an "AS IS" BASIS, 9 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | * See the License for the specific language governing permissions and 11 | * limitations under the License. 12 | */ 13 | import { spawn } from 'child_process'; 14 | import { relative, join, parse } from 'path'; 15 | import { promises as fsp } from 'fs'; 16 | import { promisify } from 'util'; 17 | 18 | import * as ts from 'typescript'; 19 | import glob from 'glob'; 20 | 21 | const globP = promisify(glob); 22 | 23 | const extRe = /\.tsx?$/; 24 | 25 | function loadConfig(mainPath) { 26 | const fileName = ts.findConfigFile(mainPath, ts.sys.fileExists); 27 | if (!fileName) throw Error('tsconfig not found'); 28 | const text = ts.sys.readFile(fileName); 29 | const loadedConfig = ts.parseConfigFileTextToJson(fileName, text).config; 30 | const parsedTsConfig = ts.parseJsonConfigFileContent( 31 | loadedConfig, 32 | ts.sys, 33 | process.cwd(), 34 | undefined, 35 | fileName, 36 | ); 37 | return parsedTsConfig; 38 | } 39 | 40 | export default function simpleTS(mainPath, { noBuild, watch } = {}) { 41 | const config = loadConfig(mainPath); 42 | const args = ['-b', mainPath]; 43 | 44 | let done = Promise.resolve(); 45 | 46 | if (!noBuild) { 47 | done = new Promise((resolve) => { 48 | const proc = spawn('tsc', args, { 49 | stdio: 'inherit', 50 | }); 51 | 52 | proc.on('exit', (code) => { 53 | if (code !== 0) { 54 | throw Error('TypeScript build failed'); 55 | } 56 | resolve(); 57 | }); 58 | }); 59 | } 60 | 61 | if (!noBuild && watch) { 62 | done.then(() => { 63 | spawn('tsc', [...args, '--watch', '--preserveWatchOutput'], { 64 | stdio: 'inherit', 65 | }); 66 | }); 67 | } 68 | 69 | return { 70 | name: 'simple-ts', 71 | async buildStart() { 72 | await done; 73 | const matches = await globP(config.options.outDir + '/**/*.js'); 74 | for (const match of matches) this.addWatchFile(match); 75 | }, 76 | resolveId(id, importer) { 77 | // If there isn't an importer, it's an entry point, so we don't need to resolve it relative 78 | // to something. 79 | if (!importer) return null; 80 | 81 | const tsResolve = ts.resolveModuleName( 82 | id, 83 | importer, 84 | config.options, 85 | ts.sys, 86 | ); 87 | 88 | if ( 89 | // It didn't find anything 90 | !tsResolve.resolvedModule || 91 | // Or if it's linking to a definition file, it's something in node_modules, 92 | // or something local like css.d.ts 93 | tsResolve.resolvedModule.extension === '.d.ts' 94 | ) { 95 | return null; 96 | } 97 | return tsResolve.resolvedModule.resolvedFileName; 98 | }, 99 | async load(id) { 100 | if (!extRe.test(id)) return null; 101 | 102 | // Look for the JS equivalent in the tmp folder 103 | const basePath = join( 104 | config.options.outDir, 105 | relative(process.cwd(), id), 106 | ).replace(extRe, ''); 107 | 108 | const srcP = fsp.readFile(basePath + '.js', { encoding: 'utf8' }); 109 | 110 | // Also copy maps and definitions 111 | const assetExtensions = ['.d.ts']; 112 | 113 | await Promise.all( 114 | assetExtensions.map(async (extension) => { 115 | const fileName = basePath + extension; 116 | const source = await fsp.readFile(fileName); 117 | this.emitFile({ 118 | type: 'asset', 119 | source, 120 | fileName: parse(fileName).base, 121 | }); 122 | }), 123 | ); 124 | 125 | return srcP; 126 | }, 127 | }; 128 | } 129 | -------------------------------------------------------------------------------- /test/open.ts: -------------------------------------------------------------------------------- 1 | import 'mocha/mocha'; 2 | import chai from 'chai/chai'; 3 | import { openDB, IDBPDatabase, IDBPTransaction, wrap, unwrap } from '../src/'; 4 | import { assert as typeAssert, IsExact } from 'conditional-type-checks'; 5 | import { 6 | getNextVersion, 7 | TestDBSchema, 8 | dbName, 9 | openDBWithSchema, 10 | deleteDatabase, 11 | } from './utils'; 12 | 13 | const { assert } = chai; 14 | 15 | suite('openDb', () => { 16 | let db: IDBPDatabase; 17 | 18 | teardown('Close DB', () => { 19 | if (db) db.close(); 20 | }); 21 | 22 | test('upgrade', async () => { 23 | let upgradeRun = false; 24 | const version = getNextVersion(); 25 | db = (await openDB(dbName, version, { 26 | upgrade(db, oldVersion, newVersion, tx) { 27 | upgradeRun = true; 28 | 29 | typeAssert>>(true); 30 | assert.instanceOf(db, IDBDatabase, 'db instance'); 31 | 32 | assert.strictEqual(oldVersion, 0); 33 | assert.strictEqual(newVersion, version); 34 | 35 | typeAssert< 36 | IsExact< 37 | typeof tx, 38 | IDBPTransaction< 39 | TestDBSchema, 40 | ('key-val-store' | 'object-store')[], 41 | 'versionchange' 42 | > 43 | > 44 | >(true); 45 | assert.instanceOf(tx, IDBTransaction, 'db instance'); 46 | assert.strictEqual(tx.mode, 'versionchange', 'tx mode'); 47 | }, 48 | })) as IDBPDatabase; 49 | 50 | assert.isTrue(upgradeRun, 'upgrade run'); 51 | }); 52 | 53 | test('open without version - upgrade should not run', async () => { 54 | let upgradeRun = false; 55 | 56 | db = (await openDB(dbName, undefined, { 57 | upgrade(db, oldVersion, newVersion, tx) { 58 | upgradeRun = true; 59 | }, 60 | })) as IDBPDatabase; 61 | 62 | assert.isFalse(upgradeRun, 'upgrade not run'); 63 | assert.strictEqual(db.version, 1); 64 | }); 65 | 66 | test('open without version - database never existed', async () => { 67 | db = (await openDB(dbName)) as IDBPDatabase; 68 | 69 | assert.strictEqual(db.version, 1); 70 | }); 71 | 72 | test('open with undefined version - database never existed', async () => { 73 | db = (await openDB(dbName, undefined, {})) as IDBPDatabase; 74 | 75 | assert.strictEqual(db.version, 1); 76 | }); 77 | 78 | test('open without version - database previously created', async () => { 79 | const version = getNextVersion(); 80 | db = (await openDB(dbName, version)) as IDBPDatabase; 81 | db.close(); 82 | 83 | db = (await openDB(dbName)) as IDBPDatabase; 84 | 85 | assert.strictEqual(db.version, version); 86 | }); 87 | 88 | test('open with undefined version - database previously created', async () => { 89 | const version = getNextVersion(); 90 | db = (await openDB(dbName, version)) as IDBPDatabase; 91 | db.close(); 92 | 93 | db = (await openDB(dbName, undefined, {})) as IDBPDatabase; 94 | 95 | assert.strictEqual(db.version, version); 96 | }); 97 | 98 | test('upgrade - schemaless', async () => { 99 | let upgradeRun = false; 100 | const version = getNextVersion(); 101 | db = await openDB(dbName, version, { 102 | upgrade(db, oldVersion, newVersion, tx) { 103 | upgradeRun = true; 104 | typeAssert>(true); 105 | typeAssert< 106 | IsExact< 107 | typeof tx, 108 | IDBPTransaction 109 | > 110 | >(true); 111 | }, 112 | }); 113 | 114 | assert.isTrue(upgradeRun, 'upgrade run'); 115 | }); 116 | 117 | test('blocked and blocking', async () => { 118 | let blockedCalled = false; 119 | let blockingCalled = false; 120 | let newDbBlockedCalled = false; 121 | let newDbBlockingCalled = false; 122 | 123 | db = (await openDB(dbName, getNextVersion(), { 124 | blocked() { 125 | blockedCalled = true; 126 | }, 127 | blocking() { 128 | blockingCalled = true; 129 | // 'blocked' isn't called if older databases close once blocking fires. 130 | // Using set timeout so closing isn't immediate. 131 | setTimeout(() => db.close(), 0); 132 | }, 133 | })) as IDBPDatabase; 134 | 135 | assert.isFalse(blockedCalled); 136 | assert.isFalse(blockingCalled); 137 | 138 | db = (await openDB(dbName, getNextVersion(), { 139 | blocked() { 140 | newDbBlockedCalled = true; 141 | }, 142 | blocking() { 143 | newDbBlockingCalled = true; 144 | }, 145 | })) as IDBPDatabase; 146 | 147 | assert.isFalse(blockedCalled); 148 | assert.isTrue(blockingCalled); 149 | assert.isTrue(newDbBlockedCalled); 150 | assert.isFalse(newDbBlockingCalled); 151 | }); 152 | 153 | test('wrap', async () => { 154 | let wrappedRequest: Promise = 155 | Promise.resolve(undefined); 156 | 157 | // Let's do it the old fashioned way 158 | const idb = await new Promise(async (resolve) => { 159 | const request = indexedDB.open(dbName, getNextVersion()); 160 | wrappedRequest = wrap(request); 161 | request.addEventListener('success', () => resolve(request.result)); 162 | }); 163 | 164 | assert.instanceOf(wrappedRequest, Promise, 'Wrapped request type'); 165 | db = wrap(idb); 166 | 167 | typeAssert>(true); 168 | 169 | assert.instanceOf(db, IDBDatabase, 'DB type'); 170 | assert.property(db, 'getAllFromIndex', 'DB looks wrapped'); 171 | assert.strictEqual( 172 | db, 173 | await wrappedRequest, 174 | 'Wrapped request and wrapped db are same', 175 | ); 176 | }); 177 | 178 | test('unwrap', async () => { 179 | const openPromise = openDB(dbName, getNextVersion()); 180 | const request = unwrap(openPromise); 181 | 182 | typeAssert>(true); 183 | 184 | assert.instanceOf(request, IDBOpenDBRequest, 'Request type'); 185 | db = (await openPromise) as IDBPDatabase; 186 | const idb = unwrap(db); 187 | 188 | typeAssert>(true); 189 | 190 | assert.instanceOf(idb, IDBDatabase, 'DB type'); 191 | assert.isFalse('getAllFromIndex' in idb, 'DB looks unwrapped'); 192 | }); 193 | }); 194 | 195 | suite('deleteDb', () => { 196 | let db: IDBPDatabase; 197 | 198 | teardown('Close DB', () => { 199 | if (db) db.close(); 200 | }); 201 | 202 | test('deleteDb', async () => { 203 | db = (await openDBWithSchema()) as IDBPDatabase; 204 | assert.lengthOf(db.objectStoreNames, 3, 'DB has three stores'); 205 | db.close(); 206 | await deleteDatabase(); 207 | db = await openDB(dbName, getNextVersion()); 208 | assert.lengthOf(db.objectStoreNames, 0, 'DB has no stores'); 209 | }); 210 | 211 | test('blocked', async () => { 212 | let blockedCalled = false; 213 | let blockingCalled = false; 214 | let closeDbBlockedCalled = false; 215 | 216 | db = await openDB(dbName, getNextVersion(), { 217 | blocked() { 218 | blockedCalled = true; 219 | }, 220 | blocking() { 221 | blockingCalled = true; 222 | // 'blocked' isn't called if older databases close once blocking fires. 223 | // Using set timeout so closing isn't immediate. 224 | setTimeout(() => db.close(), 0); 225 | }, 226 | }); 227 | 228 | assert.isFalse(blockedCalled); 229 | assert.isFalse(blockingCalled); 230 | 231 | await deleteDatabase({ 232 | blocked() { 233 | closeDbBlockedCalled = true; 234 | }, 235 | }); 236 | 237 | assert.isFalse(blockedCalled); 238 | assert.isTrue(blockingCalled); 239 | assert.isTrue(closeDbBlockedCalled); 240 | }); 241 | }); 242 | -------------------------------------------------------------------------------- /test/iterate.ts: -------------------------------------------------------------------------------- 1 | // Since this library proxies IDB, I haven't retested all of IDB. I've tried to cover parts of the 2 | // library that behave differently to IDB, or may cause accidental differences. 3 | 4 | import 'mocha/mocha'; 5 | import chai from 'chai/chai'; 6 | import { IDBPDatabase, IDBPCursorWithValueIteratorValue } from '../src/'; 7 | import '../src/async-iterators'; 8 | import { assert as typeAssert, IsExact } from 'conditional-type-checks'; 9 | import { 10 | deleteDatabase, 11 | openDBWithData, 12 | TestDBSchema, 13 | ObjectStoreValue, 14 | } from './utils'; 15 | 16 | const { assert } = chai; 17 | 18 | suite('Async iterators', () => { 19 | let db: IDBPDatabase; 20 | 21 | teardown('Close DB', async () => { 22 | if (db) db.close(); 23 | await deleteDatabase(); 24 | }); 25 | 26 | test('object stores', async () => { 27 | const schemaDB = await openDBWithData(); 28 | db = schemaDB as IDBPDatabase; 29 | 30 | { 31 | const store = schemaDB.transaction('key-val-store').store; 32 | const keys = []; 33 | const values = []; 34 | 35 | assert.isTrue(Symbol.asyncIterator in store); 36 | 37 | for await (const cursor of store) { 38 | typeAssert< 39 | IsExact< 40 | typeof cursor, 41 | IDBPCursorWithValueIteratorValue< 42 | TestDBSchema, 43 | ['key-val-store'], 44 | 'key-val-store', 45 | unknown 46 | > 47 | > 48 | >(true); 49 | 50 | typeAssert>(true); 51 | 52 | typeAssert>(true); 53 | 54 | keys.push(cursor.key); 55 | values.push(cursor.value); 56 | } 57 | 58 | assert.deepEqual(values, [456, 123, 789], 'Correct values'); 59 | assert.deepEqual(keys, ['bar', 'foo', 'hello'], 'Correct keys'); 60 | } 61 | { 62 | const store = db.transaction('key-val-store').store; 63 | const keys = []; 64 | const values = []; 65 | 66 | for await (const cursor of store) { 67 | typeAssert< 68 | IsExact< 69 | typeof cursor, 70 | IDBPCursorWithValueIteratorValue< 71 | unknown, 72 | ['key-val-store'], 73 | 'key-val-store', 74 | unknown 75 | > 76 | > 77 | >(true); 78 | 79 | typeAssert>(true); 80 | 81 | typeAssert>(true); 82 | 83 | keys.push(cursor.key); 84 | values.push(cursor.value); 85 | } 86 | 87 | assert.deepEqual(values, [456, 123, 789], 'Correct values'); 88 | assert.deepEqual(keys, ['bar', 'foo', 'hello'], 'Correct keys'); 89 | } 90 | }); 91 | 92 | test('object stores iterate', async () => { 93 | const schemaDB = await openDBWithData(); 94 | db = schemaDB as IDBPDatabase; 95 | 96 | { 97 | const store = schemaDB.transaction('key-val-store').store; 98 | assert.property(store, 'iterate'); 99 | 100 | typeAssert< 101 | IsExact< 102 | Parameters[0], 103 | string | IDBKeyRange | undefined | null 104 | > 105 | >(true); 106 | 107 | for await (const _ of store.iterate('blah')) { 108 | assert.fail('This should not be called'); 109 | } 110 | } 111 | { 112 | const store = db.transaction('key-val-store').store; 113 | 114 | typeAssert< 115 | IsExact< 116 | Parameters[0], 117 | IDBValidKey | IDBKeyRange | undefined | null 118 | > 119 | >(true); 120 | 121 | for await (const _ of store.iterate('blah')) { 122 | assert.fail('This should not be called'); 123 | } 124 | } 125 | }); 126 | 127 | test('Can delete during iteration', async () => { 128 | const schemaDB = await openDBWithData(); 129 | db = schemaDB as IDBPDatabase; 130 | 131 | const tx = schemaDB.transaction('key-val-store', 'readwrite'); 132 | 133 | for await (const cursor of tx.store) { 134 | cursor.delete(); 135 | } 136 | 137 | assert.strictEqual(await schemaDB.count('key-val-store'), 0); 138 | }); 139 | 140 | test('index', async () => { 141 | const schemaDB = await openDBWithData(); 142 | db = schemaDB as IDBPDatabase; 143 | 144 | { 145 | const index = schemaDB.transaction('object-store').store.index('date'); 146 | const keys = []; 147 | const values = []; 148 | 149 | assert.isTrue(Symbol.asyncIterator in index); 150 | 151 | for await (const cursor of index) { 152 | typeAssert< 153 | IsExact< 154 | typeof cursor, 155 | IDBPCursorWithValueIteratorValue< 156 | TestDBSchema, 157 | ['object-store'], 158 | 'object-store', 159 | 'date' 160 | > 161 | > 162 | >(true); 163 | 164 | typeAssert>(true); 165 | 166 | typeAssert>(true); 167 | 168 | keys.push(cursor.key); 169 | values.push(cursor.value); 170 | } 171 | 172 | assert.deepEqual( 173 | values, 174 | [ 175 | { 176 | id: 4, 177 | title: 'Article 4', 178 | date: new Date('2019-01-01'), 179 | }, 180 | { 181 | id: 3, 182 | title: 'Article 3', 183 | date: new Date('2019-01-02'), 184 | }, 185 | { 186 | id: 2, 187 | title: 'Article 2', 188 | date: new Date('2019-01-03'), 189 | }, 190 | { 191 | id: 1, 192 | title: 'Article 1', 193 | date: new Date('2019-01-04'), 194 | }, 195 | ], 196 | 'Correct values', 197 | ); 198 | assert.deepEqual( 199 | keys, 200 | [ 201 | new Date('2019-01-01'), 202 | new Date('2019-01-02'), 203 | new Date('2019-01-03'), 204 | new Date('2019-01-04'), 205 | ], 206 | 'Correct keys', 207 | ); 208 | } 209 | { 210 | const index = db.transaction('object-store').store.index('title'); 211 | const keys = []; 212 | const values = []; 213 | 214 | assert.isTrue(Symbol.asyncIterator in index); 215 | 216 | for await (const cursor of index) { 217 | typeAssert< 218 | IsExact< 219 | typeof cursor, 220 | IDBPCursorWithValueIteratorValue< 221 | unknown, 222 | ['object-store'], 223 | 'object-store', 224 | 'title' 225 | > 226 | > 227 | >(true); 228 | 229 | typeAssert>(true); 230 | 231 | typeAssert>(true); 232 | 233 | keys.push(cursor.key); 234 | values.push(cursor.value); 235 | } 236 | 237 | assert.deepEqual( 238 | values, 239 | [ 240 | { 241 | id: 1, 242 | title: 'Article 1', 243 | date: new Date('2019-01-04'), 244 | }, 245 | { 246 | id: 2, 247 | title: 'Article 2', 248 | date: new Date('2019-01-03'), 249 | }, 250 | { 251 | id: 3, 252 | title: 'Article 3', 253 | date: new Date('2019-01-02'), 254 | }, 255 | { 256 | id: 4, 257 | title: 'Article 4', 258 | date: new Date('2019-01-01'), 259 | }, 260 | ], 261 | 'Correct values', 262 | ); 263 | assert.deepEqual( 264 | keys, 265 | ['Article 1', 'Article 2', 'Article 3', 'Article 4'], 266 | 'Correct keys', 267 | ); 268 | } 269 | }); 270 | 271 | test('index iterate', async () => { 272 | const schemaDB = await openDBWithData(); 273 | db = schemaDB as IDBPDatabase; 274 | 275 | { 276 | const index = schemaDB.transaction('object-store').store.index('date'); 277 | assert.property(index, 'iterate'); 278 | 279 | typeAssert< 280 | IsExact< 281 | Parameters[0], 282 | Date | IDBKeyRange | undefined | null 283 | > 284 | >(true); 285 | 286 | for await (const _ of index.iterate(new Date('2020-01-01'))) { 287 | assert.fail('This should not be called'); 288 | } 289 | } 290 | { 291 | const index = db.transaction('object-store').store.index('title'); 292 | assert.property(index, 'iterate'); 293 | 294 | typeAssert< 295 | IsExact< 296 | Parameters[0], 297 | IDBValidKey | IDBKeyRange | undefined | null 298 | > 299 | >(true); 300 | 301 | for await (const _ of index.iterate('foo')) { 302 | assert.fail('This should not be called'); 303 | } 304 | } 305 | }); 306 | 307 | test('cursor', async () => { 308 | const schemaDB = await openDBWithData(); 309 | db = schemaDB as IDBPDatabase; 310 | 311 | const store = schemaDB.transaction('key-val-store').store; 312 | const cursor = await store.openCursor(); 313 | 314 | if (!cursor) throw Error('expected cursor'); 315 | 316 | const keys = []; 317 | const values = []; 318 | 319 | assert.isTrue(Symbol.asyncIterator in cursor); 320 | 321 | for await (const cursorIter of cursor) { 322 | typeAssert< 323 | IsExact< 324 | typeof cursorIter, 325 | IDBPCursorWithValueIteratorValue< 326 | TestDBSchema, 327 | ['key-val-store'], 328 | 'key-val-store', 329 | unknown 330 | > 331 | > 332 | >(true); 333 | 334 | typeAssert>(true); 335 | 336 | typeAssert>(true); 337 | 338 | keys.push(cursorIter.key); 339 | values.push(cursorIter.value); 340 | } 341 | 342 | assert.deepEqual(values, [456, 123, 789], 'Correct values'); 343 | assert.deepEqual(keys, ['bar', 'foo', 'hello'], 'Correct keys'); 344 | }); 345 | }); 346 | -------------------------------------------------------------------------------- /test/sync-manager.ts: -------------------------------------------------------------------------------- 1 | import 'mocha/mocha'; 2 | import chai from 'chai/chai'; 3 | import { IDBPDatabase, SyncManager } from '../src/'; 4 | import { 5 | deleteDatabase, 6 | openDBWithCustomSchema, 7 | openDBWithSchema, 8 | TestDBSchema, 9 | } from './utils'; 10 | 11 | const { assert } = chai; 12 | const BASE_URL = 'http://localhost:4000'; 13 | const NO_TRACKING_FLAG = true; 14 | 15 | async function waitForFetchSuccess( 16 | manager: SyncManager, 17 | count: number = 1, 18 | ) { 19 | return new Promise((resolve) => { 20 | manager.onfetchsuccess = (_storeName, entities) => { 21 | if (--count === 0) { 22 | resolve(entities); 23 | } 24 | }; 25 | }); 26 | } 27 | 28 | async function waitForPushSuccess( 29 | manager: SyncManager, 30 | count: number = 1, 31 | ) { 32 | return new Promise((resolve) => { 33 | manager.onpushsuccess = (change) => { 34 | if (--count === 0) { 35 | resolve(change); 36 | } 37 | }; 38 | }); 39 | } 40 | 41 | suite.only('SyncManager', () => { 42 | let db: IDBPDatabase; 43 | let manager: SyncManager; 44 | 45 | teardown('Close DB', async () => { 46 | if (db) db.close(); 47 | if (manager) manager.stop(); 48 | await deleteDatabase(); 49 | }); 50 | 51 | suite('fetch changes', () => { 52 | test('no conflict', async () => { 53 | const db = (await openDBWithSchema()) as IDBPDatabase< 54 | | TestDBSchema 55 | | { 56 | _local_offsets: { 57 | key: string; 58 | value: any; 59 | }; 60 | _local_changes: { 61 | key: string; 62 | value: any; 63 | indexes: { 64 | 'storeName, key': string[]; 65 | }; 66 | }; 67 | } 68 | >; 69 | const manager = new SyncManager(db, BASE_URL, { 70 | withoutKeyPath: { 71 | 'key-val-store': [], 72 | }, 73 | }); 74 | 75 | manager.start(); 76 | await waitForFetchSuccess(manager); 77 | 78 | const itemCount = await db.count('object-store'); 79 | 80 | assert.equal(itemCount, 2); 81 | 82 | const item = await db.get('object-store', 1); 83 | 84 | assert.deepEqual(item, { 85 | id: 1, 86 | version: 1, 87 | label: 'lorem1', 88 | updatedAt: '2000-01-01T00:00:00.000Z', 89 | }); 90 | 91 | const offset = await db.get('_local_offsets', 'object-store'); 92 | 93 | assert.deepEqual(offset, { 94 | id: 3, 95 | updatedAt: '2000-01-03T00:00:00.000Z', 96 | }); 97 | 98 | const localChangesCount = await db.count('_local_changes'); 99 | 100 | assert.equal(localChangesCount, 0); 101 | 102 | manager.stop(); 103 | db.close(); 104 | }); 105 | 106 | test('conflict', async () => { 107 | const schemaDB = await openDBWithSchema(); 108 | db = schemaDB as IDBPDatabase; 109 | manager = new SyncManager(db, BASE_URL, { 110 | withoutKeyPath: { 111 | 'key-val-store': [], 112 | }, 113 | }); 114 | 115 | // untracked 116 | await db.add( 117 | 'object-store', 118 | { 119 | id: 2, 120 | version: 1, 121 | label: 'lorem2', 122 | updatedAt: '2000-01-02T00:00:00.000Z', 123 | }, 124 | undefined, 125 | // @ts-ignore 126 | NO_TRACKING_FLAG, 127 | ); 128 | 129 | await db.put('object-store', { 130 | id: 2, 131 | version: 1, 132 | label: 'lorem2 updated', 133 | updatedAt: '2000-01-02T00:00:00.000Z', 134 | }); 135 | 136 | manager.start(); 137 | await waitForPushSuccess(manager); 138 | 139 | const item = await db.get('object-store', 2); 140 | 141 | assert.deepEqual(item, { 142 | id: 2, 143 | version: 2, // version is incremented, but change from the server is ignored 144 | label: 'lorem2 updated', 145 | updatedAt: '2000-01-02T00:00:00.000Z', 146 | }); 147 | }); 148 | 149 | test('tombstone', async () => { 150 | const schemaDB = await openDBWithSchema(); 151 | db = schemaDB as IDBPDatabase; 152 | manager = new SyncManager(db, BASE_URL, { 153 | withoutKeyPath: { 154 | 'key-val-store': [], 155 | }, 156 | }); 157 | 158 | // untracked 159 | await db.add( 160 | 'object-store', 161 | { 162 | id: 3, 163 | version: 1, 164 | }, 165 | undefined, 166 | // @ts-ignore 167 | NO_TRACKING_FLAG, 168 | ); 169 | 170 | manager.start(); 171 | await waitForFetchSuccess(manager); 172 | 173 | const item = await db.get('object-store', 3); 174 | 175 | assert.isUndefined(item); 176 | 177 | const localChangesCount = await db.count('_local_changes'); 178 | 179 | assert.equal(localChangesCount, 0); 180 | }); 181 | 182 | test('custom keyPath and updatedAt attribute', async () => { 183 | const schemaDB = await openDBWithCustomSchema(); 184 | db = schemaDB as IDBPDatabase; 185 | manager = new SyncManager(db, BASE_URL, { 186 | buildPath: (operation, storeName, key) => { 187 | if (storeName !== 'products') { 188 | return; 189 | } 190 | return '/company-products' + (key ? '/' + key : ''); 191 | }, 192 | updatedAtAttribute: 'lastUpdateDate', 193 | withoutKeyPath: { 194 | 'key-val-store': [], 195 | }, 196 | }); 197 | 198 | manager.start(); 199 | await waitForFetchSuccess(manager, 3); 200 | 201 | const product = await db.get('products', '123'); 202 | 203 | assert.deepEqual(product, { 204 | code: '123', 205 | version: 1, 206 | label: 'lorem1', 207 | lastUpdateDate: '2000-02-01T00:00:00.000Z', 208 | }); 209 | 210 | const offset = await db.get('_local_offsets', 'products'); 211 | 212 | assert.deepEqual(offset, { 213 | id: '456', 214 | updatedAt: '2000-02-02T00:00:00.000Z', 215 | }); 216 | }); 217 | 218 | test('without keyPath', async () => { 219 | const schemaDB = await openDBWithSchema(); 220 | db = schemaDB as IDBPDatabase; 221 | manager = new SyncManager(db, BASE_URL, { 222 | withoutKeyPath: { 223 | 'key-val-store': ['foo'], 224 | }, 225 | buildPath: (_operation, storeName, key) => { 226 | if (storeName === 'key-val-store') { 227 | return `/${storeName}/${key}`; 228 | } 229 | return; 230 | }, 231 | }); 232 | 233 | manager.start(); 234 | 235 | await waitForFetchSuccess(manager); // object-store 236 | const items = await waitForFetchSuccess(manager); 237 | 238 | assert.equal(items.length, 1); 239 | assert.deepEqual(items[0], { 240 | version: 1, 241 | label: 'bar', 242 | }); 243 | 244 | const foo = await db.get('key-val-store', 'foo'); 245 | 246 | assert.deepEqual(foo, { 247 | version: 1, 248 | label: 'bar', 249 | }); 250 | }); 251 | }); 252 | 253 | suite('push changes', () => { 254 | test('add', async () => { 255 | const schemaDB = await openDBWithSchema(); 256 | db = schemaDB as IDBPDatabase; 257 | manager = new SyncManager(db, BASE_URL, { 258 | withoutKeyPath: { 259 | 'key-val-store': [], 260 | }, 261 | }); 262 | 263 | await db.add('object-store', { 264 | id: 3, 265 | label: 'lorem3', 266 | }); 267 | 268 | const item = await db.get('object-store', 3); 269 | 270 | assert.deepEqual(item, { 271 | id: 3, 272 | version: 1, 273 | label: 'lorem3', 274 | }); 275 | 276 | const change = await db.get('_local_changes', 1); 277 | 278 | assert.deepEqual(change, { 279 | operation: 'add', 280 | storeName: 'object-store', 281 | key: 3, 282 | value: { 283 | id: 3, 284 | version: 1, 285 | label: 'lorem3', 286 | }, 287 | }); 288 | 289 | manager.start(); 290 | await waitForPushSuccess(manager); 291 | 292 | const localChangesCount = await db.count('_local_changes'); 293 | 294 | assert.equal(localChangesCount, 0); 295 | }); 296 | 297 | test('put', async () => { 298 | const schemaDB = await openDBWithSchema(); 299 | db = schemaDB as IDBPDatabase; 300 | manager = new SyncManager(db, BASE_URL, { 301 | withoutKeyPath: { 302 | 'key-val-store': [], 303 | }, 304 | }); 305 | 306 | await db.add('object-store', { 307 | id: 4, 308 | label1: 'lorem4', 309 | label2: 'lorem4', 310 | }); 311 | 312 | await db.put('object-store', { 313 | id: 4, 314 | version: 1, 315 | label1: 'lorem4', 316 | label2: 'lorem5', 317 | }); 318 | 319 | const change = await db.get('_local_changes', 2); 320 | 321 | assert.deepEqual(change, { 322 | operation: 'put', 323 | storeName: 'object-store', 324 | key: 4, 325 | value: { 326 | id: 4, 327 | version: 2, 328 | label1: 'lorem4', 329 | label2: 'lorem5', 330 | }, 331 | }); 332 | 333 | manager.start(); 334 | await waitForPushSuccess(manager, 2); 335 | 336 | const localChangesCount = await db.count('_local_changes'); 337 | 338 | assert.equal(localChangesCount, 0); 339 | }); 340 | 341 | test('put (discard local)', async () => { 342 | const schemaDB = await openDBWithSchema(); 343 | db = schemaDB as IDBPDatabase; 344 | manager = new SyncManager(db, BASE_URL, { 345 | withoutKeyPath: { 346 | 'key-val-store': [], 347 | }, 348 | }); 349 | 350 | await db.put('object-store', { 351 | id: 6, 352 | version: 1, 353 | }); 354 | 355 | const pushError = new Promise((resolve) => { 356 | manager.onpusherror = ( 357 | change, 358 | response, 359 | retryAfter, 360 | discardLocalChange, 361 | ) => { 362 | assert.equal(response.status, 404); 363 | discardLocalChange(); 364 | resolve(); 365 | }; 366 | }); 367 | 368 | manager.start(); 369 | 370 | await pushError; 371 | 372 | const localChangesCount = await db.count('_local_changes'); 373 | 374 | assert.equal(localChangesCount, 0); 375 | }); 376 | 377 | test('put (override remote)', async () => { 378 | const schemaDB = await openDBWithSchema(); 379 | db = schemaDB as IDBPDatabase; 380 | manager = new SyncManager(db, BASE_URL, { 381 | withoutKeyPath: { 382 | 'key-val-store': [], 383 | }, 384 | }); 385 | 386 | await db.put('object-store', { 387 | id: 7, 388 | version: 1, 389 | }); 390 | 391 | const pushError = new Promise((resolve) => { 392 | manager.onpusherror = ( 393 | change, 394 | response, 395 | retryAfter, 396 | discardLocalChange, 397 | overrideRemoteChange, 398 | ) => { 399 | assert.equal(response.status, 409); 400 | overrideRemoteChange(change.value); 401 | resolve(); 402 | }; 403 | }); 404 | 405 | manager.start(); 406 | 407 | await pushError; 408 | 409 | const localChangesCount = await db.count('_local_changes'); 410 | 411 | assert.equal(localChangesCount, 1); 412 | }); 413 | 414 | test('delete', async () => { 415 | const schemaDB = await openDBWithSchema(); 416 | db = schemaDB as IDBPDatabase; 417 | manager = new SyncManager(db, BASE_URL, { 418 | withoutKeyPath: { 419 | 'key-val-store': [], 420 | }, 421 | }); 422 | 423 | await db.add('object-store', { 424 | id: 4, 425 | label1: 'lorem4', 426 | label2: 'lorem4', 427 | }); 428 | 429 | await db.delete('object-store', 4); 430 | 431 | const change = await db.get('_local_changes', 2); 432 | 433 | assert.deepEqual(change, { 434 | operation: 'delete', 435 | storeName: 'object-store', 436 | key: 4, 437 | }); 438 | 439 | manager.start(); 440 | await waitForPushSuccess(manager, 2); 441 | 442 | const localChangesCount = await db.count('_local_changes'); 443 | 444 | assert.equal(localChangesCount, 0); 445 | }); 446 | 447 | test('clear', async () => { 448 | const schemaDB = await openDBWithSchema(); 449 | db = schemaDB as IDBPDatabase; 450 | 451 | // untracked 452 | await db.add( 453 | 'object-store', 454 | { 455 | id: 1, 456 | }, 457 | undefined, 458 | // @ts-ignore 459 | NO_TRACKING_FLAG, 460 | ); 461 | 462 | // untracked 463 | await db.add( 464 | 'object-store', 465 | { 466 | id: 2, 467 | }, 468 | undefined, 469 | // @ts-ignore 470 | NO_TRACKING_FLAG, 471 | ); 472 | 473 | await db.clear('object-store'); 474 | 475 | const localChangesCount = await db.count('_local_changes'); 476 | 477 | assert.equal(localChangesCount, 2); 478 | 479 | const change1 = await db.get('_local_changes', 1); 480 | 481 | assert.deepEqual(change1, { 482 | operation: 'delete', 483 | storeName: 'object-store', 484 | key: 1, 485 | }); 486 | 487 | const change2 = await db.get('_local_changes', 2); 488 | 489 | assert.deepEqual(change2, { 490 | operation: 'delete', 491 | storeName: 'object-store', 492 | key: 2, 493 | }); 494 | }); 495 | 496 | test('no tracking if transaction is aborted', async () => { 497 | const schemaDB = await openDBWithSchema(); 498 | db = schemaDB as IDBPDatabase; 499 | 500 | // untracked 501 | await db.add( 502 | 'object-store', 503 | { 504 | id: 5, 505 | }, 506 | undefined, 507 | // @ts-ignore 508 | NO_TRACKING_FLAG, 509 | ); 510 | 511 | try { 512 | await db.add('object-store', { 513 | id: 5, 514 | }); 515 | assert.fail('should not happen'); 516 | } catch (e) { 517 | // expected 518 | } 519 | 520 | const localChangesCount = await db.count('_local_changes'); 521 | 522 | assert.equal(localChangesCount, 0); 523 | }); 524 | 525 | test('has pending changes', async () => { 526 | const schemaDB = await openDBWithSchema(); 527 | db = schemaDB as IDBPDatabase; 528 | manager = new SyncManager(db, BASE_URL, { 529 | withoutKeyPath: { 530 | 'key-val-store': [], 531 | }, 532 | }); 533 | 534 | // untracked 535 | await db.add( 536 | 'object-store', 537 | { 538 | id: 1, 539 | }, 540 | undefined, 541 | // @ts-ignore 542 | NO_TRACKING_FLAG, 543 | ); 544 | 545 | await db.add('object-store', { 546 | id: 2, 547 | }); 548 | 549 | assert.equal(await manager.hasLocalChanges('object-store', 1), false); 550 | assert.equal(await manager.hasLocalChanges('object-store', 2), true); 551 | assert.equal(await manager.hasLocalChanges('object-store', 3), false); 552 | }); 553 | 554 | test('without keyPath', async () => { 555 | const schemaDB = await openDBWithSchema(); 556 | db = schemaDB as IDBPDatabase; 557 | manager = new SyncManager(db, BASE_URL, { 558 | withoutKeyPath: { 559 | 'key-val-store': ['foo'], 560 | }, 561 | buildPath: (operation, storeName, key) => { 562 | if (storeName === 'key-val-store') { 563 | return `/${storeName}/${key}`; 564 | } 565 | return; 566 | }, 567 | }); 568 | 569 | await db.put( 570 | 'key-val-store', 571 | { 572 | label: 'baz', 573 | }, 574 | 'bar', 575 | ); 576 | 577 | manager.start(); 578 | 579 | const change = await waitForPushSuccess(manager); 580 | 581 | assert.equal(change.key, 'bar'); 582 | 583 | const localChangesCount = await db.count('_local_changes'); 584 | 585 | assert.equal(localChangesCount, 0); 586 | }); 587 | }); 588 | }); 589 | -------------------------------------------------------------------------------- /src/wrap-idb-value.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | DBSchema, 3 | IDBPCursor, 4 | IDBPCursorWithValue, 5 | IDBPDatabase, 6 | IDBPIndex, 7 | IDBPObjectStore, 8 | IDBPTransaction, 9 | StoreNames, 10 | } from './entry.js'; 11 | import { Constructor, Func, instanceOfAny } from './util.js'; 12 | import { 13 | LOCAL_CHANGES_STORE, 14 | IGNORED_STORES, 15 | VERSION_ATTRIBUTE, 16 | CHANGE_EVENT_NAME, 17 | BROADCAST_CHANNEL_NAME, 18 | } from './constants.js'; 19 | 20 | let idbProxyableTypes: Constructor[]; 21 | let cursorAdvanceMethods: Func[]; 22 | 23 | // This is a function to prevent it throwing up in node environments. 24 | function getIdbProxyableTypes(): Constructor[] { 25 | return ( 26 | idbProxyableTypes || 27 | (idbProxyableTypes = [ 28 | IDBDatabase, 29 | IDBObjectStore, 30 | IDBIndex, 31 | IDBCursor, 32 | IDBTransaction, 33 | ]) 34 | ); 35 | } 36 | 37 | // This is a function to prevent it throwing up in node environments. 38 | function getCursorAdvanceMethods(): Func[] { 39 | return ( 40 | cursorAdvanceMethods || 41 | (cursorAdvanceMethods = [ 42 | IDBCursor.prototype.advance, 43 | IDBCursor.prototype.continue, 44 | IDBCursor.prototype.continuePrimaryKey, 45 | ]) 46 | ); 47 | } 48 | 49 | const writeMethods = [ 50 | IDBObjectStore.prototype.add, 51 | IDBObjectStore.prototype.put, 52 | IDBObjectStore.prototype.delete, 53 | IDBObjectStore.prototype.clear, 54 | ]; 55 | 56 | export class UpdateEvent extends Event { 57 | constructor(readonly impactedStores: string[]) { 58 | super(CHANGE_EVENT_NAME); 59 | } 60 | } 61 | 62 | let channel: BroadcastChannel; 63 | 64 | if (typeof BroadcastChannel === 'function') { 65 | channel = new BroadcastChannel(BROADCAST_CHANNEL_NAME); 66 | channel.onmessage = (evt) => { 67 | const impactedStores = evt.data as string[]; 68 | dispatchEvent(new UpdateEvent(impactedStores)); 69 | }; 70 | } 71 | 72 | const cursorRequestMap: WeakMap< 73 | IDBPCursor, 74 | IDBRequest 75 | > = new WeakMap(); 76 | const transactionDoneMap: WeakMap< 77 | IDBTransaction, 78 | Promise 79 | > = new WeakMap(); 80 | const transactionStoreNamesMap: WeakMap = 81 | new WeakMap(); 82 | const transformCache = new WeakMap(); 83 | export const reverseTransformCache = new WeakMap(); 84 | 85 | function promisifyRequest(request: IDBRequest): Promise { 86 | const promise = new Promise((resolve, reject) => { 87 | const unlisten = () => { 88 | request.removeEventListener('success', success); 89 | request.removeEventListener('error', error); 90 | }; 91 | const success = () => { 92 | resolve(wrap(request.result as any) as any); 93 | unlisten(); 94 | }; 95 | const error = () => { 96 | reject(request.error); 97 | unlisten(); 98 | }; 99 | request.addEventListener('success', success); 100 | request.addEventListener('error', error); 101 | }); 102 | 103 | promise 104 | .then((value) => { 105 | // Since cursoring reuses the IDBRequest (*sigh*), we cache it for later retrieval 106 | // (see wrapFunction). 107 | if (value instanceof IDBCursor) { 108 | cursorRequestMap.set( 109 | value as unknown as IDBPCursor, 110 | request as unknown as IDBRequest, 111 | ); 112 | } 113 | // Catching to avoid "Uncaught Promise exceptions" 114 | }) 115 | .catch(() => {}); 116 | 117 | // This mapping exists in reverseTransformCache but doesn't doesn't exist in transformCache. This 118 | // is because we create many promises from a single IDBRequest. 119 | reverseTransformCache.set(promise, request); 120 | return promise; 121 | } 122 | 123 | function cacheDonePromiseForTransaction(tx: IDBTransaction): void { 124 | // Early bail if we've already created a done promise for this transaction. 125 | if (transactionDoneMap.has(tx)) return; 126 | 127 | const done = new Promise((resolve, reject) => { 128 | const unlisten = () => { 129 | tx.removeEventListener('complete', complete); 130 | tx.removeEventListener('error', error); 131 | tx.removeEventListener('abort', error); 132 | }; 133 | const complete = () => { 134 | resolve(); 135 | unlisten(); 136 | }; 137 | const error = () => { 138 | reject(tx.error || new DOMException('AbortError', 'AbortError')); 139 | unlisten(); 140 | }; 141 | tx.addEventListener('complete', complete); 142 | tx.addEventListener('error', error); 143 | tx.addEventListener('abort', error); 144 | }); 145 | 146 | // Cache it for later retrieval. 147 | transactionDoneMap.set(tx, done); 148 | } 149 | 150 | let idbProxyTraps: ProxyHandler = { 151 | get(target, prop, receiver) { 152 | if (target instanceof IDBTransaction) { 153 | // Special handling for transaction.done. 154 | if (prop === 'done') return transactionDoneMap.get(target); 155 | // Polyfill for objectStoreNames because of Edge. 156 | if (prop === 'objectStoreNames') { 157 | return target.objectStoreNames || transactionStoreNamesMap.get(target); 158 | } 159 | // Make tx.store return the only store in the transaction, or undefined if there are many. 160 | if (prop === 'store') { 161 | const storeNames = 162 | receiver._initialStoreNames || receiver.objectStoreNames; 163 | return storeNames.length === 1 164 | ? receiver.objectStore(storeNames[0]) 165 | : undefined; 166 | } 167 | } 168 | // Else transform whatever we get back. 169 | return wrap(target[prop]); 170 | }, 171 | set(target, prop, value) { 172 | target[prop] = value; 173 | return true; 174 | }, 175 | has(target, prop) { 176 | if ( 177 | target instanceof IDBTransaction && 178 | (prop === 'done' || prop === 'store') 179 | ) { 180 | return true; 181 | } 182 | return prop in target; 183 | }, 184 | }; 185 | 186 | export function replaceTraps( 187 | callback: (currentTraps: ProxyHandler) => ProxyHandler, 188 | ): void { 189 | idbProxyTraps = callback(idbProxyTraps); 190 | } 191 | 192 | interface Change { 193 | operation: 'add' | 'put' | 'delete'; 194 | storeName: StoreNames; 195 | key: any; 196 | value?: any; 197 | } 198 | 199 | type ChangeHandler = ( 200 | tx: IDBPTransaction[], 'readwrite'>, 201 | change: Change, 202 | ) => Promise; 203 | 204 | const computedStores = new Map< 205 | string, 206 | { storeNames: string[]; onChange: ChangeHandler } 207 | >(); 208 | 209 | export function isComputedStore(storeName: string) { 210 | return computedStores.has(storeName); 211 | } 212 | 213 | export async function createComputedStore( 214 | db: IDBPDatabase, 215 | computedStoreName: StoreNames, 216 | mainStoreName: StoreNames, 217 | secondaryStoreNames: Array>, 218 | onChange: ChangeHandler, 219 | ): Promise { 220 | // @ts-expect-error 221 | computedStores.set(computedStoreName, { 222 | storeNames: [mainStoreName, ...secondaryStoreNames], 223 | onChange, 224 | }); 225 | 226 | const tx = db.transaction( 227 | [computedStoreName, mainStoreName, ...secondaryStoreNames], 228 | 'readwrite', 229 | ); 230 | 231 | const computedStore = tx.objectStore(computedStoreName); 232 | // @ts-expect-error the flag is used to prevent a partial init 233 | const initFlag = await computedStore.get('__init_complete'); 234 | 235 | if (initFlag) { 236 | return; 237 | } 238 | 239 | let cursor = await tx.objectStore(mainStoreName).openCursor(); 240 | while (cursor) { 241 | await onChange(tx, { 242 | storeName: mainStoreName, 243 | operation: 'add', 244 | key: cursor.key, 245 | value: cursor.value, 246 | }); 247 | cursor = await cursor.continue(); 248 | } 249 | // @ts-expect-error 250 | await computedStore.add({ 251 | [computedStore.keyPath as string]: '__init_complete', 252 | }); 253 | 254 | await tx.done; 255 | } 256 | 257 | function pushIfMissing(array: T[], e: T): void { 258 | if (!array.includes(e)) { 259 | array.push(e); 260 | } 261 | } 262 | 263 | function includesAny(array1: T[], array2: T[]): boolean { 264 | return array2.some((e) => array1.includes(e)); 265 | } 266 | 267 | function wrapFunction(func: T): Function { 268 | // Due to expected object equality (which is enforced by the caching in `wrap`), we 269 | // only create one new func per func. 270 | 271 | // Edge doesn't support objectStoreNames (booo), so we polyfill it here. 272 | if ( 273 | func === IDBDatabase.prototype.transaction && 274 | !('objectStoreNames' in IDBTransaction.prototype) 275 | ) { 276 | return function ( 277 | this: IDBPDatabase, 278 | storeNames: string | string[], 279 | ...args: any[] 280 | ) { 281 | const tx = func.call(unwrap(this), storeNames, ...args); 282 | transactionStoreNamesMap.set( 283 | tx, 284 | (storeNames as any).sort ? (storeNames as any[]).sort() : [storeNames], 285 | ); 286 | return wrap(tx); 287 | }; 288 | } 289 | 290 | // Cursor methods are special, as the behaviour is a little more different to standard IDB. In 291 | // IDB, you advance the cursor and wait for a new 'success' on the IDBRequest that gave you the 292 | // cursor. It's kinda like a promise that can resolve with many values. That doesn't make sense 293 | // with real promises, so each advance methods returns a new promise for the cursor object, or 294 | // undefined if the end of the cursor has been reached. 295 | if (getCursorAdvanceMethods().includes(func)) { 296 | return function (this: IDBPCursor, ...args: Parameters) { 297 | // Calling the original function with the proxy as 'this' causes ILLEGAL INVOCATION, so we use 298 | // the original object. 299 | func.apply(unwrap(this), args); 300 | return wrap(cursorRequestMap.get(this)!); 301 | }; 302 | } 303 | 304 | return function (this: any, ...args: Parameters) { 305 | if ( 306 | func === IDBDatabase.prototype.transaction && 307 | args[1] === 'readwrite' && 308 | args[0] !== LOCAL_CHANGES_STORE 309 | ) { 310 | if (!Array.isArray(args[0])) { 311 | args[0] = [args[0]]; 312 | } 313 | const initialStoreNames = args[0].slice(0); 314 | // transform `db.transaction("my-store", "readwrite")` into `db.transaction(["my-store", "_local_changes"], "readwrite")` 315 | pushIfMissing(args[0], LOCAL_CHANGES_STORE); 316 | 317 | for (const [computedStoreName, { storeNames }] of computedStores) { 318 | if (includesAny(storeNames, args[0])) { 319 | pushIfMissing(args[0], computedStoreName); 320 | for (const storeName of storeNames) { 321 | pushIfMissing(args[0], storeName); 322 | } 323 | } 324 | } 325 | 326 | // @ts-ignore 327 | const transaction = wrap( 328 | func.apply(unwrap(this), args), 329 | ) as IDBPTransaction; 330 | // @ts-expect-error store the initial values to resolve `transaction.store` later 331 | transaction._initialStoreNames = initialStoreNames; 332 | transaction.done.then(() => { 333 | const impactedStores = Array.from(transaction.objectStoreNames); 334 | // notify LiveQueries in the same browser tab 335 | dispatchEvent(new UpdateEvent(impactedStores)); 336 | // notify other browser tabs 337 | channel?.postMessage(impactedStores); 338 | }); 339 | return transaction; 340 | } 341 | // track any update into the _local_changes store 342 | if (writeMethods.includes(func)) { 343 | const storeName = this.name; 344 | 345 | if (!IGNORED_STORES.includes(storeName) && !isComputedStore(storeName)) { 346 | // updates from the server are not tracked, i.e. `store.add(value, key, true)` or `store.delete(key, true)` 347 | const isUpdateIgnored = 348 | args.length === 3 || 349 | (args.length === 2 && func === IDBObjectStore.prototype.delete); 350 | 351 | if (!isUpdateIgnored || computedStores.size > 0) { 352 | const store = this.transaction.objectStore(LOCAL_CHANGES_STORE); 353 | 354 | const computeChange = (callback: (change: Change) => void) => { 355 | const change: any = { 356 | operation: func.name, 357 | storeName, 358 | }; 359 | 360 | switch (func) { 361 | case IDBObjectStore.prototype.clear: 362 | change.operation = 'delete'; 363 | this.getAllKeys().then((keys: IDBValidKey[]) => { 364 | keys.forEach((key) => { 365 | change.key = key; 366 | callback(change); 367 | }); 368 | }); 369 | break; 370 | 371 | case IDBObjectStore.prototype.delete: 372 | change.key = args[0]; 373 | callback(change); 374 | break; 375 | 376 | default: // store the full entity 377 | // add or put 378 | const value = args[0]; 379 | const key = args[1] || value[this.keyPath]; 380 | 381 | change.key = key; 382 | change.value = value; 383 | callback(change); 384 | break; 385 | } 386 | }; 387 | 388 | computeChange((change) => { 389 | if (!isUpdateIgnored) { 390 | if (typeof change.value === 'object') { 391 | change.value[VERSION_ATTRIBUTE] = 392 | (change.value[VERSION_ATTRIBUTE] || 0) + 1; 393 | } 394 | store.add(change); 395 | } 396 | 397 | for (const [_, { storeNames, onChange }] of computedStores) { 398 | if (storeNames.includes(storeName)) { 399 | onChange(this.transaction, change); 400 | } 401 | } 402 | }); 403 | } 404 | } 405 | } 406 | // Calling the original function with the proxy as 'this' causes ILLEGAL INVOCATION, so we use 407 | // the original object. 408 | return wrap(func.apply(unwrap(this), args)); 409 | }; 410 | } 411 | 412 | function transformCachableValue(value: any): any { 413 | if (typeof value === 'function') return wrapFunction(value); 414 | 415 | // This doesn't return, it just creates a 'done' promise for the transaction, 416 | // which is later returned for transaction.done (see idbObjectHandler). 417 | if (value instanceof IDBTransaction) cacheDonePromiseForTransaction(value); 418 | 419 | if (instanceOfAny(value, getIdbProxyableTypes())) 420 | return new Proxy(value, idbProxyTraps); 421 | 422 | // Return the same value back if we're not going to transform it. 423 | return value; 424 | } 425 | 426 | /** 427 | * Enhance an IDB object with helpers. 428 | * 429 | * @param value The thing to enhance. 430 | */ 431 | export function wrap(value: IDBDatabase): IDBPDatabase; 432 | export function wrap(value: IDBIndex): IDBPIndex; 433 | export function wrap(value: IDBObjectStore): IDBPObjectStore; 434 | export function wrap(value: IDBTransaction): IDBPTransaction; 435 | export function wrap( 436 | value: IDBOpenDBRequest, 437 | ): Promise; 438 | export function wrap(value: IDBRequest): Promise; 439 | export function wrap(value: any): any { 440 | // We sometimes generate multiple promises from a single IDBRequest (eg when cursoring), because 441 | // IDB is weird and a single IDBRequest can yield many responses, so these can't be cached. 442 | if (value instanceof IDBRequest) return promisifyRequest(value); 443 | 444 | // If we've already transformed this value before, reuse the transformed value. 445 | // This is faster, but it also provides object equality. 446 | if (transformCache.has(value)) return transformCache.get(value); 447 | const newValue = transformCachableValue(value); 448 | 449 | // Not all types are transformed. 450 | // These may be primitive types, so they can't be WeakMap keys. 451 | if (newValue !== value) { 452 | transformCache.set(value, newValue); 453 | reverseTransformCache.set(newValue, value); 454 | } 455 | 456 | return newValue; 457 | } 458 | 459 | /** 460 | * Revert an enhanced IDB object to a plain old miserable IDB one. 461 | * 462 | * Will also revert a promise back to an IDBRequest. 463 | * 464 | * @param value The enhanced object to revert. 465 | */ 466 | interface Unwrap { 467 | (value: IDBPCursorWithValue): IDBCursorWithValue; 468 | (value: IDBPCursor): IDBCursor; 469 | (value: IDBPDatabase): IDBDatabase; 470 | (value: IDBPIndex): IDBIndex; 471 | (value: IDBPObjectStore): IDBObjectStore; 472 | (value: IDBPTransaction): IDBTransaction; 473 | (value: Promise>): IDBOpenDBRequest; 474 | (value: Promise): IDBOpenDBRequest; 475 | (value: Promise): IDBRequest; 476 | } 477 | export const unwrap: Unwrap = (value: any): any => 478 | reverseTransformCache.get(value); 479 | -------------------------------------------------------------------------------- /src/sync-manager.ts: -------------------------------------------------------------------------------- 1 | import type { DBSchema, IDBPDatabase } from './entry.js'; 2 | import { 3 | LOCAL_OFFSETS_STORE, 4 | IGNORED_STORES, 5 | LOCAL_CHANGES_STORE, 6 | VERSION_ATTRIBUTE, 7 | CHANGE_EVENT_NAME, 8 | } from './constants.js'; 9 | import { isComputedStore } from './wrap-idb-value.js'; 10 | 11 | const OPERATION_TO_METHOD = new Map([ 12 | ['add', 'POST'], 13 | ['put', 'PUT'], 14 | ['delete', 'DELETE'], 15 | ]); 16 | 17 | const MIN_DELAY_BETWEEN_REQUESTS = 50; 18 | const DEFAULT_RETRY_DELAY = 10000; 19 | const LOCK_TTL = 60 * 1000; 20 | const NO_TRACKING_FLAG = true; 21 | 22 | // format of entities in the _local_changes store 23 | interface Change { 24 | operation: 'add' | 'put' | 'delete'; 25 | storeName: string; 26 | key: IDBValidKey; 27 | value?: any; 28 | syncInProgressSince?: number; 29 | } 30 | 31 | // format of entities in the _local_offsets store 32 | interface Offset { 33 | id: IDBValidKey; 34 | updatedAt: any; 35 | } 36 | 37 | interface SyncManagerSchema { 38 | [LOCAL_OFFSETS_STORE]: { 39 | key: string; 40 | value: Offset; 41 | }; 42 | [LOCAL_CHANGES_STORE]: { 43 | key: string; 44 | value: Change; 45 | indexes: { 46 | 'storeName, key': string[]; 47 | }; 48 | }; 49 | } 50 | 51 | export interface SyncOptions { 52 | /** 53 | * Allow to override the request path for a given request 54 | */ 55 | buildPath: ( 56 | operation: 'fetch' | 'add' | 'put' | 'delete', 57 | storeName: string, 58 | key?: IDBValidKey, 59 | ) => string | undefined; 60 | /** 61 | * Additional options for all HTTP requests. 62 | * 63 | * Reference: https://developer.mozilla.org/en-US/docs/Web/API/fetch 64 | */ 65 | fetchOptions: any; 66 | /** 67 | * Allow to override the query params of the fetch requests. Defaults to "?sort=updated_at:asc&size=100&after=2000-01-01T00:00:00.000Z,123". 68 | * 69 | * @param storeName 70 | * @param offset 71 | */ 72 | buildFetchParams: (storeName: string, offset: Offset) => URLSearchParams; 73 | /** 74 | * The name of the attribute that indicates the last updated date of the entity. 75 | * 76 | * @default "updatedAt" 77 | */ 78 | updatedAtAttribute: string; 79 | /** 80 | * The number of ms between two fetch requests for a given store. 81 | * 82 | * @default 30000 83 | */ 84 | fetchInterval: number; 85 | /** 86 | * Entities without `keyPath`. 87 | * 88 | * @example 89 | * { 90 | * withoutKeyPath: { 91 | * common: [ 92 | * "user" 93 | * ] 94 | * } 95 | * } 96 | * 97 | * @see https://developer.mozilla.org/en-US/docs/Web/API/IDBObjectStore/keyPath 98 | */ 99 | withoutKeyPath: Record; 100 | } 101 | 102 | class FetchError extends Error { 103 | constructor(message: string, readonly response?: Response) { 104 | super('Error while fetching changes: ' + message); 105 | } 106 | } 107 | 108 | const defaultBuildPath = ( 109 | operation: 'fetch' | 'add' | 'put' | 'delete', 110 | storeName: string, 111 | key?: IDBValidKey, 112 | ) => { 113 | if (operation === 'fetch' || operation === 'add') { 114 | return `/${storeName}`; 115 | } else { 116 | return `/${storeName}/${key}`; 117 | } 118 | }; 119 | 120 | const defaultBuildFetchParams = (storeName: string, offset: Offset) => { 121 | const searchParams = new URLSearchParams({ 122 | sort: 'updated_at:asc', 123 | size: '100', 124 | }); 125 | if (offset) { 126 | searchParams.append('after', offset.updatedAt); 127 | searchParams.append('after_id', offset.id.toString()); 128 | } 129 | return searchParams; 130 | }; 131 | 132 | export class SyncManager { 133 | private readonly db: IDBPDatabase; 134 | private readonly opts: SyncOptions; 135 | private isOnline: boolean = true; 136 | 137 | private fetchLoop?: FetchLoop; 138 | private pushLoop?: PushLoop; 139 | 140 | constructor( 141 | db: IDBPDatabase, 142 | private readonly baseUrl: string, 143 | opts: Partial = {}, 144 | ) { 145 | this.db = db as IDBPDatabase; 146 | this.opts = { 147 | buildPath: opts.buildPath || defaultBuildPath, 148 | fetchOptions: opts.fetchOptions || {}, 149 | buildFetchParams: opts.buildFetchParams || defaultBuildFetchParams, 150 | updatedAtAttribute: opts.updatedAtAttribute || 'updatedAt', 151 | fetchInterval: opts.fetchInterval || 30_000, 152 | withoutKeyPath: opts.withoutKeyPath || {}, 153 | }; 154 | this.handleOnlineEvent = this.handleOnlineEvent.bind(this); 155 | this.handleOfflineEvent = this.handleOfflineEvent.bind(this); 156 | this.handleUpdateEvent = this.handleUpdateEvent.bind(this); 157 | } 158 | 159 | /** 160 | * Starts the sync process with the remote server 161 | */ 162 | public start() { 163 | const listenToNetworkEvents = 164 | typeof window !== undefined && 165 | !this.baseUrl.startsWith('http://localhost'); 166 | if (listenToNetworkEvents) { 167 | this.isOnline = navigator.onLine; 168 | window.addEventListener('online', this.handleOnlineEvent); 169 | window.addEventListener('offline', this.handleOfflineEvent); 170 | } 171 | addEventListener(CHANGE_EVENT_NAME, this.handleUpdateEvent); 172 | this.startLoops(); 173 | } 174 | 175 | /** 176 | * Stops the sync process 177 | */ 178 | public stop() { 179 | if (typeof window !== undefined) { 180 | window.removeEventListener('online', this.handleOnlineEvent); 181 | window.removeEventListener('offline', this.handleOfflineEvent); 182 | } 183 | removeEventListener(CHANGE_EVENT_NAME, this.handleUpdateEvent); 184 | this.cancelLoops(); 185 | } 186 | 187 | private handleOnlineEvent() { 188 | this.isOnline = true; 189 | this.startLoops(); 190 | } 191 | 192 | private startLoops() { 193 | if (!this.isOnline) { 194 | return; 195 | } 196 | if (!this.fetchLoop) { 197 | this.fetchLoop = new FetchLoop(this, this.db, this.baseUrl, this.opts); 198 | } 199 | if (!this.pushLoop) { 200 | this.pushLoop = new PushLoop(this, this.db, this.baseUrl, this.opts); 201 | this.pushLoop.oncomplete = () => { 202 | this.pushLoop = undefined; 203 | }; 204 | } 205 | } 206 | 207 | private cancelLoops() { 208 | if (this.fetchLoop) { 209 | this.fetchLoop.cancel(); 210 | this.fetchLoop = undefined; 211 | } 212 | if (this.pushLoop) { 213 | this.pushLoop.cancel(); 214 | this.pushLoop = undefined; 215 | } 216 | } 217 | 218 | private handleOfflineEvent() { 219 | this.isOnline = false; 220 | this.cancelLoops(); 221 | } 222 | 223 | private handleUpdateEvent() { 224 | if (!this.pushLoop) { 225 | this.pushLoop = new PushLoop(this, this.db, this.baseUrl, this.opts); 226 | this.pushLoop.oncomplete = () => { 227 | this.pushLoop = undefined; 228 | }; 229 | } 230 | } 231 | 232 | /** 233 | * Clears the local stores 234 | */ 235 | public clear() { 236 | return Promise.all([ 237 | this.db.clear(LOCAL_OFFSETS_STORE), 238 | this.db.clear(LOCAL_CHANGES_STORE), 239 | ]); 240 | } 241 | 242 | /** 243 | * Returns whether a given entity currently has local changes that are not synced yet. 244 | * 245 | * @param storeName 246 | * @param key 247 | */ 248 | public hasLocalChanges( 249 | storeName: string, 250 | key: IDBValidKey, 251 | ): Promise { 252 | return this.db 253 | .countFromIndex(LOCAL_CHANGES_STORE, 'storeName, key', [storeName, key]) 254 | .then((count) => count > 0); 255 | } 256 | 257 | /** 258 | * Called after some entities are successfully fetched from the remote server. 259 | * 260 | * @param storeName 261 | * @param entities 262 | * @param hasMore 263 | */ 264 | public onfetchsuccess(storeName: string, entities: any[], hasMore: boolean) {} 265 | 266 | /** 267 | * Called when something goes wrong when fetching the changes from the remote server. 268 | * @param error 269 | */ 270 | public onfetcherror(error: FetchError) {} 271 | 272 | /** 273 | * Called after a change is successfully pushed to the remote server. 274 | * 275 | * @param change 276 | */ 277 | public onpushsuccess(change: Change) {} 278 | 279 | /** 280 | * Called when something goes wrong when pushing a change to the remote server. 281 | * 282 | * @param change 283 | * @param response 284 | * @param retryAfter 285 | * @param discardLocalChange 286 | * @param overrideRemoteChange 287 | */ 288 | public onpusherror( 289 | change: Change, 290 | response: Response, 291 | retryAfter: (delay: number) => void, 292 | discardLocalChange: () => void, 293 | overrideRemoteChange: (entity: any) => void, 294 | ) { 295 | switch (response.status) { 296 | case 403: 297 | case 404: 298 | return discardLocalChange(); 299 | case 409: 300 | response.json().then((content) => { 301 | const version = content[VERSION_ATTRIBUTE]; 302 | change.value[VERSION_ATTRIBUTE] = version + 1; 303 | overrideRemoteChange(change.value); 304 | }); 305 | break; 306 | default: 307 | return retryAfter(DEFAULT_RETRY_DELAY); 308 | } 309 | } 310 | } 311 | 312 | abstract class Loop { 313 | protected isRunning: boolean = true; 314 | 315 | constructor( 316 | // TODO improve types 317 | protected readonly manager: SyncManager, 318 | protected readonly db: IDBPDatabase, 319 | protected readonly baseUrl: string, 320 | protected readonly opts: SyncOptions, 321 | ) { 322 | this.run(); 323 | } 324 | 325 | abstract run(): void; 326 | 327 | public cancel() { 328 | this.isRunning = false; 329 | } 330 | } 331 | 332 | function sleep(duration: number) { 333 | return new Promise((resolve) => setTimeout(resolve, duration)); 334 | } 335 | 336 | class FetchLoop extends Loop { 337 | async run() { 338 | if (!this.isRunning) { 339 | return; 340 | } 341 | const storeNames = this.db.objectStoreNames; 342 | 343 | try { 344 | for (let storeName of storeNames) { 345 | if ( 346 | !IGNORED_STORES.includes(storeName) && 347 | !this.opts.withoutKeyPath[storeName] && 348 | !isComputedStore(storeName) 349 | ) { 350 | while (await this.fetchUpdates(storeName)) { 351 | await sleep(MIN_DELAY_BETWEEN_REQUESTS); 352 | } 353 | } 354 | } 355 | for (const storeName in this.opts.withoutKeyPath) { 356 | for (const key of this.opts.withoutKeyPath[storeName]) { 357 | await this.fetchUpdatesForKey(storeName, key); 358 | } 359 | } 360 | } catch (e) { 361 | this.manager.onfetcherror(e as Error); 362 | } 363 | 364 | setTimeout(() => this.run(), this.opts.fetchInterval); 365 | } 366 | 367 | private async fetchUpdates(storeName: string): Promise { 368 | const path = 369 | this.opts.buildPath('fetch', storeName) || 370 | defaultBuildPath('fetch', storeName); 371 | const url = this.baseUrl + path; 372 | const lastUpdatedEntity = await this.db.get(LOCAL_OFFSETS_STORE, storeName); 373 | const searchParams = this.opts.buildFetchParams( 374 | storeName, 375 | lastUpdatedEntity, 376 | ); 377 | 378 | let response; 379 | try { 380 | response = await fetch(`${url}?${searchParams}`, { 381 | ...this.opts.fetchOptions, 382 | }); 383 | } catch (e) { 384 | throw new FetchError((e as Error).message); 385 | } 386 | 387 | if (!response.ok) { 388 | throw new FetchError('unexpected response from server', response); 389 | } 390 | 391 | const content = await response.json(); 392 | 393 | if (!Array.isArray(content.data)) { 394 | throw new FetchError('invalid response format', response); 395 | } 396 | 397 | const items = content.data; 398 | 399 | if (items.length === 0) { 400 | this.manager.onfetchsuccess(storeName, items, content.hasMore); 401 | return false; 402 | } 403 | 404 | const transaction = this.db.transaction( 405 | [LOCAL_CHANGES_STORE, LOCAL_OFFSETS_STORE, storeName], 406 | 'readwrite', 407 | ); 408 | const store = transaction.objectStore(storeName); 409 | const keyPath = store.keyPath as string; 410 | const changeIndex = transaction 411 | .objectStore(LOCAL_CHANGES_STORE) 412 | .index('storeName, key'); 413 | 414 | for (const entity of items) { 415 | const hasLocalUpdates = await changeIndex.count([ 416 | storeName, 417 | entity[keyPath], 418 | ]); 419 | if (hasLocalUpdates) { 420 | continue; 421 | } 422 | const isTombstone = entity[VERSION_ATTRIBUTE] === -1; 423 | if (isTombstone) { 424 | // @ts-ignore 425 | store.delete(entity[store.keyPath], NO_TRACKING_FLAG); 426 | } else { 427 | // @ts-ignore 428 | store.put(entity, undefined, NO_TRACKING_FLAG); 429 | } 430 | } 431 | 432 | const lastEntity = items[items.length - 1]; 433 | 434 | transaction.objectStore(LOCAL_OFFSETS_STORE).put( 435 | { 436 | id: lastEntity[keyPath], 437 | updatedAt: lastEntity[this.opts.updatedAtAttribute], 438 | }, 439 | storeName, 440 | ); 441 | 442 | await transaction.done; 443 | 444 | const hasMore = !!content.hasMore; 445 | 446 | this.manager.onfetchsuccess(storeName, items, hasMore); 447 | 448 | return hasMore; 449 | } 450 | 451 | private async fetchUpdatesForKey( 452 | storeName: string, 453 | key: IDBValidKey, 454 | ): Promise { 455 | const path = 456 | this.opts.buildPath('fetch', storeName, key) || 457 | defaultBuildPath('fetch', storeName, key); 458 | 459 | let response; 460 | try { 461 | response = await fetch(`${this.baseUrl}${path}`, { 462 | ...this.opts.fetchOptions, 463 | }); 464 | } catch (e) { 465 | throw new FetchError((e as Error).message); 466 | } 467 | 468 | if (!response.ok) { 469 | throw new FetchError('unexpected response from server', response); 470 | } 471 | 472 | const item = await response.json(); 473 | 474 | const transaction = this.db.transaction( 475 | [LOCAL_CHANGES_STORE, storeName], 476 | 'readwrite', 477 | ); 478 | const store = transaction.objectStore(storeName); 479 | const changeIndex = transaction 480 | .objectStore(LOCAL_CHANGES_STORE) 481 | .index('storeName, key'); 482 | 483 | const [hasLocalUpdates, isUpToDate] = await Promise.all([ 484 | changeIndex.count([storeName, key]), 485 | store.get(key).then((current) => { 486 | return current && current.version === item.version; 487 | }), 488 | ]); 489 | 490 | if (hasLocalUpdates || isUpToDate) { 491 | return; 492 | } 493 | 494 | // @ts-ignore 495 | await store.put(item, key, NO_TRACKING_FLAG); 496 | 497 | this.manager.onfetchsuccess(storeName, [item], false); 498 | } 499 | } 500 | 501 | class PushLoop extends Loop { 502 | async run(): Promise { 503 | if (!this.isRunning) { 504 | return; 505 | } 506 | 507 | const transaction = this.db.transaction(LOCAL_CHANGES_STORE, 'readwrite'); 508 | const cursor = await transaction.store.openCursor(); 509 | 510 | const hasSomethingToPush = cursor && cursor.value; 511 | if (!hasSomethingToPush) { 512 | return this.oncomplete(); 513 | } 514 | 515 | const change = cursor!.value as Change; 516 | const syncInProgressSince = change.syncInProgressSince; 517 | const isSyncAlreadyInProgress = 518 | syncInProgressSince && Date.now() - syncInProgressSince < LOCK_TTL; 519 | if (isSyncAlreadyInProgress) { 520 | return this.oncomplete(); 521 | } 522 | 523 | change.syncInProgressSince = Date.now(); 524 | cursor!.update(change); 525 | 526 | const changeKey = cursor!.key; 527 | const { operation, storeName, key, value } = change; 528 | const path = 529 | this.opts.buildPath(operation, storeName, key) || 530 | defaultBuildPath(operation, storeName, key); 531 | const url = this.baseUrl + path; 532 | const method = OPERATION_TO_METHOD.get(operation); 533 | 534 | const rerunAfter = (delay: number) => { 535 | setTimeout(() => { 536 | this.run(); 537 | }, delay); 538 | }; 539 | 540 | const retryAfter = async (delay: number) => { 541 | delete change.syncInProgressSince; 542 | 543 | await this.db.put(LOCAL_CHANGES_STORE, change, changeKey); 544 | 545 | rerunAfter(delay); 546 | }; 547 | 548 | let response; 549 | try { 550 | const options = { 551 | method, 552 | headers: {}, 553 | ...this.opts.fetchOptions, 554 | }; 555 | if (value) { 556 | options.body = JSON.stringify(value); 557 | options.headers['Content-Type'] = 'application/json'; 558 | } 559 | response = await fetch(url, options); 560 | } catch (e) { 561 | return retryAfter(DEFAULT_RETRY_DELAY); 562 | } 563 | 564 | if (response.ok) { 565 | await this.db.delete(LOCAL_CHANGES_STORE, changeKey); 566 | 567 | this.manager.onpushsuccess(change); 568 | 569 | return rerunAfter(MIN_DELAY_BETWEEN_REQUESTS); 570 | } 571 | 572 | const discardLocalChange = async () => { 573 | await this.db.delete(LOCAL_CHANGES_STORE, changeKey); 574 | 575 | rerunAfter(MIN_DELAY_BETWEEN_REQUESTS); 576 | }; 577 | 578 | const overrideRemoteChange = async (updatedEntity: any) => { 579 | change.value = updatedEntity; 580 | delete change.syncInProgressSince; 581 | 582 | await this.db.put(LOCAL_CHANGES_STORE, change, changeKey); 583 | 584 | rerunAfter(MIN_DELAY_BETWEEN_REQUESTS); 585 | }; 586 | 587 | this.manager.onpusherror( 588 | change, 589 | response, 590 | retryAfter, 591 | discardLocalChange, 592 | overrideRemoteChange, 593 | ); 594 | } 595 | 596 | public oncomplete() {} 597 | } 598 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # IndexedDB with usability and remote syncing 2 | 3 | This is a fork of the awesome [`idb`](https://github.com/jakearchibald/idb) library, which adds the ability to sync an IndexedDB database with a remote REST API. 4 | 5 | ![Video of two clients syncing their IndexedDB database](assets/demo.gif) 6 | 7 | The source code for the example above can be found [here](https://github.com/darrachequesne/synceddb-todo-example). 8 | 9 | Bundle size: ~3.48 kB brotli'd 10 | 11 | Based on [`idb@7.0.2`](https://github.com/jakearchibald/idb/releases/tag/v7.0.2) (Jun 2022): [e04104a...HEAD](https://github.com/darrachequesne/synceddb/compare/e04104a...HEAD) 12 | 13 | **Table of content** 14 | 15 | 16 | * [Features](#features) 17 | * [All the usability improvements from the `idb` library](#all-the-usability-improvements-from-the-idb-library) 18 | * [Sync with a remote REST API](#sync-with-a-remote-rest-api) 19 | * [Auto-reloading queries](#auto-reloading-queries) 20 | * [Computed stores](#computed-stores) 21 | * [Disclaimer](#disclaimer) 22 | * [Installation](#installation) 23 | * [API](#api) 24 | * [SyncManager](#syncmanager) 25 | * [Options](#options) 26 | * [`fetchOptions`](#fetchoptions) 27 | * [`fetchInterval`](#fetchinterval) 28 | * [`buildPath`](#buildpath) 29 | * [`buildFetchParams`](#buildfetchparams) 30 | * [`updatedAtAttribute`](#updatedatattribute) 31 | * [`withoutKeyPath`](#withoutkeypath) 32 | * [Methods](#methods) 33 | * [`start()`](#start) 34 | * [`stop()`](#stop) 35 | * [`clear()`](#clear) 36 | * [`hasLocalChanges()`](#haslocalchanges) 37 | * [`onfetchsuccess`](#onfetchsuccess) 38 | * [`onfetcherror`](#onfetcherror) 39 | * [`onpushsuccess`](#onpushsuccess) 40 | * [`onpusherror`](#onpusherror) 41 | * [LiveQuery](#livequery) 42 | * [Example with React](#example-with-react) 43 | * [Example with Vue.js](#example-with-vuejs) 44 | * [`createComputedStore()`](#createcomputedstore) 45 | * [Expectations for the REST API](#expectations-for-the-rest-api) 46 | * [Fetching changes](#fetching-changes) 47 | * [Pushing changes](#pushing-changes) 48 | * [Alternatives](#alternatives) 49 | * [Miscellaneous](#miscellaneous) 50 | 51 | 52 | ## Features 53 | 54 | ### All the usability improvements from the `idb` library 55 | 56 | Since it is a fork of the [`idb`](https://github.com/jakearchibald/idb) library, `synceddb` shares the same Promise-based API: 57 | 58 | ```js 59 | import { openDB, SyncManager } from 'synceddb'; 60 | 61 | const db = await openDB('my-awesome-database'); 62 | 63 | const transaction = db.transaction('items', 'readwrite'); 64 | await transaction.store.add({ id: 1, label: 'Dagger' }); 65 | 66 | // short version 67 | await db.add('items', { id: 1, label: 'Dagger' }); 68 | ``` 69 | 70 | Async iterators are supported too (please notice the specific import): 71 | 72 | ```js 73 | import { openDB, SyncManager } from 'synceddb/with-async-ittr'; 74 | 75 | const tx = db.transaction('items'); 76 | 77 | for await (const cursor of tx.store) { 78 | // ... 79 | } 80 | ``` 81 | 82 | More information [here](https://github.com/jakearchibald/idb#api). 83 | 84 | ### Sync with a remote REST API 85 | 86 | Every change is tracked in a store. The [SyncManager](#syncmanager) then sync these changes with the remote REST API when the connection is available, making it easier to build offline-first applications. 87 | 88 | ```js 89 | import { openDB, SyncManager } from 'synceddb'; 90 | 91 | const db = await openDB('my-awesome-database'); 92 | const manager = new SyncManager(db, 'https://example.com'); 93 | 94 | manager.start(); 95 | 96 | // will result in the following HTTP request: POST /items 97 | await db.add('items', { id: 1, label: 'Dagger' }); 98 | 99 | // will result in the following HTTP request: DELETE /items/2 100 | await db.delete('items', 2); 101 | ``` 102 | 103 | See also: [Expectations for the REST API](#expectations-for-the-rest-api) 104 | 105 | ### Auto-reloading queries 106 | 107 | The [LiveQuery](#livequery) provides a way to run a query every time the underlying stores are updated: 108 | 109 | ```js 110 | import { openDB, LiveQuery } from 'synceddb'; 111 | 112 | const db = await openDB('my awesome database'); 113 | 114 | let result; 115 | 116 | const query = new LiveQuery(['items'], async () => { 117 | // result will be updated every time the 'items' store is modified 118 | result = await db.getAll('items'); 119 | }); 120 | 121 | // trigger the liveQuery 122 | await db.put('items', { id: 2, label: 'Long sword' }); 123 | 124 | // or manually run it 125 | await query.run(); 126 | ``` 127 | 128 | Inspired from [Dexie.js liveQuery](https://dexie.org/docs/liveQuery()). 129 | 130 | 131 | ### Computed stores 132 | 133 | A computed store is a bit like [a materialized view in PostgreSQL](https://www.postgresql.org/docs/current/rules-materializedviews.html), it is updated every time one of the source object stores is updated: 134 | 135 | ```js 136 | import { openDB, createComputedStore } from 'synceddb/with-async-ittr'; 137 | 138 | const db = await openDB('my awesome database'); 139 | 140 | await createComputedStore(db, 'invoices-with-customer', 'invoices', ['customers'], async (tx, change) => { 141 | const computedStore = tx.objectStore('invoices-with-customer'); 142 | 143 | if (change.storeName === 'invoices') { 144 | if (change.operation === 'add' || change.operation === 'put') { 145 | const invoice = change.value; 146 | // fetch the customer object 147 | invoice.customer = await tx.objectStore('customers').get(invoice.customerId); 148 | // update the computed store 149 | computedStore.put(invoice); 150 | } else { // change.operation === 'delete' 151 | computedStore.delete(change.key); 152 | } 153 | } 154 | 155 | if (change.storeName === 'customers') { 156 | if (change.operation === 'put') { 157 | const customer = change.value; 158 | // update all invoices with the given customer in the computed store 159 | for await (const cursor of computedStore.index('by-customer').iterate(change.key)) { 160 | const invoice = cursor.value; 161 | if (invoice.customerId === customer.id) { 162 | invoice.customer = customer; 163 | cursor.update(invoice); 164 | } 165 | } 166 | } 167 | } 168 | }); 169 | ``` 170 | 171 | This feature is great when you need to: 172 | 173 | - apply some filters on an aggregation of multiple object stores (no need to compute the JOIN in memory, and then apply the filters) 174 | - compute statistics over historical data (as the data is computed incrementally) 175 | 176 | 177 | ## Disclaimer 178 | 179 | - no version history 180 | 181 | Only the last version of each entity is kept on the client side. 182 | 183 | - basic conflict management 184 | 185 | The last write wins (though you can customize the behavior in the [`onpusherror`](#onpusherror) handler). 186 | 187 | ## Installation 188 | 189 | ```sh 190 | npm install synceddb 191 | ``` 192 | 193 | Then: 194 | 195 | ```js 196 | import { openDB, SyncManager, LiveQuery } from 'synceddb'; 197 | 198 | async function doDatabaseStuff() { 199 | const db = await openDB('my awesome database'); 200 | 201 | // sync your database with a remote server 202 | const manager = new SyncManager(db, 'https://example.com'); 203 | 204 | manager.start(); 205 | 206 | // create an auto-reloading query 207 | let result; 208 | const query = new LiveQuery(['items'], async () => { 209 | // result will be updated every time the 'items' store is modified 210 | result = await db.getAll('items'); 211 | }); 212 | } 213 | ``` 214 | 215 | ## API 216 | 217 | For database-related operations, please see the `idb` [documentation](https://github.com/jakearchibald/idb#api). 218 | 219 | ### SyncManager 220 | 221 | ```js 222 | import { openDB, SyncManager } from 'synceddb'; 223 | 224 | const db = await openDB('my-awesome-database'); 225 | const manager = new SyncManager(db, 'https://example.com'); 226 | 227 | manager.start(); 228 | ``` 229 | 230 | #### Options 231 | 232 | ##### `fetchOptions` 233 | 234 | Additional options for all HTTP requests. 235 | 236 | ```js 237 | import { openDB, SyncManager } from 'synceddb'; 238 | 239 | const db = await openDB('my-awesome-database'); 240 | const manager = new SyncManager(db, 'https://example.com', { 241 | fetchOptions: { 242 | headers: { 243 | 'accept': 'application/json' 244 | }, 245 | credentials: 'include' 246 | } 247 | }); 248 | 249 | manager.start(); 250 | ``` 251 | 252 | Reference: https://developer.mozilla.org/en-US/docs/Web/API/fetch 253 | 254 | ##### `fetchInterval` 255 | 256 | The number of ms between two fetch requests for a given store. 257 | 258 | Default value: `30000` 259 | 260 | ```js 261 | import { openDB, SyncManager } from 'synceddb'; 262 | 263 | const db = await openDB('my-awesome-database'); 264 | const manager = new SyncManager(db, 'https://example.com', { 265 | fetchInterval: 10000 266 | }); 267 | 268 | manager.start(); 269 | ``` 270 | 271 | ##### `buildPath` 272 | 273 | A function that allows to override the request path for a given request. 274 | 275 | ```js 276 | import { openDB, SyncManager } from 'synceddb'; 277 | 278 | const db = await openDB('my-awesome-database'); 279 | const manager = new SyncManager(db, 'https://example.com', { 280 | buildPath: (operation, storeName, key) => { 281 | if (storeName === 'my-local-store') { 282 | if (key) { 283 | return `/the-remote-store/${key[1]}`; 284 | } else { 285 | return '/the-remote-store/'; 286 | } 287 | } 288 | // defaults to `/${storeName}/${key}` 289 | } 290 | }); 291 | 292 | manager.start(); 293 | ``` 294 | 295 | ##### `buildFetchParams` 296 | 297 | A function that allows to override the query params of the fetch requests. 298 | 299 | Defaults to `?sort=updated_at:asc&size=100&after=2000-01-01T00:00:00.000Z,123`. 300 | 301 | ```js 302 | import { openDB, SyncManager } from 'synceddb'; 303 | 304 | const db = await openDB('my-awesome-database'); 305 | const manager = new SyncManager(db, 'https://example.com', { 306 | buildFetchParams: (storeName, offset) => { 307 | const searchParams = new URLSearchParams({ 308 | sort: '+updatedAt', 309 | size: '10', 310 | }); 311 | if (offset) { 312 | searchParams.append('after', `${offset.updatedAt}+${offset.id}`); 313 | } 314 | return searchParams; 315 | } 316 | }); 317 | 318 | manager.start(); 319 | ``` 320 | 321 | ##### `updatedAtAttribute` 322 | 323 | The name of the attribute that indicates the last updated date of the entity. 324 | 325 | Default value: `updatedAt` 326 | 327 | ```js 328 | import { openDB, SyncManager } from 'synceddb'; 329 | 330 | const db = await openDB('my-awesome-database'); 331 | const manager = new SyncManager(db, 'https://example.com', { 332 | updatedAtAttribute: 'lastUpdateDate' 333 | }); 334 | 335 | manager.start(); 336 | ``` 337 | 338 | ##### `withoutKeyPath` 339 | 340 | List entities from object stores without `keyPath`. 341 | 342 | ```js 343 | import { openDB, SyncManager } from 'synceddb'; 344 | 345 | const db = await openDB('my-awesome-database'); 346 | const manager = new SyncManager(db, 'https://example.com', { 347 | withoutKeyPath: { 348 | common: [ 349 | 'user', 350 | 'settings' 351 | ] 352 | }, 353 | buildPath: (_operation, storeName, key) => { 354 | if (storeName === 'common') { 355 | if (key === 'user') { 356 | return '/me'; 357 | } else if (key === 'settings') { 358 | return '/settings'; 359 | } 360 | } 361 | } 362 | }); 363 | 364 | manager.start(); 365 | 366 | await db.put('common', { firstName: 'john' }, 'user'); 367 | ``` 368 | 369 | #### Methods 370 | 371 | ##### `start()` 372 | 373 | Starts the sync process with the remote server. 374 | 375 | ```js 376 | import { openDB, SyncManager } from 'synceddb'; 377 | 378 | const db = await openDB('my-awesome-database'); 379 | const manager = new SyncManager(db, 'https://example.com'); 380 | 381 | manager.start(); 382 | ``` 383 | 384 | ##### `stop()` 385 | 386 | Stops the sync process. 387 | 388 | ```js 389 | import { openDB, SyncManager } from 'synceddb'; 390 | 391 | const db = await openDB('my-awesome-database'); 392 | const manager = new SyncManager(db, 'https://example.com'); 393 | 394 | manager.stop(); 395 | ``` 396 | 397 | ##### `clear()` 398 | 399 | Clears the local stores. 400 | 401 | ```js 402 | import { openDB, SyncManager } from 'synceddb'; 403 | 404 | const db = await openDB('my-awesome-database'); 405 | const manager = new SyncManager(db, 'https://example.com'); 406 | 407 | manager.clear(); 408 | ``` 409 | 410 | ##### `hasLocalChanges()` 411 | 412 | Returns whether a given entity currently has local changes that are not synced yet. 413 | 414 | ```js 415 | import { openDB, SyncManager } from 'synceddb'; 416 | 417 | const db = await openDB('my-awesome-database'); 418 | const manager = new SyncManager(db, 'https://example.com'); 419 | 420 | await db.put('items', { id: 1 }); 421 | 422 | const hasLocalChanges = await manager.hasLocalChanges('items', 1); // true 423 | ``` 424 | 425 | ##### `onfetchsuccess` 426 | 427 | Called after some entities are successfully fetched from the remote server. 428 | 429 | ```js 430 | import { openDB, SyncManager } from 'synceddb'; 431 | 432 | const db = await openDB('my-awesome-database'); 433 | const manager = new SyncManager(db, 'https://example.com'); 434 | 435 | manager.onfetchsuccess = (storeName, entities, hasMore) => { 436 | // ... 437 | } 438 | ``` 439 | 440 | ##### `onfetcherror` 441 | 442 | Called when something goes wrong when fetching the changes from the remote server. 443 | 444 | ```js 445 | import { openDB, SyncManager } from 'synceddb'; 446 | 447 | const db = await openDB('my-awesome-database'); 448 | const manager = new SyncManager(db, 'https://example.com'); 449 | 450 | manager.onfetcherror = (err) => { 451 | // ... 452 | } 453 | ``` 454 | 455 | ##### `onpushsuccess` 456 | 457 | Called after a change is successfully pushed to the remote server. 458 | 459 | ```js 460 | import { openDB, SyncManager } from 'synceddb'; 461 | 462 | const db = await openDB('my-awesome-database'); 463 | const manager = new SyncManager(db, 'https://example.com'); 464 | 465 | manager.onpushsuccess = ({ operation, storeName, key, value }) => { 466 | // ... 467 | } 468 | ``` 469 | 470 | ##### `onpusherror` 471 | 472 | Called when something goes wrong when pushing a change to the remote server. 473 | 474 | ```js 475 | import { openDB, SyncManager } from 'synceddb'; 476 | 477 | const db = await openDB('my-awesome-database'); 478 | const manager = new SyncManager(db, 'https://example.com'); 479 | 480 | manager.onpusherror = (change, response, retryAfter, discardLocalChange, overrideRemoteChange) => { 481 | // this is the default implementation 482 | switch (response.status) { 483 | case 403: 484 | case 404: 485 | return discardLocalChange(); 486 | case 409: 487 | // last write wins by default 488 | response.json().then((content) => { 489 | const version = content[VERSION_ATTRIBUTE]; 490 | change.value[VERSION_ATTRIBUTE] = version + 1; 491 | overrideRemoteChange(change.value); 492 | }); 493 | break; 494 | default: 495 | return retryAfter(DEFAULT_RETRY_DELAY); 496 | } 497 | } 498 | ``` 499 | 500 | ### LiveQuery 501 | 502 | The first argument is an array of stores. Every time one of these stores is updated, the function provided in the 2nd argument will be called. 503 | 504 | ```js 505 | import { openDB, LiveQuery } from 'synceddb'; 506 | 507 | const db = await openDB('my awesome database'); 508 | 509 | let result; 510 | 511 | const query = new LiveQuery(['items'], async () => { 512 | // result will be updated every time the 'items' store is modified 513 | result = await db.getAll('items'); 514 | }); 515 | ``` 516 | 517 | #### Example with React 518 | 519 | ```js 520 | import { openDB, LiveQuery } from 'synceddb'; 521 | import { useEffect, useState } from 'react'; 522 | 523 | export default function MyComponent() { 524 | const [items, setItems] = useState([]); 525 | 526 | useEffect(() => { 527 | let query; 528 | 529 | openDB('test', 1, { 530 | upgrade(db) { 531 | db.createObjectStore('items', { keyPath: 'id' }); 532 | }, 533 | }).then(db => { 534 | query = new LiveQuery(['items'], async () => { 535 | setItems(await db.getAll('items')); 536 | }); 537 | 538 | query.run(); 539 | }); 540 | 541 | return () => { 542 | // !!! IMPORTANT !!! This ensures the query stops listening to the database updates and does not leak memory. 543 | query?.close(); 544 | } 545 | }, []); 546 | 547 | return ( 548 |
549 | 550 |
551 | ); 552 | } 553 | ``` 554 | 555 | #### Example with Vue.js 556 | 557 | ```vue 558 | 581 | ``` 582 | 583 | ### `createComputedStore()` 584 | 585 | Arguments: 586 | 587 | - `db`: the database object 588 | - `computedStoreName`: the name of the computed store 589 | - `mainStoreName`: the name of main source store (used to init the computed store) 590 | - `secondaryStoreNames`: the names of any additional source stores 591 | - `onChange`: the handler for the change 592 | - `tx`: the transaction 593 | - `change`: the change (fields: `storeName`, `operation`, `key`, `value`) 594 | 595 | Example: 596 | 597 | ```js 598 | import { openDB, createComputedStore } from 'synceddb/with-async-ittr'; 599 | 600 | const db = await openDB('my awesome database'); 601 | 602 | await createComputedStore(db, 'invoices-with-customer', 'invoices', ['customers'], async (tx, change) => { 603 | const computedStore = tx.objectStore('invoices-with-customer'); 604 | 605 | if (change.storeName === 'invoices') { 606 | if (change.operation === 'add' || change.operation === 'put') { 607 | const invoice = change.value; 608 | // fetch the customer object 609 | invoice.customer = await tx.objectStore('customers').get(invoice.customerId); 610 | // update the computed store 611 | computedStore.put(invoice); 612 | } else { // change.operation === 'delete' 613 | computedStore.delete(change.key); 614 | } 615 | } 616 | 617 | if (change.storeName === 'customers') { 618 | if (change.operation === 'put') { 619 | const customer = change.value; 620 | // update all invoices with the given customer in the computed store 621 | for await (const cursor of computedStore.index('by-customer').iterate(change.key)) { 622 | const invoice = cursor.value; 623 | if (invoice.customerId === customer.id) { 624 | invoice.customer = customer; 625 | cursor.update(invoice); 626 | } 627 | } 628 | } 629 | } 630 | }); 631 | ``` 632 | 633 | In the example above, the `invoices-with-customer` store will be updated every time the `invoices` or the `customers` store is updated, either by a manual update from the user or when fetching updates from the server. 634 | 635 | 636 | ## Expectations for the REST API 637 | 638 | ### Fetching changes 639 | 640 | Changes are fetched from the REST API with `GET` requests: 641 | 642 | ``` 643 | GET /?sort=updated_at:asc&size=100&after=2000-01-01T00:00:00.000Z&after_id=123 644 | ``` 645 | 646 | Explanations: 647 | 648 | - `sort=updated_at:asc` indicates that we want to sort the entities based on the date of last update 649 | - `size=100` indicates that we want 100 entities max 650 | - `after=2000-01-01T00:00:00.000Z&after_id=123` indicates the offset (with an update date above `2000-01-01T00:00:00.000Z`, excluding the entity `123`) 651 | 652 | The query parameters can be customized with the [`buildFetchParams`](#buildfetchparams) option. 653 | 654 | Expected response: 655 | 656 | ```js 657 | { 658 | data: [ 659 | { 660 | id: 1, 661 | version: 1, 662 | updatedAt: '2000-01-01T00:00:00.000Z', 663 | label: 'Dagger' 664 | }, 665 | { 666 | id: 2, 667 | version: 12, 668 | updatedAt: '2000-01-02T00:00:00.000Z', 669 | label: 'Long sword' 670 | }, 671 | { 672 | id: 3, 673 | version: -1, // tombstone 674 | updatedAt: '2000-01-03T00:00:00.000Z', 675 | } 676 | ], 677 | hasMore: true 678 | } 679 | ``` 680 | 681 | A fetch request will be sent for each store of the database, every X seconds (see the [fetchInterval](#fetchinterval) option). 682 | 683 | ### Pushing changes 684 | 685 | Each successful readwrite transaction will be translated into an HTTP request, when the connection is available: 686 | 687 | | Operation | HTTP request | Body | 688 | |---------------------------------------------------------------|-------------------------------|----------------------------------------------| 689 | | `db.add('items', { id: 1, label: 'Dagger' })` | `POST /items` | `{ id: 1, version: 1, label: 'Dagger' }` | 690 | | `db.put('items', { id: 2, version: 2, label: 'Long sword' })` | `PUT /items/2` | `{ id: 2, version: 3, label: 'Long sword' }` | 691 | | `db.delete('items', 3)` | `DELETE /items/3` | | 692 | | `db.clear('items')` | one `DELETE` request per item | | 693 | 694 | Success must be indicated by an HTTP 2xx response. Any other response status means the change was not properly synced. You can customize the error handling behavior with the [`onpusherror`](#onpusherror) method. 695 | 696 | Please see the Express server [there](https://github.com/darrachequesne/synceddb-todo-example/blob/main/express-server/index.js) for reference. 697 | 698 | ## Alternatives 699 | 700 | Here are some alternatives that you might find interesting: 701 | 702 | - idb: https://github.com/jakearchibald/idb 703 | - Dexie.js: https://dexie.org/ (and its [ISyncProtocol](https://dexie.org/docs/Syncable/Dexie.Syncable.ISyncProtocol) part) 704 | - pouchdb: https://pouchdb.com/ 705 | - Automerge: https://github.com/automerge/automerge 706 | - Yjs: https://github.com/yjs/yjs 707 | - Electric: https://electric-sql.com/ 708 | - absurd-sql: https://github.com/jlongster/absurd-sql 709 | 710 | ## Miscellaneous 711 | 712 | - [Pagination with IndexedDB](https://github.com/darrachequesne/indexeddb-pagination) 713 | - [Speeding up IndexedDB reads and writes](https://nolanlawson.com/2021/08/22/speeding-up-indexeddb-reads-and-writes/) 714 | - [Breaking the Borders of IndexedDB](https://hacks.mozilla.org/2014/06/breaking-the-borders-of-indexeddb/) 715 | -------------------------------------------------------------------------------- /src/entry.ts: -------------------------------------------------------------------------------- 1 | import { wrap } from './wrap-idb-value.js'; 2 | import { LOCAL_CHANGES_STORE, LOCAL_OFFSETS_STORE } from './constants.js'; 3 | 4 | export interface OpenDBCallbacks { 5 | /** 6 | * Called if this version of the database has never been opened before. Use it to specify the 7 | * schema for the database. 8 | * 9 | * @param database A database instance that you can use to add/remove stores and indexes. 10 | * @param oldVersion Last version of the database opened by the user. 11 | * @param newVersion Whatever new version you provided. 12 | * @param transaction The transaction for this upgrade. This is useful if you need to get data 13 | * from other stores as part of a migration. 14 | */ 15 | upgrade?( 16 | database: IDBPDatabase, 17 | oldVersion: number, 18 | newVersion: number | null, 19 | transaction: IDBPTransaction< 20 | DBTypes, 21 | StoreNames[], 22 | 'versionchange' 23 | >, 24 | ): void; 25 | /** 26 | * Called if there are older versions of the database open on the origin, so this version cannot 27 | * open. 28 | */ 29 | blocked?(): void; 30 | /** 31 | * Called if this connection is blocking a future version of the database from opening. 32 | */ 33 | blocking?(): void; 34 | /** 35 | * Called if the browser abnormally terminates the connection. 36 | * This is not called when `db.close()` is called. 37 | */ 38 | terminated?(): void; 39 | } 40 | 41 | /** 42 | * Open a database. 43 | * 44 | * @param name Name of the database. 45 | * @param version Schema version. 46 | * @param callbacks Additional callbacks. 47 | */ 48 | export function openDB( 49 | name: string, 50 | version?: number, 51 | { blocked, upgrade, blocking, terminated }: OpenDBCallbacks = {}, 52 | ): Promise> { 53 | const request = indexedDB.open(name, version); 54 | const openPromise = wrap(request) as Promise>; 55 | 56 | if (upgrade) { 57 | request.addEventListener('upgradeneeded', (event) => { 58 | upgrade( 59 | wrap(request.result) as IDBPDatabase, 60 | event.oldVersion, 61 | event.newVersion, 62 | wrap(request.transaction!) as unknown as IDBPTransaction< 63 | DBTypes, 64 | StoreNames[], 65 | 'versionchange' 66 | >, 67 | ); 68 | const db = request.result; 69 | if (!db.objectStoreNames.contains(LOCAL_CHANGES_STORE)) { 70 | const store = db.createObjectStore(LOCAL_CHANGES_STORE, { 71 | autoIncrement: true, 72 | }); 73 | store.createIndex('storeName, key', ['storeName', 'key'], { 74 | unique: false, 75 | }); 76 | } 77 | if (!db.objectStoreNames.contains(LOCAL_OFFSETS_STORE)) { 78 | db.createObjectStore(LOCAL_OFFSETS_STORE); 79 | } 80 | }); 81 | } 82 | 83 | if (blocked) request.addEventListener('blocked', () => blocked()); 84 | 85 | openPromise 86 | .then((db) => { 87 | if (terminated) db.addEventListener('close', () => terminated()); 88 | if (blocking) db.addEventListener('versionchange', () => blocking()); 89 | }) 90 | .catch(() => {}); 91 | 92 | return openPromise; 93 | } 94 | 95 | export interface DeleteDBCallbacks { 96 | /** 97 | * Called if there are connections to this database open, so it cannot be deleted. 98 | */ 99 | blocked?(): void; 100 | } 101 | 102 | /** 103 | * Delete a database. 104 | * 105 | * @param name Name of the database. 106 | */ 107 | export function deleteDB( 108 | name: string, 109 | { blocked }: DeleteDBCallbacks = {}, 110 | ): Promise { 111 | const request = indexedDB.deleteDatabase(name); 112 | if (blocked) request.addEventListener('blocked', () => blocked()); 113 | return wrap(request).then(() => undefined); 114 | } 115 | 116 | export { unwrap, wrap } from './wrap-idb-value.js'; 117 | 118 | // === The rest of this file is type defs === 119 | type KeyToKeyNoIndex = { 120 | [K in keyof T]: string extends K ? never : number extends K ? never : K; 121 | }; 122 | type ValuesOf = T extends { [K in keyof T]: infer U } ? U : never; 123 | type KnownKeys = ValuesOf>; 124 | 125 | type Omit = Pick>; 126 | 127 | export interface DBSchema { 128 | [s: string]: DBSchemaValue; 129 | } 130 | 131 | interface IndexKeys { 132 | [s: string]: IDBValidKey; 133 | } 134 | 135 | interface DBSchemaValue { 136 | key: IDBValidKey; 137 | value: any; 138 | indexes?: IndexKeys; 139 | } 140 | 141 | /** 142 | * Extract known object store names from the DB schema type. 143 | * 144 | * @template DBTypes DB schema type, or unknown if the DB isn't typed. 145 | */ 146 | export type StoreNames = 147 | DBTypes extends DBSchema ? KnownKeys : string; 148 | 149 | /** 150 | * Extract database value types from the DB schema type. 151 | * 152 | * @template DBTypes DB schema type, or unknown if the DB isn't typed. 153 | * @template StoreName Names of the object stores to get the types of. 154 | */ 155 | export type StoreValue< 156 | DBTypes extends DBSchema | unknown, 157 | StoreName extends StoreNames, 158 | > = DBTypes extends DBSchema ? DBTypes[StoreName]['value'] : any; 159 | 160 | /** 161 | * Extract database key types from the DB schema type. 162 | * 163 | * @template DBTypes DB schema type, or unknown if the DB isn't typed. 164 | * @template StoreName Names of the object stores to get the types of. 165 | */ 166 | export type StoreKey< 167 | DBTypes extends DBSchema | unknown, 168 | StoreName extends StoreNames, 169 | > = DBTypes extends DBSchema ? DBTypes[StoreName]['key'] : IDBValidKey; 170 | 171 | /** 172 | * Extract the names of indexes in certain object stores from the DB schema type. 173 | * 174 | * @template DBTypes DB schema type, or unknown if the DB isn't typed. 175 | * @template StoreName Names of the object stores to get the types of. 176 | */ 177 | export type IndexNames< 178 | DBTypes extends DBSchema | unknown, 179 | StoreName extends StoreNames, 180 | > = DBTypes extends DBSchema ? keyof DBTypes[StoreName]['indexes'] : string; 181 | 182 | /** 183 | * Extract the types of indexes in certain object stores from the DB schema type. 184 | * 185 | * @template DBTypes DB schema type, or unknown if the DB isn't typed. 186 | * @template StoreName Names of the object stores to get the types of. 187 | * @template IndexName Names of the indexes to get the types of. 188 | */ 189 | export type IndexKey< 190 | DBTypes extends DBSchema | unknown, 191 | StoreName extends StoreNames, 192 | IndexName extends IndexNames, 193 | > = DBTypes extends DBSchema 194 | ? IndexName extends keyof DBTypes[StoreName]['indexes'] 195 | ? DBTypes[StoreName]['indexes'][IndexName] 196 | : IDBValidKey 197 | : IDBValidKey; 198 | 199 | type CursorSource< 200 | DBTypes extends DBSchema | unknown, 201 | TxStores extends ArrayLike>, 202 | StoreName extends StoreNames, 203 | IndexName extends IndexNames | unknown, 204 | Mode extends IDBTransactionMode = 'readonly', 205 | > = IndexName extends IndexNames 206 | ? IDBPIndex 207 | : IDBPObjectStore; 208 | 209 | type CursorKey< 210 | DBTypes extends DBSchema | unknown, 211 | StoreName extends StoreNames, 212 | IndexName extends IndexNames | unknown, 213 | > = IndexName extends IndexNames 214 | ? IndexKey 215 | : StoreKey; 216 | 217 | type IDBPDatabaseExtends = Omit< 218 | IDBDatabase, 219 | 'createObjectStore' | 'deleteObjectStore' | 'transaction' | 'objectStoreNames' 220 | >; 221 | 222 | /** 223 | * A variation of DOMStringList with precise string types 224 | */ 225 | export interface TypedDOMStringList extends DOMStringList { 226 | contains(string: T): boolean; 227 | item(index: number): T | null; 228 | [index: number]: T; 229 | [Symbol.iterator](): IterableIterator; 230 | } 231 | 232 | interface IDBTransactionOptions { 233 | /** 234 | * The durability of the transaction. 235 | * 236 | * The default is "default". Using "relaxed" provides better performance, but with fewer 237 | * guarantees. Web applications are encouraged to use "relaxed" for ephemeral data such as caches 238 | * or quickly changing records, and "strict" in cases where reducing the risk of data loss 239 | * outweighs the impact to performance and power. 240 | */ 241 | durability?: 'default' | 'strict' | 'relaxed'; 242 | } 243 | 244 | export interface IDBPDatabase 245 | extends IDBPDatabaseExtends { 246 | /** 247 | * The names of stores in the database. 248 | */ 249 | readonly objectStoreNames: TypedDOMStringList>; 250 | /** 251 | * Creates a new object store. 252 | * 253 | * Throws a "InvalidStateError" DOMException if not called within an upgrade transaction. 254 | */ 255 | createObjectStore>( 256 | name: Name, 257 | optionalParameters?: IDBObjectStoreParameters, 258 | ): IDBPObjectStore< 259 | DBTypes, 260 | ArrayLike>, 261 | Name, 262 | 'versionchange' 263 | >; 264 | /** 265 | * Deletes the object store with the given name. 266 | * 267 | * Throws a "InvalidStateError" DOMException if not called within an upgrade transaction. 268 | */ 269 | deleteObjectStore(name: StoreNames): void; 270 | /** 271 | * Start a new transaction. 272 | * 273 | * @param storeNames The object store(s) this transaction needs. 274 | * @param mode 275 | * @param options 276 | */ 277 | transaction< 278 | Name extends StoreNames, 279 | Mode extends IDBTransactionMode = 'readonly', 280 | >( 281 | storeNames: Name, 282 | mode?: Mode, 283 | options?: IDBTransactionOptions, 284 | ): IDBPTransaction; 285 | transaction< 286 | Names extends ArrayLike>, 287 | Mode extends IDBTransactionMode = 'readonly', 288 | >( 289 | storeNames: Names, 290 | mode?: Mode, 291 | options?: IDBTransactionOptions, 292 | ): IDBPTransaction; 293 | 294 | // Shortcut methods 295 | 296 | /** 297 | * Add a value to a store. 298 | * 299 | * Rejects if an item of a given key already exists in the store. 300 | * 301 | * This is a shortcut that creates a transaction for this single action. If you need to do more 302 | * than one action, create a transaction instead. 303 | * 304 | * @param storeName Name of the store. 305 | * @param value 306 | * @param key 307 | */ 308 | add>( 309 | storeName: Name, 310 | value: StoreValue, 311 | key?: StoreKey | IDBKeyRange, 312 | ): Promise>; 313 | /** 314 | * Deletes all records in a store. 315 | * 316 | * This is a shortcut that creates a transaction for this single action. If you need to do more 317 | * than one action, create a transaction instead. 318 | * 319 | * @param storeName Name of the store. 320 | */ 321 | clear(name: StoreNames): Promise; 322 | /** 323 | * Retrieves the number of records matching the given query in a store. 324 | * 325 | * This is a shortcut that creates a transaction for this single action. If you need to do more 326 | * than one action, create a transaction instead. 327 | * 328 | * @param storeName Name of the store. 329 | * @param key 330 | */ 331 | count>( 332 | storeName: Name, 333 | key?: StoreKey | IDBKeyRange | null, 334 | ): Promise; 335 | /** 336 | * Retrieves the number of records matching the given query in an index. 337 | * 338 | * This is a shortcut that creates a transaction for this single action. If you need to do more 339 | * than one action, create a transaction instead. 340 | * 341 | * @param storeName Name of the store. 342 | * @param indexName Name of the index within the store. 343 | * @param key 344 | */ 345 | countFromIndex< 346 | Name extends StoreNames, 347 | IndexName extends IndexNames, 348 | >( 349 | storeName: Name, 350 | indexName: IndexName, 351 | key?: IndexKey | IDBKeyRange | null, 352 | ): Promise; 353 | /** 354 | * Deletes records in a store matching the given query. 355 | * 356 | * This is a shortcut that creates a transaction for this single action. If you need to do more 357 | * than one action, create a transaction instead. 358 | * 359 | * @param storeName Name of the store. 360 | * @param key 361 | */ 362 | delete>( 363 | storeName: Name, 364 | key: StoreKey | IDBKeyRange, 365 | ): Promise; 366 | /** 367 | * Retrieves the value of the first record in a store matching the query. 368 | * 369 | * Resolves with undefined if no match is found. 370 | * 371 | * This is a shortcut that creates a transaction for this single action. If you need to do more 372 | * than one action, create a transaction instead. 373 | * 374 | * @param storeName Name of the store. 375 | * @param query 376 | */ 377 | get>( 378 | storeName: Name, 379 | query: StoreKey | IDBKeyRange, 380 | ): Promise | undefined>; 381 | /** 382 | * Retrieves the value of the first record in an index matching the query. 383 | * 384 | * Resolves with undefined if no match is found. 385 | * 386 | * This is a shortcut that creates a transaction for this single action. If you need to do more 387 | * than one action, create a transaction instead. 388 | * 389 | * @param storeName Name of the store. 390 | * @param indexName Name of the index within the store. 391 | * @param query 392 | */ 393 | getFromIndex< 394 | Name extends StoreNames, 395 | IndexName extends IndexNames, 396 | >( 397 | storeName: Name, 398 | indexName: IndexName, 399 | query: IndexKey | IDBKeyRange, 400 | ): Promise | undefined>; 401 | /** 402 | * Retrieves all values in a store that match the query. 403 | * 404 | * This is a shortcut that creates a transaction for this single action. If you need to do more 405 | * than one action, create a transaction instead. 406 | * 407 | * @param storeName Name of the store. 408 | * @param query 409 | * @param count Maximum number of values to return. 410 | */ 411 | getAll>( 412 | storeName: Name, 413 | query?: StoreKey | IDBKeyRange | null, 414 | count?: number, 415 | ): Promise[]>; 416 | /** 417 | * Retrieves all values in an index that match the query. 418 | * 419 | * This is a shortcut that creates a transaction for this single action. If you need to do more 420 | * than one action, create a transaction instead. 421 | * 422 | * @param storeName Name of the store. 423 | * @param indexName Name of the index within the store. 424 | * @param query 425 | * @param count Maximum number of values to return. 426 | */ 427 | getAllFromIndex< 428 | Name extends StoreNames, 429 | IndexName extends IndexNames, 430 | >( 431 | storeName: Name, 432 | indexName: IndexName, 433 | query?: IndexKey | IDBKeyRange | null, 434 | count?: number, 435 | ): Promise[]>; 436 | /** 437 | * Retrieves the keys of records in a store matching the query. 438 | * 439 | * This is a shortcut that creates a transaction for this single action. If you need to do more 440 | * than one action, create a transaction instead. 441 | * 442 | * @param storeName Name of the store. 443 | * @param query 444 | * @param count Maximum number of keys to return. 445 | */ 446 | getAllKeys>( 447 | storeName: Name, 448 | query?: StoreKey | IDBKeyRange | null, 449 | count?: number, 450 | ): Promise[]>; 451 | /** 452 | * Retrieves the keys of records in an index matching the query. 453 | * 454 | * This is a shortcut that creates a transaction for this single action. If you need to do more 455 | * than one action, create a transaction instead. 456 | * 457 | * @param storeName Name of the store. 458 | * @param indexName Name of the index within the store. 459 | * @param query 460 | * @param count Maximum number of keys to return. 461 | */ 462 | getAllKeysFromIndex< 463 | Name extends StoreNames, 464 | IndexName extends IndexNames, 465 | >( 466 | storeName: Name, 467 | indexName: IndexName, 468 | query?: IndexKey | IDBKeyRange | null, 469 | count?: number, 470 | ): Promise[]>; 471 | /** 472 | * Retrieves the key of the first record in a store that matches the query. 473 | * 474 | * Resolves with undefined if no match is found. 475 | * 476 | * This is a shortcut that creates a transaction for this single action. If you need to do more 477 | * than one action, create a transaction instead. 478 | * 479 | * @param storeName Name of the store. 480 | * @param query 481 | */ 482 | getKey>( 483 | storeName: Name, 484 | query: StoreKey | IDBKeyRange, 485 | ): Promise | undefined>; 486 | /** 487 | * Retrieves the key of the first record in an index that matches the query. 488 | * 489 | * Resolves with undefined if no match is found. 490 | * 491 | * This is a shortcut that creates a transaction for this single action. If you need to do more 492 | * than one action, create a transaction instead. 493 | * 494 | * @param storeName Name of the store. 495 | * @param indexName Name of the index within the store. 496 | * @param query 497 | */ 498 | getKeyFromIndex< 499 | Name extends StoreNames, 500 | IndexName extends IndexNames, 501 | >( 502 | storeName: Name, 503 | indexName: IndexName, 504 | query: IndexKey | IDBKeyRange, 505 | ): Promise | undefined>; 506 | /** 507 | * Put an item in the database. 508 | * 509 | * Replaces any item with the same key. 510 | * 511 | * This is a shortcut that creates a transaction for this single action. If you need to do more 512 | * than one action, create a transaction instead. 513 | * 514 | * @param storeName Name of the store. 515 | * @param value 516 | * @param key 517 | */ 518 | put>( 519 | storeName: Name, 520 | value: StoreValue, 521 | key?: StoreKey | IDBKeyRange, 522 | ): Promise>; 523 | } 524 | 525 | type IDBPTransactionExtends = Omit< 526 | IDBTransaction, 527 | 'db' | 'objectStore' | 'objectStoreNames' 528 | >; 529 | 530 | export interface IDBPTransaction< 531 | DBTypes extends DBSchema | unknown = unknown, 532 | TxStores extends ArrayLike> = ArrayLike< 533 | StoreNames 534 | >, 535 | Mode extends IDBTransactionMode = 'readonly', 536 | > extends IDBPTransactionExtends { 537 | /** 538 | * The transaction's mode. 539 | */ 540 | readonly mode: Mode; 541 | /** 542 | * The names of stores in scope for this transaction. 543 | */ 544 | readonly objectStoreNames: TypedDOMStringList; 545 | /** 546 | * The transaction's connection. 547 | */ 548 | readonly db: IDBPDatabase; 549 | /** 550 | * Promise for the completion of this transaction. 551 | */ 552 | readonly done: Promise; 553 | /** 554 | * The associated object store, if the transaction covers a single store, otherwise undefined. 555 | */ 556 | readonly store: TxStores[1] extends undefined 557 | ? IDBPObjectStore 558 | : undefined; 559 | /** 560 | * Returns an IDBObjectStore in the transaction's scope. 561 | */ 562 | objectStore( 563 | name: StoreName, 564 | ): IDBPObjectStore; 565 | } 566 | 567 | type IDBPObjectStoreExtends = Omit< 568 | IDBObjectStore, 569 | | 'transaction' 570 | | 'add' 571 | | 'clear' 572 | | 'count' 573 | | 'createIndex' 574 | | 'delete' 575 | | 'get' 576 | | 'getAll' 577 | | 'getAllKeys' 578 | | 'getKey' 579 | | 'index' 580 | | 'openCursor' 581 | | 'openKeyCursor' 582 | | 'put' 583 | | 'indexNames' 584 | >; 585 | 586 | export interface IDBPObjectStore< 587 | DBTypes extends DBSchema | unknown = unknown, 588 | TxStores extends ArrayLike> = ArrayLike< 589 | StoreNames 590 | >, 591 | StoreName extends StoreNames = StoreNames, 592 | Mode extends IDBTransactionMode = 'readonly', 593 | > extends IDBPObjectStoreExtends { 594 | /** 595 | * The names of indexes in the store. 596 | */ 597 | readonly indexNames: TypedDOMStringList>; 598 | /** 599 | * The associated transaction. 600 | */ 601 | readonly transaction: IDBPTransaction; 602 | /** 603 | * Add a value to the store. 604 | * 605 | * Rejects if an item of a given key already exists in the store. 606 | */ 607 | add: Mode extends 'readonly' 608 | ? undefined 609 | : ( 610 | value: StoreValue, 611 | key?: StoreKey | IDBKeyRange, 612 | ) => Promise>; 613 | /** 614 | * Deletes all records in store. 615 | */ 616 | clear: Mode extends 'readonly' ? undefined : () => Promise; 617 | /** 618 | * Retrieves the number of records matching the given query. 619 | */ 620 | count( 621 | key?: StoreKey | IDBKeyRange | null, 622 | ): Promise; 623 | /** 624 | * Creates a new index in store. 625 | * 626 | * Throws an "InvalidStateError" DOMException if not called within an upgrade transaction. 627 | */ 628 | createIndex: Mode extends 'versionchange' 629 | ? >( 630 | name: IndexName, 631 | keyPath: string | string[], 632 | options?: IDBIndexParameters, 633 | ) => IDBPIndex 634 | : undefined; 635 | /** 636 | * Deletes records in store matching the given query. 637 | */ 638 | delete: Mode extends 'readonly' 639 | ? undefined 640 | : (key: StoreKey | IDBKeyRange) => Promise; 641 | /** 642 | * Retrieves the value of the first record matching the query. 643 | * 644 | * Resolves with undefined if no match is found. 645 | */ 646 | get( 647 | query: StoreKey | IDBKeyRange, 648 | ): Promise | undefined>; 649 | /** 650 | * Retrieves all values that match the query. 651 | * 652 | * @param query 653 | * @param count Maximum number of values to return. 654 | */ 655 | getAll( 656 | query?: StoreKey | IDBKeyRange | null, 657 | count?: number, 658 | ): Promise[]>; 659 | /** 660 | * Retrieves the keys of records matching the query. 661 | * 662 | * @param query 663 | * @param count Maximum number of keys to return. 664 | */ 665 | getAllKeys( 666 | query?: StoreKey | IDBKeyRange | null, 667 | count?: number, 668 | ): Promise[]>; 669 | /** 670 | * Retrieves the key of the first record that matches the query. 671 | * 672 | * Resolves with undefined if no match is found. 673 | */ 674 | getKey( 675 | query: StoreKey | IDBKeyRange, 676 | ): Promise | undefined>; 677 | /** 678 | * Get a query of a given name. 679 | */ 680 | index>( 681 | name: IndexName, 682 | ): IDBPIndex; 683 | /** 684 | * Opens a cursor over the records matching the query. 685 | * 686 | * Resolves with null if no matches are found. 687 | * 688 | * @param query If null, all records match. 689 | * @param direction 690 | */ 691 | openCursor( 692 | query?: StoreKey | IDBKeyRange | null, 693 | direction?: IDBCursorDirection, 694 | ): Promise | null>; 701 | /** 702 | * Opens a cursor over the keys matching the query. 703 | * 704 | * Resolves with null if no matches are found. 705 | * 706 | * @param query If null, all records match. 707 | * @param direction 708 | */ 709 | openKeyCursor( 710 | query?: StoreKey | IDBKeyRange | null, 711 | direction?: IDBCursorDirection, 712 | ): Promise | null>; 713 | /** 714 | * Put an item in the store. 715 | * 716 | * Replaces any item with the same key. 717 | */ 718 | put: Mode extends 'readonly' 719 | ? undefined 720 | : ( 721 | value: StoreValue, 722 | key?: StoreKey | IDBKeyRange, 723 | ) => Promise>; 724 | /** 725 | * Iterate over the store. 726 | */ 727 | [Symbol.asyncIterator](): AsyncIterableIterator< 728 | IDBPCursorWithValueIteratorValue< 729 | DBTypes, 730 | TxStores, 731 | StoreName, 732 | unknown, 733 | Mode 734 | > 735 | >; 736 | /** 737 | * Iterate over the records matching the query. 738 | * 739 | * @param query If null, all records match. 740 | * @param direction 741 | */ 742 | iterate( 743 | query?: StoreKey | IDBKeyRange | null, 744 | direction?: IDBCursorDirection, 745 | ): AsyncIterableIterator< 746 | IDBPCursorWithValueIteratorValue< 747 | DBTypes, 748 | TxStores, 749 | StoreName, 750 | unknown, 751 | Mode 752 | > 753 | >; 754 | } 755 | 756 | type IDBPIndexExtends = Omit< 757 | IDBIndex, 758 | | 'objectStore' 759 | | 'count' 760 | | 'get' 761 | | 'getAll' 762 | | 'getAllKeys' 763 | | 'getKey' 764 | | 'openCursor' 765 | | 'openKeyCursor' 766 | >; 767 | 768 | export interface IDBPIndex< 769 | DBTypes extends DBSchema | unknown = unknown, 770 | TxStores extends ArrayLike> = ArrayLike< 771 | StoreNames 772 | >, 773 | StoreName extends StoreNames = StoreNames, 774 | IndexName extends IndexNames = IndexNames< 775 | DBTypes, 776 | StoreName 777 | >, 778 | Mode extends IDBTransactionMode = 'readonly', 779 | > extends IDBPIndexExtends { 780 | /** 781 | * The IDBObjectStore the index belongs to. 782 | */ 783 | readonly objectStore: IDBPObjectStore; 784 | 785 | /** 786 | * Retrieves the number of records matching the given query. 787 | */ 788 | count( 789 | key?: IndexKey | IDBKeyRange | null, 790 | ): Promise; 791 | /** 792 | * Retrieves the value of the first record matching the query. 793 | * 794 | * Resolves with undefined if no match is found. 795 | */ 796 | get( 797 | query: IndexKey | IDBKeyRange, 798 | ): Promise | undefined>; 799 | /** 800 | * Retrieves all values that match the query. 801 | * 802 | * @param query 803 | * @param count Maximum number of values to return. 804 | */ 805 | getAll( 806 | query?: IndexKey | IDBKeyRange | null, 807 | count?: number, 808 | ): Promise[]>; 809 | /** 810 | * Retrieves the keys of records matching the query. 811 | * 812 | * @param query 813 | * @param count Maximum number of keys to return. 814 | */ 815 | getAllKeys( 816 | query?: IndexKey | IDBKeyRange | null, 817 | count?: number, 818 | ): Promise[]>; 819 | /** 820 | * Retrieves the key of the first record that matches the query. 821 | * 822 | * Resolves with undefined if no match is found. 823 | */ 824 | getKey( 825 | query: IndexKey | IDBKeyRange, 826 | ): Promise | undefined>; 827 | /** 828 | * Opens a cursor over the records matching the query. 829 | * 830 | * Resolves with null if no matches are found. 831 | * 832 | * @param query If null, all records match. 833 | * @param direction 834 | */ 835 | openCursor( 836 | query?: IndexKey | IDBKeyRange | null, 837 | direction?: IDBCursorDirection, 838 | ): Promise | null>; 845 | /** 846 | * Opens a cursor over the keys matching the query. 847 | * 848 | * Resolves with null if no matches are found. 849 | * 850 | * @param query If null, all records match. 851 | * @param direction 852 | */ 853 | openKeyCursor( 854 | query?: IndexKey | IDBKeyRange | null, 855 | direction?: IDBCursorDirection, 856 | ): Promise | null>; 857 | /** 858 | * Iterate over the index. 859 | */ 860 | [Symbol.asyncIterator](): AsyncIterableIterator< 861 | IDBPCursorWithValueIteratorValue< 862 | DBTypes, 863 | TxStores, 864 | StoreName, 865 | IndexName, 866 | Mode 867 | > 868 | >; 869 | /** 870 | * Iterate over the records matching the query. 871 | * 872 | * Resolves with null if no matches are found. 873 | * 874 | * @param query If null, all records match. 875 | * @param direction 876 | */ 877 | iterate( 878 | query?: IndexKey | IDBKeyRange | null, 879 | direction?: IDBCursorDirection, 880 | ): AsyncIterableIterator< 881 | IDBPCursorWithValueIteratorValue< 882 | DBTypes, 883 | TxStores, 884 | StoreName, 885 | IndexName, 886 | Mode 887 | > 888 | >; 889 | } 890 | 891 | type IDBPCursorExtends = Omit< 892 | IDBCursor, 893 | | 'key' 894 | | 'primaryKey' 895 | | 'source' 896 | | 'advance' 897 | | 'continue' 898 | | 'continuePrimaryKey' 899 | | 'delete' 900 | | 'update' 901 | >; 902 | 903 | export interface IDBPCursor< 904 | DBTypes extends DBSchema | unknown = unknown, 905 | TxStores extends ArrayLike> = ArrayLike< 906 | StoreNames 907 | >, 908 | StoreName extends StoreNames = StoreNames, 909 | IndexName extends IndexNames | unknown = unknown, 910 | Mode extends IDBTransactionMode = 'readonly', 911 | > extends IDBPCursorExtends { 912 | /** 913 | * The key of the current index or object store item. 914 | */ 915 | readonly key: CursorKey; 916 | /** 917 | * The key of the current object store item. 918 | */ 919 | readonly primaryKey: StoreKey; 920 | /** 921 | * Returns the IDBObjectStore or IDBIndex the cursor was opened from. 922 | */ 923 | readonly source: CursorSource; 924 | /** 925 | * Advances the cursor a given number of records. 926 | * 927 | * Resolves to null if no matching records remain. 928 | */ 929 | advance(this: T, count: number): Promise; 930 | /** 931 | * Advance the cursor by one record (unless 'key' is provided). 932 | * 933 | * Resolves to null if no matching records remain. 934 | * 935 | * @param key Advance to the index or object store with a key equal to or greater than this value. 936 | */ 937 | continue( 938 | this: T, 939 | key?: CursorKey, 940 | ): Promise; 941 | /** 942 | * Advance the cursor by given keys. 943 | * 944 | * The operation is 'and' – both keys must be satisfied. 945 | * 946 | * Resolves to null if no matching records remain. 947 | * 948 | * @param key Advance to the index or object store with a key equal to or greater than this value. 949 | * @param primaryKey and where the object store has a key equal to or greater than this value. 950 | */ 951 | continuePrimaryKey( 952 | this: T, 953 | key: CursorKey, 954 | primaryKey: StoreKey, 955 | ): Promise; 956 | /** 957 | * Delete the current record. 958 | */ 959 | delete: Mode extends 'readonly' ? undefined : () => Promise; 960 | /** 961 | * Updated the current record. 962 | */ 963 | update: Mode extends 'readonly' 964 | ? undefined 965 | : ( 966 | value: StoreValue, 967 | ) => Promise>; 968 | /** 969 | * Iterate over the cursor. 970 | */ 971 | [Symbol.asyncIterator](): AsyncIterableIterator< 972 | IDBPCursorIteratorValue 973 | >; 974 | } 975 | 976 | type IDBPCursorIteratorValueExtends< 977 | DBTypes extends DBSchema | unknown = unknown, 978 | TxStores extends ArrayLike> = ArrayLike< 979 | StoreNames 980 | >, 981 | StoreName extends StoreNames = StoreNames, 982 | IndexName extends IndexNames | unknown = unknown, 983 | Mode extends IDBTransactionMode = 'readonly', 984 | > = Omit< 985 | IDBPCursor, 986 | 'advance' | 'continue' | 'continuePrimaryKey' 987 | >; 988 | 989 | export interface IDBPCursorIteratorValue< 990 | DBTypes extends DBSchema | unknown = unknown, 991 | TxStores extends ArrayLike> = ArrayLike< 992 | StoreNames 993 | >, 994 | StoreName extends StoreNames = StoreNames, 995 | IndexName extends IndexNames | unknown = unknown, 996 | Mode extends IDBTransactionMode = 'readonly', 997 | > extends IDBPCursorIteratorValueExtends< 998 | DBTypes, 999 | TxStores, 1000 | StoreName, 1001 | IndexName, 1002 | Mode 1003 | > { 1004 | /** 1005 | * Advances the cursor a given number of records. 1006 | */ 1007 | advance(this: T, count: number): void; 1008 | /** 1009 | * Advance the cursor by one record (unless 'key' is provided). 1010 | * 1011 | * @param key Advance to the index or object store with a key equal to or greater than this value. 1012 | */ 1013 | continue(this: T, key?: CursorKey): void; 1014 | /** 1015 | * Advance the cursor by given keys. 1016 | * 1017 | * The operation is 'and' – both keys must be satisfied. 1018 | * 1019 | * @param key Advance to the index or object store with a key equal to or greater than this value. 1020 | * @param primaryKey and where the object store has a key equal to or greater than this value. 1021 | */ 1022 | continuePrimaryKey( 1023 | this: T, 1024 | key: CursorKey, 1025 | primaryKey: StoreKey, 1026 | ): void; 1027 | } 1028 | 1029 | export interface IDBPCursorWithValue< 1030 | DBTypes extends DBSchema | unknown = unknown, 1031 | TxStores extends ArrayLike> = ArrayLike< 1032 | StoreNames 1033 | >, 1034 | StoreName extends StoreNames = StoreNames, 1035 | IndexName extends IndexNames | unknown = unknown, 1036 | Mode extends IDBTransactionMode = 'readonly', 1037 | > extends IDBPCursor { 1038 | /** 1039 | * The value of the current item. 1040 | */ 1041 | readonly value: StoreValue; 1042 | /** 1043 | * Iterate over the cursor. 1044 | */ 1045 | [Symbol.asyncIterator](): AsyncIterableIterator< 1046 | IDBPCursorWithValueIteratorValue< 1047 | DBTypes, 1048 | TxStores, 1049 | StoreName, 1050 | IndexName, 1051 | Mode 1052 | > 1053 | >; 1054 | } 1055 | 1056 | // Some of that sweeeeet Java-esque naming. 1057 | type IDBPCursorWithValueIteratorValueExtends< 1058 | DBTypes extends DBSchema | unknown = unknown, 1059 | TxStores extends ArrayLike> = ArrayLike< 1060 | StoreNames 1061 | >, 1062 | StoreName extends StoreNames = StoreNames, 1063 | IndexName extends IndexNames | unknown = unknown, 1064 | Mode extends IDBTransactionMode = 'readonly', 1065 | > = Omit< 1066 | IDBPCursorWithValue, 1067 | 'advance' | 'continue' | 'continuePrimaryKey' 1068 | >; 1069 | 1070 | export interface IDBPCursorWithValueIteratorValue< 1071 | DBTypes extends DBSchema | unknown = unknown, 1072 | TxStores extends ArrayLike> = ArrayLike< 1073 | StoreNames 1074 | >, 1075 | StoreName extends StoreNames = StoreNames, 1076 | IndexName extends IndexNames | unknown = unknown, 1077 | Mode extends IDBTransactionMode = 'readonly', 1078 | > extends IDBPCursorWithValueIteratorValueExtends< 1079 | DBTypes, 1080 | TxStores, 1081 | StoreName, 1082 | IndexName, 1083 | Mode 1084 | > { 1085 | /** 1086 | * Advances the cursor a given number of records. 1087 | */ 1088 | advance(this: T, count: number): void; 1089 | /** 1090 | * Advance the cursor by one record (unless 'key' is provided). 1091 | * 1092 | * @param key Advance to the index or object store with a key equal to or greater than this value. 1093 | */ 1094 | continue(this: T, key?: CursorKey): void; 1095 | /** 1096 | * Advance the cursor by given keys. 1097 | * 1098 | * The operation is 'and' – both keys must be satisfied. 1099 | * 1100 | * @param key Advance to the index or object store with a key equal to or greater than this value. 1101 | * @param primaryKey and where the object store has a key equal to or greater than this value. 1102 | */ 1103 | continuePrimaryKey( 1104 | this: T, 1105 | key: CursorKey, 1106 | primaryKey: StoreKey, 1107 | ): void; 1108 | } 1109 | --------------------------------------------------------------------------------