@cosmicdrift/kumiko-bundled-features 0.352.0 → 0.354.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 (259) hide show
  1. package/dist/agent-tools/agent-doc-lint.d.ts +2 -0
  2. package/dist/agent-tools/agent-doc-lint.js +61 -1
  3. package/dist/agent-tools/agent-manifest.d.ts +1 -0
  4. package/dist/agent-tools/agent-manifest.js +17 -5
  5. package/dist/agent-tools/feature.js +7 -2
  6. package/dist/agent-tools/index.d.ts +1 -1
  7. package/dist/agent-tools/index.js +1 -1
  8. package/dist/agent-tools/manifest-deny-qns.d.ts +3 -0
  9. package/dist/agent-tools/manifest-deny-qns.js +17 -0
  10. package/dist/agent-tools/tool-catalog.d.ts +2 -1
  11. package/dist/agent-tools/tool-catalog.js +4 -1
  12. package/dist/agent-tools/tool-dispatch.js +1 -1
  13. package/dist/audit/handlers/list.query.js +13 -8
  14. package/dist/auth-email-password/handlers/change-password.write.js +1 -0
  15. package/dist/auth-email-password/handlers/invite-accept-with-login.write.js +2 -2
  16. package/dist/auth-email-password/handlers/invite-accept.write.js +2 -2
  17. package/dist/auth-email-password/handlers/invite-create.write.js +17 -0
  18. package/dist/auth-email-password/handlers/invite-signup-complete.write.js +4 -1
  19. package/dist/auth-email-password/handlers/login.write.js +1 -0
  20. package/dist/auth-email-password/handlers/reset-password.write.js +1 -0
  21. package/dist/auth-email-password/handlers/signup-confirm.write.js +6 -4
  22. package/dist/auth-email-password/handlers/signup-request.write.js +51 -77
  23. package/dist/auth-email-password/handlers/token-request-handler.js +1 -0
  24. package/dist/auth-email-password/handlers/verify-email.write.js +1 -0
  25. package/dist/auth-email-password/index.d.ts +2 -0
  26. package/dist/auth-email-password/index.js +2 -0
  27. package/dist/auth-email-password/seeding.d.ts +2 -0
  28. package/dist/auth-email-password/seeding.js +30 -8
  29. package/dist/auth-email-password/signup-activation.d.ts +24 -0
  30. package/dist/auth-email-password/signup-activation.js +41 -0
  31. package/dist/auth-email-password/signup-token-store.d.ts +5 -0
  32. package/dist/auth-email-password/signup-token-store.js +8 -3
  33. package/dist/auth-email-password/web/auth-gate.d.ts +2 -2
  34. package/dist/auth-email-password/web/auth-gate.js +5 -5
  35. package/dist/auth-email-password/web/auth-redirect.d.ts +1 -0
  36. package/dist/auth-email-password/web/auth-redirect.js +14 -0
  37. package/dist/auth-email-password/web/login-screen.js +2 -2
  38. package/dist/auth-email-password/web/user-menu.js +1 -1
  39. package/dist/auth-mfa/feature.js +5 -0
  40. package/dist/auth-mfa/handlers/enable-confirm-preauth.write.js +1 -0
  41. package/dist/auth-mfa/handlers/verify.write.js +1 -0
  42. package/dist/auth-mfa/schema/user-mfa.d.ts +6 -0
  43. package/dist/auth-mfa/screens.js +2 -2
  44. package/dist/auth-mfa/web/mfa-recovery-codes-reveal.js +1 -1
  45. package/dist/auth-mfa/web/mfa-setup-preauth-screen.d.ts +1 -1
  46. package/dist/auth-mfa/web/mfa-setup-preauth-screen.js +3 -3
  47. package/dist/auth-mfa/web/mfa-verify-screen.d.ts +1 -1
  48. package/dist/auth-mfa/web/mfa-verify-screen.js +1 -1
  49. package/dist/billing-foundation/billing-info-query.d.ts +5 -2
  50. package/dist/billing-foundation/checkout-core.d.ts +7 -0
  51. package/dist/billing-foundation/checkout-core.js +7 -2
  52. package/dist/billing-foundation/entities.d.ts +8 -0
  53. package/dist/billing-foundation/handlers/start-plan-checkout.write.js +7 -1
  54. package/dist/billing-foundation/projection.d.ts +8 -0
  55. package/dist/cap-counter/entity.d.ts +1 -0
  56. package/dist/cap-overview/access.js +2 -9
  57. package/dist/cap-overview/handlers/tenant-caps-list.query.js +2 -2
  58. package/dist/cap-overview/handlers/tenant-options.query.js +4 -5
  59. package/dist/channel-in-app/in-app-channel.js +1 -1
  60. package/dist/channel-in-app/tables.js +4 -2
  61. package/dist/compliance-profiles/schema/profile-selection.d.ts +10 -8
  62. package/dist/compliance-profiles-ops/handlers/tenants-missing-profile.query.js +2 -4
  63. package/dist/config/handlers/values.query.d.ts +3 -1
  64. package/dist/config/handlers/values.query.js +7 -2
  65. package/dist/config/table.d.ts +6 -0
  66. package/dist/crypto-shredding/handlers/forget-subject.write.d.ts +1 -0
  67. package/dist/crypto-shredding/handlers/forget-subject.write.js +110 -61
  68. package/dist/custom-fields/entity.d.ts +4 -0
  69. package/dist/data-retention/schema/tenant-retention-override.d.ts +4 -0
  70. package/dist/delivery/db/queries/address-opt-outs.d.ts +3 -1
  71. package/dist/delivery/db/queries/address-opt-outs.js +12 -7
  72. package/dist/delivery/delivery-service.js +60 -12
  73. package/dist/delivery/public-names.js +1 -1
  74. package/dist/delivery/tables.d.ts +6 -0
  75. package/dist/delivery/unsubscribe.js +66 -14
  76. package/dist/document-ingest-foundation/entity.d.ts +4 -0
  77. package/dist/feature-toggles/feature.js +2 -3
  78. package/dist/feature-toggles/toggle-runtime.d.ts +5 -1
  79. package/dist/feature-toggles/toggle-runtime.js +30 -6
  80. package/dist/feature-toggles/toggle-sync-signal.d.ts +3 -2
  81. package/dist/feature-toggles/toggle-sync-signal.js +29 -10
  82. package/dist/file-derivatives/handlers/public-variant.query.d.ts +1 -0
  83. package/dist/file-derivatives/handlers/public-variant.query.js +7 -1
  84. package/dist/files-tenant-data/handlers/sweep-orphaned-derivatives.job.js +5 -0
  85. package/dist/files-tenant-data/hooks.js +61 -24
  86. package/dist/folders/entity.d.ts +8 -0
  87. package/dist/folders/feature.js +2 -10
  88. package/dist/folders/handlers/delete-folder.write.js +31 -4
  89. package/dist/form-draft/db/queries/cleanup.d.ts +1 -1
  90. package/dist/form-draft/db/queries/cleanup.js +4 -3
  91. package/dist/form-draft/entity.d.ts +2 -0
  92. package/dist/form-draft/handlers/cleanup.job.d.ts +5 -1
  93. package/dist/form-draft/handlers/cleanup.job.js +11 -5
  94. package/dist/foundation-shared/index.d.ts +1 -1
  95. package/dist/foundation-shared/index.js +1 -1
  96. package/dist/foundation-shared/mail-host-policy.d.ts +4 -0
  97. package/dist/foundation-shared/mail-host-policy.js +13 -4
  98. package/dist/inbound-mail-foundation/entities.d.ts +25 -0
  99. package/dist/inbound-mail-foundation/projection.d.ts +20 -0
  100. package/dist/inbound-provider-imap/feature.js +2 -0
  101. package/dist/inbound-provider-imap/imap-client.js +3 -4
  102. package/dist/jobs/constants.d.ts +1 -0
  103. package/dist/jobs/constants.js +1 -0
  104. package/dist/jobs/db/queries/retention.d.ts +1 -0
  105. package/dist/jobs/db/queries/retention.js +5 -0
  106. package/dist/jobs/db/queries/stale-run-sweep.d.ts +2 -0
  107. package/dist/jobs/db/queries/stale-run-sweep.js +13 -2
  108. package/dist/jobs/feature.js +7 -1
  109. package/dist/jobs/handlers/list.query.d.ts +2 -1
  110. package/dist/jobs/handlers/list.query.js +40 -15
  111. package/dist/jobs/handlers/tenant-failures.query.js +1 -1
  112. package/dist/jobs/handlers/tenant-runs.query.d.ts +17 -0
  113. package/dist/jobs/handlers/tenant-runs.query.js +72 -0
  114. package/dist/jobs/i18n.js +1 -0
  115. package/dist/jobs/index.d.ts +2 -0
  116. package/dist/jobs/index.js +1 -0
  117. package/dist/jobs/job-run-logger.d.ts +2 -2
  118. package/dist/jobs/job-run-logger.js +98 -9
  119. package/dist/jobs/job-run-table.d.ts +6 -0
  120. package/dist/jobs/tenant-job-failure-table.js +15 -2
  121. package/dist/jobs/tenant-job-run-table.d.ts +4 -0
  122. package/dist/jobs/tenant-job-run-table.js +34 -0
  123. package/dist/ledger/entity.d.ts +24 -8
  124. package/dist/ledger/entity.js +17 -5
  125. package/dist/ledger/feature.js +3 -0
  126. package/dist/ledger/handlers/confirm-schedule-period.write.js +9 -3
  127. package/dist/ledger/handlers/create-transaction.write.js +1 -0
  128. package/dist/ledger/handlers/reverse-transaction.write.js +1 -0
  129. package/dist/ledger/schedule-subject-pair.d.ts +2 -0
  130. package/dist/ledger/schedule-subject-pair.js +24 -0
  131. package/dist/ledger/schemas.js +8 -2
  132. package/dist/mail-transport-smtp/feature.d.ts +3 -4
  133. package/dist/mail-transport-smtp/feature.js +4 -13
  134. package/dist/managed-pages/feature.js +28 -10
  135. package/dist/managed-pages/table.d.ts +12 -0
  136. package/dist/notes-history/entity.d.ts +12 -0
  137. package/dist/notes-history/entity.js +4 -0
  138. package/dist/notes-history/feature.js +2 -10
  139. package/dist/personal-access-tokens/handlers/create.write.js +1 -0
  140. package/dist/personal-access-tokens/schema/api-token.d.ts +6 -0
  141. package/dist/personal-access-tokens/screens.js +14 -3
  142. package/dist/secrets/table.d.ts +1 -0
  143. package/dist/seo/feature.js +28 -6
  144. package/dist/sessions/feature.d.ts +1 -0
  145. package/dist/sessions/feature.js +9 -8
  146. package/dist/sessions/handlers/detail.query.d.ts +1 -1
  147. package/dist/sessions/handlers/detail.query.js +2 -2
  148. package/dist/sessions/handlers/list.query.d.ts +1 -1
  149. package/dist/sessions/handlers/list.query.js +5 -3
  150. package/dist/sessions/schema/user-session.d.ts +4 -0
  151. package/dist/sessions/screens.d.ts +2 -2
  152. package/dist/sessions/screens.js +6 -7
  153. package/dist/shared/assert-no-where-rule-in-ownership-write.d.ts +2 -0
  154. package/dist/shared/assert-no-where-rule-in-ownership-write.js +18 -0
  155. package/dist/shared/encrypt-for-direct-write.js +10 -2
  156. package/dist/shared/index.d.ts +4 -3
  157. package/dist/shared/index.js +4 -3
  158. package/dist/shared/parent-visibility.d.ts +2 -1
  159. package/dist/shared/parent-visibility.js +21 -0
  160. package/dist/shared/rate-limited-text-response.d.ts +2 -0
  161. package/dist/shared/rate-limited-text-response.js +9 -0
  162. package/dist/shared/row-bound-grant.js +16 -2
  163. package/dist/shared/session-locale-field.d.ts +1 -0
  164. package/dist/shared/session-locale-field.js +6 -0
  165. package/dist/tags/entity.d.ts +9 -0
  166. package/dist/tags/feature.js +2 -10
  167. package/dist/template-resolver/api.d.ts +2 -2
  168. package/dist/template-resolver/handlers/list.query.d.ts +2 -0
  169. package/dist/template-resolver/handlers/list.query.js +2 -2
  170. package/dist/template-resolver/table.d.ts +48 -30
  171. package/dist/template-resolver/user-content-table.d.ts +34 -20
  172. package/dist/tenant/decrypt-tenant-names.d.ts +3 -0
  173. package/dist/tenant/decrypt-tenant-names.js +10 -0
  174. package/dist/tenant/feature.js +7 -1
  175. package/dist/tenant/handlers/me.query.js +5 -1
  176. package/dist/tenant/handlers/memberships.query.js +3 -2
  177. package/dist/tenant/handlers/tenant-directory.query.js +9 -3
  178. package/dist/tenant/handlers/toggle-enabled.write.js +2 -0
  179. package/dist/tenant/handlers/update-member-roles.write.js +1 -0
  180. package/dist/tenant/index.d.ts +1 -0
  181. package/dist/tenant/index.js +1 -0
  182. package/dist/tenant/invitation-table.d.ts +54 -8
  183. package/dist/tenant/invitation-table.js +6 -1
  184. package/dist/tenant/invited-membership.d.ts +8 -6
  185. package/dist/tenant/invited-membership.js +28 -7
  186. package/dist/tenant/membership-table.d.ts +4 -0
  187. package/dist/tenant/schema/tenant.d.ts +18 -10
  188. package/dist/tenant/schema/tenant.js +4 -6
  189. package/dist/tenant/screens.js +1 -1
  190. package/dist/tenant-handover/move-entity-graph.js +11 -18
  191. package/dist/tenant-handover/transfer-graph.d.ts +2 -21
  192. package/dist/tenant-handover/transfer-graph.js +8 -75
  193. package/dist/tenant-lifecycle/boot-checks.d.ts +1 -0
  194. package/dist/tenant-lifecycle/boot-checks.js +13 -0
  195. package/dist/tenant-lifecycle/constants.d.ts +3 -0
  196. package/dist/tenant-lifecycle/constants.js +5 -0
  197. package/dist/tenant-lifecycle/events.d.ts +5 -0
  198. package/dist/tenant-lifecycle/events.js +5 -0
  199. package/dist/tenant-lifecycle/feature.js +6 -3
  200. package/dist/tenant-lifecycle/run-tenant-destroy.d.ts +10 -5
  201. package/dist/tenant-lifecycle/run-tenant-destroy.js +59 -17
  202. package/dist/tenant-lifecycle/stages.d.ts +8 -1
  203. package/dist/tenant-lifecycle/stages.js +33 -4
  204. package/dist/tenant-lifecycle/testing.d.ts +8 -0
  205. package/dist/tenant-lifecycle/testing.js +42 -0
  206. package/dist/tier-engine/entity.d.ts +2 -0
  207. package/dist/tier-engine/handlers/set-tenant-tier.write.js +1 -0
  208. package/dist/tier-engine/tier-resolver.d.ts +2 -0
  209. package/dist/user/handlers/detail.query.js +2 -0
  210. package/dist/user/handlers/list.query.js +6 -2
  211. package/dist/user/handlers/update.write.js +1 -0
  212. package/dist/user/schema/user.d.ts +22 -8
  213. package/dist/user/seeding.d.ts +1 -0
  214. package/dist/user/seeding.js +20 -0
  215. package/dist/user-data-rights/boot-checks.js +5 -0
  216. package/dist/user-data-rights/handlers/confirm-deletion-by-token.write.js +2 -2
  217. package/dist/user-data-rights/handlers/request-deletion.write.js +1 -0
  218. package/dist/user-data-rights/index.d.ts +1 -0
  219. package/dist/user-data-rights/index.js +1 -0
  220. package/dist/user-data-rights/schema/download-attempt.d.ts +14 -0
  221. package/dist/user-data-rights/schema/download-attempt.js +17 -9
  222. package/dist/user-data-rights/schema/download-token.d.ts +8 -0
  223. package/dist/user-data-rights/schema/export-job.d.ts +16 -8
  224. package/dist/user-profile/handlers/change-email.write.js +8 -2
  225. package/dist/waitlist/entity.d.ts +22 -6
  226. package/dist/workflow-runner/event-trigger.d.ts +1 -0
  227. package/dist/workflow-runner/event-trigger.js +63 -15
  228. package/dist/workflow-runner/handlers/resume-run.write.js +8 -2
  229. package/dist/workflow-runner/runner.d.ts +2 -1
  230. package/dist/workflow-runner/runner.js +5 -1
  231. package/package.json +35 -10
  232. package/src/agent-tools/changes.json +12 -0
  233. package/src/audit/changes.json +6 -0
  234. package/src/auth-email-password/changes.json +24 -0
  235. package/src/auth-mfa/changes.json +7 -0
  236. package/src/billing-foundation/changes.json +6 -0
  237. package/src/channel-in-app/changes.json +8 -1
  238. package/src/crypto-shredding/changes.json +12 -0
  239. package/src/delivery/changes.json +6 -0
  240. package/src/files/changes.json +24 -0
  241. package/src/files-tenant-data/changes.json +5 -0
  242. package/src/folders/changes.json +6 -0
  243. package/src/form-draft/changes.json +7 -1
  244. package/src/inbound-provider-imap/changes.json +6 -0
  245. package/src/jobs/changes.json +30 -0
  246. package/src/ledger/changes.json +23 -0
  247. package/src/managed-pages/changes.json +6 -0
  248. package/src/notes-history/changes.json +13 -0
  249. package/src/seo/changes.json +5 -0
  250. package/src/template-resolver/changes.json +5 -0
  251. package/src/tenant/changes.json +19 -0
  252. package/src/tenant-handover/changes.json +12 -0
  253. package/src/tenant-lifecycle/changes.json +22 -0
  254. package/src/tier-engine/changes.json +5 -0
  255. package/src/user/changes.json +15 -0
  256. package/src/user-data-rights/changes.json +6 -0
  257. package/src/workflow-runner/changes.json +5 -0
  258. package/dist/shared/has-where-rule.d.ts +0 -2
  259. package/dist/shared/has-where-rule.js +0 -10
@@ -11,11 +11,13 @@
11
11
  // schema/migration change, no web filter-dropdown/status-enum update, and
12
12
  // the run becomes retriable via the existing jobs:write:retry gate
13
13
  // (retry.write.ts only allows retry from "failed").
14
- import { selectMany, updateMany } from "@cosmicdrift/kumiko-framework/bun-db";
14
+ import { deleteManyBatched, selectMany, updateMany } from "@cosmicdrift/kumiko-framework/bun-db";
15
15
  import { mapWithConcurrency } from "../../../shared/index.js";
16
16
  import { encryptFailureError } from "../../job-run-logger.js";
17
17
  import { jobRunsTable } from "../../job-run-table.js";
18
+ import { tenantJobRunsTable } from "../../tenant-job-run-table.js";
18
19
  const KMS_POOL_CONCURRENCY = 4;
20
+ const STALE_DELETE_BATCH_SIZE = 500;
19
21
  export const STALE_JOB_RUN_ERROR = "job run exceeded the stale-run timeout without a completion signal (likely a crashed worker process)";
20
22
  export async function markStaleJobRunsFailed(db, timeoutHours) {
21
23
  const cutoff = Temporal.Now.instant().subtract({ hours: timeoutHours });
@@ -57,5 +59,14 @@ export async function markStaleJobRunsFailed(db, timeoutHours) {
57
59
  return updated.length;
58
60
  });
59
61
  const runsMarkedFailed = updatedPerSubject.reduce((sum, count) => sum + count, 0);
60
- return { runsMarkedFailed };
62
+ // Tenant-visible run state follows the same clock: a running row whose
63
+ // worker died is a failure, a queued row that never started (job lost from
64
+ // Redis, enqueue hook without a run) would otherwise show "waiting" forever.
65
+ const tenantRunsFailed = await updateMany(db, tenantJobRunsTable, { status: "failed", finishedAt: now, updatedAt: now }, { status: "running", startedAt: { lt: cutoff } });
66
+ const tenantQueuedStale = await deleteManyBatched(db, tenantJobRunsTable, { status: "queued", updatedAt: { lt: cutoff } }, { limit: STALE_DELETE_BATCH_SIZE });
67
+ return {
68
+ runsMarkedFailed,
69
+ tenantRunsMarkedFailed: tenantRunsFailed.length,
70
+ tenantQueuedDropped: tenantQueuedStale.deleted,
71
+ };
61
72
  }
@@ -9,15 +9,17 @@ import { createRetentionCleanupJob, DEFAULT_JOB_RUN_RETENTION_DAYS, } from "./ha
9
9
  import { retryWrite } from "./handlers/retry.write.js";
10
10
  import { createStaleRunSweepJob, DEFAULT_JOB_RUN_STALE_TIMEOUT_HOURS, } from "./handlers/stale-run-sweep.job.js";
11
11
  import { tenantFailuresQuery } from "./handlers/tenant-failures.query.js";
12
+ import { tenantRunsQuery } from "./handlers/tenant-runs.query.js";
12
13
  import { triggerWrite } from "./handlers/trigger.write.js";
13
14
  import { JOBS_I18N } from "./i18n.js";
14
15
  import { jobRunLogsTableMeta, jobRunsTableMeta } from "./job-run-table.js";
15
16
  import { tenantJobFailuresTableMeta } from "./tenant-job-failure-table.js";
17
+ import { tenantJobRunsTableMeta } from "./tenant-job-run-table.js";
16
18
  export function createJobsFeature(options = {}) {
17
19
  const retentionDays = options.retentionDays ?? DEFAULT_JOB_RUN_RETENTION_DAYS;
18
20
  const staleRunTimeoutHours = options.staleRunTimeoutHours ?? DEFAULT_JOB_RUN_STALE_TIMEOUT_HOURS;
19
21
  return defineFeature("jobs", (r) => {
20
- r.describe("Persistence and operator tooling for background jobs registered via `r.job(...)`. Every job execution writes directly into `store_job_runs` (current status + duration) and `store_job_run_logs` (per-line log rows) from the BullMQ callbacks — no event stream in between (#2243). A daily `retention-cleanup` job deletes runs (and their logs) older than `retentionDays`; an hourly `stale-run-sweep` job marks runs stuck at status `running` past `staleRunTimeoutHours` as `failed` (#2246 — a crashed worker never fires the completion callback, so nothing else ever revisits the row). Exposes `jobs:write:trigger` (manual run) and `jobs:write:retry` (operator retry of a failed run), plus `jobs:query:list`, `jobs:query:details`, and `jobs:query:catalog` (manual jobs) for the operator UI. A job that declares `tenantVisibleFailure` also records its last failed attempt per tenant and subject in `store_tenant_job_failures`, which the tenant itself reads through `jobs:query:failures` — a translation key only, never the provider's message (fw#3079).");
22
+ r.describe("Persistence and operator tooling for background jobs registered via `r.job(...)`. Every job execution writes directly into `store_job_runs` (current status + duration) and `store_job_run_logs` (per-line log rows) from the BullMQ callbacks — no event stream in between (#2243). A daily `retention-cleanup` job deletes runs (and their logs) older than `retentionDays`; an hourly `stale-run-sweep` job marks runs stuck at status `running` past `staleRunTimeoutHours` as `failed` (#2246 — a crashed worker never fires the completion callback, so nothing else ever revisits the row). Exposes `jobs:write:trigger` (manual run) and `jobs:write:retry` (operator retry of a failed run), plus `jobs:query:list`, `jobs:query:details`, and `jobs:query:catalog` (manual jobs) for the operator UI. A job that declares `tenantVisibleFailure` also records its last failed attempt per tenant and subject in `store_tenant_job_failures`, which the tenant itself reads through `jobs:query:failures` — a translation key only, never the provider's message (fw#3079). A job that declares `tenantVisibleRun` also keeps its run state per tenant and subject in `store_tenant_job_runs` (queued, running, plus the latest completed or failed run, no payload, error text or logs), which the tenant reads through `jobs:query:tenant-runs` (fw#3616); the stale-run sweep fails its stuck `running` rows and drops its stuck `queued` rows.");
21
23
  r.uiHints({
22
24
  displayLabel: "Jobs · Audit & Operator UI",
23
25
  category: "operations",
@@ -38,6 +40,9 @@ export function createJobsFeature(options = {}) {
38
40
  r.storeTable(tenantJobFailuresTableMeta, {
39
41
  reason: "direct_write.tenant_job_failures",
40
42
  });
43
+ r.storeTable(tenantJobRunsTableMeta, {
44
+ reason: "direct_write.tenant_job_runs",
45
+ });
41
46
  // Framework-provided rebuild job — available whenever `jobs` is composed; enqueueProjectionRebuild dispatches it.
42
47
  r.job("projectionRebuild", { trigger: { manual: true }, concurrency: "skip", schema: projectionRebuildPayloadSchema }, projectionRebuildJob);
43
48
  // Retroactive search backfill (#1206/#1215) — manual + perTenant, so one
@@ -68,6 +73,7 @@ export function createJobsFeature(options = {}) {
68
73
  detail: r.queryHandler(detailQuery),
69
74
  catalog: r.queryHandler(catalogQuery),
70
75
  failures: r.queryHandler(tenantFailuresQuery),
76
+ tenantRuns: r.queryHandler(tenantRunsQuery),
71
77
  };
72
78
  const systemAdminAccess = { roles: ["SystemAdmin"] };
73
79
  r.translations({ keys: JOBS_I18N });
@@ -28,9 +28,10 @@ export declare const listQuery: import("@cosmicdrift/kumiko-types/define-handler
28
28
  desc: "desc";
29
29
  }>>;
30
30
  limit: z.ZodOptional<z.ZodNumber>;
31
+ cursor: z.ZodOptional<z.ZodString>;
31
32
  totalCount: z.ZodOptional<z.ZodBoolean>;
32
33
  }, z.core.$strip>, {
33
34
  rows: any[];
34
- nextCursor: null;
35
+ nextCursor: string | null;
35
36
  total?: number | undefined;
36
37
  }, import("@cosmicdrift/kumiko-types/event-type-map").KumikoEventTypeMap>;
@@ -1,10 +1,12 @@
1
1
  import { countWhere, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import { decodeCursor, encodeCursor } from "@cosmicdrift/kumiko-framework/db";
2
3
  import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
3
- import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
4
+ import { InternalError, ValidationError } from "@cosmicdrift/kumiko-framework/errors";
4
5
  import * as z from "zod";
5
6
  import { decryptStoredPii, mapWithConcurrency } from "../../shared/index.js";
6
7
  import { jobRunsTable } from "../job-run-table.js";
7
8
  const KMS_POOL_CONCURRENCY = 4;
9
+ const DEFAULT_PAGE_SIZE = 50;
8
10
  const jobRunStatusSchema = z.enum(["queued", "running", "completed", "failed"]);
9
11
  async function decryptRunRow(row) {
10
12
  let result = row;
@@ -19,6 +21,26 @@ async function decryptRunRow(row) {
19
21
  }
20
22
  return result;
21
23
  }
24
+ function buildListWhere(payload) {
25
+ const where = {};
26
+ if (payload.jobName)
27
+ where["jobName"] = payload.jobName;
28
+ const statuses = payload.filters?.[0]?.value ?? (payload.status ? [payload.status] : []);
29
+ if (statuses.length === 1)
30
+ where["status"] = statuses[0];
31
+ else if (statuses.length > 1)
32
+ where["status"] = { in: statuses };
33
+ return where;
34
+ }
35
+ function parseOffsetCursor(cursor) {
36
+ const offset = cursor ? Number(decodeCursor(cursor)) : 0;
37
+ if (!Number.isInteger(offset) || offset < 0) {
38
+ throw new ValidationError({
39
+ fields: [{ path: "cursor", code: "invalid_cursor", i18nKey: "jobs.errors.invalidCursor" }],
40
+ });
41
+ }
42
+ return offset;
43
+ }
22
44
  export const listQuery = defineQueryHandler({
23
45
  name: "list",
24
46
  description: "Lists job runs across all tenants newest-first, optionally filtered by job name and status; use it to check whether a job ran and whether it succeeded.",
@@ -34,7 +56,8 @@ export const listQuery = defineQueryHandler({
34
56
  .optional(),
35
57
  sort: z.enum(["jobName", "status", "startedAt", "duration"]).optional(),
36
58
  sortDirection: z.enum(["asc", "desc"]).optional(),
37
- limit: z.number().optional(),
59
+ limit: z.number().int().min(1).optional(),
60
+ cursor: z.string().optional(),
38
61
  totalCount: z.boolean().optional(),
39
62
  }),
40
63
  access: { roles: ["SystemAdmin"] },
@@ -45,27 +68,29 @@ export const listQuery = defineQueryHandler({
45
68
  });
46
69
  }
47
70
  const db = ctx.systemDb.unsafeRaw("cross-tenant job monitoring: SystemAdmin lists job runs of every tenant");
48
- const where = {};
49
- if (query.payload.jobName)
50
- where["jobName"] = query.payload.jobName;
51
- const statusFilter = query.payload.filters?.find((filter) => filter.field === "status");
52
- const statuses = statusFilter?.value ?? (query.payload.status ? [query.payload.status] : []);
53
- if (statuses.length === 1)
54
- where["status"] = statuses[0];
55
- else if (statuses.length > 1)
56
- where["status"] = { in: statuses };
71
+ const where = buildListWhere(query.payload);
57
72
  const sortColumn = query.payload.sort ?? "startedAt";
58
- const rows = await selectMany(db, jobRunsTable, where, {
59
- orderBy: { col: sortColumn, direction: query.payload.sortDirection ?? "desc" },
60
- limit: query.payload.limit ?? 50,
73
+ const limit = query.payload.limit ?? DEFAULT_PAGE_SIZE;
74
+ const offset = parseOffsetCursor(query.payload.cursor);
75
+ const direction = query.payload.sortDirection ?? "desc";
76
+ // selectMany has no OFFSET: read through the end of the page plus one probe
77
+ // row, then slice. `id` as tiebreaker keeps pages stable for non-unique sorts.
78
+ const fetched = await selectMany(db, jobRunsTable, where, {
79
+ orderBy: [
80
+ { col: sortColumn, direction },
81
+ { col: "id", direction },
82
+ ],
83
+ limit: offset + limit + 1,
61
84
  });
85
+ const hasMore = fetched.length > offset + limit;
86
+ const rows = fetched.slice(offset, offset + limit);
62
87
  // countWhere reruns the SAME `where` without the limit — `rows.length` is
63
88
  // capped at the page size and would silently undercount the total.
64
89
  const total = query.payload.totalCount === true ? await countWhere(db, jobRunsTable, where) : undefined;
65
90
  // payload/error are stored encrypted under the triggering user's DEK (#799, #2307).
66
91
  return {
67
92
  rows: await mapWithConcurrency(rows, KMS_POOL_CONCURRENCY, decryptRunRow),
68
- nextCursor: null,
93
+ nextCursor: hasMore ? encodeCursor(String(offset + limit)) : null,
69
94
  ...(total !== undefined && { total }),
70
95
  };
71
96
  },
@@ -26,7 +26,7 @@ export const tenantFailuresQuery = defineQueryHandler({
26
26
  schema: z.object({
27
27
  jobName: z.string().optional(),
28
28
  subject: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional(),
29
- limit: z.number().min(1).max(200).optional(),
29
+ limit: z.number().int().min(1).max(200).optional(),
30
30
  }),
31
31
  // Every membership rank: a failure record carries a job name and a
32
32
  // translation key, nothing a team member of the tenant may not see.
@@ -0,0 +1,17 @@
1
+ import * as z from "zod";
2
+ import { type TenantJobRunStatus } from "../tenant-job-run-table.js";
3
+ export declare const tenantRunsQuery: import("@cosmicdrift/kumiko-types/define-handler").QueryHandlerDefinition<"tenant-runs", z.ZodObject<{
4
+ jobName: z.ZodOptional<z.ZodString>;
5
+ subject: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean]>>>;
6
+ limit: z.ZodOptional<z.ZodNumber>;
7
+ }, z.core.$strip>, {
8
+ rows: {
9
+ jobName: string;
10
+ subject: Record<string, unknown> | null;
11
+ status: TenantJobRunStatus;
12
+ queuedAt: Temporal.Instant | null;
13
+ startedAt: Temporal.Instant | null;
14
+ finishedAt: Temporal.Instant | null;
15
+ }[];
16
+ nextCursor: null;
17
+ }, import("@cosmicdrift/kumiko-types/event-type-map").KumikoEventTypeMap>;
@@ -0,0 +1,72 @@
1
+ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import { access, defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
4
+ import { serializeJobSubject } from "@cosmicdrift/kumiko-framework/jobs";
5
+ import { parseJsonSafe } from "@cosmicdrift/kumiko-framework/utils";
6
+ import * as z from "zod";
7
+ import { tenantJobRunsTable } from "../tenant-job-run-table.js";
8
+ const DEFAULT_LIMIT = 50;
9
+ function isSubjectEntry(value) {
10
+ return Array.isArray(value) && typeof value[0] === "string";
11
+ }
12
+ function parseSubject(stored) {
13
+ if (stored === null)
14
+ return null;
15
+ // A corrupt subject must not fail the whole list; parseJsonSafe only
16
+ // survives a SyntaxError, so the shape needs its own check.
17
+ const parsed = parseJsonSafe(stored, null);
18
+ if (!Array.isArray(parsed))
19
+ return null;
20
+ return Object.fromEntries(parsed.filter(isSubjectEntry));
21
+ }
22
+ export const tenantRunsQuery = defineQueryHandler({
23
+ name: "tenant-runs",
24
+ description: "Lists the calling tenant's own job runs that opted in via tenantVisibleRun — per job and subject every queued or running run plus the latest completed or failed one, active runs first, then newest first; each row carries only status and queuedAt/startedAt/finishedAt, never payloads, error text or logs; the optional subject filter matches exactly: all declared subjectFields with the same JSON types as the job payload; use it to show whether work for a subject is waiting, running or done, and to tell a failed run from a finished one.",
25
+ schema: z.object({
26
+ jobName: z.string().optional(),
27
+ subject: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional(),
28
+ limit: z.number().int().min(1).max(200).optional(),
29
+ }),
30
+ // Every membership rank: a run state carries a job name, a status and
31
+ // times, nothing a team member of the tenant may not see.
32
+ access: { roles: access.roles("User", "Editor", ...access.admin) },
33
+ handler: async (query, ctx) => {
34
+ if (!ctx.systemDb) {
35
+ throw new InternalError({
36
+ message: "jobs:query:tenant-runs requires ctx.systemDb (feature must declare r.systemScope())",
37
+ });
38
+ }
39
+ // assertTenantMatch returns the same unfiltered system-mode db — the
40
+ // explicit tenantId below is the filter, assertRowsTenant the second net.
41
+ const db = ctx.systemDb.assertTenantMatch(query.user.tenantId);
42
+ const where = { tenantId: [query.user.tenantId] };
43
+ if (query.payload.jobName)
44
+ where["jobName"] = query.payload.jobName;
45
+ if (query.payload.subject)
46
+ where["subject"] = serializeJobSubject(query.payload.subject);
47
+ const limit = query.payload.limit ?? DEFAULT_LIMIT;
48
+ const newestFirst = { col: "updatedAt", direction: "desc" };
49
+ const activeRows = await selectMany(db, tenantJobRunsTable, { ...where, status: { in: ["queued", "running"] } }, { orderBy: newestFirst, limit });
50
+ const finishedRows = await selectMany(db, tenantJobRunsTable, { ...where, status: { in: ["completed", "failed"] } }, { orderBy: newestFirst, limit });
51
+ const seenFinishedKeys = new Set();
52
+ const finishedNewestPerKey = finishedRows.filter((row) => {
53
+ const key = JSON.stringify([row.jobName, row.subject]);
54
+ if (seenFinishedKeys.has(key))
55
+ return false;
56
+ seenFinishedKeys.add(key);
57
+ return true;
58
+ });
59
+ const activeFirst = [...activeRows, ...finishedNewestPerKey];
60
+ return {
61
+ rows: ctx.systemDb.assertRowsTenant(activeFirst.slice(0, limit), "tenantId").map((row) => ({
62
+ jobName: row.jobName,
63
+ subject: parseSubject(row.subject),
64
+ status: row.status,
65
+ queuedAt: row.queuedAt,
66
+ startedAt: row.startedAt,
67
+ finishedAt: row.finishedAt,
68
+ })),
69
+ nextCursor: null,
70
+ };
71
+ },
72
+ });
package/dist/jobs/i18n.js CHANGED
@@ -33,6 +33,7 @@ export const JOBS_I18N = {
33
33
  "jobs.detail.logs": { en: "Logs" },
34
34
  "jobs.detail.retry": { en: "Retry" },
35
35
  "jobs.trigger.title": { en: "Run a job" },
36
+ "jobs.errors.invalidCursor": { en: "The pagination cursor is invalid." },
36
37
  "jobs.trigger.submit": { en: "Run" },
37
38
  "jobs.errors.unknownJob": { en: "Unknown job." },
38
39
  "jobs.errors.notManual": {
@@ -5,3 +5,5 @@ export { createJobRunLogger } from "./job-run-logger.js";
5
5
  export type { JobLogLevel, JobRunStatus } from "./job-run-table.js";
6
6
  export { jobRunLogsTable, jobRunsTable } from "./job-run-table.js";
7
7
  export { tenantJobFailuresTable } from "./tenant-job-failure-table.js";
8
+ export type { TenantJobRunStatus } from "./tenant-job-run-table.js";
9
+ export { tenantJobRunsTable } from "./tenant-job-run-table.js";
@@ -3,3 +3,4 @@ export { createJobsFeature } from "./feature.js";
3
3
  export { createJobRunLogger } from "./job-run-logger.js";
4
4
  export { jobRunLogsTable, jobRunsTable } from "./job-run-table.js";
5
5
  export { tenantJobFailuresTable } from "./tenant-job-failure-table.js";
6
+ export { tenantJobRunsTable } from "./tenant-job-run-table.js";
@@ -1,10 +1,10 @@
1
- import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
1
+ import { type DbConnection } from "@cosmicdrift/kumiko-framework/db";
2
2
  import { type Registry } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import type { JobRunnerOptions } from "@cosmicdrift/kumiko-framework/jobs";
4
4
  export type JobRunLoggerOptions = {
5
5
  readonly db: DbConnection;
6
6
  readonly registry: Registry;
7
7
  };
8
- export type JobRunLoggerCallbacks = Pick<JobRunnerOptions, "onJobStart" | "onJobComplete" | "onJobFailed">;
8
+ export type JobRunLoggerCallbacks = Pick<JobRunnerOptions, "onJobStart" | "onJobComplete" | "onJobFailed" | "onJobQueued" | "onJobDropped">;
9
9
  export declare function encryptFailureError(error: string, triggeredById: string | null): Promise<string>;
10
10
  export declare function createJobRunLogger(opts: JobRunLoggerOptions): JobRunLoggerCallbacks;
@@ -1,5 +1,6 @@
1
- import { deleteMany, fetchOne, insertMany, insertOne, updateMany, } from "@cosmicdrift/kumiko-framework/bun-db";
1
+ import { deleteMany, fetchOne, insertMany, insertOnConflictDoNothing, insertOne, transaction, updateMany, upsertOnConflict, } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import { configuredPiiSubjectKms, encryptPiiValueForSubject, KeyErasedError, PII_ERASED_SENTINEL, } from "@cosmicdrift/kumiko-framework/crypto";
3
+ import { acquireNamespacedAdvisoryLock } from "@cosmicdrift/kumiko-framework/db";
3
4
  import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
4
5
  import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
5
6
  import { generateId } from "@cosmicdrift/kumiko-framework/utils";
@@ -8,6 +9,7 @@ import { runCompletedSchema, runFailedSchema, runStartedSchema } from "./events.
8
9
  import { parseJobInstant } from "./job-instant.js";
9
10
  import { jobRunLogsTable, jobRunsTable } from "./job-run-table.js";
10
11
  import { tenantJobFailuresTable } from "./tenant-job-failure-table.js";
12
+ import { tenantJobRunsTable } from "./tenant-job-run-table.js";
11
13
  // Matches PgKmsAdapter's default pool size (see tenant/handlers/*.query.ts) —
12
14
  // bounds concurrent getOrCreateDek calls so a large log batch doesn't claim
13
15
  // every connection in the pool.
@@ -92,6 +94,9 @@ async function encryptStartedPayload(payload, triggeredById) {
92
94
  return payload;
93
95
  return encryptOrSentinel(kms, triggeredById, payload, "payload");
94
96
  }
97
+ // pg_advisory_xact_lock namespace (int4): 'tjfl' as ASCII, disjoint from the
98
+ // framework's other fixed advisory-lock keys.
99
+ const TENANT_JOB_FAILURE_LOCK_NAMESPACE = 0x746a666c;
95
100
  // fw#3079 — which row the tenant-visible failure record lives in: one per
96
101
  // (tenant, job, subject), so the next outcome of the same work replaces or
97
102
  // clears it. `subject` is null for a job that declares no subjectFields.
@@ -112,14 +117,18 @@ async function recordTenantJobFailure(db, jobName, outcome) {
112
117
  // may still resolve.
113
118
  if (!where || !messageKey || outcome?.finalAttempt !== true)
114
119
  return;
115
- // ponytail: delete-then-insert instead of an upsert — two runs of the same
116
- // key finishing at once can leave two rows, and the query returns the
117
- // newest. Add a unique index + ON CONFLICT if that ever matters.
118
- await deleteMany(db, tenantJobFailuresTable, where);
119
- await insertOne(db, tenantJobFailuresTable, {
120
- ...where,
121
- messageKey,
122
- failedAt: Temporal.Now.instant(),
120
+ // Delete-then-insert under a per-key lock instead of an upsert: the two
121
+ // partial unique indexes (subject NULL vs. set) are no ON CONFLICT target.
122
+ // The lock makes the later of two racing final failures replace the earlier
123
+ // one; the indexes are the backstop against any writer that skips the lock.
124
+ await transaction(db, async (tx) => {
125
+ await acquireNamespacedAdvisoryLock(tx, TENANT_JOB_FAILURE_LOCK_NAMESPACE, JSON.stringify([where["tenantId"], where["jobName"], where["subject"]]));
126
+ await deleteMany(tx, tenantJobFailuresTable, where);
127
+ await insertOne(tx, tenantJobFailuresTable, {
128
+ ...where,
129
+ messageKey,
130
+ failedAt: Temporal.Now.instant(),
131
+ });
123
132
  });
124
133
  }
125
134
  async function clearTenantJobFailure(db, jobName, outcome) {
@@ -129,7 +138,78 @@ async function clearTenantJobFailure(db, jobName, outcome) {
129
138
  return;
130
139
  await deleteMany(db, tenantJobFailuresTable, where);
131
140
  }
141
+ function tenantRunKey(jobName, tenantId, subject) {
142
+ // skip: tenant-less run (cron resolves to SYSTEM_TENANT_ID) — no tenant-scoped query could read it
143
+ if (tenantId === SYSTEM_TENANT_ID)
144
+ return null;
145
+ return { tenantId, jobName, subject };
146
+ }
147
+ async function recordTenantRunQueued(db, jobName, bullJobId, meta) {
148
+ const key = tenantRunKey(jobName, meta.tenantId, meta.subject);
149
+ // skip: system-tenant run, no tenant-scoped query reads it
150
+ if (!key)
151
+ return;
152
+ const now = Temporal.Now.instant();
153
+ // DO NOTHING: a fast worker can report the start before this enqueue hook
154
+ // returns, and a repeated jobId (debounce, fan-out dedup) is the same job.
155
+ await insertOnConflictDoNothing(db, tenantJobRunsTable, { ...key, bullJobId, status: "queued", queuedAt: now, updatedAt: now }, { conflictKeys: ["bullJobId"] });
156
+ }
157
+ async function recordTenantRunStarted(db, jobName, bullJobId, meta) {
158
+ // skip: job did not opt in to tenantVisibleRun
159
+ if (!meta.tenantVisibleRun)
160
+ return;
161
+ const key = tenantRunKey(jobName, meta.tenantVisibleRun.tenantId, meta.tenantVisibleRun.subject);
162
+ // skip: system-tenant run, no tenant-scoped query reads it
163
+ if (!key)
164
+ return;
165
+ const now = Temporal.Now.instant();
166
+ await upsertOnConflict(db, tenantJobRunsTable, { ...key, bullJobId, status: "running", startedAt: now, updatedAt: now }, {
167
+ conflictKeys: ["bullJobId"],
168
+ update: { status: "running", startedAt: now, finishedAt: null, updatedAt: now },
169
+ });
170
+ }
171
+ async function recordTenantRunOutcome(db, jobName, bullJobId, outcome, result) {
172
+ // skip: job did not opt in to tenantVisibleRun
173
+ if (!outcome?.tenantVisibleRun)
174
+ return;
175
+ const key = tenantRunKey(jobName, outcome.tenantId, outcome.tenantVisibleRun.subject);
176
+ // skip: system-tenant run, no tenant-scoped query reads it
177
+ if (!key)
178
+ return;
179
+ const now = Temporal.Now.instant();
180
+ // A failed attempt BullMQ will retry goes back to waiting: the tenant keeps
181
+ // seeing pending work, not a failure the next attempt may still resolve.
182
+ if (result === "failed" && !outcome.finalAttempt) {
183
+ await updateMany(db, tenantJobRunsTable, { status: "queued", startedAt: null, finishedAt: null, updatedAt: now }, { bullJobId });
184
+ // skip: retry pending, the row stays active until the final attempt
185
+ return;
186
+ }
187
+ await upsertOnConflict(db, tenantJobRunsTable, { ...key, bullJobId, status: result, finishedAt: now, updatedAt: now }, { conflictKeys: ["bullJobId"], update: { status: result, finishedAt: now, updatedAt: now } });
188
+ await deleteMany(db, tenantJobRunsTable, {
189
+ ...key,
190
+ status: { in: ["completed", "failed"] },
191
+ bullJobId: { ne: bullJobId },
192
+ });
193
+ }
194
+ async function dropTenantRunQueued(db, bullJobId) {
195
+ await deleteMany(db, tenantJobRunsTable, { bullJobId, status: "queued" });
196
+ }
132
197
  const log = createFallbackLogger("job-run-logger");
198
+ // The tenant run state is an optional side write: a throw here must not
199
+ // fail the enqueue, skip the run-row write or turn a completed run into a
200
+ // BullMQ retry.
201
+ async function isolateTenantRunWrite(write, jobName, bullJobId) {
202
+ try {
203
+ await write();
204
+ }
205
+ catch (e) {
206
+ log.error("tenant job run state write failed", {
207
+ jobName,
208
+ bullJobId,
209
+ error: e instanceof Error ? e.message : String(e),
210
+ });
211
+ }
212
+ }
133
213
  // The tenant failure record is an optional side write: a throw here must not
134
214
  // skip the run-row update or turn a completed run into a BullMQ retry.
135
215
  async function isolateTenantFailureWrite(write, jobName, bullJobId) {
@@ -191,7 +271,14 @@ export function createJobRunLogger(opts) {
191
271
  return { runId, triggeredById };
192
272
  }
193
273
  return {
274
+ onJobQueued: async (jobName, bullJobId, meta) => {
275
+ await isolateTenantRunWrite(() => recordTenantRunQueued(db, jobName, bullJobId, meta), jobName, bullJobId);
276
+ },
277
+ onJobDropped: async (jobName, bullJobId) => {
278
+ await isolateTenantRunWrite(() => dropTenantRunQueued(db, bullJobId), jobName, bullJobId);
279
+ },
194
280
  onJobStart: async (jobName, bullJobId, meta) => {
281
+ await isolateTenantRunWrite(() => recordTenantRunStarted(db, jobName, bullJobId, meta), jobName, bullJobId);
195
282
  const runId = generateId();
196
283
  const triggeredById = meta.triggeredById ?? null;
197
284
  cachePut(bullJobId, runId, triggeredById);
@@ -227,6 +314,7 @@ export function createJobRunLogger(opts) {
227
314
  // clears the tenant's failure record even when the run row itself is
228
315
  // unreachable (the state-loss return below).
229
316
  await isolateTenantFailureWrite(() => clearTenantJobFailure(db, jobName, outcome), jobName, bullJobId);
317
+ await isolateTenantRunWrite(() => recordTenantRunOutcome(db, jobName, bullJobId, outcome, "completed"), jobName, bullJobId);
230
318
  const resolved = await resolveRun(bullJobId);
231
319
  // skip: state loss between start + complete (worker restart, cache
232
320
  // evicted AND DB has no matching bull_job_id). Rare edge case; we
@@ -272,6 +360,7 @@ export function createJobRunLogger(opts) {
272
360
  // Mirror of onJobComplete: recorded independently of the run row, so a
273
361
  // tenant still learns their job failed if the row is unreachable.
274
362
  await isolateTenantFailureWrite(() => recordTenantJobFailure(db, jobName, outcome), jobName, bullJobId);
363
+ await isolateTenantRunWrite(() => recordTenantRunOutcome(db, jobName, bullJobId, outcome, "failed"), jobName, bullJobId);
275
364
  const resolved = await resolveRun(bullJobId);
276
365
  // skip: same rare state-loss case as in onJobComplete — drop the
277
366
  // failure write rather than forge a run row from scratch.
@@ -6,6 +6,7 @@ export declare const jobRunEntity: import("@cosmicdrift/kumiko-types/fields").En
6
6
  readonly type: "text";
7
7
  readonly description?: string;
8
8
  readonly maxLength?: number;
9
+ readonly minLength?: number;
9
10
  readonly required?: boolean;
10
11
  readonly searchable?: boolean;
11
12
  readonly sortable?: boolean;
@@ -27,6 +28,7 @@ export declare const jobRunEntity: import("@cosmicdrift/kumiko-types/fields").En
27
28
  readonly type: "text";
28
29
  readonly description?: string;
29
30
  readonly maxLength?: number;
31
+ readonly minLength?: number;
30
32
  readonly required?: boolean;
31
33
  readonly searchable?: boolean;
32
34
  readonly sortable?: boolean;
@@ -48,6 +50,7 @@ export declare const jobRunEntity: import("@cosmicdrift/kumiko-types/fields").En
48
50
  readonly type: "text";
49
51
  readonly description?: string;
50
52
  readonly maxLength?: number;
53
+ readonly minLength?: number;
51
54
  readonly required?: boolean;
52
55
  readonly searchable?: boolean;
53
56
  readonly sortable?: boolean;
@@ -69,6 +72,7 @@ export declare const jobRunEntity: import("@cosmicdrift/kumiko-types/fields").En
69
72
  readonly type: "text";
70
73
  readonly description?: string;
71
74
  readonly maxLength?: number;
75
+ readonly minLength?: number;
72
76
  readonly required?: boolean;
73
77
  readonly searchable?: boolean;
74
78
  readonly sortable?: boolean;
@@ -90,6 +94,7 @@ export declare const jobRunEntity: import("@cosmicdrift/kumiko-types/fields").En
90
94
  readonly type: "text";
91
95
  readonly description?: string;
92
96
  readonly maxLength?: number;
97
+ readonly minLength?: number;
93
98
  readonly required?: boolean;
94
99
  readonly searchable?: boolean;
95
100
  readonly sortable?: boolean;
@@ -179,6 +184,7 @@ export declare const jobRunEntity: import("@cosmicdrift/kumiko-types/fields").En
179
184
  readonly type: "text";
180
185
  readonly description?: string;
181
186
  readonly maxLength?: number;
187
+ readonly minLength?: number;
182
188
  readonly required?: boolean;
183
189
  readonly searchable?: boolean;
184
190
  readonly sortable?: boolean;
@@ -1,5 +1,5 @@
1
1
  import { requireEntityTableMeta } from "@cosmicdrift/kumiko-framework/bun-db";
2
- import { instant, table as pgTable, serial, sql, text, uuid, } from "@cosmicdrift/kumiko-framework/db";
2
+ import { index, instant, table as pgTable, serial, sql, text, uniqueIndex, uuid, } from "@cosmicdrift/kumiko-framework/db";
3
3
  // The tenant's own view of a failed job (fw#3079). Direct-write store like
4
4
  // store_job_runs: job-run-logger.ts writes it from the BullMQ callbacks,
5
5
  // outside any dispatcher transaction.
@@ -21,5 +21,18 @@ export const tenantJobFailuresTable = pgTable("store_tenant_job_failures", {
21
21
  subject: text("subject"),
22
22
  messageKey: text("message_key").notNull(),
23
23
  failedAt: instant("failed_at").default(sql `now()`).notNull(),
24
- });
24
+ }, (t) => [
25
+ // One row per key, also the lookup for the delete before every write;
26
+ // subject NULL needs its own partial index because NULLs
27
+ // are distinct in a plain unique index.
28
+ uniqueIndex("store_tenant_job_failures_tenant_job_subject_uidx")
29
+ .on(t.tenantId, t.jobName, t.subject)
30
+ .where(sql `subject IS NOT NULL`),
31
+ uniqueIndex("store_tenant_job_failures_tenant_job_null_subject_uidx")
32
+ .on(t.tenantId, t.jobName)
33
+ .where(sql `subject IS NULL`),
34
+ // The tenant list sorts by failed_at; retention filters on it alone.
35
+ index("store_tenant_job_failures_tenant_failed_at_idx").on(t.tenantId, t.failedAt),
36
+ index("store_tenant_job_failures_failed_at_idx").on(t.failedAt),
37
+ ]);
25
38
  export const tenantJobFailuresTableMeta = requireEntityTableMeta(tenantJobFailuresTable, "tenantJobFailuresTable");
@@ -0,0 +1,4 @@
1
+ import { type EntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
2
+ export type TenantJobRunStatus = "queued" | "running" | "completed" | "failed";
3
+ export declare const tenantJobRunsTable: import("@cosmicdrift/kumiko-types/schema-table-types").SchemaTable;
4
+ export declare const tenantJobRunsTableMeta: EntityTableMeta;
@@ -0,0 +1,34 @@
1
+ import { requireEntityTableMeta } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import { index, instant, table as pgTable, serial, sql, text, uniqueIndex, uuid, } from "@cosmicdrift/kumiko-framework/db";
3
+ // The tenant's own view of a job's run state (fw#3616). Direct-write store
4
+ // like store_job_runs, written by job-run-logger.ts from the runner callbacks.
5
+ //
6
+ // store_job_runs cannot serve the tenant: it anchors every run on the system
7
+ // tenant, has no subject, and gets its row only when a run starts, so a queued
8
+ // job is invisible there. This table holds no payload, error text or logs by
9
+ // design — the tenant learns only that work is waiting, running or finished.
10
+ //
11
+ // One row per BullMQ job. Active rows (queued/running) are kept as they are;
12
+ // a finished row replaces earlier finished rows of the same (tenant, job,
13
+ // subject), so only the latest outcome stays. `subject` is the canonical JSON
14
+ // of the job's declared subjectFields, or NULL for none — stored in clear, so
15
+ // a job must not declare a PII field as its subject.
16
+ export const tenantJobRunsTable = pgTable("store_tenant_job_runs", {
17
+ id: serial("id").primaryKey(),
18
+ tenantId: uuid("tenant_id").notNull(),
19
+ jobName: text("job_name").notNull(),
20
+ subject: text("subject"),
21
+ bullJobId: text("bull_job_id").notNull(),
22
+ status: text("status").notNull(),
23
+ queuedAt: instant("queued_at"),
24
+ startedAt: instant("started_at"),
25
+ finishedAt: instant("finished_at"),
26
+ updatedAt: instant("updated_at").default(sql `now()`).notNull(),
27
+ }, (t) => [
28
+ uniqueIndex("store_tenant_job_runs_bull_job_id_unique").on(t.bullJobId),
29
+ // Finishing a run prunes older outcomes by this key; the tenant query reads by it.
30
+ index("store_tenant_job_runs_tenant_job_subject_idx").on(t.tenantId, t.jobName, t.subject),
31
+ // Stale sweep and retention filter on age alone.
32
+ index("store_tenant_job_runs_updated_at_idx").on(t.updatedAt),
33
+ ]);
34
+ export const tenantJobRunsTableMeta = requireEntityTableMeta(tenantJobRunsTable, "tenantJobRunsTable");