From 0a305584ab7fa388191afa9620fe44b25c2a2a69 Mon Sep 17 00:00:00 2001 From: James Coglan Date: Fri, 27 Mar 2026 15:45:25 +0000 Subject: [PATCH 1/5] chore: Add test suite for multi-tab behaviour --- .eslintrc.json | 3 +- tests/multitab/concurrency.test.js | 42 +++++++++++++++++++++++ tests/multitab/shell.html | 9 +++++ tests/multitab/user_agent.js | 53 ++++++++++++++++++++++++++++++ 4 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 tests/multitab/concurrency.test.js create mode 100644 tests/multitab/shell.html create mode 100644 tests/multitab/user_agent.js diff --git a/.eslintrc.json b/.eslintrc.json index 83668f2826..c1cab7be30 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -21,7 +21,8 @@ "ArrayBuffer": "readonly", "FileReaderSync": "readonly", "emit": "readonly", - "PouchDB": "readonly" + "PouchDB": "readonly", + "__pouch__": "readonly" }, "rules": { diff --git a/tests/multitab/concurrency.test.js b/tests/multitab/concurrency.test.js new file mode 100644 index 0000000000..a93607f26a --- /dev/null +++ b/tests/multitab/concurrency.test.js @@ -0,0 +1,42 @@ +'use strict'; + +const { assert } = require('chai'); +const UserAgent = require('./user_agent'); + +describe('multi-tab concurrency', () => { + let agent, res; + + beforeEach(async () => { + agent = await UserAgent.start(); + }); + + afterEach(async () => { + await agent.stop(); + }); + + async function checkInfo({ doc_count }) { + let info1 = await agent.eval(1, () => __pouch__.info()); + assert.equal(info1.doc_count, doc_count); + + let info2 = await agent.eval(2, () => __pouch__.info()); + assert.deepEqual(info1, info2); + } + + it('creates docs concurrently in two tabs', async () => { + res = await agent.eval(1, () => __pouch__.put({ _id: 'doc-1' })); + assert(res.ok); + await checkInfo({ doc_count: 1 }); + + res = await agent.eval(2, () => __pouch__.put({ _id: 'doc-2' })); + assert(res.ok); + await checkInfo({ doc_count: 2 }); + + res = await agent.eval(1, () => __pouch__.put({ _id: 'doc-3' })); + assert(res.ok); + await checkInfo({ doc_count: 3 }); // fails on indexeddb; pages have different info + + res = await agent.eval(2, () => __pouch__.put({ _id: 'doc-4' })); + assert(res.ok); // fails on indexeddb + await checkInfo({ doc_count: 4 }); + }); +}); diff --git a/tests/multitab/shell.html b/tests/multitab/shell.html new file mode 100644 index 0000000000..8f1e229136 --- /dev/null +++ b/tests/multitab/shell.html @@ -0,0 +1,9 @@ + + + + Playwright shell + + + + + diff --git a/tests/multitab/user_agent.js b/tests/multitab/user_agent.js new file mode 100644 index 0000000000..d16ea2fe46 --- /dev/null +++ b/tests/multitab/user_agent.js @@ -0,0 +1,53 @@ +'use strict'; + +const playwright = require('playwright'); + +const ADAPTERS = process.env.ADAPTERS || 'indexeddb'; +const CLIENT = process.env.CLIENT || 'firefox'; +const SHELL_URL = 'http://127.0.0.1:8000/tests/multitab/shell.html'; + +class UserAgent { + static async start() { + let browser = await playwright[CLIENT].launch(); + let context = await browser.newContext(); + return new UserAgent(ADAPTERS, browser, context); + } + + constructor(adapter, browser, context) { + this._adapter = adapter; + this._browser = browser; + this._context = context; + this._pages = new Map(); + } + + async stop() { + await this._browser.close(); + } + + async eval(pageId, fn) { + let page = await this._getPage(pageId); + return page.evaluate(fn); + } + + _getPage(id) { + if (!this._pages.has(id)) { + this._pages.set(id, this._setupPage()); + } + return this._pages.get(id); + } + + async _setupPage() { + let page = await this._context.newPage(); + await page.goto(SHELL_URL); + + if (this._adapter === 'idb') { + await page.evaluate(() => window.__pouch__ = new PouchDB('testdb', { adapter: 'idb' })); + } else if (this._adapter === 'indexeddb') { + await page.evaluate(() => window.__pouch__ = new PouchDB('testdb', { adapter: 'indexeddb' })); + } + + return page; + } +} + +module.exports = UserAgent; From 4db17ece34601ef08aab251b393d20c4783d4032 Mon Sep 17 00:00:00 2001 From: James Coglan Date: Fri, 27 Mar 2026 15:46:17 +0000 Subject: [PATCH 2/5] chore: Add npm command for running the multi-tab tests with the required dev server running --- bin/test-multitab.js | 14 ++++++++++++++ package.json | 1 + 2 files changed, 15 insertions(+) create mode 100644 bin/test-multitab.js diff --git a/bin/test-multitab.js b/bin/test-multitab.js new file mode 100644 index 0000000000..499a0054e1 --- /dev/null +++ b/bin/test-multitab.js @@ -0,0 +1,14 @@ +'use strict'; + +const { spawn } = require('child_process'); +const devserver = require('./dev-server'); + +const MOCHA_BIN = './node_modules/.bin/mocha'; +const MULTITAB_TESTS = 'tests/multitab'; +const TIMEOUT = '10000'; + +devserver.start(() => { + let argv = ['-t', TIMEOUT, MULTITAB_TESTS]; + let mocha = spawn(MOCHA_BIN, argv, { stdio: 'inherit' }); + mocha.on('close', (status) => process.exit(status)); +}); diff --git a/package.json b/package.json index 328803bcac..c3022a1c24 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "test-component": "mocha --exit tests/component # --exit for #8839", "test-fuzzy": "TYPE=fuzzy npm run test", "test-browser": "node ./bin/test-browser.js", + "test-multitab": "node ./bin/test-multitab.js", "test-memleak": "mocha -gc tests/memleak", "eslint": "eslint --cache bin/ packages/node_modules/**/src tests/", "dev": "./bin/run-dev.sh", From 8fca3887fb41971881077cb829e7f85b41bdb7bd Mon Sep 17 00:00:00 2001 From: James Coglan Date: Fri, 27 Mar 2026 15:53:48 +0000 Subject: [PATCH 3/5] chore: Document the multi-tab tests and run them on CI --- .github/workflows/ci.yml | 24 ++++++++++++++++++++++++ TESTING.md | 19 +++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 265e998ff9..2cc0dbad7e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -178,6 +178,30 @@ jobs: if: steps.retry.outcome == 'failure' run: ${{ matrix.cmd }} + # Run the multitab tests in every supported browser + + multitab: + needs: lint + strategy: + fail-fast: false + matrix: + client: ['firefox', 'chromium', 'webkit'] + adapter: ['idb', 'indexeddb'] + runs-on: ubuntu-latest + env: + CLIENT: ${{ matrix.client }} + ADAPTERS: ${{ matrix.adapter }} + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: ./.github/actions/install-node-package + with: + node-version: ${{ env.NODE_VERSION }} + - uses: ./.github/actions/build-pouchdb + - run: npx playwright install --with-deps ${{ matrix.client }} + - run: npm run test-multitab + # Run the integration, find and mapreduce tests against all the Node.js # PouchDB adapters. This should be run for every adapter on every version of # Node.js we support. diff --git a/TESTING.md b/TESTING.md index 961d8fa151..e897484295 100644 --- a/TESTING.md +++ b/TESTING.md @@ -22,6 +22,7 @@ help fixing them though. - [Test options](#test-options) - [Other sets of tests](#other-sets-of-tests) - [`find` and `mapreduce`](#find-and-mapreduce) + - [multi-tab tests](#multi-tab-tests) - ["Fuzzy" tests](#fuzzy-tests) - [Performance tests](#performance-tests) - [Running tests in the browser](#running-tests-in-the-browser) @@ -235,6 +236,24 @@ like so: Note that the default choice for the `SERVER` value (`pouchdb-express-router`) does not support `find` or `mapreduce` and does not need to pass these tests. +### Multi-tab tests + +We have a few tests to check that interactions with backing stores like +IndexedDB are synchronized across tabs where multiple PouchDB instances are +connected to the same database. These have to be scripted via Playwright rather +than being run inside the normal unit test suite. To run them: + + $ npm run test-multitab + +Two environment variables can be used to control how these are run: + +- `CLIENT`: may be `firefox`, `chromium` or `webkit` to select the browser where + the tests will be run. +- `ADAPTERS`: may be `idb` or `indexeddb` to set which backend adapter PouchDB + will use. + +All combinations of these should be tested on CI. + ### "Fuzzy" tests This test suite checks some more unusual replication scenarios, it can be run From 70d531e0f76db7816b1cac794e9a8511862b80a1 Mon Sep 17 00:00:00 2001 From: James Coglan Date: Tue, 31 Mar 2026 15:01:22 +0100 Subject: [PATCH 4/5] fix: Remove `versionChangedWhileOpen` i.e. always open IndexedDB with a version number --- .../pouchdb-adapter-indexeddb/src/setup.js | 21 ++++--------------- 1 file changed, 4 insertions(+), 17 deletions(-) diff --git a/packages/node_modules/pouchdb-adapter-indexeddb/src/setup.js b/packages/node_modules/pouchdb-adapter-indexeddb/src/setup.js index 18734aeb38..fc3d1e920e 100644 --- a/packages/node_modules/pouchdb-adapter-indexeddb/src/setup.js +++ b/packages/node_modules/pouchdb-adapter-indexeddb/src/setup.js @@ -115,9 +115,7 @@ function upgradePouchDbSchema(dbName, db, tx, pouchdbVersion) { } function openDatabase(openDatabases, api, opts, resolve, reject) { - const openReq = opts.versionChangedWhileOpen ? - indexedDB.open(opts.name) : - indexedDB.open(opts.name, createIdbVersion()); + const openReq = indexedDB.open(opts.name, createIdbVersion()); openReq.onupgradeneeded = function (e) { if (e.oldVersion > 0 && e.oldVersion < versionMultiplier) { @@ -185,23 +183,15 @@ function openDatabase(openDatabases, api, opts, resolve, reject) { idb.close(); }; - // In IndexedDB you can only change the version, and thus the schema, when you are opening the database. - // versionChangedWhileOpen means that something else outside of our control has likely updated the version. - // One way this could happen is if you open multiple tabs, as the version number changes each time the database is opened. - // If we suspect this we close the db and tag it, so that next time it's accessed it reopens the DB with the current version - // as opposed to upping the version again - // This avoids infinite loops of version updates if you have multiple tabs open idb.onversionchange = function () { console.log('Database was made stale, closing handle'); - openDatabases[opts.name].versionChangedWhileOpen = true; + delete openDatabases[opts.name]; idb.close(); }; idb.onclose = function () { console.log('Database was made stale, closing handle'); - if (opts.name in openDatabases) { - openDatabases[opts.name].versionChangedWhileOpen = true; - } + delete openDatabases[opts.name]; }; let metadata = {id: META_LOCAL_STORE}; @@ -257,10 +247,7 @@ function openDatabase(openDatabases, api, opts, resolve, reject) { } export default function (openDatabases, api, opts) { - if (!openDatabases[opts.name] || openDatabases[opts.name].versionChangedWhileOpen) { - opts.versionChangedWhileOpen = openDatabases[opts.name] && - openDatabases[opts.name].versionChangedWhileOpen; - + if (!openDatabases[opts.name]) { openDatabases[opts.name] = new Promise(function (resolve, reject) { openDatabase(openDatabases, api, opts, resolve, reject); }); From 1eac67f997d8786b3a44115a3d3b879d47bec613 Mon Sep 17 00:00:00 2001 From: James Coglan Date: Wed, 1 Apr 2026 13:57:53 +0100 Subject: [PATCH 5/5] fix: Do not use cached metadata for `doc_count` and `seq` --- .../pouchdb-adapter-indexeddb/src/bulkDocs.js | 35 ++++++------ .../pouchdb-adapter-indexeddb/src/index.js | 57 ++++++++++++++----- .../pouchdb-adapter-indexeddb/src/setup.js | 21 +++++-- 3 files changed, 78 insertions(+), 35 deletions(-) diff --git a/packages/node_modules/pouchdb-adapter-indexeddb/src/bulkDocs.js b/packages/node_modules/pouchdb-adapter-indexeddb/src/bulkDocs.js index 2f45d70a84..9fe2e782ae 100644 --- a/packages/node_modules/pouchdb-adapter-indexeddb/src/bulkDocs.js +++ b/packages/node_modules/pouchdb-adapter-indexeddb/src/bulkDocs.js @@ -24,9 +24,9 @@ import { DOC_STORE, META_LOCAL_STORE, idbError } from './util'; import { rewrite, sanitise } from './rewrite'; const sanitisedAttachmentKey = sanitise('_attachments'); -export default function (api, req, opts, metadata, dbOpts, idbChanges, callback) { +export default function (api, req, opts, connectionInfo, dbOpts, idbChanges, callback) { - let txn; + let metadata = {}; // TODO: I would prefer to get rid of these globals let error; @@ -49,7 +49,7 @@ export default function (api, req, opts, metadata, dbOpts, idbChanges, callback) // Reads the original doc from the store if available // As in allDocs with keys option using multiple get calls is the fastest way - function fetchExistingDocs(txn, docs) { + function fetchExistingDocs(txn, metadata, docs) { let fetched = 0; const oldDocs = {}; @@ -58,7 +58,7 @@ export default function (api, req, opts, metadata, dbOpts, idbChanges, callback) oldDocs[e.target.result.id] = e.target.result; } if (++fetched === docs.length) { - processDocs(txn, docs, oldDocs); + processDocs(txn, metadata, docs, oldDocs); } } @@ -76,7 +76,7 @@ export default function (api, req, opts, metadata, dbOpts, idbChanges, callback) }); } - function processDocs(txn, docs, oldDocs) { + function processDocs(txn, metadata, docs, oldDocs) { docs.forEach(function (doc, i) { let newDoc; @@ -116,7 +116,7 @@ export default function (api, req, opts, metadata, dbOpts, idbChanges, callback) } else { oldDocs[newDoc.id] = newDoc; lastWriteIndex = i; - write(txn, newDoc, i); + write(txn, metadata, newDoc, i); } }); } @@ -182,7 +182,7 @@ export default function (api, req, opts, metadata, dbOpts, idbChanges, callback) return doc; } - function write(txn, doc, i) { + function write(txn, metadata, doc, i) { // We copy the data from the winning revision into the root // of the document so that it can be indexed @@ -287,7 +287,7 @@ export default function (api, req, opts, metadata, dbOpts, idbChanges, callback) rev: '0-0' }; }; - updateSeq(i); + updateSeq(txn, metadata, i); return; } @@ -298,11 +298,11 @@ export default function (api, req, opts, metadata, dbOpts, idbChanges, callback) id: doc.id, rev: writtenRev }; - updateSeq(i); + updateSeq(txn, metadata, i); }; } - function updateSeq(i) { + function updateSeq(txn, metadata, i) { if (i === lastWriteIndex) { txn.objectStore(META_LOCAL_STORE).put(metadata); } @@ -320,12 +320,12 @@ export default function (api, req, opts, metadata, dbOpts, idbChanges, callback) } catch (e) { return Promise.reject(createError(BAD_ARG, 'Attachment is not a valid base64 string')); } - if (metadata.idb_attachment_format === 'binary') { + if (connectionInfo.idb_attachment_format === 'binary') { attachment.data = binStringToBlobOrBuffer(binData, attachment.content_type); } } else { binData = attachment.data; - if (metadata.idb_attachment_format === 'base64') { + if (connectionInfo.idb_attachment_format === 'base64') { // TODO could run these in parallel, if we cared return new Promise(resolve => { blufferToBase64(attachment.data, function (b64) { @@ -395,13 +395,11 @@ export default function (api, req, opts, metadata, dbOpts, idbChanges, callback) // We _could_ check doc ids here, and skip opening DOC_STORE if all docs are local. // This may marginally slow things down for local docs. It seems pragmatic to keep // the code simple and optimise for calls to bulkDocs() which include non-local docs. - api._openTransactionSafely([DOC_STORE, META_LOCAL_STORE], 'readwrite', function (err, _txn) { + api._openTransactionSafely([DOC_STORE, META_LOCAL_STORE], 'readwrite', function (err, txn) { if (err) { return callback(err); } - txn = _txn; - txn.onabort = function () { callback(error || createError(UNKNOWN_ERROR, 'transaction was aborted')); }; @@ -412,8 +410,11 @@ export default function (api, req, opts, metadata, dbOpts, idbChanges, callback) callback(null, results); }; - // We would like to use promises here, but idb sucks - fetchExistingDocs(txn, docs); + const metaStore = txn.objectStore(META_LOCAL_STORE); + metaStore.get(META_LOCAL_STORE).onsuccess = function (e) { + const metadata = e.target.result; + fetchExistingDocs(txn, metadata, docs); + }; }); }).catch(function (err) { callback(err); diff --git a/packages/node_modules/pouchdb-adapter-indexeddb/src/index.js b/packages/node_modules/pouchdb-adapter-indexeddb/src/index.js index a88bd326b8..44867af5ef 100644 --- a/packages/node_modules/pouchdb-adapter-indexeddb/src/index.js +++ b/packages/node_modules/pouchdb-adapter-indexeddb/src/index.js @@ -34,14 +34,29 @@ function IndexeddbPouch(dbOpts, callback) { } const api = this; - let metadata = {}; + + // When the database is opened, we cache the "static" metadata fields i.e. + // those that do not change when docs are updated. These are needed for some + // async operations (e.g. preprocessing attachments) that cannot be performed + // in the middle of a transaction due to the nature of the IndexedDB API, so + // they need to know these settings without having to read from the object + // store. "Dynamic" values like `doc_count` and `seq` must always be read + // from the object store so that they are not stale. + const CACHED_INFO_KEYS = ['db_uuid', 'idb_attachment_format']; + const connectionInfo = {}; + + function updateConnectionInfo(metadata) { + for (const key of CACHED_INFO_KEYS) { + connectionInfo[key] = metadata[key]; + } + } // Wrapper that gives you an active DB handle. You probably want $t. const $ = function (fun) { return function () { const args = Array.prototype.slice.call(arguments); setup(openDatabases, api, dbOpts).then(function (res) { - metadata = res.metadata; + updateConnectionInfo(res.metadata); args.unshift(res.idb); fun.apply(api, args); }).catch(function (err) { @@ -60,9 +75,8 @@ function IndexeddbPouch(dbOpts, callback) { const args = Array.prototype.slice.call(arguments); return setup(openDatabases, api, dbOpts).then(function (res) { - metadata = res.metadata; + updateConnectionInfo(res.metadata); args.unshift(res.idb); - return fun.apply(api, args); }); }; @@ -78,7 +92,7 @@ function IndexeddbPouch(dbOpts, callback) { const args = Array.prototype.slice.call(arguments); const txn = {}; setup(openDatabases, api, dbOpts).then(function (res) { - metadata = res.metadata; + updateConnectionInfo(res.metadata); txn.txn = res.idb.transaction(stores, mode); }).catch(function (err) { console.error('Failed to establish transaction safely'); @@ -97,16 +111,27 @@ function IndexeddbPouch(dbOpts, callback) { }, stores, mode)(callback); }; + api._getMetadata = $t(function (txn, cb) { + const metaStore = txn.txn.objectStore(META_LOCAL_STORE); + metaStore.get(META_LOCAL_STORE).onsuccess = function (e) { + cb(null, e.target.result); + }; + }, [META_LOCAL_STORE]); + api._remote = false; api.type = function () { return ADAPTER_NAME; }; - api._id = $(function (_, cb) { - cb(null, metadata.db_uuid); - }); + api._id = function (cb) { + api._getMetadata(function (err, metadata) { + cb(null, metadata.db_uuid); + }); + }; - api._info = $(function (_, cb) { - return info(metadata, cb); - }); + api._info = function (cb) { + api._getMetadata(function (err, metadata) { + return info(metadata, cb); + }); + }; api._get = $t(get, [DOC_STORE]); api._getLocal = $t(function (txn, id, callback) { @@ -114,12 +139,16 @@ function IndexeddbPouch(dbOpts, callback) { }, [META_LOCAL_STORE]); api._bulkDocs = $(function (_, req, opts, callback) { - bulkDocs(api, req, opts, metadata, dbOpts, idbChanges, callback); + bulkDocs(api, req, opts, connectionInfo, dbOpts, idbChanges, callback); }); api._allDocs = $t(function (txn, opts, cb) { - allDocs(txn, metadata, opts, cb); - }, [DOC_STORE]); + const metaStore = txn.txn.objectStore(META_LOCAL_STORE); + metaStore.get(META_LOCAL_STORE).onsuccess = function (e) { + const metadata = e.target.result; + allDocs(txn, metadata, opts, cb); + }; + }, [DOC_STORE, META_LOCAL_STORE]); api._getAttachment = getAttachment; diff --git a/packages/node_modules/pouchdb-adapter-indexeddb/src/setup.js b/packages/node_modules/pouchdb-adapter-indexeddb/src/setup.js index fc3d1e920e..18734aeb38 100644 --- a/packages/node_modules/pouchdb-adapter-indexeddb/src/setup.js +++ b/packages/node_modules/pouchdb-adapter-indexeddb/src/setup.js @@ -115,7 +115,9 @@ function upgradePouchDbSchema(dbName, db, tx, pouchdbVersion) { } function openDatabase(openDatabases, api, opts, resolve, reject) { - const openReq = indexedDB.open(opts.name, createIdbVersion()); + const openReq = opts.versionChangedWhileOpen ? + indexedDB.open(opts.name) : + indexedDB.open(opts.name, createIdbVersion()); openReq.onupgradeneeded = function (e) { if (e.oldVersion > 0 && e.oldVersion < versionMultiplier) { @@ -183,15 +185,23 @@ function openDatabase(openDatabases, api, opts, resolve, reject) { idb.close(); }; + // In IndexedDB you can only change the version, and thus the schema, when you are opening the database. + // versionChangedWhileOpen means that something else outside of our control has likely updated the version. + // One way this could happen is if you open multiple tabs, as the version number changes each time the database is opened. + // If we suspect this we close the db and tag it, so that next time it's accessed it reopens the DB with the current version + // as opposed to upping the version again + // This avoids infinite loops of version updates if you have multiple tabs open idb.onversionchange = function () { console.log('Database was made stale, closing handle'); - delete openDatabases[opts.name]; + openDatabases[opts.name].versionChangedWhileOpen = true; idb.close(); }; idb.onclose = function () { console.log('Database was made stale, closing handle'); - delete openDatabases[opts.name]; + if (opts.name in openDatabases) { + openDatabases[opts.name].versionChangedWhileOpen = true; + } }; let metadata = {id: META_LOCAL_STORE}; @@ -247,7 +257,10 @@ function openDatabase(openDatabases, api, opts, resolve, reject) { } export default function (openDatabases, api, opts) { - if (!openDatabases[opts.name]) { + if (!openDatabases[opts.name] || openDatabases[opts.name].versionChangedWhileOpen) { + opts.versionChangedWhileOpen = openDatabases[opts.name] && + openDatabases[opts.name].versionChangedWhileOpen; + openDatabases[opts.name] = new Promise(function (resolve, reject) { openDatabase(openDatabases, api, opts, resolve, reject); });