@pramen/server 0.0.12 → 0.0.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/worker.ts CHANGED
@@ -8,10 +8,12 @@ import { authorizeTenant, HmacStrategy, isAdmin, JwksStrategy, resolveIdentity,
8
8
  import { dispatch, tasksFacade, bindTasks } from "./runtime/dispatch";
9
9
  import { ensureOutbox, drainOutbox, listTasks } from "./runtime/outbox";
10
10
  import { createMail } from "./runtime/mail";
11
+ import { createQueue, type QueueProducerBinding } from "./runtime/queue";
12
+ import { dispatchQueueBatch, type QueueBatch, type QueueContext } from "./runtime/queue-consumer";
11
13
  import { migrate } from "./runtime/migrate";
12
14
  import { compileAcl } from "./runtime/acl";
13
15
  import { Db } from "./runtime/db";
14
- import { D1Driver, type Driver } from "./runtime/driver";
16
+ import { D1Driver, type D1SessionStart, type Driver } from "./runtime/driver";
15
17
  import { toResponse } from "./runtime/errors";
16
18
  import { Kv } from "./runtime/kv";
17
19
  import { listDOs, partitionDoName } from "./runtime/registry";
@@ -45,12 +47,37 @@ export interface Env {
45
47
  CORS_ORIGINS?: string;
46
48
  /** "true" to apply destructive schema migrations on the D1 path. Off by default. */
47
49
  PRAMEN_ALLOW_DESTRUCTIVE?: string;
50
+ /** Default store for /rpc when no `x-pramen-store` header is sent: `"d1"` runs the
51
+ * Worker+D1 path by default (requires DB bound); `"do"` (the default) routes to the
52
+ * per-tenant Durable Object. The header still overrides per-request. /live always
53
+ * needs the DO regardless of this setting. */
54
+ PRAMEN_STORE?: string;
55
+ /** Cloudflare Queues producer binding for ctx.queue (declared in oblaka.ts). Optional —
56
+ * ctx.queue discovers any producer binding by name; this just types the common one. */
57
+ JOBS?: QueueProducerBinding;
48
58
  }
49
59
 
50
60
  /** The secret used to sign/verify file tokens — a dedicated FILES_SECRET if set,
51
61
  * else AUTH_SECRET (so HS256 setups work out of the box). */
52
62
  const filesSecret = (env: Env): string => env.FILES_SECRET || env.AUTH_SECRET;
53
63
 
64
+ /** Request/response header carrying the D1 session bookmark for read-your-writes: a
65
+ * client echoes the last response's value on its next request, anchoring a fresh
66
+ * session at that write so it reads its own writes (even off a lagging replica). */
67
+ const D1_BOOKMARK_HEADER = "x-pramen-d1-bookmark";
68
+
69
+ /** Decide whether an /rpc request runs on the D1 store. **Live queries ALWAYS use the
70
+ * DO** (they need a single writer + a socket host), regardless of header or default —
71
+ * so enabling `PRAMEN_STORE=d1` never silently breaks `/live`. Otherwise an explicit
72
+ * `x-pramen-store` header wins (`d1`/`do`), then the `PRAMEN_STORE` default. Pure +
73
+ * exported for unit testing. */
74
+ export function useD1Store(opts: { storeHeader: string | null; isLive: boolean; defaultStore: string | undefined }): boolean {
75
+ if (opts.isLive) return false; // live is DO-only — never the D1 path
76
+ if (opts.storeHeader === "d1") return true;
77
+ if (opts.storeHeader === "do") return false;
78
+ return opts.defaultStore === "d1";
79
+ }
80
+
54
81
  const json = (body: unknown, status = 200) => Response.json(body, { status });
55
82
  const forbidden = (what: string) => json({ ok: false, error: `access denied: ${what}`, code: "forbidden" }, 403);
56
83
  const badRequest = (msg: string) => json({ ok: false, error: msg, code: "bad_request" }, 400);
@@ -65,7 +92,10 @@ function corsHeaders(origin: string | null, env: Env): Record<string, string> {
65
92
  return {
66
93
  "access-control-allow-origin": allow.includes("*") ? "*" : origin,
67
94
  "access-control-allow-methods": "GET, POST, OPTIONS",
68
- "access-control-allow-headers": "content-type, authorization, x-pramen-tenant, x-pramen-store",
95
+ "access-control-allow-headers": "content-type, authorization, x-pramen-tenant, x-pramen-store, x-pramen-d1-bookmark",
96
+ // Expose the D1 read-your-writes bookmark so a browser client can read it off the
97
+ // response and carry it forward on the next request.
98
+ "access-control-expose-headers": "x-pramen-d1-bookmark",
69
99
  vary: "origin",
70
100
  };
71
101
  }
@@ -83,6 +113,15 @@ function withCors(res: Response, cors: Record<string, string>): Response {
83
113
  * `tenant` for the default partition (so `idFromName(tenant)` is byte-for-byte unchanged
84
114
  * — backward-compat) and `${tenant}:${partition}` for any other partition. */
85
115
  function partitionStubFor(env: Env, tenant: string, partition: string = DEFAULT_PARTITION): DurableObjectStub {
116
+ // Fail with a clear message rather than a cryptic `Cannot read 'get' of undefined`
117
+ // when the Durable Object isn't bound (e.g. a D1-only deployment that fell through to
118
+ // the DO path). The Worker RPC surface depends on this binding existing.
119
+ if (!env.PRAMEN) {
120
+ throw new Error(
121
+ "pramen: no Durable Object bound (PRAMEN). Pin the D1 store per request with the " +
122
+ "'x-pramen-store: d1' header (or set PRAMEN_STORE=d1), or bind the PramenDO.",
123
+ );
124
+ }
86
125
  return env.PRAMEN.get(env.PRAMEN.idFromName(partitionDoName(tenant, partition)));
87
126
  }
88
127
 
@@ -151,21 +190,24 @@ export function makeWorker(app: PramenApp) {
151
190
  const files = createFiles({ tenant: "main", secret: filesSecret(env), adapter: new R2Adapter(env.FILES) });
152
191
  const db = new Db(driver, { acl: d1Acl, identity, system: true, schema: app.schema, suppressTriggers: true }, app.schema);
153
192
  const kv = new Kv(env.KV);
154
- return { db, kv, files, env: env as unknown as Record<string, unknown>, identity, tasks: tasksFacade(driver), mail: createMail(env as unknown as Record<string, unknown>, kv) };
193
+ return { db, kv, files, env: env as unknown as Record<string, unknown>, identity, tasks: tasksFacade(driver), mail: createMail(env as unknown as Record<string, unknown>, kv), queue: createQueue(env as unknown as Record<string, unknown>) };
155
194
  };
156
195
 
157
196
  /** Drain the D1 outbox in the Worker (no DO/alarm on this path) — called by the
158
197
  * /admin/tasks/drain route with `x-pramen-store: d1`, and by `scheduled()` (Cron). */
159
198
  const drainD1 = async (env: Env): Promise<unknown> => {
160
199
  if (!env.DB) throw new Error("D1 store is not configured");
161
- const driver = new D1Driver(env.DB);
200
+ // The drain reads due tasks then writes their status — pin the primary so it sees
201
+ // and updates current outbox state (not a lagging replica).
202
+ const driver = new D1Driver(env.DB, { start: "first-primary" });
162
203
  await ensureD1Migrated(driver, env.PRAMEN_ALLOW_DESTRUCTIVE === "true");
163
204
  return drainOutbox(driver, bindTasks(app.tasks, d1TaskCtx(driver, env)), Date.now());
164
205
  };
165
206
 
166
207
  const listD1Tasks = async (env: Env, status?: string, limit?: number): Promise<unknown> => {
167
208
  if (!env.DB) throw new Error("D1 store is not configured");
168
- const driver = new D1Driver(env.DB);
209
+ // Inspection listing pin the primary so it reflects current outbox state.
210
+ const driver = new D1Driver(env.DB, { start: "first-primary" });
169
211
  await ensureD1Migrated(driver, env.PRAMEN_ALLOW_DESTRUCTIVE === "true");
170
212
  return listTasks(driver, { status, limit });
171
213
  };
@@ -344,23 +386,42 @@ export function makeWorker(app: PramenApp) {
344
386
  const tenant = req.headers.get("x-pramen-tenant") ?? "main";
345
387
  if (!authorizeTenant(identity, tenant)) return withCors(forbidden(`tenant '${tenant}'`), cors);
346
388
 
347
- // --- Worker + D1 (no DO): the same schema/ACL/read engine over a D1 binding,
348
- // selected per-request via `x-pramen-store: d1`. RPC only live queries need the
349
- // DO (single writer + a socket host). This proof uses ONE shared D1 database
350
- // across tenants; a real product would add a tenant column or a per-tenant DB.
351
- if (req.headers.get("x-pramen-store") === "d1") {
389
+ // --- Worker + D1 (no DO): the same schema/ACL/read engine over a D1 binding.
390
+ // Selected per-request via `x-pramen-store: d1`, OR as the app-wide default when
391
+ // PRAMEN_STORE=d1 (the header still overrides: `x-pramen-store: do` forces the DO).
392
+ // RPC only live queries need the DO (single writer + a socket host). This proof
393
+ // uses ONE shared D1 database across tenants; a real product would add a tenant
394
+ // column or a per-tenant DB.
395
+ const storeHeader = req.headers.get("x-pramen-store");
396
+ const useD1 = useD1Store({ storeHeader, isLive, defaultStore: env.PRAMEN_STORE });
397
+ if (useD1) {
352
398
  if (!env.DB) return badRequest("D1 store is not configured");
353
- if (isLive) return badRequest("live queries require the default (DO) store");
399
+ // (isLive is excluded by useD1Store — live always routes to the DO below.)
354
400
  const name = url.pathname.replace(/^\/rpc\//, "");
355
401
  let input: unknown;
356
402
  if (request.method === "POST") input = await request.json().catch(() => undefined);
357
- const driver = new D1Driver(env.DB);
403
+
404
+ // Pick where the D1 session may start its first read. A client-supplied bookmark
405
+ // wins (read-your-writes); otherwise default by handler kind: a mutation pins the
406
+ // primary so its reads see current data, a query may begin at the nearest replica.
407
+ const inboundBookmark = req.headers.get(D1_BOOKMARK_HEADER);
408
+ const kind = app.handlers[name]?.kind;
409
+ let start: D1SessionStart;
410
+ if (inboundBookmark) start = inboundBookmark;
411
+ else if (kind === "mutation") start = "first-primary";
412
+ else start = "first-unconstrained";
413
+
414
+ const driver = new D1Driver(env.DB, { start });
358
415
  const files = createFiles({ tenant, secret: filesSecret(env), adapter: new R2Adapter(env.FILES) });
359
416
  const envBag = env as unknown as Record<string, unknown>;
360
417
  try {
361
418
  await ensureD1Migrated(driver, env.PRAMEN_ALLOW_DESTRUCTIVE === "true");
362
419
  const { result } = await dispatch(app.handlers, app.schema, driver, new Kv(env.KV), files, envBag, { acl: d1Acl, identity }, name, input);
363
- return withCors(json({ ok: true, result }), cors);
420
+ const res = json({ ok: true, result });
421
+ // Thread the session's latest bookmark back so the client can read its own writes.
422
+ const bookmark = driver.getBookmark();
423
+ if (bookmark) res.headers.set(D1_BOOKMARK_HEADER, bookmark);
424
+ return withCors(res, cors);
364
425
  } catch (err) {
365
426
  const { status, body } = toResponse(err);
366
427
  return withCors(json(body, status), cors);
@@ -384,6 +445,21 @@ export function makeWorker(app: PramenApp) {
384
445
  else headers.delete("x-pramen-identity");
385
446
  headers.set("x-pramen-partition", partition);
386
447
 
448
+ // Routed to the DO but no DO is bound — return a clear, actionable error instead of
449
+ // crashing the whole RPC surface. (A D1-only deployment should pin the D1 store with
450
+ // the `x-pramen-store: d1` header; the `PRAMEN_STORE` env default can be dropped by
451
+ // some adapters' env proxies, so the header is the reliable way to pin it.)
452
+ if (!env.PRAMEN) {
453
+ return withCors(
454
+ badRequest(
455
+ isLive
456
+ ? "live queries require a Durable Object, but no PRAMEN binding is configured"
457
+ : "no Durable Object (PRAMEN) is bound — pin the D1 store with the 'x-pramen-store: d1' header (or bind the DO)",
458
+ ),
459
+ cors,
460
+ );
461
+ }
462
+
387
463
  const stub = partitionStubFor(env, tenant, partition);
388
464
  // WebSocket upgrades (101) must be returned untouched; only add CORS to HTTP.
389
465
  const res = await stub.fetch(new Request(req, { headers }));
@@ -395,5 +471,21 @@ export function makeWorker(app: PramenApp) {
395
471
  async scheduled(_event: unknown, env: Env): Promise<void> {
396
472
  if (env.DB) await drainD1(env);
397
473
  },
474
+
475
+ // Cloudflare Queues consumer entry: routes a batch to the matching `app.queues`
476
+ // handler (ACK on success / RETRY on throw, per message). A consumer is Worker-level
477
+ // (no tenant DO): its ctx carries env/kv/mail/queue + callPrivileged to reach a DO.
478
+ async queue(batch: QueueBatch, env: Env): Promise<void> {
479
+ const envBag = env as unknown as Record<string, unknown>;
480
+ const kv = new Kv(env.KV);
481
+ const ctx: QueueContext = {
482
+ env: envBag,
483
+ kv,
484
+ mail: createMail(envBag, kv),
485
+ queue: createQueue(envBag),
486
+ callPrivileged: (opts) => callPrivileged(env, opts),
487
+ };
488
+ await dispatchQueueBatch(app.queues ?? {}, ctx, batch);
489
+ },
398
490
  };
399
491
  }