@checkstack/cache-memory-backend 0.2.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 +88 -0
- package/package.json +26 -0
- package/src/index.ts +23 -0
- package/src/memory-cache.test.ts +219 -0
- package/src/memory-cache.ts +137 -0
- package/src/plugin-metadata.ts +9 -0
- package/src/plugin.ts +34 -0
- package/tsconfig.json +6 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# @checkstack/cache-memory-backend
|
|
2
|
+
|
|
3
|
+
## 0.2.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
|
+
- Updated dependencies [8d1ef12]
|
|
85
|
+
- @checkstack/cache-memory-common@0.2.0
|
|
86
|
+
- @checkstack/common@0.7.0
|
|
87
|
+
- @checkstack/cache-api@0.2.0
|
|
88
|
+
- @checkstack/backend-api@0.13.0
|
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@checkstack/cache-memory-backend",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "src/index.ts",
|
|
6
|
+
"checkstack": {
|
|
7
|
+
"type": "backend"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"typecheck": "tsc --noEmit",
|
|
11
|
+
"lint": "bun run lint:code",
|
|
12
|
+
"lint:code": "eslint . --max-warnings 0"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@checkstack/backend-api": "0.12.0",
|
|
16
|
+
"@checkstack/cache-api": "0.1.0",
|
|
17
|
+
"@checkstack/cache-memory-common": "0.1.0",
|
|
18
|
+
"@checkstack/common": "0.6.5",
|
|
19
|
+
"zod": "^4.2.1"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@types/bun": "latest",
|
|
23
|
+
"@checkstack/tsconfig": "0.0.5",
|
|
24
|
+
"@checkstack/scripts": "0.1.2"
|
|
25
|
+
}
|
|
26
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { createBackendPlugin, coreServices } from "@checkstack/backend-api";
|
|
2
|
+
import { InMemoryCachePlugin } from "./plugin";
|
|
3
|
+
import { cacheMemoryAccessRules } from "@checkstack/cache-memory-common";
|
|
4
|
+
import { pluginMetadata } from "./plugin-metadata";
|
|
5
|
+
|
|
6
|
+
export default createBackendPlugin({
|
|
7
|
+
metadata: pluginMetadata,
|
|
8
|
+
register(env) {
|
|
9
|
+
env.registerAccessRules(cacheMemoryAccessRules);
|
|
10
|
+
|
|
11
|
+
env.registerInit({
|
|
12
|
+
deps: {
|
|
13
|
+
cachePluginRegistry: coreServices.cachePluginRegistry,
|
|
14
|
+
logger: coreServices.logger,
|
|
15
|
+
},
|
|
16
|
+
init: async ({ cachePluginRegistry, logger }) => {
|
|
17
|
+
logger.debug("🔌 Registering In-Memory Cache Plugin...");
|
|
18
|
+
const plugin = new InMemoryCachePlugin();
|
|
19
|
+
cachePluginRegistry.register(plugin);
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
},
|
|
23
|
+
});
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from "bun:test";
|
|
2
|
+
import { InMemoryCache } from "./memory-cache";
|
|
3
|
+
|
|
4
|
+
describe("InMemoryCache", () => {
|
|
5
|
+
const caches: InMemoryCache[] = [];
|
|
6
|
+
|
|
7
|
+
function createCache({
|
|
8
|
+
maxEntries = 100,
|
|
9
|
+
sweepIntervalMs = 0,
|
|
10
|
+
}: { maxEntries?: number; sweepIntervalMs?: number } = {}): InMemoryCache {
|
|
11
|
+
const cache = new InMemoryCache({ maxEntries, sweepIntervalMs });
|
|
12
|
+
caches.push(cache);
|
|
13
|
+
return cache;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
for (const cache of caches) {
|
|
18
|
+
cache.stop();
|
|
19
|
+
}
|
|
20
|
+
caches.length = 0;
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("basic operations", () => {
|
|
24
|
+
it("returns undefined for missing keys", async () => {
|
|
25
|
+
const cache = createCache();
|
|
26
|
+
expect(await cache.get("nonexistent")).toBeUndefined();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("stores and retrieves values", async () => {
|
|
30
|
+
const cache = createCache();
|
|
31
|
+
await cache.set("key1", { data: "hello" });
|
|
32
|
+
expect(await cache.get<{ data: string }>("key1")).toEqual({
|
|
33
|
+
data: "hello",
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("stores and retrieves primitive values", async () => {
|
|
38
|
+
const cache = createCache();
|
|
39
|
+
await cache.set("num", 42);
|
|
40
|
+
await cache.set("str", "test");
|
|
41
|
+
await cache.set("bool", true);
|
|
42
|
+
|
|
43
|
+
expect(await cache.get<number>("num")).toBe(42);
|
|
44
|
+
expect(await cache.get<string>("str")).toBe("test");
|
|
45
|
+
expect(await cache.get<boolean>("bool")).toBe(true);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("overwrites existing values", async () => {
|
|
49
|
+
const cache = createCache();
|
|
50
|
+
await cache.set("key", "v1");
|
|
51
|
+
await cache.set("key", "v2");
|
|
52
|
+
expect(await cache.get<string>("key")).toBe("v2");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("deletes values", async () => {
|
|
56
|
+
const cache = createCache();
|
|
57
|
+
await cache.set("key", "value");
|
|
58
|
+
expect(await cache.has("key")).toBe(true);
|
|
59
|
+
|
|
60
|
+
await cache.delete("key");
|
|
61
|
+
expect(await cache.has("key")).toBe(false);
|
|
62
|
+
expect(await cache.get("key")).toBeUndefined();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("delete is a no-op for missing keys", async () => {
|
|
66
|
+
const cache = createCache();
|
|
67
|
+
// Should not throw
|
|
68
|
+
await cache.delete("nonexistent");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("has returns false for missing keys", async () => {
|
|
72
|
+
const cache = createCache();
|
|
73
|
+
expect(await cache.has("missing")).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("has returns true for existing keys", async () => {
|
|
77
|
+
const cache = createCache();
|
|
78
|
+
await cache.set("present", 1);
|
|
79
|
+
expect(await cache.has("present")).toBe(true);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("TTL expiration", () => {
|
|
84
|
+
it("returns value before TTL expires", async () => {
|
|
85
|
+
const cache = createCache();
|
|
86
|
+
await cache.set("key", "value", 10_000);
|
|
87
|
+
expect(await cache.get<string>("key")).toBe("value");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("returns undefined after TTL expires (passive eviction on get)", async () => {
|
|
91
|
+
const cache = createCache();
|
|
92
|
+
// Set with 1ms TTL
|
|
93
|
+
await cache.set("key", "value", 1);
|
|
94
|
+
// Wait for expiry
|
|
95
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
96
|
+
expect(await cache.get("key")).toBeUndefined();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("has returns false after TTL expires", async () => {
|
|
100
|
+
const cache = createCache();
|
|
101
|
+
await cache.set("key", "value", 1);
|
|
102
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
103
|
+
expect(await cache.has("key")).toBe(false);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("set without TTL creates non-expiring entry", async () => {
|
|
107
|
+
const cache = createCache();
|
|
108
|
+
await cache.set("key", "forever");
|
|
109
|
+
// Should always be available
|
|
110
|
+
expect(await cache.get<string>("key")).toBe("forever");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("overwriting resets TTL", async () => {
|
|
114
|
+
const cache = createCache();
|
|
115
|
+
await cache.set("key", "v1", 1);
|
|
116
|
+
// Overwrite with no TTL
|
|
117
|
+
await cache.set("key", "v2");
|
|
118
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
119
|
+
// Should still be available since new entry has no TTL
|
|
120
|
+
expect(await cache.get<string>("key")).toBe("v2");
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe("capacity eviction", () => {
|
|
125
|
+
it("evicts oldest entries when maxEntries is reached", async () => {
|
|
126
|
+
const cache = createCache({ maxEntries: 3 });
|
|
127
|
+
await cache.set("a", 1);
|
|
128
|
+
await cache.set("b", 2);
|
|
129
|
+
await cache.set("c", 3);
|
|
130
|
+
|
|
131
|
+
// Adding a 4th should evict "a" (oldest)
|
|
132
|
+
await cache.set("d", 4);
|
|
133
|
+
|
|
134
|
+
expect(await cache.has("a")).toBe(false);
|
|
135
|
+
expect(await cache.get<number>("b")).toBe(2);
|
|
136
|
+
expect(await cache.get<number>("c")).toBe(3);
|
|
137
|
+
expect(await cache.get<number>("d")).toBe(4);
|
|
138
|
+
expect(cache.size).toBe(3);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("overwriting existing key does not trigger eviction", async () => {
|
|
142
|
+
const cache = createCache({ maxEntries: 2 });
|
|
143
|
+
await cache.set("a", 1);
|
|
144
|
+
await cache.set("b", 2);
|
|
145
|
+
|
|
146
|
+
// Overwrite "a" — should NOT evict anything
|
|
147
|
+
await cache.set("a", 10);
|
|
148
|
+
|
|
149
|
+
expect(await cache.get<number>("a")).toBe(10);
|
|
150
|
+
expect(await cache.get<number>("b")).toBe(2);
|
|
151
|
+
expect(cache.size).toBe(2);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe("deleteByPrefix", () => {
|
|
156
|
+
it("removes only keys starting with the prefix", async () => {
|
|
157
|
+
const cache = createCache();
|
|
158
|
+
await cache.set("incident:list:active", "a");
|
|
159
|
+
await cache.set("incident:get:1", "b");
|
|
160
|
+
await cache.set("incident:get:2", "c");
|
|
161
|
+
await cache.set("healthcheck:status:s1", "d");
|
|
162
|
+
|
|
163
|
+
const removed = await cache.deleteByPrefix("incident:get:");
|
|
164
|
+
|
|
165
|
+
expect(removed).toBe(2);
|
|
166
|
+
expect(await cache.has("incident:list:active")).toBe(true);
|
|
167
|
+
expect(await cache.has("incident:get:1")).toBe(false);
|
|
168
|
+
expect(await cache.has("incident:get:2")).toBe(false);
|
|
169
|
+
expect(await cache.has("healthcheck:status:s1")).toBe(true);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("returns 0 when no keys match", async () => {
|
|
173
|
+
const cache = createCache();
|
|
174
|
+
await cache.set("foo", "bar");
|
|
175
|
+
expect(await cache.deleteByPrefix("nope:")).toBe(0);
|
|
176
|
+
expect(await cache.has("foo")).toBe(true);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("treats expired entries as deletable", async () => {
|
|
180
|
+
const cache = createCache();
|
|
181
|
+
await cache.set("scope:a", "x", 1);
|
|
182
|
+
await cache.set("scope:b", "y");
|
|
183
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
184
|
+
|
|
185
|
+
// Both keys are still in the underlying map (passive eviction);
|
|
186
|
+
// deleteByPrefix sweeps both regardless.
|
|
187
|
+
const removed = await cache.deleteByPrefix("scope:");
|
|
188
|
+
expect(removed).toBe(2);
|
|
189
|
+
expect(cache.size).toBe(0);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe("sweep", () => {
|
|
194
|
+
it("periodic sweep removes expired entries", async () => {
|
|
195
|
+
// Sweep every 10ms
|
|
196
|
+
const cache = createCache({ sweepIntervalMs: 10 });
|
|
197
|
+
await cache.set("expires", "value", 1);
|
|
198
|
+
await cache.set("stays", "value");
|
|
199
|
+
|
|
200
|
+
// Wait for sweep to run
|
|
201
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
202
|
+
|
|
203
|
+
// "expires" should have been swept
|
|
204
|
+
expect(cache.size).toBe(1);
|
|
205
|
+
expect(await cache.get<string>("stays")).toBe("value");
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
describe("stop", () => {
|
|
210
|
+
it("stops the sweep timer", async () => {
|
|
211
|
+
const cache = createCache({ sweepIntervalMs: 10 });
|
|
212
|
+
cache.stop();
|
|
213
|
+
// Should not throw or continue sweeping
|
|
214
|
+
await cache.set("key", "value", 1);
|
|
215
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
216
|
+
// Entry may or may not be expired (passive eviction), but no crash
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
});
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import type { CacheProvider } from "@checkstack/cache-api";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Internal cache entry with optional expiration tracking.
|
|
5
|
+
*/
|
|
6
|
+
interface CacheEntry {
|
|
7
|
+
value: unknown;
|
|
8
|
+
/** Absolute timestamp (ms since epoch) when this entry expires, or undefined for no expiry */
|
|
9
|
+
expiresAt: number | undefined;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Configuration for the InMemoryCache.
|
|
14
|
+
*/
|
|
15
|
+
export interface InMemoryCacheConfig {
|
|
16
|
+
maxEntries: number;
|
|
17
|
+
sweepIntervalMs: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* In-memory CacheProvider implementation using a Map.
|
|
22
|
+
*
|
|
23
|
+
* Features:
|
|
24
|
+
* - Passive TTL eviction: expired entries are detected on `get` and `has`
|
|
25
|
+
* - Active sweep: periodic background sweep removes expired entries
|
|
26
|
+
* - LRU-style eviction: oldest entries are evicted when maxEntries is reached
|
|
27
|
+
*/
|
|
28
|
+
export class InMemoryCache implements CacheProvider {
|
|
29
|
+
private store = new Map<string, CacheEntry>();
|
|
30
|
+
private sweepTimer: ReturnType<typeof setInterval> | undefined;
|
|
31
|
+
|
|
32
|
+
constructor(private config: InMemoryCacheConfig) {
|
|
33
|
+
// Start periodic sweep if configured
|
|
34
|
+
if (config.sweepIntervalMs > 0) {
|
|
35
|
+
this.sweepTimer = setInterval(() => {
|
|
36
|
+
this.sweep();
|
|
37
|
+
}, config.sweepIntervalMs);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async get<T>(key: string): Promise<T | undefined> {
|
|
42
|
+
const entry = this.store.get(key);
|
|
43
|
+
if (!entry) return undefined;
|
|
44
|
+
|
|
45
|
+
// Passive TTL eviction
|
|
46
|
+
if (entry.expiresAt !== undefined && Date.now() > entry.expiresAt) {
|
|
47
|
+
this.store.delete(key);
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return entry.value as T;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async set<T>(key: string, value: T, ttlMs?: number): Promise<void> {
|
|
55
|
+
// Evict oldest entries if at capacity (and key doesn't already exist)
|
|
56
|
+
if (!this.store.has(key) && this.store.size >= this.config.maxEntries) {
|
|
57
|
+
this.evictOldest();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Delete and re-insert to maintain insertion order for LRU
|
|
61
|
+
this.store.delete(key);
|
|
62
|
+
|
|
63
|
+
this.store.set(key, {
|
|
64
|
+
value,
|
|
65
|
+
expiresAt: ttlMs === undefined ? undefined : Date.now() + ttlMs,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async delete(key: string): Promise<void> {
|
|
70
|
+
this.store.delete(key);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async deleteByPrefix(prefix: string): Promise<number> {
|
|
74
|
+
let removed = 0;
|
|
75
|
+
for (const key of this.store.keys()) {
|
|
76
|
+
if (key.startsWith(prefix)) {
|
|
77
|
+
this.store.delete(key);
|
|
78
|
+
removed++;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return removed;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async has(key: string): Promise<boolean> {
|
|
85
|
+
const entry = this.store.get(key);
|
|
86
|
+
if (!entry) return false;
|
|
87
|
+
|
|
88
|
+
// Passive TTL eviction
|
|
89
|
+
if (entry.expiresAt !== undefined && Date.now() > entry.expiresAt) {
|
|
90
|
+
this.store.delete(key);
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Sweep expired entries from the store.
|
|
99
|
+
* Called periodically by the sweep timer.
|
|
100
|
+
*/
|
|
101
|
+
private sweep(): void {
|
|
102
|
+
const now = Date.now();
|
|
103
|
+
for (const [key, entry] of this.store) {
|
|
104
|
+
if (entry.expiresAt !== undefined && now > entry.expiresAt) {
|
|
105
|
+
this.store.delete(key);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Evict the oldest entry (first in Map insertion order).
|
|
112
|
+
* This provides simple LRU-style eviction.
|
|
113
|
+
*/
|
|
114
|
+
private evictOldest(): void {
|
|
115
|
+
const firstKey = this.store.keys().next().value;
|
|
116
|
+
if (firstKey !== undefined) {
|
|
117
|
+
this.store.delete(firstKey);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Stop the sweep timer. Call during shutdown.
|
|
123
|
+
*/
|
|
124
|
+
stop(): void {
|
|
125
|
+
if (this.sweepTimer) {
|
|
126
|
+
clearInterval(this.sweepTimer);
|
|
127
|
+
this.sweepTimer = undefined;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Get the current number of entries (for testing/stats).
|
|
133
|
+
*/
|
|
134
|
+
get size(): number {
|
|
135
|
+
return this.store.size;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { definePluginMetadata } from "@checkstack/common";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Plugin metadata for the Cache Memory backend.
|
|
5
|
+
* This is the single source of truth for the plugin ID.
|
|
6
|
+
*/
|
|
7
|
+
export const pluginMetadata = definePluginMetadata({
|
|
8
|
+
pluginId: "cache-memory",
|
|
9
|
+
});
|
package/src/plugin.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { CachePlugin, CacheProvider } from "@checkstack/cache-api";
|
|
2
|
+
import type { Logger } from "@checkstack/backend-api";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { InMemoryCache } from "./memory-cache";
|
|
5
|
+
|
|
6
|
+
const configSchema = z.object({
|
|
7
|
+
maxEntries: z
|
|
8
|
+
.number()
|
|
9
|
+
.min(1)
|
|
10
|
+
.default(10_000)
|
|
11
|
+
.describe("Maximum number of cache entries before eviction"),
|
|
12
|
+
sweepIntervalMs: z
|
|
13
|
+
.number()
|
|
14
|
+
.min(0)
|
|
15
|
+
.default(60_000)
|
|
16
|
+
.describe(
|
|
17
|
+
"Interval in ms for background sweep of expired entries. Set to 0 to disable.",
|
|
18
|
+
),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
export type InMemoryCacheConfig = z.infer<typeof configSchema>;
|
|
22
|
+
|
|
23
|
+
export class InMemoryCachePlugin implements CachePlugin<InMemoryCacheConfig> {
|
|
24
|
+
id = "memory";
|
|
25
|
+
displayName = "In-Memory Cache";
|
|
26
|
+
description =
|
|
27
|
+
"Simple in-memory cache for development and single-instance deployments";
|
|
28
|
+
configVersion = 1;
|
|
29
|
+
configSchema = configSchema;
|
|
30
|
+
|
|
31
|
+
createProvider(config: InMemoryCacheConfig, _logger: Logger): CacheProvider {
|
|
32
|
+
return new InMemoryCache(config);
|
|
33
|
+
}
|
|
34
|
+
}
|