@cosmicdrift/kumiko-framework 0.132.0 → 0.134.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 +2 -2
- package/src/db/__tests__/assert-no-unreachable-live-rows.integration.test.ts +139 -0
- package/src/db/queries/shadow-swap.ts +62 -0
- package/src/engine/__tests__/boot-validator-dashboard.test.ts +136 -2
- package/src/engine/__tests__/boot-validator-located-timestamps.test.ts +3 -19
- package/src/engine/__tests__/deep-link.test.ts +35 -0
- package/src/engine/__tests__/factories-time.test.ts +2 -66
- package/src/engine/__tests__/{visual-tree-patterns.test.ts → tree-actions-patterns.test.ts} +4 -84
- package/src/engine/boot-validator/entity-handler.ts +5 -4
- package/src/engine/boot-validator/screens-nav.ts +112 -16
- package/src/engine/deep-link.ts +23 -0
- package/src/engine/define-feature.ts +3 -17
- package/src/engine/factories.ts +2 -49
- package/src/engine/feature-ast/extractors/index.ts +1 -4
- package/src/engine/feature-ast/extractors/round6.ts +2 -36
- package/src/engine/feature-ast/parse.ts +1 -4
- package/src/engine/feature-ast/patch.ts +2 -5
- package/src/engine/feature-ast/patterns.ts +0 -14
- package/src/engine/feature-ast/render.ts +1 -9
- package/src/engine/index.ts +2 -1
- package/src/engine/pattern-library/__tests__/library.test.ts +0 -3
- package/src/engine/pattern-library/library.ts +0 -19
- package/src/engine/registry.ts +6 -16
- package/src/engine/types/feature.ts +3 -33
- package/src/engine/types/fields.ts +5 -4
- package/src/engine/types/index.ts +5 -0
- package/src/engine/types/screen.ts +64 -1
- package/src/engine/types/workspace.ts +0 -7
- package/src/errors/classes.ts +1 -1
- package/src/errors/field-issue.ts +0 -3
- package/src/errors/index.ts +0 -1
- package/src/i18n/required-surface-keys.ts +29 -10
- package/src/jobs/__tests__/job-queue-depth.integration.test.ts +82 -0
- package/src/jobs/job-runner.ts +41 -1
- package/src/observability/__tests__/recording-tracer.test.ts +6 -4
- package/src/observability/index.ts +1 -0
- package/src/observability/noop-provider.ts +0 -9
- package/src/observability/recording-tracer.ts +0 -12
- package/src/observability/standard-metrics.ts +28 -0
- package/src/observability/types/span.ts +0 -6
- package/src/pipeline/projection-rebuild.ts +7 -0
- package/src/time/tz-context.ts +1 -1
- package/src/ui-types/index.ts +5 -0
- package/src/bun-db/__tests__/bun-test-stack.ts +0 -6
- package/src/db/row-helpers.ts +0 -4
- package/src/engine/feature-ast/__tests__/visual-tree-parse.test.ts +0 -184
|
@@ -70,7 +70,7 @@ import type {
|
|
|
70
70
|
} from "./projection";
|
|
71
71
|
import type { EntityRelations, RelationDefinition } from "./relations";
|
|
72
72
|
import type { ScreenDefinition } from "./screen";
|
|
73
|
-
import type { TreeActionDef, TreeActionsHandle
|
|
73
|
+
import type { TreeActionDef, TreeActionsHandle } from "./tree-node";
|
|
74
74
|
import type { WorkspaceDefinition } from "./workspace";
|
|
75
75
|
|
|
76
76
|
// --- Metrics (declared by features via r.metric()) ---
|
|
@@ -352,13 +352,6 @@ export type FeatureDefinition = {
|
|
|
352
352
|
// feature exports via setup-return — buildTarget consumes the handle,
|
|
353
353
|
// not this slot. See visual-tree.md A5 + A7.
|
|
354
354
|
readonly treeActions?: Readonly<Record<string, TreeActionDef>>;
|
|
355
|
-
// Tree-Provider declared via r.tree(). At-most-one per feature.
|
|
356
|
-
// Provider liefert die Top-Level-Knoten dieses Features im Visual-
|
|
357
|
-
// Workspace (navigation: "tree"). Subscribe-Form mit lazy-Eval: erst
|
|
358
|
-
// beim Mount des Workspaces aufgerufen, kann Updates emittieren.
|
|
359
|
-
// Feature ohne treeProvider ist im Visual-Workspace unsichtbar
|
|
360
|
-
// (Zero-Whitelist-Filter aus visual-tree.md A2).
|
|
361
|
-
readonly treeProvider?: TreeChildrenSubscribe;
|
|
362
355
|
// HTTP-Routes declared via r.httpRoute(). Index is "METHOD path"
|
|
363
356
|
// (z.B. "GET /feed.xml") — eindeutig pro Feature. Die App-Server-
|
|
364
357
|
// Boot-Stage iteriert getAllHttpRoutes() und mountet jede Route auf
|
|
@@ -771,20 +764,6 @@ export type FeatureRegistrar<TFeature extends string = string> = {
|
|
|
771
764
|
// k8s-secret hints) goes into `.meta({ kumiko: { pulumi: {...} } })`
|
|
772
765
|
// — see framework/env/index.ts for the meta-shape.
|
|
773
766
|
envSchema(schema: z.ZodObject<z.ZodRawShape>): void;
|
|
774
|
-
|
|
775
|
-
// Register the tree-provider for this feature — the Subscribe-Function
|
|
776
|
-
// that emits the top-level Tree-Knoten when the Visual-Workspace
|
|
777
|
-
// (navigation: "tree") mounts. At-most-one call per feature.
|
|
778
|
-
//
|
|
779
|
-
// Provider returns a Subscribe-Function (emit-fn → unsubscribe-fn).
|
|
780
|
-
// Initial-emit synchron oder async, weitere Emits beliebig oft (e.g.
|
|
781
|
-
// on entity-update SSE). Provider sind session-bound; tenantId fließt
|
|
782
|
-
// über die Backend-Session bei fetch/dispatch, nicht über ein ctx-Arg.
|
|
783
|
-
//
|
|
784
|
-
// A feature without r.tree() is invisible in `navigation: "tree"`-
|
|
785
|
-
// workspaces — that's the Zero-Whitelist-Filter from visual-tree.md A2:
|
|
786
|
-
// provider-Vorhandensein ist der Filter, kein Workspace-Mapping.
|
|
787
|
-
tree(provider: TreeChildrenSubscribe): void;
|
|
788
767
|
};
|
|
789
768
|
|
|
790
769
|
// --- Registry (created from features) ---
|
|
@@ -977,18 +956,9 @@ export type Registry = {
|
|
|
977
956
|
// the first workspace the user has access to.
|
|
978
957
|
getDefaultWorkspace(): WorkspaceDefinition | undefined;
|
|
979
958
|
|
|
980
|
-
// Tree-Providers declared via r.tree() across all features. Keyed by
|
|
981
|
-
// declaring feature name (NOT qualified — Provider sind feature-bound,
|
|
982
|
-
// ein Feature liefert genau eine Provider-Function). The Visual-Tree
|
|
983
|
-
// component (renderer-web) iteriert getTreeProviders() beim Mount des
|
|
984
|
-
// navigation: "tree"-Workspaces, ruft jeden Provider mit ctx auf,
|
|
985
|
-
// sammelt die emitted TreeNode[] und merged sie zur Top-Level-Liste.
|
|
986
|
-
// See visual-tree.md A2 (Zero-Whitelist) + A4 (Subscribe-Form).
|
|
987
|
-
getTreeProviders(): ReadonlyMap<string, TreeChildrenSubscribe>;
|
|
988
|
-
|
|
989
959
|
// Tree-Actions-Map des Features. Returns the erased Record (compile-
|
|
990
|
-
// time-typed handle wandert über setup-export, nicht hier).
|
|
991
|
-
// Tree-
|
|
960
|
+
// time-typed handle wandert über setup-export, nicht hier). Die
|
|
961
|
+
// Content-Tree-Nav nutzt das für Runtime-Action-Lookup beim Klick auf
|
|
992
962
|
// einen TreeNode.target — der Resolver findet das Feature via
|
|
993
963
|
// TargetRef.featureId und holt sich die zugehörige Action-Definition.
|
|
994
964
|
getTreeActions(featureName: string): Readonly<Record<string, TreeActionDef>> | undefined;
|
|
@@ -354,7 +354,7 @@ export type JsonbFieldDef = {
|
|
|
354
354
|
// Legacy "date" — JS-Date-Object, semantisch unklar (Wall-Clock vs Instant).
|
|
355
355
|
// Für neue Felder bevorzuge:
|
|
356
356
|
// - `timestamp` für UTC-Instant ("wann ist das passiert")
|
|
357
|
-
// - `
|
|
357
|
+
// - `createLocatedTimestampField()` für Termine die an einem Ort
|
|
358
358
|
// stattfinden ("Pickup um 10:00 in Lissabon")
|
|
359
359
|
// - (kommt) `plainDate` für Kalender-Daten ohne Uhrzeit (z.B. Geburtstag)
|
|
360
360
|
// Siehe docs/plans/architecture/timezones.md
|
|
@@ -384,8 +384,8 @@ export type DateFieldDef = {
|
|
|
384
384
|
// speichert Wall-Clock+tz und konvertiert transparent (siehe DB-Wrapper,
|
|
385
385
|
// kommt in einer späteren Iteration).
|
|
386
386
|
//
|
|
387
|
-
//
|
|
388
|
-
//
|
|
387
|
+
// Für neue Felder bevorzuge `createLocatedTimestampField()` — EIN atomares
|
|
388
|
+
// Feld statt eines lose verdrahteten Pairs (siehe LocatedTimestampFieldDef).
|
|
389
389
|
export type TimestampFieldDef = {
|
|
390
390
|
readonly type: "timestamp";
|
|
391
391
|
readonly required?: boolean;
|
|
@@ -397,8 +397,9 @@ export type TimestampFieldDef = {
|
|
|
397
397
|
* Marker: dieses Timestamp-Feld ist Wall-Clock-Zeit an einem Ort.
|
|
398
398
|
* Wert ist der Name des begleitenden tz-Felds (IANA-Zone).
|
|
399
399
|
*
|
|
400
|
-
* Beispiel:
|
|
400
|
+
* Beispiel: manuelles Pair
|
|
401
401
|
* { pickupAt: { type: "timestamp", locatedBy: "pickupTz" }, pickupTz: { type: "tz" } }
|
|
402
|
+
* — bevorzuge stattdessen `createLocatedTimestampField()`.
|
|
402
403
|
*/
|
|
403
404
|
readonly locatedBy?: string;
|
|
404
405
|
/** Erlaubte Grenzen als ISO-Datetime. Begrenzt den Picker auf
|
|
@@ -220,9 +220,14 @@ export type {
|
|
|
220
220
|
CustomScreenDefinition,
|
|
221
221
|
CustomScreenRoute,
|
|
222
222
|
DashboardChartPanel,
|
|
223
|
+
DashboardCustomPanel,
|
|
224
|
+
DashboardFeedPanel,
|
|
225
|
+
DashboardFilterDefinition,
|
|
223
226
|
DashboardListPanel,
|
|
224
227
|
DashboardPanelDefinition,
|
|
228
|
+
DashboardProgressListPanel,
|
|
225
229
|
DashboardScreenDefinition,
|
|
230
|
+
DashboardStatGroupPanel,
|
|
226
231
|
DashboardStatPanel,
|
|
227
232
|
EditExtensionSection,
|
|
228
233
|
EditFieldSpec,
|
|
@@ -369,15 +369,78 @@ export type DashboardListPanel = {
|
|
|
369
369
|
readonly columns: readonly ListColumnSpec[];
|
|
370
370
|
};
|
|
371
371
|
|
|
372
|
+
// Betitelte Sektion aus mehreren Stat-Panels (z.B. "Net Worth": Assets/Debts/
|
|
373
|
+
// Net). Ein Nesting-Level, kein Group-of-Groups — jedes Kind bleibt ein
|
|
374
|
+
// vollwertiges DashboardStatPanel mit eigener Query/id/label, der Renderer
|
|
375
|
+
// zieht sie nur gemeinsam unter einen Sektions-Titel.
|
|
376
|
+
export type DashboardStatGroupPanel = {
|
|
377
|
+
readonly kind: "stat-group";
|
|
378
|
+
readonly id: string;
|
|
379
|
+
readonly label: string;
|
|
380
|
+
readonly stats: readonly DashboardStatPanel[];
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
// Nicht-tabellarische Kurzliste (z.B. "nächste Termine"). Query-Result-
|
|
384
|
+
// Contract: `{ rows: { primary: string; trailing?: string }[] }`.
|
|
385
|
+
export type DashboardFeedPanel = {
|
|
386
|
+
readonly kind: "feed";
|
|
387
|
+
readonly id: string;
|
|
388
|
+
readonly label: string;
|
|
389
|
+
readonly query: string;
|
|
390
|
+
readonly emptyLabel?: string;
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
// Liste aus Label/Wert/Fortschrittsbalken (z.B. Tilgungsfortschritt pro
|
|
394
|
+
// Kredit). Query-Result-Contract: `{ rows: { label: string; value: string;
|
|
395
|
+
// fraction: number }[] }` — fraction wird auf 0..1 geclampt.
|
|
396
|
+
export type DashboardProgressListPanel = {
|
|
397
|
+
readonly kind: "progress-list";
|
|
398
|
+
readonly id: string;
|
|
399
|
+
readonly label: string;
|
|
400
|
+
readonly query: string;
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
// Eingehängte App-Komponente, die ihre Daten/Titel selbst verwaltet (wie ein
|
|
404
|
+
// custom Screen, nur als Panel — bleibt an ihrer Array-Position statt in
|
|
405
|
+
// einen separaten Slot zu wandern). Kein `query`, keine `label`: der Renderer
|
|
406
|
+
// löst `component` über dieselbe extensionSectionComponents-Registry auf wie
|
|
407
|
+
// entityEdit-Extension-Sections und List-Header-Slots.
|
|
408
|
+
export type DashboardCustomPanel = {
|
|
409
|
+
readonly kind: "custom";
|
|
410
|
+
readonly id: string;
|
|
411
|
+
readonly component: PlatformComponent;
|
|
412
|
+
};
|
|
413
|
+
|
|
372
414
|
export type DashboardPanelDefinition =
|
|
373
415
|
| DashboardStatPanel
|
|
416
|
+
| DashboardStatGroupPanel
|
|
374
417
|
| DashboardChartPanel
|
|
375
|
-
| DashboardListPanel
|
|
418
|
+
| DashboardListPanel
|
|
419
|
+
| DashboardFeedPanel
|
|
420
|
+
| DashboardProgressListPanel
|
|
421
|
+
| DashboardCustomPanel;
|
|
422
|
+
|
|
423
|
+
// Screen-weiter Picker (Combobox), dessen gewählter Wert unter `id` in JEDE
|
|
424
|
+
// Panel-Query dieses Screens gemerged wird (Query-Handler validieren den Wert
|
|
425
|
+
// selbst gegen die Tenant-Sicht — dies ist UX-Scoping, keine Access-Boundary).
|
|
426
|
+
// Genau eins von `options`/`optionsQuery` ist gesetzt (Boot-Validator prüft).
|
|
427
|
+
export type DashboardFilterDefinition = {
|
|
428
|
+
readonly id: string;
|
|
429
|
+
readonly label: string;
|
|
430
|
+
readonly kind: "select";
|
|
431
|
+
readonly placeholder?: string;
|
|
432
|
+
/** i18n-Key für den "(alle)"-Eintrag. */
|
|
433
|
+
readonly allLabel?: string;
|
|
434
|
+
readonly options?: readonly { readonly value: string; readonly label: string }[];
|
|
435
|
+
/** Query-Result-Contract: `{ rows: { value: string; label: string }[] }`. */
|
|
436
|
+
readonly optionsQuery?: string;
|
|
437
|
+
};
|
|
376
438
|
|
|
377
439
|
export type DashboardScreenDefinition = {
|
|
378
440
|
readonly id: string;
|
|
379
441
|
readonly type: "dashboard";
|
|
380
442
|
readonly panels: readonly DashboardPanelDefinition[];
|
|
443
|
+
readonly filter?: DashboardFilterDefinition;
|
|
381
444
|
readonly slots?: ScreenSlots;
|
|
382
445
|
readonly access?: AccessRule;
|
|
383
446
|
};
|
|
@@ -39,11 +39,4 @@ export type WorkspaceDefinition = {
|
|
|
39
39
|
// Default workspace at login when the user has access to multiple. Boot
|
|
40
40
|
// validator rejects more than one default per app.
|
|
41
41
|
readonly default?: boolean;
|
|
42
|
-
// Render mode of the workspace's sidebar. "nav" (default, missing-ok)
|
|
43
|
-
// mounts the existing NavTree component as before. "tree" mounts the
|
|
44
|
-
// Visual-Tree component which collects r.tree() providers across all
|
|
45
|
-
// active features. Default-on-undefined preserves backwards-compat —
|
|
46
|
-
// every existing workspace stays nav-mode without code touch.
|
|
47
|
-
// See visual-tree.md A1.
|
|
48
|
-
readonly navigation?: "nav" | "tree";
|
|
49
42
|
};
|
package/src/errors/classes.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { toSnakeCase } from "../utils/case";
|
|
|
2
2
|
import type { FieldIssue } from "./field-issue";
|
|
3
3
|
import { type ErrorOpts, KumikoError } from "./kumiko-error";
|
|
4
4
|
|
|
5
|
-
export type { FieldIssue
|
|
5
|
+
export type { FieldIssue } from "./field-issue";
|
|
6
6
|
|
|
7
7
|
export type ValidationDetails = {
|
|
8
8
|
readonly fields: readonly FieldIssue[];
|
package/src/errors/index.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
ActionFormScreenDefinition,
|
|
3
3
|
ConfigEditScreenDefinition,
|
|
4
|
+
DashboardFilterDefinition,
|
|
5
|
+
DashboardPanelDefinition,
|
|
4
6
|
DashboardScreenDefinition,
|
|
5
7
|
EntityEditScreenDefinition,
|
|
6
8
|
EntityListScreenDefinition,
|
|
@@ -66,6 +68,32 @@ function pushRowActionKeys(out: Set<string>, action: RowAction): void {
|
|
|
66
68
|
}
|
|
67
69
|
}
|
|
68
70
|
|
|
71
|
+
function pushDashboardScreenKeys(out: Set<string>, dashboard: DashboardScreenDefinition): void {
|
|
72
|
+
for (const panel of dashboard.panels) pushDashboardPanelKeys(out, panel);
|
|
73
|
+
if (dashboard.filter !== undefined) pushDashboardFilterKeys(out, dashboard.filter);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function pushDashboardPanelKeys(out: Set<string>, panel: DashboardPanelDefinition): void {
|
|
77
|
+
// skip: custom-Panel übersetzt sich selbst, kein Key hier
|
|
78
|
+
if (panel.kind === "custom") return;
|
|
79
|
+
pushKey(out, panel.label);
|
|
80
|
+
if (panel.kind === "stat-group") {
|
|
81
|
+
for (const stat of panel.stats) pushKey(out, stat.label);
|
|
82
|
+
}
|
|
83
|
+
if (panel.kind === "list") {
|
|
84
|
+
for (const col of panel.columns) {
|
|
85
|
+
const normalized = normalizeListColumn(col);
|
|
86
|
+
if (normalized.label !== undefined) pushKey(out, normalized.label);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function pushDashboardFilterKeys(out: Set<string>, filter: DashboardFilterDefinition): void {
|
|
92
|
+
pushKey(out, filter.label);
|
|
93
|
+
if (filter.allLabel !== undefined) pushKey(out, filter.allLabel);
|
|
94
|
+
for (const opt of filter.options ?? []) pushKey(out, opt.label);
|
|
95
|
+
}
|
|
96
|
+
|
|
69
97
|
function pushToolbarActionKeys(out: Set<string>, action: ToolbarAction): void {
|
|
70
98
|
pushKey(out, action.label);
|
|
71
99
|
if (action.kind === "writeHandler") {
|
|
@@ -108,16 +136,7 @@ export function requiredKeysFromScreen(
|
|
|
108
136
|
break;
|
|
109
137
|
}
|
|
110
138
|
case "dashboard": {
|
|
111
|
-
|
|
112
|
-
for (const panel of dashboard.panels) {
|
|
113
|
-
pushKey(out, panel.label);
|
|
114
|
-
if (panel.kind === "list") {
|
|
115
|
-
for (const col of panel.columns) {
|
|
116
|
-
const normalized = normalizeListColumn(col);
|
|
117
|
-
if (normalized.label !== undefined) pushKey(out, normalized.label);
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
}
|
|
139
|
+
pushDashboardScreenKeys(out, screen as DashboardScreenDefinition);
|
|
121
140
|
break;
|
|
122
141
|
}
|
|
123
142
|
case "entityEdit": {
|
|
@@ -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
|
+
});
|
package/src/jobs/job-runner.ts
CHANGED
|
@@ -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 {
|
|
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;
|
|
@@ -87,11 +87,13 @@ describe("RecordingTracer", () => {
|
|
|
87
87
|
expect(recorded).toHaveLength(1);
|
|
88
88
|
});
|
|
89
89
|
|
|
90
|
-
it("
|
|
90
|
+
it("startSpan with parent context continues an upstream trace", () => {
|
|
91
91
|
const { tracer, recorded } = makeTracer();
|
|
92
|
-
const span = tracer.
|
|
93
|
-
|
|
94
|
-
|
|
92
|
+
const span = tracer.startSpan("child", {
|
|
93
|
+
parent: {
|
|
94
|
+
traceId: "aabbccddeeff00112233445566778899",
|
|
95
|
+
spanId: "1122334455667788",
|
|
96
|
+
},
|
|
95
97
|
});
|
|
96
98
|
span.end();
|
|
97
99
|
expect(recorded[0]?.traceId).toBe("aabbccddeeff00112233445566778899");
|
|
@@ -5,7 +5,6 @@ import type {
|
|
|
5
5
|
Meter,
|
|
6
6
|
MetricDefinition,
|
|
7
7
|
ObservabilityProvider,
|
|
8
|
-
SerializedTraceContext,
|
|
9
8
|
Span,
|
|
10
9
|
SpanStatus,
|
|
11
10
|
StartSpanOptions,
|
|
@@ -68,14 +67,6 @@ class NoopTracer implements Tracer {
|
|
|
68
67
|
getActiveSpan(): Span | undefined {
|
|
69
68
|
return undefined;
|
|
70
69
|
}
|
|
71
|
-
|
|
72
|
-
startSpanFromContext(
|
|
73
|
-
name: string,
|
|
74
|
-
_context: SerializedTraceContext,
|
|
75
|
-
_options?: StartSpanOptions,
|
|
76
|
-
): Span {
|
|
77
|
-
return new NoopSpan(name, undefined);
|
|
78
|
-
}
|
|
79
70
|
}
|
|
80
71
|
|
|
81
72
|
class NoopCounter implements Counter {
|
|
@@ -175,18 +175,6 @@ export class RecordingTracer implements Tracer {
|
|
|
175
175
|
getActiveSpan(): Span | undefined {
|
|
176
176
|
return observabilityContext.getActiveSpan();
|
|
177
177
|
}
|
|
178
|
-
|
|
179
|
-
/**
|
|
180
|
-
* @deprecated Prefer `startSpan(name, { parent: context })`. Retained as a
|
|
181
|
-
* thin alias for call-sites that pre-date the unified StartSpanOptions.
|
|
182
|
-
*/
|
|
183
|
-
startSpanFromContext(
|
|
184
|
-
name: string,
|
|
185
|
-
context: SerializedTraceContext,
|
|
186
|
-
options?: StartSpanOptions,
|
|
187
|
-
): Span {
|
|
188
|
-
return this.startSpan(name, { ...options, parent: context });
|
|
189
|
-
}
|
|
190
178
|
}
|
|
191
179
|
|
|
192
180
|
// Helper to serialize an active Span into the cross-process format.
|
|
@@ -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
|
+
}
|
|
@@ -55,10 +55,4 @@ export interface Tracer {
|
|
|
55
55
|
): Promise<T>;
|
|
56
56
|
// Current active span from AsyncLocalStorage, or undefined.
|
|
57
57
|
getActiveSpan(): Span | undefined;
|
|
58
|
-
// @deprecated Prefer `startSpan(name, { parent: context })`.
|
|
59
|
-
startSpanFromContext(
|
|
60
|
-
name: string,
|
|
61
|
-
context: SerializedTraceContext,
|
|
62
|
-
options?: StartSpanOptions,
|
|
63
|
-
): Span;
|
|
64
58
|
}
|
|
@@ -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
|
});
|
package/src/time/tz-context.ts
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
import type { GeoAddress, GeoCoordinates, GeoTzProvider } from "./geo-tz";
|
|
18
18
|
import { ensureTemporalPolyfill, getTemporal } from "./polyfill";
|
|
19
19
|
|
|
20
|
-
// JSON-Form für Wall-Clock+TZ — siehe
|
|
20
|
+
// JSON-Form für Wall-Clock+TZ — siehe createLocatedTimestampField() in
|
|
21
21
|
// engine/factories.ts. Zwei Felder, idiotensicher.
|
|
22
22
|
export type LocatedTimestampJson = {
|
|
23
23
|
/** Wall-Clock-ISO ohne Offset, z.B. "2026-04-03T10:00:00" */
|
package/src/ui-types/index.ts
CHANGED
|
@@ -47,9 +47,14 @@ export type {
|
|
|
47
47
|
CustomScreenDefinition,
|
|
48
48
|
CustomScreenRoute,
|
|
49
49
|
DashboardChartPanel,
|
|
50
|
+
DashboardCustomPanel,
|
|
51
|
+
DashboardFeedPanel,
|
|
52
|
+
DashboardFilterDefinition,
|
|
50
53
|
DashboardListPanel,
|
|
51
54
|
DashboardPanelDefinition,
|
|
55
|
+
DashboardProgressListPanel,
|
|
52
56
|
DashboardScreenDefinition,
|
|
57
|
+
DashboardStatGroupPanel,
|
|
53
58
|
DashboardStatPanel,
|
|
54
59
|
EditExtensionSection,
|
|
55
60
|
EditFieldSpec,
|
|
@@ -1,6 +0,0 @@
|
|
|
1
|
-
// Provider-agnostic Test-Stack — re-exported from stack/test-stack.ts.
|
|
2
|
-
// setupBunTestStack / BunTestStack sind Aliase für setupTestStack / TestStack.
|
|
3
|
-
// "Bun" ist historisch, jetzt provider-neutral (DB_PROVIDER env).
|
|
4
|
-
|
|
5
|
-
export type { TestStack as BunTestStack } from "../../stack/test-stack";
|
|
6
|
-
export { setupTestStack as setupBunTestStack } from "../../stack/test-stack";
|
package/src/db/row-helpers.ts
DELETED