@ayepi/work 0.1.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.
@@ -0,0 +1,1167 @@
1
+ import { BoundedDoerOptions, Doer, Doer as Doer$1, DoerTaskOptions, UnlimitedDoerOptions, ageDoer, balancedDoer, priorityDoer, unlimitedDoer } from "@ayepi/core/doer";
2
+ import { MaybePromise } from "@ayepi/core";
3
+
4
+ //#region src/internal.d.ts
5
+
6
+ /** The signature of a `logWith`-style context wrapper (`@ayepi/log`'s `logWith`). */
7
+ type LogWith = <R>(add: object, inner: () => R) => R;
8
+ /** The default {@link LogWith}: runs `inner` with no added context. */
9
+
10
+ /** A monotonic-ish clock injection point (`() => Date.now()` by default). */
11
+ type Clock = () => number;
12
+ //#endregion
13
+ //#region src/json.d.ts
14
+ /**
15
+ * # JSON codec
16
+ *
17
+ * Work inputs, outputs, and group results cross the wire as strings. A plain
18
+ * `JSON.stringify` silently drops `undefined`, throws on `BigInt`, and flattens
19
+ * `Date`/`Map`/`Set` into useless shapes. {@link defaultCodec} round-trips all of
20
+ * them with a tagged-wrapper replacer/reviver, and you can supply your own
21
+ * {@link JsonCodec} (globally or per work type) for custom classes.
22
+ *
23
+ * @module
24
+ */
25
+ /**
26
+ * A bidirectional JSON serializer. The default round-trips `Date`, `BigInt`, `Map`,
27
+ * `Set`, `undefined`, and `Error`; replace it to support custom types.
28
+ */
29
+ interface JsonCodec {
30
+ /** Serialize a value to a string. */
31
+ stringify(value: unknown): string;
32
+ /** Parse a string back to a value. */
33
+ parse(text: string): unknown;
34
+ }
35
+ /**
36
+ * The default {@link JsonCodec}. Tags values JSON can't represent natively so they
37
+ * survive a `stringify` → `parse` round-trip:
38
+ *
39
+ * | Value | Encoded as |
40
+ * |-------------|-------------------------------------|
41
+ * | `undefined` | `{ $ayepi:'undefined' }` |
42
+ * | `bigint` | `{ $ayepi:'BigInt', value:'123' }` |
43
+ * | `Date` | `{ $ayepi:'Date', value:<iso> }` |
44
+ * | `Map` | `{ $ayepi:'Map', value:[[k,v]…] }` |
45
+ * | `Set` | `{ $ayepi:'Set', value:[…] }` |
46
+ * | `Error` | `{ $ayepi:'Error', value:{name,message,stack} }` |
47
+ */
48
+ declare const defaultCodec: JsonCodec;
49
+ //#endregion
50
+ //#region src/ports.d.ts
51
+ /**
52
+ * # Ports
53
+ *
54
+ * The three pluggable seams every backend slots into. `@ayepi/work` ships an
55
+ * in-memory implementation of all three ({@link memoryQueue}/{@link memoryPubSub}/
56
+ * {@link memoryStore}); a distributed deployment swaps in Redis/SQS/etc. behind the
57
+ * same interfaces with no engine changes.
58
+ *
59
+ * All durations are **milliseconds**.
60
+ *
61
+ * - {@link Queue} — the durable work log: push bodies, lease a batch, heartbeat the
62
+ * lease, ack/fail/dead-letter. At-least-once with a visibility timeout, so a dead
63
+ * worker's in-flight item is redelivered.
64
+ * - {@link PubSub} — best-effort cross-instance fanout (identical shape to
65
+ * `@ayepi/core`'s `Broker`): used to wake distributed waiters and nudge gates.
66
+ * - {@link Store} — get/set with TTL plus a compare-and-set primitive
67
+ * ({@link Store.setIfNotExists}) that backs every idempotency/lease concern.
68
+ *
69
+ * @module
70
+ */
71
+ /** Options for {@link Queue.push}. */
72
+ interface PushOptions {
73
+ /** Delay before the item first becomes visible (retry backoff, scheduled work). */
74
+ readonly delay?: number;
75
+ /**
76
+ * Idempotency key. If a backend supports it, pushing a second body with the same
77
+ * key while the first is still pending is a no-op — used for at-least-once-safe
78
+ * fan-out (dependency payloads, retries). Best-effort; not all backends dedupe.
79
+ */
80
+ readonly dedupeKey?: string;
81
+ }
82
+ /**
83
+ * A unit of work leased from a {@link Queue}. `handle` is the backend-specific token
84
+ * (a memory lease token, an SQS receipt handle, a Redis lease id, …) — pass the same
85
+ * object back to {@link Queue.heartbeat}/{@link Queue.ack}/{@link Queue.fail}.
86
+ */
87
+ interface PulledWork {
88
+ /** The opaque message body (a JSON-encoded work envelope). */
89
+ readonly body: string;
90
+ /** Backend-specific lease/receipt handle — round-tripped to heartbeat/ack/fail. */
91
+ readonly handle: unknown;
92
+ /** Delivery attempt for this body, starting at 1 (increments on redelivery). */
93
+ readonly attempt: number;
94
+ }
95
+ /**
96
+ * The durable work log. At-least-once delivery with a visibility timeout: a popped
97
+ * item is invisible to other workers until its lease elapses; a worker keeps the
98
+ * lease alive with {@link heartbeat} and removes the item with {@link ack}. A worker
99
+ * that dies without acking lets the lease expire, and the item is redelivered.
100
+ */
101
+ interface Queue {
102
+ /** Append a body to the log (optionally delayed/deduped). */
103
+ push(body: string, opts?: PushOptions): void | Promise<void>;
104
+ /**
105
+ * Lease up to `max` currently-visible items, hiding each for `visibility` ms.
106
+ * Returns fewer (or none) when the queue is short. Reclaims items whose lease
107
+ * expired (redelivery) before leasing fresh ones.
108
+ */
109
+ pop(max: number, visibility: number): PulledWork[] | Promise<PulledWork[]>;
110
+ /** Extend a leased item's visibility by `visibility` ms (called on a heartbeat). */
111
+ heartbeat(pulled: PulledWork, visibility: number): void | Promise<void>;
112
+ /** Permanently remove a leased item (it completed). A stale lease must not ack. */
113
+ ack(pulled: PulledWork): void | Promise<void>;
114
+ /** Return a leased item to the queue, visible again after `delay` ms (a retry). */
115
+ fail(pulled: PulledWork, delay?: number): void | Promise<void>;
116
+ /** Move a body to a dead-letter sink after exhausting retries (optional). */
117
+ deadLetter?(body: string, error: string): void | Promise<void>;
118
+ }
119
+ /**
120
+ * Best-effort cross-instance message fanout. Identical in shape to `@ayepi/core`'s
121
+ * `Broker`: publish an opaque string, subscribe to every published string.
122
+ */
123
+ interface PubSub {
124
+ /** Publish an opaque message to every subscriber across all instances. */
125
+ publish(message: string): void | Promise<void>;
126
+ /**
127
+ * Register a listener for published messages.
128
+ * @returns an unsubscribe function that detaches the listener.
129
+ */
130
+ subscribe(listener: (message: string) => void): () => void;
131
+ }
132
+ /**
133
+ * A small key/value store with TTL and one compare-and-set primitive.
134
+ * {@link setIfNotExists} is the single atom every distributed claim is built on:
135
+ * dependency fire-once, scheduler tick lease, group-handled claim, waiter registry.
136
+ */
137
+ interface Store {
138
+ /** Read a value, or `undefined` if absent/expired. */
139
+ get(key: string): string | undefined | Promise<string | undefined>;
140
+ /** Write a value, optionally expiring after `ttl` ms. */
141
+ set(key: string, value: string, ttl?: number): void | Promise<void>;
142
+ /** Delete a key (optional). */
143
+ delete?(key: string): void | Promise<void>;
144
+ /**
145
+ * Set **only if absent**. Returns `true` when this caller won the slot, `false` when
146
+ * the key already held a (non-expired) value. The atomic claim every
147
+ * idempotency/lease concern relies on.
148
+ */
149
+ setIfNotExists(key: string, value: string, ttl?: number): boolean | Promise<boolean>;
150
+ /**
151
+ * Atomically add `by` (may be negative) to an integer key and return the new value.
152
+ * Backs the group open-work counter. Optional: when absent the engine falls back to
153
+ * a (non-atomic) get+set, which is only safe on a single-process backend.
154
+ */
155
+ increment?(key: string, by: number, ttl?: number): number | Promise<number>;
156
+ }
157
+ /** The three ports a backend provides together. */
158
+ interface Backend {
159
+ readonly queue: Queue;
160
+ readonly pubsub: PubSub;
161
+ readonly store: Store;
162
+ }
163
+ //#endregion
164
+ //#region ../core/dist/stats.d.ts
165
+ //#region src/stats.d.ts
166
+ /**
167
+ * # Stats — a tiny, runtime-agnostic metrics primitive
168
+ *
169
+ * A dependency-free way to track named, typed measurements and hand them to whatever the
170
+ * end user already runs — a periodic log, StatsD, Prometheus. Three metric kinds cover
171
+ * the field:
172
+ *
173
+ * - **counter** — a monotonic tally (`inc`): requests served, jobs failed.
174
+ * - **gauge** — a value that moves both ways (`set`/`add`/`max`): in-flight count, a peak.
175
+ * - **summary** — a distribution of observations (`observe`): always count/total/min/max/avg,
176
+ * and — when buckets/quantiles are configured — histogram buckets + approximate
177
+ * percentiles (p50/p95/p99). Histogram-backed, so it's deterministic and bounded-memory.
178
+ *
179
+ * Every metric carries metadata (`name`, `kind`, `description`, `unit`) and is **labelled**
180
+ * (e.g. `{ type: 'email' }`), so one name spans many series. {@link createMetrics} is the
181
+ * registry: create handles, snapshot every series as a flat {@link StatValue} list, and
182
+ * {@link Metrics.subscribe} to **coalesced** change notifications (a burst of mutations
183
+ * yields one batched callback). {@link formatPrometheus} renders a snapshot as Prometheus
184
+ * text exposition.
185
+ *
186
+ * ```ts
187
+ * import { createMetrics, formatPrometheus } from '@ayepi/core'
188
+ *
189
+ * const m = createMetrics({ quantiles: [0.5, 0.95, 0.99] })
190
+ * m.counter('jobs_done', { type: 'email' }).inc()
191
+ * m.summary('job_ms', { type: 'email' }, { unit: 'ms' }).observe(42)
192
+ *
193
+ * setInterval(() => console.log(formatPrometheus(m.list())), 15_000) // scrape/log loop
194
+ * ```
195
+ *
196
+ * @module
197
+ */
198
+ /** The three metric shapes. */
199
+ type StatKind = 'counter' | 'gauge' | 'summary';
200
+ /** A metric's label set (a series within a metric family). Values are strings, order-insensitive. */
201
+ type Labels = Readonly<Record<string, string>>;
202
+ /** Static metadata describing a metric family (shared across its label series). */
203
+ interface StatMeta {
204
+ /** The metric name (the family key). */
205
+ readonly name: string;
206
+ /** Which kind of metric this is. */
207
+ readonly kind: StatKind;
208
+ /** Human-readable description (exported as Prometheus `# HELP`). */
209
+ readonly description?: string;
210
+ /** Unit of measure, e.g. `'ms'`, `'bytes'`, `'count'` (informational). */
211
+ readonly unit?: string;
212
+ }
213
+ /** One histogram bucket: the cumulative count of observations `<= le` (`le` = upper bound, `Infinity` for the overflow). */
214
+ interface StatBucket {
215
+ readonly le: number;
216
+ readonly count: number;
217
+ }
218
+ /** A summary's distribution snapshot. `quantiles`/`buckets` are present only when configured. */
219
+ interface StatSummary {
220
+ /** Number of observations. */
221
+ readonly count: number;
222
+ /** Sum of all observations. */
223
+ readonly total: number;
224
+ /** Smallest observation (0 when none). */
225
+ readonly min: number;
226
+ /** Largest observation (0 when none). */
227
+ readonly max: number;
228
+ /** Mean (0 when none). */
229
+ readonly avg: number;
230
+ /** Approximate quantiles by probability key, e.g. `{ '0.95': 180 }` — when `quantiles` were configured. */
231
+ readonly quantiles?: Readonly<Record<string, number>>;
232
+ /** Cumulative histogram buckets — when buckets were configured. */
233
+ readonly buckets?: readonly StatBucket[];
234
+ }
235
+ /** A point-in-time snapshot of a single metric series (one name + label set). */
236
+ interface StatValue {
237
+ /** The owning family's metadata. */
238
+ readonly meta: StatMeta;
239
+ /** This series' labels. */
240
+ readonly labels: Labels;
241
+ /** Counter/gauge value; for a summary, its observation `count` (full detail in {@link summary}). */
242
+ readonly value: number;
243
+ /** Present iff `meta.kind === 'summary'`. */
244
+ readonly summary?: StatSummary;
245
+ }
246
+ /** A monotonic tally. */
247
+ interface Counter {
248
+ /** Add `by` (default 1). */
249
+ inc(by?: number): void;
250
+ /** Current total. */
251
+ value(): number;
252
+ }
253
+ /** A value that moves up and down. */
254
+ interface Gauge {
255
+ /** Replace the value. */
256
+ set(v: number): void;
257
+ /** Add `by` (may be negative). */
258
+ add(by: number): void;
259
+ /** Raise to `v` if larger (a running high-water mark). */
260
+ max(v: number): void;
261
+ /** Current value. */
262
+ value(): number;
263
+ }
264
+ /** A distribution of observations. */
265
+ interface Summary {
266
+ /** Record one observation. */
267
+ observe(v: number): void;
268
+ /** Current distribution snapshot. */
269
+ snapshot(): StatSummary;
270
+ }
271
+ /** Options for {@link createMetrics}. */
272
+ interface MetricsOptions {
273
+ /**
274
+ * Probabilities (0–1) to estimate for every summary, e.g. `[0.5, 0.95, 0.99]`. Enables
275
+ * histogram bucketing (default {@link DEFAULT_BUCKETS} unless `buckets` is given) and fills
276
+ * {@link StatSummary.quantiles}. Omit for count/total/min/max/avg only (no per-observation cost).
277
+ */
278
+ readonly quantiles?: readonly number[];
279
+ /** Histogram bucket upper bounds for summaries (ascending). Defaults to {@link DEFAULT_BUCKETS} when `quantiles` is set. */
280
+ readonly buckets?: readonly number[];
281
+ /**
282
+ * Schedules the coalesced flush of change notifications. Default batches via `queueMicrotask`
283
+ * (one callback per synchronous burst). Inject `(fn) => fn()` for synchronous delivery, or a
284
+ * manual collector in tests.
285
+ */
286
+ readonly schedule?: (flush: () => void) => void;
287
+ }
288
+ /** The metrics registry returned by {@link createMetrics}. */
289
+ interface Metrics {
290
+ /** Get-or-create a {@link Counter} series. */
291
+ counter(name: string, labels?: Labels, meta?: Omit<StatMeta, 'name' | 'kind'>): Counter;
292
+ /** Get-or-create a {@link Gauge} series. */
293
+ gauge(name: string, labels?: Labels, meta?: Omit<StatMeta, 'name' | 'kind'>): Gauge;
294
+ /** Get-or-create a {@link Summary} series. */
295
+ summary(name: string, labels?: Labels, meta?: Omit<StatMeta, 'name' | 'kind'>): Summary;
296
+ /** Snapshot every series as a flat list (one {@link StatValue} per name + label set). */
297
+ list(): StatValue[];
298
+ /** Snapshot one series, or `undefined` if it was never created. */
299
+ get(name: string, labels?: Labels): StatValue | undefined;
300
+ /** Subscribe to **coalesced** change notifications; the listener gets the batch of changed series. Returns an unsubscribe fn. */
301
+ subscribe(listener: (changed: readonly StatValue[]) => void): () => void;
302
+ }
303
+ /** Default histogram bucket upper bounds (ms-oriented), used when `quantiles` is set without explicit `buckets`. */
304
+ declare const DEFAULT_BUCKETS: readonly number[];
305
+ /** Create a metrics registry. */
306
+ declare function createMetrics(opts?: MetricsOptions): Metrics;
307
+ /**
308
+ * Render a {@link Metrics.list} snapshot as Prometheus text exposition format. Counters and gauges
309
+ * map directly; summaries are emitted as **histograms** (`_bucket`/`_count`/`_sum`) when buckets are
310
+ * present, else as bare `_count`/`_sum`. Names are sanitized to valid Prometheus identifiers.
311
+ */
312
+ declare function formatPrometheus(stats: readonly StatValue[]): string;
313
+ //#endregion
314
+ //#endregion
315
+ //#region ../core/dist/types.d.ts
316
+ /** A value that may be returned either synchronously or as a `Promise`. */
317
+ type MaybePromise$1<T> = T | Promise<T>;
318
+ /**
319
+ * The empty object type. Used as the identity element when merging contributed
320
+ * shapes (middleware context, path params, etc.) so an absent contribution adds
321
+ * nothing rather than widening the result.
322
+ */
323
+ //#endregion
324
+ //#region ../core/dist/retry.d.ts
325
+ //#region src/retry.d.ts
326
+
327
+ /**
328
+ * Throw this from a {@link retry} operation to **stop retrying immediately**: the remaining
329
+ * attempts (and their backoff) are skipped, `onError` fires once, and `retry` then re-throws the
330
+ * abort's `cause` (or returns `errorResult` if one was configured). Use it for a permanent failure
331
+ * a retry can't fix — a 4xx, a validation error, a missing resource.
332
+ *
333
+ * ```ts
334
+ * await retry(async () => {
335
+ * const res = await fetch(url);
336
+ * if (res.status === 404) throw new RetryAbort(new Error('not found')); // don't retry a 404
337
+ * if (!res.ok) throw new Error(`http ${res.status}`); // transient → retried
338
+ * return res.json();
339
+ * });
340
+ * ```
341
+ */
342
+ declare class RetryAbort extends Error {
343
+ constructor(cause?: unknown, message?: string);
344
+ }
345
+ /** Live state passed to the operation and to the {@link RetryOptions} hooks. */
346
+ interface RetryState {
347
+ /** When the first attempt started (epoch ms). */
348
+ readonly startAt: number;
349
+ /** Current attempt number (1-based). */
350
+ readonly attempt: number;
351
+ /** Total attempts allowed. */
352
+ readonly attempts: number;
353
+ /** When the current attempt started (epoch ms). */
354
+ readonly lastAttemptAt: number;
355
+ /** The most recent error (undefined before any failure). */
356
+ readonly lastError?: unknown;
357
+ }
358
+ /** Options for {@link retry}. Every numeric field has a default (see {@link DEFAULT_RETRY_OPTIONS}). */
359
+ interface RetryOptions<R = unknown> {
360
+ /** Total attempts including the first (default 3). */
361
+ attempts?: number;
362
+ /** First-retry delay in ms (default 1000). */
363
+ base?: number;
364
+ /** Multiplier applied per attempt (default 2). */
365
+ factor?: number;
366
+ /** Delay cap in ms (default 30000). */
367
+ max?: number;
368
+ /** Jitter fraction in `[0,1]`: each delay is scaled down by up to this much (default 0.5). */
369
+ jitter?: number;
370
+ /** If every attempt fails, resolve with this value instead of throwing. */
371
+ errorResult?: R;
372
+ /**
373
+ * Decide what to do with a thrown error: return `false` to **stop** retrying (abort), or a number
374
+ * of **milliseconds to wait at least** before the next attempt — a floor under the normal backoff
375
+ * (e.g. honor a `Retry-After`; `0` = just use the backoff). May be async. Default:
376
+ * `(err) => (err instanceof RetryAbort ? false : 0)` — overriding it **replaces** that check
377
+ * (e.g. `on: (e) => (e.status === 404 ? false : 0)` aborts on a 404 with no `RetryAbort` wrapper).
378
+ * To keep retrying through a `RetryAbort`, override it (e.g. `on: () => 0`).
379
+ */
380
+ on?: (err: unknown) => MaybePromise$1<number | false>;
381
+ /** Called after a successful attempt. */
382
+ onSuccess?: (result: R, state: RetryState) => void;
383
+ /** Called after a failed attempt that will be retried, before backing off. */
384
+ onRetry?: (error: unknown, state: RetryState) => void;
385
+ /** Called when all attempts are exhausted, before throwing or returning `errorResult`. */
386
+ onError?: (error: unknown, state: RetryState) => void;
387
+ /** Sleep implementation (ms) — injectable for tests (default an unref'd timer). */
388
+ sleep?: (ms: number) => Promise<void>;
389
+ /** Randomness for jitter (default `Math.random`). */
390
+ random?: () => number;
391
+ /** Clock (default `Date.now`). */
392
+ now?: () => number;
393
+ }
394
+ /** The built-in defaults — the floor under {@link setDefaultRetryOptions} and per-call options. */
395
+ declare const DEFAULT_RETRY_OPTIONS: Required<Pick<RetryOptions, 'attempts' | 'base' | 'factor' | 'max' | 'jitter'>>;
396
+ /** Set process-wide default {@link RetryOptions} (merged over previous overrides). Per-call options still win. */
397
+ declare function setDefaultRetryOptions(options: RetryOptions): void;
398
+ /** The effective global defaults ({@link DEFAULT_RETRY_OPTIONS} plus any {@link setDefaultRetryOptions} overrides). */
399
+ declare function getDefaultRetryOptions(): RetryOptions;
400
+ /**
401
+ * Backoff delay for retry `attempt` (1 = the first retry):
402
+ * `min(base · factor^(attempt-1), max) · (1 − jitter · random())`.
403
+ */
404
+ declare function backoff(attempt: number, opts?: Pick<RetryOptions, 'base' | 'factor' | 'max' | 'jitter'>, random?: () => number): number;
405
+ /**
406
+ * Run `fn`, retrying on rejection with exponential backoff + jitter up to `attempts`
407
+ * times. Resolves with the first success; on exhaustion it returns `errorResult` if one
408
+ * was provided, otherwise re-throws the last error.
409
+ */
410
+ declare function retry<R>(fn: (state: RetryState) => Promise<R>, options?: RetryOptions<R>): Promise<R>;
411
+ //#endregion
412
+ //#endregion
413
+ //#region src/types.d.ts
414
+ /**
415
+ * A queueable, type-carrying unit of work produced by a {@link WorkBuilder}. Its `id`
416
+ * is assigned at build time (so you can reference it before queueing — e.g. to depend
417
+ * on it). `__out` is a phantom: it never exists at runtime, it only threads the output
418
+ * type through `enqueue`.
419
+ */
420
+ interface Work<Name extends string = string, O = unknown> {
421
+ /** Stable id, assigned when the instance is built. */
422
+ readonly id: string;
423
+ /** The work type name (the registry key). */
424
+ readonly type: Name;
425
+ /** The (already type-checked) input payload. */
426
+ readonly input: unknown;
427
+ /** Phantom output-type carrier — never present at runtime. */
428
+ readonly __out: O;
429
+ }
430
+ /** The execution context handed to a {@link WorkHandler}. */
431
+ interface WorkContext {
432
+ /** This work item's id. */
433
+ readonly id: string;
434
+ /** The group id shared by this item and everything it queues. */
435
+ readonly groupId: string;
436
+ /** Delivery attempt (1 = first try; higher after a retry). */
437
+ readonly attempt: number;
438
+ /** Queue child work **into the same group**; returns the child id(s). */
439
+ queue(work: Work, options?: WorkInstanceOptions): string;
440
+ queue(works: readonly Work[], options?: WorkInstanceOptions): string[];
441
+ /** Set the group's result (last-writer-wins) — what a top-level `await enqueue(...)` resolves to. */
442
+ setResult(result: unknown): void;
443
+ /** Read the current {@link WorkState} of other work items (for dependency-style coordination). */
444
+ states(ids: readonly string[]): Promise<(WorkState | undefined)[]>;
445
+ /** Win a one-time distributed claim for `key` (returns `true` once across the fleet). */
446
+ claim(key: string): Promise<boolean>;
447
+ }
448
+ /** A work handler: maps typed input (+ context) to typed output. */
449
+ type WorkHandler<I, O> = (input: I, ctx: WorkContext) => O | Promise<O>;
450
+ /**
451
+ * Per-instance options resolved at enqueue time and **serialized with the instance**.
452
+ * Provided at queue time, as per-type constants, or computed by {@link WorkOptions.options}.
453
+ */
454
+ interface WorkInstanceOptions {
455
+ /** Delay before the item becomes runnable (ms). Sets `startAt = queueAt + delay`. */
456
+ readonly delay?: number;
457
+ /**
458
+ * Absolute time the item should become runnable (epoch ms) — an alternative to {@link delay}
459
+ * (`delay = runAt - now`, wins over `delay`). Works for arbitrarily-far times even on backends
460
+ * that cap a single delay (e.g. SQS's 15-min `DelaySeconds`): the engine re-defers early arrivals
461
+ * until `runAt`. See also the handler-thrown `WorkDelayError`.
462
+ */
463
+ readonly runAt?: number;
464
+ /** Retry policy override for this item (`@ayepi/core`'s {@link RetryOptions}; callbacks apply to `skipQueue` work). */
465
+ readonly retry?: RetryOptions;
466
+ /** Scheduling priority (higher runs first) — consumed by the {@link Doer}. */
467
+ readonly priority?: number;
468
+ /** Fairness group label — consumed by `balancedDoer`. */
469
+ readonly group?: string;
470
+ /** Skip the durable queue and run this item directly via the doer (in-process; see {@link WorkOptions.skipQueue}). */
471
+ readonly skipQueue?: boolean;
472
+ }
473
+ /** Batch execution config for a work type (see {@link defineBatchWork}). */
474
+ interface BatchConfig<I, O> {
475
+ /** Flush when this many items are buffered. */
476
+ readonly size: number;
477
+ /** Flush a partial batch this long after the first item is buffered (ms). */
478
+ readonly maxWait: number;
479
+ /** Execute a whole batch at once; return one output per input, in the same order. */
480
+ readonly run: (inputs: I[]) => O[] | Promise<O[]>;
481
+ }
482
+ /**
483
+ * What a handler failure should do — the return of an {@link WorkOptions.onFailure} /
484
+ * {@link WorkSystemOptions.onFailure} classifier. Lets you treat, say, an API rate-limit (429) as
485
+ * "come back later" instead of a retry that burns the attempt budget and eventually dead-letters.
486
+ */
487
+ type FailureDecision = /** Re-enqueue with backoff, advancing `attempt` (dead-letters once exhausted) — the default if no classifier matches. */
488
+ 'retry'
489
+ /** Dead-letter the item now; no further attempts (a permanent failure). */ | 'abort'
490
+ /** Re-enqueue after `delay` ms **without** advancing `attempt` — a reschedule, e.g. honor a rate-limit's `Retry-After`. */ | {
491
+ readonly delay: number;
492
+ }
493
+ /** Re-enqueue to run at an absolute time (epoch ms) **without** advancing `attempt`. */ | {
494
+ readonly runAt: number;
495
+ };
496
+ /** Context passed to an {@link WorkOptions.onFailure} classifier. */
497
+ interface WorkFailureInfo {
498
+ readonly id: string;
499
+ readonly type: string;
500
+ /** The delivery attempt that just failed (1-based). */
501
+ readonly attempt: number;
502
+ /** Total attempts allowed for this item. */
503
+ readonly attempts: number;
504
+ }
505
+ /** Classify a handler error into a {@link FailureDecision}; `void` (the default) means retry/dead-letter by attempt count. */
506
+ type FailureClassifier = (err: unknown, info: WorkFailureInfo) => MaybePromise<FailureDecision | void>;
507
+ /** Per-work-type options passed to {@link defineWork}. */
508
+ interface WorkOptions<I> {
509
+ /** Default retry policy for this type (overridden per-instance by {@link WorkInstanceOptions.retry}). */
510
+ readonly retry?: RetryOptions;
511
+ /** Default scheduling priority for this type. */
512
+ readonly priority?: number;
513
+ /** Default fairness group for this type. */
514
+ readonly group?: string;
515
+ /** Dedicated doer for this type (defaults to the work system's doer) — caps this type's concurrency. */
516
+ readonly doer?: Doer$1;
517
+ /**
518
+ * Dedicated {@link Queue} for this type (defaults to the work system's queue). Several types can
519
+ * share one `Queue` instance; the engine polls **every** distinct queue each tick, so grouping
520
+ * types onto separate queues isolates a flooding type — it can't starve types on another queue.
521
+ * Compose with a per-type {@link doer} to also cap its concurrency.
522
+ */
523
+ readonly queue?: Queue;
524
+ /** Compute per-instance {@link WorkInstanceOptions} from the input (overridden by queue-time options). */
525
+ readonly options?: (input: I) => WorkInstanceOptions;
526
+ /** Per-type JSON codec (defaults to the system's global codec). */
527
+ readonly codec?: JsonCodec;
528
+ /** Per-type lifecycle hook (fired alongside the global {@link WorkSystemOptions.onEvent}). */
529
+ readonly onEvent?: (event: WorkEvent) => void;
530
+ /**
531
+ * Classify a handler failure for this type into a {@link FailureDecision} — `'abort'` (dead-letter
532
+ * now), a `{ delay }`/`{ runAt }` reschedule (re-queue without counting a retry, e.g. a rate limit),
533
+ * or `'retry'`/nothing for the default. Overrides {@link WorkSystemOptions.onFailure}. (A handler can
534
+ * also decide directly by throwing `RetryAbort` → dead-letter, or `WorkDelayError` → reschedule.)
535
+ */
536
+ readonly onFailure?: FailureClassifier;
537
+ /** Derive `logWith` context from this type's input (merged over the global hook). */
538
+ readonly logContext?: (input: I) => object;
539
+ /**
540
+ * Run this type **without the durable queue** — straight to the doer, in-process.
541
+ * Retries, grouping, priority, events, and results still work; there is no queue,
542
+ * store, or heartbeat (so no cross-instance durability). Per-instance
543
+ * {@link WorkInstanceOptions.skipQueue} overrides this.
544
+ */
545
+ readonly skipQueue?: boolean;
546
+ }
547
+ /** The full definition behind a {@link WorkBuilder}. */
548
+ interface WorkDefinition<I, O> {
549
+ readonly name: string;
550
+ readonly handler: WorkHandler<I, O>;
551
+ readonly options: WorkOptions<I>;
552
+ /** Present for batched work types (see {@link defineBatchWork}). */
553
+ readonly batch?: BatchConfig<I, O>;
554
+ }
555
+ /**
556
+ * A callable work builder. Invoke it with the work's input to mint a queueable
557
+ * {@link Work} (with a fresh id); it also exposes its `type` and underlying `def`.
558
+ */
559
+ interface WorkBuilder<Name extends string, I, O> {
560
+ /** Build a type-checked, queueable instance from this work's input. */
561
+ (input: I): Work<Name, O>;
562
+ /** The work type name. */
563
+ readonly type: Name;
564
+ /** The underlying definition (handler + options). */
565
+ readonly def: WorkDefinition<I, O>;
566
+ }
567
+ /** The loose base every {@link WorkBuilder} satisfies regardless of its input type. */
568
+ type AnyWorkBuilder = WorkBuilder<string, never, unknown>;
569
+ /**
570
+ * Define a work type. Returns a {@link WorkBuilder} — a function that builds queueable
571
+ * instances — typed by its input `I` and output `O`.
572
+ */
573
+ declare function defineWork<Name extends string, I, O>(name: Name, handler: WorkHandler<I, O>, opts?: WorkOptions<I>): WorkBuilder<Name, I, O>;
574
+ /**
575
+ * Define a **batched** work type. Items still enqueue, retry, prioritize, and join
576
+ * groups individually, but execute together via {@link BatchConfig.run} once `size`
577
+ * accumulate or `maxWait` ms elapse — so each `.result()` resolves to its aligned
578
+ * output. The per-type {@link WorkOptions.doer} governs how many *batches* run at once.
579
+ */
580
+ declare function defineBatchWork<Name extends string, I, O>(name: Name, config: BatchConfig<I, O> & WorkOptions<I>): WorkBuilder<Name, I, O>;
581
+ /** The input type a builder accepts. */
582
+ type InputOf<B> = B extends ((input: infer I) => unknown) ? I : never;
583
+ /** The output type a builder's instances carry. */
584
+ type OutputOf<B> = B extends ((...args: never[]) => Work<string, infer O>) ? O : never;
585
+ /** The name of a builder. */
586
+ type NameOf<B> = B extends {
587
+ readonly type: infer N extends string;
588
+ } ? N : never;
589
+ /** The output type carried by a {@link Work}. */
590
+ type OutputOfWork<W> = W extends Work<string, infer O> ? O : unknown;
591
+ /** Drop `void`/`undefined` from a union (work that returns "nothing"). */
592
+ type NonVoidUnion<U> = Exclude<U, void | undefined>;
593
+ /** The union of every registered work name. */
594
+ type RegistryNames<Defs extends readonly AnyWorkBuilder[]> = NameOf<Defs[number]>;
595
+ /** The builder in the registry with name `K`. */
596
+ type BuilderForName<Defs extends readonly AnyWorkBuilder[], K extends string> = Extract<Defs[number], {
597
+ readonly type: K;
598
+ }>;
599
+ /** The input type of the registry's work named `K`. */
600
+ type InputForName<Defs extends readonly AnyWorkBuilder[], K extends string> = InputOf<BuilderForName<Defs, K>>;
601
+ /** The output type of the registry's work named `K`. */
602
+ type OutputForName<Defs extends readonly AnyWorkBuilder[], K extends string> = OutputOf<BuilderForName<Defs, K>>;
603
+ /**
604
+ * The group's final result type: the union of every registered work's non-void
605
+ * output. The value a top-level `await enqueue(...)` resolves to.
606
+ */
607
+ type GroupResult<Defs extends readonly AnyWorkBuilder[]> = NonVoidUnion<OutputOf<Defs[number]>>;
608
+ /**
609
+ * The thenable returned by `enqueue`. **Awaiting it resolves to the group result**
610
+ * ({@link GroupResult}); use {@link result} for this item's own output and
611
+ * {@link group} for the explicit group form.
612
+ */
613
+ interface WorkHandle<Self, Group> extends PromiseLike<Group> {
614
+ /** This work item's id. */
615
+ readonly id: string;
616
+ /** The group id (this item plus everything queued under it). */
617
+ readonly groupId: string;
618
+ /** Resolve to **this item's** own output. */
619
+ result(): Promise<Self>;
620
+ /** Resolve to the **group's** final result (same as awaiting the handle). */
621
+ group(): Promise<Group>;
622
+ }
623
+ /** Lifecycle status of a single work item. */
624
+ type WorkStatus = 'pending' | 'running' | 'success' | 'failed' | 'dead';
625
+ /** A snapshot of one work item's state. */
626
+ interface WorkState {
627
+ readonly id: string;
628
+ readonly type: string;
629
+ readonly status: WorkStatus;
630
+ readonly attempt: number;
631
+ readonly result?: unknown;
632
+ readonly error?: string;
633
+ /** When the item was enqueued (epoch ms). */
634
+ readonly queueAt: number;
635
+ /** Scheduled earliest start (epoch ms) — `queueAt + delay`. */
636
+ readonly startAt: number;
637
+ /** When execution actually began (epoch ms), if it has. */
638
+ readonly runAt?: number;
639
+ /** When the item reached a terminal state (epoch ms), if it has. */
640
+ readonly endAt?: number;
641
+ /** Scheduling priority. */
642
+ readonly priority?: number;
643
+ /** Fairness group label. */
644
+ readonly group?: string;
645
+ }
646
+ /** A unit of work currently held by this instance (polled and accepted, not skipped). */
647
+ interface ActiveWork {
648
+ readonly id: string;
649
+ readonly type: string;
650
+ readonly groupId: string;
651
+ /** `'pending'` = accepted into the doer, awaiting a slot; `'running'` = executing. */
652
+ readonly status: 'pending' | 'running';
653
+ readonly attempt: number;
654
+ readonly priority: number;
655
+ readonly group?: string;
656
+ readonly queueAt: number;
657
+ readonly startAt: number;
658
+ /** When execution began, if it has. */
659
+ readonly runAt?: number;
660
+ }
661
+ /**
662
+ * A lifecycle event delivered to {@link WorkSystemOptions.onEvent}. `'failed'` covers
663
+ * both a retry (`willRetry: true`) and the terminal dead-letter (`willRetry: false`);
664
+ * `'deferred'` is a reschedule (a handler threw `WorkDelayError`) — it does **not** advance the
665
+ * attempt count. (An item put back merely because it arrived before its `startAt` is silent.)
666
+ */
667
+ type WorkEvent = {
668
+ readonly kind: 'queued';
669
+ readonly id: string;
670
+ readonly type: string;
671
+ readonly groupId: string;
672
+ readonly at: number;
673
+ } | {
674
+ readonly kind: 'started';
675
+ readonly id: string;
676
+ readonly type: string;
677
+ readonly groupId: string;
678
+ readonly attempt: number;
679
+ readonly at: number;
680
+ } | {
681
+ readonly kind: 'deferred';
682
+ readonly id: string;
683
+ readonly type: string;
684
+ readonly groupId: string;
685
+ readonly runAt: number;
686
+ readonly at: number;
687
+ } | {
688
+ readonly kind: 'succeeded';
689
+ readonly id: string;
690
+ readonly type: string;
691
+ readonly groupId: string;
692
+ readonly attempt: number;
693
+ readonly result: unknown;
694
+ readonly at: number;
695
+ } | {
696
+ readonly kind: 'failed';
697
+ readonly id: string;
698
+ readonly type: string;
699
+ readonly groupId: string;
700
+ readonly attempt: number;
701
+ readonly error: string;
702
+ readonly willRetry: boolean;
703
+ readonly at: number;
704
+ } | {
705
+ readonly kind: 'group-done';
706
+ readonly groupId: string;
707
+ readonly result: unknown;
708
+ readonly at: number;
709
+ };
710
+ /** What {@link WorkSystemOptions.accept} receives to decide whether *this* instance should run an item. */
711
+ interface WorkAcceptInfo {
712
+ readonly id: string;
713
+ readonly type: string;
714
+ readonly groupId: string;
715
+ readonly attempt: number;
716
+ readonly input: unknown;
717
+ }
718
+ /**
719
+ * What a {@link WorkSystemOptions.backpressure} hook receives each poll: a live per-type
720
+ * {@link WorkStats} snapshot and the total in-flight count. Lets the hook adapt the pause to
721
+ * observed throughput/latency (see the bundled `adaptiveDelay` helper). The hook may still take
722
+ * no arguments — the context is optional.
723
+ */
724
+ interface BackpressureContext {
725
+ /** The live metrics registry — read per-type counters/gauges/summaries (same as {@link WorkSystem.metrics}). */
726
+ readonly metrics: Metrics;
727
+ /** Total items this instance is currently holding (polled + accepted, across all types). */
728
+ readonly active: number;
729
+ }
730
+ /** Passed to {@link WorkSystemOptions.unhandledWorkGroup} when a group finishes with no waiter. */
731
+ interface UnhandledWorkGroupInfo {
732
+ readonly groupId: string;
733
+ readonly lastResult: unknown;
734
+ readonly states: readonly WorkState[];
735
+ }
736
+ /** When a dependency fires. JSON-serializable so it runs on any instance. */
737
+ type DependencyCondition = /** Every watched item reached a terminal state (success, failed, or dead). */
738
+ 'all-done'
739
+ /** Every watched item succeeded. */ | 'all-success'
740
+ /** At least `count` watched items reached the given state (`'done'` default). */ | {
741
+ readonly count: number;
742
+ readonly of?: 'done' | 'success';
743
+ };
744
+ /** A recurring schedule: a cron expression **or** a next-time function, plus what to run. */
745
+ interface ScheduleConfig {
746
+ /** Unique schedule name (also the distributed firing-lease key). */
747
+ readonly name: string;
748
+ /** A 5-field cron expression (`min hour dom mon dow`). Mutually exclusive with {@link next}. */
749
+ readonly cron?: string;
750
+ /** Compute the next fire time from `now` (epoch ms) — return ms/`Date`, or void to stop. */
751
+ readonly next?: (now: number) => number | Date | void;
752
+ /** Produce the work instance to enqueue on each fire (or enqueue yourself and return void). */
753
+ readonly run: () => Work | void;
754
+ }
755
+ /** Options for `createWork`. Every field has a sensible default; `createWork()` works zero-config. */
756
+ interface WorkSystemOptions {
757
+ /** The durable queue port (default: bundled {@link memoryQueue}). */
758
+ readonly queue?: Backend['queue'];
759
+ /** The cross-instance pub/sub port (default: bundled {@link memoryPubSub}). */
760
+ readonly pubsub?: Backend['pubsub'];
761
+ /** The key/value store port (default: bundled {@link memoryStore}). */
762
+ readonly store?: Backend['store'];
763
+ /** Default retry policy for this system, over `@ayepi/core`'s global defaults (per-type/instance `retry` override). */
764
+ readonly retry?: RetryOptions;
765
+ /**
766
+ * Resilience wrapper for the engine's **load-bearing** port writes (state/result/group store
767
+ * writes, queue push/ack). When set, each such call is retried with these options so a transient
768
+ * queue/store blip is absorbed before it reaches the engine's commit/queue handling; on exhaustion
769
+ * the error surfaces as usual (a commit error is reported via {@link onError}, never re-runs the
770
+ * handler). Off by default — best for backends without their own retry; the bundled Redis/SQS
771
+ * backends already retry per call, so this is an additional engine-level safety net.
772
+ */
773
+ readonly portRetry?: Omit<RetryOptions, 'errorResult'>;
774
+ /** Global doer governing concurrency + ordering (default {@link unlimitedDoer}). Per-type `doer` overrides. */
775
+ readonly doer?: Doer$1;
776
+ /** Queue poll interval when idle (ms, default 1000). */
777
+ readonly pollInterval?: number;
778
+ /**
779
+ * Dynamic backpressure, checked before **every** poll. Return a number of **milliseconds to
780
+ * pause** before taking any work — even when doers have free slots — or `0`/nothing to proceed
781
+ * (the default). The loop sleeps the returned time, then checks again, so it's re-polled until it
782
+ * returns `0`. Use it to stop pulling work while an external resource is saturated (a database at
783
+ * capacity, a downstream API rate-limited, a memory ceiling). May be async. A throwing
784
+ * `backpressure` is reported via {@link onError} (`'queue'`) and the loop backs off `pollInterval`.
785
+ * Prefer a modest interval (it also bounds how long `stop()` waits for the loop to exit).
786
+ *
787
+ * The hook receives a {@link BackpressureContext} (a live {@link WorkStats} snapshot + the
788
+ * in-flight count) so the pause can adapt to observed throughput/latency — pass the bundled
789
+ * `adaptiveDelay()` helper, or read `ctx.stats` yourself. (Taking no arguments is still valid.)
790
+ */
791
+ readonly backpressure?: (ctx: BackpressureContext) => MaybePromise<number | void>;
792
+ /** Lease/visibility timeout for a pulled item (ms, default 30000). */
793
+ readonly visibility?: number;
794
+ /** Heartbeat interval extending the lease (ms, default `visibility / 3`). */
795
+ readonly heartbeat?: number;
796
+ /** Key namespace for every store/queue key (default `'work:'`). */
797
+ readonly prefix?: string;
798
+ /** Global JSON codec (per-type `codec` overrides win). */
799
+ readonly codec?: JsonCodec;
800
+ /** Wrap each handler in a context scope (e.g. `@ayepi/log`'s `logWith`). */
801
+ readonly logWith?: LogWith;
802
+ /** Global hook: derive `logWith` context from every work's input + type. */
803
+ readonly logContext?: (input: unknown, type: string) => object;
804
+ /** Global lifecycle hook — fired for queued/started/succeeded/failed/group-done (never throws into the engine). */
805
+ readonly onEvent?: (event: WorkEvent) => void;
806
+ /**
807
+ * Observe a **non-critical** engine error that was swallowed to keep work flowing — so it
808
+ * can't be mistaken for a handler failure. `phase` is `'commit'` (recording a result that
809
+ * the handler **already produced** — the store/queue-ack/pub-sub after success; reported,
810
+ * **never retried**) or `'queue'` (a poll/routing error in the worker loop; the loop sleeps
811
+ * and continues). A handler's own error is **not** routed here — it retries/dead-letters as
812
+ * usual. Off by default; it must not throw — if it does, the throw is ignored.
813
+ */
814
+ readonly onError?: (err: unknown, phase: 'commit' | 'queue') => void;
815
+ /**
816
+ * Default classifier for handler failures (a per-type {@link WorkOptions.onFailure} overrides it):
817
+ * map an error to `'abort'` (dead-letter now), a `{ delay }`/`{ runAt }` reschedule (re-queue
818
+ * without burning a retry — e.g. a rate limit), or `'retry'`/nothing for the normal attempt-counted
819
+ * behavior. A throwing classifier is reported and falls back to the default.
820
+ */
821
+ readonly onFailure?: FailureClassifier;
822
+ /**
823
+ * A readable dead-letter {@link Queue} to **redrive** from. When the normal queue(s) are idle
824
+ * (a poll round pulled nothing) and there is free capacity, the loop transfers up to
825
+ * {@link redriveCount} bodies from here back onto their type's queue as **fresh** work
826
+ * (`attempt` reset to 1, full retry budget) and acks them off the DLQ. Use it to automatically
827
+ * reprocess dead-lettered items once a downstream recovers. Point it at the same sink your
828
+ * queue's `deadLetter` writes to (an unparseable body is dropped). Off by default.
829
+ */
830
+ readonly dlq?: Queue;
831
+ /** Max messages to redrive from {@link dlq} per idle poll (default 10; `0` disables redrive). */
832
+ readonly redriveCount?: number;
833
+ /**
834
+ * Instance affinity. Return `false` to decline an item on this instance (it is
835
+ * deferred for another to pick up) — lets you shard work types across a fleet.
836
+ */
837
+ readonly accept?: (info: WorkAcceptInfo) => boolean;
838
+ /** Called once when a group finishes with a result but nobody was waiting for it. */
839
+ readonly unhandledWorkGroup?: (info: UnhandledWorkGroupInfo) => void;
840
+ /**
841
+ * The {@link Metrics} registry the engine records per-type stats into (default: a fresh
842
+ * `createMetrics()`). Provide one to enable summary **quantiles** (`createMetrics({ quantiles:
843
+ * [0.5, 0.95, 0.99] })`), share a registry across several systems, or subscribe to changes.
844
+ * Exposed back as {@link WorkSystem.metrics}.
845
+ */
846
+ readonly metrics?: Metrics;
847
+ /** Start the worker loop immediately (default true). */
848
+ readonly autoStart?: boolean;
849
+ /** Clock injection for tests (default `Date.now`). */
850
+ readonly now?: Clock;
851
+ /** Randomness injection for backoff jitter (default `Math.random`). */
852
+ readonly random?: () => number;
853
+ }
854
+ /** The work system returned by `createWork`. */
855
+ interface WorkSystem<Defs extends readonly AnyWorkBuilder[]> {
856
+ /** Enqueue a built instance; await the handle for the group result, `.result()` for its own. */
857
+ enqueue<W extends Work>(work: W, options?: WorkInstanceOptions): WorkHandle<OutputOfWork<W>, GroupResult<Defs>>;
858
+ /** Enqueue by registered name with a type-checked input. */
859
+ enqueue<K extends RegistryNames<Defs>>(name: K, input: InputForName<Defs, K>, options?: WorkInstanceOptions): WorkHandle<OutputForName<Defs, K>, GroupResult<Defs>>;
860
+ /** Register a recurring schedule; returns a cancel function. */
861
+ schedule(config: ScheduleConfig): () => void;
862
+ /** Start the worker + scheduler loops (idempotent). */
863
+ start(): void;
864
+ /** Stop the loops and flush in-flight heartbeats (idempotent). */
865
+ stop(): Promise<void>;
866
+ /** Snapshot of known work states (best-effort). */
867
+ list(): Promise<WorkState[]>;
868
+ /** Work currently held by this instance — polled and accepted (will not be skipped). */
869
+ active(): ActiveWork[];
870
+ /**
871
+ * A flat snapshot of every per-type metric series (counters/gauges/summaries), labelled by work
872
+ * `type` — a convenience for `metrics.list()`. Pass to `formatPrometheus`, or read individual
873
+ * series via {@link metrics}. See `@ayepi/core`'s {@link StatValue}.
874
+ */
875
+ stats(): StatValue[];
876
+ /**
877
+ * The live {@link Metrics} registry the engine records into — `list()` / `get(name, { type })` /
878
+ * `subscribe()` for change notifications. Bring your own via {@link WorkSystemOptions.metrics}
879
+ * (e.g. to enable quantiles or share one registry across systems).
880
+ */
881
+ readonly metrics: Metrics;
882
+ /** The underlying ports (queue/pubsub/store) — useful in tests and for sharing a backend. */
883
+ readonly backend: Backend;
884
+ }
885
+ //#endregion
886
+ //#region src/engine.d.ts
887
+ /**
888
+ * Create a work system. Zero-config (`createWork()`) uses the bundled in-memory backend
889
+ * and an {@link unlimitedDoer}; pass `work: [...] as const` for a typed registry, a
890
+ * `doer` to govern concurrency, and/or `queue`/`pubsub`/`store` to go distributed.
891
+ */
892
+ declare function createWork<const Defs extends readonly AnyWorkBuilder[]>(opts?: WorkSystemOptions & {
893
+ work?: Defs;
894
+ }): WorkSystem<Defs>;
895
+ //#endregion
896
+ //#region src/errors.d.ts
897
+ /**
898
+ * # Work errors
899
+ *
900
+ * {@link WorkDelayError} — throw it from a handler to **defer** the item to a later time
901
+ * instead of completing or failing it. It is a **reschedule, not a retry**: the attempt count
902
+ * is unchanged, so a handler can defer indefinitely (e.g. "the upstream isn't ready, try me in
903
+ * 5 minutes"). The item is re-enqueued to run at the resolved time; a far-future time is honored
904
+ * even on backends that cap a single delay (the engine re-defers early arrivals).
905
+ *
906
+ * @module
907
+ */
908
+ /** When a {@link WorkDelayError} wants the work to run: an absolute time, a relative delay, or both. */
909
+ interface WorkDelaySpec {
910
+ /** Absolute time to run at (epoch ms). Wins over {@link delay}. */
911
+ readonly runAt?: number;
912
+ /** Relative delay from now (ms). `runAt` is computed as `now + delay`. */
913
+ readonly delay?: number;
914
+ }
915
+ /**
916
+ * Throw from a work handler to **defer** the item to a future time (a reschedule, not a failure).
917
+ *
918
+ * ```ts
919
+ * defineWork('poll', async (input, ctx) => {
920
+ * if (!(await upstreamReady())) throw new WorkDelayError({ delay: 5 * 60_000 }); // retry in 5 min, attempt unchanged
921
+ * return doWork(input);
922
+ * });
923
+ * ```
924
+ */
925
+ declare class WorkDelayError extends Error {
926
+ /** When the item should next run. */
927
+ readonly when: WorkDelaySpec;
928
+ constructor(/** When the item should next run. */
929
+ when: WorkDelaySpec, message?: string);
930
+ }
931
+ //#endregion
932
+ //#region src/stats.d.ts
933
+ /** Metric names (dotted; `formatPrometheus` sanitizes the dots to underscores). Exported so consumers can reference series by name. */
934
+ declare const WORK_METRICS: {
935
+ readonly queued: "work.queued";
936
+ readonly started: "work.started";
937
+ readonly succeeded: "work.succeeded";
938
+ readonly failed: "work.failed";
939
+ readonly retried: "work.retried";
940
+ readonly deferred: "work.deferred";
941
+ readonly rescheduled: "work.rescheduled";
942
+ readonly active: "work.active";
943
+ readonly pending: "work.pending";
944
+ readonly running: "work.running";
945
+ readonly peak: "work.peak_active";
946
+ readonly lastQueued: "work.last_queued_at";
947
+ readonly lastStarted: "work.last_started_at";
948
+ readonly lastSucceeded: "work.last_succeeded_at";
949
+ readonly lastFailed: "work.last_failed_at";
950
+ readonly waitTime: "work.wait_time";
951
+ readonly totalTime: "work.total_time";
952
+ readonly successTime: "work.success_time";
953
+ readonly errorTime: "work.error_time";
954
+ readonly delayTime: "work.delay_time";
955
+ readonly rescheduleTime: "work.reschedule_time";
956
+ readonly attempts: "work.attempts";
957
+ };
958
+ /** The recorder the engine feeds; all durations are **milliseconds**, timestamps epoch ms. */
959
+ //#endregion
960
+ //#region src/adaptive.d.ts
961
+ /** Options for {@link adaptiveDelay}. All times are **milliseconds**. */
962
+ interface AdaptiveDelayOptions {
963
+ /** Restrict the failure-rate calculation to these work types (default: every type in the snapshot). */
964
+ readonly types?: readonly string[];
965
+ /** Backoff triggers when an interval's `failed / (succeeded + failed)` exceeds this (default `0` — any failure). */
966
+ readonly maxFailRate?: number;
967
+ /** Pause floor returned while healthy (default `0` — no pause when all is well). */
968
+ readonly min?: number;
969
+ /** Pause ceiling — the backoff never grows past this (default `30000`). */
970
+ readonly max?: number;
971
+ /** The first non-zero pause when backoff starts (default `100`). */
972
+ readonly base?: number;
973
+ /** Multiplier applied to the current pause on each unhealthy interval (default `2`). */
974
+ readonly factor?: number;
975
+ /** Amount subtracted from the pause on each healthy interval (default `base`). */
976
+ readonly step?: number;
977
+ }
978
+ /** A stateful backpressure function: feed it the per-poll {@link BackpressureContext}, it returns the pause (ms). */
979
+ type AdaptiveDelay = (ctx: BackpressureContext) => number;
980
+ /**
981
+ * Build an {@link AdaptiveDelay} controller for {@link WorkSystemOptions.backpressure}. It holds a
982
+ * little state (the current pause + the last cumulative counts) across calls, so create **one** per
983
+ * work system. Watching a subset of `types` lets one system protect a specific downstream.
984
+ */
985
+ declare function adaptiveDelay(opts?: AdaptiveDelayOptions): AdaptiveDelay;
986
+ //#endregion
987
+ //#region src/memory.d.ts
988
+ /** Options shared by the in-memory ports (mainly the injectable clock). */
989
+ interface MemoryOptions {
990
+ /** Clock injection for deterministic tests (default `Date.now`). */
991
+ readonly now?: Clock;
992
+ }
993
+ /**
994
+ * The minimal **synchronous** filesystem surface the file-backed {@link memoryQueue} uses.
995
+ * `node:fs` satisfies it (the default); tests inject their own. Writes are synchronous so the
996
+ * queue's own synchronous operations stay synchronous.
997
+ */
998
+ interface QueueFsLike {
999
+ /** Read a file's contents, or `undefined` when it doesn't exist. */
1000
+ readFile(path: string): string | undefined;
1001
+ /** Write (overwrite) a file. */
1002
+ writeFile(path: string, data: string): void;
1003
+ /** Rename a file (used for an atomic temp → target swap). */
1004
+ rename(from: string, to: string): void;
1005
+ /** Ensure a directory exists (recursive; called once before the first write). */
1006
+ mkdir(path: string): void;
1007
+ }
1008
+ /**
1009
+ * In-process {@link PubSub} (the `localBroker` shape). Share one instance across
1010
+ * engines to fan a publish out to every "pod".
1011
+ */
1012
+ declare function memoryPubSub(): PubSub;
1013
+ /**
1014
+ * In-memory {@link Store} with lazy TTL expiry and an atomic {@link Store.setIfNotExists}.
1015
+ * The compare-and-set every distributed claim relies on.
1016
+ */
1017
+ declare function memoryStore(opts?: MemoryOptions): Store;
1018
+ /** Dead-lettered bodies, exposed for inspection in tests via {@link MemoryQueue.dead}. */
1019
+ interface DeadLettered {
1020
+ readonly body: string;
1021
+ readonly error: string;
1022
+ }
1023
+ /** A {@link Queue} plus the in-memory extras tests reach for (its operations are synchronous). */
1024
+ interface MemoryQueue extends Queue {
1025
+ /** Lease up to `max` visible items (synchronous — reclaims expired leases first). */
1026
+ pop(max: number, visibility: number): PulledWork[];
1027
+ /** Items currently moved to the dead-letter sink. */
1028
+ readonly dead: readonly DeadLettered[];
1029
+ /** Count of items still in the queue (leased or visible). */
1030
+ size(): number;
1031
+ }
1032
+ /** File-persistence options for {@link memoryQueue} — single-process durability. */
1033
+ interface MemoryQueuePersistence {
1034
+ /**
1035
+ * Persist the queue (pending items + dead-letter sink) to this file so work survives a
1036
+ * process restart. Writes are atomic (a temp file is renamed over the target) and happen
1037
+ * after every mutation. On startup the file is reloaded. Omit for a pure in-memory queue.
1038
+ */
1039
+ readonly file?: string;
1040
+ /** Injected filesystem (default synchronous `node:fs`). For tests, or a custom backing store. */
1041
+ readonly fs?: QueueFsLike;
1042
+ /**
1043
+ * Observe a (best-effort) persistence error — a load that found a corrupt file, or a failed
1044
+ * write. Persistence never throws into the engine: the in-memory state stays authoritative
1045
+ * for the running process and the failure is reported here. Off by default; must not throw.
1046
+ */
1047
+ readonly onError?: (err: unknown) => void;
1048
+ }
1049
+ /** Options for {@link memoryQueue}: the shared clock plus optional file {@link MemoryQueuePersistence}. */
1050
+ interface MemoryQueueOptions extends MemoryOptions, MemoryQueuePersistence {}
1051
+ /**
1052
+ * In-memory {@link Queue} with visibility-timeout leasing. `pop` first **reclaims**
1053
+ * items whose lease expired (a dead worker → redelivery, `attempt++`), then leases
1054
+ * fresh visible items. `ack`/`heartbeat`/`fail` are token-gated, so a stale worker
1055
+ * whose lease already lapsed cannot ack work another worker now owns.
1056
+ *
1057
+ * Pass `file` to make it **durable**: state is reloaded on construction and rewritten
1058
+ * atomically after every mutation. A heartbeat is *not* persisted (lease expiry is
1059
+ * reset on reload anyway), so steady-state heartbeating doesn't touch the disk.
1060
+ */
1061
+ declare function memoryQueue(opts?: MemoryQueueOptions): MemoryQueue;
1062
+ /** Options for {@link memoryBackend}: the shared clock plus optional queue file persistence. */
1063
+ interface MemoryBackendOptions extends MemoryOptions {
1064
+ /**
1065
+ * File-persistence for the bundled queue (the store and pub/sub stay purely in-memory).
1066
+ * Pass `{ file: '…' }` to make pending work survive a process restart.
1067
+ */
1068
+ readonly queue?: MemoryQueuePersistence;
1069
+ }
1070
+ /**
1071
+ * The three in-memory ports together, sharing one clock. The default backend when
1072
+ * `createWork()` is called with no ports.
1073
+ *
1074
+ * @example A two-pod test on one shared backend:
1075
+ * ```ts
1076
+ * const backend = memoryBackend()
1077
+ * const podA = createWork({ ...backend, work: [add] })
1078
+ * const podB = createWork({ ...backend, work: [add] })
1079
+ * ```
1080
+ *
1081
+ * @example A single durable process — pending work survives a restart:
1082
+ * ```ts
1083
+ * const backend = memoryBackend({ queue: { file: './work-queue.json' } })
1084
+ * const work = createWork({ ...backend, work: [add] })
1085
+ * ```
1086
+ */
1087
+ declare function memoryBackend(opts?: MemoryBackendOptions): Backend;
1088
+ //#endregion
1089
+ //#region src/dependency.d.ts
1090
+ /** The built-in work type name for a dependency. */
1091
+ declare const DEPENDENCY_TYPE = "@work/dependency";
1092
+ /** A dependent serialized into a dependency's input (a minimal {@link Work}). */
1093
+
1094
+ /** Options for {@link dependency}. */
1095
+ interface DependencyOptions {
1096
+ /** Works (or their ids) to wait on. */
1097
+ readonly on: readonly (string | Work)[];
1098
+ /** Works to queue, into the same group, once satisfied. */
1099
+ readonly queue: readonly Work[];
1100
+ /** When to fire (default `'all-success'`). */
1101
+ readonly config?: DependencyCondition;
1102
+ /** Re-check interval (ms, default 1000). */
1103
+ readonly poll?: number;
1104
+ /** Give up (dead-letter) after this long (ms). */
1105
+ readonly timeout?: number;
1106
+ }
1107
+ /**
1108
+ * Evaluate a {@link DependencyCondition} against the watched items' states. A missing
1109
+ * state counts as "not yet done". Pure and JSON-driven, so every instance agrees.
1110
+ */
1111
+ declare function conditionMet(condition: DependencyCondition, states: readonly (WorkState | undefined)[]): boolean;
1112
+ /**
1113
+ * Build a dependency: when the works it waits `on` satisfy `config`, it queues its
1114
+ * `queue` dependents (once). Enqueue it like any work.
1115
+ */
1116
+ declare function dependency(opts: DependencyOptions): Work<typeof DEPENDENCY_TYPE, void>;
1117
+ /**
1118
+ * The non-blocking handler for {@link DEPENDENCY_TYPE}: check once, then fire or
1119
+ * re-queue. Registered automatically by every work system. Remembers terminal statuses
1120
+ * (`resolved`) so it neither re-reads settled works nor treats an evicted state as a
1121
+ * failure.
1122
+ */
1123
+ //#endregion
1124
+ //#region src/schedule.d.ts
1125
+ /** A parsed cron expression: a matching set per field. */
1126
+ interface CronFields {
1127
+ readonly minute: Set<number>;
1128
+ readonly hour: Set<number>;
1129
+ readonly dom: Set<number>;
1130
+ readonly month: Set<number>;
1131
+ readonly dow: Set<number>;
1132
+ /** Whether dom / dow were restricted (not `*`) — drives the OR semantics. */
1133
+ readonly domRestricted: boolean;
1134
+ readonly dowRestricted: boolean;
1135
+ }
1136
+ /** Parse a 5-field cron expression (`min hour dom mon dow`). */
1137
+ declare function parseCron(expr: string): CronFields;
1138
+ /**
1139
+ * The next epoch-ms strictly after `fromMs` that matches `expr`, or `undefined` if
1140
+ * none within ~a year. Minute-granular (cron's resolution).
1141
+ */
1142
+ declare function nextAfter(expr: string, fromMs: number): number | undefined;
1143
+ /** Engine-supplied dependencies for {@link startSchedule}. */
1144
+
1145
+ //#endregion
1146
+ //#region src/index.d.ts
1147
+ /** The default (registry-less) work system. Most apps call {@link createWork} with their own registry instead. */
1148
+ declare const work: WorkSystem<readonly AnyWorkBuilder[]>;
1149
+ /** Enqueue on the default system (instance form). */
1150
+ declare const enqueue: {
1151
+ <W extends Work>(work: W, options?: WorkInstanceOptions): WorkHandle<OutputOfWork<W>, unknown>;
1152
+ <K extends string>(name: K, input: InputOf<Extract<AnyWorkBuilder, {
1153
+ readonly type: K;
1154
+ }>>, options?: WorkInstanceOptions): WorkHandle<OutputOf<Extract<AnyWorkBuilder, {
1155
+ readonly type: K;
1156
+ }>>, unknown>;
1157
+ };
1158
+ /** Register a recurring schedule on the default system. */
1159
+ declare const schedule: (config: ScheduleConfig) => () => void;
1160
+ /** Start the default system's worker loop. */
1161
+ declare const start: () => void;
1162
+ /** Stop the default system. */
1163
+ declare const stop: () => Promise<void>;
1164
+ /** Snapshot the default system's known work states. */
1165
+ declare const list: () => Promise<WorkState[]>;
1166
+ //#endregion
1167
+ export { type ActiveWork, type AdaptiveDelay, type AdaptiveDelayOptions, type AnyWorkBuilder, type Backend, type BackpressureContext, type BatchConfig, type BoundedDoerOptions, type BuilderForName, type Counter, DEFAULT_BUCKETS, DEFAULT_RETRY_OPTIONS, DEPENDENCY_TYPE, type DeadLettered, type DependencyCondition, type DependencyOptions, type Doer, type DoerTaskOptions, type FailureClassifier, type FailureDecision, type Gauge, type GroupResult, type InputForName, type InputOf, type JsonCodec, type Labels, type MemoryBackendOptions, type MemoryOptions, type MemoryQueue, type MemoryQueueOptions, type MemoryQueuePersistence, type Metrics, type MetricsOptions, type NameOf, type NonVoidUnion, type OutputForName, type OutputOf, type OutputOfWork, type PubSub, type PulledWork, type PushOptions, type Queue, type QueueFsLike, type RegistryNames, RetryAbort, type RetryOptions, type RetryState, type ScheduleConfig, type StatBucket, type StatKind, type StatMeta, type StatSummary, type StatValue, type Store, type Summary, type UnhandledWorkGroupInfo, type UnlimitedDoerOptions, WORK_METRICS, type Work, type WorkAcceptInfo, type WorkBuilder, type WorkContext, type WorkDefinition, WorkDelayError, type WorkDelaySpec, type WorkEvent, type WorkFailureInfo, type WorkHandle, type WorkHandler, type WorkInstanceOptions, type WorkOptions, type WorkState, type WorkStatus, type WorkSystem, type WorkSystemOptions, adaptiveDelay, ageDoer, backoff, balancedDoer, conditionMet, createMetrics, createWork, defaultCodec, defineBatchWork, defineWork, dependency, enqueue, formatPrometheus, getDefaultRetryOptions, list, memoryBackend, memoryPubSub, memoryQueue, memoryStore, nextAfter, parseCron, priorityDoer, retry, schedule, setDefaultRetryOptions, start, stop, unlimitedDoer, work };