@checkstack/backend 0.9.0 → 0.10.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.
@@ -1,6 +1,7 @@
1
1
  import type { Hono, Context } from "hono";
2
2
  import type { SafeDatabase } from "@checkstack/backend-api";
3
3
  import { RPCHandler } from "@orpc/server/fetch";
4
+ import { OpenAPIHandler } from "@orpc/openapi/fetch";
4
5
  import {
5
6
  coreServices,
6
7
  AuthService,
@@ -23,9 +24,182 @@ import type { PluginMetadata } from "@checkstack/common";
23
24
  import { rootLogger } from "../logger";
24
25
  import { extractErrorMessage } from "@checkstack/common";
25
26
 
27
+ interface RouteHandlerDeps {
28
+ registry: ServiceRegistry;
29
+ pluginRpcRouters: Map<string, unknown>;
30
+ pluginMetadataRegistry: Map<string, PluginMetadata>;
31
+ }
32
+
33
+ interface ResolvedRequestContext {
34
+ context: RpcContext;
35
+ logger: Logger;
36
+ }
37
+
38
+ type ContextResult =
39
+ | { ok: true; resolved: ResolvedRequestContext }
40
+ | { ok: false; response: Response };
41
+
42
+ function createServiceGetter(registry: ServiceRegistry) {
43
+ return async function getService<T>(ref: {
44
+ id: string;
45
+ T: T;
46
+ }): Promise<T | undefined> {
47
+ try {
48
+ return await registry.get(ref, { pluginId: "core" });
49
+ } catch {
50
+ return undefined;
51
+ }
52
+ };
53
+ }
54
+
55
+ /**
56
+ * Resolve auth + core services and build the per-request RpcContext.
57
+ * Shared between the oRPC `/api/*` handler and the REST `/rest/*` handler.
58
+ */
59
+ async function resolveRequestContext({
60
+ c,
61
+ pluginId,
62
+ deps,
63
+ }: {
64
+ c: Context;
65
+ pluginId: string;
66
+ deps: RouteHandlerDeps;
67
+ }): Promise<ContextResult> {
68
+ const getService = createServiceGetter(deps.registry);
69
+ const pathname = new URL(c.req.raw.url).pathname;
70
+
71
+ const logger = await getService(coreServices.logger);
72
+ const auth = await getService(coreServices.auth);
73
+ const db = await getService(coreServices.database);
74
+ const fetch = await getService(coreServices.fetch);
75
+ const healthCheckRegistry = await getService(
76
+ coreServices.healthCheckRegistry,
77
+ );
78
+ const collectorRegistry = await getService(coreServices.collectorRegistry);
79
+ const queuePluginRegistry = await getService(
80
+ coreServices.queuePluginRegistry,
81
+ );
82
+ const queueManager = await getService(coreServices.queueManager);
83
+ const cachePluginRegistry = await getService(
84
+ coreServices.cachePluginRegistry,
85
+ );
86
+ const cacheManager = await getService(coreServices.cacheManager);
87
+ const eventBus = await getService(coreServices.eventBus);
88
+
89
+ if (
90
+ !auth ||
91
+ !logger ||
92
+ !db ||
93
+ !fetch ||
94
+ !healthCheckRegistry ||
95
+ !collectorRegistry ||
96
+ !queuePluginRegistry ||
97
+ !queueManager ||
98
+ !cachePluginRegistry ||
99
+ !cacheManager ||
100
+ !eventBus
101
+ ) {
102
+ const missing = [
103
+ !auth && "auth",
104
+ !logger && "logger",
105
+ !db && "db",
106
+ !fetch && "fetch",
107
+ !healthCheckRegistry && "healthCheckRegistry",
108
+ !collectorRegistry && "collectorRegistry",
109
+ !queuePluginRegistry && "queuePluginRegistry",
110
+ !queueManager && "queueManager",
111
+ !cachePluginRegistry && "cachePluginRegistry",
112
+ !cacheManager && "cacheManager",
113
+ !eventBus && "eventBus",
114
+ ]
115
+ .filter(Boolean)
116
+ .join(", ");
117
+ (logger ?? rootLogger).error(
118
+ `${pathname}: core services not initialized — missing: ${missing}`,
119
+ );
120
+ return {
121
+ ok: false,
122
+ response: c.json({ error: "Core services not initialized" }, 500),
123
+ };
124
+ }
125
+
126
+ const user = await (auth as AuthService).authenticate(c.req.raw);
127
+
128
+ const emitHook: EmitHookFn = async <T>(hook: Hook<T>, payload: T) => {
129
+ await (eventBus as EventBus).emit(hook, payload);
130
+ };
131
+
132
+ const pluginMetadata: PluginMetadata | undefined =
133
+ deps.pluginMetadataRegistry.get(pluginId);
134
+
135
+ if (!pluginMetadata) {
136
+ (logger as Logger).error(
137
+ `${pathname}: no plugin metadata registered for pluginId='${pluginId}'. ` +
138
+ `Regular plugins populate this during register(); core routers must call ` +
139
+ `pluginManager.registerCorePluginMetadata().`,
140
+ );
141
+ return {
142
+ ok: false,
143
+ response: c.json({ error: "Plugin metadata not found in registry" }, 500),
144
+ };
145
+ }
146
+
147
+ const context: RpcContext = {
148
+ pluginMetadata,
149
+ auth: auth as AuthService,
150
+ logger: logger as Logger,
151
+ db: db as SafeDatabase<Record<string, unknown>>,
152
+ fetch: fetch as Fetch,
153
+ healthCheckRegistry: healthCheckRegistry as HealthCheckRegistry,
154
+ collectorRegistry: collectorRegistry as CollectorRegistry,
155
+ queuePluginRegistry: queuePluginRegistry as QueuePluginRegistry,
156
+ queueManager: queueManager as QueueManager,
157
+ cachePluginRegistry: cachePluginRegistry as CachePluginRegistry,
158
+ cacheManager: cacheManager as CacheManager,
159
+ user,
160
+ emitHook,
161
+ };
162
+
163
+ return { ok: true, resolved: { context, logger: logger as Logger } };
164
+ }
165
+
166
+ function buildRpcRouter(
167
+ pluginRpcRouters: Map<string, unknown>,
168
+ ): Record<string, unknown> {
169
+ const rootRpcRouter: Record<string, unknown> = {};
170
+ for (const [pluginId, router] of pluginRpcRouters.entries()) {
171
+ rootRpcRouter[pluginId] = router;
172
+ }
173
+ return rootRpcRouter;
174
+ }
175
+
176
+ function logHandlerError({
177
+ error,
178
+ pathname,
179
+ logger,
180
+ protocolLabel,
181
+ }: {
182
+ error: unknown;
183
+ pathname: string;
184
+ logger: Logger | undefined;
185
+ protocolLabel: string;
186
+ }) {
187
+ const target = (logger ?? rootLogger) as Logger;
188
+ target.error(
189
+ `${protocolLabel} ${pathname} failed: ${extractErrorMessage(error)}`,
190
+ );
191
+ const stack =
192
+ error !== null && typeof error === "object" && "stack" in error
193
+ ? (error as { stack: string }).stack
194
+ : undefined;
195
+ if (stack) {
196
+ target.error(`Stack trace: ${stack}`);
197
+ }
198
+ }
199
+
26
200
  /**
27
201
  * Creates the API route handler for Hono.
28
- * Extracted from PluginManager for better organization.
202
+ * Serves oRPC's native RPC wire protocol at /api/:pluginId/*.
29
203
  */
30
204
  export function createApiRouteHandler({
31
205
  registry,
@@ -38,54 +212,36 @@ export function createApiRouteHandler({
38
212
  pluginHttpHandlers: Map<string, (req: Request) => Promise<Response>>;
39
213
  pluginMetadataRegistry: Map<string, PluginMetadata>;
40
214
  }) {
41
- // Helper to get service from registry
42
- async function getService<T>(ref: {
43
- id: string;
44
- T: T;
45
- }): Promise<T | undefined> {
46
- try {
47
- return await registry.get(ref, { pluginId: "core" });
48
- } catch {
49
- return undefined;
50
- }
51
- }
215
+ const deps: RouteHandlerDeps = {
216
+ registry,
217
+ pluginRpcRouters,
218
+ pluginMetadataRegistry,
219
+ };
52
220
 
53
221
  return async function handleApiRequest(c: Context) {
54
- // Extract pluginId from Hono path parameter (/api/:pluginId/*)
55
222
  const pluginId = c.req.param("pluginId") || "";
56
223
  const pathname = new URL(c.req.raw.url).pathname;
57
224
 
58
- // Build RPC handler lazily at request time
59
- // This ensures all plugins registered during init are included
60
- const rootRpcRouter: Record<string, unknown> = {};
61
- for (const [pluginId, router] of pluginRpcRouters.entries()) {
62
- rootRpcRouter[pluginId] = router;
63
- }
64
-
65
- // Resolve logger first for use in interceptor
66
- const logger = await getService(coreServices.logger);
225
+ const result = await resolveRequestContext({ c, pluginId, deps });
226
+ if (!result.ok) return result.response;
227
+ const { context, logger } = result.resolved;
67
228
 
68
229
  const rpcHandler = new RPCHandler(
69
- rootRpcRouter as ConstructorParameters<typeof RPCHandler>[0],
230
+ buildRpcRouter(pluginRpcRouters) as ConstructorParameters<
231
+ typeof RPCHandler
232
+ >[0],
70
233
  {
71
234
  interceptors: [
72
235
  async ({ next, ...rest }) => {
73
236
  try {
74
237
  return await next(rest);
75
238
  } catch (error) {
76
- const target = (logger ?? rootLogger) as Logger;
77
- target.error(
78
- `RPC ${pathname} failed: ${extractErrorMessage(error)}`,
79
- );
80
- const stack =
81
- error !== null &&
82
- typeof error === "object" &&
83
- "stack" in error
84
- ? (error as { stack: string }).stack
85
- : undefined;
86
- if (stack) {
87
- target.error(`Stack trace: ${stack}`);
88
- }
239
+ logHandlerError({
240
+ error,
241
+ pathname,
242
+ logger,
243
+ protocolLabel: "RPC",
244
+ });
89
245
  throw error;
90
246
  }
91
247
  },
@@ -93,93 +249,6 @@ export function createApiRouteHandler({
93
249
  },
94
250
  );
95
251
 
96
- // Resolve core services for RPC context
97
- const auth = await getService(coreServices.auth);
98
- const db = await getService(coreServices.database);
99
- const fetch = await getService(coreServices.fetch);
100
- const healthCheckRegistry = await getService(
101
- coreServices.healthCheckRegistry,
102
- );
103
- const collectorRegistry = await getService(coreServices.collectorRegistry);
104
- const queuePluginRegistry = await getService(
105
- coreServices.queuePluginRegistry,
106
- );
107
- const queueManager = await getService(coreServices.queueManager);
108
- const cachePluginRegistry = await getService(
109
- coreServices.cachePluginRegistry,
110
- );
111
- const cacheManager = await getService(coreServices.cacheManager);
112
- const eventBus = await getService(coreServices.eventBus);
113
-
114
- if (
115
- !auth ||
116
- !logger ||
117
- !db ||
118
- !fetch ||
119
- !healthCheckRegistry ||
120
- !collectorRegistry ||
121
- !queuePluginRegistry ||
122
- !queueManager ||
123
- !cachePluginRegistry ||
124
- !cacheManager ||
125
- !eventBus
126
- ) {
127
- const missing = [
128
- !auth && "auth",
129
- !logger && "logger",
130
- !db && "db",
131
- !fetch && "fetch",
132
- !healthCheckRegistry && "healthCheckRegistry",
133
- !collectorRegistry && "collectorRegistry",
134
- !queuePluginRegistry && "queuePluginRegistry",
135
- !queueManager && "queueManager",
136
- !cachePluginRegistry && "cachePluginRegistry",
137
- !cacheManager && "cacheManager",
138
- !eventBus && "eventBus",
139
- ].filter(Boolean).join(", ");
140
- (logger ?? rootLogger).error(
141
- `${pathname}: core services not initialized — missing: ${missing}`,
142
- );
143
- return c.json({ error: "Core services not initialized" }, 500);
144
- }
145
-
146
- const user = await (auth as AuthService).authenticate(c.req.raw);
147
-
148
- // Create emitHook function using eventBus
149
- const emitHook: EmitHookFn = async <T>(hook: Hook<T>, payload: T) => {
150
- await (eventBus as EventBus).emit(hook, payload);
151
- };
152
-
153
- // Lookup plugin metadata from registry
154
- const pluginMetadata: PluginMetadata | undefined =
155
- pluginMetadataRegistry.get(pluginId);
156
-
157
- if (!pluginMetadata) {
158
- (logger as Logger).error(
159
- `${pathname}: no plugin metadata registered for pluginId='${pluginId}'. ` +
160
- `Regular plugins populate this during register(); core routers must call ` +
161
- `pluginManager.registerCorePluginMetadata().`,
162
- );
163
- return c.json({ error: "Plugin metadata not found in registry" }, 500);
164
- }
165
-
166
- const context: RpcContext = {
167
- pluginMetadata,
168
- auth: auth as AuthService,
169
- logger: logger as Logger,
170
- db: db as SafeDatabase<Record<string, unknown>>,
171
- fetch: fetch as Fetch,
172
- healthCheckRegistry: healthCheckRegistry as HealthCheckRegistry,
173
- collectorRegistry: collectorRegistry as CollectorRegistry,
174
- queuePluginRegistry: queuePluginRegistry as QueuePluginRegistry,
175
- queueManager: queueManager as QueueManager,
176
- cachePluginRegistry: cachePluginRegistry as CachePluginRegistry,
177
- cacheManager: cacheManager as CacheManager,
178
- user,
179
- emitHook,
180
- };
181
-
182
- // 1. Try oRPC first
183
252
  const { matched, response } = await rpcHandler.handle(c.req.raw, {
184
253
  prefix: "/api",
185
254
  context,
@@ -191,8 +260,7 @@ export function createApiRouteHandler({
191
260
 
192
261
  logger.debug(`RPC mismatch for: ${c.req.method} ${pathname}`);
193
262
 
194
- // 2. Try native handlers
195
- // Sort by path length (descending) to ensure more specific paths are tried first
263
+ // Fall through to native plugin HTTP handlers (sorted longest-prefix first)
196
264
  const sortedHandlers = [...pluginHttpHandlers.entries()].toSorted(
197
265
  function (a, b) {
198
266
  return b[0].length - a[0].length;
@@ -209,6 +277,71 @@ export function createApiRouteHandler({
209
277
  };
210
278
  }
211
279
 
280
+ /**
281
+ * Creates the REST route handler for Hono.
282
+ * Serves the REST/OpenAPI shape of the same oRPC contract at /rest/:pluginId/*.
283
+ * Standard JSON bodies / query params work here — matches /api/openapi.json.
284
+ */
285
+ export function createRestRouteHandler({
286
+ registry,
287
+ pluginRpcRouters,
288
+ pluginMetadataRegistry,
289
+ }: {
290
+ registry: ServiceRegistry;
291
+ pluginRpcRouters: Map<string, unknown>;
292
+ pluginMetadataRegistry: Map<string, PluginMetadata>;
293
+ }) {
294
+ const deps: RouteHandlerDeps = {
295
+ registry,
296
+ pluginRpcRouters,
297
+ pluginMetadataRegistry,
298
+ };
299
+
300
+ return async function handleRestRequest(c: Context) {
301
+ const pluginId = c.req.param("pluginId") || "";
302
+ const pathname = new URL(c.req.raw.url).pathname;
303
+
304
+ const result = await resolveRequestContext({ c, pluginId, deps });
305
+ if (!result.ok) return result.response;
306
+ const { context, logger } = result.resolved;
307
+
308
+ const restHandler = new OpenAPIHandler(
309
+ buildRpcRouter(pluginRpcRouters) as ConstructorParameters<
310
+ typeof OpenAPIHandler
311
+ >[0],
312
+ {
313
+ interceptors: [
314
+ async ({ next, ...rest }) => {
315
+ try {
316
+ return await next(rest);
317
+ } catch (error) {
318
+ logHandlerError({
319
+ error,
320
+ pathname,
321
+ logger,
322
+ protocolLabel: "REST",
323
+ });
324
+ throw error;
325
+ }
326
+ },
327
+ ],
328
+ },
329
+ );
330
+
331
+ const { matched, response } = await restHandler.handle(c.req.raw, {
332
+ prefix: "/rest",
333
+ context,
334
+ });
335
+
336
+ if (matched) {
337
+ return c.newResponse(response.body, response);
338
+ }
339
+
340
+ logger.debug(`REST mismatch for: ${c.req.method} ${pathname}`);
341
+ return c.json({ error: "Not Found" }, 404);
342
+ };
343
+ }
344
+
212
345
  /**
213
346
  * Registers the /api/:pluginId/* route with Hono.
214
347
  */
@@ -218,3 +351,13 @@ export function registerApiRoute(
218
351
  ) {
219
352
  rootRouter.all("/api/:pluginId/*", handler);
220
353
  }
354
+
355
+ /**
356
+ * Registers the /rest/:pluginId/* route with Hono.
357
+ */
358
+ export function registerRestRoute(
359
+ rootRouter: Hono,
360
+ handler: ReturnType<typeof createRestRouteHandler>,
361
+ ) {
362
+ rootRouter.all("/rest/:pluginId/*", handler);
363
+ }
@@ -29,7 +29,12 @@ import {
29
29
  } from "../utils/plugin-discovery";
30
30
  import type { InitCallback, PendingInit } from "./types";
31
31
  import { sortPlugins } from "./dependency-sorter";
32
- import { createApiRouteHandler, registerApiRoute } from "./api-router";
32
+ import {
33
+ createApiRouteHandler,
34
+ createRestRouteHandler,
35
+ registerApiRoute,
36
+ registerRestRoute,
37
+ } from "./api-router";
33
38
  import type { ExtensionPointManager } from "./extension-points";
34
39
  import { Router } from "@orpc/server";
35
40
  import { AnyContractRouter } from "@orpc/contract";
@@ -302,7 +307,10 @@ export async function loadPlugins({
302
307
  const sortedIds = sortPlugins({ pendingInits, providedBy, logger });
303
308
  rootLogger.debug(`✅ Initialization Order: ${sortedIds.join(" -> ")}`);
304
309
 
305
- // Register /api/* route BEFORE plugin initialization
310
+ // Register /api/* (oRPC wire format) and /rest/* (REST/OpenAPI shape) routes
311
+ // BEFORE plugin initialization. Both routes share the same plugin RPC routers
312
+ // and per-request context; they differ only in how the handler decodes the
313
+ // request and matches it to a procedure.
306
314
  const apiHandler = createApiRouteHandler({
307
315
  registry: deps.registry,
308
316
  pluginRpcRouters: deps.pluginRpcRouters,
@@ -311,6 +319,13 @@ export async function loadPlugins({
311
319
  });
312
320
  registerApiRoute(rootRouter, apiHandler);
313
321
 
322
+ const restHandler = createRestRouteHandler({
323
+ registry: deps.registry,
324
+ pluginRpcRouters: deps.pluginRpcRouters,
325
+ pluginMetadataRegistry: deps.pluginMetadataRegistry,
326
+ });
327
+ registerRestRoute(rootRouter, restHandler);
328
+
314
329
  // Routes are now registered on the root router. Signal readiness so the
315
330
  // server can stop blocking incoming requests in `waitForInit()`. We open
316
331
  // the gate here (BEFORE Phase 2 / Phase 3) so that:
@@ -0,0 +1,172 @@
1
+ import { describe, it, expect, beforeEach } from "bun:test";
2
+ import { z } from "zod";
3
+ import { CacheManagerImpl } from "./cache-manager";
4
+ import { CachePluginRegistryImpl } from "./cache-plugin-registry";
5
+ import type { CachePlugin, CacheProvider, CacheStats } from "@checkstack/cache-api";
6
+ import type { ConfigService, Logger } from "@checkstack/backend-api";
7
+ import { createMockLogger } from "@checkstack/test-utils-backend";
8
+
9
+ class FakeInMemoryCache implements CacheProvider {
10
+ store = new Map<string, unknown>();
11
+ hits = 0;
12
+ misses = 0;
13
+
14
+ async get<T>(key: string): Promise<T | undefined> {
15
+ if (this.store.has(key)) {
16
+ this.hits++;
17
+ return this.store.get(key) as T;
18
+ }
19
+ this.misses++;
20
+ return undefined;
21
+ }
22
+ async set<T>(key: string, value: T): Promise<void> {
23
+ this.store.set(key, value);
24
+ }
25
+ async delete(key: string): Promise<void> {
26
+ this.store.delete(key);
27
+ }
28
+ async deleteByPrefix(): Promise<number> {
29
+ return 0;
30
+ }
31
+ async has(key: string): Promise<boolean> {
32
+ return this.store.has(key);
33
+ }
34
+ async getStats(): Promise<CacheStats> {
35
+ return {
36
+ keyCount: this.store.size,
37
+ sizeBytes: null,
38
+ hits: this.hits,
39
+ misses: this.misses,
40
+ scope: "instance",
41
+ };
42
+ }
43
+ }
44
+
45
+ const memoryConfigSchema = z.record(z.string(), z.unknown());
46
+
47
+ const createMemoryPlugin = (
48
+ factory: () => FakeInMemoryCache,
49
+ ): CachePlugin<unknown> => ({
50
+ id: "memory",
51
+ displayName: "Memory",
52
+ configVersion: 1,
53
+ configSchema: memoryConfigSchema,
54
+ createProvider: () => factory(),
55
+ });
56
+
57
+ class StubConfigService implements ConfigService {
58
+ store = new Map<string, unknown>();
59
+ async set<T>(configId: string, _s: unknown, _v: number, data: T) {
60
+ this.store.set(configId, data);
61
+ }
62
+ async get<T>(configId: string): Promise<T | undefined> {
63
+ return this.store.get(configId) as T | undefined;
64
+ }
65
+ async getRedacted<T>(configId: string): Promise<Partial<T> | undefined> {
66
+ return this.store.get(configId) as Partial<T> | undefined;
67
+ }
68
+ async delete(configId: string): Promise<void> {
69
+ this.store.delete(configId);
70
+ }
71
+ async list(): Promise<string[]> {
72
+ return [...this.store.keys()];
73
+ }
74
+ }
75
+
76
+ describe("CacheManagerImpl provider proxy", () => {
77
+ let registry: CachePluginRegistryImpl;
78
+ let configService: StubConfigService;
79
+ let logger: Logger;
80
+ let manager: CacheManagerImpl;
81
+ let instances: FakeInMemoryCache[];
82
+
83
+ beforeEach(() => {
84
+ registry = new CachePluginRegistryImpl();
85
+ configService = new StubConfigService();
86
+ logger = createMockLogger() as unknown as Logger;
87
+ instances = [];
88
+ registry.register(
89
+ createMemoryPlugin(() => {
90
+ const inst = new FakeInMemoryCache();
91
+ instances.push(inst);
92
+ return inst;
93
+ }),
94
+ );
95
+ manager = new CacheManagerImpl(registry, configService, logger);
96
+ });
97
+
98
+ it("returns the same proxy reference across calls", async () => {
99
+ const a = manager.getProvider();
100
+ const b = manager.getProvider();
101
+ expect(a).toBe(b);
102
+ });
103
+
104
+ it("delegates writes to the active provider after loadConfiguration", async () => {
105
+ const provider = manager.getProvider();
106
+ await manager.loadConfiguration();
107
+ await provider.set("foo", "bar");
108
+ expect(instances[0].store.get("foo")).toBe("bar");
109
+ });
110
+
111
+ it("keeps a captured proxy reference live across setActiveBackend", async () => {
112
+ await manager.loadConfiguration();
113
+ const captured = manager.getProvider();
114
+ await captured.set("before", 1);
115
+
116
+ await manager.setActiveBackend("memory", {
117
+ maxEntries: 5000,
118
+ sweepIntervalMs: 30_000,
119
+ });
120
+
121
+ // The captured reference must now write to the NEW active provider,
122
+ // not the orphaned old one.
123
+ await captured.set("after", 2);
124
+
125
+ const oldInstance = instances[0];
126
+ const newInstance = instances.at(-1)!;
127
+ expect(oldInstance).not.toBe(newInstance);
128
+ expect(oldInstance.store.has("after")).toBe(false);
129
+ expect(newInstance.store.has("after")).toBe(true);
130
+ });
131
+
132
+ it("getStats reads from the currently active provider", async () => {
133
+ await manager.loadConfiguration();
134
+ const provider = manager.getProvider();
135
+ await provider.set("k1", "v1");
136
+ await provider.get("k1"); // hit
137
+ await provider.get("missing"); // miss
138
+
139
+ const stats = await provider.getStats!();
140
+ expect(stats.keyCount).toBe(1);
141
+ expect(stats.hits).toBe(1);
142
+ expect(stats.misses).toBe(1);
143
+ });
144
+
145
+ it("getStats returns all-null when active provider has no getStats impl", async () => {
146
+ // Plugin whose provider doesn't implement getStats.
147
+ const minimalPlugin: CachePlugin<unknown> = {
148
+ id: "noStats",
149
+ displayName: "No stats",
150
+ configVersion: 1,
151
+ configSchema: memoryConfigSchema,
152
+ createProvider: (): CacheProvider => ({
153
+ get: async () => undefined,
154
+ set: async () => {},
155
+ delete: async () => {},
156
+ deleteByPrefix: async () => 0,
157
+ has: async () => false,
158
+ }),
159
+ };
160
+ registry.register(minimalPlugin);
161
+
162
+ await manager.setActiveBackend("noStats", {});
163
+ const stats = await manager.getProvider().getStats!();
164
+ expect(stats).toEqual({
165
+ keyCount: null,
166
+ sizeBytes: null,
167
+ hits: null,
168
+ misses: null,
169
+ scope: "instance",
170
+ });
171
+ });
172
+ });