@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/dist/durable-object.js +2 -0
- package/dist/index.d.ts +6 -2
- package/dist/index.js +4 -1
- package/dist/pramen.d.ts +6 -0
- package/dist/pramen.js +1 -1
- package/dist/runtime/ddl.js +5 -4
- package/dist/runtime/dispatch.d.ts +1 -1
- package/dist/runtime/dispatch.js +9 -1
- 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/app.js +2 -2
- package/dist/sdk/handlers.d.ts +22 -0
- package/dist/sdk/handlers.js +14 -2
- 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 +8 -2
- package/src/pramen.ts +7 -1
- package/src/runtime/ddl.ts +5 -4
- package/src/runtime/dispatch.ts +10 -2
- 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/app.ts +2 -2
- package/src/sdk/handlers.ts +33 -2
- package/src/worker.ts +105 -13
|
@@ -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
|
+
}
|
package/dist/sdk/app.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// const { query, mutation } = createApp(schema);
|
|
6
6
|
// const listNotes = query((ctx) => ctx.db.find({ from: "notes" })); // typed!
|
|
7
7
|
export function createApp(schema) {
|
|
8
|
-
const query = (run, opts) => ({ kind: "query", run: run, input: opts?.input, partition: opts?.partition });
|
|
9
|
-
const mutation = (run, opts) => ({ kind: "mutation", run: run, input: opts?.input, partition: opts?.partition });
|
|
8
|
+
const query = (run, opts) => ({ kind: "query", run: run, input: opts?.input, partition: opts?.partition, auth: opts?.auth });
|
|
9
|
+
const mutation = (run, opts) => ({ kind: "mutation", run: run, input: opts?.input, partition: opts?.partition, auth: opts?.auth });
|
|
10
10
|
return { schema, query, mutation };
|
|
11
11
|
}
|
package/dist/sdk/handlers.d.ts
CHANGED
|
@@ -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 {
|
|
@@ -56,6 +63,17 @@ export type TaskHandler = (ctx: HandlerContext, payload: unknown, meta: TaskMeta
|
|
|
56
63
|
/** Map of `kind` → handler. Set as `app.tasks`; drained by the DO alarm / a Cron. */
|
|
57
64
|
export type AppTaskMap = Record<string, TaskHandler>;
|
|
58
65
|
export type HandlerKind = "query" | "mutation";
|
|
66
|
+
/** Authorization required to CALL a handler, enforced BEFORE its body runs. This is
|
|
67
|
+
* distinct from the row-level ACL (which gates `ctx.db`): use it to gate handlers that
|
|
68
|
+
* touch `ctx.kv`/`ctx.env`/`ctx.mail`/`ctx.tasks` directly — those bypass the ACL, so an
|
|
69
|
+
* un-gated such handler is callable by anyone (incl. anonymous) on an open tenant. Forms:
|
|
70
|
+
* - `"authenticated"` — any non-anonymous caller (identity != null)
|
|
71
|
+
* - `string[]` — the caller must hold one of these roles
|
|
72
|
+
* - `(identity) => boolean` — a custom predicate
|
|
73
|
+
* Absent ⇒ open (the prior behavior; a `ctx.db` handler is still ACL-gated). */
|
|
74
|
+
export type HandlerAuth = "authenticated" | readonly string[] | ((identity: Identity | null) => boolean);
|
|
75
|
+
/** Evaluate a handler's `auth` requirement against the caller's identity. */
|
|
76
|
+
export declare function authorizeHandler(auth: HandlerAuth, identity: Identity | null): boolean;
|
|
59
77
|
export interface Handler<I = unknown, O = unknown> {
|
|
60
78
|
readonly kind: HandlerKind;
|
|
61
79
|
readonly run: (ctx: HandlerContext<any>, input: I) => O | Promise<O>;
|
|
@@ -66,11 +84,15 @@ export interface Handler<I = unknown, O = unknown> {
|
|
|
66
84
|
* routes the request to the matching partition-DO before dispatch. Absent ⇒ the
|
|
67
85
|
* default partition (routed to the bare tenant key). */
|
|
68
86
|
readonly partition?: string;
|
|
87
|
+
/** Optional call-authorization, enforced before the handler runs (see HandlerAuth). */
|
|
88
|
+
readonly auth?: HandlerAuth;
|
|
69
89
|
}
|
|
70
90
|
export interface HandlerOpts<I> {
|
|
71
91
|
input?: (raw: unknown) => I;
|
|
72
92
|
/** DO partition this handler runs in. Absent ⇒ the default partition. */
|
|
73
93
|
partition?: string;
|
|
94
|
+
/** Authorization to CALL this handler (see HandlerAuth) — gate non-`ctx.db` handlers. */
|
|
95
|
+
auth?: HandlerAuth;
|
|
74
96
|
}
|
|
75
97
|
export declare function query<I = unknown, O = unknown>(run: (ctx: HandlerContext, input: I) => O | Promise<O>, opts?: HandlerOpts<I>): Handler<I, O>;
|
|
76
98
|
export declare function mutation<I = unknown, O = unknown>(run: (ctx: HandlerContext, input: I) => O | Promise<O>, opts?: HandlerOpts<I>): Handler<I, O>;
|
package/dist/sdk/handlers.js
CHANGED
|
@@ -1,11 +1,23 @@
|
|
|
1
1
|
// Handler factories — `query()` and `mutation()`. A query reads; a mutation is
|
|
2
2
|
// wrapped in BEGIN/COMMIT by the dispatcher and
|
|
3
3
|
// rolls back on throw (see runtime/dispatch.ts).
|
|
4
|
+
/** Evaluate a handler's `auth` requirement against the caller's identity. */
|
|
5
|
+
export function authorizeHandler(auth, identity) {
|
|
6
|
+
if (auth === "authenticated")
|
|
7
|
+
return identity != null;
|
|
8
|
+
if (typeof auth === "function")
|
|
9
|
+
return auth(identity);
|
|
10
|
+
if (!identity)
|
|
11
|
+
return false; // a role list can never be satisfied by an anonymous caller
|
|
12
|
+
const roles = Array.isArray(identity.roles) ? identity.roles : [];
|
|
13
|
+
const held = identity.role ? [identity.role, ...roles] : roles;
|
|
14
|
+
return auth.some((r) => held.includes(r));
|
|
15
|
+
}
|
|
4
16
|
// Standalone (schema-agnostic) handler factories. Prefer createApp(schema) for a
|
|
5
17
|
// typed ctx.db; these remain for untyped/ad-hoc use.
|
|
6
18
|
export function query(run, opts) {
|
|
7
|
-
return { kind: "query", run, input: opts?.input, partition: opts?.partition };
|
|
19
|
+
return { kind: "query", run, input: opts?.input, partition: opts?.partition, auth: opts?.auth };
|
|
8
20
|
}
|
|
9
21
|
export function mutation(run, opts) {
|
|
10
|
-
return { kind: "mutation", run, input: opts?.input, partition: opts?.partition };
|
|
22
|
+
return { kind: "mutation", run, input: opts?.input, partition: opts?.partition, auth: opts?.auth };
|
|
11
23
|
}
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
299
|
-
//
|
|
300
|
-
//
|
|
301
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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": {
|
package/src/durable-object.ts
CHANGED
|
@@ -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
|
@@ -26,8 +26,8 @@ export type {
|
|
|
26
26
|
|
|
27
27
|
// --- app + handlers ---
|
|
28
28
|
export { createApp } from "./sdk/app";
|
|
29
|
-
export { query, mutation } from "./sdk/handlers";
|
|
30
|
-
export type { Handler, HandlerContext, HandlerKind, HandlerMap, HandlerOpts, Tasks, TaskHandler, AppTaskMap } from "./sdk/handlers";
|
|
29
|
+
export { query, mutation, authorizeHandler } from "./sdk/handlers";
|
|
30
|
+
export type { Handler, HandlerContext, HandlerKind, HandlerMap, HandlerOpts, HandlerAuth, Tasks, TaskHandler, AppTaskMap } from "./sdk/handlers";
|
|
31
31
|
|
|
32
32
|
// --- ACL ---
|
|
33
33
|
export { $identity, $input, allow, deny, policy, resolve, role, isAllow, isDeny, isResolver, isIdentityMarker, isInputMarker } from "./sdk/acl";
|
|
@@ -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
|
}
|
package/src/runtime/ddl.ts
CHANGED
|
@@ -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
|
}
|
package/src/runtime/dispatch.ts
CHANGED
|
@@ -10,15 +10,16 @@
|
|
|
10
10
|
|
|
11
11
|
import { Db } from "./db";
|
|
12
12
|
import { warmup, type AclContext } from "./acl";
|
|
13
|
-
import { BadRequest } from "./errors";
|
|
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";
|
|
19
20
|
import type { ResolverDb } from "../sdk/acl";
|
|
20
21
|
import type { SchemaDef } from "../sdk/schema";
|
|
21
|
-
import type
|
|
22
|
+
import { authorizeHandler, type AppTaskMap, type HandlerContext, type HandlerKind, type HandlerMap, type Tasks } from "../sdk/handlers";
|
|
22
23
|
|
|
23
24
|
export interface DispatchResult {
|
|
24
25
|
readonly result: unknown;
|
|
@@ -60,6 +61,12 @@ export async function dispatch(
|
|
|
60
61
|
const handler = handlers[name];
|
|
61
62
|
if (!handler) throw new BadRequest(`unknown handler: ${name}`);
|
|
62
63
|
|
|
64
|
+
// Per-handler authorization, enforced before any work (input parse / handler body) —
|
|
65
|
+
// gates handlers that bypass the row-ACL by touching ctx.kv/ctx.env/ctx.mail directly.
|
|
66
|
+
if (handler.auth && !authorizeHandler(handler.auth, acl.identity)) {
|
|
67
|
+
throw new Forbidden(`not authorized to call '${name}'`);
|
|
68
|
+
}
|
|
69
|
+
|
|
63
70
|
// Validate/parse the request input at the boundary, if the handler declares it.
|
|
64
71
|
let parsed = input;
|
|
65
72
|
if (handler.input) {
|
|
@@ -85,6 +92,7 @@ export async function dispatch(
|
|
|
85
92
|
identity: acl.identity,
|
|
86
93
|
tasks: tasksFacade(driver, () => enqueued++),
|
|
87
94
|
mail: createMail(env, kv),
|
|
95
|
+
queue: createQueue(env),
|
|
88
96
|
};
|
|
89
97
|
|
|
90
98
|
const result =
|