@lunora/queue 1.0.0-alpha.1 → 1.0.0-alpha.10

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/LICENSE.md CHANGED
@@ -103,3 +103,9 @@ Unless required by applicable law or agreed to in writing, software distributed
103
103
  under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
104
104
  CONDITIONS OF ANY KIND, either express or implied. See the License for the
105
105
  specific language governing permissions and limitations under the License.
106
+
107
+ <!-- DEPENDENCIES -->
108
+ <!-- /DEPENDENCIES -->
109
+
110
+ <!-- TYPE_DEPENDENCIES -->
111
+ <!-- /TYPE_DEPENDENCIES -->
package/dist/index.d.mts CHANGED
@@ -1,9 +1,9 @@
1
1
  /**
2
- * Shared types for dispatching a Lunora function back into the worker from a
3
- * server-initiated context (a workflow body, a queue handler, a scheduled job).
4
- * Node-safe — no Cloudflare runtime imports — so the consumers stay unit-testable
5
- * with plain-object doubles.
6
- */
2
+ * Shared types for dispatching a Lunora function back into the worker from a
3
+ * server-initiated context (a workflow body, a queue handler, a scheduled job).
4
+ * Node-safe — no Cloudflare runtime imports — so the consumers stay unit-testable
5
+ * with plain-object doubles.
6
+ */
7
7
  /** Opaque generated function reference (`api.foo.bar`), carrying its dispatch id. */
8
8
  interface FunctionReference {
9
9
  __lunoraRef: string;
@@ -24,7 +24,6 @@ interface DispatchLogger {
24
24
  info: (message: unknown, ...rest: unknown[]) => void;
25
25
  warn: (message: unknown, ...rest: unknown[]) => void;
26
26
  }
27
- /** Build a {@link DispatchLogger} that prefixes every line with `prefix` (e.g. `[queue:email]`). */
28
27
  /** How a queue message body is serialized on the wire (Cloudflare default `"json"`). */
29
28
  type QueueContentType = "bytes" | "json" | "text" | "v8";
30
29
  /** Options for a single `producer.send(body, options?)`. */
@@ -46,10 +45,10 @@ interface MessageSendRequestLike<Body = unknown> {
46
45
  delaySeconds?: number;
47
46
  }
48
47
  /**
49
- * Minimal structural projection of workers-types' `Queue&lt;Body>` (the producer
50
- * binding). The real binding's `send`/`sendBatch` resolve to a metadata object;
51
- * we widen the return to `Promise&lt;unknown>` so a plain-object fake satisfies it.
52
- */
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
+ */
53
52
  interface QueueBindingLike<Body = unknown> {
54
53
  send: (message: Body, options?: QueueSendOptions) => Promise<unknown>;
55
54
  sendBatch: (messages: Iterable<MessageSendRequestLike<Body>>, options?: QueueSendBatchOptions) => Promise<unknown>;
@@ -80,10 +79,10 @@ interface MessageBatchLike<Body = unknown> {
80
79
  retryAll: (options?: QueueRetryOptions) => void;
81
80
  }
82
81
  /**
83
- * The typed producer bound to `ctx.queues.&lt;name>`. Sending is a side effect, so
84
- * the generated context exposes this only on `MutationCtx` / `ActionCtx` (never
85
- * the deterministic `QueryCtx`), mirroring `ctx.scheduler` / `ctx.workflows`.
86
- */
82
+ * The typed producer bound to `ctx.queues.&lt;name>`. Sending is a side effect, so
83
+ * the generated context exposes this only on `MutationCtx` / `ActionCtx` (never
84
+ * the deterministic `QueryCtx`), mirroring `ctx.scheduler` / `ctx.workflows`.
85
+ */
87
86
  interface QueueProducer<Body = unknown> {
88
87
  /** Enqueue one message. */
89
88
  send: (body: Body, options?: QueueSendOptions) => Promise<void>;
@@ -91,10 +90,10 @@ interface QueueProducer<Body = unknown> {
91
90
  sendBatch: (messages: Iterable<MessageSendRequestLike<Body>>, options?: QueueSendBatchOptions) => Promise<void>;
92
91
  }
93
92
  /**
94
- * `ctx.queues` — the map of declared queue export names → typed producers.
95
- * Codegen narrows this to the exact export names; the package keeps it open so
96
- * `createQueues` stays schema-agnostic.
97
- */
93
+ * `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.
96
+ */
98
97
  interface Queues {
99
98
  [exportName: string]: QueueProducer;
100
99
  }
@@ -104,11 +103,11 @@ interface LunoraQueuesOptions {
104
103
  bindings: Record<string, QueueBindingLike>;
105
104
  }
106
105
  /**
107
- * The context handed to a `defineQueue` handler. Decoupled from `@lunora/server`
108
- * (like the workflow run context): to touch data, call a Lunora mutation/action
109
- * via `ctx.run(api.x.y, args)` — the dispatch goes through the same
110
- * `/_lunora/scheduler/dispatch` path the SchedulerDO and workflows use.
111
- */
106
+ * The context handed to a `defineQueue` handler. Decoupled from `@lunora/server`
107
+ * (like the workflow run context): to touch data, call a Lunora mutation/action
108
+ * via `ctx.run(api.x.y, args)` — the dispatch goes through the same
109
+ * `/_lunora/scheduler/dispatch` path the SchedulerDO and workflows use.
110
+ */
112
111
  interface QueueRunContext {
113
112
  /** The worker `env` (bindings + vars). */
114
113
  readonly env: Record<string, unknown>;
@@ -137,25 +136,25 @@ interface QueueConsumerTuning {
137
136
  /** The config object passed to `defineQueue`. */
138
137
  interface QueueConfig<Body = unknown> extends QueueConsumerTuning {
139
138
  /**
140
- * The push-consumer body. Required for `mode: "push"` (the default); omit it
141
- * for `mode: "pull"`, where an external worker polls the queue over HTTP.
142
- */
139
+ * The push-consumer body. Required for `mode: "push"` (the default); omit it
140
+ * for `mode: "pull"`, where an external worker polls the queue over HTTP.
141
+ */
143
142
  handler?: QueueHandler<Body>;
144
143
  /** How this queue is consumed. Defaults to `"push"`. */
145
144
  mode?: QueueConsumerMode;
146
145
  /**
147
- * Stable wrangler queue name (`queues.producers[].queue`). Defaults to the
148
- * kebab-cased export name (`emailQueue` → `email-queue`).
149
- */
146
+ * Stable wrangler queue name (`queues.producers[].queue`). Defaults to the
147
+ * kebab-cased export name (`emailQueue` → `email-queue`).
148
+ */
150
149
  name?: string;
151
150
  }
152
151
  /** The branded result of `defineQueue`, discovered by codegen + config. */
153
152
  interface QueueDefinition<Body = unknown> extends QueueConfig<Body> {
154
153
  /**
155
- * Phantom carrier for the message body type, so codegen can type the
156
- * generated `ctx.queues.&lt;name>` producer as `QueueProducer&lt;Body>` from
157
- * `typeof &lt;export>`. Never assigned at runtime (type-only).
158
- */
154
+ * 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).
157
+ */
159
158
  readonly __lunoraBody?: Body;
160
159
  /** Runtime brand identifying a `defineQueue` result. */
161
160
  isLunoraQueue: true;
@@ -169,98 +168,165 @@ interface QueueBindingSpec {
169
168
  /** The stable wrangler queue name, e.g. `email-queue`. */
170
169
  name: string;
171
170
  }
172
- /**
173
- * Build the `ctx.queues` map for a request: resolve every spec's `env[binding]`
174
- * into the `exportName → Queue binding` map and wrap it in {@link createQueues}.
175
- * A spec whose binding is absent from `env` is skipped here — the helpful "no
176
- * queue named …" error is raised lazily by `ctx.queues.&lt;name>.send(...)` when
177
- * the missing queue is actually used.
178
- */
179
- declare const createQueueContext: (env: Record<string, unknown>, specs: ReadonlyArray<QueueBindingSpec>) => Queues;
180
- /**
181
- * Build the `ctx.queues` map from `lunora/queues.ts` export name → Cloudflare
182
- * `Queue` binding. Each property is a typed {@link QueueProducer}; accessing an
183
- * export whose binding is absent throws a directed error naming the declared
184
- * queues (raised lazily on first use).
185
- */
186
- declare const createQueues: (options: LunoraQueuesOptions) => Queues;
187
- /**
188
- * The wrangler producer binding name for a queue export: `emailQueue` →
189
- * `QUEUE_EMAIL_QUEUE`, `email` → `QUEUE_EMAIL`. The `QUEUE_` prefix namespaces
190
- * these away from `SHARD`/`SESSION`/`SCHEDULER`/`WORKFLOW_*`/`CONTAINER_*` so a
191
- * queue export can never collide with the built-in bindings.
192
- */
193
- declare const queueBindingName: (exportName: string) => string;
194
- /**
195
- * The stable queue name wrangler registers (`queues.producers[].queue` and
196
- * `queues.consumers[].queue`): `emailQueue` → `email-queue`. Used as the
197
- * deployed queue's identifier when no explicit `name` override is given.
198
- */
199
- declare const queueDefaultName: (exportName: string) => string;
200
- /**
201
- * Declare a Cloudflare Queue deployed alongside the app. Pure validation +
202
- * branding: codegen discovers the export, emits the typed `ctx.queues.&lt;name>`
203
- * producer and (for push consumers) the worker `queue()` dispatch; the config
204
- * layer reconciles the wrangler `queues.producers[]` / `queues.consumers[]`
205
- * entries from the same definition.
206
- *
207
- * ```ts
208
- * // lunora/queues.ts
209
- * import { defineQueue } from "@lunora/queue";
210
- * import { api } from "./_generated/api";
211
- *
212
- * export const emailQueue = defineQueue&lt;{ to: string }>({
213
- * handler: async (ctx, batch) => {
214
- * for (const message of batch.messages) {
215
- * await ctx.run(api.email.send, { to: message.body.to });
216
- * message.ack();
217
- * }
218
- * },
219
- * });
220
- * ```
221
- *
222
- * Enqueue from a mutation or action: `await ctx.queues.emailQueue.send({ to })`.
223
- *
224
- * ⚠️ **Privileged dispatch.** A push handler's `ctx.run(...)` calls back into
225
- * Lunora functions over the admin-authenticated dispatch endpoint (the same
226
- * trusted path the scheduler and workflows use), so those calls run with the
227
- * system identity — **end-user RLS is not applied**. Treat a queue handler as
228
- * trusted server code: validate `message.body` (it may be attacker-influenced if
229
- * anything user-facing can enqueue) before acting on it, and don't forward an
230
- * unchecked body straight into a privileged mutation.
231
- */
232
- declare const defineQueue: <Body = unknown>(config: QueueConfig<Body>) => QueueDefinition<Body>;
233
- /** True when a value is a `defineQueue` result (the runtime brand check). */
234
- declare const isQueueDefinition: (value: unknown) => value is QueueDefinition;
235
171
  /** One declared queue, keyed for batch routing by its stable wrangler name. */
236
172
  interface QueueRegistryEntry {
237
173
  /**
238
- * The `defineQueue` result (carries the push handler). The body type is
239
- * erased to `any` here because the registry is heterogeneous — different
240
- * queues carry different message bodies, and the handler param is
241
- * contravariant, so a precise `QueueDefinition&lt;Body>` would not be assignable
242
- * to a shared `unknown`-bodied slot. Runtime dispatch passes the delivered
243
- * batch straight through, so the erasure is type-only.
244
- */
174
+ * The `defineQueue` result (carries the push handler). The body type is
175
+ * erased to `any` here because the registry is heterogeneous — different
176
+ * queues carry different message bodies, and the handler param is
177
+ * contravariant, so a precise `QueueDefinition&lt;Body>` would not be assignable
178
+ * to a shared `unknown`-bodied slot. Runtime dispatch passes the delivered
179
+ * batch straight through, so the erasure is type-only.
180
+ */
245
181
  definition: QueueDefinition<any>;
246
182
  /** The `lunora/queues.ts` export name, for log correlation. */
247
183
  exportName: string;
248
184
  }
249
185
  /** Map of stable wrangler queue name → registry entry, built by codegen. */
250
186
  type QueueRegistry = Record<string, QueueRegistryEntry>;
187
+ /** The disposition a consumer left one message in for a single delivery attempt. */
188
+ type QueueMessageOutcome = "ack" | "error" | "retry";
189
+ /**
190
+ * One consumed message as captured by {@link dispatchQueueBatch} and handed to an
191
+ * {@link QueueCaptureSink}. Structurally matches `@lunora/do`'s
192
+ * `RecordQueueMessageInput` (the reserved `recordQueueMessage` admin RPC payload);
193
+ * the two packages share only this contract, so keep them in sync by hand.
194
+ */
195
+ interface CapturedQueueMessage {
196
+ /** Delivery attempt number for this message (`message.attempts`). */
197
+ attempts: number;
198
+ /** The message body (JSON-encoded + capped by the catcher). */
199
+ body: unknown;
200
+ /** `true` when this failed delivery was the message's last (its retries are exhausted — the broker dead-letters it). */
201
+ deadLettered: boolean;
202
+ /** Handler error message when `outcome` is `error`; absent otherwise. */
203
+ error?: string;
204
+ /** The `lunora/queues.ts` export name that consumed it. */
205
+ exportName: string;
206
+ /** The delivered message id (`message.id`). */
207
+ messageId: string;
208
+ /** How the handler disposed of the message this attempt. */
209
+ outcome: QueueMessageOutcome;
210
+ /** The stable wrangler queue name the batch was delivered from (`batch.queue`). */
211
+ queue: string;
212
+ /** Original message timestamp in epoch-ms (`message.timestamp`). */
213
+ timestamp: number;
214
+ }
215
+ /**
216
+ * Persists a batch of consumed messages. The codegen worker wires this to POST the
217
+ * batch to the root shard's `recordQueueMessage` admin RPC (the dev queue catcher).
218
+ * Best-effort by contract: {@link dispatchQueueBatch} swallows a rejection so a
219
+ * capture failure never changes delivery semantics.
220
+ */
221
+ type QueueCaptureSink = (messages: CapturedQueueMessage[]) => Promise<void> | void;
251
222
  interface DispatchOptions {
223
+ /**
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.
228
+ */
229
+ capture?: QueueCaptureSink;
252
230
  /** Worker `env`, forwarded to the queue run context. */
253
231
  env: Record<string, unknown>;
254
232
  /** Injectable fetch for the `ctx.run` dispatcher (tests). */
255
233
  fetchImpl?: typeof fetch;
256
234
  }
257
235
  /**
258
- * Look up the handler for `batch.queue` and invoke it with a fresh
259
- * `QueueRunContext`. Throws a directed error when no push handler is registered
260
- * for the delivered queue (a misconfiguration — the consumer was declared
261
- * `pull`, or the queue name drifted from the `defineQueue` export).
262
- */
236
+ * Look up the handler for `batch.queue` and invoke it with a fresh
237
+ * `QueueRunContext`. Throws a directed error when no push handler is registered
238
+ * for the delivered queue (a misconfiguration — the consumer was declared
239
+ * `pull`, or the queue name drifted from the `defineQueue` export).
240
+ */
263
241
  declare const dispatchQueueBatch: (batch: MessageBatchLike, registry: QueueRegistry, options: DispatchOptions) => Promise<void>;
242
+ /** A Worker `env` projected as a plain record (vars, secrets, and bindings are `unknown`-valued). */
243
+ type QueueEnv = Record<string, unknown>;
244
+ /** Options for {@link createQueueCaptureSink}. */
245
+ interface QueueCaptureOptions {
246
+ /** Pin the consumed-message log to a Cloudflare data-residency jurisdiction (match the worker's `jurisdiction`). */
247
+ jurisdiction?: string;
248
+ /** Shard the consumed-message log lives on; override if the worker sets a custom `defaultShardKey`. */
249
+ rootShard?: string;
250
+ }
251
+ /**
252
+ * Whether consumed queue messages should be captured into the studio's log.
253
+ * Explicit `LUNORA_QUEUE_CAPTURE` (`"1"`/`"true"` vs `"0"`/`"false"`) always wins;
254
+ * unset, capture is on only in a development environment. Mirrors
255
+ * `@lunora/mail`'s `shouldCaptureMail` so mail and queue dev capture toggle the
256
+ * same way.
257
+ */
258
+ declare const shouldCaptureQueue: (env: QueueEnv) => boolean;
259
+ /**
260
+ * Build the {@link QueueCaptureSink} that records a processed batch into the
261
+ * studio's root-shard consumed-message log via the reserved `recordQueueMessage`
262
+ * admin RPC. Best-effort by contract: without the `SHARD` binding or
263
+ * `LUNORA_ADMIN_TOKEN` it no-ops, and `dispatchQueueBatch` swallows a
264
+ * rejection, so capture never changes delivery semantics.
265
+ */
266
+ declare const createQueueCaptureSink: (env: QueueEnv, options?: QueueCaptureOptions) => QueueCaptureSink;
267
+ /**
268
+ * Build the `ctx.queues` map for a request: resolve every spec's `env[binding]`
269
+ * into the `exportName → Queue binding` map and wrap it in {@link createQueues}.
270
+ * 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
272
+ * the missing queue is actually used.
273
+ */
274
+ declare const createQueueContext: (env: Record<string, unknown>, specs: ReadonlyArray<QueueBindingSpec>) => Queues;
275
+ /**
276
+ * Build the `ctx.queues` map from `lunora/queues.ts` export name → Cloudflare
277
+ * `Queue` binding. Each property is a typed {@link QueueProducer}; accessing an
278
+ * export whose binding is absent throws a directed error naming the declared
279
+ * queues (raised lazily on first use).
280
+ */
281
+ declare const createQueues: (options: LunoraQueuesOptions) => Queues;
282
+ /**
283
+ * The wrangler producer binding name for a queue export: `emailQueue` →
284
+ * `QUEUE_EMAIL_QUEUE`, `email` → `QUEUE_EMAIL`. The `QUEUE_` prefix namespaces
285
+ * these away from `SHARD`/`SESSION`/`SCHEDULER`/`WORKFLOW_*`/`CONTAINER_*` so a
286
+ * queue export can never collide with the built-in bindings.
287
+ */
288
+ declare const queueBindingName: (exportName: string) => string;
289
+ /**
290
+ * The stable queue name wrangler registers (`queues.producers[].queue` and
291
+ * `queues.consumers[].queue`): `emailQueue` → `email-queue`. Used as the
292
+ * deployed queue's identifier when no explicit `name` override is given.
293
+ */
294
+ declare const queueDefaultName: (exportName: string) => string;
295
+ /**
296
+ * Declare a Cloudflare Queue deployed alongside the app. Pure validation +
297
+ * branding: codegen discovers the export, emits the typed `ctx.queues.&lt;name>`
298
+ * producer and (for push consumers) the worker `queue()` dispatch; the config
299
+ * layer reconciles the wrangler `queues.producers[]` / `queues.consumers[]`
300
+ * entries from the same definition.
301
+ *
302
+ * ```ts
303
+ * // lunora/queues.ts
304
+ * import { defineQueue } from "@lunora/queue";
305
+ * import { api } from "./_generated/api";
306
+ *
307
+ * export const emailQueue = defineQueue&lt;{ to: string }>({
308
+ * handler: async (ctx, batch) => {
309
+ * for (const message of batch.messages) {
310
+ * await ctx.run(api.email.send, { to: message.body.to });
311
+ * message.ack();
312
+ * }
313
+ * },
314
+ * });
315
+ * ```
316
+ *
317
+ * Enqueue from a mutation or action: `await ctx.queues.emailQueue.send({ to })`.
318
+ *
319
+ * ⚠️ **Privileged dispatch.** A push handler's `ctx.run(...)` calls back into
320
+ * Lunora functions over the admin-authenticated dispatch endpoint (the same
321
+ * trusted path the scheduler and workflows use), so those calls run with the
322
+ * system identity — **end-user RLS is not applied**. Treat a queue handler as
323
+ * trusted server code: validate `message.body` (it may be attacker-influenced if
324
+ * anything user-facing can enqueue) before acting on it, and don't forward an
325
+ * unchecked body straight into a privileged mutation.
326
+ */
327
+ declare const defineQueue: <Body = unknown>(config: QueueConfig<Body>) => QueueDefinition<Body>;
328
+ /** True when a value is a `defineQueue` result (the runtime brand check). */
329
+ declare const isQueueDefinition: (value: unknown) => value is QueueDefinition;
264
330
  interface RunContextOptions {
265
331
  env: Record<string, unknown>;
266
332
  exportName: string;
@@ -268,4 +334,4 @@ interface RunContextOptions {
268
334
  }
269
335
  /** Assemble the {@link QueueRunContext} passed to a `defineQueue` handler. */
270
336
  declare const createQueueRunContext: (options: RunContextOptions) => QueueRunContext;
271
- export { type ArgsOf, type FunctionReference, type LunoraQueuesOptions, type MessageBatchLike, type MessageLike, type MessageSendRequestLike, type QueueBindingLike, type QueueBindingSpec, type QueueConfig, type QueueConsumerMode, type QueueConsumerTuning, type QueueContentType, type QueueDefinition, 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, createQueueContext, createQueueRunContext, createQueues, defineQueue, dispatchQueueBatch, isQueueDefinition, queueBindingName, queueDefaultName };
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 };
package/dist/index.d.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  /**
2
- * Shared types for dispatching a Lunora function back into the worker from a
3
- * server-initiated context (a workflow body, a queue handler, a scheduled job).
4
- * Node-safe — no Cloudflare runtime imports — so the consumers stay unit-testable
5
- * with plain-object doubles.
6
- */
2
+ * Shared types for dispatching a Lunora function back into the worker from a
3
+ * server-initiated context (a workflow body, a queue handler, a scheduled job).
4
+ * Node-safe — no Cloudflare runtime imports — so the consumers stay unit-testable
5
+ * with plain-object doubles.
6
+ */
7
7
  /** Opaque generated function reference (`api.foo.bar`), carrying its dispatch id. */
8
8
  interface FunctionReference {
9
9
  __lunoraRef: string;
@@ -24,7 +24,6 @@ interface DispatchLogger {
24
24
  info: (message: unknown, ...rest: unknown[]) => void;
25
25
  warn: (message: unknown, ...rest: unknown[]) => void;
26
26
  }
27
- /** Build a {@link DispatchLogger} that prefixes every line with `prefix` (e.g. `[queue:email]`). */
28
27
  /** How a queue message body is serialized on the wire (Cloudflare default `"json"`). */
29
28
  type QueueContentType = "bytes" | "json" | "text" | "v8";
30
29
  /** Options for a single `producer.send(body, options?)`. */
@@ -46,10 +45,10 @@ interface MessageSendRequestLike<Body = unknown> {
46
45
  delaySeconds?: number;
47
46
  }
48
47
  /**
49
- * Minimal structural projection of workers-types' `Queue&lt;Body>` (the producer
50
- * binding). The real binding's `send`/`sendBatch` resolve to a metadata object;
51
- * we widen the return to `Promise&lt;unknown>` so a plain-object fake satisfies it.
52
- */
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
+ */
53
52
  interface QueueBindingLike<Body = unknown> {
54
53
  send: (message: Body, options?: QueueSendOptions) => Promise<unknown>;
55
54
  sendBatch: (messages: Iterable<MessageSendRequestLike<Body>>, options?: QueueSendBatchOptions) => Promise<unknown>;
@@ -80,10 +79,10 @@ interface MessageBatchLike<Body = unknown> {
80
79
  retryAll: (options?: QueueRetryOptions) => void;
81
80
  }
82
81
  /**
83
- * The typed producer bound to `ctx.queues.&lt;name>`. Sending is a side effect, so
84
- * the generated context exposes this only on `MutationCtx` / `ActionCtx` (never
85
- * the deterministic `QueryCtx`), mirroring `ctx.scheduler` / `ctx.workflows`.
86
- */
82
+ * The typed producer bound to `ctx.queues.&lt;name>`. Sending is a side effect, so
83
+ * the generated context exposes this only on `MutationCtx` / `ActionCtx` (never
84
+ * the deterministic `QueryCtx`), mirroring `ctx.scheduler` / `ctx.workflows`.
85
+ */
87
86
  interface QueueProducer<Body = unknown> {
88
87
  /** Enqueue one message. */
89
88
  send: (body: Body, options?: QueueSendOptions) => Promise<void>;
@@ -91,10 +90,10 @@ interface QueueProducer<Body = unknown> {
91
90
  sendBatch: (messages: Iterable<MessageSendRequestLike<Body>>, options?: QueueSendBatchOptions) => Promise<void>;
92
91
  }
93
92
  /**
94
- * `ctx.queues` — the map of declared queue export names → typed producers.
95
- * Codegen narrows this to the exact export names; the package keeps it open so
96
- * `createQueues` stays schema-agnostic.
97
- */
93
+ * `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.
96
+ */
98
97
  interface Queues {
99
98
  [exportName: string]: QueueProducer;
100
99
  }
@@ -104,11 +103,11 @@ interface LunoraQueuesOptions {
104
103
  bindings: Record<string, QueueBindingLike>;
105
104
  }
106
105
  /**
107
- * The context handed to a `defineQueue` handler. Decoupled from `@lunora/server`
108
- * (like the workflow run context): to touch data, call a Lunora mutation/action
109
- * via `ctx.run(api.x.y, args)` — the dispatch goes through the same
110
- * `/_lunora/scheduler/dispatch` path the SchedulerDO and workflows use.
111
- */
106
+ * The context handed to a `defineQueue` handler. Decoupled from `@lunora/server`
107
+ * (like the workflow run context): to touch data, call a Lunora mutation/action
108
+ * via `ctx.run(api.x.y, args)` — the dispatch goes through the same
109
+ * `/_lunora/scheduler/dispatch` path the SchedulerDO and workflows use.
110
+ */
112
111
  interface QueueRunContext {
113
112
  /** The worker `env` (bindings + vars). */
114
113
  readonly env: Record<string, unknown>;
@@ -137,25 +136,25 @@ interface QueueConsumerTuning {
137
136
  /** The config object passed to `defineQueue`. */
138
137
  interface QueueConfig<Body = unknown> extends QueueConsumerTuning {
139
138
  /**
140
- * The push-consumer body. Required for `mode: "push"` (the default); omit it
141
- * for `mode: "pull"`, where an external worker polls the queue over HTTP.
142
- */
139
+ * The push-consumer body. Required for `mode: "push"` (the default); omit it
140
+ * for `mode: "pull"`, where an external worker polls the queue over HTTP.
141
+ */
143
142
  handler?: QueueHandler<Body>;
144
143
  /** How this queue is consumed. Defaults to `"push"`. */
145
144
  mode?: QueueConsumerMode;
146
145
  /**
147
- * Stable wrangler queue name (`queues.producers[].queue`). Defaults to the
148
- * kebab-cased export name (`emailQueue` → `email-queue`).
149
- */
146
+ * Stable wrangler queue name (`queues.producers[].queue`). Defaults to the
147
+ * kebab-cased export name (`emailQueue` → `email-queue`).
148
+ */
150
149
  name?: string;
151
150
  }
152
151
  /** The branded result of `defineQueue`, discovered by codegen + config. */
153
152
  interface QueueDefinition<Body = unknown> extends QueueConfig<Body> {
154
153
  /**
155
- * Phantom carrier for the message body type, so codegen can type the
156
- * generated `ctx.queues.&lt;name>` producer as `QueueProducer&lt;Body>` from
157
- * `typeof &lt;export>`. Never assigned at runtime (type-only).
158
- */
154
+ * 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).
157
+ */
159
158
  readonly __lunoraBody?: Body;
160
159
  /** Runtime brand identifying a `defineQueue` result. */
161
160
  isLunoraQueue: true;
@@ -169,98 +168,165 @@ interface QueueBindingSpec {
169
168
  /** The stable wrangler queue name, e.g. `email-queue`. */
170
169
  name: string;
171
170
  }
172
- /**
173
- * Build the `ctx.queues` map for a request: resolve every spec's `env[binding]`
174
- * into the `exportName → Queue binding` map and wrap it in {@link createQueues}.
175
- * A spec whose binding is absent from `env` is skipped here — the helpful "no
176
- * queue named …" error is raised lazily by `ctx.queues.&lt;name>.send(...)` when
177
- * the missing queue is actually used.
178
- */
179
- declare const createQueueContext: (env: Record<string, unknown>, specs: ReadonlyArray<QueueBindingSpec>) => Queues;
180
- /**
181
- * Build the `ctx.queues` map from `lunora/queues.ts` export name → Cloudflare
182
- * `Queue` binding. Each property is a typed {@link QueueProducer}; accessing an
183
- * export whose binding is absent throws a directed error naming the declared
184
- * queues (raised lazily on first use).
185
- */
186
- declare const createQueues: (options: LunoraQueuesOptions) => Queues;
187
- /**
188
- * The wrangler producer binding name for a queue export: `emailQueue` →
189
- * `QUEUE_EMAIL_QUEUE`, `email` → `QUEUE_EMAIL`. The `QUEUE_` prefix namespaces
190
- * these away from `SHARD`/`SESSION`/`SCHEDULER`/`WORKFLOW_*`/`CONTAINER_*` so a
191
- * queue export can never collide with the built-in bindings.
192
- */
193
- declare const queueBindingName: (exportName: string) => string;
194
- /**
195
- * The stable queue name wrangler registers (`queues.producers[].queue` and
196
- * `queues.consumers[].queue`): `emailQueue` → `email-queue`. Used as the
197
- * deployed queue's identifier when no explicit `name` override is given.
198
- */
199
- declare const queueDefaultName: (exportName: string) => string;
200
- /**
201
- * Declare a Cloudflare Queue deployed alongside the app. Pure validation +
202
- * branding: codegen discovers the export, emits the typed `ctx.queues.&lt;name>`
203
- * producer and (for push consumers) the worker `queue()` dispatch; the config
204
- * layer reconciles the wrangler `queues.producers[]` / `queues.consumers[]`
205
- * entries from the same definition.
206
- *
207
- * ```ts
208
- * // lunora/queues.ts
209
- * import { defineQueue } from "@lunora/queue";
210
- * import { api } from "./_generated/api";
211
- *
212
- * export const emailQueue = defineQueue&lt;{ to: string }>({
213
- * handler: async (ctx, batch) => {
214
- * for (const message of batch.messages) {
215
- * await ctx.run(api.email.send, { to: message.body.to });
216
- * message.ack();
217
- * }
218
- * },
219
- * });
220
- * ```
221
- *
222
- * Enqueue from a mutation or action: `await ctx.queues.emailQueue.send({ to })`.
223
- *
224
- * ⚠️ **Privileged dispatch.** A push handler's `ctx.run(...)` calls back into
225
- * Lunora functions over the admin-authenticated dispatch endpoint (the same
226
- * trusted path the scheduler and workflows use), so those calls run with the
227
- * system identity — **end-user RLS is not applied**. Treat a queue handler as
228
- * trusted server code: validate `message.body` (it may be attacker-influenced if
229
- * anything user-facing can enqueue) before acting on it, and don't forward an
230
- * unchecked body straight into a privileged mutation.
231
- */
232
- declare const defineQueue: <Body = unknown>(config: QueueConfig<Body>) => QueueDefinition<Body>;
233
- /** True when a value is a `defineQueue` result (the runtime brand check). */
234
- declare const isQueueDefinition: (value: unknown) => value is QueueDefinition;
235
171
  /** One declared queue, keyed for batch routing by its stable wrangler name. */
236
172
  interface QueueRegistryEntry {
237
173
  /**
238
- * The `defineQueue` result (carries the push handler). The body type is
239
- * erased to `any` here because the registry is heterogeneous — different
240
- * queues carry different message bodies, and the handler param is
241
- * contravariant, so a precise `QueueDefinition&lt;Body>` would not be assignable
242
- * to a shared `unknown`-bodied slot. Runtime dispatch passes the delivered
243
- * batch straight through, so the erasure is type-only.
244
- */
174
+ * The `defineQueue` result (carries the push handler). The body type is
175
+ * erased to `any` here because the registry is heterogeneous — different
176
+ * queues carry different message bodies, and the handler param is
177
+ * contravariant, so a precise `QueueDefinition&lt;Body>` would not be assignable
178
+ * to a shared `unknown`-bodied slot. Runtime dispatch passes the delivered
179
+ * batch straight through, so the erasure is type-only.
180
+ */
245
181
  definition: QueueDefinition<any>;
246
182
  /** The `lunora/queues.ts` export name, for log correlation. */
247
183
  exportName: string;
248
184
  }
249
185
  /** Map of stable wrangler queue name → registry entry, built by codegen. */
250
186
  type QueueRegistry = Record<string, QueueRegistryEntry>;
187
+ /** The disposition a consumer left one message in for a single delivery attempt. */
188
+ type QueueMessageOutcome = "ack" | "error" | "retry";
189
+ /**
190
+ * One consumed message as captured by {@link dispatchQueueBatch} and handed to an
191
+ * {@link QueueCaptureSink}. Structurally matches `@lunora/do`'s
192
+ * `RecordQueueMessageInput` (the reserved `recordQueueMessage` admin RPC payload);
193
+ * the two packages share only this contract, so keep them in sync by hand.
194
+ */
195
+ interface CapturedQueueMessage {
196
+ /** Delivery attempt number for this message (`message.attempts`). */
197
+ attempts: number;
198
+ /** The message body (JSON-encoded + capped by the catcher). */
199
+ body: unknown;
200
+ /** `true` when this failed delivery was the message's last (its retries are exhausted — the broker dead-letters it). */
201
+ deadLettered: boolean;
202
+ /** Handler error message when `outcome` is `error`; absent otherwise. */
203
+ error?: string;
204
+ /** The `lunora/queues.ts` export name that consumed it. */
205
+ exportName: string;
206
+ /** The delivered message id (`message.id`). */
207
+ messageId: string;
208
+ /** How the handler disposed of the message this attempt. */
209
+ outcome: QueueMessageOutcome;
210
+ /** The stable wrangler queue name the batch was delivered from (`batch.queue`). */
211
+ queue: string;
212
+ /** Original message timestamp in epoch-ms (`message.timestamp`). */
213
+ timestamp: number;
214
+ }
215
+ /**
216
+ * Persists a batch of consumed messages. The codegen worker wires this to POST the
217
+ * batch to the root shard's `recordQueueMessage` admin RPC (the dev queue catcher).
218
+ * Best-effort by contract: {@link dispatchQueueBatch} swallows a rejection so a
219
+ * capture failure never changes delivery semantics.
220
+ */
221
+ type QueueCaptureSink = (messages: CapturedQueueMessage[]) => Promise<void> | void;
251
222
  interface DispatchOptions {
223
+ /**
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.
228
+ */
229
+ capture?: QueueCaptureSink;
252
230
  /** Worker `env`, forwarded to the queue run context. */
253
231
  env: Record<string, unknown>;
254
232
  /** Injectable fetch for the `ctx.run` dispatcher (tests). */
255
233
  fetchImpl?: typeof fetch;
256
234
  }
257
235
  /**
258
- * Look up the handler for `batch.queue` and invoke it with a fresh
259
- * `QueueRunContext`. Throws a directed error when no push handler is registered
260
- * for the delivered queue (a misconfiguration — the consumer was declared
261
- * `pull`, or the queue name drifted from the `defineQueue` export).
262
- */
236
+ * Look up the handler for `batch.queue` and invoke it with a fresh
237
+ * `QueueRunContext`. Throws a directed error when no push handler is registered
238
+ * for the delivered queue (a misconfiguration — the consumer was declared
239
+ * `pull`, or the queue name drifted from the `defineQueue` export).
240
+ */
263
241
  declare const dispatchQueueBatch: (batch: MessageBatchLike, registry: QueueRegistry, options: DispatchOptions) => Promise<void>;
242
+ /** A Worker `env` projected as a plain record (vars, secrets, and bindings are `unknown`-valued). */
243
+ type QueueEnv = Record<string, unknown>;
244
+ /** Options for {@link createQueueCaptureSink}. */
245
+ interface QueueCaptureOptions {
246
+ /** Pin the consumed-message log to a Cloudflare data-residency jurisdiction (match the worker's `jurisdiction`). */
247
+ jurisdiction?: string;
248
+ /** Shard the consumed-message log lives on; override if the worker sets a custom `defaultShardKey`. */
249
+ rootShard?: string;
250
+ }
251
+ /**
252
+ * Whether consumed queue messages should be captured into the studio's log.
253
+ * Explicit `LUNORA_QUEUE_CAPTURE` (`"1"`/`"true"` vs `"0"`/`"false"`) always wins;
254
+ * unset, capture is on only in a development environment. Mirrors
255
+ * `@lunora/mail`'s `shouldCaptureMail` so mail and queue dev capture toggle the
256
+ * same way.
257
+ */
258
+ declare const shouldCaptureQueue: (env: QueueEnv) => boolean;
259
+ /**
260
+ * Build the {@link QueueCaptureSink} that records a processed batch into the
261
+ * studio's root-shard consumed-message log via the reserved `recordQueueMessage`
262
+ * admin RPC. Best-effort by contract: without the `SHARD` binding or
263
+ * `LUNORA_ADMIN_TOKEN` it no-ops, and `dispatchQueueBatch` swallows a
264
+ * rejection, so capture never changes delivery semantics.
265
+ */
266
+ declare const createQueueCaptureSink: (env: QueueEnv, options?: QueueCaptureOptions) => QueueCaptureSink;
267
+ /**
268
+ * Build the `ctx.queues` map for a request: resolve every spec's `env[binding]`
269
+ * into the `exportName → Queue binding` map and wrap it in {@link createQueues}.
270
+ * 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
272
+ * the missing queue is actually used.
273
+ */
274
+ declare const createQueueContext: (env: Record<string, unknown>, specs: ReadonlyArray<QueueBindingSpec>) => Queues;
275
+ /**
276
+ * Build the `ctx.queues` map from `lunora/queues.ts` export name → Cloudflare
277
+ * `Queue` binding. Each property is a typed {@link QueueProducer}; accessing an
278
+ * export whose binding is absent throws a directed error naming the declared
279
+ * queues (raised lazily on first use).
280
+ */
281
+ declare const createQueues: (options: LunoraQueuesOptions) => Queues;
282
+ /**
283
+ * The wrangler producer binding name for a queue export: `emailQueue` →
284
+ * `QUEUE_EMAIL_QUEUE`, `email` → `QUEUE_EMAIL`. The `QUEUE_` prefix namespaces
285
+ * these away from `SHARD`/`SESSION`/`SCHEDULER`/`WORKFLOW_*`/`CONTAINER_*` so a
286
+ * queue export can never collide with the built-in bindings.
287
+ */
288
+ declare const queueBindingName: (exportName: string) => string;
289
+ /**
290
+ * The stable queue name wrangler registers (`queues.producers[].queue` and
291
+ * `queues.consumers[].queue`): `emailQueue` → `email-queue`. Used as the
292
+ * deployed queue's identifier when no explicit `name` override is given.
293
+ */
294
+ declare const queueDefaultName: (exportName: string) => string;
295
+ /**
296
+ * Declare a Cloudflare Queue deployed alongside the app. Pure validation +
297
+ * branding: codegen discovers the export, emits the typed `ctx.queues.&lt;name>`
298
+ * producer and (for push consumers) the worker `queue()` dispatch; the config
299
+ * layer reconciles the wrangler `queues.producers[]` / `queues.consumers[]`
300
+ * entries from the same definition.
301
+ *
302
+ * ```ts
303
+ * // lunora/queues.ts
304
+ * import { defineQueue } from "@lunora/queue";
305
+ * import { api } from "./_generated/api";
306
+ *
307
+ * export const emailQueue = defineQueue&lt;{ to: string }>({
308
+ * handler: async (ctx, batch) => {
309
+ * for (const message of batch.messages) {
310
+ * await ctx.run(api.email.send, { to: message.body.to });
311
+ * message.ack();
312
+ * }
313
+ * },
314
+ * });
315
+ * ```
316
+ *
317
+ * Enqueue from a mutation or action: `await ctx.queues.emailQueue.send({ to })`.
318
+ *
319
+ * ⚠️ **Privileged dispatch.** A push handler's `ctx.run(...)` calls back into
320
+ * Lunora functions over the admin-authenticated dispatch endpoint (the same
321
+ * trusted path the scheduler and workflows use), so those calls run with the
322
+ * system identity — **end-user RLS is not applied**. Treat a queue handler as
323
+ * trusted server code: validate `message.body` (it may be attacker-influenced if
324
+ * anything user-facing can enqueue) before acting on it, and don't forward an
325
+ * unchecked body straight into a privileged mutation.
326
+ */
327
+ declare const defineQueue: <Body = unknown>(config: QueueConfig<Body>) => QueueDefinition<Body>;
328
+ /** True when a value is a `defineQueue` result (the runtime brand check). */
329
+ declare const isQueueDefinition: (value: unknown) => value is QueueDefinition;
264
330
  interface RunContextOptions {
265
331
  env: Record<string, unknown>;
266
332
  exportName: string;
@@ -268,4 +334,4 @@ interface RunContextOptions {
268
334
  }
269
335
  /** Assemble the {@link QueueRunContext} passed to a `defineQueue` handler. */
270
336
  declare const createQueueRunContext: (options: RunContextOptions) => QueueRunContext;
271
- export { type ArgsOf, type FunctionReference, type LunoraQueuesOptions, type MessageBatchLike, type MessageLike, type MessageSendRequestLike, type QueueBindingLike, type QueueBindingSpec, type QueueConfig, type QueueConsumerMode, type QueueConsumerTuning, type QueueContentType, type QueueDefinition, 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, createQueueContext, createQueueRunContext, createQueues, defineQueue, dispatchQueueBatch, isQueueDefinition, queueBindingName, queueDefaultName };
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 };
package/dist/index.mjs CHANGED
@@ -1,5 +1,6 @@
1
+ export { createQueueCaptureSink, shouldCaptureQueue } from './packem_shared/createQueueCaptureSink-B8WE0eHf.mjs';
1
2
  export { createQueueContext } from './packem_shared/createQueueContext-D0XCdCsd.mjs';
2
3
  export { default as createQueues } from './packem_shared/createQueues-14-vSICK.mjs';
3
4
  export { defineQueue, isQueueDefinition, queueBindingName, queueDefaultName } from './packem_shared/defineQueue-D40gREfg.mjs';
4
- export { dispatchQueueBatch } from './packem_shared/dispatchQueueBatch-DVMmBISH.mjs';
5
- export { createQueueRunContext } from './packem_shared/createQueueRunContext-DVqG7Oyk.mjs';
5
+ export { dispatchQueueBatch } from './packem_shared/dispatchQueueBatch-DSEWhEy8.mjs';
6
+ export { createQueueRunContext } from './packem_shared/createQueueRunContext-C8jboCk6.mjs';
@@ -0,0 +1,64 @@
1
+ import { LunoraError } from '@lunora/errors';
2
+
3
+ const RECORD_QUEUE_MESSAGE_OP = "__lunora_admin__:recordQueueMessage";
4
+ const DEFAULT_ROOT_SHARD = "__root__";
5
+ const DEV_ENVIRONMENT_PATTERN = /^(?:dev(?:elopment)?|local(?:host)?|test)$/iu;
6
+ const ENVIRONMENT_VARS = ["CF_ENV", "ENVIRONMENT", "NODE_ENV", "WORKER_ENV"];
7
+ const CAPTURE_FETCH_TIMEOUT_MS = 5e3;
8
+ const shouldCaptureQueue = (env) => {
9
+ const flag = env["LUNORA_QUEUE_CAPTURE"];
10
+ if (typeof flag === "string") {
11
+ return flag === "1" || flag.toLowerCase() === "true";
12
+ }
13
+ return ENVIRONMENT_VARS.some((key) => {
14
+ const value = env[key];
15
+ return typeof value === "string" && DEV_ENVIRONMENT_PATTERN.test(value);
16
+ });
17
+ };
18
+ const createQueueCaptureSink = (env, options = {}) => {
19
+ const rootShard = options.rootShard ?? DEFAULT_ROOT_SHARD;
20
+ return async (messages) => {
21
+ if (messages.length === 0) {
22
+ return;
23
+ }
24
+ const binding = env["SHARD"];
25
+ const adminToken = typeof env["LUNORA_ADMIN_TOKEN"] === "string" ? env["LUNORA_ADMIN_TOKEN"] : void 0;
26
+ if (binding === void 0 || adminToken === void 0) {
27
+ return;
28
+ }
29
+ let namespace = binding;
30
+ if (options.jurisdiction !== void 0) {
31
+ if (typeof binding.jurisdiction !== "function") {
32
+ throw new TypeError(
33
+ `@lunora/queue: Durable Object namespace does not support jurisdiction("${options.jurisdiction}") — update @cloudflare/workers-types or remove the jurisdiction option`
34
+ );
35
+ }
36
+ namespace = binding.jurisdiction(options.jurisdiction);
37
+ }
38
+ const stub = namespace.get(namespace.idFromName(rootShard));
39
+ const controller = new AbortController();
40
+ const timeout = setTimeout(() => {
41
+ controller.abort();
42
+ }, CAPTURE_FETCH_TIMEOUT_MS);
43
+ try {
44
+ const response = await stub.fetch("https://shard.internal/rpc", {
45
+ body: JSON.stringify({ args: { messages }, functionPath: RECORD_QUEUE_MESSAGE_OP }),
46
+ headers: { authorization: `Bearer ${adminToken}`, "content-type": "application/json" },
47
+ method: "POST",
48
+ signal: controller.signal
49
+ });
50
+ if (!response.ok) {
51
+ const detail = await response.text().catch(() => "");
52
+ throw new LunoraError(
53
+ "INTERNAL",
54
+ `@lunora/queue: capture write to the root shard failed (${String(response.status)} ${response.statusText})${detail === "" ? "" : `: ${detail}`}`
55
+ );
56
+ }
57
+ await response.body?.cancel();
58
+ } finally {
59
+ clearTimeout(timeout);
60
+ }
61
+ };
62
+ };
63
+
64
+ export { createQueueCaptureSink, shouldCaptureQueue };
@@ -1,3 +1,5 @@
1
+ import { LunoraError } from '@lunora/errors';
2
+
1
3
  const createDispatchLogger = (prefix) => {
2
4
  return {
3
5
  debug: (message, ...rest) => {
@@ -23,6 +25,18 @@ const trimTrailingSlashes = (value) => {
23
25
  }
24
26
  return value.slice(0, end);
25
27
  };
28
+ const toDispatchError = (label, status, rawBody) => {
29
+ try {
30
+ const parsed = JSON.parse(rawBody);
31
+ const errorBody = parsed?.error;
32
+ if (typeof errorBody === "object" && errorBody !== null && typeof errorBody.code === "string") {
33
+ const { code, data, message } = errorBody;
34
+ return new LunoraError(code, typeof message === "string" ? message : void 0, { data, status });
35
+ }
36
+ } catch {
37
+ }
38
+ return new LunoraError("INTERNAL", `${label}: function dispatch failed (${String(status)}): ${rawBody}`, { status });
39
+ };
26
40
  const createDispatchRunner = (options) => {
27
41
  const { label } = options;
28
42
  const globalFetch = globalThis.fetch;
@@ -33,20 +47,27 @@ const createDispatchRunner = (options) => {
33
47
  }
34
48
  const origin = options.env.LUNORA_ORIGIN_URL;
35
49
  if (typeof origin !== "string" || origin.length === 0) {
36
- throw new Error(`${label}: \`LUNORA_ORIGIN_URL\` must be set on the Worker env so a handler can call back into Lunora functions`);
50
+ throw new LunoraError("INTERNAL", `${label}: \`LUNORA_ORIGIN_URL\` must be set on the Worker env so a handler can call back into Lunora functions`);
37
51
  }
38
52
  const token = options.env.LUNORA_ADMIN_TOKEN;
39
53
  if (typeof token !== "string" || token.length === 0) {
40
- throw new Error(`${label}: \`LUNORA_ADMIN_TOKEN\` must be set on the Worker env to authenticate function dispatch`);
54
+ throw new LunoraError("INTERNAL", `${label}: \`LUNORA_ADMIN_TOKEN\` must be set on the Worker env to authenticate function dispatch`);
41
55
  }
42
56
  const url = `${trimTrailingSlashes(origin)}${SCHEDULER_DISPATCH_PATH}`;
57
+ const headers = { authorization: `Bearer ${token}`, "content-type": "application/json" };
58
+ if (options.identity?.userId !== void 0) {
59
+ headers["x-lunora-userid"] = options.identity.userId;
60
+ }
61
+ if (options.identity?.claims !== void 0) {
62
+ headers["x-lunora-identity"] = JSON.stringify(options.identity.claims);
63
+ }
43
64
  const response = await fetchImpl(url, {
44
65
  body: JSON.stringify({ args: args ?? {}, functionPath: function_.__lunoraRef, shardKey: runOptions.shardKey }),
45
- headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
66
+ headers,
46
67
  method: "POST"
47
68
  });
48
69
  if (!response.ok) {
49
- throw new Error(`${label}: function dispatch failed (${String(response.status)}): ${await response.text()}`);
70
+ throw toDispatchError(label, response.status, await response.text());
50
71
  }
51
72
  const text = await response.text();
52
73
  if (text.length === 0) {
@@ -55,7 +76,9 @@ const createDispatchRunner = (options) => {
55
76
  try {
56
77
  return JSON.parse(text);
57
78
  } catch {
58
- return text;
79
+ throw new LunoraError("INTERNAL", `${label}: function dispatch returned a non-JSON body (${String(response.status)}): ${text}`, {
80
+ status: response.status
81
+ });
59
82
  }
60
83
  };
61
84
  };
@@ -0,0 +1,132 @@
1
+ import { LunoraError } from '@lunora/errors';
2
+ import { createQueueRunContext } from './createQueueRunContext-C8jboCk6.mjs';
3
+
4
+ const DEFAULT_MAX_RETRIES = 3;
5
+ const timestampToMs = (value) => {
6
+ if (value instanceof Date) {
7
+ return value.getTime();
8
+ }
9
+ const asNumber = typeof value === "number" ? value : Number(value);
10
+ return Number.isFinite(asNumber) ? asNumber : 0;
11
+ };
12
+ const instrumentBatch = (batch) => {
13
+ const dispositions = /* @__PURE__ */ new Map();
14
+ const originals = batch.messages;
15
+ const wrappedMessages = originals.map((message) => {
16
+ return {
17
+ ack: () => {
18
+ dispositions.set(message, "ack");
19
+ message.ack();
20
+ },
21
+ get attempts() {
22
+ return message.attempts;
23
+ },
24
+ get body() {
25
+ return message.body;
26
+ },
27
+ get id() {
28
+ return message.id;
29
+ },
30
+ retry: (options) => {
31
+ dispositions.set(message, "retry");
32
+ message.retry(options);
33
+ },
34
+ get timestamp() {
35
+ return message.timestamp;
36
+ }
37
+ };
38
+ });
39
+ const fillUndecided = (outcome) => {
40
+ for (const message of originals) {
41
+ if (!dispositions.has(message)) {
42
+ dispositions.set(message, outcome);
43
+ }
44
+ }
45
+ };
46
+ const wrappedBatch = {
47
+ ackAll: () => {
48
+ fillUndecided("ack");
49
+ batch.ackAll();
50
+ },
51
+ messages: wrappedMessages,
52
+ queue: batch.queue,
53
+ retryAll: (options) => {
54
+ fillUndecided("retry");
55
+ batch.retryAll(options);
56
+ }
57
+ };
58
+ return { dispositions, originals, wrappedBatch };
59
+ };
60
+ const describeThrownError = (handlerError) => {
61
+ if (handlerError instanceof Error) {
62
+ return handlerError.message;
63
+ }
64
+ if (typeof handlerError === "string") {
65
+ return handlerError;
66
+ }
67
+ if (handlerError !== null && typeof handlerError === "object") {
68
+ try {
69
+ return JSON.stringify(handlerError);
70
+ } catch {
71
+ return "[unserializable thrown value]";
72
+ }
73
+ }
74
+ return String(handlerError);
75
+ };
76
+ const buildCaptureRecords = (harness, entry, queue, threw, handlerError) => {
77
+ const errorMessage = threw ? describeThrownError(handlerError) : void 0;
78
+ const maxRetries = typeof entry.definition.maxRetries === "number" ? entry.definition.maxRetries : DEFAULT_MAX_RETRIES;
79
+ return harness.originals.map((message) => {
80
+ const decided = harness.dispositions.get(message);
81
+ const outcome = decided ?? (threw ? "error" : "ack");
82
+ const attempts = typeof message.attempts === "number" ? message.attempts : 1;
83
+ return {
84
+ attempts,
85
+ body: message.body,
86
+ deadLettered: outcome !== "ack" && attempts > maxRetries,
87
+ error: outcome === "error" ? errorMessage : void 0,
88
+ exportName: entry.exportName,
89
+ messageId: message.id,
90
+ outcome,
91
+ queue,
92
+ timestamp: timestampToMs(message.timestamp)
93
+ };
94
+ });
95
+ };
96
+ const dispatchQueueBatch = async (batch, registry, options) => {
97
+ const entry = Object.hasOwn(registry, batch.queue) ? registry[batch.queue] : void 0;
98
+ if (entry === void 0) {
99
+ const known = Object.keys(registry);
100
+ const suffix = known.length === 0 ? "no push queues are declared" : `known push queues: ${known.join(", ")}`;
101
+ throw new LunoraError("INTERNAL", `@lunora/queue: received a batch for queue "${batch.queue}" but no push handler is registered (${suffix})`);
102
+ }
103
+ const { handler } = entry.definition;
104
+ if (typeof handler !== "function") {
105
+ throw new TypeError(`@lunora/queue: queue "${batch.queue}" (${entry.exportName}) has no push handler — it is declared as a pull consumer`);
106
+ }
107
+ const context = createQueueRunContext({ env: options.env, exportName: entry.exportName, fetchImpl: options.fetchImpl });
108
+ if (options.capture === void 0) {
109
+ await handler(context, batch);
110
+ return;
111
+ }
112
+ const harness = instrumentBatch(batch);
113
+ let threw = false;
114
+ let handlerError;
115
+ try {
116
+ await handler(context, harness.wrappedBatch);
117
+ } catch (error) {
118
+ threw = true;
119
+ handlerError = error;
120
+ }
121
+ try {
122
+ const records = buildCaptureRecords(harness, entry, batch.queue, threw, handlerError);
123
+ await options.capture(records);
124
+ } catch (captureError) {
125
+ console.warn("@lunora/queue: capture sink failed (delivery unaffected):", captureError);
126
+ }
127
+ if (threw) {
128
+ throw handlerError;
129
+ }
130
+ };
131
+
132
+ export { dispatchQueueBatch };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lunora/queue",
3
- "version": "1.0.0-alpha.1",
3
+ "version": "1.0.0-alpha.10",
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",
@@ -43,6 +43,9 @@
43
43
  "publishConfig": {
44
44
  "access": "public"
45
45
  },
46
+ "dependencies": {
47
+ "@lunora/errors": "1.0.0-alpha.8"
48
+ },
46
49
  "engines": {
47
50
  "node": "^22.15.0 || >=24.11.0"
48
51
  }
@@ -1,18 +0,0 @@
1
- import { createQueueRunContext } from './createQueueRunContext-DVqG7Oyk.mjs';
2
-
3
- const dispatchQueueBatch = async (batch, registry, options) => {
4
- const entry = registry[batch.queue];
5
- if (entry === void 0) {
6
- const known = Object.keys(registry);
7
- const suffix = known.length === 0 ? "no push queues are declared" : `known push queues: ${known.join(", ")}`;
8
- throw new Error(`@lunora/queue: received a batch for queue "${batch.queue}" but no push handler is registered (${suffix})`);
9
- }
10
- const { handler } = entry.definition;
11
- if (typeof handler !== "function") {
12
- throw new TypeError(`@lunora/queue: queue "${batch.queue}" (${entry.exportName}) has no push handler — it is declared as a pull consumer`);
13
- }
14
- const context = createQueueRunContext({ env: options.env, exportName: entry.exportName, fetchImpl: options.fetchImpl });
15
- await handler(context, batch);
16
- };
17
-
18
- export { dispatchQueueBatch };