121 lines
4.7 KiB
TypeScript
121 lines
4.7 KiB
TypeScript
|
|
/**
|
||
|
|
* 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();
|
||
|
|
});
|
||
|
|
});
|