/** * The SQLite vault store, with persistence as the headline property. * * This exists because of 2026-08-12: the blob store fell back to memory when * its path was unset, worked perfectly, and lost every profile on the next * container recreate — with no error anywhere. A backup store that forgets is * worse than none, so "survives a close and reopen" is tested directly rather * than assumed from the fact that SQLite is involved. */ import { describe, test, expect, afterEach } from 'bun:test'; import { unlinkSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { SqliteVaultStore } from '../src/sqlite-vault-store.js'; const paths: string[] = []; function scratch(name: string): string { const p = join(tmpdir(), `shade-vault-${name}-${process.pid}-${Math.random().toString(36).slice(2)}.db`); paths.push(p); return p; } afterEach(() => { for (const p of paths.splice(0)) { for (const suffix of ['', '-wal', '-shm']) { try { unlinkSync(p + suffix); } catch { // Not every WAL sidecar exists; absence is fine. } } } }); const VAULT = 'a'.repeat(64); const HASH = 'b'.repeat(64); describe('persistence', () => { test('objects, owner and log survive a close and reopen', async () => { const path = scratch('reopen'); const first = new SqliteVaultStore(path); await first.setOwner(VAULT, new Uint8Array([1, 2, 3, 4])); await first.putObject(VAULT, HASH, new Uint8Array([9, 8, 7])); await first.appendLog(VAULT, { seq: 1, manifest: HASH, at: 1700, bytes: 3 }); first.close(); const second = new SqliteVaultStore(path); expect(await second.getOwner(VAULT)).toEqual(new Uint8Array([1, 2, 3, 4])); expect(await second.getObject(VAULT, HASH)).toEqual(new Uint8Array([9, 8, 7])); expect(await second.head(VAULT)).toBe(1); expect((await second.log(VAULT))[0]!.manifest).toBe(HASH); second.close(); }); test('binary content round-trips without base64 mangling', async () => { // Objects are ciphertext: every byte value occurs, including 0x00. const path = scratch('binary'); const store = new SqliteVaultStore(path); const bytes = new Uint8Array(256); for (let i = 0; i < 256; i++) bytes[i] = i; await store.putObject(VAULT, HASH, bytes); store.close(); const reopened = new SqliteVaultStore(path); expect(await reopened.getObject(VAULT, HASH)).toEqual(bytes); reopened.close(); }); }); describe('semantics', () => { test('an empty vault has head 0 and no owner', async () => { const store = new SqliteVaultStore(scratch('empty')); expect(await store.head(VAULT)).toBe(0); expect(await store.getOwner(VAULT)).toBeNull(); expect(await store.log(VAULT)).toEqual([]); expect(await store.usage(VAULT)).toBe(0); store.close(); }); test('re-putting the same hash is a no-op, not a duplicate', async () => { // The name IS the content hash, so a repeat is the same bytes by // definition. Usage must not double. const store = new SqliteVaultStore(scratch('dupe')); await store.putObject(VAULT, HASH, new Uint8Array(100)); await store.putObject(VAULT, HASH, new Uint8Array(100)); expect(await store.usage(VAULT)).toBe(100); store.close(); }); test('vaults are isolated from each other', async () => { const store = new SqliteVaultStore(scratch('isolated')); const other = 'c'.repeat(64); await store.putObject(VAULT, HASH, new Uint8Array([1])); expect(await store.hasObject(other, HASH)).toBe(false); expect(await store.getObject(other, HASH)).toBeNull(); expect(await store.usage(other)).toBe(0); store.close(); }); test('the log is ordered oldest-first and limit counts back from the head', async () => { const store = new SqliteVaultStore(scratch('log')); for (let seq = 1; seq <= 5; seq++) { await store.appendLog(VAULT, { seq, manifest: `${seq}`.repeat(64), at: seq, bytes: seq }); } expect((await store.log(VAULT)).map((e) => e.seq)).toEqual([1, 2, 3, 4, 5]); // A client asking for the last two wants 4 and 5, still in order. expect((await store.log(VAULT, 2)).map((e) => e.seq)).toEqual([4, 5]); expect(await store.head(VAULT)).toBe(5); store.close(); }); test('a duplicate seq is rejected by the primary key', async () => { // The route layer refuses this first, but the store is the last line: // two rows with the same seq would make history ambiguous. const store = new SqliteVaultStore(scratch('seq')); await store.appendLog(VAULT, { seq: 1, manifest: HASH, at: 1, bytes: 1 }); expect(store.appendLog(VAULT, { seq: 1, manifest: HASH, at: 2, bytes: 1 })).rejects.toThrow(); store.close(); }); });