@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,522 @@
1
+ import path from "node:path";
2
+ import { randomUUID } from "node:crypto";
3
+ import { eq, and, sql, inArray } from "drizzle-orm";
4
+ import {
5
+ coreServices,
6
+ type SafeDatabase,
7
+ type PluginSource,
8
+ } from "@checkstack/backend-api";
9
+ import { extractErrorMessage } from "@checkstack/common";
10
+ import {
11
+ installPreviewSchema,
12
+ uninstallPreviewSchema,
13
+ type InstallPreview,
14
+ type UninstallPreview,
15
+ } from "@checkstack/pluginmanager-common";
16
+ import type { PluginManager } from "../plugin-manager";
17
+ import type { ServiceRegistry } from "./service-registry";
18
+ import { plugins, pluginConfigs } from "../schema";
19
+ import { resolveBundle } from "./plugin-bundle-resolver";
20
+ import {
21
+ loadCheckstackPackageVersions,
22
+ checkCompatibility,
23
+ } from "./compatibility-checker";
24
+ import type { PluginEventRecorder } from "./plugin-event-recorder";
25
+ import { PluginInstallError } from "./plugin-installers/plugin-install-error";
26
+
27
+ /**
28
+ * Originator-side install/uninstall orchestration.
29
+ *
30
+ * Lives next to the router because both flows are tightly coupled to the
31
+ * `pluginManager` instance, the artifact store, and `db`. The router is a
32
+ * thin oRPC wrapper over these functions.
33
+ */
34
+
35
+ export async function previewInstallOriginator({
36
+ source,
37
+ registry,
38
+ workspaceRoot,
39
+ runtimeDir,
40
+ }: {
41
+ source: PluginSource;
42
+ registry: ServiceRegistry;
43
+ workspaceRoot: string;
44
+ runtimeDir: string;
45
+ }): Promise<InstallPreview> {
46
+ const installerRegistry = await registry.get(
47
+ coreServices.pluginInstallerRegistry,
48
+ { pluginId: "core" },
49
+ );
50
+
51
+ const { primary, packages } = await resolveBundle({
52
+ source,
53
+ installerRegistry,
54
+ });
55
+
56
+ const loadedVersions = loadCheckstackPackageVersions({
57
+ workspaceRoot,
58
+ runtimeDir,
59
+ });
60
+
61
+ const compatibilityIssues = checkCompatibility({
62
+ packages: packages.map((p) => p.packageJson),
63
+ loadedVersions,
64
+ });
65
+
66
+ const totalSizeBytes = packages.reduce(
67
+ (sum, p) => sum + p.tarball.byteLength,
68
+ 0,
69
+ );
70
+ const hasInstallScripts = packages.some(
71
+ (p) => p.packageJson.checkstack.allowInstallScripts === true,
72
+ );
73
+
74
+ return installPreviewSchema.parse({
75
+ primary: primary.packageJson,
76
+ packages: packages.map((p) => p.packageJson),
77
+ compatibilityIssues,
78
+ totalSizeBytes,
79
+ hasInstallScripts,
80
+ } satisfies InstallPreview);
81
+ }
82
+
83
+ export async function installOriginator({
84
+ source,
85
+ pluginManager,
86
+ db,
87
+ registry,
88
+ workspaceRoot,
89
+ runtimeDir,
90
+ eventRecorder,
91
+ userId,
92
+ }: {
93
+ source: PluginSource;
94
+ pluginManager: PluginManager;
95
+ db: SafeDatabase<Record<string, unknown>>;
96
+ registry: ServiceRegistry;
97
+ workspaceRoot: string;
98
+ runtimeDir: string;
99
+ eventRecorder: PluginEventRecorder;
100
+ userId?: string;
101
+ }): Promise<{
102
+ bundleId: string | null;
103
+ installedPackages: string[];
104
+ }> {
105
+ const installerRegistry = await registry.get(
106
+ coreServices.pluginInstallerRegistry,
107
+ { pluginId: "core" },
108
+ );
109
+ const artifactStore = await registry.get(coreServices.pluginArtifactStore, {
110
+ pluginId: "core",
111
+ });
112
+
113
+ // 1. Validate (re-fetch + recheck — preview is non-binding)
114
+ await eventRecorder.record({
115
+ action: "install",
116
+ phase: "validate",
117
+ status: "started",
118
+ source,
119
+ userId,
120
+ });
121
+ const { primary, packages } = await resolveBundle({
122
+ source,
123
+ installerRegistry,
124
+ });
125
+ const loadedVersions = loadCheckstackPackageVersions({
126
+ workspaceRoot,
127
+ runtimeDir,
128
+ });
129
+ const issues = checkCompatibility({
130
+ packages: packages.map((p) => p.packageJson),
131
+ loadedVersions,
132
+ });
133
+ if (issues.length > 0) {
134
+ const message = issues.map((i) => i.message).join("\n");
135
+ await eventRecorder.record({
136
+ pluginName: primary.packageJson.name,
137
+ action: "install",
138
+ phase: "validate",
139
+ status: "failed",
140
+ source,
141
+ error: message,
142
+ userId,
143
+ });
144
+ throw new PluginInstallError(
145
+ "COMPATIBILITY_FAILED",
146
+ `This plugin isn't compatible with the platform:\n${message}`,
147
+ { issues },
148
+ );
149
+ }
150
+ await eventRecorder.record({
151
+ pluginName: primary.packageJson.name,
152
+ action: "install",
153
+ phase: "validate",
154
+ status: "succeeded",
155
+ source,
156
+ userId,
157
+ });
158
+
159
+ // 2. Persist artifacts + plugins rows. One bundle = one bundleId.
160
+ // Single-package installs use `null` (drizzle writes a real NULL column),
161
+ // not `undefined` (which would skip the column on insert).
162
+ const bundleId: string | null =
163
+ packages.length > 1 ? randomUUID() : null;
164
+ await eventRecorder.record({
165
+ pluginName: primary.packageJson.name,
166
+ bundleId,
167
+ action: "install",
168
+ phase: "persist",
169
+ status: "started",
170
+ source,
171
+ userId,
172
+ });
173
+
174
+ try {
175
+ for (const pkg of packages) {
176
+ await artifactStore.store({
177
+ pluginName: pkg.packageJson.name,
178
+ version: pkg.packageJson.version,
179
+ bundleId,
180
+ tarball: pkg.tarball,
181
+ });
182
+
183
+ // Insert / update plugins row. We do NOT install into node_modules
184
+ // here on the originator — that happens uniformly on every instance
185
+ // (originator included) when the install broadcast is handled.
186
+ const isPrimary = pkg.packageJson.name === primary.packageJson.name;
187
+ const expectedPath = path.join(
188
+ runtimeDir,
189
+ "node_modules",
190
+ pkg.packageJson.name,
191
+ );
192
+ await db
193
+ .insert(plugins)
194
+ .values({
195
+ name: pkg.packageJson.name,
196
+ path: expectedPath,
197
+ enabled: true,
198
+ isUninstallable: true,
199
+ type: pkg.packageJson.checkstack.type,
200
+ version: pkg.packageJson.version,
201
+ metadata: pkg.packageJson,
202
+ source,
203
+ bundleId,
204
+ isPrimary,
205
+ })
206
+ .onConflictDoUpdate({
207
+ target: [plugins.name],
208
+ set: {
209
+ path: expectedPath,
210
+ enabled: true,
211
+ isUninstallable: true,
212
+ type: pkg.packageJson.checkstack.type,
213
+ version: pkg.packageJson.version,
214
+ metadata: pkg.packageJson,
215
+ source,
216
+ bundleId,
217
+ isPrimary,
218
+ },
219
+ });
220
+ }
221
+ await eventRecorder.record({
222
+ pluginName: primary.packageJson.name,
223
+ bundleId,
224
+ action: "install",
225
+ phase: "persist",
226
+ status: "succeeded",
227
+ source,
228
+ userId,
229
+ });
230
+ } catch (error) {
231
+ await eventRecorder.record({
232
+ pluginName: primary.packageJson.name,
233
+ bundleId,
234
+ action: "install",
235
+ phase: "persist",
236
+ status: "failed",
237
+ source,
238
+ error: extractErrorMessage(error),
239
+ userId,
240
+ });
241
+ throw error;
242
+ }
243
+
244
+ // 3. Broadcast — every instance (incl. originator) hydrates from
245
+ // plugin_artifacts and loads the module. Backend packages first so
246
+ // frontend siblings have access to backend services on init.
247
+ const sortedPackageNames = packages
248
+ .toSorted(
249
+ (a, b) =>
250
+ orderForType(a.packageJson.checkstack.type) -
251
+ orderForType(b.packageJson.checkstack.type),
252
+ )
253
+ .map((p) => p.packageJson.name);
254
+
255
+ await eventRecorder.record({
256
+ pluginName: primary.packageJson.name,
257
+ bundleId,
258
+ action: "install",
259
+ phase: "broadcast",
260
+ status: "started",
261
+ source,
262
+ userId,
263
+ });
264
+ try {
265
+ await pluginManager.broadcastInstallation(sortedPackageNames);
266
+ await eventRecorder.record({
267
+ pluginName: primary.packageJson.name,
268
+ bundleId,
269
+ action: "install",
270
+ phase: "broadcast",
271
+ status: "succeeded",
272
+ source,
273
+ userId,
274
+ });
275
+ } catch (error) {
276
+ await eventRecorder.record({
277
+ pluginName: primary.packageJson.name,
278
+ bundleId,
279
+ action: "install",
280
+ phase: "broadcast",
281
+ status: "failed",
282
+ source,
283
+ error: extractErrorMessage(error),
284
+ userId,
285
+ });
286
+ throw error;
287
+ }
288
+
289
+ return {
290
+ bundleId,
291
+ installedPackages: sortedPackageNames,
292
+ };
293
+ }
294
+
295
+ export async function previewUninstallOriginator({
296
+ pluginName,
297
+ db,
298
+ }: {
299
+ pluginName: string;
300
+ db: SafeDatabase<Record<string, unknown>>;
301
+ }): Promise<UninstallPreview> {
302
+ const rows = await db
303
+ .select()
304
+ .from(plugins)
305
+ .where(eq(plugins.name, pluginName))
306
+ .limit(1);
307
+ if (rows.length === 0) {
308
+ throw new PluginInstallError(
309
+ "NOT_FOUND",
310
+ `Plugin '${pluginName}' is not installed.`,
311
+ );
312
+ }
313
+ const target = rows[0];
314
+ if (!target.isUninstallable) {
315
+ throw new PluginInstallError(
316
+ "FORBIDDEN",
317
+ `Plugin '${pluginName}' is part of the platform core and cannot be uninstalled.`,
318
+ );
319
+ }
320
+
321
+ const siblings: string[] = [];
322
+ if (target.bundleId) {
323
+ const sibRows = await db
324
+ .select({ name: plugins.name })
325
+ .from(plugins)
326
+ .where(eq(plugins.bundleId, target.bundleId));
327
+ for (const r of sibRows) siblings.push(r.name);
328
+ } else {
329
+ siblings.push(target.name);
330
+ }
331
+
332
+ // Collect the set of plugin ids whose schemas would drop.
333
+ const schemasToDrop = siblings.map((s) => `plugin_${s}`);
334
+
335
+ // Plugin configs row count for siblings.
336
+ const allConfigsRows = await db
337
+ .select({ count: sql<string>`count(*)` })
338
+ .from(pluginConfigs)
339
+ .where(inArray(pluginConfigs.pluginId, siblings));
340
+ const pluginConfigCount = Number(allConfigsRows[0]?.count ?? 0);
341
+
342
+ // Dependents — other (loaded) runtime plugins whose @checkstack/* deps
343
+ // include any of the siblings.
344
+ const dependents: string[] = [];
345
+ const otherRows = await db
346
+ .select()
347
+ .from(plugins)
348
+ .where(and(eq(plugins.isUninstallable, true)));
349
+ for (const row of otherRows) {
350
+ if (siblings.includes(row.name)) continue;
351
+ const deps =
352
+ (row.metadata as { dependencies?: Record<string, string> })?.dependencies ??
353
+ {};
354
+ if (Object.keys(deps).some((d) => siblings.includes(d))) {
355
+ dependents.push(row.name);
356
+ }
357
+ }
358
+
359
+ return uninstallPreviewSchema.parse({
360
+ pluginName: target.name,
361
+ siblings,
362
+ dependents,
363
+ schemasToDrop,
364
+ pluginConfigCount,
365
+ } satisfies UninstallPreview);
366
+ }
367
+
368
+ export async function uninstallOriginator({
369
+ pluginName,
370
+ deleteSchema,
371
+ deleteConfigs,
372
+ cascade,
373
+ pluginManager,
374
+ db,
375
+ eventRecorder,
376
+ userId,
377
+ }: {
378
+ pluginName: string;
379
+ deleteSchema: boolean;
380
+ deleteConfigs: boolean;
381
+ cascade: boolean;
382
+ pluginManager: PluginManager;
383
+ db: SafeDatabase<Record<string, unknown>>;
384
+ eventRecorder: PluginEventRecorder;
385
+ userId?: string;
386
+ }): Promise<{ uninstalledPackages: string[] }> {
387
+ const target = await db
388
+ .select()
389
+ .from(plugins)
390
+ .where(eq(plugins.name, pluginName))
391
+ .limit(1)
392
+ .then((rows) => rows[0]);
393
+ if (!target) {
394
+ throw new PluginInstallError(
395
+ "NOT_FOUND",
396
+ `Plugin '${pluginName}' is not installed.`,
397
+ );
398
+ }
399
+ if (!target.isUninstallable) {
400
+ throw new PluginInstallError(
401
+ "FORBIDDEN",
402
+ `Plugin '${pluginName}' is part of the platform core and cannot be uninstalled.`,
403
+ );
404
+ }
405
+
406
+ const ids: string[] = [];
407
+ if (target.bundleId) {
408
+ const sibRows = await db
409
+ .select({ name: plugins.name })
410
+ .from(plugins)
411
+ .where(eq(plugins.bundleId, target.bundleId));
412
+ for (const r of sibRows) ids.push(r.name);
413
+ } else {
414
+ ids.push(target.name);
415
+ }
416
+
417
+ if (cascade) {
418
+ // Find all dependents recursively via metadata.dependencies.
419
+ const queue = [...ids];
420
+ const seen = new Set(ids);
421
+ while (queue.length > 0) {
422
+ const current = queue.shift()!;
423
+ const candidates = await db
424
+ .select()
425
+ .from(plugins)
426
+ .where(and(eq(plugins.isUninstallable, true)));
427
+ for (const row of candidates) {
428
+ if (seen.has(row.name)) continue;
429
+ const deps =
430
+ (row.metadata as { dependencies?: Record<string, string> })
431
+ ?.dependencies ?? {};
432
+ if (Object.keys(deps).includes(current)) {
433
+ ids.push(row.name);
434
+ seen.add(row.name);
435
+ queue.push(row.name);
436
+ }
437
+ }
438
+ }
439
+ }
440
+
441
+ // 1. Broadcast in-process unload to all instances (incl. originator).
442
+ await eventRecorder.record({
443
+ pluginName,
444
+ bundleId: target.bundleId ?? undefined,
445
+ action: "uninstall",
446
+ phase: "broadcast",
447
+ status: "started",
448
+ userId,
449
+ });
450
+ try {
451
+ await pluginManager.broadcastDeregistration(ids);
452
+ await eventRecorder.record({
453
+ pluginName,
454
+ bundleId: target.bundleId ?? undefined,
455
+ action: "uninstall",
456
+ phase: "broadcast",
457
+ status: "succeeded",
458
+ userId,
459
+ });
460
+ } catch (error) {
461
+ await eventRecorder.record({
462
+ pluginName,
463
+ bundleId: target.bundleId ?? undefined,
464
+ action: "uninstall",
465
+ phase: "broadcast",
466
+ status: "failed",
467
+ error: extractErrorMessage(error),
468
+ userId,
469
+ });
470
+ throw error;
471
+ }
472
+
473
+ // 2. Originator-only destructive cleanup.
474
+ await eventRecorder.record({
475
+ pluginName,
476
+ bundleId: target.bundleId ?? undefined,
477
+ action: "uninstall",
478
+ phase: "destructive-cleanup",
479
+ status: "started",
480
+ userId,
481
+ });
482
+ try {
483
+ await pluginManager.deletePluginData({
484
+ pluginIds: ids,
485
+ bundleId: target.bundleId ?? undefined,
486
+ deleteSchema,
487
+ deleteConfigs,
488
+ });
489
+ await eventRecorder.record({
490
+ pluginName,
491
+ bundleId: target.bundleId ?? undefined,
492
+ action: "uninstall",
493
+ phase: "destructive-cleanup",
494
+ status: "succeeded",
495
+ userId,
496
+ });
497
+ } catch (error) {
498
+ await eventRecorder.record({
499
+ pluginName,
500
+ bundleId: target.bundleId ?? undefined,
501
+ action: "uninstall",
502
+ phase: "destructive-cleanup",
503
+ status: "failed",
504
+ error: extractErrorMessage(error),
505
+ userId,
506
+ });
507
+ throw error;
508
+ }
509
+
510
+ return { uninstalledPackages: ids };
511
+ }
512
+
513
+ function orderForType(t: "backend" | "frontend" | "common"): number {
514
+ switch (t) {
515
+ case "common": { return 0;
516
+ }
517
+ case "backend": { return 1;
518
+ }
519
+ case "frontend": { return 2;
520
+ }
521
+ }
522
+ }