@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.
@@ -0,0 +1,270 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import type {
3
+ EntityChanged,
4
+ EntityHandle,
5
+ } from "@checkstack/automation-backend";
6
+ import { SYSTEM_ACTOR } from "@checkstack/common";
7
+
8
+ import {
9
+ DEPENDENCY_EDGE_ENTITY_KIND,
10
+ DEPENDENCY_TRIGGER_EVENTS,
11
+ DependencyEdgeStateSchema,
12
+ createDependencyEntityRead,
13
+ dependencyChangeToPayload,
14
+ deriveDependencyTriggerEvents,
15
+ removeDependencyEdge,
16
+ toDependencyEdgeState,
17
+ writeDependencyEdge,
18
+ type DependencyEdgeState,
19
+ } from "./dependency-entity";
20
+ import {
21
+ dependencyCreatedTrigger,
22
+ dependencyDeletedTrigger,
23
+ dependencyUpdatedTrigger,
24
+ } from "./automations";
25
+ import type { DependencyService } from "./services/dependency-service";
26
+
27
+ function change(overrides: Partial<EntityChanged> = {}): EntityChanged {
28
+ return {
29
+ kind: DEPENDENCY_EDGE_ENTITY_KIND,
30
+ id: "dep-1",
31
+ prev: {
32
+ sourceSystemId: "a",
33
+ targetSystemId: "b",
34
+ impactType: "degraded",
35
+ transitive: false,
36
+ },
37
+ next: {
38
+ sourceSystemId: "a",
39
+ targetSystemId: "b",
40
+ impactType: "critical",
41
+ transitive: false,
42
+ },
43
+ delta: { impactType: "critical" },
44
+ changedFields: ["impactType"],
45
+ actor: SYSTEM_ACTOR,
46
+ occurredAt: new Date().toISOString(),
47
+ ...overrides,
48
+ };
49
+ }
50
+
51
+ describe("dependencyChangeToPayload — payloadSchema parity", () => {
52
+ it("a create payload validates against the created trigger's payloadSchema", () => {
53
+ const payload = dependencyChangeToPayload(
54
+ change({
55
+ prev: null,
56
+ next: {
57
+ sourceSystemId: "a",
58
+ targetSystemId: "b",
59
+ impactType: "degraded",
60
+ transitive: false,
61
+ },
62
+ }),
63
+ );
64
+ const parsed = dependencyCreatedTrigger.payloadSchema.parse(payload);
65
+ expect(parsed.dependencyId).toBe("dep-1");
66
+ expect(parsed.sourceSystemId).toBe("a");
67
+ expect(parsed.targetSystemId).toBe("b");
68
+ expect(parsed.impactType).toBe("degraded");
69
+ });
70
+
71
+ it("an update payload validates against the updated trigger's payloadSchema", () => {
72
+ const parsed = dependencyUpdatedTrigger.payloadSchema.parse(
73
+ dependencyChangeToPayload(change()),
74
+ );
75
+ expect(parsed.dependencyId).toBe("dep-1");
76
+ expect(parsed.impactType).toBe("critical");
77
+ });
78
+
79
+ it("a delete payload validates against the deleted trigger's payloadSchema (endpoints from prev)", () => {
80
+ const parsed = dependencyDeletedTrigger.payloadSchema.parse(
81
+ dependencyChangeToPayload(change({ next: null })),
82
+ );
83
+ expect(parsed.dependencyId).toBe("dep-1");
84
+ // The deleted edge's endpoints come from `prev` (next is the tombstone).
85
+ expect(parsed.sourceSystemId).toBe("a");
86
+ expect(parsed.targetSystemId).toBe("b");
87
+ });
88
+ });
89
+
90
+ describe("deriveDependencyTriggerEvents", () => {
91
+ it("create → dependency.created", () => {
92
+ expect(
93
+ deriveDependencyTriggerEvents(
94
+ change({
95
+ prev: null,
96
+ next: {
97
+ sourceSystemId: "a",
98
+ targetSystemId: "b",
99
+ impactType: "degraded",
100
+ transitive: false,
101
+ },
102
+ }),
103
+ ),
104
+ ).toEqual([DEPENDENCY_TRIGGER_EVENTS.created]);
105
+ });
106
+ it("tombstone → dependency.deleted", () => {
107
+ expect(deriveDependencyTriggerEvents(change({ next: null }))).toEqual([
108
+ DEPENDENCY_TRIGGER_EVENTS.deleted,
109
+ ]);
110
+ });
111
+ it("update → dependency.updated", () => {
112
+ expect(deriveDependencyTriggerEvents(change())).toEqual([
113
+ DEPENDENCY_TRIGGER_EVENTS.updated,
114
+ ]);
115
+ });
116
+ });
117
+
118
+ describe("DependencyEdgeStateSchema", () => {
119
+ it("parses the reactive subset", () => {
120
+ const parsed = DependencyEdgeStateSchema.parse({
121
+ sourceSystemId: "a",
122
+ targetSystemId: "b",
123
+ impactType: "degraded",
124
+ transitive: true,
125
+ });
126
+ expect(parsed.transitive).toBe(true);
127
+ });
128
+ });
129
+
130
+ describe("toDependencyEdgeState", () => {
131
+ it("projects the reactive subset off a full dependency", () => {
132
+ expect(
133
+ toDependencyEdgeState({
134
+ sourceSystemId: "a",
135
+ targetSystemId: "b",
136
+ impactType: "critical",
137
+ transitive: true,
138
+ }),
139
+ ).toEqual({
140
+ sourceSystemId: "a",
141
+ targetSystemId: "b",
142
+ impactType: "critical",
143
+ transitive: true,
144
+ });
145
+ });
146
+ });
147
+
148
+ describe("createDependencyEntityRead", () => {
149
+ it("routes the batched read straight to the service (plugin-backed)", async () => {
150
+ const seen: ReadonlyArray<string>[] = [];
151
+ const service = {
152
+ async getManyEntityStates(ids: ReadonlyArray<string>) {
153
+ seen.push(ids);
154
+ return {
155
+ "dep-1": {
156
+ sourceSystemId: "a",
157
+ targetSystemId: "b",
158
+ impactType: "degraded" as const,
159
+ transitive: false,
160
+ },
161
+ };
162
+ },
163
+ } as unknown as DependencyService;
164
+ const read = createDependencyEntityRead(service);
165
+ const out = await read(["dep-1", "dep-2"]);
166
+ expect(seen).toEqual([["dep-1", "dep-2"]]);
167
+ expect(out["dep-1"]).toEqual({
168
+ sourceSystemId: "a",
169
+ targetSystemId: "b",
170
+ impactType: "degraded",
171
+ transitive: false,
172
+ });
173
+ });
174
+ });
175
+
176
+ describe("writeDependencyEdge", () => {
177
+ it("drives the write through handle.mutate keyed by dependency id", async () => {
178
+ const calls: Array<{ id: string; next: DependencyEdgeState }> = [];
179
+ const handle = {
180
+ kind: DEPENDENCY_EDGE_ENTITY_KIND,
181
+ async mutate(input: {
182
+ id: string;
183
+ apply: () => Promise<DependencyEdgeState>;
184
+ }) {
185
+ const next = await input.apply();
186
+ calls.push({ id: input.id, next });
187
+ return next;
188
+ },
189
+ } as unknown as EntityHandle<DependencyEdgeState>;
190
+ let applied = false;
191
+ await writeDependencyEdge({
192
+ handle,
193
+ dependencyId: "dep-9",
194
+ apply: async () => {
195
+ applied = true;
196
+ return {
197
+ sourceSystemId: "a",
198
+ targetSystemId: "b",
199
+ impactType: "critical",
200
+ transitive: true,
201
+ };
202
+ },
203
+ });
204
+ expect(applied).toBe(true);
205
+ expect(calls).toEqual([
206
+ {
207
+ id: "dep-9",
208
+ next: {
209
+ sourceSystemId: "a",
210
+ targetSystemId: "b",
211
+ impactType: "critical",
212
+ transitive: true,
213
+ },
214
+ },
215
+ ]);
216
+ });
217
+
218
+ it("still runs the plugin write when no handle is wired", async () => {
219
+ let applied = false;
220
+ await writeDependencyEdge({
221
+ handle: undefined,
222
+ dependencyId: "x",
223
+ apply: async () => {
224
+ applied = true;
225
+ return {
226
+ sourceSystemId: "a",
227
+ targetSystemId: "b",
228
+ impactType: "degraded",
229
+ transitive: false,
230
+ };
231
+ },
232
+ });
233
+ expect(applied).toBe(true);
234
+ });
235
+ });
236
+
237
+ describe("removeDependencyEdge", () => {
238
+ it("tombstones via handle.remove({ apply })", async () => {
239
+ const removed: string[] = [];
240
+ let deleted = false;
241
+ const handle = {
242
+ kind: DEPENDENCY_EDGE_ENTITY_KIND,
243
+ async remove(input: { id: string; apply: () => Promise<void> }) {
244
+ await input.apply();
245
+ removed.push(input.id);
246
+ },
247
+ } as unknown as EntityHandle<DependencyEdgeState>;
248
+ await removeDependencyEdge({
249
+ handle,
250
+ dependencyId: "dep-9",
251
+ apply: async () => {
252
+ deleted = true;
253
+ },
254
+ });
255
+ expect(deleted).toBe(true);
256
+ expect(removed).toEqual(["dep-9"]);
257
+ });
258
+
259
+ it("still runs the delete when no handle is wired", async () => {
260
+ let deleted = false;
261
+ await removeDependencyEdge({
262
+ handle: undefined,
263
+ dependencyId: "x",
264
+ apply: async () => {
265
+ deleted = true;
266
+ },
267
+ });
268
+ expect(deleted).toBe(true);
269
+ });
270
+ });
@@ -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,8 +1,5 @@
1
1
  import { createHook } from "@checkstack/backend-api";
2
- import type {
3
- DerivedState,
4
- ImpactType,
5
- } from "@checkstack/dependency-common";
2
+ import type { DerivedState } from "@checkstack/dependency-common";
6
3
 
7
4
  /**
8
5
  * Dependency hooks for cross-plugin communication.
@@ -14,34 +11,12 @@ import type {
14
11
  * in the editor.
15
12
  */
16
13
  export const dependencyHooks = {
17
- /**
18
- * Emitted when a dependency is created.
19
- */
20
- dependencyCreated: createHook<{
21
- dependencyId: string;
22
- sourceSystemId: string;
23
- targetSystemId: string;
24
- impactType: ImpactType;
25
- }>("dependency.created"),
26
-
27
- /**
28
- * Emitted when a dependency is updated.
29
- */
30
- dependencyUpdated: createHook<{
31
- dependencyId: string;
32
- sourceSystemId: string;
33
- targetSystemId: string;
34
- impactType: ImpactType;
35
- }>("dependency.updated"),
36
-
37
- /**
38
- * Emitted when a dependency is deleted.
39
- */
40
- dependencyDeleted: createHook<{
41
- dependencyId: string;
42
- sourceSystemId: string;
43
- targetSystemId: string;
44
- }>("dependency.deleted"),
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.
45
20
 
46
21
  /**
47
22
  * Emitted when an upstream system's state change has propagated