@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.
- package/LICENSE +1 -1
- package/package.json +1 -1
- package/packages/bundled-features/package.json +7 -1
- package/packages/bundled-features/src/audit/changes.json +18 -0
- package/packages/bundled-features/src/audit/feature.ts +1 -1
- package/packages/bundled-features/src/audit/handlers/list.query.ts +10 -5
- package/packages/bundled-features/src/audit/run-escape-hatch-retention.ts +63 -11
- package/packages/bundled-features/src/auth-email-password/bootstrap.ts +30 -11
- package/packages/bundled-features/src/auth-email-password/changes.json +55 -0
- package/packages/bundled-features/src/auth-email-password/email-rate-limits.ts +16 -0
- package/packages/bundled-features/src/auth-email-password/feature.ts +4 -7
- package/packages/bundled-features/src/auth-email-password/handlers/change-password.write.ts +6 -2
- package/packages/bundled-features/src/auth-email-password/handlers/invite-accept-with-login.write.ts +2 -3
- package/packages/bundled-features/src/auth-email-password/handlers/invite-create.write.ts +6 -4
- package/packages/bundled-features/src/auth-email-password/handlers/logout.write.ts +7 -2
- package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +2 -1
- package/packages/bundled-features/src/auth-email-password/handlers/switch-tenant-mfa-gate.write.ts +2 -2
- package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +2 -1
- package/packages/bundled-features/src/auth-email-password/magic-link-mail.ts +7 -3
- package/packages/bundled-features/src/auth-email-password/mail-i18n.ts +10 -0
- package/packages/bundled-features/src/auth-email-password/web/auth-gate.tsx +24 -5
- package/packages/bundled-features/src/billing-foundation/changes.json +48 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/confirmation-mail.ts +57 -5
- package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-gate.ts +22 -1
- package/packages/bundled-features/src/billing-foundation/consumer-protection/mail-notifications.ts +4 -1
- package/packages/bundled-features/src/billing-foundation/consumer-protection/register-termination.ts +11 -1
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-mail.ts +9 -3
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-receipt-limit.ts +41 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-record.ts +18 -2
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-request.ts +35 -19
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-texts.ts +9 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/withdrawal-period.ts +35 -0
- package/packages/bundled-features/src/billing-foundation/handlers/create-checkout-session.write.ts +11 -3
- package/packages/bundled-features/src/billing-foundation/handlers/process-event.write.ts +10 -4
- package/packages/bundled-features/src/billing-foundation/i18n.ts +9 -0
- package/packages/bundled-features/src/billing-foundation/subscription-tier-sync.ts +27 -7
- package/packages/bundled-features/src/billing-foundation/types.ts +7 -2
- package/packages/bundled-features/src/cap-counter/book-cap-usage.ts +20 -8
- package/packages/bundled-features/src/cap-counter/calendar-cap-usage-reservation.ts +2 -0
- package/packages/bundled-features/src/cap-counter/cap-reservation.ts +7 -2
- package/packages/bundled-features/src/cap-counter/changes.json +19 -0
- package/packages/bundled-features/src/cap-counter/enforce-cap.ts +75 -5
- package/packages/bundled-features/src/cap-counter/handlers/increment-rolling.write.ts +2 -0
- package/packages/bundled-features/src/cap-counter/index.ts +1 -0
- package/packages/bundled-features/src/cap-counter/with-cap-enforcement.ts +3 -0
- package/packages/bundled-features/src/cap-overview/changes.json +6 -0
- package/packages/bundled-features/src/cap-overview/handlers/tenant-caps-list.query.ts +2 -1
- package/packages/bundled-features/src/cap-overview/handlers/tenant-options.query.ts +2 -1
- package/packages/bundled-features/src/channel-discord/discord-channel.ts +4 -2
- package/packages/bundled-features/src/channel-email/changes.json +18 -0
- package/packages/bundled-features/src/channel-email/email-channel.ts +47 -24
- package/packages/bundled-features/src/channel-email/smtp-transport.ts +7 -1
- package/packages/bundled-features/src/channel-slack/slack-channel.ts +4 -2
- package/packages/bundled-features/src/channel-teams/teams-channel.ts +4 -2
- package/packages/bundled-features/src/config/changes.json +6 -0
- package/packages/bundled-features/src/config/handlers/set.write.ts +2 -2
- package/packages/bundled-features/src/config/resolver.ts +15 -12
- package/packages/bundled-features/src/config/write-helpers.ts +23 -0
- package/packages/bundled-features/src/crypto-shredding/changes.json +6 -0
- package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +54 -27
- package/packages/bundled-features/src/custom-fields/changes.json +6 -0
- package/packages/bundled-features/src/custom-fields/db/queries/retention.ts +2 -2
- package/packages/bundled-features/src/custom-fields/handlers/update-tenant-field.write.ts +0 -1
- package/packages/bundled-features/src/data-retention/run-retention-cleanup.ts +4 -5
- package/packages/bundled-features/src/delivery/attachment-ref.ts +39 -1
- package/packages/bundled-features/src/delivery/changes.json +30 -0
- package/packages/bundled-features/src/delivery/channel-context.ts +31 -8
- package/packages/bundled-features/src/delivery/delivery-service.ts +93 -92
- package/packages/bundled-features/src/delivery/index.ts +2 -0
- package/packages/bundled-features/src/delivery/jobs.ts +51 -14
- package/packages/bundled-features/src/delivery/types.ts +7 -1
- package/packages/bundled-features/src/document-ingest-foundation/changes.json +6 -0
- package/packages/bundled-features/src/feature-toggles/changes.json +7 -0
- package/packages/bundled-features/src/feature-toggles/toggle-runtime.ts +26 -12
- package/packages/bundled-features/src/file-derivatives/handlers/public-variant.query.ts +1 -1
- package/packages/bundled-features/src/file-foundation/changes.json +6 -0
- package/packages/bundled-features/src/file-foundation/read-file-ref-content.ts +6 -3
- package/packages/bundled-features/src/files-tenant-data/changes.json +6 -0
- package/packages/bundled-features/src/files-tenant-data/hooks.ts +4 -0
- package/packages/bundled-features/src/files-tenant-data/index.ts +3 -1
- package/packages/bundled-features/src/form-draft/db/queries/owned-file-refs.ts +12 -3
- package/packages/bundled-features/src/form-draft/handlers/discard.write.ts +1 -1
- package/packages/bundled-features/src/inbound-mail-foundation/changes.json +6 -0
- package/packages/bundled-features/src/inbound-mail-foundation/oauth-access-token.ts +13 -8
- package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +3 -1
- package/packages/bundled-features/src/jobs/changes.json +31 -0
- package/packages/bundled-features/src/jobs/db/queries/stale-run-sweep.ts +12 -4
- package/packages/bundled-features/src/jobs/feature.ts +7 -4
- package/packages/bundled-features/src/jobs/handlers/list.query.ts +3 -1
- package/packages/bundled-features/src/jobs/handlers/stale-run-sweep.job.ts +5 -2
- package/packages/bundled-features/src/jobs/job-run-logger.ts +56 -18
- package/packages/bundled-features/src/jobs/tenant-job-run-table.ts +7 -2
- package/packages/bundled-features/src/ledger/changes.json +7 -0
- package/packages/bundled-features/src/ledger/entity.ts +3 -1
- package/packages/bundled-features/src/metrics/changes.json +6 -0
- package/packages/bundled-features/src/metrics/filters.ts +28 -12
- package/packages/bundled-features/src/personal-access-tokens/changes.json +6 -0
- package/packages/bundled-features/src/personal-access-tokens/revoke-for-user.ts +7 -8
- package/packages/bundled-features/src/renderer-foundation/types.ts +2 -0
- package/packages/bundled-features/src/renderer-pdf/changes.json +12 -0
- package/packages/bundled-features/src/renderer-pdf/render-table-pdf.ts +162 -82
- package/packages/bundled-features/src/secrets/changes.json +12 -0
- package/packages/bundled-features/src/secrets/feature.ts +4 -3
- package/packages/bundled-features/src/secrets/handlers/delete.write.ts +3 -1
- package/packages/bundled-features/src/secrets/secrets-context.ts +23 -3
- package/packages/bundled-features/src/secrets/write-gate.ts +8 -2
- package/packages/bundled-features/src/shared/changes.json +6 -0
- package/packages/bundled-features/src/shared/chunked-entity-migration.ts +4 -1
- package/packages/bundled-features/src/shared/index.ts +8 -0
- package/packages/bundled-features/src/shared/mail-template-body.ts +17 -0
- package/packages/bundled-features/src/shared/resource-image-formats.ts +31 -0
- package/packages/bundled-features/src/shared/sharp-input-limits.ts +7 -8
- package/packages/bundled-features/src/step-dispatcher/webhook-runner.ts +2 -2
- package/packages/bundled-features/src/subscription-stripe/changes.json +6 -0
- package/packages/bundled-features/src/subscription-stripe/plugin-methods.ts +19 -11
- package/packages/bundled-features/src/subscription-stripe/verify-webhook.ts +3 -1
- package/packages/bundled-features/src/template-resolver/README.md +4 -2
- package/packages/bundled-features/src/template-resolver/api.ts +3 -2
- package/packages/bundled-features/src/template-resolver/changes.json +149 -0
- package/packages/bundled-features/src/template-resolver/compile-document-body.ts +148 -18
- package/packages/bundled-features/src/template-resolver/compile-steps-block.ts +68 -0
- package/packages/bundled-features/src/template-resolver/constants.ts +23 -3
- package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +3 -0
- package/packages/bundled-features/src/template-resolver/document-schema.ts +33 -5
- package/packages/bundled-features/src/template-resolver/document-steps.ts +114 -0
- package/packages/bundled-features/src/template-resolver/feature.ts +16 -1
- package/packages/bundled-features/src/template-resolver/handlers/bundle-import.ts +81 -39
- package/packages/bundled-features/src/template-resolver/handlers/collection-set.write.ts +4 -3
- package/packages/bundled-features/src/template-resolver/handlers/copy-locale.write.ts +121 -53
- package/packages/bundled-features/src/template-resolver/handlers/diff-to-system.query.ts +6 -44
- package/packages/bundled-features/src/template-resolver/handlers/discard-draft.write.ts +52 -0
- package/packages/bundled-features/src/template-resolver/handlers/export-bundle.query.ts +56 -1
- package/packages/bundled-features/src/template-resolver/handlers/find-by-id.query.ts +6 -3
- package/packages/bundled-features/src/template-resolver/handlers/import-bundle-system.write.ts +25 -15
- package/packages/bundled-features/src/template-resolver/handlers/import-bundle.write.ts +12 -1
- package/packages/bundled-features/src/template-resolver/handlers/layout-source.query.ts +73 -30
- package/packages/bundled-features/src/template-resolver/handlers/list.query.ts +14 -11
- package/packages/bundled-features/src/template-resolver/handlers/override-diff.ts +49 -0
- package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +102 -19
- package/packages/bundled-features/src/template-resolver/handlers/resource-upload.write.ts +49 -10
- package/packages/bundled-features/src/template-resolver/handlers/send-test.write.ts +39 -10
- package/packages/bundled-features/src/template-resolver/handlers/template-gates.ts +14 -2
- package/packages/bundled-features/src/template-resolver/handlers/template-write.ts +54 -4
- package/packages/bundled-features/src/template-resolver/handlers/tenant-overrides.query.ts +96 -14
- package/packages/bundled-features/src/template-resolver/handlers/toggle-status.write.ts +51 -24
- package/packages/bundled-features/src/template-resolver/handlers/upsert-tenant.write.ts +1 -1
- package/packages/bundled-features/src/template-resolver/handlers/usage.query.ts +16 -2
- package/packages/bundled-features/src/template-resolver/handlers/validate.query.ts +4 -1
- package/packages/bundled-features/src/template-resolver/index.ts +18 -2
- package/packages/bundled-features/src/template-resolver/layout-appearance.ts +56 -0
- package/packages/bundled-features/src/template-resolver/mail-constants.ts +4 -0
- package/packages/bundled-features/src/template-resolver/pdf-constants.ts +12 -0
- package/packages/bundled-features/src/template-resolver/qualified-names.ts +4 -0
- package/packages/bundled-features/src/template-resolver/resource-image.ts +9 -16
- package/packages/bundled-features/src/template-resolver/seeding.ts +27 -4
- package/packages/bundled-features/src/template-resolver/store-template-resource.ts +390 -124
- package/packages/bundled-features/src/template-resolver/table.ts +13 -1
- package/packages/bundled-features/src/template-resolver/template-bundle.ts +4 -0
- package/packages/bundled-features/src/template-resolver/template-draft.ts +106 -0
- package/packages/bundled-features/src/template-resolver/template-meta.ts +95 -24
- package/packages/bundled-features/src/template-resolver/template-preview.ts +160 -57
- package/packages/bundled-features/src/template-resolver/template-resources.ts +3 -4
- package/packages/bundled-features/src/template-resolver/template-usage.ts +31 -11
- package/packages/bundled-features/src/template-resolver/template-validation.ts +16 -9
- package/packages/bundled-features/src/template-resolver/web/client-plugin.tsx +12 -8
- package/packages/bundled-features/src/template-resolver/web/document-editor-extensions.ts +97 -10
- package/packages/bundled-features/src/template-resolver/web/document-editor.tsx +15 -5
- package/packages/bundled-features/src/template-resolver/web/document-tiptap-editor.tsx +405 -145
- package/packages/bundled-features/src/template-resolver/web/document-toolbar-blocks.ts +17 -3
- package/packages/bundled-features/src/template-resolver/web/i18n.ts +32 -4
- package/packages/bundled-features/src/template-resolver/web/index.ts +8 -0
- package/packages/bundled-features/src/template-resolver/web/placeholder-line-editor.tsx +23 -7
- package/packages/bundled-features/src/tenant/changes.json +6 -0
- package/packages/bundled-features/src/tenant/handlers/update-member-roles.write.ts +3 -8
- package/packages/bundled-features/src/tenant/seeding.ts +33 -27
- package/packages/bundled-features/src/tenant-handover/move-entity-graph.ts +14 -3
- package/packages/bundled-features/src/tenant-lifecycle/changes.json +6 -0
- package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +9 -1
- package/packages/bundled-features/src/tenant-lifecycle/stages.ts +3 -2
- package/packages/bundled-features/src/tier-engine/changes.json +18 -0
- package/packages/bundled-features/src/tier-engine/constants.ts +9 -0
- package/packages/bundled-features/src/tier-engine/entity.ts +9 -4
- package/packages/bundled-features/src/tier-engine/feature.ts +11 -3
- package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +8 -5
- package/packages/bundled-features/src/tier-engine/index.ts +2 -0
- package/packages/bundled-features/src/user/changes.json +6 -0
- package/packages/bundled-features/src/user/constants.ts +1 -0
- package/packages/bundled-features/src/user/invited-global-roles.ts +2 -4
- package/packages/bundled-features/src/user/resolve-display-names.ts +8 -15
- package/packages/bundled-features/src/user/seeding.ts +19 -5
- package/packages/bundled-features/src/user-data-rights/changes.json +12 -0
- package/packages/bundled-features/src/user-data-rights/email-templates.ts +0 -1
- package/packages/bundled-features/src/user-data-rights/i18n.ts +5 -0
- package/packages/bundled-features/src/user-data-rights/lib/update-user-lifecycle.ts +13 -2
- package/packages/bundled-features/src/user-data-rights/run-forget-cleanup.ts +7 -1
- package/packages/bundled-features/src/user-profile/changes.json +1 -1
- package/packages/bundled-features/src/waitlist/changes.json +18 -0
- package/packages/bundled-features/src/waitlist/feature.ts +1 -1
- package/packages/bundled-features/src/waitlist/handlers/invite.write.ts +35 -3
- package/packages/bundled-features/src/waitlist/handlers/reject.write.ts +2 -17
- package/packages/bundled-features/src/waitlist/handlers/submit.write.ts +33 -9
- package/packages/bundled-features/src/waitlist/i18n.ts +9 -0
- package/packages/bundled-features/src/waitlist/lib.ts +20 -0
- package/packages/bundled-features/src/waitlist/options.ts +1 -1
- package/packages/bundled-features/src/workflow-runner/changes.json +6 -0
- package/packages/bundled-features/src/workflow-runner/event-trigger.ts +2 -1
- package/packages/bundled-features/src/workflow-runner/feature.ts +1 -1
- package/packages/bundled-features/src/workflow-runner/handlers/resume-run.write.ts +7 -1
- package/samples/apps/ui-walkthrough/package.json +9 -0
- package/samples/apps/ui-walkthrough/playwright.config.ts +40 -7
- package/samples/apps/ui-walkthrough/src/app/seed-templates.ts +87 -2
- package/samples/apps/ui-walkthrough/src/app/template-seed-data.ts +246 -30
- package/samples/apps/ui-walkthrough/src/features/billing/feature.ts +4 -0
- package/samples/apps/ui-walkthrough/src/run-config.ts +18 -1
- package/samples/apps/use-all-bundled/README.md +3 -13
- package/samples/apps/use-all-bundled/feature-manifest.json +7 -5
- package/samples/apps/use-all-bundled/scripts/check-coverage.ts +34 -118
- package/samples/apps/use-all-bundled/src/app/notes-feature.ts +1 -1
- package/samples/apps/use-all-bundled/src/run-config.ts +0 -3
- package/samples/recipes/auth-mfa/README.md +2 -1
- package/samples/recipes/auth-mfa/src/feature.ts +1 -0
- package/samples/recipes/custom-handlers/README.md +4 -3
- package/samples/recipes/custom-handlers/src/feature.ts +21 -12
- package/samples/recipes/dashboard-stats/README.md +38 -0
- package/samples/recipes/dashboard-stats/package.json +8 -0
- package/samples/recipes/dashboard-stats/src/feature.ts +104 -0
- package/samples/recipes/legal-pages/README.md +2 -2
- package/samples/recipes/managed-pages/README.md +2 -2
- package/samples/recipes/pdf-audit-export/README.md +2 -1
- package/samples/recipes/pdf-audit-export/package.json +1 -1
- package/samples/recipes/pdf-audit-export/src/feature.ts +68 -48
- package/samples/recipes/workflow-engine/src/db/queries/suspended-runs.ts +2 -2
- package/samples/apps/use-all-bundled/schema/generate.ts +0 -617
- package/samples/apps/use-all-bundled/schema/migrations/meta/0000_snapshot.json +0 -3055
- package/samples/apps/use-all-bundled/schema/migrations/meta/_journal.json +0 -13
- package/samples/apps/use-all-bundled/schema/schema.generated.ts +0 -94
- 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
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
518
|
-
|
|
519
|
-
|
|
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:
|
|
524
|
-
// delivery loop resolves it again and reports channel_error as before.
|
|
525
|
-
// Channels
|
|
526
|
-
// here: resolve may have side effects and deliverToUser skips those channels.
|
|
527
|
-
|
|
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
|
-
|
|
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
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
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
|
-
|
|
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
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
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
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
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
|
|
689
|
-
const address =
|
|
690
|
-
?
|
|
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
|
-
|
|
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
|
|
889
|
+
const prefetched: PrefetchedRecipients =
|
|
882
890
|
priority === "critical"
|
|
883
|
-
? new Map
|
|
884
|
-
: await
|
|
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
|
-
[...
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
142
|
-
//
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
//
|
|
44
|
-
// started before
|
|
45
|
-
private
|
|
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
|
-
|
|
73
|
-
type Row = { featureName: string; enabled: boolean };
|
|
75
|
+
initialize(): Promise<void> {
|
|
74
76
|
const pendingFlips = new Map<string, boolean>();
|
|
75
|
-
this.
|
|
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.
|
|
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.
|
|
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
|
|
148
|
-
// REDIS_URL is set, process-local otherwise. close() releases
|
|
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
|
|
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.
|
|
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
|