@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
@@ -3,8 +3,9 @@ import { createTenantDb, type DbConnection, type TenantDb } from "@cosmicdrift/k
3
3
  import type { ConfigAccessor, Registry, TenantId } from "@cosmicdrift/kumiko-framework/engine";
4
4
  import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
5
5
  import type { FileContext } from "@cosmicdrift/kumiko-framework/files";
6
+ import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
6
7
  import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
7
- import { readFileRefContent } from "../file-foundation/index.js";
8
+ import { type FileRefContent, readFileRefContent } from "../file-foundation/index.js";
8
9
  import {
9
10
  collectRendererPlugins,
10
11
  createRendererFoundationApi,
@@ -40,7 +41,10 @@ export function createTemplateMailRenderer(
40
41
  html: response.html,
41
42
  text: response.text,
42
43
  ...(response.previewText !== undefined && { previewText: response.previewText }),
44
+ ...(response.fromName !== undefined && { fromName: response.fromName }),
45
+ ...(response.replyTo !== undefined && { replyTo: response.replyTo }),
43
46
  ...(response.attachments !== undefined && { attachments: response.attachments }),
47
+ ...(response.layout !== undefined && { layout: response.layout }),
44
48
  };
45
49
  };
46
50
  }
@@ -82,6 +86,22 @@ function createPdfRenderer(
82
86
  };
83
87
  }
84
88
 
89
+ // A decorative inline image (logo) that cannot be read must not fail the whole mail.
90
+ async function readInlineImageOrNull(
91
+ fileRefId: string,
92
+ read: () => Promise<FileRefContent | null>,
93
+ ): Promise<FileRefContent | null> {
94
+ try {
95
+ return await read();
96
+ } catch (err) {
97
+ createFallbackLogger("delivery").warn("inline image could not be read, sending without it", {
98
+ fileRefId,
99
+ errorName: err instanceof Error ? err.name : typeof err,
100
+ });
101
+ return null;
102
+ }
103
+ }
104
+
85
105
  // Inline images and file attachments the renderer took from a template may come from the system
86
106
  // tenant (system template or tenant override of one). A file ref from notify data stays limited to
87
107
  // the job's own tenant, since a caller picks it. Inline content that is not an image is dropped, so
@@ -96,13 +116,16 @@ export function createEmailAttachmentReader(
96
116
  const renderPdf = createPdfRenderer(db, registry, tenantId, config, filesForTenant);
97
117
  return async (ref: EmailAttachmentRef, origin: AttachmentOrigin = "message") => {
98
118
  if (ref.kind === "rendered-pdf") return renderPdf(ref);
99
- const found = await readFileRefContent({
100
- db,
101
- filesForTenant,
102
- fileRefId: ref.fileRefId,
103
- allowedTenantIds:
104
- ref.kind === "file" && origin === "message" ? [tenantId] : [tenantId, SYSTEM_TENANT_ID],
105
- });
119
+ const readRef = () =>
120
+ readFileRefContent({
121
+ db,
122
+ filesForTenant,
123
+ fileRefId: ref.fileRefId,
124
+ allowedTenantIds:
125
+ ref.kind === "file" && origin === "message" ? [tenantId] : [tenantId, SYSTEM_TENANT_ID],
126
+ });
127
+ const found =
128
+ ref.kind === "inline" ? await readInlineImageOrNull(ref.fileRefId, readRef) : await readRef();
106
129
  if (found === null) return null;
107
130
  if (ref.kind === "inline" && !found.contentType.startsWith(INLINE_IMAGE_CONTENT_TYPE_PREFIX)) {
108
131
  return null;
@@ -205,14 +205,23 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
205
205
 
206
206
  // A key that guarded nothing must not turn the caller's retry into a skipped duplicate.
207
207
  // Partial success keeps it: a retry would re-send to those who got it.
208
+ // A failing release is logged and swallowed: it must neither replace the dispatch error the
209
+ // caller retries on nor fail a notify() that already dispatched.
208
210
  async function releaseIdempotencyIfNothingDelivered(
209
211
  tenantId: TenantId,
210
212
  key: string | undefined,
211
213
  run: NotifyRun,
214
+ notificationType: string,
212
215
  ): Promise<void> {
213
216
  // skip: no key claimed, or a recipient already got the notification
214
217
  if (!key || run.deliveries.some(isDeliveredOrQueued)) return;
215
- await idemRedis?.del(deliveryIdempotencyRedisKey(tenantId, key));
218
+ try {
219
+ await idemRedis?.del(deliveryIdempotencyRedisKey(tenantId, key));
220
+ } catch (releaseErr) {
221
+ logError.warn(`idempotency key release failed: ${redactedMessageOf(releaseErr)}`, {
222
+ notificationType,
223
+ });
224
+ }
216
225
  }
217
226
 
218
227
  // Only a single-channel route lets the queued job free the key on terminal failure
@@ -508,29 +517,53 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
508
517
 
509
518
  const recipientChannelKey = (userId: string, channelName: string) => `${userId}|${channelName}`;
510
519
 
511
- async function isChannelDeliverable(
520
+ type ChannelGate = "open" | "channel_disabled" | "preference_disabled" | "rate_limited";
521
+ type PrefetchedRecipients = {
522
+ readonly addresses: ReadonlyMap<string, string | null>;
523
+ readonly gates: ReadonlyMap<string, ChannelGate>;
524
+ };
525
+
526
+ // Kill switch, then the user's preference, then the rate limit — the order deliverToUser reports
527
+ // skips in. The rate limit consumes a token, so each recipient/channel pair is evaluated once.
528
+ async function evaluateChannelGate(
512
529
  run: NotifyRun,
513
530
  userId: string,
514
531
  tenantId: TenantId,
515
532
  notificationType: string,
516
533
  channelName: string,
517
- ): Promise<boolean> {
518
- if (isChannelKilled && (await isChannelKilled(tenantId, channelName))) return false;
519
- return isChannelEnabled(run, userId, tenantId, notificationType, channelName);
534
+ priority: NotifyPriority,
535
+ ): Promise<ChannelGate> {
536
+ if (isChannelKilled && (await isChannelKilled(tenantId, channelName))) {
537
+ return "channel_disabled";
538
+ }
539
+ // Critical priority skips the preference check
540
+ if (
541
+ priority !== "critical" &&
542
+ !(await isChannelEnabled(run, userId, tenantId, notificationType, channelName))
543
+ ) {
544
+ return "preference_disabled";
545
+ }
546
+ if (rateLimit && !(await checkRateLimit(rateLimit, tenantId, channelName))) {
547
+ return "rate_limited";
548
+ }
549
+ return "open";
520
550
  }
521
551
 
522
552
  // Resolves every user's channel addresses up front so the opt-out lookup can
523
- // cover them all at once. A resolve that throws stays out of the map: the
524
- // delivery loop resolves it again and reports channel_error as before.
525
- // Channels the kill switch or the user's preferences disable are never resolved
526
- // here: resolve may have side effects and deliverToUser skips those channels.
527
- async function prefetchUserAddresses(
553
+ // cover them all at once. A resolve that throws stays out of the address map:
554
+ // the delivery loop resolves it again and reports channel_error as before.
555
+ // Channels that are gated (kill switch, preferences, rate limit) are never resolved
556
+ // here: resolve may have side effects and deliverToUser skips those channels. The
557
+ // gate verdict is kept so deliverToUser does not query it a second time.
558
+ async function prefetchRecipients(
528
559
  run: NotifyRun,
529
560
  userIds: readonly string[],
530
561
  tenantId: TenantId,
531
562
  notificationType: string,
532
- ): Promise<Map<string, string | null>> {
563
+ priority: NotifyPriority,
564
+ ): Promise<PrefetchedRecipients> {
533
565
  const addresses = new Map<string, string | null>();
566
+ const gates = new Map<string, ChannelGate>();
534
567
  const channelCtx = buildChannelContext(
535
568
  run.db,
536
569
  registry,
@@ -543,22 +576,25 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
543
576
  for (const userId of userIds) {
544
577
  for (const channel of channels) {
545
578
  if (!channel.resolve) continue;
579
+ const key = recipientChannelKey(userId, channel.name);
546
580
  try {
547
- if (
548
- !(await isChannelDeliverable(run, userId, tenantId, notificationType, channel.name))
549
- ) {
550
- continue;
551
- }
552
- addresses.set(
553
- recipientChannelKey(userId, channel.name),
554
- await channel.resolve(userId, channelCtx),
581
+ const gate = await evaluateChannelGate(
582
+ run,
583
+ userId,
584
+ tenantId,
585
+ notificationType,
586
+ channel.name,
587
+ priority,
555
588
  );
589
+ gates.set(key, gate);
590
+ if (gate !== "open") continue;
591
+ addresses.set(key, await channel.resolve(userId, channelCtx));
556
592
  } catch {
557
593
  // skip: re-resolved (and logged) by deliverToUser
558
594
  }
559
595
  }
560
596
  }
561
- return addresses;
597
+ return { addresses, gates };
562
598
  }
563
599
 
564
600
  // Check if user has disabled this notification+channel combo.
@@ -605,7 +641,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
605
641
  tenantId: TenantId,
606
642
  priority: NotifyPriority,
607
643
  run: NotifyRun,
608
- prefetchedAddresses: ReadonlyMap<string, string | null>,
644
+ prefetched: PrefetchedRecipients,
609
645
  suppressed: ReadonlySet<AddressOptOutKey>,
610
646
  ): Promise<void> {
611
647
  const channelCtx = buildChannelContext(
@@ -624,70 +660,35 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
624
660
 
625
661
  const message = buildMessage(notificationType, data, channel.name, run.locale);
626
662
 
627
- // Kill switch: tenant admin disabled this channel entirely
628
- if (isChannelKilled) {
629
- const killed = await isChannelKilled(tenantId, channel.name);
630
- if (killed) {
631
- await logDelivery(run, {
632
- tenantId,
633
- notificationType,
634
- channel: channel.name,
635
- recipientId: userId,
636
- recipientAddress: null,
637
- status: "skipped",
638
- error: "channel_disabled",
639
- priority,
640
- });
641
- continue;
642
- }
643
- }
644
-
645
- // Check preferences (critical priority skips preference check)
646
- if (priority !== "critical") {
647
- const enabled = await isChannelEnabled(
663
+ const prefetchKey = recipientChannelKey(userId, channel.name);
664
+ const gate =
665
+ prefetched.gates.get(prefetchKey) ??
666
+ (await evaluateChannelGate(
648
667
  run,
649
668
  userId,
650
669
  tenantId,
651
670
  notificationType,
652
671
  channel.name,
653
- );
654
- if (!enabled) {
655
- await logDelivery(run, {
656
- tenantId,
657
- notificationType,
658
- channel: channel.name,
659
- recipientId: userId,
660
- recipientAddress: null,
661
- status: "skipped",
662
- error: "preference_disabled",
663
- priority,
664
- });
665
- continue;
666
- }
667
- }
668
-
669
- // Rate limiting
670
- if (rateLimit) {
671
- const allowed = await checkRateLimit(rateLimit, tenantId, channel.name);
672
- if (!allowed) {
673
- await logDelivery(run, {
674
- tenantId,
675
- notificationType,
676
- channel: channel.name,
677
- recipientId: userId,
678
- recipientAddress: null,
679
- status: "skipped",
680
- error: "rate_limited",
681
- priority,
682
- });
683
- continue;
684
- }
672
+ priority,
673
+ ));
674
+ if (gate !== "open") {
675
+ await logDelivery(run, {
676
+ tenantId,
677
+ notificationType,
678
+ channel: channel.name,
679
+ recipientId: userId,
680
+ recipientAddress: null,
681
+ status: "skipped",
682
+ error: gate,
683
+ priority,
684
+ });
685
+ continue;
685
686
  }
686
687
 
687
688
  try {
688
- const prefetchKey = recipientChannelKey(userId, channel.name);
689
- const address = prefetchedAddresses.has(prefetchKey)
690
- ? prefetchedAddresses.get(prefetchKey)
689
+ const wasPrefetched = prefetched.addresses.has(prefetchKey);
690
+ const address = wasPrefetched
691
+ ? prefetched.addresses.get(prefetchKey)
691
692
  : await channel.resolve(userId, channelCtx);
692
693
  if (!address) {
693
694
  await logDelivery(run, {
@@ -703,7 +704,14 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
703
704
  continue;
704
705
  }
705
706
 
706
- if (priority !== "critical" && isAddressSuppressed(suppressed, address, channel.name)) {
707
+ // A fallback resolve yields an address the batched opt-out lookup never covered.
708
+ const suppressedForAddress = wasPrefetched
709
+ ? suppressed
710
+ : await loadSuppressedAddresses(run, tenantId, notificationType, priority, [address]);
711
+ if (
712
+ priority !== "critical" &&
713
+ isAddressSuppressed(suppressedForAddress, address, channel.name)
714
+ ) {
707
715
  await logDelivery(run, {
708
716
  tenantId,
709
717
  notificationType,
@@ -878,16 +886,16 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
878
886
  userIds = to;
879
887
  }
880
888
 
881
- const prefetchedAddresses =
889
+ const prefetched: PrefetchedRecipients =
882
890
  priority === "critical"
883
- ? new Map<string, string | null>()
884
- : await prefetchUserAddresses(run, userIds, tenantId, notificationType);
891
+ ? { addresses: new Map(), gates: new Map() }
892
+ : await prefetchRecipients(run, userIds, tenantId, notificationType, priority);
885
893
  const suppressed = await loadSuppressedAddresses(
886
894
  run,
887
895
  tenantId,
888
896
  notificationType,
889
897
  priority,
890
- [...prefetchedAddresses.values()].filter((address): address is string => !!address),
898
+ [...prefetched.addresses.values()].filter((address): address is string => !!address),
891
899
  );
892
900
 
893
901
  for (const userId of userIds) {
@@ -898,7 +906,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
898
906
  tenantId,
899
907
  priority,
900
908
  run,
901
- prefetchedAddresses,
909
+ prefetched,
902
910
  suppressed,
903
911
  );
904
912
  }
@@ -955,17 +963,10 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
955
963
  try {
956
964
  await dispatchNotification(notificationType, options, tenantId, priority, run);
957
965
  } catch (err) {
958
- // A failing release must not replace the dispatch error the caller retries on.
959
- await releaseIdempotencyIfNothingDelivered(tenantId, idempotencyKey, run).catch(
960
- (releaseErr: unknown) => {
961
- logError.warn(`idempotency key release failed: ${redactedMessageOf(releaseErr)}`, {
962
- notificationType,
963
- });
964
- },
965
- );
966
+ await releaseIdempotencyIfNothingDelivered(tenantId, idempotencyKey, run, notificationType);
966
967
  throw err;
967
968
  }
968
- await releaseIdempotencyIfNothingDelivered(tenantId, idempotencyKey, run);
969
+ await releaseIdempotencyIfNothingDelivered(tenantId, idempotencyKey, run, notificationType);
969
970
  return dispatched(run);
970
971
  },
971
972
  };
@@ -1,6 +1,7 @@
1
1
  export { sanitizePdfAttachmentFileName } from "../shared/index.js";
2
2
  export { hashUnsubscribeAddress } from "./address-opt-out.js";
3
3
  export {
4
+ DELIVERY_ENVELOPE_DATA_KEYS,
4
5
  type EmailAttachmentRef,
5
6
  emailAttachmentRefSchema,
6
7
  type FileAttachmentRef,
@@ -12,6 +13,7 @@ export {
12
13
  type RenderedPdfAttachmentRef,
13
14
  renderedPdfAttachmentRefSchema,
14
15
  toAttachmentRef,
16
+ withoutDeliveryEnvelopeKeys,
15
17
  } from "./attachment-ref.js";
16
18
  export {
17
19
  type AttemptLogRetentionOptions,
@@ -59,6 +59,8 @@ const sendJobPayloadSchema = renderJobPayloadSchema.extend({
59
59
  html: z.string(),
60
60
  subject: z.string(),
61
61
  text: z.string().optional(),
62
+ fromName: z.string().optional(),
63
+ replyTo: z.string().optional(),
62
64
  attachments: z.array(emailAttachmentRefSchema).max(MAX_EMAIL_ATTACHMENTS).optional(),
63
65
  })
64
66
  .optional(),
@@ -96,6 +98,8 @@ function toSendRendered(
96
98
  html: rendered.html,
97
99
  subject: rendered.subject,
98
100
  ...(rendered.text !== undefined && { text: rendered.text }),
101
+ ...(rendered.fromName !== undefined && { fromName: rendered.fromName }),
102
+ ...(rendered.replyTo !== undefined && { replyTo: rendered.replyTo }),
99
103
  ...(rendered.attachments !== undefined && {
100
104
  attachments: rendered.attachments.map(toAttachmentRef),
101
105
  }),
@@ -138,15 +142,11 @@ function entryFor(
138
142
  };
139
143
  }
140
144
 
141
- // Only the final retry frees the key: earlier failures are retried by the runner and
142
- // must still dedup. Assumes ctx.redis is the handle the service claimed on; runProdApp
143
- // wires the same instance into both.
144
- async function releaseIdempotencyOnTerminalFailure(
145
- ctx: JobContext,
146
- p: RenderJobPayload,
147
- ): Promise<void> {
148
- // skip: nothing to release before the last attempt or without an idempotency key
149
- if (!ctx.finalAttempt || !p.idempotencyKey) return;
145
+ // Assumes ctx.redis is the handle the service claimed on; runProdApp wires the same
146
+ // instance into both.
147
+ async function releaseIdempotencyKey(ctx: JobContext, p: RenderJobPayload): Promise<void> {
148
+ // skip: no idempotency key was claimed for this notification
149
+ if (!p.idempotencyKey) return;
150
150
  if (!ctx.redis) {
151
151
  ctx.log.warn("delivery: idempotency key not released, job context has no redis", {
152
152
  notificationType: p.notificationType,
@@ -162,6 +162,33 @@ async function releaseIdempotencyOnTerminalFailure(
162
162
  }
163
163
  }
164
164
 
165
+ // Only the final retry frees the key: earlier failures are retried by the runner and
166
+ // must still dedup.
167
+ async function releaseIdempotencyOnTerminalFailure(
168
+ ctx: JobContext,
169
+ p: RenderJobPayload,
170
+ ): Promise<void> {
171
+ // skip: not the last attempt, the runner retries and the key must keep deduplicating
172
+ if (!ctx.finalAttempt) return;
173
+ await releaseIdempotencyKey(ctx, p);
174
+ }
175
+
176
+ // Covers every throw after the payload parsed, not just the attempt-logged ones: a
177
+ // missing registry or unknown channel on the last attempt would otherwise hold the key
178
+ // until its TTL.
179
+ async function releasingKeyOnTerminalFailure<T>(
180
+ ctx: JobContext,
181
+ p: RenderJobPayload,
182
+ run: () => Promise<T>,
183
+ ): Promise<T> {
184
+ try {
185
+ return await run();
186
+ } catch (err) {
187
+ await releaseIdempotencyOnTerminalFailure(ctx, p);
188
+ throw err;
189
+ }
190
+ }
191
+
165
192
  // The job runner stores err.message in the run row, and BullMQ keeps failedReason and
166
193
  // the stack trace in Redis in plain text, so the rethrown error carries only the code.
167
194
  // The redacted message goes to the log.
@@ -184,7 +211,6 @@ async function failAttempt(
184
211
  p.deliveryAttemptId,
185
212
  entryFor(p, "failed", code, p.address),
186
213
  );
187
- await releaseIdempotencyOnTerminalFailure(ctx, p);
188
214
  throw new Error(code);
189
215
  }
190
216
 
@@ -193,6 +219,10 @@ async function failAttempt(
193
219
  // the send step is never reached, so a stuck render can't half-send.
194
220
  export const deliveryRenderJob: JobHandlerFn = async (payload, ctx) => {
195
221
  const p = renderJobPayloadSchema.parse(payload);
222
+ return releasingKeyOnTerminalFailure(ctx, p, () => renderStep(p, ctx));
223
+ };
224
+
225
+ async function renderStep(p: RenderJobPayload, ctx: JobContext): Promise<void> {
196
226
  const tenantId = p.tenantId as TenantId; // @cast-boundary engine-payload — stream tenant
197
227
  const { db, registry } = requireTenantScopedDeps(ctx, tenantId);
198
228
  const channel = resolveChannel(registry, p.channelName);
@@ -203,7 +233,7 @@ export const deliveryRenderJob: JobHandlerFn = async (payload, ctx) => {
203
233
  tenantId,
204
234
  ctx.secrets,
205
235
  ctx.config,
206
- (fileTenantId) => fileContextForTenant(ctx, fileTenantId),
236
+ (fileTenantId) => fileContextForTenant(ctx, ctx.systemUser.tenantId, fileTenantId),
207
237
  );
208
238
 
209
239
  let rendered: RenderedMessage;
@@ -225,13 +255,17 @@ export const deliveryRenderJob: JobHandlerFn = async (payload, ctx) => {
225
255
  } catch (err) {
226
256
  return failAttempt(ctx, db, registry, p, "channel_error", err);
227
257
  }
228
- };
258
+ }
229
259
 
230
260
  // Deliver via the channel using the rendered payload (if any). On failure:
231
261
  // record the terminal failed event and rethrow so BullMQ retries the send with
232
262
  // the same already-rendered HTML — no re-render needed.
233
263
  export const deliverySendJob: JobHandlerFn = async (payload, ctx) => {
234
264
  const p = sendJobPayloadSchema.parse(payload);
265
+ return releasingKeyOnTerminalFailure(ctx, p, () => sendStep(p, ctx));
266
+ };
267
+
268
+ async function sendStep(p: z.infer<typeof sendJobPayloadSchema>, ctx: JobContext): Promise<void> {
235
269
  const tenantId = p.tenantId as TenantId; // @cast-boundary engine-payload — stream tenant
236
270
  const { db, registry } = requireTenantScopedDeps(ctx, tenantId);
237
271
  const channel = resolveChannel(registry, p.channelName);
@@ -242,7 +276,7 @@ export const deliverySendJob: JobHandlerFn = async (payload, ctx) => {
242
276
  tenantId,
243
277
  ctx.secrets,
244
278
  ctx.config,
245
- (fileTenantId) => fileContextForTenant(ctx, fileTenantId),
279
+ (fileTenantId) => fileContextForTenant(ctx, ctx.systemUser.tenantId, fileTenantId),
246
280
  );
247
281
 
248
282
  try {
@@ -259,7 +293,10 @@ export const deliverySendJob: JobHandlerFn = async (payload, ctx) => {
259
293
  result.confirmed,
260
294
  ),
261
295
  );
296
+ // A soft failure ends the job successfully, so no retry follows and the caller's
297
+ // retry must not be skipped as a duplicate.
298
+ if (result.status === "failed") await releaseIdempotencyKey(ctx, p);
262
299
  } catch (err) {
263
300
  return failAttempt(ctx, db, registry, p, "send_failed", err);
264
301
  }
265
- };
302
+ }
@@ -13,7 +13,7 @@ import type {
13
13
  } from "@cosmicdrift/kumiko-framework/engine";
14
14
  import type { FileProviderResolver } from "@cosmicdrift/kumiko-framework/files";
15
15
  import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
16
- import type { MailPayload } from "../renderer-foundation/index.js";
16
+ import type { MailPayload, RenderedTemplateRef } from "../renderer-foundation/index.js";
17
17
  import type { EmailAttachmentRef } from "./attachment-ref.js";
18
18
  import { DELIVERY_CHANNEL_EXTENSION } from "./constants.js";
19
19
 
@@ -24,7 +24,11 @@ export type RenderedMail = {
24
24
  readonly html: string;
25
25
  readonly text: string;
26
26
  readonly previewText?: string;
27
+ readonly fromName?: string;
28
+ readonly replyTo?: string;
27
29
  readonly attachments?: readonly EmailAttachmentRef[];
30
+ /** The layout version the html was rendered through; the editor preview names it. */
31
+ readonly layout?: RenderedTemplateRef;
28
32
  };
29
33
 
30
34
  export type ChannelContext = {
@@ -71,6 +75,8 @@ export type RenderedMessage = {
71
75
  readonly html: string;
72
76
  readonly subject: string;
73
77
  readonly text?: string;
78
+ readonly fromName?: string;
79
+ readonly replyTo?: string;
74
80
  readonly attachments?: readonly EmailAttachmentRef[];
75
81
  };
76
82
 
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.365.0",
4
+ "type": "fix",
5
+ "title": "Ingest events: correlate by event type and payload.fileRefId",
6
+ "detail": "New documentIngest events live on their own stream while older ones stay on the fileRef stream (aggregateId = fileRefId). Consumers must not correlate by aggregateId or the fileRef stream version."
7
+ },
2
8
  {
3
9
  "version": "0.362.0",
4
10
  "type": "fix",
@@ -1,4 +1,11 @@
1
1
  [
2
+ {
3
+ "version": "0.365.0",
4
+ "type": "fix",
5
+ "title": "Overlapping toggle refreshes no longer drop a flip, and the runtime can reuse the server cache-sync bus",
6
+ "detail": "initialize() and refresh() now run one snapshot load at a time, so a flip applied while a second load is in flight is kept instead of being overwritten by older rows. createFeatureToggleRuntime takes an optional fourth cacheSyncBus argument; with it no second bus (and no extra Redis connections) is created and close() leaves the shared bus open.",
7
+ "migration": "Pass the server's cache-sync bus as the fourth argument of createFeatureToggleRuntime to avoid the additional Redis publisher and subscriber connections of the default bus; without it the runtime still builds and closes its own bus."
8
+ },
2
9
  {
3
10
  "version": "0.336.0",
4
11
  "type": "fix",
@@ -7,6 +7,7 @@ import {
7
7
  } from "@cosmicdrift/kumiko-framework/engine";
8
8
  import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
9
9
  import {
10
+ type CacheSyncBus,
10
11
  type ClosableCacheSyncBus,
11
12
  createDefaultCacheSyncBus,
12
13
  } from "@cosmicdrift/kumiko-framework/redis";
@@ -40,9 +41,11 @@ export type ToggleSyncSignal = {
40
41
  // per-instance behavior for the only process there is.
41
42
  export class GlobalFeatureToggleRuntime {
42
43
  private snapshot = new Map<string, boolean>();
43
- // Flips applied while a snapshot load is in flight. The load's SELECT may have
44
- // started before the flip committed, so its rows can be older than these.
45
- private appliedDuringLoad: Map<string, boolean> | undefined;
44
+ // One buffer per requested load, open from the call until its SELECT finished: the SELECT
45
+ // may have started before a flip committed, so its rows can be older than the flips collected.
46
+ private readonly pendingFlipBuffers = new Set<Map<string, boolean>>();
47
+ // Loads run one at a time so the older rows of one cannot overwrite the result of another.
48
+ private loadInFlight: Promise<void> = Promise.resolve();
46
49
 
47
50
  constructor(
48
51
  private readonly db: DbConnection,
@@ -69,10 +72,16 @@ export class GlobalFeatureToggleRuntime {
69
72
  await this.ownedBus?.close();
70
73
  }
71
74
 
72
- async initialize(): Promise<void> {
73
- type Row = { featureName: string; enabled: boolean };
75
+ initialize(): Promise<void> {
74
76
  const pendingFlips = new Map<string, boolean>();
75
- this.appliedDuringLoad = pendingFlips;
77
+ this.pendingFlipBuffers.add(pendingFlips);
78
+ const load = this.loadInFlight.then(() => this.loadSnapshot(pendingFlips));
79
+ this.loadInFlight = load.catch(() => undefined);
80
+ return load;
81
+ }
82
+
83
+ private async loadSnapshot(pendingFlips: Map<string, boolean>): Promise<void> {
84
+ type Row = { featureName: string; enabled: boolean };
76
85
  try {
77
86
  const rows = await selectMany<Row>(this.db, globalFeatureStateTable);
78
87
  this.snapshot = new Map([
@@ -80,7 +89,7 @@ export class GlobalFeatureToggleRuntime {
80
89
  ...pendingFlips,
81
90
  ]);
82
91
  } finally {
83
- this.appliedDuringLoad = undefined;
92
+ this.pendingFlipBuffers.delete(pendingFlips);
84
93
  }
85
94
  }
86
95
 
@@ -98,7 +107,7 @@ export class GlobalFeatureToggleRuntime {
98
107
  // lock. Kept alongside refresh() so both options are explicit.
99
108
  apply(featureName: string, enabled: boolean): void {
100
109
  this.snapshot.set(featureName, enabled);
101
- this.appliedDuringLoad?.set(featureName, enabled);
110
+ for (const pendingFlips of this.pendingFlipBuffers) pendingFlips.set(featureName, enabled);
102
111
  }
103
112
 
104
113
  // Called by the toggle-cache-sync MSP handler (feature.ts) — the one
@@ -144,15 +153,20 @@ export class GlobalFeatureToggleRuntime {
144
153
 
145
154
  // Factory for app-boot wiring: instantiate, initialize, return the runtime (for the
146
155
  // set-handler to refresh; its effectiveFeatures feeds createDispatcher). Without an explicit
147
- // syncSignal the runtime builds one on the framework cache-sync bus: Redis-backed when
148
- // REDIS_URL is set, process-local otherwise. close() releases a bus it created.
156
+ // syncSignal the signal runs on the given cacheSyncBus or, failing that, on a bus the runtime
157
+ // creates itself (Redis-backed when REDIS_URL is set, process-local otherwise). close() releases
158
+ // only a bus the runtime created.
149
159
  export async function createFeatureToggleRuntime(
150
160
  db: DbConnection,
151
161
  registry: Registry,
152
162
  syncSignal?: ToggleSyncSignal,
163
+ // The server's own bus (the one handed to the dispatcher): reusing it avoids a second set of
164
+ // Redis connections that nothing closes at shutdown.
165
+ cacheSyncBus?: CacheSyncBus,
153
166
  ): Promise<GlobalFeatureToggleRuntime> {
154
- const ownedBus = syncSignal ? undefined : createDefaultCacheSyncBus();
155
- const signal = syncSignal ?? (ownedBus ? createCacheSyncToggleSignal(ownedBus) : undefined);
167
+ const ownedBus = syncSignal || cacheSyncBus ? undefined : createDefaultCacheSyncBus();
168
+ const sharedBus = cacheSyncBus ?? ownedBus;
169
+ const signal = syncSignal ?? (sharedBus ? createCacheSyncToggleSignal(sharedBus) : undefined);
156
170
  const runtime = new GlobalFeatureToggleRuntime(db, registry, signal, ownedBus);
157
171
  try {
158
172
  await runtime.initialize();
@@ -147,7 +147,7 @@ export const publicVariantQuery = defineQueryHandler({
147
147
  const spec = resolveFieldVariant(ctx.registry, entityType, fieldName, query.payload.variant);
148
148
  if (!spec) return null;
149
149
  const result = await derivatives.variant(query.payload.fileRefId, spec, query.payload.variant);
150
- const bytes = await files.ref(result.storageKey).read();
150
+ const bytes = await files.refFromFileRef(result).read();
151
151
 
152
152
  // ponytail: fully buffered (no streaming), +33% over the wire from
153
153
  // base64 — HttpRouteHandlerDeps only exposes {app, systemQuery} (no