effect-cqrs 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +326 -2
  3. package/dist/Aggregate.d.ts +127 -0
  4. package/dist/Aggregate.d.ts.map +1 -0
  5. package/dist/Aggregate.js +59 -0
  6. package/dist/Aggregate.js.map +1 -0
  7. package/dist/AggregateService.d.ts +154 -0
  8. package/dist/AggregateService.d.ts.map +1 -0
  9. package/dist/AggregateService.js +407 -0
  10. package/dist/AggregateService.js.map +1 -0
  11. package/dist/Causation.d.ts +34 -0
  12. package/dist/Causation.d.ts.map +1 -0
  13. package/dist/Causation.js +32 -0
  14. package/dist/Causation.js.map +1 -0
  15. package/dist/Checkpoints.d.ts +19 -0
  16. package/dist/Checkpoints.d.ts.map +1 -0
  17. package/dist/Checkpoints.js +18 -0
  18. package/dist/Checkpoints.js.map +1 -0
  19. package/dist/EventStore.d.ts +132 -0
  20. package/dist/EventStore.d.ts.map +1 -0
  21. package/dist/EventStore.js +156 -0
  22. package/dist/EventStore.js.map +1 -0
  23. package/dist/EventVersions.d.ts +64 -0
  24. package/dist/EventVersions.d.ts.map +1 -0
  25. package/dist/EventVersions.js +55 -0
  26. package/dist/EventVersions.js.map +1 -0
  27. package/dist/Fixture.d.ts +106 -0
  28. package/dist/Fixture.d.ts.map +1 -0
  29. package/dist/Fixture.js +149 -0
  30. package/dist/Fixture.js.map +1 -0
  31. package/dist/Metadata.d.ts +42 -0
  32. package/dist/Metadata.d.ts.map +1 -0
  33. package/dist/Metadata.js +39 -0
  34. package/dist/Metadata.js.map +1 -0
  35. package/dist/PostgresEventStore.d.ts +30 -0
  36. package/dist/PostgresEventStore.d.ts.map +1 -0
  37. package/dist/PostgresEventStore.js +153 -0
  38. package/dist/PostgresEventStore.js.map +1 -0
  39. package/dist/Saga.d.ts +105 -0
  40. package/dist/Saga.d.ts.map +1 -0
  41. package/dist/Saga.js +97 -0
  42. package/dist/Saga.js.map +1 -0
  43. package/dist/SqliteEventStore.d.ts +25 -0
  44. package/dist/SqliteEventStore.d.ts.map +1 -0
  45. package/dist/SqliteEventStore.js +142 -0
  46. package/dist/SqliteEventStore.js.map +1 -0
  47. package/dist/ViewModel.d.ts +90 -0
  48. package/dist/ViewModel.d.ts.map +1 -0
  49. package/dist/ViewModel.js +172 -0
  50. package/dist/ViewModel.js.map +1 -0
  51. package/dist/index.d.ts +13 -0
  52. package/dist/index.d.ts.map +1 -0
  53. package/dist/index.js +13 -0
  54. package/dist/index.js.map +1 -0
  55. package/package.json +89 -3
  56. package/src/Aggregate.ts +205 -0
  57. package/src/AggregateService.ts +705 -0
  58. package/src/Causation.ts +48 -0
  59. package/src/Checkpoints.ts +23 -0
  60. package/src/EventStore.ts +270 -0
  61. package/src/EventVersions.ts +104 -0
  62. package/src/Fixture.ts +298 -0
  63. package/src/Metadata.ts +52 -0
  64. package/src/PostgresEventStore.ts +199 -0
  65. package/src/Saga.ts +176 -0
  66. package/src/SqliteEventStore.ts +196 -0
  67. package/src/ViewModel.ts +350 -0
  68. package/src/index.ts +12 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2019-2026 David Nussio
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,327 @@
1
- # Temporary Holding Version
1
+ # effect-cqrs
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ This is a trial port of an old project,
4
+ [moleculer-cqrs](https://github.com/davidnussio/moleculer-cqrs), to
5
+ [Effect](https://effect.website) v4. The original is a CQRS and event sourcing module
6
+ for [Moleculer](https://moleculer.services), written in 2019. This repository
7
+ rebuilds the same ideas on Effect to find out how well they carry over.
8
+
9
+ It's an experiment, not a maintained library: the API may change, and
10
+ [Limitations](#limitations) lists what the port leaves out.
11
+
12
+ The documentation, with a walkthrough of the bank demo, is at
13
+ https://davidnussio.github.io/effect-cqrs/.
14
+
15
+ Commands decide on events, events are the only source of truth, and read models
16
+ are projections you can throw away and rebuild.
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ pnpm add effect-cqrs effect
22
+ ```
23
+
24
+ It needs Node 24 or later and Effect 4. To store events in SQLite or PostgreSQL, add
25
+ that database's `effect/sql` driver too, `@effect/sql-sqlite-node` or `@effect/sql-pg`.
26
+
27
+ ## Develop
28
+
29
+ ```bash
30
+ pnpm install
31
+ pnpm test
32
+ pnpm example
33
+ ```
34
+
35
+ The example needs `node:sqlite` and runs TypeScript directly. `pnpm build` compiles
36
+ `src` into `dist`, which is what npm gets.
37
+
38
+ `pnpm ui` serves an interactive demo at http://localhost:5173, also published at
39
+ https://davidnussio.github.io/effect-cqrs/demo/. It runs the framework
40
+ in the browser on the in-memory store: a bank whose accounts are aggregates, three
41
+ view models, and a saga for transfers. Its devtools slow commands and projections
42
+ down, pause and step the command bus, crash a projection, scrub through the log and
43
+ replay view models up to a chosen event.
44
+
45
+ ## Define an aggregate
46
+
47
+ An aggregate is a schema of events, a projection folding them into state, and
48
+ commands deciding on new events.
49
+
50
+ ```ts
51
+ import { DateTime, Effect, Schema } from "effect"
52
+ import { Aggregate } from "effect-cqrs"
53
+
54
+ const TodoEvent = Schema.TaggedUnion({
55
+ TodoCreated: { title: Schema.NonEmptyString, createdAt: Schema.DateTimeUtc },
56
+ TodoDeleted: { deletedAt: Schema.DateTimeUtc }
57
+ })
58
+
59
+ class TodoAlreadyDeleted extends Schema.TaggedError<TodoAlreadyDeleted>()("TodoAlreadyDeleted", {}) {}
60
+
61
+ type TodoState = { readonly title: string; readonly deleted: boolean }
62
+
63
+ const Todo = Aggregate.make<TodoState>()({
64
+ name: "todo",
65
+ events: TodoEvent,
66
+ initial: { title: "", deleted: false },
67
+ projection: {
68
+ TodoCreated: (state, { title }) => ({ ...state, title }),
69
+ TodoDeleted: (state) => ({ ...state, deleted: true })
70
+ }
71
+ })
72
+ .command("createTodo", {
73
+ payload: Schema.Struct({ title: Schema.NonEmptyString }),
74
+ handler: Effect.fnUntraced(function*(_, { title }) {
75
+ return TodoEvent.cases.TodoCreated.make({ title, createdAt: yield* DateTime.now })
76
+ })
77
+ })
78
+ .command("deleteTodo", {
79
+ payload: Schema.Struct({}),
80
+ handler: Effect.fnUntraced(function*(state) {
81
+ if (state.deleted) return yield* new TodoAlreadyDeleted()
82
+ return TodoEvent.cases.TodoDeleted.make({ deletedAt: yield* DateTime.now })
83
+ })
84
+ })
85
+ ```
86
+
87
+ The projection must cover every event or it doesn't compile. A handler returns one
88
+ event, several, or `[]` to record nothing. It can use services, fail with typed
89
+ errors, and may run more than once, so it should only decide. Projections and
90
+ handlers must not mutate the state, which is kept between commands.
91
+
92
+ When an event's schema changes, the stored events keep their old shape. The aggregate
93
+ declares the older shapes and how each becomes the next, and they are upcast as they
94
+ are read:
95
+
96
+ ```ts
97
+ const Todo = Aggregate.make<TodoState>()({
98
+ // ...
99
+ versions: {
100
+ TodoCreated: EventVersions.from(TodoCreatedV1).next(TodoEvent.cases.TodoCreated, (v1) => ({ ...v1, dueDate: null }))
101
+ }
102
+ })
103
+ ```
104
+
105
+ ## Run commands
106
+
107
+ ```ts
108
+ import { AggregateService, EventStore } from "effect-cqrs"
109
+
110
+ const program = Effect.gen(function*() {
111
+ const todos = yield* AggregateService.make(Todo)
112
+
113
+ // Typed call
114
+ yield* todos.commands.createTodo("todo-1", { title: "Buy Milk" })
115
+
116
+ // Untyped JSON from a transport, validated before running
117
+ yield* todos.dispatch({ aggregateId: "todo-2", type: "createTodo", payload: { title: "Buy Eggs" } })
118
+
119
+ yield* todos.readModel("todo-1")
120
+ yield* todos.readModel("todo-1", { finishTime }) // state as it was before finishTime
121
+ yield* todos.history("todo-1", { payload: true })
122
+ }).pipe(Effect.provide(EventStore.layerMemory))
123
+ ```
124
+
125
+ Commands on one stream take turns inside a service, so they can't race. A handler must
126
+ not run a command on its own stream, which would wait for itself. Across processes
127
+ writers use optimistic concurrency: a command that loses a race backs off, reloads the
128
+ state and decides again, up to `maxRetries` times (5 by default), then fails with
129
+ `ConcurrencyConflict`.
130
+
131
+ The service remembers the state of recent aggregates (`stateCacheSize`, 10,000 by
132
+ default), so a command only reads the events it hasn't seen. With
133
+ `snapshots: { every, schema, version }` it also saves states through a `KeyValueStore`
134
+ from `effect/persistence`, so a restart replays only what came after the last one.
135
+
136
+ Every event records a `correlationId` and a `causationId`. A command a view model runs
137
+ gets the handled event as its cause, so a chain of reactions can be traced back to the
138
+ request that started it. `Causation.withCausation` sets the ids from outside.
139
+
140
+ Commands can also carry metadata, a JSON object for what the domain doesn't decide on,
141
+ such as the software version or the user who sent them. It is recorded on every event they
142
+ write. `Metadata.layer({ appVersion })` sets it for the whole application and
143
+ `Metadata.withMetadata` adds to it per request.
144
+
145
+ ## Project into view models
146
+
147
+ A view model reads the event store from its own checkpoint, so it catches up after
148
+ downtime and can't miss an event. Delivery is at least once, so handlers should be
149
+ idempotent.
150
+
151
+ ```ts
152
+ import { Checkpoints, ViewModel } from "effect-cqrs"
153
+
154
+ const TodoList = ViewModel.make({
155
+ name: "todo-list",
156
+ aggregates: [Todo],
157
+ dispose: clearList, // runs before a replay
158
+ handlers: {
159
+ TodoCreated: ({ title }, { aggregateId }) => addToList(aggregateId, title),
160
+ TodoDeleted: (_, { aggregateId }) => removeFromList(aggregateId)
161
+ }
162
+ })
163
+
164
+ const program = Effect.gen(function*() {
165
+ const todos = yield* AggregateService.make(Todo)
166
+ const viewModels = yield* ViewModel.ViewModels
167
+
168
+ const { position } = yield* todos.commands.createTodo("todo-1", { title: "Buy Milk" })
169
+ yield* viewModels.caughtUp(position) // read your own write
170
+
171
+ yield* viewModels.replay({ viewModels: ["todo-list"] })
172
+ yield* viewModels.status
173
+ }).pipe(
174
+ Effect.provide(ViewModel.layer([TodoList]).pipe(
175
+ Layer.provideMerge(Layer.merge(EventStore.layerMemory, Checkpoints.layerMemory))
176
+ ))
177
+ )
178
+ ```
179
+
180
+ A failing handler stalls its view model on that event and retries with backoff, so
181
+ events are never skipped or reordered. `viewModels.status` exposes the failure.
182
+
183
+ Anything else that reacts to events can use `store.subscribe(filter)`, a stream of
184
+ past events followed by live ones.
185
+
186
+ ## Test an aggregate
187
+
188
+ `Fixture` runs commands through the real service on an in-memory store, so payload
189
+ validation and event encoding are tested too. It reads like `CQRSFixture`, and the
190
+ chain runs when you yield it or hand it to `it.effect`:
191
+
192
+ ```ts
193
+ import { assert, it } from "@effect/vitest"
194
+ import { Fixture } from "effect-cqrs"
195
+
196
+ it.effect("creates a todo", () =>
197
+ Fixture.make(Todo)
198
+ .givenEvents()
199
+ .when("createTodo", { title: "Buy Milk" })
200
+ .expectEvent(TodoEvent.cases.TodoCreated.make({ title: "Buy Milk", createdAt: DateTime.makeUnsafe(0) }))
201
+ .inspectState((state) => assert.strictEqual(state.title, "Buy Milk")))
202
+
203
+ it.effect("runs commands one after another", () =>
204
+ Fixture.make(Todo)
205
+ .givenEvents([created])
206
+ .when("deleteTodo", {})
207
+ .expectEvent("TodoDeleted")
208
+ .when("createTodo", { title: "Buy Eggs" })
209
+ .expectEvents(["TodoCreated"]))
210
+
211
+ it.effect("rejects deleting twice", () =>
212
+ Effect.gen(function*() {
213
+ const error = yield* Fixture.make(Todo)
214
+ .givenEvents([created, deleted])
215
+ .whenFails("deleteTodo", {})
216
+
217
+ assert.strictEqual(error._tag, "TodoAlreadyDeleted")
218
+ }))
219
+ ```
220
+
221
+ - `when` runs a command by name, typed against the aggregate. Commands that fail
222
+ stop the chain with their typed error.
223
+ - `expectEvent` expects exactly one event and `expectEvents` an exact, ordered list,
224
+ so `expectEvents([])` asserts that nothing was recorded. Each entry is a whole
225
+ event, compared with the aggregate's schema, or only its tag.
226
+ - `inspectState` takes a callback that throws to fail the test, so any assertion
227
+ library works.
228
+ - Yielding the chain gives `{ events, state, version, fixture }` for the last
229
+ command, if you prefer plain assertions.
230
+ - `givenEvents` also takes events in an older schema version, built with
231
+ `Aggregate.historical(Todo, "TodoCreated", 1, { ... })`, to test upcasting.
232
+ - `checkInvariants({ invariant })` runs random command sequences generated from the
233
+ payload schemas and reports the shortest one that breaks the predicate.
234
+
235
+ `it.effect` runs on a test clock stopped at zero, so timestamps need no mocking: an
236
+ event created by `DateTime.now` has `DateTime.makeUnsafe(0)` until
237
+ `TestClock.adjust` moves time.
238
+
239
+ ## Storage
240
+
241
+ `EventStore.layerMemory` keeps events in memory. For SQLite, provide any
242
+ `effect/sql` SQLite client:
243
+
244
+ ```ts
245
+ import { SqliteClient } from "@effect/sql-sqlite-node"
246
+ import { SqliteEventStore } from "effect-cqrs"
247
+
248
+ const Storage = Layer.merge(SqliteEventStore.layer(), SqliteEventStore.layerCheckpoints()).pipe(
249
+ Layer.provide(SqliteClient.layer({ filename: "./data/events.sqlite" }))
250
+ )
251
+ ```
252
+
253
+ Tables are created on startup. Use durable checkpoints only with a durable view
254
+ model: an in-memory view model paired with `Checkpoints.layerMemory` rebuilds itself
255
+ on every start, which is usually what you want.
256
+
257
+ For PostgreSQL, provide an `effect/sql` PostgreSQL client:
258
+
259
+ ```ts
260
+ import { PgClient } from "@effect/sql-pg"
261
+ import { PostgresEventStore } from "effect-cqrs"
262
+
263
+ const Storage = Layer.merge(PostgresEventStore.layer(), PostgresEventStore.layerCheckpoints()).pipe(
264
+ Layer.provide(PgClient.layer({ url: Redacted.make("postgres://localhost:5432/cqrs") }))
265
+ )
266
+ ```
267
+
268
+ Writers wait for each other at one step of each commit, which keeps positions in commit
269
+ order. Several processes can write to one database, but a view model only wakes on
270
+ appends from its own process. After a bulk load, run `ANALYZE`, or reads for view models
271
+ are slow until autovacuum gets to the table.
272
+
273
+ To run the tests on Postgres, `docker compose up -d --wait` starts one, and setting
274
+ `POSTGRES_URL=postgres://postgres:postgres@localhost:5432/cqrs` includes it in
275
+ `pnpm test`.
276
+
277
+ Another database needs an `EventStore.Backend`: `append`, `readBatch` and `head`.
278
+ Positions must follow commit order, so a backend on a database with concurrent
279
+ writers has to guarantee that itself.
280
+
281
+ ## Performance
282
+
283
+ On a SQLite file a command takes about 0.1 ms, however long its stream is and however
284
+ many events the store holds, and a million events replay in under 5 seconds. On Postgres
285
+ a command takes about 1 ms. The first use of a stream after a restart replays it, at
286
+ about 3 µs per event. See the [performance and stress report](bench/REPORT.md) for the
287
+ numbers and the remaining limits.
288
+
289
+ `pnpm bench` measures each operation and `pnpm stress` loads the framework while
290
+ checking that nothing is lost.
291
+
292
+ ## Coming from moleculer-cqrs
293
+
294
+ | moleculer-cqrs | effect-cqrs |
295
+ | --- | --- |
296
+ | `CQRSEventSourcing({ aggregate })` mixin | `AggregateService.make(aggregate)` |
297
+ | `call todo.command` | `todos.commands.x(id, payload)` or `todos.dispatch(json)` |
298
+ | `call todo.read-model` | `todos.readModel(id, { finishTime })` |
299
+ | `call todo.history` | `todos.history(id, { payload, startTime, finishTime })` |
300
+ | `call todo.replay` | `viewModels.replay({ viewModels, startTime, finishTime })` |
301
+ | view model service with `events` | `ViewModel.make({ handlers, dispose })` |
302
+ | `new CQRSFixture(aggregate)` | `Fixture.make(aggregate)` |
303
+ | `.givenEvents([...])` | `.givenEvents([...])` |
304
+ | `.when(createNews, payload)` | `.when("createNews", payload)` |
305
+ | `.expectEvent(event)` | `.expectEvent(event)` or `.expectEvent("Tag")` |
306
+ | `.inspectState(cb)` | `.inspectState(cb)` |
307
+ | `.whenThrow(command).toThrow(...)` | `.whenFails(command, payload)`, which returns the error |
308
+ | `fastest-validator` | `Schema` |
309
+ | `throw` in a command | typed errors |
310
+ | `resolve-storage-*` adapters | `EventStore.Backend` |
311
+
312
+ The largest change is on the read side. moleculer-cqrs broadcasts events over the
313
+ broker and only replay recovers one that was lost; here view models pull from the
314
+ store, so `replay` is for rebuilding, not for recovery.
315
+
316
+ ## Limitations
317
+
318
+ - **New events stay in their process.** They wake subscribers in the same process only.
319
+ There is no transport equivalent to the Moleculer broker, so `dispatch` is the entry
320
+ point to wire into your own HTTP or RPC layer.
321
+ - **SQLite, PostgreSQL and memory only.** There are no adapters for MongoDB or MySQL.
322
+ - **No code generator.** There is no equivalent of `cqrs generate`.
323
+ - **Snapshots are opt-in.** Without `snapshots`, recent state is only kept in memory, so
324
+ after a restart each stream is replayed the first time it is used. A changed state
325
+ needs a new snapshot `version` by hand.
326
+ - **Event upcasts are pure.** Older event shapes declared with `EventVersions` are
327
+ upcast on read, but an upcast can't read a service or fail with its own error.
@@ -0,0 +1,127 @@
1
+ /**
2
+ * The write side of the domain: a schema of events, a projection that folds them
3
+ * into state, and commands that inspect that state and decide on new events.
4
+ *
5
+ * An aggregate is plain data. {@link AggregateService} gives it a store to run
6
+ * against, and `Fixture` runs it in tests.
7
+ */
8
+ import { type DateTime, type Effect, Schema } from "effect";
9
+ import * as EventVersions from "./EventVersions.ts";
10
+ export interface Tagged {
11
+ readonly _tag: string;
12
+ }
13
+ export type EventSchema<Event extends Tagged = Tagged> = Schema.Top & {
14
+ readonly Type: Event;
15
+ readonly DecodingServices: never;
16
+ readonly EncodingServices: never;
17
+ };
18
+ export type PayloadSchema = Schema.Top & {
19
+ readonly DecodingServices: never;
20
+ readonly EncodingServices: never;
21
+ };
22
+ /** Where an event sits in its stream and in the store. */
23
+ export interface EventMeta {
24
+ readonly aggregateName: string;
25
+ readonly aggregateId: string;
26
+ readonly version: number;
27
+ readonly position: number;
28
+ readonly timestamp: DateTime.Utc;
29
+ /** Shared by everything that follows from one original request, see `Causation`. */
30
+ readonly correlationId: string | null;
31
+ /** What directly caused the command that recorded the event. */
32
+ readonly causationId: string | null;
33
+ /** What the command carried beside its payload, such as the software version, see `Metadata`. */
34
+ readonly metadata: Schema.JsonObject;
35
+ }
36
+ export interface CommandContext {
37
+ readonly aggregateId: string;
38
+ readonly version: number;
39
+ }
40
+ /** Exhaustive by event tag, so adding an event to the schema without folding it does not compile. */
41
+ export type Projection<State, Event extends Tagged> = {
42
+ readonly [Tag in Event["_tag"]]: (state: State, event: Extract<Event, {
43
+ readonly _tag: Tag;
44
+ }>, meta: EventMeta) => State;
45
+ };
46
+ /**
47
+ * Decides on the events a command produces. It may run several times when
48
+ * concurrent writers collide, so it should only decide and leave side effects to
49
+ * view models. Returning an empty array records nothing.
50
+ */
51
+ export type CommandHandler<State, Payload, Event, E, R> = (state: State, payload: Payload, context: CommandContext) => Effect.Effect<Event | ReadonlyArray<Event>, E, R>;
52
+ export interface Command<Payload extends PayloadSchema, State, Event, E, R> {
53
+ readonly payload: Payload;
54
+ readonly handler: CommandHandler<State, Payload["Type"], Event, E, R>;
55
+ }
56
+ export interface AnyCommand {
57
+ readonly payload: PayloadSchema;
58
+ readonly handler: CommandHandler<any, any, any, any, any>;
59
+ }
60
+ /**
61
+ * The older shapes of some events, keyed by tag. Each history has to end in the
62
+ * event's current shape.
63
+ */
64
+ export type Versions<Event extends Tagged> = {
65
+ readonly [Tag in Event["_tag"]]?: EventVersions.Any<Extract<Event, {
66
+ readonly _tag: Tag;
67
+ }>>;
68
+ };
69
+ export interface Aggregate<Name extends string, State, Events extends EventSchema<any>, Commands extends Record<string, AnyCommand>, V extends Versions<Events["Type"]> = {}> {
70
+ readonly name: Name;
71
+ readonly events: Events;
72
+ readonly initial: State;
73
+ readonly projection: Projection<State, Events["Type"]>;
74
+ readonly commands: Commands;
75
+ readonly versions: V;
76
+ /** Returns a new aggregate that also accepts this command. */
77
+ command<const CommandName extends string, Payload extends PayloadSchema, E = never, R = never>(name: CommandName, options: {
78
+ readonly payload: Payload;
79
+ readonly handler: CommandHandler<State, Payload["Type"], Events["Type"], E, R>;
80
+ }): Aggregate<Name, State, Events, Commands & Record<CommandName, Command<Payload, State, Events["Type"], E, R>>, V>;
81
+ }
82
+ export type Any = Aggregate<string, any, EventSchema<any>, Record<string, AnyCommand>, any>;
83
+ export type Name<A extends Any> = A["name"];
84
+ export type State<A extends Any> = A["initial"];
85
+ export type Event<A extends Any> = A["events"]["Type"];
86
+ export type Commands<A extends Any> = A["commands"];
87
+ export type CommandName<A extends Any> = keyof A["commands"] & string;
88
+ export type Payload<A extends Any, K extends CommandName<A>> = A["commands"][K]["payload"]["Type"];
89
+ export type CommandError<A extends Any, K extends CommandName<A> = CommandName<A>> = A["commands"][K] extends Command<any, any, any, infer E, any> ? E : never;
90
+ export type Context<A extends Any> = A["commands"][CommandName<A>] extends Command<any, any, any, any, infer R> ? R : never;
91
+ /**
92
+ * The state is given explicitly, `Aggregate.make<State>()({ ... })`, so that the
93
+ * projection's return values are checked against it rather than widened.
94
+ */
95
+ export declare const make: <State>() => <const Name extends string, Events extends EventSchema<any>, const V extends Versions<Events["Type"]> = {}>(options: {
96
+ readonly name: Name;
97
+ readonly events: Events;
98
+ readonly initial: State;
99
+ readonly projection: Projection<State, Events["Type"]>;
100
+ /** Older shapes of events already in the store, see `EventVersions`. */
101
+ readonly versions?: V | undefined;
102
+ }) => Aggregate<Name, State, Events, {}, V>;
103
+ /** Folds one event into the state. */
104
+ export declare const evolve: <A extends Any>(aggregate: A, state: State<A>, event: Event<A>, meta: EventMeta) => State<A>;
105
+ /** The schema version new events with this tag are written with. */
106
+ export declare const schemaVersion: (aggregate: Any, tag: string) => number;
107
+ /**
108
+ * An event as it was stored in the past, in an older version of its schema.
109
+ * Construct one directly to replay JSON taken from a database.
110
+ */
111
+ export declare class Historical {
112
+ readonly type: string;
113
+ readonly schemaVersion: number;
114
+ readonly payload: Schema.Json;
115
+ constructor(options: {
116
+ readonly type: string;
117
+ readonly schemaVersion: number;
118
+ readonly payload: Schema.Json;
119
+ });
120
+ }
121
+ /**
122
+ * Encodes an event in an older version of its schema, as code from that time
123
+ * would have stored it. The value is checked against that version, so a test
124
+ * can't seed a shape that never existed.
125
+ */
126
+ export declare const historical: <A extends Any, const Tag extends keyof A["versions"] & string, const Version extends number>(aggregate: A, tag: Tag, version: Version, value: EventVersions.SchemaAt<NonNullable<A["versions"][Tag]>, Version>["Type"]) => Historical;
127
+ //# sourceMappingURL=Aggregate.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Aggregate.d.ts","sourceRoot":"","sources":["../src/Aggregate.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,59 @@
1
+ /**
2
+ * The write side of the domain: a schema of events, a projection that folds them
3
+ * into state, and commands that inspect that state and decide on new events.
4
+ *
5
+ * An aggregate is plain data. {@link AggregateService} gives it a store to run
6
+ * against, and `Fixture` runs it in tests.
7
+ */
8
+ import { Predicate, Schema } from "effect";
9
+ import * as EventVersions from "./EventVersions.js";
10
+ const build = (definition) => ({
11
+ ...definition,
12
+ command: (name, options) => build({
13
+ ...definition,
14
+ commands: { ...definition.commands, [name]: options }
15
+ })
16
+ });
17
+ /**
18
+ * The state is given explicitly, `Aggregate.make<State>()({ ... })`, so that the
19
+ * projection's return values are checked against it rather than widened.
20
+ */
21
+ export const make = () => (options) => build({ ...options, versions: options.versions ?? {}, commands: {} });
22
+ /** Folds one event into the state. */
23
+ export const evolve = (aggregate, state, event, meta) => aggregate.projection[event._tag](state, event, meta);
24
+ /** The schema version new events with this tag are written with. */
25
+ export const schemaVersion = (aggregate, tag) => EventVersions.current(aggregate.versions[tag]);
26
+ /**
27
+ * An event as it was stored in the past, in an older version of its schema.
28
+ * Construct one directly to replay JSON taken from a database.
29
+ */
30
+ export class Historical {
31
+ type;
32
+ schemaVersion;
33
+ payload;
34
+ constructor(options) {
35
+ this.type = options.type;
36
+ this.schemaVersion = options.schemaVersion;
37
+ this.payload = options.payload;
38
+ }
39
+ }
40
+ /**
41
+ * Encodes an event in an older version of its schema, as code from that time
42
+ * would have stored it. The value is checked against that version, so a test
43
+ * can't seed a shape that never existed.
44
+ */
45
+ export const historical = (aggregate, tag, version, value) => {
46
+ const versions = aggregate.versions[tag];
47
+ const schema = versions?.schemas[version - 1];
48
+ if (schema === undefined) {
49
+ throw new Error(`${aggregate.name} has no version ${version} of ${tag}`);
50
+ }
51
+ const payload = Schema.encodeSync(Schema.toCodecJson(schema))(value);
52
+ return new Historical({
53
+ type: tag,
54
+ schemaVersion: version,
55
+ // Events were always stored with their tag, whatever the schema of the time said.
56
+ payload: Predicate.isObject(payload) ? { ...payload, _tag: tag } : payload
57
+ });
58
+ };
59
+ //# sourceMappingURL=Aggregate.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Aggregate.js","sourceRoot":"","sources":["../src/Aggregate.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,OAAO,EAA8B,SAAS,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAA;AACtE,OAAO,KAAK,aAAa,MAAM,oBAAoB,CAAA;AA2GnD,MAAM,KAAK,GAAG,CAOZ,UAGC,EAC4C,EAAE,CAAC,CAAC;IACjD,GAAG,UAAU;IACb,OAAO,EAAE,CAAC,IAAI,EAAE,OAAO,EAAE,EAAE,CACzB,KAAK,CAAC;QACJ,GAAG,UAAU;QACb,QAAQ,EAAE,EAAE,GAAG,UAAU,CAAC,QAAQ,EAAE,CAAC,IAAI,CAAC,EAAE,OAAO,EAAS;KAC7D,CAAC;CACL,CAAC,CAAA;AAEF;;;GAGG;AACH,MAAM,CAAC,MAAM,IAAI,GAAG,GAAU,EAAE,CAChC,CAA4G,OAO3G,EAAyC,EAAE,CAC1C,KAAK,CAAC,EAAE,GAAG,OAAO,EAAE,QAAQ,EAAE,OAAO,CAAC,QAAQ,IAAK,EAAQ,EAAE,QAAQ,EAAE,EAAE,EAAE,CAAC,CAAA;AAE9E,sCAAsC;AACtC,MAAM,CAAC,MAAM,MAAM,GAAG,CAAgB,SAAY,EAAE,KAAe,EAAE,KAAe,EAAE,IAAe,EAAY,EAAE,CAChH,SAAS,CAAC,UAA8F,CAAC,KAAK,CAAC,IAAI,CAAE,CACpH,KAAK,EACL,KAAK,EACL,IAAI,CACL,CAAA;AAEH,oEAAoE;AACpE,MAAM,CAAC,MAAM,aAAa,GAAG,CAAC,SAAc,EAAE,GAAW,EAAU,EAAE,CACnE,aAAa,CAAC,OAAO,CAAE,SAAS,CAAC,QAA0D,CAAC,GAAG,CAAC,CAAC,CAAA;AAEnG;;;GAGG;AACH,MAAM,OAAO,UAAU;IACZ,IAAI,CAAQ;IACZ,aAAa,CAAQ;IACrB,OAAO,CAAa;IAC7B,YAAY,OAAiG;QAC3G,IAAI,CAAC,IAAI,GAAG,OAAO,CAAC,IAAI,CAAA;QACxB,IAAI,CAAC,aAAa,GAAG,OAAO,CAAC,aAAa,CAAA;QAC1C,IAAI,CAAC,OAAO,GAAG,OAAO,CAAC,OAAO,CAAA;IAChC,CAAC;CACF;AAED;;;;GAIG;AACH,MAAM,CAAC,MAAM,UAAU,GAAG,CAKxB,SAAY,EACZ,GAAQ,EACR,OAAgB,EAChB,KAA+E,EACnE,EAAE;IACd,MAAM,QAAQ,GAAI,SAAS,CAAC,QAA0D,CAAC,GAAG,CAAC,CAAA;IAC3F,MAAM,MAAM,GAAG,QAAQ,EAAE,OAAO,CAAC,OAAO,GAAG,CAAC,CAAC,CAAA;IAC7C,IAAI,MAAM,KAAK,SAAS,EAAE,CAAC;QACzB,MAAM,IAAI,KAAK,CAAC,GAAG,SAAS,CAAC,IAAI,mBAAmB,OAAO,OAAO,GAAG,EAAE,CAAC,CAAA;IAC1E,CAAC;IACD,MAAM,OAAO,GAAG,MAAM,CAAC,UAAU,CAAC,MAAM,CAAC,WAAW,CAAC,MAAM,CAAC,CAAC,CAAC,KAAK,CAAgB,CAAA;IACnF,OAAO,IAAI,UAAU,CAAC;QACpB,IAAI,EAAE,GAAG;QACT,aAAa,EAAE,OAAO;QACtB,kFAAkF;QAClF,OAAO,EAAE,SAAS,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,EAAE,GAAI,OAAkB,EAAE,IAAI,EAAE,GAAG,EAAiB,CAAC,CAAC,CAAC,OAAO;KACtG,CAAC,CAAA;AACJ,CAAC,CAAA"}