@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.
- package/CHANGELOG.md +225 -0
- package/drizzle/0000_loose_yellow_claw.sql +28 -0
- package/drizzle/meta/0000_snapshot.json +187 -0
- package/drizzle/meta/_journal.json +13 -0
- package/drizzle.config.ts +10 -0
- package/package.json +42 -0
- package/src/db.ts +20 -0
- package/src/health-check-plugin-integration.test.ts +93 -0
- package/src/index.ts +419 -0
- package/src/integration/event-bus.integration.test.ts +313 -0
- package/src/logger.ts +65 -0
- package/src/openapi-router.ts +177 -0
- package/src/plugin-lifecycle.test.ts +276 -0
- package/src/plugin-manager/api-router.ts +163 -0
- package/src/plugin-manager/core-services.ts +312 -0
- package/src/plugin-manager/dependency-sorter.ts +103 -0
- package/src/plugin-manager/deregistration-guard.ts +41 -0
- package/src/plugin-manager/extension-points.ts +85 -0
- package/src/plugin-manager/index.ts +13 -0
- package/src/plugin-manager/plugin-admin-router.ts +89 -0
- package/src/plugin-manager/plugin-loader.ts +464 -0
- package/src/plugin-manager/types.ts +14 -0
- package/src/plugin-manager.test.ts +464 -0
- package/src/plugin-manager.ts +431 -0
- package/src/rpc-rest-compat.test.ts +80 -0
- package/src/schema.ts +46 -0
- package/src/services/config-service.test.ts +66 -0
- package/src/services/config-service.ts +322 -0
- package/src/services/event-bus.test.ts +469 -0
- package/src/services/event-bus.ts +317 -0
- package/src/services/health-check-registry.test.ts +101 -0
- package/src/services/health-check-registry.ts +27 -0
- package/src/services/jwt.ts +45 -0
- package/src/services/keystore.test.ts +198 -0
- package/src/services/keystore.ts +136 -0
- package/src/services/plugin-installer.test.ts +90 -0
- package/src/services/plugin-installer.ts +70 -0
- package/src/services/queue-manager.ts +382 -0
- package/src/services/queue-plugin-registry.ts +17 -0
- package/src/services/queue-proxy.ts +182 -0
- package/src/services/service-registry.ts +35 -0
- package/src/test-preload.ts +114 -0
- package/src/utils/plugin-discovery.test.ts +383 -0
- package/src/utils/plugin-discovery.ts +157 -0
- package/src/utils/strip-public-schema.ts +40 -0
- 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
|
+
}
|