@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.
- package/dist/durable-object.js +2 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +3 -0
- package/dist/pramen.d.ts +6 -0
- package/dist/pramen.js +1 -1
- package/dist/runtime/ddl.js +5 -4
- package/dist/runtime/dispatch.js +2 -0
- package/dist/runtime/driver.d.ts +41 -7
- package/dist/runtime/driver.js +38 -11
- package/dist/runtime/migrate.d.ts +1 -1
- package/dist/runtime/migrate.js +10 -14
- package/dist/runtime/queue-consumer.d.ts +64 -0
- package/dist/runtime/queue-consumer.js +46 -0
- package/dist/runtime/queue.d.ts +72 -0
- package/dist/runtime/queue.js +110 -0
- package/dist/sdk/handlers.d.ts +7 -0
- package/dist/worker.d.ts +21 -0
- package/dist/worker.js +90 -13
- package/package.json +1 -1
- package/src/durable-object.ts +2 -0
- package/src/index.ts +6 -0
- package/src/pramen.ts +7 -1
- package/src/runtime/ddl.ts +5 -4
- package/src/runtime/dispatch.ts +2 -0
- package/src/runtime/driver.ts +52 -9
- package/src/runtime/migrate.ts +10 -15
- package/src/runtime/queue-consumer.ts +97 -0
- package/src/runtime/queue.ts +155 -0
- package/src/sdk/handlers.ts +7 -0
- package/src/worker.ts +105 -13
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Queue consumer dispatch — the receiving half of ctx.queue. A pramen Worker is the
|
|
2
|
+
// consumer for its declared queues (oblaka `new Queue({ binding: "both", ... })`), so
|
|
3
|
+
// `createPramen(app).queue` is the Cloudflare `queue(batch, env, ctx)` entry. It routes
|
|
4
|
+
// each batch to the matching `app.queues[name]` handler and ACKs/RETRIES per message.
|
|
5
|
+
//
|
|
6
|
+
// A consumer runs in the WORKER, not a Durable Object — a queue message isn't bound to a
|
|
7
|
+
// tenant, so there's no direct `ctx.db`. To touch tenant data, carry the tenant in the
|
|
8
|
+
// message body and `ctx.callPrivileged({ name, input, tenant })` into its DO (exactly
|
|
9
|
+
// like a public route). The consumer still gets `ctx.mail` / `ctx.queue` / `ctx.kv` /
|
|
10
|
+
// `ctx.env`, so the canonical "consume a job → send a notification" path is one call.
|
|
11
|
+
|
|
12
|
+
import type { Mail } from "./mail";
|
|
13
|
+
import type { Queue } from "./queue";
|
|
14
|
+
import type { Kv } from "./kv";
|
|
15
|
+
|
|
16
|
+
/** One received message (the Cloudflare Queues `Message` shape). */
|
|
17
|
+
export interface QueueMessage<Body = unknown> {
|
|
18
|
+
readonly id: string;
|
|
19
|
+
readonly timestamp: Date;
|
|
20
|
+
readonly body: Body;
|
|
21
|
+
/** 1-based delivery attempt — grows on each retry (use it to give up / dead-letter). */
|
|
22
|
+
readonly attempts: number;
|
|
23
|
+
/** Mark this message handled (won't be redelivered). The framework calls this for you
|
|
24
|
+
* when the handler resolves; call it yourself only for fine-grained control. */
|
|
25
|
+
ack(): void;
|
|
26
|
+
/** Schedule this message for redelivery (the framework calls it when the handler throws). */
|
|
27
|
+
retry(options?: { delaySeconds?: number }): void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A batch delivered to the consumer (the Cloudflare Queues `MessageBatch` shape). */
|
|
31
|
+
export interface QueueBatch<Body = unknown> {
|
|
32
|
+
/** The queue this batch came from (the oblaka `Queue` name; env-prefixed remotely). */
|
|
33
|
+
readonly queue: string;
|
|
34
|
+
readonly messages: readonly QueueMessage<Body>[];
|
|
35
|
+
ackAll(): void;
|
|
36
|
+
retryAll(options?: { delaySeconds?: number }): void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The context handed to a queue consumer handler. Worker-level (no `ctx.db`): reach
|
|
40
|
+
* tenant data via `ctx.callPrivileged`. */
|
|
41
|
+
export interface QueueContext {
|
|
42
|
+
/** The Worker environment (bindings + vars + secrets). */
|
|
43
|
+
readonly env: Readonly<Record<string, unknown>>;
|
|
44
|
+
/** Project KV (cross-tenant). */
|
|
45
|
+
readonly kv: Kv;
|
|
46
|
+
/** Send email (the notification path). */
|
|
47
|
+
readonly mail: Mail;
|
|
48
|
+
/** Enqueue onto a (possibly different) queue — fan-out / chaining. */
|
|
49
|
+
readonly queue: Queue;
|
|
50
|
+
/** Apply a privileged mutation into a tenant's DO (the consumer has no direct db).
|
|
51
|
+
* The message body should carry the `tenant`. */
|
|
52
|
+
callPrivileged(opts: { name: string; input?: unknown; tenant?: string; roles?: string[]; partition?: string }): Promise<Response>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A queue consumer handler — runs once per message. Resolving ACKs the message;
|
|
56
|
+
* throwing RETRIES it (subject to the queue's max_retries → dead-letter queue). */
|
|
57
|
+
export type QueueHandler<Body = unknown> = (ctx: QueueContext, message: QueueMessage<Body>) => void | Promise<void>;
|
|
58
|
+
|
|
59
|
+
/** Map of queue name → consumer handler. Set as `app.queues`; dispatched by
|
|
60
|
+
* `createPramen(app).queue`. */
|
|
61
|
+
export type AppQueueMap = Record<string, QueueHandler>;
|
|
62
|
+
|
|
63
|
+
/** Resolve the handler for a batch's queue. Queue names are env-prefixed in remote
|
|
64
|
+
* environments (`production-pramen-jobs`) but bare locally (`pramen-jobs`), so match
|
|
65
|
+
* leniently: exact, then suffix (`…-<key>`), then — if there's exactly one handler —
|
|
66
|
+
* fall through to it (the common single-queue app). Returns null if nothing matches. */
|
|
67
|
+
export function routeQueue(queues: AppQueueMap, queueName: string): QueueHandler | null {
|
|
68
|
+
const keys = Object.keys(queues);
|
|
69
|
+
if (queues[queueName]) return queues[queueName];
|
|
70
|
+
const suffix = keys.find((k) => queueName.endsWith(`-${k}`) || k.endsWith(`-${queueName}`));
|
|
71
|
+
if (suffix) return queues[suffix];
|
|
72
|
+
if (keys.length === 1) return queues[keys[0]];
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Dispatch one batch: route to the handler, then run it per message, ACKing on success
|
|
77
|
+
* and RETRYing on throw (per message, so one poison message doesn't re-deliver the rest).
|
|
78
|
+
* An unrouted batch is retried whole (never silently acked) and logged. */
|
|
79
|
+
export async function dispatchQueueBatch(queues: AppQueueMap, ctx: QueueContext, batch: QueueBatch): Promise<void> {
|
|
80
|
+
const handler = routeQueue(queues, batch.queue);
|
|
81
|
+
if (!handler) {
|
|
82
|
+
console.error(`pramen: no app.queues handler for queue '${batch.queue}' — retrying batch (declare it in app.queues)`);
|
|
83
|
+
batch.retryAll();
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
await Promise.all(
|
|
87
|
+
batch.messages.map(async (message) => {
|
|
88
|
+
try {
|
|
89
|
+
await handler(ctx, message);
|
|
90
|
+
message.ack();
|
|
91
|
+
} catch (err) {
|
|
92
|
+
console.error(`pramen: queue '${batch.queue}' message ${message.id} failed (attempt ${message.attempts}) — retrying`, err);
|
|
93
|
+
message.retry();
|
|
94
|
+
}
|
|
95
|
+
}),
|
|
96
|
+
);
|
|
97
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// ctx.queue — Cloudflare Queues producer facade, the same shape as ctx.mail / ctx.files:
|
|
2
|
+
// an adapter seam (CloudflareQueueAdapter / MemoryQueueAdapter) behind a thin `Queue`
|
|
3
|
+
// facade, built from the environment. Handlers enqueue onto a native Cloudflare Queue
|
|
4
|
+
// without touching the producer binding directly:
|
|
5
|
+
//
|
|
6
|
+
// await ctx.queue.send("jobs", { kind: "resize", id });
|
|
7
|
+
// await ctx.queue.sendBatch("jobs", [{ body: a }, { body: b, delaySeconds: 30 }]);
|
|
8
|
+
//
|
|
9
|
+
// This is distinct from `ctx.tasks` (the transactional outbox). `ctx.tasks.enqueue`
|
|
10
|
+
// is atomic with the mutation's DB write (commit-or-rollback together) and drained
|
|
11
|
+
// in-process. `ctx.queue` is a native Cloudflare Queue: NOT transactional with the
|
|
12
|
+
// write (the message is sent regardless of whether the mutation later rolls back),
|
|
13
|
+
// but higher-throughput, with platform-native batching/retry/DLQ and a consumer that
|
|
14
|
+
// can run in a *different* Worker. Reach for ctx.tasks when the side-effect must commit
|
|
15
|
+
// with the data; reach for ctx.queue for decoupled, high-volume fan-out.
|
|
16
|
+
//
|
|
17
|
+
// On Cloudflare the transport is the Queues producer binding (declared in oblaka.ts as
|
|
18
|
+
// `new Queue({ binding: "both", ... })`). Off-platform / unconfigured, sending to a queue
|
|
19
|
+
// that isn't bound FAILS CLOSED (throws) rather than silently dropping the message —
|
|
20
|
+
// mirroring how ctx.mail fails closed without a transport.
|
|
21
|
+
|
|
22
|
+
/** Cloudflare Queues content type for a sent message. Omitted ⇒ the platform default
|
|
23
|
+
* (v8 structured clone). Use "json" for cross-runtime / external consumers. */
|
|
24
|
+
export type QueueContentType = "text" | "bytes" | "json" | "v8";
|
|
25
|
+
|
|
26
|
+
/** Per-message send options (mirrors the Cloudflare Queues producer API). */
|
|
27
|
+
export interface QueueSendOptions {
|
|
28
|
+
/** Defer delivery by N seconds (the consumer won't see the message until then). */
|
|
29
|
+
delaySeconds?: number;
|
|
30
|
+
/** How the body is serialized on the wire. Omitted ⇒ platform default (v8). */
|
|
31
|
+
contentType?: QueueContentType;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** One message in a `sendBatch` — a body plus its own per-message options. */
|
|
35
|
+
export interface QueueSendRequest {
|
|
36
|
+
body: unknown;
|
|
37
|
+
delaySeconds?: number;
|
|
38
|
+
contentType?: QueueContentType;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Batch-level send options. */
|
|
42
|
+
export interface QueueBatchOptions {
|
|
43
|
+
/** Default delay applied to every message in the batch (per-message overrides win). */
|
|
44
|
+
delaySeconds?: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The Cloudflare Queues producer binding shape (what `env.<QUEUE>` exposes). A binding
|
|
48
|
+
* is recognized as a queue producer iff it has BOTH `send` and `sendBatch` (which
|
|
49
|
+
* distinguishes it from the email `send`-only binding, KV, R2, D1, …). */
|
|
50
|
+
export interface QueueProducerBinding {
|
|
51
|
+
send(body: unknown, options?: QueueSendOptions): Promise<void>;
|
|
52
|
+
sendBatch(messages: Iterable<QueueSendRequest>, options?: QueueBatchOptions): Promise<void>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The transport seam — one per backend (Cloudflare Queues, an in-memory capture, …). */
|
|
56
|
+
export interface QueueAdapter {
|
|
57
|
+
send(queue: string, body: unknown, options?: QueueSendOptions): Promise<void>;
|
|
58
|
+
sendBatch(queue: string, messages: readonly QueueSendRequest[], options?: QueueBatchOptions): Promise<void>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The `ctx.queue` facade: validates, then delegates to the adapter for a named queue. */
|
|
62
|
+
export class Queue {
|
|
63
|
+
constructor(private readonly adapter: QueueAdapter) {}
|
|
64
|
+
|
|
65
|
+
/** Enqueue a single message onto `queue`. `body` is serialized by the platform. */
|
|
66
|
+
async send(queue: string, body: unknown, options?: QueueSendOptions): Promise<void> {
|
|
67
|
+
assertQueueName(queue);
|
|
68
|
+
if (body === undefined) throw new Error("ctx.queue.send: `body` is required");
|
|
69
|
+
await this.adapter.send(queue, body, options);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Enqueue many messages onto `queue` in one call (cheaper than N sends). */
|
|
73
|
+
async sendBatch(queue: string, messages: readonly QueueSendRequest[], options?: QueueBatchOptions): Promise<void> {
|
|
74
|
+
assertQueueName(queue);
|
|
75
|
+
if (!Array.isArray(messages) || messages.length === 0) {
|
|
76
|
+
throw new Error("ctx.queue.sendBatch: `messages` must be a non-empty array");
|
|
77
|
+
}
|
|
78
|
+
for (const m of messages) {
|
|
79
|
+
if (!m || m.body === undefined) throw new Error("ctx.queue.sendBatch: every message needs a `body`");
|
|
80
|
+
}
|
|
81
|
+
await this.adapter.sendBatch(queue, messages, options);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function assertQueueName(queue: string): void {
|
|
86
|
+
if (typeof queue !== "string" || queue.length === 0) {
|
|
87
|
+
throw new Error("ctx.queue: a queue name is required (the oblaka `Queue` name)");
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Cloudflare Queues transport. Constructed with the producer bindings discovered from
|
|
92
|
+
* the environment, keyed by binding name. Sending to a name with no bound queue throws
|
|
93
|
+
* a clear error (fail-closed) — a missing binding is a config error, not a silent drop. */
|
|
94
|
+
export class CloudflareQueueAdapter implements QueueAdapter {
|
|
95
|
+
constructor(private readonly bindings: Readonly<Record<string, QueueProducerBinding>>) {}
|
|
96
|
+
|
|
97
|
+
private bindingFor(queue: string): QueueProducerBinding {
|
|
98
|
+
const b = this.bindings[queue];
|
|
99
|
+
if (!b) {
|
|
100
|
+
const known = Object.keys(this.bindings);
|
|
101
|
+
const avail = known.length ? known.join(", ") : "none";
|
|
102
|
+
throw new Error(
|
|
103
|
+
`ctx.queue: no queue binding '${queue}' — declare it in oblaka.ts ` +
|
|
104
|
+
`(new Queue({ name: '${queue}', binding: 'both' })). Bound queues: ${avail}.`,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
return b;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async send(queue: string, body: unknown, options?: QueueSendOptions): Promise<void> {
|
|
111
|
+
await this.bindingFor(queue).send(body, options);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async sendBatch(queue: string, messages: readonly QueueSendRequest[], options?: QueueBatchOptions): Promise<void> {
|
|
115
|
+
await this.bindingFor(queue).sendBatch(messages, options);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** In-memory transport: captures sent messages instead of delivering them. For unit
|
|
120
|
+
* tests (assert on `.sent`) and pure off-platform use. */
|
|
121
|
+
export class MemoryQueueAdapter implements QueueAdapter {
|
|
122
|
+
readonly sent: Array<{ queue: string; body: unknown; options?: QueueSendOptions | QueueBatchOptions }> = [];
|
|
123
|
+
async send(queue: string, body: unknown, options?: QueueSendOptions): Promise<void> {
|
|
124
|
+
this.sent.push({ queue, body, options });
|
|
125
|
+
}
|
|
126
|
+
async sendBatch(queue: string, messages: readonly QueueSendRequest[], options?: QueueBatchOptions): Promise<void> {
|
|
127
|
+
for (const m of messages) this.sent.push({ queue, body: m.body, options: { ...options, ...m } });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Discover the Cloudflare Queues producer bindings in an environment: any value that
|
|
132
|
+
* exposes BOTH `send` and `sendBatch` functions (which excludes the email `send`-only
|
|
133
|
+
* binding, KV, R2, D1, the DO namespace, …). Returns name → binding. */
|
|
134
|
+
export function discoverQueueBindings(env: Readonly<Record<string, unknown>>): Record<string, QueueProducerBinding> {
|
|
135
|
+
const out: Record<string, QueueProducerBinding> = {};
|
|
136
|
+
for (const [name, value] of Object.entries(env)) {
|
|
137
|
+
if (
|
|
138
|
+
value &&
|
|
139
|
+
typeof value === "object" &&
|
|
140
|
+
typeof (value as { send?: unknown }).send === "function" &&
|
|
141
|
+
typeof (value as { sendBatch?: unknown }).sendBatch === "function"
|
|
142
|
+
) {
|
|
143
|
+
out[name] = value as QueueProducerBinding;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Build `ctx.queue` from the environment: a Cloudflare adapter over the discovered
|
|
150
|
+
* producer bindings. Sending to an undeclared queue fails closed (the adapter throws).
|
|
151
|
+
* There is no silent capture fallback — declare the `Queue` binding and it exists in
|
|
152
|
+
* dev (lopata) and miniflare too. */
|
|
153
|
+
export function createQueue(env: Readonly<Record<string, unknown>>): Queue {
|
|
154
|
+
return new Queue(new CloudflareQueueAdapter(discoverQueueBindings(env)));
|
|
155
|
+
}
|
package/src/sdk/handlers.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import type { Db } from "../runtime/db";
|
|
6
6
|
import type { Kv } from "../runtime/kv";
|
|
7
7
|
import type { Mail } from "../runtime/mail";
|
|
8
|
+
import type { Queue } from "../runtime/queue";
|
|
8
9
|
import type { Identity } from "./acl";
|
|
9
10
|
import type { Files } from "./files";
|
|
10
11
|
import type { SchemaDef } from "./schema";
|
|
@@ -36,6 +37,12 @@ export interface HandlerContext<S extends SchemaDef = SchemaDef> {
|
|
|
36
37
|
* runs the matching `app.tasks` handler after commit, off the write path, with
|
|
37
38
|
* retry. For notification email, webhooks, etc. — see `app.tasks`. */
|
|
38
39
|
readonly tasks: Tasks;
|
|
40
|
+
/** Enqueue onto a native Cloudflare Queue: `ctx.queue.send("jobs", body)`. Unlike
|
|
41
|
+
* `ctx.tasks` (a transactional outbox, atomic with the mutation, drained in-process),
|
|
42
|
+
* a queue send is NOT transactional with the write but is higher-throughput, with
|
|
43
|
+
* platform-native batching/retry/DLQ and a consumer that may live in another Worker.
|
|
44
|
+
* Declare queues in oblaka.ts; consume them via `app.queues`. */
|
|
45
|
+
readonly queue: Queue;
|
|
39
46
|
}
|
|
40
47
|
|
|
41
48
|
/** The deferred-side-effects facade handed to handlers as `ctx.tasks`. */
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
349
|
-
//
|
|
350
|
-
//
|
|
351
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|