@checkstack/slo-backend 0.2.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 +41 -0
- package/drizzle/0000_rainy_kronos.sql +57 -0
- package/drizzle/meta/0000_snapshot.json +370 -0
- package/drizzle/meta/_journal.json +13 -0
- package/drizzle.config.ts +7 -0
- package/package.json +41 -0
- package/src/achievement-evaluator.ts +201 -0
- package/src/hooks.ts +76 -0
- package/src/index.ts +425 -0
- package/src/router.ts +192 -0
- package/src/schema.ts +120 -0
- package/src/service.ts +682 -0
- package/src/slo-engine.test.ts +662 -0
- package/src/slo-engine.ts +425 -0
- package/src/streak-calculator.ts +107 -0
- package/src/weekly-digest.ts +140 -0
- package/tsconfig.json +6 -0
package/src/service.ts
ADDED
|
@@ -0,0 +1,682 @@
|
|
|
1
|
+
import { eq, and, isNull, desc, gte, lte } from "drizzle-orm";
|
|
2
|
+
import type { SafeDatabase } from "@checkstack/backend-api";
|
|
3
|
+
import * as schema from "./schema";
|
|
4
|
+
import {
|
|
5
|
+
sloObjectives,
|
|
6
|
+
sloDowntimeEvents,
|
|
7
|
+
sloDailySnapshots,
|
|
8
|
+
sloStreaks,
|
|
9
|
+
sloAchievements,
|
|
10
|
+
} from "./schema";
|
|
11
|
+
import type {
|
|
12
|
+
CreateSloObjectiveInput,
|
|
13
|
+
UpdateSloObjectiveInput,
|
|
14
|
+
SloObjective,
|
|
15
|
+
SloDowntimeEvent,
|
|
16
|
+
SloDailySnapshot,
|
|
17
|
+
SloStreak,
|
|
18
|
+
SloAchievement,
|
|
19
|
+
AttributionType,
|
|
20
|
+
} from "@checkstack/slo-common";
|
|
21
|
+
|
|
22
|
+
type Db = SafeDatabase<typeof schema>;
|
|
23
|
+
|
|
24
|
+
function generateId(): string {
|
|
25
|
+
return crypto.randomUUID();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class SloService {
|
|
29
|
+
constructor(private db: Db) {}
|
|
30
|
+
|
|
31
|
+
// ===========================================================================
|
|
32
|
+
// OBJECTIVES CRUD
|
|
33
|
+
// ===========================================================================
|
|
34
|
+
|
|
35
|
+
async listObjectives(): Promise<SloObjective[]> {
|
|
36
|
+
const rows = await this.db.select().from(sloObjectives);
|
|
37
|
+
return rows.map((row) => mapObjectiveRow(row));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async getObjective({
|
|
41
|
+
id,
|
|
42
|
+
}: {
|
|
43
|
+
id: string;
|
|
44
|
+
}): Promise<SloObjective | undefined> {
|
|
45
|
+
const [row] = await this.db
|
|
46
|
+
.select()
|
|
47
|
+
.from(sloObjectives)
|
|
48
|
+
.where(eq(sloObjectives.id, id));
|
|
49
|
+
return row ? mapObjectiveRow(row) : undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async getObjectivesForSystem({
|
|
53
|
+
systemId,
|
|
54
|
+
}: {
|
|
55
|
+
systemId: string;
|
|
56
|
+
}): Promise<SloObjective[]> {
|
|
57
|
+
const rows = await this.db
|
|
58
|
+
.select()
|
|
59
|
+
.from(sloObjectives)
|
|
60
|
+
.where(eq(sloObjectives.systemId, systemId));
|
|
61
|
+
return rows.map((row) => mapObjectiveRow(row));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async createObjective({
|
|
65
|
+
input,
|
|
66
|
+
}: {
|
|
67
|
+
input: CreateSloObjectiveInput;
|
|
68
|
+
}): Promise<SloObjective> {
|
|
69
|
+
const id = generateId();
|
|
70
|
+
const now = new Date();
|
|
71
|
+
|
|
72
|
+
await this.db.insert(sloObjectives).values({
|
|
73
|
+
id,
|
|
74
|
+
systemId: input.systemId,
|
|
75
|
+
// eslint-disable-next-line unicorn/no-null -- Drizzle requires null for nullable columns
|
|
76
|
+
healthCheckConfigurationId: input.healthCheckConfigurationId ?? null,
|
|
77
|
+
target: input.target,
|
|
78
|
+
windowDays: input.windowDays,
|
|
79
|
+
dependencyExclusion: input.dependencyExclusion ?? "strict",
|
|
80
|
+
excludedDependencyIds: input.excludedDependencyIds ?? [],
|
|
81
|
+
burnRateWarningPercent: input.burnRateThresholds?.warningPercent ?? 50,
|
|
82
|
+
burnRateCriticalPercent: input.burnRateThresholds?.criticalPercent ?? 80,
|
|
83
|
+
burnRateFastBurnMultiplier:
|
|
84
|
+
input.burnRateThresholds?.fastBurnMultiplier ?? 5,
|
|
85
|
+
createdAt: now,
|
|
86
|
+
updatedAt: now,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// Create initial streak record
|
|
90
|
+
await this.db.insert(sloStreaks).values({
|
|
91
|
+
objectiveId: id,
|
|
92
|
+
systemId: input.systemId,
|
|
93
|
+
currentStreak: 0,
|
|
94
|
+
bestStreak: 0,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
return (await this.getObjective({ id }))!;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async updateObjective({
|
|
101
|
+
input,
|
|
102
|
+
}: {
|
|
103
|
+
input: UpdateSloObjectiveInput;
|
|
104
|
+
}): Promise<SloObjective | undefined> {
|
|
105
|
+
const [existing] = await this.db
|
|
106
|
+
.select()
|
|
107
|
+
.from(sloObjectives)
|
|
108
|
+
.where(eq(sloObjectives.id, input.id));
|
|
109
|
+
|
|
110
|
+
if (!existing) return undefined;
|
|
111
|
+
|
|
112
|
+
const updateData: Partial<typeof sloObjectives.$inferInsert> = {
|
|
113
|
+
updatedAt: new Date(),
|
|
114
|
+
};
|
|
115
|
+
if (input.target !== undefined) updateData.target = input.target;
|
|
116
|
+
if (input.windowDays !== undefined) updateData.windowDays = input.windowDays;
|
|
117
|
+
if (input.dependencyExclusion !== undefined)
|
|
118
|
+
updateData.dependencyExclusion = input.dependencyExclusion;
|
|
119
|
+
if (input.excludedDependencyIds !== undefined)
|
|
120
|
+
updateData.excludedDependencyIds = input.excludedDependencyIds;
|
|
121
|
+
if (input.burnRateThresholds !== undefined) {
|
|
122
|
+
updateData.burnRateWarningPercent =
|
|
123
|
+
input.burnRateThresholds.warningPercent;
|
|
124
|
+
updateData.burnRateCriticalPercent =
|
|
125
|
+
input.burnRateThresholds.criticalPercent;
|
|
126
|
+
updateData.burnRateFastBurnMultiplier =
|
|
127
|
+
input.burnRateThresholds.fastBurnMultiplier;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
await this.db
|
|
131
|
+
.update(sloObjectives)
|
|
132
|
+
.set(updateData)
|
|
133
|
+
.where(eq(sloObjectives.id, input.id));
|
|
134
|
+
|
|
135
|
+
return (await this.getObjective({ id: input.id }))!;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async deleteObjective({ id }: { id: string }): Promise<boolean> {
|
|
139
|
+
const [existing] = await this.db
|
|
140
|
+
.select()
|
|
141
|
+
.from(sloObjectives)
|
|
142
|
+
.where(eq(sloObjectives.id, id));
|
|
143
|
+
|
|
144
|
+
if (!existing) return false;
|
|
145
|
+
|
|
146
|
+
// Cascade delete handles downtime events, snapshots, streaks
|
|
147
|
+
await this.db.delete(sloObjectives).where(eq(sloObjectives.id, id));
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async deleteObjectivesForSystem({
|
|
152
|
+
systemId,
|
|
153
|
+
}: {
|
|
154
|
+
systemId: string;
|
|
155
|
+
}): Promise<void> {
|
|
156
|
+
await this.db
|
|
157
|
+
.delete(sloObjectives)
|
|
158
|
+
.where(eq(sloObjectives.systemId, systemId));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ===========================================================================
|
|
162
|
+
// DOWNTIME EVENTS
|
|
163
|
+
// ===========================================================================
|
|
164
|
+
|
|
165
|
+
async openDowntimeEvent({
|
|
166
|
+
objectiveId,
|
|
167
|
+
systemId,
|
|
168
|
+
attributionType,
|
|
169
|
+
upstreamSystemId,
|
|
170
|
+
upstreamSystemName,
|
|
171
|
+
}: {
|
|
172
|
+
objectiveId: string;
|
|
173
|
+
systemId: string;
|
|
174
|
+
attributionType: AttributionType;
|
|
175
|
+
upstreamSystemId?: string;
|
|
176
|
+
upstreamSystemName?: string;
|
|
177
|
+
}): Promise<SloDowntimeEvent> {
|
|
178
|
+
const id = generateId();
|
|
179
|
+
const now = new Date();
|
|
180
|
+
|
|
181
|
+
await this.db.insert(sloDowntimeEvents).values({
|
|
182
|
+
id,
|
|
183
|
+
objectiveId,
|
|
184
|
+
systemId,
|
|
185
|
+
startTime: now,
|
|
186
|
+
attributionType,
|
|
187
|
+
// eslint-disable-next-line unicorn/no-null -- Drizzle requires null for nullable columns
|
|
188
|
+
upstreamSystemId: upstreamSystemId ?? null,
|
|
189
|
+
// eslint-disable-next-line unicorn/no-null -- Drizzle requires null for nullable columns
|
|
190
|
+
upstreamSystemName: upstreamSystemName ?? null,
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
const [event] = await this.db
|
|
194
|
+
.select()
|
|
195
|
+
.from(sloDowntimeEvents)
|
|
196
|
+
.where(eq(sloDowntimeEvents.id, id));
|
|
197
|
+
|
|
198
|
+
return mapDowntimeEventRow(event);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async closeDowntimeEvent({ id }: { id: string }): Promise<SloDowntimeEvent> {
|
|
202
|
+
const [event] = await this.db
|
|
203
|
+
.select()
|
|
204
|
+
.from(sloDowntimeEvents)
|
|
205
|
+
.where(eq(sloDowntimeEvents.id, id));
|
|
206
|
+
|
|
207
|
+
if (!event || event.endTime) {
|
|
208
|
+
throw new Error(`Cannot close downtime event ${id}: not found or already closed`);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const now = new Date();
|
|
212
|
+
const durationSeconds =
|
|
213
|
+
(now.getTime() - event.startTime.getTime()) / 1000;
|
|
214
|
+
|
|
215
|
+
await this.db
|
|
216
|
+
.update(sloDowntimeEvents)
|
|
217
|
+
.set({ endTime: now, durationSeconds })
|
|
218
|
+
.where(eq(sloDowntimeEvents.id, id));
|
|
219
|
+
|
|
220
|
+
const [updated] = await this.db
|
|
221
|
+
.select()
|
|
222
|
+
.from(sloDowntimeEvents)
|
|
223
|
+
.where(eq(sloDowntimeEvents.id, id));
|
|
224
|
+
|
|
225
|
+
return mapDowntimeEventRow(updated);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async getOpenDowntimeEvents({
|
|
229
|
+
systemId,
|
|
230
|
+
}: {
|
|
231
|
+
systemId: string;
|
|
232
|
+
}): Promise<SloDowntimeEvent[]> {
|
|
233
|
+
const rows = await this.db
|
|
234
|
+
.select()
|
|
235
|
+
.from(sloDowntimeEvents)
|
|
236
|
+
.where(
|
|
237
|
+
and(
|
|
238
|
+
eq(sloDowntimeEvents.systemId, systemId),
|
|
239
|
+
isNull(sloDowntimeEvents.endTime),
|
|
240
|
+
),
|
|
241
|
+
);
|
|
242
|
+
return rows.map((row) => mapDowntimeEventRow(row));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async getOpenDowntimeEventsForObjective({
|
|
246
|
+
objectiveId,
|
|
247
|
+
}: {
|
|
248
|
+
objectiveId: string;
|
|
249
|
+
}): Promise<SloDowntimeEvent[]> {
|
|
250
|
+
const rows = await this.db
|
|
251
|
+
.select()
|
|
252
|
+
.from(sloDowntimeEvents)
|
|
253
|
+
.where(
|
|
254
|
+
and(
|
|
255
|
+
eq(sloDowntimeEvents.objectiveId, objectiveId),
|
|
256
|
+
isNull(sloDowntimeEvents.endTime),
|
|
257
|
+
),
|
|
258
|
+
);
|
|
259
|
+
return rows.map((row) => mapDowntimeEventRow(row));
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async getOpenUpstreamEvents({
|
|
263
|
+
systemId,
|
|
264
|
+
upstreamSystemId,
|
|
265
|
+
}: {
|
|
266
|
+
systemId: string;
|
|
267
|
+
upstreamSystemId: string;
|
|
268
|
+
}): Promise<SloDowntimeEvent[]> {
|
|
269
|
+
const rows = await this.db
|
|
270
|
+
.select()
|
|
271
|
+
.from(sloDowntimeEvents)
|
|
272
|
+
.where(
|
|
273
|
+
and(
|
|
274
|
+
eq(sloDowntimeEvents.systemId, systemId),
|
|
275
|
+
eq(sloDowntimeEvents.attributionType, "upstream"),
|
|
276
|
+
eq(sloDowntimeEvents.upstreamSystemId, upstreamSystemId),
|
|
277
|
+
isNull(sloDowntimeEvents.endTime),
|
|
278
|
+
),
|
|
279
|
+
);
|
|
280
|
+
return rows.map((row) => mapDowntimeEventRow(row));
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async getOpenSelfEvents({
|
|
284
|
+
systemId,
|
|
285
|
+
}: {
|
|
286
|
+
systemId: string;
|
|
287
|
+
}): Promise<SloDowntimeEvent[]> {
|
|
288
|
+
const rows = await this.db
|
|
289
|
+
.select()
|
|
290
|
+
.from(sloDowntimeEvents)
|
|
291
|
+
.where(
|
|
292
|
+
and(
|
|
293
|
+
eq(sloDowntimeEvents.systemId, systemId),
|
|
294
|
+
eq(sloDowntimeEvents.attributionType, "self"),
|
|
295
|
+
isNull(sloDowntimeEvents.endTime),
|
|
296
|
+
),
|
|
297
|
+
);
|
|
298
|
+
return rows.map((row) => mapDowntimeEventRow(row));
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Get budget consumption for an objective within a time window.
|
|
303
|
+
* Returns total consumed minutes and per-attribution-type breakdown.
|
|
304
|
+
*/
|
|
305
|
+
async getDowntimeForWindow({
|
|
306
|
+
objectiveId,
|
|
307
|
+
windowStart,
|
|
308
|
+
windowEnd,
|
|
309
|
+
}: {
|
|
310
|
+
objectiveId: string;
|
|
311
|
+
windowStart: Date;
|
|
312
|
+
windowEnd: Date;
|
|
313
|
+
}): Promise<{
|
|
314
|
+
totalMinutes: number;
|
|
315
|
+
selfMinutes: number;
|
|
316
|
+
upstreamMinutes: number;
|
|
317
|
+
entries: Array<{
|
|
318
|
+
attributionType: string;
|
|
319
|
+
upstreamSystemId: string | null;
|
|
320
|
+
upstreamSystemName: string | null;
|
|
321
|
+
totalMinutes: number;
|
|
322
|
+
}>;
|
|
323
|
+
}> {
|
|
324
|
+
// Get closed events within the window
|
|
325
|
+
const closedEvents = await this.db
|
|
326
|
+
.select()
|
|
327
|
+
.from(sloDowntimeEvents)
|
|
328
|
+
.where(
|
|
329
|
+
and(
|
|
330
|
+
eq(sloDowntimeEvents.objectiveId, objectiveId),
|
|
331
|
+
gte(sloDowntimeEvents.startTime, windowStart),
|
|
332
|
+
lte(sloDowntimeEvents.startTime, windowEnd),
|
|
333
|
+
),
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
// Also include open events (use current time as endTime for running duration)
|
|
337
|
+
const now = new Date();
|
|
338
|
+
let totalSeconds = 0;
|
|
339
|
+
let selfSeconds = 0;
|
|
340
|
+
let upstreamSeconds = 0;
|
|
341
|
+
const bySource = new Map<
|
|
342
|
+
string,
|
|
343
|
+
{
|
|
344
|
+
attributionType: string;
|
|
345
|
+
upstreamSystemId: string | null;
|
|
346
|
+
upstreamSystemName: string | null;
|
|
347
|
+
totalSeconds: number;
|
|
348
|
+
}
|
|
349
|
+
>();
|
|
350
|
+
|
|
351
|
+
for (const event of closedEvents) {
|
|
352
|
+
const duration =
|
|
353
|
+
event.durationSeconds ??
|
|
354
|
+
(((event.endTime ?? now).getTime() - event.startTime.getTime()) / 1000);
|
|
355
|
+
|
|
356
|
+
totalSeconds += duration;
|
|
357
|
+
if (event.attributionType === "self") {
|
|
358
|
+
selfSeconds += duration;
|
|
359
|
+
} else {
|
|
360
|
+
upstreamSeconds += duration;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const key =
|
|
364
|
+
event.attributionType === "self"
|
|
365
|
+
? "self"
|
|
366
|
+
: `upstream:${event.upstreamSystemId}`;
|
|
367
|
+
const existing = bySource.get(key);
|
|
368
|
+
if (existing) {
|
|
369
|
+
existing.totalSeconds += duration;
|
|
370
|
+
} else {
|
|
371
|
+
bySource.set(key, {
|
|
372
|
+
attributionType: event.attributionType,
|
|
373
|
+
upstreamSystemId: event.upstreamSystemId,
|
|
374
|
+
upstreamSystemName: event.upstreamSystemName,
|
|
375
|
+
totalSeconds: duration,
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
return {
|
|
381
|
+
totalMinutes: totalSeconds / 60,
|
|
382
|
+
selfMinutes: selfSeconds / 60,
|
|
383
|
+
upstreamMinutes: upstreamSeconds / 60,
|
|
384
|
+
entries: [...bySource.values()].map((e) => ({
|
|
385
|
+
...e,
|
|
386
|
+
totalMinutes: e.totalSeconds / 60,
|
|
387
|
+
})),
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
async getRecentDowntimeEvents({
|
|
392
|
+
objectiveId,
|
|
393
|
+
limit,
|
|
394
|
+
}: {
|
|
395
|
+
objectiveId: string;
|
|
396
|
+
limit: number;
|
|
397
|
+
}): Promise<SloDowntimeEvent[]> {
|
|
398
|
+
const rows = await this.db
|
|
399
|
+
.select()
|
|
400
|
+
.from(sloDowntimeEvents)
|
|
401
|
+
.where(eq(sloDowntimeEvents.objectiveId, objectiveId))
|
|
402
|
+
.orderBy(desc(sloDowntimeEvents.startTime))
|
|
403
|
+
.limit(limit);
|
|
404
|
+
return rows.map((row) => mapDowntimeEventRow(row));
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// ===========================================================================
|
|
408
|
+
// DAILY SNAPSHOTS
|
|
409
|
+
// ===========================================================================
|
|
410
|
+
|
|
411
|
+
async insertDailySnapshot({
|
|
412
|
+
snapshot,
|
|
413
|
+
}: {
|
|
414
|
+
snapshot: Omit<SloDailySnapshot, "id">;
|
|
415
|
+
}): Promise<void> {
|
|
416
|
+
await this.db.insert(sloDailySnapshots).values({
|
|
417
|
+
id: generateId(),
|
|
418
|
+
...snapshot,
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
async getDailySnapshots({
|
|
423
|
+
objectiveId,
|
|
424
|
+
startDate,
|
|
425
|
+
endDate,
|
|
426
|
+
}: {
|
|
427
|
+
objectiveId: string;
|
|
428
|
+
startDate: Date;
|
|
429
|
+
endDate: Date;
|
|
430
|
+
}): Promise<SloDailySnapshot[]> {
|
|
431
|
+
const rows = await this.db
|
|
432
|
+
.select()
|
|
433
|
+
.from(sloDailySnapshots)
|
|
434
|
+
.where(
|
|
435
|
+
and(
|
|
436
|
+
eq(sloDailySnapshots.objectiveId, objectiveId),
|
|
437
|
+
gte(sloDailySnapshots.date, startDate),
|
|
438
|
+
lte(sloDailySnapshots.date, endDate),
|
|
439
|
+
),
|
|
440
|
+
);
|
|
441
|
+
return rows.map((row) => mapSnapshotRow(row));
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// ===========================================================================
|
|
445
|
+
// STREAKS
|
|
446
|
+
// ===========================================================================
|
|
447
|
+
|
|
448
|
+
async getStreak({
|
|
449
|
+
objectiveId,
|
|
450
|
+
}: {
|
|
451
|
+
objectiveId: string;
|
|
452
|
+
}): Promise<SloStreak | undefined> {
|
|
453
|
+
const [row] = await this.db
|
|
454
|
+
.select()
|
|
455
|
+
.from(sloStreaks)
|
|
456
|
+
.where(eq(sloStreaks.objectiveId, objectiveId));
|
|
457
|
+
return row ? mapStreakRow(row) : undefined;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
async getAllStreaks(): Promise<SloStreak[]> {
|
|
461
|
+
const rows = await this.db.select().from(sloStreaks);
|
|
462
|
+
return rows.map((row) => mapStreakRow(row));
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
async incrementStreak({
|
|
466
|
+
objectiveId,
|
|
467
|
+
}: {
|
|
468
|
+
objectiveId: string;
|
|
469
|
+
}): Promise<SloStreak> {
|
|
470
|
+
const streak = await this.getStreak({ objectiveId });
|
|
471
|
+
if (!streak) throw new Error(`Streak not found for objective ${objectiveId}`);
|
|
472
|
+
|
|
473
|
+
const newCurrent = streak.currentStreak + 1;
|
|
474
|
+
const newBest = Math.max(newCurrent, streak.bestStreak);
|
|
475
|
+
const now = new Date();
|
|
476
|
+
|
|
477
|
+
await this.db
|
|
478
|
+
.update(sloStreaks)
|
|
479
|
+
.set({
|
|
480
|
+
currentStreak: newCurrent,
|
|
481
|
+
bestStreak: newBest,
|
|
482
|
+
streakStart: streak.streakStart ?? now,
|
|
483
|
+
// eslint-disable-next-line unicorn/no-null -- Drizzle requires null for nullable columns
|
|
484
|
+
bestStreakEnd: newCurrent > streak.bestStreak ? null : streak.bestStreakEnd,
|
|
485
|
+
})
|
|
486
|
+
.where(eq(sloStreaks.objectiveId, objectiveId));
|
|
487
|
+
|
|
488
|
+
return (await this.getStreak({ objectiveId }))!;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
async resetStreak({
|
|
492
|
+
objectiveId,
|
|
493
|
+
}: {
|
|
494
|
+
objectiveId: string;
|
|
495
|
+
}): Promise<SloStreak> {
|
|
496
|
+
const streak = await this.getStreak({ objectiveId });
|
|
497
|
+
if (!streak) throw new Error(`Streak not found for objective ${objectiveId}`);
|
|
498
|
+
|
|
499
|
+
const now = new Date();
|
|
500
|
+
const updateData: Partial<typeof sloStreaks.$inferInsert> = {
|
|
501
|
+
currentStreak: 0,
|
|
502
|
+
// eslint-disable-next-line unicorn/no-null -- Drizzle requires null for nullable columns
|
|
503
|
+
streakStart: null,
|
|
504
|
+
};
|
|
505
|
+
|
|
506
|
+
// If current streak was the best, record when it ended
|
|
507
|
+
if (streak.currentStreak >= streak.bestStreak && streak.currentStreak > 0) {
|
|
508
|
+
updateData.bestStreakEnd = now;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
await this.db
|
|
512
|
+
.update(sloStreaks)
|
|
513
|
+
.set(updateData)
|
|
514
|
+
.where(eq(sloStreaks.objectiveId, objectiveId));
|
|
515
|
+
|
|
516
|
+
return (await this.getStreak({ objectiveId }))!;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// ===========================================================================
|
|
520
|
+
// ACHIEVEMENTS
|
|
521
|
+
// ===========================================================================
|
|
522
|
+
|
|
523
|
+
async getAchievements({
|
|
524
|
+
systemId,
|
|
525
|
+
}: {
|
|
526
|
+
systemId: string;
|
|
527
|
+
}): Promise<SloAchievement[]> {
|
|
528
|
+
const rows = await this.db
|
|
529
|
+
.select()
|
|
530
|
+
.from(sloAchievements)
|
|
531
|
+
.where(eq(sloAchievements.systemId, systemId));
|
|
532
|
+
return rows.map((row) => mapAchievementRow(row));
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
async getRecentMilestones({
|
|
536
|
+
limit,
|
|
537
|
+
}: {
|
|
538
|
+
limit: number;
|
|
539
|
+
}): Promise<SloAchievement[]> {
|
|
540
|
+
const rows = await this.db
|
|
541
|
+
.select()
|
|
542
|
+
.from(sloAchievements)
|
|
543
|
+
.orderBy(desc(sloAchievements.unlockedAt))
|
|
544
|
+
.limit(limit);
|
|
545
|
+
return rows.map((row) => mapAchievementRow(row));
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
async hasAchievement({
|
|
549
|
+
systemId,
|
|
550
|
+
achievement,
|
|
551
|
+
}: {
|
|
552
|
+
systemId: string;
|
|
553
|
+
achievement: string;
|
|
554
|
+
}): Promise<boolean> {
|
|
555
|
+
const [existing] = await this.db
|
|
556
|
+
.select({ id: sloAchievements.id })
|
|
557
|
+
.from(sloAchievements)
|
|
558
|
+
.where(
|
|
559
|
+
and(
|
|
560
|
+
eq(sloAchievements.systemId, systemId),
|
|
561
|
+
eq(sloAchievements.achievement, achievement),
|
|
562
|
+
),
|
|
563
|
+
)
|
|
564
|
+
.limit(1);
|
|
565
|
+
return !!existing;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
async unlockAchievement({
|
|
569
|
+
systemId,
|
|
570
|
+
achievement,
|
|
571
|
+
}: {
|
|
572
|
+
systemId: string;
|
|
573
|
+
achievement: string;
|
|
574
|
+
}): Promise<SloAchievement | undefined> {
|
|
575
|
+
// Idempotent: skip if already unlocked
|
|
576
|
+
if (await this.hasAchievement({ systemId, achievement })) {
|
|
577
|
+
return undefined;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
const id = generateId();
|
|
581
|
+
await this.db.insert(sloAchievements).values({
|
|
582
|
+
id,
|
|
583
|
+
systemId,
|
|
584
|
+
achievement,
|
|
585
|
+
unlockedAt: new Date(),
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
const [row] = await this.db
|
|
589
|
+
.select()
|
|
590
|
+
.from(sloAchievements)
|
|
591
|
+
.where(eq(sloAchievements.id, id));
|
|
592
|
+
return mapAchievementRow(row);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
async deleteAchievementsForSystem({
|
|
596
|
+
systemId,
|
|
597
|
+
}: {
|
|
598
|
+
systemId: string;
|
|
599
|
+
}): Promise<void> {
|
|
600
|
+
await this.db
|
|
601
|
+
.delete(sloAchievements)
|
|
602
|
+
.where(eq(sloAchievements.systemId, systemId));
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// =============================================================================
|
|
607
|
+
// ROW MAPPERS
|
|
608
|
+
// =============================================================================
|
|
609
|
+
|
|
610
|
+
function mapObjectiveRow(
|
|
611
|
+
row: typeof sloObjectives.$inferSelect,
|
|
612
|
+
): SloObjective {
|
|
613
|
+
return {
|
|
614
|
+
id: row.id,
|
|
615
|
+
systemId: row.systemId,
|
|
616
|
+
healthCheckConfigurationId: row.healthCheckConfigurationId,
|
|
617
|
+
target: row.target,
|
|
618
|
+
windowDays: row.windowDays,
|
|
619
|
+
dependencyExclusion: row.dependencyExclusion as SloObjective["dependencyExclusion"],
|
|
620
|
+
excludedDependencyIds: (row.excludedDependencyIds as string[] | null) ?? undefined,
|
|
621
|
+
burnRateThresholds: {
|
|
622
|
+
warningPercent: row.burnRateWarningPercent,
|
|
623
|
+
criticalPercent: row.burnRateCriticalPercent,
|
|
624
|
+
fastBurnMultiplier: row.burnRateFastBurnMultiplier,
|
|
625
|
+
},
|
|
626
|
+
createdAt: row.createdAt,
|
|
627
|
+
updatedAt: row.updatedAt,
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function mapDowntimeEventRow(
|
|
632
|
+
row: typeof sloDowntimeEvents.$inferSelect,
|
|
633
|
+
): SloDowntimeEvent {
|
|
634
|
+
return {
|
|
635
|
+
id: row.id,
|
|
636
|
+
objectiveId: row.objectiveId,
|
|
637
|
+
systemId: row.systemId,
|
|
638
|
+
startTime: row.startTime,
|
|
639
|
+
endTime: row.endTime,
|
|
640
|
+
durationSeconds: row.durationSeconds,
|
|
641
|
+
attributionType: row.attributionType as SloDowntimeEvent["attributionType"],
|
|
642
|
+
upstreamSystemId: row.upstreamSystemId,
|
|
643
|
+
upstreamSystemName: row.upstreamSystemName,
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function mapSnapshotRow(
|
|
648
|
+
row: typeof sloDailySnapshots.$inferSelect,
|
|
649
|
+
): SloDailySnapshot {
|
|
650
|
+
return {
|
|
651
|
+
id: row.id,
|
|
652
|
+
objectiveId: row.objectiveId,
|
|
653
|
+
date: row.date,
|
|
654
|
+
availabilityPercent: row.availabilityPercent,
|
|
655
|
+
budgetConsumedMinutes: row.budgetConsumedMinutes,
|
|
656
|
+
budgetRemainingPercent: row.budgetRemainingPercent,
|
|
657
|
+
burnRate: row.burnRate,
|
|
658
|
+
streakDays: row.streakDays,
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function mapStreakRow(row: typeof sloStreaks.$inferSelect): SloStreak {
|
|
663
|
+
return {
|
|
664
|
+
objectiveId: row.objectiveId,
|
|
665
|
+
systemId: row.systemId,
|
|
666
|
+
currentStreak: row.currentStreak,
|
|
667
|
+
bestStreak: row.bestStreak,
|
|
668
|
+
streakStart: row.streakStart,
|
|
669
|
+
bestStreakEnd: row.bestStreakEnd,
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function mapAchievementRow(
|
|
674
|
+
row: typeof sloAchievements.$inferSelect,
|
|
675
|
+
): SloAchievement {
|
|
676
|
+
return {
|
|
677
|
+
id: row.id,
|
|
678
|
+
systemId: row.systemId,
|
|
679
|
+
achievement: row.achievement as SloAchievement["achievement"],
|
|
680
|
+
unlockedAt: row.unlockedAt,
|
|
681
|
+
};
|
|
682
|
+
}
|