@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,464 @@
|
|
|
1
|
+
import { drizzle } from "drizzle-orm/node-postgres";
|
|
2
|
+
import { migrate } from "drizzle-orm/node-postgres/migrator";
|
|
3
|
+
import { Pool } from "pg";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import type { Hono } from "hono";
|
|
7
|
+
import { eq, and } from "drizzle-orm";
|
|
8
|
+
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
9
|
+
import {
|
|
10
|
+
coreServices,
|
|
11
|
+
BackendPlugin,
|
|
12
|
+
AfterPluginsReadyContext,
|
|
13
|
+
DatabaseDeps,
|
|
14
|
+
ServiceRef,
|
|
15
|
+
Deps,
|
|
16
|
+
ResolvedDeps,
|
|
17
|
+
coreHooks,
|
|
18
|
+
HookSubscribeOptions,
|
|
19
|
+
RpcContext,
|
|
20
|
+
} from "@checkstack/backend-api";
|
|
21
|
+
import type { Permission } from "@checkstack/common";
|
|
22
|
+
import { getPluginSchemaName } from "@checkstack/drizzle-helper";
|
|
23
|
+
import { rootLogger } from "../logger";
|
|
24
|
+
import type { ServiceRegistry } from "../services/service-registry";
|
|
25
|
+
import { plugins } from "../schema";
|
|
26
|
+
import { stripPublicSchemaFromMigrations } from "../utils/strip-public-schema";
|
|
27
|
+
import {
|
|
28
|
+
discoverLocalPlugins,
|
|
29
|
+
syncPluginsToDatabase,
|
|
30
|
+
} from "../utils/plugin-discovery";
|
|
31
|
+
import type { InitCallback, PendingInit } from "./types";
|
|
32
|
+
import { sortPlugins } from "./dependency-sorter";
|
|
33
|
+
import { createApiRouteHandler, registerApiRoute } from "./api-router";
|
|
34
|
+
import type { ExtensionPointManager } from "./extension-points";
|
|
35
|
+
import { Router } from "@orpc/server";
|
|
36
|
+
import { AnyContractRouter } from "@orpc/contract";
|
|
37
|
+
import type { PluginMetadata } from "@checkstack/common";
|
|
38
|
+
|
|
39
|
+
export interface PluginLoaderDeps {
|
|
40
|
+
registry: ServiceRegistry;
|
|
41
|
+
pluginRpcRouters: Map<string, unknown>;
|
|
42
|
+
pluginHttpHandlers: Map<string, (req: Request) => Promise<Response>>;
|
|
43
|
+
extensionPointManager: ExtensionPointManager;
|
|
44
|
+
registeredPermissions: (Permission & { pluginId: string })[];
|
|
45
|
+
getAllPermissions: () => Permission[];
|
|
46
|
+
db: NodePgDatabase<Record<string, unknown>>;
|
|
47
|
+
/**
|
|
48
|
+
* Map of pluginId -> PluginMetadata for request-time context injection.
|
|
49
|
+
*/
|
|
50
|
+
pluginMetadataRegistry: Map<string, PluginMetadata>;
|
|
51
|
+
/**
|
|
52
|
+
* Map of pluginId -> cleanup handlers (stored in registration order, executed LIFO)
|
|
53
|
+
*/
|
|
54
|
+
cleanupHandlers: Map<string, Array<() => Promise<void>>>;
|
|
55
|
+
/**
|
|
56
|
+
* Map of pluginId -> contract for OpenAPI generation.
|
|
57
|
+
*/
|
|
58
|
+
pluginContractRegistry: Map<string, AnyContractRouter>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Registers a single plugin - called during Phase 1.
|
|
63
|
+
*/
|
|
64
|
+
export function registerPlugin({
|
|
65
|
+
backendPlugin,
|
|
66
|
+
pluginPath,
|
|
67
|
+
pendingInits,
|
|
68
|
+
providedBy,
|
|
69
|
+
deps,
|
|
70
|
+
}: {
|
|
71
|
+
backendPlugin: BackendPlugin;
|
|
72
|
+
pluginPath: string;
|
|
73
|
+
pendingInits: PendingInit[];
|
|
74
|
+
providedBy: Map<string, string>;
|
|
75
|
+
deps: PluginLoaderDeps;
|
|
76
|
+
}) {
|
|
77
|
+
if (!backendPlugin || typeof backendPlugin.register !== "function") {
|
|
78
|
+
rootLogger.warn(
|
|
79
|
+
`Plugin ${
|
|
80
|
+
backendPlugin?.metadata?.pluginId || "unknown"
|
|
81
|
+
} is not using new API. Skipping.`
|
|
82
|
+
);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const pluginId = backendPlugin.metadata.pluginId;
|
|
87
|
+
|
|
88
|
+
// Store metadata for request-time context injection
|
|
89
|
+
deps.pluginMetadataRegistry.set(pluginId, backendPlugin.metadata);
|
|
90
|
+
|
|
91
|
+
// Execute Register
|
|
92
|
+
backendPlugin.register({
|
|
93
|
+
registerInit: <
|
|
94
|
+
D extends Deps,
|
|
95
|
+
S extends Record<string, unknown> | undefined = undefined
|
|
96
|
+
>(args: {
|
|
97
|
+
deps: D;
|
|
98
|
+
schema?: S;
|
|
99
|
+
init: (deps: ResolvedDeps<D> & DatabaseDeps<S>) => Promise<void>;
|
|
100
|
+
afterPluginsReady?: (
|
|
101
|
+
deps: ResolvedDeps<D> & DatabaseDeps<S> & AfterPluginsReadyContext
|
|
102
|
+
) => Promise<void>;
|
|
103
|
+
}) => {
|
|
104
|
+
pendingInits.push({
|
|
105
|
+
metadata: backendPlugin.metadata,
|
|
106
|
+
pluginPath: pluginPath,
|
|
107
|
+
deps: args.deps,
|
|
108
|
+
init: args.init as InitCallback,
|
|
109
|
+
afterPluginsReady: args.afterPluginsReady as InitCallback | undefined,
|
|
110
|
+
schema: args.schema,
|
|
111
|
+
});
|
|
112
|
+
},
|
|
113
|
+
registerService: (ref: ServiceRef<unknown>, impl: unknown) => {
|
|
114
|
+
deps.registry.register(ref, impl);
|
|
115
|
+
providedBy.set(ref.id, pluginId);
|
|
116
|
+
rootLogger.debug(` -> Registered service '${ref.id}'`);
|
|
117
|
+
},
|
|
118
|
+
registerExtensionPoint: (ref, impl) => {
|
|
119
|
+
deps.extensionPointManager.registerExtensionPoint(ref, impl);
|
|
120
|
+
},
|
|
121
|
+
getExtensionPoint: (ref) => {
|
|
122
|
+
return deps.extensionPointManager.getExtensionPoint(ref);
|
|
123
|
+
},
|
|
124
|
+
registerPermissions: (permissions: Permission[]) => {
|
|
125
|
+
// Store permissions with pluginId prefix to namespace them
|
|
126
|
+
const prefixed = permissions.map((p) => ({
|
|
127
|
+
pluginId: pluginId,
|
|
128
|
+
id: `${pluginId}.${p.id}`,
|
|
129
|
+
description: p.description,
|
|
130
|
+
isAuthenticatedDefault: p.isAuthenticatedDefault,
|
|
131
|
+
isPublicDefault: p.isPublicDefault,
|
|
132
|
+
}));
|
|
133
|
+
deps.registeredPermissions.push(...prefixed);
|
|
134
|
+
rootLogger.debug(
|
|
135
|
+
` -> Registered ${prefixed.length} permissions for ${pluginId}`
|
|
136
|
+
);
|
|
137
|
+
},
|
|
138
|
+
registerRouter: (
|
|
139
|
+
router: Router<AnyContractRouter, RpcContext>,
|
|
140
|
+
contract: AnyContractRouter
|
|
141
|
+
) => {
|
|
142
|
+
deps.pluginRpcRouters.set(pluginId, router);
|
|
143
|
+
deps.pluginContractRegistry.set(pluginId, contract);
|
|
144
|
+
rootLogger.debug(` -> Registered router and contract for ${pluginId}`);
|
|
145
|
+
},
|
|
146
|
+
registerCleanup: (cleanup: () => Promise<void>) => {
|
|
147
|
+
const existing = deps.cleanupHandlers.get(pluginId) || [];
|
|
148
|
+
existing.push(cleanup);
|
|
149
|
+
deps.cleanupHandlers.set(pluginId, existing);
|
|
150
|
+
rootLogger.debug(` -> Registered cleanup handler for ${pluginId}`);
|
|
151
|
+
},
|
|
152
|
+
pluginManager: {
|
|
153
|
+
getAllPermissions: () => deps.getAllPermissions(),
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Loads all plugins - main orchestration function.
|
|
160
|
+
*/
|
|
161
|
+
export async function loadPlugins({
|
|
162
|
+
rootRouter,
|
|
163
|
+
manualPlugins = [],
|
|
164
|
+
skipDiscovery = false,
|
|
165
|
+
deps,
|
|
166
|
+
}: {
|
|
167
|
+
rootRouter: Hono;
|
|
168
|
+
manualPlugins?: BackendPlugin[];
|
|
169
|
+
/** When true, skip filesystem plugin discovery (for testing) */
|
|
170
|
+
skipDiscovery?: boolean;
|
|
171
|
+
deps: PluginLoaderDeps;
|
|
172
|
+
}) {
|
|
173
|
+
if (skipDiscovery) {
|
|
174
|
+
rootLogger.debug("⏭️ Plugin discovery skipped (test mode)");
|
|
175
|
+
} else {
|
|
176
|
+
rootLogger.info("🔍 Discovering plugins...");
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// 1. Discover local BACKEND plugins from monorepo using package.json metadata
|
|
180
|
+
let allPlugins: Array<{ name: string; path: string }> = [];
|
|
181
|
+
|
|
182
|
+
if (!skipDiscovery) {
|
|
183
|
+
const workspaceRoot = path.join(__dirname, "..", "..", "..", "..");
|
|
184
|
+
const localPlugins = discoverLocalPlugins({
|
|
185
|
+
workspaceRoot,
|
|
186
|
+
type: "backend",
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
rootLogger.debug(
|
|
190
|
+
` -> Found ${localPlugins.length} local backend plugin(s) in workspace`
|
|
191
|
+
);
|
|
192
|
+
rootLogger.debug(" -> Discovered plugins:");
|
|
193
|
+
for (const p of localPlugins) {
|
|
194
|
+
rootLogger.debug(` • ${p.packageName}`);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// 2. Sync local plugins to database
|
|
198
|
+
await syncPluginsToDatabase({ localPlugins, db: deps.db });
|
|
199
|
+
|
|
200
|
+
// 3. Load all enabled BACKEND plugins from database
|
|
201
|
+
allPlugins = await deps.db
|
|
202
|
+
.select()
|
|
203
|
+
.from(plugins)
|
|
204
|
+
.where(and(eq(plugins.enabled, true), eq(plugins.type, "backend")));
|
|
205
|
+
|
|
206
|
+
rootLogger.debug(
|
|
207
|
+
` -> ${allPlugins.length} enabled backend plugins in database:`
|
|
208
|
+
);
|
|
209
|
+
for (const p of allPlugins) {
|
|
210
|
+
rootLogger.debug(` • ${p.name}`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (allPlugins.length === 0 && manualPlugins.length === 0) {
|
|
215
|
+
rootLogger.info("ℹ️ No enabled plugins found.");
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Phase 1: Load Modules & Register Services
|
|
220
|
+
const pendingInits: PendingInit[] = [];
|
|
221
|
+
const providedBy = new Map<string, string>();
|
|
222
|
+
|
|
223
|
+
for (const plugin of allPlugins) {
|
|
224
|
+
rootLogger.debug(`🔌 Loading module ${plugin.name}...`);
|
|
225
|
+
|
|
226
|
+
try {
|
|
227
|
+
let pluginModule;
|
|
228
|
+
try {
|
|
229
|
+
pluginModule = await import(plugin.name);
|
|
230
|
+
} catch {
|
|
231
|
+
rootLogger.debug(
|
|
232
|
+
` -> Package name import failed, trying path: ${plugin.path}`
|
|
233
|
+
);
|
|
234
|
+
pluginModule = await import(plugin.path);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const backendPlugin: BackendPlugin = pluginModule.default;
|
|
238
|
+
registerPlugin({
|
|
239
|
+
backendPlugin,
|
|
240
|
+
pluginPath: plugin.path,
|
|
241
|
+
pendingInits,
|
|
242
|
+
providedBy,
|
|
243
|
+
deps,
|
|
244
|
+
});
|
|
245
|
+
} catch (error) {
|
|
246
|
+
rootLogger.error(`❌ Failed to load module for ${plugin.name}:`, error);
|
|
247
|
+
rootLogger.error(` Expected path: ${plugin.path}`);
|
|
248
|
+
throw new Error(`Failed to load plugin ${plugin.name}`, { cause: error });
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Phase 1.5: Register manual plugins
|
|
253
|
+
for (const backendPlugin of manualPlugins) {
|
|
254
|
+
registerPlugin({
|
|
255
|
+
backendPlugin,
|
|
256
|
+
pluginPath: "",
|
|
257
|
+
pendingInits,
|
|
258
|
+
providedBy,
|
|
259
|
+
deps,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Phase 2: Initialize Plugins (Topological Sort)
|
|
264
|
+
const logger = await deps.registry.get(coreServices.logger, {
|
|
265
|
+
pluginId: "core",
|
|
266
|
+
});
|
|
267
|
+
const sortedIds = sortPlugins({ pendingInits, providedBy, logger });
|
|
268
|
+
rootLogger.debug(`✅ Initialization Order: ${sortedIds.join(" -> ")}`);
|
|
269
|
+
|
|
270
|
+
// Register /api/* route BEFORE plugin initialization
|
|
271
|
+
const apiHandler = createApiRouteHandler({
|
|
272
|
+
registry: deps.registry,
|
|
273
|
+
pluginRpcRouters: deps.pluginRpcRouters,
|
|
274
|
+
pluginHttpHandlers: deps.pluginHttpHandlers,
|
|
275
|
+
pluginMetadataRegistry: deps.pluginMetadataRegistry,
|
|
276
|
+
});
|
|
277
|
+
registerApiRoute(rootRouter, apiHandler);
|
|
278
|
+
|
|
279
|
+
for (const id of sortedIds) {
|
|
280
|
+
const p = pendingInits.find((x) => x.metadata.pluginId === id)!;
|
|
281
|
+
rootLogger.info(`🚀 Initializing ${p.metadata.pluginId}...`);
|
|
282
|
+
|
|
283
|
+
try {
|
|
284
|
+
const pluginDb = await deps.registry.get(
|
|
285
|
+
coreServices.database,
|
|
286
|
+
p.metadata
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
// Run Migrations
|
|
290
|
+
const migrationsFolder = path.join(p.pluginPath, "drizzle");
|
|
291
|
+
const migrationsSchema = getPluginSchemaName(p.metadata.pluginId);
|
|
292
|
+
if (fs.existsSync(migrationsFolder)) {
|
|
293
|
+
try {
|
|
294
|
+
// Strip "public". schema references from migration SQL at runtime
|
|
295
|
+
stripPublicSchemaFromMigrations(migrationsFolder);
|
|
296
|
+
rootLogger.debug(
|
|
297
|
+
` -> Running migrations for ${p.metadata.pluginId} from ${migrationsFolder}`
|
|
298
|
+
);
|
|
299
|
+
await migrate(pluginDb, { migrationsFolder, migrationsSchema });
|
|
300
|
+
} catch (error) {
|
|
301
|
+
rootLogger.error(
|
|
302
|
+
`❌ Failed migration of plugin ${p.metadata.pluginId}:`,
|
|
303
|
+
error
|
|
304
|
+
);
|
|
305
|
+
throw new Error(`Failed to migrate plugin ${p.metadata.pluginId}`, {
|
|
306
|
+
cause: error,
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
} else {
|
|
310
|
+
rootLogger.debug(
|
|
311
|
+
` -> No migrations found for ${p.metadata.pluginId} (skipping)`
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Resolve Dependencies
|
|
316
|
+
const resolvedDeps: Record<string, unknown> = {};
|
|
317
|
+
for (const [key, ref] of Object.entries(p.deps)) {
|
|
318
|
+
resolvedDeps[key] = await deps.registry.get(
|
|
319
|
+
ref as ServiceRef<unknown>,
|
|
320
|
+
p.metadata
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Inject Schema-aware Database if schema is provided
|
|
325
|
+
if (p.schema) {
|
|
326
|
+
const baseUrl = process.env.DATABASE_URL;
|
|
327
|
+
const assignedSchema = getPluginSchemaName(p.metadata.pluginId);
|
|
328
|
+
const scopedUrl = `${baseUrl}?options=-c%20search_path%3D${assignedSchema}`;
|
|
329
|
+
const pluginPool = new Pool({ connectionString: scopedUrl });
|
|
330
|
+
resolvedDeps["database"] = drizzle(pluginPool, { schema: p.schema });
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
try {
|
|
334
|
+
await p.init(resolvedDeps);
|
|
335
|
+
rootLogger.debug(` -> Initialized ${p.metadata.pluginId}`);
|
|
336
|
+
} catch (error) {
|
|
337
|
+
rootLogger.error(
|
|
338
|
+
`❌ Failed to initialize ${p.metadata.pluginId}:`,
|
|
339
|
+
error
|
|
340
|
+
);
|
|
341
|
+
throw new Error(`Failed to initialize plugin ${p.metadata.pluginId}`, {
|
|
342
|
+
cause: error,
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
} catch (error) {
|
|
346
|
+
rootLogger.error(
|
|
347
|
+
`❌ Critical error loading plugin ${p.metadata.pluginId}:`,
|
|
348
|
+
error
|
|
349
|
+
);
|
|
350
|
+
throw new Error(`Critical error loading plugin ${p.metadata.pluginId}`, {
|
|
351
|
+
cause: error,
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Emit pluginInitialized hooks for all plugins after Phase 2 completes
|
|
357
|
+
// (EventBus is now available)
|
|
358
|
+
const eventBus = await deps.registry.get(coreServices.eventBus, {
|
|
359
|
+
pluginId: "core",
|
|
360
|
+
});
|
|
361
|
+
for (const p of pendingInits) {
|
|
362
|
+
try {
|
|
363
|
+
await eventBus.emit(coreHooks.pluginInitialized, {
|
|
364
|
+
pluginId: p.metadata.pluginId,
|
|
365
|
+
});
|
|
366
|
+
} catch (error) {
|
|
367
|
+
rootLogger.error(
|
|
368
|
+
`Failed to emit pluginInitialized hook for ${p.metadata.pluginId}:`,
|
|
369
|
+
error
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Phase 3: Run afterPluginsReady callbacks
|
|
375
|
+
rootLogger.debug("🔄 Running afterPluginsReady callbacks...");
|
|
376
|
+
|
|
377
|
+
// Emit permission registration hooks at start of Phase 3
|
|
378
|
+
// (EventBus already retrieved above, all plugins can receive notifications)
|
|
379
|
+
const permissionsByPlugin = new Map<string, Permission[]>();
|
|
380
|
+
for (const perm of deps.registeredPermissions) {
|
|
381
|
+
if (!permissionsByPlugin.has(perm.pluginId)) {
|
|
382
|
+
permissionsByPlugin.set(perm.pluginId, []);
|
|
383
|
+
}
|
|
384
|
+
permissionsByPlugin.get(perm.pluginId)!.push({
|
|
385
|
+
id: perm.id,
|
|
386
|
+
description: perm.description,
|
|
387
|
+
isAuthenticatedDefault: perm.isAuthenticatedDefault,
|
|
388
|
+
isPublicDefault: perm.isPublicDefault,
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
for (const [pluginId, permissions] of permissionsByPlugin) {
|
|
392
|
+
try {
|
|
393
|
+
await eventBus.emit(coreHooks.permissionsRegistered, {
|
|
394
|
+
pluginId,
|
|
395
|
+
permissions,
|
|
396
|
+
});
|
|
397
|
+
} catch (error) {
|
|
398
|
+
rootLogger.error(
|
|
399
|
+
`Failed to emit permissionsRegistered hook for ${pluginId}:`,
|
|
400
|
+
error
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
for (const p of pendingInits) {
|
|
405
|
+
if (p.afterPluginsReady) {
|
|
406
|
+
try {
|
|
407
|
+
const resolvedDeps: Record<string, unknown> = {};
|
|
408
|
+
for (const [key, ref] of Object.entries(p.deps)) {
|
|
409
|
+
resolvedDeps[key] = await deps.registry.get(
|
|
410
|
+
ref as ServiceRef<unknown>,
|
|
411
|
+
p.metadata
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
if (p.schema) {
|
|
416
|
+
const baseUrl = process.env.DATABASE_URL;
|
|
417
|
+
const assignedSchema = getPluginSchemaName(p.metadata.pluginId);
|
|
418
|
+
const scopedUrl = `${baseUrl}?options=-c%20search_path%3D${assignedSchema}`;
|
|
419
|
+
const pluginPool = new Pool({ connectionString: scopedUrl });
|
|
420
|
+
resolvedDeps["database"] = drizzle(pluginPool, { schema: p.schema });
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const eventBus = await deps.registry.get(coreServices.eventBus, {
|
|
424
|
+
pluginId: "core",
|
|
425
|
+
});
|
|
426
|
+
resolvedDeps["onHook"] = <T>(
|
|
427
|
+
hook: { id: string },
|
|
428
|
+
listener: (payload: T) => Promise<void>,
|
|
429
|
+
options?: HookSubscribeOptions
|
|
430
|
+
) => {
|
|
431
|
+
return eventBus.subscribe(
|
|
432
|
+
p.metadata.pluginId,
|
|
433
|
+
hook,
|
|
434
|
+
listener,
|
|
435
|
+
options
|
|
436
|
+
);
|
|
437
|
+
};
|
|
438
|
+
resolvedDeps["emitHook"] = async <T>(
|
|
439
|
+
hook: { id: string },
|
|
440
|
+
payload: T
|
|
441
|
+
) => {
|
|
442
|
+
await eventBus.emit(hook, payload);
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
await p.afterPluginsReady(resolvedDeps);
|
|
446
|
+
rootLogger.debug(
|
|
447
|
+
` -> ${p.metadata.pluginId} afterPluginsReady complete`
|
|
448
|
+
);
|
|
449
|
+
} catch (error) {
|
|
450
|
+
rootLogger.error(
|
|
451
|
+
`❌ Failed afterPluginsReady for ${p.metadata.pluginId}:`,
|
|
452
|
+
error
|
|
453
|
+
);
|
|
454
|
+
throw new Error(
|
|
455
|
+
`Failed afterPluginsReady for plugin ${p.metadata.pluginId}`,
|
|
456
|
+
{
|
|
457
|
+
cause: error,
|
|
458
|
+
}
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
rootLogger.debug("✅ All afterPluginsReady callbacks complete");
|
|
464
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { ServiceRef } from "@checkstack/backend-api";
|
|
2
|
+
import type { PluginMetadata } from "@checkstack/common";
|
|
3
|
+
|
|
4
|
+
/** Erased callback type used in PendingInit storage */
|
|
5
|
+
export type InitCallback = (deps: Record<string, unknown>) => Promise<void>;
|
|
6
|
+
|
|
7
|
+
export interface PendingInit {
|
|
8
|
+
metadata: PluginMetadata;
|
|
9
|
+
pluginPath: string;
|
|
10
|
+
deps: Record<string, ServiceRef<unknown>>;
|
|
11
|
+
init: InitCallback;
|
|
12
|
+
afterPluginsReady?: InitCallback;
|
|
13
|
+
schema?: Record<string, unknown>;
|
|
14
|
+
}
|