@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,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
|
+
});
|