@checkstack/maintenance-backend 1.1.6 → 1.3.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/src/index.ts CHANGED
@@ -1,6 +1,5 @@
1
1
  import * as schema from "./schema";
2
2
  import type { SafeDatabase } from "@checkstack/backend-api";
3
- import { z } from "zod";
4
3
  import {
5
4
  maintenanceAccessRules,
6
5
  maintenanceAccess,
@@ -13,7 +12,13 @@ import {
13
12
  } from "@checkstack/maintenance-common";
14
13
 
15
14
  import { createBackendPlugin, coreServices } from "@checkstack/backend-api";
16
- import { integrationEventExtensionPoint } from "@checkstack/integration-backend";
15
+ import {
16
+ automationActionExtensionPoint,
17
+ automationArtifactTypeExtensionPoint,
18
+ automationTriggerExtensionPoint,
19
+ entityExtensionPoint,
20
+ type EntityHandle,
21
+ } from "@checkstack/automation-backend";
17
22
  import {
18
23
  NotificationApi,
19
24
  specToRegistration,
@@ -24,33 +29,20 @@ import { CatalogApi } from "@checkstack/catalog-common";
24
29
  import { AuthApi } from "@checkstack/auth-common";
25
30
  import { registerSearchProvider } from "@checkstack/command-backend";
26
31
  import { resolveRoute, type InferClient } from "@checkstack/common";
27
- import { maintenanceHooks } from "./hooks";
28
32
  import { createMaintenanceCache } from "./cache";
29
-
30
- // =============================================================================
31
- // Integration Event Payload Schemas
32
- // =============================================================================
33
-
34
- const maintenanceCreatedPayloadSchema = z.object({
35
- maintenanceId: z.string(),
36
- systemIds: z.array(z.string()),
37
- title: z.string(),
38
- description: z.string().optional(),
39
- status: z.string(),
40
- startAt: z.string(),
41
- endAt: z.string(),
42
- });
43
-
44
- const maintenanceUpdatedPayloadSchema = z.object({
45
- maintenanceId: z.string(),
46
- systemIds: z.array(z.string()),
47
- title: z.string(),
48
- description: z.string().optional(),
49
- status: z.string(),
50
- startAt: z.string(),
51
- endAt: z.string(),
52
- action: z.enum(["updated", "closed"]),
53
- });
33
+ import {
34
+ createMaintenanceActions,
35
+ maintenanceArtifactType,
36
+ maintenanceTriggers,
37
+ } from "./automations";
38
+ import {
39
+ MAINTENANCE_ENTITY_KIND,
40
+ createMaintenanceEntityRead,
41
+ deriveMaintenanceEvents,
42
+ maintenanceChangeToPayload,
43
+ maintenanceEntityStateSchema,
44
+ type MaintenanceEntityState,
45
+ } from "./entity";
54
46
 
55
47
  // Queue and job constants
56
48
  const STATUS_TRANSITION_QUEUE = "maintenance-status-transitions";
@@ -70,32 +62,62 @@ export default createBackendPlugin({
70
62
  maintenanceGroupSubscription,
71
63
  ]);
72
64
 
73
- // Register hooks as integration events
74
- const integrationEvents = env.getExtensionPoint(
75
- integrationEventExtensionPoint,
76
- );
65
+ // ─── Automation Platform: entity + artifact type ───────────────────
66
+ // Buffered behind the extension point until automation-backend's
67
+ // register() runs. Actions are wired in afterPluginsReady so the entity
68
+ // handle is available on the service — see below.
69
+ //
70
+ // Reactive entity (reactive automation engine §10.2): the
71
+ // `maintenance.created` / `maintenance.updated` trigger events are now
72
+ // DERIVED from `maintenance` entity changes. The triggers stay registered
73
+ // (ENTITY-DRIVEN, no hook) so they remain in the editor's trigger catalog +
74
+ // payload-introspectable; a `toPayload` mapper makes the runtime
75
+ // `trigger.payload` match their `payloadSchema` (mirroring incident /
76
+ // catalog / dependency / healthcheck).
77
+ //
78
+ // PLUGIN-BACKED (Model B): the `maintenances` + `maintenance_systems`
79
+ // tables ARE the current-state storage. `read` routes straight to the
80
+ // service's batched authoritative read — no framework `entity_state` row,
81
+ // so no `indexes` (those only apply to store-backed kinds). The `read`
82
+ // closure resolves the service set by init() (mutations only happen from
83
+ // init onward).
84
+ const entity = env.getExtensionPoint(entityExtensionPoint);
77
85
 
78
- integrationEvents.registerEvent(
79
- {
80
- hook: maintenanceHooks.maintenanceCreated,
81
- displayName: "Maintenance Created",
82
- description: "Fired when a new maintenance is scheduled",
83
- category: "Maintenance",
84
- payloadSchema: maintenanceCreatedPayloadSchema,
85
- },
86
- pluginMetadata,
87
- );
86
+ // The maintenance service is created in init() (it needs the resolved
87
+ // database), but the PLUGIN-BACKED entity `read` accessor must be supplied
88
+ // at `defineEntity` time in register(). This holder bridges the two: the
89
+ // `read` closure resolves the service lazily, and init() sets it before
90
+ // any mutation runs (the registry only mutates from init() onward).
91
+ let maintenanceServiceRef: MaintenanceService | undefined;
88
92
 
89
- integrationEvents.registerEvent(
90
- {
91
- hook: maintenanceHooks.maintenanceUpdated,
92
- displayName: "Maintenance Updated",
93
- description: "Fired when a maintenance is updated or closed",
94
- category: "Maintenance",
95
- payloadSchema: maintenanceUpdatedPayloadSchema,
96
- },
97
- pluginMetadata,
93
+ const maintenanceEntityHandle: EntityHandle<MaintenanceEntityState> =
94
+ entity.defineEntity<MaintenanceEntityState>({
95
+ kind: MAINTENANCE_ENTITY_KIND,
96
+ state: maintenanceEntityStateSchema,
97
+ read: (ids) => {
98
+ const svc = maintenanceServiceRef;
99
+ if (!svc) {
100
+ throw new Error(
101
+ "maintenance entity read before init: service not yet resolved",
102
+ );
103
+ }
104
+ return createMaintenanceEntityRead(svc)(ids);
105
+ },
106
+ });
107
+ entity.registerChangeDeriver({
108
+ kind: MAINTENANCE_ENTITY_KIND,
109
+ derive: deriveMaintenanceEvents,
110
+ toPayload: maintenanceChangeToPayload,
111
+ });
112
+ const automationTriggers = env.getExtensionPoint(
113
+ automationTriggerExtensionPoint,
98
114
  );
115
+ for (const trigger of maintenanceTriggers) {
116
+ automationTriggers.registerTrigger(trigger, pluginMetadata);
117
+ }
118
+ env
119
+ .getExtensionPoint(automationArtifactTypeExtensionPoint)
120
+ .registerArtifactType(maintenanceArtifactType, pluginMetadata);
99
121
 
100
122
  // Store service reference for afterPluginsReady
101
123
  let maintenanceService: MaintenanceService;
@@ -132,6 +154,9 @@ export default createBackendPlugin({
132
154
  maintenanceService = new MaintenanceService(
133
155
  database as SafeDatabase<typeof schema>,
134
156
  );
157
+ // Publish the service for the PLUGIN-BACKED entity `read` accessor
158
+ // (defined in register()). Mutations only run from here onward.
159
+ maintenanceServiceRef = maintenanceService;
135
160
  const cache = createMaintenanceCache({ cacheManager, logger });
136
161
  const router = createRouter(
137
162
  maintenanceService,
@@ -141,6 +166,7 @@ export default createBackendPlugin({
141
166
  authClient,
142
167
  logger,
143
168
  cache,
169
+ maintenanceEntityHandle,
144
170
  );
145
171
  rpc.registerRouter(router, maintenanceContract);
146
172
 
@@ -173,6 +199,19 @@ export default createBackendPlugin({
173
199
  logger.debug("✅ Maintenance Backend initialized.");
174
200
  },
175
201
  afterPluginsReady: async ({ queueManager, logger }) => {
202
+ // Register automation actions. Mutation actions mirror window state
203
+ // through the `maintenance` entity handle (created in init) rather
204
+ // than emitting the removed hooks.
205
+ const automationActions = env.getExtensionPoint(
206
+ automationActionExtensionPoint,
207
+ );
208
+ for (const action of createMaintenanceActions({
209
+ service: maintenanceService,
210
+ entityHandle: maintenanceEntityHandle,
211
+ })) {
212
+ automationActions.registerAction(action, pluginMetadata);
213
+ }
214
+
176
215
  // Notification subscription specs. Per-resource group lifecycle
177
216
  // is platform-managed by notification-backend — maintenance just
178
217
  // declares the specs.
@@ -264,6 +303,3 @@ export default createBackendPlugin({
264
303
  });
265
304
  },
266
305
  });
267
-
268
- // Re-export hooks for other plugins to use
269
- export { maintenanceHooks } from "./hooks";
package/src/router.ts CHANGED
@@ -7,17 +7,24 @@ import {
7
7
  autoAuthMiddleware,
8
8
  correlationMiddleware,
9
9
  Logger,
10
+ resolveActor,
10
11
  type RpcContext,
11
12
  } from "@checkstack/backend-api";
13
+ import type { EntityHandle } from "@checkstack/automation-backend";
12
14
  import type { SignalService } from "@checkstack/signal-common";
13
15
  import type { MaintenanceService } from "./service";
14
16
  import { CatalogApi } from "@checkstack/catalog-common";
15
17
  import { AuthApi } from "@checkstack/auth-common";
16
18
  import type { InferClient } from "@checkstack/common";
17
- import { maintenanceHooks } from "./hooks";
18
19
  import { notifyAffectedSystems } from "./notifications";
19
20
  import type { MaintenanceUpdate } from "@checkstack/maintenance-common";
20
21
  import type { MaintenanceCache } from "./cache";
22
+ import {
23
+ removeMaintenanceEntity,
24
+ toMaintenanceEntityState,
25
+ writeMaintenanceEntity,
26
+ type MaintenanceEntityState,
27
+ } from "./entity";
21
28
 
22
29
  export function createRouter(
23
30
  service: MaintenanceService,
@@ -29,6 +36,15 @@ export function createRouter(
29
36
  authClient: InferClient<typeof AuthApi>,
30
37
  logger: Logger,
31
38
  cache: MaintenanceCache,
39
+ /**
40
+ * Reactive `maintenance` entity handle (reactive automation engine §10.2).
41
+ * PLUGIN-BACKED (Model B): the `maintenances` + `maintenance_systems` tables
42
+ * ARE the current-state storage. Mutation sites drive the REAL write through
43
+ * `handle.mutate` / `handle.remove` (the write runs inside `apply`); the
44
+ * change-deriver re-emits the `maintenance.created` / `maintenance.updated`
45
+ * trigger events that automations match.
46
+ */
47
+ entityHandle: EntityHandle<MaintenanceEntityState>,
32
48
  ) {
33
49
  /**
34
50
  * Resolve user IDs to profile names for a list of updates.
@@ -144,7 +160,23 @@ export function createRouter(
144
160
 
145
161
  createMaintenance: os.createMaintenance.handler(
146
162
  async ({ input, context }) => {
147
- const result = await service.createMaintenance(input);
163
+ // Drive the create through the reactive `maintenance` entity (§10.2):
164
+ // `apply` performs the REAL `maintenances`/junction write (the plugin's
165
+ // own db/tx) and returns the new reactive state; the deriver fires
166
+ // `maintenance.created` from the resulting change. The id is generated
167
+ // up front so the handle is keyed on it and the create's `prev`
168
+ // snapshot correctly reads the not-yet-existing row as absent.
169
+ const maintenanceId = crypto.randomUUID();
170
+ let result!: Awaited<ReturnType<typeof service.createMaintenance>>;
171
+ await writeMaintenanceEntity({
172
+ handle: entityHandle,
173
+ maintenanceId,
174
+ opts: { actor: resolveActor(context.user) },
175
+ apply: async () => {
176
+ result = await service.createMaintenance(input, maintenanceId);
177
+ return toMaintenanceEntityState(result);
178
+ },
179
+ });
148
180
 
149
181
  // Invalidate before signal so any frontend that refetches in
150
182
  // response sees fresh data. Mutation invariant in this file:
@@ -161,17 +193,6 @@ export function createRouter(
161
193
  action: "created",
162
194
  });
163
195
 
164
- // Emit hook for cross-plugin coordination and integrations
165
- await context.emitHook(maintenanceHooks.maintenanceCreated, {
166
- maintenanceId: result.id,
167
- systemIds: result.systemIds,
168
- title: result.title,
169
- description: result.description,
170
- status: result.status,
171
- startAt: result.startAt.toISOString(),
172
- endAt: result.endAt.toISOString(),
173
- });
174
-
175
196
  // Send notifications to system subscribers
176
197
  const systemNames = await resolveSystemNames(result.systemIds);
177
198
  await notifyAffectedSystems({
@@ -191,13 +212,38 @@ export function createRouter(
191
212
 
192
213
  updateMaintenance: os.updateMaintenance.handler(
193
214
  async ({ input, context }) => {
194
- const result = await service.updateMaintenance(input);
195
- if (!result) {
215
+ // Probe existence first so a missing maintenance still surfaces as
216
+ // NOT_FOUND without driving an entity write.
217
+ const exists = await service.getMaintenance(input.id);
218
+ if (!exists) {
196
219
  throw new ORPCError("NOT_FOUND", {
197
220
  message: "Maintenance not found",
198
221
  });
199
222
  }
200
223
 
224
+ // Drive the update through the reactive `maintenance` entity (§10.2);
225
+ // `apply` performs the REAL update (the plugin's own db/tx) and returns
226
+ // the new reactive state. The deriver fires `maintenance.updated` from
227
+ // the resulting change.
228
+ let result!: NonNullable<
229
+ Awaited<ReturnType<typeof service.updateMaintenance>>
230
+ >;
231
+ await writeMaintenanceEntity({
232
+ handle: entityHandle,
233
+ maintenanceId: input.id,
234
+ opts: { actor: resolveActor(context.user) },
235
+ apply: async () => {
236
+ const updated = await service.updateMaintenance(input);
237
+ if (!updated) {
238
+ throw new ORPCError("NOT_FOUND", {
239
+ message: "Maintenance not found",
240
+ });
241
+ }
242
+ result = updated;
243
+ return toMaintenanceEntityState(result);
244
+ },
245
+ });
246
+
201
247
  await cache.invalidateForMutation({
202
248
  maintenanceId: result.id,
203
249
  systemIds: result.systemIds,
@@ -210,18 +256,6 @@ export function createRouter(
210
256
  action: "updated",
211
257
  });
212
258
 
213
- // Emit hook for cross-plugin coordination and integrations
214
- await context.emitHook(maintenanceHooks.maintenanceUpdated, {
215
- maintenanceId: result.id,
216
- systemIds: result.systemIds,
217
- title: result.title,
218
- description: result.description,
219
- status: result.status,
220
- startAt: result.startAt.toISOString(),
221
- endAt: result.endAt.toISOString(),
222
- action: "updated",
223
- });
224
-
225
259
  return result;
226
260
  },
227
261
  ),
@@ -236,10 +270,31 @@ export function createRouter(
236
270
  : undefined;
237
271
  const previousStatus = previousMaintenance?.status;
238
272
 
239
- const result = await service.addUpdate(input, userId);
240
- // Read post-write state directly from the service so the broadcast
241
- // payload is fresh; the cache is invalidated below before the signal.
242
- const maintenance = await service.getMaintenance(input.maintenanceId);
273
+ // Drive the update through the reactive `maintenance` entity (§10.2).
274
+ // `apply` posts the update row + (optionally) flips status in the
275
+ // plugin's own db/tx, then re-reads the post-write reactive state. The
276
+ // deriver fires `maintenance.updated` purely from the entity diff; when
277
+ // the status/window is unchanged, the diff is empty and no event fires.
278
+ let result!: Awaited<ReturnType<typeof service.addUpdate>>;
279
+ let maintenance: Awaited<ReturnType<typeof service.getMaintenance>>;
280
+ await writeMaintenanceEntity({
281
+ handle: entityHandle,
282
+ maintenanceId: input.maintenanceId,
283
+ opts: { actor: resolveActor(context.user) },
284
+ apply: async () => {
285
+ result = await service.addUpdate(input, userId);
286
+ maintenance = await service.getMaintenance(input.maintenanceId);
287
+ // The maintenance must exist (the update FK-references it); guard for
288
+ // the type and to fail loudly if it vanished mid-write.
289
+ if (!maintenance) {
290
+ throw new ORPCError("NOT_FOUND", {
291
+ message: "Maintenance not found",
292
+ });
293
+ }
294
+ return toMaintenanceEntityState(maintenance);
295
+ },
296
+ });
297
+
243
298
  if (maintenance) {
244
299
  await cache.invalidateForMutation({
245
300
  maintenanceId: input.maintenanceId,
@@ -256,18 +311,6 @@ export function createRouter(
256
311
  action,
257
312
  });
258
313
 
259
- // Emit hook for cross-plugin coordination and integrations
260
- await context.emitHook(maintenanceHooks.maintenanceUpdated, {
261
- maintenanceId: input.maintenanceId,
262
- systemIds: maintenance.systemIds,
263
- title: maintenance.title,
264
- description: maintenance.description,
265
- status: maintenance.status,
266
- startAt: maintenance.startAt.toISOString(),
267
- endAt: maintenance.endAt.toISOString(),
268
- action,
269
- });
270
-
271
314
  // Send notifications when status actually changes
272
315
  if (input.statusChange && previousStatus !== input.statusChange) {
273
316
  // Determine notification action based on the actual status transition
@@ -303,16 +346,43 @@ export function createRouter(
303
346
  async ({ input, context }) => {
304
347
  const userId =
305
348
  context.user && "id" in context.user ? context.user.id : undefined;
306
- const result = await service.closeMaintenance(
307
- input.id,
308
- input.message,
309
- userId,
310
- );
311
- if (!result) {
349
+
350
+ // Probe existence first so a missing maintenance still surfaces as
351
+ // NOT_FOUND without driving an entity write.
352
+ const exists = await service.getMaintenance(input.id);
353
+ if (!exists) {
312
354
  throw new ORPCError("NOT_FOUND", {
313
355
  message: "Maintenance not found",
314
356
  });
315
357
  }
358
+
359
+ // Drive the close through the reactive `maintenance` entity (§10.2);
360
+ // `apply` performs the REAL close (status → completed, the plugin's own
361
+ // db/tx) and returns the new reactive state. The deriver fires
362
+ // `maintenance.updated` from the status transition.
363
+ let result!: NonNullable<
364
+ Awaited<ReturnType<typeof service.closeMaintenance>>
365
+ >;
366
+ await writeMaintenanceEntity({
367
+ handle: entityHandle,
368
+ maintenanceId: input.id,
369
+ opts: { actor: resolveActor(context.user) },
370
+ apply: async () => {
371
+ const closed = await service.closeMaintenance(
372
+ input.id,
373
+ input.message,
374
+ userId,
375
+ );
376
+ if (!closed) {
377
+ throw new ORPCError("NOT_FOUND", {
378
+ message: "Maintenance not found",
379
+ });
380
+ }
381
+ result = closed;
382
+ return toMaintenanceEntityState(result);
383
+ },
384
+ });
385
+
316
386
  await cache.invalidateForMutation({
317
387
  maintenanceId: result.id,
318
388
  systemIds: result.systemIds,
@@ -324,18 +394,6 @@ export function createRouter(
324
394
  action: "closed",
325
395
  });
326
396
 
327
- // Emit hook for cross-plugin coordination and integrations
328
- await context.emitHook(maintenanceHooks.maintenanceUpdated, {
329
- maintenanceId: result.id,
330
- systemIds: result.systemIds,
331
- title: result.title,
332
- description: result.description,
333
- status: result.status,
334
- startAt: result.startAt.toISOString(),
335
- endAt: result.endAt.toISOString(),
336
- action: "closed",
337
- });
338
-
339
397
  // Send notifications to system subscribers
340
398
  const systemNames = await resolveSystemNames(result.systemIds);
341
399
  await notifyAffectedSystems({
@@ -353,10 +411,25 @@ export function createRouter(
353
411
  },
354
412
  ),
355
413
 
356
- deleteMaintenance: os.deleteMaintenance.handler(async ({ input }) => {
414
+ deleteMaintenance: os.deleteMaintenance.handler(async ({ input, context }) => {
357
415
  // Get maintenance before deleting to get systemIds
358
416
  const maintenance = await service.getMaintenance(input.id);
359
- const success = await service.deleteMaintenance(input.id);
417
+
418
+ // Drive the delete through the reactive `maintenance` entity tombstone
419
+ // (§10.2). `apply` performs the REAL delete (the plugin's own db/tx);
420
+ // the framework records the tombstone transition and emits a tombstone
421
+ // change. The deriver fires nothing, matching the historical behaviour
422
+ // where delete emitted no maintenance hook.
423
+ let success = false;
424
+ await removeMaintenanceEntity({
425
+ handle: entityHandle,
426
+ maintenanceId: input.id,
427
+ opts: { actor: resolveActor(context.user) },
428
+ apply: async () => {
429
+ success = await service.deleteMaintenance(input.id);
430
+ },
431
+ });
432
+
360
433
  if (success && maintenance) {
361
434
  await cache.invalidateForMutation({
362
435
  maintenanceId: input.id,
@@ -380,6 +453,11 @@ export function createRouter(
380
453
  return { suppressed };
381
454
  }),
382
455
 
456
+ hasActiveMaintenance: os.hasActiveMaintenance.handler(async ({ input }) => {
457
+ const active = await service.hasActiveMaintenance(input.systemId);
458
+ return { active };
459
+ }),
460
+
383
461
  addLink: os.addLink.handler(async ({ input }) => {
384
462
  const maintenance = await service.getMaintenance(input.maintenanceId);
385
463
  if (!maintenance) {
package/src/service.ts CHANGED
@@ -174,13 +174,89 @@ export class MaintenanceService {
174
174
  }
175
175
 
176
176
  /**
177
- * Create a new maintenance
177
+ * Batched reactive-state read for the `maintenance` entity (Model B
178
+ * plugin-backed `read` accessor). Given maintenance ids, return the
179
+ * reactive subset `{ status, systemIds, startAt, endAt }` for each that
180
+ * exists (missing ids omitted). Reads the AUTHORITATIVE `maintenances` +
181
+ * `maintenance_systems` tables — no framework `entity_state` storage. This
182
+ * is the single source of truth `handle.mutate` snapshots `prev` from and
183
+ * `get`/`getMany`/scope enrichment route through. `startAt`/`endAt` are
184
+ * serialized to ISO strings to match the entity state schema.
185
+ */
186
+ async getManyEntityStates(
187
+ ids: ReadonlyArray<string>,
188
+ ): Promise<
189
+ Record<
190
+ string,
191
+ {
192
+ status: MaintenanceStatus;
193
+ systemIds: string[];
194
+ startAt: string;
195
+ endAt: string;
196
+ }
197
+ >
198
+ > {
199
+ if (ids.length === 0) return {};
200
+
201
+ const rows = await this.db
202
+ .select({
203
+ id: maintenances.id,
204
+ status: maintenances.status,
205
+ startAt: maintenances.startAt,
206
+ endAt: maintenances.endAt,
207
+ })
208
+ .from(maintenances)
209
+ .where(inArray(maintenances.id, [...ids]));
210
+ if (rows.length === 0) return {};
211
+
212
+ const presentIds = rows.map((r) => r.id);
213
+ const systemRows = await this.db
214
+ .select({
215
+ maintenanceId: maintenanceSystems.maintenanceId,
216
+ systemId: maintenanceSystems.systemId,
217
+ })
218
+ .from(maintenanceSystems)
219
+ .where(inArray(maintenanceSystems.maintenanceId, presentIds));
220
+
221
+ const systemsByMaintenance = new Map<string, string[]>();
222
+ for (const r of systemRows) {
223
+ const list = systemsByMaintenance.get(r.maintenanceId);
224
+ if (list) list.push(r.systemId);
225
+ else systemsByMaintenance.set(r.maintenanceId, [r.systemId]);
226
+ }
227
+
228
+ const out: Record<
229
+ string,
230
+ {
231
+ status: MaintenanceStatus;
232
+ systemIds: string[];
233
+ startAt: string;
234
+ endAt: string;
235
+ }
236
+ > = {};
237
+ for (const row of rows) {
238
+ out[row.id] = {
239
+ status: row.status,
240
+ systemIds: systemsByMaintenance.get(row.id) ?? [],
241
+ startAt: row.startAt.toISOString(),
242
+ endAt: row.endAt.toISOString(),
243
+ };
244
+ }
245
+ return out;
246
+ }
247
+
248
+ /**
249
+ * Create a new maintenance.
250
+ *
251
+ * `id` may be supplied by the caller so the reactive `maintenance` entity
252
+ * can be keyed on a known id BEFORE the insert runs (the create's `prev`
253
+ * snapshot must read the not-yet-existing row as absent — see §10.2). When
254
+ * omitted, a fresh id is generated. The id is server-owned either way.
178
255
  */
179
256
  async createMaintenance(
180
257
  input: CreateMaintenanceInput,
258
+ id: string = generateId(),
181
259
  ): Promise<MaintenanceWithSystems> {
182
- const id = generateId();
183
-
184
260
  await this.db.insert(maintenances).values({
185
261
  id,
186
262
  title: input.title,
@@ -409,6 +485,39 @@ export class MaintenanceService {
409
485
  return !!match;
410
486
  }
411
487
 
488
+ /**
489
+ * Check if a system currently has an active maintenance window,
490
+ * regardless of whether notification suppression is enabled. A
491
+ * maintenance is "active" when its status is "in_progress".
492
+ *
493
+ * Unlike {@link hasActiveMaintenanceWithSuppression}, this is
494
+ * suppression-agnostic: it answers "is this system in a maintenance
495
+ * window right now?" so automations can gate on maintenance state
496
+ * without being coupled to the notification-suppression flag.
497
+ */
498
+ async hasActiveMaintenance(systemId: string): Promise<boolean> {
499
+ const systemMaintenances = await this.db
500
+ .select({ maintenanceId: maintenanceSystems.maintenanceId })
501
+ .from(maintenanceSystems)
502
+ .where(eq(maintenanceSystems.systemId, systemId));
503
+
504
+ const ids = systemMaintenances.map((r) => r.maintenanceId);
505
+ if (ids.length === 0) return false;
506
+
507
+ const [match] = await this.db
508
+ .select({ id: maintenances.id })
509
+ .from(maintenances)
510
+ .where(
511
+ and(
512
+ inArray(maintenances.id, ids),
513
+ eq(maintenances.status, "in_progress"),
514
+ ),
515
+ )
516
+ .limit(1);
517
+
518
+ return !!match;
519
+ }
520
+
412
521
  /**
413
522
  * Get maintenances that should transition from 'scheduled' to 'in_progress'.
414
523
  * These are maintenances where status = 'scheduled' AND startAt <= now.
package/tsconfig.json CHANGED
@@ -7,6 +7,12 @@
7
7
  {
8
8
  "path": "../auth-common"
9
9
  },
10
+ {
11
+ "path": "../automation-backend"
12
+ },
13
+ {
14
+ "path": "../automation-common"
15
+ },
10
16
  {
11
17
  "path": "../backend-api"
12
18
  },
@@ -31,12 +37,6 @@
31
37
  {
32
38
  "path": "../drizzle-helper"
33
39
  },
34
- {
35
- "path": "../integration-backend"
36
- },
37
- {
38
- "path": "../integration-common"
39
- },
40
40
  {
41
41
  "path": "../maintenance-common"
42
42
  },