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

@@ -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);
}
}