├── 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 | 
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