@pramen/server 0.0.10 → 0.0.12

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.
@@ -30,13 +30,48 @@ const relationBuilders = {
30
30
  };
31
31
  /** The default partition name for entities that don't declare one. */
32
32
  export const DEFAULT_PARTITION = "default";
33
+ /** Declare an entity trigger (sugar; returns the def). */
34
+ export function trigger(def) {
35
+ return def;
36
+ }
37
+ /** Does this trigger fire for `op` given the columns the write touched? */
38
+ export function triggerFires(t, op, writtenCols) {
39
+ if (op === "create")
40
+ return t.on.create === true;
41
+ if (op === "delete")
42
+ return t.on.delete === true;
43
+ const u = t.on.update;
44
+ if (u === true)
45
+ return true;
46
+ if (Array.isArray(u))
47
+ return u.some((f) => writtenCols.includes(f));
48
+ return false;
49
+ }
33
50
  export function Entity(build, relations, opts) {
34
51
  return {
35
52
  fields: build(builders),
36
53
  relations: (relations ? relations(relationBuilders) : {}),
37
54
  partition: opts?.partition ?? DEFAULT_PARTITION,
55
+ triggers: opts?.triggers ?? [],
38
56
  };
39
57
  }
58
+ /** The triggers declared on an entity (empty if none / unknown entity). */
59
+ export function triggersOf(schema, entity) {
60
+ return schema[entity]?.triggers ?? [];
61
+ }
62
+ /** Throw if any declarative trigger names a `task` not in `taskNames` — caught at
63
+ * deploy/load by createPramen, so a typo can't silently enqueue a task that never runs
64
+ * (it would retry then dead-letter). */
65
+ export function validateTriggerTasks(schema, taskNames) {
66
+ const known = new Set(taskNames);
67
+ for (const [entity, def] of Object.entries(schema)) {
68
+ for (const t of def.triggers) {
69
+ if (!known.has(t.task)) {
70
+ throw new Error(`trigger on '${entity}' references task '${t.task}', but app.tasks has no such handler.`);
71
+ }
72
+ }
73
+ }
74
+ }
40
75
  /** Annotate a field as renamed from a previous column name (migration hint). Wraps
41
76
  * a builder result, preserving its literal field type: `title: renamedFrom(t.text(), "name")`. */
42
77
  export function renamedFrom(field, from) {
@@ -151,5 +186,18 @@ export function validateSchema(schema) {
151
186
  `put both entities in the same partition or drop the relation.`);
152
187
  }
153
188
  }
189
+ for (const t of def.triggers) {
190
+ if (!t.task)
191
+ throw new Error(`trigger on '${entity}' is missing a 'task'.`);
192
+ if (!t.on.create && !t.on.update && !t.on.delete) {
193
+ throw new Error(`trigger '${t.task}' on '${entity}' fires on nothing — set on.create/update/delete.`);
194
+ }
195
+ const watched = Array.isArray(t.on.update) ? t.on.update : [];
196
+ for (const f of watched) {
197
+ if (!(f in def.fields)) {
198
+ throw new Error(`trigger '${t.task}' on '${entity}' watches unknown column '${f}'.`);
199
+ }
200
+ }
201
+ }
154
202
  }
155
203
  }
package/dist/worker.js CHANGED
@@ -6,6 +6,7 @@
6
6
  import { authorizeTenant, HmacStrategy, isAdmin, JwksStrategy, resolveIdentity } from "./auth";
7
7
  import { dispatch, tasksFacade, bindTasks } from "./runtime/dispatch";
8
8
  import { ensureOutbox, drainOutbox, listTasks } from "./runtime/outbox";
9
+ import { createMail } from "./runtime/mail";
9
10
  import { migrate } from "./runtime/migrate";
10
11
  import { compileAcl } from "./runtime/acl";
11
12
  import { Db } from "./runtime/db";
@@ -109,8 +110,9 @@ export function makeWorker(app) {
109
110
  const d1TaskCtx = (driver, env) => {
110
111
  const identity = { roles: ["admin"] };
111
112
  const files = createFiles({ tenant: "main", secret: filesSecret(env), adapter: new R2Adapter(env.FILES) });
112
- const db = new Db(driver, { acl: d1Acl, identity, system: true, schema: app.schema }, app.schema);
113
- return { db, kv: new Kv(env.KV), files, env: env, identity, tasks: tasksFacade(driver) };
113
+ const db = new Db(driver, { acl: d1Acl, identity, system: true, schema: app.schema, suppressTriggers: true }, app.schema);
114
+ const kv = new Kv(env.KV);
115
+ return { db, kv, files, env: env, identity, tasks: tasksFacade(driver), mail: createMail(env, kv) };
114
116
  };
115
117
  /** Drain the D1 outbox in the Worker (no DO/alarm on this path) — called by the
116
118
  * /admin/tasks/drain route with `x-pramen-store: d1`, and by `scheduled()` (Cron). */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pramen/server",
3
- "version": "0.0.10",
3
+ "version": "0.0.12",
4
4
  "description": "pramen server runtime — schema, ACL, ORM, live queries, files, and the createPramen(app) factory for Cloudflare Workers + Durable Objects.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -18,6 +18,7 @@
18
18
  import { DurableObject } from "cloudflare:workers";
19
19
  import { migrate } from "./runtime/migrate";
20
20
  import { dispatch, tasksFacade, bindTasks } from "./runtime/dispatch";
21
+ import { createMail } from "./runtime/mail";
21
22
  import { ensureOutbox, drainOutbox, listTasks } from "./runtime/outbox";
22
23
  import { Db } from "./runtime/db";
23
24
  import { digest } from "./runtime/digest";
@@ -202,10 +203,18 @@ export class PramenDOBase extends DurableObject<DoEnv> {
202
203
  const identity: Identity = { roles: ["admin"] };
203
204
  const db = new Db(
204
205
  this.driver,
205
- { acl: this.acl, identity, system: true, schema: this.app.schema, partition: this.partition },
206
+ { acl: this.acl, identity, system: true, schema: this.app.schema, partition: this.partition, suppressTriggers: true },
206
207
  this.app.schema,
207
208
  );
208
- return { db, kv: this.kv, files: this.filesFor(this.tenant), env: this.envBag, identity, tasks: tasksFacade(this.driver) };
209
+ return {
210
+ db,
211
+ kv: this.kv,
212
+ files: this.filesFor(this.tenant),
213
+ env: this.envBag,
214
+ identity,
215
+ tasks: tasksFacade(this.driver),
216
+ mail: createMail(this.envBag, this.kv),
217
+ };
209
218
  }
210
219
 
211
220
  /** Restore (tenant, partition) on a cold wake (e.g. an alarm with no request) so the
@@ -417,27 +426,21 @@ export class PramenDOBase extends DurableObject<DoEnv> {
417
426
  }
418
427
  }
419
428
 
420
- // Introspection: this tenant's applied schema hash + live table/column shape
421
- // (admin-gated at the Worker). Powers the CLI's `schema status`.
429
+ // Introspection: this tenant's applied schema hash + table/column shape (admin-gated
430
+ // at the Worker). Powers the CLI's `schema status`. Both are read from _pramen_meta
431
+ // (written by migrate on boot) — NOT a request-time `PRAGMA`/introspection: once the
432
+ // DO-storage alarm API has run in this object, workerd's SQLite authorizer rejects
433
+ // PRAGMA (SQLITE_AUTH), so the outbox's alarm would otherwise break this endpoint.
422
434
  private async handleSchema(): Promise<Response> {
423
- // The applied-schema hash is stored per-partition (migrate keys it
424
- // `schema_hash:<partition>` whenever a partition is scoped, which the DO always
425
- // does). Read this DO's partition's key.
426
435
  const hashKey = `schema_hash:${this.partition}`;
427
- const hashRow = (await this.driver.exec(`SELECT value FROM _pramen_meta WHERE key = ?`, [hashKey])) as {
428
- value: string;
429
- }[];
430
- const tableRows = (await this.driver.exec(
431
- `SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'`,
432
- [],
433
- )) as { name: string }[];
434
- const tables: Record<string, string[]> = {};
435
- for (const { name } of tableRows) {
436
- // Skip pramen's internal bookkeeping tables (_pramen_meta, _pramen_outbox, …).
437
- if (name.toLowerCase().startsWith("_pramen") || name.toLowerCase().startsWith("__pramen")) continue;
438
- tables[name] = ((await this.driver.exec(`PRAGMA table_info(${name})`, [])) as { name: string }[]).map((r) => r.name);
439
- }
440
- return Response.json({ ok: true, result: { hash: hashRow[0]?.value ?? null, tables } });
436
+ const tablesKey = `schema_tables:${this.partition}`;
437
+ const rows = (await this.driver.exec(`SELECT key, value FROM _pramen_meta WHERE key IN (?, ?)`, [
438
+ hashKey,
439
+ tablesKey,
440
+ ])) as { key: string; value: string }[];
441
+ const byKey = new Map(rows.map((r) => [r.key, r.value]));
442
+ const tables = byKey.has(tablesKey) ? (JSON.parse(byKey.get(tablesKey)!) as Record<string, string[]>) : {};
443
+ return Response.json({ ok: true, result: { hash: byKey.get(hashKey) ?? null, tables } });
441
444
  }
442
445
 
443
446
  // Generic admin data ops (admin-gated at the Worker). Runs through a SYSTEM-mode
package/src/index.ts CHANGED
@@ -8,7 +8,8 @@
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 } from "./sdk/schema";
11
+ export { Entity, defineSchema, renamedFrom, notNull, unique, indexed, hidden, defaultTo, primaryKey, generated, expr, ExprDefault, trigger } from "./sdk/schema";
12
+ export type { TriggerDef, TriggerOp } from "./sdk/schema";
12
13
  export { isValidUuid } from "./sdk/uuid";
13
14
  export type {
14
15
  DefaultValue,
@@ -71,6 +72,10 @@ export type { FileRef, Files, SignUploadOpts, SignDownloadOpts, HeadResult } fro
71
72
  export { R2Adapter, MemoryAdapter, createFiles, handleFileRequest } from "./runtime/storage";
72
73
  export type { StorageAdapter, PutResult, GetResult } from "./runtime/storage";
73
74
 
75
+ // --- mail (ctx.mail) ---
76
+ export { Mail, CloudflareEmailAdapter, KvMailAdapter, MemoryMailAdapter, UnconfiguredMailAdapter, createMail } from "./runtime/mail";
77
+ export type { MailMessage, MailAddress, MailAdapter, SendEmailBinding } from "./runtime/mail";
78
+
74
79
  // --- errors ---
75
80
  export { PramenError, BadRequest, Unauthorized, Forbidden } from "./runtime/errors";
76
81
 
package/src/pramen.ts CHANGED
@@ -14,7 +14,7 @@
14
14
 
15
15
  import { makeWorker, type Env } from "./worker";
16
16
  import { pramenDO, type DoEnv } from "./durable-object";
17
- import type { SchemaDef } from "./sdk/schema";
17
+ import { validateTriggerTasks, type SchemaDef } from "./sdk/schema";
18
18
  import type { AppTaskMap, HandlerMap } from "./sdk/handlers";
19
19
  import type { Role } from "./sdk/acl";
20
20
 
@@ -60,6 +60,7 @@ export function createPramen(app: PramenApp): {
60
60
  scheduled: (event: unknown, env: Env) => Promise<void>;
61
61
  PramenDO: ReturnType<typeof pramenDO>;
62
62
  } {
63
+ validateTriggerTasks(app.schema, Object.keys(app.tasks ?? {})); // fail fast on a typo'd trigger task
63
64
  const worker = makeWorker(app);
64
65
  return { fetch: worker.fetch, scheduled: worker.scheduled, PramenDO: pramenDO(app) };
65
66
  }
@@ -64,6 +64,11 @@ export interface AclContext {
64
64
  * lives in a different partition (a partition-DO only owns its own tables). Unset
65
65
  * (e.g. the D1/Worker shared-store path) disables the guard — a no-op. */
66
66
  readonly partition?: string;
67
+ /** Suppress declarative write-triggers for this Db. Set on the privileged context
68
+ * that DRAINS tasks, so a task handler's writes don't re-fire triggers (which would
69
+ * cascade — a trigger → task → write → trigger loop). Triggers fire on request-path
70
+ * writes, not on task-handler writes. */
71
+ readonly suppressTriggers?: boolean;
67
72
  }
68
73
 
69
74
  /** Evaluate every resolver reachable by the identity's roles, once per request.
package/src/runtime/db.ts CHANGED
@@ -40,8 +40,9 @@ import {
40
40
  type SqlExpr,
41
41
  } from "./read-engine";
42
42
  import { BadRequest } from "./errors";
43
+ import { enqueueTask } from "./outbox";
43
44
  import type { Dialect, Driver } from "./driver";
44
- import { partitionOf, type EntityFields, type FieldDef, type RelationDef, type SchemaDef } from "../sdk/schema";
45
+ import { partitionOf, triggersOf, triggerFires, type EntityFields, type FieldDef, type RelationDef, type SchemaDef, type TriggerOp } from "../sdk/schema";
45
46
  import { isValidUuid } from "../sdk/uuid";
46
47
  import type { Cell, FieldsOf, InferInsert, InferRow, InferUpdate, RelationsOf, RelationsResult, WhereClause } from "../sdk/infer";
47
48
 
@@ -52,6 +53,15 @@ type Selected = Partial<Record<string, true>> | undefined;
52
53
 
53
54
  const DEFAULT_PAGE_SIZE = 50;
54
55
 
56
+ /** Compare two decoded cell values for trigger change-detection. Primitives by ===;
57
+ * json/object cells (already parsed) by structural JSON equality. */
58
+ function cellEqual(a: unknown, b: unknown): boolean {
59
+ if (a === b) return true;
60
+ if (a == null || b == null) return false;
61
+ if (typeof a === "object" || typeof b === "object") return JSON.stringify(a) === JSON.stringify(b);
62
+ return false;
63
+ }
64
+
55
65
  function normalizeOrder(orderBy: unknown): OrderBy[] | undefined {
56
66
  if (!orderBy) return undefined;
57
67
  return (Array.isArray(orderBy) ? orderBy : [orderBy]) as OrderBy[];
@@ -154,6 +164,12 @@ function keysetAfter(order: OrderBy[], values: unknown[]): SqlExpr {
154
164
  export class Db<S extends SchemaDef = SchemaDef> {
155
165
  /** Tables read or written during this Db's lifetime. */
156
166
  readonly touched = new Set<string>();
167
+ /** Tasks enqueued by declarative triggers during this Db's lifetime — the DO adds
168
+ * this to ctx.tasks enqueues to decide whether to arm its drain alarm. */
169
+ private taskEnqueueCount = 0;
170
+ get taskEnqueues(): number {
171
+ return this.taskEnqueueCount;
172
+ }
157
173
  private readonly dialect: Dialect;
158
174
  private readonly acl: AclContext;
159
175
 
@@ -403,6 +419,43 @@ export class Db<S extends SchemaDef = SchemaDef> {
403
419
  return out;
404
420
  }
405
421
 
422
+ /** Fire declarative write-triggers for `op` on `entity`: enqueue a task per matching
423
+ * trigger into the outbox, in THIS mutation's transaction (atomic with the write).
424
+ * `row` is the affected row (decoded — new values for create/update, the removed row
425
+ * for delete); `writtenCols` are the columns the write touched; `before` is the prior
426
+ * row (update only) for value-change detection on a field-filtered trigger.
427
+ *
428
+ * - Hidden columns are STRIPPED from the payload row — `hidden()` ("never readable via
429
+ * the ORM, even under SYSTEM") must hold here too, or a secret like passwordHash
430
+ * would leak to a task handler / webhook.
431
+ * - A field-filtered update trigger fires only when a watched column's value actually
432
+ * CHANGED (not merely was written to the same value).
433
+ * - Suppressed in the task-drain context, so a task's own writes don't re-fire
434
+ * triggers (preventing a trigger→task→write→trigger cascade). */
435
+ private async fireTriggers(
436
+ entity: string,
437
+ op: TriggerOp,
438
+ row: Row,
439
+ writtenCols: string[],
440
+ before?: Row,
441
+ ): Promise<void> {
442
+ if (this.acl.suppressTriggers) return;
443
+ const triggers = triggersOf(this.schema, entity);
444
+ if (triggers.length === 0) return;
445
+ const id = row[this.pkOf(entity)];
446
+ let safeRow: Row | undefined;
447
+ for (const t of triggers) {
448
+ if (!triggerFires(t, op, writtenCols)) continue;
449
+ if (op === "update" && Array.isArray(t.on.update) && before) {
450
+ const changed = t.on.update.some((c) => writtenCols.includes(c) && !cellEqual(before[c], row[c]));
451
+ if (!changed) continue; // watched column(s) written, but value unchanged
452
+ }
453
+ if (!safeRow) safeRow = this.stripHidden(entity, row); // never leak hidden columns
454
+ await enqueueTask(this.driver, Date.now(), { kind: t.task, payload: { entity, op, id, row: safeRow } });
455
+ this.taskEnqueueCount++;
456
+ }
457
+ }
458
+
406
459
  /** Mint a UUID for every `generated()` uuid column the caller omitted, mutating
407
460
  * `vals`. Returns the columns it filled — server-minted, so the insert path treats
408
461
  * them like forced `set` values (bypassing the writable-field ACL check). */
@@ -558,7 +611,9 @@ export class Db<S extends SchemaDef = SchemaDef> {
558
611
  const params = cols.map((c) => this.encodeCell(jsonCols, c, vals[c]));
559
612
  const sql = `INSERT INTO ${this.dialect.id(table)} (${colList}) VALUES (${phs})${this.returningClause("*")}`;
560
613
  const rows = await this.driver.exec(sql, params);
561
- return this.projectWrite(table, this.decodeRow(table, rows[0])!, cols) as InferRow<FieldsOf<S[T]>>;
614
+ const persisted = this.decodeRow(table, rows[0])!;
615
+ await this.fireTriggers(table, "create", persisted, cols);
616
+ return this.projectWrite(table, persisted, cols) as InferRow<FieldsOf<S[T]>>;
562
617
  }
563
618
 
564
619
  /** Project a mutation's RETURNING row so the echo never reveals more than a read
@@ -597,13 +652,18 @@ export class Db<S extends SchemaDef = SchemaDef> {
597
652
  const cols = Object.keys(p);
598
653
  if (cols.length === 0) return undefined;
599
654
 
600
- // Per-row field permission is evaluated against the FINAL (post-merge) row, so
601
- // fetch the existing row within update scope when any cell-level rule applies.
655
+ // Fetch the existing row when we need it: for per-row field permission (evaluated
656
+ // against the FINAL post-merge row) OR for a field-filtered update trigger's
657
+ // value-change detection (so it fires only on an actual change, not a same-value write).
658
+ const needCellEval = scope.fields !== null && (scope.conditional.length > 0 || scope.fieldsFns.length > 0);
659
+ const needBefore = !this.acl.suppressTriggers && triggersOf(this.schema, table).some((t) => Array.isArray(t.on.update));
602
660
  let evalRow: Row = p;
603
- if (scope.fields !== null && (scope.conditional.length > 0 || scope.fieldsFns.length > 0)) {
661
+ let before: Row | undefined;
662
+ if (needCellEval || needBefore) {
604
663
  const existing = await this.fetchOne(table, id, scope.where);
605
664
  if (!existing) return undefined; // out of update scope -> no-op
606
- evalRow = { ...existing, ...p };
665
+ before = existing;
666
+ if (needCellEval) evalRow = { ...existing, ...p };
607
667
  }
608
668
  this.checkWriteFields(table, "update", scope, cols, evalRow, new Set(Object.keys(set)));
609
669
  this.runValidators(validators, p);
@@ -622,6 +682,7 @@ export class Db<S extends SchemaDef = SchemaDef> {
622
682
  sql += this.scopeClause(scope.where, params);
623
683
  sql += this.returningClause("*");
624
684
  const updated = this.decodeRow(table, (await this.driver.exec(sql, params))[0]);
685
+ if (updated) await this.fireTriggers(table, "update", updated, cols, before);
625
686
  return (updated ? this.projectWrite(table, updated, cols) : undefined) as InferRow<FieldsOf<S[T]>> | undefined;
626
687
  }
627
688
 
@@ -635,7 +696,9 @@ export class Db<S extends SchemaDef = SchemaDef> {
635
696
  let sql = `DELETE FROM ${this.dialect.id(table)} WHERE ${this.dialect.id(this.pkOf(table))} = ${this.dialect.placeholder(1)}`;
636
697
  sql += this.scopeClause(scope.where, params);
637
698
  sql += this.returningClause("*");
638
- return (await this.driver.exec(sql, params)).length > 0;
699
+ const deleted = this.decodeRow(table, (await this.driver.exec(sql, params))[0]);
700
+ if (deleted) await this.fireTriggers(table, "delete", deleted, []);
701
+ return deleted != null;
639
702
  }
640
703
 
641
704
  /** Escape hatch — raw SQL, NOT ACL-checked. For system/internal use only. */
@@ -12,6 +12,7 @@ import { Db } from "./db";
12
12
  import { warmup, type AclContext } from "./acl";
13
13
  import { BadRequest } from "./errors";
14
14
  import { enqueueTask, type TaskMap } from "./outbox";
15
+ import { createMail } from "./mail";
15
16
  import type { Driver } from "./driver";
16
17
  import type { Kv } from "./kv";
17
18
  import type { Files } from "../sdk/files";
@@ -76,12 +77,21 @@ export async function dispatch(
76
77
 
77
78
  const db = new Db(driver, { acl: acl.acl, identity: acl.identity, input: parsed, resolved, schema, partition: acl.partition }, schema);
78
79
  let enqueued = 0;
79
- const ctx: HandlerContext = { db, kv, files, env, identity: acl.identity, tasks: tasksFacade(driver, () => enqueued++) };
80
+ const ctx: HandlerContext = {
81
+ db,
82
+ kv,
83
+ files,
84
+ env,
85
+ identity: acl.identity,
86
+ tasks: tasksFacade(driver, () => enqueued++),
87
+ mail: createMail(env, kv),
88
+ };
80
89
 
81
90
  const result =
82
91
  handler.kind === "query"
83
92
  ? await handler.run(ctx, parsed)
84
93
  : await driver.transaction(async () => handler.run(ctx, parsed));
85
94
 
86
- return { result, kind: handler.kind, touched: [...db.touched], enqueued };
95
+ // Both explicit ctx.tasks.enqueue and declarative trigger enqueues (in db) count.
96
+ return { result, kind: handler.kind, touched: [...db.touched], enqueued: enqueued + db.taskEnqueues };
87
97
  }
@@ -0,0 +1,136 @@
1
+ // ctx.mail — transactional-ish email facade, the same shape as ctx.files: an adapter
2
+ // seam (CloudflareEmailAdapter / KvMailAdapter / MemoryMailAdapter) behind a thin
3
+ // `Mail` facade, chosen from the environment. Handlers send mail without touching the
4
+ // `send_email` binding directly:
5
+ //
6
+ // await ctx.mail.send({ to: "u@x.com", subject: "Welcome", text: "…" });
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.
12
+
13
+ import type { Kv } from "./kv";
14
+
15
+ export interface MailAddress {
16
+ email: string;
17
+ name?: string;
18
+ }
19
+
20
+ export interface MailMessage {
21
+ to: string | string[];
22
+ /** Sender. Optional — defaults to MAIL_FROM (a verified address). */
23
+ from?: MailAddress;
24
+ subject: string;
25
+ text?: string;
26
+ html?: string;
27
+ replyTo?: string | MailAddress;
28
+ }
29
+
30
+ /** The transport seam. One per backend (Cloudflare Email Sending, a dev stash, …). */
31
+ export interface MailAdapter {
32
+ /** Deliver a fully-resolved message (`from` already filled by the facade). */
33
+ send(message: MailMessage & { from: MailAddress }): Promise<void>;
34
+ }
35
+
36
+ /** The `ctx.mail` facade: resolves the sender, validates, and delegates to the adapter. */
37
+ export class Mail {
38
+ constructor(
39
+ private readonly adapter: MailAdapter,
40
+ private readonly defaultFrom?: MailAddress,
41
+ ) {}
42
+
43
+ async send(message: MailMessage): Promise<void> {
44
+ const to = Array.isArray(message.to) ? message.to : [message.to];
45
+ if (to.length === 0 || to.some((a) => typeof a !== "string" || a.length === 0)) {
46
+ throw new Error("ctx.mail.send: `to` is required");
47
+ }
48
+ if (typeof message.subject !== "string" || message.subject.length === 0) {
49
+ throw new Error("ctx.mail.send: `subject` is required");
50
+ }
51
+ const from = message.from ?? this.defaultFrom;
52
+ if (!from) throw new Error("ctx.mail.send: no sender — set the MAIL_FROM var or pass `from`");
53
+ await this.adapter.send({ ...message, from });
54
+ }
55
+ }
56
+
57
+ /** The Cloudflare `send_email` binding shape (workers binding form: `from` uses `email`). */
58
+ export interface SendEmailBinding {
59
+ send(message: {
60
+ to: string | string[];
61
+ from: MailAddress;
62
+ subject: string;
63
+ text?: string;
64
+ html?: string;
65
+ replyTo?: string | MailAddress;
66
+ }): Promise<void>;
67
+ }
68
+
69
+ /** Cloudflare Email Sending — sends via the `send_email` binding (no API keys). The
70
+ * `from` domain must be onboarded (`wrangler email sending enable yourdomain.com`). */
71
+ export class CloudflareEmailAdapter implements MailAdapter {
72
+ constructor(private readonly binding: SendEmailBinding) {}
73
+ async send(message: MailMessage & { from: MailAddress }): Promise<void> {
74
+ await this.binding.send({
75
+ to: message.to,
76
+ from: message.from,
77
+ subject: message.subject,
78
+ text: message.text,
79
+ html: message.html,
80
+ replyTo: message.replyTo,
81
+ });
82
+ }
83
+ }
84
+
85
+ /** Dev/test transport: stash the message in KV under `mail:<recipient>` so an e2e suite
86
+ * (or a dashboard) can read the "inbox" instead of really sending. */
87
+ export class KvMailAdapter implements MailAdapter {
88
+ constructor(private readonly kv: Kv) {}
89
+ async send(message: MailMessage & { from: MailAddress }): Promise<void> {
90
+ const to = Array.isArray(message.to) ? message.to : [message.to];
91
+ const value = JSON.stringify({ from: message.from, subject: message.subject, text: message.text, html: message.html });
92
+ for (const addr of to) await this.kv.put(`mail:${addr}`, value, { expirationTtl: 900 });
93
+ }
94
+ }
95
+
96
+ /** In-memory transport: captures sent messages (pure; for unit tests). */
97
+ export class MemoryMailAdapter implements MailAdapter {
98
+ readonly sent: Array<MailMessage & { from: MailAddress }> = [];
99
+ async send(message: MailMessage & { from: MailAddress }): Promise<void> {
100
+ this.sent.push(message);
101
+ }
102
+ }
103
+
104
+ /** Fail-closed transport: no real sender and no explicit dev-capture opt-in, so a
105
+ * `send` THROWS rather than silently capturing. Prevents a misconfigured production
106
+ * (no MAIL_FROM) from writing security emails — magic-link tokens, resets — into KV
107
+ * instead of delivering them. Mirrors how files fail closed without FILES_SECRET. */
108
+ export class UnconfiguredMailAdapter implements MailAdapter {
109
+ async send(): Promise<void> {
110
+ throw new Error(
111
+ "ctx.mail: no transport configured — set MAIL_FROM (with the EMAIL binding) to send, " +
112
+ "or MAIL_CAPTURE=true to capture in dev.",
113
+ );
114
+ }
115
+ }
116
+
117
+ /** Build `ctx.mail` from the environment:
118
+ * - `EMAIL` binding + `MAIL_FROM` → Cloudflare Email Sending (real send).
119
+ * - else `MAIL_CAPTURE=true` → capture (KV inbox if a Kv is given, else in-memory) with
120
+ * a synthetic dev sender — an EXPLICIT dev opt-in, never the production default.
121
+ * - else → fail closed: a `send` throws (so a missing-MAIL_FROM prod doesn't silently
122
+ * stash security emails in KV). */
123
+ export function createMail(env: Readonly<Record<string, unknown>>, kv?: Kv): Mail {
124
+ const binding = env.EMAIL as SendEmailBinding | undefined;
125
+ const fromAddr = typeof env.MAIL_FROM === "string" && env.MAIL_FROM ? env.MAIL_FROM : undefined;
126
+ if (binding && fromAddr) {
127
+ const name = typeof env.MAIL_FROM_NAME === "string" ? env.MAIL_FROM_NAME : undefined;
128
+ return new Mail(new CloudflareEmailAdapter(binding), { email: fromAddr, name });
129
+ }
130
+ if (env.MAIL_CAPTURE === "true") {
131
+ const devFrom: MailAddress = { email: "dev@pramen.local", name: "pramen (dev)" };
132
+ return new Mail(kv ? new KvMailAdapter(kv) : new MemoryMailAdapter(), devFrom);
133
+ }
134
+ // Sentinel `from` so the facade delegates to the adapter, which throws the clear error.
135
+ return new Mail(new UnconfiguredMailAdapter(), { email: "unconfigured@invalid" });
136
+ }
@@ -139,9 +139,15 @@ export async function migrate(driver: Driver, schema: SchemaDef, opts: MigrateOp
139
139
  // `schema_hash` key for backward compatibility (existing stores + the D1 path).
140
140
  const subset: SchemaDef = Object.fromEntries(entries);
141
141
  const hashKey = opts.partition === undefined ? "schema_hash" : `schema_hash:${opts.partition}`;
142
+ const tablesKey = opts.partition === undefined ? "schema_tables" : `schema_tables:${opts.partition}`;
143
+ const tablesValue = () => JSON.stringify(Object.fromEntries(entries.map(([table, def]) => [table, Object.keys(def.fields)])));
142
144
  const current = schemaHash(subset);
143
- if ((await readMeta(driver, hashKey)) === current)
145
+ if ((await readMeta(driver, hashKey)) === current) {
146
+ // Backfill the table map for stores migrated before this key existed (the schema
147
+ // is unchanged, so it's exactly what's applied).
148
+ if ((await readMeta(driver, tablesKey)) == null) await writeMeta(driver, tablesKey, tablesValue());
144
149
  return { changed: false, created: [], added: [], rebuilt: [], droppedTables: [], skipped: [] };
150
+ }
145
151
 
146
152
  const created: string[] = [];
147
153
  const added: string[] = [];
@@ -230,6 +236,10 @@ export async function migrate(driver: Driver, schema: SchemaDef, opts: MigrateOp
230
236
  // additive work is idempotent, so re-running is safe.
231
237
  if (skipped.length === 0) {
232
238
  await writeMeta(driver, hashKey, current);
239
+ // Persist the applied table→columns so /admin/schema reports it without a raw
240
+ // PRAGMA at request time — workerd's SQLite authorizer rejects PRAGMA once the
241
+ // DO-storage alarm API has run in the object. Migrate runs on boot, before any.
242
+ await writeMeta(driver, tablesKey, tablesValue());
233
243
  } else {
234
244
  console.warn(
235
245
  `pramen: ${skipped.length} destructive migration(s) skipped (set PRAMEN_ALLOW_DESTRUCTIVE=true to apply): ${skipped.join("; ")}`,
@@ -4,6 +4,7 @@
4
4
 
5
5
  import type { Db } from "../runtime/db";
6
6
  import type { Kv } from "../runtime/kv";
7
+ import type { Mail } from "../runtime/mail";
7
8
  import type { Identity } from "./acl";
8
9
  import type { Files } from "./files";
9
10
  import type { SchemaDef } from "./schema";
@@ -17,6 +18,11 @@ export interface HandlerContext<S extends SchemaDef = SchemaDef> {
17
18
  /** Per-tenant file storage: mint signed upload/download urls, head/delete blobs.
18
19
  * Bytes flow through the Worker /files/* route, never through the DO. */
19
20
  readonly files: Files;
21
+ /** Send email: `ctx.mail.send({ to, subject, text/html })`. On Cloudflare this is
22
+ * Cloudflare Email Sending (the `send_email` binding); off-platform / unconfigured it
23
+ * captures instead of sending. Prefer enqueuing the send as a task (see `ctx.tasks`)
24
+ * so it runs off the single-writer write path. */
25
+ readonly mail: Mail;
20
26
  /** The Worker/DO environment — bindings (KV, R2, DB, …) plus vars and secrets
21
27
  * (AUTH_SECRET, plus anything in wrangler.jsonc / .dev.vars / `wrangler secret`).
22
28
  * Use it to call external services from handlers — Cloudflare bindings (e.g. the