@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.
- package/dist/durable-object.js +24 -17
- package/dist/index.d.ts +4 -1
- package/dist/index.js +3 -1
- package/dist/pramen.d.ts +1 -1
- package/dist/pramen.js +2 -0
- package/dist/runtime/acl.d.ts +5 -0
- package/dist/runtime/db.d.ts +18 -0
- package/dist/runtime/db.js +73 -7
- package/dist/runtime/dispatch.js +12 -2
- package/dist/runtime/mail.d.ts +80 -0
- package/dist/runtime/mail.js +102 -0
- package/dist/runtime/migrate.js +12 -1
- package/dist/sdk/handlers.d.ts +6 -0
- package/dist/sdk/schema.d.ts +32 -0
- package/dist/sdk/schema.js +48 -0
- package/dist/worker.js +4 -2
- package/package.json +1 -1
- package/src/durable-object.ts +24 -21
- package/src/index.ts +6 -1
- package/src/pramen.ts +2 -1
- package/src/runtime/acl.ts +5 -0
- package/src/runtime/db.ts +70 -7
- package/src/runtime/dispatch.ts +12 -2
- package/src/runtime/mail.ts +136 -0
- package/src/runtime/migrate.ts +11 -1
- package/src/sdk/handlers.ts +6 -0
- package/src/sdk/schema.ts +69 -1
- package/src/worker.ts +4 -2
package/dist/durable-object.js
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
import { DurableObject } from "cloudflare:workers";
|
|
18
18
|
import { migrate } from "./runtime/migrate";
|
|
19
19
|
import { dispatch, tasksFacade, bindTasks } from "./runtime/dispatch";
|
|
20
|
+
import { createMail } from "./runtime/mail";
|
|
20
21
|
import { ensureOutbox, drainOutbox, listTasks } from "./runtime/outbox";
|
|
21
22
|
import { Db } from "./runtime/db";
|
|
22
23
|
import { digest } from "./runtime/digest";
|
|
@@ -153,8 +154,16 @@ export class PramenDOBase extends DurableObject {
|
|
|
153
154
|
* Task handlers get full db access + env (e.g. ctx.env.EMAIL) + their own ctx.tasks. */
|
|
154
155
|
taskCtx() {
|
|
155
156
|
const identity = { roles: ["admin"] };
|
|
156
|
-
const db = new Db(this.driver, { acl: this.acl, identity, system: true, schema: this.app.schema, partition: this.partition }, this.app.schema);
|
|
157
|
-
return {
|
|
157
|
+
const db = new Db(this.driver, { acl: this.acl, identity, system: true, schema: this.app.schema, partition: this.partition, suppressTriggers: true }, this.app.schema);
|
|
158
|
+
return {
|
|
159
|
+
db,
|
|
160
|
+
kv: this.kv,
|
|
161
|
+
files: this.filesFor(this.tenant),
|
|
162
|
+
env: this.envBag,
|
|
163
|
+
identity,
|
|
164
|
+
tasks: tasksFacade(this.driver),
|
|
165
|
+
mail: createMail(this.envBag, this.kv),
|
|
166
|
+
};
|
|
158
167
|
}
|
|
159
168
|
/** Restore (tenant, partition) on a cold wake (e.g. an alarm with no request) so the
|
|
160
169
|
* task context is scoped correctly. No-op once loaded/persisted this instance. */
|
|
@@ -353,23 +362,21 @@ export class PramenDOBase extends DurableObject {
|
|
|
353
362
|
return Response.json({ ok: false, error: "point-in-time recovery is unavailable in this environment", code: "unavailable" }, { status: 501 });
|
|
354
363
|
}
|
|
355
364
|
}
|
|
356
|
-
// Introspection: this tenant's applied schema hash +
|
|
357
|
-
//
|
|
365
|
+
// Introspection: this tenant's applied schema hash + table/column shape (admin-gated
|
|
366
|
+
// at the Worker). Powers the CLI's `schema status`. Both are read from _pramen_meta
|
|
367
|
+
// (written by migrate on boot) — NOT a request-time `PRAGMA`/introspection: once the
|
|
368
|
+
// DO-storage alarm API has run in this object, workerd's SQLite authorizer rejects
|
|
369
|
+
// PRAGMA (SQLITE_AUTH), so the outbox's alarm would otherwise break this endpoint.
|
|
358
370
|
async handleSchema() {
|
|
359
|
-
// The applied-schema hash is stored per-partition (migrate keys it
|
|
360
|
-
// `schema_hash:<partition>` whenever a partition is scoped, which the DO always
|
|
361
|
-
// does). Read this DO's partition's key.
|
|
362
371
|
const hashKey = `schema_hash:${this.partition}`;
|
|
363
|
-
const
|
|
364
|
-
const
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
}
|
|
372
|
-
return Response.json({ ok: true, result: { hash: hashRow[0]?.value ?? null, tables } });
|
|
372
|
+
const tablesKey = `schema_tables:${this.partition}`;
|
|
373
|
+
const rows = (await this.driver.exec(`SELECT key, value FROM _pramen_meta WHERE key IN (?, ?)`, [
|
|
374
|
+
hashKey,
|
|
375
|
+
tablesKey,
|
|
376
|
+
]));
|
|
377
|
+
const byKey = new Map(rows.map((r) => [r.key, r.value]));
|
|
378
|
+
const tables = byKey.has(tablesKey) ? JSON.parse(byKey.get(tablesKey)) : {};
|
|
379
|
+
return Response.json({ ok: true, result: { hash: byKey.get(hashKey) ?? null, tables } });
|
|
373
380
|
}
|
|
374
381
|
// Generic admin data ops (admin-gated at the Worker). Runs through a SYSTEM-mode
|
|
375
382
|
// Db, so ACL is bypassed — admin can browse/edit any row of any table — while the
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
export { Entity, defineSchema, renamedFrom, notNull, unique, indexed, hidden, defaultTo, primaryKey, generated, expr, ExprDefault } from "./sdk/schema";
|
|
1
|
+
export { Entity, defineSchema, renamedFrom, notNull, unique, indexed, hidden, defaultTo, primaryKey, generated, expr, ExprDefault, trigger } from "./sdk/schema";
|
|
2
|
+
export type { TriggerDef, TriggerOp } from "./sdk/schema";
|
|
2
3
|
export { isValidUuid } from "./sdk/uuid";
|
|
3
4
|
export type { DefaultValue, FieldType, FieldDef, EntityFields, EntityDef, SchemaDef, RelationDef, RelationDefs, BelongsToDef, HasManyDef, } from "./sdk/schema";
|
|
4
5
|
export { createApp } from "./sdk/app";
|
|
@@ -10,6 +11,8 @@ export type { Cell, FieldsOf, InferInsert, InferRow, InferUpdate, JsonValue, Pro
|
|
|
10
11
|
export type { FileRef, Files, SignUploadOpts, SignDownloadOpts, HeadResult } from "./sdk/files";
|
|
11
12
|
export { R2Adapter, MemoryAdapter, createFiles, handleFileRequest } from "./runtime/storage";
|
|
12
13
|
export type { StorageAdapter, PutResult, GetResult } from "./runtime/storage";
|
|
14
|
+
export { Mail, CloudflareEmailAdapter, KvMailAdapter, MemoryMailAdapter, UnconfiguredMailAdapter, createMail } from "./runtime/mail";
|
|
15
|
+
export type { MailMessage, MailAddress, MailAdapter, SendEmailBinding } from "./runtime/mail";
|
|
13
16
|
export { PramenError, BadRequest, Unauthorized, Forbidden } from "./runtime/errors";
|
|
14
17
|
export { sqliteDialect, postgresDialect, DoSqliteDriver, D1Driver } from "./runtime/driver";
|
|
15
18
|
export type { Driver, Dialect, Row } from "./runtime/driver";
|
package/dist/index.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// which only exists in the Workers runtime; keeping it separate lets the CLI, tests,
|
|
8
8
|
// and codegen load an app.ts for its schema without dragging in the DO runtime.
|
|
9
9
|
// --- schema authoring ---
|
|
10
|
-
export { Entity, defineSchema, renamedFrom, notNull, unique, indexed, hidden, defaultTo, primaryKey, generated, expr, ExprDefault } from "./sdk/schema";
|
|
10
|
+
export { Entity, defineSchema, renamedFrom, notNull, unique, indexed, hidden, defaultTo, primaryKey, generated, expr, ExprDefault, trigger } from "./sdk/schema";
|
|
11
11
|
export { isValidUuid } from "./sdk/uuid";
|
|
12
12
|
// --- app + handlers ---
|
|
13
13
|
export { createApp } from "./sdk/app";
|
|
@@ -15,6 +15,8 @@ export { query, mutation } from "./sdk/handlers";
|
|
|
15
15
|
// --- ACL ---
|
|
16
16
|
export { $identity, $input, allow, deny, policy, resolve, role, isAllow, isDeny, isResolver, isIdentityMarker, isInputMarker } from "./sdk/acl";
|
|
17
17
|
export { R2Adapter, MemoryAdapter, createFiles, handleFileRequest } from "./runtime/storage";
|
|
18
|
+
// --- mail (ctx.mail) ---
|
|
19
|
+
export { Mail, CloudflareEmailAdapter, KvMailAdapter, MemoryMailAdapter, UnconfiguredMailAdapter, createMail } from "./runtime/mail";
|
|
18
20
|
// --- errors ---
|
|
19
21
|
export { PramenError, BadRequest, Unauthorized, Forbidden } from "./runtime/errors";
|
|
20
22
|
// --- substrate seam (advanced: bring your own SQL backend) ---
|
package/dist/pramen.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type Env } from "./worker";
|
|
2
2
|
import { pramenDO, type DoEnv } from "./durable-object";
|
|
3
|
-
import type
|
|
3
|
+
import { type SchemaDef } from "./sdk/schema";
|
|
4
4
|
import type { AppTaskMap, HandlerMap } from "./sdk/handlers";
|
|
5
5
|
import type { Role } from "./sdk/acl";
|
|
6
6
|
/** Injected into a public route's handler — forward a privileged mutation into the
|
package/dist/pramen.js
CHANGED
|
@@ -13,10 +13,12 @@
|
|
|
13
13
|
// type-only by worker.ts / durable-object.ts, so there is no runtime import cycle.
|
|
14
14
|
import { makeWorker } from "./worker";
|
|
15
15
|
import { pramenDO } from "./durable-object";
|
|
16
|
+
import { validateTriggerTasks } from "./sdk/schema";
|
|
16
17
|
/** Build the deployable pair for an app. `scheduled` is a Cron Trigger entry that
|
|
17
18
|
* drains the D1 outbox (the DO path self-drains via an alarm) — wire it only if you
|
|
18
19
|
* use the D1 store with deferred tasks. */
|
|
19
20
|
export function createPramen(app) {
|
|
21
|
+
validateTriggerTasks(app.schema, Object.keys(app.tasks ?? {})); // fail fast on a typo'd trigger task
|
|
20
22
|
const worker = makeWorker(app);
|
|
21
23
|
return { fetch: worker.fetch, scheduled: worker.scheduled, PramenDO: pramenDO(app) };
|
|
22
24
|
}
|
package/dist/runtime/acl.d.ts
CHANGED
|
@@ -30,6 +30,11 @@ export interface AclContext {
|
|
|
30
30
|
* lives in a different partition (a partition-DO only owns its own tables). Unset
|
|
31
31
|
* (e.g. the D1/Worker shared-store path) disables the guard — a no-op. */
|
|
32
32
|
readonly partition?: string;
|
|
33
|
+
/** Suppress declarative write-triggers for this Db. Set on the privileged context
|
|
34
|
+
* that DRAINS tasks, so a task handler's writes don't re-fire triggers (which would
|
|
35
|
+
* cascade — a trigger → task → write → trigger loop). Triggers fire on request-path
|
|
36
|
+
* writes, not on task-handler writes. */
|
|
37
|
+
readonly suppressTriggers?: boolean;
|
|
33
38
|
}
|
|
34
39
|
/** Evaluate every resolver reachable by the identity's roles, once per request.
|
|
35
40
|
* Resolvers read through a SYSTEM-mode db (ACL bypassed) to avoid recursion. */
|
package/dist/runtime/db.d.ts
CHANGED
|
@@ -62,6 +62,10 @@ export declare class Db<S extends SchemaDef = SchemaDef> {
|
|
|
62
62
|
private readonly schema;
|
|
63
63
|
/** Tables read or written during this Db's lifetime. */
|
|
64
64
|
readonly touched: Set<string>;
|
|
65
|
+
/** Tasks enqueued by declarative triggers during this Db's lifetime — the DO adds
|
|
66
|
+
* this to ctx.tasks enqueues to decide whether to arm its drain alarm. */
|
|
67
|
+
private taskEnqueueCount;
|
|
68
|
+
get taskEnqueues(): number;
|
|
65
69
|
private readonly dialect;
|
|
66
70
|
private readonly acl;
|
|
67
71
|
constructor(driver: Driver, acl: AclContext, schema: SchemaDef);
|
|
@@ -124,6 +128,20 @@ export declare class Db<S extends SchemaDef = SchemaDef> {
|
|
|
124
128
|
private hiddenColsOf;
|
|
125
129
|
/** Drop hidden columns from a row (copying only if any are present). */
|
|
126
130
|
private stripHidden;
|
|
131
|
+
/** Fire declarative write-triggers for `op` on `entity`: enqueue a task per matching
|
|
132
|
+
* trigger into the outbox, in THIS mutation's transaction (atomic with the write).
|
|
133
|
+
* `row` is the affected row (decoded — new values for create/update, the removed row
|
|
134
|
+
* for delete); `writtenCols` are the columns the write touched; `before` is the prior
|
|
135
|
+
* row (update only) for value-change detection on a field-filtered trigger.
|
|
136
|
+
*
|
|
137
|
+
* - Hidden columns are STRIPPED from the payload row — `hidden()` ("never readable via
|
|
138
|
+
* the ORM, even under SYSTEM") must hold here too, or a secret like passwordHash
|
|
139
|
+
* would leak to a task handler / webhook.
|
|
140
|
+
* - A field-filtered update trigger fires only when a watched column's value actually
|
|
141
|
+
* CHANGED (not merely was written to the same value).
|
|
142
|
+
* - Suppressed in the task-drain context, so a task's own writes don't re-fire
|
|
143
|
+
* triggers (preventing a trigger→task→write→trigger cascade). */
|
|
144
|
+
private fireTriggers;
|
|
127
145
|
/** Mint a UUID for every `generated()` uuid column the caller omitted, mutating
|
|
128
146
|
* `vals`. Returns the columns it filled — server-minted, so the insert path treats
|
|
129
147
|
* them like forced `set` values (bypassing the writable-field ACL check). */
|
package/dist/runtime/db.js
CHANGED
|
@@ -13,9 +13,21 @@
|
|
|
13
13
|
import { AclDenied, ALLOW_ALL, compileScopedWhere, effectiveFields, projectRow, resolveRelationScope, resolveScope, resolveWriteRules, } from "./acl";
|
|
14
14
|
import { and, cmp, compileAggregate, compileCount, compileExpr, compileSelect, eq, inList, or, TRUE, } from "./read-engine";
|
|
15
15
|
import { BadRequest } from "./errors";
|
|
16
|
-
import {
|
|
16
|
+
import { enqueueTask } from "./outbox";
|
|
17
|
+
import { partitionOf, triggersOf, triggerFires } from "../sdk/schema";
|
|
17
18
|
import { isValidUuid } from "../sdk/uuid";
|
|
18
19
|
const DEFAULT_PAGE_SIZE = 50;
|
|
20
|
+
/** Compare two decoded cell values for trigger change-detection. Primitives by ===;
|
|
21
|
+
* json/object cells (already parsed) by structural JSON equality. */
|
|
22
|
+
function cellEqual(a, b) {
|
|
23
|
+
if (a === b)
|
|
24
|
+
return true;
|
|
25
|
+
if (a == null || b == null)
|
|
26
|
+
return false;
|
|
27
|
+
if (typeof a === "object" || typeof b === "object")
|
|
28
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
19
31
|
function normalizeOrder(orderBy) {
|
|
20
32
|
if (!orderBy)
|
|
21
33
|
return undefined;
|
|
@@ -55,6 +67,12 @@ export class Db {
|
|
|
55
67
|
schema;
|
|
56
68
|
/** Tables read or written during this Db's lifetime. */
|
|
57
69
|
touched = new Set();
|
|
70
|
+
/** Tasks enqueued by declarative triggers during this Db's lifetime — the DO adds
|
|
71
|
+
* this to ctx.tasks enqueues to decide whether to arm its drain alarm. */
|
|
72
|
+
taskEnqueueCount = 0;
|
|
73
|
+
get taskEnqueues() {
|
|
74
|
+
return this.taskEnqueueCount;
|
|
75
|
+
}
|
|
58
76
|
dialect;
|
|
59
77
|
acl;
|
|
60
78
|
constructor(driver, acl, schema) {
|
|
@@ -283,6 +301,41 @@ export class Db {
|
|
|
283
301
|
delete out[c];
|
|
284
302
|
return out;
|
|
285
303
|
}
|
|
304
|
+
/** Fire declarative write-triggers for `op` on `entity`: enqueue a task per matching
|
|
305
|
+
* trigger into the outbox, in THIS mutation's transaction (atomic with the write).
|
|
306
|
+
* `row` is the affected row (decoded — new values for create/update, the removed row
|
|
307
|
+
* for delete); `writtenCols` are the columns the write touched; `before` is the prior
|
|
308
|
+
* row (update only) for value-change detection on a field-filtered trigger.
|
|
309
|
+
*
|
|
310
|
+
* - Hidden columns are STRIPPED from the payload row — `hidden()` ("never readable via
|
|
311
|
+
* the ORM, even under SYSTEM") must hold here too, or a secret like passwordHash
|
|
312
|
+
* would leak to a task handler / webhook.
|
|
313
|
+
* - A field-filtered update trigger fires only when a watched column's value actually
|
|
314
|
+
* CHANGED (not merely was written to the same value).
|
|
315
|
+
* - Suppressed in the task-drain context, so a task's own writes don't re-fire
|
|
316
|
+
* triggers (preventing a trigger→task→write→trigger cascade). */
|
|
317
|
+
async fireTriggers(entity, op, row, writtenCols, before) {
|
|
318
|
+
if (this.acl.suppressTriggers)
|
|
319
|
+
return;
|
|
320
|
+
const triggers = triggersOf(this.schema, entity);
|
|
321
|
+
if (triggers.length === 0)
|
|
322
|
+
return;
|
|
323
|
+
const id = row[this.pkOf(entity)];
|
|
324
|
+
let safeRow;
|
|
325
|
+
for (const t of triggers) {
|
|
326
|
+
if (!triggerFires(t, op, writtenCols))
|
|
327
|
+
continue;
|
|
328
|
+
if (op === "update" && Array.isArray(t.on.update) && before) {
|
|
329
|
+
const changed = t.on.update.some((c) => writtenCols.includes(c) && !cellEqual(before[c], row[c]));
|
|
330
|
+
if (!changed)
|
|
331
|
+
continue; // watched column(s) written, but value unchanged
|
|
332
|
+
}
|
|
333
|
+
if (!safeRow)
|
|
334
|
+
safeRow = this.stripHidden(entity, row); // never leak hidden columns
|
|
335
|
+
await enqueueTask(this.driver, Date.now(), { kind: t.task, payload: { entity, op, id, row: safeRow } });
|
|
336
|
+
this.taskEnqueueCount++;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
286
339
|
/** Mint a UUID for every `generated()` uuid column the caller omitted, mutating
|
|
287
340
|
* `vals`. Returns the columns it filled — server-minted, so the insert path treats
|
|
288
341
|
* them like forced `set` values (bypassing the writable-field ACL check). */
|
|
@@ -443,7 +496,9 @@ export class Db {
|
|
|
443
496
|
const params = cols.map((c) => this.encodeCell(jsonCols, c, vals[c]));
|
|
444
497
|
const sql = `INSERT INTO ${this.dialect.id(table)} (${colList}) VALUES (${phs})${this.returningClause("*")}`;
|
|
445
498
|
const rows = await this.driver.exec(sql, params);
|
|
446
|
-
|
|
499
|
+
const persisted = this.decodeRow(table, rows[0]);
|
|
500
|
+
await this.fireTriggers(table, "create", persisted, cols);
|
|
501
|
+
return this.projectWrite(table, persisted, cols);
|
|
447
502
|
}
|
|
448
503
|
/** Project a mutation's RETURNING row so the echo never reveals more than a read
|
|
449
504
|
* would: the caller's readable fields for this row, PLUS the columns they just
|
|
@@ -481,14 +536,20 @@ export class Db {
|
|
|
481
536
|
const cols = Object.keys(p);
|
|
482
537
|
if (cols.length === 0)
|
|
483
538
|
return undefined;
|
|
484
|
-
//
|
|
485
|
-
//
|
|
539
|
+
// Fetch the existing row when we need it: for per-row field permission (evaluated
|
|
540
|
+
// against the FINAL post-merge row) OR for a field-filtered update trigger's
|
|
541
|
+
// value-change detection (so it fires only on an actual change, not a same-value write).
|
|
542
|
+
const needCellEval = scope.fields !== null && (scope.conditional.length > 0 || scope.fieldsFns.length > 0);
|
|
543
|
+
const needBefore = !this.acl.suppressTriggers && triggersOf(this.schema, table).some((t) => Array.isArray(t.on.update));
|
|
486
544
|
let evalRow = p;
|
|
487
|
-
|
|
545
|
+
let before;
|
|
546
|
+
if (needCellEval || needBefore) {
|
|
488
547
|
const existing = await this.fetchOne(table, id, scope.where);
|
|
489
548
|
if (!existing)
|
|
490
549
|
return undefined; // out of update scope -> no-op
|
|
491
|
-
|
|
550
|
+
before = existing;
|
|
551
|
+
if (needCellEval)
|
|
552
|
+
evalRow = { ...existing, ...p };
|
|
492
553
|
}
|
|
493
554
|
this.checkWriteFields(table, "update", scope, cols, evalRow, new Set(Object.keys(set)));
|
|
494
555
|
this.runValidators(validators, p);
|
|
@@ -506,6 +567,8 @@ export class Db {
|
|
|
506
567
|
sql += this.scopeClause(scope.where, params);
|
|
507
568
|
sql += this.returningClause("*");
|
|
508
569
|
const updated = this.decodeRow(table, (await this.driver.exec(sql, params))[0]);
|
|
570
|
+
if (updated)
|
|
571
|
+
await this.fireTriggers(table, "update", updated, cols, before);
|
|
509
572
|
return (updated ? this.projectWrite(table, updated, cols) : undefined);
|
|
510
573
|
}
|
|
511
574
|
/** Delete a row by id within scope. Returns whether a row was deleted. */
|
|
@@ -519,7 +582,10 @@ export class Db {
|
|
|
519
582
|
let sql = `DELETE FROM ${this.dialect.id(table)} WHERE ${this.dialect.id(this.pkOf(table))} = ${this.dialect.placeholder(1)}`;
|
|
520
583
|
sql += this.scopeClause(scope.where, params);
|
|
521
584
|
sql += this.returningClause("*");
|
|
522
|
-
|
|
585
|
+
const deleted = this.decodeRow(table, (await this.driver.exec(sql, params))[0]);
|
|
586
|
+
if (deleted)
|
|
587
|
+
await this.fireTriggers(table, "delete", deleted, []);
|
|
588
|
+
return deleted != null;
|
|
523
589
|
}
|
|
524
590
|
/** Escape hatch — raw SQL, NOT ACL-checked. For system/internal use only. */
|
|
525
591
|
async exec(sql, ...params) {
|
package/dist/runtime/dispatch.js
CHANGED
|
@@ -11,6 +11,7 @@ import { Db } from "./db";
|
|
|
11
11
|
import { warmup } from "./acl";
|
|
12
12
|
import { BadRequest } from "./errors";
|
|
13
13
|
import { enqueueTask } from "./outbox";
|
|
14
|
+
import { createMail } from "./mail";
|
|
14
15
|
/** The `ctx.tasks` facade over the outbox. `onEnqueue` lets the caller count enqueues
|
|
15
16
|
* so it can wake the drainer. */
|
|
16
17
|
export function tasksFacade(driver, onEnqueue) {
|
|
@@ -48,9 +49,18 @@ export async function dispatch(handlers, schema, driver, kv, files, env, acl, na
|
|
|
48
49
|
const resolved = await warmup(acl.acl, acl.identity, systemDb);
|
|
49
50
|
const db = new Db(driver, { acl: acl.acl, identity: acl.identity, input: parsed, resolved, schema, partition: acl.partition }, schema);
|
|
50
51
|
let enqueued = 0;
|
|
51
|
-
const ctx = {
|
|
52
|
+
const ctx = {
|
|
53
|
+
db,
|
|
54
|
+
kv,
|
|
55
|
+
files,
|
|
56
|
+
env,
|
|
57
|
+
identity: acl.identity,
|
|
58
|
+
tasks: tasksFacade(driver, () => enqueued++),
|
|
59
|
+
mail: createMail(env, kv),
|
|
60
|
+
};
|
|
52
61
|
const result = handler.kind === "query"
|
|
53
62
|
? await handler.run(ctx, parsed)
|
|
54
63
|
: await driver.transaction(async () => handler.run(ctx, parsed));
|
|
55
|
-
|
|
64
|
+
// Both explicit ctx.tasks.enqueue and declarative trigger enqueues (in db) count.
|
|
65
|
+
return { result, kind: handler.kind, touched: [...db.touched], enqueued: enqueued + db.taskEnqueues };
|
|
56
66
|
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { Kv } from "./kv";
|
|
2
|
+
export interface MailAddress {
|
|
3
|
+
email: string;
|
|
4
|
+
name?: string;
|
|
5
|
+
}
|
|
6
|
+
export interface MailMessage {
|
|
7
|
+
to: string | string[];
|
|
8
|
+
/** Sender. Optional — defaults to MAIL_FROM (a verified address). */
|
|
9
|
+
from?: MailAddress;
|
|
10
|
+
subject: string;
|
|
11
|
+
text?: string;
|
|
12
|
+
html?: string;
|
|
13
|
+
replyTo?: string | MailAddress;
|
|
14
|
+
}
|
|
15
|
+
/** The transport seam. One per backend (Cloudflare Email Sending, a dev stash, …). */
|
|
16
|
+
export interface MailAdapter {
|
|
17
|
+
/** Deliver a fully-resolved message (`from` already filled by the facade). */
|
|
18
|
+
send(message: MailMessage & {
|
|
19
|
+
from: MailAddress;
|
|
20
|
+
}): Promise<void>;
|
|
21
|
+
}
|
|
22
|
+
/** The `ctx.mail` facade: resolves the sender, validates, and delegates to the adapter. */
|
|
23
|
+
export declare class Mail {
|
|
24
|
+
private readonly adapter;
|
|
25
|
+
private readonly defaultFrom?;
|
|
26
|
+
constructor(adapter: MailAdapter, defaultFrom?: MailAddress | undefined);
|
|
27
|
+
send(message: MailMessage): Promise<void>;
|
|
28
|
+
}
|
|
29
|
+
/** The Cloudflare `send_email` binding shape (workers binding form: `from` uses `email`). */
|
|
30
|
+
export interface SendEmailBinding {
|
|
31
|
+
send(message: {
|
|
32
|
+
to: string | string[];
|
|
33
|
+
from: MailAddress;
|
|
34
|
+
subject: string;
|
|
35
|
+
text?: string;
|
|
36
|
+
html?: string;
|
|
37
|
+
replyTo?: string | MailAddress;
|
|
38
|
+
}): Promise<void>;
|
|
39
|
+
}
|
|
40
|
+
/** Cloudflare Email Sending — sends via the `send_email` binding (no API keys). The
|
|
41
|
+
* `from` domain must be onboarded (`wrangler email sending enable yourdomain.com`). */
|
|
42
|
+
export declare class CloudflareEmailAdapter implements MailAdapter {
|
|
43
|
+
private readonly binding;
|
|
44
|
+
constructor(binding: SendEmailBinding);
|
|
45
|
+
send(message: MailMessage & {
|
|
46
|
+
from: MailAddress;
|
|
47
|
+
}): Promise<void>;
|
|
48
|
+
}
|
|
49
|
+
/** Dev/test transport: stash the message in KV under `mail:<recipient>` so an e2e suite
|
|
50
|
+
* (or a dashboard) can read the "inbox" instead of really sending. */
|
|
51
|
+
export declare class KvMailAdapter implements MailAdapter {
|
|
52
|
+
private readonly kv;
|
|
53
|
+
constructor(kv: Kv);
|
|
54
|
+
send(message: MailMessage & {
|
|
55
|
+
from: MailAddress;
|
|
56
|
+
}): Promise<void>;
|
|
57
|
+
}
|
|
58
|
+
/** In-memory transport: captures sent messages (pure; for unit tests). */
|
|
59
|
+
export declare class MemoryMailAdapter implements MailAdapter {
|
|
60
|
+
readonly sent: Array<MailMessage & {
|
|
61
|
+
from: MailAddress;
|
|
62
|
+
}>;
|
|
63
|
+
send(message: MailMessage & {
|
|
64
|
+
from: MailAddress;
|
|
65
|
+
}): Promise<void>;
|
|
66
|
+
}
|
|
67
|
+
/** Fail-closed transport: no real sender and no explicit dev-capture opt-in, so a
|
|
68
|
+
* `send` THROWS rather than silently capturing. Prevents a misconfigured production
|
|
69
|
+
* (no MAIL_FROM) from writing security emails — magic-link tokens, resets — into KV
|
|
70
|
+
* instead of delivering them. Mirrors how files fail closed without FILES_SECRET. */
|
|
71
|
+
export declare class UnconfiguredMailAdapter implements MailAdapter {
|
|
72
|
+
send(): Promise<void>;
|
|
73
|
+
}
|
|
74
|
+
/** Build `ctx.mail` from the environment:
|
|
75
|
+
* - `EMAIL` binding + `MAIL_FROM` → Cloudflare Email Sending (real send).
|
|
76
|
+
* - else `MAIL_CAPTURE=true` → capture (KV inbox if a Kv is given, else in-memory) with
|
|
77
|
+
* a synthetic dev sender — an EXPLICIT dev opt-in, never the production default.
|
|
78
|
+
* - else → fail closed: a `send` throws (so a missing-MAIL_FROM prod doesn't silently
|
|
79
|
+
* stash security emails in KV). */
|
|
80
|
+
export declare function createMail(env: Readonly<Record<string, unknown>>, kv?: Kv): Mail;
|
|
@@ -0,0 +1,102 @@
|
|
|
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
|
+
/** The `ctx.mail` facade: resolves the sender, validates, and delegates to the adapter. */
|
|
13
|
+
export class Mail {
|
|
14
|
+
adapter;
|
|
15
|
+
defaultFrom;
|
|
16
|
+
constructor(adapter, defaultFrom) {
|
|
17
|
+
this.adapter = adapter;
|
|
18
|
+
this.defaultFrom = defaultFrom;
|
|
19
|
+
}
|
|
20
|
+
async send(message) {
|
|
21
|
+
const to = Array.isArray(message.to) ? message.to : [message.to];
|
|
22
|
+
if (to.length === 0 || to.some((a) => typeof a !== "string" || a.length === 0)) {
|
|
23
|
+
throw new Error("ctx.mail.send: `to` is required");
|
|
24
|
+
}
|
|
25
|
+
if (typeof message.subject !== "string" || message.subject.length === 0) {
|
|
26
|
+
throw new Error("ctx.mail.send: `subject` is required");
|
|
27
|
+
}
|
|
28
|
+
const from = message.from ?? this.defaultFrom;
|
|
29
|
+
if (!from)
|
|
30
|
+
throw new Error("ctx.mail.send: no sender — set the MAIL_FROM var or pass `from`");
|
|
31
|
+
await this.adapter.send({ ...message, from });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** Cloudflare Email Sending — sends via the `send_email` binding (no API keys). The
|
|
35
|
+
* `from` domain must be onboarded (`wrangler email sending enable yourdomain.com`). */
|
|
36
|
+
export class CloudflareEmailAdapter {
|
|
37
|
+
binding;
|
|
38
|
+
constructor(binding) {
|
|
39
|
+
this.binding = binding;
|
|
40
|
+
}
|
|
41
|
+
async send(message) {
|
|
42
|
+
await this.binding.send({
|
|
43
|
+
to: message.to,
|
|
44
|
+
from: message.from,
|
|
45
|
+
subject: message.subject,
|
|
46
|
+
text: message.text,
|
|
47
|
+
html: message.html,
|
|
48
|
+
replyTo: message.replyTo,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/** Dev/test transport: stash the message in KV under `mail:<recipient>` so an e2e suite
|
|
53
|
+
* (or a dashboard) can read the "inbox" instead of really sending. */
|
|
54
|
+
export class KvMailAdapter {
|
|
55
|
+
kv;
|
|
56
|
+
constructor(kv) {
|
|
57
|
+
this.kv = kv;
|
|
58
|
+
}
|
|
59
|
+
async send(message) {
|
|
60
|
+
const to = Array.isArray(message.to) ? message.to : [message.to];
|
|
61
|
+
const value = JSON.stringify({ from: message.from, subject: message.subject, text: message.text, html: message.html });
|
|
62
|
+
for (const addr of to)
|
|
63
|
+
await this.kv.put(`mail:${addr}`, value, { expirationTtl: 900 });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/** In-memory transport: captures sent messages (pure; for unit tests). */
|
|
67
|
+
export class MemoryMailAdapter {
|
|
68
|
+
sent = [];
|
|
69
|
+
async send(message) {
|
|
70
|
+
this.sent.push(message);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/** Fail-closed transport: no real sender and no explicit dev-capture opt-in, so a
|
|
74
|
+
* `send` THROWS rather than silently capturing. Prevents a misconfigured production
|
|
75
|
+
* (no MAIL_FROM) from writing security emails — magic-link tokens, resets — into KV
|
|
76
|
+
* instead of delivering them. Mirrors how files fail closed without FILES_SECRET. */
|
|
77
|
+
export class UnconfiguredMailAdapter {
|
|
78
|
+
async send() {
|
|
79
|
+
throw new Error("ctx.mail: no transport configured — set MAIL_FROM (with the EMAIL binding) to send, " +
|
|
80
|
+
"or MAIL_CAPTURE=true to capture in dev.");
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/** Build `ctx.mail` from the environment:
|
|
84
|
+
* - `EMAIL` binding + `MAIL_FROM` → Cloudflare Email Sending (real send).
|
|
85
|
+
* - else `MAIL_CAPTURE=true` → capture (KV inbox if a Kv is given, else in-memory) with
|
|
86
|
+
* a synthetic dev sender — an EXPLICIT dev opt-in, never the production default.
|
|
87
|
+
* - else → fail closed: a `send` throws (so a missing-MAIL_FROM prod doesn't silently
|
|
88
|
+
* stash security emails in KV). */
|
|
89
|
+
export function createMail(env, kv) {
|
|
90
|
+
const binding = env.EMAIL;
|
|
91
|
+
const fromAddr = typeof env.MAIL_FROM === "string" && env.MAIL_FROM ? env.MAIL_FROM : undefined;
|
|
92
|
+
if (binding && fromAddr) {
|
|
93
|
+
const name = typeof env.MAIL_FROM_NAME === "string" ? env.MAIL_FROM_NAME : undefined;
|
|
94
|
+
return new Mail(new CloudflareEmailAdapter(binding), { email: fromAddr, name });
|
|
95
|
+
}
|
|
96
|
+
if (env.MAIL_CAPTURE === "true") {
|
|
97
|
+
const devFrom = { email: "dev@pramen.local", name: "pramen (dev)" };
|
|
98
|
+
return new Mail(kv ? new KvMailAdapter(kv) : new MemoryMailAdapter(), devFrom);
|
|
99
|
+
}
|
|
100
|
+
// Sentinel `from` so the facade delegates to the adapter, which throws the clear error.
|
|
101
|
+
return new Mail(new UnconfiguredMailAdapter(), { email: "unconfigured@invalid" });
|
|
102
|
+
}
|
package/dist/runtime/migrate.js
CHANGED
|
@@ -98,9 +98,16 @@ export async function migrate(driver, schema, opts = {}) {
|
|
|
98
98
|
// `schema_hash` key for backward compatibility (existing stores + the D1 path).
|
|
99
99
|
const subset = Object.fromEntries(entries);
|
|
100
100
|
const hashKey = opts.partition === undefined ? "schema_hash" : `schema_hash:${opts.partition}`;
|
|
101
|
+
const tablesKey = opts.partition === undefined ? "schema_tables" : `schema_tables:${opts.partition}`;
|
|
102
|
+
const tablesValue = () => JSON.stringify(Object.fromEntries(entries.map(([table, def]) => [table, Object.keys(def.fields)])));
|
|
101
103
|
const current = schemaHash(subset);
|
|
102
|
-
if ((await readMeta(driver, hashKey)) === current)
|
|
104
|
+
if ((await readMeta(driver, hashKey)) === current) {
|
|
105
|
+
// Backfill the table map for stores migrated before this key existed (the schema
|
|
106
|
+
// is unchanged, so it's exactly what's applied).
|
|
107
|
+
if ((await readMeta(driver, tablesKey)) == null)
|
|
108
|
+
await writeMeta(driver, tablesKey, tablesValue());
|
|
103
109
|
return { changed: false, created: [], added: [], rebuilt: [], droppedTables: [], skipped: [] };
|
|
110
|
+
}
|
|
104
111
|
const created = [];
|
|
105
112
|
const added = [];
|
|
106
113
|
const rebuilt = [];
|
|
@@ -186,6 +193,10 @@ export async function migrate(driver, schema, opts = {}) {
|
|
|
186
193
|
// additive work is idempotent, so re-running is safe.
|
|
187
194
|
if (skipped.length === 0) {
|
|
188
195
|
await writeMeta(driver, hashKey, current);
|
|
196
|
+
// Persist the applied table→columns so /admin/schema reports it without a raw
|
|
197
|
+
// PRAGMA at request time — workerd's SQLite authorizer rejects PRAGMA once the
|
|
198
|
+
// DO-storage alarm API has run in the object. Migrate runs on boot, before any.
|
|
199
|
+
await writeMeta(driver, tablesKey, tablesValue());
|
|
189
200
|
}
|
|
190
201
|
else {
|
|
191
202
|
console.warn(`pramen: ${skipped.length} destructive migration(s) skipped (set PRAMEN_ALLOW_DESTRUCTIVE=true to apply): ${skipped.join("; ")}`);
|
package/dist/sdk/handlers.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Db } from "../runtime/db";
|
|
2
2
|
import type { Kv } from "../runtime/kv";
|
|
3
|
+
import type { Mail } from "../runtime/mail";
|
|
3
4
|
import type { Identity } from "./acl";
|
|
4
5
|
import type { Files } from "./files";
|
|
5
6
|
import type { SchemaDef } from "./schema";
|
|
@@ -12,6 +13,11 @@ export interface HandlerContext<S extends SchemaDef = SchemaDef> {
|
|
|
12
13
|
/** Per-tenant file storage: mint signed upload/download urls, head/delete blobs.
|
|
13
14
|
* Bytes flow through the Worker /files/* route, never through the DO. */
|
|
14
15
|
readonly files: Files;
|
|
16
|
+
/** Send email: `ctx.mail.send({ to, subject, text/html })`. On Cloudflare this is
|
|
17
|
+
* Cloudflare Email Sending (the `send_email` binding); off-platform / unconfigured it
|
|
18
|
+
* captures instead of sending. Prefer enqueuing the send as a task (see `ctx.tasks`)
|
|
19
|
+
* so it runs off the single-writer write path. */
|
|
20
|
+
readonly mail: Mail;
|
|
15
21
|
/** The Worker/DO environment — bindings (KV, R2, DB, …) plus vars and secrets
|
|
16
22
|
* (AUTH_SECRET, plus anything in wrangler.jsonc / .dev.vars / `wrangler secret`).
|
|
17
23
|
* Use it to call external services from handlers — Cloudflare bindings (e.g. the
|
package/dist/sdk/schema.d.ts
CHANGED
|
@@ -103,16 +103,48 @@ declare const relationBuilders: {
|
|
|
103
103
|
export type RelationBuilders = typeof relationBuilders;
|
|
104
104
|
/** The default partition name for entities that don't declare one. */
|
|
105
105
|
export declare const DEFAULT_PARTITION = "default";
|
|
106
|
+
export type TriggerOp = "create" | "update" | "delete";
|
|
107
|
+
/** A declarative trigger on an entity: when a matching write commits, the `Db` write
|
|
108
|
+
* path enqueues a task of `task` (handled by `app.tasks[task]`) IN THE SAME transaction
|
|
109
|
+
* as the write, with payload `{ entity, op, id, row }`. So a side effect (webhook,
|
|
110
|
+
* notification email) fires reliably after the write, off the single-writer path —
|
|
111
|
+
* reusing the whole outbox machinery (retry, idempotency, drain). Only ORM writes
|
|
112
|
+
* (`ctx.db` insert/update/delete) fire triggers; the raw `ctx.db.exec` escape hatch
|
|
113
|
+
* does not. */
|
|
114
|
+
export interface TriggerDef {
|
|
115
|
+
/** The `app.tasks` handler kind that runs the side effect. */
|
|
116
|
+
readonly task: string;
|
|
117
|
+
/** Which ops fire it. For `update`, an array names the columns to watch — fire only
|
|
118
|
+
* when the update writes one of them; `true` fires on any update. */
|
|
119
|
+
readonly on: {
|
|
120
|
+
create?: boolean;
|
|
121
|
+
update?: boolean | readonly string[];
|
|
122
|
+
delete?: boolean;
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
/** Declare an entity trigger (sugar; returns the def). */
|
|
126
|
+
export declare function trigger(def: TriggerDef): TriggerDef;
|
|
127
|
+
/** Does this trigger fire for `op` given the columns the write touched? */
|
|
128
|
+
export declare function triggerFires(t: TriggerDef, op: TriggerOp, writtenCols: readonly string[]): boolean;
|
|
106
129
|
export interface EntityDef<F extends EntityFields = EntityFields, R extends RelationDefs = Record<string, never>> {
|
|
107
130
|
readonly fields: F;
|
|
108
131
|
readonly relations: R;
|
|
109
132
|
/** The partition (Durable Object class) this entity lives in. Always populated;
|
|
110
133
|
* defaults to `"default"` so downstream code never branches on `undefined`. */
|
|
111
134
|
readonly partition: string;
|
|
135
|
+
/** Declarative write-triggers (see TriggerDef). Always an array (possibly empty). */
|
|
136
|
+
readonly triggers: readonly TriggerDef[];
|
|
112
137
|
}
|
|
113
138
|
export declare function Entity<F extends EntityFields, R extends RelationDefs = Record<string, never>>(build: (t: FieldBuilders) => F, relations?: (r: RelationBuilders) => R, opts?: {
|
|
114
139
|
partition?: string;
|
|
140
|
+
triggers?: readonly TriggerDef[];
|
|
115
141
|
}): EntityDef<F, R>;
|
|
142
|
+
/** The triggers declared on an entity (empty if none / unknown entity). */
|
|
143
|
+
export declare function triggersOf(schema: SchemaDef, entity: string): readonly TriggerDef[];
|
|
144
|
+
/** Throw if any declarative trigger names a `task` not in `taskNames` — caught at
|
|
145
|
+
* deploy/load by createPramen, so a typo can't silently enqueue a task that never runs
|
|
146
|
+
* (it would retry then dead-letter). */
|
|
147
|
+
export declare function validateTriggerTasks(schema: SchemaDef, taskNames: Iterable<string>): void;
|
|
116
148
|
/** Annotate a field as renamed from a previous column name (migration hint). Wraps
|
|
117
149
|
* a builder result, preserving its literal field type: `title: renamedFrom(t.text(), "name")`. */
|
|
118
150
|
export declare function renamedFrom<F extends FieldDef>(field: F, from: string): F & {
|