@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 CHANGED
@@ -1,5 +1,190 @@
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
+
95
+ ## 1.2.0
96
+
97
+ ### Minor Changes
98
+
99
+ - 41c77f4: feat(automation): type enum-able trigger/artifact fields as enums for editor value autocompletion
100
+
101
+ The automation editor's staged completion offers concrete values after a
102
+ comparator (`{{ trigger.payload.severity == "high" }}`) only when the
103
+ field's JSON Schema carries an `enum`. Several trigger payload + artifact
104
+ schemas declared closed-set fields as loose `z.string()`, so no values
105
+ were suggested. Tightened them to the canonical enums that already
106
+ existed in each plugin's `-common` package (and matched the hook payload
107
+ types in lockstep so the trigger's `payloadSchema` and `hook` keep the
108
+ same `TPayload`):
109
+
110
+ - **incident** — trigger payloads: `severity` → `IncidentSeverityEnum`,
111
+ `status` / `statusChange` → `IncidentStatusEnum`.
112
+ - **healthcheck** — trigger payloads: `previousStatus` / `newStatus` /
113
+ `status` → `HealthCheckStatusSchema` (across systemDegraded,
114
+ systemHealthy, systemHealthChanged, checkFailed; plus checkCompleted's
115
+ hook type).
116
+ - **dependency** — trigger + artifact: `impactType` → `ImpactTypeSchema`;
117
+ impactPropagated `previousState` / `newState` → `DerivedStateSchema`.
118
+ Also deduped the inline `impactTypeSchema` action-config enum to reuse
119
+ the canonical `ImpactTypeSchema`.
120
+ - **maintenance** — trigger + artifact: `status` →
121
+ `MaintenanceStatusEnum`; deduped the inline `maintenanceStatusEnum`
122
+ (used by `add_update.statusChange`) to the canonical one.
123
+ - **slo** — `achievement.unlocked` trigger + hook: `achievement` →
124
+ `AchievementTypeSchema`.
125
+
126
+ Runtime behaviour is unchanged — these fields always carried valid enum
127
+ values (the underlying records are enum-constrained); only the schema
128
+ types were loose. The hook payload generics are now precise too, which
129
+ caught one stale test fixture asserting an invalid `impactType: "soft"`.
130
+
131
+ Fields that look enum-ish but are genuinely free-form were intentionally
132
+ left as `z.string()`: satellite `region` (user-entered), Jira issue
133
+ `status` (per-instance workflow name), notification `strategyQualifiedId`
134
+ / `errorMessage`, healthcheck collector `result`, and script
135
+ `stdout` / `stderr`.
136
+
137
+ - 41c77f4: feat(dependency): Phase 9 — triggers + create/remove actions for the Automation Platform
138
+
139
+ - Triggers `dependency.created`, `dependency.updated`, `dependency.deleted`,
140
+ each carrying `contextKey: (p) => p.dependencyId` so `wait_for_trigger`
141
+ resumes on the same edge.
142
+ - New hook `dependencyHooks.impactPropagated` + matching trigger
143
+ `dependency.impact_propagated` — fires once per upstream event from
144
+ `evaluateAndNotifyDownstream` with the list of downstream systems
145
+ whose derived state actually moved. Carries previous/new state for
146
+ each affected system so subscribers don't have to re-query the
147
+ graph. Fires regardless of notification suppression, so an
148
+ automation can react even when the user-facing notification is
149
+ skipped. `contextKey: (p) => p.sourceSystemId`.
150
+ - Actions `dependency.create` (with cycle + duplicate-edge detection
151
+ surfaced via the action's `error`) and `dependency.remove`. Both emit
152
+ the matching `dependencyHooks.*` so downstream automations and caches
153
+ react identically to RPC-driven changes.
154
+ - Artifact type `dependency.edge` for source/target/impact pass-through
155
+ between steps.
156
+
157
+ ### Patch Changes
158
+
159
+ - Updated dependencies [e2d6f25]
160
+ - Updated dependencies [41c77f4]
161
+ - Updated dependencies [41c77f4]
162
+ - Updated dependencies [e1a2077]
163
+ - Updated dependencies [41c77f4]
164
+ - Updated dependencies [41c77f4]
165
+ - Updated dependencies [41c77f4]
166
+ - Updated dependencies [41c77f4]
167
+ - Updated dependencies [41c77f4]
168
+ - Updated dependencies [41c77f4]
169
+ - Updated dependencies [41c77f4]
170
+ - Updated dependencies [6d52276]
171
+ - Updated dependencies [6d52276]
172
+ - Updated dependencies [35bc682]
173
+ - @checkstack/automation-backend@0.2.0
174
+ - @checkstack/healthcheck-backend@1.3.0
175
+ - @checkstack/catalog-backend@1.2.0
176
+ - @checkstack/common@0.12.0
177
+ - @checkstack/backend-api@0.18.0
178
+ - @checkstack/healthcheck-common@1.3.0
179
+ - @checkstack/catalog-common@2.2.3
180
+ - @checkstack/dependency-common@1.1.3
181
+ - @checkstack/incident-common@1.3.1
182
+ - @checkstack/maintenance-common@1.2.3
183
+ - @checkstack/gitops-backend@0.3.7
184
+ - @checkstack/gitops-common@0.4.2
185
+ - @checkstack/notification-common@1.2.1
186
+ - @checkstack/signal-common@0.2.5
187
+
3
188
  ## 1.1.6
4
189
 
5
190
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/dependency-backend",
3
- "version": "1.1.6",
3
+ "version": "1.3.0",
4
4
  "license": "Elastic-2.0",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -11,30 +11,32 @@
11
11
  "typecheck": "tsgo -b",
12
12
  "generate": "drizzle-kit generate",
13
13
  "lint": "bun run lint:code",
14
- "lint:code": "eslint . --max-warnings 0"
14
+ "lint:code": "eslint . --max-warnings 0",
15
+ "test": "bun test"
15
16
  },
16
17
  "dependencies": {
17
- "@checkstack/backend-api": "0.17.0",
18
- "@checkstack/dependency-common": "1.1.2",
19
- "@checkstack/catalog-common": "2.2.2",
20
- "@checkstack/catalog-backend": "1.1.5",
21
- "@checkstack/healthcheck-common": "1.1.2",
22
- "@checkstack/healthcheck-backend": "1.1.4",
23
- "@checkstack/maintenance-common": "1.2.2",
24
- "@checkstack/incident-common": "1.2.2",
25
- "@checkstack/notification-common": "1.2.0",
26
- "@checkstack/signal-common": "0.2.4",
27
- "@checkstack/gitops-backend": "0.3.5",
28
- "@checkstack/gitops-common": "0.4.1",
29
- "@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",
30
32
  "drizzle-orm": "^0.45.0",
31
33
  "zod": "^4.2.1",
32
34
  "@orpc/server": "^1.13.2"
33
35
  },
34
36
  "devDependencies": {
35
37
  "@checkstack/drizzle-helper": "0.0.5",
36
- "@checkstack/scripts": "0.3.3",
37
- "@checkstack/test-utils-backend": "0.1.29",
38
+ "@checkstack/scripts": "0.3.4",
39
+ "@checkstack/test-utils-backend": "0.1.31",
38
40
  "@checkstack/tsconfig": "0.0.7",
39
41
  "@types/bun": "^1.0.0",
40
42
  "drizzle-kit": "^0.31.10",
@@ -0,0 +1,363 @@
1
+ /**
2
+ * Behaviour tests for the dependency automation triggers + actions.
3
+ */
4
+ import { describe, expect, it, mock } from "bun:test";
5
+ import type { Logger } from "@checkstack/backend-api";
6
+ import { createMockLogger } from "@checkstack/test-utils-backend";
7
+
8
+ import {
9
+ createDependencyActions,
10
+ dependencyArtifactType,
11
+ dependencyCreatedTrigger,
12
+ dependencyDeletedTrigger,
13
+ dependencyImpactPropagatedTrigger,
14
+ dependencyTriggers,
15
+ dependencyUpdatedTrigger,
16
+ } from "./automations";
17
+ import type { DependencyService } from "./services/dependency-service";
18
+
19
+ const logger = createMockLogger() as Logger;
20
+
21
+ const ctxBase = {
22
+ runId: "run-1",
23
+ automationId: "auto-1",
24
+ contextKey: null,
25
+ logger,
26
+ getService: async <T,>(): Promise<T> => {
27
+ throw new Error("not used");
28
+ },
29
+ };
30
+
31
+ describe("dependency triggers", () => {
32
+ it("exposes four triggers in a stable order", () => {
33
+ expect(dependencyTriggers).toHaveLength(4);
34
+ expect(dependencyTriggers[0]).toBe(
35
+ dependencyCreatedTrigger as (typeof dependencyTriggers)[number],
36
+ );
37
+ expect(dependencyTriggers[1]).toBe(
38
+ dependencyUpdatedTrigger as (typeof dependencyTriggers)[number],
39
+ );
40
+ expect(dependencyTriggers[2]).toBe(
41
+ dependencyDeletedTrigger as (typeof dependencyTriggers)[number],
42
+ );
43
+ expect(dependencyTriggers[3]).toBe(
44
+ dependencyImpactPropagatedTrigger as (typeof dependencyTriggers)[number],
45
+ );
46
+ });
47
+
48
+ it("extracts dependencyId as the contextKey on the edge-lifecycle triggers", () => {
49
+ const payload = {
50
+ dependencyId: "dep-1",
51
+ sourceSystemId: "sys-a",
52
+ targetSystemId: "sys-b",
53
+ impactType: "critical",
54
+ } as const;
55
+ expect(dependencyCreatedTrigger.contextKey?.(payload)).toBe("dep-1");
56
+ expect(dependencyUpdatedTrigger.contextKey?.(payload)).toBe("dep-1");
57
+ expect(dependencyDeletedTrigger.contextKey?.(payload)).toBe("dep-1");
58
+ });
59
+
60
+ it("extracts sourceSystemId as the contextKey on impactPropagated", () => {
61
+ const payload = {
62
+ sourceSystemId: "sys-upstream",
63
+ affectedSystems: [
64
+ { systemId: "sys-a", previousState: null, newState: "degraded" as const },
65
+ ],
66
+ timestamp: "2026-05-29T12:00:00Z",
67
+ };
68
+ expect(dependencyImpactPropagatedTrigger.contextKey?.(payload)).toBe(
69
+ "sys-upstream",
70
+ );
71
+ });
72
+
73
+ it("requires affectedSystems on the impactPropagated payload", () => {
74
+ const ok = dependencyImpactPropagatedTrigger.payloadSchema.safeParse({
75
+ sourceSystemId: "sys-1",
76
+ affectedSystems: [],
77
+ timestamp: "2026-05-29T12:00:00Z",
78
+ });
79
+ expect(ok.success).toBe(true);
80
+
81
+ const bad = dependencyImpactPropagatedTrigger.payloadSchema.safeParse({
82
+ sourceSystemId: "sys-1",
83
+ timestamp: "2026-05-29T12:00:00Z",
84
+ });
85
+ expect(bad.success).toBe(false);
86
+ });
87
+
88
+ it("rejects edge-lifecycle payloads missing required fields", () => {
89
+ const bad = dependencyCreatedTrigger.payloadSchema.safeParse({
90
+ dependencyId: "dep-1",
91
+ sourceSystemId: "sys-a",
92
+ });
93
+ expect(bad.success).toBe(false);
94
+ });
95
+ });
96
+
97
+ describe("dependencyArtifactType", () => {
98
+ it("validates the canonical edge shape", () => {
99
+ const ok = dependencyArtifactType.schema.safeParse({
100
+ dependencyId: "dep-1",
101
+ sourceSystemId: "sys-a",
102
+ targetSystemId: "sys-b",
103
+ impactType: "critical",
104
+ });
105
+ expect(ok.success).toBe(true);
106
+
107
+ // The artifact schema now uses the canonical ImpactType enum, so a
108
+ // value outside it is rejected.
109
+ const bad = dependencyArtifactType.schema.safeParse({
110
+ dependencyId: "dep-1",
111
+ sourceSystemId: "sys-a",
112
+ targetSystemId: "sys-b",
113
+ impactType: "soft",
114
+ });
115
+ expect(bad.success).toBe(false);
116
+ });
117
+ });
118
+
119
+ interface FakeDependencyRow {
120
+ id: string;
121
+ sourceSystemId: string;
122
+ targetSystemId: string;
123
+ impactType: string;
124
+ transitive: boolean;
125
+ label: string | null;
126
+ }
127
+
128
+ function makeService(args: {
129
+ createBehaviour?:
130
+ | { ok: true; row: FakeDependencyRow }
131
+ | { ok: false; error: string };
132
+ existingForRemove?: FakeDependencyRow;
133
+ deleteResult?: boolean;
134
+ }): DependencyService & {
135
+ createMock: ReturnType<typeof mock>;
136
+ deleteMock: ReturnType<typeof mock>;
137
+ getByIdMock: ReturnType<typeof mock>;
138
+ } {
139
+ const createMock = mock(async (input: unknown) => {
140
+ if (args.createBehaviour && !args.createBehaviour.ok) {
141
+ throw new Error(args.createBehaviour.error);
142
+ }
143
+ if (args.createBehaviour?.ok) return args.createBehaviour.row;
144
+ const i = input as { sourceSystemId: string; targetSystemId: string; impactType: string };
145
+ return {
146
+ id: "generated",
147
+ sourceSystemId: i.sourceSystemId,
148
+ targetSystemId: i.targetSystemId,
149
+ impactType: i.impactType,
150
+ transitive: false,
151
+ label: null,
152
+ };
153
+ });
154
+ const deleteMock = mock(async (_id: string) => args.deleteResult ?? true);
155
+ const getByIdMock = mock(async (_id: string) => args.existingForRemove);
156
+ return {
157
+ createDependency: createMock,
158
+ deleteDependency: deleteMock,
159
+ getDependencyById: getByIdMock,
160
+ createMock,
161
+ deleteMock,
162
+ getByIdMock,
163
+ } as unknown as DependencyService & {
164
+ createMock: ReturnType<typeof mock>;
165
+ deleteMock: ReturnType<typeof mock>;
166
+ getByIdMock: ReturnType<typeof mock>;
167
+ };
168
+ }
169
+
170
+ describe("dependency.create", () => {
171
+ it("creates an edge, fires dependencyCreated, and emits an artifact", async () => {
172
+ const service = makeService({
173
+ createBehaviour: {
174
+ ok: true,
175
+ row: {
176
+ id: "dep-1",
177
+ sourceSystemId: "sys-a",
178
+ targetSystemId: "sys-b",
179
+ impactType: "critical",
180
+ transitive: false,
181
+ label: null,
182
+ },
183
+ },
184
+ });
185
+ const emitHook = mock(async (_hook: unknown, _payload: unknown) => {});
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
+ });
208
+
209
+ const result = await create!.execute({
210
+ ...ctxBase,
211
+ consumedArtifacts: {},
212
+ config: {
213
+ sourceSystemId: "sys-a",
214
+ targetSystemId: "sys-b",
215
+ impactType: "critical",
216
+ transitive: false,
217
+ } as never,
218
+ });
219
+
220
+ expect(result.success).toBe(true);
221
+ if (!result.success) return;
222
+ expect(result.externalId).toBe("dep-1");
223
+ expect((result.artifact as { dependencyId: string }).dependencyId).toBe("dep-1");
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);
242
+ });
243
+
244
+ it("returns a failure when service.createDependency throws (e.g. cycle detected)", async () => {
245
+ const service = makeService({
246
+ createBehaviour: {
247
+ ok: false,
248
+ error: "Cannot create dependency: would form a circular chain: a → b → a",
249
+ },
250
+ });
251
+ const emitHook = mock(async (_hook: unknown, _payload: unknown) => {});
252
+ const [create] = createDependencyActions({ service, emitHook: emitHook as never });
253
+
254
+ const result = await create!.execute({
255
+ ...ctxBase,
256
+ consumedArtifacts: {},
257
+ config: {
258
+ sourceSystemId: "a",
259
+ targetSystemId: "b",
260
+ impactType: "soft",
261
+ } as never,
262
+ });
263
+
264
+ expect(result.success).toBe(false);
265
+ if (result.success) return;
266
+ expect(result.error).toMatch(/circular chain/);
267
+ expect(emitHook).not.toHaveBeenCalled();
268
+ });
269
+ });
270
+
271
+ describe("dependency.remove", () => {
272
+ it("removes an edge, fires dependencyDeleted, and emits an artifact reflecting the removed edge", async () => {
273
+ const service = makeService({
274
+ existingForRemove: {
275
+ id: "dep-1",
276
+ sourceSystemId: "sys-a",
277
+ targetSystemId: "sys-b",
278
+ impactType: "critical",
279
+ transitive: false,
280
+ label: null,
281
+ },
282
+ deleteResult: true,
283
+ });
284
+ const emitHook = mock(async (_hook: unknown, _payload: unknown) => {});
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
+ });
302
+
303
+ const result = await remove!.execute({
304
+ ...ctxBase,
305
+ consumedArtifacts: {},
306
+ config: { dependencyId: "dep-1" } as never,
307
+ });
308
+
309
+ expect(result.success).toBe(true);
310
+ if (!result.success) return;
311
+ expect(result.externalId).toBe("dep-1");
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"]);
318
+ });
319
+
320
+ it("returns failure if the dependency does not exist", async () => {
321
+ const service = makeService({ existingForRemove: undefined });
322
+ const emitHook = mock(async (_hook: unknown, _payload: unknown) => {});
323
+ const [, remove] = createDependencyActions({ service, emitHook: emitHook as never });
324
+
325
+ const result = await remove!.execute({
326
+ ...ctxBase,
327
+ consumedArtifacts: {},
328
+ config: { dependencyId: "missing" } as never,
329
+ });
330
+
331
+ expect(result.success).toBe(false);
332
+ if (result.success) return;
333
+ expect(result.error).toMatch(/not found/i);
334
+ expect(emitHook).not.toHaveBeenCalled();
335
+ });
336
+
337
+ it("returns failure if delete returns false (race-deleted)", async () => {
338
+ const service = makeService({
339
+ existingForRemove: {
340
+ id: "dep-1",
341
+ sourceSystemId: "sys-a",
342
+ targetSystemId: "sys-b",
343
+ impactType: "critical",
344
+ transitive: false,
345
+ label: null,
346
+ },
347
+ deleteResult: false,
348
+ });
349
+ const emitHook = mock(async (_hook: unknown, _payload: unknown) => {});
350
+ const [, remove] = createDependencyActions({ service, emitHook: emitHook as never });
351
+
352
+ const result = await remove!.execute({
353
+ ...ctxBase,
354
+ consumedArtifacts: {},
355
+ config: { dependencyId: "dep-1" } as never,
356
+ });
357
+
358
+ expect(result.success).toBe(false);
359
+ if (result.success) return;
360
+ expect(result.error).toMatch(/disappeared mid-delete/);
361
+ expect(emitHook).not.toHaveBeenCalled();
362
+ });
363
+ });