@daloyjs/core 1.2.0 → 1.3.0

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/dist/jobs.d.ts ADDED
@@ -0,0 +1,665 @@
1
+ /**
2
+ * Queue-agnostic background jobs for DaloyJS.
3
+ *
4
+ * Where {@link Scheduler} answers *“run this work in this process, on this
5
+ * clock”*, this module answers *“run this work somewhere,
6
+ * eventually”* — the three things a production job interface needs:
7
+ *
8
+ * - **A durable record.** {@link JobStore.put} persists a named JSON payload
9
+ * so the work survives the HTTP request and the process that accepted it.
10
+ * {@link MemoryJobStore} is correct for tests and single-process apps;
11
+ * production supplies a shared adapter (Redis, Postgres, SQS) that
12
+ * implements the same SPI. Core stays zero-dependency.
13
+ * - **At-least-once execution.** {@link createJobWorker} claims a lease,
14
+ * runs an allowlisted handler, and completes or fails with bounded
15
+ * retries (exponential backoff, full jitter) and a dead-letter status.
16
+ * If the process dies after the side effect but before complete, the
17
+ * lease expires and another worker may run it again — handlers must be
18
+ * idempotent. {@link EnqueueOptions.idempotencyKey} collapses duplicate
19
+ * producers (same key + same payload) the way HTTP `idempotency()`
20
+ * collapses duplicate POSTs.
21
+ * - **A clean split from workflows.** This is not Temporal, Inngest, or
22
+ * Vercel Workflow: there is no replay of TypeScript, no “world”,
23
+ * and no `await sleep("7 days")` inside a function body. Delayed jobs and
24
+ * retries are *new attempts of the same record*, not continuations.
25
+ *
26
+ * Pair `App.useJobs` to attach a queue (and optionally a worker that
27
+ * drains on graceful shutdown) and `App.cronEnqueue` when a cron tick
28
+ * should create a job instead of running the side effect in-process.
29
+ *
30
+ * @example
31
+ * ```ts
32
+ * import { createJobQueue, createJobWorker, MemoryJobStore } from "@daloyjs/core/jobs";
33
+ *
34
+ * const store = new MemoryJobStore();
35
+ * const queue = createJobQueue({ store });
36
+ * const worker = createJobWorker({
37
+ * queue,
38
+ * handlers: {
39
+ * "email.welcome": async ({ job, signal }) => {
40
+ * await sendEmail(job.payload, { signal });
41
+ * },
42
+ * },
43
+ * });
44
+ * await queue.enqueue({ name: "email.welcome", payload: { to: "a@b.c" } });
45
+ * await worker.runOnce();
46
+ * ```
47
+ *
48
+ * @module
49
+ * @since 1.3.0
50
+ */
51
+ import type { SchedulerLogger, TimerFns } from "./scheduler.js";
52
+ /**
53
+ * Machine-readable reason carried by a {@link JobConfigError}.
54
+ *
55
+ * - `invalid_name` — job or queue name failed the charset allowlist.
56
+ * - `invalid_payload` — payload (or result) is not plain JSON, or carries a
57
+ * prototype-pollution key.
58
+ * - `payload_too_large` — serialized payload (or result) exceeds
59
+ * {@link JobQueueOptions.payloadMaxBytes}.
60
+ * - `invalid_option` — a numeric/enum option failed validation, or a
61
+ * lifecycle call was duplicated (`useJobs` twice, worker `start()` twice).
62
+ * - `unknown_handler` — no handler was registered for a claimed job's name.
63
+ * - `store_required` — an operation needs a queue that was never configured
64
+ * (for example `app.cronEnqueue()` before `app.useJobs()`).
65
+ * - `store_full` — a bounded store rejected a new job because it is at
66
+ * capacity.
67
+ *
68
+ * @since 1.3.0
69
+ */
70
+ export type JobConfigErrorCode = "invalid_name" | "invalid_payload" | "payload_too_large" | "invalid_option" | "unknown_handler" | "store_required" | "store_full";
71
+ /**
72
+ * Enqueue validation, unknown names at define-time, and bad options. An
73
+ * ordinary `Error` subclass (like `CronParseError`), not an `HttpError` —
74
+ * the jobs engine is a library, not a mounted route; map it to `400`/`422`
75
+ * in your own contract route if you expose enqueue over HTTP.
76
+ *
77
+ * @since 1.3.0
78
+ */
79
+ export declare class JobConfigError extends Error {
80
+ /** Machine-readable failure reason; see {@link JobConfigErrorCode}. */
81
+ readonly code: JobConfigErrorCode;
82
+ constructor(code: JobConfigErrorCode, message: string);
83
+ }
84
+ /**
85
+ * Same idempotency key, different payload fingerprint. A key is permanently
86
+ * bound to the first payload it was enqueued with — mirroring the `422`
87
+ * key-reuse rule of the HTTP `idempotency()` middleware.
88
+ *
89
+ * @since 1.3.0
90
+ */
91
+ export declare class JobIdempotencyConflictError extends Error {
92
+ /** Machine-readable failure reason. */
93
+ readonly code: "idempotency_conflict";
94
+ /** The idempotency key that was reused. */
95
+ readonly key: string;
96
+ /** The id of the job that already holds the key. */
97
+ readonly existingJobId: string;
98
+ constructor(key: string, existingJobId: string);
99
+ }
100
+ /**
101
+ * Thrown by a handler (or the worker, for an unknown job name) to signal
102
+ * “do not retry” — the job is dead-lettered on the spot, however
103
+ * many attempts remain. Analogous to a webhook SSRF / permanent-4xx failure.
104
+ *
105
+ * @since 1.3.0
106
+ */
107
+ export declare class JobFatalError extends Error {
108
+ /** Machine-readable failure reason. */
109
+ readonly code: "fatal";
110
+ readonly cause?: unknown;
111
+ constructor(message: string, cause?: unknown);
112
+ }
113
+ /**
114
+ * A claimed job exceeded its `timeoutMs`; the worker aborted the handler's
115
+ * signal and failed the job as retryable (unless the attempt budget is
116
+ * already exhausted).
117
+ *
118
+ * @since 1.3.0
119
+ */
120
+ export declare class JobTimeoutError extends Error {
121
+ /** Machine-readable failure reason. */
122
+ readonly code: "timeout";
123
+ /** The per-attempt timeout that elapsed, in ms. */
124
+ readonly timeoutMs: number;
125
+ constructor(timeoutMs: number);
126
+ }
127
+ /**
128
+ * Lifecycle states of a {@link Job}. Terminal states (`completed`, `dead`,
129
+ * `cancelled`) have no outgoing transitions.
130
+ *
131
+ * ```text
132
+ * delayed --(runAt <= now)--> queued
133
+ * queued --claim--> running
134
+ * running --complete--> completed (terminal)
135
+ * running --fail, attempts < max--> delayed (runAt = now + backoff)
136
+ * running --fail, attempts >= max--> dead (terminal)
137
+ * running --JobFatalError--> dead (terminal)
138
+ * running --lease expired--> queued (attempts already counted)
139
+ * queued/delayed/running --cancel--> cancelled (terminal)
140
+ * ```
141
+ *
142
+ * @since 1.3.0
143
+ */
144
+ export type JobStatus = "queued" | "delayed" | "running" | "completed" | "dead" | "cancelled";
145
+ /**
146
+ * A serializable snapshot of one unit of background work: an opaque named
147
+ * handler plus a plain-JSON payload. All fields are readonly snapshots —
148
+ * mutate nothing; stores return deep copies.
149
+ *
150
+ * @since 1.3.0
151
+ */
152
+ export interface Job<P = unknown> {
153
+ /** Unique job id (`crypto.randomUUID()`). */
154
+ readonly id: string;
155
+ /** Named partition inside the store (`default`, `mail`, …). */
156
+ readonly queue: string;
157
+ /** Handler registry key. */
158
+ readonly name: string;
159
+ /** Plain-JSON handler input, validated at enqueue. */
160
+ readonly payload: P;
161
+ /** Current lifecycle state. */
162
+ readonly status: JobStatus;
163
+ /** How many times execution has started, including the current run. */
164
+ readonly attempts: number;
165
+ /** Maximum starts before the job dead-letters. */
166
+ readonly maxAttempts: number;
167
+ /** Earliest epoch ms the job may be claimed. */
168
+ readonly runAt: number;
169
+ /** Epoch ms the record was created. */
170
+ readonly createdAt: number;
171
+ /** Epoch ms the record last changed. */
172
+ readonly updatedAt: number;
173
+ /** Epoch ms the current lease ends, or `null` when not claimed. */
174
+ readonly leaseUntil: number | null;
175
+ /** Worker holding the lease, or `null` when not claimed. */
176
+ readonly lockedBy: string | null;
177
+ /** Lease duration granted per claim, in ms. */
178
+ readonly leaseMs: number;
179
+ /** Producer-supplied dedupe key, unique per queue, or `null`. */
180
+ readonly idempotencyKey: string | null;
181
+ /** Claim ordering: higher first. */
182
+ readonly priority: number;
183
+ /** Per-attempt timeout in ms; `0` disables. */
184
+ readonly timeoutMs: number;
185
+ /** Message of the most recent failure, or `null`. */
186
+ readonly lastError: string | null;
187
+ /** Epoch ms the job completed, or `null`. */
188
+ readonly completedAt: number | null;
189
+ /** Optional value stored at completion (the handler's return value). */
190
+ readonly result: unknown | null;
191
+ /** Optional tenant discriminator copied from enqueue (data, not authz). */
192
+ readonly tenant: string | null;
193
+ }
194
+ /**
195
+ * Input to {@link JobQueue.enqueue}. Only `name` and `payload` are required.
196
+ *
197
+ * @since 1.3.0
198
+ */
199
+ export interface EnqueueOptions<P = unknown> {
200
+ /** Handler registry key: `^[a-zA-Z][a-zA-Z0-9._:-]{0,127}$`. */
201
+ name: string;
202
+ /** Plain-JSON handler input. Not a blob store — see `payloadMaxBytes`. */
203
+ payload: P;
204
+ /** Named partition. Default `"default"`. Charset `[a-zA-Z0-9_:-]{1,64}`. */
205
+ queue?: string;
206
+ /**
207
+ * Unique-per-queue dedupe key. A second enqueue with the same key and a
208
+ * deep-equal payload returns the existing job with `duplicate: true`; the
209
+ * same key with a different payload throws {@link JobIdempotencyConflictError}.
210
+ * Printable ASCII, 1–255 chars — build tenant-safe keys with
211
+ * {@link jobIdempotencyKey}.
212
+ */
213
+ idempotencyKey?: string;
214
+ /** Absolute earliest claim time. If in the future, status starts `delayed`. */
215
+ runAt?: number | Date;
216
+ /** Relative delay in ms. Ignored when {@link runAt} is set. */
217
+ delayMs?: number;
218
+ /** Claim ordering: higher first. Default `0`. */
219
+ priority?: number;
220
+ /** Maximum starts before dead-letter. Default `5`. */
221
+ maxAttempts?: number;
222
+ /** Per-attempt timeout in ms; `0` disables (dangerous). Default `30_000`. */
223
+ timeoutMs?: number;
224
+ /** Lease duration granted per claim in ms. Default `30_000`. */
225
+ leaseMs?: number;
226
+ /**
227
+ * Optional tenant discriminator copied onto the record for store key
228
+ * partitioning and logs. Validated against the same `[a-z0-9_-]` grammar
229
+ * as `tenancy()` ids. Jobs are not HTTP — pass `ctx.state.tenant`
230
+ * explicitly; nothing reads it for you.
231
+ */
232
+ tenant?: string;
233
+ }
234
+ /**
235
+ * Outcome of {@link JobQueue.enqueue} / {@link JobStore.put}.
236
+ *
237
+ * @since 1.3.0
238
+ */
239
+ export interface EnqueueResult<P = unknown> {
240
+ /** The created job, or the existing one on an idempotency hit. */
241
+ job: Job<P>;
242
+ /** `true` when an existing job was returned due to an idempotency-key hit. */
243
+ duplicate: boolean;
244
+ }
245
+ /**
246
+ * Persistence SPI — the whole point of &ldquo;queue-agnostic&rdquo;. All
247
+ * durability lives behind this interface; durable backends (Redis, Postgres,
248
+ * SQS) are user-supplied adapters, never core dependencies. Every method may
249
+ * be synchronous or asynchronous, mirroring `IdempotencyStore`.
250
+ *
251
+ * Implementations must treat {@link JobStore.claim} and {@link JobStore.put}
252
+ * as atomic: two concurrent claims must never hand the same job to two
253
+ * workers, and two concurrent puts with the same idempotency key must never
254
+ * create two records.
255
+ *
256
+ * @since 1.3.0
257
+ */
258
+ export interface JobStore {
259
+ /**
260
+ * Insert a new job. If `idempotencyKey` is set and a job already exists in
261
+ * this queue with that key, return the existing job with `duplicate: true`
262
+ * WITHOUT modifying it — even if it is terminal (first-writer-wins). If the
263
+ * existing payload fingerprint differs, throw
264
+ * {@link JobIdempotencyConflictError}.
265
+ *
266
+ * @param job - The fully-formed record to persist.
267
+ * @param fingerprint - SHA-256 hex of the serialized payload, or `null`
268
+ * when no idempotency key is set. Provided so adapters can compare
269
+ * payloads without re-hashing.
270
+ */
271
+ put(job: Job, fingerprint: string | null): Promise<EnqueueResult> | EnqueueResult;
272
+ /**
273
+ * Atomically select the next runnable job in `queue` (`queued`, or
274
+ * `delayed` with `runAt <= now`, or `running` with an expired lease),
275
+ * highest `priority` first, then oldest `runAt` / `createdAt`. Set
276
+ * `status = "running"`, `leaseUntil = now + job.leaseMs`,
277
+ * `lockedBy = workerId`, increment `attempts`, and return the claimed
278
+ * record. Return `null` when nothing is runnable.
279
+ *
280
+ * @param queue - The partition to claim from (never cross-queue).
281
+ * @param workerId - Identity written to `lockedBy` for fencing.
282
+ * @param now - Epoch ms, injected for deterministic leases.
283
+ */
284
+ claim(queue: string, workerId: string, now: number): Promise<Job | null> | Job | null;
285
+ /**
286
+ * Extend the lease of a running job still owned by `workerId`.
287
+ *
288
+ * @returns `false` when the lease was lost (not running, wrong owner, or
289
+ * already expired) — the caller must stop touching the job.
290
+ */
291
+ heartbeat(id: string, workerId: string, leaseUntil: number, now: number): Promise<boolean> | boolean;
292
+ /**
293
+ * Mark a running job owned by `workerId` as `completed` (terminal),
294
+ * storing an optional result value.
295
+ *
296
+ * @returns `false` when the lease was lost — the worker must treat the
297
+ * completion as not persisted.
298
+ */
299
+ complete(id: string, workerId: string, now: number, result: unknown | null): Promise<boolean> | boolean;
300
+ /**
301
+ * Record a failure for a running job owned by `workerId`. Attempts were
302
+ * already incremented at claim time: when `attempts >= maxAttempts` the
303
+ * store marks the job `dead`; otherwise it applies `next` (requeue
304
+ * `delayed` until `runAt`, or straight back to `queued`).
305
+ *
306
+ * @returns `false` when the lease was lost — another worker owns the job.
307
+ */
308
+ fail(id: string, workerId: string, error: string, next: {
309
+ status: "delayed" | "queued" | "dead";
310
+ runAt: number;
311
+ }, now: number): Promise<boolean> | boolean;
312
+ /**
313
+ * Cancel a non-terminal job. Terminal jobs return `false`.
314
+ */
315
+ cancel(id: string, now: number): Promise<boolean> | boolean;
316
+ /**
317
+ * Read one job by id, or `null` when unknown. Expired leases are reaped
318
+ * lazily before the snapshot is taken.
319
+ */
320
+ get(id: string, now: number): Promise<Job | null> | Job | null;
321
+ /**
322
+ * Optional filtered listing — required on {@link MemoryJobStore} for
323
+ * tests and inspection; production adapters may omit it.
324
+ */
325
+ list?(filter?: {
326
+ queue?: string;
327
+ status?: JobStatus | JobStatus[];
328
+ name?: string;
329
+ tenant?: string;
330
+ }): Promise<readonly Job[]> | readonly Job[];
331
+ }
332
+ /**
333
+ * Exponential backoff with full jitter, in ms — the same math as webhook
334
+ * delivery: `min(max, base * 2^(attempt-1))` scaled by a uniform random
335
+ * factor in `[0, 1)`. Exported for deterministic tests (inject
336
+ * `random: () => 1` for the unfuzzed ceiling).
337
+ *
338
+ * @param attempt - The attempt number that just failed (1-based; attempts
339
+ * were already incremented at claim time).
340
+ * @param base - Base delay for the first retry, in ms.
341
+ * @param max - Upper bound on the un-jittered delay, in ms.
342
+ * @param random - Uniform source in `[0, 1)`.
343
+ * @returns The delay before the next attempt, in ms.
344
+ * @since 1.3.0
345
+ */
346
+ export declare function computeBackoffMs(attempt: number, base: number, max: number, random: () => number): number;
347
+ /**
348
+ * Options for {@link MemoryJobStore}.
349
+ *
350
+ * @since 1.3.0
351
+ */
352
+ export interface MemoryJobStoreOptions {
353
+ /**
354
+ * Maximum jobs held (all statuses). Default `10_000`. On overflow of new
355
+ * puts the store first sweeps terminal records past their retention, then
356
+ * throws {@link JobConfigError} (`store_full`) — evicting queued work
357
+ * would be silent data loss.
358
+ */
359
+ capacity?: number;
360
+ /** Injectable clock (ms since epoch). Default {@link Date.now}. */
361
+ now?: () => number;
362
+ /**
363
+ * How long `completed` / `cancelled` records are retained after their last
364
+ * update, in ms. Default 24h. Terminal records are swept lazily on
365
+ * mutating operations.
366
+ */
367
+ retentionMs?: number;
368
+ /** How long `dead` records are retained for inspection, in ms. Default 7d. */
369
+ deadRetentionMs?: number;
370
+ }
371
+ /**
372
+ * In-memory {@link JobStore}. Correct for tests and single-process apps —
373
+ * a full implementation of the SPI, not a fake. **Not durable across
374
+ * processes** and invisible to other replicas: production deployments must
375
+ * supply a shared store (Redis, Postgres, SQS) through the same interface.
376
+ * `app.useJobs()` warns when it sees this store with production config.
377
+ *
378
+ * Payloads are held serialized and re-parsed with the prototype-pollution-safe
379
+ * `safeJsonParse` on every read, and every read returns a deep copy, so
380
+ * callers can never mutate store state.
381
+ *
382
+ * @since 1.3.0
383
+ */
384
+ export declare class MemoryJobStore implements JobStore {
385
+ #private;
386
+ /**
387
+ * @param opts - Capacity, clock, and retention knobs.
388
+ * @throws {RangeError} when a numeric option is out of bounds.
389
+ */
390
+ constructor(opts?: MemoryJobStoreOptions);
391
+ /** The number of jobs currently held (all statuses). */
392
+ get size(): number;
393
+ /**
394
+ * @inheritDoc
395
+ * `_fingerprint` is part of the {@link JobStore} contract but unused here:
396
+ * the in-memory store compares serialized payloads directly.
397
+ */
398
+ put(job: Job, _fingerprint: string | null): EnqueueResult;
399
+ /** @inheritDoc */
400
+ claim(queue: string, workerId: string, now: number): Job | null;
401
+ /** @inheritDoc */
402
+ heartbeat(id: string, workerId: string, leaseUntil: number, now: number): boolean;
403
+ /** @inheritDoc */
404
+ complete(id: string, workerId: string, now: number, result: unknown | null): boolean;
405
+ /** @inheritDoc */
406
+ fail(id: string, workerId: string, error: string, next: {
407
+ status: "delayed" | "queued" | "dead";
408
+ runAt: number;
409
+ }, now: number): boolean;
410
+ /** @inheritDoc */
411
+ cancel(id: string, now: number): boolean;
412
+ /** @inheritDoc */
413
+ get(id: string, now: number): Job | null;
414
+ /** @inheritDoc */
415
+ list(filter?: {
416
+ queue?: string;
417
+ status?: JobStatus | JobStatus[];
418
+ name?: string;
419
+ tenant?: string;
420
+ }): Job[];
421
+ /** Test helper: every job, including terminal records. */
422
+ dump(): Job[];
423
+ }
424
+ /**
425
+ * Context handed to a {@link JobHandler} on each attempt.
426
+ *
427
+ * @since 1.3.0
428
+ */
429
+ export interface JobContext<P = unknown> {
430
+ /** The claimed job snapshot (status `running`, `attempts` already incremented). */
431
+ readonly job: Job<P>;
432
+ /**
433
+ * Aborted when the per-attempt `timeoutMs` elapses, when the worker stops
434
+ * out of grace, or when the lease is lost to another worker. Forward it to
435
+ * every I/O call the handler makes.
436
+ */
437
+ readonly signal: AbortSignal;
438
+ /** 1-based attempt number (=== `job.attempts`). */
439
+ readonly attempt: number;
440
+ /**
441
+ * Extend the lease by another `leaseMs`. Long-running handlers should call
442
+ * this (or rely on the worker's automatic heartbeat every `leaseMs / 3`).
443
+ * If the lease is already lost, the handler's signal is aborted instead.
444
+ */
445
+ heartbeat(): Promise<void>;
446
+ /** Structured logger pre-bound with job id / name / queue / attempt. */
447
+ readonly log: SchedulerLogger;
448
+ }
449
+ /**
450
+ * A registered unit of work. Throw {@link JobFatalError} for permanent
451
+ * failures (no retry); any other throw — or a timeout — is retried with
452
+ * backoff until the attempt budget is spent, then the job is dead-lettered.
453
+ * The return value, when not `undefined`, is stored as {@link Job.result}
454
+ * under the same plain-JSON rules and size cap as payloads.
455
+ *
456
+ * Handlers must be idempotent: delivery is at-least-once. Pass an
457
+ * idempotency key through to downstream APIs (e.g. Stripe's
458
+ * `Idempotency-Key`) when a duplicate run would move money or send email.
459
+ *
460
+ * @since 1.3.0
461
+ */
462
+ export type JobHandler<P = unknown> = (ctx: JobContext<P>) => unknown | Promise<unknown>;
463
+ /**
464
+ * A handler registry: job name to handler. Closed at
465
+ * {@link createJobWorker} time — there is deliberately no dynamic
466
+ * registration and no `import(job.name)`, so a store record can never pick
467
+ * the code that runs it. Typed as `unknown` payload per entry; a
468
+ * heterogeneous map cannot unify payload types, so annotate each handler's
469
+ * `ctx` as {@link JobContext}`<YourPayload>` for narrowing.
470
+ *
471
+ * @since 1.3.0
472
+ */
473
+ export type JobHandlerMap = {
474
+ [name: string]: JobHandler<unknown>;
475
+ };
476
+ /**
477
+ * Backoff policy shared by queue and worker.
478
+ *
479
+ * @since 1.3.0
480
+ */
481
+ export interface JobBackoffOptions {
482
+ /** Base delay for the first retry, in ms. Default `200`. */
483
+ baseDelayMs: number;
484
+ /** Upper bound on the un-jittered delay, in ms. Default `60_000`. */
485
+ maxDelayMs: number;
486
+ /** Uniform jitter source in `[0, 1)`. Default {@link Math.random}. */
487
+ random: () => number;
488
+ }
489
+ /**
490
+ * Options for {@link createJobQueue}. Only `store` is required.
491
+ *
492
+ * @since 1.3.0
493
+ */
494
+ export interface JobQueueOptions {
495
+ /** Persistence backend. {@link MemoryJobStore} for tests / single process. */
496
+ store: JobStore;
497
+ /** Structured logger for queue events. */
498
+ logger?: SchedulerLogger;
499
+ /** Injectable clock (ms since epoch). Default {@link Date.now}. */
500
+ now?: () => number;
501
+ /**
502
+ * Maximum UTF-8 bytes of the serialized payload (and of a completion
503
+ * result). Default `65536` (64 KiB) — jobs are not a blob store.
504
+ */
505
+ payloadMaxBytes?: number;
506
+ /** Partition used when {@link EnqueueOptions.queue} is omitted. Default `"default"`. */
507
+ defaultQueue?: string;
508
+ /** Default for {@link EnqueueOptions.maxAttempts}. Default `5`. */
509
+ defaultMaxAttempts?: number;
510
+ /** Default for {@link EnqueueOptions.timeoutMs}. Default `30_000`. */
511
+ defaultTimeoutMs?: number;
512
+ /** Default for {@link EnqueueOptions.leaseMs}. Default `30_000`. */
513
+ defaultLeaseMs?: number;
514
+ /** Retry backoff policy; see {@link JobBackoffOptions}. */
515
+ backoff?: {
516
+ baseDelayMs?: number;
517
+ maxDelayMs?: number;
518
+ random?: () => number;
519
+ };
520
+ }
521
+ /**
522
+ * The producer façade: validate, serialize, fingerprint, enqueue, inspect,
523
+ * cancel. Split from {@link JobWorker} so HTTP replicas never poll. Safe to
524
+ * share across an entire process.
525
+ *
526
+ * @since 1.3.0
527
+ */
528
+ export interface JobQueue {
529
+ /**
530
+ * Persist a job. Resolves with `duplicate: true` and the existing job when
531
+ * the idempotency key matches a prior enqueue of the same payload.
532
+ *
533
+ * @param opts - The job to create; see {@link EnqueueOptions}.
534
+ * @returns The created (or deduplicated) record.
535
+ * @throws {@link JobConfigError} on invalid names, options, non-JSON
536
+ * payloads, oversized payloads, or a full bounded store.
537
+ * @throws {@link JobIdempotencyConflictError} when the key was already used
538
+ * with a different payload.
539
+ */
540
+ enqueue<P>(opts: EnqueueOptions<P>): Promise<EnqueueResult<P>>;
541
+ /** Read one job by id, or `null`. */
542
+ get(id: string): Promise<Job | null>;
543
+ /** Cancel a non-terminal job. Resolves `false` for terminal/unknown ids. */
544
+ cancel(id: string): Promise<boolean>;
545
+ /** The underlying store, exposed for adapters and test inspection. */
546
+ readonly store: JobStore;
547
+ /** Partition used when enqueue omits `queue`. */
548
+ readonly defaultQueue: string;
549
+ /** Serialized-payload (and result) byte cap. */
550
+ readonly payloadMaxBytes: number;
551
+ /** Resolved retry backoff policy (used by workers on failure). */
552
+ readonly backoff: JobBackoffOptions;
553
+ }
554
+ /**
555
+ * Create a {@link JobQueue} over a {@link JobStore}. Validates all options
556
+ * eagerly (fail-fast config): numeric bounds throw `RangeError`, bad names
557
+ * throw {@link JobConfigError}, a missing store throws
558
+ * {@link JobConfigError} (`store_required`).
559
+ *
560
+ * @param opts - Queue configuration; see {@link JobQueueOptions}.
561
+ * @returns The producer façade.
562
+ * @since 1.3.0
563
+ */
564
+ export declare function createJobQueue(opts: JobQueueOptions): JobQueue;
565
+ /**
566
+ * Options for {@link createJobWorker}.
567
+ *
568
+ * @since 1.3.0
569
+ */
570
+ export interface JobWorkerOptions {
571
+ /** The queue to claim from (provides the store and backoff policy). */
572
+ queue: JobQueue;
573
+ /**
574
+ * Handler registry, closed at construction. Keys must satisfy the job-name
575
+ * grammar; values are `JobHandler<any>` because a heterogeneous map cannot
576
+ * unify payload types — annotate each handler's ctx for narrowing.
577
+ */
578
+ handlers: Record<string, JobHandler<any>>;
579
+ /** Partitions to claim from, in order. Default `[queue.defaultQueue]`. */
580
+ queues?: string[];
581
+ /** Maximum jobs run concurrently. Default `1`. */
582
+ concurrency?: number;
583
+ /** Idle poll cadence in ms. Default `200`. */
584
+ pollIntervalMs?: number;
585
+ /** Fencing identity written to `lockedBy`. Default `crypto.randomUUID()`. */
586
+ workerId?: string;
587
+ /** Structured logger for worker lifecycle and failure events. */
588
+ logger?: SchedulerLogger;
589
+ /** Injectable timer primitives (shared shape with the Scheduler). */
590
+ timers?: TimerFns;
591
+ /** Injectable clock (ms since epoch). Default {@link Date.now}. */
592
+ now?: () => number;
593
+ /** Called when a job dead-letters (fatal, or budget exhausted). */
594
+ onDead?: (job: Job, error: string) => void | Promise<void>;
595
+ /** Called when a job completes. */
596
+ onComplete?: (job: Job) => void | Promise<void>;
597
+ /** Called on every non-fatal failure, before the next state is applied. */
598
+ onFail?: (job: Job, error: string, willRetry: boolean, delayMs?: number) => void | Promise<void>;
599
+ }
600
+ /**
601
+ * A claimed-job consumer: poll/claim/run/complete with leases, heartbeats,
602
+ * bounded retries, and graceful drain. Create it where a long-lived process
603
+ * exists (Node/Bun/Deno); on serverless isolates, enqueue only.
604
+ *
605
+ * @since 1.3.0
606
+ */
607
+ export interface JobWorker {
608
+ /**
609
+ * Start the poll loop.
610
+ *
611
+ * @throws {@link JobConfigError} when the worker was already started (or
612
+ * was started and then stopped — a stopped worker is spent).
613
+ */
614
+ start(): void;
615
+ /**
616
+ * Stop polling, wait up to `graceMs` for in-flight jobs to settle, then
617
+ * abort their signals and wait for them to unwind. Jobs aborted this way
618
+ * are failed back to the queue so another worker can pick them up.
619
+ * Idempotent.
620
+ */
621
+ stop(graceMs?: number): Promise<void>;
622
+ /** Point-in-time worker state. */
623
+ getState(): {
624
+ running: boolean;
625
+ inFlight: number;
626
+ workerId: string;
627
+ };
628
+ /**
629
+ * Test helper: claim and run one available job to settlement, whether or
630
+ * not the loop is started. Resolves `false` when nothing was claimable.
631
+ */
632
+ runOnce(): Promise<boolean>;
633
+ }
634
+ /**
635
+ * Create a {@link JobWorker}. The handler registry is frozen into a
636
+ * null-prototype map at construction: unknown job names dead-letter as
637
+ * poison pills instead of spinning, and no code path resolves a handler by
638
+ * dynamic lookup or import.
639
+ *
640
+ * @param opts - Worker configuration; see {@link JobWorkerOptions}.
641
+ * @returns The worker.
642
+ * @throws {RangeError} on out-of-bounds numeric options.
643
+ * @throws {@link JobConfigError} on a missing/empty registry or invalid
644
+ * handler names.
645
+ * @since 1.3.0
646
+ */
647
+ export declare function createJobWorker(opts: JobWorkerOptions): JobWorker;
648
+ /**
649
+ * Build a tenant-safe idempotency key of the form `t/{tenant}/{name}/{key}`
650
+ * (or `g/{name}/{key}` when no tenant applies). Segments are validated so a
651
+ * key can never smuggle whitespace/control characters, newlines, or `..`
652
+ * into store keys or log lines, and tenant ids follow the `tenancy()`
653
+ * grammar — one tenant's keys can never collide with another's.
654
+ *
655
+ * @param parts - `name` is the job name; `key` is the caller's dedupe id
656
+ * (user id, provider event id, order id, …); `tenant` scopes the key.
657
+ * @returns The composed key, at most 255 chars.
658
+ * @throws {@link JobConfigError} when a segment fails validation.
659
+ * @since 1.3.0
660
+ */
661
+ export declare function jobIdempotencyKey(parts: {
662
+ tenant?: string;
663
+ name: string;
664
+ key: string;
665
+ }): string;