ingenium 0.0.1

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.
Files changed (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +943 -0
  3. package/dist/index.cjs +7078 -0
  4. package/dist/index.cjs.map +1 -0
  5. package/dist/index.d.cts +4262 -0
  6. package/dist/index.d.ts +4262 -0
  7. package/dist/index.js +6963 -0
  8. package/dist/index.js.map +1 -0
  9. package/package.json +47 -0
  10. package/src/api-key/middleware.ts +157 -0
  11. package/src/api-key/types.ts +37 -0
  12. package/src/app/scope.ts +392 -0
  13. package/src/app.ts +1752 -0
  14. package/src/body/limit.ts +21 -0
  15. package/src/body/middleware.ts +30 -0
  16. package/src/body/multipart-types.ts +40 -0
  17. package/src/body/multipart.ts +254 -0
  18. package/src/context/body.ts +324 -0
  19. package/src/context/context.ts +650 -0
  20. package/src/context/cookies.ts +282 -0
  21. package/src/context/pool.ts +32 -0
  22. package/src/cors/middleware.ts +182 -0
  23. package/src/cors/types.ts +79 -0
  24. package/src/cron/parser.ts +311 -0
  25. package/src/cron/registry.ts +49 -0
  26. package/src/cron/scheduler.ts +153 -0
  27. package/src/csrf/middleware.ts +224 -0
  28. package/src/csrf/types.ts +65 -0
  29. package/src/errors.ts +148 -0
  30. package/src/idempotency/middleware.ts +197 -0
  31. package/src/idempotency/store.ts +70 -0
  32. package/src/idempotency/types.ts +87 -0
  33. package/src/index.ts +328 -0
  34. package/src/jobs/queue.ts +306 -0
  35. package/src/jobs/registry.ts +82 -0
  36. package/src/jobs/store-memory.ts +113 -0
  37. package/src/jobs/types.ts +135 -0
  38. package/src/jwt/jwks.ts +143 -0
  39. package/src/jwt/middleware.ts +313 -0
  40. package/src/jwt/types.ts +137 -0
  41. package/src/jwt/verify.ts +370 -0
  42. package/src/middleware/compose.ts +94 -0
  43. package/src/middleware/types.ts +37 -0
  44. package/src/negotiation/accept.ts +159 -0
  45. package/src/negotiation/etag.ts +30 -0
  46. package/src/negotiation/format.ts +88 -0
  47. package/src/negotiation/fresh.ts +89 -0
  48. package/src/negotiation/json-etag.ts +122 -0
  49. package/src/negotiation/negotiate.ts +97 -0
  50. package/src/openapi/describe.ts +79 -0
  51. package/src/openapi/extract-params.ts +62 -0
  52. package/src/openapi/generate.ts +251 -0
  53. package/src/openapi/handler.ts +73 -0
  54. package/src/openapi/types.ts +145 -0
  55. package/src/plugin/decorators.ts +100 -0
  56. package/src/plugin/hooks.ts +114 -0
  57. package/src/plugin/types.ts +189 -0
  58. package/src/problem/middleware.ts +55 -0
  59. package/src/problem/serialize.ts +121 -0
  60. package/src/problem/types.ts +68 -0
  61. package/src/proxy/trust.ts +247 -0
  62. package/src/rate-limit/middleware.ts +72 -0
  63. package/src/rate-limit/store.ts +129 -0
  64. package/src/rate-limit/types.ts +60 -0
  65. package/src/response/reflect.ts +93 -0
  66. package/src/router/router.ts +284 -0
  67. package/src/router/trie.ts +309 -0
  68. package/src/router/types.ts +54 -0
  69. package/src/schema/standard.ts +67 -0
  70. package/src/session/middleware.ts +379 -0
  71. package/src/session/store-memory.ts +79 -0
  72. package/src/session/types.ts +95 -0
  73. package/src/sinatra/filters.ts +129 -0
  74. package/src/sinatra/top-level.ts +151 -0
  75. package/src/sse/keep-alive.ts +52 -0
  76. package/src/sse/sse.ts +115 -0
  77. package/src/static/middleware.ts +254 -0
  78. package/src/static/types.ts +31 -0
  79. package/src/transport/http2-helpers.ts +242 -0
  80. package/src/transport/http2.ts +316 -0
  81. package/src/transport/node.ts +261 -0
  82. package/src/transport/shutdown.ts +86 -0
  83. package/src/transport/types.ts +72 -0
  84. package/src/util/safe-json.ts +66 -0
  85. package/src/ws/index.ts +164 -0
  86. package/src/ws/middleware.ts +178 -0
  87. package/src/ws/types.ts +52 -0
  88. package/src/ws/ws-node-adapter.ts +162 -0
@@ -0,0 +1,306 @@
1
+ import { MemoryQueueStore } from './store-memory.ts'
2
+ import type {
3
+ FailedJob,
4
+ QueueOptions,
5
+ QueueStore,
6
+ QueueWorker,
7
+ RetryPolicy,
8
+ } from './types.ts'
9
+
10
+ /** Default exponential backoff: 100ms, 400ms, 1.6s, 6.4s, ... (4^(n-1) * 100). */
11
+ const DEFAULT_RETRIES: RetryPolicy = {
12
+ attempts: 3,
13
+ backoffMs: (attempt: number) => 100 * Math.pow(4, attempt - 1),
14
+ }
15
+
16
+ function normalizeRetries(retries: number | RetryPolicy | undefined): RetryPolicy {
17
+ if (retries === undefined) return DEFAULT_RETRIES
18
+ if (typeof retries === 'number') {
19
+ return { attempts: Math.max(1, retries), backoffMs: DEFAULT_RETRIES.backoffMs }
20
+ }
21
+ return retries
22
+ }
23
+
24
+ /**
25
+ * A single named background queue. Wraps a {@link QueueStore} with a worker
26
+ * pool, retry/backoff logic, pause/resume controls, and a `drain()` for
27
+ * graceful shutdown.
28
+ *
29
+ * The pool is event-driven: when a slot frees up, the queue immediately
30
+ * tries to pull another job; if none is ready (empty or all delayed), the
31
+ * pool sleeps until either:
32
+ * - a new `add()` call wakes it, or
33
+ * - the earliest delayed job's `notBefore` elapses (timer-based wake).
34
+ *
35
+ * All timers are `unref()`'d so the queue alone never keeps the event loop alive.
36
+ */
37
+ export class IngeniumQueue<TData = unknown> {
38
+ readonly name: string
39
+ private readonly store: QueueStore<TData>
40
+ private readonly worker: QueueWorker<TData>
41
+ private readonly retries: RetryPolicy
42
+ private readonly concurrency: number
43
+ private readonly onFailed: ((job: FailedJob<TData>) => void | Promise<void>) | undefined
44
+
45
+ /** Active worker slot count. When `< concurrency`, pull more work. */
46
+ private active = 0
47
+ /** Whether `pause()` has been called and `resume()` not yet. */
48
+ private paused = false
49
+ /** Whether `drain()`/close has been called. No new jobs accepted. */
50
+ private closed = false
51
+ /** Timer for waking the pool when a delayed retry becomes due. */
52
+ private wakeTimer: NodeJS.Timeout | null = null
53
+ /** Resolvers waiting for `active` to hit 0 (used by `drain()`). */
54
+ private idleWaiters: (() => void)[] = []
55
+ /** Set when the pool is started — protects against double-start. */
56
+ private started = false
57
+
58
+ constructor(name: string, opts: QueueOptions<TData>, worker: QueueWorker<TData>) {
59
+ this.name = name
60
+ this.store = opts.store ?? new MemoryQueueStore<TData>()
61
+ this.worker = worker
62
+ this.retries = normalizeRetries(opts.retries)
63
+ this.concurrency = Math.max(1, opts.concurrency ?? 1)
64
+ this.onFailed = opts.onFailed
65
+ }
66
+
67
+ /**
68
+ * Start the worker pool. Idempotent — safe to call multiple times. The
69
+ * pool is also implicitly started by the first `add()` call so direct
70
+ * invocation is optional.
71
+ */
72
+ start(): void {
73
+ if (this.started) return
74
+ this.started = true
75
+ this.pump()
76
+ }
77
+
78
+ /** Enqueue a job. Returns the assigned id. */
79
+ async add(data: TData): Promise<{ id: string }> {
80
+ if (this.closed) {
81
+ throw new Error(`ingenium: queue "${this.name}" is closed (no new jobs accepted)`)
82
+ }
83
+ const handle = await this.store.enqueue(data)
84
+ // Lazy-start: first add() triggers worker pool boot if `start()` wasn't called.
85
+ if (!this.started) this.start()
86
+ else this.pump()
87
+ return handle
88
+ }
89
+
90
+ /** Approximate number of pending jobs. */
91
+ size(): Promise<number> {
92
+ return this.store.size()
93
+ }
94
+
95
+ /** Number of jobs in the dead-letter list. */
96
+ failedCount(): Promise<number> {
97
+ return this.store.failedCount()
98
+ }
99
+
100
+ /**
101
+ * Empty the dead-letter list. Only effective when the underlying store
102
+ * is the default {@link MemoryQueueStore}; custom stores should provide
103
+ * their own clearing surface.
104
+ */
105
+ clearFailed(): void {
106
+ if (this.store instanceof MemoryQueueStore) this.store.clearFailed()
107
+ }
108
+
109
+ /**
110
+ * Stop pulling new jobs from the store. In-flight jobs continue to run
111
+ * until they complete. Idempotent.
112
+ */
113
+ pause(): void {
114
+ this.paused = true
115
+ }
116
+
117
+ /** Resume pulling jobs. Wakes the pool. */
118
+ resume(): void {
119
+ if (!this.paused) return
120
+ this.paused = false
121
+ this.pump()
122
+ }
123
+
124
+ /**
125
+ * Wait for all in-flight jobs to complete, then stop accepting new ones.
126
+ * If `timeoutMs` elapses first, resolve anyway — the orphaned jobs keep
127
+ * running until they naturally finish (JS can't cancel a Promise), but
128
+ * the framework stops waiting.
129
+ *
130
+ * Returns `true` if the queue drained cleanly; `false` on timeout.
131
+ */
132
+ async drain(timeoutMs?: number): Promise<boolean> {
133
+ this.closed = true
134
+ this.paused = true // stop pulling new work even if currently active
135
+ if (this.active === 0) {
136
+ this.clearWakeTimer()
137
+ return true
138
+ }
139
+ return new Promise<boolean>((resolve) => {
140
+ let settled = false
141
+ const onIdle = (): void => {
142
+ if (settled) return
143
+ settled = true
144
+ if (timer) clearTimeout(timer)
145
+ this.clearWakeTimer()
146
+ resolve(true)
147
+ }
148
+ this.idleWaiters.push(onIdle)
149
+ const timer = timeoutMs !== undefined
150
+ ? setTimeout(() => {
151
+ if (settled) return
152
+ settled = true
153
+ // Drop our waiter so the pool doesn't re-resolve us.
154
+ const idx = this.idleWaiters.indexOf(onIdle)
155
+ if (idx >= 0) this.idleWaiters.splice(idx, 1)
156
+ this.clearWakeTimer()
157
+ resolve(false)
158
+ }, timeoutMs)
159
+ : null
160
+ timer?.unref?.()
161
+ })
162
+ }
163
+
164
+ /**
165
+ * Pump the pool: while we have free slots and we're not paused, pull jobs
166
+ * and dispatch them. No-op when paused / saturated. Re-entrant: every
167
+ * job completion calls `pump()` again to fill the slot.
168
+ */
169
+ private pump(): void {
170
+ if (this.paused) {
171
+ this.checkIdle()
172
+ return
173
+ }
174
+ while (this.active < this.concurrency) {
175
+ // Synchronously check store state via a microtask. The store API is
176
+ // async (so a Redis adapter works), so each pull is a Promise we don't
177
+ // await inline — we just kick it off and let `runOne` increment/decrement
178
+ // `active` around the actual worker invocation.
179
+ const slotOpen = this.tryFillSlot()
180
+ if (!slotOpen) break
181
+ }
182
+ this.checkIdle()
183
+ }
184
+
185
+ /**
186
+ * Attempts to take one job from the store and run it. Returns `false` if
187
+ * the store is empty (or all-delayed) so the caller stops looping.
188
+ *
189
+ * NOTE: we increment `active` BEFORE the async `next()` resolves so a
190
+ * burst of synchronous `pump()` calls doesn't over-subscribe the pool.
191
+ * We decrement on the `null` path.
192
+ */
193
+ private tryFillSlot(): boolean {
194
+ this.active++
195
+ void this.runOne().catch(() => {
196
+ // runOne handles all errors internally; this catch is a defensive
197
+ // backstop so an unexpected bug doesn't unhandle a rejection.
198
+ })
199
+ return true
200
+ }
201
+
202
+ private async runOne(): Promise<void> {
203
+ let job: { id: string; data: TData; attempt: number } | null = null
204
+ try {
205
+ job = await this.store.next()
206
+ } catch {
207
+ // Store failure pulling the next job — release slot and back off.
208
+ this.active--
209
+ this.checkIdle()
210
+ return
211
+ }
212
+ if (job === null) {
213
+ // Empty (or all delayed). Release the speculative slot, schedule a
214
+ // wake for the earliest delayed job (if any), and stop pulling.
215
+ this.active--
216
+ this.scheduleDelayedWake()
217
+ this.checkIdle()
218
+ return
219
+ }
220
+
221
+ let lastError: unknown = undefined
222
+ try {
223
+ await this.worker({ id: job.id, data: job.data, attempt: job.attempt })
224
+ await this.store.ack(job.id)
225
+ } catch (err) {
226
+ lastError = err
227
+ const attempt = job.attempt
228
+ if (attempt < this.retries.attempts) {
229
+ const delay = Math.max(0, this.retries.backoffMs(attempt))
230
+ try {
231
+ await this.store.retry(job.id, delay)
232
+ } catch {
233
+ // If retry bookkeeping fails, fall back to fail() so the job
234
+ // doesn't get stuck in-flight forever.
235
+ await this.safeFail(job, lastError)
236
+ }
237
+ } else {
238
+ await this.safeFail(job, lastError)
239
+ }
240
+ } finally {
241
+ this.active--
242
+ // Refill the freed slot on the next macrotask, not synchronously. A
243
+ // synchronous refill would chain pickup of the *next* job into the same
244
+ // microtask run as this job's completion, so the pool would never be
245
+ // observably idle between jobs (active would dip and immediately rise
246
+ // within one turn). Deferring to setImmediate yields a macrotask
247
+ // boundary where `active` reflects only genuinely-running jobs.
248
+ const t = setImmediate(() => this.pump())
249
+ t.unref?.()
250
+ }
251
+ }
252
+
253
+ private async safeFail(
254
+ job: { id: string; data: TData; attempt: number },
255
+ lastError: unknown,
256
+ ): Promise<void> {
257
+ try {
258
+ await this.store.fail(job.id)
259
+ } catch {
260
+ // If even fail() throws, we've done what we can — the job stays
261
+ // in-flight in the store, which is at-least-once-correct.
262
+ return
263
+ }
264
+ if (this.onFailed) {
265
+ try {
266
+ await this.onFailed({ id: job.id, data: job.data, attempt: job.attempt, lastError })
267
+ } catch {
268
+ // onFailed errors are observation-only; swallow.
269
+ }
270
+ }
271
+ }
272
+
273
+ /**
274
+ * If the store has only delayed entries (e.g. just-retried jobs whose
275
+ * backoff hasn't elapsed), schedule a one-shot wake when the earliest
276
+ * delay fires so we don't spin or sleep forever.
277
+ *
278
+ * Only the default in-memory store exposes `earliestPendingAt`; for
279
+ * custom stores we don't poll — we rely on the next `add()` to wake us.
280
+ */
281
+ private scheduleDelayedWake(): void {
282
+ if (!(this.store instanceof MemoryQueueStore)) return
283
+ const earliest = this.store.earliestPendingAt()
284
+ if (earliest === null) return
285
+ const delay = Math.max(1, earliest - Date.now())
286
+ this.clearWakeTimer()
287
+ this.wakeTimer = setTimeout(() => {
288
+ this.wakeTimer = null
289
+ this.pump()
290
+ }, delay)
291
+ this.wakeTimer.unref?.()
292
+ }
293
+
294
+ private clearWakeTimer(): void {
295
+ if (this.wakeTimer) {
296
+ clearTimeout(this.wakeTimer)
297
+ this.wakeTimer = null
298
+ }
299
+ }
300
+
301
+ private checkIdle(): void {
302
+ if (this.active !== 0 || this.idleWaiters.length === 0) return
303
+ const waiters = this.idleWaiters.splice(0, this.idleWaiters.length)
304
+ for (const w of waiters) w()
305
+ }
306
+ }
@@ -0,0 +1,82 @@
1
+ import { IngeniumQueue } from './queue.ts'
2
+ import type { QueueOptions, QueueWorker } from './types.ts'
3
+
4
+ /**
5
+ * Maps queue names → {@link IngeniumQueue} instances. Held by `IngeniumApp`,
6
+ * mirroring the shape of {@link CronRegistry}.
7
+ *
8
+ * Lookup is O(1). Names must be unique within an app — re-registering the
9
+ * same name throws (mirrors how `app.get('/users')` would conflict if you
10
+ * registered the same path twice with the same method).
11
+ */
12
+ export class QueueRegistry {
13
+ private readonly queues: Map<string, IngeniumQueue<unknown>> = new Map()
14
+
15
+ /**
16
+ * Register a new queue. Returns the created instance. Throws if a queue
17
+ * with `name` is already registered.
18
+ */
19
+ register<TData>(
20
+ name: string,
21
+ opts: QueueOptions<TData>,
22
+ worker: QueueWorker<TData>,
23
+ ): IngeniumQueue<TData> {
24
+ if (this.queues.has(name)) {
25
+ throw new Error(`ingenium: queue "${name}" is already registered`)
26
+ }
27
+ const queue = new IngeniumQueue<TData>(name, opts, worker)
28
+ this.queues.set(name, queue as unknown as IngeniumQueue<unknown>)
29
+ return queue
30
+ }
31
+
32
+ /** Look up a queue. Throws if not registered (typos surface immediately). */
33
+ get<TData = unknown>(name: string): IngeniumQueue<TData> {
34
+ const queue = this.queues.get(name)
35
+ if (!queue) {
36
+ throw new Error(
37
+ `ingenium: queue "${name}" is not registered (call app.queue("${name}", ...) first)`,
38
+ )
39
+ }
40
+ return queue as unknown as IngeniumQueue<TData>
41
+ }
42
+
43
+ /** Has a queue with this name been registered? */
44
+ has(name: string): boolean {
45
+ return this.queues.has(name)
46
+ }
47
+
48
+ /** Number of registered queues. */
49
+ count(): number {
50
+ return this.queues.size
51
+ }
52
+
53
+ /** All registered queue names (insertion order). */
54
+ names(): string[] {
55
+ return [...this.queues.keys()]
56
+ }
57
+
58
+ /**
59
+ * Start the worker pool of every registered queue. Called by the app's
60
+ * composition step so workers don't process jobs before the app is ready.
61
+ */
62
+ startAll(): void {
63
+ for (const q of this.queues.values()) q.start()
64
+ }
65
+
66
+ /**
67
+ * Drain every queue concurrently. Resolves when all queues either finish
68
+ * their in-flight work or hit `timeoutMs`. The returned object reports
69
+ * which queues drained cleanly vs timed out — useful for shutdown logs.
70
+ */
71
+ async drainAll(timeoutMs?: number): Promise<{ clean: string[]; timedOut: string[] }> {
72
+ const entries = [...this.queues.entries()]
73
+ const results = await Promise.all(entries.map(([, q]) => q.drain(timeoutMs)))
74
+ const clean: string[] = []
75
+ const timedOut: string[] = []
76
+ entries.forEach(([name], i) => {
77
+ if (results[i]) clean.push(name)
78
+ else timedOut.push(name)
79
+ })
80
+ return { clean, timedOut }
81
+ }
82
+ }
@@ -0,0 +1,113 @@
1
+ import type { QueueStore } from './types.ts'
2
+
3
+ interface PendingEntry<TData> {
4
+ id: string
5
+ data: TData
6
+ attempt: number
7
+ /** When `> Date.now()`, the job is delayed (used by retries). */
8
+ notBefore: number
9
+ }
10
+
11
+ interface InFlightEntry<TData> {
12
+ id: string
13
+ data: TData
14
+ attempt: number
15
+ }
16
+
17
+ /**
18
+ * In-process FIFO queue store. Backs {@link IngeniumQueue} when no custom
19
+ * store is supplied. Suitable for single-instance deployments and tests.
20
+ *
21
+ * Layout:
22
+ * - `pending`: ordered list of jobs ready to be picked up. Delayed jobs
23
+ * (post-retry backoff) sit here too — `next()` skips entries whose
24
+ * `notBefore` hasn't elapsed yet, so callers should poll on a timer.
25
+ * - `inFlight`: jobs that have been `next()`-ed but not yet `ack`/`retry`/`fail`-ed.
26
+ * - `failed`: dead-letter list. Persists until `clearFailed()` is called.
27
+ *
28
+ * No background timers — purely event-driven via the queue worker pool.
29
+ */
30
+ export class MemoryQueueStore<TData> implements QueueStore<TData> {
31
+ private readonly pending: PendingEntry<TData>[] = []
32
+ private readonly inFlight: Map<string, InFlightEntry<TData>> = new Map()
33
+ private readonly failed: { id: string; data: TData; attempt: number }[] = []
34
+ private nextId = 1
35
+
36
+ enqueue(data: TData): Promise<{ id: string }> {
37
+ const id = String(this.nextId++)
38
+ this.pending.push({ id, data, attempt: 1, notBefore: 0 })
39
+ return Promise.resolve({ id })
40
+ }
41
+
42
+ next(): Promise<{ id: string; data: TData; attempt: number } | null> {
43
+ const now = Date.now()
44
+ // Find the first pending entry whose delay has elapsed.
45
+ for (let i = 0; i < this.pending.length; i++) {
46
+ const entry = this.pending[i]!
47
+ if (entry.notBefore <= now) {
48
+ this.pending.splice(i, 1)
49
+ const inflight: InFlightEntry<TData> = {
50
+ id: entry.id,
51
+ data: entry.data,
52
+ attempt: entry.attempt,
53
+ }
54
+ this.inFlight.set(entry.id, inflight)
55
+ return Promise.resolve({ id: entry.id, data: entry.data, attempt: entry.attempt })
56
+ }
57
+ }
58
+ return Promise.resolve(null)
59
+ }
60
+
61
+ ack(id: string): Promise<void> {
62
+ this.inFlight.delete(id)
63
+ return Promise.resolve()
64
+ }
65
+
66
+ retry(id: string, delayMs: number): Promise<void> {
67
+ const entry = this.inFlight.get(id)
68
+ if (!entry) return Promise.resolve()
69
+ this.inFlight.delete(id)
70
+ this.pending.push({
71
+ id: entry.id,
72
+ data: entry.data,
73
+ attempt: entry.attempt + 1,
74
+ notBefore: Date.now() + Math.max(0, delayMs),
75
+ })
76
+ return Promise.resolve()
77
+ }
78
+
79
+ fail(id: string): Promise<void> {
80
+ const entry = this.inFlight.get(id)
81
+ if (!entry) return Promise.resolve()
82
+ this.inFlight.delete(id)
83
+ this.failed.push({ id: entry.id, data: entry.data, attempt: entry.attempt })
84
+ return Promise.resolve()
85
+ }
86
+
87
+ size(): Promise<number> {
88
+ return Promise.resolve(this.pending.length)
89
+ }
90
+
91
+ failedCount(): Promise<number> {
92
+ return Promise.resolve(this.failed.length)
93
+ }
94
+
95
+ /** @internal Used by `IngeniumQueue.clearFailed()`. */
96
+ clearFailed(): void {
97
+ this.failed.length = 0
98
+ }
99
+
100
+ /** @internal Used by `IngeniumQueue.drain()` to know if work is outstanding. */
101
+ inFlightCount(): number {
102
+ return this.inFlight.size
103
+ }
104
+
105
+ /** @internal Earliest `notBefore` of any pending entry, or `null` if none. */
106
+ earliestPendingAt(): number | null {
107
+ let min: number | null = null
108
+ for (const e of this.pending) {
109
+ if (min === null || e.notBefore < min) min = e.notBefore
110
+ }
111
+ return min
112
+ }
113
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Background-job type surface for Ingenium.
3
+ *
4
+ * The core abstraction is a {@link QueueStore} — a pluggable persistence layer
5
+ * for FIFO jobs with retries and a dead-letter list. The default implementation
6
+ * ({@link MemoryQueueStore}) keeps everything in process; a Redis adapter can
7
+ * land later by simply implementing this interface.
8
+ *
9
+ * A {@link IngeniumQueue} wraps a store with a worker pool, retry policy, and
10
+ * pause/resume/drain controls; a {@link QueueRegistry} (held by `IngeniumApp`)
11
+ * indexes named queues so route handlers can enqueue from any code path.
12
+ */
13
+
14
+ /**
15
+ * Worker function for a registered queue. Throwing causes a retry per the
16
+ * configured {@link RetryPolicy}; resolving means the job is `ack`'d.
17
+ */
18
+ export type QueueWorker<TData> = (job: {
19
+ /** Stable id assigned by the store at enqueue time. */
20
+ id: string
21
+ /** Job payload (the value passed to `add`). */
22
+ data: TData
23
+ /**
24
+ * 1-indexed attempt counter. `1` on first delivery; incremented before each
25
+ * retry. Use this in the worker to back off external calls or short-circuit
26
+ * non-recoverable failures.
27
+ */
28
+ attempt: number
29
+ }) => unknown | Promise<unknown>
30
+
31
+ /**
32
+ * Retry policy. The first attempt is included in the count: `attempts: 3`
33
+ * means one initial try + two retries.
34
+ */
35
+ export interface RetryPolicy {
36
+ /** Total tries including the first delivery. Must be `>= 1`. */
37
+ attempts: number
38
+ /**
39
+ * Delay (ms) before the NEXT attempt, given the attempt that just failed
40
+ * (1-indexed). E.g. for `attempts: 3`, this is called with `1` then `2`.
41
+ */
42
+ backoffMs: (attempt: number) => number
43
+ }
44
+
45
+ /**
46
+ * Options for {@link IngeniumApp.queue}. All fields optional.
47
+ *
48
+ * @typeParam TData - shape of job payloads enqueued onto this queue
49
+ */
50
+ export interface QueueOptions<TData> {
51
+ /**
52
+ * Max concurrent jobs processed in parallel by this queue's worker pool.
53
+ * Default `1` (strict FIFO). Bump this for I/O-bound work.
54
+ */
55
+ concurrency?: number
56
+ /**
57
+ * Retry policy on worker throw. Numeric shorthand `n` is equivalent to
58
+ * `{ attempts: n, backoffMs: exponential }`. Default: 3 attempts at
59
+ * 100ms / 400ms / 1.6s.
60
+ */
61
+ retries?: number | RetryPolicy
62
+ /**
63
+ * Custom store. Default {@link MemoryQueueStore}. Implement this to back
64
+ * the queue with Redis / Postgres / SQS / etc.
65
+ */
66
+ store?: QueueStore<TData>
67
+ /**
68
+ * Called once retries are exhausted, just before the job is moved to the
69
+ * dead-letter list. Throwing here is logged and swallowed — the job is
70
+ * still moved to the DLQ.
71
+ */
72
+ onFailed?: (job: FailedJob<TData>) => void | Promise<void>
73
+ }
74
+
75
+ /**
76
+ * Pluggable persistence layer. The default {@link MemoryQueueStore} keeps
77
+ * pending and failed jobs in arrays + an in-flight map. A Redis adapter
78
+ * would map this onto LPUSH / BRPOPLPUSH / a processing list / a DLQ list.
79
+ *
80
+ * Implementations MUST guarantee at-least-once delivery (a `next()`-ed
81
+ * job that is neither `ack`'d nor `retry`'d nor `fail`'d is considered
82
+ * stuck and may be re-delivered by the store on its own schedule).
83
+ */
84
+ export interface QueueStore<TData> {
85
+ /** Append a job to the tail. Returns the assigned id. */
86
+ enqueue(data: TData): Promise<{ id: string }>
87
+ /**
88
+ * Pop the next pending job and move it to the in-flight set. Returns
89
+ * `null` when the queue is empty. The returned `attempt` reflects how
90
+ * many times this job has been delivered (1 on first delivery).
91
+ */
92
+ next(): Promise<{ id: string; data: TData; attempt: number } | null>
93
+ /** Mark an in-flight job as completed. Removes it from the store. */
94
+ ack(id: string): Promise<void>
95
+ /**
96
+ * Re-enqueue the in-flight job for another attempt after `delayMs`.
97
+ * The store MUST increment its internal attempt counter so the next
98
+ * `next()` returns it with the bumped count.
99
+ */
100
+ retry(id: string, delayMs: number): Promise<void>
101
+ /** Move the in-flight job to the dead-letter list. */
102
+ fail(id: string): Promise<void>
103
+ /** Number of pending (not in-flight, not failed) jobs. */
104
+ size(): Promise<number>
105
+ /** Number of jobs in the dead-letter list. */
106
+ failedCount(): Promise<number>
107
+ }
108
+
109
+ /** Payload passed to {@link QueueOptions.onFailed} when retries are exhausted. */
110
+ export interface FailedJob<TData> {
111
+ id: string
112
+ data: TData
113
+ /** Final attempt number (== `retries.attempts`). */
114
+ attempt: number
115
+ /** Whatever the worker threw on its last attempt. */
116
+ lastError: unknown
117
+ }
118
+
119
+ /** Per-request handle returned by `ctx.queue(name)`. */
120
+ export interface JobHandle<TData = unknown> {
121
+ /** Enqueue `data`. Resolves to the assigned job id. */
122
+ add(data: TData): Promise<{ id: string }>
123
+ }
124
+
125
+ /**
126
+ * Bookkeeping wrapper a {@link QueueRegistry} keeps for every registered
127
+ * queue. Mostly useful for introspection / tests.
128
+ */
129
+ export interface RegisteredQueue<TData = unknown> {
130
+ name: string
131
+ options: Required<Pick<QueueOptions<TData>, 'concurrency'>> & {
132
+ retries: RetryPolicy
133
+ onFailed: ((job: FailedJob<TData>) => void | Promise<void>) | undefined
134
+ }
135
+ }