@pramen/server 0.0.58 → 0.0.60

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/src/index.ts CHANGED
@@ -8,7 +8,10 @@
8
8
  // and codegen load an app.ts for its schema without dragging in the DO runtime.
9
9
 
10
10
  // --- schema authoring ---
11
- export { Entity, defineSchema, renamedFrom, notNull, unique, indexed, hidden, defaultTo, primaryKey, generated, expr, ExprDefault, trigger, partitionOf, DEFAULT_PARTITION } from "./sdk/schema";
11
+ export { Entity, defineSchema, renamedFrom, notNull, unique, indexed, hidden, defaultTo, primaryKey, generated, expr, ExprDefault, ISO_NOW_SQL, trigger, partitionOf, DEFAULT_PARTITION } from "./sdk/schema";
12
+ // The one-off rewrite that goes with `expr.now()` changing shape — see ./sdk/iso-timestamps.
13
+ export { isoTimestampBackfill, timestampColumns, LEGACY_NOW_SQL } from "./sdk/iso-timestamps";
14
+ export type { ExtraTimestampColumns, IsoTimestampBackfillOpts } from "./sdk/iso-timestamps";
12
15
  export type { TriggerDef, TriggerOp } from "./sdk/schema";
13
16
  export { isValidUuid } from "./sdk/uuid";
14
17
  export type {
@@ -31,7 +34,7 @@ export type {
31
34
  // --- app + handlers ---
32
35
  export { createApp } from "./sdk/app";
33
36
  export { query, mutation, authorizeHandler } from "./sdk/handlers";
34
- export type { EnvBag, Handler, HandlerContext, HandlerKind, HandlerMap, HandlerOpts, HandlerAuth, Tasks, TaskHandler, AppTaskMap, BootstrapContext, BootstrapFn } from "./sdk/handlers";
37
+ export type { EnvBag, Handler, HandlerContext, HandlerKind, HandlerMap, HandlerOpts, HandlerAuth, Tasks, TaskHandler, AppTaskMap, BootstrapContext, BootstrapFn, MigrationContext, DataMigration } from "./sdk/handlers";
35
38
 
36
39
  // --- ACL ---
37
40
  export { $identity, $input, $now, allow, deny, policy, resolve, role, isAllow, isDeny, isResolver, isIdentityMarker, isInputMarker, isNowMarker } from "./sdk/acl";
@@ -95,7 +98,7 @@ export type { ExpiringToken } from "./runtime/token";
95
98
  export type { StorageAdapter, PutResult, GetResult } from "./runtime/storage";
96
99
 
97
100
  // --- mail (ctx.mail) ---
98
- export { Mail, CloudflareEmailAdapter, KvMailAdapter, MemoryMailAdapter, UnconfiguredMailAdapter, createMail } from "./runtime/mail";
101
+ export { Mail, CloudflareEmailAdapter, MailgunAdapter, KvMailAdapter, MemoryMailAdapter, UnconfiguredMailAdapter, createMail } from "./runtime/mail";
99
102
  export type { MailMessage, MailAddress, MailAdapter, SendEmailBinding } from "./runtime/mail";
100
103
 
101
104
  // --- queue (ctx.queue — Cloudflare Queues) ---
package/src/pramen.ts CHANGED
@@ -15,7 +15,8 @@
15
15
  import { makeWorker, type Env } from "./worker";
16
16
  import { pramenDO, type DoEnv } from "./durable-object";
17
17
  import { validateTriggerTasks, type SchemaDef } from "./sdk/schema";
18
- import type { AppTaskMap, HandlerMap, BootstrapFn } from "./sdk/handlers";
18
+ import { validateMigrations } from "./runtime/data-migrations";
19
+ import type { AppTaskMap, HandlerMap, BootstrapFn, DataMigration } from "./sdk/handlers";
19
20
  import type { AppQueueMap, QueueBatch } from "./runtime/queue-consumer";
20
21
  import type { Role } from "./sdk/acl";
21
22
  import type { EnvBag } from "./sdk/handlers";
@@ -59,6 +60,12 @@ export interface PramenApp {
59
60
  * code-defined reference data into the store (see `BootstrapFn`). Each runs with a
60
61
  * privileged system Db; failures are logged, never fatal. */
61
62
  bootstrap?: readonly BootstrapFn[];
63
+ /** Imperative DATA migrations — backfills, splits, normalizations — run in declaration
64
+ * order after `migrate()` and before `bootstrap`, each recorded ONCE per (id, partition)
65
+ * in `_pramen_migrations` (see `DataMigration`). The declarative migrator diffs shapes
66
+ * and so can only enact structure; this is the transformation half. Unlike `bootstrap`,
67
+ * a failure is NOT swallowed — it fails the boot closed and retries next fetch. */
68
+ migrations?: readonly DataMigration[];
62
69
  }
63
70
 
64
71
  export type { Env, DoEnv };
@@ -73,6 +80,7 @@ export function createPramen(app: PramenApp): {
73
80
  PramenDO: ReturnType<typeof pramenDO>;
74
81
  } {
75
82
  validateTriggerTasks(app.schema, Object.keys(app.tasks ?? {})); // fail fast on a typo'd trigger task
83
+ validateMigrations(app.schema, app.migrations); // fail fast on a duplicate/empty id or an unknown partition
76
84
  const worker = makeWorker(app);
77
85
  return { fetch: worker.fetch, scheduled: worker.scheduled, queue: worker.queue, PramenDO: pramenDO(app) };
78
86
  }
@@ -0,0 +1,343 @@
1
+ // Data migrations — the imperative, recorded half of schema evolution.
2
+ //
3
+ // `migrate()` (runtime/migrate.ts) is declarative: it diffs the live table shape against
4
+ // the declared schema and enacts the structural delta. A diff between two SHAPES can only
5
+ // ever express structure, never TRANSFORMATION — it cannot split `name` into
6
+ // `firstName`/`lastName`, backfill the nullable column `ADD COLUMN` just created (SQLite
7
+ // can't add NOT NULL to a populated table, so every new column starts as a hole), rewrite
8
+ // `priceHalers` → `priceCzk`, or normalize a `t.json()` blob whose shape changed.
9
+ //
10
+ // So: structure stays declarative, data becomes imperative, ordered, and RECORDED. Each
11
+ // `app.migrations` entry runs at most once per `(id, partition)`, its ledger row written in
12
+ // `_pramen_migrations` in the SAME transaction as the work. That is what makes a
13
+ // non-idempotent transformation safe, and it is precisely why `app.bootstrap` cannot be
14
+ // stretched to cover this (bootstrap runs on EVERY boot, must be idempotent, and swallows
15
+ // its errors).
16
+ //
17
+ // Fail closed. A throwing migration records no ledger row, does not let the boot complete,
18
+ // and propagates — the tenant's request fails and the migration is retried on the next
19
+ // fetch. This mirrors migrate()'s "withhold the schema hash on a skip" invariant: the store
20
+ // is never marked as having reached a state it did not reach.
21
+ //
22
+ // CLAIM FIRST, UNDER A LEASE. The ledger row is taken BEFORE the work — one atomic upsert
23
+ // that inserts a pending row, or steals one whose lease has expired, or does nothing — and
24
+ // the migration runs only if that statement won the row. On the DO this is merely bookkeeping
25
+ // order inside one transaction. On D1 it is what makes the once-only contract hold at all:
26
+ // there is no single writer and no interactive transaction, `d1Ready` is per-isolate, so two
27
+ // cold isolates racing a `SET n = n * 2` backfill would each read an empty ledger, each run
28
+ // it, and quadruple the data. The conflicting upsert is the lock.
29
+ //
30
+ // The row therefore has two states, distinguished by `leaseUntil`: IN FLIGHT (non-NULL — a
31
+ // runner holds it until that instant) and APPLIED (NULL). Only an applied row counts as
32
+ // applied, so an in-flight migration reads as pending everywhere, including the admin ledger.
33
+ //
34
+ // A runner that finds a LIVE lease does not skip — skipping would let it serve traffic
35
+ // against half-migrated data, and would strand the migration entirely if the holder then
36
+ // failed. It waits for the holder, up to WAIT_BUDGET_MS, and then either proceeds (the holder
37
+ // committed), takes the row (the holder released it), or fails closed. A holder that dies
38
+ // without releasing — an isolate evicted mid-backfill, which no compensating DELETE can
39
+ // cover — is recovered by the lease simply expiring.
40
+ //
41
+ // Written against the `Driver`/`Dialect` seam only (no `cloudflare:workers`), so the same
42
+ // runner drives the DO boot path and the D1/Worker path.
43
+
44
+ import { DEFAULT_PARTITION, partitionsOf, type SchemaDef } from "../sdk/schema";
45
+ import type { DataMigration, MigrationContext } from "../sdk/handlers";
46
+ import type { CellValue } from "../sdk/infer";
47
+ import type { Driver } from "./driver";
48
+
49
+ export const MIGRATIONS_TABLE = "_pramen_migrations";
50
+
51
+ /** One applied-migration ledger row. */
52
+ export interface AppliedMigration {
53
+ id: string;
54
+ partition: string;
55
+ /** ISO-8601 UTC instant the migration committed. */
56
+ appliedAt: string;
57
+ }
58
+
59
+ /** Create the ledger if absent. Idempotent — run on every boot before the runner and from
60
+ * the /__migrations endpoint (a DO that has never applied one still answers). Internal
61
+ * table (`_pramen_` prefix), so `isInternalTable()` keeps the migrator's hands off it.
62
+ *
63
+ * `partition` is a reserved-ish word in some engines, so every identifier goes through
64
+ * `dialect.id(...)` (the single `quoteIdent` source of truth) rather than being interpolated
65
+ * bare — here and in every query below. */
66
+ export async function ensureMigrationsTable(driver: Driver): Promise<void> {
67
+ const d = driver.dialect;
68
+ const t = d.id(MIGRATIONS_TABLE);
69
+ await driver.exec(
70
+ `CREATE TABLE IF NOT EXISTS ${t} (` +
71
+ `${d.id("id")} TEXT NOT NULL, ${d.id("partition")} TEXT NOT NULL, ${d.id("appliedAt")} TEXT NOT NULL, ` +
72
+ `${d.id("leaseUntil")} INTEGER, PRIMARY KEY (${d.id("id")}, ${d.id("partition")}))`,
73
+ [],
74
+ );
75
+ // `leaseUntil` was added after the table's first shape. Probe for it with a zero-row SELECT
76
+ // rather than PRAGMA table_info: the PRAGMA is what workerd's DO SQLite authorizer starts
77
+ // rejecting (SQLITE_AUTH) once the alarm API has run in the object, and this runs on every
78
+ // boot. Adding a nullable column is a plain ALTER — no rebuild, and existing rows read as
79
+ // applied, which is what they are.
80
+ const hasLease = await driver
81
+ .exec(`SELECT ${d.id("leaseUntil")} FROM ${t} LIMIT 0`, [])
82
+ .then(() => true)
83
+ .catch(() => false);
84
+ if (!hasLease) await driver.exec(`ALTER TABLE ${t} ADD COLUMN ${d.id("leaseUntil")} INTEGER`, []);
85
+ }
86
+
87
+ /** Ledger rows for one partition — or every partition when `partition` is omitted (the D1
88
+ * store, which holds one shared ledger for all of them). Oldest first. */
89
+ export async function appliedMigrations(driver: Driver, partition?: string): Promise<AppliedMigration[]> {
90
+ const d = driver.dialect;
91
+ // `leaseUntil IS NULL` is the definition of applied: a row a runner is still holding is
92
+ // in flight, and must read as PENDING everywhere — the admin ledger and the CLI included.
93
+ const scope = partition === undefined ? "" : ` AND ${d.id("partition")} = ${d.placeholder(1)}`;
94
+ const where = ` WHERE ${d.id("leaseUntil")} IS NULL${scope}`;
95
+ const rows = await driver.exec(
96
+ `SELECT ${d.id("id")}, ${d.id("partition")}, ${d.id("appliedAt")} FROM ${d.id(MIGRATIONS_TABLE)}${where} ` +
97
+ `ORDER BY ${d.id("appliedAt")}, ${d.id("id")}`,
98
+ partition === undefined ? [] : [d.encode(partition)],
99
+ );
100
+ return rows.map((r) => ({ id: String(r.id), partition: String(r.partition), appliedAt: String(r.appliedAt) }));
101
+ }
102
+
103
+ /** The migrations that belong to `partition`: an entry with no declared partition belongs
104
+ * to the default one. Both boot paths select through this, so "which partition owns this
105
+ * migration" is answered in exactly one place. */
106
+ export function migrationsForPartition(
107
+ migrations: readonly DataMigration[],
108
+ partition: string,
109
+ ): readonly DataMigration[] {
110
+ return migrations.filter((m) => (m.partition ?? DEFAULT_PARTITION) === partition);
111
+ }
112
+
113
+ export interface RunMigrationsResult {
114
+ /** Ids applied by THIS run, in the order they committed. */
115
+ applied: string[];
116
+ /** Ids already in the ledger (a previous boot applied them). */
117
+ skipped: string[];
118
+ }
119
+
120
+ /** Lease timings. Defaults are deliberate, not arbitrary:
121
+ *
122
+ * - `ttlMs` must EXCEED the slowest migration, because a lease that expires under a holder
123
+ * still running is exactly the double-apply this whole mechanism prevents. 60s is above
124
+ * anything that can finish inside a Worker's limits at all, and the docs already require
125
+ * backfills to be bounded.
126
+ * - `waitMs` bounds how long a runner blocks on someone else's live lease before failing
127
+ * closed. Long enough for an ordinary backfill to land (so the waiter proceeds against
128
+ * fully-migrated data), short enough not to hold a request open.
129
+ * - `pollMs` is the re-check interval while waiting. */
130
+ export interface LeaseOpts {
131
+ ttlMs: number;
132
+ waitMs: number;
133
+ pollMs: number;
134
+ }
135
+
136
+ export const DEFAULT_LEASE: LeaseOpts = { ttlMs: 60_000, waitMs: 5_000, pollMs: 100 };
137
+
138
+ export interface RunMigrationsOpts {
139
+ /** The partition to run: only its migrations are selected, and each ledger row is keyed
140
+ * by it. OMIT it on the D1 store — see `runDataMigrations` for why that store runs every
141
+ * declared migration regardless of partition. */
142
+ partition?: string;
143
+ /** Build the privileged context for one migration. Called per migration with the
144
+ * partition its ledger row will be keyed under, so a caller can scope `db` accordingly. */
145
+ makeContext: (partition: string) => MigrationContext;
146
+ /** Override the lease timings (see `LeaseOpts`) — for tests, and for a deployment whose
147
+ * backfills legitimately run long. Absent ⇒ `DEFAULT_LEASE`. */
148
+ lease?: Partial<LeaseOpts>;
149
+ }
150
+
151
+ /** Run every pending migration, in DECLARATION order, each inside its own
152
+ * `driver.transaction()`, CLAIMING its ledger row before it runs the work — so "the work
153
+ * happened" and "the work is recorded" cannot come apart on a substrate with real
154
+ * transactions (the DO), and cannot double-run on one without (D1). A migration whose claim
155
+ * loses the race (another isolate holds it, or a previous boot applied it) is skipped.
156
+ *
157
+ * D1 CAVEAT: `D1Driver.transaction(fn)` is `fn()` (D1 has no interactive transactions), so
158
+ * there the claim and the work do NOT commit together. A throw therefore RELEASES the claim
159
+ * explicitly (a compensating DELETE, issued inside the transaction so the DO simply rolls it
160
+ * back with everything else) — otherwise a failed migration would stay marked as applied,
161
+ * which is the one outcome the fail-closed contract exists to prevent. A holder that dies
162
+ * without reaching that DELETE is covered by the lease expiring instead. Prefer SQL that
163
+ * tolerates a re-run (`WHERE col IS NULL`) when the D1 store is in play: a mid-flight failure
164
+ * leaves the partial writes behind.
165
+ *
166
+ * Fails CLOSED: the first throw aborts the run, so migrations declared after it do not run
167
+ * either (order is a contract — a later one may depend on an earlier one's output). */
168
+ export async function runDataMigrations(
169
+ driver: Driver,
170
+ migrations: readonly DataMigration[],
171
+ opts: RunMigrationsOpts,
172
+ ): Promise<RunMigrationsResult> {
173
+ const applied: string[] = [];
174
+ const skipped: string[] = [];
175
+ const pending = opts.partition === undefined ? migrations : migrationsForPartition(migrations, opts.partition);
176
+ if (pending.length === 0) return { applied, skipped };
177
+
178
+ const lease = { ...DEFAULT_LEASE, ...opts.lease };
179
+ await ensureMigrationsTable(driver);
180
+ const done = new Set((await appliedMigrations(driver, opts.partition)).map((r) => ledgerKey(r.id, r.partition)));
181
+
182
+ for (const m of pending) {
183
+ const partition = m.partition ?? DEFAULT_PARTITION;
184
+ // The cheap pre-check: skip without opening a transaction for what a previous boot
185
+ // already applied (the common case — every boot after the first). `claim` is what
186
+ // actually decides; this only keeps the steady state free of no-op transactions.
187
+ if (done.has(ledgerKey(m.id, partition))) {
188
+ skipped.push(m.id);
189
+ continue;
190
+ }
191
+ let ran = false;
192
+ try {
193
+ await driver.transaction(async () => {
194
+ // Take the row, waiting out any live lease. "applied" means another runner finished
195
+ // it while we waited — nothing left to do, and we now know the data IS migrated.
196
+ if ((await acquire(driver, m.id, partition, lease)) === "applied") return;
197
+ ran = true;
198
+ try {
199
+ await m.up(opts.makeContext(partition));
200
+ await complete(driver, m.id, partition);
201
+ } catch (e) {
202
+ await release(driver, m.id, partition); // no-op on the DO (rolled back anyway)
203
+ throw e;
204
+ }
205
+ });
206
+ } catch (e) {
207
+ // Name the migration in the message: the raw SQL error alone gives no clue WHICH of
208
+ // an ordered list failed, and the boot it aborted is the tenant's first request.
209
+ throw new Error(
210
+ `[pramen] data migration ${JSON.stringify(m.id)} failed (partition=${partition}): ` +
211
+ `${e instanceof Error ? e.message : String(e)}`,
212
+ { cause: e },
213
+ );
214
+ }
215
+ if (!ran) {
216
+ skipped.push(m.id);
217
+ continue;
218
+ }
219
+ console.log(`[pramen] data migration ${JSON.stringify(m.id)} applied (partition=${partition})`);
220
+ applied.push(m.id);
221
+ }
222
+ return { applied, skipped };
223
+ }
224
+
225
+ /** The ledger is keyed by (id, partition) — the same reason the schema hash is
226
+ * `schema_hash:<partition>`: partitions are independent DOs and must not thrash each
227
+ * other's state. NUL-joined, so no (id, partition) pair can collide with another. */
228
+ const ledgerKey = (id: string, partition: string): string => `${id}\u0000${partition}`;
229
+
230
+ /** What one attempt at taking the ledger row found. */
231
+ type ClaimOutcome = "claimed" | "applied" | "held";
232
+
233
+ /** ONE atomic statement that does all three things a claim must: insert the row when the
234
+ * migration is pending, STEAL it when a previous holder's lease has expired (that holder is
235
+ * presumed dead — an isolate evicted mid-backfill releases nothing), and do nothing when the
236
+ * row is applied or a live lease holds it. `RETURNING` fires only when a row was actually
237
+ * written, so it reports which happened. The upsert's `WHERE` is what confines the steal to
238
+ * an expired lease: without it this would be a plain overwrite and two live runners would
239
+ * both "win". */
240
+ async function tryClaim(driver: Driver, id: string, partition: string, now: number, ttlMs: number): Promise<ClaimOutcome> {
241
+ const d = driver.dialect;
242
+ const t = d.id(MIGRATIONS_TABLE);
243
+ const params: CellValue[] = [id, partition, new Date(now).toISOString(), now + ttlMs, now];
244
+ const rows = await driver.exec(
245
+ `INSERT INTO ${t} (${d.id("id")}, ${d.id("partition")}, ${d.id("appliedAt")}, ${d.id("leaseUntil")}) ` +
246
+ `VALUES (${d.placeholder(1)}, ${d.placeholder(2)}, ${d.placeholder(3)}, ${d.placeholder(4)}) ` +
247
+ `ON CONFLICT (${d.id("id")}, ${d.id("partition")}) DO UPDATE SET ` +
248
+ `${d.id("appliedAt")} = excluded.${d.id("appliedAt")}, ${d.id("leaseUntil")} = excluded.${d.id("leaseUntil")} ` +
249
+ `WHERE ${t}.${d.id("leaseUntil")} IS NOT NULL AND ${t}.${d.id("leaseUntil")} <= ${d.placeholder(5)} ` +
250
+ `RETURNING ${d.id("id")}`,
251
+ params.map((v) => d.encode(v)),
252
+ );
253
+ if (rows.length > 0) return "claimed";
254
+ // Nothing written — so the row exists and we did not qualify. Which of the two is it?
255
+ const [row] = await driver.exec(
256
+ `SELECT ${d.id("leaseUntil")} FROM ${t} WHERE ${d.id("id")} = ${d.placeholder(1)} AND ${d.id("partition")} = ${d.placeholder(2)}`,
257
+ [id, partition].map((v) => d.encode(v)),
258
+ );
259
+ // Gone between the two statements (a holder released it) — contended, not applied.
260
+ if (!row) return "held";
261
+ return row.leaseUntil == null ? "applied" : "held";
262
+ }
263
+
264
+ /** Take the row, waiting out a live lease. Returns "applied" when someone else finished it
265
+ * while we waited — in which case the caller must NOT run the migration, but CAN proceed
266
+ * knowing the data is migrated. That distinction is the point: skipping a live lease outright
267
+ * (the previous behavior) let a runner serve traffic against half-migrated data, and stranded
268
+ * the migration on that isolate entirely if the holder then failed.
269
+ *
270
+ * On the DO this never waits — one Durable Object is a single writer and the migration runs
271
+ * inside `blockConcurrencyWhile`, so a second concurrent first-fetch is queued by the platform
272
+ * rather than contending here. The wait exists for D1, where nothing serializes isolates. */
273
+ async function acquire(driver: Driver, id: string, partition: string, lease: LeaseOpts): Promise<"claimed" | "applied"> {
274
+ const deadline = Date.now() + lease.waitMs;
275
+ for (;;) {
276
+ const outcome = await tryClaim(driver, id, partition, Date.now(), lease.ttlMs);
277
+ if (outcome !== "held") return outcome;
278
+ if (Date.now() >= deadline) {
279
+ // Fail closed rather than guess. Another runner is mid-flight; the request retries and
280
+ // will usually find it applied. Serving on through a migration we know is unfinished is
281
+ // the one thing that must not happen.
282
+ throw new Error(`another runner holds the lease and did not finish within ${lease.waitMs}ms`);
283
+ }
284
+ await new Promise((r) => setTimeout(r, lease.pollMs));
285
+ }
286
+ }
287
+
288
+ /** Mark the migration applied: drop the lease (NULL is the definition of applied) and stamp
289
+ * the instant the work actually committed rather than the one the claim was taken. */
290
+ async function complete(driver: Driver, id: string, partition: string): Promise<void> {
291
+ const d = driver.dialect;
292
+ await driver.exec(
293
+ `UPDATE ${d.id(MIGRATIONS_TABLE)} SET ${d.id("appliedAt")} = ${d.placeholder(1)}, ${d.id("leaseUntil")} = NULL ` +
294
+ `WHERE ${d.id("id")} = ${d.placeholder(2)} AND ${d.id("partition")} = ${d.placeholder(3)}`,
295
+ [new Date().toISOString(), id, partition].map((v) => d.encode(v)),
296
+ );
297
+ }
298
+
299
+ /** Give the claim back after a failed `up()`, so the next runner retries immediately instead
300
+ * of waiting out the lease. Only observable where `transaction()` does not roll back (D1); on
301
+ * the DO the enclosing transaction discards this along with the claim itself. Guarded on
302
+ * `leaseUntil IS NOT NULL` so it can only ever delete an IN-FLIGHT row — never an applied one,
303
+ * whatever else has happened to the ledger in between. */
304
+ async function release(driver: Driver, id: string, partition: string): Promise<void> {
305
+ const d = driver.dialect;
306
+ await driver.exec(
307
+ `DELETE FROM ${d.id(MIGRATIONS_TABLE)} WHERE ${d.id("id")} = ${d.placeholder(1)} AND ${d.id("partition")} = ${d.placeholder(2)} ` +
308
+ `AND ${d.id("leaseUntil")} IS NOT NULL`,
309
+ [id, partition].map((v) => d.encode(v)),
310
+ );
311
+ }
312
+
313
+ /** Static validation, called from `createPramen` next to `validateTriggerTasks` — these are
314
+ * declaration bugs, and the only honest time to surface them is before a single tenant has
315
+ * booted. Throws on the first violation:
316
+ *
317
+ * - an empty id (the ledger key would be meaningless);
318
+ * - a duplicate id — ids are GLOBALLY unique across the array, not per partition, so a
319
+ * copy-pasted id can never quietly mark a different migration as already applied;
320
+ * - a declared `partition` no entity lives in — the migration would be dead code on the
321
+ * DO path (no DO serves that partition) while still running on D1. */
322
+ export function validateMigrations(schema: SchemaDef, migrations: readonly DataMigration[] | undefined): void {
323
+ if (!migrations?.length) return;
324
+ const known = new Set(partitionsOf(schema));
325
+ known.add(DEFAULT_PARTITION); // always addressable, even for a schema with no entities
326
+ const seen = new Set<string>();
327
+ for (const m of migrations) {
328
+ if (!m.id) throw new Error("app.migrations: every migration needs a non-empty, stable `id` (it is the ledger key).");
329
+ if (seen.has(m.id)) {
330
+ throw new Error(
331
+ `app.migrations: duplicate id ${JSON.stringify(m.id)}. Ids are the ledger key and must be globally ` +
332
+ `unique — a reused id makes one of the two silently a no-op.`,
333
+ );
334
+ }
335
+ seen.add(m.id);
336
+ if (m.partition !== undefined && !known.has(m.partition)) {
337
+ throw new Error(
338
+ `app.migrations: migration ${JSON.stringify(m.id)} declares partition ${JSON.stringify(m.partition)}, ` +
339
+ `which no entity lives in. Known partitions: ${[...known].join(", ")}.`,
340
+ );
341
+ }
342
+ }
343
+ }
@@ -5,10 +5,15 @@
5
5
  //
6
6
  // await ctx.mail.send({ to: "u@x.com", subject: "Welcome", text: "…" });
7
7
  //
8
- // On Cloudflare the transport is Cloudflare Email Sending (the `send_email`/`EMAIL`
9
- // binding, no API keys). With no verified sender configured (local/dev), mail is
10
- // captured instead of sent to KV (so an e2e/dashboard can read the "inbox") or
11
- // in-memory so handlers work unchanged off-platform.
8
+ // Two real transports. Cloudflare Email Sending (the `send_email`/`EMAIL` binding) needs
9
+ // no API keys, but it can only send FROM a domain that is a zone in the same account,
10
+ // and on some accounts only TO addresses verified in Email Routing which rules it out
11
+ // whenever the recipients are ordinary people. Mailgun is the way out of both: an HTTP
12
+ // API, any recipient, at the cost of a key. Configure it and it wins.
13
+ //
14
+ // With neither configured (local/dev), mail is captured instead of sent — to KV (so an
15
+ // e2e/dashboard can read the "inbox") or in-memory — so handlers work unchanged
16
+ // off-platform.
12
17
 
13
18
  import type { Kv } from "./kv";
14
19
  import type { EnvBag } from "../sdk/handlers";
@@ -83,6 +88,56 @@ export class CloudflareEmailAdapter implements MailAdapter {
83
88
  }
84
89
  }
85
90
 
91
+ /** Mailgun — an HTTP transport, for when Cloudflare Email Sending cannot be used.
92
+ *
93
+ * Worth the key for one reason: Cloudflare will only send from a domain that is a zone
94
+ * in the same account, and some accounts additionally refuse any recipient that is not a
95
+ * verified destination in Email Routing ("destination address is not a verified
96
+ * address"). That is workable for a handful of operators and hopeless for real users.
97
+ * Mailgun asks the domain be verified once, then delivers to anyone.
98
+ *
99
+ * A non-2xx THROWS, deliberately: `ctx.mail.send` is normally called from a task, and a
100
+ * throw is what makes the outbox retry and then dead-letter visibly. Swallowing the
101
+ * status would turn a bounced sign-in link into silence. The response body rides along
102
+ * in the message because Mailgun's 400s are specific and worth reading ("not a valid
103
+ * address", "domain not found"); the key never does. */
104
+ export class MailgunAdapter implements MailAdapter {
105
+ constructor(
106
+ private readonly apiKey: string,
107
+ private readonly domain: string,
108
+ /** `https://api.eu.mailgun.net` for an EU-region account; the two are separate
109
+ * deployments and a key from one 401s against the other. */
110
+ private readonly apiBase: string = "https://api.mailgun.net",
111
+ ) {}
112
+
113
+ async send(message: MailMessage & { from: MailAddress }): Promise<void> {
114
+ const body = new URLSearchParams();
115
+ body.set("from", message.from.name ? `${message.from.name} <${message.from.email}>` : message.from.email);
116
+ for (const to of Array.isArray(message.to) ? message.to : [message.to]) body.append("to", to);
117
+ body.set("subject", message.subject);
118
+ if (message.text) body.set("text", message.text);
119
+ if (message.html) body.set("html", message.html);
120
+ if (message.replyTo) {
121
+ const r = message.replyTo;
122
+ body.set("h:Reply-To", typeof r === "string" ? r : r.name ? `${r.name} <${r.email}>` : r.email);
123
+ }
124
+
125
+ const res = await fetch(`${this.apiBase.replace(/\/+$/, "")}/v3/${encodeURIComponent(this.domain)}/messages`, {
126
+ method: "POST",
127
+ headers: {
128
+ // `api` is the literal username Mailgun expects; the key is the password.
129
+ authorization: `Basic ${btoa(`api:${this.apiKey}`)}`,
130
+ "content-type": "application/x-www-form-urlencoded",
131
+ },
132
+ body,
133
+ });
134
+ if (!res.ok) {
135
+ const detail = await res.text().catch(() => "");
136
+ throw new Error(`mailgun: send failed (${res.status})${detail ? ` — ${detail.slice(0, 300)}` : ""}`);
137
+ }
138
+ }
139
+ }
140
+
86
141
  /** Dev/test transport: stash the message in KV under `mail:<recipient>` so an e2e suite
87
142
  * (or a dashboard) can read the "inbox" instead of really sending. */
88
143
  export class KvMailAdapter implements MailAdapter {
@@ -109,23 +164,39 @@ export class MemoryMailAdapter implements MailAdapter {
109
164
  export class UnconfiguredMailAdapter implements MailAdapter {
110
165
  async send(): Promise<void> {
111
166
  throw new Error(
112
- "ctx.mail: no transport configured — set MAIL_FROM (with the EMAIL binding) to send, " +
113
- "or MAIL_CAPTURE=true to capture in dev.",
167
+ "ctx.mail: no transport configured — set MAIL_FROM with either the EMAIL binding " +
168
+ "or MAILGUN_API_KEY + MAILGUN_DOMAIN to send, or MAIL_CAPTURE=true to capture in dev.",
114
169
  );
115
170
  }
116
171
  }
117
172
 
118
173
  /** Build `ctx.mail` from the environment:
119
- * - `EMAIL` binding + `MAIL_FROM` → Cloudflare Email Sending (real send).
174
+ * - `MAILGUN_API_KEY` + `MAILGUN_DOMAIN` + `MAIL_FROM` → Mailgun (real send).
175
+ * - else `EMAIL` binding + `MAIL_FROM` → Cloudflare Email Sending (real send).
120
176
  * - else `MAIL_CAPTURE=true` → capture (KV inbox if a Kv is given, else in-memory) with
121
177
  * a synthetic dev sender — an EXPLICIT dev opt-in, never the production default.
122
178
  * - else → fail closed: a `send` throws (so a missing-MAIL_FROM prod doesn't silently
123
- * stash security emails in KV). */
179
+ * stash security emails in KV).
180
+ *
181
+ * Mailgun outranks the binding on purpose. The binding tends to be present because the
182
+ * infrastructure declares it, whereas an API key is only ever there because somebody put
183
+ * it there — so when both exist, the key is the newer decision. */
124
184
  export function createMail(env: EnvBag, kv?: Kv): Mail {
125
185
  const binding = env.EMAIL as SendEmailBinding | undefined;
126
186
  const fromAddr = typeof env.MAIL_FROM === "string" && env.MAIL_FROM ? env.MAIL_FROM : undefined;
187
+ const str = (k: string): string | undefined =>
188
+ typeof env[k] === "string" && (env[k] as string) ? (env[k] as string) : undefined;
189
+ const name = typeof env.MAIL_FROM_NAME === "string" ? env.MAIL_FROM_NAME : undefined;
190
+
191
+ const mailgunKey = str("MAILGUN_API_KEY");
192
+ const mailgunDomain = str("MAILGUN_DOMAIN");
193
+ if (mailgunKey && mailgunDomain && fromAddr) {
194
+ return new Mail(new MailgunAdapter(mailgunKey, mailgunDomain, str("MAILGUN_API_BASE")), {
195
+ email: fromAddr,
196
+ name,
197
+ });
198
+ }
127
199
  if (binding && fromAddr) {
128
- const name = typeof env.MAIL_FROM_NAME === "string" ? env.MAIL_FROM_NAME : undefined;
129
200
  return new Mail(new CloudflareEmailAdapter(binding), { email: fromAddr, name });
130
201
  }
131
202
  if (env.MAIL_CAPTURE === "true") {
package/src/sdk/acl.ts CHANGED
@@ -81,11 +81,19 @@ export interface NowMarker {
81
81
  * future timestamp is non-null, so a scheduled row would be readable the moment it
82
82
  * is saved.
83
83
  *
84
- * Comparison is lexicographic TEXT, which is exact for ISO-8601 UTC but NOT
85
- * cross-format: `expr.now()` defaults store `'YYYY-MM-DD HH:MM:SS'` (space, no `Z`)
86
- * and will not compare correctly against this. Store the column with
87
- * `toISOString()` as the CMS `publish` field does or compare it against
88
- * `expr.now()`-shaped values only. */
84
+ * Comparison is lexicographic TEXT, which is exact WITHIN one format and wrong across two.
85
+ * Both `'YYYY-MM-DD HH:MM:SS'` and the ISO form open with the same date, so values on
86
+ * different dates still order correctly it is the SAME date that breaks, where index 10
87
+ * decides and a space (0x20) always sorts below `T` (0x54). A space-form value dated today
88
+ * therefore compares as less than this marker whatever its time-of-day, so a row scheduled
89
+ * for later today reads as already past. The column must hold ISO-8601 UTC —
90
+ * `new Date().toISOString()`.
91
+ *
92
+ * `expr.now()` produces exactly that, so a column defaulted with it is directly comparable
93
+ * here. It did NOT always: it emitted the `datetime('now')` space form, and a policy over
94
+ * such a column was a time boundary that silently did not hold. A store written by a build
95
+ * from before that change still holds space-form values until
96
+ * `isoTimestampBackfill()` rewrites them. */
89
97
  export function $now(): NowMarker {
90
98
  return { [NOW_MARKER]: true };
91
99
  }
@@ -82,6 +82,49 @@ export interface BootstrapContext<S extends SchemaDef = SchemaDef> {
82
82
  * tenant's boot; it simply retries on the next boot. Set as `app.bootstrap`. */
83
83
  export type BootstrapFn = (ctx: BootstrapContext) => void | Promise<void>;
84
84
 
85
+ /** Context handed to a data migration's `up()`. Same privileged, SYSTEM-scoped shape as
86
+ * `BootstrapContext` (ACL bypassed, triggers suppressed) but scoped to the migration's
87
+ * own `partition` — each partition-DO runs its own partition's migrations, so `db`/`driver`
88
+ * here address exactly that DO's tables. */
89
+ export interface MigrationContext<S extends SchemaDef = SchemaDef> {
90
+ /** System-scoped Db (ACL bypassed), triggers suppressed, scoped to `partition`. */
91
+ readonly db: Db<S>;
92
+ /** Raw driver — a bulk `UPDATE`/`INSERT … SELECT` is usually the right tool for a
93
+ * backfill; going row-by-row through `db` on a large table is what blows the DO's
94
+ * wall-clock budget. */
95
+ readonly driver: Driver;
96
+ readonly schema: S;
97
+ /** The partition this migration is running (and being recorded) under. */
98
+ readonly partition: string;
99
+ }
100
+
101
+ /** One imperative, ORDERED, recorded-once transformation of existing DATA — the half a
102
+ * declarative diff cannot express: split a column, backfill the nullable column `ADD COLUMN`
103
+ * just created, rewrite units, normalize a `t.json()` blob after its shape changed.
104
+ *
105
+ * The deliberate inverse of `BootstrapFn` on every axis that matters: it runs ONCE ever
106
+ * (recorded in `_pramen_migrations` keyed by `(id, partition)`), so it need NOT be
107
+ * idempotent — a backfill that would double a value on a second run is exactly what this
108
+ * exists for; and a throw is NOT swallowed. It fails CLOSED: no ledger row, the boot does
109
+ * not complete, the request fails, and the migration is retried on the tenant's next fetch.
110
+ * Silently marking a half-finished backfill as done is the one outcome worth bricking a
111
+ * boot to avoid.
112
+ *
113
+ * Runs after `migrate()` (so the column exists) and before `app.bootstrap`. There are no
114
+ * DOWN migrations. Set as `app.migrations`. */
115
+ export interface DataMigration {
116
+ /** Stable, unique, never reused — this is the ledger key. Deleting an id from the array
117
+ * does NOT un-apply it; and a cold tenant is unmigrated until touched, so an id can only
118
+ * be pruned once EVERY live tenant reports it applied (`pramen migrations status
119
+ * --all-tenants`). */
120
+ id: string;
121
+ /** Restrict to one partition; default = the default partition. Only that partition's DO
122
+ * runs it (its tables live in no other DO). The D1 store is one shared database with no
123
+ * partition split, so there every migration runs — still recorded under this key. */
124
+ partition?: string;
125
+ up(ctx: MigrationContext): void | Promise<void>;
126
+ }
127
+
85
128
  /** The deferred-side-effects facade handed to handlers as `ctx.tasks`. */
86
129
  export interface Tasks {
87
130
  /** Enqueue a task to run after commit. `kind` selects the `app.tasks` handler;