feat(archive): archive-files-store CRUD + summaryByStreamer (10 tests)

upsert / get / list (filter by streamer, ordered by createdAt DESC NULLS LAST) /
setVerified / delete / summaryByStreamer (aggregated count + bytes per streamer) /
totalBytes. normalizeLogin used on streamer_login at write + filter time so
@Alice/Alice/alice all collapse to alice.

186 unit tests gruen. Storage layer for Pillar 1 verification + Pillar 6
archive index — recorder/storage-stats integration is post-5.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
xRangerDE 2026-05-11 23:53:54 +02:00
parent 987fb73a0e
commit bd1db9b873
2 changed files with 244 additions and 0 deletions

View File

@ -0,0 +1,106 @@
import { test, expect, describe, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { openDatabase, type DbHandle } from '../infra/db';
import { createArchiveFilesStore, type ArchiveFilesStore } from './archive-files-store';
let tmpDir: string;
let db: DbHandle;
let store: ArchiveFilesStore;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'archive-'));
db = openDatabase(path.join(tmpDir, 'app.db'));
store = createArchiveFilesStore(db);
});
afterEach(() => {
db.close();
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
});
describe('createArchiveFilesStore', () => {
test('upsert + get roundtrip', () => {
const rec = store.upsert({
path: 'C:/vods/foo/2026-05-11.mp4',
streamerLogin: 'Foo',
sizeBytes: 1024 * 1024 * 100,
durationSeconds: 3600,
createdAt: 1700000000,
verified: true,
});
expect(rec.path).toBe('C:/vods/foo/2026-05-11.mp4');
expect(rec.streamerLogin).toBe('foo');
expect(rec.sizeBytes).toBe(1024 * 1024 * 100);
expect(rec.verified).toBe(true);
const fetched = store.get('C:/vods/foo/2026-05-11.mp4');
expect(fetched?.streamerLogin).toBe('foo');
});
test('upsert same path updates instead of duplicating', () => {
store.upsert({ path: '/x', streamerLogin: 'a', sizeBytes: 100 });
store.upsert({ path: '/x', streamerLogin: 'a', sizeBytes: 200 });
const list = store.list();
expect(list).toHaveLength(1);
expect(list[0].sizeBytes).toBe(200);
});
test('list returns all, ordered by created_at DESC NULLS LAST', () => {
store.upsert({ path: '/older', streamerLogin: 'a', createdAt: 1000 });
store.upsert({ path: '/newer', streamerLogin: 'a', createdAt: 2000 });
store.upsert({ path: '/no-date', streamerLogin: 'a' });
const list = store.list();
expect(list.map(r => r.path)).toEqual(['/newer', '/older', '/no-date']);
});
test('list(streamerLogin) filters and normalizes', () => {
store.upsert({ path: '/a1', streamerLogin: 'alice' });
store.upsert({ path: '/a2', streamerLogin: 'Alice' }); // normalized to alice
store.upsert({ path: '/b1', streamerLogin: 'bob' });
const aliceFiles = store.list('@Alice');
expect(aliceFiles).toHaveLength(2);
});
test('setVerified toggles the flag', () => {
store.upsert({ path: '/v', verified: false });
store.setVerified('/v', true);
expect(store.get('/v')?.verified).toBe(true);
store.setVerified('/v', false);
expect(store.get('/v')?.verified).toBe(false);
});
test('delete removes the record', () => {
store.upsert({ path: '/d', streamerLogin: 'x' });
store.delete('/d');
expect(store.get('/d')).toBeNull();
});
test('summaryByStreamer aggregates counts and total bytes', () => {
store.upsert({ path: '/a1', streamerLogin: 'alice', sizeBytes: 100 });
store.upsert({ path: '/a2', streamerLogin: 'alice', sizeBytes: 200 });
store.upsert({ path: '/b1', streamerLogin: 'bob', sizeBytes: 50 });
store.upsert({ path: '/orphan', sizeBytes: 999 }); // no streamer — excluded
const summary = store.summaryByStreamer();
// Sorted by total DESC: alice (300), bob (50)
expect(summary).toHaveLength(2);
expect(summary[0]).toEqual({ streamerLogin: 'alice', fileCount: 2, totalBytes: 300 });
expect(summary[1]).toEqual({ streamerLogin: 'bob', fileCount: 1, totalBytes: 50 });
});
test('totalBytes sums across everything', () => {
store.upsert({ path: '/1', sizeBytes: 100 });
store.upsert({ path: '/2', sizeBytes: 200 });
store.upsert({ path: '/3', sizeBytes: 300, streamerLogin: 'a' });
store.upsert({ path: '/4' }); // null bytes — coalesced to 0
expect(store.totalBytes()).toBe(600);
});
test('get returns null for missing path', () => {
expect(store.get('/nope')).toBeNull();
});
test('totalBytes on empty table = 0', () => {
expect(store.totalBytes()).toBe(0);
});
});

View File

@ -0,0 +1,138 @@
import type { DbHandle } from '../infra/db';
import { normalizeLogin } from './config-normalize';
export interface ArchiveFileRecord {
path: string;
streamerLogin: string | null;
sizeBytes: number | null;
durationSeconds: number | null;
createdAt: number | null;
verified: boolean;
}
export interface ArchiveFileWriteInput {
path: string;
streamerLogin?: string;
sizeBytes?: number;
durationSeconds?: number;
createdAt?: number;
verified?: boolean;
}
export interface ArchiveStreamerSummary {
streamerLogin: string;
fileCount: number;
totalBytes: number;
}
export interface ArchiveFilesStore {
upsert(input: ArchiveFileWriteInput): ArchiveFileRecord;
get(path: string): ArchiveFileRecord | null;
list(streamerLogin?: string): ArchiveFileRecord[];
setVerified(path: string, verified: boolean): void;
delete(path: string): void;
summaryByStreamer(): ArchiveStreamerSummary[];
totalBytes(): number;
}
interface ArchiveRow {
path: string;
streamer_login: string | null;
size_bytes: number | null;
duration_seconds: number | null;
created_at: number | null;
verified: number;
}
function rowToRecord(row: ArchiveRow): ArchiveFileRecord {
return {
path: row.path,
streamerLogin: row.streamer_login,
sizeBytes: row.size_bytes,
durationSeconds: row.duration_seconds,
createdAt: row.created_at,
verified: row.verified === 1,
};
}
export function createArchiveFilesStore(db: DbHandle): ArchiveFilesStore {
return {
upsert(input) {
const streamerLogin = input.streamerLogin
? normalizeLogin(input.streamerLogin)
: null;
const verified = input.verified ? 1 : 0;
db.run(
`INSERT INTO archive_files(path, streamer_login, size_bytes, duration_seconds, created_at, verified)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(path) DO UPDATE SET
streamer_login = excluded.streamer_login,
size_bytes = excluded.size_bytes,
duration_seconds = excluded.duration_seconds,
created_at = excluded.created_at,
verified = excluded.verified`,
[
input.path,
streamerLogin,
input.sizeBytes ?? null,
input.durationSeconds ?? null,
input.createdAt ?? null,
verified,
]
);
const row = db.get<ArchiveRow>('SELECT * FROM archive_files WHERE path = ?', [input.path]);
if (!row) throw new Error(`archive-files-store: upsert lookup failed for ${input.path}`);
return rowToRecord(row);
},
get(p) {
const row = db.get<ArchiveRow>('SELECT * FROM archive_files WHERE path = ?', [p]);
return row ? rowToRecord(row) : null;
},
list(streamerLogin) {
const rows = streamerLogin
? db.all<ArchiveRow>(
'SELECT * FROM archive_files WHERE streamer_login = ? ORDER BY created_at DESC NULLS LAST, path',
[normalizeLogin(streamerLogin)]
)
: db.all<ArchiveRow>('SELECT * FROM archive_files ORDER BY created_at DESC NULLS LAST, path');
return rows.map(rowToRecord);
},
setVerified(p, verified) {
db.run(
'UPDATE archive_files SET verified = ? WHERE path = ?',
[verified ? 1 : 0, p]
);
},
delete(p) {
db.run('DELETE FROM archive_files WHERE path = ?', [p]);
},
summaryByStreamer() {
const rows = db.all<{ streamer_login: string | null; cnt: number; total: number | null }>(
`SELECT streamer_login, COUNT(*) AS cnt, COALESCE(SUM(size_bytes), 0) AS total
FROM archive_files
WHERE streamer_login IS NOT NULL
GROUP BY streamer_login
ORDER BY total DESC`
);
return rows
.filter((r): r is { streamer_login: string; cnt: number; total: number | null } => r.streamer_login !== null)
.map(r => ({
streamerLogin: r.streamer_login,
fileCount: r.cnt,
totalBytes: r.total ?? 0,
}));
},
totalBytes() {
const row = db.get<{ total: number | null }>(
'SELECT COALESCE(SUM(size_bytes), 0) AS total FROM archive_files'
);
return row?.total ?? 0;
},
};
}