@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.
@@ -0,0 +1,280 @@
1
+ /**
2
+ * Unit tests for the maintenance reactive-entity mapping (reactive
3
+ * automation engine §10.2). Proves the deriver returns the EXACT qualified
4
+ * trigger event ids existing automations match, the state builder produces
5
+ * the §10.2 entity shape, and the PLUGIN-BACKED `read`/`mutate`/`remove`
6
+ * helpers route to the service (no framework `entity_state`).
7
+ */
8
+ import { describe, expect, it } from "bun:test";
9
+ import { SYSTEM_ACTOR } from "@checkstack/common";
10
+ import type {
11
+ EntityChanged,
12
+ EntityHandle,
13
+ } from "@checkstack/automation-backend";
14
+
15
+ import {
16
+ MAINTENANCE_CREATED_EVENT,
17
+ MAINTENANCE_ENTITY_KIND,
18
+ MAINTENANCE_UPDATED_EVENT,
19
+ createMaintenanceEntityRead,
20
+ deriveMaintenanceEvents,
21
+ maintenanceChangeToPayload,
22
+ maintenanceEntityStateSchema,
23
+ removeMaintenanceEntity,
24
+ toMaintenanceEntityState,
25
+ writeMaintenanceEntity,
26
+ type MaintenanceEntityState,
27
+ } from "./entity";
28
+ import {
29
+ maintenanceCreatedTrigger,
30
+ maintenanceUpdatedTrigger,
31
+ } from "./automations";
32
+ import type { MaintenanceService } from "./service";
33
+
34
+ function makeChange(over: Partial<EntityChanged>): EntityChanged {
35
+ return {
36
+ kind: "maintenance",
37
+ id: "m-1",
38
+ prev: null,
39
+ next: { status: "scheduled", systemIds: [], startAt: "a", endAt: "b" },
40
+ delta: {},
41
+ changedFields: [],
42
+ actor: SYSTEM_ACTOR,
43
+ occurredAt: "2026-05-31T00:00:00.000Z",
44
+ ...over,
45
+ };
46
+ }
47
+
48
+ describe("maintenanceChangeToPayload — payloadSchema parity", () => {
49
+ it("a create payload validates against the created trigger's payloadSchema", () => {
50
+ const parsed = maintenanceCreatedTrigger.payloadSchema.parse(
51
+ maintenanceChangeToPayload(
52
+ makeChange({
53
+ prev: null,
54
+ next: {
55
+ status: "scheduled",
56
+ systemIds: ["sys-1"],
57
+ startAt: "2026-05-31T00:00:00.000Z",
58
+ endAt: "2026-05-31T01:00:00.000Z",
59
+ },
60
+ }),
61
+ ),
62
+ );
63
+ expect(parsed.maintenanceId).toBe("m-1");
64
+ expect(parsed.status).toBe("scheduled");
65
+ expect(parsed.systemIds).toEqual(["sys-1"]);
66
+ expect(parsed.startAt).toBe("2026-05-31T00:00:00.000Z");
67
+ expect(parsed.endAt).toBe("2026-05-31T01:00:00.000Z");
68
+ });
69
+
70
+ it("an update payload validates against the updated trigger's payloadSchema", () => {
71
+ const parsed = maintenanceUpdatedTrigger.payloadSchema.parse(
72
+ maintenanceChangeToPayload(
73
+ makeChange({
74
+ prev: {
75
+ status: "scheduled",
76
+ systemIds: ["sys-1"],
77
+ startAt: "2026-05-31T00:00:00.000Z",
78
+ endAt: "2026-05-31T01:00:00.000Z",
79
+ },
80
+ next: {
81
+ status: "in_progress",
82
+ systemIds: ["sys-1"],
83
+ startAt: "2026-05-31T00:00:00.000Z",
84
+ endAt: "2026-05-31T01:00:00.000Z",
85
+ },
86
+ }),
87
+ ),
88
+ );
89
+ expect(parsed.maintenanceId).toBe("m-1");
90
+ expect(parsed.status).toBe("in_progress");
91
+ });
92
+ });
93
+
94
+ describe("deriveMaintenanceEvents", () => {
95
+ it("maps a create (prev === null) to maintenance.created", () => {
96
+ const events = deriveMaintenanceEvents(makeChange({ prev: null }));
97
+ expect(events).toEqual([MAINTENANCE_CREATED_EVENT]);
98
+ });
99
+
100
+ it("maps an update (prev present, next present) to maintenance.updated", () => {
101
+ const events = deriveMaintenanceEvents(
102
+ makeChange({
103
+ prev: { status: "scheduled", systemIds: [], startAt: "a", endAt: "b" },
104
+ next: { status: "in_progress", systemIds: [], startAt: "a", endAt: "b" },
105
+ }),
106
+ );
107
+ expect(events).toEqual([MAINTENANCE_UPDATED_EVENT]);
108
+ });
109
+
110
+ it("maps a tombstone (next === null) to no event", () => {
111
+ const events = deriveMaintenanceEvents(
112
+ makeChange({
113
+ prev: { status: "scheduled", systemIds: [], startAt: "a", endAt: "b" },
114
+ next: null,
115
+ }),
116
+ );
117
+ expect(events).toEqual([]);
118
+ });
119
+
120
+ it("returns event ids that exactly equal the qualified trigger ids", () => {
121
+ // The created/updated trigger ids are namespaced to `${pluginId}.${id}`
122
+ // → `maintenance.created` / `maintenance.updated`. Lock the constants.
123
+ expect(MAINTENANCE_CREATED_EVENT).toBe("maintenance.created");
124
+ expect(MAINTENANCE_UPDATED_EVENT).toBe("maintenance.updated");
125
+ });
126
+ });
127
+
128
+ describe("toMaintenanceEntityState", () => {
129
+ it("serializes Date columns to ISO strings and validates against the schema", () => {
130
+ const state = toMaintenanceEntityState({
131
+ status: "scheduled",
132
+ systemIds: ["sys-1", "sys-2"],
133
+ startAt: new Date("2026-05-29T11:00:00Z"),
134
+ endAt: new Date("2026-05-29T12:00:00Z"),
135
+ });
136
+ expect(state).toEqual({
137
+ status: "scheduled",
138
+ systemIds: ["sys-1", "sys-2"],
139
+ startAt: "2026-05-29T11:00:00.000Z",
140
+ endAt: "2026-05-29T12:00:00.000Z",
141
+ });
142
+ expect(() => maintenanceEntityStateSchema.parse(state)).not.toThrow();
143
+ });
144
+
145
+ it("passes through already-ISO strings unchanged", () => {
146
+ const state = toMaintenanceEntityState({
147
+ status: "completed",
148
+ systemIds: [],
149
+ startAt: "2026-05-29T11:00:00.000Z",
150
+ endAt: "2026-05-29T12:00:00.000Z",
151
+ });
152
+ expect(state.startAt).toBe("2026-05-29T11:00:00.000Z");
153
+ expect(state.endAt).toBe("2026-05-29T12:00:00.000Z");
154
+ });
155
+ });
156
+
157
+ describe("createMaintenanceEntityRead", () => {
158
+ it("routes the batched read straight to the service (plugin-backed)", async () => {
159
+ const seen: ReadonlyArray<string>[] = [];
160
+ const service = {
161
+ async getManyEntityStates(ids: ReadonlyArray<string>) {
162
+ seen.push(ids);
163
+ return {
164
+ "m-1": {
165
+ status: "scheduled" as const,
166
+ systemIds: ["sys-1"],
167
+ startAt: "2026-05-29T11:00:00.000Z",
168
+ endAt: "2026-05-29T12:00:00.000Z",
169
+ },
170
+ };
171
+ },
172
+ } as unknown as MaintenanceService;
173
+ const read = createMaintenanceEntityRead(service);
174
+ const out = await read(["m-1", "m-2"]);
175
+ expect(seen).toEqual([["m-1", "m-2"]]);
176
+ expect(out["m-1"]).toEqual({
177
+ status: "scheduled",
178
+ systemIds: ["sys-1"],
179
+ startAt: "2026-05-29T11:00:00.000Z",
180
+ endAt: "2026-05-29T12:00:00.000Z",
181
+ });
182
+ });
183
+ });
184
+
185
+ describe("writeMaintenanceEntity", () => {
186
+ it("drives the write through handle.mutate keyed by maintenance id", async () => {
187
+ const calls: Array<{ id: string; next: MaintenanceEntityState }> = [];
188
+ const handle = {
189
+ kind: MAINTENANCE_ENTITY_KIND,
190
+ async mutate(input: {
191
+ id: string;
192
+ apply: () => Promise<MaintenanceEntityState>;
193
+ }) {
194
+ const next = await input.apply();
195
+ calls.push({ id: input.id, next });
196
+ return next;
197
+ },
198
+ } as unknown as EntityHandle<MaintenanceEntityState>;
199
+
200
+ let applied = false;
201
+ await writeMaintenanceEntity({
202
+ handle,
203
+ maintenanceId: "m-9",
204
+ apply: async () => {
205
+ applied = true;
206
+ return {
207
+ status: "in_progress",
208
+ systemIds: ["sys-1"],
209
+ startAt: "2026-05-29T11:00:00.000Z",
210
+ endAt: "2026-05-29T12:00:00.000Z",
211
+ };
212
+ },
213
+ });
214
+ expect(applied).toBe(true);
215
+ expect(calls).toEqual([
216
+ {
217
+ id: "m-9",
218
+ next: {
219
+ status: "in_progress",
220
+ systemIds: ["sys-1"],
221
+ startAt: "2026-05-29T11:00:00.000Z",
222
+ endAt: "2026-05-29T12:00:00.000Z",
223
+ },
224
+ },
225
+ ]);
226
+ });
227
+
228
+ it("still runs the plugin write when no handle is wired", async () => {
229
+ let applied = false;
230
+ await writeMaintenanceEntity({
231
+ handle: undefined,
232
+ maintenanceId: "m-9",
233
+ apply: async () => {
234
+ applied = true;
235
+ return {
236
+ status: "scheduled",
237
+ systemIds: [],
238
+ startAt: "a",
239
+ endAt: "b",
240
+ };
241
+ },
242
+ });
243
+ expect(applied).toBe(true);
244
+ });
245
+ });
246
+
247
+ describe("removeMaintenanceEntity", () => {
248
+ it("tombstones via handle.remove({ apply })", async () => {
249
+ const removed: string[] = [];
250
+ let deleted = false;
251
+ const handle = {
252
+ kind: MAINTENANCE_ENTITY_KIND,
253
+ async remove(input: { id: string; apply: () => Promise<void> }) {
254
+ await input.apply();
255
+ removed.push(input.id);
256
+ },
257
+ } as unknown as EntityHandle<MaintenanceEntityState>;
258
+ await removeMaintenanceEntity({
259
+ handle,
260
+ maintenanceId: "m-9",
261
+ apply: async () => {
262
+ deleted = true;
263
+ },
264
+ });
265
+ expect(deleted).toBe(true);
266
+ expect(removed).toEqual(["m-9"]);
267
+ });
268
+
269
+ it("still runs the delete when no handle is wired", async () => {
270
+ let deleted = false;
271
+ await removeMaintenanceEntity({
272
+ handle: undefined,
273
+ maintenanceId: "m-9",
274
+ apply: async () => {
275
+ deleted = true;
276
+ },
277
+ });
278
+ expect(deleted).toBe(true);
279
+ });
280
+ });
package/src/entity.ts ADDED
@@ -0,0 +1,187 @@
1
+ /**
2
+ * The reactive `maintenance` entity (reactive automation engine §10.2).
3
+ *
4
+ * Model B PLUGIN-BACKED entity: the `maintenances` + `maintenance_systems`
5
+ * tables are authoritative AND ARE the entity's current-state storage — there
6
+ * is NO framework `entity_state` row for a maintenance window.
7
+ * `defineEntity({ read })` makes that plugin state reactive: every
8
+ * reactive-state write goes through `handle.mutate`, whose `apply()` performs
9
+ * the REAL `maintenances`/junction write via the maintenance service (the
10
+ * plugin's own db/tx) and returns the resulting `{ status, systemIds, startAt,
11
+ * endAt }`. The framework snapshots `prev` via `read`, appends the transition
12
+ * log (its own db), and emits `ENTITY_CHANGED`. The change → trigger-event
13
+ * deriver reproduces `maintenance.created` / `.updated` so automations keep
14
+ * firing.
15
+ */
16
+ import { z } from "zod";
17
+ import {
18
+ MaintenanceStatusEnum,
19
+ type MaintenanceStatus,
20
+ } from "@checkstack/maintenance-common";
21
+ import type {
22
+ EntityChanged,
23
+ EntityChangePayloadMapper,
24
+ EntityHandle,
25
+ EntityMutationOpts,
26
+ EntityRead,
27
+ } from "@checkstack/automation-backend";
28
+ import {
29
+ withEntityRemove,
30
+ withEntityWrite,
31
+ } from "@checkstack/automation-backend";
32
+
33
+ import type { MaintenanceService } from "./service";
34
+
35
+ /** Globally-unique entity kind for a maintenance window. */
36
+ export const MAINTENANCE_ENTITY_KIND = "maintenance";
37
+
38
+ /**
39
+ * Qualified trigger event ids the deriver maps to — `${pluginId}.${triggerId}`
40
+ * for the (now-removed) `created` / `updated` triggers. Automations reference
41
+ * these strings in `definition.triggers[].event`, so the deriver MUST return
42
+ * them verbatim for Stage-1 routing to match.
43
+ */
44
+ export const MAINTENANCE_CREATED_EVENT = "maintenance.created";
45
+ export const MAINTENANCE_UPDATED_EVENT = "maintenance.updated";
46
+
47
+ /**
48
+ * Reactive state of a maintenance window (reactive automation engine §10.2).
49
+ * `startAt` / `endAt` are ISO strings (the `read` accessor serializes the
50
+ * service's `Date` columns; the `apply()` return uses {@link
51
+ * toMaintenanceEntityState} to do the same).
52
+ */
53
+ export const maintenanceEntityStateSchema = z.object({
54
+ status: MaintenanceStatusEnum,
55
+ systemIds: z.array(z.string()),
56
+ startAt: z.string(),
57
+ endAt: z.string(),
58
+ });
59
+
60
+ export type MaintenanceEntityState = z.infer<
61
+ typeof maintenanceEntityStateSchema
62
+ >;
63
+
64
+ /**
65
+ * Project a freshly-written maintenance row onto the reactive `{ status,
66
+ * systemIds, startAt, endAt }` subset. Accepts either `Date` (service return
67
+ * shape) or already-ISO strings. This is the `apply()` return for
68
+ * `handle.mutate`.
69
+ */
70
+ export function toMaintenanceEntityState(row: {
71
+ status: MaintenanceStatus;
72
+ systemIds: string[];
73
+ startAt: Date | string;
74
+ endAt: Date | string;
75
+ }): MaintenanceEntityState {
76
+ return {
77
+ status: row.status,
78
+ systemIds: row.systemIds,
79
+ startAt:
80
+ row.startAt instanceof Date ? row.startAt.toISOString() : row.startAt,
81
+ endAt: row.endAt instanceof Date ? row.endAt.toISOString() : row.endAt,
82
+ };
83
+ }
84
+
85
+ /**
86
+ * Map a `maintenance` entity change to the qualified trigger event id(s) it
87
+ * should fire (reactive automation engine §7, Stage-1 routing):
88
+ *
89
+ * - create (`prev === null`) → `maintenance.created`
90
+ * - update (a real diff, prev present) → `maintenance.updated`
91
+ * - remove (tombstone, `next === null`) → nothing (the old domain never
92
+ * emitted a hook on delete)
93
+ *
94
+ * The deriver never sees no-op writes — the handle only emits a change event
95
+ * on a real diff — so a plain non-tombstone change is always a meaningful
96
+ * `created`/`updated`.
97
+ */
98
+ export function deriveMaintenanceEvents(
99
+ changed: EntityChanged,
100
+ ): ReadonlyArray<string> {
101
+ if (changed.next === null) {
102
+ // Tombstone — delete fired no maintenance hook historically.
103
+ return [];
104
+ }
105
+ if (changed.prev === null) {
106
+ return [MAINTENANCE_CREATED_EVENT];
107
+ }
108
+ return [MAINTENANCE_UPDATED_EVENT];
109
+ }
110
+
111
+ /**
112
+ * Map a `maintenance` entity change to the domain-named `trigger.payload` the
113
+ * maintenance triggers declare via `payloadSchema` (`maintenanceId`, `status`,
114
+ * `systemIds`, `startAt`, `endAt`). Restores the keys operators read
115
+ * (`trigger.payload.maintenanceId`, `.status`, …) that the generic change shape
116
+ * omits, so an entity-driven maintenance trigger sees the same documented
117
+ * payload the four migrated lifecycle domains do.
118
+ *
119
+ * `maintenanceId` is the entity id; the remaining fields read off `next` (a
120
+ * tombstone fires no event, so `next` is always present when a trigger fires).
121
+ * The reactive subset is `{ status, systemIds, startAt, endAt }`, so the
122
+ * descriptive fields the old hook carried (`title`, `description`) are NOT
123
+ * derivable from a change and are declared OPTIONAL on the payload schemas.
124
+ */
125
+ export const maintenanceChangeToPayload: EntityChangePayloadMapper = (
126
+ changed,
127
+ ) => {
128
+ const next = changed.next;
129
+ const systemIds = next === null ? undefined : next["systemIds"];
130
+ const readString = (field: string): unknown =>
131
+ next === null ? undefined : next[field];
132
+ return {
133
+ maintenanceId: changed.id,
134
+ status: readString("status"),
135
+ systemIds: Array.isArray(systemIds) ? systemIds : [],
136
+ startAt: readString("startAt"),
137
+ endAt: readString("endAt"),
138
+ };
139
+ };
140
+
141
+ /**
142
+ * Build the PLUGIN-BACKED `read` accessor for the `maintenance` entity. Routes
143
+ * straight to the service's batched authoritative read — no framework storage.
144
+ */
145
+ export function createMaintenanceEntityRead(
146
+ service: MaintenanceService,
147
+ ): EntityRead<MaintenanceEntityState> {
148
+ return (ids) => service.getManyEntityStates(ids);
149
+ }
150
+
151
+ /**
152
+ * Drive a reactive-state maintenance write through `handle.mutate` (§10.2).
153
+ * `apply` performs the REAL `maintenances`/junction write via the service (the
154
+ * plugin's own db/tx) and returns the new reactive state. The framework
155
+ * snapshots `prev`, appends the transition log, and emits `ENTITY_CHANGED`
156
+ * (the deriver turns that into `maintenance.created/.updated`).
157
+ *
158
+ * When no handle is available (tests construct the router without one), the
159
+ * write still runs — the entity reactivity is layered on top, never required
160
+ * for the underlying write to succeed.
161
+ */
162
+ export async function writeMaintenanceEntity(args: {
163
+ handle: EntityHandle<MaintenanceEntityState> | undefined;
164
+ maintenanceId: string;
165
+ opts?: EntityMutationOpts;
166
+ apply: () => Promise<MaintenanceEntityState>;
167
+ }): Promise<void> {
168
+ const { handle, maintenanceId, opts, apply } = args;
169
+ await withEntityWrite({ handle, id: maintenanceId, opts, apply });
170
+ }
171
+
172
+ /**
173
+ * Drive a maintenance tombstone through `handle.remove` (§10.2). `apply`
174
+ * performs the REAL delete via the service; the framework records the
175
+ * tombstone transition and emits a tombstone change (the deriver fires
176
+ * nothing — the old domain never emitted a hook on delete). Without a handle,
177
+ * the delete still runs.
178
+ */
179
+ export async function removeMaintenanceEntity(args: {
180
+ handle: EntityHandle<MaintenanceEntityState> | undefined;
181
+ maintenanceId: string;
182
+ opts?: EntityMutationOpts;
183
+ apply: () => Promise<void>;
184
+ }): Promise<void> {
185
+ const { handle, maintenanceId, opts, apply } = args;
186
+ await withEntityRemove({ handle, id: maintenanceId, opts, apply });
187
+ }
@@ -0,0 +1,69 @@
1
+ import { describe, it, expect, mock } from "bun:test";
2
+ import { MaintenanceService } from "./service";
3
+
4
+ /**
5
+ * Mock db for hasActiveMaintenance. The method issues two select shapes
6
+ * in order:
7
+ * 1. .select({maintenanceId}).from(maintenanceSystems).where(...) -> ids
8
+ * 2. .select({id}).from(maintenances).where(...).limit(1) -> match
9
+ * We disambiguate by call order.
10
+ */
11
+ function createMockDb(opts: {
12
+ systemMaintenanceIds: string[];
13
+ activeMatch: boolean;
14
+ }) {
15
+ let firstSelect = true;
16
+ return {
17
+ select: mock(() => {
18
+ if (firstSelect) {
19
+ firstSelect = false;
20
+ return {
21
+ from: mock(() => ({
22
+ where: mock(() =>
23
+ Promise.resolve(
24
+ opts.systemMaintenanceIds.map((maintenanceId) => ({
25
+ maintenanceId,
26
+ })),
27
+ ),
28
+ ),
29
+ })),
30
+ };
31
+ }
32
+ return {
33
+ from: mock(() => ({
34
+ where: mock(() => ({
35
+ limit: mock(() =>
36
+ Promise.resolve(opts.activeMatch ? [{ id: "m-1" }] : []),
37
+ ),
38
+ })),
39
+ })),
40
+ };
41
+ }),
42
+ };
43
+ }
44
+
45
+ describe("MaintenanceService.hasActiveMaintenance", () => {
46
+ it("returns false when the system has no maintenance memberships", async () => {
47
+ const db = createMockDb({ systemMaintenanceIds: [], activeMatch: false });
48
+ const service = new MaintenanceService(db as never);
49
+ expect(await service.hasActiveMaintenance("system-1")).toBe(false);
50
+ });
51
+
52
+ it("returns true when an in_progress maintenance covers the system", async () => {
53
+ const db = createMockDb({
54
+ systemMaintenanceIds: ["m-1"],
55
+ activeMatch: true,
56
+ });
57
+ const service = new MaintenanceService(db as never);
58
+ expect(await service.hasActiveMaintenance("system-1")).toBe(true);
59
+ });
60
+
61
+ it("returns false when memberships exist but none are in_progress", async () => {
62
+ const db = createMockDb({
63
+ systemMaintenanceIds: ["m-1", "m-2"],
64
+ activeMatch: false,
65
+ });
66
+ const service = new MaintenanceService(db as never);
67
+ expect(await service.hasActiveMaintenance("system-1")).toBe(false);
68
+ });
69
+ });