fix(stats): invalidate reading caches after committed changes

Refresh dashboard and statistics caches immediately after reading history,
projection, reset, import, and shared deletion changes. Coalesce bulk
notifications while keeping committed changes visible to concurrent reads,
and isolate listener failures from successful writes.

Skip unchanged Hardcover attempt updates and protect cache generations
against stale in-flight loads. Cover date edits, projection-only resumes,
rollback, bulk failures, user scope, and cache races with regression tests.

Ref #1695
This commit is contained in:
neon
2026-10-07 01:28:10 +00:00
parent 58756565fc
commit e6ea5bd77e
37 changed files with 1628 additions and 311 deletions
@@ -20,6 +20,7 @@ import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
import { BookService } from '../book/book.service';
import type { BookCoverSlotRow } from '../book-cover-store/book-cover-store.repository';
import { BookCoverStore } from '../book-cover-store/book-cover-store.service';
import { ReadingAttemptEventsService } from '../user-book-status/reading-attempt-events.service';
import { LibraryService } from '../library/library.service';
import { CoverSweepStore, type SweepRecord } from './cover-sweep.store';
import {
@@ -49,6 +50,7 @@ export class MissingResourcesService {
private readonly bookService: BookService,
config: ConfigService,
private readonly coverStore: BookCoverStore,
private readonly readingAttemptEvents: ReadingAttemptEventsService,
) {
this.coversRoot = join(config.get<string>('storage.appDataPath')!, 'covers');
}
@@ -140,11 +142,13 @@ export class MissingResourcesService {
this.logger.log(`[${event}] [start] userId=${user.id} requested=${requestedIds.length} targets=${targets.length} - missing book cleanup started`);
try {
let cleaned = 0;
for (let index = 0; index < targets.length; index += DELETE_BATCH_SIZE) {
const batch = targets.slice(index, index + DELETE_BATCH_SIZE);
const result = await this.bookService.deleteBooks(batch, user);
cleaned += result.total;
}
await this.readingAttemptEvents.coalesceChanges(async () => {
for (let index = 0; index < targets.length; index += DELETE_BATCH_SIZE) {
const batch = targets.slice(index, index + DELETE_BATCH_SIZE);
const result = await this.bookService.deleteBooks(batch, user);
cleaned += result.total;
}
});
const remaining = await this.repo.countMissingBooks(libraryIds);
this.logger.log(
`[${event}] [end] userId=${user.id} requested=${requestedIds.length} cleaned=${cleaned} remaining=${remaining} durationMs=${Date.now() - startedAt} - missing book cleanup completed`,