import { ConflictError, PreconditionFailedError } from '@shade/core'; import type { BlobStore, BlobSlotRecord, PutBlobResult } from './blob-store.js'; /** * In-memory BlobStore — used in tests and as the default fallback when * no SQLite/Postgres URL is configured. Rows are kept in a single Map. * * Etag is a strictly-monotonic per-process counter — guarantees a total * order across writes even when many land in the same millisecond. (We * could scope it per-slot, but a global counter keeps the implementation * trivial and the etag values still uniquely identify the write that * produced them, which is all CAS needs.) */ export class MemoryBlobStore implements BlobStore { private slots = new Map(); private nextEtag = 0; async get(slotId: string): Promise { const r = this.slots.get(slotId); if (!r) return null; return { slotId: r.slotId, blob: new Uint8Array(r.blob), ownerPubkey: new Uint8Array(r.ownerPubkey), etag: r.etag, updatedAt: r.updatedAt, }; } async put(args: { slotId: string; blob: Uint8Array; ownerPubkey: Uint8Array; expectedEtag: number | '*' | undefined; now: number; }): Promise { const existing = this.slots.get(args.slotId); if (!existing) { // Empty slot. `ifMatch: '*'` per RFC 7232 still fails — there is // no entity to match. A numeric etag also fails (we have nothing // to compare against). if (args.expectedEtag !== undefined) { throw new PreconditionFailedError( `Slot ${args.slotId} does not exist; cannot match ifMatch=${String(args.expectedEtag)}`, ); } this.nextEtag = Math.max(this.nextEtag + 1, args.now); const etag = this.nextEtag; this.slots.set(args.slotId, { slotId: args.slotId, blob: new Uint8Array(args.blob), ownerPubkey: new Uint8Array(args.ownerPubkey), etag, updatedAt: args.now, }); return { created: true, etag, updatedAt: args.now }; } // Slot exists. Pubkey check is the route layer's job — by the time // we're here the signature has already been verified against // `existing.ownerPubkey`. 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.nextEtag = Math.max(this.nextEtag + 1, args.now); const etag = this.nextEtag; existing.blob = new Uint8Array(args.blob); existing.etag = etag; existing.updatedAt = args.now; return { created: false, etag, updatedAt: args.now }; } async delete(slotId: string): Promise { return this.slots.delete(slotId); } }