Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,47 @@
import { MemoryCache } from "@suaveplan/cache";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { cachedStorage } from "../../src/factory/factory.js";
import { FakeStorage } from "../setup/fake-storage.js";
/**
* Integration: a long-lived cache instance survives the
* `CachedStorage` wrapper being dropped and re-instantiated. We
* stand-in for a file-backed cache with a shared `MemoryCache`
* instance: the cache outlives the wrapper, and the second wrapper
* (constructed after the first is dropped) observes a hit on the first
* read — same behaviour any persistent backend (file, OPFS, redis)
* would exhibit.
*/
describe("integration: cache survives wrapper re-instantiation", () => {
it("populates, drops the wrapper, and the next wrapper observes a hit", {
timeout: 30000,
}, async () => {
const inner = new FakeStorage();
const sharedCache = new MemoryCache<Uint8Array>();
inner.seed("v.bin", new Uint8Array([9, 9, 9]));
// First wrapper populates the shared cache and is then dropped.
const first = cachedStorage(inner, {
cache: sharedCache,
namespace: "shared",
});
await first.get("v.bin");
expect(first.getCacheStats().populations).toBe(1);
// Drop the reference. The shared cache persists.
// (No explicit teardown needed — `MemoryCache` has no resource handle.)
// New wrapper over the same cache + same inner. Inner.getCalls is now 1.
// Seed the inner with a different value to prove the cached bytes
// win on the next read.
inner.seed("v.bin", new Uint8Array([1, 2, 3]));
const second = cachedStorage(inner, {
cache: sharedCache,
namespace: "shared",
});
const out = await second.get("v.bin");
expect(out).toEqual(new Uint8Array([9, 9, 9])); // served from cache
expect(inner.getCalls.length).toBe(1); // unchanged after the second read
expect(second.getCacheStats().hits).toBe(1);
});
});

View File

@@ -0,0 +1,47 @@
import { MemoryCache } from "@suaveplan/cache";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { cachedStorage } from "../../src/factory/factory.js";
import { FakeStorage } from "../setup/fake-storage.js";
/**
* Integration: real `MemoryCache` from `@suaveplan/cache` (no fake, no
* stub) sitting in front of an `AbstractStorage` instance that
* represents a remote backend (`FakeStorage` here, which is a real
* `AbstractStorage` subclass over an in-memory `Map`). Two reads of the
* same key MUST hit the inner exactly once; the second read MUST be
* served from the cache.
*/
describe("integration: MemoryCache in front of a remote storage", () => {
it("two reads of the same key hit inner once", {
timeout: 30000,
}, async () => {
const inner = new FakeStorage();
const cache = new MemoryCache<Uint8Array>();
const wrapper = cachedStorage(inner, { cache, namespace: "remote" });
inner.seed("clip.mov", new Uint8Array([1, 2, 3, 4]));
const first = await wrapper.get("clip.mov");
const second = await wrapper.get("clip.mov");
expect(first).toEqual(new Uint8Array([1, 2, 3, 4]));
expect(second).toEqual(new Uint8Array([1, 2, 3, 4]));
expect(inner.getCalls.length).toBe(1);
expect(wrapper.getCacheStats()).toMatchObject({
hits: 1,
misses: 1,
populations: 1,
});
});
it("write invalidates so the next read refetches", {
timeout: 30000,
}, async () => {
const inner = new FakeStorage();
const cache = new MemoryCache<Uint8Array>();
const wrapper = cachedStorage(inner, { cache, namespace: "remote" });
inner.seed("clip.mov", new Uint8Array([1]));
await wrapper.get("clip.mov"); // populates
await wrapper.set("clip.mov", new Uint8Array([2, 2])); // invalidates
const refreshed = await wrapper.get("clip.mov"); // miss again
expect(refreshed).toEqual(new Uint8Array([2, 2]));
expect(inner.getCalls.length).toBe(2);
});
});

View File

@@ -0,0 +1,84 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { cachedStorage } from "../../src/factory/factory.js";
import { KeysCapableFakeCache } from "../setup/fake-cache.js";
import { FakeStorage } from "../setup/fake-storage.js";
/**
* Integration: two `CachedStorage` wrappers sharing one
* `AbstractCache` instance with distinct namespaces. Neither wrapper
* observes the other's entries on `get`, and `wrapperA.clear()` MUST
* NOT evict `wrapperB`'s entries.
*
* Uses the `KeysCapableFakeCache` test helper because the canonical
* `MemoryCache` from `@suaveplan/cache` does not expose `keys()`. The
* test helper is a real `AbstractCache<Uint8Array>` subclass — not a
* mock — so the integration exercises real iteration over real
* cache entries.
*/
describe("integration: namespaced isolation on a shared cache", () => {
it("distinct namespaces isolate entries on a shared cache", {
timeout: 30000,
}, async () => {
const innerA = new FakeStorage();
const innerB = new FakeStorage();
const shared = new KeysCapableFakeCache();
const wrapperA = cachedStorage(innerA, {
cache: shared,
namespace: "a",
});
const wrapperB = cachedStorage(innerB, {
cache: shared,
namespace: "b",
});
innerA.seed("clip.mov", new Uint8Array([0xa]));
innerB.seed("clip.mov", new Uint8Array([0xb]));
// Wrapper A populates its entry under "a::clip.mov".
await wrapperA.get("clip.mov");
expect(shared._peek("a::clip.mov")).toEqual(new Uint8Array([0xa]));
expect(shared._peek("b::clip.mov")).toBeUndefined();
// Wrapper B observes a miss for the same logical key (different namespace).
const fromB = await wrapperB.get("clip.mov");
expect(fromB).toEqual(new Uint8Array([0xb]));
expect(wrapperB.getCacheStats().misses).toBe(1);
expect(shared._peek("b::clip.mov")).toEqual(new Uint8Array([0xb]));
});
it("clear on one wrapper does not evict the other's entries", {
timeout: 30000,
}, async () => {
const innerA = new FakeStorage();
const innerB = new FakeStorage();
const shared = new KeysCapableFakeCache();
const wrapperA = cachedStorage(innerA, {
cache: shared,
namespace: "a",
});
const wrapperB = cachedStorage(innerB, {
cache: shared,
namespace: "b",
});
// Populate both.
innerA.seed("x", new Uint8Array([0xa, 1]));
innerA.seed("y", new Uint8Array([0xa, 2]));
innerB.seed("z", new Uint8Array([0xb, 1]));
await wrapperA.get("x");
await wrapperA.get("y");
await wrapperB.get("z");
expect(shared._peek("a::x")).toBeDefined();
expect(shared._peek("a::y")).toBeDefined();
expect(shared._peek("b::z")).toBeDefined();
// Wrapper A clears.
await wrapperA.clear();
expect(shared._peek("a::x")).toBeUndefined();
expect(shared._peek("a::y")).toBeUndefined();
expect(shared._peek("b::z")).toEqual(new Uint8Array([0xb, 1]));
});
});

View File

@@ -0,0 +1,141 @@
import type { CacheSetOptions, CacheStats } from "@suaveplan/cache";
import { AbstractCache } from "@suaveplan/cache";
/**
* Real in-memory `AbstractCache<Uint8Array>` subclass used by the unit
* tests. NOT a mock — it implements every abstract method against a
* backing `Map`. The `failNext(op, err)` helper injects a deterministic
* rejection for the next call to that op so the wrapper's
* cache-failure-swallow paths are exercised against real throwing
* behaviour.
*
* The class deliberately omits `keys()` by default; the
* `KeysCapableFakeCache` subclass adds it. This lets the wrapper's
* `clear()` feature-detection be tested against both shapes without a
* mock.
*/
export class FakeCache extends AbstractCache<Uint8Array> {
#data = new Map<string, Uint8Array>();
public getCalls: string[] = [];
public setCalls: { key: string; value: Uint8Array }[] = [];
public deleteCalls: string[] = [];
public clearCalls = 0;
public hasCalls: string[] = [];
#failNext: Partial<
Record<"get" | "set" | "delete" | "clear" | "has", Error>
> = {};
public failNext(
op: "get" | "set" | "delete" | "clear" | "has",
err: Error
): void {
this.#failNext[op] = err;
}
override async get(key: string): Promise<Uint8Array | undefined> {
this.getCalls.push(key);
const fail = this.#failNext.get;
if (fail !== undefined) {
this.#failNext.get = undefined;
throw fail;
}
return this.#data.get(key);
}
override async set(
key: string,
value: Uint8Array,
_options?: CacheSetOptions
): Promise<void> {
this.setCalls.push({ key, value });
const fail = this.#failNext.set;
if (fail !== undefined) {
this.#failNext.set = undefined;
throw fail;
}
this.#data.set(key, value);
}
override async delete(key: string): Promise<boolean> {
this.deleteCalls.push(key);
const fail = this.#failNext.delete;
if (fail !== undefined) {
this.#failNext.delete = undefined;
throw fail;
}
return this.#data.delete(key);
}
override async clear(): Promise<void> {
this.clearCalls += 1;
const fail = this.#failNext.clear;
if (fail !== undefined) {
this.#failNext.clear = undefined;
throw fail;
}
this.#data.clear();
}
override async has(key: string): Promise<boolean> {
this.hasCalls.push(key);
const fail = this.#failNext.has;
if (fail !== undefined) {
this.#failNext.has = undefined;
throw fail;
}
return this.#data.has(key);
}
override getStats(): CacheStats {
return {
hits: 0,
misses: 0,
size: this.#data.size,
hitRate: 0,
evictions: 0,
};
}
/** Test-only direct access. */
public _peek(key: string): Uint8Array | undefined {
return this.#data.get(key);
}
/** Test-only direct seed. */
public _seed(key: string, value: Uint8Array): void {
this.#data.set(key, value);
}
public get _data(): ReadonlyMap<string, Uint8Array> {
return this.#data;
}
}
/**
* `FakeCache` extension that exposes a synchronous `keys()` iterator
* — the exact shape `CachedStorage.clear()` feature-detects.
*/
export class KeysCapableFakeCache extends FakeCache {
public keysCalls = 0;
public *keys(): IterableIterator<string> {
this.keysCalls += 1;
for (const k of this._data.keys()) yield k;
}
}
/**
* `FakeCache` extension exposing an async `keys()` iterator —
* representative of remote cache backends that stream keys.
*/
export class AsyncKeysCapableFakeCache extends FakeCache {
public keysCalls = 0;
public async *keys(): AsyncIterableIterator<string> {
this.keysCalls += 1;
for (const k of this._data.keys()) {
yield k;
}
}
}

View File

@@ -0,0 +1,264 @@
import {
AbstractStorage,
STORAGE_CODES,
StorageError,
type StorageListOptions,
type StorageListResult,
type StorageMetadata,
type StorageResult,
} from "@suaveplan/storage";
/**
* Real in-memory `AbstractStorage` subclass used by the unit tests. NOT
* a mock — it implements every abstract method against a backing
* `Map<string, Uint8Array>`. The helpers `setFailOn` / `setReturn`
* inject deterministic failures or value overrides for one upcoming
* call (e.g. force `set("x", _)` to reject with a foreign throwable so
* the wrapper's "failed set does not invalidate" path is exercised).
*/
export class FakeStorage extends AbstractStorage {
#data = new Map<string, Uint8Array>();
public getCalls: { key: string; signal?: AbortSignal | undefined }[] = [];
public setCalls: {
key: string;
value: Uint8Array;
signal?: AbortSignal | undefined;
}[] = [];
public setPartialCalls: {
key: string;
offset: number;
bytes: Uint8Array;
}[] = [];
public deleteCalls: { key: string }[] = [];
public clearCalls = 0;
public existsCalls: { key: string }[] = [];
public listCalls: { options?: StorageListOptions | undefined }[] = [];
public getMetadataCalls: { key: string }[] = [];
public getWithMetadataCalls: { key: string }[] = [];
public getSizeCalls = 0;
public getItemSizeCalls: { key: string }[] = [];
public getPartialCalls: {
key: string;
offset: number;
length: number;
}[] = [];
public createReadStreamCalls: { key: string }[] = [];
public createWriteStreamCalls: { key: string }[] = [];
#failNext: Partial<
Record<"get" | "set" | "setPartial" | "delete" | "clear", Error>
> = {};
#returnNext: { get?: Uint8Array | undefined } = {};
public failNext(
op: "get" | "set" | "setPartial" | "delete" | "clear",
err: Error
): void {
this.#failNext[op] = err;
}
public returnNextGet(value: Uint8Array | undefined): void {
this.#returnNext.get = value;
}
public seed(key: string, value: Uint8Array): void {
this.#data.set(key, value);
}
async get(
key: string,
signal?: AbortSignal
): Promise<Uint8Array | undefined> {
this.getCalls.push({ key, signal });
const fail = this.#failNext.get;
if (fail !== undefined) {
this.#failNext.get = undefined;
throw fail;
}
if (signal?.aborted === true) {
throw new StorageError(STORAGE_CODES.ABORTED, "aborted");
}
if ("get" in this.#returnNext) {
const override = this.#returnNext.get;
delete this.#returnNext.get;
return override;
}
return this.#data.get(key);
}
async set(
key: string,
value: Uint8Array,
_options?: unknown,
signal?: AbortSignal
): Promise<void> {
this.setCalls.push({ key, value, signal });
const fail = this.#failNext.set;
if (fail !== undefined) {
this.#failNext.set = undefined;
throw fail;
}
if (signal?.aborted === true) {
throw new StorageError(STORAGE_CODES.ABORTED, "aborted");
}
this.#data.set(key, value);
}
async setPartial(
key: string,
offset: number,
bytes: Uint8Array,
signal?: AbortSignal
): Promise<void> {
this.setPartialCalls.push({ key, offset, bytes });
const fail = this.#failNext.setPartial;
if (fail !== undefined) {
this.#failNext.setPartial = undefined;
throw fail;
}
if (signal?.aborted === true) {
throw new StorageError(STORAGE_CODES.ABORTED, "aborted");
}
await this.defaultSetPartial(key, offset, bytes, signal);
}
async delete(key: string, signal?: AbortSignal): Promise<boolean> {
this.deleteCalls.push({ key });
const fail = this.#failNext.delete;
if (fail !== undefined) {
this.#failNext.delete = undefined;
throw fail;
}
if (signal?.aborted === true) {
throw new StorageError(STORAGE_CODES.ABORTED, "aborted");
}
return this.#data.delete(key);
}
async clear(signal?: AbortSignal): Promise<void> {
this.clearCalls += 1;
const fail = this.#failNext.clear;
if (fail !== undefined) {
this.#failNext.clear = undefined;
throw fail;
}
if (signal?.aborted === true) {
throw new StorageError(STORAGE_CODES.ABORTED, "aborted");
}
this.#data.clear();
}
async exists(key: string, _signal?: AbortSignal): Promise<boolean> {
this.existsCalls.push({ key });
return this.#data.has(key);
}
async list(
options?: StorageListOptions,
_signal?: AbortSignal
): Promise<StorageListResult> {
this.listCalls.push({ options });
const prefix = options?.prefix ?? "";
const keys = [...this.#data.keys()].filter((k) => k.startsWith(prefix));
return { keys, total: keys.length, hasMore: false };
}
async getMetadata(
key: string,
_signal?: AbortSignal
): Promise<StorageMetadata | null> {
this.getMetadataCalls.push({ key });
const bytes = this.#data.get(key);
if (bytes === undefined) return null;
return {
size: bytes.byteLength,
createdAt: 0,
modifiedAt: 0,
};
}
async getWithMetadata(
key: string,
_signal?: AbortSignal
): Promise<StorageResult<Uint8Array> | null> {
this.getWithMetadataCalls.push({ key });
const bytes = this.#data.get(key);
if (bytes === undefined) return null;
return {
value: bytes,
metadata: {
size: bytes.byteLength,
createdAt: 0,
modifiedAt: 0,
},
};
}
async getSize(_signal?: AbortSignal): Promise<number> {
this.getSizeCalls += 1;
let total = 0;
for (const v of this.#data.values()) total += v.byteLength;
return total;
}
async getItemSize(
key: string,
_signal?: AbortSignal
): Promise<number | null> {
this.getItemSizeCalls.push({ key });
const bytes = this.#data.get(key);
return bytes === undefined ? null : bytes.byteLength;
}
async getPartial(
key: string,
offset: number,
length: number,
_signal?: AbortSignal
): Promise<Uint8Array | null> {
this.getPartialCalls.push({ key, offset, length });
const bytes = this.#data.get(key);
if (bytes === undefined) return null;
return bytes.subarray(offset, offset + length);
}
createReadStream(
key: string,
_signal?: AbortSignal
): ReadableStream<Uint8Array> {
this.createReadStreamCalls.push({ key });
const bytes = this.#data.get(key);
return new ReadableStream<Uint8Array>({
start(controller) {
if (bytes !== undefined) controller.enqueue(bytes);
controller.close();
},
});
}
createWriteStream(
key: string,
_signal?: AbortSignal
): WritableStream<Uint8Array> {
this.createWriteStreamCalls.push({ key });
const chunks: Uint8Array[] = [];
const data = this.#data;
return new WritableStream<Uint8Array>({
write(chunk) {
chunks.push(chunk);
},
close() {
let total = 0;
for (const c of chunks) total += c.byteLength;
const merged = new Uint8Array(total);
let offset = 0;
for (const c of chunks) {
merged.set(c, offset);
offset += c.byteLength;
}
data.set(key, merged);
},
});
}
}

View File

@@ -0,0 +1,6 @@
// Polyfills for test environment — none required beyond what
// `@suaveplan/testing/polyfills` already installs. Node 18+ provides
// `URL`, `AbortController`, `ReadableStream`, `WritableStream`, and
// `Response` natively. File exists so vitest has a stable additional
// setup entry alongside the package-local fixture imports.
export {};