@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.
- package/CHANGELOG.md +185 -0
- package/package.json +19 -17
- package/src/automations.test.ts +363 -0
- package/src/automations.ts +327 -0
- package/src/dependency-entity.test.ts +270 -0
- package/src/dependency-entity.ts +157 -0
- package/src/hooks.ts +31 -24
- package/src/index.ts +156 -26
- package/src/notifications.ts +51 -0
- package/src/router.ts +72 -29
- package/src/services/dependency-service.ts +67 -3
- package/tsconfig.json +3 -0
|
@@ -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
|
+
});
|