@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,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The reactive `dependency-edge` entity (reactive automation engine §10.5).
|
|
3
|
+
*
|
|
4
|
+
* Model B PLUGIN-BACKED entity: the `dependencies` table is authoritative AND
|
|
5
|
+
* IS the entity's current-state storage — there is NO framework `entity_state`
|
|
6
|
+
* row for a dependency edge. `defineEntity({ read })` makes that plugin state
|
|
7
|
+
* reactive: every reactive-state write goes through `handle.mutate`, whose
|
|
8
|
+
* `apply()` performs the REAL `dependencies` write via the `DependencyService`
|
|
9
|
+
* (the plugin's own db/tx) and returns the resulting reactive subset
|
|
10
|
+
* `{ sourceSystemId, targetSystemId, impactType, transitive }`. The framework
|
|
11
|
+
* snapshots `prev` via `read`, appends the transition log (its own db), and
|
|
12
|
+
* emits `ENTITY_CHANGED`. The change → trigger-event deriver reproduces
|
|
13
|
+
* `dependency.created/.updated/.deleted` so automations keep firing.
|
|
14
|
+
*
|
|
15
|
+
* `dependency_derived_states` (the propagation cursor) and the
|
|
16
|
+
* `dependency.impact_propagated` notification stay NON-reactive (§5):
|
|
17
|
+
* derived per-system state is reachable via the `health` entity, and
|
|
18
|
+
* impact propagation is a fan-out signal, not a single mutable field.
|
|
19
|
+
*/
|
|
20
|
+
import { z } from "zod";
|
|
21
|
+
import { ImpactTypeSchema } from "@checkstack/dependency-common";
|
|
22
|
+
import type {
|
|
23
|
+
EntityChangeDeriver,
|
|
24
|
+
EntityChangePayloadMapper,
|
|
25
|
+
EntityHandle,
|
|
26
|
+
EntityRead,
|
|
27
|
+
} from "@checkstack/automation-backend";
|
|
28
|
+
import {
|
|
29
|
+
withEntityRemove,
|
|
30
|
+
withEntityWrite,
|
|
31
|
+
} from "@checkstack/automation-backend";
|
|
32
|
+
|
|
33
|
+
import type { DependencyService } from "./services/dependency-service";
|
|
34
|
+
|
|
35
|
+
export const DEPENDENCY_EDGE_ENTITY_KIND = "dependency-edge";
|
|
36
|
+
|
|
37
|
+
export const DependencyEdgeStateSchema = z.object({
|
|
38
|
+
sourceSystemId: z.string(),
|
|
39
|
+
targetSystemId: z.string(),
|
|
40
|
+
impactType: ImpactTypeSchema,
|
|
41
|
+
transitive: z.boolean(),
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
export type DependencyEdgeState = z.infer<typeof DependencyEdgeStateSchema>;
|
|
45
|
+
|
|
46
|
+
export const DEPENDENCY_TRIGGER_EVENTS = {
|
|
47
|
+
created: "dependency.created",
|
|
48
|
+
updated: "dependency.updated",
|
|
49
|
+
deleted: "dependency.deleted",
|
|
50
|
+
} as const;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* `dependency-edge` change → trigger events. Create (`prev === null`),
|
|
54
|
+
* tombstone (`next === null`), or a field update map to the matching
|
|
55
|
+
* lifecycle event (the handle suppresses no-op diffs, so an update always
|
|
56
|
+
* carries a real change).
|
|
57
|
+
*/
|
|
58
|
+
export const deriveDependencyTriggerEvents: EntityChangeDeriver = (changed) => {
|
|
59
|
+
if (changed.prev === null && changed.next !== null) {
|
|
60
|
+
return [DEPENDENCY_TRIGGER_EVENTS.created];
|
|
61
|
+
}
|
|
62
|
+
if (changed.next === null) {
|
|
63
|
+
return [DEPENDENCY_TRIGGER_EVENTS.deleted];
|
|
64
|
+
}
|
|
65
|
+
return [DEPENDENCY_TRIGGER_EVENTS.updated];
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Map a `dependency-edge` change to the domain-named `trigger.payload` the
|
|
70
|
+
* dependency triggers declare via `payloadSchema` (`dependencyId`,
|
|
71
|
+
* `sourceSystemId`, `targetSystemId`, `impactType`). Restores the keys
|
|
72
|
+
* operators read (`trigger.payload.dependencyId`, `.sourceSystemId`, …) that
|
|
73
|
+
* the generic change shape omits.
|
|
74
|
+
*
|
|
75
|
+
* `dependencyId` is the entity id. The edge fields are read from `next`, or
|
|
76
|
+
* from `prev` on a tombstone (`deleted`), so a delete trigger still carries
|
|
77
|
+
* the removed edge's endpoints. `impactType` is omitted on a delete (the
|
|
78
|
+
* `deleted` schema does not declare it).
|
|
79
|
+
*/
|
|
80
|
+
export const dependencyChangeToPayload: EntityChangePayloadMapper = (
|
|
81
|
+
changed,
|
|
82
|
+
) => {
|
|
83
|
+
const source = changed.next ?? changed.prev;
|
|
84
|
+
const impactType = source?.["impactType"];
|
|
85
|
+
return {
|
|
86
|
+
dependencyId: changed.id,
|
|
87
|
+
sourceSystemId: source?.["sourceSystemId"],
|
|
88
|
+
targetSystemId: source?.["targetSystemId"],
|
|
89
|
+
...(impactType === undefined ? {} : { impactType }),
|
|
90
|
+
};
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Build the PLUGIN-BACKED `read` accessor for the `dependency-edge` entity.
|
|
95
|
+
* Routes straight to the service's batched authoritative read over the
|
|
96
|
+
* `dependencies` table — no framework storage.
|
|
97
|
+
*/
|
|
98
|
+
export function createDependencyEntityRead(
|
|
99
|
+
service: DependencyService,
|
|
100
|
+
): EntityRead<DependencyEdgeState> {
|
|
101
|
+
return (ids) => service.getManyEntityStates(ids);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Project a dependency row onto the reactive `{ sourceSystemId,
|
|
106
|
+
* targetSystemId, impactType, transitive }` subset. The router/action service
|
|
107
|
+
* writes return the full dependency; this is the `apply()` return for
|
|
108
|
+
* `handle.mutate`.
|
|
109
|
+
*/
|
|
110
|
+
export function toDependencyEdgeState(dependency: {
|
|
111
|
+
sourceSystemId: string;
|
|
112
|
+
targetSystemId: string;
|
|
113
|
+
impactType: DependencyEdgeState["impactType"];
|
|
114
|
+
transitive: boolean;
|
|
115
|
+
}): DependencyEdgeState {
|
|
116
|
+
return {
|
|
117
|
+
sourceSystemId: dependency.sourceSystemId,
|
|
118
|
+
targetSystemId: dependency.targetSystemId,
|
|
119
|
+
impactType: dependency.impactType,
|
|
120
|
+
transitive: dependency.transitive,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Drive a reactive-state `dependency-edge` write through `handle.mutate`
|
|
126
|
+
* (§10.5). `apply` performs the REAL `dependencies` write via the service
|
|
127
|
+
* (the plugin's own db/tx) and returns the new reactive state. The framework
|
|
128
|
+
* snapshots `prev`, appends the transition log, and emits `ENTITY_CHANGED`
|
|
129
|
+
* (the deriver turns that into `dependency.created/.updated`).
|
|
130
|
+
*
|
|
131
|
+
* When no handle is available (tests construct the router without one), the
|
|
132
|
+
* write still runs — the entity reactivity is layered on top, never required
|
|
133
|
+
* for the underlying write to succeed.
|
|
134
|
+
*/
|
|
135
|
+
export async function writeDependencyEdge(args: {
|
|
136
|
+
handle: EntityHandle<DependencyEdgeState> | undefined;
|
|
137
|
+
dependencyId: string;
|
|
138
|
+
apply: () => Promise<DependencyEdgeState>;
|
|
139
|
+
}): Promise<void> {
|
|
140
|
+
const { handle, dependencyId, apply } = args;
|
|
141
|
+
await withEntityWrite({ handle, id: dependencyId, apply });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Drive a dependency-edge tombstone through `handle.remove` (§10.5). `apply`
|
|
146
|
+
* performs the REAL delete via the service; the framework records the
|
|
147
|
+
* tombstone transition and emits a tombstone change (the deriver fires
|
|
148
|
+
* `dependency.deleted`). Without a handle, the delete still runs.
|
|
149
|
+
*/
|
|
150
|
+
export async function removeDependencyEdge(args: {
|
|
151
|
+
handle: EntityHandle<DependencyEdgeState> | undefined;
|
|
152
|
+
dependencyId: string;
|
|
153
|
+
apply: () => Promise<void>;
|
|
154
|
+
}): Promise<void> {
|
|
155
|
+
const { handle, dependencyId, apply } = args;
|
|
156
|
+
await withEntityRemove({ handle, id: dependencyId, apply });
|
|
157
|
+
}
|
package/src/hooks.ts
CHANGED
|
@@ -1,36 +1,43 @@
|
|
|
1
1
|
import { createHook } from "@checkstack/backend-api";
|
|
2
|
+
import type { DerivedState } from "@checkstack/dependency-common";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Dependency hooks for cross-plugin communication.
|
|
5
6
|
* Other plugins can subscribe to these hooks to react to dependency changes.
|
|
7
|
+
*
|
|
8
|
+
* `impactType` and the derived-state fields carry the canonical
|
|
9
|
+
* `ImpactType` / `DerivedState` enum values, so automation triggers
|
|
10
|
+
* built on these hooks can offer the known values for `==` comparisons
|
|
11
|
+
* in the editor.
|
|
6
12
|
*/
|
|
7
13
|
export const dependencyHooks = {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
targetSystemId: string;
|
|
15
|
-
impactType: string;
|
|
16
|
-
}>("dependency.created"),
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Emitted when a dependency is updated.
|
|
20
|
-
*/
|
|
21
|
-
dependencyUpdated: createHook<{
|
|
22
|
-
dependencyId: string;
|
|
23
|
-
sourceSystemId: string;
|
|
24
|
-
targetSystemId: string;
|
|
25
|
-
impactType: string;
|
|
26
|
-
}>("dependency.updated"),
|
|
14
|
+
// The `dependency.created` / `.updated` / `.deleted` hooks were removed in
|
|
15
|
+
// Phase 4 (§10.5): dependency edges are now the reactive `dependency-edge`
|
|
16
|
+
// entity, whose change deriver fires the matching `dependency.created` /
|
|
17
|
+
// `.updated` / `.deleted` trigger events through Stage-1 routing. The
|
|
18
|
+
// `impact_propagated` hook below is KEPT — it is a derived fan-out signal
|
|
19
|
+
// (per-downstream deltas), not a single mutable entity field.
|
|
27
20
|
|
|
28
21
|
/**
|
|
29
|
-
* Emitted when
|
|
22
|
+
* Emitted when an upstream system's state change has propagated
|
|
23
|
+
* through the dependency graph and produced derived-state changes
|
|
24
|
+
* on one or more downstream systems. Carries the list of affected
|
|
25
|
+
* downstream systems with their previous and new derived states so
|
|
26
|
+
* subscribers don't have to re-query the graph.
|
|
27
|
+
*
|
|
28
|
+
* Fires only when at least one downstream state actually changed —
|
|
29
|
+
* upstream events that don't move any downstream's derived state
|
|
30
|
+
* are silent. Emitted from `evaluateAndNotifyDownstream` once per
|
|
31
|
+
* upstream event (deduplicated by `sourceSystemId`, not by
|
|
32
|
+
* downstream).
|
|
30
33
|
*/
|
|
31
|
-
|
|
32
|
-
dependencyId: string;
|
|
34
|
+
impactPropagated: createHook<{
|
|
33
35
|
sourceSystemId: string;
|
|
34
|
-
|
|
35
|
-
|
|
36
|
+
affectedSystems: Array<{
|
|
37
|
+
systemId: string;
|
|
38
|
+
previousState: DerivedState | null;
|
|
39
|
+
newState: DerivedState | null;
|
|
40
|
+
}>;
|
|
41
|
+
timestamp: string;
|
|
42
|
+
}>("dependency.impact_propagated"),
|
|
36
43
|
} as const;
|
package/src/index.ts
CHANGED
|
@@ -8,18 +8,42 @@ import {
|
|
|
8
8
|
dependencyGroupSubscription,
|
|
9
9
|
} from "@checkstack/dependency-common";
|
|
10
10
|
import { createBackendPlugin, coreServices } from "@checkstack/backend-api";
|
|
11
|
+
import {
|
|
12
|
+
automationActionExtensionPoint,
|
|
13
|
+
automationArtifactTypeExtensionPoint,
|
|
14
|
+
automationTriggerExtensionPoint,
|
|
15
|
+
entityExtensionPoint,
|
|
16
|
+
type EntityHandle,
|
|
17
|
+
} from "@checkstack/automation-backend";
|
|
11
18
|
import { DependencyService } from "./services/dependency-service";
|
|
12
19
|
import { WarningEvaluationService } from "./services/warning-evaluation-service";
|
|
13
20
|
import type { SystemStatus } from "./services/warning-evaluation-service";
|
|
14
21
|
import { createRouter } from "./router";
|
|
22
|
+
import {
|
|
23
|
+
createDependencyActions,
|
|
24
|
+
dependencyArtifactType,
|
|
25
|
+
dependencyTriggers,
|
|
26
|
+
} from "./automations";
|
|
27
|
+
import { dependencyHooks } from "./hooks";
|
|
15
28
|
import { CatalogApi } from "@checkstack/catalog-common";
|
|
16
29
|
import { HealthCheckApi } from "@checkstack/healthcheck-common";
|
|
17
30
|
import { MaintenanceApi } from "@checkstack/maintenance-common";
|
|
18
31
|
import { IncidentApi } from "@checkstack/incident-common";
|
|
19
32
|
import { NotificationApi } from "@checkstack/notification-common";
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
33
|
+
import { CATALOG_SYSTEM_ENTITY_KIND } from "@checkstack/catalog-backend";
|
|
34
|
+
import {
|
|
35
|
+
HEALTH_ENTITY_KIND,
|
|
36
|
+
classifyHealthChange,
|
|
37
|
+
} from "@checkstack/healthcheck-backend";
|
|
22
38
|
import { evaluateAndNotifyDownstream } from "./notifications";
|
|
39
|
+
import {
|
|
40
|
+
DEPENDENCY_EDGE_ENTITY_KIND,
|
|
41
|
+
DependencyEdgeStateSchema,
|
|
42
|
+
createDependencyEntityRead,
|
|
43
|
+
dependencyChangeToPayload,
|
|
44
|
+
deriveDependencyTriggerEvents,
|
|
45
|
+
type DependencyEdgeState,
|
|
46
|
+
} from "./dependency-entity";
|
|
23
47
|
import { entityKindExtensionPoint } from "@checkstack/gitops-backend";
|
|
24
48
|
import { registerDependencyGitOpsKinds } from "./dependency-gitops-kinds";
|
|
25
49
|
|
|
@@ -27,6 +51,17 @@ import { registerDependencyGitOpsKinds } from "./dependency-gitops-kinds";
|
|
|
27
51
|
// Plugin Definition
|
|
28
52
|
// =============================================================================
|
|
29
53
|
|
|
54
|
+
// Reactive `dependency-edge` entity handle (§10.5). Defined in register();
|
|
55
|
+
// mutated from the router/actions onward.
|
|
56
|
+
let dependencyEntity: EntityHandle<DependencyEdgeState> | undefined;
|
|
57
|
+
|
|
58
|
+
// The DependencyService is created in init() (it needs the resolved
|
|
59
|
+
// database), but the PLUGIN-BACKED entity `read` accessor must be supplied at
|
|
60
|
+
// `defineEntity` time in register(). This holder bridges the two: the `read`
|
|
61
|
+
// closure resolves the service lazily, and init() sets it before any mutation
|
|
62
|
+
// runs (the registry only mutates from init() onward).
|
|
63
|
+
let dependencyServiceRef: DependencyService | undefined;
|
|
64
|
+
|
|
30
65
|
export default createBackendPlugin({
|
|
31
66
|
metadata: pluginMetadata,
|
|
32
67
|
register(env) {
|
|
@@ -36,6 +71,51 @@ export default createBackendPlugin({
|
|
|
36
71
|
dependencyGroupSubscription,
|
|
37
72
|
]);
|
|
38
73
|
|
|
74
|
+
// ─── Automation Platform: triggers + artifact type ─────────────────
|
|
75
|
+
const automationTriggers = env.getExtensionPoint(
|
|
76
|
+
automationTriggerExtensionPoint,
|
|
77
|
+
);
|
|
78
|
+
for (const trigger of dependencyTriggers) {
|
|
79
|
+
automationTriggers.registerTrigger(trigger, pluginMetadata);
|
|
80
|
+
}
|
|
81
|
+
env
|
|
82
|
+
.getExtensionPoint(automationArtifactTypeExtensionPoint)
|
|
83
|
+
.registerArtifactType(dependencyArtifactType, pluginMetadata);
|
|
84
|
+
|
|
85
|
+
// ─── Reactive `dependency-edge` entity (§10.5) ─────────────────────
|
|
86
|
+
// PLUGIN-BACKED (Model B): the `dependencies` table IS the current-state
|
|
87
|
+
// storage. `read` routes straight to the service's batched authoritative
|
|
88
|
+
// read — no framework `entity_state` row, so no `indexes` (those only
|
|
89
|
+
// apply to store-backed kinds). The `read` closure resolves the service
|
|
90
|
+
// set by init() (mutations only happen from init on).
|
|
91
|
+
const entityPoint = env.getExtensionPoint(entityExtensionPoint);
|
|
92
|
+
dependencyEntity = entityPoint.defineEntity<DependencyEdgeState>({
|
|
93
|
+
kind: DEPENDENCY_EDGE_ENTITY_KIND,
|
|
94
|
+
state: DependencyEdgeStateSchema,
|
|
95
|
+
read: (ids) => {
|
|
96
|
+
const svc = dependencyServiceRef;
|
|
97
|
+
if (!svc) {
|
|
98
|
+
throw new Error(
|
|
99
|
+
"dependency entity read before init: service not yet resolved",
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
return createDependencyEntityRead(svc)(ids);
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
entityPoint.registerChangeDeriver({
|
|
106
|
+
kind: DEPENDENCY_EDGE_ENTITY_KIND,
|
|
107
|
+
derive: deriveDependencyTriggerEvents,
|
|
108
|
+
toPayload: dependencyChangeToPayload,
|
|
109
|
+
});
|
|
110
|
+
const onEntityChanged = entityPoint.onEntityChanged;
|
|
111
|
+
// The propagation cursor is internal bookkeeping (§5): derived
|
|
112
|
+
// per-system state is reachable via the `health` entity, not this table.
|
|
113
|
+
entityPoint.declareNonReactiveState({
|
|
114
|
+
table: "dependency_derived_states",
|
|
115
|
+
reason: "bookkeeping",
|
|
116
|
+
note: "Propagation cursor for downstream impact evaluation. Derived per-system state is reachable via the health entity.",
|
|
117
|
+
});
|
|
118
|
+
|
|
39
119
|
// ─── GitOps Entity Kind Registration ─────────────────────────────
|
|
40
120
|
let gitopsService: DependencyService | undefined;
|
|
41
121
|
const kindRegistry = env.getExtensionPoint(entityKindExtensionPoint);
|
|
@@ -66,6 +146,9 @@ export default createBackendPlugin({
|
|
|
66
146
|
database as SafeDatabase<typeof schema>,
|
|
67
147
|
);
|
|
68
148
|
gitopsService = service;
|
|
149
|
+
// Publish the service for the PLUGIN-BACKED entity `read` accessor
|
|
150
|
+
// (defined in register()). Mutations only run from here onward.
|
|
151
|
+
dependencyServiceRef = service;
|
|
69
152
|
const warningService = new WarningEvaluationService();
|
|
70
153
|
|
|
71
154
|
const router = createRouter({
|
|
@@ -75,6 +158,7 @@ export default createBackendPlugin({
|
|
|
75
158
|
catalogClient,
|
|
76
159
|
healthCheckClient,
|
|
77
160
|
logger,
|
|
161
|
+
getDependencyEntity: () => dependencyEntity,
|
|
78
162
|
});
|
|
79
163
|
rpc.registerRouter(router, dependencyContract);
|
|
80
164
|
|
|
@@ -84,13 +168,39 @@ export default createBackendPlugin({
|
|
|
84
168
|
database,
|
|
85
169
|
rpcClient,
|
|
86
170
|
logger,
|
|
87
|
-
|
|
171
|
+
emitHook,
|
|
88
172
|
signalService,
|
|
89
173
|
}) => {
|
|
174
|
+
// Bound callback that fires `dependency.impact_propagated`
|
|
175
|
+
// when `evaluateAndNotifyDownstream` reports actual downstream
|
|
176
|
+
// state transitions. Local so we don't pass the full
|
|
177
|
+
// `emitHook` into helper modules that should only know about
|
|
178
|
+
// the one hook they fire.
|
|
179
|
+
const emitImpactPropagated = (event: {
|
|
180
|
+
sourceSystemId: string;
|
|
181
|
+
affectedSystems: Array<{
|
|
182
|
+
systemId: string;
|
|
183
|
+
previousState: string | null;
|
|
184
|
+
newState: string | null;
|
|
185
|
+
}>;
|
|
186
|
+
timestamp: string;
|
|
187
|
+
}) => emitHook(dependencyHooks.impactPropagated, event);
|
|
90
188
|
const typedDb = database as SafeDatabase<typeof schema>;
|
|
91
189
|
const service = new DependencyService(typedDb);
|
|
92
190
|
const warningService = new WarningEvaluationService();
|
|
93
191
|
|
|
192
|
+
// Register automation actions now that `emitHook` is available.
|
|
193
|
+
const automationActions = env.getExtensionPoint(
|
|
194
|
+
automationActionExtensionPoint,
|
|
195
|
+
);
|
|
196
|
+
for (const action of createDependencyActions({
|
|
197
|
+
service,
|
|
198
|
+
emitHook,
|
|
199
|
+
getDependencyEntity: () => dependencyEntity,
|
|
200
|
+
})) {
|
|
201
|
+
automationActions.registerAction(action, pluginMetadata);
|
|
202
|
+
}
|
|
203
|
+
|
|
94
204
|
const catalogClient = rpcClient.forPlugin(CatalogApi);
|
|
95
205
|
const healthCheckClient = rpcClient.forPlugin(HealthCheckApi);
|
|
96
206
|
const maintenanceClient = rpcClient.forPlugin(MaintenanceApi);
|
|
@@ -158,27 +268,42 @@ export default createBackendPlugin({
|
|
|
158
268
|
return statuses;
|
|
159
269
|
}
|
|
160
270
|
|
|
161
|
-
//
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
271
|
+
// Cross-plugin consumers now react to the reactive `catalog-system`
|
|
272
|
+
// / `health` ENTITY changes via `onEntityChanged` instead of the
|
|
273
|
+
// (being-removed) catalog/healthcheck hooks (§10.5). All keep
|
|
274
|
+
// `work-queue` delivery: cleanup + downstream-propagation are
|
|
275
|
+
// side-effecting writes that must run exactly once per cluster, not
|
|
276
|
+
// per-instance (broadcast would re-run them N times).
|
|
277
|
+
|
|
278
|
+
// Subscribe to catalog system deletion (tombstone) to clean up edges
|
|
279
|
+
onEntityChanged({
|
|
280
|
+
kind: CATALOG_SYSTEM_ENTITY_KIND,
|
|
281
|
+
handler: async (change) => {
|
|
282
|
+
if (change.next !== null) return; // tombstone only
|
|
283
|
+
const systemId = change.id;
|
|
165
284
|
logger.debug(
|
|
166
|
-
`Cleaning up dependencies for deleted system: ${
|
|
285
|
+
`Cleaning up dependencies for deleted system: ${systemId}`,
|
|
167
286
|
);
|
|
168
|
-
await service.removeSystemDependencies(
|
|
287
|
+
await service.removeSystemDependencies(systemId);
|
|
169
288
|
},
|
|
170
|
-
|
|
171
|
-
|
|
289
|
+
delivery: {
|
|
290
|
+
mode: "work-queue",
|
|
291
|
+
workerGroup: "dependency-system-cleanup",
|
|
292
|
+
},
|
|
293
|
+
});
|
|
172
294
|
|
|
173
|
-
//
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
async (
|
|
295
|
+
// Upstream health DEGRADED → evaluate downstream dependents
|
|
296
|
+
onEntityChanged({
|
|
297
|
+
kind: HEALTH_ENTITY_KIND,
|
|
298
|
+
handler: async (change) => {
|
|
299
|
+
const { systemId, degraded, previousStatus, newStatus } =
|
|
300
|
+
classifyHealthChange(change);
|
|
301
|
+
if (!degraded) return;
|
|
177
302
|
logger.debug(
|
|
178
|
-
`Upstream ${
|
|
303
|
+
`Upstream ${systemId} degraded (${previousStatus} → ${newStatus}), evaluating downstream dependencies`,
|
|
179
304
|
);
|
|
180
305
|
await evaluateAndNotifyDownstream({
|
|
181
|
-
changedSystemId:
|
|
306
|
+
changedSystemId: systemId,
|
|
182
307
|
db: typedDb,
|
|
183
308
|
dependencyService: service,
|
|
184
309
|
warningService,
|
|
@@ -189,22 +314,26 @@ export default createBackendPlugin({
|
|
|
189
314
|
incidentClient,
|
|
190
315
|
signalService,
|
|
191
316
|
logger,
|
|
317
|
+
emitImpactPropagated,
|
|
192
318
|
});
|
|
193
319
|
},
|
|
194
|
-
{
|
|
320
|
+
delivery: {
|
|
195
321
|
mode: "work-queue",
|
|
196
322
|
workerGroup: "dependency-notification-evaluator",
|
|
197
323
|
},
|
|
198
|
-
);
|
|
324
|
+
});
|
|
199
325
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
326
|
+
// Upstream health RECOVERED → evaluate downstream dependents
|
|
327
|
+
onEntityChanged({
|
|
328
|
+
kind: HEALTH_ENTITY_KIND,
|
|
329
|
+
handler: async (change) => {
|
|
330
|
+
const { systemId, recovered } = classifyHealthChange(change);
|
|
331
|
+
if (!recovered) return;
|
|
203
332
|
logger.debug(
|
|
204
|
-
`Upstream ${
|
|
333
|
+
`Upstream ${systemId} recovered, evaluating downstream dependencies`,
|
|
205
334
|
);
|
|
206
335
|
await evaluateAndNotifyDownstream({
|
|
207
|
-
changedSystemId:
|
|
336
|
+
changedSystemId: systemId,
|
|
208
337
|
db: typedDb,
|
|
209
338
|
dependencyService: service,
|
|
210
339
|
warningService,
|
|
@@ -215,13 +344,14 @@ export default createBackendPlugin({
|
|
|
215
344
|
incidentClient,
|
|
216
345
|
signalService,
|
|
217
346
|
logger,
|
|
347
|
+
emitImpactPropagated,
|
|
218
348
|
});
|
|
219
349
|
},
|
|
220
|
-
{
|
|
350
|
+
delivery: {
|
|
221
351
|
mode: "work-queue",
|
|
222
352
|
workerGroup: "dependency-notification-recovery",
|
|
223
353
|
},
|
|
224
|
-
);
|
|
354
|
+
});
|
|
225
355
|
|
|
226
356
|
logger.debug("✅ Dependency Backend afterPluginsReady complete.");
|
|
227
357
|
},
|
package/src/notifications.ts
CHANGED
|
@@ -190,6 +190,7 @@ export async function evaluateAndNotifyDownstream({
|
|
|
190
190
|
incidentClient,
|
|
191
191
|
signalService,
|
|
192
192
|
logger,
|
|
193
|
+
emitImpactPropagated,
|
|
193
194
|
}: {
|
|
194
195
|
changedSystemId: string;
|
|
195
196
|
db: Db;
|
|
@@ -204,6 +205,21 @@ export async function evaluateAndNotifyDownstream({
|
|
|
204
205
|
incidentClient: InferClient<typeof IncidentApi>;
|
|
205
206
|
signalService: SignalService;
|
|
206
207
|
logger: Logger;
|
|
208
|
+
/**
|
|
209
|
+
* Optional callback fired when at least one downstream system's
|
|
210
|
+
* derived state actually changed. Wired in `afterPluginsReady` to
|
|
211
|
+
* emit `dependencyHooks.impactPropagated`; left undefined in tests
|
|
212
|
+
* + stripped-down harnesses to keep them allocation-free.
|
|
213
|
+
*/
|
|
214
|
+
emitImpactPropagated?: (event: {
|
|
215
|
+
sourceSystemId: string;
|
|
216
|
+
affectedSystems: Array<{
|
|
217
|
+
systemId: string;
|
|
218
|
+
previousState: string | null;
|
|
219
|
+
newState: string | null;
|
|
220
|
+
}>;
|
|
221
|
+
timestamp: string;
|
|
222
|
+
}) => Promise<void>;
|
|
207
223
|
}): Promise<void> {
|
|
208
224
|
try {
|
|
209
225
|
// 1. Find all downstream systems that depend on the changed system
|
|
@@ -309,6 +325,11 @@ export async function evaluateAndNotifyDownstream({
|
|
|
309
325
|
// 6. Evaluate state changes and collect systems that need notification
|
|
310
326
|
const changedSystemIds: string[] = [];
|
|
311
327
|
const systemsToNotify: SystemNotificationEntry[] = [];
|
|
328
|
+
const impactPropagatedAffected: Array<{
|
|
329
|
+
systemId: string;
|
|
330
|
+
previousState: string | null;
|
|
331
|
+
newState: string | null;
|
|
332
|
+
}> = [];
|
|
312
333
|
|
|
313
334
|
for (const systemId of downstreamIds) {
|
|
314
335
|
const currentWarning = warningMap.get(systemId);
|
|
@@ -320,6 +341,16 @@ export async function evaluateAndNotifyDownstream({
|
|
|
320
341
|
continue;
|
|
321
342
|
}
|
|
322
343
|
|
|
344
|
+
// Always record every derived-state transition for the
|
|
345
|
+
// automation hook — including ones suppressed for end-user
|
|
346
|
+
// notifications, since an operator may want to react to
|
|
347
|
+
// propagation regardless of suppression.
|
|
348
|
+
impactPropagatedAffected.push({
|
|
349
|
+
systemId,
|
|
350
|
+
previousState: previousState ?? null,
|
|
351
|
+
newState: currentState ?? null,
|
|
352
|
+
});
|
|
353
|
+
|
|
323
354
|
// State changed — update DB first
|
|
324
355
|
await (currentState
|
|
325
356
|
? db
|
|
@@ -389,6 +420,26 @@ export async function evaluateAndNotifyDownstream({
|
|
|
389
420
|
affectedSystemIds: changedSystemIds,
|
|
390
421
|
});
|
|
391
422
|
}
|
|
423
|
+
|
|
424
|
+
// 9. Fire the `dependency.impact_propagated` automation hook
|
|
425
|
+
// exactly once per upstream event, with the full set of
|
|
426
|
+
// downstream state transitions. Best-effort — failures are
|
|
427
|
+
// logged but never propagated up so a misbehaving subscriber
|
|
428
|
+
// can't disrupt notifications or signal broadcasts.
|
|
429
|
+
if (emitImpactPropagated && impactPropagatedAffected.length > 0) {
|
|
430
|
+
try {
|
|
431
|
+
await emitImpactPropagated({
|
|
432
|
+
sourceSystemId: changedSystemId,
|
|
433
|
+
affectedSystems: impactPropagatedAffected,
|
|
434
|
+
timestamp: new Date().toISOString(),
|
|
435
|
+
});
|
|
436
|
+
} catch (error) {
|
|
437
|
+
logger.error(
|
|
438
|
+
`Failed to emit dependency.impact_propagated hook for upstream ${changedSystemId}:`,
|
|
439
|
+
error,
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
392
443
|
} catch (error) {
|
|
393
444
|
// Don't crash the hook handler
|
|
394
445
|
logger.error(
|