@checkstack/dependency-backend 1.1.6 → 1.3.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,327 @@
1
+ /**
2
+ * Dependency triggers + actions registered with the Automation Platform.
3
+ *
4
+ * Triggers expose the existing `dependencyHooks` as automation entry
5
+ * points (`dependency.created`, `dependency.updated`, `dependency.deleted`).
6
+ * Actions wrap `DependencyService.createDependency` / `deleteDependency`
7
+ * so operators can build / remove edges from automation flows.
8
+ *
9
+ * The plan also mentions a `dependency.impact_propagated` trigger.
10
+ * That event isn't emitted today — propagation runs synchronously
11
+ * inside `evaluateAndNotifyDownstream`. Adding a hook there is a
12
+ * separate change because it requires deciding what payload to pass
13
+ * (which downstream systems were re-evaluated, what their new status
14
+ * is, whether to deduplicate). Tracked as a follow-up in the plan;
15
+ * not included in this chunk.
16
+ *
17
+ * Mutation actions emit their hook themselves (via the `emitHook`
18
+ * factory dep) so downstream automations / caches react the same way
19
+ * they do when the mutation comes in via RPC.
20
+ */
21
+ import { z } from "zod";
22
+ import { Versioned, type Hook } from "@checkstack/backend-api";
23
+ import type {
24
+ ActionDefinition,
25
+ TriggerDefinition,
26
+ } from "@checkstack/automation-backend";
27
+ import { makeEntityDrivenTriggerSetup } from "@checkstack/automation-backend";
28
+ import { extractErrorMessage } from "@checkstack/common";
29
+ import {
30
+ DerivedStateSchema,
31
+ ImpactTypeSchema,
32
+ } from "@checkstack/dependency-common";
33
+
34
+ import type { EntityHandle } from "@checkstack/automation-backend";
35
+ import { dependencyHooks } from "./hooks";
36
+ import type { DependencyService } from "./services/dependency-service";
37
+ import {
38
+ removeDependencyEdge,
39
+ toDependencyEdgeState,
40
+ writeDependencyEdge,
41
+ type DependencyEdgeState,
42
+ } from "./dependency-entity";
43
+
44
+ // ─── Payload schemas — match the hook payloads exactly ─────────────────
45
+
46
+ const dependencyCreatedPayloadSchema = z.object({
47
+ dependencyId: z.string(),
48
+ sourceSystemId: z.string(),
49
+ targetSystemId: z.string(),
50
+ impactType: ImpactTypeSchema,
51
+ });
52
+
53
+ const dependencyUpdatedPayloadSchema = z.object({
54
+ dependencyId: z.string(),
55
+ sourceSystemId: z.string(),
56
+ targetSystemId: z.string(),
57
+ impactType: ImpactTypeSchema,
58
+ });
59
+
60
+ const dependencyDeletedPayloadSchema = z.object({
61
+ dependencyId: z.string(),
62
+ sourceSystemId: z.string(),
63
+ targetSystemId: z.string(),
64
+ });
65
+
66
+ const dependencyImpactPropagatedPayloadSchema = z.object({
67
+ sourceSystemId: z.string(),
68
+ affectedSystems: z.array(
69
+ z.object({
70
+ systemId: z.string(),
71
+ previousState: DerivedStateSchema.nullable(),
72
+ newState: DerivedStateSchema.nullable(),
73
+ }),
74
+ ),
75
+ timestamp: z.string(),
76
+ });
77
+
78
+ // ─── Triggers ──────────────────────────────────────────────────────────
79
+
80
+ // These three triggers are ENTITY-DRIVEN (§10.5): the `dependency-edge`
81
+ // entity's change deriver fires `dependency.created/.updated/.deleted` via
82
+ // Stage-1 routing, so they no longer subscribe to a hook. A no-op `setup`
83
+ // keeps them in the editor's trigger catalog without re-introducing a hook.
84
+ export const dependencyCreatedTrigger: TriggerDefinition<
85
+ z.infer<typeof dependencyCreatedPayloadSchema>
86
+ > = {
87
+ id: "created",
88
+ displayName: "Dependency Created",
89
+ description: "Fires when a new dependency edge is added between two systems",
90
+ category: "Dependencies",
91
+ icon: "Network",
92
+ payloadSchema: dependencyCreatedPayloadSchema,
93
+ setup: makeEntityDrivenTriggerSetup<
94
+ z.infer<typeof dependencyCreatedPayloadSchema>
95
+ >(),
96
+ contextKey: (p) => p.dependencyId,
97
+ };
98
+
99
+ export const dependencyUpdatedTrigger: TriggerDefinition<
100
+ z.infer<typeof dependencyUpdatedPayloadSchema>
101
+ > = {
102
+ id: "updated",
103
+ displayName: "Dependency Updated",
104
+ description:
105
+ "Fires when an existing dependency's reactive state changes (impact type, source, target, or transitivity). A label-only edit does not fire this trigger.",
106
+ category: "Dependencies",
107
+ icon: "Network",
108
+ payloadSchema: dependencyUpdatedPayloadSchema,
109
+ setup: makeEntityDrivenTriggerSetup<
110
+ z.infer<typeof dependencyUpdatedPayloadSchema>
111
+ >(),
112
+ contextKey: (p) => p.dependencyId,
113
+ };
114
+
115
+ export const dependencyDeletedTrigger: TriggerDefinition<
116
+ z.infer<typeof dependencyDeletedPayloadSchema>
117
+ > = {
118
+ id: "deleted",
119
+ displayName: "Dependency Deleted",
120
+ description: "Fires when a dependency edge is removed",
121
+ category: "Dependencies",
122
+ icon: "Network",
123
+ payloadSchema: dependencyDeletedPayloadSchema,
124
+ setup: makeEntityDrivenTriggerSetup<
125
+ z.infer<typeof dependencyDeletedPayloadSchema>
126
+ >(),
127
+ contextKey: (p) => p.dependencyId,
128
+ };
129
+
130
+ export const dependencyImpactPropagatedTrigger: TriggerDefinition<
131
+ z.infer<typeof dependencyImpactPropagatedPayloadSchema>
132
+ > = {
133
+ id: "impact_propagated",
134
+ displayName: "Dependency Impact Propagated",
135
+ description:
136
+ "Fires once per upstream health change with the list of downstream systems whose derived state actually moved",
137
+ category: "Dependencies",
138
+ icon: "Network",
139
+ payloadSchema: dependencyImpactPropagatedPayloadSchema,
140
+ hook: dependencyHooks.impactPropagated,
141
+ contextKey: (p) => p.sourceSystemId,
142
+ };
143
+
144
+ export const dependencyTriggers: TriggerDefinition<unknown>[] = [
145
+ dependencyCreatedTrigger as TriggerDefinition<unknown>,
146
+ dependencyUpdatedTrigger as TriggerDefinition<unknown>,
147
+ dependencyDeletedTrigger as TriggerDefinition<unknown>,
148
+ dependencyImpactPropagatedTrigger as TriggerDefinition<unknown>,
149
+ ];
150
+
151
+ // ─── Action configs ────────────────────────────────────────────────────
152
+
153
+ const impactTypeSchema = ImpactTypeSchema.describe(
154
+ "How the target is affected when the source is affected — `critical` propagates the upstream's status (degraded → degraded, down → down), `degraded` always pulls the downstream to degraded, `informational` warns only.",
155
+ );
156
+
157
+ const dependencyCreateConfigSchema = z.object({
158
+ sourceSystemId: z.string().min(1).describe("Source (upstream) system id"),
159
+ targetSystemId: z.string().min(1).describe("Target (downstream) system id"),
160
+ impactType: impactTypeSchema,
161
+ transitive: z.boolean().optional().default(false),
162
+ label: z.string().optional(),
163
+ });
164
+
165
+ export type DependencyCreateConfig = z.infer<
166
+ typeof dependencyCreateConfigSchema
167
+ >;
168
+
169
+ const dependencyRemoveConfigSchema = z.object({
170
+ dependencyId: z.string().min(1).describe("Id of the dependency to remove"),
171
+ });
172
+
173
+ export type DependencyRemoveConfig = z.infer<
174
+ typeof dependencyRemoveConfigSchema
175
+ >;
176
+
177
+ // ─── Artifact type ─────────────────────────────────────────────────────
178
+
179
+ const dependencyArtifactSchema = z.object({
180
+ dependencyId: z.string(),
181
+ sourceSystemId: z.string(),
182
+ targetSystemId: z.string(),
183
+ impactType: ImpactTypeSchema,
184
+ });
185
+
186
+ export type DependencyArtifact = z.infer<typeof dependencyArtifactSchema>;
187
+
188
+ export const dependencyArtifactType = {
189
+ id: "edge",
190
+ displayName: "Dependency Edge",
191
+ description: "Source → target edge produced or removed by an automation",
192
+ schema: dependencyArtifactSchema,
193
+ } as const;
194
+
195
+ // ─── Actions ───────────────────────────────────────────────────────────
196
+
197
+ export interface DependencyActionDeps {
198
+ service: DependencyService;
199
+ emitHook: <T>(hook: Hook<T>, payload: T) => Promise<void>;
200
+ /** Resolver for the reactive `dependency-edge` entity (§10.5). */
201
+ getDependencyEntity?: () => EntityHandle<DependencyEdgeState> | undefined;
202
+ }
203
+
204
+ export function createDependencyActions(
205
+ deps: DependencyActionDeps,
206
+ ): ActionDefinition<unknown, unknown>[] {
207
+ const createAction: ActionDefinition<
208
+ DependencyCreateConfig,
209
+ DependencyArtifact
210
+ > = {
211
+ id: "create",
212
+ displayName: "Create Dependency",
213
+ description: "Add a dependency edge between two systems",
214
+ category: "Dependencies",
215
+ icon: "Network",
216
+ config: new Versioned({
217
+ version: 1,
218
+ schema: dependencyCreateConfigSchema,
219
+ }),
220
+ produces: "dependency.edge",
221
+ execute: async ({ config, logger }) => {
222
+ try {
223
+ // Drive the create through the reactive `dependency-edge` entity
224
+ // (§10.5): the REAL create (with cycle/duplicate validation that may
225
+ // throw) runs INSIDE `apply`, so `prev` is snapshotted (absent →
226
+ // null) BEFORE the insert and the deriver fires `dependency.created`.
227
+ // The id is generated up front so the create's `prev` snapshot reads
228
+ // the not-yet-existing row as absent.
229
+ const dependencyId = crypto.randomUUID();
230
+ let created!: Awaited<
231
+ ReturnType<typeof deps.service.createDependency>
232
+ >;
233
+ await writeDependencyEdge({
234
+ handle: deps.getDependencyEntity?.(),
235
+ dependencyId,
236
+ apply: async () => {
237
+ created = await deps.service.createDependency(
238
+ {
239
+ sourceSystemId: config.sourceSystemId,
240
+ targetSystemId: config.targetSystemId,
241
+ impactType: config.impactType,
242
+ transitive: config.transitive,
243
+ label: config.label,
244
+ healthCheckRules: [],
245
+ },
246
+ dependencyId,
247
+ );
248
+ return toDependencyEdgeState(created);
249
+ },
250
+ });
251
+ logger.info(`Automation created dependency ${created.id}`);
252
+ return {
253
+ success: true,
254
+ externalId: created.id,
255
+ artifact: {
256
+ dependencyId: created.id,
257
+ sourceSystemId: created.sourceSystemId,
258
+ targetSystemId: created.targetSystemId,
259
+ impactType: created.impactType,
260
+ },
261
+ };
262
+ } catch (error) {
263
+ // Both duplicate-edge and cycle detection throw — surface the
264
+ // user-facing message so the run-detail UI shows the reason.
265
+ return { success: false, error: extractErrorMessage(error) };
266
+ }
267
+ },
268
+ };
269
+
270
+ const removeAction: ActionDefinition<
271
+ DependencyRemoveConfig,
272
+ DependencyArtifact
273
+ > = {
274
+ id: "remove",
275
+ displayName: "Remove Dependency",
276
+ description: "Delete a dependency edge by id",
277
+ category: "Dependencies",
278
+ icon: "Network",
279
+ config: new Versioned({
280
+ version: 1,
281
+ schema: dependencyRemoveConfigSchema,
282
+ }),
283
+ produces: "dependency.edge",
284
+ execute: async ({ config, logger }) => {
285
+ const existing = await deps.service.getDependencyById(config.dependencyId);
286
+ if (!existing) {
287
+ return {
288
+ success: false,
289
+ error: `Dependency not found: ${config.dependencyId}`,
290
+ };
291
+ }
292
+ // Drive the delete through the reactive `dependency-edge` entity
293
+ // tombstone (§10.5); the REAL delete runs INSIDE `apply`, so `prev` is
294
+ // snapshotted before it and the deriver fires `dependency.deleted`.
295
+ let removed = false;
296
+ await removeDependencyEdge({
297
+ handle: deps.getDependencyEntity?.(),
298
+ dependencyId: existing.id,
299
+ apply: async () => {
300
+ removed = await deps.service.deleteDependency(config.dependencyId);
301
+ },
302
+ });
303
+ if (!removed) {
304
+ return {
305
+ success: false,
306
+ error: `Dependency ${config.dependencyId} disappeared mid-delete`,
307
+ };
308
+ }
309
+ logger.info(`Automation removed dependency ${existing.id}`);
310
+ return {
311
+ success: true,
312
+ externalId: existing.id,
313
+ artifact: {
314
+ dependencyId: existing.id,
315
+ sourceSystemId: existing.sourceSystemId,
316
+ targetSystemId: existing.targetSystemId,
317
+ impactType: existing.impactType,
318
+ },
319
+ };
320
+ },
321
+ };
322
+
323
+ return [
324
+ createAction as ActionDefinition<unknown, unknown>,
325
+ removeAction as ActionDefinition<unknown, unknown>,
326
+ ];
327
+ }
@@ -0,0 +1,270 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import type {
3
+ EntityChanged,
4
+ EntityHandle,
5
+ } from "@checkstack/automation-backend";
6
+ import { SYSTEM_ACTOR } from "@checkstack/common";
7
+
8
+ import {
9
+ DEPENDENCY_EDGE_ENTITY_KIND,
10
+ DEPENDENCY_TRIGGER_EVENTS,
11
+ DependencyEdgeStateSchema,
12
+ createDependencyEntityRead,
13
+ dependencyChangeToPayload,
14
+ deriveDependencyTriggerEvents,
15
+ removeDependencyEdge,
16
+ toDependencyEdgeState,
17
+ writeDependencyEdge,
18
+ type DependencyEdgeState,
19
+ } from "./dependency-entity";
20
+ import {
21
+ dependencyCreatedTrigger,
22
+ dependencyDeletedTrigger,
23
+ dependencyUpdatedTrigger,
24
+ } from "./automations";
25
+ import type { DependencyService } from "./services/dependency-service";
26
+
27
+ function change(overrides: Partial<EntityChanged> = {}): EntityChanged {
28
+ return {
29
+ kind: DEPENDENCY_EDGE_ENTITY_KIND,
30
+ id: "dep-1",
31
+ prev: {
32
+ sourceSystemId: "a",
33
+ targetSystemId: "b",
34
+ impactType: "degraded",
35
+ transitive: false,
36
+ },
37
+ next: {
38
+ sourceSystemId: "a",
39
+ targetSystemId: "b",
40
+ impactType: "critical",
41
+ transitive: false,
42
+ },
43
+ delta: { impactType: "critical" },
44
+ changedFields: ["impactType"],
45
+ actor: SYSTEM_ACTOR,
46
+ occurredAt: new Date().toISOString(),
47
+ ...overrides,
48
+ };
49
+ }
50
+
51
+ describe("dependencyChangeToPayload — payloadSchema parity", () => {
52
+ it("a create payload validates against the created trigger's payloadSchema", () => {
53
+ const payload = dependencyChangeToPayload(
54
+ change({
55
+ prev: null,
56
+ next: {
57
+ sourceSystemId: "a",
58
+ targetSystemId: "b",
59
+ impactType: "degraded",
60
+ transitive: false,
61
+ },
62
+ }),
63
+ );
64
+ const parsed = dependencyCreatedTrigger.payloadSchema.parse(payload);
65
+ expect(parsed.dependencyId).toBe("dep-1");
66
+ expect(parsed.sourceSystemId).toBe("a");
67
+ expect(parsed.targetSystemId).toBe("b");
68
+ expect(parsed.impactType).toBe("degraded");
69
+ });
70
+
71
+ it("an update payload validates against the updated trigger's payloadSchema", () => {
72
+ const parsed = dependencyUpdatedTrigger.payloadSchema.parse(
73
+ dependencyChangeToPayload(change()),
74
+ );
75
+ expect(parsed.dependencyId).toBe("dep-1");
76
+ expect(parsed.impactType).toBe("critical");
77
+ });
78
+
79
+ it("a delete payload validates against the deleted trigger's payloadSchema (endpoints from prev)", () => {
80
+ const parsed = dependencyDeletedTrigger.payloadSchema.parse(
81
+ dependencyChangeToPayload(change({ next: null })),
82
+ );
83
+ expect(parsed.dependencyId).toBe("dep-1");
84
+ // The deleted edge's endpoints come from `prev` (next is the tombstone).
85
+ expect(parsed.sourceSystemId).toBe("a");
86
+ expect(parsed.targetSystemId).toBe("b");
87
+ });
88
+ });
89
+
90
+ describe("deriveDependencyTriggerEvents", () => {
91
+ it("create → dependency.created", () => {
92
+ expect(
93
+ deriveDependencyTriggerEvents(
94
+ change({
95
+ prev: null,
96
+ next: {
97
+ sourceSystemId: "a",
98
+ targetSystemId: "b",
99
+ impactType: "degraded",
100
+ transitive: false,
101
+ },
102
+ }),
103
+ ),
104
+ ).toEqual([DEPENDENCY_TRIGGER_EVENTS.created]);
105
+ });
106
+ it("tombstone → dependency.deleted", () => {
107
+ expect(deriveDependencyTriggerEvents(change({ next: null }))).toEqual([
108
+ DEPENDENCY_TRIGGER_EVENTS.deleted,
109
+ ]);
110
+ });
111
+ it("update → dependency.updated", () => {
112
+ expect(deriveDependencyTriggerEvents(change())).toEqual([
113
+ DEPENDENCY_TRIGGER_EVENTS.updated,
114
+ ]);
115
+ });
116
+ });
117
+
118
+ describe("DependencyEdgeStateSchema", () => {
119
+ it("parses the reactive subset", () => {
120
+ const parsed = DependencyEdgeStateSchema.parse({
121
+ sourceSystemId: "a",
122
+ targetSystemId: "b",
123
+ impactType: "degraded",
124
+ transitive: true,
125
+ });
126
+ expect(parsed.transitive).toBe(true);
127
+ });
128
+ });
129
+
130
+ describe("toDependencyEdgeState", () => {
131
+ it("projects the reactive subset off a full dependency", () => {
132
+ expect(
133
+ toDependencyEdgeState({
134
+ sourceSystemId: "a",
135
+ targetSystemId: "b",
136
+ impactType: "critical",
137
+ transitive: true,
138
+ }),
139
+ ).toEqual({
140
+ sourceSystemId: "a",
141
+ targetSystemId: "b",
142
+ impactType: "critical",
143
+ transitive: true,
144
+ });
145
+ });
146
+ });
147
+
148
+ describe("createDependencyEntityRead", () => {
149
+ it("routes the batched read straight to the service (plugin-backed)", async () => {
150
+ const seen: ReadonlyArray<string>[] = [];
151
+ const service = {
152
+ async getManyEntityStates(ids: ReadonlyArray<string>) {
153
+ seen.push(ids);
154
+ return {
155
+ "dep-1": {
156
+ sourceSystemId: "a",
157
+ targetSystemId: "b",
158
+ impactType: "degraded" as const,
159
+ transitive: false,
160
+ },
161
+ };
162
+ },
163
+ } as unknown as DependencyService;
164
+ const read = createDependencyEntityRead(service);
165
+ const out = await read(["dep-1", "dep-2"]);
166
+ expect(seen).toEqual([["dep-1", "dep-2"]]);
167
+ expect(out["dep-1"]).toEqual({
168
+ sourceSystemId: "a",
169
+ targetSystemId: "b",
170
+ impactType: "degraded",
171
+ transitive: false,
172
+ });
173
+ });
174
+ });
175
+
176
+ describe("writeDependencyEdge", () => {
177
+ it("drives the write through handle.mutate keyed by dependency id", async () => {
178
+ const calls: Array<{ id: string; next: DependencyEdgeState }> = [];
179
+ const handle = {
180
+ kind: DEPENDENCY_EDGE_ENTITY_KIND,
181
+ async mutate(input: {
182
+ id: string;
183
+ apply: () => Promise<DependencyEdgeState>;
184
+ }) {
185
+ const next = await input.apply();
186
+ calls.push({ id: input.id, next });
187
+ return next;
188
+ },
189
+ } as unknown as EntityHandle<DependencyEdgeState>;
190
+ let applied = false;
191
+ await writeDependencyEdge({
192
+ handle,
193
+ dependencyId: "dep-9",
194
+ apply: async () => {
195
+ applied = true;
196
+ return {
197
+ sourceSystemId: "a",
198
+ targetSystemId: "b",
199
+ impactType: "critical",
200
+ transitive: true,
201
+ };
202
+ },
203
+ });
204
+ expect(applied).toBe(true);
205
+ expect(calls).toEqual([
206
+ {
207
+ id: "dep-9",
208
+ next: {
209
+ sourceSystemId: "a",
210
+ targetSystemId: "b",
211
+ impactType: "critical",
212
+ transitive: true,
213
+ },
214
+ },
215
+ ]);
216
+ });
217
+
218
+ it("still runs the plugin write when no handle is wired", async () => {
219
+ let applied = false;
220
+ await writeDependencyEdge({
221
+ handle: undefined,
222
+ dependencyId: "x",
223
+ apply: async () => {
224
+ applied = true;
225
+ return {
226
+ sourceSystemId: "a",
227
+ targetSystemId: "b",
228
+ impactType: "degraded",
229
+ transitive: false,
230
+ };
231
+ },
232
+ });
233
+ expect(applied).toBe(true);
234
+ });
235
+ });
236
+
237
+ describe("removeDependencyEdge", () => {
238
+ it("tombstones via handle.remove({ apply })", async () => {
239
+ const removed: string[] = [];
240
+ let deleted = false;
241
+ const handle = {
242
+ kind: DEPENDENCY_EDGE_ENTITY_KIND,
243
+ async remove(input: { id: string; apply: () => Promise<void> }) {
244
+ await input.apply();
245
+ removed.push(input.id);
246
+ },
247
+ } as unknown as EntityHandle<DependencyEdgeState>;
248
+ await removeDependencyEdge({
249
+ handle,
250
+ dependencyId: "dep-9",
251
+ apply: async () => {
252
+ deleted = true;
253
+ },
254
+ });
255
+ expect(deleted).toBe(true);
256
+ expect(removed).toEqual(["dep-9"]);
257
+ });
258
+
259
+ it("still runs the delete when no handle is wired", async () => {
260
+ let deleted = false;
261
+ await removeDependencyEdge({
262
+ handle: undefined,
263
+ dependencyId: "x",
264
+ apply: async () => {
265
+ deleted = true;
266
+ },
267
+ });
268
+ expect(deleted).toBe(true);
269
+ });
270
+ });