import { Database } from 'bun:sqlite'; import { ConflictError, PreconditionFailedError, toBase64, fromBase64, } from '@shade/core'; import type { BlobStore, BlobSlotRecord, PutBlobResult } from '@shade/inbox-server'; /** * SQLite-backed BlobStore for the V4.9 encrypted-blob primitive. * * Single-table layout: each slot is a row keyed on the 64-hex slotId, * with the AEAD ciphertext base64-encoded inline. The relay never * decrypts the blob — it only enforces auth + CAS. ETag is a * monotonic per-process counter clamped against `Date.now()` so the * value is unique across writes and useful as a wall-clock hint. * * Docker usage: same volume as the inbox DB by convention; set * `SHADE_BLOB_DB_PATH` (falls back to `/data/shade-blob.db`). */ export class SqliteBlobStore implements BlobStore { private db: Database; private stmts!: { get: ReturnType; insert: ReturnType; update: ReturnType; delete: ReturnType; maxEtag: ReturnType; }; private seq = 0; constructor(dbPath?: string) { const path = dbPath ?? process.env.SHADE_BLOB_DB_PATH ?? '/data/shade-blob.db'; this.db = new Database(path, { create: true }); this.db.exec('PRAGMA journal_mode=WAL'); this.ensureTables(); this.prepareStatements(); this.bootstrapSeq(); } private ensureTables() { this.db.exec(` CREATE TABLE IF NOT EXISTS shade_blob_slots ( slot_id TEXT PRIMARY KEY, owner_pubkey TEXT NOT NULL, blob TEXT NOT NULL, etag INTEGER NOT NULL, updated_at INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS idx_shade_blob_updated ON shade_blob_slots(updated_at); `); } private prepareStatements() { this.stmts = { get: this.db.prepare( 'SELECT slot_id, owner_pubkey, blob, etag, updated_at FROM shade_blob_slots WHERE slot_id = ?', ), insert: this.db.prepare( 'INSERT INTO shade_blob_slots (slot_id, owner_pubkey, blob, etag, updated_at) VALUES (?, ?, ?, ?, ?)', ), update: this.db.prepare( 'UPDATE shade_blob_slots SET blob = ?, etag = ?, updated_at = ? WHERE slot_id = ?', ), delete: this.db.prepare('DELETE FROM shade_blob_slots WHERE slot_id = ?'), maxEtag: this.db.prepare('SELECT MAX(etag) AS max FROM shade_blob_slots'), }; } private bootstrapSeq() { const row = this.stmts.maxEtag.get() as { max: number | null }; this.seq = Math.max(row?.max ?? 0, Date.now()); } close() { this.db.close(); } async get(slotId: string): Promise { const row = this.stmts.get.get(slotId) as | { slot_id: string; owner_pubkey: string; blob: string; etag: number; updated_at: number; } | undefined; if (!row) return null; return { slotId: row.slot_id, ownerPubkey: fromBase64(row.owner_pubkey), blob: fromBase64(row.blob), etag: row.etag, updatedAt: row.updated_at, }; } async put(args: { slotId: string; blob: Uint8Array; ownerPubkey: Uint8Array; expectedEtag: number | '*' | undefined; now: number; }): Promise { // Read-then-write inside a write transaction so concurrent // CAS attempts can't both observe the same etag. const tx = this.db.transaction((): PutBlobResult => { const existing = this.stmts.get.get(args.slotId) as | { etag: number } | undefined; if (!existing) { if (args.expectedEtag !== undefined) { throw new PreconditionFailedError( `Slot ${args.slotId} does not exist; cannot match ifMatch=${String(args.expectedEtag)}`, ); } this.seq = Math.max(this.seq + 1, args.now); const etag = this.seq; this.stmts.insert.run( args.slotId, toBase64(args.ownerPubkey), toBase64(args.blob), etag, args.now, ); return { created: true, etag, updatedAt: args.now }; } if (args.expectedEtag === undefined) { throw new ConflictError( `Slot ${args.slotId} already exists; supply ifMatch to update`, ); } if (args.expectedEtag !== '*' && args.expectedEtag !== existing.etag) { throw new PreconditionFailedError( `Slot ${args.slotId} etag mismatch: expected=${args.expectedEtag} actual=${existing.etag}`, ); } this.seq = Math.max(this.seq + 1, args.now); const etag = this.seq; this.stmts.update.run(toBase64(args.blob), etag, args.now, args.slotId); return { created: false, etag, updatedAt: args.now }; }); return tx(); } async delete(slotId: string): Promise { const result = this.stmts.delete.run(slotId); return result.changes > 0; } }