@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.
@@ -0,0 +1,128 @@
1
+ // Transactional outbox — the substrate-agnostic core of deferred side-effects
2
+ // ("tasks"), e.g. sending a notification email off the write path.
3
+ //
4
+ // A handler calls `ctx.tasks.enqueue({ kind, payload })`, which INSERTs a row into
5
+ // `_pramen_outbox` through the SAME Driver (and, for a mutation, the SAME transaction)
6
+ // as the data write — so the task and the data commit or roll back together (no
7
+ // dual-write window). A drainer later runs the app's task handler for that `kind`,
8
+ // with retry/backoff and a dead-letter terminal state.
9
+ //
10
+ // Everything here is written against the `Driver`/`Dialect` seam, so it runs
11
+ // identically on the DO's in-process SQLite AND on D1 (the Worker path). What differs
12
+ // is only the WAKE-UP: the DO self-drains via an alarm scheduled at the next due time;
13
+ // the D1/Worker path drains via a Cron Trigger or POST /admin/tasks/drain — same
14
+ // drainOutbox(), both paths.
15
+ //
16
+ // Delivery is at-least-once. The drain CLAIMS a batch atomically (status
17
+ // pending→processing) so concurrent drainers (the D1/Cron path) never process the same
18
+ // row twice; a crashed drainer's claim is reclaimed after STALE_MS. Handlers get the
19
+ // task `id` as an idempotency key so they can dedupe across the rare retry.
20
+ export const OUTBOX_TABLE = "_pramen_outbox";
21
+ const MAX_ATTEMPTS = 5;
22
+ /** A claimed ('processing') row whose claim is older than this is presumed abandoned
23
+ * (the drainer crashed) and is reclaimed. Must exceed the slowest task. */
24
+ const STALE_MS = 60_000;
25
+ /** Keep 'done' rows this long (a dedup window + debugging), then prune. */
26
+ const DONE_RETENTION_MS = 3_600_000;
27
+ /** Exponential backoff (ms) before the next attempt of a failed task. */
28
+ function backoffMs(attempts) {
29
+ return Math.min(2 ** attempts * 1000, 5 * 60_000); // 2s, 4s, 8s, … capped at 5min
30
+ }
31
+ const enc = (driver, params) => params.map((p) => driver.dialect.encode(p));
32
+ /** Create the outbox table if absent. Idempotent — run on DO boot (and lazily on the
33
+ * D1 path). Internal table (`_pramen_` prefix), never part of the user schema. */
34
+ export async function ensureOutbox(driver) {
35
+ const t = driver.dialect.id(OUTBOX_TABLE);
36
+ await driver.exec(`CREATE TABLE IF NOT EXISTS ${t} (` +
37
+ `id TEXT PRIMARY KEY, kind TEXT NOT NULL, payload TEXT NOT NULL, ` +
38
+ `status TEXT NOT NULL DEFAULT 'pending', attempts INTEGER NOT NULL DEFAULT 0, ` +
39
+ `runAt INTEGER NOT NULL, createdAt INTEGER NOT NULL, claimedAt INTEGER, lastError TEXT)`, []);
40
+ // Drain queries filter on (status, runAt); index keeps claim/scan cheap as it grows.
41
+ await driver.exec(`CREATE INDEX IF NOT EXISTS _pramen_outbox_due ON ${OUTBOX_TABLE} (status, runAt)`, []);
42
+ }
43
+ /** Insert one task row. Uses the driver directly, so inside a mutation it joins that
44
+ * mutation's transaction (atomic with the data write). `now` is stamped by the caller. */
45
+ export async function enqueueTask(driver, now, opts) {
46
+ if (!opts || typeof opts.kind !== "string" || opts.kind.length === 0) {
47
+ throw new Error("ctx.tasks.enqueue: `kind` is required");
48
+ }
49
+ const d = driver.dialect;
50
+ const ph = (i) => d.placeholder(i);
51
+ await driver.exec(`INSERT INTO ${d.id(OUTBOX_TABLE)} (id, kind, payload, status, attempts, runAt, createdAt) ` +
52
+ `VALUES (${ph(1)}, ${ph(2)}, ${ph(3)}, ${ph(4)}, ${ph(5)}, ${ph(6)}, ${ph(7)})`, enc(driver, [
53
+ crypto.randomUUID(),
54
+ opts.kind,
55
+ JSON.stringify(opts.payload ?? null),
56
+ "pending",
57
+ 0,
58
+ now + Math.max(0, Math.trunc(opts.delayMs ?? 0)),
59
+ now,
60
+ ]));
61
+ }
62
+ /** Run every due task once (claimed batch, up to `limit`). On success mark it done; on
63
+ * throw, bump attempts and back off, or dead-letter ('failed') past MAX_ATTEMPTS.
64
+ *
65
+ * Substrate-agnostic and concurrency-safe: the claim UPDATE (pending→processing) is
66
+ * atomic, so two drainers (the D1/Cron path) get disjoint batches; a crashed drainer's
67
+ * claim is reclaimed after STALE_MS. The DO path is single-writer so claims never
68
+ * contend, but the same code runs there too. */
69
+ export async function drainOutbox(driver, tasks, now, limit = 50) {
70
+ const d = driver.dialect;
71
+ const ph = (i) => d.placeholder(i);
72
+ // Prune long-since-delivered rows so the table stays bounded.
73
+ await driver.exec(`DELETE FROM ${d.id(OUTBOX_TABLE)} WHERE status = ${ph(1)} AND createdAt < ${ph(2)}`, enc(driver, ["done", now - DONE_RETENTION_MS]));
74
+ // Atomically claim a due batch: fresh 'pending', plus 'processing' rows whose claim
75
+ // is stale (the drainer crashed). RETURNING gives us exactly our claimed rows, so a
76
+ // concurrent drainer (writes serialize) claims a disjoint set.
77
+ const staleBefore = now - STALE_MS;
78
+ const claimed = await driver.exec(`UPDATE ${d.id(OUTBOX_TABLE)} SET status = ${ph(1)}, claimedAt = ${ph(2)} WHERE id IN (` +
79
+ `SELECT id FROM ${d.id(OUTBOX_TABLE)} ` +
80
+ `WHERE (status = ${ph(3)} OR (status = ${ph(4)} AND claimedAt <= ${ph(5)})) AND runAt <= ${ph(6)} ` +
81
+ `ORDER BY createdAt LIMIT ${Math.max(1, Math.trunc(limit))}) ` +
82
+ `RETURNING id, kind, payload, attempts`, enc(driver, ["processing", now, "pending", "processing", staleBefore, now]));
83
+ let succeeded = 0;
84
+ let failed = 0;
85
+ for (const row of claimed) {
86
+ const id = String(row.id);
87
+ const kind = String(row.kind);
88
+ const attempts = Number(row.attempts) + 1;
89
+ const handler = tasks[kind];
90
+ try {
91
+ if (!handler)
92
+ throw new Error(`no task handler registered for kind ${JSON.stringify(kind)}`);
93
+ await handler(JSON.parse(String(row.payload)), { id, attempts });
94
+ await driver.exec(`UPDATE ${d.id(OUTBOX_TABLE)} SET status = ${ph(1)}, attempts = ${ph(2)}, claimedAt = NULL WHERE id = ${ph(3)}`, enc(driver, ["done", attempts, id]));
95
+ succeeded++;
96
+ }
97
+ catch (e) {
98
+ const dead = attempts >= MAX_ATTEMPTS;
99
+ const msg = e instanceof Error ? e.message : String(e);
100
+ await driver.exec(`UPDATE ${d.id(OUTBOX_TABLE)} SET status = ${ph(1)}, attempts = ${ph(2)}, runAt = ${ph(3)}, claimedAt = NULL, lastError = ${ph(4)} WHERE id = ${ph(5)}`, enc(driver, [dead ? "failed" : "pending", attempts, now + backoffMs(attempts), msg.slice(0, 500), id]));
101
+ failed++;
102
+ }
103
+ }
104
+ // remaining = pending AND due now; nextRunAt = the earliest pending runAt (any), so
105
+ // the DO can schedule its alarm exactly when the next task — including a backed-off
106
+ // retry — becomes due.
107
+ const stats = await driver.exec(`SELECT ` +
108
+ `(SELECT COUNT(*) FROM ${d.id(OUTBOX_TABLE)} WHERE status = ${ph(1)} AND runAt <= ${ph(2)}) AS due, ` +
109
+ `(SELECT MIN(runAt) FROM ${d.id(OUTBOX_TABLE)} WHERE status = ${ph(3)}) AS nextRunAt`, enc(driver, ["pending", now, "pending"]));
110
+ const nextRaw = stats[0]?.nextRunAt;
111
+ return {
112
+ processed: claimed.length,
113
+ succeeded,
114
+ failed,
115
+ remaining: Number(stats[0]?.due ?? 0),
116
+ nextRunAt: nextRaw == null ? null : Number(nextRaw),
117
+ };
118
+ }
119
+ /** List outbox rows for admin visibility (e.g. inspect dead-lettered tasks). Newest
120
+ * first; optionally filter by `status` (e.g. "failed"). Excludes the payload. */
121
+ export async function listTasks(driver, opts = {}) {
122
+ const d = driver.dialect;
123
+ const limit = Math.min(Math.max(Math.trunc(opts.limit ?? 100) || 100, 1), 500);
124
+ const where = opts.status ? `WHERE status = ${d.placeholder(1)} ` : "";
125
+ const rows = await driver.exec(`SELECT id, kind, status, attempts, runAt, createdAt, lastError FROM ${d.id(OUTBOX_TABLE)} ` +
126
+ `${where}ORDER BY createdAt DESC LIMIT ${limit}`, opts.status ? enc(driver, [opts.status]) : []);
127
+ return rows;
128
+ }
@@ -20,7 +20,35 @@ export interface HandlerContext<S extends SchemaDef = SchemaDef> {
20
20
  readonly env: Readonly<Record<string, unknown>>;
21
21
  /** Resolved identity for this request (null = anonymous). */
22
22
  readonly identity: Identity | null;
23
+ /** Deferred side-effects (a transactional outbox). `tasks.enqueue` persists a task
24
+ * row in the SAME transaction as a mutation (atomic with the data write); a drainer
25
+ * runs the matching `app.tasks` handler after commit, off the write path, with
26
+ * retry. For notification email, webhooks, etc. — see `app.tasks`. */
27
+ readonly tasks: Tasks;
23
28
  }
29
+ /** The deferred-side-effects facade handed to handlers as `ctx.tasks`. */
30
+ export interface Tasks {
31
+ /** Enqueue a task to run after commit. `kind` selects the `app.tasks` handler;
32
+ * `payload` is JSON-serialized. `delayMs` defers when it becomes due. */
33
+ enqueue(opts: {
34
+ kind: string;
35
+ payload?: unknown;
36
+ delayMs?: number;
37
+ }): Promise<void>;
38
+ }
39
+ /** Idempotency metadata for a task delivery. `id` is stable across retries — record it
40
+ * to dedupe the rare duplicate (delivery is at-least-once). `attempts` is 1-based. */
41
+ export interface TaskMeta {
42
+ id: string;
43
+ attempts: number;
44
+ }
45
+ /** An app task handler — runs a deferred side effect for one `kind` (e.g. send an
46
+ * email via `ctx.env.EMAIL`). Throwing schedules a retry (capped, then dead-lettered).
47
+ * Receives a privileged, system-scoped context plus the task's idempotency `meta`.
48
+ * Register handlers in `app.tasks`. */
49
+ export type TaskHandler = (ctx: HandlerContext, payload: unknown, meta: TaskMeta) => void | Promise<void>;
50
+ /** Map of `kind` → handler. Set as `app.tasks`; drained by the DO alarm / a Cron. */
51
+ export type AppTaskMap = Record<string, TaskHandler>;
24
52
  export type HandlerKind = "query" | "mutation";
25
53
  export interface Handler<I = unknown, O = unknown> {
26
54
  readonly kind: HandlerKind;
@@ -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.d.ts CHANGED
@@ -39,4 +39,5 @@ export declare function callPrivileged(env: Env, opts: {
39
39
  * compiled-ACL + one-time migration) is per-app, held in this closure. */
40
40
  export declare function makeWorker(app: PramenApp): {
41
41
  fetch(request: Request, env: Env): Promise<Response>;
42
+ scheduled(_event: unknown, env: Env): Promise<void>;
42
43
  };
package/dist/worker.js CHANGED
@@ -4,9 +4,11 @@
4
4
  // endpoints (/tenants, /admin/recover, /admin/schema). createPramen() pairs the
5
5
  // returned fetch with the matching DO class; a consumer just re-exports both.
6
6
  import { authorizeTenant, HmacStrategy, isAdmin, JwksStrategy, resolveIdentity } from "./auth";
7
- import { dispatch } from "./runtime/dispatch";
7
+ import { dispatch, tasksFacade, bindTasks } from "./runtime/dispatch";
8
+ import { ensureOutbox, drainOutbox, listTasks } from "./runtime/outbox";
8
9
  import { migrate } from "./runtime/migrate";
9
10
  import { compileAcl } from "./runtime/acl";
11
+ import { Db } from "./runtime/db";
10
12
  import { D1Driver } from "./runtime/driver";
11
13
  import { toResponse } from "./runtime/errors";
12
14
  import { Kv } from "./runtime/kv";
@@ -92,6 +94,7 @@ export function makeWorker(app) {
92
94
  const ensureD1Migrated = (driver, allowDestructive) => {
93
95
  if (!d1Ready) {
94
96
  d1Ready = migrate(driver, app.schema, { allowDestructive })
97
+ .then(() => ensureOutbox(driver)) // the deferred-tasks table also lives in D1
95
98
  .then(() => undefined)
96
99
  .catch((e) => {
97
100
  d1Ready = undefined;
@@ -100,6 +103,31 @@ export function makeWorker(app) {
100
103
  }
101
104
  return d1Ready;
102
105
  };
106
+ // A privileged, system-scoped context for running task handlers on the D1 (Worker)
107
+ // path — mirrors the DO's taskCtx. No live socket, so no DO; drained by a Cron / the
108
+ // /admin/tasks/drain route, never a DO alarm.
109
+ const d1TaskCtx = (driver, env) => {
110
+ const identity = { roles: ["admin"] };
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, suppressTriggers: true }, app.schema);
113
+ return { db, kv: new Kv(env.KV), files, env: env, identity, tasks: tasksFacade(driver) };
114
+ };
115
+ /** Drain the D1 outbox in the Worker (no DO/alarm on this path) — called by the
116
+ * /admin/tasks/drain route with `x-pramen-store: d1`, and by `scheduled()` (Cron). */
117
+ const drainD1 = async (env) => {
118
+ if (!env.DB)
119
+ throw new Error("D1 store is not configured");
120
+ const driver = new D1Driver(env.DB);
121
+ await ensureD1Migrated(driver, env.PRAMEN_ALLOW_DESTRUCTIVE === "true");
122
+ return drainOutbox(driver, bindTasks(app.tasks, d1TaskCtx(driver, env)), Date.now());
123
+ };
124
+ const listD1Tasks = async (env, status, limit) => {
125
+ if (!env.DB)
126
+ throw new Error("D1 store is not configured");
127
+ const driver = new D1Driver(env.DB);
128
+ await ensureD1Migrated(driver, env.PRAMEN_ALLOW_DESTRUCTIVE === "true");
129
+ return listTasks(driver, { status, limit });
130
+ };
103
131
  return {
104
132
  async fetch(request, env) {
105
133
  const url = new URL(request.url);
@@ -196,6 +224,60 @@ export function makeWorker(app) {
196
224
  }));
197
225
  return withCors(res, cors);
198
226
  }
227
+ // --- admin: drain the deferred-task outbox now (the DO also self-drains via an
228
+ // alarm; this is the manual / Cron entry, and the ONLY drain for the D1 path).
229
+ // `x-pramen-store: d1` drains the D1 outbox in the Worker; else the tenant's DO. ---
230
+ if (url.pathname === "/admin/tasks/drain" && request.method === "POST") {
231
+ if (!isAdmin(identity))
232
+ return forbidden("tasks");
233
+ if (request.headers.get("x-pramen-store") === "d1") {
234
+ try {
235
+ return withCors(json({ ok: true, result: await drainD1(env) }), cors);
236
+ }
237
+ catch (err) {
238
+ const { status, body } = toResponse(err);
239
+ return withCors(json(body, status), cors);
240
+ }
241
+ }
242
+ const body = (await request.json().catch(() => ({})));
243
+ const tenant = typeof body.tenant === "string" && body.tenant ? body.tenant : "main";
244
+ const partition = typeof body.partition === "string" && body.partition ? body.partition : DEFAULT_PARTITION;
245
+ const stub = partitionStubFor(env, tenant, partition);
246
+ const res = await stub.fetch(new Request("https://do/__admin/tasks/drain", {
247
+ method: "POST",
248
+ headers: { "content-type": "application/json", "x-pramen-tenant": tenant, "x-pramen-partition": partition },
249
+ }));
250
+ return withCors(res, cors);
251
+ }
252
+ // --- admin: list outbox tasks (inspect dead-letters etc.). ?status=&limit=,
253
+ // ?tenant=&partition= (DO store) or x-pramen-store: d1 (Worker outbox). ---
254
+ if (url.pathname === "/admin/tasks/list") {
255
+ if (!isAdmin(identity))
256
+ return withCors(forbidden("tasks"), cors);
257
+ const status = url.searchParams.get("status") ?? undefined;
258
+ const limit = Number(url.searchParams.get("limit")) || undefined;
259
+ if (request.headers.get("x-pramen-store") === "d1") {
260
+ try {
261
+ return withCors(json({ ok: true, result: await listD1Tasks(env, status, limit) }), cors);
262
+ }
263
+ catch (err) {
264
+ const { status: s, body } = toResponse(err);
265
+ return withCors(json(body, s), cors);
266
+ }
267
+ }
268
+ const tenant = url.searchParams.get("tenant") ?? "main";
269
+ const partition = url.searchParams.get("partition") || DEFAULT_PARTITION;
270
+ const q = new URLSearchParams();
271
+ if (status)
272
+ q.set("status", status);
273
+ if (limit)
274
+ q.set("limit", String(limit));
275
+ const stub = partitionStubFor(env, tenant, partition);
276
+ const res = await stub.fetch(new Request(`https://do/__admin/tasks/list?${q}`, {
277
+ headers: { "x-pramen-tenant": tenant, "x-pramen-partition": partition },
278
+ }));
279
+ return withCors(res, cors);
280
+ }
199
281
  const isRpc = url.pathname.startsWith("/rpc/");
200
282
  const isLive = url.pathname === "/live";
201
283
  if (!isRpc && !(isLive && isWs)) {
@@ -259,5 +341,11 @@ export function makeWorker(app) {
259
341
  const res = await stub.fetch(new Request(req, { headers }));
260
342
  return isWs ? res : withCors(res, cors);
261
343
  },
344
+ // Cron Trigger entry: drains the D1 outbox (the DO path self-drains via an alarm,
345
+ // so it needs no cron). Wire a `[triggers] crons` in wrangler/oblaka to call this.
346
+ async scheduled(_event, env) {
347
+ if (env.DB)
348
+ await drainD1(env);
349
+ },
262
350
  };
263
351
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pramen/server",
3
- "version": "0.0.9",
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": {
@@ -17,7 +17,8 @@
17
17
 
18
18
  import { DurableObject } from "cloudflare:workers";
19
19
  import { migrate } from "./runtime/migrate";
20
- import { dispatch } from "./runtime/dispatch";
20
+ import { dispatch, tasksFacade, bindTasks } from "./runtime/dispatch";
21
+ import { ensureOutbox, drainOutbox, listTasks } from "./runtime/outbox";
21
22
  import { Db } from "./runtime/db";
22
23
  import { digest } from "./runtime/digest";
23
24
  import { compileAcl, type AclContext, type CompiledAcl } from "./runtime/acl";
@@ -28,6 +29,7 @@ import { registryKey } from "./runtime/registry";
28
29
  import { DEFAULT_PARTITION } from "./sdk/schema";
29
30
  import { createFiles, R2Adapter, type Files } from "./runtime/storage";
30
31
  import type { Identity } from "./sdk/acl";
32
+ import type { HandlerContext } from "./sdk/handlers";
31
33
  import type { PramenApp } from "./pramen";
32
34
  import type { ClientMsg, ServerMsg, Subscription } from "./runtime/protocol";
33
35
 
@@ -75,6 +77,11 @@ export class PramenDOBase extends DurableObject<DoEnv> {
75
77
  * constructor — see `ensureMigrated`. Guards against re-running. */
76
78
  private migrated = false;
77
79
  private files?: Files;
80
+ /** Have we persisted (tenant, partition) to DO storage this instance? They're
81
+ * persisted so a COLD alarm wake (no request header) can build a correctly-scoped
82
+ * task context — a DO can't introspect its own idFromName. */
83
+ private identityPersisted = false;
84
+ private identityLoaded = false;
78
85
 
79
86
  constructor(ctx: DurableObjectState, env: DoEnv, app: PramenApp) {
80
87
  super(ctx, env);
@@ -112,6 +119,7 @@ export class PramenDOBase extends DurableObject<DoEnv> {
112
119
  await this.driver.transaction(() =>
113
120
  migrate(this.driver, this.app.schema, { allowDestructive, partition }).then(() => {}),
114
121
  );
122
+ await ensureOutbox(this.driver); // the deferred-tasks table (internal, all partitions)
115
123
  this.migrated = true;
116
124
  });
117
125
  }
@@ -126,11 +134,25 @@ export class PramenDOBase extends DurableObject<DoEnv> {
126
134
 
127
135
  await this.ensureMigrated();
128
136
  await this.ensureRegistered(request);
137
+ // Persist (tenant, partition) once per instance so a cold alarm can rebuild the
138
+ // right task context (it has no request header to learn them from). Stored in the
139
+ // SQL store (_pramen_meta), NOT ctx.storage.put — mixing the KV-style storage API
140
+ // with raw `PRAGMA` trips workerd's DO SQLite authorizer (SQLITE_AUTH).
141
+ if (!this.identityPersisted) {
142
+ await this.driver.exec(
143
+ `INSERT OR REPLACE INTO _pramen_meta (key, value) VALUES (?, ?), (?, ?)`,
144
+ ["_pramen_tenant", this.tenant, "_pramen_partition", this.partition].map((v) => this.driver.dialect.encode(v)),
145
+ );
146
+ this.identityPersisted = true;
147
+ this.identityLoaded = true;
148
+ }
129
149
 
130
150
  const path = new URL(request.url).pathname;
131
151
  if (path === "/__recover") return this.handleRecover(request);
132
152
  if (path === "/__schema") return this.handleSchema();
133
153
  if (path === "/__admin/data") return this.handleAdminData(request);
154
+ if (path === "/__admin/tasks/drain") return this.handleDrain();
155
+ if (path === "/__admin/tasks/list") return this.handleTasksList(request);
134
156
 
135
157
  const identity = this.identityOf(request);
136
158
 
@@ -148,7 +170,7 @@ export class PramenDOBase extends DurableObject<DoEnv> {
148
170
  }
149
171
 
150
172
  try {
151
- const { result, kind, touched } = await dispatch(
173
+ const { result, kind, touched, enqueued } = await dispatch(
152
174
  this.app.handlers,
153
175
  this.app.schema,
154
176
  this.driver,
@@ -160,6 +182,7 @@ export class PramenDOBase extends DurableObject<DoEnv> {
160
182
  input,
161
183
  );
162
184
  if (kind === "mutation" && touched.length > 0) await this.broadcast(touched);
185
+ if (enqueued > 0) await this.armDrain();
163
186
  return Response.json({ ok: true, result });
164
187
  } catch (err) {
165
188
  const { status, body } = toResponse(err);
@@ -167,6 +190,72 @@ export class PramenDOBase extends DurableObject<DoEnv> {
167
190
  }
168
191
  }
169
192
 
193
+ /** Arm the drain alarm soon after a mutation enqueued task(s). setAlarm replaces any
194
+ * pending alarm; a near-future time batches a burst of enqueues into one drain. */
195
+ private async armDrain(): Promise<void> {
196
+ await this.ctx.storage.setAlarm(Date.now() + 50);
197
+ }
198
+
199
+ /** A privileged, system-scoped context for running task handlers (outside a request).
200
+ * Task handlers get full db access + env (e.g. ctx.env.EMAIL) + their own ctx.tasks. */
201
+ private taskCtx(): HandlerContext {
202
+ const identity: Identity = { roles: ["admin"] };
203
+ const db = new Db(
204
+ this.driver,
205
+ { acl: this.acl, identity, system: true, schema: this.app.schema, partition: this.partition, suppressTriggers: true },
206
+ this.app.schema,
207
+ );
208
+ return { db, kv: this.kv, files: this.filesFor(this.tenant), env: this.envBag, identity, tasks: tasksFacade(this.driver) };
209
+ }
210
+
211
+ /** Restore (tenant, partition) on a cold wake (e.g. an alarm with no request) so the
212
+ * task context is scoped correctly. No-op once loaded/persisted this instance. */
213
+ private async loadIdentity(): Promise<void> {
214
+ if (this.identityLoaded) return;
215
+ // _pramen_meta exists: an alarm only fires after a fetch armed it, and that fetch
216
+ // ran the boot migration which creates the table.
217
+ const rows = (await this.driver.exec(
218
+ `SELECT key, value FROM _pramen_meta WHERE key IN ('_pramen_tenant', '_pramen_partition')`,
219
+ [],
220
+ )) as { key: string; value: string }[];
221
+ for (const r of rows) {
222
+ if (r.key === "_pramen_tenant" && r.value) this.tenant = r.value;
223
+ if (r.key === "_pramen_partition" && r.value) this.partition = r.value;
224
+ }
225
+ this.identityLoaded = true;
226
+ }
227
+
228
+ /** Drain due tasks. Called by the alarm (DO path) and the /__admin/tasks/drain route
229
+ * (manual / cron). Returns the drain stats incl. `nextRunAt` for rescheduling. */
230
+ private async drainTasks(): Promise<Awaited<ReturnType<typeof drainOutbox>>> {
231
+ await ensureOutbox(this.driver); // idempotent — the table may predate this instance (cold alarm)
232
+ return drainOutbox(this.driver, bindTasks(this.app.tasks, this.taskCtx()), Date.now());
233
+ }
234
+
235
+ override async alarm(): Promise<void> {
236
+ await this.loadIdentity(); // cold wake: restore tenant/partition before building taskCtx
237
+ const { nextRunAt } = await this.drainTasks();
238
+ // Reschedule to the NEXT task's due time (a backed-off retry, or the next batch if
239
+ // the drain hit its limit) so a failed task can't stall waiting for a new enqueue.
240
+ if (nextRunAt != null) await this.ctx.storage.setAlarm(Math.max(nextRunAt, Date.now() + 250));
241
+ }
242
+
243
+ private async handleDrain(): Promise<Response> {
244
+ await this.loadIdentity();
245
+ const result = await this.drainTasks();
246
+ // Keep the alarm honest even when drained manually: ensure a backed-off retry wakes.
247
+ if (result.nextRunAt != null) await this.ctx.storage.setAlarm(Math.max(result.nextRunAt, Date.now() + 250));
248
+ return Response.json({ ok: true, result });
249
+ }
250
+
251
+ private async handleTasksList(request: Request): Promise<Response> {
252
+ await ensureOutbox(this.driver);
253
+ const url = new URL(request.url);
254
+ const status = url.searchParams.get("status") ?? undefined;
255
+ const limit = Number(url.searchParams.get("limit")) || undefined;
256
+ return Response.json({ ok: true, result: await listTasks(this.driver, { status, limit }) });
257
+ }
258
+
170
259
  // --- Hibernatable WebSocket handlers ---
171
260
 
172
261
  override async webSocketMessage(ws: WebSocket, raw: string | ArrayBuffer): Promise<void> {
@@ -240,9 +329,10 @@ export class PramenDOBase extends DurableObject<DoEnv> {
240
329
  private async onCall(ws: WebSocket, id: string, name: string, input: unknown): Promise<void> {
241
330
  const state = this.getState(ws);
242
331
  try {
243
- 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);
332
+ 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);
244
333
  this.send(ws, { type: "result", id, result });
245
334
  if (kind === "mutation" && touched.length > 0) await this.broadcast(touched);
335
+ if (enqueued > 0) await this.armDrain();
246
336
  } catch (err) {
247
337
  this.send(ws, toWsError(id, err));
248
338
  }
@@ -327,25 +417,21 @@ export class PramenDOBase extends DurableObject<DoEnv> {
327
417
  }
328
418
  }
329
419
 
330
- // Introspection: this tenant's applied schema hash + live table/column shape
331
- // (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.
332
425
  private async handleSchema(): Promise<Response> {
333
- // The applied-schema hash is stored per-partition (migrate keys it
334
- // `schema_hash:<partition>` whenever a partition is scoped, which the DO always
335
- // does). Read this DO's partition's key.
336
426
  const hashKey = `schema_hash:${this.partition}`;
337
- const hashRow = (await this.driver.exec(`SELECT value FROM _pramen_meta WHERE key = ?`, [hashKey])) as {
338
- value: string;
339
- }[];
340
- const tableRows = (await this.driver.exec(
341
- `SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND name <> '_pramen_meta'`,
342
- [],
343
- )) as { name: string }[];
344
- const tables: Record<string, string[]> = {};
345
- for (const { name } of tableRows) {
346
- tables[name] = ((await this.driver.exec(`PRAGMA table_info(${name})`, [])) as { name: string }[]).map((r) => r.name);
347
- }
348
- 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 } });
349
435
  }
350
436
 
351
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,
@@ -26,7 +27,7 @@ export type {
26
27
  // --- app + handlers ---
27
28
  export { createApp } from "./sdk/app";
28
29
  export { query, mutation } from "./sdk/handlers";
29
- export type { Handler, HandlerContext, HandlerKind, HandlerMap, HandlerOpts } from "./sdk/handlers";
30
+ export type { Handler, HandlerContext, HandlerKind, HandlerMap, HandlerOpts, Tasks, TaskHandler, AppTaskMap } from "./sdk/handlers";
30
31
 
31
32
  // --- ACL ---
32
33
  export { $identity, $input, allow, deny, policy, resolve, role, isAllow, isDeny, isResolver, isIdentityMarker, isInputMarker } from "./sdk/acl";