diff --git a/apps/electron-backend/src/app/database/operations/playlist-server-timezone.operations.ts b/apps/electron-backend/src/app/database/operations/playlist-server-timezone.operations.ts new file mode 100644 index 000000000..7d4f3cc02 --- /dev/null +++ b/apps/electron-backend/src/app/database/operations/playlist-server-timezone.operations.ts @@ -0,0 +1,155 @@ +/** + * The panel clock an Xtream account-info check learns (`serverTimezone`) + * lives in the playlist row's JSON payload — it has no column — and every + * write that touches it must stay atomic against the row's CURRENT + * connection, because the database worker interleaves requests and the + * Xtream edit dialog saves through `DB_UPDATE_PLAYLIST` outside + * `PlaylistsService`'s queue (issue #1562). This module owns the three SQL + * shapes and the one projection the playlist operations compose: + * + * - `setPlaylistServerTimezone`: record a learned clock, conditionally. + * - `serverTimezoneInvalidation`: drop it when an update moves the source. + * - `playlistConflictUpdate`: keep it across a clockless full upsert. + * - `readPayloadServerTimezone`: surface it on a raw row read. + */ + +import { sql, type SQL } from 'drizzle-orm'; +import * as schema from '@iptvnator/shared/database/schema'; +import type { AppDatabase } from '../database.types'; + +export interface PlaylistConnectionIdentity { + serverUrl: string; + username: string; + password: string; +} + +export type PlaylistConflictUpdate = { + [K in keyof schema.NewPlaylist]?: schema.NewPlaylist[K] | SQL; +}; + +const stored = schema.playlists; + +/** + * Records the panel clock a successful account-info check learned as ONE + * conditional UPDATE: the row must still point at the panel the answer + * came from (`DB_UPDATE_PLAYLIST` may have moved it meanwhile), a payload + * already carrying the value is left untouched, and a malformed payload is + * never rewritten. No read precedes the write, so it can neither hand a + * concurrent upsert's newer payload back to the past nor undo an edit that + * landed in between. + */ +export async function setPlaylistServerTimezone( + db: AppDatabase, + playlistId: string, + connection: PlaylistConnectionIdentity, + serverTimezone: string +): Promise<{ updated: boolean }> { + const result = await db + .update(stored) + .set({ + payload: sql`CASE + WHEN ${stored.payload} IS NULL + THEN json_object('serverTimezone', ${serverTimezone}) + ELSE json_set(${stored.payload}, '$.serverTimezone', ${serverTimezone}) + END`, + }) + .where( + // CASE, not AND: SQLite may reorder AND terms, and json_extract + // raises on a malformed payload unless json_valid ran first. + sql`${stored.id} = ${playlistId} + AND ${stored.serverUrl} IS ${connection.serverUrl} + AND ${stored.username} IS ${connection.username} + AND ${stored.password} IS ${connection.password} + AND CASE + WHEN ${stored.payload} IS NULL THEN 1 + WHEN json_valid(${stored.payload}) + THEN json_extract(${stored.payload}, '$.serverTimezone') IS NOT ${serverTimezone} + ELSE 0 + END` + ) + .run(); + + return { updated: result.changes > 0 }; +} + +/** + * The payload expression for a `DB_UPDATE_PLAYLIST` that sets `serverUrl`: + * a learned clock belongs to the panel it was learned from, so pointing the + * row at another server drops it until the next account-info check, and + * Favorites / Recent catch-up cannot keep rendering the OLD panel's clock. + */ +export function serverTimezoneInvalidation(nextServerUrl: string): SQL { + return sql`CASE + WHEN ${stored.serverUrl} IS NOT ${nextServerUrl} + AND json_valid(${stored.payload}) + THEN json_remove(${stored.payload}, '$.serverTimezone') + ELSE ${stored.payload} + END`; +} + +/** + * The `DO UPDATE` half of an app-playlist upsert. A full upsert is built + * from a snapshot the caller read earlier; when that snapshot carries no + * panel clock while the stored row does — `setPlaylistServerTimezone` + * landed in between — the stored clock is carried over as long as the row + * still points at the same panel, so a favorites, recent-items or metadata + * write cannot hand a clockless payload back to the past. A snapshot that + * carries its own clock, or moves the source, wins as is. Nested CASE, not + * AND: SQLite may reorder AND terms, and the json_* readers raise on a + * malformed payload unless json_valid ran first. + */ +export function playlistConflictUpdate( + row: schema.NewPlaylist, + playlist: Record +): PlaylistConflictUpdate { + if (readString(playlist.serverTimezone)) { + return row; + } + const excludedPayload = sql.raw(`excluded.${stored.payload.name}`); + const payload = sql`CASE + WHEN json_valid(${stored.payload}) + THEN CASE + WHEN json_type(${stored.payload}, '$.serverTimezone') = 'text' + AND ${stored.serverUrl} IS excluded.${sql.raw(stored.serverUrl.name)} + AND ${stored.username} IS excluded.${sql.raw(stored.username.name)} + AND ${stored.password} IS excluded.${sql.raw(stored.password.name)} + THEN json_set( + ${excludedPayload}, + '$.serverTimezone', + json_extract(${stored.payload}, '$.serverTimezone') + ) + ELSE ${excludedPayload} + END + ELSE ${excludedPayload} + END`; + return { ...row, payload }; +} + +/** + * The clock stored in a raw row's payload, for `DB_GET_PLAYLIST`: the + * Xtream store seeds `currentPlaylist` from that read before (or without) + * the account-info check that learns the value. + */ +export function readPayloadServerTimezone( + payload: string | null | undefined +): string | undefined { + if (!payload) { + return undefined; + } + try { + const parsed: unknown = JSON.parse(payload); + return parsed && typeof parsed === 'object' + ? readString((parsed as Record).serverTimezone) + : undefined; + } catch { + return undefined; + } +} + +function readString(value: unknown): string | undefined { + if (typeof value !== 'string') { + return undefined; + } + const normalized = value.trim(); + return normalized.length > 0 ? normalized : undefined; +} diff --git a/apps/electron-backend/src/app/database/operations/playlist.operations.spec.ts b/apps/electron-backend/src/app/database/operations/playlist.operations.spec.ts index 599195f20..3370d633e 100644 --- a/apps/electron-backend/src/app/database/operations/playlist.operations.spec.ts +++ b/apps/electron-backend/src/app/database/operations/playlist.operations.spec.ts @@ -36,6 +36,9 @@ function runPlaylistUpdateScenario(scenario: string): unknown { const operationsUrl = pathToFileURL( resolve(__dirname, 'playlist.operations.ts') ).href; + const timezoneOperationsUrl = pathToFileURL( + resolve(__dirname, 'playlist-server-timezone.operations.ts') + ).href; const connectionUrl = pathToFileURL( resolve(process.cwd(), 'libs/shared/database/src/lib/connection.ts') ).href; @@ -43,7 +46,8 @@ function runPlaylistUpdateScenario(scenario: string): unknown { const { default: Database } = await import('better-sqlite3'); const { drizzle } = await import('drizzle-orm/better-sqlite3'); const schema = await import('@iptvnator/shared/database/schema'); - const { updatePlaylist, setPlaylistServerTimezone, upsertAppPlaylist, upsertAppPlaylists } = await import(${JSON.stringify(operationsUrl)}); + const { updatePlaylist, upsertAppPlaylist, upsertAppPlaylists } = await import(${JSON.stringify(operationsUrl)}); + const { setPlaylistServerTimezone } = await import(${JSON.stringify(timezoneOperationsUrl)}); const { __databaseConnectionTestHooks } = await import(${JSON.stringify(connectionUrl)}); const sqlite = new Database(':memory:'); const statements = [ diff --git a/apps/electron-backend/src/app/database/operations/playlist.operations.ts b/apps/electron-backend/src/app/database/operations/playlist.operations.ts index 037660327..96e9b1bc0 100644 --- a/apps/electron-backend/src/app/database/operations/playlist.operations.ts +++ b/apps/electron-backend/src/app/database/operations/playlist.operations.ts @@ -1,4 +1,4 @@ -import { eq, sql, type SQL } from 'drizzle-orm'; +import { eq, sql } from 'drizzle-orm'; import * as schema from '@iptvnator/shared/database/schema'; import type { Channel, M3uFavoriteChannel } from '@iptvnator/shared/interfaces'; import { @@ -23,6 +23,11 @@ import { reportOperationProgress, } from './operation-control'; import type { DatabaseOperationPerformancePhaseCapture } from './performance-phase-capture'; +import { + playlistConflictUpdate, + readPayloadServerTimezone, + serverTimezoneInvalidation, +} from './playlist-server-timezone.operations'; const PLAYLIST_TYPES = { XTREAM: 'xtream', @@ -235,48 +240,6 @@ export function buildPlaylistRow( }; } -/** - * The `DO UPDATE` half of an app-playlist upsert. A full upsert is built - * from a snapshot the caller read earlier; when that snapshot carries no - * panel clock while the stored row does — `setPlaylistServerTimezone` - * landed in between — the stored clock is carried over as long as the row - * still points at the same panel, so a favorites, recent-items or metadata - * write cannot hand a clockless payload back to the past (issue #1562). - * A snapshot that carries its own clock, or moves the source, wins as is. - * Nested CASE, not AND: SQLite may reorder AND terms, and the json_* - * readers raise on a malformed payload unless json_valid ran first. - */ -type PlaylistConflictUpdate = { - [K in keyof schema.NewPlaylist]?: schema.NewPlaylist[K] | SQL; -}; - -function playlistConflictUpdate( - row: schema.NewPlaylist, - playlist: Record -): PlaylistConflictUpdate { - if (getStringValue(playlist.serverTimezone)) { - return row; - } - const stored = schema.playlists; - const payload = sql`CASE - WHEN json_valid(${stored.payload}) - THEN CASE - WHEN json_type(${stored.payload}, '$.serverTimezone') = 'text' - AND ${stored.serverUrl} IS excluded.${sql.raw(stored.serverUrl.name)} - AND ${stored.username} IS excluded.${sql.raw(stored.username.name)} - AND ${stored.password} IS excluded.${sql.raw(stored.password.name)} - THEN json_set( - excluded.${sql.raw(stored.payload.name)}, - '$.serverTimezone', - json_extract(${stored.payload}, '$.serverTimezone') - ) - ELSE excluded.${sql.raw(stored.payload.name)} - END - ELSE excluded.${sql.raw(stored.payload.name)} - END`; - return { ...row, payload }; -} - function getPlaylistItemCount( playlist: Record ): number | undefined { @@ -632,10 +595,7 @@ export async function getPlaylist(db: AppDatabase, playlistId: string) { return null; } - const serverTimezone = getStringValue( - parseJsonValue | null>(row.payload, null) - ?.serverTimezone - ); + const serverTimezone = readPayloadServerTimezone(row.payload); return serverTimezone ? { ...row, serverTimezone } : row; } @@ -663,73 +623,6 @@ export async function updatePlaylist( return { success: true }; } -/** - * The persisted panel clock (`serverTimezone`, payload-only) belongs to the - * panel it was learned from: pointing the row at another server drops it, - * so Favorites / Recent catch-up cannot keep rendering the OLD panel's - * clock until the next account-info check (issue #1562). The removal is - * one SQL expression inside the same UPDATE — the worker interleaves - * requests, so a read-modify-write of the payload could hand a concurrent - * upsert's newer payload back to the past. - */ -function serverTimezoneInvalidation(nextServerUrl: string) { - return sql`CASE - WHEN ${schema.playlists.serverUrl} IS NOT ${nextServerUrl} - AND json_valid(${schema.playlists.payload}) - THEN json_remove(${schema.playlists.payload}, '$.serverTimezone') - ELSE ${schema.playlists.payload} - END`; -} - -export interface PlaylistConnectionIdentity { - serverUrl: string; - username: string; - password: string; -} - -/** - * Records the panel clock a successful account-info check learned - * (`serverTimezone`, payload-only) as ONE conditional UPDATE: the row must - * still point at the panel the answer came from (`DB_UPDATE_PLAYLIST` may - * have moved it meanwhile), a payload already carrying the value is left - * untouched, and a malformed payload is never rewritten. No read precedes - * the write, so it can neither hand a concurrent upsert's newer payload - * back to the past nor undo an edit that landed in between (issue #1562). - */ -export async function setPlaylistServerTimezone( - db: AppDatabase, - playlistId: string, - connection: PlaylistConnectionIdentity, - serverTimezone: string -): Promise<{ updated: boolean }> { - const result = await db - .update(schema.playlists) - .set({ - payload: sql`CASE - WHEN ${schema.playlists.payload} IS NULL - THEN json_object('serverTimezone', ${serverTimezone}) - ELSE json_set(${schema.playlists.payload}, '$.serverTimezone', ${serverTimezone}) - END`, - }) - .where( - // CASE, not AND: SQLite may reorder AND terms, and json_extract - // raises on a malformed payload unless json_valid ran first. - sql`${schema.playlists.id} = ${playlistId} - AND ${schema.playlists.serverUrl} IS ${connection.serverUrl} - AND ${schema.playlists.username} IS ${connection.username} - AND ${schema.playlists.password} IS ${connection.password} - AND CASE - WHEN ${schema.playlists.payload} IS NULL THEN 1 - WHEN json_valid(${schema.playlists.payload}) - THEN json_extract(${schema.playlists.payload}, '$.serverTimezone') IS NOT ${serverTimezone} - ELSE 0 - END` - ) - .run(); - - return { updated: result.changes > 0 }; -} - interface PlaylistDeletionCollection { readonly categoryIds: number[]; /** Content rows per category, the unit the delete is batched by. */ diff --git a/apps/electron-backend/src/app/workers/database.worker.ts b/apps/electron-backend/src/app/workers/database.worker.ts index 2e68b6d5a..042c11744 100644 --- a/apps/electron-backend/src/app/workers/database.worker.ts +++ b/apps/electron-backend/src/app/workers/database.worker.ts @@ -69,13 +69,13 @@ import { getAppState, getPlaylist, setAppState, - setPlaylistServerTimezone, type AppPlaylistGetPhaseCapture, type AppPlaylistUpsertPhaseCapture, updatePlaylist, upsertAppPlaylist, upsertAppPlaylists, } from '../database/operations/playlist.operations'; +import { setPlaylistServerTimezone } from '../database/operations/playlist-server-timezone.operations'; import { addRecentItem, clearPlaylistRecentItems,