@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/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
|
|
193
|
+
async ({ input }) => {
|
|
185
194
|
try {
|
|
186
|
-
|
|
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
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
|
275
|
+
await signalService.broadcast(DEPENDENCY_CHANGED, {
|
|
240
276
|
dependencyId: result.id,
|
|
241
277
|
sourceSystemId: result.sourceSystemId,
|
|
242
278
|
targetSystemId: result.targetSystemId,
|
|
243
|
-
|
|
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
|
|
291
|
+
async ({ input }) => {
|
|
256
292
|
const existing = await service.getDependencyById(input.id);
|
|
257
|
-
|
|
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(
|
|
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
|
// ===========================================================================
|