import { describe, expect, it, vi } from "vitest"; import { CompositeBlobStorage, LocalStorageBlobStorage, MemoryBlobStorage, composeStorageTransforms, createAesGcmTransform, createMsgPackTransform, type StorageTransform, } from "../src/index.js"; interface ExampleValue { readonly name: string; readonly count: bigint; readonly createdAt: Date; } describe("storage transforms", () => { it("round trips nested MessagePack values", async () => { const transform = createMsgPackTransform(); const value = { name: "test", count: 9n, createdAt: new Date("2026-01-01T00:00:00.000Z") }; expect(await transform.decode(await transform.encode(value))).toEqual(value); }); it("composes randomized AES-GCM encryption and rejects modified ciphertext", async () => { const key = await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"]); const transform = composeStorageTransforms( createMsgPackTransform(), createAesGcmTransform(key), ); const value = { name: "secret", count: 3n, createdAt: new Date("2026-02-01T00:00:00.000Z") }; const first = await transform.encode(value); const second = await transform.encode(value); expect(first).not.toEqual(second); expect(await transform.decode(first)).toEqual(value); const modified = first.slice(); modified[modified.length - 1] ^= 1; await expect(transform.decode(modified)).rejects.toBeDefined(); }); }); describe("MemoryBlobStorage", () => { it("stores logical values and emits added exactly once after persistence", async () => { const encode = vi.fn(async (value: { value: number }) => new Uint8Array([value.value])); const transform: StorageTransform<{ value: number }> = { encode, async decode(bytes) { return { value: bytes[0] ?? 0 }; }, }; const storage = new MemoryBlobStorage({ transform }); const events: unknown[] = []; storage.on("added", (event) => events.push(event)); await storage.set("item/a", { value: 1 }); await storage.set("item/a", { value: 99 }); expect(await storage.get("item/a")).toEqual({ value: 1 }); expect(await storage.keys("item/")).toEqual(["item/a"]); expect(await storage.has("missing")).toBe(false); expect(encode).toHaveBeenCalledTimes(1); expect(events).toEqual([{ id: "item/a", data: { value: 1 }, source: "local" }]); }); it("isolates listener exceptions from successful persistence", async () => { const storage = new MemoryBlobStorage<{ value: number }>(); const errors: unknown[] = []; storage.on("added", () => { throw new Error("listener failed"); }); storage.on("error", (error) => errors.push(error)); await expect(storage.set("id", { value: 1 })).resolves.toBeUndefined(); expect(await storage.get("id")).toEqual({ value: 1 }); expect(errors).toHaveLength(1); }); }); describe("LocalStorageBlobStorage", () => { it("stores transformed values under a namespace and reopens them", async () => { const physical = new FakeStorage(); const first = new LocalStorageBlobStorage<{ value: string }>("test", { storage: physical }); await first.set("a", { value: "persisted" }); await first.close(); const reopened = new LocalStorageBlobStorage<{ value: string }>("test", { storage: physical }); expect(await reopened.get("a")).toEqual({ value: "persisted" }); expect(await reopened.keys()).toEqual(["a"]); }); }); describe("CompositeBlobStorage", () => { it("synchronizes the logical ID union and imports remote observations once", async () => { const primary = new MemoryBlobStorage<{ value: number }>(); const replica = new MemoryBlobStorage<{ value: number }>(); await primary.set("primary", { value: 1 }); await replica.set("replica", { value: 2 }); const composite = new CompositeBlobStorage({ primary, replicas: [replica] }); await composite.synchronize(); expect(await primary.get("replica")).toEqual({ value: 2 }); expect(await replica.get("primary")).toEqual({ value: 1 }); const added = composite.waitFor("added", (event) => event.id === "remote"); await replica.set("remote", { value: 3 }); await expect(added).resolves.toMatchObject({ id: "remote", data: { value: 3 } }); expect(await primary.get("remote")).toEqual({ value: 3 }); }); }); class FakeStorage implements Storage { readonly #values = new Map(); get length(): number { return this.#values.size; } clear(): void { this.#values.clear(); } getItem(key: string): string | null { return this.#values.get(key) ?? null; } key(index: number): string | null { return [...this.#values.keys()][index] ?? null; } removeItem(key: string): void { this.#values.delete(key); } setItem(key: string, value: string): void { this.#values.set(key, value); } }