@checkstack/announcement-backend 0.2.5 → 0.3.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 CHANGED
@@ -1,5 +1,95 @@
1
1
  # @checkstack/announcement-backend
2
2
 
3
+ ## 0.3.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/announcement-common@0.2.2
89
+ - @checkstack/auth-backend@0.4.20
90
+ - @checkstack/command-backend@0.1.20
91
+ - @checkstack/signal-common@0.1.10
92
+
3
93
  ## 0.2.5
4
94
 
5
95
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/announcement-backend",
3
- "version": "0.2.5",
3
+ "version": "0.3.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/announcement-common": "0.2.1",
18
- "@checkstack/auth-backend": "0.4.18",
20
+ "@checkstack/auth-backend": "0.4.19",
19
21
  "@checkstack/command-backend": "0.1.19",
20
22
  "@checkstack/common": "0.6.5",
21
23
  "@checkstack/signal-common": "0.1.9",
package/src/cache.ts ADDED
@@ -0,0 +1,73 @@
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
+ /** 45s — admin-driven, infrequently changing data. */
9
+ const ANNOUNCEMENT_TTL_MS = 45_000;
10
+
11
+ const ACTIVE_PREFIX = "active:";
12
+ const LIST_ALL_KEY = "list:all";
13
+
14
+ const activeKeyForUser = (userId: string, includeDismissed: boolean): string =>
15
+ `${ACTIVE_PREFIX}user:${userId}:${includeDismissed ? "all" : "undismissed"}`;
16
+ const activeKeyForAnon = (includeDismissed: boolean): string =>
17
+ `${ACTIVE_PREFIX}anon:${includeDismissed ? "all" : "undismissed"}`;
18
+ const userActivePrefix = (userId: string): string =>
19
+ `${ACTIVE_PREFIX}user:${userId}:`;
20
+
21
+ export interface AnnouncementCache {
22
+ wrapActive: <T>(
23
+ props: { userId: string | undefined; includeDismissed: boolean },
24
+ loader: () => Promise<T>,
25
+ ) => Promise<T>;
26
+ wrapListAll: <T>(loader: () => Promise<T>) => Promise<T>;
27
+
28
+ /** Drop all cached active-announcement results for every user. */
29
+ invalidateAllActive: () => Promise<number>;
30
+
31
+ /** Drop just one user's active-announcement cache (e.g. on dismissal). */
32
+ invalidateUserActive: (userId: string) => Promise<number>;
33
+
34
+ /** Drop the admin list cache. */
35
+ invalidateListAll: () => Promise<void>;
36
+
37
+ scope: CachedScope;
38
+ }
39
+
40
+ export function createAnnouncementCache({
41
+ cacheManager,
42
+ logger,
43
+ }: {
44
+ cacheManager: CacheManager;
45
+ logger: Logger;
46
+ }): AnnouncementCache {
47
+ const scope = createCachedScope({
48
+ cacheManager,
49
+ pluginId: "announcement",
50
+ defaultTtlMs: ANNOUNCEMENT_TTL_MS,
51
+ onError: (op: string, error: unknown) => {
52
+ logger.warn(`announcement cache ${op} failed: ${String(error)}`);
53
+ },
54
+ });
55
+
56
+ return {
57
+ wrapActive: ({ userId, includeDismissed }, loader) => {
58
+ const key =
59
+ userId === undefined
60
+ ? activeKeyForAnon(includeDismissed)
61
+ : activeKeyForUser(userId, includeDismissed);
62
+ return scope.wrap(key, loader);
63
+ },
64
+ wrapListAll: (loader) => scope.wrap(LIST_ALL_KEY, loader),
65
+
66
+ invalidateAllActive: () => scope.invalidatePrefix(ACTIVE_PREFIX),
67
+ invalidateUserActive: (userId) =>
68
+ scope.invalidatePrefix(userActivePrefix(userId)),
69
+ invalidateListAll: () => scope.invalidate(LIST_ALL_KEY),
70
+
71
+ scope,
72
+ };
73
+ }
package/src/index.ts CHANGED
@@ -10,6 +10,7 @@ import { resolveRoute } from "@checkstack/common";
10
10
  import { eq } from "drizzle-orm";
11
11
  import * as schema from "./schema";
12
12
  import { createAnnouncementRouter } from "./router";
13
+ import { createAnnouncementCache, type AnnouncementCache } from "./cache";
13
14
  import { authHooks } from "@checkstack/auth-backend";
14
15
  import { registerSearchProvider } from "@checkstack/command-backend";
15
16
  import type { SafeDatabase } from "@checkstack/backend-api";
@@ -21,18 +22,24 @@ export default createBackendPlugin({
21
22
  // Register access rules
22
23
  env.registerAccessRules(announcementAccessRules);
23
24
 
25
+ let announcementCache: AnnouncementCache | undefined;
26
+
24
27
  env.registerInit({
25
28
  schema,
26
29
  deps: {
27
30
  rpc: coreServices.rpc,
28
31
  logger: coreServices.logger,
29
32
  signalService: coreServices.signalService,
33
+ cacheManager: coreServices.cacheManager,
30
34
  },
31
- init: async ({ database, rpc, signalService }) => {
35
+ init: async ({ database, rpc, signalService, logger, cacheManager }) => {
32
36
  const db = database as SafeDatabase<typeof schema>;
33
37
 
38
+ const cache = createAnnouncementCache({ cacheManager, logger });
39
+ announcementCache = cache;
40
+
34
41
  // Create and register the announcement router
35
- const router = createAnnouncementRouter(db, signalService);
42
+ const router = createAnnouncementRouter(db, signalService, cache);
36
43
  rpc.registerRouter(router, announcementContract);
37
44
 
38
45
  // Register commands in the command palette
@@ -74,6 +81,7 @@ export default createBackendPlugin({
74
81
  await db
75
82
  .delete(schema.announcementDismissals)
76
83
  .where(eq(schema.announcementDismissals.userId, userId));
84
+ await announcementCache?.invalidateUserActive(userId);
77
85
  logger.debug(
78
86
  `Cleaned up announcement dismissals for user: ${userId}`,
79
87
  );
@@ -4,6 +4,16 @@ import { createMockRpcContext } from "@checkstack/backend-api";
4
4
  import { createMockDb } from "@checkstack/test-utils-backend";
5
5
  import { ANNOUNCEMENT_UPDATED } from "@checkstack/announcement-common";
6
6
  import { call } from "@orpc/server";
7
+ import type { AnnouncementCache } from "./cache";
8
+
9
+ const passthroughCache: AnnouncementCache = {
10
+ wrapActive: (_props, loader) => loader(),
11
+ wrapListAll: (loader) => loader(),
12
+ invalidateAllActive: async () => 0,
13
+ invalidateUserActive: async () => 0,
14
+ invalidateListAll: async () => {},
15
+ scope: {} as AnnouncementCache["scope"],
16
+ };
7
17
 
8
18
  describe("Announcement Router", () => {
9
19
  const adminUser = {
@@ -48,7 +58,11 @@ describe("Announcement Router", () => {
48
58
  beforeEach(() => {
49
59
  mockDb = createMockDb();
50
60
  mockSignalService.broadcast.mockClear();
51
- router = createAnnouncementRouter(mockDb as never, mockSignalService);
61
+ router = createAnnouncementRouter(
62
+ mockDb as never,
63
+ mockSignalService,
64
+ passthroughCache,
65
+ );
52
66
  });
53
67
 
54
68
  // ---------------------------------------------------------------------------
package/src/router.ts CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  import type { SafeDatabase } from "@checkstack/backend-api";
14
14
  import * as schema from "./schema";
15
15
  import { eq, and, or, lte, gte, isNull } from "drizzle-orm";
16
+ import type { AnnouncementCache } from "./cache";
16
17
 
17
18
  type AnnouncementDb = SafeDatabase<typeof schema>;
18
19
 
@@ -44,6 +45,7 @@ function toAnnouncement(
44
45
  export function createAnnouncementRouter(
45
46
  db: AnnouncementDb,
46
47
  signalService: SignalService,
48
+ cache: AnnouncementCache,
47
49
  ) {
48
50
  const os = implement(announcementContract)
49
51
  .$context<RpcContext>()
@@ -55,51 +57,59 @@ export function createAnnouncementRouter(
55
57
  // -------------------------------------------------------------------------
56
58
  getActiveAnnouncements: os.getActiveAnnouncements.handler(
57
59
  async ({ input, context }) => {
58
- const now = new Date();
59
60
  const includeDismissed = input?.includeDismissed ?? false;
60
-
61
- // Base query: active announcements within their time window
62
- const rows = await db
63
- .select()
64
- .from(schema.announcements)
65
- .where(
66
- and(
67
- eq(schema.announcements.active, true),
68
- or(
69
- isNull(schema.announcements.startsAt),
70
- lte(schema.announcements.startsAt, now),
71
- ),
72
- or(
73
- isNull(schema.announcements.expiresAt),
74
- gte(schema.announcements.expiresAt, now),
75
- ),
76
- ),
77
- );
78
-
79
- let announcements = rows.map((row) => toAnnouncement(row));
80
-
81
- // If the caller is authenticated, filter out their dismissed announcements
82
- // (unless includeDismissed is explicitly requested, e.g. for dashboard)
83
61
  const user = context.user;
84
- if (!includeDismissed && user && "id" in user) {
85
- const userId = (user as RealUser).id;
86
- const dismissals = await db
87
- .select({ announcementId: schema.announcementDismissals.announcementId })
88
- .from(schema.announcementDismissals)
89
- .where(eq(schema.announcementDismissals.userId, userId));
90
-
91
- const dismissedIds = new Set(dismissals.map((d) => d.announcementId));
92
- announcements = announcements.filter((a) => !dismissedIds.has(a.id));
93
- }
94
-
95
- // Filter visibility for unauthenticated users
96
- if (!user) {
97
- announcements = announcements.filter(
98
- (a) => a.visibility === "all",
99
- );
100
- }
101
-
102
- return { announcements };
62
+ const userId =
63
+ user && "id" in user ? (user as RealUser).id : undefined;
64
+
65
+ return cache.wrapActive({ userId, includeDismissed }, async () => {
66
+ const now = new Date();
67
+
68
+ // Base query: active announcements within their time window
69
+ const rows = await db
70
+ .select()
71
+ .from(schema.announcements)
72
+ .where(
73
+ and(
74
+ eq(schema.announcements.active, true),
75
+ or(
76
+ isNull(schema.announcements.startsAt),
77
+ lte(schema.announcements.startsAt, now),
78
+ ),
79
+ or(
80
+ isNull(schema.announcements.expiresAt),
81
+ gte(schema.announcements.expiresAt, now),
82
+ ),
83
+ ),
84
+ );
85
+
86
+ let announcements = rows.map((row) => toAnnouncement(row));
87
+
88
+ // If the caller is authenticated, filter out their dismissed announcements
89
+ // (unless includeDismissed is explicitly requested, e.g. for dashboard)
90
+ if (!includeDismissed && userId !== undefined) {
91
+ const dismissals = await db
92
+ .select({ announcementId: schema.announcementDismissals.announcementId })
93
+ .from(schema.announcementDismissals)
94
+ .where(eq(schema.announcementDismissals.userId, userId));
95
+
96
+ const dismissedIds = new Set(
97
+ dismissals.map((d) => d.announcementId),
98
+ );
99
+ announcements = announcements.filter(
100
+ (a) => !dismissedIds.has(a.id),
101
+ );
102
+ }
103
+
104
+ // Filter visibility for unauthenticated users
105
+ if (userId === undefined) {
106
+ announcements = announcements.filter(
107
+ (a) => a.visibility === "all",
108
+ );
109
+ }
110
+
111
+ return { announcements };
112
+ });
103
113
  },
104
114
  ),
105
115
 
@@ -132,20 +142,25 @@ export function createAnnouncementRouter(
132
142
  dismissedAt: new Date(),
133
143
  })
134
144
  .onConflictDoNothing();
145
+
146
+ // Drop only this user's cache — other users' dismissals are unaffected.
147
+ await cache.invalidateUserActive(userId);
135
148
  },
136
149
  ),
137
150
 
138
151
  // -------------------------------------------------------------------------
139
152
  // Admin: List all announcements
140
153
  // -------------------------------------------------------------------------
141
- listAllAnnouncements: os.listAllAnnouncements.handler(async () => {
142
- const rows = await db
143
- .select()
144
- .from(schema.announcements)
145
- .orderBy(schema.announcements.createdAt);
154
+ listAllAnnouncements: os.listAllAnnouncements.handler(async () =>
155
+ cache.wrapListAll(async () => {
156
+ const rows = await db
157
+ .select()
158
+ .from(schema.announcements)
159
+ .orderBy(schema.announcements.createdAt);
146
160
 
147
- return { announcements: rows.map((row) => toAnnouncement(row)) };
148
- }),
161
+ return { announcements: rows.map((row) => toAnnouncement(row)) };
162
+ }),
163
+ ),
149
164
 
150
165
  // -------------------------------------------------------------------------
151
166
  // Admin: Create announcement
@@ -177,6 +192,11 @@ export function createAnnouncementRouter(
177
192
 
178
193
  const announcement = toAnnouncement(row);
179
194
 
195
+ await Promise.all([
196
+ cache.invalidateAllActive(),
197
+ cache.invalidateListAll(),
198
+ ]);
199
+
180
200
  await signalService.broadcast(ANNOUNCEMENT_UPDATED, {
181
201
  announcementId: announcement.id,
182
202
  action: "created",
@@ -241,6 +261,11 @@ export function createAnnouncementRouter(
241
261
  .returning({ id: schema.announcements.id });
242
262
 
243
263
  if (result.length > 0) {
264
+ await Promise.all([
265
+ cache.invalidateAllActive(),
266
+ cache.invalidateListAll(),
267
+ ]);
268
+
244
269
  await signalService.broadcast(ANNOUNCEMENT_UPDATED, {
245
270
  announcementId: input.id,
246
271
  action: "deleted",