@checkstack/incident-backend 1.3.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.
- package/CHANGELOG.md +157 -0
- package/package.json +18 -18
- package/src/automations.test.ts +356 -5
- package/src/automations.ts +322 -34
- package/src/hooks.ts +8 -53
- package/src/incident-entity.test.ts +266 -0
- package/src/incident-entity.ts +192 -0
- package/src/index.ts +96 -16
- package/src/router.ts +162 -98
- package/src/service.test.ts +199 -0
- package/src/service.ts +147 -3
|
@@ -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
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -12,7 +12,10 @@ import {
|
|
|
12
12
|
import { createBackendPlugin, coreServices } from "@checkstack/backend-api";
|
|
13
13
|
import {
|
|
14
14
|
automationActionExtensionPoint,
|
|
15
|
+
automationArtifactTypeExtensionPoint,
|
|
15
16
|
automationTriggerExtensionPoint,
|
|
17
|
+
entityExtensionPoint,
|
|
18
|
+
type EntityHandle,
|
|
16
19
|
} from "@checkstack/automation-backend";
|
|
17
20
|
import {
|
|
18
21
|
NotificationApi,
|
|
@@ -22,16 +25,39 @@ import { IncidentService } from "./service";
|
|
|
22
25
|
import { createRouter } from "./router";
|
|
23
26
|
import { CatalogApi } from "@checkstack/catalog-common";
|
|
24
27
|
import { AuthApi } from "@checkstack/auth-common";
|
|
25
|
-
import {
|
|
28
|
+
import { CATALOG_SYSTEM_ENTITY_KIND } from "@checkstack/catalog-backend";
|
|
29
|
+
import {
|
|
30
|
+
INCIDENT_ENTITY_KIND,
|
|
31
|
+
IncidentEntityStateSchema,
|
|
32
|
+
createIncidentEntityRead,
|
|
33
|
+
deriveIncidentTriggerEvents,
|
|
34
|
+
incidentChangeToPayload,
|
|
35
|
+
type IncidentEntityState,
|
|
36
|
+
} from "./incident-entity";
|
|
26
37
|
import { registerSearchProvider } from "@checkstack/command-backend";
|
|
27
38
|
import { resolveRoute } from "@checkstack/common";
|
|
28
39
|
import { createIncidentCache } from "./cache";
|
|
29
|
-
import {
|
|
40
|
+
import {
|
|
41
|
+
createIncidentActions,
|
|
42
|
+
incidentArtifactType,
|
|
43
|
+
incidentTriggers,
|
|
44
|
+
} from "./automations";
|
|
30
45
|
|
|
31
46
|
// =============================================================================
|
|
32
47
|
// Plugin Definition
|
|
33
48
|
// =============================================================================
|
|
34
49
|
|
|
50
|
+
// Reactive `incident` entity handle (§10.1). Defined in register(); mutated
|
|
51
|
+
// from the router onward.
|
|
52
|
+
let incidentEntity: EntityHandle<IncidentEntityState> | undefined;
|
|
53
|
+
|
|
54
|
+
// The incident service is created in init() (it needs the resolved database),
|
|
55
|
+
// but the PLUGIN-BACKED entity `read` accessor must be supplied at
|
|
56
|
+
// `defineEntity` time in register(). This holder bridges the two: the `read`
|
|
57
|
+
// closure resolves the service lazily, and init() sets it before any mutation
|
|
58
|
+
// runs (the registry only mutates from init() onward).
|
|
59
|
+
let incidentServiceRef: IncidentService | undefined;
|
|
60
|
+
|
|
35
61
|
export default createBackendPlugin({
|
|
36
62
|
metadata: pluginMetadata,
|
|
37
63
|
register(env) {
|
|
@@ -41,10 +67,10 @@ export default createBackendPlugin({
|
|
|
41
67
|
incidentGroupSubscription,
|
|
42
68
|
]);
|
|
43
69
|
|
|
44
|
-
// Register
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
70
|
+
// Register triggers — buffered until the automation plugin's
|
|
71
|
+
// `register()` runs and the extension point resolves. Triggers expose
|
|
72
|
+
// `contextKey` so wait_for_trigger can match resume events back to the
|
|
73
|
+
// originating incident.
|
|
48
74
|
const automationTriggers = env.getExtensionPoint(
|
|
49
75
|
automationTriggerExtensionPoint,
|
|
50
76
|
);
|
|
@@ -52,6 +78,43 @@ export default createBackendPlugin({
|
|
|
52
78
|
automationTriggers.registerTrigger(trigger, pluginMetadata);
|
|
53
79
|
}
|
|
54
80
|
|
|
81
|
+
// ─── Reactive `incident` entity (§10.1) ────────────────────────────
|
|
82
|
+
// PLUGIN-BACKED (Model B): the `incidents` + `incident_systems` tables ARE
|
|
83
|
+
// the current-state storage. `read` routes straight to the service's
|
|
84
|
+
// batched authoritative read — no framework `entity_state` row, so no
|
|
85
|
+
// `indexes` (those only apply to store-backed kinds). The `read` closure
|
|
86
|
+
// resolves the service set by init() (mutations only happen from init on).
|
|
87
|
+
const entityPoint = env.getExtensionPoint(entityExtensionPoint);
|
|
88
|
+
incidentEntity = entityPoint.defineEntity<IncidentEntityState>({
|
|
89
|
+
kind: INCIDENT_ENTITY_KIND,
|
|
90
|
+
state: IncidentEntityStateSchema,
|
|
91
|
+
read: (ids) => {
|
|
92
|
+
const svc = incidentServiceRef;
|
|
93
|
+
if (!svc) {
|
|
94
|
+
throw new Error(
|
|
95
|
+
"incident entity read before init: service not yet resolved",
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
return createIncidentEntityRead(svc)(ids);
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
entityPoint.registerChangeDeriver({
|
|
102
|
+
kind: INCIDENT_ENTITY_KIND,
|
|
103
|
+
derive: deriveIncidentTriggerEvents,
|
|
104
|
+
toPayload: incidentChangeToPayload,
|
|
105
|
+
});
|
|
106
|
+
const onEntityChanged = entityPoint.onEntityChanged;
|
|
107
|
+
|
|
108
|
+
// Register the `incident` artifact type so `incident.create` can
|
|
109
|
+
// `produces` it and the close/update actions can `consumes` it.
|
|
110
|
+
const automationArtifactTypes = env.getExtensionPoint(
|
|
111
|
+
automationArtifactTypeExtensionPoint,
|
|
112
|
+
);
|
|
113
|
+
automationArtifactTypes.registerArtifactType(
|
|
114
|
+
incidentArtifactType,
|
|
115
|
+
pluginMetadata,
|
|
116
|
+
);
|
|
117
|
+
|
|
55
118
|
let incidentCache:
|
|
56
119
|
| ReturnType<typeof createIncidentCache>
|
|
57
120
|
| undefined;
|
|
@@ -82,6 +145,9 @@ export default createBackendPlugin({
|
|
|
82
145
|
const service = new IncidentService(
|
|
83
146
|
database as SafeDatabase<typeof schema>,
|
|
84
147
|
);
|
|
148
|
+
// Publish the service for the PLUGIN-BACKED entity `read` accessor
|
|
149
|
+
// (defined in register()). Mutations only run from here onward.
|
|
150
|
+
incidentServiceRef = service;
|
|
85
151
|
const cache = createIncidentCache({ cacheManager, logger });
|
|
86
152
|
incidentCache = cache;
|
|
87
153
|
const router = createRouter(
|
|
@@ -92,6 +158,7 @@ export default createBackendPlugin({
|
|
|
92
158
|
authClient,
|
|
93
159
|
logger,
|
|
94
160
|
cache,
|
|
161
|
+
() => incidentEntity,
|
|
95
162
|
);
|
|
96
163
|
rpc.registerRouter(router, incidentContract);
|
|
97
164
|
|
|
@@ -103,7 +170,10 @@ export default createBackendPlugin({
|
|
|
103
170
|
const automationActions = env.getExtensionPoint(
|
|
104
171
|
automationActionExtensionPoint,
|
|
105
172
|
);
|
|
106
|
-
for (const action of createIncidentActions({
|
|
173
|
+
for (const action of createIncidentActions({
|
|
174
|
+
service,
|
|
175
|
+
getIncidentEntity: () => incidentEntity,
|
|
176
|
+
})) {
|
|
107
177
|
automationActions.registerAction(action, pluginMetadata);
|
|
108
178
|
}
|
|
109
179
|
|
|
@@ -138,7 +208,7 @@ export default createBackendPlugin({
|
|
|
138
208
|
// associations) + register subscription specs. Per-system /
|
|
139
209
|
// per-group notification group lifecycle is fully owned by
|
|
140
210
|
// notification-backend now — incident never touches it.
|
|
141
|
-
afterPluginsReady: async ({ database, logger,
|
|
211
|
+
afterPluginsReady: async ({ database, logger, rpcClient }) => {
|
|
142
212
|
const typedDb = database as SafeDatabase<typeof schema>;
|
|
143
213
|
const service = new IncidentService(typedDb);
|
|
144
214
|
const notificationClient = rpcClient.forPlugin(NotificationApi);
|
|
@@ -152,17 +222,27 @@ export default createBackendPlugin({
|
|
|
152
222
|
),
|
|
153
223
|
]);
|
|
154
224
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
225
|
+
// React to catalog system deletion (tombstone) via the reactive
|
|
226
|
+
// `catalog-system` entity instead of the (being-removed)
|
|
227
|
+
// `system.deleted` hook (§10.1). `work-queue` delivery preserved:
|
|
228
|
+
// association cleanup is a side-effecting write that must run once
|
|
229
|
+
// per cluster, not per-instance.
|
|
230
|
+
onEntityChanged({
|
|
231
|
+
kind: CATALOG_SYSTEM_ENTITY_KIND,
|
|
232
|
+
handler: async (change) => {
|
|
233
|
+
if (change.next !== null) return; // tombstone only
|
|
234
|
+
const systemId = change.id;
|
|
158
235
|
logger.debug(
|
|
159
|
-
`Cleaning up incident associations for deleted system: ${
|
|
236
|
+
`Cleaning up incident associations for deleted system: ${systemId}`,
|
|
160
237
|
);
|
|
161
|
-
await service.removeSystemAssociations(
|
|
162
|
-
await incidentCache?.invalidateSystem(
|
|
238
|
+
await service.removeSystemAssociations(systemId);
|
|
239
|
+
await incidentCache?.invalidateSystem(systemId);
|
|
163
240
|
},
|
|
164
|
-
|
|
165
|
-
|
|
241
|
+
delivery: {
|
|
242
|
+
mode: "work-queue",
|
|
243
|
+
workerGroup: "incident-system-cleanup",
|
|
244
|
+
},
|
|
245
|
+
});
|
|
166
246
|
|
|
167
247
|
logger.debug("✅ Incident Backend afterPluginsReady complete.");
|
|
168
248
|
},
|