From aa719d1aaadcfdacda53ae613dacc19bc1e02275 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20K=C3=B6hler?= Date: Mon, 2 Nov 2026 10:22:24 +0100 Subject: [PATCH 1/6] [LokiJS] Opt-in exclusiveLock: one tab at a time holds the database With exclusiveLock: true, the adapter takes an exclusive Web Lock per database before opening it. Other tabs wait until the holder is closed and get onDatabaseLockedElsewhere. If the lock is lost, the driver breaks (stops saving, rejects changes). Without navigator.locks, the database opens without a lock and a warning is logged. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG-Unreleased.md | 2 + docs-website/docs/docs/Setup.md | 10 +- src/adapters/lokijs/databaseLock.test.js | 188 +++++++++++++++++++ src/adapters/lokijs/index.d.ts | 11 ++ src/adapters/lokijs/index.js | 11 ++ src/adapters/lokijs/worker/DatabaseDriver.js | 4 + src/adapters/lokijs/worker/databaseLock.js | 74 ++++++++ src/adapters/lokijs/worker/lokiExtensions.js | 2 +- 8 files changed, 299 insertions(+), 3 deletions(-) create mode 100644 src/adapters/lokijs/databaseLock.test.js create mode 100644 src/adapters/lokijs/worker/databaseLock.js diff --git a/CHANGELOG-Unreleased.md b/CHANGELOG-Unreleased.md index 12d8eadaf..f15d29210 100644 --- a/CHANGELOG-Unreleased.md +++ b/CHANGELOG-Unreleased.md @@ -11,6 +11,8 @@ - [LokiJS] New `onInMemoryFallback` option: called when the database falls back to being in-memory only - New `Collection.observeRecord(id)`: a failsafe version of `findAndObserve(id)` that emits `null` while the record does not exist (instead of erroring), emits the record once it is created, and emits `null` (instead of completing) when it is deleted. `@experimentalFailsafe` relations now use it, so their `observe()` recovers when a missing related record is created later +- [LokiJS] New opt-in `exclusiveLock` option: only one browser tab at a time has the database open (using the Web Locks API), so tabs no longer overwrite each other's changes or undo a reset done in another tab. Other tabs wait until it's closed, and `onDatabaseLockedElsewhere` is called. Without the Web Locks API, the database is opened without a lock + ### Fixes - [LokiJS] Multitab sync issue fix diff --git a/docs-website/docs/docs/Setup.md b/docs-website/docs/docs/Setup.md index 19569cea9..2caf335a6 100644 --- a/docs-website/docs/docs/Setup.md +++ b/docs-website/docs/docs/Setup.md @@ -100,6 +100,11 @@ const adapter = new LokiJSAdapter({ onInMemoryFallback: (reason) => { // IndexedDB is unavailable, so data will be lost when the page is closed -- warn the user }, + // (Opt-in) Only one browser tab at a time has the database open (uses the Web Locks API) + exclusiveLock: true, + onDatabaseLockedElsewhere: () => { + // The database is open in another tab. Setup waits until that tab is closed -- tell the user + }, extraIncrementalIDBOptions: { onDidOverwrite: () => { // Called when this adapter is forced to overwrite contents of IndexedDB. @@ -127,8 +132,9 @@ On the web, `database.write()` resolves once changes are applied in memory. Loki Multiple tabs of the same app share one IndexedDB database, but each tab keeps its own in-memory copy: - When another tab deletes the database (e.g. on logout), this tab stops saving to IndexedDB, rejects further changes (including `unsafeExecute`), and calls `onPersistenceError` and `onversionchange`. Reload the page to continue. -- When two tabs make changes concurrently, the last tab to save overwrites changes saved by the other one (`onDidOverwrite` is called). WatermelonDB does not coordinate writers across tabs. -- `onPersistenceError`, `onInMemoryFallback`, `onQuotaExceededError` and `extraIncrementalIDBOptions` callbacks only work with `useWebWorker: false`. +- When two tabs make changes concurrently, the last tab to save overwrites changes saved by the other one (`onDidOverwrite` is called). By default, WatermelonDB does not coordinate writers across tabs. +- With `exclusiveLock: true`, only one tab at a time has the database open, using the [Web Locks API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Locks_API). In other tabs, setup (and all database operations) wait until that tab is closed, and `onDatabaseLockedElsewhere` is called. If the lock is lost (e.g. stolen using DevTools), the tab stops saving and rejects further changes. Where the Web Locks API is unavailable (old browsers, insecure contexts), the database is opened without a lock and a warning is logged. +- `onPersistenceError`, `onInMemoryFallback`, `onQuotaExceededError`, `onDatabaseLockedElsewhere` and `extraIncrementalIDBOptions` callbacks only work with `useWebWorker: false`. ::: * * * diff --git a/src/adapters/lokijs/databaseLock.test.js b/src/adapters/lokijs/databaseLock.test.js new file mode 100644 index 000000000..70057f748 --- /dev/null +++ b/src/adapters/lokijs/databaseLock.test.js @@ -0,0 +1,188 @@ +import { LokiMemoryAdapter } from 'lokijs' + +import { testSchema, mockTaskRaw } from '../__tests__/helpers' +import logger from '../../utils/common/logger' + +import LokiJSAdapter from './index' +import DatabaseAdapterCompat from '../compat' + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) + +// Mimics the Web Locks API (navigator.locks) shared by all tabs of the same origin +class FakeLockManager { + held = new Map() + + waiting = new Map() + + requestCount = 0 + + request(name, options, callback) { + this.requestCount += 1 + return new Promise((resolve, reject) => { + const grant = () => { + const holder = { reject } + this.held.set(name, holder) + Promise.resolve(callback({ name, mode: options.mode })).then( + (value) => { + this._release(name, holder) + resolve(value) + }, + (error) => { + this._release(name, holder) + reject(error) + }, + ) + } + if (!this.held.has(name)) { + grant() + } else if (options.ifAvailable) { + Promise.resolve(callback(null)).then(resolve, reject) + } else { + this.waiting.set(name, [...(this.waiting.get(name) || []), grant]) + } + }) + } + + _release(name, holder) { + if (this.held.get(name) !== holder) { + return + } + this.held.delete(name) + const [next, ...rest] = this.waiting.get(name) || [] + this.waiting.set(name, rest) + next && next() + } + + // The browser releases locks held by a tab when it's closed + closeHolder(name) { + this._release(name, this.held.get(name)) + } + + // What happens to the holder when another tab requests the lock with `steal: true` + steal(name) { + const holder = this.held.get(name) + this.held.delete(name) + holder.reject(new DOMException('Lock broken by another request', 'AbortError')) + } +} + +const originalNavigator = Object.getOwnPropertyDescriptor(global, 'navigator') +const setNavigator = (value) => { + Object.defineProperty(global, 'navigator', { value, configurable: true, writable: true }) +} + +let locks +let adapters = [] + +beforeEach(() => { + locks = new FakeLockManager() + setNavigator({ locks }) +}) + +afterEach(() => { + adapters.forEach((adapter) => { + const loki = adapter._driver?.loki + loki && loki.autosaveDisable() + }) + adapters = [] + if (originalNavigator) { + Object.defineProperty(global, 'navigator', originalNavigator) + } else { + delete global.navigator + } +}) + +// Each adapter is like a separate tab of the same app +const openTab = (options) => { + const lokiAdapter = new LokiJSAdapter({ + dbName: 'app', + schema: testSchema, + useWebWorker: false, + useIncrementalIndexedDB: true, + _testLokiAdapter: new LokiMemoryAdapter(), + ...options, + }) + adapters.push(lokiAdapter) + return new DatabaseAdapterCompat(lokiAdapter) +} + +const isSettled = async (promise) => { + let settled = false + promise.then( + () => { + settled = true + }, + () => { + settled = true + }, + ) + await sleep(20) + return settled +} + +describe('LokiJSAdapter exclusiveLock', () => { + it('opens the database in one tab at a time, and calls onDatabaseLockedElsewhere', async () => { + const tab1 = openTab({ exclusiveLock: true }) + await tab1.getLocal('foo') + expect(locks.held.has('WatermelonDB:app')).toBe(true) + + const onDatabaseLockedElsewhere = jest.fn() + const tab2 = openTab({ exclusiveLock: true, onDatabaseLockedElsewhere }) + const tab2Ready = tab2.getLocal('foo') + expect(await isSettled(tab2Ready)).toBe(false) + expect(onDatabaseLockedElsewhere).toHaveBeenCalledTimes(1) + + // first tab is closed - second one opens the database + locks.closeHolder('WatermelonDB:app') + await tab2Ready + await tab2.batch([['create', 'tasks', mockTaskRaw({ id: 't1' })]]) + expect(onDatabaseLockedElsewhere).toHaveBeenCalledTimes(1) + }) + + it('waits for the lock without onDatabaseLockedElsewhere', async () => { + const tab1 = openTab({ exclusiveLock: true }) + await tab1.getLocal('foo') + + const tab2Ready = openTab({ exclusiveLock: true }).getLocal('foo') + expect(await isSettled(tab2Ready)).toBe(false) + + locks.closeHolder('WatermelonDB:app') + await tab2Ready + }) + + it('uses a separate lock for each database', async () => { + await openTab({ exclusiveLock: true }).getLocal('foo') + await openTab({ exclusiveLock: true, dbName: 'other' }).getLocal('foo') + expect(Array.from(locks.held.keys())).toEqual(['WatermelonDB:app', 'WatermelonDB:other']) + }) + + it('stops saving and rejects changes when the lock is lost', async () => { + const _onFatalError = jest.fn() + const tab = openTab({ exclusiveLock: true, _onFatalError }) + await tab.batch([['create', 'tasks', mockTaskRaw({ id: 't1' })]]) + + locks.steal('WatermelonDB:app') + await sleep(0) + + expect(_onFatalError).toHaveBeenCalledTimes(1) + expect(_onFatalError.mock.calls[0][0].name).toBe('DatabaseLockLostError') + expect(adapters[0]._driver.loki.autosave).toBe(false) + await expect(tab.batch([['create', 'tasks', mockTaskRaw({ id: 't2' })]])).rejects.toThrow( + 'broken', + ) + }) + + it('opens the database without a lock if Web Locks API is not available', async () => { + const warn = jest.spyOn(logger, 'warn') + setNavigator({}) + await openTab({ exclusiveLock: true }).getLocal('foo') + await openTab({ exclusiveLock: true }).getLocal('foo') + expect(warn).toHaveBeenCalledWith(expect.stringContaining('Web Locks API')) + }) + + it('does not use locks unless enabled', async () => { + await openTab({}).getLocal('foo') + await openTab({}).getLocal('foo') + expect(locks.requestCount).toBe(0) + }) +}) diff --git a/src/adapters/lokijs/index.d.ts b/src/adapters/lokijs/index.d.ts index 6a0a2b1e4..c176837c4 100644 --- a/src/adapters/lokijs/index.d.ts +++ b/src/adapters/lokijs/index.d.ts @@ -50,6 +50,17 @@ export type LokiAdapterOptions = $Exact<{ // Called when the database falls back to being in-memory only (see `allowInMemoryFallback`) // Note that this only works when `useWebWorker: false` onInMemoryFallback?: (reason: Error) => void + // (Opt-in) Only one browser tab at a time has the database open, using the Web Locks API + // (`navigator.locks`). Without it, every tab keeps its own in-memory copy of the database, and + // the last tab to save overwrites changes saved by others (also: a reset, e.g. on logout). + // In other tabs, setup waits until the tab holding the database is closed. If the lock is lost + // (e.g. stolen using DevTools), the database stops being saved, and further changes are rejected. + // If the Web Locks API is not available, the database is opened without a lock. + exclusiveLock?: boolean + // Called when `exclusiveLock` is enabled and the database is open in another tab. Setup (and all + // database operations) wait until the other tab is closed, so use it to tell the user to close it. + // Note that this only works when `useWebWorker: false` + onDatabaseLockedElsewhere?: () => void // extra options passed to Loki constructor extraLokiOptions?: $Exact<{ autosave?: boolean diff --git a/src/adapters/lokijs/index.js b/src/adapters/lokijs/index.js index 9d46042b6..cdeb43723 100644 --- a/src/adapters/lokijs/index.js +++ b/src/adapters/lokijs/index.js @@ -54,6 +54,17 @@ export type LokiAdapterOptions = $Exact<{ // Called when the database falls back to being in-memory only (see `allowInMemoryFallback`) // Note that this only works when `useWebWorker: false` onInMemoryFallback?: (reason: Error) => void, + // (Opt-in) Only one browser tab at a time has the database open, using the Web Locks API + // (`navigator.locks`). Without it, every tab keeps its own in-memory copy of the database, and + // the last tab to save overwrites changes saved by others (also: a reset, e.g. on logout). + // In other tabs, setup waits until the tab holding the database is closed. If the lock is lost + // (e.g. stolen using DevTools), the database stops being saved, and further changes are rejected. + // If the Web Locks API is not available, the database is opened without a lock. + exclusiveLock?: boolean, + // Called when `exclusiveLock` is enabled and the database is open in another tab. Setup (and all + // database operations) wait until the other tab is closed, so use it to tell the user to close it. + // Note that this only works when `useWebWorker: false` + onDatabaseLockedElsewhere?: () => void, // extra options passed to Loki constructor extraLokiOptions?: $Exact<{ autosave?: boolean, diff --git a/src/adapters/lokijs/worker/DatabaseDriver.js b/src/adapters/lokijs/worker/DatabaseDriver.js index 9c035ce00..15ac32db5 100644 --- a/src/adapters/lokijs/worker/DatabaseDriver.js +++ b/src/adapters/lokijs/worker/DatabaseDriver.js @@ -24,6 +24,7 @@ import type { Loki, LokiCollection } from '../type' import { newLoki, deleteDatabase, lokiFatalError } from './lokiExtensions' import { executeQuery, executeCount } from './executeQuery' +import { acquireDatabaseLock } from './databaseLock' import type { LokiAdapterOptions } from '../index' @@ -59,6 +60,9 @@ export default class DatabaseDriver { } async setUp(): Promise { + if (this.options.exclusiveLock) { + await acquireDatabaseLock(this.options, (error) => this._breakDriver(error)) + } await this._openDatabase() await this._migrateIfNeeded() } diff --git a/src/adapters/lokijs/worker/databaseLock.js b/src/adapters/lokijs/worker/databaseLock.js new file mode 100644 index 000000000..5aa5fffc2 --- /dev/null +++ b/src/adapters/lokijs/worker/databaseLock.js @@ -0,0 +1,74 @@ +// @flow +/* eslint-disable no-undef */ + +// don't import the whole utils/ here! +import logger from '../../../utils/common/logger' +import type { LokiAdapterOptions } from '../index' + +import { callSafely } from './lokiExtensions' + +// Never settles - the lock is held until the page (or worker) is closed +const holdForever = (): Promise => new Promise(() => {}) + +// Acquires an exclusive Web Lock for this database, so that only one browser tab at a time has it +// open (see `exclusiveLock` option). Resolves once the lock is held. If another tab holds it, +// `onDatabaseLockedElsewhere` is called, and this waits until that tab is closed. +// `onLockLost` is called if the lock is taken away later (e.g. stolen using DevTools). +// If the Web Locks API is unavailable, the database is opened without a lock. +export async function acquireDatabaseLock( + options: LokiAdapterOptions, + onLockLost: (error: Error) => void, +): Promise { + // $FlowFixMe + const locks = typeof navigator !== 'undefined' ? navigator.locks : undefined + if (!locks || typeof locks.request !== 'function') { + logger.warn( + '[Loki] exclusiveLock is enabled, but the Web Locks API (navigator.locks) is not available (e.g. old browser or insecure context). Opening the database without a lock, so changes made in multiple tabs can overwrite each other', + ) + return + } + + const name = `WatermelonDB:${options.dbName || 'loki'}` + + // NOTE: `request()` resolves (or rejects) when the lock is released, and rejects with AbortError + // if it is stolen. Rejections before the lock is granted are passed to the caller. + const request = (lockOptions: Object): Promise => + new Promise((resolve, reject) => { + let isGranted = false + locks + .request(name, lockOptions, (lock) => { + if (!lock) { + // not available (ifAvailable: true) + resolve(false) + return undefined + } + isGranted = true + resolve(true) + return holdForever() + }) + .then( + () => {}, + (error) => { + if (isGranted) { + logger.error('[Loki] Database lock was lost', error) + const lostError = new Error( + `[Loki] Exclusive database lock was lost (${error?.name}: ${error?.message}). To avoid overwriting changes made in another tab, this database will not be saved anymore. App must be reloaded.`, + ) + lostError.name = 'DatabaseLockLostError' + onLockLost(lostError) + } else { + reject(error) + } + }, + ) + }) + + if (await request({ mode: 'exclusive', ifAvailable: true })) { + return + } + + logger.warn(`[Loki] Database is open in another tab. Waiting until it's closed...`) + callSafely(options.onDatabaseLockedElsewhere, undefined) + await request({ mode: 'exclusive' }) + logger.log('[Loki] Database lock acquired') +} diff --git a/src/adapters/lokijs/worker/lokiExtensions.js b/src/adapters/lokijs/worker/lokiExtensions.js index cf7848d11..524d4c01d 100644 --- a/src/adapters/lokijs/worker/lokiExtensions.js +++ b/src/adapters/lokijs/worker/lokiExtensions.js @@ -8,7 +8,7 @@ import type { Loki } from '../type' // App callbacks are called from Loki timers, IndexedDB event handlers and adapter setup, and must // not change what happens next (or prevent other callbacks), so errors thrown by them are logged -function callSafely(callback: ?(T) => void, arg: T): void { +export function callSafely(callback: ?(T) => void, arg: T): void { try { callback && callback(arg) } catch (error) { From 6ecf77941ad64ea2840f00bf3fd4bcbd8bb34fe2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20K=C3=B6hler?= Date: Mon, 2 Nov 2026 10:38:06 +0100 Subject: [PATCH 2/6] Stop a database that finishes loading after the driver broke If the exclusive lock is lost while Loki is loading (during setup or reset), the new instance is now stopped from saving and setup or reset fails before the schema is set up, so this tab can't overwrite the new lock holder's data. Co-Authored-By: Claude Opus 5.5 --- src/adapters/lokijs/databaseLock.test.js | 94 ++++++++++++++++++++ src/adapters/lokijs/worker/DatabaseDriver.js | 10 ++- 2 files changed, 103 insertions(+), 1 deletion(-) diff --git a/src/adapters/lokijs/databaseLock.test.js b/src/adapters/lokijs/databaseLock.test.js index 70057f748..cae71c1bd 100644 --- a/src/adapters/lokijs/databaseLock.test.js +++ b/src/adapters/lokijs/databaseLock.test.js @@ -66,6 +66,51 @@ class FakeLockManager { } } +const waitFor = async (predicate) => { + for (let i = 0; i < 200; i += 1) { + if (predicate()) { + return + } + // eslint-disable-next-line no-await-in-loop + await sleep(5) + } + throw new Error('Timed out waiting for condition') +} + +// In-memory incremental persistence adapter. While `holdLoads` is set, loads only finish when +// `finishLoad()` is called +class HeldLoadAdapter { + mode = 'incremental' + + holdLoads = false + + finishLoad = null + + saves = 0 + + loadDatabase(dbname, callback) { + const finish = () => { + this.finishLoad = null + callback(null) + } + if (this.holdLoads) { + this.finishLoad = finish + } else { + finish() + } + } + + saveDatabase(dbname, getLokiCopy, callback) { + getLokiCopy() + this.saves += 1 + setTimeout(() => callback(null), 0) + } + + deleteDatabase(dbname, callback) { + callback({ success: true }) + } +} + const originalNavigator = Object.getOwnPropertyDescriptor(global, 'navigator') const setNavigator = (value) => { Object.defineProperty(global, 'navigator', { value, configurable: true, writable: true }) @@ -100,6 +145,7 @@ const openTab = (options) => { useWebWorker: false, useIncrementalIndexedDB: true, _testLokiAdapter: new LokiMemoryAdapter(), + extraLokiOptions: { autosaveInterval: 5 }, ...options, }) adapters.push(lokiAdapter) @@ -172,6 +218,54 @@ describe('LokiJSAdapter exclusiveLock', () => { ) }) + it('does not save or set up a database that was loading when the lock was lost', async () => { + const persistenceAdapter = new HeldLoadAdapter() + persistenceAdapter.holdLoads = true + const _onFatalError = jest.fn() + const onSetUpError = jest.fn() + const tab = openTab({ + exclusiveLock: true, + _onFatalError, + onSetUpError, + _testLokiAdapter: persistenceAdapter, + }) + const afterSetUp = tab.getLocal('foo') + await waitFor(() => persistenceAdapter.finishLoad) + + locks.steal('WatermelonDB:app') + await sleep(0) + persistenceAdapter.finishLoad() + + await expect(afterSetUp).rejects.toThrow('driver is not set up') + expect(onSetUpError).toHaveBeenCalledTimes(1) + expect(onSetUpError.mock.calls[0][0].message).toMatch('broken') + expect(_onFatalError).toHaveBeenCalledTimes(1) + expect(adapters[0]._driver).toBe(undefined) + expect(persistenceAdapter.saves).toBe(0) + }) + + it('does not save a database that was reloading during reset when the lock was lost', async () => { + const persistenceAdapter = new HeldLoadAdapter() + const tab = openTab({ exclusiveLock: true, _testLokiAdapter: persistenceAdapter }) + await tab.batch([['create', 'tasks', mockTaskRaw({ id: 't1' })]]) + const driver = adapters[0]._driver + + persistenceAdapter.holdLoads = true + const reset = tab.unsafeResetDatabase() + await waitFor(() => persistenceAdapter.finishLoad) + const savesBeforeReload = persistenceAdapter.saves + locks.steal('WatermelonDB:app') + await sleep(0) + persistenceAdapter.finishLoad() + + await expect(reset).rejects.toThrow('broken') + // the reloaded database is not set up and never saved + expect(driver.loki.autosave).toBe(false) + expect(driver.loki.getCollection('tasks')).toBe(null) + await sleep(30) + expect(persistenceAdapter.saves).toBe(savesBeforeReload) + }) + it('opens the database without a lock if Web Locks API is not available', async () => { const warn = jest.spyOn(logger, 'warn') setNavigator({}) diff --git a/src/adapters/lokijs/worker/DatabaseDriver.js b/src/adapters/lokijs/worker/DatabaseDriver.js index 15ac32db5..6a49f4c98 100644 --- a/src/adapters/lokijs/worker/DatabaseDriver.js +++ b/src/adapters/lokijs/worker/DatabaseDriver.js @@ -298,7 +298,15 @@ export default class DatabaseDriver { async _openDatabase(): Promise { logger.log('[Loki] Initializing IndexedDB') - this.loki = await newLoki(this.options, (error) => this._breakDriver(error)) + const loki = await newLoki(this.options, (error) => this._breakDriver(error)) + this.loki = loki + + // If the driver broke while the database was loading (e.g. the exclusive lock was lost), the + // new instance must not save either, and setup (or reset) must not continue + if (this._isBroken) { + lokiFatalError(loki) + this._assertNotBroken() + } logger.log('[Loki] Database loaded') } From 212d8605cad26e92d5d4e547ac828a969c7c0ac6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20K=C3=B6hler?= Date: Mon, 2 Nov 2026 10:42:57 +0100 Subject: [PATCH 3/6] Log errors thrown by callbacks when the database lock is lost Co-Authored-By: Claude Opus 5.5 --- src/adapters/lokijs/databaseLock.test.js | 24 ++++++++++++++++++++++ src/adapters/lokijs/worker/databaseLock.js | 2 +- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/adapters/lokijs/databaseLock.test.js b/src/adapters/lokijs/databaseLock.test.js index cae71c1bd..11e707271 100644 --- a/src/adapters/lokijs/databaseLock.test.js +++ b/src/adapters/lokijs/databaseLock.test.js @@ -218,6 +218,30 @@ describe('LokiJSAdapter exclusiveLock', () => { ) }) + it('logs errors thrown by callbacks when the lock is lost', async () => { + const unhandledRejections = [] + const onUnhandledRejection = (reason) => unhandledRejections.push(reason) + process.on('unhandledRejection', onUnhandledRejection) + try { + const _onFatalError = jest.fn(() => { + throw new Error('app callback failed') + }) + const tab = openTab({ exclusiveLock: true, _onFatalError }) + await tab.getLocal('foo') + + locks.steal('WatermelonDB:app') + await sleep(10) + + expect(_onFatalError).toHaveBeenCalledTimes(1) + expect(unhandledRejections).toEqual([]) + await expect(tab.batch([['create', 'tasks', mockTaskRaw({ id: 't1' })]])).rejects.toThrow( + 'broken', + ) + } finally { + process.off('unhandledRejection', onUnhandledRejection) + } + }) + it('does not save or set up a database that was loading when the lock was lost', async () => { const persistenceAdapter = new HeldLoadAdapter() persistenceAdapter.holdLoads = true diff --git a/src/adapters/lokijs/worker/databaseLock.js b/src/adapters/lokijs/worker/databaseLock.js index 5aa5fffc2..fff35496a 100644 --- a/src/adapters/lokijs/worker/databaseLock.js +++ b/src/adapters/lokijs/worker/databaseLock.js @@ -55,7 +55,7 @@ export async function acquireDatabaseLock( `[Loki] Exclusive database lock was lost (${error?.name}: ${error?.message}). To avoid overwriting changes made in another tab, this database will not be saved anymore. App must be reloaded.`, ) lostError.name = 'DatabaseLockLostError' - onLockLost(lostError) + callSafely(onLockLost, lostError) } else { reject(error) } From faca18c8eaaaa1d33f4d5c637149de2625c6e2bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20K=C3=B6hler?= Date: Mon, 2 Nov 2026 10:51:20 +0100 Subject: [PATCH 4/6] Don't delete the database after losing the lock, and match Loki's default name - unsafeResetDatabase() checks that the driver isn't broken right before deleting the database, after waiting for in-flight saves, so a tab that lost the lock meanwhile can't delete the new holder's database. - After a fatal error, callbacks waiting for a save that was in progress (close(), throttledSaveDrain()) are called with an error instead of never being called, so a reset can't hang. - The lock name falls back to Loki's default database name, loki.db. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG-Unreleased.md | 1 + src/adapters/lokijs/databaseLock.test.js | 50 ++++++++++++++++++-- src/adapters/lokijs/worker/DatabaseDriver.js | 3 +- src/adapters/lokijs/worker/databaseLock.js | 3 +- src/adapters/lokijs/worker/lokiExtensions.js | 20 ++++++-- 5 files changed, 69 insertions(+), 8 deletions(-) diff --git a/CHANGELOG-Unreleased.md b/CHANGELOG-Unreleased.md index f15d29210..ff0527338 100644 --- a/CHANGELOG-Unreleased.md +++ b/CHANGELOG-Unreleased.md @@ -18,6 +18,7 @@ - [LokiJS] Multitab sync issue fix - [LokiJS] When IndexedDB database is deleted in another tab (`onversionchange`), the database is no longer re-created from this tab's memory on next autosave. Instead, the adapter stops saving, rejects further changes, and calls `onPersistenceError` - [LokiJS] Failures to save the database to IndexedDB are now always logged +- [LokiJS] After the database stops being saved (e.g. it was deleted in another tab), callbacks waiting for a save that was in progress (from `close()` or `unsafeResetDatabase()`) are now called with an error instead of never being called - [LokiJS] When accessing or opening IndexedDB throws synchronously (e.g. `SecurityError` when storage access is denied), the adapter now falls back to an in-memory database like for other IndexedDB open failures (or fails setup with `allowInMemoryFallback: false`), instead of always failing setup - [Android] Added linker flag for building with 16kB page alignment - [TS] make catchError visible to typescript diff --git a/src/adapters/lokijs/databaseLock.test.js b/src/adapters/lokijs/databaseLock.test.js index 11e707271..459dce721 100644 --- a/src/adapters/lokijs/databaseLock.test.js +++ b/src/adapters/lokijs/databaseLock.test.js @@ -77,8 +77,8 @@ const waitFor = async (predicate) => { throw new Error('Timed out waiting for condition') } -// In-memory incremental persistence adapter. While `holdLoads` is set, loads only finish when -// `finishLoad()` is called +// In-memory incremental persistence adapter. While `holdLoads`/`holdSaves` is set, loads/saves only +// finish when `finishLoad()`/`finishSave()` is called class HeldLoadAdapter { mode = 'incremental' @@ -100,13 +100,28 @@ class HeldLoadAdapter { } } + holdSaves = false + + finishSave = null + + deletes = 0 + saveDatabase(dbname, getLokiCopy, callback) { getLokiCopy() this.saves += 1 - setTimeout(() => callback(null), 0) + const finish = () => { + this.finishSave = null + callback(null) + } + if (this.holdSaves) { + this.finishSave = finish + } else { + setTimeout(finish, 0) + } } deleteDatabase(dbname, callback) { + this.deletes += 1 callback({ success: true }) } } @@ -290,6 +305,35 @@ describe('LokiJSAdapter exclusiveLock', () => { expect(persistenceAdapter.saves).toBe(savesBeforeReload) }) + it('does not delete the database if the lock is lost while reset waits for a save', async () => { + const persistenceAdapter = new HeldLoadAdapter() + const tab = openTab({ exclusiveLock: true, _testLokiAdapter: persistenceAdapter }) + await tab.getLocal('foo') + + persistenceAdapter.holdSaves = true + await tab.batch([['create', 'tasks', mockTaskRaw({ id: 't1' })]]) + await waitFor(() => persistenceAdapter.finishSave) + // (an empty database is reset during setup) + const deletesBeforeReset = persistenceAdapter.deletes + const reset = tab.unsafeResetDatabase() + await sleep(10) + + locks.steal('WatermelonDB:app') + await sleep(0) + persistenceAdapter.finishSave() + + await expect(reset).rejects.toThrow('broken') + expect(persistenceAdapter.deletes).toBe(deletesBeforeReset) + }) + + it(`uses Loki's default database name for the lock if dbName is not passed`, async () => { + await openTab({ exclusiveLock: true, dbName: undefined }).getLocal('foo') + expect(Array.from(locks.held.keys())).toEqual(['WatermelonDB:loki.db']) + + const sameDatabase = openTab({ exclusiveLock: true, dbName: 'loki.db' }).getLocal('foo') + expect(await isSettled(sameDatabase)).toBe(false) + }) + it('opens the database without a lock if Web Locks API is not available', async () => { const warn = jest.spyOn(logger, 'warn') setNavigator({}) diff --git a/src/adapters/lokijs/worker/DatabaseDriver.js b/src/adapters/lokijs/worker/DatabaseDriver.js index 6a49f4c98..4bc7f4cf1 100644 --- a/src/adapters/lokijs/worker/DatabaseDriver.js +++ b/src/adapters/lokijs/worker/DatabaseDriver.js @@ -248,7 +248,8 @@ export default class DatabaseDriver { // A broken driver must not delete and re-create the database (e.g. if it was deleted in // another tab, that would bring it back with this driver's autosave) this._assertNotBroken() - await deleteDatabase(this.loki) + // (the driver can also break while we wait for saves, e.g. if the exclusive lock is lost) + await deleteDatabase(this.loki, () => this._assertNotBroken()) logger.log('[Loki] Database is now reset') diff --git a/src/adapters/lokijs/worker/databaseLock.js b/src/adapters/lokijs/worker/databaseLock.js index fff35496a..d6db675dd 100644 --- a/src/adapters/lokijs/worker/databaseLock.js +++ b/src/adapters/lokijs/worker/databaseLock.js @@ -28,7 +28,8 @@ export async function acquireDatabaseLock( return } - const name = `WatermelonDB:${options.dbName || 'loki'}` + // NOTE: `loki.db` is Loki's default database name (used if dbName is not passed) + const name = `WatermelonDB:${options.dbName || 'loki.db'}` // NOTE: `request()` resolves (or rejects) when the lock is released, and rejects with AbortError // if it is stolen. Rejections before the lock is granted are passed to the caller. diff --git a/src/adapters/lokijs/worker/lokiExtensions.js b/src/adapters/lokijs/worker/lokiExtensions.js index 524d4c01d..e83bd2701 100644 --- a/src/adapters/lokijs/worker/lokiExtensions.js +++ b/src/adapters/lokijs/worker/lokiExtensions.js @@ -241,7 +241,9 @@ export async function newLoki( return _loki } -export async function deleteDatabase(loki: Loki): Promise { +// `assertCanDelete` is called right before deleting, after waiting for saves, and can throw to abort +// (e.g. if the driver broke in the meantime) +export async function deleteDatabase(loki: Loki, assertCanDelete?: () => void): Promise { await new Promise((resolve, reject) => { // Works around a race condition - Loki doesn't disable autosave or drain save queue before // deleting database, so it's possible to delete and then have the database be saved @@ -251,6 +253,7 @@ export async function deleteDatabase(loki: Loki): Promise { // delete database while it is drainSaves(loki, () => { try { + assertCanDelete && assertCanDelete() loki.deleteDatabase({}, (response) => { // LokiIndexedAdapter responds with `{ success: true }`, while // LokiMemory adapter just calls it with no params @@ -284,8 +287,19 @@ function drainSaves(loki: Loki, callback: () => void): void { export function lokiFatalError(loki: Loki): void { try { // below is some very ugly defensive coding, but we're fatal and don't trust anyone anymore - const fatalHandler = () => { - throw new Error('Illegal attempt to save Loki database after a fatal error') + const fatalHandler = (callback: any) => { + const error = new Error('Illegal attempt to save Loki database after a fatal error') + // When a save that was in progress finishes, Loki starts the next one for callbacks that wait + // for that save (e.g. from close() or throttledSaveDrain()). If we threw, they would never be + // called, so they're called with an error instead (and so is the callback of this save) + const waiting = loki.throttledCallbacks || [] + loki.throttledCallbacks = [] + if (!callback && !waiting.length) { + throw error + } + waiting.concat([callback]).forEach((waitingCallback) => { + typeof waitingCallback === 'function' && waitingCallback(error) + }) } loki.save = fatalHandler loki.saveDatabase = fatalHandler From 00ce686d5224b1c3a7f49049ec5a243e43c09cb0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20K=C3=B6hler?= Date: Mon, 2 Nov 2026 11:02:03 +0100 Subject: [PATCH 5/6] Test lock handoff and lock loss against IndexedDB Uses fake-indexeddb with the real IncrementalIndexedDBAdapter: a waiting tab gets all saved changes, a tab that lost the lock doesn't overwrite the new holder's changes, and a reset that lost the lock doesn't delete the new holder's database. Co-Authored-By: Claude Opus 5.5 --- package.json | 1 + src/adapters/lokijs/databaseLock.test.js | 81 ++++++++++++++++++++++++ yarn.lock | 5 ++ 3 files changed, 87 insertions(+) diff --git a/package.json b/package.json index 5bc0ce5fe..8eaec2090 100644 --- a/package.json +++ b/package.json @@ -145,6 +145,7 @@ "eslint-plugin-react": "^7.34.2", "eslint-plugin-react-hooks": "^4.6.2", "execa": "^7.0.0", + "fake-indexeddb": "^6.2.5", "fast-async": "^7.0", "flow-bin": "0.200.0", "fs-extra": "^11.2.0", diff --git a/src/adapters/lokijs/databaseLock.test.js b/src/adapters/lokijs/databaseLock.test.js index 459dce721..a801af453 100644 --- a/src/adapters/lokijs/databaseLock.test.js +++ b/src/adapters/lokijs/databaseLock.test.js @@ -1,3 +1,4 @@ +import 'fake-indexeddb/auto' import { LokiMemoryAdapter } from 'lokijs' import { testSchema, mockTaskRaw } from '../__tests__/helpers' @@ -334,6 +335,86 @@ describe('LokiJSAdapter exclusiveLock', () => { expect(await isSettled(sameDatabase)).toBe(false) }) + describe('with IndexedDB', () => { + // Like openTab(), but persisted to (fake) IndexedDB using IncrementalIndexedDBAdapter + const openIdbTab = (dbName, options) => + openTab({ dbName, _testLokiAdapter: undefined, exclusiveLock: true, ...options }) + + const lokiOf = (index) => adapters[index]._driver.loki + + const closeTab = async (index) => { + const loki = lokiOf(index) + await waitFor(() => !loki.autosaveDirty() && !loki.throttledSavePending) + loki.autosaveDisable() + locks.closeHolder(`WatermelonDB:${loki.filename}`) + } + + it('hands the database over to a waiting tab with all saved changes', async () => { + const dbName = `test${Math.random()}` + const tab1 = openIdbTab(dbName) + await tab1.batch([['create', 'tasks', mockTaskRaw({ id: 't1' })]]) + + const tab2 = openIdbTab(dbName) + const tab2Ready = tab2.getLocal('foo') + await tab1.batch([['create', 'tasks', mockTaskRaw({ id: 't2' })]]) + expect(await isSettled(tab2Ready)).toBe(false) + + await closeTab(0) + await tab2Ready + expect((await tab2.find('tasks', 't1')).id).toBe('t1') + expect((await tab2.find('tasks', 't2')).id).toBe('t2') + }) + + it(`does not overwrite the new holder's changes after losing the lock`, async () => { + const dbName = `test${Math.random()}` + const tab1 = openIdbTab(dbName) + await tab1.batch([['create', 'tasks', mockTaskRaw({ id: 't1' })]]) + await waitFor(() => !lokiOf(0).autosaveDirty() && !lokiOf(0).throttledSavePending) + + // tab1 has unsaved changes (and maybe a save in flight) when another tab takes over + await tab1.batch([['create', 'tasks', mockTaskRaw({ id: 'unsaved' })]]) + const tab2 = openIdbTab(dbName) + const tab2Ready = tab2.getLocal('foo') + locks.steal(`WatermelonDB:${dbName}`) + locks.closeHolder(`WatermelonDB:${dbName}`) + await tab2Ready + + await tab2.batch([['destroyPermanently', 'tasks', 't1']]) + await tab2.batch([['create', 'tasks', mockTaskRaw({ id: 't2' })]]) + await expect(tab1.batch([['create', 'tasks', mockTaskRaw({ id: 't3' })]])).rejects.toThrow( + 'broken', + ) + await sleep(50) + await closeTab(1) + + const tab3 = openIdbTab(dbName) + expect(await tab3.find('tasks', 't1')).toBe(null) + expect((await tab3.find('tasks', 't2')).id).toBe('t2') + expect(await tab3.find('tasks', 't3')).toBe(null) + }) + + it(`does not delete the new holder's database when reset loses the lock`, async () => { + const dbName = `test${Math.random()}` + const tab1 = openIdbTab(dbName) + await tab1.batch([['create', 'tasks', mockTaskRaw({ id: 't1' })]]) + + // reset waits for the in-flight save, and loses the lock in the meantime + const reset = tab1.unsafeResetDatabase() + const tab2 = openIdbTab(dbName) + const tab2Ready = tab2.getLocal('foo') + locks.steal(`WatermelonDB:${dbName}`) + locks.closeHolder(`WatermelonDB:${dbName}`) + await reset.catch(() => {}) + await tab2Ready + await tab2.batch([['create', 'tasks', mockTaskRaw({ id: 't2' })]]) + await sleep(50) + await closeTab(1) + + const tab3 = openIdbTab(dbName) + expect((await tab3.find('tasks', 't2')).id).toBe('t2') + }) + }) + it('opens the database without a lock if Web Locks API is not available', async () => { const warn = jest.spyOn(logger, 'warn') setNavigator({}) diff --git a/yarn.lock b/yarn.lock index 15d7db5ad..72262ceeb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5228,6 +5228,11 @@ extglob@^2.0.4: snapdragon "^0.8.1" to-regex "^3.0.1" +fake-indexeddb@^6.2.5: + version "6.2.5" + resolved "https://registry.yarnpkg.com/fake-indexeddb/-/fake-indexeddb-6.2.5.tgz#74285b6821467d6c102af092f0a4517ad5521613" + integrity sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w== + fast-async@^7.0: version "7.0.6" resolved "https://registry.yarnpkg.com/fast-async/-/fast-async-7.0.6.tgz#a9bcc0b4aa6c7a5b25f0cec1a9b7324d13cb169b" From 069fe6c76849b61cfc79aadd7bd72118033ad2a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20K=C3=B6hler?= Date: Mon, 2 Nov 2026 11:33:11 +0100 Subject: [PATCH 6/6] Call every waiting save callback after a fatal error, even if one throws Co-Authored-By: Claude Opus 5.5 --- src/adapters/lokijs/persistence.test.js | 21 ++++++++++++++++++++ src/adapters/lokijs/worker/lokiExtensions.js | 3 ++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/adapters/lokijs/persistence.test.js b/src/adapters/lokijs/persistence.test.js index 9f8aed9bb..48389b72b 100644 --- a/src/adapters/lokijs/persistence.test.js +++ b/src/adapters/lokijs/persistence.test.js @@ -2,6 +2,7 @@ import { testSchema, mockTaskRaw } from '../__tests__/helpers' import LokiJSAdapter from './index' import DatabaseAdapterCompat from '../compat' +import { lokiFatalError } from './worker/lokiExtensions' const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) @@ -340,4 +341,24 @@ describe('LokiJSAdapter in-memory fallback', () => { }) expect(setUpError.cause).toBe(idbError) }) + + it('calls every callback waiting for a save after a fatal error, even if one throws', () => { + const LokiDb = require('lokijs') + const loki = new LokiDb('test', { adapter: new FakeIncrementalAdapter() }) + const first = jest.fn(() => { + throw new Error('callback failed') + }) + const second = jest.fn() + // as if close() and throttledSaveDrain() were waiting for a save in progress + loki.throttledCallbacks = [first, second] + + lokiFatalError(loki) + // what Loki does when the save in progress finishes + loki.saveDatabase() + + expect(first).toHaveBeenCalledTimes(1) + expect(second).toHaveBeenCalledTimes(1) + expect(second.mock.calls[0][0].message).toMatch('after a fatal error') + expect(loki.throttledCallbacks).toEqual([]) + }) }) diff --git a/src/adapters/lokijs/worker/lokiExtensions.js b/src/adapters/lokijs/worker/lokiExtensions.js index 6da969d7b..664ba1cb0 100644 --- a/src/adapters/lokijs/worker/lokiExtensions.js +++ b/src/adapters/lokijs/worker/lokiExtensions.js @@ -363,8 +363,9 @@ export function lokiFatalError(loki: Loki): void { if (!callback && !waiting.length) { throw error } + // (one throwing callback must not prevent the others from being called) waiting.concat([callback]).forEach((waitingCallback) => { - typeof waitingCallback === 'function' && waitingCallback(error) + typeof waitingCallback === 'function' && callSafely(waitingCallback, error) }) } loki.save = fatalHandler