@checkstack/maintenance-backend 0.6.0 → 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 +123 -0
- package/package.json +5 -3
- package/src/cache.ts +115 -0
- package/src/index.ts +12 -1
- package/src/router.ts +78 -10
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,128 @@
|
|
|
1
1
|
# @checkstack/maintenance-backend
|
|
2
2
|
|
|
3
|
+
## 0.7.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/backend-api@0.13.0
|
|
110
|
+
- @checkstack/auth-common@0.6.3
|
|
111
|
+
- @checkstack/catalog-common@1.5.2
|
|
112
|
+
- @checkstack/command-backend@0.1.20
|
|
113
|
+
- @checkstack/integration-backend@0.1.20
|
|
114
|
+
- @checkstack/integration-common@0.2.9
|
|
115
|
+
- @checkstack/maintenance-common@0.4.11
|
|
116
|
+
- @checkstack/notification-common@0.2.9
|
|
117
|
+
- @checkstack/signal-common@0.1.10
|
|
118
|
+
|
|
119
|
+
## 0.6.1
|
|
120
|
+
|
|
121
|
+
### Patch Changes
|
|
122
|
+
|
|
123
|
+
- @checkstack/catalog-common@1.5.1
|
|
124
|
+
- @checkstack/maintenance-common@0.4.10
|
|
125
|
+
|
|
3
126
|
## 0.6.0
|
|
4
127
|
|
|
5
128
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@checkstack/maintenance-backend",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.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/
|
|
17
|
+
"@checkstack/cache-api": "0.1.0",
|
|
18
|
+
"@checkstack/cache-utils": "0.1.0",
|
|
19
|
+
"@checkstack/maintenance-common": "0.4.10",
|
|
18
20
|
"@checkstack/notification-common": "0.2.8",
|
|
19
|
-
"@checkstack/catalog-common": "1.
|
|
21
|
+
"@checkstack/catalog-common": "1.5.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,115 @@
|
|
|
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 { MaintenanceService } from "./service";
|
|
8
|
+
|
|
9
|
+
const MAINTENANCE_TTL_MS = 15_000;
|
|
10
|
+
|
|
11
|
+
const LIST_PREFIX = "list:";
|
|
12
|
+
const MAINTENANCE_PREFIX = "maintenance:";
|
|
13
|
+
const SYSTEM_PREFIX = "system:";
|
|
14
|
+
|
|
15
|
+
const maintenanceKey = (id: string): string => `${MAINTENANCE_PREFIX}${id}`;
|
|
16
|
+
const systemKey = (systemId: string): string => `${SYSTEM_PREFIX}${systemId}`;
|
|
17
|
+
|
|
18
|
+
function stableStringify(value: unknown): string {
|
|
19
|
+
if (value === null || typeof value !== "object") {
|
|
20
|
+
return JSON.stringify(value);
|
|
21
|
+
}
|
|
22
|
+
if (Array.isArray(value)) {
|
|
23
|
+
return `[${value.map((v) => stableStringify(v)).join(",")}]`;
|
|
24
|
+
}
|
|
25
|
+
const entries = Object.entries(value as Record<string, unknown>)
|
|
26
|
+
.filter(([, v]) => v !== undefined)
|
|
27
|
+
.toSorted(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
28
|
+
return `{${entries
|
|
29
|
+
.map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`)
|
|
30
|
+
.join(",")}}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const listKey = (filters: unknown): string =>
|
|
34
|
+
`${LIST_PREFIX}${stableStringify(filters ?? {})}`;
|
|
35
|
+
|
|
36
|
+
type ListResult = Awaited<ReturnType<MaintenanceService["listMaintenances"]>>;
|
|
37
|
+
type MaintenanceResult = Awaited<
|
|
38
|
+
ReturnType<MaintenanceService["getMaintenance"]>
|
|
39
|
+
>;
|
|
40
|
+
type SystemResult = Awaited<
|
|
41
|
+
ReturnType<MaintenanceService["getMaintenancesForSystem"]>
|
|
42
|
+
>;
|
|
43
|
+
|
|
44
|
+
export interface MaintenanceCache {
|
|
45
|
+
wrapList: (
|
|
46
|
+
filters: unknown,
|
|
47
|
+
loader: () => Promise<ListResult>,
|
|
48
|
+
) => Promise<ListResult>;
|
|
49
|
+
wrapMaintenance: (
|
|
50
|
+
id: string,
|
|
51
|
+
loader: () => Promise<MaintenanceResult>,
|
|
52
|
+
) => Promise<MaintenanceResult>;
|
|
53
|
+
wrapSystem: (
|
|
54
|
+
systemId: string,
|
|
55
|
+
loader: () => Promise<SystemResult>,
|
|
56
|
+
) => Promise<SystemResult>;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Drops the per-maintenance entry, every per-system entry for the
|
|
60
|
+
* affected systems, and every cached list shape. Must be awaited
|
|
61
|
+
* before emitting `MAINTENANCE_UPDATED` so frontend refetches see
|
|
62
|
+
* fresh data.
|
|
63
|
+
*/
|
|
64
|
+
invalidateForMutation: (props: {
|
|
65
|
+
maintenanceId: string;
|
|
66
|
+
systemIds: readonly string[];
|
|
67
|
+
}) => Promise<void>;
|
|
68
|
+
|
|
69
|
+
invalidateSystem: (systemId: string) => Promise<void>;
|
|
70
|
+
|
|
71
|
+
scope: CachedScope;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function createMaintenanceCache({
|
|
75
|
+
cacheManager,
|
|
76
|
+
logger,
|
|
77
|
+
}: {
|
|
78
|
+
cacheManager: CacheManager;
|
|
79
|
+
logger: Logger;
|
|
80
|
+
}): MaintenanceCache {
|
|
81
|
+
const scope = createCachedScope({
|
|
82
|
+
cacheManager,
|
|
83
|
+
pluginId: "maintenance",
|
|
84
|
+
defaultTtlMs: MAINTENANCE_TTL_MS,
|
|
85
|
+
onError: (op, error) => {
|
|
86
|
+
logger.warn(`maintenance cache ${op} failed: ${String(error)}`);
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
wrapList: (filters, loader) => scope.wrap(listKey(filters), loader),
|
|
92
|
+
wrapMaintenance: (id, loader) =>
|
|
93
|
+
scope.wrap(maintenanceKey(id), loader),
|
|
94
|
+
wrapSystem: (systemId, loader) => scope.wrap(systemKey(systemId), loader),
|
|
95
|
+
|
|
96
|
+
invalidateForMutation: async ({ maintenanceId, systemIds }) => {
|
|
97
|
+
await Promise.all([
|
|
98
|
+
scope.invalidate(maintenanceKey(maintenanceId)),
|
|
99
|
+
...systemIds.map((systemId) =>
|
|
100
|
+
scope.invalidate(systemKey(systemId)),
|
|
101
|
+
),
|
|
102
|
+
scope.invalidatePrefix(LIST_PREFIX),
|
|
103
|
+
]);
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
invalidateSystem: async (systemId) => {
|
|
107
|
+
await Promise.all([
|
|
108
|
+
scope.invalidate(systemKey(systemId)),
|
|
109
|
+
scope.invalidatePrefix(LIST_PREFIX),
|
|
110
|
+
]);
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
scope,
|
|
114
|
+
};
|
|
115
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { AuthApi } from "@checkstack/auth-common";
|
|
|
19
19
|
import { registerSearchProvider } from "@checkstack/command-backend";
|
|
20
20
|
import { resolveRoute, type InferClient } from "@checkstack/common";
|
|
21
21
|
import { maintenanceHooks } from "./hooks";
|
|
22
|
+
import { createMaintenanceCache } from "./cache";
|
|
22
23
|
|
|
23
24
|
// =============================================================================
|
|
24
25
|
// Integration Event Payload Schemas
|
|
@@ -100,8 +101,16 @@ export default createBackendPlugin({
|
|
|
100
101
|
rpcClient: coreServices.rpcClient,
|
|
101
102
|
signalService: coreServices.signalService,
|
|
102
103
|
queueManager: coreServices.queueManager,
|
|
104
|
+
cacheManager: coreServices.cacheManager,
|
|
103
105
|
},
|
|
104
|
-
init: async ({
|
|
106
|
+
init: async ({
|
|
107
|
+
logger,
|
|
108
|
+
database,
|
|
109
|
+
rpc,
|
|
110
|
+
rpcClient,
|
|
111
|
+
signalService,
|
|
112
|
+
cacheManager,
|
|
113
|
+
}) => {
|
|
105
114
|
logger.debug("🔧 Initializing Maintenance Backend...");
|
|
106
115
|
|
|
107
116
|
catalogClient = rpcClient.forPlugin(CatalogApi);
|
|
@@ -111,12 +120,14 @@ export default createBackendPlugin({
|
|
|
111
120
|
maintenanceService = new MaintenanceService(
|
|
112
121
|
database as SafeDatabase<typeof schema>,
|
|
113
122
|
);
|
|
123
|
+
const cache = createMaintenanceCache({ cacheManager, logger });
|
|
114
124
|
const router = createRouter(
|
|
115
125
|
maintenanceService,
|
|
116
126
|
signalService,
|
|
117
127
|
catalogClient,
|
|
118
128
|
authClient,
|
|
119
129
|
logger,
|
|
130
|
+
cache,
|
|
120
131
|
);
|
|
121
132
|
rpc.registerRouter(router, maintenanceContract);
|
|
122
133
|
|
package/src/router.ts
CHANGED
|
@@ -16,6 +16,7 @@ import type { InferClient } from "@checkstack/common";
|
|
|
16
16
|
import { maintenanceHooks } from "./hooks";
|
|
17
17
|
import { notifyAffectedSystems } from "./notifications";
|
|
18
18
|
import type { MaintenanceUpdate } from "@checkstack/maintenance-common";
|
|
19
|
+
import type { MaintenanceCache } from "./cache";
|
|
19
20
|
|
|
20
21
|
export function createRouter(
|
|
21
22
|
service: MaintenanceService,
|
|
@@ -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: MaintenanceCache,
|
|
26
28
|
) {
|
|
27
29
|
/**
|
|
28
30
|
* Resolve user IDs to profile names for a list of updates.
|
|
@@ -58,47 +60,79 @@ 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(maintenanceContract)
|
|
62
88
|
.$context<RpcContext>()
|
|
63
89
|
.use(autoAuthMiddleware);
|
|
64
90
|
|
|
65
91
|
return os.router({
|
|
66
92
|
listMaintenances: os.listMaintenances.handler(async ({ input }) => {
|
|
67
|
-
return {
|
|
93
|
+
return {
|
|
94
|
+
maintenances: await cache.wrapList(input ?? {}, () =>
|
|
95
|
+
service.listMaintenances(input ?? {}),
|
|
96
|
+
),
|
|
97
|
+
};
|
|
68
98
|
}),
|
|
69
99
|
|
|
70
100
|
getMaintenance: os.getMaintenance.handler(async ({ input }) => {
|
|
71
|
-
const result = await
|
|
101
|
+
const result = await cache.wrapMaintenance(input.id, () =>
|
|
102
|
+
service.getMaintenance(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.
|
|
77
110
|
const updatesWithNames = await resolveUserNames(result.updates);
|
|
78
111
|
return { ...result, updates: updatesWithNames };
|
|
79
112
|
}),
|
|
80
113
|
|
|
81
114
|
getMaintenancesForSystem: os.getMaintenancesForSystem.handler(
|
|
82
115
|
async ({ input }) => {
|
|
83
|
-
return
|
|
116
|
+
return cache.wrapSystem(input.systemId, () =>
|
|
117
|
+
service.getMaintenancesForSystem(input.systemId),
|
|
118
|
+
);
|
|
84
119
|
},
|
|
85
120
|
),
|
|
86
121
|
|
|
87
122
|
getBulkMaintenancesForSystems: os.getBulkMaintenancesForSystems.handler(
|
|
88
123
|
async ({ input }) => {
|
|
124
|
+
// Per-entity caching: see ./cache.ts for the invalidation contract.
|
|
89
125
|
const maintenances: Record<
|
|
90
126
|
string,
|
|
91
127
|
Awaited<ReturnType<typeof service.getMaintenancesForSystem>>
|
|
92
128
|
> = {};
|
|
93
|
-
|
|
94
|
-
// Fetch maintenances for each system in parallel
|
|
95
129
|
await Promise.all(
|
|
96
130
|
input.systemIds.map(async (systemId) => {
|
|
97
|
-
maintenances[systemId] =
|
|
98
|
-
|
|
131
|
+
maintenances[systemId] = await cache.wrapSystem(systemId, () =>
|
|
132
|
+
service.getMaintenancesForSystem(systemId),
|
|
133
|
+
);
|
|
99
134
|
}),
|
|
100
135
|
);
|
|
101
|
-
|
|
102
136
|
return { maintenances };
|
|
103
137
|
},
|
|
104
138
|
),
|
|
@@ -107,6 +141,14 @@ export function createRouter(
|
|
|
107
141
|
async ({ input, context }) => {
|
|
108
142
|
const result = await service.createMaintenance(input);
|
|
109
143
|
|
|
144
|
+
// Invalidate before signal so any frontend that refetches in
|
|
145
|
+
// response sees fresh data. Mutation invariant in this file:
|
|
146
|
+
// db.write → cache.invalidate (await) → signals.emit.
|
|
147
|
+
await cache.invalidateForMutation({
|
|
148
|
+
maintenanceId: result.id,
|
|
149
|
+
systemIds: result.systemIds,
|
|
150
|
+
});
|
|
151
|
+
|
|
110
152
|
// Broadcast signal for realtime updates
|
|
111
153
|
await signalService.broadcast(MAINTENANCE_UPDATED, {
|
|
112
154
|
maintenanceId: result.id,
|
|
@@ -126,12 +168,14 @@ export function createRouter(
|
|
|
126
168
|
});
|
|
127
169
|
|
|
128
170
|
// Send notifications to system subscribers
|
|
171
|
+
const systemNames = await resolveSystemNames(result.systemIds);
|
|
129
172
|
await notifyAffectedSystems({
|
|
130
173
|
catalogClient,
|
|
131
174
|
logger,
|
|
132
175
|
maintenanceId: result.id,
|
|
133
176
|
maintenanceTitle: result.title,
|
|
134
177
|
systemIds: result.systemIds,
|
|
178
|
+
systemNames,
|
|
135
179
|
action: "created",
|
|
136
180
|
});
|
|
137
181
|
|
|
@@ -148,6 +192,11 @@ export function createRouter(
|
|
|
148
192
|
});
|
|
149
193
|
}
|
|
150
194
|
|
|
195
|
+
await cache.invalidateForMutation({
|
|
196
|
+
maintenanceId: result.id,
|
|
197
|
+
systemIds: result.systemIds,
|
|
198
|
+
});
|
|
199
|
+
|
|
151
200
|
// Broadcast signal for realtime updates
|
|
152
201
|
await signalService.broadcast(MAINTENANCE_UPDATED, {
|
|
153
202
|
maintenanceId: result.id,
|
|
@@ -182,9 +231,15 @@ export function createRouter(
|
|
|
182
231
|
const previousStatus = previousMaintenance?.status;
|
|
183
232
|
|
|
184
233
|
const result = await service.addUpdate(input, userId);
|
|
185
|
-
//
|
|
234
|
+
// Read post-write state directly from the service so the broadcast
|
|
235
|
+
// payload is fresh; the cache is invalidated below before the signal.
|
|
186
236
|
const maintenance = await service.getMaintenance(input.maintenanceId);
|
|
187
237
|
if (maintenance) {
|
|
238
|
+
await cache.invalidateForMutation({
|
|
239
|
+
maintenanceId: input.maintenanceId,
|
|
240
|
+
systemIds: maintenance.systemIds,
|
|
241
|
+
});
|
|
242
|
+
|
|
188
243
|
// Determine action based on status change
|
|
189
244
|
const action =
|
|
190
245
|
input.statusChange === "completed" ? "closed" : "updated";
|
|
@@ -222,12 +277,14 @@ export function createRouter(
|
|
|
222
277
|
notificationAction = "updated";
|
|
223
278
|
}
|
|
224
279
|
|
|
280
|
+
const systemNames = await resolveSystemNames(maintenance.systemIds);
|
|
225
281
|
await notifyAffectedSystems({
|
|
226
282
|
catalogClient,
|
|
227
283
|
logger,
|
|
228
284
|
maintenanceId: input.maintenanceId,
|
|
229
285
|
maintenanceTitle: maintenance.title,
|
|
230
286
|
systemIds: maintenance.systemIds,
|
|
287
|
+
systemNames,
|
|
231
288
|
action: notificationAction,
|
|
232
289
|
});
|
|
233
290
|
}
|
|
@@ -249,6 +306,10 @@ export function createRouter(
|
|
|
249
306
|
message: "Maintenance not found",
|
|
250
307
|
});
|
|
251
308
|
}
|
|
309
|
+
await cache.invalidateForMutation({
|
|
310
|
+
maintenanceId: result.id,
|
|
311
|
+
systemIds: result.systemIds,
|
|
312
|
+
});
|
|
252
313
|
// Broadcast signal for realtime updates
|
|
253
314
|
await signalService.broadcast(MAINTENANCE_UPDATED, {
|
|
254
315
|
maintenanceId: result.id,
|
|
@@ -269,12 +330,14 @@ export function createRouter(
|
|
|
269
330
|
});
|
|
270
331
|
|
|
271
332
|
// Send notifications to system subscribers
|
|
333
|
+
const systemNames = await resolveSystemNames(result.systemIds);
|
|
272
334
|
await notifyAffectedSystems({
|
|
273
335
|
catalogClient,
|
|
274
336
|
logger,
|
|
275
337
|
maintenanceId: result.id,
|
|
276
338
|
maintenanceTitle: result.title,
|
|
277
339
|
systemIds: result.systemIds,
|
|
340
|
+
systemNames,
|
|
278
341
|
action: "completed",
|
|
279
342
|
});
|
|
280
343
|
|
|
@@ -287,6 +350,11 @@ export function createRouter(
|
|
|
287
350
|
const maintenance = await service.getMaintenance(input.id);
|
|
288
351
|
const success = await service.deleteMaintenance(input.id);
|
|
289
352
|
if (success && maintenance) {
|
|
353
|
+
await cache.invalidateForMutation({
|
|
354
|
+
maintenanceId: input.id,
|
|
355
|
+
systemIds: maintenance.systemIds,
|
|
356
|
+
});
|
|
357
|
+
|
|
290
358
|
await signalService.broadcast(MAINTENANCE_UPDATED, {
|
|
291
359
|
maintenanceId: input.id,
|
|
292
360
|
systemIds: maintenance.systemIds,
|