From 614cf061008d4f00303dff3ccc550cc6cbf14de9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20K=C3=B6hler?= Date: Sat, 31 Oct 2026 09:25:39 +0100 Subject: [PATCH] [LokiJS] Wait for in-flight saves before deleting database on reset 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 --- CHANGELOG-Unreleased.md | 1 + src/adapters/lokijs/test.js | 75 ++++++++++++++++++++ src/adapters/lokijs/worker/lokiExtensions.js | 32 +++++++-- 3 files changed, 101 insertions(+), 7 deletions(-) diff --git a/CHANGELOG-Unreleased.md b/CHANGELOG-Unreleased.md index 5f71752ab..97df9c0cd 100644 --- a/CHANGELOG-Unreleased.md +++ b/CHANGELOG-Unreleased.md @@ -13,6 +13,7 @@ - [LokiJS] Multitab sync issue fix - [Android] Added linker flag for building with 16kB page alignment - [TS] make catchError visible to typescript +- [LokiJS] Fixed `unsafeResetDatabase()` failing with "another operation is already in progress" when it's called while an autosave is in progress ### Performance diff --git a/src/adapters/lokijs/test.js b/src/adapters/lokijs/test.js index 6de2fb280..cea9180ce 100644 --- a/src/adapters/lokijs/test.js +++ b/src/adapters/lokijs/test.js @@ -28,6 +28,81 @@ describe('LokiJSAdapter (Synchronous / Memory persistence)', () => { }) }) +// Mimics IncrementalIndexedDBAdapter, which throws if asked to do something while an operation is +// in progress. While `holdSaves` is set, saves stay in progress until `finishSave()` is called +class SlowIncrementalAdapter { + mode = 'incremental' + + operationInProgress = false + + holdSaves = false + + finishSave = null + + deleteCount = 0 + + loadDatabase(dbname, callback) { + callback(null) + } + + saveDatabase(dbname, getLokiCopy, callback) { + if (this.operationInProgress) { + throw new Error('Error while saving to database - another operation is already in progress') + } + this.operationInProgress = true + getLokiCopy() + const finish = () => { + this.operationInProgress = false + callback() + } + if (this.holdSaves) { + this.finishSave = finish + } else { + setTimeout(finish, 1) + } + } + + deleteDatabase(dbname, callback) { + if (this.operationInProgress) { + throw new Error('Error while deleting database - another operation is already in progress') + } + this.deleteCount += 1 + callback({ success: true }) + } +} + +describe('LokiJSAdapter (Incremental persistence)', () => { + it('waits for a save in progress before resetting database', async () => { + const persistenceAdapter = new SlowIncrementalAdapter() + const adapter = new DatabaseAdapterCompat( + new LokiJSAdapter({ + dbName: `test${Math.random()}`, + schema: testSchema, + useWebWorker: false, + useIncrementalIndexedDB: true, + _testLokiAdapter: persistenceAdapter, + }), + ) + await adapter.setLocal('foo', 'bar') + const deleteCount = persistenceAdapter.deleteCount + + // start a save, which clears Loki's dirty flags while it's still in progress + persistenceAdapter.holdSaves = true + await adapter.unsafeExecute({ loki: (loki) => loki.saveDatabase() }) + persistenceAdapter.holdSaves = false + expect(persistenceAdapter.operationInProgress).toBe(true) + + const reset = adapter.unsafeResetDatabase() + await new Promise((resolve) => setTimeout(resolve, 10)) + expect(persistenceAdapter.deleteCount).toBe(deleteCount) + + persistenceAdapter.finishSave() + await reset + expect(persistenceAdapter.deleteCount).toBe(deleteCount + 1) + expect(await adapter.getLocal('foo')).toBe(null) + }) +}) + // TODO: Run tests with: // - mocked/polyfilled web worker // - legacy indexeddb adapter (fake-indexeddb polyfill) diff --git a/src/adapters/lokijs/worker/lokiExtensions.js b/src/adapters/lokijs/worker/lokiExtensions.js index 698d8c969..4fafa5f7d 100644 --- a/src/adapters/lokijs/worker/lokiExtensions.js +++ b/src/adapters/lokijs/worker/lokiExtensions.js @@ -98,19 +98,37 @@ export async function deleteDatabase(loki: Loki): Promise { // Works around a race condition - Loki doesn't disable autosave or drain save queue before // deleting database, so it's possible to delete and then have the database be saved loki.close(() => { - loki.deleteDatabase({}, (response) => { - // LokiIndexedAdapter responds with `{ success: true }`, while - // LokiMemory adapter just calls it with no params - if ((response && response.success) || response === undefined) { - resolve() - } else { - reject(response) + // close() only waits for a save if Loki is dirty, but a save may still be in flight (dirty + // flags are cleared when it starts) - and IncrementalIndexedDBAdapter throws if we try to + // delete database while it is + drainSaves(loki, () => { + try { + loki.deleteDatabase({}, (response) => { + // LokiIndexedAdapter responds with `{ success: true }`, while + // LokiMemory adapter just calls it with no params + if ((response && response.success) || response === undefined) { + resolve() + } else { + reject(response) + } + }) + } catch (error) { + reject(error) } }) }) }) } +function drainSaves(loki: Loki, callback: () => void): void { + // NOTE: throttledSaveDrain calls back twice if throttledSaves is disabled + if (loki.throttledSaves) { + loki.throttledSaveDrain(() => callback()) + } else { + callback() + } +} + // In case of a fatal error, break Loki so that it cannot save its contents to disk anymore // This might result in a loss of data in recent changes, but we assume that whatever caused the // fatal error has corrupted the database, so we want to prevent it from being persisted