@cosmicdrift/kumiko-framework 0.382.0 → 0.383.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 (94) hide show
  1. package/agents/examples/api-worker-split.md +57 -0
  2. package/agents/examples/record-detail-layout.md +20 -18
  3. package/agents/examples/screens-nav.md +3 -2
  4. package/agents/examples/writeform-section.md +2 -1
  5. package/dist/api/api-constants.d.ts +1 -0
  6. package/dist/api/api-constants.js +2 -0
  7. package/dist/api/auth-middleware.d.ts +1 -0
  8. package/dist/api/auth-middleware.js +17 -0
  9. package/dist/api/auth-routes.d.ts +1 -0
  10. package/dist/api/auth-routes.js +14 -4
  11. package/dist/api/client-error-route.d.ts +12 -0
  12. package/dist/api/client-error-route.js +113 -0
  13. package/dist/api/request-context.d.ts +6 -0
  14. package/dist/api/request-context.js +9 -0
  15. package/dist/api/routes.js +7 -4
  16. package/dist/api/server.js +11 -1
  17. package/dist/db/connection.d.ts +7 -0
  18. package/dist/db/connection.js +40 -0
  19. package/dist/db/index.d.ts +2 -1
  20. package/dist/db/index.js +2 -1
  21. package/dist/db/migrate-runner.js +4 -0
  22. package/dist/db/queries/backfill-pii.js +5 -1
  23. package/dist/db/queries/event-store.js +9 -2
  24. package/dist/db/statement-timeout.d.ts +2 -0
  25. package/dist/db/statement-timeout.js +18 -0
  26. package/dist/db/tenant-db.js +5 -12
  27. package/dist/engine/boot-validator/access-declarations.js +17 -18
  28. package/dist/engine/boot-validator/query-output-columns.js +1 -2
  29. package/dist/engine/boot-validator/screens.js +12 -16
  30. package/dist/engine/boot-validator/url-prefill-fields.js +1 -3
  31. package/dist/engine/build-config-feature-schema.js +4 -4
  32. package/dist/engine/entity-handlers.js +5 -7
  33. package/dist/engine/escape-hatch-declaration.d.ts +1 -1
  34. package/dist/engine/escape-hatch-declaration.js +5 -2
  35. package/dist/engine/escape-hatch-reason.d.ts +2 -0
  36. package/dist/engine/escape-hatch-reason.js +35 -0
  37. package/dist/engine/extensions/escape-hatch-usage.js +2 -1
  38. package/dist/engine/feature-config-events-jobs.js +6 -4
  39. package/dist/engine/feature-ui-extensions.js +12 -6
  40. package/dist/engine/iban.d.ts +2 -0
  41. package/dist/engine/iban.js +19 -0
  42. package/dist/engine/index.d.ts +1 -1
  43. package/dist/engine/project-app-schema-for-roles.js +1 -1
  44. package/dist/engine/registry-validate.d.ts +1 -0
  45. package/dist/engine/registry-validate.js +8 -0
  46. package/dist/engine/registry.js +2 -1
  47. package/dist/engine/schema-builder.js +9 -0
  48. package/dist/engine/screen-helpers.d.ts +3 -3
  49. package/dist/engine/screen-helpers.js +1 -2
  50. package/dist/engine/steps/_read-source.js +2 -0
  51. package/dist/engine/types/index.d.ts +2 -2
  52. package/dist/engine/types/index.js +1 -1
  53. package/dist/engine/types/screen.d.ts +1 -1
  54. package/dist/engine/types/screen.js +1 -1
  55. package/dist/entrypoint/index.d.ts +1 -0
  56. package/dist/entrypoint/index.js +1 -0
  57. package/dist/event-store/event-store.d.ts +0 -3
  58. package/dist/event-store/event-store.js +1 -26
  59. package/dist/event-store/index.d.ts +1 -1
  60. package/dist/event-store/index.js +1 -1
  61. package/dist/event-store/snapshot.js +6 -1
  62. package/dist/files/file-ref-entity.d.ts +6 -6
  63. package/dist/files/file-ref-table.d.ts +6 -6
  64. package/dist/i18n/required-surface-keys.js +0 -1
  65. package/dist/jobs/job-runner.d.ts +1 -0
  66. package/dist/jobs/job-runner.js +74 -7
  67. package/dist/logging/index.d.ts +2 -1
  68. package/dist/logging/index.js +2 -1
  69. package/dist/logging/pino-logger.d.ts +1 -0
  70. package/dist/logging/pino-logger.js +35 -0
  71. package/dist/logging/request-log-fields.d.ts +1 -0
  72. package/dist/logging/request-log-fields.js +21 -0
  73. package/dist/logging/scrub.d.ts +2 -0
  74. package/dist/logging/scrub.js +21 -0
  75. package/dist/logging/utils.d.ts +1 -0
  76. package/dist/logging/utils.js +40 -6
  77. package/dist/migrations/pii-event-backfill.js +5 -1
  78. package/dist/observability/standard-metrics.d.ts +1 -0
  79. package/dist/observability/standard-metrics.js +9 -0
  80. package/dist/pipeline/msp-rebuild.js +5 -1
  81. package/dist/pipeline/projection-rebuild.js +5 -1
  82. package/dist/rate-limit/middleware.d.ts +8 -3
  83. package/dist/rate-limit/middleware.js +28 -10
  84. package/dist/rate-limit/resolver.js +10 -0
  85. package/dist/redis/client.d.ts +2 -0
  86. package/dist/redis/client.js +20 -0
  87. package/dist/redis/index.d.ts +1 -1
  88. package/dist/redis/index.js +1 -1
  89. package/dist/search/purge-subject.js +6 -8
  90. package/dist/search/reindex-entity.js +11 -10
  91. package/dist/ui-types/index.d.ts +1 -1
  92. package/dist/ui-types/index.js +1 -1
  93. package/package.json +3 -3
  94. package/src/changes.json +64 -0
@@ -13,7 +13,7 @@ import { SYSTEM_TENANT_ID, } from "../engine/types/index.js";
13
13
  import { InternalError } from "../errors/index.js";
14
14
  import { isKumikoError } from "../errors/kumiko-error.js";
15
15
  import { createTenantFileContext } from "../files/file-handle.js";
16
- import { createFallbackLogger } from "../logging/index.js";
16
+ import { createFallbackLogger, scrubLogText } from "../logging/index.js";
17
17
  import { createEscapeHatchProcessDedup, createEscapeHatchReporter, createEscapeHatchReportWindow, createMetricsForFactory, emitJobLastSuccess, emitJobQueueDepth, flushEscapeHatchAudits, getFallbackTracer, registerStandardMetrics, withEscapeHatchAuditScope, } from "../observability/index.js";
18
18
  import { configAccessorFactoryOf } from "../pipeline/config-accessor-factory.js";
19
19
  import { createDistributedLock } from "../pipeline/distributed-lock.js";
@@ -262,6 +262,9 @@ function tenantFailureMessageKey(err, declaration) {
262
262
  // (_triggeredById, _tenantId, _payload).
263
263
  const TRACE_CONTEXT_KEY = "_traceContext";
264
264
  const DEFER_COUNT_KEY = "_deferCount";
265
+ // Separate from the maxPerTenant counter so tenant-limit deferrals do not eat into maxDefers.
266
+ const HANDLER_DEFER_COUNT_KEY = "_handlerDeferCount";
267
+ const DEFAULT_MAX_DEFERS = 100;
265
268
  const DEFER_DELAY_CAP_MS = 30_000;
266
269
  const DEFER_BACKOFF_MAX_DOUBLINGS = 5;
267
270
  function readTraceContext(data) {
@@ -280,18 +283,28 @@ function captureTraceContext(tracer) {
280
283
  return { traceId: span.traceId, spanId: span.spanId };
281
284
  }
282
285
  const QUEUE_DEPTH_POLL_INTERVAL_MS = 15_000;
286
+ const QUEUE_DEPTH_WARN_INTERVAL_MS = 5 * 60_000;
283
287
  // kumiko_job_queue_depth: only the lane's own consumer polls — the count is
284
288
  // a global Redis-backed value (BullMQ, not per-process), so one reporter per
285
289
  // lane is enough. Fires once immediately (metrics exist before the first
286
290
  // poll tick) then on an interval; the caller stores + clears the handle.
287
291
  async function startQueueDepthPolling(queue, lane, meter) {
292
+ let lastWarnAt;
288
293
  const pollQueueDepth = async () => {
289
294
  try {
290
295
  const counts = await queue.getJobCounts("waiting", "active", "delayed", "failed", "paused");
291
296
  emitJobQueueDepth(meter, lane, counts);
292
297
  }
293
- catch {
294
- // skip: transient Redis hiccup — next poll retries
298
+ catch (err) {
299
+ // Next poll retries; the warn is throttled so a Redis outage logs once per interval, not once per poll.
300
+ const nowMs = Date.now();
301
+ if (lastWarnAt === undefined || nowMs - lastWarnAt >= QUEUE_DEPTH_WARN_INTERVAL_MS) {
302
+ lastWarnAt = nowMs;
303
+ createFallbackLogger("job-runner").warn("queue depth poll failed", {
304
+ lane,
305
+ error: scrubLogText(err instanceof Error ? err.message : String(err)),
306
+ });
307
+ }
295
308
  }
296
309
  };
297
310
  await pollQueueDepth();
@@ -614,6 +627,16 @@ export function createJobRunner(options) {
614
627
  const grown = Math.min(deferMs * 2 ** Math.min(deferCount, DEFER_BACKOFF_MAX_DOUBLINGS), DEFER_DELAY_CAP_MS);
615
628
  return grown + Math.floor(Math.random() * deferMs);
616
629
  }
630
+ function isJobDeferral(value) {
631
+ return (typeof value === "object" &&
632
+ value !== null &&
633
+ "kind" in value &&
634
+ value.kind === "job-deferral");
635
+ }
636
+ function priorDeferCountOf(data, key) {
637
+ const prior = data[key];
638
+ return typeof prior === "number" ? prior : 0;
639
+ }
617
640
  // Only err.message, never payload or the logs array: job payloads can carry PII.
618
641
  function logFailedAttempt(entry) {
619
642
  errorLogger.error("job failed", {
@@ -622,7 +645,9 @@ export function createJobRunner(options) {
622
645
  attempt: entry.attempt,
623
646
  // BullMQ never retries an UnrecoverableError, whatever attempts remain.
624
647
  final: entry.finalAttempt || entry.err instanceof UnrecoverableError,
625
- error: entry.err instanceof Error ? entry.err.message : String(entry.err),
648
+ error: scrubLogText(entry.err instanceof Error ? entry.err.message : String(entry.err)),
649
+ ...(entry.err instanceof Error &&
650
+ entry.err.stack && { stack: scrubLogText(entry.err.stack) }),
626
651
  });
627
652
  }
628
653
  async function reportPreRunFailure(jobName, bullJob, err) {
@@ -779,8 +804,7 @@ export function createJobRunner(options) {
779
804
  activeOnly: true,
780
805
  self: { id: bullJob.id, processedOn: bullJob.processedOn },
781
806
  }))) {
782
- const priorDeferCount = bullJob.data[DEFER_COUNT_KEY];
783
- const deferCount = typeof priorDeferCount === "number" ? priorDeferCount : 0;
807
+ const deferCount = priorDeferCountOf(bullJob.data, DEFER_COUNT_KEY);
784
808
  await bullJob.updateData?.({ ...bullJob.data, [DEFER_COUNT_KEY]: deferCount + 1 });
785
809
  await bullJob.moveToDelayed(Date.now() + deferDelayMs(maxPerTenant.deferMs, deferCount), token);
786
810
  throw new DelayedError();
@@ -1008,6 +1032,17 @@ export function createJobRunner(options) {
1008
1032
  },
1009
1033
  }, { reason: identitySwitchGrantReason, report: reportEscapeHatch });
1010
1034
  const jobContextWithoutTz = {
1035
+ defer: (delayMs) => {
1036
+ if (!Number.isInteger(delayMs) || delayMs <= 0) {
1037
+ throw new RangeError(`Job "${jobName}": ctx.defer needs a positive integer delay in ms (got ${delayMs})`);
1038
+ }
1039
+ if (token === undefined || !bullJob.moveToDelayed) {
1040
+ throw new InternalError({
1041
+ message: `Job "${jobName}": ctx.defer needs a queued run; it is not available inline (boot gate).`,
1042
+ });
1043
+ }
1044
+ return { kind: "job-deferral", delayMs };
1045
+ },
1011
1046
  ...context,
1012
1047
  db: jobDb ?? missingDbProxy(jobName),
1013
1048
  files,
@@ -1064,6 +1099,7 @@ export function createJobRunner(options) {
1064
1099
  const inheritedCorrelationId = rawData["_correlationId"] ?? undefined; // @cast-boundary dynamic-key
1065
1100
  const jobRequestId = requestContext.generateId();
1066
1101
  const jobCorrelationId = inheritedCorrelationId ?? jobRequestId;
1102
+ const deferred = {};
1067
1103
  const runInSpan = async () => {
1068
1104
  try {
1069
1105
  if (subjectConfigError) {
@@ -1076,17 +1112,29 @@ export function createJobRunner(options) {
1076
1112
  "a trustworthy anonymous-root gate.",
1077
1113
  });
1078
1114
  }
1079
- await requestContext.run({
1115
+ const handlerResult = await requestContext.run({
1080
1116
  requestId: jobRequestId,
1081
1117
  correlationId: jobCorrelationId,
1082
1118
  // #3043 — events a job writes carry the job as their origin.
1083
1119
  handler: jobName,
1084
1120
  feature: registry.getJobFeature(jobName) ?? qnScope(jobName),
1085
1121
  writeOrigin: jobOrigin,
1122
+ tenantId,
1123
+ ...(triggeredById !== null && { userId: triggeredById }),
1086
1124
  }, async () => jobDef.handler(payload, {
1087
1125
  ...jobContextWithoutTz,
1088
1126
  tz: await resolveJobTz(config, context),
1089
1127
  }));
1128
+ if (isJobDeferral(handlerResult)) {
1129
+ const maxDefers = jobDef.maxDefers ?? DEFAULT_MAX_DEFERS;
1130
+ if (priorDeferCountOf(bullJob.data, HANDLER_DEFER_COUNT_KEY) >= maxDefers) {
1131
+ // The counter never resets, so a retry would hit the cap again.
1132
+ throw new UnrecoverableError(`Job "${jobName}" was deferred ${maxDefers} times (maxDefers); giving up.`);
1133
+ }
1134
+ deferred.deferral = handlerResult;
1135
+ // skip: the deferral is applied after the span closes, below
1136
+ return;
1137
+ }
1090
1138
  // Stamped before the observer hook: a throwing onJobComplete must not
1091
1139
  // make a run that actually succeeded look dead to the liveness alert.
1092
1140
  if (context.meter) {
@@ -1135,6 +1183,25 @@ export function createJobRunner(options) {
1135
1183
  },
1136
1184
  ...(parentContext ? { parent: parentContext } : {}),
1137
1185
  }, runInSpan));
1186
+ if (deferred.deferral && token !== undefined && bullJob.moveToDelayed) {
1187
+ // Run-log bookkeeping must not turn a defer into a failure.
1188
+ try {
1189
+ await options.onJobDeferred?.(jobName, jobId, meta);
1190
+ }
1191
+ catch (err) {
1192
+ errorLogger.warn("job deferred hook failed", {
1193
+ job: jobName,
1194
+ jobId,
1195
+ error: scrubLogText(err instanceof Error ? err.message : String(err)),
1196
+ });
1197
+ }
1198
+ await bullJob.updateData?.({
1199
+ ...bullJob.data,
1200
+ [HANDLER_DEFER_COUNT_KEY]: priorDeferCountOf(bullJob.data, HANDLER_DEFER_COUNT_KEY) + 1,
1201
+ });
1202
+ await bullJob.moveToDelayed(Date.now() + deferred.deferral.delayMs, token);
1203
+ throw new DelayedError();
1204
+ }
1138
1205
  }
1139
1206
  finally {
1140
1207
  // Release the sequential lock value-matched (Lua compare-and-delete
@@ -1,4 +1,5 @@
1
1
  export type { LoggerOptions } from "./pino-logger.ts";
2
2
  export { createLogger } from "./pino-logger.ts";
3
+ export { scrubLogText, scrubLogValue } from "./scrub.ts";
3
4
  export type { Logger } from "./types.ts";
4
- export { createFallbackLogger } from "./utils.ts";
5
+ export { createFallbackLogger, setProcessLogger } from "./utils.ts";
@@ -1,2 +1,3 @@
1
1
  export { createLogger } from "./pino-logger.js";
2
- export { createFallbackLogger } from "./utils.js";
2
+ export { scrubLogText, scrubLogValue } from "./scrub.js";
3
+ export { createFallbackLogger, setProcessLogger } from "./utils.js";
@@ -1,5 +1,6 @@
1
1
  import { type DestinationStream } from "pino";
2
2
  import type { Logger } from "./types.ts";
3
+ export declare const REDACT_PATHS: readonly string[];
3
4
  export type LoggerOptions = {
4
5
  level?: "debug" | "info" | "warn" | "error";
5
6
  pretty?: boolean;
@@ -1,10 +1,45 @@
1
1
  import pino, {} from "pino";
2
2
  import { observabilityContext } from "../observability/index.js";
3
+ import { requestLogFields } from "./request-log-fields.js";
4
+ const REDACTED_KEYS = [
5
+ "password",
6
+ "passwordHash",
7
+ "newPassword",
8
+ "token",
9
+ "accessToken",
10
+ "refreshToken",
11
+ "idToken",
12
+ "secret",
13
+ "clientSecret",
14
+ "apiKey",
15
+ "authorization",
16
+ "cookie",
17
+ "iban",
18
+ "email",
19
+ "set-cookie",
20
+ "x-api-key",
21
+ "sessionToken",
22
+ "privateKey",
23
+ "credentials",
24
+ "otp",
25
+ ];
26
+ // pino/fast-redact needs bracket notation for keys that are not plain identifiers.
27
+ function redactPathSegment(key) {
28
+ return /^[A-Za-z_$][\w$]*$/.test(key) ? key : `["${key}"]`;
29
+ }
30
+ // Depth 0-2: pino wildcards match exactly one level, so each depth needs its own path.
31
+ export const REDACT_PATHS = REDACTED_KEYS.flatMap((key) => {
32
+ const segment = redactPathSegment(key);
33
+ const dotted = segment.startsWith("[") ? segment : `.${segment}`;
34
+ return [segment, `*${dotted}`, `*.*${dotted}`];
35
+ });
3
36
  export function createLogger(options = {}) {
4
37
  const level = options.level ?? process.env["LOG_LEVEL"] ?? "info"; // @cast-boundary dynamic-key
5
38
  const pretty = options.pretty ?? process.env["LOG_FORMAT"] === "pretty";
6
39
  const pinoConfig = {
7
40
  level,
41
+ redact: { paths: [...REDACT_PATHS], censor: "[redacted]" },
42
+ mixin: requestLogFields,
8
43
  ...(pretty && !options.destination
9
44
  ? { transport: { target: "pino-pretty", options: { colorize: true } } }
10
45
  : {}),
@@ -0,0 +1 @@
1
+ export declare function requestLogFields(): Record<string, string>;
@@ -0,0 +1,21 @@
1
+ import { requestContext } from "../api/request-context.js";
2
+ // Only set fields are emitted so log lines outside a request stay lean.
3
+ export function requestLogFields() {
4
+ const store = requestContext.get();
5
+ if (!store)
6
+ return {};
7
+ const fields = {};
8
+ if (store.requestId)
9
+ fields["requestId"] = store.requestId;
10
+ if (store.correlationId)
11
+ fields["correlationId"] = store.correlationId;
12
+ if (store.tenantId)
13
+ fields["tenantId"] = store.tenantId;
14
+ if (store.userId)
15
+ fields["userId"] = store.userId;
16
+ if (store.feature)
17
+ fields["feature"] = store.feature;
18
+ if (store.handler)
19
+ fields["handler"] = store.handler;
20
+ return fields;
21
+ }
@@ -0,0 +1,2 @@
1
+ export declare function scrubLogText(text: string): string;
2
+ export declare function scrubLogValue(value: unknown): unknown;
@@ -0,0 +1,21 @@
1
+ const EMAIL_PATTERN = /([A-Za-z0-9._%+-])[A-Za-z0-9._%+-]*@([A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,})/g;
2
+ // Postgres echoes the offending row values in constraint-violation details.
3
+ const PG_KEY_DETAIL_PATTERN = /Key \(([^)]*)\)=\(.*?\)(?=\s|$|[.,;])/g;
4
+ const PG_FAILING_ROW_PATTERN = /Failing row contains \(.*\)/g;
5
+ const JWT_PATTERN = /eyJ[\w-]+\.[\w-]+\.[\w-]+/g;
6
+ const BEARER_PATTERN = /Bearer\s+\S+/g;
7
+ export function scrubLogText(text) {
8
+ return text
9
+ .replace(PG_KEY_DETAIL_PATTERN, "Key ($1)=(***)")
10
+ .replace(PG_FAILING_ROW_PATTERN, "Failing row contains (***)")
11
+ .replace(EMAIL_PATTERN, "$1***@$2")
12
+ .replace(JWT_PATTERN, "[jwt]")
13
+ .replace(BEARER_PATTERN, "Bearer [redacted]");
14
+ }
15
+ export function scrubLogValue(value) {
16
+ if (typeof value === "string")
17
+ return scrubLogText(value);
18
+ if (value instanceof Error)
19
+ return scrubLogText(value.message);
20
+ return value;
21
+ }
@@ -4,5 +4,6 @@ type FallbackLogger = {
4
4
  warn(msg: string, data?: Record<string, unknown>): void;
5
5
  debug(msg: string, data?: Record<string, unknown>): void;
6
6
  };
7
+ export declare function setProcessLogger(logger: Logger | null): void;
7
8
  export declare function createFallbackLogger(namespace: string, logger?: (Pick<Logger, "error"> & Partial<Pick<Logger, "warn" | "debug">>) | undefined): FallbackLogger;
8
9
  export {};
@@ -1,3 +1,8 @@
1
+ let processLogger = null;
2
+ // Fallback loggers created at module level before boot must pick this up, so it is read per call.
3
+ export function setProcessLogger(logger) {
4
+ processLogger = logger;
5
+ }
1
6
  export function createFallbackLogger(namespace, logger) {
2
7
  if (logger) {
3
8
  return {
@@ -24,18 +29,47 @@ export function createFallbackLogger(namespace, logger) {
24
29
  },
25
30
  };
26
31
  }
32
+ let cachedParent = null;
33
+ let cachedChild = null;
34
+ const resolveProcessChild = () => {
35
+ if (!processLogger)
36
+ return null;
37
+ if (cachedParent !== processLogger || !cachedChild) {
38
+ cachedParent = processLogger;
39
+ cachedChild = processLogger.child({ component: namespace });
40
+ }
41
+ return cachedChild;
42
+ };
27
43
  return {
28
44
  error(msg, data) {
29
- // biome-ignore lint/suspicious/noConsole: ops-visible fallback when no logger is wired
30
- console.error(`[${namespace}] ${msg}`, data);
45
+ const child = resolveProcessChild();
46
+ if (child) {
47
+ child.error(msg, data);
48
+ }
49
+ else {
50
+ // biome-ignore lint/suspicious/noConsole: ops-visible fallback when no logger is wired
51
+ console.error(`[${namespace}] ${msg}`, data);
52
+ }
31
53
  },
32
54
  warn(msg, data) {
33
- // biome-ignore lint/suspicious/noConsole: ops-visible fallback when no logger is wired
34
- console.warn(`[${namespace}] ${msg}`, data);
55
+ const child = resolveProcessChild();
56
+ if (child) {
57
+ child.warn(msg, data);
58
+ }
59
+ else {
60
+ // biome-ignore lint/suspicious/noConsole: ops-visible fallback when no logger is wired
61
+ console.warn(`[${namespace}] ${msg}`, data);
62
+ }
35
63
  },
36
64
  debug(msg, data) {
37
- // biome-ignore lint/suspicious/noConsole: ops-visible fallback when no logger is wired
38
- console.debug(`[${namespace}] ${msg}`, data);
65
+ const child = resolveProcessChild();
66
+ if (child) {
67
+ child.debug(msg, data);
68
+ }
69
+ else {
70
+ // biome-ignore lint/suspicious/noConsole: ops-visible fallback when no logger is wired
71
+ console.debug(`[${namespace}] ${msg}`, data);
72
+ }
39
73
  },
40
74
  };
41
75
  }
@@ -18,6 +18,7 @@ import { alterTableAddColumn } from "../db/queries/ddl.js";
18
18
  import { persistPiiBackfillState, readPiiBackfillState, } from "../db/queries/pii-backfill-state.js";
19
19
  import { upsertOnConflict } from "../db/query.js";
20
20
  import { tableExists } from "../db/schema-inspection.js";
21
+ import { withoutStatementTimeout } from "../db/statement-timeout.js";
21
22
  import { unsafePushTables } from "../db/table-push.js";
22
23
  import { createFallbackLogger } from "../logging/utils.js";
23
24
  import { clearPendingRebuilds, createPendingRebuildsTable, listPendingRebuildRows, pendingRebuildsTable, rebuildProjectionOrMultiStream, } from "./pending-rebuilds.js";
@@ -207,7 +208,10 @@ function reportRunOutcome(log, progress) {
207
208
  });
208
209
  }
209
210
  }
210
- export async function runPiiEventBackfill(db, registry, options = {}) {
211
+ export function runPiiEventBackfill(db, registry, options = {}) {
212
+ return withoutStatementTimeout(db, (unboundedDb) => backfillPiiEvents(unboundedDb, registry, options));
213
+ }
214
+ async function backfillPiiEvents(db, registry, options) {
211
215
  const log = createFallbackLogger("pii-event-backfill", options.logger);
212
216
  const kms = configuredPiiSubjectKms();
213
217
  if (!kms || !isLocalKeyKmsAdapter(kms))
@@ -32,6 +32,7 @@ export declare function emitEventConsumerPendingGaps(meter: Meter, labels: {
32
32
  readonly consumer: string;
33
33
  readonly instanceId: string;
34
34
  }, gapRanges: number): void;
35
+ export declare function emitRateLimitFailOpen(meter: Meter, label: string): void;
35
36
  export declare function emitEventConsumerRearmExhausted(meter: Meter, labels: {
36
37
  readonly consumer: string;
37
38
  readonly instanceId: string;
@@ -3,6 +3,12 @@
3
3
  // Feature code never emits these — the Framework wires them from the HTTP
4
4
  // middleware, dispatcher, and DB wrapper.
5
5
  export const STANDARD_METRIC_DEFS = [
6
+ {
7
+ name: "kumiko_rate_limit_fail_open_total",
8
+ type: "counter",
9
+ description: "Requests let through because the rate-limit backend (Redis) failed.",
10
+ labels: ["label"],
11
+ },
6
12
  {
7
13
  name: "kumiko_http_requests_total",
8
14
  type: "counter",
@@ -227,6 +233,9 @@ export function emitEventConsumerPendingGaps(meter, labels, gapRanges) {
227
233
  // Caller (event-dispatcher.ts) is responsible for calling this exactly
228
234
  // once per (consumer, instanceId) transition into exhausted-dead — not
229
235
  // once per poll pass while it stays dead.
236
+ export function emitRateLimitFailOpen(meter, label) {
237
+ meter.counter("kumiko_rate_limit_fail_open_total").inc(1, { label });
238
+ }
230
239
  export function emitEventConsumerRearmExhausted(meter, labels) {
231
240
  meter.counter("kumiko_event_consumer_rearm_exhausted_total").inc(1, {
232
241
  consumer: labels.consumer,
@@ -1,6 +1,7 @@
1
1
  import { markConsumerRebuildFailed, resetConsumerForMspRebuild, selectConsumerForUpdate, selectEventIdHorizonWithMissingRanges, selectSnapshotXmax, updateConsumerRebuildCursor, } from "../db/queries/event-consumer.js";
2
2
  import { assertLiveColumnsMatchMeta, assertLiveTableHasNoRowLevelSecurity, buildShadowTable, ensureRebuildSchema, rebuildMetaOrThrow, swapShadowIntoLive, } from "../db/queries/shadow-swap.js";
3
3
  import { runInSavepoint, selectMany } from "../db/query.js";
4
+ import { withoutStatementTimeout } from "../db/statement-timeout.js";
4
5
  import { withExecutorManagedWrites } from "../db/tenant-db.js";
5
6
  import { InternalError } from "../errors/index.js";
6
7
  import { loadAggregate, loadAggregateAsOf } from "../event-store/event-store.js";
@@ -29,7 +30,10 @@ function createRebuildCtx(registry, db, tenantId) {
29
30
  },
30
31
  };
31
32
  }
32
- export async function rebuildMultiStreamProjection(mspName, deps) {
33
+ export function rebuildMultiStreamProjection(mspName, deps) {
34
+ return withoutStatementTimeout(deps.db, (db) => replayMultiStreamProjectionIntoShadow(mspName, { ...deps, db }));
35
+ }
36
+ async function replayMultiStreamProjectionIntoShadow(mspName, deps) {
33
37
  const { db, registry } = deps;
34
38
  const msp = registry.getAllMultiStreamProjections().get(mspName);
35
39
  if (!msp) {
@@ -1,6 +1,7 @@
1
1
  import { countSubscribedEvents, finalizeProjectionRebuild, markProjectionRebuildFailed, markProjectionRebuilding, selectEventsForProjectionRebuildBatch, } from "../db/queries/projection-rebuild.js";
2
2
  import { assertLiveColumnsMatchMeta, assertLiveTableHasNoRowLevelSecurity, assertNoBlindIndexLoss, assertNoUnreachableLiveRows, buildShadowTable, countColumnDrift, ensureRebuildSchema, fenceLiveTable, rebuildMetaOrThrow, swapShadowIntoLive, } from "../db/queries/shadow-swap.js";
3
3
  import { coerceRow, extractTableInfo, runInSavepoint, selectMany } from "../db/query.js";
4
+ import { withoutStatementTimeout } from "../db/statement-timeout.js";
4
5
  import { withExecutorManagedWrites } from "../db/tenant-db.js";
5
6
  import { eventsTable, getEventsHighWaterMark, upcastStoredEvent, } from "../event-store/index.js";
6
7
  import { createRebuildDeadLetterTable, recordRebuildDeadLetters, } from "../event-store/rebuild-dead-letter.js";
@@ -45,7 +46,10 @@ function warnOnColumnDrift(projectionName, tableName, drift) {
45
46
  "Not blocking — replay wins on swap. Likely cause: a legacy direct-write predating the row's " +
46
47
  "event history (#494), or a sensitive custom field pending #972. Investigate if unexpected.");
47
48
  }
48
- export async function rebuildProjection(projectionName, deps) {
49
+ export function rebuildProjection(projectionName, deps) {
50
+ return withoutStatementTimeout(deps.db, (db) => replayProjectionIntoShadow(projectionName, { ...deps, db }));
51
+ }
52
+ async function replayProjectionIntoShadow(projectionName, deps) {
49
53
  const { db, registry } = deps;
50
54
  const projection = registry.getAllProjections().get(projectionName);
51
55
  if (!projection) {
@@ -1,7 +1,8 @@
1
1
  import type { Context, MiddlewareHandler } from "hono";
2
2
  import { type ClientIpResolver } from "../api/client-ip.ts";
3
3
  import type { HttpRouteRateLimit } from "../engine/types/http-route.ts";
4
- import type { RateLimitResolver } from "./resolver.ts";
4
+ import type { Meter } from "../observability/types/index.ts";
5
+ import type { RateLimitDecision, RateLimitResolver } from "./resolver.ts";
5
6
  export type GlobalIpRateLimitOptions = {
6
7
  readonly resolver: RateLimitResolver;
7
8
  readonly limit?: number;
@@ -9,7 +10,8 @@ export type GlobalIpRateLimitOptions = {
9
10
  readonly trustedProxyHops?: number;
10
11
  readonly clientIpResolver?: ClientIpResolver;
11
12
  readonly extractIp?: (c: Context) => string | undefined;
12
- readonly onFailClosed?: (err: unknown) => void;
13
+ readonly onFailOpen?: (err: unknown) => void;
14
+ readonly meter?: Meter;
13
15
  };
14
16
  export declare const DEFAULT_GLOBAL_RATE_LIMIT: {
15
17
  readonly limit: 600;
@@ -21,7 +23,8 @@ export type HttpRouteRateLimitOptions = {
21
23
  readonly rateLimit: HttpRouteRateLimit;
22
24
  readonly routeKey: string;
23
25
  readonly clientIpResolver: ClientIpResolver;
24
- readonly onFailClosed?: (err: unknown) => void;
26
+ readonly onFailOpen?: (err: unknown) => void;
27
+ readonly meter?: Meter;
25
28
  };
26
29
  export declare function httpRouteRateLimit(opts: HttpRouteRateLimitOptions): MiddlewareHandler;
27
30
  export type AuthEndpointRateLimitOptions = {
@@ -35,3 +38,5 @@ export type AuthEndpointRateLimitOptions = {
35
38
  readonly onFailClosed?: (err: unknown) => void;
36
39
  };
37
40
  export declare function authEndpointRateLimit(opts: AuthEndpointRateLimitOptions): MiddlewareHandler;
41
+ export declare function createFailOpenReporter(label: string, meter?: Meter, now?: () => number): (err: unknown) => void;
42
+ export declare function respondRateLimited(c: Context, decision: RateLimitDecision, bucket: string): Response;
@@ -2,6 +2,8 @@ import { isAuthRateLimitExempt } from "../api/api-constants.js";
2
2
  import { clientIpSourceFromHonoContext, createClientIpResolver, } from "../api/client-ip.js";
3
3
  import { requestContext } from "../api/request-context.js";
4
4
  import { RateLimitError, serializeError } from "../errors/index.js";
5
+ import { createFallbackLogger, scrubLogText } from "../logging/index.js";
6
+ import { emitRateLimitFailOpen } from "../observability/standard-metrics.js";
5
7
  import { toPublicBucketName } from "./bucket.js";
6
8
  // Shared with runProdApp (server-runtime re-exports it). The auth middleware default stays
7
9
  // stricter than runProdApp's auth limit (30): a bare authEndpointRateLimit guards brute force.
@@ -12,7 +14,7 @@ export function globalIpRateLimit(opts) {
12
14
  const clientIpResolver = opts.clientIpResolver ??
13
15
  createClientIpResolver(opts.trustedProxyHops ?? 0, "globalIpRateLimit");
14
16
  const extractIp = opts.extractIp ?? ((c) => clientIpResolver.resolve(clientIpSourceFromHonoContext(c)));
15
- const onFailClosed = opts.onFailClosed ?? defaultOnFailClosed("l1-global-ip");
17
+ const onFailOpen = opts.onFailOpen ?? createFailOpenReporter("l1-global-ip", opts.meter);
16
18
  return async (c, next) => {
17
19
  const ip = extractIp(c);
18
20
  if (!ip) {
@@ -28,20 +30,18 @@ export function globalIpRateLimit(opts) {
28
30
  setRateLimitHeaders(c, decision);
29
31
  }
30
32
  catch (e) {
31
- // Fail-closed: refuse rather than let a flood through with no cap.
32
33
  // resolver.check never throws RateLimitError (only enforce does),
33
34
  // so any throw here is an infrastructure failure (Redis down).
34
- onFailClosed(e);
35
- return c.json({ error: { code: "rate_limit_unavailable", message: "Rate limiter unavailable" } }, 503);
35
+ onFailOpen(e);
36
36
  }
37
37
  await next();
38
38
  };
39
39
  }
40
- // Per-route limit for r.httpRoute({ rateLimit }). Fail-closed like L1: these
41
- // routes are anonymous, so an outage must not turn into an unbounded flood.
40
+ // Per-route limit for r.httpRoute({ rateLimit }). Fail-open like L1: a Redis outage must not
41
+ // take anonymous routes (status pages, webhooks) down with it.
42
42
  export function httpRouteRateLimit(opts) {
43
43
  const { rateLimit, routeKey } = opts;
44
- const onFailClosed = opts.onFailClosed ?? defaultOnFailClosed("http-route");
44
+ const onFailOpen = opts.onFailOpen ?? createFailOpenReporter("http-route", opts.meter);
45
45
  const config = {
46
46
  limit: rateLimit.limit,
47
47
  windowSeconds: rateLimit.windowSeconds,
@@ -59,8 +59,7 @@ export function httpRouteRateLimit(opts) {
59
59
  setRateLimitHeaders(c, decision);
60
60
  }
61
61
  catch (e) {
62
- onFailClosed(e);
63
- return c.json({ error: { code: "rate_limit_unavailable", message: "Rate limiter unavailable" } }, 503);
62
+ onFailOpen(e);
64
63
  }
65
64
  await next();
66
65
  };
@@ -95,6 +94,25 @@ export function authEndpointRateLimit(opts) {
95
94
  await next();
96
95
  };
97
96
  }
97
+ const FAIL_OPEN_WARN_INTERVAL_MS = 30_000;
98
+ export function createFailOpenReporter(label, meter, now = Date.now) {
99
+ const logger = createFallbackLogger("rate-limit");
100
+ let lastWarnAt;
101
+ return (err) => {
102
+ if (meter)
103
+ emitRateLimitFailOpen(meter, label);
104
+ const nowMs = now();
105
+ // skip: throttled, the counter above still records every fail-open
106
+ if (lastWarnAt !== undefined && nowMs - lastWarnAt < FAIL_OPEN_WARN_INTERVAL_MS)
107
+ return;
108
+ lastWarnAt = nowMs;
109
+ logger.warn("rate limiter unavailable, failing open", {
110
+ label,
111
+ // ioredis errors can carry the command args, i.e. the bucket key with IP/target.
112
+ error: scrubLogText(err instanceof Error ? err.message : String(err)),
113
+ });
114
+ };
115
+ }
98
116
  function defaultOnFailClosed(label) {
99
117
  return (err) => {
100
118
  // Loud by default — fail-closed for an unknown reason is an ops
@@ -116,7 +134,7 @@ function setRateLimitHeaders(c, decision) {
116
134
  // client libs that parse as integer would silently see NaN.
117
135
  c.header("X-RateLimit-Reset", String(Math.floor(decision.resetAt.epochMilliseconds / 1000)));
118
136
  }
119
- function respondRateLimited(c, decision, bucket) {
137
+ export function respondRateLimited(c, decision, bucket) {
120
138
  // Build a RateLimitError so the wire shape is identical to the L3
121
139
  // dispatcher path. serializeError adds i18nKey, requestId, timestamp —
122
140
  // fields a hand-rolled body would silently miss.
@@ -179,11 +179,19 @@ function ensureCommand(redis) {
179
179
  }
180
180
  return redis; // @cast-boundary engine-bridge
181
181
  }
182
+ // ioredis queues commands while reconnecting; waiting out commandTimeout per request would
183
+ // stall every caller, so a non-ready connection counts as a failure immediately.
184
+ function assertRedisReady(redis) {
185
+ if (redis.status !== "ready") {
186
+ throw new Error(`rate-limit redis connection not ready (status: ${redis.status})`);
187
+ }
188
+ }
182
189
  export function createRateLimitResolver(opts) {
183
190
  const client = ensureCommand(opts.redis);
184
191
  const prefix = opts.keyPrefix ?? RedisKeys.rateLimit;
185
192
  const now = opts.nowMs ?? (() => Date.now());
186
193
  async function check(bucket, config) {
194
+ assertRedisReady(opts.redis);
187
195
  const cost = config.cost ?? 1;
188
196
  const refillRatePerMs = config.limit / (config.windowSeconds * 1000);
189
197
  const ttlSeconds = Math.max(1, config.windowSeconds * 2);
@@ -216,6 +224,7 @@ export function createRateLimitResolver(opts) {
216
224
  async function enforceAll(requests) {
217
225
  if (requests.length === 0)
218
226
  return [];
227
+ assertRedisReady(opts.redis);
219
228
  const nowMs = now();
220
229
  const keys = requests.map(({ bucket }) => `${prefix}${bucket}`);
221
230
  // The script writes each key back once, so a repeated key would deduct only one of its costs.
@@ -257,6 +266,7 @@ export function createRateLimitResolver(opts) {
257
266
  });
258
267
  }
259
268
  async function peek(bucket, config) {
269
+ assertRedisReady(opts.redis);
260
270
  const refillRatePerMs = config.limit / (config.windowSeconds * 1000);
261
271
  const nowMs = now();
262
272
  const [remaining, retryAfterMs] = await client.kumikoRateLimitPeek(`${prefix}${bucket}`, String(config.limit), String(refillRatePerMs), String(nowMs));
@@ -1,4 +1,5 @@
1
1
  import { Redis, type RedisOptions } from "ioredis";
2
+ import type { Logger } from "../logging/types.ts";
2
3
  export type RedisClientOptions = {
3
4
  readonly connectTimeoutMs?: number;
4
5
  readonly commandTimeoutMs?: number;
@@ -6,5 +7,6 @@ export type RedisClientOptions = {
6
7
  readonly extra?: Readonly<Omit<RedisOptions, "connectTimeout" | "commandTimeout" | "maxRetriesPerRequest">>;
7
8
  };
8
9
  export declare function createRedisClient(url: string, options?: RedisClientOptions): Redis;
10
+ export declare function logRedisConnectionState(redis: Redis, logger: Pick<Logger, "warn" | "info">): void;
9
11
  export declare function redisChannelPrefixFromEnv(env?: Readonly<Record<string, string | undefined>>): string;
10
12
  export declare function redisClientOptionsFromEnv(env?: Readonly<Record<string, string | undefined>>): RedisClientOptions;