@cosmicdrift/kumiko-samples 0.363.0 → 0.365.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 (237) hide show
  1. package/LICENSE +1 -1
  2. package/package.json +1 -1
  3. package/packages/bundled-features/package.json +7 -1
  4. package/packages/bundled-features/src/audit/changes.json +18 -0
  5. package/packages/bundled-features/src/audit/feature.ts +1 -1
  6. package/packages/bundled-features/src/audit/handlers/list.query.ts +10 -5
  7. package/packages/bundled-features/src/audit/run-escape-hatch-retention.ts +63 -11
  8. package/packages/bundled-features/src/auth-email-password/bootstrap.ts +30 -11
  9. package/packages/bundled-features/src/auth-email-password/changes.json +55 -0
  10. package/packages/bundled-features/src/auth-email-password/email-rate-limits.ts +16 -0
  11. package/packages/bundled-features/src/auth-email-password/feature.ts +4 -7
  12. package/packages/bundled-features/src/auth-email-password/handlers/change-password.write.ts +6 -2
  13. package/packages/bundled-features/src/auth-email-password/handlers/invite-accept-with-login.write.ts +2 -3
  14. package/packages/bundled-features/src/auth-email-password/handlers/invite-create.write.ts +6 -4
  15. package/packages/bundled-features/src/auth-email-password/handlers/logout.write.ts +7 -2
  16. package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +2 -1
  17. package/packages/bundled-features/src/auth-email-password/handlers/switch-tenant-mfa-gate.write.ts +2 -2
  18. package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +2 -1
  19. package/packages/bundled-features/src/auth-email-password/magic-link-mail.ts +7 -3
  20. package/packages/bundled-features/src/auth-email-password/mail-i18n.ts +10 -0
  21. package/packages/bundled-features/src/auth-email-password/web/auth-gate.tsx +24 -5
  22. package/packages/bundled-features/src/billing-foundation/changes.json +48 -0
  23. package/packages/bundled-features/src/billing-foundation/consumer-protection/confirmation-mail.ts +57 -5
  24. package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-gate.ts +22 -1
  25. package/packages/bundled-features/src/billing-foundation/consumer-protection/mail-notifications.ts +4 -1
  26. package/packages/bundled-features/src/billing-foundation/consumer-protection/register-termination.ts +11 -1
  27. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-mail.ts +9 -3
  28. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-receipt-limit.ts +41 -0
  29. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-record.ts +18 -2
  30. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-request.ts +35 -19
  31. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-texts.ts +9 -0
  32. package/packages/bundled-features/src/billing-foundation/consumer-protection/withdrawal-period.ts +35 -0
  33. package/packages/bundled-features/src/billing-foundation/handlers/create-checkout-session.write.ts +11 -3
  34. package/packages/bundled-features/src/billing-foundation/handlers/process-event.write.ts +10 -4
  35. package/packages/bundled-features/src/billing-foundation/i18n.ts +9 -0
  36. package/packages/bundled-features/src/billing-foundation/subscription-tier-sync.ts +27 -7
  37. package/packages/bundled-features/src/billing-foundation/types.ts +7 -2
  38. package/packages/bundled-features/src/cap-counter/book-cap-usage.ts +20 -8
  39. package/packages/bundled-features/src/cap-counter/calendar-cap-usage-reservation.ts +2 -0
  40. package/packages/bundled-features/src/cap-counter/cap-reservation.ts +7 -2
  41. package/packages/bundled-features/src/cap-counter/changes.json +19 -0
  42. package/packages/bundled-features/src/cap-counter/enforce-cap.ts +75 -5
  43. package/packages/bundled-features/src/cap-counter/handlers/increment-rolling.write.ts +2 -0
  44. package/packages/bundled-features/src/cap-counter/index.ts +1 -0
  45. package/packages/bundled-features/src/cap-counter/with-cap-enforcement.ts +3 -0
  46. package/packages/bundled-features/src/cap-overview/changes.json +6 -0
  47. package/packages/bundled-features/src/cap-overview/handlers/tenant-caps-list.query.ts +2 -1
  48. package/packages/bundled-features/src/cap-overview/handlers/tenant-options.query.ts +2 -1
  49. package/packages/bundled-features/src/channel-discord/discord-channel.ts +4 -2
  50. package/packages/bundled-features/src/channel-email/changes.json +18 -0
  51. package/packages/bundled-features/src/channel-email/email-channel.ts +47 -24
  52. package/packages/bundled-features/src/channel-email/smtp-transport.ts +7 -1
  53. package/packages/bundled-features/src/channel-slack/slack-channel.ts +4 -2
  54. package/packages/bundled-features/src/channel-teams/teams-channel.ts +4 -2
  55. package/packages/bundled-features/src/config/changes.json +6 -0
  56. package/packages/bundled-features/src/config/handlers/set.write.ts +2 -2
  57. package/packages/bundled-features/src/config/resolver.ts +15 -12
  58. package/packages/bundled-features/src/config/write-helpers.ts +23 -0
  59. package/packages/bundled-features/src/crypto-shredding/changes.json +6 -0
  60. package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +54 -27
  61. package/packages/bundled-features/src/custom-fields/changes.json +6 -0
  62. package/packages/bundled-features/src/custom-fields/db/queries/retention.ts +2 -2
  63. package/packages/bundled-features/src/custom-fields/handlers/update-tenant-field.write.ts +0 -1
  64. package/packages/bundled-features/src/data-retention/run-retention-cleanup.ts +4 -5
  65. package/packages/bundled-features/src/delivery/attachment-ref.ts +39 -1
  66. package/packages/bundled-features/src/delivery/changes.json +30 -0
  67. package/packages/bundled-features/src/delivery/channel-context.ts +31 -8
  68. package/packages/bundled-features/src/delivery/delivery-service.ts +93 -92
  69. package/packages/bundled-features/src/delivery/index.ts +2 -0
  70. package/packages/bundled-features/src/delivery/jobs.ts +51 -14
  71. package/packages/bundled-features/src/delivery/types.ts +7 -1
  72. package/packages/bundled-features/src/document-ingest-foundation/changes.json +6 -0
  73. package/packages/bundled-features/src/feature-toggles/changes.json +7 -0
  74. package/packages/bundled-features/src/feature-toggles/toggle-runtime.ts +26 -12
  75. package/packages/bundled-features/src/file-derivatives/handlers/public-variant.query.ts +1 -1
  76. package/packages/bundled-features/src/file-foundation/changes.json +6 -0
  77. package/packages/bundled-features/src/file-foundation/read-file-ref-content.ts +6 -3
  78. package/packages/bundled-features/src/files-tenant-data/changes.json +6 -0
  79. package/packages/bundled-features/src/files-tenant-data/hooks.ts +4 -0
  80. package/packages/bundled-features/src/files-tenant-data/index.ts +3 -1
  81. package/packages/bundled-features/src/form-draft/db/queries/owned-file-refs.ts +12 -3
  82. package/packages/bundled-features/src/form-draft/handlers/discard.write.ts +1 -1
  83. package/packages/bundled-features/src/inbound-mail-foundation/changes.json +6 -0
  84. package/packages/bundled-features/src/inbound-mail-foundation/oauth-access-token.ts +13 -8
  85. package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +3 -1
  86. package/packages/bundled-features/src/jobs/changes.json +31 -0
  87. package/packages/bundled-features/src/jobs/db/queries/stale-run-sweep.ts +12 -4
  88. package/packages/bundled-features/src/jobs/feature.ts +7 -4
  89. package/packages/bundled-features/src/jobs/handlers/list.query.ts +3 -1
  90. package/packages/bundled-features/src/jobs/handlers/stale-run-sweep.job.ts +5 -2
  91. package/packages/bundled-features/src/jobs/job-run-logger.ts +56 -18
  92. package/packages/bundled-features/src/jobs/tenant-job-run-table.ts +7 -2
  93. package/packages/bundled-features/src/ledger/changes.json +7 -0
  94. package/packages/bundled-features/src/ledger/entity.ts +3 -1
  95. package/packages/bundled-features/src/metrics/changes.json +6 -0
  96. package/packages/bundled-features/src/metrics/filters.ts +28 -12
  97. package/packages/bundled-features/src/personal-access-tokens/changes.json +6 -0
  98. package/packages/bundled-features/src/personal-access-tokens/revoke-for-user.ts +7 -8
  99. package/packages/bundled-features/src/renderer-foundation/types.ts +2 -0
  100. package/packages/bundled-features/src/renderer-pdf/changes.json +12 -0
  101. package/packages/bundled-features/src/renderer-pdf/render-table-pdf.ts +162 -82
  102. package/packages/bundled-features/src/secrets/changes.json +12 -0
  103. package/packages/bundled-features/src/secrets/feature.ts +4 -3
  104. package/packages/bundled-features/src/secrets/handlers/delete.write.ts +3 -1
  105. package/packages/bundled-features/src/secrets/secrets-context.ts +23 -3
  106. package/packages/bundled-features/src/secrets/write-gate.ts +8 -2
  107. package/packages/bundled-features/src/shared/changes.json +6 -0
  108. package/packages/bundled-features/src/shared/chunked-entity-migration.ts +4 -1
  109. package/packages/bundled-features/src/shared/index.ts +8 -0
  110. package/packages/bundled-features/src/shared/mail-template-body.ts +17 -0
  111. package/packages/bundled-features/src/shared/resource-image-formats.ts +31 -0
  112. package/packages/bundled-features/src/shared/sharp-input-limits.ts +7 -8
  113. package/packages/bundled-features/src/step-dispatcher/webhook-runner.ts +2 -2
  114. package/packages/bundled-features/src/subscription-stripe/changes.json +6 -0
  115. package/packages/bundled-features/src/subscription-stripe/plugin-methods.ts +19 -11
  116. package/packages/bundled-features/src/subscription-stripe/verify-webhook.ts +3 -1
  117. package/packages/bundled-features/src/template-resolver/README.md +4 -2
  118. package/packages/bundled-features/src/template-resolver/api.ts +3 -2
  119. package/packages/bundled-features/src/template-resolver/changes.json +149 -0
  120. package/packages/bundled-features/src/template-resolver/compile-document-body.ts +148 -18
  121. package/packages/bundled-features/src/template-resolver/compile-steps-block.ts +68 -0
  122. package/packages/bundled-features/src/template-resolver/constants.ts +23 -3
  123. package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +3 -0
  124. package/packages/bundled-features/src/template-resolver/document-schema.ts +33 -5
  125. package/packages/bundled-features/src/template-resolver/document-steps.ts +114 -0
  126. package/packages/bundled-features/src/template-resolver/feature.ts +16 -1
  127. package/packages/bundled-features/src/template-resolver/handlers/bundle-import.ts +81 -39
  128. package/packages/bundled-features/src/template-resolver/handlers/collection-set.write.ts +4 -3
  129. package/packages/bundled-features/src/template-resolver/handlers/copy-locale.write.ts +121 -53
  130. package/packages/bundled-features/src/template-resolver/handlers/diff-to-system.query.ts +6 -44
  131. package/packages/bundled-features/src/template-resolver/handlers/discard-draft.write.ts +52 -0
  132. package/packages/bundled-features/src/template-resolver/handlers/export-bundle.query.ts +56 -1
  133. package/packages/bundled-features/src/template-resolver/handlers/find-by-id.query.ts +6 -3
  134. package/packages/bundled-features/src/template-resolver/handlers/import-bundle-system.write.ts +25 -15
  135. package/packages/bundled-features/src/template-resolver/handlers/import-bundle.write.ts +12 -1
  136. package/packages/bundled-features/src/template-resolver/handlers/layout-source.query.ts +73 -30
  137. package/packages/bundled-features/src/template-resolver/handlers/list.query.ts +14 -11
  138. package/packages/bundled-features/src/template-resolver/handlers/override-diff.ts +49 -0
  139. package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +102 -19
  140. package/packages/bundled-features/src/template-resolver/handlers/resource-upload.write.ts +49 -10
  141. package/packages/bundled-features/src/template-resolver/handlers/send-test.write.ts +39 -10
  142. package/packages/bundled-features/src/template-resolver/handlers/template-gates.ts +14 -2
  143. package/packages/bundled-features/src/template-resolver/handlers/template-write.ts +54 -4
  144. package/packages/bundled-features/src/template-resolver/handlers/tenant-overrides.query.ts +96 -14
  145. package/packages/bundled-features/src/template-resolver/handlers/toggle-status.write.ts +51 -24
  146. package/packages/bundled-features/src/template-resolver/handlers/upsert-tenant.write.ts +1 -1
  147. package/packages/bundled-features/src/template-resolver/handlers/usage.query.ts +16 -2
  148. package/packages/bundled-features/src/template-resolver/handlers/validate.query.ts +4 -1
  149. package/packages/bundled-features/src/template-resolver/index.ts +18 -2
  150. package/packages/bundled-features/src/template-resolver/layout-appearance.ts +56 -0
  151. package/packages/bundled-features/src/template-resolver/mail-constants.ts +4 -0
  152. package/packages/bundled-features/src/template-resolver/pdf-constants.ts +12 -0
  153. package/packages/bundled-features/src/template-resolver/qualified-names.ts +4 -0
  154. package/packages/bundled-features/src/template-resolver/resource-image.ts +9 -16
  155. package/packages/bundled-features/src/template-resolver/seeding.ts +27 -4
  156. package/packages/bundled-features/src/template-resolver/store-template-resource.ts +390 -124
  157. package/packages/bundled-features/src/template-resolver/table.ts +13 -1
  158. package/packages/bundled-features/src/template-resolver/template-bundle.ts +4 -0
  159. package/packages/bundled-features/src/template-resolver/template-draft.ts +106 -0
  160. package/packages/bundled-features/src/template-resolver/template-meta.ts +95 -24
  161. package/packages/bundled-features/src/template-resolver/template-preview.ts +160 -57
  162. package/packages/bundled-features/src/template-resolver/template-resources.ts +3 -4
  163. package/packages/bundled-features/src/template-resolver/template-usage.ts +31 -11
  164. package/packages/bundled-features/src/template-resolver/template-validation.ts +16 -9
  165. package/packages/bundled-features/src/template-resolver/web/client-plugin.tsx +12 -8
  166. package/packages/bundled-features/src/template-resolver/web/document-editor-extensions.ts +97 -10
  167. package/packages/bundled-features/src/template-resolver/web/document-editor.tsx +15 -5
  168. package/packages/bundled-features/src/template-resolver/web/document-tiptap-editor.tsx +405 -145
  169. package/packages/bundled-features/src/template-resolver/web/document-toolbar-blocks.ts +17 -3
  170. package/packages/bundled-features/src/template-resolver/web/i18n.ts +32 -4
  171. package/packages/bundled-features/src/template-resolver/web/index.ts +8 -0
  172. package/packages/bundled-features/src/template-resolver/web/placeholder-line-editor.tsx +23 -7
  173. package/packages/bundled-features/src/tenant/changes.json +6 -0
  174. package/packages/bundled-features/src/tenant/handlers/update-member-roles.write.ts +3 -8
  175. package/packages/bundled-features/src/tenant/seeding.ts +33 -27
  176. package/packages/bundled-features/src/tenant-handover/move-entity-graph.ts +14 -3
  177. package/packages/bundled-features/src/tenant-lifecycle/changes.json +6 -0
  178. package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +9 -1
  179. package/packages/bundled-features/src/tenant-lifecycle/stages.ts +3 -2
  180. package/packages/bundled-features/src/tier-engine/changes.json +18 -0
  181. package/packages/bundled-features/src/tier-engine/constants.ts +9 -0
  182. package/packages/bundled-features/src/tier-engine/entity.ts +9 -4
  183. package/packages/bundled-features/src/tier-engine/feature.ts +11 -3
  184. package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +8 -5
  185. package/packages/bundled-features/src/tier-engine/index.ts +2 -0
  186. package/packages/bundled-features/src/user/changes.json +6 -0
  187. package/packages/bundled-features/src/user/constants.ts +1 -0
  188. package/packages/bundled-features/src/user/invited-global-roles.ts +2 -4
  189. package/packages/bundled-features/src/user/resolve-display-names.ts +8 -15
  190. package/packages/bundled-features/src/user/seeding.ts +19 -5
  191. package/packages/bundled-features/src/user-data-rights/changes.json +12 -0
  192. package/packages/bundled-features/src/user-data-rights/email-templates.ts +0 -1
  193. package/packages/bundled-features/src/user-data-rights/i18n.ts +5 -0
  194. package/packages/bundled-features/src/user-data-rights/lib/update-user-lifecycle.ts +13 -2
  195. package/packages/bundled-features/src/user-data-rights/run-forget-cleanup.ts +7 -1
  196. package/packages/bundled-features/src/user-profile/changes.json +1 -1
  197. package/packages/bundled-features/src/waitlist/changes.json +18 -0
  198. package/packages/bundled-features/src/waitlist/feature.ts +1 -1
  199. package/packages/bundled-features/src/waitlist/handlers/invite.write.ts +35 -3
  200. package/packages/bundled-features/src/waitlist/handlers/reject.write.ts +2 -17
  201. package/packages/bundled-features/src/waitlist/handlers/submit.write.ts +33 -9
  202. package/packages/bundled-features/src/waitlist/i18n.ts +9 -0
  203. package/packages/bundled-features/src/waitlist/lib.ts +20 -0
  204. package/packages/bundled-features/src/waitlist/options.ts +1 -1
  205. package/packages/bundled-features/src/workflow-runner/changes.json +6 -0
  206. package/packages/bundled-features/src/workflow-runner/event-trigger.ts +2 -1
  207. package/packages/bundled-features/src/workflow-runner/feature.ts +1 -1
  208. package/packages/bundled-features/src/workflow-runner/handlers/resume-run.write.ts +7 -1
  209. package/samples/apps/ui-walkthrough/package.json +9 -0
  210. package/samples/apps/ui-walkthrough/playwright.config.ts +40 -7
  211. package/samples/apps/ui-walkthrough/src/app/seed-templates.ts +87 -2
  212. package/samples/apps/ui-walkthrough/src/app/template-seed-data.ts +246 -30
  213. package/samples/apps/ui-walkthrough/src/features/billing/feature.ts +4 -0
  214. package/samples/apps/ui-walkthrough/src/run-config.ts +18 -1
  215. package/samples/apps/use-all-bundled/README.md +3 -13
  216. package/samples/apps/use-all-bundled/feature-manifest.json +7 -5
  217. package/samples/apps/use-all-bundled/scripts/check-coverage.ts +34 -118
  218. package/samples/apps/use-all-bundled/src/app/notes-feature.ts +1 -1
  219. package/samples/apps/use-all-bundled/src/run-config.ts +0 -3
  220. package/samples/recipes/auth-mfa/README.md +2 -1
  221. package/samples/recipes/auth-mfa/src/feature.ts +1 -0
  222. package/samples/recipes/custom-handlers/README.md +4 -3
  223. package/samples/recipes/custom-handlers/src/feature.ts +21 -12
  224. package/samples/recipes/dashboard-stats/README.md +38 -0
  225. package/samples/recipes/dashboard-stats/package.json +8 -0
  226. package/samples/recipes/dashboard-stats/src/feature.ts +104 -0
  227. package/samples/recipes/legal-pages/README.md +2 -2
  228. package/samples/recipes/managed-pages/README.md +2 -2
  229. package/samples/recipes/pdf-audit-export/README.md +2 -1
  230. package/samples/recipes/pdf-audit-export/package.json +1 -1
  231. package/samples/recipes/pdf-audit-export/src/feature.ts +68 -48
  232. package/samples/recipes/workflow-engine/src/db/queries/suspended-runs.ts +2 -2
  233. package/samples/apps/use-all-bundled/schema/generate.ts +0 -617
  234. package/samples/apps/use-all-bundled/schema/migrations/meta/0000_snapshot.json +0 -3055
  235. package/samples/apps/use-all-bundled/schema/migrations/meta/_journal.json +0 -13
  236. package/samples/apps/use-all-bundled/schema/schema.generated.ts +0 -94
  237. package/samples/apps/use-all-bundled/schema/schema.ts +0 -9
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.364.0",
4
+ "type": "fix",
5
+ "title": "readFileRefContent reads handed-over files; fileContextForTenant takes the context tenant",
6
+ "detail": "readFileRefContent, readTemplateResource and the public variant query read bytes through FileContext.refFromFileRef, so a fileRef moved by a tenant handover is readable again. fileContextForTenant gains a `contextTenantId` argument: without a provider resolver it returns undefined for any other tenant instead of the context's files, which are bound to a different key space."
7
+ },
2
8
  {
3
9
  "version": "0.292.0",
4
10
  "type": "improvement",
@@ -22,16 +22,19 @@ type FileRefRow = {
22
22
  readonly size: number | bigint | string;
23
23
  };
24
24
 
25
- // Tenant-bound on purpose: the FileContext of the current request only reaches its own key space.
25
+ // Tenant-bound on purpose: without a resolver `ctx.files` only reaches the storage of the
26
+ // context's own tenant (`contextTenantId`), so a request for another tenant's files is
27
+ // undefined instead of silently reading through the wrong tenant's storage.
26
28
  export function fileContextForTenant(
27
29
  ctx: {
28
30
  readonly _fileProviderResolver?: FileProviderResolver | undefined;
29
31
  readonly files?: FileContext | undefined;
30
32
  },
33
+ contextTenantId: string,
31
34
  tenantId: string,
32
35
  ): FileContext | undefined {
33
36
  const resolver = ctx._fileProviderResolver;
34
- if (resolver === undefined) return ctx.files;
37
+ if (resolver === undefined) return tenantId === contextTenantId ? ctx.files : undefined;
35
38
  return fileContextFromResolver(resolver, tenantId);
36
39
  }
37
40
 
@@ -81,6 +84,6 @@ export async function readFileRefContent(args: {
81
84
  if (files === undefined) {
82
85
  throw new Error("readFileRefContent: no file storage is available for the file's tenant");
83
86
  }
84
- const content = await files.ref(row.storageKey).read();
87
+ const content = await files.refFromFileRef(row).read();
85
88
  return { content, contentType: row.mimeType, tenantId: row.tenantId, size };
86
89
  }
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.364.0",
4
+ "type": "fix",
5
+ "title": "Tenant destroy hooks make progress even when the deadline already passed",
6
+ "detail": "The chunked migration takes a minBatches option and the file destroy hooks process one chunk per tick, so a slow list call no longer makes every run time out empty."
7
+ },
2
8
  {
3
9
  "version": "0.354.0",
4
10
  "type": "fix",
@@ -129,6 +129,7 @@ export const fileRefTenantDestroyHook: TenantDataDestroyHook = async (ctx) => {
129
129
  },
130
130
  maxFailures: 1,
131
131
  deadlineAt: ctx.deadlineAt ?? Number.POSITIVE_INFINITY,
132
+ minBatches: 1,
132
133
  onRowError: (_row, err) => {
133
134
  firstFailure = err instanceof Error ? err.message : String(err);
134
135
  },
@@ -222,6 +223,9 @@ export const fileRefStorageDestroyHook: StorageProviderDestroyTenantHook = async
222
223
  },
223
224
  maxFailures: 1,
224
225
  deadlineAt: ctx.deadlineAt ?? Number.POSITIVE_INFINITY,
226
+ // list() and the survivor lookup above can use up the whole budget; one chunk per tick
227
+ // still deletes, so the stage never reports "no progress".
228
+ minBatches: 1,
225
229
  onRowError: (_key, err) => {
226
230
  firstFailure = err instanceof Error ? err.message : String(err);
227
231
  },
@@ -27,8 +27,10 @@ import { fileRefStorageDestroyHook, fileRefTenantDestroyHook } from "./hooks.js"
27
27
  const SWEEP_ORPHANED_DERIVATIVES_ESCAPE_HATCH_REASON =
28
28
  "iterates every tenant and checks fileRef owners per tenant";
29
29
 
30
+ export const FILES_TENANT_DATA_FEATURE = "files-tenant-data" as const;
31
+
30
32
  export function createFilesTenantDataFeature(): FeatureDefinition {
31
- return defineFeature("files-tenant-data", (r) => {
33
+ return defineFeature(FILES_TENANT_DATA_FEATURE, (r) => {
32
34
  r.describe(
33
35
  "GDPR coverage for the `files` feature's `fileRef` entity: tenant-destroy row purge (EXT_TENANT_DATA) and full storage-prefix binary wipe (EXT_STORAGE_PROVIDER), plus a manual backfill/GC job that sweeps derivatives orphaned by a forget/tenant-destroy that ran before #2461 wired binary cleanup into those flows. Requires `tenant-lifecycle`, optionalRequires `files`.",
34
36
  );
@@ -1,5 +1,8 @@
1
- import { unsafeReadRetrying } from "@cosmicdrift/kumiko-framework/bun-db";
2
- import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
1
+ import {
2
+ instantToTimestamptzParam,
3
+ unsafeReadRetrying,
4
+ } from "@cosmicdrift/kumiko-framework/bun-db";
5
+ import { type DbRunner, toPostgresArrayLiteral } from "@cosmicdrift/kumiko-framework/db";
3
6
  import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
4
7
  import type { Temporal } from "@cosmicdrift/kumiko-types/temporal";
5
8
 
@@ -68,7 +71,13 @@ export async function filterOwnedFileRefs(
68
71
  WHERE "tenant_id" = $1 AND "inserted_by_id" = $2
69
72
  AND "storage_key" = ANY($3::text[]) AND "is_deleted" = false
70
73
  AND ($5::boolean OR "inserted_at" > $4::timestamptz)`,
71
- [tenantId, ownerId, candidateKeys, draftInsertedAt.toString(), isCreateMode],
74
+ [
75
+ tenantId,
76
+ ownerId,
77
+ toPostgresArrayLiteral(candidateKeys),
78
+ instantToTimestamptzParam(draftInsertedAt),
79
+ isCreateMode,
80
+ ],
72
81
  );
73
82
  return rows.map((row) => ({ id: row.id, storageKey: row.storage_key }));
74
83
  }
@@ -93,7 +93,7 @@ export const discardDraftWrite = defineWriteHandler({
93
93
  await releaseDraftFileRefs(
94
94
  keys,
95
95
  async (key) => {
96
- await files.ref(key).delete();
96
+ await files.refFromFileRef({ storageKey: key }).delete();
97
97
  },
98
98
  log,
99
99
  );
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.364.0",
4
+ "type": "fix",
5
+ "title": "Watch supervisor skips and logs accounts whose address does not decrypt",
6
+ "detail": "Previously a single such account made the whole account snapshot throw."
7
+ },
2
8
  {
3
9
  "version": "0.340.0",
4
10
  "type": "improvement",
@@ -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,35 @@
1
1
  [
2
+ {
3
+ "version": "0.365.0",
4
+ "type": "fix",
5
+ "title": "Finished tenant run reads its timestamp under the lock",
6
+ "detail": "The run that finishes last in lock order now carries the latest finishedAt, so a stale timestamp can no longer make the newest run the row that gets pruned."
7
+ },
8
+ {
9
+ "version": "0.365.0",
10
+ "type": "fix",
11
+ "title": "Older migration notes that say \"kumiko migrate generate\" mean kumiko-schema generate",
12
+ "detail": "Migration notes of the delivery, jobs, metrics and sessions features name a command that does not exist, `kumiko migrate generate`. Run `kumiko-schema generate <name>` and then `kumiko-schema apply` instead. The published notes stay as they were."
13
+ },
14
+ {
15
+ "version": "0.364.0",
16
+ "type": "fix",
17
+ "title": "jobs list query caps the page size at 200",
18
+ "detail": "limit above 200 is rejected with a validation error."
19
+ },
20
+ {
21
+ "version": "0.364.0",
22
+ "type": "fix",
23
+ "title": "Stale-run sweep uses the job-run retention window for queued runs",
24
+ "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."
25
+ },
26
+ {
27
+ "version": "0.364.0",
28
+ "type": "fix",
29
+ "title": "Tenant-visible job runs no longer collide across tenants, jobs and lanes",
30
+ "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.",
31
+ "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 }."
32
+ },
2
33
  {
3
34
  "version": "0.360.0",
4
35
  "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;
@@ -323,11 +341,17 @@ async function recordTenantRunOutcome(
323
341
  TENANT_JOB_RUN_LOCK_NAMESPACE,
324
342
  JSON.stringify([key.tenantId, key.jobName, key.subject]),
325
343
  );
344
+ // Read under the lock: the run that finishes last in lock order must also carry the
345
+ // latest finishedAt, otherwise a stale timestamp makes it the row that gets pruned.
346
+ const finishedAt = Temporal.Now.instant();
326
347
  await upsertOnConflict(
327
348
  tx,
328
349
  tenantJobRunsTable,
329
- { ...key, bullJobId, status: result, finishedAt: now, updatedAt: now },
330
- { conflictKeys: ["bullJobId"], update: { status: result, finishedAt: now, updatedAt: now } },
350
+ { ...key, bullJobId, status: result, finishedAt, updatedAt: finishedAt },
351
+ {
352
+ conflictKeys: [...TENANT_RUN_CONFLICT_KEYS],
353
+ update: { status: result, finishedAt, updatedAt: finishedAt },
354
+ },
331
355
  );
332
356
  await deleteMany(tx, tenantJobRunsTable, {
333
357
  ...key,
@@ -337,8 +361,18 @@ async function recordTenantRunOutcome(
337
361
  });
338
362
  }
339
363
 
340
- async function dropTenantRunQueued(db: DbConnection, bullJobId: string): Promise<void> {
341
- await deleteMany(db, tenantJobRunsTable, { bullJobId, status: "queued" });
364
+ async function dropTenantRunQueued(
365
+ db: DbConnection,
366
+ jobName: string,
367
+ bullJobId: string,
368
+ meta: JobDroppedMeta,
369
+ ): Promise<void> {
370
+ await deleteMany(db, tenantJobRunsTable, {
371
+ tenantId: meta.tenantId,
372
+ jobName,
373
+ bullJobId,
374
+ status: "queued",
375
+ });
342
376
  }
343
377
 
344
378
  const log = createFallbackLogger("job-run-logger");
@@ -458,8 +492,12 @@ export function createJobRunLogger(opts: JobRunLoggerOptions): JobRunLoggerCallb
458
492
  );
459
493
  },
460
494
 
461
- onJobDropped: async (jobName: string, bullJobId: string) => {
462
- await isolateTenantRunWrite(() => dropTenantRunQueued(db, bullJobId), jobName, bullJobId);
495
+ onJobDropped: async (jobName: string, bullJobId: string, meta: JobDroppedMeta) => {
496
+ await isolateTenantRunWrite(
497
+ () => dropTenantRunQueued(db, jobName, bullJobId, meta),
498
+ jobName,
499
+ bullJobId,
500
+ );
463
501
  },
464
502
 
465
503
  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
  {
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.365.0",
4
+ "type": "fix",
5
+ "title": "Metric integer filters reject fractions and out-of-range values; feature-name constants exported",
6
+ "detail": "Filters on smallint, integer and bigint columns now fail validation for decimals and values outside the column range instead of a Postgres cast error. FILES_FEATURE and FILES_TENANT_DATA_FEATURE are exported and reused by compose-features."
7
+ },
2
8
  {
3
9
  "version": "0.349.0",
4
10
  "type": "improvement",
@@ -7,26 +7,34 @@ export const RESERVED_PAYLOAD_KEYS: readonly string[] = ["range", "timeZone", "t
7
7
 
8
8
  // Dashboard select options always send strings.
9
9
  const NUMERIC_STRING = /^-?\d+(\.\d+)?$/;
10
+ const INTEGER_STRING = /^-?\d+$/;
10
11
  const MAX_FILTER_STRING_LENGTH = 200;
11
- const NUMERIC_PG_TYPES: ReadonlySet<string> = new Set([
12
- "integer",
13
- "int4",
14
- "smallint",
15
- "int2",
16
- "bigint",
17
- "serial",
18
- "bigserial",
19
- "double precision",
20
- ]);
12
+ const INTEGER_RANGES = {
13
+ smallint: { min: -32_768, max: 32_767 },
14
+ integer: { min: -2_147_483_648, max: 2_147_483_647 },
15
+ // bigint is bound as a JS number, so only the exactly representable range is accepted.
16
+ bigint: { min: Number.MIN_SAFE_INTEGER, max: Number.MAX_SAFE_INTEGER },
17
+ } as const;
18
+ const PG_INTEGER_KINDS: Readonly<Record<string, keyof typeof INTEGER_RANGES>> = {
19
+ smallint: "smallint",
20
+ int2: "smallint",
21
+ integer: "integer",
22
+ int4: "integer",
23
+ serial: "integer",
24
+ bigint: "bigint",
25
+ bigserial: "bigint",
26
+ };
21
27
 
22
- export type FilterValueKind = "uuid" | "number" | "string";
28
+ export type FilterValueKind = "uuid" | "number" | "string" | keyof typeof INTEGER_RANGES;
23
29
 
24
30
  /** Undefined = the column type cannot be filtered by a payload value without risking a Postgres cast error. */
25
31
  export function filterValueKindOf(pgType: string | undefined): FilterValueKind | undefined {
26
32
  if (pgType === undefined) return undefined;
27
33
  if (pgType === "uuid") return "uuid";
28
34
  if (pgType === "text") return "string";
29
- if (NUMERIC_PG_TYPES.has(pgType) || pgType.startsWith("numeric")) return "number";
35
+ const integerKind = PG_INTEGER_KINDS[pgType];
36
+ if (integerKind !== undefined) return integerKind;
37
+ if (pgType === "double precision" || pgType.startsWith("numeric")) return "number";
30
38
  return undefined;
31
39
  }
32
40
 
@@ -44,8 +52,16 @@ export function metricFilterEntries(metric: MetricDefinition): readonly MetricFi
44
52
  );
45
53
  }
46
54
 
55
+ function integerValueSchema(range: { readonly min: number; readonly max: number }): z.ZodType {
56
+ const bounded = z.number().int().min(range.min).max(range.max);
57
+ return z.union([bounded, z.string().regex(INTEGER_STRING).transform(Number).pipe(bounded)]);
58
+ }
59
+
47
60
  function filterValueSchema(kind: FilterValueKind): z.ZodType {
48
61
  if (kind === "uuid") return z.uuid();
62
+ if (kind === "smallint" || kind === "integer" || kind === "bigint") {
63
+ return integerValueSchema(INTEGER_RANGES[kind]);
64
+ }
49
65
  if (kind === "number")
50
66
  return z.union([z.number(), z.string().regex(NUMERIC_STRING).transform(Number)]);
51
67
  return z.string().min(1).max(MAX_FILTER_STRING_LENGTH);
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.365.0",
4
+ "type": "fix",
5
+ "title": "revokeAllPatTokensForUser detects begin() the same way as the transaction helpers",
6
+ "detail": "The transaction probe looked for begin() directly on the handle, so a pool wrapped by a legacy Drizzle connection fell back to a non-atomic update plus append. It now uses runnerCanBeginTransaction from bun-db, which unwraps the client first."
7
+ },
2
8
  {
3
9
  "version": "0.347.0",
4
10
  "type": "improvement",