mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-27 16:49:51 -07:00
fix(launcher): address sync review feedback
This commit is contained in:
@@ -160,7 +160,9 @@ export function mergeVideos(
|
||||
): VideoMergeResult {
|
||||
const videoIdMap = new Map<number, number>();
|
||||
const addedVideoIds = new Set<number>();
|
||||
const byKey = local.prepare<SqlRow>('SELECT video_id, watched FROM imm_videos WHERE video_key = ?');
|
||||
const byKey = local.prepare<SqlRow>(
|
||||
'SELECT video_id, watched FROM imm_videos WHERE video_key = ?',
|
||||
);
|
||||
const setWatched = local.prepare('UPDATE imm_videos SET watched = 1 WHERE video_id = ?');
|
||||
|
||||
for (const row of selectAll(
|
||||
@@ -168,7 +170,8 @@ export function mergeVideos(
|
||||
`SELECT video_id, anime_id, ${VIDEO_COPY_COLUMNS.join(', ')} FROM imm_videos`,
|
||||
)) {
|
||||
const remoteId = Number(row.video_id);
|
||||
const mappedAnimeId = row.anime_id === null ? null : (animeIdMap.get(Number(row.anime_id)) ?? null);
|
||||
const mappedAnimeId =
|
||||
row.anime_id === null ? null : (animeIdMap.get(Number(row.anime_id)) ?? null);
|
||||
const existing = byKey.get(row.video_key);
|
||||
if (existing) {
|
||||
const localId = Number(existing.video_id);
|
||||
@@ -194,9 +197,11 @@ export function mergeMediaMetadata(
|
||||
videoIdMap: Map<number, number>,
|
||||
addedVideoIds: Set<number>,
|
||||
): void {
|
||||
if (addedVideoIds.size === 0) return;
|
||||
if (videoIdMap.size === 0) return;
|
||||
const metadataVideoIds = new Set<number>([...addedVideoIds, ...videoIdMap.keys()]);
|
||||
|
||||
const hasBlobStore = tableExists(local, 'imm_cover_art_blobs') && tableExists(remote, 'imm_cover_art_blobs');
|
||||
const hasBlobStore =
|
||||
tableExists(local, 'imm_cover_art_blobs') && tableExists(remote, 'imm_cover_art_blobs');
|
||||
const copyBlob = hasBlobStore
|
||||
? local.prepare(
|
||||
`INSERT INTO imm_cover_art_blobs (blob_hash, cover_blob, CREATED_DATE, LAST_UPDATE_DATE)
|
||||
@@ -208,15 +213,19 @@ export function mergeMediaMetadata(
|
||||
? remote.prepare<SqlRow>('SELECT * FROM imm_cover_art_blobs WHERE blob_hash = ?')
|
||||
: null;
|
||||
|
||||
if (tableExists(remote, 'imm_media_art')) {
|
||||
for (const remoteVideoId of addedVideoIds) {
|
||||
if (tableExists(remote, 'imm_media_art') && tableExists(local, 'imm_media_art')) {
|
||||
const localArtExists = local.prepare<SqlRow>(
|
||||
'SELECT 1 FROM imm_media_art WHERE video_id = ? LIMIT 1',
|
||||
);
|
||||
for (const remoteVideoId of metadataVideoIds) {
|
||||
const localVideoId = videoIdMap.get(remoteVideoId)!;
|
||||
if (localArtExists.get(localVideoId)) continue;
|
||||
const row = remote
|
||||
.query<SqlRow>(
|
||||
`SELECT ${MEDIA_ART_COPY_COLUMNS.join(', ')} FROM imm_media_art WHERE video_id = ?`,
|
||||
)
|
||||
.get(remoteVideoId);
|
||||
if (!row) continue;
|
||||
const localVideoId = videoIdMap.get(remoteVideoId)!;
|
||||
if (row.cover_blob_hash && copyBlob && readBlob) {
|
||||
const blob = readBlob.get(row.cover_blob_hash);
|
||||
if (blob) {
|
||||
@@ -233,7 +242,12 @@ export function mergeMediaMetadata(
|
||||
}
|
||||
|
||||
if (tableExists(remote, 'imm_youtube_videos') && tableExists(local, 'imm_youtube_videos')) {
|
||||
for (const remoteVideoId of addedVideoIds) {
|
||||
const localYoutubeExists = local.prepare<SqlRow>(
|
||||
'SELECT 1 FROM imm_youtube_videos WHERE video_id = ? LIMIT 1',
|
||||
);
|
||||
for (const remoteVideoId of metadataVideoIds) {
|
||||
const localVideoId = videoIdMap.get(remoteVideoId)!;
|
||||
if (localYoutubeExists.get(localVideoId)) continue;
|
||||
const row = remote
|
||||
.query<SqlRow>(
|
||||
`SELECT ${YOUTUBE_COPY_COLUMNS.join(', ')} FROM imm_youtube_videos WHERE video_id = ?`,
|
||||
@@ -244,7 +258,7 @@ export function mergeMediaMetadata(
|
||||
local,
|
||||
'imm_youtube_videos',
|
||||
['video_id', ...YOUTUBE_COPY_COLUMNS],
|
||||
[videoIdMap.get(remoteVideoId)!, ...YOUTUBE_COPY_COLUMNS.map((column) => row[column])],
|
||||
[localVideoId, ...YOUTUBE_COPY_COLUMNS.map((column) => row[column])],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -255,7 +269,10 @@ export function mergeExcludedWords(
|
||||
remote: Database,
|
||||
summary: SyncMergeSummary,
|
||||
): void {
|
||||
if (!tableExists(remote, 'imm_stats_excluded_words') || !tableExists(local, 'imm_stats_excluded_words')) {
|
||||
if (
|
||||
!tableExists(remote, 'imm_stats_excluded_words') ||
|
||||
!tableExists(local, 'imm_stats_excluded_words')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const insert = local.prepare(
|
||||
@@ -267,7 +284,13 @@ export function mergeExcludedWords(
|
||||
remote,
|
||||
'SELECT headword, word, reading, CREATED_DATE, LAST_UPDATE_DATE FROM imm_stats_excluded_words',
|
||||
)) {
|
||||
const result = insert.run(row.headword, row.word, row.reading, row.CREATED_DATE, row.LAST_UPDATE_DATE);
|
||||
const result = insert.run(
|
||||
row.headword,
|
||||
row.word,
|
||||
row.reading,
|
||||
row.CREATED_DATE,
|
||||
row.LAST_UPDATE_DATE,
|
||||
);
|
||||
summary.excludedWordsAdded += result.changes;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,7 +149,9 @@ export function refreshRollupsForNewSessions(
|
||||
}
|
||||
|
||||
const stampMs = nowDbTimestamp();
|
||||
const deleteDaily = local.prepare('DELETE FROM imm_daily_rollups WHERE rollup_day = ? AND video_id = ?');
|
||||
const deleteDaily = local.prepare(
|
||||
'DELETE FROM imm_daily_rollups WHERE rollup_day = ? AND video_id = ?',
|
||||
);
|
||||
const deleteMonthly = local.prepare(
|
||||
'DELETE FROM imm_monthly_rollups WHERE rollup_month = ? AND video_id = ?',
|
||||
);
|
||||
@@ -192,6 +194,15 @@ export function copyRemoteOnlyRollups(
|
||||
const localDaySessions = local.prepare(
|
||||
`SELECT 1 FROM imm_sessions WHERE video_id = ? AND ${LOCAL_DAY_EXPR} = ? LIMIT 1`,
|
||||
);
|
||||
const localMonthSessions = local.prepare(
|
||||
`SELECT 1 FROM imm_sessions WHERE video_id = ? AND ${LOCAL_MONTH_EXPR} = ? LIMIT 1`,
|
||||
);
|
||||
const localMonthSessionsForDay = local.prepare(
|
||||
`SELECT 1 FROM imm_sessions
|
||||
WHERE video_id = ?
|
||||
AND ${LOCAL_MONTH_EXPR} = CAST(strftime('%Y%m', CAST(? AS INTEGER) * 86400, 'unixepoch', 'localtime') AS INTEGER)
|
||||
LIMIT 1`,
|
||||
);
|
||||
const insertDaily = local.prepare(
|
||||
`INSERT INTO imm_daily_rollups (
|
||||
rollup_day, video_id, total_sessions, total_active_min, total_lines_seen,
|
||||
@@ -205,6 +216,7 @@ export function copyRemoteOnlyRollups(
|
||||
if (localVideoId === undefined) continue;
|
||||
if (localDailyExists.get(row.rollup_day, localVideoId)) continue;
|
||||
if (localDaySessions.get(localVideoId, row.rollup_day)) continue;
|
||||
if (localMonthSessionsForDay.get(localVideoId, row.rollup_day)) continue;
|
||||
insertDaily.run(
|
||||
row.rollup_day,
|
||||
localVideoId,
|
||||
@@ -225,9 +237,6 @@ export function copyRemoteOnlyRollups(
|
||||
const localMonthlyExists = local.prepare(
|
||||
'SELECT 1 FROM imm_monthly_rollups WHERE rollup_month = ? AND video_id = ? LIMIT 1',
|
||||
);
|
||||
const localMonthSessions = local.prepare(
|
||||
`SELECT 1 FROM imm_sessions WHERE video_id = ? AND ${LOCAL_MONTH_EXPR} = ? LIMIT 1`,
|
||||
);
|
||||
const insertMonthly = local.prepare(
|
||||
`INSERT INTO imm_monthly_rollups (
|
||||
rollup_month, video_id, total_sessions, total_active_min, total_lines_seen,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { assertSafeSshHost, shellQuote } from './ssh.js';
|
||||
import { assertSafeSshHost, runScp, shellQuote } from './ssh.js';
|
||||
|
||||
test('assertSafeSshHost rejects option-like hosts', () => {
|
||||
assert.throws(() => assertSafeSshHost('-oProxyCommand=touch pwned'), /looks like an option/);
|
||||
@@ -17,3 +17,15 @@ test('shellQuote escapes single quotes and wraps in quotes', () => {
|
||||
assert.equal(shellQuote('subminer'), `'subminer'`);
|
||||
assert.equal(shellQuote(`a'; rm -rf ~; '`), `'a'\\''; rm -rf ~; '\\'''`);
|
||||
});
|
||||
|
||||
test('runScp rejects option-like local endpoints before spawning scp', () => {
|
||||
assert.throws(() => runScp('-oProxyCommand=sh', '/tmp/out.sqlite'), /looks like an option/);
|
||||
assert.throws(() => runScp('/tmp/in.sqlite', '-bad-destination'), /looks like an option/);
|
||||
});
|
||||
|
||||
test('runScp rejects option-like remote host components', () => {
|
||||
assert.throws(
|
||||
() => runScp('-oProxyCommand=sh:/tmp/in.sqlite', '/tmp/out.sqlite'),
|
||||
/SSH host that looks like an option/,
|
||||
);
|
||||
});
|
||||
|
||||
+26
-5
@@ -3,6 +3,7 @@ import { spawnSync } from 'node:child_process';
|
||||
export interface RemoteRunResult {
|
||||
status: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -17,23 +18,43 @@ export function assertSafeSshHost(host: string): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a command on the SSH host. stdin/stderr stay attached to the terminal
|
||||
* so key passphrase / password prompts and remote progress output work;
|
||||
* stdout is captured for the caller.
|
||||
* Run a command on the SSH host. stdin stays attached so interactive prompts
|
||||
* can still read from the terminal; stdout/stderr are captured for callers
|
||||
* that need actionable remote failure messages.
|
||||
*/
|
||||
export function runSsh(host: string, remoteCommand: string): RemoteRunResult {
|
||||
assertSafeSshHost(host);
|
||||
const result = spawnSync('ssh', [host, remoteCommand], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['inherit', 'pipe', 'inherit'],
|
||||
stdio: ['inherit', 'pipe', 'pipe'],
|
||||
});
|
||||
if (result.error) {
|
||||
throw new Error(`Failed to run ssh: ${(result.error as Error).message}`);
|
||||
}
|
||||
return { status: result.status ?? 1, stdout: result.stdout ?? '' };
|
||||
return { status: result.status ?? 1, stdout: result.stdout ?? '', stderr: result.stderr ?? '' };
|
||||
}
|
||||
|
||||
function assertSafeScpEndpoint(endpoint: string): void {
|
||||
const colon = endpoint.indexOf(':');
|
||||
const slash = endpoint.indexOf('/');
|
||||
if (colon <= 0 || (slash !== -1 && slash < colon)) {
|
||||
if (endpoint.startsWith('-')) {
|
||||
throw new Error(`Refusing to use scp endpoint that looks like an option: ${endpoint}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const host = endpoint.slice(0, colon);
|
||||
const remotePath = endpoint.slice(colon + 1);
|
||||
assertSafeSshHost(host);
|
||||
if (remotePath.startsWith('-')) {
|
||||
throw new Error(`Refusing to use scp remote path that looks like an option: ${remotePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function runScp(from: string, to: string): void {
|
||||
assertSafeScpEndpoint(from);
|
||||
assertSafeScpEndpoint(to);
|
||||
const result = spawnSync('scp', ['-q', from, to], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['inherit', 'inherit', 'inherit'],
|
||||
|
||||
+232
-58
@@ -26,7 +26,11 @@ function makeDbPair(): { dir: string; localPath: string; remotePath: string } {
|
||||
return { dir, localPath, remotePath };
|
||||
}
|
||||
|
||||
function queryOne<T extends Record<string, unknown>>(dbPath: string, sql: string, params: unknown[] = []): T | undefined {
|
||||
function queryOne<T extends Record<string, unknown>>(
|
||||
dbPath: string,
|
||||
sql: string,
|
||||
params: unknown[] = [],
|
||||
): T | undefined {
|
||||
const db = new Database(dbPath, { readonly: true });
|
||||
try {
|
||||
return db.query<T>(sql).get(...params) as T | undefined;
|
||||
@@ -39,6 +43,15 @@ function count(dbPath: string, sql: string, params: unknown[] = []): number {
|
||||
return Number(queryOne<{ n: number }>(dbPath, sql, params)?.n ?? 0);
|
||||
}
|
||||
|
||||
function withWritableDb<T>(dbPath: string, fn: (db: Database) => T): T {
|
||||
const db = new Database(dbPath, { readwrite: true });
|
||||
try {
|
||||
return fn(db);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
test('merges remote-only sessions with catalog, lifetime, and rollups', () => {
|
||||
const { dir, localPath, remotePath } = makeDbPair();
|
||||
try {
|
||||
@@ -73,7 +86,13 @@ test('merges remote-only sessions with catalog, lifetime, and rollups', () => {
|
||||
assert.equal(summary.telemetryRowsAdded, 1);
|
||||
|
||||
assert.equal(count(localPath, 'SELECT COUNT(*) AS n FROM imm_sessions'), 2);
|
||||
const global = queryOne<{ total_sessions: number; total_active_ms: number; total_cards: number; active_days: number; episodes_started: number }>(
|
||||
const global = queryOne<{
|
||||
total_sessions: number;
|
||||
total_active_ms: number;
|
||||
total_cards: number;
|
||||
active_days: number;
|
||||
episodes_started: number;
|
||||
}>(
|
||||
localPath,
|
||||
'SELECT total_sessions, total_active_ms, total_cards, active_days, episodes_started FROM imm_lifetime_global WHERE global_id = 1',
|
||||
);
|
||||
@@ -98,11 +117,15 @@ test('merges remote-only sessions with catalog, lifetime, and rollups', () => {
|
||||
// The merged session's rollup group was recomputed.
|
||||
assert.equal(summary.rollupGroupsRecomputed, 1);
|
||||
const mergedVideoId = Number(
|
||||
queryOne<{ video_id: number }>(localPath, `SELECT video_id FROM imm_videos WHERE video_key = 'showb-e1'`)
|
||||
?.video_id,
|
||||
queryOne<{ video_id: number }>(
|
||||
localPath,
|
||||
`SELECT video_id FROM imm_videos WHERE video_key = 'showb-e1'`,
|
||||
)?.video_id,
|
||||
);
|
||||
assert.equal(
|
||||
count(localPath, 'SELECT COUNT(*) AS n FROM imm_daily_rollups WHERE video_id = ?', [mergedVideoId]),
|
||||
count(localPath, 'SELECT COUNT(*) AS n FROM imm_daily_rollups WHERE video_id = ?', [
|
||||
mergedVideoId,
|
||||
]),
|
||||
1,
|
||||
);
|
||||
} finally {
|
||||
@@ -143,7 +166,12 @@ test('is idempotent: re-merging the same snapshot changes nothing', () => {
|
||||
{ ...globalAfterFirst, LAST_UPDATE_DATE: null },
|
||||
);
|
||||
assert.equal(
|
||||
Number(queryOne<{ frequency: number }>(localPath, `SELECT frequency FROM imm_words WHERE word = '見た'`)?.frequency),
|
||||
Number(
|
||||
queryOne<{ frequency: number }>(
|
||||
localPath,
|
||||
`SELECT frequency FROM imm_words WHERE word = '見た'`,
|
||||
)?.frequency,
|
||||
),
|
||||
4,
|
||||
);
|
||||
} finally {
|
||||
@@ -161,9 +189,11 @@ test('preserves anilist_id when inserting a new anime from the snapshot', () =>
|
||||
startedAtMs: BASE_MS,
|
||||
applyLifetime: true,
|
||||
});
|
||||
const remoteDb = new Database(remotePath, { readwrite: true });
|
||||
remoteDb.prepare(`UPDATE imm_anime SET anilist_id = 12345 WHERE normalized_title_key = 'showb'`).run();
|
||||
remoteDb.close();
|
||||
withWritableDb(remotePath, (remoteDb) => {
|
||||
remoteDb
|
||||
.prepare(`UPDATE imm_anime SET anilist_id = 12345 WHERE normalized_title_key = 'showb'`)
|
||||
.run();
|
||||
});
|
||||
|
||||
const summary = mergeSnapshotIntoDb(localPath, remotePath);
|
||||
assert.equal(summary.animeAdded, 1);
|
||||
@@ -194,12 +224,16 @@ test('matches an existing local anime by anilist_id even when the title key diff
|
||||
startedAtMs: BASE_MS + DAY_MS,
|
||||
applyLifetime: true,
|
||||
});
|
||||
const localDb = new Database(localPath, { readwrite: true });
|
||||
localDb.prepare(`UPDATE imm_anime SET anilist_id = 999 WHERE normalized_title_key = 'show-romaji'`).run();
|
||||
localDb.close();
|
||||
const remoteDb = new Database(remotePath, { readwrite: true });
|
||||
remoteDb.prepare(`UPDATE imm_anime SET anilist_id = 999 WHERE normalized_title_key = 'show-native'`).run();
|
||||
remoteDb.close();
|
||||
withWritableDb(localPath, (localDb) => {
|
||||
localDb
|
||||
.prepare(`UPDATE imm_anime SET anilist_id = 999 WHERE normalized_title_key = 'show-romaji'`)
|
||||
.run();
|
||||
});
|
||||
withWritableDb(remotePath, (remoteDb) => {
|
||||
remoteDb
|
||||
.prepare(`UPDATE imm_anime SET anilist_id = 999 WHERE normalized_title_key = 'show-native'`)
|
||||
.run();
|
||||
});
|
||||
|
||||
const summary = mergeSnapshotIntoDb(localPath, remotePath);
|
||||
// Same anilist_id → one anime, not two.
|
||||
@@ -241,7 +275,11 @@ test('matches shared videos by video_key and merges the watched flag', () => {
|
||||
|
||||
// Both sessions now credit the same video; episode was started once and
|
||||
// completed once (by the remote session that watched it to the end).
|
||||
const global = queryOne<{ episodes_started: number; episodes_completed: number; total_sessions: number }>(
|
||||
const global = queryOne<{
|
||||
episodes_started: number;
|
||||
episodes_completed: number;
|
||||
total_sessions: number;
|
||||
}>(
|
||||
localPath,
|
||||
'SELECT episodes_started, episodes_completed, total_sessions FROM imm_lifetime_global WHERE global_id = 1',
|
||||
);
|
||||
@@ -253,6 +291,75 @@ test('matches shared videos by video_key and merges the watched flag', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('backfills media metadata for matched videos when local metadata is absent', () => {
|
||||
const { dir, localPath, remotePath } = makeDbPair();
|
||||
try {
|
||||
insertFixtureSession(localPath, {
|
||||
uuid: 'local-1',
|
||||
videoKey: 'showa-e1',
|
||||
animeTitleKey: 'showa',
|
||||
startedAtMs: BASE_MS,
|
||||
applyLifetime: true,
|
||||
});
|
||||
insertFixtureSession(remotePath, {
|
||||
uuid: 'remote-1',
|
||||
videoKey: 'showa-e1',
|
||||
animeTitleKey: 'showa',
|
||||
startedAtMs: BASE_MS + DAY_MS,
|
||||
applyLifetime: true,
|
||||
});
|
||||
const remoteDb = new Database(remotePath, { readwrite: true });
|
||||
try {
|
||||
const remoteVideoId = Number(
|
||||
remoteDb
|
||||
.query<{
|
||||
video_id: number;
|
||||
}>(`SELECT video_id FROM imm_videos WHERE video_key = 'showa-e1'`)
|
||||
.get()?.video_id,
|
||||
);
|
||||
remoteDb
|
||||
.prepare(
|
||||
`INSERT INTO imm_media_art (video_id, anilist_id, cover_url, title_romaji, fetched_at_ms)
|
||||
VALUES (?, 123, 'https://example.test/cover.jpg', 'Show A', ?)`,
|
||||
)
|
||||
.run(remoteVideoId, String(BASE_MS));
|
||||
remoteDb
|
||||
.prepare(
|
||||
`INSERT INTO imm_youtube_videos (video_id, youtube_video_id, video_url, video_title, fetched_at_ms)
|
||||
VALUES (?, 'yt-1', 'https://youtube.test/watch?v=yt-1', 'Remote Video', ?)`,
|
||||
)
|
||||
.run(remoteVideoId, String(BASE_MS));
|
||||
} finally {
|
||||
remoteDb.close();
|
||||
}
|
||||
|
||||
const summary = mergeSnapshotIntoDb(localPath, remotePath);
|
||||
assert.equal(summary.videosAdded, 0);
|
||||
const localVideoId = Number(
|
||||
queryOne<{ video_id: number }>(
|
||||
localPath,
|
||||
`SELECT video_id FROM imm_videos WHERE video_key = 'showa-e1'`,
|
||||
)?.video_id,
|
||||
);
|
||||
const art = queryOne<{ cover_url: string; title_romaji: string }>(
|
||||
localPath,
|
||||
'SELECT cover_url, title_romaji FROM imm_media_art WHERE video_id = ?',
|
||||
[localVideoId],
|
||||
);
|
||||
assert.equal(art?.cover_url, 'https://example.test/cover.jpg');
|
||||
assert.equal(art?.title_romaji, 'Show A');
|
||||
const youtube = queryOne<{ youtube_video_id: string; video_title: string }>(
|
||||
localPath,
|
||||
'SELECT youtube_video_id, video_title FROM imm_youtube_videos WHERE video_id = ?',
|
||||
[localVideoId],
|
||||
);
|
||||
assert.equal(youtube?.youtube_video_id, 'yt-1');
|
||||
assert.equal(youtube?.video_title, 'Remote Video');
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('does not double-count active_days when a merged session starts earlier on an already-credited day', () => {
|
||||
const { dir, localPath, remotePath } = makeDbPair();
|
||||
try {
|
||||
@@ -310,18 +417,21 @@ test('copies remote-only historical rollups but never sums shared groups', () =>
|
||||
startedAtMs: BASE_MS,
|
||||
applyLifetime: true,
|
||||
});
|
||||
const remoteDb = new Database(remotePath, { readwrite: true });
|
||||
const remoteVideoId = Number(
|
||||
remoteDb.query<{ video_id: number }>(`SELECT video_id FROM imm_videos WHERE video_key = 'old-show-e1'`).get()
|
||||
?.video_id,
|
||||
);
|
||||
remoteDb
|
||||
.prepare(
|
||||
`INSERT INTO imm_daily_rollups (rollup_day, video_id, total_sessions, total_active_min, total_lines_seen, total_tokens_seen, total_cards)
|
||||
VALUES (10000, ?, 4, 120.5, 400, 3200, 9)`,
|
||||
)
|
||||
.run(remoteVideoId);
|
||||
remoteDb.close();
|
||||
withWritableDb(remotePath, (remoteDb) => {
|
||||
const remoteVideoId = Number(
|
||||
remoteDb
|
||||
.query<{
|
||||
video_id: number;
|
||||
}>(`SELECT video_id FROM imm_videos WHERE video_key = 'old-show-e1'`)
|
||||
.get()?.video_id,
|
||||
);
|
||||
remoteDb
|
||||
.prepare(
|
||||
`INSERT INTO imm_daily_rollups (rollup_day, video_id, total_sessions, total_active_min, total_lines_seen, total_tokens_seen, total_cards)
|
||||
VALUES (10000, ?, 4, 120.5, 400, 3200, 9)`,
|
||||
)
|
||||
.run(remoteVideoId);
|
||||
});
|
||||
|
||||
// Local already has its own rollup row for a shared group.
|
||||
insertFixtureSession(localPath, {
|
||||
@@ -330,18 +440,22 @@ test('copies remote-only historical rollups but never sums shared groups', () =>
|
||||
startedAtMs: BASE_MS,
|
||||
applyLifetime: true,
|
||||
});
|
||||
const localDb = new Database(localPath, { readwrite: true });
|
||||
const localVideoId = Number(
|
||||
localDb.query<{ video_id: number }>(`SELECT video_id FROM imm_videos WHERE video_key = 'old-show-e1'`).get()
|
||||
?.video_id,
|
||||
);
|
||||
localDb
|
||||
.prepare(
|
||||
`INSERT INTO imm_daily_rollups (rollup_day, video_id, total_sessions, total_active_min, total_lines_seen, total_tokens_seen, total_cards)
|
||||
VALUES (10000, ?, 2, 60.0, 200, 1600, 4)`,
|
||||
)
|
||||
.run(localVideoId);
|
||||
localDb.close();
|
||||
const localVideoId = withWritableDb(localPath, (localDb) => {
|
||||
const videoId = Number(
|
||||
localDb
|
||||
.query<{
|
||||
video_id: number;
|
||||
}>(`SELECT video_id FROM imm_videos WHERE video_key = 'old-show-e1'`)
|
||||
.get()?.video_id,
|
||||
);
|
||||
localDb
|
||||
.prepare(
|
||||
`INSERT INTO imm_daily_rollups (rollup_day, video_id, total_sessions, total_active_min, total_lines_seen, total_tokens_seen, total_cards)
|
||||
VALUES (10000, ?, 2, 60.0, 200, 1600, 4)`,
|
||||
)
|
||||
.run(videoId);
|
||||
return videoId;
|
||||
});
|
||||
|
||||
const summary = mergeSnapshotIntoDb(localPath, remotePath);
|
||||
// Shared group (day 10000) untouched; the remote-only session's own group
|
||||
@@ -355,28 +469,30 @@ test('copies remote-only historical rollups but never sums shared groups', () =>
|
||||
assert.equal(shared?.total_sessions, 2);
|
||||
|
||||
// Now a rollup for a video with no local sessions at all gets copied.
|
||||
const remoteDb2 = new Database(remotePath, { readwrite: true });
|
||||
const orphanVideoId = Number(
|
||||
withWritableDb(remotePath, (remoteDb2) => {
|
||||
const orphanVideoId = Number(
|
||||
remoteDb2
|
||||
.prepare(
|
||||
`INSERT INTO imm_videos (video_key, canonical_title, source_type, watched, duration_ms)
|
||||
VALUES ('pruned-show-e1', 'pruned-show-e1', 1, 1, 1440000)`,
|
||||
)
|
||||
.run().lastInsertRowid,
|
||||
);
|
||||
remoteDb2
|
||||
.prepare(
|
||||
`INSERT INTO imm_videos (video_key, canonical_title, source_type, watched, duration_ms)
|
||||
VALUES ('pruned-show-e1', 'pruned-show-e1', 1, 1, 1440000)`,
|
||||
`INSERT INTO imm_daily_rollups (rollup_day, video_id, total_sessions, total_active_min, total_lines_seen, total_tokens_seen, total_cards)
|
||||
VALUES (9000, ?, 3, 90.0, 300, 2400, 6)`,
|
||||
)
|
||||
.run().lastInsertRowid,
|
||||
);
|
||||
remoteDb2
|
||||
.prepare(
|
||||
`INSERT INTO imm_daily_rollups (rollup_day, video_id, total_sessions, total_active_min, total_lines_seen, total_tokens_seen, total_cards)
|
||||
VALUES (9000, ?, 3, 90.0, 300, 2400, 6)`,
|
||||
)
|
||||
.run(orphanVideoId);
|
||||
remoteDb2.close();
|
||||
.run(orphanVideoId);
|
||||
});
|
||||
|
||||
const summary2 = mergeSnapshotIntoDb(localPath, remotePath);
|
||||
assert.equal(summary2.dailyRollupsCopied, 1);
|
||||
const copiedVideoId = Number(
|
||||
queryOne<{ video_id: number }>(localPath, `SELECT video_id FROM imm_videos WHERE video_key = 'pruned-show-e1'`)
|
||||
?.video_id,
|
||||
queryOne<{ video_id: number }>(
|
||||
localPath,
|
||||
`SELECT video_id FROM imm_videos WHERE video_key = 'pruned-show-e1'`,
|
||||
)?.video_id,
|
||||
);
|
||||
const copied = queryOne<{ total_sessions: number; total_active_min: number }>(
|
||||
localPath,
|
||||
@@ -390,12 +506,70 @@ test('copies remote-only historical rollups but never sums shared groups', () =>
|
||||
}
|
||||
});
|
||||
|
||||
test('does not copy remote-only daily rollups into months with local sessions', () => {
|
||||
const { dir, localPath, remotePath } = makeDbPair();
|
||||
try {
|
||||
insertFixtureSession(localPath, {
|
||||
uuid: 'local-1',
|
||||
videoKey: 'mixed-month-e1',
|
||||
animeTitleKey: 'mixed-month',
|
||||
startedAtMs: BASE_MS,
|
||||
applyLifetime: true,
|
||||
});
|
||||
const remoteDb = new Database(remotePath, { readwrite: true });
|
||||
try {
|
||||
const remoteVideoId = Number(
|
||||
remoteDb
|
||||
.prepare(
|
||||
`INSERT INTO imm_videos (video_key, canonical_title, source_type, watched, duration_ms)
|
||||
VALUES ('mixed-month-e1', 'mixed-month-e1', 1, 1, 1440000)`,
|
||||
)
|
||||
.run().lastInsertRowid,
|
||||
);
|
||||
remoteDb
|
||||
.prepare(
|
||||
`INSERT INTO imm_daily_rollups (rollup_day, video_id, total_sessions, total_active_min, total_lines_seen, total_tokens_seen, total_cards)
|
||||
VALUES (20615, ?, 3, 90.0, 300, 2400, 6)`,
|
||||
)
|
||||
.run(remoteVideoId);
|
||||
remoteDb
|
||||
.prepare(
|
||||
`INSERT INTO imm_monthly_rollups (rollup_month, video_id, total_sessions, total_active_min, total_lines_seen, total_tokens_seen, total_cards)
|
||||
VALUES (202606, ?, 3, 90.0, 300, 2400, 6)`,
|
||||
)
|
||||
.run(remoteVideoId);
|
||||
} finally {
|
||||
remoteDb.close();
|
||||
}
|
||||
|
||||
const summary = mergeSnapshotIntoDb(localPath, remotePath);
|
||||
assert.equal(summary.dailyRollupsCopied, 0);
|
||||
assert.equal(summary.monthlyRollupsCopied, 0);
|
||||
const localVideoId = Number(
|
||||
queryOne<{ video_id: number }>(
|
||||
localPath,
|
||||
`SELECT video_id FROM imm_videos WHERE video_key = 'mixed-month-e1'`,
|
||||
)?.video_id,
|
||||
);
|
||||
assert.equal(
|
||||
count(
|
||||
localPath,
|
||||
'SELECT COUNT(*) AS n FROM imm_daily_rollups WHERE rollup_day = 20615 AND video_id = ?',
|
||||
[localVideoId],
|
||||
),
|
||||
0,
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects snapshots at a different schema version', () => {
|
||||
const { dir, localPath, remotePath } = makeDbPair();
|
||||
try {
|
||||
const remoteDb = new Database(remotePath, { readwrite: true });
|
||||
remoteDb.prepare('UPDATE imm_schema_version SET schema_version = 17').run();
|
||||
remoteDb.close();
|
||||
withWritableDb(remotePath, (remoteDb) => {
|
||||
remoteDb.prepare('UPDATE imm_schema_version SET schema_version = 17').run();
|
||||
});
|
||||
|
||||
assert.throws(() => mergeSnapshotIntoDb(localPath, remotePath), /schema version 17/);
|
||||
} finally {
|
||||
|
||||
Reference in New Issue
Block a user