@pramen/server 0.0.12 → 0.0.14
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.js +2 -0
- package/dist/index.d.ts +6 -2
- package/dist/index.js +4 -1
- package/dist/pramen.d.ts +6 -0
- package/dist/pramen.js +1 -1
- package/dist/runtime/ddl.js +5 -4
- package/dist/runtime/dispatch.d.ts +1 -1
- package/dist/runtime/dispatch.js +9 -1
- package/dist/runtime/driver.d.ts +41 -7
- package/dist/runtime/driver.js +38 -11
- package/dist/runtime/migrate.d.ts +1 -1
- package/dist/runtime/migrate.js +10 -14
- package/dist/runtime/queue-consumer.d.ts +64 -0
- package/dist/runtime/queue-consumer.js +46 -0
- package/dist/runtime/queue.d.ts +72 -0
- package/dist/runtime/queue.js +110 -0
- package/dist/sdk/app.js +2 -2
- package/dist/sdk/handlers.d.ts +22 -0
- package/dist/sdk/handlers.js +14 -2
- package/dist/worker.d.ts +21 -0
- package/dist/worker.js +90 -13
- package/package.json +1 -1
- package/src/durable-object.ts +2 -0
- package/src/index.ts +8 -2
- package/src/pramen.ts +7 -1
- package/src/runtime/ddl.ts +5 -4
- package/src/runtime/dispatch.ts +10 -2
- package/src/runtime/driver.ts +52 -9
- package/src/runtime/migrate.ts +10 -15
- package/src/runtime/queue-consumer.ts +97 -0
- package/src/runtime/queue.ts +155 -0
- package/src/sdk/app.ts +2 -2
- package/src/sdk/handlers.ts +33 -2
- package/src/worker.ts +105 -13
package/src/runtime/driver.ts
CHANGED
|
@@ -33,10 +33,20 @@ function checkIdent(name: string): string {
|
|
|
33
33
|
return name;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
/**
|
|
37
|
-
*
|
|
36
|
+
/** Render an identifier as a standard double-quoted name (`"order"`), guarding its
|
|
37
|
+
* shape first. SQLite (DO SQLite + D1) and Postgres all accept double-quoted
|
|
38
|
+
* identifiers, so a column/table named after a reserved word (`order`, `group`, …)
|
|
39
|
+
* is safe and case is preserved. The single source of truth for both dialects and
|
|
40
|
+
* the DDL generator — keep every emitted identifier going through this. */
|
|
41
|
+
export function quoteIdent(name: string): string {
|
|
42
|
+
return `"${checkIdent(name)}"`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** SQLite (DO SQLite and D1 both speak this). Double-quoted identifiers (so reserved
|
|
46
|
+
* words like `order` work), `?` placeholders, booleans stored as INTEGER 0/1,
|
|
47
|
+
* RETURNING supported. */
|
|
38
48
|
export const sqliteDialect: Dialect = {
|
|
39
|
-
id:
|
|
49
|
+
id: quoteIdent,
|
|
40
50
|
placeholder: () => "?",
|
|
41
51
|
returning: true,
|
|
42
52
|
encode: (v) => (typeof v === "boolean" ? (v ? 1 : 0) : v),
|
|
@@ -46,7 +56,7 @@ export const sqliteDialect: Dialect = {
|
|
|
46
56
|
* `ownerId` doesn't fold to `ownerid`), `$n` placeholders, native booleans,
|
|
47
57
|
* RETURNING supported. */
|
|
48
58
|
export const postgresDialect: Dialect = {
|
|
49
|
-
id:
|
|
59
|
+
id: quoteIdent,
|
|
50
60
|
placeholder: (n) => `$${n}`,
|
|
51
61
|
returning: true,
|
|
52
62
|
encode: (v) => v, // the pg driver handles type encoding
|
|
@@ -76,19 +86,52 @@ export class DoSqliteDriver implements Driver {
|
|
|
76
86
|
}
|
|
77
87
|
}
|
|
78
88
|
|
|
79
|
-
/**
|
|
80
|
-
* `
|
|
81
|
-
*
|
|
89
|
+
/** How a D1Driver's session is anchored (passed to `db.withSession`):
|
|
90
|
+
* - `"first-primary"` — first query hits the primary (current data), the rest
|
|
91
|
+
* read replicas consistent with the session bookmark. Use
|
|
92
|
+
* for a MUTATION (reads must see current data; writes go
|
|
93
|
+
* to primary anyway).
|
|
94
|
+
* - `"first-unconstrained"` — first query may hit the nearest replica. Use for a QUERY.
|
|
95
|
+
* - a bookmark string — anchor at a prior write's bookmark for read-your-writes
|
|
96
|
+
* (the client carries it forward via a header).
|
|
97
|
+
* A bookmark always wins over a constraint when one is supplied. */
|
|
98
|
+
export type D1SessionStart = "first-primary" | "first-unconstrained" | (string & {});
|
|
99
|
+
|
|
100
|
+
/** D1 — SQLite over RPC. Async by nature.
|
|
101
|
+
*
|
|
102
|
+
* Read replicas (Sessions API): every D1Driver opens ONE `db.withSession(start)` and
|
|
103
|
+
* runs all `exec` through it. Writes in a session always land on the primary; the
|
|
104
|
+
* `start` only chooses where the FIRST read may begin. The session maintains a
|
|
105
|
+
* bookmark (`getBookmark()`) so later reads are sequentially consistent with earlier
|
|
106
|
+
* writes — read-your-writes when the bookmark is threaded across requests.
|
|
107
|
+
*
|
|
108
|
+
* ATOMICITY LIMIT (intentional): D1 has NO interactive transactions — a session can't
|
|
109
|
+
* read mid-`batch()`, and pramen mutations interleave reads + writes + RETURNING +
|
|
110
|
+
* trigger-into-outbox inside one `transaction()`. So `transaction(fn) = fn()`: each
|
|
111
|
+
* statement auto-commits on its own, and a multi-statement mutation does NOT roll back
|
|
112
|
+
* on throw the way it does on a DO. Single-statement mutations are atomic; anything
|
|
113
|
+
* multi-statement is not. Use the DO store when you need atomic mutations. */
|
|
82
114
|
export class D1Driver implements Driver {
|
|
83
115
|
readonly dialect = sqliteDialect;
|
|
84
|
-
|
|
116
|
+
private readonly session: D1DatabaseSession;
|
|
117
|
+
constructor(db: D1Database, opts?: { start?: D1SessionStart }) {
|
|
118
|
+
this.session = db.withSession(opts?.start ?? "first-unconstrained");
|
|
119
|
+
}
|
|
85
120
|
|
|
86
121
|
async exec(sql: string, params: unknown[]): Promise<Row[]> {
|
|
87
|
-
const stmt = params.length ? this.
|
|
122
|
+
const stmt = params.length ? this.session.prepare(sql).bind(...params) : this.session.prepare(sql);
|
|
88
123
|
const { results } = await stmt.all<Row>();
|
|
89
124
|
return results ?? [];
|
|
90
125
|
}
|
|
91
126
|
|
|
127
|
+
/** The session's latest bookmark (null before any query). Threaded back to the client
|
|
128
|
+
* via the `x-pramen-d1-bookmark` response header so a subsequent request can anchor a
|
|
129
|
+
* fresh session at it and read its own writes. */
|
|
130
|
+
getBookmark(): string | null {
|
|
131
|
+
return this.session.getBookmark();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// D1 has no interactive/atomic transactions — see the class doc. Run `fn` as-is.
|
|
92
135
|
transaction<T>(fn: () => Promise<T>): Promise<T> {
|
|
93
136
|
return fn();
|
|
94
137
|
}
|
package/src/runtime/migrate.ts
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
|
|
21
21
|
import { addColumnSql, createTableSql, indexStatements, sqlType } from "./ddl";
|
|
22
22
|
import { digest } from "./digest";
|
|
23
|
-
import type
|
|
23
|
+
import { quoteIdent, type Driver } from "./driver";
|
|
24
24
|
import { entitiesInPartition, validateSchema } from "../sdk/schema";
|
|
25
25
|
import type { EntityFields, FieldDef, SchemaDef } from "../sdk/schema";
|
|
26
26
|
|
|
@@ -66,11 +66,6 @@ function isInternalTable(name: string): boolean {
|
|
|
66
66
|
);
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
function ident(name: string): string {
|
|
70
|
-
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) throw new Error(`invalid identifier: ${name}`);
|
|
71
|
-
return name;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
69
|
export function schemaHash(schema: SchemaDef): string {
|
|
75
70
|
const canon: Record<string, unknown> = {};
|
|
76
71
|
for (const [table, def] of Object.entries(schema)) canon[table] = def.fields;
|
|
@@ -80,7 +75,7 @@ export function schemaHash(schema: SchemaDef): string {
|
|
|
80
75
|
/** Live columns of a table -> their declared SQL type (uppercased). Empty if the
|
|
81
76
|
* table doesn't exist. */
|
|
82
77
|
async function tableColumns(driver: Driver, table: string): Promise<Map<string, string>> {
|
|
83
|
-
const rows = (await driver.exec(`PRAGMA table_info(${
|
|
78
|
+
const rows = (await driver.exec(`PRAGMA table_info(${quoteIdent(table)})`, [])) as { name: string; type: string }[];
|
|
84
79
|
return new Map(rows.map((r) => [r.name, (r.type || "").toUpperCase()]));
|
|
85
80
|
}
|
|
86
81
|
|
|
@@ -98,7 +93,7 @@ async function writeMeta(driver: Driver, key: string, value: string): Promise<vo
|
|
|
98
93
|
* type change; brand-new columns left NULL), drop the old table, rename the temp. */
|
|
99
94
|
async function rebuildTable(driver: Driver, table: string, def: { fields: EntityFields }, live: Map<string, string>): Promise<void> {
|
|
100
95
|
const tmp = `__pramen_rebuild_${table}`;
|
|
101
|
-
await driver.exec(`DROP TABLE IF EXISTS ${
|
|
96
|
+
await driver.exec(`DROP TABLE IF EXISTS ${quoteIdent(tmp)}`, []);
|
|
102
97
|
await driver.exec(createTableSql(tmp, def), []);
|
|
103
98
|
|
|
104
99
|
const destCols: string[] = [];
|
|
@@ -108,14 +103,14 @@ async function rebuildTable(driver: Driver, table: string, def: { fields: Entity
|
|
|
108
103
|
const src = f.renamedFrom && live.has(f.renamedFrom) ? f.renamedFrom : live.has(name) ? name : undefined;
|
|
109
104
|
if (!src) continue; // brand-new column with no source -> leave NULL
|
|
110
105
|
const target = sqlType(f);
|
|
111
|
-
destCols.push(
|
|
112
|
-
srcExprs.push(live.get(src) === target ?
|
|
106
|
+
destCols.push(quoteIdent(name));
|
|
107
|
+
srcExprs.push(live.get(src) === target ? quoteIdent(src) : `CAST(${quoteIdent(src)} AS ${target})`);
|
|
113
108
|
}
|
|
114
109
|
if (destCols.length > 0) {
|
|
115
|
-
await driver.exec(`INSERT INTO ${
|
|
110
|
+
await driver.exec(`INSERT INTO ${quoteIdent(tmp)} (${destCols.join(", ")}) SELECT ${srcExprs.join(", ")} FROM ${quoteIdent(table)}`, []);
|
|
116
111
|
}
|
|
117
|
-
await driver.exec(`DROP TABLE ${
|
|
118
|
-
await driver.exec(`ALTER TABLE ${
|
|
112
|
+
await driver.exec(`DROP TABLE ${quoteIdent(table)}`, []);
|
|
113
|
+
await driver.exec(`ALTER TABLE ${quoteIdent(tmp)} RENAME TO ${quoteIdent(table)}`, []);
|
|
119
114
|
}
|
|
120
115
|
|
|
121
116
|
export async function migrate(driver: Driver, schema: SchemaDef, opts: MigrateOptions = {}): Promise<MigrationReport> {
|
|
@@ -174,7 +169,7 @@ export async function migrate(driver: Driver, schema: SchemaDef, opts: MigrateOp
|
|
|
174
169
|
needsAdditiveRebuild = true;
|
|
175
170
|
continue;
|
|
176
171
|
}
|
|
177
|
-
await driver.exec(`ALTER TABLE ${
|
|
172
|
+
await driver.exec(`ALTER TABLE ${quoteIdent(table)} ADD COLUMN ${addColumnSql(name, field as FieldDef)}`, []);
|
|
178
173
|
added.push(`${table}.${name}`);
|
|
179
174
|
}
|
|
180
175
|
|
|
@@ -224,7 +219,7 @@ export async function migrate(driver: Driver, schema: SchemaDef, opts: MigrateOp
|
|
|
224
219
|
for (const { name } of liveTables) {
|
|
225
220
|
if (isInternalTable(name) || inScope.has(name) || otherPartitionTables.has(name)) continue;
|
|
226
221
|
if (allowDestructive) {
|
|
227
|
-
await driver.exec(`DROP TABLE ${
|
|
222
|
+
await driver.exec(`DROP TABLE ${quoteIdent(name)}`, []);
|
|
228
223
|
droppedTables.push(name);
|
|
229
224
|
} else {
|
|
230
225
|
skipped.push(`drop table ${name}`);
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Queue consumer dispatch — the receiving half of ctx.queue. A pramen Worker is the
|
|
2
|
+
// consumer for its declared queues (oblaka `new Queue({ binding: "both", ... })`), so
|
|
3
|
+
// `createPramen(app).queue` is the Cloudflare `queue(batch, env, ctx)` entry. It routes
|
|
4
|
+
// each batch to the matching `app.queues[name]` handler and ACKs/RETRIES per message.
|
|
5
|
+
//
|
|
6
|
+
// A consumer runs in the WORKER, not a Durable Object — a queue message isn't bound to a
|
|
7
|
+
// tenant, so there's no direct `ctx.db`. To touch tenant data, carry the tenant in the
|
|
8
|
+
// message body and `ctx.callPrivileged({ name, input, tenant })` into its DO (exactly
|
|
9
|
+
// like a public route). The consumer still gets `ctx.mail` / `ctx.queue` / `ctx.kv` /
|
|
10
|
+
// `ctx.env`, so the canonical "consume a job → send a notification" path is one call.
|
|
11
|
+
|
|
12
|
+
import type { Mail } from "./mail";
|
|
13
|
+
import type { Queue } from "./queue";
|
|
14
|
+
import type { Kv } from "./kv";
|
|
15
|
+
|
|
16
|
+
/** One received message (the Cloudflare Queues `Message` shape). */
|
|
17
|
+
export interface QueueMessage<Body = unknown> {
|
|
18
|
+
readonly id: string;
|
|
19
|
+
readonly timestamp: Date;
|
|
20
|
+
readonly body: Body;
|
|
21
|
+
/** 1-based delivery attempt — grows on each retry (use it to give up / dead-letter). */
|
|
22
|
+
readonly attempts: number;
|
|
23
|
+
/** Mark this message handled (won't be redelivered). The framework calls this for you
|
|
24
|
+
* when the handler resolves; call it yourself only for fine-grained control. */
|
|
25
|
+
ack(): void;
|
|
26
|
+
/** Schedule this message for redelivery (the framework calls it when the handler throws). */
|
|
27
|
+
retry(options?: { delaySeconds?: number }): void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A batch delivered to the consumer (the Cloudflare Queues `MessageBatch` shape). */
|
|
31
|
+
export interface QueueBatch<Body = unknown> {
|
|
32
|
+
/** The queue this batch came from (the oblaka `Queue` name; env-prefixed remotely). */
|
|
33
|
+
readonly queue: string;
|
|
34
|
+
readonly messages: readonly QueueMessage<Body>[];
|
|
35
|
+
ackAll(): void;
|
|
36
|
+
retryAll(options?: { delaySeconds?: number }): void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The context handed to a queue consumer handler. Worker-level (no `ctx.db`): reach
|
|
40
|
+
* tenant data via `ctx.callPrivileged`. */
|
|
41
|
+
export interface QueueContext {
|
|
42
|
+
/** The Worker environment (bindings + vars + secrets). */
|
|
43
|
+
readonly env: Readonly<Record<string, unknown>>;
|
|
44
|
+
/** Project KV (cross-tenant). */
|
|
45
|
+
readonly kv: Kv;
|
|
46
|
+
/** Send email (the notification path). */
|
|
47
|
+
readonly mail: Mail;
|
|
48
|
+
/** Enqueue onto a (possibly different) queue — fan-out / chaining. */
|
|
49
|
+
readonly queue: Queue;
|
|
50
|
+
/** Apply a privileged mutation into a tenant's DO (the consumer has no direct db).
|
|
51
|
+
* The message body should carry the `tenant`. */
|
|
52
|
+
callPrivileged(opts: { name: string; input?: unknown; tenant?: string; roles?: string[]; partition?: string }): Promise<Response>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A queue consumer handler — runs once per message. Resolving ACKs the message;
|
|
56
|
+
* throwing RETRIES it (subject to the queue's max_retries → dead-letter queue). */
|
|
57
|
+
export type QueueHandler<Body = unknown> = (ctx: QueueContext, message: QueueMessage<Body>) => void | Promise<void>;
|
|
58
|
+
|
|
59
|
+
/** Map of queue name → consumer handler. Set as `app.queues`; dispatched by
|
|
60
|
+
* `createPramen(app).queue`. */
|
|
61
|
+
export type AppQueueMap = Record<string, QueueHandler>;
|
|
62
|
+
|
|
63
|
+
/** Resolve the handler for a batch's queue. Queue names are env-prefixed in remote
|
|
64
|
+
* environments (`production-pramen-jobs`) but bare locally (`pramen-jobs`), so match
|
|
65
|
+
* leniently: exact, then suffix (`…-<key>`), then — if there's exactly one handler —
|
|
66
|
+
* fall through to it (the common single-queue app). Returns null if nothing matches. */
|
|
67
|
+
export function routeQueue(queues: AppQueueMap, queueName: string): QueueHandler | null {
|
|
68
|
+
const keys = Object.keys(queues);
|
|
69
|
+
if (queues[queueName]) return queues[queueName];
|
|
70
|
+
const suffix = keys.find((k) => queueName.endsWith(`-${k}`) || k.endsWith(`-${queueName}`));
|
|
71
|
+
if (suffix) return queues[suffix];
|
|
72
|
+
if (keys.length === 1) return queues[keys[0]];
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Dispatch one batch: route to the handler, then run it per message, ACKing on success
|
|
77
|
+
* and RETRYing on throw (per message, so one poison message doesn't re-deliver the rest).
|
|
78
|
+
* An unrouted batch is retried whole (never silently acked) and logged. */
|
|
79
|
+
export async function dispatchQueueBatch(queues: AppQueueMap, ctx: QueueContext, batch: QueueBatch): Promise<void> {
|
|
80
|
+
const handler = routeQueue(queues, batch.queue);
|
|
81
|
+
if (!handler) {
|
|
82
|
+
console.error(`pramen: no app.queues handler for queue '${batch.queue}' — retrying batch (declare it in app.queues)`);
|
|
83
|
+
batch.retryAll();
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
await Promise.all(
|
|
87
|
+
batch.messages.map(async (message) => {
|
|
88
|
+
try {
|
|
89
|
+
await handler(ctx, message);
|
|
90
|
+
message.ack();
|
|
91
|
+
} catch (err) {
|
|
92
|
+
console.error(`pramen: queue '${batch.queue}' message ${message.id} failed (attempt ${message.attempts}) — retrying`, err);
|
|
93
|
+
message.retry();
|
|
94
|
+
}
|
|
95
|
+
}),
|
|
96
|
+
);
|
|
97
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// ctx.queue — Cloudflare Queues producer facade, the same shape as ctx.mail / ctx.files:
|
|
2
|
+
// an adapter seam (CloudflareQueueAdapter / MemoryQueueAdapter) behind a thin `Queue`
|
|
3
|
+
// facade, built from the environment. Handlers enqueue onto a native Cloudflare Queue
|
|
4
|
+
// without touching the producer binding directly:
|
|
5
|
+
//
|
|
6
|
+
// await ctx.queue.send("jobs", { kind: "resize", id });
|
|
7
|
+
// await ctx.queue.sendBatch("jobs", [{ body: a }, { body: b, delaySeconds: 30 }]);
|
|
8
|
+
//
|
|
9
|
+
// This is distinct from `ctx.tasks` (the transactional outbox). `ctx.tasks.enqueue`
|
|
10
|
+
// is atomic with the mutation's DB write (commit-or-rollback together) and drained
|
|
11
|
+
// in-process. `ctx.queue` is a native Cloudflare Queue: NOT transactional with the
|
|
12
|
+
// write (the message is sent regardless of whether the mutation later rolls back),
|
|
13
|
+
// but higher-throughput, with platform-native batching/retry/DLQ and a consumer that
|
|
14
|
+
// can run in a *different* Worker. Reach for ctx.tasks when the side-effect must commit
|
|
15
|
+
// with the data; reach for ctx.queue for decoupled, high-volume fan-out.
|
|
16
|
+
//
|
|
17
|
+
// On Cloudflare the transport is the Queues producer binding (declared in oblaka.ts as
|
|
18
|
+
// `new Queue({ binding: "both", ... })`). Off-platform / unconfigured, sending to a queue
|
|
19
|
+
// that isn't bound FAILS CLOSED (throws) rather than silently dropping the message —
|
|
20
|
+
// mirroring how ctx.mail fails closed without a transport.
|
|
21
|
+
|
|
22
|
+
/** Cloudflare Queues content type for a sent message. Omitted ⇒ the platform default
|
|
23
|
+
* (v8 structured clone). Use "json" for cross-runtime / external consumers. */
|
|
24
|
+
export type QueueContentType = "text" | "bytes" | "json" | "v8";
|
|
25
|
+
|
|
26
|
+
/** Per-message send options (mirrors the Cloudflare Queues producer API). */
|
|
27
|
+
export interface QueueSendOptions {
|
|
28
|
+
/** Defer delivery by N seconds (the consumer won't see the message until then). */
|
|
29
|
+
delaySeconds?: number;
|
|
30
|
+
/** How the body is serialized on the wire. Omitted ⇒ platform default (v8). */
|
|
31
|
+
contentType?: QueueContentType;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** One message in a `sendBatch` — a body plus its own per-message options. */
|
|
35
|
+
export interface QueueSendRequest {
|
|
36
|
+
body: unknown;
|
|
37
|
+
delaySeconds?: number;
|
|
38
|
+
contentType?: QueueContentType;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Batch-level send options. */
|
|
42
|
+
export interface QueueBatchOptions {
|
|
43
|
+
/** Default delay applied to every message in the batch (per-message overrides win). */
|
|
44
|
+
delaySeconds?: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The Cloudflare Queues producer binding shape (what `env.<QUEUE>` exposes). A binding
|
|
48
|
+
* is recognized as a queue producer iff it has BOTH `send` and `sendBatch` (which
|
|
49
|
+
* distinguishes it from the email `send`-only binding, KV, R2, D1, …). */
|
|
50
|
+
export interface QueueProducerBinding {
|
|
51
|
+
send(body: unknown, options?: QueueSendOptions): Promise<void>;
|
|
52
|
+
sendBatch(messages: Iterable<QueueSendRequest>, options?: QueueBatchOptions): Promise<void>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The transport seam — one per backend (Cloudflare Queues, an in-memory capture, …). */
|
|
56
|
+
export interface QueueAdapter {
|
|
57
|
+
send(queue: string, body: unknown, options?: QueueSendOptions): Promise<void>;
|
|
58
|
+
sendBatch(queue: string, messages: readonly QueueSendRequest[], options?: QueueBatchOptions): Promise<void>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The `ctx.queue` facade: validates, then delegates to the adapter for a named queue. */
|
|
62
|
+
export class Queue {
|
|
63
|
+
constructor(private readonly adapter: QueueAdapter) {}
|
|
64
|
+
|
|
65
|
+
/** Enqueue a single message onto `queue`. `body` is serialized by the platform. */
|
|
66
|
+
async send(queue: string, body: unknown, options?: QueueSendOptions): Promise<void> {
|
|
67
|
+
assertQueueName(queue);
|
|
68
|
+
if (body === undefined) throw new Error("ctx.queue.send: `body` is required");
|
|
69
|
+
await this.adapter.send(queue, body, options);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Enqueue many messages onto `queue` in one call (cheaper than N sends). */
|
|
73
|
+
async sendBatch(queue: string, messages: readonly QueueSendRequest[], options?: QueueBatchOptions): Promise<void> {
|
|
74
|
+
assertQueueName(queue);
|
|
75
|
+
if (!Array.isArray(messages) || messages.length === 0) {
|
|
76
|
+
throw new Error("ctx.queue.sendBatch: `messages` must be a non-empty array");
|
|
77
|
+
}
|
|
78
|
+
for (const m of messages) {
|
|
79
|
+
if (!m || m.body === undefined) throw new Error("ctx.queue.sendBatch: every message needs a `body`");
|
|
80
|
+
}
|
|
81
|
+
await this.adapter.sendBatch(queue, messages, options);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function assertQueueName(queue: string): void {
|
|
86
|
+
if (typeof queue !== "string" || queue.length === 0) {
|
|
87
|
+
throw new Error("ctx.queue: a queue name is required (the oblaka `Queue` name)");
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Cloudflare Queues transport. Constructed with the producer bindings discovered from
|
|
92
|
+
* the environment, keyed by binding name. Sending to a name with no bound queue throws
|
|
93
|
+
* a clear error (fail-closed) — a missing binding is a config error, not a silent drop. */
|
|
94
|
+
export class CloudflareQueueAdapter implements QueueAdapter {
|
|
95
|
+
constructor(private readonly bindings: Readonly<Record<string, QueueProducerBinding>>) {}
|
|
96
|
+
|
|
97
|
+
private bindingFor(queue: string): QueueProducerBinding {
|
|
98
|
+
const b = this.bindings[queue];
|
|
99
|
+
if (!b) {
|
|
100
|
+
const known = Object.keys(this.bindings);
|
|
101
|
+
const avail = known.length ? known.join(", ") : "none";
|
|
102
|
+
throw new Error(
|
|
103
|
+
`ctx.queue: no queue binding '${queue}' — declare it in oblaka.ts ` +
|
|
104
|
+
`(new Queue({ name: '${queue}', binding: 'both' })). Bound queues: ${avail}.`,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
return b;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async send(queue: string, body: unknown, options?: QueueSendOptions): Promise<void> {
|
|
111
|
+
await this.bindingFor(queue).send(body, options);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async sendBatch(queue: string, messages: readonly QueueSendRequest[], options?: QueueBatchOptions): Promise<void> {
|
|
115
|
+
await this.bindingFor(queue).sendBatch(messages, options);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** In-memory transport: captures sent messages instead of delivering them. For unit
|
|
120
|
+
* tests (assert on `.sent`) and pure off-platform use. */
|
|
121
|
+
export class MemoryQueueAdapter implements QueueAdapter {
|
|
122
|
+
readonly sent: Array<{ queue: string; body: unknown; options?: QueueSendOptions | QueueBatchOptions }> = [];
|
|
123
|
+
async send(queue: string, body: unknown, options?: QueueSendOptions): Promise<void> {
|
|
124
|
+
this.sent.push({ queue, body, options });
|
|
125
|
+
}
|
|
126
|
+
async sendBatch(queue: string, messages: readonly QueueSendRequest[], options?: QueueBatchOptions): Promise<void> {
|
|
127
|
+
for (const m of messages) this.sent.push({ queue, body: m.body, options: { ...options, ...m } });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Discover the Cloudflare Queues producer bindings in an environment: any value that
|
|
132
|
+
* exposes BOTH `send` and `sendBatch` functions (which excludes the email `send`-only
|
|
133
|
+
* binding, KV, R2, D1, the DO namespace, …). Returns name → binding. */
|
|
134
|
+
export function discoverQueueBindings(env: Readonly<Record<string, unknown>>): Record<string, QueueProducerBinding> {
|
|
135
|
+
const out: Record<string, QueueProducerBinding> = {};
|
|
136
|
+
for (const [name, value] of Object.entries(env)) {
|
|
137
|
+
if (
|
|
138
|
+
value &&
|
|
139
|
+
typeof value === "object" &&
|
|
140
|
+
typeof (value as { send?: unknown }).send === "function" &&
|
|
141
|
+
typeof (value as { sendBatch?: unknown }).sendBatch === "function"
|
|
142
|
+
) {
|
|
143
|
+
out[name] = value as QueueProducerBinding;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Build `ctx.queue` from the environment: a Cloudflare adapter over the discovered
|
|
150
|
+
* producer bindings. Sending to an undeclared queue fails closed (the adapter throws).
|
|
151
|
+
* There is no silent capture fallback — declare the `Queue` binding and it exists in
|
|
152
|
+
* dev (lopata) and miniflare too. */
|
|
153
|
+
export function createQueue(env: Readonly<Record<string, unknown>>): Queue {
|
|
154
|
+
return new Queue(new CloudflareQueueAdapter(discoverQueueBindings(env)));
|
|
155
|
+
}
|
package/src/sdk/app.ts
CHANGED
|
@@ -14,12 +14,12 @@ export function createApp<S extends SchemaDef>(schema: S) {
|
|
|
14
14
|
const query = <I = unknown, O = unknown>(
|
|
15
15
|
run: (ctx: Ctx, input: I) => O | Promise<O>,
|
|
16
16
|
opts?: HandlerOpts<I>,
|
|
17
|
-
): Handler<I, O> => ({ kind: "query", run: run as Handler<I, O>["run"], input: opts?.input, partition: opts?.partition });
|
|
17
|
+
): Handler<I, O> => ({ kind: "query", run: run as Handler<I, O>["run"], input: opts?.input, partition: opts?.partition, auth: opts?.auth });
|
|
18
18
|
|
|
19
19
|
const mutation = <I = unknown, O = unknown>(
|
|
20
20
|
run: (ctx: Ctx, input: I) => O | Promise<O>,
|
|
21
21
|
opts?: HandlerOpts<I>,
|
|
22
|
-
): Handler<I, O> => ({ kind: "mutation", run: run as Handler<I, O>["run"], input: opts?.input, partition: opts?.partition });
|
|
22
|
+
): Handler<I, O> => ({ kind: "mutation", run: run as Handler<I, O>["run"], input: opts?.input, partition: opts?.partition, auth: opts?.auth });
|
|
23
23
|
|
|
24
24
|
return { schema, query, mutation };
|
|
25
25
|
}
|
package/src/sdk/handlers.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import type { Db } from "../runtime/db";
|
|
6
6
|
import type { Kv } from "../runtime/kv";
|
|
7
7
|
import type { Mail } from "../runtime/mail";
|
|
8
|
+
import type { Queue } from "../runtime/queue";
|
|
8
9
|
import type { Identity } from "./acl";
|
|
9
10
|
import type { Files } from "./files";
|
|
10
11
|
import type { SchemaDef } from "./schema";
|
|
@@ -36,6 +37,12 @@ export interface HandlerContext<S extends SchemaDef = SchemaDef> {
|
|
|
36
37
|
* runs the matching `app.tasks` handler after commit, off the write path, with
|
|
37
38
|
* retry. For notification email, webhooks, etc. — see `app.tasks`. */
|
|
38
39
|
readonly tasks: Tasks;
|
|
40
|
+
/** Enqueue onto a native Cloudflare Queue: `ctx.queue.send("jobs", body)`. Unlike
|
|
41
|
+
* `ctx.tasks` (a transactional outbox, atomic with the mutation, drained in-process),
|
|
42
|
+
* a queue send is NOT transactional with the write but is higher-throughput, with
|
|
43
|
+
* platform-native batching/retry/DLQ and a consumer that may live in another Worker.
|
|
44
|
+
* Declare queues in oblaka.ts; consume them via `app.queues`. */
|
|
45
|
+
readonly queue: Queue;
|
|
39
46
|
}
|
|
40
47
|
|
|
41
48
|
/** The deferred-side-effects facade handed to handlers as `ctx.tasks`. */
|
|
@@ -62,6 +69,26 @@ export type AppTaskMap = Record<string, TaskHandler>;
|
|
|
62
69
|
|
|
63
70
|
export type HandlerKind = "query" | "mutation";
|
|
64
71
|
|
|
72
|
+
/** Authorization required to CALL a handler, enforced BEFORE its body runs. This is
|
|
73
|
+
* distinct from the row-level ACL (which gates `ctx.db`): use it to gate handlers that
|
|
74
|
+
* touch `ctx.kv`/`ctx.env`/`ctx.mail`/`ctx.tasks` directly — those bypass the ACL, so an
|
|
75
|
+
* un-gated such handler is callable by anyone (incl. anonymous) on an open tenant. Forms:
|
|
76
|
+
* - `"authenticated"` — any non-anonymous caller (identity != null)
|
|
77
|
+
* - `string[]` — the caller must hold one of these roles
|
|
78
|
+
* - `(identity) => boolean` — a custom predicate
|
|
79
|
+
* Absent ⇒ open (the prior behavior; a `ctx.db` handler is still ACL-gated). */
|
|
80
|
+
export type HandlerAuth = "authenticated" | readonly string[] | ((identity: Identity | null) => boolean);
|
|
81
|
+
|
|
82
|
+
/** Evaluate a handler's `auth` requirement against the caller's identity. */
|
|
83
|
+
export function authorizeHandler(auth: HandlerAuth, identity: Identity | null): boolean {
|
|
84
|
+
if (auth === "authenticated") return identity != null;
|
|
85
|
+
if (typeof auth === "function") return auth(identity);
|
|
86
|
+
if (!identity) return false; // a role list can never be satisfied by an anonymous caller
|
|
87
|
+
const roles = Array.isArray(identity.roles) ? identity.roles : [];
|
|
88
|
+
const held = identity.role ? [identity.role, ...roles] : roles;
|
|
89
|
+
return auth.some((r) => held.includes(r));
|
|
90
|
+
}
|
|
91
|
+
|
|
65
92
|
export interface Handler<I = unknown, O = unknown> {
|
|
66
93
|
readonly kind: HandlerKind;
|
|
67
94
|
// Stored handlers are schema-agnostic; createApp() binds the typed surface.
|
|
@@ -74,12 +101,16 @@ export interface Handler<I = unknown, O = unknown> {
|
|
|
74
101
|
* routes the request to the matching partition-DO before dispatch. Absent ⇒ the
|
|
75
102
|
* default partition (routed to the bare tenant key). */
|
|
76
103
|
readonly partition?: string;
|
|
104
|
+
/** Optional call-authorization, enforced before the handler runs (see HandlerAuth). */
|
|
105
|
+
readonly auth?: HandlerAuth;
|
|
77
106
|
}
|
|
78
107
|
|
|
79
108
|
export interface HandlerOpts<I> {
|
|
80
109
|
input?: (raw: unknown) => I;
|
|
81
110
|
/** DO partition this handler runs in. Absent ⇒ the default partition. */
|
|
82
111
|
partition?: string;
|
|
112
|
+
/** Authorization to CALL this handler (see HandlerAuth) — gate non-`ctx.db` handlers. */
|
|
113
|
+
auth?: HandlerAuth;
|
|
83
114
|
}
|
|
84
115
|
|
|
85
116
|
// Standalone (schema-agnostic) handler factories. Prefer createApp(schema) for a
|
|
@@ -88,14 +119,14 @@ export function query<I = unknown, O = unknown>(
|
|
|
88
119
|
run: (ctx: HandlerContext, input: I) => O | Promise<O>,
|
|
89
120
|
opts?: HandlerOpts<I>,
|
|
90
121
|
): Handler<I, O> {
|
|
91
|
-
return { kind: "query", run, input: opts?.input, partition: opts?.partition };
|
|
122
|
+
return { kind: "query", run, input: opts?.input, partition: opts?.partition, auth: opts?.auth };
|
|
92
123
|
}
|
|
93
124
|
|
|
94
125
|
export function mutation<I = unknown, O = unknown>(
|
|
95
126
|
run: (ctx: HandlerContext, input: I) => O | Promise<O>,
|
|
96
127
|
opts?: HandlerOpts<I>,
|
|
97
128
|
): Handler<I, O> {
|
|
98
|
-
return { kind: "mutation", run, input: opts?.input, partition: opts?.partition };
|
|
129
|
+
return { kind: "mutation", run, input: opts?.input, partition: opts?.partition, auth: opts?.auth };
|
|
99
130
|
}
|
|
100
131
|
|
|
101
132
|
// Registry of handlers keyed by RPC name. Uses `any` for the per-handler input/
|