@pramen/server 0.0.13 → 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.
@@ -0,0 +1,110 @@
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
+ /** The `ctx.queue` facade: validates, then delegates to the adapter for a named queue. */
22
+ export class Queue {
23
+ adapter;
24
+ constructor(adapter) {
25
+ this.adapter = adapter;
26
+ }
27
+ /** Enqueue a single message onto `queue`. `body` is serialized by the platform. */
28
+ async send(queue, body, options) {
29
+ assertQueueName(queue);
30
+ if (body === undefined)
31
+ throw new Error("ctx.queue.send: `body` is required");
32
+ await this.adapter.send(queue, body, options);
33
+ }
34
+ /** Enqueue many messages onto `queue` in one call (cheaper than N sends). */
35
+ async sendBatch(queue, messages, options) {
36
+ assertQueueName(queue);
37
+ if (!Array.isArray(messages) || messages.length === 0) {
38
+ throw new Error("ctx.queue.sendBatch: `messages` must be a non-empty array");
39
+ }
40
+ for (const m of messages) {
41
+ if (!m || m.body === undefined)
42
+ throw new Error("ctx.queue.sendBatch: every message needs a `body`");
43
+ }
44
+ await this.adapter.sendBatch(queue, messages, options);
45
+ }
46
+ }
47
+ function assertQueueName(queue) {
48
+ if (typeof queue !== "string" || queue.length === 0) {
49
+ throw new Error("ctx.queue: a queue name is required (the oblaka `Queue` name)");
50
+ }
51
+ }
52
+ /** Cloudflare Queues transport. Constructed with the producer bindings discovered from
53
+ * the environment, keyed by binding name. Sending to a name with no bound queue throws
54
+ * a clear error (fail-closed) — a missing binding is a config error, not a silent drop. */
55
+ export class CloudflareQueueAdapter {
56
+ bindings;
57
+ constructor(bindings) {
58
+ this.bindings = bindings;
59
+ }
60
+ bindingFor(queue) {
61
+ const b = this.bindings[queue];
62
+ if (!b) {
63
+ const known = Object.keys(this.bindings);
64
+ const avail = known.length ? known.join(", ") : "none";
65
+ throw new Error(`ctx.queue: no queue binding '${queue}' — declare it in oblaka.ts ` +
66
+ `(new Queue({ name: '${queue}', binding: 'both' })). Bound queues: ${avail}.`);
67
+ }
68
+ return b;
69
+ }
70
+ async send(queue, body, options) {
71
+ await this.bindingFor(queue).send(body, options);
72
+ }
73
+ async sendBatch(queue, messages, options) {
74
+ await this.bindingFor(queue).sendBatch(messages, options);
75
+ }
76
+ }
77
+ /** In-memory transport: captures sent messages instead of delivering them. For unit
78
+ * tests (assert on `.sent`) and pure off-platform use. */
79
+ export class MemoryQueueAdapter {
80
+ sent = [];
81
+ async send(queue, body, options) {
82
+ this.sent.push({ queue, body, options });
83
+ }
84
+ async sendBatch(queue, messages, options) {
85
+ for (const m of messages)
86
+ this.sent.push({ queue, body: m.body, options: { ...options, ...m } });
87
+ }
88
+ }
89
+ /** Discover the Cloudflare Queues producer bindings in an environment: any value that
90
+ * exposes BOTH `send` and `sendBatch` functions (which excludes the email `send`-only
91
+ * binding, KV, R2, D1, the DO namespace, …). Returns name → binding. */
92
+ export function discoverQueueBindings(env) {
93
+ const out = {};
94
+ for (const [name, value] of Object.entries(env)) {
95
+ if (value &&
96
+ typeof value === "object" &&
97
+ typeof value.send === "function" &&
98
+ typeof value.sendBatch === "function") {
99
+ out[name] = value;
100
+ }
101
+ }
102
+ return out;
103
+ }
104
+ /** Build `ctx.queue` from the environment: a Cloudflare adapter over the discovered
105
+ * producer bindings. Sending to an undeclared queue fails closed (the adapter throws).
106
+ * There is no silent capture fallback — declare the `Queue` binding and it exists in
107
+ * dev (lopata) and miniflare too. */
108
+ export function createQueue(env) {
109
+ return new Queue(new CloudflareQueueAdapter(discoverQueueBindings(env)));
110
+ }
@@ -1,6 +1,7 @@
1
1
  import type { Db } from "../runtime/db";
2
2
  import type { Kv } from "../runtime/kv";
3
3
  import type { Mail } from "../runtime/mail";
4
+ import type { Queue } from "../runtime/queue";
4
5
  import type { Identity } from "./acl";
5
6
  import type { Files } from "./files";
6
7
  import type { SchemaDef } from "./schema";
@@ -31,6 +32,12 @@ export interface HandlerContext<S extends SchemaDef = SchemaDef> {
31
32
  * runs the matching `app.tasks` handler after commit, off the write path, with
32
33
  * retry. For notification email, webhooks, etc. — see `app.tasks`. */
33
34
  readonly tasks: Tasks;
35
+ /** Enqueue onto a native Cloudflare Queue: `ctx.queue.send("jobs", body)`. Unlike
36
+ * `ctx.tasks` (a transactional outbox, atomic with the mutation, drained in-process),
37
+ * a queue send is NOT transactional with the write but is higher-throughput, with
38
+ * platform-native batching/retry/DLQ and a consumer that may live in another Worker.
39
+ * Declare queues in oblaka.ts; consume them via `app.queues`. */
40
+ readonly queue: Queue;
34
41
  }
35
42
  /** The deferred-side-effects facade handed to handlers as `ctx.tasks`. */
36
43
  export interface Tasks {
package/dist/worker.d.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { type QueueProducerBinding } from "./runtime/queue";
2
+ import { type QueueBatch } from "./runtime/queue-consumer";
1
3
  import type { PramenApp } from "./pramen";
2
4
  export interface Env {
3
5
  PRAMEN: DurableObjectNamespace;
@@ -23,7 +25,25 @@ export interface Env {
23
25
  CORS_ORIGINS?: string;
24
26
  /** "true" to apply destructive schema migrations on the D1 path. Off by default. */
25
27
  PRAMEN_ALLOW_DESTRUCTIVE?: string;
28
+ /** Default store for /rpc when no `x-pramen-store` header is sent: `"d1"` runs the
29
+ * Worker+D1 path by default (requires DB bound); `"do"` (the default) routes to the
30
+ * per-tenant Durable Object. The header still overrides per-request. /live always
31
+ * needs the DO regardless of this setting. */
32
+ PRAMEN_STORE?: string;
33
+ /** Cloudflare Queues producer binding for ctx.queue (declared in oblaka.ts). Optional —
34
+ * ctx.queue discovers any producer binding by name; this just types the common one. */
35
+ JOBS?: QueueProducerBinding;
26
36
  }
37
+ /** Decide whether an /rpc request runs on the D1 store. **Live queries ALWAYS use the
38
+ * DO** (they need a single writer + a socket host), regardless of header or default —
39
+ * so enabling `PRAMEN_STORE=d1` never silently breaks `/live`. Otherwise an explicit
40
+ * `x-pramen-store` header wins (`d1`/`do`), then the `PRAMEN_STORE` default. Pure +
41
+ * exported for unit testing. */
42
+ export declare function useD1Store(opts: {
43
+ storeHeader: string | null;
44
+ isLive: boolean;
45
+ defaultStore: string | undefined;
46
+ }): boolean;
27
47
  /** Forward a privileged mutation into a tenant's DO from a public route. The
28
48
  * synthetic identity (default `["admin"]`) is trusted because the call originates
29
49
  * in the Worker — the same internal mechanism the admin endpoints use. Returns the
@@ -40,4 +60,5 @@ export declare function callPrivileged(env: Env, opts: {
40
60
  export declare function makeWorker(app: PramenApp): {
41
61
  fetch(request: Request, env: Env): Promise<Response>;
42
62
  scheduled(_event: unknown, env: Env): Promise<void>;
63
+ queue(batch: QueueBatch, env: Env): Promise<void>;
43
64
  };
package/dist/worker.js CHANGED
@@ -7,6 +7,8 @@ import { authorizeTenant, HmacStrategy, isAdmin, JwksStrategy, resolveIdentity }
7
7
  import { dispatch, tasksFacade, bindTasks } from "./runtime/dispatch";
8
8
  import { ensureOutbox, drainOutbox, listTasks } from "./runtime/outbox";
9
9
  import { createMail } from "./runtime/mail";
10
+ import { createQueue } from "./runtime/queue";
11
+ import { dispatchQueueBatch } from "./runtime/queue-consumer";
10
12
  import { migrate } from "./runtime/migrate";
11
13
  import { compileAcl } from "./runtime/acl";
12
14
  import { Db } from "./runtime/db";
@@ -19,6 +21,24 @@ import { DEFAULT_PARTITION } from "./sdk/schema";
19
21
  /** The secret used to sign/verify file tokens — a dedicated FILES_SECRET if set,
20
22
  * else AUTH_SECRET (so HS256 setups work out of the box). */
21
23
  const filesSecret = (env) => env.FILES_SECRET || env.AUTH_SECRET;
24
+ /** Request/response header carrying the D1 session bookmark for read-your-writes: a
25
+ * client echoes the last response's value on its next request, anchoring a fresh
26
+ * session at that write so it reads its own writes (even off a lagging replica). */
27
+ const D1_BOOKMARK_HEADER = "x-pramen-d1-bookmark";
28
+ /** Decide whether an /rpc request runs on the D1 store. **Live queries ALWAYS use the
29
+ * DO** (they need a single writer + a socket host), regardless of header or default —
30
+ * so enabling `PRAMEN_STORE=d1` never silently breaks `/live`. Otherwise an explicit
31
+ * `x-pramen-store` header wins (`d1`/`do`), then the `PRAMEN_STORE` default. Pure +
32
+ * exported for unit testing. */
33
+ export function useD1Store(opts) {
34
+ if (opts.isLive)
35
+ return false; // live is DO-only — never the D1 path
36
+ if (opts.storeHeader === "d1")
37
+ return true;
38
+ if (opts.storeHeader === "do")
39
+ return false;
40
+ return opts.defaultStore === "d1";
41
+ }
22
42
  const json = (body, status = 200) => Response.json(body, { status });
23
43
  const forbidden = (what) => json({ ok: false, error: `access denied: ${what}`, code: "forbidden" }, 403);
24
44
  const badRequest = (msg) => json({ ok: false, error: msg, code: "bad_request" }, 400);
@@ -34,7 +54,10 @@ function corsHeaders(origin, env) {
34
54
  return {
35
55
  "access-control-allow-origin": allow.includes("*") ? "*" : origin,
36
56
  "access-control-allow-methods": "GET, POST, OPTIONS",
37
- "access-control-allow-headers": "content-type, authorization, x-pramen-tenant, x-pramen-store",
57
+ "access-control-allow-headers": "content-type, authorization, x-pramen-tenant, x-pramen-store, x-pramen-d1-bookmark",
58
+ // Expose the D1 read-your-writes bookmark so a browser client can read it off the
59
+ // response and carry it forward on the next request.
60
+ "access-control-expose-headers": "x-pramen-d1-bookmark",
38
61
  vary: "origin",
39
62
  };
40
63
  }
@@ -52,6 +75,13 @@ function withCors(res, cors) {
52
75
  * `tenant` for the default partition (so `idFromName(tenant)` is byte-for-byte unchanged
53
76
  * — backward-compat) and `${tenant}:${partition}` for any other partition. */
54
77
  function partitionStubFor(env, tenant, partition = DEFAULT_PARTITION) {
78
+ // Fail with a clear message rather than a cryptic `Cannot read 'get' of undefined`
79
+ // when the Durable Object isn't bound (e.g. a D1-only deployment that fell through to
80
+ // the DO path). The Worker RPC surface depends on this binding existing.
81
+ if (!env.PRAMEN) {
82
+ throw new Error("pramen: no Durable Object bound (PRAMEN). Pin the D1 store per request with the " +
83
+ "'x-pramen-store: d1' header (or set PRAMEN_STORE=d1), or bind the PramenDO.");
84
+ }
55
85
  return env.PRAMEN.get(env.PRAMEN.idFromName(partitionDoName(tenant, partition)));
56
86
  }
57
87
  /** Forward a privileged mutation into a tenant's DO from a public route. The
@@ -112,21 +142,24 @@ export function makeWorker(app) {
112
142
  const files = createFiles({ tenant: "main", secret: filesSecret(env), adapter: new R2Adapter(env.FILES) });
113
143
  const db = new Db(driver, { acl: d1Acl, identity, system: true, schema: app.schema, suppressTriggers: true }, app.schema);
114
144
  const kv = new Kv(env.KV);
115
- return { db, kv, files, env: env, identity, tasks: tasksFacade(driver), mail: createMail(env, kv) };
145
+ return { db, kv, files, env: env, identity, tasks: tasksFacade(driver), mail: createMail(env, kv), queue: createQueue(env) };
116
146
  };
117
147
  /** Drain the D1 outbox in the Worker (no DO/alarm on this path) — called by the
118
148
  * /admin/tasks/drain route with `x-pramen-store: d1`, and by `scheduled()` (Cron). */
119
149
  const drainD1 = async (env) => {
120
150
  if (!env.DB)
121
151
  throw new Error("D1 store is not configured");
122
- const driver = new D1Driver(env.DB);
152
+ // The drain reads due tasks then writes their status — pin the primary so it sees
153
+ // and updates current outbox state (not a lagging replica).
154
+ const driver = new D1Driver(env.DB, { start: "first-primary" });
123
155
  await ensureD1Migrated(driver, env.PRAMEN_ALLOW_DESTRUCTIVE === "true");
124
156
  return drainOutbox(driver, bindTasks(app.tasks, d1TaskCtx(driver, env)), Date.now());
125
157
  };
126
158
  const listD1Tasks = async (env, status, limit) => {
127
159
  if (!env.DB)
128
160
  throw new Error("D1 store is not configured");
129
- const driver = new D1Driver(env.DB);
161
+ // Inspection listing pin the primary so it reflects current outbox state.
162
+ const driver = new D1Driver(env.DB, { start: "first-primary" });
130
163
  await ensureD1Migrated(driver, env.PRAMEN_ALLOW_DESTRUCTIVE === "true");
131
164
  return listTasks(driver, { status, limit });
132
165
  };
@@ -294,26 +327,46 @@ export function makeWorker(app) {
294
327
  const tenant = req.headers.get("x-pramen-tenant") ?? "main";
295
328
  if (!authorizeTenant(identity, tenant))
296
329
  return withCors(forbidden(`tenant '${tenant}'`), cors);
297
- // --- Worker + D1 (no DO): the same schema/ACL/read engine over a D1 binding,
298
- // selected per-request via `x-pramen-store: d1`. RPC only live queries need the
299
- // DO (single writer + a socket host). This proof uses ONE shared D1 database
300
- // across tenants; a real product would add a tenant column or a per-tenant DB.
301
- if (req.headers.get("x-pramen-store") === "d1") {
330
+ // --- Worker + D1 (no DO): the same schema/ACL/read engine over a D1 binding.
331
+ // Selected per-request via `x-pramen-store: d1`, OR as the app-wide default when
332
+ // PRAMEN_STORE=d1 (the header still overrides: `x-pramen-store: do` forces the DO).
333
+ // RPC only live queries need the DO (single writer + a socket host). This proof
334
+ // uses ONE shared D1 database across tenants; a real product would add a tenant
335
+ // column or a per-tenant DB.
336
+ const storeHeader = req.headers.get("x-pramen-store");
337
+ const useD1 = useD1Store({ storeHeader, isLive, defaultStore: env.PRAMEN_STORE });
338
+ if (useD1) {
302
339
  if (!env.DB)
303
340
  return badRequest("D1 store is not configured");
304
- if (isLive)
305
- return badRequest("live queries require the default (DO) store");
341
+ // (isLive is excluded by useD1Store — live always routes to the DO below.)
306
342
  const name = url.pathname.replace(/^\/rpc\//, "");
307
343
  let input;
308
344
  if (request.method === "POST")
309
345
  input = await request.json().catch(() => undefined);
310
- const driver = new D1Driver(env.DB);
346
+ // Pick where the D1 session may start its first read. A client-supplied bookmark
347
+ // wins (read-your-writes); otherwise default by handler kind: a mutation pins the
348
+ // primary so its reads see current data, a query may begin at the nearest replica.
349
+ const inboundBookmark = req.headers.get(D1_BOOKMARK_HEADER);
350
+ const kind = app.handlers[name]?.kind;
351
+ let start;
352
+ if (inboundBookmark)
353
+ start = inboundBookmark;
354
+ else if (kind === "mutation")
355
+ start = "first-primary";
356
+ else
357
+ start = "first-unconstrained";
358
+ const driver = new D1Driver(env.DB, { start });
311
359
  const files = createFiles({ tenant, secret: filesSecret(env), adapter: new R2Adapter(env.FILES) });
312
360
  const envBag = env;
313
361
  try {
314
362
  await ensureD1Migrated(driver, env.PRAMEN_ALLOW_DESTRUCTIVE === "true");
315
363
  const { result } = await dispatch(app.handlers, app.schema, driver, new Kv(env.KV), files, envBag, { acl: d1Acl, identity }, name, input);
316
- return withCors(json({ ok: true, result }), cors);
364
+ const res = json({ ok: true, result });
365
+ // Thread the session's latest bookmark back so the client can read its own writes.
366
+ const bookmark = driver.getBookmark();
367
+ if (bookmark)
368
+ res.headers.set(D1_BOOKMARK_HEADER, bookmark);
369
+ return withCors(res, cors);
317
370
  }
318
371
  catch (err) {
319
372
  const { status, body } = toResponse(err);
@@ -338,6 +391,15 @@ export function makeWorker(app) {
338
391
  else
339
392
  headers.delete("x-pramen-identity");
340
393
  headers.set("x-pramen-partition", partition);
394
+ // Routed to the DO but no DO is bound — return a clear, actionable error instead of
395
+ // crashing the whole RPC surface. (A D1-only deployment should pin the D1 store with
396
+ // the `x-pramen-store: d1` header; the `PRAMEN_STORE` env default can be dropped by
397
+ // some adapters' env proxies, so the header is the reliable way to pin it.)
398
+ if (!env.PRAMEN) {
399
+ return withCors(badRequest(isLive
400
+ ? "live queries require a Durable Object, but no PRAMEN binding is configured"
401
+ : "no Durable Object (PRAMEN) is bound — pin the D1 store with the 'x-pramen-store: d1' header (or bind the DO)"), cors);
402
+ }
341
403
  const stub = partitionStubFor(env, tenant, partition);
342
404
  // WebSocket upgrades (101) must be returned untouched; only add CORS to HTTP.
343
405
  const res = await stub.fetch(new Request(req, { headers }));
@@ -349,5 +411,20 @@ export function makeWorker(app) {
349
411
  if (env.DB)
350
412
  await drainD1(env);
351
413
  },
414
+ // Cloudflare Queues consumer entry: routes a batch to the matching `app.queues`
415
+ // handler (ACK on success / RETRY on throw, per message). A consumer is Worker-level
416
+ // (no tenant DO): its ctx carries env/kv/mail/queue + callPrivileged to reach a DO.
417
+ async queue(batch, env) {
418
+ const envBag = env;
419
+ const kv = new Kv(env.KV);
420
+ const ctx = {
421
+ env: envBag,
422
+ kv,
423
+ mail: createMail(envBag, kv),
424
+ queue: createQueue(envBag),
425
+ callPrivileged: (opts) => callPrivileged(env, opts),
426
+ };
427
+ await dispatchQueueBatch(app.queues ?? {}, ctx, batch);
428
+ },
352
429
  };
353
430
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pramen/server",
3
- "version": "0.0.13",
3
+ "version": "0.0.14",
4
4
  "description": "pramen server runtime — schema, ACL, ORM, live queries, files, and the createPramen(app) factory for Cloudflare Workers + Durable Objects.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -19,6 +19,7 @@ import { DurableObject } from "cloudflare:workers";
19
19
  import { migrate } from "./runtime/migrate";
20
20
  import { dispatch, tasksFacade, bindTasks } from "./runtime/dispatch";
21
21
  import { createMail } from "./runtime/mail";
22
+ import { createQueue } from "./runtime/queue";
22
23
  import { ensureOutbox, drainOutbox, listTasks } from "./runtime/outbox";
23
24
  import { Db } from "./runtime/db";
24
25
  import { digest } from "./runtime/digest";
@@ -214,6 +215,7 @@ export class PramenDOBase extends DurableObject<DoEnv> {
214
215
  identity,
215
216
  tasks: tasksFacade(this.driver),
216
217
  mail: createMail(this.envBag, this.kv),
218
+ queue: createQueue(this.envBag),
217
219
  };
218
220
  }
219
221
 
package/src/index.ts CHANGED
@@ -76,6 +76,12 @@ export type { StorageAdapter, PutResult, GetResult } from "./runtime/storage";
76
76
  export { Mail, CloudflareEmailAdapter, KvMailAdapter, MemoryMailAdapter, UnconfiguredMailAdapter, createMail } from "./runtime/mail";
77
77
  export type { MailMessage, MailAddress, MailAdapter, SendEmailBinding } from "./runtime/mail";
78
78
 
79
+ // --- queue (ctx.queue — Cloudflare Queues) ---
80
+ export { Queue, CloudflareQueueAdapter, MemoryQueueAdapter, createQueue, discoverQueueBindings } from "./runtime/queue";
81
+ export type { QueueAdapter, QueueProducerBinding, QueueSendOptions, QueueSendRequest, QueueBatchOptions, QueueContentType } from "./runtime/queue";
82
+ export { routeQueue, dispatchQueueBatch } from "./runtime/queue-consumer";
83
+ export type { QueueContext, QueueHandler, QueueMessage, QueueBatch, AppQueueMap } from "./runtime/queue-consumer";
84
+
79
85
  // --- errors ---
80
86
  export { PramenError, BadRequest, Unauthorized, Forbidden } from "./runtime/errors";
81
87
 
package/src/pramen.ts CHANGED
@@ -16,6 +16,7 @@ import { makeWorker, type Env } from "./worker";
16
16
  import { pramenDO, type DoEnv } from "./durable-object";
17
17
  import { validateTriggerTasks, type SchemaDef } from "./sdk/schema";
18
18
  import type { AppTaskMap, HandlerMap } from "./sdk/handlers";
19
+ import type { AppQueueMap, QueueBatch } from "./runtime/queue-consumer";
19
20
  import type { Role } from "./sdk/acl";
20
21
 
21
22
  /** Injected into a public route's handler — forward a privileged mutation into the
@@ -48,6 +49,10 @@ export interface PramenApp {
48
49
  /** Deferred side-effect handlers keyed by `kind` — drained from the outbox after a
49
50
  * mutation enqueues via `ctx.tasks.enqueue`. For notification email, webhooks, etc. */
50
51
  tasks?: AppTaskMap;
52
+ /** Cloudflare Queues consumers keyed by queue name — process messages produced via
53
+ * `ctx.queue.send(...)`. Dispatched by `createPramen(app).queue` (a consumer is
54
+ * Worker-level: no `ctx.db`, reach a tenant via `ctx.callPrivileged`). */
55
+ queues?: AppQueueMap;
51
56
  }
52
57
 
53
58
  export type { Env, DoEnv };
@@ -58,9 +63,10 @@ export type { Env, DoEnv };
58
63
  export function createPramen(app: PramenApp): {
59
64
  fetch: (request: Request, env: Env) => Promise<Response>;
60
65
  scheduled: (event: unknown, env: Env) => Promise<void>;
66
+ queue: (batch: QueueBatch, env: Env) => Promise<void>;
61
67
  PramenDO: ReturnType<typeof pramenDO>;
62
68
  } {
63
69
  validateTriggerTasks(app.schema, Object.keys(app.tasks ?? {})); // fail fast on a typo'd trigger task
64
70
  const worker = makeWorker(app);
65
- return { fetch: worker.fetch, scheduled: worker.scheduled, PramenDO: pramenDO(app) };
71
+ return { fetch: worker.fetch, scheduled: worker.scheduled, queue: worker.queue, PramenDO: pramenDO(app) };
66
72
  }
@@ -3,6 +3,7 @@
3
3
  // these are applied.
4
4
 
5
5
  import type { DefaultValue, EntityFields, FieldDef } from "../sdk/schema";
6
+ import { quoteIdent } from "./driver";
6
7
 
7
8
  // SQLite has no boolean type; store as INTEGER 0/1. json + fileRef + uuid are
8
9
  // stored as TEXT. Exported for the migrator, which compares declared column types
@@ -35,7 +36,7 @@ function defaultSql(f: FieldDef): string {
35
36
  }
36
37
 
37
38
  function columnSql(name: string, f: FieldDef): string {
38
- let s = `${name} ${sqlType(f)}`;
39
+ let s = `${quoteIdent(name)} ${sqlType(f)}`;
39
40
  if (f.primaryKey) s += " PRIMARY KEY";
40
41
  if (f.autoIncrement) s += " AUTOINCREMENT";
41
42
  if (f.notNull && !f.primaryKey) s += " NOT NULL";
@@ -45,14 +46,14 @@ function columnSql(name: string, f: FieldDef): string {
45
46
 
46
47
  export function createTableSql(table: string, def: { fields: EntityFields }): string {
47
48
  const cols = Object.entries(def.fields).map(([n, f]) => columnSql(n, f));
48
- return `CREATE TABLE IF NOT EXISTS ${table} (${cols.join(", ")})`;
49
+ return `CREATE TABLE IF NOT EXISTS ${quoteIdent(table)} (${cols.join(", ")})`;
49
50
  }
50
51
 
51
52
  /** Column definition for ALTER TABLE ADD COLUMN. No PRIMARY KEY / AUTOINCREMENT.
52
53
  * NOT NULL is only emitted alongside a DEFAULT (SQLite can't add a bare NOT NULL to
53
54
  * a populated table); a DEFAULT alone backfills existing rows. */
54
55
  export function addColumnSql(name: string, f: FieldDef): string {
55
- let s = `${name} ${sqlType(f)}`;
56
+ let s = `${quoteIdent(name)} ${sqlType(f)}`;
56
57
  if (f.notNull && f.default !== undefined) s += " NOT NULL";
57
58
  s += defaultSql(f);
58
59
  return s;
@@ -70,7 +71,7 @@ export function indexStatements(table: string, def: { fields: EntityFields }): s
70
71
  for (const [col, f] of Object.entries(def.fields)) {
71
72
  if (!f.unique && !f.index) continue;
72
73
  const kind = f.unique ? "UNIQUE INDEX" : "INDEX";
73
- out.push(`CREATE ${kind} IF NOT EXISTS ${indexName(table, col)} ON ${table} (${col})`);
74
+ out.push(`CREATE ${kind} IF NOT EXISTS ${quoteIdent(indexName(table, col))} ON ${quoteIdent(table)} (${quoteIdent(col)})`);
74
75
  }
75
76
  return out;
76
77
  }
@@ -13,6 +13,7 @@ import { warmup, type AclContext } from "./acl";
13
13
  import { BadRequest, Forbidden } from "./errors";
14
14
  import { enqueueTask, type TaskMap } from "./outbox";
15
15
  import { createMail } from "./mail";
16
+ import { createQueue } from "./queue";
16
17
  import type { Driver } from "./driver";
17
18
  import type { Kv } from "./kv";
18
19
  import type { Files } from "../sdk/files";
@@ -91,6 +92,7 @@ export async function dispatch(
91
92
  identity: acl.identity,
92
93
  tasks: tasksFacade(driver, () => enqueued++),
93
94
  mail: createMail(env, kv),
95
+ queue: createQueue(env),
94
96
  };
95
97
 
96
98
  const result =
@@ -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}`);