mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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:
1 parent
272e26c47c
commit
76f75971e8
4 files changed
+168
-116
No files matched your search
+155
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user