@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.
- package/dist/durable-object.d.ts +20 -0
- package/dist/durable-object.js +97 -15
- package/dist/index.d.ts +3 -2
- package/dist/index.js +1 -1
- package/dist/pramen.d.ts +9 -3
- package/dist/pramen.js +7 -2
- package/dist/runtime/acl.d.ts +5 -0
- package/dist/runtime/db.d.ts +18 -0
- package/dist/runtime/db.js +73 -7
- package/dist/runtime/dispatch.d.ts +9 -1
- package/dist/runtime/dispatch.js +22 -2
- package/dist/runtime/migrate.js +12 -1
- package/dist/runtime/outbox.d.ts +57 -0
- package/dist/runtime/outbox.js +128 -0
- package/dist/sdk/handlers.d.ts +28 -0
- package/dist/sdk/schema.d.ts +32 -0
- package/dist/sdk/schema.js +48 -0
- package/dist/worker.d.ts +1 -0
- package/dist/worker.js +89 -1
- package/package.json +1 -1
- package/src/durable-object.ts +106 -20
- package/src/index.ts +3 -2
- package/src/pramen.ts +12 -4
- package/src/runtime/acl.ts +5 -0
- package/src/runtime/db.ts +70 -7
- package/src/runtime/dispatch.ts +26 -3
- package/src/runtime/migrate.ts +11 -1
- package/src/runtime/outbox.ts +204 -0
- package/src/sdk/handlers.ts +27 -0
- package/src/sdk/schema.ts +69 -1
- package/src/worker.ts +91 -1
package/src/pramen.ts
CHANGED
|
@@ -14,8 +14,8 @@
|
|
|
14
14
|
|
|
15
15
|
import { makeWorker, type Env } from "./worker";
|
|
16
16
|
import { pramenDO, type DoEnv } from "./durable-object";
|
|
17
|
-
import type
|
|
18
|
-
import type { HandlerMap } from "./sdk/handlers";
|
|
17
|
+
import { validateTriggerTasks, type SchemaDef } from "./sdk/schema";
|
|
18
|
+
import type { AppTaskMap, HandlerMap } from "./sdk/handlers";
|
|
19
19
|
import type { Role } from "./sdk/acl";
|
|
20
20
|
|
|
21
21
|
/** Injected into a public route's handler — forward a privileged mutation into the
|
|
@@ -45,14 +45,22 @@ export interface PramenApp {
|
|
|
45
45
|
handlers: HandlerMap;
|
|
46
46
|
acl?: Role[];
|
|
47
47
|
routes?: PublicRoute[];
|
|
48
|
+
/** Deferred side-effect handlers keyed by `kind` — drained from the outbox after a
|
|
49
|
+
* mutation enqueues via `ctx.tasks.enqueue`. For notification email, webhooks, etc. */
|
|
50
|
+
tasks?: AppTaskMap;
|
|
48
51
|
}
|
|
49
52
|
|
|
50
53
|
export type { Env, DoEnv };
|
|
51
54
|
|
|
52
|
-
/** Build the deployable pair for an app.
|
|
55
|
+
/** Build the deployable pair for an app. `scheduled` is a Cron Trigger entry that
|
|
56
|
+
* drains the D1 outbox (the DO path self-drains via an alarm) — wire it only if you
|
|
57
|
+
* use the D1 store with deferred tasks. */
|
|
53
58
|
export function createPramen(app: PramenApp): {
|
|
54
59
|
fetch: (request: Request, env: Env) => Promise<Response>;
|
|
60
|
+
scheduled: (event: unknown, env: Env) => Promise<void>;
|
|
55
61
|
PramenDO: ReturnType<typeof pramenDO>;
|
|
56
62
|
} {
|
|
57
|
-
|
|
63
|
+
validateTriggerTasks(app.schema, Object.keys(app.tasks ?? {})); // fail fast on a typo'd trigger task
|
|
64
|
+
const worker = makeWorker(app);
|
|
65
|
+
return { fetch: worker.fetch, scheduled: worker.scheduled, PramenDO: pramenDO(app) };
|
|
58
66
|
}
|
package/src/runtime/acl.ts
CHANGED
|
@@ -64,6 +64,11 @@ export interface AclContext {
|
|
|
64
64
|
* lives in a different partition (a partition-DO only owns its own tables). Unset
|
|
65
65
|
* (e.g. the D1/Worker shared-store path) disables the guard — a no-op. */
|
|
66
66
|
readonly partition?: string;
|
|
67
|
+
/** Suppress declarative write-triggers for this Db. Set on the privileged context
|
|
68
|
+
* that DRAINS tasks, so a task handler's writes don't re-fire triggers (which would
|
|
69
|
+
* cascade — a trigger → task → write → trigger loop). Triggers fire on request-path
|
|
70
|
+
* writes, not on task-handler writes. */
|
|
71
|
+
readonly suppressTriggers?: boolean;
|
|
67
72
|
}
|
|
68
73
|
|
|
69
74
|
/** Evaluate every resolver reachable by the identity's roles, once per request.
|
package/src/runtime/db.ts
CHANGED
|
@@ -40,8 +40,9 @@ import {
|
|
|
40
40
|
type SqlExpr,
|
|
41
41
|
} from "./read-engine";
|
|
42
42
|
import { BadRequest } from "./errors";
|
|
43
|
+
import { enqueueTask } from "./outbox";
|
|
43
44
|
import type { Dialect, Driver } from "./driver";
|
|
44
|
-
import { partitionOf, type EntityFields, type FieldDef, type RelationDef, type SchemaDef } from "../sdk/schema";
|
|
45
|
+
import { partitionOf, triggersOf, triggerFires, type EntityFields, type FieldDef, type RelationDef, type SchemaDef, type TriggerOp } from "../sdk/schema";
|
|
45
46
|
import { isValidUuid } from "../sdk/uuid";
|
|
46
47
|
import type { Cell, FieldsOf, InferInsert, InferRow, InferUpdate, RelationsOf, RelationsResult, WhereClause } from "../sdk/infer";
|
|
47
48
|
|
|
@@ -52,6 +53,15 @@ type Selected = Partial<Record<string, true>> | undefined;
|
|
|
52
53
|
|
|
53
54
|
const DEFAULT_PAGE_SIZE = 50;
|
|
54
55
|
|
|
56
|
+
/** Compare two decoded cell values for trigger change-detection. Primitives by ===;
|
|
57
|
+
* json/object cells (already parsed) by structural JSON equality. */
|
|
58
|
+
function cellEqual(a: unknown, b: unknown): boolean {
|
|
59
|
+
if (a === b) return true;
|
|
60
|
+
if (a == null || b == null) return false;
|
|
61
|
+
if (typeof a === "object" || typeof b === "object") return JSON.stringify(a) === JSON.stringify(b);
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
|
|
55
65
|
function normalizeOrder(orderBy: unknown): OrderBy[] | undefined {
|
|
56
66
|
if (!orderBy) return undefined;
|
|
57
67
|
return (Array.isArray(orderBy) ? orderBy : [orderBy]) as OrderBy[];
|
|
@@ -154,6 +164,12 @@ function keysetAfter(order: OrderBy[], values: unknown[]): SqlExpr {
|
|
|
154
164
|
export class Db<S extends SchemaDef = SchemaDef> {
|
|
155
165
|
/** Tables read or written during this Db's lifetime. */
|
|
156
166
|
readonly touched = new Set<string>();
|
|
167
|
+
/** Tasks enqueued by declarative triggers during this Db's lifetime — the DO adds
|
|
168
|
+
* this to ctx.tasks enqueues to decide whether to arm its drain alarm. */
|
|
169
|
+
private taskEnqueueCount = 0;
|
|
170
|
+
get taskEnqueues(): number {
|
|
171
|
+
return this.taskEnqueueCount;
|
|
172
|
+
}
|
|
157
173
|
private readonly dialect: Dialect;
|
|
158
174
|
private readonly acl: AclContext;
|
|
159
175
|
|
|
@@ -403,6 +419,43 @@ export class Db<S extends SchemaDef = SchemaDef> {
|
|
|
403
419
|
return out;
|
|
404
420
|
}
|
|
405
421
|
|
|
422
|
+
/** Fire declarative write-triggers for `op` on `entity`: enqueue a task per matching
|
|
423
|
+
* trigger into the outbox, in THIS mutation's transaction (atomic with the write).
|
|
424
|
+
* `row` is the affected row (decoded — new values for create/update, the removed row
|
|
425
|
+
* for delete); `writtenCols` are the columns the write touched; `before` is the prior
|
|
426
|
+
* row (update only) for value-change detection on a field-filtered trigger.
|
|
427
|
+
*
|
|
428
|
+
* - Hidden columns are STRIPPED from the payload row — `hidden()` ("never readable via
|
|
429
|
+
* the ORM, even under SYSTEM") must hold here too, or a secret like passwordHash
|
|
430
|
+
* would leak to a task handler / webhook.
|
|
431
|
+
* - A field-filtered update trigger fires only when a watched column's value actually
|
|
432
|
+
* CHANGED (not merely was written to the same value).
|
|
433
|
+
* - Suppressed in the task-drain context, so a task's own writes don't re-fire
|
|
434
|
+
* triggers (preventing a trigger→task→write→trigger cascade). */
|
|
435
|
+
private async fireTriggers(
|
|
436
|
+
entity: string,
|
|
437
|
+
op: TriggerOp,
|
|
438
|
+
row: Row,
|
|
439
|
+
writtenCols: string[],
|
|
440
|
+
before?: Row,
|
|
441
|
+
): Promise<void> {
|
|
442
|
+
if (this.acl.suppressTriggers) return;
|
|
443
|
+
const triggers = triggersOf(this.schema, entity);
|
|
444
|
+
if (triggers.length === 0) return;
|
|
445
|
+
const id = row[this.pkOf(entity)];
|
|
446
|
+
let safeRow: Row | undefined;
|
|
447
|
+
for (const t of triggers) {
|
|
448
|
+
if (!triggerFires(t, op, writtenCols)) continue;
|
|
449
|
+
if (op === "update" && Array.isArray(t.on.update) && before) {
|
|
450
|
+
const changed = t.on.update.some((c) => writtenCols.includes(c) && !cellEqual(before[c], row[c]));
|
|
451
|
+
if (!changed) continue; // watched column(s) written, but value unchanged
|
|
452
|
+
}
|
|
453
|
+
if (!safeRow) safeRow = this.stripHidden(entity, row); // never leak hidden columns
|
|
454
|
+
await enqueueTask(this.driver, Date.now(), { kind: t.task, payload: { entity, op, id, row: safeRow } });
|
|
455
|
+
this.taskEnqueueCount++;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
406
459
|
/** Mint a UUID for every `generated()` uuid column the caller omitted, mutating
|
|
407
460
|
* `vals`. Returns the columns it filled — server-minted, so the insert path treats
|
|
408
461
|
* them like forced `set` values (bypassing the writable-field ACL check). */
|
|
@@ -558,7 +611,9 @@ export class Db<S extends SchemaDef = SchemaDef> {
|
|
|
558
611
|
const params = cols.map((c) => this.encodeCell(jsonCols, c, vals[c]));
|
|
559
612
|
const sql = `INSERT INTO ${this.dialect.id(table)} (${colList}) VALUES (${phs})${this.returningClause("*")}`;
|
|
560
613
|
const rows = await this.driver.exec(sql, params);
|
|
561
|
-
|
|
614
|
+
const persisted = this.decodeRow(table, rows[0])!;
|
|
615
|
+
await this.fireTriggers(table, "create", persisted, cols);
|
|
616
|
+
return this.projectWrite(table, persisted, cols) as InferRow<FieldsOf<S[T]>>;
|
|
562
617
|
}
|
|
563
618
|
|
|
564
619
|
/** Project a mutation's RETURNING row so the echo never reveals more than a read
|
|
@@ -597,13 +652,18 @@ export class Db<S extends SchemaDef = SchemaDef> {
|
|
|
597
652
|
const cols = Object.keys(p);
|
|
598
653
|
if (cols.length === 0) return undefined;
|
|
599
654
|
|
|
600
|
-
//
|
|
601
|
-
//
|
|
655
|
+
// Fetch the existing row when we need it: for per-row field permission (evaluated
|
|
656
|
+
// against the FINAL post-merge row) OR for a field-filtered update trigger's
|
|
657
|
+
// value-change detection (so it fires only on an actual change, not a same-value write).
|
|
658
|
+
const needCellEval = scope.fields !== null && (scope.conditional.length > 0 || scope.fieldsFns.length > 0);
|
|
659
|
+
const needBefore = !this.acl.suppressTriggers && triggersOf(this.schema, table).some((t) => Array.isArray(t.on.update));
|
|
602
660
|
let evalRow: Row = p;
|
|
603
|
-
|
|
661
|
+
let before: Row | undefined;
|
|
662
|
+
if (needCellEval || needBefore) {
|
|
604
663
|
const existing = await this.fetchOne(table, id, scope.where);
|
|
605
664
|
if (!existing) return undefined; // out of update scope -> no-op
|
|
606
|
-
|
|
665
|
+
before = existing;
|
|
666
|
+
if (needCellEval) evalRow = { ...existing, ...p };
|
|
607
667
|
}
|
|
608
668
|
this.checkWriteFields(table, "update", scope, cols, evalRow, new Set(Object.keys(set)));
|
|
609
669
|
this.runValidators(validators, p);
|
|
@@ -622,6 +682,7 @@ export class Db<S extends SchemaDef = SchemaDef> {
|
|
|
622
682
|
sql += this.scopeClause(scope.where, params);
|
|
623
683
|
sql += this.returningClause("*");
|
|
624
684
|
const updated = this.decodeRow(table, (await this.driver.exec(sql, params))[0]);
|
|
685
|
+
if (updated) await this.fireTriggers(table, "update", updated, cols, before);
|
|
625
686
|
return (updated ? this.projectWrite(table, updated, cols) : undefined) as InferRow<FieldsOf<S[T]>> | undefined;
|
|
626
687
|
}
|
|
627
688
|
|
|
@@ -635,7 +696,9 @@ export class Db<S extends SchemaDef = SchemaDef> {
|
|
|
635
696
|
let sql = `DELETE FROM ${this.dialect.id(table)} WHERE ${this.dialect.id(this.pkOf(table))} = ${this.dialect.placeholder(1)}`;
|
|
636
697
|
sql += this.scopeClause(scope.where, params);
|
|
637
698
|
sql += this.returningClause("*");
|
|
638
|
-
|
|
699
|
+
const deleted = this.decodeRow(table, (await this.driver.exec(sql, params))[0]);
|
|
700
|
+
if (deleted) await this.fireTriggers(table, "delete", deleted, []);
|
|
701
|
+
return deleted != null;
|
|
639
702
|
}
|
|
640
703
|
|
|
641
704
|
/** Escape hatch — raw SQL, NOT ACL-checked. For system/internal use only. */
|
package/src/runtime/dispatch.ts
CHANGED
|
@@ -11,17 +11,38 @@
|
|
|
11
11
|
import { Db } from "./db";
|
|
12
12
|
import { warmup, type AclContext } from "./acl";
|
|
13
13
|
import { BadRequest } from "./errors";
|
|
14
|
+
import { enqueueTask, type TaskMap } from "./outbox";
|
|
14
15
|
import type { Driver } from "./driver";
|
|
15
16
|
import type { Kv } from "./kv";
|
|
16
17
|
import type { Files } from "../sdk/files";
|
|
17
18
|
import type { ResolverDb } from "../sdk/acl";
|
|
18
19
|
import type { SchemaDef } from "../sdk/schema";
|
|
19
|
-
import type { HandlerContext, HandlerKind, HandlerMap } from "../sdk/handlers";
|
|
20
|
+
import type { AppTaskMap, HandlerContext, HandlerKind, HandlerMap, Tasks } from "../sdk/handlers";
|
|
20
21
|
|
|
21
22
|
export interface DispatchResult {
|
|
22
23
|
readonly result: unknown;
|
|
23
24
|
readonly kind: HandlerKind;
|
|
24
25
|
readonly touched: string[];
|
|
26
|
+
/** Number of tasks the handler enqueued — the DO uses this to arm its drain alarm. */
|
|
27
|
+
readonly enqueued: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The `ctx.tasks` facade over the outbox. `onEnqueue` lets the caller count enqueues
|
|
31
|
+
* so it can wake the drainer. */
|
|
32
|
+
export function tasksFacade(driver: Driver, onEnqueue?: () => void): Tasks {
|
|
33
|
+
return {
|
|
34
|
+
enqueue: async (opts) => {
|
|
35
|
+
await enqueueTask(driver, Date.now(), opts);
|
|
36
|
+
onEnqueue?.();
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Bind `app.tasks` (which take a ctx) into the ctx-free `TaskMap` the drainer calls. */
|
|
42
|
+
export function bindTasks(appTasks: AppTaskMap | undefined, ctx: HandlerContext): TaskMap {
|
|
43
|
+
const out: TaskMap = {};
|
|
44
|
+
for (const [kind, handler] of Object.entries(appTasks ?? {})) out[kind] = (payload, meta) => handler(ctx, payload, meta);
|
|
45
|
+
return out;
|
|
25
46
|
}
|
|
26
47
|
|
|
27
48
|
export async function dispatch(
|
|
@@ -54,12 +75,14 @@ export async function dispatch(
|
|
|
54
75
|
const resolved = await warmup(acl.acl, acl.identity, systemDb as unknown as ResolverDb);
|
|
55
76
|
|
|
56
77
|
const db = new Db(driver, { acl: acl.acl, identity: acl.identity, input: parsed, resolved, schema, partition: acl.partition }, schema);
|
|
57
|
-
|
|
78
|
+
let enqueued = 0;
|
|
79
|
+
const ctx: HandlerContext = { db, kv, files, env, identity: acl.identity, tasks: tasksFacade(driver, () => enqueued++) };
|
|
58
80
|
|
|
59
81
|
const result =
|
|
60
82
|
handler.kind === "query"
|
|
61
83
|
? await handler.run(ctx, parsed)
|
|
62
84
|
: await driver.transaction(async () => handler.run(ctx, parsed));
|
|
63
85
|
|
|
64
|
-
|
|
86
|
+
// Both explicit ctx.tasks.enqueue and declarative trigger enqueues (in db) count.
|
|
87
|
+
return { result, kind: handler.kind, touched: [...db.touched], enqueued: enqueued + db.taskEnqueues };
|
|
65
88
|
}
|
package/src/runtime/migrate.ts
CHANGED
|
@@ -139,9 +139,15 @@ export async function migrate(driver: Driver, schema: SchemaDef, opts: MigrateOp
|
|
|
139
139
|
// `schema_hash` key for backward compatibility (existing stores + the D1 path).
|
|
140
140
|
const subset: SchemaDef = Object.fromEntries(entries);
|
|
141
141
|
const hashKey = opts.partition === undefined ? "schema_hash" : `schema_hash:${opts.partition}`;
|
|
142
|
+
const tablesKey = opts.partition === undefined ? "schema_tables" : `schema_tables:${opts.partition}`;
|
|
143
|
+
const tablesValue = () => JSON.stringify(Object.fromEntries(entries.map(([table, def]) => [table, Object.keys(def.fields)])));
|
|
142
144
|
const current = schemaHash(subset);
|
|
143
|
-
if ((await readMeta(driver, hashKey)) === current)
|
|
145
|
+
if ((await readMeta(driver, hashKey)) === current) {
|
|
146
|
+
// Backfill the table map for stores migrated before this key existed (the schema
|
|
147
|
+
// is unchanged, so it's exactly what's applied).
|
|
148
|
+
if ((await readMeta(driver, tablesKey)) == null) await writeMeta(driver, tablesKey, tablesValue());
|
|
144
149
|
return { changed: false, created: [], added: [], rebuilt: [], droppedTables: [], skipped: [] };
|
|
150
|
+
}
|
|
145
151
|
|
|
146
152
|
const created: string[] = [];
|
|
147
153
|
const added: string[] = [];
|
|
@@ -230,6 +236,10 @@ export async function migrate(driver: Driver, schema: SchemaDef, opts: MigrateOp
|
|
|
230
236
|
// additive work is idempotent, so re-running is safe.
|
|
231
237
|
if (skipped.length === 0) {
|
|
232
238
|
await writeMeta(driver, hashKey, current);
|
|
239
|
+
// Persist the applied table→columns so /admin/schema reports it without a raw
|
|
240
|
+
// PRAGMA at request time — workerd's SQLite authorizer rejects PRAGMA once the
|
|
241
|
+
// DO-storage alarm API has run in the object. Migrate runs on boot, before any.
|
|
242
|
+
await writeMeta(driver, tablesKey, tablesValue());
|
|
233
243
|
} else {
|
|
234
244
|
console.warn(
|
|
235
245
|
`pramen: ${skipped.length} destructive migration(s) skipped (set PRAMEN_ALLOW_DESTRUCTIVE=true to apply): ${skipped.join("; ")}`,
|
|
@@ -0,0 +1,204 @@
|
|
|
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
|
+
|
|
21
|
+
import type { Driver } from "./driver";
|
|
22
|
+
|
|
23
|
+
export const OUTBOX_TABLE = "_pramen_outbox";
|
|
24
|
+
|
|
25
|
+
const MAX_ATTEMPTS = 5;
|
|
26
|
+
/** A claimed ('processing') row whose claim is older than this is presumed abandoned
|
|
27
|
+
* (the drainer crashed) and is reclaimed. Must exceed the slowest task. */
|
|
28
|
+
const STALE_MS = 60_000;
|
|
29
|
+
/** Keep 'done' rows this long (a dedup window + debugging), then prune. */
|
|
30
|
+
const DONE_RETENTION_MS = 3_600_000;
|
|
31
|
+
|
|
32
|
+
/** Exponential backoff (ms) before the next attempt of a failed task. */
|
|
33
|
+
function backoffMs(attempts: number): number {
|
|
34
|
+
return Math.min(2 ** attempts * 1000, 5 * 60_000); // 2s, 4s, 8s, … capped at 5min
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const enc = (driver: Driver, params: unknown[]): unknown[] => params.map((p) => driver.dialect.encode(p));
|
|
38
|
+
|
|
39
|
+
/** Create the outbox table if absent. Idempotent — run on DO boot (and lazily on the
|
|
40
|
+
* D1 path). Internal table (`_pramen_` prefix), never part of the user schema. */
|
|
41
|
+
export async function ensureOutbox(driver: Driver): Promise<void> {
|
|
42
|
+
const t = driver.dialect.id(OUTBOX_TABLE);
|
|
43
|
+
await driver.exec(
|
|
44
|
+
`CREATE TABLE IF NOT EXISTS ${t} (` +
|
|
45
|
+
`id TEXT PRIMARY KEY, kind TEXT NOT NULL, payload TEXT NOT NULL, ` +
|
|
46
|
+
`status TEXT NOT NULL DEFAULT 'pending', attempts INTEGER NOT NULL DEFAULT 0, ` +
|
|
47
|
+
`runAt INTEGER NOT NULL, createdAt INTEGER NOT NULL, claimedAt INTEGER, lastError TEXT)`,
|
|
48
|
+
[],
|
|
49
|
+
);
|
|
50
|
+
// Drain queries filter on (status, runAt); index keeps claim/scan cheap as it grows.
|
|
51
|
+
await driver.exec(`CREATE INDEX IF NOT EXISTS _pramen_outbox_due ON ${OUTBOX_TABLE} (status, runAt)`, []);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface EnqueueOpts {
|
|
55
|
+
kind: string;
|
|
56
|
+
payload?: unknown;
|
|
57
|
+
/** Delay before the task becomes due (ms from now). Default 0 (drain ASAP). */
|
|
58
|
+
delayMs?: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Insert one task row. Uses the driver directly, so inside a mutation it joins that
|
|
62
|
+
* mutation's transaction (atomic with the data write). `now` is stamped by the caller. */
|
|
63
|
+
export async function enqueueTask(driver: Driver, now: number, opts: EnqueueOpts): Promise<void> {
|
|
64
|
+
if (!opts || typeof opts.kind !== "string" || opts.kind.length === 0) {
|
|
65
|
+
throw new Error("ctx.tasks.enqueue: `kind` is required");
|
|
66
|
+
}
|
|
67
|
+
const d = driver.dialect;
|
|
68
|
+
const ph = (i: number) => d.placeholder(i);
|
|
69
|
+
await driver.exec(
|
|
70
|
+
`INSERT INTO ${d.id(OUTBOX_TABLE)} (id, kind, payload, status, attempts, runAt, createdAt) ` +
|
|
71
|
+
`VALUES (${ph(1)}, ${ph(2)}, ${ph(3)}, ${ph(4)}, ${ph(5)}, ${ph(6)}, ${ph(7)})`,
|
|
72
|
+
enc(driver, [
|
|
73
|
+
crypto.randomUUID(),
|
|
74
|
+
opts.kind,
|
|
75
|
+
JSON.stringify(opts.payload ?? null),
|
|
76
|
+
"pending",
|
|
77
|
+
0,
|
|
78
|
+
now + Math.max(0, Math.trunc(opts.delayMs ?? 0)),
|
|
79
|
+
now,
|
|
80
|
+
]),
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Idempotency metadata handed to a task handler. `id` is stable across retries — a
|
|
85
|
+
* handler can record it and skip a duplicate delivery (at-least-once). */
|
|
86
|
+
export interface TaskMeta {
|
|
87
|
+
id: string;
|
|
88
|
+
/** 1-based attempt number for this delivery. */
|
|
89
|
+
attempts: number;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** A task handler: runs the side effect for one `kind`. Throwing schedules a retry. */
|
|
93
|
+
export type TaskHandler = (payload: unknown, meta: TaskMeta) => void | Promise<void>;
|
|
94
|
+
export type TaskMap = Record<string, TaskHandler>;
|
|
95
|
+
|
|
96
|
+
export interface DrainResult {
|
|
97
|
+
processed: number;
|
|
98
|
+
succeeded: number;
|
|
99
|
+
failed: number;
|
|
100
|
+
/** Tasks still pending+due after this pass (a caller may loop to clear a backlog). */
|
|
101
|
+
remaining: number;
|
|
102
|
+
/** Epoch-ms of the earliest not-yet-run task (due or backed-off), or null if none —
|
|
103
|
+
* the DO schedules its next alarm here so a backed-off retry can't stall. */
|
|
104
|
+
nextRunAt: number | null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Run every due task once (claimed batch, up to `limit`). On success mark it done; on
|
|
108
|
+
* throw, bump attempts and back off, or dead-letter ('failed') past MAX_ATTEMPTS.
|
|
109
|
+
*
|
|
110
|
+
* Substrate-agnostic and concurrency-safe: the claim UPDATE (pending→processing) is
|
|
111
|
+
* atomic, so two drainers (the D1/Cron path) get disjoint batches; a crashed drainer's
|
|
112
|
+
* claim is reclaimed after STALE_MS. The DO path is single-writer so claims never
|
|
113
|
+
* contend, but the same code runs there too. */
|
|
114
|
+
export async function drainOutbox(driver: Driver, tasks: TaskMap, now: number, limit = 50): Promise<DrainResult> {
|
|
115
|
+
const d = driver.dialect;
|
|
116
|
+
const ph = (i: number) => d.placeholder(i);
|
|
117
|
+
|
|
118
|
+
// Prune long-since-delivered rows so the table stays bounded.
|
|
119
|
+
await driver.exec(
|
|
120
|
+
`DELETE FROM ${d.id(OUTBOX_TABLE)} WHERE status = ${ph(1)} AND createdAt < ${ph(2)}`,
|
|
121
|
+
enc(driver, ["done", now - DONE_RETENTION_MS]),
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
// Atomically claim a due batch: fresh 'pending', plus 'processing' rows whose claim
|
|
125
|
+
// is stale (the drainer crashed). RETURNING gives us exactly our claimed rows, so a
|
|
126
|
+
// concurrent drainer (writes serialize) claims a disjoint set.
|
|
127
|
+
const staleBefore = now - STALE_MS;
|
|
128
|
+
const claimed = await driver.exec(
|
|
129
|
+
`UPDATE ${d.id(OUTBOX_TABLE)} SET status = ${ph(1)}, claimedAt = ${ph(2)} WHERE id IN (` +
|
|
130
|
+
`SELECT id FROM ${d.id(OUTBOX_TABLE)} ` +
|
|
131
|
+
`WHERE (status = ${ph(3)} OR (status = ${ph(4)} AND claimedAt <= ${ph(5)})) AND runAt <= ${ph(6)} ` +
|
|
132
|
+
`ORDER BY createdAt LIMIT ${Math.max(1, Math.trunc(limit))}) ` +
|
|
133
|
+
`RETURNING id, kind, payload, attempts`,
|
|
134
|
+
enc(driver, ["processing", now, "pending", "processing", staleBefore, now]),
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
let succeeded = 0;
|
|
138
|
+
let failed = 0;
|
|
139
|
+
for (const row of claimed) {
|
|
140
|
+
const id = String(row.id);
|
|
141
|
+
const kind = String(row.kind);
|
|
142
|
+
const attempts = Number(row.attempts) + 1;
|
|
143
|
+
const handler = tasks[kind];
|
|
144
|
+
try {
|
|
145
|
+
if (!handler) throw new Error(`no task handler registered for kind ${JSON.stringify(kind)}`);
|
|
146
|
+
await handler(JSON.parse(String(row.payload)), { id, attempts });
|
|
147
|
+
await driver.exec(
|
|
148
|
+
`UPDATE ${d.id(OUTBOX_TABLE)} SET status = ${ph(1)}, attempts = ${ph(2)}, claimedAt = NULL WHERE id = ${ph(3)}`,
|
|
149
|
+
enc(driver, ["done", attempts, id]),
|
|
150
|
+
);
|
|
151
|
+
succeeded++;
|
|
152
|
+
} catch (e) {
|
|
153
|
+
const dead = attempts >= MAX_ATTEMPTS;
|
|
154
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
155
|
+
await driver.exec(
|
|
156
|
+
`UPDATE ${d.id(OUTBOX_TABLE)} SET status = ${ph(1)}, attempts = ${ph(2)}, runAt = ${ph(3)}, claimedAt = NULL, lastError = ${ph(4)} WHERE id = ${ph(5)}`,
|
|
157
|
+
enc(driver, [dead ? "failed" : "pending", attempts, now + backoffMs(attempts), msg.slice(0, 500), id]),
|
|
158
|
+
);
|
|
159
|
+
failed++;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// remaining = pending AND due now; nextRunAt = the earliest pending runAt (any), so
|
|
164
|
+
// the DO can schedule its alarm exactly when the next task — including a backed-off
|
|
165
|
+
// retry — becomes due.
|
|
166
|
+
const stats = await driver.exec(
|
|
167
|
+
`SELECT ` +
|
|
168
|
+
`(SELECT COUNT(*) FROM ${d.id(OUTBOX_TABLE)} WHERE status = ${ph(1)} AND runAt <= ${ph(2)}) AS due, ` +
|
|
169
|
+
`(SELECT MIN(runAt) FROM ${d.id(OUTBOX_TABLE)} WHERE status = ${ph(3)}) AS nextRunAt`,
|
|
170
|
+
enc(driver, ["pending", now, "pending"]),
|
|
171
|
+
);
|
|
172
|
+
const nextRaw = stats[0]?.nextRunAt;
|
|
173
|
+
return {
|
|
174
|
+
processed: claimed.length,
|
|
175
|
+
succeeded,
|
|
176
|
+
failed,
|
|
177
|
+
remaining: Number(stats[0]?.due ?? 0),
|
|
178
|
+
nextRunAt: nextRaw == null ? null : Number(nextRaw),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export interface TaskRow {
|
|
183
|
+
id: string;
|
|
184
|
+
kind: string;
|
|
185
|
+
status: string;
|
|
186
|
+
attempts: number;
|
|
187
|
+
runAt: number;
|
|
188
|
+
createdAt: number;
|
|
189
|
+
lastError: string | null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** List outbox rows for admin visibility (e.g. inspect dead-lettered tasks). Newest
|
|
193
|
+
* first; optionally filter by `status` (e.g. "failed"). Excludes the payload. */
|
|
194
|
+
export async function listTasks(driver: Driver, opts: { status?: string; limit?: number } = {}): Promise<TaskRow[]> {
|
|
195
|
+
const d = driver.dialect;
|
|
196
|
+
const limit = Math.min(Math.max(Math.trunc(opts.limit ?? 100) || 100, 1), 500);
|
|
197
|
+
const where = opts.status ? `WHERE status = ${d.placeholder(1)} ` : "";
|
|
198
|
+
const rows = await driver.exec(
|
|
199
|
+
`SELECT id, kind, status, attempts, runAt, createdAt, lastError FROM ${d.id(OUTBOX_TABLE)} ` +
|
|
200
|
+
`${where}ORDER BY createdAt DESC LIMIT ${limit}`,
|
|
201
|
+
opts.status ? enc(driver, [opts.status]) : [],
|
|
202
|
+
);
|
|
203
|
+
return rows as unknown as TaskRow[];
|
|
204
|
+
}
|
package/src/sdk/handlers.ts
CHANGED
|
@@ -25,8 +25,35 @@ export interface HandlerContext<S extends SchemaDef = SchemaDef> {
|
|
|
25
25
|
readonly env: Readonly<Record<string, unknown>>;
|
|
26
26
|
/** Resolved identity for this request (null = anonymous). */
|
|
27
27
|
readonly identity: Identity | null;
|
|
28
|
+
/** Deferred side-effects (a transactional outbox). `tasks.enqueue` persists a task
|
|
29
|
+
* row in the SAME transaction as a mutation (atomic with the data write); a drainer
|
|
30
|
+
* runs the matching `app.tasks` handler after commit, off the write path, with
|
|
31
|
+
* retry. For notification email, webhooks, etc. — see `app.tasks`. */
|
|
32
|
+
readonly tasks: Tasks;
|
|
28
33
|
}
|
|
29
34
|
|
|
35
|
+
/** The deferred-side-effects facade handed to handlers as `ctx.tasks`. */
|
|
36
|
+
export interface Tasks {
|
|
37
|
+
/** Enqueue a task to run after commit. `kind` selects the `app.tasks` handler;
|
|
38
|
+
* `payload` is JSON-serialized. `delayMs` defers when it becomes due. */
|
|
39
|
+
enqueue(opts: { kind: string; payload?: unknown; delayMs?: number }): Promise<void>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Idempotency metadata for a task delivery. `id` is stable across retries — record it
|
|
43
|
+
* to dedupe the rare duplicate (delivery is at-least-once). `attempts` is 1-based. */
|
|
44
|
+
export interface TaskMeta {
|
|
45
|
+
id: string;
|
|
46
|
+
attempts: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** An app task handler — runs a deferred side effect for one `kind` (e.g. send an
|
|
50
|
+
* email via `ctx.env.EMAIL`). Throwing schedules a retry (capped, then dead-lettered).
|
|
51
|
+
* Receives a privileged, system-scoped context plus the task's idempotency `meta`.
|
|
52
|
+
* Register handlers in `app.tasks`. */
|
|
53
|
+
export type TaskHandler = (ctx: HandlerContext, payload: unknown, meta: TaskMeta) => void | Promise<void>;
|
|
54
|
+
/** Map of `kind` → handler. Set as `app.tasks`; drained by the DO alarm / a Cron. */
|
|
55
|
+
export type AppTaskMap = Record<string, TaskHandler>;
|
|
56
|
+
|
|
30
57
|
export type HandlerKind = "query" | "mutation";
|
|
31
58
|
|
|
32
59
|
export interface Handler<I = unknown, O = unknown> {
|
package/src/sdk/schema.ts
CHANGED
|
@@ -95,26 +95,82 @@ export type RelationBuilders = typeof relationBuilders;
|
|
|
95
95
|
/** The default partition name for entities that don't declare one. */
|
|
96
96
|
export const DEFAULT_PARTITION = "default";
|
|
97
97
|
|
|
98
|
+
// --- triggers — declarative "on write → enqueue a task" (layered on the outbox) ---
|
|
99
|
+
|
|
100
|
+
export type TriggerOp = "create" | "update" | "delete";
|
|
101
|
+
|
|
102
|
+
/** A declarative trigger on an entity: when a matching write commits, the `Db` write
|
|
103
|
+
* path enqueues a task of `task` (handled by `app.tasks[task]`) IN THE SAME transaction
|
|
104
|
+
* as the write, with payload `{ entity, op, id, row }`. So a side effect (webhook,
|
|
105
|
+
* notification email) fires reliably after the write, off the single-writer path —
|
|
106
|
+
* reusing the whole outbox machinery (retry, idempotency, drain). Only ORM writes
|
|
107
|
+
* (`ctx.db` insert/update/delete) fire triggers; the raw `ctx.db.exec` escape hatch
|
|
108
|
+
* does not. */
|
|
109
|
+
export interface TriggerDef {
|
|
110
|
+
/** The `app.tasks` handler kind that runs the side effect. */
|
|
111
|
+
readonly task: string;
|
|
112
|
+
/** Which ops fire it. For `update`, an array names the columns to watch — fire only
|
|
113
|
+
* when the update writes one of them; `true` fires on any update. */
|
|
114
|
+
readonly on: { create?: boolean; update?: boolean | readonly string[]; delete?: boolean };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Declare an entity trigger (sugar; returns the def). */
|
|
118
|
+
export function trigger(def: TriggerDef): TriggerDef {
|
|
119
|
+
return def;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Does this trigger fire for `op` given the columns the write touched? */
|
|
123
|
+
export function triggerFires(t: TriggerDef, op: TriggerOp, writtenCols: readonly string[]): boolean {
|
|
124
|
+
if (op === "create") return t.on.create === true;
|
|
125
|
+
if (op === "delete") return t.on.delete === true;
|
|
126
|
+
const u = t.on.update;
|
|
127
|
+
if (u === true) return true;
|
|
128
|
+
if (Array.isArray(u)) return u.some((f) => writtenCols.includes(f));
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
|
|
98
132
|
export interface EntityDef<F extends EntityFields = EntityFields, R extends RelationDefs = Record<string, never>> {
|
|
99
133
|
readonly fields: F;
|
|
100
134
|
readonly relations: R;
|
|
101
135
|
/** The partition (Durable Object class) this entity lives in. Always populated;
|
|
102
136
|
* defaults to `"default"` so downstream code never branches on `undefined`. */
|
|
103
137
|
readonly partition: string;
|
|
138
|
+
/** Declarative write-triggers (see TriggerDef). Always an array (possibly empty). */
|
|
139
|
+
readonly triggers: readonly TriggerDef[];
|
|
104
140
|
}
|
|
105
141
|
|
|
106
142
|
export function Entity<F extends EntityFields, R extends RelationDefs = Record<string, never>>(
|
|
107
143
|
build: (t: FieldBuilders) => F,
|
|
108
144
|
relations?: (r: RelationBuilders) => R,
|
|
109
|
-
opts?: { partition?: string },
|
|
145
|
+
opts?: { partition?: string; triggers?: readonly TriggerDef[] },
|
|
110
146
|
): EntityDef<F, R> {
|
|
111
147
|
return {
|
|
112
148
|
fields: build(builders),
|
|
113
149
|
relations: (relations ? relations(relationBuilders) : {}) as R,
|
|
114
150
|
partition: opts?.partition ?? DEFAULT_PARTITION,
|
|
151
|
+
triggers: opts?.triggers ?? [],
|
|
115
152
|
};
|
|
116
153
|
}
|
|
117
154
|
|
|
155
|
+
/** The triggers declared on an entity (empty if none / unknown entity). */
|
|
156
|
+
export function triggersOf(schema: SchemaDef, entity: string): readonly TriggerDef[] {
|
|
157
|
+
return schema[entity]?.triggers ?? [];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Throw if any declarative trigger names a `task` not in `taskNames` — caught at
|
|
161
|
+
* deploy/load by createPramen, so a typo can't silently enqueue a task that never runs
|
|
162
|
+
* (it would retry then dead-letter). */
|
|
163
|
+
export function validateTriggerTasks(schema: SchemaDef, taskNames: Iterable<string>): void {
|
|
164
|
+
const known = new Set(taskNames);
|
|
165
|
+
for (const [entity, def] of Object.entries(schema)) {
|
|
166
|
+
for (const t of def.triggers) {
|
|
167
|
+
if (!known.has(t.task)) {
|
|
168
|
+
throw new Error(`trigger on '${entity}' references task '${t.task}', but app.tasks has no such handler.`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
118
174
|
/** Annotate a field as renamed from a previous column name (migration hint). Wraps
|
|
119
175
|
* a builder result, preserving its literal field type: `title: renamedFrom(t.text(), "name")`. */
|
|
120
176
|
export function renamedFrom<F extends FieldDef>(field: F, from: string): F & { readonly renamedFrom: string } {
|
|
@@ -254,5 +310,17 @@ export function validateSchema(schema: SchemaDef): void {
|
|
|
254
310
|
);
|
|
255
311
|
}
|
|
256
312
|
}
|
|
313
|
+
for (const t of def.triggers) {
|
|
314
|
+
if (!t.task) throw new Error(`trigger on '${entity}' is missing a 'task'.`);
|
|
315
|
+
if (!t.on.create && !t.on.update && !t.on.delete) {
|
|
316
|
+
throw new Error(`trigger '${t.task}' on '${entity}' fires on nothing — set on.create/update/delete.`);
|
|
317
|
+
}
|
|
318
|
+
const watched = Array.isArray(t.on.update) ? t.on.update : [];
|
|
319
|
+
for (const f of watched) {
|
|
320
|
+
if (!(f in def.fields)) {
|
|
321
|
+
throw new Error(`trigger '${t.task}' on '${entity}' watches unknown column '${f}'.`);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
257
325
|
}
|
|
258
326
|
}
|