Files
Shade/packages/shade-storage-sqlite/src/sqlite-blob-store.ts

157 lines
4.9 KiB
TypeScript
Raw Normal View History

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<Database['prepare']>;
insert: ReturnType<Database['prepare']>;
update: ReturnType<Database['prepare']>;
delete: ReturnType<Database['prepare']>;
maxEtag: ReturnType<Database['prepare']>;
};
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<BlobSlotRecord | null> {
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<PutBlobResult> {
// 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<boolean> {
const result = this.stmts.delete.run(slotId);
return result.changes > 0;
}
}