@checkstack/backend 0.0.2

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.
Files changed (46) hide show
  1. package/CHANGELOG.md +225 -0
  2. package/drizzle/0000_loose_yellow_claw.sql +28 -0
  3. package/drizzle/meta/0000_snapshot.json +187 -0
  4. package/drizzle/meta/_journal.json +13 -0
  5. package/drizzle.config.ts +10 -0
  6. package/package.json +42 -0
  7. package/src/db.ts +20 -0
  8. package/src/health-check-plugin-integration.test.ts +93 -0
  9. package/src/index.ts +419 -0
  10. package/src/integration/event-bus.integration.test.ts +313 -0
  11. package/src/logger.ts +65 -0
  12. package/src/openapi-router.ts +177 -0
  13. package/src/plugin-lifecycle.test.ts +276 -0
  14. package/src/plugin-manager/api-router.ts +163 -0
  15. package/src/plugin-manager/core-services.ts +312 -0
  16. package/src/plugin-manager/dependency-sorter.ts +103 -0
  17. package/src/plugin-manager/deregistration-guard.ts +41 -0
  18. package/src/plugin-manager/extension-points.ts +85 -0
  19. package/src/plugin-manager/index.ts +13 -0
  20. package/src/plugin-manager/plugin-admin-router.ts +89 -0
  21. package/src/plugin-manager/plugin-loader.ts +464 -0
  22. package/src/plugin-manager/types.ts +14 -0
  23. package/src/plugin-manager.test.ts +464 -0
  24. package/src/plugin-manager.ts +431 -0
  25. package/src/rpc-rest-compat.test.ts +80 -0
  26. package/src/schema.ts +46 -0
  27. package/src/services/config-service.test.ts +66 -0
  28. package/src/services/config-service.ts +322 -0
  29. package/src/services/event-bus.test.ts +469 -0
  30. package/src/services/event-bus.ts +317 -0
  31. package/src/services/health-check-registry.test.ts +101 -0
  32. package/src/services/health-check-registry.ts +27 -0
  33. package/src/services/jwt.ts +45 -0
  34. package/src/services/keystore.test.ts +198 -0
  35. package/src/services/keystore.ts +136 -0
  36. package/src/services/plugin-installer.test.ts +90 -0
  37. package/src/services/plugin-installer.ts +70 -0
  38. package/src/services/queue-manager.ts +382 -0
  39. package/src/services/queue-plugin-registry.ts +17 -0
  40. package/src/services/queue-proxy.ts +182 -0
  41. package/src/services/service-registry.ts +35 -0
  42. package/src/test-preload.ts +114 -0
  43. package/src/utils/plugin-discovery.test.ts +383 -0
  44. package/src/utils/plugin-discovery.ts +157 -0
  45. package/src/utils/strip-public-schema.ts +40 -0
  46. package/tsconfig.json +6 -0
@@ -0,0 +1,136 @@
1
+ import { generateKeyPair, exportJWK, importJWK } from "jose";
2
+ import { db } from "../db";
3
+ import { jwtKeys } from "../schema";
4
+ import { eq, and, isNull, desc, lt } from "drizzle-orm";
5
+ import { rootLogger } from "../logger";
6
+
7
+ const logger = rootLogger.child({ service: "KeyStore" });
8
+
9
+ const ALG = "RS256";
10
+ const ROTATION_INTERVAL_MS = 1000 * 60 * 60; // 1 hour
11
+ const ROTATION_GRACE_PERIOD_MS = 1000 * 60 * 60 * 24; // 24 hours
12
+
13
+ export class KeyStore {
14
+ /**
15
+ * Generates a new key pair and stores it
16
+ */
17
+ async generateKey() {
18
+ logger.info("Generating new JWKS key pair...");
19
+ const { publicKey, privateKey } = await generateKeyPair(ALG, {
20
+ extractable: true,
21
+ });
22
+ const publicJwk = await exportJWK(publicKey);
23
+ const privateJwk = await exportJWK(privateKey);
24
+
25
+ const kid = crypto.randomUUID();
26
+ publicJwk.kid = kid;
27
+ publicJwk.use = "sig";
28
+ publicJwk.alg = ALG;
29
+
30
+ privateJwk.kid = kid;
31
+ privateJwk.use = "sig";
32
+ privateJwk.alg = ALG;
33
+
34
+ const now = new Date(); // Use Date object for timestamp
35
+
36
+ await db.insert(jwtKeys).values({
37
+ id: kid,
38
+ publicKey: JSON.stringify(publicJwk),
39
+ privateKey: JSON.stringify(privateJwk),
40
+ algorithm: ALG,
41
+ createdAt: now.toISOString(),
42
+ expiresAt: undefined,
43
+ revokedAt: undefined,
44
+ });
45
+
46
+ return { kid, publicKey, privateKey };
47
+ }
48
+
49
+ /**
50
+ * Gets the current signing key, rotating if necessary
51
+ */
52
+ async getSigningKey() {
53
+ const validKeys = await db
54
+ .select()
55
+ .from(jwtKeys)
56
+ .where(and(isNull(jwtKeys.revokedAt), isNull(jwtKeys.expiresAt)))
57
+ .orderBy(desc(jwtKeys.createdAt))
58
+ .limit(1);
59
+
60
+ let activeKey = validKeys[0];
61
+ const now = Date.now();
62
+ let shouldRotate = false;
63
+
64
+ if (activeKey) {
65
+ const created = new Date(activeKey.createdAt).getTime();
66
+ if (now - created > ROTATION_INTERVAL_MS) {
67
+ shouldRotate = true;
68
+ }
69
+ } else {
70
+ shouldRotate = true;
71
+ }
72
+
73
+ if (shouldRotate) {
74
+ if (activeKey) {
75
+ // Set expiry on old key
76
+ const expiresAt = new Date(Date.now() + ROTATION_GRACE_PERIOD_MS);
77
+ logger.info(
78
+ `Rotating key ${
79
+ activeKey.id
80
+ }, setting expiry to ${expiresAt.toISOString()}`
81
+ );
82
+ await db
83
+ .update(jwtKeys)
84
+ .set({ expiresAt: expiresAt.toISOString() })
85
+ .where(eq(jwtKeys.id, activeKey.id));
86
+ }
87
+
88
+ const { kid } = await this.generateKey();
89
+ const newKeys = await db
90
+ .select()
91
+ .from(jwtKeys)
92
+ .where(eq(jwtKeys.id, kid));
93
+ activeKey = newKeys[0];
94
+
95
+ // Clean up old keys on rotation
96
+ await this.cleanupKeys();
97
+ }
98
+
99
+ if (!activeKey) {
100
+ throw new Error("Failed to get signing key");
101
+ }
102
+
103
+ const privateJwk = JSON.parse(activeKey.privateKey);
104
+ const privateKey = await importJWK(privateJwk, ALG);
105
+ return { kid: activeKey.id, key: privateKey };
106
+ }
107
+
108
+ /**
109
+ * Returns public keys in JWKS format
110
+ */
111
+ async getPublicJWKS() {
112
+ const validKeys = await db
113
+ .select({
114
+ publicKey: jwtKeys.publicKey,
115
+ })
116
+ .from(jwtKeys)
117
+ .where(isNull(jwtKeys.revokedAt)); // Return all non-revoked keys (even if old, for grace period)
118
+
119
+ const keys = validKeys.map((k) => JSON.parse(k.publicKey));
120
+ return { keys };
121
+ }
122
+
123
+ /**
124
+ * Cleans up expired keys that are past their grace period
125
+ */
126
+ async cleanupKeys() {
127
+ const now = new Date().toISOString();
128
+ logger.info("Cleaning up expired JWKS keys...");
129
+
130
+ // We only delete keys that have an expiresAt set AND that date is in the past.
131
+ // Since we set expiresAt to now + grace_period, we can just check if expiresAt < now.
132
+ await db.delete(jwtKeys).where(lt(jwtKeys.expiresAt, now));
133
+ }
134
+ }
135
+
136
+ export const keyStore = new KeyStore();
@@ -0,0 +1,90 @@
1
+ import { describe, it, expect, mock, beforeEach } from "bun:test";
2
+
3
+ // 1. Mock child_process and fs BEFORE importing the target module
4
+ const mockExec = mock((_cmd: string, cb: any) => {
5
+ cb(null, { stdout: "mocked" }, { stderr: "" });
6
+ });
7
+
8
+ const mockExistsSync = mock(() => true);
9
+ const mockMkdirSync = mock();
10
+ const mockReadFileSync = mock(() => JSON.stringify({ name: "mock-plugin" }));
11
+
12
+ mock.module("node:util", () => ({
13
+ promisify: (fn: any) => {
14
+ return async (...args: any[]) => {
15
+ // Return a promise that resolves with what our mock would return
16
+ // We can just call mockExec and return its "result"
17
+ return new Promise((resolve) => {
18
+ fn(...args, (err: any, stdout: any, stderr: any) =>
19
+ resolve({ stdout, stderr })
20
+ );
21
+ });
22
+ };
23
+ },
24
+ }));
25
+
26
+ mock.module("node:child_process", () => ({
27
+ exec: mockExec,
28
+ }));
29
+
30
+ mock.module("node:fs", () => {
31
+ const exports = {
32
+ existsSync: mockExistsSync,
33
+ mkdirSync: mockMkdirSync,
34
+ readFileSync: mockReadFileSync,
35
+ };
36
+ return {
37
+ ...exports,
38
+ default: exports,
39
+ };
40
+ });
41
+
42
+ // 2. Now import the module under test
43
+ import { PluginLocalInstaller } from "./plugin-installer";
44
+ import fs from "node:fs";
45
+ import path from "node:path";
46
+
47
+ describe("PluginLocalInstaller", () => {
48
+ const runtimeDir = "/tmp/runtime_plugins";
49
+ let installer: PluginLocalInstaller;
50
+ let customExec: any;
51
+
52
+ beforeEach(() => {
53
+ customExec = mock(() => Promise.resolve({ stdout: "mocked", stderr: "" }));
54
+ installer = new PluginLocalInstaller(runtimeDir, customExec);
55
+ mockExistsSync.mockClear();
56
+ mockExistsSync.mockReturnValue(true);
57
+ });
58
+
59
+ it("should install a package using bun", async () => {
60
+ const result = await installer.install("my-plugin");
61
+
62
+ expect(customExec).toHaveBeenCalled();
63
+ const command = customExec.mock.calls[0][0];
64
+ expect(command).toContain("bun install my-plugin");
65
+ expect(command).toContain(`--cwd ${path.resolve(runtimeDir)}`);
66
+ expect(command).toContain("--no-save");
67
+
68
+ expect(result.name).toBe("mock-plugin");
69
+ });
70
+
71
+ it("should handle scoped packages correctly", async () => {
72
+ const result = await installer.install("@scope/plugin");
73
+
74
+ expect(customExec).toHaveBeenCalled();
75
+ const command = customExec.mock.calls[0][0];
76
+ expect(command).toContain("bun install @scope/plugin");
77
+
78
+ expect(result.name).toBe("mock-plugin");
79
+ });
80
+
81
+ it("should throw error if package.json is missing after install", async () => {
82
+ // The constructor was already called in beforeEach.
83
+ // The next call to existsSync will be inside the install method for the pkgJsonPath.
84
+ mockExistsSync.mockReturnValueOnce(false);
85
+
86
+ await expect(installer.install("failing-plugin")).rejects.toThrow(
87
+ "not found"
88
+ );
89
+ });
90
+ });
@@ -0,0 +1,70 @@
1
+ import { PluginInstaller } from "@checkstack/backend-api";
2
+ import { exec } from "node:child_process";
3
+ import { promisify } from "node:util";
4
+ import path from "node:path";
5
+ import fs from "node:fs";
6
+
7
+ export class PluginLocalInstaller implements PluginInstaller {
8
+ private runtimeDir: string;
9
+ private execAsync: (
10
+ command: string
11
+ ) => Promise<{ stdout: string; stderr: string }>;
12
+
13
+ constructor(
14
+ runtimeDir: string,
15
+ customExec?: (
16
+ command: string
17
+ ) => Promise<{ stdout: string; stderr: string }>
18
+ ) {
19
+ this.runtimeDir = path.resolve(runtimeDir);
20
+ this.execAsync = customExec || promisify(exec);
21
+ if (!fs.existsSync(this.runtimeDir)) {
22
+ fs.mkdirSync(this.runtimeDir, { recursive: true });
23
+ }
24
+ }
25
+
26
+ async install(packageName: string): Promise<{ name: string; path: string }> {
27
+ try {
28
+ console.log(
29
+ `🔌 Installing plugin: ${packageName} into ${this.runtimeDir}`
30
+ );
31
+
32
+ // Use bun install with --no-save to avoid creating/modifying lockfiles
33
+ // in the runtime directory. This keeps plugins isolated from the main app.
34
+ await this.execAsync(
35
+ `bun install ${packageName} --cwd ${this.runtimeDir} --no-save`
36
+ );
37
+
38
+ // Extract the actual package name (packageName could be a URL or @org/name@version)
39
+ // For now, we assume it's a simple package name for the path lookup,
40
+ // or we can parse it better.
41
+ // A safer way is to check the recently changed folders in node_modules?
42
+ // Or just assume the input packageName (stripped of @version) matches the folder.
43
+ let folderName = packageName;
44
+ if (packageName.includes("@") && !packageName.startsWith("@")) {
45
+ folderName = packageName.split("@")[0];
46
+ } else if (packageName.startsWith("@") && packageName.includes("@", 1)) {
47
+ folderName = "@" + packageName.split("@")[1];
48
+ }
49
+
50
+ const pkgPath = path.join(this.runtimeDir, "node_modules", folderName);
51
+ const pkgJsonPath = path.join(pkgPath, "package.json");
52
+
53
+ if (!fs.existsSync(pkgJsonPath)) {
54
+ throw new Error(
55
+ `Package folder ${folderName} not found at ${pkgPath} after installation`
56
+ );
57
+ }
58
+
59
+ const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, "utf8"));
60
+
61
+ return {
62
+ name: pkgJson.name,
63
+ path: pkgPath,
64
+ };
65
+ } catch (error) {
66
+ console.error(`❌ Failed to install plugin ${packageName}:`, error);
67
+ throw error;
68
+ }
69
+ }
70
+ }
@@ -0,0 +1,382 @@
1
+ import type {
2
+ Queue,
3
+ QueueManager,
4
+ SwitchResult,
5
+ RecurringJobInfo,
6
+ } from "@checkstack/queue-api";
7
+ import type { QueuePluginRegistryImpl } from "./queue-plugin-registry";
8
+ import type { Logger, ConfigService } from "@checkstack/backend-api";
9
+ import { z } from "zod";
10
+ import { QueueProxy } from "./queue-proxy";
11
+
12
+ // Schema for active plugin pointer with version for multi-instance coordination
13
+ const activePluginPointerSchema = z.object({
14
+ activePluginId: z.string(),
15
+ version: z.number(),
16
+ });
17
+
18
+ type ActivePluginPointer = z.infer<typeof activePluginPointerSchema>;
19
+
20
+ /**
21
+ * QueueManagerImpl handles queue creation, backend switching, and multi-instance coordination.
22
+ *
23
+ * Features:
24
+ * - Returns stable QueueProxy instances that survive backend switches
25
+ * - Polls config for changes to support multi-instance coordination
26
+ * - Handles graceful migration of recurring jobs when switching backends
27
+ */
28
+ export class QueueManagerImpl implements QueueManager {
29
+ private activePluginId: string = "memory"; // Default
30
+ private activeConfig: unknown = { concurrency: 10, maxQueueSize: 10_000 };
31
+ private configVersion: number = 0;
32
+
33
+ // Stable queue proxies - survive backend switches
34
+ private queueProxies = new Map<string, QueueProxy<unknown>>();
35
+
36
+ // Polling
37
+ private pollingInterval: ReturnType<typeof setInterval> | undefined =
38
+ undefined;
39
+
40
+ constructor(
41
+ private registry: QueuePluginRegistryImpl,
42
+ private configService: ConfigService,
43
+ private logger: Logger
44
+ ) {}
45
+
46
+ async loadConfiguration(): Promise<void> {
47
+ try {
48
+ // Load active plugin pointer
49
+ const pointer = await this.configService.get<ActivePluginPointer>(
50
+ "queue:active",
51
+ activePluginPointerSchema,
52
+ 1
53
+ );
54
+
55
+ if (pointer) {
56
+ this.activePluginId = pointer.activePluginId;
57
+ this.configVersion = pointer.version;
58
+
59
+ // Load the actual config for this plugin
60
+ const plugin = this.registry.getPlugin(this.activePluginId);
61
+ if (plugin) {
62
+ const config = await this.configService.get(
63
+ this.activePluginId,
64
+ plugin.configSchema,
65
+ plugin.configVersion
66
+ );
67
+
68
+ if (config) {
69
+ this.activeConfig = config;
70
+ }
71
+ }
72
+
73
+ this.logger.info(
74
+ `📋 Loaded queue configuration: plugin=${this.activePluginId}, version=${this.configVersion}`
75
+ );
76
+ } else {
77
+ this.logger.info(
78
+ `📋 No queue configuration found, using default: plugin=${this.activePluginId}`
79
+ );
80
+ }
81
+ } catch (error) {
82
+ this.logger.error("Failed to load queue configuration", error);
83
+ // Continue with defaults
84
+ }
85
+ }
86
+
87
+ getQueue<T>(name: string): Queue<T> {
88
+ let proxy = this.queueProxies.get(name) as QueueProxy<T> | undefined;
89
+
90
+ if (!proxy) {
91
+ proxy = new QueueProxy<T>(name);
92
+ this.queueProxies.set(name, proxy as QueueProxy<unknown>);
93
+
94
+ // If we already have config loaded, create delegate immediately
95
+ if (this.configVersion > 0 || this.activePluginId === "memory") {
96
+ this.initializeQueueProxy(proxy, name);
97
+ }
98
+ }
99
+
100
+ return proxy;
101
+ }
102
+
103
+ private initializeQueueProxy<T>(proxy: QueueProxy<T>, name: string): void {
104
+ const plugin = this.registry.getPlugin(this.activePluginId);
105
+ if (!plugin) {
106
+ this.logger.warn(
107
+ `Queue plugin '${this.activePluginId}' not found, deferring queue creation`
108
+ );
109
+ return;
110
+ }
111
+
112
+ const queue = plugin.createQueue<T>(name, this.activeConfig);
113
+ proxy.switchDelegate(queue).catch((error) => {
114
+ this.logger.error(`Failed to initialize queue '${name}'`, error);
115
+ });
116
+ }
117
+
118
+ getActivePlugin(): string {
119
+ return this.activePluginId;
120
+ }
121
+
122
+ getActiveConfig(): unknown {
123
+ return this.activeConfig;
124
+ }
125
+
126
+ async setActiveBackend(
127
+ pluginId: string,
128
+ config: unknown
129
+ ): Promise<SwitchResult> {
130
+ const warnings: string[] = [];
131
+
132
+ // 1. Validate plugin exists
133
+ const newPlugin = this.registry.getPlugin(pluginId);
134
+ if (!newPlugin) {
135
+ throw new Error(`Plugin '${pluginId}' not found`);
136
+ }
137
+
138
+ // 2. Validate config against schema
139
+ newPlugin.configSchema.parse(config);
140
+
141
+ // 3. Test connection
142
+ this.logger.info("🔍 Testing queue connection...");
143
+ try {
144
+ const testQueue = newPlugin.createQueue("__connection_test__", config);
145
+ await testQueue.testConnection();
146
+ await testQueue.stop();
147
+ this.logger.info("✅ Connection test successful");
148
+ } catch (error) {
149
+ const message = error instanceof Error ? error.message : String(error);
150
+ this.logger.error(`❌ Connection test failed: ${message}`);
151
+ throw new Error(`Failed to connect to queue: ${message}`);
152
+ }
153
+
154
+ // 4. Check for in-flight jobs and warn
155
+ const inFlightCount = await this.getInFlightJobCount();
156
+ if (inFlightCount > 0) {
157
+ warnings.push(
158
+ `${inFlightCount} jobs are currently in-flight and may be disrupted`
159
+ );
160
+ this.logger.warn(
161
+ `⚠️ ${inFlightCount} in-flight jobs detected during backend switch`
162
+ );
163
+ }
164
+
165
+ // 5. Collect recurring jobs for migration
166
+ const oldPlugin = this.registry.getPlugin(this.activePluginId);
167
+ const recurringJobs = await this.listAllRecurringJobs();
168
+ let migratedRecurringJobs = 0;
169
+
170
+ // 6. Stop all current queues gracefully
171
+ this.logger.info("🛑 Stopping current queues...");
172
+ for (const [name, proxy] of this.queueProxies.entries()) {
173
+ this.logger.debug(`Stopping queue: ${name}`);
174
+ await proxy.stop().catch((error) => {
175
+ this.logger.error(`Failed to stop queue ${name}`, error);
176
+ });
177
+ }
178
+
179
+ // 7. Update internal state
180
+ const oldPluginId = this.activePluginId;
181
+ this.activePluginId = pluginId;
182
+ this.activeConfig = config;
183
+ this.configVersion++;
184
+
185
+ // 8. Create new queues and switch delegates
186
+ this.logger.info("🔄 Switching to new backend...");
187
+ for (const [name, proxy] of this.queueProxies.entries()) {
188
+ const newQueue = newPlugin.createQueue(name, config);
189
+ await proxy.switchDelegate(newQueue);
190
+ }
191
+
192
+ // 9. Migrate recurring jobs
193
+ if (recurringJobs.length > 0 && oldPlugin && pluginId !== oldPluginId) {
194
+ this.logger.info(
195
+ `📦 Migrating ${recurringJobs.length} recurring jobs...`
196
+ );
197
+
198
+ for (const job of recurringJobs) {
199
+ try {
200
+ // Get the proxy for this queue
201
+ const proxy = this.queueProxies.get(job.queueName);
202
+ if (proxy) {
203
+ // Get details from old implementation (via proxy's old delegate before switch)
204
+ // Since we already switched, we need to get this from the collected info
205
+ const details = await proxy.getRecurringJobDetails(job.jobId);
206
+ if (details) {
207
+ await proxy.scheduleRecurring(details.data as unknown, {
208
+ jobId: details.jobId,
209
+ intervalSeconds: details.intervalSeconds,
210
+ priority: details.priority,
211
+ });
212
+ migratedRecurringJobs++;
213
+ }
214
+ }
215
+ } catch (error) {
216
+ this.logger.error(
217
+ `Failed to migrate recurring job ${job.jobId}`,
218
+ error
219
+ );
220
+ warnings.push(`Failed to migrate recurring job: ${job.jobId}`);
221
+ }
222
+ }
223
+ }
224
+
225
+ // 10. Save configuration
226
+ await this.configService.set(
227
+ pluginId,
228
+ newPlugin.configSchema,
229
+ newPlugin.configVersion,
230
+ config
231
+ );
232
+
233
+ await this.configService.set("queue:active", activePluginPointerSchema, 1, {
234
+ activePluginId: pluginId,
235
+ version: this.configVersion,
236
+ });
237
+
238
+ this.logger.info(`✅ Queue backend switched: ${oldPluginId} → ${pluginId}`);
239
+
240
+ return {
241
+ success: true,
242
+ migratedRecurringJobs,
243
+ warnings,
244
+ };
245
+ }
246
+
247
+ async getInFlightJobCount(): Promise<number> {
248
+ let total = 0;
249
+ for (const proxy of this.queueProxies.values()) {
250
+ try {
251
+ const delegate = proxy.getDelegate();
252
+ if (delegate) {
253
+ total += await delegate.getInFlightCount();
254
+ }
255
+ } catch {
256
+ // Queue may not be initialized yet
257
+ }
258
+ }
259
+ return total;
260
+ }
261
+
262
+ async listAllRecurringJobs(): Promise<RecurringJobInfo[]> {
263
+ const jobs: RecurringJobInfo[] = [];
264
+
265
+ for (const [queueName, proxy] of this.queueProxies.entries()) {
266
+ try {
267
+ const delegate = proxy.getDelegate();
268
+ if (delegate) {
269
+ const jobIds = await delegate.listRecurringJobs();
270
+ for (const jobId of jobIds) {
271
+ const details = await delegate.getRecurringJobDetails(jobId);
272
+ if (details) {
273
+ jobs.push({
274
+ queueName,
275
+ jobId,
276
+ intervalSeconds: details.intervalSeconds,
277
+ nextRunAt: details.nextRunAt,
278
+ });
279
+ }
280
+ }
281
+ }
282
+ } catch {
283
+ // Queue may not be initialized yet
284
+ }
285
+ }
286
+
287
+ return jobs;
288
+ }
289
+
290
+ startPolling(intervalMs: number = 5000): void {
291
+ if (this.pollingInterval) {
292
+ return; // Already polling
293
+ }
294
+
295
+ this.logger.debug(`Starting queue config polling every ${intervalMs}ms`);
296
+
297
+ this.pollingInterval = setInterval(async () => {
298
+ try {
299
+ const pointer = await this.configService.get<ActivePluginPointer>(
300
+ "queue:active",
301
+ activePluginPointerSchema,
302
+ 1
303
+ );
304
+
305
+ if (pointer && pointer.version !== this.configVersion) {
306
+ this.logger.info(
307
+ `🔄 Queue configuration changed (v${this.configVersion} → v${pointer.version}), reloading...`
308
+ );
309
+ await this.reloadConfiguration(pointer);
310
+ }
311
+ } catch (error) {
312
+ this.logger.error("Error polling queue config", error);
313
+ }
314
+ }, intervalMs);
315
+ }
316
+
317
+ private async reloadConfiguration(
318
+ pointer: ActivePluginPointer
319
+ ): Promise<void> {
320
+ // Load new plugin config
321
+ const plugin = this.registry.getPlugin(pointer.activePluginId);
322
+ if (!plugin) {
323
+ this.logger.error(
324
+ `Queue plugin '${pointer.activePluginId}' not found during reload`
325
+ );
326
+ return;
327
+ }
328
+
329
+ const config = await this.configService.get(
330
+ pointer.activePluginId,
331
+ plugin.configSchema,
332
+ plugin.configVersion
333
+ );
334
+
335
+ if (!config) {
336
+ this.logger.error(
337
+ `Failed to load config for plugin '${pointer.activePluginId}'`
338
+ );
339
+ return;
340
+ }
341
+
342
+ // Stop and switch all queues
343
+ for (const [name, proxy] of this.queueProxies.entries()) {
344
+ try {
345
+ const newQueue = plugin.createQueue(name, config);
346
+ await proxy.switchDelegate(newQueue);
347
+ } catch (error) {
348
+ this.logger.error(`Failed to switch queue '${name}'`, error);
349
+ }
350
+ }
351
+
352
+ // Update state
353
+ this.activePluginId = pointer.activePluginId;
354
+ this.activeConfig = config;
355
+ this.configVersion = pointer.version;
356
+
357
+ this.logger.info(
358
+ `✅ Queue configuration reloaded: plugin=${this.activePluginId}`
359
+ );
360
+ }
361
+
362
+ async shutdown(): Promise<void> {
363
+ // Stop polling
364
+ if (this.pollingInterval) {
365
+ clearInterval(this.pollingInterval);
366
+ this.pollingInterval = undefined;
367
+ }
368
+
369
+ // Stop all queues
370
+ this.logger.info("🛑 Shutting down all queues...");
371
+ for (const [name, proxy] of this.queueProxies.entries()) {
372
+ try {
373
+ await proxy.stop();
374
+ this.logger.debug(`Stopped queue: ${name}`);
375
+ } catch (error) {
376
+ this.logger.error(`Failed to stop queue ${name}`, error);
377
+ }
378
+ }
379
+
380
+ this.logger.info("✅ All queues shut down");
381
+ }
382
+ }
@@ -0,0 +1,17 @@
1
+ import { QueuePlugin, QueuePluginRegistry } from "@checkstack/queue-api";
2
+
3
+ export class QueuePluginRegistryImpl implements QueuePluginRegistry {
4
+ private plugins = new Map<string, QueuePlugin<unknown>>();
5
+
6
+ register(plugin: QueuePlugin<unknown>): void {
7
+ this.plugins.set(plugin.id, plugin);
8
+ }
9
+
10
+ getPlugin(id: string): QueuePlugin<unknown> | undefined {
11
+ return this.plugins.get(id);
12
+ }
13
+
14
+ getPlugins(): QueuePlugin<unknown>[] {
15
+ return [...this.plugins.values()];
16
+ }
17
+ }