refactor(electron): move the panel-clock SQL into its own operations module

Review follow-up (Greptile): the timezone persistence, invalidation,
upsert-preservation and row projection had landed in
`playlist.operations.ts`, a baselined 1,000-line file. They now live in
`playlist-server-timezone.operations.ts` (155 lines) — the three SQL
shapes plus the payload projection — and the playlist operations compose
them; the baselined file shrinks by 107 lines. Behaviour and the
real-SQLite coverage are unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-09-07 09:20:39 +02:00
1 parent 272e26c47c
commit 76f75971e8
4 files changed
+168 -116

No files matched your search

@@ -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<string, unknown>
): 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<string, unknown>).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;
}
@@ -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 = [
@@ -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<string, unknown>
): 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<string, unknown>
): number | undefined {
@@ -632,10 +595,7 @@ export async function getPlaylist(db: AppDatabase, playlistId: string) {
return null;
}
const serverTimezone = getStringValue(
parseJsonValue<Record<string, unknown> | 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. */
@@ -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,