@checkstack/maintenance-backend 0.4.0 → 0.5.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 CHANGED
@@ -1,5 +1,37 @@
1
1
  # @checkstack/maintenance-backend
2
2
 
3
+ ## 0.5.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 2c0822d: ### Queue System
8
+
9
+ - Added cron pattern support to `scheduleRecurring()` - accepts either `intervalSeconds` or `cronPattern`
10
+ - BullMQ backend uses native cron scheduling via `pattern` option
11
+ - InMemoryQueue implements wall-clock cron scheduling with `cron-parser`
12
+
13
+ ### Maintenance Backend
14
+
15
+ - Auto status transitions now use cron pattern `* * * * *` for precise second-0 scheduling
16
+ - User notifications are now sent for auto-started and auto-completed maintenances
17
+ - Refactored to call `addUpdate` RPC for status changes, centralizing hook/signal/notification logic
18
+
19
+ ### UI
20
+
21
+ - DateTimePicker now resets seconds and milliseconds to 0 when time is changed
22
+
23
+ ### Patch Changes
24
+
25
+ - 66a3963: Update database types to use SafeDatabase
26
+
27
+ - Updated all database type declarations from `NodePgDatabase` to `SafeDatabase` for compile-time safety
28
+
29
+ - Updated dependencies [66a3963]
30
+ - Updated dependencies [66a3963]
31
+ - @checkstack/integration-backend@0.1.6
32
+ - @checkstack/backend-api@0.5.0
33
+ - @checkstack/command-backend@0.1.6
34
+
3
35
  ## 0.4.0
4
36
 
5
37
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/maintenance-backend",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "scripts": {
package/src/index.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import * as schema from "./schema";
2
- import type { NodePgDatabase } from "drizzle-orm/node-postgres";
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
- MAINTENANCE_UPDATED,
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
- const catalogClient = rpcClient.forPlugin(CatalogApi);
106
+ catalogClient = rpcClient.forPlugin(CatalogApi);
107
+ maintenanceClient = rpcClient.forPlugin(MaintenanceApi);
104
108
 
105
109
  maintenanceService = new MaintenanceService(
106
- database as NodePgDatabase<typeof schema>,
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
- const updated = await maintenanceService.transitionStatus(
164
- maintenance.id,
165
- "in_progress",
166
- "Maintenance started automatically",
167
- );
168
-
169
- if (updated) {
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 "${updated.title}" transitioned to in_progress`,
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
- const updated = await maintenanceService.transitionStatus(
200
- maintenance.id,
201
- "completed",
202
- "Maintenance completed automatically",
203
- );
204
-
205
- if (updated) {
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 "${updated.title}" transitioned to completed`,
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 (60 seconds)
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
- intervalSeconds: 60,
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: "updated",
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: "updated",
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 { NodePgDatabase } from "drizzle-orm/node-postgres";
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 = NodePgDatabase<typeof schema>;
15
+ type Db = SafeDatabase<typeof schema>;
16
16
 
17
17
  function generateId(): string {
18
18
  return crypto.randomUUID();