Skip to content

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

Merged
joker-777 merged 7 commits into
kenhubfrom
claude/loki-web-locks
Nov 2, 2026
Merged

joker-777 merged 7 commits into
kenhubfrom
claude/loki-web-locks

Conversation

@joker-777

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

Copy link
Copy Markdown
  • 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.
  • onversionchange needs 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

  • 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.

🤖 Generated with Claude Code

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>
Copilot AI balanced review requested due to automatic review settings November 2, 2026 09:22

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

A critical lock-loss race during database opening can leave autosave enabled, allowing schema changes to overwrite the new lock holder’s data.

1 open issue
What changed in this PR

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.
docs-website/​docs/​docs/​Setup.md Documents configuration and multi-tab behavior.
CHANGELOG-Unreleased.md Announces the exclusive-lock option.

Review effort: Balanced

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>
Copilot AI balanced review requested due to automatic review settings November 2, 2026 09:38

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Medium severity 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.

1 resolved since last review

Review effort: Balanced

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

Copy link
Copy Markdown
Author

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.

Copilot AI balanced review requested due to automatic review settings November 2, 2026 09:43

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

High severity Recheck lock ownership before deleting the database

src/​adapters/​lokijs/​worker/​DatabaseDriver.js:64

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.

High severity 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.

Review effort: Balanced

…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>
@joker-777

Copy link
Copy Markdown
Author

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.

Copilot AI balanced review requested due to automatic review settings November 2, 2026 09:51

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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.

No open issues 🎉

Review effort: Balanced

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>
@joker-777

Copy link
Copy Markdown
Author

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.

Copilot AI balanced review requested due to automatic review settings November 2, 2026 10:02

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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.

No open issues 🎉

Review effort: Balanced

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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.

1 open issue

Review effort: Balanced

# Conflicts:
#	CHANGELOG-Unreleased.md
Copilot AI balanced review requested due to automatic review settings November 2, 2026 10:28

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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.

1 open issue

Review effort: Balanced

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@joker-777
joker-777 requested a balanced review from Copilot and removed request for Copilot November 2, 2026 10:33

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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.

No open issues 🎉

1 resolved since last review

Review effort: Balanced

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

No active review issues remain for the opt-in LokiJS cross-tab locking changes.

No open issues 🎉

Review effort: Balanced

@joker-777
joker-777 merged commit c2dd8d9 into kenhub Nov 2, 2026
9 of 10 checks passed
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.

2 participants