@cosmicdrift/kumiko-framework 0.146.3 → 0.147.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 +6 -2
- package/src/api/auth-routes.ts +32 -67
- package/src/api/routes.ts +1 -3
- package/src/bun-db/__tests__/PATTERN.md +0 -1
- package/src/db/__tests__/assert-no-unreachable-live-rows.integration.test.ts +29 -3
- package/src/db/__tests__/schema-inspection.test.ts +7 -0
- package/src/db/event-store-executor-context.ts +398 -0
- package/src/db/event-store-executor-read.ts +276 -0
- package/src/db/event-store-executor-write.ts +615 -0
- package/src/db/event-store-executor.ts +29 -1166
- package/src/db/queries/shadow-swap.ts +3 -1
- package/src/db/schema-inspection.ts +1 -1
- package/src/engine/__tests__/boot-validator-dashboard.test.ts +10 -0
- package/src/engine/__tests__/engine.test.ts +45 -1
- package/src/engine/__tests__/event-migration-declarative.test.ts +6 -0
- package/src/engine/__tests__/membership-roles.test.ts +4 -10
- package/src/engine/__tests__/screen.test.ts +26 -0
- package/src/engine/boot-validator/entity-list-screens.ts +1 -1
- package/src/engine/boot-validator/gdpr-storage.ts +1 -1
- package/src/engine/boot-validator/index.ts +12 -8
- package/src/engine/boot-validator/nav.ts +120 -0
- package/src/engine/boot-validator/{screens-nav.ts → screens.ts} +12 -190
- package/src/engine/boot-validator/workspaces.ts +68 -0
- package/src/engine/define-feature.ts +77 -1011
- package/src/engine/feature-ast/__tests__/fixtures/cross-file-registrar/feature.ts +9 -0
- package/src/engine/feature-ast/__tests__/fixtures/cross-file-registrar/screens.ts +4 -0
- package/src/engine/feature-ast/__tests__/parse-real-features.test.ts +18 -8
- package/src/engine/feature-ast/__tests__/parse.test.ts +291 -2
- package/src/engine/feature-ast/__tests__/patcher.test.ts +1 -1
- package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +73 -0
- package/src/engine/feature-ast/extractors/round1.ts +3 -3
- package/src/engine/feature-ast/extractors/round4.ts +45 -10
- package/src/engine/feature-ast/extractors/shared.ts +64 -6
- package/src/engine/feature-ast/parse.ts +123 -10
- package/src/engine/feature-ast/patterns.ts +14 -7
- package/src/engine/feature-ast/render.ts +28 -7
- package/src/engine/feature-builder-state.ts +165 -0
- package/src/engine/feature-config-events-jobs.ts +303 -0
- package/src/engine/feature-entity-handlers.ts +161 -0
- package/src/engine/feature-ui-extensions.ts +413 -0
- package/src/engine/index.ts +0 -2
- package/src/engine/pattern-library/library.ts +44 -1131
- package/src/engine/pattern-library/mixed-schemas.ts +484 -0
- package/src/engine/pattern-library/opaque-schemas.ts +124 -0
- package/src/engine/pattern-library/shared-fields.ts +80 -0
- package/src/engine/pattern-library/static-schemas.ts +456 -0
- package/src/engine/registry-facade.ts +349 -0
- package/src/engine/registry-ingest.ts +473 -0
- package/src/engine/registry-state.ts +388 -0
- package/src/engine/registry-validate.ts +660 -0
- package/src/engine/registry.ts +79 -1695
- package/src/engine/types/screen.ts +4 -2
- package/src/engine/types/tree-node.ts +2 -5
- package/src/event-store/snapshot.ts +7 -7
- package/src/i18n/required-surface-keys.ts +2 -0
- package/src/jobs/job-runner.ts +1 -1
- package/src/observability/standard-metrics.ts +4 -3
- package/src/pipeline/dispatch-batch.ts +3 -3
- package/src/pipeline/dispatch-shared.ts +17 -10
- package/src/pipeline/event-dispatcher-admin.ts +289 -0
- package/src/pipeline/event-dispatcher-delivery.ts +264 -0
- package/src/pipeline/event-dispatcher.ts +28 -540
- package/src/pipeline/projection-rebuild.ts +1 -1
- package/src/stack/__tests__/setup-test-stack-jobs.integration.test.ts +95 -0
- package/src/stack/test-stack.ts +46 -1
- package/src/testing/boot-validator-fixture.ts +1 -2
- package/src/engine/__tests__/deep-link.test.ts +0 -35
- 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. "↓
|
|
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
|
|
2
|
-
//
|
|
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
|
|
package/src/jobs/job-runner.ts
CHANGED
|
@@ -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
|
|
231
|
-
// (waiting/active/delayed/failed/
|
|
232
|
-
//
|
|
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
|
|
173
|
-
// one
|
|
174
|
-
//
|
|
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
|
+
}
|