@pithy-sh/email 0.1.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 +21 -0
- package/README.md +17 -0
- package/package.json +55 -0
- package/pithy.manifest.json +73 -0
- package/src/analytics.ts +39 -0
- package/src/audit/actions.ts +48 -0
- package/src/bounce/classify.ts +103 -0
- package/src/bounce/handler.ts +136 -0
- package/src/capability.ts +385 -0
- package/src/cloudflare-test.d.ts +19 -0
- package/src/crypto/signingKey.ts +44 -0
- package/src/crypto/token.ts +148 -0
- package/src/data/emailEvent.ts +42 -0
- package/src/data/emailJob.ts +138 -0
- package/src/data/emailSuppression.ts +40 -0
- package/src/data/enums.ts +75 -0
- package/src/data/tables.ts +47 -0
- package/src/error/errors.ts +129 -0
- package/src/http/callbacks.ts +200 -0
- package/src/http/guards.ts +154 -0
- package/src/http/responses.ts +192 -0
- package/src/http/routes.ts +467 -0
- package/src/http/schemas.ts +203 -0
- package/src/http/view.ts +139 -0
- package/src/index.ts +73 -0
- package/src/jobs/read.ts +273 -0
- package/src/jobs/retry.ts +214 -0
- package/src/migrations/0001_init.ts +174 -0
- package/src/migrations/0001_suppressions.ts +40 -0
- package/src/provision/devDelivery.ts +47 -0
- package/src/provision/hostCatalogs.ts +107 -0
- package/src/provision/provisionEmail.ts +179 -0
- package/src/provision/resolveEmailConfig.ts +225 -0
- package/src/provision/settingsCheck.ts +212 -0
- package/src/send/batchIdentity.ts +47 -0
- package/src/send/enqueue.ts +391 -0
- package/src/send/errorMapping.ts +73 -0
- package/src/send/events.ts +34 -0
- package/src/send/fromComposition.ts +57 -0
- package/src/send/retryPolicy.ts +42 -0
- package/src/send/runSend.ts +320 -0
- package/src/send/sendAt.ts +77 -0
- package/src/send/sender.ts +44 -0
- package/src/send/senderBinding.ts +56 -0
- package/src/send/suppression.ts +194 -0
- package/src/templates/engine.ts +392 -0
- package/src/templates/messages.es.ts +109 -0
- package/src/templates/messages.ts +315 -0
- package/src/templates/partials.ts +88 -0
- package/src/templates/precompiled.generated.ts +1342 -0
- package/src/templates/registry.ts +550 -0
- package/src/templates/samples.ts +75 -0
- package/src/templates/severity.ts +102 -0
- package/src/templates/theme.ts +212 -0
- package/src/version.generated.ts +16 -0
- package/src/workflows/hostApp.ts +54 -0
- package/src/workflows/hostEnv.ts +219 -0
- package/src/workflows/instanceLiveness.ts +39 -0
- package/src/workflows/instances.ts +16 -0
- package/src/workflows/params.ts +35 -0
- package/src/workflows/scheduler.ts +220 -0
- package/src/workflows/sendBatch.ts +154 -0
- package/src/workflows/worker.ts +203 -0
- package/src/workflows/wrangler.jsonc +75 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { DeclaredEnvironments } from "@pithy-sh/core/src/naming/environment";
|
|
5
|
+
import { GLOBAL_SCOPE } from "@pithy-sh/core/src/naming/environment";
|
|
6
|
+
import { resourceName } from "@pithy-sh/core/src/naming/resource";
|
|
7
|
+
import { resourceNames } from "@pithy-sh/core/src/naming/resourceNames";
|
|
8
|
+
import { type ManagedEnvironment, managedEnvironments } from "@pithy-sh/secrets/src/scope";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The provisioning orchestration for the email capability — the live counterpart to `pithy add email`'s
|
|
12
|
+
* config wiring. It stands up the durable infrastructure the rest of the capability assumes: the
|
|
13
|
+
* suppression database (one per **project**, bound by every one of that project's environments) and the
|
|
14
|
+
* per-environment prebuilt email worker that hosts the send + scheduler Workflows and the every-minute
|
|
15
|
+
* cron.
|
|
16
|
+
*
|
|
17
|
+
* Unlike `@pithy-sh/secrets`, the email worker needs **no minted CF API token** — it sends through the
|
|
18
|
+
* Cloudflare Email Service `send_email` binding and reads its signing key through the `SECRETS` bindings,
|
|
19
|
+
* neither of which uses an API token. So provisioning here is purely: create + migrate the suppression
|
|
20
|
+
* DB, then deploy each environment's worker.
|
|
21
|
+
*
|
|
22
|
+
* The live Cloudflare/wrangler steps are behind the {@link EmailProvisioner} seam, so the orchestration
|
|
23
|
+
* (order, idempotency contract, per-env fan-out) is unit-tested without touching Cloudflare; the real
|
|
24
|
+
* seam implementation is the live-CF glue, exercised by the integration suite. Every step is idempotent —
|
|
25
|
+
* re-running provisioning is a no-op.
|
|
26
|
+
*
|
|
27
|
+
* **Operator prerequisites (out of band, like the secrets store):** the sending domain must be onboarded
|
|
28
|
+
* onto Cloudflare Email Service, one Email Routing rule must point bounce/complaint mail at the production
|
|
29
|
+
* app worker, and the link-signing key must exist (`pithy secrets create email-link-signing-key`). These
|
|
30
|
+
* are one-time account/DNS actions provisioning does not own.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** The capability segment every email-owned name carries — the migration namespace and error domain too. */
|
|
34
|
+
export const EMAIL_CAPABILITY = "email";
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The shared, durable suppression database — **one per project, shared across that project's
|
|
38
|
+
* environments**: `<project>-global-email-suppressions`.
|
|
39
|
+
*
|
|
40
|
+
* The sharing across environments is deliberate and load-bearing. "Do not email this person again" is
|
|
41
|
+
* not an environment-local fact; an address that hard-bounced in production must not be retried from
|
|
42
|
+
* staging, or the sending domain's reputation pays for the distinction. `global` sits in the
|
|
43
|
+
* environment slot to say so out loud rather than by omission.
|
|
44
|
+
*
|
|
45
|
+
* The sharing across **projects** was not deliberate — it was the absence of a project segment. D1's
|
|
46
|
+
* namespace is account-wide and provisioning reuses a database it finds by name, so a fixed
|
|
47
|
+
* `pithy-email-suppressions` meant a second, unrelated Pithy product in the same account silently
|
|
48
|
+
* inherited the first's opt-out list: one product's unsubscribe suppressing another product's
|
|
49
|
+
* transactional mail, with no row anywhere recording why. The project segment gives the scope a
|
|
50
|
+
* definition instead of an accident.
|
|
51
|
+
*
|
|
52
|
+
* Composed through core's naming facade under the **`d1`** namespace, so the name is measured against
|
|
53
|
+
* a D1 database name's limit rather than the single 63 the generic composer defaults to.
|
|
54
|
+
*/
|
|
55
|
+
export function suppressionDatabaseName(project: string): string {
|
|
56
|
+
return resourceNames(project).global.d1(`${EMAIL_CAPABILITY}-suppressions`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The name of the inbound Email Routing rule that delivers bounce and complaint mail to the app worker.
|
|
61
|
+
*
|
|
62
|
+
* `global` in the environment slot because there is one rule per zone — the environments are separated
|
|
63
|
+
* by which app Worker the rule points at, not by the rule. Project-scoped and distinct from
|
|
64
|
+
* `@pithy-sh/support`'s `<project>-global-support-inbound`: `ensureWorkerRoute` keys idempotency on the
|
|
65
|
+
* rule *name*, so two projects sharing a zone under one unscoped name would each read the other's rule
|
|
66
|
+
* as their own, and one project's bounces would be delivered to the other's Worker.
|
|
67
|
+
*
|
|
68
|
+
* **The one name here still on the generic composer**, because an Email Routing rule is not a namespace
|
|
69
|
+
* `@pithy-sh/core/src/naming/limits` carries a verified Cloudflare cap for. The facade's whole point is
|
|
70
|
+
* that a kind of thing carries its own number; inventing one for this kind would be the opposite. So it
|
|
71
|
+
* takes the conservative default until that namespace lands.
|
|
72
|
+
*/
|
|
73
|
+
export function bounceRoutingRuleName(project: string): string {
|
|
74
|
+
return resourceName({ project, env: GLOBAL_SCOPE, thing: `${EMAIL_CAPABILITY}-bounce` });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The deployed Worker name for a project's environment — also its resolved config basename. Composed
|
|
79
|
+
* through core's naming facade under the **`worker`** namespace (63, the workers.dev cap), which
|
|
80
|
+
* `resolveEmailConfig` also stamps onto the config: one source, so the name the CLI audits and deletes
|
|
81
|
+
* under cannot drift from the name it deploys under. The environment is validated on the way through,
|
|
82
|
+
* so a stale `production` fails here rather than deploying a second host beside the real one.
|
|
83
|
+
*/
|
|
84
|
+
export function emailWorkerName(project: string, env: ManagedEnvironment): string {
|
|
85
|
+
return resourceNames(project).env(env).worker(EMAIL_CAPABILITY);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The live Cloudflare/wrangler seam. Each step must be idempotent. */
|
|
89
|
+
export interface EmailProvisioner {
|
|
90
|
+
/**
|
|
91
|
+
* Verify account prerequisites before any resource is created — most importantly a registered
|
|
92
|
+
* `workers.dev` subdomain, which Cloudflare requires to deploy the Workflow-hosting email worker.
|
|
93
|
+
* Throws a clear, actionable error so provisioning fails fast and clean rather than mid-deploy.
|
|
94
|
+
*/
|
|
95
|
+
preflight(): Promise<void>;
|
|
96
|
+
/** Create (or reuse) the project's suppression D1; returns its id. Idempotent. Runs once, before any env. */
|
|
97
|
+
ensureSuppressionDatabase(): Promise<{ databaseId: string }>;
|
|
98
|
+
/** Run the `email_0001_suppressions` migration against the suppression D1. Idempotent (applied ones skip). */
|
|
99
|
+
migrateSuppression(databaseId: string): Promise<void>;
|
|
100
|
+
/** Deploy the prebuilt email worker for this environment, wired to the suppression DB and the env's own resources. */
|
|
101
|
+
deployWorker(env: ManagedEnvironment, suppressionDatabaseId: string): Promise<void>;
|
|
102
|
+
/**
|
|
103
|
+
* Ensure the inbound Email Routing rule that points bounce/complaint mail at the production app worker
|
|
104
|
+
* (one per domain). Idempotent. Returns `skipped: true` when no routing config is supplied — the
|
|
105
|
+
* routing target and inbound address are an operator choice (and must not disturb the apex MX), so a
|
|
106
|
+
* project that hasn't decided yet provisions everything else and adds the rule later.
|
|
107
|
+
*/
|
|
108
|
+
ensureRoutingRule(): Promise<{ created: boolean; skipped: boolean }>;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** What provisioning produced. */
|
|
112
|
+
export interface EmailProvisionResult {
|
|
113
|
+
suppressionDatabaseId: string;
|
|
114
|
+
environments: ManagedEnvironment[];
|
|
115
|
+
/** Whether the inbound routing rule was created, already present, or skipped (not configured). */
|
|
116
|
+
routing: { created: boolean; skipped: boolean };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Provision the email infrastructure: create + migrate the shared suppression DB once, then deploy the
|
|
121
|
+
* email worker for every managed environment. The order matters — the suppression DB exists and is
|
|
122
|
+
* migrated before any worker that binds it is deployed. Idempotent end to end (each step is).
|
|
123
|
+
*
|
|
124
|
+
* `environments` is the project's declaration from the root `pithy.config.ts` (#241). Every declared
|
|
125
|
+
* environment is provisioned; an environment this skipped would be one the project deploys to with no
|
|
126
|
+
* resources behind it — the silence the closed `ManagedEnvironment` enum used to produce.
|
|
127
|
+
*/
|
|
128
|
+
export async function provisionEmail(
|
|
129
|
+
provisioner: EmailProvisioner,
|
|
130
|
+
environments: DeclaredEnvironments | readonly string[],
|
|
131
|
+
): Promise<EmailProvisionResult> {
|
|
132
|
+
await provisioner.preflight();
|
|
133
|
+
const { databaseId } = await provisioner.ensureSuppressionDatabase();
|
|
134
|
+
await provisioner.migrateSuppression(databaseId);
|
|
135
|
+
for (const env of managedEnvironments(environments)) {
|
|
136
|
+
await provisioner.deployWorker(env, databaseId);
|
|
137
|
+
}
|
|
138
|
+
// One inbound routing rule per domain (production app worker), after the workers are up.
|
|
139
|
+
const routing = await provisioner.ensureRoutingRule();
|
|
140
|
+
return { suppressionDatabaseId: databaseId, environments: managedEnvironments(environments), routing };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The teardown seam — the inverse of {@link EmailProvisioner}. Every step idempotent (a missing resource is a no-op). */
|
|
144
|
+
export interface EmailDeprovisioner {
|
|
145
|
+
/** Delete the env's email worker. Idempotent (a missing worker is a no-op). */
|
|
146
|
+
deleteWorker(env: ManagedEnvironment): Promise<void>;
|
|
147
|
+
/**
|
|
148
|
+
* Delete the shared suppression D1. **Destructive** — the global suppression list is lost, so every
|
|
149
|
+
* environment forgets who unsubscribed or hard-bounced — so the orchestration only calls it when
|
|
150
|
+
* explicitly asked. Idempotent.
|
|
151
|
+
*/
|
|
152
|
+
deleteSuppressionDatabase(): Promise<void>;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Teardown options. By default the suppression DB is **kept** — losing the global opt-out list is harmful. */
|
|
156
|
+
export interface EmailDeprovisionOptions {
|
|
157
|
+
/** Also delete the shared suppression database. Off by default; only a full destroy sets it. */
|
|
158
|
+
deleteSuppression?: boolean;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Tear down the email infrastructure, reversing {@link provisionEmail}: delete every environment's worker
|
|
163
|
+
* first (they bind the suppression DB), then — only when `deleteSuppression` is set — the shared
|
|
164
|
+
* suppression DB. The suppression list is preserved unless explicitly requested. Idempotent end to end.
|
|
165
|
+
*
|
|
166
|
+
* `environments` is the project's declaration from the root `pithy.config.ts` (#241). Every declared
|
|
167
|
+
* environment is provisioned; an environment this skipped would be one the project deploys to with no
|
|
168
|
+
* resources behind it — the silence the closed `ManagedEnvironment` enum used to produce.
|
|
169
|
+
*/
|
|
170
|
+
export async function deprovisionEmail(
|
|
171
|
+
deprovisioner: EmailDeprovisioner,
|
|
172
|
+
environments: DeclaredEnvironments | readonly string[],
|
|
173
|
+
options: EmailDeprovisionOptions = {},
|
|
174
|
+
): Promise<void> {
|
|
175
|
+
for (const env of managedEnvironments(environments)) {
|
|
176
|
+
await deprovisioner.deleteWorker(env);
|
|
177
|
+
}
|
|
178
|
+
if (options.deleteSuppression) await deprovisioner.deleteSuppressionDatabase();
|
|
179
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { ValidationError } from "@pithy-sh/core/src/error/pithyError";
|
|
5
|
+
import type { LocaleCatalogs } from "@pithy-sh/core/src/i18n/catalog";
|
|
6
|
+
import type {
|
|
7
|
+
HostD1Binding,
|
|
8
|
+
HostSecretsStoreBinding,
|
|
9
|
+
HostSendEmailBinding,
|
|
10
|
+
HostWorkflowBinding,
|
|
11
|
+
WorkflowHostTemplate,
|
|
12
|
+
} from "@pithy-sh/core/src/workflow/host";
|
|
13
|
+
import { hostWorkflowsFor, resolveWorkflowHost } from "@pithy-sh/core/src/workflow/host";
|
|
14
|
+
import { workflowKey } from "@pithy-sh/core/src/workflow/naming";
|
|
15
|
+
import type { WorkflowRegistry } from "@pithy-sh/core/src/workflow/spec";
|
|
16
|
+
import { masterKeySecretName } from "@pithy-sh/secrets/src/provision/provisionSecrets";
|
|
17
|
+
import type { ManagedEnvironment } from "@pithy-sh/secrets/src/scope";
|
|
18
|
+
import { emailCatalogVarName } from "../templates/messages";
|
|
19
|
+
import type { EmailTheme } from "../templates/theme";
|
|
20
|
+
import { EmailScheduleParams, EmailSendParams } from "../workflows/params";
|
|
21
|
+
import { type DevMailDelivery, emailRemoteBindings } from "./devDelivery";
|
|
22
|
+
import { EMAIL_CAPABILITY, suppressionDatabaseName } from "./provisionEmail";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The email worker's `wrangler.jsonc` template shape. Email invented the prebuilt-host convention;
|
|
26
|
+
* `@pithy-sh/core`'s {@link WorkflowHostTemplate} is the generalization of it, so this is now that
|
|
27
|
+
* contract with the fields email's committed template always carries narrowed to required. The
|
|
28
|
+
* committed template (`src/workflows/wrangler.jsonc`) stays the source of truth for the static fields
|
|
29
|
+
* (compatibility date, the every-minute cron, class names, the `send_email` binding, theme vars).
|
|
30
|
+
*/
|
|
31
|
+
export interface EmailWorkerWranglerTemplate extends WorkflowHostTemplate {
|
|
32
|
+
compatibility_date: string;
|
|
33
|
+
compatibility_flags: string[];
|
|
34
|
+
workers_dev: boolean;
|
|
35
|
+
d1_databases: HostD1Binding[];
|
|
36
|
+
send_email: HostSendEmailBinding[];
|
|
37
|
+
secrets_store_secrets: HostSecretsStoreBinding[];
|
|
38
|
+
workflows: HostWorkflowBinding[];
|
|
39
|
+
triggers: { crons: string[] };
|
|
40
|
+
vars: Record<string, string>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The two durable jobs email's host runs, as a {@link WorkflowRegistry} — the shape
|
|
45
|
+
* {@link hostWorkflowsFor} derives project-scoped Workflow names from.
|
|
46
|
+
*
|
|
47
|
+
* Email declares these jobs on its `Capability` (`capability.ts`) rather than in a `workflows/specs.ts`
|
|
48
|
+
* the way every later capability does — it predates that convention. The host resolver needs a registry
|
|
49
|
+
* and cannot reach into a capability instance (building one requires an adopter's config), so the two
|
|
50
|
+
* jobs are mirrored here, and `resolveEmailConfig.test.ts` asserts the bindings and class names match
|
|
51
|
+
* the committed template byte for byte.
|
|
52
|
+
*
|
|
53
|
+
* **`params` is no longer a placeholder** (pithy-sh/pithy#410). The host mounts the shared dispatch
|
|
54
|
+
* route, and that route validates an arriving loopback payload against the declaring spec's own
|
|
55
|
+
* schema before it starts anything — so the registry's `params` is the request contract of a real
|
|
56
|
+
* HTTP surface. Both sides therefore read the one schema out of `workflows/params.ts`; a `z.unknown()`
|
|
57
|
+
* here would let a malformed dispatch through to fail inside a durable instance instead.
|
|
58
|
+
*/
|
|
59
|
+
const EMAIL_HOST_JOBS = {
|
|
60
|
+
send: { binding: "EMAIL_SENDER", className: "EmailSendWorkflow", params: EmailSendParams },
|
|
61
|
+
schedule: {
|
|
62
|
+
binding: "EMAIL_SCHEDULER",
|
|
63
|
+
className: "EmailSchedulerWorkflow",
|
|
64
|
+
params: EmailScheduleParams,
|
|
65
|
+
schedule: "* * * * *",
|
|
66
|
+
},
|
|
67
|
+
} as const;
|
|
68
|
+
|
|
69
|
+
/** The registry the host resolver derives its `workflows` array from, and the host's app dispatches on. */
|
|
70
|
+
export const emailWorkflowRegistry: WorkflowRegistry = Object.fromEntries(
|
|
71
|
+
Object.entries(EMAIL_HOST_JOBS).map(([job, spec]) => {
|
|
72
|
+
const key = workflowKey(EMAIL_CAPABILITY, job);
|
|
73
|
+
return [key, { key, capability: EMAIL_CAPABILITY, job, spec }];
|
|
74
|
+
}),
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The most one Cloudflare Worker variable holds. **Per variable, and that is the whole design.**
|
|
79
|
+
*
|
|
80
|
+
* Cloudflare's documented limit is 5 KB per variable, on Free and Paid alike, and a Worker carries 64
|
|
81
|
+
* of them on Free and 128 on Paid. The failure it produces is API error 10054 at upload — a wrangler
|
|
82
|
+
* exit nobody can act on, in the middle of a provision run that has already created databases.
|
|
83
|
+
*
|
|
84
|
+
* So each locale travels in its own variable. One pack of the kit's Spanish is about 3 KB, which
|
|
85
|
+
* leaves 2 KB of room inside its own ceiling, and twenty languages is twenty variables rather than one
|
|
86
|
+
* 62 KB value that is refused outright. The host renders one email in one locale and never needs the
|
|
87
|
+
* other nineteen, so nothing is paid for the split.
|
|
88
|
+
*
|
|
89
|
+
* The check below therefore guards what it should: a single language pack growing past what a variable
|
|
90
|
+
* holds, which would take the kit's email copy growing by about two thirds. It is deliberately a
|
|
91
|
+
* refusal and not a truncation — a shortened catalog is a letter half in Spanish and half in English,
|
|
92
|
+
* with nothing anywhere failing to say so.
|
|
93
|
+
*/
|
|
94
|
+
export const MAX_WORKER_VAR_BYTES = 5 * 1024;
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* One `EMAIL_MESSAGES_<LOCALE>` var per locale, or nothing when the project has no catalogs to carry.
|
|
98
|
+
*
|
|
99
|
+
* A locale with an empty catalog is omitted rather than written as `"{}"`: the host reads an absent
|
|
100
|
+
* variable as the kit's English, which is exactly what that means, and an empty object in the deployed
|
|
101
|
+
* config reads like a value somebody failed to fill.
|
|
102
|
+
*
|
|
103
|
+
* **Exported because this is not the only host that carries them.** `@pithy-sh/testers`' daily-pass
|
|
104
|
+
* worker reads the same variables through the same `catalogsFromEnv` seam, against the same ceiling —
|
|
105
|
+
* so the limit, the naming and the refusal are composed here once rather than restated there, where
|
|
106
|
+
* the second copy is the one that drifts.
|
|
107
|
+
*/
|
|
108
|
+
export function emailMessagesVars(messages: LocaleCatalogs | undefined): Record<string, string> {
|
|
109
|
+
if (!messages) return {};
|
|
110
|
+
const vars: Record<string, string> = {};
|
|
111
|
+
for (const [locale, catalog] of Object.entries(messages)) {
|
|
112
|
+
if (!catalog || Object.keys(catalog).length === 0) continue;
|
|
113
|
+
const serialized = JSON.stringify(catalog);
|
|
114
|
+
const bytes = new TextEncoder().encode(serialized).length;
|
|
115
|
+
if (bytes > MAX_WORKER_VAR_BYTES) {
|
|
116
|
+
throw new ValidationError({
|
|
117
|
+
message: `The \`${locale}\` email catalog is ${bytes} bytes; a Cloudflare Worker variable holds ${MAX_WORKER_VAR_BYTES}.`,
|
|
118
|
+
action: `Shorten the \`email/\` sentences for \`${locale}\` in i18n({ messages }), or drop that locale from supportedLocales.`,
|
|
119
|
+
detail: `${emailCatalogVarName(locale)} serialized to ${bytes} bytes over the ${MAX_WORKER_VAR_BYTES}-byte limit; Cloudflare refuses the upload with error 10054.`,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
vars[emailCatalogVarName(locale)] = serialized;
|
|
123
|
+
}
|
|
124
|
+
return vars;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The resolved resource ids + per-env values for one environment's email-worker deploy. */
|
|
128
|
+
export interface EmailConfigParams {
|
|
129
|
+
/**
|
|
130
|
+
* The project name — the `<project>` segment the worker, both Workflows, and the suppression
|
|
131
|
+
* database name lead with. The root `pithy.config.ts` `name`, resolved by `requireProjectName` and
|
|
132
|
+
* never guessed: Worker script and Workflow names are account-scoped, so a wrong value here
|
|
133
|
+
* overwrites another project's running email host.
|
|
134
|
+
*/
|
|
135
|
+
project: string;
|
|
136
|
+
/**
|
|
137
|
+
* The environment being resolved. `dev` as well as a deployed one: `pithy dev` resolves this same
|
|
138
|
+
* template into the local host config it runs, which is what {@link EmailConfigParams.devDelivery}
|
|
139
|
+
* governs.
|
|
140
|
+
*/
|
|
141
|
+
env: ManagedEnvironment | "dev";
|
|
142
|
+
/** The app database id for this environment — where jobs/events live. */
|
|
143
|
+
appDatabaseId: string;
|
|
144
|
+
/** The shared suppression database id (same in every environment). */
|
|
145
|
+
suppressionDatabaseId: string;
|
|
146
|
+
/** This environment's secrets database id (`<project>-<env>-secrets`) — holds the signing key. */
|
|
147
|
+
secretsDatabaseId: string;
|
|
148
|
+
/** The CF Secrets Store id holding the per-env master key. */
|
|
149
|
+
storeId: string;
|
|
150
|
+
/** The app worker's public base URL for this environment — callback links are built against it. */
|
|
151
|
+
baseUrl: string;
|
|
152
|
+
/** The resolved brand theme — serialized into the worker's `EMAIL_THEME` var. */
|
|
153
|
+
theme: EmailTheme;
|
|
154
|
+
/**
|
|
155
|
+
* This project's email catalogs — serialized into the worker's `EMAIL_MESSAGES` var.
|
|
156
|
+
*
|
|
157
|
+
* The same journey `theme` makes, and for the same reason: the host composes no capabilities, so
|
|
158
|
+
* anything the adopter configured has to arrive as data. `emailHostCatalogs` builds it from the
|
|
159
|
+
* composed project's layers; a project that composed no i18n capability passes `{}` or omits it, and
|
|
160
|
+
* the var is not written at all.
|
|
161
|
+
*/
|
|
162
|
+
messages?: LocaleCatalogs;
|
|
163
|
+
/**
|
|
164
|
+
* What the host's `send_email` binding does under `pithy dev` — the adopter's `email({ devDelivery })`.
|
|
165
|
+
* Defaults to `remote`, which sends real mail from the developer's machine. Ignored outside `dev`.
|
|
166
|
+
*/
|
|
167
|
+
devDelivery?: DevMailDelivery;
|
|
168
|
+
// No `schedulerEnabled`. `EmailConfig.schedulerEnabled` is parsed and exposed but never arrives
|
|
169
|
+
// here, so the template's hardcoded SCHEDULER_ENABLED="true" always wins — a known defect, filed
|
|
170
|
+
// rather than fixed, because closing it changes this signature and the provisioner's option bag,
|
|
171
|
+
// both of which are pinned by tests.
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Resolve the email-worker `wrangler.jsonc` template into one environment's standalone config — no
|
|
176
|
+
* `[env.*]` stanzas (staging and prod are genuinely separate workers, per CLAUDE.md).
|
|
177
|
+
*
|
|
178
|
+
* The mechanics are `@pithy-sh/core`'s {@link resolveWorkflowHost}: this is the email-shaped face of
|
|
179
|
+
* it, mapping email's seven inputs onto the generic host params. What stays here is the part that is
|
|
180
|
+
* genuinely email's — which binding takes which database id, and the `@pithy-sh/secrets` import that
|
|
181
|
+
* names the env-scoped master key. Core must never depend on `@pithy-sh/secrets`, so the resolved
|
|
182
|
+
* string is passed in rather than the naming rule being hoisted.
|
|
183
|
+
*
|
|
184
|
+
* `send_email`'s `remote` flag is the one thing here that is a *decision* rather than a fill: real
|
|
185
|
+
* delivery is the default in every environment, and `dev` alone may choose the local simulator
|
|
186
|
+
* instead (`devDelivery.ts`).
|
|
187
|
+
*
|
|
188
|
+
* Only `EMAIL_SUPPRESSIONS`'s `database_name` is rewritten. That database is email's own, and its name
|
|
189
|
+
* now carries the project — leaving the template's `pithy-email-suppressions` in place would print a
|
|
190
|
+
* name no account holds. `pithy-app` and `pithy-secrets` are owned elsewhere and pass through
|
|
191
|
+
* untouched; only their ids differ per environment.
|
|
192
|
+
*/
|
|
193
|
+
export function resolveEmailConfig(
|
|
194
|
+
template: EmailWorkerWranglerTemplate,
|
|
195
|
+
params: EmailConfigParams,
|
|
196
|
+
): EmailWorkerWranglerTemplate {
|
|
197
|
+
const { project, env, appDatabaseId, suppressionDatabaseId, secretsDatabaseId, storeId, baseUrl, theme } = params;
|
|
198
|
+
const resolved = resolveWorkflowHost(template, {
|
|
199
|
+
project,
|
|
200
|
+
capability: EMAIL_CAPABILITY,
|
|
201
|
+
env,
|
|
202
|
+
databaseIds: {
|
|
203
|
+
DB: appDatabaseId,
|
|
204
|
+
EMAIL_SUPPRESSIONS: suppressionDatabaseId,
|
|
205
|
+
SECRETS: secretsDatabaseId,
|
|
206
|
+
},
|
|
207
|
+
databaseNames: { EMAIL_SUPPRESSIONS: suppressionDatabaseName(project) },
|
|
208
|
+
secretsStoreId: storeId,
|
|
209
|
+
// The master key entry is project- and env-scoped, matching what the secrets manager wrote.
|
|
210
|
+
masterKeySecretName: masterKeySecretName(project, env),
|
|
211
|
+
// The theme and the catalogs travel the same way, and they have to: the host composes no
|
|
212
|
+
// capabilities, so the brand and the words are both things only a provision run can hand it.
|
|
213
|
+
vars: { EMAIL_THEME: JSON.stringify(theme), BASE_URL: baseUrl, ...emailMessagesVars(params.messages) },
|
|
214
|
+
// The `send_email` binding's `remote` flag, which the committed template deliberately no longer
|
|
215
|
+
// carries: the resolver only ever *adds* `remote`, so a hardcoded `true` could never be turned
|
|
216
|
+
// off and the documented simulator flag would have had nothing to act on. See `devDelivery.ts`.
|
|
217
|
+
remoteBindings: emailRemoteBindings(env, params.devDelivery ?? "remote"),
|
|
218
|
+
// Both Workflows, derived from the registry. A Workflow name is account-scoped, so the deployed
|
|
219
|
+
// name has to carry the project — the template's `pithy-email-send` cannot be suffixed into one.
|
|
220
|
+
workflows: hostWorkflowsFor(emailWorkflowRegistry, { project, capability: EMAIL_CAPABILITY, env }).workflows,
|
|
221
|
+
});
|
|
222
|
+
// The resolver fills fields; it never drops one. So every field this template narrows to required
|
|
223
|
+
// survives — knowledge the generic return type cannot express, restored here.
|
|
224
|
+
return resolved as EmailWorkerWranglerTemplate;
|
|
225
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { D1Database } from "@cloudflare/workers-types";
|
|
5
|
+
import type {
|
|
6
|
+
CapabilitySettings,
|
|
7
|
+
SettingsAccountContext,
|
|
8
|
+
SettingsCheckContext,
|
|
9
|
+
SettingsFinding,
|
|
10
|
+
} from "@pithy-sh/core/src/capability/settings";
|
|
11
|
+
import { hostEnvFindings } from "@pithy-sh/core/src/capability/settings";
|
|
12
|
+
import { checkHostEnv } from "@pithy-sh/core/src/workflow/hostEnv";
|
|
13
|
+
import { EMAIL_LINK_SIGNING_KEY } from "../crypto/signingKey";
|
|
14
|
+
import type { EmailTheme } from "../templates/theme";
|
|
15
|
+
import { emailHostEnv } from "../workflows/hostEnv";
|
|
16
|
+
import { suppressionDatabaseName } from "./provisionEmail";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Whether email's settings **work**, as `pithy doctor` asks it (pithy-sh/pithy#411).
|
|
20
|
+
*
|
|
21
|
+
* Every check doctor had before this one asked about presence: the `email({ … })` option keys are
|
|
22
|
+
* written, the `DB` and `EMAIL_SENDER` bindings are declared, the ledger is level. All of them pass while
|
|
23
|
+
* `fromAddress` names a domain nobody onboarded, the link-signing key was never created, `BASE_URL` is
|
|
24
|
+
* staging's URL in production's config, and the suppression database does not exist — and the way an
|
|
25
|
+
* adopter learns any of that is a message that never arrives.
|
|
26
|
+
*
|
|
27
|
+
* ## The local tier runs the host's own schema
|
|
28
|
+
*
|
|
29
|
+
* `checkHostEnv(emailHostEnv, …)` is not a second reading of the same rules — it is *the* reading. The
|
|
30
|
+
* prebuilt host worker refuses to start on the same declaration (`workflows/worker.ts`), so a value doctor
|
|
31
|
+
* calls good is a value the host will accept, and a rule that changes changes in one file. Bindings are
|
|
32
|
+
* stubbed on the way in, deliberately: whether `DB` is bound is `pithy doctor`'s `bindings` check and
|
|
33
|
+
* `Secret bindings:` block, and asking it twice would report one fault as two.
|
|
34
|
+
*
|
|
35
|
+
* ## The account tier asks the three things only the account knows
|
|
36
|
+
*
|
|
37
|
+
* Is the sending domain a zone here, does the suppression database exist, does the signing key have a
|
|
38
|
+
* value. Each costs one Cloudflare call, each is skipped whole when the account cannot be reached, and
|
|
39
|
+
* none of them is inferable from a file in the checkout.
|
|
40
|
+
*
|
|
41
|
+
* Nothing here writes. Every finding names the command, the config key, or the one-time dashboard action
|
|
42
|
+
* that resolves it.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
/** The resolved config slice the check reads. The capability builds it; nothing here parses config. */
|
|
46
|
+
export interface EmailSettingsInput {
|
|
47
|
+
/** The address every message is sent from — its domain is what must be onboarded. */
|
|
48
|
+
fromAddress: string;
|
|
49
|
+
/** The public base URL every link in a message is built against. */
|
|
50
|
+
baseUrl: string;
|
|
51
|
+
/** The resolved theme, as the host receives it: one JSON var. */
|
|
52
|
+
theme?: EmailTheme;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A stand-in for a binding, so the local tier judges values rather than wiring.
|
|
57
|
+
*
|
|
58
|
+
* `EmailHostEnv` duck-types its bindings — it asserts the method the host calls, because these are host
|
|
59
|
+
* objects the runtime hands over and there is no class to compare against. That is what makes a stub
|
|
60
|
+
* possible here at all, and it is why the stub carries the methods rather than being an empty object: the
|
|
61
|
+
* schema would refuse `{}` and the report would fill with wiring faults doctor already states elsewhere.
|
|
62
|
+
*/
|
|
63
|
+
function stubBindings(): Record<string, unknown> {
|
|
64
|
+
const d1 = { prepare: () => undefined } as unknown as D1Database;
|
|
65
|
+
return {
|
|
66
|
+
DB: d1,
|
|
67
|
+
EMAIL_SUPPRESSIONS: d1,
|
|
68
|
+
SECRETS: d1,
|
|
69
|
+
SECRETS_ENCRYPTION_KEYS: "checked elsewhere",
|
|
70
|
+
EMAIL: { send: () => undefined },
|
|
71
|
+
EMAIL_SENDER: { create: () => undefined, get: () => undefined },
|
|
72
|
+
EMAIL_SCHEDULER: { create: () => undefined },
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The domain half of an address, lowercased, or `null` where the address carries none usable. */
|
|
77
|
+
function domainOf(address: string): string | null {
|
|
78
|
+
const at = address.lastIndexOf("@");
|
|
79
|
+
if (at < 0) return null;
|
|
80
|
+
const domain = address
|
|
81
|
+
.slice(at + 1)
|
|
82
|
+
.trim()
|
|
83
|
+
.toLowerCase();
|
|
84
|
+
return domain.includes(".") ? domain : null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The origin of a URL, or `null` when it is not one. Compared origin-to-origin, never string-to-string. */
|
|
88
|
+
function originOf(url: string): string | null {
|
|
89
|
+
try {
|
|
90
|
+
return new URL(url).origin;
|
|
91
|
+
} catch {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The host's own parse of the values this composition would hand it.
|
|
98
|
+
*
|
|
99
|
+
* Run **once**, not once per environment, and reported with no environment named: `email({ … })` is one
|
|
100
|
+
* config object per Worker, so a `BASE_URL` that is not a URL is one edit however many environments a
|
|
101
|
+
* project declares. Three lines about one key is how a report stops being read.
|
|
102
|
+
*/
|
|
103
|
+
function localHostEnv(config: EmailSettingsInput): SettingsFinding[] {
|
|
104
|
+
const candidate = {
|
|
105
|
+
...stubBindings(),
|
|
106
|
+
BASE_URL: config.baseUrl,
|
|
107
|
+
// Serialized exactly as `resolveEmailConfig` stamps it, so what is checked is what the host parses —
|
|
108
|
+
// a theme that resolves in TypeScript and does not survive a round trip through one JSON var is the
|
|
109
|
+
// failure this catches.
|
|
110
|
+
...(config.theme ? { EMAIL_THEME: JSON.stringify(config.theme) } : {}),
|
|
111
|
+
};
|
|
112
|
+
return hostEnvFindings(checkHostEnv(emailHostEnv, candidate), null);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Whether the configured base URL is an origin this project actually answers on.
|
|
117
|
+
*
|
|
118
|
+
* `pithy email provision` stamps each host's `BASE_URL` from that environment's *resolved worker address*,
|
|
119
|
+
* so a deployed host is right by construction — but `email({ baseUrl })` is what `pithy dev` renders links
|
|
120
|
+
* against and what the repository states its intent with, and a value matching no declared origin is a
|
|
121
|
+
* link to a host nothing serves. Asked only when some environment declares one: a project before its first
|
|
122
|
+
* domain has nothing to compare against, and `Origins:` already reports that.
|
|
123
|
+
*/
|
|
124
|
+
function localBaseUrlOrigin(config: EmailSettingsInput, context: SettingsCheckContext): SettingsFinding[] {
|
|
125
|
+
const declared = context.environments
|
|
126
|
+
.map((environment) => (environment.origin === null ? null : originOf(environment.origin)))
|
|
127
|
+
.filter((origin): origin is string => origin !== null);
|
|
128
|
+
const configured = originOf(config.baseUrl);
|
|
129
|
+
if (declared.length === 0 || configured === null || declared.includes(configured)) return [];
|
|
130
|
+
return [
|
|
131
|
+
{
|
|
132
|
+
setting: "BASE_URL",
|
|
133
|
+
environment: null,
|
|
134
|
+
problem: `Links are built against ${configured}, and no environment this project declares answers on it.`,
|
|
135
|
+
action: `Set \`email({ baseUrl })\` to an origin this project serves: ${declared.join(", ")}.`,
|
|
136
|
+
},
|
|
137
|
+
];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** The address question, asked once: it is one config key, and one edit fixes every environment. */
|
|
141
|
+
function localForAddress(config: EmailSettingsInput): SettingsFinding[] {
|
|
142
|
+
if (domainOf(config.fromAddress) !== null) return [];
|
|
143
|
+
return [
|
|
144
|
+
{
|
|
145
|
+
setting: "fromAddress",
|
|
146
|
+
environment: null,
|
|
147
|
+
problem: `${config.fromAddress} is not an address a sending domain can be read from.`,
|
|
148
|
+
action: "Set `email({ fromAddress })` to an address on a domain you have onboarded onto Email Service.",
|
|
149
|
+
},
|
|
150
|
+
];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The last declared environment — the one a project-wide remedy is named for. */
|
|
154
|
+
function lastEnvironment(context: SettingsCheckContext): string {
|
|
155
|
+
return context.environments.at(-1)?.name ?? "prod";
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The account half: the zone, the suppression database, and the signing key. */
|
|
159
|
+
async function accountFindings(
|
|
160
|
+
config: EmailSettingsInput,
|
|
161
|
+
context: SettingsAccountContext,
|
|
162
|
+
): Promise<SettingsFinding[]> {
|
|
163
|
+
const findings: SettingsFinding[] = [];
|
|
164
|
+
|
|
165
|
+
const domain = domainOf(config.fromAddress);
|
|
166
|
+
// A domain the local tier already refused is not asked about again: it named the edit, and a second
|
|
167
|
+
// line about the same key would send the operator looking for a second problem.
|
|
168
|
+
if (domain !== null && !(await context.account.zone(domain))) {
|
|
169
|
+
findings.push({
|
|
170
|
+
setting: "fromAddress",
|
|
171
|
+
environment: null,
|
|
172
|
+
problem: `${domain} is not a zone on this Cloudflare account, so it cannot be onboarded onto Email Service.`,
|
|
173
|
+
action: `Add ${domain} to this Cloudflare account, then onboard it onto Email Service in the dashboard.`,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const suppressions = suppressionDatabaseName(context.project);
|
|
178
|
+
if (!(await context.account.d1Databases()).includes(suppressions)) {
|
|
179
|
+
findings.push({
|
|
180
|
+
setting: "EMAIL_SUPPRESSIONS",
|
|
181
|
+
environment: null,
|
|
182
|
+
problem: `No D1 database named ${suppressions} exists on this account.`,
|
|
183
|
+
// One database for the whole project, so the remedy is one run in any environment — named as the
|
|
184
|
+
// last declared one, which is the environment an operator is least likely to have skipped.
|
|
185
|
+
action: `Run \`pithy email provision --env ${lastEnvironment(context)}\`. Nothing is suppressed until it exists.`,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
for (const environment of context.environments) {
|
|
190
|
+
// `dev` has no manager Worker to ask: a `d1` secret's value is sealed under a master key that never
|
|
191
|
+
// leaves the environment's manager, and local dev has none. Asking would be answered by a refusal,
|
|
192
|
+
// which the runner would report as an unchecked capability rather than as this clean pass.
|
|
193
|
+
if (environment.name === "dev") continue;
|
|
194
|
+
if (await context.account.secret({ name: EMAIL_LINK_SIGNING_KEY, environment: environment.name })) continue;
|
|
195
|
+
findings.push({
|
|
196
|
+
setting: EMAIL_LINK_SIGNING_KEY,
|
|
197
|
+
environment: environment.name,
|
|
198
|
+
problem: `The link-signing key has no value in ${environment.name}, so no tracking or unsubscribe link can be signed.`,
|
|
199
|
+
action: `Run \`pithy secrets provision --env ${environment.name}\`.`,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return findings;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Email's settings check, built from one composition's resolved config. Declared on the capability. */
|
|
207
|
+
export function emailSettings(config: EmailSettingsInput): CapabilitySettings {
|
|
208
|
+
return {
|
|
209
|
+
local: (context) => [...localHostEnv(config), ...localForAddress(config), ...localBaseUrlOrigin(config, context)],
|
|
210
|
+
account: (context) => accountFindings(config, context),
|
|
211
|
+
};
|
|
212
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Batch identity — the one rule every dispatcher of a send Workflow keeps (pithy-sh/pithy#342).
|
|
6
|
+
*
|
|
7
|
+
* **A job's `batchId` names the send Workflow instance that is coming for it, and nothing else.** Null
|
|
8
|
+
* means no instance is. There is no third meaning, and in particular it is not a history of which batch
|
|
9
|
+
* touched the row last.
|
|
10
|
+
*
|
|
11
|
+
* It binds over every row in a status `runScheduler` queries — `scheduled`, `pending`, `sending` — which
|
|
12
|
+
* is every row the id can be read from. A terminal row (`sent`, `failed`, `suppressed`, `bounced`,
|
|
13
|
+
* `canceled`) keeps whatever it last carried, because no tick will ever look at it and clearing it
|
|
14
|
+
* would be a write bought for nobody. What that costs is exactly one obligation, and it is `retryJob`'s:
|
|
15
|
+
* the write that brings a terminal row back into the queried set must set this in the same statement.
|
|
16
|
+
*
|
|
17
|
+
* So the rule for a future writer is short. **If you move a job into `scheduled`, `pending` or
|
|
18
|
+
* `sending`, you own this column in that statement.** Three places do today.
|
|
19
|
+
*
|
|
20
|
+
* That is the whole basis of the scheduler's veto. `runScheduler` leaves a stale-looking row alone when
|
|
21
|
+
* the runtime says the instance it names is alive, so a row naming an instance that is *not* the one
|
|
22
|
+
* working it turns the veto into a lie in one of two directions:
|
|
23
|
+
*
|
|
24
|
+
* - It names a **dead** instance and a live one is working the row anyway — the tick re-drives, a second
|
|
25
|
+
* Workflow renders and sends, and one person gets two copies.
|
|
26
|
+
* - It names a **live** instance that is not working the row — the tick holds a genuinely stranded job
|
|
27
|
+
* for as long as some unrelated batch keeps running, and the mail does not go out.
|
|
28
|
+
*
|
|
29
|
+
* Three places set a job to a status the scheduler queries, so three places must uphold it: `enqueueEmail`
|
|
30
|
+
* (immediate dispatch), `retryJob` (an operator re-queueing a failure), and `runScheduler` itself (the
|
|
31
|
+
* claim). Each mints the id *before* the write that makes the row queryable and creates the instance
|
|
32
|
+
* under it afterwards, so the worst an interrupted dispatch can leave behind is a row naming an instance
|
|
33
|
+
* that does not exist — which the runtime disowns, which reads as dead, which is recovered. The failure
|
|
34
|
+
* mode of this design is a duplicate render that `runSend` short-circuits, never a duplicate send.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Mint a batch id. A UUID, and deliberately nothing more.
|
|
39
|
+
*
|
|
40
|
+
* It becomes a Workflow instance id, so uniqueness is the only property it needs and the only one
|
|
41
|
+
* anything may read from it: a batch id is not a timestamp, not a shard key, and not sortable. It is
|
|
42
|
+
* here rather than inline at each dispatcher so that the three of them cannot drift into three id
|
|
43
|
+
* schemes, one of which collides and rejects a `create` nobody sees fail.
|
|
44
|
+
*/
|
|
45
|
+
export function mintBatchId(): string {
|
|
46
|
+
return crypto.randomUUID();
|
|
47
|
+
}
|