@checkstack/dependency-backend 1.2.0 → 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 CHANGED
@@ -1,5 +1,97 @@
1
1
  # @checkstack/dependency-backend
2
2
 
3
+ ## 1.3.0
4
+
5
+ ### Minor Changes
6
+
7
+ - b995afb: Make `dependency-edge` a plugin-backed reactive entity via the Model-B entity state machine + rewire cross-plugin consumers.
8
+
9
+ Dependency defines a `dependency-edge` entity `{ sourceSystemId, targetSystemId, impactType, transitive }` keyed by dependency id. The `dependencies` table is BOTH authoritative AND the entity's current-state storage - there is no framework `entity_state` row for a dependency edge. `defineEntity` is given a plugin `read` accessor (`DependencyService.getManyEntityStates`) that projects the reactive subset straight off that table, and every reactive-state write goes through `handle.mutate` / `handle.remove`: `apply` performs the REAL `dependencies` write (the plugin's own db/tx, including the cycle/duplicate validation that may throw) and returns the new state; the framework snapshots `prev` via `read` BEFORE the write, appends the transition log, and emits `ENTITY_CHANGED` AFTER the write commits. Covered sites: create, update, delete (tombstone), plus the `dependency.create` / `dependency.remove` automation actions. Create sites pre-generate the id so the create's `prev` snapshot reads the not-yet-existing row as absent; `createDependency` accepts an optional pre-generated `id` (server-owned either way). The `dependency_derived_states` propagation cursor is declared non-reactive (bookkeeping).
10
+
11
+ A change -> trigger-event deriver reproduces the existing `dependency.created` / `.updated` / `.deleted` qualified events so automations keep firing. The old `dependency.created` / `.updated` / `.deleted` change hooks are removed; the catalog + healthcheck consumers switched from `onHook(<hook>)` to `onEntityChanged({ kind })`, all keeping `work-queue` delivery (cleanup + downstream-propagation are side-effecting writes that must run once per cluster):
12
+
13
+ - `dependency-system-cleanup`: reacts to `catalog-system` tombstones (`change.next === null`).
14
+ - `dependency-notification-evaluator` / `-recovery`: react to `health` changes filtered to a degraded / recovered transition via `classifyHealthChange`, reproducing the old `systemDegraded` / `systemHealthy` predicates.
15
+
16
+ `@checkstack/automation-backend` adds `makeEntityDrivenTriggerSetup()` - a no-op `setup` factory so a migrated domain's lifecycle triggers stay in the editor's trigger catalog (and register cleanly) while being fired by the entity change deriver via Stage-1 routing rather than a hook.
17
+
18
+ BREAKING CHANGES:
19
+
20
+ - The `dependency.created` / `dependency.updated` / `dependency.deleted` cross-plugin hooks (the `createHook` descriptors) are removed. Dependency lifecycle is now the reactive `dependency-edge` entity; the matching trigger events still fire (via the entity change deriver), so existing automations on `dependency.created/.updated/.deleted` keep working. The `dependency.impact_propagated` hook is KEPT (a derived fan-out signal, not a single mutable field). No in-repo plugin subscribed to the removed hooks.
21
+ - On the RPC create path, the `dependency.created` entity emit (via `mutate`) now precedes the `DEPENDENCY_CHANGED` realtime signal broadcast (previously the signal fired first, then the mirror); both still fire on a successful create.
22
+ - NARROWING: `dependency.updated` now fires only on a change to the REACTIVE state (`impactType`, `source`, `target`, or `transitive`). A label-only edit no longer fires `dependency.updated` (the label is not reactive entity state). Re-author any automation that needed to react to a label-only dependency edit against a different signal.
23
+
24
+ - b995afb: Restore the documented domain payload fields on entity-driven automation triggers.
25
+
26
+ Migrated triggers declare domain-named `payloadSchema`s (incident `incidentId`; health `systemId` / `previousStatus`; catalog `systemId` / `changedFields`; dependency `dependencyId`), but Stage-2 dispatch built `trigger.payload` from the generic entity-change shape (`{ kind, id, prev, next, delta, ...next }`). Operator filters and templates reading `trigger.payload.incidentId` / `.systemId` / `.previousStatus` silently resolved to `undefined` — a regression vs the legacy hook payloads.
27
+
28
+ Changes:
29
+
30
+ - `@checkstack/automation-backend`: `registerChangeDeriver` now accepts an optional per-kind `toPayload(changed) => Record<string, unknown>` mapper (at most one per kind; a second distinct mapper throws). Stage-2's `changedToPayload` uses the registered mapper to build `trigger.payload` so it matches the kind's declared `payloadSchema`, falling back to the generic change shape for kinds without a mapper. New exported type `EntityChangePayloadMapper`.
31
+ - `@checkstack/incident-backend`, `@checkstack/healthcheck-backend`, `@checkstack/catalog-backend`, `@checkstack/dependency-backend`: implement and register a `toPayload` for each entity-driven kind so `trigger.payload` carries the legacy domain keys again.
32
+
33
+ Descriptive incident payload fields not derivable from the reactive entity state (`title`, `description`, `createdAt`, `resolvedAt`) are now OPTIONAL on the incident trigger `payloadSchema`s — they were always absent from an entity-driven payload.
34
+
35
+ ### Patch Changes
36
+
37
+ - b995afb: Extract a shared `withEntityWrite` / `withEntityRemove` guard for PLUGIN-BACKED (Model B) reactive entities and refactor the per-domain copies onto it.
38
+
39
+ Every plugin-backed domain (incident, catalog, dependency, maintenance, slo, satellite) reimplemented the same "no handle wired → run the plugin write directly; handle wired → route through `handle.mutate` / `handle.remove`" guard, varying only in the id-key name. `@checkstack/automation-backend` now exports `withEntityWrite` / `withEntityRemove` (from the entity barrel) and each domain's thin, well-named wrappers (`writeIncidentEntity`, `writeMaintenanceEntity`, satellite's `mirror`, …) delegate to it, so the branch lives in exactly one place. Behavior is unchanged.
40
+
41
+ `writeHealthEntity` (healthcheck-backend) is intentionally NOT migrated onto the helper — it is genuinely bespoke (closure-captured durable state, distinct rethrow-vs-fail-soft branches, a per-system serializer, and it returns the computed state). SLO keeps its fail-soft `onError` wrapper around the shared guard.
42
+
43
+ - Updated dependencies [270ef29]
44
+ - Updated dependencies [b995afb]
45
+ - Updated dependencies [b995afb]
46
+ - Updated dependencies [b995afb]
47
+ - Updated dependencies [270ef29]
48
+ - Updated dependencies [270ef29]
49
+ - Updated dependencies [270ef29]
50
+ - Updated dependencies [270ef29]
51
+ - Updated dependencies [270ef29]
52
+ - Updated dependencies [270ef29]
53
+ - Updated dependencies [270ef29]
54
+ - Updated dependencies [270ef29]
55
+ - Updated dependencies [270ef29]
56
+ - Updated dependencies [b995afb]
57
+ - Updated dependencies [b995afb]
58
+ - Updated dependencies [b995afb]
59
+ - Updated dependencies [b995afb]
60
+ - Updated dependencies [270ef29]
61
+ - Updated dependencies [b995afb]
62
+ - Updated dependencies [270ef29]
63
+ - Updated dependencies [b995afb]
64
+ - Updated dependencies [b995afb]
65
+ - Updated dependencies [270ef29]
66
+ - Updated dependencies [b995afb]
67
+ - Updated dependencies [b995afb]
68
+ - Updated dependencies [270ef29]
69
+ - Updated dependencies [b995afb]
70
+ - Updated dependencies [b995afb]
71
+ - Updated dependencies [b995afb]
72
+ - Updated dependencies [b995afb]
73
+ - Updated dependencies [b995afb]
74
+ - Updated dependencies [b995afb]
75
+ - Updated dependencies [b995afb]
76
+ - Updated dependencies [270ef29]
77
+ - Updated dependencies [270ef29]
78
+ - Updated dependencies [270ef29]
79
+ - Updated dependencies [270ef29]
80
+ - Updated dependencies [270ef29]
81
+ - Updated dependencies [270ef29]
82
+ - Updated dependencies [270ef29]
83
+ - Updated dependencies [270ef29]
84
+ - Updated dependencies [b995afb]
85
+ - Updated dependencies [b995afb]
86
+ - @checkstack/backend-api@0.19.0
87
+ - @checkstack/automation-backend@0.3.0
88
+ - @checkstack/gitops-common@0.5.0
89
+ - @checkstack/gitops-backend@0.4.0
90
+ - @checkstack/healthcheck-backend@1.4.0
91
+ - @checkstack/healthcheck-common@1.4.0
92
+ - @checkstack/maintenance-common@1.3.0
93
+ - @checkstack/catalog-backend@1.3.0
94
+
3
95
  ## 1.2.0
4
96
 
5
97
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/dependency-backend",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "license": "Elastic-2.0",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -15,28 +15,28 @@
15
15
  "test": "bun test"
16
16
  },
17
17
  "dependencies": {
18
- "@checkstack/backend-api": "0.17.1",
19
- "@checkstack/automation-backend": "0.1.0",
20
- "@checkstack/dependency-common": "1.1.2",
21
- "@checkstack/catalog-common": "2.2.2",
22
- "@checkstack/catalog-backend": "1.1.6",
23
- "@checkstack/healthcheck-common": "1.2.0",
24
- "@checkstack/healthcheck-backend": "1.2.0",
25
- "@checkstack/maintenance-common": "1.2.2",
26
- "@checkstack/incident-common": "1.3.0",
27
- "@checkstack/notification-common": "1.2.0",
28
- "@checkstack/signal-common": "0.2.4",
29
- "@checkstack/gitops-backend": "0.3.6",
30
- "@checkstack/gitops-common": "0.4.1",
31
- "@checkstack/common": "0.11.0",
18
+ "@checkstack/backend-api": "0.18.0",
19
+ "@checkstack/automation-backend": "0.2.0",
20
+ "@checkstack/dependency-common": "1.1.3",
21
+ "@checkstack/catalog-common": "2.2.3",
22
+ "@checkstack/catalog-backend": "1.2.0",
23
+ "@checkstack/healthcheck-common": "1.3.0",
24
+ "@checkstack/healthcheck-backend": "1.3.0",
25
+ "@checkstack/maintenance-common": "1.2.3",
26
+ "@checkstack/incident-common": "1.3.1",
27
+ "@checkstack/notification-common": "1.2.1",
28
+ "@checkstack/signal-common": "0.2.5",
29
+ "@checkstack/gitops-backend": "0.3.7",
30
+ "@checkstack/gitops-common": "0.4.2",
31
+ "@checkstack/common": "0.12.0",
32
32
  "drizzle-orm": "^0.45.0",
33
33
  "zod": "^4.2.1",
34
34
  "@orpc/server": "^1.13.2"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@checkstack/drizzle-helper": "0.0.5",
38
- "@checkstack/scripts": "0.3.3",
39
- "@checkstack/test-utils-backend": "0.1.30",
38
+ "@checkstack/scripts": "0.3.4",
39
+ "@checkstack/test-utils-backend": "0.1.31",
40
40
  "@checkstack/tsconfig": "0.0.7",
41
41
  "@types/bun": "^1.0.0",
42
42
  "drizzle-kit": "^0.31.10",
@@ -14,7 +14,6 @@ import {
14
14
  dependencyTriggers,
15
15
  dependencyUpdatedTrigger,
16
16
  } from "./automations";
17
- import { dependencyHooks } from "./hooks";
18
17
  import type { DependencyService } from "./services/dependency-service";
19
18
 
20
19
  const logger = createMockLogger() as Logger;
@@ -184,7 +183,28 @@ describe("dependency.create", () => {
184
183
  },
185
184
  });
186
185
  const emitHook = mock(async (_hook: unknown, _payload: unknown) => {});
187
- const [create] = createDependencyActions({ service, emitHook: emitHook as never });
186
+ // The action now drives the create through `handle.mutate({ id, apply })`:
187
+ // it pre-generates the dependency id (keying the handle), runs `apply()`
188
+ // (the real `createDependency`), and the handle records the resulting
189
+ // reactive state.
190
+ const mutateCalls: Array<{ id: string; next: unknown }> = [];
191
+ const getDependencyEntity = () =>
192
+ ({
193
+ kind: "dependency-edge",
194
+ async mutate(input: {
195
+ id: string;
196
+ apply: () => Promise<unknown>;
197
+ }) {
198
+ const next = await input.apply();
199
+ mutateCalls.push({ id: input.id, next });
200
+ return next;
201
+ },
202
+ }) as never;
203
+ const [create] = createDependencyActions({
204
+ service,
205
+ emitHook: emitHook as never,
206
+ getDependencyEntity,
207
+ });
188
208
 
189
209
  const result = await create!.execute({
190
210
  ...ctxBase,
@@ -201,9 +221,24 @@ describe("dependency.create", () => {
201
221
  if (!result.success) return;
202
222
  expect(result.externalId).toBe("dep-1");
203
223
  expect((result.artifact as { dependencyId: string }).dependencyId).toBe("dep-1");
204
- expect(emitHook).toHaveBeenCalledTimes(1);
205
- const emitCall = emitHook.mock.calls[0]!;
206
- expect(emitCall[0]).toBe(dependencyHooks.dependencyCreated);
224
+ // The old `dependencyCreated` hook emission was replaced by driving the
225
+ // create through the reactive `dependency-edge` entity via `handle.mutate`
226
+ // (§10.5): the handle is keyed by the pre-generated dependency id, and
227
+ // `apply` returns the resulting reactive state.
228
+ expect(emitHook).not.toHaveBeenCalled();
229
+ expect(mutateCalls).toHaveLength(1);
230
+ // The id keying the handle is the pre-generated id passed into the
231
+ // service create (server-owned uuid), not asserted to a literal here.
232
+ expect(typeof mutateCalls[0]!.id).toBe("string");
233
+ expect(mutateCalls[0]!.next).toEqual({
234
+ sourceSystemId: "sys-a",
235
+ targetSystemId: "sys-b",
236
+ impactType: "critical",
237
+ transitive: false,
238
+ });
239
+ // The create was driven with the pre-generated id as its second arg.
240
+ expect(service.createMock).toHaveBeenCalledTimes(1);
241
+ expect(service.createMock.mock.calls[0]![1]).toBe(mutateCalls[0]!.id);
207
242
  });
208
243
 
209
244
  it("returns a failure when service.createDependency throws (e.g. cycle detected)", async () => {
@@ -247,7 +282,23 @@ describe("dependency.remove", () => {
247
282
  deleteResult: true,
248
283
  });
249
284
  const emitHook = mock(async (_hook: unknown, _payload: unknown) => {});
250
- const [, remove] = createDependencyActions({ service, emitHook: emitHook as never });
285
+ // The action now drives the delete through `handle.remove({ id, apply })`:
286
+ // it runs `apply()` (the real `deleteDependency`) and the handle records
287
+ // the tombstoned id.
288
+ const removeCalls: string[] = [];
289
+ const getDependencyEntity = () =>
290
+ ({
291
+ kind: "dependency-edge",
292
+ async remove(input: { id: string; apply: () => Promise<void> }) {
293
+ await input.apply();
294
+ removeCalls.push(input.id);
295
+ },
296
+ }) as never;
297
+ const [, remove] = createDependencyActions({
298
+ service,
299
+ emitHook: emitHook as never,
300
+ getDependencyEntity,
301
+ });
251
302
 
252
303
  const result = await remove!.execute({
253
304
  ...ctxBase,
@@ -258,8 +309,12 @@ describe("dependency.remove", () => {
258
309
  expect(result.success).toBe(true);
259
310
  if (!result.success) return;
260
311
  expect(result.externalId).toBe("dep-1");
261
- expect(emitHook).toHaveBeenCalledTimes(1);
262
- expect(emitHook.mock.calls[0]![0]).toBe(dependencyHooks.dependencyDeleted);
312
+ // The old `dependencyDeleted` hook emission was replaced by driving the
313
+ // tombstone through the reactive `dependency-edge` entity via
314
+ // `handle.remove` (§10.5). The real delete ran inside `apply`.
315
+ expect(emitHook).not.toHaveBeenCalled();
316
+ expect(service.deleteMock).toHaveBeenCalledTimes(1);
317
+ expect(removeCalls).toEqual(["dep-1"]);
263
318
  });
264
319
 
265
320
  it("returns failure if the dependency does not exist", async () => {
@@ -24,14 +24,22 @@ import type {
24
24
  ActionDefinition,
25
25
  TriggerDefinition,
26
26
  } from "@checkstack/automation-backend";
27
+ import { makeEntityDrivenTriggerSetup } from "@checkstack/automation-backend";
27
28
  import { extractErrorMessage } from "@checkstack/common";
28
29
  import {
29
30
  DerivedStateSchema,
30
31
  ImpactTypeSchema,
31
32
  } from "@checkstack/dependency-common";
32
33
 
34
+ import type { EntityHandle } from "@checkstack/automation-backend";
33
35
  import { dependencyHooks } from "./hooks";
34
36
  import type { DependencyService } from "./services/dependency-service";
37
+ import {
38
+ removeDependencyEdge,
39
+ toDependencyEdgeState,
40
+ writeDependencyEdge,
41
+ type DependencyEdgeState,
42
+ } from "./dependency-entity";
35
43
 
36
44
  // ─── Payload schemas — match the hook payloads exactly ─────────────────
37
45
 
@@ -69,6 +77,10 @@ const dependencyImpactPropagatedPayloadSchema = z.object({
69
77
 
70
78
  // ─── Triggers ──────────────────────────────────────────────────────────
71
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.
72
84
  export const dependencyCreatedTrigger: TriggerDefinition<
73
85
  z.infer<typeof dependencyCreatedPayloadSchema>
74
86
  > = {
@@ -78,7 +90,9 @@ export const dependencyCreatedTrigger: TriggerDefinition<
78
90
  category: "Dependencies",
79
91
  icon: "Network",
80
92
  payloadSchema: dependencyCreatedPayloadSchema,
81
- hook: dependencyHooks.dependencyCreated,
93
+ setup: makeEntityDrivenTriggerSetup<
94
+ z.infer<typeof dependencyCreatedPayloadSchema>
95
+ >(),
82
96
  contextKey: (p) => p.dependencyId,
83
97
  };
84
98
 
@@ -87,11 +101,14 @@ export const dependencyUpdatedTrigger: TriggerDefinition<
87
101
  > = {
88
102
  id: "updated",
89
103
  displayName: "Dependency Updated",
90
- description: "Fires when an existing dependency's impact-type or label changes",
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.",
91
106
  category: "Dependencies",
92
107
  icon: "Network",
93
108
  payloadSchema: dependencyUpdatedPayloadSchema,
94
- hook: dependencyHooks.dependencyUpdated,
109
+ setup: makeEntityDrivenTriggerSetup<
110
+ z.infer<typeof dependencyUpdatedPayloadSchema>
111
+ >(),
95
112
  contextKey: (p) => p.dependencyId,
96
113
  };
97
114
 
@@ -104,7 +121,9 @@ export const dependencyDeletedTrigger: TriggerDefinition<
104
121
  category: "Dependencies",
105
122
  icon: "Network",
106
123
  payloadSchema: dependencyDeletedPayloadSchema,
107
- hook: dependencyHooks.dependencyDeleted,
124
+ setup: makeEntityDrivenTriggerSetup<
125
+ z.infer<typeof dependencyDeletedPayloadSchema>
126
+ >(),
108
127
  contextKey: (p) => p.dependencyId,
109
128
  };
110
129
 
@@ -178,6 +197,8 @@ export const dependencyArtifactType = {
178
197
  export interface DependencyActionDeps {
179
198
  service: DependencyService;
180
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;
181
202
  }
182
203
 
183
204
  export function createDependencyActions(
@@ -199,19 +220,33 @@ export function createDependencyActions(
199
220
  produces: "dependency.edge",
200
221
  execute: async ({ config, logger }) => {
201
222
  try {
202
- const created = await deps.service.createDependency({
203
- sourceSystemId: config.sourceSystemId,
204
- targetSystemId: config.targetSystemId,
205
- impactType: config.impactType,
206
- transitive: config.transitive,
207
- label: config.label,
208
- healthCheckRules: [],
209
- });
210
- await deps.emitHook(dependencyHooks.dependencyCreated, {
211
- dependencyId: created.id,
212
- sourceSystemId: created.sourceSystemId,
213
- targetSystemId: created.targetSystemId,
214
- impactType: created.impactType,
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
+ },
215
250
  });
216
251
  logger.info(`Automation created dependency ${created.id}`);
217
252
  return {
@@ -254,18 +289,23 @@ export function createDependencyActions(
254
289
  error: `Dependency not found: ${config.dependencyId}`,
255
290
  };
256
291
  }
257
- const removed = await deps.service.deleteDependency(config.dependencyId);
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
+ });
258
303
  if (!removed) {
259
304
  return {
260
305
  success: false,
261
306
  error: `Dependency ${config.dependencyId} disappeared mid-delete`,
262
307
  };
263
308
  }
264
- await deps.emitHook(dependencyHooks.dependencyDeleted, {
265
- dependencyId: existing.id,
266
- sourceSystemId: existing.sourceSystemId,
267
- targetSystemId: existing.targetSystemId,
268
- });
269
309
  logger.info(`Automation removed dependency ${existing.id}`);
270
310
  return {
271
311
  success: true,