@checkstack/incident-backend 1.11.0 → 1.13.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 +388 -0
- package/drizzle/0005_real_leper_queen.sql +4 -0
- package/drizzle/0006_last_princess_powerful.sql +1 -0
- package/drizzle/0007_colorful_true_believers.sql +2 -0
- package/drizzle/meta/0005_snapshot.json +346 -0
- package/drizzle/meta/0006_snapshot.json +353 -0
- package/drizzle/meta/0007_snapshot.json +391 -0
- package/drizzle/meta/_journal.json +21 -0
- package/package.json +15 -15
- package/src/ai/incident-add-link.test.ts +1 -0
- package/src/ai/incident-add-update.test.ts +6 -1
- package/src/ai/incident-delete-update.test.ts +59 -0
- package/src/ai/incident-delete-update.ts +73 -0
- package/src/ai/register-ai-tools.ts +2 -0
- package/src/automations.test.ts +70 -0
- package/src/automations.ts +77 -4
- package/src/hooks.ts +54 -4
- package/src/index.ts +4 -0
- package/src/notifications.test.ts +181 -0
- package/src/notifications.ts +12 -1
- package/src/read-visibility.test.ts +158 -0
- package/src/read-visibility.ts +99 -0
- package/src/router.test.ts +69 -1
- package/src/router.ts +195 -15
- package/src/schema.ts +55 -10
- package/src/service-reads.it.test.ts +353 -0
- package/src/service-updates.it.test.ts +340 -0
- package/src/service.it.test.ts +57 -0
- package/src/service.test.ts +235 -1
- package/src/service.ts +489 -177
- package/src/status-page-widget.test.ts +149 -0
- package/src/status-page-widget.ts +136 -37
package/src/service.it.test.ts
CHANGED
|
@@ -73,6 +73,7 @@ describe.skipIf(!process.env.CHECKSTACK_IT)(
|
|
|
73
73
|
incident_id text NOT NULL,
|
|
74
74
|
label text,
|
|
75
75
|
url text NOT NULL,
|
|
76
|
+
visibility text NOT NULL DEFAULT 'public',
|
|
76
77
|
created_at timestamp NOT NULL DEFAULT now()
|
|
77
78
|
)`,
|
|
78
79
|
);
|
|
@@ -126,5 +127,61 @@ describe.skipIf(!process.env.CHECKSTACK_IT)(
|
|
|
126
127
|
// The other incident's link is untouched.
|
|
127
128
|
expect(await linkExists("lnk-2")).toBe(true);
|
|
128
129
|
});
|
|
130
|
+
|
|
131
|
+
it("updateLink edits the link when the pair matches", async () => {
|
|
132
|
+
await insertLink({ id: "lnk-1", incidentId: "inc-1", url: "https://a" });
|
|
133
|
+
|
|
134
|
+
const updated = await service.updateLink({
|
|
135
|
+
id: "lnk-1",
|
|
136
|
+
incidentId: "inc-1",
|
|
137
|
+
label: "Runbook",
|
|
138
|
+
url: "https://b",
|
|
139
|
+
visibility: "internal",
|
|
140
|
+
});
|
|
141
|
+
expect(updated?.label).toBe("Runbook");
|
|
142
|
+
expect(updated?.url).toBe("https://b");
|
|
143
|
+
expect(updated?.visibility).toBe("internal");
|
|
144
|
+
|
|
145
|
+
const res = await pool.query(
|
|
146
|
+
`SELECT url, label, visibility FROM "${SCHEMA}".incident_links WHERE id = $1`,
|
|
147
|
+
["lnk-1"],
|
|
148
|
+
);
|
|
149
|
+
expect(res.rows[0]).toEqual({
|
|
150
|
+
url: "https://b",
|
|
151
|
+
label: "Runbook",
|
|
152
|
+
visibility: "internal",
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("updateLink edits nothing and returns undefined when the incidentId does not own the link", async () => {
|
|
157
|
+
await insertLink({ id: "lnk-1", incidentId: "inc-1", url: "https://a" });
|
|
158
|
+
|
|
159
|
+
// Link belongs to inc-1; the caller is authorized against inc-2. The
|
|
160
|
+
// spoofed pair must NOT edit.
|
|
161
|
+
expect(
|
|
162
|
+
await service.updateLink({
|
|
163
|
+
id: "lnk-1",
|
|
164
|
+
incidentId: "inc-2",
|
|
165
|
+
url: "https://evil",
|
|
166
|
+
}),
|
|
167
|
+
).toBeUndefined();
|
|
168
|
+
|
|
169
|
+
const res = await pool.query(
|
|
170
|
+
`SELECT url FROM "${SCHEMA}".incident_links WHERE id = $1`,
|
|
171
|
+
["lnk-1"],
|
|
172
|
+
);
|
|
173
|
+
// The link is untouched.
|
|
174
|
+
expect(res.rows[0].url).toBe("https://a");
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("updateLink returns undefined for a link id that does not exist", async () => {
|
|
178
|
+
expect(
|
|
179
|
+
await service.updateLink({
|
|
180
|
+
id: "missing",
|
|
181
|
+
incidentId: "inc-1",
|
|
182
|
+
url: "https://x",
|
|
183
|
+
}),
|
|
184
|
+
).toBeUndefined();
|
|
185
|
+
});
|
|
129
186
|
},
|
|
130
187
|
);
|
package/src/service.test.ts
CHANGED
|
@@ -70,8 +70,16 @@ function createProgrammableSelectDb(resultsByCall: unknown[][]) {
|
|
|
70
70
|
return { from };
|
|
71
71
|
});
|
|
72
72
|
|
|
73
|
+
// Read-batching methods (getManyEntityStates, listOpenIncidentsBySystem, ...)
|
|
74
|
+
// now run their reads inside `withScopedTransaction` -> `db.transaction(fn)`.
|
|
75
|
+
// The tx exposes the SAME `select` mock so the per-invocation call counter is
|
|
76
|
+
// shared and `getCallCount()` still reflects total queries issued.
|
|
77
|
+
const transaction = mock((fn: (tx: { select: typeof select }) => unknown) =>
|
|
78
|
+
Promise.resolve(fn({ select })),
|
|
79
|
+
);
|
|
80
|
+
|
|
73
81
|
return {
|
|
74
|
-
db: { select } as unknown,
|
|
82
|
+
db: { select, transaction } as unknown,
|
|
75
83
|
select,
|
|
76
84
|
getCallCount: () => callIndex,
|
|
77
85
|
};
|
|
@@ -227,6 +235,232 @@ describe("IncidentService.getManyEntityStates (plugin-backed entity read)", () =
|
|
|
227
235
|
});
|
|
228
236
|
});
|
|
229
237
|
|
|
238
|
+
describe("IncidentService.getIncident (batched detail read)", () => {
|
|
239
|
+
const createdAt = new Date("2026-06-01T10:00:00.000Z");
|
|
240
|
+
const updatedAt = new Date("2026-06-01T10:05:00.000Z");
|
|
241
|
+
|
|
242
|
+
it("assembles the detail from incident + systems + updates + links (4 queries, one tx)", async () => {
|
|
243
|
+
const dbHelper = createProgrammableSelectDb([
|
|
244
|
+
// 1st: the incident row.
|
|
245
|
+
[
|
|
246
|
+
{
|
|
247
|
+
id: "inc-1",
|
|
248
|
+
title: "DB down",
|
|
249
|
+
description: null,
|
|
250
|
+
status: "investigating",
|
|
251
|
+
severity: "critical",
|
|
252
|
+
suppressNotifications: false,
|
|
253
|
+
healthOverride: null,
|
|
254
|
+
createdAt,
|
|
255
|
+
updatedAt,
|
|
256
|
+
},
|
|
257
|
+
],
|
|
258
|
+
// 2nd: system associations.
|
|
259
|
+
[{ systemId: "sys-a" }, { systemId: "sys-b" }],
|
|
260
|
+
// 3rd: the full (unfiltered) timeline.
|
|
261
|
+
[
|
|
262
|
+
{
|
|
263
|
+
id: "u1",
|
|
264
|
+
incidentId: "inc-1",
|
|
265
|
+
message: "looking",
|
|
266
|
+
statusChange: "investigating",
|
|
267
|
+
visibility: "public",
|
|
268
|
+
createdAt,
|
|
269
|
+
editedAt: null,
|
|
270
|
+
createdBy: null,
|
|
271
|
+
},
|
|
272
|
+
],
|
|
273
|
+
// 4th: hotlinks.
|
|
274
|
+
[
|
|
275
|
+
{
|
|
276
|
+
id: "lnk-1",
|
|
277
|
+
incidentId: "inc-1",
|
|
278
|
+
label: "Runbook",
|
|
279
|
+
url: "https://a",
|
|
280
|
+
visibility: "public",
|
|
281
|
+
createdAt,
|
|
282
|
+
},
|
|
283
|
+
],
|
|
284
|
+
]);
|
|
285
|
+
const service = new IncidentService(
|
|
286
|
+
dbHelper.db as never,
|
|
287
|
+
makeFakeAdvisoryLock(),
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
const out = await service.getIncident("inc-1");
|
|
291
|
+
|
|
292
|
+
expect(out?.systemIds).toEqual(["sys-a", "sys-b"]);
|
|
293
|
+
// null description normalized to undefined, nullable update fields too.
|
|
294
|
+
expect(out?.description).toBeUndefined();
|
|
295
|
+
expect(out?.updates).toEqual([
|
|
296
|
+
{
|
|
297
|
+
id: "u1",
|
|
298
|
+
incidentId: "inc-1",
|
|
299
|
+
message: "looking",
|
|
300
|
+
statusChange: "investigating",
|
|
301
|
+
visibility: "public",
|
|
302
|
+
createdAt,
|
|
303
|
+
editedAt: undefined,
|
|
304
|
+
editHistory: [],
|
|
305
|
+
createdBy: undefined,
|
|
306
|
+
},
|
|
307
|
+
]);
|
|
308
|
+
expect(out?.links.map((l) => l.id)).toEqual(["lnk-1"]);
|
|
309
|
+
// Exactly the 4 reads (no per-row fan-out), all issued on the tx.
|
|
310
|
+
expect(dbHelper.getCallCount()).toBe(4);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it("returns undefined after a single query when the incident is absent", async () => {
|
|
314
|
+
const dbHelper = createProgrammableSelectDb([[]]);
|
|
315
|
+
const service = new IncidentService(
|
|
316
|
+
dbHelper.db as never,
|
|
317
|
+
makeFakeAdvisoryLock(),
|
|
318
|
+
);
|
|
319
|
+
expect(await service.getIncident("ghost")).toBeUndefined();
|
|
320
|
+
// The systems/updates/links reads are skipped once the incident is missing.
|
|
321
|
+
expect(dbHelper.getCallCount()).toBe(1);
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
describe("IncidentService.listIncidents (set-based system grouping)", () => {
|
|
326
|
+
const createdAt = new Date("2026-06-01T10:00:00.000Z");
|
|
327
|
+
const updatedAt = new Date("2026-06-01T10:05:00.000Z");
|
|
328
|
+
const incidentRow = (id: string, description: string | null) => ({
|
|
329
|
+
id,
|
|
330
|
+
title: id,
|
|
331
|
+
description,
|
|
332
|
+
status: "investigating" as const,
|
|
333
|
+
severity: "major" as const,
|
|
334
|
+
suppressNotifications: false,
|
|
335
|
+
healthOverride: null,
|
|
336
|
+
createdAt,
|
|
337
|
+
updatedAt,
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it("fetches all system associations in ONE junction query (no N+1) and groups them", async () => {
|
|
341
|
+
const dbHelper = createProgrammableSelectDb([
|
|
342
|
+
// 1st: the incidents matching the status filter.
|
|
343
|
+
[incidentRow("inc-1", null), incidentRow("inc-2", "elevated latency")],
|
|
344
|
+
// 2nd (and ONLY): a single inArray junction read for BOTH incidents.
|
|
345
|
+
[
|
|
346
|
+
{ incidentId: "inc-1", systemId: "sys-a" },
|
|
347
|
+
{ incidentId: "inc-1", systemId: "sys-b" },
|
|
348
|
+
{ incidentId: "inc-2", systemId: "sys-c" },
|
|
349
|
+
],
|
|
350
|
+
]);
|
|
351
|
+
const service = new IncidentService(
|
|
352
|
+
dbHelper.db as never,
|
|
353
|
+
makeFakeAdvisoryLock(),
|
|
354
|
+
);
|
|
355
|
+
|
|
356
|
+
const out = await service.listIncidents({ includeResolved: true });
|
|
357
|
+
|
|
358
|
+
expect(out.map((i) => i.id)).toEqual(["inc-1", "inc-2"]);
|
|
359
|
+
expect(out[0].systemIds).toEqual(["sys-a", "sys-b"]);
|
|
360
|
+
expect(out[1].systemIds).toEqual(["sys-c"]);
|
|
361
|
+
expect(out[0].description).toBeUndefined();
|
|
362
|
+
expect(out[1].description).toBe("elevated latency");
|
|
363
|
+
// 1 incidents read + exactly 1 junction read, regardless of row count.
|
|
364
|
+
expect(dbHelper.getCallCount()).toBe(2);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it("skips the junction query when no incidents match", async () => {
|
|
368
|
+
const dbHelper = createProgrammableSelectDb([[]]);
|
|
369
|
+
const service = new IncidentService(
|
|
370
|
+
dbHelper.db as never,
|
|
371
|
+
makeFakeAdvisoryLock(),
|
|
372
|
+
);
|
|
373
|
+
expect(await service.listIncidents()).toEqual([]);
|
|
374
|
+
expect(dbHelper.getCallCount()).toBe(1);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it("resolves the system's incident ids first when filtering by systemId (3 queries total)", async () => {
|
|
378
|
+
const dbHelper = createProgrammableSelectDb([
|
|
379
|
+
// 1st: incident ids attached to the system.
|
|
380
|
+
[{ incidentId: "inc-1" }],
|
|
381
|
+
// 2nd: the incidents themselves (status-filtered).
|
|
382
|
+
[incidentRow("inc-1", null)],
|
|
383
|
+
// 3rd: the single junction grouping read.
|
|
384
|
+
[{ incidentId: "inc-1", systemId: "sys-a" }],
|
|
385
|
+
]);
|
|
386
|
+
const service = new IncidentService(
|
|
387
|
+
dbHelper.db as never,
|
|
388
|
+
makeFakeAdvisoryLock(),
|
|
389
|
+
);
|
|
390
|
+
|
|
391
|
+
const out = await service.listIncidents({ systemId: "sys-a" });
|
|
392
|
+
|
|
393
|
+
expect(out.map((i) => i.id)).toEqual(["inc-1"]);
|
|
394
|
+
expect(out[0].systemIds).toEqual(["sys-a"]);
|
|
395
|
+
expect(dbHelper.getCallCount()).toBe(3);
|
|
396
|
+
});
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
describe("IncidentService.getIncidentsForSystem (set-based system grouping)", () => {
|
|
400
|
+
const createdAt = new Date("2026-06-01T10:00:00.000Z");
|
|
401
|
+
const updatedAt = new Date("2026-06-01T10:05:00.000Z");
|
|
402
|
+
|
|
403
|
+
it("groups memberships from ONE junction query after resolving the system's incidents", async () => {
|
|
404
|
+
const dbHelper = createProgrammableSelectDb([
|
|
405
|
+
// 1st: incident ids attached to the system.
|
|
406
|
+
[{ incidentId: "inc-1" }, { incidentId: "inc-2" }],
|
|
407
|
+
// 2nd: non-resolved incidents for those ids.
|
|
408
|
+
[
|
|
409
|
+
{
|
|
410
|
+
id: "inc-1",
|
|
411
|
+
title: "A",
|
|
412
|
+
description: null,
|
|
413
|
+
status: "investigating",
|
|
414
|
+
severity: "major",
|
|
415
|
+
suppressNotifications: false,
|
|
416
|
+
healthOverride: null,
|
|
417
|
+
createdAt,
|
|
418
|
+
updatedAt,
|
|
419
|
+
},
|
|
420
|
+
{
|
|
421
|
+
id: "inc-2",
|
|
422
|
+
title: "B",
|
|
423
|
+
description: null,
|
|
424
|
+
status: "monitoring",
|
|
425
|
+
severity: "minor",
|
|
426
|
+
suppressNotifications: false,
|
|
427
|
+
healthOverride: null,
|
|
428
|
+
createdAt,
|
|
429
|
+
updatedAt,
|
|
430
|
+
},
|
|
431
|
+
],
|
|
432
|
+
// 3rd (and ONLY) junction read for BOTH incidents' full membership.
|
|
433
|
+
[
|
|
434
|
+
{ incidentId: "inc-1", systemId: "sys-a" },
|
|
435
|
+
{ incidentId: "inc-1", systemId: "sys-b" },
|
|
436
|
+
{ incidentId: "inc-2", systemId: "sys-a" },
|
|
437
|
+
],
|
|
438
|
+
]);
|
|
439
|
+
const service = new IncidentService(
|
|
440
|
+
dbHelper.db as never,
|
|
441
|
+
makeFakeAdvisoryLock(),
|
|
442
|
+
);
|
|
443
|
+
|
|
444
|
+
const out = await service.getIncidentsForSystem("sys-a");
|
|
445
|
+
|
|
446
|
+
expect(out.map((i) => i.id)).toEqual(["inc-1", "inc-2"]);
|
|
447
|
+
// Each incident carries its FULL membership, not just the queried system.
|
|
448
|
+
expect(out[0].systemIds).toEqual(["sys-a", "sys-b"]);
|
|
449
|
+
expect(out[1].systemIds).toEqual(["sys-a"]);
|
|
450
|
+
expect(dbHelper.getCallCount()).toBe(3);
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
it("returns [] without a junction query when the system has no incidents", async () => {
|
|
454
|
+
const dbHelper = createProgrammableSelectDb([[]]);
|
|
455
|
+
const service = new IncidentService(
|
|
456
|
+
dbHelper.db as never,
|
|
457
|
+
makeFakeAdvisoryLock(),
|
|
458
|
+
);
|
|
459
|
+
expect(await service.getIncidentsForSystem("sys-x")).toEqual([]);
|
|
460
|
+
expect(dbHelper.getCallCount()).toBe(1);
|
|
461
|
+
});
|
|
462
|
+
});
|
|
463
|
+
|
|
230
464
|
describe("IncidentService.listOpenIncidentsBySystem (global signals read)", () => {
|
|
231
465
|
it("returns {} without a junction query when no open incidents exist", async () => {
|
|
232
466
|
const dbHelper = createProgrammableSelectDb([
|