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