Skip to content

[LokiJS] Opt-in exclusiveLock: one tab at a time holds the database - #1989

Open
joker-777 wants to merge 3 commits into
Nozbe:masterfrom
kenhub:fix/loki-exclusive-lock
Open

joker-777 wants to merge 3 commits into
Nozbe:masterfrom
kenhub:fix/loki-exclusive-lock

Conversation

@joker-777

@joker-777 joker-777 commented Nov 2, 2026 •

Copy link
Copy Markdown

Adds an opt-in exclusiveLock option to LokiJSAdapter, so only one browser tab at a time has the database open.

Without it, every tab keeps its own in-memory copy of the database, and the last tab to save overwrites what the others saved. That includes undoing a reset done in another tab, for example on logout.

Builds on #1979 and #1980. It uses the driver-breaking and persistence error handling from #1979 and drainSaves from #1980, so this branch contains both of those commits. Only the last commit, "[LokiJS] Opt-in exclusiveLock: one tab at a time holds the database", belongs to this PR. Once those two merge, the diff shrinks to it.

Changes

  • New exclusiveLock option. Before opening IndexedDB, the adapter takes an exclusive Web Lock (navigator.locks, named WatermelonDB:<dbName>), and holds it until the tab is closed
  • Other tabs wait. Their setup and all database operations stay pending until the tab holding the lock is closed; then they load the current data. A new onDatabaseLockedElsewhere callback is called once while they wait, so the app can tell the user to close the other tab. Setup doesn't fail here on purpose, because many apps reset the database in onSetUpError
  • A lost lock breaks the driver. If the lock is stolen (steal: true, or from DevTools), saving stops and further changes are rejected, through the same path as a database deleted in another tab ([LokiJS] Report persistence failures and allow refusing in-memory fallback #1979). Setup or a reset that is in progress when the lock is lost doesn't continue
  • Fallback: without the Web Locks API (old browsers, insecure contexts), the database opens without a lock, as before, and a warning is logged
  • Fix: after the database stops being saved, callbacks waiting for a save that was in progress (from close() or unsafeResetDatabase()) are now called with an error instead of never being called
  • Docs (Setup.md) and TypeScript types updated

Tests

  • New src/adapters/lokijs/databaseLock.test.js, with a stubbed navigator.locks. It covers one tab at a time with the callback, waiting without it, one lock per dbName, a stolen lock breaking the driver, the missing-API fallback, and no lock requests unless the option is enabled
  • Integration tests on fake-indexeddb with the real IncrementalIndexedDBAdapter: handoff to a waiting tab, a tab that loses the lock with unsaved changes, and a reset that loses the lock while waiting for a save
  • A test that every callback waiting for a save is called after a fatal error, even if one of them throws

Verification

  • On this branch: Jest passes on the host (81 suites; the sqlite-node suite was left out because better-sqlite3 doesn't build on the host's Node, and no SQLite code changed). flow check is clean, and ESLint is clean on the changed files
  • In Chromium, with real navigator.locks and IndexedDB:
    • Handoff: while tab A held the lock, tab B waited and onDatabaseLockedElsewhere fired once. After A was closed, B opened with all of A's saved records
    • Lock stolen while saving: tab A wrote a record every ~3 ms while tab C stole the lock and opened the database. A stopped saving and rejected writes. After reloading C, IndexedDB matched C's state exactly, and nothing A wrote after the steal reached it
    • Lock stolen during a reset: tab A called unsafeResetDatabase() while saving 40,000 records, and the lock was stolen right after. The reset rejected, the database was not deleted, and a new tab opened with all 40,000 records
  • Merges with [LokiJS] Web migrations: destroyTable, unsafeExecuteLoki, warn on ignored SQL #1988 (web migrations) with one trivial conflict in lokiExtensions.js, where both add a function at the same place

🤖 Generated with Claude Code

joker-777 and others added 3 commits October 31, 2026 23:52
…lback

- Add `onPersistenceError(error)`, wired to Loki's `autosaveCallback`: called once per save failure (not on every autosave retry), and again after a successful save. Raw IDB events are unwrapped, quota errors also go to `onQuotaExceededError`, and save failures are always logged.
- Add `allowInMemoryFallback` (default `true`): with `false`, unusable IndexedDB (missing, failing to open, or throwing on access/open) fails setup, so `onSetUpError` is called, with the original error as `cause`.
- Add `onInMemoryFallback(reason)`, called once per adapter.
- On `versionchange` (database deleted or upgraded in another tab), stop saving instead of re-creating the database from memory, break the driver so changes (`batch`, local storage, `unsafeExecute`, `unsafeResetDatabase`) are rejected, and report it via `onPersistenceError` before calling the app's `onversionchange`.
- Errors thrown by app callbacks on these paths are logged, so one failing callback can't skip the others or change the fallback policy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
unsafeResetDatabase() closes Loki and then deletes the database. close()
only waits for a save when Loki is dirty, but an autosave clears the dirty
flags as soon as it starts. If one was still running, close() emitted
'close' synchronously and IncrementalIndexedDBAdapter.deleteDatabase threw
"another operation is already in progress", rejecting the reset.

Drain Loki's throttled save queue after close() and before deleting.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* [LokiJS] Opt-in exclusiveLock: one tab at a time holds the database

With exclusiveLock: true, the adapter takes an exclusive Web Lock per
database before opening it. Other tabs wait until the holder is closed
and get onDatabaseLockedElsewhere. If the lock is lost, the driver
breaks (stops saving, rejects changes). Without navigator.locks, the
database opens without a lock and a warning is logged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Stop a database that finishes loading after the driver broke

If the exclusive lock is lost while Loki is loading (during setup or
reset), the new instance is now stopped from saving and setup or reset
fails before the schema is set up, so this tab can't overwrite the new
lock holder's data.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Log errors thrown by callbacks when the database lock is lost

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Don't delete the database after losing the lock, and match Loki's default name

- unsafeResetDatabase() checks that the driver isn't broken right before
  deleting the database, after waiting for in-flight saves, so a tab that
  lost the lock meanwhile can't delete the new holder's database.
- After a fatal error, callbacks waiting for a save that was in progress
  (close(), throttledSaveDrain()) are called with an error instead of
  never being called, so a reset can't hang.
- The lock name falls back to Loki's default database name, loki.db.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Test lock handoff and lock loss against IndexedDB

Uses fake-indexeddb with the real IncrementalIndexedDBAdapter: a waiting
tab gets all saved changes, a tab that lost the lock doesn't overwrite
the new holder's changes, and a reset that lost the lock doesn't delete
the new holder's database.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Call every waiting save callback after a fatal error, even if one throws

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
@StasDoskalenko

Copy link
Copy Markdown

Hi @joker-777, thank you, the lock-stealing tests are really thorough!

I maintain NitromelonDB, a maintained fork of WatermelonDB (New Architecture, Nitro Modules). Something that might interest you: on the web, our SQLiteAdapter now runs SQLite compiled to WASM (wa-sqlite) in a web worker, stored in IndexedDB. It's the same import and adapter as on native. Tabs share one database and coordinate through Web Locks, and every write is a real SQLite transaction, so there's no in-memory copy per tab for the last save to overwrite, and no need to lock other tabs out. More details are in the wa-sqlite adapter docs. One caveat: existing LokiJS data in the browser is not migrated automatically.

The fork was also rewritten in TypeScript (strict mode) to reduce the chance of bugs slipping through, so if you'd like to stay on LokiJS for now, a PR against our repo would be very welcome too. If you don't have the time or would rather not, no worries at all, I'm happy to port it myself. Just let me know. Thanks a lot!

@lucaswitch

Copy link
Copy Markdown

Hi @joker-777, thank you, the lock-stealing tests are really thorough!

I maintain NitromelonDB, a maintained fork of WatermelonDB (New Architecture, Nitro Modules). Something that might interest you: on the web, our SQLiteAdapter now runs SQLite compiled to WASM (wa-sqlite) in a web worker, stored in IndexedDB. It's the same import and adapter as on native. Tabs share one database and coordinate through Web Locks, and every write is a real SQLite transaction, so there's no in-memory copy per tab for the last save to overwrite, and no need to lock other tabs out. More details are in the wa-sqlite adapter docs. One caveat: existing LokiJS data in the browser is not migrated automatically.

The fork was also rewritten in TypeScript (strict mode) to reduce the chance of bugs slipping through, so if you'd like to stay on LokiJS for now, a PR against our repo would be very welcome too. If you don't have the time or would rather not, no worries at all, I'm happy to port it myself. Just let me know. Thanks a lot!

Yeah, watermelon is not being maintained. Nitromelon is the correct replacement, i have migrated two apps, no problems with nitromelon.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants