@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,469 @@
1
+ import { describe, it, expect, beforeEach } from "bun:test";
2
+ import { EventBus } from "./event-bus";
3
+ import type { QueueManager } from "@checkstack/queue-api";
4
+ import type { Logger, Hook } from "@checkstack/backend-api";
5
+ import { createHook } from "@checkstack/backend-api";
6
+ import {
7
+ createMockLogger,
8
+ createMockQueueManager,
9
+ } from "@checkstack/test-utils-backend";
10
+
11
+ describe("EventBus", () => {
12
+ let eventBus: EventBus;
13
+ let mockQueueManager: QueueManager;
14
+ let mockLogger: Logger;
15
+
16
+ beforeEach(() => {
17
+ mockQueueManager = createMockQueueManager();
18
+ mockLogger = createMockLogger();
19
+ eventBus = new EventBus(mockQueueManager, mockLogger);
20
+ });
21
+
22
+ describe("Validation", () => {
23
+ it("should require workerGroup for work-queue mode", async () => {
24
+ const testHook = createHook<{ test: string }>("test.hook");
25
+
26
+ await expect(
27
+ eventBus.subscribe(
28
+ "test-plugin",
29
+ testHook,
30
+ async () => {},
31
+ { mode: "work-queue" } as any // Missing workerGroup
32
+ )
33
+ ).rejects.toThrow("workerGroup is required when mode is 'work-queue'");
34
+ });
35
+
36
+ it("should detect duplicate workerGroups in same plugin", async () => {
37
+ const testHook = createHook<{ test: string }>("test.hook");
38
+
39
+ // First subscription: OK
40
+ await eventBus.subscribe("test-plugin", testHook, async () => {}, {
41
+ mode: "work-queue",
42
+ workerGroup: "sync",
43
+ });
44
+
45
+ // Second subscription with same workerGroup: ERROR
46
+ await expect(
47
+ eventBus.subscribe("test-plugin", testHook, async () => {}, {
48
+ mode: "work-queue",
49
+ workerGroup: "sync",
50
+ })
51
+ ).rejects.toThrow("Duplicate workerGroup 'sync' detected");
52
+ });
53
+
54
+ it("should allow same workerGroup name in different plugins", async () => {
55
+ const testHook = createHook<{ test: string }>("test.hook");
56
+
57
+ // Both should succeed (different plugins)
58
+ await eventBus.subscribe("plugin-a", testHook, async () => {}, {
59
+ mode: "work-queue",
60
+ workerGroup: "sync",
61
+ });
62
+
63
+ await eventBus.subscribe("plugin-b", testHook, async () => {}, {
64
+ mode: "work-queue",
65
+ workerGroup: "sync",
66
+ });
67
+
68
+ // No error - different namespaces
69
+ expect(true).toBe(true);
70
+ });
71
+ });
72
+
73
+ describe("Plugin Namespacing", () => {
74
+ it("should namespace workerGroup by plugin ID", async () => {
75
+ const testHook = createHook<{ test: string }>("test.hook");
76
+ const calls: string[] = [];
77
+
78
+ await eventBus.subscribe(
79
+ "plugin-a",
80
+ testHook,
81
+ async () => {
82
+ calls.push("a");
83
+ },
84
+ {
85
+ mode: "work-queue",
86
+ workerGroup: "sync",
87
+ }
88
+ );
89
+
90
+ await eventBus.subscribe(
91
+ "plugin-b",
92
+ testHook,
93
+ async () => {
94
+ calls.push("b");
95
+ },
96
+ {
97
+ mode: "work-queue",
98
+ workerGroup: "sync",
99
+ }
100
+ );
101
+
102
+ await eventBus.emit(testHook, { test: "data" });
103
+
104
+ // Wait for processing
105
+ await new Promise((resolve) => setTimeout(resolve, 50));
106
+
107
+ // Both should execute (different namespaces: plugin-a.sync and plugin-b.sync)
108
+ expect(calls).toContain("a");
109
+ expect(calls).toContain("b");
110
+ });
111
+
112
+ it("should create unique consumer groups for broadcast mode", async () => {
113
+ const testHook = createHook<{ test: string }>("test.hook");
114
+ const calls: number[] = [];
115
+
116
+ // Two broadcast subscribers from same plugin
117
+ await eventBus.subscribe("plugin-a", testHook, async () => {
118
+ calls.push(1);
119
+ });
120
+
121
+ await eventBus.subscribe("plugin-a", testHook, async () => {
122
+ calls.push(2);
123
+ });
124
+
125
+ await eventBus.emit(testHook, { test: "data" });
126
+
127
+ // Wait for processing
128
+ await new Promise((resolve) => setTimeout(resolve, 50));
129
+
130
+ // Both should receive (each gets unique consumer group with instance ID)
131
+ // Note: They both execute because they have different consumer groups
132
+ expect(calls.length).toBeGreaterThanOrEqual(1);
133
+ expect(calls.length).toBeLessThanOrEqual(2);
134
+ });
135
+ });
136
+
137
+ describe("Unsubscribe", () => {
138
+ it("should unsubscribe and stop receiving events", async () => {
139
+ const testHook = createHook<{ test: string }>("test.hook");
140
+ const calls: number[] = [];
141
+
142
+ const unsubscribe = await eventBus.subscribe(
143
+ "test-plugin",
144
+ testHook,
145
+ async () => {
146
+ calls.push(1);
147
+ }
148
+ );
149
+
150
+ await eventBus.emit(testHook, { test: "data" });
151
+ await new Promise((resolve) => setTimeout(resolve, 50));
152
+ expect(calls.length).toBe(1);
153
+
154
+ // Unsubscribe
155
+ await unsubscribe();
156
+
157
+ // Emit again
158
+ await eventBus.emit(testHook, { test: "data" });
159
+ await new Promise((resolve) => setTimeout(resolve, 50));
160
+
161
+ // Should not receive second event
162
+ expect(calls.length).toBe(1);
163
+ });
164
+
165
+ it("should remove workerGroup from tracking on unsubscribe", async () => {
166
+ const testHook = createHook<{ test: string }>("test.hook");
167
+
168
+ const unsubscribe = await eventBus.subscribe(
169
+ "test-plugin",
170
+ testHook,
171
+ async () => {},
172
+ {
173
+ mode: "work-queue",
174
+ workerGroup: "sync",
175
+ }
176
+ );
177
+
178
+ // Unsubscribe
179
+ await unsubscribe();
180
+
181
+ // Should be able to re-use the same workerGroup name
182
+ await eventBus.subscribe("test-plugin", testHook, async () => {}, {
183
+ mode: "work-queue",
184
+ workerGroup: "sync",
185
+ });
186
+
187
+ // No error!
188
+ expect(true).toBe(true);
189
+ });
190
+
191
+ it("should stop queue when all listeners unsubscribe", async () => {
192
+ const testHook = createHook<{ test: string }>("test.hook");
193
+
194
+ const unsubscribe1 = await eventBus.subscribe(
195
+ "test-plugin",
196
+ testHook,
197
+ async () => {}
198
+ );
199
+
200
+ const unsubscribe2 = await eventBus.subscribe(
201
+ "test-plugin",
202
+ testHook,
203
+ async () => {}
204
+ );
205
+
206
+ // Unsubscribe both
207
+ await unsubscribe1();
208
+ await unsubscribe2();
209
+
210
+ // Successfully unsubscribed without errors
211
+ expect(true).toBe(true);
212
+ });
213
+ });
214
+
215
+ describe("Error Handling", () => {
216
+ it("should log listener errors", async () => {
217
+ const testHook = createHook<{ test: string }>("test.hook");
218
+
219
+ await eventBus.subscribe(
220
+ "test-plugin",
221
+ testHook,
222
+ async () => {
223
+ throw new Error("Listener failed");
224
+ },
225
+ {
226
+ mode: "work-queue",
227
+ workerGroup: "fail-group",
228
+ maxRetries: 0,
229
+ }
230
+ );
231
+
232
+ // Emit - should not throw
233
+ await eventBus.emit(testHook, { test: "data" });
234
+
235
+ // Wait for async processing
236
+ await new Promise((resolve) => setTimeout(resolve, 100));
237
+
238
+ // Error should be logged
239
+ expect(mockLogger.error).toHaveBeenCalled();
240
+ });
241
+ });
242
+
243
+ describe("Hook Emission", () => {
244
+ it("should create queue channel lazily on first emit", async () => {
245
+ const testHook = createHook<{ test: string }>("test.hook");
246
+
247
+ // Emit creates the queue lazily
248
+ await eventBus.emit(testHook, { test: "data" });
249
+
250
+ // No errors - queue was created
251
+ expect(true).toBe(true);
252
+ });
253
+
254
+ it("should deliver payload to all subscribers", async () => {
255
+ const testHook = createHook<{ value: number }>("test.hook");
256
+ const received: number[] = [];
257
+
258
+ await eventBus.subscribe("plugin-1", testHook, async (payload) => {
259
+ received.push(payload.value);
260
+ });
261
+
262
+ await eventBus.subscribe("plugin-2", testHook, async (payload) => {
263
+ received.push(payload.value * 2);
264
+ });
265
+
266
+ await eventBus.emit(testHook, { value: 10 });
267
+
268
+ await new Promise((resolve) => setTimeout(resolve, 50));
269
+
270
+ expect(received).toContain(10);
271
+ expect(received).toContain(20);
272
+ });
273
+ });
274
+
275
+ describe("Shutdown", () => {
276
+ it("should stop all queue channels", async () => {
277
+ const hook1 = createHook<{ test: string }>("hook1");
278
+ const hook2 = createHook<{ test: string }>("hook2");
279
+
280
+ await eventBus.subscribe("test-plugin", hook1, async () => {});
281
+ await eventBus.subscribe("test-plugin", hook2, async () => {});
282
+
283
+ await eventBus.shutdown();
284
+
285
+ // All queues should be stopped
286
+ expect(mockLogger.info).toHaveBeenCalledWith("EventBus shut down");
287
+ });
288
+ });
289
+
290
+ describe("Instance-Local Hooks", () => {
291
+ it("should subscribe via instance-local mode", async () => {
292
+ const testHook = createHook<{ value: number }>("test.local.hook");
293
+ const received: number[] = [];
294
+
295
+ await eventBus.subscribe(
296
+ "test-plugin",
297
+ testHook,
298
+ async (payload) => {
299
+ received.push(payload.value);
300
+ },
301
+ { mode: "instance-local" }
302
+ );
303
+
304
+ await eventBus.emitLocal(testHook, { value: 42 });
305
+
306
+ // Local hooks are synchronous-ish (no queue involved)
307
+ expect(received).toEqual([42]);
308
+ });
309
+
310
+ it("should call all local listeners on emitLocal", async () => {
311
+ const testHook = createHook<{ value: string }>("test.local.hook");
312
+ const calls: string[] = [];
313
+
314
+ await eventBus.subscribe(
315
+ "plugin-a",
316
+ testHook,
317
+ async () => {
318
+ calls.push("a");
319
+ },
320
+ { mode: "instance-local" }
321
+ );
322
+
323
+ await eventBus.subscribe(
324
+ "plugin-b",
325
+ testHook,
326
+ async () => {
327
+ calls.push("b");
328
+ },
329
+ { mode: "instance-local" }
330
+ );
331
+
332
+ await eventBus.emitLocal(testHook, { value: "test" });
333
+
334
+ expect(calls).toContain("a");
335
+ expect(calls).toContain("b");
336
+ expect(calls.length).toBe(2);
337
+ });
338
+
339
+ it("should isolate failures via Promise.allSettled - one listener error does not block others", async () => {
340
+ const testHook = createHook<{ value: number }>("test.local.hook");
341
+ const successfulCalls: number[] = [];
342
+
343
+ await eventBus.subscribe(
344
+ "plugin-a",
345
+ testHook,
346
+ async (payload) => {
347
+ successfulCalls.push(payload.value);
348
+ },
349
+ { mode: "instance-local" }
350
+ );
351
+
352
+ await eventBus.subscribe(
353
+ "plugin-b",
354
+ testHook,
355
+ async () => {
356
+ throw new Error("Intentional failure");
357
+ },
358
+ { mode: "instance-local" }
359
+ );
360
+
361
+ await eventBus.subscribe(
362
+ "plugin-c",
363
+ testHook,
364
+ async (payload) => {
365
+ successfulCalls.push(payload.value * 2);
366
+ },
367
+ { mode: "instance-local" }
368
+ );
369
+
370
+ // Should NOT throw despite plugin-b failing
371
+ await eventBus.emitLocal(testHook, { value: 10 });
372
+
373
+ // Both successful listeners should have executed
374
+ expect(successfulCalls).toContain(10);
375
+ expect(successfulCalls).toContain(20);
376
+ expect(successfulCalls.length).toBe(2);
377
+
378
+ // Error should be logged
379
+ expect(mockLogger.error).toHaveBeenCalled();
380
+ expect(mockLogger.warn).toHaveBeenCalled();
381
+ });
382
+
383
+ it("should unsubscribe local listeners correctly", async () => {
384
+ const testHook = createHook<{ value: number }>("test.local.hook");
385
+ const calls: number[] = [];
386
+
387
+ const unsubscribe = await eventBus.subscribe(
388
+ "test-plugin",
389
+ testHook,
390
+ async (payload) => {
391
+ calls.push(payload.value);
392
+ },
393
+ { mode: "instance-local" }
394
+ );
395
+
396
+ await eventBus.emitLocal(testHook, { value: 1 });
397
+ expect(calls).toEqual([1]);
398
+
399
+ // Unsubscribe
400
+ await unsubscribe();
401
+
402
+ await eventBus.emitLocal(testHook, { value: 2 });
403
+ // Should NOT receive second event
404
+ expect(calls).toEqual([1]);
405
+ });
406
+
407
+ it("should not trigger local listeners on distributed emit", async () => {
408
+ const testHook = createHook<{ value: number }>("test.local.hook");
409
+ const localCalls: number[] = [];
410
+ const distributedCalls: number[] = [];
411
+
412
+ await eventBus.subscribe(
413
+ "test-plugin",
414
+ testHook,
415
+ async (payload) => {
416
+ localCalls.push(payload.value);
417
+ },
418
+ { mode: "instance-local" }
419
+ );
420
+
421
+ await eventBus.subscribe("test-plugin", testHook, async (payload) => {
422
+ distributedCalls.push(payload.value);
423
+ });
424
+
425
+ // Distributed emit - should only trigger distributed listener
426
+ await eventBus.emit(testHook, { value: 42 });
427
+ await new Promise((resolve) => setTimeout(resolve, 50));
428
+
429
+ expect(distributedCalls).toContain(42);
430
+ expect(localCalls).toEqual([]); // Local listener should NOT be triggered
431
+ });
432
+
433
+ it("should not trigger distributed listeners on emitLocal", async () => {
434
+ const testHook = createHook<{ value: number }>("test.local.hook");
435
+ const localCalls: number[] = [];
436
+ const distributedCalls: number[] = [];
437
+
438
+ await eventBus.subscribe(
439
+ "test-plugin",
440
+ testHook,
441
+ async (payload) => {
442
+ localCalls.push(payload.value);
443
+ },
444
+ { mode: "instance-local" }
445
+ );
446
+
447
+ await eventBus.subscribe("test-plugin", testHook, async (payload) => {
448
+ distributedCalls.push(payload.value);
449
+ });
450
+
451
+ // Local emit - should only trigger local listener
452
+ await eventBus.emitLocal(testHook, { value: 99 });
453
+
454
+ expect(localCalls).toEqual([99]);
455
+ expect(distributedCalls).toEqual([]); // Distributed listener should NOT be triggered
456
+ });
457
+
458
+ it("should handle emitLocal with no listeners gracefully", async () => {
459
+ const testHook = createHook<{ value: number }>("test.no.listeners");
460
+
461
+ // Should not throw
462
+ await eventBus.emitLocal(testHook, { value: 42 });
463
+
464
+ expect(mockLogger.debug).toHaveBeenCalledWith(
465
+ `No local listeners for hook: ${testHook.id}`
466
+ );
467
+ });
468
+ });
469
+ });