152 lines
5.3 KiB
TypeScript
152 lines
5.3 KiB
TypeScript
import type { ParsedUri, UriPlugin } from "@suaveplan/storage-uri";
|
|
import type { StorageLike } from "@suaveplan/types";
|
|
|
|
/** Minimal in-memory `StorageLike` for tests. Key shape is opaque. */
|
|
export class MemStore implements StorageLike {
|
|
readonly #map = new Map<string, Uint8Array>();
|
|
public readOnly = false;
|
|
public failExists = false;
|
|
public failList = false;
|
|
public failGetWith: Error | undefined;
|
|
public delayGetMs = 0;
|
|
|
|
public seed(key: string, bytes: Uint8Array): void {
|
|
this.#map.set(key, bytes);
|
|
}
|
|
|
|
async get(key: string): Promise<Uint8Array | undefined> {
|
|
if (this.delayGetMs > 0) {
|
|
await new Promise((r) => setTimeout(r, this.delayGetMs));
|
|
}
|
|
if (this.failGetWith) throw this.failGetWith;
|
|
return this.#map.get(key);
|
|
}
|
|
|
|
async set(key: string, value: Uint8Array): Promise<void> {
|
|
if (this.readOnly) {
|
|
const err: Error & { code?: string } = new Error("read-only");
|
|
err.code = "STORAGE_URI.WRITE_UNSUPPORTED";
|
|
throw err;
|
|
}
|
|
this.#map.set(key, value);
|
|
}
|
|
|
|
async delete(key: string): Promise<boolean> {
|
|
return this.#map.delete(key);
|
|
}
|
|
|
|
async exists(key: string): Promise<boolean> {
|
|
if (this.failExists) return false;
|
|
return this.#map.has(key);
|
|
}
|
|
|
|
async list(opts?: { prefix?: string; limit?: number }): Promise<{
|
|
keys: string[];
|
|
total: number;
|
|
hasMore: boolean;
|
|
continuationToken?: string | undefined;
|
|
}> {
|
|
if (this.failList) throw new Error("list-failed");
|
|
const prefix = opts?.prefix ?? "";
|
|
const keys = [...this.#map.keys()].filter((k) => k.startsWith(prefix));
|
|
const limited = opts?.limit ? keys.slice(0, opts.limit) : keys;
|
|
return {
|
|
keys: limited,
|
|
total: keys.length,
|
|
hasMore: opts?.limit ? keys.length > opts.limit : false,
|
|
};
|
|
}
|
|
|
|
async clear(): Promise<void> {
|
|
this.#map.clear();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* UriPlugin wrapping a single `MemStore`. URI shape:
|
|
* `<scheme>://<authority>/<path>` ⇒ key dispatched as
|
|
* `<authority>/<path>` (the storage-uri resolver passes whatever
|
|
* `parse(uri).key` returns to the adapter; we use the canonical
|
|
* URL-recovered shape directly).
|
|
*
|
|
* The second-argument record form (`{ <bucket>: store }`) is preserved
|
|
* for ergonomic test composition: when the URI's authority matches one of
|
|
* the registered bucket names, the matching store handles the operation.
|
|
*/
|
|
export function memPlugin(
|
|
scheme: string,
|
|
stores: MemStore | Record<string, MemStore>
|
|
): UriPlugin {
|
|
const single = stores instanceof MemStore;
|
|
function pick(key: string): { store: MemStore; inner: string } {
|
|
if (single) return { store: stores, inner: key };
|
|
const slash = key.indexOf("/");
|
|
const bucket = slash >= 0 ? key.slice(0, slash) : key;
|
|
const store = (stores as Record<string, MemStore>)[bucket];
|
|
if (!store) throw new Error(`unknown bucket: ${bucket}`);
|
|
return { store, inner: key };
|
|
}
|
|
return {
|
|
scheme,
|
|
adapter: {
|
|
async get(key) {
|
|
const { store, inner } = pick(key);
|
|
return store.get(inner);
|
|
},
|
|
async set(key, value) {
|
|
const { store, inner } = pick(key);
|
|
return store.set(inner, value);
|
|
},
|
|
async delete(key) {
|
|
const { store, inner } = pick(key);
|
|
return store.delete(inner);
|
|
},
|
|
async exists(key) {
|
|
const { store, inner } = pick(key);
|
|
return store.exists(inner);
|
|
},
|
|
async list(opts) {
|
|
const prefix = opts?.prefix ?? "";
|
|
if (single) {
|
|
const inner = await stores.list({
|
|
prefix,
|
|
...(opts?.limit !== undefined
|
|
? { limit: opts.limit }
|
|
: {}),
|
|
});
|
|
return inner;
|
|
}
|
|
const slash = prefix.indexOf("/");
|
|
const bucket = slash >= 0 ? prefix.slice(0, slash) : prefix;
|
|
const store = (stores as Record<string, MemStore>)[bucket];
|
|
if (!store) return { keys: [], total: 0, hasMore: false };
|
|
const inner = await store.list({
|
|
prefix,
|
|
...(opts?.limit !== undefined ? { limit: opts.limit } : {}),
|
|
});
|
|
return inner;
|
|
},
|
|
async clear() {
|
|
if (single) return stores.clear();
|
|
for (const s of Object.values(
|
|
stores as Record<string, MemStore>
|
|
)) {
|
|
await s.clear();
|
|
}
|
|
},
|
|
},
|
|
parse(uri): ParsedUri {
|
|
const m = new URL(uri);
|
|
const host = m.host;
|
|
const path = m.pathname.replace(/^\//, "");
|
|
const key = path.length > 0 ? `${host}/${path}` : host;
|
|
return { key };
|
|
},
|
|
build(key) {
|
|
const slash = key.indexOf("/");
|
|
if (slash < 0) return `${scheme}://${key}`;
|
|
return `${scheme}://${key.slice(0, slash)}/${key.slice(slash + 1)}`;
|
|
},
|
|
};
|
|
}
|