@checkstack/incident-backend 0.5.0 → 0.6.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 +124 -0
- package/package.json +6 -4
- package/src/cache.ts +129 -0
- package/src/index.ts +18 -1
- package/src/router.ts +84 -9
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,129 @@
|
|
|
1
1
|
# @checkstack/incident-backend
|
|
2
2
|
|
|
3
|
+
## 0.6.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 8d1ef12: ## Anomaly Detection & UI Improvements
|
|
8
|
+
|
|
9
|
+
### Anomaly Detection Enhancements (Phase 2)
|
|
10
|
+
|
|
11
|
+
- **`@checkstack/anomaly-backend`**: Implemented background baseline analyzer jobs and anomaly trend deviation detection mechanics.
|
|
12
|
+
- **`@checkstack/anomaly-common`**: Added new baseline statistical logic and inference rules.
|
|
13
|
+
- **`@checkstack/anomaly-frontend`**: Added new Anomaly Widget and refactored system detail rendering to be more human-readable.
|
|
14
|
+
- **`@checkstack/dashboard-frontend`**: Refined the global anomaly widget and fixed hardcoded access gating to render appropriately.
|
|
15
|
+
- **`@checkstack/healthcheck-backend`**: Connected executor telemetry to the anomaly pipeline.
|
|
16
|
+
- **`@checkstack/healthcheck-frontend`**: Reconciled baseline display consistency in Drawer and charts.
|
|
17
|
+
|
|
18
|
+
### Notification Identifiers
|
|
19
|
+
|
|
20
|
+
- **`@checkstack/incident-backend`**: Resolved system IDs to human-readable System Names within Incident notifications to eliminate ID-only alert content.
|
|
21
|
+
- **`@checkstack/maintenance-backend`**: Adopted the same resolution strategy for Maintenance notifications to keep parity.
|
|
22
|
+
|
|
23
|
+
### UI Experience
|
|
24
|
+
|
|
25
|
+
- **`@checkstack/incident-frontend`**: Fixed the "Back to X" BackLink to properly use `react-router` hook `useNavigate` instead of doing a full application reload.
|
|
26
|
+
- **`@checkstack/healthcheck-frontend`**: Implemented `useNavigate` for seamless SPA back-linking.
|
|
27
|
+
- **`@checkstack/integration-frontend`**: Updated connections and delivery logs links to navigate without hard reloads.
|
|
28
|
+
|
|
29
|
+
- 8d1ef12: ## Per-entity caching with single-flight + safe invalidation across the dashboard hot paths
|
|
30
|
+
|
|
31
|
+
### `@checkstack/cache-api`
|
|
32
|
+
|
|
33
|
+
- **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.
|
|
34
|
+
- `createScopedCache` forwards `deleteByPrefix` and keeps prefixes scoped to the calling plugin.
|
|
35
|
+
|
|
36
|
+
### `@checkstack/cache-utils` (new package)
|
|
37
|
+
|
|
38
|
+
High-level read-through caching helpers built on `CacheProvider`:
|
|
39
|
+
|
|
40
|
+
- `createCachedScope({ cacheManager, pluginId })` returns a scope with `wrap`, `wrapMany`, `invalidate`, and `invalidatePrefix`.
|
|
41
|
+
- **Single-flight**: concurrent cache misses for the same key share one loader.
|
|
42
|
+
- **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.
|
|
43
|
+
- **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`.
|
|
44
|
+
- Cache failures fall through to the loader so a cache outage cannot break reads.
|
|
45
|
+
|
|
46
|
+
### `@checkstack/backend`
|
|
47
|
+
|
|
48
|
+
- 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.
|
|
49
|
+
|
|
50
|
+
### `@checkstack/healthcheck-backend`
|
|
51
|
+
|
|
52
|
+
- `getSystemHealthStatus` and `getBulkSystemHealthStatus` now read through a per-system cache (`healthcheck:status:<systemId>`), eliminating N database queries per dashboard refresh for unchanged systems.
|
|
53
|
+
- 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.
|
|
54
|
+
|
|
55
|
+
### `@checkstack/incident-backend`
|
|
56
|
+
|
|
57
|
+
- `listIncidents`, `getIncident`, `getIncidentsForSystem`, and `getBulkIncidentsForSystems` now read through a scoped cache:
|
|
58
|
+
- per-incident at `incident:<id>`
|
|
59
|
+
- per-system at `system:<systemId>`
|
|
60
|
+
- per-filter-shape at `list:<stable-stringify(filters)>` for the few list shapes the dashboard polls
|
|
61
|
+
- Mutations (`createIncident`, `updateIncident`, `addUpdate`, `resolveIncident`, `deleteIncident`) invalidate the incident, every affected system, and every cached list before broadcasting `INCIDENT_UPDATED`.
|
|
62
|
+
- The catalog `systemDeleted` cleanup hook drops that system's cached entries.
|
|
63
|
+
|
|
64
|
+
### `@checkstack/maintenance-backend`
|
|
65
|
+
|
|
66
|
+
- `listMaintenances`, `getMaintenance`, `getMaintenancesForSystem`, and `getBulkMaintenancesForSystems` use the same per-entity / per-system / per-filter-shape pattern as incidents.
|
|
67
|
+
- Mutations (`createMaintenance`, `updateMaintenance`, `addUpdate`, `closeMaintenance`, `deleteMaintenance`) invalidate before broadcasting `MAINTENANCE_UPDATED`.
|
|
68
|
+
|
|
69
|
+
### `@checkstack/catalog-backend`
|
|
70
|
+
|
|
71
|
+
- Topology reads (`getEntities`, `getSystems`, `getSystem`, `getGroups`, `getSystemGroupIds`) cache under the `entity:` family (25s TTL).
|
|
72
|
+
- Views (`getViews`) and per-system contacts (`getSystemContacts`) cache in their own families.
|
|
73
|
+
- System / group / membership mutations drop the entire `entity:` family (every reader joins the same tables); view and contact mutations drop only their respective scopes.
|
|
74
|
+
|
|
75
|
+
### `@checkstack/slo-backend`
|
|
76
|
+
|
|
77
|
+
- `listObjectives`, `getObjective`, `getObjectivesForSystem`, and `getBulkObjectivesForSystems` cache results including the expensive `engine.computeStatus` output.
|
|
78
|
+
- Per-entity caching for the bulk handler so dashboards with overlapping system sets share entries.
|
|
79
|
+
- Mutations (`createObjective`, `updateObjective`, `deleteObjective`) invalidate before broadcasting `SLO_STATUS_CHANGED`.
|
|
80
|
+
|
|
81
|
+
### `@checkstack/anomaly-backend`
|
|
82
|
+
|
|
83
|
+
- New `router-cache.ts` adds a cache scope distinct from the existing detector baseline cache, keyed by stable filter hash.
|
|
84
|
+
- `getAnomalies` and `getAnomalyBaselines` cache through this scope (15s TTL).
|
|
85
|
+
- The detector invalidates the router cache before broadcasting `ANOMALY_STATE_CHANGED` on every state transition (suspicious/anomaly/recovered).
|
|
86
|
+
- Config mutations also invalidate.
|
|
87
|
+
|
|
88
|
+
### `@checkstack/notification-backend`
|
|
89
|
+
|
|
90
|
+
- `getUnreadCount`, `getNotifications`, and `getSubscriptions` cache per-user.
|
|
91
|
+
- `markAsRead`, `deleteNotification`, `notifyUsers`, and `notifyGroups` invalidate every affected user's cache before sending realtime signals to that user.
|
|
92
|
+
- `subscribe` and `unsubscribe` invalidate the user's subscription cache.
|
|
93
|
+
|
|
94
|
+
### `@checkstack/announcement-backend`
|
|
95
|
+
|
|
96
|
+
- `getActiveAnnouncements` caches per-user (or anonymous) and per-`includeDismissed` flag (45s TTL — admin-driven, slowly changing).
|
|
97
|
+
- `listAllAnnouncements` caches under a single key.
|
|
98
|
+
- `dismissAnnouncement` only drops that user's cache; `createAnnouncement`, `updateAnnouncement`, `deleteAnnouncement` drop every user's cache before broadcasting `ANNOUNCEMENT_UPDATED`.
|
|
99
|
+
- The auth `userDeleted` cleanup hook drops that user's cached entries.
|
|
100
|
+
|
|
101
|
+
### Patch Changes
|
|
102
|
+
|
|
103
|
+
- Updated dependencies [8d1ef12]
|
|
104
|
+
- Updated dependencies [8d1ef12]
|
|
105
|
+
- Updated dependencies [8d1ef12]
|
|
106
|
+
- @checkstack/common@0.7.0
|
|
107
|
+
- @checkstack/cache-api@0.2.0
|
|
108
|
+
- @checkstack/cache-utils@0.2.0
|
|
109
|
+
- @checkstack/catalog-backend@0.7.0
|
|
110
|
+
- @checkstack/backend-api@0.13.0
|
|
111
|
+
- @checkstack/auth-common@0.6.3
|
|
112
|
+
- @checkstack/catalog-common@1.5.2
|
|
113
|
+
- @checkstack/command-backend@0.1.20
|
|
114
|
+
- @checkstack/incident-common@0.4.9
|
|
115
|
+
- @checkstack/integration-backend@0.1.20
|
|
116
|
+
- @checkstack/integration-common@0.2.9
|
|
117
|
+
- @checkstack/signal-common@0.1.10
|
|
118
|
+
|
|
119
|
+
## 0.5.1
|
|
120
|
+
|
|
121
|
+
### Patch Changes
|
|
122
|
+
|
|
123
|
+
- @checkstack/catalog-common@1.5.1
|
|
124
|
+
- @checkstack/incident-common@0.4.8
|
|
125
|
+
- @checkstack/catalog-backend@0.6.1
|
|
126
|
+
|
|
3
127
|
## 0.5.0
|
|
4
128
|
|
|
5
129
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@checkstack/incident-backend",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"checkstack": {
|
|
@@ -14,9 +14,11 @@
|
|
|
14
14
|
},
|
|
15
15
|
"dependencies": {
|
|
16
16
|
"@checkstack/backend-api": "0.12.0",
|
|
17
|
-
"@checkstack/
|
|
18
|
-
"@checkstack/
|
|
19
|
-
"@checkstack/
|
|
17
|
+
"@checkstack/cache-api": "0.1.0",
|
|
18
|
+
"@checkstack/cache-utils": "0.1.0",
|
|
19
|
+
"@checkstack/incident-common": "0.4.8",
|
|
20
|
+
"@checkstack/catalog-common": "1.5.1",
|
|
21
|
+
"@checkstack/catalog-backend": "0.6.1",
|
|
20
22
|
"@checkstack/auth-common": "0.6.2",
|
|
21
23
|
"@checkstack/command-backend": "0.1.19",
|
|
22
24
|
"@checkstack/signal-common": "0.1.9",
|
package/src/cache.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
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
|
+
import type { IncidentService } from "./service";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 15s — comfortably under the dashboard's 30s `staleTime` so signal-driven
|
|
11
|
+
* invalidation almost always wins, and TTL only acts as a safety net.
|
|
12
|
+
*/
|
|
13
|
+
const INCIDENT_TTL_MS = 15_000;
|
|
14
|
+
|
|
15
|
+
const LIST_PREFIX = "list:";
|
|
16
|
+
const INCIDENT_PREFIX = "incident:";
|
|
17
|
+
const SYSTEM_PREFIX = "system:";
|
|
18
|
+
|
|
19
|
+
const incidentKey = (id: string): string => `${INCIDENT_PREFIX}${id}`;
|
|
20
|
+
const systemKey = (systemId: string): string => `${SYSTEM_PREFIX}${systemId}`;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Stable JSON for filter shapes — sort keys so {a:1,b:2} and {b:2,a:1}
|
|
24
|
+
* collapse to the same cache key. Filter shapes here are tiny (<10 fields)
|
|
25
|
+
* so a recursive sort is cheap and the resulting key is human-readable.
|
|
26
|
+
*/
|
|
27
|
+
function stableStringify(value: unknown): string {
|
|
28
|
+
if (value === null || typeof value !== "object") {
|
|
29
|
+
return JSON.stringify(value);
|
|
30
|
+
}
|
|
31
|
+
if (Array.isArray(value)) {
|
|
32
|
+
return `[${value.map((v) => stableStringify(v)).join(",")}]`;
|
|
33
|
+
}
|
|
34
|
+
const entries = Object.entries(value as Record<string, unknown>)
|
|
35
|
+
.filter(([, v]) => v !== undefined)
|
|
36
|
+
.toSorted(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
37
|
+
return `{${entries
|
|
38
|
+
.map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`)
|
|
39
|
+
.join(",")}}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const listKey = (filters: unknown): string =>
|
|
43
|
+
`${LIST_PREFIX}${stableStringify(filters ?? {})}`;
|
|
44
|
+
|
|
45
|
+
type ListResult = Awaited<ReturnType<IncidentService["listIncidents"]>>;
|
|
46
|
+
type IncidentResult = Awaited<ReturnType<IncidentService["getIncident"]>>;
|
|
47
|
+
type SystemResult = Awaited<ReturnType<IncidentService["getIncidentsForSystem"]>>;
|
|
48
|
+
|
|
49
|
+
export interface IncidentCache {
|
|
50
|
+
/** Read-through cache for {@link IncidentService.listIncidents}. */
|
|
51
|
+
wrapList: (
|
|
52
|
+
filters: unknown,
|
|
53
|
+
loader: () => Promise<ListResult>,
|
|
54
|
+
) => Promise<ListResult>;
|
|
55
|
+
|
|
56
|
+
/** Read-through cache for {@link IncidentService.getIncident}. */
|
|
57
|
+
wrapIncident: (
|
|
58
|
+
id: string,
|
|
59
|
+
loader: () => Promise<IncidentResult>,
|
|
60
|
+
) => Promise<IncidentResult>;
|
|
61
|
+
|
|
62
|
+
/** Read-through cache for {@link IncidentService.getIncidentsForSystem}. */
|
|
63
|
+
wrapSystem: (
|
|
64
|
+
systemId: string,
|
|
65
|
+
loader: () => Promise<SystemResult>,
|
|
66
|
+
) => Promise<SystemResult>;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Invalidate everything affected by a mutation on `incidentId` that
|
|
70
|
+
* touches `systemIds`. Drops:
|
|
71
|
+
* - the per-incident cache entry
|
|
72
|
+
* - every per-system cache entry for the affected systems
|
|
73
|
+
* - every cached list shape (any filter could match)
|
|
74
|
+
*
|
|
75
|
+
* Must be awaited *before* emitting `INCIDENT_UPDATED` signals so any
|
|
76
|
+
* frontend that refetches in response sees fresh data.
|
|
77
|
+
*/
|
|
78
|
+
invalidateForMutation: (props: {
|
|
79
|
+
incidentId: string;
|
|
80
|
+
systemIds: readonly string[];
|
|
81
|
+
}) => Promise<void>;
|
|
82
|
+
|
|
83
|
+
/** Invalidate every cached entry for one system (used on system deletion). */
|
|
84
|
+
invalidateSystem: (systemId: string) => Promise<void>;
|
|
85
|
+
|
|
86
|
+
scope: CachedScope;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function createIncidentCache({
|
|
90
|
+
cacheManager,
|
|
91
|
+
logger,
|
|
92
|
+
}: {
|
|
93
|
+
cacheManager: CacheManager;
|
|
94
|
+
logger: Logger;
|
|
95
|
+
}): IncidentCache {
|
|
96
|
+
const scope = createCachedScope({
|
|
97
|
+
cacheManager,
|
|
98
|
+
pluginId: "incident",
|
|
99
|
+
defaultTtlMs: INCIDENT_TTL_MS,
|
|
100
|
+
onError: (op, error) => {
|
|
101
|
+
logger.warn(`incident cache ${op} failed: ${String(error)}`);
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
wrapList: (filters, loader) => scope.wrap(listKey(filters), loader),
|
|
107
|
+
wrapIncident: (id, loader) => scope.wrap(incidentKey(id), loader),
|
|
108
|
+
wrapSystem: (systemId, loader) => scope.wrap(systemKey(systemId), loader),
|
|
109
|
+
|
|
110
|
+
invalidateForMutation: async ({ incidentId, systemIds }) => {
|
|
111
|
+
await Promise.all([
|
|
112
|
+
scope.invalidate(incidentKey(incidentId)),
|
|
113
|
+
...systemIds.map((systemId) =>
|
|
114
|
+
scope.invalidate(systemKey(systemId)),
|
|
115
|
+
),
|
|
116
|
+
scope.invalidatePrefix(LIST_PREFIX),
|
|
117
|
+
]);
|
|
118
|
+
},
|
|
119
|
+
|
|
120
|
+
invalidateSystem: async (systemId) => {
|
|
121
|
+
await Promise.all([
|
|
122
|
+
scope.invalidate(systemKey(systemId)),
|
|
123
|
+
scope.invalidatePrefix(LIST_PREFIX),
|
|
124
|
+
]);
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
scope,
|
|
128
|
+
};
|
|
129
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { catalogHooks } from "@checkstack/catalog-backend";
|
|
|
18
18
|
import { registerSearchProvider } from "@checkstack/command-backend";
|
|
19
19
|
import { resolveRoute } from "@checkstack/common";
|
|
20
20
|
import { incidentHooks } from "./hooks";
|
|
21
|
+
import { createIncidentCache } from "./cache";
|
|
21
22
|
|
|
22
23
|
// =============================================================================
|
|
23
24
|
// Integration Event Payload Schemas
|
|
@@ -99,6 +100,10 @@ export default createBackendPlugin({
|
|
|
99
100
|
pluginMetadata,
|
|
100
101
|
);
|
|
101
102
|
|
|
103
|
+
let incidentCache:
|
|
104
|
+
| ReturnType<typeof createIncidentCache>
|
|
105
|
+
| undefined;
|
|
106
|
+
|
|
102
107
|
env.registerInit({
|
|
103
108
|
schema,
|
|
104
109
|
deps: {
|
|
@@ -106,8 +111,16 @@ export default createBackendPlugin({
|
|
|
106
111
|
rpc: coreServices.rpc,
|
|
107
112
|
rpcClient: coreServices.rpcClient,
|
|
108
113
|
signalService: coreServices.signalService,
|
|
114
|
+
cacheManager: coreServices.cacheManager,
|
|
109
115
|
},
|
|
110
|
-
init: async ({
|
|
116
|
+
init: async ({
|
|
117
|
+
logger,
|
|
118
|
+
database,
|
|
119
|
+
rpc,
|
|
120
|
+
rpcClient,
|
|
121
|
+
signalService,
|
|
122
|
+
cacheManager,
|
|
123
|
+
}) => {
|
|
111
124
|
logger.debug("🔧 Initializing Incident Backend...");
|
|
112
125
|
|
|
113
126
|
const catalogClient = rpcClient.forPlugin(CatalogApi);
|
|
@@ -116,12 +129,15 @@ export default createBackendPlugin({
|
|
|
116
129
|
const service = new IncidentService(
|
|
117
130
|
database as SafeDatabase<typeof schema>,
|
|
118
131
|
);
|
|
132
|
+
const cache = createIncidentCache({ cacheManager, logger });
|
|
133
|
+
incidentCache = cache;
|
|
119
134
|
const router = createRouter(
|
|
120
135
|
service,
|
|
121
136
|
signalService,
|
|
122
137
|
catalogClient,
|
|
123
138
|
authClient,
|
|
124
139
|
logger,
|
|
140
|
+
cache,
|
|
125
141
|
);
|
|
126
142
|
rpc.registerRouter(router, incidentContract);
|
|
127
143
|
|
|
@@ -165,6 +181,7 @@ export default createBackendPlugin({
|
|
|
165
181
|
`Cleaning up incident associations for deleted system: ${payload.systemId}`,
|
|
166
182
|
);
|
|
167
183
|
await service.removeSystemAssociations(payload.systemId);
|
|
184
|
+
await incidentCache?.invalidateSystem(payload.systemId);
|
|
168
185
|
},
|
|
169
186
|
{ mode: "work-queue", workerGroup: "incident-system-cleanup" },
|
|
170
187
|
);
|
package/src/router.ts
CHANGED
|
@@ -16,6 +16,7 @@ import type { InferClient } from "@checkstack/common";
|
|
|
16
16
|
import { incidentHooks } from "./hooks";
|
|
17
17
|
import { notifyAffectedSystems } from "./notifications";
|
|
18
18
|
import type { IncidentUpdate } from "@checkstack/incident-common";
|
|
19
|
+
import type { IncidentCache } from "./cache";
|
|
19
20
|
|
|
20
21
|
export function createRouter(
|
|
21
22
|
service: IncidentService,
|
|
@@ -23,6 +24,7 @@ export function createRouter(
|
|
|
23
24
|
catalogClient: InferClient<typeof CatalogApi>,
|
|
24
25
|
authClient: InferClient<typeof AuthApi>,
|
|
25
26
|
logger: Logger,
|
|
27
|
+
cache: IncidentCache,
|
|
26
28
|
) {
|
|
27
29
|
/**
|
|
28
30
|
* Resolve user IDs to profile names for a list of updates.
|
|
@@ -58,46 +60,82 @@ export function createRouter(
|
|
|
58
60
|
}));
|
|
59
61
|
}
|
|
60
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Fetch system names for a list of system IDs.
|
|
65
|
+
*/
|
|
66
|
+
async function resolveSystemNames(
|
|
67
|
+
systemIds: string[],
|
|
68
|
+
): Promise<Map<string, string>> {
|
|
69
|
+
const systemNames = new Map<string, string>();
|
|
70
|
+
if (systemIds.length === 0) return systemNames;
|
|
71
|
+
|
|
72
|
+
await Promise.all(
|
|
73
|
+
[...new Set(systemIds)].map(async (systemId) => {
|
|
74
|
+
try {
|
|
75
|
+
const system = await catalogClient.getSystem({ systemId });
|
|
76
|
+
if (system?.name) {
|
|
77
|
+
systemNames.set(systemId, system.name);
|
|
78
|
+
}
|
|
79
|
+
} catch {
|
|
80
|
+
// System not found, skip
|
|
81
|
+
}
|
|
82
|
+
}),
|
|
83
|
+
);
|
|
84
|
+
return systemNames;
|
|
85
|
+
}
|
|
86
|
+
|
|
61
87
|
const os = implement(incidentContract)
|
|
62
88
|
.$context<RpcContext>()
|
|
63
89
|
.use(autoAuthMiddleware);
|
|
64
90
|
|
|
65
91
|
return os.router({
|
|
66
92
|
listIncidents: os.listIncidents.handler(async ({ input }) => {
|
|
67
|
-
return {
|
|
93
|
+
return {
|
|
94
|
+
incidents: await cache.wrapList(input ?? {}, () =>
|
|
95
|
+
service.listIncidents(input ?? {}),
|
|
96
|
+
),
|
|
97
|
+
};
|
|
68
98
|
}),
|
|
69
99
|
|
|
70
100
|
getIncident: os.getIncident.handler(async ({ input }) => {
|
|
71
|
-
const result = await
|
|
101
|
+
const result = await cache.wrapIncident(input.id, () =>
|
|
102
|
+
service.getIncident(input.id),
|
|
103
|
+
);
|
|
72
104
|
if (!result) {
|
|
73
105
|
// eslint-disable-next-line unicorn/no-null -- oRPC contract requires null for missing values
|
|
74
106
|
return null;
|
|
75
107
|
}
|
|
76
|
-
//
|
|
108
|
+
// User-name resolution stays outside the cache: it's a foreign-system
|
|
109
|
+
// lookup with its own freshness needs and is cheap relative to the
|
|
110
|
+
// incident query.
|
|
77
111
|
const updatesWithNames = await resolveUserNames(result.updates);
|
|
78
112
|
return { ...result, updates: updatesWithNames };
|
|
79
113
|
}),
|
|
80
114
|
|
|
81
115
|
getIncidentsForSystem: os.getIncidentsForSystem.handler(
|
|
82
116
|
async ({ input }) => {
|
|
83
|
-
return
|
|
117
|
+
return cache.wrapSystem(input.systemId, () =>
|
|
118
|
+
service.getIncidentsForSystem(input.systemId),
|
|
119
|
+
);
|
|
84
120
|
},
|
|
85
121
|
),
|
|
86
122
|
|
|
87
123
|
getBulkIncidentsForSystems: os.getBulkIncidentsForSystems.handler(
|
|
88
124
|
async ({ input }) => {
|
|
125
|
+
// Per-entity caching: each system's incidents are cached individually
|
|
126
|
+
// so dashboards with overlapping (but non-identical) system sets share
|
|
127
|
+
// cache entries. See ./cache.ts for the key/TTL/invalidation contract.
|
|
89
128
|
const incidents: Record<
|
|
90
129
|
string,
|
|
91
130
|
Awaited<ReturnType<typeof service.getIncidentsForSystem>>
|
|
92
131
|
> = {};
|
|
93
|
-
|
|
94
|
-
// Fetch incidents for each system in parallel
|
|
95
132
|
await Promise.all(
|
|
96
133
|
input.systemIds.map(async (systemId) => {
|
|
97
|
-
incidents[systemId] = await
|
|
134
|
+
incidents[systemId] = await cache.wrapSystem(systemId, () =>
|
|
135
|
+
service.getIncidentsForSystem(systemId),
|
|
136
|
+
);
|
|
98
137
|
}),
|
|
99
138
|
);
|
|
100
|
-
|
|
101
139
|
return { incidents };
|
|
102
140
|
},
|
|
103
141
|
),
|
|
@@ -107,6 +145,14 @@ export function createRouter(
|
|
|
107
145
|
context.user && "id" in context.user ? context.user.id : undefined;
|
|
108
146
|
const result = await service.createIncident(input, userId);
|
|
109
147
|
|
|
148
|
+
// Invalidate before signal so any frontend that refetches in response
|
|
149
|
+
// sees fresh data. The mutation invariant for every handler in this
|
|
150
|
+
// file is: db.write → cache.invalidate (await) → signals.emit.
|
|
151
|
+
await cache.invalidateForMutation({
|
|
152
|
+
incidentId: result.id,
|
|
153
|
+
systemIds: result.systemIds,
|
|
154
|
+
});
|
|
155
|
+
|
|
110
156
|
// Broadcast signal for realtime updates
|
|
111
157
|
await signalService.broadcast(INCIDENT_UPDATED, {
|
|
112
158
|
incidentId: result.id,
|
|
@@ -126,12 +172,14 @@ export function createRouter(
|
|
|
126
172
|
});
|
|
127
173
|
|
|
128
174
|
// Send notifications to system subscribers
|
|
175
|
+
const systemNames = await resolveSystemNames(result.systemIds);
|
|
129
176
|
await notifyAffectedSystems({
|
|
130
177
|
catalogClient,
|
|
131
178
|
logger,
|
|
132
179
|
incidentId: result.id,
|
|
133
180
|
incidentTitle: result.title,
|
|
134
181
|
systemIds: result.systemIds,
|
|
182
|
+
systemNames,
|
|
135
183
|
action: "created",
|
|
136
184
|
severity: result.severity,
|
|
137
185
|
});
|
|
@@ -145,6 +193,11 @@ export function createRouter(
|
|
|
145
193
|
throw new ORPCError("NOT_FOUND", { message: "Incident not found" });
|
|
146
194
|
}
|
|
147
195
|
|
|
196
|
+
await cache.invalidateForMutation({
|
|
197
|
+
incidentId: result.id,
|
|
198
|
+
systemIds: result.systemIds,
|
|
199
|
+
});
|
|
200
|
+
|
|
148
201
|
// Broadcast signal for realtime updates
|
|
149
202
|
await signalService.broadcast(INCIDENT_UPDATED, {
|
|
150
203
|
incidentId: result.id,
|
|
@@ -163,12 +216,14 @@ export function createRouter(
|
|
|
163
216
|
});
|
|
164
217
|
|
|
165
218
|
// Send notifications to system subscribers
|
|
219
|
+
const systemNames = await resolveSystemNames(result.systemIds);
|
|
166
220
|
await notifyAffectedSystems({
|
|
167
221
|
catalogClient,
|
|
168
222
|
logger,
|
|
169
223
|
incidentId: result.id,
|
|
170
224
|
incidentTitle: result.title,
|
|
171
225
|
systemIds: result.systemIds,
|
|
226
|
+
systemNames,
|
|
172
227
|
action: "updated",
|
|
173
228
|
severity: result.severity,
|
|
174
229
|
});
|
|
@@ -188,9 +243,15 @@ export function createRouter(
|
|
|
188
243
|
|
|
189
244
|
const result = await service.addUpdate(input, userId);
|
|
190
245
|
|
|
191
|
-
//
|
|
246
|
+
// Read post-write state directly from the service so the broadcast
|
|
247
|
+
// payload is fresh; the cache is invalidated below before the signal.
|
|
192
248
|
const incident = await service.getIncident(input.incidentId);
|
|
193
249
|
if (incident) {
|
|
250
|
+
await cache.invalidateForMutation({
|
|
251
|
+
incidentId: input.incidentId,
|
|
252
|
+
systemIds: incident.systemIds,
|
|
253
|
+
});
|
|
254
|
+
|
|
194
255
|
await signalService.broadcast(INCIDENT_UPDATED, {
|
|
195
256
|
incidentId: input.incidentId,
|
|
196
257
|
systemIds: incident.systemIds,
|
|
@@ -232,12 +293,14 @@ export function createRouter(
|
|
|
232
293
|
notificationAction = "updated";
|
|
233
294
|
}
|
|
234
295
|
|
|
296
|
+
const systemNames = await resolveSystemNames(incident.systemIds);
|
|
235
297
|
await notifyAffectedSystems({
|
|
236
298
|
catalogClient,
|
|
237
299
|
logger,
|
|
238
300
|
incidentId: input.incidentId,
|
|
239
301
|
incidentTitle: incident.title,
|
|
240
302
|
systemIds: incident.systemIds,
|
|
303
|
+
systemNames,
|
|
241
304
|
action: notificationAction,
|
|
242
305
|
severity: incident.severity,
|
|
243
306
|
});
|
|
@@ -259,6 +322,11 @@ export function createRouter(
|
|
|
259
322
|
throw new ORPCError("NOT_FOUND", { message: "Incident not found" });
|
|
260
323
|
}
|
|
261
324
|
|
|
325
|
+
await cache.invalidateForMutation({
|
|
326
|
+
incidentId: result.id,
|
|
327
|
+
systemIds: result.systemIds,
|
|
328
|
+
});
|
|
329
|
+
|
|
262
330
|
// Broadcast signal for realtime updates
|
|
263
331
|
await signalService.broadcast(INCIDENT_UPDATED, {
|
|
264
332
|
incidentId: result.id,
|
|
@@ -276,12 +344,14 @@ export function createRouter(
|
|
|
276
344
|
});
|
|
277
345
|
|
|
278
346
|
// Send notifications to system subscribers
|
|
347
|
+
const systemNames = await resolveSystemNames(result.systemIds);
|
|
279
348
|
await notifyAffectedSystems({
|
|
280
349
|
catalogClient,
|
|
281
350
|
logger,
|
|
282
351
|
incidentId: result.id,
|
|
283
352
|
incidentTitle: result.title,
|
|
284
353
|
systemIds: result.systemIds,
|
|
354
|
+
systemNames,
|
|
285
355
|
action: "resolved",
|
|
286
356
|
severity: result.severity,
|
|
287
357
|
});
|
|
@@ -294,6 +364,11 @@ export function createRouter(
|
|
|
294
364
|
const incident = await service.getIncident(input.id);
|
|
295
365
|
const success = await service.deleteIncident(input.id);
|
|
296
366
|
if (success && incident) {
|
|
367
|
+
await cache.invalidateForMutation({
|
|
368
|
+
incidentId: input.id,
|
|
369
|
+
systemIds: incident.systemIds,
|
|
370
|
+
});
|
|
371
|
+
|
|
297
372
|
await signalService.broadcast(INCIDENT_UPDATED, {
|
|
298
373
|
incidentId: input.id,
|
|
299
374
|
systemIds: incident.systemIds,
|