@checkstack/dependency-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.
@@ -0,0 +1,376 @@
1
+ import { eq, or, and, inArray } from "drizzle-orm";
2
+ import type { SafeDatabase } from "@checkstack/backend-api";
3
+ import * as schema from "../schema";
4
+ import {
5
+ dependencies,
6
+ dependencyHealthCheckRules,
7
+ nodePositions,
8
+ } from "../schema";
9
+ import type {
10
+ Dependency,
11
+ CreateDependencyInput,
12
+ UpdateDependencyInput,
13
+ NodePosition,
14
+ } from "@checkstack/dependency-common";
15
+
16
+ type Db = SafeDatabase<typeof schema>;
17
+
18
+ function generateId(): string {
19
+ return crypto.randomUUID();
20
+ }
21
+
22
+ export class DependencyService {
23
+ constructor(private db: Db) {}
24
+
25
+ /**
26
+ * Get all dependencies for a system in the specified direction.
27
+ */
28
+ async getDependencies({
29
+ systemId,
30
+ direction = "both",
31
+ }: {
32
+ systemId: string;
33
+ direction?: "upstream" | "downstream" | "both";
34
+ }): Promise<Dependency[]> {
35
+ let condition;
36
+ if (direction === "upstream") {
37
+ condition = eq(dependencies.sourceSystemId, systemId);
38
+ } else if (direction === "downstream") {
39
+ condition = eq(dependencies.targetSystemId, systemId);
40
+ } else {
41
+ condition = or(
42
+ eq(dependencies.sourceSystemId, systemId),
43
+ eq(dependencies.targetSystemId, systemId),
44
+ );
45
+ }
46
+
47
+ const rows = await this.db.select().from(dependencies).where(condition);
48
+ return this.enrichWithRules(rows);
49
+ }
50
+
51
+ /**
52
+ * Get all dependencies in the system (for the canvas).
53
+ */
54
+ async getAllDependencies(): Promise<Dependency[]> {
55
+ const rows = await this.db.select().from(dependencies);
56
+ return this.enrichWithRules(rows);
57
+ }
58
+
59
+ /**
60
+ * Create a new dependency with cycle detection.
61
+ * Throws if the dependency would create a circular chain.
62
+ */
63
+ async createDependency(input: CreateDependencyInput): Promise<Dependency> {
64
+ // Check for duplicate edge
65
+ const [existing] = await this.db
66
+ .select()
67
+ .from(dependencies)
68
+ .where(
69
+ and(
70
+ eq(dependencies.sourceSystemId, input.sourceSystemId),
71
+ eq(dependencies.targetSystemId, input.targetSystemId),
72
+ ),
73
+ );
74
+
75
+ if (existing) {
76
+ throw new Error(
77
+ `Dependency already exists between ${input.sourceSystemId} and ${input.targetSystemId}`,
78
+ );
79
+ }
80
+
81
+ // Cycle detection: check if adding this edge would create a cycle
82
+ const cyclePath = await this.detectCycle({
83
+ sourceSystemId: input.sourceSystemId,
84
+ targetSystemId: input.targetSystemId,
85
+ });
86
+
87
+ if (cyclePath) {
88
+ throw new Error(
89
+ `Cannot create dependency: would form a circular chain: ${cyclePath.join(" → ")}`,
90
+ );
91
+ }
92
+
93
+ const id = generateId();
94
+ await this.db.insert(dependencies).values({
95
+ id,
96
+ sourceSystemId: input.sourceSystemId,
97
+ targetSystemId: input.targetSystemId,
98
+ impactType: input.impactType,
99
+ transitive: input.transitive ?? false,
100
+ // eslint-disable-next-line unicorn/no-null -- Drizzle SQL column is nullable
101
+ label: input.label ?? null,
102
+ });
103
+
104
+ // Create health check rules if provided
105
+ if (input.healthCheckRules && input.healthCheckRules.length > 0) {
106
+ for (const rule of input.healthCheckRules) {
107
+ await this.db.insert(dependencyHealthCheckRules).values({
108
+ id: generateId(),
109
+ dependencyId: id,
110
+ healthCheckId: rule.healthCheckId,
111
+ overrideImpactType: rule.overrideImpactType,
112
+ });
113
+ }
114
+ }
115
+
116
+ const result = await this.getDependencyById(id);
117
+ if (!result) {
118
+ throw new Error("Failed to create dependency");
119
+ }
120
+ return result;
121
+ }
122
+
123
+ /**
124
+ * Update an existing dependency.
125
+ */
126
+ async updateDependency(
127
+ input: UpdateDependencyInput,
128
+ ): Promise<Dependency | undefined> {
129
+ const [existing] = await this.db
130
+ .select()
131
+ .from(dependencies)
132
+ .where(eq(dependencies.id, input.id));
133
+
134
+ if (!existing) return undefined;
135
+
136
+ const updateData: Partial<typeof dependencies.$inferInsert> = {
137
+ updatedAt: new Date(),
138
+ };
139
+ if (input.impactType !== undefined) updateData.impactType = input.impactType;
140
+ if (input.transitive !== undefined) updateData.transitive = input.transitive;
141
+ if (input.label !== undefined) updateData.label = input.label;
142
+
143
+ await this.db
144
+ .update(dependencies)
145
+ .set(updateData)
146
+ .where(eq(dependencies.id, input.id));
147
+
148
+ // Replace health check rules if provided
149
+ if (input.healthCheckRules !== undefined) {
150
+ await this.db
151
+ .delete(dependencyHealthCheckRules)
152
+ .where(eq(dependencyHealthCheckRules.dependencyId, input.id));
153
+
154
+ for (const rule of input.healthCheckRules) {
155
+ await this.db.insert(dependencyHealthCheckRules).values({
156
+ id: generateId(),
157
+ dependencyId: input.id,
158
+ healthCheckId: rule.healthCheckId,
159
+ overrideImpactType: rule.overrideImpactType,
160
+ });
161
+ }
162
+ }
163
+
164
+ return this.getDependencyById(input.id);
165
+ }
166
+
167
+ /**
168
+ * Delete a dependency.
169
+ */
170
+ async deleteDependency(id: string): Promise<boolean> {
171
+ const [existing] = await this.db
172
+ .select()
173
+ .from(dependencies)
174
+ .where(eq(dependencies.id, id));
175
+
176
+ if (!existing) return false;
177
+
178
+ // Cascade delete handles health check rules
179
+ await this.db.delete(dependencies).where(eq(dependencies.id, id));
180
+ return true;
181
+ }
182
+
183
+ /**
184
+ * Remove all dependencies referencing a system (both as source and target).
185
+ * Called when a system is deleted from the catalog.
186
+ */
187
+ async removeSystemDependencies(systemId: string): Promise<void> {
188
+ await this.db
189
+ .delete(dependencies)
190
+ .where(
191
+ or(
192
+ eq(dependencies.sourceSystemId, systemId),
193
+ eq(dependencies.targetSystemId, systemId),
194
+ ),
195
+ );
196
+ }
197
+
198
+ /**
199
+ * Get a single dependency by ID with health check rules.
200
+ */
201
+ async getDependencyById(id: string): Promise<Dependency | undefined> {
202
+ const [row] = await this.db
203
+ .select()
204
+ .from(dependencies)
205
+ .where(eq(dependencies.id, id));
206
+
207
+ if (!row) return undefined;
208
+
209
+ const rules = await this.db
210
+ .select()
211
+ .from(dependencyHealthCheckRules)
212
+ .where(eq(dependencyHealthCheckRules.dependencyId, id));
213
+
214
+ return {
215
+ ...row,
216
+ // eslint-disable-next-line unicorn/no-null -- Drizzle SQL column is nullable
217
+ label: row.label ?? null,
218
+ healthCheckRules: rules.length > 0 ? rules : undefined,
219
+ };
220
+ }
221
+
222
+ // ===========================================================================
223
+ // NODE POSITIONS
224
+ // ===========================================================================
225
+
226
+ /**
227
+ * Get saved node positions for a user.
228
+ */
229
+ async getNodePositions(userId: string): Promise<NodePosition[]> {
230
+ const rows = await this.db
231
+ .select()
232
+ .from(nodePositions)
233
+ .where(eq(nodePositions.userId, userId));
234
+
235
+ return rows.map((r) => ({
236
+ systemId: r.systemId,
237
+ x: r.x,
238
+ y: r.y,
239
+ }));
240
+ }
241
+
242
+ /**
243
+ * Save node positions for a user (replace all).
244
+ */
245
+ async saveNodePositions({
246
+ userId,
247
+ positions,
248
+ }: {
249
+ userId: string;
250
+ positions: NodePosition[];
251
+ }): Promise<void> {
252
+ // Delete existing positions for this user
253
+ await this.db
254
+ .delete(nodePositions)
255
+ .where(eq(nodePositions.userId, userId));
256
+
257
+ // Insert new positions
258
+ for (const pos of positions) {
259
+ await this.db.insert(nodePositions).values({
260
+ id: generateId(),
261
+ userId,
262
+ systemId: pos.systemId,
263
+ x: pos.x,
264
+ y: pos.y,
265
+ });
266
+ }
267
+ }
268
+
269
+ // ===========================================================================
270
+ // CYCLE DETECTION
271
+ // ===========================================================================
272
+
273
+ /**
274
+ * Detect if adding an edge from source → target would create a cycle.
275
+ * Uses DFS from the target system following existing edges.
276
+ * Returns the cycle path if found, or undefined if safe.
277
+ */
278
+ async detectCycle({
279
+ sourceSystemId,
280
+ targetSystemId,
281
+ }: {
282
+ sourceSystemId: string;
283
+ targetSystemId: string;
284
+ }): Promise<string[] | undefined> {
285
+ // Load all edges for traversal
286
+ const allEdges = await this.db
287
+ .select({
288
+ sourceSystemId: dependencies.sourceSystemId,
289
+ targetSystemId: dependencies.targetSystemId,
290
+ })
291
+ .from(dependencies);
292
+
293
+ // Build adjacency map: for each system, which systems does it depend on (upstream)?
294
+ // Edge direction: source depends on target, so source → target
295
+ // To detect cycles, we follow: target → (what depends on target as its upstream)
296
+ // i.e., we need to check: can we reach sourceSystemId by following edges from targetSystemId?
297
+ // An edge (A, B) means A depends on B. If we're adding (source, target),
298
+ // we need to check if following existing edges from source as a target,
299
+ // we can reach target. More precisely: does target already transitively depend on source?
300
+
301
+ // Build: for each node, what are its upstream dependencies (targetSystemId values)?
302
+ const upstreamMap = new Map<string, string[]>();
303
+ for (const edge of allEdges) {
304
+ const existing = upstreamMap.get(edge.sourceSystemId) ?? [];
305
+ existing.push(edge.targetSystemId);
306
+ upstreamMap.set(edge.sourceSystemId, existing);
307
+ }
308
+
309
+ // DFS: starting from targetSystemId, follow its upstream dependencies
310
+ // If we reach sourceSystemId, there's a cycle
311
+ const visited = new Set<string>();
312
+ const path: string[] = [sourceSystemId, targetSystemId];
313
+
314
+ const dfs = (current: string): string[] | undefined => {
315
+ if (current === sourceSystemId) {
316
+ return path;
317
+ }
318
+
319
+ if (visited.has(current)) {
320
+ return undefined;
321
+ }
322
+ visited.add(current);
323
+
324
+ const upstreams = upstreamMap.get(current) ?? [];
325
+ for (const upstream of upstreams) {
326
+ path.push(upstream);
327
+ const result = dfs(upstream);
328
+ if (result) return result;
329
+ path.pop();
330
+ }
331
+
332
+ return undefined;
333
+ };
334
+
335
+ return dfs(targetSystemId);
336
+ }
337
+
338
+ // ===========================================================================
339
+ // HELPERS
340
+ // ===========================================================================
341
+
342
+ /**
343
+ * Enrich dependency rows with their health check rules.
344
+ */
345
+ private async enrichWithRules(
346
+ rows: (typeof dependencies.$inferSelect)[],
347
+ ): Promise<Dependency[]> {
348
+ if (rows.length === 0) return [];
349
+
350
+ const ids = rows.map((r) => r.id);
351
+ const allRules = await this.db
352
+ .select()
353
+ .from(dependencyHealthCheckRules)
354
+ .where(inArray(dependencyHealthCheckRules.dependencyId, ids));
355
+
356
+ const rulesByDep = new Map<
357
+ string,
358
+ (typeof dependencyHealthCheckRules.$inferSelect)[]
359
+ >();
360
+ for (const rule of allRules) {
361
+ const existing = rulesByDep.get(rule.dependencyId) ?? [];
362
+ existing.push(rule);
363
+ rulesByDep.set(rule.dependencyId, existing);
364
+ }
365
+
366
+ return rows.map((row) => {
367
+ const rules = rulesByDep.get(row.id);
368
+ return {
369
+ ...row,
370
+ // eslint-disable-next-line unicorn/no-null -- Drizzle SQL column is nullable
371
+ label: row.label ?? null,
372
+ healthCheckRules: rules && rules.length > 0 ? rules : undefined,
373
+ };
374
+ });
375
+ }
376
+ }
@@ -0,0 +1,305 @@
1
+ import type {
2
+ Dependency,
3
+ DependencyWarning,
4
+ DerivedState,
5
+ ImpactType,
6
+ } from "@checkstack/dependency-common";
7
+
8
+ /**
9
+ * Upstream system status as reported by the platform.
10
+ */
11
+ export interface SystemStatus {
12
+ systemId: string;
13
+ systemName: string;
14
+ /** Overall system status: operational, degraded, or down */
15
+ status: "operational" | "degraded" | "down";
16
+ /** Per-health-check statuses (for advanced rules) */
17
+ healthCheckStatuses?: Array<{
18
+ healthCheckId: string;
19
+ status: "healthy" | "degraded" | "unhealthy";
20
+ }>;
21
+ }
22
+
23
+ /**
24
+ * Evaluates derived dependency warnings based on system statuses.
25
+ * This is a pure computation engine — it does not fetch data itself.
26
+ */
27
+ export class WarningEvaluationService {
28
+ /**
29
+ * Evaluate dependency warnings for a set of systems.
30
+ *
31
+ * @param systemIds - The systems to evaluate
32
+ * @param allDependencies - All dependency edges in the system
33
+ * @param systemStatuses - Current status of all referenced systems
34
+ * @returns Map of systemId → DependencyWarning (only for systems with warnings)
35
+ */
36
+ evaluateWarnings({
37
+ systemIds,
38
+ allDependencies,
39
+ systemStatuses,
40
+ }: {
41
+ systemIds: string[];
42
+ allDependencies: Dependency[];
43
+ systemStatuses: Map<string, SystemStatus>;
44
+ }): Map<string, DependencyWarning> {
45
+ const warnings = new Map<string, DependencyWarning>();
46
+
47
+ for (const systemId of systemIds) {
48
+ const warning = this.evaluateSystem({
49
+ systemId,
50
+ allDependencies,
51
+ systemStatuses,
52
+ visited: new Set<string>(),
53
+ });
54
+
55
+ if (warning) {
56
+ warnings.set(systemId, warning);
57
+ }
58
+ }
59
+
60
+ return warnings;
61
+ }
62
+
63
+ /**
64
+ * Evaluate a single system's dependency warning.
65
+ */
66
+ evaluateSystem({
67
+ systemId,
68
+ allDependencies,
69
+ systemStatuses,
70
+ visited,
71
+ }: {
72
+ systemId: string;
73
+ allDependencies: Dependency[];
74
+ systemStatuses: Map<string, SystemStatus>;
75
+ visited: Set<string>;
76
+ }): DependencyWarning | undefined {
77
+ // Cycle guard: prevent infinite loops in transitive evaluation
78
+ if (visited.has(systemId)) {
79
+ return undefined;
80
+ }
81
+ visited.add(systemId);
82
+
83
+ // Find all dependencies where this system is the source (downstream)
84
+ const upstreamDeps = allDependencies.filter(
85
+ (d) => d.sourceSystemId === systemId,
86
+ );
87
+
88
+ if (upstreamDeps.length === 0) {
89
+ return undefined;
90
+ }
91
+
92
+ let worstDerivedState: DerivedState | undefined;
93
+ const affectedUpstreams: DependencyWarning["affectedUpstreams"] = [];
94
+
95
+ for (const dep of upstreamDeps) {
96
+ const upstreamStatus = systemStatuses.get(dep.targetSystemId);
97
+ if (!upstreamStatus) continue;
98
+
99
+ // Determine the effective upstream status
100
+ let effectiveStatus = upstreamStatus.status;
101
+
102
+ // For transitive dependencies, consider the upstream's own derived warnings
103
+ if (dep.transitive) {
104
+ const upstreamWarning = this.evaluateSystem({
105
+ systemId: dep.targetSystemId,
106
+ allDependencies,
107
+ systemStatuses,
108
+ visited: new Set(visited), // Fresh copy to allow fan-out
109
+ });
110
+
111
+ if (upstreamWarning) {
112
+ // Promote the effective status based on upstream's worst warning
113
+ effectiveStatus = this.promoteStatus({
114
+ ownStatus: effectiveStatus,
115
+ warningState: upstreamWarning.derivedState,
116
+ });
117
+ }
118
+ }
119
+
120
+ // Evaluate impact based on the dependency configuration
121
+ const derivedState = this.evaluateDependencyImpact({
122
+ dependency: dep,
123
+ upstreamStatus: effectiveStatus,
124
+ upstreamHealthChecks: upstreamStatus.healthCheckStatuses,
125
+ });
126
+
127
+ if (derivedState) {
128
+ affectedUpstreams.push({
129
+ systemId: dep.targetSystemId,
130
+ systemName: upstreamStatus.systemName,
131
+ ownStatus: effectiveStatus,
132
+ impactType: dep.impactType,
133
+ dependencyLabel: dep.label,
134
+ });
135
+
136
+ worstDerivedState = this.worstState(worstDerivedState, derivedState);
137
+ }
138
+ }
139
+
140
+ if (!worstDerivedState || affectedUpstreams.length === 0) {
141
+ return undefined;
142
+ }
143
+
144
+ return {
145
+ systemId,
146
+ derivedState: worstDerivedState,
147
+ affectedUpstreams,
148
+ };
149
+ }
150
+
151
+ /**
152
+ * Evaluate the impact of a single dependency based on upstream status.
153
+ * Returns the derived state, or undefined if no impact.
154
+ */
155
+ private evaluateDependencyImpact({
156
+ dependency,
157
+ upstreamStatus,
158
+ upstreamHealthChecks,
159
+ }: {
160
+ dependency: Dependency;
161
+ upstreamStatus: "operational" | "degraded" | "down";
162
+ upstreamHealthChecks?: Array<{
163
+ healthCheckId: string;
164
+ status: "healthy" | "degraded" | "unhealthy";
165
+ }>;
166
+ }): DerivedState | undefined {
167
+ // If upstream is operational, no impact
168
+ if (upstreamStatus === "operational") {
169
+ return undefined;
170
+ }
171
+
172
+ // If the dependency has health check rules, evaluate those instead
173
+ if (
174
+ dependency.healthCheckRules &&
175
+ dependency.healthCheckRules.length > 0 &&
176
+ upstreamHealthChecks
177
+ ) {
178
+ return this.evaluateHealthCheckRules({
179
+ rules: dependency.healthCheckRules,
180
+ healthCheckStatuses: upstreamHealthChecks,
181
+ });
182
+ }
183
+
184
+ // Apply the impact matrix based on overall status
185
+ return this.applyImpactMatrix({
186
+ impactType: dependency.impactType,
187
+ upstreamStatus,
188
+ });
189
+ }
190
+
191
+ /**
192
+ * Evaluate health check rules for a dependency.
193
+ * Returns the worst derived state from matching rules, or undefined.
194
+ */
195
+ private evaluateHealthCheckRules({
196
+ rules,
197
+ healthCheckStatuses,
198
+ }: {
199
+ rules: NonNullable<Dependency["healthCheckRules"]>;
200
+ healthCheckStatuses: Array<{
201
+ healthCheckId: string;
202
+ status: "healthy" | "degraded" | "unhealthy";
203
+ }>;
204
+ }): DerivedState | undefined {
205
+ let worstState: DerivedState | undefined;
206
+
207
+ for (const rule of rules) {
208
+ const checkStatus = healthCheckStatuses.find(
209
+ (s) => s.healthCheckId === rule.healthCheckId,
210
+ );
211
+
212
+ // If the check is not found or is healthy, skip
213
+ if (!checkStatus || checkStatus.status === "healthy") continue;
214
+
215
+ // Map health check status to upstream status equivalent
216
+ const upstreamEquivalent =
217
+ checkStatus.status === "unhealthy" ? "down" : "degraded";
218
+
219
+ const state = this.applyImpactMatrix({
220
+ impactType: rule.overrideImpactType,
221
+ upstreamStatus: upstreamEquivalent,
222
+ });
223
+
224
+ if (state) {
225
+ worstState = this.worstState(worstState, state);
226
+ }
227
+ }
228
+
229
+ return worstState;
230
+ }
231
+
232
+ /**
233
+ * Apply the impact matrix to determine derived state.
234
+ *
235
+ * | Impact Type | Upstream degraded | Upstream down |
236
+ * |---------------|--------------------|--------------------|
237
+ * | informational | info | info |
238
+ * | degraded | degraded | degraded |
239
+ * | critical | degraded | down |
240
+ */
241
+ private applyImpactMatrix({
242
+ impactType,
243
+ upstreamStatus,
244
+ }: {
245
+ impactType: ImpactType;
246
+ upstreamStatus: "degraded" | "down";
247
+ }): DerivedState {
248
+ switch (impactType) {
249
+ case "informational": {
250
+ return "info";
251
+ }
252
+ case "degraded": {
253
+ return "degraded";
254
+ }
255
+ case "critical": {
256
+ return upstreamStatus === "down" ? "down" : "degraded";
257
+ }
258
+ }
259
+ }
260
+
261
+ /**
262
+ * Promote a system's own status based on its upstream warning state.
263
+ * Used for transitive evaluation.
264
+ */
265
+ private promoteStatus({
266
+ ownStatus,
267
+ warningState,
268
+ }: {
269
+ ownStatus: "operational" | "degraded" | "down";
270
+ warningState: DerivedState;
271
+ }): "operational" | "degraded" | "down" {
272
+ const statusOrder: Record<string, number> = {
273
+ operational: 0,
274
+ info: 0, // info doesn't change the status
275
+ degraded: 1,
276
+ down: 2,
277
+ };
278
+
279
+ const ownLevel = statusOrder[ownStatus] ?? 0;
280
+ const warningLevel = statusOrder[warningState] ?? 0;
281
+
282
+ if (warningLevel > ownLevel) {
283
+ return warningState === "down" ? "down" : "degraded";
284
+ }
285
+ return ownStatus;
286
+ }
287
+
288
+ /**
289
+ * Return the worst of two derived states.
290
+ */
291
+ private worstState(
292
+ a: DerivedState | undefined,
293
+ b: DerivedState,
294
+ ): DerivedState {
295
+ if (!a) return b;
296
+
297
+ const order: Record<DerivedState, number> = {
298
+ info: 0,
299
+ degraded: 1,
300
+ down: 2,
301
+ };
302
+
303
+ return order[a] >= order[b] ? a : b;
304
+ }
305
+ }