@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
@@ -0,0 +1,155 @@
1
+ // ctx.queue — Cloudflare Queues producer facade, the same shape as ctx.mail / ctx.files:
2
+ // an adapter seam (CloudflareQueueAdapter / MemoryQueueAdapter) behind a thin `Queue`
3
+ // facade, built from the environment. Handlers enqueue onto a native Cloudflare Queue
4
+ // without touching the producer binding directly:
5
+ //
6
+ // await ctx.queue.send("jobs", { kind: "resize", id });
7
+ // await ctx.queue.sendBatch("jobs", [{ body: a }, { body: b, delaySeconds: 30 }]);
8
+ //
9
+ // This is distinct from `ctx.tasks` (the transactional outbox). `ctx.tasks.enqueue`
10
+ // is atomic with the mutation's DB write (commit-or-rollback together) and drained
11
+ // in-process. `ctx.queue` is a native Cloudflare Queue: NOT transactional with the
12
+ // write (the message is sent regardless of whether the mutation later rolls back),
13
+ // but higher-throughput, with platform-native batching/retry/DLQ and a consumer that
14
+ // can run in a *different* Worker. Reach for ctx.tasks when the side-effect must commit
15
+ // with the data; reach for ctx.queue for decoupled, high-volume fan-out.
16
+ //
17
+ // On Cloudflare the transport is the Queues producer binding (declared in oblaka.ts as
18
+ // `new Queue({ binding: "both", ... })`). Off-platform / unconfigured, sending to a queue
19
+ // that isn't bound FAILS CLOSED (throws) rather than silently dropping the message —
20
+ // mirroring how ctx.mail fails closed without a transport.
21
+
22
+ /** Cloudflare Queues content type for a sent message. Omitted ⇒ the platform default
23
+ * (v8 structured clone). Use "json" for cross-runtime / external consumers. */
24
+ export type QueueContentType = "text" | "bytes" | "json" | "v8";
25
+
26
+ /** Per-message send options (mirrors the Cloudflare Queues producer API). */
27
+ export interface QueueSendOptions {
28
+ /** Defer delivery by N seconds (the consumer won't see the message until then). */
29
+ delaySeconds?: number;
30
+ /** How the body is serialized on the wire. Omitted ⇒ platform default (v8). */
31
+ contentType?: QueueContentType;
32
+ }
33
+
34
+ /** One message in a `sendBatch` — a body plus its own per-message options. */
35
+ export interface QueueSendRequest {
36
+ body: unknown;
37
+ delaySeconds?: number;
38
+ contentType?: QueueContentType;
39
+ }
40
+
41
+ /** Batch-level send options. */
42
+ export interface QueueBatchOptions {
43
+ /** Default delay applied to every message in the batch (per-message overrides win). */
44
+ delaySeconds?: number;
45
+ }
46
+
47
+ /** The Cloudflare Queues producer binding shape (what `env.<QUEUE>` exposes). A binding
48
+ * is recognized as a queue producer iff it has BOTH `send` and `sendBatch` (which
49
+ * distinguishes it from the email `send`-only binding, KV, R2, D1, …). */
50
+ export interface QueueProducerBinding {
51
+ send(body: unknown, options?: QueueSendOptions): Promise<void>;
52
+ sendBatch(messages: Iterable<QueueSendRequest>, options?: QueueBatchOptions): Promise<void>;
53
+ }
54
+
55
+ /** The transport seam — one per backend (Cloudflare Queues, an in-memory capture, …). */
56
+ export interface QueueAdapter {
57
+ send(queue: string, body: unknown, options?: QueueSendOptions): Promise<void>;
58
+ sendBatch(queue: string, messages: readonly QueueSendRequest[], options?: QueueBatchOptions): Promise<void>;
59
+ }
60
+
61
+ /** The `ctx.queue` facade: validates, then delegates to the adapter for a named queue. */
62
+ export class Queue {
63
+ constructor(private readonly adapter: QueueAdapter) {}
64
+
65
+ /** Enqueue a single message onto `queue`. `body` is serialized by the platform. */
66
+ async send(queue: string, body: unknown, options?: QueueSendOptions): Promise<void> {
67
+ assertQueueName(queue);
68
+ if (body === undefined) throw new Error("ctx.queue.send: `body` is required");
69
+ await this.adapter.send(queue, body, options);
70
+ }
71
+
72
+ /** Enqueue many messages onto `queue` in one call (cheaper than N sends). */
73
+ async sendBatch(queue: string, messages: readonly QueueSendRequest[], options?: QueueBatchOptions): Promise<void> {
74
+ assertQueueName(queue);
75
+ if (!Array.isArray(messages) || messages.length === 0) {
76
+ throw new Error("ctx.queue.sendBatch: `messages` must be a non-empty array");
77
+ }
78
+ for (const m of messages) {
79
+ if (!m || m.body === undefined) throw new Error("ctx.queue.sendBatch: every message needs a `body`");
80
+ }
81
+ await this.adapter.sendBatch(queue, messages, options);
82
+ }
83
+ }
84
+
85
+ function assertQueueName(queue: string): void {
86
+ if (typeof queue !== "string" || queue.length === 0) {
87
+ throw new Error("ctx.queue: a queue name is required (the oblaka `Queue` name)");
88
+ }
89
+ }
90
+
91
+ /** Cloudflare Queues transport. Constructed with the producer bindings discovered from
92
+ * the environment, keyed by binding name. Sending to a name with no bound queue throws
93
+ * a clear error (fail-closed) — a missing binding is a config error, not a silent drop. */
94
+ export class CloudflareQueueAdapter implements QueueAdapter {
95
+ constructor(private readonly bindings: Readonly<Record<string, QueueProducerBinding>>) {}
96
+
97
+ private bindingFor(queue: string): QueueProducerBinding {
98
+ const b = this.bindings[queue];
99
+ if (!b) {
100
+ const known = Object.keys(this.bindings);
101
+ const avail = known.length ? known.join(", ") : "none";
102
+ throw new Error(
103
+ `ctx.queue: no queue binding '${queue}' — declare it in oblaka.ts ` +
104
+ `(new Queue({ name: '${queue}', binding: 'both' })). Bound queues: ${avail}.`,
105
+ );
106
+ }
107
+ return b;
108
+ }
109
+
110
+ async send(queue: string, body: unknown, options?: QueueSendOptions): Promise<void> {
111
+ await this.bindingFor(queue).send(body, options);
112
+ }
113
+
114
+ async sendBatch(queue: string, messages: readonly QueueSendRequest[], options?: QueueBatchOptions): Promise<void> {
115
+ await this.bindingFor(queue).sendBatch(messages, options);
116
+ }
117
+ }
118
+
119
+ /** In-memory transport: captures sent messages instead of delivering them. For unit
120
+ * tests (assert on `.sent`) and pure off-platform use. */
121
+ export class MemoryQueueAdapter implements QueueAdapter {
122
+ readonly sent: Array<{ queue: string; body: unknown; options?: QueueSendOptions | QueueBatchOptions }> = [];
123
+ async send(queue: string, body: unknown, options?: QueueSendOptions): Promise<void> {
124
+ this.sent.push({ queue, body, options });
125
+ }
126
+ async sendBatch(queue: string, messages: readonly QueueSendRequest[], options?: QueueBatchOptions): Promise<void> {
127
+ for (const m of messages) this.sent.push({ queue, body: m.body, options: { ...options, ...m } });
128
+ }
129
+ }
130
+
131
+ /** Discover the Cloudflare Queues producer bindings in an environment: any value that
132
+ * exposes BOTH `send` and `sendBatch` functions (which excludes the email `send`-only
133
+ * binding, KV, R2, D1, the DO namespace, …). Returns name → binding. */
134
+ export function discoverQueueBindings(env: Readonly<Record<string, unknown>>): Record<string, QueueProducerBinding> {
135
+ const out: Record<string, QueueProducerBinding> = {};
136
+ for (const [name, value] of Object.entries(env)) {
137
+ if (
138
+ value &&
139
+ typeof value === "object" &&
140
+ typeof (value as { send?: unknown }).send === "function" &&
141
+ typeof (value as { sendBatch?: unknown }).sendBatch === "function"
142
+ ) {
143
+ out[name] = value as QueueProducerBinding;
144
+ }
145
+ }
146
+ return out;
147
+ }
148
+
149
+ /** Build `ctx.queue` from the environment: a Cloudflare adapter over the discovered
150
+ * producer bindings. Sending to an undeclared queue fails closed (the adapter throws).
151
+ * There is no silent capture fallback — declare the `Queue` binding and it exists in
152
+ * dev (lopata) and miniflare too. */
153
+ export function createQueue(env: Readonly<Record<string, unknown>>): Queue {
154
+ return new Queue(new CloudflareQueueAdapter(discoverQueueBindings(env)));
155
+ }
@@ -91,7 +91,12 @@ export function compileExpr(expr: SqlExpr, dialect: Dialect, params: unknown[] =
91
91
  case "false":
92
92
  return { sql: "0", params };
93
93
  case "cmp":
94
- if (expr.value === null) return { sql: `${dialect.id(expr.col)} IS NULL`, params };
94
+ // A comparison against NULL is never TRUE in SQL (=, !=, <, > all yield NULL).
95
+ // Only the dedicated `null` node produces `IS NULL`; a `cmp` with a null operand
96
+ // matches nothing. (`eq()` already routes an equality-to-null to the `null` node,
97
+ // and the keyset comparator handles null order-keys explicitly — so no legitimate
98
+ // caller reaches here with a null value.)
99
+ if (expr.value === null) return { sql: "0", params };
95
100
  params.push(dialect.encode(expr.value));
96
101
  return { sql: `${dialect.id(expr.col)} ${expr.op} ${dialect.placeholder(params.length)}`, params };
97
102
  case "null":
@@ -145,7 +150,7 @@ export function evalExpr(expr: SqlExpr, row: Record<string, unknown>): boolean {
145
150
  return false;
146
151
  case "cmp": {
147
152
  const left = bind(row[expr.col]);
148
- if (expr.value === null) return left === null || left === undefined;
153
+ if (expr.value === null) return false; // comparison against NULL is never true (use the `null` node for IS NULL)
149
154
  if (left === null || left === undefined) return false; // NULL compared to a value -> false
150
155
  const right = bind(expr.value);
151
156
  switch (expr.op) {
@@ -1,56 +1,170 @@
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`.
6
19
 
7
20
  import type { FieldDef, SchemaDef } from "../sdk/schema";
21
+ import { partitionOf } from "../sdk/schema";
8
22
 
9
- /** table -> column -> field type. The comparable surface of a schema. */
10
- export type SchemaShape = Record<string, Record<string, string>>;
23
+ /** The comparable fingerprint of a single column: type + migration-relevant modifiers. */
24
+ export interface ColumnShape {
25
+ type: string;
26
+ notNull?: boolean;
27
+ unique?: boolean;
28
+ primaryKey?: boolean;
29
+ generated?: boolean;
30
+ hidden?: boolean;
31
+ /** The literal or raw-SQL default, normalized to a string for comparison. */
32
+ default?: string;
33
+ }
34
+
35
+ /** The comparable fingerprint of a table: its partition + each column's shape. */
36
+ export interface TableShape {
37
+ partition: string;
38
+ columns: Record<string, ColumnShape>;
39
+ }
40
+
41
+ /** table -> table shape. The comparable surface of a schema. */
42
+ export type SchemaShape = Record<string, TableShape>;
43
+
44
+ function columnShape(f: FieldDef): ColumnShape {
45
+ const c: ColumnShape = { type: f.type };
46
+ if (f.notNull) c.notNull = true;
47
+ if (f.unique) c.unique = true;
48
+ if (f.primaryKey) c.primaryKey = true;
49
+ if (f.generated) c.generated = true;
50
+ if (f.hidden) c.hidden = true;
51
+ if (f.defaultExpr !== undefined) c.default = `(${f.defaultExpr})`;
52
+ else if (f.default !== undefined) c.default = JSON.stringify(f.default);
53
+ return c;
54
+ }
11
55
 
12
56
  export function schemaShape(schema: SchemaDef): SchemaShape {
13
57
  const out: SchemaShape = {};
14
58
  for (const [table, def] of Object.entries(schema)) {
15
- const cols: Record<string, string> = {};
16
- for (const [col, f] of Object.entries(def.fields)) cols[col] = (f as FieldDef).type;
17
- out[table] = cols;
59
+ const columns: Record<string, ColumnShape> = {};
60
+ for (const [col, f] of Object.entries(def.fields)) columns[col] = columnShape(f as FieldDef);
61
+ out[table] = { partition: partitionOf(schema, table), columns };
18
62
  }
19
63
  return out;
20
64
  }
21
65
 
66
+ /** The modifier fields compared for a `change-column` (everything but `type`). */
67
+ const MODIFIER_KEYS: (keyof ColumnShape)[] = ["notNull", "unique", "primaryKey", "generated", "hidden", "default"];
68
+
69
+ /** Does `next` tighten a constraint `prev` lacked (add NOT NULL / UNIQUE / PRIMARY KEY)?
70
+ * Such a change may require the destructive gate or be skipped when the live data
71
+ * conflicts (NULL rows / duplicates) — so the diff flags it `destructive`. */
72
+ function tightensConstraint(prev: ColumnShape, next: ColumnShape): boolean {
73
+ return (!!next.notNull && !prev.notNull) || (!!next.unique && !prev.unique) || (!!next.primaryKey && !prev.primaryKey);
74
+ }
75
+
76
+ function modifierDiff(prev: ColumnShape, next: ColumnShape): string | null {
77
+ const parts: string[] = [];
78
+ for (const k of MODIFIER_KEYS) {
79
+ if (prev[k] !== next[k]) parts.push(`${k}: ${fmt(prev[k])} → ${fmt(next[k])}`);
80
+ }
81
+ return parts.length ? parts.join(", ") : null;
82
+ }
83
+
84
+ function fmt(v: unknown): string {
85
+ return v === undefined ? "—" : String(v);
86
+ }
87
+
22
88
  export interface SchemaChange {
23
- kind: "add-table" | "drop-table" | "add-column" | "drop-column" | "change-type";
89
+ kind: "add-table" | "drop-table" | "add-column" | "drop-column" | "change-type" | "change-column" | "move-partition";
24
90
  table: string;
25
91
  column?: string;
26
92
  detail?: string;
27
- /** true = rebuilds the table and may lose data (drop / type change); false =
28
- * additive, no data loss. All changes are auto-applied on the next DO boot. */
93
+ /** true = rebuilds the table and may lose data (drop / type change). false = additive
94
+ * OR a metadata-only change (modifier / partition move) see `appliesOnBoot`. */
29
95
  destructive: boolean;
96
+ /** Whether migrate() enacts this change on the next DO boot. Additive changes are
97
+ * always applied; destructive changes (type/drop, or a constraint-tightening modifier
98
+ * change) apply only when the deploy sets PRAMEN_ALLOW_DESTRUCTIVE=true (and are
99
+ * skipped when the live data conflicts, leaving the hash unwritten). `false` here means
100
+ * the boot migrator will NEVER enact it — today only a partition MOVE (needs a manual
101
+ * cross-DO data migration). Reported for honesty. */
102
+ appliesOnBoot: boolean;
30
103
  }
31
104
 
32
105
  export function diffSchemaShape(prev: SchemaShape, next: SchemaShape): SchemaChange[] {
33
106
  const changes: SchemaChange[] = [];
34
107
 
35
108
  for (const table of Object.keys(next)) {
36
- if (!(table in prev)) {
37
- changes.push({ kind: "add-table", table, destructive: false });
109
+ const pt = prev[table];
110
+ if (!pt) {
111
+ changes.push({ kind: "add-table", table, destructive: false, appliesOnBoot: true });
38
112
  continue;
39
113
  }
40
- for (const col of Object.keys(next[table]!)) {
41
- if (!(col in prev[table]!)) {
42
- changes.push({ kind: "add-column", table, column: col, destructive: false });
43
- } else if (prev[table]![col] !== next[table]![col]) {
44
- changes.push({ kind: "change-type", table, column: col, detail: `${prev[table]![col]} → ${next[table]![col]}`, destructive: true });
114
+ const nt = next[table]!;
115
+ if (pt.partition !== nt.partition) {
116
+ changes.push({
117
+ kind: "move-partition",
118
+ table,
119
+ detail: `${pt.partition} → ${nt.partition}`,
120
+ destructive: false,
121
+ // A partition is a separate Durable Object; boot migration can't move a table's
122
+ // data across DOs. Needs a manual data migration.
123
+ appliesOnBoot: false,
124
+ });
125
+ }
126
+ for (const col of Object.keys(nt.columns)) {
127
+ const pc = pt.columns[col];
128
+ const ncol = nt.columns[col]!;
129
+ if (!pc) {
130
+ changes.push({ kind: "add-column", table, column: col, destructive: false, appliesOnBoot: true });
131
+ } else if (pc.type !== ncol.type) {
132
+ changes.push({
133
+ kind: "change-type",
134
+ table,
135
+ column: col,
136
+ detail: `${pc.type} → ${ncol.type}`,
137
+ destructive: true,
138
+ // Applied only under PRAMEN_ALLOW_DESTRUCTIVE (a table rebuild). Report it as
139
+ // boot-applicable — the destructive-gating note explains the opt-in.
140
+ appliesOnBoot: true,
141
+ });
142
+ } else {
143
+ const md = modifierDiff(pc, ncol);
144
+ if (md) {
145
+ changes.push({
146
+ kind: "change-column",
147
+ table,
148
+ column: col,
149
+ detail: md,
150
+ // Tightening a constraint (add NOT NULL / UNIQUE / PRIMARY KEY) rebuilds/
151
+ // indexes and applies only under PRAMEN_ALLOW_DESTRUCTIVE (or is skipped when
152
+ // live data conflicts). Loosening or a DEFAULT change is additive.
153
+ destructive: tightensConstraint(pc, ncol),
154
+ // migrate() now reconciles modifier changes on an existing column on boot.
155
+ appliesOnBoot: true,
156
+ });
157
+ }
45
158
  }
46
159
  }
47
- for (const col of Object.keys(prev[table]!)) {
48
- if (!(col in next[table]!)) changes.push({ kind: "drop-column", table, column: col, destructive: true });
160
+ for (const col of Object.keys(pt.columns)) {
161
+ if (!(col in nt.columns))
162
+ changes.push({ kind: "drop-column", table, column: col, destructive: true, appliesOnBoot: true });
49
163
  }
50
164
  }
51
165
 
52
166
  for (const table of Object.keys(prev)) {
53
- if (!(table in next)) changes.push({ kind: "drop-table", table, destructive: true });
167
+ if (!(table in next)) changes.push({ kind: "drop-table", table, destructive: true, appliesOnBoot: true });
54
168
  }
55
169
 
56
170
  return changes;
Binary file
@@ -5,6 +5,7 @@
5
5
  import type { Db } from "../runtime/db";
6
6
  import type { Kv } from "../runtime/kv";
7
7
  import type { Mail } from "../runtime/mail";
8
+ import type { Queue } from "../runtime/queue";
8
9
  import type { Identity } from "./acl";
9
10
  import type { Files } from "./files";
10
11
  import type { SchemaDef } from "./schema";
@@ -36,6 +37,12 @@ export interface HandlerContext<S extends SchemaDef = SchemaDef> {
36
37
  * runs the matching `app.tasks` handler after commit, off the write path, with
37
38
  * retry. For notification email, webhooks, etc. — see `app.tasks`. */
38
39
  readonly tasks: Tasks;
40
+ /** Enqueue onto a native Cloudflare Queue: `ctx.queue.send("jobs", body)`. Unlike
41
+ * `ctx.tasks` (a transactional outbox, atomic with the mutation, drained in-process),
42
+ * a queue send is NOT transactional with the write but is higher-throughput, with
43
+ * platform-native batching/retry/DLQ and a consumer that may live in another Worker.
44
+ * Declare queues in oblaka.ts; consume them via `app.queues`. */
45
+ readonly queue: Queue;
39
46
  }
40
47
 
41
48
  /** The deferred-side-effects facade handed to handlers as `ctx.tasks`. */