@cosmicdrift/kumiko-framework 0.193.1 → 0.195.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/api/__tests__/http-route-rate-limit.integration.test.ts +71 -0
- package/src/api/server.ts +1 -1
- package/src/db/__tests__/migrate-generator.test.ts +17 -0
- package/src/db/__tests__/money.test.ts +41 -5
- package/src/db/event-store-executor-read.ts +1 -1
- package/src/db/index.ts +1 -1
- package/src/db/migrate-generator.ts +13 -3
- package/src/db/money.ts +34 -4
- package/src/derivatives/__tests__/derivatives-context.integration.test.ts +73 -6
- package/src/derivatives/__tests__/derivatives-context.test.ts +9 -2
- package/src/derivatives/__tests__/variant-key.test.ts +2 -2
- package/src/derivatives/__tests__/variant-route.integration.test.ts +28 -0
- package/src/derivatives/derivatives-context.ts +7 -7
- package/src/derivatives/variant-key.ts +1 -1
- package/src/engine/__tests__/boot-validator.test.ts +140 -0
- package/src/engine/__tests__/embedded-derived.test.ts +35 -0
- package/src/engine/__tests__/engine.test.ts +20 -0
- package/src/engine/__tests__/schema-builder.test.ts +93 -0
- package/src/engine/boot-validator/entity-handler.ts +64 -5
- package/src/engine/boot-validator/screens.ts +57 -36
- package/src/engine/embedded-derived.ts +9 -1
- package/src/engine/schema-builder.ts +33 -23
- package/src/entrypoint/__tests__/split-deploy.integration.test.ts +37 -6
- package/src/errors/zod-bridge.ts +4 -9
- package/src/event-store/__tests__/perf.integration.test.ts +2 -11
- package/src/files/file-routes.ts +20 -6
- package/src/files/storage-tracking.ts +2 -1
- package/src/jobs/job-runner.ts +18 -9
- package/src/logging/__tests__/fallback-logger.test.ts +43 -0
- package/src/logging/utils.ts +14 -1
- package/src/observability/__tests__/metric-validator.test.ts +10 -2
- package/src/observability/__tests__/metrics-handle.test.ts +30 -0
- package/src/observability/metric-validator.ts +4 -3
- package/src/observability/metrics-handle.ts +24 -12
- package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +69 -5
- package/src/pipeline/dispatch-shared.ts +12 -9
- package/src/ui-types/index.ts +1 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import {
|
|
2
|
+
import { moneyPayloadToMinorUnits } from "../db/money";
|
|
3
3
|
import { isValidIanaTimeZone } from "../time";
|
|
4
4
|
import { assertUnreachable } from "../utils";
|
|
5
5
|
import { withDerivedCells } from "./embedded-derived";
|
|
@@ -295,14 +295,14 @@ export function fieldToZod(
|
|
|
295
295
|
// Runs via the same z.object().safeParse() call on both the client
|
|
296
296
|
// (form-controller's runValidate) and the server (write handler) — one
|
|
297
297
|
// mechanism, no separate client/server validation path to keep in sync.
|
|
298
|
-
//
|
|
299
|
-
//
|
|
300
|
-
//
|
|
301
|
-
// here if multi-currency siblings become a real case.
|
|
298
|
+
// Row cells have no currency of their own (they're minor units in the
|
|
299
|
+
// entity's default currency); a sibling tagged with a different currency
|
|
300
|
+
// fails the check even if the raw minor-unit amounts happen to match.
|
|
302
301
|
//
|
|
303
302
|
// Known limitation: compares against rounded `derived` cells, i.e.
|
|
304
303
|
// "sum-of-rounded" not "round-of-sum" (kumiko-framework#1866). Follow-up
|
|
305
304
|
// for a computed, read-only sibling total: kumiko-framework#1873.
|
|
305
|
+
// kumiko-lint-ignore complexity-budget currency-equality check on sibling money payloads
|
|
306
306
|
function applyTotalsMatchRefinements(
|
|
307
307
|
entity: EntityDefinition,
|
|
308
308
|
schema: z.ZodObject<Record<string, z.ZodTypeAny>>,
|
|
@@ -313,30 +313,40 @@ function applyTotalsMatchRefinements(
|
|
|
313
313
|
const totalsMatch = field.totalsMatch;
|
|
314
314
|
result = result.superRefine((values, ctx) => {
|
|
315
315
|
for (const [subFieldName, siblingFieldName] of Object.entries(totalsMatch)) {
|
|
316
|
-
const
|
|
316
|
+
const rawRows = values[fieldName];
|
|
317
317
|
const siblingRaw = values[siblingFieldName];
|
|
318
318
|
// Not sent -> not checkable, not an error (partial update payloads).
|
|
319
|
-
if (
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
319
|
+
if (rawRows === undefined || siblingRaw === undefined) continue;
|
|
320
|
+
// Not an array -> a different refinement already rejects the shape;
|
|
321
|
+
// this check isn't the right place to report it.
|
|
322
|
+
if (!Array.isArray(rawRows)) continue;
|
|
323
|
+
const siblingMinor = moneyPayloadToMinorUnits(siblingRaw);
|
|
324
|
+
if (siblingMinor === undefined) continue;
|
|
325
|
+
const siblingCurrency =
|
|
326
|
+
typeof siblingRaw === "object" && siblingRaw !== null && "currency" in siblingRaw
|
|
327
|
+
? (siblingRaw as { currency: unknown }).currency
|
|
328
|
+
: undefined;
|
|
329
|
+
const entityCurrency = entity.defaultCurrency ?? DEFAULT_CURRENCIES[0];
|
|
330
|
+
if (typeof siblingCurrency === "string" && siblingCurrency !== entityCurrency) {
|
|
331
|
+
ctx.addIssue({
|
|
332
|
+
code: "custom",
|
|
333
|
+
path: [siblingFieldName],
|
|
334
|
+
message: `"${siblingFieldName}" currency (${siblingCurrency}) does not match the entity's default currency (${entityCurrency}) that "${fieldName}" rows are summed in`,
|
|
335
|
+
});
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
const sumMinor = rawRows.reduce((total: number, row: unknown) => {
|
|
339
|
+
const value =
|
|
340
|
+
typeof row === "object" && row !== null
|
|
341
|
+
? (row as Record<string, unknown>)[subFieldName]
|
|
328
342
|
: undefined;
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
total + (typeof row[subFieldName] === "number" ? (row[subFieldName] as number) : 0),
|
|
333
|
-
0,
|
|
334
|
-
);
|
|
335
|
-
if (sumMinor !== toMinorUnits(siblingAmount)) {
|
|
343
|
+
return total + (typeof value === "number" ? value : 0);
|
|
344
|
+
}, 0);
|
|
345
|
+
if (sumMinor !== siblingMinor) {
|
|
336
346
|
ctx.addIssue({
|
|
337
347
|
code: "custom",
|
|
338
348
|
path: [fieldName],
|
|
339
|
-
message: `Sum of "${subFieldName}" across "${fieldName}" (${sumMinor}) does not match "${siblingFieldName}" (${
|
|
349
|
+
message: `Sum of "${subFieldName}" across "${fieldName}" (${sumMinor}) does not match "${siblingFieldName}" (${siblingMinor})`,
|
|
340
350
|
});
|
|
341
351
|
}
|
|
342
352
|
}
|
|
@@ -66,8 +66,13 @@ const workerWriteFeature = defineFeature("workerWrite", (r) => {
|
|
|
66
66
|
// The job-runner is built BEFORE the server, so it used to capture the raw
|
|
67
67
|
// caller context — without the per-tenant file-provider resolver buildServer
|
|
68
68
|
// wires onto it. An event-triggered job reaching for ctx.files then died in
|
|
69
|
-
// the worker while the identical code worked on the request path.
|
|
70
|
-
|
|
69
|
+
// the worker while the identical code worked on the request path. Recording
|
|
70
|
+
// `typeof ctx.files?.ref` (not the private `_fileProviderResolver` wire
|
|
71
|
+
// field) pins the observable symptom: a usable ctx.files handle. `ref()`
|
|
72
|
+
// itself stays lazy (file-handle.ts) — it never calls the resolver, which
|
|
73
|
+
// this feature's provider intentionally throws in, so the assertion below
|
|
74
|
+
// doesn't need a working provider to be meaningful.
|
|
75
|
+
const jobSawFilesRef: string[] = [];
|
|
71
76
|
|
|
72
77
|
const fileJobFeature = defineFeature("fileJob", (r) => {
|
|
73
78
|
const requested = r.defineEvent("bytes-requested", z.object({ storageKey: z.string() }), {
|
|
@@ -84,7 +89,7 @@ const fileJobFeature = defineFeature("fileJob", (r) => {
|
|
|
84
89
|
"read-bytes",
|
|
85
90
|
{ trigger: { on: requested.name }, runIn: "worker" },
|
|
86
91
|
async (_payload, ctx) => {
|
|
87
|
-
|
|
92
|
+
jobSawFilesRef.push(typeof ctx.files?.ref);
|
|
88
93
|
},
|
|
89
94
|
);
|
|
90
95
|
// Worker mode refuses to boot without a consumer to drain.
|
|
@@ -176,7 +181,7 @@ describe("entrypoint factories", () => {
|
|
|
176
181
|
queueNamePrefix: uniquePrefix("split-filejob"),
|
|
177
182
|
});
|
|
178
183
|
|
|
179
|
-
|
|
184
|
+
jobSawFilesRef.length = 0;
|
|
180
185
|
await worker.start();
|
|
181
186
|
try {
|
|
182
187
|
await worker.jobRunner.handleEvent(
|
|
@@ -184,8 +189,8 @@ describe("entrypoint factories", () => {
|
|
|
184
189
|
{ storageKey: "some/key.pdf" },
|
|
185
190
|
TestUsers.admin,
|
|
186
191
|
);
|
|
187
|
-
await waitForCondition(() =>
|
|
188
|
-
expect(
|
|
192
|
+
await waitForCondition(() => jobSawFilesRef.length > 0);
|
|
193
|
+
expect(jobSawFilesRef[0]).toBe("function");
|
|
189
194
|
} finally {
|
|
190
195
|
await worker.stop();
|
|
191
196
|
}
|
|
@@ -234,6 +239,32 @@ describe("entrypoint factories", () => {
|
|
|
234
239
|
}
|
|
235
240
|
});
|
|
236
241
|
|
|
242
|
+
test("All-in-one job-context also carries ctx.files, same fixture as the worker", async () => {
|
|
243
|
+
const registry = createRegistry([fileJobFeature]);
|
|
244
|
+
const redisUrl = `redis://${testRedis.redis.options.host}:${testRedis.redis.options.port}/${testRedis.redis.options.db}`;
|
|
245
|
+
const entry = createAllInOneEntrypoint({
|
|
246
|
+
registry,
|
|
247
|
+
context: { db: testDb.db, redis: testRedis.redis },
|
|
248
|
+
jwtSecret: JWT,
|
|
249
|
+
redisUrl,
|
|
250
|
+
queueNamePrefix: uniquePrefix("all-filejob"),
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
jobSawFilesRef.length = 0;
|
|
254
|
+
await entry.start();
|
|
255
|
+
try {
|
|
256
|
+
await entry.jobRunner.handleEvent(
|
|
257
|
+
"file-job:event:bytes-requested",
|
|
258
|
+
{ storageKey: "some/key.pdf" },
|
|
259
|
+
TestUsers.admin,
|
|
260
|
+
);
|
|
261
|
+
await waitForCondition(() => jobSawFilesRef.length > 0);
|
|
262
|
+
expect(jobSawFilesRef[0]).toBe("function");
|
|
263
|
+
} finally {
|
|
264
|
+
await entry.stop();
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
|
|
237
268
|
test("All-in-one entrypoint has both HTTP surface and background workers", async () => {
|
|
238
269
|
const registry = createRegistry([splitFeature]);
|
|
239
270
|
const redisUrl = process.env["REDIS_URL"] ?? "redis://localhost:16379";
|
package/src/errors/zod-bridge.ts
CHANGED
|
@@ -37,15 +37,10 @@ export function validationErrorFromZod(error: ZodError): ValidationError {
|
|
|
37
37
|
return new ValidationError({ fields }, { cause: error });
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
// `
|
|
43
|
-
//
|
|
44
|
-
// same `errors.validation.custom` ("Invalid value.") key. A `superRefine`
|
|
45
|
-
// that needs its own key sets `params.i18nKey` on the issue; this is the one
|
|
46
|
-
// place that honors it. Keep in sync with the client-side mirror
|
|
47
|
-
// (packages/headless/src/form/zod-bridge.ts) — a superRefine can run on
|
|
48
|
-
// either side.
|
|
40
|
+
// `code: "custom"` is zod's catch-all for every superRefine/refine check;
|
|
41
|
+
// left mechanical it'd collapse onto one generic key, so a superRefine can
|
|
42
|
+
// set `params.i18nKey` to override it. Keep in sync with the client mirror
|
|
43
|
+
// (packages/headless/src/form/zod-bridge.ts).
|
|
49
44
|
function resolveI18nKey(issue: ZodIssue): string {
|
|
50
45
|
if (issue.code === "custom") {
|
|
51
46
|
const override = issue.params?.["i18nKey"];
|
|
@@ -12,17 +12,8 @@
|
|
|
12
12
|
// latency, single-node PG. Production deploys are slower; these numbers
|
|
13
13
|
// are the ceiling.
|
|
14
14
|
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
// suite, and flaked up to 3.4x under that (30-102ms vs the 25-30ms budgets
|
|
18
|
-
// above, #1940). Moved to its own `event-store-perf` CI job
|
|
19
|
-
// (test:integration:perf:eventstore) — but re-measuring against a fresh
|
|
20
|
-
// container per run (mirroring that job) showed the real cause wasn't job
|
|
21
|
-
// contention: p50 sits at 1-3ms in every run, and single-sample p99 spikes
|
|
22
|
-
// to 47-73ms even fully isolated on an idle machine, from cold-Postgres
|
|
23
|
-
// connection/cache warm-up. Gate switched from p99 (the single worst-of-200
|
|
24
|
-
// sample) to p95 (drops the top 10), which absorbs that cold-start outlier
|
|
25
|
-
// while still catching a real order-of-magnitude regression.
|
|
15
|
+
// Runs isolated in the `event-store-perf` CI job (test:integration:perf:eventstore,
|
|
16
|
+
// #1940) — see that job's comment in ci.yml for why the gate is p95 not p99.
|
|
26
17
|
|
|
27
18
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
28
19
|
import { type BunTestDb, createTestDb } from "../../bun-db/__tests__/bun-test-db";
|
package/src/files/file-routes.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { getUser } from "../api/auth-middleware";
|
|
|
4
4
|
import type { DbConnection } from "../db/connection";
|
|
5
5
|
import { createEventStoreExecutor } from "../db/event-store-executor";
|
|
6
6
|
import { createTenantDb } from "../db/tenant-db";
|
|
7
|
-
import { createDerivativesContext, resolveFieldVariant } from "../derivatives";
|
|
7
|
+
import { createDerivativesContext, resolveFieldVariant, resolveRenderer } from "../derivatives";
|
|
8
8
|
import { isFileField, type Registry, type SessionUser, type TenantId } from "../engine/types";
|
|
9
9
|
import { generateId } from "../utils";
|
|
10
10
|
import { buildContentDispositionHeader } from "./content-disposition";
|
|
@@ -68,7 +68,7 @@ const DEFAULT_PRIVILEGED_ROLES = ["Admin", "SystemAdmin"] as const;
|
|
|
68
68
|
|
|
69
69
|
// 15 minutes — long enough for a download to start, short enough that a
|
|
70
70
|
// leaked URL (e.g. from a browser history screenshot) isn't a long-lived
|
|
71
|
-
// credential
|
|
71
|
+
// credential (see "Signed-URL default expiry" in core-files.md).
|
|
72
72
|
const SIGNED_URL_DEFAULT_EXPIRY_SECONDS = 15 * 60;
|
|
73
73
|
|
|
74
74
|
// Default guard: on attached files, allow the uploader or a privileged role.
|
|
@@ -252,15 +252,23 @@ export function createFileRoutes(options: FileRoutesOptions): Hono {
|
|
|
252
252
|
if (decision === "deny") return c.json({ error: "not_found" }, 404);
|
|
253
253
|
|
|
254
254
|
// An unknown name and a denied file answer alike, so the route never
|
|
255
|
-
// confirms what exists.
|
|
256
|
-
// throws out of variant() as a 500, because a mount gap is a config
|
|
257
|
-
// error, not a missing variant.
|
|
255
|
+
// confirms what exists.
|
|
258
256
|
const registry = options.registry;
|
|
259
257
|
const spec = registry
|
|
260
258
|
? resolveFieldVariant(registry, fileRef.entityType, fileRef.fieldName, name)
|
|
261
259
|
: undefined;
|
|
262
260
|
if (!registry || !spec) return c.json({ error: "not_found" }, 404);
|
|
263
261
|
|
|
262
|
+
// The file's mimeType comes from the upload (`file.type`) — validateFile
|
|
263
|
+
// only checks it against the field's `accept` when the field declares
|
|
264
|
+
// one, so a field without `accept` lets a client upload anything and
|
|
265
|
+
// then request /variant/*. Without this check, derivatives.variant()
|
|
266
|
+
// throws for an unsupported source mimeType — an uncaught 500 whose
|
|
267
|
+
// message lists every registered renderer's extension name.
|
|
268
|
+
if (!resolveRenderer(registry, fileRef.mimeType)) {
|
|
269
|
+
return c.json({ error: "unsupported_media_type" }, 415);
|
|
270
|
+
}
|
|
271
|
+
|
|
264
272
|
// Built per request: createFileContext caches the resolved provider, so
|
|
265
273
|
// one shared across requests would serve tenant A's store to tenant B.
|
|
266
274
|
const files = createFileContext(() => options.resolveProvider(user.tenantId));
|
|
@@ -274,8 +282,14 @@ export function createFileRoutes(options: FileRoutesOptions): Hono {
|
|
|
274
282
|
const data = await files.ref(result.storageKey).read();
|
|
275
283
|
// No Content-Length/Content-Disposition: fileRef.size is the ORIGINAL's
|
|
276
284
|
// size, and a variant is rendered for display, not for download.
|
|
285
|
+
// storageKey embeds specHash(spec), so the URL is content-stable until
|
|
286
|
+
// the spec changes — safe to cache. "private" because the response sits
|
|
287
|
+
// behind the tenant + guard gate above, not a shared CDN-cacheable asset.
|
|
277
288
|
return new Response(Buffer.from(data), {
|
|
278
|
-
headers: {
|
|
289
|
+
headers: {
|
|
290
|
+
"Content-Type": result.mimeType,
|
|
291
|
+
"Cache-Control": "private, max-age=31536000, immutable",
|
|
292
|
+
},
|
|
279
293
|
});
|
|
280
294
|
});
|
|
281
295
|
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
// Tracking-only for Phase 1: no hard limit, no upload gatekeeping. Apps read
|
|
4
4
|
// the row to decide what to do (show a warning, soft-throttle, bill, …).
|
|
5
5
|
// Enforcement is a conscious deferred call — we want production numbers
|
|
6
|
-
// before picking thresholds (see
|
|
6
|
+
// before picking thresholds (see "Storage tracking: counted now, enforced
|
|
7
|
+
// later" in core-files.md).
|
|
7
8
|
//
|
|
8
9
|
// The MSP is packaged as its own opt-in feature so tests that don't care
|
|
9
10
|
// about storage metrics don't pay for the projection-table push or the
|
package/src/jobs/job-runner.ts
CHANGED
|
@@ -215,6 +215,11 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
|
215
215
|
// boot); otherwise noop so dispatch/handleJob stay zero-cost without config.
|
|
216
216
|
const tracer: Tracer = context.tracer ?? getFallbackTracer();
|
|
217
217
|
const errorLogger = createFallbackLogger("job-runner", context.log);
|
|
218
|
+
// Set at the top of stop() — a graceful shutdown closes the redis/BullMQ
|
|
219
|
+
// clients itself, which fires the exact same 'error' listeners below with
|
|
220
|
+
// an expected "Connection is closed." Downgrading to debug once stopping
|
|
221
|
+
// is true keeps those out of error-rate alerts without losing them.
|
|
222
|
+
let stopping = false;
|
|
218
223
|
|
|
219
224
|
const allJobs = registry.getAllJobs();
|
|
220
225
|
|
|
@@ -240,9 +245,10 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
|
240
245
|
// "Connection is closed") is unhandled and crashes the process — in
|
|
241
246
|
// bun:test it gets attributed to whichever test happens to run next
|
|
242
247
|
// (fw#1805).
|
|
243
|
-
lockRedis.on("error", (err) =>
|
|
244
|
-
|
|
245
|
-
|
|
248
|
+
lockRedis.on("error", (err) => {
|
|
249
|
+
const log = stopping ? errorLogger.debug : errorLogger.error;
|
|
250
|
+
log("lock redis connection error", { error: err.message });
|
|
251
|
+
});
|
|
246
252
|
const lockScope = consumerLane ?? "enqueue";
|
|
247
253
|
sequentialLock = createDistributedLock(lockRedis, `${RedisKeys.lock}seq:${lockScope}:`);
|
|
248
254
|
}
|
|
@@ -267,9 +273,10 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
|
267
273
|
// Same unhandled-'error'-crash hazard as lockRedis above, just via
|
|
268
274
|
// BullMQ's internal ioredis client (fw#1805).
|
|
269
275
|
for (const queue of Object.values(queues)) {
|
|
270
|
-
queue.on("error", (err) =>
|
|
271
|
-
|
|
272
|
-
|
|
276
|
+
queue.on("error", (err) => {
|
|
277
|
+
const log = stopping ? errorLogger.debug : errorLogger.error;
|
|
278
|
+
log("queue redis connection error", { error: err.message });
|
|
279
|
+
});
|
|
273
280
|
}
|
|
274
281
|
let worker: Worker | null = null;
|
|
275
282
|
let queueDepthTimer: ReturnType<typeof setInterval> | null = null;
|
|
@@ -536,9 +543,10 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
|
536
543
|
connection: redisOpts,
|
|
537
544
|
concurrency: 5,
|
|
538
545
|
});
|
|
539
|
-
worker.on("error", (err) =>
|
|
540
|
-
|
|
541
|
-
|
|
546
|
+
worker.on("error", (err) => {
|
|
547
|
+
const log = stopping ? errorLogger.debug : errorLogger.error;
|
|
548
|
+
log("worker redis connection error", { error: err.message });
|
|
549
|
+
});
|
|
542
550
|
// A caller that calls stop() right after start() otherwise races the
|
|
543
551
|
// still-settling blocking connection: it rejects in-flight commands
|
|
544
552
|
// via ioredis's flushQueue() during close(), which isn't a listenable
|
|
@@ -603,6 +611,7 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
|
603
611
|
},
|
|
604
612
|
|
|
605
613
|
async stop(): Promise<void> {
|
|
614
|
+
stopping = true;
|
|
606
615
|
if (queueDepthTimer) {
|
|
607
616
|
clearInterval(queueDepthTimer);
|
|
608
617
|
queueDepthTimer = null;
|
|
@@ -27,6 +27,35 @@ describe("createFallbackLogger", () => {
|
|
|
27
27
|
|
|
28
28
|
expect(error).toHaveBeenCalledWith("[jobs] boom", undefined);
|
|
29
29
|
});
|
|
30
|
+
|
|
31
|
+
test("debug() delegiert an logger.debug wenn vorhanden (fw#1812: graceful-shutdown log-level downgrade)", () => {
|
|
32
|
+
const error = mock((_msg: string, _data?: Record<string, unknown>) => {});
|
|
33
|
+
const debug = mock((_msg: string, _data?: Record<string, unknown>) => {});
|
|
34
|
+
const fallback = createFallbackLogger("job-runner", { error, debug });
|
|
35
|
+
|
|
36
|
+
fallback.debug("connection is closed", { reason: "graceful-shutdown" });
|
|
37
|
+
|
|
38
|
+
expect(debug).toHaveBeenCalledTimes(1);
|
|
39
|
+
expect(debug).toHaveBeenCalledWith("[job-runner] connection is closed", {
|
|
40
|
+
reason: "graceful-shutdown",
|
|
41
|
+
});
|
|
42
|
+
expect(error).not.toHaveBeenCalled();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("debug() fällt auf console.debug zurück wenn der wrapped logger keine debug-Methode hat", () => {
|
|
46
|
+
const error = mock((_msg: string, _data?: Record<string, unknown>) => {});
|
|
47
|
+
const spy = spyOn(console, "debug").mockImplementation(() => {});
|
|
48
|
+
try {
|
|
49
|
+
const fallback = createFallbackLogger("job-runner", { error });
|
|
50
|
+
|
|
51
|
+
fallback.debug("connection is closed");
|
|
52
|
+
|
|
53
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
54
|
+
expect(spy).toHaveBeenCalledWith("[job-runner] connection is closed", undefined);
|
|
55
|
+
} finally {
|
|
56
|
+
spy.mockRestore();
|
|
57
|
+
}
|
|
58
|
+
});
|
|
30
59
|
});
|
|
31
60
|
|
|
32
61
|
describe("ohne logger (console-Fallback)", () => {
|
|
@@ -43,5 +72,19 @@ describe("createFallbackLogger", () => {
|
|
|
43
72
|
spy.mockRestore();
|
|
44
73
|
}
|
|
45
74
|
});
|
|
75
|
+
|
|
76
|
+
test("debug() schreibt auf console.debug mit [namespace]-Prefix", () => {
|
|
77
|
+
const spy = spyOn(console, "debug").mockImplementation(() => {});
|
|
78
|
+
try {
|
|
79
|
+
const fallback = createFallbackLogger("boot");
|
|
80
|
+
|
|
81
|
+
fallback.debug("no logger wired", { phase: "init" });
|
|
82
|
+
|
|
83
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
84
|
+
expect(spy).toHaveBeenCalledWith("[boot] no logger wired", { phase: "init" });
|
|
85
|
+
} finally {
|
|
86
|
+
spy.mockRestore();
|
|
87
|
+
}
|
|
88
|
+
});
|
|
46
89
|
});
|
|
47
90
|
});
|
package/src/logging/utils.ts
CHANGED
|
@@ -2,17 +2,26 @@ import type { Logger } from "./types";
|
|
|
2
2
|
|
|
3
3
|
type FallbackLogger = {
|
|
4
4
|
error(msg: string, data?: Record<string, unknown>): void;
|
|
5
|
+
debug(msg: string, data?: Record<string, unknown>): void;
|
|
5
6
|
};
|
|
6
7
|
|
|
7
8
|
export function createFallbackLogger(
|
|
8
9
|
namespace: string,
|
|
9
|
-
logger?: Pick<Logger, "error"> | undefined,
|
|
10
|
+
logger?: (Pick<Logger, "error"> & Partial<Pick<Logger, "debug">>) | undefined,
|
|
10
11
|
): FallbackLogger {
|
|
11
12
|
if (logger) {
|
|
12
13
|
return {
|
|
13
14
|
error(msg, data) {
|
|
14
15
|
logger.error(`[${namespace}] ${msg}`, data);
|
|
15
16
|
},
|
|
17
|
+
debug(msg, data) {
|
|
18
|
+
if (logger.debug) {
|
|
19
|
+
logger.debug(`[${namespace}] ${msg}`, data);
|
|
20
|
+
} else {
|
|
21
|
+
// biome-ignore lint/suspicious/noConsole: ops-visible fallback when the wrapped logger has no debug method
|
|
22
|
+
console.debug(`[${namespace}] ${msg}`, data);
|
|
23
|
+
}
|
|
24
|
+
},
|
|
16
25
|
};
|
|
17
26
|
}
|
|
18
27
|
return {
|
|
@@ -20,5 +29,9 @@ export function createFallbackLogger(
|
|
|
20
29
|
// biome-ignore lint/suspicious/noConsole: ops-visible fallback when no logger is wired
|
|
21
30
|
console.error(`[${namespace}] ${msg}`, data);
|
|
22
31
|
},
|
|
32
|
+
debug(msg, data) {
|
|
33
|
+
// biome-ignore lint/suspicious/noConsole: ops-visible fallback when no logger is wired
|
|
34
|
+
console.debug(`[${namespace}] ${msg}`, data);
|
|
35
|
+
},
|
|
23
36
|
};
|
|
24
37
|
}
|
|
@@ -66,8 +66,16 @@ describe("buildMetricName", () => {
|
|
|
66
66
|
expect(buildMetricName("orders", "created_total")).toBe("kumiko_orders_created_total");
|
|
67
67
|
});
|
|
68
68
|
|
|
69
|
-
it("rejects
|
|
70
|
-
|
|
69
|
+
it("rejects a feature name that stays invalid after kebab-normalization", () => {
|
|
70
|
+
// A single leading capital ("Orders") now normalizes cleanly via toKebab
|
|
71
|
+
// — this must still reject a space, which toKebab doesn't touch.
|
|
72
|
+
expect(() => buildMetricName("orders team", "created_total")).toThrow(/snake_case/);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("normalizes camelCase feature names the same as their kebab-case equivalent", () => {
|
|
76
|
+
expect(buildMetricName("aiFoundation", "created_total")).toBe(
|
|
77
|
+
buildMetricName("ai-foundation", "created_total"),
|
|
78
|
+
);
|
|
71
79
|
});
|
|
72
80
|
});
|
|
73
81
|
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { createSafeMetricsHandle } from "../metrics-handle";
|
|
3
|
+
import { RecordingMeter } from "../recording-meter";
|
|
4
|
+
|
|
5
|
+
describe("createSafeMetricsHandle", () => {
|
|
6
|
+
it("an invalid featureName is a no-op, not a throw", () => {
|
|
7
|
+
const meter = new RecordingMeter(() => {});
|
|
8
|
+
const handle = createSafeMetricsHandle(meter, "Not Kebab Case!");
|
|
9
|
+
expect(() => handle.inc("created_total")).not.toThrow();
|
|
10
|
+
expect(() => handle.observe("duration_seconds", 1)).not.toThrow();
|
|
11
|
+
expect(() => handle.set("active", 1)).not.toThrow();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("an unregistered metric name is a no-op, not a throw", () => {
|
|
15
|
+
const meter = new RecordingMeter(() => {});
|
|
16
|
+
const handle = createSafeMetricsHandle(meter, "orders");
|
|
17
|
+
expect(() => handle.inc("not_registered_total")).not.toThrow();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("a registered metric on a valid featureName still records", () => {
|
|
21
|
+
const events: unknown[] = [];
|
|
22
|
+
const meter = new RecordingMeter((e) => events.push(e));
|
|
23
|
+
meter.registerMetric({ name: "kumiko_orders_created_total", type: "counter" });
|
|
24
|
+
const handle = createSafeMetricsHandle(meter, "orders");
|
|
25
|
+
handle.inc("created_total");
|
|
26
|
+
expect(events).toEqual([
|
|
27
|
+
{ type: "counter.inc", name: "kumiko_orders_created_total", value: 1, labels: undefined },
|
|
28
|
+
]);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { toKebab } from "../engine/qualified-name";
|
|
1
2
|
import { assertUnreachable } from "../utils";
|
|
2
3
|
import type { MetricType } from "./types";
|
|
3
4
|
|
|
@@ -74,11 +75,11 @@ export function validateMetricName(name: string, type: MetricType): void {
|
|
|
74
75
|
// path (registry-ingest.ts) and the read path (ctx.metrics / ctx.metricsFor),
|
|
75
76
|
// instead of the kebab form being rejected outright (framework#1844).
|
|
76
77
|
export function buildMetricName(featureName: string, shortName: string): string {
|
|
77
|
-
const normalizedFeatureName = featureName.replace(/-/g, "_");
|
|
78
|
+
const normalizedFeatureName = toKebab(featureName).replace(/-/g, "_");
|
|
78
79
|
if (!SNAKE_CASE.test(normalizedFeatureName)) {
|
|
79
80
|
throw new Error(
|
|
80
|
-
`[Kumiko Observability] Feature name "${featureName}" must be kebab-case
|
|
81
|
-
`(a-z, 0-9, "-" or "_").`,
|
|
81
|
+
`[Kumiko Observability] Feature name "${featureName}" must be kebab-case, camelCase, ` +
|
|
82
|
+
`or snake_case (a-z, 0-9, "-" or "_").`,
|
|
82
83
|
);
|
|
83
84
|
}
|
|
84
85
|
return `kumiko_${normalizedFeatureName}_${shortName}`;
|
|
@@ -38,27 +38,39 @@ export function createMetricsHandle(meter: Meter, featureName: string): MetricsH
|
|
|
38
38
|
// unregistered name here is a silent no-op, not a throw. This handle is
|
|
39
39
|
// meant for error/catch-path counters in shared code — a missing
|
|
40
40
|
// registration (consuming feature not mounted, metric not declared yet)
|
|
41
|
-
// must not turn an already-swallowed error into a thrown one.
|
|
42
|
-
//
|
|
43
|
-
//
|
|
41
|
+
// must not turn an already-swallowed error into a thrown one.
|
|
42
|
+
//
|
|
43
|
+
// buildMetricName itself can also throw (invalid featureName) — also
|
|
44
|
+
// swallowed to a no-op here, since it fires from the very catch block this
|
|
45
|
+
// handle is meant to protect: a malformed featureName must not turn an
|
|
46
|
+
// already-swallowed error into a thrown one either. Everything else (wrong
|
|
47
|
+
// metric type for the call) still throws.
|
|
48
|
+
function tryBuildMetricName(featureName: string, shortName: string): string | undefined {
|
|
49
|
+
try {
|
|
50
|
+
return buildMetricName(featureName, shortName);
|
|
51
|
+
} catch {
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
44
56
|
export function createSafeMetricsHandle(meter: Meter, featureName: string): MetricsHandle {
|
|
45
57
|
return {
|
|
46
58
|
inc(shortName, labels, value) {
|
|
47
|
-
const name =
|
|
48
|
-
// skip: unregistered name
|
|
49
|
-
if (!meter.definitions().has(name)) return;
|
|
59
|
+
const name = tryBuildMetricName(featureName, shortName);
|
|
60
|
+
// skip: invalid featureName or unregistered name are the documented no-op contract of this handle
|
|
61
|
+
if (name === undefined || !meter.definitions().has(name)) return;
|
|
50
62
|
meter.counter(name).inc(value, labels);
|
|
51
63
|
},
|
|
52
64
|
observe(shortName, value, labels) {
|
|
53
|
-
const name =
|
|
54
|
-
// skip: unregistered name
|
|
55
|
-
if (!meter.definitions().has(name)) return;
|
|
65
|
+
const name = tryBuildMetricName(featureName, shortName);
|
|
66
|
+
// skip: invalid featureName or unregistered name are the documented no-op contract of this handle
|
|
67
|
+
if (name === undefined || !meter.definitions().has(name)) return;
|
|
56
68
|
meter.histogram(name).observe(value, labels);
|
|
57
69
|
},
|
|
58
70
|
set(shortName, value, labels) {
|
|
59
|
-
const name =
|
|
60
|
-
// skip: unregistered name
|
|
61
|
-
if (!meter.definitions().has(name)) return;
|
|
71
|
+
const name = tryBuildMetricName(featureName, shortName);
|
|
72
|
+
// skip: invalid featureName or unregistered name are the documented no-op contract of this handle
|
|
73
|
+
if (name === undefined || !meter.definitions().has(name)) return;
|
|
62
74
|
meter.gauge(name).set(value, labels);
|
|
63
75
|
},
|
|
64
76
|
};
|