@cosmicdrift/kumiko-framework 0.341.0 → 0.343.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 (74) hide show
  1. package/dist/api/auth-routes.d.ts +2 -0
  2. package/dist/api/auth-routes.js +35 -2
  3. package/dist/api/request-context.d.ts +2 -0
  4. package/dist/api/request-id-middleware.d.ts +2 -0
  5. package/dist/api/request-id-middleware.js +4 -1
  6. package/dist/api/routes.js +5 -5
  7. package/dist/api/server.d.ts +3 -0
  8. package/dist/api/server.js +4 -1
  9. package/dist/compliance/duration-spec.d.ts +2 -1
  10. package/dist/compliance/duration-spec.js +11 -0
  11. package/dist/compliance/index.d.ts +2 -2
  12. package/dist/compliance/index.js +1 -1
  13. package/dist/compliance/profiles.d.ts +6 -5
  14. package/dist/crypto/kek-source.d.ts +3 -4
  15. package/dist/crypto/kek-source.js +2 -5
  16. package/dist/crypto/kms-wiring.d.ts +2 -2
  17. package/dist/crypto/kms-wiring.js +2 -2
  18. package/dist/db/entity-field-encryption.d.ts +1 -1
  19. package/dist/db/entity-field-encryption.js +5 -5
  20. package/dist/db/event-store-executor-write.js +38 -11
  21. package/dist/db/index.d.ts +2 -1
  22. package/dist/db/index.js +2 -1
  23. package/dist/db/migrate-generator.d.ts +6 -0
  24. package/dist/db/migrate-generator.js +15 -1
  25. package/dist/db/migrate-runner.d.ts +1 -0
  26. package/dist/db/migrate-runner.js +11 -1
  27. package/dist/db/projection-registry-binding.d.ts +3 -0
  28. package/dist/db/projection-registry-binding.js +12 -0
  29. package/dist/db/retired-framework-tables.d.ts +10 -0
  30. package/dist/db/retired-framework-tables.js +72 -0
  31. package/dist/db/tenant-db.d.ts +2 -0
  32. package/dist/db/tenant-db.js +22 -7
  33. package/dist/engine/boot-validator/index.d.ts +11 -0
  34. package/dist/engine/boot-validator/index.js +45 -1
  35. package/dist/engine/boot-validator/query-output-columns.js +8 -7
  36. package/dist/engine/boot-validator/query-refs.js +3 -0
  37. package/dist/engine/boot-validator/screens.js +28 -0
  38. package/dist/engine/index.d.ts +2 -2
  39. package/dist/engine/index.js +1 -1
  40. package/dist/engine/soft-delete-cleanup.d.ts +2 -0
  41. package/dist/engine/soft-delete-cleanup.js +28 -9
  42. package/dist/engine/types/index.d.ts +2 -2
  43. package/dist/engine/types/index.js +1 -1
  44. package/dist/env/index.d.ts +3 -0
  45. package/dist/env/index.js +12 -0
  46. package/dist/errors/docs-url.d.ts +7 -0
  47. package/dist/errors/docs-url.js +36 -0
  48. package/dist/errors/index.d.ts +2 -0
  49. package/dist/errors/index.js +1 -0
  50. package/dist/errors/kumiko-error.d.ts +0 -2
  51. package/dist/errors/kumiko-error.js +0 -24
  52. package/dist/errors/serialize.d.ts +3 -2
  53. package/dist/errors/serialize.js +4 -2
  54. package/dist/jobs/job-runner.js +37 -2
  55. package/dist/observability/escape-hatch-report.d.ts +10 -1
  56. package/dist/observability/escape-hatch-report.js +19 -1
  57. package/dist/observability/index.d.ts +2 -2
  58. package/dist/observability/index.js +2 -2
  59. package/dist/observability/standard-metrics.d.ts +1 -0
  60. package/dist/observability/standard-metrics.js +14 -0
  61. package/dist/pipeline/dispatch-shared.js +3 -1
  62. package/dist/pipeline/event-retention.d.ts +1 -0
  63. package/dist/pipeline/event-retention.js +6 -0
  64. package/dist/pipeline/index.d.ts +2 -0
  65. package/dist/pipeline/index.js +1 -0
  66. package/dist/pipeline/projections-runner.d.ts +1 -0
  67. package/dist/pipeline/projections-runner.js +23 -14
  68. package/dist/rate-limit/middleware.js +1 -1
  69. package/dist/schema-cli.d.ts +8 -6
  70. package/dist/schema-cli.js +37 -4
  71. package/dist/stack/test-stack.d.ts +7 -0
  72. package/dist/stack/test-stack.js +6 -0
  73. package/package.json +4 -4
  74. package/src/changes.json +60 -0
@@ -0,0 +1,36 @@
1
+ import { AgentReasons, FrameworkReasons } from "./reasons.js";
2
+ const DEFAULT_DOCS_BASE_URL = "https://docs.kumiko.rocks";
3
+ const REASON_SLUG_RE = /^[a-z0-9_.-]+$/;
4
+ const frameworkReasons = new Set([
5
+ ...Object.values(FrameworkReasons),
6
+ ...Object.values(AgentReasons),
7
+ ]);
8
+ function reasonOf(err) {
9
+ if (!err.details || typeof err.details !== "object")
10
+ return undefined;
11
+ // @cast-boundary error-details — per-error typed details; reflection shape only for the reason lookup.
12
+ const reason = err.details["reason"];
13
+ return typeof reason === "string" && REASON_SLUG_RE.test(reason) ? reason : undefined;
14
+ }
15
+ function isCoveredByConfig(reason, errorDocs) {
16
+ if (!errorDocs)
17
+ return false;
18
+ return errorDocs.reasons === "all" || (errorDocs.reasons?.includes(reason) ?? false);
19
+ }
20
+ function errorsUrl(baseUrl, slug) {
21
+ return `${baseUrl}/errors/${encodeURIComponent(slug)}`;
22
+ }
23
+ // A reason the framework documents (or the error code itself) links to the framework docs,
24
+ // overridable via KUMIKO_DOCS_URL. An app's own reason links only where `errorDocs` covers it —
25
+ // otherwise the framework docs would have no page for it.
26
+ export function resolveErrorDocsUrl(err, errorDocs) {
27
+ const reason = reasonOf(err);
28
+ if (reason === undefined || reason === err.code || frameworkReasons.has(reason)) {
29
+ const baseUrl = process.env["KUMIKO_DOCS_URL"] ?? DEFAULT_DOCS_BASE_URL;
30
+ return errorsUrl(baseUrl, reason ?? err.code);
31
+ }
32
+ if (errorDocs && isCoveredByConfig(reason, errorDocs)) {
33
+ return errorsUrl(errorDocs.baseUrl, reason);
34
+ }
35
+ return undefined;
36
+ }
@@ -1,5 +1,7 @@
1
1
  export type { FeatureDisabledDetails, FieldIssue, NotFoundDetails, PreconditionFailedDetails, RateLimitDetails, UnconfiguredDetails, UniqueViolationDetails, UnprocessableOpts, ValidationDetails, VersionConflictDetails, } from "./classes.js";
2
2
  export { AccessDeniedError, ConflictError, FeatureDisabledError, IdempotentReplayError, InternalError, NotFoundError, PreconditionFailedError, RateLimitError, RateLimitUnavailableError, UnauthenticatedError, UnconfiguredError, UniqueViolationError, UnprocessableError, ValidationError, VersionConflictError, } from "./classes.js";
3
+ export type { ErrorDocsConfig } from "./docs-url.js";
4
+ export { resolveErrorDocsUrl } from "./docs-url.js";
3
5
  export type { ErrorCtorInput, ErrorOpts } from "./kumiko-error.js";
4
6
  export { isKumikoError, KumikoError } from "./kumiko-error.js";
5
7
  export { memberResolutionReadOnlyDenied } from "./member-resolution.js";
@@ -1,4 +1,5 @@
1
1
  export { AccessDeniedError, ConflictError, FeatureDisabledError, IdempotentReplayError, InternalError, NotFoundError, PreconditionFailedError, RateLimitError, RateLimitUnavailableError, UnauthenticatedError, UnconfiguredError, UniqueViolationError, UnprocessableError, ValidationError, VersionConflictError, } from "./classes.js";
2
+ export { resolveErrorDocsUrl } from "./docs-url.js";
2
3
  export { isKumikoError, KumikoError } from "./kumiko-error.js";
3
4
  export { memberResolutionReadOnlyDenied } from "./member-resolution.js";
4
5
  export { AgentReasons, FrameworkReasons } from "./reasons.js";
@@ -19,7 +19,5 @@ export declare abstract class KumikoError extends Error {
19
19
  readonly i18nParams: Readonly<Record<string, unknown>> | undefined;
20
20
  readonly details: unknown;
21
21
  constructor(input: ErrorCtorInput);
22
- get docsUrl(): string;
23
- private get reasonSlug();
24
22
  }
25
23
  export declare function isKumikoError(e: unknown): e is KumikoError;
@@ -2,13 +2,6 @@
2
2
  // Anything that isn't a KumikoError is treated as an unexpected crash and
3
3
  // auto-wrapped into InternalError by the dispatcher — so the HTTP layer never
4
4
  // has to guess the status or the client-facing shape.
5
- // Default docs URL for self-service errors. Override via `KUMIKO_DOCS_URL`
6
- // (e.g. self-hosted customers pointing at their own docs instance).
7
- const DEFAULT_DOCS_BASE_URL = "https://docs.kumiko.rocks";
8
- const REASON_SLUG_RE = /^[a-z0-9_.-]+$/;
9
- function docsBaseUrl() {
10
- return process.env["KUMIKO_DOCS_URL"] ?? DEFAULT_DOCS_BASE_URL;
11
- }
12
5
  export class KumikoError extends Error {
13
6
  i18nKey;
14
7
  i18nParams;
@@ -20,23 +13,6 @@ export class KumikoError extends Error {
20
13
  this.i18nParams = input.i18nParams;
21
14
  this.details = input.details;
22
15
  }
23
- // Docs URL for self-service. Prefer a well-formed `details.reason` slug
24
- // (e.g. ConflictError → "stale_state"); otherwise fall back to the error
25
- // code. Encode the path segment so spaces/`../` in free-form reasons
26
- // cannot break or redirect the link the default renderer shows.
27
- get docsUrl() {
28
- return `${docsBaseUrl()}/errors/${encodeURIComponent(this.reasonSlug)}`;
29
- }
30
- get reasonSlug() {
31
- if (this.details && typeof this.details === "object") {
32
- // @cast-boundary error-details — per-error typed details; reflection
33
- // shape only for the reasonSlug lookup.
34
- const r = this.details["reason"];
35
- if (typeof r === "string" && REASON_SLUG_RE.test(r))
36
- return r;
37
- }
38
- return this.code;
39
- }
40
16
  }
41
17
  export function isKumikoError(e) {
42
18
  return e instanceof KumikoError;
@@ -1,3 +1,4 @@
1
+ import { type ErrorDocsConfig } from "./docs-url.js";
1
2
  import type { KumikoError } from "./kumiko-error.js";
2
3
  export type ErrorResponseBody = {
3
4
  readonly error: {
@@ -6,12 +7,12 @@ export type ErrorResponseBody = {
6
7
  readonly i18nParams?: Readonly<Record<string, unknown>>;
7
8
  readonly message: string;
8
9
  readonly details?: unknown;
9
- readonly docsUrl: string;
10
+ readonly docsUrl?: string;
10
11
  readonly requestId?: string;
11
12
  readonly timestamp: string;
12
13
  };
13
14
  };
14
- export declare function serializeError(err: KumikoError, requestId?: string): ErrorResponseBody;
15
+ export declare function serializeError(err: KumikoError, requestId?: string, errorDocs?: ErrorDocsConfig): ErrorResponseBody;
15
16
  export type ErrorLogEntry = {
16
17
  readonly name: string;
17
18
  readonly code: string;
@@ -1,3 +1,4 @@
1
+ import { resolveErrorDocsUrl } from "./docs-url.js";
1
2
  // InternalError deliberately omits `details` from the response in production,
2
3
  // because that field often carries the internal cause. The log still has
3
4
  // everything (see buildErrorLog).
@@ -19,7 +20,7 @@ function devCauseDetail(cause) {
19
20
  ...(cause.stack && { causeStack: cause.stack.split("\n").slice(0, 8).join("\n") }),
20
21
  };
21
22
  }
22
- export function serializeError(err, requestId) {
23
+ export function serializeError(err, requestId, errorDocs) {
23
24
  const stripped = CODES_WITHOUT_CLIENT_DETAILS.has(err.code) && isProductionEnv();
24
25
  const exposeDeclaredDetails = err.details !== undefined && !stripped;
25
26
  // For internal_error in dev/test, surface the cause chain so the
@@ -34,6 +35,7 @@ export function serializeError(err, requestId) {
34
35
  }
35
36
  return undefined;
36
37
  })();
38
+ const docsUrl = resolveErrorDocsUrl(err, errorDocs);
37
39
  return {
38
40
  error: {
39
41
  code: err.code,
@@ -41,7 +43,7 @@ export function serializeError(err, requestId) {
41
43
  ...(err.i18nParams && { i18nParams: err.i18nParams }),
42
44
  message: err.message,
43
45
  ...(detailsForResponse !== undefined && { details: detailsForResponse }),
44
- docsUrl: err.docsUrl,
46
+ ...(docsUrl !== undefined && { docsUrl }),
45
47
  ...(requestId && { requestId }),
46
48
  timestamp: new Date().toISOString(),
47
49
  },
@@ -11,7 +11,7 @@ import { InternalError } from "../errors/index.js";
11
11
  import { isKumikoError } from "../errors/kumiko-error.js";
12
12
  import { createFileContext } from "../files/file-handle.js";
13
13
  import { createFallbackLogger } from "../logging/index.js";
14
- import { createEscapeHatchReporter, emitJobLastSuccess, emitJobQueueDepth, getFallbackTracer, registerStandardMetrics, } from "../observability/index.js";
14
+ import { createEscapeHatchProcessDedup, createEscapeHatchReporter, emitJobLastSuccess, emitJobQueueDepth, getFallbackTracer, registerStandardMetrics, } from "../observability/index.js";
15
15
  import { createDistributedLock } from "../pipeline/distributed-lock.js";
16
16
  import { RedisKeys } from "../pipeline/redis-keys.js";
17
17
  import { buildPersonalDataGate, isPersonalDataGated, parseWriteOrigin, } from "../pipeline/write-origin.js";
@@ -159,6 +159,22 @@ function missingDbProxy(jobName) {
159
159
  },
160
160
  });
161
161
  }
162
+ function createCrossTenantReads(systemModeDb, reason, report) {
163
+ return {
164
+ selectMany(table, where, options) {
165
+ report("cross-tenant-read", reason);
166
+ return systemModeDb.selectMany(table, where, options);
167
+ },
168
+ fetchOne(table, where) {
169
+ report("cross-tenant-read", reason);
170
+ return systemModeDb.fetchOne(table, where);
171
+ },
172
+ count(table, where) {
173
+ report("cross-tenant-read", reason);
174
+ return systemModeDb.count(table, where);
175
+ },
176
+ };
177
+ }
162
178
  function priorAttemptsOf(data) {
163
179
  const value = data["_priorAttempts"];
164
180
  return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : 0;
@@ -316,6 +332,12 @@ export function createJobRunner(options) {
316
332
  // must not depend on buildServer having done it first. Idempotent.
317
333
  if (context.meter)
318
334
  registerStandardMetrics(context.meter);
335
+ // Instance-scoped, not module-level: each runner (and each test) audits a system cron's declared hatch once.
336
+ const systemCronEscapeHatchDedup = createEscapeHatchProcessDedup([
337
+ "unsafe-raw",
338
+ "global-write",
339
+ "cross-tenant-read",
340
+ ]);
319
341
  // Set at the top of stop() — a graceful shutdown closes the redis/BullMQ
320
342
  // clients itself, which fires the exact same 'error' listeners below with
321
343
  // an expected "Connection is closed." Downgrading to debug once stopping
@@ -688,16 +710,22 @@ export function createJobRunner(options) {
688
710
  // unchecked cross-tenant escape hatch for such a job. Gated like jobDb so
689
711
  // ctx.systemDb, built from it, is gated too.
690
712
  const tenantScopedDb = configDb
691
- ? createTenantDb(configDb, tenantId, "system", undefined, undefined, undefined, jobPersonalDataGate ? { personalDataGate: jobPersonalDataGate } : undefined)
713
+ ? createTenantDb(configDb, tenantId, "system", undefined, undefined, undefined, {
714
+ projectionRegistry: registry,
715
+ ...(jobPersonalDataGate && { personalDataGate: jobPersonalDataGate }),
716
+ })
692
717
  : undefined;
693
718
  const isSystemJob = registry.isJobSystemScoped(jobName);
694
719
  // One reporter for ctx.systemDb and ctx.db.unsafeRaw() so both dedupe in the same window.
720
+ const isSystemCronRun = triggeredById === null && jobDef.escapeHatch !== undefined && "cron" in jobDef.trigger;
695
721
  const reportEscapeHatch = createEscapeHatchReporter({
696
722
  handler: jobName,
697
723
  tenantId,
698
724
  actor: jobSystemUser.id,
699
725
  sink: context._escapeHatchAuditSink,
700
726
  log: context.log,
727
+ ...(isSystemCronRun && { processDedup: systemCronEscapeHatchDedup }),
728
+ ...(context.meter && { meter: context.meter }),
701
729
  });
702
730
  const systemDb = isSystemJob && tenantScopedDb
703
731
  ? createUncheckedSystemDb(tenantScopedDb, undefined, reportEscapeHatch)
@@ -705,10 +733,16 @@ export function createJobRunner(options) {
705
733
  const jobDb = configDb
706
734
  ? createTenantDb(configDb, tenantId, "tenant", context.tracer, context.meter, undefined, {
707
735
  unsafeRaw: escapeHatchFor(jobDef.escapeHatch, "unsafeRaw"),
736
+ globalWrites: escapeHatchFor(jobDef.escapeHatch, "globalWrites"),
708
737
  report: reportEscapeHatch,
738
+ projectionRegistry: registry,
709
739
  ...(jobPersonalDataGate && { personalDataGate: jobPersonalDataGate }),
710
740
  })
711
741
  : undefined;
742
+ const crossTenantReadsReason = escapeHatchFor(jobDef.escapeHatch, "unsafeRaw")?.reason;
743
+ const crossTenantReads = tenantScopedDb && crossTenantReadsReason !== undefined
744
+ ? createCrossTenantReads(tenantScopedDb, crossTenantReadsReason, reportEscapeHatch)
745
+ : undefined;
712
746
  const config = context._configAccessorFactory && tenantScopedDb
713
747
  ? context._configAccessorFactory({
714
748
  user: { id: jobSystemUser.id, tenantId },
@@ -737,6 +771,7 @@ export function createJobRunner(options) {
737
771
  ...(notify !== undefined && { notify }),
738
772
  ...(config !== undefined && { config }),
739
773
  ...(systemDb && { systemDb }),
774
+ ...(crossTenantReads && { crossTenantReads }),
740
775
  // The runner owns the registry it resolved this job from — expose it so
741
776
  // workers can reach projections/jobs without the app author duplicating
742
777
  // it into `context` (the JobContext contract guarantees `registry`).
@@ -1,5 +1,6 @@
1
- import type { EscapeHatchAuditSink, EscapeHatchReporter, EscapeHatchUseEvent, TenantId } from "../engine/types/index.js";
1
+ import type { EscapeHatchAuditSink, EscapeHatchKind, EscapeHatchReporter, EscapeHatchUseEvent, TenantId } from "../engine/types/index.js";
2
2
  import type { Logger } from "../logging/types.js";
3
+ import type { Meter } from "./types/index.js";
3
4
  export declare const ESCAPE_HATCH_USED_SIGNAL = "security:escape-hatch-used";
4
5
  export type EscapeHatchReportWindow = {
5
6
  shouldReport(key: string, nowMs: number): boolean;
@@ -8,6 +9,11 @@ export declare function createEscapeHatchReportWindow(opts?: {
8
9
  readonly windowMs?: number;
9
10
  readonly maxEntries?: number;
10
11
  }): EscapeHatchReportWindow;
12
+ export type EscapeHatchProcessDedup = {
13
+ readonly kinds: ReadonlySet<EscapeHatchKind>;
14
+ readonly seen: Set<string>;
15
+ };
16
+ export declare function createEscapeHatchProcessDedup(kinds: readonly EscapeHatchKind[]): EscapeHatchProcessDedup;
11
17
  export declare function reportEscapeHatchUse(event: EscapeHatchUseEvent, deps: {
12
18
  readonly sink?: EscapeHatchAuditSink;
13
19
  readonly log?: Logger;
@@ -19,6 +25,9 @@ export declare function createEscapeHatchReporter(opts: {
19
25
  readonly sink?: EscapeHatchAuditSink;
20
26
  readonly log?: Logger;
21
27
  readonly window?: EscapeHatchReportWindow;
28
+ readonly processDedup?: EscapeHatchProcessDedup;
29
+ readonly meter?: Meter;
30
+ readonly now?: () => number;
22
31
  }): EscapeHatchReporter;
23
32
  export declare const UNATTRIBUTED_ACTOR = "system";
24
33
  export declare function fallbackEscapeHatchReporter(tenantId: TenantId): EscapeHatchReporter;
@@ -1,3 +1,4 @@
1
+ import { emitEscapeHatchUse } from "./standard-metrics.js";
1
2
  export const ESCAPE_HATCH_USED_SIGNAL = "security:escape-hatch-used";
2
3
  const DEFAULT_WINDOW_MS = 60_000;
3
4
  const DEFAULT_MAX_ENTRIES = 1_000;
@@ -24,6 +25,9 @@ export function createEscapeHatchReportWindow(opts) {
24
25
  },
25
26
  };
26
27
  }
28
+ export function createEscapeHatchProcessDedup(kinds) {
29
+ return { kinds: new Set(kinds), seen: new Set() };
30
+ }
27
31
  // Default window for createEscapeHatchReporter callers that don't pass their own.
28
32
  const fallbackReportWindow = createEscapeHatchReportWindow();
29
33
  const consoleLogger = {
@@ -67,7 +71,21 @@ export function reportEscapeHatchUse(event, deps) {
67
71
  }
68
72
  export function createEscapeHatchReporter(opts) {
69
73
  const window = opts.window ?? fallbackReportWindow;
74
+ const now = opts.now ?? Date.now;
70
75
  return (kind, reason, target) => {
76
+ if (opts.meter)
77
+ emitEscapeHatchUse(opts.meter, opts.handler, kind);
78
+ const { processDedup } = opts;
79
+ if (processDedup?.kinds.has(kind)) {
80
+ const processKey = JSON.stringify([opts.handler, kind, opts.tenantId]);
81
+ // skip: this (handler, kind, tenant) was already audited by this process
82
+ if (processDedup.seen.has(processKey))
83
+ return;
84
+ processDedup.seen.add(processKey);
85
+ reportEscapeHatchUse({ handler: opts.handler, kind, reason, tenantId: opts.tenantId, actor: opts.actor, target }, { sink: opts.sink, log: opts.log });
86
+ // skip: audited above, the window dedup below only serves the other kinds
87
+ return;
88
+ }
71
89
  const key = JSON.stringify([
72
90
  opts.handler,
73
91
  kind,
@@ -78,7 +96,7 @@ export function createEscapeHatchReporter(opts) {
78
96
  target?.tenantId ?? null,
79
97
  ]);
80
98
  // skip: same (handler, kind, reason, target) already reported within the window — dedup
81
- if (!window.shouldReport(key, Date.now()))
99
+ if (!window.shouldReport(key, now()))
82
100
  return;
83
101
  reportEscapeHatchUse({ handler: opts.handler, kind, reason, tenantId: opts.tenantId, actor: opts.actor, target }, { sink: opts.sink, log: opts.log });
84
102
  };
@@ -1,6 +1,6 @@
1
1
  export { type ConsoleProviderOptions, createConsoleProvider } from "./console-provider.js";
2
2
  export { observabilityContext } from "./context.js";
3
- export { createEscapeHatchReporter, createEscapeHatchReportWindow, ESCAPE_HATCH_USED_SIGNAL, type EscapeHatchReportWindow, fallbackEscapeHatchReporter, reportEscapeHatchUse, UNATTRIBUTED_ACTOR, } from "./escape-hatch-report.js";
3
+ export { createEscapeHatchProcessDedup, createEscapeHatchReporter, createEscapeHatchReportWindow, ESCAPE_HATCH_USED_SIGNAL, type EscapeHatchProcessDedup, type EscapeHatchReportWindow, fallbackEscapeHatchReporter, reportEscapeHatchUse, UNATTRIBUTED_ACTOR, } from "./escape-hatch-report.js";
4
4
  export { getFallbackMeter, getFallbackProvider, getFallbackTracer } from "./fallback.js";
5
5
  export { generateSpanId, generateTraceId } from "./ids.js";
6
6
  export { buildMetricName, validateLabelKey, validateMetricName, } from "./metric-validator.js";
@@ -12,5 +12,5 @@ export { type MetricEvent, type MetricEventHandler, RecordingMeter, } from "./re
12
12
  export { type RecordedSpan, RecordingTracer, type RecordingTracerOptions, serializeSpanContext, } from "./recording-tracer.js";
13
13
  export { wrapRedisClient } from "./redis-wrapper.js";
14
14
  export { DEFAULT_SENSITIVE_CONFIG, mergeSensitiveConfig, REDACTED, redactAttributes, redactHeaders, redactQueryString, redactValue, shouldRedactAttribute, } from "./sensitive-filter.js";
15
- export { emitDbQuery, emitDispatcherError, emitDispatcherHandler, emitEventConsumerLag, emitEventConsumerPassOutcome, emitEventConsumerPassSkipped, emitEventConsumerRearmExhausted, emitEventDispatcherListenConnected, emitHttpRequest, emitJobLastSuccess, emitJobQueueDepth, registerStandardMetrics, STANDARD_METRIC_DEFS, } from "./standard-metrics.js";
15
+ export { emitDbQuery, emitDispatcherError, emitDispatcherHandler, emitEscapeHatchUse, emitEventConsumerLag, emitEventConsumerPassOutcome, emitEventConsumerPassSkipped, emitEventConsumerRearmExhausted, emitEventDispatcherListenConnected, emitHttpRequest, emitJobLastSuccess, emitJobQueueDepth, registerStandardMetrics, STANDARD_METRIC_DEFS, } from "./standard-metrics.js";
16
16
  export type { Counter, Gauge, Histogram, Meter, MetricDefinition, MetricLabels, MetricsHandle, MetricType, ObservabilityOptions, ObservabilityProvider, SamplingConfig, SensitiveFilterConfig, SerializedTraceContext, Span, SpanAttributes, SpanAttributeValue, SpanKind, SpanStatus, StartSpanOptions, Tracer, } from "./types/index.js";
@@ -1,7 +1,7 @@
1
1
  // Public surface of the observability module.
2
2
  export { createConsoleProvider } from "./console-provider.js";
3
3
  export { observabilityContext } from "./context.js";
4
- export { createEscapeHatchReporter, createEscapeHatchReportWindow, ESCAPE_HATCH_USED_SIGNAL, fallbackEscapeHatchReporter, reportEscapeHatchUse, UNATTRIBUTED_ACTOR, } from "./escape-hatch-report.js";
4
+ export { createEscapeHatchProcessDedup, createEscapeHatchReporter, createEscapeHatchReportWindow, ESCAPE_HATCH_USED_SIGNAL, fallbackEscapeHatchReporter, reportEscapeHatchUse, UNATTRIBUTED_ACTOR, } from "./escape-hatch-report.js";
5
5
  export { getFallbackMeter, getFallbackProvider, getFallbackTracer } from "./fallback.js";
6
6
  export { generateSpanId, generateTraceId } from "./ids.js";
7
7
  export { buildMetricName, validateLabelKey, validateMetricName, } from "./metric-validator.js";
@@ -13,4 +13,4 @@ export { RecordingMeter, } from "./recording-meter.js";
13
13
  export { RecordingTracer, serializeSpanContext, } from "./recording-tracer.js";
14
14
  export { wrapRedisClient } from "./redis-wrapper.js";
15
15
  export { DEFAULT_SENSITIVE_CONFIG, mergeSensitiveConfig, REDACTED, redactAttributes, redactHeaders, redactQueryString, redactValue, shouldRedactAttribute, } from "./sensitive-filter.js";
16
- export { emitDbQuery, emitDispatcherError, emitDispatcherHandler, emitEventConsumerLag, emitEventConsumerPassOutcome, emitEventConsumerPassSkipped, emitEventConsumerRearmExhausted, emitEventDispatcherListenConnected, emitHttpRequest, emitJobLastSuccess, emitJobQueueDepth, registerStandardMetrics, STANDARD_METRIC_DEFS, } from "./standard-metrics.js";
16
+ export { emitDbQuery, emitDispatcherError, emitDispatcherHandler, emitEscapeHatchUse, emitEventConsumerLag, emitEventConsumerPassOutcome, emitEventConsumerPassSkipped, emitEventConsumerRearmExhausted, emitEventDispatcherListenConnected, emitHttpRequest, emitJobLastSuccess, emitJobQueueDepth, registerStandardMetrics, STANDARD_METRIC_DEFS, } from "./standard-metrics.js";
@@ -43,3 +43,4 @@ export declare function emitEventConsumerPassOutcome(meter: Meter, labels: {
43
43
  }, processed: number, failed: number): void;
44
44
  export declare function emitJobQueueDepth(meter: Meter, lane: string, counts: Readonly<Record<string, number>>): void;
45
45
  export declare function emitJobLastSuccess(meter: Meter, job: string): void;
46
+ export declare function emitEscapeHatchUse(meter: Meter, handler: string, kind: string): void;
@@ -140,6 +140,15 @@ export const STANDARD_METRIC_DEFS = [
140
140
  description: "Unix timestamp of the last successful run, per registered job.",
141
141
  labels: ["job"],
142
142
  },
143
+ // Every escape-hatch use counts here, including the ones the audit sink
144
+ // dedups away: a system cron firing every minute writes one audit event per
145
+ // process, so this counter is the only record of how often it ran.
146
+ {
147
+ name: "kumiko_escape_hatch_uses_total",
148
+ type: "counter",
149
+ description: "Escape-hatch uses by handler and kind, whether or not an audit event was written.",
150
+ labels: ["handler", "kind"],
151
+ },
143
152
  ];
144
153
  export function registerStandardMetrics(meter) {
145
154
  for (const def of STANDARD_METRIC_DEFS) {
@@ -251,3 +260,8 @@ export function emitJobQueueDepth(meter, lane, counts) {
251
260
  export function emitJobLastSuccess(meter, job) {
252
261
  meter.gauge("kumiko_job_last_success_timestamp_seconds").set(Date.now() / 1000, { job });
253
262
  }
263
+ // `handler` is a registry-declared handler/job name, `kind` a closed union —
264
+ // no tenant or user input reaches the label set.
265
+ export function emitEscapeHatchUse(meter, handler, kind) {
266
+ meter.counter("kumiko_escape_hatch_uses_total").inc(1, { handler, kind });
267
+ }
@@ -178,6 +178,7 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
178
178
  report: reportEscapeHatch,
179
179
  memberReadOnly: isMemberResolutionPrincipal(user),
180
180
  personalDataGate: buildPersonalDataGate(registry, origin),
181
+ projectionRegistry: registry,
181
182
  });
182
183
  // Propagate the request's AbortSignal so every TenantDb query throws when
183
184
  // the client has disconnected — handlers with many sequential queries skip
@@ -244,7 +245,7 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
244
245
  const targetSystemUser = createSystemUser(targetTenantId);
245
246
  return configFactory({
246
247
  user: { id: targetSystemUser.id, tenantId: targetTenantId },
247
- db: createTenantDb(dbSource, targetTenantId, "tenant", context.tracer, context.meter),
248
+ db: createTenantDb(dbSource, targetTenantId, "tenant", context.tracer, context.meter, undefined, { projectionRegistry: registry }),
248
249
  secrets: context.secrets,
249
250
  });
250
251
  }
@@ -983,6 +984,7 @@ function buildAuthClaimsContext(ctx, user) {
983
984
  log: context.log,
984
985
  window: ctx.escapeHatchReportWindow,
985
986
  }),
987
+ projectionRegistry: ctx.registry,
986
988
  });
987
989
  const configAccessor = context._configAccessorFactory
988
990
  ? context._configAccessorFactory({
@@ -4,6 +4,7 @@ export type PruneEventsOptions = {
4
4
  readonly olderThan?: Temporal.Instant;
5
5
  readonly olderThanDays?: number;
6
6
  readonly aggregateTypes: readonly string[];
7
+ readonly aggregateIds?: readonly string[];
7
8
  readonly dryRun?: boolean;
8
9
  };
9
10
  export type PruneEventsResult = {
@@ -1,3 +1,4 @@
1
+ /// <reference types="temporal-polyfill/global" preserve="true" />
1
2
  import { lockEventConsumersShareMode } from "../db/queries/event-consumer.js";
2
3
  import { deleteMany, selectMany, transaction } from "../db/query.js";
3
4
  import { eventsTable } from "../event-store/index.js";
@@ -30,7 +31,11 @@ export async function pruneEvents(db, options) {
30
31
  if (!options.aggregateTypes || options.aggregateTypes.length === 0) {
31
32
  throw new Error("pruneEvents: aggregateTypes is required and must be non-empty. Pruning the event log is destructive — name the aggregate types to delete explicitly.");
32
33
  }
34
+ if (options.aggregateIds !== undefined && options.aggregateIds.length === 0) {
35
+ throw new Error("pruneEvents: aggregateIds must be non-empty when set.");
36
+ }
33
37
  const aggregateTypes = options.aggregateTypes;
38
+ const aggregateIds = options.aggregateIds;
34
39
  const dryRun = options.dryRun === true;
35
40
  return transaction(db, async (tx) => {
36
41
  // Serialise against consumer-bootstrap INSERTs. Without this, the race
@@ -49,6 +54,7 @@ export async function pruneEvents(db, options) {
49
54
  // Step 1 — collect candidate event ids.
50
55
  const candidates = await selectMany(tx, eventsTable, {
51
56
  aggregateType: [...aggregateTypes],
57
+ ...(aggregateIds ? { aggregateId: [...aggregateIds] } : {}),
52
58
  createdAt: { lt: cutoff },
53
59
  });
54
60
  if (candidates.length === 0) {
@@ -5,6 +5,8 @@ export { resolveAuthClaims } from "./auth-claims-resolver.js";
5
5
  export { createCascadeDeleteHook } from "./cascade-handler.js";
6
6
  export type { Dispatcher } from "./dispatcher.js";
7
7
  export { createDispatcher, dispatcherToWriteRef } from "./dispatcher.js";
8
+ export type { FailedWriteResult } from "./dispatcher-utils.js";
9
+ export { isFailedWriteResult } from "./dispatcher-utils.js";
8
10
  export type { DistributedLock } from "./distributed-lock.js";
9
11
  export { createDistributedLock } from "./distributed-lock.js";
10
12
  export type { EntityCache, EntityCacheOptions } from "./entity-cache.js";
@@ -2,6 +2,7 @@ export { createEscapeHatchReporter, createEscapeHatchReportWindow, ESCAPE_HATCH_
2
2
  export { resolveAuthClaims } from "./auth-claims-resolver.js";
3
3
  export { createCascadeDeleteHook } from "./cascade-handler.js";
4
4
  export { createDispatcher, dispatcherToWriteRef } from "./dispatcher.js";
5
+ export { isFailedWriteResult } from "./dispatcher-utils.js";
5
6
  export { createDistributedLock } from "./distributed-lock.js";
6
7
  export { createEntityCache } from "./entity-cache.js";
7
8
  export { ConsumerStatuses, createEventConsumerStateTable, eventConsumerStateTable, SHARED_INSTANCE_SENTINEL, } from "./event-consumer-state.js";
@@ -2,4 +2,5 @@ import type { DbRunner } from "../db/index.js";
2
2
  import type { LifecycleResult, Registry } from "../engine/types/index.js";
3
3
  import type { StoredEvent } from "../event-store/index.js";
4
4
  export declare function runProjections(result: LifecycleResult, registry: Registry, runner: DbRunner | undefined): Promise<void>;
5
+ export declare function hasLiveProjectionsForEvent(event: StoredEvent, registry: Registry): boolean;
5
6
  export declare function runProjectionsForEvent(event: StoredEvent, registry: Registry, tx: DbRunner): Promise<void>;
@@ -36,22 +36,31 @@ export async function runProjections(result, registry, runner) {
36
36
  // CRUD path (via runProjections) and the ctx.appendEvent path (domain events
37
37
  // emitted inside a write handler). Keeping one function means an auto-event
38
38
  // and a r.defineEvent-event land in the same inline-projection pipeline.
39
+ // Events whose projections already ran. The EventStoreExecutor projects right
40
+ // after its write and hands the same object back on the LifecycleResult, so the
41
+ // dispatcher's later runProjections() and manual calls become no-ops instead of
42
+ // double-applying. Marked only after a successful run: a rolled-back write is
43
+ // retried with a fresh event object, so a thrown projection never blocks a retry.
44
+ const projectedEvents = new WeakSet();
45
+ // Implicit projections exist only for rebuildProjection: the EventStoreExecutor
46
+ // already writes their table on the live path, so applying them live would write
47
+ // twice and hit a unique key violation.
48
+ function liveProjectionsForEvent(event, registry) {
49
+ return registry
50
+ .getProjectionsForSource(event.aggregateType)
51
+ .filter((proj) => !proj.isImplicit && proj.apply[event.type] !== undefined);
52
+ }
53
+ export function hasLiveProjectionsForEvent(event, registry) {
54
+ return liveProjectionsForEvent(event, registry).length > 0;
55
+ }
39
56
  export async function runProjectionsForEvent(event, registry, tx) {
40
- const projections = registry.getProjectionsForSource(event.aggregateType);
41
- // skip: no projection feeds off this entity — fast path for the common case
42
- if (projections.length === 0)
57
+ // skip: this event object was already projected in this write
58
+ if (projectedEvents.has(event))
43
59
  return;
44
- for (const proj of projections) {
45
- // ImplicitProjections existieren nur für rebuildProjection — der
46
- // EventStoreExecutor schreibt im Live-Pfad bereits direkt in die
47
- // Tabelle. Live-Apply der Implicit würde doppelt schreiben → unique
48
- // key violation. Filter ist Pflicht.
49
- if (proj.isImplicit)
50
- continue;
60
+ for (const proj of liveProjectionsForEvent(event, registry)) {
51
61
  const applyFn = proj.apply[event.type];
52
- // skip: this projection doesn't care about this event type
53
- if (!applyFn)
54
- continue;
55
- await applyFn(event, tx, proj.table);
62
+ if (applyFn)
63
+ await applyFn(event, tx, proj.table);
56
64
  }
65
+ projectedEvents.add(event);
57
66
  }
@@ -128,5 +128,5 @@ function respondRateLimited(c, decision, bucket) {
128
128
  c.header("Retry-After", String(Math.max(1, decision.retryAfterSeconds)));
129
129
  setRateLimitHeaders(c, decision);
130
130
  const reqId = requestContext.get()?.requestId;
131
- return c.json(serializeError(err, reqId), 429);
131
+ return c.json(serializeError(err, reqId, requestContext.get()?.errorDocs), 429);
132
132
  }
@@ -4,15 +4,17 @@ export type SchemaCliOut = {
4
4
  readonly err: (line: string) => void;
5
5
  };
6
6
  export type RunSchemaCliOptions = {
7
+ readonly features?: undefined;
8
+ readonly kmsSlots?: undefined;
9
+ } | {
7
10
  /** Composed app features. When given, `apply` rebuilds the projections whose
8
11
  * tables a freshly applied migration changed (via its `.rebuild.json`
9
12
  * marker). Omitted (dev `kumiko schema`) → no rebuild, migrations only. */
10
- readonly features?: readonly FeatureDefinition[];
11
- /** Schema-declared Key Manager slots, typically `kmsSlotsOf(composedEnv.schema)`.
12
- * This CLI parses no env schema, so `apply`'s KMS wiring cannot derive them
13
- * itself; omitted, `resolvePlatformKeks` (inside `resolveKmsWiringAsync`)
14
- * falls back to its own default. */
15
- readonly kmsSlots?: readonly string[];
13
+ readonly features: readonly FeatureDefinition[];
14
+ /** Schema-declared Key Manager slots, `kmsSlotsOf(composedEnv.schema)`, or
15
+ * `[]` when the app wires none. This CLI parses no env schema, so the
16
+ * rebuild's KMS wiring cannot derive them itself. */
17
+ readonly kmsSlots: readonly string[];
16
18
  };
17
19
  /**
18
20
  * Runs a schema-CLI subcommand. `appCwd` is the app workspace root (where