@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.
- package/CHANGELOG.md +242 -0
- package/package.json +18 -16
- package/src/automations.test.ts +523 -0
- package/src/automations.ts +601 -0
- package/src/hooks.ts +9 -45
- package/src/incident-entity.test.ts +266 -0
- package/src/incident-entity.ts +192 -0
- package/src/index.ts +110 -76
- package/src/router.ts +162 -98
- package/src/service.test.ts +199 -0
- package/src/service.ts +147 -3
- package/tsconfig.json +6 -0
|
@@ -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
|
+
}
|