@checkstack/maintenance-backend 1.2.0 → 1.3.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 CHANGED
@@ -1,5 +1,98 @@
1
1
  # @checkstack/maintenance-backend
2
2
 
3
+ ## 1.3.1
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [a57f7db]
8
+ - @checkstack/backend-api@0.20.0
9
+ - @checkstack/automation-backend@0.4.0
10
+ - @checkstack/cache-api@0.3.8
11
+ - @checkstack/catalog-backend@1.3.1
12
+ - @checkstack/command-backend@0.1.33
13
+ - @checkstack/cache-utils@0.2.13
14
+
15
+ ## 1.3.0
16
+
17
+ ### Minor Changes
18
+
19
+ - 270ef29: Add the health-state provider data contract (automation sensing layer, Wave 2 Phase 13).
20
+
21
+ - New `health_check_state_transitions` table records every aggregate health-status transition for a system (all statuses, not just unhealthy), giving a reliable "in current status since" timestamp. Written wherever an aggregate transition is detected. Pruned with raw-run retention, but the single most-recent row per system is always kept so an active streak never blanks.
22
+ - New service-typed RPCs on `HealthCheckApi`: `getHealthState({ systemId, configurationId? })` returns `{ status, inStatusSince, inStatusForMs, latencyMs?, avgLatencyMs?, p95LatencyMs?, successRate?, lastRunAt?, inMaintenance, evaluatedAt }`, and `getBulkHealthState({ systemIds })` (POST) resolves many systems against one shared timestamp.
23
+ - New service-typed RPC on `MaintenanceApi`: `hasActiveMaintenance({ systemId })` reports whether a system is in an active maintenance window regardless of notification-suppression (suppression-agnostic), folded into `getHealthState` as `inMaintenance`.
24
+
25
+ All reads are fail-safe: a missing transition row yields `inStatusSince: null`, and a maintenance-plugin error fails open to `inMaintenance: false`.
26
+
27
+ - b995afb: Make `maintenance` a plugin-backed reactive entity via the Model-B entity state machine.
28
+
29
+ Maintenance defines a `maintenance` entity `{ status, systemIds, startAt, endAt }`. The `maintenances` + `maintenance_systems` tables are BOTH authoritative AND the entity's current-state storage - there is no framework `entity_state` mirror. `defineEntity` is given a plugin `read` accessor (`MaintenanceService.getManyEntityStates`, projecting the reactive subset with ISO-serialized timestamps straight off those tables), and every create / update / add-update / close / delete site (plus the automation actions) drives the REAL service write through `handle.mutate` / `handle.remove` via the `writeMaintenanceEntity` / `removeMaintenanceEntity` helpers: `apply` runs the write in the plugin's own transaction and returns the new state; the framework snapshots `prev` via `read` BEFORE the write, appends the transition log, and emits `ENTITY_CHANGED` AFTER the write commits. `MaintenanceService.createMaintenance` accepts an optional pre-generated `id` so a create is keyed on a known id and its `prev` snapshot reads the not-yet-existing row as absent.
30
+
31
+ A registered change-deriver maps `maintenance` entity changes back to the `maintenance.created` / `maintenance.updated` trigger events, so existing automations keep firing via the reactive Stage-1/Stage-2 dispatch pipeline. The old `maintenance.created` / `maintenance.updated` change hooks and their hook-backed triggers are removed in favor of the reactive entity.
32
+
33
+ BREAKING CHANGES:
34
+
35
+ - Removed the `maintenance.created` and `maintenance.updated` hooks (`createHook`) and their re-export from the plugin entry point. Use the `maintenance` entity's auto-emitted change events (subscribe via the `automation.entity` extension point's `onEntityChanged`, or author automations against the derived `maintenance.created` / `maintenance.updated` trigger events).
36
+ - The `created` / `updated` automation triggers are now ENTITY-DRIVEN instead of hook-backed: they are fired by the `maintenance` entity change-deriver (Stage-1 routing) rather than a `createHook`, but stay REGISTERED in the automation editor's trigger catalog (a no-op `setup` via `makeEntityDrivenTriggerSetup`), so they remain offered as picker entries and payload-introspectable. Already-authored automations referencing `maintenance.created` / `maintenance.updated` continue to fire. A registered `toPayload` mapper keeps the runtime `trigger.payload` matching each trigger's declared `payloadSchema` (`maintenanceId`, `status`, `systemIds`, `startAt`, `endAt`). The descriptive fields the old hook carried (`title`, `description`, the `updated`/`closed` `action` discriminator) are NOT part of the reactive entity state, so they are no longer present on the payload.
37
+ - NARROWING: `maintenance.updated` now fires only on a change to the REACTIVE state (`status`, `startAt` / `endAt` window, or affected `systemIds`). A title / description / message-only edit no longer fires `maintenance.updated` (those fields are not reactive entity state). Re-author any automation that needed to react to a metadata-only maintenance edit against a different signal.
38
+
39
+ ### Patch Changes
40
+
41
+ - b995afb: Extract a shared `withEntityWrite` / `withEntityRemove` guard for PLUGIN-BACKED (Model B) reactive entities and refactor the per-domain copies onto it.
42
+
43
+ Every plugin-backed domain (incident, catalog, dependency, maintenance, slo, satellite) reimplemented the same "no handle wired → run the plugin write directly; handle wired → route through `handle.mutate` / `handle.remove`" guard, varying only in the id-key name. `@checkstack/automation-backend` now exports `withEntityWrite` / `withEntityRemove` (from the entity barrel) and each domain's thin, well-named wrappers (`writeIncidentEntity`, `writeMaintenanceEntity`, satellite's `mirror`, …) delegate to it, so the branch lives in exactly one place. Behavior is unchanged.
44
+
45
+ `writeHealthEntity` (healthcheck-backend) is intentionally NOT migrated onto the helper — it is genuinely bespoke (closure-captured durable state, distinct rethrow-vs-fail-soft branches, a per-system serializer, and it returns the computed state). SLO keeps its fail-soft `onError` wrapper around the shared guard.
46
+
47
+ - Updated dependencies [270ef29]
48
+ - Updated dependencies [b995afb]
49
+ - Updated dependencies [b995afb]
50
+ - Updated dependencies [b995afb]
51
+ - Updated dependencies [270ef29]
52
+ - Updated dependencies [270ef29]
53
+ - Updated dependencies [270ef29]
54
+ - Updated dependencies [270ef29]
55
+ - Updated dependencies [270ef29]
56
+ - Updated dependencies [270ef29]
57
+ - Updated dependencies [270ef29]
58
+ - Updated dependencies [270ef29]
59
+ - Updated dependencies [270ef29]
60
+ - Updated dependencies [b995afb]
61
+ - Updated dependencies [b995afb]
62
+ - Updated dependencies [b995afb]
63
+ - Updated dependencies [b995afb]
64
+ - Updated dependencies [270ef29]
65
+ - Updated dependencies [b995afb]
66
+ - Updated dependencies [270ef29]
67
+ - Updated dependencies [b995afb]
68
+ - Updated dependencies [b995afb]
69
+ - Updated dependencies [270ef29]
70
+ - Updated dependencies [b995afb]
71
+ - Updated dependencies [b995afb]
72
+ - Updated dependencies [b995afb]
73
+ - Updated dependencies [b995afb]
74
+ - Updated dependencies [b995afb]
75
+ - Updated dependencies [b995afb]
76
+ - Updated dependencies [b995afb]
77
+ - Updated dependencies [270ef29]
78
+ - Updated dependencies [270ef29]
79
+ - Updated dependencies [270ef29]
80
+ - Updated dependencies [270ef29]
81
+ - Updated dependencies [270ef29]
82
+ - Updated dependencies [270ef29]
83
+ - Updated dependencies [270ef29]
84
+ - Updated dependencies [270ef29]
85
+ - Updated dependencies [b995afb]
86
+ - Updated dependencies [b995afb]
87
+ - @checkstack/backend-api@0.19.0
88
+ - @checkstack/automation-backend@0.3.0
89
+ - @checkstack/automation-common@0.3.0
90
+ - @checkstack/maintenance-common@1.3.0
91
+ - @checkstack/catalog-backend@1.3.0
92
+ - @checkstack/cache-api@0.3.7
93
+ - @checkstack/command-backend@0.1.32
94
+ - @checkstack/cache-utils@0.2.12
95
+
3
96
  ## 1.2.0
4
97
 
5
98
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/maintenance-backend",
3
- "version": "1.2.0",
3
+ "version": "1.3.1",
4
4
  "license": "Elastic-2.0",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -15,26 +15,27 @@
15
15
  "test": "bun test"
16
16
  },
17
17
  "dependencies": {
18
- "@checkstack/backend-api": "0.17.1",
19
- "@checkstack/cache-api": "0.3.5",
20
- "@checkstack/cache-utils": "0.2.10",
21
- "@checkstack/maintenance-common": "1.2.2",
22
- "@checkstack/notification-common": "1.2.0",
23
- "@checkstack/catalog-common": "2.2.2",
24
- "@checkstack/catalog-backend": "1.1.6",
25
- "@checkstack/auth-common": "0.7.1",
26
- "@checkstack/command-backend": "0.1.30",
27
- "@checkstack/signal-common": "0.2.4",
28
- "@checkstack/automation-backend": "0.1.0",
18
+ "@checkstack/backend-api": "0.18.0",
19
+ "@checkstack/cache-api": "0.3.6",
20
+ "@checkstack/cache-utils": "0.2.11",
21
+ "@checkstack/maintenance-common": "1.2.3",
22
+ "@checkstack/notification-common": "1.2.1",
23
+ "@checkstack/catalog-common": "2.2.3",
24
+ "@checkstack/catalog-backend": "1.2.0",
25
+ "@checkstack/auth-common": "0.7.2",
26
+ "@checkstack/command-backend": "0.1.31",
27
+ "@checkstack/signal-common": "0.2.5",
28
+ "@checkstack/automation-backend": "0.2.0",
29
+ "@checkstack/automation-common": "0.2.0",
29
30
  "drizzle-orm": "^0.45.0",
30
31
  "zod": "^4.2.1",
31
- "@checkstack/common": "0.11.0",
32
+ "@checkstack/common": "0.12.0",
32
33
  "@orpc/server": "^1.13.2"
33
34
  },
34
35
  "devDependencies": {
35
36
  "@checkstack/drizzle-helper": "0.0.5",
36
- "@checkstack/scripts": "0.3.3",
37
- "@checkstack/test-utils-backend": "0.1.30",
37
+ "@checkstack/scripts": "0.3.4",
38
+ "@checkstack/test-utils-backend": "0.1.31",
38
39
  "@checkstack/tsconfig": "0.0.7",
39
40
  "@types/bun": "^1.0.0",
40
41
  "drizzle-kit": "^0.31.10",
@@ -1,18 +1,25 @@
1
1
  /**
2
- * Behaviour tests for the maintenance automation triggers + actions.
2
+ * Behaviour tests for the maintenance automation actions.
3
+ *
4
+ * The maintenance domain is now a Model-B PLUGIN-BACKED entity (reactive
5
+ * automation engine §10.2): the `maintenances` table IS the current-state
6
+ * storage. Mutation actions drive the REAL write through `handle.mutate`
7
+ * (the write runs inside `apply`); these tests assert the mutate is keyed by
8
+ * maintenance id and `apply` returns the §10.2 entity shape.
3
9
  */
4
10
  import { describe, expect, it, mock } from "bun:test";
5
11
  import type { Logger } from "@checkstack/backend-api";
12
+ import type {
13
+ EntityHandle,
14
+ EntityMutationOpts,
15
+ } from "@checkstack/automation-backend";
6
16
  import { createMockLogger } from "@checkstack/test-utils-backend";
7
17
 
8
18
  import {
9
19
  createMaintenanceActions,
10
20
  maintenanceArtifactType,
11
- maintenanceCreatedTrigger,
12
- maintenanceTriggers,
13
- maintenanceUpdatedTrigger,
14
21
  } from "./automations";
15
- import { maintenanceHooks } from "./hooks";
22
+ import type { MaintenanceEntityState } from "./entity";
16
23
  import type { MaintenanceService } from "./service";
17
24
 
18
25
  const logger = createMockLogger() as Logger;
@@ -27,56 +34,55 @@ const ctxBase = {
27
34
  },
28
35
  };
29
36
 
30
- // ─── Triggers ──────────────────────────────────────────────────────────
31
-
32
- describe("maintenance triggers", () => {
33
- it("exposes two triggers in a stable order", () => {
34
- expect(maintenanceTriggers).toHaveLength(2);
35
- expect(maintenanceTriggers[0]).toBe(
36
- maintenanceCreatedTrigger as (typeof maintenanceTriggers)[number],
37
- );
38
- expect(maintenanceTriggers[1]).toBe(
39
- maintenanceUpdatedTrigger as (typeof maintenanceTriggers)[number],
40
- );
41
- });
42
-
43
- it("extracts maintenanceId as the contextKey on both triggers", () => {
44
- const payload = {
45
- maintenanceId: "m-1",
46
- systemIds: ["sys-1"],
47
- title: "Deploy",
48
- status: "scheduled" as const,
49
- startAt: "2026-05-29T11:00:00Z",
50
- endAt: "2026-05-29T12:00:00Z",
51
- };
52
- expect(maintenanceCreatedTrigger.contextKey?.(payload)).toBe("m-1");
53
- expect(
54
- maintenanceUpdatedTrigger.contextKey?.({ ...payload, action: "updated" }),
55
- ).toBe("m-1");
56
- });
37
+ interface RecordedMutate {
38
+ id: string;
39
+ next: MaintenanceEntityState;
40
+ opts?: EntityMutationOpts;
41
+ }
57
42
 
58
- it("requires action enum on updated payload", () => {
59
- const ok = maintenanceUpdatedTrigger.payloadSchema.safeParse({
60
- maintenanceId: "m-1",
61
- systemIds: [],
62
- title: "Deploy",
63
- status: "completed",
64
- startAt: "2026-05-29T11:00:00Z",
65
- endAt: "2026-05-29T12:00:00Z",
66
- action: "closed",
67
- });
68
- const bad = maintenanceUpdatedTrigger.payloadSchema.safeParse({
69
- maintenanceId: "m-1",
70
- systemIds: [],
71
- title: "Deploy",
72
- status: "completed",
73
- startAt: "2026-05-29T11:00:00Z",
74
- endAt: "2026-05-29T12:00:00Z",
75
- });
76
- expect(ok.success).toBe(true);
77
- expect(bad.success).toBe(false);
78
- });
79
- });
43
+ /**
44
+ * A fake PLUGIN-BACKED entity handle that records `mutate` / `remove` calls
45
+ * for assertion. `mutate` runs the driven `apply()` (the action's real write)
46
+ * and captures the returned next-state, mirroring how the framework drives a
47
+ * plugin-backed write.
48
+ */
49
+ function makeEntityHandle(): EntityHandle<MaintenanceEntityState> & {
50
+ mutates: RecordedMutate[];
51
+ removes: string[];
52
+ } {
53
+ const mutates: RecordedMutate[] = [];
54
+ const removes: string[] = [];
55
+ return {
56
+ kind: "maintenance",
57
+ mutates,
58
+ removes,
59
+ async mutate(input) {
60
+ const next = await input.apply();
61
+ mutates.push({ id: input.id, next, opts: input.opts });
62
+ return next;
63
+ },
64
+ async get() {
65
+ return undefined;
66
+ },
67
+ async getMany() {
68
+ return {};
69
+ },
70
+ async remove(input) {
71
+ // Plugin-backed remove takes `{ id, apply }`; run apply + record the id.
72
+ await input.apply();
73
+ removes.push(input.id);
74
+ },
75
+ async inStateSince() {
76
+ return null;
77
+ },
78
+ async inStateForMs() {
79
+ return 0;
80
+ },
81
+ async transitionCount() {
82
+ return 0;
83
+ },
84
+ };
85
+ }
80
86
 
81
87
  describe("maintenanceArtifactType", () => {
82
88
  it("validates the canonical artifact shape", () => {
@@ -117,7 +123,12 @@ function makeService(args: {
117
123
  activeMock: ReturnType<typeof mock>;
118
124
  closeMock: ReturnType<typeof mock>;
119
125
  } {
120
- const createMock = mock(async (_input: unknown) => args.rowToReturn);
126
+ // The real service inserts with the server-generated id passed as the 2nd
127
+ // arg and returns the row carrying THAT id. Echo it so the entity key (the
128
+ // generated id) and the returned `externalId` agree, like production.
129
+ const createMock = mock(async (_input: unknown, id?: string) =>
130
+ args.rowToReturn ? { ...args.rowToReturn, id: id ?? args.rowToReturn.id } : args.rowToReturn,
131
+ );
121
132
  const updateMock = mock(async (_input: unknown) => args.updateReturn);
122
133
  const addUpdateMock = mock(async (_input: unknown) => ({
123
134
  id: "upd-1",
@@ -163,13 +174,10 @@ const sampleRow: FakeMaintenance = {
163
174
  };
164
175
 
165
176
  describe("maintenance.create", () => {
166
- it("creates a maintenance, fires maintenanceCreated, and emits a maintenance.window artifact", async () => {
177
+ it("creates a maintenance, mirrors the entity state, and emits a maintenance.window artifact", async () => {
167
178
  const service = makeService({ rowToReturn: sampleRow });
168
- const emitHook = mock(async (_hook: unknown, _payload: unknown) => {});
169
- const [create] = createMaintenanceActions({
170
- service,
171
- emitHook: emitHook as never,
172
- });
179
+ const entityHandle = makeEntityHandle();
180
+ const [create] = createMaintenanceActions({ service, entityHandle });
173
181
 
174
182
  const result = await create!.execute({
175
183
  ...ctxBase,
@@ -184,20 +192,28 @@ describe("maintenance.create", () => {
184
192
 
185
193
  expect(result.success).toBe(true);
186
194
  if (!result.success) return;
187
- expect(result.externalId).toBe("m-1");
188
- expect(emitHook).toHaveBeenCalledTimes(1);
189
- expect(emitHook.mock.calls[0]![0]).toBe(maintenanceHooks.maintenanceCreated);
195
+ expect(entityHandle.mutates).toHaveLength(1);
196
+ // The entity is keyed on the server-generated id (the create's `prev`
197
+ // snapshot reads it as absent); the action's `externalId` echoes that
198
+ // same id, so the two MUST agree.
199
+ expect(typeof result.externalId).toBe("string");
200
+ expect(entityHandle.mutates[0]!.id).toBe(result.externalId!);
201
+ expect(entityHandle.mutates[0]!.next).toEqual({
202
+ status: "scheduled",
203
+ systemIds: ["sys-1"],
204
+ startAt: "2026-05-29T11:00:00.000Z",
205
+ endAt: "2026-05-29T12:00:00.000Z",
206
+ });
207
+ // Run-originated writes carry the runId (masking) + the run actor.
208
+ expect(entityHandle.mutates[0]!.opts?.runId).toBe("run-1");
190
209
  });
191
210
  });
192
211
 
193
212
  describe("maintenance.update", () => {
194
- it("returns failure when the maintenance doesn't exist", async () => {
213
+ it("returns failure and mirrors nothing when the maintenance doesn't exist", async () => {
195
214
  const service = makeService({ updateReturn: undefined });
196
- const emitHook = mock(async (_hook: unknown, _payload: unknown) => {});
197
- const [, update] = createMaintenanceActions({
198
- service,
199
- emitHook: emitHook as never,
200
- });
215
+ const entityHandle = makeEntityHandle();
216
+ const [, update] = createMaintenanceActions({ service, entityHandle });
201
217
 
202
218
  const result = await update!.execute({
203
219
  ...ctxBase,
@@ -208,16 +224,17 @@ describe("maintenance.update", () => {
208
224
  expect(result.success).toBe(false);
209
225
  if (result.success) return;
210
226
  expect(result.error).toMatch(/not found/i);
211
- expect(emitHook).not.toHaveBeenCalled();
227
+ expect(entityHandle.mutates).toHaveLength(0);
212
228
  });
213
229
 
214
- it("emits maintenanceUpdated with action='updated' on success", async () => {
215
- const service = makeService({ updateReturn: sampleRow });
216
- const emitHook = mock(async (_hook: unknown, _payload: unknown) => {});
217
- const [, update] = createMaintenanceActions({
218
- service,
219
- emitHook: emitHook as never,
230
+ it("mirrors the updated state on success", async () => {
231
+ const service = makeService({
232
+ // The action probes existence first, then updates inside `apply`.
233
+ getMaintenanceReturn: sampleRow,
234
+ updateReturn: { ...sampleRow, status: "in_progress" },
220
235
  });
236
+ const entityHandle = makeEntityHandle();
237
+ const [, update] = createMaintenanceActions({ service, entityHandle });
221
238
 
222
239
  const result = await update!.execute({
223
240
  ...ctxBase,
@@ -226,22 +243,18 @@ describe("maintenance.update", () => {
226
243
  });
227
244
 
228
245
  expect(result.success).toBe(true);
229
- const emitCall = emitHook.mock.calls[0]!;
230
- expect(emitCall[0]).toBe(maintenanceHooks.maintenanceUpdated);
231
- expect((emitCall[1] as { action: string }).action).toBe("updated");
246
+ expect(entityHandle.mutates).toHaveLength(1);
247
+ expect(entityHandle.mutates[0]!.next.status).toBe("in_progress");
232
248
  });
233
249
  });
234
250
 
235
251
  describe("maintenance.add_update", () => {
236
- it("uses action='closed' when statusChange is 'completed'", async () => {
252
+ it("mirrors the refreshed state when statusChange is 'completed'", async () => {
237
253
  const service = makeService({
238
254
  getMaintenanceReturn: { ...sampleRow, status: "completed" },
239
255
  });
240
- const emitHook = mock(async (_hook: unknown, _payload: unknown) => {});
241
- const [, , addUpdate] = createMaintenanceActions({
242
- service,
243
- emitHook: emitHook as never,
244
- });
256
+ const entityHandle = makeEntityHandle();
257
+ const [, , addUpdate] = createMaintenanceActions({ service, entityHandle });
245
258
 
246
259
  const result = await addUpdate!.execute({
247
260
  ...ctxBase,
@@ -254,18 +267,14 @@ describe("maintenance.add_update", () => {
254
267
  });
255
268
 
256
269
  expect(result.success).toBe(true);
257
- expect((emitHook.mock.calls[0]![1] as { action: string }).action).toBe(
258
- "closed",
259
- );
270
+ expect(entityHandle.mutates).toHaveLength(1);
271
+ expect(entityHandle.mutates[0]!.next.status).toBe("completed");
260
272
  });
261
273
 
262
274
  it("returns failure when the maintenance vanishes between addUpdate and getMaintenance", async () => {
263
275
  const service = makeService({ getMaintenanceReturn: undefined });
264
- const emitHook = mock(async (_hook: unknown, _payload: unknown) => {});
265
- const [, , addUpdate] = createMaintenanceActions({
266
- service,
267
- emitHook: emitHook as never,
268
- });
276
+ const entityHandle = makeEntityHandle();
277
+ const [, , addUpdate] = createMaintenanceActions({ service, entityHandle });
269
278
 
270
279
  const result = await addUpdate!.execute({
271
280
  ...ctxBase,
@@ -276,18 +285,18 @@ describe("maintenance.add_update", () => {
276
285
  expect(result.success).toBe(false);
277
286
  if (result.success) return;
278
287
  expect(result.error).toMatch(/not found/i);
279
- expect(emitHook).not.toHaveBeenCalled();
288
+ expect(entityHandle.mutates).toHaveLength(0);
280
289
  });
281
290
  });
282
291
 
283
292
  describe("maintenance.set_system", () => {
284
- it("schedules a now+durationMinutes window covering one system", async () => {
293
+ it("schedules a now+durationMinutes window and mirrors it covering one system", async () => {
285
294
  const service = makeService({ rowToReturn: sampleRow });
286
- const emitHook = mock(async (_hook: unknown, _payload: unknown) => {});
295
+ const entityHandle = makeEntityHandle();
287
296
  const fixedNow = new Date("2026-05-29T11:00:00Z");
288
297
  const [, , , setSystem] = createMaintenanceActions({
289
298
  service,
290
- emitHook: emitHook as never,
299
+ entityHandle,
291
300
  now: () => fixedNow,
292
301
  });
293
302
 
@@ -309,12 +318,14 @@ describe("maintenance.set_system", () => {
309
318
  expect(call.systemIds).toEqual(["sys-1"]);
310
319
  expect(call.startAt.toISOString()).toBe("2026-05-29T11:00:00.000Z");
311
320
  expect(call.endAt.toISOString()).toBe("2026-05-29T12:00:00.000Z");
321
+ expect(entityHandle.mutates).toHaveLength(1);
322
+ expect(entityHandle.mutates[0]!.next.systemIds).toEqual(["sys-1"]);
312
323
  });
313
324
  });
314
325
 
315
326
  describe("maintenance.clear_system", () => {
316
- it("closes every active window for the system + emits one updated hook per close", async () => {
317
- const window1 = { ...sampleRow, id: "m-1" };
327
+ it("closes every active window for the system + mirrors one state per close", async () => {
328
+ const window1 = { ...sampleRow, id: "m-1", status: "completed" };
318
329
  const window2 = { ...sampleRow, id: "m-2" };
319
330
  const service = makeService({
320
331
  activeForSystem: [window1, window2],
@@ -322,11 +333,8 @@ describe("maintenance.clear_system", () => {
322
333
  });
323
334
  // closeMaintenance returns the same row both times in the fixture
324
335
  // — that's fine for this test; we only assert the count + ids.
325
- const emitHook = mock(async (_hook: unknown, _payload: unknown) => {});
326
- const actions = createMaintenanceActions({
327
- service,
328
- emitHook: emitHook as never,
329
- });
336
+ const entityHandle = makeEntityHandle();
337
+ const actions = createMaintenanceActions({ service, entityHandle });
330
338
  const clearSystem = actions[4]!;
331
339
 
332
340
  const result = await clearSystem.execute({
@@ -337,20 +345,16 @@ describe("maintenance.clear_system", () => {
337
345
 
338
346
  expect(result.success).toBe(true);
339
347
  expect(service.closeMock).toHaveBeenCalledTimes(2);
340
- expect(emitHook).toHaveBeenCalledTimes(2);
341
- for (const call of emitHook.mock.calls) {
342
- expect(call[0]).toBe(maintenanceHooks.maintenanceUpdated);
343
- expect((call[1] as { action: string }).action).toBe("closed");
348
+ expect(entityHandle.mutates).toHaveLength(2);
349
+ for (const recorded of entityHandle.mutates) {
350
+ expect(recorded.next.status).toBe("completed");
344
351
  }
345
352
  });
346
353
 
347
- it("succeeds and emits an empty artifact when no windows are active for the system", async () => {
354
+ it("succeeds and mirrors nothing when no windows are active for the system", async () => {
348
355
  const service = makeService({ activeForSystem: [] });
349
- const emitHook = mock(async (_hook: unknown, _payload: unknown) => {});
350
- const actions = createMaintenanceActions({
351
- service,
352
- emitHook: emitHook as never,
353
- });
356
+ const entityHandle = makeEntityHandle();
357
+ const actions = createMaintenanceActions({ service, entityHandle });
354
358
  const clearSystem = actions[4]!;
355
359
 
356
360
  const result = await clearSystem.execute({
@@ -363,6 +367,6 @@ describe("maintenance.clear_system", () => {
363
367
  if (!result.success) return;
364
368
  const artifact = result.artifact as { closedMaintenanceIds: string[] };
365
369
  expect(artifact.closedMaintenanceIds).toEqual([]);
366
- expect(emitHook).not.toHaveBeenCalled();
370
+ expect(entityHandle.mutates).toHaveLength(0);
367
371
  });
368
372
  });