evcojs
Version:
CQRS with CloudEvents. Segregate read and write code and use cloud events to exchange data between them.
216 lines (215 loc) • 8.16 kB
JavaScript
import { randomUUID } from "node:crypto";
let source = "CQRS_DEFAULTSOURCE";
const commands = new Map();
const stateRebuilder = new Map();
const eventHandler = new Map();
const upcastHandler = new Map();
const stateLoader = new Map();
/**
* Sets the default source for all commands and events.
*
* @param {string} src
*/
export function setSource(src) {
source = src;
}
/**
* Registers a upcaster function for a given event type.
*
* The upcaster function will be called before the event is processed.
* The upcaster function takes an event and returns a new event.
* The new event will be used instead of the original event.
*
* @param {string} type - The type of the event.
* @param {string} context - The context of the event.
* @param {function(CloudEvent<any>): CloudEvent<any>} func - The upcaster function.
*/
export function registerUpcaster(type, context, func) {
const key = createKey(context, type);
if (upcastHandler.has(key)) {
throw new Error(`upcaster for ${key} already exists`);
}
upcastHandler.set(key, { type, context, upcast: func });
}
/**
* Registers an event handler for a specific event type.
*
* @param {string} type - The type of the event to register the handler for.
* @param {function(CloudEvent<any>, any): void} func - The event handler function
* that processes the event and optionally the current state.
*
* The event handler is added to a list of handlers associated with the event type.
* If no handlers exist for the given type, a new list is created.
*/
export function registerEventhandler(type, func) {
if (!eventHandler.has(type)) {
eventHandler.set(type, []);
}
eventHandler.get(type).push({ type, eventHandler: func });
}
/**
* Registers a function that loads historical events for a specific context.
*
* The function is called with an array of subjects (e.g., '/book/123') and
* should return a Promise that resolves with an array of historical events
* associated with the given subjects.
*
* @param context - The context for which the events are loaded.
* @param load - The function that loads the events.
*/
export function registerStateLoadingFunction(context, load) {
stateLoader.set(context, { load, context: context });
}
/**
* Registers a command handler for a specific command type.
*
* The command handler is a function that validates business rules and
* returns new events to be saved persistently.
*
* @param {string} type - The type of the command to register the handler for.
* @param {string} context - The context in which the command is executed.
* @param {function(any, any): CloudEvent<any>[]} commandHandler - The command handler function.
* The function receives the command and the current state and returns an array of new events.
*/
export function registerCommandHandler(type, context, commandHandler) {
if (commands.has(type)) {
throw new Error(`Command handler for ${type} already exists`);
}
commands.set(type, { type, context, handle: commandHandler });
}
/**
* Registers a state rebuilder function for a specific event type within a given context.
*
* The state rebuilder function is responsible for applying the event to the current state
* to produce an updated state. It is an essential part of the event sourcing pattern,
* allowing the system to reconstruct the state by replaying events.
*
* @param {string} type - The type of the event for which the state rebuilder is registered.
* @param {string} context - The context in which the state rebuilder operates.
* @param {function(any): void} builderHandler - The state rebuilder function that applies
* the event to the state.
*
* @throws Will throw an error if a state rebuilder for the given event type and context
* already exists.
*/
export function registerStateRebuilder(type, context, builderHandler) {
const key = createKey(context, type);
if (stateRebuilder.has(key)) {
throw new Error(`State rebuilder for ${key} already exists`);
}
stateRebuilder.set(key, {
type,
context,
stateRebuilder: builderHandler,
});
}
/**
* Applies an upcaster function to the given event if a matching upcaster is
* registered. If no upcaster is registered, the event is passed through unchanged.
*
* @param {CloudEvent<any>} event - The event to upcast or pass through.
* @returns {CloudEvent<any>} The upcasted event if an upcaster is registered,
* otherwise the original event.
*/
function maybeUpcast(event) {
const upcaster = upcastHandler.get(event.type);
if (upcaster) {
return upcaster.upcast(event);
}
return event;
}
/**
* Executes a command and applies the returned events to the state.
*
* @param {Command<C>} command - The command to execute.
*
* @returns {Promise<void>} A promise that resolves when the command has been
* executed and the state has been updated.
*/
export async function handleCommand(command) {
const commandHandler = commands.get(command.type);
if (commandHandler) {
let state = await createState(commandHandler.context, command.subjects);
const newEvents = commandHandler.handle(command.data, state);
for (const event of newEvents) {
if (command.subjects.includes(event.subject)) {
state = executeEvent(commandHandler.context, event, state);
}
}
const cloudEvents = newEvents.map((event) => {
return {
...event,
source: event.source ?? source,
id: event.id ?? randomUUID(),
time: event.time ?? new Date().toISOString(),
specversion: "1.0",
data: event.data,
};
});
for (const cloudEvent of cloudEvents) {
if (eventHandler.has(cloudEvent.type)) {
eventHandler
.get(cloudEvent.type)
.forEach((eh) => eh.eventHandler(cloudEvent, state));
}
}
return state;
}
return null;
}
/**
* Reconstructs the state for a given context and subjects by loading and processing historical events.
*
* @template C - The type of the state to be reconstructed.
*
* @param {string} context - The unique context for which the state is reconstructed.
* @param {string[]} subjects - An array of subjects used to load relevant events.
*
* @returns {Promise<any>} A promise that resolves to the reconstructed state.
*
* The function retrieves the state loading function associated with the provided context,
* loads historical events for the specified subjects, and applies each event to rebuild
* the current state. If no state loading function is found, an empty state is returned.
*/
export async function createState(context, subjects) {
const stateLoadingFunction = stateLoader.get(context);
let events = stateLoadingFunction
? await stateLoadingFunction.load(subjects)
: [];
events = events.map(maybeUpcast);
let state = null;
for (const event of events) {
state = executeEvent(context, event, state);
}
return state;
}
/**
* Applies an event to the state by executing the associated state rebuilder.
*
* @param {string} context - The context of the event.
* @param {CloudEvent<any>} event - The event to be applied to the state.
* @param {any} [state=null] - The current state.
*
* @returns {any} The updated state.
*
* If no state rebuilder is registered for the given event type and context, the
* event is ignored and the state is returned unchanged.
*/
function executeEvent(context, event, state) {
const rebuilder = stateRebuilder.get(createKey(context, event.type));
if (rebuilder && rebuilder.context === context) {
state = rebuilder.stateRebuilder(event.data, state);
}
return state;
}
/**
* Creates a unique key for the given context and event type.
*
* @param {string} context - The context of the event.
* @param {string} type - The type of the event.
*
* @returns {string} The unique key.
*/
function createKey(context, type) {
return `[${context}|${type}]`;
}