@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 +15 -0
- package/README.md +13 -0
- package/commandRecorder.js +71 -0
- package/context.js +25 -0
- package/handleAdminCommand.js +33 -0
- package/handleCommand.js +61 -0
- package/index.js +19 -0
- package/package.json +48 -0
- package/validation.js +13 -0
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
|
+
};
|
package/handleCommand.js
ADDED
|
@@ -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
|
+
}
|