Merge origin/master into claude/parental-control-feature-31dde2

Resolves the settings-store defaults split, the electron-conf key list and
the guidance reorganization (CLAUDE.md now imports AGENTS.md; the parental
lock contract is linked from the agent context map instead).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-09-25 22:12:58 +02:00
commit ae5d3f49de
732 files changed
+42572 -15380

No files matched your search

@@ -139,8 +139,16 @@ describe('runMigrations error tolerance', () => {
});
});
describe('TMDB search lookup v2 cache cleanup', () => {
it('deletes legacy search rows and records the migration atomically', () => {
describe('TMDB search lookup cache cleanup', () => {
function deleteStatements(sqlite: SqliteHandle): string[] {
return (sqlite.prepare as jest.Mock).mock.calls
.map(([statement]) => compactSql(statement))
.filter((statement) =>
statement.includes('DELETE FROM tmdb_metadata')
);
}
it('deletes every retired key generation and records each migration atomically', () => {
const deleteRun = jest.fn();
const markerRun = jest.fn();
const { sqlite, transaction } = createSqliteMock([
@@ -151,22 +159,56 @@ describe('TMDB search lookup v2 cache cleanup', () => {
cleanupLegacyTmdbSearchCache(sqlite);
expect(transaction).toHaveBeenCalledTimes(1);
expect(deleteRun).toHaveBeenCalledTimes(1);
expect(markerRun).toHaveBeenCalledWith(
'migration:tmdb-search-lookup-v2-cache-cleanup:v1'
);
const deleteSql = compactSql(
(sqlite.prepare as jest.Mock).mock.calls.find(([statement]) =>
statement.includes('DELETE FROM tmdb_metadata')
)?.[0]
);
expect(deleteSql).toContain(
expect(transaction).toHaveBeenCalledTimes(3);
expect(deleteRun).toHaveBeenCalledTimes(3);
expect(markerRun.mock.calls.map(([key]) => key)).toEqual([
'migration:tmdb-search-lookup-v2-cache-cleanup:v1',
'migration:tmdb-search-lookup-v3-cache-cleanup:v1',
'migration:tmdb-search-lookup-v4-cache-cleanup:v1',
]);
const [unversionedDelete, v2Delete, v3Delete] =
deleteStatements(sqlite);
expect(unversionedDelete).toContain(
"lookup_key LIKE 'title:%|year:%' AND lookup_key NOT LIKE 'title:%|year:%|v%'"
);
// Each generation deletes only its own rows: the v4 rows the resolver
// writes now, and the `id:`/`person:`/`badProviderId:` rows, survive.
expect(v2Delete).toContain("WHERE lookup_key LIKE 'title:%|year:%|v2'");
expect(v2Delete).not.toContain('v3');
expect(v3Delete).toContain("WHERE lookup_key LIKE 'title:%|year:%|v3'");
expect(v3Delete).not.toContain('v4');
});
it('does nothing after the migration has completed', () => {
it('runs only the generations that have not completed yet', () => {
const markerRun = jest.fn();
const { sqlite, transaction } = createSqliteMock([
[
'SELECT value FROM app_state',
{
get: (key: unknown) =>
key ===
'migration:tmdb-search-lookup-v2-cache-cleanup:v1'
? { value: 'done' }
: undefined,
},
],
['INSERT INTO app_state', { run: markerRun }],
]);
cleanupLegacyTmdbSearchCache(sqlite);
expect(transaction).toHaveBeenCalledTimes(2);
expect(markerRun.mock.calls.map(([key]) => key)).toEqual([
'migration:tmdb-search-lookup-v3-cache-cleanup:v1',
'migration:tmdb-search-lookup-v4-cache-cleanup:v1',
]);
expect(deleteStatements(sqlite)).toEqual([
expect.stringContaining("lookup_key LIKE 'title:%|year:%|v2'"),
expect.stringContaining("lookup_key LIKE 'title:%|year:%|v3'"),
]);
});
it('does nothing after every migration has completed', () => {
const { sqlite, prepare, transaction } = createSqliteMock([
completedMigrationStateRule,
]);
@@ -174,7 +216,8 @@ describe('TMDB search lookup v2 cache cleanup', () => {
cleanupLegacyTmdbSearchCache(sqlite);
expect(transaction).not.toHaveBeenCalled();
expect(prepare).toHaveBeenCalledTimes(1);
// One marker read per retired generation, nothing else
expect(prepare).toHaveBeenCalledTimes(3);
});
});
+84 -40
View File
@@ -43,6 +43,10 @@ const EPG_PROGRAM_SOURCE_URL_BACKFILL_MIGRATION_KEY =
'migration:epg-program-source-url-backfill:v1';
const TMDB_SEARCH_LOOKUP_V2_CACHE_CLEANUP_MIGRATION_KEY =
'migration:tmdb-search-lookup-v2-cache-cleanup:v1';
const TMDB_SEARCH_LOOKUP_V3_CACHE_CLEANUP_MIGRATION_KEY =
'migration:tmdb-search-lookup-v3-cache-cleanup:v1';
const TMDB_SEARCH_LOOKUP_V4_CACHE_CLEANUP_MIGRATION_KEY =
'migration:tmdb-search-lookup-v4-cache-cleanup:v1';
const EPG_PROGRAM_SOURCE_URL_BACKFILL_BATCH_SIZE = 50_000;
function readTraceFlag(name: string): boolean {
@@ -966,50 +970,90 @@ function widenTmdbMetadataMediaTypeCheck(sqliteDb: Database.Database): void {
}
/**
* Search-match cache keys gained a v2 suffix when title normalization changed.
* Remove the now-unreachable unversioned rows once rather than leaving negative
* resolutions and other legacy search matches in long-lived installations.
* Every search-match cache key generation that has been retired, oldest
* first, each with the predicate selecting exactly the rows written under
* it. A retired generation's rows are unreachable — the resolver only ever
* reads the current key — so they would otherwise sit in long-lived
* installations forever, negative resolutions included.
*
* - unversioned → v2: title normalization learned to strip appended
* language/quality tags.
* - v2 → v3: the search query stopped being the folded comparison key. Under
* v2 every title with a Cyrillic "й"/"ё" was searched folded — the fold
* spells them "и" and "е", as in the illustrative "леика" for "Лейка" —
* got no answer, and was cached as missing for 7 days.
* - v3 → v4: year evidence became tiered. Under v3 a series admitted only by
* the "premiered earlier" tolerance competed with an exact-year match on
* popularity alone, so a new series resolved to its older, better-known
* namesake — and that positive row stays fresh for 30 days.
*/
const LEGACY_TMDB_SEARCH_CACHE_CLEANUPS: ReadonlyArray<{
migrationKey: string;
rowPredicate: string;
}> = [
{
migrationKey: TMDB_SEARCH_LOOKUP_V2_CACHE_CLEANUP_MIGRATION_KEY,
rowPredicate: `lookup_key LIKE 'title:%|year:%'
AND lookup_key NOT LIKE 'title:%|year:%|v%'`,
},
{
migrationKey: TMDB_SEARCH_LOOKUP_V3_CACHE_CLEANUP_MIGRATION_KEY,
rowPredicate: `lookup_key LIKE 'title:%|year:%|v2'`,
},
{
migrationKey: TMDB_SEARCH_LOOKUP_V4_CACHE_CLEANUP_MIGRATION_KEY,
rowPredicate: `lookup_key LIKE 'title:%|year:%|v3'`,
},
];
/**
* Remove search-match rows written under a retired key generation, once per
* generation, recorded in `app_state`. Each generation is its own marker so
* an installation that skipped a release still runs every cleanup it missed,
* in order.
*/
function cleanupLegacyTmdbSearchCache(sqliteDb: Database.Database): void {
try {
const migrationState = sqliteDb
.prepare(`SELECT value FROM app_state WHERE key = ?`)
.get(TMDB_SEARCH_LOOKUP_V2_CACHE_CLEANUP_MIGRATION_KEY) as
{ value?: unknown } | undefined;
for (const cleanup of LEGACY_TMDB_SEARCH_CACHE_CLEANUPS) {
try {
const migrationState = sqliteDb
.prepare(`SELECT value FROM app_state WHERE key = ?`)
.get(cleanup.migrationKey) as { value?: unknown } | undefined;
if (migrationState?.value === 'done') {
return;
if (migrationState?.value === 'done') {
continue;
}
const executeCleanup = sqliteDb.transaction(() => {
sqliteDb
.prepare(
`DELETE FROM tmdb_metadata
WHERE ${cleanup.rowPredicate}`
)
.run();
sqliteDb
.prepare(
`INSERT INTO app_state (key, value, updated_at)
VALUES (?, 'done', datetime('now'))
ON CONFLICT(key) DO UPDATE SET
value = excluded.value,
updated_at = excluded.updated_at`
)
.run(cleanup.migrationKey);
});
executeCleanup();
} catch (error) {
const message =
typeof error === 'object' &&
error !== null &&
'message' in error
? String((error as { message?: unknown }).message ?? error)
: String(error);
console.warn(
`Legacy TMDB search cache cleanup failed (continuing): ${message}`
);
}
const executeCleanup = sqliteDb.transaction(() => {
sqliteDb
.prepare(
`DELETE FROM tmdb_metadata
WHERE lookup_key LIKE 'title:%|year:%'
AND lookup_key NOT LIKE 'title:%|year:%|v%'`
)
.run();
sqliteDb
.prepare(
`INSERT INTO app_state (key, value, updated_at)
VALUES (?, 'done', datetime('now'))
ON CONFLICT(key) DO UPDATE SET
value = excluded.value,
updated_at = excluded.updated_at`
)
.run(TMDB_SEARCH_LOOKUP_V2_CACHE_CLEANUP_MIGRATION_KEY);
});
executeCleanup();
} catch (error) {
const message =
typeof error === 'object' && error !== null && 'message' in error
? String((error as { message?: unknown }).message ?? error)
: String(error);
console.warn(
`Legacy TMDB search cache cleanup failed (continuing): ${message}`
);
}
}
@@ -0,0 +1,174 @@
import { execFileSync } from 'node:child_process';
import { createRequire } from 'node:module';
import { resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
/**
* Real-SQLite coverage for the retired search-key cleanups, run inside
* Electron so better-sqlite3 links against the ABI the app ships with. The
* mock-based spec proves the SQL shape; this one proves what a persisted
* database looks like after an upgrade — from a release that skipped every
* cleanup, from the previous release, on a fresh database, and on the next
* startup after that.
*/
it('drops only retired search rows across skipped, previous, pre-person, fresh and repeated startups', () => {
const electron = createRequire(__filename)('electron') as string;
const connectionUrl = pathToFileURL(
resolve(__dirname, 'connection.ts')
).href;
const result = execFileSync(
electron,
[
'--import',
'tsx',
'--eval',
`
const { default: Database } = await import('better-sqlite3');
const { __databaseConnectionTestHooks: hooks } = await import(${JSON.stringify(connectionUrl)});
console.log = () => undefined;
const V2_MARKER = 'migration:tmdb-search-lookup-v2-cache-cleanup:v1';
const V3_MARKER = 'migration:tmdb-search-lookup-v3-cache-cleanup:v1';
const V4_MARKER = 'migration:tmdb-search-lookup-v4-cache-cleanup:v1';
const ROWS = [
['tv', 'title:леика|year:2026', 'ru-RU', null],
['tv', 'title:леика|year:2026|v2', 'ru-RU', null],
['tv', 'title:the boys|year:2019|v2', 'en-US', 76479],
['tv', 'title:лейка|year:2026|v3', 'ru-RU', 101101],
['tv', 'title:nightfall|year:2026|v4', 'ru-RU', 424242],
['tv', 'id:101101|v2', 'ru-RU', 101101],
['tv', 'id:101101|season:1', 'ru-RU', 101101],
['person', 'person:287', 'en-US', 287],
['movie', 'badProviderId:999', 'any', null],
['movie', 'trending:week', 'en-US', null],
];
function openDb(markers) {
const db = new Database(':memory:');
hooks.createTables(db);
const insert = db.prepare('INSERT INTO tmdb_metadata (media_type, lookup_key, language, tmdb_id, payload) VALUES (?, ?, ?, ?, ?)');
for (const [type, key, lang, id] of ROWS) insert.run(type, key, lang, id, id === null ? null : '{"id":' + id + '}');
const mark = db.prepare("INSERT INTO app_state (key, value, updated_at) VALUES (?, 'done', datetime('now'))");
for (const marker of markers) mark.run(marker);
return db;
}
function snapshot(db) {
return {
keys: db.prepare('SELECT lookup_key FROM tmdb_metadata ORDER BY lookup_key').all().map((r) => r.lookup_key),
payloads: db.prepare("SELECT payload FROM tmdb_metadata WHERE lookup_key LIKE 'id:%' ORDER BY lookup_key").all().map((r) => r.payload),
markers: db.prepare("SELECT key FROM app_state WHERE key LIKE 'migration:tmdb-search-lookup-%' AND value = 'done' ORDER BY key").all().map((r) => r.key),
};
}
// Skipped every release since the unversioned keys: both cleanups run
// through the real initialization path
const skipped = openDb([]);
hooks.runMigrations(skipped);
const skippedAfter = snapshot(skipped);
// Next startup: a row written meanwhile under the current key survives
skipped.prepare("INSERT INTO tmdb_metadata (media_type, lookup_key, language, tmdb_id) VALUES ('tv', 'title:сосенка|year:2026|v4', 'ru-RU', 101103)").run();
hooks.runMigrations(skipped);
const repeated = snapshot(skipped);
// Previous release: the earlier cleanups already ran; only v3 rows go
const previous = openDb([V2_MARKER, V3_MARKER]);
hooks.runMigrations(previous);
const previousAfter = snapshot(previous);
hooks.runMigrations(previous);
const previousRepeated = snapshot(previous);
// Oldest historical schema: the pre-'person' CHECK. That migration
// rebuilds the pure-cache table empty by design; the cleanups must
// still record their markers on the rebuilt table and stay idempotent.
const prePerson = new Database(':memory:');
hooks.createTables(prePerson);
prePerson.exec("DROP TABLE tmdb_metadata; CREATE TABLE tmdb_metadata (id INTEGER PRIMARY KEY AUTOINCREMENT, media_type TEXT NOT NULL CHECK (media_type IN ('movie', 'tv')), lookup_key TEXT NOT NULL, language TEXT NOT NULL, tmdb_id INTEGER, payload TEXT, fetched_at TEXT DEFAULT (datetime('now'))); CREATE UNIQUE INDEX tmdb_metadata_lookup_unique ON tmdb_metadata(media_type, lookup_key, language)");
prePerson.prepare("INSERT INTO tmdb_metadata (media_type, lookup_key, language, tmdb_id) VALUES ('tv', 'title:леика|year:2026', 'ru-RU', NULL)").run();
hooks.runMigrations(prePerson);
const prePersonAfter = { ...snapshot(prePerson), check: prePerson.prepare("SELECT sql FROM sqlite_master WHERE name = 'tmdb_metadata'").get().sql.includes("'person'") };
hooks.runMigrations(prePerson);
const prePersonRepeated = snapshot(prePerson);
// Fresh database through the real initialization path
const fresh = new Database(':memory:');
hooks.createTables(fresh);
hooks.runMigrations(fresh);
const freshAfter = snapshot(fresh);
hooks.runMigrations(fresh);
const freshRepeated = snapshot(fresh);
process.stdout.write(JSON.stringify({ skippedAfter, repeated, previousAfter, previousRepeated, prePersonAfter, prePersonRepeated, freshAfter, freshRepeated }));
`,
],
{
cwd: process.cwd(),
encoding: 'utf8',
env: {
...process.env,
ELECTRON_RUN_AS_NODE: '1',
TSX_TSCONFIG_PATH: resolve(process.cwd(), 'tsconfig.base.json'),
},
}
);
const V2_MARKER = 'migration:tmdb-search-lookup-v2-cache-cleanup:v1';
const V3_MARKER = 'migration:tmdb-search-lookup-v3-cache-cleanup:v1';
const V4_MARKER = 'migration:tmdb-search-lookup-v4-cache-cleanup:v1';
const survivors = [
'badProviderId:999',
'id:101101|season:1',
'id:101101|v2',
'person:287',
'title:nightfall|year:2026|v4',
'trending:week',
];
const detailsPayloads = ['{"id":101101}', '{"id":101101}'];
expect(JSON.parse(result)).toEqual({
skippedAfter: {
keys: survivors,
payloads: detailsPayloads,
markers: [V2_MARKER, V3_MARKER, V4_MARKER],
},
repeated: {
keys: [...survivors, 'title:сосенка|year:2026|v4'].sort(),
payloads: detailsPayloads,
markers: [V2_MARKER, V3_MARKER, V4_MARKER],
},
previousAfter: {
// The older generations' rows are their business, already done
keys: [
...survivors,
'title:леика|year:2026',
'title:леика|year:2026|v2',
'title:the boys|year:2019|v2',
].sort(),
payloads: detailsPayloads,
markers: [V2_MARKER, V3_MARKER, V4_MARKER],
},
previousRepeated: {
keys: [
...survivors,
'title:леика|year:2026',
'title:леика|year:2026|v2',
'title:the boys|year:2019|v2',
].sort(),
payloads: detailsPayloads,
markers: [V2_MARKER, V3_MARKER, V4_MARKER],
},
prePersonAfter: {
keys: [],
payloads: [],
markers: [V2_MARKER, V3_MARKER, V4_MARKER],
check: true,
},
prePersonRepeated: {
keys: [],
payloads: [],
markers: [V2_MARKER, V3_MARKER, V4_MARKER],
},
freshAfter: {
keys: [],
payloads: [],
markers: [V2_MARKER, V3_MARKER, V4_MARKER],
},
freshRepeated: {
keys: [],
payloads: [],
markers: [V2_MARKER, V3_MARKER, V4_MARKER],
},
});
});