@checkstack/maintenance-backend 0.3.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,61 @@
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
+
35
+ ## 0.4.0
36
+
37
+ ### Minor Changes
38
+
39
+ - 65aa47e: Add automatic maintenance status transitions
40
+
41
+ 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.
42
+
43
+ ### Patch Changes
44
+
45
+ - Updated dependencies [8a87cd4]
46
+ - Updated dependencies [8a87cd4]
47
+ - Updated dependencies [8a87cd4]
48
+ - Updated dependencies [8a87cd4]
49
+ - @checkstack/backend-api@0.4.1
50
+ - @checkstack/catalog-common@1.2.3
51
+ - @checkstack/common@0.5.0
52
+ - @checkstack/maintenance-common@0.4.1
53
+ - @checkstack/command-backend@0.1.5
54
+ - @checkstack/integration-backend@0.1.5
55
+ - @checkstack/integration-common@0.2.2
56
+ - @checkstack/notification-common@0.2.2
57
+ - @checkstack/signal-common@0.1.3
58
+
3
59
  ## 0.3.0
4
60
 
5
61
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/maintenance-backend",
3
- "version": "0.3.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,14 +7,16 @@ import {
7
7
  pluginMetadata,
8
8
  maintenanceContract,
9
9
  maintenanceRoutes,
10
+ MaintenanceApi,
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";
14
16
  import { createRouter } from "./router";
15
17
  import { CatalogApi } from "@checkstack/catalog-common";
16
18
  import { registerSearchProvider } from "@checkstack/command-backend";
17
- import { resolveRoute } from "@checkstack/common";
19
+ import { resolveRoute, type InferClient } from "@checkstack/common";
18
20
  import { maintenanceHooks } from "./hooks";
19
21
 
20
22
  // =============================================================================
@@ -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,15 @@ 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
+ // Store clients for afterPluginsReady
91
+ let catalogClient: InferClient<typeof CatalogApi>;
92
+ let maintenanceClient: InferClient<typeof MaintenanceApi>;
93
+
81
94
  env.registerInit({
82
95
  schema,
83
96
  deps: {
@@ -85,20 +98,22 @@ export default createBackendPlugin({
85
98
  rpc: coreServices.rpc,
86
99
  rpcClient: coreServices.rpcClient,
87
100
  signalService: coreServices.signalService,
101
+ queueManager: coreServices.queueManager,
88
102
  },
89
103
  init: async ({ logger, database, rpc, rpcClient, signalService }) => {
90
104
  logger.debug("🔧 Initializing Maintenance Backend...");
91
105
 
92
- const catalogClient = rpcClient.forPlugin(CatalogApi);
106
+ catalogClient = rpcClient.forPlugin(CatalogApi);
107
+ maintenanceClient = rpcClient.forPlugin(MaintenanceApi);
93
108
 
94
- const service = new MaintenanceService(
95
- database as NodePgDatabase<typeof schema>
109
+ maintenanceService = new MaintenanceService(
110
+ database as SafeDatabase<typeof schema>,
96
111
  );
97
112
  const router = createRouter(
98
- service,
113
+ maintenanceService,
99
114
  signalService,
100
115
  catalogClient,
101
- logger
116
+ logger,
102
117
  );
103
118
  rpc.registerRouter(router, maintenanceContract);
104
119
 
@@ -130,6 +145,83 @@ export default createBackendPlugin({
130
145
 
131
146
  logger.debug("✅ Maintenance Backend initialized.");
132
147
  },
148
+ afterPluginsReady: async ({ queueManager, logger }) => {
149
+ // Schedule the recurring status transition check job
150
+ const queue = queueManager.getQueue<Record<string, never>>(
151
+ STATUS_TRANSITION_QUEUE,
152
+ );
153
+
154
+ // Subscribe to process status transition check jobs
155
+ await queue.consume(
156
+ async () => {
157
+ logger.debug("⏰ Checking maintenance status transitions...");
158
+
159
+ // Get maintenances that need to start
160
+ const toStart = await maintenanceService.getMaintenancesToStart();
161
+ for (const maintenance of toStart) {
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
+ });
169
+ logger.info(
170
+ `Maintenance "${maintenance.title}" transitioned to in_progress`,
171
+ );
172
+ } catch (error) {
173
+ logger.error(
174
+ `Failed to transition maintenance ${maintenance.id}:`,
175
+ error,
176
+ );
177
+ }
178
+ }
179
+
180
+ // Get maintenances that need to complete
181
+ const toComplete =
182
+ await maintenanceService.getMaintenancesToComplete();
183
+ for (const maintenance of toComplete) {
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
+ });
191
+ logger.info(
192
+ `Maintenance "${maintenance.title}" transitioned to completed`,
193
+ );
194
+ } catch (error) {
195
+ logger.error(
196
+ `Failed to transition maintenance ${maintenance.id}:`,
197
+ error,
198
+ );
199
+ }
200
+ }
201
+
202
+ if (toStart.length > 0 || toComplete.length > 0) {
203
+ logger.debug(
204
+ `Status transitions: ${toStart.length} started, ${toComplete.length} completed`,
205
+ );
206
+ }
207
+ },
208
+ {
209
+ consumerGroup: WORKER_GROUP,
210
+ maxRetries: 0, // Status checks should not retry
211
+ },
212
+ );
213
+
214
+ // Schedule to run every minute at second 0 (cron-based for precise timing)
215
+ await queue.scheduleRecurring(
216
+ {}, // Empty payload - the job just triggers a check
217
+ {
218
+ jobId: STATUS_TRANSITION_JOB_ID,
219
+ cronPattern: "* * * * *", // Every minute at :00 seconds
220
+ },
221
+ );
222
+
223
+ logger.debug("✅ Maintenance status transition job scheduled.");
224
+ },
133
225
  });
134
226
  },
135
227
  });
@@ -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();
@@ -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
  }