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
@@ -249,23 +249,23 @@ note only says where to start.
|
|||||||
`EventForm`, `src/client/components/TaskForm.tsx`). Review them as a reader would — field
|
`EventForm`, `src/client/components/TaskForm.tsx`). Review them as a reader would — field
|
||||||
order, defaults, copy, validation messages, how a cadence and a measure are chosen, phone
|
order, defaults, copy, validation messages, how a cadence and a measure are chosen, phone
|
||||||
width — against `docs/DESIGN.md` and `docs/PRODUCT.md`.
|
width — against `docs/DESIGN.md` and `docs/PRODUCT.md`.
|
||||||
- [ ] Follow up the 2026-10-18 pre-ship review of the v1→v2 reader migration. A schema-guardian review
|
- [x] Follow up the 2026-10-18 pre-ship review of the v1→v2 reader migration. A schema-guardian review
|
||||||
found nothing blocking: event IDs, day keys and the feed contract are unchanged, and v1 keys are
|
found nothing blocking: event IDs, day keys and the feed contract are unchanged, and v1 keys are
|
||||||
never removed, so a rollback finds them byte-identical. These gaps remain, all low severity:
|
never removed, so a rollback finds them byte-identical. Done 2026-10-18:
|
||||||
- Orphaned v1 entries (marks or amounts for a deleted `myevent:`/`mytask:`, or any entry whose game
|
- Start-up (finishing a pending restore, then migrating or reconciling) now runs under the reader
|
||||||
can't be resolved) are not carried into v2 account data (`state/reader/migration.ts`,
|
lock every save takes (`state/reader/open.ts`), so a second tab's save cannot be overwritten.
|
||||||
`gameOf`). They survive in `legacyRaw`, `gacha-tracker:v2:migration-recovery` and the untouched v1
|
Where no lock exists (no Web Locks, no IndexedDB) it opens unlocked, as before.
|
||||||
keys. Consider a catch-all bucket so a restored custom item finds them.
|
- The automatic backups have a size cap, `AUTO_BACKUP_BYTES`, like Back up now's: past it the
|
||||||
- Importing a v1 file whose region differs from the reader's account lands its marks in a new
|
oldest snapshots are dropped, never the newest (`state/reader/backups.ts`). The main save still
|
||||||
`account:import-N`, not the visible account (`state/reader/import.ts`, `export.ts`). Kept, but
|
carries `legacyRaw`, which reconciling and rollback need.
|
||||||
out of sight.
|
- Left as they are, by the agent's call: orphaned v1 entries (marks for a deleted
|
||||||
- `initializeReader` reconciles and writes back outside `withReaderLock`, with no compare-and-set
|
`myevent:`/`mytask:`, or with no resolvable game) stay out of v2 account data, as nothing can
|
||||||
(`state/reader/store.ts`). A second tab's write in that window can be overwritten.
|
show them; they survive in `legacyRaw`, `v2:migration-recovery` and the v1 keys. After a
|
||||||
- Storage pressure: every save carries `legacyRaw`, and every automatic backup embeds the state,
|
rollback, anything done in the new build is in v2 only: stranded for the old app, not lost.
|
||||||
`legacyRaw` and the v1 keys again, with no size cap (only manual backups are capped). Heavy
|
- [ ] Decide where an imported v1 file's marks go when its region differs from the reader's account.
|
||||||
readers could reach the ~5 MB quota. Failures are reported, not silent.
|
Today they land in a new `account:import-N`, kept but out of sight (`state/reader/import.ts`,
|
||||||
- After a rollback, anything done in the new build lives only in v2 and isn't shown by the old app.
|
`export.ts`). Options: merge into the visible account for that game, or show the imported account
|
||||||
It's stranded, not lost.
|
as a second server. Needs the owner's call (found by the 2026-10-18 migration review).
|
||||||
- [x] Get readers to try the layout choices, not just be told about them (owner, 2026-10-18). Done
|
- [x] Get readers to try the layout choices, not just be told about them (owner, 2026-10-18). Done
|
||||||
2026-10-18. The tour is now forced once for everyone, including new readers and anyone who saw the
|
2026-10-18. The tour is now forced once for everyone, including new readers and anyone who saw the
|
||||||
earlier tour. Its "Pick a layout" and Timeline steps switch the page behind in place, and the
|
earlier tour. Its "Pick a layout" and Timeline steps switch the page behind in place, and the
|
||||||
|
|||||||
+6
-2
@@ -797,12 +797,16 @@ importedExtensions as well as mergeable known fields. Existing same-server setti
|
|||||||
Migration preserves raw v1 keys in place and in migration recovery, creates one account per game at
|
Migration preserves raw v1 keys in place and in migration recovery, creates one account per game at
|
||||||
the saved region and maps old follows/pins/logs. Newly offered games remain off. Startup compares
|
the saved region and maps old follows/pins/logs. Newly offered games remain off. Startup compares
|
||||||
legacyRaw with surviving v1 keys to carry older-tab deltas into the original account without
|
legacyRaw with surviving v1 keys to carry older-tab deltas into the original account without
|
||||||
resurrecting newer v2 deletions. Parser aliases carry progress, ticks, amounts and archive/seen IDs.
|
resurrecting newer v2 deletions. Startup (finishing a pending restore, then migrating or reconciling)
|
||||||
|
runs under the same reader lock as every save (`state/reader/open.ts`), so another tab's save
|
||||||
|
cannot land between its read and its write. Parser aliases carry progress, ticks, amounts and archive/seen IDs.
|
||||||
|
|
||||||
Keys outside the envelope:
|
Keys outside the envelope:
|
||||||
|
|
||||||
- gacha-tracker:v2:migration-recovery — raw pre-migration bytes.
|
- gacha-tracker:v2:migration-recovery — raw pre-migration bytes.
|
||||||
- gacha-tracker:v2:backups — at most 7 daily + 4 weekly + 6 monthly UTC bucket snapshots.
|
- gacha-tracker:v2:backups — at most 7 daily + 4 weekly + 6 monthly UTC bucket snapshots, and at
|
||||||
|
most `AUTO_BACKUP_BYTES` (1.5M UTF-16 units) together: past it the oldest are dropped, never the
|
||||||
|
newest. Back up now copies (`v2:manual-backups`) have their own 20-copy, 1.5M cap.
|
||||||
- gacha-tracker:v2:restore-recovery — the complete pre-restore export.
|
- gacha-tracker:v2:restore-recovery — the complete pre-restore export.
|
||||||
- gacha-tracker:v2:restore-pending — prepared replacement, resumed before mounting on startup.
|
- gacha-tracker:v2:restore-pending — prepared replacement, resumed before mounting on startup.
|
||||||
|
|
||||||
|
|||||||
+10
-18
@@ -16,13 +16,9 @@ import {
|
|||||||
type FeedState,
|
type FeedState,
|
||||||
type RefreshOutcome,
|
type RefreshOutcome,
|
||||||
} from "./api.ts";
|
} from "./api.ts";
|
||||||
import {
|
import { createReaderStore, type ReaderStore } from "./state/reader/store.ts";
|
||||||
initializeReader,
|
import { openReader } from "./state/reader/open.ts";
|
||||||
createReaderStore,
|
|
||||||
type ReaderStore,
|
|
||||||
} from "./state/reader/store.ts";
|
|
||||||
import { ReaderContext, useReader } from "./state/reader/useReader.ts";
|
import { ReaderContext, useReader } from "./state/reader/useReader.ts";
|
||||||
import { finishPendingRestore } from "./state/reader/restore.ts";
|
|
||||||
import { saveBackup } from "./state/reader/backups.ts";
|
import { saveBackup } from "./state/reader/backups.ts";
|
||||||
import {
|
import {
|
||||||
pairKey,
|
pairKey,
|
||||||
@@ -128,15 +124,8 @@ export function ReaderApp() {
|
|||||||
.then(setCodes)
|
.then(setCodes)
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
void fetchFeed(ac.signal)
|
void fetchFeed(ac.signal)
|
||||||
.then((feed) => {
|
.then(async (feed) => {
|
||||||
const recovery = finishPendingRestore(localStorage);
|
const opened = await openReader(
|
||||||
if (!recovery.ok) {
|
|
||||||
setError(
|
|
||||||
"Your interrupted restore could not be recovered. Free browser storage and reload; saved data has been retained.",
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const initialized = initializeReader(
|
|
||||||
localStorage,
|
localStorage,
|
||||||
[
|
[
|
||||||
...new Set([
|
...new Set([
|
||||||
@@ -147,13 +136,16 @@ export function ReaderApp() {
|
|||||||
new Date().toISOString(),
|
new Date().toISOString(),
|
||||||
mintAccountId,
|
mintAccountId,
|
||||||
);
|
);
|
||||||
if (!initialized.ok) {
|
if (ac.signal.aborted) return;
|
||||||
|
if (!opened.ok) {
|
||||||
setError(
|
setError(
|
||||||
"Your saved data could not be opened safely. It has been retained. Check browser storage access and reload.",
|
opened.reason === "restore"
|
||||||
|
? "Your interrupted restore could not be recovered. Free browser storage and reload; saved data has been retained."
|
||||||
|
: "Your saved data could not be opened safely. It has been retained. Check browser storage access and reload.",
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const created = createReaderStore(localStorage, initialized.state);
|
const created = createReaderStore(localStorage, opened.state);
|
||||||
setStore(created);
|
setStore(created);
|
||||||
setFeed({ status: "ready", feed });
|
setFeed({ status: "ready", feed });
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -60,8 +60,19 @@ export function saveBackup(
|
|||||||
[...previous, { id: mintLocalId(), at, payload }],
|
[...previous, { id: mintLocalId(), at, payload }],
|
||||||
Date.parse(at),
|
Date.parse(at),
|
||||||
);
|
);
|
||||||
|
// Oldest first, but never the newest: one recent copy is worth more than
|
||||||
|
// the room, and past the cap the main save is what would run out.
|
||||||
|
while (next.length > 1 && JSON.stringify(next).length > AUTO_BACKUP_BYTES)
|
||||||
|
next.pop();
|
||||||
return tryWriteJson(storage, READER_KEYS.backups, next);
|
return tryWriteJson(storage, READER_KEYS.backups, next);
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* The most the automatic history may take in local storage, in UTF-16 units.
|
||||||
|
* Each snapshot embeds the state, `legacyRaw` and the v1 keys, and up to 17
|
||||||
|
* are retained; for a heavy reader that alone could fill the origin's ~5MB.
|
||||||
|
* Together with `MANUAL_BACKUP_BYTES` this leaves the main save room to grow.
|
||||||
|
*/
|
||||||
|
export const AUTO_BACKUP_BYTES = 1_500_000;
|
||||||
/**
|
/**
|
||||||
* Which part of the retained history a snapshot belongs to, with the same
|
* Which part of the retained history a snapshot belongs to, with the same
|
||||||
* windows `retainBackups` keeps: the last 7 days, the 4 weeks before them,
|
* windows `retainBackups` keeps: the last 7 days, the 4 weeks before them,
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import type { LaneId } from "../../../shared/custom.ts";
|
||||||
|
import { withReaderLock } from "./lock.ts";
|
||||||
|
import { finishPendingRestore } from "./restore.ts";
|
||||||
|
import { initializeReader } from "./store.ts";
|
||||||
|
import type { AccountId, ReaderState } from "./schema.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the reader's save at start-up: finish an interrupted restore, then
|
||||||
|
* migrate or reconcile and write back. Both read the envelope and write it
|
||||||
|
* again, so they run under the lock every save takes; otherwise a second
|
||||||
|
* tab's save landing between the read and the write would be overwritten.
|
||||||
|
*/
|
||||||
|
export async function openReader(
|
||||||
|
storage: Storage,
|
||||||
|
games: readonly LaneId[],
|
||||||
|
at: string,
|
||||||
|
mint: () => AccountId,
|
||||||
|
): Promise<
|
||||||
|
{ ok: true; state: ReaderState } | { ok: false; reason: "restore" | "open" }
|
||||||
|
> {
|
||||||
|
const open = () => {
|
||||||
|
if (!finishPendingRestore(storage).ok)
|
||||||
|
return { ok: false, reason: "restore" } as const;
|
||||||
|
const opened = initializeReader(storage, games, at, mint);
|
||||||
|
return opened.ok ? opened : ({ ok: false, reason: "open" } as const);
|
||||||
|
};
|
||||||
|
// Both steps catch their own errors, so a throw here is the lock itself
|
||||||
|
// being unavailable (no Web Locks and no IndexedDB, as in some private
|
||||||
|
// windows). Opening unlocked then is what start-up did before the lock.
|
||||||
|
try {
|
||||||
|
return await withReaderLock(open);
|
||||||
|
} catch {
|
||||||
|
return open();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { expect, test } from "bun:test";
|
import { expect, test } from "bun:test";
|
||||||
import {
|
import {
|
||||||
|
AUTO_BACKUP_BYTES,
|
||||||
deleteManualBackup,
|
deleteManualBackup,
|
||||||
MANUAL_BACKUP_BYTES,
|
MANUAL_BACKUP_BYTES,
|
||||||
MANUAL_BACKUP_LIMIT,
|
MANUAL_BACKUP_LIMIT,
|
||||||
@@ -88,3 +89,36 @@ test("the list refuses a copy that would push it past its size budget", () => {
|
|||||||
});
|
});
|
||||||
expect(readManualBackups(storage)).toEqual([]);
|
expect(readManualBackups(storage)).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("the automatic history drops its oldest copies past its size cap, never the newest", () => {
|
||||||
|
const storage = memory();
|
||||||
|
// A heavy reader: about 300k units of v1 data embedded in every snapshot.
|
||||||
|
storage.setItem(
|
||||||
|
"gacha-tracker:v1:progress",
|
||||||
|
JSON.stringify("x".repeat(300_000)),
|
||||||
|
);
|
||||||
|
const day = 86_400_000;
|
||||||
|
for (let i = 0; i < 10; i++)
|
||||||
|
expect(
|
||||||
|
saveBackup(
|
||||||
|
storage,
|
||||||
|
state,
|
||||||
|
new Date(Date.parse(at) + i * day).toISOString(),
|
||||||
|
).ok,
|
||||||
|
).toBe(true);
|
||||||
|
const kept = readBackups(storage);
|
||||||
|
expect(storage.getItem(READER_KEYS.backups)!.length).toBeLessThanOrEqual(
|
||||||
|
AUTO_BACKUP_BYTES,
|
||||||
|
);
|
||||||
|
expect(kept.length).toBeGreaterThan(1);
|
||||||
|
expect(kept.length).toBeLessThan(7);
|
||||||
|
expect(kept[0]!.at).toBe(new Date(Date.parse(at) + 9 * day).toISOString());
|
||||||
|
|
||||||
|
storage.setItem(
|
||||||
|
"gacha-tracker:v1:progress",
|
||||||
|
JSON.stringify("x".repeat(AUTO_BACKUP_BYTES)),
|
||||||
|
);
|
||||||
|
const last = new Date(Date.parse(at) + 10 * day).toISOString();
|
||||||
|
expect(saveBackup(storage, state, last).ok).toBe(true);
|
||||||
|
expect(readBackups(storage).map((b) => b.at)).toEqual([last]);
|
||||||
|
});
|
||||||
@@ -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