@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
@@ -0,0 +1,65 @@
1
+ /// <reference types="temporal-polyfill/global" preserve="true" />
2
+ import type { AppContext, DispatchWriteRef, JobRunIn, JobTriggerEvent, Registry, SessionUser, TenantId } from "../engine/types/index.ts";
3
+ export type JobLogEntry = {
4
+ level: "info" | "warn" | "error";
5
+ message: string;
6
+ timestamp: Temporal.Instant;
7
+ };
8
+ export type JobMeta = {
9
+ triggeredById?: string | undefined;
10
+ payload?: string | undefined;
11
+ attempt?: number | undefined;
12
+ priority?: number | undefined;
13
+ jobId?: string | undefined;
14
+ tenantVisibleRun?: {
15
+ readonly tenantId: string;
16
+ readonly subject: string | null;
17
+ } | undefined;
18
+ };
19
+ export type JobQueuedMeta = {
20
+ readonly tenantId: string;
21
+ readonly subject: string | null;
22
+ };
23
+ export type JobDroppedMeta = {
24
+ readonly tenantId: string;
25
+ };
26
+ export type JobOutcomeMeta = {
27
+ readonly tenantId: string;
28
+ readonly finalAttempt: boolean;
29
+ readonly tenantVisible?: {
30
+ readonly subject: string | null;
31
+ readonly messageKey: string | null;
32
+ } | undefined;
33
+ readonly tenantVisibleRun?: {
34
+ readonly subject: string | null;
35
+ } | undefined;
36
+ };
37
+ export type JobSubjectValue = string | number | boolean | null;
38
+ export type JobRunner = {
39
+ start(): Promise<void>;
40
+ stop(): Promise<void>;
41
+ dispatch(jobName: string, payload?: Record<string, unknown>, meta?: JobMeta): Promise<string>;
42
+ handleEvent(eventName: string, payload: Record<string, unknown>, user?: SessionUser, triggerEvent?: JobTriggerEvent): Promise<void>;
43
+ attachDispatcher(ref: DispatchWriteRef): void;
44
+ countPendingJobs(): Promise<number>;
45
+ };
46
+ export type JobRunnerOptions = {
47
+ registry: Registry;
48
+ context: AppContext;
49
+ redisUrl: string;
50
+ consumerLane?: JobRunIn | undefined;
51
+ queueNamePrefix?: string | undefined;
52
+ bootRedisTimeoutMs?: number | undefined;
53
+ schedulerSeenRefreshMs?: number | undefined;
54
+ jobRetention?: {
55
+ completedAgeSec?: number | undefined;
56
+ failedAgeSec?: number | undefined;
57
+ } | undefined;
58
+ getActiveTenantIds?: () => Promise<TenantId[]>;
59
+ onJobStart?: (jobName: string, jobId: string, meta: JobMeta) => void;
60
+ onJobComplete?: (jobName: string, jobId: string, duration: number, logs: JobLogEntry[], outcome?: JobOutcomeMeta) => void;
61
+ onJobFailed?: (jobName: string, jobId: string, error: string, logs: JobLogEntry[], outcome?: JobOutcomeMeta) => void;
62
+ onJobDeferred?: (jobName: string, jobId: string, meta: JobMeta) => void;
63
+ onJobQueued?: (jobName: string, jobId: string, meta: JobQueuedMeta) => void;
64
+ onJobDropped?: (jobName: string, jobId: string, meta: JobDroppedMeta) => void;
65
+ };
@@ -0,0 +1,2 @@
1
+ /// <reference types="temporal-polyfill/global" preserve="true" />
2
+ export {};
@@ -1,77 +1,6 @@
1
1
  /// <reference types="temporal-polyfill/global" preserve="true" />
2
- import { type AppContext, type DispatchWriteRef, type JobRunIn, type JobTriggerEvent, type Registry, type SessionUser, type TenantId } from "../engine/types/index.ts";
3
- /**
4
- * BullMQ job ids are `repeat:<schedulerId>:<millis>`. Colons inside the
5
- * scheduler id push the segment count to ≥5, which BullMQ's legacy heuristic
6
- * treated as old repeatables — spawning a new scheduler entry every tick and
7
- * leaking permanent `repeat:*` hashes (taskforcesh/bullmq#3828, fw#1603 /
8
- * publicstatus Redis OOM). Strip `.` and `:` from the job QN.
9
- */
10
- export declare function schedulerIdForJobName(jobName: string): string;
11
- export declare function bootJobIdForJobName(jobName: string): string;
12
- export declare const ORPHAN_SCHEDULER_GRACE_MS: number;
13
- export type JobLogEntry = {
14
- level: "info" | "warn" | "error";
15
- message: string;
16
- timestamp: Temporal.Instant;
17
- };
18
- export type JobMeta = {
19
- triggeredById?: string | undefined;
20
- payload?: string | undefined;
21
- attempt?: number | undefined;
22
- priority?: number | undefined;
23
- jobId?: string | undefined;
24
- tenantVisibleRun?: {
25
- readonly tenantId: string;
26
- readonly subject: string | null;
27
- } | undefined;
28
- };
29
- export type JobQueuedMeta = {
30
- readonly tenantId: string;
31
- readonly subject: string | null;
32
- };
33
- export type JobDroppedMeta = {
34
- readonly tenantId: string;
35
- };
36
- export type JobOutcomeMeta = {
37
- readonly tenantId: string;
38
- readonly finalAttempt: boolean;
39
- readonly tenantVisible?: {
40
- readonly subject: string | null;
41
- readonly messageKey: string | null;
42
- } | undefined;
43
- readonly tenantVisibleRun?: {
44
- readonly subject: string | null;
45
- } | undefined;
46
- };
47
- export type JobSubjectValue = string | number | boolean | null;
48
- export declare function serializeJobSubject(subject: Readonly<Record<string, JobSubjectValue>>): string;
49
- export type JobRunner = {
50
- start(): Promise<void>;
51
- stop(): Promise<void>;
52
- dispatch(jobName: string, payload?: Record<string, unknown>, meta?: JobMeta): Promise<string>;
53
- handleEvent(eventName: string, payload: Record<string, unknown>, user?: SessionUser, triggerEvent?: JobTriggerEvent): Promise<void>;
54
- attachDispatcher(ref: DispatchWriteRef): void;
55
- countPendingJobs(): Promise<number>;
56
- };
57
- export type JobRunnerOptions = {
58
- registry: Registry;
59
- context: AppContext;
60
- redisUrl: string;
61
- consumerLane?: JobRunIn | undefined;
62
- queueNamePrefix?: string | undefined;
63
- bootRedisTimeoutMs?: number | undefined;
64
- schedulerSeenRefreshMs?: number | undefined;
65
- jobRetention?: {
66
- completedAgeSec?: number | undefined;
67
- failedAgeSec?: number | undefined;
68
- } | undefined;
69
- getActiveTenantIds?: () => Promise<TenantId[]>;
70
- onJobStart?: (jobName: string, jobId: string, meta: JobMeta) => void;
71
- onJobComplete?: (jobName: string, jobId: string, duration: number, logs: JobLogEntry[], outcome?: JobOutcomeMeta) => void;
72
- onJobFailed?: (jobName: string, jobId: string, error: string, logs: JobLogEntry[], outcome?: JobOutcomeMeta) => void;
73
- onJobDeferred?: (jobName: string, jobId: string, meta: JobMeta) => void;
74
- onJobQueued?: (jobName: string, jobId: string, meta: JobQueuedMeta) => void;
75
- onJobDropped?: (jobName: string, jobId: string, meta: JobDroppedMeta) => void;
76
- };
2
+ import type { JobRunner, JobRunnerOptions } from "./job-runner-types.ts";
3
+ export { serializeJobSubject } from "./job-runner-helpers.ts";
4
+ export { bootJobIdForJobName, ORPHAN_SCHEDULER_GRACE_MS, schedulerIdForJobName, } from "./job-runner-schedulers.ts";
5
+ export type { JobDroppedMeta, JobLogEntry, JobMeta, JobOutcomeMeta, JobQueuedMeta, JobRunner, JobRunnerOptions, JobSubjectValue, } from "./job-runner-types.ts";
77
6
  export declare function createJobRunner(options: JobRunnerOptions): JobRunner;
@@ -1,406 +1,30 @@
1
1
  /// <reference types="temporal-polyfill/global" preserve="true" />
2
- import { escapeHatchFor } from "@cosmicdrift/kumiko-types/handlers";
3
- import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
2
+ import { crossTenantReadsGrantFor, escapeHatchFor } from "@cosmicdrift/kumiko-types/handlers";
4
3
  import { DelayedError, Queue, UnrecoverableError, Worker } from "bullmq";
5
4
  import { Redis } from "ioredis";
6
5
  import { requestContext } from "../api/request-context.js";
6
+ import { createCrossTenantReads } from "../db/cross-tenant-reads.js";
7
7
  import { createTenantDb, createUncheckedSystemDb } from "../db/tenant-db.js";
8
8
  import { createDerivativesContext } from "../derivatives/derivatives-context.js";
9
9
  import { bridgeStub } from "../engine/bridge-stub.js";
10
10
  import { qnScope } from "../engine/qualified-name.js";
11
11
  import { createSystemUser } from "../engine/system-user.js";
12
- import { SYSTEM_TENANT_ID, } from "../engine/types/index.js";
12
+ import { SYSTEM_TENANT_ID } from "../engine/types/index.js";
13
13
  import { InternalError } from "../errors/index.js";
14
- import { isKumikoError } from "../errors/kumiko-error.js";
15
14
  import { createTenantFileContext } from "../files/file-handle.js";
16
15
  import { createFallbackLogger, scrubLogText } from "../logging/index.js";
17
- import { createEscapeHatchProcessDedup, createEscapeHatchReporter, createEscapeHatchReportWindow, createMetricsForFactory, emitJobLastSuccess, emitJobQueueDepth, flushEscapeHatchAudits, getFallbackTracer, registerStandardMetrics, withEscapeHatchAuditScope, } from "../observability/index.js";
16
+ import { createEscapeHatchProcessDedup, createEscapeHatchReporter, createEscapeHatchReportWindow, createMetricsForFactory, emitJobLastSuccess, flushEscapeHatchAudits, getFallbackTracer, registerStandardMetrics, withEscapeHatchAuditScope, } from "../observability/index.js";
18
17
  import { configAccessorFactoryOf } from "../pipeline/config-accessor-factory.js";
19
18
  import { createDistributedLock } from "../pipeline/distributed-lock.js";
20
19
  import { RedisKeys } from "../pipeline/redis-keys.js";
21
20
  import { createGatedIdentitySwitch } from "../pipeline/system-identity-switch.js";
22
- import { TENANT_TIMEZONE_CONFIG_KEY } from "../pipeline/tenant-timezone-config-key.js";
23
- import { buildPersonalDataGate, isPersonalDataGated, parseWriteOrigin, } from "../pipeline/write-origin.js";
24
- import { createTzContext, isValidIanaTimeZone } from "../time/index.js";
21
+ import { buildPersonalDataGate, parseWriteOrigin } from "../pipeline/write-origin.js";
25
22
  import { cronRepeatOptions } from "./cron-repeat-options.js";
23
+ import { BOOT_GATE_SLOW_WARN_MS, BOOT_REDIS_TIMEOUT_MS, buildRetryBullOpts, COMPLETED_JOB_RETENTION_AGE_SEC, captureTraceContext, createJobLogger, DEFAULT_MAX_DEFERS, DEFER_BACKOFF_MAX_DOUBLINGS, DEFER_COUNT_KEY, DEFER_DELAY_CAP_MS, FAILED_JOB_RETENTION_AGE_SEC, HANDLER_DEFER_COUNT_KEY, isJobTriggerEvent, jobSubjectKey, maxRetryWindowMs, missingDbProxy, parseRedisOpts, payloadMatchesTriggerFilter, priorAttemptsOf, readTraceContext, resolveJobTz, STOP_QUEUE_READY_TIMEOUT_MS, stampGatedWriteOrigin, startQueueDepthPolling, TRACE_CONTEXT_KEY, tenantFailureMessageKey, timeoutReject, } from "./job-runner-helpers.js";
24
+ import { ACTIVE_TENANT_IDS_QUERY_NAME, bootJobIdForJobName, DEFAULT_QUEUE_NAME_PREFIX, markSchedulersSeen, ORPHAN_SCHEDULER_GRACE_MS, perTenantChildJobId, queueNameFor, removeOrphanJobSchedulers, schedulerIdForJobName, } from "./job-runner-schedulers.js";
26
25
  import { resolveMaxPerTenant } from "./max-per-tenant.js";
27
- // Shape-checked after the BullMQ/Redis JSON round-trip. Unlike `_writeOrigin`
28
- // (a security-relevant escape hatch that must fail the run closed), a
29
- // malformed `_triggerEvent` only costs an idempotency-key convenience — the
30
- // run proceeds without it rather than failing over what would be a framework
31
- // bug, not untrusted input.
32
- function isJobTriggerEvent(value) {
33
- if (!isPlainObject(value))
34
- return false;
35
- if (typeof value["id"] !== "string")
36
- return false;
37
- const headers = value["headers"];
38
- if (typeof headers !== "object" || headers === null)
39
- return false;
40
- return Object.values(headers).every((v) => typeof v === "string" || typeof v === "number" || typeof v === "boolean");
41
- }
42
- // A payload's own `_writeOrigin` is never trusted; only the ambient gated origin is stamped.
43
- function stampGatedWriteOrigin(data) {
44
- delete data["_writeOrigin"];
45
- const origin = requestContext.get()?.writeOrigin;
46
- if (origin && isPersonalDataGated(origin)) {
47
- data["_writeOrigin"] = origin;
48
- }
49
- }
50
- // Queue-name convention: <prefix>-<lane>. The prefix is fixed in prod
51
- // ("kumiko-jobs") — it must match between enqueuers and consumers, and an
52
- // accidental drift would silently drop jobs. Tests override via
53
- // `queueNamePrefix` for per-run isolation (stale jobs from a prior run
54
- // don't leak into a new test because the queue name includes a timestamp).
55
- const DEFAULT_QUEUE_NAME_PREFIX = "kumiko-jobs";
56
- function queueNameFor(prefix, lane) {
57
- return `${prefix}-${lane}`;
58
- }
59
- // perTenant jobs need a source of truth for "which tenants are active" —
60
- // see createJobRunner's defaultGetActiveTenantIds below. The tenant feature
61
- // (bundled-features package) registers a query under this exact name when
62
- // mounted. framework must not depend on bundled-features (wrong direction —
63
- // bundled-features already depends on framework), so this is a name-only
64
- // coupling: createJobRunner looks the handler up in the registry by string
65
- // instead of importing it.
66
- const ACTIVE_TENANT_IDS_QUERY_NAME = "tenant:query:active-tenant-ids";
67
- /**
68
- * BullMQ job ids are `repeat:<schedulerId>:<millis>`. Colons inside the
69
- * scheduler id push the segment count to ≥5, which BullMQ's legacy heuristic
70
- * treated as old repeatables — spawning a new scheduler entry every tick and
71
- * leaking permanent `repeat:*` hashes (taskforcesh/bullmq#3828, fw#1603 /
72
- * publicstatus Redis OOM). Strip `.` and `:` from the job QN.
73
- */
74
- export function schedulerIdForJobName(jobName) {
75
- return `scheduler-${jobName.replace(/[.:]/g, "-")}`;
76
- }
77
- // Same colon-in-BullMQ-id hazard as schedulerIdForJobName (fw#1603/#1604) —
78
- // a QN like "publicstatus:job:uptime-probe" must not leave ":" in the id.
79
- export function bootJobIdForJobName(jobName) {
80
- return `boot-${jobName.replace(/[.:]/g, "-")}`;
81
- }
82
- const SCHEDULER_PAGE_SIZE = 100;
83
- // Rolling deploys run old and new pods against the same Redis for a while; an
84
- // old runner restarting in that window must not delete the scheduler of a
85
- // cron job the new version just added.
86
- export const ORPHAN_SCHEDULER_GRACE_MS = 60 * 60 * 1000;
87
- function schedulerSeenKey(queue) {
88
- return queue.toKey("kumiko-scheduler-seen");
89
- }
90
- // "Seen" has to mean a live runner holds the scheduler, so runners re-stamp
91
- // their own ids periodically; a single stamp at start() would let a runner
92
- // restarting during a mixed-version window longer than the grace period delete
93
- // a scheduler a healthy newer pod still owns.
94
- async function markSchedulersSeen(queue, schedulerIds) {
95
- // skip: no cron scheduler on this lane, nothing to stamp
96
- if (schedulerIds.size === 0)
97
- return;
98
- const stamp = String(Date.now());
99
- const entries = Object.fromEntries([...schedulerIds].map((id) => [id, stamp]));
100
- await (await queue.client).hset(schedulerSeenKey(queue), entries);
101
- }
102
- // A cron job dropped from the app (or an older scheduler-id format) leaves its
103
- // scheduler in Redis, where it keeps firing jobs the worker no longer knows
104
- // ("Unknown job"). Best-effort: a Redis hiccup here must not stop the start.
105
- async function removeOrphanJobSchedulers(queue, expectedSchedulerIds, lane, log) {
106
- try {
107
- // Collect first: removing while paging would shift the indices.
108
- const existingKeys = [];
109
- for (let start = 0;; start += SCHEDULER_PAGE_SIZE) {
110
- const page = await queue.getJobSchedulers(start, start + SCHEDULER_PAGE_SIZE - 1);
111
- for (const scheduler of page)
112
- existingKeys.push(scheduler.key);
113
- if (page.length < SCHEDULER_PAGE_SIZE)
114
- break;
115
- }
116
- const client = await queue.client;
117
- const seenAt = await client.hgetall(schedulerSeenKey(queue));
118
- const now = Date.now();
119
- for (const key of existingKeys) {
120
- if (expectedSchedulerIds.has(key))
121
- continue;
122
- const lastSeen = Number(seenAt[key]);
123
- if (key in seenAt && now - lastSeen < ORPHAN_SCHEDULER_GRACE_MS)
124
- continue;
125
- await queue.removeJobScheduler(key);
126
- await client.hdel(schedulerSeenKey(queue), key);
127
- log.info("removed orphan job scheduler", { schedulerId: key, lane });
128
- }
129
- }
130
- catch (err) {
131
- log.warn("orphan job scheduler cleanup failed", {
132
- lane,
133
- error: err instanceof Error ? err.message : String(err),
134
- });
135
- }
136
- }
137
- // Deterministic per-tenant child job id, derived from the perTenant
138
- // wrapper's own BullMQ job id. A cron tick's wrapper id is
139
- // `repeat:<schedulerId>:<millis>` (stable for that tick, including across
140
- // BullMQ's own retry/stalled-job redelivery of the *same* job); a manual
141
- // dispatch's wrapper id is BullMQ's auto-generated id, equally stable
142
- // across retries of that job. Reusing it means a wrapper that runs twice
143
- // for the same trigger (retry after failure, a Redis drop, an instance
144
- // restarting mid-run) re-derives the *same* child ids, and BullMQ's
145
- // existing-jobId add() no-ops the second batch instead of creating
146
- // duplicates — safe only as long as the child job hash outlives the
147
- // wrapper's retry window, enforced at construction against
148
- // COMPLETED_JOB_RETENTION_AGE_SEC (see the invariant check in
149
- // createJobRunner). Same colon-in-BullMQ-id hazard as schedulerIdForJobName
150
- // (fw#1603/#1604) — the wrapper id already contains ":", so strip
151
- // separators from both halves before joining instead of interpolating raw.
152
- function perTenantChildJobId(wrapperJobId, tenantId) {
153
- const sanitize = (value) => value.replace(/[.:]/g, "-");
154
- return `child-${sanitize(wrapperJobId)}-${sanitize(tenantId)}`;
155
- }
156
- function createJobLogger(logs, baseContext = {}) {
157
- function push(level, msg, data) {
158
- const merged = { ...baseContext, ...data };
159
- const message = Object.keys(merged).length > 0 ? `${msg} ${JSON.stringify(merged)}` : msg;
160
- logs.push({ level, message, timestamp: Temporal.Now.instant() });
161
- }
162
- const logger = {
163
- info(msg, data) {
164
- push("info", msg, data);
165
- },
166
- warn(msg, data) {
167
- push("warn", msg, data);
168
- },
169
- error(msg, data) {
170
- push("error", msg, data);
171
- },
172
- // No-op by design, not missing coverage: JobLogEntry.level (and the
173
- // persisted run-completed/-failed event schema in bundled-features/
174
- // jobs/events.ts) only has info|warn|error — a job's debug() calls have
175
- // nowhere durable to land.
176
- debug() { },
177
- child(context) {
178
- return createJobLogger(logs, { ...baseContext, ...context });
179
- },
180
- };
181
- return logger;
182
- }
183
- // Single source of the stored subject string: the writer (jobSubjectKey) and
184
- // the tenant failures query both go through it. Matching is exact — field names
185
- // and JSON types must equal what the writer stored (`"1"` is not `1`).
186
- export function serializeJobSubject(subject) {
187
- return JSON.stringify(Object.keys(subject)
188
- .sort()
189
- .map((field) => [field, subject[field] ?? null]));
190
- }
191
- // Stable identity for the declared payload fields: sorted keys, so two runs
192
- // with the same subject values produce the same string and the later one
193
- // replaces the earlier record. Non-primitives would serialize into something
194
- // no caller can reconstruct for a lookup, so they fail the run loudly.
195
- function jobSubjectKey(jobName, payload, fields, declaration) {
196
- if (fields === undefined || fields.length === 0)
197
- return null;
198
- const subject = {};
199
- for (const field of fields) {
200
- const value = payload[field] ?? null;
201
- if (value !== null && typeof value === "object") {
202
- throw new Error(`Job "${jobName}": ${declaration}.subjectFields["${field}"] must be a primitive, got ${typeof value}`);
203
- }
204
- subject[field] = value;
205
- }
206
- return serializeJobSubject(subject);
207
- }
208
- async function resolveJobTz(config, context) {
209
- const tenantTz = config ? await config(TENANT_TIMEZONE_CONFIG_KEY) : undefined;
210
- return createTzContext({
211
- ...(context.geoTzProvider !== undefined && { geoTz: context.geoTzProvider }),
212
- ...(context.clock !== undefined && { clock: context.clock }),
213
- tenant: typeof tenantTz === "string" && isValidIanaTimeZone(tenantTz) ? tenantTz : "UTC",
214
- });
215
- }
216
- // Jobs that never touch ctx.db run fine on a runner built without one; a job
217
- // that does gets a named error instead of a bare TypeError on undefined.
218
- function missingDbProxy(jobName) {
219
- // @cast-boundary db-operator — every access throws, so no TenantDb member is ever returned
220
- return new Proxy({}, {
221
- get() {
222
- throw new InternalError({
223
- message: `[jobs] job runner constructed without a db connection — ctx.db is unavailable for "${jobName}"`,
224
- });
225
- },
226
- });
227
- }
228
- function createCrossTenantReads(systemModeDb, reason, report) {
229
- return {
230
- selectMany(table, where, options) {
231
- report("cross-tenant-read", reason);
232
- return systemModeDb.selectMany(table, where, options);
233
- },
234
- fetchOne(table, where) {
235
- report("cross-tenant-read", reason);
236
- return systemModeDb.fetchOne(table, where);
237
- },
238
- count(table, where) {
239
- report("cross-tenant-read", reason);
240
- return systemModeDb.count(table, where);
241
- },
242
- };
243
- }
244
- function priorAttemptsOf(data) {
245
- const value = data["_priorAttempts"];
246
- return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : 0;
247
- }
248
- // Only a translation key ever travels to the tenant: the thrown error's own
249
- // i18nKey when it carries one, otherwise the key declared at the job. The
250
- // error message itself can echo provider or payload content and stays on the
251
- // run row and in the run log.
252
- function tenantFailureMessageKey(err, declaration) {
253
- // skip: job did not opt in — nothing tenant-visible to record
254
- if (!declaration)
255
- return null;
256
- if (isKumikoError(err) && err.i18nKey.length > 0)
257
- return err.i18nKey;
258
- return declaration.messageKey;
259
- }
260
- // Serialized trace context lives under this key in the BullMQ job data.
261
- // Leading underscore matches the existing internal-meta convention
262
- // (_triggeredById, _tenantId, _payload).
263
- const TRACE_CONTEXT_KEY = "_traceContext";
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;
268
- const DEFER_DELAY_CAP_MS = 30_000;
269
- const DEFER_BACKOFF_MAX_DOUBLINGS = 5;
270
- function readTraceContext(data) {
271
- const raw = data[TRACE_CONTEXT_KEY];
272
- if (!raw || typeof raw !== "object")
273
- return undefined;
274
- const ctx = raw; // @cast-boundary engine-payload
275
- if (!ctx.traceId || !ctx.spanId)
276
- return undefined;
277
- return { traceId: ctx.traceId, spanId: ctx.spanId };
278
- }
279
- function captureTraceContext(tracer) {
280
- const span = tracer.getActiveSpan();
281
- if (!span?.traceId)
282
- return undefined;
283
- return { traceId: span.traceId, spanId: span.spanId };
284
- }
285
- const QUEUE_DEPTH_POLL_INTERVAL_MS = 15_000;
286
- const QUEUE_DEPTH_WARN_INTERVAL_MS = 5 * 60_000;
287
- // kumiko_job_queue_depth: only the lane's own consumer polls — the count is
288
- // a global Redis-backed value (BullMQ, not per-process), so one reporter per
289
- // lane is enough. Fires once immediately (metrics exist before the first
290
- // poll tick) then on an interval; the caller stores + clears the handle.
291
- async function startQueueDepthPolling(queue, lane, meter) {
292
- let lastWarnAt;
293
- const pollQueueDepth = async () => {
294
- try {
295
- const counts = await queue.getJobCounts("waiting", "active", "delayed", "failed", "paused");
296
- emitJobQueueDepth(meter, lane, counts);
297
- }
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
- }
308
- }
309
- };
310
- await pollQueueDepth();
311
- return setInterval(() => void pollQueueDepth(), QUEUE_DEPTH_POLL_INTERVAL_MS);
312
- }
313
- function parseRedisOpts(url) {
314
- const parsed = new URL(url);
315
- const result = {
316
- host: parsed.hostname,
317
- port: Number(parsed.port) || 6379,
318
- };
319
- if (parsed.pathname.length > 1) {
320
- result.db = Number(parsed.pathname.slice(1));
321
- }
322
- return result;
323
- }
324
- // redisOpts carries no connectTimeout/retry cap, so an unreachable Redis
325
- // would otherwise hang start() forever with no health endpoint to notice.
326
- const BOOT_REDIS_TIMEOUT_MS = 10_000;
327
- // Shutdown must fit a k8s grace period even when Redis is down, so the
328
- // readiness wait in stop() is capped well below the boot timeout.
329
- const STOP_QUEUE_READY_TIMEOUT_MS = 1_000;
330
- // A gate without an explicit `timeout` may legitimately run long (migration-style
331
- // checks), so it is never rejected; a hang just must not stay silent.
332
- const BOOT_GATE_SLOW_WARN_MS = 60_000;
333
- function timeoutReject(ms, message) {
334
- let timer;
335
- const promise = new Promise((_, reject) => {
336
- timer = setTimeout(() => reject(new Error(message)), ms);
337
- });
338
- return {
339
- promise,
340
- cancel: () => {
341
- if (timer !== undefined)
342
- clearTimeout(timer);
343
- },
344
- };
345
- }
346
- // Default base delay when a job opts into backoff without an explicit
347
- // delayMs. Without a `delay`, BullMQ's fixed/exponential strategies compute
348
- // NaN/undefined, which is falsy — the job retries immediately instead of
349
- // waiting.
350
- const DEFAULT_JOB_BACKOFF_DELAY_MS = 1_000;
351
- // Shared by every enqueue path (dispatch(), handleEvent(), cron, runOnBoot,
352
- // perTenant wrapper/children, sequential re-enqueue) — a job with `retries`
353
- // set must retry the same way regardless of how it got enqueued, or it
354
- // fails for good on the very first error on whichever path skips this.
355
- function buildRetryBullOpts(jobDef, priorAttempts = 0) {
356
- const opts = {};
357
- if (jobDef.retries !== undefined)
358
- opts.attempts = Math.max(1, jobDef.retries + 1 - priorAttempts);
359
- if (jobDef.backoff) {
360
- opts.backoff =
361
- typeof jobDef.backoff === "string"
362
- ? { type: jobDef.backoff, delay: DEFAULT_JOB_BACKOFF_DELAY_MS }
363
- : {
364
- type: jobDef.backoff.type,
365
- delay: jobDef.backoff.delayMs ?? DEFAULT_JOB_BACKOFF_DELAY_MS,
366
- };
367
- }
368
- return opts;
369
- }
370
- // Equality filter on top-level payload fields for a job's trigger.where —
371
- // lets N jobs share one broad event QN, partitioned by a payload
372
- // discriminant instead of needing N distinct event types.
373
- function payloadMatchesTriggerFilter(where, payload) {
374
- if (!where)
375
- return true;
376
- return Object.entries(where).every(([key, value]) => payload[key] === value);
377
- }
378
- // BullMQ sweeps queue-wide: any job finishing with keepJobs set can evict
379
- // OTHER jobs in the same completed/failed zset (moveToFinished lua,
380
- // removeJobsByMaxAge/removeJobsByMaxCount), not just the finishing job, and
381
- // the sweep only runs lazily on a later finish into that set. Per-job
382
- // retention is therefore meaningless on a shared queue — only queue-wide,
383
- // age-only retention (no count) is safe: a count would evict perTenant
384
- // children still inside their wrapper's retry window, and boot jobs. The
385
- // completed age must stay above every perTenant wrapper's own worst-case
386
- // retry window (see maxRetryWindowMs and the invariant check in
387
- // createJobRunner below), because child dedup (perTenantChildJobId) relies
388
- // on the children still existing in Redis when a wrapper retry re-derives
389
- // their ids. The margin here is generous — queue wait time isn't bounded by
390
- // backoff — to cover realistic windows. Audit trail lives in read_job_runs,
391
- // not BullMQ (fw#3199).
392
- const COMPLETED_JOB_RETENTION_AGE_SEC = 86_400; // 24h
393
- const FAILED_JOB_RETENTION_AGE_SEC = 604_800; // 7d
394
- // Total wall-clock time BullMQ can hold a perTenant wrapper across all its
395
- // retries, consistent with buildRetryBullOpts's own backoff computation.
396
- function maxRetryWindowMs(jobDef) {
397
- if (!jobDef.backoff || jobDef.retries === undefined)
398
- return 0;
399
- const delayMs = (typeof jobDef.backoff === "string" ? undefined : jobDef.backoff.delayMs) ??
400
- DEFAULT_JOB_BACKOFF_DELAY_MS;
401
- const type = typeof jobDef.backoff === "string" ? jobDef.backoff : jobDef.backoff.type;
402
- return type === "exponential" ? delayMs * (2 ** jobDef.retries - 1) : jobDef.retries * delayMs;
403
- }
26
+ export { serializeJobSubject } from "./job-runner-helpers.js";
27
+ export { bootJobIdForJobName, ORPHAN_SCHEDULER_GRACE_MS, schedulerIdForJobName, } from "./job-runner-schedulers.js";
404
28
  export function createJobRunner(options) {
405
29
  const { registry, context, redisUrl, consumerLane } = options;
406
30
  const queueNamePrefix = options.queueNamePrefix ?? DEFAULT_QUEUE_NAME_PREFIX;
@@ -988,7 +612,7 @@ export function createJobRunner(options) {
988
612
  },
989
613
  })
990
614
  : undefined;
991
- const crossTenantReadsReason = escapeHatchFor(jobDef.escapeHatch, "unsafeRaw")?.reason;
615
+ const crossTenantReadsReason = crossTenantReadsGrantFor(jobDef.escapeHatch)?.reason;
992
616
  const crossTenantReads = tenantScopedDb && crossTenantReadsReason !== undefined
993
617
  ? createCrossTenantReads(tenantScopedDb, crossTenantReadsReason, reportEscapeHatch)
994
618
  : undefined;
@@ -1,5 +1,6 @@
1
- import { escapeHatchFor } from "@cosmicdrift/kumiko-types/handlers";
2
- import { requestContext, runWithOrigin } from "../api/request-context.js";
1
+ import { crossTenantReadsGrantFor, escapeHatchFor } from "@cosmicdrift/kumiko-types/handlers";
2
+ import { isAgentToolCallScope, requestContext, runWithOrigin } from "../api/request-context.js";
3
+ import { createHandlerCrossTenantReads } from "../db/cross-tenant-reads.js";
3
4
  import { runInSavepoint, selectMany } from "../db/query.js";
4
5
  import { createTenantDb, createUncheckedSystemDb, hasTenantColumn, } from "../db/tenant-db.js";
5
6
  import { createDerivativesContext } from "../derivatives/derivatives-context.js";
@@ -186,6 +187,7 @@ export function applyMemberResolutionReadOnly(handlerContext) {
186
187
  // A read as a member gets no system-scope DB, preSave pipeline or file storage.
187
188
  runPreSave: undefined,
188
189
  systemDb: undefined,
190
+ crossTenantReads: undefined,
189
191
  files: undefined,
190
192
  derivatives: undefined,
191
193
  ...(handlerContext.jobRunner && { jobRunner: denyingJobRunnerProxy() }),
@@ -326,6 +328,28 @@ readOnlyForTenant = false) {
326
328
  const systemDb = isSystem && db
327
329
  ? createUncheckedSystemDb(db, rawDbOutsideTransaction, reportEscapeHatch)
328
330
  : undefined;
331
+ // An agent tool call runs with the user's session but feeds the LLM context, so
332
+ // cross-tenant rows must never reach it, even when unsafeRaw implies the grant.
333
+ const crossTenantReadsReason = isAgentToolCallScope()
334
+ ? undefined
335
+ : crossTenantReadsGrantFor(handlerEscapeHatch)?.reason;
336
+ const crossTenantReads = dbSource && crossTenantReadsReason !== undefined
337
+ ? createHandlerCrossTenantReads({
338
+ db: dbSource,
339
+ tenantId: user.tenantId,
340
+ tracer: context.tracer,
341
+ meter: context.meter,
342
+ signal: reqCtx?.signal,
343
+ reason: crossTenantReadsReason,
344
+ report: reportEscapeHatch,
345
+ grants: {
346
+ report: reportEscapeHatch,
347
+ memberReadOnly: isMemberResolutionPrincipal(user),
348
+ personalDataGate: buildPersonalDataGate(registry, origin),
349
+ projectionRegistry: registry,
350
+ },
351
+ })
352
+ : undefined;
329
353
  // Exposed as ctx.db below — the internal `db` above stays the real,
330
354
  // working TenantDb for this function's own use (config/derivatives
331
355
  // accessors, systemDb construction).
@@ -576,8 +600,8 @@ readOnlyForTenant = false) {
576
600
  await appendDomainEvent(ctx, args, user, tx, registry.getHandlerFeature(type));
577
601
  },
578
602
  // Savepoint-scoped append: catches a losing writer's VersionConflictError
579
- // without poisoning the rest of the handler's transaction. Bun.SQL/
580
- // postgres.js abort the WHOLE begin() on an uncaught statement error
603
+ // without poisoning the rest of the handler's transaction. postgres.js
604
+ // aborts the WHOLE begin() on an uncaught statement error
581
605
  // (SQLSTATE 25P02) even if the JS error is caught — runInSavepoint
582
606
  // wraps the append in a real SAVEPOINT so only that nested scope rolls
583
607
  // back on conflict, and subsequent statements in the outer tx (e.g. a
@@ -855,6 +879,7 @@ readOnlyForTenant = false) {
855
879
  db: exposedDb,
856
880
  dbOutsideTransaction,
857
881
  ...(systemDb && { systemDb }),
882
+ ...(crossTenantReads && { crossTenantReads }),
858
883
  log,
859
884
  notify,
860
885
  ...(config && { config }),
@@ -21,7 +21,6 @@ const CONNECTION_LOSS_CODES = new Set([
21
21
  "CONNECT_TIMEOUT",
22
22
  "ECONNREFUSED",
23
23
  "ECONNRESET",
24
- "ERR_POSTGRES_CONNECTION_CLOSED",
25
24
  "57P01",
26
25
  ]);
27
26
  function isConnectionLossError(e) {
@@ -506,7 +505,7 @@ export function createEventDispatcher(options) {
506
505
  // Fires on initial connect and again when the driver's own
507
506
  // re-LISTEN lands after a drop; during an outage that re-LISTEN
508
507
  // waits in its connection backoff (postgres-js: measured 10-30s
509
- // after the DB returns, Bun.SQL: up to 32s), polling covers
508
+ // after the DB returns), polling covers
510
509
  // delivery meanwhile. Never unlisten()
511
510
  // + listen() by hand: that leaves duplicate listeners.
512
511
  // ponytail: no own LISTEN reconnect, the backoff gap is polled;
@@ -534,7 +533,7 @@ export function createEventDispatcher(options) {
534
533
  timer = null;
535
534
  }
536
535
  if (pgSubscription) {
537
- // Called on the subscription: Bun.SQL's unlisten is a prototype method.
536
+ // Called on the subscription, not detached: unlisten may rely on `this`.
538
537
  await pgSubscription.unlisten().catch(() => {
539
538
  // skip: unlisten failure only matters during shutdown — the
540
539
  // subscription is being torn down anyway.