@checkstack/backend 0.9.0 → 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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,135 @@
1
1
  # @checkstack/backend
2
2
 
3
+ ## 0.9.1
4
+
5
+ ### Patch Changes
6
+
7
+ - aa89bc5: Replace the bespoke `registerInfrastructureTab()` registry with a standard
8
+ slot-extension contract (`InfrastructureTabsSlot` from
9
+ `@checkstack/infrastructure-common`). Plugins now contribute infrastructure
10
+ tabs via `createSlotExtension`, depending only on the slot owner.
11
+
12
+ The slot system in `@checkstack/frontend-api` gains a second type parameter
13
+ on `createSlot<TContext, TMetadata>` so extensions can declare typed static
14
+ metadata at registration time (label, icon, access rules, ordering for the
15
+ infrastructure tab bar). A new `useSlotExtensions(slot)` hook returns typed
16
+ extensions and subscribes to plugin lifecycle changes.
17
+
18
+ Each tab body now stacks a **Runtime** sub-section (live state, read-only)
19
+ on top of a **Configuration** sub-section (settings, gated by `canUpdate`).
20
+
21
+ **Queue runtime panel.** Surfaces aggregated counts (pending / processing /
22
+ completed / failed) plus three sub-tabs of recent jobs: **Active**, **Recent
23
+ failed** (with the failure message), and **Recent completed** (with
24
+ duration). Job payloads are deliberately not surfaced — they may carry
25
+ secrets and need a separate manage-access gate to be shown.
26
+
27
+ To support this, `Queue<T>` gains a required `listJobs(opts)` method
28
+ returning `JobSummary[]` (no payloads), and `QueueStats` gains a
29
+ `scope: "instance" | "cluster"` field. The in-memory queue keeps rolling
30
+ ring buffers (200 entries) for completed/failed history and tracks active
31
+ jobs by id; BullMQ uses native `getJobs`. `QueueManager.listJobs` aggregates
32
+ across queues and sorts (most-recent-first for terminal states, FIFO for
33
+ active/waiting/delayed).
34
+
35
+ **Cache runtime panel.** Lists the top N entries by size (or by recency) so
36
+ operators can debug a cache filling up. Values are deliberately omitted —
37
+ PII / secret risk. Backends opt in via an optional `listEntries?` method on
38
+ `CacheProvider`; non-supporting backends return `{ supported: false }` and
39
+ the UI renders a "not supported by this backend" hint. The in-memory cache
40
+ implements it using its existing per-entry byte tracking.
41
+
42
+ `CacheStats` also gains `scope: "instance" | "cluster"`.
43
+
44
+ **Multi-instance scope warning.** A new `<InstanceScopeBanner>` component in
45
+ `@checkstack/ui` renders a yellow banner above any runtime panel whose
46
+ backend reports `scope: "instance"` — i.e. in-memory queue or cache running
47
+ in a horizontally scaled deployment. The banner explains the metrics are
48
+ local to the responding replica and recommends switching to a clustered
49
+ backend (Redis-backed queue / cache) for cluster-wide visibility.
50
+
51
+ **Bug fix — stable cache provider proxy.** `CacheManagerImpl.getProvider()`
52
+ now returns a single stable proxy that delegates to whatever provider is
53
+ currently active. Previously, consumers of `createCachedScope` (and any
54
+ direct `cacheManager.getProvider()` caller) captured the active provider
55
+ reference at plugin-init time. After any `setActiveBackend` call — including
56
+ saving the same memory config in the new Cache tab, which reconstructs the
57
+ in-memory cache — those scopes wrote to an orphaned old provider while the
58
+ runtime panel read stats from the new (empty) one, making the runtime panel
59
+ appear to report 0 keys. With the proxy, all consumers share a single stable
60
+ identity and writes always land in the active provider.
61
+
62
+ **Bytes tracking on the in-memory cache.** `InMemoryCache.getStats().sizeBytes`
63
+ now returns a running approximation (UTF-8 bytes of the key plus
64
+ `v8.serialize(value).byteLength`, with a JSON fallback) that's kept in sync
65
+ across all eviction paths. Treat the number as a sanity gauge; it doesn't
66
+ include `Map` per-entry overhead.
67
+
68
+ **Pagination.** Both `Queue<T>.listJobs` and `CacheProvider.listEntries?`
69
+ are offset-paginated. Inputs gain an `offset: number`; outputs change to
70
+ `{ items, total: number | null, hasMore: boolean }`. `total` is nullable
71
+ so backends that can't compute it cheaply still paginate via `hasMore`.
72
+ The UI uses the existing `<Pagination>` component with a 25-row default
73
+ page size. `QueueManager.listJobs` aggregates by over-fetching
74
+ `[0, offset+limit)` per queue, merge-sorting, then slicing the window —
75
+ optimal for the single-queue case, acceptable for the multi-queue case
76
+ within the UI's reasonable page-depth bounds. BullMQ uses native offset
77
+ ranges via `getJobs(types, start, end)` plus `getJobCounts` for `total`.
78
+
79
+ **Pending tab.** The Queue runtime panel exposes a virtual `"pending"`
80
+ state (waiting ∪ delayed, FIFO). It's now the default sub-tab, since
81
+ "what's queued up?" is the most common question. Per-row state is shown
82
+ when viewing the combined list.
83
+
84
+ **Recurring schedules visible under Pending.** Cron- and interval-based
85
+ recurring jobs (e.g. healthchecks) are surfaced under Pending/Delayed
86
+ between fires, with a `nextRunAt` countdown column and a "(recurring)"
87
+ label. `JobSummary` gains optional `nextRunAt: Date` and `recurring:
88
+ boolean` fields. The in-memory queue synthesises these rows from its
89
+ `recurringJobs` registry; BullMQ already materialises the next fire of
90
+ each scheduler as a delayed job and we now surface its trigger time and
91
+ the `repeatJobKey`-derived `recurring` flag.
92
+
93
+ **Bug fix — drop hook emits with no listeners.** `EventBus.emit` no
94
+ longer enqueues a job when zero listeners (distributed or instance-local)
95
+ are registered for the hook. Previously, hooks like
96
+ `core.plugin.initialized` — emitted on every plugin init but subscribed
97
+ to by nothing in the core repo — accumulated one waiting job per emit
98
+ forever. The in-memory queue's `processNext` short-circuits when there
99
+ are zero consumer groups, so its post-loop cleanup never ran for these
100
+ orphaned jobs. The fix drops the emit at the source and logs a debug
101
+ line. Note: in distributed deployments using a Redis-backed queue, this
102
+ means a subscriber on another replica won't receive an event if no
103
+ replica that emits it has a local listener. Plugins needing cross-process
104
+ delivery must register their listener on every replica that should
105
+ receive the hook.
106
+
107
+ **Breaking notes (treated as minor under beta semantics)**:
108
+
109
+ - `@checkstack/infrastructure-common` removes `registerInfrastructureTab`
110
+ and `getInfrastructureTabs`; former callers must register an extension
111
+ into `InfrastructureTabsSlot`.
112
+ - `@checkstack/queue-api`'s `Queue<T>` interface requires the new
113
+ `listJobs(opts)` method returning `ListJobsResult` (paginated). Both
114
+ bundled queue backends (memory, BullMQ) are updated; out-of-tree
115
+ implementations will need to add it.
116
+ - `QueueStats` and `CacheStats` add a required `scope` field.
117
+ - `CacheProvider.listEntries?` (when implemented) now returns
118
+ `ListEntriesResult` instead of `CacheEntrySummary[]`.
119
+ - `JobState` adds a `"pending"` variant.
120
+
121
+ - Updated dependencies [42abfff]
122
+ - Updated dependencies [aa89bc5]
123
+ - @checkstack/common@0.9.0
124
+ - @checkstack/queue-api@0.3.0
125
+ - @checkstack/cache-api@0.3.0
126
+ - @checkstack/api-docs-common@0.1.12
127
+ - @checkstack/auth-common@0.6.6
128
+ - @checkstack/backend-api@0.15.1
129
+ - @checkstack/pluginmanager-common@0.2.1
130
+ - @checkstack/signal-backend@0.2.4
131
+ - @checkstack/signal-common@0.2.2
132
+
3
133
  ## 0.9.0
4
134
 
5
135
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/backend",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "license": "Elastic-2.0",
5
5
  "checkstack": {
6
6
  "type": "backend"
@@ -14,16 +14,16 @@
14
14
  "lint:code": "eslint . --max-warnings 0"
15
15
  },
16
16
  "dependencies": {
17
- "@checkstack/api-docs-common": "0.1.10",
18
- "@checkstack/auth-common": "0.6.4",
19
- "@checkstack/backend-api": "0.14.1",
20
- "@checkstack/common": "0.7.0",
21
- "@checkstack/drizzle-helper": "0.0.4",
22
- "@checkstack/cache-api": "0.2.3",
23
- "@checkstack/queue-api": "0.2.17",
24
- "@checkstack/signal-backend": "0.2.2",
25
- "@checkstack/signal-common": "0.2.0",
26
- "@checkstack/pluginmanager-common": "0.1.0",
17
+ "@checkstack/api-docs-common": "0.1.11",
18
+ "@checkstack/auth-common": "0.6.5",
19
+ "@checkstack/backend-api": "0.15.0",
20
+ "@checkstack/common": "0.8.0",
21
+ "@checkstack/drizzle-helper": "0.0.5",
22
+ "@checkstack/cache-api": "0.2.4",
23
+ "@checkstack/queue-api": "0.2.18",
24
+ "@checkstack/signal-backend": "0.2.3",
25
+ "@checkstack/signal-common": "0.2.1",
26
+ "@checkstack/pluginmanager-common": "0.2.0",
27
27
  "@hono/zod-validator": "^0.7.6",
28
28
  "@orpc/client": "^1.13.14",
29
29
  "@orpc/contract": "^1.13.14",
@@ -44,9 +44,9 @@
44
44
  "@types/pg": "^8.11.0",
45
45
  "@types/bun": "latest",
46
46
  "@types/semver": "^7.5.0",
47
- "@checkstack/tsconfig": "0.0.6",
48
- "@checkstack/scripts": "0.1.2",
49
- "@checkstack/test-utils-backend": "0.1.23",
47
+ "@checkstack/tsconfig": "0.0.7",
48
+ "@checkstack/scripts": "0.3.0",
49
+ "@checkstack/test-utils-backend": "0.1.24",
50
50
  "drizzle-kit": "^0.31.10"
51
51
  }
52
52
  }
@@ -0,0 +1,172 @@
1
+ import { describe, it, expect, beforeEach } from "bun:test";
2
+ import { z } from "zod";
3
+ import { CacheManagerImpl } from "./cache-manager";
4
+ import { CachePluginRegistryImpl } from "./cache-plugin-registry";
5
+ import type { CachePlugin, CacheProvider, CacheStats } from "@checkstack/cache-api";
6
+ import type { ConfigService, Logger } from "@checkstack/backend-api";
7
+ import { createMockLogger } from "@checkstack/test-utils-backend";
8
+
9
+ class FakeInMemoryCache implements CacheProvider {
10
+ store = new Map<string, unknown>();
11
+ hits = 0;
12
+ misses = 0;
13
+
14
+ async get<T>(key: string): Promise<T | undefined> {
15
+ if (this.store.has(key)) {
16
+ this.hits++;
17
+ return this.store.get(key) as T;
18
+ }
19
+ this.misses++;
20
+ return undefined;
21
+ }
22
+ async set<T>(key: string, value: T): Promise<void> {
23
+ this.store.set(key, value);
24
+ }
25
+ async delete(key: string): Promise<void> {
26
+ this.store.delete(key);
27
+ }
28
+ async deleteByPrefix(): Promise<number> {
29
+ return 0;
30
+ }
31
+ async has(key: string): Promise<boolean> {
32
+ return this.store.has(key);
33
+ }
34
+ async getStats(): Promise<CacheStats> {
35
+ return {
36
+ keyCount: this.store.size,
37
+ sizeBytes: null,
38
+ hits: this.hits,
39
+ misses: this.misses,
40
+ scope: "instance",
41
+ };
42
+ }
43
+ }
44
+
45
+ const memoryConfigSchema = z.record(z.string(), z.unknown());
46
+
47
+ const createMemoryPlugin = (
48
+ factory: () => FakeInMemoryCache,
49
+ ): CachePlugin<unknown> => ({
50
+ id: "memory",
51
+ displayName: "Memory",
52
+ configVersion: 1,
53
+ configSchema: memoryConfigSchema,
54
+ createProvider: () => factory(),
55
+ });
56
+
57
+ class StubConfigService implements ConfigService {
58
+ store = new Map<string, unknown>();
59
+ async set<T>(configId: string, _s: unknown, _v: number, data: T) {
60
+ this.store.set(configId, data);
61
+ }
62
+ async get<T>(configId: string): Promise<T | undefined> {
63
+ return this.store.get(configId) as T | undefined;
64
+ }
65
+ async getRedacted<T>(configId: string): Promise<Partial<T> | undefined> {
66
+ return this.store.get(configId) as Partial<T> | undefined;
67
+ }
68
+ async delete(configId: string): Promise<void> {
69
+ this.store.delete(configId);
70
+ }
71
+ async list(): Promise<string[]> {
72
+ return [...this.store.keys()];
73
+ }
74
+ }
75
+
76
+ describe("CacheManagerImpl provider proxy", () => {
77
+ let registry: CachePluginRegistryImpl;
78
+ let configService: StubConfigService;
79
+ let logger: Logger;
80
+ let manager: CacheManagerImpl;
81
+ let instances: FakeInMemoryCache[];
82
+
83
+ beforeEach(() => {
84
+ registry = new CachePluginRegistryImpl();
85
+ configService = new StubConfigService();
86
+ logger = createMockLogger() as unknown as Logger;
87
+ instances = [];
88
+ registry.register(
89
+ createMemoryPlugin(() => {
90
+ const inst = new FakeInMemoryCache();
91
+ instances.push(inst);
92
+ return inst;
93
+ }),
94
+ );
95
+ manager = new CacheManagerImpl(registry, configService, logger);
96
+ });
97
+
98
+ it("returns the same proxy reference across calls", async () => {
99
+ const a = manager.getProvider();
100
+ const b = manager.getProvider();
101
+ expect(a).toBe(b);
102
+ });
103
+
104
+ it("delegates writes to the active provider after loadConfiguration", async () => {
105
+ const provider = manager.getProvider();
106
+ await manager.loadConfiguration();
107
+ await provider.set("foo", "bar");
108
+ expect(instances[0].store.get("foo")).toBe("bar");
109
+ });
110
+
111
+ it("keeps a captured proxy reference live across setActiveBackend", async () => {
112
+ await manager.loadConfiguration();
113
+ const captured = manager.getProvider();
114
+ await captured.set("before", 1);
115
+
116
+ await manager.setActiveBackend("memory", {
117
+ maxEntries: 5000,
118
+ sweepIntervalMs: 30_000,
119
+ });
120
+
121
+ // The captured reference must now write to the NEW active provider,
122
+ // not the orphaned old one.
123
+ await captured.set("after", 2);
124
+
125
+ const oldInstance = instances[0];
126
+ const newInstance = instances.at(-1)!;
127
+ expect(oldInstance).not.toBe(newInstance);
128
+ expect(oldInstance.store.has("after")).toBe(false);
129
+ expect(newInstance.store.has("after")).toBe(true);
130
+ });
131
+
132
+ it("getStats reads from the currently active provider", async () => {
133
+ await manager.loadConfiguration();
134
+ const provider = manager.getProvider();
135
+ await provider.set("k1", "v1");
136
+ await provider.get("k1"); // hit
137
+ await provider.get("missing"); // miss
138
+
139
+ const stats = await provider.getStats!();
140
+ expect(stats.keyCount).toBe(1);
141
+ expect(stats.hits).toBe(1);
142
+ expect(stats.misses).toBe(1);
143
+ });
144
+
145
+ it("getStats returns all-null when active provider has no getStats impl", async () => {
146
+ // Plugin whose provider doesn't implement getStats.
147
+ const minimalPlugin: CachePlugin<unknown> = {
148
+ id: "noStats",
149
+ displayName: "No stats",
150
+ configVersion: 1,
151
+ configSchema: memoryConfigSchema,
152
+ createProvider: (): CacheProvider => ({
153
+ get: async () => undefined,
154
+ set: async () => {},
155
+ delete: async () => {},
156
+ deleteByPrefix: async () => 0,
157
+ has: async () => false,
158
+ }),
159
+ };
160
+ registry.register(minimalPlugin);
161
+
162
+ await manager.setActiveBackend("noStats", {});
163
+ const stats = await manager.getProvider().getStats!();
164
+ expect(stats).toEqual({
165
+ keyCount: null,
166
+ sizeBytes: null,
167
+ hits: null,
168
+ misses: null,
169
+ scope: "instance",
170
+ });
171
+ });
172
+ });
@@ -1,4 +1,10 @@
1
- import type { CacheManager, CacheProvider } from "@checkstack/cache-api";
1
+ import type {
2
+ CacheManager,
3
+ CacheProvider,
4
+ CacheStats,
5
+ ListEntriesOptions,
6
+ ListEntriesResult,
7
+ } from "@checkstack/cache-api";
2
8
  import type { CachePluginRegistryImpl } from "./cache-plugin-registry";
3
9
  import type { Logger, ConfigService } from "@checkstack/backend-api";
4
10
  import { z } from "zod";
@@ -18,6 +24,10 @@ type ActiveCachePointer = z.infer<typeof activeCachePointerSchema>;
18
24
  /**
19
25
  * A no-op CacheProvider used before any backend is configured.
20
26
  * All operations are safe to call but behave as if the cache is empty.
27
+ *
28
+ * Held only as the initial value of `activeProvider` until
29
+ * `loadConfiguration()` runs; consumers always interact with the stable
30
+ * proxy below, so they pick up the real provider as soon as it's ready.
21
31
  */
22
32
  const nullProvider: CacheProvider = {
23
33
  // eslint-disable-next-line unicorn/no-useless-undefined
@@ -31,8 +41,18 @@ const nullProvider: CacheProvider = {
31
41
  /**
32
42
  * CacheManagerImpl handles cache provider lifecycle and backend switching.
33
43
  *
34
- * Simpler than QueueManagerImpl no proxy pattern needed since cache is
35
- * stateless key/value. The active provider is replaced atomically on backend switch.
44
+ * `getProvider()` returns a single stable proxy whose methods delegate to
45
+ * whichever provider is currently active. This means:
46
+ *
47
+ * - Plugins that capture the proxy at init time (e.g. via
48
+ * `createCachedScope`) keep functioning across backend switches and
49
+ * don't write into orphaned providers.
50
+ * - The Infrastructure runtime panel reads stats from the same instance
51
+ * plugins write to — no split-brain.
52
+ *
53
+ * The earlier "no proxy pattern needed since cache is stateless key/value"
54
+ * comment was wrong: in-memory backends are stateful and replacing the
55
+ * active reference would orphan their state.
36
56
  */
37
57
  export class CacheManagerImpl implements CacheManager {
38
58
  private activePluginId: string = "memory";
@@ -42,12 +62,49 @@ export class CacheManagerImpl implements CacheManager {
42
62
  };
43
63
  private configVersion: number = 0;
44
64
  private activeProvider: CacheProvider = nullProvider;
65
+ private readonly providerProxy: CacheProvider;
45
66
 
46
67
  constructor(
47
68
  private registry: CachePluginRegistryImpl,
48
69
  private configService: ConfigService,
49
70
  private logger: Logger,
50
- ) {}
71
+ ) {
72
+ this.providerProxy = this.createProviderProxy();
73
+ }
74
+
75
+ private createProviderProxy(): CacheProvider {
76
+ return {
77
+ get: <T>(key: string) => this.activeProvider.get<T>(key),
78
+ set: <T>(key: string, value: T, ttlMs?: number) =>
79
+ this.activeProvider.set<T>(key, value, ttlMs),
80
+ delete: (key: string) => this.activeProvider.delete(key),
81
+ deleteByPrefix: (prefix: string) =>
82
+ this.activeProvider.deleteByPrefix(prefix),
83
+ has: (key: string) => this.activeProvider.has(key),
84
+ getStats: async (): Promise<CacheStats> => {
85
+ const provider = this.activeProvider;
86
+ if (provider.getStats) {
87
+ return provider.getStats();
88
+ }
89
+ return {
90
+ keyCount: null,
91
+ sizeBytes: null,
92
+ hits: null,
93
+ misses: null,
94
+ scope: "instance",
95
+ };
96
+ },
97
+ listEntries: async (
98
+ opts: ListEntriesOptions,
99
+ ): Promise<ListEntriesResult> => {
100
+ const provider = this.activeProvider;
101
+ if (!provider.listEntries) {
102
+ return { items: [], total: 0, hasMore: false };
103
+ }
104
+ return provider.listEntries(opts);
105
+ },
106
+ };
107
+ }
51
108
 
52
109
  async loadConfiguration(): Promise<void> {
53
110
  try {
@@ -83,11 +140,10 @@ export class CacheManagerImpl implements CacheManager {
83
140
  );
84
141
  }
85
142
 
86
- // Initialize the active provider
87
143
  this.initializeProvider();
88
144
  } catch (error) {
89
145
  this.logger.error("Failed to load cache configuration", error);
90
- // Continue with defaults — nullProvider is already set
146
+ // Continue with defaults — nullProvider is already set behind the proxy.
91
147
  }
92
148
  }
93
149
 
@@ -113,8 +169,12 @@ export class CacheManagerImpl implements CacheManager {
113
169
  }
114
170
  }
115
171
 
172
+ /**
173
+ * Returns the stable proxy. The same instance is returned for the
174
+ * lifetime of the manager, so callers can safely capture it once.
175
+ */
116
176
  getProvider(): CacheProvider {
117
- return this.activeProvider;
177
+ return this.providerProxy;
118
178
  }
119
179
 
120
180
  getActivePlugin(): string {
@@ -126,21 +186,17 @@ export class CacheManagerImpl implements CacheManager {
126
186
  }
127
187
 
128
188
  async setActiveBackend(pluginId: string, config: unknown): Promise<void> {
129
- // 1. Validate plugin exists
130
189
  const newPlugin = this.registry.getPlugin(pluginId);
131
190
  if (!newPlugin) {
132
191
  throw new Error(`Cache plugin '${pluginId}' not found`);
133
192
  }
134
193
 
135
- // 2. Validate config against schema
136
194
  newPlugin.configSchema.parse(config);
137
195
 
138
- // 3. Create new provider (acts as connection test)
139
196
  this.logger.info("🔍 Testing new cache provider...");
140
197
  let newProvider: CacheProvider;
141
198
  try {
142
199
  newProvider = newPlugin.createProvider(config, this.logger);
143
- // Quick smoke test
144
200
  await newProvider.set("__test__", true, 1000);
145
201
  await newProvider.delete("__test__");
146
202
  this.logger.info("✅ Cache provider test successful");
@@ -150,20 +206,17 @@ export class CacheManagerImpl implements CacheManager {
150
206
  throw new Error(`Failed to create cache provider: ${message}`);
151
207
  }
152
208
 
153
- // 4. Stop old provider if it has a stop method
154
209
  const oldProvider = this.activeProvider;
155
210
  if ("stop" in oldProvider && typeof oldProvider.stop === "function") {
156
211
  await (oldProvider as { stop: () => Promise<void> }).stop();
157
212
  }
158
213
 
159
- // 5. Switch to new provider
160
214
  const oldPluginId = this.activePluginId;
161
215
  this.activePluginId = pluginId;
162
216
  this.activeConfig = config;
163
217
  this.configVersion++;
164
218
  this.activeProvider = newProvider;
165
219
 
166
- // 6. Persist configuration
167
220
  await this.configService.set(
168
221
  pluginId,
169
222
  newPlugin.configSchema,
@@ -465,5 +465,57 @@ describe("EventBus", () => {
465
465
  `No local listeners for hook: ${testHook.id}`
466
466
  );
467
467
  });
468
+
469
+ it("should drop emit (not enqueue) when no listeners are registered", async () => {
470
+ const testHook = createHook<{ value: number }>(
471
+ "test.emit.no.listeners",
472
+ );
473
+
474
+ // Capture queue creation: getQueue is invoked lazily on enqueue.
475
+ const before = (mockQueueManager as any).getQueue?.mock?.calls?.length ?? 0;
476
+
477
+ await eventBus.emit(testHook, { value: 1 });
478
+ await eventBus.emit(testHook, { value: 2 });
479
+
480
+ const after = (mockQueueManager as any).getQueue?.mock?.calls?.length ?? 0;
481
+ // No queue should be created and no enqueue should occur for an
482
+ // entirely unsubscribed hook — otherwise jobs would pile up forever.
483
+ expect(after).toBe(before);
484
+
485
+ expect(mockLogger.debug).toHaveBeenCalledWith(
486
+ `Dropped hook ${testHook.id}: no listeners registered`,
487
+ );
488
+ });
489
+
490
+ it("should enqueue when at least one distributed listener is registered", async () => {
491
+ const testHook = createHook<{ value: number }>(
492
+ "test.emit.with.listener",
493
+ );
494
+ const received: number[] = [];
495
+ await eventBus.subscribe("test-plugin", testHook, async (payload) => {
496
+ received.push(payload.value);
497
+ });
498
+
499
+ await eventBus.emit(testHook, { value: 7 });
500
+ // Allow the mock queue to deliver synchronously.
501
+ await new Promise((resolve) => setTimeout(resolve, 10));
502
+ expect(received).toContain(7);
503
+ });
504
+
505
+ it("should enqueue when at least one instance-local listener is registered", async () => {
506
+ const testHook = createHook<{ value: number }>(
507
+ "test.emit.with.local.listener",
508
+ );
509
+ await eventBus.subscribe("test-plugin", testHook, async () => {}, {
510
+ mode: "instance-local",
511
+ });
512
+
513
+ // emit (distributed) should still enqueue because a local listener
514
+ // exists — drops are based on absence of *any* listener.
515
+ await eventBus.emit(testHook, { value: 1 });
516
+ expect(mockLogger.debug).not.toHaveBeenCalledWith(
517
+ `Dropped hook ${testHook.id}: no listeners registered`,
518
+ );
519
+ });
468
520
  });
469
521
  });
@@ -185,9 +185,35 @@ export class EventBus implements IEventBus {
185
185
  }
186
186
 
187
187
  /**
188
- * Emit a hook
188
+ * Emit a hook.
189
+ *
190
+ * Skips the underlying queue enqueue when no listener has been
191
+ * registered for the hook in this process. Without this guard, hooks
192
+ * with no subscribers (e.g. `core.plugin.initialized` when no plugin
193
+ * has registered a listener) would accumulate jobs in the in-memory
194
+ * queue forever — they'd be enqueued, no consumer group exists to
195
+ * process them, and `processNext` short-circuits before its cleanup
196
+ * pass when `consumerGroups.size === 0`.
197
+ *
198
+ * Note: this checks listeners in the *local process*. In distributed
199
+ * deployments with a Redis-backed queue, a subscriber on another
200
+ * replica would never see the event under this rule. Callers that
201
+ * need cross-process delivery must therefore ensure at least one
202
+ * listener registers on every replica that should receive the hook.
189
203
  */
190
204
  async emit<T>(hook: Hook<T>, payload: T): Promise<void> {
205
+ const hasDistributedListeners =
206
+ (this.listeners.get(hook.id)?.length ?? 0) > 0;
207
+ const hasLocalListeners =
208
+ (this.localListeners.get(hook.id)?.length ?? 0) > 0;
209
+
210
+ if (!hasDistributedListeners && !hasLocalListeners) {
211
+ this.logger.debug(
212
+ `Dropped hook ${hook.id}: no listeners registered`,
213
+ );
214
+ return;
215
+ }
216
+
191
217
  let channel = this.queueChannels.get(hook.id);
192
218
 
193
219
  // Create channel lazily if not exists
@@ -4,6 +4,9 @@ import type {
4
4
  SwitchResult,
5
5
  RecurringJobInfo,
6
6
  QueueStats,
7
+ JobSummary,
8
+ ListJobsOptions,
9
+ ListJobsResult,
7
10
  } from "@checkstack/queue-api";
8
11
  import type { QueuePluginRegistryImpl } from "./queue-plugin-registry";
9
12
  import type { Logger, ConfigService } from "@checkstack/backend-api";
@@ -276,7 +279,11 @@ export class QueueManagerImpl implements QueueManager {
276
279
  }
277
280
 
278
281
  async getAggregatedStats(): Promise<QueueStats> {
279
- const aggregated: QueueStats = {
282
+ // The narrowest scope wins: if any queue reports `instance`, the
283
+ // aggregate cannot claim cluster-wide accuracy.
284
+ let scope: "instance" | "cluster" = "cluster";
285
+ let sawAny = false;
286
+ const aggregated: Omit<QueueStats, "scope"> = {
280
287
  pending: 0,
281
288
  processing: 0,
282
289
  completed: 0,
@@ -294,13 +301,81 @@ export class QueueManagerImpl implements QueueManager {
294
301
  aggregated.completed += stats.completed;
295
302
  aggregated.failed += stats.failed;
296
303
  aggregated.consumerGroups += stats.consumerGroups;
304
+ if (stats.scope === "instance") {
305
+ scope = "instance";
306
+ }
307
+ sawAny = true;
308
+ }
309
+ } catch {
310
+ // Queue may not be initialized yet
311
+ }
312
+ }
313
+
314
+ return { ...aggregated, scope: sawAny ? scope : "instance" };
315
+ }
316
+
317
+ /**
318
+ * Aggregate `listJobs` across every queue proxy. For deployments with a
319
+ * single queue (the common case) this is a straight pass-through; with
320
+ * multiple queues we over-fetch [0, offset+limit) per queue, merge-sort
321
+ * by the same key the per-queue impls use, then slice the requested
322
+ * window. Sort key:
323
+ * - completed/failed → finishedAt desc (most-recent first)
324
+ * - active → startedAt asc (oldest still-running first)
325
+ * - waiting/delayed → enqueuedAt asc (FIFO)
326
+ */
327
+ async listJobs(opts: ListJobsOptions): Promise<ListJobsResult> {
328
+ const limit = Math.max(0, opts.limit);
329
+ const offset = Math.max(0, opts.offset);
330
+ if (limit === 0) {
331
+ return { items: [], total: 0, hasMore: false };
332
+ }
333
+
334
+ const fetchUpTo = offset + limit;
335
+ const merged: JobSummary[] = [];
336
+ let totalSum = 0;
337
+ let totalIsNull = false;
338
+
339
+ for (const proxy of this.queueProxies.values()) {
340
+ try {
341
+ const delegate = proxy.getDelegate();
342
+ if (delegate) {
343
+ const part = await delegate.listJobs({
344
+ state: opts.state,
345
+ offset: 0,
346
+ limit: fetchUpTo,
347
+ });
348
+ merged.push(...part.items);
349
+ if (part.total === null) {
350
+ totalIsNull = true;
351
+ } else {
352
+ totalSum += part.total;
353
+ }
297
354
  }
298
355
  } catch {
299
356
  // Queue may not be initialized yet
300
357
  }
301
358
  }
302
359
 
303
- return aggregated;
360
+ const sortKey = (j: JobSummary): number => {
361
+ if (opts.state === "completed" || opts.state === "failed") {
362
+ return -(j.finishedAt?.getTime() ?? 0);
363
+ }
364
+ if (opts.state === "active") {
365
+ return j.startedAt?.getTime() ?? 0;
366
+ }
367
+ return j.enqueuedAt.getTime();
368
+ };
369
+ merged.sort((a, b) => sortKey(a) - sortKey(b));
370
+
371
+ const items = merged.slice(offset, offset + limit);
372
+ const total = totalIsNull ? null : totalSum;
373
+ const hasMore =
374
+ total === null
375
+ ? merged.length > offset + items.length
376
+ : offset + items.length < total;
377
+
378
+ return { items, total, hasMore };
304
379
  }
305
380
 
306
381
  async listAllRecurringJobs(): Promise<RecurringJobInfo[]> {
@@ -5,6 +5,8 @@ import type {
5
5
  QueueStats,
6
6
  RecurringJobDetails,
7
7
  RecurringSchedule,
8
+ ListJobsOptions,
9
+ ListJobsResult,
8
10
  } from "@checkstack/queue-api";
9
11
  import { rootLogger } from "../logger";
10
12
 
@@ -173,6 +175,11 @@ export class QueueProxy<T = unknown> implements Queue<T> {
173
175
  return delegate.getStats();
174
176
  }
175
177
 
178
+ async listJobs(opts: ListJobsOptions): Promise<ListJobsResult> {
179
+ const delegate = this.ensureDelegate();
180
+ return delegate.listJobs(opts);
181
+ }
182
+
176
183
  /**
177
184
  * Get subscription count (for testing/debugging)
178
185
  */