@cosmicdrift/kumiko-framework 0.185.0 → 0.186.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/src/db/__tests__/event-store-executor-money-rehydrate.integration.test.ts +140 -0
- package/src/db/event-store-executor-read.ts +10 -7
- package/src/observability/__tests__/observability.integration.test.ts +80 -0
- package/src/observability/index.ts +1 -0
- package/src/observability/metric-validator.ts +14 -3
- package/src/observability/metrics-handle.ts +37 -0
- package/src/pipeline/dispatch-shared.ts +9 -0
- package/src/testing/handler-context.ts +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.186.1",
|
|
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>",
|
|
@@ -182,7 +182,7 @@
|
|
|
182
182
|
"./package.json": "./package.json"
|
|
183
183
|
},
|
|
184
184
|
"dependencies": {
|
|
185
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
185
|
+
"@cosmicdrift/kumiko-types": "0.186.1",
|
|
186
186
|
"bullmq": "^5.76.7",
|
|
187
187
|
"bun-types": "^1.3.13",
|
|
188
188
|
"hono": "^4.12.27",
|
|
@@ -198,7 +198,7 @@
|
|
|
198
198
|
"zod": "^4.4.3"
|
|
199
199
|
},
|
|
200
200
|
"devDependencies": {
|
|
201
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
201
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.186.1",
|
|
202
202
|
"bun-types": "^1.3.13",
|
|
203
203
|
"pino-pretty": "^13.1.3"
|
|
204
204
|
},
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// fw#1855 — money columns in list()/detail() surfaced the raw minor-units
|
|
2
|
+
// integer instead of { amount, currency }: rehydrateCompoundTypes ran BEFORE
|
|
3
|
+
// coerceRow on raw-SQL rows, so rehydrateMoney looked up the still-snake_case
|
|
4
|
+
// key and silently no-op'd. money.test.ts/compound-types.test.ts only feed
|
|
5
|
+
// rehydrateMoney pre-camelCased rows directly — neither covers the real
|
|
6
|
+
// list()/detail() path through raw SQL, which is why this went unnoticed.
|
|
7
|
+
|
|
8
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
9
|
+
import { type BunTestDb, createTestDb } from "../../bun-db/__tests__/bun-test-db";
|
|
10
|
+
import { asRawClient } from "../../db/query";
|
|
11
|
+
import { createEntity, createMoneyField, createTextField, from } from "../../engine";
|
|
12
|
+
import { createEventsTable } from "../../event-store";
|
|
13
|
+
import { TestUsers, unsafeCreateEntityTable } from "../../stack";
|
|
14
|
+
import { createTestEnvelopeCipher } from "../../testing";
|
|
15
|
+
import { ensureTemporalPolyfill } from "../../time/polyfill";
|
|
16
|
+
import {
|
|
17
|
+
configureEntityFieldEncryption,
|
|
18
|
+
resetEntityFieldEncryptionCacheForTests,
|
|
19
|
+
} from "../entity-field-encryption";
|
|
20
|
+
import { createEventStoreExecutor } from "../event-store-executor";
|
|
21
|
+
import { buildEntityTable } from "../table-builder";
|
|
22
|
+
import { createTenantDb, type TenantDb } from "../tenant-db";
|
|
23
|
+
|
|
24
|
+
const TEST_KEY = Buffer.from("a]bJm#kP9xQ2@wN!vL$hR5yT8eU0iO3f").toString("base64");
|
|
25
|
+
const cipher = createTestEnvelopeCipher(TEST_KEY);
|
|
26
|
+
|
|
27
|
+
// Multi-word field name on purpose: "price" maps to column "price" (snake ==
|
|
28
|
+
// camel), which would pass even with the key-mismatch bug still present.
|
|
29
|
+
// "grossTotal" maps to "gross_total" / "gross_total_currency" and exposes it.
|
|
30
|
+
// billingIban ("billing_iban") is multi-word + encrypted: true for the same
|
|
31
|
+
// reason, targeting the encrypted-field variant of the bug on detail()'s
|
|
32
|
+
// ownership.kind==="sql" branch (decryptForRead used to run against a still
|
|
33
|
+
// snake_case row and silently skip the field, leaking ciphertext).
|
|
34
|
+
const entity = createEntity({
|
|
35
|
+
table: "read_money_orders",
|
|
36
|
+
fields: {
|
|
37
|
+
ownerId: createTextField({ required: true }),
|
|
38
|
+
grossTotal: createMoneyField(),
|
|
39
|
+
billingIban: createTextField({ required: true, encrypted: true }),
|
|
40
|
+
},
|
|
41
|
+
// A non-"all" read rule forces buildOwnershipClause into the
|
|
42
|
+
// ownership.kind==="sql" raw-SQL branch that list()/detail() read through.
|
|
43
|
+
// The "pass" branch (db.fetchOne → selectMany → coerceRows) is already
|
|
44
|
+
// camelCase and would pass this test even with the bug present.
|
|
45
|
+
access: { read: { Admin: from("user:id", "ownerId") } },
|
|
46
|
+
});
|
|
47
|
+
const table = buildEntityTable("moneyOrder", entity);
|
|
48
|
+
|
|
49
|
+
let testDb: BunTestDb;
|
|
50
|
+
let tdb: TenantDb;
|
|
51
|
+
const admin = TestUsers.admin;
|
|
52
|
+
|
|
53
|
+
beforeAll(async () => {
|
|
54
|
+
await ensureTemporalPolyfill();
|
|
55
|
+
testDb = await createTestDb();
|
|
56
|
+
await unsafeCreateEntityTable(testDb.db, entity, "moneyOrder");
|
|
57
|
+
await createEventsTable(testDb.db);
|
|
58
|
+
tdb = createTenantDb(testDb.db, admin.tenantId);
|
|
59
|
+
configureEntityFieldEncryption(cipher);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
afterAll(async () => {
|
|
63
|
+
resetEntityFieldEncryptionCacheForTests();
|
|
64
|
+
await testDb.cleanup();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
beforeEach(async () => {
|
|
68
|
+
await asRawClient(testDb.db).unsafe(
|
|
69
|
+
`TRUNCATE kumiko_events, read_money_orders RESTART IDENTITY CASCADE`,
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("event-store-executor — money column rehydration through raw SQL (fw#1855)", () => {
|
|
74
|
+
const exec = createEventStoreExecutor(table, entity, { entityName: "moneyOrder" });
|
|
75
|
+
|
|
76
|
+
test("list(): money column arrives as { amount, currency }, not the raw minor-units integer", async () => {
|
|
77
|
+
const created = await exec.create(
|
|
78
|
+
{
|
|
79
|
+
ownerId: admin.id,
|
|
80
|
+
grossTotal: { amount: 136.85, currency: "EUR" },
|
|
81
|
+
billingIban: "DE1234567890",
|
|
82
|
+
},
|
|
83
|
+
admin,
|
|
84
|
+
tdb,
|
|
85
|
+
);
|
|
86
|
+
expect(created.isSuccess).toBe(true);
|
|
87
|
+
|
|
88
|
+
const res = await exec.list({ limit: 50 }, admin, tdb);
|
|
89
|
+
expect(res.rows).toHaveLength(1);
|
|
90
|
+
const row = res.rows[0] as Record<string, unknown>;
|
|
91
|
+
expect(row["grossTotal"]).toEqual({ amount: 136.85, currency: "EUR", amountMinor: 13685 });
|
|
92
|
+
expect("grossTotalCurrency" in row).toBe(false);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('detail() via ownership.kind==="sql": money column arrives as { amount, currency }', async () => {
|
|
96
|
+
const created = await exec.create(
|
|
97
|
+
{
|
|
98
|
+
ownerId: admin.id,
|
|
99
|
+
grossTotal: { amount: 42.5, currency: "USD" },
|
|
100
|
+
billingIban: "DE9876543210",
|
|
101
|
+
},
|
|
102
|
+
admin,
|
|
103
|
+
tdb,
|
|
104
|
+
);
|
|
105
|
+
expect(created.isSuccess).toBe(true);
|
|
106
|
+
if (!created.isSuccess) return;
|
|
107
|
+
|
|
108
|
+
const row = (await exec.detail({ id: created.data.id }, admin, tdb)) as Record<
|
|
109
|
+
string,
|
|
110
|
+
unknown
|
|
111
|
+
> | null;
|
|
112
|
+
expect(row).not.toBeNull();
|
|
113
|
+
if (!row) return;
|
|
114
|
+
expect(row["grossTotal"]).toEqual({ amount: 42.5, currency: "USD", amountMinor: 4250 });
|
|
115
|
+
expect("grossTotalCurrency" in row).toBe(false);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('detail() via ownership.kind==="sql": encrypted field decrypts to plaintext, not ciphertext', async () => {
|
|
119
|
+
const plainIban = "DE44500105175407324931";
|
|
120
|
+
const created = await exec.create(
|
|
121
|
+
{
|
|
122
|
+
ownerId: admin.id,
|
|
123
|
+
grossTotal: { amount: 10, currency: "EUR" },
|
|
124
|
+
billingIban: plainIban,
|
|
125
|
+
},
|
|
126
|
+
admin,
|
|
127
|
+
tdb,
|
|
128
|
+
);
|
|
129
|
+
expect(created.isSuccess).toBe(true);
|
|
130
|
+
if (!created.isSuccess) return;
|
|
131
|
+
|
|
132
|
+
const row = (await exec.detail({ id: created.data.id }, admin, tdb)) as Record<
|
|
133
|
+
string,
|
|
134
|
+
unknown
|
|
135
|
+
> | null;
|
|
136
|
+
expect(row).not.toBeNull();
|
|
137
|
+
if (!row) return;
|
|
138
|
+
expect(row["billingIban"]).toBe(plainIban);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
@@ -167,13 +167,14 @@ export function createReadVerbs(ctx: ExecutorContext): Pick<EventStoreExecutor,
|
|
|
167
167
|
|
|
168
168
|
const rawRows = await executeRawQuery<Record<string, unknown>>(db.raw, listSql, params);
|
|
169
169
|
// Read-Side rehydrate pro Row + snake→camel coercion für driver-agnostic Feldnamen.
|
|
170
|
-
// Coerce BEFORE decrypt: the raw SELECT * rows carry snake_case
|
|
171
|
-
// names, while
|
|
172
|
-
//
|
|
173
|
-
//
|
|
170
|
+
// Coerce BEFORE rehydrate/decrypt: the raw SELECT * rows carry snake_case
|
|
171
|
+
// column names, while compound-type lookups (rehydrateMoney et al.) and the
|
|
172
|
+
// encrypted/pii field lists are all camelCase — running either first on a
|
|
173
|
+
// still-snake_case row silently no-ops (money) or skips every multi-word
|
|
174
|
+
// field (ciphertext leaked to the caller).
|
|
174
175
|
const tableInfo = extractTableInfo(table);
|
|
175
176
|
const encryptedRows = rawRows.map((r) =>
|
|
176
|
-
coerceRow(
|
|
177
|
+
rehydrateCompoundTypes(coerceRow(r, tableInfo), entity),
|
|
177
178
|
);
|
|
178
179
|
const rows = await Promise.all(encryptedRows.map((r) => decryptForRead(r)));
|
|
179
180
|
|
|
@@ -256,9 +257,11 @@ export function createReadVerbs(ctx: ExecutorContext): Pick<EventStoreExecutor,
|
|
|
256
257
|
const rows = await loadWithOwnership(db, idWhere, ownership);
|
|
257
258
|
const raw = rows[0];
|
|
258
259
|
if (!raw) return null;
|
|
259
|
-
|
|
260
|
+
// Same coerce-before-rehydrate/decrypt ordering as list() above — raw
|
|
261
|
+
// is snake_case only on the ownership.kind==="sql" branch (raw SQL);
|
|
262
|
+
// coerceRow is a no-op on the already-camelCase "pass" branch rows.
|
|
260
263
|
const rowInfo = extractTableInfo(table);
|
|
261
|
-
const coerced = coerceRow(
|
|
264
|
+
const coerced = await decryptForRead(rehydrateCompoundTypes(coerceRow(raw, rowInfo), entity));
|
|
262
265
|
|
|
263
266
|
if (entityCache && entityName) {
|
|
264
267
|
await entityCache.set(
|
|
@@ -557,3 +557,83 @@ describe("Observability (integration) — error path", () => {
|
|
|
557
557
|
expect(errorCounter?.labels?.["handler"]).toBe("err:write:boom");
|
|
558
558
|
});
|
|
559
559
|
});
|
|
560
|
+
|
|
561
|
+
// Simulates shared/library code called from several consumer features
|
|
562
|
+
// (framework#1844's ai-foundation scenario) — the same call site, not a
|
|
563
|
+
// copy-pasted inc() per feature. Feature names use real kebab-case (as
|
|
564
|
+
// declared at defineFeature time and used in QN dispatch types) — buildMetricName
|
|
565
|
+
// normalizes "-" to "_" so registration and lookup resolve to the same name.
|
|
566
|
+
function recordSharedCall(ctx: {
|
|
567
|
+
metricsFor: (featureName: string) => { inc: (n: string) => void };
|
|
568
|
+
}) {
|
|
569
|
+
ctx.metricsFor("shared-lib").inc("call_total");
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const sharedLibFeature = defineFeature("shared-lib", (r) => {
|
|
573
|
+
r.metric("call_total", { type: "counter" });
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
const consumerAFeature = defineFeature("consumer-a", (r) => {
|
|
577
|
+
r.writeHandler(
|
|
578
|
+
"run",
|
|
579
|
+
z.object({}),
|
|
580
|
+
async (_event, ctx) => {
|
|
581
|
+
recordSharedCall(ctx);
|
|
582
|
+
return { isSuccess: true, data: { ok: true } };
|
|
583
|
+
},
|
|
584
|
+
{ access: { openToAll: true } },
|
|
585
|
+
);
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
const consumerBFeature = defineFeature("consumer-b", (r) => {
|
|
589
|
+
r.writeHandler(
|
|
590
|
+
"run",
|
|
591
|
+
z.object({}),
|
|
592
|
+
async (_event, ctx) => {
|
|
593
|
+
recordSharedCall(ctx);
|
|
594
|
+
ctx.metricsFor("unregistered-lib").inc("never_total");
|
|
595
|
+
return { isSuccess: true, data: { ok: true } };
|
|
596
|
+
},
|
|
597
|
+
{ access: { openToAll: true } },
|
|
598
|
+
);
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
describe("Observability (integration) — ctx.metricsFor", () => {
|
|
602
|
+
let stack: TestStack;
|
|
603
|
+
let provider: RecordingProvider;
|
|
604
|
+
|
|
605
|
+
beforeEach(async () => {
|
|
606
|
+
provider = createRecordingProvider();
|
|
607
|
+
stack = await setupTestStack({
|
|
608
|
+
features: [sharedLibFeature, consumerAFeature, consumerBFeature],
|
|
609
|
+
observability: provider,
|
|
610
|
+
});
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
afterEach(async () => {
|
|
614
|
+
await stack.cleanup();
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
it("resolves the same library-owned metric name from two different consumer features", async () => {
|
|
618
|
+
await stack.http.command("consumer-a:write:run", {}, adminUser);
|
|
619
|
+
await stack.http.command("consumer-b:write:run", {}, adminUser);
|
|
620
|
+
|
|
621
|
+
const sharedEvents = provider.metricEvents.filter(
|
|
622
|
+
(e) => e.type === "counter.inc" && e.name === "kumiko_shared_lib_call_total",
|
|
623
|
+
);
|
|
624
|
+
expect(sharedEvents).toHaveLength(2);
|
|
625
|
+
|
|
626
|
+
const splinteredEvents = provider.metricEvents.filter(
|
|
627
|
+
(e) => e.type === "counter.inc" && /^kumiko_consumer_(a|b)_call_total$/.test(e.name),
|
|
628
|
+
);
|
|
629
|
+
expect(splinteredEvents).toHaveLength(0);
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
it("does not throw and emits nothing for an unregistered metricsFor name", async () => {
|
|
633
|
+
const res = await stack.http.command("consumer-b:write:run", {}, adminUser);
|
|
634
|
+
expect(res.status).toBeLessThan(300);
|
|
635
|
+
|
|
636
|
+
const neverEvents = provider.metricEvents.filter((e) => e.name.includes("never_total"));
|
|
637
|
+
expect(neverEvents).toHaveLength(0);
|
|
638
|
+
});
|
|
639
|
+
});
|
|
@@ -66,11 +66,22 @@ export function validateMetricName(name: string, type: MetricType): void {
|
|
|
66
66
|
|
|
67
67
|
// Prefix a short feature-local metric name with the Kumiko + feature prefix.
|
|
68
68
|
// Short name: "created_total". Feature: "orders". Result: "kumiko_orders_created_total".
|
|
69
|
+
//
|
|
70
|
+
// Feature names are kebab-case everywhere else (qualified-name segments,
|
|
71
|
+
// r.metric() is called with `feature.name` as registered at defineFeature
|
|
72
|
+
// time) — normalize "-" to "_" here so a feature like "ai-foundation"
|
|
73
|
+
// resolves to the same "kumiko_ai_foundation_x" on both the registration
|
|
74
|
+
// path (registry-ingest.ts) and the read path (ctx.metrics / ctx.metricsFor),
|
|
75
|
+
// instead of the kebab form being rejected outright (framework#1844).
|
|
69
76
|
export function buildMetricName(featureName: string, shortName: string): string {
|
|
70
|
-
|
|
71
|
-
|
|
77
|
+
const normalizedFeatureName = featureName.replace(/-/g, "_");
|
|
78
|
+
if (!SNAKE_CASE.test(normalizedFeatureName)) {
|
|
79
|
+
throw new Error(
|
|
80
|
+
`[Kumiko Observability] Feature name "${featureName}" must be kebab-case or snake_case ` +
|
|
81
|
+
`(a-z, 0-9, "-" or "_").`,
|
|
82
|
+
);
|
|
72
83
|
}
|
|
73
|
-
return `kumiko_${
|
|
84
|
+
return `kumiko_${normalizedFeatureName}_${shortName}`;
|
|
74
85
|
}
|
|
75
86
|
|
|
76
87
|
// Validate label keys: snake_case, not reserved.
|
|
@@ -27,6 +27,43 @@ export function createMetricsHandle(meter: Meter, featureName: string): MetricsH
|
|
|
27
27
|
};
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
// Same feature-bound resolution as createMetricsHandle, but for an
|
|
31
|
+
// explicit `featureName` chosen by the caller rather than the dispatching
|
|
32
|
+
// handler's own feature (framework#1844). Meant for shared/library code
|
|
33
|
+
// invoked from many features' HandlerContext (ctx.metricsFor) — the
|
|
34
|
+
// library owns one stable metric name instead of splintering into
|
|
35
|
+
// kumiko_<caller>_x per consumer.
|
|
36
|
+
//
|
|
37
|
+
// Decision (framework#1844 DoD): unlike createMetricsHandle, an
|
|
38
|
+
// unregistered name here is a silent no-op, not a throw. This handle is
|
|
39
|
+
// meant for error/catch-path counters in shared code — a missing
|
|
40
|
+
// registration (consuming feature not mounted, metric not declared yet)
|
|
41
|
+
// must not turn an already-swallowed error into a thrown one. Every other
|
|
42
|
+
// failure (invalid featureName, wrong metric type for the call) still
|
|
43
|
+
// throws — only the "not registered" case is swallowed.
|
|
44
|
+
export function createSafeMetricsHandle(meter: Meter, featureName: string): MetricsHandle {
|
|
45
|
+
return {
|
|
46
|
+
inc(shortName, labels, value) {
|
|
47
|
+
const name = buildMetricName(featureName, shortName);
|
|
48
|
+
// skip: unregistered name is the documented no-op contract of this handle
|
|
49
|
+
if (!meter.definitions().has(name)) return;
|
|
50
|
+
meter.counter(name).inc(value, labels);
|
|
51
|
+
},
|
|
52
|
+
observe(shortName, value, labels) {
|
|
53
|
+
const name = buildMetricName(featureName, shortName);
|
|
54
|
+
// skip: unregistered name is the documented no-op contract of this handle
|
|
55
|
+
if (!meter.definitions().has(name)) return;
|
|
56
|
+
meter.histogram(name).observe(value, labels);
|
|
57
|
+
},
|
|
58
|
+
set(shortName, value, labels) {
|
|
59
|
+
const name = buildMetricName(featureName, shortName);
|
|
60
|
+
// skip: unregistered name is the documented no-op contract of this handle
|
|
61
|
+
if (!meter.definitions().has(name)) return;
|
|
62
|
+
meter.gauge(name).set(value, labels);
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
30
67
|
// Fallback for contexts where the feature is unknown (e.g. system-hooks,
|
|
31
68
|
// internal pipeline code). Short names are used verbatim — useful for
|
|
32
69
|
// framework-level usage, but rejected by the Meter unless pre-registered.
|
|
@@ -53,6 +53,7 @@ import { createFileContext } from "../files/file-handle";
|
|
|
53
53
|
import {
|
|
54
54
|
createMetricsHandle,
|
|
55
55
|
createNoopMetricsHandle,
|
|
56
|
+
createSafeMetricsHandle,
|
|
56
57
|
emitDispatcherError,
|
|
57
58
|
emitDispatcherHandler,
|
|
58
59
|
type getFallbackMeter,
|
|
@@ -217,6 +218,13 @@ export async function buildHandlerContext(
|
|
|
217
218
|
const featureName = registry.getHandlerFeature(type);
|
|
218
219
|
const metrics =
|
|
219
220
|
meter && featureName ? createMetricsHandle(meter, featureName) : createNoopMetricsHandle();
|
|
221
|
+
// ctx.metricsFor(featureName) — shared/library code binds to a feature
|
|
222
|
+
// name of its own choosing instead of the dispatching handler's
|
|
223
|
+
// (framework#1844). Unregistered names no-op rather than throw, see
|
|
224
|
+
// createSafeMetricsHandle.
|
|
225
|
+
const metricsFor = meter
|
|
226
|
+
? (targetFeatureName: string) => createSafeMetricsHandle(meter, targetFeatureName)
|
|
227
|
+
: () => createNoopMetricsHandle();
|
|
220
228
|
|
|
221
229
|
// Cross-feature bridge. Queries and writes invoked through ctx.* share:
|
|
222
230
|
// - the current transaction (tx) — nested writes roll back with the parent
|
|
@@ -571,6 +579,7 @@ export async function buildHandlerContext(
|
|
|
571
579
|
}),
|
|
572
580
|
tracer,
|
|
573
581
|
metrics,
|
|
582
|
+
metricsFor,
|
|
574
583
|
tz,
|
|
575
584
|
// Cancellation signal flows from the HTTP middleware via
|
|
576
585
|
// requestContext. Conditional spread so non-HTTP entry-points
|
|
@@ -58,6 +58,7 @@ export function bridgeStub(opts?: {
|
|
|
58
58
|
| "resolveAuthClaims"
|
|
59
59
|
| "hasFeature"
|
|
60
60
|
| "metrics"
|
|
61
|
+
| "metricsFor"
|
|
61
62
|
| "tracer"
|
|
62
63
|
| "tz"
|
|
63
64
|
| "user"
|
|
@@ -119,6 +120,7 @@ export function bridgeStub(opts?: {
|
|
|
119
120
|
// when no effectiveFeatures resolver is wired (tests without toggles).
|
|
120
121
|
hasFeature: async () => true,
|
|
121
122
|
metrics: createNoopMetricsHandle(),
|
|
123
|
+
metricsFor: () => createNoopMetricsHandle(),
|
|
122
124
|
tracer: noopTracer,
|
|
123
125
|
// Echter TzContext, kein notAvailable — Test-Code nutzt ctx.tz häufig
|
|
124
126
|
// ohne dass es ein "Bridge"-Konzept ist. Default UTC.
|