7.1 KiB
EventDB Storage
A small local-first event and document database for applications that need offline writes, deterministic synchronization, and replaceable persistence without adopting a distributed database framework.
The package deliberately uses a simple model:
DocumentDB
derives current documents from immutable events
↓
EventDB
identifies, verifies, deduplicates, and orders events
↓
BlobStorage<T>
transforms and stores logical values by immutable ID
It is designed for trusted applications with bounded event histories. Writes succeed locally, synchronization is idempotent, and replicas with the same event set converge to the same document state.
Installation
npm install @harvmaster/eventdb-storage
The package is ESM-only and requires Web Crypto. Node.js 20 or newer and current browsers provide the required APIs. TypeScript applications should include the DOM library because the cross-platform public adapters expose standard Fetch, Web Crypto, LocalStorage, and IndexedDB types.
Quick start
import {
DocumentDB,
EventDB,
IndexedDbBlobStorage,
createDocumentEventIdentity,
type DocumentEvent,
} from '@harvmaster/eventdb-storage';
const storage = new IndexedDbBlobStorage<DocumentEvent>('my-application');
const eventDB = await EventDB.open(storage, {
identify: createDocumentEventIdentity(),
});
const documentDB = await DocumentDB.open(eventDB);
const notes = documentDB.collection<{
_id: string;
title: string;
content: string;
}>('notes');
await notes.put({
_id: 'note-1',
title: 'Local first',
content: 'This write is available immediately and can replicate later.',
});
console.log(notes.get('note-1'));
await notes.delete('note-1');
// Close from the highest layer to the lowest layer.
await documentDB.close();
await eventDB.close();
await storage.close();
DocumentDB.open() performs a complete initial rebuild, so synchronous collection reads are ready when the promise resolves.
Choosing a storage adapter
| Adapter | Intended use | Durability | Cross-context observations |
|---|---|---|---|
MemoryBlobStorage<T> |
Tests and temporary processing | None | Current instance only |
LocalStorageBlobStorage<T> |
Small browser datasets and fallbacks | Browser storage | Browser storage events |
IndexedDbBlobStorage<T> |
Normal browser applications | IndexedDB | BroadcastChannel when available |
SqliteBlobStorage<T> |
Node, Electron, native shells, and servers | Supplied SQLite database | Current instance only |
HttpBlobStorage<T> |
Remote persistence and SSE observations | Remote server | Server-Sent Events |
CompositeBlobStorage<T> |
Local-first storage with replicas | Primary adapter | Primary and replica notifications |
The SQLite adapter is driver-neutral. Supply an object implementing SqliteDatabase; this avoids forcing applications to install a particular native SQLite package.
Encryption
Storage encryption is a transform and does not change event identity:
import {
LocalStorageBlobStorage,
composeStorageTransforms,
createAesGcmTransform,
createMsgPackTransform,
type DocumentEvent,
} from '@harvmaster/eventdb-storage';
const encryptionKey = await crypto.subtle.generateKey(
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt'],
);
const transform = composeStorageTransforms(
createMsgPackTransform<DocumentEvent>(),
createAesGcmTransform(encryptionKey),
);
const storage = new LocalStorageBlobStorage<DocumentEvent>('event-db', {
transform,
});
AES-GCM uses a fresh random IV for every encoding. Two physical ciphertexts can therefore differ while representing the same logical event ID. Keep the encryption key and transform configuration available for the lifetime of the stored data.
Local and remote replication
import {
CompositeBlobStorage,
HttpBlobStorage,
IndexedDbBlobStorage,
type DocumentEvent,
} from '@harvmaster/eventdb-storage';
const local = new IndexedDbBlobStorage<DocumentEvent>('event-db');
const remote = new HttpBlobStorage<DocumentEvent>('https://example.com/storage');
const storage = new CompositeBlobStorage({
primary: local,
replicas: [remote],
});
Normal reads use the primary. Writes persist to the primary before replication is attempted, so remote outages do not invalidate successful local writes. Composite storage performs a full immutable-ID reconciliation when constructed; synchronize() can also be called explicitly after connectivity returns.
HTTP protocol
HttpBlobStorage expects these endpoints beneath its base URL:
GET /keys?prefix=...returns a JSON array of IDs.GET /blobs/:encodedIdreturns transformed binary data or404.POST /blobs/:encodedIdcreates the transformed binary value and treats an existing ID as success.GET /changesreturns Server-Sent Events whose JSONdatais{ "id": string, "data": base64 }.
The server can remain blind to logical values and encryption keys. EventDB verifies every decoded event against its expected ID.
Consistency model
The package guarantees:
- immutable, idempotent event storage;
- immediate local read-your-writes behavior;
- deterministic ordering by
(timestamp, eventId); - convergence when replicas receive the same valid event set;
- complete recovery from authoritative events after notifications are missed;
- whole-document replacement and deletion.
It intentionally does not provide transactions across events, causal ordering, field-level merging, authorization, signatures, or proof that a remote returned every event. Timestamp-and-ID ordering is deterministic last-writer-wins, not a trusted global clock.
Lifecycle and ownership
Each layer releases only resources it owns:
DocumentDB.close()removes its EventDB listener.EventDB.close()removes its BlobStorage listeners.- A leaf storage adapter closes its physical resource.
CompositeBlobStorage.close()owns and closes its primary and replica adapters.
All close() methods are idempotent. Applications should stop document activity first and close dependencies in reverse construction order.
Custom logical events
DocumentDB uses its built-in canonical identity function, but EventDB can store any event extending BaseEventData:
import {
CanonicalJsonEventCodec,
EventDB,
MemoryBlobStorage,
createEventIdentity,
type BaseEventData,
} from '@harvmaster/eventdb-storage';
interface CounterIncremented extends BaseEventData {
type: 'counter.incremented';
amount: number;
}
const codec = new CanonicalJsonEventCodec<CounterIncremented>();
const identify = createEventIdentity<CounterIncremented>(
(event) => codec.encodeCanonical(event),
);
const storage = new MemoryBlobStorage<CounterIncremented>();
const events = await EventDB.open(storage, { identify });
Canonical identity supports plain objects, arrays, finite numbers, strings, booleans, null, Uint8Array, Date, and bigint. Functions, symbols, sparse arrays, cyclic references, accessors, and non-plain object instances are rejected.
Development
npm test
npm run typecheck
npm run build
The compiled package and declarations are written to dist/.
License
ISC. See LICENSE.