@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.
package/src/router.ts ADDED
@@ -0,0 +1,305 @@
1
+ import { implement, ORPCError } from "@orpc/server";
2
+ import {
3
+ dependencyContract,
4
+ DEPENDENCY_CHANGED,
5
+ DEPENDENCY_WARNINGS_CHANGED,
6
+ } from "@checkstack/dependency-common";
7
+ import {
8
+ autoAuthMiddleware,
9
+ type Logger,
10
+ type RpcContext,
11
+ } from "@checkstack/backend-api";
12
+ import type { SignalService } from "@checkstack/signal-common";
13
+ import type { DependencyService } from "./services/dependency-service";
14
+ import type {
15
+ WarningEvaluationService,
16
+ SystemStatus,
17
+ } from "./services/warning-evaluation-service";
18
+ import { dependencyHooks } from "./hooks";
19
+ import type { InferClient } from "@checkstack/common";
20
+ import { CatalogApi } from "@checkstack/catalog-common";
21
+ import { HealthCheckApi } from "@checkstack/healthcheck-common";
22
+
23
+ export function createRouter({
24
+ service,
25
+ warningService,
26
+ signalService,
27
+ catalogClient,
28
+ healthCheckClient,
29
+ logger,
30
+ }: {
31
+ service: DependencyService;
32
+ warningService: WarningEvaluationService;
33
+ signalService: SignalService;
34
+ catalogClient: InferClient<typeof CatalogApi>;
35
+ healthCheckClient: InferClient<typeof HealthCheckApi>;
36
+ logger: Logger;
37
+ }) {
38
+ /**
39
+ * Fetch system statuses for warning evaluation using the bulk health status API.
40
+ * Combines catalog system names with health check status data.
41
+ */
42
+ async function fetchSystemStatuses(
43
+ systemIds: string[],
44
+ ): Promise<Map<string, SystemStatus>> {
45
+ const statuses = new Map<string, SystemStatus>();
46
+
47
+ // Get system names from catalog
48
+ const { systems } = await catalogClient.getSystems();
49
+ const systemMap = new Map(systems.map((s) => [s.id, s]));
50
+
51
+ // Bulk-fetch health statuses for all systems
52
+ try {
53
+ const { statuses: healthStatuses } =
54
+ await healthCheckClient.getBulkSystemHealthStatus({
55
+ systemIds,
56
+ });
57
+
58
+ for (const systemId of systemIds) {
59
+ const system = systemMap.get(systemId);
60
+ if (!system) continue;
61
+
62
+ const healthStatus = healthStatuses[systemId];
63
+
64
+ if (healthStatus) {
65
+ // Map from health check status to simplified system status
66
+ let overallStatus: "operational" | "degraded" | "down" =
67
+ "operational";
68
+ if (healthStatus.status === "unhealthy") {
69
+ overallStatus = "down";
70
+ } else if (healthStatus.status === "degraded") {
71
+ overallStatus = "degraded";
72
+ }
73
+
74
+ const checkStatuses = healthStatus.checkStatuses.map((cs) => ({
75
+ healthCheckId: cs.configurationId,
76
+ status: cs.status,
77
+ }));
78
+
79
+ statuses.set(systemId, {
80
+ systemId,
81
+ systemName: system.name,
82
+ status: overallStatus,
83
+ healthCheckStatuses: checkStatuses,
84
+ });
85
+ } else {
86
+ statuses.set(systemId, {
87
+ systemId,
88
+ systemName: system.name,
89
+ status: "operational",
90
+ });
91
+ }
92
+ }
93
+ } catch (error) {
94
+ logger.debug(
95
+ `Failed to bulk-fetch health statuses: ${String(error)}`,
96
+ );
97
+ // Fallback: default all systems to operational
98
+ for (const systemId of systemIds) {
99
+ const system = systemMap.get(systemId);
100
+ if (!system) continue;
101
+ statuses.set(systemId, {
102
+ systemId,
103
+ systemName: system.name,
104
+ status: "operational",
105
+ });
106
+ }
107
+ }
108
+
109
+ return statuses;
110
+ }
111
+
112
+ const os = implement(dependencyContract)
113
+ .$context<RpcContext>()
114
+ .use(autoAuthMiddleware);
115
+
116
+ return os.router({
117
+ getDependencies: os.getDependencies.handler(async ({ input }) => {
118
+ const deps = await service.getDependencies({
119
+ systemId: input.systemId,
120
+ direction: input.direction,
121
+ });
122
+ return { dependencies: deps };
123
+ }),
124
+
125
+ getAllDependencies: os.getAllDependencies.handler(async () => {
126
+ return { dependencies: await service.getAllDependencies() };
127
+ }),
128
+
129
+ getWarnings: os.getWarnings.handler(async ({ input }) => {
130
+ const allDeps = await service.getAllDependencies();
131
+
132
+ // Collect all system IDs that need status evaluation
133
+ const allSystemIds = new Set<string>(input.systemIds);
134
+ for (const dep of allDeps) {
135
+ allSystemIds.add(dep.sourceSystemId);
136
+ allSystemIds.add(dep.targetSystemId);
137
+ }
138
+
139
+ const statuses = await fetchSystemStatuses([...allSystemIds]);
140
+ const warningMap = warningService.evaluateWarnings({
141
+ systemIds: input.systemIds,
142
+ allDependencies: allDeps,
143
+ systemStatuses: statuses,
144
+ });
145
+
146
+ // Convert map to record for API output
147
+ const warnings: Record<string, NonNullable<ReturnType<typeof warningMap.get>>> = {};
148
+ for (const systemId of input.systemIds) {
149
+ const warning = warningMap.get(systemId);
150
+ if (warning) {
151
+ warnings[systemId] = warning;
152
+ }
153
+ }
154
+
155
+ return { warnings };
156
+ }),
157
+
158
+ getWarningsForSystem: os.getWarningsForSystem.handler(
159
+ async ({ input }) => {
160
+ const allDeps = await service.getAllDependencies();
161
+
162
+ const allSystemIds = new Set<string>([input.systemId]);
163
+ for (const dep of allDeps) {
164
+ allSystemIds.add(dep.sourceSystemId);
165
+ allSystemIds.add(dep.targetSystemId);
166
+ }
167
+
168
+ const statuses = await fetchSystemStatuses([...allSystemIds]);
169
+ const warningMap = warningService.evaluateWarnings({
170
+ systemIds: [input.systemId],
171
+ allDependencies: allDeps,
172
+ systemStatuses: statuses,
173
+ });
174
+
175
+ // eslint-disable-next-line unicorn/no-null -- oRPC contract requires null
176
+ return warningMap.get(input.systemId) ?? null;
177
+ },
178
+ ),
179
+
180
+ createDependency: os.createDependency.handler(
181
+ async ({ input, context }) => {
182
+ try {
183
+ const result = await service.createDependency(input);
184
+
185
+ // Broadcast signal
186
+ await signalService.broadcast(DEPENDENCY_CHANGED, {
187
+ dependencyId: result.id,
188
+ sourceSystemId: result.sourceSystemId,
189
+ targetSystemId: result.targetSystemId,
190
+ action: "created",
191
+ });
192
+
193
+ // Emit hook
194
+ await context.emitHook(dependencyHooks.dependencyCreated, {
195
+ dependencyId: result.id,
196
+ sourceSystemId: result.sourceSystemId,
197
+ targetSystemId: result.targetSystemId,
198
+ impactType: result.impactType,
199
+ });
200
+
201
+ // Notify affected systems about warning changes
202
+ await signalService.broadcast(DEPENDENCY_WARNINGS_CHANGED, {
203
+ affectedSystemIds: [result.sourceSystemId],
204
+ });
205
+
206
+ return result;
207
+ } catch (error) {
208
+ if (
209
+ error instanceof Error &&
210
+ (error.message.includes("circular chain") ||
211
+ error.message.includes("already exists"))
212
+ ) {
213
+ throw new ORPCError("BAD_REQUEST", { message: error.message });
214
+ }
215
+ throw error;
216
+ }
217
+ },
218
+ ),
219
+
220
+ updateDependency: os.updateDependency.handler(
221
+ async ({ input, context }) => {
222
+ const result = await service.updateDependency(input);
223
+ if (!result) {
224
+ throw new ORPCError("NOT_FOUND", {
225
+ message: "Dependency not found",
226
+ });
227
+ }
228
+
229
+ await signalService.broadcast(DEPENDENCY_CHANGED, {
230
+ dependencyId: result.id,
231
+ sourceSystemId: result.sourceSystemId,
232
+ targetSystemId: result.targetSystemId,
233
+ action: "updated",
234
+ });
235
+
236
+ await context.emitHook(dependencyHooks.dependencyUpdated, {
237
+ dependencyId: result.id,
238
+ sourceSystemId: result.sourceSystemId,
239
+ targetSystemId: result.targetSystemId,
240
+ impactType: result.impactType,
241
+ });
242
+
243
+ await signalService.broadcast(DEPENDENCY_WARNINGS_CHANGED, {
244
+ affectedSystemIds: [result.sourceSystemId],
245
+ });
246
+
247
+ return result;
248
+ },
249
+ ),
250
+
251
+ deleteDependency: os.deleteDependency.handler(
252
+ async ({ input, context }) => {
253
+ const existing = await service.getDependencyById(input.id);
254
+ const success = await service.deleteDependency(input.id);
255
+
256
+ if (success && existing) {
257
+ await signalService.broadcast(DEPENDENCY_CHANGED, {
258
+ dependencyId: input.id,
259
+ sourceSystemId: existing.sourceSystemId,
260
+ targetSystemId: existing.targetSystemId,
261
+ action: "deleted",
262
+ });
263
+
264
+ await context.emitHook(dependencyHooks.dependencyDeleted, {
265
+ dependencyId: input.id,
266
+ sourceSystemId: existing.sourceSystemId,
267
+ targetSystemId: existing.targetSystemId,
268
+ });
269
+
270
+ await signalService.broadcast(DEPENDENCY_WARNINGS_CHANGED, {
271
+ affectedSystemIds: [existing.sourceSystemId],
272
+ });
273
+ }
274
+
275
+ return { success };
276
+ },
277
+ ),
278
+
279
+ getNodePositions: os.getNodePositions.handler(async ({ context }) => {
280
+ const userId =
281
+ context.user && "id" in context.user ? context.user.id : undefined;
282
+ if (!userId) {
283
+ return { positions: [] };
284
+ }
285
+ return { positions: await service.getNodePositions(userId) };
286
+ }),
287
+
288
+ saveNodePositions: os.saveNodePositions.handler(
289
+ async ({ input, context }) => {
290
+ const userId =
291
+ context.user && "id" in context.user ? context.user.id : undefined;
292
+ if (!userId) {
293
+ throw new ORPCError("UNAUTHORIZED", {
294
+ message: "User ID required to save positions",
295
+ });
296
+ }
297
+ await service.saveNodePositions({
298
+ userId,
299
+ positions: input.positions,
300
+ });
301
+ return { success: true };
302
+ },
303
+ ),
304
+ });
305
+ }
package/src/schema.ts ADDED
@@ -0,0 +1,82 @@
1
+ import {
2
+ pgTable,
3
+ pgEnum,
4
+ text,
5
+ timestamp,
6
+ boolean,
7
+ real,
8
+ unique,
9
+ } from "drizzle-orm/pg-core";
10
+
11
+ /**
12
+ * Impact type enum for dependency relationships.
13
+ */
14
+ export const impactTypeEnum = pgEnum("impact_type", [
15
+ "informational",
16
+ "degraded",
17
+ "critical",
18
+ ]);
19
+
20
+ /**
21
+ * Main dependencies table.
22
+ * Represents directional edges: sourceSystemId (downstream) depends on targetSystemId (upstream).
23
+ */
24
+ export const dependencies = pgTable(
25
+ "dependencies",
26
+ {
27
+ id: text("id").primaryKey(),
28
+ sourceSystemId: text("source_system_id").notNull(),
29
+ targetSystemId: text("target_system_id").notNull(),
30
+ impactType: impactTypeEnum("impact_type").notNull().default("degraded"),
31
+ transitive: boolean("transitive").default(false).notNull(),
32
+ label: text("label"),
33
+ createdAt: timestamp("created_at").defaultNow().notNull(),
34
+ updatedAt: timestamp("updated_at").defaultNow().notNull(),
35
+ },
36
+ (t) => ({
37
+ uniqueEdge: unique("uq_dependency_edge").on(
38
+ t.sourceSystemId,
39
+ t.targetSystemId,
40
+ ),
41
+ }),
42
+ );
43
+
44
+ /**
45
+ * Optional health check rules for fine-grained dependency triggers.
46
+ * When rules exist on a dependency, only specified checks trigger the impact.
47
+ */
48
+ export const dependencyHealthCheckRules = pgTable(
49
+ "dependency_health_check_rules",
50
+ {
51
+ id: text("id").primaryKey(),
52
+ dependencyId: text("dependency_id")
53
+ .notNull()
54
+ .references(() => dependencies.id, { onDelete: "cascade" }),
55
+ healthCheckId: text("health_check_id").notNull(),
56
+ overrideImpactType: impactTypeEnum("override_impact_type").notNull(),
57
+ },
58
+ );
59
+
60
+ /**
61
+ * Per-user node positions for the dependency map canvas.
62
+ * Persisted server-side so layout syncs across devices.
63
+ */
64
+ export const nodePositions = pgTable("node_positions", {
65
+ id: text("id").primaryKey(),
66
+ userId: text("user_id").notNull(),
67
+ systemId: text("system_id").notNull(),
68
+ x: real("x").notNull(),
69
+ y: real("y").notNull(),
70
+ updatedAt: timestamp("updated_at").defaultNow().notNull(),
71
+ });
72
+
73
+ /**
74
+ * Tracks the last known derived state per downstream system.
75
+ * Used by the notification sidecar to detect state transitions
76
+ * and avoid duplicate notifications across horizontally-scaled instances.
77
+ */
78
+ export const dependencyDerivedStates = pgTable("dependency_derived_states", {
79
+ systemId: text("system_id").primaryKey(),
80
+ derivedState: text("derived_state").notNull(),
81
+ updatedAt: timestamp("updated_at").defaultNow().notNull(),
82
+ });