155 lines
5.8 KiB
TypeScript
155 lines
5.8 KiB
TypeScript
|
|
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<Database['prepare']>;
|
||
|
|
setOwner: ReturnType<Database['prepare']>;
|
||
|
|
hasObject: ReturnType<Database['prepare']>;
|
||
|
|
putObject: ReturnType<Database['prepare']>;
|
||
|
|
getObject: ReturnType<Database['prepare']>;
|
||
|
|
head: ReturnType<Database['prepare']>;
|
||
|
|
log: ReturnType<Database['prepare']>;
|
||
|
|
logLimit: ReturnType<Database['prepare']>;
|
||
|
|
appendLog: ReturnType<Database['prepare']>;
|
||
|
|
usage: ReturnType<Database['prepare']>;
|
||
|
|
};
|
||
|
|
|
||
|
|
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<Uint8Array | null> {
|
||
|
|
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<void> {
|
||
|
|
this.stmts.setOwner.run(vaultId, publicKey, Date.now());
|
||
|
|
}
|
||
|
|
|
||
|
|
async hasObject(vaultId: string, hash: string): Promise<boolean> {
|
||
|
|
return this.stmts.hasObject.get(vaultId, hash) !== null;
|
||
|
|
}
|
||
|
|
|
||
|
|
async putObject(vaultId: string, hash: string, bytes: Uint8Array): Promise<void> {
|
||
|
|
this.stmts.putObject.run(vaultId, hash, bytes, bytes.length, Date.now());
|
||
|
|
}
|
||
|
|
|
||
|
|
async getObject(vaultId: string, hash: string): Promise<Uint8Array | null> {
|
||
|
|
const row = this.stmts.getObject.get(vaultId, hash) as { bytes: Uint8Array } | null;
|
||
|
|
return row ? new Uint8Array(row.bytes) : null;
|
||
|
|
}
|
||
|
|
|
||
|
|
async head(vaultId: string): Promise<number> {
|
||
|
|
const row = this.stmts.head.get(vaultId) as { head: number | null } | null;
|
||
|
|
return row?.head ?? 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
async log(vaultId: string, limit?: number): Promise<VaultLogEntry[]> {
|
||
|
|
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<void> {
|
||
|
|
this.stmts.appendLog.run(vaultId, entry.seq, entry.manifest, entry.at, entry.bytes);
|
||
|
|
}
|
||
|
|
|
||
|
|
async usage(vaultId: string): Promise<number> {
|
||
|
|
const row = this.stmts.usage.get(vaultId) as { total: number } | null;
|
||
|
|
return row?.total ?? 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
close(): void {
|
||
|
|
this.db.close();
|
||
|
|
}
|
||
|
|
}
|