@cosmicdrift/kumiko-samples 0.363.0 → 0.364.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 (140) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +7 -1
  3. package/packages/bundled-features/src/audit/changes.json +6 -0
  4. package/packages/bundled-features/src/audit/handlers/list.query.ts +10 -5
  5. package/packages/bundled-features/src/auth-email-password/bootstrap.ts +29 -8
  6. package/packages/bundled-features/src/auth-email-password/changes.json +37 -0
  7. package/packages/bundled-features/src/auth-email-password/email-rate-limits.ts +16 -0
  8. package/packages/bundled-features/src/auth-email-password/handlers/change-password.write.ts +6 -2
  9. package/packages/bundled-features/src/auth-email-password/handlers/logout.write.ts +7 -2
  10. package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +2 -1
  11. package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +2 -1
  12. package/packages/bundled-features/src/auth-email-password/web/auth-gate.tsx +24 -5
  13. package/packages/bundled-features/src/billing-foundation/changes.json +36 -0
  14. package/packages/bundled-features/src/billing-foundation/consumer-protection/confirmation-mail.ts +57 -5
  15. package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-gate.ts +22 -1
  16. package/packages/bundled-features/src/billing-foundation/consumer-protection/mail-notifications.ts +4 -1
  17. package/packages/bundled-features/src/billing-foundation/consumer-protection/register-termination.ts +11 -1
  18. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-mail.ts +9 -3
  19. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-receipt-limit.ts +41 -0
  20. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-record.ts +10 -1
  21. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-request.ts +35 -19
  22. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-texts.ts +9 -0
  23. package/packages/bundled-features/src/billing-foundation/consumer-protection/withdrawal-period.ts +35 -0
  24. package/packages/bundled-features/src/billing-foundation/handlers/create-checkout-session.write.ts +11 -3
  25. package/packages/bundled-features/src/billing-foundation/handlers/process-event.write.ts +10 -4
  26. package/packages/bundled-features/src/billing-foundation/i18n.ts +3 -0
  27. package/packages/bundled-features/src/billing-foundation/subscription-tier-sync.ts +17 -5
  28. package/packages/bundled-features/src/billing-foundation/types.ts +7 -2
  29. package/packages/bundled-features/src/cap-counter/calendar-cap-usage-reservation.ts +2 -0
  30. package/packages/bundled-features/src/cap-counter/changes.json +6 -0
  31. package/packages/bundled-features/src/cap-counter/enforce-cap.ts +75 -5
  32. package/packages/bundled-features/src/cap-counter/index.ts +1 -0
  33. package/packages/bundled-features/src/cap-counter/with-cap-enforcement.ts +3 -0
  34. package/packages/bundled-features/src/channel-email/email-channel.ts +17 -4
  35. package/packages/bundled-features/src/config/changes.json +6 -0
  36. package/packages/bundled-features/src/config/handlers/set.write.ts +2 -2
  37. package/packages/bundled-features/src/config/resolver.ts +15 -12
  38. package/packages/bundled-features/src/config/write-helpers.ts +23 -0
  39. package/packages/bundled-features/src/crypto-shredding/changes.json +6 -0
  40. package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +48 -27
  41. package/packages/bundled-features/src/custom-fields/changes.json +6 -0
  42. package/packages/bundled-features/src/custom-fields/handlers/update-tenant-field.write.ts +0 -1
  43. package/packages/bundled-features/src/delivery/attachment-ref.ts +21 -0
  44. package/packages/bundled-features/src/delivery/changes.json +18 -0
  45. package/packages/bundled-features/src/delivery/channel-context.ts +31 -8
  46. package/packages/bundled-features/src/delivery/index.ts +2 -0
  47. package/packages/bundled-features/src/delivery/jobs.ts +51 -14
  48. package/packages/bundled-features/src/delivery/types.ts +7 -1
  49. package/packages/bundled-features/src/file-derivatives/handlers/public-variant.query.ts +1 -1
  50. package/packages/bundled-features/src/file-foundation/changes.json +6 -0
  51. package/packages/bundled-features/src/file-foundation/read-file-ref-content.ts +6 -3
  52. package/packages/bundled-features/src/files-tenant-data/changes.json +6 -0
  53. package/packages/bundled-features/src/files-tenant-data/hooks.ts +4 -0
  54. package/packages/bundled-features/src/inbound-mail-foundation/changes.json +6 -0
  55. package/packages/bundled-features/src/inbound-mail-foundation/oauth-access-token.ts +13 -8
  56. package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +3 -1
  57. package/packages/bundled-features/src/jobs/changes.json +19 -0
  58. package/packages/bundled-features/src/jobs/db/queries/stale-run-sweep.ts +12 -4
  59. package/packages/bundled-features/src/jobs/feature.ts +7 -4
  60. package/packages/bundled-features/src/jobs/handlers/list.query.ts +3 -1
  61. package/packages/bundled-features/src/jobs/handlers/stale-run-sweep.job.ts +5 -2
  62. package/packages/bundled-features/src/jobs/job-run-logger.ts +52 -17
  63. package/packages/bundled-features/src/jobs/tenant-job-run-table.ts +7 -2
  64. package/packages/bundled-features/src/ledger/changes.json +7 -0
  65. package/packages/bundled-features/src/ledger/entity.ts +3 -1
  66. package/packages/bundled-features/src/renderer-foundation/types.ts +2 -0
  67. package/packages/bundled-features/src/renderer-pdf/changes.json +12 -0
  68. package/packages/bundled-features/src/renderer-pdf/render-table-pdf.ts +162 -82
  69. package/packages/bundled-features/src/secrets/changes.json +6 -0
  70. package/packages/bundled-features/src/secrets/feature.ts +4 -3
  71. package/packages/bundled-features/src/secrets/secrets-context.ts +23 -3
  72. package/packages/bundled-features/src/shared/changes.json +6 -0
  73. package/packages/bundled-features/src/shared/chunked-entity-migration.ts +4 -1
  74. package/packages/bundled-features/src/shared/mail-template-body.ts +17 -0
  75. package/packages/bundled-features/src/subscription-stripe/changes.json +6 -0
  76. package/packages/bundled-features/src/subscription-stripe/plugin-methods.ts +19 -11
  77. package/packages/bundled-features/src/subscription-stripe/verify-webhook.ts +3 -1
  78. package/packages/bundled-features/src/template-resolver/README.md +4 -2
  79. package/packages/bundled-features/src/template-resolver/api.ts +3 -2
  80. package/packages/bundled-features/src/template-resolver/changes.json +68 -0
  81. package/packages/bundled-features/src/template-resolver/compile-document-body.ts +77 -13
  82. package/packages/bundled-features/src/template-resolver/constants.ts +12 -2
  83. package/packages/bundled-features/src/template-resolver/document-schema.ts +26 -4
  84. package/packages/bundled-features/src/template-resolver/feature.ts +4 -1
  85. package/packages/bundled-features/src/template-resolver/handlers/copy-locale.write.ts +31 -13
  86. package/packages/bundled-features/src/template-resolver/handlers/diff-to-system.query.ts +5 -44
  87. package/packages/bundled-features/src/template-resolver/handlers/discard-draft.write.ts +52 -0
  88. package/packages/bundled-features/src/template-resolver/handlers/export-bundle.query.ts +56 -1
  89. package/packages/bundled-features/src/template-resolver/handlers/find-by-id.query.ts +6 -3
  90. package/packages/bundled-features/src/template-resolver/handlers/layout-source.query.ts +73 -30
  91. package/packages/bundled-features/src/template-resolver/handlers/list.query.ts +14 -11
  92. package/packages/bundled-features/src/template-resolver/handlers/override-diff.ts +49 -0
  93. package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +90 -17
  94. package/packages/bundled-features/src/template-resolver/handlers/resource-upload.write.ts +1 -1
  95. package/packages/bundled-features/src/template-resolver/handlers/send-test.write.ts +39 -10
  96. package/packages/bundled-features/src/template-resolver/handlers/template-gates.ts +14 -2
  97. package/packages/bundled-features/src/template-resolver/handlers/template-write.ts +54 -4
  98. package/packages/bundled-features/src/template-resolver/handlers/tenant-overrides.query.ts +96 -14
  99. package/packages/bundled-features/src/template-resolver/handlers/toggle-status.write.ts +51 -24
  100. package/packages/bundled-features/src/template-resolver/handlers/upsert-tenant.write.ts +1 -1
  101. package/packages/bundled-features/src/template-resolver/handlers/usage.query.ts +16 -2
  102. package/packages/bundled-features/src/template-resolver/index.ts +12 -1
  103. package/packages/bundled-features/src/template-resolver/layout-appearance.ts +40 -0
  104. package/packages/bundled-features/src/template-resolver/pdf-constants.ts +12 -0
  105. package/packages/bundled-features/src/template-resolver/qualified-names.ts +4 -0
  106. package/packages/bundled-features/src/template-resolver/seeding.ts +25 -4
  107. package/packages/bundled-features/src/template-resolver/store-template-resource.ts +2 -2
  108. package/packages/bundled-features/src/template-resolver/table.ts +13 -1
  109. package/packages/bundled-features/src/template-resolver/template-draft.ts +106 -0
  110. package/packages/bundled-features/src/template-resolver/template-meta.ts +87 -24
  111. package/packages/bundled-features/src/template-resolver/template-preview.ts +159 -56
  112. package/packages/bundled-features/src/template-resolver/template-resources.ts +44 -4
  113. package/packages/bundled-features/src/template-resolver/template-usage.ts +31 -11
  114. package/packages/bundled-features/src/template-resolver/template-validation.ts +1 -0
  115. package/packages/bundled-features/src/template-resolver/web/client-plugin.tsx +12 -8
  116. package/packages/bundled-features/src/template-resolver/web/document-editor-extensions.ts +63 -10
  117. package/packages/bundled-features/src/template-resolver/web/document-editor.tsx +12 -3
  118. package/packages/bundled-features/src/template-resolver/web/document-tiptap-editor.tsx +349 -134
  119. package/packages/bundled-features/src/template-resolver/web/document-toolbar-blocks.ts +11 -0
  120. package/packages/bundled-features/src/template-resolver/web/i18n.ts +25 -4
  121. package/packages/bundled-features/src/template-resolver/web/index.ts +7 -0
  122. package/packages/bundled-features/src/template-resolver/web/placeholder-line-editor.tsx +10 -3
  123. package/packages/bundled-features/src/tier-engine/changes.json +6 -0
  124. package/packages/bundled-features/src/tier-engine/feature.ts +11 -3
  125. package/packages/bundled-features/src/user/changes.json +6 -0
  126. package/packages/bundled-features/src/user/resolve-display-names.ts +8 -15
  127. package/packages/bundled-features/src/user/seeding.ts +19 -5
  128. package/packages/bundled-features/src/user-data-rights/changes.json +6 -0
  129. package/packages/bundled-features/src/user-profile/changes.json +1 -1
  130. package/packages/bundled-features/src/waitlist/options.ts +1 -1
  131. package/packages/bundled-features/src/workflow-runner/changes.json +6 -0
  132. package/packages/bundled-features/src/workflow-runner/event-trigger.ts +2 -1
  133. package/packages/bundled-features/src/workflow-runner/handlers/resume-run.write.ts +7 -1
  134. package/samples/apps/ui-walkthrough/package.json +9 -0
  135. package/samples/apps/ui-walkthrough/playwright.config.ts +40 -7
  136. package/samples/apps/ui-walkthrough/src/app/seed-templates.ts +87 -2
  137. package/samples/apps/ui-walkthrough/src/app/template-seed-data.ts +246 -30
  138. package/samples/apps/ui-walkthrough/src/features/billing/feature.ts +4 -0
  139. package/samples/apps/ui-walkthrough/src/run-config.ts +4 -0
  140. package/samples/apps/use-all-bundled/feature-manifest.json +5 -3
@@ -140,14 +140,17 @@ export function createOAuthAccessTokenManager(
140
140
  const secretKey = inboundCredentialSecretKey(account.id);
141
141
  return transaction(deps.db, async (tx) => {
142
142
  await acquireNamespacedAdvisoryLock(tx, OAUTH_REFRESH_LOCK_NAMESPACE, lockKey(account));
143
- const stored = await deps.secrets.get(account.tenantId, secretKey);
143
+ // Secret read/write join the lock transaction: a second pool connection per refresh
144
+ // would exhaust a small pool when many accounts refresh at once.
145
+ const secrets = deps.secrets.withRunner(tx);
146
+ const stored = await secrets.get(account.tenantId, secretKey);
144
147
  if (!stored) {
145
148
  throw new InboundAuthError("no refresh token stored for account");
146
149
  }
147
150
  const currentRefreshToken = stored.reveal();
148
151
  const tokens = await refreshWithTimeout(plugin, account, currentRefreshToken);
149
152
  if (tokens.refreshToken && tokens.refreshToken !== currentRefreshToken) {
150
- await deps.secrets.set(
153
+ await secrets.set(
151
154
  account.tenantId,
152
155
  secretKey,
153
156
  tokens.refreshToken,
@@ -180,12 +183,14 @@ export function createOAuthAccessTokenManager(
180
183
  async storeRefreshToken(account, refreshToken) {
181
184
  await transaction(deps.db, async (tx) => {
182
185
  await acquireNamespacedAdvisoryLock(tx, OAUTH_REFRESH_LOCK_NAMESPACE, lockKey(account));
183
- await deps.secrets.set(
184
- account.tenantId,
185
- inboundCredentialSecretKey(account.id),
186
- refreshToken,
187
- inboundRefreshTokenSecretOptions(account.id),
188
- );
186
+ await deps.secrets
187
+ .withRunner(tx)
188
+ .set(
189
+ account.tenantId,
190
+ inboundCredentialSecretKey(account.id),
191
+ refreshToken,
192
+ inboundRefreshTokenSecretOptions(account.id),
193
+ );
189
194
  });
190
195
  cache.delete(account.id);
191
196
  },
@@ -231,8 +231,10 @@ export function createInboundMailSupervisor(
231
231
  : row;
232
232
  const decryptedAddress = decrypted["address"];
233
233
  // Falling back to row.address would hand the ciphertext to a provider.
234
+ // One undecryptable account must not stop polling of every other tenant's accounts.
234
235
  if (typeof decryptedAddress !== "string") {
235
- throw new Error(`inbound-mail: address of mail account ${row.id} did not decrypt to text`);
236
+ log(`inbound-mail: account ${row.id} skipped — address did not decrypt to text`);
237
+ continue;
236
238
  }
237
239
  records.push({
238
240
  id: row.id,
@@ -1,4 +1,23 @@
1
1
  [
2
+ {
3
+ "version": "0.364.0",
4
+ "type": "fix",
5
+ "title": "jobs list query caps the page size at 200",
6
+ "detail": "limit above 200 is rejected with a validation error."
7
+ },
8
+ {
9
+ "version": "0.364.0",
10
+ "type": "fix",
11
+ "title": "Stale-run sweep uses the job-run retention window for queued runs",
12
+ "detail": "The sweep now only fails queued runs that are older than the configured retention days instead of every queued run past the stale cutoff, so freshly queued runs of a long backlog are no longer failed."
13
+ },
14
+ {
15
+ "version": "0.364.0",
16
+ "type": "fix",
17
+ "title": "Tenant-visible job runs no longer collide across tenants, jobs and lanes",
18
+ "detail": "store_tenant_job_runs was unique on bull_job_id alone, but BullMQ ids count per queue, so a job on the api lane and one on the worker lane (or two tenants after a Redis reset) shared an id and overwrote each other's row. The unique index now covers (tenant_id, job_name, bull_job_id), every write filters by all three, and an enqueue replaces a finished row left over under the same key. onJobDropped now receives { tenantId } as a third argument.",
19
+ "migration": "Run `kumiko-schema generate` in your app to add the migration that creates store_tenant_job_runs_tenant_job_bull_job_id_unique and drops store_tenant_job_runs_bull_job_id_unique (generated as a commented DROP INDEX; uncomment it). A custom onJobDropped callback passed to the job runner now gets a third argument { tenantId }."
20
+ },
2
21
  {
3
22
  "version": "0.360.0",
4
23
  "type": "fix",
@@ -15,6 +15,7 @@
15
15
  import { deleteManyBatched, selectMany, updateMany } from "@cosmicdrift/kumiko-framework/bun-db";
16
16
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
17
17
  import { mapWithConcurrency } from "../../../shared/index.js";
18
+ import { DEFAULT_JOB_RUN_RETENTION_DAYS } from "../../handlers/retention-cleanup.job.js";
18
19
  import { encryptFailureError } from "../../job-run-logger.js";
19
20
  import { jobRunsTable } from "../../job-run-table.js";
20
21
  import { tenantJobRunsTable } from "../../tenant-job-run-table.js";
@@ -31,18 +32,24 @@ export type StaleRunSweepResult = {
31
32
  readonly tenantQueuedDropped: number;
32
33
  };
33
34
 
35
+ // A queued tenant row can legitimately wait much longer than a run takes (backlog, delay/backoff,
36
+ // sequential requeue), so it is only treated as lost once it is older than the retention window,
37
+ // not after the running-timeout.
34
38
  export async function markStaleJobRunsFailed(
35
39
  db: DbConnection,
36
40
  timeoutHours: number,
41
+ queuedRetentionDays: number = DEFAULT_JOB_RUN_RETENTION_DAYS,
37
42
  ): Promise<StaleRunSweepResult> {
38
43
  const cutoff = Temporal.Now.instant().subtract({ hours: timeoutHours });
44
+ const queuedCutoff = Temporal.Now.instant().subtract({ hours: queuedRetentionDays * 24 });
39
45
  const now = Temporal.Now.instant();
40
46
 
41
47
  // Fetch the matched rows first: `error` carries the same per-triggering-
42
48
  // user subject annotation as job-run-logger.ts's onJobFailed path
43
49
  // (personal: { of: "triggeredById" } on job-run-table.ts), so it is encrypted
44
- // under that subject's DEK. Runs are grouped by subject: one encrypt and one
45
- // updateMany per subject instead of per run.
50
+ // under the triggering user's DEK, or the system tenant key when
51
+ // triggeredById is null (whenAbsent: "tenant", fw#3654). Runs are grouped by
52
+ // that subject: one encrypt and one updateMany per subject instead of per run.
46
53
  const stale = await selectMany<{ id: string; triggeredById: string | null }>(db, jobRunsTable, {
47
54
  status: "running",
48
55
  startedAt: { lt: cutoff },
@@ -86,7 +93,8 @@ export async function markStaleJobRunsFailed(
86
93
 
87
94
  // Tenant-visible run state follows the same clock: a running row whose
88
95
  // worker died is a failure, a queued row that never started (job lost from
89
- // Redis, enqueue hook without a run) would otherwise show "waiting" forever.
96
+ // Redis, enqueue hook without a run) would otherwise show "waiting" forever;
97
+ // it is dropped once it has outlived the retention window.
90
98
  const tenantRunsFailed = await updateMany(
91
99
  db,
92
100
  tenantJobRunsTable,
@@ -96,7 +104,7 @@ export async function markStaleJobRunsFailed(
96
104
  const tenantQueuedStale = await deleteManyBatched(
97
105
  db,
98
106
  tenantJobRunsTable,
99
- { status: "queued", updatedAt: { lt: cutoff } },
107
+ { status: "queued", updatedAt: { lt: queuedCutoff } },
100
108
  { limit: STALE_DELETE_BATCH_SIZE },
101
109
  );
102
110
 
@@ -36,7 +36,8 @@ import { tenantJobRunsTableMeta } from "./tenant-job-run-table.js";
36
36
 
37
37
  export type JobsFeatureOptions = {
38
38
  // How long a job run (and its logs) stays in store_job_runs/
39
- // store_job_run_logs before the daily retention-cleanup job deletes it.
39
+ // store_job_run_logs before the daily retention-cleanup job deletes it. Also the age after
40
+ // which a tenant-visible "queued" row counts as lost and is dropped by the stale-run sweep.
40
41
  readonly retentionDays?: number;
41
42
  // How long a run can sit at status "running" before the hourly
42
43
  // stale-run-sweep job marks it "failed" (#2246 — a process kill mid-run
@@ -51,7 +52,7 @@ export function createJobsFeature(options: JobsFeatureOptions = {}): FeatureDefi
51
52
  const staleRunTimeoutHours = options.staleRunTimeoutHours ?? DEFAULT_JOB_RUN_STALE_TIMEOUT_HOURS;
52
53
  return defineFeature("jobs", (r) => {
53
54
  r.describe(
54
- "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.",
55
+ "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 `queued` rows older than `retentionDays`.",
55
56
  );
56
57
  r.uiHints({
57
58
  displayLabel: "Jobs · Audit & Operator UI",
@@ -61,7 +62,9 @@ export function createJobsFeature(options: JobsFeatureOptions = {}): FeatureDefi
61
62
  r.systemScope();
62
63
  r.storeTable(jobRunsTableMeta, {
63
64
  reason: "direct_write.job_runs",
64
- // payload/error carry personal:{of:"triggeredById"} (job-run-table.ts).
65
+ // payload/error carry personal:{of:"triggeredById"} (job-run-table.ts): encrypted under the
66
+ // triggering user's DEK, or the system tenant key when triggeredById is null
67
+ // (whenAbsent: "tenant", fw#3654).
65
68
  // job-run-logger.ts manually encrypts both before every insert/update
66
69
  // (encryptStartedPayload/encryptFailureError) since this store bypasses
67
70
  // the executor's automatic PII pipeline (#2243).
@@ -115,7 +118,7 @@ export function createJobsFeature(options: JobsFeatureOptions = {}): FeatureDefi
115
118
  r.job(
116
119
  "stale-run-sweep",
117
120
  { trigger: { cron: "0 * * * *" }, concurrency: "skip" },
118
- createStaleRunSweepJob(staleRunTimeoutHours),
121
+ createStaleRunSweepJob(staleRunTimeoutHours, retentionDays),
119
122
  );
120
123
 
121
124
  const handlers = {
@@ -8,6 +8,8 @@ import { jobRunsTable } from "../job-run-table.js";
8
8
 
9
9
  const KMS_POOL_CONCURRENCY = 4;
10
10
  const DEFAULT_PAGE_SIZE = 50;
11
+ // The offset cursor re-reads everything up to the page end, so the page size bounds that read.
12
+ const MAX_LIST_LIMIT = 200;
11
13
 
12
14
  const jobRunStatusSchema = z.enum(["queued", "running", "completed", "failed"]);
13
15
 
@@ -68,7 +70,7 @@ export const listQuery = defineQueryHandler({
68
70
  .optional(),
69
71
  sort: z.enum(["jobName", "status", "startedAt", "duration"]).optional(),
70
72
  sortDirection: z.enum(["asc", "desc"]).optional(),
71
- limit: z.number().int().min(1).optional(),
73
+ limit: z.number().int().min(1).max(MAX_LIST_LIMIT).optional(),
72
74
  cursor: z.string().optional(),
73
75
  totalCount: z.boolean().optional(),
74
76
  }),
@@ -13,7 +13,10 @@ import { markStaleJobRunsFailed } from "../db/queries/stale-run-sweep.js";
13
13
  // Configurable per-app via JobsFeatureOptions.staleRunTimeoutHours.
14
14
  export const DEFAULT_JOB_RUN_STALE_TIMEOUT_HOURS = 24;
15
15
 
16
- export function createStaleRunSweepJob(timeoutHours: number): JobHandlerFn {
16
+ export function createStaleRunSweepJob(
17
+ timeoutHours: number,
18
+ queuedRetentionDays: number,
19
+ ): JobHandlerFn {
17
20
  return async (_payload, ctx) => {
18
21
  if (!ctx.systemDb) {
19
22
  throw new InternalError({
@@ -24,7 +27,7 @@ export function createStaleRunSweepJob(timeoutHours: number): JobHandlerFn {
24
27
  const db = ctx.systemDb.unsafeRaw(
25
28
  "stale-run sweep marks stuck runs of every tenant as failed",
26
29
  ) as DbConnection; // @cast-boundary db-operator — DbRunner narrows to DbConnection, jobs never run inside a DbTx
27
- const result = await markStaleJobRunsFailed(db, timeoutHours);
30
+ const result = await markStaleJobRunsFailed(db, timeoutHours, queuedRetentionDays);
28
31
  ctx.log?.info?.(`[jobs:stale-run-sweep] complete: ${JSON.stringify(result)}`);
29
32
  };
30
33
  }
@@ -19,6 +19,7 @@ import {
19
19
  import { acquireNamespacedAdvisoryLock, type DbConnection } from "@cosmicdrift/kumiko-framework/db";
20
20
  import { type Registry, SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
21
21
  import type {
22
+ JobDroppedMeta,
22
23
  JobLogEntry,
23
24
  JobMeta,
24
25
  JobOutcomeMeta,
@@ -169,6 +170,7 @@ async function encryptStartedPayload(
169
170
  const TENANT_JOB_FAILURE_LOCK_NAMESPACE = 0x746a666c;
170
171
  // 'tjrl': serialises the finished-row write + prune per tenant run key.
171
172
  const TENANT_JOB_RUN_LOCK_NAMESPACE = 0x746a726c;
173
+ const TENANT_RUN_CONFLICT_KEYS = ["tenantId", "jobName", "bullJobId"] as const;
172
174
 
173
175
  // fw#3079 — which row the tenant-visible failure record lives in: one per
174
176
  // (tenant, job, subject), so the next outcome of the same work replaces or
@@ -227,8 +229,11 @@ async function clearTenantJobFailure(
227
229
  }
228
230
 
229
231
  // fw#3616 — tenant-visible run state. One row per BullMQ job in
230
- // store_tenant_job_runs; the writes below only ever touch rows of the same
231
- // bullJobId, except pruning older finished rows of the same key.
232
+ // store_tenant_job_runs, addressed by (tenant, job name, bullJobId): BullMQ
233
+ // ids are only unique per queue (api and worker lane both count from 1) and
234
+ // restart after a Redis reset, so the id alone must never pick a row. The
235
+ // writes below only touch rows of that triple, except pruning older finished
236
+ // rows of the same key.
232
237
  type TenantRunKey = {
233
238
  readonly tenantId: string;
234
239
  readonly jobName: string;
@@ -255,14 +260,27 @@ async function recordTenantRunQueued(
255
260
  // skip: system-tenant run, no tenant-scoped query reads it
256
261
  if (!key) return;
257
262
  const now = Temporal.Now.instant();
258
- // DO NOTHING: a fast worker can report the start before this enqueue hook
259
- // returns, and a repeated jobId (debounce, fan-out dedup) is the same job.
260
- await insertOnConflictDoNothing(
261
- db,
262
- tenantJobRunsTable,
263
- { ...key, bullJobId, status: "queued", queuedAt: now, updatedAt: now },
264
- { conflictKeys: ["bullJobId"] },
265
- );
263
+ await transaction(db, async (tx) => {
264
+ // A finished row under the same id is left over from before a Redis reset
265
+ // (ids restart at 1), not this job. Only rows that went through this hook
266
+ // (queuedAt set) qualify: a fast worker can finish before the hook runs,
267
+ // and that row is this job's own and must stay.
268
+ await deleteMany(tx, tenantJobRunsTable, {
269
+ tenantId: key.tenantId,
270
+ jobName,
271
+ bullJobId,
272
+ status: { in: ["completed", "failed"] },
273
+ queuedAt: { gt: Temporal.Instant.fromEpochMilliseconds(0) },
274
+ });
275
+ // DO NOTHING: a fast worker can report the start before this enqueue hook
276
+ // returns, and a repeated jobId (debounce, fan-out dedup) is the same job.
277
+ await insertOnConflictDoNothing(
278
+ tx,
279
+ tenantJobRunsTable,
280
+ { ...key, bullJobId, status: "queued", queuedAt: now, updatedAt: now },
281
+ { conflictKeys: [...TENANT_RUN_CONFLICT_KEYS] },
282
+ );
283
+ });
266
284
  }
267
285
 
268
286
  async function recordTenantRunStarted(
@@ -282,7 +300,7 @@ async function recordTenantRunStarted(
282
300
  tenantJobRunsTable,
283
301
  { ...key, bullJobId, status: "running", startedAt: now, updatedAt: now },
284
302
  {
285
- conflictKeys: ["bullJobId"],
303
+ conflictKeys: [...TENANT_RUN_CONFLICT_KEYS],
286
304
  update: { status: "running", startedAt: now, finishedAt: null, updatedAt: now },
287
305
  },
288
306
  );
@@ -308,7 +326,7 @@ async function recordTenantRunOutcome(
308
326
  db,
309
327
  tenantJobRunsTable,
310
328
  { status: "queued", startedAt: null, finishedAt: null, updatedAt: now },
311
- { bullJobId },
329
+ { tenantId: key.tenantId, jobName, bullJobId },
312
330
  );
313
331
  // skip: retry pending, the row stays active until the final attempt
314
332
  return;
@@ -327,7 +345,10 @@ async function recordTenantRunOutcome(
327
345
  tx,
328
346
  tenantJobRunsTable,
329
347
  { ...key, bullJobId, status: result, finishedAt: now, updatedAt: now },
330
- { conflictKeys: ["bullJobId"], update: { status: result, finishedAt: now, updatedAt: now } },
348
+ {
349
+ conflictKeys: [...TENANT_RUN_CONFLICT_KEYS],
350
+ update: { status: result, finishedAt: now, updatedAt: now },
351
+ },
331
352
  );
332
353
  await deleteMany(tx, tenantJobRunsTable, {
333
354
  ...key,
@@ -337,8 +358,18 @@ async function recordTenantRunOutcome(
337
358
  });
338
359
  }
339
360
 
340
- async function dropTenantRunQueued(db: DbConnection, bullJobId: string): Promise<void> {
341
- await deleteMany(db, tenantJobRunsTable, { bullJobId, status: "queued" });
361
+ async function dropTenantRunQueued(
362
+ db: DbConnection,
363
+ jobName: string,
364
+ bullJobId: string,
365
+ meta: JobDroppedMeta,
366
+ ): Promise<void> {
367
+ await deleteMany(db, tenantJobRunsTable, {
368
+ tenantId: meta.tenantId,
369
+ jobName,
370
+ bullJobId,
371
+ status: "queued",
372
+ });
342
373
  }
343
374
 
344
375
  const log = createFallbackLogger("job-run-logger");
@@ -458,8 +489,12 @@ export function createJobRunLogger(opts: JobRunLoggerOptions): JobRunLoggerCallb
458
489
  );
459
490
  },
460
491
 
461
- onJobDropped: async (jobName: string, bullJobId: string) => {
462
- await isolateTenantRunWrite(() => dropTenantRunQueued(db, bullJobId), jobName, bullJobId);
492
+ onJobDropped: async (jobName: string, bullJobId: string, meta: JobDroppedMeta) => {
493
+ await isolateTenantRunWrite(
494
+ () => dropTenantRunQueued(db, jobName, bullJobId, meta),
495
+ jobName,
496
+ bullJobId,
497
+ );
463
498
  },
464
499
 
465
500
  onJobStart: async (jobName: string, bullJobId: string, meta: JobMeta) => {
@@ -21,7 +21,7 @@ export type TenantJobRunStatus = "queued" | "running" | "completed" | "failed";
21
21
  // job is invisible there. This table holds no payload, error text or logs by
22
22
  // design — the tenant learns only that work is waiting, running or finished.
23
23
  //
24
- // One row per BullMQ job. Active rows (queued/running) are kept as they are;
24
+ // One row per BullMQ job and (tenant, job name). Active rows (queued/running) are kept as they are;
25
25
  // a finished row replaces earlier finished rows of the same (tenant, job,
26
26
  // subject), so only the latest outcome stays. `subject` is the canonical JSON
27
27
  // of the job's declared subjectFields, or NULL for none — stored in clear, so
@@ -41,7 +41,12 @@ export const tenantJobRunsTable = pgTable(
41
41
  updatedAt: instant("updated_at").default(sql`now()`).notNull(),
42
42
  },
43
43
  (t) => [
44
- uniqueIndex("store_tenant_job_runs_bull_job_id_unique").on(t.bullJobId),
44
+ // BullMQ ids are unique per queue only; the id alone would let two tenants collide.
45
+ uniqueIndex("store_tenant_job_runs_tenant_job_bull_job_id_unique").on(
46
+ t.tenantId,
47
+ t.jobName,
48
+ t.bullJobId,
49
+ ),
45
50
  // Finishing a run prunes older outcomes by this key; the tenant query reads by it.
46
51
  index("store_tenant_job_runs_tenant_job_subject_idx").on(t.tenantId, t.jobName, t.subject),
47
52
  // Stale sweep and retention filter on age alone.
@@ -1,4 +1,11 @@
1
1
  [
2
+ {
3
+ "version": "0.364.0",
4
+ "type": "fix",
5
+ "title": "Document the duplicate check required before the (tenant_id, source_ref) unique index",
6
+ "detail": "The unique index on read_ledger_schedules (tenant_id, source_ref) is created in place and fails on existing duplicate pairs, and a projection rebuild over duplicate events fails the same way. Replay handling for duplicate events is not part of this change.",
7
+ "migration": "Before upgrading, check each database for duplicates and clean them up: SELECT tenant_id, source_ref, count(*) FROM read_ledger_schedules WHERE source_ref IS NOT NULL GROUP BY 1, 2 HAVING count(*) > 1; Any row returned makes the unique index migration abort."
8
+ },
2
9
  {
3
10
  "version": "0.354.0",
4
11
  "type": "fix",
@@ -176,7 +176,9 @@ export const scheduleEntity = createEntity({
176
176
  },
177
177
  // Find-then-create by sourceRef races across tabs/devices; only the DB closes
178
178
  // that gap. Partial because sourceRef is optional. A new name (not the old
179
- // non-unique default) keeps the migration an in-place CREATE UNIQUE INDEX.
179
+ // non-unique default) keeps the migration an in-place CREATE UNIQUE INDEX;
180
+ // that statement, like a projection rebuild, fails on existing duplicate
181
+ // (tenant_id, source_ref) pairs, so duplicates must be cleaned up beforehand.
180
182
  indexes: [
181
183
  { columns: ["tenantId", "subjectType", "subjectId"] },
182
184
  {
@@ -62,6 +62,8 @@ export type RenderResponse = (
62
62
  html: string;
63
63
  text: string;
64
64
  previewText?: string;
65
+ fromName?: string;
66
+ replyTo?: string;
65
67
  attachments?: readonly MailAttachmentRef[];
66
68
  }
67
69
  | {
@@ -1,4 +1,16 @@
1
1
  [
2
+ {
3
+ "version": "0.364.0",
4
+ "type": "fix",
5
+ "title": "renderTablePdf rejects tables above 10000 rows, 30 columns or 5000 characters per cell",
6
+ "detail": "The table document schema now caps rows at 10000, columns at 30 and each cell string at 5000 characters. Larger input fails with invalid_payload instead of exhausting memory during layout."
7
+ },
8
+ {
9
+ "version": "0.364.0",
10
+ "type": "fix",
11
+ "title": "render-table-pdf validates input and paginates its intro",
12
+ "detail": "Table documents reject negative margins and unusable column widths, normalize CRLF and paginate the intro text and the first header row."
13
+ },
2
14
  {
3
15
  "version": "0.360.0",
4
16
  "type": "improvement",