@checkstack/dependency-backend 1.2.0 → 1.3.1

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/src/index.ts CHANGED
@@ -12,6 +12,8 @@ import {
12
12
  automationActionExtensionPoint,
13
13
  automationArtifactTypeExtensionPoint,
14
14
  automationTriggerExtensionPoint,
15
+ entityExtensionPoint,
16
+ type EntityHandle,
15
17
  } from "@checkstack/automation-backend";
16
18
  import { DependencyService } from "./services/dependency-service";
17
19
  import { WarningEvaluationService } from "./services/warning-evaluation-service";
@@ -28,9 +30,20 @@ import { HealthCheckApi } from "@checkstack/healthcheck-common";
28
30
  import { MaintenanceApi } from "@checkstack/maintenance-common";
29
31
  import { IncidentApi } from "@checkstack/incident-common";
30
32
  import { NotificationApi } from "@checkstack/notification-common";
31
- import { catalogHooks } from "@checkstack/catalog-backend";
32
- import { healthCheckHooks } from "@checkstack/healthcheck-backend";
33
+ import { CATALOG_SYSTEM_ENTITY_KIND } from "@checkstack/catalog-backend";
34
+ import {
35
+ HEALTH_ENTITY_KIND,
36
+ classifyHealthChange,
37
+ } from "@checkstack/healthcheck-backend";
33
38
  import { evaluateAndNotifyDownstream } from "./notifications";
39
+ import {
40
+ DEPENDENCY_EDGE_ENTITY_KIND,
41
+ DependencyEdgeStateSchema,
42
+ createDependencyEntityRead,
43
+ dependencyChangeToPayload,
44
+ deriveDependencyTriggerEvents,
45
+ type DependencyEdgeState,
46
+ } from "./dependency-entity";
34
47
  import { entityKindExtensionPoint } from "@checkstack/gitops-backend";
35
48
  import { registerDependencyGitOpsKinds } from "./dependency-gitops-kinds";
36
49
 
@@ -38,6 +51,17 @@ import { registerDependencyGitOpsKinds } from "./dependency-gitops-kinds";
38
51
  // Plugin Definition
39
52
  // =============================================================================
40
53
 
54
+ // Reactive `dependency-edge` entity handle (§10.5). Defined in register();
55
+ // mutated from the router/actions onward.
56
+ let dependencyEntity: EntityHandle<DependencyEdgeState> | undefined;
57
+
58
+ // The DependencyService is created in init() (it needs the resolved
59
+ // database), but the PLUGIN-BACKED entity `read` accessor must be supplied at
60
+ // `defineEntity` time in register(). This holder bridges the two: the `read`
61
+ // closure resolves the service lazily, and init() sets it before any mutation
62
+ // runs (the registry only mutates from init() onward).
63
+ let dependencyServiceRef: DependencyService | undefined;
64
+
41
65
  export default createBackendPlugin({
42
66
  metadata: pluginMetadata,
43
67
  register(env) {
@@ -58,6 +82,40 @@ export default createBackendPlugin({
58
82
  .getExtensionPoint(automationArtifactTypeExtensionPoint)
59
83
  .registerArtifactType(dependencyArtifactType, pluginMetadata);
60
84
 
85
+ // ─── Reactive `dependency-edge` entity (§10.5) ─────────────────────
86
+ // PLUGIN-BACKED (Model B): the `dependencies` table IS the current-state
87
+ // storage. `read` routes straight to the service's batched authoritative
88
+ // read — no framework `entity_state` row, so no `indexes` (those only
89
+ // apply to store-backed kinds). The `read` closure resolves the service
90
+ // set by init() (mutations only happen from init on).
91
+ const entityPoint = env.getExtensionPoint(entityExtensionPoint);
92
+ dependencyEntity = entityPoint.defineEntity<DependencyEdgeState>({
93
+ kind: DEPENDENCY_EDGE_ENTITY_KIND,
94
+ state: DependencyEdgeStateSchema,
95
+ read: (ids) => {
96
+ const svc = dependencyServiceRef;
97
+ if (!svc) {
98
+ throw new Error(
99
+ "dependency entity read before init: service not yet resolved",
100
+ );
101
+ }
102
+ return createDependencyEntityRead(svc)(ids);
103
+ },
104
+ });
105
+ entityPoint.registerChangeDeriver({
106
+ kind: DEPENDENCY_EDGE_ENTITY_KIND,
107
+ derive: deriveDependencyTriggerEvents,
108
+ toPayload: dependencyChangeToPayload,
109
+ });
110
+ const onEntityChanged = entityPoint.onEntityChanged;
111
+ // The propagation cursor is internal bookkeeping (§5): derived
112
+ // per-system state is reachable via the `health` entity, not this table.
113
+ entityPoint.declareNonReactiveState({
114
+ table: "dependency_derived_states",
115
+ reason: "bookkeeping",
116
+ note: "Propagation cursor for downstream impact evaluation. Derived per-system state is reachable via the health entity.",
117
+ });
118
+
61
119
  // ─── GitOps Entity Kind Registration ─────────────────────────────
62
120
  let gitopsService: DependencyService | undefined;
63
121
  const kindRegistry = env.getExtensionPoint(entityKindExtensionPoint);
@@ -88,6 +146,9 @@ export default createBackendPlugin({
88
146
  database as SafeDatabase<typeof schema>,
89
147
  );
90
148
  gitopsService = service;
149
+ // Publish the service for the PLUGIN-BACKED entity `read` accessor
150
+ // (defined in register()). Mutations only run from here onward.
151
+ dependencyServiceRef = service;
91
152
  const warningService = new WarningEvaluationService();
92
153
 
93
154
  const router = createRouter({
@@ -97,6 +158,7 @@ export default createBackendPlugin({
97
158
  catalogClient,
98
159
  healthCheckClient,
99
160
  logger,
161
+ getDependencyEntity: () => dependencyEntity,
100
162
  });
101
163
  rpc.registerRouter(router, dependencyContract);
102
164
 
@@ -106,7 +168,6 @@ export default createBackendPlugin({
106
168
  database,
107
169
  rpcClient,
108
170
  logger,
109
- onHook,
110
171
  emitHook,
111
172
  signalService,
112
173
  }) => {
@@ -132,7 +193,11 @@ export default createBackendPlugin({
132
193
  const automationActions = env.getExtensionPoint(
133
194
  automationActionExtensionPoint,
134
195
  );
135
- for (const action of createDependencyActions({ service, emitHook })) {
196
+ for (const action of createDependencyActions({
197
+ service,
198
+ emitHook,
199
+ getDependencyEntity: () => dependencyEntity,
200
+ })) {
136
201
  automationActions.registerAction(action, pluginMetadata);
137
202
  }
138
203
 
@@ -203,27 +268,42 @@ export default createBackendPlugin({
203
268
  return statuses;
204
269
  }
205
270
 
206
- // Subscribe to catalog system deletion to clean up dependencies
207
- onHook(
208
- catalogHooks.systemDeleted,
209
- async (payload) => {
271
+ // Cross-plugin consumers now react to the reactive `catalog-system`
272
+ // / `health` ENTITY changes via `onEntityChanged` instead of the
273
+ // (being-removed) catalog/healthcheck hooks (§10.5). All keep
274
+ // `work-queue` delivery: cleanup + downstream-propagation are
275
+ // side-effecting writes that must run exactly once per cluster, not
276
+ // per-instance (broadcast would re-run them N times).
277
+
278
+ // Subscribe to catalog system deletion (tombstone) to clean up edges
279
+ onEntityChanged({
280
+ kind: CATALOG_SYSTEM_ENTITY_KIND,
281
+ handler: async (change) => {
282
+ if (change.next !== null) return; // tombstone only
283
+ const systemId = change.id;
210
284
  logger.debug(
211
- `Cleaning up dependencies for deleted system: ${payload.systemId}`,
285
+ `Cleaning up dependencies for deleted system: ${systemId}`,
212
286
  );
213
- await service.removeSystemDependencies(payload.systemId);
287
+ await service.removeSystemDependencies(systemId);
214
288
  },
215
- { mode: "work-queue", workerGroup: "dependency-system-cleanup" },
216
- );
289
+ delivery: {
290
+ mode: "work-queue",
291
+ workerGroup: "dependency-system-cleanup",
292
+ },
293
+ });
217
294
 
218
- // Subscribe to health check state changes to notify downstream dependents
219
- onHook(
220
- healthCheckHooks.systemDegraded,
221
- async (payload) => {
295
+ // Upstream health DEGRADED evaluate downstream dependents
296
+ onEntityChanged({
297
+ kind: HEALTH_ENTITY_KIND,
298
+ handler: async (change) => {
299
+ const { systemId, degraded, previousStatus, newStatus } =
300
+ classifyHealthChange(change);
301
+ if (!degraded) return;
222
302
  logger.debug(
223
- `Upstream ${payload.systemId} degraded (${payload.previousStatus} → ${payload.newStatus}), evaluating downstream dependencies`,
303
+ `Upstream ${systemId} degraded (${previousStatus} → ${newStatus}), evaluating downstream dependencies`,
224
304
  );
225
305
  await evaluateAndNotifyDownstream({
226
- changedSystemId: payload.systemId,
306
+ changedSystemId: systemId,
227
307
  db: typedDb,
228
308
  dependencyService: service,
229
309
  warningService,
@@ -237,20 +317,23 @@ export default createBackendPlugin({
237
317
  emitImpactPropagated,
238
318
  });
239
319
  },
240
- {
320
+ delivery: {
241
321
  mode: "work-queue",
242
322
  workerGroup: "dependency-notification-evaluator",
243
323
  },
244
- );
324
+ });
245
325
 
246
- onHook(
247
- healthCheckHooks.systemHealthy,
248
- async (payload) => {
326
+ // Upstream health RECOVERED → evaluate downstream dependents
327
+ onEntityChanged({
328
+ kind: HEALTH_ENTITY_KIND,
329
+ handler: async (change) => {
330
+ const { systemId, recovered } = classifyHealthChange(change);
331
+ if (!recovered) return;
249
332
  logger.debug(
250
- `Upstream ${payload.systemId} recovered, evaluating downstream dependencies`,
333
+ `Upstream ${systemId} recovered, evaluating downstream dependencies`,
251
334
  );
252
335
  await evaluateAndNotifyDownstream({
253
- changedSystemId: payload.systemId,
336
+ changedSystemId: systemId,
254
337
  db: typedDb,
255
338
  dependencyService: service,
256
339
  warningService,
@@ -264,11 +347,11 @@ export default createBackendPlugin({
264
347
  emitImpactPropagated,
265
348
  });
266
349
  },
267
- {
350
+ delivery: {
268
351
  mode: "work-queue",
269
352
  workerGroup: "dependency-notification-recovery",
270
353
  },
271
- );
354
+ });
272
355
 
273
356
  logger.debug("✅ Dependency Backend afterPluginsReady complete.");
274
357
  },
package/src/router.ts CHANGED
@@ -16,11 +16,17 @@ import type {
16
16
  WarningEvaluationService,
17
17
  SystemStatus,
18
18
  } from "./services/warning-evaluation-service";
19
- import { dependencyHooks } from "./hooks";
20
19
  import type { InferClient } from "@checkstack/common";
21
20
  import { extractErrorMessage } from "@checkstack/common";
22
21
  import { CatalogApi } from "@checkstack/catalog-common";
23
22
  import { HealthCheckApi } from "@checkstack/healthcheck-common";
23
+ import type { EntityHandle } from "@checkstack/automation-backend";
24
+ import {
25
+ removeDependencyEdge,
26
+ toDependencyEdgeState,
27
+ writeDependencyEdge,
28
+ type DependencyEdgeState,
29
+ } from "./dependency-entity";
24
30
 
25
31
  export function createRouter({
26
32
  service,
@@ -29,6 +35,7 @@ export function createRouter({
29
35
  catalogClient,
30
36
  healthCheckClient,
31
37
  logger,
38
+ getDependencyEntity,
32
39
  }: {
33
40
  service: DependencyService;
34
41
  warningService: WarningEvaluationService;
@@ -36,6 +43,8 @@ export function createRouter({
36
43
  catalogClient: InferClient<typeof CatalogApi>;
37
44
  healthCheckClient: InferClient<typeof HealthCheckApi>;
38
45
  logger: Logger;
46
+ /** Resolver for the reactive `dependency-edge` entity (§10.5). */
47
+ getDependencyEntity?: () => EntityHandle<DependencyEdgeState> | undefined;
39
48
  }) {
40
49
  /**
41
50
  * Fetch system statuses for warning evaluation using the bulk health status API.
@@ -181,9 +190,27 @@ export function createRouter({
181
190
  ),
182
191
 
183
192
  createDependency: os.createDependency.handler(
184
- async ({ input, context }) => {
193
+ async ({ input }) => {
185
194
  try {
186
- const result = await service.createDependency(input);
195
+ // Drive the create through the reactive `dependency-edge` entity
196
+ // (§10.5): `apply` performs the REAL `dependencies` write (the
197
+ // plugin's own db/tx, including cycle/duplicate validation that may
198
+ // throw) and returns the new reactive state; the deriver fires
199
+ // `dependency.created` from the resulting change. The id is
200
+ // generated up front so the handle is keyed on it and the create's
201
+ // `prev` snapshot reads the not-yet-existing row as absent. A
202
+ // throwing `apply` means no transition is appended and nothing is
203
+ // emitted (the create never happened).
204
+ const dependencyId = crypto.randomUUID();
205
+ let result!: Awaited<ReturnType<typeof service.createDependency>>;
206
+ await writeDependencyEdge({
207
+ handle: getDependencyEntity?.(),
208
+ dependencyId,
209
+ apply: async () => {
210
+ result = await service.createDependency(input, dependencyId);
211
+ return toDependencyEdgeState(result);
212
+ },
213
+ });
187
214
 
188
215
  // Broadcast signal
189
216
  await signalService.broadcast(DEPENDENCY_CHANGED, {
@@ -193,14 +220,6 @@ export function createRouter({
193
220
  action: "created",
194
221
  });
195
222
 
196
- // Emit hook
197
- await context.emitHook(dependencyHooks.dependencyCreated, {
198
- dependencyId: result.id,
199
- sourceSystemId: result.sourceSystemId,
200
- targetSystemId: result.targetSystemId,
201
- impactType: result.impactType,
202
- });
203
-
204
223
  // Notify affected systems about warning changes
205
224
  await signalService.broadcast(DEPENDENCY_WARNINGS_CHANGED, {
206
225
  affectedSystemIds: [result.sourceSystemId],
@@ -221,26 +240,43 @@ export function createRouter({
221
240
  ),
222
241
 
223
242
  updateDependency: os.updateDependency.handler(
224
- async ({ input, context }) => {
225
- const result = await service.updateDependency(input);
226
- if (!result) {
243
+ async ({ input }) => {
244
+ // Probe existence first so a missing dependency still surfaces as
245
+ // NOT_FOUND without driving an entity write.
246
+ const exists = await service.getDependencyById(input.id);
247
+ if (!exists) {
227
248
  throw new ORPCError("NOT_FOUND", {
228
249
  message: "Dependency not found",
229
250
  });
230
251
  }
231
252
 
232
- await signalService.broadcast(DEPENDENCY_CHANGED, {
233
- dependencyId: result.id,
234
- sourceSystemId: result.sourceSystemId,
235
- targetSystemId: result.targetSystemId,
236
- action: "updated",
253
+ // Drive the update through the reactive `dependency-edge` entity
254
+ // (§10.5); the REAL update runs INSIDE `apply`, so `prev` is
255
+ // snapshotted before the write and the deriver fires
256
+ // `dependency.updated` from the resulting change.
257
+ let result!: NonNullable<
258
+ Awaited<ReturnType<typeof service.updateDependency>>
259
+ >;
260
+ await writeDependencyEdge({
261
+ handle: getDependencyEntity?.(),
262
+ dependencyId: input.id,
263
+ apply: async () => {
264
+ const updated = await service.updateDependency(input);
265
+ if (!updated) {
266
+ throw new ORPCError("NOT_FOUND", {
267
+ message: "Dependency not found",
268
+ });
269
+ }
270
+ result = updated;
271
+ return toDependencyEdgeState(result);
272
+ },
237
273
  });
238
274
 
239
- await context.emitHook(dependencyHooks.dependencyUpdated, {
275
+ await signalService.broadcast(DEPENDENCY_CHANGED, {
240
276
  dependencyId: result.id,
241
277
  sourceSystemId: result.sourceSystemId,
242
278
  targetSystemId: result.targetSystemId,
243
- impactType: result.impactType,
279
+ action: "updated",
244
280
  });
245
281
 
246
282
  await signalService.broadcast(DEPENDENCY_WARNINGS_CHANGED, {
@@ -252,9 +288,22 @@ export function createRouter({
252
288
  ),
253
289
 
254
290
  deleteDependency: os.deleteDependency.handler(
255
- async ({ input, context }) => {
291
+ async ({ input }) => {
256
292
  const existing = await service.getDependencyById(input.id);
257
- const success = await service.deleteDependency(input.id);
293
+
294
+ // Drive the delete through the reactive `dependency-edge` entity
295
+ // tombstone (§10.5). The REAL delete runs INSIDE `apply`, so `prev`
296
+ // is snapshotted before it and the deriver fires `dependency.deleted`
297
+ // from the tombstone. `success` tracks whether the row was actually
298
+ // deleted.
299
+ let success = false;
300
+ await removeDependencyEdge({
301
+ handle: getDependencyEntity?.(),
302
+ dependencyId: input.id,
303
+ apply: async () => {
304
+ success = await service.deleteDependency(input.id);
305
+ },
306
+ });
258
307
 
259
308
  if (success && existing) {
260
309
  await signalService.broadcast(DEPENDENCY_CHANGED, {
@@ -264,12 +313,6 @@ export function createRouter({
264
313
  action: "deleted",
265
314
  });
266
315
 
267
- await context.emitHook(dependencyHooks.dependencyDeleted, {
268
- dependencyId: input.id,
269
- sourceSystemId: existing.sourceSystemId,
270
- targetSystemId: existing.targetSystemId,
271
- });
272
-
273
316
  await signalService.broadcast(DEPENDENCY_WARNINGS_CHANGED, {
274
317
  affectedSystemIds: [existing.sourceSystemId],
275
318
  });
@@ -11,6 +11,7 @@ import type {
11
11
  CreateDependencyInput,
12
12
  UpdateDependencyInput,
13
13
  NodePosition,
14
+ ImpactType,
14
15
  } from "@checkstack/dependency-common";
15
16
 
16
17
  type Db = SafeDatabase<typeof schema>;
@@ -59,8 +60,17 @@ export class DependencyService {
59
60
  /**
60
61
  * Create a new dependency with cycle detection.
61
62
  * Throws if the dependency would create a circular chain.
63
+ *
64
+ * `id` may be supplied by the caller so the reactive `dependency-edge`
65
+ * entity can be keyed on a known id BEFORE the insert runs (the create's
66
+ * `prev` snapshot must read the not-yet-existing row as absent — see
67
+ * §10.5). When omitted, a fresh id is generated. The id is server-owned
68
+ * either way.
62
69
  */
63
- async createDependency(input: CreateDependencyInput): Promise<Dependency> {
70
+ async createDependency(
71
+ input: CreateDependencyInput,
72
+ id: string = generateId(),
73
+ ): Promise<Dependency> {
64
74
  // Check for duplicate edge
65
75
  const [existing] = await this.db
66
76
  .select()
@@ -90,7 +100,6 @@ export class DependencyService {
90
100
  );
91
101
  }
92
102
 
93
- const id = generateId();
94
103
  await this.db.insert(dependencies).values({
95
104
  id,
96
105
  sourceSystemId: input.sourceSystemId,
@@ -213,12 +222,67 @@ export class DependencyService {
213
222
 
214
223
  return {
215
224
  ...row,
216
-
225
+
217
226
  label: row.label ?? null,
218
227
  healthCheckRules: rules.length > 0 ? rules : undefined,
219
228
  };
220
229
  }
221
230
 
231
+ /**
232
+ * Batched reactive-state read for the `dependency-edge` entity (Model B
233
+ * plugin-backed `read` accessor). Given dependency ids, return the reactive
234
+ * subset `{ sourceSystemId, targetSystemId, impactType, transitive }` for
235
+ * each that exists (missing ids omitted). Reads the AUTHORITATIVE
236
+ * `dependencies` table — no framework `entity_state` storage. This is the
237
+ * single source of truth `handle.mutate` snapshots `prev` from and
238
+ * `get`/`getMany`/scope enrichment route through.
239
+ */
240
+ async getManyEntityStates(
241
+ ids: ReadonlyArray<string>,
242
+ ): Promise<
243
+ Record<
244
+ string,
245
+ {
246
+ sourceSystemId: string;
247
+ targetSystemId: string;
248
+ impactType: ImpactType;
249
+ transitive: boolean;
250
+ }
251
+ >
252
+ > {
253
+ if (ids.length === 0) return {};
254
+
255
+ const rows = await this.db
256
+ .select({
257
+ id: dependencies.id,
258
+ sourceSystemId: dependencies.sourceSystemId,
259
+ targetSystemId: dependencies.targetSystemId,
260
+ impactType: dependencies.impactType,
261
+ transitive: dependencies.transitive,
262
+ })
263
+ .from(dependencies)
264
+ .where(inArray(dependencies.id, [...ids]));
265
+
266
+ const out: Record<
267
+ string,
268
+ {
269
+ sourceSystemId: string;
270
+ targetSystemId: string;
271
+ impactType: ImpactType;
272
+ transitive: boolean;
273
+ }
274
+ > = {};
275
+ for (const row of rows) {
276
+ out[row.id] = {
277
+ sourceSystemId: row.sourceSystemId,
278
+ targetSystemId: row.targetSystemId,
279
+ impactType: row.impactType,
280
+ transitive: row.transitive,
281
+ };
282
+ }
283
+ return out;
284
+ }
285
+
222
286
  // ===========================================================================
223
287
  // NODE POSITIONS
224
288
  // ===========================================================================