@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,464 @@
1
+ import { describe, it, expect, mock, beforeEach } from "bun:test";
2
+ import {
3
+ createServiceRef,
4
+ createExtensionPoint,
5
+ createBackendPlugin,
6
+ ServiceRef,
7
+ coreServices,
8
+ } from "@checkstack/backend-api";
9
+ import {
10
+ createMockLogger,
11
+ createMockQueueManager,
12
+ } from "@checkstack/test-utils-backend";
13
+ import { sortPlugins } from "./plugin-manager/dependency-sorter";
14
+
15
+ // Note: ./db and ./logger are mocked via test-preload.ts (bunfig.toml preload)
16
+ // This ensures mocks are in place BEFORE any module imports them
17
+
18
+ import { PluginManager } from "./plugin-manager";
19
+
20
+ describe("PluginManager", () => {
21
+ let pluginManager: PluginManager;
22
+
23
+ beforeEach(() => {
24
+ pluginManager = new PluginManager();
25
+ });
26
+
27
+ describe("Service Registration", () => {
28
+ it("should register and retrieve a global service", async () => {
29
+ const testServiceRef = createServiceRef<{ foo: string }>("test.service");
30
+ const testImpl = { foo: "bar" };
31
+
32
+ pluginManager.registerService(testServiceRef, testImpl);
33
+ const retrieved = await pluginManager.getService(testServiceRef);
34
+
35
+ expect(retrieved).toBe(testImpl);
36
+ });
37
+
38
+ it("should return undefined for unregistered service", async () => {
39
+ const testServiceRef = createServiceRef<{ foo: string }>("test.service");
40
+ const retrieved = await pluginManager.getService(testServiceRef);
41
+
42
+ expect(retrieved).toBeUndefined();
43
+ });
44
+ });
45
+
46
+ describe("Extension Points", () => {
47
+ interface TestExtensionPoint {
48
+ addThing(name: string): void;
49
+ }
50
+
51
+ it("should allow registering and getting an extension point", () => {
52
+ const testEPRef = createExtensionPoint<TestExtensionPoint>("test.ep");
53
+ const mockImpl: TestExtensionPoint = {
54
+ addThing: mock(),
55
+ };
56
+
57
+ // In the real flow, a plugin calls registerExtensionPoint
58
+ // which sets the implementation on the proxy
59
+ // We can simulate this by mocking the environment passed to register
60
+ pluginManager["registerExtensionPoint"](testEPRef, mockImpl);
61
+
62
+ const ep = pluginManager.getExtensionPoint(testEPRef);
63
+ ep.addThing("hello");
64
+
65
+ expect(mockImpl.addThing).toHaveBeenCalledWith("hello");
66
+ });
67
+
68
+ it("should buffer calls to extension points before they are registered", () => {
69
+ const testEPRef = createExtensionPoint<TestExtensionPoint>("test.ep");
70
+
71
+ // 1. Get the proxy before implementation is registered
72
+ const ep = pluginManager.getExtensionPoint(testEPRef);
73
+
74
+ // 2. Call a method on the proxy
75
+ ep.addThing("buffered-call");
76
+
77
+ const mockImpl: TestExtensionPoint = {
78
+ addThing: mock(),
79
+ };
80
+
81
+ // 3. Register the implementation
82
+ pluginManager["registerExtensionPoint"](testEPRef, mockImpl);
83
+
84
+ // 4. Verify the buffered call was replayed
85
+ expect(mockImpl.addThing).toHaveBeenCalledWith("buffered-call");
86
+ });
87
+ });
88
+
89
+ describe("sortPlugins (Topological Sort)", () => {
90
+ it("should sort plugins based on their dependencies", () => {
91
+ const s1 = createServiceRef<unknown>("service-1");
92
+ const s2 = createServiceRef<unknown>("service-2");
93
+
94
+ const pendingInits = [
95
+ {
96
+ metadata: { pluginId: "consumer" },
97
+ deps: { d1: s1, d2: s2 } as Record<string, ServiceRef<unknown>>,
98
+ },
99
+ {
100
+ metadata: { pluginId: "provider-1" },
101
+ deps: {} as Record<string, ServiceRef<unknown>>,
102
+ },
103
+ {
104
+ metadata: { pluginId: "provider-2" },
105
+ deps: { d1: s1 } as Record<string, ServiceRef<unknown>>,
106
+ },
107
+ ];
108
+
109
+ const providedBy = new Map([
110
+ [s1.id, "provider-1"],
111
+ [s2.id, "provider-2"],
112
+ ]);
113
+
114
+ const sorted = sortPlugins({
115
+ pendingInits,
116
+ providedBy,
117
+ logger: createMockLogger(),
118
+ });
119
+
120
+ // provider-1 must come before consumer and provider-2
121
+ // provider-2 must come before consumer
122
+ expect(sorted.indexOf("provider-1")).toBeLessThan(
123
+ sorted.indexOf("consumer")
124
+ );
125
+ expect(sorted.indexOf("provider-1")).toBeLessThan(
126
+ sorted.indexOf("provider-2")
127
+ );
128
+ expect(sorted.indexOf("provider-2")).toBeLessThan(
129
+ sorted.indexOf("consumer")
130
+ );
131
+ });
132
+
133
+ it("should throw error on circular dependency", () => {
134
+ const s1 = createServiceRef<unknown>("service-1");
135
+ const s2 = createServiceRef<unknown>("service-2");
136
+
137
+ const pendingInits = [
138
+ { metadata: { pluginId: "p1" }, deps: { d: s2 } },
139
+ { metadata: { pluginId: "p2" }, deps: { d: s1 } },
140
+ ];
141
+
142
+ const providedBy = new Map([
143
+ [s1.id, "p1"],
144
+ [s2.id, "p2"],
145
+ ]);
146
+
147
+ expect(() =>
148
+ sortPlugins({ pendingInits, providedBy, logger: createMockLogger() })
149
+ ).toThrow("Circular dependency detected");
150
+ });
151
+
152
+ describe("Queue Plugin Ordering", () => {
153
+ it("should initialize queue plugin providers before queue consumers", () => {
154
+ const queueManagerRef = createServiceRef<unknown>(
155
+ coreServices.queueManager.id
156
+ );
157
+ const queueRegistryRef = createServiceRef<unknown>(
158
+ coreServices.queuePluginRegistry.id
159
+ );
160
+
161
+ const pendingInits = [
162
+ {
163
+ metadata: { pluginId: "queue-consumer" },
164
+ deps: { queueManager: queueManagerRef } as Record<
165
+ string,
166
+ ServiceRef<unknown>
167
+ >,
168
+ },
169
+ {
170
+ metadata: { pluginId: "queue-provider" },
171
+ deps: { queuePluginRegistry: queueRegistryRef } as Record<
172
+ string,
173
+ ServiceRef<unknown>
174
+ >,
175
+ },
176
+ ];
177
+
178
+ const providedBy = new Map<string, string>();
179
+ const sorted = sortPlugins({
180
+ pendingInits,
181
+ providedBy,
182
+ logger: createMockLogger(),
183
+ });
184
+
185
+ // Queue provider should come before queue consumer
186
+ expect(sorted.indexOf("queue-provider")).toBeLessThan(
187
+ sorted.indexOf("queue-consumer")
188
+ );
189
+ });
190
+
191
+ it("should handle multiple queue providers and consumers", () => {
192
+ const queueManagerRef = createServiceRef<unknown>(
193
+ coreServices.queueManager.id
194
+ );
195
+ const queueRegistryRef = createServiceRef<unknown>(
196
+ coreServices.queuePluginRegistry.id
197
+ );
198
+ const loggerRef = createServiceRef<unknown>("core.logger");
199
+
200
+ const pendingInits = [
201
+ {
202
+ metadata: { pluginId: "consumer-1" },
203
+ deps: { queueManager: queueManagerRef } as Record<
204
+ string,
205
+ ServiceRef<unknown>
206
+ >,
207
+ },
208
+ {
209
+ metadata: { pluginId: "provider-1" },
210
+ deps: { queuePluginRegistry: queueRegistryRef } as Record<
211
+ string,
212
+ ServiceRef<unknown>
213
+ >,
214
+ },
215
+ {
216
+ metadata: { pluginId: "consumer-2" },
217
+ deps: { queueManager: queueManagerRef } as Record<
218
+ string,
219
+ ServiceRef<unknown>
220
+ >,
221
+ },
222
+ {
223
+ metadata: { pluginId: "provider-2" },
224
+ deps: { queuePluginRegistry: queueRegistryRef } as Record<
225
+ string,
226
+ ServiceRef<unknown>
227
+ >,
228
+ },
229
+ {
230
+ metadata: { pluginId: "unrelated" },
231
+ deps: { logger: loggerRef } as Record<string, ServiceRef<unknown>>,
232
+ },
233
+ ];
234
+
235
+ const providedBy = new Map<string, string>();
236
+ const sorted = sortPlugins({
237
+ pendingInits,
238
+ providedBy,
239
+ logger: createMockLogger(),
240
+ });
241
+
242
+ // All providers should come before all consumers
243
+ const provider1Index = sorted.indexOf("provider-1");
244
+ const provider2Index = sorted.indexOf("provider-2");
245
+ const consumer1Index = sorted.indexOf("consumer-1");
246
+ const consumer2Index = sorted.indexOf("consumer-2");
247
+
248
+ expect(provider1Index).toBeLessThan(consumer1Index);
249
+ expect(provider1Index).toBeLessThan(consumer2Index);
250
+ expect(provider2Index).toBeLessThan(consumer1Index);
251
+ expect(provider2Index).toBeLessThan(consumer2Index);
252
+ });
253
+
254
+ it("should respect existing service dependencies while prioritizing queue plugins", () => {
255
+ const queueManagerRef = createServiceRef<unknown>(
256
+ coreServices.queueManager.id
257
+ );
258
+ const queueRegistryRef = createServiceRef<unknown>(
259
+ coreServices.queuePluginRegistry.id
260
+ );
261
+ const customServiceRef = createServiceRef<unknown>("custom.service");
262
+ const loggerRef = createServiceRef<unknown>("core.logger");
263
+
264
+ const pendingInits = [
265
+ {
266
+ metadata: { pluginId: "queue-consumer" },
267
+ deps: {
268
+ queueManager: queueManagerRef,
269
+ customService: customServiceRef,
270
+ } as Record<string, ServiceRef<unknown>>,
271
+ },
272
+ {
273
+ metadata: { pluginId: "queue-provider" },
274
+ deps: { queuePluginRegistry: queueRegistryRef } as Record<
275
+ string,
276
+ ServiceRef<unknown>
277
+ >,
278
+ },
279
+ {
280
+ metadata: { pluginId: "provider-plugin" },
281
+ deps: { logger: loggerRef } as Record<string, ServiceRef<unknown>>,
282
+ },
283
+ ];
284
+
285
+ const providedBy = new Map<string, string>([
286
+ [customServiceRef.id, "provider-plugin"],
287
+ ]);
288
+
289
+ const sorted = sortPlugins({
290
+ pendingInits,
291
+ providedBy,
292
+ logger: createMockLogger(),
293
+ });
294
+
295
+ // Queue provider should come before queue consumer
296
+ const queueProviderIndex = sorted.indexOf("queue-provider");
297
+ const queueConsumerIndex = sorted.indexOf("queue-consumer");
298
+ expect(queueProviderIndex).toBeLessThan(queueConsumerIndex);
299
+
300
+ // Provider plugin should come before queue consumer (due to service dependency)
301
+ const providerPluginIndex = sorted.indexOf("provider-plugin");
302
+ expect(providerPluginIndex).toBeLessThan(queueConsumerIndex);
303
+ });
304
+
305
+ it("should handle plugins that both provide and consume queues", () => {
306
+ const queueManagerRef = createServiceRef<unknown>(
307
+ coreServices.queueManager.id
308
+ );
309
+ const queueRegistryRef = createServiceRef<unknown>(
310
+ coreServices.queuePluginRegistry.id
311
+ );
312
+
313
+ const pendingInits = [
314
+ {
315
+ metadata: { pluginId: "dual-plugin" },
316
+ deps: {
317
+ queuePluginRegistry: queueRegistryRef,
318
+ queueManager: queueManagerRef,
319
+ } as Record<string, ServiceRef<unknown>>,
320
+ },
321
+ {
322
+ metadata: { pluginId: "consumer-only" },
323
+ deps: { queueManager: queueManagerRef } as Record<
324
+ string,
325
+ ServiceRef<unknown>
326
+ >,
327
+ },
328
+ ];
329
+
330
+ const providedBy = new Map<string, string>();
331
+ const sorted = sortPlugins({
332
+ pendingInits,
333
+ providedBy,
334
+ logger: createMockLogger(),
335
+ });
336
+
337
+ // Dual plugin should come before consumer-only
338
+ const dualIndex = sorted.indexOf("dual-plugin");
339
+ const consumerIndex = sorted.indexOf("consumer-only");
340
+ expect(dualIndex).toBeLessThan(consumerIndex);
341
+ });
342
+
343
+ it("should not create circular dependencies with queue ordering", () => {
344
+ const queueManagerRef = createServiceRef<unknown>(
345
+ coreServices.queueManager.id
346
+ );
347
+ const queueRegistryRef = createServiceRef<unknown>(
348
+ coreServices.queuePluginRegistry.id
349
+ );
350
+
351
+ const pendingInits = [
352
+ {
353
+ metadata: { pluginId: "queue-provider" },
354
+ deps: { queuePluginRegistry: queueRegistryRef } as Record<
355
+ string,
356
+ ServiceRef<unknown>
357
+ >,
358
+ },
359
+ {
360
+ metadata: { pluginId: "queue-consumer" },
361
+ deps: { queueManager: queueManagerRef } as Record<
362
+ string,
363
+ ServiceRef<unknown>
364
+ >,
365
+ },
366
+ ];
367
+
368
+ const providedBy = new Map<string, string>();
369
+
370
+ // Should not throw
371
+ expect(() => {
372
+ sortPlugins({ pendingInits, providedBy, logger: createMockLogger() });
373
+ }).not.toThrow();
374
+ });
375
+ });
376
+ });
377
+
378
+ describe("Permission Registration", () => {
379
+ it("should store permissions in the registry", () => {
380
+ // Permissions are now stored directly via the registeredPermissions array
381
+ // and hooks are emitted in Phase 3 (afterPluginsReady)
382
+ const perms = (
383
+ pluginManager as unknown as {
384
+ registeredPermissions: {
385
+ pluginId: string;
386
+ id: string;
387
+ description?: string;
388
+ }[];
389
+ }
390
+ ).registeredPermissions;
391
+
392
+ // Add permissions directly (simulating what plugin-loader does)
393
+ perms.push({
394
+ pluginId: "test-plugin",
395
+ id: "test-plugin.test.permission",
396
+ description: "Test permission",
397
+ });
398
+
399
+ // getAllPermissions should return them (without pluginId in the output)
400
+ const all = pluginManager.getAllPermissions();
401
+ expect(all.length).toBe(1);
402
+ expect(all[0]).toEqual({
403
+ id: "test-plugin.test.permission",
404
+ description: "Test permission",
405
+ });
406
+ });
407
+
408
+ it("should aggregate permissions from multiple plugins", () => {
409
+ const perms = (
410
+ pluginManager as unknown as {
411
+ registeredPermissions: {
412
+ pluginId: string;
413
+ id: string;
414
+ description?: string;
415
+ }[];
416
+ }
417
+ ).registeredPermissions;
418
+
419
+ perms.push(
420
+ { pluginId: "plugin-1", id: "plugin-1.perm.1", description: undefined },
421
+ { pluginId: "plugin-1", id: "plugin-1.perm.2", description: undefined },
422
+ { pluginId: "plugin-2", id: "plugin-2.perm.3", description: undefined }
423
+ );
424
+
425
+ const all = pluginManager.getAllPermissions();
426
+ expect(all.length).toBe(3);
427
+ });
428
+ });
429
+
430
+ describe("loadPlugins", () => {
431
+ it("should discover and initialize plugins", async () => {
432
+ const mockRouter = {
433
+ route: mock(),
434
+ all: mock(),
435
+ newResponse: mock(),
436
+ } as never;
437
+
438
+ // Mock dynamic imports
439
+ const testBackendInit = mock(async () => {});
440
+
441
+ const testPlugin = createBackendPlugin({
442
+ metadata: { pluginId: "test-backend" },
443
+ register(env) {
444
+ env.registerInit({ deps: {}, init: testBackendInit });
445
+ },
446
+ });
447
+
448
+ // Register mock services since core-services is mocked as no-op
449
+ pluginManager.registerService(
450
+ coreServices.queueManager,
451
+ createMockQueueManager()
452
+ );
453
+ pluginManager.registerService(coreServices.logger, createMockLogger());
454
+ pluginManager.registerService(coreServices.database, {} as never); // Mock database
455
+
456
+ // Use manual plugin injection with skipDiscovery to avoid loading real plugins
457
+ await pluginManager.loadPlugins(mockRouter, [testPlugin], {
458
+ skipDiscovery: true,
459
+ });
460
+
461
+ expect(testBackendInit).toHaveBeenCalled();
462
+ });
463
+ });
464
+ });