@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.
@@ -33,10 +33,20 @@ function checkIdent(name: string): string {
33
33
  return name;
34
34
  }
35
35
 
36
- /** SQLite (DO SQLite and D1 both speak this). Bare identifiers, `?` placeholders,
37
- * booleans stored as INTEGER 0/1, RETURNING supported. */
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: checkIdent,
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: (name) => `"${checkIdent(name)}"`,
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
- /** D1 SQLite over RPC. Async by nature. D1 has no interactive transactions, so
80
- * `transaction()` runs `fn` without one (a documented limitation: mutations don't
81
- * roll back on throw the way they do on a DO). Use a DO when you need that. */
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
- constructor(private readonly db: D1Database) {}
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.db.prepare(sql).bind(...params) : this.db.prepare(sql);
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
  }
@@ -20,7 +20,7 @@
20
20
 
21
21
  import { addColumnSql, createTableSql, indexStatements, sqlType } from "./ddl";
22
22
  import { digest } from "./digest";
23
- import type { Driver } from "./driver";
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(${ident(table)})`, [])) as { name: string; type: string }[];
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 ${ident(tmp)}`, []);
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(ident(name));
112
- srcExprs.push(live.get(src) === target ? ident(src) : `CAST(${ident(src)} AS ${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 ${ident(tmp)} (${destCols.join(", ")}) SELECT ${srcExprs.join(", ")} FROM ${ident(table)}`, []);
110
+ await driver.exec(`INSERT INTO ${quoteIdent(tmp)} (${destCols.join(", ")}) SELECT ${srcExprs.join(", ")} FROM ${quoteIdent(table)}`, []);
116
111
  }
117
- await driver.exec(`DROP TABLE ${ident(table)}`, []);
118
- await driver.exec(`ALTER TABLE ${ident(tmp)} RENAME TO ${ident(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 ${ident(table)} ADD COLUMN ${addColumnSql(name, field as FieldDef)}`, []);
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 ${ident(name)}`, []);
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
  }
@@ -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/