@checkstack/maintenance-backend 0.4.0 → 0.5.1
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 +47 -0
- package/package.json +1 -1
- package/src/index.ts +38 -67
- package/src/notifications.ts +58 -0
- package/src/router.ts +41 -53
- package/src/service.ts +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,52 @@
|
|
|
1
1
|
# @checkstack/maintenance-backend
|
|
2
2
|
|
|
3
|
+
## 0.5.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Updated dependencies [db1f56f]
|
|
8
|
+
- @checkstack/common@0.6.0
|
|
9
|
+
- @checkstack/backend-api@0.5.1
|
|
10
|
+
- @checkstack/catalog-common@1.2.4
|
|
11
|
+
- @checkstack/command-backend@0.1.7
|
|
12
|
+
- @checkstack/integration-backend@0.1.7
|
|
13
|
+
- @checkstack/integration-common@0.2.3
|
|
14
|
+
- @checkstack/maintenance-common@0.4.2
|
|
15
|
+
- @checkstack/notification-common@0.2.3
|
|
16
|
+
- @checkstack/signal-common@0.1.4
|
|
17
|
+
|
|
18
|
+
## 0.5.0
|
|
19
|
+
|
|
20
|
+
### Minor Changes
|
|
21
|
+
|
|
22
|
+
- 2c0822d: ### Queue System
|
|
23
|
+
|
|
24
|
+
- Added cron pattern support to `scheduleRecurring()` - accepts either `intervalSeconds` or `cronPattern`
|
|
25
|
+
- BullMQ backend uses native cron scheduling via `pattern` option
|
|
26
|
+
- InMemoryQueue implements wall-clock cron scheduling with `cron-parser`
|
|
27
|
+
|
|
28
|
+
### Maintenance Backend
|
|
29
|
+
|
|
30
|
+
- Auto status transitions now use cron pattern `* * * * *` for precise second-0 scheduling
|
|
31
|
+
- User notifications are now sent for auto-started and auto-completed maintenances
|
|
32
|
+
- Refactored to call `addUpdate` RPC for status changes, centralizing hook/signal/notification logic
|
|
33
|
+
|
|
34
|
+
### UI
|
|
35
|
+
|
|
36
|
+
- DateTimePicker now resets seconds and milliseconds to 0 when time is changed
|
|
37
|
+
|
|
38
|
+
### Patch Changes
|
|
39
|
+
|
|
40
|
+
- 66a3963: Update database types to use SafeDatabase
|
|
41
|
+
|
|
42
|
+
- Updated all database type declarations from `NodePgDatabase` to `SafeDatabase` for compile-time safety
|
|
43
|
+
|
|
44
|
+
- Updated dependencies [66a3963]
|
|
45
|
+
- Updated dependencies [66a3963]
|
|
46
|
+
- @checkstack/integration-backend@0.1.6
|
|
47
|
+
- @checkstack/backend-api@0.5.0
|
|
48
|
+
- @checkstack/command-backend@0.1.6
|
|
49
|
+
|
|
3
50
|
## 0.4.0
|
|
4
51
|
|
|
5
52
|
### Minor Changes
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as schema from "./schema";
|
|
2
|
-
import type {
|
|
2
|
+
import type { SafeDatabase } from "@checkstack/backend-api";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import {
|
|
5
5
|
maintenanceAccessRules,
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
pluginMetadata,
|
|
8
8
|
maintenanceContract,
|
|
9
9
|
maintenanceRoutes,
|
|
10
|
-
|
|
10
|
+
MaintenanceApi,
|
|
11
11
|
} from "@checkstack/maintenance-common";
|
|
12
12
|
|
|
13
13
|
import { createBackendPlugin, coreServices } from "@checkstack/backend-api";
|
|
@@ -16,7 +16,7 @@ import { MaintenanceService } from "./service";
|
|
|
16
16
|
import { createRouter } from "./router";
|
|
17
17
|
import { CatalogApi } from "@checkstack/catalog-common";
|
|
18
18
|
import { registerSearchProvider } from "@checkstack/command-backend";
|
|
19
|
-
import { resolveRoute } from "@checkstack/common";
|
|
19
|
+
import { resolveRoute, type InferClient } from "@checkstack/common";
|
|
20
20
|
import { maintenanceHooks } from "./hooks";
|
|
21
21
|
|
|
22
22
|
// =============================================================================
|
|
@@ -87,6 +87,9 @@ export default createBackendPlugin({
|
|
|
87
87
|
|
|
88
88
|
// Store service reference for afterPluginsReady
|
|
89
89
|
let maintenanceService: MaintenanceService;
|
|
90
|
+
// Store clients for afterPluginsReady
|
|
91
|
+
let catalogClient: InferClient<typeof CatalogApi>;
|
|
92
|
+
let maintenanceClient: InferClient<typeof MaintenanceApi>;
|
|
90
93
|
|
|
91
94
|
env.registerInit({
|
|
92
95
|
schema,
|
|
@@ -100,10 +103,11 @@ export default createBackendPlugin({
|
|
|
100
103
|
init: async ({ logger, database, rpc, rpcClient, signalService }) => {
|
|
101
104
|
logger.debug("🔧 Initializing Maintenance Backend...");
|
|
102
105
|
|
|
103
|
-
|
|
106
|
+
catalogClient = rpcClient.forPlugin(CatalogApi);
|
|
107
|
+
maintenanceClient = rpcClient.forPlugin(MaintenanceApi);
|
|
104
108
|
|
|
105
109
|
maintenanceService = new MaintenanceService(
|
|
106
|
-
database as
|
|
110
|
+
database as SafeDatabase<typeof schema>,
|
|
107
111
|
);
|
|
108
112
|
const router = createRouter(
|
|
109
113
|
maintenanceService,
|
|
@@ -141,12 +145,7 @@ export default createBackendPlugin({
|
|
|
141
145
|
|
|
142
146
|
logger.debug("✅ Maintenance Backend initialized.");
|
|
143
147
|
},
|
|
144
|
-
afterPluginsReady: async ({
|
|
145
|
-
queueManager,
|
|
146
|
-
emitHook,
|
|
147
|
-
logger,
|
|
148
|
-
signalService,
|
|
149
|
-
}) => {
|
|
148
|
+
afterPluginsReady: async ({ queueManager, logger }) => {
|
|
150
149
|
// Schedule the recurring status transition check job
|
|
151
150
|
const queue = queueManager.getQueue<Record<string, never>>(
|
|
152
151
|
STATUS_TRANSITION_QUEUE,
|
|
@@ -160,35 +159,21 @@ export default createBackendPlugin({
|
|
|
160
159
|
// Get maintenances that need to start
|
|
161
160
|
const toStart = await maintenanceService.getMaintenancesToStart();
|
|
162
161
|
for (const maintenance of toStart) {
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
162
|
+
try {
|
|
163
|
+
// Call addUpdate via RPC - this handles hooks, signals, and notifications
|
|
164
|
+
await maintenanceClient.addUpdate({
|
|
165
|
+
maintenanceId: maintenance.id,
|
|
166
|
+
message: "Maintenance started automatically",
|
|
167
|
+
statusChange: "in_progress",
|
|
168
|
+
});
|
|
170
169
|
logger.info(
|
|
171
|
-
`Maintenance "${
|
|
170
|
+
`Maintenance "${maintenance.title}" transitioned to in_progress`,
|
|
171
|
+
);
|
|
172
|
+
} catch (error) {
|
|
173
|
+
logger.error(
|
|
174
|
+
`Failed to transition maintenance ${maintenance.id}:`,
|
|
175
|
+
error,
|
|
172
176
|
);
|
|
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
177
|
}
|
|
193
178
|
}
|
|
194
179
|
|
|
@@ -196,35 +181,21 @@ export default createBackendPlugin({
|
|
|
196
181
|
const toComplete =
|
|
197
182
|
await maintenanceService.getMaintenancesToComplete();
|
|
198
183
|
for (const maintenance of toComplete) {
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
184
|
+
try {
|
|
185
|
+
// Call addUpdate via RPC - this handles hooks, signals, and notifications
|
|
186
|
+
await maintenanceClient.addUpdate({
|
|
187
|
+
maintenanceId: maintenance.id,
|
|
188
|
+
message: "Maintenance completed automatically",
|
|
189
|
+
statusChange: "completed",
|
|
190
|
+
});
|
|
206
191
|
logger.info(
|
|
207
|
-
`Maintenance "${
|
|
192
|
+
`Maintenance "${maintenance.title}" transitioned to completed`,
|
|
193
|
+
);
|
|
194
|
+
} catch (error) {
|
|
195
|
+
logger.error(
|
|
196
|
+
`Failed to transition maintenance ${maintenance.id}:`,
|
|
197
|
+
error,
|
|
208
198
|
);
|
|
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
199
|
}
|
|
229
200
|
}
|
|
230
201
|
|
|
@@ -240,12 +211,12 @@ export default createBackendPlugin({
|
|
|
240
211
|
},
|
|
241
212
|
);
|
|
242
213
|
|
|
243
|
-
// Schedule to run every minute (
|
|
214
|
+
// Schedule to run every minute at second 0 (cron-based for precise timing)
|
|
244
215
|
await queue.scheduleRecurring(
|
|
245
216
|
{}, // Empty payload - the job just triggers a check
|
|
246
217
|
{
|
|
247
218
|
jobId: STATUS_TRANSITION_JOB_ID,
|
|
248
|
-
|
|
219
|
+
cronPattern: "* * * * *", // Every minute at :00 seconds
|
|
249
220
|
},
|
|
250
221
|
);
|
|
251
222
|
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { CatalogApi } from "@checkstack/catalog-common";
|
|
2
|
+
import type { Logger } from "@checkstack/backend-api";
|
|
3
|
+
import type { InferClient } from "@checkstack/common";
|
|
4
|
+
import { resolveRoute } from "@checkstack/common";
|
|
5
|
+
import { maintenanceRoutes } from "@checkstack/maintenance-common";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Helper to notify subscribers of affected systems about a maintenance event.
|
|
9
|
+
* Each system triggers a separate notification call, but within each call
|
|
10
|
+
* the subscribers are deduplicated (system + its groups).
|
|
11
|
+
*/
|
|
12
|
+
export async function notifyAffectedSystems(props: {
|
|
13
|
+
catalogClient: InferClient<typeof CatalogApi>;
|
|
14
|
+
logger: Logger;
|
|
15
|
+
maintenanceId: string;
|
|
16
|
+
maintenanceTitle: string;
|
|
17
|
+
systemIds: string[];
|
|
18
|
+
action: "created" | "updated" | "started" | "completed";
|
|
19
|
+
}): Promise<void> {
|
|
20
|
+
const {
|
|
21
|
+
catalogClient,
|
|
22
|
+
logger,
|
|
23
|
+
maintenanceId,
|
|
24
|
+
maintenanceTitle,
|
|
25
|
+
systemIds,
|
|
26
|
+
action,
|
|
27
|
+
} = props;
|
|
28
|
+
|
|
29
|
+
const actionText = {
|
|
30
|
+
created: "scheduled",
|
|
31
|
+
updated: "updated",
|
|
32
|
+
started: "started",
|
|
33
|
+
completed: "completed",
|
|
34
|
+
}[action];
|
|
35
|
+
|
|
36
|
+
const maintenanceDetailPath = resolveRoute(maintenanceRoutes.routes.detail, {
|
|
37
|
+
maintenanceId,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
for (const systemId of systemIds) {
|
|
41
|
+
try {
|
|
42
|
+
await catalogClient.notifySystemSubscribers({
|
|
43
|
+
systemId,
|
|
44
|
+
title: `Maintenance ${actionText}`,
|
|
45
|
+
body: `A maintenance **"${maintenanceTitle}"** has been ${actionText} for a system you're subscribed to.`,
|
|
46
|
+
importance: "info",
|
|
47
|
+
action: { label: "View Maintenance", url: maintenanceDetailPath },
|
|
48
|
+
includeGroupSubscribers: true,
|
|
49
|
+
});
|
|
50
|
+
} catch (error) {
|
|
51
|
+
// Log but don't fail the operation - notifications are best-effort
|
|
52
|
+
logger.warn(
|
|
53
|
+
`Failed to notify subscribers for system ${systemId}:`,
|
|
54
|
+
error,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
package/src/router.ts
CHANGED
|
@@ -2,7 +2,6 @@ import { implement, ORPCError } from "@orpc/server";
|
|
|
2
2
|
import {
|
|
3
3
|
maintenanceContract,
|
|
4
4
|
MAINTENANCE_UPDATED,
|
|
5
|
-
maintenanceRoutes,
|
|
6
5
|
} from "@checkstack/maintenance-common";
|
|
7
6
|
import {
|
|
8
7
|
autoAuthMiddleware,
|
|
@@ -13,8 +12,8 @@ import type { SignalService } from "@checkstack/signal-common";
|
|
|
13
12
|
import type { MaintenanceService } from "./service";
|
|
14
13
|
import { CatalogApi } from "@checkstack/catalog-common";
|
|
15
14
|
import type { InferClient } from "@checkstack/common";
|
|
16
|
-
import { resolveRoute } from "@checkstack/common";
|
|
17
15
|
import { maintenanceHooks } from "./hooks";
|
|
16
|
+
import { notifyAffectedSystems } from "./notifications";
|
|
18
17
|
|
|
19
18
|
export function createRouter(
|
|
20
19
|
service: MaintenanceService,
|
|
@@ -26,47 +25,6 @@ export function createRouter(
|
|
|
26
25
|
.$context<RpcContext>()
|
|
27
26
|
.use(autoAuthMiddleware);
|
|
28
27
|
|
|
29
|
-
/**
|
|
30
|
-
* Helper to notify subscribers of affected systems about a maintenance event.
|
|
31
|
-
* Each system triggers a separate notification call, but within each call
|
|
32
|
-
* the subscribers are deduplicated (system + its groups).
|
|
33
|
-
*/
|
|
34
|
-
const notifyAffectedSystems = async (props: {
|
|
35
|
-
maintenanceId: string;
|
|
36
|
-
maintenanceTitle: string;
|
|
37
|
-
systemIds: string[];
|
|
38
|
-
action: "created" | "updated";
|
|
39
|
-
}) => {
|
|
40
|
-
const { maintenanceId, maintenanceTitle, systemIds, action } = props;
|
|
41
|
-
|
|
42
|
-
const actionText = action === "created" ? "scheduled" : "updated";
|
|
43
|
-
const maintenanceDetailPath = resolveRoute(
|
|
44
|
-
maintenanceRoutes.routes.detail,
|
|
45
|
-
{
|
|
46
|
-
maintenanceId,
|
|
47
|
-
},
|
|
48
|
-
);
|
|
49
|
-
|
|
50
|
-
for (const systemId of systemIds) {
|
|
51
|
-
try {
|
|
52
|
-
await catalogClient.notifySystemSubscribers({
|
|
53
|
-
systemId,
|
|
54
|
-
title: `Maintenance ${actionText}`,
|
|
55
|
-
body: `A maintenance **"${maintenanceTitle}"** has been ${actionText} for a system you're subscribed to.`,
|
|
56
|
-
importance: "info",
|
|
57
|
-
action: { label: "View Maintenance", url: maintenanceDetailPath },
|
|
58
|
-
includeGroupSubscribers: true,
|
|
59
|
-
});
|
|
60
|
-
} catch (error) {
|
|
61
|
-
// Log but don't fail the operation - notifications are best-effort
|
|
62
|
-
logger.warn(
|
|
63
|
-
`Failed to notify subscribers for system ${systemId}:`,
|
|
64
|
-
error,
|
|
65
|
-
);
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
};
|
|
69
|
-
|
|
70
28
|
return os.router({
|
|
71
29
|
listMaintenances: os.listMaintenances.handler(async ({ input }) => {
|
|
72
30
|
return { maintenances: await service.listMaintenances(input ?? {}) };
|
|
@@ -127,6 +85,8 @@ export function createRouter(
|
|
|
127
85
|
|
|
128
86
|
// Send notifications to system subscribers
|
|
129
87
|
await notifyAffectedSystems({
|
|
88
|
+
catalogClient,
|
|
89
|
+
logger,
|
|
130
90
|
maintenanceId: result.id,
|
|
131
91
|
maintenanceTitle: result.title,
|
|
132
92
|
systemIds: result.systemIds,
|
|
@@ -165,14 +125,6 @@ export function createRouter(
|
|
|
165
125
|
action: "updated",
|
|
166
126
|
});
|
|
167
127
|
|
|
168
|
-
// Send notifications to system subscribers
|
|
169
|
-
await notifyAffectedSystems({
|
|
170
|
-
maintenanceId: result.id,
|
|
171
|
-
maintenanceTitle: result.title,
|
|
172
|
-
systemIds: result.systemIds,
|
|
173
|
-
action: "updated",
|
|
174
|
-
});
|
|
175
|
-
|
|
176
128
|
return result;
|
|
177
129
|
},
|
|
178
130
|
),
|
|
@@ -180,14 +132,25 @@ export function createRouter(
|
|
|
180
132
|
addUpdate: os.addUpdate.handler(async ({ input, context }) => {
|
|
181
133
|
const userId =
|
|
182
134
|
context.user && "id" in context.user ? context.user.id : undefined;
|
|
135
|
+
|
|
136
|
+
// Get previous status before update for comparison
|
|
137
|
+
const previousMaintenance = input.statusChange
|
|
138
|
+
? await service.getMaintenance(input.maintenanceId)
|
|
139
|
+
: undefined;
|
|
140
|
+
const previousStatus = previousMaintenance?.status;
|
|
141
|
+
|
|
183
142
|
const result = await service.addUpdate(input, userId);
|
|
184
143
|
// Get maintenance to broadcast with correct systemIds
|
|
185
144
|
const maintenance = await service.getMaintenance(input.maintenanceId);
|
|
186
145
|
if (maintenance) {
|
|
146
|
+
// Determine action based on status change
|
|
147
|
+
const action =
|
|
148
|
+
input.statusChange === "completed" ? "closed" : "updated";
|
|
149
|
+
|
|
187
150
|
await signalService.broadcast(MAINTENANCE_UPDATED, {
|
|
188
151
|
maintenanceId: input.maintenanceId,
|
|
189
152
|
systemIds: maintenance.systemIds,
|
|
190
|
-
action
|
|
153
|
+
action,
|
|
191
154
|
});
|
|
192
155
|
|
|
193
156
|
// Emit hook for cross-plugin coordination and integrations
|
|
@@ -199,8 +162,33 @@ export function createRouter(
|
|
|
199
162
|
status: maintenance.status,
|
|
200
163
|
startAt: maintenance.startAt.toISOString(),
|
|
201
164
|
endAt: maintenance.endAt.toISOString(),
|
|
202
|
-
action
|
|
165
|
+
action,
|
|
203
166
|
});
|
|
167
|
+
|
|
168
|
+
// Send notifications when status actually changes
|
|
169
|
+
if (input.statusChange && previousStatus !== input.statusChange) {
|
|
170
|
+
// Determine notification action based on the actual status transition
|
|
171
|
+
let notificationAction: "started" | "completed" | "updated";
|
|
172
|
+
if (
|
|
173
|
+
input.statusChange === "in_progress" &&
|
|
174
|
+
previousStatus !== "in_progress"
|
|
175
|
+
) {
|
|
176
|
+
notificationAction = "started";
|
|
177
|
+
} else if (input.statusChange === "completed") {
|
|
178
|
+
notificationAction = "completed";
|
|
179
|
+
} else {
|
|
180
|
+
notificationAction = "updated";
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
await notifyAffectedSystems({
|
|
184
|
+
catalogClient,
|
|
185
|
+
logger,
|
|
186
|
+
maintenanceId: input.maintenanceId,
|
|
187
|
+
maintenanceTitle: maintenance.title,
|
|
188
|
+
systemIds: maintenance.systemIds,
|
|
189
|
+
action: notificationAction,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
204
192
|
}
|
|
205
193
|
return result;
|
|
206
194
|
}),
|
package/src/service.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { eq, and, or, inArray } from "drizzle-orm";
|
|
2
|
-
import type {
|
|
2
|
+
import type { SafeDatabase } from "@checkstack/backend-api";
|
|
3
3
|
import * as schema from "./schema";
|
|
4
4
|
import { maintenances, maintenanceSystems, maintenanceUpdates } from "./schema";
|
|
5
5
|
import type {
|
|
@@ -12,7 +12,7 @@ import type {
|
|
|
12
12
|
MaintenanceStatus,
|
|
13
13
|
} from "@checkstack/maintenance-common";
|
|
14
14
|
|
|
15
|
-
type Db =
|
|
15
|
+
type Db = SafeDatabase<typeof schema>;
|
|
16
16
|
|
|
17
17
|
function generateId(): string {
|
|
18
18
|
return crypto.randomUUID();
|