@cosmicdrift/kumiko-framework 0.384.0 → 0.386.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 (78) hide show
  1. package/agents/examples/i18n.md +36 -1
  2. package/agents/features.md +4 -0
  3. package/dist/api/auth-rate-limiters.d.ts +8 -0
  4. package/dist/api/auth-rate-limiters.js +105 -0
  5. package/dist/api/auth-routes-context.d.ts +34 -0
  6. package/dist/api/auth-routes-context.js +192 -0
  7. package/dist/api/auth-routes-invite.d.ts +2 -0
  8. package/dist/api/auth-routes-invite.js +171 -0
  9. package/dist/api/auth-routes-login.d.ts +2 -0
  10. package/dist/api/auth-routes-login.js +103 -0
  11. package/dist/api/auth-routes-mfa.d.ts +4 -0
  12. package/dist/api/auth-routes-mfa.js +211 -0
  13. package/dist/api/auth-routes-session.d.ts +2 -0
  14. package/dist/api/auth-routes-session.js +144 -0
  15. package/dist/api/auth-routes-shared.d.ts +33 -0
  16. package/dist/api/auth-routes-shared.js +81 -0
  17. package/dist/api/auth-routes-signup.d.ts +2 -0
  18. package/dist/api/auth-routes-signup.js +75 -0
  19. package/dist/api/auth-routes-token-flows.d.ts +4 -0
  20. package/dist/api/auth-routes-token-flows.js +91 -0
  21. package/dist/api/auth-routes-types.d.ts +76 -0
  22. package/dist/api/auth-routes-types.js +1 -0
  23. package/dist/api/auth-routes.d.ts +5 -85
  24. package/dist/api/auth-routes.js +20 -1095
  25. package/dist/api/request-context.d.ts +2 -0
  26. package/dist/api/request-context.js +16 -7
  27. package/dist/config/index.d.ts +1 -1
  28. package/dist/crypto/pii-ciphertext-format.js +1 -1
  29. package/dist/db/blind-index-cleanup.js +1 -1
  30. package/dist/db/cross-tenant-reads.d.ts +8 -0
  31. package/dist/db/cross-tenant-reads.js +23 -0
  32. package/dist/db/dialect.js +1 -1
  33. package/dist/db/distinct-on.d.ts +9 -0
  34. package/dist/db/distinct-on.js +28 -0
  35. package/dist/db/event-store-executor-write.js +2 -2
  36. package/dist/db/migrate-runner.js +4 -4
  37. package/dist/db/pg-error.js +0 -12
  38. package/dist/db/queries/event-consumer.js +3 -3
  39. package/dist/db/queries/postgres-array-literal.js +2 -2
  40. package/dist/db/queries/test-stack.d.ts +4 -1
  41. package/dist/db/queries/test-stack.js +15 -2
  42. package/dist/db/query.js +19 -19
  43. package/dist/db/tenant-db.js +1 -1
  44. package/dist/engine/boot-validator/i18n-keys.d.ts +1 -0
  45. package/dist/engine/boot-validator/i18n-keys.js +8 -1
  46. package/dist/engine/boot-validator/index.d.ts +4 -0
  47. package/dist/engine/boot-validator/index.js +2 -1
  48. package/dist/engine/boot-validator/screens-actions.d.ts +67 -0
  49. package/dist/engine/boot-validator/screens-actions.js +488 -0
  50. package/dist/engine/boot-validator/screens-dashboard.d.ts +15 -0
  51. package/dist/engine/boot-validator/screens-dashboard.js +467 -0
  52. package/dist/engine/boot-validator/screens-forms.d.ts +24 -0
  53. package/dist/engine/boot-validator/screens-forms.js +516 -0
  54. package/dist/engine/boot-validator/screens-lists.d.ts +47 -0
  55. package/dist/engine/boot-validator/screens-lists.js +319 -0
  56. package/dist/engine/boot-validator/screens.d.ts +4 -21
  57. package/dist/engine/boot-validator/screens.js +26 -1799
  58. package/dist/engine/feature-ast/pattern-change-schema.d.ts +8 -0
  59. package/dist/engine/steps/read-find-one.js +1 -1
  60. package/dist/event-store/provenance-append.js +1 -1
  61. package/dist/files/file-routes.js +1 -1
  62. package/dist/i18n/required-surface-keys.d.ts +16 -0
  63. package/dist/i18n/required-surface-keys.js +62 -0
  64. package/dist/internal/stack-support.d.ts +1 -1
  65. package/dist/internal/stack-support.js +1 -1
  66. package/dist/jobs/job-runner-helpers.d.ts +44 -0
  67. package/dist/jobs/job-runner-helpers.js +265 -0
  68. package/dist/jobs/job-runner-schedulers.d.ts +23 -0
  69. package/dist/jobs/job-runner-schedulers.js +106 -0
  70. package/dist/jobs/job-runner-types.d.ts +65 -0
  71. package/dist/jobs/job-runner-types.js +2 -0
  72. package/dist/jobs/job-runner.d.ts +4 -75
  73. package/dist/jobs/job-runner.js +10 -386
  74. package/dist/pipeline/dispatch-shared.js +29 -4
  75. package/dist/pipeline/event-dispatcher.js +2 -3
  76. package/dist/schema-cli.js +5 -0
  77. package/package.json +3 -3
  78. package/src/changes.json +19 -0
@@ -233,6 +233,7 @@ export declare const PATTERN_SCHEMAS_BY_KIND: {
233
233
  escapeHatch: z.ZodOptional<z.ZodUnion<readonly [z.ZodObject<{
234
234
  reason: z.ZodString;
235
235
  grants: z.ZodOptional<z.ZodArray<z.ZodEnum<{
236
+ crossTenantReads: "crossTenantReads";
236
237
  globalWrites: "globalWrites";
237
238
  systemIdentity: "systemIdentity";
238
239
  unsafeRaw: "unsafeRaw";
@@ -314,6 +315,7 @@ export declare const PATTERN_SCHEMAS_BY_KIND: {
314
315
  escapeHatch: z.ZodOptional<z.ZodUnion<readonly [z.ZodObject<{
315
316
  reason: z.ZodString;
316
317
  grants: z.ZodOptional<z.ZodArray<z.ZodEnum<{
318
+ crossTenantReads: "crossTenantReads";
317
319
  globalWrites: "globalWrites";
318
320
  systemIdentity: "systemIdentity";
319
321
  unsafeRaw: "unsafeRaw";
@@ -384,6 +386,7 @@ export declare const PATTERN_SCHEMAS_BY_KIND: {
384
386
  escapeHatch: z.ZodOptional<z.ZodUnion<readonly [z.ZodObject<{
385
387
  reason: z.ZodString;
386
388
  grants: z.ZodOptional<z.ZodArray<z.ZodEnum<{
389
+ crossTenantReads: "crossTenantReads";
387
390
  globalWrites: "globalWrites";
388
391
  systemIdentity: "systemIdentity";
389
392
  unsafeRaw: "unsafeRaw";
@@ -420,6 +423,7 @@ export declare const PATTERN_SCHEMAS_BY_KIND: {
420
423
  escapeHatch: z.ZodOptional<z.ZodUnion<readonly [z.ZodObject<{
421
424
  reason: z.ZodString;
422
425
  grants: z.ZodOptional<z.ZodArray<z.ZodEnum<{
426
+ crossTenantReads: "crossTenantReads";
423
427
  globalWrites: "globalWrites";
424
428
  systemIdentity: "systemIdentity";
425
429
  unsafeRaw: "unsafeRaw";
@@ -828,6 +832,7 @@ export declare const patternSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
828
832
  escapeHatch: z.ZodOptional<z.ZodUnion<readonly [z.ZodObject<{
829
833
  reason: z.ZodString;
830
834
  grants: z.ZodOptional<z.ZodArray<z.ZodEnum<{
835
+ crossTenantReads: "crossTenantReads";
831
836
  globalWrites: "globalWrites";
832
837
  systemIdentity: "systemIdentity";
833
838
  unsafeRaw: "unsafeRaw";
@@ -908,6 +913,7 @@ export declare const patternSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
908
913
  escapeHatch: z.ZodOptional<z.ZodUnion<readonly [z.ZodObject<{
909
914
  reason: z.ZodString;
910
915
  grants: z.ZodOptional<z.ZodArray<z.ZodEnum<{
916
+ crossTenantReads: "crossTenantReads";
911
917
  globalWrites: "globalWrites";
912
918
  systemIdentity: "systemIdentity";
913
919
  unsafeRaw: "unsafeRaw";
@@ -977,6 +983,7 @@ export declare const patternSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
977
983
  escapeHatch: z.ZodOptional<z.ZodUnion<readonly [z.ZodObject<{
978
984
  reason: z.ZodString;
979
985
  grants: z.ZodOptional<z.ZodArray<z.ZodEnum<{
986
+ crossTenantReads: "crossTenantReads";
980
987
  globalWrites: "globalWrites";
981
988
  systemIdentity: "systemIdentity";
982
989
  unsafeRaw: "unsafeRaw";
@@ -1012,6 +1019,7 @@ export declare const patternSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
1012
1019
  escapeHatch: z.ZodOptional<z.ZodUnion<readonly [z.ZodObject<{
1013
1020
  reason: z.ZodString;
1014
1021
  grants: z.ZodOptional<z.ZodArray<z.ZodEnum<{
1022
+ crossTenantReads: "crossTenantReads";
1015
1023
  globalWrites: "globalWrites";
1016
1024
  systemIdentity: "systemIdentity";
1017
1025
  unsafeRaw: "unsafeRaw";
@@ -1,6 +1,6 @@
1
1
  // r.step.read.findOne — load a single row from a projection table.
2
2
  //
3
- // Thin wrapper on selectMany(db, table, where, { limit: 1 }) (bun-db).
3
+ // Thin wrapper on selectMany(db, table, where, { limit: 1 }) (db/query.ts).
4
4
  // Resolves to the first row or null. Tenant-filtered like ctx.db
5
5
  // method-form reads (own tenant + SYSTEM_TENANT_ID reference rows); a
6
6
  // foreign `where.tenantId` is narrowed to the caller's own scope.
@@ -32,7 +32,7 @@ export async function appendProvenanceEvent(db, event) {
32
32
  message: `appendProvenanceEvent: event tenant "${event.tenantId}" does not match the TenantDb's tenant "${db.tenantId}".`,
33
33
  });
34
34
  }
35
- // Bun.SQL/postgres.js abort the whole surrounding begin() on a statement
35
+ // postgres-js aborts the whole surrounding begin() on a statement
36
36
  // error (25P02) even when the caller's catch swallows it — the savepoint
37
37
  // confines that to this scope. Pool connections have no ambient tx to poison.
38
38
  await runInSavepointIfSupported(runner, async (sp) => {
@@ -447,7 +447,7 @@ export function createFileRoutes(options) {
447
447
  });
448
448
  async function loadFileForTenant(id, tenantId) {
449
449
  // fileRefsTable.id is a UUID column — a malformed id must fail here, not
450
- // at Postgres (22P02 poisons the pooled Bun.SQL connection). 404, not
450
+ // at Postgres (22P02 poisons the pooled connection). 404, not
451
451
  // 400: an authenticated caller sending a bad id must see the same
452
452
  // response as a nonexistent one, no existence-oracle.
453
453
  if (!isUuid(id))
@@ -37,3 +37,19 @@ export type TranslationLocaleGap = {
37
37
  * consistent by definition (locale packages, en-only framework features).
38
38
  */
39
39
  export declare function findTranslationLocaleGaps(features: readonly FeatureDefinition[]): readonly TranslationLocaleGap[];
40
+ export type RequiredLocaleGap = {
41
+ readonly featureName: string;
42
+ readonly key: string;
43
+ readonly missingLocales: readonly string[];
44
+ };
45
+ /**
46
+ * Per translation key, the union of locales over ALL features must cover
47
+ * `requiredLocales`. Locale packages translate other features' keys under the
48
+ * full `feature:key` name, so the lookup unions by effective name. Raw names
49
+ * only come from locale packages and already-prefixed keys. Entries that only translate another
50
+ * mounted feature's key are not reported separately; the defining feature is.
51
+ * Locale packages (`locale-*`) only contribute locales: their keys often belong to
52
+ * client bundles (e.g. `auth.errors.*`) that no feature defines, so they are never
53
+ * reported as gap owners.
54
+ */
55
+ export declare function findRequiredLocaleGaps(features: readonly FeatureDefinition[], requiredLocales: readonly string[]): readonly RequiredLocaleGap[];
@@ -549,3 +549,65 @@ export function findTranslationLocaleGaps(features) {
549
549
  }
550
550
  return gaps;
551
551
  }
552
+ const LOCALE_PACKAGE_PREFIX = "locale-";
553
+ function collectLocalesByName(features) {
554
+ // A plain key of a regular feature is only its own; registering it raw would let two
555
+ // features with the same plain key cover each other's missing locales.
556
+ const namesOf = (featureName, key) => featureName.startsWith(LOCALE_PACKAGE_PREFIX) || key.includes(":")
557
+ ? [`${featureName}:${key}`, key]
558
+ : [`${featureName}:${key}`];
559
+ const localesByName = new Map();
560
+ for (const feature of features) {
561
+ for (const [key, byLocale] of Object.entries(feature.translations ?? {})) {
562
+ const present = Object.entries(byLocale)
563
+ .filter(([, value]) => hasLocaleValue(value))
564
+ .map(([locale]) => locale);
565
+ for (const name of namesOf(feature.name, key)) {
566
+ const locales = localesByName.get(name) ?? new Set();
567
+ for (const locale of present)
568
+ locales.add(locale);
569
+ localesByName.set(name, locales);
570
+ }
571
+ }
572
+ }
573
+ return localesByName;
574
+ }
575
+ /**
576
+ * Per translation key, the union of locales over ALL features must cover
577
+ * `requiredLocales`. Locale packages translate other features' keys under the
578
+ * full `feature:key` name, so the lookup unions by effective name. Raw names
579
+ * only come from locale packages and already-prefixed keys. Entries that only translate another
580
+ * mounted feature's key are not reported separately; the defining feature is.
581
+ * Locale packages (`locale-*`) only contribute locales: their keys often belong to
582
+ * client bundles (e.g. `auth.errors.*`) that no feature defines, so they are never
583
+ * reported as gap owners.
584
+ */
585
+ export function findRequiredLocaleGaps(features, requiredLocales) {
586
+ const featureNames = new Set(features.map((feature) => feature.name));
587
+ const localesByName = collectLocalesByName(features);
588
+ const gaps = [];
589
+ const reported = new Set();
590
+ for (const feature of features) {
591
+ if (feature.name.startsWith(LOCALE_PACKAGE_PREFIX))
592
+ continue;
593
+ for (const key of Object.keys(feature.translations ?? {})) {
594
+ const prefix = key.includes(":") ? key.slice(0, key.indexOf(":")) : undefined;
595
+ const translatesOtherFeature = prefix !== undefined && prefix !== feature.name && featureNames.has(prefix);
596
+ if (translatesOtherFeature)
597
+ continue;
598
+ const effective = key.includes(":") ? key : `${feature.name}:${key}`;
599
+ if (reported.has(effective))
600
+ continue;
601
+ // Locale packages spell plurals as flat `.one`/`.other` keys; `other` is the form every locale has.
602
+ const present = new Set([effective, key, `${effective}.other`, `${key}.other`].flatMap((name) => [
603
+ ...(localesByName.get(name) ?? []),
604
+ ]));
605
+ const missingLocales = requiredLocales.filter((locale) => !present.has(locale));
606
+ if (missingLocales.length > 0) {
607
+ reported.add(effective);
608
+ gaps.push({ featureName: feature.name, key, missingLocales });
609
+ }
610
+ }
611
+ }
612
+ return gaps;
613
+ }
@@ -1,5 +1,5 @@
1
1
  export { createDbConnection } from "../db/connection.ts";
2
- export { createDatabase, databaseExists, dropDatabaseIfExists, truncateTablesRestartIdentity, } from "../db/queries/test-stack.ts";
2
+ export { acquireSessionAdvisoryLock, createDatabase, databaseExists, dropDatabaseIfExists, listDatabaseNamesWithPrefix, renameDatabase, truncateTablesRestartIdentity, } from "../db/queries/test-stack.ts";
3
3
  export { type AnyDb, isClosedConnectionError } from "../db/query.ts";
4
4
  export { columnNamesOf } from "../db/schema-inspection.ts";
5
5
  export { schemaTableName } from "../db/table-introspection.ts";
@@ -1,6 +1,6 @@
1
1
  // @runtime dev
2
2
  export { createDbConnection } from "../db/connection.js";
3
- export { createDatabase, databaseExists, dropDatabaseIfExists, truncateTablesRestartIdentity, } from "../db/queries/test-stack.js";
3
+ export { acquireSessionAdvisoryLock, createDatabase, databaseExists, dropDatabaseIfExists, listDatabaseNamesWithPrefix, renameDatabase, truncateTablesRestartIdentity, } from "../db/queries/test-stack.js";
4
4
  export { isClosedConnectionError } from "../db/query.js";
5
5
  export { columnNamesOf } from "../db/schema-inspection.js";
6
6
  export { schemaTableName } from "../db/table-introspection.js";
@@ -0,0 +1,44 @@
1
+ /// <reference types="temporal-polyfill/global" preserve="true" />
2
+ import type { ConfigAccessor } from "@cosmicdrift/kumiko-types/config";
3
+ import type { TzContext } from "@cosmicdrift/kumiko-types/tz-context";
4
+ import type { JobsOptions, Queue } from "bullmq";
5
+ import type { TenantDb } from "../db/tenant-db.ts";
6
+ import type { AppContext, JobDefinition, JobRunIn, JobTriggerEvent } from "../engine/types/index.ts";
7
+ import type { Logger } from "../logging/types.ts";
8
+ import type { Meter, SerializedTraceContext, Tracer } from "../observability/index.ts";
9
+ import type { JobLogEntry, JobSubjectValue } from "./job-runner-types.ts";
10
+ export declare function isJobTriggerEvent(value: unknown): value is JobTriggerEvent;
11
+ export declare function stampGatedWriteOrigin(data: Record<string, unknown>): void;
12
+ export declare function createJobLogger(logs: JobLogEntry[], baseContext?: Record<string, unknown>): Logger;
13
+ export declare function serializeJobSubject(subject: Readonly<Record<string, JobSubjectValue>>): string;
14
+ export declare function jobSubjectKey(jobName: string, payload: Record<string, unknown>, fields: readonly string[] | undefined, declaration: "tenantVisibleFailure" | "tenantVisibleRun"): string | null;
15
+ export declare function resolveJobTz(config: ConfigAccessor | undefined, context: Pick<AppContext, "geoTzProvider" | "clock">): Promise<TzContext>;
16
+ export declare function missingDbProxy(jobName: string): TenantDb;
17
+ export declare function priorAttemptsOf(data: Record<string, unknown>): number;
18
+ export declare function tenantFailureMessageKey(err: unknown, declaration: JobDefinition["tenantVisibleFailure"]): string | null;
19
+ export declare const TRACE_CONTEXT_KEY = "_traceContext";
20
+ export declare const DEFER_COUNT_KEY = "_deferCount";
21
+ export declare const HANDLER_DEFER_COUNT_KEY = "_handlerDeferCount";
22
+ export declare const DEFAULT_MAX_DEFERS = 100;
23
+ export declare const DEFER_DELAY_CAP_MS = 30000;
24
+ export declare const DEFER_BACKOFF_MAX_DOUBLINGS = 5;
25
+ export declare function readTraceContext(data: Record<string, unknown>): SerializedTraceContext | undefined;
26
+ export declare function captureTraceContext(tracer: Tracer): SerializedTraceContext | undefined;
27
+ export declare function startQueueDepthPolling(queue: Queue, lane: JobRunIn, meter: Meter): Promise<ReturnType<typeof setInterval>>;
28
+ export declare function parseRedisOpts(url: string): {
29
+ host: string;
30
+ port: number;
31
+ db?: number | undefined;
32
+ };
33
+ export declare const BOOT_REDIS_TIMEOUT_MS = 10000;
34
+ export declare const STOP_QUEUE_READY_TIMEOUT_MS = 1000;
35
+ export declare const BOOT_GATE_SLOW_WARN_MS = 60000;
36
+ export declare function timeoutReject(ms: number, message: string): {
37
+ promise: Promise<never>;
38
+ cancel: () => void;
39
+ };
40
+ export declare function buildRetryBullOpts(jobDef: JobDefinition, priorAttempts?: number): Pick<JobsOptions, "attempts" | "backoff">;
41
+ export declare function payloadMatchesTriggerFilter(where: Readonly<Record<string, string | number | boolean>> | undefined, payload: Record<string, unknown>): boolean;
42
+ export declare const COMPLETED_JOB_RETENTION_AGE_SEC = 86400;
43
+ export declare const FAILED_JOB_RETENTION_AGE_SEC = 604800;
44
+ export declare function maxRetryWindowMs(jobDef: JobDefinition): number;
@@ -0,0 +1,265 @@
1
+ /// <reference types="temporal-polyfill/global" preserve="true" />
2
+ import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
3
+ import { requestContext } from "../api/request-context.js";
4
+ import { InternalError } from "../errors/index.js";
5
+ import { isKumikoError } from "../errors/kumiko-error.js";
6
+ import { createFallbackLogger, scrubLogText } from "../logging/index.js";
7
+ import { emitJobQueueDepth } from "../observability/index.js";
8
+ import { TENANT_TIMEZONE_CONFIG_KEY } from "../pipeline/tenant-timezone-config-key.js";
9
+ import { isPersonalDataGated } from "../pipeline/write-origin.js";
10
+ import { createTzContext, isValidIanaTimeZone } from "../time/index.js";
11
+ // Shape-checked after the BullMQ/Redis JSON round-trip. Unlike `_writeOrigin`
12
+ // (a security-relevant escape hatch that must fail the run closed), a
13
+ // malformed `_triggerEvent` only costs an idempotency-key convenience — the
14
+ // run proceeds without it rather than failing over what would be a framework
15
+ // bug, not untrusted input.
16
+ export function isJobTriggerEvent(value) {
17
+ if (!isPlainObject(value))
18
+ return false;
19
+ if (typeof value["id"] !== "string")
20
+ return false;
21
+ const headers = value["headers"];
22
+ if (typeof headers !== "object" || headers === null)
23
+ return false;
24
+ return Object.values(headers).every((v) => typeof v === "string" || typeof v === "number" || typeof v === "boolean");
25
+ }
26
+ // A payload's own `_writeOrigin` is never trusted; only the ambient gated origin is stamped.
27
+ export function stampGatedWriteOrigin(data) {
28
+ delete data["_writeOrigin"];
29
+ const origin = requestContext.get()?.writeOrigin;
30
+ if (origin && isPersonalDataGated(origin)) {
31
+ data["_writeOrigin"] = origin;
32
+ }
33
+ }
34
+ export function createJobLogger(logs, baseContext = {}) {
35
+ function push(level, msg, data) {
36
+ const merged = { ...baseContext, ...data };
37
+ const message = Object.keys(merged).length > 0 ? `${msg} ${JSON.stringify(merged)}` : msg;
38
+ logs.push({ level, message, timestamp: Temporal.Now.instant() });
39
+ }
40
+ const logger = {
41
+ info(msg, data) {
42
+ push("info", msg, data);
43
+ },
44
+ warn(msg, data) {
45
+ push("warn", msg, data);
46
+ },
47
+ error(msg, data) {
48
+ push("error", msg, data);
49
+ },
50
+ // No-op by design, not missing coverage: JobLogEntry.level (and the
51
+ // persisted run-completed/-failed event schema in bundled-features/
52
+ // jobs/events.ts) only has info|warn|error — a job's debug() calls have
53
+ // nowhere durable to land.
54
+ debug() { },
55
+ child(context) {
56
+ return createJobLogger(logs, { ...baseContext, ...context });
57
+ },
58
+ };
59
+ return logger;
60
+ }
61
+ // Single source of the stored subject string: the writer (jobSubjectKey) and
62
+ // the tenant failures query both go through it. Matching is exact — field names
63
+ // and JSON types must equal what the writer stored (`"1"` is not `1`).
64
+ export function serializeJobSubject(subject) {
65
+ return JSON.stringify(Object.keys(subject)
66
+ .sort()
67
+ .map((field) => [field, subject[field] ?? null]));
68
+ }
69
+ // Stable identity for the declared payload fields: sorted keys, so two runs
70
+ // with the same subject values produce the same string and the later one
71
+ // replaces the earlier record. Non-primitives would serialize into something
72
+ // no caller can reconstruct for a lookup, so they fail the run loudly.
73
+ export function jobSubjectKey(jobName, payload, fields, declaration) {
74
+ if (fields === undefined || fields.length === 0)
75
+ return null;
76
+ const subject = {};
77
+ for (const field of fields) {
78
+ const value = payload[field] ?? null;
79
+ if (value !== null && typeof value === "object") {
80
+ throw new Error(`Job "${jobName}": ${declaration}.subjectFields["${field}"] must be a primitive, got ${typeof value}`);
81
+ }
82
+ subject[field] = value;
83
+ }
84
+ return serializeJobSubject(subject);
85
+ }
86
+ export async function resolveJobTz(config, context) {
87
+ const tenantTz = config ? await config(TENANT_TIMEZONE_CONFIG_KEY) : undefined;
88
+ return createTzContext({
89
+ ...(context.geoTzProvider !== undefined && { geoTz: context.geoTzProvider }),
90
+ ...(context.clock !== undefined && { clock: context.clock }),
91
+ tenant: typeof tenantTz === "string" && isValidIanaTimeZone(tenantTz) ? tenantTz : "UTC",
92
+ });
93
+ }
94
+ // Jobs that never touch ctx.db run fine on a runner built without one; a job
95
+ // that does gets a named error instead of a bare TypeError on undefined.
96
+ export function missingDbProxy(jobName) {
97
+ // @cast-boundary db-operator — every access throws, so no TenantDb member is ever returned
98
+ return new Proxy({}, {
99
+ get() {
100
+ throw new InternalError({
101
+ message: `[jobs] job runner constructed without a db connection — ctx.db is unavailable for "${jobName}"`,
102
+ });
103
+ },
104
+ });
105
+ }
106
+ export function priorAttemptsOf(data) {
107
+ const value = data["_priorAttempts"];
108
+ return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : 0;
109
+ }
110
+ // Only a translation key ever travels to the tenant: the thrown error's own
111
+ // i18nKey when it carries one, otherwise the key declared at the job. The
112
+ // error message itself can echo provider or payload content and stays on the
113
+ // run row and in the run log.
114
+ export function tenantFailureMessageKey(err, declaration) {
115
+ // skip: job did not opt in — nothing tenant-visible to record
116
+ if (!declaration)
117
+ return null;
118
+ if (isKumikoError(err) && err.i18nKey.length > 0)
119
+ return err.i18nKey;
120
+ return declaration.messageKey;
121
+ }
122
+ // Serialized trace context lives under this key in the BullMQ job data.
123
+ // Leading underscore matches the existing internal-meta convention
124
+ // (_triggeredById, _tenantId, _payload).
125
+ export const TRACE_CONTEXT_KEY = "_traceContext";
126
+ export const DEFER_COUNT_KEY = "_deferCount";
127
+ // Separate from the maxPerTenant counter so tenant-limit deferrals do not eat into maxDefers.
128
+ export const HANDLER_DEFER_COUNT_KEY = "_handlerDeferCount";
129
+ export const DEFAULT_MAX_DEFERS = 100;
130
+ export const DEFER_DELAY_CAP_MS = 30_000;
131
+ export const DEFER_BACKOFF_MAX_DOUBLINGS = 5;
132
+ export function readTraceContext(data) {
133
+ const raw = data[TRACE_CONTEXT_KEY];
134
+ if (!raw || typeof raw !== "object")
135
+ return undefined;
136
+ const ctx = raw; // @cast-boundary engine-payload
137
+ if (!ctx.traceId || !ctx.spanId)
138
+ return undefined;
139
+ return { traceId: ctx.traceId, spanId: ctx.spanId };
140
+ }
141
+ export function captureTraceContext(tracer) {
142
+ const span = tracer.getActiveSpan();
143
+ if (!span?.traceId)
144
+ return undefined;
145
+ return { traceId: span.traceId, spanId: span.spanId };
146
+ }
147
+ const QUEUE_DEPTH_POLL_INTERVAL_MS = 15_000;
148
+ const QUEUE_DEPTH_WARN_INTERVAL_MS = 5 * 60_000;
149
+ // kumiko_job_queue_depth: only the lane's own consumer polls — the count is
150
+ // a global Redis-backed value (BullMQ, not per-process), so one reporter per
151
+ // lane is enough. Fires once immediately (metrics exist before the first
152
+ // poll tick) then on an interval; the caller stores + clears the handle.
153
+ export async function startQueueDepthPolling(queue, lane, meter) {
154
+ let lastWarnAt;
155
+ const pollQueueDepth = async () => {
156
+ try {
157
+ const counts = await queue.getJobCounts("waiting", "active", "delayed", "failed", "paused");
158
+ emitJobQueueDepth(meter, lane, counts);
159
+ }
160
+ catch (err) {
161
+ // Next poll retries; the warn is throttled so a Redis outage logs once per interval, not once per poll.
162
+ const nowMs = Date.now();
163
+ if (lastWarnAt === undefined || nowMs - lastWarnAt >= QUEUE_DEPTH_WARN_INTERVAL_MS) {
164
+ lastWarnAt = nowMs;
165
+ createFallbackLogger("job-runner").warn("queue depth poll failed", {
166
+ lane,
167
+ error: scrubLogText(err instanceof Error ? err.message : String(err)),
168
+ });
169
+ }
170
+ }
171
+ };
172
+ await pollQueueDepth();
173
+ return setInterval(() => void pollQueueDepth(), QUEUE_DEPTH_POLL_INTERVAL_MS);
174
+ }
175
+ export function parseRedisOpts(url) {
176
+ const parsed = new URL(url);
177
+ const result = {
178
+ host: parsed.hostname,
179
+ port: Number(parsed.port) || 6379,
180
+ };
181
+ if (parsed.pathname.length > 1) {
182
+ result.db = Number(parsed.pathname.slice(1));
183
+ }
184
+ return result;
185
+ }
186
+ // redisOpts carries no connectTimeout/retry cap, so an unreachable Redis
187
+ // would otherwise hang start() forever with no health endpoint to notice.
188
+ export const BOOT_REDIS_TIMEOUT_MS = 10_000;
189
+ // Shutdown must fit a k8s grace period even when Redis is down, so the
190
+ // readiness wait in stop() is capped well below the boot timeout.
191
+ export const STOP_QUEUE_READY_TIMEOUT_MS = 1_000;
192
+ // A gate without an explicit `timeout` may legitimately run long (migration-style
193
+ // checks), so it is never rejected; a hang just must not stay silent.
194
+ export const BOOT_GATE_SLOW_WARN_MS = 60_000;
195
+ export function timeoutReject(ms, message) {
196
+ let timer;
197
+ const promise = new Promise((_, reject) => {
198
+ timer = setTimeout(() => reject(new Error(message)), ms);
199
+ });
200
+ return {
201
+ promise,
202
+ cancel: () => {
203
+ if (timer !== undefined)
204
+ clearTimeout(timer);
205
+ },
206
+ };
207
+ }
208
+ // Default base delay when a job opts into backoff without an explicit
209
+ // delayMs. Without a `delay`, BullMQ's fixed/exponential strategies compute
210
+ // NaN/undefined, which is falsy — the job retries immediately instead of
211
+ // waiting.
212
+ const DEFAULT_JOB_BACKOFF_DELAY_MS = 1_000;
213
+ // Shared by every enqueue path (dispatch(), handleEvent(), cron, runOnBoot,
214
+ // perTenant wrapper/children, sequential re-enqueue) — a job with `retries`
215
+ // set must retry the same way regardless of how it got enqueued, or it
216
+ // fails for good on the very first error on whichever path skips this.
217
+ export function buildRetryBullOpts(jobDef, priorAttempts = 0) {
218
+ const opts = {};
219
+ if (jobDef.retries !== undefined)
220
+ opts.attempts = Math.max(1, jobDef.retries + 1 - priorAttempts);
221
+ if (jobDef.backoff) {
222
+ opts.backoff =
223
+ typeof jobDef.backoff === "string"
224
+ ? { type: jobDef.backoff, delay: DEFAULT_JOB_BACKOFF_DELAY_MS }
225
+ : {
226
+ type: jobDef.backoff.type,
227
+ delay: jobDef.backoff.delayMs ?? DEFAULT_JOB_BACKOFF_DELAY_MS,
228
+ };
229
+ }
230
+ return opts;
231
+ }
232
+ // Equality filter on top-level payload fields for a job's trigger.where —
233
+ // lets N jobs share one broad event QN, partitioned by a payload
234
+ // discriminant instead of needing N distinct event types.
235
+ export function payloadMatchesTriggerFilter(where, payload) {
236
+ if (!where)
237
+ return true;
238
+ return Object.entries(where).every(([key, value]) => payload[key] === value);
239
+ }
240
+ // BullMQ sweeps queue-wide: any job finishing with keepJobs set can evict
241
+ // OTHER jobs in the same completed/failed zset (moveToFinished lua,
242
+ // removeJobsByMaxAge/removeJobsByMaxCount), not just the finishing job, and
243
+ // the sweep only runs lazily on a later finish into that set. Per-job
244
+ // retention is therefore meaningless on a shared queue — only queue-wide,
245
+ // age-only retention (no count) is safe: a count would evict perTenant
246
+ // children still inside their wrapper's retry window, and boot jobs. The
247
+ // completed age must stay above every perTenant wrapper's own worst-case
248
+ // retry window (see maxRetryWindowMs and the invariant check in
249
+ // createJobRunner below), because child dedup (perTenantChildJobId) relies
250
+ // on the children still existing in Redis when a wrapper retry re-derives
251
+ // their ids. The margin here is generous — queue wait time isn't bounded by
252
+ // backoff — to cover realistic windows. Audit trail lives in read_job_runs,
253
+ // not BullMQ (fw#3199).
254
+ export const COMPLETED_JOB_RETENTION_AGE_SEC = 86_400; // 24h
255
+ export const FAILED_JOB_RETENTION_AGE_SEC = 604_800; // 7d
256
+ // Total wall-clock time BullMQ can hold a perTenant wrapper across all its
257
+ // retries, consistent with buildRetryBullOpts's own backoff computation.
258
+ export function maxRetryWindowMs(jobDef) {
259
+ if (!jobDef.backoff || jobDef.retries === undefined)
260
+ return 0;
261
+ const delayMs = (typeof jobDef.backoff === "string" ? undefined : jobDef.backoff.delayMs) ??
262
+ DEFAULT_JOB_BACKOFF_DELAY_MS;
263
+ const type = typeof jobDef.backoff === "string" ? jobDef.backoff : jobDef.backoff.type;
264
+ return type === "exponential" ? delayMs * (2 ** jobDef.retries - 1) : jobDef.retries * delayMs;
265
+ }
@@ -0,0 +1,23 @@
1
+ import type { Queue } from "bullmq";
2
+ import type { JobRunIn } from "../engine/types/index.ts";
3
+ export declare const DEFAULT_QUEUE_NAME_PREFIX = "kumiko-jobs";
4
+ export declare function queueNameFor(prefix: string, lane: JobRunIn): string;
5
+ export declare const ACTIVE_TENANT_IDS_QUERY_NAME = "tenant:query:active-tenant-ids";
6
+ /**
7
+ * BullMQ job ids are `repeat:<schedulerId>:<millis>`. Colons inside the
8
+ * scheduler id push the segment count to ≥5, which BullMQ's legacy heuristic
9
+ * treated as old repeatables — spawning a new scheduler entry every tick and
10
+ * leaking permanent `repeat:*` hashes (taskforcesh/bullmq#3828, fw#1603 /
11
+ * publicstatus Redis OOM). Strip `.` and `:` from the job QN.
12
+ */
13
+ export declare function schedulerIdForJobName(jobName: string): string;
14
+ export declare function bootJobIdForJobName(jobName: string): string;
15
+ export declare const ORPHAN_SCHEDULER_GRACE_MS: number;
16
+ export declare function markSchedulersSeen(queue: Queue, schedulerIds: ReadonlySet<string>): Promise<void>;
17
+ type OrphanSchedulerLog = {
18
+ info(msg: string, data?: Record<string, unknown>): void;
19
+ warn(msg: string, data?: Record<string, unknown>): void;
20
+ };
21
+ export declare function removeOrphanJobSchedulers(queue: Queue, expectedSchedulerIds: ReadonlySet<string>, lane: JobRunIn, log: OrphanSchedulerLog): Promise<void>;
22
+ export declare function perTenantChildJobId(wrapperJobId: string, tenantId: string): string;
23
+ export {};
@@ -0,0 +1,106 @@
1
+ // Queue-name convention: <prefix>-<lane>. The prefix is fixed in prod
2
+ // ("kumiko-jobs") — it must match between enqueuers and consumers, and an
3
+ // accidental drift would silently drop jobs. Tests override via
4
+ // `queueNamePrefix` for per-run isolation (stale jobs from a prior run
5
+ // don't leak into a new test because the queue name includes a timestamp).
6
+ export const DEFAULT_QUEUE_NAME_PREFIX = "kumiko-jobs";
7
+ export function queueNameFor(prefix, lane) {
8
+ return `${prefix}-${lane}`;
9
+ }
10
+ // perTenant jobs need a source of truth for "which tenants are active" —
11
+ // see createJobRunner's defaultGetActiveTenantIds below. The tenant feature
12
+ // (bundled-features package) registers a query under this exact name when
13
+ // mounted. framework must not depend on bundled-features (wrong direction —
14
+ // bundled-features already depends on framework), so this is a name-only
15
+ // coupling: createJobRunner looks the handler up in the registry by string
16
+ // instead of importing it.
17
+ export const ACTIVE_TENANT_IDS_QUERY_NAME = "tenant:query:active-tenant-ids";
18
+ /**
19
+ * BullMQ job ids are `repeat:<schedulerId>:<millis>`. Colons inside the
20
+ * scheduler id push the segment count to ≥5, which BullMQ's legacy heuristic
21
+ * treated as old repeatables — spawning a new scheduler entry every tick and
22
+ * leaking permanent `repeat:*` hashes (taskforcesh/bullmq#3828, fw#1603 /
23
+ * publicstatus Redis OOM). Strip `.` and `:` from the job QN.
24
+ */
25
+ export function schedulerIdForJobName(jobName) {
26
+ return `scheduler-${jobName.replace(/[.:]/g, "-")}`;
27
+ }
28
+ // Same colon-in-BullMQ-id hazard as schedulerIdForJobName (fw#1603/#1604) —
29
+ // a QN like "publicstatus:job:uptime-probe" must not leave ":" in the id.
30
+ export function bootJobIdForJobName(jobName) {
31
+ return `boot-${jobName.replace(/[.:]/g, "-")}`;
32
+ }
33
+ const SCHEDULER_PAGE_SIZE = 100;
34
+ // Rolling deploys run old and new pods against the same Redis for a while; an
35
+ // old runner restarting in that window must not delete the scheduler of a
36
+ // cron job the new version just added.
37
+ export const ORPHAN_SCHEDULER_GRACE_MS = 60 * 60 * 1000;
38
+ function schedulerSeenKey(queue) {
39
+ return queue.toKey("kumiko-scheduler-seen");
40
+ }
41
+ // "Seen" has to mean a live runner holds the scheduler, so runners re-stamp
42
+ // their own ids periodically; a single stamp at start() would let a runner
43
+ // restarting during a mixed-version window longer than the grace period delete
44
+ // a scheduler a healthy newer pod still owns.
45
+ export async function markSchedulersSeen(queue, schedulerIds) {
46
+ // skip: no cron scheduler on this lane, nothing to stamp
47
+ if (schedulerIds.size === 0)
48
+ return;
49
+ const stamp = String(Date.now());
50
+ const entries = Object.fromEntries([...schedulerIds].map((id) => [id, stamp]));
51
+ await (await queue.client).hset(schedulerSeenKey(queue), entries);
52
+ }
53
+ // A cron job dropped from the app (or an older scheduler-id format) leaves its
54
+ // scheduler in Redis, where it keeps firing jobs the worker no longer knows
55
+ // ("Unknown job"). Best-effort: a Redis hiccup here must not stop the start.
56
+ export async function removeOrphanJobSchedulers(queue, expectedSchedulerIds, lane, log) {
57
+ try {
58
+ // Collect first: removing while paging would shift the indices.
59
+ const existingKeys = [];
60
+ for (let start = 0;; start += SCHEDULER_PAGE_SIZE) {
61
+ const page = await queue.getJobSchedulers(start, start + SCHEDULER_PAGE_SIZE - 1);
62
+ for (const scheduler of page)
63
+ existingKeys.push(scheduler.key);
64
+ if (page.length < SCHEDULER_PAGE_SIZE)
65
+ break;
66
+ }
67
+ const client = await queue.client;
68
+ const seenAt = await client.hgetall(schedulerSeenKey(queue));
69
+ const now = Date.now();
70
+ for (const key of existingKeys) {
71
+ if (expectedSchedulerIds.has(key))
72
+ continue;
73
+ const lastSeen = Number(seenAt[key]);
74
+ if (key in seenAt && now - lastSeen < ORPHAN_SCHEDULER_GRACE_MS)
75
+ continue;
76
+ await queue.removeJobScheduler(key);
77
+ await client.hdel(schedulerSeenKey(queue), key);
78
+ log.info("removed orphan job scheduler", { schedulerId: key, lane });
79
+ }
80
+ }
81
+ catch (err) {
82
+ log.warn("orphan job scheduler cleanup failed", {
83
+ lane,
84
+ error: err instanceof Error ? err.message : String(err),
85
+ });
86
+ }
87
+ }
88
+ // Deterministic per-tenant child job id, derived from the perTenant
89
+ // wrapper's own BullMQ job id. A cron tick's wrapper id is
90
+ // `repeat:<schedulerId>:<millis>` (stable for that tick, including across
91
+ // BullMQ's own retry/stalled-job redelivery of the *same* job); a manual
92
+ // dispatch's wrapper id is BullMQ's auto-generated id, equally stable
93
+ // across retries of that job. Reusing it means a wrapper that runs twice
94
+ // for the same trigger (retry after failure, a Redis drop, an instance
95
+ // restarting mid-run) re-derives the *same* child ids, and BullMQ's
96
+ // existing-jobId add() no-ops the second batch instead of creating
97
+ // duplicates — safe only as long as the child job hash outlives the
98
+ // wrapper's retry window, enforced at construction against
99
+ // COMPLETED_JOB_RETENTION_AGE_SEC (see the invariant check in
100
+ // createJobRunner). Same colon-in-BullMQ-id hazard as schedulerIdForJobName
101
+ // (fw#1603/#1604) — the wrapper id already contains ":", so strip
102
+ // separators from both halves before joining instead of interpolating raw.
103
+ export function perTenantChildJobId(wrapperJobId, tenantId) {
104
+ const sanitize = (value) => value.replace(/[.:]/g, "-");
105
+ return `child-${sanitize(wrapperJobId)}-${sanitize(tenantId)}`;
106
+ }