You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
On the web, LokiJSAdapter can lose data without the app ever finding out:
Save failures are swallowed. With autosave, Loki saves without a callback, so when an IndexedDB save fails (quota exceeded, aborted transaction, connection closing), nothing reaches WatermelonDB. onQuotaExceededError only fires when the pre-open checker database fails, which a full disk does not trigger. database.write() resolving therefore does not mean the data is on disk (see Save database from in-memory loki db to IndexedDB manually on web Nozbe/WatermelonDB#1329).
The in-memory fallback is silent. When IndexedDB is unavailable or fails to open, the adapter falls back to LokiMemoryAdapter with only a log line. Setup succeeds, onSetUpError is not called, and the app cannot tell that everything will be lost when the page is closed.
A deleted database comes back. When another tab deletes the database (e.g. on logout), IncrementalIndexedDBAdapter closes its connection on versionchange. The next autosave in this tab silently re-opens and re-creates the database from this tab's memory. Related multi-tab issue: Schema migration issue - multiple tabs Nozbe/WatermelonDB#1865.
Changes
All new options are additive. Defaults keep today's behaviour, except for the deleted-database case.
onPersistenceError(error) is wired to Loki's autosaveCallback. It is called once per failure, not on every 500 ms autosave retry, and again if saving fails after it succeeded in the meantime. Raw IDB Events are unwrapped to event.target.error. QuotaExceededErrors are also passed to onQuotaExceededError. Save failures are now always logged, not only in the adapter's debug mode.
allowInMemoryFallback defaults to true. With false, a missing or unusable IndexedDB fails setup, so onSetUpError is called. The error's cause is the original IDB error.
onInMemoryFallback(reason) lets an app that keeps the default learn about the fallback, for example to warn the user. It is called once per adapter, even though setting up an empty database opens it twice.
versionchange: Loki saving is disabled through the existing lokiFatalError path instead of re-creating the database. The driver is put in the broken state, so further changes are rejected while reads still work. onPersistenceError is called with a VersionChangeError, and the app's own onversionchange is still called afterwards. Breaking the driver was factored out of DatabaseDriver._fatalError into _breakDriver, which does not depend on experimentalAllowsFatalError.
Synchronous access and open errors: if reading indexedDB or calling indexedDB.open throws (e.g. SecurityError when storage access is denied, or in a sandboxed iframe), it is handled like other open failures. It falls back to memory by default, where setup used to fail, or fails setup with allowInMemoryFallback: false.
Errors thrown by the app's callbacks on these paths are logged, so one failing callback can't skip the others or change the fallback policy.
Flow types, index.d.ts, the web adapter section of the Setup docs, and the changelog are updated.
Remaining multi-tab caveats (documented, not addressed here)
Two tabs writing concurrently still overwrite each other's saves, and onDidOverwrite is called. Coordinating writers, for example with Web Locks, is out of scope.
After versionchange, the tab must be reloaded. There is no in-place recovery.
The non-incremental LokiIndexedAdapter has no versionchange handling.
The callbacks only work with useWebWorker: false, like the existing ones.
Usage
constadapter=newLokiJSAdapter({
schema,useWebWorker: false,useIncrementalIndexedDB: true,onSetUpError: (error)=>{/* now also called when IndexedDB is unusable */},allowInMemoryFallback: false,onPersistenceError: (error)=>{/* warn that recent changes are not saved */},})
Tests
src/adapters/lokijs/persistence.test.js uses a fake incremental persistence adapter through _testLokiAdapter and a stubbed indexedDB global. It covers:
reporting once per failure, unwrapping the event, routing quota errors, and reporting again after a recovery;
versionchange stopping saves and rejecting writes;
the fallback with and without allowInMemoryFallback, and the checker open failure.
Jest for src/adapters/lokijs and src/adapters/__tests__ passes, with 150 tests. ESLint, TSLint and Flow are clean.
Validation against a real IndexedDB implementation
The unit tests use a fake persistence adapter. In addition, the cross-tab deletion path was checked once with the real LokiJSAdapter and lokijs IncrementalIndexedDBAdapter on top of fake-indexeddb 6.0.1, a spec-compliant IndexedDB implementation. That check is not committed, because adding a dev dependency would grow the diff.
Deleted after a save: another connection calls indexedDB.deleteDatabase(). The deletion is not blocked, onversionchange and onPersistenceError are each called once, later writes are rejected, and after several autosave intervals the database still does not exist.
Deleted while a save is pending: a write is made, then the database is deleted before autosave runs. The database is still not re-created.
Control: with this PR's versionchange handling disabled, the same scenario re-creates the deleted database.
Jest's sandbox can't simulate a global indexedDB getter that throws. That case was checked with a plain Node script that loads the adapter through Babel. With the fix, the default setup falls back to memory and allowInMemoryFallback: false fails setup, both with the original error as cause. Before the fix, setup failed in both modes.
Validation in a real browser with multiple tabs
This PR's code (commit 9d8c920) was transpiled with the repo's Babel config, bundled with esbuild into a test page, and served from localhost. Each tab of Chromium 152 ran its own LokiJSAdapter on real IndexedDB, with useIncrementalIndexedDB: true, useWebWorker: false and allowInMemoryFallback: false. The test page is not committed.
Scenario
Result
Tab B deletes the database after tab A saved
The deletion is not blocked. Tab A gets onPersistenceError with a VersionChangeError, then onversionchange. Tab A rejects batch and unsafeExecute, and 3 s later the database still does not exist.
Tab B deletes the database while tab A's save is in flight, coordinated over a BroadcastChannel so the delete starts 1 ms after the save
Tab A gets versionchange mid-save and reports once, with no second error for the aborted save. The database is still gone 4 s later.
Logout: tab A resets the database while tab B has it open with 5 records
Tab B is broken and rejects writes. A third tab opening the database finds only tab A's one post-reset record.
Control for the logout scenario, with this PR's versionchange handling disabled
Tab B's next autosave writes its records back: 7 records, including the 5 logged-out ones. Tab A's new record is overwritten.
Tab B deletes the database while tab A is setting up, at delays from 0 to 80 ms
At 0 ms the delete runs before the database exists, and setup creates a fresh one. At 2 and 5 ms, setup fails through onSetUpError after a VersionChangeError. From 10 to 80 ms, setup succeeds, then the driver is broken. In every case where the database existed, it stayed deleted.
The result does not depend on event order. Saving is disabled synchronously in the versionchange handler, and closing the connection lets an in-flight transaction finish before the deletion proceeds. So whichever finishes first, nothing writes to the database after it has been deleted.
…lback
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 <noreply@anthropic.com>
The reason will be displayed to describe this comment to others. Learn more.
🟡 Changes recommended
The persistence-error handler runs before the driver is marked broken, allowing in-memory writes and thrown callbacks to bypass the broken state and version-change notification; shared options also suppress fallback notifications for separate adapters.
This PR makes LokiJS persistence failures visible to applications and adds control over in-memory fallback. It also stops saving and rejects further changes when another connection deletes or upgrades the IndexedDB database.
Changes:
Adds persistence-error reporting, quota-error routing, and configurable in-memory fallback.
Handles IndexedDB version changes by disabling saves and breaking the driver.
Adds regression tests, public types, and documentation for the new behavior.
File
Description
src/adapters/lokijs/worker/lokiExtensions.js
Implements persistence reporting, fallback controls, and version-change handling.
src/adapters/lokijs/worker/DatabaseDriver.js
Extracts the broken-state transition and connects it to persistence loss.
src/adapters/lokijs/persistence.test.js
Tests save failures, recovery, fallback, and version changes.
src/adapters/lokijs/index.js
Adds Flow options and documents callback behavior.
src/adapters/lokijs/index.d.ts
Adds matching TypeScript option declarations.
docs-website/docs/docs/Setup.md
Documents configuration, durability limits, and multi-tab behavior.
…er 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 <noreply@anthropic.com>
The reason will be displayed to describe this comment to others. Learn more.
🟡 Changes recommended
unsafeResetDatabase() can delete and recreate persistence after versionchange without checking the broken-driver state, while throwing error handlers can suppress required persistence notifications.
A throwing onQuotaExceededError prevents onPersistenceError from receiving the save failure. Since isFailing is already set, subsequent failed retries also skip reporting, leaving the new persistence callback unnotified until a successful save occurs. Invoke onPersistenceError in a finally block so the two callbacks are independent.
Ensure public notifications run when the fatal-error handler throws
If _onFatalError throws, onPersistenceLost(error) propagates that exception from _breakDriver before entering the try block. Both onPersistenceError and the app's onversionchange callback are then skipped, even though persistence has already been permanently disabled. Put this call inside an outer try/finally so both public notifications still run when the fatal-error handler throws.
- `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 <noreply@anthropic.com>
The reason will be displayed to describe this comment to others. Learn more.
🔵 Needs a closer look
Synchronous IndexedDB access errors and exceptions from the fallback notification can bypass the configured fallback policy, causing setup to fail despite allowing in-memory storage.
No open issues 🎉
2 previously missed issues
In code that hasn't changed since last review
Route synchronous IndexedDB access errors through fallback handling
IndexedDB access can throw synchronously (for example, a SecurityError when storage access is denied). In that case, checkIDB() rejects instead of returning an error, so this await bypasses the fallback handling below: allowInMemoryFallback: true fails setup without notifying onInMemoryFallback, and false receives an unwrapped error without the promised original cause. Convert the rejection into the error result so both cases use the same fallback/refusal path.
Guard fallback notification so callback errors do not prevent memory setup
If onInMemoryFallback throws (for example, while displaying the warning), getLokiAdapter() rejects before constructing the memory adapter, so setup fails even with allowInMemoryFallback: true. Invoke this notification through callSafely, like the persistence notifications, so an application callback failure does not change the configured fallback policy.
…lback
- 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 <noreply@anthropic.com>
…tions 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 <noreply@anthropic.com>
The reason will be displayed to describe this comment to others. Learn more.
🔵 Needs a closer look
Fake-adapter tests leave the real LokiJS/IndexedDB cross-tab deletion and pending-save lifecycle unverified, requiring browser integration validation to confirm deleted databases cannot be recreated.
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
The reason will be displayed to describe this comment to others. Learn more.
🔵 Needs a closer look
Permanent persistence shutdown on cross-tab deletion depends on IndexedDB event ordering, and fake-adapter tests leave real-browser two-tab validation during in-flight saves and setup/reset unresolved.
The reason will be displayed to describe this comment to others. Learn more.
🟡 Changes recommended
A critical race in DatabaseDriver.js can reopen persistence after a versionchange breaks the driver during deletion, leaving autosave enabled and allowing the deleted database to be recreated.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
On the web,
LokiJSAdaptercan lose data without the app ever finding out:onQuotaExceededErroronly fires when the pre-open checker database fails, which a full disk does not trigger.database.write()resolving therefore does not mean the data is on disk (see Save database from in-memory loki db to IndexedDB manually on web Nozbe/WatermelonDB#1329).LokiMemoryAdapterwith only a log line. Setup succeeds,onSetUpErroris not called, and the app cannot tell that everything will be lost when the page is closed.IncrementalIndexedDBAdaptercloses its connection onversionchange. The next autosave in this tab silently re-opens and re-creates the database from this tab's memory. Related multi-tab issue: Schema migration issue - multiple tabs Nozbe/WatermelonDB#1865.Changes
All new options are additive. Defaults keep today's behaviour, except for the deleted-database case.
onPersistenceError(error)is wired to Loki'sautosaveCallback. It is called once per failure, not on every 500 ms autosave retry, and again if saving fails after it succeeded in the meantime. Raw IDBEvents are unwrapped toevent.target.error.QuotaExceededErrors are also passed toonQuotaExceededError. Save failures are now always logged, not only in the adapter's debug mode.allowInMemoryFallbackdefaults totrue. Withfalse, a missing or unusable IndexedDB fails setup, soonSetUpErroris called. The error'scauseis the original IDB error.onInMemoryFallback(reason)lets an app that keeps the default learn about the fallback, for example to warn the user. It is called once per adapter, even though setting up an empty database opens it twice.versionchange: Loki saving is disabled through the existinglokiFatalErrorpath instead of re-creating the database. The driver is put in the broken state, so further changes are rejected while reads still work.onPersistenceErroris called with aVersionChangeError, and the app's ownonversionchangeis still called afterwards. Breaking the driver was factored out ofDatabaseDriver._fatalErrorinto_breakDriver, which does not depend onexperimentalAllowsFatalError.indexedDBor callingindexedDB.openthrows (e.g.SecurityErrorwhen storage access is denied, or in a sandboxed iframe), it is handled like other open failures. It falls back to memory by default, where setup used to fail, or fails setup withallowInMemoryFallback: false.index.d.ts, the web adapter section of the Setup docs, and the changelog are updated.Remaining multi-tab caveats (documented, not addressed here)
onDidOverwriteis called. Coordinating writers, for example with Web Locks, is out of scope.versionchange, the tab must be reloaded. There is no in-place recovery.LokiIndexedAdapterhas noversionchangehandling.useWebWorker: false, like the existing ones.Usage
Tests
src/adapters/lokijs/persistence.test.jsuses a fake incremental persistence adapter through_testLokiAdapterand a stubbedindexedDBglobal. It covers:versionchangestopping saves and rejecting writes;allowInMemoryFallback, and the checker open failure.Jest for
src/adapters/lokijsandsrc/adapters/__tests__passes, with 150 tests. ESLint, TSLint and Flow are clean.Validation against a real IndexedDB implementation
The unit tests use a fake persistence adapter. In addition, the cross-tab deletion path was checked once with the real
LokiJSAdapterand lokijsIncrementalIndexedDBAdapteron top offake-indexeddb6.0.1, a spec-compliant IndexedDB implementation. That check is not committed, because adding a dev dependency would grow the diff.indexedDB.deleteDatabase(). The deletion is not blocked,onversionchangeandonPersistenceErrorare each called once, later writes are rejected, and after several autosave intervals the database still does not exist.versionchangehandling disabled, the same scenario re-creates the deleted database.Jest's sandbox can't simulate a global
indexedDBgetter that throws. That case was checked with a plain Node script that loads the adapter through Babel. With the fix, the default setup falls back to memory andallowInMemoryFallback: falsefails setup, both with the original error ascause. Before the fix, setup failed in both modes.Validation in a real browser with multiple tabs
This PR's code (commit 9d8c920) was transpiled with the repo's Babel config, bundled with esbuild into a test page, and served from localhost. Each tab of Chromium 152 ran its own
LokiJSAdapteron real IndexedDB, withuseIncrementalIndexedDB: true,useWebWorker: falseandallowInMemoryFallback: false. The test page is not committed.onPersistenceErrorwith aVersionChangeError, thenonversionchange. Tab A rejectsbatchandunsafeExecute, and 3 s later the database still does not exist.versionchangemid-save and reports once, with no second error for the aborted save. The database is still gone 4 s later.versionchangehandling disabledonSetUpErrorafter aVersionChangeError. From 10 to 80 ms, setup succeeds, then the driver is broken. In every case where the database existed, it stayed deleted.The result does not depend on event order. Saving is disabled synchronously in the
versionchangehandler, and closing the connection lets an in-flight transaction finish before the deletion proceeds. So whichever finishes first, nothing writes to the database after it has been deleted.🤖 Generated with Claude Code