@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.
@@ -18,6 +18,7 @@ import { DurableObject } from "cloudflare:workers";
18
18
  import { migrate } from "./runtime/migrate";
19
19
  import { dispatch, tasksFacade, bindTasks } from "./runtime/dispatch";
20
20
  import { createMail } from "./runtime/mail";
21
+ import { createQueue } from "./runtime/queue";
21
22
  import { ensureOutbox, drainOutbox, listTasks } from "./runtime/outbox";
22
23
  import { Db } from "./runtime/db";
23
24
  import { digest } from "./runtime/digest";
@@ -163,6 +164,7 @@ export class PramenDOBase extends DurableObject {
163
164
  identity,
164
165
  tasks: tasksFacade(this.driver),
165
166
  mail: createMail(this.envBag, this.kv),
167
+ queue: createQueue(this.envBag),
166
168
  };
167
169
  }
168
170
  /** Restore (tenant, partition) on a cold wake (e.g. an alarm with no request) so the
package/dist/index.d.ts CHANGED
@@ -3,8 +3,8 @@ export type { TriggerDef, TriggerOp } from "./sdk/schema";
3
3
  export { isValidUuid } from "./sdk/uuid";
4
4
  export type { DefaultValue, FieldType, FieldDef, EntityFields, EntityDef, SchemaDef, RelationDef, RelationDefs, BelongsToDef, HasManyDef, } from "./sdk/schema";
5
5
  export { createApp } from "./sdk/app";
6
- export { query, mutation } from "./sdk/handlers";
7
- export type { Handler, HandlerContext, HandlerKind, HandlerMap, HandlerOpts, Tasks, TaskHandler, AppTaskMap } from "./sdk/handlers";
6
+ export { query, mutation, authorizeHandler } from "./sdk/handlers";
7
+ export type { Handler, HandlerContext, HandlerKind, HandlerMap, HandlerOpts, HandlerAuth, Tasks, TaskHandler, AppTaskMap } from "./sdk/handlers";
8
8
  export { $identity, $input, allow, deny, policy, resolve, role, isAllow, isDeny, isResolver, isIdentityMarker, isInputMarker } from "./sdk/acl";
9
9
  export type { Action, Identity, IdentityMarker, InputMarker, Policy, PolicyRule, PolicyRules, Role, Validator, WhereRule, ConditionalFields, FieldsFn, RelationAclRule, SetValue, ResolverFn, ResolverContext, ResolverDb, } from "./sdk/acl";
10
10
  export type { Cell, FieldsOf, InferInsert, InferRow, InferUpdate, JsonValue, ProjectedRow, RelationsOf, RelationsResult, WhereClause, WhereInput, WhereOps, } from "./sdk/infer";
@@ -13,6 +13,10 @@ export { R2Adapter, MemoryAdapter, createFiles, handleFileRequest } from "./runt
13
13
  export type { StorageAdapter, PutResult, GetResult } from "./runtime/storage";
14
14
  export { Mail, CloudflareEmailAdapter, KvMailAdapter, MemoryMailAdapter, UnconfiguredMailAdapter, createMail } from "./runtime/mail";
15
15
  export type { MailMessage, MailAddress, MailAdapter, SendEmailBinding } from "./runtime/mail";
16
+ export { Queue, CloudflareQueueAdapter, MemoryQueueAdapter, createQueue, discoverQueueBindings } from "./runtime/queue";
17
+ export type { QueueAdapter, QueueProducerBinding, QueueSendOptions, QueueSendRequest, QueueBatchOptions, QueueContentType } from "./runtime/queue";
18
+ export { routeQueue, dispatchQueueBatch } from "./runtime/queue-consumer";
19
+ export type { QueueContext, QueueHandler, QueueMessage, QueueBatch, AppQueueMap } from "./runtime/queue-consumer";
16
20
  export { PramenError, BadRequest, Unauthorized, Forbidden } from "./runtime/errors";
17
21
  export { sqliteDialect, postgresDialect, DoSqliteDriver, D1Driver } from "./runtime/driver";
18
22
  export type { Driver, Dialect, Row } from "./runtime/driver";
package/dist/index.js CHANGED
@@ -11,12 +11,15 @@ export { Entity, defineSchema, renamedFrom, notNull, unique, indexed, hidden, de
11
11
  export { isValidUuid } from "./sdk/uuid";
12
12
  // --- app + handlers ---
13
13
  export { createApp } from "./sdk/app";
14
- export { query, mutation } from "./sdk/handlers";
14
+ export { query, mutation, authorizeHandler } from "./sdk/handlers";
15
15
  // --- ACL ---
16
16
  export { $identity, $input, allow, deny, policy, resolve, role, isAllow, isDeny, isResolver, isIdentityMarker, isInputMarker } from "./sdk/acl";
17
17
  export { R2Adapter, MemoryAdapter, createFiles, handleFileRequest } from "./runtime/storage";
18
18
  // --- mail (ctx.mail) ---
19
19
  export { Mail, CloudflareEmailAdapter, KvMailAdapter, MemoryMailAdapter, UnconfiguredMailAdapter, createMail } from "./runtime/mail";
20
+ // --- queue (ctx.queue — Cloudflare Queues) ---
21
+ export { Queue, CloudflareQueueAdapter, MemoryQueueAdapter, createQueue, discoverQueueBindings } from "./runtime/queue";
22
+ export { routeQueue, dispatchQueueBatch } from "./runtime/queue-consumer";
20
23
  // --- errors ---
21
24
  export { PramenError, BadRequest, Unauthorized, Forbidden } from "./runtime/errors";
22
25
  // --- substrate seam (advanced: bring your own SQL backend) ---
package/dist/pramen.d.ts CHANGED
@@ -2,6 +2,7 @@ import { type Env } from "./worker";
2
2
  import { pramenDO, type DoEnv } from "./durable-object";
3
3
  import { type SchemaDef } from "./sdk/schema";
4
4
  import type { AppTaskMap, HandlerMap } from "./sdk/handlers";
5
+ import type { AppQueueMap, QueueBatch } from "./runtime/queue-consumer";
5
6
  import type { Role } from "./sdk/acl";
6
7
  /** Injected into a public route's handler — forward a privileged mutation into the
7
8
  * tenant's DO without the handler importing any deploy-side code (so app.ts stays
@@ -36,6 +37,10 @@ export interface PramenApp {
36
37
  /** Deferred side-effect handlers keyed by `kind` — drained from the outbox after a
37
38
  * mutation enqueues via `ctx.tasks.enqueue`. For notification email, webhooks, etc. */
38
39
  tasks?: AppTaskMap;
40
+ /** Cloudflare Queues consumers keyed by queue name — process messages produced via
41
+ * `ctx.queue.send(...)`. Dispatched by `createPramen(app).queue` (a consumer is
42
+ * Worker-level: no `ctx.db`, reach a tenant via `ctx.callPrivileged`). */
43
+ queues?: AppQueueMap;
39
44
  }
40
45
  export type { Env, DoEnv };
41
46
  /** Build the deployable pair for an app. `scheduled` is a Cron Trigger entry that
@@ -44,5 +49,6 @@ export type { Env, DoEnv };
44
49
  export declare function createPramen(app: PramenApp): {
45
50
  fetch: (request: Request, env: Env) => Promise<Response>;
46
51
  scheduled: (event: unknown, env: Env) => Promise<void>;
52
+ queue: (batch: QueueBatch, env: Env) => Promise<void>;
47
53
  PramenDO: ReturnType<typeof pramenDO>;
48
54
  };
package/dist/pramen.js CHANGED
@@ -20,5 +20,5 @@ import { validateTriggerTasks } from "./sdk/schema";
20
20
  export function createPramen(app) {
21
21
  validateTriggerTasks(app.schema, Object.keys(app.tasks ?? {})); // fail fast on a typo'd trigger task
22
22
  const worker = makeWorker(app);
23
- return { fetch: worker.fetch, scheduled: worker.scheduled, PramenDO: pramenDO(app) };
23
+ return { fetch: worker.fetch, scheduled: worker.scheduled, queue: worker.queue, PramenDO: pramenDO(app) };
24
24
  }
@@ -1,6 +1,7 @@
1
1
  // DDL generation — CREATE TABLE for a new entity and the additive ALTER fragment
2
2
  // for a new column. Runs in TS inside the isolate; see runtime/migrate.ts for how
3
3
  // these are applied.
4
+ import { quoteIdent } from "./driver";
4
5
  // SQLite has no boolean type; store as INTEGER 0/1. json + fileRef + uuid are
5
6
  // stored as TEXT. Exported for the migrator, which compares declared column types
6
7
  // (and CASTs on a type change).
@@ -32,7 +33,7 @@ function defaultSql(f) {
32
33
  return f.default !== undefined ? ` DEFAULT ${defaultLiteral(f.default)}` : "";
33
34
  }
34
35
  function columnSql(name, f) {
35
- let s = `${name} ${sqlType(f)}`;
36
+ let s = `${quoteIdent(name)} ${sqlType(f)}`;
36
37
  if (f.primaryKey)
37
38
  s += " PRIMARY KEY";
38
39
  if (f.autoIncrement)
@@ -44,13 +45,13 @@ function columnSql(name, f) {
44
45
  }
45
46
  export function createTableSql(table, def) {
46
47
  const cols = Object.entries(def.fields).map(([n, f]) => columnSql(n, f));
47
- return `CREATE TABLE IF NOT EXISTS ${table} (${cols.join(", ")})`;
48
+ return `CREATE TABLE IF NOT EXISTS ${quoteIdent(table)} (${cols.join(", ")})`;
48
49
  }
49
50
  /** Column definition for ALTER TABLE ADD COLUMN. No PRIMARY KEY / AUTOINCREMENT.
50
51
  * NOT NULL is only emitted alongside a DEFAULT (SQLite can't add a bare NOT NULL to
51
52
  * a populated table); a DEFAULT alone backfills existing rows. */
52
53
  export function addColumnSql(name, f) {
53
- let s = `${name} ${sqlType(f)}`;
54
+ let s = `${quoteIdent(name)} ${sqlType(f)}`;
54
55
  if (f.notNull && f.default !== undefined)
55
56
  s += " NOT NULL";
56
57
  s += defaultSql(f);
@@ -68,7 +69,7 @@ export function indexStatements(table, def) {
68
69
  if (!f.unique && !f.index)
69
70
  continue;
70
71
  const kind = f.unique ? "UNIQUE INDEX" : "INDEX";
71
- out.push(`CREATE ${kind} IF NOT EXISTS ${indexName(table, col)} ON ${table} (${col})`);
72
+ out.push(`CREATE ${kind} IF NOT EXISTS ${quoteIdent(indexName(table, col))} ON ${quoteIdent(table)} (${quoteIdent(col)})`);
72
73
  }
73
74
  return out;
74
75
  }
@@ -4,7 +4,7 @@ import type { Driver } from "./driver";
4
4
  import type { Kv } from "./kv";
5
5
  import type { Files } from "../sdk/files";
6
6
  import type { SchemaDef } from "../sdk/schema";
7
- import type { AppTaskMap, HandlerContext, HandlerKind, HandlerMap, Tasks } from "../sdk/handlers";
7
+ import { type AppTaskMap, type HandlerContext, type HandlerKind, type HandlerMap, type Tasks } from "../sdk/handlers";
8
8
  export interface DispatchResult {
9
9
  readonly result: unknown;
10
10
  readonly kind: HandlerKind;
@@ -9,9 +9,11 @@
9
9
  // layer can match a mutation's writes against each subscription's reads.
10
10
  import { Db } from "./db";
11
11
  import { warmup } from "./acl";
12
- import { BadRequest } from "./errors";
12
+ import { BadRequest, Forbidden } from "./errors";
13
13
  import { enqueueTask } from "./outbox";
14
14
  import { createMail } from "./mail";
15
+ import { createQueue } from "./queue";
16
+ import { authorizeHandler } from "../sdk/handlers";
15
17
  /** The `ctx.tasks` facade over the outbox. `onEnqueue` lets the caller count enqueues
16
18
  * so it can wake the drainer. */
17
19
  export function tasksFacade(driver, onEnqueue) {
@@ -33,6 +35,11 @@ export async function dispatch(handlers, schema, driver, kv, files, env, acl, na
33
35
  const handler = handlers[name];
34
36
  if (!handler)
35
37
  throw new BadRequest(`unknown handler: ${name}`);
38
+ // Per-handler authorization, enforced before any work (input parse / handler body) —
39
+ // gates handlers that bypass the row-ACL by touching ctx.kv/ctx.env/ctx.mail directly.
40
+ if (handler.auth && !authorizeHandler(handler.auth, acl.identity)) {
41
+ throw new Forbidden(`not authorized to call '${name}'`);
42
+ }
36
43
  // Validate/parse the request input at the boundary, if the handler declares it.
37
44
  let parsed = input;
38
45
  if (handler.input) {
@@ -57,6 +64,7 @@ export async function dispatch(handlers, schema, driver, kv, files, env, acl, na
57
64
  identity: acl.identity,
58
65
  tasks: tasksFacade(driver, () => enqueued++),
59
66
  mail: createMail(env, kv),
67
+ queue: createQueue(env),
60
68
  };
61
69
  const result = handler.kind === "query"
62
70
  ? await handler.run(ctx, parsed)
@@ -9,8 +9,15 @@ export interface Dialect {
9
9
  /** Coerce a JS value for binding (e.g. boolean → 0/1 on SQLite). */
10
10
  encode(v: unknown): unknown;
11
11
  }
12
- /** SQLite (DO SQLite and D1 both speak this). Bare identifiers, `?` placeholders,
13
- * booleans stored as INTEGER 0/1, RETURNING supported. */
12
+ /** Render an identifier as a standard double-quoted name (`"order"`), guarding its
13
+ * shape first. SQLite (DO SQLite + D1) and Postgres all accept double-quoted
14
+ * identifiers, so a column/table named after a reserved word (`order`, `group`, …)
15
+ * is safe and case is preserved. The single source of truth for both dialects and
16
+ * the DDL generator — keep every emitted identifier going through this. */
17
+ export declare function quoteIdent(name: string): string;
18
+ /** SQLite (DO SQLite and D1 both speak this). Double-quoted identifiers (so reserved
19
+ * words like `order` work), `?` placeholders, booleans stored as INTEGER 0/1,
20
+ * RETURNING supported. */
14
21
  export declare const sqliteDialect: Dialect;
15
22
  /** Postgres (e.g. over Hyperdrive). Double-quoted identifiers preserve case (so
16
23
  * `ownerId` doesn't fold to `ownerid`), `$n` placeholders, native booleans,
@@ -33,13 +40,40 @@ export declare class DoSqliteDriver implements Driver {
33
40
  exec(sql: string, params: unknown[]): Promise<Row[]>;
34
41
  transaction<T>(fn: () => Promise<T>): Promise<T>;
35
42
  }
36
- /** D1 SQLite over RPC. Async by nature. D1 has no interactive transactions, so
37
- * `transaction()` runs `fn` without one (a documented limitation: mutations don't
38
- * roll back on throw the way they do on a DO). Use a DO when you need that. */
43
+ /** How a D1Driver's session is anchored (passed to `db.withSession`):
44
+ * - `"first-primary"` first query hits the primary (current data), the rest
45
+ * read replicas consistent with the session bookmark. Use
46
+ * for a MUTATION (reads must see current data; writes go
47
+ * to primary anyway).
48
+ * - `"first-unconstrained"` — first query may hit the nearest replica. Use for a QUERY.
49
+ * - a bookmark string — anchor at a prior write's bookmark for read-your-writes
50
+ * (the client carries it forward via a header).
51
+ * A bookmark always wins over a constraint when one is supplied. */
52
+ export type D1SessionStart = "first-primary" | "first-unconstrained" | (string & {});
53
+ /** D1 — SQLite over RPC. Async by nature.
54
+ *
55
+ * Read replicas (Sessions API): every D1Driver opens ONE `db.withSession(start)` and
56
+ * runs all `exec` through it. Writes in a session always land on the primary; the
57
+ * `start` only chooses where the FIRST read may begin. The session maintains a
58
+ * bookmark (`getBookmark()`) so later reads are sequentially consistent with earlier
59
+ * writes — read-your-writes when the bookmark is threaded across requests.
60
+ *
61
+ * ATOMICITY LIMIT (intentional): D1 has NO interactive transactions — a session can't
62
+ * read mid-`batch()`, and pramen mutations interleave reads + writes + RETURNING +
63
+ * trigger-into-outbox inside one `transaction()`. So `transaction(fn) = fn()`: each
64
+ * statement auto-commits on its own, and a multi-statement mutation does NOT roll back
65
+ * on throw the way it does on a DO. Single-statement mutations are atomic; anything
66
+ * multi-statement is not. Use the DO store when you need atomic mutations. */
39
67
  export declare class D1Driver implements Driver {
40
- private readonly db;
41
68
  readonly dialect: Dialect;
42
- constructor(db: D1Database);
69
+ private readonly session;
70
+ constructor(db: D1Database, opts?: {
71
+ start?: D1SessionStart;
72
+ });
43
73
  exec(sql: string, params: unknown[]): Promise<Row[]>;
74
+ /** The session's latest bookmark (null before any query). Threaded back to the client
75
+ * via the `x-pramen-d1-bookmark` response header so a subsequent request can anchor a
76
+ * fresh session at it and read its own writes. */
77
+ getBookmark(): string | null;
44
78
  transaction<T>(fn: () => Promise<T>): Promise<T>;
45
79
  }
@@ -18,10 +18,19 @@ function checkIdent(name) {
18
18
  throw new Error(`invalid identifier: ${name}`);
19
19
  return name;
20
20
  }
21
- /** SQLite (DO SQLite and D1 both speak this). Bare identifiers, `?` placeholders,
22
- * booleans stored as INTEGER 0/1, RETURNING supported. */
21
+ /** Render an identifier as a standard double-quoted name (`"order"`), guarding its
22
+ * shape first. SQLite (DO SQLite + D1) and Postgres all accept double-quoted
23
+ * identifiers, so a column/table named after a reserved word (`order`, `group`, …)
24
+ * is safe and case is preserved. The single source of truth for both dialects and
25
+ * the DDL generator — keep every emitted identifier going through this. */
26
+ export function quoteIdent(name) {
27
+ return `"${checkIdent(name)}"`;
28
+ }
29
+ /** SQLite (DO SQLite and D1 both speak this). Double-quoted identifiers (so reserved
30
+ * words like `order` work), `?` placeholders, booleans stored as INTEGER 0/1,
31
+ * RETURNING supported. */
23
32
  export const sqliteDialect = {
24
- id: checkIdent,
33
+ id: quoteIdent,
25
34
  placeholder: () => "?",
26
35
  returning: true,
27
36
  encode: (v) => (typeof v === "boolean" ? (v ? 1 : 0) : v),
@@ -30,7 +39,7 @@ export const sqliteDialect = {
30
39
  * `ownerId` doesn't fold to `ownerid`), `$n` placeholders, native booleans,
31
40
  * RETURNING supported. */
32
41
  export const postgresDialect = {
33
- id: (name) => `"${checkIdent(name)}"`,
42
+ id: quoteIdent,
34
43
  placeholder: (n) => `$${n}`,
35
44
  returning: true,
36
45
  encode: (v) => v, // the pg driver handles type encoding
@@ -50,20 +59,38 @@ export class DoSqliteDriver {
50
59
  return this.storage.transaction(fn);
51
60
  }
52
61
  }
53
- /** D1 — SQLite over RPC. Async by nature. D1 has no interactive transactions, so
54
- * `transaction()` runs `fn` without one (a documented limitation: mutations don't
55
- * roll back on throw the way they do on a DO). Use a DO when you need that. */
62
+ /** D1 — SQLite over RPC. Async by nature.
63
+ *
64
+ * Read replicas (Sessions API): every D1Driver opens ONE `db.withSession(start)` and
65
+ * runs all `exec` through it. Writes in a session always land on the primary; the
66
+ * `start` only chooses where the FIRST read may begin. The session maintains a
67
+ * bookmark (`getBookmark()`) so later reads are sequentially consistent with earlier
68
+ * writes — read-your-writes when the bookmark is threaded across requests.
69
+ *
70
+ * ATOMICITY LIMIT (intentional): D1 has NO interactive transactions — a session can't
71
+ * read mid-`batch()`, and pramen mutations interleave reads + writes + RETURNING +
72
+ * trigger-into-outbox inside one `transaction()`. So `transaction(fn) = fn()`: each
73
+ * statement auto-commits on its own, and a multi-statement mutation does NOT roll back
74
+ * on throw the way it does on a DO. Single-statement mutations are atomic; anything
75
+ * multi-statement is not. Use the DO store when you need atomic mutations. */
56
76
  export class D1Driver {
57
- db;
58
77
  dialect = sqliteDialect;
59
- constructor(db) {
60
- this.db = db;
78
+ session;
79
+ constructor(db, opts) {
80
+ this.session = db.withSession(opts?.start ?? "first-unconstrained");
61
81
  }
62
82
  async exec(sql, params) {
63
- const stmt = params.length ? this.db.prepare(sql).bind(...params) : this.db.prepare(sql);
83
+ const stmt = params.length ? this.session.prepare(sql).bind(...params) : this.session.prepare(sql);
64
84
  const { results } = await stmt.all();
65
85
  return results ?? [];
66
86
  }
87
+ /** The session's latest bookmark (null before any query). Threaded back to the client
88
+ * via the `x-pramen-d1-bookmark` response header so a subsequent request can anchor a
89
+ * fresh session at it and read its own writes. */
90
+ getBookmark() {
91
+ return this.session.getBookmark();
92
+ }
93
+ // D1 has no interactive/atomic transactions — see the class doc. Run `fn` as-is.
67
94
  transaction(fn) {
68
95
  return fn();
69
96
  }
@@ -1,4 +1,4 @@
1
- import type { Driver } from "./driver";
1
+ import { type Driver } from "./driver";
2
2
  import type { SchemaDef } from "../sdk/schema";
3
3
  export interface MigrationReport {
4
4
  changed: boolean;
@@ -19,6 +19,7 @@
19
19
  // so it must be declared with `renamedFrom`; otherwise it is applied as drop+add.
20
20
  import { addColumnSql, createTableSql, indexStatements, sqlType } from "./ddl";
21
21
  import { digest } from "./digest";
22
+ import { quoteIdent } from "./driver";
22
23
  import { entitiesInPartition, validateSchema } from "../sdk/schema";
23
24
  /** Internal bookkeeping tables the migrator must never touch — pramen's own, SQLite's,
24
25
  * and the substrate's (D1 keeps `_cf_*` / `d1_*` tables in sqlite_master and forbids
@@ -31,11 +32,6 @@ function isInternalTable(name) {
31
32
  n.startsWith("_cf_") ||
32
33
  n.startsWith("d1_"));
33
34
  }
34
- function ident(name) {
35
- if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name))
36
- throw new Error(`invalid identifier: ${name}`);
37
- return name;
38
- }
39
35
  export function schemaHash(schema) {
40
36
  const canon = {};
41
37
  for (const [table, def] of Object.entries(schema))
@@ -45,7 +41,7 @@ export function schemaHash(schema) {
45
41
  /** Live columns of a table -> their declared SQL type (uppercased). Empty if the
46
42
  * table doesn't exist. */
47
43
  async function tableColumns(driver, table) {
48
- const rows = (await driver.exec(`PRAGMA table_info(${ident(table)})`, []));
44
+ const rows = (await driver.exec(`PRAGMA table_info(${quoteIdent(table)})`, []));
49
45
  return new Map(rows.map((r) => [r.name, (r.type || "").toUpperCase()]));
50
46
  }
51
47
  async function readMeta(driver, key) {
@@ -60,7 +56,7 @@ async function writeMeta(driver, key, value) {
60
56
  * type change; brand-new columns left NULL), drop the old table, rename the temp. */
61
57
  async function rebuildTable(driver, table, def, live) {
62
58
  const tmp = `__pramen_rebuild_${table}`;
63
- await driver.exec(`DROP TABLE IF EXISTS ${ident(tmp)}`, []);
59
+ await driver.exec(`DROP TABLE IF EXISTS ${quoteIdent(tmp)}`, []);
64
60
  await driver.exec(createTableSql(tmp, def), []);
65
61
  const destCols = [];
66
62
  const srcExprs = [];
@@ -70,14 +66,14 @@ async function rebuildTable(driver, table, def, live) {
70
66
  if (!src)
71
67
  continue; // brand-new column with no source -> leave NULL
72
68
  const target = sqlType(f);
73
- destCols.push(ident(name));
74
- srcExprs.push(live.get(src) === target ? ident(src) : `CAST(${ident(src)} AS ${target})`);
69
+ destCols.push(quoteIdent(name));
70
+ srcExprs.push(live.get(src) === target ? quoteIdent(src) : `CAST(${quoteIdent(src)} AS ${target})`);
75
71
  }
76
72
  if (destCols.length > 0) {
77
- await driver.exec(`INSERT INTO ${ident(tmp)} (${destCols.join(", ")}) SELECT ${srcExprs.join(", ")} FROM ${ident(table)}`, []);
73
+ await driver.exec(`INSERT INTO ${quoteIdent(tmp)} (${destCols.join(", ")}) SELECT ${srcExprs.join(", ")} FROM ${quoteIdent(table)}`, []);
78
74
  }
79
- await driver.exec(`DROP TABLE ${ident(table)}`, []);
80
- await driver.exec(`ALTER TABLE ${ident(tmp)} RENAME TO ${ident(table)}`, []);
75
+ await driver.exec(`DROP TABLE ${quoteIdent(table)}`, []);
76
+ await driver.exec(`ALTER TABLE ${quoteIdent(tmp)} RENAME TO ${quoteIdent(table)}`, []);
81
77
  }
82
78
  export async function migrate(driver, schema, opts = {}) {
83
79
  // Static schema invariants (relation targets exist, no cross-partition relations) —
@@ -133,7 +129,7 @@ export async function migrate(driver, schema, opts = {}) {
133
129
  needsAdditiveRebuild = true;
134
130
  continue;
135
131
  }
136
- await driver.exec(`ALTER TABLE ${ident(table)} ADD COLUMN ${addColumnSql(name, field)}`, []);
132
+ await driver.exec(`ALTER TABLE ${quoteIdent(table)} ADD COLUMN ${addColumnSql(name, field)}`, []);
137
133
  added.push(`${table}.${name}`);
138
134
  }
139
135
  // Pass 2 — destructive: rebuild if any live column must be dropped, a declared
@@ -181,7 +177,7 @@ export async function migrate(driver, schema, opts = {}) {
181
177
  if (isInternalTable(name) || inScope.has(name) || otherPartitionTables.has(name))
182
178
  continue;
183
179
  if (allowDestructive) {
184
- await driver.exec(`DROP TABLE ${ident(name)}`, []);
180
+ await driver.exec(`DROP TABLE ${quoteIdent(name)}`, []);
185
181
  droppedTables.push(name);
186
182
  }
187
183
  else {
@@ -0,0 +1,64 @@
1
+ import type { Mail } from "./mail";
2
+ import type { Queue } from "./queue";
3
+ import type { Kv } from "./kv";
4
+ /** One received message (the Cloudflare Queues `Message` shape). */
5
+ export interface QueueMessage<Body = unknown> {
6
+ readonly id: string;
7
+ readonly timestamp: Date;
8
+ readonly body: Body;
9
+ /** 1-based delivery attempt — grows on each retry (use it to give up / dead-letter). */
10
+ readonly attempts: number;
11
+ /** Mark this message handled (won't be redelivered). The framework calls this for you
12
+ * when the handler resolves; call it yourself only for fine-grained control. */
13
+ ack(): void;
14
+ /** Schedule this message for redelivery (the framework calls it when the handler throws). */
15
+ retry(options?: {
16
+ delaySeconds?: number;
17
+ }): void;
18
+ }
19
+ /** A batch delivered to the consumer (the Cloudflare Queues `MessageBatch` shape). */
20
+ export interface QueueBatch<Body = unknown> {
21
+ /** The queue this batch came from (the oblaka `Queue` name; env-prefixed remotely). */
22
+ readonly queue: string;
23
+ readonly messages: readonly QueueMessage<Body>[];
24
+ ackAll(): void;
25
+ retryAll(options?: {
26
+ delaySeconds?: number;
27
+ }): void;
28
+ }
29
+ /** The context handed to a queue consumer handler. Worker-level (no `ctx.db`): reach
30
+ * tenant data via `ctx.callPrivileged`. */
31
+ export interface QueueContext {
32
+ /** The Worker environment (bindings + vars + secrets). */
33
+ readonly env: Readonly<Record<string, unknown>>;
34
+ /** Project KV (cross-tenant). */
35
+ readonly kv: Kv;
36
+ /** Send email (the notification path). */
37
+ readonly mail: Mail;
38
+ /** Enqueue onto a (possibly different) queue — fan-out / chaining. */
39
+ readonly queue: Queue;
40
+ /** Apply a privileged mutation into a tenant's DO (the consumer has no direct db).
41
+ * The message body should carry the `tenant`. */
42
+ callPrivileged(opts: {
43
+ name: string;
44
+ input?: unknown;
45
+ tenant?: string;
46
+ roles?: string[];
47
+ partition?: string;
48
+ }): Promise<Response>;
49
+ }
50
+ /** A queue consumer handler — runs once per message. Resolving ACKs the message;
51
+ * throwing RETRIES it (subject to the queue's max_retries → dead-letter queue). */
52
+ export type QueueHandler<Body = unknown> = (ctx: QueueContext, message: QueueMessage<Body>) => void | Promise<void>;
53
+ /** Map of queue name → consumer handler. Set as `app.queues`; dispatched by
54
+ * `createPramen(app).queue`. */
55
+ export type AppQueueMap = Record<string, QueueHandler>;
56
+ /** Resolve the handler for a batch's queue. Queue names are env-prefixed in remote
57
+ * environments (`production-pramen-jobs`) but bare locally (`pramen-jobs`), so match
58
+ * leniently: exact, then suffix (`…-<key>`), then — if there's exactly one handler —
59
+ * fall through to it (the common single-queue app). Returns null if nothing matches. */
60
+ export declare function routeQueue(queues: AppQueueMap, queueName: string): QueueHandler | null;
61
+ /** Dispatch one batch: route to the handler, then run it per message, ACKing on success
62
+ * and RETRYing on throw (per message, so one poison message doesn't re-deliver the rest).
63
+ * An unrouted batch is retried whole (never silently acked) and logged. */
64
+ export declare function dispatchQueueBatch(queues: AppQueueMap, ctx: QueueContext, batch: QueueBatch): Promise<void>;
@@ -0,0 +1,46 @@
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
+ /** Resolve the handler for a batch's queue. Queue names are env-prefixed in remote
12
+ * environments (`production-pramen-jobs`) but bare locally (`pramen-jobs`), so match
13
+ * leniently: exact, then suffix (`…-<key>`), then — if there's exactly one handler —
14
+ * fall through to it (the common single-queue app). Returns null if nothing matches. */
15
+ export function routeQueue(queues, queueName) {
16
+ const keys = Object.keys(queues);
17
+ if (queues[queueName])
18
+ return queues[queueName];
19
+ const suffix = keys.find((k) => queueName.endsWith(`-${k}`) || k.endsWith(`-${queueName}`));
20
+ if (suffix)
21
+ return queues[suffix];
22
+ if (keys.length === 1)
23
+ return queues[keys[0]];
24
+ return null;
25
+ }
26
+ /** Dispatch one batch: route to the handler, then run it per message, ACKing on success
27
+ * and RETRYing on throw (per message, so one poison message doesn't re-deliver the rest).
28
+ * An unrouted batch is retried whole (never silently acked) and logged. */
29
+ export async function dispatchQueueBatch(queues, ctx, batch) {
30
+ const handler = routeQueue(queues, batch.queue);
31
+ if (!handler) {
32
+ console.error(`pramen: no app.queues handler for queue '${batch.queue}' — retrying batch (declare it in app.queues)`);
33
+ batch.retryAll();
34
+ return;
35
+ }
36
+ await Promise.all(batch.messages.map(async (message) => {
37
+ try {
38
+ await handler(ctx, message);
39
+ message.ack();
40
+ }
41
+ catch (err) {
42
+ console.error(`pramen: queue '${batch.queue}' message ${message.id} failed (attempt ${message.attempts}) — retrying`, err);
43
+ message.retry();
44
+ }
45
+ }));
46
+ }
@@ -0,0 +1,72 @@
1
+ /** Cloudflare Queues content type for a sent message. Omitted ⇒ the platform default
2
+ * (v8 structured clone). Use "json" for cross-runtime / external consumers. */
3
+ export type QueueContentType = "text" | "bytes" | "json" | "v8";
4
+ /** Per-message send options (mirrors the Cloudflare Queues producer API). */
5
+ export interface QueueSendOptions {
6
+ /** Defer delivery by N seconds (the consumer won't see the message until then). */
7
+ delaySeconds?: number;
8
+ /** How the body is serialized on the wire. Omitted ⇒ platform default (v8). */
9
+ contentType?: QueueContentType;
10
+ }
11
+ /** One message in a `sendBatch` — a body plus its own per-message options. */
12
+ export interface QueueSendRequest {
13
+ body: unknown;
14
+ delaySeconds?: number;
15
+ contentType?: QueueContentType;
16
+ }
17
+ /** Batch-level send options. */
18
+ export interface QueueBatchOptions {
19
+ /** Default delay applied to every message in the batch (per-message overrides win). */
20
+ delaySeconds?: number;
21
+ }
22
+ /** The Cloudflare Queues producer binding shape (what `env.<QUEUE>` exposes). A binding
23
+ * is recognized as a queue producer iff it has BOTH `send` and `sendBatch` (which
24
+ * distinguishes it from the email `send`-only binding, KV, R2, D1, …). */
25
+ export interface QueueProducerBinding {
26
+ send(body: unknown, options?: QueueSendOptions): Promise<void>;
27
+ sendBatch(messages: Iterable<QueueSendRequest>, options?: QueueBatchOptions): Promise<void>;
28
+ }
29
+ /** The transport seam — one per backend (Cloudflare Queues, an in-memory capture, …). */
30
+ export interface QueueAdapter {
31
+ send(queue: string, body: unknown, options?: QueueSendOptions): Promise<void>;
32
+ sendBatch(queue: string, messages: readonly QueueSendRequest[], options?: QueueBatchOptions): Promise<void>;
33
+ }
34
+ /** The `ctx.queue` facade: validates, then delegates to the adapter for a named queue. */
35
+ export declare class Queue {
36
+ private readonly adapter;
37
+ constructor(adapter: QueueAdapter);
38
+ /** Enqueue a single message onto `queue`. `body` is serialized by the platform. */
39
+ send(queue: string, body: unknown, options?: QueueSendOptions): Promise<void>;
40
+ /** Enqueue many messages onto `queue` in one call (cheaper than N sends). */
41
+ sendBatch(queue: string, messages: readonly QueueSendRequest[], options?: QueueBatchOptions): Promise<void>;
42
+ }
43
+ /** Cloudflare Queues transport. Constructed with the producer bindings discovered from
44
+ * the environment, keyed by binding name. Sending to a name with no bound queue throws
45
+ * a clear error (fail-closed) — a missing binding is a config error, not a silent drop. */
46
+ export declare class CloudflareQueueAdapter implements QueueAdapter {
47
+ private readonly bindings;
48
+ constructor(bindings: Readonly<Record<string, QueueProducerBinding>>);
49
+ private bindingFor;
50
+ send(queue: string, body: unknown, options?: QueueSendOptions): Promise<void>;
51
+ sendBatch(queue: string, messages: readonly QueueSendRequest[], options?: QueueBatchOptions): Promise<void>;
52
+ }
53
+ /** In-memory transport: captures sent messages instead of delivering them. For unit
54
+ * tests (assert on `.sent`) and pure off-platform use. */
55
+ export declare class MemoryQueueAdapter implements QueueAdapter {
56
+ readonly sent: Array<{
57
+ queue: string;
58
+ body: unknown;
59
+ options?: QueueSendOptions | QueueBatchOptions;
60
+ }>;
61
+ send(queue: string, body: unknown, options?: QueueSendOptions): Promise<void>;
62
+ sendBatch(queue: string, messages: readonly QueueSendRequest[], options?: QueueBatchOptions): Promise<void>;
63
+ }
64
+ /** Discover the Cloudflare Queues producer bindings in an environment: any value that
65
+ * exposes BOTH `send` and `sendBatch` functions (which excludes the email `send`-only
66
+ * binding, KV, R2, D1, the DO namespace, …). Returns name → binding. */
67
+ export declare function discoverQueueBindings(env: Readonly<Record<string, unknown>>): Record<string, QueueProducerBinding>;
68
+ /** Build `ctx.queue` from the environment: a Cloudflare adapter over the discovered
69
+ * producer bindings. Sending to an undeclared queue fails closed (the adapter throws).
70
+ * There is no silent capture fallback — declare the `Queue` binding and it exists in
71
+ * dev (lopata) and miniflare too. */
72
+ export declare function createQueue(env: Readonly<Record<string, unknown>>): Queue;