@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/CHANGELOG.md +44 -0
- package/drizzle/0000_safe_dark_phoenix.sql +30 -0
- package/drizzle/0001_cooing_bedlam.sql +5 -0
- package/drizzle/meta/0000_snapshot.json +206 -0
- package/drizzle/meta/0001_snapshot.json +238 -0
- package/drizzle/meta/_journal.json +20 -0
- package/drizzle.config.ts +7 -0
- package/package.json +38 -0
- package/src/hooks.ts +36 -0
- package/src/index.ts +208 -0
- package/src/notifications.ts +339 -0
- package/src/router.ts +305 -0
- package/src/schema.ts +82 -0
- package/src/services/dependency-service.ts +376 -0
- package/src/services/warning-evaluation-service.ts +305 -0
- package/tests/notifications.test.ts +155 -0
- package/tests/warning-evaluation-service.test.ts +773 -0
- package/tsconfig.json +6 -0
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
|
+
});
|