import { Database } from 'bun:sqlite'; import type { VaultLogEntry, VaultStore } from '@shade/vault'; /** * SQLite-backed VaultStore for the V4.13 encrypted file store. * * Three tables, mirroring the model: an owner key per vault, the * content-addressed objects, and the append-only manifest log. The relay * never decrypts anything — it enforces auth, verifies that an object's bytes * hash to its name, and refuses a commit whose objects are not all present. * * Objects are stored as BLOBs rather than base64 text. A workspace backup is * a few hundred files where the blob primitive holds one small profile, so the * 33% base64 overhead stops being a rounding error. * * Docker usage: set `SHADE_VAULT_DB_PATH` (falls back to `/data/shade-vault.db`). * **Set it.** The blob store's equivalent was missing from `docker-compose.yml` * for months and nobody noticed, because the in-memory fallback works * perfectly until the container is recreated — at which point everything is * gone, with no error anywhere. That happened on 2026-08-12. */ export class SqliteVaultStore implements VaultStore { private db: Database; private stmts!: { getOwner: ReturnType; setOwner: ReturnType; hasObject: ReturnType; putObject: ReturnType; getObject: ReturnType; head: ReturnType; log: ReturnType; logLimit: ReturnType; appendLog: ReturnType; usage: ReturnType; }; constructor(dbPath?: string) { const path = dbPath ?? process.env.SHADE_VAULT_DB_PATH ?? '/data/shade-vault.db'; this.db = new Database(path, { create: true }); this.db.exec('PRAGMA journal_mode=WAL'); this.ensureTables(); this.prepareStatements(); } private ensureTables() { this.db.exec(` CREATE TABLE IF NOT EXISTS shade_vault_owners ( vault_id TEXT PRIMARY KEY, owner_pubkey BLOB NOT NULL, created_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS shade_vault_objects ( vault_id TEXT NOT NULL, hash TEXT NOT NULL, bytes BLOB NOT NULL, size INTEGER NOT NULL, created_at INTEGER NOT NULL, PRIMARY KEY (vault_id, hash) ); CREATE TABLE IF NOT EXISTS shade_vault_log ( vault_id TEXT NOT NULL, seq INTEGER NOT NULL, manifest TEXT NOT NULL, at INTEGER NOT NULL, bytes INTEGER NOT NULL, PRIMARY KEY (vault_id, seq) ); `); } private prepareStatements() { this.stmts = { getOwner: this.db.prepare('SELECT owner_pubkey FROM shade_vault_owners WHERE vault_id = ?'), setOwner: this.db.prepare( 'INSERT OR REPLACE INTO shade_vault_owners (vault_id, owner_pubkey, created_at) VALUES (?, ?, ?)', ), hasObject: this.db.prepare( 'SELECT 1 FROM shade_vault_objects WHERE vault_id = ? AND hash = ? LIMIT 1', ), // An object's name IS its content hash, so a repeat upload is the same // bytes by definition — ignoring it is correct, not lossy. putObject: this.db.prepare( 'INSERT OR IGNORE INTO shade_vault_objects (vault_id, hash, bytes, size, created_at) VALUES (?, ?, ?, ?, ?)', ), getObject: this.db.prepare( 'SELECT bytes FROM shade_vault_objects WHERE vault_id = ? AND hash = ?', ), head: this.db.prepare('SELECT MAX(seq) AS head FROM shade_vault_log WHERE vault_id = ?'), log: this.db.prepare( 'SELECT seq, manifest, at, bytes FROM shade_vault_log WHERE vault_id = ? ORDER BY seq ASC', ), logLimit: this.db.prepare( 'SELECT seq, manifest, at, bytes FROM (SELECT seq, manifest, at, bytes FROM shade_vault_log WHERE vault_id = ? ORDER BY seq DESC LIMIT ?) ORDER BY seq ASC', ), appendLog: this.db.prepare( 'INSERT INTO shade_vault_log (vault_id, seq, manifest, at, bytes) VALUES (?, ?, ?, ?, ?)', ), usage: this.db.prepare( 'SELECT COALESCE(SUM(size), 0) AS total FROM shade_vault_objects WHERE vault_id = ?', ), }; } async getOwner(vaultId: string): Promise { const row = this.stmts.getOwner.get(vaultId) as { owner_pubkey: Uint8Array } | null; return row ? new Uint8Array(row.owner_pubkey) : null; } async setOwner(vaultId: string, publicKey: Uint8Array): Promise { this.stmts.setOwner.run(vaultId, publicKey, Date.now()); } async hasObject(vaultId: string, hash: string): Promise { return this.stmts.hasObject.get(vaultId, hash) !== null; } async putObject(vaultId: string, hash: string, bytes: Uint8Array): Promise { this.stmts.putObject.run(vaultId, hash, bytes, bytes.length, Date.now()); } async getObject(vaultId: string, hash: string): Promise { const row = this.stmts.getObject.get(vaultId, hash) as { bytes: Uint8Array } | null; return row ? new Uint8Array(row.bytes) : null; } async head(vaultId: string): Promise { const row = this.stmts.head.get(vaultId) as { head: number | null } | null; return row?.head ?? 0; } async log(vaultId: string, limit?: number): Promise { const rows = ( limit === undefined ? this.stmts.log.all(vaultId) : this.stmts.logLimit.all(vaultId, limit) ) as VaultLogEntry[]; return rows; } async appendLog(vaultId: string, entry: VaultLogEntry): Promise { this.stmts.appendLog.run(vaultId, entry.seq, entry.manifest, entry.at, entry.bytes); } async usage(vaultId: string): Promise { const row = this.stmts.usage.get(vaultId) as { total: number } | null; return row?.total ?? 0; } close(): void { this.db.close(); } }