@checkstack/incident-backend 1.2.0 → 1.4.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,266 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import type {
3
+ EntityChanged,
4
+ EntityHandle,
5
+ } from "@checkstack/automation-backend";
6
+ import { SYSTEM_ACTOR } from "@checkstack/common";
7
+
8
+ import {
9
+ INCIDENT_ENTITY_KIND,
10
+ INCIDENT_TRIGGER_EVENTS,
11
+ IncidentEntityStateSchema,
12
+ createIncidentEntityRead,
13
+ deriveIncidentTriggerEvents,
14
+ incidentChangeToPayload,
15
+ removeIncidentEntity,
16
+ toIncidentEntityState,
17
+ writeIncidentEntity,
18
+ type IncidentEntityState,
19
+ } from "./incident-entity";
20
+ import {
21
+ incidentCreatedTrigger,
22
+ incidentResolvedTrigger,
23
+ incidentUpdatedTrigger,
24
+ } from "./automations";
25
+ import type { IncidentService } from "./service";
26
+
27
+ function change(overrides: Partial<EntityChanged> = {}): EntityChanged {
28
+ return {
29
+ kind: INCIDENT_ENTITY_KIND,
30
+ id: "inc-1",
31
+ prev: { status: "investigating", severity: "major", systemIds: ["a"] },
32
+ next: { status: "monitoring", severity: "major", systemIds: ["a"] },
33
+ delta: { status: "monitoring" },
34
+ changedFields: ["status"],
35
+ actor: SYSTEM_ACTOR,
36
+ occurredAt: new Date().toISOString(),
37
+ ...overrides,
38
+ };
39
+ }
40
+
41
+ describe("deriveIncidentTriggerEvents", () => {
42
+ it("create → incident.created", () => {
43
+ expect(
44
+ deriveIncidentTriggerEvents(
45
+ change({
46
+ prev: null,
47
+ next: { status: "investigating", severity: "minor", systemIds: ["a"] },
48
+ }),
49
+ ),
50
+ ).toEqual([INCIDENT_TRIGGER_EVENTS.created]);
51
+ });
52
+
53
+ it("transition to resolved → incident.resolved", () => {
54
+ expect(
55
+ deriveIncidentTriggerEvents(
56
+ change({
57
+ prev: { status: "monitoring", severity: "major", systemIds: ["a"] },
58
+ next: { status: "resolved", severity: "major", systemIds: ["a"] },
59
+ }),
60
+ ),
61
+ ).toEqual([INCIDENT_TRIGGER_EVENTS.resolved]);
62
+ });
63
+
64
+ it("non-resolve field change → incident.updated", () => {
65
+ expect(deriveIncidentTriggerEvents(change())).toEqual([
66
+ INCIDENT_TRIGGER_EVENTS.updated,
67
+ ]);
68
+ });
69
+
70
+ it("reopen (resolved → investigating) → incident.updated", () => {
71
+ expect(
72
+ deriveIncidentTriggerEvents(
73
+ change({
74
+ prev: { status: "resolved", severity: "major", systemIds: ["a"] },
75
+ next: { status: "investigating", severity: "major", systemIds: ["a"] },
76
+ }),
77
+ ),
78
+ ).toEqual([INCIDENT_TRIGGER_EVENTS.updated]);
79
+ });
80
+
81
+ it("tombstone → no event (no incident.deleted)", () => {
82
+ expect(deriveIncidentTriggerEvents(change({ next: null }))).toEqual([]);
83
+ });
84
+ });
85
+
86
+ describe("incidentChangeToPayload — payloadSchema parity", () => {
87
+ it("a create payload validates against the created trigger's payloadSchema", () => {
88
+ const payload = incidentChangeToPayload(
89
+ change({
90
+ prev: null,
91
+ next: { status: "investigating", severity: "minor", systemIds: ["a", "b"] },
92
+ delta: { status: "investigating" },
93
+ changedFields: ["status", "severity", "systemIds"],
94
+ }),
95
+ );
96
+ const parsed = incidentCreatedTrigger.payloadSchema.parse(payload);
97
+ expect(parsed.incidentId).toBe("inc-1");
98
+ expect(parsed.status).toBe("investigating");
99
+ expect(parsed.severity).toBe("minor");
100
+ expect(parsed.systemIds).toEqual(["a", "b"]);
101
+ });
102
+
103
+ it("a status-flip payload validates against the updated trigger's payloadSchema and carries statusChange", () => {
104
+ const payload = incidentChangeToPayload(change());
105
+ const parsed = incidentUpdatedTrigger.payloadSchema.parse(payload);
106
+ expect(parsed.incidentId).toBe("inc-1");
107
+ expect(parsed.status).toBe("monitoring");
108
+ // status was in changedFields → statusChange mirrors the new status.
109
+ expect(parsed.statusChange).toBe("monitoring");
110
+ });
111
+
112
+ it("a resolve payload validates against the resolved trigger's payloadSchema", () => {
113
+ const payload = incidentChangeToPayload(
114
+ change({
115
+ prev: { status: "monitoring", severity: "major", systemIds: ["a"] },
116
+ next: { status: "resolved", severity: "major", systemIds: ["a"] },
117
+ }),
118
+ );
119
+ const parsed = incidentResolvedTrigger.payloadSchema.parse(payload);
120
+ expect(parsed.incidentId).toBe("inc-1");
121
+ expect(parsed.severity).toBe("major");
122
+ expect(parsed.systemIds).toEqual(["a"]);
123
+ });
124
+
125
+ it("omits statusChange when status did not change", () => {
126
+ const payload = incidentChangeToPayload(
127
+ change({
128
+ delta: { severity: "critical" },
129
+ changedFields: ["severity"],
130
+ next: { status: "monitoring", severity: "critical", systemIds: ["a"] },
131
+ }),
132
+ );
133
+ expect("statusChange" in payload).toBe(false);
134
+ });
135
+ });
136
+
137
+ describe("IncidentEntityStateSchema", () => {
138
+ it("parses the reactive subset", () => {
139
+ const parsed = IncidentEntityStateSchema.parse({
140
+ status: "investigating",
141
+ severity: "critical",
142
+ systemIds: ["a", "b"],
143
+ });
144
+ expect(parsed.systemIds).toEqual(["a", "b"]);
145
+ });
146
+ });
147
+
148
+ describe("toIncidentEntityState", () => {
149
+ it("projects the reactive subset off a full incident", () => {
150
+ expect(
151
+ toIncidentEntityState({
152
+ status: "monitoring",
153
+ severity: "major",
154
+ systemIds: ["a", "b"],
155
+ }),
156
+ ).toEqual({ status: "monitoring", severity: "major", systemIds: ["a", "b"] });
157
+ });
158
+ });
159
+
160
+ describe("createIncidentEntityRead", () => {
161
+ it("routes the batched read straight to the service (plugin-backed)", async () => {
162
+ const seen: ReadonlyArray<string>[] = [];
163
+ const service = {
164
+ async getManyEntityStates(ids: ReadonlyArray<string>) {
165
+ seen.push(ids);
166
+ return {
167
+ "inc-1": {
168
+ status: "investigating" as const,
169
+ severity: "minor" as const,
170
+ systemIds: ["a"],
171
+ },
172
+ };
173
+ },
174
+ } as unknown as IncidentService;
175
+ const read = createIncidentEntityRead(service);
176
+ const out = await read(["inc-1", "inc-2"]);
177
+ expect(seen).toEqual([["inc-1", "inc-2"]]);
178
+ expect(out["inc-1"]).toEqual({
179
+ status: "investigating",
180
+ severity: "minor",
181
+ systemIds: ["a"],
182
+ });
183
+ });
184
+ });
185
+
186
+ describe("writeIncidentEntity", () => {
187
+ it("drives the write through handle.mutate keyed by incident id", async () => {
188
+ const calls: Array<{ id: string; next: IncidentEntityState }> = [];
189
+ const handle = {
190
+ kind: INCIDENT_ENTITY_KIND,
191
+ async mutate(input: {
192
+ id: string;
193
+ apply: () => Promise<IncidentEntityState>;
194
+ }) {
195
+ const next = await input.apply();
196
+ calls.push({ id: input.id, next });
197
+ return next;
198
+ },
199
+ } as unknown as EntityHandle<IncidentEntityState>;
200
+
201
+ let applied = false;
202
+ await writeIncidentEntity({
203
+ handle,
204
+ incidentId: "inc-9",
205
+ apply: async () => {
206
+ applied = true;
207
+ return { status: "monitoring", severity: "critical", systemIds: ["a"] };
208
+ },
209
+ });
210
+ expect(applied).toBe(true);
211
+ expect(calls).toEqual([
212
+ {
213
+ id: "inc-9",
214
+ next: { status: "monitoring", severity: "critical", systemIds: ["a"] },
215
+ },
216
+ ]);
217
+ });
218
+
219
+ it("still runs the plugin write when no handle is wired", async () => {
220
+ let applied = false;
221
+ await writeIncidentEntity({
222
+ handle: undefined,
223
+ incidentId: "inc-9",
224
+ apply: async () => {
225
+ applied = true;
226
+ return { status: "investigating", severity: "minor", systemIds: [] };
227
+ },
228
+ });
229
+ expect(applied).toBe(true);
230
+ });
231
+ });
232
+
233
+ describe("removeIncidentEntity", () => {
234
+ it("tombstones via handle.remove({ apply })", async () => {
235
+ const removed: string[] = [];
236
+ let deleted = false;
237
+ const handle = {
238
+ kind: INCIDENT_ENTITY_KIND,
239
+ async remove(input: { id: string; apply: () => Promise<void> }) {
240
+ await input.apply();
241
+ removed.push(input.id);
242
+ },
243
+ } as unknown as EntityHandle<IncidentEntityState>;
244
+ await removeIncidentEntity({
245
+ handle,
246
+ incidentId: "inc-9",
247
+ apply: async () => {
248
+ deleted = true;
249
+ },
250
+ });
251
+ expect(deleted).toBe(true);
252
+ expect(removed).toEqual(["inc-9"]);
253
+ });
254
+
255
+ it("still runs the delete when no handle is wired", async () => {
256
+ let deleted = false;
257
+ await removeIncidentEntity({
258
+ handle: undefined,
259
+ incidentId: "inc-9",
260
+ apply: async () => {
261
+ deleted = true;
262
+ },
263
+ });
264
+ expect(deleted).toBe(true);
265
+ });
266
+ });
@@ -0,0 +1,192 @@
1
+ /**
2
+ * The reactive `incident` entity (reactive automation engine §10.1).
3
+ *
4
+ * Model B PLUGIN-BACKED entity: the `incidents` + `incident_systems` tables
5
+ * are authoritative AND ARE the entity's current-state storage — there is NO
6
+ * framework `entity_state` row for an incident. `defineEntity({ read })` makes
7
+ * that plugin state reactive: every reactive-state write goes through
8
+ * `handle.mutate`, whose `apply()` performs the REAL `incidents`/junction write
9
+ * via the incident service (the plugin's own db/tx) and returns the resulting
10
+ * `{ status, severity, systemIds }`. The framework snapshots `prev` via `read`,
11
+ * appends the transition log (its own db), and emits `ENTITY_CHANGED`. The
12
+ * change → trigger-event deriver reproduces `incident.created` / `.updated` /
13
+ * `.resolved` so automations keep firing.
14
+ */
15
+ import { z } from "zod";
16
+ import {
17
+ IncidentSeverityEnum,
18
+ IncidentStatusEnum,
19
+ } from "@checkstack/incident-common";
20
+ import type {
21
+ EntityChangeDeriver,
22
+ EntityChangePayloadMapper,
23
+ EntityHandle,
24
+ EntityMutationOpts,
25
+ EntityRead,
26
+ } from "@checkstack/automation-backend";
27
+ import {
28
+ withEntityRemove,
29
+ withEntityWrite,
30
+ } from "@checkstack/automation-backend";
31
+
32
+ import type { IncidentService } from "./service";
33
+
34
+ export const INCIDENT_ENTITY_KIND = "incident";
35
+
36
+ export const IncidentEntityStateSchema = z.object({
37
+ status: IncidentStatusEnum,
38
+ severity: IncidentSeverityEnum,
39
+ systemIds: z.array(z.string()),
40
+ });
41
+
42
+ export type IncidentEntityState = z.infer<typeof IncidentEntityStateSchema>;
43
+
44
+ export const INCIDENT_TRIGGER_EVENTS = {
45
+ created: "incident.created",
46
+ updated: "incident.updated",
47
+ resolved: "incident.resolved",
48
+ } as const;
49
+
50
+ function readStatus(state: Record<string, unknown> | null): string | null {
51
+ if (state === null) return null;
52
+ const status = state["status"];
53
+ return typeof status === "string" ? status : null;
54
+ }
55
+
56
+ /**
57
+ * `incident` change → trigger events.
58
+ *
59
+ * - create (`prev === null`) → `incident.created`
60
+ * - transition TO `resolved` (and not already resolved) → `incident.resolved`
61
+ * - any other field change → `incident.updated`
62
+ * - tombstone (`next === null`, from `deleteIncident`) → no event (there is
63
+ * no `incident.deleted` trigger event)
64
+ *
65
+ * NOTE (deviation): the old `addUpdate`-with-status=resolved path emitted
66
+ * BOTH `incident.updated` and `incident.resolved`; the deriver fires only
67
+ * `incident.resolved` on a resolution (matching the dedicated
68
+ * `resolveIncident` / `resolveAutoIncident` paths, which only emitted
69
+ * `incident.resolved`). A resolution is no longer also surfaced as a generic
70
+ * `incident.updated` — automations meant to react to resolution should use
71
+ * the `incident.resolved` trigger.
72
+ */
73
+ export const deriveIncidentTriggerEvents: EntityChangeDeriver = (changed) => {
74
+ if (changed.prev === null && changed.next !== null) {
75
+ return [INCIDENT_TRIGGER_EVENTS.created];
76
+ }
77
+ if (changed.next === null) {
78
+ return [];
79
+ }
80
+ const prevStatus = readStatus(changed.prev);
81
+ const nextStatus = readStatus(changed.next);
82
+ if (nextStatus === "resolved" && prevStatus !== "resolved") {
83
+ return [INCIDENT_TRIGGER_EVENTS.resolved];
84
+ }
85
+ return [INCIDENT_TRIGGER_EVENTS.updated];
86
+ };
87
+
88
+ function readField(
89
+ state: Record<string, unknown> | null,
90
+ field: string,
91
+ ): unknown {
92
+ return state === null ? undefined : state[field];
93
+ }
94
+
95
+ /**
96
+ * Map an `incident` entity change to the domain-named `trigger.payload` the
97
+ * incident triggers declare via `payloadSchema` (`incidentId`, `systemIds`,
98
+ * `severity`, `status`, `statusChange`). This restores the keys operators read
99
+ * (`trigger.payload.incidentId` etc.) that the generic change shape omits.
100
+ *
101
+ * The reactive `incident` entity state is only `{ status, severity, systemIds }`,
102
+ * so the schemas' descriptive fields (`title`, `description`, `createdAt`,
103
+ * `resolvedAt`) are NOT available from a change and are declared OPTIONAL on
104
+ * those schemas. `statusChange` is populated when the status field changed (the
105
+ * `updated` trigger's hint), so a status flip carries the new status both as
106
+ * `status` and `statusChange`.
107
+ */
108
+ export const incidentChangeToPayload: EntityChangePayloadMapper = (changed) => {
109
+ const next = changed.next;
110
+ const statusChanged = changed.changedFields.includes("status");
111
+ const nextStatus = readField(next, "status");
112
+ return {
113
+ incidentId: changed.id,
114
+ systemIds: Array.isArray(readField(next, "systemIds"))
115
+ ? (readField(next, "systemIds") as unknown[])
116
+ : [],
117
+ severity: readField(next, "severity"),
118
+ status: nextStatus,
119
+ ...(statusChanged && typeof nextStatus === "string"
120
+ ? { statusChange: nextStatus }
121
+ : {}),
122
+ };
123
+ };
124
+
125
+ /**
126
+ * Build the PLUGIN-BACKED `read` accessor for the `incident` entity. Routes
127
+ * straight to the service's batched authoritative read — no framework storage.
128
+ */
129
+ export function createIncidentEntityRead(
130
+ service: IncidentService,
131
+ ): EntityRead<IncidentEntityState> {
132
+ return (ids) => service.getManyEntityStates(ids);
133
+ }
134
+
135
+ /**
136
+ * Project a service incident shape onto the reactive `{ status, severity,
137
+ * systemIds }` subset. The router's service writes return the full incident;
138
+ * this is the `apply()` return for `handle.mutate`.
139
+ */
140
+ export function toIncidentEntityState(incident: {
141
+ status: IncidentEntityState["status"];
142
+ severity: IncidentEntityState["severity"];
143
+ systemIds: string[];
144
+ }): IncidentEntityState {
145
+ return {
146
+ status: incident.status,
147
+ severity: incident.severity,
148
+ systemIds: incident.systemIds,
149
+ };
150
+ }
151
+
152
+ /**
153
+ * Drive a reactive-state incident write through `handle.mutate` (§10.1).
154
+ * `apply` performs the REAL `incidents`/junction write via the service (the
155
+ * plugin's own db/tx) and returns the new reactive state. The framework
156
+ * snapshots `prev`, appends the transition log, and emits `ENTITY_CHANGED`
157
+ * (the deriver turns that into `incident.created/.updated/.resolved`).
158
+ *
159
+ * When no handle is available (tests construct the router without one), the
160
+ * write still runs — the entity reactivity is layered on top, never required
161
+ * for the underlying write to succeed.
162
+ */
163
+ export async function writeIncidentEntity(args: {
164
+ handle: EntityHandle<IncidentEntityState> | undefined;
165
+ incidentId: string;
166
+ /**
167
+ * Mutation context (actor / runId). When the write originates inside a
168
+ * dispatch run, pass `opts: { runId }` so a run-resolved secret that lands
169
+ * in the reactive state is masked in the transition rows + `ENTITY_CHANGED`.
170
+ */
171
+ opts?: EntityMutationOpts;
172
+ apply: () => Promise<IncidentEntityState>;
173
+ }): Promise<void> {
174
+ const { handle, incidentId, opts, apply } = args;
175
+ await withEntityWrite({ handle, id: incidentId, opts, apply });
176
+ }
177
+
178
+ /**
179
+ * Drive an incident tombstone through `handle.remove` (§10.1). `apply`
180
+ * performs the REAL delete via the service; the framework records the
181
+ * tombstone transition and emits a tombstone change (the deriver fires
182
+ * nothing — there is no `incident.deleted` trigger event). Without a handle,
183
+ * the delete still runs.
184
+ */
185
+ export async function removeIncidentEntity(args: {
186
+ handle: EntityHandle<IncidentEntityState> | undefined;
187
+ incidentId: string;
188
+ apply: () => Promise<void>;
189
+ }): Promise<void> {
190
+ const { handle, incidentId, apply } = args;
191
+ await withEntityRemove({ handle, id: incidentId, apply });
192
+ }