Files
Shade/packages/shade-inbox-server/src/memory-blob-store.ts

86 lines
2.9 KiB
TypeScript
Raw Normal View History

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<string, BlobSlotRecord>();
private nextEtag = 0;
async get(slotId: string): Promise<BlobSlotRecord | null> {
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<PutBlobResult> {
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<boolean> {
return this.slots.delete(slotId);
}
}