@checkstack/maintenance-backend 0.3.0 → 0.4.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/CHANGELOG.md +24 -0
- package/package.json +1 -1
- package/src/index.ts +128 -7
- package/src/service.ts +106 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
# @checkstack/maintenance-backend
|
|
2
2
|
|
|
3
|
+
## 0.4.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 65aa47e: Add automatic maintenance status transitions
|
|
8
|
+
|
|
9
|
+
Maintenances now automatically transition from `scheduled` to `in_progress` when their `startAt` time is reached, and from `in_progress` to `completed` when their `endAt` time is reached. A recurring queue job runs every minute to check and transition statuses. Integration hooks and real-time signals are emitted upon each transition.
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- Updated dependencies [8a87cd4]
|
|
14
|
+
- Updated dependencies [8a87cd4]
|
|
15
|
+
- Updated dependencies [8a87cd4]
|
|
16
|
+
- Updated dependencies [8a87cd4]
|
|
17
|
+
- @checkstack/backend-api@0.4.1
|
|
18
|
+
- @checkstack/catalog-common@1.2.3
|
|
19
|
+
- @checkstack/common@0.5.0
|
|
20
|
+
- @checkstack/maintenance-common@0.4.1
|
|
21
|
+
- @checkstack/command-backend@0.1.5
|
|
22
|
+
- @checkstack/integration-backend@0.1.5
|
|
23
|
+
- @checkstack/integration-common@0.2.2
|
|
24
|
+
- @checkstack/notification-common@0.2.2
|
|
25
|
+
- @checkstack/signal-common@0.1.3
|
|
26
|
+
|
|
3
27
|
## 0.3.0
|
|
4
28
|
|
|
5
29
|
### Minor Changes
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -7,7 +7,9 @@ import {
|
|
|
7
7
|
pluginMetadata,
|
|
8
8
|
maintenanceContract,
|
|
9
9
|
maintenanceRoutes,
|
|
10
|
+
MAINTENANCE_UPDATED,
|
|
10
11
|
} from "@checkstack/maintenance-common";
|
|
12
|
+
|
|
11
13
|
import { createBackendPlugin, coreServices } from "@checkstack/backend-api";
|
|
12
14
|
import { integrationEventExtensionPoint } from "@checkstack/integration-backend";
|
|
13
15
|
import { MaintenanceService } from "./service";
|
|
@@ -42,6 +44,11 @@ const maintenanceUpdatedPayloadSchema = z.object({
|
|
|
42
44
|
action: z.enum(["updated", "closed"]),
|
|
43
45
|
});
|
|
44
46
|
|
|
47
|
+
// Queue and job constants
|
|
48
|
+
const STATUS_TRANSITION_QUEUE = "maintenance-status-transitions";
|
|
49
|
+
const STATUS_TRANSITION_JOB_ID = "maintenance-status-transition-check";
|
|
50
|
+
const WORKER_GROUP = "maintenance-status-worker";
|
|
51
|
+
|
|
45
52
|
// =============================================================================
|
|
46
53
|
// Plugin Definition
|
|
47
54
|
// =============================================================================
|
|
@@ -53,7 +60,7 @@ export default createBackendPlugin({
|
|
|
53
60
|
|
|
54
61
|
// Register hooks as integration events
|
|
55
62
|
const integrationEvents = env.getExtensionPoint(
|
|
56
|
-
integrationEventExtensionPoint
|
|
63
|
+
integrationEventExtensionPoint,
|
|
57
64
|
);
|
|
58
65
|
|
|
59
66
|
integrationEvents.registerEvent(
|
|
@@ -64,7 +71,7 @@ export default createBackendPlugin({
|
|
|
64
71
|
category: "Maintenance",
|
|
65
72
|
payloadSchema: maintenanceCreatedPayloadSchema,
|
|
66
73
|
},
|
|
67
|
-
pluginMetadata
|
|
74
|
+
pluginMetadata,
|
|
68
75
|
);
|
|
69
76
|
|
|
70
77
|
integrationEvents.registerEvent(
|
|
@@ -75,9 +82,12 @@ export default createBackendPlugin({
|
|
|
75
82
|
category: "Maintenance",
|
|
76
83
|
payloadSchema: maintenanceUpdatedPayloadSchema,
|
|
77
84
|
},
|
|
78
|
-
pluginMetadata
|
|
85
|
+
pluginMetadata,
|
|
79
86
|
);
|
|
80
87
|
|
|
88
|
+
// Store service reference for afterPluginsReady
|
|
89
|
+
let maintenanceService: MaintenanceService;
|
|
90
|
+
|
|
81
91
|
env.registerInit({
|
|
82
92
|
schema,
|
|
83
93
|
deps: {
|
|
@@ -85,20 +95,21 @@ export default createBackendPlugin({
|
|
|
85
95
|
rpc: coreServices.rpc,
|
|
86
96
|
rpcClient: coreServices.rpcClient,
|
|
87
97
|
signalService: coreServices.signalService,
|
|
98
|
+
queueManager: coreServices.queueManager,
|
|
88
99
|
},
|
|
89
100
|
init: async ({ logger, database, rpc, rpcClient, signalService }) => {
|
|
90
101
|
logger.debug("🔧 Initializing Maintenance Backend...");
|
|
91
102
|
|
|
92
103
|
const catalogClient = rpcClient.forPlugin(CatalogApi);
|
|
93
104
|
|
|
94
|
-
|
|
95
|
-
database as NodePgDatabase<typeof schema
|
|
105
|
+
maintenanceService = new MaintenanceService(
|
|
106
|
+
database as NodePgDatabase<typeof schema>,
|
|
96
107
|
);
|
|
97
108
|
const router = createRouter(
|
|
98
|
-
|
|
109
|
+
maintenanceService,
|
|
99
110
|
signalService,
|
|
100
111
|
catalogClient,
|
|
101
|
-
logger
|
|
112
|
+
logger,
|
|
102
113
|
);
|
|
103
114
|
rpc.registerRouter(router, maintenanceContract);
|
|
104
115
|
|
|
@@ -130,6 +141,116 @@ export default createBackendPlugin({
|
|
|
130
141
|
|
|
131
142
|
logger.debug("✅ Maintenance Backend initialized.");
|
|
132
143
|
},
|
|
144
|
+
afterPluginsReady: async ({
|
|
145
|
+
queueManager,
|
|
146
|
+
emitHook,
|
|
147
|
+
logger,
|
|
148
|
+
signalService,
|
|
149
|
+
}) => {
|
|
150
|
+
// Schedule the recurring status transition check job
|
|
151
|
+
const queue = queueManager.getQueue<Record<string, never>>(
|
|
152
|
+
STATUS_TRANSITION_QUEUE,
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
// Subscribe to process status transition check jobs
|
|
156
|
+
await queue.consume(
|
|
157
|
+
async () => {
|
|
158
|
+
logger.debug("⏰ Checking maintenance status transitions...");
|
|
159
|
+
|
|
160
|
+
// Get maintenances that need to start
|
|
161
|
+
const toStart = await maintenanceService.getMaintenancesToStart();
|
|
162
|
+
for (const maintenance of toStart) {
|
|
163
|
+
const updated = await maintenanceService.transitionStatus(
|
|
164
|
+
maintenance.id,
|
|
165
|
+
"in_progress",
|
|
166
|
+
"Maintenance started automatically",
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
if (updated) {
|
|
170
|
+
logger.info(
|
|
171
|
+
`Maintenance "${updated.title}" transitioned to in_progress`,
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
// Emit hook for integrations
|
|
175
|
+
await emitHook(maintenanceHooks.maintenanceUpdated, {
|
|
176
|
+
maintenanceId: updated.id,
|
|
177
|
+
systemIds: updated.systemIds,
|
|
178
|
+
title: updated.title,
|
|
179
|
+
description: updated.description,
|
|
180
|
+
status: updated.status,
|
|
181
|
+
startAt: updated.startAt.toISOString(),
|
|
182
|
+
endAt: updated.endAt.toISOString(),
|
|
183
|
+
action: "updated" as const,
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// Send signal for real-time UI updates
|
|
187
|
+
await signalService.broadcast(MAINTENANCE_UPDATED, {
|
|
188
|
+
maintenanceId: updated.id,
|
|
189
|
+
systemIds: updated.systemIds,
|
|
190
|
+
action: "updated",
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Get maintenances that need to complete
|
|
196
|
+
const toComplete =
|
|
197
|
+
await maintenanceService.getMaintenancesToComplete();
|
|
198
|
+
for (const maintenance of toComplete) {
|
|
199
|
+
const updated = await maintenanceService.transitionStatus(
|
|
200
|
+
maintenance.id,
|
|
201
|
+
"completed",
|
|
202
|
+
"Maintenance completed automatically",
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
if (updated) {
|
|
206
|
+
logger.info(
|
|
207
|
+
`Maintenance "${updated.title}" transitioned to completed`,
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
// Emit hook for integrations
|
|
211
|
+
await emitHook(maintenanceHooks.maintenanceUpdated, {
|
|
212
|
+
maintenanceId: updated.id,
|
|
213
|
+
systemIds: updated.systemIds,
|
|
214
|
+
title: updated.title,
|
|
215
|
+
description: updated.description,
|
|
216
|
+
status: updated.status,
|
|
217
|
+
startAt: updated.startAt.toISOString(),
|
|
218
|
+
endAt: updated.endAt.toISOString(),
|
|
219
|
+
action: "closed" as const,
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
// Send signal for real-time UI updates
|
|
223
|
+
await signalService.broadcast(MAINTENANCE_UPDATED, {
|
|
224
|
+
maintenanceId: updated.id,
|
|
225
|
+
systemIds: updated.systemIds,
|
|
226
|
+
action: "closed",
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (toStart.length > 0 || toComplete.length > 0) {
|
|
232
|
+
logger.debug(
|
|
233
|
+
`Status transitions: ${toStart.length} started, ${toComplete.length} completed`,
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
},
|
|
237
|
+
{
|
|
238
|
+
consumerGroup: WORKER_GROUP,
|
|
239
|
+
maxRetries: 0, // Status checks should not retry
|
|
240
|
+
},
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
// Schedule to run every minute (60 seconds)
|
|
244
|
+
await queue.scheduleRecurring(
|
|
245
|
+
{}, // Empty payload - the job just triggers a check
|
|
246
|
+
{
|
|
247
|
+
jobId: STATUS_TRANSITION_JOB_ID,
|
|
248
|
+
intervalSeconds: 60,
|
|
249
|
+
},
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
logger.debug("✅ Maintenance status transition job scheduled.");
|
|
253
|
+
},
|
|
133
254
|
});
|
|
134
255
|
},
|
|
135
256
|
});
|
package/src/service.ts
CHANGED
|
@@ -362,4 +362,110 @@ export class MaintenanceService {
|
|
|
362
362
|
|
|
363
363
|
return !!match;
|
|
364
364
|
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Get maintenances that should transition from 'scheduled' to 'in_progress'.
|
|
368
|
+
* These are maintenances where status = 'scheduled' AND startAt <= now.
|
|
369
|
+
*/
|
|
370
|
+
async getMaintenancesToStart(): Promise<MaintenanceWithSystems[]> {
|
|
371
|
+
const now = new Date();
|
|
372
|
+
|
|
373
|
+
const rows = await this.db
|
|
374
|
+
.select()
|
|
375
|
+
.from(maintenances)
|
|
376
|
+
.where(
|
|
377
|
+
and(
|
|
378
|
+
eq(maintenances.status, "scheduled"),
|
|
379
|
+
// startAt is in the past or now
|
|
380
|
+
// Using SQL comparison - startAt <= now
|
|
381
|
+
),
|
|
382
|
+
);
|
|
383
|
+
|
|
384
|
+
// Filter in JS since Drizzle SQL comparison can be tricky with dates
|
|
385
|
+
const startable = rows.filter((m) => m.startAt <= now);
|
|
386
|
+
|
|
387
|
+
// Fetch system IDs for each
|
|
388
|
+
const result: MaintenanceWithSystems[] = [];
|
|
389
|
+
for (const m of startable) {
|
|
390
|
+
const systems = await this.db
|
|
391
|
+
.select({ systemId: maintenanceSystems.systemId })
|
|
392
|
+
.from(maintenanceSystems)
|
|
393
|
+
.where(eq(maintenanceSystems.maintenanceId, m.id));
|
|
394
|
+
|
|
395
|
+
result.push({
|
|
396
|
+
...m,
|
|
397
|
+
description: m.description ?? undefined,
|
|
398
|
+
systemIds: systems.map((s) => s.systemId),
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
return result;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Get maintenances that should transition from 'in_progress' to 'completed'.
|
|
407
|
+
* These are maintenances where status = 'in_progress' AND endAt <= now.
|
|
408
|
+
*/
|
|
409
|
+
async getMaintenancesToComplete(): Promise<MaintenanceWithSystems[]> {
|
|
410
|
+
const now = new Date();
|
|
411
|
+
|
|
412
|
+
const rows = await this.db
|
|
413
|
+
.select()
|
|
414
|
+
.from(maintenances)
|
|
415
|
+
.where(eq(maintenances.status, "in_progress"));
|
|
416
|
+
|
|
417
|
+
// Filter in JS for those that have ended
|
|
418
|
+
const completable = rows.filter((m) => m.endAt <= now);
|
|
419
|
+
|
|
420
|
+
// Fetch system IDs for each
|
|
421
|
+
const result: MaintenanceWithSystems[] = [];
|
|
422
|
+
for (const m of completable) {
|
|
423
|
+
const systems = await this.db
|
|
424
|
+
.select({ systemId: maintenanceSystems.systemId })
|
|
425
|
+
.from(maintenanceSystems)
|
|
426
|
+
.where(eq(maintenanceSystems.maintenanceId, m.id));
|
|
427
|
+
|
|
428
|
+
result.push({
|
|
429
|
+
...m,
|
|
430
|
+
description: m.description ?? undefined,
|
|
431
|
+
systemIds: systems.map((s) => s.systemId),
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
return result;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Transition a maintenance to a new status with an automatic update entry.
|
|
440
|
+
* Used by the scheduled job for automatic status transitions.
|
|
441
|
+
*/
|
|
442
|
+
async transitionStatus(
|
|
443
|
+
id: string,
|
|
444
|
+
newStatus: MaintenanceStatus,
|
|
445
|
+
message: string,
|
|
446
|
+
): Promise<MaintenanceWithSystems | undefined> {
|
|
447
|
+
const [existing] = await this.db
|
|
448
|
+
.select()
|
|
449
|
+
.from(maintenances)
|
|
450
|
+
.where(eq(maintenances.id, id));
|
|
451
|
+
|
|
452
|
+
if (!existing) return undefined;
|
|
453
|
+
|
|
454
|
+
// Update the maintenance status
|
|
455
|
+
await this.db
|
|
456
|
+
.update(maintenances)
|
|
457
|
+
.set({ status: newStatus, updatedAt: new Date() })
|
|
458
|
+
.where(eq(maintenances.id, id));
|
|
459
|
+
|
|
460
|
+
// Add update entry (no user - system-generated)
|
|
461
|
+
await this.db.insert(maintenanceUpdates).values({
|
|
462
|
+
id: generateId(),
|
|
463
|
+
maintenanceId: id,
|
|
464
|
+
message,
|
|
465
|
+
statusChange: newStatus,
|
|
466
|
+
createdBy: undefined, // System-generated, no user
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
return (await this.getMaintenance(id))!;
|
|
470
|
+
}
|
|
365
471
|
}
|