@checkstack/backend 0.8.2 → 0.9.1

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 (42) hide show
  1. package/CHANGELOG.md +333 -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 +18 -13
  6. package/src/index.ts +276 -17
  7. package/src/plugin-deregistration.test.ts +137 -0
  8. package/src/plugin-manager/api-router.ts +35 -11
  9. package/src/plugin-manager/plugin-loader.ts +73 -0
  10. package/src/plugin-manager.ts +295 -105
  11. package/src/schema.ts +79 -1
  12. package/src/services/cache-manager.test.ts +172 -0
  13. package/src/services/cache-manager.ts +67 -14
  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/event-bus.test.ts +52 -0
  19. package/src/services/event-bus.ts +27 -1
  20. package/src/services/plugin-artifact-store.ts +131 -0
  21. package/src/services/plugin-bundle-resolver.ts +76 -0
  22. package/src/services/plugin-event-recorder.ts +87 -0
  23. package/src/services/plugin-installers/catalog-installer.ts +33 -0
  24. package/src/services/plugin-installers/github-installer.ts +207 -0
  25. package/src/services/plugin-installers/install-from-tarball.ts +69 -0
  26. package/src/services/plugin-installers/installer-registry.ts +51 -0
  27. package/src/services/plugin-installers/npm-installer.ts +156 -0
  28. package/src/services/plugin-installers/plugin-install-error.ts +37 -0
  29. package/src/services/plugin-installers/tarball-installer.ts +80 -0
  30. package/src/services/plugin-installers/tarball-utils.test.ts +200 -0
  31. package/src/services/plugin-installers/tarball-utils.ts +172 -0
  32. package/src/services/plugin-manager-orchestrator.ts +522 -0
  33. package/src/services/plugin-manager-router.ts +219 -0
  34. package/src/services/queue-manager.ts +77 -2
  35. package/src/services/queue-proxy.ts +7 -0
  36. package/src/utils/plugin-discovery.test.ts +6 -0
  37. package/src/utils/plugin-discovery.ts +6 -1
  38. package/tsconfig.json +3 -0
  39. package/src/plugin-lifecycle.test.ts +0 -276
  40. package/src/plugin-manager/plugin-admin-router.ts +0 -89
  41. package/src/services/plugin-installer.test.ts +0 -90
  42. package/src/services/plugin-installer.ts +0 -70
@@ -0,0 +1,219 @@
1
+ import { implement, ORPCError } from "@orpc/server";
2
+ import { desc } from "drizzle-orm";
3
+ import {
4
+ autoAuthMiddleware,
5
+ coreServices,
6
+ type RpcContext,
7
+ type SafeDatabase,
8
+ } from "@checkstack/backend-api";
9
+ import {
10
+ pluginManagerContract,
11
+ type InstalledPlugin,
12
+ } from "@checkstack/pluginmanager-common";
13
+ import {
14
+ pluginPackageTypeSchema,
15
+ pluginSourceSchema,
16
+ } from "@checkstack/common";
17
+ import type { PluginManager } from "../plugin-manager";
18
+ import type { ServiceRegistry } from "./service-registry";
19
+ import type { PluginEventRecorder } from "./plugin-event-recorder";
20
+ import { plugins } from "../schema";
21
+ import {
22
+ installOriginator,
23
+ previewInstallOriginator,
24
+ previewUninstallOriginator,
25
+ uninstallOriginator,
26
+ } from "./plugin-manager-orchestrator";
27
+ import {
28
+ PluginInstallError,
29
+ type PluginInstallErrorCode,
30
+ } from "./plugin-installers/plugin-install-error";
31
+
32
+ /**
33
+ * Map our installer/orchestrator error codes onto the closest oRPC error
34
+ * codes so the UI sees a meaningful HTTP status + message instead of a
35
+ * generic `INTERNAL_SERVER_ERROR`.
36
+ *
37
+ * oRPC's built-in `ORPCError` codes match common HTTP semantics; we route
38
+ * `COMPATIBILITY_FAILED` and `VALIDATION_FAILED` to `BAD_REQUEST` because
39
+ * neither is a server fault — both indicate user-correctable input.
40
+ */
41
+ const ORPC_CODE_MAP: Record<
42
+ PluginInstallErrorCode,
43
+ | "BAD_REQUEST"
44
+ | "UNAUTHORIZED"
45
+ | "FORBIDDEN"
46
+ | "NOT_FOUND"
47
+ | "PAYLOAD_TOO_LARGE"
48
+ | "BAD_GATEWAY"
49
+ | "NOT_IMPLEMENTED"
50
+ > = {
51
+ NOT_FOUND: "NOT_FOUND",
52
+ BAD_REQUEST: "BAD_REQUEST",
53
+ UNAUTHORIZED: "UNAUTHORIZED",
54
+ FORBIDDEN: "FORBIDDEN",
55
+ PAYLOAD_TOO_LARGE: "PAYLOAD_TOO_LARGE",
56
+ BAD_GATEWAY: "BAD_GATEWAY",
57
+ VALIDATION_FAILED: "BAD_REQUEST",
58
+ COMPATIBILITY_FAILED: "BAD_REQUEST",
59
+ NOT_IMPLEMENTED: "NOT_IMPLEMENTED",
60
+ };
61
+
62
+ /**
63
+ * Run an originator function and translate any `PluginInstallError` into
64
+ * an `ORPCError` with the right code + the user-facing message intact.
65
+ * Other errors propagate untouched and surface as 500s — those represent
66
+ * genuine bugs and stay loud.
67
+ */
68
+ async function withTranslatedErrors<T>(fn: () => Promise<T>): Promise<T> {
69
+ try {
70
+ return await fn();
71
+ } catch (error) {
72
+ if (error instanceof PluginInstallError) {
73
+ const data: Record<string, unknown> = { kind: error.code };
74
+ if (error.details) Object.assign(data, error.details);
75
+ throw new ORPCError(ORPC_CODE_MAP[error.code], {
76
+ message: error.message,
77
+ data,
78
+ });
79
+ }
80
+ throw error;
81
+ }
82
+ }
83
+
84
+ /**
85
+ * oRPC router that backs `pluginManagerContract`.
86
+ *
87
+ * Lives in `core/backend` because it directly references the in-process
88
+ * `PluginManager` instance. Wired into the request pipeline via
89
+ * `pluginManager.registerCoreRouter("pluginmanager", ...)`.
90
+ */
91
+ export function createPluginManagerRouter({
92
+ db,
93
+ pluginManager,
94
+ registry,
95
+ eventRecorder,
96
+ workspaceRoot,
97
+ runtimeDir,
98
+ }: {
99
+ db: SafeDatabase<Record<string, unknown>>;
100
+ pluginManager: PluginManager;
101
+ registry: ServiceRegistry;
102
+ eventRecorder: PluginEventRecorder;
103
+ workspaceRoot: string;
104
+ runtimeDir: string;
105
+ }) {
106
+ // touch coreServices to keep the import alive in case future router
107
+ // procedures need to resolve services on demand.
108
+ void coreServices;
109
+
110
+ const impl = implement(pluginManagerContract)
111
+ .$context<RpcContext>()
112
+ .use(autoAuthMiddleware);
113
+
114
+ return impl.router({
115
+ list: impl.list.handler(async () => {
116
+ const rows = await db
117
+ .select()
118
+ .from(plugins)
119
+ .orderBy(desc(plugins.id));
120
+ const list: InstalledPlugin[] = rows.map((row) => {
121
+ // type may legitimately not match the enum for legacy/tooling rows
122
+ // — fall back to a safe value rather than 500-ing on parse.
123
+ const typeParsed = pluginPackageTypeSchema.safeParse(row.type);
124
+ // metadata is JSONB; drizzle returns `unknown | null`. The contract
125
+ // declares it as `optional()` (accepts undefined, NOT null), so
126
+ // coerce. Also accept only objects — string/number/array would fail
127
+ // schema validation.
128
+ const metadata =
129
+ row.metadata && typeof row.metadata === "object" && !Array.isArray(row.metadata)
130
+ ? (row.metadata as InstalledPlugin["metadata"])
131
+ : undefined;
132
+ // source: null for monorepo-local; validated PluginSource for
133
+ // runtime-installed. Wrap parse in safeParse so a malformed
134
+ // legacy row doesn't break the whole list response.
135
+ const sourceParsed = row.source
136
+ ? pluginSourceSchema.safeParse(row.source)
137
+ : undefined;
138
+ return {
139
+ name: row.name,
140
+ version: row.version ?? "",
141
+ type: typeParsed.success ? typeParsed.data : "backend",
142
+ enabled: row.enabled,
143
+ isUninstallable: row.isUninstallable,
144
+ isPrimary: row.isPrimary,
145
+ bundleId: row.bundleId,
146
+ source: sourceParsed?.success ? sourceParsed.data : null,
147
+ metadata,
148
+ };
149
+ });
150
+ return { plugins: list };
151
+ }),
152
+
153
+ previewInstall: impl.previewInstall.handler(({ input }) =>
154
+ withTranslatedErrors(() =>
155
+ previewInstallOriginator({
156
+ source: input.source,
157
+ registry,
158
+ workspaceRoot,
159
+ runtimeDir,
160
+ }),
161
+ ),
162
+ ),
163
+
164
+ install: impl.install.handler(async ({ input, context }) => {
165
+ const userId = (context.user as { id?: string } | undefined)?.id;
166
+ const result = await withTranslatedErrors(() =>
167
+ installOriginator({
168
+ source: input.source,
169
+ pluginManager,
170
+ db,
171
+ registry,
172
+ workspaceRoot,
173
+ runtimeDir,
174
+ eventRecorder,
175
+ userId,
176
+ }),
177
+ );
178
+ return {
179
+ success: true,
180
+ bundleId: result.bundleId,
181
+ installedPackages: result.installedPackages,
182
+ };
183
+ }),
184
+
185
+ previewUninstall: impl.previewUninstall.handler(({ input }) =>
186
+ withTranslatedErrors(() =>
187
+ previewUninstallOriginator({
188
+ pluginName: input.pluginName,
189
+ db,
190
+ }),
191
+ ),
192
+ ),
193
+
194
+ uninstall: impl.uninstall.handler(async ({ input, context }) => {
195
+ const userId = (context.user as { id?: string } | undefined)?.id;
196
+ const result = await withTranslatedErrors(() =>
197
+ uninstallOriginator({
198
+ pluginName: input.pluginName,
199
+ deleteSchema: input.deleteSchema,
200
+ deleteConfigs: input.deleteConfigs,
201
+ cascade: input.cascade,
202
+ pluginManager,
203
+ db,
204
+ eventRecorder,
205
+ userId,
206
+ }),
207
+ );
208
+ return { success: true, uninstalledPackages: result.uninstalledPackages };
209
+ }),
210
+
211
+ events: impl.events.handler(async ({ input }) => {
212
+ const events = await eventRecorder.list({
213
+ pluginName: input.pluginName,
214
+ limit: input.limit,
215
+ });
216
+ return { events };
217
+ }),
218
+ });
219
+ }
@@ -4,6 +4,9 @@ import type {
4
4
  SwitchResult,
5
5
  RecurringJobInfo,
6
6
  QueueStats,
7
+ JobSummary,
8
+ ListJobsOptions,
9
+ ListJobsResult,
7
10
  } from "@checkstack/queue-api";
8
11
  import type { QueuePluginRegistryImpl } from "./queue-plugin-registry";
9
12
  import type { Logger, ConfigService } from "@checkstack/backend-api";
@@ -276,7 +279,11 @@ export class QueueManagerImpl implements QueueManager {
276
279
  }
277
280
 
278
281
  async getAggregatedStats(): Promise<QueueStats> {
279
- const aggregated: QueueStats = {
282
+ // The narrowest scope wins: if any queue reports `instance`, the
283
+ // aggregate cannot claim cluster-wide accuracy.
284
+ let scope: "instance" | "cluster" = "cluster";
285
+ let sawAny = false;
286
+ const aggregated: Omit<QueueStats, "scope"> = {
280
287
  pending: 0,
281
288
  processing: 0,
282
289
  completed: 0,
@@ -294,13 +301,81 @@ export class QueueManagerImpl implements QueueManager {
294
301
  aggregated.completed += stats.completed;
295
302
  aggregated.failed += stats.failed;
296
303
  aggregated.consumerGroups += stats.consumerGroups;
304
+ if (stats.scope === "instance") {
305
+ scope = "instance";
306
+ }
307
+ sawAny = true;
308
+ }
309
+ } catch {
310
+ // Queue may not be initialized yet
311
+ }
312
+ }
313
+
314
+ return { ...aggregated, scope: sawAny ? scope : "instance" };
315
+ }
316
+
317
+ /**
318
+ * Aggregate `listJobs` across every queue proxy. For deployments with a
319
+ * single queue (the common case) this is a straight pass-through; with
320
+ * multiple queues we over-fetch [0, offset+limit) per queue, merge-sort
321
+ * by the same key the per-queue impls use, then slice the requested
322
+ * window. Sort key:
323
+ * - completed/failed → finishedAt desc (most-recent first)
324
+ * - active → startedAt asc (oldest still-running first)
325
+ * - waiting/delayed → enqueuedAt asc (FIFO)
326
+ */
327
+ async listJobs(opts: ListJobsOptions): Promise<ListJobsResult> {
328
+ const limit = Math.max(0, opts.limit);
329
+ const offset = Math.max(0, opts.offset);
330
+ if (limit === 0) {
331
+ return { items: [], total: 0, hasMore: false };
332
+ }
333
+
334
+ const fetchUpTo = offset + limit;
335
+ const merged: JobSummary[] = [];
336
+ let totalSum = 0;
337
+ let totalIsNull = false;
338
+
339
+ for (const proxy of this.queueProxies.values()) {
340
+ try {
341
+ const delegate = proxy.getDelegate();
342
+ if (delegate) {
343
+ const part = await delegate.listJobs({
344
+ state: opts.state,
345
+ offset: 0,
346
+ limit: fetchUpTo,
347
+ });
348
+ merged.push(...part.items);
349
+ if (part.total === null) {
350
+ totalIsNull = true;
351
+ } else {
352
+ totalSum += part.total;
353
+ }
297
354
  }
298
355
  } catch {
299
356
  // Queue may not be initialized yet
300
357
  }
301
358
  }
302
359
 
303
- return aggregated;
360
+ const sortKey = (j: JobSummary): number => {
361
+ if (opts.state === "completed" || opts.state === "failed") {
362
+ return -(j.finishedAt?.getTime() ?? 0);
363
+ }
364
+ if (opts.state === "active") {
365
+ return j.startedAt?.getTime() ?? 0;
366
+ }
367
+ return j.enqueuedAt.getTime();
368
+ };
369
+ merged.sort((a, b) => sortKey(a) - sortKey(b));
370
+
371
+ const items = merged.slice(offset, offset + limit);
372
+ const total = totalIsNull ? null : totalSum;
373
+ const hasMore =
374
+ total === null
375
+ ? merged.length > offset + items.length
376
+ : offset + items.length < total;
377
+
378
+ return { items, total, hasMore };
304
379
  }
305
380
 
306
381
  async listAllRecurringJobs(): Promise<RecurringJobInfo[]> {
@@ -5,6 +5,8 @@ import type {
5
5
  QueueStats,
6
6
  RecurringJobDetails,
7
7
  RecurringSchedule,
8
+ ListJobsOptions,
9
+ ListJobsResult,
8
10
  } from "@checkstack/queue-api";
9
11
  import { rootLogger } from "../logger";
10
12
 
@@ -173,6 +175,11 @@ export class QueueProxy<T = unknown> implements Queue<T> {
173
175
  return delegate.getStats();
174
176
  }
175
177
 
178
+ async listJobs(opts: ListJobsOptions): Promise<ListJobsResult> {
179
+ const delegate = this.ensureDelegate();
180
+ return delegate.listJobs(opts);
181
+ }
182
+
176
183
  /**
177
184
  * Get subscription count (for testing/debugging)
178
185
  */
@@ -51,6 +51,7 @@ describe("extractPluginMetadata", () => {
51
51
  pluginPath: "/fake/path/test-backend",
52
52
  type: "backend",
53
53
  enabled: true,
54
+ version: "0.0.1",
54
55
  });
55
56
  });
56
57
 
@@ -288,6 +289,7 @@ describe("syncPluginsToDatabase", () => {
288
289
  pluginPath: "/workspace/plugins/new-backend",
289
290
  type: "backend",
290
291
  enabled: true,
292
+ version: "2.1.0",
291
293
  },
292
294
  ];
293
295
 
@@ -310,6 +312,7 @@ describe("syncPluginsToDatabase", () => {
310
312
  type: "backend",
311
313
  enabled: true,
312
314
  isUninstallable: false,
315
+ version: "2.1.0",
313
316
  });
314
317
  });
315
318
 
@@ -320,6 +323,7 @@ describe("syncPluginsToDatabase", () => {
320
323
  pluginPath: "/workspace/plugins/new-location",
321
324
  type: "backend",
322
325
  enabled: true,
326
+ version: "1.0.0",
323
327
  },
324
328
  ];
325
329
 
@@ -347,6 +351,7 @@ describe("syncPluginsToDatabase", () => {
347
351
  expect(updateCall.set).toHaveBeenCalledWith({
348
352
  path: "/workspace/plugins/new-location",
349
353
  type: "backend",
354
+ version: "1.0.0",
350
355
  });
351
356
  });
352
357
 
@@ -357,6 +362,7 @@ describe("syncPluginsToDatabase", () => {
357
362
  pluginPath: "/workspace/plugins/remote-backend",
358
363
  type: "backend",
359
364
  enabled: true,
365
+ version: "0.0.0",
360
366
  },
361
367
  ];
362
368
 
@@ -10,6 +10,7 @@ export interface PluginMetadata {
10
10
  pluginPath: string; // Absolute path to plugin directory
11
11
  type: "backend" | "frontend" | "common";
12
12
  enabled: boolean;
13
+ version: string; // From package.json "version"
13
14
  }
14
15
 
15
16
  /**
@@ -64,6 +65,7 @@ export function extractPluginMetadata({
64
65
  pluginPath: pluginDir,
65
66
  type,
66
67
  enabled: true, // Local plugins are always enabled
68
+ version: typeof pkgJson.version === "string" ? pkgJson.version : "",
67
69
  };
68
70
  } catch (error) {
69
71
  rootLogger.debug(`⚠️ Failed to read package.json for ${pluginDir}:`, error);
@@ -148,16 +150,19 @@ export async function syncPluginsToDatabase({
148
150
  type: plugin.type,
149
151
  enabled: plugin.enabled,
150
152
  isUninstallable: false, // Local plugins are part of monorepo
153
+ version: plugin.version,
151
154
  });
152
155
  } else {
153
156
  // Update existing plugin ONLY if it's a local plugin (not remotely installed)
154
- // This handles the case where a plugin directory was renamed
157
+ // This handles the case where a plugin directory was renamed AND keeps
158
+ // `version` in lockstep with each release.
155
159
  if (!existing[0].isUninstallable) {
156
160
  await db
157
161
  .update(plugins)
158
162
  .set({
159
163
  path: plugin.pluginPath,
160
164
  type: plugin.type,
165
+ version: plugin.version,
161
166
  })
162
167
  .where(
163
168
  and(
package/tsconfig.json CHANGED
@@ -22,6 +22,9 @@
22
22
  {
23
23
  "path": "../drizzle-helper"
24
24
  },
25
+ {
26
+ "path": "../pluginmanager-common"
27
+ },
25
28
  {
26
29
  "path": "../queue-api"
27
30
  },