From f2d238d96bbea6b82b0ee3d1eaacced44574533d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20K=C3=B6hler?= Date: Sat, 31 Oct 2026 08:14:53 +0100 Subject: [PATCH 1/7] [LokiJS] Report persistence failures and allow refusing in-memory fallback IndexedDB save failures (quota exceeded, aborted transactions, closed connection) were swallowed: with autosave, Loki has no callback to report them to, so `database.write()` resolving did not mean the data was on disk. When IndexedDB was unavailable, the adapter silently fell back to an in-memory database. - Add `onPersistenceError(error)`, wired to Loki's `autosaveCallback`. It is called once per failure (not on every autosave retry), and again after a successful save. Raw IDB events are unwrapped to `event.target.error`, and quota exceeded errors are also passed to `onQuotaExceededError`. Save failures are always logged. - Add `allowInMemoryFallback` (default `true`). With `false`, a missing or unusable IndexedDB fails setup, so `onSetUpError` is called. - Add `onInMemoryFallback(reason)` to learn about the fallback. - When the IndexedDB database is deleted or upgraded by another connection (`versionchange`), stop saving instead of re-creating the database from this tab's memory on the next autosave, break the driver so further changes are rejected, and report it via `onPersistenceError`. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG-Unreleased.md | 6 + docs-website/docs/docs/Setup.md | 22 ++ src/adapters/lokijs/index.d.ts | 17 ++ src/adapters/lokijs/index.js | 17 ++ src/adapters/lokijs/persistence.test.js | 214 +++++++++++++++++++ src/adapters/lokijs/worker/DatabaseDriver.js | 22 +- src/adapters/lokijs/worker/lokiExtensions.js | 150 +++++++++++-- 7 files changed, 430 insertions(+), 18 deletions(-) create mode 100644 src/adapters/lokijs/persistence.test.js diff --git a/CHANGELOG-Unreleased.md b/CHANGELOG-Unreleased.md index 9d43e2bdd..506bf2d3d 100644 --- a/CHANGELOG-Unreleased.md +++ b/CHANGELOG-Unreleased.md @@ -6,9 +6,15 @@ ### New features +- [LokiJS] New `onPersistenceError` option: called when saving the database to IndexedDB fails (e.g. quota exceeded, aborted transaction), once per failure. Quota exceeded save errors are now also passed to `onQuotaExceededError` +- [LokiJS] New `allowInMemoryFallback` option (default: `true`). Pass `false` to make setup fail (and `onSetUpError` be called) instead of silently falling back to an in-memory database when IndexedDB is unavailable +- [LokiJS] New `onInMemoryFallback` option: called when the database falls back to being in-memory only + ### Fixes - [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 - [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 88b940c2e..5cc458c94 100644 --- a/docs-website/docs/docs/Setup.md +++ b/docs-website/docs/docs/Setup.md @@ -89,6 +89,17 @@ const adapter = new LokiJSAdapter({ onSetUpError: (error) => { // Database failed to load -- offer the user to reload the app or log out }, + onPersistenceError: (error) => { + // Saving to IndexedDB failed (e.g. disk full, database deleted in another tab). Changes made + // since the last successful save only exist in memory and will be lost if the page is closed. + // Called once per failure, not on every autosave retry + }, + // (true by default) Pass `false` to make setup fail (and call `onSetUpError`) instead of silently + // falling back to an in-memory database when IndexedDB is unavailable (e.g. some private modes) + allowInMemoryFallback: true, + onInMemoryFallback: (reason) => { + // IndexedDB is unavailable, so data will be lost when the page is closed -- warn the user + }, extraIncrementalIDBOptions: { onDidOverwrite: () => { // Called when this adapter is forced to overwrite contents of IndexedDB. @@ -99,6 +110,7 @@ const adapter = new LokiJSAdapter({ onversionchange: () => { // database was deleted in another browser tab (user logged out), so we must make sure we delete // it in this tab as well - usually best to just refresh the page + // (from this point, this tab no longer saves to IndexedDB and rejects further changes) if (checkIfUserIsLoggedIn()) { window.location.reload() } @@ -109,6 +121,16 @@ const adapter = new LokiJSAdapter({ // The rest is the same! ``` +:::note +On the web, `database.write()` resolves once changes are applied in memory. LokiJS saves them to IndexedDB shortly after (autosave), so a failure to save is only reported via `onPersistenceError`. + +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, 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`. +::: + * * * ## Next steps diff --git a/src/adapters/lokijs/index.d.ts b/src/adapters/lokijs/index.d.ts index e09fb142f..6a0a2b1e4 100644 --- a/src/adapters/lokijs/index.d.ts +++ b/src/adapters/lokijs/index.d.ts @@ -35,6 +35,21 @@ export type LokiAdapterOptions = $Exact<{ // This means that app can't save more data or that it will fall back to using in-memory database only // Note that this only works when `useWebWorker: false` onQuotaExceededError?: (error: Error) => void + // Called when saving the database to IndexedDB fails (e.g. quota exceeded, transaction aborted, + // database closed). Changes made since the last successful save are only kept in memory until + // a later save succeeds, and will be lost if the page is closed. Note that `database.write()` + // resolving does not mean that changes were saved to disk. Called once per failure, not on every + // retry, and called again if saving fails after it succeeded in the meantime. Quota exceeded + // errors are also passed to `onQuotaExceededError`. + // Note that this only works when `useWebWorker: false` + onPersistenceError?: (error: Error) => void + // (true by default) If IndexedDB is not available or fails to open (e.g. in some browsers' private + // mode), the database falls back to being in-memory only and data is lost when the page is closed. + // Pass `false` to make setup fail instead (`onSetUpError` will be called) + allowInMemoryFallback?: boolean + // 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 // extra options passed to Loki constructor extraLokiOptions?: $Exact<{ autosave?: boolean @@ -47,6 +62,8 @@ export type LokiAdapterOptions = $Exact<{ // You might use it as an opportunity to alert user to the potential loss of data onDidOverwrite?: () => void // Called when internal IndexedDB version changed (most likely the database was deleted in another browser tab) + // When this happens, the database stops being saved (so that it's not re-created from memory), + // further changes are rejected, and `onPersistenceError` is called. // Pass a callback to force log out in this copy of the app as well // (Due to a race condition, it's usually best to just reload the web app) // Note that this only works when not using web workers diff --git a/src/adapters/lokijs/index.js b/src/adapters/lokijs/index.js index 1b32021a6..9d46042b6 100644 --- a/src/adapters/lokijs/index.js +++ b/src/adapters/lokijs/index.js @@ -39,6 +39,21 @@ export type LokiAdapterOptions = $Exact<{ // This means that app can't save more data or that it will fall back to using in-memory database only // Note that this only works when `useWebWorker: false` onQuotaExceededError?: (error: Error) => void, + // Called when saving the database to IndexedDB fails (e.g. quota exceeded, transaction aborted, + // database closed). Changes made since the last successful save are only kept in memory until + // a later save succeeds, and will be lost if the page is closed. Note that `database.write()` + // resolving does not mean that changes were saved to disk. Called once per failure, not on every + // retry, and called again if saving fails after it succeeded in the meantime. Quota exceeded + // errors are also passed to `onQuotaExceededError`. + // Note that this only works when `useWebWorker: false` + onPersistenceError?: (error: Error) => void, + // (true by default) If IndexedDB is not available or fails to open (e.g. in some browsers' private + // mode), the database falls back to being in-memory only and data is lost when the page is closed. + // Pass `false` to make setup fail instead (`onSetUpError` will be called) + allowInMemoryFallback?: boolean, + // 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, // extra options passed to Loki constructor extraLokiOptions?: $Exact<{ autosave?: boolean, @@ -51,6 +66,8 @@ export type LokiAdapterOptions = $Exact<{ // You might use it as an opportunity to alert user to the potential loss of data onDidOverwrite?: () => void, // Called when internal IndexedDB version changed (most likely the database was deleted in another browser tab) + // When this happens, the database stops being saved (so that it's not re-created from memory), + // further changes are rejected, and `onPersistenceError` is called. // Pass a callback to force log out in this copy of the app as well // (Due to a race condition, it's usually best to just reload the web app) // Note that this only works when not using web workers diff --git a/src/adapters/lokijs/persistence.test.js b/src/adapters/lokijs/persistence.test.js new file mode 100644 index 000000000..a662d3f38 --- /dev/null +++ b/src/adapters/lokijs/persistence.test.js @@ -0,0 +1,214 @@ +import { testSchema, mockTaskRaw } from '../__tests__/helpers' + +import LokiJSAdapter from './index' +import DatabaseAdapterCompat from '../compat' + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) + +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') +} + +// Mimics lokijs's IncrementalIndexedDBAdapter: incremental mode, `options` object, async save callback +class FakeIncrementalAdapter { + mode = 'incremental' + + options = {} + + saveError = null + + saves = 0 + + loadDatabase(dbname, callback) { + callback(null) + } + + saveDatabase(dbname, getLokiCopy, callback) { + getLokiCopy() + this.saves += 1 + const error = this.saveError + setTimeout(() => callback(error), 0) + } + + deleteDatabase(dbname, callback) { + callback({ success: true }) + } +} + +// What IncrementalIndexedDBAdapter passes to the save callback when an IDB transaction aborts +const abortEvent = (error) => ({ type: 'abort', target: { error } }) + +const quotaError = () => { + const error = new Error('The quota has been exceeded.') + error.name = 'QuotaExceededError' + return error +} + +let adapters = [] + +const makeAdapter = (options) => { + const adapter = new LokiJSAdapter({ + dbName: `test${Math.random()}`, + schema: testSchema, + useWebWorker: false, + useIncrementalIndexedDB: true, + extraLokiOptions: { autosaveInterval: 5 }, + ...options, + }) + adapters.push(adapter) + return adapter +} + +let taskId = 0 +const writeTask = (adapter) => { + taskId += 1 + return adapter.batch([['create', 'tasks', mockTaskRaw({ id: `t${taskId}` })]]) +} + +afterEach(() => { + adapters.forEach((adapter) => { + const loki = adapter._driver?.loki + loki && loki.autosaveDisable() + }) + adapters = [] + delete global.indexedDB +}) + +describe('LokiJSAdapter persistence errors', () => { + it('reports a failing save once, and again after a successful save in between', async () => { + const persistenceAdapter = new FakeIncrementalAdapter() + const onPersistenceError = jest.fn() + const onQuotaExceededError = jest.fn() + const adapter = new DatabaseAdapterCompat( + makeAdapter({ + _testLokiAdapter: persistenceAdapter, + onPersistenceError, + onQuotaExceededError, + }), + ) + + // saves fine + await writeTask(adapter) + await waitFor(() => persistenceAdapter.saves >= 1) + expect(onPersistenceError).not.toHaveBeenCalled() + + // quota exceeded, reported once even though autosave keeps retrying + const error = quotaError() + persistenceAdapter.saveError = abortEvent(error) + await writeTask(adapter) + await waitFor(() => onPersistenceError.mock.calls.length === 1) + const savesAfterFailure = persistenceAdapter.saves + await waitFor(() => persistenceAdapter.saves >= savesAfterFailure + 3) + expect(onPersistenceError).toHaveBeenCalledTimes(1) + // raw IDB event is unwrapped + expect(onPersistenceError).toHaveBeenCalledWith(error) + expect(onQuotaExceededError).toHaveBeenCalledTimes(1) + expect(onQuotaExceededError).toHaveBeenCalledWith(error) + + // recovers - the retried save succeeds + persistenceAdapter.saveError = null + const savesBeforeRecovery = persistenceAdapter.saves + await waitFor(() => persistenceAdapter.saves > savesBeforeRecovery) + await sleep(20) + + // fails again - reported again + const otherError = new Error('Error saving database') + persistenceAdapter.saveError = otherError + await writeTask(adapter) + await waitFor(() => onPersistenceError.mock.calls.length === 2) + expect(onPersistenceError).toHaveBeenLastCalledWith(otherError) + expect(onQuotaExceededError).toHaveBeenCalledTimes(1) + }) + + it('stops saving and breaks the driver when IndexedDB is deleted by another connection', async () => { + const persistenceAdapter = new FakeIncrementalAdapter() + const onPersistenceError = jest.fn() + const onversionchange = jest.fn() + const _onFatalError = jest.fn() + const lokiAdapter = makeAdapter({ + _testLokiAdapter: persistenceAdapter, + onPersistenceError, + _onFatalError, + extraIncrementalIDBOptions: { onversionchange }, + }) + const adapter = new DatabaseAdapterCompat(lokiAdapter) + + await writeTask(adapter) + await waitFor(() => persistenceAdapter.saves >= 1) + + const event = { type: 'versionchange' } + persistenceAdapter.options.onversionchange(event) + + expect(onversionchange).toHaveBeenCalledWith(event) + expect(onPersistenceError).toHaveBeenCalledTimes(1) + expect(onPersistenceError.mock.calls[0][0].name).toBe('VersionChangeError') + expect(_onFatalError).toHaveBeenCalledTimes(1) + + // changes are rejected, and nothing is saved anymore + const savesBefore = persistenceAdapter.saves + await expect(writeTask(adapter)).rejects.toThrow('broken state') + expect(() => lokiAdapter._driver.loki.saveDatabase()).toThrow() + await sleep(30) + expect(persistenceAdapter.saves).toBe(savesBefore) + expect(onPersistenceError).toHaveBeenCalledTimes(1) + + // reads still work + expect(await adapter.find('tasks', `t${taskId - 1}`)).toBeTruthy() + }) +}) + +describe('LokiJSAdapter in-memory fallback', () => { + it('falls back to memory and reports it when IndexedDB is unavailable (by default)', async () => { + expect(typeof indexedDB).toBe('undefined') + const onInMemoryFallback = jest.fn() + const onSetUpError = jest.fn() + const adapter = new DatabaseAdapterCompat(makeAdapter({ onInMemoryFallback, onSetUpError })) + + await writeTask(adapter) + expect(onSetUpError).not.toHaveBeenCalled() + expect(onInMemoryFallback).toHaveBeenCalledTimes(1) + expect(onInMemoryFallback.mock.calls[0][0].message).toMatch('IndexedDB is not available') + }) + + it('fails setup when IndexedDB is unavailable and allowInMemoryFallback is false', async () => { + const onInMemoryFallback = jest.fn() + const error = await new Promise((resolve) => { + makeAdapter({ allowInMemoryFallback: false, onInMemoryFallback, onSetUpError: resolve }) + }) + expect(error.message).toMatch('IndexedDB is not available') + expect(onInMemoryFallback).not.toHaveBeenCalled() + }) + + it('reports why IndexedDB failed to open', async () => { + const idbError = quotaError() + global.indexedDB = { + open: () => { + const request = {} + setTimeout(() => request.onerror({ target: { error: idbError } }), 0) + return request + }, + } + const onQuotaExceededError = jest.fn() + + const onInMemoryFallback = jest.fn() + const adapter = new DatabaseAdapterCompat( + makeAdapter({ onInMemoryFallback, onQuotaExceededError }), + ) + await writeTask(adapter) + expect(onQuotaExceededError).toHaveBeenCalledWith(idbError) + expect(onInMemoryFallback).toHaveBeenCalledTimes(1) + expect(onInMemoryFallback.mock.calls[0][0].cause).toBe(idbError) + + const setUpError = await new Promise((resolve) => { + makeAdapter({ allowInMemoryFallback: false, onSetUpError: resolve }) + }) + expect(setUpError.cause).toBe(idbError) + }) +}) diff --git a/src/adapters/lokijs/worker/DatabaseDriver.js b/src/adapters/lokijs/worker/DatabaseDriver.js index f9731e575..caefcfd0e 100644 --- a/src/adapters/lokijs/worker/DatabaseDriver.js +++ b/src/adapters/lokijs/worker/DatabaseDriver.js @@ -284,7 +284,7 @@ export default class DatabaseDriver { async _openDatabase(): Promise { logger.log('[Loki] Initializing IndexedDB') - this.loki = await newLoki(this.options) + this.loki = await newLoki(this.options, (error) => this._breakDriver(error)) logger.log('[Loki] Database loaded') } @@ -464,18 +464,28 @@ export default class DatabaseDriver { ) throw error } + this._breakDriver(error) + + // Rethrow error + throw error + } + + // Puts driver in a broken state: further changes are rejected and Loki won't save anymore. + // Used directly (regardless of experimentalAllowsFatalError) when the database can no longer be + // safely persisted, e.g. because it was deleted in another browser tab + _breakDriver(error: Error): void { + if (this._isBroken) { + return + } // Stop further mutations this._isBroken = true - // Disable Loki autosave - lokiFatalError(this.loki) + // Disable Loki autosave (Loki might not be assigned yet if this happens while it's loading) + this.loki && lokiFatalError(this.loki) // Notify handler logger.error('DatabaseDriver is broken. App must be reloaded before continuing.') const handler = this.options._onFatalError handler && handler(error) - - // Rethrow error - throw error } } diff --git a/src/adapters/lokijs/worker/lokiExtensions.js b/src/adapters/lokijs/worker/lokiExtensions.js index 698d8c969..83eca2250 100644 --- a/src/adapters/lokijs/worker/lokiExtensions.js +++ b/src/adapters/lokijs/worker/lokiExtensions.js @@ -6,11 +6,13 @@ import logger from '../../../utils/common/logger' import type { LokiAdapterOptions } from '../index' import type { Loki } from '../type' -const isIDBAvailable = (onQuotaExceededError: ?(error: Error) => void) => { +// Resolves to `null` if IndexedDB can be used, or to an Error explaining why it can't +const checkIDB = (onQuotaExceededError: ?(error: Error) => void): Promise => { return new Promise((resolve) => { // $FlowFixMe if (typeof indexedDB === 'undefined') { - resolve(false) + resolve(new Error('IndexedDB is not available in this environment')) + return } // in Firefox private mode, IDB will be available, but will fail to open @@ -19,7 +21,7 @@ const isIDBAvailable = (onQuotaExceededError: ?(error: Error) => void) => { checkRequest.onsuccess = (e) => { const db: IDBDatabase = e.target.result db.close() - resolve(true) + resolve(null) } checkRequest.onerror = (event) => { const error: ?Error = event?.target?.error @@ -27,7 +29,7 @@ const isIDBAvailable = (onQuotaExceededError: ?(error: Error) => void) => { // DOMException: "A mutation operation was attempted on a database that did not allow mutations." // code: 11, name: InvalidStateError logger.error( - '[Loki] IndexedDB checker failed to open. Most likely, user is in Private Mode. It could also be a quota exceeded error. Will fall back to in-memory database.', + '[Loki] IndexedDB checker failed to open. Most likely, user is in Private Mode. It could also be a quota exceeded error.', event, error, ) @@ -35,7 +37,7 @@ const isIDBAvailable = (onQuotaExceededError: ?(error: Error) => void) => { logger.log('[Loki] Looks like disk quota was exceeded: ', error) onQuotaExceededError && onQuotaExceededError(error) } - resolve(false) + resolve(error || new Error('IndexedDB failed to open')) } checkRequest.onblocked = () => { logger.error('IndexedDB checker call is blocked') @@ -43,6 +45,10 @@ const isIDBAvailable = (onQuotaExceededError: ?(error: Error) => void) => { }) } +// Setting up an empty database opens it twice (see DatabaseDriver._migrateIfNeeded), so we keep +// track of what was reported, so that `onInMemoryFallback` is only called once per adapter +const reportedInMemoryFallbacks: WeakSet = new WeakSet() + async function getLokiAdapter(options: LokiAdapterOptions): mixed { const { useIncrementalIndexedDB, @@ -50,16 +56,22 @@ async function getLokiAdapter(options: LokiAdapterOptions): mixed { onQuotaExceededError, dbName, extraIncrementalIDBOptions = {}, + allowInMemoryFallback = true, + onInMemoryFallback, } = options if (adapter) { return adapter - } else if (await isIDBAvailable(onQuotaExceededError)) { + } + + const idbError = await checkIDB(onQuotaExceededError) + if (!idbError) { if (useIncrementalIndexedDB) { const IncrementalIDBAdapter = options._betaLoki ? require('lokijs/src/incremental-indexeddb-adapter') : require('lokijs/src/incremental-indexeddb-adapter') + // NOTE: options are copied, because newLoki() installs its own `onversionchange` handler // $FlowFixMe - return new IncrementalIDBAdapter(extraIncrementalIDBOptions) + return new IncrementalIDBAdapter({ ...extraIncrementalIDBOptions }) } const LokiIndexedAdapter = require('lokijs/src/loki-indexed-adapter') return new LokiIndexedAdapter(dbName) @@ -67,30 +79,144 @@ async function getLokiAdapter(options: LokiAdapterOptions): mixed { // if IDB is unavailable (that happens in private mode), fall back to memory adapter // we could also fall back to localstorage adapter, but it will fail in all but the smallest dbs + const reason = new Error( + `[Loki] IndexedDB cannot be used (${idbError.name}: ${idbError.message}), so the database can only be kept in memory and will be lost when the page is closed`, + ) + // $FlowFixMe + reason.cause = idbError + if (!allowInMemoryFallback) { + logger.error(`${reason.message}. Refusing to set up, because allowInMemoryFallback is false`) + throw reason + } + if (!reportedInMemoryFallbacks.has(options)) { + reportedInMemoryFallbacks.add(options) + logger.error(`${reason.message}. Falling back to in-memory database`) + onInMemoryFallback && onInMemoryFallback(reason) + } + const { LokiMemoryAdapter } = options._betaLoki ? require('lokijs') : require('lokijs') return new LokiMemoryAdapter() } -export async function newLoki(options: LokiAdapterOptions): Loki { +// Loki reports save errors as whatever the persistence adapter passes: an Error, or, in case of +// IncrementalIndexedDBAdapter, sometimes the raw IndexedDB `error`/`abort` Event +function normalizePersistenceError(rawError: any): Error { + if (rawError instanceof Error) { + return rawError + } + const targetError = rawError && rawError.target && rawError.target.error + if (targetError) { + return targetError + } + if (rawError && rawError.type) { + return new Error(`IndexedDB transaction failed (${rawError.type} event)`) + } + return new Error(`Unknown error while saving database: ${String(rawError)}`) +} + +function createPersistenceErrorReporter(options: LokiAdapterOptions): { + onSaveResult: (rawError: any) => void, + reportFatal: (error: Error) => void, +} { + const { onPersistenceError, onQuotaExceededError } = options + // We only report the first failure until a save succeeds again, since autosave retries + // a failing save every `autosaveInterval` + let isFailing = false + let isDead = false + + const report = (error: Error) => { + if (error && error.name === 'QuotaExceededError') { + onQuotaExceededError && onQuotaExceededError(error) + } + onPersistenceError && onPersistenceError(error) + } + + return { + onSaveResult: (rawError) => { + if (isDead) { + return + } + if (!rawError) { + if (isFailing) { + logger.log('[Loki] Database saved successfully after previous failures') + isFailing = false + } + return + } + if (isFailing) { + return + } + isFailing = true + const error = normalizePersistenceError(rawError) + logger.error( + '[Loki] Failed to save database. Recent changes are only in memory and will be lost if the page is closed. Will keep retrying.', + error, + ) + report(error) + }, + reportFatal: (error) => { + if (isDead) { + return + } + isDead = true + isFailing = true + logger.error(error.message) + report(error) + }, + } +} + +// `onPersistenceLost` is called when the database can no longer be saved and the caller should +// stop accepting changes (currently: IndexedDB was deleted or upgraded by another connection) +export async function newLoki( + options: LokiAdapterOptions, + onPersistenceLost?: (error: Error) => void, +): Loki { const { extraLokiOptions = {} } = options const LokiDb = options._betaLoki ? require('lokijs') : require('lokijs') + const reporter = createPersistenceErrorReporter(options) + const adapter: any = await getLokiAdapter(options) + + let loki: ?Loki = null + + // When another connection deletes (or upgrades) the IndexedDB database - most likely, the user + // logged out in another tab - IncrementalIDBAdapter closes its connection. If we kept going, + // the next autosave would silently re-create the database from this tab's memory, bringing + // the deleted data back. Instead, we stop saving and report it. + if (adapter && adapter.mode === 'incremental' && adapter.options) { + const userOnVersionChange = options.extraIncrementalIDBOptions?.onversionchange + adapter.options.onversionchange = (event: any) => { + const error = new Error( + '[Loki] IndexedDB database was deleted or upgraded by another connection (most likely in another browser tab). To avoid re-creating it from memory, this database will not be saved anymore. App must be reloaded.', + ) + error.name = 'VersionChangeError' + loki && lokiFatalError(loki) + reporter.reportFatal(error) + onPersistenceLost && onPersistenceLost(error) + // $FlowFixMe + userOnVersionChange && userOnVersionChange(event) + } + } + // $FlowFixMe - const loki: Loki = new LokiDb(options.dbName, { - adapter: await getLokiAdapter(options), + loki = new LokiDb(options.dbName, { + adapter, autosave: true, autosaveInterval: 500, + autosaveCallback: reporter.onSaveResult, verbose: true, ...extraLokiOptions, }) + const _loki: Loki = loki // force load database now await new Promise((resolve, reject) => { - loki.loadDatabase({}, (error) => { + _loki.loadDatabase({}, (error) => { error ? reject(error) : resolve() }) }) - return loki + return _loki } export async function deleteDatabase(loki: Loki): Promise { From bebc12151de5e30968dc935881e2fdea3a9beaf1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20K=C3=B6hler?= Date: Sat, 31 Oct 2026 08:21:15 +0100 Subject: [PATCH 2/7] [LokiJS] Break driver before persistence callbacks, report fallback per adapter - On versionchange, mark the driver broken before calling `onPersistenceError`, so the handler can't make changes anymore, and still call the app's `onversionchange` if the handler throws. - Copy options in DatabaseDriver, so that `onInMemoryFallback` is reported for each adapter even when an app reuses one options object. Co-Authored-By: Claude Opus 5.5 --- src/adapters/lokijs/persistence.test.js | 37 ++++++++++++++++++++ src/adapters/lokijs/worker/DatabaseDriver.js | 4 ++- src/adapters/lokijs/worker/lokiExtensions.js | 12 +++++-- 3 files changed, 49 insertions(+), 4 deletions(-) diff --git a/src/adapters/lokijs/persistence.test.js b/src/adapters/lokijs/persistence.test.js index a662d3f38..258abcf6c 100644 --- a/src/adapters/lokijs/persistence.test.js +++ b/src/adapters/lokijs/persistence.test.js @@ -162,6 +162,29 @@ describe('LokiJSAdapter persistence errors', () => { // reads still work expect(await adapter.find('tasks', `t${taskId - 1}`)).toBeTruthy() }) + + it('breaks the driver before notifying, even if a callback throws', async () => { + const persistenceAdapter = new FakeIncrementalAdapter() + const onversionchange = jest.fn() + let wasBrokenWhenNotified = false + const lokiAdapter = makeAdapter({ + _testLokiAdapter: persistenceAdapter, + onPersistenceError: () => { + wasBrokenWhenNotified = lokiAdapter._driver._isBroken + throw new Error('handler failed') + }, + extraIncrementalIDBOptions: { onversionchange }, + }) + const adapter = new DatabaseAdapterCompat(lokiAdapter) + await writeTask(adapter) + + expect(() => persistenceAdapter.options.onversionchange({ type: 'versionchange' })).toThrow( + 'handler failed', + ) + expect(wasBrokenWhenNotified).toBe(true) + expect(onversionchange).toHaveBeenCalledTimes(1) + await expect(writeTask(adapter)).rejects.toThrow('broken state') + }) }) describe('LokiJSAdapter in-memory fallback', () => { @@ -177,6 +200,20 @@ describe('LokiJSAdapter in-memory fallback', () => { expect(onInMemoryFallback.mock.calls[0][0].message).toMatch('IndexedDB is not available') }) + it('reports the fallback for each adapter, even if they share options', async () => { + const onInMemoryFallback = jest.fn() + const options = { + dbName: `test${Math.random()}`, + schema: testSchema, + useWebWorker: false, + useIncrementalIndexedDB: true, + onInMemoryFallback, + } + await writeTask(new DatabaseAdapterCompat(makeAdapter(options))) + await writeTask(new DatabaseAdapterCompat(makeAdapter(options))) + expect(onInMemoryFallback).toHaveBeenCalledTimes(2) + }) + it('fails setup when IndexedDB is unavailable and allowInMemoryFallback is false', async () => { const onInMemoryFallback = jest.fn() const error = await new Promise((resolve) => { diff --git a/src/adapters/lokijs/worker/DatabaseDriver.js b/src/adapters/lokijs/worker/DatabaseDriver.js index caefcfd0e..dc6743c69 100644 --- a/src/adapters/lokijs/worker/DatabaseDriver.js +++ b/src/adapters/lokijs/worker/DatabaseDriver.js @@ -51,7 +51,9 @@ export default class DatabaseDriver { constructor(options: LokiAdapterOptions): void { const { schema, migrations } = options - this.options = options + // copied, so that this object identifies this driver, even if the app reuses its options object + // for another adapter (see lokiExtensions) + this.options = { ...options } this.schema = schema this.migrations = migrations } diff --git a/src/adapters/lokijs/worker/lokiExtensions.js b/src/adapters/lokijs/worker/lokiExtensions.js index 83eca2250..2c6b23d5c 100644 --- a/src/adapters/lokijs/worker/lokiExtensions.js +++ b/src/adapters/lokijs/worker/lokiExtensions.js @@ -47,6 +47,7 @@ const checkIDB = (onQuotaExceededError: ?(error: Error) => void): Promise = new WeakSet() async function getLokiAdapter(options: LokiAdapterOptions): mixed { @@ -190,11 +191,16 @@ export async function newLoki( '[Loki] IndexedDB database was deleted or upgraded by another connection (most likely in another browser tab). To avoid re-creating it from memory, this database will not be saved anymore. App must be reloaded.', ) error.name = 'VersionChangeError' + // Stop saving and accepting changes before notifying app callbacks, so that they can't make + // changes anymore, and so that a throwing callback can't prevent it loki && lokiFatalError(loki) - reporter.reportFatal(error) onPersistenceLost && onPersistenceLost(error) - // $FlowFixMe - userOnVersionChange && userOnVersionChange(event) + try { + reporter.reportFatal(error) + } finally { + // $FlowFixMe + userOnVersionChange && userOnVersionChange(event) + } } } From 9231998174e5940350e97a992b5cfc0658187aa0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20K=C3=B6hler?= Date: Sat, 31 Oct 2026 08:28:40 +0100 Subject: [PATCH 3/7] [LokiJS] Reject reset of a broken driver, isolate persistence callbacks - `unsafeResetDatabase()` now rejects on a broken driver. After the database was deleted in another tab, a reset would otherwise delete and re-create it with autosave enabled. - Errors thrown by `onQuotaExceededError`, `onPersistenceError`, `_onFatalError` and `onversionchange` on these paths are logged instead of rethrown, so each callback is notified even if another one throws. Co-Authored-By: Claude Opus 5.5 --- src/adapters/lokijs/persistence.test.js | 36 +++++++++++++++++--- src/adapters/lokijs/worker/DatabaseDriver.js | 3 ++ src/adapters/lokijs/worker/lokiExtensions.js | 25 +++++++++----- 3 files changed, 51 insertions(+), 13 deletions(-) diff --git a/src/adapters/lokijs/persistence.test.js b/src/adapters/lokijs/persistence.test.js index 258abcf6c..95fb7dc35 100644 --- a/src/adapters/lokijs/persistence.test.js +++ b/src/adapters/lokijs/persistence.test.js @@ -26,6 +26,8 @@ class FakeIncrementalAdapter { saves = 0 + deletes = 0 + loadDatabase(dbname, callback) { callback(null) } @@ -38,6 +40,7 @@ class FakeIncrementalAdapter { } deleteDatabase(dbname, callback) { + this.deletes += 1 callback({ success: true }) } } @@ -161,14 +164,22 @@ describe('LokiJSAdapter persistence errors', () => { // reads still work expect(await adapter.find('tasks', `t${taskId - 1}`)).toBeTruthy() + + // database can't be deleted and re-created + const deletesBefore = persistenceAdapter.deletes + await expect(adapter.unsafeResetDatabase()).rejects.toThrow('broken state') + expect(persistenceAdapter.deletes).toBe(deletesBefore) }) - it('breaks the driver before notifying, even if a callback throws', async () => { + it('breaks the driver before notifying, and notifies even if callbacks throw', async () => { const persistenceAdapter = new FakeIncrementalAdapter() const onversionchange = jest.fn() let wasBrokenWhenNotified = false const lokiAdapter = makeAdapter({ _testLokiAdapter: persistenceAdapter, + _onFatalError: () => { + throw new Error('fatal error handler failed') + }, onPersistenceError: () => { wasBrokenWhenNotified = lokiAdapter._driver._isBroken throw new Error('handler failed') @@ -178,13 +189,30 @@ describe('LokiJSAdapter persistence errors', () => { const adapter = new DatabaseAdapterCompat(lokiAdapter) await writeTask(adapter) - expect(() => persistenceAdapter.options.onversionchange({ type: 'versionchange' })).toThrow( - 'handler failed', - ) + persistenceAdapter.options.onversionchange({ type: 'versionchange' }) expect(wasBrokenWhenNotified).toBe(true) expect(onversionchange).toHaveBeenCalledTimes(1) await expect(writeTask(adapter)).rejects.toThrow('broken state') }) + + it('reports a failing save to onPersistenceError even if onQuotaExceededError throws', async () => { + const persistenceAdapter = new FakeIncrementalAdapter() + const onPersistenceError = jest.fn() + const adapter = new DatabaseAdapterCompat( + makeAdapter({ + _testLokiAdapter: persistenceAdapter, + onPersistenceError, + onQuotaExceededError: () => { + throw new Error('quota handler failed') + }, + }), + ) + const error = quotaError() + persistenceAdapter.saveError = abortEvent(error) + await writeTask(adapter) + await waitFor(() => onPersistenceError.mock.calls.length === 1) + expect(onPersistenceError).toHaveBeenCalledWith(error) + }) }) describe('LokiJSAdapter in-memory fallback', () => { diff --git a/src/adapters/lokijs/worker/DatabaseDriver.js b/src/adapters/lokijs/worker/DatabaseDriver.js index dc6743c69..0b6306e5b 100644 --- a/src/adapters/lokijs/worker/DatabaseDriver.js +++ b/src/adapters/lokijs/worker/DatabaseDriver.js @@ -235,6 +235,9 @@ export default class DatabaseDriver { } async unsafeResetDatabase(): Promise { + // 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) this.cachedRecords.clear() diff --git a/src/adapters/lokijs/worker/lokiExtensions.js b/src/adapters/lokijs/worker/lokiExtensions.js index 2c6b23d5c..b824e8176 100644 --- a/src/adapters/lokijs/worker/lokiExtensions.js +++ b/src/adapters/lokijs/worker/lokiExtensions.js @@ -115,6 +115,16 @@ function normalizePersistenceError(rawError: any): Error { return new Error(`Unknown error while saving database: ${String(rawError)}`) } +// App callbacks are called from Loki timers and IndexedDB event handlers, and each of them must +// be notified even if another one throws, so errors thrown by them are logged, not rethrown +function callSafely(callback: ?(T) => void, arg: T): void { + try { + callback && callback(arg) + } catch (error) { + logger.error('[Loki] Error thrown by a persistence error callback', error) + } +} + function createPersistenceErrorReporter(options: LokiAdapterOptions): { onSaveResult: (rawError: any) => void, reportFatal: (error: Error) => void, @@ -127,9 +137,9 @@ function createPersistenceErrorReporter(options: LokiAdapterOptions): { const report = (error: Error) => { if (error && error.name === 'QuotaExceededError') { - onQuotaExceededError && onQuotaExceededError(error) + callSafely(onQuotaExceededError, error) } - onPersistenceError && onPersistenceError(error) + callSafely(onPersistenceError, error) } return { @@ -194,13 +204,10 @@ export async function newLoki( // Stop saving and accepting changes before notifying app callbacks, so that they can't make // changes anymore, and so that a throwing callback can't prevent it loki && lokiFatalError(loki) - onPersistenceLost && onPersistenceLost(error) - try { - reporter.reportFatal(error) - } finally { - // $FlowFixMe - userOnVersionChange && userOnVersionChange(event) - } + callSafely(onPersistenceLost, error) + reporter.reportFatal(error) + // $FlowFixMe + callSafely(userOnVersionChange, event) } } From b2c0d70d02bf7fe2d1469b1e407d9f5cf37cede0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20K=C3=B6hler?= Date: Sat, 31 Oct 2026 08:34:12 +0100 Subject: [PATCH 4/7] [LokiJS] Handle synchronous IndexedDB open errors, guard fallback callback - If `indexedDB.open` throws synchronously (e.g. SecurityError when storage access is denied), treat it like other open failures: fall back to memory, or fail setup with `allowInMemoryFallback: false`, with the original error as `cause`. - Call `onInMemoryFallback` through the same error-logging helper, so a throwing callback doesn't make setup fail. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG-Unreleased.md | 1 + src/adapters/lokijs/persistence.test.js | 34 ++++++++++++++++++++ src/adapters/lokijs/worker/lokiExtensions.js | 34 ++++++++++++-------- 3 files changed, 56 insertions(+), 13 deletions(-) diff --git a/CHANGELOG-Unreleased.md b/CHANGELOG-Unreleased.md index 506bf2d3d..1dfe79442 100644 --- a/CHANGELOG-Unreleased.md +++ b/CHANGELOG-Unreleased.md @@ -15,6 +15,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] When 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/persistence.test.js b/src/adapters/lokijs/persistence.test.js index 95fb7dc35..5e08491ec 100644 --- a/src/adapters/lokijs/persistence.test.js +++ b/src/adapters/lokijs/persistence.test.js @@ -251,6 +251,40 @@ describe('LokiJSAdapter in-memory fallback', () => { expect(onInMemoryFallback).not.toHaveBeenCalled() }) + it('sets up in memory even if onInMemoryFallback throws', async () => { + const onSetUpError = jest.fn() + const adapter = new DatabaseAdapterCompat( + makeAdapter({ + onSetUpError, + onInMemoryFallback: () => { + throw new Error('fallback handler failed') + }, + }), + ) + await writeTask(adapter) + expect(onSetUpError).not.toHaveBeenCalled() + }) + + it('handles IndexedDB throwing synchronously when opening', async () => { + const idbError = new Error('The operation is insecure.') + idbError.name = 'SecurityError' + global.indexedDB = { + open: () => { + throw idbError + }, + } + + const onInMemoryFallback = jest.fn() + await writeTask(new DatabaseAdapterCompat(makeAdapter({ onInMemoryFallback }))) + expect(onInMemoryFallback).toHaveBeenCalledTimes(1) + expect(onInMemoryFallback.mock.calls[0][0].cause).toBe(idbError) + + const setUpError = await new Promise((resolve) => { + makeAdapter({ allowInMemoryFallback: false, onSetUpError: resolve }) + }) + expect(setUpError.cause).toBe(idbError) + }) + it('reports why IndexedDB failed to open', async () => { const idbError = quotaError() global.indexedDB = { diff --git a/src/adapters/lokijs/worker/lokiExtensions.js b/src/adapters/lokijs/worker/lokiExtensions.js index b824e8176..39e95c5a3 100644 --- a/src/adapters/lokijs/worker/lokiExtensions.js +++ b/src/adapters/lokijs/worker/lokiExtensions.js @@ -6,6 +6,16 @@ import logger from '../../../utils/common/logger' import type { LokiAdapterOptions } from '../index' 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 { + try { + callback && callback(arg) + } catch (error) { + logger.error('[Loki] Error thrown by a LokiJSAdapter callback', error) + } +} + // Resolves to `null` if IndexedDB can be used, or to an Error explaining why it can't const checkIDB = (onQuotaExceededError: ?(error: Error) => void): Promise => { return new Promise((resolve) => { @@ -16,8 +26,16 @@ const checkIDB = (onQuotaExceededError: ?(error: Error) => void): Promise { const db: IDBDatabase = e.target.result db.close() @@ -92,7 +110,7 @@ async function getLokiAdapter(options: LokiAdapterOptions): mixed { if (!reportedInMemoryFallbacks.has(options)) { reportedInMemoryFallbacks.add(options) logger.error(`${reason.message}. Falling back to in-memory database`) - onInMemoryFallback && onInMemoryFallback(reason) + callSafely(onInMemoryFallback, reason) } const { LokiMemoryAdapter } = options._betaLoki ? require('lokijs') : require('lokijs') @@ -115,16 +133,6 @@ function normalizePersistenceError(rawError: any): Error { return new Error(`Unknown error while saving database: ${String(rawError)}`) } -// App callbacks are called from Loki timers and IndexedDB event handlers, and each of them must -// be notified even if another one throws, so errors thrown by them are logged, not rethrown -function callSafely(callback: ?(T) => void, arg: T): void { - try { - callback && callback(arg) - } catch (error) { - logger.error('[Loki] Error thrown by a persistence error callback', error) - } -} - function createPersistenceErrorReporter(options: LokiAdapterOptions): { onSaveResult: (rawError: any) => void, reportFatal: (error: Error) => void, From 5594f341f9a4cbed14d9e422f4fe81eff2d1fc88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20K=C3=B6hler?= Date: Sat, 31 Oct 2026 08:39:22 +0100 Subject: [PATCH 5/7] [LokiJS] Don't hang setup on a throwing quota callback, fix shared-options test - Call `onQuotaExceededError` from the IndexedDB checker through the error-logging helper. If it threw, the check never resolved and setup hung. - The fallback-per-adapter test now passes the very same options object to both adapters. Before, the test helper copied it, so the test didn't exercise sharing. Co-Authored-By: Claude Opus 5.5 --- src/adapters/lokijs/persistence.test.js | 31 ++++++++++++++++++-- src/adapters/lokijs/worker/lokiExtensions.js | 2 +- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/src/adapters/lokijs/persistence.test.js b/src/adapters/lokijs/persistence.test.js index 5e08491ec..2aeec541d 100644 --- a/src/adapters/lokijs/persistence.test.js +++ b/src/adapters/lokijs/persistence.test.js @@ -237,8 +237,12 @@ describe('LokiJSAdapter in-memory fallback', () => { useIncrementalIndexedDB: true, onInMemoryFallback, } - await writeTask(new DatabaseAdapterCompat(makeAdapter(options))) - await writeTask(new DatabaseAdapterCompat(makeAdapter(options))) + // not using makeAdapter(), so that both adapters get the very same options object + const adapter1 = new LokiJSAdapter(options) + const adapter2 = new LokiJSAdapter(options) + adapters.push(adapter1, adapter2) + await writeTask(new DatabaseAdapterCompat(adapter1)) + await writeTask(new DatabaseAdapterCompat(adapter2)) expect(onInMemoryFallback).toHaveBeenCalledTimes(2) }) @@ -285,6 +289,29 @@ describe('LokiJSAdapter in-memory fallback', () => { expect(setUpError.cause).toBe(idbError) }) + it('does not hang setup if onQuotaExceededError throws', async () => { + const idbError = quotaError() + global.indexedDB = { + open: () => { + const request = {} + setTimeout(() => request.onerror({ target: { error: idbError } }), 0) + return request + }, + } + const onInMemoryFallback = jest.fn() + await writeTask( + new DatabaseAdapterCompat( + makeAdapter({ + onInMemoryFallback, + onQuotaExceededError: () => { + throw new Error('quota handler failed') + }, + }), + ), + ) + expect(onInMemoryFallback).toHaveBeenCalledTimes(1) + }) + it('reports why IndexedDB failed to open', async () => { const idbError = quotaError() global.indexedDB = { diff --git a/src/adapters/lokijs/worker/lokiExtensions.js b/src/adapters/lokijs/worker/lokiExtensions.js index 39e95c5a3..bfe9679f4 100644 --- a/src/adapters/lokijs/worker/lokiExtensions.js +++ b/src/adapters/lokijs/worker/lokiExtensions.js @@ -53,7 +53,7 @@ const checkIDB = (onQuotaExceededError: ?(error: Error) => void): Promise Date: Sat, 31 Oct 2026 09:06:12 +0100 Subject: [PATCH 6/7] [LokiJS] Reject unsafeExecute on a broken driver After the database was deleted in another tab, `unsafeExecute()` could still change Loki's in-memory data, which can no longer be saved. It now rejects like other changes. Co-Authored-By: Claude Opus 5.5 --- docs-website/docs/docs/Setup.md | 2 +- src/adapters/lokijs/persistence.test.js | 3 +++ src/adapters/lokijs/worker/DatabaseDriver.js | 2 ++ 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/docs-website/docs/docs/Setup.md b/docs-website/docs/docs/Setup.md index 5cc458c94..19569cea9 100644 --- a/docs-website/docs/docs/Setup.md +++ b/docs-website/docs/docs/Setup.md @@ -126,7 +126,7 @@ 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, and calls `onPersistenceError` and `onversionchange`. Reload the page to continue. +- 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`. ::: diff --git a/src/adapters/lokijs/persistence.test.js b/src/adapters/lokijs/persistence.test.js index 2aeec541d..9f8aed9bb 100644 --- a/src/adapters/lokijs/persistence.test.js +++ b/src/adapters/lokijs/persistence.test.js @@ -157,6 +157,9 @@ describe('LokiJSAdapter persistence errors', () => { // changes are rejected, and nothing is saved anymore const savesBefore = persistenceAdapter.saves await expect(writeTask(adapter)).rejects.toThrow('broken state') + const rawWrite = jest.fn() + await expect(adapter.unsafeExecute({ loki: rawWrite })).rejects.toThrow('broken state') + expect(rawWrite).not.toHaveBeenCalled() expect(() => lokiAdapter._driver.loki.saveDatabase()).toThrow() await sleep(30) expect(persistenceAdapter.saves).toBe(savesBefore) diff --git a/src/adapters/lokijs/worker/DatabaseDriver.js b/src/adapters/lokijs/worker/DatabaseDriver.js index 0b6306e5b..0cb38929e 100644 --- a/src/adapters/lokijs/worker/DatabaseDriver.js +++ b/src/adapters/lokijs/worker/DatabaseDriver.js @@ -221,6 +221,8 @@ export default class DatabaseDriver { } unsafeExecute(operations: UnsafeExecuteOperations): void { + // raw access could change data that can no longer be saved + this._assertNotBroken() if (process.env.NODE_ENV !== 'production') { invariant( operations && From 9d8c92007cc2de5dae3b6371f84700b4d3093c4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20K=C3=B6hler?= Date: Sat, 31 Oct 2026 09:18:20 +0100 Subject: [PATCH 7/7] [LokiJS] Apply fallback policy when accessing indexedDB throws In some contexts (e.g. a sandboxed iframe) even reading the `indexedDB` global throws a SecurityError. The availability check now catches that too, so it falls back to memory, or fails setup with `allowInMemoryFallback: false`, with the original error as `cause`. Before, setup always failed. Jest's sandbox can't simulate a throwing global getter, so this was checked with a plain Node script instead. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG-Unreleased.md | 2 +- src/adapters/lokijs/worker/lokiExtensions.js | 15 ++++++++------- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/CHANGELOG-Unreleased.md b/CHANGELOG-Unreleased.md index 7db0147c2..1abd40f02 100644 --- a/CHANGELOG-Unreleased.md +++ b/CHANGELOG-Unreleased.md @@ -16,7 +16,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] When 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 +- [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/worker/lokiExtensions.js b/src/adapters/lokijs/worker/lokiExtensions.js index bfe9679f4..f26d84547 100644 --- a/src/adapters/lokijs/worker/lokiExtensions.js +++ b/src/adapters/lokijs/worker/lokiExtensions.js @@ -19,15 +19,16 @@ function callSafely(callback: ?(T) => void, arg: T): void { // Resolves to `null` if IndexedDB can be used, or to an Error explaining why it can't const checkIDB = (onQuotaExceededError: ?(error: Error) => void): Promise => { return new Promise((resolve) => { - // $FlowFixMe - if (typeof indexedDB === 'undefined') { - resolve(new Error('IndexedDB is not available in this environment')) - return - } - - // in Firefox private mode, IDB will be available, but will fail to open let checkRequest: IDBOpenDBRequest try { + // NOTE: Even accessing `indexedDB` can throw (e.g. in a sandboxed iframe) + // $FlowFixMe + if (typeof indexedDB === 'undefined') { + resolve(new Error('IndexedDB is not available in this environment')) + return + } + + // in Firefox private mode, IDB will be available, but will fail to open // $FlowFixMe checkRequest = indexedDB.open('WatermelonIDBChecker') } catch (error) {