mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 20:16:15 -08:00
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:
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
|
||||
|
||||
Reference in new issue
Block a user