@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.
Files changed (53) hide show
  1. package/dist/auth.d.ts +17 -2
  2. package/dist/auth.js +26 -7
  3. package/dist/cli.d.ts +2 -0
  4. package/dist/cli.js +311 -0
  5. package/dist/durable-object.d.ts +22 -4
  6. package/dist/durable-object.js +121 -55
  7. package/dist/index.d.ts +4 -0
  8. package/dist/index.js +3 -0
  9. package/dist/pramen.d.ts +6 -0
  10. package/dist/pramen.js +1 -1
  11. package/dist/runtime/acl.js +28 -7
  12. package/dist/runtime/db.d.ts +6 -0
  13. package/dist/runtime/db.js +86 -10
  14. package/dist/runtime/ddl.d.ts +16 -3
  15. package/dist/runtime/ddl.js +28 -8
  16. package/dist/runtime/dispatch.js +2 -0
  17. package/dist/runtime/driver.d.ts +41 -7
  18. package/dist/runtime/driver.js +38 -11
  19. package/dist/runtime/migrate.d.ts +1 -1
  20. package/dist/runtime/migrate.js +222 -33
  21. package/dist/runtime/outbox.js +28 -6
  22. package/dist/runtime/queue-consumer.d.ts +71 -0
  23. package/dist/runtime/queue-consumer.js +63 -0
  24. package/dist/runtime/queue.d.ts +72 -0
  25. package/dist/runtime/queue.js +110 -0
  26. package/dist/runtime/read-engine.js +7 -2
  27. package/dist/runtime/schema-diff.d.ts +28 -5
  28. package/dist/runtime/schema-diff.js +111 -19
  29. package/dist/runtime/storage.d.ts +7 -0
  30. package/dist/runtime/storage.js +0 -0
  31. package/dist/sdk/handlers.d.ts +7 -0
  32. package/dist/worker.d.ts +36 -0
  33. package/dist/worker.js +128 -18
  34. package/package.json +6 -2
  35. package/src/auth.ts +64 -21
  36. package/src/cli.ts +336 -0
  37. package/src/durable-object.ts +118 -52
  38. package/src/index.ts +6 -0
  39. package/src/pramen.ts +7 -1
  40. package/src/runtime/acl.ts +25 -5
  41. package/src/runtime/db.ts +80 -9
  42. package/src/runtime/ddl.ts +26 -8
  43. package/src/runtime/dispatch.ts +2 -0
  44. package/src/runtime/driver.ts +52 -9
  45. package/src/runtime/migrate.ts +246 -34
  46. package/src/runtime/outbox.ts +30 -7
  47. package/src/runtime/queue-consumer.ts +116 -0
  48. package/src/runtime/queue.ts +155 -0
  49. package/src/runtime/read-engine.ts +7 -2
  50. package/src/runtime/schema-diff.ts +137 -23
  51. package/src/runtime/storage.ts +0 -0
  52. package/src/sdk/handlers.ts +7 -0
  53. package/src/worker.ts +162 -19
@@ -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; nextRunAt = the earliest pending runAt (any), so
105
- // the DO can schedule its alarm exactly when the next task including a backed-off
106
- // retry becomes due.
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 nextRunAt`, enc(driver, ["pending", now, "pending"]));
110
- const nextRaw = stats[0]?.nextRunAt;
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: nextRaw == null ? null : Number(nextRaw),
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: `${dialect.id(expr.col)} IS NULL`, params };
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 left === null || left === undefined;
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
- /** table -> column -> field type. The comparable surface of a schema. */
3
- export type SchemaShape = Record<string, Record<string, string>>;
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); false =
11
- * additive, no data loss. All changes are auto-applied on the next DO boot. */
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`. migrate() applies every
2
- // change on the next DO boot, additive AND destructive. A diff classifies each as
3
- // `destructive` (drop / type change rebuilds the table and CAN lose data) or not
4
- // (add table/column no data loss). A rename can't be detected from a shape diff;
5
- // it shows as drop+add unless declared with `renamedFrom` in the schema.
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 cols = {};
41
+ const columns = {};
10
42
  for (const [col, f] of Object.entries(def.fields))
11
- cols[col] = f.type;
12
- out[table] = cols;
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
- if (!(table in prev)) {
20
- changes.push({ kind: "add-table", table, destructive: false });
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
- for (const col of Object.keys(next[table])) {
24
- if (!(col in prev[table])) {
25
- changes.push({ kind: "add-column", table, column: col, destructive: false });
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 if (prev[table][col] !== next[table][col]) {
28
- changes.push({ kind: "change-type", table, column: col, detail: `${prev[table][col]} → ${next[table][col]}`, destructive: true });
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(prev[table])) {
32
- if (!(col in next[table]))
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>;
Binary file
@@ -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 {