@checkstack/catalog-backend 1.1.5 → 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 +66 -0
- package/package.json +17 -14
- package/src/automations.test.ts +369 -0
- package/src/automations.ts +211 -0
- package/src/hooks.ts +14 -0
- package/src/index.ts +52 -3
- package/src/router.ts +23 -4
- package/tsconfig.json +6 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,71 @@
|
|
|
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
|
+
|
|
58
|
+
## 1.1.6
|
|
59
|
+
|
|
60
|
+
### Patch Changes
|
|
61
|
+
|
|
62
|
+
- @checkstack/backend-api@0.17.1
|
|
63
|
+
- @checkstack/auth-backend@0.4.30
|
|
64
|
+
- @checkstack/cache-api@0.3.5
|
|
65
|
+
- @checkstack/command-backend@0.1.30
|
|
66
|
+
- @checkstack/gitops-backend@0.3.6
|
|
67
|
+
- @checkstack/cache-utils@0.2.10
|
|
68
|
+
|
|
3
69
|
## 1.1.5
|
|
4
70
|
|
|
5
71
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@checkstack/catalog-backend",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.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.
|
|
18
|
-
"@checkstack/
|
|
19
|
-
"@checkstack/cache-
|
|
20
|
-
"@checkstack/
|
|
21
|
-
"@checkstack/
|
|
22
|
-
"@checkstack/
|
|
23
|
-
"@checkstack/
|
|
24
|
-
"@checkstack/
|
|
25
|
-
"@checkstack/gitops-
|
|
26
|
-
"@checkstack/
|
|
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",
|
|
22
|
+
"@checkstack/auth-common": "0.7.1",
|
|
23
|
+
"@checkstack/catalog-common": "2.2.2",
|
|
24
|
+
"@checkstack/command-backend": "0.1.30",
|
|
25
|
+
"@checkstack/auth-backend": "0.4.30",
|
|
26
|
+
"@checkstack/gitops-backend": "0.3.6",
|
|
27
|
+
"@checkstack/gitops-common": "0.4.1",
|
|
28
|
+
"@checkstack/notification-common": "1.2.0",
|
|
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.
|
|
34
|
+
"@checkstack/common": "0.11.0"
|
|
33
35
|
},
|
|
34
36
|
"devDependencies": {
|
|
35
37
|
"@checkstack/drizzle-helper": "0.0.5",
|
|
36
|
-
"@checkstack/scripts": "0.3.
|
|
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 ({
|
|
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
|
|
291
|
-
await
|
|
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
|
-
|
|
255
|
-
if (input.data.
|
|
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
|
-
|
|
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
|
}
|