Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,72 @@
# projection-builder
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides `createProjection`, a factory for building declarative read-model projections over sequences of domain events. Projections are the CQRS query side — they accumulate state from an immutable event log without modifying aggregates. The builder pattern keeps projections pure, composable, and testable independently of the event store.
## Features
- `project(events)` — folds a synchronous array of events onto `initialState` using the registered handlers
- `projectStream(stream)` — async variant that consumes an `AsyncIterable<DomainEvent>` for streaming replay
- Unknown event types are silently ignored — projections are forward-compatible with new event types
- Fully typed: `TState` is inferred from `initialState`
- No side effects — projections always start fresh from `initialState`
## Usage: Basic Example
```ts
import { createProjection } from "@suaveplan/event-sourcing";
const orderSummary = createProjection({
initialState: { total: 0, count: 0 },
handlers: {
OrderPlaced: (state, event) => ({
total: state.total + (event.payload as { amount: number }).amount,
count: state.count + 1,
}),
},
});
const result = orderSummary.project(events);
console.log(result.count); // number of placed orders
```
## Usage: Advanced Example
```ts
import { createProjection, createMemoryEventStore } from "@suaveplan/event-sourcing";
const store = createMemoryEventStore();
// Build projection from async stream
const projection = createProjection({
initialState: [] as string[],
handlers: {
UserRegistered: (state, event) => [
...state,
(event.payload as { username: string }).username,
],
},
});
const result = await projection.projectStream(store.getStream("users"));
```
## API Reference
### `createProjection<TState>(options)`
- `options.initialState: TState` — starting state for each projection run
- `options.handlers: Record<string, (state: TState, event: DomainEvent) => TState>` — reducer per event type
### `Projection<TState>`
- `project(events: DomainEvent[]): TState`
- `projectStream(stream: AsyncIterable<DomainEvent>): Promise<TState>`
## Implementation Notes
Each call to `project` or `projectStream` starts from `initialState` — there is no incremental update. For live projections that need incremental updates, callers should maintain external state and call the handler functions directly, or use a stateful wrapper that holds the accumulated state.

View File

@@ -0,0 +1,89 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { DomainEvent } from "../../types/event-sourcing.types.js";
import { createProjection } from "./builder.js";
function makeEvent(type: string, payload: unknown, version = 1): DomainEvent {
return {
id: `e-${version}`,
aggregateId: "a1",
aggregateType: "T",
type,
version,
payload,
metadata: {},
occurredAt: new Date(),
};
}
describe("createProjection", () => {
it("projects empty events to initial state", { timeout: 5000 }, () => {
const p = createProjection({ initialState: 0, handlers: {} });
expect(p.project([])).toBe(0);
});
it("applies handlers to events", { timeout: 5000 }, () => {
const p = createProjection({
initialState: { count: 0 },
handlers: {
Incremented: (s) => ({ count: s.count + 1 }),
},
});
const result = p.project([
makeEvent("Incremented", {}),
makeEvent("Incremented", {}),
]);
expect(result.count).toBe(2);
});
it("ignores unknown event types", { timeout: 5000 }, () => {
const p = createProjection({
initialState: 0,
handlers: { Known: (s) => s + 1 },
});
expect(p.project([makeEvent("Unknown", {})])).toBe(0);
});
it("passes event to handler", { timeout: 5000 }, () => {
const p = createProjection({
initialState: 0,
handlers: {
Added: (s, e) => s + (e.payload as { amount: number }).amount,
},
});
expect(p.project([makeEvent("Added", { amount: 5 })])).toBe(5);
});
it("projectStream processes async iterable", {
timeout: 5000,
}, async () => {
const p = createProjection({
initialState: 0,
handlers: { Tick: (s) => s + 1 },
});
async function* gen(): AsyncGenerator<DomainEvent> {
yield makeEvent("Tick", {}, 1);
yield makeEvent("Tick", {}, 2);
yield makeEvent("Tick", {}, 3);
}
const result = await p.projectStream(gen());
expect(result).toBe(3);
});
it("projectStream ignores unknown events", { timeout: 5000 }, async () => {
const p = createProjection({ initialState: 0, handlers: {} });
async function* gen(): AsyncGenerator<DomainEvent> {
yield makeEvent("Unknown", {});
}
const result = await p.projectStream(gen());
expect(result).toBe(0);
});
it("starts fresh from initialState each call", { timeout: 5000 }, () => {
const p = createProjection({
initialState: { count: 0 },
handlers: { Incremented: (s) => ({ count: s.count + 1 }) },
});
p.project([makeEvent("Incremented", {})]);
expect(p.project([])).toEqual({ count: 0 });
});
});

View File

@@ -0,0 +1,73 @@
import type {
DomainEvent,
Projection,
ProjectionOptions,
} from "../../types/event-sourcing.types.js";
/**
* Builds a read-model projection from a declarative handler map.
*
* Each key in `handlers` is an event type string; the value is a reducer
* function `(state, event) => newState`. Unknown event types are silently
* ignored, enabling forward-compatible projections.
*/
class ProjectionImpl<TState> implements Projection<TState> {
protected readonly initialState: TState;
protected readonly handlers: Record<
string,
(state: TState, event: DomainEvent) => TState
>;
public constructor(options: ProjectionOptions<TState>) {
this.initialState = options.initialState;
this.handlers = options.handlers;
}
public project(events: DomainEvent[]): TState {
let state = this.initialState;
for (const event of events) {
const handler = this.handlers[event.type];
if (handler) {
state = handler(state, event);
}
}
return state;
}
public async projectStream(
stream: AsyncIterable<DomainEvent>
): Promise<TState> {
let state = this.initialState;
for await (const event of stream) {
const handler = this.handlers[event.type];
if (handler) {
state = handler(state, event);
}
}
return state;
}
}
/**
* Creates a typed `Projection` from an initial state and handler map.
*
* @example
* ```ts
* const projection = createProjection({
* initialState: { total: 0 },
* handlers: {
* OrderPlaced: (state, event) => ({
* total: state.total + (event.payload as { amount: number }).amount,
* }),
* },
* });
* const result = projection.project(events);
* ```
*/
export function createProjection<TState>(
options: ProjectionOptions<TState>
): Projection<TState> {
return new ProjectionImpl(options);
}
export type { Projection } from "../../types/event-sourcing.types.js";

View File

@@ -0,0 +1,2 @@
export type { Projection } from "./builder.js";
export { createProjection } from "./builder.js";