fix(persistence): serialize autosave generations before queue effects

Track settings input generations so an older asynchronous secret save cannot mark a newer value durable. Commit queue snapshots before cancellation, cleanup, or pause effects so SQLite failures leave runtime processes and files unchanged.
This commit is contained in:
Sucukdeluxe
2026-08-12 00:38:49 +02:00
parent 6f02e9aa3a
commit 421da7be77
5 changed files with 211 additions and 26 deletions
+61 -2
View File
@@ -1,10 +1,10 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { openDatabase, type DbHandle } from '../infra/db';
import { createAppStateStore } from './app-state-store';
import { persistStateChange } from './persistence-commit';
import { commitQueueMutation, persistStateChange } from './persistence-commit';
let directory: string;
let db: DbHandle;
@@ -59,4 +59,63 @@ describe('persistStateChange', () => {
expect(runtime).toEqual(previous);
expect(createAppStateStore(db).loadQueue()).toEqual(previous);
});
it('does not cancel a process or delete a file when queue removal cannot persist', async () => {
const previous = [{ id: 'q1', status: 'pending' }];
const temporaryFile = path.join(directory, 'q1.partial');
fs.writeFileSync(temporaryFile, 'keep');
createAppStateStore(db).saveQueue(previous);
const failingDb: DbHandle = {
...db,
run(sql, params) {
if (sql.includes('DELETE FROM queue_items')) throw new Error('SQLITE_IOERR remove');
db.run(sql, params);
},
};
const cancelProcess = vi.fn();
let runtime = previous;
await expect(commitQueueMutation(
runtime,
() => [],
(candidate) => createAppStateStore(failingDb).saveQueue(candidate),
(candidate) => { runtime = candidate; },
async () => {
cancelProcess('q1');
fs.rmSync(temporaryFile);
},
)).rejects.toThrow('SQLITE_IOERR remove');
expect(cancelProcess).not.toHaveBeenCalled();
expect(fs.existsSync(temporaryFile)).toBe(true);
expect(runtime).toEqual(previous);
expect(createAppStateStore(db).loadQueue()).toEqual(previous);
});
it('does not pause a process when a paused queue snapshot cannot persist', async () => {
const previous = [{ id: 'q1', status: 'downloading' }];
const paused = [{ id: 'q1', status: 'paused' }];
createAppStateStore(db).saveQueue(previous);
const failingDb: DbHandle = {
...db,
run(sql, params) {
if (sql.includes('DELETE FROM queue_items')) throw new Error('SQLITE_IOERR pause');
db.run(sql, params);
},
};
const pauseProcess = vi.fn();
let runtime = previous;
await expect(commitQueueMutation(
runtime,
() => paused,
(candidate) => createAppStateStore(failingDb).saveQueue(candidate),
(candidate) => { runtime = candidate; },
async () => { pauseProcess('q1'); },
)).rejects.toThrow('SQLITE_IOERR pause');
expect(pauseProcess).not.toHaveBeenCalled();
expect(runtime).toEqual(previous);
expect(createAppStateStore(db).loadQueue()).toEqual(previous);
});
});
+14
View File
@@ -3,3 +3,17 @@ export function persistStateChange<T>(current: T, createNext: (current: T) => T,
persist(next);
return next;
}
export async function commitQueueMutation<T>(
current: T,
createNext: (current: T) => T,
persist: (next: T) => void,
apply: (next: T) => void,
effects: () => Promise<void>,
): Promise<T> {
const next = createNext(current);
persist(next);
apply(next);
await effects();
return next;
}