@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 +93 -0
- package/package.json +16 -15
- package/src/automations.test.ts +120 -116
- package/src/automations.ts +212 -115
- 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 +65 -9
- package/src/router.ts +141 -63
- package/src/service.ts +112 -3
- package/tsconfig.json +3 -0
- package/src/hooks.ts +0 -42
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.
|
|
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.
|
|
19
|
-
"@checkstack/cache-api": "0.3.
|
|
20
|
-
"@checkstack/cache-utils": "0.2.
|
|
21
|
-
"@checkstack/maintenance-common": "1.2.
|
|
22
|
-
"@checkstack/notification-common": "1.2.
|
|
23
|
-
"@checkstack/catalog-common": "2.2.
|
|
24
|
-
"@checkstack/catalog-backend": "1.
|
|
25
|
-
"@checkstack/auth-common": "0.7.
|
|
26
|
-
"@checkstack/command-backend": "0.1.
|
|
27
|
-
"@checkstack/signal-common": "0.2.
|
|
28
|
-
"@checkstack/automation-backend": "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.
|
|
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.
|
|
37
|
-
"@checkstack/test-utils-backend": "0.1.
|
|
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",
|
package/src/automations.test.ts
CHANGED
|
@@ -1,18 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Behaviour tests for the maintenance automation
|
|
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 {
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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(
|
|
188
|
-
|
|
189
|
-
|
|
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
|
|
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(
|
|
227
|
+
expect(entityHandle.mutates).toHaveLength(0);
|
|
212
228
|
});
|
|
213
229
|
|
|
214
|
-
it("
|
|
215
|
-
const service = makeService({
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
|
|
230
|
-
expect(
|
|
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("
|
|
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
|
|
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(
|
|
258
|
-
|
|
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
|
|
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(
|
|
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
|
|
295
|
+
const entityHandle = makeEntityHandle();
|
|
287
296
|
const fixedNow = new Date("2026-05-29T11:00:00Z");
|
|
288
297
|
const [, , , setSystem] = createMaintenanceActions({
|
|
289
298
|
service,
|
|
290
|
-
|
|
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 +
|
|
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
|
|
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(
|
|
341
|
-
for (const
|
|
342
|
-
expect(
|
|
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
|
|
354
|
+
it("succeeds and mirrors nothing when no windows are active for the system", async () => {
|
|
348
355
|
const service = makeService({ activeForSystem: [] });
|
|
349
|
-
const
|
|
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(
|
|
370
|
+
expect(entityHandle.mutates).toHaveLength(0);
|
|
367
371
|
});
|
|
368
372
|
});
|