Initial Commit
This commit is contained in:
@@ -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.
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { Projection } from "./builder.js";
|
||||
export { createProjection } from "./builder.js";
|
||||
Reference in New Issue
Block a user