@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 +43 -0
- package/package.json +42 -0
- package/readmodel-handlers.js +261 -0
- package/replay-handlers.js +97 -0
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
|
+
};
|