86 lines
2.9 KiB
TypeScript
86 lines
2.9 KiB
TypeScript
|
|
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);
|
||
|
|
}
|
||
|
|
}
|