@checkstack/notification-backend 0.1.23 → 0.2.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,109 @@
1
1
  # @checkstack/notification-backend
2
2
 
3
+ ## 0.2.1
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [208ad71]
8
+ - @checkstack/signal-common@0.2.0
9
+ - @checkstack/notification-common@0.3.0
10
+ - @checkstack/backend-api@0.13.1
11
+ - @checkstack/auth-backend@0.4.21
12
+ - @checkstack/cache-api@0.2.1
13
+ - @checkstack/queue-api@0.2.15
14
+ - @checkstack/cache-utils@0.2.1
15
+
16
+ ## 0.2.0
17
+
18
+ ### Minor Changes
19
+
20
+ - 8d1ef12: ## Per-entity caching with single-flight + safe invalidation across the dashboard hot paths
21
+
22
+ ### `@checkstack/cache-api`
23
+
24
+ - **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.
25
+ - `createScopedCache` forwards `deleteByPrefix` and keeps prefixes scoped to the calling plugin.
26
+
27
+ ### `@checkstack/cache-utils` (new package)
28
+
29
+ High-level read-through caching helpers built on `CacheProvider`:
30
+
31
+ - `createCachedScope({ cacheManager, pluginId })` returns a scope with `wrap`, `wrapMany`, `invalidate`, and `invalidatePrefix`.
32
+ - **Single-flight**: concurrent cache misses for the same key share one loader.
33
+ - **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.
34
+ - **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`.
35
+ - Cache failures fall through to the loader so a cache outage cannot break reads.
36
+
37
+ ### `@checkstack/backend`
38
+
39
+ - 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.
40
+
41
+ ### `@checkstack/healthcheck-backend`
42
+
43
+ - `getSystemHealthStatus` and `getBulkSystemHealthStatus` now read through a per-system cache (`healthcheck:status:<systemId>`), eliminating N database queries per dashboard refresh for unchanged systems.
44
+ - 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.
45
+
46
+ ### `@checkstack/incident-backend`
47
+
48
+ - `listIncidents`, `getIncident`, `getIncidentsForSystem`, and `getBulkIncidentsForSystems` now read through a scoped cache:
49
+ - per-incident at `incident:<id>`
50
+ - per-system at `system:<systemId>`
51
+ - per-filter-shape at `list:<stable-stringify(filters)>` for the few list shapes the dashboard polls
52
+ - Mutations (`createIncident`, `updateIncident`, `addUpdate`, `resolveIncident`, `deleteIncident`) invalidate the incident, every affected system, and every cached list before broadcasting `INCIDENT_UPDATED`.
53
+ - The catalog `systemDeleted` cleanup hook drops that system's cached entries.
54
+
55
+ ### `@checkstack/maintenance-backend`
56
+
57
+ - `listMaintenances`, `getMaintenance`, `getMaintenancesForSystem`, and `getBulkMaintenancesForSystems` use the same per-entity / per-system / per-filter-shape pattern as incidents.
58
+ - Mutations (`createMaintenance`, `updateMaintenance`, `addUpdate`, `closeMaintenance`, `deleteMaintenance`) invalidate before broadcasting `MAINTENANCE_UPDATED`.
59
+
60
+ ### `@checkstack/catalog-backend`
61
+
62
+ - Topology reads (`getEntities`, `getSystems`, `getSystem`, `getGroups`, `getSystemGroupIds`) cache under the `entity:` family (25s TTL).
63
+ - Views (`getViews`) and per-system contacts (`getSystemContacts`) cache in their own families.
64
+ - System / group / membership mutations drop the entire `entity:` family (every reader joins the same tables); view and contact mutations drop only their respective scopes.
65
+
66
+ ### `@checkstack/slo-backend`
67
+
68
+ - `listObjectives`, `getObjective`, `getObjectivesForSystem`, and `getBulkObjectivesForSystems` cache results including the expensive `engine.computeStatus` output.
69
+ - Per-entity caching for the bulk handler so dashboards with overlapping system sets share entries.
70
+ - Mutations (`createObjective`, `updateObjective`, `deleteObjective`) invalidate before broadcasting `SLO_STATUS_CHANGED`.
71
+
72
+ ### `@checkstack/anomaly-backend`
73
+
74
+ - New `router-cache.ts` adds a cache scope distinct from the existing detector baseline cache, keyed by stable filter hash.
75
+ - `getAnomalies` and `getAnomalyBaselines` cache through this scope (15s TTL).
76
+ - The detector invalidates the router cache before broadcasting `ANOMALY_STATE_CHANGED` on every state transition (suspicious/anomaly/recovered).
77
+ - Config mutations also invalidate.
78
+
79
+ ### `@checkstack/notification-backend`
80
+
81
+ - `getUnreadCount`, `getNotifications`, and `getSubscriptions` cache per-user.
82
+ - `markAsRead`, `deleteNotification`, `notifyUsers`, and `notifyGroups` invalidate every affected user's cache before sending realtime signals to that user.
83
+ - `subscribe` and `unsubscribe` invalidate the user's subscription cache.
84
+
85
+ ### `@checkstack/announcement-backend`
86
+
87
+ - `getActiveAnnouncements` caches per-user (or anonymous) and per-`includeDismissed` flag (45s TTL — admin-driven, slowly changing).
88
+ - `listAllAnnouncements` caches under a single key.
89
+ - `dismissAnnouncement` only drops that user's cache; `createAnnouncement`, `updateAnnouncement`, `deleteAnnouncement` drop every user's cache before broadcasting `ANNOUNCEMENT_UPDATED`.
90
+ - The auth `userDeleted` cleanup hook drops that user's cached entries.
91
+
92
+ ### Patch Changes
93
+
94
+ - Updated dependencies [8d1ef12]
95
+ - Updated dependencies [8d1ef12]
96
+ - Updated dependencies [8d1ef12]
97
+ - @checkstack/common@0.7.0
98
+ - @checkstack/cache-api@0.2.0
99
+ - @checkstack/cache-utils@0.2.0
100
+ - @checkstack/backend-api@0.13.0
101
+ - @checkstack/auth-backend@0.4.20
102
+ - @checkstack/auth-common@0.6.3
103
+ - @checkstack/notification-common@0.2.9
104
+ - @checkstack/signal-common@0.1.10
105
+ - @checkstack/queue-api@0.2.14
106
+
3
107
  ## 0.1.23
4
108
 
5
109
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/notification-backend",
3
- "version": "0.1.23",
3
+ "version": "0.2.1",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "checkstack": {
@@ -14,22 +14,24 @@
14
14
  "test": "bun test"
15
15
  },
16
16
  "dependencies": {
17
- "@checkstack/notification-common": "0.2.8",
18
- "@checkstack/backend-api": "0.12.0",
19
- "@checkstack/signal-common": "0.1.9",
20
- "@checkstack/queue-api": "0.2.13",
21
- "@checkstack/auth-backend": "0.4.18",
22
- "@checkstack/auth-common": "0.6.1",
17
+ "@checkstack/notification-common": "0.2.9",
18
+ "@checkstack/backend-api": "0.13.0",
19
+ "@checkstack/cache-api": "0.2.0",
20
+ "@checkstack/cache-utils": "0.2.0",
21
+ "@checkstack/signal-common": "0.1.10",
22
+ "@checkstack/queue-api": "0.2.14",
23
+ "@checkstack/auth-backend": "0.4.20",
24
+ "@checkstack/auth-common": "0.6.3",
23
25
  "drizzle-orm": "^0.45.0",
24
26
  "zod": "^4.2.1",
25
- "@checkstack/common": "0.6.5",
27
+ "@checkstack/common": "0.7.0",
26
28
  "@orpc/server": "^1.13.2"
27
29
  },
28
30
  "devDependencies": {
29
31
  "@checkstack/drizzle-helper": "0.0.4",
30
32
  "@checkstack/scripts": "0.1.2",
31
33
  "@checkstack/tsconfig": "0.0.5",
32
- "@checkstack/test-utils-backend": "0.1.19",
34
+ "@checkstack/test-utils-backend": "0.1.20",
33
35
  "@types/node": "^20.0.0",
34
36
  "typescript": "^5.0.0"
35
37
  }
package/src/cache.ts ADDED
@@ -0,0 +1,101 @@
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
+ * 15s. The NotificationBell on every page polls `getUnreadCount` every 30s,
10
+ * so even short caching provides huge savings. Mutations send signals to the
11
+ * affected users which trigger frontend refetches — invalidation must
12
+ * complete before the signal so the refetch hits a fresh DB read.
13
+ */
14
+ const NOTIFICATION_TTL_MS = 15_000;
15
+
16
+ const UNREAD_PREFIX = "unread:";
17
+ const NOTIFS_PREFIX = "notifs:";
18
+ const SUBS_PREFIX = "subs:";
19
+
20
+ const unreadKey = (userId: string): string => `${UNREAD_PREFIX}${userId}`;
21
+ const subsKey = (userId: string): string => `${SUBS_PREFIX}${userId}`;
22
+
23
+ function stableStringify(value: unknown): string {
24
+ if (value === null || typeof value !== "object") {
25
+ return JSON.stringify(value);
26
+ }
27
+ if (Array.isArray(value)) {
28
+ return `[${value.map((v) => stableStringify(v)).join(",")}]`;
29
+ }
30
+ const entries = Object.entries(value as Record<string, unknown>)
31
+ .filter(([, v]) => v !== undefined)
32
+ .toSorted(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
33
+ return `{${entries
34
+ .map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`)
35
+ .join(",")}}`;
36
+ }
37
+
38
+ const notifsKey = (userId: string, filters: unknown): string =>
39
+ `${NOTIFS_PREFIX}${userId}:${stableStringify(filters ?? {})}`;
40
+ const notifsPrefixForUser = (userId: string): string =>
41
+ `${NOTIFS_PREFIX}${userId}:`;
42
+
43
+ export interface NotificationCache {
44
+ wrapUnread: <T>(userId: string, loader: () => Promise<T>) => Promise<T>;
45
+ wrapNotifications: <T>(
46
+ userId: string,
47
+ filters: unknown,
48
+ loader: () => Promise<T>,
49
+ ) => Promise<T>;
50
+ wrapSubscriptions: <T>(
51
+ userId: string,
52
+ loader: () => Promise<T>,
53
+ ) => Promise<T>;
54
+
55
+ /**
56
+ * Drop unread + notifications cache for a single user. Used after a
57
+ * mutation that adds/removes/marks-as-read a notification for that user.
58
+ */
59
+ invalidateForUser: (userId: string) => Promise<void>;
60
+
61
+ /** Drop subscriptions cache for one user (subscribe/unsubscribe). */
62
+ invalidateSubscriptions: (userId: string) => Promise<void>;
63
+
64
+ scope: CachedScope;
65
+ }
66
+
67
+ export function createNotificationCache({
68
+ cacheManager,
69
+ logger,
70
+ }: {
71
+ cacheManager: CacheManager;
72
+ logger: Logger;
73
+ }): NotificationCache {
74
+ const scope = createCachedScope({
75
+ cacheManager,
76
+ pluginId: "notification",
77
+ defaultTtlMs: NOTIFICATION_TTL_MS,
78
+ onError: (op: string, error: unknown) => {
79
+ logger.warn(`notification cache ${op} failed: ${String(error)}`);
80
+ },
81
+ });
82
+
83
+ return {
84
+ wrapUnread: (userId, loader) => scope.wrap(unreadKey(userId), loader),
85
+ wrapNotifications: (userId, filters, loader) =>
86
+ scope.wrap(notifsKey(userId, filters), loader),
87
+ wrapSubscriptions: (userId, loader) =>
88
+ scope.wrap(subsKey(userId), loader),
89
+
90
+ invalidateForUser: async (userId) => {
91
+ await Promise.all([
92
+ scope.invalidate(unreadKey(userId)),
93
+ scope.invalidatePrefix(notifsPrefixForUser(userId)),
94
+ ]);
95
+ },
96
+
97
+ invalidateSubscriptions: (userId) => scope.invalidate(subsKey(userId)),
98
+
99
+ scope,
100
+ };
101
+ }
package/src/index.ts CHANGED
@@ -21,6 +21,7 @@ import { eq } from "drizzle-orm";
21
21
 
22
22
  import * as schema from "./schema";
23
23
  import { createNotificationRouter } from "./router";
24
+ import { createNotificationCache } from "./cache";
24
25
  import { authHooks } from "@checkstack/auth-backend";
25
26
  import { createOAuthCallbackHandler } from "./oauth-callback-handler";
26
27
  import { createStrategyService } from "./strategy-service";
@@ -157,6 +158,7 @@ export default createBackendPlugin({
157
158
  rpcClient: coreServices.rpcClient,
158
159
  config: coreServices.config,
159
160
  signalService: coreServices.signalService,
161
+ cacheManager: coreServices.cacheManager,
160
162
  },
161
163
  init: async ({
162
164
  logger,
@@ -165,6 +167,7 @@ export default createBackendPlugin({
165
167
  rpcClient,
166
168
  config,
167
169
  signalService,
170
+ cacheManager,
168
171
  }) => {
169
172
  logger.debug("🔔 Initializing Notification Backend...");
170
173
 
@@ -184,6 +187,8 @@ export default createBackendPlugin({
184
187
  env as unknown as { strategyService: typeof strategyService }
185
188
  ).strategyService = strategyService;
186
189
 
190
+ const cache = createNotificationCache({ cacheManager, logger });
191
+
187
192
  // Create and register the notification router with strategy registry
188
193
  const router = createNotificationRouter(
189
194
  db,
@@ -191,7 +196,8 @@ export default createBackendPlugin({
191
196
  signalService,
192
197
  strategyRegistry,
193
198
  rpcClient,
194
- logger
199
+ logger,
200
+ cache,
195
201
  );
196
202
  rpc.registerRouter(router, notificationContract);
197
203
 
package/src/router.ts CHANGED
@@ -18,6 +18,7 @@ import {
18
18
  } from "@checkstack/notification-common";
19
19
  import { AuthApi } from "@checkstack/auth-common";
20
20
  import type { SignalService } from "@checkstack/signal-common";
21
+ import type { NotificationCache } from "./cache";
21
22
  import { SafeDatabase } from "@checkstack/backend-api";
22
23
  import * as schema from "./schema";
23
24
  import {
@@ -95,7 +96,8 @@ export const createNotificationRouter = (
95
96
  signalService: SignalService,
96
97
  strategyRegistry: NotificationStrategyRegistry,
97
98
  rpcApi: RpcClient,
98
- logger: Logger
99
+ logger: Logger,
100
+ cache: NotificationCache,
99
101
  ) => {
100
102
  // Create strategy service for config management
101
103
  const strategyService: StrategyService = createStrategyService({
@@ -269,39 +271,46 @@ export const createNotificationRouter = (
269
271
  // context.user is guaranteed to be RealUser by contract meta + autoAuthMiddleware
270
272
  const userId = (context.user as RealUser).id;
271
273
 
272
- const result = await getUserNotifications(database, userId, {
273
- limit: input.limit,
274
- offset: input.offset,
275
- unreadOnly: input.unreadOnly,
276
- });
274
+ return cache.wrapNotifications(userId, input, async () => {
275
+ const result = await getUserNotifications(database, userId, {
276
+ limit: input.limit,
277
+ offset: input.offset,
278
+ unreadOnly: input.unreadOnly,
279
+ });
277
280
 
278
- return {
279
- notifications: result.notifications.map((n) => ({
280
- id: n.id,
281
- userId: n.userId,
282
- title: n.title,
283
- body: n.body,
284
- action: n.action ?? undefined,
285
- importance: n.importance as "info" | "warning" | "critical",
286
- isRead: n.isRead,
287
- groupId: n.groupId ?? undefined,
288
- createdAt: n.createdAt,
289
- })),
290
- total: result.total,
291
- };
281
+ return {
282
+ notifications: result.notifications.map((n) => ({
283
+ id: n.id,
284
+ userId: n.userId,
285
+ title: n.title,
286
+ body: n.body,
287
+ action: n.action ?? undefined,
288
+ importance: n.importance as "info" | "warning" | "critical",
289
+ isRead: n.isRead,
290
+ groupId: n.groupId ?? undefined,
291
+ createdAt: n.createdAt,
292
+ })),
293
+ total: result.total,
294
+ };
295
+ });
292
296
  }
293
297
  ),
294
298
 
295
299
  getUnreadCount: os.getUnreadCount.handler(async ({ context }) => {
296
300
  const userId = (context.user as RealUser).id;
297
- const count = await getUnreadCount(database, userId);
298
- return { count };
301
+ return cache.wrapUnread(userId, async () => {
302
+ const count = await getUnreadCount(database, userId);
303
+ return { count };
304
+ });
299
305
  }),
300
306
 
301
307
  markAsRead: os.markAsRead.handler(async ({ input, context }) => {
302
308
  const userId = (context.user as RealUser).id;
303
309
  await markAsRead(database, userId, input.notificationId);
304
310
 
311
+ // Mutation invariant: db.write → cache.invalidate (await) → signal.
312
+ await cache.invalidateForUser(userId);
313
+
305
314
  // Send signal to update NotificationBell in realtime
306
315
  void signalService.sendToUser(NOTIFICATION_READ, userId, {
307
316
  notificationId: input.notificationId,
@@ -312,6 +321,7 @@ export const createNotificationRouter = (
312
321
  async ({ input, context }) => {
313
322
  const userId = (context.user as RealUser).id;
314
323
  await deleteNotification(database, userId, input.notificationId);
324
+ await cache.invalidateForUser(userId);
315
325
  }
316
326
  ),
317
327
 
@@ -333,11 +343,9 @@ export const createNotificationRouter = (
333
343
 
334
344
  getSubscriptions: os.getSubscriptions.handler(async ({ context }) => {
335
345
  const userId = (context.user as RealUser).id;
336
- const subscriptions = await getEnrichedUserSubscriptions(
337
- database,
338
- userId
346
+ return cache.wrapSubscriptions(userId, () =>
347
+ getEnrichedUserSubscriptions(database, userId),
339
348
  );
340
- return subscriptions;
341
349
  }),
342
350
 
343
351
  subscribe: os.subscribe.handler(async ({ input, context }) => {
@@ -354,11 +362,13 @@ export const createNotificationRouter = (
354
362
  }
355
363
  throw error;
356
364
  }
365
+ await cache.invalidateSubscriptions(userId);
357
366
  }),
358
367
 
359
368
  unsubscribe: os.unsubscribe.handler(async ({ input, context }) => {
360
369
  const userId = (context.user as RealUser).id;
361
370
  await unsubscribeFromGroup(database, userId, input.groupId);
371
+ await cache.invalidateSubscriptions(userId);
362
372
  }),
363
373
 
364
374
  // ==========================================================================
@@ -465,6 +475,12 @@ export const createNotificationRouter = (
465
475
  userId: schema.notifications.userId,
466
476
  });
467
477
 
478
+ // Drop each affected user's cache before signaling, so the
479
+ // NotificationBell's refetch sees the new unread count.
480
+ await Promise.all(
481
+ userIds.map((userId) => cache.invalidateForUser(userId)),
482
+ );
483
+
468
484
  // Send realtime signals to each user
469
485
  for (const notification of inserted) {
470
486
  void signalService.sendToUser(
@@ -527,6 +543,10 @@ export const createNotificationRouter = (
527
543
  userId: schema.notifications.userId,
528
544
  });
529
545
 
546
+ await Promise.all(
547
+ subscribers.map((sub) => cache.invalidateForUser(sub.userId)),
548
+ );
549
+
530
550
  // Send realtime signals to each subscriber
531
551
  for (const notification of inserted) {
532
552
  void signalService.sendToUser(