mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-14 13:55:55 -07:00
test(stats): organize lifetime summary repair tests
- Extract shared lifetime test fixtures - Move repair scenarios into a dedicated test file
This commit is contained in:
@@ -1,168 +1,21 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import { Database } from '../sqlite.js';
|
|
||||||
import type { DatabaseSync } from '../sqlite.js';
|
|
||||||
import {
|
|
||||||
applyPragmas,
|
|
||||||
ensureSchema,
|
|
||||||
getOrCreateAnimeRecord,
|
|
||||||
getOrCreateVideoRecord,
|
|
||||||
linkVideoToAnimeRecord,
|
|
||||||
} from '../storage.js';
|
|
||||||
import { startSessionRecord } from '../session.js';
|
import { startSessionRecord } from '../session.js';
|
||||||
import {
|
import { applySessionLifetimeSummary, rebuildLifetimeSummaries } from '../lifetime.js';
|
||||||
applySessionLifetimeSummary,
|
|
||||||
rebuildLifetimeSummaries,
|
|
||||||
repairLifetimeSummariesFromMedia,
|
|
||||||
} from '../lifetime.js';
|
|
||||||
import { deleteMaintenanceBatch } from '../query-delete-maintenance.js';
|
import { deleteMaintenanceBatch } from '../query-delete-maintenance.js';
|
||||||
import { toDbTimestamp } from '../query-shared.js';
|
import { toDbTimestamp } from '../query-shared.js';
|
||||||
|
import {
|
||||||
const SOURCE_TYPE_LOCAL = 1;
|
BASE_MS,
|
||||||
const DAY_MS = 86_400_000;
|
DAY_MS,
|
||||||
// Noon UTC keeps every seeded timestamp on the same local day regardless of
|
cleanRow,
|
||||||
// the timezone the test host runs in.
|
createDb,
|
||||||
const BASE_MS = Date.UTC(2026, 0, 5, 12, 0, 0);
|
seedAnime,
|
||||||
|
seedEndedSession,
|
||||||
function createDb(): DatabaseSync {
|
seedVideo,
|
||||||
const db = new Database(':memory:');
|
snapshotAnime,
|
||||||
applyPragmas(db);
|
snapshotGlobal,
|
||||||
ensureSchema(db);
|
snapshotMedia,
|
||||||
return db;
|
} from './lifetime-test-fixtures.js';
|
||||||
}
|
|
||||||
|
|
||||||
function seedAnime(db: DatabaseSync, title: string, episodesTotal: number | null): number {
|
|
||||||
const animeId = getOrCreateAnimeRecord(db, {
|
|
||||||
parsedTitle: title,
|
|
||||||
canonicalTitle: title,
|
|
||||||
anilistId: null,
|
|
||||||
titleRomaji: null,
|
|
||||||
titleEnglish: null,
|
|
||||||
titleNative: null,
|
|
||||||
metadataJson: null,
|
|
||||||
});
|
|
||||||
if (episodesTotal !== null) {
|
|
||||||
db.prepare('UPDATE imm_anime SET episodes_total = ? WHERE anime_id = ?').run(
|
|
||||||
episodesTotal,
|
|
||||||
animeId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return animeId;
|
|
||||||
}
|
|
||||||
|
|
||||||
function seedVideo(
|
|
||||||
db: DatabaseSync,
|
|
||||||
animeId: number | null,
|
|
||||||
name: string,
|
|
||||||
options: { watched?: boolean } = {},
|
|
||||||
): number {
|
|
||||||
const videoId = getOrCreateVideoRecord(db, `local:/tmp/${name}.mkv`, {
|
|
||||||
canonicalTitle: name,
|
|
||||||
sourcePath: `/tmp/${name}.mkv`,
|
|
||||||
sourceUrl: null,
|
|
||||||
sourceType: SOURCE_TYPE_LOCAL,
|
|
||||||
});
|
|
||||||
if (animeId !== null) {
|
|
||||||
linkVideoToAnimeRecord(db, videoId, {
|
|
||||||
animeId,
|
|
||||||
parsedBasename: `${name}.mkv`,
|
|
||||||
parsedTitle: name,
|
|
||||||
parsedSeason: 1,
|
|
||||||
parsedEpisode: 1,
|
|
||||||
parserSource: 'test',
|
|
||||||
parserConfidence: 1,
|
|
||||||
parseMetadataJson: null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (options.watched) {
|
|
||||||
db.prepare('UPDATE imm_videos SET watched = 1 WHERE video_id = ?').run(videoId);
|
|
||||||
}
|
|
||||||
return videoId;
|
|
||||||
}
|
|
||||||
|
|
||||||
function seedEndedSession(
|
|
||||||
db: DatabaseSync,
|
|
||||||
videoId: number,
|
|
||||||
startedAtMs: number,
|
|
||||||
metrics: { activeMs: number; cards?: number; lines?: number; tokens?: number },
|
|
||||||
): number {
|
|
||||||
const sessionId = startSessionRecord(db, videoId, startedAtMs).sessionId;
|
|
||||||
db.prepare(
|
|
||||||
`
|
|
||||||
UPDATE imm_sessions SET
|
|
||||||
ended_at_ms = ?,
|
|
||||||
active_watched_ms = ?,
|
|
||||||
total_watched_ms = ?,
|
|
||||||
cards_mined = ?,
|
|
||||||
lines_seen = ?,
|
|
||||||
tokens_seen = ?
|
|
||||||
WHERE session_id = ?
|
|
||||||
`,
|
|
||||||
).run(
|
|
||||||
toDbTimestamp(startedAtMs + metrics.activeMs),
|
|
||||||
metrics.activeMs,
|
|
||||||
metrics.activeMs,
|
|
||||||
metrics.cards ?? 0,
|
|
||||||
metrics.lines ?? 0,
|
|
||||||
metrics.tokens ?? 0,
|
|
||||||
sessionId,
|
|
||||||
);
|
|
||||||
return sessionId;
|
|
||||||
}
|
|
||||||
|
|
||||||
// libsql attaches a per-query `_metadata` property to result rows; strip it so
|
|
||||||
// row snapshots can be compared with deepEqual.
|
|
||||||
function cleanRow<T>(row: unknown): T {
|
|
||||||
const { _metadata: _ignored, ...rest } = row as Record<string, unknown>;
|
|
||||||
return rest as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GlobalSnapshot {
|
|
||||||
total_sessions: number;
|
|
||||||
total_active_ms: number;
|
|
||||||
total_cards: number;
|
|
||||||
active_days: number;
|
|
||||||
episodes_started: number;
|
|
||||||
episodes_completed: number;
|
|
||||||
anime_completed: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function snapshotGlobal(db: DatabaseSync): GlobalSnapshot {
|
|
||||||
const row = db
|
|
||||||
.prepare(
|
|
||||||
`SELECT total_sessions, total_active_ms, total_cards, active_days,
|
|
||||||
episodes_started, episodes_completed, anime_completed
|
|
||||||
FROM imm_lifetime_global WHERE global_id = 1`,
|
|
||||||
)
|
|
||||||
.get();
|
|
||||||
return cleanRow<GlobalSnapshot>(row);
|
|
||||||
}
|
|
||||||
|
|
||||||
function snapshotMedia(db: DatabaseSync): unknown[] {
|
|
||||||
return db
|
|
||||||
.prepare(
|
|
||||||
`SELECT video_id, total_sessions, total_active_ms, total_cards,
|
|
||||||
total_lines_seen, total_tokens_seen, completed,
|
|
||||||
CAST(first_watched_ms AS REAL) AS first_watched,
|
|
||||||
CAST(last_watched_ms AS REAL) AS last_watched
|
|
||||||
FROM imm_lifetime_media ORDER BY video_id`,
|
|
||||||
)
|
|
||||||
.all()
|
|
||||||
.map((row) => cleanRow(row));
|
|
||||||
}
|
|
||||||
|
|
||||||
function snapshotAnime(db: DatabaseSync): unknown[] {
|
|
||||||
return db
|
|
||||||
.prepare(
|
|
||||||
`SELECT anime_id, total_sessions, total_active_ms, total_cards,
|
|
||||||
total_lines_seen, total_tokens_seen, episodes_started, episodes_completed,
|
|
||||||
CAST(first_watched_ms AS REAL) AS first_watched,
|
|
||||||
CAST(last_watched_ms AS REAL) AS last_watched
|
|
||||||
FROM imm_lifetime_anime ORDER BY anime_id`,
|
|
||||||
)
|
|
||||||
.all()
|
|
||||||
.map((row) => cleanRow(row));
|
|
||||||
}
|
|
||||||
|
|
||||||
test('fractional lifetime metrics stay normalized across apply, rebuild, and delete', () => {
|
test('fractional lifetime metrics stay normalized across apply, rebuild, and delete', () => {
|
||||||
const db = createDb();
|
const db = createDb();
|
||||||
@@ -448,82 +301,3 @@ test('deleting a video updates anime and global rollups without a rebuild', () =
|
|||||||
db.close();
|
db.close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('repair after a video moves between anime matches a full rebuild', () => {
|
|
||||||
const db = createDb();
|
|
||||||
try {
|
|
||||||
const animeA = seedAnime(db, 'Move Source', 2);
|
|
||||||
const animeB = seedAnime(db, 'Move Target', 2);
|
|
||||||
const movedVideo = seedVideo(db, animeA, 'moved-ep', { watched: true });
|
|
||||||
const stayingVideo = seedVideo(db, animeA, 'staying-ep');
|
|
||||||
const targetVideo = seedVideo(db, animeB, 'target-ep', { watched: true });
|
|
||||||
seedEndedSession(db, movedVideo, BASE_MS, { activeMs: 60_000, cards: 2 });
|
|
||||||
seedEndedSession(db, stayingVideo, BASE_MS + DAY_MS, { activeMs: 30_000 });
|
|
||||||
seedEndedSession(db, targetVideo, BASE_MS + 2 * DAY_MS, { activeMs: 45_000, cards: 1 });
|
|
||||||
rebuildLifetimeSummaries(db);
|
|
||||||
|
|
||||||
// Simulate a library merge reassigning the episode to the other anime.
|
|
||||||
db.prepare('UPDATE imm_videos SET anime_id = ? WHERE video_id = ?').run(animeB, movedVideo);
|
|
||||||
repairLifetimeSummariesFromMedia(db);
|
|
||||||
|
|
||||||
const repairedGlobal = snapshotGlobal(db);
|
|
||||||
const repairedMedia = snapshotMedia(db);
|
|
||||||
const repairedAnime = snapshotAnime(db);
|
|
||||||
|
|
||||||
rebuildLifetimeSummaries(db);
|
|
||||||
assert.deepEqual(repairedGlobal, snapshotGlobal(db));
|
|
||||||
assert.deepEqual(repairedMedia, snapshotMedia(db));
|
|
||||||
assert.deepEqual(repairedAnime, snapshotAnime(db));
|
|
||||||
} finally {
|
|
||||||
db.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('repair preserves lifetime history from pruned sessions where a rebuild would not', () => {
|
|
||||||
const db = createDb();
|
|
||||||
try {
|
|
||||||
const animeId = seedAnime(db, 'Repair Anime', null);
|
|
||||||
const videoId = seedVideo(db, animeId, 'repair-ep');
|
|
||||||
const prunedSessionId = seedEndedSession(db, videoId, BASE_MS, {
|
|
||||||
activeMs: 90_000,
|
|
||||||
cards: 3,
|
|
||||||
});
|
|
||||||
seedEndedSession(db, videoId, BASE_MS + DAY_MS, { activeMs: 30_000, cards: 1 });
|
|
||||||
rebuildLifetimeSummaries(db);
|
|
||||||
|
|
||||||
db.prepare('DELETE FROM imm_sessions WHERE session_id = ?').run(prunedSessionId);
|
|
||||||
repairLifetimeSummariesFromMedia(db);
|
|
||||||
|
|
||||||
const globalRow = snapshotGlobal(db);
|
|
||||||
assert.equal(globalRow.total_sessions, 2, 'repair keeps the pruned session contribution');
|
|
||||||
assert.equal(globalRow.total_active_ms, 120_000);
|
|
||||||
assert.equal(globalRow.total_cards, 4);
|
|
||||||
assert.equal(globalRow.active_days, 2, 'repair never subtracts active days');
|
|
||||||
|
|
||||||
const animeRow = db
|
|
||||||
.prepare('SELECT total_sessions FROM imm_lifetime_anime WHERE anime_id = ?')
|
|
||||||
.get(animeId);
|
|
||||||
assert.equal(cleanRow<{ total_sessions: number }>(animeRow).total_sessions, 2);
|
|
||||||
} finally {
|
|
||||||
db.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('repair leaves a caller-owned transaction intact when its begin fails', () => {
|
|
||||||
const db = createDb();
|
|
||||||
try {
|
|
||||||
db.exec('BEGIN');
|
|
||||||
const animeId = seedAnime(db, 'Caller Transaction', null);
|
|
||||||
|
|
||||||
assert.throws(() => repairLifetimeSummariesFromMedia(db), /transaction/i);
|
|
||||||
assert.ok(
|
|
||||||
db.prepare('SELECT 1 FROM imm_anime WHERE anime_id = ?').get(animeId),
|
|
||||||
'the repair did not roll back the caller transaction',
|
|
||||||
);
|
|
||||||
|
|
||||||
db.exec('ROLLBACK');
|
|
||||||
assert.equal(db.prepare('SELECT 1 FROM imm_anime WHERE anime_id = ?').get(animeId), undefined);
|
|
||||||
} finally {
|
|
||||||
db.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { rebuildLifetimeSummaries, repairLifetimeSummariesFromMedia } from '../lifetime.js';
|
||||||
|
import {
|
||||||
|
BASE_MS,
|
||||||
|
DAY_MS,
|
||||||
|
cleanRow,
|
||||||
|
createDb,
|
||||||
|
seedAnime,
|
||||||
|
seedEndedSession,
|
||||||
|
seedVideo,
|
||||||
|
snapshotAnime,
|
||||||
|
snapshotGlobal,
|
||||||
|
snapshotMedia,
|
||||||
|
} from './lifetime-test-fixtures.js';
|
||||||
|
|
||||||
|
test('repair after a video moves between anime matches a full rebuild', () => {
|
||||||
|
const db = createDb();
|
||||||
|
try {
|
||||||
|
const animeA = seedAnime(db, 'Move Source', 2);
|
||||||
|
const animeB = seedAnime(db, 'Move Target', 2);
|
||||||
|
const movedVideo = seedVideo(db, animeA, 'moved-ep', { watched: true });
|
||||||
|
const stayingVideo = seedVideo(db, animeA, 'staying-ep');
|
||||||
|
const targetVideo = seedVideo(db, animeB, 'target-ep', { watched: true });
|
||||||
|
seedEndedSession(db, movedVideo, BASE_MS, { activeMs: 60_000, cards: 2 });
|
||||||
|
seedEndedSession(db, stayingVideo, BASE_MS + DAY_MS, { activeMs: 30_000 });
|
||||||
|
seedEndedSession(db, targetVideo, BASE_MS + 2 * DAY_MS, { activeMs: 45_000, cards: 1 });
|
||||||
|
rebuildLifetimeSummaries(db);
|
||||||
|
|
||||||
|
// Simulate a library merge reassigning the episode to the other anime.
|
||||||
|
db.prepare('UPDATE imm_videos SET anime_id = ? WHERE video_id = ?').run(animeB, movedVideo);
|
||||||
|
repairLifetimeSummariesFromMedia(db);
|
||||||
|
|
||||||
|
const repairedGlobal = snapshotGlobal(db);
|
||||||
|
const repairedMedia = snapshotMedia(db);
|
||||||
|
const repairedAnime = snapshotAnime(db);
|
||||||
|
|
||||||
|
rebuildLifetimeSummaries(db);
|
||||||
|
assert.deepEqual(repairedGlobal, snapshotGlobal(db));
|
||||||
|
assert.deepEqual(repairedMedia, snapshotMedia(db));
|
||||||
|
assert.deepEqual(repairedAnime, snapshotAnime(db));
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('repair preserves lifetime history from pruned sessions where a rebuild would not', () => {
|
||||||
|
const db = createDb();
|
||||||
|
try {
|
||||||
|
const animeId = seedAnime(db, 'Repair Anime', null);
|
||||||
|
const videoId = seedVideo(db, animeId, 'repair-ep');
|
||||||
|
const prunedSessionId = seedEndedSession(db, videoId, BASE_MS, {
|
||||||
|
activeMs: 90_000,
|
||||||
|
cards: 3,
|
||||||
|
});
|
||||||
|
seedEndedSession(db, videoId, BASE_MS + DAY_MS, { activeMs: 30_000, cards: 1 });
|
||||||
|
rebuildLifetimeSummaries(db);
|
||||||
|
|
||||||
|
db.prepare('DELETE FROM imm_sessions WHERE session_id = ?').run(prunedSessionId);
|
||||||
|
repairLifetimeSummariesFromMedia(db);
|
||||||
|
|
||||||
|
const globalRow = snapshotGlobal(db);
|
||||||
|
assert.equal(globalRow.total_sessions, 2, 'repair keeps the pruned session contribution');
|
||||||
|
assert.equal(globalRow.total_active_ms, 120_000);
|
||||||
|
assert.equal(globalRow.total_cards, 4);
|
||||||
|
assert.equal(globalRow.active_days, 2, 'repair never subtracts active days');
|
||||||
|
|
||||||
|
const animeRow = db
|
||||||
|
.prepare('SELECT total_sessions FROM imm_lifetime_anime WHERE anime_id = ?')
|
||||||
|
.get(animeId);
|
||||||
|
assert.equal(cleanRow<{ total_sessions: number }>(animeRow).total_sessions, 2);
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('repair leaves a caller-owned transaction intact when its begin fails', () => {
|
||||||
|
const db = createDb();
|
||||||
|
try {
|
||||||
|
db.exec('BEGIN');
|
||||||
|
const animeId = seedAnime(db, 'Caller Transaction', null);
|
||||||
|
|
||||||
|
assert.throws(() => repairLifetimeSummariesFromMedia(db), /transaction/i);
|
||||||
|
assert.ok(
|
||||||
|
db.prepare('SELECT 1 FROM imm_anime WHERE anime_id = ?').get(animeId),
|
||||||
|
'the repair did not roll back the caller transaction',
|
||||||
|
);
|
||||||
|
|
||||||
|
db.exec('ROLLBACK');
|
||||||
|
assert.equal(db.prepare('SELECT 1 FROM imm_anime WHERE anime_id = ?').get(animeId), undefined);
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import { Database } from '../sqlite.js';
|
||||||
|
import type { DatabaseSync } from '../sqlite.js';
|
||||||
|
import {
|
||||||
|
applyPragmas,
|
||||||
|
ensureSchema,
|
||||||
|
getOrCreateAnimeRecord,
|
||||||
|
getOrCreateVideoRecord,
|
||||||
|
linkVideoToAnimeRecord,
|
||||||
|
} from '../storage.js';
|
||||||
|
import { startSessionRecord } from '../session.js';
|
||||||
|
import { toDbTimestamp } from '../query-shared.js';
|
||||||
|
|
||||||
|
const SOURCE_TYPE_LOCAL = 1;
|
||||||
|
export const DAY_MS = 86_400_000;
|
||||||
|
// Noon UTC keeps every seeded timestamp on the same local day regardless of
|
||||||
|
// the timezone the test host runs in.
|
||||||
|
export const BASE_MS = Date.UTC(2026, 0, 5, 12, 0, 0);
|
||||||
|
|
||||||
|
export function createDb(): DatabaseSync {
|
||||||
|
const db = new Database(':memory:');
|
||||||
|
applyPragmas(db);
|
||||||
|
ensureSchema(db);
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function seedAnime(db: DatabaseSync, title: string, episodesTotal: number | null): number {
|
||||||
|
const animeId = getOrCreateAnimeRecord(db, {
|
||||||
|
parsedTitle: title,
|
||||||
|
canonicalTitle: title,
|
||||||
|
anilistId: null,
|
||||||
|
titleRomaji: null,
|
||||||
|
titleEnglish: null,
|
||||||
|
titleNative: null,
|
||||||
|
metadataJson: null,
|
||||||
|
});
|
||||||
|
if (episodesTotal !== null) {
|
||||||
|
db.prepare('UPDATE imm_anime SET episodes_total = ? WHERE anime_id = ?').run(
|
||||||
|
episodesTotal,
|
||||||
|
animeId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return animeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function seedVideo(
|
||||||
|
db: DatabaseSync,
|
||||||
|
animeId: number | null,
|
||||||
|
name: string,
|
||||||
|
options: { watched?: boolean } = {},
|
||||||
|
): number {
|
||||||
|
const videoId = getOrCreateVideoRecord(db, `local:/tmp/${name}.mkv`, {
|
||||||
|
canonicalTitle: name,
|
||||||
|
sourcePath: `/tmp/${name}.mkv`,
|
||||||
|
sourceUrl: null,
|
||||||
|
sourceType: SOURCE_TYPE_LOCAL,
|
||||||
|
});
|
||||||
|
if (animeId !== null) {
|
||||||
|
linkVideoToAnimeRecord(db, videoId, {
|
||||||
|
animeId,
|
||||||
|
parsedBasename: `${name}.mkv`,
|
||||||
|
parsedTitle: name,
|
||||||
|
parsedSeason: 1,
|
||||||
|
parsedEpisode: 1,
|
||||||
|
parserSource: 'test',
|
||||||
|
parserConfidence: 1,
|
||||||
|
parseMetadataJson: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (options.watched) {
|
||||||
|
db.prepare('UPDATE imm_videos SET watched = 1 WHERE video_id = ?').run(videoId);
|
||||||
|
}
|
||||||
|
return videoId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function seedEndedSession(
|
||||||
|
db: DatabaseSync,
|
||||||
|
videoId: number,
|
||||||
|
startedAtMs: number,
|
||||||
|
metrics: { activeMs: number; cards?: number; lines?: number; tokens?: number },
|
||||||
|
): number {
|
||||||
|
const sessionId = startSessionRecord(db, videoId, startedAtMs).sessionId;
|
||||||
|
db.prepare(
|
||||||
|
`
|
||||||
|
UPDATE imm_sessions SET
|
||||||
|
ended_at_ms = ?,
|
||||||
|
active_watched_ms = ?,
|
||||||
|
total_watched_ms = ?,
|
||||||
|
cards_mined = ?,
|
||||||
|
lines_seen = ?,
|
||||||
|
tokens_seen = ?
|
||||||
|
WHERE session_id = ?
|
||||||
|
`,
|
||||||
|
).run(
|
||||||
|
toDbTimestamp(startedAtMs + metrics.activeMs),
|
||||||
|
metrics.activeMs,
|
||||||
|
metrics.activeMs,
|
||||||
|
metrics.cards ?? 0,
|
||||||
|
metrics.lines ?? 0,
|
||||||
|
metrics.tokens ?? 0,
|
||||||
|
sessionId,
|
||||||
|
);
|
||||||
|
return sessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// libsql attaches a per-query `_metadata` property to result rows; strip it so
|
||||||
|
// row snapshots can be compared with deepEqual.
|
||||||
|
export function cleanRow<T>(row: unknown): T {
|
||||||
|
const { _metadata: _ignored, ...rest } = row as Record<string, unknown>;
|
||||||
|
return rest as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GlobalSnapshot {
|
||||||
|
total_sessions: number;
|
||||||
|
total_active_ms: number;
|
||||||
|
total_cards: number;
|
||||||
|
active_days: number;
|
||||||
|
episodes_started: number;
|
||||||
|
episodes_completed: number;
|
||||||
|
anime_completed: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function snapshotGlobal(db: DatabaseSync): GlobalSnapshot {
|
||||||
|
const row = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT total_sessions, total_active_ms, total_cards, active_days,
|
||||||
|
episodes_started, episodes_completed, anime_completed
|
||||||
|
FROM imm_lifetime_global WHERE global_id = 1`,
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
return cleanRow<GlobalSnapshot>(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function snapshotMedia(db: DatabaseSync): unknown[] {
|
||||||
|
return db
|
||||||
|
.prepare(
|
||||||
|
`SELECT video_id, total_sessions, total_active_ms, total_cards,
|
||||||
|
total_lines_seen, total_tokens_seen, completed,
|
||||||
|
CAST(first_watched_ms AS REAL) AS first_watched,
|
||||||
|
CAST(last_watched_ms AS REAL) AS last_watched
|
||||||
|
FROM imm_lifetime_media ORDER BY video_id`,
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
.map((row) => cleanRow(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function snapshotAnime(db: DatabaseSync): unknown[] {
|
||||||
|
return db
|
||||||
|
.prepare(
|
||||||
|
`SELECT anime_id, total_sessions, total_active_ms, total_cards,
|
||||||
|
total_lines_seen, total_tokens_seen, episodes_started, episodes_completed,
|
||||||
|
CAST(first_watched_ms AS REAL) AS first_watched,
|
||||||
|
CAST(last_watched_ms AS REAL) AS last_watched
|
||||||
|
FROM imm_lifetime_anime ORDER BY anime_id`,
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
.map((row) => cleanRow(row));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user