diff --git a/CHANGELOG-Unreleased.md b/CHANGELOG-Unreleased.md index a69ceced8..a22894d3b 100644 --- a/CHANGELOG-Unreleased.md +++ b/CHANGELOG-Unreleased.md @@ -14,6 +14,7 @@ - New `destroyTable({ table })` migration step. On SQLite, it's `DROP TABLE IF EXISTS`. On LokiJS, the collection is removed and its data is deleted from IndexedDB, so it's no longer loaded and parsed on every launch (also if it was already removed from Loki) - [LokiJS] New `unsafeExecuteLoki(loki => {})` migration step, the LokiJS counterpart of `unsafeExecuteSql()`. It's ignored on SQLite - [LokiJS] `unsafeExecuteSql()` migration steps, which are ignored on LokiJS, now log a warning +- [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 @@ -22,6 +23,7 @@ - [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] When a migration fails, changes made by the steps that ran before the failure are no longer autosaved under the old schema version +- [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/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..a801af453 --- /dev/null +++ b/src/adapters/lokijs/databaseLock.test.js @@ -0,0 +1,431 @@ +import 'fake-indexeddb/auto' +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 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`/`holdSaves` is set, loads/saves only +// finish when `finishLoad()`/`finishSave()` 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() + } + } + + holdSaves = false + + finishSave = null + + deletes = 0 + + saveDatabase(dbname, getLokiCopy, callback) { + getLokiCopy() + this.saves += 1 + 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 }) + } +} + +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(), + extraLokiOptions: { autosaveInterval: 5 }, + ...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('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 + 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('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) + }) + + 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({}) + 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 39cdc5e5a..da3d5403d 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/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/DatabaseDriver.js b/src/adapters/lokijs/worker/DatabaseDriver.js index 873d2fa72..581bdcb2d 100644 --- a/src/adapters/lokijs/worker/DatabaseDriver.js +++ b/src/adapters/lokijs/worker/DatabaseDriver.js @@ -25,6 +25,7 @@ import type { Loki, LokiCollection } from '../type' import { newLoki, deleteDatabase, lokiFatalError, destroyCollection } from './lokiExtensions' import { executeQuery, executeCount } from './executeQuery' +import { acquireDatabaseLock } from './databaseLock' import type { LokiAdapterOptions } from '../index' @@ -60,6 +61,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() } @@ -245,7 +249,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') @@ -295,7 +300,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') } diff --git a/src/adapters/lokijs/worker/databaseLock.js b/src/adapters/lokijs/worker/databaseLock.js new file mode 100644 index 000000000..d6db675dd --- /dev/null +++ b/src/adapters/lokijs/worker/databaseLock.js @@ -0,0 +1,75 @@ +// @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 + } + + // 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. + 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' + callSafely(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 b484c510a..664ba1cb0 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) { @@ -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 @@ -350,8 +353,20 @@ function getDestroyedCollections(adapter: any): Set { 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 + } + // (one throwing callback must not prevent the others from being called) + waiting.concat([callback]).forEach((waitingCallback) => { + typeof waitingCallback === 'function' && callSafely(waitingCallback, error) + }) } loki.save = fatalHandler loki.saveDatabase = fatalHandler