EventStore
Shared API returned by the built-in persistence factories.
| Backend | Factory | Storage lifetime | Runtime |
|---|---|---|---|
| Memory | persistence.memory() | One store instance | Browser or Node.js |
| SQLite | await persistence.sqlite(location?) | File-backed, or one connection with ":memory:" | Node.js |
| IndexedDB | await persistence.indexeddb({ name }) | IndexedDB database name | Browser |
export interface EventStore {
readonly writer: EventWriter;
readonly reader: EventReader;
readonly tags: EventTags;
subscribe(
listener: EventListener,
options?: SubscribeOptions
): () => void;
subscribeRejections(
listener: RejectionListener
): () => void;
compact(
options: CompactOptions
): CompactReport;
close(): void;
[Symbol.dispose](): void;
}Data model
type ActorUser = {
type: "user";
id: string;
};
type ActorSystem = {
type: "system";
source: string;
};
export type Actor = ActorUser | ActorSystem;
export interface AppendInput {
assetType: string;
assetId: string;
eventType: string;
eventData: unknown;
actor: Actor;
expectedVersion?: number;
}
export interface Event {
eventId: number;
assetType: string;
assetId: string;
eventType: string;
eventData: unknown;
eventVersion: number;
actor: Actor;
createdAt: string;
}Schema map
Passing a map of event type to payload shape narrows what append accepts. Every factory takes it as an optional type argument and returns a TypedEventStore<TMap>, which remains assignable to EventStore.
type SpriteMap = {
"sprite.created": { path: string; size: number; };
"sprite.renamed": { from: string; to: string; };
};
const store = persistence.memory<SpriteMap>();
store.writer.append({
assetType: "sprite",
assetId: "a1",
eventType: "sprite.created",
eventData: { path: "a.png", size: 12 },
actor
});An eventType outside the map is rejected, and so is an eventData that does not match the payload its eventType declares.
append still returns the loose Event, so the map only constrains what a holder of the store writes. A store built without a map therefore satisfies a TypedEventStore<TMap> parameter — its append accepts every input the map describes — while a store built with a different map does not.
TypedEvent<TMap> is the discriminated union the map describes. Narrowing on eventType narrows eventData with it:
export type TypedEvent<TMap extends EventDataMap> = {
[K in EventType<TMap>]: EventEnvelope & {
eventType: K;
eventData: TMap[K];
};
}[EventType<TMap>];Reads are deliberately left untyped. reader hands back Event, whose eventData is unknown, because a stored row is parsed from JSON and may predate the current map. Validate it with a type guard before use:
function isSpriteCreated(
event: Event
): event is Extract<TypedEvent<SpriteMap>, { eventType: "sprite.created"; }> {
return event.eventType === "sprite.created" &&
typeof (event.eventData as { path?: unknown; })?.path === "string";
}writer
writer.append(input: AppendInput): Result<Event, Error>append returns the stored event on success, including the eventId and eventVersion assigned by the backend. Failures are returned as Result errors.
Expected version
expectedVersion makes the append conditional: it succeeds only while the asset's newest eventVersion equals it (0 for an empty stream). Otherwise append returns an EventVersionConflictError and stores nothing:
class EventVersionConflictError extends Error {
readonly assetId: string;
readonly expectedVersion: number;
readonly actualVersion: number;
}The check and the insert are one statement, so another connection cannot append in between. A writer that derived the event from state at version v passes v to make sure nothing landed since.
subscribe
export type EventListener = (event: Event) => void;
export interface SubscribeOptions {
eventTypePrefix?: string;
}
subscribe(listener: EventListener, options?: SubscribeOptions): () => voidCalls listener with each event the store accepts, in append order, and returns the function that detaches it. A rejected append notifies nothing. eventTypePrefix matches the start of eventType literally, the same way the reader filters, so a consumer folding one family of events does not filter the stream itself.
const unsubscribe = store.subscribe(
(event) => catalog.apply(event),
{ eventTypePrefix: "asset." }
);Subscribing does not replay history. A projection that starts from stored events reads them first, then subscribes to follow the log.
subscribeRejections
export type RejectionListener = (error: Error, input: AppendInput) => void;
subscribeRejections(listener: RejectionListener): () => voidCalls listener with the error and the original input of each append the store rejects, and returns the function that detaches it. append still returns the same error, so the appender handles it either way.
reader
reader.get(assetId: string, eventVersion: number): Event | undefined
reader.list(assetId: string, fromVersion?: number): Event[]
reader.listAll(options?: ListAllOptions): Event[]
reader.listFromCheckpoints(options: ListFromCheckpointsOptions): Event[]get returns the event stored at one version of an asset, or undefined when the stream does not hold that version, for example after compaction removed it.
list returns one asset stream in eventVersion order and treats fromVersion as an exclusive lower bound.
listAll reads every stream in eventId order and accepts these filters:
export interface ListAllOptions {
fromEventId?: number;
beforeEventId?: number;
eventTypePrefix?: string;
limit?: number;
newestFirst?: boolean;
}fromEventIdis an exclusive lower bound.beforeEventIdis an exclusive upper bound.eventTypePrefixmatches the start ofeventTypeliterally.limittruncates the ordered result.newestFirstreverses the order, solimitkeeps the newest events. Pass the lasteventIdread asbeforeEventIdto read the next page.
listFromCheckpoints
export interface ListFromCheckpointsOptions {
checkpointEventTypes: readonly string[];
eventTypePrefix?: string;
assetId?: string;
}Returns, for each asset, its newest event whose type is in checkpointEventTypes plus everything appended after it, across every stream in eventId order. An asset holding no checkpoint yields its whole stream, so an empty checkpointEventTypes returns every event, or the whole stream when assetId is set.
eventTypePrefix filters the result without moving the bound: an event matching the prefix but stored before its asset's checkpoint is still left out.
assetId limits the read to one stream, which then comes back in eventVersion order as well. A consumer that folds one asset uses it to read only the events its fold still needs:
const events = store.reader.listFromCheckpoints({
assetId: "a1",
checkpointEventTypes: ["asset.created", "asset.updated"]
});tags
export interface EventTagInput {
assetId: string;
name: string;
eventVersion: number;
message?: string;
actor: Actor;
}
export interface EventTag extends EventTagInput {
createdAt: string;
}
tags.create(input: EventTagInput): Result<EventTag, Error>
tags.get(assetId: string, name: string): EventTag | undefined
tags.list(assetId?: string): EventTag[]
tags.delete(assetId: string, name: string): booleanA tag names one event of one asset stream and keeps that event through compaction. Its name is unique within the asset, and several names may point at the same event. The store only refuses an empty name; any other naming rule belongs to the consumer.
const [checkpoint] = store.reader.listFromCheckpoints({
assetId: "a1",
checkpointEventTypes: ["asset.created", "asset.updated"]
});
store.tags.create({
assetId: "a1",
name: "v1",
eventVersion: checkpoint.eventVersion,
message: "First pass",
actor
}).unwrap();create returns the stored tag with the createdAt the store assigned. It returns an error and stores nothing when:
- the stream holds no event at
eventVersion, including one that compaction already removed:UnknownEventError; - the asset already has a tag with that name:
EventTagConflictError; nameis empty:TypeError.
class UnknownEventError extends Error {
readonly assetId: string;
readonly eventVersion: number;
}
class EventTagConflictError extends Error {
readonly assetId: string;
readonly tagName: string;
}list returns tags in creation order, across every asset or for one. A tag never moves: to point a name at another event, delete it and create it again, which also moves it to the end of the list.
delete returns false when the tag does not exist. It leaves the event in place, and the next compact removes that event unless another tag still points at it.
Compaction
export interface EventPosition {
assetId: string;
eventVersion: number;
}
export interface CompactOptions {
checkpointEventTypes: readonly string[];
reclaim?: boolean;
assetId?: string;
keep?: Iterable<EventPosition>;
}
export interface CompactReport {
removed: number;
assets: number;
}compact removes every event stored before each asset's newest checkpoint, which is the set listFromCheckpoints already skips, minus the events a tag points at. An asset holding no checkpoint keeps its whole stream, and an empty checkpointEventTypes removes nothing. assetId limits the pass to one asset. keep names more events to leave in place, as a tag does, for a consumer whose checkpoints still read older events.
Because tagged events stay, list and listAll can return a stream with gaps below its newest checkpoint, such as v3, v9, v10. listFromCheckpoints never returns a tagged event once a newer checkpoint exists.
WARNING
Compaction is destructive and irreversible. Only call it when the checkpoint types genuinely replace an asset's whole state, so nothing below them is ever read again.
Surviving events keep their eventId and eventVersion, so a position held elsewhere stays valid and append keeps assigning increasing versions. reclaim defaults to true and asks the backend to release the freed space.
Lifecycle
close(): void
[Symbol.dispose](): voidclose() is idempotent. A closed store rejects later reads and writes with an EventLogClosedError: append and tags.create return it, and every other member throws it. [Symbol.dispose] calls close(), so stores work with using declarations.
Storage contract
All backends follow the same behavior:
eventDataandactorpass through JSON serialization.BigIntvalues cannot be stored, top-level values such asundefinedandSymbolfail the append, object properties with those values are omitted, andDatevalues become ISO strings.- The stored event does not alias the input, and values returned by the reader or
tagscannot mutate the log. appendreturns an event equivalent to the one returned by a laterlist.- A rejected append consumes neither an
eventIdnor aneventVersion.