@eventmodelers/node-kit 0.0.11 → 0.0.13
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/package.json +1 -1
- package/templates/.claude/skills/build-automation/SKILL.md +260 -0
- package/templates/.claude/skills/build-state-change/SKILL.md +329 -0
- package/templates/.claude/skills/build-state-view/SKILL.md +384 -0
- package/templates/.claude/skills/learn-eventmodelers-api/SKILL.md +609 -0
- package/templates/.claude/skills/load-slice/SKILL.md +69 -14
- package/templates/.claude/skills/update-slice-status/SKILL.md +105 -0
- package/templates/realtime-agent/src/index.js +11 -1
- package/templates/root/.env.example +22 -0
- package/templates/root/Claude.md +58 -0
- package/templates/root/agent.sh +15 -0
- package/templates/root/backend-prompt.md +139 -0
- package/templates/root/flyway.conf +17 -0
- package/templates/root/package.json +52 -0
- package/templates/root/ralph.sh +55 -29
- package/templates/root/server.ts +156 -0
- package/templates/root/setup-env.sh +55 -0
- package/templates/root/src/common/assertions.ts +6 -0
- package/templates/root/src/common/db.ts +32 -0
- package/templates/root/src/common/loadPostgresEventstore.ts +23 -0
- package/templates/root/src/common/parseEndpoint.ts +51 -0
- package/templates/root/src/common/processorDlq.ts +28 -0
- package/templates/root/src/common/realtimeBroadcast.ts +19 -0
- package/templates/root/src/common/replay.ts +16 -0
- package/templates/root/src/common/routes.ts +19 -0
- package/templates/root/src/common/testHelpers.ts +54 -0
- package/templates/root/src/slices/example/routes.ts +134 -0
- package/templates/root/src/supabase/LoginHandler.ts +36 -0
- package/templates/root/src/supabase/ProtectedPageProps.ts +21 -0
- package/templates/root/src/supabase/README.md +171 -0
- package/templates/root/src/supabase/api.ts +56 -0
- package/templates/root/src/supabase/component.ts +12 -0
- package/templates/root/src/supabase/requireOrgaAdmin.ts +32 -0
- package/templates/root/src/supabase/requireUser.ts +72 -0
- package/templates/root/src/supabase/serverProps.ts +25 -0
- package/templates/root/src/supabase/staticProps.ts +10 -0
- package/templates/root/src/swagger.ts +34 -0
- package/templates/root/src/util/assertions.ts +6 -0
- package/templates/root/src/util/hash.ts +9 -0
- package/templates/root/src/util/sanitize.ts +23 -0
- package/templates/root/supabase/config.toml +295 -0
- package/templates/root/supabase/migrations/V1__schema.sql.example +12 -0
- package/templates/root/supabase/seed.sql +1 -0
- package/templates/root/tsconfig.json +32 -0
- package/templates/root/vercel.json +8 -0
- package/templates/root/model.md +0 -1
package/package.json
CHANGED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: build-automation
|
|
3
|
+
description: Implements an emmett automation slice (reactor processor + command handler) from a slice.json definition
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Build Automation Slice
|
|
7
|
+
|
|
8
|
+
> Before doing anything else, read the slice definition from `.slices/{Context}/{slicename}/slice.json`. This file is the **source of truth** for which trigger event drives the automation and what command it fires.
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## What an Automation Slice is
|
|
13
|
+
|
|
14
|
+
An automation slice reacts to events from the event store and fires a command in response. It replaces the old CRON + TODO-list pattern with an event-driven **reactor**.
|
|
15
|
+
|
|
16
|
+
Architecture:
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
Event Store
|
|
20
|
+
│ (TriggerEvent emitted by another slice)
|
|
21
|
+
▼
|
|
22
|
+
processor.ts ← reactor — listens, maps, fires command
|
|
23
|
+
│
|
|
24
|
+
▼
|
|
25
|
+
{SliceName}Command.ts ← command handler (decide/evolve)
|
|
26
|
+
│
|
|
27
|
+
▼
|
|
28
|
+
Event Store ← new events appended
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
An automation slice is a **state-change slice with a reactor**. Always build the command handler first, then wire the processor.
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## Step 1 — Read the slice.json
|
|
36
|
+
|
|
37
|
+
From the slice definition, extract:
|
|
38
|
+
- **sliceName** — the command being fired by the automation
|
|
39
|
+
- **context** — bounded context
|
|
40
|
+
- **processors[]** — each processor defines:
|
|
41
|
+
- `triggerEvent` — the event that starts the automation
|
|
42
|
+
- `command` — the command to fire
|
|
43
|
+
- `processorId` — unique kebab-case identifier
|
|
44
|
+
- **commands[]** — command data fields
|
|
45
|
+
- **events[]** — events emitted by the command
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## Step 2 — Build the command handler
|
|
50
|
+
|
|
51
|
+
Follow the **build-state-change** skill to create:
|
|
52
|
+
- `{SliceName}Command.ts` (Command type, evolve, decide, handle{SliceName})
|
|
53
|
+
- `{SliceName}.test.ts` (DeciderSpecification tests)
|
|
54
|
+
|
|
55
|
+
**Do NOT create a `routes.ts`** for automations — the command is fired internally by the processor, not via HTTP.
|
|
56
|
+
|
|
57
|
+
Refer to the build-state-change skill for the full command handler structure.
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
## Step 3 — Ensure the trigger event type exists
|
|
62
|
+
|
|
63
|
+
The trigger event must be defined in `[Context]Events.ts`. If it belongs to a different context, import it from that context's events file.
|
|
64
|
+
|
|
65
|
+
If the trigger event is missing from the union type, add it:
|
|
66
|
+
|
|
67
|
+
```typescript
|
|
68
|
+
// in {TriggerContext}Events.ts
|
|
69
|
+
export type {TriggerEventName} = Event<'{TriggerEventName}', {
|
|
70
|
+
id: string;
|
|
71
|
+
// fields the processor will use to construct the command
|
|
72
|
+
}, CommonMeta>;
|
|
73
|
+
|
|
74
|
+
export type {TriggerContext}Events = /* existing */ | {TriggerEventName};
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
---
|
|
78
|
+
|
|
79
|
+
## Step 4 — Create `processor.ts`
|
|
80
|
+
|
|
81
|
+
File: `src/slices/{context}/{SliceName}/processor.ts`
|
|
82
|
+
|
|
83
|
+
```typescript
|
|
84
|
+
import {type {TriggerEventName}} from '../{TriggerContext}Events';
|
|
85
|
+
import {{SliceName}Command, handle{SliceName}} from './{SliceName}Command';
|
|
86
|
+
import {PostgresEventStore, PostgreSQLEventStoreConsumer} from '@event-driven-io/emmett-postgresql';
|
|
87
|
+
import {storeDlqMessage} from '../../../common/processorDlq';
|
|
88
|
+
import {v4} from 'uuid';
|
|
89
|
+
|
|
90
|
+
const PROCESSOR_ID = '{unique-kebab-case-processor-id}';
|
|
91
|
+
|
|
92
|
+
let _consumer: PostgreSQLEventStoreConsumer<{TriggerEventName}> | null = null;
|
|
93
|
+
|
|
94
|
+
export const processor = {
|
|
95
|
+
start: async (eventStore: PostgresEventStore) => {
|
|
96
|
+
_consumer = eventStore.consumer<{TriggerEventName}>();
|
|
97
|
+
|
|
98
|
+
_consumer.reactor<{TriggerEventName}>({
|
|
99
|
+
processorId: PROCESSOR_ID,
|
|
100
|
+
processorInstanceId: v4(), // new UUID each restart — enables competing consumers
|
|
101
|
+
canHandle: ['{TriggerEventName}'],
|
|
102
|
+
lock: {
|
|
103
|
+
timeoutSeconds: 30,
|
|
104
|
+
acquisitionPolicy: {type: 'retry', retries: 60, minTimeout: 1000, maxTimeout: 2000},
|
|
105
|
+
},
|
|
106
|
+
eachMessage: async (message) => {
|
|
107
|
+
try {
|
|
108
|
+
console.log(`Processing ${message.type} for ${message.data.id}`);
|
|
109
|
+
|
|
110
|
+
const command: {SliceName}Command = {
|
|
111
|
+
type: '{SliceName}',
|
|
112
|
+
data: {
|
|
113
|
+
id: message.data.id,
|
|
114
|
+
// map fields from message.data to the command's data shape
|
|
115
|
+
},
|
|
116
|
+
metadata: {
|
|
117
|
+
correlation_id: message.data.id,
|
|
118
|
+
causation_id: message.metadata?.correlation_id,
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
await handle{SliceName}(message.data.id, command);
|
|
123
|
+
} catch (err) {
|
|
124
|
+
console.error(`${PROCESSOR_ID}: failed to process message`, message.data, err);
|
|
125
|
+
await storeDlqMessage(PROCESSOR_ID, message, err);
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
_consumer?.start().catch(err =>
|
|
131
|
+
console.error(`${PROCESSOR_ID} consumer error:`, err),
|
|
132
|
+
);
|
|
133
|
+
},
|
|
134
|
+
|
|
135
|
+
stop: async () => {
|
|
136
|
+
await _consumer?.stop();
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
### Key decisions when filling in the template
|
|
142
|
+
|
|
143
|
+
**`PROCESSOR_ID`** — unique kebab-case string identifying this processor across restarts. Use format: `{slicename}-automation` (e.g. `assign-user-to-organization-automation`). Never reuse IDs between processors.
|
|
144
|
+
|
|
145
|
+
**`processorInstanceId`** — `v4()` UUID generated at startup. A new UUID each time the server restarts allows multiple competing consumers to run safely in parallel.
|
|
146
|
+
|
|
147
|
+
**`canHandle`** — must list only the exact event type string(s) this reactor listens to.
|
|
148
|
+
|
|
149
|
+
**`lock`** — do not change the lock configuration unless there is a specific reason. The defaults provide safe at-least-once delivery with retry.
|
|
150
|
+
|
|
151
|
+
**Metadata mapping:**
|
|
152
|
+
- `correlation_id` in the command → pass the trigger message's `id` (aggregate identifier)
|
|
153
|
+
- `causation_id` in the command → pass the trigger message's `metadata?.correlation_id`
|
|
154
|
+
|
|
155
|
+
**DLQ** — always wrap `eachMessage` in try/catch and call `storeDlqMessage` on failure. Failed messages are not retried automatically; the DLQ record allows manual reprocessing.
|
|
156
|
+
|
|
157
|
+
---
|
|
158
|
+
|
|
159
|
+
## Step 5 — Register the processor in application startup
|
|
160
|
+
|
|
161
|
+
Find the app startup file (usually `src/index.ts` or `src/server.ts`) where other processors are started. Add:
|
|
162
|
+
|
|
163
|
+
```typescript
|
|
164
|
+
import {processor as {SliceName}Processor} from './slices/{context}/{SliceName}/processor';
|
|
165
|
+
|
|
166
|
+
// during startup, after eventStore is initialized:
|
|
167
|
+
await {SliceName}Processor.start(eventStore);
|
|
168
|
+
|
|
169
|
+
// during shutdown:
|
|
170
|
+
await {SliceName}Processor.stop();
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
The `eventStore` instance is the one returned by `findEventstore()` — reuse the shared instance, do not create a second one.
|
|
174
|
+
|
|
175
|
+
---
|
|
176
|
+
|
|
177
|
+
## Step 6 — Verify the event store bootstrap
|
|
178
|
+
|
|
179
|
+
The event store **must** call `schema.migrate()` before any processor starts. Check `src/common/loadPostgresEventstore.ts`:
|
|
180
|
+
|
|
181
|
+
```typescript
|
|
182
|
+
export const findEventstore = async () => {
|
|
183
|
+
// ...
|
|
184
|
+
await eventStoreInstance.schema.migrate(); // ← must be present
|
|
185
|
+
return eventStoreInstance;
|
|
186
|
+
};
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
If this line is missing, add it. Without it, the emmett schema functions (`emt_try_acquire_processor_lock` etc.) are not created and the reactor will fail to start.
|
|
190
|
+
|
|
191
|
+
---
|
|
192
|
+
|
|
193
|
+
## Processor patterns reference
|
|
194
|
+
|
|
195
|
+
### Single trigger event → single command (standard)
|
|
196
|
+
|
|
197
|
+
```typescript
|
|
198
|
+
eachMessage: async (message) => {
|
|
199
|
+
const command: MyCommand = {
|
|
200
|
+
type: 'MyCommand',
|
|
201
|
+
data: {id: message.data.id},
|
|
202
|
+
metadata: {
|
|
203
|
+
correlation_id: message.data.id,
|
|
204
|
+
causation_id: message.metadata?.correlation_id,
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
await handleMyCommand(message.data.id, command);
|
|
208
|
+
},
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
### Conditional processing (skip if condition not met)
|
|
212
|
+
|
|
213
|
+
```typescript
|
|
214
|
+
eachMessage: async (message) => {
|
|
215
|
+
if (!message.data.someField) {
|
|
216
|
+
console.log(`Skipping — someField not set for ${message.data.id}`);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
// proceed normally
|
|
220
|
+
},
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
### Multiple commands from one trigger
|
|
224
|
+
|
|
225
|
+
```typescript
|
|
226
|
+
eachMessage: async (message) => {
|
|
227
|
+
await handleFirstCommand(message.data.id, firstCommand);
|
|
228
|
+
await handleSecondCommand(message.data.id, secondCommand);
|
|
229
|
+
},
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
---
|
|
233
|
+
|
|
234
|
+
## Files to create / modify
|
|
235
|
+
|
|
236
|
+
```
|
|
237
|
+
src/slices/{context}/{SliceName}/
|
|
238
|
+
├── {SliceName}Command.ts ← command handler (decide/evolve/handle) — see build-state-change
|
|
239
|
+
├── {SliceName}.test.ts ← DeciderSpecification tests — see build-state-change
|
|
240
|
+
└── processor.ts ← reactor (start/stop)
|
|
241
|
+
|
|
242
|
+
src/
|
|
243
|
+
└── index.ts (or server.ts) ← register processor.start() / processor.stop()
|
|
244
|
+
|
|
245
|
+
src/common/
|
|
246
|
+
└── loadPostgresEventstore.ts ← verify schema.migrate() is called
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
---
|
|
250
|
+
|
|
251
|
+
## Checklist
|
|
252
|
+
|
|
253
|
+
- [ ] `PROCESSOR_ID` is unique across all processors in the codebase (grep to verify)
|
|
254
|
+
- [ ] `processorInstanceId` uses `v4()` — not a hardcoded string
|
|
255
|
+
- [ ] `canHandle` matches the exact event type string from `{Context}Events.ts`
|
|
256
|
+
- [ ] `eachMessage` is wrapped in try/catch with `storeDlqMessage` fallback
|
|
257
|
+
- [ ] Processor registered in application startup (start + stop)
|
|
258
|
+
- [ ] `schema.migrate()` is called in `loadPostgresEventstore.ts`
|
|
259
|
+
- [ ] No `routes.ts` created (automations are not exposed via HTTP)
|
|
260
|
+
- [ ] Command handler tests cover idempotency (what happens if the command fires twice)
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: build-state-change
|
|
3
|
+
description: Implements an emmett state-change slice (command handler, tests, route) from a slice.json definition
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Build State Change Slice
|
|
7
|
+
|
|
8
|
+
> Before doing anything else, read the slice definition from `.slices/{Context}/{slicename}/slice.json`. This file is the **source of truth** for all fields, events, and metadata. Never invent fields not defined there.
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## What a State Change Slice is
|
|
13
|
+
|
|
14
|
+
A state-change slice processes a command using event sourcing. It:
|
|
15
|
+
1. Loads the current aggregate state by replaying past events (`evolve`)
|
|
16
|
+
2. Validates the command against that state (`decide`)
|
|
17
|
+
3. Returns new events if valid, throws if not
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Step 1 — Read the slice.json
|
|
22
|
+
|
|
23
|
+
From the slice definition, extract:
|
|
24
|
+
- **sliceName** — the slice title (becomes the Command name)
|
|
25
|
+
- **context** — the bounded context (used to find `[Context]Events.ts`)
|
|
26
|
+
- **commands[]** — list of commands with their data fields
|
|
27
|
+
- **events[]** — list of events emitted by each command
|
|
28
|
+
- **specifications[]** — test scenarios (given/when/then)
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## Step 2 — Ensure the shared events union exists
|
|
33
|
+
|
|
34
|
+
Each context has one `[Context]Events.ts` file that exports a union of all event types.
|
|
35
|
+
|
|
36
|
+
File location: `src/slices/{context}/[Context]Events.ts` (or wherever the existing one lives — search for it).
|
|
37
|
+
|
|
38
|
+
### Event type shape
|
|
39
|
+
|
|
40
|
+
```typescript
|
|
41
|
+
import type {Event} from '@event-driven-io/emmett';
|
|
42
|
+
|
|
43
|
+
type CommonMeta = {
|
|
44
|
+
stream_name?: string;
|
|
45
|
+
userId?: string;
|
|
46
|
+
correlation_id?: string;
|
|
47
|
+
causation_id?: string;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export type {EventName} = Event<'{EventName}', {
|
|
51
|
+
// data fields from slice.json
|
|
52
|
+
}, CommonMeta>;
|
|
53
|
+
|
|
54
|
+
// Add the new event to the union
|
|
55
|
+
export type {Context}Events = /* existing events */ | {EventName};
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Add each new event type and update the union. Do NOT remove existing types.
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
## Step 3 — Create `{SliceName}Command.ts`
|
|
63
|
+
|
|
64
|
+
File: `src/slices/{context}/{SliceName}/{SliceName}Command.ts`
|
|
65
|
+
|
|
66
|
+
### Full structure
|
|
67
|
+
|
|
68
|
+
```typescript
|
|
69
|
+
import type {Command} from '@event-driven-io/emmett';
|
|
70
|
+
import {CommandHandler} from '@event-driven-io/emmett';
|
|
71
|
+
import {type {Context}Events} from '../{Context}Events';
|
|
72
|
+
import {findEventstore} from '../../../common/loadPostgresEventstore';
|
|
73
|
+
|
|
74
|
+
// 1. Command type — data fields come from slice.json commands[]
|
|
75
|
+
export type {SliceName}Command = Command<'{SliceName}', {
|
|
76
|
+
id: string;
|
|
77
|
+
// ... other data fields from the slice definition
|
|
78
|
+
}, {
|
|
79
|
+
correlation_id?: string;
|
|
80
|
+
causation_id?: string;
|
|
81
|
+
}>;
|
|
82
|
+
|
|
83
|
+
// 2. State — only fields needed for validation
|
|
84
|
+
export type {SliceName}State = {
|
|
85
|
+
// e.g. { processed: boolean } or { assignedIds: Set<string> }
|
|
86
|
+
// Use {} if no validation state is needed
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
export const {SliceName}InitialState = (): {SliceName}State => ({
|
|
90
|
+
// initial values
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// 3. Evolve — pure function, updates state from past events
|
|
94
|
+
export const evolve = (
|
|
95
|
+
state: {SliceName}State,
|
|
96
|
+
event: {Context}Events,
|
|
97
|
+
): {SliceName}State => {
|
|
98
|
+
const {type} = event;
|
|
99
|
+
|
|
100
|
+
switch (type) {
|
|
101
|
+
case '{EmittedEventName}':
|
|
102
|
+
return {...state, /* update field */};
|
|
103
|
+
default:
|
|
104
|
+
return state;
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
// 4. Decide — validates command, returns events or throws
|
|
109
|
+
export const decide = (
|
|
110
|
+
command: {SliceName}Command,
|
|
111
|
+
state: {SliceName}State,
|
|
112
|
+
): {Context}Events[] => {
|
|
113
|
+
// idempotency / business rule check
|
|
114
|
+
if (state.processed) {
|
|
115
|
+
throw {code: 'already_processed', message: 'Already processed'};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return [{
|
|
119
|
+
type: '{EmittedEventName}',
|
|
120
|
+
data: {
|
|
121
|
+
id: command.data.id,
|
|
122
|
+
// ... map all fields from command.data per slice.json
|
|
123
|
+
},
|
|
124
|
+
metadata: {
|
|
125
|
+
correlation_id: command.metadata?.correlation_id,
|
|
126
|
+
causation_id: command.metadata?.causation_id,
|
|
127
|
+
userId: command.data.userId,
|
|
128
|
+
},
|
|
129
|
+
}];
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
// 5. CommandHandler + exported handle function
|
|
133
|
+
const {SliceName}CommandHandler = CommandHandler<{SliceName}State, {Context}Events>({
|
|
134
|
+
evolve,
|
|
135
|
+
initialState: {SliceName}InitialState,
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
export const handle{SliceName} = async (id: string, command: {SliceName}Command) => {
|
|
139
|
+
const eventStore = await findEventstore();
|
|
140
|
+
const result = await {SliceName}CommandHandler(
|
|
141
|
+
eventStore,
|
|
142
|
+
id,
|
|
143
|
+
(state: {SliceName}State) => decide(command, state),
|
|
144
|
+
);
|
|
145
|
+
return {
|
|
146
|
+
nextExpectedStreamVersion: result.nextExpectedStreamVersion,
|
|
147
|
+
lastEventGlobalPosition: result.lastEventGlobalPosition,
|
|
148
|
+
};
|
|
149
|
+
};
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### State complexity guide
|
|
153
|
+
|
|
154
|
+
| Scenario | State shape |
|
|
155
|
+
|----------|-------------|
|
|
156
|
+
| Simple create-once | `{ created: boolean }` |
|
|
157
|
+
| Idempotency by user | `{ processedUserIds: Set<string> }` |
|
|
158
|
+
| Count validation | `{ count: number; limit: number }` |
|
|
159
|
+
| No validation needed | `{}` (empty object) |
|
|
160
|
+
|
|
161
|
+
---
|
|
162
|
+
|
|
163
|
+
## Step 4 — Create `{SliceName}.test.ts`
|
|
164
|
+
|
|
165
|
+
File: `src/slices/{context}/{SliceName}/{SliceName}.test.ts`
|
|
166
|
+
|
|
167
|
+
Use `DeciderSpecification` for unit tests. Derive test scenarios from `specifications[]` in the slice.json.
|
|
168
|
+
|
|
169
|
+
```typescript
|
|
170
|
+
import {DeciderSpecification} from '@event-driven-io/emmett';
|
|
171
|
+
import {
|
|
172
|
+
{SliceName}Command,
|
|
173
|
+
{SliceName}InitialState,
|
|
174
|
+
decide,
|
|
175
|
+
evolve,
|
|
176
|
+
} from './{SliceName}Command';
|
|
177
|
+
import {describe, it} from 'node:test';
|
|
178
|
+
|
|
179
|
+
describe('{SliceName} Specification', () => {
|
|
180
|
+
const given = DeciderSpecification.for({
|
|
181
|
+
decide,
|
|
182
|
+
evolve,
|
|
183
|
+
initialState: {SliceName}InitialState,
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('spec: {SliceName} - creates event on empty stream', () => {
|
|
187
|
+
const command: {SliceName}Command = {
|
|
188
|
+
type: '{SliceName}',
|
|
189
|
+
data: {
|
|
190
|
+
id: 'test-id',
|
|
191
|
+
// ... test values
|
|
192
|
+
},
|
|
193
|
+
metadata: {},
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
given([])
|
|
197
|
+
.when(command)
|
|
198
|
+
.then([{
|
|
199
|
+
type: '{EmittedEventName}',
|
|
200
|
+
data: {
|
|
201
|
+
id: 'test-id',
|
|
202
|
+
// ... expected event data
|
|
203
|
+
},
|
|
204
|
+
metadata: {},
|
|
205
|
+
}]);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('spec: {SliceName} - throws when already processed', () => {
|
|
209
|
+
const command: {SliceName}Command = {
|
|
210
|
+
type: '{SliceName}',
|
|
211
|
+
data: {id: 'test-id'},
|
|
212
|
+
metadata: {},
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
given([{
|
|
216
|
+
type: '{EmittedEventName}',
|
|
217
|
+
data: {id: 'test-id'},
|
|
218
|
+
metadata: {},
|
|
219
|
+
}])
|
|
220
|
+
.when(command)
|
|
221
|
+
.thenThrows();
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
Add one test per specification in the slice.json. If the spec has no precondition events, use `given([])`.
|
|
227
|
+
|
|
228
|
+
---
|
|
229
|
+
|
|
230
|
+
## Step 5 — Create `routes.ts`
|
|
231
|
+
|
|
232
|
+
File: `src/slices/{context}/{SliceName}/routes.ts`
|
|
233
|
+
|
|
234
|
+
> **Concrete example**: `src/slices/example/routes.ts` — shows the full pattern with `requireUser`, `assertNotEmpty`, error mapping, and OpenAPI annotations. Read it before implementing.
|
|
235
|
+
|
|
236
|
+
```typescript
|
|
237
|
+
import {Request, Response, Router} from 'express';
|
|
238
|
+
import {WebApiSetup} from '@event-driven-io/emmett-expressjs';
|
|
239
|
+
import {requireUser} from '../../../supabase/requireUser';
|
|
240
|
+
import {{SliceName}Command, handle{SliceName}} from './{SliceName}Command';
|
|
241
|
+
|
|
242
|
+
export const api = (): WebApiSetup => (router: Router): void => {
|
|
243
|
+
|
|
244
|
+
router.post('/api/{slicename}/:id', async (req: Request, res: Response) => {
|
|
245
|
+
const auth = await requireUser(req, res);
|
|
246
|
+
if (auth.error) return;
|
|
247
|
+
|
|
248
|
+
const id = req.params.id;
|
|
249
|
+
const correlationId = req.header('correlation_id') ?? id;
|
|
250
|
+
|
|
251
|
+
try {
|
|
252
|
+
const command: {SliceName}Command = {
|
|
253
|
+
type: '{SliceName}',
|
|
254
|
+
data: {
|
|
255
|
+
id,
|
|
256
|
+
// ... map from req.body
|
|
257
|
+
},
|
|
258
|
+
metadata: {
|
|
259
|
+
correlation_id: correlationId,
|
|
260
|
+
causation_id: id,
|
|
261
|
+
},
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
const result = await handle{SliceName}(id, command);
|
|
265
|
+
|
|
266
|
+
res.set('correlation_id', correlationId);
|
|
267
|
+
res.set('causation_id', id);
|
|
268
|
+
|
|
269
|
+
return res.status(201).json({
|
|
270
|
+
ok: true,
|
|
271
|
+
next_expected_stream_version: result.nextExpectedStreamVersion?.toString(),
|
|
272
|
+
last_event_global_position: result.lastEventGlobalPosition?.toString(),
|
|
273
|
+
});
|
|
274
|
+
} catch (err: any) {
|
|
275
|
+
const errorMessage = errorMapping(err?.code);
|
|
276
|
+
if (errorMessage) {
|
|
277
|
+
return res.status(409).json({error: errorMessage});
|
|
278
|
+
}
|
|
279
|
+
console.error(err);
|
|
280
|
+
return res.status(500).json({ok: false, error: 'Server error'});
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
const errorMapping = (code: string): string | null => {
|
|
286
|
+
switch (code) {
|
|
287
|
+
case 'already_processed': return 'This action has already been performed.';
|
|
288
|
+
// add other error codes from slice.json specifications
|
|
289
|
+
default: return null;
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
---
|
|
295
|
+
|
|
296
|
+
## Step 6 — Wire up the route
|
|
297
|
+
|
|
298
|
+
Find the application's router registration (usually `src/index.ts` or `src/app.ts`) and add:
|
|
299
|
+
|
|
300
|
+
```typescript
|
|
301
|
+
import {api as {SliceName}Api} from './slices/{context}/{SliceName}/routes';
|
|
302
|
+
|
|
303
|
+
// inside the router setup:
|
|
304
|
+
{SliceName}Api()(router);
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
---
|
|
308
|
+
|
|
309
|
+
## Key patterns
|
|
310
|
+
|
|
311
|
+
- **Metadata optional chaining**: always use `command.metadata?.correlation_id` (metadata may be absent in tests)
|
|
312
|
+
- **Throw with code**: `throw {code: 'snake_case_code', message: '...'}` — routes catch by `err?.code`
|
|
313
|
+
- **Idempotency in evolve**: track processed IDs in state, check in decide
|
|
314
|
+
- **Stream ID**: pass the aggregate ID as the first argument to `handle{SliceName}(id, command)` — the stream is `{context}-{id}`
|
|
315
|
+
- **No side effects in evolve**: evolve must be a pure function; all side effects go in decide or the route
|
|
316
|
+
|
|
317
|
+
---
|
|
318
|
+
|
|
319
|
+
## Files to create
|
|
320
|
+
|
|
321
|
+
```
|
|
322
|
+
src/slices/{context}/{SliceName}/
|
|
323
|
+
├── {SliceName}Command.ts ← command handler (decide/evolve/handle)
|
|
324
|
+
├── {SliceName}.test.ts ← DeciderSpecification tests
|
|
325
|
+
└── routes.ts ← Express POST endpoint
|
|
326
|
+
|
|
327
|
+
src/slices/{context}/
|
|
328
|
+
└── {Context}Events.ts ← add new event types here (update union)
|
|
329
|
+
```
|