feat: add local-first EventDB storage package

This commit is contained in:
2026-08-01 15:11:04 +00:00
commit 181a67f261
32 changed files with 4845 additions and 0 deletions

112
tests/blob-storage.test.ts Normal file
View File

@@ -0,0 +1,112 @@
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<ExampleValue>();
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<ExampleValue>(),
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<string, string>();
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); }
}

53
tests/document-db.test.ts Normal file
View File

@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import {
DocumentDB,
EventDB,
MemoryBlobStorage,
createDocumentEventIdentity,
type DocumentEvent,
} from "../src/index.js";
interface Note {
readonly _id: string;
readonly title: string;
}
async function createDatabases(options: { now?: () => number; nonce?: () => string; remoteRebuildDebounceMs?: number } = {}) {
const storage = new MemoryBlobStorage<DocumentEvent>();
const events = await EventDB.open(storage, { identify: createDocumentEventIdentity() });
const documents = await DocumentDB.open(events, options);
return { storage, events, documents };
}
describe("DocumentDB", () => {
it("provides read-your-writes for replacement, deletion, and recreation", async () => {
let nonce = 0;
const { documents } = await createDatabases({ now: () => 10, nonce: () => `nonce-${nonce++}` });
const notes = documents.collection<Note>("notes");
await notes.put({ _id: "one", title: "first" });
await notes.put({ _id: "one", title: "replacement" });
expect(notes.get("one")).toEqual({ _id: "one", title: "replacement" });
await notes.delete("one");
expect(notes.get("one")).toBeUndefined();
await notes.put({ _id: "one", title: "recreated" });
expect(notes.find((note) => note.title.startsWith("re"))).toEqual([{ _id: "one", title: "recreated" }]);
});
it("opens with historical state and rebuilds late events in deterministic order", async () => {
const storage = new MemoryBlobStorage<DocumentEvent>();
const events = await EventDB.open(storage, { identify: createDocumentEventIdentity() });
await events.append({
version: 1, timestamp: 20, nonce: "new", type: "document.put",
collection: "notes", documentId: "one", document: { _id: "one", title: "newer" },
});
const documents = await DocumentDB.open(events);
const notes = documents.collection<Note>("notes");
expect(notes.get("one")?.title).toBe("newer");
await events.append({
version: 1, timestamp: 10, nonce: "late", type: "document.delete",
collection: "notes", documentId: "one",
});
await documents.rebuild();
expect(notes.get("one")?.title).toBe("newer");
});
});

71
tests/event-db.test.ts Normal file
View File

@@ -0,0 +1,71 @@
import { describe, expect, it } from "vitest";
import {
CanonicalJsonEventCodec,
EventDB,
EventHashMismatchError,
MemoryBlobStorage,
composeStorageTransforms,
createAesGcmTransform,
createEventIdentity,
createMsgPackTransform,
type BaseEventData,
type BlobStorageAddedEvent,
} from "../src/index.js";
interface TestEvent extends BaseEventData {
readonly type: "test";
readonly payload: unknown;
}
const codec = new CanonicalJsonEventCodec<TestEvent>();
const identify = createEventIdentity<TestEvent>((event) => codec.encodeCanonical(event));
describe("EventDB", () => {
it("loads historical events, deduplicates observations, and sorts deterministically", async () => {
const storage = new ObservableMemoryStorage<TestEvent>();
const first: TestEvent = { version: 1, timestamp: 2, nonce: "a", type: "test", payload: 1 };
const firstId = await identify(first);
await storage.set(firstId, first);
const database = await EventDB.open(storage, { identify });
const observed: string[] = [];
database.on("event", (event) => observed.push(event.id));
expect((await database.append(first)).id).toBe(firstId);
storage.notify({ id: firstId, data: first, source: "remote" });
const earlier = await database.append({ ...first, timestamp: 1, nonce: "b" });
const tied = await database.append({ ...first, nonce: "c" });
const events = await database.events();
expect(events[0]?.id).toBe(earlier.id);
expect(events.slice(1).map((event) => event.id)).toEqual([firstId, tied.id].sort());
expect(observed).toEqual([earlier.id, tied.id]);
});
it("rejects a logical value stored under the wrong ID", async () => {
const storage = new MemoryBlobStorage<TestEvent>();
await storage.set("sha256:wrong", {
version: 1, timestamp: 1, nonce: "a", type: "test", payload: null,
});
await expect(EventDB.open(storage, { identify })).rejects.toBeInstanceOf(EventHashMismatchError);
});
it("keeps event identity stable when storage encryption changes physical bytes", async () => {
const key = await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"]);
const transform = composeStorageTransforms(
createMsgPackTransform<TestEvent>(),
createAesGcmTransform(key),
);
const plainStorage = new MemoryBlobStorage<TestEvent>();
const encryptedStorage = new MemoryBlobStorage<TestEvent>({ transform });
const plain = await EventDB.open(plainStorage, { identify });
const encrypted = await EventDB.open(encryptedStorage, { identify });
const event: TestEvent = { version: 1, timestamp: 1, nonce: "same", type: "test", payload: "value" };
expect((await plain.append(event)).id).toBe((await encrypted.append(event)).id);
});
});
class ObservableMemoryStorage<T> extends MemoryBlobStorage<T> {
notify(event: BlobStorageAddedEvent<T>): void {
this.emitAdded(event);
}
}