@cosmicdrift/kumiko-framework 0.131.0 → 0.133.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.131.0",
3
+ "version": "0.133.0",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -189,7 +189,7 @@
189
189
  "zod": "^4.4.3"
190
190
  },
191
191
  "devDependencies": {
192
- "@cosmicdrift/kumiko-dispatcher-live": "0.131.0",
192
+ "@cosmicdrift/kumiko-dispatcher-live": "0.133.0",
193
193
  "bun-types": "^1.3.13",
194
194
  "pino-pretty": "^13.1.3"
195
195
  },
@@ -0,0 +1,139 @@
1
+ // Runtime guard #722: before the rebuild swaps the shadow over the live table,
2
+ // abort if a live row has NO event in the projection's source streams — a row
3
+ // no replay can reconstruct (#498 ghost, direct-inserted without a .created
4
+ // event), which the swap would silently drop.
5
+ //
6
+ // The guard is deliberately narrow (event EXISTENCE only, not a column diff):
7
+ // the framework legitimately makes live diverge from a fresh replay in shipped
8
+ // ways (blind-index erase→NULL, sensitive-strip, archived-stream wipe, the #494
9
+ // backfill flow). Those rows all HAVE an event, so they are not ghosts and the
10
+ // guard leaves them alone — proven by the "direct column-write" test below.
11
+
12
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
13
+ import { type BunTestDb, createTestDb } from "../../bun-db/__tests__/bun-test-db";
14
+ import { asRawClient, insertOne, selectMany } from "../../db/query";
15
+ import { createBooleanField, createEntity, createTextField, defineFeature } from "../../engine";
16
+ import { createRegistry } from "../../engine/registry";
17
+ import { createEventsTable } from "../../event-store";
18
+ import { rebuildProjection } from "../../pipeline";
19
+ import { createProjectionStateTable } from "../../pipeline/projection-state";
20
+ import { TestUsers, unsafeCreateEntityTable } from "../../stack";
21
+ import { ensureTemporalPolyfill } from "../../time/polyfill";
22
+ import { createEventStoreExecutor } from "../event-store-executor";
23
+ import { buildEntityTable } from "../table-builder";
24
+ import { createTenantDb, type TenantDb } from "../tenant-db";
25
+
26
+ const userEntity = createEntity({
27
+ table: "read_unreachable_users",
28
+ fields: {
29
+ email: createTextField({ required: true }),
30
+ firstName: createTextField(),
31
+ isEnabled: createBooleanField({ default: true }),
32
+ },
33
+ softDelete: true,
34
+ });
35
+
36
+ const userFeature = defineFeature("unreachabletest", (r) => {
37
+ r.entity("user", userEntity);
38
+ });
39
+
40
+ const userTable = buildEntityTable("user", userEntity);
41
+ const projectionName = "unreachabletest:projection:user-entity";
42
+ const registry = createRegistry([userFeature]);
43
+
44
+ let testDb: BunTestDb;
45
+ let tdb: TenantDb;
46
+ const adminUser = TestUsers.admin;
47
+
48
+ beforeAll(async () => {
49
+ await ensureTemporalPolyfill();
50
+ testDb = await createTestDb();
51
+ await unsafeCreateEntityTable(testDb.db, userEntity, "user");
52
+ await createEventsTable(testDb.db);
53
+ await createProjectionStateTable(testDb.db);
54
+ tdb = createTenantDb(testDb.db, adminUser.tenantId);
55
+ });
56
+
57
+ afterAll(async () => {
58
+ await testDb.cleanup();
59
+ });
60
+
61
+ beforeEach(async () => {
62
+ await asRawClient(testDb.db).unsafe(
63
+ `TRUNCATE kumiko_events, read_unreachable_users, kumiko_projections RESTART IDENTITY CASCADE`,
64
+ );
65
+ });
66
+
67
+ async function snapshotTable(): Promise<readonly Record<string, unknown>[]> {
68
+ const rows = await selectMany(
69
+ testDb.db,
70
+ userTable,
71
+ {},
72
+ { orderBy: { col: "id", direction: "asc" } },
73
+ );
74
+ return rows as readonly Record<string, unknown>[];
75
+ }
76
+
77
+ describe("assert-no-unreachable-live-rows / #722 ghost-row guard", () => {
78
+ test("clean executor-only projection rebuilds without firing the guard", async () => {
79
+ const crud = createEventStoreExecutor(userTable, userEntity, { entityName: "user" });
80
+ await crud.create({ email: "a@test.de", firstName: "Alice" }, adminUser, tdb);
81
+ await crud.create({ email: "b@test.de", firstName: "Bob" }, adminUser, tdb);
82
+ const before = await snapshotTable();
83
+
84
+ const result = await rebuildProjection(projectionName, { db: testDb.db, registry });
85
+
86
+ expect(result.eventsProcessed).toBe(2);
87
+ expect(await snapshotTable()).toEqual(before);
88
+ });
89
+
90
+ test("ghost row (no backing event) → swap aborts, live untouched", async () => {
91
+ const crud = createEventStoreExecutor(userTable, userEntity, { entityName: "user" });
92
+ const a = await crud.create({ email: "a@test.de", firstName: "Alice" }, adminUser, tdb);
93
+ if (!a.isSuccess) throw new Error("setup failed");
94
+
95
+ // Drift: clone the event-backed row into a second id WITHOUT appending an
96
+ // event — the #498 ghost. No replay can ever reconstruct it.
97
+ const [liveRow] = await selectMany(testDb.db, userTable, { id: a.data.id as string });
98
+ if (!liveRow) throw new Error("live row missing");
99
+ const ghostId = crypto.randomUUID();
100
+ // Deliberately bypass the EXECUTOR_ONLY brand: the whole point is to write
101
+ // the entity table WITHOUT going through the executor — the production bug
102
+ // the guard defends against.
103
+ const writable = userTable as unknown as Parameters<typeof insertOne>[1];
104
+ await insertOne(tdb, writable, { ...liveRow, id: ghostId, email: "ghost@test.de" });
105
+ expect(await snapshotTable()).toHaveLength(2);
106
+
107
+ await expect(rebuildProjection(projectionName, { db: testDb.db, registry })).rejects.toThrow(
108
+ /have no\s+event in the projection's source streams/,
109
+ );
110
+
111
+ // Swap never ran: both rows — including the ghost — survive.
112
+ const after = await snapshotTable();
113
+ expect(after).toHaveLength(2);
114
+ expect(after.map((r) => r["id"])).toContain(ghostId);
115
+ });
116
+
117
+ test("direct column-write on an event-backed row does NOT trip the guard", async () => {
118
+ // The row has a .created event, so it is not a ghost. Its column state was
119
+ // direct-written without an event (the #494 / blind-index-erase class) — the
120
+ // guard is event-existence-only and leaves it alone; the replay overwrites
121
+ // the column from the event, which is the intended, shipped behavior.
122
+ const crud = createEventStoreExecutor(userTable, userEntity, { entityName: "user" });
123
+ const a = await crud.create({ email: "a@test.de", firstName: "Alice" }, adminUser, tdb);
124
+ if (!a.isSuccess) throw new Error("setup failed");
125
+
126
+ await asRawClient(testDb.db).unsafe(
127
+ `UPDATE read_unreachable_users SET first_name = 'DirectWrite' WHERE id = $1`,
128
+ [a.data.id],
129
+ );
130
+
131
+ // No throw — the row is event-backed. Rebuild replays the create event.
132
+ await rebuildProjection(projectionName, { db: testDb.db, registry });
133
+
134
+ const [rebuilt] = await selectMany(testDb.db, userTable, { id: a.data.id as string });
135
+ expect(rebuilt?.["email"]).toBe("a@test.de");
136
+ // The direct write is overwritten by the replay — deliberately allowed.
137
+ expect(rebuilt?.["firstName"]).toBe("Alice");
138
+ });
139
+ });
@@ -149,6 +149,68 @@ export async function fenceLiveTable(
149
149
  await raw.unsafe(`LOCK TABLE public.${quoteTableIdent(tableName)} IN ACCESS EXCLUSIVE MODE`);
150
150
  }
151
151
 
152
+ // Ids reported when the swap is aborted — enough to locate the ghost rows
153
+ // without dumping an unbounded set into the log.
154
+ const UNREACHABLE_SAMPLE_LIMIT = 20;
155
+
156
+ // Runs INSIDE the rebuild tx, under the fence, before swapShadowIntoLive. A live
157
+ // row whose aggregate id has NO event in the projection's source streams is
158
+ // UNREACHABLE: no replay can ever reconstruct it, so the swap would silently
159
+ // drop it. That is the #498 ghost — a row direct-inserted without ever emitting
160
+ // a .created event. The static CI guard cannot see it in data that already
161
+ // exists in production, or on table identifiers it couldn't resolve; this
162
+ // catches it at cutover and aborts (tx rolls back, live untouched).
163
+ //
164
+ // Deliberately NARROW — event EXISTENCE only, not a column or row-vs-shadow
165
+ // diff. The framework legitimately makes live diverge from a fresh replay in
166
+ // several SHIPPED ways, none of which is drift:
167
+ // - a blind-index column recomputed to NULL after the subject's key is
168
+ // shredded (GDPR erase) — the NULL is the intended end state;
169
+ // - a `sensitive` column stripped from the event log by design;
170
+ // - an archived stream that stops replaying (fw#832) — the row's wipe is the
171
+ // intended tombstone behavior, reported via backfill's `failed` list;
172
+ // - a legacy column direct-written before its handler emitted events, healed
173
+ // by the #494 backfill-then-rebuild flow.
174
+ // Checking event existence INCLUDING archived streams leaves every one of them
175
+ // alone: those rows all have a real event, so they are not ghosts. Column-level
176
+ // drift detection (fail-hard vs. graceful repair) is the open question deferred
177
+ // from #722.
178
+ //
179
+ // Implicit projections only (caller-gated). aggregate_id and the entity id are
180
+ // both uuid, so the anti-join probes the events index without a cast.
181
+ export async function assertNoUnreachableLiveRows(
182
+ tx: AnyDb,
183
+ projectionName: string,
184
+ tableName: string,
185
+ aggregateTypes: readonly string[],
186
+ ): Promise<void> {
187
+ // skip: no source streams → no events could back any row anyway; a rebuild
188
+ // of a subscription-less projection swaps an empty shadow (handled upstream).
189
+ if (aggregateTypes.length === 0) return;
190
+ const raw = asRawClient(tx);
191
+ const t = quoteTableIdent(tableName);
192
+ const ghosts = await raw.unsafe<{ id: unknown }>(
193
+ `SELECT l."id" FROM public.${t} l
194
+ WHERE NOT EXISTS (
195
+ SELECT 1 FROM "kumiko_events" e
196
+ WHERE e."aggregate_id" = l."id" AND e."aggregate_type" = ANY($1::text[])
197
+ )
198
+ LIMIT ${UNREACHABLE_SAMPLE_LIMIT}`,
199
+ [aggregateTypes],
200
+ );
201
+ // skip: every live row has a backing event — nothing unreachable, swap is safe
202
+ if (ghosts.length === 0) return;
203
+ const ids = ghosts.map((r) => String(r.id));
204
+ throw new Error(
205
+ `projection-rebuild "${projectionName}": ${ids.length}+ live rows in "${tableName}" have no ` +
206
+ `event in the projection's source streams and cannot be reconstructed by replay — the swap ` +
207
+ `would silently drop them (ids: ${ids.join(", ")}). A handler direct-inserted these rows ` +
208
+ `without emitting a .created event. Fix: register the table with r.unmanagedTable(meta, ` +
209
+ `{ reason }) to opt out of rebuild, or emit the missing events. See ` +
210
+ `docs/reference/entity-write-patterns.md. Rebuild aborted; live table untouched.`,
211
+ );
212
+ }
213
+
152
214
  // Atomic swap, INSIDE the rebuild tx, AFTER replay. Schema-qualified so the
153
215
  // active shadow search_path can't redirect them. DROP without CASCADE: if any
154
216
  // object depends on the live table the swap fails loud and the whole rebuild
@@ -0,0 +1,35 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { buildDeepLinkUrl } from "../deep-link";
3
+
4
+ describe("buildDeepLinkUrl()", () => {
5
+ test("screenId only", () => {
6
+ expect(buildDeepLinkUrl("https://app.example.com", { screenId: "jobs" })).toBe(
7
+ "https://app.example.com/jobs",
8
+ );
9
+ });
10
+
11
+ test("screenId + entityId", () => {
12
+ expect(
13
+ buildDeepLinkUrl("https://app.example.com", { screenId: "jobs", entityId: "job-1" }),
14
+ ).toBe("https://app.example.com/jobs/job-1");
15
+ });
16
+
17
+ test("workspaceId + screenId + entityId, in path order", () => {
18
+ expect(
19
+ buildDeepLinkUrl("https://app.example.com", {
20
+ workspaceId: "admin",
21
+ screenId: "jobs",
22
+ entityId: "job-1",
23
+ }),
24
+ ).toBe("https://app.example.com/admin/jobs/job-1");
25
+ });
26
+
27
+ test("strips trailing slash(es) from baseUrl", () => {
28
+ expect(buildDeepLinkUrl("https://app.example.com/", { screenId: "jobs" })).toBe(
29
+ "https://app.example.com/jobs",
30
+ );
31
+ expect(buildDeepLinkUrl("https://app.example.com//", { screenId: "jobs" })).toBe(
32
+ "https://app.example.com/jobs",
33
+ );
34
+ });
35
+ });
@@ -0,0 +1,23 @@
1
+ // Deep-Link-URL-Builder für Notification-Templates (#449, Lazy-Scope: nur
2
+ // Notification→Screen, kein Permalink-Sharing-Layer). Server-seitig nutzbar
3
+ // (kein React) — der Renderer hat mit `formatPath`
4
+ // (packages/renderer/src/app/nav.tsx) dasselbe Pfad-Format fürs Client-Routing,
5
+ // hier bewusst dupliziert statt importiert: `renderer` zieht React als
6
+ // Dependency, Notification-Data-Fns laufen server-seitig im Write-Handler.
7
+ //
8
+ // baseUrl kommt vom App-Autor (analog `AuthMailOptions.baseUrl` /
9
+ // magic-link-mail.ts appendToken) — kein Auto-Detect, kein Env-Var-Read hier.
10
+
11
+ export type DeepLinkTarget = {
12
+ readonly screenId: string;
13
+ readonly entityId?: string;
14
+ readonly workspaceId?: string;
15
+ };
16
+
17
+ export function buildDeepLinkUrl(baseUrl: string, target: DeepLinkTarget): string {
18
+ const segments: string[] = [];
19
+ if (target.workspaceId !== undefined) segments.push(target.workspaceId);
20
+ segments.push(target.screenId);
21
+ if (target.entityId !== undefined) segments.push(target.entityId);
22
+ return `${baseUrl.replace(/\/+$/, "")}/${segments.join("/")}`;
23
+ }
@@ -38,6 +38,8 @@ export {
38
38
  export type { App, AppConfig } from "./create-app";
39
39
  export { createApp } from "./create-app";
40
40
  export { crossTenantOverrideDenied } from "./cross-tenant";
41
+ export type { DeepLinkTarget } from "./deep-link";
42
+ export { buildDeepLinkUrl } from "./deep-link";
41
43
  export { defineFeature } from "./define-feature";
42
44
  export type {
43
45
  QueryHandlerDefinition,
@@ -0,0 +1,82 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { createRegistry, defineFeature } from "../../engine";
3
+ import type { AppContext, Registry } from "../../engine/types";
4
+ import { createPrometheusMeter, registerStandardMetrics } from "../../observability";
5
+ import { createTestRedis, type TestRedis } from "../../stack";
6
+ import { sleep } from "../../testing";
7
+ import { createJobRunner } from "../job-runner";
8
+
9
+ let testRedis: TestRedis;
10
+ let redisUrl: string;
11
+
12
+ const testFeature = defineFeature("test-queue-depth", (r) => {
13
+ r.job("noop", { trigger: { manual: true } }, async () => {});
14
+ });
15
+
16
+ beforeAll(async () => {
17
+ testRedis = await createTestRedis();
18
+ redisUrl = `redis://${testRedis.redis.options.host}:${testRedis.redis.options.port}/${testRedis.redis.options.db}`;
19
+ });
20
+
21
+ afterAll(async () => {
22
+ await testRedis.cleanup();
23
+ });
24
+
25
+ describe("job-runner — kumiko_job_queue_depth", () => {
26
+ test("start() polls BullMQ counts into the gauge, stop() tears the poller down", async () => {
27
+ const registry: Registry = createRegistry([testFeature]);
28
+ const meter = createPrometheusMeter();
29
+ registerStandardMetrics(meter);
30
+ const context: AppContext = { meter };
31
+ const queueNamePrefix = `kumiko-test-qd-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
32
+
33
+ const runner = createJobRunner({
34
+ registry,
35
+ context,
36
+ redisUrl,
37
+ consumerLane: "worker",
38
+ queueNamePrefix,
39
+ });
40
+
41
+ try {
42
+ await runner.start();
43
+
44
+ const snapshot = meter.snapshot().get("kumiko_job_queue_depth");
45
+ expect(snapshot).toBeDefined();
46
+ const waitingSlot = snapshot?.slots.find(
47
+ (s) => s.labels?.["lane"] === "worker" && s.labels?.["state"] === "waiting",
48
+ );
49
+ expect(waitingSlot).toBeDefined();
50
+ expect((waitingSlot as { value: number }).value).toBe(0);
51
+ // BullMQ's fresh Worker connection is still settling right after
52
+ // start() returns (no boot/cron job here to have already warmed it
53
+ // up, unlike every other scenario in this suite) — stop() immediately
54
+ // after start() races the Worker's own connection teardown and throws
55
+ // an unrelated "Connection is closed" from ioredis. Production runners
56
+ // live far longer than this; the race is a test-only artifact of
57
+ // start()+stop() with zero work in between.
58
+ await sleep(50);
59
+ } finally {
60
+ await runner.stop();
61
+ const keys = await testRedis.redis.keys(`bull:${queueNamePrefix}-worker:*`);
62
+ if (keys.length > 0) await testRedis.redis.del(...keys);
63
+ }
64
+ });
65
+
66
+ test("enqueuer-only runner (no consumerLane) never polls — no gauge slots", async () => {
67
+ const registry: Registry = createRegistry([testFeature]);
68
+ const meter = createPrometheusMeter();
69
+ registerStandardMetrics(meter);
70
+ const context: AppContext = { meter };
71
+ const queueNamePrefix = `kumiko-test-qd-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
72
+
73
+ const runner = createJobRunner({ registry, context, redisUrl, queueNamePrefix });
74
+ try {
75
+ await runner.start();
76
+ const snapshot = meter.snapshot().get("kumiko_job_queue_depth");
77
+ expect(snapshot?.slots.length ?? 0).toBe(0);
78
+ } finally {
79
+ await runner.stop();
80
+ }
81
+ });
82
+ });
@@ -11,7 +11,13 @@ import {
11
11
  SYSTEM_TENANT_ID,
12
12
  } from "../engine/types";
13
13
  import type { Logger } from "../logging/types";
14
- import { getFallbackTracer, type SerializedTraceContext, type Tracer } from "../observability";
14
+ import {
15
+ emitJobQueueDepth,
16
+ getFallbackTracer,
17
+ type Meter,
18
+ type SerializedTraceContext,
19
+ type Tracer,
20
+ } from "../observability";
15
21
  import { createDistributedLock, type DistributedLock } from "../pipeline/distributed-lock";
16
22
  import { RedisKeys } from "../pipeline/redis-keys";
17
23
 
@@ -119,6 +125,29 @@ function captureTraceContext(tracer: Tracer): SerializedTraceContext | undefined
119
125
  return { traceId: span.traceId, spanId: span.spanId };
120
126
  }
121
127
 
128
+ const QUEUE_DEPTH_POLL_INTERVAL_MS = 15_000;
129
+
130
+ // kumiko_job_queue_depth: only the lane's own consumer polls — the count is
131
+ // a global Redis-backed value (BullMQ, not per-process), so one reporter per
132
+ // lane is enough. Fires once immediately (metrics exist before the first
133
+ // poll tick) then on an interval; the caller stores + clears the handle.
134
+ async function startQueueDepthPolling(
135
+ queue: Queue,
136
+ lane: JobRunIn,
137
+ meter: Meter,
138
+ ): Promise<ReturnType<typeof setInterval>> {
139
+ const pollQueueDepth = async (): Promise<void> => {
140
+ try {
141
+ const counts = await queue.getJobCounts();
142
+ emitJobQueueDepth(meter, lane, counts);
143
+ } catch {
144
+ // skip: transient Redis hiccup — next poll retries
145
+ }
146
+ };
147
+ await pollQueueDepth();
148
+ return setInterval(() => void pollQueueDepth(), QUEUE_DEPTH_POLL_INTERVAL_MS);
149
+ }
150
+
122
151
  function parseRedisOpts(url: string): { host: string; port: number; db?: number | undefined } {
123
152
  const parsed = new URL(url);
124
153
  const result: { host: string; port: number; db?: number | undefined } = {
@@ -181,6 +210,7 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
181
210
  worker: new Queue(queueNameFor(queueNamePrefix, "worker"), { connection: redisOpts }),
182
211
  };
183
212
  let worker: Worker | null = null;
213
+ let queueDepthTimer: ReturnType<typeof setInterval> | null = null;
184
214
  // Forward reference to the runner's own API, exposed on the job-handler ctx
185
215
  // so a handler can dispatch a follow-up job (job→job chaining, e.g.
186
216
  // delivery.render → delivery.send). Assigned just before return; reads happen
@@ -431,9 +461,19 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
431
461
  await consumerQueue.add(bootName, {}, { jobId: `boot-${name.replace(/\./g, "-")}` });
432
462
  }
433
463
  }
464
+
465
+ // Skipped when no meter is wired (context.meter is optional, e.g. in
466
+ // tests without an observability provider).
467
+ if (context.meter) {
468
+ queueDepthTimer = await startQueueDepthPolling(consumerQueue, consumerLane, context.meter);
469
+ }
434
470
  },
435
471
 
436
472
  async stop(): Promise<void> {
473
+ if (queueDepthTimer) {
474
+ clearInterval(queueDepthTimer);
475
+ queueDepthTimer = null;
476
+ }
437
477
  if (worker) {
438
478
  await worker.close();
439
479
  worker = null;
@@ -52,6 +52,7 @@ export {
52
52
  emitEventConsumerPassOutcome,
53
53
  emitEventDispatcherListenConnected,
54
54
  emitHttpRequest,
55
+ emitJobQueueDepth,
55
56
  registerStandardMetrics,
56
57
  STANDARD_METRIC_DEFS,
57
58
  } from "./standard-metrics";
@@ -94,6 +94,21 @@ export const STANDARD_METRIC_DEFS: readonly MetricDefinition[] = [
94
94
  "1 if the event-dispatcher holds an active PG LISTEN subscription on the events channel, 0 otherwise.",
95
95
  labels: [],
96
96
  },
97
+ // BullMQ backlog per lane + state (waiting/active/delayed/failed/…). Only
98
+ // the process that owns a lane's consumerLane polls it (job-runner.ts
99
+ // start()) — the count is a global Redis-backed value, no per-instance
100
+ // label needed. A growing "waiting" count means the lane's worker can't
101
+ // keep up with dispatch; "failed" trending up means jobs are erroring
102
+ // silently. Motivated by apps that run every job on the "api" lane
103
+ // (no dedicated worker consumer) — fanout/reconcile jobs then share
104
+ // Redis-queue capacity with request-handling, and contention is
105
+ // otherwise invisible until requests slow down.
106
+ {
107
+ name: "kumiko_job_queue_depth",
108
+ type: "gauge",
109
+ description: "BullMQ job counts per lane and state.",
110
+ labels: ["lane", "state"],
111
+ },
97
112
  ] as const;
98
113
 
99
114
  export function registerStandardMetrics(meter: Meter): void {
@@ -211,3 +226,16 @@ export function emitEventConsumerPassOutcome(
211
226
  });
212
227
  }
213
228
  }
229
+
230
+ // counts: BullMQ's Queue.getJobCounts() result — a state → count record
231
+ // (waiting/active/delayed/failed/completed/…), passed through unmodified so
232
+ // this stays agnostic of exactly which states BullMQ reports.
233
+ export function emitJobQueueDepth(
234
+ meter: Meter,
235
+ lane: string,
236
+ counts: Readonly<Record<string, number>>,
237
+ ): void {
238
+ for (const [state, count] of Object.entries(counts)) {
239
+ meter.gauge("kumiko_job_queue_depth").set(count, { lane, state });
240
+ }
241
+ }
@@ -8,6 +8,7 @@ import {
8
8
  } from "../db/queries/projection-rebuild";
9
9
  import {
10
10
  assertLiveColumnsMatchMeta,
11
+ assertNoUnreachableLiveRows,
11
12
  buildShadowTable,
12
13
  ensureRebuildSchema,
13
14
  fenceLiveTable,
@@ -326,6 +327,12 @@ export async function rebuildProjection(
326
327
  if (skipped.length > 0) {
327
328
  await recordRebuildDeadLetters(tx, projectionName, skipped);
328
329
  }
330
+ // Guard the swap: abort if the live table holds a row no event can
331
+ // reconstruct (#498 ghost — direct-inserted without a .created event),
332
+ // which the swap would silently drop. Implicit projections only.
333
+ if (projection.isImplicit === true) {
334
+ await assertNoUnreachableLiveRows(tx, projectionName, meta.tableName, sourcesList);
335
+ }
329
336
  await finalizeProjectionRebuild(tx, projectionName, lastProcessedEventId);
330
337
  await swapShadowIntoLive(tx, meta.tableName);
331
338
  });