fix(reader): open the save under the reader lock, and cap automatic backups
Follow-up to the 2026-10-18 review of the v1→v2 migration. Start-up (finishing a pending restore, then migrating or reconciling) now runs under the lock every save takes, so a second tab's save cannot be overwritten; where no lock exists it opens unlocked, as before. The automatic backups get a size cap like Back up now's: past it the oldest are dropped, never the newest, leaving the main save room to grow. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6720c72840
commit
94fba4d7b5
7 files changed
+185
-36
No files matched your search
@@ -0,0 +1,73 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { openReader } from "../src/client/state/reader/open.ts";
|
||||
import { READER_KEYS } from "../src/client/state/reader/schema.ts";
|
||||
import { MemoryStorage } from "./helpers/reader.ts";
|
||||
|
||||
const at = "2026-10-18T12:00:00.000Z";
|
||||
|
||||
test("start-up reads and writes the save inside the reader lock", async () => {
|
||||
const s = new MemoryStorage();
|
||||
const seen: string[] = [];
|
||||
const locks = {
|
||||
request: async (name: string, run: () => unknown) => {
|
||||
seen.push(`lock ${name}`);
|
||||
const value = await run();
|
||||
seen.push(`release, saved: ${s.getItem(READER_KEYS.reader) !== null}`);
|
||||
return value;
|
||||
},
|
||||
};
|
||||
const original = Object.getOwnPropertyDescriptor(globalThis, "navigator");
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
value: { locks },
|
||||
configurable: true,
|
||||
});
|
||||
try {
|
||||
const opened = await openReader(s, ["genshin"], at, () => "account:one");
|
||||
expect(opened.ok).toBe(true);
|
||||
} finally {
|
||||
if (original) Object.defineProperty(globalThis, "navigator", original);
|
||||
else delete (globalThis as { navigator?: unknown }).navigator;
|
||||
}
|
||||
expect(seen).toEqual([
|
||||
"lock gacha-tracker:v2:reader",
|
||||
"release, saved: true",
|
||||
]);
|
||||
});
|
||||
|
||||
test("an interrupted restore that cannot be read stops start-up, keeping the save", async () => {
|
||||
const s = new MemoryStorage();
|
||||
s.setItem(READER_KEYS.pending, JSON.stringify({ at, payload: {} }));
|
||||
const opened = await openReader(s, ["genshin"], at, () => "account:one");
|
||||
expect(opened).toEqual({ ok: false, reason: "restore" });
|
||||
expect(s.getItem(READER_KEYS.pending)).not.toBeNull();
|
||||
expect(s.getItem(READER_KEYS.reader)).toBeNull();
|
||||
});
|
||||
|
||||
test("an unreadable save is reported, not replaced", async () => {
|
||||
const s = new MemoryStorage();
|
||||
s.setItem(READER_KEYS.reader, JSON.stringify({ version: 2 }));
|
||||
const opened = await openReader(s, ["genshin"], at, () => "account:one");
|
||||
expect(opened).toEqual({ ok: false, reason: "open" });
|
||||
expect(s.getItem(READER_KEYS.reader)).toBe(JSON.stringify({ version: 2 }));
|
||||
});
|
||||
|
||||
test("with no lock available, start-up still opens the save", async () => {
|
||||
const s = new MemoryStorage();
|
||||
const original = Object.getOwnPropertyDescriptor(globalThis, "navigator");
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
value: {
|
||||
locks: {
|
||||
request: () => Promise.reject(new Error("unavailable")),
|
||||
},
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
try {
|
||||
const opened = await openReader(s, ["genshin"], at, () => "account:one");
|
||||
expect(opened.ok).toBe(true);
|
||||
} finally {
|
||||
if (original) Object.defineProperty(globalThis, "navigator", original);
|
||||
else delete (globalThis as { navigator?: unknown }).navigator;
|
||||
}
|
||||
expect(s.getItem(READER_KEYS.reader)).not.toBeNull();
|
||||
});
|
||||
Reference in new issue
Block a user