@checkstack/backend 0.8.1 → 0.9.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.
Files changed (40) hide show
  1. package/CHANGELOG.md +280 -0
  2. package/drizzle/0001_slim_mordo.sql +34 -0
  3. package/drizzle/meta/0001_snapshot.json +444 -0
  4. package/drizzle/meta/_journal.json +7 -0
  5. package/package.json +14 -9
  6. package/src/index.ts +460 -23
  7. package/src/plugin-deregistration.test.ts +137 -0
  8. package/src/plugin-manager/api-router.ts +35 -11
  9. package/src/plugin-manager/core-services.ts +21 -2
  10. package/src/plugin-manager/plugin-loader.ts +94 -0
  11. package/src/plugin-manager.ts +324 -105
  12. package/src/router-incremental.test.ts +49 -0
  13. package/src/schema.ts +79 -1
  14. package/src/services/compatibility-checker.test.ts +146 -0
  15. package/src/services/compatibility-checker.ts +137 -0
  16. package/src/services/dev-auth.test.ts +87 -0
  17. package/src/services/dev-auth.ts +56 -0
  18. package/src/services/plugin-artifact-store.ts +131 -0
  19. package/src/services/plugin-bundle-resolver.ts +76 -0
  20. package/src/services/plugin-event-recorder.ts +87 -0
  21. package/src/services/plugin-installers/catalog-installer.ts +33 -0
  22. package/src/services/plugin-installers/github-installer.ts +207 -0
  23. package/src/services/plugin-installers/install-from-tarball.ts +69 -0
  24. package/src/services/plugin-installers/installer-registry.ts +51 -0
  25. package/src/services/plugin-installers/npm-installer.ts +156 -0
  26. package/src/services/plugin-installers/plugin-install-error.ts +37 -0
  27. package/src/services/plugin-installers/tarball-installer.ts +80 -0
  28. package/src/services/plugin-installers/tarball-utils.test.ts +200 -0
  29. package/src/services/plugin-installers/tarball-utils.ts +172 -0
  30. package/src/services/plugin-manager-orchestrator.ts +522 -0
  31. package/src/services/plugin-manager-router.ts +219 -0
  32. package/src/services/readiness-registry.test.ts +124 -0
  33. package/src/services/readiness-registry.ts +103 -0
  34. package/src/utils/plugin-discovery.test.ts +6 -0
  35. package/src/utils/plugin-discovery.ts +6 -1
  36. package/tsconfig.json +36 -1
  37. package/src/plugin-lifecycle.test.ts +0 -276
  38. package/src/plugin-manager/plugin-admin-router.ts +0 -89
  39. package/src/services/plugin-installer.test.ts +0 -90
  40. package/src/services/plugin-installer.ts +0 -70
@@ -0,0 +1,137 @@
1
+ import { describe, it, expect, mock, beforeEach } from "bun:test";
2
+ import { PluginManager } from "./plugin-manager";
3
+
4
+ /**
5
+ * Integration test for the originator/in-process split introduced in the
6
+ * runtime plugin system.
7
+ *
8
+ * Key contract: `deregisterPluginInProcess` must NOT touch any persistent
9
+ * state. It runs on every instance via the broadcast hook; the destructive
10
+ * cleanup (drop schema, delete plugin_configs, delete plugin_artifacts,
11
+ * delete plugins rows) only runs on the originator via `deletePluginData`.
12
+ *
13
+ * If a future refactor accidentally pushes destructive ops back into the
14
+ * in-process path, these tests fire.
15
+ */
16
+
17
+ describe("deregisterPluginInProcess", () => {
18
+ let pluginManager: PluginManager;
19
+
20
+ beforeEach(() => {
21
+ pluginManager = new PluginManager();
22
+ });
23
+
24
+ it("clears in-memory cleanup handlers and runs them in LIFO order", async () => {
25
+ const order: string[] = [];
26
+ const handlerA = mock(async () => {
27
+ order.push("a");
28
+ });
29
+ const handlerB = mock(async () => {
30
+ order.push("b");
31
+ });
32
+
33
+ // Inject cleanup handlers using the same mechanism plugins use during
34
+ // registration. We poke the private map because the public path (full
35
+ // backendPlugin.register) needs a real plugin module.
36
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
37
+ const handlers = (pluginManager as any).cleanupHandlers as Map<
38
+ string,
39
+ Array<() => Promise<void>>
40
+ >;
41
+ handlers.set("test-plugin", [handlerA, handlerB]);
42
+
43
+ await pluginManager.deregisterPluginInProcess("test-plugin");
44
+
45
+ // LIFO: handlerB ran before handlerA
46
+ expect(order).toEqual(["b", "a"]);
47
+ expect(handlers.has("test-plugin")).toBe(false);
48
+ });
49
+
50
+ it("removes router, contract, metadata and access-rule entries", async () => {
51
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
52
+ const pm = pluginManager as any;
53
+ pm.pluginRpcRouters.set("foo", { router: true });
54
+ pm.pluginContractRegistry.set("foo", { contract: true });
55
+ pm.pluginMetadataRegistry.set("foo", { pluginId: "foo" });
56
+ pm.registeredAccessRules.push({
57
+ id: "foo.something",
58
+ pluginId: "foo",
59
+ action: "read",
60
+ resourceType: "thing",
61
+ description: "test",
62
+ });
63
+
64
+ await pluginManager.deregisterPluginInProcess("foo");
65
+
66
+ expect(pm.pluginRpcRouters.has("foo")).toBe(false);
67
+ expect(pm.pluginContractRegistry.has("foo")).toBe(false);
68
+ expect(pm.pluginMetadataRegistry.has("foo")).toBe(false);
69
+ expect(
70
+ pm.registeredAccessRules.some(
71
+ (r: { pluginId: string }) => r.pluginId === "foo",
72
+ ),
73
+ ).toBe(false);
74
+ });
75
+
76
+ it("does not invoke any artifact-store or destructive ops", async () => {
77
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
78
+ const pm = pluginManager as any;
79
+
80
+ const artifactStoreCalls: string[] = [];
81
+ const fakeArtifactStore = {
82
+ maxArtifactSize: 1,
83
+ store: () => {
84
+ artifactStoreCalls.push("store");
85
+ return Promise.resolve({ artifactId: "x", contentHash: "x" });
86
+ },
87
+ fetch: () => {
88
+ artifactStoreCalls.push("fetch");
89
+ return Promise.resolve(undefined);
90
+ },
91
+ fetchById: () => {
92
+ artifactStoreCalls.push("fetchById");
93
+ return Promise.resolve(undefined);
94
+ },
95
+ delete: () => {
96
+ artifactStoreCalls.push("delete");
97
+ return Promise.resolve();
98
+ },
99
+ deleteByBundle: () => {
100
+ artifactStoreCalls.push("deleteByBundle");
101
+ return Promise.resolve();
102
+ },
103
+ };
104
+ // The artifact store is registered via coreServices.pluginArtifactStore.
105
+ // Even when present, deregisterPluginInProcess should not consult it.
106
+ pm.registry.register(
107
+ { id: "core.pluginArtifactStore" },
108
+ fakeArtifactStore,
109
+ );
110
+
111
+ pm.cleanupHandlers.set("foo", []);
112
+ await pluginManager.deregisterPluginInProcess("foo");
113
+
114
+ expect(artifactStoreCalls).toEqual([]);
115
+ });
116
+
117
+ it("handler errors do not block subsequent cleanup or remove-from-registry", async () => {
118
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
119
+ const pm = pluginManager as any;
120
+
121
+ const goodHandler = mock(async () => {});
122
+ const badHandler = mock(async () => {
123
+ throw new Error("intentional");
124
+ });
125
+
126
+ pm.cleanupHandlers.set("plugin-a", [goodHandler, badHandler]);
127
+ pm.pluginRpcRouters.set("plugin-a", {});
128
+
129
+ await pluginManager.deregisterPluginInProcess("plugin-a");
130
+
131
+ expect(badHandler).toHaveBeenCalled();
132
+ expect(goodHandler).toHaveBeenCalled();
133
+ // Even though one handler threw, the in-memory state was cleared:
134
+ expect(pm.pluginRpcRouters.has("plugin-a")).toBe(false);
135
+ expect(pm.cleanupHandlers.has("plugin-a")).toBe(false);
136
+ });
137
+ });
@@ -20,6 +20,8 @@ import type {
20
20
  import type { ServiceRegistry } from "../services/service-registry";
21
21
  import type { EventBus } from "@checkstack/backend-api";
22
22
  import type { PluginMetadata } from "@checkstack/common";
23
+ import { rootLogger } from "../logger";
24
+ import { extractErrorMessage } from "@checkstack/common";
23
25
 
24
26
  /**
25
27
  * Creates the API route handler for Hono.
@@ -71,17 +73,18 @@ export function createApiRouteHandler({
71
73
  try {
72
74
  return await next(rest);
73
75
  } catch (error) {
74
- if (logger) {
75
- logger.error(`RPC procedure error: ${String(error)}`);
76
- const stack =
77
- error !== null &&
78
- typeof error === "object" &&
79
- "stack" in error
80
- ? (error as { stack: string }).stack
81
- : undefined;
82
- if (stack) {
83
- logger.error(`Stack trace: ${stack}`);
84
- }
76
+ const target = (logger ?? rootLogger) as Logger;
77
+ target.error(
78
+ `RPC ${pathname} failed: ${extractErrorMessage(error)}`,
79
+ );
80
+ const stack =
81
+ error !== null &&
82
+ typeof error === "object" &&
83
+ "stack" in error
84
+ ? (error as { stack: string }).stack
85
+ : undefined;
86
+ if (stack) {
87
+ target.error(`Stack trace: ${stack}`);
85
88
  }
86
89
  throw error;
87
90
  }
@@ -121,6 +124,22 @@ export function createApiRouteHandler({
121
124
  !cacheManager ||
122
125
  !eventBus
123
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
+ );
124
143
  return c.json({ error: "Core services not initialized" }, 500);
125
144
  }
126
145
 
@@ -136,6 +155,11 @@ export function createApiRouteHandler({
136
155
  pluginMetadataRegistry.get(pluginId);
137
156
 
138
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
+ );
139
163
  return c.json({ error: "Plugin metadata not found in registry" }, 500);
140
164
  }
141
165
 
@@ -30,6 +30,10 @@ import {
30
30
  WebSocketRouteStoreImpl,
31
31
  createScopedWsRegistry,
32
32
  } from "../services/ws-route-registry";
33
+ import {
34
+ CoreReadinessRegistry,
35
+ createScopedReadinessRegistry,
36
+ } from "../services/readiness-registry";
33
37
 
34
38
  /**
35
39
  * Check if a PostgreSQL schema exists.
@@ -59,7 +63,11 @@ export function registerCoreServices({
59
63
  pluginRpcRouters: Map<string, unknown>;
60
64
  pluginHttpHandlers: Map<string, (req: Request) => Promise<Response>>;
61
65
  pluginContractRegistry: Map<string, unknown>;
62
- }): { collectorRegistry: CoreCollectorRegistry; wsStore: WebSocketRouteStoreImpl } {
66
+ }): {
67
+ collectorRegistry: CoreCollectorRegistry;
68
+ wsStore: WebSocketRouteStoreImpl;
69
+ readinessRegistry: CoreReadinessRegistry;
70
+ } {
63
71
  // 1. Database Factory (Scoped)
64
72
  registry.registerFactory(coreServices.database, async (metadata) => {
65
73
  const { pluginId, previousPluginIds } = metadata;
@@ -356,6 +364,17 @@ export function registerCoreServices({
356
364
  createScopedWsRegistry(globalWsStore, metadata.pluginId),
357
365
  );
358
366
 
367
+ // 11. Readiness Registry (Scoped Factory)
368
+ // Plugins contribute probes that are aggregated by the /ready endpoint.
369
+ const globalReadinessRegistry = new CoreReadinessRegistry();
370
+ registry.registerFactory(coreServices.readinessRegistry, () =>
371
+ createScopedReadinessRegistry(globalReadinessRegistry),
372
+ );
373
+
359
374
  // Return global registries for lifecycle cleanup
360
- return { collectorRegistry: globalCollectorRegistry, wsStore: globalWsStore };
375
+ return {
376
+ collectorRegistry: globalCollectorRegistry,
377
+ wsStore: globalWsStore,
378
+ readinessRegistry: globalReadinessRegistry,
379
+ };
361
380
  }
@@ -56,6 +56,14 @@ export interface PluginLoaderDeps {
56
56
  * Map of pluginId -> contract for OpenAPI generation.
57
57
  */
58
58
  pluginContractRegistry: Map<string, AnyContractRouter>;
59
+ /**
60
+ * Called once `/api/:pluginId/*` is added to the root router and Phase 2
61
+ * (per-plugin init) is about to start. From this point on, plugin RPC
62
+ * routers come online incrementally as each plugin initializes — so
63
+ * self-referencing HTTP calls (e.g. RPC made from `afterPluginsReady`)
64
+ * can be allowed through the boot-time request gate without deadlocking.
65
+ */
66
+ onApiRouteRegistered?: () => void;
59
67
  }
60
68
 
61
69
  /**
@@ -216,6 +224,14 @@ export async function loadPlugins({
216
224
  // 2. Sync local plugins to database
217
225
  await syncPluginsToDatabase({ localPlugins, db: deps.db });
218
226
 
227
+ // 2.5 Bootstrap runtime-installed plugins missing from node_modules.
228
+ // For every `is_uninstallable=true` row in `plugins`, ensure the
229
+ // package is installed in the runtime dir. If not, fetch its tarball
230
+ // from `plugin_artifacts` and run `bun install` so the import below
231
+ // resolves. This is what lets a freshly spun replica recover the
232
+ // full plugin set from Postgres alone.
233
+ await bootstrapRuntimePlugins({ db: deps.db, registry: deps.registry });
234
+
219
235
  // 3. Load all enabled BACKEND plugins from database
220
236
  allPlugins = await deps.db
221
237
  .select()
@@ -295,6 +311,19 @@ export async function loadPlugins({
295
311
  });
296
312
  registerApiRoute(rootRouter, apiHandler);
297
313
 
314
+ // Routes are now registered on the root router. Signal readiness so the
315
+ // server can stop blocking incoming requests in `waitForInit()`. We open
316
+ // the gate here (BEFORE Phase 2 / Phase 3) so that:
317
+ // - the static module-load endpoints (/api/plugins, /api/about, …) stop
318
+ // hanging behind the boot gate;
319
+ // - cross-plugin RPC calls made from `afterPluginsReady` can self-loop
320
+ // through the HTTP server without deadlocking on init completion.
321
+ // Plugin RPC routers come online incrementally as each plugin's Phase 2
322
+ // init runs; requests targeting a not-yet-initialized plugin fall through
323
+ // to the api-router's "Plugin metadata not found" 500, which is the
324
+ // pre-existing behavior and is preferable to a multi-second hang.
325
+ deps.onApiRouteRegistered?.();
326
+
298
327
  for (const id of sortedIds) {
299
328
  const p = pendingInits.find((x) => x.metadata.pluginId === id)!;
300
329
  rootLogger.info(`🚀 Initializing ${p.metadata.pluginId}...`);
@@ -620,3 +649,68 @@ function validateContractAccessRules({
620
649
  }
621
650
  }
622
651
  }
652
+
653
+ /**
654
+ * Fresh-instance bootstrap.
655
+ *
656
+ * Walks every `is_uninstallable=true` row in the `plugins` table and ensures
657
+ * the corresponding package is installed under the runtime dir. Missing
658
+ * packages are recovered from `plugin_artifacts` (bytea) and installed with
659
+ * `bun install --no-save --ignore-scripts`. Once this returns, the normal
660
+ * `pluginModule = await import(...)` in Phase 1 below resolves.
661
+ */
662
+ async function bootstrapRuntimePlugins({
663
+ db,
664
+ registry,
665
+ }: {
666
+ db: SafeDatabase<Record<string, unknown>>;
667
+ registry: ServiceRegistry;
668
+ }): Promise<void> {
669
+ const installerRegistry = await registry
670
+ .get(coreServices.pluginInstallerRegistry, { pluginId: "core" })
671
+ .catch(() => {});
672
+ const artifactStore = await registry
673
+ .get(coreServices.pluginArtifactStore, { pluginId: "core" })
674
+ .catch(() => {});
675
+ if (!installerRegistry || !artifactStore) {
676
+ rootLogger.debug(
677
+ " -> Skipping runtime plugin bootstrap (services not registered)",
678
+ );
679
+ return;
680
+ }
681
+
682
+ const remoteRows = await db
683
+ .select()
684
+ .from(plugins)
685
+ .where(eq(plugins.isUninstallable, true));
686
+
687
+ for (const row of remoteRows) {
688
+ if (!row.path) continue;
689
+ const pkgJsonPath = path.join(row.path, "package.json");
690
+ if (fs.existsSync(pkgJsonPath)) {
691
+ rootLogger.debug(` -> ${row.name} already present, skipping bootstrap`);
692
+ continue;
693
+ }
694
+ rootLogger.info(
695
+ `🔌 Bootstrapping runtime plugin from artifact store: ${row.name}@${row.version}`,
696
+ );
697
+ const artifact = await artifactStore.fetch({
698
+ pluginName: row.name,
699
+ version: row.version,
700
+ });
701
+ if (!artifact) {
702
+ rootLogger.warn(
703
+ ` -> No artifact for ${row.name}@${row.version} — skipping. Re-install from the original source.`,
704
+ );
705
+ continue;
706
+ }
707
+ const allowInstallScripts =
708
+ (row.metadata as { checkstack?: { allowInstallScripts?: boolean } })
709
+ ?.checkstack?.allowInstallScripts === true;
710
+ await installerRegistry.forSource("npm").installFromArtifact({
711
+ tarball: artifact.tarball,
712
+ pluginName: row.name,
713
+ allowInstallScripts,
714
+ });
715
+ }
716
+ }