@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/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
+ }