@checkstack/catalog-backend 0.7.1 → 1.0.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,165 @@
1
1
  # @checkstack/catalog-backend
2
2
 
3
+ ## 1.0.0
4
+
5
+ ### Major Changes
6
+
7
+ - 32d52c6: feat: notification target pattern + per-spec subscriptions
8
+
9
+ Replaces the all-or-nothing catalog system/group notification model with a
10
+ platform-level target pattern. Each notification-emitting plugin declares
11
+ _subscription specs_ against typed _target_ objects exported from the
12
+ target's owning plugin (catalog ships `catalogSystemTarget` and
13
+ `catalogGroupTarget`). Notification-backend handles every per-resource
14
+ group lifecycle, parent-edge inheritance, and legacy-subscription seeding
15
+ — plugins never author groupId helpers, lifecycle hooks, or migration
16
+ code again.
17
+
18
+ **Plugin-author surface area is now ~12 lines per emitter:**
19
+
20
+ ```ts
21
+ // <plugin>-common
22
+ const { defineSubscription } = createSubscriptionFactory(pluginMetadata);
23
+ export const fooSystemSubscription = defineSubscription({
24
+ localId: "system",
25
+ target: catalogSystemTarget,
26
+ display: { title: "Foo Alerts", description: "...", iconName: "Bell" },
27
+ });
28
+
29
+ // <plugin>-backend register()
30
+ env.registerSubscriptionSpecs([fooSystemSubscription]);
31
+ // ^ feeds the plugin loader's dependency sorter — each spec's
32
+ // target.ownerPlugin becomes an implicit init-order dep, so this
33
+ // plugin automatically waits for catalog (the target owner) to
34
+ // finish init + afterPluginsReady before its own runs.
35
+
36
+ // <plugin>-backend afterPluginsReady
37
+ await notificationClient.registerSubscriptionSpec(
38
+ specToRegistration(fooSystemSubscription)
39
+ );
40
+ // dispatch
41
+ await notificationClient.notifyForSubscription({
42
+ specId: fooSystemSubscription.specId,
43
+ resourceKeys: [systemId],
44
+ title,
45
+ body,
46
+ importance,
47
+ action,
48
+ collapseKey,
49
+ subjects,
50
+ });
51
+
52
+ // <plugin>-frontend
53
+ createNotificationSubscriptionExtension({ spec: fooSystemSubscription });
54
+ ```
55
+
56
+ **Migrated plugins**: anomaly, incident, maintenance, healthcheck,
57
+ dependency. Each lost its bespoke `notification-groups.ts`,
58
+ `bootstrap*NotificationGroups`, `ensure*Group`, and inheritance walk —
59
+ all of that is now centralized in notification-backend's
60
+ `subscription-engine`.
61
+
62
+ **Plugin loader change** (`@checkstack/backend-api`,
63
+ `@checkstack/backend`): the register-time API gains
64
+ `env.registerSubscriptionSpecs([...specs])`. The dependency sorter
65
+ walks `spec.target.ownerPlugin` for every declared spec and adds the
66
+ target owner as an init-order dependency of the emitting plugin. This
67
+ guarantees that catalog (the owner of the platform's `system` and
68
+ `group` targets) completes init + afterPluginsReady before any
69
+ emitting plugin tries to register its specs against the notification
70
+ service — no string-prefix heuristics, no manual `dependsOnPlugins`
71
+ list, no stub rows. Plugins that fail to declare their specs at
72
+ register time get a clear `Target type X is not registered. Did the
73
+ emitting plugin declare this spec via env.registerSubscriptionSpecs?`
74
+ error from the dispatcher.
75
+
76
+ **Removed** (no backwards compat):
77
+
78
+ - `catalogClient.notifySystemSubscribers` and
79
+ `catalogClient.notifyManySystemSubscribers`
80
+ - `notificationClient.notifyUsers` and `notificationClient.notifyGroups`
81
+ as direct dispatch primitives — replaced by spec-bound
82
+ `notifyForSubscription`
83
+ - catalog's `bootstrapNotificationGroups` (replaced by
84
+ `bootstrapNotificationTargets`)
85
+
86
+ **Enforcement**: the dispatcher rejects calls referencing unregistered
87
+ specIds, specs owned by other plugins, or resourceKeys that haven't been
88
+ pushed via `upsertNotificationResource`. Display metadata for any
89
+ groupId is recoverable via the spec registry, so audit lists render
90
+ correct labels even when an emitter's frontend isn't loaded.
91
+
92
+ **Per-field anomaly mute** keeps working — it now lives inside the
93
+ generic SubscriptionRow's optional `SubControls` panel
94
+ (`AnomalyFieldMuteList`), exposed through the catalog system detail
95
+ page's notifications card.
96
+
97
+ The catalog system detail page renders a "Notifications" card hosting
98
+ `SystemNotificationSubscriptionsSlot`. The matching group surface is
99
+ not yet rendered — group-level subscriptions are wired end-to-end on
100
+ the backend; a follow-up will add the host UI.
101
+
102
+ **Migration of existing subscribers**: target types declare a
103
+ `legacyGroupIdTemplate`; on first registration of each spec,
104
+ notification-backend reads subscribers from the legacy
105
+ `catalog.system.<id>` / `catalog.group.<id>` groups and seeds the new
106
+ spec groups exactly once per (spec × resource) pair, tracked in
107
+ `subscription_migrations`. Anomaly stays opt-in (its target also
108
+ declares the template, but the user-explicit nature of the original
109
+ opt-in flow means the seeding produces the same set of subscribers
110
+ they already had).
111
+
112
+ ### Minor Changes
113
+
114
+ - 32d52c6: feat(anomaly): per-system and per-field notification mute
115
+
116
+ Anomaly notifications now flow through their own subscription group
117
+ (`anomaly.system.<systemId>`) instead of the shared catalog system group, so
118
+ users can opt out of anomaly noise without losing incident or healthcheck
119
+ alerts for the same system. On first deploy, existing subscribers of each
120
+ `catalog.system.<id>` group are seeded onto the new anomaly group so no one
121
+ silently stops getting alerts.
122
+
123
+ A new mute table (`anomaly_notification_mutes`) backs two granularities:
124
+
125
+ - **Per-field**: silence a single noisy metric on one system.
126
+ - **Per-system**: silence every anomaly for one system in one click.
127
+
128
+ The system anomaly widget now exposes a bell icon on each anomaly row plus a
129
+ `Mute all` toggle in the card header. Mutes are user-scoped and persist
130
+ across sessions.
131
+
132
+ Catalog gains a `systemCreated` hook so anomaly (and any future plugin) can
133
+ provision per-system state on creation rather than waiting for a restart.
134
+ The notification service gains a `bulkSubscribe` service-RPC used by the
135
+ one-time migration described above.
136
+
137
+ - 32d52c6: Bulk notifications affecting multiple systems and collapse lifecycle events into a single card.
138
+
139
+ Notifications now carry an optional `subjects` array (the entities they affect) and an optional `collapseKey` (so related notifications collapse into one row per recipient). Incidents, maintenances, anomalies, healthchecks, and dependency-impact events route through these new fields, so an incident affecting three systems produces one in-app notification + one external send per subscriber instead of three. Lifecycle updates for the same entity (created → updated → resolved) also collapse, with an expandable "+N updates" timeline.
140
+
141
+ Subject kinds are namespaced as `<pluginId>.<localKind>` and built via type-safe helpers exported from each domain's common package (`createSystemSubject`, `incidentCollapseKey`, etc.). The frontend kind registry (`registerSubjectKind`) lets plugins bind icon + label for their kinds; unknown kinds fall back to a generic chip.
142
+
143
+ All notification strategies (SMTP, Slack, Discord, Teams, Telegram, Pushover, Gotify, Webex, Backstage) render the affected subjects natively in their format (HTML cards, Slack blocks, Discord embed fields, adaptive cards, markdown lists, etc.).
144
+
145
+ ### Patch Changes
146
+
147
+ - Updated dependencies [32d52c6]
148
+ - Updated dependencies [32d52c6]
149
+ - Updated dependencies [32d52c6]
150
+ - Updated dependencies [32d52c6]
151
+ - Updated dependencies [32d52c6]
152
+ - Updated dependencies [32d52c6]
153
+ - @checkstack/gitops-backend@0.2.6
154
+ - @checkstack/notification-common@1.0.0
155
+ - @checkstack/catalog-common@2.0.0
156
+ - @checkstack/backend-api@0.14.0
157
+ - @checkstack/auth-backend@0.4.22
158
+ - @checkstack/auth-common@0.6.4
159
+ - @checkstack/cache-api@0.2.2
160
+ - @checkstack/command-backend@0.1.22
161
+ - @checkstack/cache-utils@0.2.2
162
+
3
163
  ## 0.7.1
4
164
 
5
165
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/catalog-backend",
3
- "version": "0.7.1",
3
+ "version": "1.0.0",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "checkstack": {
@@ -13,16 +13,16 @@
13
13
  "lint:code": "eslint . --max-warnings 0"
14
14
  },
15
15
  "dependencies": {
16
- "@checkstack/backend-api": "0.13.0",
17
- "@checkstack/cache-api": "0.2.0",
18
- "@checkstack/cache-utils": "0.2.0",
16
+ "@checkstack/backend-api": "0.13.1",
17
+ "@checkstack/cache-api": "0.2.1",
18
+ "@checkstack/cache-utils": "0.2.1",
19
19
  "@checkstack/auth-common": "0.6.3",
20
- "@checkstack/catalog-common": "1.5.2",
21
- "@checkstack/command-backend": "0.1.20",
22
- "@checkstack/auth-backend": "0.4.20",
23
- "@checkstack/gitops-backend": "0.2.4",
20
+ "@checkstack/catalog-common": "1.5.3",
21
+ "@checkstack/command-backend": "0.1.21",
22
+ "@checkstack/auth-backend": "0.4.21",
23
+ "@checkstack/gitops-backend": "0.2.5",
24
24
  "@checkstack/gitops-common": "0.2.1",
25
- "@checkstack/notification-common": "0.2.9",
25
+ "@checkstack/notification-common": "0.3.0",
26
26
  "@orpc/server": "^1.13.2",
27
27
  "drizzle-orm": "^0.45.0",
28
28
  "hono": "^4.12.14",
@@ -37,6 +37,7 @@
37
37
  "@types/bun": "^1.3.5",
38
38
  "@types/node": "^20.0.0",
39
39
  "@types/uuid": "^11.0.0",
40
+ "drizzle-kit": "^0.31.10",
40
41
  "typescript": "^5.0.0"
41
42
  }
42
43
  }
package/src/hooks.ts CHANGED
@@ -4,6 +4,16 @@ import { createHook } from "@checkstack/backend-api";
4
4
  * Catalog hooks for cross-plugin communication
5
5
  */
6
6
  export const catalogHooks = {
7
+ /**
8
+ * Emitted when a system is created.
9
+ * Plugins can subscribe (work-queue mode) to bootstrap related state
10
+ * (e.g. per-system notification groups).
11
+ */
12
+ systemCreated: createHook<{
13
+ systemId: string;
14
+ systemName: string;
15
+ }>("catalog.system.created"),
16
+
7
17
  /**
8
18
  * Emitted when a system is deleted.
9
19
  * Plugins can subscribe (work-queue mode) to clean up related data.
@@ -13,6 +23,16 @@ export const catalogHooks = {
13
23
  systemName?: string;
14
24
  }>("catalog.system.deleted"),
15
25
 
26
+ /**
27
+ * Emitted when a catalog group is created.
28
+ * Plugins can subscribe to bootstrap related state (e.g. anomaly creates
29
+ * its own per-group notification group on this signal).
30
+ */
31
+ groupCreated: createHook<{
32
+ groupId: string;
33
+ groupName: string;
34
+ }>("catalog.group.created"),
35
+
16
36
  /**
17
37
  * Emitted when a group is deleted.
18
38
  * Plugins can subscribe (work-queue mode) to clean up related data.
package/src/index.ts CHANGED
@@ -9,10 +9,15 @@ import {
9
9
  pluginMetadata,
10
10
  catalogContract,
11
11
  catalogRoutes,
12
+ catalogSystemTarget,
13
+ catalogGroupTarget,
12
14
  } from "@checkstack/catalog-common";
13
15
  import { createCatalogRouter } from "./router";
14
16
  import { createCatalogCache } from "./cache";
15
- import { NotificationApi } from "@checkstack/notification-common";
17
+ import {
18
+ NotificationApi,
19
+ targetToRegistration,
20
+ } from "@checkstack/notification-common";
16
21
  import { AuthApi } from "@checkstack/auth-common";
17
22
  import { authHooks } from "@checkstack/auth-backend";
18
23
  import { resolveRoute, type InferClient, extractErrorMessage} from "@checkstack/common";
@@ -270,8 +275,12 @@ export default createBackendPlugin({
270
275
  const typedDb = database as SafeDatabase<typeof schema>;
271
276
  const notificationClient = rpcClient.forPlugin(NotificationApi);
272
277
 
273
- // Bootstrap: Create notification groups for existing systems and groups
274
- await bootstrapNotificationGroups(typedDb, notificationClient, logger);
278
+ // Catalog owns the platform's "system" and "group" notification
279
+ // target types. Register both target types, then push the
280
+ // current set of resources + parent edges to notification-
281
+ // backend in one shot. Per-resource notification group
282
+ // provisioning happens server-side from this signal.
283
+ await bootstrapNotificationTargets(typedDb, notificationClient, logger);
275
284
 
276
285
  // Subscribe to user deletion to clean up user contacts
277
286
  onHook(
@@ -290,45 +299,77 @@ export default createBackendPlugin({
290
299
  });
291
300
 
292
301
  /**
293
- * Bootstrap notification groups for existing catalog entities
302
+ * Register the catalog target types and seed every existing system /
303
+ * group as a known resource. Runs every startup (idempotent on the
304
+ * notification-backend side via primary-key upsert) so resources stay
305
+ * in sync even if catalog and notification-backend are restarted in
306
+ * different orders.
294
307
  */
295
- async function bootstrapNotificationGroups(
308
+ async function bootstrapNotificationTargets(
296
309
  database: SafeDatabase<typeof schema>,
297
310
  notificationClient: InferClient<typeof NotificationApi>,
298
311
  logger: { debug: (msg: string) => void },
299
312
  ) {
300
313
  try {
301
- // Get all existing systems and groups
314
+ await Promise.all([
315
+ notificationClient.registerNotificationTarget(
316
+ targetToRegistration(catalogSystemTarget),
317
+ ),
318
+ notificationClient.registerNotificationTarget(
319
+ targetToRegistration(catalogGroupTarget),
320
+ ),
321
+ ]);
322
+
302
323
  const systems = await database.select().from(schema.systems);
303
324
  const groups = await database.select().from(schema.groups);
304
325
 
305
- // Create notification groups for each system
306
- for (const system of systems) {
307
- await notificationClient.createGroup({
308
- groupId: `system.${system.id}`,
309
- name: `${system.name} Notifications`,
310
- description: `Notifications for the ${system.name} system`,
311
- ownerPlugin: pluginMetadata.pluginId,
326
+ if (systems.length > 0) {
327
+ await notificationClient.upsertNotificationResources({
328
+ targetTypeId: catalogSystemTarget.targetTypeId,
329
+ resources: systems.map((s) => ({
330
+ resourceKey: s.id,
331
+ displayLabel: s.name,
332
+ })),
333
+ });
334
+ }
335
+
336
+ if (groups.length > 0) {
337
+ await notificationClient.upsertNotificationResources({
338
+ targetTypeId: catalogGroupTarget.targetTypeId,
339
+ resources: groups.map((g) => ({
340
+ resourceKey: g.id,
341
+ displayLabel: g.name,
342
+ })),
312
343
  });
313
344
  }
314
345
 
315
- // Create notification groups for each catalog group
316
- for (const group of groups) {
317
- await notificationClient.createGroup({
318
- groupId: `group.${group.id}`,
319
- name: `${group.name} Notifications`,
320
- description: `Notifications for the ${group.name} group`,
321
- ownerPlugin: pluginMetadata.pluginId,
346
+ // Seed parent edges from the systems_groups join table so the
347
+ // dispatcher can walk inheritance without re-querying catalog at
348
+ // notification time.
349
+ const memberships = await database.select().from(schema.systemsGroups);
350
+ const parentsBySystem = new Map<string, string[]>();
351
+ for (const m of memberships) {
352
+ const arr = parentsBySystem.get(m.systemId) ?? [];
353
+ arr.push(m.groupId);
354
+ parentsBySystem.set(m.systemId, arr);
355
+ }
356
+ for (const [systemId, parentGroupIds] of parentsBySystem) {
357
+ await notificationClient.setNotificationResourceParents({
358
+ childTargetTypeId: catalogSystemTarget.targetTypeId,
359
+ childResourceKey: systemId,
360
+ parents: parentGroupIds.map((groupId) => ({
361
+ parentTargetTypeId: catalogGroupTarget.targetTypeId,
362
+ parentResourceKey: groupId,
363
+ })),
322
364
  });
323
365
  }
324
366
 
325
367
  logger.debug(
326
- `Bootstrapped notification groups for ${systems.length} systems and ${groups.length} groups`,
368
+ `Bootstrapped notification targets: ${systems.length} systems, ${groups.length} groups, ${memberships.length} parent edges`,
327
369
  );
328
370
  } catch (error) {
329
- // Don't fail startup if notification service is unavailable
330
371
  logger.debug(
331
- `Failed to bootstrap notification groups: ${
372
+ `Failed to bootstrap notification targets: ${
332
373
  extractErrorMessage(error, "Unknown error")
333
374
  }`,
334
375
  );
package/src/router.ts CHANGED
@@ -2,6 +2,8 @@ import { implement, ORPCError } from "@orpc/server";
2
2
  import { autoAuthMiddleware, type RpcContext } from "@checkstack/backend-api";
3
3
  import {
4
4
  catalogContract,
5
+ catalogSystemTarget,
6
+ catalogGroupTarget,
5
7
  type SystemContact,
6
8
  } from "@checkstack/catalog-common";
7
9
  import { EntityService } from "./services/entity-service";
@@ -39,7 +41,7 @@ export const createCatalogRouter = ({
39
41
  notificationClient,
40
42
  authClient,
41
43
  gitOpsClient,
42
- pluginId,
44
+ pluginId: _pluginId,
43
45
  cache,
44
46
  }: CatalogRouterDeps) => {
45
47
  const entityService = new EntityService(database);
@@ -56,42 +58,84 @@ export const createCatalogRouter = ({
56
58
  }
57
59
  };
58
60
 
59
- // Helper to create notification group for an entity
60
- const createNotificationGroup = async (
61
- type: "system" | "group",
62
- id: string,
63
- name: string,
64
- ) => {
61
+ // Resource lifecycle: catalog pushes systems and groups into
62
+ // notification-backend's resource registry. The platform takes over
63
+ // from there — registering specs, provisioning per-resource groups,
64
+ // walking parent edges at dispatch time. Catalog never directly
65
+ // creates per-resource notification groups any more.
66
+ const upsertSystemResource = async (system: { id: string; name: string }) => {
65
67
  try {
66
- await notificationClient.createGroup({
67
- groupId: `${type}.${id}`,
68
- name: `${name} Notifications`,
69
- description: `Notifications for the ${name} ${type}`,
70
- ownerPlugin: pluginId,
68
+ await notificationClient.upsertNotificationResource({
69
+ targetTypeId: catalogSystemTarget.targetTypeId,
70
+ resource: { resourceKey: system.id, displayLabel: system.name },
71
71
  });
72
72
  } catch (error) {
73
- // Log but don't fail the operation
74
73
  console.warn(
75
- `Failed to create notification group for ${type} ${id}:`,
74
+ `Failed to upsert notification resource for system ${system.id}:`,
76
75
  error,
77
76
  );
78
77
  }
79
78
  };
80
79
 
81
- // Helper to delete notification group for an entity
82
- const deleteNotificationGroup = async (
83
- type: "system" | "group",
84
- id: string,
85
- ) => {
80
+ const upsertGroupResource = async (group: { id: string; name: string }) => {
86
81
  try {
87
- await notificationClient.deleteGroup({
88
- groupId: `${pluginId}.${type}.${id}`,
89
- ownerPlugin: pluginId,
82
+ await notificationClient.upsertNotificationResource({
83
+ targetTypeId: catalogGroupTarget.targetTypeId,
84
+ resource: { resourceKey: group.id, displayLabel: group.name },
90
85
  });
91
86
  } catch (error) {
92
- // Log but don't fail the operation
93
87
  console.warn(
94
- `Failed to delete notification group for ${type} ${id}:`,
88
+ `Failed to upsert notification resource for catalog group ${group.id}:`,
89
+ error,
90
+ );
91
+ }
92
+ };
93
+
94
+ const refreshSystemParents = async (systemId: string) => {
95
+ try {
96
+ const groups = await entityService.getGroups();
97
+ const parents = groups
98
+ .filter((g) => g.systemIds?.includes(systemId))
99
+ .map((g) => ({
100
+ parentTargetTypeId: catalogGroupTarget.targetTypeId,
101
+ parentResourceKey: g.id,
102
+ }));
103
+ await notificationClient.setNotificationResourceParents({
104
+ childTargetTypeId: catalogSystemTarget.targetTypeId,
105
+ childResourceKey: systemId,
106
+ parents,
107
+ });
108
+ } catch (error) {
109
+ console.warn(
110
+ `Failed to refresh notification parents for system ${systemId}:`,
111
+ error,
112
+ );
113
+ }
114
+ };
115
+
116
+ const removeSystemResource = async (systemId: string) => {
117
+ try {
118
+ await notificationClient.removeNotificationResource({
119
+ targetTypeId: catalogSystemTarget.targetTypeId,
120
+ resourceKey: systemId,
121
+ });
122
+ } catch (error) {
123
+ console.warn(
124
+ `Failed to remove notification resource for system ${systemId}:`,
125
+ error,
126
+ );
127
+ }
128
+ };
129
+
130
+ const removeGroupResource = async (groupId: string) => {
131
+ try {
132
+ await notificationClient.removeNotificationResource({
133
+ targetTypeId: catalogGroupTarget.targetTypeId,
134
+ resourceKey: groupId,
135
+ });
136
+ } catch (error) {
137
+ console.warn(
138
+ `Failed to remove notification resource for catalog group ${groupId}:`,
95
139
  error,
96
140
  );
97
141
  }
@@ -154,17 +198,45 @@ export const createCatalogRouter = ({
154
198
  }),
155
199
  );
156
200
 
157
- const createSystem = os.createSystem.handler(async ({ input }) => {
201
+ const getSystemGroups = os.getSystemGroups.handler(
202
+ async ({ input }) => {
203
+ // Fetch all groups (cache-warm), then filter to those that contain
204
+ // the system. This is cheaper than a per-system join because
205
+ // `getGroups()` already produces the full populated list and is
206
+ // cached topology-wide; per-system mutations invalidate this cache
207
+ // alongside everything else.
208
+ const groups = await entityService.getGroups();
209
+ const filtered = groups.filter((group) =>
210
+ group.systemIds?.includes(input.systemId),
211
+ );
212
+ return filtered as unknown as Array<
213
+ (typeof filtered)[number] & {
214
+ metadata: Record<string, unknown> | null;
215
+ }
216
+ >;
217
+ },
218
+ );
219
+
220
+ const createSystem = os.createSystem.handler(async ({ input, context }) => {
158
221
  const result = await entityService.createSystem(input);
159
222
 
160
- // Create a notification group for this system
161
- await createNotificationGroup("system", result.id, result.name);
223
+ // Push the new system into notification-backend's resource registry.
224
+ // notification-backend handles all per-spec group provisioning from
225
+ // this single signal — catalog never authors notification groups
226
+ // directly.
227
+ await upsertSystemResource({ id: result.id, name: result.name });
228
+ await refreshSystemParents(result.id);
162
229
 
163
- // Drop the topology cache before any downstream side-effect that might
164
- // observe it (notification group creation already happened, but the
165
- // hook chain in deleteSystem/etc. relies on this ordering).
166
230
  await cache.invalidateTopology();
167
231
 
232
+ // Hooks remain for non-notification cleanup concerns (e.g. incident
233
+ // associations) — emitting plugins no longer use them for
234
+ // subscription provisioning.
235
+ await context.emitHook(catalogHooks.systemCreated, {
236
+ systemId: result.id,
237
+ systemName: result.name,
238
+ });
239
+
168
240
  return result as typeof result & {
169
241
  metadata: Record<string, unknown> | null;
170
242
  };
@@ -191,6 +263,11 @@ export const createCatalogRouter = ({
191
263
  });
192
264
  }
193
265
  await cache.invalidateTopology();
266
+ // Refresh display label in notification-backend on rename so the
267
+ // settings/audit UI shows the current name.
268
+ if (input.data.name !== undefined) {
269
+ await upsertSystemResource({ id: result.id, name: result.name });
270
+ }
194
271
  return result as typeof result & {
195
272
  metadata: Record<string, unknown> | null;
196
273
  };
@@ -200,8 +277,7 @@ export const createCatalogRouter = ({
200
277
  await enforceNotGitOpsLocked("System", input);
201
278
  await entityService.deleteSystem(input);
202
279
 
203
- // Delete the notification group for this system
204
- await deleteNotificationGroup("system", input);
280
+ await removeSystemResource(input);
205
281
 
206
282
  // Drop catalog topology + this system's contacts BEFORE the hook fires,
207
283
  // so downstream plugins (e.g. healthcheck) and any frontend that
@@ -217,17 +293,21 @@ export const createCatalogRouter = ({
217
293
  return { success: true };
218
294
  });
219
295
 
220
- const createGroup = os.createGroup.handler(async ({ input }) => {
296
+ const createGroup = os.createGroup.handler(async ({ input, context }) => {
221
297
  const result = await entityService.createGroup({
222
298
  name: input.name,
223
299
  metadata: input.metadata,
224
300
  });
225
301
 
226
- // Create a notification group for this catalog group
227
- await createNotificationGroup("group", result.id, result.name);
302
+ await upsertGroupResource({ id: result.id, name: result.name });
228
303
 
229
304
  await cache.invalidateTopology();
230
305
 
306
+ await context.emitHook(catalogHooks.groupCreated, {
307
+ groupId: result.id,
308
+ groupName: result.name,
309
+ });
310
+
231
311
  // New groups have no systems yet
232
312
  return {
233
313
  ...result,
@@ -258,6 +338,9 @@ export const createCatalogRouter = ({
258
338
  });
259
339
  }
260
340
  await cache.invalidateTopology();
341
+ if (input.data.name !== undefined) {
342
+ await upsertGroupResource({ id: fullGroup.id, name: fullGroup.name });
343
+ }
261
344
  return fullGroup as unknown as typeof fullGroup & {
262
345
  metadata: Record<string, unknown> | null;
263
346
  };
@@ -267,8 +350,7 @@ export const createCatalogRouter = ({
267
350
  await enforceNotGitOpsLocked("Group", input);
268
351
  await entityService.deleteGroup(input);
269
352
 
270
- // Delete the notification group for this catalog group
271
- await deleteNotificationGroup("group", input);
353
+ await removeGroupResource(input);
272
354
 
273
355
  await cache.invalidateTopology();
274
356
 
@@ -279,23 +361,21 @@ export const createCatalogRouter = ({
279
361
  });
280
362
 
281
363
  const addSystemToGroup = os.addSystemToGroup.handler(async ({ input }) => {
282
- // Note: We only enforce the lock on the System, not the Group.
283
- // This is because system-group associations are reconciled as a kindExtension
284
- // of the System kind. The Group reconciler does not touch associations.
285
- // Thus, it is perfectly safe (and intended) to manually add an unlocked System
286
- // to a GitOps-managed Group.
287
364
  await enforceNotGitOpsLocked("System", input.systemId);
288
365
  await entityService.addSystemToGroup(input);
289
366
  await cache.invalidateTopology();
367
+ // Push refreshed parent edges so notification-backend's dispatcher
368
+ // walks the new membership when computing inherited subscribers.
369
+ await refreshSystemParents(input.systemId);
290
370
  return { success: true };
291
371
  });
292
372
 
293
373
  const removeSystemFromGroup = os.removeSystemFromGroup.handler(
294
374
  async ({ input }) => {
295
- // See addSystemToGroup for why we only check the System provenance lock.
296
375
  await enforceNotGitOpsLocked("System", input.systemId);
297
376
  await entityService.removeSystemFromGroup(input);
298
377
  await cache.invalidateTopology();
378
+ await refreshSystemParents(input.systemId);
299
379
  return { success: true };
300
380
  },
301
381
  );
@@ -417,51 +497,6 @@ export const createCatalogRouter = ({
417
497
  },
418
498
  );
419
499
 
420
- /**
421
- * Notify all users subscribed to a system (and optionally its groups).
422
- * Delegates deduplication to notification-backend via notifyGroups RPC.
423
- */
424
- const notifySystemSubscribers = os.notifySystemSubscribers.handler(
425
- async ({ input }) => {
426
- const {
427
- systemId,
428
- title,
429
- body,
430
- importance,
431
- action,
432
- includeGroupSubscribers,
433
- } = input;
434
-
435
- // Collect all notification group IDs to notify
436
- // Start with the system's notification group
437
- const groupIds = [`${pluginId}.system.${systemId}`];
438
-
439
- // If includeGroupSubscribers is true, add groups containing this system
440
- if (includeGroupSubscribers) {
441
- const systemGroups = await database
442
- .select({ groupId: schema.systemsGroups.groupId })
443
- .from(schema.systemsGroups)
444
- .where(eq(schema.systemsGroups.systemId, systemId));
445
-
446
- // Spread to avoid mutation
447
- groupIds.push(
448
- ...systemGroups.map(({ groupId }) => `${pluginId}.group.${groupId}`),
449
- );
450
- }
451
-
452
- // 3. Send to notification-backend, which handles deduplication
453
- const result = await notificationClient.notifyGroups({
454
- groupIds,
455
- title,
456
- body,
457
- importance: importance ?? "info",
458
- action,
459
- });
460
-
461
- return { notifiedCount: result.notifiedCount };
462
- },
463
- );
464
-
465
500
  /**
466
501
  * Get the catalog group IDs that contain a specific system.
467
502
  * Used by the dependency plugin for batched notification deduplication.
@@ -484,6 +519,7 @@ export const createCatalogRouter = ({
484
519
  getSystems,
485
520
  getSystem,
486
521
  getGroups,
522
+ getSystemGroups,
487
523
  createSystem,
488
524
  updateSystem,
489
525
  deleteSystem,
@@ -497,7 +533,6 @@ export const createCatalogRouter = ({
497
533
  removeSystemFromGroup,
498
534
  getViews,
499
535
  createView,
500
- notifySystemSubscribers,
501
536
  getSystemGroupIds,
502
537
  });
503
538
  };