@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.
Files changed (46) hide show
  1. package/CHANGELOG.md +225 -0
  2. package/drizzle/0000_loose_yellow_claw.sql +28 -0
  3. package/drizzle/meta/0000_snapshot.json +187 -0
  4. package/drizzle/meta/_journal.json +13 -0
  5. package/drizzle.config.ts +10 -0
  6. package/package.json +42 -0
  7. package/src/db.ts +20 -0
  8. package/src/health-check-plugin-integration.test.ts +93 -0
  9. package/src/index.ts +419 -0
  10. package/src/integration/event-bus.integration.test.ts +313 -0
  11. package/src/logger.ts +65 -0
  12. package/src/openapi-router.ts +177 -0
  13. package/src/plugin-lifecycle.test.ts +276 -0
  14. package/src/plugin-manager/api-router.ts +163 -0
  15. package/src/plugin-manager/core-services.ts +312 -0
  16. package/src/plugin-manager/dependency-sorter.ts +103 -0
  17. package/src/plugin-manager/deregistration-guard.ts +41 -0
  18. package/src/plugin-manager/extension-points.ts +85 -0
  19. package/src/plugin-manager/index.ts +13 -0
  20. package/src/plugin-manager/plugin-admin-router.ts +89 -0
  21. package/src/plugin-manager/plugin-loader.ts +464 -0
  22. package/src/plugin-manager/types.ts +14 -0
  23. package/src/plugin-manager.test.ts +464 -0
  24. package/src/plugin-manager.ts +431 -0
  25. package/src/rpc-rest-compat.test.ts +80 -0
  26. package/src/schema.ts +46 -0
  27. package/src/services/config-service.test.ts +66 -0
  28. package/src/services/config-service.ts +322 -0
  29. package/src/services/event-bus.test.ts +469 -0
  30. package/src/services/event-bus.ts +317 -0
  31. package/src/services/health-check-registry.test.ts +101 -0
  32. package/src/services/health-check-registry.ts +27 -0
  33. package/src/services/jwt.ts +45 -0
  34. package/src/services/keystore.test.ts +198 -0
  35. package/src/services/keystore.ts +136 -0
  36. package/src/services/plugin-installer.test.ts +90 -0
  37. package/src/services/plugin-installer.ts +70 -0
  38. package/src/services/queue-manager.ts +382 -0
  39. package/src/services/queue-plugin-registry.ts +17 -0
  40. package/src/services/queue-proxy.ts +182 -0
  41. package/src/services/service-registry.ts +35 -0
  42. package/src/test-preload.ts +114 -0
  43. package/src/utils/plugin-discovery.test.ts +383 -0
  44. package/src/utils/plugin-discovery.ts +157 -0
  45. package/src/utils/strip-public-schema.ts +40 -0
  46. package/tsconfig.json +6 -0
@@ -0,0 +1,182 @@
1
+ import type {
2
+ Queue,
3
+ QueueConsumer,
4
+ ConsumeOptions,
5
+ QueueStats,
6
+ RecurringJobDetails,
7
+ } from "@checkstack/queue-api";
8
+ import { rootLogger } from "../logger";
9
+
10
+ /**
11
+ * Stored subscription for replay after backend switch
12
+ */
13
+ interface StoredSubscription<T> {
14
+ consumer: QueueConsumer<T>;
15
+ options: ConsumeOptions;
16
+ }
17
+
18
+ /**
19
+ * QueueProxy wraps a real queue implementation and provides:
20
+ * - Stable reference that survives backend switches
21
+ * - Automatic subscription replay when backend changes
22
+ * - Pending operation tracking for graceful switching
23
+ */
24
+ export class QueueProxy<T = unknown> implements Queue<T> {
25
+ private delegate: Queue<T> | undefined = undefined;
26
+ private subscriptions = new Map<string, StoredSubscription<T>>();
27
+ private pendingOperations: Promise<unknown>[] = [];
28
+ private stopped = false;
29
+
30
+ constructor(private readonly name: string) {}
31
+
32
+ /**
33
+ * Switch the underlying queue implementation.
34
+ * Called by QueueManager when backend changes.
35
+ */
36
+ async switchDelegate(newQueue: Queue<T>): Promise<void> {
37
+ rootLogger.debug(`Switching delegate for queue '${this.name}'`);
38
+
39
+ // Wait for pending operations to complete
40
+ if (this.pendingOperations.length > 0) {
41
+ rootLogger.debug(
42
+ `Waiting for ${this.pendingOperations.length} pending operations...`
43
+ );
44
+ await Promise.allSettled(this.pendingOperations);
45
+ }
46
+
47
+ // Stop old delegate gracefully
48
+ if (this.delegate) {
49
+ await this.delegate.stop();
50
+ }
51
+
52
+ // Switch to new implementation
53
+ this.delegate = newQueue;
54
+ this.stopped = false;
55
+
56
+ // Re-apply all stored subscriptions
57
+ for (const [group, { consumer, options }] of this.subscriptions) {
58
+ rootLogger.debug(
59
+ `Re-applying subscription for group '${group}' on queue '${this.name}'`
60
+ );
61
+ await this.delegate.consume(consumer, options);
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Get the underlying delegate for direct access (use sparingly)
67
+ */
68
+ getDelegate(): Queue<T> | undefined {
69
+ return this.delegate;
70
+ }
71
+
72
+ private ensureDelegate(): Queue<T> {
73
+ if (!this.delegate) {
74
+ throw new Error(
75
+ `Queue '${this.name}' not initialized. Ensure QueueManager.loadConfiguration() has been called.`
76
+ );
77
+ }
78
+ if (this.stopped) {
79
+ throw new Error(`Queue '${this.name}' has been stopped.`);
80
+ }
81
+ return this.delegate;
82
+ }
83
+
84
+ private trackOperation<R>(operation: Promise<R>): Promise<R> {
85
+ this.pendingOperations.push(operation);
86
+ return operation.finally(() => {
87
+ this.pendingOperations = this.pendingOperations.filter(
88
+ (p) => p !== operation
89
+ );
90
+ });
91
+ }
92
+
93
+ async enqueue(
94
+ data: T,
95
+ options?: {
96
+ priority?: number;
97
+ startDelay?: number;
98
+ jobId?: string;
99
+ }
100
+ ): Promise<string> {
101
+ const delegate = this.ensureDelegate();
102
+ return this.trackOperation(delegate.enqueue(data, options));
103
+ }
104
+
105
+ async consume(
106
+ consumer: QueueConsumer<T>,
107
+ options: ConsumeOptions
108
+ ): Promise<void> {
109
+ // Store subscription for replay after backend switch
110
+ this.subscriptions.set(options.consumerGroup, { consumer, options });
111
+
112
+ // If we have a delegate, apply immediately
113
+ if (this.delegate && !this.stopped) {
114
+ await this.delegate.consume(consumer, options);
115
+ }
116
+ }
117
+
118
+ async scheduleRecurring(
119
+ data: T,
120
+ options: {
121
+ jobId: string;
122
+ intervalSeconds: number;
123
+ startDelay?: number;
124
+ priority?: number;
125
+ }
126
+ ): Promise<string> {
127
+ const delegate = this.ensureDelegate();
128
+ return this.trackOperation(delegate.scheduleRecurring(data, options));
129
+ }
130
+
131
+ async cancelRecurring(jobId: string): Promise<void> {
132
+ const delegate = this.ensureDelegate();
133
+ return this.trackOperation(delegate.cancelRecurring(jobId));
134
+ }
135
+
136
+ async listRecurringJobs(): Promise<string[]> {
137
+ const delegate = this.ensureDelegate();
138
+ return delegate.listRecurringJobs();
139
+ }
140
+
141
+ async getRecurringJobDetails(
142
+ jobId: string
143
+ ): Promise<RecurringJobDetails<T> | undefined> {
144
+ const delegate = this.ensureDelegate();
145
+ return delegate.getRecurringJobDetails(jobId);
146
+ }
147
+
148
+ async getInFlightCount(): Promise<number> {
149
+ const delegate = this.ensureDelegate();
150
+ return delegate.getInFlightCount();
151
+ }
152
+
153
+ async testConnection(): Promise<void> {
154
+ const delegate = this.ensureDelegate();
155
+ return delegate.testConnection();
156
+ }
157
+
158
+ async stop(): Promise<void> {
159
+ this.stopped = true;
160
+
161
+ // Wait for pending operations
162
+ if (this.pendingOperations.length > 0) {
163
+ await Promise.allSettled(this.pendingOperations);
164
+ }
165
+
166
+ if (this.delegate) {
167
+ await this.delegate.stop();
168
+ }
169
+ }
170
+
171
+ async getStats(): Promise<QueueStats> {
172
+ const delegate = this.ensureDelegate();
173
+ return delegate.getStats();
174
+ }
175
+
176
+ /**
177
+ * Get subscription count (for testing/debugging)
178
+ */
179
+ getSubscriptionCount(): number {
180
+ return this.subscriptions.size;
181
+ }
182
+ }
@@ -0,0 +1,35 @@
1
+ import { ServiceRef } from "@checkstack/backend-api";
2
+ import type { PluginMetadata } from "@checkstack/common";
3
+
4
+ type ServiceFactory<T> = (metadata: PluginMetadata) => T | Promise<T>;
5
+
6
+ export class ServiceRegistry {
7
+ private services = new Map<string, unknown>();
8
+ private factories = new Map<string, ServiceFactory<unknown>>();
9
+
10
+ register<T>(ref: ServiceRef<T>, impl: T) {
11
+ this.services.set(ref.id, impl);
12
+ }
13
+
14
+ registerFactory<T>(ref: ServiceRef<T>, factory: ServiceFactory<T>) {
15
+ this.factories.set(ref.id, factory as ServiceFactory<unknown>);
16
+ }
17
+
18
+ async get<T>(ref: ServiceRef<T>, metadata: PluginMetadata): Promise<T> {
19
+ // 1. Try Factory (Scoped)
20
+ const factory = this.factories.get(ref.id);
21
+ if (factory) {
22
+ return (await factory(metadata)) as T;
23
+ }
24
+
25
+ // 2. Try Global Service
26
+ const service = this.services.get(ref.id);
27
+ if (service) {
28
+ return service as T;
29
+ }
30
+
31
+ throw new Error(
32
+ `Service '${ref.id}' not found for plugin '${metadata.pluginId}'`
33
+ );
34
+ }
35
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Test Preload File
3
+ *
4
+ * This file is loaded BEFORE any test file runs (via bunfig.toml).
5
+ * It ensures mock.module() is called before mocked modules are imported.
6
+ */
7
+
8
+ import { mock } from "bun:test";
9
+ import {
10
+ createMockDbModule,
11
+ createMockLoggerModule,
12
+ createMockLogger,
13
+ } from "@checkstack/test-utils-backend";
14
+ import { coreServices } from "@checkstack/backend-api";
15
+ import path from "node:path";
16
+
17
+ // Get absolute paths to the modules we need to mock
18
+ const backendSrcDir = path.join(__dirname);
19
+ const dbPath = path.join(backendSrcDir, "db");
20
+ const loggerPath = path.join(backendSrcDir, "logger");
21
+ const coreServicesPath = path.join(
22
+ backendSrcDir,
23
+ "plugin-manager",
24
+ "core-services"
25
+ );
26
+
27
+ // Mock database module with absolute path
28
+ mock.module(dbPath, () => createMockDbModule());
29
+
30
+ // Mock logger module with absolute path
31
+ mock.module(loggerPath, () => createMockLoggerModule());
32
+
33
+ /**
34
+ * Mock core-services to register mock factories that DON'T access DATABASE_URL.
35
+ *
36
+ * The real issue: core-services.ts line 79 directly accesses process.env.DATABASE_URL
37
+ * inside the database factory function. This happens at RUNTIME when the factory
38
+ * is called, not at import time. Module mocking can't prevent it.
39
+ *
40
+ * Solution: Provide a mock version of registerCoreServices that registers
41
+ * test-safe factories.
42
+ */
43
+ mock.module(coreServicesPath, () => ({
44
+ registerCoreServices: ({
45
+ registry,
46
+ }: {
47
+ registry: {
48
+ registerFactory: (ref: { id: string }, factory: unknown) => void;
49
+ register: (ref: { id: string }, impl: unknown) => void;
50
+ };
51
+ }) => {
52
+ // Register mock database factory - returns empty object, no DATABASE_URL check
53
+ registry.registerFactory(coreServices.database, () => ({}));
54
+
55
+ // Register mock logger factory
56
+ registry.registerFactory(coreServices.logger, () => createMockLogger());
57
+
58
+ // Register mock auth factory
59
+ registry.registerFactory(coreServices.auth, () => ({
60
+ authenticate: async () => {},
61
+ getCredentials: async () => ({ headers: {} }),
62
+ getAnonymousPermissions: async () => [],
63
+ }));
64
+
65
+ // Register mock fetch factory
66
+ registry.registerFactory(coreServices.fetch, () => ({
67
+ fetch: async () => new Response(),
68
+ forPlugin: () => ({
69
+ fetch: async () => new Response(),
70
+ get: async () => new Response(),
71
+ post: async () => new Response(),
72
+ put: async () => new Response(),
73
+ patch: async () => new Response(),
74
+ delete: async () => new Response(),
75
+ }),
76
+ }));
77
+
78
+ // Register mock RPC client factory
79
+ registry.registerFactory(coreServices.rpcClient, () => ({
80
+ forPlugin: () => ({}),
81
+ }));
82
+
83
+ // Register mock health check registry (singleton) - with actual storage
84
+ const strategies = new Map<string, unknown>();
85
+ registry.registerFactory(coreServices.healthCheckRegistry, () => ({
86
+ register: (strategy: { id: string }) => {
87
+ strategies.set(strategy.id, strategy);
88
+ },
89
+ getStrategy: (id: string) => strategies.get(id),
90
+ getAllStrategies: () => [...strategies.values()],
91
+ }));
92
+
93
+ // Register mock RPC service factory
94
+ registry.registerFactory(coreServices.rpc, () => ({
95
+ registerRouter: () => {},
96
+ registerHttpHandler: () => {},
97
+ }));
98
+
99
+ // Register mock config factory
100
+ registry.registerFactory(coreServices.config, () => ({
101
+ get: async () => {},
102
+ set: async () => {},
103
+ delete: async () => {},
104
+ }));
105
+
106
+ // Register mock EventBus factory
107
+ registry.registerFactory(coreServices.eventBus, () => ({
108
+ emit: async () => {},
109
+ emitLocal: async () => {},
110
+ // eslint-disable-next-line unicorn/consistent-function-scoping
111
+ subscribe: async () => () => {},
112
+ }));
113
+ },
114
+ }));
@@ -0,0 +1,383 @@
1
+ import { describe, it, expect, beforeEach, mock } from "bun:test";
2
+ import {
3
+ extractPluginMetadata,
4
+ discoverLocalPlugins,
5
+ syncPluginsToDatabase,
6
+ type PluginMetadata,
7
+ } from "./plugin-discovery";
8
+ import fs from "node:fs";
9
+ import path from "node:path";
10
+
11
+ // Mock filesystem for testing
12
+ const mockExistsSync = mock(() => true);
13
+ const mockReadFileSync = mock(() => "{}");
14
+ const mockReaddirSync = mock(() => []);
15
+
16
+ mock.module("node:fs", () => {
17
+ const exports = {
18
+ existsSync: mockExistsSync,
19
+ readFileSync: mockReadFileSync,
20
+ readdirSync: mockReaddirSync,
21
+ };
22
+ return {
23
+ ...exports,
24
+ default: exports,
25
+ };
26
+ });
27
+
28
+ describe("extractPluginMetadata", () => {
29
+ beforeEach(() => {
30
+ mockExistsSync.mockClear();
31
+ mockReadFileSync.mockClear();
32
+ mockExistsSync.mockReturnValue(true);
33
+ });
34
+
35
+ it("should extract metadata from valid backend plugin", () => {
36
+ mockReadFileSync.mockReturnValue(
37
+ JSON.stringify({
38
+ name: "@checkstack/test-backend",
39
+ version: "0.0.1",
40
+ type: "module",
41
+ })
42
+ );
43
+
44
+ const result = extractPluginMetadata({
45
+ pluginDir: "/fake/path/test-backend",
46
+ });
47
+
48
+ expect(result).toEqual({
49
+ packageName: "@checkstack/test-backend",
50
+ pluginPath: "/fake/path/test-backend",
51
+ type: "backend",
52
+ enabled: true,
53
+ });
54
+ });
55
+
56
+ it("should extract metadata from valid frontend plugin", () => {
57
+ mockReadFileSync.mockReturnValue(
58
+ JSON.stringify({
59
+ name: "@checkstack/test-frontend",
60
+ })
61
+ );
62
+
63
+ const result = extractPluginMetadata({
64
+ pluginDir: "/fake/path/test-frontend",
65
+ });
66
+
67
+ expect(result?.type).toBe("frontend");
68
+ });
69
+
70
+ it("should extract metadata from valid common plugin", () => {
71
+ mockReadFileSync.mockReturnValue(
72
+ JSON.stringify({
73
+ name: "@checkstack/test-common",
74
+ })
75
+ );
76
+
77
+ const result = extractPluginMetadata({
78
+ pluginDir: "/fake/path/test-common",
79
+ });
80
+
81
+ expect(result?.type).toBe("common");
82
+ });
83
+
84
+ it("should return undefined if package.json is missing", () => {
85
+ mockExistsSync.mockReturnValue(false);
86
+
87
+ const result = extractPluginMetadata({
88
+ pluginDir: "/fake/path/invalid",
89
+ });
90
+
91
+ expect(result).toBeUndefined();
92
+ });
93
+
94
+ it("should return undefined if package.json has no name field", () => {
95
+ mockReadFileSync.mockReturnValue(
96
+ JSON.stringify({
97
+ version: "0.0.1",
98
+ })
99
+ );
100
+
101
+ const result = extractPluginMetadata({
102
+ pluginDir: "/fake/path/invalid",
103
+ });
104
+
105
+ expect(result).toBeUndefined();
106
+ });
107
+
108
+ it("should return undefined for non-plugin core (wrong suffix)", () => {
109
+ mockReadFileSync.mockReturnValue(
110
+ JSON.stringify({
111
+ name: "@checkstack/not-a-plugin",
112
+ })
113
+ );
114
+
115
+ const result = extractPluginMetadata({
116
+ pluginDir: "/fake/path/not-a-plugin",
117
+ });
118
+
119
+ expect(result).toBeUndefined();
120
+ });
121
+
122
+ it("should return undefined if package.json is malformed", () => {
123
+ mockReadFileSync.mockReturnValue("invalid json{");
124
+
125
+ const result = extractPluginMetadata({
126
+ pluginDir: "/fake/path/invalid",
127
+ });
128
+
129
+ expect(result).toBeUndefined();
130
+ });
131
+ });
132
+
133
+ describe("discoverLocalPlugins", () => {
134
+ beforeEach(() => {
135
+ mockExistsSync.mockClear();
136
+ mockReadFileSync.mockClear();
137
+ mockReaddirSync.mockClear();
138
+ mockExistsSync.mockReturnValue(true);
139
+ });
140
+
141
+ it("should discover all valid backend plugins from both core/ and plugins/", () => {
142
+ // Mock different contents for core/ and plugins/ directories
143
+ mockReaddirSync.mockImplementation(((dirPath: string) => {
144
+ if (dirPath.includes("core")) {
145
+ return [
146
+ { isDirectory: () => true, name: "auth-backend" },
147
+ { isDirectory: () => false, name: "README.md" }, // File, should skip
148
+ ];
149
+ }
150
+ if (dirPath.includes("plugins")) {
151
+ return [
152
+ { isDirectory: () => true, name: "catalog-backend" },
153
+ { isDirectory: () => true, name: "invalid-plugin" }, // No -backend suffix
154
+ ];
155
+ }
156
+ return [];
157
+ }) as typeof mockReaddirSync);
158
+
159
+ // Mock package.json reads
160
+ mockReadFileSync.mockImplementation(((filePath: string) => {
161
+ if (filePath.includes("auth-backend")) {
162
+ return JSON.stringify({ name: "@checkstack/auth-backend" });
163
+ }
164
+ if (filePath.includes("catalog-backend")) {
165
+ return JSON.stringify({ name: "@checkstack/catalog-backend" });
166
+ }
167
+ if (filePath.includes("invalid-plugin")) {
168
+ return JSON.stringify({ name: "@checkstack/invalid-plugin" });
169
+ }
170
+ return "{}";
171
+ }) as typeof mockReadFileSync);
172
+
173
+ const result = discoverLocalPlugins({ workspaceRoot: "/fake/workspace" });
174
+
175
+ expect(result).toHaveLength(2);
176
+ expect(result.map((r) => r.packageName)).toContain(
177
+ "@checkstack/auth-backend"
178
+ );
179
+ expect(result.map((r) => r.packageName)).toContain(
180
+ "@checkstack/catalog-backend"
181
+ );
182
+ });
183
+
184
+ it("should filter plugins by type when type parameter is provided", () => {
185
+ // Mock: core/ has backend and frontend, plugins/ has common
186
+ mockReaddirSync.mockImplementation(((dirPath: string) => {
187
+ if (dirPath.includes("core")) {
188
+ return [
189
+ { isDirectory: () => true, name: "auth-backend" },
190
+ { isDirectory: () => true, name: "auth-frontend" },
191
+ ];
192
+ }
193
+ if (dirPath.includes("plugins")) {
194
+ return [{ isDirectory: () => true, name: "auth-common" }];
195
+ }
196
+ return [];
197
+ }) as typeof mockReaddirSync);
198
+
199
+ mockReadFileSync.mockImplementation(((filePath: string) => {
200
+ if (filePath.includes("auth-backend")) {
201
+ return JSON.stringify({ name: "@checkstack/auth-backend" });
202
+ }
203
+ if (filePath.includes("auth-frontend")) {
204
+ return JSON.stringify({ name: "@checkstack/auth-frontend" });
205
+ }
206
+ if (filePath.includes("auth-common")) {
207
+ return JSON.stringify({ name: "@checkstack/auth-common" });
208
+ }
209
+ return "{}";
210
+ }) as typeof mockReadFileSync);
211
+
212
+ const backendResult = discoverLocalPlugins({
213
+ workspaceRoot: "/fake/workspace",
214
+ type: "backend",
215
+ });
216
+ expect(backendResult).toHaveLength(1);
217
+ expect(backendResult[0].type).toBe("backend");
218
+
219
+ const frontendResult = discoverLocalPlugins({
220
+ workspaceRoot: "/fake/workspace",
221
+ type: "frontend",
222
+ });
223
+ expect(frontendResult).toHaveLength(1);
224
+ expect(frontendResult[0].type).toBe("frontend");
225
+ });
226
+
227
+ it("should return empty array if neither core/ nor plugins/ directory exists", () => {
228
+ mockExistsSync.mockReturnValue(false);
229
+
230
+ const result = discoverLocalPlugins({ workspaceRoot: "/fake/workspace" });
231
+
232
+ expect(result).toEqual([]);
233
+ });
234
+
235
+ it("should skip directories without valid package.json", () => {
236
+ mockReaddirSync.mockImplementation(((dirPath: string) => {
237
+ if (dirPath.includes("core")) {
238
+ return [{ isDirectory: () => true, name: "broken-backend" }];
239
+ }
240
+ return [];
241
+ }) as typeof mockReaddirSync);
242
+
243
+ mockExistsSync.mockImplementation(((filePath: string) => {
244
+ // core/ and plugins/ directories exist
245
+ if (filePath.endsWith("core") || filePath.endsWith("plugins")) {
246
+ return true;
247
+ }
248
+ // package.json doesn't exist for broken-backend
249
+ return !filePath.includes("broken-backend");
250
+ }) as typeof mockExistsSync);
251
+
252
+ const result = discoverLocalPlugins({ workspaceRoot: "/fake/workspace" });
253
+
254
+ expect(result).toEqual([]);
255
+ });
256
+ });
257
+
258
+ describe("syncPluginsToDatabase", () => {
259
+ let mockDb: any;
260
+
261
+ beforeEach(() => {
262
+ mockDb = {
263
+ select: mock(() => ({
264
+ from: mock(() => ({
265
+ where: mock(() => ({
266
+ limit: mock(() => Promise.resolve([])),
267
+ })),
268
+ })),
269
+ })),
270
+ insert: mock(() => ({
271
+ values: mock(() => Promise.resolve()),
272
+ })),
273
+ update: mock(() => ({
274
+ set: mock(() => ({
275
+ where: mock(() => Promise.resolve()),
276
+ })),
277
+ })),
278
+ };
279
+ });
280
+
281
+ it("should insert new plugin that doesn't exist in database", async () => {
282
+ const localPlugins: PluginMetadata[] = [
283
+ {
284
+ packageName: "@checkstack/new-backend",
285
+ pluginPath: "/workspace/plugins/new-backend",
286
+ type: "backend",
287
+ enabled: true,
288
+ },
289
+ ];
290
+
291
+ // Mock: plugin doesn't exist
292
+ mockDb.select.mockReturnValue({
293
+ from: mock(() => ({
294
+ where: mock(() => ({
295
+ limit: mock(() => Promise.resolve([])),
296
+ })),
297
+ })),
298
+ });
299
+
300
+ await syncPluginsToDatabase({ localPlugins, db: mockDb });
301
+
302
+ expect(mockDb.insert).toHaveBeenCalled();
303
+ const insertCall = mockDb.insert.mock.results[0].value;
304
+ expect(insertCall.values).toHaveBeenCalledWith({
305
+ name: "@checkstack/new-backend",
306
+ path: "/workspace/plugins/new-backend",
307
+ type: "backend",
308
+ enabled: true,
309
+ isUninstallable: false,
310
+ });
311
+ });
312
+
313
+ it("should update path for renamed local plugin", async () => {
314
+ const localPlugins: PluginMetadata[] = [
315
+ {
316
+ packageName: "@checkstack/renamed-backend",
317
+ pluginPath: "/workspace/plugins/new-location",
318
+ type: "backend",
319
+ enabled: true,
320
+ },
321
+ ];
322
+
323
+ // Mock: plugin exists as local plugin (isUninstallable=false)
324
+ mockDb.select.mockReturnValue({
325
+ from: mock(() => ({
326
+ where: mock(() => ({
327
+ limit: mock(() =>
328
+ Promise.resolve([
329
+ {
330
+ name: "@checkstack/renamed-backend",
331
+ path: "/workspace/plugins/old-location",
332
+ isUninstallable: false,
333
+ },
334
+ ])
335
+ ),
336
+ })),
337
+ })),
338
+ });
339
+
340
+ await syncPluginsToDatabase({ localPlugins, db: mockDb });
341
+
342
+ expect(mockDb.update).toHaveBeenCalled();
343
+ const updateCall = mockDb.update.mock.results[0].value;
344
+ expect(updateCall.set).toHaveBeenCalledWith({
345
+ path: "/workspace/plugins/new-location",
346
+ type: "backend",
347
+ });
348
+ });
349
+
350
+ it("should not modify remotely installed plugins", async () => {
351
+ const localPlugins: PluginMetadata[] = [
352
+ {
353
+ packageName: "@checkstack/remote-backend",
354
+ pluginPath: "/workspace/plugins/remote-backend",
355
+ type: "backend",
356
+ enabled: true,
357
+ },
358
+ ];
359
+
360
+ // Mock: plugin exists as remote plugin (isUninstallable=true)
361
+ mockDb.select.mockReturnValue({
362
+ from: mock(() => ({
363
+ where: mock(() => ({
364
+ limit: mock(() =>
365
+ Promise.resolve([
366
+ {
367
+ name: "@checkstack/remote-backend",
368
+ path: "/runtime/node_modules/@checkstack/remote-backend",
369
+ isUninstallable: true,
370
+ },
371
+ ])
372
+ ),
373
+ })),
374
+ })),
375
+ });
376
+
377
+ await syncPluginsToDatabase({ localPlugins, db: mockDb });
378
+
379
+ // Should not call insert or update
380
+ expect(mockDb.insert).not.toHaveBeenCalled();
381
+ expect(mockDb.update).not.toHaveBeenCalled();
382
+ });
383
+ });