Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
da912a3
Add consumer projection persistence support
Copilot May 29, 2026
e809568
Implement durable consumer projections with HMAC validation
Copilot May 29, 2026
2e6f849
Refactor projections into Projection class and integrate Consumer/Eve…
Copilot May 30, 2026
2f46add
Address projection API review feedback
Copilot May 31, 2026
a222f4c
Address projection API and utility review feedback
Copilot Jun 4, 2026
f9e7fdd
Refine projection API docs and atomic fs helpers
Copilot Jun 4, 2026
30a73db
Address remaining projection and consumer review feedback
Copilot Jun 4, 2026
143d9b9
Merge origin/main and resolve conflicts
Copilot Jun 4, 2026
ecc8282
refactor projection composition and simplify consumer persist
Copilot Jun 5, 2026
ace4218
Code cleanup for the matcher logic
albe Jun 6, 2026
8b102ff
Optimize multi value matcher
albe Jun 6, 2026
46f248a
Update docblocks
albe Jun 6, 2026
d9f23dd
Improve AGENTS.md
albe Jun 6, 2026
abe922e
Optimize numeric operator matching for single operators by ~100%
albe Jun 6, 2026
5cd481e
Remove toggle for new operator matcher
albe Jun 6, 2026
2ba937c
Optimize multi-operator numeric checks
albe Jun 6, 2026
f2f6033
Add more matcher benchmarks
albe Jun 6, 2026
bffeffd
Add performance section for matchers
albe Jun 6, 2026
0a26f7f
Optimize $ne operator by ~20%
albe Jun 6, 2026
08059f8
Add ISO DateTime matching tests
albe Jun 6, 2026
95f3ad0
Add platform hint for Agents
albe Jun 6, 2026
9b302d6
Fix type of CommitCondition matcher
albe Jun 6, 2026
999f4ac
Add type information
albe Jun 7, 2026
c8de460
Deprecate EventStream.filter(Matcher) in favor of EventStream.where()
albe Jun 7, 2026
c56c542
Update AGENTS.md
albe Jun 7, 2026
c9960ef
Bump version 1.3.1
albe Jun 7, 2026
f3a5746
Fix bug in ReadOnlyStorage open with callback
albe Jun 8, 2026
90e87ad
Add EventStore makeReadOnly() API method
albe Jun 8, 2026
df7132a
Add documentation for makeReadOnly()
albe Jun 8, 2026
d280d81
Add VERSION export
albe Jun 8, 2026
5499008
Update .gitignore
albe Jun 8, 2026
388cb5d
Bump version
albe Jun 8, 2026
f053257
Add consumer projection persistence support
Copilot May 29, 2026
cf21ab9
Implement durable consumer projections with HMAC validation
Copilot May 29, 2026
2204aa8
Refactor projections into Projection class and integrate Consumer/Eve…
Copilot May 30, 2026
8a7e2d5
Address projection API review feedback
Copilot May 31, 2026
240fbd2
Address projection API and utility review feedback
Copilot Jun 4, 2026
5029f61
Refine projection API docs and atomic fs helpers
Copilot Jun 4, 2026
b1eba00
Address remaining projection and consumer review feedback
Copilot Jun 4, 2026
90c6e5f
refactor projection composition and simplify consumer persist
Copilot Jun 5, 2026
89f6899
Rebase branch onto latest main and resolve conflicts
Copilot Jun 9, 2026
dd085a7
Merge remote-tracking branch 'origin/copilot/extend-consumer-projecti…
Copilot Jun 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,6 @@ node_modules
/data
*.tgz
/event-storage-ui
/event-storage-http
/event-storage-http
/Platform.md
/*/node_modules
14 changes: 13 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,15 @@
## General guidelines for interaction

Do not repeat yourself. Be concise and precise in your answers. No paraphrasing of previous information unless specifically asked for.
If you run into a decision point, maybe because the instructions given were contradictory or incomplete, try to identify the underlying principle or intent and unless that is absolutely clear, ask for clarification and/or a decision. Explain the issue you stumbled upon and the options you see, and ask for guidance on how to proceed.
The code and documentation language is English, even if the user is communicating in another language.
Read the Platform.md file if it exists to know which platform you are running on and what capabilities you have, instead of trying to run linux tools on a Windows machine.
**Keep this file up-to-date**: after any code review that surfaces new architectural insights or a new principle, compact this file and integrate what was learned.

## Principles (in priority order)

1. **Clean API surface** — usage for common cases should be straightforward and well-documented. Avoid breaking changes unless they significantly improve usability.
2. **Understandable code** — cyclomatic complexity per method should stay around or below current levels. Higher-level methods should delegate to clearly-named helpers so the logic reads like pseudo-code. Use utility functions (e.g. `kWayMerge`, binary search) for generic algorithms.
2. **Understandable code** — cyclomatic complexity per method should stay around or below current levels (~15). Higher-level methods should delegate to clearly-named helpers so the logic reads like pseudo-code. Use utility functions (e.g. `kWayMerge`, binary search) for generic algorithms.
3. **Performance** — maintain good performance across all code paths. The Index and Partition layers are most performance-sensitive and may prefer performance over readability at the margin. Elsewhere, prefer simplicity; simpler code is often faster.

### Performance trade-off lessons
Expand All @@ -18,8 +21,17 @@ Do not repeat yourself. Be concise and precise in your answers. No paraphrasing
- **Pass cheap prefilter results as hints to expensive scans**: when a cheap pass (e.g. `Buffer.indexOf`) already locates a candidate position, carry that result forward as a hint to the costlier depth- or context-aware pass rather than letting it re-scan from the start. Eliminates a full O(n) scan on every call.
- **Custom implementations only when benchmarks justify the complexity**: a hand-rolled solution may edge out a standard library call by 10–15 %, but the maintenance cost is rarely worth it unless the gain is substantial and measurable at realistic data sizes (e.g. a custom numeric parser vs. `JSON.parse` with try/catch).
- **Prefer a flag on a shared helper over near-duplicate functions**: when two functions differ only in a single behavioral detail, add a boolean parameter to the shared function rather than maintaining two near-identical copies. Keep the flag's semantics explicit and limited to one axis of variation.
- **Inline single-use hot-path helpers once the surrounding flow becomes simpler**: if a helper is only called from one place and its logic can be embedded while keeping the caller below the local complexity/return-count budget, prefer the inline version. Removes call indirection and often makes the hot path easier to read as straight-line pseudo-code.


### Code Quality standards

- Keep cyclomatic complexity per method around 15 or below. If a method exceeds that, look for ways to break it down into smaller, clearly-named helper methods. Exceptions can be made for very performance-sensitive code paths, but prefer readability in general.
- Keep number of return statements per method below 6. Multiple return points can be acceptable if they significantly improve readability, but watch for methods that become hard to follow due to too many exit points.
- Avoid deep nesting of conditionals. If you find yourself nesting more than 3 levels deep, consider refactoring to flatten the structure, such as by using guard clauses or extracting nested logic into separate methods.
- Use descriptive method and variable names to make the code self-documenting. Higher level methods should read like pseudo-code, delegating to well-named helpers that encapsulate specific logic or steps in the process.
- In general, optimize the code for readability first, and only introduce complexity when there is a clear performance benefit that has been measured and justified.

## Architecture

```
Expand Down
19 changes: 13 additions & 6 deletions bench/bench-matcher.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,24 @@ const WARMUP_RUNS = 1;
const MEASURED_RUNS = 5;

const matcherObjects = [
{ name: 'flat', matcher: { type: 'Foo' } },
{ name: 'scoped', matcher: { payload: { type: 'Foo' } } },
{ name: 'multi', matcher: { payload: { type: ['Foo', 'Baz'] } } },
{ name: '$gt', matcher: { payload: { value: { $gt: 100 } } } },
{ name: '$eq', matcher: { payload: { type: { $eq: 'Foo' } } } }
{ name: 'flat', matcher: { type: 'Foo' } },
{ name: 'scoped', matcher: { payload: { type: 'Foo' } } },
{ name: 'anyOf2', matcher: { payload: { type: ['BazingaHappened', 'Foo'] } } },
{ name: 'anyOf4', matcher: { payload: { type: ['BazingaHappened', 'BarxingaHappened', 'QuuxingaHappened', 'Foo'] } } },
{ name: '$gt', matcher: { payload: { value: { $gt: 100 } } } },
{ name: '$eq', matcher: { payload: { type: { $eq: 'Foo' } } } },
// ── multi-operator numeric: two numeric ops → fast path (no JSON.parse) ──
{ name: '$gte+$lt', matcher: { payload: { value: { $gte: 100, $lt: 2000 } } } },
// ── two numeric + string: numeric ops on value field + string op on type field ──
{ name: '$gte+$lt+str', matcher: { payload: { value: { $gte: 100, $lt: 2000 }, type: 'Foo' } } }
];

const implementations = item => [
{ name: `${item.name}-obj`, impl: 'obj', fn: (doc) => matches(doc, item.matcher) },
{ name: `${item.name}-raw`, impl: 'raw', fn: buildRawBufferMatcher(item.matcher) },
{ name: `${item.name}-+obj`, impl: '+obj', fn: (buffer) => matches(JSON.parse(buffer.toString('utf8')), item.matcher) }
// The following is a reference benchmark against realistic usage, where an obj matcher requires a deserialization, which is costly.
// Do NOT remove the commented implementation case.
//{ name: `${item.name}-+obj`, impl: '+obj', fn: (buffer) => matches(JSON.parse(buffer.toString('utf8')), item.matcher) }
];
const implementationsCount = implementations(matcherObjects[0]).length; // assuming all have same count

Expand Down
48 changes: 48 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,19 @@ Close the event store and free all resources.

---

#### `makeReadOnly([callback])` ✅ Stable

```javascript
eventstore.makeReadOnly([callback])
```

Flush pending writes, close the writable storage, and reopen the store in read-only mode.
The optional `callback` is called after the new read-only storage has emitted `'opened'`.

This is mainly useful for deployment handover workflows where one process stops writing and the same process switches into read-only mode; for most applications, `eventstore.close(); new EventStore(..., { readOnly: true })` is the simpler option.

---

#### `commit(streamName, events, [expectedVersion], [metadata], [callback])` ✅ Stable

```javascript
Expand Down Expand Up @@ -267,6 +280,41 @@ Asynchronously scan all consumer state files and return their identifiers.

---

#### `eventstore.getProjection(name, [handlers], [initialState], [matcher])`

```javascript
eventstore.getProjection(name [, handlers] [, initialState] [, matcher]) → Projection
```

Create a `Projection` with EventStore defaults (`typeAccessor`, storage HMAC), or restore a previously persisted one when `handlers` is omitted.

- `handlers`: reducer function `(state, event) => state` or map `{ [eventType]: reducer }`
- `initialState`: initial projection state (default `{}`)
- `matcher`: optional object/function matcher (same shape as stream/query matchers)


---

#### `consumer.project(projection)`

```javascript
consumer.project(projection)
```

Attach a projection-like object (`apply(state, event)`) as the consumer `'data'` handler.

---

#### `projection.subscribe(consumer)`

```javascript
projection.subscribe(consumer)
```

Attach this projection to the consumer (same wiring behavior as `consumer.project(projection)`) and persist its definition next to the consumer state file so `eventstore.getConsumer(...)` can restore and reconnect it automatically.

---

### Events emitted

| Event | Payload | Description |
Expand Down
13 changes: 13 additions & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
# Changelog

## 1.3.2

- Added `EventStore.makeReadOnly([callback])` as a niche handover API for switching a running writer process into read-only mode without restarting it. For the common case, creating a fresh read-only `EventStore` instance remains the recommended approach.
- Added public `VERSION` export at the package root and mirrored it on `EventStore.VERSION` so dependent layers can read the `event-storage` version directly without resolving/parsing `package.json` at runtime.
- Fixed `ReadOnlyStorage.open()` to keep the same opened-event semantics as the readable and writable storage variants, so read-only reopen paths and the `makeReadOnly()` callback now work consistently.

## 1.3.1

- Added published TypeScript declarations (`index.d.ts`) and package-level `types` metadata so consumers can reference `EventStore` and related public APIs from `event-storage`.
- `EventStream` now exposes `where()` for matcher-based filtering.
- `EventStream.filter()` now supports `Readable.filter(callback, options)` delegation when options are provided.
- Matcher-style `EventStream.filter(matcher)` remains supported for backward compatibility but now emits a deprecation warning and should be migrated to `where()`.

## 1.3.0

New in 1.3:
Expand Down
32 changes: 32 additions & 0 deletions docs/consumers.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,36 @@ consumer.setState({ count: 0 });
consumer.setState((state) => ({ ...state, count: state.count + 1 }));
```

## Projections

Use a `Projection` to define *how* events are projected into state, then connect it to a `Consumer` for durable continuous updates.

```javascript
const projection = eventstore.getProjection('orders-total', {
OrderCreated: (state, event) => ({ ...state, total: state.total + (event.payload.amount || 0) })
}, { total: 0 });

const consumer = eventstore.getConsumer('orders', 'orders-projection', projection.initialState);
projection.subscribe(consumer);
```

Projections are composable via `CompositeProjection`:

```javascript
import { CompositeProjection, Projection } from 'event-storage';

const count = new Projection('count', {
initialState: 0,
handlers: { OrderCreated: (state) => state + 1 }
});

const overview = new CompositeProjection('overview', {
Comment thread
albe marked this conversation as resolved.
count,
last: { initialState: null, handlers: { OrderCreated: (state, event) => event.payload } }
});
// overview.state -> { count: number, last: object|null }
```
Comment thread
albe marked this conversation as resolved.

## Resetting a Consumer

Force the consumer to reprocess events from a given position:
Expand Down Expand Up @@ -144,4 +174,6 @@ eventstore.on('ready', () => {
- Multiple read-only instances can run simultaneously.
- This enables a multi-process projection pattern: one writer process + N reader processes building different projections.

If you need to hand the *current* process over from writing to reading during a deployment, use `EventStore.makeReadOnly([callback])` instead of starting a new read-only instance.

> In principle this also works across machines sharing a common filesystem (e.g. NFS), as long as the Node.js file watcher functions correctly on that filesystem.
31 changes: 31 additions & 0 deletions docs/performance.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,3 +128,34 @@ These benchmarks measure pure **read operations** (no writes). The range scan re
| range scan | stable | 453,288 | ±0.28% | 86 |
| range scan | latest | 423,291 | ±0.45% | 81 |
| range scan | latest(raw) | 886,578 | ±0.59% | 90 |

### Raw Buffer Operator Matcher Benchmark

This benchmark compares the same matcher definitions in two execution modes:

- **`obj` (baseline):** run `matches(document, matcher)` on already deserialized events
- **`raw`:** run `buildRawBufferMatcher(matcher)` directly on NDJSON buffers

Matcher objects used in `bench/bench-matcher.js`:

```js
{ type: 'Foo' }
{ payload: { type: 'Foo' } }
{ payload: { type: ['BazingaHappened', 'Foo'] } }
{ payload: { type: ['BazingaHappened', 'BarxingaHappened', 'QuuxingaHappened', 'Foo'] } }
{ payload: { value: { $gt: 100 } } }
{ payload: { type: { $eq: 'Foo' } } }
{ payload: { value: { $gte: 100, $lt: 2000 } } }
{ payload: { value: { $gte: 100, $lt: 2000 }, type: 'Foo' } }
```

Observed median delta (`raw` vs `obj`) from the benchmark run:

At **ANY match ratio**, raw is roughly **40–55% slower** than object mode.
At **ANY match ratio**, raw is roughly **50–60% faster** than object mode when deserializing the event first.

Interpretation:

- When events are **already deserialized**, object matchers are the correct baseline and typically faster.
- In workloads where data is still in **raw buffer form** (streaming), raw matchers avoid `JSON.parse` and can outperform a deserialize+object-match pipeline by roughly **~50%** in practice.

145 changes: 145 additions & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { EventEmitter } from 'node:events';
import { Readable } from 'node:stream';

export type EventMatcher = Record<string, unknown> | ((payload: any, metadata?: any) => boolean);
export type EventPredicate = EventMatcher | ((buffer: Buffer) => boolean) | null;

export interface StreamIndexInfo {
length: number;
metadata?: Record<string, unknown> | null;
}

export interface StreamInfo {
index: StreamIndexInfo;
matcher?: EventMatcher;
closed?: boolean;
}

export class CommitCondition {
constructor(types: string[], matcher: EventMatcher | null | undefined, noneMatchAfter: number, raw?: boolean);
types: string[];
matcher: EventMatcher | null;
raw: boolean;
noneMatchAfter: number;
}

export class OptimisticConcurrencyError extends Error {}

export const ExpectedVersion: {
Any: -1;
EmptyStream: 0;
};

export const VERSION: string;

export class EventStream extends Readable {
constructor(name: string, eventStore: EventStore, minRevision?: number, maxRevision?: number, predicate?: EventPredicate, raw?: boolean);
name: string;
raw: boolean;
version: number;
minRevision: number;
maxRevision: number;
streamIndex: StreamIndexInfo;
predicate: EventPredicate;

from(revision: number): this;
until(revision: number): this;
fromStart(): this;
fromEnd(): this;
previous(amount: number): this;
following(amount: number): this;
backwards(): this;
forwards(): this;
where(predicate?: EventPredicate): this;
filter(predicate?: EventPredicate): this;
filter(
predicate: (chunk: any, options?: { signal?: AbortSignal }) => boolean | Promise<boolean>,
options: { concurrency?: number; signal?: AbortSignal }
): Readable;
next(): any | false;
}

export class Consumer extends Readable {
constructor(storage: Storage, indexName: string, identifier: string, initialState?: Record<string, unknown>, startFrom?: number);
streamName: string;
position: number;
state: Record<string, unknown>;
start(): this;
stop(): void;
reset(initialState?: Record<string, unknown>, startFrom?: number): this;
setState(state: Record<string, unknown>): this;
}

export class Storage extends EventEmitter {
static ReadOnly: typeof Storage;
static StorageLockedError: typeof StorageLockedError;
static LOCK_THROW: number;
static LOCK_RECLAIM: number;

initialized: boolean | null;
length: number;
indexDirectory: string;
storageFile: string;
}

export class StorageLockedError extends Error {}

export class Index {
static ReadOnly: typeof Index;
static Entry: unknown;

length: number;
metadata?: Record<string, unknown> | null;
}

export class EventStore extends EventEmitter {
static Storage: typeof Storage;
static Index: typeof Index;
static VERSION: string;

storage: Storage;
streams: Record<string, StreamInfo>;
consumers: Map<string, Consumer>;
length: number;

constructor(storeName?: string, config?: Record<string, unknown>);

open(callback?: (err?: Error | null) => void): void;
close(callback?: (err?: Error | null) => void): void;

getStreamVersion(streamName: string): number;
getEventStream(streamName: string, minRevision?: number, maxRevision?: number, predicate?: EventPredicate, raw?: boolean): EventStream | false;
getAllEvents(minRevision?: number, maxRevision?: number, predicate?: EventPredicate, raw?: boolean): EventStream;
fromStreams(streamName: string, streamNames: string[], minRevision?: number, maxRevision?: number, predicate?: EventPredicate, raw?: boolean): EventStream;
getEventStreamForCategory(categoryName: string, minRevision?: number, maxRevision?: number, predicate?: EventPredicate, raw?: boolean): EventStream;

query(types: string[], matcher?: EventMatcher | null, minRevision?: number, raw?: boolean): { stream: EventStream; condition: CommitCondition };
createEventStream(streamName: string, matcher: EventMatcher, reindex?: boolean): EventStream;

commit(
streamName: string,
events: any[],
expectedVersion: number | CommitCondition,
metadata: Record<string, unknown>,
callback: (error: Error | null, commit?: Record<string, unknown>) => void
): void;

getConsumer(streamName: string, identifier: string, initialState?: Record<string, unknown>, since?: number): Consumer;
getConsumer(identifier: string): Consumer | null;
scanConsumers(
callback: (error: Error | null, consumers: Array<{ name: string; stream: string; identifier: string }>) => void,
autoStart?: boolean
): void;
}

export const LOCK_THROW: number;
export const LOCK_RECLAIM: number;

export function matches(document: Record<string, unknown>, matcher: Record<string, unknown>): boolean;
export function buildRawBufferMatcher(matcher: Record<string, unknown>): (buffer: Buffer, startOffset?: number) => boolean;

export default EventStore;




Loading