@checkstack/catalog-backend 1.1.6 → 1.2.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,60 @@
1
1
  # @checkstack/catalog-backend
2
2
 
3
+ ## 1.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 41c77f4: feat(catalog): system triggers + update_metadata action for the Automation Platform
8
+
9
+ Ships the catalog chunk of Phase 9:
10
+
11
+ - Triggers: `catalog.created`, `catalog.updated`, `catalog.deleted`
12
+ — named consistently with the other plugin lifecycle triggers
13
+ (incident.created, dependency.created, maintenance.created, …).
14
+ Each carries `contextKey: (p) => p.systemId` so `wait_for_trigger`
15
+ can resume the right run.
16
+ - Action: `catalog.update_metadata` — sets or merges metadata on a
17
+ system (`strategy: "merge" | "replace"`). Default is `merge` so
18
+ untouched keys survive. Returns a `catalog.system_record` artifact
19
+ (`systemId`, `systemName`, `metadata`).
20
+
21
+ New hook: `catalogHooks.systemUpdated` (`{ systemId, systemName,
22
+ changedFields }`). Emitted from both the `updateSystem` RPC handler
23
+ and the `update_metadata` automation action so downstream automations
24
+ and caches see both code paths. Emission is skipped when no tracked
25
+ field changed (no-op saves don't spam subscribers).
26
+
27
+ The `system.health_changed`, `system.set_maintenance`, and
28
+ `system.clear_maintenance` items in the original Phase 9 plan move to
29
+ the **healthcheck** and **maintenance** chunks respectively, where the
30
+ underlying data and RPCs live.
31
+
32
+ ### Patch Changes
33
+
34
+ - Updated dependencies [e2d6f25]
35
+ - Updated dependencies [41c77f4]
36
+ - Updated dependencies [e1a2077]
37
+ - Updated dependencies [41c77f4]
38
+ - Updated dependencies [41c77f4]
39
+ - Updated dependencies [41c77f4]
40
+ - Updated dependencies [41c77f4]
41
+ - Updated dependencies [41c77f4]
42
+ - Updated dependencies [6d52276]
43
+ - Updated dependencies [6d52276]
44
+ - Updated dependencies [35bc682]
45
+ - @checkstack/automation-backend@0.2.0
46
+ - @checkstack/common@0.12.0
47
+ - @checkstack/backend-api@0.18.0
48
+ - @checkstack/catalog-common@2.2.3
49
+ - @checkstack/auth-backend@0.4.31
50
+ - @checkstack/auth-common@0.7.2
51
+ - @checkstack/command-backend@0.1.31
52
+ - @checkstack/gitops-backend@0.3.7
53
+ - @checkstack/gitops-common@0.4.2
54
+ - @checkstack/notification-common@1.2.1
55
+ - @checkstack/cache-api@0.3.6
56
+ - @checkstack/cache-utils@0.2.11
57
+
3
58
  ## 1.1.6
4
59
 
5
60
  ### 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.2.0",
4
4
  "license": "Elastic-2.0",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -11,17 +11,19 @@
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",
18
+ "@checkstack/backend-api": "0.17.1",
19
+ "@checkstack/automation-backend": "0.1.0",
20
+ "@checkstack/cache-api": "0.3.5",
21
+ "@checkstack/cache-utils": "0.2.10",
20
22
  "@checkstack/auth-common": "0.7.1",
21
23
  "@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",
24
+ "@checkstack/command-backend": "0.1.30",
25
+ "@checkstack/auth-backend": "0.4.30",
26
+ "@checkstack/gitops-backend": "0.3.6",
25
27
  "@checkstack/gitops-common": "0.4.1",
26
28
  "@checkstack/notification-common": "1.2.0",
27
29
  "@orpc/server": "^1.13.2",
@@ -34,6 +36,7 @@
34
36
  "devDependencies": {
35
37
  "@checkstack/drizzle-helper": "0.0.5",
36
38
  "@checkstack/scripts": "0.3.3",
39
+ "@checkstack/test-utils-backend": "0.1.30",
37
40
  "@checkstack/tsconfig": "0.0.7",
38
41
  "@types/bun": "^1.3.5",
39
42
  "@types/node": "^20.0.0",
@@ -0,0 +1,369 @@
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
+ import { catalogHooks } from "./hooks";
26
+
27
+ const logger = createMockLogger() as Logger;
28
+
29
+ const ctxBase = {
30
+ runId: "run-1",
31
+ automationId: "auto-1",
32
+ contextKey: null,
33
+ logger,
34
+ getService: async <T,>(): Promise<T> => {
35
+ throw new Error("not used");
36
+ },
37
+ };
38
+
39
+ // ─── Triggers ──────────────────────────────────────────────────────────
40
+
41
+ describe("catalog triggers", () => {
42
+ it("exposes three triggers in a stable order", () => {
43
+ expect(catalogTriggers).toHaveLength(3);
44
+ expect(catalogTriggers[0]).toBe(
45
+ systemCreatedTrigger as (typeof catalogTriggers)[number],
46
+ );
47
+ expect(catalogTriggers[1]).toBe(
48
+ systemUpdatedTrigger as (typeof catalogTriggers)[number],
49
+ );
50
+ expect(catalogTriggers[2]).toBe(
51
+ systemDeletedTrigger as (typeof catalogTriggers)[number],
52
+ );
53
+ });
54
+
55
+ it("validates the systemCreated payload + extracts systemId as contextKey", () => {
56
+ const payload = { systemId: "sys-1", systemName: "API" };
57
+ const parsed = systemCreatedTrigger.payloadSchema.safeParse(payload);
58
+ expect(parsed.success).toBe(true);
59
+ expect(systemCreatedTrigger.contextKey?.(payload)).toBe("sys-1");
60
+ expect(systemCreatedTrigger.hook).toBe(catalogHooks.systemCreated);
61
+ });
62
+
63
+ it("requires changedFields on the systemUpdated payload", () => {
64
+ const ok = systemUpdatedTrigger.payloadSchema.safeParse({
65
+ systemId: "sys-1",
66
+ systemName: "API",
67
+ changedFields: ["metadata"],
68
+ });
69
+ expect(ok.success).toBe(true);
70
+
71
+ const bad = systemUpdatedTrigger.payloadSchema.safeParse({
72
+ systemId: "sys-1",
73
+ systemName: "API",
74
+ });
75
+ expect(bad.success).toBe(false);
76
+
77
+ const badEnum = systemUpdatedTrigger.payloadSchema.safeParse({
78
+ systemId: "sys-1",
79
+ systemName: "API",
80
+ changedFields: ["unknown_field"],
81
+ });
82
+ expect(badEnum.success).toBe(false);
83
+ });
84
+
85
+ it("accepts an optional systemName on the systemDeleted payload", () => {
86
+ const withName = systemDeletedTrigger.payloadSchema.safeParse({
87
+ systemId: "sys-1",
88
+ systemName: "API",
89
+ });
90
+ const withoutName = systemDeletedTrigger.payloadSchema.safeParse({
91
+ systemId: "sys-1",
92
+ });
93
+ expect(withName.success).toBe(true);
94
+ expect(withoutName.success).toBe(true);
95
+ });
96
+ });
97
+
98
+ // ─── Artifact type ─────────────────────────────────────────────────────
99
+
100
+ describe("systemRecordArtifactType", () => {
101
+ it("validates the canonical artifact shape", () => {
102
+ const ok = systemRecordArtifactType.schema.safeParse({
103
+ systemId: "sys-1",
104
+ systemName: "API",
105
+ metadata: { tier: "gold" },
106
+ });
107
+ expect(ok.success).toBe(true);
108
+ });
109
+
110
+ it("rejects when metadata is missing", () => {
111
+ const bad = systemRecordArtifactType.schema.safeParse({
112
+ systemId: "sys-1",
113
+ systemName: "API",
114
+ });
115
+ expect(bad.success).toBe(false);
116
+ });
117
+ });
118
+
119
+ // ─── Action: system.update_metadata ────────────────────────────────────
120
+
121
+ interface FakeSystemRow {
122
+ id: string;
123
+ name: string;
124
+ description?: string | null;
125
+ metadata: Record<string, unknown> | null;
126
+ }
127
+
128
+ interface ActionFixture {
129
+ service: EntityService;
130
+ cache: ReturnType<typeof createCatalogCache>;
131
+ emitHookMock: ReturnType<typeof mock>;
132
+ updateSystemMock: ReturnType<typeof mock>;
133
+ getSystemMock: ReturnType<typeof mock>;
134
+ invalidateTopologyMock: ReturnType<typeof mock>;
135
+ }
136
+
137
+ function makeFixture(args: {
138
+ initialRow: FakeSystemRow | undefined;
139
+ /**
140
+ * If set, updateSystem returns this value instead of the merged
141
+ * row. Use `{ value: undefined }` to simulate a race-deleted row.
142
+ * Omit to get the default "merge + return updated row" behaviour.
143
+ */
144
+ updateResult?: { value: FakeSystemRow | undefined };
145
+ }): ActionFixture {
146
+ let row = args.initialRow ? { ...args.initialRow } : undefined;
147
+ const getSystemMock = mock(async (_id: string) => row);
148
+ const updateSystemMock = mock(
149
+ async (id: string, data: { metadata?: Record<string, unknown> }) => {
150
+ if (args.updateResult) return args.updateResult.value;
151
+ if (row) {
152
+ row = { ...row, metadata: data.metadata ?? row.metadata };
153
+ return row;
154
+ }
155
+ return undefined;
156
+ },
157
+ );
158
+ const service = {
159
+ getSystem: getSystemMock,
160
+ updateSystem: updateSystemMock,
161
+ } as unknown as EntityService;
162
+
163
+ const invalidateTopologyMock = mock(async () => {});
164
+ const cache = {
165
+ invalidateTopology: invalidateTopologyMock,
166
+ // Other cache methods aren't exercised by the action; cast to the
167
+ // full shape so the factory's deps typecheck.
168
+ invalidateContacts: mock(async () => {}),
169
+ } as unknown as ReturnType<typeof createCatalogCache>;
170
+
171
+ const emitHookMock = mock(async () => {});
172
+
173
+ return {
174
+ service,
175
+ cache,
176
+ emitHookMock,
177
+ updateSystemMock,
178
+ getSystemMock,
179
+ invalidateTopologyMock,
180
+ };
181
+ }
182
+
183
+ describe("catalog.system.update_metadata", () => {
184
+ it("shallow-merges by default and preserves untouched keys", async () => {
185
+ const fx = makeFixture({
186
+ initialRow: {
187
+ id: "sys-1",
188
+ name: "API",
189
+ metadata: { tier: "gold", region: "eu-central-1" },
190
+ },
191
+ });
192
+ const [action] = createCatalogActions({
193
+ entityService: fx.service,
194
+ cache: fx.cache,
195
+ emitHook: fx.emitHookMock as never,
196
+ });
197
+
198
+ const result = await action!.execute({
199
+ ...ctxBase,
200
+ consumedArtifacts: {},
201
+ config: {
202
+ systemId: "sys-1",
203
+ strategy: "merge",
204
+ metadata: { tier: "platinum", owner: "platform" },
205
+ } as never,
206
+ });
207
+
208
+ expect(result.success).toBe(true);
209
+ if (!result.success) return;
210
+ const artifact = result.artifact as {
211
+ metadata: Record<string, unknown>;
212
+ };
213
+ expect(artifact.metadata).toEqual({
214
+ tier: "platinum",
215
+ region: "eu-central-1",
216
+ owner: "platform",
217
+ });
218
+
219
+ // Service was called with the merged metadata, not the partial config.
220
+ expect(fx.updateSystemMock).toHaveBeenCalledTimes(1);
221
+ const updateCall = fx.updateSystemMock.mock.calls[0]!;
222
+ expect(updateCall[0]).toBe("sys-1");
223
+ expect((updateCall[1] as { metadata: Record<string, unknown> }).metadata)
224
+ .toEqual({
225
+ tier: "platinum",
226
+ region: "eu-central-1",
227
+ owner: "platform",
228
+ });
229
+
230
+ expect(fx.invalidateTopologyMock).toHaveBeenCalledTimes(1);
231
+ expect(fx.emitHookMock).toHaveBeenCalledTimes(1);
232
+ const emitCall = fx.emitHookMock.mock.calls[0]!;
233
+ expect(emitCall[0]).toBe(catalogHooks.systemUpdated);
234
+ expect(emitCall[1]).toEqual({
235
+ systemId: "sys-1",
236
+ systemName: "API",
237
+ changedFields: ["metadata"],
238
+ });
239
+ });
240
+
241
+ it("replaces the whole metadata object when strategy=replace", async () => {
242
+ const fx = makeFixture({
243
+ initialRow: {
244
+ id: "sys-1",
245
+ name: "API",
246
+ metadata: { tier: "gold", region: "eu-central-1" },
247
+ },
248
+ });
249
+ const [action] = createCatalogActions({
250
+ entityService: fx.service,
251
+ cache: fx.cache,
252
+ emitHook: fx.emitHookMock as never,
253
+ });
254
+
255
+ const result = await action!.execute({
256
+ ...ctxBase,
257
+ consumedArtifacts: {},
258
+ config: {
259
+ systemId: "sys-1",
260
+ strategy: "replace",
261
+ metadata: { owner: "platform" },
262
+ } as never,
263
+ });
264
+
265
+ expect(result.success).toBe(true);
266
+ if (!result.success) return;
267
+ const artifact = result.artifact as {
268
+ metadata: Record<string, unknown>;
269
+ };
270
+ expect(artifact.metadata).toEqual({ owner: "platform" });
271
+
272
+ const updateCall = fx.updateSystemMock.mock.calls[0]!;
273
+ expect((updateCall[1] as { metadata: Record<string, unknown> }).metadata)
274
+ .toEqual({ owner: "platform" });
275
+ });
276
+
277
+ it("treats a null existing metadata as an empty object when merging", async () => {
278
+ const fx = makeFixture({
279
+ initialRow: {
280
+ id: "sys-1",
281
+ name: "API",
282
+ metadata: null,
283
+ },
284
+ });
285
+ const [action] = createCatalogActions({
286
+ entityService: fx.service,
287
+ cache: fx.cache,
288
+ emitHook: fx.emitHookMock as never,
289
+ });
290
+
291
+ const result = await action!.execute({
292
+ ...ctxBase,
293
+ consumedArtifacts: {},
294
+ config: {
295
+ systemId: "sys-1",
296
+ strategy: "merge",
297
+ metadata: { tier: "gold" },
298
+ } as never,
299
+ });
300
+
301
+ expect(result.success).toBe(true);
302
+ if (!result.success) return;
303
+ const artifact = result.artifact as {
304
+ metadata: Record<string, unknown>;
305
+ };
306
+ expect(artifact.metadata).toEqual({ tier: "gold" });
307
+ });
308
+
309
+ it("returns failure when the target system does not exist", async () => {
310
+ const fx = makeFixture({ initialRow: undefined });
311
+ const [action] = createCatalogActions({
312
+ entityService: fx.service,
313
+ cache: fx.cache,
314
+ emitHook: fx.emitHookMock as never,
315
+ });
316
+
317
+ const result = await action!.execute({
318
+ ...ctxBase,
319
+ consumedArtifacts: {},
320
+ config: {
321
+ systemId: "missing",
322
+ strategy: "merge",
323
+ metadata: { tier: "gold" },
324
+ } as never,
325
+ });
326
+
327
+ expect(result.success).toBe(false);
328
+ if (result.success) return;
329
+ expect(result.error).toMatch(/System not found/);
330
+ expect(fx.updateSystemMock).not.toHaveBeenCalled();
331
+ expect(fx.invalidateTopologyMock).not.toHaveBeenCalled();
332
+ expect(fx.emitHookMock).not.toHaveBeenCalled();
333
+ });
334
+
335
+ it("returns failure if updateSystem returns undefined (race-deleted mid-update)", async () => {
336
+ const fx = makeFixture({
337
+ initialRow: {
338
+ id: "sys-1",
339
+ name: "API",
340
+ metadata: {},
341
+ },
342
+ // Simulate a race: getSystem found the row, but updateSystem
343
+ // returned `undefined` (the row was deleted between the two
344
+ // queries).
345
+ updateResult: { value: undefined },
346
+ });
347
+ const [action] = createCatalogActions({
348
+ entityService: fx.service,
349
+ cache: fx.cache,
350
+ emitHook: fx.emitHookMock as never,
351
+ });
352
+
353
+ const result = await action!.execute({
354
+ ...ctxBase,
355
+ consumedArtifacts: {},
356
+ config: {
357
+ systemId: "sys-1",
358
+ strategy: "merge",
359
+ metadata: { tier: "gold" },
360
+ } as never,
361
+ });
362
+
363
+ expect(result.success).toBe(false);
364
+ if (result.success) return;
365
+ expect(result.error).toMatch(/disappeared mid-update/);
366
+ expect(fx.invalidateTopologyMock).not.toHaveBeenCalled();
367
+ expect(fx.emitHookMock).not.toHaveBeenCalled();
368
+ });
369
+ });
@@ -0,0 +1,211 @@
1
+ /**
2
+ * Catalog triggers + actions registered with the Automation Platform.
3
+ *
4
+ * Triggers re-expose the existing `catalogHooks` as automation entry
5
+ * points (`system.created`, `system.updated`, `system.deleted`).
6
+ * Actions wrap the existing `EntityService` so operators can mutate
7
+ * systems from automation flows (e.g. "when an incident is resolved,
8
+ * clear the system's `incident_severity` metadata field").
9
+ *
10
+ * `system.set_maintenance` / `system.clear_maintenance` are NOT in
11
+ * this file — those wrap maintenance-backend RPCs and ship as part of
12
+ * the maintenance Phase 9 chunk. `system.health_changed` is owned by
13
+ * the healthcheck chunk where the aggregation data lives.
14
+ *
15
+ * Action handlers call `entityService.updateSystem` directly and then
16
+ * invalidate the catalog topology cache + emit the `systemUpdated`
17
+ * hook themselves. The router handler does the same thing for
18
+ * RPC-driven edits; centralising it in the service would be a
19
+ * separate refactor (covered elsewhere). For now, both paths emit the
20
+ * hook so downstream automations + cache subscribers see the change.
21
+ */
22
+ import { z } from "zod";
23
+ import { Versioned, type Hook } from "@checkstack/backend-api";
24
+ import type {
25
+ ActionDefinition,
26
+ TriggerDefinition,
27
+ } from "@checkstack/automation-backend";
28
+
29
+ import { catalogHooks } from "./hooks";
30
+ import type { EntityService } from "./services/entity-service";
31
+ import type { createCatalogCache } from "./cache";
32
+
33
+ // ─── Payload schemas — match the hook payloads exactly ─────────────────
34
+
35
+ const systemCreatedPayloadSchema = z.object({
36
+ systemId: z.string(),
37
+ systemName: z.string(),
38
+ });
39
+
40
+ const systemUpdatedPayloadSchema = z.object({
41
+ systemId: z.string(),
42
+ systemName: z.string(),
43
+ changedFields: z.array(z.enum(["name", "description", "metadata"])),
44
+ });
45
+
46
+ const systemDeletedPayloadSchema = z.object({
47
+ systemId: z.string(),
48
+ systemName: z.string().optional(),
49
+ });
50
+
51
+ // ─── Triggers ──────────────────────────────────────────────────────────
52
+
53
+ export const systemCreatedTrigger: TriggerDefinition<
54
+ z.infer<typeof systemCreatedPayloadSchema>
55
+ > = {
56
+ id: "created",
57
+ displayName: "System Created",
58
+ description: "Fires when a new system is added to the catalog",
59
+ category: "Catalog",
60
+ icon: "Activity",
61
+ payloadSchema: systemCreatedPayloadSchema,
62
+ hook: catalogHooks.systemCreated,
63
+ contextKey: (p) => p.systemId,
64
+ };
65
+
66
+ export const systemUpdatedTrigger: TriggerDefinition<
67
+ z.infer<typeof systemUpdatedPayloadSchema>
68
+ > = {
69
+ id: "updated",
70
+ displayName: "System Updated",
71
+ description: "Fires when a system's name, description, or metadata changes",
72
+ category: "Catalog",
73
+ icon: "Activity",
74
+ payloadSchema: systemUpdatedPayloadSchema,
75
+ hook: catalogHooks.systemUpdated,
76
+ contextKey: (p) => p.systemId,
77
+ };
78
+
79
+ export const systemDeletedTrigger: TriggerDefinition<
80
+ z.infer<typeof systemDeletedPayloadSchema>
81
+ > = {
82
+ id: "deleted",
83
+ displayName: "System Deleted",
84
+ description: "Fires when a system is removed from the catalog",
85
+ category: "Catalog",
86
+ icon: "Activity",
87
+ payloadSchema: systemDeletedPayloadSchema,
88
+ hook: catalogHooks.systemDeleted,
89
+ contextKey: (p) => p.systemId,
90
+ };
91
+
92
+ export const catalogTriggers: TriggerDefinition<unknown>[] = [
93
+ systemCreatedTrigger as TriggerDefinition<unknown>,
94
+ systemUpdatedTrigger as TriggerDefinition<unknown>,
95
+ systemDeletedTrigger as TriggerDefinition<unknown>,
96
+ ];
97
+
98
+ // ─── Action: system.update_metadata ────────────────────────────────────
99
+
100
+ const systemUpdateMetadataConfigSchema = z.object({
101
+ systemId: z.string().min(1).describe("Target system id"),
102
+ /**
103
+ * Strategy for combining `metadata` with the system's existing
104
+ * metadata object:
105
+ * - `merge` (default): shallow-merge — preserves untouched keys.
106
+ * - `replace`: overwrite the entire metadata object.
107
+ */
108
+ strategy: z.enum(["merge", "replace"]).default("merge"),
109
+ metadata: z
110
+ .record(z.string(), z.unknown())
111
+ .describe("New metadata key-value pairs"),
112
+ });
113
+
114
+ export type SystemUpdateMetadataConfig = z.infer<
115
+ typeof systemUpdateMetadataConfigSchema
116
+ >;
117
+
118
+ const systemRecordArtifactSchema = z.object({
119
+ systemId: z.string(),
120
+ systemName: z.string(),
121
+ metadata: z.record(z.string(), z.unknown()),
122
+ });
123
+
124
+ export type SystemRecordArtifact = z.infer<typeof systemRecordArtifactSchema>;
125
+
126
+ export const systemRecordArtifactType = {
127
+ id: "system_record",
128
+ displayName: "System Record",
129
+ description: "Snapshot of a system after an automation-driven change",
130
+ schema: systemRecordArtifactSchema,
131
+ } as const;
132
+
133
+ export interface CatalogActionDeps {
134
+ entityService: EntityService;
135
+ cache: ReturnType<typeof createCatalogCache>;
136
+ /**
137
+ * `emitHook` bound during `afterPluginsReady`. Required so the
138
+ * action fires `systemUpdated` downstream — without it, other
139
+ * automations waiting on the trigger wouldn't see the change.
140
+ */
141
+ emitHook: <T>(hook: Hook<T>, payload: T) => Promise<void>;
142
+ }
143
+
144
+ export function createCatalogActions(
145
+ deps: CatalogActionDeps,
146
+ ): ActionDefinition<unknown, unknown>[] {
147
+ const updateMetadata: ActionDefinition<
148
+ SystemUpdateMetadataConfig,
149
+ SystemRecordArtifact
150
+ > = {
151
+ id: "update_metadata",
152
+ displayName: "Update System Metadata",
153
+ description:
154
+ "Set or merge metadata keys on a system. Useful for cross-plugin state (e.g. flagging a system from an automation).",
155
+ category: "Catalog",
156
+ icon: "FilePenLine",
157
+ config: new Versioned({
158
+ version: 1,
159
+ schema: systemUpdateMetadataConfigSchema,
160
+ }),
161
+ produces: "catalog.system_record",
162
+ execute: async ({ config, logger }) => {
163
+ const existing = await deps.entityService.getSystem(config.systemId);
164
+ if (!existing) {
165
+ return {
166
+ success: false,
167
+ error: `System not found: ${config.systemId}`,
168
+ };
169
+ }
170
+
171
+ const existingMetadata =
172
+ (existing.metadata as Record<string, unknown> | null) ?? {};
173
+ const nextMetadata =
174
+ config.strategy === "replace"
175
+ ? config.metadata
176
+ : { ...existingMetadata, ...config.metadata };
177
+
178
+ const updated = await deps.entityService.updateSystem(config.systemId, {
179
+ metadata: nextMetadata,
180
+ });
181
+ if (!updated) {
182
+ return {
183
+ success: false,
184
+ error: `System ${config.systemId} disappeared mid-update`,
185
+ };
186
+ }
187
+
188
+ await deps.cache.invalidateTopology();
189
+ await deps.emitHook(catalogHooks.systemUpdated, {
190
+ systemId: updated.id,
191
+ systemName: updated.name,
192
+ changedFields: ["metadata"],
193
+ });
194
+
195
+ logger.info(
196
+ `Automation updated metadata on system ${updated.id} (${config.strategy})`,
197
+ );
198
+ return {
199
+ success: true,
200
+ externalId: updated.id,
201
+ artifact: {
202
+ systemId: updated.id,
203
+ systemName: updated.name,
204
+ metadata: nextMetadata,
205
+ },
206
+ };
207
+ },
208
+ };
209
+
210
+ return [updateMetadata as ActionDefinition<unknown, unknown>];
211
+ }
package/src/hooks.ts CHANGED
@@ -14,6 +14,20 @@ export const catalogHooks = {
14
14
  systemName: string;
15
15
  }>("catalog.system.created"),
16
16
 
17
+ /**
18
+ * Emitted when a system's name, description, or metadata changes.
19
+ *
20
+ * `changedFields` lists the keys the caller actually set on the
21
+ * update (excluding `id`), so subscribers can route on field-level
22
+ * changes (e.g. "only react to metadata edits") without diffing the
23
+ * full record.
24
+ */
25
+ systemUpdated: createHook<{
26
+ systemId: string;
27
+ systemName: string;
28
+ changedFields: Array<"name" | "description" | "metadata">;
29
+ }>("catalog.system.updated"),
30
+
17
31
  /**
18
32
  * Emitted when a system is deleted.
19
33
  * Plugins can subscribe (work-queue mode) to clean up related data.
package/src/index.ts CHANGED
@@ -3,6 +3,11 @@ import {
3
3
  type SafeDatabase,
4
4
  } from "@checkstack/backend-api";
5
5
  import { coreServices } from "@checkstack/backend-api";
6
+ import {
7
+ automationActionExtensionPoint,
8
+ automationArtifactTypeExtensionPoint,
9
+ automationTriggerExtensionPoint,
10
+ } from "@checkstack/automation-backend";
6
11
  import {
7
12
  catalogAccessRules,
8
13
  catalogAccess,
@@ -27,6 +32,12 @@ import { entityKindExtensionPoint } from "@checkstack/gitops-backend";
27
32
  import { CHECKSTACK_API_VERSION, entityRefSchema, GitOpsApi } from "@checkstack/gitops-common";
28
33
  import { z } from "zod";
29
34
 
35
+ import {
36
+ catalogTriggers,
37
+ createCatalogActions,
38
+ systemRecordArtifactType,
39
+ } from "./automations";
40
+
30
41
  // Database schema is still needed for types in creating the router
31
42
  import * as schema from "./schema";
32
43
 
@@ -40,6 +51,21 @@ export default createBackendPlugin({
40
51
  register(env) {
41
52
  env.registerAccessRules(catalogAccessRules);
42
53
 
54
+ // ─── Automation Platform: triggers + artifact type ─────────────────
55
+ // Buffered behind the extension point until automation-backend's
56
+ // register() runs. The action factory is wired in afterPluginsReady
57
+ // below — that's where `emitHook` becomes available, which the
58
+ // `update_metadata` action needs in order to fire `systemUpdated`.
59
+ const automationTriggers = env.getExtensionPoint(
60
+ automationTriggerExtensionPoint,
61
+ );
62
+ for (const trigger of catalogTriggers) {
63
+ automationTriggers.registerTrigger(trigger, pluginMetadata);
64
+ }
65
+ env
66
+ .getExtensionPoint(automationArtifactTypeExtensionPoint)
67
+ .registerArtifactType(systemRecordArtifactType, pluginMetadata);
68
+
43
69
  // ─── GitOps Entity Kind Registration ───────────────────────────────
44
70
  // Mutable DB reference — populated during init(), consumed by reconcile closures.
45
71
  // Safe because reconcile is only called during sync (afterPluginsReady), by which
@@ -271,7 +297,14 @@ export default createBackendPlugin({
271
297
  logger.debug("✅ Catalog Backend initialized.");
272
298
  },
273
299
  // Phase 3: Safe to make RPC calls after all plugins are ready
274
- afterPluginsReady: async ({ database, rpcClient, logger, onHook }) => {
300
+ afterPluginsReady: async ({
301
+ database,
302
+ rpcClient,
303
+ logger,
304
+ onHook,
305
+ emitHook,
306
+ cacheManager,
307
+ }) => {
275
308
  const typedDb = database as SafeDatabase<typeof schema>;
276
309
  const notificationClient = rpcClient.forPlugin(NotificationApi);
277
310
 
@@ -282,13 +315,29 @@ export default createBackendPlugin({
282
315
  // provisioning happens server-side from this signal.
283
316
  await bootstrapNotificationTargets(typedDb, notificationClient, logger);
284
317
 
318
+ // Register automation actions now that `emitHook` is available
319
+ // — the `update_metadata` action needs to fire `systemUpdated`
320
+ // downstream so other automations + caches react to the change.
321
+ const automationActions = env.getExtensionPoint(
322
+ automationActionExtensionPoint,
323
+ );
324
+ const entityService = new EntityService(typedDb);
325
+ const cache = createCatalogCache({ cacheManager, logger });
326
+ for (const action of createCatalogActions({
327
+ entityService,
328
+ cache,
329
+ emitHook,
330
+ })) {
331
+ automationActions.registerAction(action, pluginMetadata);
332
+ }
333
+
285
334
  // Subscribe to user deletion to clean up user contacts
286
335
  onHook(
287
336
  authHooks.userDeleted,
288
337
  async ({ userId }) => {
289
338
  logger.debug(`Cleaning up contacts for deleted user: ${userId}`);
290
- const entityService = new EntityService(typedDb);
291
- await entityService.deleteContactsByUserId(userId);
339
+ const userCleanupService = new EntityService(typedDb);
340
+ await userCleanupService.deleteContactsByUserId(userId);
292
341
  logger.debug(`Cleaned up contacts for user: ${userId}`);
293
342
  },
294
343
  { mode: "work-queue", workerGroup: "user-cleanup" },
package/src/router.ts CHANGED
@@ -243,7 +243,7 @@ export const createCatalogRouter = ({
243
243
  };
244
244
  });
245
245
 
246
- const updateSystem = os.updateSystem.handler(async ({ input }) => {
246
+ const updateSystem = os.updateSystem.handler(async ({ input, context }) => {
247
247
  await enforceNotGitOpsLocked("System", input.id);
248
248
  // Convert null to undefined and filter out fields
249
249
  const cleanData: Partial<{
@@ -251,11 +251,19 @@ export const createCatalogRouter = ({
251
251
  description?: string;
252
252
  metadata?: Record<string, unknown>;
253
253
  }> = {};
254
- if (input.data.name !== undefined) cleanData.name = input.data.name;
255
- if (input.data.description !== undefined)
254
+ const changedFields: Array<"name" | "description" | "metadata"> = [];
255
+ if (input.data.name !== undefined) {
256
+ cleanData.name = input.data.name;
257
+ changedFields.push("name");
258
+ }
259
+ if (input.data.description !== undefined) {
256
260
  cleanData.description = input.data.description ?? undefined;
257
- if (input.data.metadata !== undefined)
261
+ changedFields.push("description");
262
+ }
263
+ if (input.data.metadata !== undefined) {
258
264
  cleanData.metadata = input.data.metadata ?? undefined;
265
+ changedFields.push("metadata");
266
+ }
259
267
 
260
268
  const result = await entityService.updateSystem(input.id, cleanData);
261
269
  if (!result) {
@@ -269,6 +277,17 @@ export const createCatalogRouter = ({
269
277
  if (input.data.name !== undefined) {
270
278
  await upsertSystemResource({ id: result.id, name: result.name });
271
279
  }
280
+
281
+ // Emit only when a tracked field actually changed (skip no-op
282
+ // updates so automations don't fire on every save-with-no-diff).
283
+ if (changedFields.length > 0) {
284
+ await context.emitHook(catalogHooks.systemUpdated, {
285
+ systemId: result.id,
286
+ systemName: result.name,
287
+ changedFields,
288
+ });
289
+ }
290
+
272
291
  return result as typeof result & {
273
292
  metadata: Record<string, unknown> | null;
274
293
  };
package/tsconfig.json CHANGED
@@ -10,6 +10,9 @@
10
10
  {
11
11
  "path": "../auth-common"
12
12
  },
13
+ {
14
+ "path": "../automation-backend"
15
+ },
13
16
  {
14
17
  "path": "../backend-api"
15
18
  },
@@ -39,6 +42,9 @@
39
42
  },
40
43
  {
41
44
  "path": "../notification-common"
45
+ },
46
+ {
47
+ "path": "../test-utils-backend"
42
48
  }
43
49
  ]
44
50
  }