hybridq 0.1.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.
@@ -0,0 +1,463 @@
1
+ /**
2
+ * Core type contracts for hybridq.
3
+ *
4
+ * These are intentionally dependency-free so they can be shared by the
5
+ * server bundle (queueing + execution) and stay tree-shakeable.
6
+ */
7
+ /** Lifecycle states a job moves through inside the store. */
8
+ type JobStatus = "pending" | "active" | "completed" | "failed" | "delayed";
9
+ /**
10
+ * A unit of deferred work.
11
+ *
12
+ * `payload` is whatever the producer enqueued. When payload encryption is
13
+ * enabled the adapter stores a ciphertext envelope on disk/Redis and only
14
+ * decrypts back into `payload` when the engine claims the job.
15
+ */
16
+ interface Job<TPayload = unknown> {
17
+ /** Globally unique id. Also used as the idempotency key when provided. */
18
+ id: string;
19
+ /** Logical queue/topic name. Lets one store host many independent queues. */
20
+ queue: string;
21
+ /** The work to perform. Decrypted in-memory; never logged. */
22
+ payload: TPayload;
23
+ /** Current lifecycle state. */
24
+ status: JobStatus;
25
+ /** How many times this job has been attempted (claimed). */
26
+ attempts: number;
27
+ /** Hard cap on attempts before the job is moved to `failed`. */
28
+ maxAttempts: number;
29
+ /** Epoch ms when the job becomes eligible to run (for delays/backoff). */
30
+ runAt: number;
31
+ /** Epoch ms when the job was first enqueued. */
32
+ createdAt: number;
33
+ /**
34
+ * Epoch ms when the current lease expires. While `status === "active"` and
35
+ * `now < leaseUntil`, no other trigger may claim this job. Once the lease
36
+ * lapses (e.g. the serverless function was killed mid-run) the job becomes
37
+ * reclaimable — this is what makes processing crash-safe.
38
+ */
39
+ leaseUntil?: number;
40
+ /** Last error message, recorded on failure for observability. */
41
+ lastError?: string;
42
+ }
43
+ /** The result the engine derives from each handler invocation. */
44
+ type JobResult = {
45
+ status: "completed";
46
+ } | {
47
+ status: "failed";
48
+ error: string;
49
+ retry?: boolean;
50
+ };
51
+ /**
52
+ * Storage Adapter contract.
53
+ *
54
+ * Implementations must be safe to construct per-request in stateless
55
+ * serverless environments. Every method takes the queue name so a single
56
+ * adapter instance can serve multiple queues.
57
+ *
58
+ * The claim/ack model is lease-based: `shiftBatch` atomically moves jobs to
59
+ * `active` with a lease; the engine must later `complete`, `fail`, or
60
+ * `release` each claimed job. Crashed runs simply let the lease expire.
61
+ */
62
+ interface StorageAdapter {
63
+ /** Append a job to the tail of its queue. */
64
+ push(job: Job): Promise<void>;
65
+ /**
66
+ * Atomically claim up to `count` eligible jobs (pending + runAt <= now, or
67
+ * active jobs whose lease has expired), mark them `active`, and stamp a
68
+ * lease of `leaseMs`. Returns the claimed jobs with decrypted payloads.
69
+ */
70
+ shiftBatch(queue: string, count: number, leaseMs: number): Promise<Job[]>;
71
+ /** Mark a claimed job done and remove it from the working set. */
72
+ complete(queue: string, jobId: string): Promise<void>;
73
+ /**
74
+ * Mark a claimed job as failed. If `retry` is true and attempts remain, the
75
+ * job is requeued as `pending` with `runAt = now + backoffMs`; otherwise it
76
+ * lands in `failed`.
77
+ */
78
+ fail(queue: string, jobId: string, error: string, retry: boolean, backoffMs: number): Promise<void>;
79
+ /**
80
+ * Release a still-leased job back to `pending` without consuming an attempt.
81
+ * Used when a budget is exhausted mid-run so unprocessed jobs return cleanly.
82
+ */
83
+ release(queue: string, jobId: string): Promise<void>;
84
+ /** Approximate number of pending (claimable) jobs — for triggers/metrics. */
85
+ size(queue: string): Promise<number>;
86
+ }
87
+ /**
88
+ * Self-limiting execution budget. Enforced on every trigger run so a single
89
+ * invocation can never exceed the platform's wall-clock or cost limits.
90
+ */
91
+ interface BudgetConfig {
92
+ /** Hard cap on total jobs processed in one trigger run. */
93
+ maxJobsPerTrigger: number;
94
+ /**
95
+ * Wall-clock budget in ms. The loop stops *before* starting a new job once
96
+ * elapsed time would risk the platform timeout. Set comfortably under your
97
+ * function's limit (e.g. 8000 for a 10s Vercel function).
98
+ */
99
+ maxExecutionTimeMs: number;
100
+ /**
101
+ * Optional soft ceiling on estimated CPU utilisation (0-100). Best-effort:
102
+ * derived from event-loop/CPU sampling where available, ignored otherwise.
103
+ */
104
+ maxCpuBudgetPct?: number;
105
+ }
106
+ /** A single job handler. Throwing is treated as a retryable failure. */
107
+ type JobHandler<TPayload = unknown> = (job: Job<TPayload>) => Promise<void> | void;
108
+ /** Outcome summary returned to the trigger caller. */
109
+ interface DrainReport {
110
+ /** Jobs that completed successfully. */
111
+ processed: number;
112
+ /** Jobs that errored this run (may still retry later). */
113
+ failed: number;
114
+ /** Jobs released unprocessed because a budget was hit. */
115
+ released: number;
116
+ /** Why the loop stopped. */
117
+ stoppedBy: "drained" | "jobCap" | "timeBudget" | "cpuBudget" | "lockBusy";
118
+ /** Total wall-clock spent in the loop. */
119
+ elapsedMs: number;
120
+ }
121
+
122
+ /**
123
+ * Optional payload-at-rest encryption. When a secret is configured, payloads
124
+ * are sealed with AES-256-GCM before they ever touch the store, so sensitive
125
+ * data (emails, tokens) is never plaintext in Redis.
126
+ */
127
+ interface PayloadCipher {
128
+ encrypt(plaintext: string): string;
129
+ decrypt(envelope: string): string;
130
+ }
131
+ /**
132
+ * Build a cipher from a secret. Returns `null` when no secret is supplied so
133
+ * callers can transparently no-op (store plaintext) in dev.
134
+ */
135
+ declare function createPayloadCipher(secret?: string): PayloadCipher | null;
136
+ /**
137
+ * HMAC trigger tokens. A trigger presents `t=<ts>,v=<sig>` where
138
+ * sig = HMAC-SHA256(secret, `${ts}.${path}`). Tokens expire to blunt replay.
139
+ */
140
+ declare function signTrigger(secret: string, path: string, ts?: number): string;
141
+ declare function verifyTrigger(secret: string, path: string, token: string, maxSkewMs?: number): boolean;
142
+
143
+ /**
144
+ * In-memory storage adapter.
145
+ *
146
+ * For local dev and tests only — state lives in the process, so it does NOT
147
+ * survive across serverless invocations. The locking/lease semantics mirror
148
+ * the Redis adapter so handler code behaves identically in both.
149
+ */
150
+
151
+ interface MemoryAdapterOptions {
152
+ /** Optional cipher to encrypt payloads at rest, matching prod behaviour. */
153
+ cipher?: PayloadCipher | null;
154
+ }
155
+ declare class MemoryAdapter implements StorageAdapter {
156
+ private readonly queues;
157
+ private readonly cipher;
158
+ constructor(opts?: MemoryAdapterOptions);
159
+ private bucket;
160
+ private seal;
161
+ private open;
162
+ push(job: Job): Promise<void>;
163
+ shiftBatch(queue: string, count: number, leaseMs: number): Promise<Job[]>;
164
+ complete(queue: string, jobId: string): Promise<void>;
165
+ fail(queue: string, jobId: string, error: string, retry: boolean, backoffMs: number): Promise<void>;
166
+ release(queue: string, jobId: string): Promise<void>;
167
+ size(queue: string): Promise<number>;
168
+ }
169
+
170
+ /**
171
+ * Serverless-optimized Redis storage adapter.
172
+ *
173
+ * Works with either:
174
+ * - `@upstash/redis` (HTTP/fetch — ideal for edge & connectionless runtimes)
175
+ * - `ioredis` (TCP — uses a module-level singleton so we don't open a
176
+ * new socket on every cold-ish invocation)
177
+ *
178
+ * Both clients differ in their `eval` signature, so we normalize them behind a
179
+ * tiny `RedisDriver` and do all the claim logic in a single atomic Lua script.
180
+ *
181
+ * Data model (per queue `q`):
182
+ * hq:{q}:pending LIST — ids waiting to be claimed, FIFO
183
+ * hq:{q}:delayed ZSET — id -> runAt (promoted to pending when due)
184
+ * hq:{q}:active ZSET — id -> leaseUntil (reclaimed when the lease lapses)
185
+ * hq:{q}:jobs HASH — id -> envelope JSON (see JobEnvelope)
186
+ *
187
+ * The user payload is stored as an opaque string field (`payloadJson`) so the
188
+ * server-side Lua `cjson` round-trip can never mangle nested structures.
189
+ */
190
+
191
+ /** Minimal normalized Redis surface the adapter needs. */
192
+ interface RedisDriver {
193
+ eval(script: string, keys: string[], args: (string | number)[]): Promise<unknown>;
194
+ hget(key: string, field: string): Promise<string | null>;
195
+ hset(key: string, field: string, value: string): Promise<unknown>;
196
+ hdel(key: string, field: string): Promise<unknown>;
197
+ rpush(key: string, value: string): Promise<unknown>;
198
+ zadd(key: string, score: number, member: string): Promise<unknown>;
199
+ zrem(key: string, member: string): Promise<unknown>;
200
+ llen(key: string): Promise<number>;
201
+ }
202
+ interface RedisAdapterOptions {
203
+ /** Optional namespace prefix (default "hq"). Lets you isolate apps/envs. */
204
+ namespace?: string;
205
+ /** Optional cipher for payload-at-rest encryption. */
206
+ cipher?: PayloadCipher | null;
207
+ }
208
+ declare class RedisAdapter implements StorageAdapter {
209
+ private readonly redis;
210
+ private readonly ns;
211
+ private readonly cipher;
212
+ constructor(redis: RedisDriver, opts?: RedisAdapterOptions);
213
+ private k;
214
+ private toEnvelope;
215
+ private fromEnvelope;
216
+ push(job: Job): Promise<void>;
217
+ shiftBatch(queue: string, count: number, leaseMs: number): Promise<Job[]>;
218
+ complete(queue: string, jobId: string): Promise<void>;
219
+ fail(queue: string, jobId: string, error: string, retry: boolean, backoffMs: number): Promise<void>;
220
+ release(queue: string, jobId: string): Promise<void>;
221
+ size(queue: string): Promise<number>;
222
+ }
223
+ /**
224
+ * Wrap an `@upstash/redis` client. Connectionless (HTTP), so it's safe to
225
+ * construct per request — no pooling needed.
226
+ */
227
+ declare function fromUpstash(client: {
228
+ eval: (script: string, keys: string[], args: unknown[]) => Promise<unknown>;
229
+ hget: (k: string, f: string) => Promise<string | null>;
230
+ hset: (k: string, v: Record<string, string>) => Promise<unknown>;
231
+ hdel: (k: string, f: string) => Promise<unknown>;
232
+ rpush: (k: string, ...v: string[]) => Promise<unknown>;
233
+ zadd: (k: string, m: {
234
+ score: number;
235
+ member: string;
236
+ }) => Promise<unknown>;
237
+ zrem: (k: string, m: string) => Promise<unknown>;
238
+ llen: (k: string) => Promise<number>;
239
+ }): RedisDriver;
240
+ /**
241
+ * Wrap an `ioredis` client. ioredis opens a real TCP socket, so callers should
242
+ * reuse ONE client across invocations — see `getSharedIORedis` below.
243
+ */
244
+ declare function fromIORedis(client: {
245
+ eval: (script: string, numKeys: number, ...rest: (string | number)[]) => Promise<unknown>;
246
+ hget: (k: string, f: string) => Promise<string | null>;
247
+ hset: (k: string, f: string, v: string) => Promise<unknown>;
248
+ hdel: (k: string, f: string) => Promise<unknown>;
249
+ rpush: (k: string, v: string) => Promise<unknown>;
250
+ zadd: (k: string, score: number, member: string) => Promise<unknown>;
251
+ zrem: (k: string, m: string) => Promise<unknown>;
252
+ llen: (k: string) => Promise<number>;
253
+ }): RedisDriver;
254
+ /**
255
+ * Connection-pool-safe ioredis singleton for serverless. Stashes the client on
256
+ * `globalThis` so warm invocations reuse the same socket instead of exhausting
257
+ * Redis connections under load.
258
+ */
259
+ declare function getSharedIORedis(factory: () => RedisDriver, key?: string): RedisDriver;
260
+
261
+ /**
262
+ * Distributed lock used to guarantee single-flight processing.
263
+ *
264
+ * When several inbound requests fire a trigger at the same instant we don't
265
+ * want N concurrent drain loops fighting over the same jobs. Each run first
266
+ * tries to grab a short-TTL lock; losers exit immediately (the winner is
267
+ * already draining). The TTL is short and auto-expires, so a crashed run never
268
+ * wedges the queue.
269
+ */
270
+
271
+ interface DistributedLock {
272
+ /** Try to acquire `key` for `ttlMs`. Returns a release token, or null. */
273
+ acquire(key: string, ttlMs: number): Promise<string | null>;
274
+ /** Release `key` only if we still own it (compare-and-delete). */
275
+ release(key: string, token: string): Promise<void>;
276
+ }
277
+ /** Redis-backed lock (SET NX PX + compare-and-delete). */
278
+ declare class RedisLock implements DistributedLock {
279
+ private readonly redis;
280
+ constructor(redis: RedisDriver);
281
+ acquire(key: string, ttlMs: number): Promise<string | null>;
282
+ release(key: string, token: string): Promise<void>;
283
+ }
284
+ /** In-process lock for the MemoryAdapter / single-instance dev. */
285
+ declare class MemoryLock implements DistributedLock {
286
+ private readonly held;
287
+ acquire(key: string, ttlMs: number): Promise<string | null>;
288
+ release(key: string, token: string): Promise<void>;
289
+ }
290
+
291
+ /**
292
+ * The processing engine.
293
+ *
294
+ * A trigger calls `drain()`. It claims jobs in batches and runs the handler,
295
+ * checking the budget *between every job*. The moment the time or job-count
296
+ * (or optional CPU) budget is exhausted it stops claiming, releases any jobs it
297
+ * grabbed-but-didn't-run back to the queue, and returns — guaranteeing the
298
+ * serverless function never blows past its wall-clock limit.
299
+ *
300
+ * Crash-safety comes from the lease model in the adapter: anything claimed but
301
+ * not completed/failed/released simply becomes reclaimable once its lease
302
+ * lapses, so a killed function loses no work.
303
+ */
304
+
305
+ interface DrainOptions<TPayload = unknown> {
306
+ queue: string;
307
+ adapter: StorageAdapter;
308
+ handler: JobHandler<TPayload>;
309
+ budget: BudgetConfig;
310
+ /** Optional single-flight lock so concurrent triggers don't double-drain. */
311
+ lock?: DistributedLock;
312
+ /** Jobs claimed per round trip to the store. Default 10. */
313
+ batchSize?: number;
314
+ /**
315
+ * Lease length for claimed jobs. Should exceed how long one job can take.
316
+ * Defaults to `maxExecutionTimeMs` so a whole run is covered by one lease.
317
+ */
318
+ leaseMs?: number;
319
+ /** Retry backoff in ms given the attempt number (1-based). */
320
+ backoff?: (attempt: number) => number;
321
+ /** Observability hook for handler errors. Never throws into the loop. */
322
+ onError?: (err: unknown, job: Job<TPayload>) => void;
323
+ }
324
+ declare function drain<TPayload = unknown>(opts: DrainOptions<TPayload>): Promise<DrainReport>;
325
+
326
+ /**
327
+ * High-level Queue facade.
328
+ *
329
+ * Wires a storage adapter, optional lock, and the engine into an ergonomic
330
+ * producer/consumer surface:
331
+ * - `enqueue()` from any API route to defer work.
332
+ * - `process()` from a trigger to drain a budgeted batch.
333
+ */
334
+
335
+ interface QueueConfig {
336
+ /** Logical queue name (a single store can host many). */
337
+ name: string;
338
+ /** Where jobs live. */
339
+ adapter: StorageAdapter;
340
+ /** Default execution budget; overridable per `process()` call. */
341
+ budget?: Partial<BudgetConfig>;
342
+ /** Single-flight lock to prevent concurrent drains double-processing. */
343
+ lock?: DistributedLock;
344
+ /** Default retry ceiling for enqueued jobs. Default 3. */
345
+ defaultMaxAttempts?: number;
346
+ /** Jobs claimed per round trip while draining. Default 10. */
347
+ batchSize?: number;
348
+ /** Retry backoff in ms for attempt N (1-based). */
349
+ backoff?: (attempt: number) => number;
350
+ }
351
+ interface EnqueueOptions {
352
+ /** Delay before the job becomes eligible to run. */
353
+ delayMs?: number;
354
+ /** Override retry ceiling for this job. */
355
+ maxAttempts?: number;
356
+ /**
357
+ * Stable id → idempotency key. Re-enqueuing with the same id is a no-op at
358
+ * the application layer (callers should treat enqueue as best-effort unique).
359
+ */
360
+ id?: string;
361
+ }
362
+ declare class Queue<TPayload = unknown> {
363
+ private readonly config;
364
+ private readonly budget;
365
+ constructor(config: QueueConfig);
366
+ get name(): string;
367
+ /** Producer side: push a unit of work. Returns the created job. */
368
+ enqueue(payload: TPayload, opts?: EnqueueOptions): Promise<Job<TPayload>>;
369
+ /** How many jobs are currently claimable. Handy for trigger gating. */
370
+ size(): Promise<number>;
371
+ /**
372
+ * Consumer side: drain a budgeted batch with the given handler. Safe to call
373
+ * from many concurrent requests — the lock ensures only one actually runs.
374
+ */
375
+ process(handler: JobHandler<TPayload>, budgetOverride?: Partial<BudgetConfig>): Promise<DrainReport>;
376
+ }
377
+ /** Convenience factory. */
378
+ declare function defineQueue<TPayload = unknown>(config: QueueConfig): Queue<TPayload>;
379
+
380
+ /**
381
+ * Hybrid trigger middleware.
382
+ *
383
+ * Two ways to kick off a drain without a dedicated worker daemon:
384
+ *
385
+ * 1. `withTrigger(handler)` — wraps a normal API route / webhook. It runs your
386
+ * real handler, returns the response immediately, and drains the queue
387
+ * *after* the response is flushed using the platform's "keep the function
388
+ * alive a bit longer" primitive (`waitUntil`). Inbound traffic = free CPU.
389
+ *
390
+ * 2. `createTriggerEndpoint(...)` — a dedicated, authenticated endpoint that
391
+ * client pings (see ../client/trigger) or a cron hits to force a drain.
392
+ *
393
+ * Both verify a shared secret (X-Worker-Trigger-Secret) or an HMAC token so a
394
+ * stranger can't pound the endpoint and turn your worker into a money pit.
395
+ */
396
+
397
+ /** Anything with `waitUntil` — Vercel/Cloudflare give you one per request. */
398
+ interface ExecutionContextLike {
399
+ waitUntil(promise: Promise<unknown>): void;
400
+ }
401
+ declare const TRIGGER_SECRET_HEADER = "x-worker-trigger-secret";
402
+ declare const TRIGGER_TOKEN_HEADER = "x-worker-trigger-token";
403
+ interface TriggerAuthConfig {
404
+ /** Pre-shared secret compared in constant time against the header. */
405
+ secret?: string;
406
+ /** HMAC secret enabling short-lived signed tokens (replay-resistant). */
407
+ hmacSecret?: string;
408
+ /** Path bound into the HMAC signature. Default the request pathname. */
409
+ path?: string;
410
+ }
411
+ /**
412
+ * Verify a trigger request. Accepts EITHER a valid pre-shared secret header OR
413
+ * a valid HMAC token. Returns true only when at least one configured method
414
+ * passes — so an endpoint with no auth configured always rejects.
415
+ */
416
+ declare function authorizeTrigger(req: Request, auth: TriggerAuthConfig): boolean;
417
+ interface WithTriggerOptions<TPayload> {
418
+ queue: Queue<TPayload>;
419
+ handler: JobHandler<TPayload>;
420
+ /** Platform execution context exposing `waitUntil`. */
421
+ ctx?: ExecutionContextLike;
422
+ /** Optional per-trigger budget override. */
423
+ budget?: Partial<BudgetConfig>;
424
+ /** Skip draining when the queue is empty to avoid pointless work. */
425
+ skipIfEmpty?: boolean;
426
+ }
427
+ /**
428
+ * Wrap a normal request handler so each inbound request opportunistically
429
+ * drains the queue *after* responding. This is the core "hybrid trigger".
430
+ *
431
+ * @example
432
+ * export const POST = withTrigger(
433
+ * async (req) => Response.json({ ok: true }),
434
+ * { queue, handler: sendEmail, ctx },
435
+ * );
436
+ */
437
+ declare function withTrigger<TPayload>(route: (req: Request) => Promise<Response> | Response, opts: WithTriggerOptions<TPayload>): (req: Request) => Promise<Response>;
438
+ interface TriggerEndpointOptions<TPayload> {
439
+ queue: Queue<TPayload>;
440
+ handler: JobHandler<TPayload>;
441
+ auth: TriggerAuthConfig;
442
+ ctx?: ExecutionContextLike;
443
+ budget?: Partial<BudgetConfig>;
444
+ /**
445
+ * When true (default) the drain runs in the background via waitUntil and the
446
+ * endpoint returns 202 immediately. Set false to await the drain and return
447
+ * the full report (useful for cron jobs that want the result).
448
+ */
449
+ background?: boolean;
450
+ }
451
+ /**
452
+ * Build a standalone authenticated trigger endpoint (Fetch-API style). Mount it
453
+ * at e.g. `app/api/_worker/route.ts` and point client pings / cron at it.
454
+ */
455
+ declare function createTriggerEndpoint<TPayload>(opts: TriggerEndpointOptions<TPayload>): (req: Request) => Promise<Response>;
456
+
457
+ /**
458
+ * Small id helper. Prefers crypto.randomUUID where present (Node 18+ and all
459
+ * serverless edge runtimes), with a cheap fallback for exotic environments.
460
+ */
461
+ declare function newId(prefix?: string): string;
462
+
463
+ export { type BudgetConfig, type DistributedLock, type DrainOptions, type DrainReport, type EnqueueOptions, type ExecutionContextLike, type Job, type JobHandler, type JobResult, type JobStatus, MemoryAdapter, type MemoryAdapterOptions, MemoryLock, type PayloadCipher, Queue, type QueueConfig, RedisAdapter, type RedisAdapterOptions, type RedisDriver, RedisLock, type StorageAdapter, TRIGGER_SECRET_HEADER, TRIGGER_TOKEN_HEADER, type TriggerAuthConfig, type TriggerEndpointOptions, type WithTriggerOptions, authorizeTrigger, createPayloadCipher, createTriggerEndpoint, defineQueue, drain, fromIORedis, fromUpstash, getSharedIORedis, newId, signTrigger, verifyTrigger, withTrigger };