@lazyapps/command-processor 0.1.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 ADDED
@@ -0,0 +1,15 @@
1
+ ISC License
2
+
3
+ Copyright (c) 2026, Oliver Sturm
4
+
5
+ Permission to use, copy, modify, and/or distribute this software for any
6
+ purpose with or without fee is hereby granted, provided that the above
7
+ copyright notice and this permission notice appear in all copies.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
10
+ REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
11
+ AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
12
+ INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
13
+ LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
14
+ OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
15
+ PERFORMANCE OF THIS SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,13 @@
1
+ # @lazyapps/command-processor
2
+
3
+ Transport-agnostic command validation and aggregate execution engine. Core of the write side in the LazyApps CQRS architecture.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pnpm add @lazyapps/command-processor
9
+ ```
10
+
11
+ ## Part of LazyApps
12
+
13
+ This package is part of the [LazyApps](https://github.com/oliversturm/lazyapps-libs) event-sourcing and CQRS framework.
@@ -0,0 +1,71 @@
1
+ import { createWriteStream, fsyncSync } from 'fs';
2
+ import { getLogger } from '@lazyapps/logger';
3
+
4
+ const initLog = getLogger('CP/CmdRec', 'INIT');
5
+
6
+ export const createCommandRecorder = (filePath) => {
7
+ initLog.info(`Setting up command recording to ${filePath}`);
8
+
9
+ const writeStream = createWriteStream(filePath, {
10
+ flags: 'a',
11
+ encoding: 'utf8',
12
+ // Set to false to ensure we control when data is flushed
13
+ autoClose: false,
14
+ });
15
+
16
+ // Handle stream errors
17
+ writeStream.on('error', (error) => {
18
+ initLog.error(`Error writing to command record file: ${error}`);
19
+ });
20
+
21
+ const recordCommand = (commandRecord) => {
22
+ return new Promise((resolve, reject) => {
23
+ const log = getLogger('CP/CmdRec', commandRecord.correlationId);
24
+ writeStream.write(JSON.stringify(commandRecord) + '\n', (error) => {
25
+ if (error) {
26
+ log.error(
27
+ `Failed to record command ${commandRecord.command}: ${error}`,
28
+ );
29
+ reject(error);
30
+ return;
31
+ }
32
+
33
+ // Force flush to disk
34
+ try {
35
+ writeStream.fd && fsyncSync(writeStream.fd);
36
+ log.debug(`Recorded command ${commandRecord.command}.`);
37
+ resolve(commandRecord);
38
+ } catch (error) {
39
+ log.error(
40
+ `Failed to flush command ${commandRecord.command}: ${error}`,
41
+ );
42
+ reject(error);
43
+ }
44
+ });
45
+ });
46
+ };
47
+
48
+ // Add cleanup method
49
+ const close = () => {
50
+ return new Promise((resolve, reject) => {
51
+ // Ensure final flush before closing
52
+ try {
53
+ writeStream.fd && fsyncSync(writeStream.fd);
54
+ } catch (error) {
55
+ initLog.error(`Error flushing final data: ${error}`);
56
+ }
57
+
58
+ writeStream.end((error) => {
59
+ if (error) {
60
+ initLog.error(`Error closing command record file: ${error}`);
61
+ reject(error);
62
+ } else {
63
+ initLog.info('Command recorder closed');
64
+ resolve();
65
+ }
66
+ });
67
+ });
68
+ };
69
+
70
+ return { recordCommand, close };
71
+ };
package/context.js ADDED
@@ -0,0 +1,25 @@
1
+ export const initializeContext = (
2
+ correlationConfig,
3
+ { aggregateStore, eventStore, eventBus, aggregates },
4
+ handleCommand,
5
+ handleAdminCommand,
6
+ ) =>
7
+ Promise.all([aggregateStore(aggregates), eventStore()])
8
+ .then(([aggregateStore, eventStore]) => ({
9
+ aggregates,
10
+ aggregateStore,
11
+ eventStore,
12
+ handleCommand,
13
+ handleAdminCommand,
14
+ correlationConfig,
15
+ }))
16
+ .then((context) =>
17
+ eventBus().then((eventBus) => ({ ...context, eventBus })),
18
+ )
19
+ // We run a full replay on startup, to get all aggregates
20
+ // up and running. Not a great idea for production.
21
+ .then((context) =>
22
+ context.eventStore
23
+ .replay('INIT' /*correlationId*/)(context)
24
+ .then(() => context),
25
+ );
@@ -0,0 +1,33 @@
1
+ import { getLogger } from '@lazyapps/logger';
2
+
3
+ export const handleAdminCommand =
4
+ (skipAuthCheck = false) =>
5
+ (context, command, params, auth, correlationId) => {
6
+ const log = getLogger('CP/AdHandler', correlationId);
7
+ // Not very flexible this check, but we'll live with it for now
8
+ if (!skipAuthCheck && (!auth || !auth.admin)) {
9
+ log.error(`Unauthorized ${auth}`);
10
+ return Promise.reject(new Error(`Unauthorized ${auth}`));
11
+ }
12
+
13
+ if (command !== 'setReplayState') {
14
+ log.error(`Invalid admin command ${command}`);
15
+ return Promise.reject(new Error(`Invalid admin command ${command}`));
16
+ }
17
+
18
+ if (!params || typeof params.state !== 'boolean') {
19
+ log.error(`Invalid replay state ${params.state}`);
20
+ return Promise.reject(new Error(`Invalid replay state ${params.state}`));
21
+ }
22
+
23
+ const { eventBus } = context;
24
+ if (!eventBus) {
25
+ log.error(`Event bus not found`);
26
+ return Promise.reject(new Error(`Event bus not found`));
27
+ }
28
+
29
+ // Trying to remember why, but this call is synchronous
30
+ return new Promise((resolve) => {
31
+ resolve(eventBus.publishReplayState(correlationId)(params.state));
32
+ });
33
+ };
@@ -0,0 +1,61 @@
1
+ import { getLogger } from '@lazyapps/logger';
2
+
3
+ // We can receive an external timestamp, in case that gives
4
+ // better accuracy. If we don't receive one here, we'll
5
+ // record our own as soon as we can.
6
+ export const handleCommand = (
7
+ aggregateStore,
8
+ eventStore,
9
+ eventBus,
10
+ command,
11
+ aggregateName,
12
+ aggregateId,
13
+ payload,
14
+ commandHandler,
15
+ auth,
16
+ timestamp = null,
17
+ correlationId,
18
+ commandRecorder = null,
19
+ ) =>
20
+ Promise.resolve(timestamp || Date.now())
21
+ .then((timestamp) => {
22
+ // If command recording is enabled, record the command first
23
+ if (commandRecorder) {
24
+ return commandRecorder
25
+ .recordCommand({
26
+ command,
27
+ aggregateName,
28
+ aggregateId,
29
+ payload,
30
+ auth,
31
+ timestamp,
32
+ correlationId,
33
+ })
34
+ .then(() => timestamp);
35
+ }
36
+ return timestamp;
37
+ })
38
+ .then((timestamp) =>
39
+ Promise.resolve(
40
+ aggregateStore.getAggregateState(aggregateName, aggregateId),
41
+ )
42
+ .then((aggregateState) => commandHandler(aggregateState, payload, auth))
43
+ .then((eventMixin) => {
44
+ if (!eventMixin.type)
45
+ throw new Error(
46
+ `[${correlationId}] Event created for command ${command} on aggregate ${aggregateName}(${aggregateId}) has no 'type'`,
47
+ );
48
+ const event = {
49
+ ...eventMixin,
50
+ timestamp,
51
+ aggregateName,
52
+ aggregateId,
53
+ };
54
+ const log = getLogger('CP/Handler', correlationId);
55
+ log.debug(`Event generated: ${JSON.stringify(event)}`);
56
+ return event;
57
+ })
58
+ .then(eventStore.addEvent(correlationId))
59
+ .then(aggregateStore.applyAggregateProjection(correlationId))
60
+ .then(eventBus.publishEvent(correlationId)),
61
+ );
package/index.js ADDED
@@ -0,0 +1,19 @@
1
+ import { initializeContext } from './context.js';
2
+ import { handleCommand } from './handleCommand.js';
3
+ import { createCommandRecorder } from './commandRecorder.js';
4
+ import { handleAdminCommand } from './handleAdminCommand.js';
5
+
6
+ export const startCommandProcessor = (correlationConfig, config) => {
7
+ const commandRecorder = config.commandRecording?.enabled
8
+ ? createCommandRecorder(config.commandRecording.filePath)
9
+ : null;
10
+
11
+ return initializeContext(
12
+ correlationConfig,
13
+ config,
14
+ (...args) => handleCommand(...args, commandRecorder),
15
+ config.commandRecording
16
+ ? handleAdminCommand(config.commandRecording.skipAuthCheck)
17
+ : null,
18
+ ).then((context) => config.receiver(context));
19
+ };
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@lazyapps/command-processor",
3
+ "version": "0.1.0",
4
+ "description": "Transport-agnostic command validation and aggregate execution engine for LazyApps",
5
+ "main": "index.js",
6
+ "exports": {
7
+ ".": "./index.js"
8
+ },
9
+ "files": [
10
+ "*.js"
11
+ ],
12
+ "keywords": [
13
+ "event-sourcing",
14
+ "cqrs",
15
+ "lazyapps",
16
+ "command-processing",
17
+ "aggregates"
18
+ ],
19
+ "author": "Oliver Sturm",
20
+ "license": "ISC",
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/oliversturm/lazyapps-libs.git",
24
+ "directory": "packages/command-processor"
25
+ },
26
+ "bugs": {
27
+ "url": "https://github.com/oliversturm/lazyapps-libs/issues"
28
+ },
29
+ "homepage": "https://github.com/oliversturm/lazyapps-libs/tree/main/packages/command-processor#readme",
30
+ "engines": {
31
+ "node": ">=18.20.3 || >=20.18.0"
32
+ },
33
+ "devDependencies": {
34
+ "eslint": "^8.46.0",
35
+ "vitest": "^4.0.18"
36
+ },
37
+ "dependencies": {
38
+ "nanoid": "^5.0.7",
39
+ "@lazyapps/aggregatestore-inmemory": "^0.1.0",
40
+ "@lazyapps/eventbus-mqemitter-redis": "^0.1.0",
41
+ "@lazyapps/eventstore-mongodb": "^0.1.0",
42
+ "@lazyapps/logger": "^0.1.0"
43
+ },
44
+ "type": "module",
45
+ "scripts": {
46
+ "test": "vitest"
47
+ }
48
+ }
package/validation.js ADDED
@@ -0,0 +1,13 @@
1
+ export class ValidationError extends Error {
2
+ constructor(message) {
3
+ super(message);
4
+ this.name = this.constructor.name;
5
+ }
6
+ }
7
+
8
+ export class AuthorizationError extends Error {
9
+ constructor(message) {
10
+ super(message);
11
+ this.name = this.constructor.name;
12
+ }
13
+ }