@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
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration test for the BATCHED / SET-BASED incident read paths against a
|
|
3
|
+
* REAL Postgres: `getIncident`, `listIncidents`, and `getIncidentsForSystem`.
|
|
4
|
+
*
|
|
5
|
+
* These reads were reshaped for the scoped-db query-batching sweep:
|
|
6
|
+
* - `getIncident` now runs its 4 sequential reads (incident -> systems ->
|
|
7
|
+
* updates -> links) inside ONE `withScopedTransaction` (single SET LOCAL).
|
|
8
|
+
* - `listIncidents` / `getIncidentsForSystem` replaced a per-row N+1 system
|
|
9
|
+
* lookup with a SINGLE set-based `inArray` junction read grouped in JS.
|
|
10
|
+
*
|
|
11
|
+
* The mocked-DB unit tests prove the JS grouping and the query COUNT, but only
|
|
12
|
+
* a real database proves the rewritten SQL (the `inArray` grouping, the status
|
|
13
|
+
* filters, and the full-timeline assembly) returns byte-identical results. That
|
|
14
|
+
* is exactly what this guard covers.
|
|
15
|
+
*
|
|
16
|
+
* Gated on CHECKSTACK_IT so it runs in CI (shared compose Postgres) and is
|
|
17
|
+
* skipped in the default `bun test` run, matching the other *.it.test.ts here.
|
|
18
|
+
*/
|
|
19
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test";
|
|
20
|
+
import { drizzle } from "drizzle-orm/node-postgres";
|
|
21
|
+
import { Pool } from "pg";
|
|
22
|
+
import type {
|
|
23
|
+
AdvisoryLockService,
|
|
24
|
+
SafeDatabase,
|
|
25
|
+
} from "@checkstack/backend-api";
|
|
26
|
+
import * as schema from "./schema";
|
|
27
|
+
import { IncidentService } from "./service";
|
|
28
|
+
|
|
29
|
+
const PG_URL =
|
|
30
|
+
process.env.CHECKSTACK_IT_PG_URL ??
|
|
31
|
+
"postgres://postgres:postgres@localhost:5432/postgres";
|
|
32
|
+
const SCHEMA = "incident_it_reads";
|
|
33
|
+
|
|
34
|
+
const noopAdvisoryLock: AdvisoryLockService = {
|
|
35
|
+
tryAcquire: async () => null,
|
|
36
|
+
withXactLock: async ({ fn }) => fn(),
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
let admin: Pool;
|
|
40
|
+
let pool: Pool;
|
|
41
|
+
let service: IncidentService;
|
|
42
|
+
|
|
43
|
+
async function insertIncident(row: {
|
|
44
|
+
id: string;
|
|
45
|
+
status: string;
|
|
46
|
+
severity?: string;
|
|
47
|
+
description?: string | null;
|
|
48
|
+
systemIds: string[];
|
|
49
|
+
}): Promise<void> {
|
|
50
|
+
await pool.query(
|
|
51
|
+
`INSERT INTO "${SCHEMA}".incidents (id, title, description, status, severity)
|
|
52
|
+
VALUES ($1, $2, $3, $4, $5)`,
|
|
53
|
+
[
|
|
54
|
+
row.id,
|
|
55
|
+
`Incident ${row.id}`,
|
|
56
|
+
row.description ?? null,
|
|
57
|
+
row.status,
|
|
58
|
+
row.severity ?? "major",
|
|
59
|
+
],
|
|
60
|
+
);
|
|
61
|
+
for (const systemId of row.systemIds) {
|
|
62
|
+
await pool.query(
|
|
63
|
+
`INSERT INTO "${SCHEMA}".incident_systems (incident_id, system_id)
|
|
64
|
+
VALUES ($1, $2)`,
|
|
65
|
+
[row.id, systemId],
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function insertUpdate(row: {
|
|
71
|
+
id: string;
|
|
72
|
+
incidentId: string;
|
|
73
|
+
statusChange: string | null;
|
|
74
|
+
visibility: string;
|
|
75
|
+
createdAt: string;
|
|
76
|
+
}): Promise<void> {
|
|
77
|
+
await pool.query(
|
|
78
|
+
`INSERT INTO "${SCHEMA}".incident_updates
|
|
79
|
+
(id, incident_id, message, status_change, visibility, created_at)
|
|
80
|
+
VALUES ($1, $2, $3, $4, $5, $6)`,
|
|
81
|
+
[
|
|
82
|
+
row.id,
|
|
83
|
+
row.incidentId,
|
|
84
|
+
`msg-${row.id}`,
|
|
85
|
+
row.statusChange,
|
|
86
|
+
row.visibility,
|
|
87
|
+
row.createdAt,
|
|
88
|
+
],
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function insertLink(row: {
|
|
93
|
+
id: string;
|
|
94
|
+
incidentId: string;
|
|
95
|
+
url: string;
|
|
96
|
+
visibility: string;
|
|
97
|
+
}): Promise<void> {
|
|
98
|
+
await pool.query(
|
|
99
|
+
`INSERT INTO "${SCHEMA}".incident_links (id, incident_id, url, visibility)
|
|
100
|
+
VALUES ($1, $2, $3, $4)`,
|
|
101
|
+
[row.id, row.incidentId, row.url, row.visibility],
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
describe.skipIf(!process.env.CHECKSTACK_IT)(
|
|
106
|
+
"IncidentService batched/set-based reads (shared Postgres)",
|
|
107
|
+
() => {
|
|
108
|
+
beforeAll(async () => {
|
|
109
|
+
admin = new Pool({ connectionString: PG_URL });
|
|
110
|
+
await admin.query(`DROP SCHEMA IF EXISTS "${SCHEMA}" CASCADE`);
|
|
111
|
+
await admin.query(`CREATE SCHEMA "${SCHEMA}"`);
|
|
112
|
+
// Minimal DDL: enum-typed columns are plain text (the query builder only
|
|
113
|
+
// compares/filters on values), keeping the schema self-contained like the
|
|
114
|
+
// sibling *.it.test.ts files.
|
|
115
|
+
await admin.query(
|
|
116
|
+
`CREATE TABLE "${SCHEMA}".incidents (
|
|
117
|
+
id text PRIMARY KEY,
|
|
118
|
+
title text NOT NULL,
|
|
119
|
+
description text,
|
|
120
|
+
status text NOT NULL DEFAULT 'investigating',
|
|
121
|
+
severity text NOT NULL DEFAULT 'major',
|
|
122
|
+
suppress_notifications boolean NOT NULL DEFAULT false,
|
|
123
|
+
health_override text,
|
|
124
|
+
created_at timestamp NOT NULL DEFAULT now(),
|
|
125
|
+
updated_at timestamp NOT NULL DEFAULT now()
|
|
126
|
+
)`,
|
|
127
|
+
);
|
|
128
|
+
await admin.query(
|
|
129
|
+
`CREATE TABLE "${SCHEMA}".incident_systems (
|
|
130
|
+
incident_id text NOT NULL,
|
|
131
|
+
system_id text NOT NULL,
|
|
132
|
+
PRIMARY KEY (incident_id, system_id)
|
|
133
|
+
)`,
|
|
134
|
+
);
|
|
135
|
+
await admin.query(
|
|
136
|
+
`CREATE TABLE "${SCHEMA}".incident_updates (
|
|
137
|
+
id text PRIMARY KEY,
|
|
138
|
+
incident_id text NOT NULL,
|
|
139
|
+
message text NOT NULL,
|
|
140
|
+
status_change text,
|
|
141
|
+
visibility text NOT NULL DEFAULT 'public',
|
|
142
|
+
created_at timestamp NOT NULL DEFAULT now(),
|
|
143
|
+
edited_at timestamp,
|
|
144
|
+
edit_history jsonb NOT NULL DEFAULT '[]'::jsonb,
|
|
145
|
+
created_by text
|
|
146
|
+
)`,
|
|
147
|
+
);
|
|
148
|
+
await admin.query(
|
|
149
|
+
`CREATE TABLE "${SCHEMA}".incident_links (
|
|
150
|
+
id text PRIMARY KEY,
|
|
151
|
+
incident_id text NOT NULL,
|
|
152
|
+
label text,
|
|
153
|
+
url text NOT NULL,
|
|
154
|
+
visibility text NOT NULL DEFAULT 'public',
|
|
155
|
+
created_at timestamp NOT NULL DEFAULT now()
|
|
156
|
+
)`,
|
|
157
|
+
);
|
|
158
|
+
pool = new Pool({
|
|
159
|
+
connectionString: PG_URL,
|
|
160
|
+
options: `-c search_path=${SCHEMA}`,
|
|
161
|
+
});
|
|
162
|
+
const db = drizzle(pool, {
|
|
163
|
+
schema,
|
|
164
|
+
}) as unknown as SafeDatabase<typeof schema>;
|
|
165
|
+
service = new IncidentService(db, noopAdvisoryLock);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
afterAll(async () => {
|
|
169
|
+
await pool?.end();
|
|
170
|
+
await admin.query(`DROP SCHEMA IF EXISTS "${SCHEMA}" CASCADE`);
|
|
171
|
+
await admin.end();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
beforeEach(async () => {
|
|
175
|
+
await pool.query(`TRUNCATE "${SCHEMA}".incident_links`);
|
|
176
|
+
await pool.query(`TRUNCATE "${SCHEMA}".incident_updates`);
|
|
177
|
+
await pool.query(`TRUNCATE "${SCHEMA}".incident_systems`);
|
|
178
|
+
await pool.query(`TRUNCATE "${SCHEMA}".incidents CASCADE`);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("getIncident assembles systems, the FULL timeline, and links from one tx", async () => {
|
|
182
|
+
await insertIncident({
|
|
183
|
+
id: "inc-1",
|
|
184
|
+
status: "investigating",
|
|
185
|
+
description: null,
|
|
186
|
+
systemIds: ["sys-b", "sys-a"],
|
|
187
|
+
});
|
|
188
|
+
await insertUpdate({
|
|
189
|
+
id: "u-pub",
|
|
190
|
+
incidentId: "inc-1",
|
|
191
|
+
statusChange: "investigating",
|
|
192
|
+
visibility: "public",
|
|
193
|
+
createdAt: "2026-06-01T00:00:00Z",
|
|
194
|
+
});
|
|
195
|
+
// An `internal` update must still be returned by the service (audience
|
|
196
|
+
// filtering happens in the router read layer, not the service).
|
|
197
|
+
await insertUpdate({
|
|
198
|
+
id: "u-int",
|
|
199
|
+
incidentId: "inc-1",
|
|
200
|
+
statusChange: null,
|
|
201
|
+
visibility: "internal",
|
|
202
|
+
createdAt: "2026-06-01T01:00:00Z",
|
|
203
|
+
});
|
|
204
|
+
await insertLink({
|
|
205
|
+
id: "lnk-1",
|
|
206
|
+
incidentId: "inc-1",
|
|
207
|
+
url: "https://a",
|
|
208
|
+
visibility: "public",
|
|
209
|
+
});
|
|
210
|
+
await insertLink({
|
|
211
|
+
id: "lnk-2",
|
|
212
|
+
incidentId: "inc-1",
|
|
213
|
+
url: "https://b",
|
|
214
|
+
visibility: "internal",
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
const detail = await service.getIncident("inc-1");
|
|
218
|
+
|
|
219
|
+
expect(detail?.systemIds.toSorted()).toEqual(["sys-a", "sys-b"]);
|
|
220
|
+
expect(detail?.description).toBeUndefined();
|
|
221
|
+
expect(detail?.updates.map((u) => u.id).toSorted()).toEqual([
|
|
222
|
+
"u-int",
|
|
223
|
+
"u-pub",
|
|
224
|
+
]);
|
|
225
|
+
expect(detail?.links.map((l) => l.id).toSorted()).toEqual([
|
|
226
|
+
"lnk-1",
|
|
227
|
+
"lnk-2",
|
|
228
|
+
]);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("getIncident returns undefined for a missing id", async () => {
|
|
232
|
+
expect(await service.getIncident("ghost")).toBeUndefined();
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("getBulkIncidentUpdates returns per-id updates equivalent to getIncident", async () => {
|
|
236
|
+
// inc-1: two updates (public + internal); inc-2: one; inc-3: none.
|
|
237
|
+
await insertIncident({ id: "inc-1", status: "investigating", systemIds: ["sys-a"] });
|
|
238
|
+
await insertIncident({ id: "inc-2", status: "monitoring", systemIds: ["sys-b"] });
|
|
239
|
+
await insertIncident({ id: "inc-3", status: "investigating", systemIds: ["sys-c"] });
|
|
240
|
+
await insertUpdate({
|
|
241
|
+
id: "u-1a",
|
|
242
|
+
incidentId: "inc-1",
|
|
243
|
+
statusChange: "investigating",
|
|
244
|
+
visibility: "public",
|
|
245
|
+
createdAt: "2026-06-01T00:00:00Z",
|
|
246
|
+
});
|
|
247
|
+
await insertUpdate({
|
|
248
|
+
id: "u-1b",
|
|
249
|
+
incidentId: "inc-1",
|
|
250
|
+
statusChange: null,
|
|
251
|
+
visibility: "internal",
|
|
252
|
+
createdAt: "2026-06-01T01:00:00Z",
|
|
253
|
+
});
|
|
254
|
+
await insertUpdate({
|
|
255
|
+
id: "u-2a",
|
|
256
|
+
incidentId: "inc-2",
|
|
257
|
+
statusChange: "monitoring",
|
|
258
|
+
visibility: "public",
|
|
259
|
+
createdAt: "2026-06-01T02:00:00Z",
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
const ids = ["inc-1", "inc-2", "inc-3"];
|
|
263
|
+
const bulk = await service.getBulkIncidentUpdates(ids);
|
|
264
|
+
|
|
265
|
+
// Each id's bulk result equals the single-endpoint updates (order-agnostic).
|
|
266
|
+
const byId = (list: { id: string }[]) => list.map((u) => u.id).toSorted();
|
|
267
|
+
for (const id of ids) {
|
|
268
|
+
const single = (await service.getIncident(id))?.updates ?? [];
|
|
269
|
+
expect(byId(bulk[id] ?? [])).toEqual(byId(single));
|
|
270
|
+
// Full-record equivalence (sorted by update id) for the populated ones.
|
|
271
|
+
const sortById = <T extends { id: string }>(l: T[]) =>
|
|
272
|
+
[...l].toSorted((a, b) => a.id.localeCompare(b.id));
|
|
273
|
+
expect(sortById(bulk[id] ?? [])).toEqual(sortById(single));
|
|
274
|
+
}
|
|
275
|
+
// Incidents with no updates are omitted from the record.
|
|
276
|
+
expect(bulk["inc-3"]).toBeUndefined();
|
|
277
|
+
expect(Object.keys(bulk).toSorted()).toEqual(["inc-1", "inc-2"]);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it("getBulkIncidentUpdates returns an empty map for an empty request", async () => {
|
|
281
|
+
expect(await service.getBulkIncidentUpdates([])).toEqual({});
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("listIncidents groups full membership per incident with a single junction read", async () => {
|
|
285
|
+
await insertIncident({
|
|
286
|
+
id: "inc-1",
|
|
287
|
+
status: "investigating",
|
|
288
|
+
systemIds: ["sys-a", "sys-b"],
|
|
289
|
+
});
|
|
290
|
+
await insertIncident({
|
|
291
|
+
id: "inc-2",
|
|
292
|
+
status: "monitoring",
|
|
293
|
+
description: "elevated latency",
|
|
294
|
+
systemIds: ["sys-c"],
|
|
295
|
+
});
|
|
296
|
+
// Resolved incident is hidden by default.
|
|
297
|
+
await insertIncident({
|
|
298
|
+
id: "inc-3",
|
|
299
|
+
status: "resolved",
|
|
300
|
+
systemIds: ["sys-a"],
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
const openOnly = await service.listIncidents();
|
|
304
|
+
expect(openOnly.map((i) => i.id).toSorted()).toEqual(["inc-1", "inc-2"]);
|
|
305
|
+
const inc1 = openOnly.find((i) => i.id === "inc-1");
|
|
306
|
+
expect(inc1?.systemIds.toSorted()).toEqual(["sys-a", "sys-b"]);
|
|
307
|
+
expect(
|
|
308
|
+
openOnly.find((i) => i.id === "inc-2")?.description,
|
|
309
|
+
).toBe("elevated latency");
|
|
310
|
+
|
|
311
|
+
// includeResolved surfaces the resolved incident too.
|
|
312
|
+
const all = await service.listIncidents({ includeResolved: true });
|
|
313
|
+
expect(all.map((i) => i.id).toSorted()).toEqual([
|
|
314
|
+
"inc-1",
|
|
315
|
+
"inc-2",
|
|
316
|
+
"inc-3",
|
|
317
|
+
]);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("getIncidentsForSystem returns non-resolved incidents with full membership", async () => {
|
|
321
|
+
await insertIncident({
|
|
322
|
+
id: "inc-1",
|
|
323
|
+
status: "investigating",
|
|
324
|
+
systemIds: ["sys-a", "sys-b"],
|
|
325
|
+
});
|
|
326
|
+
await insertIncident({
|
|
327
|
+
id: "inc-2",
|
|
328
|
+
status: "monitoring",
|
|
329
|
+
systemIds: ["sys-a"],
|
|
330
|
+
});
|
|
331
|
+
// Resolved incident on sys-a is excluded.
|
|
332
|
+
await insertIncident({
|
|
333
|
+
id: "inc-3",
|
|
334
|
+
status: "resolved",
|
|
335
|
+
systemIds: ["sys-a"],
|
|
336
|
+
});
|
|
337
|
+
// Different system, must not leak in.
|
|
338
|
+
await insertIncident({
|
|
339
|
+
id: "inc-4",
|
|
340
|
+
status: "investigating",
|
|
341
|
+
systemIds: ["sys-z"],
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
const forA = await service.getIncidentsForSystem("sys-a");
|
|
345
|
+
|
|
346
|
+
expect(forA.map((i) => i.id).toSorted()).toEqual(["inc-1", "inc-2"]);
|
|
347
|
+
// The multi-system incident reports its FULL membership under sys-a.
|
|
348
|
+
expect(
|
|
349
|
+
forA.find((i) => i.id === "inc-1")?.systemIds.toSorted(),
|
|
350
|
+
).toEqual(["sys-a", "sys-b"]);
|
|
351
|
+
});
|
|
352
|
+
},
|
|
353
|
+
);
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration test for IncidentService.editUpdate / deleteUpdate status
|
|
3
|
+
* re-derivation against a REAL Postgres. The mocked-DB unit tests cannot prove
|
|
4
|
+
* the transactional recompute of `incidents.status` from the timeline, which is
|
|
5
|
+
* exactly the header/timeline-divergence class this feature exists to prevent.
|
|
6
|
+
*
|
|
7
|
+
* "Current status" = the `statusChange` of the MOST RECENT status-bearing
|
|
8
|
+
* update (message-only updates ignored). These tests prove:
|
|
9
|
+
* - deleting the latest status-bearing update re-derives the header to the
|
|
10
|
+
* prior status-bearing update;
|
|
11
|
+
* - deleting a non-latest status-bearing update, or a message-only update,
|
|
12
|
+
* does NOT change the header;
|
|
13
|
+
* - editing the statusChange of the latest status-bearing update updates the
|
|
14
|
+
* header, even when the latest ROW is a message-only update (the
|
|
15
|
+
* message-only-latest edge case).
|
|
16
|
+
*
|
|
17
|
+
* Gated on CHECKSTACK_IT so it runs in CI (shared compose Postgres) and is
|
|
18
|
+
* skipped in the default `bun test` run, matching the other *.it.test.ts here.
|
|
19
|
+
*/
|
|
20
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test";
|
|
21
|
+
import { drizzle } from "drizzle-orm/node-postgres";
|
|
22
|
+
import { Pool } from "pg";
|
|
23
|
+
import type {
|
|
24
|
+
AdvisoryLockService,
|
|
25
|
+
SafeDatabase,
|
|
26
|
+
} from "@checkstack/backend-api";
|
|
27
|
+
import * as schema from "./schema";
|
|
28
|
+
import { IncidentService } from "./service";
|
|
29
|
+
|
|
30
|
+
const PG_URL =
|
|
31
|
+
process.env.CHECKSTACK_IT_PG_URL ??
|
|
32
|
+
"postgres://postgres:postgres@localhost:5432/postgres";
|
|
33
|
+
const SCHEMA = "incident_it_updates";
|
|
34
|
+
|
|
35
|
+
const noopAdvisoryLock: AdvisoryLockService = {
|
|
36
|
+
tryAcquire: async () => null,
|
|
37
|
+
withXactLock: async ({ fn }) => fn(),
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
let admin: Pool;
|
|
41
|
+
let pool: Pool;
|
|
42
|
+
let service: IncidentService;
|
|
43
|
+
|
|
44
|
+
async function insertIncident(id: string, status: string): Promise<void> {
|
|
45
|
+
await pool.query(
|
|
46
|
+
`INSERT INTO "${SCHEMA}".incidents (id, status) VALUES ($1, $2)`,
|
|
47
|
+
[id, status],
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function insertUpdate(row: {
|
|
52
|
+
id: string;
|
|
53
|
+
incidentId: string;
|
|
54
|
+
statusChange: string | null;
|
|
55
|
+
createdAt: string;
|
|
56
|
+
}): Promise<void> {
|
|
57
|
+
await pool.query(
|
|
58
|
+
`INSERT INTO "${SCHEMA}".incident_updates
|
|
59
|
+
(id, incident_id, message, status_change, created_at)
|
|
60
|
+
VALUES ($1, $2, $3, $4, $5)`,
|
|
61
|
+
[row.id, row.incidentId, "msg", row.statusChange, row.createdAt],
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function headerStatus(id: string): Promise<string> {
|
|
66
|
+
const res = await pool.query(
|
|
67
|
+
`SELECT status FROM "${SCHEMA}".incidents WHERE id = $1`,
|
|
68
|
+
[id],
|
|
69
|
+
);
|
|
70
|
+
return res.rows[0]?.status as string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function updateRow(id: string): Promise<{
|
|
74
|
+
message: string;
|
|
75
|
+
editedAt: Date | null;
|
|
76
|
+
editHistory: Array<{ message: string; createdAt: string; editedAt: string }>;
|
|
77
|
+
}> {
|
|
78
|
+
const res = await pool.query(
|
|
79
|
+
`SELECT message, edited_at, edit_history
|
|
80
|
+
FROM "${SCHEMA}".incident_updates WHERE id = $1`,
|
|
81
|
+
[id],
|
|
82
|
+
);
|
|
83
|
+
const row = res.rows[0];
|
|
84
|
+
return {
|
|
85
|
+
message: row.message as string,
|
|
86
|
+
editedAt: row.edited_at as Date | null,
|
|
87
|
+
editHistory: row.edit_history as Array<{
|
|
88
|
+
message: string;
|
|
89
|
+
createdAt: string;
|
|
90
|
+
editedAt: string;
|
|
91
|
+
}>,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
describe.skipIf(!process.env.CHECKSTACK_IT)(
|
|
96
|
+
"IncidentService edit/delete status re-derivation (shared Postgres)",
|
|
97
|
+
() => {
|
|
98
|
+
beforeAll(async () => {
|
|
99
|
+
admin = new Pool({ connectionString: PG_URL });
|
|
100
|
+
await admin.query(`DROP SCHEMA IF EXISTS "${SCHEMA}" CASCADE`);
|
|
101
|
+
await admin.query(`CREATE SCHEMA "${SCHEMA}"`);
|
|
102
|
+
// Minimal DDL: only the columns edit/delete touch. `status` is text (not
|
|
103
|
+
// the enum) so the test schema stays self-contained; the service issues
|
|
104
|
+
// partial updates that reference only these columns.
|
|
105
|
+
await admin.query(
|
|
106
|
+
`CREATE TABLE "${SCHEMA}".incidents (
|
|
107
|
+
id text PRIMARY KEY,
|
|
108
|
+
status text NOT NULL DEFAULT 'investigating',
|
|
109
|
+
updated_at timestamp NOT NULL DEFAULT now()
|
|
110
|
+
)`,
|
|
111
|
+
);
|
|
112
|
+
await admin.query(
|
|
113
|
+
`CREATE TABLE "${SCHEMA}".incident_updates (
|
|
114
|
+
id text PRIMARY KEY,
|
|
115
|
+
incident_id text NOT NULL,
|
|
116
|
+
message text NOT NULL,
|
|
117
|
+
status_change text,
|
|
118
|
+
visibility text NOT NULL DEFAULT 'public',
|
|
119
|
+
created_at timestamp NOT NULL DEFAULT now(),
|
|
120
|
+
edited_at timestamp,
|
|
121
|
+
edit_history jsonb NOT NULL DEFAULT '[]'::jsonb,
|
|
122
|
+
created_by text
|
|
123
|
+
)`,
|
|
124
|
+
);
|
|
125
|
+
pool = new Pool({
|
|
126
|
+
connectionString: PG_URL,
|
|
127
|
+
options: `-c search_path=${SCHEMA}`,
|
|
128
|
+
});
|
|
129
|
+
const db = drizzle(pool, {
|
|
130
|
+
schema,
|
|
131
|
+
}) as unknown as SafeDatabase<typeof schema>;
|
|
132
|
+
service = new IncidentService(db, noopAdvisoryLock);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
afterAll(async () => {
|
|
136
|
+
await pool?.end();
|
|
137
|
+
await admin.query(`DROP SCHEMA IF EXISTS "${SCHEMA}" CASCADE`);
|
|
138
|
+
await admin.end();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
beforeEach(async () => {
|
|
142
|
+
await pool.query(`TRUNCATE "${SCHEMA}".incidents`);
|
|
143
|
+
await pool.query(`TRUNCATE "${SCHEMA}".incident_updates`);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("deleting the LATEST status-bearing update re-derives the header", async () => {
|
|
147
|
+
await insertIncident("inc", "identified");
|
|
148
|
+
await insertUpdate({
|
|
149
|
+
id: "u1",
|
|
150
|
+
incidentId: "inc",
|
|
151
|
+
statusChange: "investigating",
|
|
152
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
153
|
+
});
|
|
154
|
+
await insertUpdate({
|
|
155
|
+
id: "u2",
|
|
156
|
+
incidentId: "inc",
|
|
157
|
+
statusChange: "identified",
|
|
158
|
+
createdAt: "2026-01-01T01:00:00Z",
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
expect(await service.deleteUpdate("u2", "inc")).toBe("inc");
|
|
162
|
+
// Header falls back to the remaining status-bearing update (u1).
|
|
163
|
+
expect(await headerStatus("inc")).toBe("investigating");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("deleting a NON-latest status-bearing update leaves the header", async () => {
|
|
167
|
+
await insertIncident("inc", "identified");
|
|
168
|
+
await insertUpdate({
|
|
169
|
+
id: "u1",
|
|
170
|
+
incidentId: "inc",
|
|
171
|
+
statusChange: "investigating",
|
|
172
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
173
|
+
});
|
|
174
|
+
await insertUpdate({
|
|
175
|
+
id: "u2",
|
|
176
|
+
incidentId: "inc",
|
|
177
|
+
statusChange: "identified",
|
|
178
|
+
createdAt: "2026-01-01T01:00:00Z",
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// Delete the OLDER status-bearing update; the newest status (u2) still
|
|
182
|
+
// defines the header.
|
|
183
|
+
expect(await service.deleteUpdate("u1", "inc")).toBe("inc");
|
|
184
|
+
expect(await headerStatus("inc")).toBe("identified");
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("deleting a MESSAGE-ONLY update never changes the header", async () => {
|
|
188
|
+
await insertIncident("inc", "identified");
|
|
189
|
+
await insertUpdate({
|
|
190
|
+
id: "u1",
|
|
191
|
+
incidentId: "inc",
|
|
192
|
+
statusChange: "identified",
|
|
193
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
194
|
+
});
|
|
195
|
+
await insertUpdate({
|
|
196
|
+
id: "u2",
|
|
197
|
+
incidentId: "inc",
|
|
198
|
+
statusChange: null,
|
|
199
|
+
createdAt: "2026-01-01T01:00:00Z",
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
expect(await service.deleteUpdate("u2", "inc")).toBe("inc");
|
|
203
|
+
expect(await headerStatus("inc")).toBe("identified");
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("editing the latest status-bearing update updates the header (message-only latest edge case)", async () => {
|
|
207
|
+
await insertIncident("inc", "identified");
|
|
208
|
+
await insertUpdate({
|
|
209
|
+
id: "u1",
|
|
210
|
+
incidentId: "inc",
|
|
211
|
+
statusChange: "investigating",
|
|
212
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
213
|
+
});
|
|
214
|
+
await insertUpdate({
|
|
215
|
+
id: "u2",
|
|
216
|
+
incidentId: "inc",
|
|
217
|
+
statusChange: "identified",
|
|
218
|
+
createdAt: "2026-01-01T01:00:00Z",
|
|
219
|
+
});
|
|
220
|
+
// The LATEST ROW is message-only, so a "latest row" heuristic would edit
|
|
221
|
+
// the wrong record; the header must follow the latest STATUS-BEARING one.
|
|
222
|
+
await insertUpdate({
|
|
223
|
+
id: "u3",
|
|
224
|
+
incidentId: "inc",
|
|
225
|
+
statusChange: null,
|
|
226
|
+
createdAt: "2026-01-01T02:00:00Z",
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
await service.editUpdate({
|
|
230
|
+
id: "u2",
|
|
231
|
+
incidentId: "inc",
|
|
232
|
+
statusChange: "fixing",
|
|
233
|
+
});
|
|
234
|
+
expect(await headerStatus("inc")).toBe("fixing");
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("deleting the latest status-bearing update behind a message-only latest row re-derives correctly", async () => {
|
|
238
|
+
await insertIncident("inc", "identified");
|
|
239
|
+
await insertUpdate({
|
|
240
|
+
id: "u1",
|
|
241
|
+
incidentId: "inc",
|
|
242
|
+
statusChange: "investigating",
|
|
243
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
244
|
+
});
|
|
245
|
+
await insertUpdate({
|
|
246
|
+
id: "u2",
|
|
247
|
+
incidentId: "inc",
|
|
248
|
+
statusChange: "identified",
|
|
249
|
+
createdAt: "2026-01-01T01:00:00Z",
|
|
250
|
+
});
|
|
251
|
+
await insertUpdate({
|
|
252
|
+
id: "u3",
|
|
253
|
+
incidentId: "inc",
|
|
254
|
+
statusChange: null,
|
|
255
|
+
createdAt: "2026-01-01T02:00:00Z",
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
// Delete the latest status-bearing update (u2); the header must fall back
|
|
259
|
+
// to u1's status even though a newer (message-only) row remains.
|
|
260
|
+
expect(await service.deleteUpdate("u2", "inc")).toBe("inc");
|
|
261
|
+
expect(await headerStatus("inc")).toBe("investigating");
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("archives the prior version into edit_history and stamps edited_at on a real change", async () => {
|
|
265
|
+
await insertIncident("inc", "investigating");
|
|
266
|
+
await insertUpdate({
|
|
267
|
+
id: "u1",
|
|
268
|
+
incidentId: "inc",
|
|
269
|
+
statusChange: "investigating",
|
|
270
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
const before = await updateRow("u1");
|
|
274
|
+
expect(before.editedAt).toBeNull();
|
|
275
|
+
expect(before.editHistory).toEqual([]);
|
|
276
|
+
|
|
277
|
+
await service.editUpdate({
|
|
278
|
+
id: "u1",
|
|
279
|
+
incidentId: "inc",
|
|
280
|
+
message: "corrected wording",
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
const after = await updateRow("u1");
|
|
284
|
+
expect(after.message).toBe("corrected wording");
|
|
285
|
+
expect(after.editedAt).not.toBeNull();
|
|
286
|
+
// The pre-edit message ("msg") is archived as the first snapshot.
|
|
287
|
+
expect(after.editHistory).toHaveLength(1);
|
|
288
|
+
expect(after.editHistory[0].message).toBe("msg");
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("a no-op edit (unchanged fields) neither archives nor stamps edited_at", async () => {
|
|
292
|
+
await insertIncident("inc", "investigating");
|
|
293
|
+
await insertUpdate({
|
|
294
|
+
id: "u1",
|
|
295
|
+
incidentId: "inc",
|
|
296
|
+
statusChange: "investigating",
|
|
297
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
// Re-send the SAME message + status; nothing actually changes.
|
|
301
|
+
await service.editUpdate({
|
|
302
|
+
id: "u1",
|
|
303
|
+
incidentId: "inc",
|
|
304
|
+
message: "msg",
|
|
305
|
+
statusChange: "investigating",
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
const after = await updateRow("u1");
|
|
309
|
+
expect(after.editedAt).toBeNull();
|
|
310
|
+
expect(after.editHistory).toEqual([]);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it("re-timing an update (createdAt) re-derives the header even without a status edit", async () => {
|
|
314
|
+
await insertIncident("inc", "identified");
|
|
315
|
+
await insertUpdate({
|
|
316
|
+
id: "u1",
|
|
317
|
+
incidentId: "inc",
|
|
318
|
+
statusChange: "investigating",
|
|
319
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
320
|
+
});
|
|
321
|
+
await insertUpdate({
|
|
322
|
+
id: "u2",
|
|
323
|
+
incidentId: "inc",
|
|
324
|
+
statusChange: "identified",
|
|
325
|
+
createdAt: "2026-01-01T01:00:00Z",
|
|
326
|
+
});
|
|
327
|
+
// Header currently follows u2 (the latest status-bearing update).
|
|
328
|
+
expect(await headerStatus("inc")).toBe("identified");
|
|
329
|
+
|
|
330
|
+
// Move u1 AFTER u2 without touching its status; u1 is now the latest
|
|
331
|
+
// status-bearing update, so the header must fall back to its status.
|
|
332
|
+
await service.editUpdate({
|
|
333
|
+
id: "u1",
|
|
334
|
+
incidentId: "inc",
|
|
335
|
+
createdAt: new Date("2026-01-01T02:00:00Z"),
|
|
336
|
+
});
|
|
337
|
+
expect(await headerStatus("inc")).toBe("investigating");
|
|
338
|
+
});
|
|
339
|
+
},
|
|
340
|
+
);
|