feat: add local-first EventDB storage package
This commit is contained in:
112
tests/blob-storage.test.ts
Normal file
112
tests/blob-storage.test.ts
Normal 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
53
tests/document-db.test.ts
Normal 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
71
tests/event-db.test.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user