@cosmicdrift/kumiko-framework 0.184.0 → 0.186.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 +3 -3
- package/src/db/__tests__/compound-types.test.ts +2 -2
- package/src/db/__tests__/money.test.ts +30 -14
- package/src/db/money.ts +6 -3
- package/src/engine/__tests__/boot-validator.test.ts +112 -16
- package/src/engine/__tests__/engine.test.ts +183 -0
- package/src/engine/__tests__/schema-builder.test.ts +218 -0
- package/src/engine/boot-validator/entity-handler.ts +247 -65
- package/src/engine/boot-validator/index.ts +1 -1
- package/src/engine/embedded-derived.ts +51 -0
- package/src/engine/schema-builder.ts +81 -6
- package/src/engine/types/index.ts +1 -0
- 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/__tests__/ctx-bridge.integration.test.ts +34 -0
- package/src/pipeline/dispatch-shared.ts +28 -14
- package/src/testing/handler-context.ts +2 -0
- package/src/ui-types/index.ts +1 -0
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { toMinorUnits } from "../db/money";
|
|
2
3
|
import { isValidIanaTimeZone } from "../time";
|
|
3
4
|
import { assertUnreachable } from "../utils";
|
|
5
|
+
import { withDerivedCells } from "./embedded-derived";
|
|
4
6
|
import type { EmbeddedSubFieldDef, EntityDefinition, FieldDefinition } from "./types";
|
|
5
7
|
import { DEFAULT_CURRENCIES } from "./types";
|
|
6
8
|
|
|
@@ -55,6 +57,10 @@ function embeddedSubFieldToZod(subField: EmbeddedSubFieldDef): z.ZodTypeAny {
|
|
|
55
57
|
// row. The safe-integer cap mirrors bigInt mode:"number" — jsonb has no
|
|
56
58
|
// BIGINT column behind it, so 2^53 is the real representability boundary.
|
|
57
59
|
return z.number().int().safe();
|
|
60
|
+
case "timestamp":
|
|
61
|
+
// No locatedBy/min/max on EmbeddedSubFieldDef (unlike the top-level
|
|
62
|
+
// timestamp field) — plain UTC-instant ISO-datetime validation.
|
|
63
|
+
return z.iso.datetime();
|
|
58
64
|
case "decimal": {
|
|
59
65
|
// No numeric column behind jsonb, so the bounds come from float
|
|
60
66
|
// representability alone: the value scaled by 10^scale must be a safe
|
|
@@ -68,6 +74,13 @@ function embeddedSubFieldToZod(subField: EmbeddedSubFieldDef): z.ZodTypeAny {
|
|
|
68
74
|
message: `at most ${subField.scale} decimal places`,
|
|
69
75
|
});
|
|
70
76
|
}
|
|
77
|
+
case "select": {
|
|
78
|
+
const [first, ...rest] = subField.options;
|
|
79
|
+
if (!first) return z.string();
|
|
80
|
+
return z.enum([first, ...rest]);
|
|
81
|
+
}
|
|
82
|
+
case "reference":
|
|
83
|
+
return z.uuid();
|
|
71
84
|
default:
|
|
72
85
|
assertUnreachable(subField, "embedded sub-field type");
|
|
73
86
|
}
|
|
@@ -191,13 +204,24 @@ export function fieldToZod(
|
|
|
191
204
|
const zodSub = embeddedSubFieldToZod(subField);
|
|
192
205
|
shape[subName] = subField.required ? zodSub : zodSub.optional();
|
|
193
206
|
}
|
|
194
|
-
const
|
|
207
|
+
const baseRow = z.object(shape);
|
|
208
|
+
const derived = field.derived;
|
|
209
|
+
// The server is the authority for derived cells: a row is recomputed
|
|
210
|
+
// here, overwriting whatever the client sent, instead of merely
|
|
211
|
+
// being checked against it.
|
|
212
|
+
const row =
|
|
213
|
+
derived === undefined
|
|
214
|
+
? baseRow
|
|
215
|
+
: z.preprocess((value) => withDerivedCells(value, derived), baseRow);
|
|
195
216
|
if (field.multiple !== true) return row;
|
|
196
217
|
// `required: true` means non-empty, same reading as multiSelect —
|
|
197
218
|
// whether the key may be omitted at all is decided by buildInsertSchema
|
|
198
|
-
// off the same flag.
|
|
199
|
-
|
|
200
|
-
|
|
219
|
+
// off the same flag. `minItems` overrides that default when set.
|
|
220
|
+
let list = z.array(row);
|
|
221
|
+
const min = field.minItems ?? (field.required === true ? 1 : undefined);
|
|
222
|
+
if (min !== undefined) list = list.min(min);
|
|
223
|
+
if (field.maxItems !== undefined) list = list.max(field.maxItems);
|
|
224
|
+
return list;
|
|
201
225
|
}
|
|
202
226
|
case "jsonb": {
|
|
203
227
|
// Free-form jsonb — keys sind tenant-/runtime-defined. Validation
|
|
@@ -266,6 +290,57 @@ export function fieldToZod(
|
|
|
266
290
|
}
|
|
267
291
|
}
|
|
268
292
|
|
|
293
|
+
// Cross-field check for `EmbeddedFieldDef.totalsMatch`: the sum of a list
|
|
294
|
+
// subfield across every row must equal a sibling top-level money field.
|
|
295
|
+
// Runs via the same z.object().safeParse() call on both the client
|
|
296
|
+
// (form-controller's runValidate) and the server (write handler) — one
|
|
297
|
+
// mechanism, no separate client/server validation path to keep in sync.
|
|
298
|
+
// ponytail: compares raw minor-unit amounts only, not currencies — a row
|
|
299
|
+
// sum in the entity's default currency against a sibling amount tagged with
|
|
300
|
+
// a different currency string still passes. Add a currency-equality check
|
|
301
|
+
// here if multi-currency siblings become a real case.
|
|
302
|
+
function applyTotalsMatchRefinements(
|
|
303
|
+
entity: EntityDefinition,
|
|
304
|
+
schema: z.ZodObject<Record<string, z.ZodTypeAny>>,
|
|
305
|
+
): z.ZodObject<Record<string, z.ZodTypeAny>> {
|
|
306
|
+
let result = schema;
|
|
307
|
+
for (const [fieldName, field] of Object.entries(entity.fields)) {
|
|
308
|
+
if (field.type !== "embedded" || field.totalsMatch === undefined) continue;
|
|
309
|
+
const totalsMatch = field.totalsMatch;
|
|
310
|
+
result = result.superRefine((values, ctx) => {
|
|
311
|
+
for (const [subFieldName, siblingFieldName] of Object.entries(totalsMatch)) {
|
|
312
|
+
const rows = values[fieldName] as ReadonlyArray<Record<string, unknown>> | undefined;
|
|
313
|
+
const siblingRaw = values[siblingFieldName];
|
|
314
|
+
// Not sent -> not checkable, not an error (partial update payloads).
|
|
315
|
+
if (rows === undefined || siblingRaw === undefined) continue;
|
|
316
|
+
const siblingAmount =
|
|
317
|
+
typeof siblingRaw === "object" &&
|
|
318
|
+
siblingRaw !== null &&
|
|
319
|
+
"amount" in siblingRaw &&
|
|
320
|
+
typeof (siblingRaw as { amount: unknown }).amount === "number"
|
|
321
|
+
? (siblingRaw as { amount: number }).amount
|
|
322
|
+
: typeof siblingRaw === "number"
|
|
323
|
+
? siblingRaw
|
|
324
|
+
: undefined;
|
|
325
|
+
if (siblingAmount === undefined) continue;
|
|
326
|
+
const sumMinor = rows.reduce(
|
|
327
|
+
(total, row) =>
|
|
328
|
+
total + (typeof row[subFieldName] === "number" ? (row[subFieldName] as number) : 0),
|
|
329
|
+
0,
|
|
330
|
+
);
|
|
331
|
+
if (sumMinor !== toMinorUnits(siblingAmount)) {
|
|
332
|
+
ctx.addIssue({
|
|
333
|
+
code: "custom",
|
|
334
|
+
path: [fieldName],
|
|
335
|
+
message: `Sum of "${subFieldName}" across "${fieldName}" (${sumMinor}) does not match "${siblingFieldName}" (${toMinorUnits(siblingAmount)})`,
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
return result;
|
|
342
|
+
}
|
|
343
|
+
|
|
269
344
|
export function buildInsertSchema(
|
|
270
345
|
entity: EntityDefinition,
|
|
271
346
|
currencies: readonly string[] = [...DEFAULT_CURRENCIES],
|
|
@@ -279,7 +354,7 @@ export function buildInsertSchema(
|
|
|
279
354
|
shape[name] = isRequired || hasDefault ? zodField : zodField.optional();
|
|
280
355
|
}
|
|
281
356
|
|
|
282
|
-
return z.object(shape);
|
|
357
|
+
return applyTotalsMatchRefinements(entity, z.object(shape));
|
|
283
358
|
}
|
|
284
359
|
|
|
285
360
|
export function buildUpdateSchema(
|
|
@@ -300,5 +375,5 @@ export function buildUpdateSchema(
|
|
|
300
375
|
shape[name] = fieldToZod(field, currencies, { applyDefaults: false }).optional();
|
|
301
376
|
}
|
|
302
377
|
|
|
303
|
-
return z.object(shape);
|
|
378
|
+
return applyTotalsMatchRefinements(entity, z.object(shape));
|
|
304
379
|
}
|
|
@@ -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.
|
|
@@ -116,6 +116,25 @@ const bridgeFeature = defineFeature("ctxbridge", (r) => {
|
|
|
116
116
|
{ access: { roles: ["Admin"] } },
|
|
117
117
|
);
|
|
118
118
|
|
|
119
|
+
// Two inserts, one via ctx.db (tx-bound) and one via ctx.dbOutsideTransaction
|
|
120
|
+
// (unbound pool), then an unconditional failure. Proves the outside-tx write
|
|
121
|
+
// survives the handler's own rollback while the tx-bound one doesn't.
|
|
122
|
+
r.writeHandler(
|
|
123
|
+
"bag:create-outside-tx-then-fail",
|
|
124
|
+
z.object({ label: z.string() }),
|
|
125
|
+
async (event, ctx) => {
|
|
126
|
+
const crud = createEventStoreExecutor(bagTable, bagEntity, { entityName: "bag" });
|
|
127
|
+
await crud.create({ label: `${event.payload.label}-inside-tx` }, event.user, ctx.db);
|
|
128
|
+
await crud.create(
|
|
129
|
+
{ label: `${event.payload.label}-outside-tx` },
|
|
130
|
+
event.user,
|
|
131
|
+
ctx.dbOutsideTransaction,
|
|
132
|
+
);
|
|
133
|
+
return writeFailure(new UnprocessableError("intentional_failure"));
|
|
134
|
+
},
|
|
135
|
+
{ access: { roles: ["Admin"] } },
|
|
136
|
+
);
|
|
137
|
+
|
|
119
138
|
// Handler that fetches the secret via ctx.queryAs(system) — proves the
|
|
120
139
|
// privileged call returns the token field even though the caller (Admin)
|
|
121
140
|
// couldn't read it themselves.
|
|
@@ -233,3 +252,18 @@ describe("ctx.writeAs shares the outer transaction", () => {
|
|
|
233
252
|
expect(afterCommitLog).toEqual([]);
|
|
234
253
|
});
|
|
235
254
|
});
|
|
255
|
+
|
|
256
|
+
describe("ctx.dbOutsideTransaction", () => {
|
|
257
|
+
test("a write through it survives the handler's own transaction rolling back", async () => {
|
|
258
|
+
const res = await stack.http.write(
|
|
259
|
+
"ctxbridge:write:bag:create-outside-tx-then-fail",
|
|
260
|
+
{ label: "probe" },
|
|
261
|
+
admin,
|
|
262
|
+
);
|
|
263
|
+
expect((await res.json()).isSuccess).toBe(false);
|
|
264
|
+
|
|
265
|
+
const bags = await selectMany(stack.db, bagTable);
|
|
266
|
+
const labels = (bags as Array<Record<string, unknown>>).map((row) => row["label"]);
|
|
267
|
+
expect(labels).toEqual(["probe-outside-tx"]);
|
|
268
|
+
});
|
|
269
|
+
});
|
|
@@ -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,
|
|
@@ -168,20 +169,24 @@ export async function buildHandlerContext(
|
|
|
168
169
|
// but at this point we're the root of the pipeline — cast is safe.
|
|
169
170
|
const dbSource = resolveDbSource(ctx, tx);
|
|
170
171
|
const reqCtx = requestContext.get();
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
172
|
+
const buildTenantScopedDb = (source: DbConnection | DbTx) =>
|
|
173
|
+
createTenantDb(
|
|
174
|
+
source,
|
|
175
|
+
user.tenantId,
|
|
176
|
+
isSystem ? "system" : "tenant",
|
|
177
|
+
context.tracer,
|
|
178
|
+
context.meter,
|
|
179
|
+
// Propagate the request's AbortSignal so every TenantDb query
|
|
180
|
+
// throws when the client has disconnected — handlers with many
|
|
181
|
+
// sequential queries skip the rest of the chain instead of
|
|
182
|
+
// burning DB-CPU for results no one reads.
|
|
183
|
+
reqCtx?.signal,
|
|
184
|
+
);
|
|
185
|
+
const db = dbSource ? buildTenantScopedDb(dbSource) : undefined;
|
|
186
|
+
// Unbound pool, tenant-scoped like `db` but never tx-bound — writes
|
|
187
|
+
// through it survive a rollback of the handler's own transaction.
|
|
188
|
+
const outsideTxSource = resolveDbSource(ctx, undefined);
|
|
189
|
+
const dbOutsideTransaction = outsideTxSource ? buildTenantScopedDb(outsideTxSource) : undefined;
|
|
185
190
|
const log = context.log?.child({
|
|
186
191
|
handler: type,
|
|
187
192
|
tenantId: user.tenantId,
|
|
@@ -213,6 +218,13 @@ export async function buildHandlerContext(
|
|
|
213
218
|
const featureName = registry.getHandlerFeature(type);
|
|
214
219
|
const metrics =
|
|
215
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();
|
|
216
228
|
|
|
217
229
|
// Cross-feature bridge. Queries and writes invoked through ctx.* share:
|
|
218
230
|
// - the current transaction (tx) — nested writes roll back with the parent
|
|
@@ -548,6 +560,7 @@ export async function buildHandlerContext(
|
|
|
548
560
|
...context,
|
|
549
561
|
registry,
|
|
550
562
|
db,
|
|
563
|
+
dbOutsideTransaction,
|
|
551
564
|
log,
|
|
552
565
|
notify,
|
|
553
566
|
...(config && { config }),
|
|
@@ -566,6 +579,7 @@ export async function buildHandlerContext(
|
|
|
566
579
|
}),
|
|
567
580
|
tracer,
|
|
568
581
|
metrics,
|
|
582
|
+
metricsFor,
|
|
569
583
|
tz,
|
|
570
584
|
// Cancellation signal flows from the HTTP middleware via
|
|
571
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.
|
package/src/ui-types/index.ts
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
// When adding a symbol here, verify it's either a type or a pure
|
|
21
21
|
// helper with no cross-module side-effects.
|
|
22
22
|
|
|
23
|
+
export { computeDerivedCellValue } from "../engine/embedded-derived";
|
|
23
24
|
export type { ParsedRefTarget } from "../engine/parse-ref-target";
|
|
24
25
|
export { parseRefTarget } from "../engine/parse-ref-target";
|
|
25
26
|
export {
|