@cosmicdrift/kumiko-framework 0.146.4 → 0.147.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.
Files changed (68) hide show
  1. package/package.json +6 -2
  2. package/src/api/auth-routes.ts +32 -67
  3. package/src/api/routes.ts +1 -3
  4. package/src/bun-db/__tests__/PATTERN.md +0 -1
  5. package/src/db/__tests__/assert-no-unreachable-live-rows.integration.test.ts +29 -3
  6. package/src/db/__tests__/schema-inspection.test.ts +7 -0
  7. package/src/db/event-store-executor-context.ts +398 -0
  8. package/src/db/event-store-executor-read.ts +276 -0
  9. package/src/db/event-store-executor-write.ts +615 -0
  10. package/src/db/event-store-executor.ts +29 -1166
  11. package/src/db/queries/shadow-swap.ts +3 -1
  12. package/src/db/schema-inspection.ts +1 -1
  13. package/src/engine/__tests__/boot-validator-dashboard.test.ts +10 -0
  14. package/src/engine/__tests__/engine.test.ts +45 -1
  15. package/src/engine/__tests__/event-migration-declarative.test.ts +6 -0
  16. package/src/engine/__tests__/membership-roles.test.ts +4 -10
  17. package/src/engine/__tests__/screen.test.ts +26 -0
  18. package/src/engine/boot-validator/entity-list-screens.ts +1 -1
  19. package/src/engine/boot-validator/gdpr-storage.ts +1 -1
  20. package/src/engine/boot-validator/index.ts +12 -8
  21. package/src/engine/boot-validator/nav.ts +120 -0
  22. package/src/engine/boot-validator/{screens-nav.ts → screens.ts} +12 -190
  23. package/src/engine/boot-validator/workspaces.ts +68 -0
  24. package/src/engine/define-feature.ts +77 -1011
  25. package/src/engine/feature-ast/__tests__/fixtures/cross-file-registrar/feature.ts +9 -0
  26. package/src/engine/feature-ast/__tests__/fixtures/cross-file-registrar/screens.ts +4 -0
  27. package/src/engine/feature-ast/__tests__/parse-real-features.test.ts +18 -8
  28. package/src/engine/feature-ast/__tests__/parse.test.ts +291 -2
  29. package/src/engine/feature-ast/__tests__/patcher.test.ts +1 -1
  30. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +73 -0
  31. package/src/engine/feature-ast/extractors/round1.ts +3 -3
  32. package/src/engine/feature-ast/extractors/round4.ts +45 -10
  33. package/src/engine/feature-ast/extractors/shared.ts +64 -6
  34. package/src/engine/feature-ast/parse.ts +123 -10
  35. package/src/engine/feature-ast/patterns.ts +14 -7
  36. package/src/engine/feature-ast/render.ts +28 -7
  37. package/src/engine/feature-builder-state.ts +165 -0
  38. package/src/engine/feature-config-events-jobs.ts +303 -0
  39. package/src/engine/feature-entity-handlers.ts +161 -0
  40. package/src/engine/feature-ui-extensions.ts +413 -0
  41. package/src/engine/index.ts +0 -2
  42. package/src/engine/pattern-library/library.ts +44 -1131
  43. package/src/engine/pattern-library/mixed-schemas.ts +484 -0
  44. package/src/engine/pattern-library/opaque-schemas.ts +124 -0
  45. package/src/engine/pattern-library/shared-fields.ts +80 -0
  46. package/src/engine/pattern-library/static-schemas.ts +456 -0
  47. package/src/engine/registry-facade.ts +349 -0
  48. package/src/engine/registry-ingest.ts +473 -0
  49. package/src/engine/registry-state.ts +388 -0
  50. package/src/engine/registry-validate.ts +660 -0
  51. package/src/engine/registry.ts +79 -1695
  52. package/src/engine/types/screen.ts +4 -2
  53. package/src/engine/types/tree-node.ts +2 -5
  54. package/src/event-store/snapshot.ts +7 -7
  55. package/src/i18n/required-surface-keys.ts +2 -0
  56. package/src/jobs/job-runner.ts +1 -1
  57. package/src/observability/standard-metrics.ts +4 -3
  58. package/src/pipeline/dispatch-batch.ts +3 -3
  59. package/src/pipeline/dispatch-shared.ts +17 -10
  60. package/src/pipeline/event-dispatcher-admin.ts +289 -0
  61. package/src/pipeline/event-dispatcher-delivery.ts +264 -0
  62. package/src/pipeline/event-dispatcher.ts +28 -540
  63. package/src/pipeline/projection-rebuild.ts +1 -1
  64. package/src/stack/__tests__/setup-test-stack-jobs.integration.test.ts +95 -0
  65. package/src/stack/test-stack.ts +46 -1
  66. package/src/testing/boot-validator-fixture.ts +1 -2
  67. package/src/engine/__tests__/deep-link.test.ts +0 -35
  68. package/src/engine/deep-link.ts +0 -23
@@ -346,7 +346,7 @@ export type DashboardStatPanel = {
346
346
  readonly valueField: string;
347
347
  readonly subField?: string;
348
348
  readonly toneField?: string;
349
- /** Optionaler Delta-Chip (z.B. "↓ 23 %") neben dem Label. Nur wenn BEIDE
349
+ /** Optionaler Delta-Chip (z.B. "↓23 %") neben dem Label. Nur wenn BEIDE
350
350
  * Felder gesetzt sind UND der Query-Handler sie liefert, rendert der Chip
351
351
  * — sonst bleibt die Kachel wie ohne Delta. `deltaToneField` fällt auf
352
352
  * `toneField`/"default" zurück, wenn ungesetzt. */
@@ -361,7 +361,9 @@ export type DashboardStatPanel = {
361
361
  * für ein reines Icon). */
362
362
  readonly icon?: PlatformComponent;
363
363
  /** Statischer CSS-Farbwert (z.B. "var(--color-debt)") für den Icon-Chip —
364
- * Passthrough an die Kachel, keine Registry, kein Lookup. */
364
+ * Passthrough an die Kachel, keine Registry, kein Lookup. Wirkt NUR wenn
365
+ * `icon` gesetzt ist (StatCard rendert den Chip nur zusammen mit einem
366
+ * Icon) — ohne icon wird der Wert still verworfen. */
365
367
  readonly accentColor?: string;
366
368
  };
367
369
 
@@ -1,6 +1,5 @@
1
- // TreeNode — single Knoten im Visual-Tree (opt-in Workspace mit
2
- // `navigation: "tree"`). Provider liefern entweder statische
3
- // readonly TreeNode[] oder dynamische TreeChildrenSubscribe.
1
+ // TreeNode — single Knoten im Client-navProvider-Tree. Provider liefern
2
+ // entweder statische readonly TreeNode[] oder dynamische TreeChildrenSubscribe.
4
3
  //
5
4
  // **Mental-Modell** (VS-Code-Explorer):
6
5
  // [icon] [label] [...hover-actions]
@@ -20,8 +19,6 @@
20
19
  // Emits beliebig oft (z.B. wenn Entity-Row neu erscheint via SSE).
21
20
  // Spielt natürlich mit existing SSE-Frame: ein Provider kann intern
22
21
  // auf Entity-Update-Events abonnieren und bei Änderung emit() aufrufen.
23
- //
24
- // Siehe docs/plans/architecture/visual-tree.md A4.
25
22
 
26
23
  import type { TargetRef } from "./target-ref";
27
24
 
@@ -209,13 +209,6 @@ export async function loadAggregateWithSnapshot<TState extends Record<string, un
209
209
  initial: TState,
210
210
  options?: LoadAggregateWithSnapshotOptions,
211
211
  ): Promise<LoadAggregateWithSnapshotResult<TState>> {
212
- if (!options?.includeArchived) {
213
- const archived = await isStreamArchived(db, tenantId, aggregateId);
214
- if (archived) {
215
- return { state: initial, version: 0, snapshotHit: false };
216
- }
217
- }
218
- const shapeVersion = options?.snapshotVersion ?? 1;
219
212
  if (
220
213
  options?.snapshotEvery !== undefined &&
221
214
  (!Number.isInteger(options.snapshotEvery) || options.snapshotEvery < 1)
@@ -224,6 +217,13 @@ export async function loadAggregateWithSnapshot<TState extends Record<string, un
224
217
  `loadAggregateWithSnapshot: snapshotEvery must be an integer >= 1, got ${String(options.snapshotEvery)}`,
225
218
  );
226
219
  }
220
+ if (!options?.includeArchived) {
221
+ const archived = await isStreamArchived(db, tenantId, aggregateId);
222
+ if (archived) {
223
+ return { state: initial, version: 0, snapshotHit: false };
224
+ }
225
+ }
226
+ const shapeVersion = options?.snapshotVersion ?? 1;
227
227
  const stored = await loadLatestSnapshot<TState>(db, aggregateId, tenantId);
228
228
  const snapshot = stored && stored.snapshotVersion === shapeVersion ? stored : null;
229
229
  const baseState = snapshot ? snapshot.state : initial;
@@ -86,11 +86,13 @@ function pushDashboardPanelKeys(out: Set<string>, panel: DashboardPanelDefinitio
86
86
  if (normalized.label !== undefined) pushKey(out, normalized.label);
87
87
  }
88
88
  }
89
+ if (panel.kind === "feed" && panel.emptyLabel !== undefined) pushKey(out, panel.emptyLabel);
89
90
  }
90
91
 
91
92
  function pushDashboardFilterKeys(out: Set<string>, filter: DashboardFilterDefinition): void {
92
93
  pushKey(out, filter.label);
93
94
  if (filter.allLabel !== undefined) pushKey(out, filter.allLabel);
95
+ if (filter.placeholder !== undefined) pushKey(out, filter.placeholder);
94
96
  for (const opt of filter.options ?? []) pushKey(out, opt.label);
95
97
  }
96
98
 
@@ -138,7 +138,7 @@ async function startQueueDepthPolling(
138
138
  ): Promise<ReturnType<typeof setInterval>> {
139
139
  const pollQueueDepth = async (): Promise<void> => {
140
140
  try {
141
- const counts = await queue.getJobCounts();
141
+ const counts = await queue.getJobCounts("waiting", "active", "delayed", "failed", "paused");
142
142
  emitJobQueueDepth(meter, lane, counts);
143
143
  } catch {
144
144
  // skip: transient Redis hiccup — next poll retries
@@ -227,9 +227,10 @@ export function emitEventConsumerPassOutcome(
227
227
  }
228
228
  }
229
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.
230
+ // counts: BullMQ's Queue.getJobCounts() result, filtered by the caller to
231
+ // backlog-relevant states (waiting/active/delayed/failed/paused) "completed"
232
+ // grows unboundedly without removeOnComplete and would make the gauge series
233
+ // monotonically increasing, useless as a backlog signal.
233
234
  export function emitJobQueueDepth(
234
235
  meter: Meter,
235
236
  lane: string,
@@ -169,9 +169,9 @@ export async function runBatch(
169
169
  }
170
170
 
171
171
  // Unwrap a BatchResult into a single WriteResult for write()/command().
172
- // Picks the last result if present (the failing one for failures, the only
173
- // one for successful single writes). Falls back to a synthetic error if the
174
- // batch didn't produce any results (unexpected).
172
+ // Picks the first result on success (the only one for a single write), the
173
+ // failing one on failure. Falls back to a synthetic error if the batch
174
+ // didn't produce any results (unexpected).
175
175
  export function unwrapSingle(batchResult: BatchResult): WriteResult {
176
176
  if (batchResult.isSuccess) {
177
177
  return (
@@ -147,7 +147,7 @@ export function buildHandlerContext(
147
147
  afterCommitHooks?: AfterCommitHook[],
148
148
  includeDeleted?: boolean,
149
149
  ): HandlerContext {
150
- const { registry, appContext: context, effectiveFeatures } = ctx;
150
+ const { registry, appContext: context, effectiveFeatures, jobRunner } = ctx;
151
151
  const isSystem = registry.isHandlerSystemScoped(type);
152
152
  // The outer dispatcher receives a DbConnection from the server/stack;
153
153
  // AppContext's `db` union also allows TenantDb (for downstream hook calls),
@@ -483,6 +483,13 @@ export function buildHandlerContext(
483
483
  // Propagate the feature-toggle resolver so the lifecycle pipeline,
484
484
  // MSP runner, and ctx.hasFeature all pull from the same source.
485
485
  ...(effectiveFeatures && { effectiveFeatures }),
486
+ // Symmetric with job-runner.ts's jobContext: a manual write handler
487
+ // reaches `ctx.jobRunner.dispatch(...)` the same way a follow-up job
488
+ // does (bundled jobs feature's trigger.write.ts is the canonical
489
+ // caller). Was never actually wired here — DispatchContext.jobRunner
490
+ // only fed the internal afterCommit auto-trigger hook in
491
+ // executeWriteInner, never the handler-facing ctx (#983).
492
+ ...(jobRunner && { jobRunner }),
486
493
  // ctx.user als Convenience-Alias auf event.user. Der typisch-
487
494
  // intuitive Pfad „der Context kennt seinen User" — ohne den
488
495
  // schreiben Handler `event.user.tenantId` und brechen sich die
@@ -559,15 +566,6 @@ export async function runHandlerInstrumented<T>(
559
566
  }
560
567
  }
561
568
 
562
- // L3 rate limit gate. Called by both query and write paths before
563
- // access-check. Reasoning:
564
- // - handler without rateLimit → no-op
565
- // - app booted without rateLimit resolver → InternalError so the
566
- // misconfig surfaces immediately, not on first 429
567
- // - bucket builder returns "skip" (e.g. ip-based but no client IP):
568
- // pass through. ip-modes are commonly used at L1/L2 middleware
569
- // where the IP comes from Hono directly; falling back to "skip"
570
- // here keeps non-HTTP entry-points (jobs, MSPs) functional.
571
569
  // Feature-toggle gate. Returns the error to fold into a WriteFailure in the
572
570
  // write path, or throws for the query path (where throws flow through the
573
571
  // same outer instrumentation wrapper as other dispatcher errors).
@@ -608,6 +606,15 @@ export async function ensureFeatureEnabled(
608
606
  if (err) throw err;
609
607
  }
610
608
 
609
+ // L3 rate limit gate. Called by both query and write paths before
610
+ // access-check. Reasoning:
611
+ // - handler without rateLimit → no-op
612
+ // - app booted without rateLimit resolver → InternalError so the
613
+ // misconfig surfaces immediately, not on first 429
614
+ // - bucket builder returns "skip" (e.g. ip-based but no client IP):
615
+ // pass through. ip-modes are commonly used at L1/L2 middleware
616
+ // where the IP comes from Hono directly; falling back to "skip"
617
+ // here keeps non-HTTP entry-points (jobs, MSPs) functional.
611
618
  export async function enforceRateLimit(
612
619
  ctx: DispatchContext,
613
620
  rateLimit: import("../engine/types").RateLimitOption | undefined,
@@ -0,0 +1,289 @@
1
+ import type { DbConnection, DbTx } from "../db/connection";
2
+ import {
3
+ advanceConsumerPastEventReturning,
4
+ updateConsumerStatusReturning,
5
+ } from "../db/queries/event-consumer";
6
+ import { selectNextEventIdAfter } from "../db/queries/event-store";
7
+ import { coerceRow, extractTableInfo, selectMany } from "../db/query";
8
+ import { getEventsHighWaterMark } from "../event-store";
9
+ import { eventConsumerStateTable, SHARED_INSTANCE_SENTINEL } from "./event-consumer-state";
10
+ import type { ConsumerStateRow, ConsumerStateRowShape } from "./event-dispatcher-delivery";
11
+
12
+ // --- Ops recovery surface ---
13
+ //
14
+ // These are intentionally verb-distinct; each maps to a CLI sub-command.
15
+ // They all target a single consumer row by name. Every call returns the
16
+ // state after the write so the CLI can echo what actually changed.
17
+ //
18
+ // Semantics:
19
+ // restartConsumer status="dead" → "idle", attempts=0, lastError=null.
20
+ // Cursor unchanged → next pass retries the SAME event
21
+ // that poisoned the consumer. For transient failures.
22
+ // disableConsumer status=* → "disabled". Dispatcher skips this consumer
23
+ // until enableConsumer() flips it back.
24
+ // enableConsumer status="disabled" → "idle". No-op on any other state.
25
+ // skipPoisonEvent cursor advances past the first event after the
26
+ // current cursor (the one that's failing). attempts=0,
27
+ // lastError=null, status="idle". For events that will
28
+ // never succeed (broken payload, removed feature code).
29
+
30
+ function normalizeConsumerState(row: ConsumerStateRowShape): ConsumerRecoveryState {
31
+ return {
32
+ name: row.name,
33
+ instanceId: row.instanceId,
34
+ status: row.status,
35
+ lastProcessedEventId: row.lastProcessedEventId,
36
+ attempts: row.attempts,
37
+ lastError: row.lastError,
38
+ updatedAt: row.updatedAt,
39
+ };
40
+ }
41
+
42
+ export type ConsumerRecoveryState = {
43
+ readonly name: string;
44
+ readonly instanceId: string;
45
+ readonly status: string;
46
+ readonly lastProcessedEventId: bigint;
47
+ readonly attempts: number;
48
+ readonly lastError: string | null;
49
+ readonly updatedAt: Temporal.Instant;
50
+ };
51
+
52
+ // Ops calls default to the SHARED_INSTANCE_SENTINEL row — that's the only
53
+ // row shared-delivery consumers have, so legacy CLI invocations without
54
+ // --instance-id keep working. Per-instance consumers require an explicit
55
+ // instanceId: picking one of N shards arbitrarily ("first row wins") or
56
+ // mutating all shards simultaneously ("bounce every instance") are both
57
+ // worse than a loud missing-arg error on the CLI.
58
+ async function requireConsumerRow(
59
+ db: DbConnection,
60
+ name: string,
61
+ instanceId: string,
62
+ ): Promise<ConsumerStateRowShape> {
63
+ const [row] = await selectMany<ConsumerStateRow>(db, eventConsumerStateTable, {
64
+ name,
65
+ instanceId,
66
+ });
67
+ if (!row) {
68
+ throw new Error(
69
+ `Consumer "${name}" (instance_id="${instanceId}") has no state row — it hasn't run yet, the name is misspelled, or the instance is misspelled. ` +
70
+ `For per-instance consumers pass the instance_id explicitly; shared consumers use the default.`,
71
+ );
72
+ }
73
+ return row;
74
+ }
75
+
76
+ async function applyConsumerStatusTransition(
77
+ db: DbConnection,
78
+ name: string,
79
+ instanceId: string,
80
+ targetStatus: "idle" | "disabled",
81
+ ): Promise<ConsumerRecoveryState> {
82
+ const raw = await updateConsumerStatusReturning(db, name, instanceId, targetStatus);
83
+ const updated =
84
+ raw && (coerceRow(raw, extractTableInfo(eventConsumerStateTable)) as ConsumerStateRow);
85
+ if (!updated) {
86
+ throw new Error(
87
+ `Consumer "${name}" (instance_id="${instanceId}") vanished between read and write — retry.`,
88
+ );
89
+ }
90
+ return normalizeConsumerState(updated);
91
+ }
92
+
93
+ export async function restartConsumer(
94
+ db: DbConnection,
95
+ name: string,
96
+ instanceId: string = SHARED_INSTANCE_SENTINEL,
97
+ ): Promise<ConsumerRecoveryState> {
98
+ const before = await requireConsumerRow(db, name, instanceId);
99
+ if (before.status !== "dead") {
100
+ throw new Error(
101
+ `Consumer "${name}" (instance_id="${instanceId}") is not dead (status="${before.status}"). Restart only applies to dead consumers; use "enable" for a disabled one.`,
102
+ );
103
+ }
104
+ return applyConsumerStatusTransition(db, name, instanceId, "idle");
105
+ }
106
+
107
+ export async function disableConsumer(
108
+ db: DbConnection,
109
+ name: string,
110
+ instanceId: string = SHARED_INSTANCE_SENTINEL,
111
+ ): Promise<ConsumerRecoveryState> {
112
+ await requireConsumerRow(db, name, instanceId);
113
+ return applyConsumerStatusTransition(db, name, instanceId, "disabled");
114
+ }
115
+
116
+ export async function enableConsumer(
117
+ db: DbConnection,
118
+ name: string,
119
+ instanceId: string = SHARED_INSTANCE_SENTINEL,
120
+ ): Promise<ConsumerRecoveryState> {
121
+ const before = await requireConsumerRow(db, name, instanceId);
122
+ if (before.status !== "disabled") {
123
+ throw new Error(
124
+ `Consumer "${name}" (instance_id="${instanceId}") is not disabled (status="${before.status}"). Enable only flips disabled → idle; use "restart" for a dead consumer.`,
125
+ );
126
+ }
127
+ return applyConsumerStatusTransition(db, name, instanceId, "idle");
128
+ }
129
+
130
+ // skipPoisonEvent advances the cursor past the first event after the
131
+ // current cursor. Single TX so concurrent dispatcher passes can't double-
132
+ // advance. If no event exists past the cursor, there is nothing to skip —
133
+ // treat as idempotent no-op (cursor already at head).
134
+ export async function skipPoisonEvent(
135
+ db: DbConnection,
136
+ name: string,
137
+ instanceId: string = SHARED_INSTANCE_SENTINEL,
138
+ ): Promise<ConsumerRecoveryState & { readonly skippedEventId: bigint | null }> {
139
+ const before = await requireConsumerRow(db, name, instanceId);
140
+ return db.begin(async (tx: DbTx) => {
141
+ const poisonId = await selectNextEventIdAfter(tx, before.lastProcessedEventId);
142
+ if (poisonId === null) {
143
+ const [unchanged] = await selectMany<ConsumerStateRow>(tx, eventConsumerStateTable, {
144
+ name,
145
+ instanceId,
146
+ });
147
+ if (!unchanged)
148
+ throw new Error(`Consumer "${name}" (instance_id="${instanceId}") vanished — retry.`);
149
+ return { ...normalizeConsumerState(unchanged), skippedEventId: null };
150
+ }
151
+ const raw = await advanceConsumerPastEventReturning(tx, name, instanceId, poisonId);
152
+ const updated =
153
+ raw && (coerceRow(raw, extractTableInfo(eventConsumerStateTable)) as ConsumerStateRow);
154
+ if (!updated)
155
+ throw new Error(
156
+ `Consumer "${name}" (instance_id="${instanceId}") vanished mid-skip — retry.`,
157
+ );
158
+ return { ...normalizeConsumerState(updated), skippedEventId: poisonId };
159
+ });
160
+ }
161
+
162
+ // Read-only status for one consumer shard — CLI surface.
163
+ export async function getConsumerState(
164
+ db: DbConnection,
165
+ name: string,
166
+ instanceId: string = SHARED_INSTANCE_SENTINEL,
167
+ ): Promise<{
168
+ readonly name: string;
169
+ readonly instanceId: string;
170
+ readonly status: string;
171
+ readonly lastProcessedEventId: bigint;
172
+ readonly attempts: number;
173
+ readonly lastError: string | null;
174
+ readonly updatedAt: Temporal.Instant;
175
+ } | null> {
176
+ const [row] = await selectMany<ConsumerStateRow>(db, eventConsumerStateTable, {
177
+ name,
178
+ instanceId,
179
+ });
180
+ if (!row) return null;
181
+ return {
182
+ name: row.name,
183
+ instanceId: row.instanceId,
184
+ status: row.status,
185
+ lastProcessedEventId: row.lastProcessedEventId,
186
+ attempts: row.attempts,
187
+ lastError: row.lastError,
188
+ updatedAt: row.updatedAt,
189
+ };
190
+ }
191
+
192
+ // List every consumer the registry knows about, joined with all shard rows
193
+ // from the state table. One entry per (name, instance_id) shard. Consumers
194
+ // that have never run appear with status="never-run" and instance_id =
195
+ // SHARED_INSTANCE_SENTINEL — a placeholder, because without a running
196
+ // dispatcher we can't know the instance-ids of per-instance consumers yet.
197
+ // Mirrors listProjectionsWithState — the registry (not the DB) is the
198
+ // source-of-truth for which consumer-names exist; the DB is the source-
199
+ // of-truth for which instance-shards have been seen.
200
+ export async function listConsumersWithState(
201
+ db: DbConnection,
202
+ registeredNames: readonly string[],
203
+ ): Promise<
204
+ ReadonlyArray<{
205
+ readonly name: string;
206
+ readonly instanceId: string;
207
+ readonly status: string;
208
+ readonly lastProcessedEventId: bigint;
209
+ readonly attempts: number;
210
+ readonly lastError: string | null;
211
+ }>
212
+ > {
213
+ const stateRows = await selectMany<ConsumerStateRow>(db, eventConsumerStateTable);
214
+ const registered = new Set(registeredNames);
215
+
216
+ // Materialize one output row per (name, instance_id). Registered names
217
+ // without any shard (never-run) get a placeholder row so ops can still
218
+ // see the name exists.
219
+ const out: Array<{
220
+ name: string;
221
+ instanceId: string;
222
+ status: string;
223
+ lastProcessedEventId: bigint;
224
+ attempts: number;
225
+ lastError: string | null;
226
+ }> = [];
227
+
228
+ const seenNames = new Set<string>();
229
+ for (const r of stateRows) {
230
+ if (!registered.has(r.name)) continue; // stale row from an older deploy
231
+ seenNames.add(r.name);
232
+ out.push({
233
+ name: r.name,
234
+ instanceId: r.instanceId,
235
+ status: r.status,
236
+ lastProcessedEventId: r.lastProcessedEventId,
237
+ attempts: r.attempts,
238
+ lastError: r.lastError,
239
+ });
240
+ }
241
+ for (const name of registeredNames) {
242
+ if (seenNames.has(name)) continue;
243
+ out.push({
244
+ name,
245
+ instanceId: SHARED_INSTANCE_SENTINEL,
246
+ status: "never-run",
247
+ lastProcessedEventId: 0n,
248
+ attempts: 0,
249
+ lastError: null,
250
+ });
251
+ }
252
+ return out;
253
+ }
254
+
255
+ export type ConsumerProgress = {
256
+ readonly name: string;
257
+ readonly instanceId: string;
258
+ readonly status: string;
259
+ readonly lastProcessedEventId: bigint;
260
+ readonly attempts: number;
261
+ readonly lastError: string | null;
262
+ // Global MAX(events.id) at query time.
263
+ readonly highWaterMark: bigint;
264
+ // HWM - cursor. 0n when caught-up. Disabled consumers often show high
265
+ // lag intentionally (ops parks them before pruning).
266
+ readonly lag: bigint;
267
+ };
268
+
269
+ // Like listConsumersWithState, but also returns HWM + lag per consumer.
270
+ // Async consumers (MSPs) lag behind inline projections because they run
271
+ // post-commit — lag is the primary signal for backpressure, dead consumers,
272
+ // or dispatcher stalls. Programmatic callers can map the result to a
273
+ // `kumiko_consumer_lag{name}` Prometheus gauge.
274
+ // guard:dup-ok — intentionale Parallele zu getAllProjectionProgress; Consumer ≠ Projection (verschiedene Subsysteme)
275
+ export async function getAllConsumerProgress(
276
+ db: DbConnection,
277
+ registeredNames: readonly string[],
278
+ ): Promise<readonly ConsumerProgress[]> {
279
+ const [consumers, highWaterMark] = await Promise.all([
280
+ listConsumersWithState(db, registeredNames),
281
+ getEventsHighWaterMark(db),
282
+ ]);
283
+
284
+ return consumers.map((c) => ({
285
+ ...c,
286
+ highWaterMark,
287
+ lag: highWaterMark - c.lastProcessedEventId,
288
+ }));
289
+ }