@checkstack/catalog-backend 0.2.24 → 0.4.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
+ ## 0.4.0
4
+
5
+ ### Minor Changes
6
+
7
+ - b01078f: Added GitOps System kind extension for managing system group associations
8
+
9
+ ## 0.3.0
10
+
11
+ ### Minor Changes
12
+
13
+ - 6c40b5b: Register catalog System and Group as GitOps entity kinds
14
+
15
+ - **catalog-backend**: Registers `kind: System` and `kind: Group` with the GitOps Entity Kind Registry. The catalog now supports declarative management via YAML descriptors in Git repositories. Systems and groups are reconciled using the `metadata.gitops_entity_name` marker for cross-sync identity lookup.
16
+ - **gitops-backend**: Wires up the delete reconciler for orphan cleanup — both automatic deletion (via `deletionPolicy: "auto"`) and manual orphan confirmation now invoke the owning plugin's `delete()` handler before removing provenance entries.
17
+
18
+ - 6c40b5b: Generalized provenance system and GitOps frontend plugin
19
+
20
+ **Breaking**: `EntityKindDefinition.reconcile()` now returns `{ entityId: string }` instead of `void`. Plugins must return the plugin-specific entity ID (e.g., catalog system UUID) so the engine can store it in provenance.
21
+
22
+ - Added `entityId` column to the provenance table (non-nullable)
23
+ - Reconciler engine passes `existingEntityId` to plugins for updates
24
+ - `getProvenance` now supports lookup by `entityId` in addition to `entityName`
25
+ - Added provider CRUD endpoints: `createProvider`, `updateProvider`, `deleteProvider`
26
+ - Created `gitops-frontend` plugin with provider management, secret management, and sync status dashboard
27
+ - Removed `gitops_entity_name` metadata markers from catalog entities
28
+ - Removed `findSystemByGitOpsName`, `deleteSystemByGitOpsName` (and Group equivalents) from EntityService
29
+ - Added provenance-based UI locking in catalog-frontend: edit/delete/drag disabled for GitOps-managed systems and groups
30
+
31
+ ### Patch Changes
32
+
33
+ - 6c40b5b: ### GitOps Ecosystem: Healthcheck Kind Registration (Phase 5)
34
+
35
+ **gitops-common**: Added required `resolveEntityRef` to `ReconcileContext`, enabling extension reconcilers to resolve cross-kind entity references (e.g., healthcheck refs in System extensions).
36
+
37
+ **gitops-backend**: Updated reconciler to populate `resolveEntityRef` by querying local provenance — no RPC round-trip needed.
38
+
39
+ **healthcheck-backend**: Registered `kind: Healthcheck` and `System → healthchecks` extension with the EntityKindRegistry:
40
+
41
+ - Validates strategy configs against registered strategy schemas at reconcile time
42
+ - Validates collector configs against registered collector schemas at reconcile time
43
+ - Manages system ↔ healthcheck associations with automatic stale removal
44
+
45
+ **healthcheck-frontend**: Added GitOps provenance locking to the HealthCheck IDE editor — GitOps-managed health checks show a lock banner and disable editing.
46
+
47
+ **catalog-backend**: Updated test fixtures for new required `resolveEntityRef` context field.
48
+
49
+ - Updated dependencies [6c40b5b]
50
+ - Updated dependencies [6c40b5b]
51
+ - Updated dependencies [6c40b5b]
52
+ - Updated dependencies [6c40b5b]
53
+ - Updated dependencies [6c40b5b]
54
+ - Updated dependencies [6c40b5b]
55
+ - @checkstack/gitops-backend@0.1.0
56
+ - @checkstack/gitops-common@0.1.0
57
+
3
58
  ## 0.2.24
4
59
 
5
60
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/catalog-backend",
3
- "version": "0.2.24",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "checkstack": {
@@ -13,11 +13,13 @@
13
13
  "lint:code": "eslint . --max-warnings 0"
14
14
  },
15
15
  "dependencies": {
16
- "@checkstack/backend-api": "0.11.1",
16
+ "@checkstack/backend-api": "0.12.0",
17
17
  "@checkstack/auth-common": "0.6.1",
18
18
  "@checkstack/catalog-common": "1.3.1",
19
- "@checkstack/command-backend": "0.1.18",
20
- "@checkstack/auth-backend": "0.4.17",
19
+ "@checkstack/command-backend": "0.1.19",
20
+ "@checkstack/auth-backend": "0.4.18",
21
+ "@checkstack/gitops-backend": "0.0.1",
22
+ "@checkstack/gitops-common": "0.0.1",
21
23
  "@checkstack/notification-common": "0.2.8",
22
24
  "@orpc/server": "^1.13.2",
23
25
  "drizzle-orm": "^0.45.0",
@@ -0,0 +1,454 @@
1
+ import { describe, it, expect, mock, beforeEach } from "bun:test";
2
+ import { z } from "zod";
3
+ import { CHECKSTACK_API_VERSION } from "@checkstack/gitops-common";
4
+ import type {
5
+ EntityKindDefinition,
6
+ ReconcileContext,
7
+ } from "@checkstack/gitops-common";
8
+
9
+ /**
10
+ * Tests for the catalog-backend's GitOps entity kind registrations.
11
+ *
12
+ * These tests exercise the reconcile/delete logic in isolation by
13
+ * reconstructing the same kind definitions that catalog-backend registers
14
+ * with the entity kind extension point.
15
+ *
16
+ * The generic entityId pattern: reconcile() returns { entityId }, the
17
+ * reconciler engine stores it in provenance, and passes it back as
18
+ * existingEntityId on subsequent reconciles.
19
+ */
20
+
21
+ // ─── Mock EntityService ────────────────────────────────────────────────────
22
+
23
+ interface MockSystem {
24
+ id: string;
25
+ name: string;
26
+ description?: string;
27
+ createdAt: Date;
28
+ updatedAt: Date;
29
+ }
30
+
31
+ interface MockGroup {
32
+ id: string;
33
+ name: string;
34
+ createdAt: Date;
35
+ updatedAt: Date;
36
+ }
37
+
38
+ function createMockEntityService() {
39
+ const systems: MockSystem[] = [];
40
+ const groups: MockGroup[] = [];
41
+
42
+ return {
43
+ systems,
44
+ groups,
45
+ createSystem: mock(async (data: { name: string; description?: string }) => {
46
+ const system: MockSystem = {
47
+ id: `sys-${systems.length + 1}`,
48
+ name: data.name,
49
+ description: data.description,
50
+ createdAt: new Date(),
51
+ updatedAt: new Date(),
52
+ };
53
+ systems.push(system);
54
+ return system;
55
+ }),
56
+ updateSystem: mock(async (id: string, data: Partial<{ name: string; description?: string }>) => {
57
+ const system = systems.find((s) => s.id === id);
58
+ if (system) {
59
+ Object.assign(system, data);
60
+ }
61
+ return system;
62
+ }),
63
+ deleteSystem: mock(async (id: string) => {
64
+ const idx = systems.findIndex((s) => s.id === id);
65
+ if (idx >= 0) systems.splice(idx, 1);
66
+ }),
67
+ createGroup: mock(async (data: { name: string }) => {
68
+ const group: MockGroup = {
69
+ id: `grp-${groups.length + 1}`,
70
+ name: data.name,
71
+ createdAt: new Date(),
72
+ updatedAt: new Date(),
73
+ };
74
+ groups.push(group);
75
+ return group;
76
+ }),
77
+ updateGroup: mock(async (id: string, data: Partial<{ name: string }>) => {
78
+ const group = groups.find((g) => g.id === id);
79
+ if (group) {
80
+ Object.assign(group, data);
81
+ }
82
+ return group;
83
+ }),
84
+ deleteGroup: mock(async (id: string) => {
85
+ const idx = groups.findIndex((g) => g.id === id);
86
+ if (idx >= 0) groups.splice(idx, 1);
87
+ }),
88
+ addSystemToGroup: mock(async (props: { groupId: string; systemId: string }) => {}),
89
+ removeSystemFromGroup: mock(async (props: { groupId: string; systemId: string }) => {}),
90
+ getGroupsForSystem: mock(async (systemId: string) => {
91
+ return [] as { groupId: string, systemId: string }[];
92
+ }),
93
+ };
94
+ }
95
+
96
+ // ─── Test Context ──────────────────────────────────────────────────────────
97
+
98
+ const mockContext: ReconcileContext = {
99
+ logger: {
100
+ debug: () => {},
101
+ info: () => {},
102
+ warn: () => {},
103
+ error: () => {},
104
+ },
105
+ resolveEntityRef: async () => undefined,
106
+ };
107
+
108
+ // ─── Tests ─────────────────────────────────────────────────────────────────
109
+
110
+ describe("Catalog GitOps Kind: System", () => {
111
+ let mockService: ReturnType<typeof createMockEntityService>;
112
+
113
+ const systemSpecSchema = z.object({});
114
+ type SystemSpec = z.infer<typeof systemSpecSchema>;
115
+
116
+ function buildSystemKind(
117
+ svc: ReturnType<typeof createMockEntityService>,
118
+ ): EntityKindDefinition<SystemSpec> {
119
+ return {
120
+ apiVersion: CHECKSTACK_API_VERSION,
121
+ kind: "System",
122
+ specSchema: systemSpecSchema,
123
+ reconcile: async ({ entity, existingEntityId, context }) => {
124
+ const displayName = entity.metadata.title ?? entity.metadata.name;
125
+ const description = entity.metadata.description;
126
+
127
+ if (existingEntityId) {
128
+ await svc.updateSystem(existingEntityId, {
129
+ name: displayName,
130
+ description,
131
+ });
132
+ context.logger.info(`Updated system (id: ${existingEntityId})`);
133
+ return { entityId: existingEntityId };
134
+ }
135
+
136
+ const system = await svc.createSystem({
137
+ name: displayName,
138
+ description,
139
+ });
140
+ context.logger.info(`Created system (id: ${system.id})`);
141
+ return { entityId: system.id };
142
+ },
143
+ delete: async ({ entityId }) => {
144
+ if (entityId) await svc.deleteSystem(entityId);
145
+ },
146
+ };
147
+ }
148
+
149
+ beforeEach(() => {
150
+ mockService = createMockEntityService();
151
+ });
152
+
153
+ it("creates a new system and returns entityId", async () => {
154
+ const kind = buildSystemKind(mockService);
155
+
156
+ const result = await kind.reconcile({
157
+ entity: {
158
+ apiVersion: CHECKSTACK_API_VERSION,
159
+ kind: "System",
160
+ metadata: {
161
+ name: "payment-service",
162
+ title: "Payment Service",
163
+ description: "Handles payments",
164
+ },
165
+ spec: {},
166
+ },
167
+ context: mockContext,
168
+ });
169
+
170
+ expect(result.entityId).toBe("sys-1");
171
+ expect(mockService.createSystem).toHaveBeenCalledTimes(1);
172
+ expect(mockService.systems).toHaveLength(1);
173
+ expect(mockService.systems[0].name).toBe("Payment Service");
174
+ expect(mockService.systems[0].description).toBe("Handles payments");
175
+ });
176
+
177
+ it("uses metadata.description for the catalog description", async () => {
178
+ const kind = buildSystemKind(mockService);
179
+
180
+ const result = await kind.reconcile({
181
+ entity: {
182
+ apiVersion: CHECKSTACK_API_VERSION,
183
+ kind: "System",
184
+ metadata: {
185
+ name: "api-gateway",
186
+ description: "Gateway description",
187
+ },
188
+ spec: {},
189
+ },
190
+ context: mockContext,
191
+ });
192
+
193
+ expect(result.entityId).toBe("sys-1");
194
+ expect(mockService.systems[0].description).toBe("Gateway description");
195
+ });
196
+
197
+ it("falls back to metadata.name when title is missing", async () => {
198
+ const kind = buildSystemKind(mockService);
199
+
200
+ await kind.reconcile({
201
+ entity: {
202
+ apiVersion: CHECKSTACK_API_VERSION,
203
+ kind: "System",
204
+ metadata: { name: "my-service" },
205
+ spec: {},
206
+ },
207
+ context: mockContext,
208
+ });
209
+
210
+ expect(mockService.systems[0].name).toBe("my-service");
211
+ });
212
+
213
+ it("updates an existing system using existingEntityId", async () => {
214
+ const kind = buildSystemKind(mockService);
215
+
216
+ // Pre-populate a system
217
+ mockService.systems.push({
218
+ id: "sys-existing",
219
+ name: "Old Name",
220
+ description: "Old description",
221
+ createdAt: new Date(),
222
+ updatedAt: new Date(),
223
+ });
224
+
225
+ const result = await kind.reconcile({
226
+ entity: {
227
+ apiVersion: CHECKSTACK_API_VERSION,
228
+ kind: "System",
229
+ metadata: {
230
+ name: "payment-service",
231
+ title: "Payment Service v2",
232
+ description: "Updated description",
233
+ },
234
+ spec: {},
235
+ },
236
+ existingEntityId: "sys-existing",
237
+ context: mockContext,
238
+ });
239
+
240
+ expect(result.entityId).toBe("sys-existing");
241
+ expect(mockService.createSystem).not.toHaveBeenCalled();
242
+ expect(mockService.updateSystem).toHaveBeenCalledTimes(1);
243
+ expect(mockService.systems[0].name).toBe("Payment Service v2");
244
+ expect(mockService.systems[0].description).toBe("Updated description");
245
+ });
246
+
247
+ it("deletes a system by entityId", async () => {
248
+ const kind = buildSystemKind(mockService);
249
+
250
+ mockService.systems.push({
251
+ id: "sys-del",
252
+ name: "To Delete",
253
+ createdAt: new Date(),
254
+ updatedAt: new Date(),
255
+ });
256
+
257
+ await kind.delete!({
258
+ entityName: "old-service",
259
+ entityId: "sys-del",
260
+ context: mockContext,
261
+ });
262
+
263
+ expect(mockService.deleteSystem).toHaveBeenCalledWith("sys-del");
264
+ expect(mockService.systems).toHaveLength(0);
265
+ });
266
+
267
+ it("skips delete when entityId is missing", async () => {
268
+ const kind = buildSystemKind(mockService);
269
+
270
+ await kind.delete!({
271
+ entityName: "unknown-service",
272
+ context: mockContext,
273
+ });
274
+
275
+ expect(mockService.deleteSystem).not.toHaveBeenCalled();
276
+ });
277
+ });
278
+
279
+ describe("Catalog GitOps Kind Extension: System -> groups", () => {
280
+ let mockService: ReturnType<typeof createMockEntityService>;
281
+
282
+ beforeEach(() => {
283
+ mockService = createMockEntityService();
284
+ });
285
+
286
+ it("associates system with groups and removes stale ones", async () => {
287
+ // We mock getGroupsForSystem to return one existing association
288
+ mockService.getGroupsForSystem.mockResolvedValueOnce([
289
+ { groupId: "grp-stale", systemId: "sys-1" }
290
+ ]);
291
+
292
+ const mockExtContext: ReconcileContext = {
293
+ ...mockContext,
294
+ resolveEntityRef: mock(async ({ entityName }) => {
295
+ if (entityName === "new-group") return "grp-new";
296
+ return undefined;
297
+ }),
298
+ };
299
+
300
+ // Simulate the inline reconcile logic from index.ts
301
+ const reconcileExt = async ({ extensionSpec, entityId, context }: any) => {
302
+ if (!extensionSpec || extensionSpec.length === 0) return;
303
+
304
+ const desiredGroupIds = new Set<string>();
305
+
306
+ for (const entry of extensionSpec) {
307
+ const groupId = await context.resolveEntityRef({
308
+ kind: entry.kind,
309
+ entityName: entry.name,
310
+ });
311
+ if (groupId) {
312
+ desiredGroupIds.add(groupId);
313
+ await mockService.addSystemToGroup({ groupId, systemId: entityId });
314
+ }
315
+ }
316
+
317
+ const currentAssociations = await mockService.getGroupsForSystem(entityId);
318
+ for (const existing of currentAssociations) {
319
+ if (!desiredGroupIds.has(existing.groupId)) {
320
+ await mockService.removeSystemFromGroup({
321
+ groupId: existing.groupId,
322
+ systemId: entityId,
323
+ });
324
+ }
325
+ }
326
+ };
327
+
328
+ await reconcileExt({
329
+ entity: { metadata: { name: "my-system" } },
330
+ extensionSpec: [{ kind: "Group", name: "new-group" }],
331
+ entityId: "sys-1",
332
+ context: mockExtContext,
333
+ });
334
+
335
+ expect(mockService.addSystemToGroup).toHaveBeenCalledWith({
336
+ groupId: "grp-new",
337
+ systemId: "sys-1",
338
+ });
339
+
340
+ expect(mockService.removeSystemFromGroup).toHaveBeenCalledWith({
341
+ groupId: "grp-stale",
342
+ systemId: "sys-1",
343
+ });
344
+ });
345
+ });
346
+
347
+ describe("Catalog GitOps Kind: Group", () => {
348
+ let mockService: ReturnType<typeof createMockEntityService>;
349
+
350
+ const groupSpecSchema = z.object({});
351
+ type GroupSpec = z.infer<typeof groupSpecSchema>;
352
+
353
+ function buildGroupKind(
354
+ svc: ReturnType<typeof createMockEntityService>,
355
+ ): EntityKindDefinition<GroupSpec> {
356
+ return {
357
+ apiVersion: CHECKSTACK_API_VERSION,
358
+ kind: "Group",
359
+ specSchema: groupSpecSchema,
360
+ reconcile: async ({ entity, existingEntityId, context }) => {
361
+ const displayName = entity.metadata.title ?? entity.metadata.name;
362
+
363
+ if (existingEntityId) {
364
+ await svc.updateGroup(existingEntityId, { name: displayName });
365
+ context.logger.info(`Updated group (id: ${existingEntityId})`);
366
+ return { entityId: existingEntityId };
367
+ }
368
+
369
+ const group = await svc.createGroup({ name: displayName });
370
+ context.logger.info(`Created group (id: ${group.id})`);
371
+ return { entityId: group.id };
372
+ },
373
+ delete: async ({ entityId }) => {
374
+ if (entityId) await svc.deleteGroup(entityId);
375
+ },
376
+ };
377
+ }
378
+
379
+ beforeEach(() => {
380
+ mockService = createMockEntityService();
381
+ });
382
+
383
+ it("creates a new group and returns entityId", async () => {
384
+ const kind = buildGroupKind(mockService);
385
+
386
+ const result = await kind.reconcile({
387
+ entity: {
388
+ apiVersion: CHECKSTACK_API_VERSION,
389
+ kind: "Group",
390
+ metadata: {
391
+ name: "platform-team",
392
+ title: "Platform Team",
393
+ },
394
+ spec: {},
395
+ },
396
+ context: mockContext,
397
+ });
398
+
399
+ expect(result.entityId).toBe("grp-1");
400
+ expect(mockService.createGroup).toHaveBeenCalledTimes(1);
401
+ expect(mockService.groups).toHaveLength(1);
402
+ expect(mockService.groups[0].name).toBe("Platform Team");
403
+ });
404
+
405
+ it("updates an existing group using existingEntityId", async () => {
406
+ const kind = buildGroupKind(mockService);
407
+
408
+ mockService.groups.push({
409
+ id: "grp-existing",
410
+ name: "Old Group",
411
+ createdAt: new Date(),
412
+ updatedAt: new Date(),
413
+ });
414
+
415
+ const result = await kind.reconcile({
416
+ entity: {
417
+ apiVersion: CHECKSTACK_API_VERSION,
418
+ kind: "Group",
419
+ metadata: {
420
+ name: "platform-team",
421
+ title: "Platform Engineering",
422
+ },
423
+ spec: {},
424
+ },
425
+ existingEntityId: "grp-existing",
426
+ context: mockContext,
427
+ });
428
+
429
+ expect(result.entityId).toBe("grp-existing");
430
+ expect(mockService.createGroup).not.toHaveBeenCalled();
431
+ expect(mockService.updateGroup).toHaveBeenCalledTimes(1);
432
+ expect(mockService.groups[0].name).toBe("Platform Engineering");
433
+ });
434
+
435
+ it("deletes a group by entityId", async () => {
436
+ const kind = buildGroupKind(mockService);
437
+
438
+ mockService.groups.push({
439
+ id: "grp-del",
440
+ name: "To Delete",
441
+ createdAt: new Date(),
442
+ updatedAt: new Date(),
443
+ });
444
+
445
+ await kind.delete!({
446
+ entityName: "old-team",
447
+ entityId: "grp-del",
448
+ context: mockContext,
449
+ });
450
+
451
+ expect(mockService.deleteGroup).toHaveBeenCalledWith("grp-del");
452
+ expect(mockService.groups).toHaveLength(0);
453
+ });
454
+ });
package/src/index.ts CHANGED
@@ -17,6 +17,9 @@ import { authHooks } from "@checkstack/auth-backend";
17
17
  import { resolveRoute, type InferClient, extractErrorMessage} from "@checkstack/common";
18
18
  import { registerSearchProvider } from "@checkstack/command-backend";
19
19
  import { EntityService } from "./services/entity-service";
20
+ import { entityKindExtensionPoint } from "@checkstack/gitops-backend";
21
+ import { CHECKSTACK_API_VERSION, entityRefSchema } from "@checkstack/gitops-common";
22
+ import { z } from "zod";
20
23
 
21
24
  // Database schema is still needed for types in creating the router
22
25
  import * as schema from "./schema";
@@ -31,6 +34,146 @@ export default createBackendPlugin({
31
34
  register(env) {
32
35
  env.registerAccessRules(catalogAccessRules);
33
36
 
37
+ // ─── GitOps Entity Kind Registration ───────────────────────────────
38
+ // Mutable DB reference — populated during init(), consumed by reconcile closures.
39
+ // Safe because reconcile is only called during sync (afterPluginsReady), by which
40
+ // point init() has already completed.
41
+ let gitopsDb: SafeDatabase<typeof schema> | undefined;
42
+
43
+ const kindRegistry = env.getExtensionPoint(entityKindExtensionPoint);
44
+
45
+ // Register kind: System
46
+ kindRegistry.registerKind({
47
+ apiVersion: CHECKSTACK_API_VERSION,
48
+ kind: "System",
49
+ specSchema: z.object({}),
50
+ reconcile: async ({ entity, existingEntityId, context }) => {
51
+ if (!gitopsDb) throw new Error("Catalog database not initialized");
52
+ const entityService = new EntityService(gitopsDb);
53
+ const displayName = entity.metadata.title ?? entity.metadata.name;
54
+ const description = entity.metadata.description;
55
+
56
+ if (existingEntityId) {
57
+ await entityService.updateSystem(existingEntityId, {
58
+ name: displayName,
59
+ description,
60
+ });
61
+ context.logger.info(
62
+ `GitOps: updated System "${displayName}" (id: ${existingEntityId})`,
63
+ );
64
+ return { entityId: existingEntityId };
65
+ }
66
+
67
+ const system = await entityService.createSystem({
68
+ name: displayName,
69
+ description,
70
+ });
71
+ context.logger.info(
72
+ `GitOps: created System "${displayName}" (id: ${system.id})`,
73
+ );
74
+ return { entityId: system.id };
75
+ },
76
+ delete: async ({ entityId, context }) => {
77
+ if (!gitopsDb) throw new Error("Catalog database not initialized");
78
+ if (!entityId) return;
79
+ const entityService = new EntityService(gitopsDb);
80
+ await entityService.deleteSystem(entityId);
81
+ context.logger.info(`GitOps: deleted System (id: ${entityId})`);
82
+ },
83
+ });
84
+
85
+ // Register kind extension: System -> groups
86
+ kindRegistry.registerKindExtension({
87
+ apiVersion: CHECKSTACK_API_VERSION,
88
+ kind: "System",
89
+ namespace: "groups",
90
+ specSchema: z.array(entityRefSchema).optional(),
91
+ reconcile: async ({ entity, extensionSpec, entityId, context }) => {
92
+ if (!gitopsDb) throw new Error("Catalog database not initialized");
93
+ if (!extensionSpec || extensionSpec.length === 0) return;
94
+
95
+ const entityService = new EntityService(gitopsDb);
96
+ const systemEntityId = entityId;
97
+
98
+ const desiredGroupIds = new Set<string>();
99
+
100
+ for (const entry of extensionSpec) {
101
+ const groupId = await context.resolveEntityRef({
102
+ kind: entry.kind,
103
+ entityName: entry.name,
104
+ });
105
+
106
+ if (!groupId) {
107
+ throw new Error(
108
+ `Cannot resolve ${entry.kind} ref "${entry.name}" — ensure the entity exists`
109
+ );
110
+ }
111
+
112
+ desiredGroupIds.add(groupId);
113
+
114
+ await entityService.addSystemToGroup({
115
+ groupId: groupId,
116
+ systemId: systemEntityId,
117
+ });
118
+
119
+ context.logger.info(
120
+ `GitOps: associated System "${entity.metadata.name}" with Group "${entry.name}" (${groupId})`
121
+ );
122
+ }
123
+
124
+ // Remove stale associations not in the spec
125
+ const currentAssociations = await entityService.getGroupsForSystem(systemEntityId);
126
+ for (const existing of currentAssociations) {
127
+ if (!desiredGroupIds.has(existing.groupId)) {
128
+ await entityService.removeSystemFromGroup({
129
+ groupId: existing.groupId,
130
+ systemId: systemEntityId,
131
+ });
132
+ context.logger.info(
133
+ `GitOps: removed stale association ${existing.groupId} from System "${entity.metadata.name}"`
134
+ );
135
+ }
136
+ }
137
+ },
138
+ });
139
+
140
+ // Register kind: Group
141
+ kindRegistry.registerKind({
142
+ apiVersion: CHECKSTACK_API_VERSION,
143
+ kind: "Group",
144
+ specSchema: z.object({}),
145
+ reconcile: async ({ entity, existingEntityId, context }) => {
146
+ if (!gitopsDb) throw new Error("Catalog database not initialized");
147
+ const entityService = new EntityService(gitopsDb);
148
+ const displayName = entity.metadata.title ?? entity.metadata.name;
149
+
150
+ if (existingEntityId) {
151
+ await entityService.updateGroup(existingEntityId, {
152
+ name: displayName,
153
+ });
154
+ context.logger.info(
155
+ `GitOps: updated Group "${displayName}" (id: ${existingEntityId})`,
156
+ );
157
+ return { entityId: existingEntityId };
158
+ }
159
+
160
+ const group = await entityService.createGroup({
161
+ name: displayName,
162
+ });
163
+ context.logger.info(
164
+ `GitOps: created Group "${displayName}" (id: ${group.id})`,
165
+ );
166
+ return { entityId: group.id };
167
+ },
168
+ delete: async ({ entityId, context }) => {
169
+ if (!gitopsDb) throw new Error("Catalog database not initialized");
170
+ if (!entityId) return;
171
+ const entityService = new EntityService(gitopsDb);
172
+ await entityService.deleteGroup(entityId);
173
+ context.logger.info(`GitOps: deleted Group (id: ${entityId})`);
174
+ },
175
+ });
176
+
34
177
  env.registerInit({
35
178
  schema,
36
179
  deps: {
@@ -42,6 +185,9 @@ export default createBackendPlugin({
42
185
  init: async ({ database, rpc, rpcClient, logger }) => {
43
186
  logger.debug("Initializing Catalog Backend...");
44
187
 
188
+ // Populate the mutable DB reference for GitOps reconcile closures
189
+ gitopsDb = database as SafeDatabase<typeof schema>;
190
+
45
191
  const typedDb = database as SafeDatabase<typeof schema>;
46
192
 
47
193
  // Get notification client for group management and sending notifications
@@ -144,6 +144,15 @@ export class EntityService {
144
144
  await this.database.delete(schema.groups).where(eq(schema.groups.id, id));
145
145
  }
146
146
 
147
+ async getGroupsForSystem(systemId: string) {
148
+ const associations = await this.database
149
+ .select()
150
+ .from(schema.systemsGroups)
151
+ .where(eq(schema.systemsGroups.systemId, systemId));
152
+
153
+ return associations;
154
+ }
155
+
147
156
  async addSystemToGroup(props: { groupId: string; systemId: string }) {
148
157
  const { groupId, systemId } = props;
149
158
  await this.database