@checkstack/catalog-backend 0.6.1 → 0.7.1

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,112 @@
1
1
  # @checkstack/catalog-backend
2
2
 
3
+ ## 0.7.1
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [208ad71]
8
+ - @checkstack/notification-common@0.3.0
9
+ - @checkstack/backend-api@0.13.1
10
+ - @checkstack/catalog-common@1.5.3
11
+ - @checkstack/auth-backend@0.4.21
12
+ - @checkstack/cache-api@0.2.1
13
+ - @checkstack/command-backend@0.1.21
14
+ - @checkstack/gitops-backend@0.2.5
15
+ - @checkstack/cache-utils@0.2.1
16
+
17
+ ## 0.7.0
18
+
19
+ ### Minor Changes
20
+
21
+ - 8d1ef12: ## Per-entity caching with single-flight + safe invalidation across the dashboard hot paths
22
+
23
+ ### `@checkstack/cache-api`
24
+
25
+ - **Breaking** for backend implementors: `CacheProvider` now requires `deleteByPrefix(prefix: string): Promise<number>` for family-level invalidation. The in-memory provider implements it; downstream providers (Redis, etc.) must add it before upgrading.
26
+ - `createScopedCache` forwards `deleteByPrefix` and keeps prefixes scoped to the calling plugin.
27
+
28
+ ### `@checkstack/cache-utils` (new package)
29
+
30
+ High-level read-through caching helpers built on `CacheProvider`:
31
+
32
+ - `createCachedScope({ cacheManager, pluginId })` returns a scope with `wrap`, `wrapMany`, `invalidate`, and `invalidatePrefix`.
33
+ - **Single-flight**: concurrent cache misses for the same key share one loader.
34
+ - **Per-entity bulk caching** via `wrapMany` so list/bulk RPCs cache by id rather than by the full input shape — overlapping callers share entries and invalidation stays exact.
35
+ - **Race-safe invalidation** via per-key epoch counters: a loader started before a mutation cannot repopulate the cache with stale data after the mutation invalidates it. The mutation invariant is `db.write → cache.invalidate (await) → signals.emit`.
36
+ - Cache failures fall through to the loader so a cache outage cannot break reads.
37
+
38
+ ### `@checkstack/backend`
39
+
40
+ - The internal null `CacheProvider` (used when no cache backend is configured) now implements the new `deleteByPrefix` method as a no-op. Patch bump only — no behavior change for existing callers.
41
+
42
+ ### `@checkstack/healthcheck-backend`
43
+
44
+ - `getSystemHealthStatus` and `getBulkSystemHealthStatus` now read through a per-system cache (`healthcheck:status:<systemId>`), eliminating N database queries per dashboard refresh for unchanged systems.
45
+ - Mutation paths (configuration CRUD, system associations, satellite ingest, queue-driven check runs, system/satellite removal hooks) invalidate affected keys before broadcasting their signals so frontend refetches always observe fresh data.
46
+
47
+ ### `@checkstack/incident-backend`
48
+
49
+ - `listIncidents`, `getIncident`, `getIncidentsForSystem`, and `getBulkIncidentsForSystems` now read through a scoped cache:
50
+ - per-incident at `incident:<id>`
51
+ - per-system at `system:<systemId>`
52
+ - per-filter-shape at `list:<stable-stringify(filters)>` for the few list shapes the dashboard polls
53
+ - Mutations (`createIncident`, `updateIncident`, `addUpdate`, `resolveIncident`, `deleteIncident`) invalidate the incident, every affected system, and every cached list before broadcasting `INCIDENT_UPDATED`.
54
+ - The catalog `systemDeleted` cleanup hook drops that system's cached entries.
55
+
56
+ ### `@checkstack/maintenance-backend`
57
+
58
+ - `listMaintenances`, `getMaintenance`, `getMaintenancesForSystem`, and `getBulkMaintenancesForSystems` use the same per-entity / per-system / per-filter-shape pattern as incidents.
59
+ - Mutations (`createMaintenance`, `updateMaintenance`, `addUpdate`, `closeMaintenance`, `deleteMaintenance`) invalidate before broadcasting `MAINTENANCE_UPDATED`.
60
+
61
+ ### `@checkstack/catalog-backend`
62
+
63
+ - Topology reads (`getEntities`, `getSystems`, `getSystem`, `getGroups`, `getSystemGroupIds`) cache under the `entity:` family (25s TTL).
64
+ - Views (`getViews`) and per-system contacts (`getSystemContacts`) cache in their own families.
65
+ - System / group / membership mutations drop the entire `entity:` family (every reader joins the same tables); view and contact mutations drop only their respective scopes.
66
+
67
+ ### `@checkstack/slo-backend`
68
+
69
+ - `listObjectives`, `getObjective`, `getObjectivesForSystem`, and `getBulkObjectivesForSystems` cache results including the expensive `engine.computeStatus` output.
70
+ - Per-entity caching for the bulk handler so dashboards with overlapping system sets share entries.
71
+ - Mutations (`createObjective`, `updateObjective`, `deleteObjective`) invalidate before broadcasting `SLO_STATUS_CHANGED`.
72
+
73
+ ### `@checkstack/anomaly-backend`
74
+
75
+ - New `router-cache.ts` adds a cache scope distinct from the existing detector baseline cache, keyed by stable filter hash.
76
+ - `getAnomalies` and `getAnomalyBaselines` cache through this scope (15s TTL).
77
+ - The detector invalidates the router cache before broadcasting `ANOMALY_STATE_CHANGED` on every state transition (suspicious/anomaly/recovered).
78
+ - Config mutations also invalidate.
79
+
80
+ ### `@checkstack/notification-backend`
81
+
82
+ - `getUnreadCount`, `getNotifications`, and `getSubscriptions` cache per-user.
83
+ - `markAsRead`, `deleteNotification`, `notifyUsers`, and `notifyGroups` invalidate every affected user's cache before sending realtime signals to that user.
84
+ - `subscribe` and `unsubscribe` invalidate the user's subscription cache.
85
+
86
+ ### `@checkstack/announcement-backend`
87
+
88
+ - `getActiveAnnouncements` caches per-user (or anonymous) and per-`includeDismissed` flag (45s TTL — admin-driven, slowly changing).
89
+ - `listAllAnnouncements` caches under a single key.
90
+ - `dismissAnnouncement` only drops that user's cache; `createAnnouncement`, `updateAnnouncement`, `deleteAnnouncement` drop every user's cache before broadcasting `ANNOUNCEMENT_UPDATED`.
91
+ - The auth `userDeleted` cleanup hook drops that user's cached entries.
92
+
93
+ ### Patch Changes
94
+
95
+ - Updated dependencies [8d1ef12]
96
+ - Updated dependencies [8d1ef12]
97
+ - Updated dependencies [8d1ef12]
98
+ - @checkstack/common@0.7.0
99
+ - @checkstack/cache-api@0.2.0
100
+ - @checkstack/cache-utils@0.2.0
101
+ - @checkstack/backend-api@0.13.0
102
+ - @checkstack/auth-backend@0.4.20
103
+ - @checkstack/auth-common@0.6.3
104
+ - @checkstack/catalog-common@1.5.2
105
+ - @checkstack/command-backend@0.1.20
106
+ - @checkstack/gitops-backend@0.2.4
107
+ - @checkstack/gitops-common@0.2.1
108
+ - @checkstack/notification-common@0.2.9
109
+
3
110
  ## 0.6.1
4
111
 
5
112
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/catalog-backend",
3
- "version": "0.6.1",
3
+ "version": "0.7.1",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "checkstack": {
@@ -13,20 +13,22 @@
13
13
  "lint:code": "eslint . --max-warnings 0"
14
14
  },
15
15
  "dependencies": {
16
- "@checkstack/backend-api": "0.12.0",
17
- "@checkstack/auth-common": "0.6.2",
18
- "@checkstack/catalog-common": "1.4.1",
19
- "@checkstack/command-backend": "0.1.19",
20
- "@checkstack/auth-backend": "0.4.19",
21
- "@checkstack/gitops-backend": "0.2.3",
22
- "@checkstack/gitops-common": "0.2.0",
23
- "@checkstack/notification-common": "0.2.8",
16
+ "@checkstack/backend-api": "0.13.0",
17
+ "@checkstack/cache-api": "0.2.0",
18
+ "@checkstack/cache-utils": "0.2.0",
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",
24
+ "@checkstack/gitops-common": "0.2.1",
25
+ "@checkstack/notification-common": "0.2.9",
24
26
  "@orpc/server": "^1.13.2",
25
27
  "drizzle-orm": "^0.45.0",
26
28
  "hono": "^4.12.14",
27
29
  "uuid": "^13.0.0",
28
30
  "zod": "^4.2.1",
29
- "@checkstack/common": "0.6.5"
31
+ "@checkstack/common": "0.7.0"
30
32
  },
31
33
  "devDependencies": {
32
34
  "@checkstack/drizzle-helper": "0.0.4",
package/src/cache.ts ADDED
@@ -0,0 +1,94 @@
1
+ import type { CacheManager } from "@checkstack/cache-api";
2
+ import {
3
+ createCachedScope,
4
+ type CachedScope,
5
+ } from "@checkstack/cache-utils";
6
+ import type { Logger } from "@checkstack/backend-api";
7
+
8
+ /**
9
+ * 25s — slightly under the dashboard's 30s `staleTime`. Catalog mutations
10
+ * are infrequent (admin operations), so signal-driven invalidation is the
11
+ * primary mechanism and TTL is a long safety net.
12
+ */
13
+ const CATALOG_TTL_MS = 25_000;
14
+
15
+ /** Topology family: entities, systems, groups, per-system, groups-for-system. */
16
+ const ENTITY_PREFIX = "entity:";
17
+ const VIEW_PREFIX = "view:";
18
+ const CONTACTS_PREFIX = "contacts:";
19
+
20
+ const ENTITIES_KEY = `${ENTITY_PREFIX}entities`;
21
+ const SYSTEMS_KEY = `${ENTITY_PREFIX}systems`;
22
+ const GROUPS_KEY = `${ENTITY_PREFIX}groups`;
23
+ const VIEWS_KEY = `${VIEW_PREFIX}all`;
24
+
25
+ const systemKey = (id: string): string => `${ENTITY_PREFIX}system:${id}`;
26
+ const groupsForSystemKey = (systemId: string): string =>
27
+ `${ENTITY_PREFIX}groups-for-system:${systemId}`;
28
+ const contactsKey = (systemId: string): string =>
29
+ `${CONTACTS_PREFIX}${systemId}`;
30
+
31
+ export interface CatalogCache {
32
+ /** Read-through caches for the dashboard hot paths. */
33
+ wrapEntities: <T>(loader: () => Promise<T>) => Promise<T>;
34
+ wrapSystems: <T>(loader: () => Promise<T>) => Promise<T>;
35
+ wrapGroups: <T>(loader: () => Promise<T>) => Promise<T>;
36
+ wrapSystem: <T>(systemId: string, loader: () => Promise<T>) => Promise<T>;
37
+ wrapGroupsForSystem: <T>(
38
+ systemId: string,
39
+ loader: () => Promise<T>,
40
+ ) => Promise<T>;
41
+ wrapViews: <T>(loader: () => Promise<T>) => Promise<T>;
42
+ wrapContacts: <T>(systemId: string, loader: () => Promise<T>) => Promise<T>;
43
+
44
+ /**
45
+ * Drop the whole topology family (entities, systems, groups, per-system,
46
+ * groups-for-system). Used by every system/group/membership mutation —
47
+ * `getEntities` already joins all four tables, so any change to any of
48
+ * them logically affects every cached topology shape.
49
+ */
50
+ invalidateTopology: () => Promise<number>;
51
+
52
+ /** Drop the views cache. */
53
+ invalidateViews: () => Promise<number>;
54
+
55
+ /** Drop one system's contact cache. */
56
+ invalidateContacts: (systemId: string) => Promise<void>;
57
+
58
+ scope: CachedScope;
59
+ }
60
+
61
+ export function createCatalogCache({
62
+ cacheManager,
63
+ logger,
64
+ }: {
65
+ cacheManager: CacheManager;
66
+ logger: Logger;
67
+ }): CatalogCache {
68
+ const scope = createCachedScope({
69
+ cacheManager,
70
+ pluginId: "catalog",
71
+ defaultTtlMs: CATALOG_TTL_MS,
72
+ onError: (op: string, error: unknown) => {
73
+ logger.warn(`catalog cache ${op} failed: ${String(error)}`);
74
+ },
75
+ });
76
+
77
+ return {
78
+ wrapEntities: (loader) => scope.wrap(ENTITIES_KEY, loader),
79
+ wrapSystems: (loader) => scope.wrap(SYSTEMS_KEY, loader),
80
+ wrapGroups: (loader) => scope.wrap(GROUPS_KEY, loader),
81
+ wrapSystem: (systemId, loader) => scope.wrap(systemKey(systemId), loader),
82
+ wrapGroupsForSystem: (systemId, loader) =>
83
+ scope.wrap(groupsForSystemKey(systemId), loader),
84
+ wrapViews: (loader) => scope.wrap(VIEWS_KEY, loader),
85
+ wrapContacts: (systemId, loader) =>
86
+ scope.wrap(contactsKey(systemId), loader),
87
+
88
+ invalidateTopology: () => scope.invalidatePrefix(ENTITY_PREFIX),
89
+ invalidateViews: () => scope.invalidatePrefix(VIEW_PREFIX),
90
+ invalidateContacts: (systemId) => scope.invalidate(contactsKey(systemId)),
91
+
92
+ scope,
93
+ };
94
+ }
package/src/index.ts CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  catalogRoutes,
12
12
  } from "@checkstack/catalog-common";
13
13
  import { createCatalogRouter } from "./router";
14
+ import { createCatalogCache } from "./cache";
14
15
  import { NotificationApi } from "@checkstack/notification-common";
15
16
  import { AuthApi } from "@checkstack/auth-common";
16
17
  import { authHooks } from "@checkstack/auth-backend";
@@ -180,9 +181,10 @@ export default createBackendPlugin({
180
181
  rpc: coreServices.rpc,
181
182
  rpcClient: coreServices.rpcClient,
182
183
  logger: coreServices.logger,
184
+ cacheManager: coreServices.cacheManager,
183
185
  },
184
186
  // Phase 2: Register router only - no RPC calls to other plugins
185
- init: async ({ database, rpc, rpcClient, logger }) => {
187
+ init: async ({ database, rpc, rpcClient, logger, cacheManager }) => {
186
188
  logger.debug("Initializing Catalog Backend...");
187
189
 
188
190
  // Populate the mutable DB reference for GitOps reconcile closures
@@ -195,6 +197,8 @@ export default createBackendPlugin({
195
197
  const authClient = rpcClient.forPlugin(AuthApi);
196
198
  const gitOpsClient = rpcClient.forPlugin(GitOpsApi);
197
199
 
200
+ const cache = createCatalogCache({ cacheManager, logger });
201
+
198
202
  // Register oRPC router with notification client and auth client
199
203
  const catalogRouter = createCatalogRouter({
200
204
  database: typedDb,
@@ -202,6 +206,7 @@ export default createBackendPlugin({
202
206
  authClient,
203
207
  gitOpsClient,
204
208
  pluginId: pluginMetadata.pluginId,
209
+ cache,
205
210
  });
206
211
  rpc.registerRouter(catalogRouter, catalogContract);
207
212
 
@@ -2,6 +2,21 @@ import { describe, it, expect, mock } from "bun:test";
2
2
  import { createCatalogRouter } from "./router";
3
3
  import { createMockRpcContext } from "@checkstack/backend-api";
4
4
  import { call } from "@orpc/server";
5
+ import type { CatalogCache } from "./cache";
6
+
7
+ const passthroughCache: CatalogCache = {
8
+ wrapEntities: (loader) => loader(),
9
+ wrapSystems: (loader) => loader(),
10
+ wrapGroups: (loader) => loader(),
11
+ wrapSystem: (_systemId, loader) => loader(),
12
+ wrapGroupsForSystem: (_systemId, loader) => loader(),
13
+ wrapViews: (loader) => loader(),
14
+ wrapContacts: (_systemId, loader) => loader(),
15
+ invalidateTopology: async () => 0,
16
+ invalidateViews: async () => 0,
17
+ invalidateContacts: async () => {},
18
+ scope: {} as CatalogCache["scope"],
19
+ };
5
20
 
6
21
  describe("Catalog Router - GitOps Provenance Enforcement", () => {
7
22
  const mockUser = {
@@ -39,6 +54,7 @@ describe("Catalog Router - GitOps Provenance Enforcement", () => {
39
54
  authClient: mockAuthClient as never,
40
55
  gitOpsClient: mockGitOpsClient as never,
41
56
  pluginId: "test-catalog",
57
+ cache: passthroughCache,
42
58
  });
43
59
 
44
60
  it("allows deleteSystem when GitOps lock is not present", async () => {
package/src/router.ts CHANGED
@@ -13,6 +13,7 @@ import type { InferClient } from "@checkstack/common";
13
13
  import { catalogHooks } from "./hooks";
14
14
  import { eq } from "drizzle-orm";
15
15
  import { GitOpsApi } from "@checkstack/gitops-common";
16
+ import type { CatalogCache } from "./cache";
16
17
 
17
18
  /**
18
19
  * Creates the catalog router using contract-based implementation.
@@ -30,6 +31,7 @@ export interface CatalogRouterDeps {
30
31
  authClient: InferClient<typeof AuthApi>;
31
32
  gitOpsClient: InferClient<typeof GitOpsApi>;
32
33
  pluginId: string;
34
+ cache: CatalogCache;
33
35
  }
34
36
 
35
37
  export const createCatalogRouter = ({
@@ -38,6 +40,7 @@ export const createCatalogRouter = ({
38
40
  authClient,
39
41
  gitOpsClient,
40
42
  pluginId,
43
+ cache,
41
44
  }: CatalogRouterDeps) => {
42
45
  const entityService = new EntityService(database);
43
46
 
@@ -95,49 +98,61 @@ export const createCatalogRouter = ({
95
98
  };
96
99
 
97
100
  // Implement each contract method
98
- const getEntities = os.getEntities.handler(async () => {
99
- const systems = await entityService.getSystems();
100
- const groups = await entityService.getGroups();
101
- // Cast to match contract - Drizzle json() returns unknown, but we expect Record | null
102
- return {
103
- systems: systems as unknown as Array<
104
- (typeof systems)[number] & {
105
- metadata: Record<string, unknown> | null;
106
- }
107
- >,
108
- groups: groups as unknown as Array<
109
- (typeof groups)[number] & { metadata: Record<string, unknown> | null }
110
- >,
111
- };
112
- });
101
+ const getEntities = os.getEntities.handler(async () =>
102
+ cache.wrapEntities(async () => {
103
+ const systems = await entityService.getSystems();
104
+ const groups = await entityService.getGroups();
105
+ // Cast to match contract - Drizzle json() returns unknown, but we expect Record | null
106
+ return {
107
+ systems: systems as unknown as Array<
108
+ (typeof systems)[number] & {
109
+ metadata: Record<string, unknown> | null;
110
+ }
111
+ >,
112
+ groups: groups as unknown as Array<
113
+ (typeof groups)[number] & {
114
+ metadata: Record<string, unknown> | null;
115
+ }
116
+ >,
117
+ };
118
+ }),
119
+ );
113
120
 
114
- const getSystems = os.getSystems.handler(async () => {
115
- const systems = await entityService.getSystems();
116
- return {
117
- systems: systems as unknown as Array<
118
- (typeof systems)[number] & { metadata: Record<string, unknown> | null }
119
- >,
120
- };
121
- });
121
+ const getSystems = os.getSystems.handler(async () =>
122
+ cache.wrapSystems(async () => {
123
+ const systems = await entityService.getSystems();
124
+ return {
125
+ systems: systems as unknown as Array<
126
+ (typeof systems)[number] & {
127
+ metadata: Record<string, unknown> | null;
128
+ }
129
+ >,
130
+ };
131
+ }),
132
+ );
122
133
 
123
- const getSystem = os.getSystem.handler(async ({ input }) => {
124
- const system = await entityService.getSystem(input.systemId);
125
- if (!system) {
126
- // oRPC contract uses .nullable() which requires null
127
- // eslint-disable-next-line unicorn/no-null
128
- return null;
129
- }
130
- return system as typeof system & {
131
- metadata: Record<string, unknown> | null;
132
- };
133
- });
134
+ const getSystem = os.getSystem.handler(async ({ input }) =>
135
+ cache.wrapSystem(input.systemId, async () => {
136
+ const system = await entityService.getSystem(input.systemId);
137
+ if (!system) {
138
+ // oRPC contract uses .nullable() which requires null
139
+ // eslint-disable-next-line unicorn/no-null
140
+ return null;
141
+ }
142
+ return system as typeof system & {
143
+ metadata: Record<string, unknown> | null;
144
+ };
145
+ }),
146
+ );
134
147
 
135
- const getGroups = os.getGroups.handler(async () => {
136
- const groups = await entityService.getGroups();
137
- return groups as unknown as Array<
138
- (typeof groups)[number] & { metadata: Record<string, unknown> | null }
139
- >;
140
- });
148
+ const getGroups = os.getGroups.handler(async () =>
149
+ cache.wrapGroups(async () => {
150
+ const groups = await entityService.getGroups();
151
+ return groups as unknown as Array<
152
+ (typeof groups)[number] & { metadata: Record<string, unknown> | null }
153
+ >;
154
+ }),
155
+ );
141
156
 
142
157
  const createSystem = os.createSystem.handler(async ({ input }) => {
143
158
  const result = await entityService.createSystem(input);
@@ -145,6 +160,11 @@ export const createCatalogRouter = ({
145
160
  // Create a notification group for this system
146
161
  await createNotificationGroup("system", result.id, result.name);
147
162
 
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
+ await cache.invalidateTopology();
167
+
148
168
  return result as typeof result & {
149
169
  metadata: Record<string, unknown> | null;
150
170
  };
@@ -170,6 +190,7 @@ export const createCatalogRouter = ({
170
190
  message: "System not found",
171
191
  });
172
192
  }
193
+ await cache.invalidateTopology();
173
194
  return result as typeof result & {
174
195
  metadata: Record<string, unknown> | null;
175
196
  };
@@ -182,6 +203,14 @@ export const createCatalogRouter = ({
182
203
  // Delete the notification group for this system
183
204
  await deleteNotificationGroup("system", input);
184
205
 
206
+ // Drop catalog topology + this system's contacts BEFORE the hook fires,
207
+ // so downstream plugins (e.g. healthcheck) and any frontend that
208
+ // refetches in response see fresh data.
209
+ await Promise.all([
210
+ cache.invalidateTopology(),
211
+ cache.invalidateContacts(input),
212
+ ]);
213
+
185
214
  // Emit hook for other plugins to clean up related data
186
215
  await context.emitHook(catalogHooks.systemDeleted, { systemId: input });
187
216
 
@@ -197,6 +226,8 @@ export const createCatalogRouter = ({
197
226
  // Create a notification group for this catalog group
198
227
  await createNotificationGroup("group", result.id, result.name);
199
228
 
229
+ await cache.invalidateTopology();
230
+
200
231
  // New groups have no systems yet
201
232
  return {
202
233
  ...result,
@@ -226,6 +257,7 @@ export const createCatalogRouter = ({
226
257
  message: "Group not found after update",
227
258
  });
228
259
  }
260
+ await cache.invalidateTopology();
229
261
  return fullGroup as unknown as typeof fullGroup & {
230
262
  metadata: Record<string, unknown> | null;
231
263
  };
@@ -238,6 +270,8 @@ export const createCatalogRouter = ({
238
270
  // Delete the notification group for this catalog group
239
271
  await deleteNotificationGroup("group", input);
240
272
 
273
+ await cache.invalidateTopology();
274
+
241
275
  // Emit hook for other plugins to clean up related data
242
276
  await context.emitHook(catalogHooks.groupDeleted, { groupId: input });
243
277
 
@@ -248,10 +282,11 @@ export const createCatalogRouter = ({
248
282
  // Note: We only enforce the lock on the System, not the Group.
249
283
  // This is because system-group associations are reconciled as a kindExtension
250
284
  // of the System kind. The Group reconciler does not touch associations.
251
- // Thus, it is perfectly safe (and intended) to manually add an unlocked System
285
+ // Thus, it is perfectly safe (and intended) to manually add an unlocked System
252
286
  // to a GitOps-managed Group.
253
287
  await enforceNotGitOpsLocked("System", input.systemId);
254
288
  await entityService.addSystemToGroup(input);
289
+ await cache.invalidateTopology();
255
290
  return { success: true };
256
291
  });
257
292
 
@@ -260,28 +295,34 @@ export const createCatalogRouter = ({
260
295
  // See addSystemToGroup for why we only check the System provenance lock.
261
296
  await enforceNotGitOpsLocked("System", input.systemId);
262
297
  await entityService.removeSystemFromGroup(input);
298
+ await cache.invalidateTopology();
263
299
  return { success: true };
264
300
  },
265
301
  );
266
302
 
267
- const getViews = os.getViews.handler(async () => entityService.getViews());
303
+ const getViews = os.getViews.handler(async () =>
304
+ cache.wrapViews(() => entityService.getViews()),
305
+ );
268
306
 
269
307
  const createView = os.createView.handler(async ({ input }) => {
270
- return entityService.createView({
308
+ const result = await entityService.createView({
271
309
  name: input.name,
272
310
  type: "custom",
273
311
  config: input.configuration as Record<string, unknown>,
274
312
  });
313
+ await cache.invalidateViews();
314
+ return result;
275
315
  });
276
316
 
277
317
  // System Contacts handlers
278
- const getSystemContacts = os.getSystemContacts.handler(async ({ input }) => {
279
- const rawContacts = await entityService.getContactsForSystem(
280
- input.systemId,
281
- );
318
+ const getSystemContacts = os.getSystemContacts.handler(async ({ input }) =>
319
+ cache.wrapContacts(input.systemId, async () => {
320
+ const rawContacts = await entityService.getContactsForSystem(
321
+ input.systemId,
322
+ );
282
323
 
283
- // Resolve user profiles for user-type contacts
284
- const enrichedContacts: SystemContact[] = await Promise.all(
324
+ // Resolve user profiles for user-type contacts
325
+ const enrichedContacts: SystemContact[] = await Promise.all(
285
326
  rawContacts.map(async (contact) => {
286
327
  if (contact.type === "user" && contact.userId) {
287
328
  // Resolve user profile via auth service
@@ -309,8 +350,9 @@ export const createCatalogRouter = ({
309
350
  }),
310
351
  );
311
352
 
312
- return enrichedContacts;
313
- });
353
+ return enrichedContacts;
354
+ }),
355
+ );
314
356
 
315
357
  const addSystemContact = os.addSystemContact.handler(async ({ input }) => {
316
358
  await enforceNotGitOpsLocked("System", input.systemId);
@@ -334,6 +376,8 @@ export const createCatalogRouter = ({
334
376
  label: input.label,
335
377
  });
336
378
 
379
+ await cache.invalidateContacts(input.systemId);
380
+
337
381
  // Return the enriched contact
338
382
  if (result.type === "user" && result.userId) {
339
383
  const user = await authClient.getUserById({ userId: result.userId });
@@ -366,6 +410,9 @@ export const createCatalogRouter = ({
366
410
  await enforceNotGitOpsLocked("System", contacts[0].systemId);
367
411
  }
368
412
  await entityService.removeContact(input);
413
+ if (contacts[0]) {
414
+ await cache.invalidateContacts(contacts[0].systemId);
415
+ }
369
416
  return { success: true };
370
417
  },
371
418
  );
@@ -420,14 +467,15 @@ export const createCatalogRouter = ({
420
467
  * Used by the dependency plugin for batched notification deduplication.
421
468
  */
422
469
  const getSystemGroupIds = os.getSystemGroupIds.handler(
423
- async ({ input }) => {
424
- const systemGroups = await database
425
- .select({ groupId: schema.systemsGroups.groupId })
426
- .from(schema.systemsGroups)
427
- .where(eq(schema.systemsGroups.systemId, input.systemId));
470
+ async ({ input }) =>
471
+ cache.wrapGroupsForSystem(input.systemId, async () => {
472
+ const systemGroups = await database
473
+ .select({ groupId: schema.systemsGroups.groupId })
474
+ .from(schema.systemsGroups)
475
+ .where(eq(schema.systemsGroups.systemId, input.systemId));
428
476
 
429
- return { groupIds: systemGroups.map((sg) => sg.groupId) };
430
- },
477
+ return { groupIds: systemGroups.map((sg) => sg.groupId) };
478
+ }),
431
479
  );
432
480
 
433
481
  // Build and return the router