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.
- package/LICENSE +21 -0
- package/README.md +326 -2
- package/dist/Aggregate.d.ts +127 -0
- package/dist/Aggregate.d.ts.map +1 -0
- package/dist/Aggregate.js +59 -0
- package/dist/Aggregate.js.map +1 -0
- package/dist/AggregateService.d.ts +154 -0
- package/dist/AggregateService.d.ts.map +1 -0
- package/dist/AggregateService.js +407 -0
- package/dist/AggregateService.js.map +1 -0
- package/dist/Causation.d.ts +34 -0
- package/dist/Causation.d.ts.map +1 -0
- package/dist/Causation.js +32 -0
- package/dist/Causation.js.map +1 -0
- package/dist/Checkpoints.d.ts +19 -0
- package/dist/Checkpoints.d.ts.map +1 -0
- package/dist/Checkpoints.js +18 -0
- package/dist/Checkpoints.js.map +1 -0
- package/dist/EventStore.d.ts +132 -0
- package/dist/EventStore.d.ts.map +1 -0
- package/dist/EventStore.js +156 -0
- package/dist/EventStore.js.map +1 -0
- package/dist/EventVersions.d.ts +64 -0
- package/dist/EventVersions.d.ts.map +1 -0
- package/dist/EventVersions.js +55 -0
- package/dist/EventVersions.js.map +1 -0
- package/dist/Fixture.d.ts +106 -0
- package/dist/Fixture.d.ts.map +1 -0
- package/dist/Fixture.js +149 -0
- package/dist/Fixture.js.map +1 -0
- package/dist/Metadata.d.ts +42 -0
- package/dist/Metadata.d.ts.map +1 -0
- package/dist/Metadata.js +39 -0
- package/dist/Metadata.js.map +1 -0
- package/dist/PostgresEventStore.d.ts +30 -0
- package/dist/PostgresEventStore.d.ts.map +1 -0
- package/dist/PostgresEventStore.js +153 -0
- package/dist/PostgresEventStore.js.map +1 -0
- package/dist/Saga.d.ts +105 -0
- package/dist/Saga.d.ts.map +1 -0
- package/dist/Saga.js +97 -0
- package/dist/Saga.js.map +1 -0
- package/dist/SqliteEventStore.d.ts +25 -0
- package/dist/SqliteEventStore.d.ts.map +1 -0
- package/dist/SqliteEventStore.js +142 -0
- package/dist/SqliteEventStore.js.map +1 -0
- package/dist/ViewModel.d.ts +90 -0
- package/dist/ViewModel.d.ts.map +1 -0
- package/dist/ViewModel.js +172 -0
- package/dist/ViewModel.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +13 -0
- package/dist/index.js.map +1 -0
- package/package.json +89 -3
- package/src/Aggregate.ts +205 -0
- package/src/AggregateService.ts +705 -0
- package/src/Causation.ts +48 -0
- package/src/Checkpoints.ts +23 -0
- package/src/EventStore.ts +270 -0
- package/src/EventVersions.ts +104 -0
- package/src/Fixture.ts +298 -0
- package/src/Metadata.ts +52 -0
- package/src/PostgresEventStore.ts +199 -0
- package/src/Saga.ts +176 -0
- package/src/SqliteEventStore.ts +196 -0
- package/src/ViewModel.ts +350 -0
- package/src/index.ts +12 -0
package/package.json
CHANGED
|
@@ -1,6 +1,92 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "effect-cqrs",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "CQRS and event sourcing for Effect v4, a port of moleculer-cqrs",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"effect",
|
|
7
|
+
"cqrs",
|
|
8
|
+
"event-sourcing",
|
|
9
|
+
"event-store",
|
|
10
|
+
"aggregate",
|
|
11
|
+
"projection",
|
|
12
|
+
"sqlite",
|
|
13
|
+
"postgres"
|
|
14
|
+
],
|
|
15
|
+
"homepage": "https://davidnussio.github.io/effect-cqrs/",
|
|
16
|
+
"bugs": {
|
|
17
|
+
"url": "https://github.com/davidnussio/effect-cqrs/issues"
|
|
18
|
+
},
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/davidnussio/effect-cqrs.git"
|
|
22
|
+
},
|
|
23
|
+
"type": "module",
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"author": "David Nussio",
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=24"
|
|
28
|
+
},
|
|
29
|
+
"exports": {
|
|
30
|
+
".": {
|
|
31
|
+
"types": "./dist/index.d.ts",
|
|
32
|
+
"default": "./dist/index.js"
|
|
33
|
+
},
|
|
34
|
+
"./*": {
|
|
35
|
+
"types": "./dist/*.d.ts",
|
|
36
|
+
"default": "./dist/*.js"
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
"files": [
|
|
40
|
+
"dist",
|
|
41
|
+
"src",
|
|
42
|
+
"!**/*.tsbuildinfo"
|
|
43
|
+
],
|
|
44
|
+
"sideEffects": false,
|
|
45
|
+
"publishConfig": {
|
|
46
|
+
"access": "public"
|
|
47
|
+
},
|
|
48
|
+
"peerDependencies": {
|
|
49
|
+
"effect": "^4.0.6"
|
|
50
|
+
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"@astrojs/starlight": "^0.42.7",
|
|
53
|
+
"@changesets/cli": "^2.31.1",
|
|
54
|
+
"@effect/platform-node": "^4.0.6",
|
|
55
|
+
"@effect/sql-pg": "^4.0.6",
|
|
56
|
+
"@effect/sql-sqlite-node": "^4.0.6",
|
|
57
|
+
"@effect/vitest": "^4.0.6",
|
|
58
|
+
"@tailwindcss/vite": "^4.3.3",
|
|
59
|
+
"@types/node": "^24.0.0",
|
|
60
|
+
"@types/react": "^19.3.0",
|
|
61
|
+
"@types/react-dom": "^19.3.0",
|
|
62
|
+
"@vitejs/plugin-react": "^6.1.2",
|
|
63
|
+
"astro": "^7.4.4",
|
|
64
|
+
"effect": "^4.0.6",
|
|
65
|
+
"motion": "^14.7.1",
|
|
66
|
+
"react": "^19.3.1",
|
|
67
|
+
"react-dom": "^19.3.1",
|
|
68
|
+
"tailwindcss": "^4.3.3",
|
|
69
|
+
"typescript": "^7.0.2",
|
|
70
|
+
"vite": "^8.3.5",
|
|
71
|
+
"vitest": "^5.0.5"
|
|
72
|
+
},
|
|
73
|
+
"scripts": {
|
|
74
|
+
"check": "tsc --noEmit && tsc --noEmit -p examples/bank-ui && tsc --noEmit -p examples/shop-ui",
|
|
75
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
|
76
|
+
"test": "vitest run",
|
|
77
|
+
"test:watch": "vitest",
|
|
78
|
+
"example": "node examples/todo/main.ts",
|
|
79
|
+
"tutorial": "node examples/tutorial/main.ts",
|
|
80
|
+
"ui": "vite examples/bank-ui",
|
|
81
|
+
"ui:shop": "vite examples/shop-ui",
|
|
82
|
+
"docs": "astro dev --root docs",
|
|
83
|
+
"docs:build": "vite build examples/bank-ui --base \"${DOCS_BASE:-/effect-cqrs}/demo/\" --outDir ../../docs/public/demo --emptyOutDir && vite build examples/shop-ui --base \"${DOCS_BASE:-/effect-cqrs}/shop-demo/\" --outDir ../../docs/public/shop-demo --emptyOutDir && astro build --root docs",
|
|
84
|
+
"docs:preview": "astro preview --root docs",
|
|
85
|
+
"bench": "node bench/performance.ts",
|
|
86
|
+
"stress": "node --expose-gc bench/stress.ts",
|
|
87
|
+
"changeset": "changeset",
|
|
88
|
+
"version-packages": "changeset version"
|
|
89
|
+
},
|
|
90
|
+
"main": "./dist/index.js",
|
|
91
|
+
"types": "./dist/index.d.ts"
|
|
6
92
|
}
|
package/src/Aggregate.ts
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
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, Predicate, Schema } from "effect"
|
|
9
|
+
import * as EventVersions from "./EventVersions.ts"
|
|
10
|
+
|
|
11
|
+
export interface Tagged {
|
|
12
|
+
readonly _tag: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type EventSchema<Event extends Tagged = Tagged> = Schema.Top & {
|
|
16
|
+
readonly Type: Event
|
|
17
|
+
readonly DecodingServices: never
|
|
18
|
+
readonly EncodingServices: never
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type PayloadSchema = Schema.Top & {
|
|
22
|
+
readonly DecodingServices: never
|
|
23
|
+
readonly EncodingServices: never
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Where an event sits in its stream and in the store. */
|
|
27
|
+
export interface EventMeta {
|
|
28
|
+
readonly aggregateName: string
|
|
29
|
+
readonly aggregateId: string
|
|
30
|
+
readonly version: number
|
|
31
|
+
readonly position: number
|
|
32
|
+
readonly timestamp: DateTime.Utc
|
|
33
|
+
/** Shared by everything that follows from one original request, see `Causation`. */
|
|
34
|
+
readonly correlationId: string | null
|
|
35
|
+
/** What directly caused the command that recorded the event. */
|
|
36
|
+
readonly causationId: string | null
|
|
37
|
+
/** What the command carried beside its payload, such as the software version, see `Metadata`. */
|
|
38
|
+
readonly metadata: Schema.JsonObject
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface CommandContext {
|
|
42
|
+
readonly aggregateId: string
|
|
43
|
+
readonly version: number
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Exhaustive by event tag, so adding an event to the schema without folding it does not compile. */
|
|
47
|
+
export type Projection<State, Event extends Tagged> = {
|
|
48
|
+
readonly [Tag in Event["_tag"]]: (state: State, event: Extract<Event, { readonly _tag: Tag }>, meta: EventMeta) => State
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Decides on the events a command produces. It may run several times when
|
|
53
|
+
* concurrent writers collide, so it should only decide and leave side effects to
|
|
54
|
+
* view models. Returning an empty array records nothing.
|
|
55
|
+
*/
|
|
56
|
+
export type CommandHandler<State, Payload, Event, E, R> = (
|
|
57
|
+
state: State,
|
|
58
|
+
payload: Payload,
|
|
59
|
+
context: CommandContext
|
|
60
|
+
) => Effect.Effect<Event | ReadonlyArray<Event>, E, R>
|
|
61
|
+
|
|
62
|
+
export interface Command<Payload extends PayloadSchema, State, Event, E, R> {
|
|
63
|
+
readonly payload: Payload
|
|
64
|
+
readonly handler: CommandHandler<State, Payload["Type"], Event, E, R>
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface AnyCommand {
|
|
68
|
+
readonly payload: PayloadSchema
|
|
69
|
+
readonly handler: CommandHandler<any, any, any, any, any>
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The older shapes of some events, keyed by tag. Each history has to end in the
|
|
74
|
+
* event's current shape.
|
|
75
|
+
*/
|
|
76
|
+
export type Versions<Event extends Tagged> = {
|
|
77
|
+
readonly [Tag in Event["_tag"]]?: EventVersions.Any<Extract<Event, { readonly _tag: Tag }>>
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface Aggregate<
|
|
81
|
+
Name extends string,
|
|
82
|
+
State,
|
|
83
|
+
Events extends EventSchema<any>,
|
|
84
|
+
Commands extends Record<string, AnyCommand>,
|
|
85
|
+
V extends Versions<Events["Type"]> = {}
|
|
86
|
+
> {
|
|
87
|
+
readonly name: Name
|
|
88
|
+
readonly events: Events
|
|
89
|
+
readonly initial: State
|
|
90
|
+
readonly projection: Projection<State, Events["Type"]>
|
|
91
|
+
readonly commands: Commands
|
|
92
|
+
readonly versions: V
|
|
93
|
+
/** Returns a new aggregate that also accepts this command. */
|
|
94
|
+
command<const CommandName extends string, Payload extends PayloadSchema, E = never, R = never>(
|
|
95
|
+
name: CommandName,
|
|
96
|
+
options: {
|
|
97
|
+
readonly payload: Payload
|
|
98
|
+
readonly handler: CommandHandler<State, Payload["Type"], Events["Type"], E, R>
|
|
99
|
+
}
|
|
100
|
+
): Aggregate<Name, State, Events, Commands & Record<CommandName, Command<Payload, State, Events["Type"], E, R>>, V>
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export type Any = Aggregate<string, any, EventSchema<any>, Record<string, AnyCommand>, any>
|
|
104
|
+
|
|
105
|
+
export type Name<A extends Any> = A["name"]
|
|
106
|
+
export type State<A extends Any> = A["initial"]
|
|
107
|
+
export type Event<A extends Any> = A["events"]["Type"]
|
|
108
|
+
export type Commands<A extends Any> = A["commands"]
|
|
109
|
+
export type CommandName<A extends Any> = keyof A["commands"] & string
|
|
110
|
+
export type Payload<A extends Any, K extends CommandName<A>> = A["commands"][K]["payload"]["Type"]
|
|
111
|
+
export type CommandError<A extends Any, K extends CommandName<A> = CommandName<A>> = A["commands"][K] extends
|
|
112
|
+
Command<any, any, any, infer E, any> ? E : never
|
|
113
|
+
export type Context<A extends Any> = A["commands"][CommandName<A>] extends Command<any, any, any, any, infer R> ? R
|
|
114
|
+
: never
|
|
115
|
+
|
|
116
|
+
const build = <
|
|
117
|
+
Name extends string,
|
|
118
|
+
State,
|
|
119
|
+
Events extends EventSchema<any>,
|
|
120
|
+
Commands extends Record<string, AnyCommand>,
|
|
121
|
+
V extends Versions<Events["Type"]>
|
|
122
|
+
>(
|
|
123
|
+
definition: Pick<
|
|
124
|
+
Aggregate<Name, State, Events, Commands, V>,
|
|
125
|
+
"name" | "events" | "initial" | "projection" | "commands" | "versions"
|
|
126
|
+
>
|
|
127
|
+
): Aggregate<Name, State, Events, Commands, V> => ({
|
|
128
|
+
...definition,
|
|
129
|
+
command: (name, options) =>
|
|
130
|
+
build({
|
|
131
|
+
...definition,
|
|
132
|
+
commands: { ...definition.commands, [name]: options } as any
|
|
133
|
+
})
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The state is given explicitly, `Aggregate.make<State>()({ ... })`, so that the
|
|
138
|
+
* projection's return values are checked against it rather than widened.
|
|
139
|
+
*/
|
|
140
|
+
export const make = <State>() =>
|
|
141
|
+
<const Name extends string, Events extends EventSchema<any>, const V extends Versions<Events["Type"]> = {}>(options: {
|
|
142
|
+
readonly name: Name
|
|
143
|
+
readonly events: Events
|
|
144
|
+
readonly initial: State
|
|
145
|
+
readonly projection: Projection<State, Events["Type"]>
|
|
146
|
+
/** Older shapes of events already in the store, see `EventVersions`. */
|
|
147
|
+
readonly versions?: V | undefined
|
|
148
|
+
}): Aggregate<Name, State, Events, {}, V> =>
|
|
149
|
+
build({ ...options, versions: options.versions ?? ({} as V), commands: {} })
|
|
150
|
+
|
|
151
|
+
/** Folds one event into the state. */
|
|
152
|
+
export const evolve = <A extends Any>(aggregate: A, state: State<A>, event: Event<A>, meta: EventMeta): State<A> =>
|
|
153
|
+
(aggregate.projection as Record<string, (state: State<A>, event: Event<A>, meta: EventMeta) => State<A>>)[event._tag]!(
|
|
154
|
+
state,
|
|
155
|
+
event,
|
|
156
|
+
meta
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
/** The schema version new events with this tag are written with. */
|
|
160
|
+
export const schemaVersion = (aggregate: Any, tag: string): number =>
|
|
161
|
+
EventVersions.current((aggregate.versions as Record<string, EventVersions.Any | undefined>)[tag])
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* An event as it was stored in the past, in an older version of its schema.
|
|
165
|
+
* Construct one directly to replay JSON taken from a database.
|
|
166
|
+
*/
|
|
167
|
+
export class Historical {
|
|
168
|
+
readonly type: string
|
|
169
|
+
readonly schemaVersion: number
|
|
170
|
+
readonly payload: Schema.Json
|
|
171
|
+
constructor(options: { readonly type: string; readonly schemaVersion: number; readonly payload: Schema.Json }) {
|
|
172
|
+
this.type = options.type
|
|
173
|
+
this.schemaVersion = options.schemaVersion
|
|
174
|
+
this.payload = options.payload
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Encodes an event in an older version of its schema, as code from that time
|
|
180
|
+
* would have stored it. The value is checked against that version, so a test
|
|
181
|
+
* can't seed a shape that never existed.
|
|
182
|
+
*/
|
|
183
|
+
export const historical = <
|
|
184
|
+
A extends Any,
|
|
185
|
+
const Tag extends keyof A["versions"] & string,
|
|
186
|
+
const Version extends number
|
|
187
|
+
>(
|
|
188
|
+
aggregate: A,
|
|
189
|
+
tag: Tag,
|
|
190
|
+
version: Version,
|
|
191
|
+
value: EventVersions.SchemaAt<NonNullable<A["versions"][Tag]>, Version>["Type"]
|
|
192
|
+
): Historical => {
|
|
193
|
+
const versions = (aggregate.versions as Record<string, EventVersions.Any | undefined>)[tag]
|
|
194
|
+
const schema = versions?.schemas[version - 1]
|
|
195
|
+
if (schema === undefined) {
|
|
196
|
+
throw new Error(`${aggregate.name} has no version ${version} of ${tag}`)
|
|
197
|
+
}
|
|
198
|
+
const payload = Schema.encodeSync(Schema.toCodecJson(schema))(value) as Schema.Json
|
|
199
|
+
return new Historical({
|
|
200
|
+
type: tag,
|
|
201
|
+
schemaVersion: version,
|
|
202
|
+
// Events were always stored with their tag, whatever the schema of the time said.
|
|
203
|
+
payload: Predicate.isObject(payload) ? { ...(payload as object), _tag: tag } as Schema.Json : payload
|
|
204
|
+
})
|
|
205
|
+
}
|