From 73ccdebb60bd192dc05c3872051800547e54968b Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 27 Sep 2026 09:52:08 +0200 Subject: [PATCH] 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 --- docs/architecture/parental-lock.md | 27 +++++++++++ .../parental-lock-lock-store.service.spec.ts | 46 +++++++++++++++++++ .../parental-lock-lock-store.service.ts | 34 ++++++++++---- 3 files changed, 99 insertions(+), 8 deletions(-) diff --git a/docs/architecture/parental-lock.md b/docs/architecture/parental-lock.md index a3067179e..008f9dfc0 100644 --- a/docs/architecture/parental-lock.md +++ b/docs/architecture/parental-lock.md @@ -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 diff --git a/libs/services/src/lib/parental-lock/parental-lock-lock-store.service.spec.ts b/libs/services/src/lib/parental-lock/parental-lock-lock-store.service.spec.ts index e0270845d..47af0b5ca 100644 --- a/libs/services/src/lib/parental-lock/parental-lock-lock-store.service.spec.ts +++ b/libs/services/src/lib/parental-lock/parental-lock-lock-store.service.spec.ts @@ -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(); diff --git a/libs/services/src/lib/parental-lock/parental-lock-lock-store.service.ts b/libs/services/src/lib/parental-lock/parental-lock-lock-store.service.ts index 0ceb32841..ca37a71ac 100644 --- a/libs/services/src/lib/parental-lock/parental-lock-lock-store.service.ts +++ b/libs/services/src/lib/parental-lock/parental-lock-lock-store.service.ts @@ -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 { - 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 { + 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); } }