@pramen/server 0.0.13 → 0.0.15
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/auth.d.ts +17 -2
- package/dist/auth.js +26 -7
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +311 -0
- package/dist/durable-object.d.ts +22 -4
- package/dist/durable-object.js +121 -55
- 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/acl.js +28 -7
- package/dist/runtime/db.d.ts +6 -0
- package/dist/runtime/db.js +86 -10
- package/dist/runtime/ddl.d.ts +16 -3
- package/dist/runtime/ddl.js +28 -8
- 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 +222 -33
- package/dist/runtime/outbox.js +28 -6
- package/dist/runtime/queue-consumer.d.ts +71 -0
- package/dist/runtime/queue-consumer.js +63 -0
- package/dist/runtime/queue.d.ts +72 -0
- package/dist/runtime/queue.js +110 -0
- package/dist/runtime/read-engine.js +7 -2
- package/dist/runtime/schema-diff.d.ts +28 -5
- package/dist/runtime/schema-diff.js +111 -19
- package/dist/runtime/storage.d.ts +7 -0
- package/dist/runtime/storage.js +0 -0
- package/dist/sdk/handlers.d.ts +7 -0
- package/dist/worker.d.ts +36 -0
- package/dist/worker.js +128 -18
- package/package.json +6 -2
- package/src/auth.ts +64 -21
- package/src/cli.ts +336 -0
- package/src/durable-object.ts +118 -52
- package/src/index.ts +6 -0
- package/src/pramen.ts +7 -1
- package/src/runtime/acl.ts +25 -5
- package/src/runtime/db.ts +80 -9
- package/src/runtime/ddl.ts +26 -8
- package/src/runtime/dispatch.ts +2 -0
- package/src/runtime/driver.ts +52 -9
- package/src/runtime/migrate.ts +246 -34
- package/src/runtime/outbox.ts +30 -7
- package/src/runtime/queue-consumer.ts +116 -0
- package/src/runtime/queue.ts +155 -0
- package/src/runtime/read-engine.ts +7 -2
- package/src/runtime/schema-diff.ts +137 -23
- package/src/runtime/storage.ts +0 -0
- package/src/sdk/handlers.ts +7 -0
- package/src/worker.ts +162 -19
package/dist/runtime/outbox.js
CHANGED
|
@@ -87,6 +87,14 @@ export async function drainOutbox(driver, tasks, now, limit = 50) {
|
|
|
87
87
|
const kind = String(row.kind);
|
|
88
88
|
const attempts = Number(row.attempts) + 1;
|
|
89
89
|
const handler = tasks[kind];
|
|
90
|
+
// Re-stamp claimedAt to WALL-CLOCK time immediately before running this row, so its
|
|
91
|
+
// stale clock starts when its own processing starts — not when the whole batch was
|
|
92
|
+
// claimed. Otherwise a batch (up to `limit` rows) processed SEQUENTIALLY whose total
|
|
93
|
+
// time exceeds STALE_MS would leave the not-yet-run tail reclaimable by a concurrent
|
|
94
|
+
// drainer under the batch-shared claimedAt, running it twice. We use Date.now() (not
|
|
95
|
+
// the caller's fixed `now`) because that is the only clock that advances across the
|
|
96
|
+
// loop; the atomic claim above still gives disjoint batches for concurrent drainers.
|
|
97
|
+
await driver.exec(`UPDATE ${d.id(OUTBOX_TABLE)} SET claimedAt = ${ph(1)} WHERE id = ${ph(2)}`, enc(driver, [Date.now(), id]));
|
|
90
98
|
try {
|
|
91
99
|
if (!handler)
|
|
92
100
|
throw new Error(`no task handler registered for kind ${JSON.stringify(kind)}`);
|
|
@@ -101,19 +109,33 @@ export async function drainOutbox(driver, tasks, now, limit = 50) {
|
|
|
101
109
|
failed++;
|
|
102
110
|
}
|
|
103
111
|
}
|
|
104
|
-
// remaining = pending AND due now
|
|
105
|
-
//
|
|
106
|
-
//
|
|
112
|
+
// remaining = pending AND due now. nextRunAt = the earliest moment the DO must wake to
|
|
113
|
+
// make progress, so it can re-arm its alarm exactly there. That is the min of:
|
|
114
|
+
// (a) MIN(runAt) over pending rows (a due-now or backed-off retry), and
|
|
115
|
+
// (b) MIN(claimedAt) + STALE_MS over 'processing' rows — a claim stranded by a
|
|
116
|
+
// crashed drainer becomes reclaimable at claimedAt + STALE_MS. Without folding
|
|
117
|
+
// this in, a mid-drain crash would leave a row 'processing' with no pending row
|
|
118
|
+
// to re-arm the alarm, and on a quiet tenant the task would stall forever (the
|
|
119
|
+
// alarm is the only DO-path drain trigger). Any processing rows here belong to a
|
|
120
|
+
// *different* (concurrent or crashed) drainer — our own batch is never left
|
|
121
|
+
// processing after this loop.
|
|
107
122
|
const stats = await driver.exec(`SELECT ` +
|
|
108
123
|
`(SELECT COUNT(*) FROM ${d.id(OUTBOX_TABLE)} WHERE status = ${ph(1)} AND runAt <= ${ph(2)}) AS due, ` +
|
|
109
|
-
`(SELECT MIN(runAt) FROM ${d.id(OUTBOX_TABLE)} WHERE status = ${ph(3)}) AS
|
|
110
|
-
|
|
124
|
+
`(SELECT MIN(runAt) FROM ${d.id(OUTBOX_TABLE)} WHERE status = ${ph(3)}) AS nextPending, ` +
|
|
125
|
+
`(SELECT MIN(claimedAt) FROM ${d.id(OUTBOX_TABLE)} WHERE status = ${ph(4)}) AS nextStale`, enc(driver, ["pending", now, "pending", "processing"]));
|
|
126
|
+
const pendingRaw = stats[0]?.nextPending;
|
|
127
|
+
const staleRaw = stats[0]?.nextStale;
|
|
128
|
+
const candidates = [];
|
|
129
|
+
if (pendingRaw != null)
|
|
130
|
+
candidates.push(Number(pendingRaw));
|
|
131
|
+
if (staleRaw != null)
|
|
132
|
+
candidates.push(Number(staleRaw) + STALE_MS);
|
|
111
133
|
return {
|
|
112
134
|
processed: claimed.length,
|
|
113
135
|
succeeded,
|
|
114
136
|
failed,
|
|
115
137
|
remaining: Number(stats[0]?.due ?? 0),
|
|
116
|
-
nextRunAt:
|
|
138
|
+
nextRunAt: candidates.length ? Math.min(...candidates) : null,
|
|
117
139
|
};
|
|
118
140
|
}
|
|
119
141
|
/** List outbox rows for admin visibility (e.g. inspect dead-lettered tasks). Newest
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { Mail } from "./mail";
|
|
2
|
+
import type { Queue } from "./queue";
|
|
3
|
+
import type { Kv } from "./kv";
|
|
4
|
+
/** One received message (the Cloudflare Queues `Message` shape). */
|
|
5
|
+
export interface QueueMessage<Body = unknown> {
|
|
6
|
+
readonly id: string;
|
|
7
|
+
readonly timestamp: Date;
|
|
8
|
+
readonly body: Body;
|
|
9
|
+
/** 1-based delivery attempt — grows on each retry (use it to give up / dead-letter). */
|
|
10
|
+
readonly attempts: number;
|
|
11
|
+
/** Mark this message handled (won't be redelivered). The framework calls this for you
|
|
12
|
+
* when the handler resolves; call it yourself only for fine-grained control. */
|
|
13
|
+
ack(): void;
|
|
14
|
+
/** Schedule this message for redelivery (the framework calls it when the handler throws). */
|
|
15
|
+
retry(options?: {
|
|
16
|
+
delaySeconds?: number;
|
|
17
|
+
}): void;
|
|
18
|
+
}
|
|
19
|
+
/** A batch delivered to the consumer (the Cloudflare Queues `MessageBatch` shape). */
|
|
20
|
+
export interface QueueBatch<Body = unknown> {
|
|
21
|
+
/** The queue this batch came from (the oblaka `Queue` name; env-prefixed remotely). */
|
|
22
|
+
readonly queue: string;
|
|
23
|
+
readonly messages: readonly QueueMessage<Body>[];
|
|
24
|
+
ackAll(): void;
|
|
25
|
+
retryAll(options?: {
|
|
26
|
+
delaySeconds?: number;
|
|
27
|
+
}): void;
|
|
28
|
+
}
|
|
29
|
+
/** The context handed to a queue consumer handler. Worker-level (no `ctx.db`): reach
|
|
30
|
+
* tenant data via `ctx.callPrivileged`. */
|
|
31
|
+
export interface QueueContext {
|
|
32
|
+
/** The Worker environment (bindings + vars + secrets). */
|
|
33
|
+
readonly env: Readonly<Record<string, unknown>>;
|
|
34
|
+
/** Project KV (cross-tenant). */
|
|
35
|
+
readonly kv: Kv;
|
|
36
|
+
/** Send email (the notification path). */
|
|
37
|
+
readonly mail: Mail;
|
|
38
|
+
/** Enqueue onto a (possibly different) queue — fan-out / chaining. */
|
|
39
|
+
readonly queue: Queue;
|
|
40
|
+
/** Apply a privileged mutation into a tenant's DO (the consumer has no direct db).
|
|
41
|
+
* The message body should carry the `tenant`. */
|
|
42
|
+
callPrivileged(opts: {
|
|
43
|
+
name: string;
|
|
44
|
+
input?: unknown;
|
|
45
|
+
tenant?: string;
|
|
46
|
+
roles?: string[];
|
|
47
|
+
partition?: string;
|
|
48
|
+
}): Promise<Response>;
|
|
49
|
+
}
|
|
50
|
+
/** A queue consumer handler — runs once per message. Resolving ACKs the message;
|
|
51
|
+
* throwing RETRIES it (subject to the queue's max_retries → dead-letter queue). */
|
|
52
|
+
export type QueueHandler<Body = unknown> = (ctx: QueueContext, message: QueueMessage<Body>) => void | Promise<void>;
|
|
53
|
+
/** Map of queue name → consumer handler. Set as `app.queues`; dispatched by
|
|
54
|
+
* `createPramen(app).queue`. */
|
|
55
|
+
export type AppQueueMap = Record<string, QueueHandler>;
|
|
56
|
+
/** Resolve the handler for a batch's queue. Queue names are env-prefixed in remote
|
|
57
|
+
* environments (`production-pramen-jobs`) but bare locally (`pramen-jobs`), so match
|
|
58
|
+
* leniently: exact, then the LONGEST `…-<key>` suffix, then — if there's exactly one
|
|
59
|
+
* handler — fall through to it (the common single-queue app). Returns null if nothing
|
|
60
|
+
* matches.
|
|
61
|
+
*
|
|
62
|
+
* The suffix match must prefer the longest key so `email-jobs` wins over `jobs` for
|
|
63
|
+
* `prod-email-jobs` (a plain `find` was insertion-order dependent and could misroute).
|
|
64
|
+
* We only match a handler key that is a `-`-delimited suffix of the incoming queue name
|
|
65
|
+
* (env prefix stripped) — never the reverse (a handler key ending in `-<queueName>`),
|
|
66
|
+
* which let a shorter queue name grab a longer, unrelated handler. */
|
|
67
|
+
export declare function routeQueue(queues: AppQueueMap, queueName: string): QueueHandler | null;
|
|
68
|
+
/** Dispatch one batch: route to the handler, then run it per message, ACKing on success
|
|
69
|
+
* and RETRYing on throw (per message, so one poison message doesn't re-deliver the rest).
|
|
70
|
+
* An unrouted batch is retried whole (never silently acked) and logged. */
|
|
71
|
+
export declare function dispatchQueueBatch(queues: AppQueueMap, ctx: QueueContext, batch: QueueBatch): Promise<void>;
|
|
@@ -0,0 +1,63 @@
|
|
|
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
|
+
/** Resolve the handler for a batch's queue. Queue names are env-prefixed in remote
|
|
12
|
+
* environments (`production-pramen-jobs`) but bare locally (`pramen-jobs`), so match
|
|
13
|
+
* leniently: exact, then the LONGEST `…-<key>` suffix, then — if there's exactly one
|
|
14
|
+
* handler — fall through to it (the common single-queue app). Returns null if nothing
|
|
15
|
+
* matches.
|
|
16
|
+
*
|
|
17
|
+
* The suffix match must prefer the longest key so `email-jobs` wins over `jobs` for
|
|
18
|
+
* `prod-email-jobs` (a plain `find` was insertion-order dependent and could misroute).
|
|
19
|
+
* We only match a handler key that is a `-`-delimited suffix of the incoming queue name
|
|
20
|
+
* (env prefix stripped) — never the reverse (a handler key ending in `-<queueName>`),
|
|
21
|
+
* which let a shorter queue name grab a longer, unrelated handler. */
|
|
22
|
+
export function routeQueue(queues, queueName) {
|
|
23
|
+
const keys = Object.keys(queues);
|
|
24
|
+
if (queues[queueName])
|
|
25
|
+
return queues[queueName];
|
|
26
|
+
let best = null;
|
|
27
|
+
for (const k of keys) {
|
|
28
|
+
if (queueName.endsWith(`-${k}`) && (best === null || k.length > best.length))
|
|
29
|
+
best = k;
|
|
30
|
+
}
|
|
31
|
+
if (best !== null)
|
|
32
|
+
return queues[best];
|
|
33
|
+
// Single-handler fallback: a lone queue whose env-prefixed name we couldn't suffix-
|
|
34
|
+
// match. Kept for the common single-queue app, but LOG it — otherwise a dead-letter
|
|
35
|
+
// queue (a distinct name) would silently route to the one handler and hide the misroute.
|
|
36
|
+
if (keys.length === 1) {
|
|
37
|
+
console.warn(`pramen: routing queue '${queueName}' to the sole handler '${keys[0]}' by fallback ` +
|
|
38
|
+
`(no exact/suffix match — verify this isn't a dead-letter or foreign queue)`);
|
|
39
|
+
return queues[keys[0]];
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
/** Dispatch one batch: route to the handler, then run it per message, ACKing on success
|
|
44
|
+
* and RETRYing on throw (per message, so one poison message doesn't re-deliver the rest).
|
|
45
|
+
* An unrouted batch is retried whole (never silently acked) and logged. */
|
|
46
|
+
export async function dispatchQueueBatch(queues, ctx, batch) {
|
|
47
|
+
const handler = routeQueue(queues, batch.queue);
|
|
48
|
+
if (!handler) {
|
|
49
|
+
console.error(`pramen: no app.queues handler for queue '${batch.queue}' — retrying batch (declare it in app.queues)`);
|
|
50
|
+
batch.retryAll();
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
await Promise.all(batch.messages.map(async (message) => {
|
|
54
|
+
try {
|
|
55
|
+
await handler(ctx, message);
|
|
56
|
+
message.ack();
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
console.error(`pramen: queue '${batch.queue}' message ${message.id} failed (attempt ${message.attempts}) — retrying`, err);
|
|
60
|
+
message.retry();
|
|
61
|
+
}
|
|
62
|
+
}));
|
|
63
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/** Cloudflare Queues content type for a sent message. Omitted ⇒ the platform default
|
|
2
|
+
* (v8 structured clone). Use "json" for cross-runtime / external consumers. */
|
|
3
|
+
export type QueueContentType = "text" | "bytes" | "json" | "v8";
|
|
4
|
+
/** Per-message send options (mirrors the Cloudflare Queues producer API). */
|
|
5
|
+
export interface QueueSendOptions {
|
|
6
|
+
/** Defer delivery by N seconds (the consumer won't see the message until then). */
|
|
7
|
+
delaySeconds?: number;
|
|
8
|
+
/** How the body is serialized on the wire. Omitted ⇒ platform default (v8). */
|
|
9
|
+
contentType?: QueueContentType;
|
|
10
|
+
}
|
|
11
|
+
/** One message in a `sendBatch` — a body plus its own per-message options. */
|
|
12
|
+
export interface QueueSendRequest {
|
|
13
|
+
body: unknown;
|
|
14
|
+
delaySeconds?: number;
|
|
15
|
+
contentType?: QueueContentType;
|
|
16
|
+
}
|
|
17
|
+
/** Batch-level send options. */
|
|
18
|
+
export interface QueueBatchOptions {
|
|
19
|
+
/** Default delay applied to every message in the batch (per-message overrides win). */
|
|
20
|
+
delaySeconds?: number;
|
|
21
|
+
}
|
|
22
|
+
/** The Cloudflare Queues producer binding shape (what `env.<QUEUE>` exposes). A binding
|
|
23
|
+
* is recognized as a queue producer iff it has BOTH `send` and `sendBatch` (which
|
|
24
|
+
* distinguishes it from the email `send`-only binding, KV, R2, D1, …). */
|
|
25
|
+
export interface QueueProducerBinding {
|
|
26
|
+
send(body: unknown, options?: QueueSendOptions): Promise<void>;
|
|
27
|
+
sendBatch(messages: Iterable<QueueSendRequest>, options?: QueueBatchOptions): Promise<void>;
|
|
28
|
+
}
|
|
29
|
+
/** The transport seam — one per backend (Cloudflare Queues, an in-memory capture, …). */
|
|
30
|
+
export interface QueueAdapter {
|
|
31
|
+
send(queue: string, body: unknown, options?: QueueSendOptions): Promise<void>;
|
|
32
|
+
sendBatch(queue: string, messages: readonly QueueSendRequest[], options?: QueueBatchOptions): Promise<void>;
|
|
33
|
+
}
|
|
34
|
+
/** The `ctx.queue` facade: validates, then delegates to the adapter for a named queue. */
|
|
35
|
+
export declare class Queue {
|
|
36
|
+
private readonly adapter;
|
|
37
|
+
constructor(adapter: QueueAdapter);
|
|
38
|
+
/** Enqueue a single message onto `queue`. `body` is serialized by the platform. */
|
|
39
|
+
send(queue: string, body: unknown, options?: QueueSendOptions): Promise<void>;
|
|
40
|
+
/** Enqueue many messages onto `queue` in one call (cheaper than N sends). */
|
|
41
|
+
sendBatch(queue: string, messages: readonly QueueSendRequest[], options?: QueueBatchOptions): Promise<void>;
|
|
42
|
+
}
|
|
43
|
+
/** Cloudflare Queues transport. Constructed with the producer bindings discovered from
|
|
44
|
+
* the environment, keyed by binding name. Sending to a name with no bound queue throws
|
|
45
|
+
* a clear error (fail-closed) — a missing binding is a config error, not a silent drop. */
|
|
46
|
+
export declare class CloudflareQueueAdapter implements QueueAdapter {
|
|
47
|
+
private readonly bindings;
|
|
48
|
+
constructor(bindings: Readonly<Record<string, QueueProducerBinding>>);
|
|
49
|
+
private bindingFor;
|
|
50
|
+
send(queue: string, body: unknown, options?: QueueSendOptions): Promise<void>;
|
|
51
|
+
sendBatch(queue: string, messages: readonly QueueSendRequest[], options?: QueueBatchOptions): Promise<void>;
|
|
52
|
+
}
|
|
53
|
+
/** In-memory transport: captures sent messages instead of delivering them. For unit
|
|
54
|
+
* tests (assert on `.sent`) and pure off-platform use. */
|
|
55
|
+
export declare class MemoryQueueAdapter implements QueueAdapter {
|
|
56
|
+
readonly sent: Array<{
|
|
57
|
+
queue: string;
|
|
58
|
+
body: unknown;
|
|
59
|
+
options?: QueueSendOptions | QueueBatchOptions;
|
|
60
|
+
}>;
|
|
61
|
+
send(queue: string, body: unknown, options?: QueueSendOptions): Promise<void>;
|
|
62
|
+
sendBatch(queue: string, messages: readonly QueueSendRequest[], options?: QueueBatchOptions): Promise<void>;
|
|
63
|
+
}
|
|
64
|
+
/** Discover the Cloudflare Queues producer bindings in an environment: any value that
|
|
65
|
+
* exposes BOTH `send` and `sendBatch` functions (which excludes the email `send`-only
|
|
66
|
+
* binding, KV, R2, D1, the DO namespace, …). Returns name → binding. */
|
|
67
|
+
export declare function discoverQueueBindings(env: Readonly<Record<string, unknown>>): Record<string, QueueProducerBinding>;
|
|
68
|
+
/** Build `ctx.queue` from the environment: a Cloudflare adapter over the discovered
|
|
69
|
+
* producer bindings. Sending to an undeclared queue fails closed (the adapter throws).
|
|
70
|
+
* There is no silent capture fallback — declare the `Queue` binding and it exists in
|
|
71
|
+
* dev (lopata) and miniflare too. */
|
|
72
|
+
export declare function createQueue(env: Readonly<Record<string, unknown>>): Queue;
|
|
@@ -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
|
+
}
|
|
@@ -85,8 +85,13 @@ export function compileExpr(expr, dialect, params = []) {
|
|
|
85
85
|
case "false":
|
|
86
86
|
return { sql: "0", params };
|
|
87
87
|
case "cmp":
|
|
88
|
+
// A comparison against NULL is never TRUE in SQL (=, !=, <, > all yield NULL).
|
|
89
|
+
// Only the dedicated `null` node produces `IS NULL`; a `cmp` with a null operand
|
|
90
|
+
// matches nothing. (`eq()` already routes an equality-to-null to the `null` node,
|
|
91
|
+
// and the keyset comparator handles null order-keys explicitly — so no legitimate
|
|
92
|
+
// caller reaches here with a null value.)
|
|
88
93
|
if (expr.value === null)
|
|
89
|
-
return { sql:
|
|
94
|
+
return { sql: "0", params };
|
|
90
95
|
params.push(dialect.encode(expr.value));
|
|
91
96
|
return { sql: `${dialect.id(expr.col)} ${expr.op} ${dialect.placeholder(params.length)}`, params };
|
|
92
97
|
case "null":
|
|
@@ -144,7 +149,7 @@ export function evalExpr(expr, row) {
|
|
|
144
149
|
case "cmp": {
|
|
145
150
|
const left = bind(row[expr.col]);
|
|
146
151
|
if (expr.value === null)
|
|
147
|
-
return
|
|
152
|
+
return false; // comparison against NULL is never true (use the `null` node for IS NULL)
|
|
148
153
|
if (left === null || left === undefined)
|
|
149
154
|
return false; // NULL compared to a value -> false
|
|
150
155
|
const right = bind(expr.value);
|
|
@@ -1,14 +1,37 @@
|
|
|
1
1
|
import type { SchemaDef } from "../sdk/schema";
|
|
2
|
-
/**
|
|
3
|
-
export
|
|
2
|
+
/** The comparable fingerprint of a single column: type + migration-relevant modifiers. */
|
|
3
|
+
export interface ColumnShape {
|
|
4
|
+
type: string;
|
|
5
|
+
notNull?: boolean;
|
|
6
|
+
unique?: boolean;
|
|
7
|
+
primaryKey?: boolean;
|
|
8
|
+
generated?: boolean;
|
|
9
|
+
hidden?: boolean;
|
|
10
|
+
/** The literal or raw-SQL default, normalized to a string for comparison. */
|
|
11
|
+
default?: string;
|
|
12
|
+
}
|
|
13
|
+
/** The comparable fingerprint of a table: its partition + each column's shape. */
|
|
14
|
+
export interface TableShape {
|
|
15
|
+
partition: string;
|
|
16
|
+
columns: Record<string, ColumnShape>;
|
|
17
|
+
}
|
|
18
|
+
/** table -> table shape. The comparable surface of a schema. */
|
|
19
|
+
export type SchemaShape = Record<string, TableShape>;
|
|
4
20
|
export declare function schemaShape(schema: SchemaDef): SchemaShape;
|
|
5
21
|
export interface SchemaChange {
|
|
6
|
-
kind: "add-table" | "drop-table" | "add-column" | "drop-column" | "change-type";
|
|
22
|
+
kind: "add-table" | "drop-table" | "add-column" | "drop-column" | "change-type" | "change-column" | "move-partition";
|
|
7
23
|
table: string;
|
|
8
24
|
column?: string;
|
|
9
25
|
detail?: string;
|
|
10
|
-
/** true = rebuilds the table and may lose data (drop / type change)
|
|
11
|
-
*
|
|
26
|
+
/** true = rebuilds the table and may lose data (drop / type change). false = additive
|
|
27
|
+
* OR a metadata-only change (modifier / partition move) — see `appliesOnBoot`. */
|
|
12
28
|
destructive: boolean;
|
|
29
|
+
/** Whether migrate() enacts this change on the next DO boot. Additive changes are
|
|
30
|
+
* always applied; destructive changes (type/drop, or a constraint-tightening modifier
|
|
31
|
+
* change) apply only when the deploy sets PRAMEN_ALLOW_DESTRUCTIVE=true (and are
|
|
32
|
+
* skipped when the live data conflicts, leaving the hash unwritten). `false` here means
|
|
33
|
+
* the boot migrator will NEVER enact it — today only a partition MOVE (needs a manual
|
|
34
|
+
* cross-DO data migration). Reported for honesty. */
|
|
35
|
+
appliesOnBoot: boolean;
|
|
13
36
|
}
|
|
14
37
|
export declare function diffSchemaShape(prev: SchemaShape, next: SchemaShape): SchemaChange[];
|
|
@@ -1,41 +1,133 @@
|
|
|
1
|
-
// Schema shape + diff — powers the CLI's `schema diff`.
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// (
|
|
5
|
-
//
|
|
1
|
+
// Schema shape + diff — powers the CLI's `schema diff`. The diff is a REPORTING tool; it
|
|
2
|
+
// does not itself migrate. On the next DO boot migrate() applies ADDITIVE changes only
|
|
3
|
+
// (new table -> CREATE TABLE; new column -> ALTER TABLE ADD COLUMN). DESTRUCTIVE changes
|
|
4
|
+
// (drop column/table, type change, table rebuild) are SKIPPED unless the deploy sets
|
|
5
|
+
// PRAMEN_ALLOW_DESTRUCTIVE=true — and when skipped the schema hash is left unwritten so a
|
|
6
|
+
// later opt-in deploy retries. A rename can't be detected from a shape diff; it shows as
|
|
7
|
+
// drop+add unless declared with `renamedFrom` in the schema.
|
|
8
|
+
//
|
|
9
|
+
// The shape records column type + the migration-relevant modifiers (notNull, unique,
|
|
10
|
+
// primaryKey, generated, default, hidden) and the entity's partition, so the diff REPORTS
|
|
11
|
+
// modifier and partition changes too. migrate() now RECONCILES modifier changes on an
|
|
12
|
+
// existing column (a NOT NULL / DEFAULT / PRIMARY KEY change via a rebuild, a UNIQUE
|
|
13
|
+
// change via a create/drop index), so a `change-column` is `appliesOnBoot: true`. It is
|
|
14
|
+
// flagged `destructive` when it tightens a constraint (adds NOT NULL / UNIQUE / PRIMARY
|
|
15
|
+
// KEY) — those apply only under PRAMEN_ALLOW_DESTRUCTIVE, or are skipped when the live
|
|
16
|
+
// data conflicts (NULL rows / duplicates), leaving the hash unwritten. A partition MOVE
|
|
17
|
+
// still CANNOT be enacted on boot (a partition is a separate Durable Object — it needs a
|
|
18
|
+
// manual cross-DO data migration), so it stays `appliesOnBoot: false`.
|
|
19
|
+
import { partitionOf } from "../sdk/schema";
|
|
20
|
+
function columnShape(f) {
|
|
21
|
+
const c = { type: f.type };
|
|
22
|
+
if (f.notNull)
|
|
23
|
+
c.notNull = true;
|
|
24
|
+
if (f.unique)
|
|
25
|
+
c.unique = true;
|
|
26
|
+
if (f.primaryKey)
|
|
27
|
+
c.primaryKey = true;
|
|
28
|
+
if (f.generated)
|
|
29
|
+
c.generated = true;
|
|
30
|
+
if (f.hidden)
|
|
31
|
+
c.hidden = true;
|
|
32
|
+
if (f.defaultExpr !== undefined)
|
|
33
|
+
c.default = `(${f.defaultExpr})`;
|
|
34
|
+
else if (f.default !== undefined)
|
|
35
|
+
c.default = JSON.stringify(f.default);
|
|
36
|
+
return c;
|
|
37
|
+
}
|
|
6
38
|
export function schemaShape(schema) {
|
|
7
39
|
const out = {};
|
|
8
40
|
for (const [table, def] of Object.entries(schema)) {
|
|
9
|
-
const
|
|
41
|
+
const columns = {};
|
|
10
42
|
for (const [col, f] of Object.entries(def.fields))
|
|
11
|
-
|
|
12
|
-
out[table] =
|
|
43
|
+
columns[col] = columnShape(f);
|
|
44
|
+
out[table] = { partition: partitionOf(schema, table), columns };
|
|
13
45
|
}
|
|
14
46
|
return out;
|
|
15
47
|
}
|
|
48
|
+
/** The modifier fields compared for a `change-column` (everything but `type`). */
|
|
49
|
+
const MODIFIER_KEYS = ["notNull", "unique", "primaryKey", "generated", "hidden", "default"];
|
|
50
|
+
/** Does `next` tighten a constraint `prev` lacked (add NOT NULL / UNIQUE / PRIMARY KEY)?
|
|
51
|
+
* Such a change may require the destructive gate or be skipped when the live data
|
|
52
|
+
* conflicts (NULL rows / duplicates) — so the diff flags it `destructive`. */
|
|
53
|
+
function tightensConstraint(prev, next) {
|
|
54
|
+
return (!!next.notNull && !prev.notNull) || (!!next.unique && !prev.unique) || (!!next.primaryKey && !prev.primaryKey);
|
|
55
|
+
}
|
|
56
|
+
function modifierDiff(prev, next) {
|
|
57
|
+
const parts = [];
|
|
58
|
+
for (const k of MODIFIER_KEYS) {
|
|
59
|
+
if (prev[k] !== next[k])
|
|
60
|
+
parts.push(`${k}: ${fmt(prev[k])} → ${fmt(next[k])}`);
|
|
61
|
+
}
|
|
62
|
+
return parts.length ? parts.join(", ") : null;
|
|
63
|
+
}
|
|
64
|
+
function fmt(v) {
|
|
65
|
+
return v === undefined ? "—" : String(v);
|
|
66
|
+
}
|
|
16
67
|
export function diffSchemaShape(prev, next) {
|
|
17
68
|
const changes = [];
|
|
18
69
|
for (const table of Object.keys(next)) {
|
|
19
|
-
|
|
20
|
-
|
|
70
|
+
const pt = prev[table];
|
|
71
|
+
if (!pt) {
|
|
72
|
+
changes.push({ kind: "add-table", table, destructive: false, appliesOnBoot: true });
|
|
21
73
|
continue;
|
|
22
74
|
}
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
75
|
+
const nt = next[table];
|
|
76
|
+
if (pt.partition !== nt.partition) {
|
|
77
|
+
changes.push({
|
|
78
|
+
kind: "move-partition",
|
|
79
|
+
table,
|
|
80
|
+
detail: `${pt.partition} → ${nt.partition}`,
|
|
81
|
+
destructive: false,
|
|
82
|
+
// A partition is a separate Durable Object; boot migration can't move a table's
|
|
83
|
+
// data across DOs. Needs a manual data migration.
|
|
84
|
+
appliesOnBoot: false,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
for (const col of Object.keys(nt.columns)) {
|
|
88
|
+
const pc = pt.columns[col];
|
|
89
|
+
const ncol = nt.columns[col];
|
|
90
|
+
if (!pc) {
|
|
91
|
+
changes.push({ kind: "add-column", table, column: col, destructive: false, appliesOnBoot: true });
|
|
92
|
+
}
|
|
93
|
+
else if (pc.type !== ncol.type) {
|
|
94
|
+
changes.push({
|
|
95
|
+
kind: "change-type",
|
|
96
|
+
table,
|
|
97
|
+
column: col,
|
|
98
|
+
detail: `${pc.type} → ${ncol.type}`,
|
|
99
|
+
destructive: true,
|
|
100
|
+
// Applied only under PRAMEN_ALLOW_DESTRUCTIVE (a table rebuild). Report it as
|
|
101
|
+
// boot-applicable — the destructive-gating note explains the opt-in.
|
|
102
|
+
appliesOnBoot: true,
|
|
103
|
+
});
|
|
26
104
|
}
|
|
27
|
-
else
|
|
28
|
-
|
|
105
|
+
else {
|
|
106
|
+
const md = modifierDiff(pc, ncol);
|
|
107
|
+
if (md) {
|
|
108
|
+
changes.push({
|
|
109
|
+
kind: "change-column",
|
|
110
|
+
table,
|
|
111
|
+
column: col,
|
|
112
|
+
detail: md,
|
|
113
|
+
// Tightening a constraint (add NOT NULL / UNIQUE / PRIMARY KEY) rebuilds/
|
|
114
|
+
// indexes and applies only under PRAMEN_ALLOW_DESTRUCTIVE (or is skipped when
|
|
115
|
+
// live data conflicts). Loosening or a DEFAULT change is additive.
|
|
116
|
+
destructive: tightensConstraint(pc, ncol),
|
|
117
|
+
// migrate() now reconciles modifier changes on an existing column on boot.
|
|
118
|
+
appliesOnBoot: true,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
29
121
|
}
|
|
30
122
|
}
|
|
31
|
-
for (const col of Object.keys(
|
|
32
|
-
if (!(col in
|
|
33
|
-
changes.push({ kind: "drop-column", table, column: col, destructive: true });
|
|
123
|
+
for (const col of Object.keys(pt.columns)) {
|
|
124
|
+
if (!(col in nt.columns))
|
|
125
|
+
changes.push({ kind: "drop-column", table, column: col, destructive: true, appliesOnBoot: true });
|
|
34
126
|
}
|
|
35
127
|
}
|
|
36
128
|
for (const table of Object.keys(prev)) {
|
|
37
129
|
if (!(table in next))
|
|
38
|
-
changes.push({ kind: "drop-table", table, destructive: true });
|
|
130
|
+
changes.push({ kind: "drop-table", table, destructive: true, appliesOnBoot: true });
|
|
39
131
|
}
|
|
40
132
|
return changes;
|
|
41
133
|
}
|
|
@@ -72,3 +72,10 @@ export declare function handleFileRequest(request: Request, opts: {
|
|
|
72
72
|
adapter: StorageAdapter;
|
|
73
73
|
secret: string;
|
|
74
74
|
}): Promise<Response | null>;
|
|
75
|
+
/** Serve a PUBLIC media blob by key: `GET /media/<key>`. Cache-friendly + nosniff, no
|
|
76
|
+
* auth (published-site assets are public; the random tenant-scoped key is the capability).
|
|
77
|
+
* Returns a Response for any `/media/*` path, or null if not a media request. Restricted
|
|
78
|
+
* to `<tenant>/media/` keys so it can't serve arbitrary (e.g. signed-private) objects. */
|
|
79
|
+
export declare function handleMediaRequest(request: Request, opts: {
|
|
80
|
+
adapter: StorageAdapter;
|
|
81
|
+
}): Promise<Response | null>;
|
package/dist/runtime/storage.js
CHANGED
|
Binary file
|
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 {
|