@lunora/queue 1.0.0-alpha.12 → 1.0.0-alpha.121

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,4 +1,4 @@
1
- import { QueueBindingLike, MessageBatchLike, QueueSendOptions, MessageSendRequestLike, QueueSendBatchOptions } from '@lunora/platform';
1
+ import { QueueBindingLike, MessageBatchLike, MessageLike, QueueSendOptions, MessageSendRequestLike, QueueSendBatchOptions } from '@lunora/platform';
2
2
  export type { MessageBatchLike, MessageLike, MessageSendRequestLike, QueueBindingLike, QueueContentType, QueueRetryOptions, QueueSendBatchOptions, QueueSendOptions } from '@lunora/platform';
3
3
  /**
4
4
  * Shared types for dispatching a Lunora function back into the worker from a
@@ -14,8 +14,45 @@ interface FunctionReference {
14
14
  type ArgsOf<F> = F extends FunctionReference ? Record<string, unknown> : never;
15
15
  /** Options for a function call made via a dispatch runner. */
16
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;
17
52
  /** Route the call to a specific shard (defaults to the worker's root shard). */
18
53
  shardKey?: string;
54
+ /** Abort the dispatch after this many ms; the abort is retryable. Overrides the runner's default. */
55
+ timeoutMs?: number;
19
56
  }
20
57
  /** Invoke a Lunora function (query/mutation/action) by reference. The shape of `ctx.run`. */
21
58
  type DispatchRunFunction = <F extends FunctionReference>(function_: F, args?: ArgsOf<F>, options?: RunFunctionOptions) => Promise<unknown>;
@@ -27,7 +64,7 @@ interface DispatchLogger {
27
64
  warn: (message: unknown, ...rest: unknown[]) => void;
28
65
  }
29
66
  /**
30
- * 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
31
68
  * the generated context exposes this only on `MutationCtx` / `ActionCtx` (never
32
69
  * the deterministic `QueryCtx`), mirroring `ctx.scheduler` / `ctx.workflows`.
33
70
  */
@@ -39,16 +76,16 @@ interface QueueProducer<Body = unknown> {
39
76
  }
40
77
  /**
41
78
  * `ctx.queues` — the map of declared queue export names → typed producers.
42
- * Codegen narrows this to the exact export names; the package keeps it open so
43
- * `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.
44
83
  */
45
- interface Queues {
46
- [exportName: string]: QueueProducer;
47
- }
84
+ type Queues<Name extends string = string> = Readonly<Record<Name, QueueProducer>>;
48
85
  /** Options the package-level `createQueues` factory takes. */
49
- interface LunoraQueuesOptions {
86
+ interface LunoraQueuesOptions<Name extends string = string> {
50
87
  /** Map of `lunora/queues.ts` export name → Cloudflare `Queue` producer binding. */
51
- bindings: Record<string, QueueBindingLike>;
88
+ bindings: Record<Name, QueueBindingLike>;
52
89
  }
53
90
  /**
54
91
  * The context handed to a `defineQueue` handler. Decoupled from `@lunora/server`
@@ -59,15 +96,68 @@ interface LunoraQueuesOptions {
59
96
  interface QueueRunContext {
60
97
  /** The worker `env` (bindings + vars). */
61
98
  readonly env: Record<string, unknown>;
99
+ /**
100
+ * `ctx.live.publish(ref, payload)` — push an event to the SSE subscribers of the
101
+ * `defineLiveStream` that declares `ref`'s topic, on the shard the publish names. Present only when the
102
+ * runtime hands the consumer a publisher (an app that declares a live stream on
103
+ * a target that serves one); absent otherwise. A consumer runs in the Worker, so
104
+ * the publish is forwarded to the shard rather than made in-process.
105
+ */
106
+ readonly live?: QueueLivePublisher;
62
107
  /** Queue-name-prefixed logger. */
63
108
  readonly log: DispatchLogger;
64
- /** Invoke a Lunora function (query/mutation/action) by reference. */
109
+ /**
110
+ * Invoke a Lunora function (query/mutation/action) by reference.
111
+ *
112
+ * Batch-unaware: a failure it throws is attributed to nothing, so a
113
+ * deterministic failure retries the whole batch. Inside the `batch.messages`
114
+ * loop, prefer {@link QueueMessage.run}, which pins the call to its message.
115
+ */
65
116
  readonly run: DispatchRunFunction;
66
117
  }
118
+ /**
119
+ * The publisher a queue consumer's `ctx.live` is. Structural, so the runtime
120
+ * builds it without importing this package. See {@link QueueRunContext.live}.
121
+ */
122
+ interface QueueLivePublisher {
123
+ publish: <Payload>(topic: QueueLiveTopicRef<Payload>, payload: NoInfer<Payload>, options?: {
124
+ shardKey?: string;
125
+ }) => Promise<void>;
126
+ }
127
+ /**
128
+ * A reference to one declared live topic, as `_generated/live.ts` hands it out.
129
+ * Structural, so this package needs neither the server nor the client: the phantom
130
+ * `Payload` is what types the event.
131
+ */
132
+ interface QueueLiveTopicRef<Payload = unknown> {
133
+ readonly __lunoraLive: string;
134
+ readonly __payload?: Payload;
135
+ readonly topic: string;
136
+ }
137
+ /**
138
+ * One delivered message as the push handler sees it: the Cloudflare `Message`
139
+ * plus `run` — a {@link QueueRunContext.run} pinned to THIS message.
140
+ *
141
+ * Prefer `message.run(api.x.y, args)` over `ctx.run(...)` inside the batch
142
+ * loop. The pin is what lets the dispatcher attribute a deterministic dispatch
143
+ * failure (400/403/404/422) to the one message that caused it: that message is
144
+ * acked and every other one is retried, instead of the whole batch being
145
+ * re-delivered because of a single poison message. A plain `ctx.run` call
146
+ * carries no message id, so its failure stays unattributed and the whole batch
147
+ * retries.
148
+ */
149
+ interface QueueMessage<Body = unknown> extends MessageLike<Body> {
150
+ /** {@link QueueRunContext.run}, pinned to this message for failure attribution. */
151
+ readonly run: DispatchRunFunction;
152
+ }
153
+ /** The delivered batch as the push handler sees it — {@link QueueMessage}s rather than bare `Message`s. */
154
+ interface QueueMessageBatch<Body = unknown> extends Omit<MessageBatchLike<Body>, "messages"> {
155
+ readonly messages: ReadonlyArray<QueueMessage<Body>>;
156
+ }
67
157
  /** Whether a declared queue is consumed by this worker (push) or polled externally (pull). */
68
158
  type QueueConsumerMode = "pull" | "push";
69
159
  /** The handler body run for each delivered batch (push consumers only). */
70
- type QueueHandler<Body = unknown> = (context: QueueRunContext, batch: MessageBatchLike<Body>) => Promise<void> | void;
160
+ type QueueHandler<Body = unknown> = (context: QueueRunContext, batch: QueueMessageBatch<Body>) => Promise<void> | void;
71
161
  /** Push-consumer batch/retry tuning, mirrored onto the wrangler `queues.consumers[]` entry. */
72
162
  interface QueueConsumerTuning {
73
163
  /** Name of the dead-letter queue messages land in after `maxRetries`. */
@@ -76,7 +166,9 @@ interface QueueConsumerTuning {
76
166
  maxBatchSize?: number;
77
167
  /** Max seconds to wait before delivering a partial batch (0–60, default 5). */
78
168
  maxBatchTimeout?: number;
79
- /** Max delivery attempts before a message is dropped / dead-lettered (default 3). */
169
+ /** Max concurrent consumer invocations (1–250). Unset lets Cloudflare autoscale. Not emulated by Miniflare in dev. */
170
+ maxConcurrency?: number;
171
+ /** Retries **after** the initial delivery, before a message is dropped / dead-lettered. Default 3, so up to 4 deliveries in total. */
80
172
  maxRetries?: number;
81
173
  /** Delay in seconds before a failed batch is retried. */
82
174
  retryDelay?: number;
@@ -100,29 +192,92 @@ interface QueueConfig<Body = unknown> extends QueueConsumerTuning {
100
192
  interface QueueDefinition<Body = unknown> extends QueueConfig<Body> {
101
193
  /**
102
194
  * Phantom carrier for the message body type, so codegen can type the
103
- * generated `ctx.queues.&lt;name>` producer as `QueueProducer&lt;Body>` from
104
- * `typeof &lt;export>`. Never assigned at runtime (type-only).
195
+ * generated `ctx.queues.<name>` producer as `QueueProducer<Body>` from
196
+ * `typeof <export>`. Never assigned at runtime (type-only).
105
197
  */
106
198
  readonly __lunoraBody?: Body;
107
199
  /** Runtime brand identifying a `defineQueue` result. */
108
200
  isLunoraQueue: true;
109
201
  }
202
+ /**
203
+ * The typed publisher bound to `ctx.topics.<name>`. A publish is one send to
204
+ * **each** subscription's queue, in parallel, so it is not atomic: when one send
205
+ * fails the call rejects, and a retry re-delivers to the subscriptions whose send
206
+ * already succeeded. Delivery is at-least-once per subscription — make handlers
207
+ * idempotent. A topic with no subscriptions publishes to nobody.
208
+ */
209
+ interface TopicPublisher<Payload = unknown> {
210
+ /** Publish one message to every subscription. */
211
+ publish: (payload: Payload, options?: QueueSendOptions) => Promise<void>;
212
+ /** Publish a batch (at most 100) to every subscription in one send per subscription. */
213
+ publishBatch: (messages: Iterable<MessageSendRequestLike<Payload>>, options?: QueueSendBatchOptions) => Promise<void>;
214
+ }
215
+ /** `ctx.topics` — declared topic export names → typed publishers, keyed like {@link Queues}. */
216
+ type Topics<Name extends string = string> = Readonly<Record<Name, TopicPublisher>>;
217
+ /** The branded result of `defineTopic`, discovered by codegen. Carries no deploy config of its own. */
218
+ interface TopicDefinition<Payload = unknown> {
219
+ /**
220
+ * Phantom carrier for the payload type, so codegen can type `ctx.topics.<name>`.
221
+ * Named like {@link QueueDefinition}'s so one emitted helper reads both. Never
222
+ * assigned at runtime.
223
+ */
224
+ readonly __lunoraBody?: Payload;
225
+ /** Runtime brand identifying a `defineTopic` result. */
226
+ isLunoraTopic: true;
227
+ }
228
+ /** The config passed to `defineSubscription`: a push consumer's handler plus its own retry/DLQ tuning. */
229
+ interface SubscriptionConfig<Payload = unknown> extends QueueConsumerTuning {
230
+ handler: QueueHandler<Payload>;
231
+ /**
232
+ * Stable wrangler queue name for this subscription. Defaults to the
233
+ * kebab-cased export name, exactly like a `defineQueue` export.
234
+ */
235
+ name?: string;
236
+ }
237
+ /**
238
+ * The branded result of `defineSubscription`: a push {@link QueueDefinition}
239
+ * (so dispatch, retries, the DLQ, wrangler reconcile and the studio treat it as
240
+ * the queue it is) tagged with the topic it consumes.
241
+ */
242
+ interface SubscriptionDefinition<Payload = unknown> extends QueueDefinition<Payload> {
243
+ handler: QueueHandler<Payload>;
244
+ mode: "push";
245
+ /** The topic this subscription consumes. */
246
+ topic: TopicDefinition<Payload>;
247
+ }
248
+ /** Wiring info for one declared topic, emitted by codegen into the generated shard. */
249
+ interface TopicBindingSpec<Name extends string = string> {
250
+ /** The `lunora/queues.ts` topic export name, e.g. `signups`. */
251
+ exportName: Name;
252
+ /** One entry per subscription: its export name and its `Queue` producer binding. */
253
+ subscriptions: ReadonlyArray<{
254
+ binding: string;
255
+ exportName: string;
256
+ }>;
257
+ }
110
258
  /** Wiring info for one declared queue, emitted by codegen into the generated shard/handler. */
111
- interface QueueBindingSpec {
259
+ interface QueueBindingSpec<Name extends string = string> {
112
260
  /** The Cloudflare `Queue` producer binding name, e.g. `QUEUE_EMAIL`. */
113
261
  binding: string;
114
262
  /** The `lunora/queues.ts` export name, e.g. `emailQueue`. */
115
- exportName: string;
263
+ exportName: Name;
116
264
  /** The stable wrangler queue name, e.g. `email-queue`. */
117
265
  name: string;
118
266
  }
119
267
  /** One declared queue, keyed for batch routing by its stable wrangler name. */
120
268
  interface QueueRegistryEntry {
269
+ /**
270
+ * The queue's own producer binding on `env` (`QUEUE_*`). A message whose
271
+ * dispatch is declined on its last delivery is re-enqueued through it (see
272
+ * {@link resolveDeclinedBatch}); without it that message is dead-lettered or
273
+ * dropped, and logged. Codegen always emits it.
274
+ */
275
+ binding?: string;
121
276
  /**
122
277
  * The `defineQueue` result (carries the push handler). The body type is
123
278
  * erased to `any` here because the registry is heterogeneous — different
124
279
  * queues carry different message bodies, and the handler param is
125
- * contravariant, so a precise `QueueDefinition&lt;Body>` would not be assignable
280
+ * contravariant, so a precise `QueueDefinition<Body>` would not be assignable
126
281
  * to a shared `unknown`-bodied slot. Runtime dispatch passes the delivered
127
282
  * batch straight through, so the erasure is type-only.
128
283
  */
@@ -145,7 +300,28 @@ interface CapturedQueueMessage {
145
300
  attempts: number;
146
301
  /** The message body (JSON-encoded + capped by the catcher). */
147
302
  body: unknown;
148
- /** `true` when this failed delivery was the message's last (its retries are exhausted — the broker dead-letters it). */
303
+ /**
304
+ * `true` when this failed delivery was the message's last (its retries are
305
+ * exhausted) AND the queue declares a `deadLetterQueue` for it to land in.
306
+ * Stays `false` for a queue with no DLQ, where the broker drops the
307
+ * exhausted message instead — `attempts > maxRetries` with
308
+ * `outcome !== "ack"` is what identifies that case.
309
+ *
310
+ * Read from the `defineQueue` DECLARATION, because a consumer has no
311
+ * runtime API that exposes its deployed `queues.consumers[]` settings. It is
312
+ * true of the deployed broker only as far as Lunora's binding reconcile (run
313
+ * by `lunora dev`, `deploy` and `prepare`) keeps the two in step: it writes
314
+ * every declared tuning field onto the consumer, including onto one that
315
+ * already exists, and takes a field back out once `defineQueue` drops it
316
+ * (it records what `defineQueue` declared in `package.json`
317
+ * `lunora.queueTuning`). The gaps that remain: a field changed by hand to
318
+ * another value than the declared one is
319
+ * kept when the declaration drops it, so a hand-set DLQ still reads
320
+ * `false` here; a `--env` deploy retunes the `env.<name>` consumers but
321
+ * never writes their `dead_letter_queue`, which that block names itself;
322
+ * and a bare `wrangler deploy` over a hand-edited consumer can disagree
323
+ * too. Nothing on this side can see any of it.
324
+ */
149
325
  deadLettered: boolean;
150
326
  /** Handler error message when `outcome` is `error`; absent otherwise. */
151
327
  error?: string;
@@ -169,22 +345,35 @@ interface CapturedQueueMessage {
169
345
  type QueueCaptureSink = (messages: CapturedQueueMessage[]) => Promise<void> | void;
170
346
  interface DispatchOptions {
171
347
  /**
172
- * Optional capture sink. When set, the batch is instrumented and every
173
- * message's final disposition is recorded and handed to this sink after the
174
- * handler runs. Omitted in production unless queue capture is enabled, so a
175
- * consumer pays no instrumentation cost by default.
348
+ * Optional capture sink. When set, every message's final disposition is
349
+ * turned into a record and handed to this sink after the handler runs.
350
+ * Omitted in production unless queue capture is enabled, so a consumer pays
351
+ * no record-building or sink cost by default. Delivery semantics — including
352
+ * poison-message isolation — do not depend on it.
176
353
  */
177
354
  capture?: QueueCaptureSink;
178
355
  /** Worker `env`, forwarded to the queue run context. */
179
356
  env: Record<string, unknown>;
180
357
  /** Injectable fetch for the `ctx.run` dispatcher (tests). */
181
358
  fetchImpl?: typeof fetch;
359
+ /** The runtime's shard-forwarding live publisher, handed to the handler as `ctx.live`. */
360
+ live?: QueueLivePublisher;
361
+ /**
362
+ * W3C `traceparent` of the consumer invocation's own trace, supplied by the
363
+ * runtime's `queue()` entry. Forwarded on every `ctx.run` dispatch so the
364
+ * functions a handler calls are CHILDREN of the queue span instead of a set of
365
+ * unrelated root traces. Absent (a hand-built dispatch, a unit test) keeps the
366
+ * prior behaviour: the callee mints its own trace.
367
+ */
368
+ traceparent?: string;
182
369
  }
183
370
  /**
184
371
  * Look up the handler for `batch.queue` and invoke it with a fresh
185
372
  * `QueueRunContext`. Throws a directed error when no push handler is registered
186
373
  * for the delivered queue (a misconfiguration — the consumer was declared
187
- * `pull`, or the queue name drifted from the `defineQueue` export).
374
+ * `pull`, or the queue name drifted from the `defineQueue` export). The
375
+ * registry already carries every per-environment name wrangler.jsonc maps to a
376
+ * declared producer binding (see codegen's `emitQueues`), so the lookup stays exact.
188
377
  */
189
378
  declare const dispatchQueueBatch: (batch: MessageBatchLike, registry: QueueRegistry, options: DispatchOptions) => Promise<void>;
190
379
  /** A Worker `env` projected as a plain record (vars, secrets, and bindings are `unknown`-valued). */
@@ -216,21 +405,21 @@ declare const createQueueCaptureSink: (env: QueueEnv, options?: QueueCaptureOpti
216
405
  * Build the `ctx.queues` map for a request: resolve every spec's `env[binding]`
217
406
  * into the `exportName → Queue binding` map and wrap it in {@link createQueues}.
218
407
  * A spec whose binding is absent from `env` is skipped here — the helpful "no
219
- * queue named …" error is raised lazily by `ctx.queues.&lt;name>.send(...)` when
408
+ * queue named …" error is raised lazily by `ctx.queues.<name>.send(...)` when
220
409
  * the missing queue is actually used.
221
410
  */
222
- declare const createQueueContext: (env: Record<string, unknown>, specs: ReadonlyArray<QueueBindingSpec>) => Queues;
411
+ declare const createQueueContext: <Name extends string>(env: Record<string, unknown>, specs: ReadonlyArray<QueueBindingSpec<Name>>) => Queues<Name>;
223
412
  /**
224
413
  * Build the `ctx.queues` map from `lunora/queues.ts` export name → Cloudflare
225
414
  * `Queue` binding. Each property is a typed {@link QueueProducer}; accessing an
226
415
  * export whose binding is absent throws a directed error naming the declared
227
416
  * queues (raised lazily on first use).
228
417
  */
229
- declare const createQueues: (options: LunoraQueuesOptions) => Queues;
418
+ declare const createQueues: <Name extends string>(options: LunoraQueuesOptions<Name>) => Queues<Name>;
230
419
  /**
231
420
  * The wrangler producer binding name for a queue export: `emailQueue` →
232
421
  * `QUEUE_EMAIL_QUEUE`, `email` → `QUEUE_EMAIL`. The `QUEUE_` prefix namespaces
233
- * these away from `SHARD`/`SESSION`/`SCHEDULER`/`WORKFLOW_*`/`CONTAINER_*` so a
422
+ * these away from `SHARD`/`SESSION`/`SCHEDULER`/`CONTAINER_*` so a
234
423
  * queue export can never collide with the built-in bindings.
235
424
  */
236
425
  declare const queueBindingName: (exportName: string) => string;
@@ -242,7 +431,7 @@ declare const queueBindingName: (exportName: string) => string;
242
431
  declare const queueDefaultName: (exportName: string) => string;
243
432
  /**
244
433
  * Declare a Cloudflare Queue deployed alongside the app. Pure validation +
245
- * branding: codegen discovers the export, emits the typed `ctx.queues.&lt;name>`
434
+ * branding: codegen discovers the export, emits the typed `ctx.queues.<name>`
246
435
  * producer and (for push consumers) the worker `queue()` dispatch; the config
247
436
  * layer reconciles the wrangler `queues.producers[]` / `queues.consumers[]`
248
437
  * entries from the same definition.
@@ -252,10 +441,10 @@ declare const queueDefaultName: (exportName: string) => string;
252
441
  * import { defineQueue } from "@lunora/queue";
253
442
  * import { api } from "./_generated/api";
254
443
  *
255
- * export const emailQueue = defineQueue&lt;{ to: string }>({
444
+ * export const emailQueue = defineQueue<{ to: string }>({
256
445
  * handler: async (ctx, batch) => {
257
446
  * for (const message of batch.messages) {
258
- * await ctx.run(api.email.send, { to: message.body.to });
447
+ * await message.run(api.email.send, { to: message.body.to });
259
448
  * message.ack();
260
449
  * }
261
450
  * },
@@ -264,6 +453,12 @@ declare const queueDefaultName: (exportName: string) => string;
264
453
  *
265
454
  * Enqueue from a mutation or action: `await ctx.queues.emailQueue.send({ to })`.
266
455
  *
456
+ * `message.run(...)` is `ctx.run(...)` pinned to that message — prefer it inside
457
+ * the batch loop. A deterministic failure (400/403/404/422) from a pinned call
458
+ * is attributed to its message: that one is acked and the rest are retried,
459
+ * instead of one poison message re-delivering (and eventually dead-lettering)
460
+ * the whole batch.
461
+ *
267
462
  * ⚠️ **Privileged dispatch.** A push handler's `ctx.run(...)` calls back into
268
463
  * Lunora functions over the admin-authenticated dispatch endpoint (the same
269
464
  * trusted path the scheduler and workflows use), so those calls run with the
@@ -279,7 +474,31 @@ interface RunContextOptions {
279
474
  env: Record<string, unknown>;
280
475
  exportName: string;
281
476
  fetchImpl?: typeof fetch;
477
+ /** The runtime's shard-forwarding publisher, surfaced as `ctx.live`. Omitted → `ctx.live` is absent. */
478
+ live?: QueueLivePublisher;
479
+ /** The consumer invocation's `traceparent`, so `ctx.run` joins the queue's trace. */
480
+ traceparent?: string;
282
481
  }
283
482
  /** Assemble the {@link QueueRunContext} passed to a `defineQueue` handler. */
284
483
  declare const createQueueRunContext: (options: RunContextOptions) => QueueRunContext;
285
- 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 QueueProducer, type QueueRegistry, type QueueRegistryEntry, type QueueRunContext, type DispatchRunFunction as QueueRunFunction, type Queues, type RunFunctionOptions, createQueueCaptureSink, createQueueContext, createQueueRunContext, createQueues, defineQueue, dispatchQueueBatch, isQueueDefinition, queueBindingName, queueDefaultName, shouldCaptureQueue };
484
+ /**
485
+ * Declare a topic in `lunora/queues.ts`. The payload type is the only thing a
486
+ * topic carries; delivery config lives on each subscription.
487
+ */
488
+ declare const defineTopic: <Payload = unknown>() => TopicDefinition<Payload>;
489
+ /**
490
+ * Subscribe to a topic. The result is a push queue definition — export it from
491
+ * `lunora/queues.ts` and it deploys, retries and dead-letters exactly like a
492
+ * `defineQueue` export of the same name.
493
+ *
494
+ * ⚠️ Same trust model as a queue handler: `message.run(...)` dispatches with the
495
+ * system identity, so validate `message.body` before acting on it.
496
+ */
497
+ declare const defineSubscription: <Payload>(topic: TopicDefinition<Payload>, config: SubscriptionConfig<Payload>) => SubscriptionDefinition<Payload>;
498
+ /**
499
+ * Build `ctx.topics` for a request from the Worker `env` and the codegen-emitted
500
+ * specs. A topic with no subscriptions publishes to nobody; a topic with an
501
+ * unbound subscription rejects every publish (see {@link unboundPublisher}).
502
+ */
503
+ declare const createTopicContext: <Name extends string>(env: Record<string, unknown>, specs: ReadonlyArray<TopicBindingSpec<Name>>) => Topics<Name>;
504
+ 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 QueueLivePublisher, 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,4 +1,4 @@
1
- import { QueueBindingLike, MessageBatchLike, QueueSendOptions, MessageSendRequestLike, QueueSendBatchOptions } from '@lunora/platform';
1
+ import { QueueBindingLike, MessageBatchLike, MessageLike, QueueSendOptions, MessageSendRequestLike, QueueSendBatchOptions } from '@lunora/platform';
2
2
  export type { MessageBatchLike, MessageLike, MessageSendRequestLike, QueueBindingLike, QueueContentType, QueueRetryOptions, QueueSendBatchOptions, QueueSendOptions } from '@lunora/platform';
3
3
  /**
4
4
  * Shared types for dispatching a Lunora function back into the worker from a
@@ -14,8 +14,45 @@ interface FunctionReference {
14
14
  type ArgsOf<F> = F extends FunctionReference ? Record<string, unknown> : never;
15
15
  /** Options for a function call made via a dispatch runner. */
16
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;
17
52
  /** Route the call to a specific shard (defaults to the worker's root shard). */
18
53
  shardKey?: string;
54
+ /** Abort the dispatch after this many ms; the abort is retryable. Overrides the runner's default. */
55
+ timeoutMs?: number;
19
56
  }
20
57
  /** Invoke a Lunora function (query/mutation/action) by reference. The shape of `ctx.run`. */
21
58
  type DispatchRunFunction = <F extends FunctionReference>(function_: F, args?: ArgsOf<F>, options?: RunFunctionOptions) => Promise<unknown>;
@@ -27,7 +64,7 @@ interface DispatchLogger {
27
64
  warn: (message: unknown, ...rest: unknown[]) => void;
28
65
  }
29
66
  /**
30
- * 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
31
68
  * the generated context exposes this only on `MutationCtx` / `ActionCtx` (never
32
69
  * the deterministic `QueryCtx`), mirroring `ctx.scheduler` / `ctx.workflows`.
33
70
  */
@@ -39,16 +76,16 @@ interface QueueProducer<Body = unknown> {
39
76
  }
40
77
  /**
41
78
  * `ctx.queues` — the map of declared queue export names → typed producers.
42
- * Codegen narrows this to the exact export names; the package keeps it open so
43
- * `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.
44
83
  */
45
- interface Queues {
46
- [exportName: string]: QueueProducer;
47
- }
84
+ type Queues<Name extends string = string> = Readonly<Record<Name, QueueProducer>>;
48
85
  /** Options the package-level `createQueues` factory takes. */
49
- interface LunoraQueuesOptions {
86
+ interface LunoraQueuesOptions<Name extends string = string> {
50
87
  /** Map of `lunora/queues.ts` export name → Cloudflare `Queue` producer binding. */
51
- bindings: Record<string, QueueBindingLike>;
88
+ bindings: Record<Name, QueueBindingLike>;
52
89
  }
53
90
  /**
54
91
  * The context handed to a `defineQueue` handler. Decoupled from `@lunora/server`
@@ -59,15 +96,68 @@ interface LunoraQueuesOptions {
59
96
  interface QueueRunContext {
60
97
  /** The worker `env` (bindings + vars). */
61
98
  readonly env: Record<string, unknown>;
99
+ /**
100
+ * `ctx.live.publish(ref, payload)` — push an event to the SSE subscribers of the
101
+ * `defineLiveStream` that declares `ref`'s topic, on the shard the publish names. Present only when the
102
+ * runtime hands the consumer a publisher (an app that declares a live stream on
103
+ * a target that serves one); absent otherwise. A consumer runs in the Worker, so
104
+ * the publish is forwarded to the shard rather than made in-process.
105
+ */
106
+ readonly live?: QueueLivePublisher;
62
107
  /** Queue-name-prefixed logger. */
63
108
  readonly log: DispatchLogger;
64
- /** Invoke a Lunora function (query/mutation/action) by reference. */
109
+ /**
110
+ * Invoke a Lunora function (query/mutation/action) by reference.
111
+ *
112
+ * Batch-unaware: a failure it throws is attributed to nothing, so a
113
+ * deterministic failure retries the whole batch. Inside the `batch.messages`
114
+ * loop, prefer {@link QueueMessage.run}, which pins the call to its message.
115
+ */
65
116
  readonly run: DispatchRunFunction;
66
117
  }
118
+ /**
119
+ * The publisher a queue consumer's `ctx.live` is. Structural, so the runtime
120
+ * builds it without importing this package. See {@link QueueRunContext.live}.
121
+ */
122
+ interface QueueLivePublisher {
123
+ publish: <Payload>(topic: QueueLiveTopicRef<Payload>, payload: NoInfer<Payload>, options?: {
124
+ shardKey?: string;
125
+ }) => Promise<void>;
126
+ }
127
+ /**
128
+ * A reference to one declared live topic, as `_generated/live.ts` hands it out.
129
+ * Structural, so this package needs neither the server nor the client: the phantom
130
+ * `Payload` is what types the event.
131
+ */
132
+ interface QueueLiveTopicRef<Payload = unknown> {
133
+ readonly __lunoraLive: string;
134
+ readonly __payload?: Payload;
135
+ readonly topic: string;
136
+ }
137
+ /**
138
+ * One delivered message as the push handler sees it: the Cloudflare `Message`
139
+ * plus `run` — a {@link QueueRunContext.run} pinned to THIS message.
140
+ *
141
+ * Prefer `message.run(api.x.y, args)` over `ctx.run(...)` inside the batch
142
+ * loop. The pin is what lets the dispatcher attribute a deterministic dispatch
143
+ * failure (400/403/404/422) to the one message that caused it: that message is
144
+ * acked and every other one is retried, instead of the whole batch being
145
+ * re-delivered because of a single poison message. A plain `ctx.run` call
146
+ * carries no message id, so its failure stays unattributed and the whole batch
147
+ * retries.
148
+ */
149
+ interface QueueMessage<Body = unknown> extends MessageLike<Body> {
150
+ /** {@link QueueRunContext.run}, pinned to this message for failure attribution. */
151
+ readonly run: DispatchRunFunction;
152
+ }
153
+ /** The delivered batch as the push handler sees it — {@link QueueMessage}s rather than bare `Message`s. */
154
+ interface QueueMessageBatch<Body = unknown> extends Omit<MessageBatchLike<Body>, "messages"> {
155
+ readonly messages: ReadonlyArray<QueueMessage<Body>>;
156
+ }
67
157
  /** Whether a declared queue is consumed by this worker (push) or polled externally (pull). */
68
158
  type QueueConsumerMode = "pull" | "push";
69
159
  /** The handler body run for each delivered batch (push consumers only). */
70
- type QueueHandler<Body = unknown> = (context: QueueRunContext, batch: MessageBatchLike<Body>) => Promise<void> | void;
160
+ type QueueHandler<Body = unknown> = (context: QueueRunContext, batch: QueueMessageBatch<Body>) => Promise<void> | void;
71
161
  /** Push-consumer batch/retry tuning, mirrored onto the wrangler `queues.consumers[]` entry. */
72
162
  interface QueueConsumerTuning {
73
163
  /** Name of the dead-letter queue messages land in after `maxRetries`. */
@@ -76,7 +166,9 @@ interface QueueConsumerTuning {
76
166
  maxBatchSize?: number;
77
167
  /** Max seconds to wait before delivering a partial batch (0–60, default 5). */
78
168
  maxBatchTimeout?: number;
79
- /** Max delivery attempts before a message is dropped / dead-lettered (default 3). */
169
+ /** Max concurrent consumer invocations (1–250). Unset lets Cloudflare autoscale. Not emulated by Miniflare in dev. */
170
+ maxConcurrency?: number;
171
+ /** Retries **after** the initial delivery, before a message is dropped / dead-lettered. Default 3, so up to 4 deliveries in total. */
80
172
  maxRetries?: number;
81
173
  /** Delay in seconds before a failed batch is retried. */
82
174
  retryDelay?: number;
@@ -100,29 +192,92 @@ interface QueueConfig<Body = unknown> extends QueueConsumerTuning {
100
192
  interface QueueDefinition<Body = unknown> extends QueueConfig<Body> {
101
193
  /**
102
194
  * Phantom carrier for the message body type, so codegen can type the
103
- * generated `ctx.queues.&lt;name>` producer as `QueueProducer&lt;Body>` from
104
- * `typeof &lt;export>`. Never assigned at runtime (type-only).
195
+ * generated `ctx.queues.<name>` producer as `QueueProducer<Body>` from
196
+ * `typeof <export>`. Never assigned at runtime (type-only).
105
197
  */
106
198
  readonly __lunoraBody?: Body;
107
199
  /** Runtime brand identifying a `defineQueue` result. */
108
200
  isLunoraQueue: true;
109
201
  }
202
+ /**
203
+ * The typed publisher bound to `ctx.topics.<name>`. A publish is one send to
204
+ * **each** subscription's queue, in parallel, so it is not atomic: when one send
205
+ * fails the call rejects, and a retry re-delivers to the subscriptions whose send
206
+ * already succeeded. Delivery is at-least-once per subscription — make handlers
207
+ * idempotent. A topic with no subscriptions publishes to nobody.
208
+ */
209
+ interface TopicPublisher<Payload = unknown> {
210
+ /** Publish one message to every subscription. */
211
+ publish: (payload: Payload, options?: QueueSendOptions) => Promise<void>;
212
+ /** Publish a batch (at most 100) to every subscription in one send per subscription. */
213
+ publishBatch: (messages: Iterable<MessageSendRequestLike<Payload>>, options?: QueueSendBatchOptions) => Promise<void>;
214
+ }
215
+ /** `ctx.topics` — declared topic export names → typed publishers, keyed like {@link Queues}. */
216
+ type Topics<Name extends string = string> = Readonly<Record<Name, TopicPublisher>>;
217
+ /** The branded result of `defineTopic`, discovered by codegen. Carries no deploy config of its own. */
218
+ interface TopicDefinition<Payload = unknown> {
219
+ /**
220
+ * Phantom carrier for the payload type, so codegen can type `ctx.topics.<name>`.
221
+ * Named like {@link QueueDefinition}'s so one emitted helper reads both. Never
222
+ * assigned at runtime.
223
+ */
224
+ readonly __lunoraBody?: Payload;
225
+ /** Runtime brand identifying a `defineTopic` result. */
226
+ isLunoraTopic: true;
227
+ }
228
+ /** The config passed to `defineSubscription`: a push consumer's handler plus its own retry/DLQ tuning. */
229
+ interface SubscriptionConfig<Payload = unknown> extends QueueConsumerTuning {
230
+ handler: QueueHandler<Payload>;
231
+ /**
232
+ * Stable wrangler queue name for this subscription. Defaults to the
233
+ * kebab-cased export name, exactly like a `defineQueue` export.
234
+ */
235
+ name?: string;
236
+ }
237
+ /**
238
+ * The branded result of `defineSubscription`: a push {@link QueueDefinition}
239
+ * (so dispatch, retries, the DLQ, wrangler reconcile and the studio treat it as
240
+ * the queue it is) tagged with the topic it consumes.
241
+ */
242
+ interface SubscriptionDefinition<Payload = unknown> extends QueueDefinition<Payload> {
243
+ handler: QueueHandler<Payload>;
244
+ mode: "push";
245
+ /** The topic this subscription consumes. */
246
+ topic: TopicDefinition<Payload>;
247
+ }
248
+ /** Wiring info for one declared topic, emitted by codegen into the generated shard. */
249
+ interface TopicBindingSpec<Name extends string = string> {
250
+ /** The `lunora/queues.ts` topic export name, e.g. `signups`. */
251
+ exportName: Name;
252
+ /** One entry per subscription: its export name and its `Queue` producer binding. */
253
+ subscriptions: ReadonlyArray<{
254
+ binding: string;
255
+ exportName: string;
256
+ }>;
257
+ }
110
258
  /** Wiring info for one declared queue, emitted by codegen into the generated shard/handler. */
111
- interface QueueBindingSpec {
259
+ interface QueueBindingSpec<Name extends string = string> {
112
260
  /** The Cloudflare `Queue` producer binding name, e.g. `QUEUE_EMAIL`. */
113
261
  binding: string;
114
262
  /** The `lunora/queues.ts` export name, e.g. `emailQueue`. */
115
- exportName: string;
263
+ exportName: Name;
116
264
  /** The stable wrangler queue name, e.g. `email-queue`. */
117
265
  name: string;
118
266
  }
119
267
  /** One declared queue, keyed for batch routing by its stable wrangler name. */
120
268
  interface QueueRegistryEntry {
269
+ /**
270
+ * The queue's own producer binding on `env` (`QUEUE_*`). A message whose
271
+ * dispatch is declined on its last delivery is re-enqueued through it (see
272
+ * {@link resolveDeclinedBatch}); without it that message is dead-lettered or
273
+ * dropped, and logged. Codegen always emits it.
274
+ */
275
+ binding?: string;
121
276
  /**
122
277
  * The `defineQueue` result (carries the push handler). The body type is
123
278
  * erased to `any` here because the registry is heterogeneous — different
124
279
  * queues carry different message bodies, and the handler param is
125
- * contravariant, so a precise `QueueDefinition&lt;Body>` would not be assignable
280
+ * contravariant, so a precise `QueueDefinition<Body>` would not be assignable
126
281
  * to a shared `unknown`-bodied slot. Runtime dispatch passes the delivered
127
282
  * batch straight through, so the erasure is type-only.
128
283
  */
@@ -145,7 +300,28 @@ interface CapturedQueueMessage {
145
300
  attempts: number;
146
301
  /** The message body (JSON-encoded + capped by the catcher). */
147
302
  body: unknown;
148
- /** `true` when this failed delivery was the message's last (its retries are exhausted — the broker dead-letters it). */
303
+ /**
304
+ * `true` when this failed delivery was the message's last (its retries are
305
+ * exhausted) AND the queue declares a `deadLetterQueue` for it to land in.
306
+ * Stays `false` for a queue with no DLQ, where the broker drops the
307
+ * exhausted message instead — `attempts > maxRetries` with
308
+ * `outcome !== "ack"` is what identifies that case.
309
+ *
310
+ * Read from the `defineQueue` DECLARATION, because a consumer has no
311
+ * runtime API that exposes its deployed `queues.consumers[]` settings. It is
312
+ * true of the deployed broker only as far as Lunora's binding reconcile (run
313
+ * by `lunora dev`, `deploy` and `prepare`) keeps the two in step: it writes
314
+ * every declared tuning field onto the consumer, including onto one that
315
+ * already exists, and takes a field back out once `defineQueue` drops it
316
+ * (it records what `defineQueue` declared in `package.json`
317
+ * `lunora.queueTuning`). The gaps that remain: a field changed by hand to
318
+ * another value than the declared one is
319
+ * kept when the declaration drops it, so a hand-set DLQ still reads
320
+ * `false` here; a `--env` deploy retunes the `env.<name>` consumers but
321
+ * never writes their `dead_letter_queue`, which that block names itself;
322
+ * and a bare `wrangler deploy` over a hand-edited consumer can disagree
323
+ * too. Nothing on this side can see any of it.
324
+ */
149
325
  deadLettered: boolean;
150
326
  /** Handler error message when `outcome` is `error`; absent otherwise. */
151
327
  error?: string;
@@ -169,22 +345,35 @@ interface CapturedQueueMessage {
169
345
  type QueueCaptureSink = (messages: CapturedQueueMessage[]) => Promise<void> | void;
170
346
  interface DispatchOptions {
171
347
  /**
172
- * Optional capture sink. When set, the batch is instrumented and every
173
- * message's final disposition is recorded and handed to this sink after the
174
- * handler runs. Omitted in production unless queue capture is enabled, so a
175
- * consumer pays no instrumentation cost by default.
348
+ * Optional capture sink. When set, every message's final disposition is
349
+ * turned into a record and handed to this sink after the handler runs.
350
+ * Omitted in production unless queue capture is enabled, so a consumer pays
351
+ * no record-building or sink cost by default. Delivery semantics — including
352
+ * poison-message isolation — do not depend on it.
176
353
  */
177
354
  capture?: QueueCaptureSink;
178
355
  /** Worker `env`, forwarded to the queue run context. */
179
356
  env: Record<string, unknown>;
180
357
  /** Injectable fetch for the `ctx.run` dispatcher (tests). */
181
358
  fetchImpl?: typeof fetch;
359
+ /** The runtime's shard-forwarding live publisher, handed to the handler as `ctx.live`. */
360
+ live?: QueueLivePublisher;
361
+ /**
362
+ * W3C `traceparent` of the consumer invocation's own trace, supplied by the
363
+ * runtime's `queue()` entry. Forwarded on every `ctx.run` dispatch so the
364
+ * functions a handler calls are CHILDREN of the queue span instead of a set of
365
+ * unrelated root traces. Absent (a hand-built dispatch, a unit test) keeps the
366
+ * prior behaviour: the callee mints its own trace.
367
+ */
368
+ traceparent?: string;
182
369
  }
183
370
  /**
184
371
  * Look up the handler for `batch.queue` and invoke it with a fresh
185
372
  * `QueueRunContext`. Throws a directed error when no push handler is registered
186
373
  * for the delivered queue (a misconfiguration — the consumer was declared
187
- * `pull`, or the queue name drifted from the `defineQueue` export).
374
+ * `pull`, or the queue name drifted from the `defineQueue` export). The
375
+ * registry already carries every per-environment name wrangler.jsonc maps to a
376
+ * declared producer binding (see codegen's `emitQueues`), so the lookup stays exact.
188
377
  */
189
378
  declare const dispatchQueueBatch: (batch: MessageBatchLike, registry: QueueRegistry, options: DispatchOptions) => Promise<void>;
190
379
  /** A Worker `env` projected as a plain record (vars, secrets, and bindings are `unknown`-valued). */
@@ -216,21 +405,21 @@ declare const createQueueCaptureSink: (env: QueueEnv, options?: QueueCaptureOpti
216
405
  * Build the `ctx.queues` map for a request: resolve every spec's `env[binding]`
217
406
  * into the `exportName → Queue binding` map and wrap it in {@link createQueues}.
218
407
  * A spec whose binding is absent from `env` is skipped here — the helpful "no
219
- * queue named …" error is raised lazily by `ctx.queues.&lt;name>.send(...)` when
408
+ * queue named …" error is raised lazily by `ctx.queues.<name>.send(...)` when
220
409
  * the missing queue is actually used.
221
410
  */
222
- declare const createQueueContext: (env: Record<string, unknown>, specs: ReadonlyArray<QueueBindingSpec>) => Queues;
411
+ declare const createQueueContext: <Name extends string>(env: Record<string, unknown>, specs: ReadonlyArray<QueueBindingSpec<Name>>) => Queues<Name>;
223
412
  /**
224
413
  * Build the `ctx.queues` map from `lunora/queues.ts` export name → Cloudflare
225
414
  * `Queue` binding. Each property is a typed {@link QueueProducer}; accessing an
226
415
  * export whose binding is absent throws a directed error naming the declared
227
416
  * queues (raised lazily on first use).
228
417
  */
229
- declare const createQueues: (options: LunoraQueuesOptions) => Queues;
418
+ declare const createQueues: <Name extends string>(options: LunoraQueuesOptions<Name>) => Queues<Name>;
230
419
  /**
231
420
  * The wrangler producer binding name for a queue export: `emailQueue` →
232
421
  * `QUEUE_EMAIL_QUEUE`, `email` → `QUEUE_EMAIL`. The `QUEUE_` prefix namespaces
233
- * these away from `SHARD`/`SESSION`/`SCHEDULER`/`WORKFLOW_*`/`CONTAINER_*` so a
422
+ * these away from `SHARD`/`SESSION`/`SCHEDULER`/`CONTAINER_*` so a
234
423
  * queue export can never collide with the built-in bindings.
235
424
  */
236
425
  declare const queueBindingName: (exportName: string) => string;
@@ -242,7 +431,7 @@ declare const queueBindingName: (exportName: string) => string;
242
431
  declare const queueDefaultName: (exportName: string) => string;
243
432
  /**
244
433
  * Declare a Cloudflare Queue deployed alongside the app. Pure validation +
245
- * branding: codegen discovers the export, emits the typed `ctx.queues.&lt;name>`
434
+ * branding: codegen discovers the export, emits the typed `ctx.queues.<name>`
246
435
  * producer and (for push consumers) the worker `queue()` dispatch; the config
247
436
  * layer reconciles the wrangler `queues.producers[]` / `queues.consumers[]`
248
437
  * entries from the same definition.
@@ -252,10 +441,10 @@ declare const queueDefaultName: (exportName: string) => string;
252
441
  * import { defineQueue } from "@lunora/queue";
253
442
  * import { api } from "./_generated/api";
254
443
  *
255
- * export const emailQueue = defineQueue&lt;{ to: string }>({
444
+ * export const emailQueue = defineQueue<{ to: string }>({
256
445
  * handler: async (ctx, batch) => {
257
446
  * for (const message of batch.messages) {
258
- * await ctx.run(api.email.send, { to: message.body.to });
447
+ * await message.run(api.email.send, { to: message.body.to });
259
448
  * message.ack();
260
449
  * }
261
450
  * },
@@ -264,6 +453,12 @@ declare const queueDefaultName: (exportName: string) => string;
264
453
  *
265
454
  * Enqueue from a mutation or action: `await ctx.queues.emailQueue.send({ to })`.
266
455
  *
456
+ * `message.run(...)` is `ctx.run(...)` pinned to that message — prefer it inside
457
+ * the batch loop. A deterministic failure (400/403/404/422) from a pinned call
458
+ * is attributed to its message: that one is acked and the rest are retried,
459
+ * instead of one poison message re-delivering (and eventually dead-lettering)
460
+ * the whole batch.
461
+ *
267
462
  * ⚠️ **Privileged dispatch.** A push handler's `ctx.run(...)` calls back into
268
463
  * Lunora functions over the admin-authenticated dispatch endpoint (the same
269
464
  * trusted path the scheduler and workflows use), so those calls run with the
@@ -279,7 +474,31 @@ interface RunContextOptions {
279
474
  env: Record<string, unknown>;
280
475
  exportName: string;
281
476
  fetchImpl?: typeof fetch;
477
+ /** The runtime's shard-forwarding publisher, surfaced as `ctx.live`. Omitted → `ctx.live` is absent. */
478
+ live?: QueueLivePublisher;
479
+ /** The consumer invocation's `traceparent`, so `ctx.run` joins the queue's trace. */
480
+ traceparent?: string;
282
481
  }
283
482
  /** Assemble the {@link QueueRunContext} passed to a `defineQueue` handler. */
284
483
  declare const createQueueRunContext: (options: RunContextOptions) => QueueRunContext;
285
- 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 QueueProducer, type QueueRegistry, type QueueRegistryEntry, type QueueRunContext, type DispatchRunFunction as QueueRunFunction, type Queues, type RunFunctionOptions, createQueueCaptureSink, createQueueContext, createQueueRunContext, createQueues, defineQueue, dispatchQueueBatch, isQueueDefinition, queueBindingName, queueDefaultName, shouldCaptureQueue };
484
+ /**
485
+ * Declare a topic in `lunora/queues.ts`. The payload type is the only thing a
486
+ * topic carries; delivery config lives on each subscription.
487
+ */
488
+ declare const defineTopic: <Payload = unknown>() => TopicDefinition<Payload>;
489
+ /**
490
+ * Subscribe to a topic. The result is a push queue definition — export it from
491
+ * `lunora/queues.ts` and it deploys, retries and dead-letters exactly like a
492
+ * `defineQueue` export of the same name.
493
+ *
494
+ * ⚠️ Same trust model as a queue handler: `message.run(...)` dispatches with the
495
+ * system identity, so validate `message.body` before acting on it.
496
+ */
497
+ declare const defineSubscription: <Payload>(topic: TopicDefinition<Payload>, config: SubscriptionConfig<Payload>) => SubscriptionDefinition<Payload>;
498
+ /**
499
+ * Build `ctx.topics` for a request from the Worker `env` and the codegen-emitted
500
+ * specs. A topic with no subscriptions publishes to nobody; a topic with an
501
+ * unbound subscription rejects every publish (see {@link unboundPublisher}).
502
+ */
503
+ declare const createTopicContext: <Name extends string>(env: Record<string, unknown>, specs: ReadonlyArray<TopicBindingSpec<Name>>) => Topics<Name>;
504
+ 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 QueueLivePublisher, 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-CPejOWBR.mjs";import{c as C}from"./packem_shared/run-context-D-HWDI09.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-D-HWDI09.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-D-HWDI09.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(i,o,r)=>(n+=1,e(i,o,{...r,dedupId:`${t}#${String(n)}`,messageId:t}))},L=(e,t,n,i)=>{const o=B(i,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"?o:Reflect.get(r,s,r)})},j=(e,t,n)=>new Proxy(e,{get:(i,o)=>o==="ackAll"?()=>{n("ack"),i.ackAll()}:o==="retryAll"?r=>{n("retry"),i.retryAll(r)}:o==="messages"?t:Reflect.get(i,o,i)}),Q=async(e,t,n)=>{const i=new Map,o=e.messages,r=new Map,s=[];for(const c of o){const u=await T(c,n);r.set(c,u),s.push(L(c,u,i,t))}const l=j(e,s,c=>{for(const u of o)i.has(u)||i.set(u,c)});return{dispositions:i,originals:o,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,i,o,r)=>{const s=i?S(o):void 0,f=b(t),l=i?"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 i=q(n);if(i===void 0)return;const o=e.originals.find(r=>v(e,r)===i);if(!(o===void 0||e.dispositions.has(o)))return o},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,i,o)=>{const r=g(o),s=q(t),f=e.originals.find(d=>v(e,d)===s&&!e.dispositions.has(d)),l=`@lunora/queue: queue "${i}" (${n.exportName})`,c=n.binding===void 0?void 0:o[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 i=Object.hasOwn(t,e.queue)?t[e.queue]:void 0;if(i===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:o}=i.definition;if(typeof o!="function")throw new TypeError(`@lunora/queue: queue "${e.queue}" (${i.exportName}) has no push handler — it is declared as a pull consumer`);const r=h({env:n.env,exportName:i.exportName,fetchImpl:n.fetchImpl,...n.traceparent===void 0?{}:{traceparent:n.traceparent},live:n.live}),s=await Q(e,r.run,g(n.env));let f=!1,l;try{await o(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}" (${i.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,i,e.queue,n.env);const d=f&&c===void 0&&!u;if(n.capture!==void 0)try{await n.capture(D(s,i,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 $}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,b="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}},c="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_",I=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+=c.charAt(n>>18&63)+c.charAt(n>>12&63)+c.charAt(n>>6&63)+c.charAt(n&63)}const o=e.length-r;if(o===1){const n=e[r]<<16;t+=c.charAt(n>>18&63)+c.charAt(n>>12&63)}else if(o===2){const n=e[r]<<16|e[r+1]<<8;t+=c.charAt(n>>18&63)+c.charAt(n>>12&63)+c.charAt(n>>6&63)}return t};new TextDecoder;const S=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=>I(S.encode(JSON.stringify(e))),M=e=>!e.startsWith(N)&&J(e)?e:`${N}${I(S.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=>$(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([b,"DISPATCH_UNAUTHENTICATED"]),_=e=>$(e)&&e[T]===!0,te=e=>_(e)&&B.has(e.status)&&!F.has(e.code),re=e=>_(e)&&e[R]===!0,v=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(v),e.ack(),"requeued"}catch(n){o=` Re-enqueueing a delayed copy failed (${n instanceof Error?n.message:String(n)}).`}return e.retry({delaySeconds:v}),a&&console.error(`${t.where}: message ${e.id} was declined (${b}) 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=i=>{throw i instanceof Error&&i.name==="TimeoutError"?X(t,o.__lunoraRef,m):i};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 i;try{i=await s.text()}catch(p){return w(p)}if(i.length===0)return;let f;try{f=JSON.parse(i)}catch{throw new u("INTERNAL",`${t}: function dispatch returned a non-JSON body (${String(s.status)}): ${i}`,{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)}): ${i}`,{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}}),...e.live===void 0?{}:{live:e.live}});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.12",
3
+ "version": "1.0.0-alpha.121",
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,8 +44,9 @@
44
44
  "access": "public"
45
45
  },
46
46
  "dependencies": {
47
- "@lunora/errors": "1.0.0-alpha.9",
48
- "@lunora/platform": "1.0.0-alpha.1"
47
+ "@lunora/errors": ">=1.0.0-alpha.51 <2.0.0-0",
48
+ "@lunora/platform": "1.0.0-alpha.72",
49
+ "@lunora/wire": "1.0.0-alpha.2"
49
50
  },
50
51
  "engines": {
51
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};