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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user