mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-09 01:16:15 -08:00
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:
1 parent
5c3e675b41
commit
73ccdebb60
3 files changed
+99
-8
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user