@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
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.
|
|
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.
|
|
18
|
-
"@checkstack/
|
|
19
|
-
"@checkstack/
|
|
20
|
-
"@checkstack/catalog-
|
|
21
|
-
"@checkstack/
|
|
22
|
-
"@checkstack/healthcheck-
|
|
23
|
-
"@checkstack/
|
|
24
|
-
"@checkstack/
|
|
25
|
-
"@checkstack/
|
|
26
|
-
"@checkstack/
|
|
27
|
-
"@checkstack/
|
|
28
|
-
"@checkstack/gitops-
|
|
29
|
-
"@checkstack/common": "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.
|
|
37
|
-
"@checkstack/test-utils-backend": "0.1.
|
|
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
|
+
});
|