@lunora/queue 1.0.0-alpha.11 → 1.0.0-alpha.110

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/README.md CHANGED
@@ -26,18 +26,36 @@ import { api } from "./_generated/api";
26
26
  export const emailQueue = defineQueue<{ to: string }>({
27
27
  handler: async (ctx, batch) => {
28
28
  for (const message of batch.messages) {
29
- await ctx.run(api.email.send, { to: message.body.to });
29
+ await message.run(api.email.send, { to: message.body.to });
30
30
  message.ack();
31
31
  }
32
32
  },
33
33
  });
34
34
  ```
35
35
 
36
+ `message.run(...)` is `ctx.run(...)` pinned to that message. Call it inside the
37
+ batch loop: when the dispatched function fails deterministically (`400`, `403`,
38
+ `404`, `422`), the consumer acks just that message and retries the rest instead
39
+ of letting one poison message retry — and eventually dead-letter — the whole
40
+ batch.
41
+
36
42
  ```ts
37
43
  // inside a mutation/action
38
44
  await ctx.queues.emailQueue.send({ to: user.email });
39
45
  ```
40
46
 
47
+ For one event with several consumers, declare a topic and subscribe to it.
48
+ Each subscription is its own queue, with its own retries and dead-letter queue:
49
+
50
+ ```ts
51
+ // lunora/queues.ts
52
+ export const signups = defineTopic<{ userId: string }>();
53
+ export const welcomeEmail = defineSubscription(signups, { handler: async (ctx, batch) => {} });
54
+
55
+ // inside a mutation/action
56
+ await ctx.topics.signups.publish({ userId });
57
+ ```
58
+
41
59
  Codegen emits the typed `ctx.queues` producer and the worker `queue()` dispatch;
42
60
  `@lunora/config` reconciles the wrangler `queues.producers[]` /
43
61
  `queues.consumers[]` entries from the same definition. Scaffold a new queue with
package/dist/index.d.mts CHANGED
@@ -1,3 +1,5 @@
1
+ import { QueueBindingLike, MessageBatchLike, MessageLike, QueueSendOptions, MessageSendRequestLike, QueueSendBatchOptions } from '@lunora/platform';
2
+ export type { MessageBatchLike, MessageLike, MessageSendRequestLike, QueueBindingLike, QueueContentType, QueueRetryOptions, QueueSendBatchOptions, QueueSendOptions } from '@lunora/platform';
1
3
  /**
2
4
  * Shared types for dispatching a Lunora function back into the worker from a
3
5
  * server-initiated context (a workflow body, a queue handler, a scheduled job).
@@ -12,8 +14,45 @@ interface FunctionReference {
12
14
  type ArgsOf<F> = F extends FunctionReference ? Record<string, unknown> : never;
13
15
  /** Options for a function call made via a dispatch runner. */
14
16
  interface RunFunctionOptions {
17
+ /**
18
+ * The at-least-once dedup key for THIS ONE CALL, sent to the dispatch
19
+ * endpoint as the body's `id` and forwarded to the shard as the
20
+ * replay-dedup `mutationId` — so a redelivery that re-runs the same call
21
+ * applies it exactly once instead of twice.
22
+ *
23
+ * Must be unique per call and stable across redeliveries. It is NOT
24
+ * {@link RunFunctionOptions.messageId}: the shard's dedup table is keyed
25
+ * `(identity, mutationId)` with no function path in it, and every
26
+ * server-initiated dispatch shares the `"system:"` identity, so reusing
27
+ * one id across two calls makes the second return the FIRST call's cached
28
+ * result without ever executing. A per-message id is 1:N with the calls a
29
+ * handler makes; this is 1:1.
30
+ *
31
+ * `@lunora/queue`'s `message.run` derives one automatically as
32
+ * `<messageId>#<n>`, `n` counting that message's calls in order. That is
33
+ * stable across redeliveries because the handler replays from the start —
34
+ * it relies on the handler issuing its `run` calls in a DETERMINISTIC
35
+ * order, which at-least-once replay already assumes. A handler whose call
36
+ * order varies per attempt (branching on `Date.now()`, `Math.random()`,
37
+ * or unordered concurrent settles) must pass its own stable ids instead.
38
+ *
39
+ * Optional; when omitted the dispatch is at-least-once.
40
+ */
41
+ dedupId?: string;
42
+ /**
43
+ * Correlate this call with a caller-defined message/item id (e.g. a queue
44
+ * message's `id`), for failure attribution only — never sent to the
45
+ * dispatch endpoint. Carried onto the `LunoraError` a deterministic
46
+ * dispatch failure throws, so a batching consumer (`@lunora/queue`'s push
47
+ * handler) can read it back and attribute the failure to the one item that
48
+ * caused it instead of the whole batch. Deliberately NOT the dedup key —
49
+ * see {@link RunFunctionOptions.dedupId}. Optional and inert when omitted.
50
+ */
51
+ messageId?: string;
15
52
  /** Route the call to a specific shard (defaults to the worker's root shard). */
16
53
  shardKey?: string;
54
+ /** Abort the dispatch after this many ms; the abort is retryable. Overrides the runner's default. */
55
+ timeoutMs?: number;
17
56
  }
18
57
  /** Invoke a Lunora function (query/mutation/action) by reference. The shape of `ctx.run`. */
19
58
  type DispatchRunFunction = <F extends FunctionReference>(function_: F, args?: ArgsOf<F>, options?: RunFunctionOptions) => Promise<unknown>;
@@ -24,62 +63,8 @@ interface DispatchLogger {
24
63
  info: (message: unknown, ...rest: unknown[]) => void;
25
64
  warn: (message: unknown, ...rest: unknown[]) => void;
26
65
  }
27
- /** How a queue message body is serialized on the wire (Cloudflare default `"json"`). */
28
- type QueueContentType = "bytes" | "json" | "text" | "v8";
29
- /** Options for a single `producer.send(body, options?)`. */
30
- interface QueueSendOptions {
31
- /** Wire serialization for this message (defaults to the queue's content type). */
32
- contentType?: QueueContentType;
33
- /** Per-message delivery delay in seconds (0–43200, i.e. up to 12 hours). */
34
- delaySeconds?: number;
35
- }
36
- /** Options for a `producer.sendBatch(messages, options?)`. */
37
- interface QueueSendBatchOptions {
38
- /** Delivery delay applied to the whole batch, in seconds. */
39
- delaySeconds?: number;
40
- }
41
- /** One entry in a `sendBatch` call — a body plus optional per-message overrides. */
42
- interface MessageSendRequestLike<Body = unknown> {
43
- body: Body;
44
- contentType?: QueueContentType;
45
- delaySeconds?: number;
46
- }
47
- /**
48
- * Minimal structural projection of workers-types' `Queue&lt;Body>` (the producer
49
- * binding). The real binding's `send`/`sendBatch` resolve to a metadata object;
50
- * we widen the return to `Promise&lt;unknown>` so a plain-object fake satisfies it.
51
- */
52
- interface QueueBindingLike<Body = unknown> {
53
- send: (message: Body, options?: QueueSendOptions) => Promise<unknown>;
54
- sendBatch: (messages: Iterable<MessageSendRequestLike<Body>>, options?: QueueSendBatchOptions) => Promise<unknown>;
55
- }
56
- /** Options for retrying a message / batch (`message.retry({ delaySeconds })`). */
57
- interface QueueRetryOptions {
58
- delaySeconds?: number;
59
- }
60
- /** Structural mirror of workers-types' `Message&lt;Body>` (one delivered message). */
61
- interface MessageLike<Body = unknown> {
62
- /** Acknowledge this message so it is not redelivered. */
63
- ack: () => void;
64
- readonly attempts: number;
65
- readonly body: Body;
66
- readonly id: string;
67
- /** Explicitly retry this message (optionally after a delay). */
68
- retry: (options?: QueueRetryOptions) => void;
69
- readonly timestamp: Date;
70
- }
71
- /** Structural mirror of workers-types' `MessageBatch&lt;Body>` handed to a consumer. */
72
- interface MessageBatchLike<Body = unknown> {
73
- /** Acknowledge every message in the batch. */
74
- ackAll: () => void;
75
- readonly messages: ReadonlyArray<MessageLike<Body>>;
76
- /** The queue name this batch was delivered from (`batch.queue`), used to route. */
77
- readonly queue: string;
78
- /** Retry every message in the batch (optionally after a delay). */
79
- retryAll: (options?: QueueRetryOptions) => void;
80
- }
81
66
  /**
82
- * The typed producer bound to `ctx.queues.&lt;name>`. Sending is a side effect, so
67
+ * The typed producer bound to `ctx.queues.<name>`. Sending is a side effect, so
83
68
  * the generated context exposes this only on `MutationCtx` / `ActionCtx` (never
84
69
  * the deterministic `QueryCtx`), mirroring `ctx.scheduler` / `ctx.workflows`.
85
70
  */
@@ -91,16 +76,16 @@ interface QueueProducer<Body = unknown> {
91
76
  }
92
77
  /**
93
78
  * `ctx.queues` — the map of declared queue export names → typed producers.
94
- * Codegen narrows this to the exact export names; the package keeps it open so
95
- * `createQueues` stays schema-agnostic.
79
+ * Keyed by the exact export names `createQueueContext` / `createQueues` are
80
+ * handed, so `queues.jobs` is a `QueueProducer` rather than `QueueProducer |
81
+ * undefined` under `noUncheckedIndexedAccess`. `Name` defaults to `string`
82
+ * (an open map) for schema-agnostic callers.
96
83
  */
97
- interface Queues {
98
- [exportName: string]: QueueProducer;
99
- }
84
+ type Queues<Name extends string = string> = Readonly<Record<Name, QueueProducer>>;
100
85
  /** Options the package-level `createQueues` factory takes. */
101
- interface LunoraQueuesOptions {
86
+ interface LunoraQueuesOptions<Name extends string = string> {
102
87
  /** Map of `lunora/queues.ts` export name → Cloudflare `Queue` producer binding. */
103
- bindings: Record<string, QueueBindingLike>;
88
+ bindings: Record<Name, QueueBindingLike>;
104
89
  }
105
90
  /**
106
91
  * The context handed to a `defineQueue` handler. Decoupled from `@lunora/server`
@@ -113,13 +98,39 @@ interface QueueRunContext {
113
98
  readonly env: Record<string, unknown>;
114
99
  /** Queue-name-prefixed logger. */
115
100
  readonly log: DispatchLogger;
116
- /** Invoke a Lunora function (query/mutation/action) by reference. */
101
+ /**
102
+ * Invoke a Lunora function (query/mutation/action) by reference.
103
+ *
104
+ * Batch-unaware: a failure it throws is attributed to nothing, so a
105
+ * deterministic failure retries the whole batch. Inside the `batch.messages`
106
+ * loop, prefer {@link QueueMessage.run}, which pins the call to its message.
107
+ */
117
108
  readonly run: DispatchRunFunction;
118
109
  }
110
+ /**
111
+ * One delivered message as the push handler sees it: the Cloudflare `Message`
112
+ * plus `run` — a {@link QueueRunContext.run} pinned to THIS message.
113
+ *
114
+ * Prefer `message.run(api.x.y, args)` over `ctx.run(...)` inside the batch
115
+ * loop. The pin is what lets the dispatcher attribute a deterministic dispatch
116
+ * failure (400/403/404/422) to the one message that caused it: that message is
117
+ * acked and every other one is retried, instead of the whole batch being
118
+ * re-delivered because of a single poison message. A plain `ctx.run` call
119
+ * carries no message id, so its failure stays unattributed and the whole batch
120
+ * retries.
121
+ */
122
+ interface QueueMessage<Body = unknown> extends MessageLike<Body> {
123
+ /** {@link QueueRunContext.run}, pinned to this message for failure attribution. */
124
+ readonly run: DispatchRunFunction;
125
+ }
126
+ /** The delivered batch as the push handler sees it — {@link QueueMessage}s rather than bare `Message`s. */
127
+ interface QueueMessageBatch<Body = unknown> extends Omit<MessageBatchLike<Body>, "messages"> {
128
+ readonly messages: ReadonlyArray<QueueMessage<Body>>;
129
+ }
119
130
  /** Whether a declared queue is consumed by this worker (push) or polled externally (pull). */
120
131
  type QueueConsumerMode = "pull" | "push";
121
132
  /** The handler body run for each delivered batch (push consumers only). */
122
- type QueueHandler<Body = unknown> = (context: QueueRunContext, batch: MessageBatchLike<Body>) => Promise<void> | void;
133
+ type QueueHandler<Body = unknown> = (context: QueueRunContext, batch: QueueMessageBatch<Body>) => Promise<void> | void;
123
134
  /** Push-consumer batch/retry tuning, mirrored onto the wrangler `queues.consumers[]` entry. */
124
135
  interface QueueConsumerTuning {
125
136
  /** Name of the dead-letter queue messages land in after `maxRetries`. */
@@ -128,7 +139,9 @@ interface QueueConsumerTuning {
128
139
  maxBatchSize?: number;
129
140
  /** Max seconds to wait before delivering a partial batch (0–60, default 5). */
130
141
  maxBatchTimeout?: number;
131
- /** Max delivery attempts before a message is dropped / dead-lettered (default 3). */
142
+ /** Max concurrent consumer invocations (1–250). Unset lets Cloudflare autoscale. Not emulated by Miniflare in dev. */
143
+ maxConcurrency?: number;
144
+ /** Retries **after** the initial delivery, before a message is dropped / dead-lettered. Default 3, so up to 4 deliveries in total. */
132
145
  maxRetries?: number;
133
146
  /** Delay in seconds before a failed batch is retried. */
134
147
  retryDelay?: number;
@@ -152,29 +165,92 @@ interface QueueConfig<Body = unknown> extends QueueConsumerTuning {
152
165
  interface QueueDefinition<Body = unknown> extends QueueConfig<Body> {
153
166
  /**
154
167
  * Phantom carrier for the message body type, so codegen can type the
155
- * generated `ctx.queues.&lt;name>` producer as `QueueProducer&lt;Body>` from
156
- * `typeof &lt;export>`. Never assigned at runtime (type-only).
168
+ * generated `ctx.queues.<name>` producer as `QueueProducer<Body>` from
169
+ * `typeof <export>`. Never assigned at runtime (type-only).
157
170
  */
158
171
  readonly __lunoraBody?: Body;
159
172
  /** Runtime brand identifying a `defineQueue` result. */
160
173
  isLunoraQueue: true;
161
174
  }
175
+ /**
176
+ * The typed publisher bound to `ctx.topics.<name>`. A publish is one send to
177
+ * **each** subscription's queue, in parallel, so it is not atomic: when one send
178
+ * fails the call rejects, and a retry re-delivers to the subscriptions whose send
179
+ * already succeeded. Delivery is at-least-once per subscription — make handlers
180
+ * idempotent. A topic with no subscriptions publishes to nobody.
181
+ */
182
+ interface TopicPublisher<Payload = unknown> {
183
+ /** Publish one message to every subscription. */
184
+ publish: (payload: Payload, options?: QueueSendOptions) => Promise<void>;
185
+ /** Publish a batch (at most 100) to every subscription in one send per subscription. */
186
+ publishBatch: (messages: Iterable<MessageSendRequestLike<Payload>>, options?: QueueSendBatchOptions) => Promise<void>;
187
+ }
188
+ /** `ctx.topics` — declared topic export names → typed publishers, keyed like {@link Queues}. */
189
+ type Topics<Name extends string = string> = Readonly<Record<Name, TopicPublisher>>;
190
+ /** The branded result of `defineTopic`, discovered by codegen. Carries no deploy config of its own. */
191
+ interface TopicDefinition<Payload = unknown> {
192
+ /**
193
+ * Phantom carrier for the payload type, so codegen can type `ctx.topics.<name>`.
194
+ * Named like {@link QueueDefinition}'s so one emitted helper reads both. Never
195
+ * assigned at runtime.
196
+ */
197
+ readonly __lunoraBody?: Payload;
198
+ /** Runtime brand identifying a `defineTopic` result. */
199
+ isLunoraTopic: true;
200
+ }
201
+ /** The config passed to `defineSubscription`: a push consumer's handler plus its own retry/DLQ tuning. */
202
+ interface SubscriptionConfig<Payload = unknown> extends QueueConsumerTuning {
203
+ handler: QueueHandler<Payload>;
204
+ /**
205
+ * Stable wrangler queue name for this subscription. Defaults to the
206
+ * kebab-cased export name, exactly like a `defineQueue` export.
207
+ */
208
+ name?: string;
209
+ }
210
+ /**
211
+ * The branded result of `defineSubscription`: a push {@link QueueDefinition}
212
+ * (so dispatch, retries, the DLQ, wrangler reconcile and the studio treat it as
213
+ * the queue it is) tagged with the topic it consumes.
214
+ */
215
+ interface SubscriptionDefinition<Payload = unknown> extends QueueDefinition<Payload> {
216
+ handler: QueueHandler<Payload>;
217
+ mode: "push";
218
+ /** The topic this subscription consumes. */
219
+ topic: TopicDefinition<Payload>;
220
+ }
221
+ /** Wiring info for one declared topic, emitted by codegen into the generated shard. */
222
+ interface TopicBindingSpec<Name extends string = string> {
223
+ /** The `lunora/queues.ts` topic export name, e.g. `signups`. */
224
+ exportName: Name;
225
+ /** One entry per subscription: its export name and its `Queue` producer binding. */
226
+ subscriptions: ReadonlyArray<{
227
+ binding: string;
228
+ exportName: string;
229
+ }>;
230
+ }
162
231
  /** Wiring info for one declared queue, emitted by codegen into the generated shard/handler. */
163
- interface QueueBindingSpec {
232
+ interface QueueBindingSpec<Name extends string = string> {
164
233
  /** The Cloudflare `Queue` producer binding name, e.g. `QUEUE_EMAIL`. */
165
234
  binding: string;
166
235
  /** The `lunora/queues.ts` export name, e.g. `emailQueue`. */
167
- exportName: string;
236
+ exportName: Name;
168
237
  /** The stable wrangler queue name, e.g. `email-queue`. */
169
238
  name: string;
170
239
  }
171
240
  /** One declared queue, keyed for batch routing by its stable wrangler name. */
172
241
  interface QueueRegistryEntry {
242
+ /**
243
+ * The queue's own producer binding on `env` (`QUEUE_*`). A message whose
244
+ * dispatch is declined on its last delivery is re-enqueued through it (see
245
+ * {@link resolveDeclinedBatch}); without it that message is dead-lettered or
246
+ * dropped, and logged. Codegen always emits it.
247
+ */
248
+ binding?: string;
173
249
  /**
174
250
  * The `defineQueue` result (carries the push handler). The body type is
175
251
  * erased to `any` here because the registry is heterogeneous — different
176
252
  * queues carry different message bodies, and the handler param is
177
- * contravariant, so a precise `QueueDefinition&lt;Body>` would not be assignable
253
+ * contravariant, so a precise `QueueDefinition<Body>` would not be assignable
178
254
  * to a shared `unknown`-bodied slot. Runtime dispatch passes the delivered
179
255
  * batch straight through, so the erasure is type-only.
180
256
  */
@@ -197,7 +273,28 @@ interface CapturedQueueMessage {
197
273
  attempts: number;
198
274
  /** The message body (JSON-encoded + capped by the catcher). */
199
275
  body: unknown;
200
- /** `true` when this failed delivery was the message's last (its retries are exhausted — the broker dead-letters it). */
276
+ /**
277
+ * `true` when this failed delivery was the message's last (its retries are
278
+ * exhausted) AND the queue declares a `deadLetterQueue` for it to land in.
279
+ * Stays `false` for a queue with no DLQ, where the broker drops the
280
+ * exhausted message instead — `attempts > maxRetries` with
281
+ * `outcome !== "ack"` is what identifies that case.
282
+ *
283
+ * Read from the `defineQueue` DECLARATION, because a consumer has no
284
+ * runtime API that exposes its deployed `queues.consumers[]` settings. It is
285
+ * true of the deployed broker only as far as Lunora's binding reconcile (run
286
+ * by `lunora dev`, `deploy` and `prepare`) keeps the two in step: it writes
287
+ * every declared tuning field onto the consumer, including onto one that
288
+ * already exists, and takes a field back out once `defineQueue` drops it
289
+ * (it records what `defineQueue` declared in `package.json`
290
+ * `lunora.queueTuning`). The gaps that remain: a field changed by hand to
291
+ * another value than the declared one is
292
+ * kept when the declaration drops it, so a hand-set DLQ still reads
293
+ * `false` here; a `--env` deploy retunes the `env.<name>` consumers but
294
+ * never writes their `dead_letter_queue`, which that block names itself;
295
+ * and a bare `wrangler deploy` over a hand-edited consumer can disagree
296
+ * too. Nothing on this side can see any of it.
297
+ */
201
298
  deadLettered: boolean;
202
299
  /** Handler error message when `outcome` is `error`; absent otherwise. */
203
300
  error?: string;
@@ -221,22 +318,33 @@ interface CapturedQueueMessage {
221
318
  type QueueCaptureSink = (messages: CapturedQueueMessage[]) => Promise<void> | void;
222
319
  interface DispatchOptions {
223
320
  /**
224
- * Optional capture sink. When set, the batch is instrumented and every
225
- * message's final disposition is recorded and handed to this sink after the
226
- * handler runs. Omitted in production unless queue capture is enabled, so a
227
- * consumer pays no instrumentation cost by default.
321
+ * Optional capture sink. When set, every message's final disposition is
322
+ * turned into a record and handed to this sink after the handler runs.
323
+ * Omitted in production unless queue capture is enabled, so a consumer pays
324
+ * no record-building or sink cost by default. Delivery semantics — including
325
+ * poison-message isolation — do not depend on it.
228
326
  */
229
327
  capture?: QueueCaptureSink;
230
328
  /** Worker `env`, forwarded to the queue run context. */
231
329
  env: Record<string, unknown>;
232
330
  /** Injectable fetch for the `ctx.run` dispatcher (tests). */
233
331
  fetchImpl?: typeof fetch;
332
+ /**
333
+ * W3C `traceparent` of the consumer invocation's own trace, supplied by the
334
+ * runtime's `queue()` entry. Forwarded on every `ctx.run` dispatch so the
335
+ * functions a handler calls are CHILDREN of the queue span instead of a set of
336
+ * unrelated root traces. Absent (a hand-built dispatch, a unit test) keeps the
337
+ * prior behaviour: the callee mints its own trace.
338
+ */
339
+ traceparent?: string;
234
340
  }
235
341
  /**
236
342
  * Look up the handler for `batch.queue` and invoke it with a fresh
237
343
  * `QueueRunContext`. Throws a directed error when no push handler is registered
238
344
  * for the delivered queue (a misconfiguration — the consumer was declared
239
- * `pull`, or the queue name drifted from the `defineQueue` export).
345
+ * `pull`, or the queue name drifted from the `defineQueue` export). The
346
+ * registry already carries every per-environment name wrangler.jsonc maps to a
347
+ * declared producer binding (see codegen's `emitQueues`), so the lookup stays exact.
240
348
  */
241
349
  declare const dispatchQueueBatch: (batch: MessageBatchLike, registry: QueueRegistry, options: DispatchOptions) => Promise<void>;
242
350
  /** A Worker `env` projected as a plain record (vars, secrets, and bindings are `unknown`-valued). */
@@ -268,21 +376,21 @@ declare const createQueueCaptureSink: (env: QueueEnv, options?: QueueCaptureOpti
268
376
  * Build the `ctx.queues` map for a request: resolve every spec's `env[binding]`
269
377
  * into the `exportName → Queue binding` map and wrap it in {@link createQueues}.
270
378
  * A spec whose binding is absent from `env` is skipped here — the helpful "no
271
- * queue named …" error is raised lazily by `ctx.queues.&lt;name>.send(...)` when
379
+ * queue named …" error is raised lazily by `ctx.queues.<name>.send(...)` when
272
380
  * the missing queue is actually used.
273
381
  */
274
- declare const createQueueContext: (env: Record<string, unknown>, specs: ReadonlyArray<QueueBindingSpec>) => Queues;
382
+ declare const createQueueContext: <Name extends string>(env: Record<string, unknown>, specs: ReadonlyArray<QueueBindingSpec<Name>>) => Queues<Name>;
275
383
  /**
276
384
  * Build the `ctx.queues` map from `lunora/queues.ts` export name → Cloudflare
277
385
  * `Queue` binding. Each property is a typed {@link QueueProducer}; accessing an
278
386
  * export whose binding is absent throws a directed error naming the declared
279
387
  * queues (raised lazily on first use).
280
388
  */
281
- declare const createQueues: (options: LunoraQueuesOptions) => Queues;
389
+ declare const createQueues: <Name extends string>(options: LunoraQueuesOptions<Name>) => Queues<Name>;
282
390
  /**
283
391
  * The wrangler producer binding name for a queue export: `emailQueue` →
284
392
  * `QUEUE_EMAIL_QUEUE`, `email` → `QUEUE_EMAIL`. The `QUEUE_` prefix namespaces
285
- * these away from `SHARD`/`SESSION`/`SCHEDULER`/`WORKFLOW_*`/`CONTAINER_*` so a
393
+ * these away from `SHARD`/`SESSION`/`SCHEDULER`/`CONTAINER_*` so a
286
394
  * queue export can never collide with the built-in bindings.
287
395
  */
288
396
  declare const queueBindingName: (exportName: string) => string;
@@ -294,7 +402,7 @@ declare const queueBindingName: (exportName: string) => string;
294
402
  declare const queueDefaultName: (exportName: string) => string;
295
403
  /**
296
404
  * Declare a Cloudflare Queue deployed alongside the app. Pure validation +
297
- * branding: codegen discovers the export, emits the typed `ctx.queues.&lt;name>`
405
+ * branding: codegen discovers the export, emits the typed `ctx.queues.<name>`
298
406
  * producer and (for push consumers) the worker `queue()` dispatch; the config
299
407
  * layer reconciles the wrangler `queues.producers[]` / `queues.consumers[]`
300
408
  * entries from the same definition.
@@ -304,10 +412,10 @@ declare const queueDefaultName: (exportName: string) => string;
304
412
  * import { defineQueue } from "@lunora/queue";
305
413
  * import { api } from "./_generated/api";
306
414
  *
307
- * export const emailQueue = defineQueue&lt;{ to: string }>({
415
+ * export const emailQueue = defineQueue<{ to: string }>({
308
416
  * handler: async (ctx, batch) => {
309
417
  * for (const message of batch.messages) {
310
- * await ctx.run(api.email.send, { to: message.body.to });
418
+ * await message.run(api.email.send, { to: message.body.to });
311
419
  * message.ack();
312
420
  * }
313
421
  * },
@@ -316,6 +424,12 @@ declare const queueDefaultName: (exportName: string) => string;
316
424
  *
317
425
  * Enqueue from a mutation or action: `await ctx.queues.emailQueue.send({ to })`.
318
426
  *
427
+ * `message.run(...)` is `ctx.run(...)` pinned to that message — prefer it inside
428
+ * the batch loop. A deterministic failure (400/403/404/422) from a pinned call
429
+ * is attributed to its message: that one is acked and the rest are retried,
430
+ * instead of one poison message re-delivering (and eventually dead-lettering)
431
+ * the whole batch.
432
+ *
319
433
  * ⚠️ **Privileged dispatch.** A push handler's `ctx.run(...)` calls back into
320
434
  * Lunora functions over the admin-authenticated dispatch endpoint (the same
321
435
  * trusted path the scheduler and workflows use), so those calls run with the
@@ -331,7 +445,29 @@ interface RunContextOptions {
331
445
  env: Record<string, unknown>;
332
446
  exportName: string;
333
447
  fetchImpl?: typeof fetch;
448
+ /** The consumer invocation's `traceparent`, so `ctx.run` joins the queue's trace. */
449
+ traceparent?: string;
334
450
  }
335
451
  /** Assemble the {@link QueueRunContext} passed to a `defineQueue` handler. */
336
452
  declare const createQueueRunContext: (options: RunContextOptions) => QueueRunContext;
337
- export { type ArgsOf, type CapturedQueueMessage, type FunctionReference, type LunoraQueuesOptions, type MessageBatchLike, type MessageLike, type MessageSendRequestLike, type QueueBindingLike, type QueueBindingSpec, type QueueCaptureOptions, type QueueCaptureSink, type QueueConfig, type QueueConsumerMode, type QueueConsumerTuning, type QueueContentType, type QueueDefinition, type QueueEnv, type QueueHandler, type DispatchLogger as QueueLogger, type QueueProducer, type QueueRegistry, type QueueRegistryEntry, type QueueRetryOptions, type QueueRunContext, type DispatchRunFunction as QueueRunFunction, type QueueSendBatchOptions, type QueueSendOptions, type Queues, type RunFunctionOptions, createQueueCaptureSink, createQueueContext, createQueueRunContext, createQueues, defineQueue, dispatchQueueBatch, isQueueDefinition, queueBindingName, queueDefaultName, shouldCaptureQueue };
453
+ /**
454
+ * Declare a topic in `lunora/queues.ts`. The payload type is the only thing a
455
+ * topic carries; delivery config lives on each subscription.
456
+ */
457
+ declare const defineTopic: <Payload = unknown>() => TopicDefinition<Payload>;
458
+ /**
459
+ * Subscribe to a topic. The result is a push queue definition — export it from
460
+ * `lunora/queues.ts` and it deploys, retries and dead-letters exactly like a
461
+ * `defineQueue` export of the same name.
462
+ *
463
+ * ⚠️ Same trust model as a queue handler: `message.run(...)` dispatches with the
464
+ * system identity, so validate `message.body` before acting on it.
465
+ */
466
+ declare const defineSubscription: <Payload>(topic: TopicDefinition<Payload>, config: SubscriptionConfig<Payload>) => SubscriptionDefinition<Payload>;
467
+ /**
468
+ * Build `ctx.topics` for a request from the Worker `env` and the codegen-emitted
469
+ * specs. A topic with no subscriptions publishes to nobody; a topic with an
470
+ * unbound subscription rejects every publish (see {@link unboundPublisher}).
471
+ */
472
+ declare const createTopicContext: <Name extends string>(env: Record<string, unknown>, specs: ReadonlyArray<TopicBindingSpec<Name>>) => Topics<Name>;
473
+ export { type ArgsOf, type CapturedQueueMessage, type FunctionReference, type LunoraQueuesOptions, type QueueBindingSpec, type QueueCaptureOptions, type QueueCaptureSink, type QueueConfig, type QueueConsumerMode, type QueueConsumerTuning, type QueueDefinition, type QueueEnv, type QueueHandler, type DispatchLogger as QueueLogger, type QueueMessage, type QueueMessageBatch, type QueueProducer, type QueueRegistry, type QueueRegistryEntry, type QueueRunContext, type DispatchRunFunction as QueueRunFunction, type Queues, type RunFunctionOptions, type SubscriptionConfig, type SubscriptionDefinition, type TopicBindingSpec, type TopicDefinition, type TopicPublisher, type Topics, createQueueCaptureSink, createQueueContext, createQueueRunContext, createQueues, createTopicContext, defineQueue, defineSubscription, defineTopic, dispatchQueueBatch, isQueueDefinition, queueBindingName, queueDefaultName, shouldCaptureQueue };
package/dist/index.d.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { QueueBindingLike, MessageBatchLike, MessageLike, QueueSendOptions, MessageSendRequestLike, QueueSendBatchOptions } from '@lunora/platform';
2
+ export type { MessageBatchLike, MessageLike, MessageSendRequestLike, QueueBindingLike, QueueContentType, QueueRetryOptions, QueueSendBatchOptions, QueueSendOptions } from '@lunora/platform';
1
3
  /**
2
4
  * Shared types for dispatching a Lunora function back into the worker from a
3
5
  * server-initiated context (a workflow body, a queue handler, a scheduled job).
@@ -12,8 +14,45 @@ interface FunctionReference {
12
14
  type ArgsOf<F> = F extends FunctionReference ? Record<string, unknown> : never;
13
15
  /** Options for a function call made via a dispatch runner. */
14
16
  interface RunFunctionOptions {
17
+ /**
18
+ * The at-least-once dedup key for THIS ONE CALL, sent to the dispatch
19
+ * endpoint as the body's `id` and forwarded to the shard as the
20
+ * replay-dedup `mutationId` — so a redelivery that re-runs the same call
21
+ * applies it exactly once instead of twice.
22
+ *
23
+ * Must be unique per call and stable across redeliveries. It is NOT
24
+ * {@link RunFunctionOptions.messageId}: the shard's dedup table is keyed
25
+ * `(identity, mutationId)` with no function path in it, and every
26
+ * server-initiated dispatch shares the `"system:"` identity, so reusing
27
+ * one id across two calls makes the second return the FIRST call's cached
28
+ * result without ever executing. A per-message id is 1:N with the calls a
29
+ * handler makes; this is 1:1.
30
+ *
31
+ * `@lunora/queue`'s `message.run` derives one automatically as
32
+ * `<messageId>#<n>`, `n` counting that message's calls in order. That is
33
+ * stable across redeliveries because the handler replays from the start —
34
+ * it relies on the handler issuing its `run` calls in a DETERMINISTIC
35
+ * order, which at-least-once replay already assumes. A handler whose call
36
+ * order varies per attempt (branching on `Date.now()`, `Math.random()`,
37
+ * or unordered concurrent settles) must pass its own stable ids instead.
38
+ *
39
+ * Optional; when omitted the dispatch is at-least-once.
40
+ */
41
+ dedupId?: string;
42
+ /**
43
+ * Correlate this call with a caller-defined message/item id (e.g. a queue
44
+ * message's `id`), for failure attribution only — never sent to the
45
+ * dispatch endpoint. Carried onto the `LunoraError` a deterministic
46
+ * dispatch failure throws, so a batching consumer (`@lunora/queue`'s push
47
+ * handler) can read it back and attribute the failure to the one item that
48
+ * caused it instead of the whole batch. Deliberately NOT the dedup key —
49
+ * see {@link RunFunctionOptions.dedupId}. Optional and inert when omitted.
50
+ */
51
+ messageId?: string;
15
52
  /** Route the call to a specific shard (defaults to the worker's root shard). */
16
53
  shardKey?: string;
54
+ /** Abort the dispatch after this many ms; the abort is retryable. Overrides the runner's default. */
55
+ timeoutMs?: number;
17
56
  }
18
57
  /** Invoke a Lunora function (query/mutation/action) by reference. The shape of `ctx.run`. */
19
58
  type DispatchRunFunction = <F extends FunctionReference>(function_: F, args?: ArgsOf<F>, options?: RunFunctionOptions) => Promise<unknown>;
@@ -24,62 +63,8 @@ interface DispatchLogger {
24
63
  info: (message: unknown, ...rest: unknown[]) => void;
25
64
  warn: (message: unknown, ...rest: unknown[]) => void;
26
65
  }
27
- /** How a queue message body is serialized on the wire (Cloudflare default `"json"`). */
28
- type QueueContentType = "bytes" | "json" | "text" | "v8";
29
- /** Options for a single `producer.send(body, options?)`. */
30
- interface QueueSendOptions {
31
- /** Wire serialization for this message (defaults to the queue's content type). */
32
- contentType?: QueueContentType;
33
- /** Per-message delivery delay in seconds (0–43200, i.e. up to 12 hours). */
34
- delaySeconds?: number;
35
- }
36
- /** Options for a `producer.sendBatch(messages, options?)`. */
37
- interface QueueSendBatchOptions {
38
- /** Delivery delay applied to the whole batch, in seconds. */
39
- delaySeconds?: number;
40
- }
41
- /** One entry in a `sendBatch` call — a body plus optional per-message overrides. */
42
- interface MessageSendRequestLike<Body = unknown> {
43
- body: Body;
44
- contentType?: QueueContentType;
45
- delaySeconds?: number;
46
- }
47
- /**
48
- * Minimal structural projection of workers-types' `Queue&lt;Body>` (the producer
49
- * binding). The real binding's `send`/`sendBatch` resolve to a metadata object;
50
- * we widen the return to `Promise&lt;unknown>` so a plain-object fake satisfies it.
51
- */
52
- interface QueueBindingLike<Body = unknown> {
53
- send: (message: Body, options?: QueueSendOptions) => Promise<unknown>;
54
- sendBatch: (messages: Iterable<MessageSendRequestLike<Body>>, options?: QueueSendBatchOptions) => Promise<unknown>;
55
- }
56
- /** Options for retrying a message / batch (`message.retry({ delaySeconds })`). */
57
- interface QueueRetryOptions {
58
- delaySeconds?: number;
59
- }
60
- /** Structural mirror of workers-types' `Message&lt;Body>` (one delivered message). */
61
- interface MessageLike<Body = unknown> {
62
- /** Acknowledge this message so it is not redelivered. */
63
- ack: () => void;
64
- readonly attempts: number;
65
- readonly body: Body;
66
- readonly id: string;
67
- /** Explicitly retry this message (optionally after a delay). */
68
- retry: (options?: QueueRetryOptions) => void;
69
- readonly timestamp: Date;
70
- }
71
- /** Structural mirror of workers-types' `MessageBatch&lt;Body>` handed to a consumer. */
72
- interface MessageBatchLike<Body = unknown> {
73
- /** Acknowledge every message in the batch. */
74
- ackAll: () => void;
75
- readonly messages: ReadonlyArray<MessageLike<Body>>;
76
- /** The queue name this batch was delivered from (`batch.queue`), used to route. */
77
- readonly queue: string;
78
- /** Retry every message in the batch (optionally after a delay). */
79
- retryAll: (options?: QueueRetryOptions) => void;
80
- }
81
66
  /**
82
- * The typed producer bound to `ctx.queues.&lt;name>`. Sending is a side effect, so
67
+ * The typed producer bound to `ctx.queues.<name>`. Sending is a side effect, so
83
68
  * the generated context exposes this only on `MutationCtx` / `ActionCtx` (never
84
69
  * the deterministic `QueryCtx`), mirroring `ctx.scheduler` / `ctx.workflows`.
85
70
  */
@@ -91,16 +76,16 @@ interface QueueProducer<Body = unknown> {
91
76
  }
92
77
  /**
93
78
  * `ctx.queues` — the map of declared queue export names → typed producers.
94
- * Codegen narrows this to the exact export names; the package keeps it open so
95
- * `createQueues` stays schema-agnostic.
79
+ * Keyed by the exact export names `createQueueContext` / `createQueues` are
80
+ * handed, so `queues.jobs` is a `QueueProducer` rather than `QueueProducer |
81
+ * undefined` under `noUncheckedIndexedAccess`. `Name` defaults to `string`
82
+ * (an open map) for schema-agnostic callers.
96
83
  */
97
- interface Queues {
98
- [exportName: string]: QueueProducer;
99
- }
84
+ type Queues<Name extends string = string> = Readonly<Record<Name, QueueProducer>>;
100
85
  /** Options the package-level `createQueues` factory takes. */
101
- interface LunoraQueuesOptions {
86
+ interface LunoraQueuesOptions<Name extends string = string> {
102
87
  /** Map of `lunora/queues.ts` export name → Cloudflare `Queue` producer binding. */
103
- bindings: Record<string, QueueBindingLike>;
88
+ bindings: Record<Name, QueueBindingLike>;
104
89
  }
105
90
  /**
106
91
  * The context handed to a `defineQueue` handler. Decoupled from `@lunora/server`
@@ -113,13 +98,39 @@ interface QueueRunContext {
113
98
  readonly env: Record<string, unknown>;
114
99
  /** Queue-name-prefixed logger. */
115
100
  readonly log: DispatchLogger;
116
- /** Invoke a Lunora function (query/mutation/action) by reference. */
101
+ /**
102
+ * Invoke a Lunora function (query/mutation/action) by reference.
103
+ *
104
+ * Batch-unaware: a failure it throws is attributed to nothing, so a
105
+ * deterministic failure retries the whole batch. Inside the `batch.messages`
106
+ * loop, prefer {@link QueueMessage.run}, which pins the call to its message.
107
+ */
117
108
  readonly run: DispatchRunFunction;
118
109
  }
110
+ /**
111
+ * One delivered message as the push handler sees it: the Cloudflare `Message`
112
+ * plus `run` — a {@link QueueRunContext.run} pinned to THIS message.
113
+ *
114
+ * Prefer `message.run(api.x.y, args)` over `ctx.run(...)` inside the batch
115
+ * loop. The pin is what lets the dispatcher attribute a deterministic dispatch
116
+ * failure (400/403/404/422) to the one message that caused it: that message is
117
+ * acked and every other one is retried, instead of the whole batch being
118
+ * re-delivered because of a single poison message. A plain `ctx.run` call
119
+ * carries no message id, so its failure stays unattributed and the whole batch
120
+ * retries.
121
+ */
122
+ interface QueueMessage<Body = unknown> extends MessageLike<Body> {
123
+ /** {@link QueueRunContext.run}, pinned to this message for failure attribution. */
124
+ readonly run: DispatchRunFunction;
125
+ }
126
+ /** The delivered batch as the push handler sees it — {@link QueueMessage}s rather than bare `Message`s. */
127
+ interface QueueMessageBatch<Body = unknown> extends Omit<MessageBatchLike<Body>, "messages"> {
128
+ readonly messages: ReadonlyArray<QueueMessage<Body>>;
129
+ }
119
130
  /** Whether a declared queue is consumed by this worker (push) or polled externally (pull). */
120
131
  type QueueConsumerMode = "pull" | "push";
121
132
  /** The handler body run for each delivered batch (push consumers only). */
122
- type QueueHandler<Body = unknown> = (context: QueueRunContext, batch: MessageBatchLike<Body>) => Promise<void> | void;
133
+ type QueueHandler<Body = unknown> = (context: QueueRunContext, batch: QueueMessageBatch<Body>) => Promise<void> | void;
123
134
  /** Push-consumer batch/retry tuning, mirrored onto the wrangler `queues.consumers[]` entry. */
124
135
  interface QueueConsumerTuning {
125
136
  /** Name of the dead-letter queue messages land in after `maxRetries`. */
@@ -128,7 +139,9 @@ interface QueueConsumerTuning {
128
139
  maxBatchSize?: number;
129
140
  /** Max seconds to wait before delivering a partial batch (0–60, default 5). */
130
141
  maxBatchTimeout?: number;
131
- /** Max delivery attempts before a message is dropped / dead-lettered (default 3). */
142
+ /** Max concurrent consumer invocations (1–250). Unset lets Cloudflare autoscale. Not emulated by Miniflare in dev. */
143
+ maxConcurrency?: number;
144
+ /** Retries **after** the initial delivery, before a message is dropped / dead-lettered. Default 3, so up to 4 deliveries in total. */
132
145
  maxRetries?: number;
133
146
  /** Delay in seconds before a failed batch is retried. */
134
147
  retryDelay?: number;
@@ -152,29 +165,92 @@ interface QueueConfig<Body = unknown> extends QueueConsumerTuning {
152
165
  interface QueueDefinition<Body = unknown> extends QueueConfig<Body> {
153
166
  /**
154
167
  * Phantom carrier for the message body type, so codegen can type the
155
- * generated `ctx.queues.&lt;name>` producer as `QueueProducer&lt;Body>` from
156
- * `typeof &lt;export>`. Never assigned at runtime (type-only).
168
+ * generated `ctx.queues.<name>` producer as `QueueProducer<Body>` from
169
+ * `typeof <export>`. Never assigned at runtime (type-only).
157
170
  */
158
171
  readonly __lunoraBody?: Body;
159
172
  /** Runtime brand identifying a `defineQueue` result. */
160
173
  isLunoraQueue: true;
161
174
  }
175
+ /**
176
+ * The typed publisher bound to `ctx.topics.<name>`. A publish is one send to
177
+ * **each** subscription's queue, in parallel, so it is not atomic: when one send
178
+ * fails the call rejects, and a retry re-delivers to the subscriptions whose send
179
+ * already succeeded. Delivery is at-least-once per subscription — make handlers
180
+ * idempotent. A topic with no subscriptions publishes to nobody.
181
+ */
182
+ interface TopicPublisher<Payload = unknown> {
183
+ /** Publish one message to every subscription. */
184
+ publish: (payload: Payload, options?: QueueSendOptions) => Promise<void>;
185
+ /** Publish a batch (at most 100) to every subscription in one send per subscription. */
186
+ publishBatch: (messages: Iterable<MessageSendRequestLike<Payload>>, options?: QueueSendBatchOptions) => Promise<void>;
187
+ }
188
+ /** `ctx.topics` — declared topic export names → typed publishers, keyed like {@link Queues}. */
189
+ type Topics<Name extends string = string> = Readonly<Record<Name, TopicPublisher>>;
190
+ /** The branded result of `defineTopic`, discovered by codegen. Carries no deploy config of its own. */
191
+ interface TopicDefinition<Payload = unknown> {
192
+ /**
193
+ * Phantom carrier for the payload type, so codegen can type `ctx.topics.<name>`.
194
+ * Named like {@link QueueDefinition}'s so one emitted helper reads both. Never
195
+ * assigned at runtime.
196
+ */
197
+ readonly __lunoraBody?: Payload;
198
+ /** Runtime brand identifying a `defineTopic` result. */
199
+ isLunoraTopic: true;
200
+ }
201
+ /** The config passed to `defineSubscription`: a push consumer's handler plus its own retry/DLQ tuning. */
202
+ interface SubscriptionConfig<Payload = unknown> extends QueueConsumerTuning {
203
+ handler: QueueHandler<Payload>;
204
+ /**
205
+ * Stable wrangler queue name for this subscription. Defaults to the
206
+ * kebab-cased export name, exactly like a `defineQueue` export.
207
+ */
208
+ name?: string;
209
+ }
210
+ /**
211
+ * The branded result of `defineSubscription`: a push {@link QueueDefinition}
212
+ * (so dispatch, retries, the DLQ, wrangler reconcile and the studio treat it as
213
+ * the queue it is) tagged with the topic it consumes.
214
+ */
215
+ interface SubscriptionDefinition<Payload = unknown> extends QueueDefinition<Payload> {
216
+ handler: QueueHandler<Payload>;
217
+ mode: "push";
218
+ /** The topic this subscription consumes. */
219
+ topic: TopicDefinition<Payload>;
220
+ }
221
+ /** Wiring info for one declared topic, emitted by codegen into the generated shard. */
222
+ interface TopicBindingSpec<Name extends string = string> {
223
+ /** The `lunora/queues.ts` topic export name, e.g. `signups`. */
224
+ exportName: Name;
225
+ /** One entry per subscription: its export name and its `Queue` producer binding. */
226
+ subscriptions: ReadonlyArray<{
227
+ binding: string;
228
+ exportName: string;
229
+ }>;
230
+ }
162
231
  /** Wiring info for one declared queue, emitted by codegen into the generated shard/handler. */
163
- interface QueueBindingSpec {
232
+ interface QueueBindingSpec<Name extends string = string> {
164
233
  /** The Cloudflare `Queue` producer binding name, e.g. `QUEUE_EMAIL`. */
165
234
  binding: string;
166
235
  /** The `lunora/queues.ts` export name, e.g. `emailQueue`. */
167
- exportName: string;
236
+ exportName: Name;
168
237
  /** The stable wrangler queue name, e.g. `email-queue`. */
169
238
  name: string;
170
239
  }
171
240
  /** One declared queue, keyed for batch routing by its stable wrangler name. */
172
241
  interface QueueRegistryEntry {
242
+ /**
243
+ * The queue's own producer binding on `env` (`QUEUE_*`). A message whose
244
+ * dispatch is declined on its last delivery is re-enqueued through it (see
245
+ * {@link resolveDeclinedBatch}); without it that message is dead-lettered or
246
+ * dropped, and logged. Codegen always emits it.
247
+ */
248
+ binding?: string;
173
249
  /**
174
250
  * The `defineQueue` result (carries the push handler). The body type is
175
251
  * erased to `any` here because the registry is heterogeneous — different
176
252
  * queues carry different message bodies, and the handler param is
177
- * contravariant, so a precise `QueueDefinition&lt;Body>` would not be assignable
253
+ * contravariant, so a precise `QueueDefinition<Body>` would not be assignable
178
254
  * to a shared `unknown`-bodied slot. Runtime dispatch passes the delivered
179
255
  * batch straight through, so the erasure is type-only.
180
256
  */
@@ -197,7 +273,28 @@ interface CapturedQueueMessage {
197
273
  attempts: number;
198
274
  /** The message body (JSON-encoded + capped by the catcher). */
199
275
  body: unknown;
200
- /** `true` when this failed delivery was the message's last (its retries are exhausted — the broker dead-letters it). */
276
+ /**
277
+ * `true` when this failed delivery was the message's last (its retries are
278
+ * exhausted) AND the queue declares a `deadLetterQueue` for it to land in.
279
+ * Stays `false` for a queue with no DLQ, where the broker drops the
280
+ * exhausted message instead — `attempts > maxRetries` with
281
+ * `outcome !== "ack"` is what identifies that case.
282
+ *
283
+ * Read from the `defineQueue` DECLARATION, because a consumer has no
284
+ * runtime API that exposes its deployed `queues.consumers[]` settings. It is
285
+ * true of the deployed broker only as far as Lunora's binding reconcile (run
286
+ * by `lunora dev`, `deploy` and `prepare`) keeps the two in step: it writes
287
+ * every declared tuning field onto the consumer, including onto one that
288
+ * already exists, and takes a field back out once `defineQueue` drops it
289
+ * (it records what `defineQueue` declared in `package.json`
290
+ * `lunora.queueTuning`). The gaps that remain: a field changed by hand to
291
+ * another value than the declared one is
292
+ * kept when the declaration drops it, so a hand-set DLQ still reads
293
+ * `false` here; a `--env` deploy retunes the `env.<name>` consumers but
294
+ * never writes their `dead_letter_queue`, which that block names itself;
295
+ * and a bare `wrangler deploy` over a hand-edited consumer can disagree
296
+ * too. Nothing on this side can see any of it.
297
+ */
201
298
  deadLettered: boolean;
202
299
  /** Handler error message when `outcome` is `error`; absent otherwise. */
203
300
  error?: string;
@@ -221,22 +318,33 @@ interface CapturedQueueMessage {
221
318
  type QueueCaptureSink = (messages: CapturedQueueMessage[]) => Promise<void> | void;
222
319
  interface DispatchOptions {
223
320
  /**
224
- * Optional capture sink. When set, the batch is instrumented and every
225
- * message's final disposition is recorded and handed to this sink after the
226
- * handler runs. Omitted in production unless queue capture is enabled, so a
227
- * consumer pays no instrumentation cost by default.
321
+ * Optional capture sink. When set, every message's final disposition is
322
+ * turned into a record and handed to this sink after the handler runs.
323
+ * Omitted in production unless queue capture is enabled, so a consumer pays
324
+ * no record-building or sink cost by default. Delivery semantics — including
325
+ * poison-message isolation — do not depend on it.
228
326
  */
229
327
  capture?: QueueCaptureSink;
230
328
  /** Worker `env`, forwarded to the queue run context. */
231
329
  env: Record<string, unknown>;
232
330
  /** Injectable fetch for the `ctx.run` dispatcher (tests). */
233
331
  fetchImpl?: typeof fetch;
332
+ /**
333
+ * W3C `traceparent` of the consumer invocation's own trace, supplied by the
334
+ * runtime's `queue()` entry. Forwarded on every `ctx.run` dispatch so the
335
+ * functions a handler calls are CHILDREN of the queue span instead of a set of
336
+ * unrelated root traces. Absent (a hand-built dispatch, a unit test) keeps the
337
+ * prior behaviour: the callee mints its own trace.
338
+ */
339
+ traceparent?: string;
234
340
  }
235
341
  /**
236
342
  * Look up the handler for `batch.queue` and invoke it with a fresh
237
343
  * `QueueRunContext`. Throws a directed error when no push handler is registered
238
344
  * for the delivered queue (a misconfiguration — the consumer was declared
239
- * `pull`, or the queue name drifted from the `defineQueue` export).
345
+ * `pull`, or the queue name drifted from the `defineQueue` export). The
346
+ * registry already carries every per-environment name wrangler.jsonc maps to a
347
+ * declared producer binding (see codegen's `emitQueues`), so the lookup stays exact.
240
348
  */
241
349
  declare const dispatchQueueBatch: (batch: MessageBatchLike, registry: QueueRegistry, options: DispatchOptions) => Promise<void>;
242
350
  /** A Worker `env` projected as a plain record (vars, secrets, and bindings are `unknown`-valued). */
@@ -268,21 +376,21 @@ declare const createQueueCaptureSink: (env: QueueEnv, options?: QueueCaptureOpti
268
376
  * Build the `ctx.queues` map for a request: resolve every spec's `env[binding]`
269
377
  * into the `exportName → Queue binding` map and wrap it in {@link createQueues}.
270
378
  * A spec whose binding is absent from `env` is skipped here — the helpful "no
271
- * queue named …" error is raised lazily by `ctx.queues.&lt;name>.send(...)` when
379
+ * queue named …" error is raised lazily by `ctx.queues.<name>.send(...)` when
272
380
  * the missing queue is actually used.
273
381
  */
274
- declare const createQueueContext: (env: Record<string, unknown>, specs: ReadonlyArray<QueueBindingSpec>) => Queues;
382
+ declare const createQueueContext: <Name extends string>(env: Record<string, unknown>, specs: ReadonlyArray<QueueBindingSpec<Name>>) => Queues<Name>;
275
383
  /**
276
384
  * Build the `ctx.queues` map from `lunora/queues.ts` export name → Cloudflare
277
385
  * `Queue` binding. Each property is a typed {@link QueueProducer}; accessing an
278
386
  * export whose binding is absent throws a directed error naming the declared
279
387
  * queues (raised lazily on first use).
280
388
  */
281
- declare const createQueues: (options: LunoraQueuesOptions) => Queues;
389
+ declare const createQueues: <Name extends string>(options: LunoraQueuesOptions<Name>) => Queues<Name>;
282
390
  /**
283
391
  * The wrangler producer binding name for a queue export: `emailQueue` →
284
392
  * `QUEUE_EMAIL_QUEUE`, `email` → `QUEUE_EMAIL`. The `QUEUE_` prefix namespaces
285
- * these away from `SHARD`/`SESSION`/`SCHEDULER`/`WORKFLOW_*`/`CONTAINER_*` so a
393
+ * these away from `SHARD`/`SESSION`/`SCHEDULER`/`CONTAINER_*` so a
286
394
  * queue export can never collide with the built-in bindings.
287
395
  */
288
396
  declare const queueBindingName: (exportName: string) => string;
@@ -294,7 +402,7 @@ declare const queueBindingName: (exportName: string) => string;
294
402
  declare const queueDefaultName: (exportName: string) => string;
295
403
  /**
296
404
  * Declare a Cloudflare Queue deployed alongside the app. Pure validation +
297
- * branding: codegen discovers the export, emits the typed `ctx.queues.&lt;name>`
405
+ * branding: codegen discovers the export, emits the typed `ctx.queues.<name>`
298
406
  * producer and (for push consumers) the worker `queue()` dispatch; the config
299
407
  * layer reconciles the wrangler `queues.producers[]` / `queues.consumers[]`
300
408
  * entries from the same definition.
@@ -304,10 +412,10 @@ declare const queueDefaultName: (exportName: string) => string;
304
412
  * import { defineQueue } from "@lunora/queue";
305
413
  * import { api } from "./_generated/api";
306
414
  *
307
- * export const emailQueue = defineQueue&lt;{ to: string }>({
415
+ * export const emailQueue = defineQueue<{ to: string }>({
308
416
  * handler: async (ctx, batch) => {
309
417
  * for (const message of batch.messages) {
310
- * await ctx.run(api.email.send, { to: message.body.to });
418
+ * await message.run(api.email.send, { to: message.body.to });
311
419
  * message.ack();
312
420
  * }
313
421
  * },
@@ -316,6 +424,12 @@ declare const queueDefaultName: (exportName: string) => string;
316
424
  *
317
425
  * Enqueue from a mutation or action: `await ctx.queues.emailQueue.send({ to })`.
318
426
  *
427
+ * `message.run(...)` is `ctx.run(...)` pinned to that message — prefer it inside
428
+ * the batch loop. A deterministic failure (400/403/404/422) from a pinned call
429
+ * is attributed to its message: that one is acked and the rest are retried,
430
+ * instead of one poison message re-delivering (and eventually dead-lettering)
431
+ * the whole batch.
432
+ *
319
433
  * ⚠️ **Privileged dispatch.** A push handler's `ctx.run(...)` calls back into
320
434
  * Lunora functions over the admin-authenticated dispatch endpoint (the same
321
435
  * trusted path the scheduler and workflows use), so those calls run with the
@@ -331,7 +445,29 @@ interface RunContextOptions {
331
445
  env: Record<string, unknown>;
332
446
  exportName: string;
333
447
  fetchImpl?: typeof fetch;
448
+ /** The consumer invocation's `traceparent`, so `ctx.run` joins the queue's trace. */
449
+ traceparent?: string;
334
450
  }
335
451
  /** Assemble the {@link QueueRunContext} passed to a `defineQueue` handler. */
336
452
  declare const createQueueRunContext: (options: RunContextOptions) => QueueRunContext;
337
- export { type ArgsOf, type CapturedQueueMessage, type FunctionReference, type LunoraQueuesOptions, type MessageBatchLike, type MessageLike, type MessageSendRequestLike, type QueueBindingLike, type QueueBindingSpec, type QueueCaptureOptions, type QueueCaptureSink, type QueueConfig, type QueueConsumerMode, type QueueConsumerTuning, type QueueContentType, type QueueDefinition, type QueueEnv, type QueueHandler, type DispatchLogger as QueueLogger, type QueueProducer, type QueueRegistry, type QueueRegistryEntry, type QueueRetryOptions, type QueueRunContext, type DispatchRunFunction as QueueRunFunction, type QueueSendBatchOptions, type QueueSendOptions, type Queues, type RunFunctionOptions, createQueueCaptureSink, createQueueContext, createQueueRunContext, createQueues, defineQueue, dispatchQueueBatch, isQueueDefinition, queueBindingName, queueDefaultName, shouldCaptureQueue };
453
+ /**
454
+ * Declare a topic in `lunora/queues.ts`. The payload type is the only thing a
455
+ * topic carries; delivery config lives on each subscription.
456
+ */
457
+ declare const defineTopic: <Payload = unknown>() => TopicDefinition<Payload>;
458
+ /**
459
+ * Subscribe to a topic. The result is a push queue definition — export it from
460
+ * `lunora/queues.ts` and it deploys, retries and dead-letters exactly like a
461
+ * `defineQueue` export of the same name.
462
+ *
463
+ * ⚠️ Same trust model as a queue handler: `message.run(...)` dispatches with the
464
+ * system identity, so validate `message.body` before acting on it.
465
+ */
466
+ declare const defineSubscription: <Payload>(topic: TopicDefinition<Payload>, config: SubscriptionConfig<Payload>) => SubscriptionDefinition<Payload>;
467
+ /**
468
+ * Build `ctx.topics` for a request from the Worker `env` and the codegen-emitted
469
+ * specs. A topic with no subscriptions publishes to nobody; a topic with an
470
+ * unbound subscription rejects every publish (see {@link unboundPublisher}).
471
+ */
472
+ declare const createTopicContext: <Name extends string>(env: Record<string, unknown>, specs: ReadonlyArray<TopicBindingSpec<Name>>) => Topics<Name>;
473
+ export { type ArgsOf, type CapturedQueueMessage, type FunctionReference, type LunoraQueuesOptions, type QueueBindingSpec, type QueueCaptureOptions, type QueueCaptureSink, type QueueConfig, type QueueConsumerMode, type QueueConsumerTuning, type QueueDefinition, type QueueEnv, type QueueHandler, type DispatchLogger as QueueLogger, type QueueMessage, type QueueMessageBatch, type QueueProducer, type QueueRegistry, type QueueRegistryEntry, type QueueRunContext, type DispatchRunFunction as QueueRunFunction, type Queues, type RunFunctionOptions, type SubscriptionConfig, type SubscriptionDefinition, type TopicBindingSpec, type TopicDefinition, type TopicPublisher, type Topics, createQueueCaptureSink, createQueueContext, createQueueRunContext, createQueues, createTopicContext, defineQueue, defineSubscription, defineTopic, dispatchQueueBatch, isQueueDefinition, queueBindingName, queueDefaultName, shouldCaptureQueue };
package/dist/index.mjs CHANGED
@@ -1 +1 @@
1
- import{createQueueCaptureSink as t,shouldCaptureQueue as r}from"./packem_shared/createQueueCaptureSink-saac8Ywl.mjs";import{createQueueContext as a}from"./packem_shared/createQueueContext-BuXG9VQI.mjs";import{default as i}from"./packem_shared/createQueues-BZBGfWI4.mjs";import{defineQueue as p,isQueueDefinition as m,queueBindingName as x,queueDefaultName as Q}from"./packem_shared/defineQueue-DJYVNfQY.mjs";import{dispatchQueueBatch as d}from"./packem_shared/dispatchQueueBatch-GZMtLZNW.mjs";import{createQueueRunContext as C}from"./packem_shared/createQueueRunContext-B-TpEaZa.mjs";export{t as createQueueCaptureSink,a as createQueueContext,C as createQueueRunContext,i as createQueues,p as defineQueue,d as dispatchQueueBatch,m as isQueueDefinition,x as queueBindingName,Q as queueDefaultName,r as shouldCaptureQueue};
1
+ import{createQueueCaptureSink as t,shouldCaptureQueue as o}from"./packem_shared/createQueueCaptureSink-CSOPftvj.mjs";import{createQueueContext as i}from"./packem_shared/createQueueContext-1r-Qo49i.mjs";import{createQueues as n}from"./packem_shared/createQueues-wa7A7lnf.mjs";import{defineQueue as f,isQueueDefinition as c,queueBindingName as x,queueDefaultName as m}from"./packem_shared/defineQueue-TY4-i3nG.mjs";import{dispatchQueueBatch as d}from"./packem_shared/dispatchQueueBatch-Ch0Yy8TN.mjs";import{c as C}from"./packem_shared/run-context-9CCDiINF.mjs";import{createTopicContext as l,defineSubscription as q,defineTopic as B}from"./packem_shared/createTopicContext-NB6VdRGc.mjs";export{t as createQueueCaptureSink,i as createQueueContext,C as createQueueRunContext,n as createQueues,l as createTopicContext,f as defineQueue,q as defineSubscription,B as defineTopic,d as dispatchQueueBatch,c as isQueueDefinition,x as queueBindingName,m as queueDefaultName,o as shouldCaptureQueue};
@@ -0,0 +1 @@
1
+ import{LunoraError as E}from"@lunora/errors";const _="__lunora_admin__:recordQueueMessage",N="__root__",f=/^(?:dev(?:elopment)?|local(?:host)?|test)$/iu,p=["CF_ENV","ENVIRONMENT","NODE_ENV","WORKER_ENV"],T=5e3,O=o=>{const t=o.LUNORA_QUEUE_CAPTURE;if(typeof t=="string"){const e=t.toLowerCase();if(e==="1"||e==="true")return!0;if(e==="0"||e==="false")return!1;console.warn(`@lunora/queue: unrecognized LUNORA_QUEUE_CAPTURE value "${t}" — expected "1"/"true" or "0"/"false"; falling back to environment detection.`)}return p.some(e=>{const r=o[e];return typeof r=="string"&&f.test(r)})},h=(o,t={})=>{const e=t.rootShard??N;return async r=>{if(r.length===0)return;const i=o.SHARD,s=typeof o.LUNORA_ADMIN_TOKEN=="string"?o.LUNORA_ADMIN_TOKEN:void 0;if(i===void 0||s===void 0)return;let a=i;if(t.jurisdiction!==void 0){if(typeof i.jurisdiction!="function")throw new TypeError(`@lunora/queue: Durable Object namespace does not support jurisdiction("${t.jurisdiction}") — update @cloudflare/workers-types or remove the jurisdiction option`);a=i.jurisdiction(t.jurisdiction)}const l=a.get(a.idFromName(e)),u=new AbortController,d=setTimeout(()=>{u.abort()},T);try{const n=await l.fetch("https://shard.internal/rpc",{body:JSON.stringify({args:{messages:r},functionPath:_}),headers:{authorization:`Bearer ${s}`,"content-type":"application/json"},method:"POST",signal:u.signal});if(!n.ok){const c=await n.text().catch(()=>"");throw new E("INTERNAL",`@lunora/queue: capture write to the root shard failed (${String(n.status)} ${n.statusText})${c===""?"":`: ${c}`}`)}await n.body?.cancel()}finally{clearTimeout(d)}}};export{h as createQueueCaptureSink,O as shouldCaptureQueue};
@@ -0,0 +1 @@
1
+ import{resolveQueueBinding as r,createQueues as s}from"./createQueues-wa7A7lnf.mjs";const u=(t,i)=>{const e={};for(const n of i){const o=r(t,n.binding);o&&(e[n.exportName]=o)}return s({bindings:e})};export{u as createQueueContext};
@@ -0,0 +1 @@
1
+ import{c as o}from"./run-context-9CCDiINF.mjs";export{o as createQueueRunContext};
@@ -0,0 +1 @@
1
+ import{LunoraError as a}from"@lunora/errors";import{h as g,R as h}from"./requeue-envelope-CSyxsn47.mjs";const i=100,d=43200,l=(t,n)=>{if(t!==void 0&&t>d)throw new a("VALIDATION_ERROR",`@lunora/queue: ${n} delaySeconds is ${String(t)}, over the Cloudflare Queues ceiling of ${String(d)} (12 hours) — use @lunora/scheduler for longer schedules`)},$=(t,n)=>{if(g(t))throw new a("VALIDATION_ERROR",`@lunora/queue: ${n} body may not contain the reserved key "${h}"`)},f=(t,n,e)=>{l(n?.delaySeconds,e),$(t,e)},E=(t,n,e)=>{l(n?.delaySeconds,e);const s=[...t];if(s.length>i)throw new a("VALIDATION_ERROR",`@lunora/queue: ${e} exceeds ${String(i)} (got ${String(s.length)}) — split across calls`);for(const[r,o]of s.entries())f(o.body,o,`${e} message ${String(r)}`);return s},O=t=>({send:async(n,e)=>{f(n,e,"send"),await t.send(n,e)},sendBatch:async(n,e)=>{await t.sendBatch(E(n,e,"sendBatch"),e)}}),y=(t,n)=>{const e=t[n];return e&&typeof e.send=="function"&&typeof e.sendBatch=="function"?e:void 0},R=(t,n,e)=>{const s=Object.assign(Object.create(null),t),r=Object.keys(s),o=r.length===0?`no ${n}s are declared`:`known ${n}s: ${r.join(", ")}`;return new Proxy(s,{get(u,c){if(typeof c=="string")return Object.hasOwn(u,c)?u[c]:e(()=>Promise.reject(new Error(`@lunora/queue: no ${n} named "${c}" (${o})`)))}})},w=t=>{const n=t.bindings??{},e={};for(const[s,r]of Object.entries(n))e[s]=O(r);return R(e,"queue",s=>({send:s,sendBatch:s}))};export{f as assertSendable,w as createQueues,R as namedLookup,E as prepareBatch,y as resolveQueueBinding};
@@ -0,0 +1 @@
1
+ import{resolveQueueBinding as c,namedLookup as p,prepareBatch as a,assertSendable as h}from"./createQueues-wa7A7lnf.mjs";const f=()=>({isLunoraTopic:!0}),b=n=>typeof n=="object"&&n!==null&&n.isLunoraTopic===!0,w=(n,e)=>{if(!b(n))throw new TypeError("defineSubscription: the first argument must be a `defineTopic()` result");if(typeof e.handler!="function")throw new TypeError("defineSubscription: `handler` must be a function");if(e.name!==void 0&&(typeof e.name!="string"||e.name.length===0))throw new TypeError("defineSubscription: `name` must be a non-empty string when provided");return{...e,isLunoraQueue:!0,mode:"push",topic:n}},l=n=>({publish:async(e,t)=>{h(e,t,"publish"),await Promise.all(n.map(async r=>r.send(e,t)))},publishBatch:async(e,t)=>{const r=a(e,t,"publishBatch");await Promise.all(n.map(async i=>i.sendBatch(r,t)))}}),d=n=>{const e=()=>Promise.reject(new Error(`@lunora/queue: missing queue binding(s) ${n.join(", ")} — add them to wrangler.jsonc (lunora dev reconciles them); nothing was published`));return{publish:e,publishBatch:e}},y=(n,e)=>{const t={};for(const r of e){const i=[],s=[];for(const o of r.subscriptions){const u=c(n,o.binding);u?i.push(u):s.push(o.binding)}t[r.exportName]=s.length>0?d(s):l(i)}return p(t,"topic",r=>({publish:r,publishBatch:r}))};export{y as createTopicContext,w as defineSubscription,f as defineTopic};
@@ -1 +1 @@
1
- const n=e=>`QUEUE_${e.replaceAll(/(?<=[a-z0-9])(?=[A-Z])/g,"_").toUpperCase()}`,o=e=>e.replaceAll(/(?<=[a-z0-9])(?=[A-Z])/g,"-").toLowerCase(),r=e=>{const u=e.mode??"push";if(u!=="push"&&u!=="pull")throw new TypeError(`defineQueue: \`mode\` must be "push" or "pull" (got ${JSON.stringify(e.mode)})`);if(u==="push"&&typeof e.handler!="function")throw new TypeError('defineQueue: `handler` must be a function for a push consumer (omit it only when `mode: "pull"`)');if(e.name!==void 0&&(typeof e.name!="string"||e.name.length===0))throw new TypeError("defineQueue: `name` must be a non-empty string when provided");return{...e,isLunoraQueue:!0,mode:u}},t=e=>typeof e=="object"&&e!==null&&e.isLunoraQueue===!0;export{r as defineQueue,t as isQueueDefinition,n as queueBindingName,o as queueDefaultName};
1
+ const n=e=>`QUEUE_${e.replaceAll(/(?<=[a-z0-9])(?=[A-Z])/g,"_").toUpperCase()}`,t=e=>e.replaceAll(/(?<=[a-z0-9])(?=[A-Z])/g,"-").toLowerCase(),o=e=>{const u=e.mode??"push";if(u!=="push"&&u!=="pull")throw new TypeError(`defineQueue: \`mode\` must be "push" or "pull" (got ${JSON.stringify(e.mode)})`);if(u==="push"&&typeof e.handler!="function")throw new TypeError('defineQueue: `handler` must be a function for a push consumer (omit it only when `mode: "pull"`)');if(e.name!==void 0&&(typeof e.name!="string"||e.name.length===0))throw new TypeError("defineQueue: `name` must be a non-empty string when provided");return{...e,isLunoraQueue:!0,mode:u}},r=e=>typeof e=="object"&&e!==null&&e.isLunoraQueue===!0;export{o as defineQueue,r as isQueueDefinition,n as queueBindingName,t as queueDefaultName};
@@ -0,0 +1 @@
1
+ import{c as h,e as x,t as N,Z as q,r as k,Q as R}from"./run-context-9CCDiINF.mjs";import{LunoraError as $,toErrorBody as M}from"@lunora/errors";import{s as A,o as I}from"./requeue-envelope-CSyxsn47.mjs";const b=e=>typeof e.definition.maxRetries=="number"?e.definition.maxRetries:R,O=e=>{if(e instanceof Date)return e.getTime();const t=typeof e=="number"?e:Number(e);return Number.isFinite(t)?t:0},T=async(e,t)=>{const n=await I(t,e.body).catch(()=>{});return n===void 0?{body:e.body,id:e.id,requeued:!1}:{...n,requeued:!0}},g=e=>{const t=e.LUNORA_ADMIN_TOKEN;return typeof t=="string"&&t.length>0?t:void 0},B=(e,t)=>{let n=0;return(o,i,r)=>(n+=1,e(o,i,{...r,dedupId:`${t}#${String(n)}`,messageId:t}))},L=(e,t,n,o)=>{const i=B(o,t.id);return new Proxy(e,{get:(r,s)=>s==="id"?t.id:s==="body"?t.body:s==="ack"?()=>{n.set(r,"ack"),r.ack()}:s==="retry"?f=>{n.set(r,"retry"),r.retry(f)}:s==="run"?i:Reflect.get(r,s,r)})},j=(e,t,n)=>new Proxy(e,{get:(o,i)=>i==="ackAll"?()=>{n("ack"),o.ackAll()}:i==="retryAll"?r=>{n("retry"),o.retryAll(r)}:i==="messages"?t:Reflect.get(o,i,o)}),Q=async(e,t,n)=>{const o=new Map,i=e.messages,r=new Map,s=[];for(const c of i){const u=await T(c,n);r.set(c,u),s.push(L(c,u,o,t))}const l=j(e,s,c=>{for(const u of i)o.has(u)||o.set(u,c)});return{dispositions:o,originals:i,requeued:new Set,views:r,wrappedBatch:l}},m=(e,t)=>e.views.get(t)??{body:t.body,id:t.id,requeued:!1},v=(e,t)=>m(e,t).id,S=e=>{if(e instanceof Error)return e.message;if(typeof e=="string")return e;if(e!==null&&typeof e=="object")try{return JSON.stringify(e)}catch{return"[unserializable thrown value]"}return String(e)},D=(e,t,n,o,i,r)=>{const s=o?S(i):void 0,f=b(t),l=o?"error":"ack",c=typeof t.definition.deadLetterQueue=="string"&&t.definition.deadLetterQueue.length>0;return e.originals.map(u=>{const d=u===r,a=e.dispositions.get(u),p=d?"error":a??l,w=typeof u.attempts=="number"?u.attempts:1,y=m(e,u);return{attempts:w,body:y.body,deadLettered:c&&!d&&!e.requeued.has(u)&&p!=="ack"&&w>f,error:p==="error"?s:void 0,exportName:t.exportName,messageId:y.id,outcome:p,queue:n,timestamp:O(u.timestamp)}})},C=(e,t,n)=>{if(!t||!N(n))return;const o=q(n);if(o===void 0)return;const i=e.originals.find(r=>v(e,r)===o);if(!(i===void 0||e.dispositions.has(i)))return i},P=(e,t)=>{t.ack();for(const n of e.originals)n!==t&&!e.dispositions.has(n)&&(e.dispositions.set(n,"retry"),n.retry())},F=async(e,t,n,o,i)=>{const r=g(i),s=q(t),f=e.originals.find(d=>v(e,d)===s&&!e.dispositions.has(d)),l=`@lunora/queue: queue "${o}" (${n.exportName})`,c=n.binding===void 0?void 0:i[n.binding],u=[];for(const d of e.originals)if(!e.dispositions.has(d)){if(e.dispositions.set(d,"retry"),f!==void 0&&d!==f){d.retry();continue}u.push(d)}await Promise.all(u.map(async d=>{const a=m(e,d),p=typeof c?.send!="function"||a.requeued||r===void 0?void 0:async w=>{await c.send(await A(r,a.id,a.body),{contentType:"json",delaySeconds:w})};await k(d,{maxRetries:b(n),requeue:p,where:l})==="requeued"&&e.requeued.add(d)}))},E=async(e,t,n)=>{const o=Object.hasOwn(t,e.queue)?t[e.queue]:void 0;if(o===void 0){const a=Object.keys(t),p=a.length===0?"no push queues are declared":`known push queues: ${a.join(", ")}`;throw new $("INTERNAL",`@lunora/queue: received a batch for queue "${e.queue}" but no push handler is registered (${p}). Likely causes: the queue is declared \`pull\`, a renamed per-environment queue has no wrangler.jsonc producer with the declared binding, or codegen was not rerun.`)}const{handler:i}=o.definition;if(typeof i!="function")throw new TypeError(`@lunora/queue: queue "${e.queue}" (${o.exportName}) has no push handler — it is declared as a pull consumer`);const r=h({env:n.env,exportName:o.exportName,fetchImpl:n.fetchImpl,...n.traceparent===void 0?{}:{traceparent:n.traceparent}}),s=await Q(e,r.run,g(n.env));let f=!1,l;try{await i(r,s.wrappedBatch)}catch(a){f=!0,l=a}const c=C(s,f,l);if(c!==void 0){P(s,c);const{body:a,status:p}=M(l,{redactedMessage:"internal error"});console.error(`@lunora/queue: dropped message ${v(s,c)} on queue "${e.queue}" (${o.exportName}) — a dispatch it made failed with a deterministic ${String(p)} (${a.code}: ${a.message}), so it was acked, not retried. Its retries are NOT exhausted and it is not dead-lettered — it will never be redelivered.`)}const u=f&&x(l);u&&await F(s,l,o,e.queue,n.env);const d=f&&c===void 0&&!u;if(n.capture!==void 0)try{await n.capture(D(s,o,e.queue,f,l,c))}catch(a){console.warn("@lunora/queue: capture sink failed (delivery unaffected):",a)}if(d)throw l};export{E as dispatchQueueBatch};
@@ -0,0 +1,4 @@
1
+ import{encodeWire as f,decodeWire as l}from"@lunora/wire";const g="lunora.requeue.v1",u=new TextEncoder,y=async(e,t)=>crypto.subtle.importKey("raw",u.encode(e),{hash:"SHA-256",name:"HMAC"},!1,[t]),d=(e,t,n)=>u.encode(`${g}
2
+ ${e}
3
+ ${t}
4
+ ${n}`),w=e=>[...new Uint8Array(e)].map(t=>t.toString(16).padStart(2,"0")).join(""),b=/^[\da-f]{64}$/u,S=async(e,t,n,s)=>w(await crypto.subtle.sign("HMAC",await y(e,"sign"),d(t,n,s))),h=async(e,t,n,s,o)=>{if(typeof o!="string"||!b.test(o))return!1;const r=Uint8Array.from({length:o.length/2},(E,i)=>Number.parseInt(o.slice(i*2,i*2+2),16));return crypto.subtle.verify("HMAC",await y(e,"verify"),r,d(t,n,s))},a="$lunora.requeued$",p="queue",c=128*1024,m=e=>typeof e=="object"&&e!==null&&Object.hasOwn(e,a),A=async(e,t,n)=>{const s=JSON.stringify(f(n)),o={[a]:{body:s,id:t,mac:await S(e,p,t,s)}},r=new TextEncoder().encode(JSON.stringify(o)).byteLength;if(r>c)throw new RangeError(`the copy would be ${String(r)} bytes, over the ${String(c)}-byte queue message limit`);return o},$=async(e,t)=>{if(e===void 0||!m(t))return;const n=t[a];if(!(typeof n?.id!="string"||typeof n.body!="string")&&await h(e,p,n.id,n.body,n.mac))return{body:l(JSON.parse(n.body)),id:n.id}};export{a as R,m as h,$ as o,A as s};
@@ -0,0 +1 @@
1
+ import{LunoraError as u,isLunoraError as b}from"@lunora/errors";import{decodeWire as D,encodeArgsOrThrow as U}from"@lunora/wire";const q=e=>({debug:(t,...r)=>{console.debug(e,t,...r)},error:(t,...r)=>{console.error(e,t,...r)},info:(t,...r)=>{console.info(e,t,...r)},warn:(t,...r)=>{console.warn(e,t,...r)}}),P=9e5,I="DISPATCH_IN_PROGRESS",j="x-lunora-dispatch-declined",C=(e,t,r)=>{if(t===void 0)return{dispose:()=>{},signal:e};const a=new AbortController,o=setTimeout(()=>{a.abort(r())},t);return{dispose:()=>{clearTimeout(o)},signal:a.signal}},i="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_",S=e=>{let t="",r=0;const a=e.length-2;for(;r<a;r+=3){const n=e[r]<<16|e[r+1]<<8|e[r+2];t+=i.charAt(n>>18&63)+i.charAt(n>>12&63)+i.charAt(n>>6&63)+i.charAt(n&63)}const o=e.length-r;if(o===1){const n=e[r]<<16;t+=i.charAt(n>>18&63)+i.charAt(n>>12&63)}else if(o===2){const n=e[r]<<16|e[r+1]<<8;t+=i.charAt(n>>18&63)+i.charAt(n>>12&63)+i.charAt(n>>6&63)}return t};new TextDecoder;const v=new TextEncoder,N="=",J=e=>{for(let t=0;t<e.length;t+=1)if(e.charCodeAt(t)>255)return!1;return!0},k=e=>S(v.encode(JSON.stringify(e))),M=e=>!e.startsWith(N)&&J(e)?e:`${N}${S(v.encode(e))}`,K="/_lunora/scheduler/dispatch",W=3e4,G=e=>{let t=e.length;for(;t>0&&e[t-1]==="/";)t-=1;return e.slice(0,t)},T=Symbol("lunoraDispatchFailure"),R=Symbol("lunoraDispatchDecline"),E=Symbol("lunoraDispatchMessageId"),O=(e,t)=>(t!==void 0&&Object.defineProperty(e,E,{value:t}),e),H=(e,t)=>(Object.defineProperty(e,T,{value:!0}),O(e,t)),ee=e=>b(e)?e[E]:void 0,Q=(e,t,r,a)=>{const{status:o}=t;try{const n=JSON.parse(r)?.error;if(typeof n=="object"&&n!==null&&typeof n.code=="string"){const{code:l,data:g,message:d}=n,h=H(new u(l,typeof d=="string"?d:void 0,{data:g,status:o}),a);return t.headers.get(j)==="1"&&Object.defineProperty(h,R,{value:!0}),h}}catch{}return O(new u("INTERNAL",`${e}: function dispatch failed (${String(o)}): ${r}`,{status:o}),a)},B=new Set([400,403,404,422]),F=new Set([I,"DISPATCH_UNAUTHENTICATED"]),_=e=>b(e)&&e[T]===!0,te=e=>_(e)&&B.has(e.status)&&!F.has(e.code),re=e=>_(e)&&e[R]===!0,$=P/1e3,z=3,ne=async(e,t)=>{const r=t.maxRetries??z,a=typeof e.attempts=="number"&&e.attempts>r;let o="";if(a&&t.requeue!==void 0)try{return await t.requeue($),e.ack(),"requeued"}catch(n){o=` Re-enqueueing a delayed copy failed (${n instanceof Error?n.message:String(n)}).`}return e.retry({delaySeconds:$}),a&&console.error(`${t.where}: message ${e.id} was declined (${I}) on its last delivery (attempt ${String(e.attempts)} of ${String(r+1)}) — its earlier delivery is still running the call.${o} It is retried anyway, but its retries are exhausted, so it will be dead-lettered if the queue has a deadLetterQueue and dropped if not. Raise max_retries so a slow call cannot exhaust it.`),"retried"},X=(e,t,r)=>new u("INTERNAL",`${e}: function dispatch to "${t}" timed out after ${String(r)}ms`,{status:503}),Z=e=>{const{label:t}=e,r=globalThis.fetch,a=e.fetchImpl??(typeof r=="function"?r.bind(globalThis):void 0);return async(o,n,l={})=>{if(typeof a!="function")throw new TypeError(`${t}: no fetch implementation available — pass fetchImpl or run on a platform with global fetch`);const g=()=>e.argsAlreadyEncoded===!0?n??{}:U(t,o.__lunoraRef,n??{}),d=e.env.LUNORA_ORIGIN_URL;if(typeof d!="string"||d.length===0)throw new u("INTERNAL",`${t}: \`LUNORA_ORIGIN_URL\` must be set on the Worker env so a handler can call back into Lunora functions`);const h=e.env.LUNORA_ADMIN_TOKEN;if(typeof h!="string"||h.length===0)throw new u("INTERNAL",`${t}: \`LUNORA_ADMIN_TOKEN\` must be set on the Worker env to authenticate function dispatch`);const L=`${G(d)}${K}`,y={authorization:`Bearer ${h}`,"content-type":"application/json"};e.traceparent!==void 0&&e.traceparent.length>0&&(y.traceparent=e.traceparent),e.identity?.userId!==void 0&&(y["x-lunora-userid"]=M(e.identity.userId)),e.identity?.claims!==void 0&&(y["x-lunora-identity"]=k(e.identity.claims));const m=l.timeoutMs??W,A=C(void 0,m,()=>new DOMException(`dispatch timed out after ${String(m)}ms`,"TimeoutError")),w=c=>{throw c instanceof Error&&c.name==="TimeoutError"?X(t,o.__lunoraRef,m):c};let s;try{try{s=await a(L,{body:JSON.stringify({args:g(),functionPath:o.__lunoraRef,id:l.dedupId,shardKey:l.shardKey}),headers:y,method:"POST",signal:A.signal})}catch(p){return w(p)}if(!s.ok){let p;try{p=await s.text()}catch(x){return w(x)}throw Q(t,s,p,l.messageId)}let c;try{c=await s.text()}catch(p){return w(p)}if(c.length===0)return;let f;try{f=JSON.parse(c)}catch{throw new u("INTERNAL",`${t}: function dispatch returned a non-JSON body (${String(s.status)}): ${c}`,{status:s.status})}if(f===null||typeof f!="object"||Array.isArray(f)||!("result"in f))throw new u("INTERNAL",`${t}: function dispatch returned a JSON body that is not a { result } envelope (${String(s.status)}): ${c}`,{status:s.status});return D(f.result)}finally{A.dispose()}}},oe=e=>({env:e.env,log:q(`[queue:${e.exportName}]`),run:Z({env:e.env,fetchImpl:e.fetchImpl,label:"@lunora/queue",...e.traceparent===void 0?{}:{traceparent:e.traceparent}})});export{z as Q,ee as Z,oe as c,re as e,ne as r,te as t};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lunora/queue",
3
- "version": "1.0.0-alpha.11",
3
+ "version": "1.0.0-alpha.110",
4
4
  "description": "Cloudflare Queues for Lunora: defineQueue producers + consumers, the ctx.queues surface, and the generated queue() worker handler",
5
5
  "keywords": [
6
6
  "background-jobs",
@@ -44,7 +44,9 @@
44
44
  "access": "public"
45
45
  },
46
46
  "dependencies": {
47
- "@lunora/errors": "1.0.0-alpha.9"
47
+ "@lunora/errors": ">=1.0.0-alpha.51 <2.0.0-0",
48
+ "@lunora/platform": "1.0.0-alpha.66",
49
+ "@lunora/wire": "1.0.0-alpha.1"
48
50
  },
49
51
  "engines": {
50
52
  "node": "^22.15.0 || >=24.11.0"
@@ -1 +0,0 @@
1
- import{LunoraError as p}from"@lunora/errors";const N="__lunora_admin__:recordQueueMessage",_="__root__",f=/^(?:dev(?:elopment)?|local(?:host)?|test)$/iu,h=["CF_ENV","ENVIRONMENT","NODE_ENV","WORKER_ENV"],E=5e3,y=e=>{const t=e.LUNORA_QUEUE_CAPTURE;return typeof t=="string"?t==="1"||t.toLowerCase()==="true":h.some(n=>{const o=e[n];return typeof o=="string"&&f.test(o)})},g=(e,t={})=>{const n=t.rootShard??_;return async o=>{if(o.length===0)return;const i=e.SHARD,a=typeof e.LUNORA_ADMIN_TOKEN=="string"?e.LUNORA_ADMIN_TOKEN:void 0;if(i===void 0||a===void 0)return;let s=i;if(t.jurisdiction!==void 0){if(typeof i.jurisdiction!="function")throw new TypeError(`@lunora/queue: Durable Object namespace does not support jurisdiction("${t.jurisdiction}") — update @cloudflare/workers-types or remove the jurisdiction option`);s=i.jurisdiction(t.jurisdiction)}const d=s.get(s.idFromName(n)),u=new AbortController,l=setTimeout(()=>{u.abort()},E);try{const r=await d.fetch("https://shard.internal/rpc",{body:JSON.stringify({args:{messages:o},functionPath:N}),headers:{authorization:`Bearer ${a}`,"content-type":"application/json"},method:"POST",signal:u.signal});if(!r.ok){const c=await r.text().catch(()=>"");throw new p("INTERNAL",`@lunora/queue: capture write to the root shard failed (${String(r.status)} ${r.statusText})${c===""?"":`: ${c}`}`)}await r.body?.cancel()}finally{clearTimeout(l)}}};export{g as createQueueCaptureSink,y as shouldCaptureQueue};
@@ -1 +0,0 @@
1
- import i from"./createQueues-BZBGfWI4.mjs";const f=(e,c)=>{const t={};for(const o of c){const n=e[o.binding];n&&typeof n.send=="function"&&typeof n.sendBatch=="function"&&(t[o.exportName]=n)}return i({bindings:t})};export{f as createQueueContext};
@@ -1 +0,0 @@
1
- import{LunoraError as a}from"@lunora/errors";const p=t=>({debug:(e,...n)=>{console.debug(t,e,...n)},error:(e,...n)=>{console.error(t,e,...n)},info:(e,...n)=>{console.info(t,e,...n)},warn:(e,...n)=>{console.warn(t,e,...n)}}),N="/_lunora/scheduler/dispatch",y=t=>{let e=t.length;for(;e>0&&t[e-1]==="/";)e-=1;return t.slice(0,e)},g=(t,e,n)=>{try{const o=JSON.parse(n)?.error;if(typeof o=="object"&&o!==null&&typeof o.code=="string"){const{code:s,data:c,message:i}=o;return new a(s,typeof i=="string"?i:void 0,{data:c,status:e})}}catch{}return new a("INTERNAL",`${t}: function dispatch failed (${String(e)}): ${n}`,{status:e})},w=t=>{const{label:e}=t,n=globalThis.fetch,o=t.fetchImpl??(typeof n=="function"?n.bind(globalThis):void 0);return async(s,c,i={})=>{if(typeof o!="function")throw new TypeError(`${e}: no fetch implementation available — pass fetchImpl or run on a platform with global fetch`);const u=t.env.LUNORA_ORIGIN_URL;if(typeof u!="string"||u.length===0)throw new a("INTERNAL",`${e}: \`LUNORA_ORIGIN_URL\` must be set on the Worker env so a handler can call back into Lunora functions`);const l=t.env.LUNORA_ADMIN_TOKEN;if(typeof l!="string"||l.length===0)throw new a("INTERNAL",`${e}: \`LUNORA_ADMIN_TOKEN\` must be set on the Worker env to authenticate function dispatch`);const d=`${y(u)}${N}`,f={authorization:`Bearer ${l}`,"content-type":"application/json"};t.identity?.userId!==void 0&&(f["x-lunora-userid"]=t.identity.userId),t.identity?.claims!==void 0&&(f["x-lunora-identity"]=JSON.stringify(t.identity.claims));const r=await o(d,{body:JSON.stringify({args:c??{},functionPath:s.__lunoraRef,shardKey:i.shardKey}),headers:f,method:"POST"});if(!r.ok)throw g(e,r.status,await r.text());const h=await r.text();if(h.length!==0)try{return JSON.parse(h)}catch{throw new a("INTERNAL",`${e}: function dispatch returned a non-JSON body (${String(r.status)}): ${h}`,{status:r.status})}}},I=t=>({env:t.env,log:p(`[queue:${t.exportName}]`),run:w({env:t.env,fetchImpl:t.fetchImpl,label:"@lunora/queue"})});export{I as createQueueRunContext};
@@ -1 +0,0 @@
1
- const u=r=>({send:async(s,n)=>{await r.send(s,n)},sendBatch:async(s,n)=>{await r.sendBatch(s,n)}}),d=r=>{const s=r.bindings??{},n=Object.create(null);for(const[t,e]of Object.entries(s))n[t]=u(e);const c=Object.keys(n),a=t=>{const e=c.length===0?"no queues are declared":`known queues: ${c.join(", ")}`,o=()=>Promise.reject(new Error(`@lunora/queue: no queue named "${t}" (${e})`));return{send:o,sendBatch:o}};return new Proxy(n,{get(t,e){if(typeof e=="string")return Object.hasOwn(t,e)?t[e]:a(e)}})};export{d as default};
@@ -1 +0,0 @@
1
- import{LunoraError as m}from"@lunora/errors";import{createQueueRunContext as d}from"./createQueueRunContext-B-TpEaZa.mjs";const l=3,f=e=>{if(e instanceof Date)return e.getTime();const r=typeof e=="number"?e:Number(e);return Number.isFinite(r)?r:0},h=e=>{const r=new Map,o=e.messages,n=o.map(t=>({ack:()=>{r.set(t,"ack"),t.ack()},get attempts(){return t.attempts},get body(){return t.body},get id(){return t.id},retry:a=>{r.set(t,"retry"),t.retry(a)},get timestamp(){return t.timestamp}})),i=t=>{for(const a of o)r.has(a)||r.set(a,t)},c={ackAll:()=>{i("ack"),e.ackAll()},messages:n,queue:e.queue,retryAll:t=>{i("retry"),e.retryAll(t)}};return{dispositions:r,originals:o,wrappedBatch:c}},y=e=>{if(e instanceof Error)return e.message;if(typeof e=="string")return e;if(e!==null&&typeof e=="object")try{return JSON.stringify(e)}catch{return"[unserializable thrown value]"}return String(e)},g=(e,r,o,n,i)=>{const c=n?y(i):void 0,t=typeof r.definition.maxRetries=="number"?r.definition.maxRetries:l;return e.originals.map(a=>{const s=e.dispositions.get(a)??(n?"error":"ack"),u=typeof a.attempts=="number"?a.attempts:1;return{attempts:u,body:a.body,deadLettered:s!=="ack"&&u>t,error:s==="error"?c:void 0,exportName:r.exportName,messageId:a.id,outcome:s,queue:o,timestamp:f(a.timestamp)}})},w=async(e,r,o)=>{const n=Object.hasOwn(r,e.queue)?r[e.queue]:void 0;if(n===void 0){const u=Object.keys(r),p=u.length===0?"no push queues are declared":`known push queues: ${u.join(", ")}`;throw new m("INTERNAL",`@lunora/queue: received a batch for queue "${e.queue}" but no push handler is registered (${p})`)}const{handler:i}=n.definition;if(typeof i!="function")throw new TypeError(`@lunora/queue: queue "${e.queue}" (${n.exportName}) has no push handler — it is declared as a pull consumer`);const c=d({env:o.env,exportName:n.exportName,fetchImpl:o.fetchImpl});if(o.capture===void 0){await i(c,e);return}const t=h(e);let a=!1,s;try{await i(c,t.wrappedBatch)}catch(u){a=!0,s=u}try{const u=g(t,n,e.queue,a,s);await o.capture(u)}catch(u){console.warn("@lunora/queue: capture sink failed (delivery unaffected):",u)}if(a)throw s};export{w as dispatchQueueBatch};