@lazyapps/admin-api 0.0.0-init.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/index.js ADDED
@@ -0,0 +1,43 @@
1
+ import {
2
+ startReplayHandler,
3
+ replayStatusHandler,
4
+ cancelReplayHandler,
5
+ legacyAdminHandler,
6
+ } from './replay-handlers.js';
7
+
8
+ import {
9
+ statusHandler,
10
+ readModelsHandler,
11
+ createBackupHandler,
12
+ listBackupsHandler,
13
+ deleteBackupHandler,
14
+ prepareReplayHandler,
15
+ replayReadModelStatusHandler,
16
+ } from './readmodel-handlers.js';
17
+
18
+ export const installReplayAdminApi = (context) => (app) => {
19
+ app.post('/api/admin/startReplay', startReplayHandler(context));
20
+ app.get('/api/admin/replayStatus/:readModel', replayStatusHandler(context));
21
+ app.post('/api/admin/cancelReplay', cancelReplayHandler(context));
22
+
23
+ // Legacy backward compat for command-replay CLI tool
24
+ app.post('/api/admin/:command', legacyAdminHandler(context));
25
+ };
26
+
27
+ export const installReadModelAdminApi = (context) => (app) => {
28
+ app.get('/admin/status', statusHandler(context));
29
+ app.get('/admin/readmodels', readModelsHandler(context));
30
+
31
+ app.post('/admin/backup/:readModelName', createBackupHandler(context));
32
+ app.get('/admin/backups/:readModelName', listBackupsHandler(context));
33
+ app.delete('/admin/backup/:backupId', deleteBackupHandler(context));
34
+
35
+ app.post(
36
+ '/admin/replay/:readModelName/prepare',
37
+ prepareReplayHandler(context),
38
+ );
39
+ app.get(
40
+ '/admin/replay/:readModelName/status',
41
+ replayReadModelStatusHandler(context),
42
+ );
43
+ };
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@lazyapps/admin-api",
3
+ "version": "0.0.0-init.0",
4
+ "description": "Admin API route handlers for LazyApps event replay and read model management",
5
+ "main": "index.js",
6
+ "files": [
7
+ "*.js"
8
+ ],
9
+ "scripts": {
10
+ "test": "vitest"
11
+ },
12
+ "keywords": [
13
+ "event-sourcing",
14
+ "cqrs",
15
+ "lazyapps",
16
+ "admin",
17
+ "replay"
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/admin-api"
25
+ },
26
+ "bugs": {
27
+ "url": "https://github.com/oliversturm/lazyapps-libs/issues"
28
+ },
29
+ "homepage": "https://github.com/oliversturm/lazyapps-libs/tree/main/packages/admin-api#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
+ "@lazyapps/logger": "workspace:^",
39
+ "nanoid": "^5.0.7"
40
+ },
41
+ "type": "module"
42
+ }
@@ -0,0 +1,261 @@
1
+ import { getLogger } from '@lazyapps/logger';
2
+ import { nanoid } from 'nanoid';
3
+
4
+ const detectSharedCollections = (readModels, targetName, targetCollections) => {
5
+ const warnings = [];
6
+ const targetSet = new Set(targetCollections);
7
+
8
+ Object.keys(readModels).forEach((rmName) => {
9
+ if (rmName === targetName) return;
10
+ const rm = readModels[rmName];
11
+ const rmCollections = rm.collections || [rmName];
12
+ const shared = rmCollections.filter((c) => targetSet.has(c));
13
+ if (shared.length) {
14
+ warnings.push(
15
+ `Read model '${rmName}' shares collections: ${shared.join(', ')}`,
16
+ );
17
+ }
18
+ });
19
+
20
+ return warnings;
21
+ };
22
+
23
+ export const statusHandler = (context) => {
24
+ const startedAt = Date.now();
25
+ return (req, res) => {
26
+ const replayStates = context.projectionHandler.getReadModelReplayStates();
27
+
28
+ res.json({
29
+ service: context.correlationConfig.serviceId,
30
+ uptime: Date.now() - startedAt,
31
+ readModels: Object.keys(context.readModels).map((name) => ({
32
+ name,
33
+ lastProjectedEventTimestamp:
34
+ context.readModels[name].lastProjectedEventTimestamp || 0,
35
+ replaying: !!replayStates[name],
36
+ })),
37
+ });
38
+ };
39
+ };
40
+
41
+ export const readModelsHandler = (context) => (req, res) => {
42
+ const replayStates = context.projectionHandler.getReadModelReplayStates();
43
+
44
+ res.json(
45
+ Object.keys(context.readModels).map((name) => {
46
+ const rm = context.readModels[name];
47
+ return {
48
+ name,
49
+ lastProjectedEventTimestamp: rm.lastProjectedEventTimestamp || 0,
50
+ status: replayStates[name] ? 'replaying' : 'active',
51
+ collections: rm.collections || [name],
52
+ };
53
+ }),
54
+ );
55
+ };
56
+
57
+ export const createBackupHandler = (context) => (req, res) => {
58
+ const correlationId = req.body.correlationId || nanoid();
59
+ const log = getLogger('Admin/Backup', correlationId);
60
+ const { readModelName } = req.params;
61
+
62
+ const rm = context.readModels[readModelName];
63
+ if (!rm) {
64
+ res.status(404).json({ error: `Read model ${readModelName} not found` });
65
+ return;
66
+ }
67
+
68
+ if (!context.backup) {
69
+ res.status(501).json({ error: 'Backup not configured' });
70
+ return;
71
+ }
72
+
73
+ const collectionNames = rm.collections || [readModelName];
74
+ log.info(`Creating backup for ${readModelName}`);
75
+
76
+ return context.backup
77
+ .createBackup(correlationId, readModelName, collectionNames)
78
+ .then((result) => {
79
+ res.json(result);
80
+ })
81
+ .catch((err) => {
82
+ log.error(`Failed to create backup: ${err}`);
83
+ res.status(500).json({ error: String(err) });
84
+ });
85
+ };
86
+
87
+ export const listBackupsHandler = (context) => (req, res) => {
88
+ const { readModelName } = req.params;
89
+
90
+ const rm = context.readModels[readModelName];
91
+ if (!rm) {
92
+ res.status(404).json({ error: `Read model ${readModelName} not found` });
93
+ return;
94
+ }
95
+
96
+ if (!context.backup) {
97
+ res.status(501).json({ error: 'Backup not configured' });
98
+ return;
99
+ }
100
+
101
+ return context.backup
102
+ .listBackups(readModelName)
103
+ .then((backups) => {
104
+ res.json(backups);
105
+ })
106
+ .catch((err) => {
107
+ const log = getLogger('Admin/Backup', 'list');
108
+ log.error(`Failed to list backups: ${err}`);
109
+ res.status(500).json({ error: String(err) });
110
+ });
111
+ };
112
+
113
+ export const deleteBackupHandler = (context) => (req, res) => {
114
+ const correlationId = nanoid();
115
+ const log = getLogger('Admin/Backup', correlationId);
116
+ const { backupId } = req.params;
117
+
118
+ if (!context.backup) {
119
+ res.status(501).json({ error: 'Backup not configured' });
120
+ return;
121
+ }
122
+
123
+ log.info(`Deleting backup ${backupId}`);
124
+
125
+ return context.backup
126
+ .deleteBackup(correlationId, backupId)
127
+ .then(() => {
128
+ res.sendStatus(204);
129
+ })
130
+ .catch((err) => {
131
+ log.error(`Failed to delete backup: ${err}`);
132
+ res.status(500).json({ error: String(err) });
133
+ });
134
+ };
135
+
136
+ export const prepareReplayHandler = (context) => (req, res) => {
137
+ const correlationId = req.body.correlationId || nanoid();
138
+ const log = getLogger('Admin/Prepare', correlationId);
139
+ const { readModelName } = req.params;
140
+ const { backupId, fromScratch } = req.body;
141
+
142
+ const rm = context.readModels[readModelName];
143
+ if (!rm) {
144
+ res.status(404).json({ error: `Read model ${readModelName} not found` });
145
+ return;
146
+ }
147
+
148
+ if (context.projectionHandler.isReadModelReplaying(readModelName)) {
149
+ res.status(409).json({
150
+ error: `Replay already in progress for ${readModelName}`,
151
+ });
152
+ return;
153
+ }
154
+
155
+ if ((backupId || fromScratch) && !context.backup) {
156
+ res.status(501).json({
157
+ error: 'Backup module required for restore or from-scratch replay',
158
+ });
159
+ return;
160
+ }
161
+
162
+ const collectionNames = rm.collections || [readModelName];
163
+ const warnings = detectSharedCollections(
164
+ context.readModels,
165
+ readModelName,
166
+ collectionNames,
167
+ );
168
+
169
+ log.info(`Preparing replay for ${readModelName}`);
170
+
171
+ // Step 1: Create pre-replay safety backup
172
+ return (
173
+ context.backup
174
+ ? context.backup.createBackup(
175
+ correlationId,
176
+ readModelName,
177
+ collectionNames,
178
+ )
179
+ : Promise.resolve(null)
180
+ )
181
+ .then((backupResult) => {
182
+ const preReplayBackupId = backupResult ? backupResult.backupId : null;
183
+
184
+ // Step 2: Set per-read-model replay state
185
+ context.projectionHandler.setReadModelReplayState(readModelName, true);
186
+
187
+ // Step 3: Restore backup or clear collections
188
+ const restoreStep = backupId
189
+ ? context.backup.restoreBackup(correlationId, readModelName, backupId)
190
+ : fromScratch
191
+ ? context.backup.clearCollections(
192
+ correlationId,
193
+ readModelName,
194
+ collectionNames,
195
+ )
196
+ : Promise.resolve();
197
+
198
+ return restoreStep
199
+ .then(() => {
200
+ // Step 4: Determine fromTimestamp
201
+ if (backupId) {
202
+ return context.backup.listBackups(readModelName).then((backups) => {
203
+ const restored = backups.find((b) => b.backupId === backupId);
204
+ return restored ? restored.eventTimestamp : 0;
205
+ });
206
+ }
207
+ if (fromScratch) return Promise.resolve(0);
208
+ return Promise.resolve(rm.lastProjectedEventTimestamp || 0);
209
+ })
210
+ .then((fromTimestamp) =>
211
+ // Step 5: Mark replayInProgress in readmodel.state
212
+ context.storage
213
+ .perRequest(correlationId)
214
+ .updateOne(
215
+ 'readmodel.state',
216
+ { name: readModelName },
217
+ {
218
+ $set: {
219
+ replayInProgress: true,
220
+ preReplayBackupId,
221
+ },
222
+ },
223
+ )
224
+ .then(() => {
225
+ res.json({
226
+ status: 'prepared',
227
+ readModel: readModelName,
228
+ fromTimestamp,
229
+ preReplayBackupId,
230
+ warnings,
231
+ });
232
+ }),
233
+ );
234
+ })
235
+ .catch((err) => {
236
+ log.error(`Failed to prepare replay: ${err}`);
237
+ context.projectionHandler.clearReadModelReplayState(readModelName);
238
+ res.status(500).json({ error: String(err) });
239
+ });
240
+ };
241
+
242
+ export const replayReadModelStatusHandler = (context) => (req, res) => {
243
+ const { readModelName } = req.params;
244
+
245
+ const rm = context.readModels[readModelName];
246
+ if (!rm) {
247
+ res.status(404).json({ error: `Read model ${readModelName} not found` });
248
+ return;
249
+ }
250
+
251
+ const isReplaying =
252
+ context.projectionHandler.isReadModelReplaying(readModelName);
253
+
254
+ res.json({
255
+ readModel: readModelName,
256
+ status: isReplaying ? 'in_progress' : 'idle',
257
+ lastProjectedEventTimestamp: rm.lastProjectedEventTimestamp || 0,
258
+ });
259
+ };
260
+
261
+ export const __testing__ = { detectSharedCollections };
@@ -0,0 +1,97 @@
1
+ import { getLogger } from '@lazyapps/logger';
2
+ import { nanoid } from 'nanoid';
3
+
4
+ export const startReplayHandler = (context) => (req, res) => {
5
+ const correlationId = req.body.correlationId || nanoid();
6
+ const log = getLogger('Admin/Replay', correlationId);
7
+ const { readModel, fromTimestamp, toTimestamp } = req.body;
8
+
9
+ if (!readModel) {
10
+ res.status(400).json({ error: 'readModel is required' });
11
+ return;
12
+ }
13
+
14
+ const currentStatus = context.replayHandler.getReplayStatus(readModel);
15
+ if (currentStatus.status === 'in_progress') {
16
+ res
17
+ .status(409)
18
+ .json({ error: `Replay already in progress for ${readModel}` });
19
+ return;
20
+ }
21
+
22
+ log.info(`Starting replay for ${readModel} from ${fromTimestamp || 0}`);
23
+
24
+ // Start replay in background (streaming runs asynchronously)
25
+ context.replayHandler
26
+ .startReplay(
27
+ correlationId,
28
+ readModel,
29
+ fromTimestamp || 0,
30
+ toTimestamp || null,
31
+ )
32
+ .catch((err) => {
33
+ log.error(`Replay failed for ${readModel}: ${err}`);
34
+ });
35
+
36
+ res.json({ status: 'started', readModel });
37
+ };
38
+
39
+ export const replayStatusHandler = (context) => (req, res) => {
40
+ const { readModel } = req.params;
41
+ res.json(context.replayHandler.getReplayStatus(readModel));
42
+ };
43
+
44
+ export const cancelReplayHandler = (context) => (req, res) => {
45
+ const correlationId = req.body.correlationId || nanoid();
46
+ const log = getLogger('Admin/Replay', correlationId);
47
+ const { readModel } = req.body;
48
+
49
+ if (!readModel) {
50
+ res.status(400).json({ error: 'readModel is required' });
51
+ return;
52
+ }
53
+
54
+ log.info(`Cancelling replay for ${readModel}`);
55
+
56
+ return context.replayHandler
57
+ .cancelReplay(correlationId, readModel)
58
+ .then(() => {
59
+ res.json({ status: 'cancelling', readModel });
60
+ })
61
+ .catch((err) => {
62
+ log.error(`Failed to cancel replay: ${err}`);
63
+ res.status(500).json({ error: String(err) });
64
+ });
65
+ };
66
+
67
+ export const legacyAdminHandler = (context) => (req, res) => {
68
+ const { correlationId } = req.body;
69
+ const log = getLogger('Admin/Legacy', correlationId);
70
+ const { command } = req.params;
71
+
72
+ const handler = context.handleAdminCommand;
73
+ if (!handler) {
74
+ log.error(`No admin command handler available`);
75
+ res.sendStatus(400);
76
+ return;
77
+ }
78
+
79
+ log.debug(
80
+ `Legacy admin command ${command} with params ${JSON.stringify(req.body.params)}`,
81
+ );
82
+
83
+ return handler(context, command, req.body.params, req.auth, correlationId)
84
+ .then(() => {
85
+ res.sendStatus(200);
86
+ })
87
+ .catch((err) => {
88
+ log.error(`Error: ${err}`);
89
+ if (err.name === 'ValidationError') {
90
+ res.sendStatus(400);
91
+ } else if (err.name === 'AuthorizationError') {
92
+ res.sendStatus(403);
93
+ } else {
94
+ res.sendStatus(500);
95
+ }
96
+ });
97
+ };