@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
|
@@ -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
|
+
}
|
package/dist/sdk/handlers.d.ts
CHANGED
|
@@ -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/sdk/schema.d.ts
CHANGED
|
@@ -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 & {
|
package/dist/sdk/schema.js
CHANGED
|
@@ -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.
|
|
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": {
|
package/src/durable-object.ts
CHANGED
|
@@ -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 +
|
|
331
|
-
//
|
|
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
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
)) as
|
|
344
|
-
|
|
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";
|