@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.
- package/CHANGELOG.md +195 -0
- package/package.json +14 -14
- package/src/index.ts +7 -2
- package/src/openapi-router.ts +38 -19
- package/src/plugin-manager/api-router.ts +268 -125
- package/src/plugin-manager/plugin-loader.ts +17 -2
- package/src/services/cache-manager.test.ts +172 -0
- package/src/services/cache-manager.ts +67 -14
- package/src/services/event-bus.test.ts +52 -0
- package/src/services/event-bus.ts +27 -1
- package/src/services/queue-manager.ts +77 -2
- package/src/services/queue-proxy.ts +7 -0
|
@@ -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
|
-
*
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
}
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
//
|
|
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 {
|
|
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/*
|
|
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
|
+
});
|