@checkstack/catalog-backend 0.6.1 → 0.7.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 +93 -0
- package/package.json +4 -2
- package/src/cache.ts +94 -0
- package/src/index.ts +6 -1
- package/src/router.test.ts +16 -0
- package/src/router.ts +106 -58
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,98 @@
|
|
|
1
1
|
# @checkstack/catalog-backend
|
|
2
2
|
|
|
3
|
+
## 0.7.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 8d1ef12: ## Per-entity caching with single-flight + safe invalidation across the dashboard hot paths
|
|
8
|
+
|
|
9
|
+
### `@checkstack/cache-api`
|
|
10
|
+
|
|
11
|
+
- **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.
|
|
12
|
+
- `createScopedCache` forwards `deleteByPrefix` and keeps prefixes scoped to the calling plugin.
|
|
13
|
+
|
|
14
|
+
### `@checkstack/cache-utils` (new package)
|
|
15
|
+
|
|
16
|
+
High-level read-through caching helpers built on `CacheProvider`:
|
|
17
|
+
|
|
18
|
+
- `createCachedScope({ cacheManager, pluginId })` returns a scope with `wrap`, `wrapMany`, `invalidate`, and `invalidatePrefix`.
|
|
19
|
+
- **Single-flight**: concurrent cache misses for the same key share one loader.
|
|
20
|
+
- **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.
|
|
21
|
+
- **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`.
|
|
22
|
+
- Cache failures fall through to the loader so a cache outage cannot break reads.
|
|
23
|
+
|
|
24
|
+
### `@checkstack/backend`
|
|
25
|
+
|
|
26
|
+
- 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.
|
|
27
|
+
|
|
28
|
+
### `@checkstack/healthcheck-backend`
|
|
29
|
+
|
|
30
|
+
- `getSystemHealthStatus` and `getBulkSystemHealthStatus` now read through a per-system cache (`healthcheck:status:<systemId>`), eliminating N database queries per dashboard refresh for unchanged systems.
|
|
31
|
+
- 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.
|
|
32
|
+
|
|
33
|
+
### `@checkstack/incident-backend`
|
|
34
|
+
|
|
35
|
+
- `listIncidents`, `getIncident`, `getIncidentsForSystem`, and `getBulkIncidentsForSystems` now read through a scoped cache:
|
|
36
|
+
- per-incident at `incident:<id>`
|
|
37
|
+
- per-system at `system:<systemId>`
|
|
38
|
+
- per-filter-shape at `list:<stable-stringify(filters)>` for the few list shapes the dashboard polls
|
|
39
|
+
- Mutations (`createIncident`, `updateIncident`, `addUpdate`, `resolveIncident`, `deleteIncident`) invalidate the incident, every affected system, and every cached list before broadcasting `INCIDENT_UPDATED`.
|
|
40
|
+
- The catalog `systemDeleted` cleanup hook drops that system's cached entries.
|
|
41
|
+
|
|
42
|
+
### `@checkstack/maintenance-backend`
|
|
43
|
+
|
|
44
|
+
- `listMaintenances`, `getMaintenance`, `getMaintenancesForSystem`, and `getBulkMaintenancesForSystems` use the same per-entity / per-system / per-filter-shape pattern as incidents.
|
|
45
|
+
- Mutations (`createMaintenance`, `updateMaintenance`, `addUpdate`, `closeMaintenance`, `deleteMaintenance`) invalidate before broadcasting `MAINTENANCE_UPDATED`.
|
|
46
|
+
|
|
47
|
+
### `@checkstack/catalog-backend`
|
|
48
|
+
|
|
49
|
+
- Topology reads (`getEntities`, `getSystems`, `getSystem`, `getGroups`, `getSystemGroupIds`) cache under the `entity:` family (25s TTL).
|
|
50
|
+
- Views (`getViews`) and per-system contacts (`getSystemContacts`) cache in their own families.
|
|
51
|
+
- System / group / membership mutations drop the entire `entity:` family (every reader joins the same tables); view and contact mutations drop only their respective scopes.
|
|
52
|
+
|
|
53
|
+
### `@checkstack/slo-backend`
|
|
54
|
+
|
|
55
|
+
- `listObjectives`, `getObjective`, `getObjectivesForSystem`, and `getBulkObjectivesForSystems` cache results including the expensive `engine.computeStatus` output.
|
|
56
|
+
- Per-entity caching for the bulk handler so dashboards with overlapping system sets share entries.
|
|
57
|
+
- Mutations (`createObjective`, `updateObjective`, `deleteObjective`) invalidate before broadcasting `SLO_STATUS_CHANGED`.
|
|
58
|
+
|
|
59
|
+
### `@checkstack/anomaly-backend`
|
|
60
|
+
|
|
61
|
+
- New `router-cache.ts` adds a cache scope distinct from the existing detector baseline cache, keyed by stable filter hash.
|
|
62
|
+
- `getAnomalies` and `getAnomalyBaselines` cache through this scope (15s TTL).
|
|
63
|
+
- The detector invalidates the router cache before broadcasting `ANOMALY_STATE_CHANGED` on every state transition (suspicious/anomaly/recovered).
|
|
64
|
+
- Config mutations also invalidate.
|
|
65
|
+
|
|
66
|
+
### `@checkstack/notification-backend`
|
|
67
|
+
|
|
68
|
+
- `getUnreadCount`, `getNotifications`, and `getSubscriptions` cache per-user.
|
|
69
|
+
- `markAsRead`, `deleteNotification`, `notifyUsers`, and `notifyGroups` invalidate every affected user's cache before sending realtime signals to that user.
|
|
70
|
+
- `subscribe` and `unsubscribe` invalidate the user's subscription cache.
|
|
71
|
+
|
|
72
|
+
### `@checkstack/announcement-backend`
|
|
73
|
+
|
|
74
|
+
- `getActiveAnnouncements` caches per-user (or anonymous) and per-`includeDismissed` flag (45s TTL — admin-driven, slowly changing).
|
|
75
|
+
- `listAllAnnouncements` caches under a single key.
|
|
76
|
+
- `dismissAnnouncement` only drops that user's cache; `createAnnouncement`, `updateAnnouncement`, `deleteAnnouncement` drop every user's cache before broadcasting `ANNOUNCEMENT_UPDATED`.
|
|
77
|
+
- The auth `userDeleted` cleanup hook drops that user's cached entries.
|
|
78
|
+
|
|
79
|
+
### Patch Changes
|
|
80
|
+
|
|
81
|
+
- Updated dependencies [8d1ef12]
|
|
82
|
+
- Updated dependencies [8d1ef12]
|
|
83
|
+
- Updated dependencies [8d1ef12]
|
|
84
|
+
- @checkstack/common@0.7.0
|
|
85
|
+
- @checkstack/cache-api@0.2.0
|
|
86
|
+
- @checkstack/cache-utils@0.2.0
|
|
87
|
+
- @checkstack/backend-api@0.13.0
|
|
88
|
+
- @checkstack/auth-backend@0.4.20
|
|
89
|
+
- @checkstack/auth-common@0.6.3
|
|
90
|
+
- @checkstack/catalog-common@1.5.2
|
|
91
|
+
- @checkstack/command-backend@0.1.20
|
|
92
|
+
- @checkstack/gitops-backend@0.2.4
|
|
93
|
+
- @checkstack/gitops-common@0.2.1
|
|
94
|
+
- @checkstack/notification-common@0.2.9
|
|
95
|
+
|
|
3
96
|
## 0.6.1
|
|
4
97
|
|
|
5
98
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@checkstack/catalog-backend",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"checkstack": {
|
|
@@ -14,8 +14,10 @@
|
|
|
14
14
|
},
|
|
15
15
|
"dependencies": {
|
|
16
16
|
"@checkstack/backend-api": "0.12.0",
|
|
17
|
+
"@checkstack/cache-api": "0.1.0",
|
|
18
|
+
"@checkstack/cache-utils": "0.1.0",
|
|
17
19
|
"@checkstack/auth-common": "0.6.2",
|
|
18
|
-
"@checkstack/catalog-common": "1.
|
|
20
|
+
"@checkstack/catalog-common": "1.5.1",
|
|
19
21
|
"@checkstack/command-backend": "0.1.19",
|
|
20
22
|
"@checkstack/auth-backend": "0.4.19",
|
|
21
23
|
"@checkstack/gitops-backend": "0.2.3",
|
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
|
|
package/src/router.test.ts
CHANGED
|
@@ -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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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 () =>
|
|
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
|
-
|
|
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
|
-
|
|
280
|
-
|
|
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
|
-
|
|
284
|
-
|
|
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
|
-
|
|
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
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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
|
-
|
|
430
|
-
|
|
477
|
+
return { groupIds: systemGroups.map((sg) => sg.groupId) };
|
|
478
|
+
}),
|
|
431
479
|
);
|
|
432
480
|
|
|
433
481
|
// Build and return the router
|