@pramen/server 0.0.9 → 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.
@@ -30,9 +30,29 @@ export declare class PramenDOBase extends DurableObject<DoEnv> {
30
30
  * constructor — see `ensureMigrated`. Guards against re-running. */
31
31
  private migrated;
32
32
  private files?;
33
+ /** Have we persisted (tenant, partition) to DO storage this instance? They're
34
+ * persisted so a COLD alarm wake (no request header) can build a correctly-scoped
35
+ * task context — a DO can't introspect its own idFromName. */
36
+ private identityPersisted;
37
+ private identityLoaded;
33
38
  constructor(ctx: DurableObjectState, env: DoEnv, app: PramenApp);
34
39
  private ensureMigrated;
35
40
  fetch(request: Request): Promise<Response>;
41
+ /** Arm the drain alarm soon after a mutation enqueued task(s). setAlarm replaces any
42
+ * pending alarm; a near-future time batches a burst of enqueues into one drain. */
43
+ private armDrain;
44
+ /** A privileged, system-scoped context for running task handlers (outside a request).
45
+ * Task handlers get full db access + env (e.g. ctx.env.EMAIL) + their own ctx.tasks. */
46
+ private taskCtx;
47
+ /** Restore (tenant, partition) on a cold wake (e.g. an alarm with no request) so the
48
+ * task context is scoped correctly. No-op once loaded/persisted this instance. */
49
+ private loadIdentity;
50
+ /** Drain due tasks. Called by the alarm (DO path) and the /__admin/tasks/drain route
51
+ * (manual / cron). Returns the drain stats incl. `nextRunAt` for rescheduling. */
52
+ private drainTasks;
53
+ alarm(): Promise<void>;
54
+ private handleDrain;
55
+ private handleTasksList;
36
56
  webSocketMessage(ws: WebSocket, raw: string | ArrayBuffer): Promise<void>;
37
57
  webSocketClose(ws: WebSocket): Promise<void>;
38
58
  webSocketError(ws: WebSocket, error: unknown): Promise<void>;
@@ -16,7 +16,8 @@
16
16
  // (identity + subscriptions) is stored via serializeAttachment().
17
17
  import { DurableObject } from "cloudflare:workers";
18
18
  import { migrate } from "./runtime/migrate";
19
- import { dispatch } from "./runtime/dispatch";
19
+ import { dispatch, tasksFacade, bindTasks } from "./runtime/dispatch";
20
+ import { ensureOutbox, drainOutbox, listTasks } from "./runtime/outbox";
20
21
  import { Db } from "./runtime/db";
21
22
  import { digest } from "./runtime/digest";
22
23
  import { compileAcl } from "./runtime/acl";
@@ -45,6 +46,11 @@ export class PramenDOBase extends DurableObject {
45
46
  * constructor — see `ensureMigrated`. Guards against re-running. */
46
47
  migrated = false;
47
48
  files;
49
+ /** Have we persisted (tenant, partition) to DO storage this instance? They're
50
+ * persisted so a COLD alarm wake (no request header) can build a correctly-scoped
51
+ * task context — a DO can't introspect its own idFromName. */
52
+ identityPersisted = false;
53
+ identityLoaded = false;
48
54
  constructor(ctx, env, app) {
49
55
  super(ctx, env);
50
56
  this.app = app;
@@ -78,6 +84,7 @@ export class PramenDOBase extends DurableObject {
78
84
  if (this.migrated)
79
85
  return; // a concurrent first request already migrated
80
86
  await this.driver.transaction(() => migrate(this.driver, this.app.schema, { allowDestructive, partition }).then(() => { }));
87
+ await ensureOutbox(this.driver); // the deferred-tasks table (internal, all partitions)
81
88
  this.migrated = true;
82
89
  });
83
90
  }
@@ -92,6 +99,15 @@ export class PramenDOBase extends DurableObject {
92
99
  this.partition = partitionHeader;
93
100
  await this.ensureMigrated();
94
101
  await this.ensureRegistered(request);
102
+ // Persist (tenant, partition) once per instance so a cold alarm can rebuild the
103
+ // right task context (it has no request header to learn them from). Stored in the
104
+ // SQL store (_pramen_meta), NOT ctx.storage.put — mixing the KV-style storage API
105
+ // with raw `PRAGMA` trips workerd's DO SQLite authorizer (SQLITE_AUTH).
106
+ if (!this.identityPersisted) {
107
+ await this.driver.exec(`INSERT OR REPLACE INTO _pramen_meta (key, value) VALUES (?, ?), (?, ?)`, ["_pramen_tenant", this.tenant, "_pramen_partition", this.partition].map((v) => this.driver.dialect.encode(v)));
108
+ this.identityPersisted = true;
109
+ this.identityLoaded = true;
110
+ }
95
111
  const path = new URL(request.url).pathname;
96
112
  if (path === "/__recover")
97
113
  return this.handleRecover(request);
@@ -99,6 +115,10 @@ export class PramenDOBase extends DurableObject {
99
115
  return this.handleSchema();
100
116
  if (path === "/__admin/data")
101
117
  return this.handleAdminData(request);
118
+ if (path === "/__admin/tasks/drain")
119
+ return this.handleDrain();
120
+ if (path === "/__admin/tasks/list")
121
+ return this.handleTasksList(request);
102
122
  const identity = this.identityOf(request);
103
123
  if (request.headers.get("Upgrade") === "websocket") {
104
124
  const { 0: client, 1: server } = new WebSocketPair();
@@ -112,9 +132,11 @@ export class PramenDOBase extends DurableObject {
112
132
  input = await request.json().catch(() => undefined);
113
133
  }
114
134
  try {
115
- const { result, kind, touched } = await dispatch(this.app.handlers, this.app.schema, this.driver, this.kv, this.filesFor(this.tenant), this.envBag, this.ctxFor(identity), name, input);
135
+ const { result, kind, touched, enqueued } = await dispatch(this.app.handlers, this.app.schema, this.driver, this.kv, this.filesFor(this.tenant), this.envBag, this.ctxFor(identity), name, input);
116
136
  if (kind === "mutation" && touched.length > 0)
117
137
  await this.broadcast(touched);
138
+ if (enqueued > 0)
139
+ await this.armDrain();
118
140
  return Response.json({ ok: true, result });
119
141
  }
120
142
  catch (err) {
@@ -122,6 +144,63 @@ export class PramenDOBase extends DurableObject {
122
144
  return Response.json(body, { status });
123
145
  }
124
146
  }
147
+ /** Arm the drain alarm soon after a mutation enqueued task(s). setAlarm replaces any
148
+ * pending alarm; a near-future time batches a burst of enqueues into one drain. */
149
+ async armDrain() {
150
+ await this.ctx.storage.setAlarm(Date.now() + 50);
151
+ }
152
+ /** A privileged, system-scoped context for running task handlers (outside a request).
153
+ * Task handlers get full db access + env (e.g. ctx.env.EMAIL) + their own ctx.tasks. */
154
+ taskCtx() {
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, suppressTriggers: true }, this.app.schema);
157
+ return { db, kv: this.kv, files: this.filesFor(this.tenant), env: this.envBag, identity, tasks: tasksFacade(this.driver) };
158
+ }
159
+ /** Restore (tenant, partition) on a cold wake (e.g. an alarm with no request) so the
160
+ * task context is scoped correctly. No-op once loaded/persisted this instance. */
161
+ async loadIdentity() {
162
+ if (this.identityLoaded)
163
+ return;
164
+ // _pramen_meta exists: an alarm only fires after a fetch armed it, and that fetch
165
+ // ran the boot migration which creates the table.
166
+ const rows = (await this.driver.exec(`SELECT key, value FROM _pramen_meta WHERE key IN ('_pramen_tenant', '_pramen_partition')`, []));
167
+ for (const r of rows) {
168
+ if (r.key === "_pramen_tenant" && r.value)
169
+ this.tenant = r.value;
170
+ if (r.key === "_pramen_partition" && r.value)
171
+ this.partition = r.value;
172
+ }
173
+ this.identityLoaded = true;
174
+ }
175
+ /** Drain due tasks. Called by the alarm (DO path) and the /__admin/tasks/drain route
176
+ * (manual / cron). Returns the drain stats incl. `nextRunAt` for rescheduling. */
177
+ async drainTasks() {
178
+ await ensureOutbox(this.driver); // idempotent — the table may predate this instance (cold alarm)
179
+ return drainOutbox(this.driver, bindTasks(this.app.tasks, this.taskCtx()), Date.now());
180
+ }
181
+ async alarm() {
182
+ await this.loadIdentity(); // cold wake: restore tenant/partition before building taskCtx
183
+ const { nextRunAt } = await this.drainTasks();
184
+ // Reschedule to the NEXT task's due time (a backed-off retry, or the next batch if
185
+ // the drain hit its limit) so a failed task can't stall waiting for a new enqueue.
186
+ if (nextRunAt != null)
187
+ await this.ctx.storage.setAlarm(Math.max(nextRunAt, Date.now() + 250));
188
+ }
189
+ async handleDrain() {
190
+ await this.loadIdentity();
191
+ const result = await this.drainTasks();
192
+ // Keep the alarm honest even when drained manually: ensure a backed-off retry wakes.
193
+ if (result.nextRunAt != null)
194
+ await this.ctx.storage.setAlarm(Math.max(result.nextRunAt, Date.now() + 250));
195
+ return Response.json({ ok: true, result });
196
+ }
197
+ async handleTasksList(request) {
198
+ await ensureOutbox(this.driver);
199
+ const url = new URL(request.url);
200
+ const status = url.searchParams.get("status") ?? undefined;
201
+ const limit = Number(url.searchParams.get("limit")) || undefined;
202
+ return Response.json({ ok: true, result: await listTasks(this.driver, { status, limit }) });
203
+ }
125
204
  // --- Hibernatable WebSocket handlers ---
126
205
  async webSocketMessage(ws, raw) {
127
206
  let msg;
@@ -190,10 +269,12 @@ export class PramenDOBase extends DurableObject {
190
269
  async onCall(ws, id, name, input) {
191
270
  const state = this.getState(ws);
192
271
  try {
193
- const { result, kind, touched } = await dispatch(this.app.handlers, this.app.schema, this.driver, this.kv, this.filesFor(state.tenant), this.envBag, this.ctxFor(state.identity, state.partition), name, input);
272
+ const { result, kind, touched, enqueued } = await dispatch(this.app.handlers, this.app.schema, this.driver, this.kv, this.filesFor(state.tenant), this.envBag, this.ctxFor(state.identity, state.partition), name, input);
194
273
  this.send(ws, { type: "result", id, result });
195
274
  if (kind === "mutation" && touched.length > 0)
196
275
  await this.broadcast(touched);
276
+ if (enqueued > 0)
277
+ await this.armDrain();
197
278
  }
198
279
  catch (err) {
199
280
  this.send(ws, toWsError(id, err));
@@ -272,20 +353,21 @@ export class PramenDOBase extends DurableObject {
272
353
  return Response.json({ ok: false, error: "point-in-time recovery is unavailable in this environment", code: "unavailable" }, { status: 501 });
273
354
  }
274
355
  }
275
- // Introspection: this tenant's applied schema hash + live table/column shape
276
- // (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.
277
361
  async handleSchema() {
278
- // The applied-schema hash is stored per-partition (migrate keys it
279
- // `schema_hash:<partition>` whenever a partition is scoped, which the DO always
280
- // does). Read this DO's partition's key.
281
362
  const hashKey = `schema_hash:${this.partition}`;
282
- const hashRow = (await this.driver.exec(`SELECT value FROM _pramen_meta WHERE key = ?`, [hashKey]));
283
- const tableRows = (await this.driver.exec(`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND name <> '_pramen_meta'`, []));
284
- const tables = {};
285
- for (const { name } of tableRows) {
286
- tables[name] = (await this.driver.exec(`PRAGMA table_info(${name})`, [])).map((r) => r.name);
287
- }
288
- 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 } });
289
371
  }
290
372
  // Generic admin data ops (admin-gated at the Worker). Runs through a SYSTEM-mode
291
373
  // Db, so ACL is bypassed — admin can browse/edit any row of any table — while the
package/dist/index.d.ts CHANGED
@@ -1,9 +1,10 @@
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";
5
6
  export { query, mutation } from "./sdk/handlers";
6
- export type { Handler, HandlerContext, HandlerKind, HandlerMap, HandlerOpts } from "./sdk/handlers";
7
+ export type { Handler, HandlerContext, HandlerKind, HandlerMap, HandlerOpts, Tasks, TaskHandler, AppTaskMap } from "./sdk/handlers";
7
8
  export { $identity, $input, allow, deny, policy, resolve, role, isAllow, isDeny, isResolver, isIdentityMarker, isInputMarker } from "./sdk/acl";
8
9
  export type { Action, Identity, IdentityMarker, InputMarker, Policy, PolicyRule, PolicyRules, Role, Validator, WhereRule, ConditionalFields, FieldsFn, RelationAclRule, SetValue, ResolverFn, ResolverContext, ResolverDb, } from "./sdk/acl";
9
10
  export type { Cell, FieldsOf, InferInsert, InferRow, InferUpdate, JsonValue, ProjectedRow, RelationsOf, RelationsResult, WhereClause, WhereInput, WhereOps, } from "./sdk/infer";
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,7 +1,7 @@
1
1
  import { type Env } from "./worker";
2
2
  import { pramenDO, type DoEnv } from "./durable-object";
3
- import type { SchemaDef } from "./sdk/schema";
4
- import type { HandlerMap } from "./sdk/handlers";
3
+ import { type SchemaDef } from "./sdk/schema";
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
7
7
  * tenant's DO without the handler importing any deploy-side code (so app.ts stays
@@ -33,10 +33,16 @@ export interface PramenApp {
33
33
  handlers: HandlerMap;
34
34
  acl?: Role[];
35
35
  routes?: PublicRoute[];
36
+ /** Deferred side-effect handlers keyed by `kind` — drained from the outbox after a
37
+ * mutation enqueues via `ctx.tasks.enqueue`. For notification email, webhooks, etc. */
38
+ tasks?: AppTaskMap;
36
39
  }
37
40
  export type { Env, DoEnv };
38
- /** Build the deployable pair for an app. */
41
+ /** Build the deployable pair for an app. `scheduled` is a Cron Trigger entry that
42
+ * drains the D1 outbox (the DO path self-drains via an alarm) — wire it only if you
43
+ * use the D1 store with deferred tasks. */
39
44
  export declare function createPramen(app: PramenApp): {
40
45
  fetch: (request: Request, env: Env) => Promise<Response>;
46
+ scheduled: (event: unknown, env: Env) => Promise<void>;
41
47
  PramenDO: ReturnType<typeof pramenDO>;
42
48
  };
package/dist/pramen.js CHANGED
@@ -13,7 +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
- /** Build the deployable pair for an app. */
16
+ import { validateTriggerTasks } from "./sdk/schema";
17
+ /** Build the deployable pair for an app. `scheduled` is a Cron Trigger entry that
18
+ * drains the D1 outbox (the DO path self-drains via an alarm) — wire it only if you
19
+ * use the D1 store with deferred tasks. */
17
20
  export function createPramen(app) {
18
- return { fetch: makeWorker(app).fetch, PramenDO: pramenDO(app) };
21
+ validateTriggerTasks(app.schema, Object.keys(app.tasks ?? {})); // fail fast on a typo'd trigger task
22
+ const worker = makeWorker(app);
23
+ return { fetch: worker.fetch, scheduled: worker.scheduled, PramenDO: pramenDO(app) };
19
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) {
@@ -1,12 +1,20 @@
1
1
  import { type AclContext } from "./acl";
2
+ import { type TaskMap } from "./outbox";
2
3
  import type { Driver } from "./driver";
3
4
  import type { Kv } from "./kv";
4
5
  import type { Files } from "../sdk/files";
5
6
  import type { SchemaDef } from "../sdk/schema";
6
- import type { HandlerKind, HandlerMap } from "../sdk/handlers";
7
+ import type { AppTaskMap, HandlerContext, HandlerKind, HandlerMap, Tasks } from "../sdk/handlers";
7
8
  export interface DispatchResult {
8
9
  readonly result: unknown;
9
10
  readonly kind: HandlerKind;
10
11
  readonly touched: string[];
12
+ /** Number of tasks the handler enqueued — the DO uses this to arm its drain alarm. */
13
+ readonly enqueued: number;
11
14
  }
15
+ /** The `ctx.tasks` facade over the outbox. `onEnqueue` lets the caller count enqueues
16
+ * so it can wake the drainer. */
17
+ export declare function tasksFacade(driver: Driver, onEnqueue?: () => void): Tasks;
18
+ /** Bind `app.tasks` (which take a ctx) into the ctx-free `TaskMap` the drainer calls. */
19
+ export declare function bindTasks(appTasks: AppTaskMap | undefined, ctx: HandlerContext): TaskMap;
12
20
  export declare function dispatch(handlers: HandlerMap, schema: SchemaDef, driver: Driver, kv: Kv, files: Files, env: Readonly<Record<string, unknown>>, acl: AclContext, name: string, input: unknown): Promise<DispatchResult>;
@@ -10,6 +10,24 @@
10
10
  import { Db } from "./db";
11
11
  import { warmup } from "./acl";
12
12
  import { BadRequest } from "./errors";
13
+ import { enqueueTask } from "./outbox";
14
+ /** The `ctx.tasks` facade over the outbox. `onEnqueue` lets the caller count enqueues
15
+ * so it can wake the drainer. */
16
+ export function tasksFacade(driver, onEnqueue) {
17
+ return {
18
+ enqueue: async (opts) => {
19
+ await enqueueTask(driver, Date.now(), opts);
20
+ onEnqueue?.();
21
+ },
22
+ };
23
+ }
24
+ /** Bind `app.tasks` (which take a ctx) into the ctx-free `TaskMap` the drainer calls. */
25
+ export function bindTasks(appTasks, ctx) {
26
+ const out = {};
27
+ for (const [kind, handler] of Object.entries(appTasks ?? {}))
28
+ out[kind] = (payload, meta) => handler(ctx, payload, meta);
29
+ return out;
30
+ }
13
31
  export async function dispatch(handlers, schema, driver, kv, files, env, acl, name, input) {
14
32
  const handler = handlers[name];
15
33
  if (!handler)
@@ -29,9 +47,11 @@ export async function dispatch(handlers, schema, driver, kv, files, env, acl, na
29
47
  const systemDb = new Db(driver, { acl: acl.acl, identity: acl.identity, system: true, schema, partition: acl.partition }, schema);
30
48
  const resolved = await warmup(acl.acl, acl.identity, systemDb);
31
49
  const db = new Db(driver, { acl: acl.acl, identity: acl.identity, input: parsed, resolved, schema, partition: acl.partition }, schema);
32
- const ctx = { db, kv, files, env, identity: acl.identity };
50
+ let enqueued = 0;
51
+ const ctx = { db, kv, files, env, identity: acl.identity, tasks: tasksFacade(driver, () => enqueued++) };
33
52
  const result = handler.kind === "query"
34
53
  ? await handler.run(ctx, parsed)
35
54
  : await driver.transaction(async () => handler.run(ctx, parsed));
36
- return { result, kind: handler.kind, touched: [...db.touched] };
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 };
37
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("; ")}`);
@@ -0,0 +1,57 @@
1
+ import type { Driver } from "./driver";
2
+ export declare const OUTBOX_TABLE = "_pramen_outbox";
3
+ /** Create the outbox table if absent. Idempotent — run on DO boot (and lazily on the
4
+ * D1 path). Internal table (`_pramen_` prefix), never part of the user schema. */
5
+ export declare function ensureOutbox(driver: Driver): Promise<void>;
6
+ export interface EnqueueOpts {
7
+ kind: string;
8
+ payload?: unknown;
9
+ /** Delay before the task becomes due (ms from now). Default 0 (drain ASAP). */
10
+ delayMs?: number;
11
+ }
12
+ /** Insert one task row. Uses the driver directly, so inside a mutation it joins that
13
+ * mutation's transaction (atomic with the data write). `now` is stamped by the caller. */
14
+ export declare function enqueueTask(driver: Driver, now: number, opts: EnqueueOpts): Promise<void>;
15
+ /** Idempotency metadata handed to a task handler. `id` is stable across retries — a
16
+ * handler can record it and skip a duplicate delivery (at-least-once). */
17
+ export interface TaskMeta {
18
+ id: string;
19
+ /** 1-based attempt number for this delivery. */
20
+ attempts: number;
21
+ }
22
+ /** A task handler: runs the side effect for one `kind`. Throwing schedules a retry. */
23
+ export type TaskHandler = (payload: unknown, meta: TaskMeta) => void | Promise<void>;
24
+ export type TaskMap = Record<string, TaskHandler>;
25
+ export interface DrainResult {
26
+ processed: number;
27
+ succeeded: number;
28
+ failed: number;
29
+ /** Tasks still pending+due after this pass (a caller may loop to clear a backlog). */
30
+ remaining: number;
31
+ /** Epoch-ms of the earliest not-yet-run task (due or backed-off), or null if none —
32
+ * the DO schedules its next alarm here so a backed-off retry can't stall. */
33
+ nextRunAt: number | null;
34
+ }
35
+ /** Run every due task once (claimed batch, up to `limit`). On success mark it done; on
36
+ * throw, bump attempts and back off, or dead-letter ('failed') past MAX_ATTEMPTS.
37
+ *
38
+ * Substrate-agnostic and concurrency-safe: the claim UPDATE (pending→processing) is
39
+ * atomic, so two drainers (the D1/Cron path) get disjoint batches; a crashed drainer's
40
+ * claim is reclaimed after STALE_MS. The DO path is single-writer so claims never
41
+ * contend, but the same code runs there too. */
42
+ export declare function drainOutbox(driver: Driver, tasks: TaskMap, now: number, limit?: number): Promise<DrainResult>;
43
+ export interface TaskRow {
44
+ id: string;
45
+ kind: string;
46
+ status: string;
47
+ attempts: number;
48
+ runAt: number;
49
+ createdAt: number;
50
+ lastError: string | null;
51
+ }
52
+ /** List outbox rows for admin visibility (e.g. inspect dead-lettered tasks). Newest
53
+ * first; optionally filter by `status` (e.g. "failed"). Excludes the payload. */
54
+ export declare function listTasks(driver: Driver, opts?: {
55
+ status?: string;
56
+ limit?: number;
57
+ }): Promise<TaskRow[]>;