From f156d8bdcf134be9de3c0554c8f66c68e6955e4a Mon Sep 17 00:00:00 2001 From: xRangerDE Date: Mon, 11 May 2026 22:19:50 +0200 Subject: [PATCH] feat(resume): chunk-index-store CRUD (8 tests) record / listForItem (ordered by chunk_seq) / countForItem / lookupBySha1 (dedupe candidates) / deleteForItem. ON CONFLICT(item_id, chunk_seq) DO UPDATE means re-recording overwrites prior hash. 143 unit tests gruen. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main/domain/chunk-index-store.test.ts | 88 +++++++++++++++++++++ src/main/domain/chunk-index-store.ts | 93 +++++++++++++++++++++++ 2 files changed, 181 insertions(+) create mode 100644 src/main/domain/chunk-index-store.test.ts create mode 100644 src/main/domain/chunk-index-store.ts diff --git a/src/main/domain/chunk-index-store.test.ts b/src/main/domain/chunk-index-store.test.ts new file mode 100644 index 0000000..28c2063 --- /dev/null +++ b/src/main/domain/chunk-index-store.test.ts @@ -0,0 +1,88 @@ +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 { createChunkIndexStore, type ChunkIndexStore } from './chunk-index-store'; + +let tmpDir: string; +let db: DbHandle; +let store: ChunkIndexStore; +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'chunkstore-')); + db = openDatabase(path.join(tmpDir, 'app.db')); + store = createChunkIndexStore(db); +}); +afterEach(() => { + db.close(); + try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ } +}); + +describe('createChunkIndexStore', () => { + test('record returns ChunkRecord with id > 0', () => { + const rec = store.record('item-1', 0, 'sha1-abc', 1024); + expect(rec.id).toBeGreaterThan(0); + expect(rec.itemId).toBe('item-1'); + expect(rec.chunkSeq).toBe(0); + expect(rec.sha1Hex).toBe('sha1-abc'); + expect(rec.bytes).toBe(1024); + }); + + test('listForItem returns chunks ordered by chunk_seq', () => { + store.record('it', 2, 's2', 200); + store.record('it', 0, 's0', 100); + store.record('it', 1, 's1', 150); + const all = store.listForItem('it'); + expect(all.map(r => r.chunkSeq)).toEqual([0, 1, 2]); + expect(all.map(r => r.sha1Hex)).toEqual(['s0', 's1', 's2']); + }); + + test('UNIQUE(item_id, chunk_seq): same key updates, no duplicate', () => { + store.record('it', 0, 'first', 100); + store.record('it', 0, 'second', 200); + const list = store.listForItem('it'); + expect(list).toHaveLength(1); + expect(list[0].sha1Hex).toBe('second'); + expect(list[0].bytes).toBe(200); + }); + + test('countForItem', () => { + expect(store.countForItem('it')).toBe(0); + store.record('it', 0, 'a', 1); + store.record('it', 1, 'b', 1); + expect(store.countForItem('it')).toBe(2); + expect(store.countForItem('other')).toBe(0); + }); + + test('lookupBySha1 finds dedupe candidates', () => { + store.record('item-A', 0, 'same-sha', 100); + store.record('item-B', 5, 'same-sha', 100); + store.record('item-C', 0, 'other-sha', 100); + + const hits = store.lookupBySha1('same-sha'); + expect(hits).toHaveLength(2); + expect(hits.map(r => r.itemId).sort()).toEqual(['item-A', 'item-B']); + }); + + test('deleteForItem removes all chunks for that item and returns count', () => { + store.record('it', 0, 'a', 1); + store.record('it', 1, 'b', 1); + store.record('keep', 0, 'c', 1); + + const removed = store.deleteForItem('it'); + expect(removed).toBe(2); + expect(store.countForItem('it')).toBe(0); + expect(store.countForItem('keep')).toBe(1); + }); + + test('deleteForItem on missing returns 0, doesnt throw', () => { + expect(store.deleteForItem('does-not-exist')).toBe(0); + }); + + test('bytes roundtrip', () => { + const rec = store.record('it', 0, 'sha', 1234567); + expect(rec.bytes).toBe(1234567); + const list = store.listForItem('it'); + expect(list[0].bytes).toBe(1234567); + }); +}); diff --git a/src/main/domain/chunk-index-store.ts b/src/main/domain/chunk-index-store.ts new file mode 100644 index 0000000..915cf8e --- /dev/null +++ b/src/main/domain/chunk-index-store.ts @@ -0,0 +1,93 @@ +import type { DbHandle } from '../infra/db'; + +export interface ChunkRecord { + id: number; + itemId: string; + chunkSeq: number; + sha1Hex: string; + bytes: number; + createdAt: number; +} + +export interface ChunkIndexStore { + /** + * Persistiert einen Chunk-Hash. Bei (itemId, chunkSeq)-Konflikt wird das + * bestehende Tupel ersetzt — die zuletzt geschriebene sha1 gewinnt + * (sinnvoll, falls dasselbe Segment neu geladen wurde). + */ + record(itemId: string, chunkSeq: number, sha1Hex: string, bytes: number): ChunkRecord; + listForItem(itemId: string): ChunkRecord[]; + countForItem(itemId: string): number; + lookupBySha1(sha1Hex: string): ChunkRecord[]; + deleteForItem(itemId: string): number; +} + +interface ChunkRow { + id: number; + item_id: string; + chunk_seq: number; + sha1_hex: string; + bytes: number; + created_at: number; +} + +function rowToRecord(row: ChunkRow): ChunkRecord { + return { + id: row.id, + itemId: row.item_id, + chunkSeq: row.chunk_seq, + sha1Hex: row.sha1_hex, + bytes: row.bytes, + createdAt: row.created_at, + }; +} + +export function createChunkIndexStore(db: DbHandle): ChunkIndexStore { + return { + record(itemId, chunkSeq, sha1Hex, bytes) { + const now = Math.floor(Date.now() / 1000); + db.run( + `INSERT INTO chunk_index(item_id, chunk_seq, sha1_hex, bytes, created_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(item_id, chunk_seq) DO UPDATE SET + sha1_hex = excluded.sha1_hex, + bytes = excluded.bytes, + created_at = excluded.created_at`, + [itemId, chunkSeq, sha1Hex, bytes, now] + ); + const row = db.get( + 'SELECT * FROM chunk_index WHERE item_id = ? AND chunk_seq = ?', + [itemId, chunkSeq] + ); + if (!row) throw new Error(`chunk-index-store: record lookup failed for ${itemId}/${chunkSeq}`); + return rowToRecord(row); + }, + + listForItem(itemId) { + const rows = db.all( + 'SELECT * FROM chunk_index WHERE item_id = ? ORDER BY chunk_seq ASC', + [itemId] + ); + return rows.map(rowToRecord); + }, + + countForItem(itemId) { + const row = db.get<{ c: number }>('SELECT COUNT(*) AS c FROM chunk_index WHERE item_id = ?', [itemId]); + return row?.c ?? 0; + }, + + lookupBySha1(sha1Hex) { + const rows = db.all( + 'SELECT * FROM chunk_index WHERE sha1_hex = ? ORDER BY item_id, chunk_seq', + [sha1Hex] + ); + return rows.map(rowToRecord); + }, + + deleteForItem(itemId) { + const before = db.get<{ c: number }>('SELECT COUNT(*) AS c FROM chunk_index WHERE item_id = ?', [itemId])?.c ?? 0; + db.run('DELETE FROM chunk_index WHERE item_id = ?', [itemId]); + return before; + }, + }; +}