@checkstack/catalog-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.
- package/CHANGELOG.md +102 -0
- package/package.json +15 -15
- package/src/automations.test.ts +92 -19
- package/src/automations.ts +61 -18
- package/src/catalog-entity.test.ts +308 -0
- package/src/catalog-entity.ts +274 -0
- package/src/hooks.ts +12 -56
- package/src/index.ts +94 -5
- package/src/router.ts +160 -61
- package/src/services/entity-service.ts +112 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,107 @@
|
|
|
1
1
|
# @checkstack/catalog-backend
|
|
2
2
|
|
|
3
|
+
## 1.3.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- b995afb: Make `catalog-system` and `catalog-group` plugin-backed reactive entities via the Model-B entity state machine.
|
|
8
|
+
|
|
9
|
+
Catalog defines a `catalog-system` entity `{ name, description, metadata }` and a `catalog-group` entity `{ name, metadata }`. The `systems` / `groups` tables are BOTH authoritative AND the entities' current-state storage - there is no framework `entity_state` row for a catalog system/group. `defineEntity` is given plugin `read` accessors (`EntityService.getManySystemEntityStates` / `getManyGroupEntityStates`) that project the reactive subsets straight off those tables, and every reactive-state write goes through `handle.mutate` / `handle.remove`: `apply` performs the REAL `systems` / `groups` write (the plugin's own db/tx) 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-system, update-system, delete-system (tombstone), create-group, update-group, delete-group (tombstone), and the `system.update_metadata` automation action. Create sites pre-generate the id so the handle is keyed on it and the create's `prev` snapshot reads the not-yet-existing row as absent; `EntityService.createSystem` / `createGroup` accept an optional pre-generated `id` (server-owned either way).
|
|
10
|
+
|
|
11
|
+
Change -> trigger-event derivers reproduce the existing qualified events (emitting the TRIGGER event ids automations match on, not the dotted hook ids):
|
|
12
|
+
|
|
13
|
+
- `catalog-system`: create -> `catalog.created`; tombstone -> `catalog.deleted`; field update -> `catalog.updated`.
|
|
14
|
+
- `catalog-group`: create -> `catalog.group.created`; tombstone -> `catalog.group.deleted` (a pure group update fires nothing).
|
|
15
|
+
|
|
16
|
+
Mirrors are diff-suppressed (a save-with-no-diff stays a no-op). The `catalog.system.*` / `catalog.group.*` cross-plugin hooks are removed in the same effort (see the healthcheck/catalog hook-removal changeset); cross-plugin consumers (incident, dependency, slo, healthcheck) read via `onEntityChanged`.
|
|
17
|
+
|
|
18
|
+
BREAKING CHANGES (behavior): none for trigger-event consumers - the same qualified trigger events still fire via the change derivers, and `onEntityChanged` consumers see the same change event. The only observable change is internal: catalog current state is read from the `systems` / `groups` tables instead of `entity_state`, and writes route through the entity handle. The `system.update_metadata` action's race-deleted ("disappeared mid-update") path now drives a no-op entity write (the framework diffs it as no change) before returning failure, instead of skipping the write entirely; no event fires either way.
|
|
19
|
+
|
|
20
|
+
- b995afb: Close a run-secret masking gap on run-originated catalog entity writes (security).
|
|
21
|
+
|
|
22
|
+
`writeCatalogSystemEntity` / `writeCatalogGroupEntity` had no `opts` parameter, so the `system.update_metadata` automation action (which has the dispatch `runId` in scope) could not forward it. Catalog `metadata` is `z.record(z.string(), z.unknown())` — the only reactive catalog field that can carry an arbitrary secret string — so a run-resolved secret merged into metadata would land UNMASKED in both the `entity_transitions` rows and the cluster-wide `ENTITY_CHANGED` event.
|
|
23
|
+
|
|
24
|
+
The catalog entity writers now accept `opts?: EntityMutationOpts` and forward it into `handle.mutate` / `handle.remove` (mirroring maintenance/slo), and `system.update_metadata` passes `opts: { runId }`. Run-resolved secrets in metadata are now masked in both the emit and the transition rows.
|
|
25
|
+
|
|
26
|
+
- b995afb: Remove the now-unused healthcheck + catalog entity hooks; rely on the reactive entities + change derivers (reactive automation engine Phase 4, final step of §10.3 / §10.4).
|
|
27
|
+
|
|
28
|
+
Now that every cross-plugin consumer (slo, dependency, incident, and healthcheck's own catalog-cleanup) reads these domains via `onEntityChanged`, the producers stop emitting the entity-change hooks and the trigger registrations become entity-driven (fired by the entity change deriver via Stage-1 routing, with a no-op `setup` so they stay in the editor's trigger catalog).
|
|
29
|
+
|
|
30
|
+
- **healthcheck**: stops emitting `healthcheck.system.degraded` / `.healthy` / `.health_changed` from the queue executor (the `health` entity mirror is the single source of truth). Its own `catalog.system.deleted` consumer switched to `onEntityChanged({ kind: "catalog-system" })` on tombstones (work-queue delivery preserved). The directional/umbrella triggers are now entity-driven.
|
|
31
|
+
- **catalog**: stops emitting `catalog.system.created` / `.updated` / `.deleted` and `catalog.group.created` / `.deleted` from the router + the `system.update_metadata` action (the `catalog-system` / `catalog-group` mirrors are authoritative). The system triggers are now entity-driven.
|
|
32
|
+
|
|
33
|
+
CORRECTNESS FIX (also affects the earlier healthcheck/catalog Phase-4 steps in this branch): the change derivers now emit the TRIGGER qualifiedIds that automations actually store in `trigger.event` and that Stage-1 routing matches on (`findEnabledByTriggerEvent`), NOT the dotted hook ids. Healthcheck triggers use underscore ids, so the deriver emits `healthcheck.system_degraded` / `system_healthy` / `system_health_changed` (not `healthcheck.system.degraded`). Catalog system triggers use ids `created`/`updated`/`deleted`, so the deriver emits `catalog.created` / `catalog.updated` / `catalog.deleted` (not `catalog.system.created`). Without this fix the migrated automations would never fire.
|
|
34
|
+
|
|
35
|
+
BREAKING CHANGES:
|
|
36
|
+
|
|
37
|
+
- `healthcheck.system.degraded` / `healthcheck.system.healthy` / `healthcheck.system.health_changed` cross-plugin hooks are removed. The reactive `health` entity drives the matching trigger events (`healthcheck.system_degraded` / `_healthy` / `_health_changed`), so existing automations keep firing. Kept healthcheck hooks: `assignment.changed`, `check.completed`, `check.failed`, `flapping_detected`.
|
|
38
|
+
- `catalog.system.created` / `.updated` / `.deleted` and `catalog.group.created` / `.deleted` cross-plugin hooks are removed. The reactive `catalog-system` / `catalog-group` entities drive the matching trigger events (`catalog.created` / `.updated` / `.deleted`); cross-plugin cleanup reactors subscribe to the `catalog-system` tombstone via `onEntityChanged`. `catalogHooks` / `healthCheckHooks` remain exported (the removed members are gone) for a stable import surface.
|
|
39
|
+
|
|
40
|
+
- b995afb: Restore the documented domain payload fields on entity-driven automation triggers.
|
|
41
|
+
|
|
42
|
+
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.
|
|
43
|
+
|
|
44
|
+
Changes:
|
|
45
|
+
|
|
46
|
+
- `@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`.
|
|
47
|
+
- `@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.
|
|
48
|
+
|
|
49
|
+
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.
|
|
50
|
+
|
|
51
|
+
### Patch Changes
|
|
52
|
+
|
|
53
|
+
- b995afb: Extract a shared `withEntityWrite` / `withEntityRemove` guard for PLUGIN-BACKED (Model B) reactive entities and refactor the per-domain copies onto it.
|
|
54
|
+
|
|
55
|
+
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.
|
|
56
|
+
|
|
57
|
+
`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.
|
|
58
|
+
|
|
59
|
+
- Updated dependencies [270ef29]
|
|
60
|
+
- Updated dependencies [b995afb]
|
|
61
|
+
- Updated dependencies [b995afb]
|
|
62
|
+
- Updated dependencies [b995afb]
|
|
63
|
+
- Updated dependencies [270ef29]
|
|
64
|
+
- Updated dependencies [270ef29]
|
|
65
|
+
- Updated dependencies [270ef29]
|
|
66
|
+
- Updated dependencies [270ef29]
|
|
67
|
+
- Updated dependencies [270ef29]
|
|
68
|
+
- Updated dependencies [270ef29]
|
|
69
|
+
- Updated dependencies [270ef29]
|
|
70
|
+
- Updated dependencies [270ef29]
|
|
71
|
+
- Updated dependencies [b995afb]
|
|
72
|
+
- Updated dependencies [b995afb]
|
|
73
|
+
- Updated dependencies [270ef29]
|
|
74
|
+
- Updated dependencies [b995afb]
|
|
75
|
+
- Updated dependencies [270ef29]
|
|
76
|
+
- Updated dependencies [b995afb]
|
|
77
|
+
- Updated dependencies [b995afb]
|
|
78
|
+
- Updated dependencies [270ef29]
|
|
79
|
+
- Updated dependencies [b995afb]
|
|
80
|
+
- Updated dependencies [b995afb]
|
|
81
|
+
- Updated dependencies [270ef29]
|
|
82
|
+
- Updated dependencies [b995afb]
|
|
83
|
+
- Updated dependencies [b995afb]
|
|
84
|
+
- Updated dependencies [b995afb]
|
|
85
|
+
- Updated dependencies [b995afb]
|
|
86
|
+
- Updated dependencies [270ef29]
|
|
87
|
+
- Updated dependencies [270ef29]
|
|
88
|
+
- Updated dependencies [270ef29]
|
|
89
|
+
- Updated dependencies [270ef29]
|
|
90
|
+
- Updated dependencies [270ef29]
|
|
91
|
+
- Updated dependencies [270ef29]
|
|
92
|
+
- Updated dependencies [270ef29]
|
|
93
|
+
- Updated dependencies [270ef29]
|
|
94
|
+
- Updated dependencies [b995afb]
|
|
95
|
+
- Updated dependencies [b995afb]
|
|
96
|
+
- @checkstack/backend-api@0.19.0
|
|
97
|
+
- @checkstack/automation-backend@0.3.0
|
|
98
|
+
- @checkstack/gitops-common@0.5.0
|
|
99
|
+
- @checkstack/gitops-backend@0.4.0
|
|
100
|
+
- @checkstack/auth-backend@0.4.32
|
|
101
|
+
- @checkstack/cache-api@0.3.7
|
|
102
|
+
- @checkstack/command-backend@0.1.32
|
|
103
|
+
- @checkstack/cache-utils@0.2.12
|
|
104
|
+
|
|
3
105
|
## 1.2.0
|
|
4
106
|
|
|
5
107
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@checkstack/catalog-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",
|
|
@@ -15,28 +15,28 @@
|
|
|
15
15
|
"test": "bun test"
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@checkstack/backend-api": "0.
|
|
19
|
-
"@checkstack/automation-backend": "0.
|
|
20
|
-
"@checkstack/cache-api": "0.3.
|
|
21
|
-
"@checkstack/cache-utils": "0.2.
|
|
22
|
-
"@checkstack/auth-common": "0.7.
|
|
23
|
-
"@checkstack/catalog-common": "2.2.
|
|
24
|
-
"@checkstack/command-backend": "0.1.
|
|
25
|
-
"@checkstack/auth-backend": "0.4.
|
|
26
|
-
"@checkstack/gitops-backend": "0.3.
|
|
27
|
-
"@checkstack/gitops-common": "0.4.
|
|
28
|
-
"@checkstack/notification-common": "1.2.
|
|
18
|
+
"@checkstack/backend-api": "0.18.0",
|
|
19
|
+
"@checkstack/automation-backend": "0.2.0",
|
|
20
|
+
"@checkstack/cache-api": "0.3.6",
|
|
21
|
+
"@checkstack/cache-utils": "0.2.11",
|
|
22
|
+
"@checkstack/auth-common": "0.7.2",
|
|
23
|
+
"@checkstack/catalog-common": "2.2.3",
|
|
24
|
+
"@checkstack/command-backend": "0.1.31",
|
|
25
|
+
"@checkstack/auth-backend": "0.4.31",
|
|
26
|
+
"@checkstack/gitops-backend": "0.3.7",
|
|
27
|
+
"@checkstack/gitops-common": "0.4.2",
|
|
28
|
+
"@checkstack/notification-common": "1.2.1",
|
|
29
29
|
"@orpc/server": "^1.13.2",
|
|
30
30
|
"drizzle-orm": "^0.45.0",
|
|
31
31
|
"hono": "^4.12.14",
|
|
32
32
|
"uuid": "^14.0.0",
|
|
33
33
|
"zod": "^4.2.1",
|
|
34
|
-
"@checkstack/common": "0.
|
|
34
|
+
"@checkstack/common": "0.12.0"
|
|
35
35
|
},
|
|
36
36
|
"devDependencies": {
|
|
37
37
|
"@checkstack/drizzle-helper": "0.0.5",
|
|
38
|
-
"@checkstack/scripts": "0.3.
|
|
39
|
-
"@checkstack/test-utils-backend": "0.1.
|
|
38
|
+
"@checkstack/scripts": "0.3.4",
|
|
39
|
+
"@checkstack/test-utils-backend": "0.1.31",
|
|
40
40
|
"@checkstack/tsconfig": "0.0.7",
|
|
41
41
|
"@types/bun": "^1.3.5",
|
|
42
42
|
"@types/node": "^20.0.0",
|
package/src/automations.test.ts
CHANGED
|
@@ -22,7 +22,6 @@ import {
|
|
|
22
22
|
} from "./automations";
|
|
23
23
|
import type { EntityService } from "./services/entity-service";
|
|
24
24
|
import type { createCatalogCache } from "./cache";
|
|
25
|
-
import { catalogHooks } from "./hooks";
|
|
26
25
|
|
|
27
26
|
const logger = createMockLogger() as Logger;
|
|
28
27
|
|
|
@@ -57,7 +56,10 @@ describe("catalog triggers", () => {
|
|
|
57
56
|
const parsed = systemCreatedTrigger.payloadSchema.safeParse(payload);
|
|
58
57
|
expect(parsed.success).toBe(true);
|
|
59
58
|
expect(systemCreatedTrigger.contextKey?.(payload)).toBe("sys-1");
|
|
60
|
-
|
|
59
|
+
// Entity-driven now (§10.4): no hook, a no-op setup keeps it in the
|
|
60
|
+
// editor catalog; the `catalog-system` deriver fires `catalog.created`.
|
|
61
|
+
expect(systemCreatedTrigger.hook).toBeUndefined();
|
|
62
|
+
expect(typeof systemCreatedTrigger.setup).toBe("function");
|
|
61
63
|
});
|
|
62
64
|
|
|
63
65
|
it("requires changedFields on the systemUpdated payload", () => {
|
|
@@ -128,7 +130,10 @@ interface FakeSystemRow {
|
|
|
128
130
|
interface ActionFixture {
|
|
129
131
|
service: EntityService;
|
|
130
132
|
cache: ReturnType<typeof createCatalogCache>;
|
|
131
|
-
|
|
133
|
+
mutateMock: ReturnType<typeof mock>;
|
|
134
|
+
/** Reactive states returned by each `apply()` the action drove. */
|
|
135
|
+
mutateResults: unknown[];
|
|
136
|
+
getSystemEntity: () => never;
|
|
132
137
|
updateSystemMock: ReturnType<typeof mock>;
|
|
133
138
|
getSystemMock: ReturnType<typeof mock>;
|
|
134
139
|
invalidateTopologyMock: ReturnType<typeof mock>;
|
|
@@ -168,12 +173,30 @@ function makeFixture(args: {
|
|
|
168
173
|
invalidateContacts: mock(async () => {}),
|
|
169
174
|
} as unknown as ReturnType<typeof createCatalogCache>;
|
|
170
175
|
|
|
171
|
-
|
|
176
|
+
// The action now drives its write through `handle.mutate({ id, apply })`:
|
|
177
|
+
// it runs `apply()` (the real `updateSystem`) once and records the id + the
|
|
178
|
+
// resulting reactive state, mirroring the framework handle.
|
|
179
|
+
const mutateResults: unknown[] = [];
|
|
180
|
+
const mutateMock = mock(
|
|
181
|
+
async (input: {
|
|
182
|
+
id: string;
|
|
183
|
+
opts?: { runId?: string };
|
|
184
|
+
apply: () => Promise<unknown>;
|
|
185
|
+
}) => {
|
|
186
|
+
const next = await input.apply();
|
|
187
|
+
mutateResults.push(next);
|
|
188
|
+
return next;
|
|
189
|
+
},
|
|
190
|
+
);
|
|
191
|
+
const getSystemEntity = () =>
|
|
192
|
+
({ kind: "catalog-system", mutate: mutateMock }) as never;
|
|
172
193
|
|
|
173
194
|
return {
|
|
174
195
|
service,
|
|
175
196
|
cache,
|
|
176
|
-
|
|
197
|
+
mutateMock,
|
|
198
|
+
mutateResults,
|
|
199
|
+
getSystemEntity,
|
|
177
200
|
updateSystemMock,
|
|
178
201
|
getSystemMock,
|
|
179
202
|
invalidateTopologyMock,
|
|
@@ -192,7 +215,7 @@ describe("catalog.system.update_metadata", () => {
|
|
|
192
215
|
const [action] = createCatalogActions({
|
|
193
216
|
entityService: fx.service,
|
|
194
217
|
cache: fx.cache,
|
|
195
|
-
|
|
218
|
+
getSystemEntity: fx.getSystemEntity,
|
|
196
219
|
});
|
|
197
220
|
|
|
198
221
|
const result = await action!.execute({
|
|
@@ -228,14 +251,55 @@ describe("catalog.system.update_metadata", () => {
|
|
|
228
251
|
});
|
|
229
252
|
|
|
230
253
|
expect(fx.invalidateTopologyMock).toHaveBeenCalledTimes(1);
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
254
|
+
// The old `systemUpdated` hook emission was replaced by driving the edit
|
|
255
|
+
// through the reactive `catalog-system` entity via `handle.mutate` (§10.4):
|
|
256
|
+
// the handle is keyed by system id, and `apply` returns the resulting
|
|
257
|
+
// reactive state.
|
|
258
|
+
expect(fx.mutateMock).toHaveBeenCalledTimes(1);
|
|
259
|
+
const mutateCall = fx.mutateMock.mock.calls[0]![0] as { id: string };
|
|
260
|
+
expect(mutateCall.id).toBe("sys-1");
|
|
261
|
+
expect(fx.mutateResults[0]).toEqual({
|
|
262
|
+
name: "API",
|
|
263
|
+
description: null,
|
|
264
|
+
metadata: {
|
|
265
|
+
tier: "platinum",
|
|
266
|
+
region: "eu-central-1",
|
|
267
|
+
owner: "platform",
|
|
268
|
+
},
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
// Fix 3 (security): the action resolves config (including `metadata` values)
|
|
273
|
+
// against the run scope, which can contain run-resolved secrets. It MUST pass
|
|
274
|
+
// `opts: { runId }` into the entity write so the framework handle masks any
|
|
275
|
+
// such secret in the `entity_transitions` rows + the cluster-wide
|
|
276
|
+
// `ENTITY_CHANGED` (the masking itself is proven end-to-end in
|
|
277
|
+
// automation-backend's mutate-handle masking test).
|
|
278
|
+
it("forwards the dispatch runId to handle.mutate (secret-masking choke point)", async () => {
|
|
279
|
+
const fx = makeFixture({
|
|
280
|
+
initialRow: { id: "sys-1", name: "API", metadata: {} },
|
|
281
|
+
});
|
|
282
|
+
const [action] = createCatalogActions({
|
|
283
|
+
entityService: fx.service,
|
|
284
|
+
cache: fx.cache,
|
|
285
|
+
getSystemEntity: fx.getSystemEntity,
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
await action!.execute({
|
|
289
|
+
...ctxBase,
|
|
290
|
+
consumedArtifacts: {},
|
|
291
|
+
config: {
|
|
292
|
+
systemId: "sys-1",
|
|
293
|
+
strategy: "merge",
|
|
294
|
+
metadata: { token: "resolved-secret-value" },
|
|
295
|
+
} as never,
|
|
238
296
|
});
|
|
297
|
+
|
|
298
|
+
expect(fx.mutateMock).toHaveBeenCalledTimes(1);
|
|
299
|
+
const mutateCall = fx.mutateMock.mock.calls[0]![0] as {
|
|
300
|
+
opts?: { runId?: string };
|
|
301
|
+
};
|
|
302
|
+
expect(mutateCall.opts?.runId).toBe("run-1");
|
|
239
303
|
});
|
|
240
304
|
|
|
241
305
|
it("replaces the whole metadata object when strategy=replace", async () => {
|
|
@@ -249,7 +313,7 @@ describe("catalog.system.update_metadata", () => {
|
|
|
249
313
|
const [action] = createCatalogActions({
|
|
250
314
|
entityService: fx.service,
|
|
251
315
|
cache: fx.cache,
|
|
252
|
-
|
|
316
|
+
getSystemEntity: fx.getSystemEntity,
|
|
253
317
|
});
|
|
254
318
|
|
|
255
319
|
const result = await action!.execute({
|
|
@@ -285,7 +349,7 @@ describe("catalog.system.update_metadata", () => {
|
|
|
285
349
|
const [action] = createCatalogActions({
|
|
286
350
|
entityService: fx.service,
|
|
287
351
|
cache: fx.cache,
|
|
288
|
-
|
|
352
|
+
getSystemEntity: fx.getSystemEntity,
|
|
289
353
|
});
|
|
290
354
|
|
|
291
355
|
const result = await action!.execute({
|
|
@@ -311,7 +375,7 @@ describe("catalog.system.update_metadata", () => {
|
|
|
311
375
|
const [action] = createCatalogActions({
|
|
312
376
|
entityService: fx.service,
|
|
313
377
|
cache: fx.cache,
|
|
314
|
-
|
|
378
|
+
getSystemEntity: fx.getSystemEntity,
|
|
315
379
|
});
|
|
316
380
|
|
|
317
381
|
const result = await action!.execute({
|
|
@@ -329,7 +393,8 @@ describe("catalog.system.update_metadata", () => {
|
|
|
329
393
|
expect(result.error).toMatch(/System not found/);
|
|
330
394
|
expect(fx.updateSystemMock).not.toHaveBeenCalled();
|
|
331
395
|
expect(fx.invalidateTopologyMock).not.toHaveBeenCalled();
|
|
332
|
-
|
|
396
|
+
// The probe failed, so no entity write was driven.
|
|
397
|
+
expect(fx.mutateMock).not.toHaveBeenCalled();
|
|
333
398
|
});
|
|
334
399
|
|
|
335
400
|
it("returns failure if updateSystem returns undefined (race-deleted mid-update)", async () => {
|
|
@@ -347,7 +412,7 @@ describe("catalog.system.update_metadata", () => {
|
|
|
347
412
|
const [action] = createCatalogActions({
|
|
348
413
|
entityService: fx.service,
|
|
349
414
|
cache: fx.cache,
|
|
350
|
-
|
|
415
|
+
getSystemEntity: fx.getSystemEntity,
|
|
351
416
|
});
|
|
352
417
|
|
|
353
418
|
const result = await action!.execute({
|
|
@@ -364,6 +429,14 @@ describe("catalog.system.update_metadata", () => {
|
|
|
364
429
|
if (result.success) return;
|
|
365
430
|
expect(result.error).toMatch(/disappeared mid-update/);
|
|
366
431
|
expect(fx.invalidateTopologyMock).not.toHaveBeenCalled();
|
|
367
|
-
|
|
432
|
+
// The probe found the row, so the write was driven — but `apply` fell
|
|
433
|
+
// back to the pre-write state, so the framework handle would diff it as a
|
|
434
|
+
// no-op (no spurious `catalog.updated`). The action still reports failure.
|
|
435
|
+
expect(fx.mutateMock).toHaveBeenCalledTimes(1);
|
|
436
|
+
expect(fx.mutateResults[0]).toEqual({
|
|
437
|
+
name: "API",
|
|
438
|
+
description: null,
|
|
439
|
+
metadata: {},
|
|
440
|
+
});
|
|
368
441
|
});
|
|
369
442
|
});
|
package/src/automations.ts
CHANGED
|
@@ -20,15 +20,22 @@
|
|
|
20
20
|
* hook so downstream automations + cache subscribers see the change.
|
|
21
21
|
*/
|
|
22
22
|
import { z } from "zod";
|
|
23
|
-
import { Versioned
|
|
23
|
+
import { Versioned } from "@checkstack/backend-api";
|
|
24
24
|
import type {
|
|
25
25
|
ActionDefinition,
|
|
26
26
|
TriggerDefinition,
|
|
27
27
|
} from "@checkstack/automation-backend";
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
import {
|
|
29
|
+
makeEntityDrivenTriggerSetup,
|
|
30
|
+
type EntityHandle,
|
|
31
|
+
} from "@checkstack/automation-backend";
|
|
30
32
|
import type { EntityService } from "./services/entity-service";
|
|
31
33
|
import type { createCatalogCache } from "./cache";
|
|
34
|
+
import {
|
|
35
|
+
toCatalogSystemState,
|
|
36
|
+
writeCatalogSystemEntity,
|
|
37
|
+
type CatalogSystemState,
|
|
38
|
+
} from "./catalog-entity";
|
|
32
39
|
|
|
33
40
|
// ─── Payload schemas — match the hook payloads exactly ─────────────────
|
|
34
41
|
|
|
@@ -50,6 +57,10 @@ const systemDeletedPayloadSchema = z.object({
|
|
|
50
57
|
|
|
51
58
|
// ─── Triggers ──────────────────────────────────────────────────────────
|
|
52
59
|
|
|
60
|
+
// These triggers are ENTITY-DRIVEN (§10.4): the `catalog-system` entity's
|
|
61
|
+
// change deriver fires `catalog.created/.updated/.deleted` via Stage-1
|
|
62
|
+
// routing, so they no longer subscribe to a hook. A no-op `setup` keeps
|
|
63
|
+
// them in the editor's trigger catalog without re-introducing a hook.
|
|
53
64
|
export const systemCreatedTrigger: TriggerDefinition<
|
|
54
65
|
z.infer<typeof systemCreatedPayloadSchema>
|
|
55
66
|
> = {
|
|
@@ -59,7 +70,9 @@ export const systemCreatedTrigger: TriggerDefinition<
|
|
|
59
70
|
category: "Catalog",
|
|
60
71
|
icon: "Activity",
|
|
61
72
|
payloadSchema: systemCreatedPayloadSchema,
|
|
62
|
-
|
|
73
|
+
setup: makeEntityDrivenTriggerSetup<
|
|
74
|
+
z.infer<typeof systemCreatedPayloadSchema>
|
|
75
|
+
>(),
|
|
63
76
|
contextKey: (p) => p.systemId,
|
|
64
77
|
};
|
|
65
78
|
|
|
@@ -72,7 +85,9 @@ export const systemUpdatedTrigger: TriggerDefinition<
|
|
|
72
85
|
category: "Catalog",
|
|
73
86
|
icon: "Activity",
|
|
74
87
|
payloadSchema: systemUpdatedPayloadSchema,
|
|
75
|
-
|
|
88
|
+
setup: makeEntityDrivenTriggerSetup<
|
|
89
|
+
z.infer<typeof systemUpdatedPayloadSchema>
|
|
90
|
+
>(),
|
|
76
91
|
contextKey: (p) => p.systemId,
|
|
77
92
|
};
|
|
78
93
|
|
|
@@ -85,7 +100,9 @@ export const systemDeletedTrigger: TriggerDefinition<
|
|
|
85
100
|
category: "Catalog",
|
|
86
101
|
icon: "Activity",
|
|
87
102
|
payloadSchema: systemDeletedPayloadSchema,
|
|
88
|
-
|
|
103
|
+
setup: makeEntityDrivenTriggerSetup<
|
|
104
|
+
z.infer<typeof systemDeletedPayloadSchema>
|
|
105
|
+
>(),
|
|
89
106
|
contextKey: (p) => p.systemId,
|
|
90
107
|
};
|
|
91
108
|
|
|
@@ -134,11 +151,12 @@ export interface CatalogActionDeps {
|
|
|
134
151
|
entityService: EntityService;
|
|
135
152
|
cache: ReturnType<typeof createCatalogCache>;
|
|
136
153
|
/**
|
|
137
|
-
*
|
|
138
|
-
* action
|
|
139
|
-
*
|
|
154
|
+
* Resolver for the reactive `catalog-system` entity (§10.4). The
|
|
155
|
+
* `update_metadata` action mirrors its edit here; the entity's change
|
|
156
|
+
* deriver fires the `catalog.updated` trigger event downstream (the old
|
|
157
|
+
* `systemUpdated` hook emission was removed).
|
|
140
158
|
*/
|
|
141
|
-
|
|
159
|
+
getSystemEntity?: () => EntityHandle<CatalogSystemState> | undefined;
|
|
142
160
|
}
|
|
143
161
|
|
|
144
162
|
export function createCatalogActions(
|
|
@@ -159,7 +177,7 @@ export function createCatalogActions(
|
|
|
159
177
|
schema: systemUpdateMetadataConfigSchema,
|
|
160
178
|
}),
|
|
161
179
|
produces: "catalog.system_record",
|
|
162
|
-
execute: async ({ config, logger }) => {
|
|
180
|
+
execute: async ({ config, logger, runId }) => {
|
|
163
181
|
const existing = await deps.entityService.getSystem(config.systemId);
|
|
164
182
|
if (!existing) {
|
|
165
183
|
return {
|
|
@@ -175,9 +193,39 @@ export function createCatalogActions(
|
|
|
175
193
|
? config.metadata
|
|
176
194
|
: { ...existingMetadata, ...config.metadata };
|
|
177
195
|
|
|
178
|
-
|
|
179
|
-
|
|
196
|
+
// Drive the update through the reactive `catalog-system` entity (§10.4);
|
|
197
|
+
// the REAL `updateSystem` runs INSIDE `apply`, so `prev` is snapshotted
|
|
198
|
+
// before the write and the deriver fires `catalog.updated`. If the row
|
|
199
|
+
// was race-deleted mid-update, `apply` falls back to the pre-write
|
|
200
|
+
// state so the diff is a no-op (no spurious `catalog.updated`), and the
|
|
201
|
+
// failure is surfaced from the captured `updated`.
|
|
202
|
+
// Capture the post-write row in a holder so TS control-flow tracks the
|
|
203
|
+
// assignment made inside the async `apply` closure (a plain `let`
|
|
204
|
+
// mutated in a closure is invisible to CFA and would narrow to `never`).
|
|
205
|
+
const captured: {
|
|
206
|
+
row: Awaited<ReturnType<typeof deps.entityService.updateSystem>> | null;
|
|
207
|
+
} = { row: null };
|
|
208
|
+
await writeCatalogSystemEntity({
|
|
209
|
+
handle: deps.getSystemEntity?.(),
|
|
210
|
+
systemId: config.systemId,
|
|
211
|
+
// Run-secret masking choke point: this action resolves config
|
|
212
|
+
// (including `metadata` values) against the run scope, which can
|
|
213
|
+
// contain run-resolved secrets. Passing `runId` masks any such secret
|
|
214
|
+
// in the `entity_transitions` rows + the cluster-wide `ENTITY_CHANGED`.
|
|
215
|
+
opts: { runId },
|
|
216
|
+
apply: async () => {
|
|
217
|
+
const row = await deps.entityService.updateSystem(config.systemId, {
|
|
218
|
+
metadata: nextMetadata,
|
|
219
|
+
});
|
|
220
|
+
captured.row = row ?? null;
|
|
221
|
+
return toCatalogSystemState({
|
|
222
|
+
name: row?.name ?? existing.name,
|
|
223
|
+
description: row?.description ?? existing.description,
|
|
224
|
+
metadata: row ? nextMetadata : existingMetadata,
|
|
225
|
+
});
|
|
226
|
+
},
|
|
180
227
|
});
|
|
228
|
+
const updated = captured.row;
|
|
181
229
|
if (!updated) {
|
|
182
230
|
return {
|
|
183
231
|
success: false,
|
|
@@ -186,11 +234,6 @@ export function createCatalogActions(
|
|
|
186
234
|
}
|
|
187
235
|
|
|
188
236
|
await deps.cache.invalidateTopology();
|
|
189
|
-
await deps.emitHook(catalogHooks.systemUpdated, {
|
|
190
|
-
systemId: updated.id,
|
|
191
|
-
systemName: updated.name,
|
|
192
|
-
changedFields: ["metadata"],
|
|
193
|
-
});
|
|
194
237
|
|
|
195
238
|
logger.info(
|
|
196
239
|
`Automation updated metadata on system ${updated.id} (${config.strategy})`,
|