@checkstack/catalog-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 CHANGED
@@ -1,5 +1,162 @@
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
+
105
+ ## 1.2.0
106
+
107
+ ### Minor Changes
108
+
109
+ - 41c77f4: feat(catalog): system triggers + update_metadata action for the Automation Platform
110
+
111
+ Ships the catalog chunk of Phase 9:
112
+
113
+ - Triggers: `catalog.created`, `catalog.updated`, `catalog.deleted`
114
+ — named consistently with the other plugin lifecycle triggers
115
+ (incident.created, dependency.created, maintenance.created, …).
116
+ Each carries `contextKey: (p) => p.systemId` so `wait_for_trigger`
117
+ can resume the right run.
118
+ - Action: `catalog.update_metadata` — sets or merges metadata on a
119
+ system (`strategy: "merge" | "replace"`). Default is `merge` so
120
+ untouched keys survive. Returns a `catalog.system_record` artifact
121
+ (`systemId`, `systemName`, `metadata`).
122
+
123
+ New hook: `catalogHooks.systemUpdated` (`{ systemId, systemName,
124
+ changedFields }`). Emitted from both the `updateSystem` RPC handler
125
+ and the `update_metadata` automation action so downstream automations
126
+ and caches see both code paths. Emission is skipped when no tracked
127
+ field changed (no-op saves don't spam subscribers).
128
+
129
+ The `system.health_changed`, `system.set_maintenance`, and
130
+ `system.clear_maintenance` items in the original Phase 9 plan move to
131
+ the **healthcheck** and **maintenance** chunks respectively, where the
132
+ underlying data and RPCs live.
133
+
134
+ ### Patch Changes
135
+
136
+ - Updated dependencies [e2d6f25]
137
+ - Updated dependencies [41c77f4]
138
+ - Updated dependencies [e1a2077]
139
+ - Updated dependencies [41c77f4]
140
+ - Updated dependencies [41c77f4]
141
+ - Updated dependencies [41c77f4]
142
+ - Updated dependencies [41c77f4]
143
+ - Updated dependencies [41c77f4]
144
+ - Updated dependencies [6d52276]
145
+ - Updated dependencies [6d52276]
146
+ - Updated dependencies [35bc682]
147
+ - @checkstack/automation-backend@0.2.0
148
+ - @checkstack/common@0.12.0
149
+ - @checkstack/backend-api@0.18.0
150
+ - @checkstack/catalog-common@2.2.3
151
+ - @checkstack/auth-backend@0.4.31
152
+ - @checkstack/auth-common@0.7.2
153
+ - @checkstack/command-backend@0.1.31
154
+ - @checkstack/gitops-backend@0.3.7
155
+ - @checkstack/gitops-common@0.4.2
156
+ - @checkstack/notification-common@1.2.1
157
+ - @checkstack/cache-api@0.3.6
158
+ - @checkstack/cache-utils@0.2.11
159
+
3
160
  ## 1.1.6
4
161
 
5
162
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/catalog-backend",
3
- "version": "1.1.6",
3
+ "version": "1.3.0",
4
4
  "license": "Elastic-2.0",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -11,29 +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.17.0",
18
- "@checkstack/cache-api": "0.3.4",
19
- "@checkstack/cache-utils": "0.2.9",
20
- "@checkstack/auth-common": "0.7.1",
21
- "@checkstack/catalog-common": "2.2.2",
22
- "@checkstack/command-backend": "0.1.29",
23
- "@checkstack/auth-backend": "0.4.29",
24
- "@checkstack/gitops-backend": "0.3.5",
25
- "@checkstack/gitops-common": "0.4.1",
26
- "@checkstack/notification-common": "1.2.0",
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",
27
29
  "@orpc/server": "^1.13.2",
28
30
  "drizzle-orm": "^0.45.0",
29
31
  "hono": "^4.12.14",
30
32
  "uuid": "^14.0.0",
31
33
  "zod": "^4.2.1",
32
- "@checkstack/common": "0.11.0"
34
+ "@checkstack/common": "0.12.0"
33
35
  },
34
36
  "devDependencies": {
35
37
  "@checkstack/drizzle-helper": "0.0.5",
36
- "@checkstack/scripts": "0.3.3",
38
+ "@checkstack/scripts": "0.3.4",
39
+ "@checkstack/test-utils-backend": "0.1.31",
37
40
  "@checkstack/tsconfig": "0.0.7",
38
41
  "@types/bun": "^1.3.5",
39
42
  "@types/node": "^20.0.0",
@@ -0,0 +1,442 @@
1
+ /**
2
+ * Behaviour tests for the catalog automation triggers + actions.
3
+ *
4
+ * The triggers are dataclasses (id, payloadSchema, hook, contextKey),
5
+ * so we lock down their payload validation + contextKey extraction.
6
+ * The `update_metadata` action carries the real behaviour: shallow
7
+ * merge vs. replace, cache invalidation, hook emission, the
8
+ * "missing system" failure path, and the "race-deleted mid-update"
9
+ * failure path.
10
+ */
11
+ import { describe, expect, it, mock } from "bun:test";
12
+ import type { Logger } from "@checkstack/backend-api";
13
+ import { createMockLogger } from "@checkstack/test-utils-backend";
14
+
15
+ import {
16
+ catalogTriggers,
17
+ createCatalogActions,
18
+ systemCreatedTrigger,
19
+ systemDeletedTrigger,
20
+ systemRecordArtifactType,
21
+ systemUpdatedTrigger,
22
+ } from "./automations";
23
+ import type { EntityService } from "./services/entity-service";
24
+ import type { createCatalogCache } from "./cache";
25
+
26
+ const logger = createMockLogger() as Logger;
27
+
28
+ const ctxBase = {
29
+ runId: "run-1",
30
+ automationId: "auto-1",
31
+ contextKey: null,
32
+ logger,
33
+ getService: async <T,>(): Promise<T> => {
34
+ throw new Error("not used");
35
+ },
36
+ };
37
+
38
+ // ─── Triggers ──────────────────────────────────────────────────────────
39
+
40
+ describe("catalog triggers", () => {
41
+ it("exposes three triggers in a stable order", () => {
42
+ expect(catalogTriggers).toHaveLength(3);
43
+ expect(catalogTriggers[0]).toBe(
44
+ systemCreatedTrigger as (typeof catalogTriggers)[number],
45
+ );
46
+ expect(catalogTriggers[1]).toBe(
47
+ systemUpdatedTrigger as (typeof catalogTriggers)[number],
48
+ );
49
+ expect(catalogTriggers[2]).toBe(
50
+ systemDeletedTrigger as (typeof catalogTriggers)[number],
51
+ );
52
+ });
53
+
54
+ it("validates the systemCreated payload + extracts systemId as contextKey", () => {
55
+ const payload = { systemId: "sys-1", systemName: "API" };
56
+ const parsed = systemCreatedTrigger.payloadSchema.safeParse(payload);
57
+ expect(parsed.success).toBe(true);
58
+ expect(systemCreatedTrigger.contextKey?.(payload)).toBe("sys-1");
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");
63
+ });
64
+
65
+ it("requires changedFields on the systemUpdated payload", () => {
66
+ const ok = systemUpdatedTrigger.payloadSchema.safeParse({
67
+ systemId: "sys-1",
68
+ systemName: "API",
69
+ changedFields: ["metadata"],
70
+ });
71
+ expect(ok.success).toBe(true);
72
+
73
+ const bad = systemUpdatedTrigger.payloadSchema.safeParse({
74
+ systemId: "sys-1",
75
+ systemName: "API",
76
+ });
77
+ expect(bad.success).toBe(false);
78
+
79
+ const badEnum = systemUpdatedTrigger.payloadSchema.safeParse({
80
+ systemId: "sys-1",
81
+ systemName: "API",
82
+ changedFields: ["unknown_field"],
83
+ });
84
+ expect(badEnum.success).toBe(false);
85
+ });
86
+
87
+ it("accepts an optional systemName on the systemDeleted payload", () => {
88
+ const withName = systemDeletedTrigger.payloadSchema.safeParse({
89
+ systemId: "sys-1",
90
+ systemName: "API",
91
+ });
92
+ const withoutName = systemDeletedTrigger.payloadSchema.safeParse({
93
+ systemId: "sys-1",
94
+ });
95
+ expect(withName.success).toBe(true);
96
+ expect(withoutName.success).toBe(true);
97
+ });
98
+ });
99
+
100
+ // ─── Artifact type ─────────────────────────────────────────────────────
101
+
102
+ describe("systemRecordArtifactType", () => {
103
+ it("validates the canonical artifact shape", () => {
104
+ const ok = systemRecordArtifactType.schema.safeParse({
105
+ systemId: "sys-1",
106
+ systemName: "API",
107
+ metadata: { tier: "gold" },
108
+ });
109
+ expect(ok.success).toBe(true);
110
+ });
111
+
112
+ it("rejects when metadata is missing", () => {
113
+ const bad = systemRecordArtifactType.schema.safeParse({
114
+ systemId: "sys-1",
115
+ systemName: "API",
116
+ });
117
+ expect(bad.success).toBe(false);
118
+ });
119
+ });
120
+
121
+ // ─── Action: system.update_metadata ────────────────────────────────────
122
+
123
+ interface FakeSystemRow {
124
+ id: string;
125
+ name: string;
126
+ description?: string | null;
127
+ metadata: Record<string, unknown> | null;
128
+ }
129
+
130
+ interface ActionFixture {
131
+ service: EntityService;
132
+ cache: ReturnType<typeof createCatalogCache>;
133
+ mutateMock: ReturnType<typeof mock>;
134
+ /** Reactive states returned by each `apply()` the action drove. */
135
+ mutateResults: unknown[];
136
+ getSystemEntity: () => never;
137
+ updateSystemMock: ReturnType<typeof mock>;
138
+ getSystemMock: ReturnType<typeof mock>;
139
+ invalidateTopologyMock: ReturnType<typeof mock>;
140
+ }
141
+
142
+ function makeFixture(args: {
143
+ initialRow: FakeSystemRow | undefined;
144
+ /**
145
+ * If set, updateSystem returns this value instead of the merged
146
+ * row. Use `{ value: undefined }` to simulate a race-deleted row.
147
+ * Omit to get the default "merge + return updated row" behaviour.
148
+ */
149
+ updateResult?: { value: FakeSystemRow | undefined };
150
+ }): ActionFixture {
151
+ let row = args.initialRow ? { ...args.initialRow } : undefined;
152
+ const getSystemMock = mock(async (_id: string) => row);
153
+ const updateSystemMock = mock(
154
+ async (id: string, data: { metadata?: Record<string, unknown> }) => {
155
+ if (args.updateResult) return args.updateResult.value;
156
+ if (row) {
157
+ row = { ...row, metadata: data.metadata ?? row.metadata };
158
+ return row;
159
+ }
160
+ return undefined;
161
+ },
162
+ );
163
+ const service = {
164
+ getSystem: getSystemMock,
165
+ updateSystem: updateSystemMock,
166
+ } as unknown as EntityService;
167
+
168
+ const invalidateTopologyMock = mock(async () => {});
169
+ const cache = {
170
+ invalidateTopology: invalidateTopologyMock,
171
+ // Other cache methods aren't exercised by the action; cast to the
172
+ // full shape so the factory's deps typecheck.
173
+ invalidateContacts: mock(async () => {}),
174
+ } as unknown as ReturnType<typeof createCatalogCache>;
175
+
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;
193
+
194
+ return {
195
+ service,
196
+ cache,
197
+ mutateMock,
198
+ mutateResults,
199
+ getSystemEntity,
200
+ updateSystemMock,
201
+ getSystemMock,
202
+ invalidateTopologyMock,
203
+ };
204
+ }
205
+
206
+ describe("catalog.system.update_metadata", () => {
207
+ it("shallow-merges by default and preserves untouched keys", async () => {
208
+ const fx = makeFixture({
209
+ initialRow: {
210
+ id: "sys-1",
211
+ name: "API",
212
+ metadata: { tier: "gold", region: "eu-central-1" },
213
+ },
214
+ });
215
+ const [action] = createCatalogActions({
216
+ entityService: fx.service,
217
+ cache: fx.cache,
218
+ getSystemEntity: fx.getSystemEntity,
219
+ });
220
+
221
+ const result = await action!.execute({
222
+ ...ctxBase,
223
+ consumedArtifacts: {},
224
+ config: {
225
+ systemId: "sys-1",
226
+ strategy: "merge",
227
+ metadata: { tier: "platinum", owner: "platform" },
228
+ } as never,
229
+ });
230
+
231
+ expect(result.success).toBe(true);
232
+ if (!result.success) return;
233
+ const artifact = result.artifact as {
234
+ metadata: Record<string, unknown>;
235
+ };
236
+ expect(artifact.metadata).toEqual({
237
+ tier: "platinum",
238
+ region: "eu-central-1",
239
+ owner: "platform",
240
+ });
241
+
242
+ // Service was called with the merged metadata, not the partial config.
243
+ expect(fx.updateSystemMock).toHaveBeenCalledTimes(1);
244
+ const updateCall = fx.updateSystemMock.mock.calls[0]!;
245
+ expect(updateCall[0]).toBe("sys-1");
246
+ expect((updateCall[1] as { metadata: Record<string, unknown> }).metadata)
247
+ .toEqual({
248
+ tier: "platinum",
249
+ region: "eu-central-1",
250
+ owner: "platform",
251
+ });
252
+
253
+ expect(fx.invalidateTopologyMock).toHaveBeenCalledTimes(1);
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,
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");
303
+ });
304
+
305
+ it("replaces the whole metadata object when strategy=replace", async () => {
306
+ const fx = makeFixture({
307
+ initialRow: {
308
+ id: "sys-1",
309
+ name: "API",
310
+ metadata: { tier: "gold", region: "eu-central-1" },
311
+ },
312
+ });
313
+ const [action] = createCatalogActions({
314
+ entityService: fx.service,
315
+ cache: fx.cache,
316
+ getSystemEntity: fx.getSystemEntity,
317
+ });
318
+
319
+ const result = await action!.execute({
320
+ ...ctxBase,
321
+ consumedArtifacts: {},
322
+ config: {
323
+ systemId: "sys-1",
324
+ strategy: "replace",
325
+ metadata: { owner: "platform" },
326
+ } as never,
327
+ });
328
+
329
+ expect(result.success).toBe(true);
330
+ if (!result.success) return;
331
+ const artifact = result.artifact as {
332
+ metadata: Record<string, unknown>;
333
+ };
334
+ expect(artifact.metadata).toEqual({ owner: "platform" });
335
+
336
+ const updateCall = fx.updateSystemMock.mock.calls[0]!;
337
+ expect((updateCall[1] as { metadata: Record<string, unknown> }).metadata)
338
+ .toEqual({ owner: "platform" });
339
+ });
340
+
341
+ it("treats a null existing metadata as an empty object when merging", async () => {
342
+ const fx = makeFixture({
343
+ initialRow: {
344
+ id: "sys-1",
345
+ name: "API",
346
+ metadata: null,
347
+ },
348
+ });
349
+ const [action] = createCatalogActions({
350
+ entityService: fx.service,
351
+ cache: fx.cache,
352
+ getSystemEntity: fx.getSystemEntity,
353
+ });
354
+
355
+ const result = await action!.execute({
356
+ ...ctxBase,
357
+ consumedArtifacts: {},
358
+ config: {
359
+ systemId: "sys-1",
360
+ strategy: "merge",
361
+ metadata: { tier: "gold" },
362
+ } as never,
363
+ });
364
+
365
+ expect(result.success).toBe(true);
366
+ if (!result.success) return;
367
+ const artifact = result.artifact as {
368
+ metadata: Record<string, unknown>;
369
+ };
370
+ expect(artifact.metadata).toEqual({ tier: "gold" });
371
+ });
372
+
373
+ it("returns failure when the target system does not exist", async () => {
374
+ const fx = makeFixture({ initialRow: undefined });
375
+ const [action] = createCatalogActions({
376
+ entityService: fx.service,
377
+ cache: fx.cache,
378
+ getSystemEntity: fx.getSystemEntity,
379
+ });
380
+
381
+ const result = await action!.execute({
382
+ ...ctxBase,
383
+ consumedArtifacts: {},
384
+ config: {
385
+ systemId: "missing",
386
+ strategy: "merge",
387
+ metadata: { tier: "gold" },
388
+ } as never,
389
+ });
390
+
391
+ expect(result.success).toBe(false);
392
+ if (result.success) return;
393
+ expect(result.error).toMatch(/System not found/);
394
+ expect(fx.updateSystemMock).not.toHaveBeenCalled();
395
+ expect(fx.invalidateTopologyMock).not.toHaveBeenCalled();
396
+ // The probe failed, so no entity write was driven.
397
+ expect(fx.mutateMock).not.toHaveBeenCalled();
398
+ });
399
+
400
+ it("returns failure if updateSystem returns undefined (race-deleted mid-update)", async () => {
401
+ const fx = makeFixture({
402
+ initialRow: {
403
+ id: "sys-1",
404
+ name: "API",
405
+ metadata: {},
406
+ },
407
+ // Simulate a race: getSystem found the row, but updateSystem
408
+ // returned `undefined` (the row was deleted between the two
409
+ // queries).
410
+ updateResult: { value: undefined },
411
+ });
412
+ const [action] = createCatalogActions({
413
+ entityService: fx.service,
414
+ cache: fx.cache,
415
+ getSystemEntity: fx.getSystemEntity,
416
+ });
417
+
418
+ const result = await action!.execute({
419
+ ...ctxBase,
420
+ consumedArtifacts: {},
421
+ config: {
422
+ systemId: "sys-1",
423
+ strategy: "merge",
424
+ metadata: { tier: "gold" },
425
+ } as never,
426
+ });
427
+
428
+ expect(result.success).toBe(false);
429
+ if (result.success) return;
430
+ expect(result.error).toMatch(/disappeared mid-update/);
431
+ expect(fx.invalidateTopologyMock).not.toHaveBeenCalled();
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
+ });
441
+ });
442
+ });