@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 +160 -0
- package/package.json +10 -9
- package/src/hooks.ts +20 -0
- package/src/index.ts +64 -23
- package/src/router.ts +124 -89
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.
|
|
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.
|
|
17
|
-
"@checkstack/cache-api": "0.2.
|
|
18
|
-
"@checkstack/cache-utils": "0.2.
|
|
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.
|
|
21
|
-
"@checkstack/command-backend": "0.1.
|
|
22
|
-
"@checkstack/auth-backend": "0.4.
|
|
23
|
-
"@checkstack/gitops-backend": "0.2.
|
|
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.
|
|
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 {
|
|
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
|
-
//
|
|
274
|
-
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
-
//
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
|
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
|
|
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
|
-
//
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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.
|
|
67
|
-
|
|
68
|
-
|
|
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
|
|
74
|
+
`Failed to upsert notification resource for system ${system.id}:`,
|
|
76
75
|
error,
|
|
77
76
|
);
|
|
78
77
|
}
|
|
79
78
|
};
|
|
80
79
|
|
|
81
|
-
|
|
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.
|
|
88
|
-
|
|
89
|
-
|
|
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
|
|
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
|
|
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
|
-
//
|
|
161
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
};
|