fix(settings): restore the lock index when an emptying write fails and publish rollbacks after re-stamping

- Removing a playlist's last lock clears the SQLite index first; if the
  clear or the store write then fails, the index is re-stamped from the
  previous locks at once. Title matching and multi-source discovery query
  the worker directly and trust the index, so the stale flag alone did not
  protect them.
- A rollback publishes its store revision only after every type is
  re-stamped, so a reload cannot read a later type through the attempted
  stamps.
- docs: restore the index rules the earlier surfaces rewrite dropped from
  the contract, now in the Lock store lifetime section.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-09-27 09:52:08 +02:00
1 parent 5c3e675b41
commit 73ccdebb60
3 files changed
+99 -8

No files matched your search

+27
View File
@@ -358,6 +358,33 @@ data layer back into `main.js`. The feature costs about 30 KB of
## Lock store lifetime
On Electron the SQLite `categories.locked` index is derived from the lock
store and must never lag behind it, because the worker filters every read
by the index alone:
- The store commits BEFORE the re-stamp, so a failed re-stamp rolls the
store back to the previous locks AND re-stamps every touched type from
them (a backup restore stamps three types, and the ones before the
failing type already carry the new locks). The store revision consumers
reload on is published only once every touched type is stamped — also
after a rollback — so a reload cannot read a later type through its old
stamps. If the rollback write or its re-stamp fails too, the playlist is
marked stale and re-stamped on the next store access.
- A write that removes a playlist's LAST lock clears the index first and
drops the key afterwards: the launch-time reconcile finds playlists only
through their key, so an interruption must leave store-with-lock and
index-without, which the next reconcile repairs toward locked. If the
clear or the store write fails, the index is re-stamped from the previous
locks at once — title matching and multi-source discovery query the
worker directly and trust the index, so a stale flag alone would not
protect them.
- Every launch re-derives the index from the store for each playlist that
has locks, and a store recovered by a later successful read marks its
playlists stale the same way. The reconcile is awaited inside the store's
`load()`, so `readable` (and with it every catalog read the renderer
gates) stays false until the index agrees with the store; a re-stamp that
keeps failing keeps the session fail-closed.
Every lock-store mutation runs through one write queue in
`ParentalLockLockStore`: each rewrites the whole persisted store from the
in-memory copy, so overlapping edits (two right-click toggles, a dialog
@@ -141,6 +141,52 @@ describe('ParentalLockLockStore', () => {
expect(storage.writeLocks).toHaveBeenLastCalledWith({});
});
it('restores the cleared index when saving the emptied store fails', async () => {
await store.load();
await store.ensureReadable();
setCategoryLocks.mockClear();
storage.writeLocks.mockResolvedValueOnce(false);
await expect(store.setXtreamLocks('pl-1', 'live', [])).resolves.toBe(
false
);
// Cleared first, then put back from the previous locks.
expect(setCategoryLocks.mock.calls).toEqual([
['pl-1', 'live', []],
['pl-1', 'live', [7]],
]);
expect(store.readable()).toBe(true);
});
it('publishes a rollback only after every type is re-stamped', async () => {
await store.load();
await store.ensureReadable();
const before = store.revision();
setCategoryLocks.mockClear();
const revisionsAtStamp: number[] = [];
let call = 0;
setCategoryLocks.mockImplementation(async () => {
revisionsAtStamp.push(store.revision());
call += 1;
// live ok, movies fails, then the rollback re-stamps succeed
return call !== 2;
});
await expect(
store.replacePlaylistLocks('pl-1', {
xtream: [{ categoryType: 'movies', xtreamId: 3 }],
stalker: [],
m3u: [],
})
).resolves.toBe(false);
expect(revisionsAtStamp.every((revision) => revision === before)).toBe(
true
);
expect(store.revision()).toBe(before + 1);
});
it('does not drop the key when clearing the index fails', async () => {
await store.load();
await store.ensureReadable();
@@ -365,19 +365,20 @@ export class ParentalLockLockStore {
return false;
}
if (
!(await this.stampXtreamLocks(
(await this.stampXtreamLocks(
playlistId,
categoryTypes,
normalizedNext
))
)) &&
(await this.persistPlaylistLocks(playlistId, normalizedNext))
) {
return false;
}
if (await this.persistPlaylistLocks(playlistId, normalizedNext)) {
return true;
}
this.markIndexStale(playlistId);
this.revisionState.update((value) => value + 1);
// The clear (partly) reached the index but the store still holds
// the locks: put the index back at once — consumers that query
// the worker directly (title matching, multi-source discovery)
// trust `locked` and are not gated by the renderer's stale flag.
await this.restoreIndex(playlistId, previous, categoryTypes);
return false;
}
// The revision is published only once every type is stamped: a
@@ -414,13 +415,30 @@ export class ParentalLockLockStore {
previous: ParentalLockPlaylistLocks,
categoryTypes: readonly ParentalLockXtreamCategoryType[]
): Promise<void> {
const restored = await this.persistPlaylistLocks(playlistId, previous);
// Published once, after the re-stamp: a reload on an earlier
// revision would read a later type through the attempted stamps.
const restored = await this.persistPlaylistLocks(playlistId, previous, {
publish: false,
});
const restamped =
restored &&
(await this.stampXtreamLocks(playlistId, categoryTypes));
if (!restored || !restamped) {
console.error('Failed to roll back the parental lock index.');
this.markIndexStale(playlistId);
}
this.revisionState.update((value) => value + 1);
}
/** Re-stamps the index from `locks`; marks it stale when that fails. */
private async restoreIndex(
playlistId: string,
locks: ParentalLockPlaylistLocks,
categoryTypes: readonly ParentalLockXtreamCategoryType[]
): Promise<void> {
if (!(await this.stampXtreamLocks(playlistId, categoryTypes, locks))) {
console.error('Failed to restore the parental lock index.');
this.markIndexStale(playlistId);
this.revisionState.update((value) => value + 1);
}
}