@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.
Files changed (32) hide show
  1. package/package.json +3 -3
  2. package/src/api/__tests__/http-route-rate-limit.integration.test.ts +71 -0
  3. package/src/api/server.ts +1 -1
  4. package/src/db/__tests__/migrate-generator.test.ts +17 -0
  5. package/src/db/__tests__/money.test.ts +41 -5
  6. package/src/db/event-store-executor-read.ts +1 -1
  7. package/src/db/index.ts +1 -1
  8. package/src/db/migrate-generator.ts +13 -3
  9. package/src/db/money.ts +34 -4
  10. package/src/derivatives/__tests__/variant-key.test.ts +2 -2
  11. package/src/derivatives/__tests__/variant-route.integration.test.ts +28 -0
  12. package/src/derivatives/variant-key.ts +1 -1
  13. package/src/engine/__tests__/boot-validator.test.ts +140 -0
  14. package/src/engine/__tests__/embedded-derived.test.ts +35 -0
  15. package/src/engine/__tests__/engine.test.ts +20 -0
  16. package/src/engine/__tests__/schema-builder.test.ts +93 -0
  17. package/src/engine/boot-validator/entity-handler.ts +64 -5
  18. package/src/engine/boot-validator/screens.ts +57 -36
  19. package/src/engine/embedded-derived.ts +9 -1
  20. package/src/engine/schema-builder.ts +33 -23
  21. package/src/entrypoint/__tests__/split-deploy.integration.test.ts +37 -6
  22. package/src/errors/zod-bridge.ts +4 -9
  23. package/src/event-store/__tests__/perf.integration.test.ts +2 -11
  24. package/src/files/file-routes.ts +20 -6
  25. package/src/files/storage-tracking.ts +2 -1
  26. package/src/jobs/job-runner.ts +18 -9
  27. package/src/logging/__tests__/fallback-logger.test.ts +43 -0
  28. package/src/logging/utils.ts +14 -1
  29. package/src/observability/__tests__/metrics-handle.test.ts +30 -0
  30. package/src/observability/metrics-handle.ts +24 -12
  31. package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +5 -5
  32. 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 core-files.md, Architektur-Entscheidung 3).
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
@@ -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
- errorLogger.error("lock redis connection error", { error: err.message }),
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
- errorLogger.error("queue redis connection error", { error: err.message }),
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
- errorLogger.error("worker redis connection error", { error: err.message }),
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
  });
@@ -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. Every other
42
- // failure (invalid featureName, wrong metric type for the call) still
43
- // throws only the "not registered" case is swallowed.
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 = buildMetricName(featureName, shortName);
48
- // skip: unregistered name is the documented no-op contract of this handle
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 = buildMetricName(featureName, shortName);
54
- // skip: unregistered name is the documented no-op contract of this handle
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 = buildMetricName(featureName, shortName);
60
- // skip: unregistered name is the documented no-op contract of this handle
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
- await crud.create(
129
- { label: `${event.payload.label}-outside-tx` },
130
- event.user,
131
- ctx.dbOutsideTransaction,
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"] } },
@@ -38,6 +38,7 @@ export {
38
38
  export type {
39
39
  BooleanFieldDef,
40
40
  DateFieldDef,
41
+ EmbeddedDerivedCellDef,
41
42
  EntityDefinition,
42
43
  FieldDefinition,
43
44
  FileFieldDef,