@pramen/server 0.0.51 → 0.0.53

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -12,6 +12,7 @@ export { Kv, denySession, allowSession, isSessionDenied } from "./runtime/kv";
12
12
  export type { FileRef, Files, SignUploadOpts, SignDownloadOpts, HeadResult } from "./sdk/files";
13
13
  export { R2Adapter, MemoryAdapter, createFiles, handleFileRequest } from "./runtime/storage";
14
14
  export { signToken, verifyToken, isUsableSecret, resolveSecret, MIN_TOKEN_SECRET_LEN } from "./runtime/token";
15
+ export { HmacStrategy, JwksStrategy, type VerifyStrategy, type VerifyOptions } from "./auth";
15
16
  export type { ExpiringToken } from "./runtime/token";
16
17
  export type { StorageAdapter, PutResult, GetResult } from "./runtime/storage";
17
18
  export { Mail, CloudflareEmailAdapter, KvMailAdapter, MemoryMailAdapter, UnconfiguredMailAdapter, createMail } from "./runtime/mail";
package/dist/index.js CHANGED
@@ -21,6 +21,10 @@ export { R2Adapter, MemoryAdapter, createFiles, handleFileRequest } from "./runt
21
21
  // page-preview links. Exported so an app (or @pramen/cms) can mint its own capability url
22
22
  // without a second signing implementation.
23
23
  export { signToken, verifyToken, isUsableSecret, resolveSecret, MIN_TOKEN_SECRET_LEN } from "./runtime/token";
24
+ // Verify strategies live on the AUTHORING entry, not `/worker`: they carry no
25
+ // `cloudflare:workers` import, and @pramen/auth's OIDC flow verifies a provider's RS256 ID
26
+ // token with the same JWKS cache (and its key-rotation handling) the Worker uses.
27
+ export { HmacStrategy, JwksStrategy } from "./auth";
24
28
  // --- mail (ctx.mail) ---
25
29
  export { Mail, CloudflareEmailAdapter, KvMailAdapter, MemoryMailAdapter, UnconfiguredMailAdapter, createMail } from "./runtime/mail";
26
30
  // --- queue (ctx.queue — Cloudflare Queues) ---
@@ -83,6 +83,15 @@ export declare class D1Driver implements Driver {
83
83
  start?: D1SessionStart;
84
84
  });
85
85
  exec(sql: string, params: CellValue[]): Promise<DriverRow[]>;
86
+ /** How many write statements this session has auto-committed.
87
+ *
88
+ * There is no rollback here (see the ATOMICITY LIMIT above), so a mutation that throws
89
+ * midway leaves whatever it had already written. Counting the writes lets the caller say
90
+ * so out loud instead of surfacing a half-applied mutation as an ordinary 500 — the
91
+ * difference between "the request failed" and "the request failed and your data is now
92
+ * in a state no code path intended". */
93
+ writtenCount(): number;
94
+ private writes;
86
95
  /** The session's latest bookmark (null before any query). Threaded back to the client
87
96
  * via the `x-pramen-d1-bookmark` response header so a subsequent request can anchor a
88
97
  * fresh session at it and read its own writes. */
@@ -59,6 +59,9 @@ export class DoSqliteDriver {
59
59
  return this.storage.transaction(fn);
60
60
  }
61
61
  }
62
+ /** Does this statement mutate? Used only to notice a partial write after a failed
63
+ * mutation — deliberately coarse: over-reporting a warning is harmless, missing one is not. */
64
+ const WRITE_SQL = /^\s*(insert|update|delete|replace|create|drop|alter)\b/i;
62
65
  /** D1 — SQLite over RPC. Async by nature.
63
66
  *
64
67
  * Read replicas (Sessions API): every D1Driver opens ONE `db.withSession(start)` and
@@ -80,10 +83,23 @@ export class D1Driver {
80
83
  this.session = db.withSession(opts?.start ?? "first-unconstrained");
81
84
  }
82
85
  async exec(sql, params) {
86
+ if (WRITE_SQL.test(sql))
87
+ this.writes++;
83
88
  const stmt = params.length ? this.session.prepare(sql).bind(...params) : this.session.prepare(sql);
84
89
  const { results } = await stmt.all();
85
90
  return results ?? [];
86
91
  }
92
+ /** How many write statements this session has auto-committed.
93
+ *
94
+ * There is no rollback here (see the ATOMICITY LIMIT above), so a mutation that throws
95
+ * midway leaves whatever it had already written. Counting the writes lets the caller say
96
+ * so out loud instead of surfacing a half-applied mutation as an ordinary 500 — the
97
+ * difference between "the request failed" and "the request failed and your data is now
98
+ * in a state no code path intended". */
99
+ writtenCount() {
100
+ return this.writes;
101
+ }
102
+ writes = 0;
87
103
  /** The session's latest bookmark (null before any query). Threaded back to the client
88
104
  * via the `x-pramen-d1-bookmark` response header so a subsequent request can anchor a
89
105
  * fresh session at it and read its own writes. */
@@ -1,3 +1,3 @@
1
- export { createPramen, type PramenApp, type PublicRoute, type Env, type DoEnv } from "./pramen";
1
+ export { createPramen, type PramenApp, type PublicRoute, type RouteContext, type Env, type DoEnv } from "./pramen";
2
2
  export { makeWorker, callPrivileged } from "./worker";
3
3
  export { pramenDO, PramenDOBase } from "./durable-object";
@@ -3,6 +3,8 @@
3
3
  // the main authoring entry: tools that merely load an app.ts to read its schema
4
4
  // (the CLI, tests, codegen) import from "@pramen/server" and never drag in the DO
5
5
  // runtime. A Worker's entry imports createPramen from here.
6
+ // `RouteContext` stays exported (it predates nothing — @pramen/auth's OIDC routes are
7
+ // typed against it); `WorkerOpts` goes with the reverted basePath.
6
8
  export { createPramen } from "./pramen";
7
9
  export { makeWorker, callPrivileged } from "./worker";
8
10
  export { pramenDO, PramenDOBase } from "./durable-object";
package/dist/worker.d.ts CHANGED
@@ -60,6 +60,22 @@ export declare function useD1Store(opts: {
60
60
  isLive: boolean;
61
61
  defaultStore: string | undefined;
62
62
  }): boolean;
63
+ /** Should we warn that no Cron trigger seems to be wired?
64
+ *
65
+ * The DO store self-drains via an alarm; the D1 store has none, so a DELAYED task — a
66
+ * scheduled publish, a retry backoff — runs only when a Cron trigger calls
67
+ * `createPramen().scheduled`. Forgetting that is silent: the row simply never goes live,
68
+ * and nothing anywhere says why.
69
+ *
70
+ * A request-tail drain that leaves something due in the FUTURE is exactly the situation
71
+ * that depends on the cron, so it is the moment to say so. Once a cron actually fires, the
72
+ * question is settled and we never warn again. Exported for tests — the decision is pure. */
73
+ export declare function shouldWarnMissingCron(opts: {
74
+ cronSeen: boolean;
75
+ warned: boolean;
76
+ nextRunAt: number | null;
77
+ now: number;
78
+ }): boolean;
63
79
  /** Forward a privileged mutation into a tenant's DO from a public route. The
64
80
  * synthetic identity (default `["admin"]`) is trusted because the call originates
65
81
  * in the Worker — the same internal mechanism the admin endpoints use. Returns the
package/dist/worker.js CHANGED
@@ -13,7 +13,7 @@ import { migrate } from "./runtime/migrate";
13
13
  import { compileAcl } from "./runtime/acl";
14
14
  import { Db } from "./runtime/db";
15
15
  import { D1Driver } from "./runtime/driver";
16
- import { toResponse } from "./runtime/errors";
16
+ import { BadRequest, Forbidden, toResponse } from "./runtime/errors";
17
17
  import { Kv, isSessionDenied } from "./runtime/kv";
18
18
  import { listDOs, partitionDoName } from "./runtime/registry";
19
19
  import { createFiles, handleFileRequest, handleMediaRequest, R2Adapter } from "./runtime/storage";
@@ -43,6 +43,21 @@ export function useD1Store(opts) {
43
43
  return false;
44
44
  return opts.defaultStore === "d1";
45
45
  }
46
+ /** Should we warn that no Cron trigger seems to be wired?
47
+ *
48
+ * The DO store self-drains via an alarm; the D1 store has none, so a DELAYED task — a
49
+ * scheduled publish, a retry backoff — runs only when a Cron trigger calls
50
+ * `createPramen().scheduled`. Forgetting that is silent: the row simply never goes live,
51
+ * and nothing anywhere says why.
52
+ *
53
+ * A request-tail drain that leaves something due in the FUTURE is exactly the situation
54
+ * that depends on the cron, so it is the moment to say so. Once a cron actually fires, the
55
+ * question is settled and we never warn again. Exported for tests — the decision is pure. */
56
+ export function shouldWarnMissingCron(opts) {
57
+ if (opts.cronSeen || opts.warned)
58
+ return false;
59
+ return opts.nextRunAt != null && opts.nextRunAt > opts.now;
60
+ }
46
61
  const json = (body, status = 200) => Response.json(body, { status });
47
62
  const forbidden = (what) => json({ ok: false, error: `access denied: ${what}`, code: "forbidden" }, 403);
48
63
  const badRequest = (msg) => json({ ok: false, error: msg, code: "bad_request" }, 400);
@@ -155,6 +170,47 @@ export function makeWorker(app) {
155
170
  }
156
171
  };
157
172
  let d1Ready;
173
+ /** Run one handler against the D1 store, in the Worker. The request path and the
174
+ * PRIVILEGED path (routes, which have no ctx.db) both come through here, so the two
175
+ * cannot drift on migration, bootstrap, the multi-tenant guard or the outbox drain.
176
+ *
177
+ * Returns the `{ ok, result }` envelope rather than a Response so each caller can add
178
+ * what only it needs — CORS and the session bookmark for a request, nothing for an
179
+ * internal call. */
180
+ const dispatchD1 = async (env, ctx, opts) => {
181
+ if (!env.DB)
182
+ throw new BadRequest("D1 store is not configured");
183
+ // Same COMMINGLING GUARD as the request path: shared D1 has no tenant column, so a
184
+ // non-`main` tenant would mix rows unless the operator opted in explicitly.
185
+ if (opts.tenant !== "main" && env.PRAMEN_D1_ALLOW_MULTITENANT !== "true") {
186
+ throw new Forbidden(`D1 store for tenant '${opts.tenant}' (shared D1 has no tenant isolation — set PRAMEN_D1_ALLOW_MULTITENANT=true to allow)`);
187
+ }
188
+ const driver = new D1Driver(env.DB, { start: opts.start });
189
+ const files = createFiles({ tenant: opts.tenant, secret: filesSecret(env), adapter: new R2Adapter(env.FILES) });
190
+ await ensureD1Migrated(driver, env.PRAMEN_ALLOW_DESTRUCTIVE === "true");
191
+ let dispatched;
192
+ try {
193
+ dispatched = await dispatch(app.handlers, app.schema, driver, new Kv(env.KV), files, envBag(env), { acl: d1Acl, identity: opts.identity, tenant: opts.tenant, store: "d1" }, opts.name, opts.input);
194
+ }
195
+ catch (err) {
196
+ // D1 has no interactive transactions, so `transaction(fn)` runs `fn` as-is and a
197
+ // mutation that throws midway keeps whatever it already wrote. Say so: a partially
198
+ // applied mutation looks exactly like an ordinary 500 in the logs, and the difference
199
+ // — data left in a state no code path intended — is the whole point.
200
+ const written = driver.writtenCount();
201
+ if (written > 0) {
202
+ console.error(`pramen: '${opts.name}' failed on the D1 store AFTER ${written} write statement(s) had committed. ` +
203
+ `D1 has no interactive transactions, so this mutation is PARTIALLY APPLIED and will not roll back. ` +
204
+ `Use the Durable Object store if this mutation must be atomic.`);
205
+ }
206
+ throw err;
207
+ }
208
+ const { result, enqueued } = dispatched;
209
+ // Drain in the request tail so an enqueued task does not wait for the next Cron tick.
210
+ if (enqueued > 0 && ctx)
211
+ ctx.waitUntil(drainD1(env));
212
+ return { driver, result: result };
213
+ };
158
214
  const ensureD1Migrated = (driver, allowDestructive) => {
159
215
  if (!d1Ready) {
160
216
  d1Ready = migrate(driver, app.schema, { allowDestructive })
@@ -183,14 +239,29 @@ export function makeWorker(app) {
183
239
  };
184
240
  /** Drain the D1 outbox in the Worker (no DO/alarm on this path) — called by the
185
241
  * /admin/tasks/drain route with `x-pramen-store: d1`, and by `scheduled()` (Cron). */
186
- const drainD1 = async (env) => {
242
+ // Whether a Cron trigger has ever driven a drain in this isolate, and whether we have
243
+ // already said it looks missing. See `shouldWarnMissingCron`.
244
+ let cronSeen = false;
245
+ let warnedNoCron = false;
246
+ const drainD1 = async (env, source = "request") => {
187
247
  if (!env.DB)
188
248
  throw new Error("D1 store is not configured");
249
+ if (source === "cron")
250
+ cronSeen = true;
189
251
  // The drain reads due tasks then writes their status — pin the primary so it sees
190
252
  // and updates current outbox state (not a lagging replica).
191
253
  const driver = new D1Driver(env.DB, { start: "first-primary" });
192
254
  await ensureD1Migrated(driver, env.PRAMEN_ALLOW_DESTRUCTIVE === "true");
193
- return drainOutbox(driver, bindTasks(app.tasks, d1TaskCtx(driver, env)), Date.now());
255
+ const result = await drainOutbox(driver, bindTasks(app.tasks, d1TaskCtx(driver, env)), Date.now());
256
+ if (source === "request" && shouldWarnMissingCron({ cronSeen, warned: warnedNoCron, nextRunAt: result.nextRunAt, now: Date.now() })) {
257
+ warnedNoCron = true;
258
+ const inSeconds = Math.round(((result.nextRunAt ?? 0) - Date.now()) / 1000);
259
+ console.warn(`pramen: a task is queued on the D1 store to run in ~${inSeconds}s, but no Cron trigger has drained this Worker. ` +
260
+ `The D1 store has no Durable Object alarm, so a DELAYED task (a scheduled publish, a retry) runs ONLY when a Cron ` +
261
+ `trigger calls createPramen().scheduled. Add \`triggers: { crons: ["* * * * *"] }\` to your Worker config, or drain ` +
262
+ `manually via POST /admin/tasks/drain. This warning appears once per isolate and stops once a Cron drain is seen.`);
263
+ }
264
+ return result;
194
265
  };
195
266
  const listD1Tasks = async (env, status, limit) => {
196
267
  if (!env.DB)
@@ -222,7 +293,35 @@ export function makeWorker(app) {
222
293
  // signature-authed webhook can live outside the JWT-gated /rpc surface.
223
294
  for (const r of app.routes ?? []) {
224
295
  if (request.method === r.method && url.pathname === r.path) {
225
- const routeCtx = { callPrivileged: (opts) => callPrivileged(env, opts) };
296
+ // A public route has no ctx.db, so it reaches a handler through here. On the D1
297
+ // store there is no Durable Object to forward to — the engine runs in THIS Worker
298
+ // — so dispatch locally instead. Without this, everything built on a pre-auth
299
+ // route (a signed preview link, the sitemap) was DO-only, and the CMS had to
300
+ // refuse to mint preview links on D1 rather than hand out a dead one.
301
+ const routeCtx = {
302
+ callPrivileged: async (opts) => {
303
+ const store = useD1Store({ storeHeader: request.headers.get("x-pramen-store"), isLive: false, defaultStore: env.PRAMEN_STORE });
304
+ if (!store)
305
+ return callPrivileged(env, opts);
306
+ try {
307
+ const { result } = await dispatchD1(env, ctx, {
308
+ name: opts.name,
309
+ input: opts.input ?? null,
310
+ tenant: opts.tenant ?? "main",
311
+ // The same synthetic identity the DO path sends in `x-pramen-identity`.
312
+ identity: { roles: opts.roles ?? ["admin"] },
313
+ // A privileged call may write (a redeemed one-time token), so pin the
314
+ // primary rather than risk a read-modify-write off a lagging replica.
315
+ start: "first-primary",
316
+ });
317
+ return json({ ok: true, result });
318
+ }
319
+ catch (err) {
320
+ const { status, body } = toResponse(err);
321
+ return json(body, status);
322
+ }
323
+ },
324
+ };
226
325
  return r.handler(request, envBag(env), routeCtx);
227
326
  }
228
327
  }
@@ -323,7 +422,7 @@ export function makeWorker(app) {
323
422
  return forbidden("tasks");
324
423
  if (request.headers.get("x-pramen-store") === "d1") {
325
424
  try {
326
- return withCors(json({ ok: true, result: await drainD1(env) }), cors);
425
+ return withCors(json({ ok: true, result: await drainD1(env, "admin") }), cors);
327
426
  }
328
427
  catch (err) {
329
428
  const { status, body } = toResponse(err);
@@ -420,21 +519,10 @@ export function makeWorker(app) {
420
519
  start = inboundBookmark;
421
520
  else
422
521
  start = "first-unconstrained";
423
- const driver = new D1Driver(env.DB, { start });
424
- const files = createFiles({ tenant, secret: filesSecret(env), adapter: new R2Adapter(env.FILES) });
425
- const bag = envBag(env);
426
522
  try {
427
- await ensureD1Migrated(driver, env.PRAMEN_ALLOW_DESTRUCTIVE === "true");
428
523
  // `tenant` matters here: a handler minting a tenant-scoped capability (a signed
429
524
  // preview link) would otherwise stamp it "main" while reading acme's rows.
430
- const { result, enqueued } = await dispatch(app.handlers, app.schema, driver, new Kv(env.KV), files, bag, { acl: d1Acl, identity, tenant, store: "d1" }, name, input);
431
- // Kick an immediate drain in the request tail when this handler enqueued tasks
432
- // (e.g. sendMagicLinkEmail). Without this, tasks wait for the next Cron trigger
433
- // — up to a full minute. `waitUntil` lets the response return now while the
434
- // drain runs; the Cron trigger remains the safety net for delayed / retried
435
- // tasks that no request happens to coincide with.
436
- if (enqueued > 0)
437
- ctx.waitUntil(drainD1(env));
525
+ const { driver, result } = await dispatchD1(env, ctx, { name, input, tenant, identity, start });
438
526
  const res = json({ ok: true, result });
439
527
  // Thread the session's latest bookmark back so the client can read its own writes.
440
528
  const bookmark = driver.getBookmark();
@@ -492,7 +580,7 @@ export function makeWorker(app) {
492
580
  // so it needs no cron). Wire a `[triggers] crons` in wrangler/oblaka to call this.
493
581
  async scheduled(_event, env) {
494
582
  if (env.DB)
495
- await drainD1(env);
583
+ await drainD1(env, "cron");
496
584
  },
497
585
  // Cloudflare Queues consumer entry: routes a batch to the matching `app.queues`
498
586
  // handler (ACK on success / RETRY on throw, per message). A consumer is Worker-level
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pramen/server",
3
- "version": "0.0.51",
3
+ "version": "0.0.53",
4
4
  "description": "pramen server runtime \u2014 schema, ACL, ORM, live queries, files, and the createPramen(app) factory for Cloudflare Workers + Durable Objects.",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/index.ts CHANGED
@@ -87,6 +87,10 @@ export { R2Adapter, MemoryAdapter, createFiles, handleFileRequest } from "./runt
87
87
  // page-preview links. Exported so an app (or @pramen/cms) can mint its own capability url
88
88
  // without a second signing implementation.
89
89
  export { signToken, verifyToken, isUsableSecret, resolveSecret, MIN_TOKEN_SECRET_LEN } from "./runtime/token";
90
+ // Verify strategies live on the AUTHORING entry, not `/worker`: they carry no
91
+ // `cloudflare:workers` import, and @pramen/auth's OIDC flow verifies a provider's RS256 ID
92
+ // token with the same JWKS cache (and its key-rotation handling) the Worker uses.
93
+ export { HmacStrategy, JwksStrategy, type VerifyStrategy, type VerifyOptions } from "./auth";
90
94
  export type { ExpiringToken } from "./runtime/token";
91
95
  export type { StorageAdapter, PutResult, GetResult } from "./runtime/storage";
92
96
 
@@ -107,6 +107,10 @@ export class DoSqliteDriver implements Driver {
107
107
  * A bookmark always wins over a constraint when one is supplied. */
108
108
  export type D1SessionStart = "first-primary" | "first-unconstrained" | (string & {});
109
109
 
110
+ /** Does this statement mutate? Used only to notice a partial write after a failed
111
+ * mutation — deliberately coarse: over-reporting a warning is harmless, missing one is not. */
112
+ const WRITE_SQL = /^\s*(insert|update|delete|replace|create|drop|alter)\b/i;
113
+
110
114
  /** D1 — SQLite over RPC. Async by nature.
111
115
  *
112
116
  * Read replicas (Sessions API): every D1Driver opens ONE `db.withSession(start)` and
@@ -129,11 +133,24 @@ export class D1Driver implements Driver {
129
133
  }
130
134
 
131
135
  async exec(sql: string, params: CellValue[]): Promise<DriverRow[]> {
136
+ if (WRITE_SQL.test(sql)) this.writes++;
132
137
  const stmt = params.length ? this.session.prepare(sql).bind(...params) : this.session.prepare(sql);
133
138
  const { results } = await stmt.all<DriverRow>();
134
139
  return results ?? [];
135
140
  }
136
141
 
142
+ /** How many write statements this session has auto-committed.
143
+ *
144
+ * There is no rollback here (see the ATOMICITY LIMIT above), so a mutation that throws
145
+ * midway leaves whatever it had already written. Counting the writes lets the caller say
146
+ * so out loud instead of surfacing a half-applied mutation as an ordinary 500 — the
147
+ * difference between "the request failed" and "the request failed and your data is now
148
+ * in a state no code path intended". */
149
+ writtenCount(): number {
150
+ return this.writes;
151
+ }
152
+ private writes = 0;
153
+
137
154
  /** The session's latest bookmark (null before any query). Threaded back to the client
138
155
  * via the `x-pramen-d1-bookmark` response header so a subsequent request can anchor a
139
156
  * fresh session at it and read its own writes. */
@@ -4,6 +4,8 @@
4
4
  // (the CLI, tests, codegen) import from "@pramen/server" and never drag in the DO
5
5
  // runtime. A Worker's entry imports createPramen from here.
6
6
 
7
- export { createPramen, type PramenApp, type PublicRoute, type Env, type DoEnv } from "./pramen";
7
+ // `RouteContext` stays exported (it predates nothing @pramen/auth's OIDC routes are
8
+ // typed against it); `WorkerOpts` goes with the reverted basePath.
9
+ export { createPramen, type PramenApp, type PublicRoute, type RouteContext, type Env, type DoEnv } from "./pramen";
8
10
  export { makeWorker, callPrivileged } from "./worker";
9
11
  export { pramenDO, PramenDOBase } from "./durable-object";
package/src/worker.ts CHANGED
@@ -16,7 +16,7 @@ import { migrate } from "./runtime/migrate";
16
16
  import { compileAcl } from "./runtime/acl";
17
17
  import { Db } from "./runtime/db";
18
18
  import { D1Driver, type D1SessionStart, type Driver } from "./runtime/driver";
19
- import { toResponse } from "./runtime/errors";
19
+ import { BadRequest, Forbidden, toResponse } from "./runtime/errors";
20
20
  import { Kv, isSessionDenied } from "./runtime/kv";
21
21
  import { listDOs, partitionDoName } from "./runtime/registry";
22
22
  import { createFiles, handleFileRequest, handleMediaRequest, R2Adapter } from "./runtime/storage";
@@ -100,6 +100,21 @@ export function useD1Store(opts: { storeHeader: string | null; isLive: boolean;
100
100
  return opts.defaultStore === "d1";
101
101
  }
102
102
 
103
+ /** Should we warn that no Cron trigger seems to be wired?
104
+ *
105
+ * The DO store self-drains via an alarm; the D1 store has none, so a DELAYED task — a
106
+ * scheduled publish, a retry backoff — runs only when a Cron trigger calls
107
+ * `createPramen().scheduled`. Forgetting that is silent: the row simply never goes live,
108
+ * and nothing anywhere says why.
109
+ *
110
+ * A request-tail drain that leaves something due in the FUTURE is exactly the situation
111
+ * that depends on the cron, so it is the moment to say so. Once a cron actually fires, the
112
+ * question is settled and we never warn again. Exported for tests — the decision is pure. */
113
+ export function shouldWarnMissingCron(opts: { cronSeen: boolean; warned: boolean; nextRunAt: number | null; now: number }): boolean {
114
+ if (opts.cronSeen || opts.warned) return false;
115
+ return opts.nextRunAt != null && opts.nextRunAt > opts.now;
116
+ }
117
+
103
118
  const json = (body: unknown, status = 200) => Response.json(body, { status });
104
119
  const forbidden = (what: string) => json({ ok: false, error: `access denied: ${what}`, code: "forbidden" }, 403);
105
120
  const badRequest = (msg: string) => json({ ok: false, error: msg, code: "bad_request" }, 400);
@@ -220,6 +235,61 @@ export function makeWorker(app: PramenApp) {
220
235
  };
221
236
 
222
237
  let d1Ready: Promise<void> | undefined;
238
+ /** Run one handler against the D1 store, in the Worker. The request path and the
239
+ * PRIVILEGED path (routes, which have no ctx.db) both come through here, so the two
240
+ * cannot drift on migration, bootstrap, the multi-tenant guard or the outbox drain.
241
+ *
242
+ * Returns the `{ ok, result }` envelope rather than a Response so each caller can add
243
+ * what only it needs — CORS and the session bookmark for a request, nothing for an
244
+ * internal call. */
245
+ const dispatchD1 = async (
246
+ env: Env,
247
+ ctx: ExecutionContext | undefined,
248
+ opts: { name: string; input: JsonValue; tenant: string; identity: Identity | null; start: D1SessionStart },
249
+ ): Promise<{ driver: D1Driver; result: JsonValue }> => {
250
+ if (!env.DB) throw new BadRequest("D1 store is not configured");
251
+ // Same COMMINGLING GUARD as the request path: shared D1 has no tenant column, so a
252
+ // non-`main` tenant would mix rows unless the operator opted in explicitly.
253
+ if (opts.tenant !== "main" && env.PRAMEN_D1_ALLOW_MULTITENANT !== "true") {
254
+ throw new Forbidden(`D1 store for tenant '${opts.tenant}' (shared D1 has no tenant isolation — set PRAMEN_D1_ALLOW_MULTITENANT=true to allow)`);
255
+ }
256
+ const driver = new D1Driver(env.DB, { start: opts.start });
257
+ const files = createFiles({ tenant: opts.tenant, secret: filesSecret(env), adapter: new R2Adapter(env.FILES) });
258
+ await ensureD1Migrated(driver, env.PRAMEN_ALLOW_DESTRUCTIVE === "true");
259
+ let dispatched;
260
+ try {
261
+ dispatched = await dispatch(
262
+ app.handlers,
263
+ app.schema,
264
+ driver,
265
+ new Kv(env.KV),
266
+ files,
267
+ envBag(env),
268
+ { acl: d1Acl, identity: opts.identity, tenant: opts.tenant, store: "d1" },
269
+ opts.name,
270
+ opts.input,
271
+ );
272
+ } catch (err) {
273
+ // D1 has no interactive transactions, so `transaction(fn)` runs `fn` as-is and a
274
+ // mutation that throws midway keeps whatever it already wrote. Say so: a partially
275
+ // applied mutation looks exactly like an ordinary 500 in the logs, and the difference
276
+ // — data left in a state no code path intended — is the whole point.
277
+ const written = driver.writtenCount();
278
+ if (written > 0) {
279
+ console.error(
280
+ `pramen: '${opts.name}' failed on the D1 store AFTER ${written} write statement(s) had committed. ` +
281
+ `D1 has no interactive transactions, so this mutation is PARTIALLY APPLIED and will not roll back. ` +
282
+ `Use the Durable Object store if this mutation must be atomic.`,
283
+ );
284
+ }
285
+ throw err;
286
+ }
287
+ const { result, enqueued } = dispatched;
288
+ // Drain in the request tail so an enqueued task does not wait for the next Cron tick.
289
+ if (enqueued > 0 && ctx) ctx.waitUntil(drainD1(env));
290
+ return { driver, result: result as JsonValue };
291
+ };
292
+
223
293
  const ensureD1Migrated = (driver: Driver, allowDestructive: boolean): Promise<void> => {
224
294
  if (!d1Ready) {
225
295
  d1Ready = migrate(driver, app.schema, { allowDestructive })
@@ -250,13 +320,30 @@ export function makeWorker(app: PramenApp) {
250
320
 
251
321
  /** Drain the D1 outbox in the Worker (no DO/alarm on this path) — called by the
252
322
  * /admin/tasks/drain route with `x-pramen-store: d1`, and by `scheduled()` (Cron). */
253
- const drainD1 = async (env: Env): Promise<unknown> => {
323
+ // Whether a Cron trigger has ever driven a drain in this isolate, and whether we have
324
+ // already said it looks missing. See `shouldWarnMissingCron`.
325
+ let cronSeen = false;
326
+ let warnedNoCron = false;
327
+
328
+ const drainD1 = async (env: Env, source: "request" | "cron" | "admin" = "request"): Promise<unknown> => {
254
329
  if (!env.DB) throw new Error("D1 store is not configured");
330
+ if (source === "cron") cronSeen = true;
255
331
  // The drain reads due tasks then writes their status — pin the primary so it sees
256
332
  // and updates current outbox state (not a lagging replica).
257
333
  const driver = new D1Driver(env.DB, { start: "first-primary" });
258
334
  await ensureD1Migrated(driver, env.PRAMEN_ALLOW_DESTRUCTIVE === "true");
259
- return drainOutbox(driver, bindTasks(app.tasks, d1TaskCtx(driver, env)), Date.now());
335
+ const result = await drainOutbox(driver, bindTasks(app.tasks, d1TaskCtx(driver, env)), Date.now());
336
+ if (source === "request" && shouldWarnMissingCron({ cronSeen, warned: warnedNoCron, nextRunAt: result.nextRunAt, now: Date.now() })) {
337
+ warnedNoCron = true;
338
+ const inSeconds = Math.round(((result.nextRunAt ?? 0) - Date.now()) / 1000);
339
+ console.warn(
340
+ `pramen: a task is queued on the D1 store to run in ~${inSeconds}s, but no Cron trigger has drained this Worker. ` +
341
+ `The D1 store has no Durable Object alarm, so a DELAYED task (a scheduled publish, a retry) runs ONLY when a Cron ` +
342
+ `trigger calls createPramen().scheduled. Add \`triggers: { crons: ["* * * * *"] }\` to your Worker config, or drain ` +
343
+ `manually via POST /admin/tasks/drain. This warning appears once per isolate and stops once a Cron drain is seen.`,
344
+ );
345
+ }
346
+ return result;
260
347
  };
261
348
 
262
349
  const listD1Tasks = async (env: Env, status?: string, limit?: number): Promise<unknown> => {
@@ -290,7 +377,33 @@ export function makeWorker(app: PramenApp) {
290
377
  // signature-authed webhook can live outside the JWT-gated /rpc surface.
291
378
  for (const r of app.routes ?? []) {
292
379
  if (request.method === r.method && url.pathname === r.path) {
293
- const routeCtx = { callPrivileged: (opts: Parameters<typeof callPrivileged>[1]) => callPrivileged(env, opts) };
380
+ // A public route has no ctx.db, so it reaches a handler through here. On the D1
381
+ // store there is no Durable Object to forward to — the engine runs in THIS Worker
382
+ // — so dispatch locally instead. Without this, everything built on a pre-auth
383
+ // route (a signed preview link, the sitemap) was DO-only, and the CMS had to
384
+ // refuse to mint preview links on D1 rather than hand out a dead one.
385
+ const routeCtx = {
386
+ callPrivileged: async (opts: Parameters<typeof callPrivileged>[1]): Promise<Response> => {
387
+ const store = useD1Store({ storeHeader: request.headers.get("x-pramen-store"), isLive: false, defaultStore: env.PRAMEN_STORE });
388
+ if (!store) return callPrivileged(env, opts);
389
+ try {
390
+ const { result } = await dispatchD1(env, ctx, {
391
+ name: opts.name,
392
+ input: opts.input ?? null,
393
+ tenant: opts.tenant ?? "main",
394
+ // The same synthetic identity the DO path sends in `x-pramen-identity`.
395
+ identity: { roles: opts.roles ?? ["admin"] },
396
+ // A privileged call may write (a redeemed one-time token), so pin the
397
+ // primary rather than risk a read-modify-write off a lagging replica.
398
+ start: "first-primary",
399
+ });
400
+ return json({ ok: true, result });
401
+ } catch (err) {
402
+ const { status, body } = toResponse(err);
403
+ return json(body, status);
404
+ }
405
+ },
406
+ };
294
407
  return r.handler(request, envBag(env), routeCtx);
295
408
  }
296
409
  }
@@ -395,7 +508,7 @@ export function makeWorker(app: PramenApp) {
395
508
  if (!isAdmin(identity)) return forbidden("tasks");
396
509
  if (request.headers.get("x-pramen-store") === "d1") {
397
510
  try {
398
- return withCors(json({ ok: true, result: await drainD1(env) }), cors);
511
+ return withCors(json({ ok: true, result: await drainD1(env, "admin") }), cors);
399
512
  } catch (err) {
400
513
  const { status, body } = toResponse(err);
401
514
  return withCors(json(body, status), cors);
@@ -498,20 +611,10 @@ export function makeWorker(app: PramenApp) {
498
611
  else if (inboundBookmark) start = inboundBookmark;
499
612
  else start = "first-unconstrained";
500
613
 
501
- const driver = new D1Driver(env.DB, { start });
502
- const files = createFiles({ tenant, secret: filesSecret(env), adapter: new R2Adapter(env.FILES) });
503
- const bag = envBag(env);
504
614
  try {
505
- await ensureD1Migrated(driver, env.PRAMEN_ALLOW_DESTRUCTIVE === "true");
506
615
  // `tenant` matters here: a handler minting a tenant-scoped capability (a signed
507
616
  // preview link) would otherwise stamp it "main" while reading acme's rows.
508
- const { result, enqueued } = await dispatch(app.handlers, app.schema, driver, new Kv(env.KV), files, bag, { acl: d1Acl, identity, tenant, store: "d1" }, name, input);
509
- // Kick an immediate drain in the request tail when this handler enqueued tasks
510
- // (e.g. sendMagicLinkEmail). Without this, tasks wait for the next Cron trigger
511
- // — up to a full minute. `waitUntil` lets the response return now while the
512
- // drain runs; the Cron trigger remains the safety net for delayed / retried
513
- // tasks that no request happens to coincide with.
514
- if (enqueued > 0) ctx.waitUntil(drainD1(env));
617
+ const { driver, result } = await dispatchD1(env, ctx, { name, input, tenant, identity, start });
515
618
  const res = json({ ok: true, result });
516
619
  // Thread the session's latest bookmark back so the client can read its own writes.
517
620
  const bookmark = driver.getBookmark();
@@ -573,7 +676,7 @@ export function makeWorker(app: PramenApp) {
573
676
  // Cron Trigger entry: drains the D1 outbox (the DO path self-drains via an alarm,
574
677
  // so it needs no cron). Wire a `[triggers] crons` in wrangler/oblaka to call this.
575
678
  async scheduled(_event: unknown, env: Env): Promise<void> {
576
- if (env.DB) await drainD1(env);
679
+ if (env.DB) await drainD1(env, "cron");
577
680
  },
578
681
 
579
682
  // Cloudflare Queues consumer entry: routes a batch to the matching `app.queues`