@cosmicdrift/kumiko-framework 0.193.0 → 0.194.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__/variant-key.test.ts +2 -2
- package/src/derivatives/__tests__/variant-route.integration.test.ts +28 -0
- 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__/metrics-handle.test.ts +30 -0
- package/src/observability/metrics-handle.ts +24 -12
- package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +5 -5
- package/src/ui-types/index.ts +1 -0
|
@@ -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
|
}
|
|
@@ -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
|
+
});
|
|
@@ -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
|
};
|
|
@@ -125,11 +125,11 @@ const bridgeFeature = defineFeature("ctxbridge", (r) => {
|
|
|
125
125
|
async (event, ctx) => {
|
|
126
126
|
const crud = createEventStoreExecutor(bagTable, bagEntity, { entityName: "bag" });
|
|
127
127
|
await crud.create({ label: `${event.payload.label}-inside-tx` }, event.user, ctx.db);
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
);
|
|
128
|
+
const outsideTx = ctx.dbOutsideTransaction;
|
|
129
|
+
if (!outsideTx) {
|
|
130
|
+
throw new Error("bag:create-outside-tx-then-fail requires ctx.dbOutsideTransaction");
|
|
131
|
+
}
|
|
132
|
+
await crud.create({ label: `${event.payload.label}-outside-tx` }, event.user, outsideTx);
|
|
133
133
|
return writeFailure(new UnprocessableError("intentional_failure"));
|
|
134
134
|
},
|
|
135
135
|
{ access: { roles: ["Admin"] } },
|