@pramen/server 0.0.8 → 0.0.10

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 }, 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));
@@ -280,9 +361,12 @@ export class PramenDOBase extends DurableObject {
280
361
  // does). Read this DO's partition's key.
281
362
  const hashKey = `schema_hash:${this.partition}`;
282
363
  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'`, []));
364
+ const tableRows = (await this.driver.exec(`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'`, []));
284
365
  const tables = {};
285
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;
286
370
  tables[name] = (await this.driver.exec(`PRAGMA table_info(${name})`, [])).map((r) => r.name);
287
371
  }
288
372
  return Response.json({ ok: true, result: { hash: hashRow[0]?.value ?? null, tables } });
package/dist/index.d.ts CHANGED
@@ -3,7 +3,7 @@ export { isValidUuid } from "./sdk/uuid";
3
3
  export type { DefaultValue, FieldType, FieldDef, EntityFields, EntityDef, SchemaDef, RelationDef, RelationDefs, BelongsToDef, HasManyDef, } from "./sdk/schema";
4
4
  export { createApp } from "./sdk/app";
5
5
  export { query, mutation } from "./sdk/handlers";
6
- export type { Handler, HandlerContext, HandlerKind, HandlerMap, HandlerOpts } from "./sdk/handlers";
6
+ export type { Handler, HandlerContext, HandlerKind, HandlerMap, HandlerOpts, Tasks, TaskHandler, AppTaskMap } from "./sdk/handlers";
7
7
  export { $identity, $input, allow, deny, policy, resolve, role, isAllow, isDeny, isResolver, isIdentityMarker, isInputMarker } from "./sdk/acl";
8
8
  export type { Action, Identity, IdentityMarker, InputMarker, Policy, PolicyRule, PolicyRules, Role, Validator, WhereRule, ConditionalFields, FieldsFn, RelationAclRule, SetValue, ResolverFn, ResolverContext, ResolverDb, } from "./sdk/acl";
9
9
  export type { Cell, FieldsOf, InferInsert, InferRow, InferUpdate, JsonValue, ProjectedRow, RelationsOf, RelationsResult, WhereClause, WhereInput, WhereOps, } from "./sdk/infer";
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
3
  import type { SchemaDef } from "./sdk/schema";
4
- import type { HandlerMap } from "./sdk/handlers";
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,10 @@
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
+ /** Build the deployable pair for an app. `scheduled` is a Cron Trigger entry that
17
+ * drains the D1 outbox (the DO path self-drains via an alarm) — wire it only if you
18
+ * use the D1 store with deferred tasks. */
17
19
  export function createPramen(app) {
18
- return { fetch: makeWorker(app).fetch, PramenDO: pramenDO(app) };
20
+ const worker = makeWorker(app);
21
+ return { fetch: worker.fetch, scheduled: worker.scheduled, PramenDO: pramenDO(app) };
19
22
  }
@@ -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,10 @@ 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
+ return { result, kind: handler.kind, touched: [...db.touched], enqueued };
37
56
  }
@@ -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[]>;
@@ -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;
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
  };