@arki/event-sourcing 0.1.0 → 0.1.2
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/dist/dot.d.ts +14 -8
- package/dist/dot.d.ts.map +1 -1
- package/dist/dot.js +29 -7
- package/dist/dot.js.map +1 -1
- package/package.json +10 -4
- package/src/builders/command-handler.ts +77 -0
- package/src/builders/command.ts +72 -0
- package/src/builders/decider.ts +288 -0
- package/src/builders/event.ts +68 -0
- package/src/builders/index.ts +40 -0
- package/src/builders/process-manager.ts +153 -0
- package/src/builders/projection.ts +151 -0
- package/src/builders/stateful-process-manager.ts +338 -0
- package/src/bus.ts +3 -0
- package/src/command-flow.ts +491 -0
- package/src/command.ts +52 -0
- package/src/debug.ts +36 -0
- package/src/decide.ts +10 -0
- package/src/dot.ts +161 -0
- package/src/error.ts +1 -0
- package/src/event-sourcing-features.ts +215 -0
- package/src/event.ts +46 -0
- package/src/index.ts +10 -0
- package/src/process-manager.ts +335 -0
- package/src/simple-process-manager.ts +70 -0
- package/src/store.ts +367 -0
- package/src/types.ts +9 -0
- package/dist/package.json +0 -5
package/dist/dot.d.ts
CHANGED
|
@@ -37,9 +37,20 @@
|
|
|
37
37
|
* load — that is intentional: the adapter only makes sense in a DOT app.
|
|
38
38
|
*/
|
|
39
39
|
import type { EventStore, MessageBus } from '@event-driven-io/emmett';
|
|
40
|
-
import type {
|
|
40
|
+
import type { EmptyShape, Pip } from '@arki/dot/pip';
|
|
41
41
|
import type { CommandHandlerRegistration } from './command.js';
|
|
42
42
|
import type { PostgreSQLProjectionInput } from './event-sourcing-features.js';
|
|
43
|
+
/**
|
|
44
|
+
* Stable error codes thrown by the event-sourcing pip. Exported so consumers
|
|
45
|
+
* and coding agents can match against them — never parse the message.
|
|
46
|
+
*
|
|
47
|
+
* @see packages/dot/docs/principles.md — principle 1.3 ("errors are part
|
|
48
|
+
* of the API") and principle 4 ("agent-discoverable everywhere").
|
|
49
|
+
*/
|
|
50
|
+
export declare const EVENT_SOURCING_PIP_ERROR_CODES: {
|
|
51
|
+
/** boot was called without a configured event-store URL. */
|
|
52
|
+
readonly dbUrlNotConfigured: "EVENT_SOURCING_PIP_E001";
|
|
53
|
+
};
|
|
43
54
|
/**
|
|
44
55
|
* Options for the event-sourcing DOT adapter.
|
|
45
56
|
*/
|
|
@@ -64,11 +75,6 @@ export type EventSourcingDotOptions = {
|
|
|
64
75
|
* `process.env`. If none are set, `boot` throws.
|
|
65
76
|
*/
|
|
66
77
|
readonly dbUrl?: string;
|
|
67
|
-
/**
|
|
68
|
-
* Pip name override. Defaults to `'event-sourcing'`. Use this only
|
|
69
|
-
* when composing multiple event-sourcing scopes inside the same app.
|
|
70
|
-
*/
|
|
71
|
-
readonly name?: string;
|
|
72
78
|
};
|
|
73
79
|
/** Services published by the event-sourcing adapter. */
|
|
74
80
|
export type EventSourcingServices = {
|
|
@@ -83,8 +89,8 @@ export type EventSourcingServices = {
|
|
|
83
89
|
/**
|
|
84
90
|
* Build a DOT pip that opens the event store, wires command handlers
|
|
85
91
|
* into an in-memory message bus, and publishes both as services. The
|
|
86
|
-
* kernel calls `dispose` in reverse
|
|
92
|
+
* kernel calls `dispose` in reverse declaration order to release the
|
|
87
93
|
* underlying PG pool.
|
|
88
94
|
*/
|
|
89
|
-
export declare function eventSourcing(options: EventSourcingDotOptions):
|
|
95
|
+
export declare function eventSourcing(options: EventSourcingDotOptions): Pip<EmptyShape, EventSourcingServices>;
|
|
90
96
|
//# sourceMappingURL=dot.d.ts.map
|
package/dist/dot.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dot.d.ts","sourceRoot":"","sources":["../src/dot.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,yBAAyB,CAAC;AAEtE,OAAO,KAAK,EAAE,
|
|
1
|
+
{"version":3,"file":"dot.d.ts","sourceRoot":"","sources":["../src/dot.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,yBAAyB,CAAC;AAEtE,OAAO,KAAK,EAAE,UAAU,EAAE,GAAG,EAAE,MAAM,eAAe,CAAC;AAGrD,OAAO,KAAK,EAAE,0BAA0B,EAAE,MAAM,cAAc,CAAC;AAC/D,OAAO,KAAK,EAAE,yBAAyB,EAAE,MAAM,8BAA8B,CAAC;AAG9E;;;;;;GAMG;AACH,eAAO,MAAM,8BAA8B;IACzC,4DAA4D;;CAEpD,CAAC;AAEX;;GAEG;AACH,MAAM,MAAM,uBAAuB,GAAG;IACpC;;;;OAIG;IACH,QAAQ,CAAC,WAAW,EAAE,SAAS,yBAAyB,EAAE,CAAC;IAC3D;;;;;;OAMG;IACH,QAAQ,CAAC,eAAe,CAAC,EAAE,SAAS,0BAA0B,EAAE,CAAC;IACjE;;;;OAIG;IACH,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;CACzB,CAAC;AAEF,wDAAwD;AACxD,MAAM,MAAM,qBAAqB,GAAG;IAClC,mEAAmE;IACnE,QAAQ,CAAC,UAAU,EAAE,UAAU,CAAC;IAChC;;;OAGG;IACH,QAAQ,CAAC,UAAU,EAAE,UAAU,CAAC;CACjC,CAAC;AAgBF;;;;;GAKG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE,uBAAuB,GAAG,GAAG,CAAC,UAAU,EAAE,qBAAqB,CAAC,CA2CtG"}
|
package/dist/dot.js
CHANGED
|
@@ -36,8 +36,19 @@
|
|
|
36
36
|
* Importing this adapter without `@arki/dot` installed will fail at module
|
|
37
37
|
* load — that is intentional: the adapter only makes sense in a DOT app.
|
|
38
38
|
*/
|
|
39
|
-
import {
|
|
39
|
+
import { pip, DotPipError } from '@arki/dot/pip';
|
|
40
40
|
import { EVENT_STORE_URL_VARIANTS, eventSourcingFeatures } from './event-sourcing-features.js';
|
|
41
|
+
/**
|
|
42
|
+
* Stable error codes thrown by the event-sourcing pip. Exported so consumers
|
|
43
|
+
* and coding agents can match against them — never parse the message.
|
|
44
|
+
*
|
|
45
|
+
* @see packages/dot/docs/principles.md — principle 1.3 ("errors are part
|
|
46
|
+
* of the API") and principle 4 ("agent-discoverable everywhere").
|
|
47
|
+
*/
|
|
48
|
+
export const EVENT_SOURCING_PIP_ERROR_CODES = {
|
|
49
|
+
/** boot was called without a configured event-store URL. */
|
|
50
|
+
dbUrlNotConfigured: 'EVENT_SOURCING_PIP_E001',
|
|
51
|
+
};
|
|
41
52
|
/**
|
|
42
53
|
* Resolve the event-store connection URL from explicit options first, then
|
|
43
54
|
* from the recognised env vars in priority order. Returns `undefined` when
|
|
@@ -56,29 +67,40 @@ function resolveDbUrl(explicit) {
|
|
|
56
67
|
/**
|
|
57
68
|
* Build a DOT pip that opens the event store, wires command handlers
|
|
58
69
|
* into an in-memory message bus, and publishes both as services. The
|
|
59
|
-
* kernel calls `dispose` in reverse
|
|
70
|
+
* kernel calls `dispose` in reverse declaration order to release the
|
|
60
71
|
* underlying PG pool.
|
|
61
72
|
*/
|
|
62
73
|
export function eventSourcing(options) {
|
|
63
|
-
const name = options.name ?? 'event-sourcing';
|
|
64
74
|
const commandHandlers = options.commandHandlers ?? [];
|
|
65
75
|
// Captured at boot so dispose can call it without re-reading services
|
|
66
76
|
// (dispose is allowed to run even when services failed to publish).
|
|
67
77
|
let closeStore;
|
|
68
|
-
return
|
|
69
|
-
name,
|
|
78
|
+
return pip({
|
|
79
|
+
name: 'event-sourcing',
|
|
70
80
|
version: '0.1.0',
|
|
71
|
-
provides: ['event-store', 'message-bus'],
|
|
72
81
|
configure(ctx) {
|
|
73
82
|
ctx.registerService('eventStore', 'event-store');
|
|
74
83
|
ctx.registerService('messageBus', 'message-bus');
|
|
84
|
+
ctx.declareProvides('event-store', 'message-bus');
|
|
75
85
|
},
|
|
76
86
|
boot() {
|
|
87
|
+
// Validate at the pip boundary so the DOT lifecycle gets a coded
|
|
88
|
+
// error. `eventSourcingFeatures.initEventSourcing` still throws raw
|
|
89
|
+
// `Error` for non-DOT consumers (its public contract is unchanged);
|
|
90
|
+
// the check here makes sure we never reach it without a URL.
|
|
77
91
|
const dbUrl = resolveDbUrl(options.dbUrl);
|
|
92
|
+
if (dbUrl === undefined) {
|
|
93
|
+
throw new DotPipError({
|
|
94
|
+
code: EVENT_SOURCING_PIP_ERROR_CODES.dbUrlNotConfigured,
|
|
95
|
+
message: '[event-sourcing] Event Store database URL is not configured.',
|
|
96
|
+
remediation: `Pass options.dbUrl to eventSourcing(...) or set one of ${EVENT_STORE_URL_VARIANTS.join(', ')} in the environment before booting the app.`,
|
|
97
|
+
docsUrl: 'https://arki.dev/dot/errors/event-sourcing-pip-e001',
|
|
98
|
+
});
|
|
99
|
+
}
|
|
78
100
|
const { eventStore, close } = eventSourcingFeatures.initEventSourcing(options.projections, dbUrl);
|
|
79
101
|
closeStore = close;
|
|
80
102
|
const messageBus = eventSourcingFeatures.initMessageBus(eventStore, [...commandHandlers]);
|
|
81
|
-
return {
|
|
103
|
+
return { eventStore, messageBus };
|
|
82
104
|
},
|
|
83
105
|
async dispose() {
|
|
84
106
|
if (closeStore !== undefined) {
|
package/dist/dot.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dot.js","sourceRoot":"","sources":["../src/dot.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AAKH,OAAO,EAAE,
|
|
1
|
+
{"version":3,"file":"dot.js","sourceRoot":"","sources":["../src/dot.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AAKH,OAAO,EAAE,GAAG,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAIjD,OAAO,EAAE,wBAAwB,EAAE,qBAAqB,EAAE,MAAM,8BAA8B,CAAC;AAE/F;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,8BAA8B,GAAG;IAC5C,4DAA4D;IAC5D,kBAAkB,EAAE,yBAAyB;CACrC,CAAC;AAuCX;;;;GAIG;AACH,SAAS,YAAY,CAAC,QAA4B;IAChD,IAAI,QAAQ,KAAK,SAAS,IAAI,QAAQ,KAAK,EAAE;QAAE,OAAO,QAAQ,CAAC;IAC/D,KAAK,MAAM,IAAI,IAAI,wBAAwB,EAAE,CAAC;QAC5C,MAAM,KAAK,GAAG,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;QAChC,IAAI,KAAK,KAAK,SAAS,IAAI,KAAK,KAAK,EAAE;YAAE,OAAO,KAAK,CAAC;IACxD,CAAC;IACD,OAAO,SAAS,CAAC;AACnB,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,aAAa,CAAC,OAAgC;IAC5D,MAAM,eAAe,GAAG,OAAO,CAAC,eAAe,IAAI,EAAE,CAAC;IAEtD,sEAAsE;IACtE,oEAAoE;IACpE,IAAI,UAA6C,CAAC;IAElD,OAAO,GAAG,CAAC;QACT,IAAI,EAAE,gBAAgB;QACtB,OAAO,EAAE,OAAO;QAChB,SAAS,CAAC,GAAG;YACX,GAAG,CAAC,eAAe,CAAC,YAAY,EAAE,aAAa,CAAC,CAAC;YACjD,GAAG,CAAC,eAAe,CAAC,YAAY,EAAE,aAAa,CAAC,CAAC;YACjD,GAAG,CAAC,eAAe,CAAC,aAAa,EAAE,aAAa,CAAC,CAAC;QACpD,CAAC;QACD,IAAI;YACF,iEAAiE;YACjE,oEAAoE;YACpE,oEAAoE;YACpE,6DAA6D;YAC7D,MAAM,KAAK,GAAG,YAAY,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC;YAC1C,IAAI,KAAK,KAAK,SAAS,EAAE,CAAC;gBACxB,MAAM,IAAI,WAAW,CAAC;oBACpB,IAAI,EAAE,8BAA8B,CAAC,kBAAkB;oBACvD,OAAO,EAAE,8DAA8D;oBACvE,WAAW,EAAE,0DAA0D,wBAAwB,CAAC,IAAI,CAClG,IAAI,CACL,6CAA6C;oBAC9C,OAAO,EAAE,qDAAqD;iBAC/D,CAAC,CAAC;YACL,CAAC;YACD,MAAM,EAAE,UAAU,EAAE,KAAK,EAAE,GAAG,qBAAqB,CAAC,iBAAiB,CAAC,OAAO,CAAC,WAAW,EAAE,KAAK,CAAC,CAAC;YAClG,UAAU,GAAG,KAAK,CAAC;YACnB,MAAM,UAAU,GAAG,qBAAqB,CAAC,cAAc,CAAC,UAAU,EAAE,CAAC,GAAG,eAAe,CAAC,CAAC,CAAC;YAC1F,OAAO,EAAE,UAAU,EAAE,UAAU,EAAE,CAAC;QACpC,CAAC;QACD,KAAK,CAAC,OAAO;YACX,IAAI,UAAU,KAAK,SAAS,EAAE,CAAC;gBAC7B,MAAM,UAAU,EAAE,CAAC;gBACnB,UAAU,GAAG,SAAS,CAAC;YACzB,CAAC;QACH,CAAC;KACF,CAAC,CAAC;AACL,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arki/event-sourcing",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Event sourcing primitives — event store, message bus, projections, process managers — built on Emmett for ARKI.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -74,20 +74,26 @@
|
|
|
74
74
|
},
|
|
75
75
|
"files": [
|
|
76
76
|
"dist/**",
|
|
77
|
+
"src/**",
|
|
78
|
+
"!src/**/*.test.*",
|
|
79
|
+
"!src/**/*.spec.*",
|
|
80
|
+
"!src/**/tests/**",
|
|
81
|
+
"!src/**/test/**",
|
|
82
|
+
"!src/**/__tests__/**",
|
|
77
83
|
"README.md",
|
|
78
84
|
"LICENSE",
|
|
79
85
|
"package.json"
|
|
80
86
|
],
|
|
81
87
|
"dependencies": {
|
|
82
|
-
"@arki/contracts": "0.0.
|
|
83
|
-
"@arki/log": "0.0.
|
|
88
|
+
"@arki/contracts": "0.0.2",
|
|
89
|
+
"@arki/log": "0.0.2",
|
|
84
90
|
"@event-driven-io/emmett": "^0.41.0",
|
|
85
91
|
"@event-driven-io/emmett-postgresql": "^0.41.0",
|
|
86
92
|
"ansis": "^3.3.2",
|
|
87
93
|
"drizzle-orm": "1.0.0-rc.1"
|
|
88
94
|
},
|
|
89
95
|
"peerDependencies": {
|
|
90
|
-
"@arki/dot": "^0.1.
|
|
96
|
+
"@arki/dot": "^0.1.2"
|
|
91
97
|
},
|
|
92
98
|
"peerDependenciesMeta": {
|
|
93
99
|
"@arki/dot": {
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DeciderCommandHandler
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
} from '@event-driven-io/emmett';
|
|
9
|
+
import type {Command, CommandHandlerResult, Event, EventStore, HandleOptions} from '@event-driven-io/emmett';
|
|
10
|
+
|
|
11
|
+
import { debugBuilder } from '../debug.js';
|
|
12
|
+
|
|
13
|
+
type DeciderConfig<State, CommandType, StreamEvent> = {
|
|
14
|
+
initialState: () => State;
|
|
15
|
+
evolve: (state: State, event: StreamEvent) => State;
|
|
16
|
+
decide: (command: CommandType, state: State) => StreamEvent | StreamEvent[];
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Store-connected command handler function type.
|
|
21
|
+
*
|
|
22
|
+
* Given an event store, stream id, and one or more commands, it loads the
|
|
23
|
+
* current aggregate state from the stream, applies the decider's `decide`
|
|
24
|
+
* function, and appends the resulting events.
|
|
25
|
+
*/
|
|
26
|
+
export type CommandHandler<State, CommandType, StreamEvent extends Event> = <Store extends EventStore>(
|
|
27
|
+
eventStore: Store,
|
|
28
|
+
id: string,
|
|
29
|
+
commands: CommandType | CommandType[],
|
|
30
|
+
handleOptions?: HandleOptions<Store>,
|
|
31
|
+
) => Promise<CommandHandlerResult<State, StreamEvent, Store>>;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Creates a store-connected command handler from a decider configuration.
|
|
35
|
+
*
|
|
36
|
+
* This is the recommended way to create command handlers. It wraps Emmett's
|
|
37
|
+
* `DeciderCommandHandler` while lifting the `CommandType extends Command`
|
|
38
|
+
* constraint that prevents commands with custom metadata from type-checking.
|
|
39
|
+
*
|
|
40
|
+
* Emmett's `Command` type uses a conditional type for metadata:
|
|
41
|
+
* - When metadata is `undefined`, the `metadata` property is optional.
|
|
42
|
+
* - When metadata is defined, it becomes required.
|
|
43
|
+
*
|
|
44
|
+
* TypeScript cannot prove that a concrete command (with custom metadata)
|
|
45
|
+
* satisfies `extends Command` across these conditional branches. This wrapper
|
|
46
|
+
* bridges that gap with a single, localised type assertion — keeping all
|
|
47
|
+
* call sites fully type-safe.
|
|
48
|
+
*
|
|
49
|
+
* @example
|
|
50
|
+
* ```typescript
|
|
51
|
+
* const handler = defineCommandHandler({
|
|
52
|
+
* initialState,
|
|
53
|
+
* evolve,
|
|
54
|
+
* decide,
|
|
55
|
+
* });
|
|
56
|
+
*
|
|
57
|
+
* export const bookCommandHandlers = BookCommands.map(commandType => ({
|
|
58
|
+
* commandType,
|
|
59
|
+
* handler,
|
|
60
|
+
* getStreamName,
|
|
61
|
+
* }));
|
|
62
|
+
* ```
|
|
63
|
+
*/
|
|
64
|
+
export function defineCommandHandler<State, CommandType, StreamEvent extends Event>(
|
|
65
|
+
config: DeciderConfig<State, CommandType, StreamEvent>,
|
|
66
|
+
): CommandHandler<State, CommandType, StreamEvent> {
|
|
67
|
+
debugBuilder('[command-handler] Creating command handler');
|
|
68
|
+
|
|
69
|
+
// DeciderCommandHandler constrains CommandType extends Command. At runtime
|
|
70
|
+
// the decide/evolve functions are called with the concrete command values —
|
|
71
|
+
// no metadata shape is enforced. We bridge the compile-time gap here.
|
|
72
|
+
type EmmettConfig = Parameters<typeof DeciderCommandHandler<State, Command, StreamEvent>>[0];
|
|
73
|
+
const emmettHandler = DeciderCommandHandler(config as EmmettConfig);
|
|
74
|
+
|
|
75
|
+
debugBuilder('[command-handler] Command handler created successfully');
|
|
76
|
+
return emmettHandler as CommandHandler<State, CommandType, StreamEvent>;
|
|
77
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { z } from '@arki/contracts';
|
|
2
|
+
|
|
3
|
+
import type { Command, DefaultCommandMetadata } from '../command.js';
|
|
4
|
+
import type { DefaultRecord } from '../types.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Configuration for defining a domain command factory.
|
|
8
|
+
*/
|
|
9
|
+
export type CommandConfig<
|
|
10
|
+
TType extends string,
|
|
11
|
+
TInput extends DefaultRecord,
|
|
12
|
+
TMetadata extends DefaultCommandMetadata | undefined = undefined,
|
|
13
|
+
> = {
|
|
14
|
+
/** The command type string (e.g., 'CreateUser') */
|
|
15
|
+
type: TType;
|
|
16
|
+
/** Zod schema for validating command input */
|
|
17
|
+
inputSchema: z.ZodType<TInput>;
|
|
18
|
+
/** Optional Zod schema for validating command metadata */
|
|
19
|
+
metadataSchema?: z.ZodType<TMetadata>;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Creates a strongly-typed command factory function.
|
|
24
|
+
*
|
|
25
|
+
* This factory validates input and metadata using Zod schemas before
|
|
26
|
+
* creating commands that can be dispatched through a command flow or aggregate.
|
|
27
|
+
*
|
|
28
|
+
* @param config - Configuration object containing type, input schema, and optional metadata schema
|
|
29
|
+
* @returns A factory function that validates and creates commands
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* ```ts
|
|
33
|
+
* const createUserCommand = defineCommand({
|
|
34
|
+
* type: 'CreateUser',
|
|
35
|
+
* inputSchema: z.object({ name: z.string(), email: z.email() }),
|
|
36
|
+
* metadataSchema: z.object({ now: z.date(), issuedBy: z.string().uuid() }),
|
|
37
|
+
* });
|
|
38
|
+
*
|
|
39
|
+
* const command = createUserCommand(
|
|
40
|
+
* { name: 'Ada', email: 'ada@example.org' },
|
|
41
|
+
* { now: new Date(), issuedBy: 'usr_123' },
|
|
42
|
+
* );
|
|
43
|
+
* ```
|
|
44
|
+
*/
|
|
45
|
+
export function defineCommand<
|
|
46
|
+
TType extends string,
|
|
47
|
+
TInput extends DefaultRecord,
|
|
48
|
+
TMetadata extends DefaultCommandMetadata | undefined = undefined,
|
|
49
|
+
>(
|
|
50
|
+
config: CommandConfig<TType, TInput, TMetadata>,
|
|
51
|
+
): (input: TInput, metadata?: TMetadata) => Command<TType, TInput, TMetadata> {
|
|
52
|
+
const { type, inputSchema, metadataSchema } = config;
|
|
53
|
+
|
|
54
|
+
return (input: TInput, metadata?: TMetadata) => {
|
|
55
|
+
const validatedInput = inputSchema.parse(input);
|
|
56
|
+
const validatedMetadata = metadataSchema ? metadataSchema.parse(metadata) : metadata;
|
|
57
|
+
|
|
58
|
+
// Conditionally construct the command object based on whether metadata is provided
|
|
59
|
+
return (validatedMetadata === undefined
|
|
60
|
+
? {
|
|
61
|
+
type,
|
|
62
|
+
data: validatedInput,
|
|
63
|
+
kind: 'Command' as const,
|
|
64
|
+
}
|
|
65
|
+
: {
|
|
66
|
+
type,
|
|
67
|
+
data: validatedInput,
|
|
68
|
+
metadata: validatedMetadata,
|
|
69
|
+
kind: 'Command' as const,
|
|
70
|
+
}) as unknown as Command<TType, TInput, TMetadata>;
|
|
71
|
+
};
|
|
72
|
+
}
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import type { Event as EmmettEvent } from '@event-driven-io/emmett';
|
|
2
|
+
import type { z } from '@arki/contracts';
|
|
3
|
+
|
|
4
|
+
import type { AnyEvent, Event } from '../event.js';
|
|
5
|
+
import { debugBuilder } from '../debug.js';
|
|
6
|
+
import { defineCommandHandler } from './command-handler.js';
|
|
7
|
+
import type {CommandHandler} from './command-handler.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Applies a domain event to the current aggregate state and produces the next state.
|
|
11
|
+
*/
|
|
12
|
+
type Evolve<State, StreamEvent extends AnyEvent> = (state: State, event: StreamEvent) => State;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Computes the domain events that should be emitted for an incoming command,
|
|
16
|
+
* based on the aggregate's current state.
|
|
17
|
+
*/
|
|
18
|
+
type Decide<State, Command, StreamEvent extends AnyEvent> = (command: Command, state: State) => StreamEvent[];
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Fluent builder for aggregate behaviour (aka "decider") definitions.
|
|
22
|
+
*
|
|
23
|
+
* In many event-sourcing texts this role is called an *aggregate*: an object that owns
|
|
24
|
+
* the authoritative state, knows how to rebuild itself from events (`evolve`), and
|
|
25
|
+
* decides which new events should be recorded when commands arrive (`decide`).
|
|
26
|
+
*
|
|
27
|
+
* We keep the historical Emmett name `Decider` to match upstream types, but the builder
|
|
28
|
+
* intentionally documents the aggregate semantics so domain code can align on that
|
|
29
|
+
* mental model.
|
|
30
|
+
*
|
|
31
|
+
* Capabilities:
|
|
32
|
+
* - Type safety through TypeScript generics
|
|
33
|
+
* - Optional Zod schemas to validate state evolution at runtime
|
|
34
|
+
* - Chainable configuration that terminates with `.handler()`, returning the executed aggregate contract
|
|
35
|
+
*
|
|
36
|
+
* @example
|
|
37
|
+
* ```typescript
|
|
38
|
+
* const cartAggregate = defineDecider()
|
|
39
|
+
* .stateSchema(z.object({ items: z.array(z.string()), total: z.number() }))
|
|
40
|
+
* .initialState(() => ({ items: [], total: 0 }))
|
|
41
|
+
* .evolve((state, event) => {
|
|
42
|
+
* if (event.type === 'ItemAdded') {
|
|
43
|
+
* return {
|
|
44
|
+
* items: [...state.items, event.data.item],
|
|
45
|
+
* total: state.total + event.data.price,
|
|
46
|
+
* };
|
|
47
|
+
* }
|
|
48
|
+
* return state;
|
|
49
|
+
* })
|
|
50
|
+
* .decide((command, state) => {
|
|
51
|
+
* if (command.type === 'AddItem') {
|
|
52
|
+
* return [
|
|
53
|
+
* {
|
|
54
|
+
* type: 'ItemAdded',
|
|
55
|
+
* data: { item: command.data.item, price: command.data.price },
|
|
56
|
+
* },
|
|
57
|
+
* ];
|
|
58
|
+
* }
|
|
59
|
+
* return [];
|
|
60
|
+
* })
|
|
61
|
+
* .handler();
|
|
62
|
+
*
|
|
63
|
+
* const initial = cartAggregate.initialState();
|
|
64
|
+
* const events = cartAggregate.decide(addItemCommand, initial);
|
|
65
|
+
* const next = cartAggregate.evolve(initial, events[0]);
|
|
66
|
+
* ```
|
|
67
|
+
*/
|
|
68
|
+
class DeciderBuilder<State = never, Command = never, StreamEvent extends AnyEvent = never> {
|
|
69
|
+
private _stateSchema?: z.ZodType<State>;
|
|
70
|
+
private _initialStateFn?: () => State;
|
|
71
|
+
private _evolveFn?: Evolve<State, StreamEvent>;
|
|
72
|
+
private _decideFn?: Decide<State, Command, StreamEvent>;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Creates a deep copy of the builder to ensure immutability
|
|
76
|
+
* @private
|
|
77
|
+
*/
|
|
78
|
+
private clone(): DeciderBuilder<State, Command, StreamEvent> {
|
|
79
|
+
const builder = new DeciderBuilder<State, Command, StreamEvent>();
|
|
80
|
+
builder._stateSchema = this._stateSchema;
|
|
81
|
+
builder._initialStateFn = this._initialStateFn;
|
|
82
|
+
builder._evolveFn = this._evolveFn;
|
|
83
|
+
builder._decideFn = this._decideFn;
|
|
84
|
+
return builder;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Sets the state schema for state validation
|
|
89
|
+
* @param schema - Zod schema for validating state
|
|
90
|
+
* @returns A new DeciderBuilder instance with the state schema set
|
|
91
|
+
*/
|
|
92
|
+
stateSchema<TNewState>(schema: z.ZodType<TNewState>): DeciderBuilder<TNewState, Command, StreamEvent> {
|
|
93
|
+
const builder = new DeciderBuilder<TNewState, Command, StreamEvent>();
|
|
94
|
+
builder._stateSchema = schema;
|
|
95
|
+
builder._initialStateFn = this._initialStateFn as (() => TNewState) | undefined;
|
|
96
|
+
builder._evolveFn = this._evolveFn as Evolve<TNewState, StreamEvent> | undefined;
|
|
97
|
+
builder._decideFn = this._decideFn as Decide<TNewState, Command, StreamEvent> | undefined;
|
|
98
|
+
debugBuilder('[decider] Set state schema');
|
|
99
|
+
return builder;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Sets the initial state function
|
|
104
|
+
* @param fn - Function that returns the initial state for a new aggregate
|
|
105
|
+
* @returns A new DeciderBuilder instance with the initial state function set
|
|
106
|
+
*/
|
|
107
|
+
initialState(fn: () => State): DeciderBuilder<State, Command, StreamEvent> {
|
|
108
|
+
const builder = this.clone();
|
|
109
|
+
builder._initialStateFn = fn;
|
|
110
|
+
debugBuilder('[decider] Set initial state function');
|
|
111
|
+
return builder;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Sets the evolve function
|
|
116
|
+
*
|
|
117
|
+
* This method is generic and will infer the StreamEvent type from the function parameter,
|
|
118
|
+
* allowing TypeScript to properly infer complex discriminated union types.
|
|
119
|
+
*
|
|
120
|
+
* @template TStreamEvent - The union type of all events (inferred from fn parameter)
|
|
121
|
+
* @param fn - Function that applies an event to state to compute the next state
|
|
122
|
+
* @returns A new DeciderBuilder instance with the evolve function and inferred event types set
|
|
123
|
+
*/
|
|
124
|
+
evolve<TStreamEvent extends AnyEvent>(fn: Evolve<State, TStreamEvent>): DeciderBuilder<State, Command, TStreamEvent> {
|
|
125
|
+
const builder = new DeciderBuilder<State, Command, TStreamEvent>();
|
|
126
|
+
builder._stateSchema = this._stateSchema;
|
|
127
|
+
builder._initialStateFn = this._initialStateFn;
|
|
128
|
+
builder._evolveFn = fn;
|
|
129
|
+
builder._decideFn = this._decideFn as Decide<State, Command, TStreamEvent> | undefined;
|
|
130
|
+
debugBuilder('[decider] Set evolve function');
|
|
131
|
+
return builder;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Sets the decide function
|
|
136
|
+
*
|
|
137
|
+
* This method is generic and will infer the Command type from the function parameter,
|
|
138
|
+
* allowing TypeScript to properly infer complex discriminated union types.
|
|
139
|
+
*
|
|
140
|
+
* @template TCommand - The union type of all commands (inferred from fn parameter)
|
|
141
|
+
* @param fn - Function that determines which events to emit for a given command and state
|
|
142
|
+
* @returns A new DeciderBuilder instance with the decide function and inferred command types set
|
|
143
|
+
*/
|
|
144
|
+
decide<TCommand>(fn: Decide<State, TCommand, StreamEvent>): DeciderBuilder<State, TCommand, StreamEvent> {
|
|
145
|
+
const builder = new DeciderBuilder<State, TCommand, StreamEvent>();
|
|
146
|
+
builder._stateSchema = this._stateSchema;
|
|
147
|
+
builder._initialStateFn = this._initialStateFn;
|
|
148
|
+
builder._evolveFn = this._evolveFn;
|
|
149
|
+
builder._decideFn = fn;
|
|
150
|
+
debugBuilder('[decider] Set decide function');
|
|
151
|
+
return builder;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Terminal method that creates the decider
|
|
156
|
+
*
|
|
157
|
+
* Validates that all required functions are set and returns an object
|
|
158
|
+
* with three validated functions:
|
|
159
|
+
* - initialState: Returns the validated initial state
|
|
160
|
+
* - evolve: Applies an event to state with validation
|
|
161
|
+
* - decide: Determines events to emit with validation
|
|
162
|
+
*
|
|
163
|
+
* @returns An object containing the three core decider functions
|
|
164
|
+
* @throws Error if state schema, initial state function, evolve function, or decide function are not defined
|
|
165
|
+
*/
|
|
166
|
+
handler(): {
|
|
167
|
+
initialState: () => State;
|
|
168
|
+
evolve: Evolve<State, StreamEvent>;
|
|
169
|
+
decide: Decide<State, Command, StreamEvent>;
|
|
170
|
+
} {
|
|
171
|
+
if (!this._stateSchema || !this._initialStateFn || !this._evolveFn || !this._decideFn) {
|
|
172
|
+
debugBuilder('[decider] ERROR: Missing required properties');
|
|
173
|
+
throw new Error(
|
|
174
|
+
'Decider must have state schema, initial state function, evolve function, and decide function defined',
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const stateSchema = this._stateSchema;
|
|
179
|
+
const initialStateFn = this._initialStateFn;
|
|
180
|
+
const evolveFn = this._evolveFn;
|
|
181
|
+
const decideFn = this._decideFn;
|
|
182
|
+
|
|
183
|
+
debugBuilder('[decider] Building decider handler');
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
initialState: () => {
|
|
187
|
+
debugBuilder('[decider] Getting initial state');
|
|
188
|
+
const state = initialStateFn();
|
|
189
|
+
return stateSchema.parse(state);
|
|
190
|
+
},
|
|
191
|
+
evolve: (state: State, event: StreamEvent) => {
|
|
192
|
+
debugBuilder('[decider] Evolving state with event: %s', (event as Event).type);
|
|
193
|
+
const validatedState = stateSchema.parse(state);
|
|
194
|
+
const nextState = evolveFn(validatedState, event);
|
|
195
|
+
return stateSchema.parse(nextState);
|
|
196
|
+
},
|
|
197
|
+
decide: (command: Command, state: State) => {
|
|
198
|
+
debugBuilder('[decider] Deciding events for command: %s', (command as { type: string }).type);
|
|
199
|
+
const validatedState = stateSchema.parse(state);
|
|
200
|
+
const events = decideFn(command, validatedState);
|
|
201
|
+
debugBuilder('[decider] Generated %d event(s)', events.length);
|
|
202
|
+
return events;
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Terminal method that creates a store-connected command handler.
|
|
209
|
+
*
|
|
210
|
+
* Unlike `.handler()`, this does **not** require a state schema — it creates
|
|
211
|
+
* a handler that can be registered directly with an event store. Internally
|
|
212
|
+
* it delegates to `defineCommandHandler`, which bridges the Emmett type
|
|
213
|
+
* constraint on command metadata.
|
|
214
|
+
*
|
|
215
|
+
* Use this when you need a handler that reads/writes events from a store
|
|
216
|
+
* (the common case for aggregates), rather than the raw decider functions.
|
|
217
|
+
*
|
|
218
|
+
* @returns A store-connected command handler function
|
|
219
|
+
* @throws Error if initial state, evolve, or decide functions are not defined
|
|
220
|
+
*
|
|
221
|
+
* @example
|
|
222
|
+
* ```typescript
|
|
223
|
+
* const handler = defineDecider()
|
|
224
|
+
* .initialState(() => null as BookState)
|
|
225
|
+
* .evolve(evolve)
|
|
226
|
+
* .decide(decide)
|
|
227
|
+
* .commandHandler();
|
|
228
|
+
* ```
|
|
229
|
+
*/
|
|
230
|
+
commandHandler(): StreamEvent extends EmmettEvent ? CommandHandler<State, Command, StreamEvent> : never {
|
|
231
|
+
if (!this._initialStateFn || !this._evolveFn || !this._decideFn) {
|
|
232
|
+
debugBuilder('[decider] ERROR: Missing required properties for command handler');
|
|
233
|
+
throw new Error(
|
|
234
|
+
'Decider must have initial state function, evolve function, and decide function defined to create a command handler',
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
debugBuilder('[decider] Building store-connected command handler');
|
|
239
|
+
return defineCommandHandler({
|
|
240
|
+
initialState: this._initialStateFn,
|
|
241
|
+
evolve: this._evolveFn,
|
|
242
|
+
decide: this._decideFn,
|
|
243
|
+
}) as StreamEvent extends EmmettEvent ? CommandHandler<State, Command, StreamEvent> : never;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Factory function to create a new DeciderBuilder
|
|
249
|
+
*
|
|
250
|
+
* **Recommended API**: This builder API supports complex discriminated unions
|
|
251
|
+
* through parameter-based type inference. The `.evolve()` and `.decide()` methods
|
|
252
|
+
* are generic and will automatically infer types from your function parameters.
|
|
253
|
+
*
|
|
254
|
+
* **Use cases:**
|
|
255
|
+
* - ✅ All cases (simple and complex)
|
|
256
|
+
* - ✅ Complex discriminated unions (10+ command/event types)
|
|
257
|
+
* - ✅ Fluent, chainable API preference
|
|
258
|
+
* - ✅ Automatic type inference from function parameters
|
|
259
|
+
*
|
|
260
|
+
* @example
|
|
261
|
+
* ```typescript
|
|
262
|
+
* // Works for both simple and complex union types
|
|
263
|
+
* const decider = defineDecider()
|
|
264
|
+
* .stateSchema(myStateSchema)
|
|
265
|
+
* .initialState(() => ({ ... }))
|
|
266
|
+
* .evolve((state, event) => {
|
|
267
|
+
* // TypeScript infers event type from this function
|
|
268
|
+
* switch (event.type) {
|
|
269
|
+
* case 'EventA': return { ... };
|
|
270
|
+
* case 'EventB': return { ... };
|
|
271
|
+
* // ... handles 10+ event types correctly
|
|
272
|
+
* }
|
|
273
|
+
* })
|
|
274
|
+
* .decide((command, state) => {
|
|
275
|
+
* // TypeScript infers command type from this function
|
|
276
|
+
* switch (command.type) {
|
|
277
|
+
* case 'CommandA': return [{ type: 'EventA', ... }];
|
|
278
|
+
* case 'CommandB': return [{ type: 'EventB', ... }];
|
|
279
|
+
* // ... handles 10+ command types correctly
|
|
280
|
+
* }
|
|
281
|
+
* })
|
|
282
|
+
* .handler();
|
|
283
|
+
* ```
|
|
284
|
+
*/
|
|
285
|
+
export function defineDecider(): DeciderBuilder {
|
|
286
|
+
debugBuilder('[decider] Creating new decider builder');
|
|
287
|
+
return new DeciderBuilder();
|
|
288
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { z } from '@arki/contracts';
|
|
2
|
+
|
|
3
|
+
import type { Event } from '../event.js';
|
|
4
|
+
import type { DefaultRecord } from '../types.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Configuration for defining a domain event factory.
|
|
8
|
+
*/
|
|
9
|
+
export type EventConfig<
|
|
10
|
+
TType extends string,
|
|
11
|
+
TData extends DefaultRecord,
|
|
12
|
+
TMetadata extends DefaultRecord | undefined = undefined,
|
|
13
|
+
> = {
|
|
14
|
+
/** The event type string (e.g., 'UserCreated') */
|
|
15
|
+
type: TType;
|
|
16
|
+
/** Zod schema for validating event data */
|
|
17
|
+
dataSchema: z.ZodType<TData>;
|
|
18
|
+
/** Optional Zod schema for validating event metadata */
|
|
19
|
+
metadataSchema?: z.ZodType<TMetadata>;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Creates a strongly-typed event factory function.
|
|
24
|
+
*
|
|
25
|
+
* This factory validates data and metadata using Zod schemas before
|
|
26
|
+
* creating events that can be appended to Emmett's event store.
|
|
27
|
+
*
|
|
28
|
+
* @param config - Configuration object containing type, data schema, and optional metadata schema
|
|
29
|
+
* @returns A factory function that validates and creates events
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* ```ts
|
|
33
|
+
* const userCreatedEvent = defineEvent({
|
|
34
|
+
* type: 'UserCreated',
|
|
35
|
+
* dataSchema: z.object({ userId: z.string().uuid(), name: z.string() }),
|
|
36
|
+
* metadataSchema: z.object({ now: z.date(), correlationId: z.string().uuid() }),
|
|
37
|
+
* });
|
|
38
|
+
*
|
|
39
|
+
* const event = userCreatedEvent(
|
|
40
|
+
* { userId: 'usr_123', name: 'Ada' },
|
|
41
|
+
* { now: new Date(), correlationId: 'corr_456' },
|
|
42
|
+
* );
|
|
43
|
+
* ```
|
|
44
|
+
*/
|
|
45
|
+
export function defineEvent<
|
|
46
|
+
TType extends string,
|
|
47
|
+
TData extends DefaultRecord,
|
|
48
|
+
TMetadata extends DefaultRecord | undefined = undefined,
|
|
49
|
+
>(config: EventConfig<TType, TData, TMetadata>): (data: TData, metadata?: TMetadata) => Event<TType, TData, TMetadata> {
|
|
50
|
+
const { type, dataSchema, metadataSchema } = config;
|
|
51
|
+
|
|
52
|
+
return (data: TData, metadata?: TMetadata) => {
|
|
53
|
+
const validatedData = dataSchema.parse(data);
|
|
54
|
+
const validatedMetadata = metadataSchema ? metadataSchema.parse(metadata) : metadata;
|
|
55
|
+
|
|
56
|
+
// Conditionally construct the event object based on whether metadata is provided
|
|
57
|
+
return (validatedMetadata === undefined
|
|
58
|
+
? {
|
|
59
|
+
type,
|
|
60
|
+
data: validatedData,
|
|
61
|
+
}
|
|
62
|
+
: {
|
|
63
|
+
type,
|
|
64
|
+
data: validatedData,
|
|
65
|
+
metadata: validatedMetadata,
|
|
66
|
+
}) as unknown as Event<TType, TData, TMetadata>;
|
|
67
|
+
};
|
|
68
|
+
}
|