fix(settings): re-check lock removals at their durable commit points

A lock removal that passed the unlocked check before its write is asked
again right after the store write and, for Xtream, after the index
stamps. A relock in between writes the previous store back or rolls the
stamps back before anything is published. The stale-index bookkeeping,
index stamping and store merge move into helpers to keep the lock store
within the file size limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-09-27 13:43:11 +02:00
1 parent 9735ccf69a
commit c4754a7aa4
5 files changed
+223 -84

No files matched your search

@@ -465,6 +465,59 @@ describe('ParentalLockLockStore', () => {
await expect(store.setM3uLocks('pl-1', [])).resolves.toBe(true);
expect(store.lockedGroupTitles('pl-1')).toEqual([]);
});
it('rolls back a removal when the app relocks while its store write is in flight', async () => {
storage.readLocks.mockResolvedValue({
'pl-1': { xtream: [], stalker: [], m3u: ['Adults'] },
});
await store.load();
await store.ensureReadable();
let unlocked = true;
store.setRemovalGate(() => unlocked);
jest.spyOn(console, 'warn').mockImplementation(() => undefined);
let finishWrite: (ok: boolean) => void = () => undefined;
storage.writeLocks.mockImplementationOnce(
() => new Promise<boolean>((resolve) => (finishWrite = resolve))
);
const revision = store.revision();
const removing = store.setM3uLocks('pl-1', []);
await waitFor(() => storage.writeLocks.mock.calls.length > 0);
unlocked = false;
finishWrite(true);
await expect(removing).resolves.toBe(false);
expect(store.lockedGroupTitles('pl-1')).toEqual(['Adults']);
expect(store.revision()).toBe(revision);
// The durable store is put back.
expect(storage.writeLocks).toHaveBeenLastCalledWith({
'pl-1': { xtream: [], stalker: [], m3u: ['Adults'] },
});
});
it('rolls back an Xtream removal when the app relocks during the index stamps', async () => {
await store.load();
await store.ensureReadable();
let unlocked = true;
store.setRemovalGate(() => unlocked);
jest.spyOn(console, 'warn').mockImplementation(() => undefined);
await store.setXtreamLocks('pl-1', 'live', [7, 9]);
setCategoryLocks.mockImplementationOnce(async () => {
unlocked = false;
return true;
});
await expect(store.setXtreamLocks('pl-1', 'live', [7])).resolves.toBe(
false
);
expect(store.lockedXtreamIds('pl-1', 'live')).toEqual([7, 9]);
expect(setCategoryLocks).toHaveBeenLastCalledWith(
'pl-1',
'live',
[7, 9]
);
expect(store.readable()).toBe(true);
});
});
async function waitFor(condition: () => boolean): Promise<void> {
@@ -12,10 +12,13 @@ import {
} from '@iptvnator/shared/interfaces';
import { DatabaseService } from '../database-electron.service';
import { RuntimeCapabilitiesService } from '../runtime-capabilities.service';
import { ParentalLockStaleIndex } from './parental-lock-stale-index';
import { ParentalLockStorageService } from './parental-lock-storage';
import {
isLockRemovalAllowed,
lockRemovalGate,
stampXtreamIndex,
withM3uLocks,
withPlaylistLocksInStore,
withStalkerLocks,
withXtreamLocks,
} from './parental-lock-store.util';
@@ -74,13 +77,7 @@ export class ParentalLockLockStore {
private writeQueue: Promise<unknown> = Promise.resolve();
/** "Remove all playlists" could not clear the persisted store yet. */
private pendingClearAll = false;
/**
* Playlists whose SQLite index could not be brought in line with the
* store (a failed re-stamp whose rollback failed too); re-stamped on
* the next store access.
*/
private readonly staleIndexPlaylists = new Set<string>();
private readonly staleIndexCount = signal(0);
private readonly staleIndex = new ParentalLockStaleIndex();
/** The persisted store could not be read; see `ensureReadable()`. */
readonly unreadable = signal(false);
@@ -96,7 +93,7 @@ export class ParentalLockLockStore {
() =>
this.loadedState() &&
!this.unreadable() &&
this.staleIndexCount() === 0
this.staleIndex.isEmpty()
);
/** Bumps whenever the lock set changes; consumers re-query. */
readonly revision = this.revisionState.asReadonly();
@@ -119,7 +116,7 @@ export class ParentalLockLockStore {
} else {
this.locks.set(locks);
for (const playlistId of Object.keys(locks)) {
this.markIndexStale(playlistId);
this.staleIndex.mark(playlistId);
}
// Awaited: catalog reads issued before the index agrees
// with the store would serve rows stamped unlocked, and
@@ -156,7 +153,7 @@ export class ParentalLockLockStore {
// the read did; re-derive it from the recovered store as load()
// does.
for (const playlistId of Object.keys(locks)) {
this.markIndexStale(playlistId);
this.staleIndex.mark(playlistId);
}
this.revisionState.update((value) => value + 1);
}
@@ -164,27 +161,17 @@ export class ParentalLockLockStore {
return this.readable();
}
private markIndexStale(playlistId: string): void {
this.staleIndexPlaylists.add(playlistId);
this.staleIndexCount.set(this.staleIndexPlaylists.size);
}
private unmarkIndexStale(playlistId: string): void {
this.staleIndexPlaylists.delete(playlistId);
this.staleIndexCount.set(this.staleIndexPlaylists.size);
}
/** Re-stamps every playlist whose index may lag behind the store. */
private async reconcileXtreamIndex(): Promise<void> {
if (!this.runtime.supportsXtreamSqliteDataSource) {
this.staleIndexPlaylists.clear();
this.staleIndexCount.set(0);
this.staleIndex.clear();
return;
}
for (const playlistId of [...this.staleIndexPlaylists]) {
const restamped: string[] = [];
for (const playlistId of this.staleIndex.ids()) {
try {
if (await this.stampXtreamLocks(playlistId)) {
this.staleIndexPlaylists.delete(playlistId);
restamped.push(playlistId);
}
} catch (error) {
console.error(
@@ -193,8 +180,8 @@ export class ParentalLockLockStore {
);
}
}
if (this.staleIndexCount() !== this.staleIndexPlaylists.size) {
this.staleIndexCount.set(this.staleIndexPlaylists.size);
if (restamped.length > 0) {
this.staleIndex.unmark(...restamped);
this.revisionState.update((value) => value + 1);
}
}
@@ -251,9 +238,6 @@ export class ParentalLockLockStore {
this.lockedXtreamIds(playlistId, categoryType)
)
);
if (!isLockRemovalAllowed(previous, next, this.mayRemoveLocks)) {
return false;
}
return this.commitLocks(playlistId, previous, next, [categoryType]);
});
}
@@ -276,10 +260,7 @@ export class ParentalLockLockStore {
this.lockedStalkerIds(playlistId, categoryType)
)
);
return (
isLockRemovalAllowed(previous, next, this.mayRemoveLocks) &&
this.persistPlaylistLocks(playlistId, next)
);
return this.persistEdit(playlistId, previous, next);
});
}
@@ -299,10 +280,7 @@ export class ParentalLockLockStore {
this.lockedGroupTitles(playlistId)
)
);
return (
isLockRemovalAllowed(previous, next, this.mayRemoveLocks) &&
this.persistPlaylistLocks(playlistId, next)
);
return this.persistEdit(playlistId, previous, next);
});
}
@@ -315,13 +293,9 @@ export class ParentalLockLockStore {
if (!(await this.ensureReadable())) {
return false;
}
const previous = this.locksFor(playlistId);
if (!isLockRemovalAllowed(previous, locks, this.mayRemoveLocks)) {
return false;
}
return this.commitLocks(
playlistId,
previous,
this.locksFor(playlistId),
locks,
XTREAM_CATEGORY_TYPES
);
@@ -357,14 +331,26 @@ export class ParentalLockLockStore {
// rewrites the whole store from this empty copy.
this.locks.set({});
this.unreadable.set(false);
this.staleIndexPlaylists.clear();
this.staleIndexCount.set(0);
this.staleIndex.clear();
this.revisionState.update((value) => value + 1);
this.pendingClearAll = !(await this.storage.writeLocks({}));
return !this.pendingClearAll;
});
}
/** Stalker and M3U edits: no index, only the store write. */
private async persistEdit(
playlistId: string,
previous: ParentalLockPlaylistLocks,
next: ParentalLockPlaylistLocks
): Promise<boolean> {
const authorize = lockRemovalGate(previous, next, this.mayRemoveLocks);
return (
authorize() &&
this.persistPlaylistLocks(playlistId, next, { authorize })
);
}
private enqueue<T>(task: () => Promise<T>): Promise<T> {
const run = this.writeQueue.then(task, task);
this.writeQueue = run.catch(() => undefined);
@@ -387,6 +373,10 @@ export class ParentalLockLockStore {
next: ParentalLockPlaylistLocks,
categoryTypes: readonly ParentalLockXtreamCategoryType[]
): Promise<boolean> {
const authorize = lockRemovalGate(previous, next, this.mayRemoveLocks);
if (!authorize()) {
return false;
}
const normalizedNext = normalizeParentalLockPlaylistLocks(next);
if (isParentalLockPlaylistLocksEmpty(normalizedNext)) {
if (!(await this.ensureReadable())) {
@@ -405,9 +395,10 @@ export class ParentalLockLockStore {
)) &&
(await this.persistPlaylistLocks(playlistId, normalizedNext, {
publish: false,
authorize,
}))
) {
this.unmarkIndexStale(playlistId);
this.staleIndex.unmark(playlistId);
this.revisionState.update((value) => value + 1);
return true;
}
@@ -425,13 +416,19 @@ export class ParentalLockLockStore {
if (
!(await this.persistPlaylistLocks(playlistId, next, {
publish: false,
authorize,
}))
) {
return false;
}
this.markStaleWhileStamping(playlistId);
if (await this.stampXtreamLocks(playlistId, categoryTypes)) {
this.unmarkIndexStale(playlistId);
// Asked again once the stamps landed: a relock during them must not
// see the removal published.
if (
(await this.stampXtreamLocks(playlistId, categoryTypes)) &&
authorize()
) {
this.staleIndex.unmark(playlistId);
this.revisionState.update((value) => value + 1);
return true;
}
@@ -464,9 +461,9 @@ export class ParentalLockLockStore {
(await this.stampXtreamLocks(playlistId, categoryTypes));
if (!restored || !restamped) {
console.error('Failed to roll back the parental lock index.');
this.markIndexStale(playlistId);
this.staleIndex.mark(playlistId);
} else {
this.unmarkIndexStale(playlistId);
this.staleIndex.unmark(playlistId);
}
this.revisionState.update((value) => value + 1);
}
@@ -479,7 +476,7 @@ export class ParentalLockLockStore {
*/
private markStaleWhileStamping(playlistId: string): void {
if (this.runtime.supportsXtreamSqliteDataSource) {
this.markIndexStale(playlistId);
this.staleIndex.mark(playlistId);
}
}
@@ -491,9 +488,9 @@ export class ParentalLockLockStore {
): Promise<void> {
if (!(await this.stampXtreamLocks(playlistId, categoryTypes, locks))) {
console.error('Failed to restore the parental lock index.');
this.markIndexStale(playlistId);
this.staleIndex.mark(playlistId);
} else {
this.unmarkIndexStale(playlistId);
this.staleIndex.unmark(playlistId);
}
this.revisionState.update((value) => value + 1);
}
@@ -507,25 +504,22 @@ export class ParentalLockLockStore {
categoryTypes: readonly ParentalLockXtreamCategoryType[] = XTREAM_CATEGORY_TYPES,
locks: ParentalLockPlaylistLocks = this.locksFor(playlistId)
): Promise<boolean> {
if (!this.runtime.supportsXtreamSqliteDataSource) {
return true;
}
let success = true;
for (const categoryType of categoryTypes) {
success =
(await this.databaseService.setCategoryLocks(
playlistId,
categoryType,
lockedXtreamCategoryIds(locks, categoryType)
)) && success;
}
return success;
return (
!this.runtime.supportsXtreamSqliteDataSource ||
stampXtreamIndex(
(id, type, ids) =>
this.databaseService.setCategoryLocks(id, type, ids),
playlistId,
categoryTypes,
locks
)
);
}
private async persistPlaylistLocks(
playlistId: string,
locks: ParentalLockPlaylistLocks,
options: { publish?: boolean } = {}
options: { publish?: boolean; authorize?: () => boolean } = {}
): Promise<boolean> {
// Every public mutation runs `ensureReadable()` before it builds its
// edit. Here only the STORE must be known: the index of the
@@ -534,16 +528,18 @@ export class ParentalLockLockStore {
if (this.unreadable()) {
return false;
}
const normalized = normalizeParentalLockPlaylistLocks(locks);
const next: ParentalLockStore = { ...this.locks() };
if (isParentalLockPlaylistLocksEmpty(normalized)) {
delete next[playlistId];
} else {
next[playlistId] = normalized;
}
const next = withPlaylistLocksInStore(this.locks(), playlistId, locks);
if (!(await this.storage.writeLocks(next))) {
return false;
}
if (options.authorize && !options.authorize()) {
// Relocked while the write was in flight: the durable store goes
// back to the in-memory one, which still holds the lock.
if (!(await this.storage.writeLocks(this.locks()))) {
console.error('Failed to revert a refused parental lock edit.');
}
return false;
}
this.locks.set(next);
if (options.publish !== false) {
this.revisionState.update((value) => value + 1);
@@ -0,0 +1,34 @@
import { computed, signal } from '@angular/core';
/**
* Playlists whose SQLite `categories.locked` index may lag behind the lock
* store: a re-stamp in progress, or one that failed (with its rollback) and
* is retried on the next store access. The lock store is not `readable`
* while any playlist is listed.
*/
export class ParentalLockStaleIndex {
private readonly playlists = new Set<string>();
private readonly count = signal(0);
readonly isEmpty = computed(() => this.count() === 0);
ids(): string[] {
return [...this.playlists];
}
mark(playlistId: string): void {
this.playlists.add(playlistId);
this.count.set(this.playlists.size);
}
unmark(...playlistIds: string[]): void {
for (const playlistId of playlistIds) {
this.playlists.delete(playlistId);
}
this.count.set(this.playlists.size);
}
clear(): void {
this.unmark(...this.playlists);
}
}
@@ -1,6 +1,10 @@
import {
isParentalLockPlaylistLocksEmpty,
lockedXtreamCategoryIds,
normalizeParentalLockPlaylistLocks,
ParentalLockPlaylistLocks,
ParentalLockStalkerCategoryType,
ParentalLockStore,
ParentalLockXtreamCategoryType,
} from '@iptvnator/shared/interfaces';
@@ -79,17 +83,65 @@ export function removesParentalLocks(
}
/**
* Whether an edit from `previous` to `next` may commit: adding locks always
* may, removing one only while `mayRemove()` says so (logged when refused).
* Whether an edit from `previous` to `next` may commit, asked each time the
* returned function is called: adding locks always may, removing one only
* while `mayRemove()` says so (logged when refused). Asked before a write
* and again at its durable commit points, since a relock can land while
* the write is in flight.
*/
export function isLockRemovalAllowed(
export function lockRemovalGate(
previous: ParentalLockPlaylistLocks,
next: ParentalLockPlaylistLocks,
mayRemove: () => boolean
): boolean {
if (!removesParentalLocks(previous, next) || mayRemove()) {
return true;
}
console.warn('A parental lock edit was refused: the app is locked.');
return false;
): () => boolean {
const removes = removesParentalLocks(previous, next);
return () => {
if (!removes || mayRemove()) {
return true;
}
console.warn('A parental lock edit was refused: the app is locked.');
return false;
};
}
/** `store` with one playlist's locks replaced; an empty set drops its key. */
export function withPlaylistLocksInStore(
store: ParentalLockStore,
playlistId: string,
locks: ParentalLockPlaylistLocks
): ParentalLockStore {
const normalized = normalizeParentalLockPlaylistLocks(locks);
const next: ParentalLockStore = { ...store };
if (isParentalLockPlaylistLocksEmpty(normalized)) {
delete next[playlistId];
} else {
next[playlistId] = normalized;
}
return next;
}
/**
* Stamps the SQLite `categories.locked` index of one playlist from `locks`,
* type by type. Every type is attempted; false when any of them failed.
*/
export async function stampXtreamIndex(
setCategoryLocks: (
playlistId: string,
categoryType: ParentalLockXtreamCategoryType,
lockedXtreamIds: number[]
) => Promise<boolean>,
playlistId: string,
categoryTypes: readonly ParentalLockXtreamCategoryType[],
locks: ParentalLockPlaylistLocks
): Promise<boolean> {
let success = true;
for (const categoryType of categoryTypes) {
success =
(await setCategoryLocks(
playlistId,
categoryType,
lockedXtreamCategoryIds(locks, categoryType)
)) && success;
}
return success;
}