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
{{ message }}
Repository navigation
[LokiJS] Opt-in exclusiveLock: one tab at a time holds the database - #19
New opt-in exclusiveLock option. The adapter takes an exclusive Web Lock (navigator.locks, named WatermelonDB:<dbName>) before opening IndexedDB and holds it until the tab closes. Tabs can no longer overwrite each other's unpushed changes, and a tab can't undo a logout reset done in another tab.
Other tabs wait. Their setup and all database operations stay pending until the holding tab is closed, then they load the current data. onDatabaseLockedElsewhere is called once while they wait, so the app can tell the user to close the other tab. Setup deliberately doesn't fail here, because the quiz companion's onSetUpError resets the database.
A lost lock breaks the driver, for example when it's stolen with steal: true or from DevTools. It goes through the existing _breakDriver path: saving stops, further changes are rejected, and _onFatalError is called.
Fallback: without the Web Locks API (old browsers, insecure contexts), the database opens without a lock, as before, and a warning is logged.
New src/adapters/lokijs/databaseLock.test.js with a stubbed navigator.locks lock manager covers one tab at a time with the callback, waiting without the callback, one lock per dbName, a stolen lock breaking the driver, the missing-API fallback, and no lock requests unless the option is enabled. 5 of 6 fail on kenhub; the "not enabled" case passes either way, as intended.
Integration tests on fake-indexeddb with the real IncrementalIndexedDBAdapter cover 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.
Host jest (all suites except sqlite-node), flow, eslint, tslint and test:typescript pass. No SQLite code changed.
Real-browser validation
Run in Chromium (the Claude desktop app's browser pane), with real navigator.locks and IndexedDB, and this branch transpiled with the repo's Babel config:
Handoff: tab A held the lock. Tab B's request stayed pending, and onDatabaseLockedElsewhere fired once. After tab A was closed, tab B opened with all of A's saved records.
Steal during in-flight saves: tab A wrote a record every ~3 ms (1460 writes) while tab C stole the lock (steal: true) and opened the database. A got DatabaseLockLostError, autosave turned off, and writes were rejected. C destroyed one of A's records and created its own. After reloading C, the persisted database matched C's state exactly. A's 4 writes that were still unsaved at the steal were lost, and nothing from A after the steal reached IndexedDB.
Steal during reset: tab A saved 20,000 records, created 20,000 more, and called unsafeResetDatabase() while that autosave was in flight. The lock was stolen right after. The reset rejected after 8 ms ("broken state"), and the database was not deleted (405 IndexedDB keys kept). A new tab opened with all 40,000 records, and A kept rejecting writes with autosave off.
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>
Adds opt-in Web Locks coordination to the LokiJS adapter so only one tab at a time opens a database, addressing cross-tab overwrites and reset conflicts.
Changes:
Acquires a database-specific lock before setup; other tabs wait and can notify the app.
Routes lock loss through the broken-driver path and warns when Web Locks are unavailable.
Adds option types, lock lifecycle tests, and usage documentation.
File
Description
src/adapters/lokijs/worker/lokiExtensions.js
Exports the safe callback helper for reuse.
src/adapters/lokijs/worker/databaseLock.js
Implements lock acquisition, waiting, fallback, and loss handling.
src/adapters/lokijs/worker/DatabaseDriver.js
Acquires the lock before database setup.
src/adapters/lokijs/index.js
Adds Flow options and callback documentation.
src/adapters/lokijs/index.d.ts
Adds matching TypeScript options.
src/adapters/lokijs/databaseLock.test.js
Tests contention, lock loss, fallback, and opt-in behavior.
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>
The reason will be displayed to describe this comment to others. Learn more.
🔵 Needs a closer look
An unguarded _onFatalError callback in the lock-loss path can throw and cause an unhandled rejection; it should use callSafely to log callback errors without letting them escape.
No open issues 🎉
1 previously missed issue
In code that hasn't changed since last review
Guard fatal-error callback to prevent unhandled rejection
src/adapters/lokijs/worker/databaseLock.js:58
If the app's _onFatalError callback throws, _breakDriver() propagates that exception through onLockLost. Because the promise returned by .then() is discarded and lock acquisition has already resolved, this produces an unhandled rejection. The existing onversionchange path guards the same driver callback with callSafely; use that guard here too so callback errors are logged without escaping globally.
Addressed the previously missed issue (an unguarded fatal-error callback on lock loss) in 212d860. The lock-loss path now calls the driver callback through callSafely, like the onversionchange path, so an error thrown by _onFatalError is logged instead of becoming an unhandled rejection. A regression test checks that no unhandled rejection occurs and the driver still rejects changes. onDatabaseLockedElsewhere already went through callSafely.
The reason will be displayed to describe this comment to others. Learn more.
🔵 Needs a closer look
Two critical locking issues remain: a reset can delete the new lock holder’s database after losing ownership, and inconsistent default lock names allow concurrent access to the same persisted database.
No open issues 🎉
2 previously missed issues
In code that hasn't changed since last review
Recheck lock ownership before deleting the database
If the lock is stolen while unsafeResetDatabase() waits for loki.close() or drainSaves(), the queued loki.deleteDatabase() still runs and can delete the new holder's database. The broken-state check in _openDatabase() runs only after that deletion. Pass a driver-state guard into the deletion helper and check it immediately before deleting; add a regression test that steals the lock while reset is draining saves.
Use Loki’s effective default database name for the lock
src/adapters/lokijs/worker/databaseLock.js:31
The fallback lock name does not match Loki's actual default database name. newLoki() passes options.dbName directly to the Loki constructor, which defaults to loki.db, while this code defaults to loki. Consequently, a tab omitting dbName and a tab specifying dbName: 'loki.db' acquire different locks for the same persisted database and can still overwrite each other's changes despite both enabling exclusiveLock. Use Loki's effective filename for the lock name, and cover the omitted-versus-explicit default case in the tests.
…ault 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>
Addressed both previously missed issues in faca18c:
Lock ownership before deleting:deleteDatabase() takes a guard that runs right before loki.deleteDatabase(), after close() and the save drain. unsafeResetDatabase() passes _assertNotBroken. While fixing this, I found that after lokiFatalError Loki's follow-up save hit the throwing stub, so callbacks queued by close()/throttledSaveDrain() were never called and the reset hung. The stub now calls queued callbacks with an error. A regression test steals the lock while reset waits for a held save; it fails if either part is reverted.
Default lock name: it now falls back to loki.db, Loki's default filename. A test checks that an adapter without dbName and one with dbName: 'loki.db' share the lock.
The reason will be displayed to describe this comment to others. Learn more.
🔵 Needs a closer look
The stub-based tests do not establish data safety during cross-tab lock handoff or forced lock loss, requiring real-browser validation with in-flight IndexedDB saves and deletes.
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>
On data safety during handoff and lock loss: 00ce686 adds integration tests that run against IndexedDB (fake-indexeddb with the real IncrementalIndexedDBAdapter, not a stub persistence adapter). They check that a waiting tab receives all changes the previous holder saved, and that a tab that loses the lock with unsaved changes doesn't overwrite the new holder's changes (a third tab reopens and checks the persisted state). They also check that a reset that loses the lock while waiting for an in-flight save doesn't delete the new holder's database. The last two fail if the lock-loss break or the pre-delete guard is reverted. Web Locks semantics in a real browser still need manual QA in the app.
The reason will be displayed to describe this comment to others. Learn more.
🔵 Needs a closer look
Cross-tab data safety depends on lock-loss ordering relative to in-flight IndexedDB saves and deletes, which stubbed tests cannot establish without real-browser multi-tab validation.
The reason will be displayed to describe this comment to others. Learn more.
🟡 Changes recommended
A throwing queued save callback can prevent later waiters from running, leaving close/reset operations pending indefinitely; each callback invocation needs isolation with callSafely.
The reason will be displayed to describe this comment to others. Learn more.
🔵 Needs a closer look
A throwing queued save/close callback can abort the loop and permanently drop remaining callbacks, leaving a pending close/reset hanging on the fatal-error path.
The reason will be displayed to describe this comment to others. Learn more.
🔵 Needs a closer look
Tests stub the lock manager, so real multi-tab browser validation is needed to verify data integrity during lock stealing and in-flight saves or resets under native Web Locks and IndexedDB scheduling.
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.
exclusiveLockoption. The adapter takes an exclusive Web Lock (navigator.locks, namedWatermelonDB:<dbName>) before opening IndexedDB and holds it until the tab closes. Tabs can no longer overwrite each other's unpushed changes, and a tab can't undo a logout reset done in another tab.onDatabaseLockedElsewhereis called once while they wait, so the app can tell the user to close the other tab. Setup deliberately doesn't fail here, because the quiz companion'sonSetUpErrorresets the database.steal: trueor from DevTools. It goes through the existing_breakDriverpath: saving stops, further changes are rejected, and_onFatalErroris called.onversionchangeneeds no change. [LokiJS] Report persistence failures and allow refusing in-memory fallback #7 already made it break the driver by default; the existing persistence test covers that.Verification
src/adapters/lokijs/databaseLock.test.jswith a stubbednavigator.lockslock manager covers one tab at a time with the callback, waiting without the callback, one lock perdbName, a stolen lock breaking the driver, the missing-API fallback, and no lock requests unless the option is enabled. 5 of 6 fail onkenhub; the "not enabled" case passes either way, as intended.IncrementalIndexedDBAdaptercover 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.test:typescriptpass. No SQLite code changed.Real-browser validation
Run in Chromium (the Claude desktop app's browser pane), with real
navigator.locksand IndexedDB, and this branch transpiled with the repo's Babel config:onDatabaseLockedElsewherefired once. After tab A was closed, tab B opened with all of A's saved records.steal: true) and opened the database. A gotDatabaseLockLostError, autosave turned off, and writes were rejected. C destroyed one of A's records and created its own. After reloading C, the persisted database matched C's state exactly. A's 4 writes that were still unsaved at the steal were lost, and nothing from A after the steal reached IndexedDB.unsafeResetDatabase()while that autosave was in flight. The lock was stolen right after. The reset rejected after 8 ms ("broken state"), and the database was not deleted (405 IndexedDB keys kept). A new tab opened with all 40,000 records, and A kept rejecting writes with autosave off.🤖 Generated with Claude Code