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) <noreply@anthropic.com>
This commit is contained in:
parent
59a8912fba
commit
f156d8bdcf
88
src/main/domain/chunk-index-store.test.ts
Normal file
88
src/main/domain/chunk-index-store.test.ts
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
93
src/main/domain/chunk-index-store.ts
Normal file
93
src/main/domain/chunk-index-store.ts
Normal file
@ -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<ChunkRow>(
|
||||
'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<ChunkRow>(
|
||||
'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<ChunkRow>(
|
||||
'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;
|
||||
},
|
||||
};
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user