一键导入
state-change-slice
builds a state-change slice from an event model
菜单
builds a state-change slice from an event model
| name | state-change-slice |
| description | builds a state-change slice from an event model |
State Change Slices are slices that change the system by processing a command. Each slice implements the Command-Event pattern using event sourcing.
When creating a state-change slice, you MUST create the following files in src/slices/{SliceName}/:
Every Command handler file follows this pattern:
import type {Command} from '@event-driven-io/emmett'
import {CommandHandler} from '@event-driven-io/emmett';
import {ContextEvents} from "../../events/ContextEvents";
import {findEventstore} from "../../common/loadPostgresEventstore";
Define the command with:
export type {CommandName}Command = Command<'{CommandName}', {
// data fields from spec
}>;
Define state needed for business logic validation:
export type {CommandName}State = {
// fields needed for validation
// often empty {} if no validation needed
}
export const {CommandName}InitialState = (): {CommandName}State => ({
// initial state
});
Updates state based on events (event sourcing projection):
export const evolve = (
state: {CommandName}State,
event: ContextEvents,
): {CommandName}State => {
const {type, data} = event;
switch (type) {
case "{EventName}":
// update state based on event
return { ...state, /* updates */ };
default:
return state;
}
};
Business logic that validates command and returns events:
export const decide = (
command: {CommandName}Command,
state: {CommandName}State,
): ContextEvents[] => {
// validation logic
// throw errors if validation fails
return [{
type: '{EventName}',
data: {
// event data fields
},
}];
};
const {CommandName}CommandHandler = CommandHandler<{CommandName}State, ContextEvents>({
evolve,
initialState: {CommandName}InitialState
});
export const handle{CommandName} = async (id: string, command: {CommandName}Command) => {
const eventStore = await findEventstore()
const result = await {CommandName}CommandHandler(eventStore, id, (state: {CommandName}State) => decide(command, state))
return {
nextExpectedStreamVersion: result.nextExpectedStreamVersion,
lastEventGlobalPosition: result.lastEventGlobalPosition
}
}
Two patterns observed:
command.data.fieldNameconst {field1, field2} = command.data;Both are acceptable - choose based on readability.
{}Every command MUST have tests using DeciderSpecification:
import {DeciderSpecification} from '@event-driven-io/emmett';
import {{CommandName}Command, {CommandName}State, decide, evolve} from "./{CommandName}Command";
import {describe, it} from "node:test";
describe('{CommandName} Specification', () => {
const given = DeciderSpecification.for({
decide,
evolve,
initialState: () => ({})
});
it('spec: {test description}', () => {
const command: {CommandName}Command = {
type: '{CommandName}',
data: {
// test data
},
}
given([/* precondition events */])
.when(command)
.then([{
type: '{EventName}',
data: {
// expected event data
},
}])
});
});
Unless explicitly told not to, create a routes.ts file:
import {Router, Request, Response} from 'express';
import {{CommandName}Command, handle{CommandName}} from './{CommandName}Command';
import {requireUser} from "../../supabase/requireUser";
import {WebApiSetup} from "@event-driven-io/emmett-expressjs";
import {assertNotEmpty} from "../../common/assertions";
export type {CommandName}RequestPayload = {
// fields matching command data (all optional with ?)
}
export type {CommandName}Request = Request<
Partial<{ id: string }>,
unknown,
Partial<{CommandName}RequestPayload>
>;
export const api =
(
// external dependencies
): WebApiSetup =>
(router: Router): void => {
router.post('/api/{commandname}/:id', requireRestaurantAccess, async (req: {CommandName}Request, res: Response) => {
const principal = await requireUser(req, res, false);
if (principal.error) {
return res.status(401).json(principal);
}
try {
const command: {CommandName}Command = {
data: {
// map from req.body with assertNotEmpty
},
type: "{CommandName}"
}
if (!req.params.id) throw "no id provided"
const result = await handle{CommandName}(assertNotEmpty(req.params.id), command);
return res.status(200).json({
ok: true,
next_expected_stream_version: result.nextExpectedStreamVersion?.toString(),
last_event_global_position: result.lastEventGlobalPosition?.toString()
});
} catch (err) {
console.error(err);
return res.status(500).json({ok: false, error: 'Server error'});
}
});
};
Make sure to annotate it with OpenAPI annotations in the comments, so it´s picked up by the open-api ui.
In *Command.ts Evolve-Function provides the state we can use to validate:
export const evolve = (
state: PlanVacationState,
event: ContextEvents,
): PlanVacationState => {
const {type, data} = event;
switch (type) {
// case "..Event":
case 'VacationPlanned':
state.plannedVacations.push({id: event.data.vacation_id, from: event.data.from, to: event.data.to})
return state;
case 'VacationCancelled':
state.plannedVacations = state.plannedVacations.filter(it => it.id !== event.data.vacation_id)
return state;
default:
return state;
}
};
The Decide-Function then makes the decision ( success or error )
export const decide = (
command: PlanVacationCommand,
state: PlanVacationState,
): ContextEvents[] => {
state.plannedVacations.forEach(vacation => {
if (
command.data.from <= vacation.to &&
command.data.to >= vacation.from
) {
throw {error: "conflicting_vacations"}
}
})
return [{
type: "VacationPlanned",
data: {
...
},
}]
};
In routes.ts, define an error mapper:
const errorMapping = (error:string): string => {
switch(error) {
case "conflicting_vacations" : return "Achtung, Betriebsurlaub überschneidet sich."
default: return "Leider ist ein Fehler aufgetreten"
}
}
use this directly in the route.
router.post('/api/planvacation/:id', requireRestaurantAccess, async (req: PlanVacationRequest, res: Response) => {
...
} catch (err:any) {
console.error(err);
return res.status(500).json({ok: false, error: errorMapping(err.error)});
}
});
};
in each slice folder, generate a file .slice.json
{
"id" : "<slice id>",
"slice": "<slice title>",
"context": "<contextx>",
"link": "https://miro.com/app/board/<board-id>=/?moveToWidget=<slice id>"
}
templates/AddLocation for samplesto build the UI - use this endpont "endpoint URL"
Payload example:
<payload example as JSON>
make sure to put endpoints into the api.ts and follow the rules:
- provide all headers