@pramen/server 0.0.10 → 0.0.11

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.
@@ -153,7 +153,7 @@ export class PramenDOBase extends DurableObject {
153
153
  * Task handlers get full db access + env (e.g. ctx.env.EMAIL) + their own ctx.tasks. */
154
154
  taskCtx() {
155
155
  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);
156
+ const db = new Db(this.driver, { acl: this.acl, identity, system: true, schema: this.app.schema, partition: this.partition, suppressTriggers: true }, this.app.schema);
157
157
  return { db, kv: this.kv, files: this.filesFor(this.tenant), env: this.envBag, identity, tasks: tasksFacade(this.driver) };
158
158
  }
159
159
  /** Restore (tenant, partition) on a cold wake (e.g. an alarm with no request) so the
@@ -353,23 +353,21 @@ export class PramenDOBase extends DurableObject {
353
353
  return Response.json({ ok: false, error: "point-in-time recovery is unavailable in this environment", code: "unavailable" }, { status: 501 });
354
354
  }
355
355
  }
356
- // Introspection: this tenant's applied schema hash + live table/column shape
357
- // (admin-gated at the Worker). Powers the CLI's `schema status`.
356
+ // Introspection: this tenant's applied schema hash + table/column shape (admin-gated
357
+ // at the Worker). Powers the CLI's `schema status`. Both are read from _pramen_meta
358
+ // (written by migrate on boot) — NOT a request-time `PRAGMA`/introspection: once the
359
+ // DO-storage alarm API has run in this object, workerd's SQLite authorizer rejects
360
+ // PRAGMA (SQLITE_AUTH), so the outbox's alarm would otherwise break this endpoint.
358
361
  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
362
  const hashKey = `schema_hash:${this.partition}`;
363
- const hashRow = (await this.driver.exec(`SELECT value FROM _pramen_meta WHERE key = ?`, [hashKey]));
364
- const tableRows = (await this.driver.exec(`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'`, []));
365
- const tables = {};
366
- for (const { name } of tableRows) {
367
- // Skip pramen's internal bookkeeping tables (_pramen_meta, _pramen_outbox, …).
368
- if (name.toLowerCase().startsWith("_pramen") || name.toLowerCase().startsWith("__pramen"))
369
- continue;
370
- tables[name] = (await this.driver.exec(`PRAGMA table_info(${name})`, [])).map((r) => r.name);
371
- }
372
- return Response.json({ ok: true, result: { hash: hashRow[0]?.value ?? null, tables } });
363
+ const tablesKey = `schema_tables:${this.partition}`;
364
+ const rows = (await this.driver.exec(`SELECT key, value FROM _pramen_meta WHERE key IN (?, ?)`, [
365
+ hashKey,
366
+ tablesKey,
367
+ ]));
368
+ const byKey = new Map(rows.map((r) => [r.key, r.value]));
369
+ const tables = byKey.has(tablesKey) ? JSON.parse(byKey.get(tablesKey)) : {};
370
+ return Response.json({ ok: true, result: { hash: byKey.get(hashKey) ?? null, tables } });
373
371
  }
374
372
  // Generic admin data ops (admin-gated at the Worker). Runs through a SYSTEM-mode
375
373
  // 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";
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";
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 { SchemaDef } from "./sdk/schema";
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
  }
@@ -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. */
@@ -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). */
@@ -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 { partitionOf } from "../sdk/schema";
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
- return this.projectWrite(table, this.decodeRow(table, rows[0]), cols);
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
- // Per-row field permission is evaluated against the FINAL (post-merge) row, so
485
- // fetch the existing row within update scope when any cell-level rule applies.
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
- if (scope.fields !== null && (scope.conditional.length > 0 || scope.fieldsFns.length > 0)) {
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
- evalRow = { ...existing, ...p };
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
- return (await this.driver.exec(sql, params)).length > 0;
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) {
@@ -52,5 +52,6 @@ export async function dispatch(handlers, schema, driver, kv, files, env, acl, na
52
52
  const result = handler.kind === "query"
53
53
  ? await handler.run(ctx, parsed)
54
54
  : await driver.transaction(async () => handler.run(ctx, parsed));
55
- return { result, kind: handler.kind, touched: [...db.touched], enqueued };
55
+ // Both explicit ctx.tasks.enqueue and declarative trigger enqueues (in db) count.
56
+ return { result, kind: handler.kind, touched: [...db.touched], enqueued: enqueued + db.taskEnqueues };
56
57
  }
@@ -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("; ")}`);
@@ -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 & {
@@ -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
@@ -109,7 +109,7 @@ export function makeWorker(app) {
109
109
  const d1TaskCtx = (driver, env) => {
110
110
  const identity = { roles: ["admin"] };
111
111
  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);
112
+ const db = new Db(driver, { acl: d1Acl, identity, system: true, schema: app.schema, suppressTriggers: true }, app.schema);
113
113
  return { db, kv: new Kv(env.KV), files, env: env, identity, tasks: tasksFacade(driver) };
114
114
  };
115
115
  /** Drain the D1 outbox in the Worker (no DO/alarm on this path) — called by the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pramen/server",
3
- "version": "0.0.10",
3
+ "version": "0.0.11",
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": {
@@ -202,7 +202,7 @@ export class PramenDOBase extends DurableObject<DoEnv> {
202
202
  const identity: Identity = { roles: ["admin"] };
203
203
  const db = new Db(
204
204
  this.driver,
205
- { acl: this.acl, identity, system: true, schema: this.app.schema, partition: this.partition },
205
+ { acl: this.acl, identity, system: true, schema: this.app.schema, partition: this.partition, suppressTriggers: true },
206
206
  this.app.schema,
207
207
  );
208
208
  return { db, kv: this.kv, files: this.filesFor(this.tenant), env: this.envBag, identity, tasks: tasksFacade(this.driver) };
@@ -417,27 +417,21 @@ export class PramenDOBase extends DurableObject<DoEnv> {
417
417
  }
418
418
  }
419
419
 
420
- // Introspection: this tenant's applied schema hash + live table/column shape
421
- // (admin-gated at the Worker). Powers the CLI's `schema status`.
420
+ // Introspection: this tenant's applied schema hash + table/column shape (admin-gated
421
+ // at the Worker). Powers the CLI's `schema status`. Both are read from _pramen_meta
422
+ // (written by migrate on boot) — NOT a request-time `PRAGMA`/introspection: once the
423
+ // DO-storage alarm API has run in this object, workerd's SQLite authorizer rejects
424
+ // PRAGMA (SQLITE_AUTH), so the outbox's alarm would otherwise break this endpoint.
422
425
  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
426
  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 } });
427
+ const tablesKey = `schema_tables:${this.partition}`;
428
+ const rows = (await this.driver.exec(`SELECT key, value FROM _pramen_meta WHERE key IN (?, ?)`, [
429
+ hashKey,
430
+ tablesKey,
431
+ ])) as { key: string; value: string }[];
432
+ const byKey = new Map(rows.map((r) => [r.key, r.value]));
433
+ const tables = byKey.has(tablesKey) ? (JSON.parse(byKey.get(tablesKey)!) as Record<string, string[]>) : {};
434
+ return Response.json({ ok: true, result: { hash: byKey.get(hashKey) ?? null, tables } });
441
435
  }
442
436
 
443
437
  // 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,
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. */
@@ -83,5 +83,6 @@ export async function dispatch(
83
83
  ? await handler.run(ctx, parsed)
84
84
  : await driver.transaction(async () => handler.run(ctx, parsed));
85
85
 
86
- return { result, kind: handler.kind, touched: [...db.touched], enqueued };
86
+ // Both explicit ctx.tasks.enqueue and declarative trigger enqueues (in db) count.
87
+ return { result, kind: handler.kind, touched: [...db.touched], enqueued: enqueued + db.taskEnqueues };
87
88
  }
@@ -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("; ")}`,
package/src/sdk/schema.ts CHANGED
@@ -95,26 +95,82 @@ export type RelationBuilders = typeof relationBuilders;
95
95
  /** The default partition name for entities that don't declare one. */
96
96
  export const DEFAULT_PARTITION = "default";
97
97
 
98
+ // --- triggers — declarative "on write → enqueue a task" (layered on the outbox) ---
99
+
100
+ export type TriggerOp = "create" | "update" | "delete";
101
+
102
+ /** A declarative trigger on an entity: when a matching write commits, the `Db` write
103
+ * path enqueues a task of `task` (handled by `app.tasks[task]`) IN THE SAME transaction
104
+ * as the write, with payload `{ entity, op, id, row }`. So a side effect (webhook,
105
+ * notification email) fires reliably after the write, off the single-writer path —
106
+ * reusing the whole outbox machinery (retry, idempotency, drain). Only ORM writes
107
+ * (`ctx.db` insert/update/delete) fire triggers; the raw `ctx.db.exec` escape hatch
108
+ * does not. */
109
+ export interface TriggerDef {
110
+ /** The `app.tasks` handler kind that runs the side effect. */
111
+ readonly task: string;
112
+ /** Which ops fire it. For `update`, an array names the columns to watch — fire only
113
+ * when the update writes one of them; `true` fires on any update. */
114
+ readonly on: { create?: boolean; update?: boolean | readonly string[]; delete?: boolean };
115
+ }
116
+
117
+ /** Declare an entity trigger (sugar; returns the def). */
118
+ export function trigger(def: TriggerDef): TriggerDef {
119
+ return def;
120
+ }
121
+
122
+ /** Does this trigger fire for `op` given the columns the write touched? */
123
+ export function triggerFires(t: TriggerDef, op: TriggerOp, writtenCols: readonly string[]): boolean {
124
+ if (op === "create") return t.on.create === true;
125
+ if (op === "delete") return t.on.delete === true;
126
+ const u = t.on.update;
127
+ if (u === true) return true;
128
+ if (Array.isArray(u)) return u.some((f) => writtenCols.includes(f));
129
+ return false;
130
+ }
131
+
98
132
  export interface EntityDef<F extends EntityFields = EntityFields, R extends RelationDefs = Record<string, never>> {
99
133
  readonly fields: F;
100
134
  readonly relations: R;
101
135
  /** The partition (Durable Object class) this entity lives in. Always populated;
102
136
  * defaults to `"default"` so downstream code never branches on `undefined`. */
103
137
  readonly partition: string;
138
+ /** Declarative write-triggers (see TriggerDef). Always an array (possibly empty). */
139
+ readonly triggers: readonly TriggerDef[];
104
140
  }
105
141
 
106
142
  export function Entity<F extends EntityFields, R extends RelationDefs = Record<string, never>>(
107
143
  build: (t: FieldBuilders) => F,
108
144
  relations?: (r: RelationBuilders) => R,
109
- opts?: { partition?: string },
145
+ opts?: { partition?: string; triggers?: readonly TriggerDef[] },
110
146
  ): EntityDef<F, R> {
111
147
  return {
112
148
  fields: build(builders),
113
149
  relations: (relations ? relations(relationBuilders) : {}) as R,
114
150
  partition: opts?.partition ?? DEFAULT_PARTITION,
151
+ triggers: opts?.triggers ?? [],
115
152
  };
116
153
  }
117
154
 
155
+ /** The triggers declared on an entity (empty if none / unknown entity). */
156
+ export function triggersOf(schema: SchemaDef, entity: string): readonly TriggerDef[] {
157
+ return schema[entity]?.triggers ?? [];
158
+ }
159
+
160
+ /** Throw if any declarative trigger names a `task` not in `taskNames` — caught at
161
+ * deploy/load by createPramen, so a typo can't silently enqueue a task that never runs
162
+ * (it would retry then dead-letter). */
163
+ export function validateTriggerTasks(schema: SchemaDef, taskNames: Iterable<string>): void {
164
+ const known = new Set(taskNames);
165
+ for (const [entity, def] of Object.entries(schema)) {
166
+ for (const t of def.triggers) {
167
+ if (!known.has(t.task)) {
168
+ throw new Error(`trigger on '${entity}' references task '${t.task}', but app.tasks has no such handler.`);
169
+ }
170
+ }
171
+ }
172
+ }
173
+
118
174
  /** Annotate a field as renamed from a previous column name (migration hint). Wraps
119
175
  * a builder result, preserving its literal field type: `title: renamedFrom(t.text(), "name")`. */
120
176
  export function renamedFrom<F extends FieldDef>(field: F, from: string): F & { readonly renamedFrom: string } {
@@ -254,5 +310,17 @@ export function validateSchema(schema: SchemaDef): void {
254
310
  );
255
311
  }
256
312
  }
313
+ for (const t of def.triggers) {
314
+ if (!t.task) throw new Error(`trigger on '${entity}' is missing a 'task'.`);
315
+ if (!t.on.create && !t.on.update && !t.on.delete) {
316
+ throw new Error(`trigger '${t.task}' on '${entity}' fires on nothing — set on.create/update/delete.`);
317
+ }
318
+ const watched = Array.isArray(t.on.update) ? t.on.update : [];
319
+ for (const f of watched) {
320
+ if (!(f in def.fields)) {
321
+ throw new Error(`trigger '${t.task}' on '${entity}' watches unknown column '${f}'.`);
322
+ }
323
+ }
324
+ }
257
325
  }
258
326
  }
package/src/worker.ts CHANGED
@@ -148,7 +148,7 @@ export function makeWorker(app: PramenApp) {
148
148
  const d1TaskCtx = (driver: Driver, env: Env): HandlerContext => {
149
149
  const identity: Identity = { roles: ["admin"] };
150
150
  const files = createFiles({ tenant: "main", secret: filesSecret(env), adapter: new R2Adapter(env.FILES) });
151
- const db = new Db(driver, { acl: d1Acl, identity, system: true, schema: app.schema }, app.schema);
151
+ const db = new Db(driver, { acl: d1Acl, identity, system: true, schema: app.schema, suppressTriggers: true }, app.schema);
152
152
  return { db, kv: new Kv(env.KV), files, env: env as unknown as Record<string, unknown>, identity, tasks: tasksFacade(driver) };
153
153
  };
154
154