fix(xtream): keep the stored panel clock across clockless full upserts

Review follow-up (Codex): a `PlaylistsService` mutation that read the row
before `DB_SET_PLAYLIST_SERVER_TIMEZONE` landed and upserted afterwards
replaced the payload with its clockless snapshot. `DB_UPSERT_APP_PLAYLIST(S)`
now carry the STORED clock into a snapshot that has none while the row still
points at the same connection (`playlistConflictUpdate`, nested CASE so the
json_* readers never run on a malformed payload); a snapshot with its own
clock, or one that moves the source, wins as is. Real-SQLite coverage for
kept / moved / own-clock / batch rows.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-09-07 00:28:40 +02:00
1 parent 212d6ed785
commit a94f811db5
3 files changed
+108 -11

No files matched your search

@@ -43,7 +43,7 @@ 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 } = await import(${JSON.stringify(operationsUrl)});
const { updatePlaylist, setPlaylistServerTimezone, upsertAppPlaylist, upsertAppPlaylists } = await import(${JSON.stringify(operationsUrl)});
const { __databaseConnectionTestHooks } = await import(${JSON.stringify(connectionUrl)});
const sqlite = new Database(':memory:');
const statements = [
@@ -273,6 +273,46 @@ describe('playlist.operations', () => {
});
});
it('keeps the stored panel timezone when a clockless full upsert lands after it on the same connection (issue #1562)', () => {
const result = runPlaylistUpdateScenario(`
const conn = { serverUrl: 'http://panel.example', username: 'u', password: 'p' };
const stale = (id, extra = {}) => ({ _id: id, title: id, ...conn, type: 'xtream', favorites: ['1'], ...extra });
for (const id of ['kept', 'moved', 'own-clock', 'batch']) {
await upsertAppPlaylist(db, stale(id));
await setPlaylistServerTimezone(db, id, conn, 'Europe/London');
}
// Snapshots read BEFORE the clock landed, written after it.
await upsertAppPlaylist(db, stale('kept', { favorites: ['1', '2'] }));
await upsertAppPlaylist(db, stale('moved', { serverUrl: 'http://other.example' }));
await upsertAppPlaylist(db, stale('own-clock', { serverTimezone: 'UTC+03:00' }));
await upsertAppPlaylists(db, [stale('batch', { favorites: ['9'] })]);
const rows = Object.fromEntries(
sqlite.prepare('SELECT id, serverUrl, payload FROM playlists').all()
.map((row) => [row.id, { serverUrl: row.serverUrl, payload: JSON.parse(row.payload) }])
);
process.stdout.write(JSON.stringify(rows));
`) as Record<
string,
{ serverUrl: string; payload: Record<string, unknown> }
>;
expect(result['kept'].payload).toEqual(
expect.objectContaining({
favorites: ['1', '2'],
serverTimezone: 'Europe/London',
})
);
expect(result['batch'].payload).toEqual(
expect.objectContaining({
favorites: ['9'],
serverTimezone: 'Europe/London',
})
);
expect(result['own-clock'].payload.serverTimezone).toBe('UTC+03:00');
expect(result['moved'].serverUrl).toBe('http://other.example');
expect(result['moved'].payload).not.toHaveProperty('serverTimezone');
});
it('loads app playlist metadata without selecting the large payload column', async () => {
const from = jest.fn().mockResolvedValue([
{
@@ -1,4 +1,4 @@
import { eq, sql } from 'drizzle-orm';
import { eq, sql, type SQL } from 'drizzle-orm';
import * as schema from '@iptvnator/shared/database/schema';
import type { Channel, M3uFavoriteChannel } from '@iptvnator/shared/interfaces';
import {
@@ -235,6 +235,48 @@ 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 {
@@ -371,10 +413,13 @@ export async function upsertAppPlaylist(
}
const write = async () => {
await db.insert(schema.playlists).values(row).onConflictDoUpdate({
target: schema.playlists.id,
set: row,
});
await db
.insert(schema.playlists)
.values(row)
.onConflictDoUpdate({
target: schema.playlists.id,
set: playlistConflictUpdate(row, playlist),
});
};
if (capturePhase) {
await capturePhase.captureAsync(
@@ -400,20 +445,27 @@ export async function upsertAppPlaylists(
}
const rows = playlists
.map((playlist) => buildPlaylistRow(playlist))
.filter((row): row is NonNullable<typeof row> => row !== null);
.map((playlist) => ({ playlist, row: buildPlaylistRow(playlist) }))
.filter(
(
entry
): entry is {
playlist: Record<string, unknown>;
row: NonNullable<typeof entry.row>;
} => entry.row !== null
);
if (rows.length === 0) {
return { success: true, count: 0 };
}
await db.transaction((tx) => {
for (const row of rows) {
for (const { playlist, row } of rows) {
tx.insert(schema.playlists)
.values(row)
.onConflictDoUpdate({
target: schema.playlists.id,
set: row,
set: playlistConflictUpdate(row, playlist),
})
.run();
}
@@ -293,7 +293,12 @@ IndexedDB readwrite cursor transaction. No read precedes the write, because
the database worker interleaves requests and the Xtream edit dialog saves
through `DB_UPDATE_PLAYLIST` outside `PlaylistsService`'s queue: a
read-modify-write could hand a concurrent upsert's newer payload back to the
past or undo an edit that landed in between. The store offers the resolved
past or undo an edit that landed in between. The reverse ordering is covered
on the upsert side: `DB_UPSERT_APP_PLAYLIST(S)` (`playlistConflictUpdate`)
carries the STORED clock into a snapshot that has none while the row still
points at the same connection, so a favorites, recent-items or metadata write
built from a pre-clock snapshot cannot strip it; a snapshot carrying its own
clock, or moving the source, wins as is. The store offers the resolved
value on every check (a transient write failure is retried by the next one),
patches its own state only while the selected playlist is still the panel the
answer came from (`answersFor`: id + connection), and returns the store's