@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/CHANGELOG.md +218 -0
- package/package.json +18 -17
- package/src/automations.test.ts +372 -0
- package/src/automations.ts +519 -0
- package/src/entity.test.ts +280 -0
- package/src/entity.ts +187 -0
- package/src/has-active-maintenance.test.ts +69 -0
- package/src/index.ts +90 -54
- package/src/router.ts +141 -63
- package/src/service.ts +112 -3
- package/tsconfig.json +6 -6
- package/src/hooks.ts +0 -37
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 {
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
//
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
90
|
-
{
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
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
|
-
|
|
195
|
-
|
|
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
|
-
|
|
240
|
-
//
|
|
241
|
-
//
|
|
242
|
-
|
|
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
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
},
|