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:
parent
987fb73a0e
commit
bd1db9b873
106
src/main/domain/archive-files-store.test.ts
Normal file
106
src/main/domain/archive-files-store.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
138
src/main/domain/archive-files-store.ts
Normal file
138
src/main/domain/archive-files-store.ts
Normal 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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user