@crewhaus/queue-protocol 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts DELETED
@@ -1,276 +0,0 @@
1
- /**
2
- * Catalog R14 `queue-protocol` — Section 23 BATCH.
3
- *
4
- * Abstract queue interface used by `queue-consumer` and the BATCH target
5
- * codegen. The interface is deliberately minimal: every adapter
6
- * implements the same four methods so swapping SQS for Redis Streams
7
- * for Postgres advisory-lock is a config flip, not a code change.
8
- *
9
- * Visibility-timeout semantics:
10
- * - `pull(maxBatch, visibilityTimeoutMs)` returns up to `maxBatch`
11
- * pending jobs; each job becomes invisible to other consumers for
12
- * `visibilityTimeoutMs`. Returns an empty array on empty-queue.
13
- * - `ack(jobId)` marks the job complete. Idempotent; double-ack is a
14
- * no-op (post-conditions: job is gone).
15
- * - `nack(jobId, reason)` returns the job to the queue with a
16
- * reason classifier. `transient` re-enqueues for retry; `permanent`
17
- * moves the job to a dead-letter bucket; `timeout` is the
18
- * consumer-internal classifier when a job's visibility window
19
- * expires before completion.
20
- * - `extendVisibility(jobId, ms)` pushes the visibility-expires-at
21
- * forward by `ms`. Used by the consumer to keep long-running jobs
22
- * from being yanked out from under it.
23
- *
24
- * Adapters in this slice:
25
- * - `createInMemoryQueue<T>()` — single-process, fast, used by tests
26
- * and the example/smoke. Produces deterministic-ish JobIds via a
27
- * monotonic counter so the smoke can assert on them.
28
- * - SQS / Redis Streams / Postgres advisory-lock — interface-only
29
- * stubs (kept here as type declarations); concrete impls land in
30
- * follow-up PRs.
31
- */
32
- import { CrewhausError } from "@crewhaus/errors";
33
-
34
- // Section 30 — additional adapter family members
35
- export {
36
- createSqsAdapter,
37
- type SqsAdapterOptions,
38
- type SqsClientLike,
39
- } from "./adapters/sqs";
40
- export {
41
- createRedisStreamsAdapter,
42
- type RedisStreamsAdapterOptions,
43
- type RedisClientLike,
44
- } from "./adapters/redis-streams";
45
- export {
46
- createPostgresAdapter,
47
- type PostgresAdapterOptions,
48
- type PostgresClientLike,
49
- } from "./adapters/postgres";
50
-
51
- export class QueueProtocolError extends CrewhausError {
52
- override readonly name = "QueueProtocolError";
53
- constructor(message: string, cause?: unknown) {
54
- super("runtime", message, cause);
55
- }
56
- }
57
-
58
- export type JobId = string;
59
-
60
- export type Job<TInput = unknown> = {
61
- readonly id: JobId;
62
- /** 1-indexed; bumped each time the job is pulled (so retries see attempt > 1). */
63
- readonly attempt: number;
64
- readonly input: TInput;
65
- readonly enqueuedAt: string;
66
- /** ISO8601 timestamp when the visibility lease expires. */
67
- readonly visibilityExpiresAt: string;
68
- };
69
-
70
- export type NackReason = "transient" | "permanent" | "timeout";
71
-
72
- export type PullOptions = {
73
- readonly maxBatch: number;
74
- readonly visibilityTimeoutMs: number;
75
- };
76
-
77
- export interface QueueAdapter<TInput = unknown> {
78
- /** The adapter's identifier ("in-memory" | "sqs" | "redis-streams" | "postgres" | …). */
79
- readonly kind: string;
80
- pull(opts: PullOptions): Promise<ReadonlyArray<Job<TInput>>>;
81
- ack(jobId: JobId): Promise<void>;
82
- nack(jobId: JobId, reason: NackReason): Promise<void>;
83
- extendVisibility(jobId: JobId, additionalMs: number): Promise<void>;
84
- /**
85
- * Snapshot counts for observability + tests. Stable contract:
86
- * pending — visible-and-enqueued
87
- * inFlight — leased to a consumer (visibilityExpiresAt > now)
88
- * acked / nacked — terminal state counts since adapter creation
89
- * deadLetter — permanent-nack count (subset of nacked)
90
- */
91
- stats(): Promise<{
92
- pending: number;
93
- inFlight: number;
94
- acked: number;
95
- nacked: number;
96
- deadLetter: number;
97
- }>;
98
- }
99
-
100
- /**
101
- * Adapters that also act as producers (the in-memory tester does;
102
- * SQS/Redis Streams adapters keep enqueue inside their respective
103
- * client SDKs and don't need this interface) implement this.
104
- */
105
- export interface QueueProducer<TInput = unknown> {
106
- enqueue(input: TInput): Promise<JobId>;
107
- }
108
-
109
- // ---------------------------------------------------------------------------
110
- // In-memory adapter — single-process queue for tests + the BATCH smoke.
111
- // ---------------------------------------------------------------------------
112
-
113
- type InternalJob<TInput> = {
114
- id: JobId;
115
- input: TInput;
116
- enqueuedAt: number;
117
- attempts: number;
118
- /** 0 = not in flight, otherwise the wall-clock ms when the lease expires. */
119
- visibilityExpiresAt: number;
120
- /** terminal status, undefined while live. */
121
- status: "pending" | "in-flight" | "acked" | "nacked" | "dead-letter";
122
- };
123
-
124
- export type InMemoryQueueOptions<TInput = unknown> = {
125
- /**
126
- * Test-time clock injection. Defaults to `Date.now`. Returning a
127
- * monotonic count is the cleanest way to drive the visibility-timeout
128
- * branches deterministically.
129
- */
130
- readonly now?: () => number;
131
- readonly initialJobs?: ReadonlyArray<TInput>;
132
- /** Test seam: override the JobId generator. */
133
- readonly newJobId?: (counter: number) => JobId;
134
- };
135
-
136
- export interface InMemoryQueueAdapter<TInput = unknown>
137
- extends QueueAdapter<TInput>,
138
- QueueProducer<TInput> {
139
- readonly kind: "in-memory";
140
- /** Test helper: list the queue's view of jobs. */
141
- inspect(): ReadonlyArray<{
142
- readonly id: JobId;
143
- readonly status: InternalJob<TInput>["status"];
144
- readonly attempts: number;
145
- }>;
146
- }
147
-
148
- export function createInMemoryQueue<TInput>(
149
- opts: InMemoryQueueOptions<TInput> = {},
150
- ): InMemoryQueueAdapter<TInput> {
151
- const now = opts.now ?? Date.now;
152
- const newJobId = opts.newJobId ?? ((c) => `job_${c.toString(16).padStart(8, "0")}`);
153
- const jobs = new Map<JobId, InternalJob<TInput>>();
154
- let counter = 0;
155
- let acked = 0;
156
- let nacked = 0;
157
- let deadLetter = 0;
158
-
159
- function makeJob(input: TInput): InternalJob<TInput> {
160
- counter += 1;
161
- return {
162
- id: newJobId(counter),
163
- input,
164
- enqueuedAt: now(),
165
- attempts: 0,
166
- visibilityExpiresAt: 0,
167
- status: "pending",
168
- };
169
- }
170
-
171
- for (const seed of opts.initialJobs ?? []) {
172
- const j = makeJob(seed);
173
- jobs.set(j.id, j);
174
- }
175
-
176
- function reclaimExpired(): void {
177
- const t = now();
178
- for (const j of jobs.values()) {
179
- if (j.status === "in-flight" && j.visibilityExpiresAt <= t) {
180
- // Visibility lease expired without ack/nack — return to pending.
181
- j.status = "pending";
182
- j.visibilityExpiresAt = 0;
183
- }
184
- }
185
- }
186
-
187
- return {
188
- kind: "in-memory",
189
-
190
- async enqueue(input) {
191
- const j = makeJob(input);
192
- jobs.set(j.id, j);
193
- return j.id;
194
- },
195
-
196
- async pull(pullOpts) {
197
- reclaimExpired();
198
- const t = now();
199
- const out: Job<TInput>[] = [];
200
- // Stable order: insertion (oldest pending first).
201
- for (const j of jobs.values()) {
202
- if (out.length >= pullOpts.maxBatch) break;
203
- if (j.status !== "pending") continue;
204
- j.attempts += 1;
205
- j.status = "in-flight";
206
- j.visibilityExpiresAt = t + pullOpts.visibilityTimeoutMs;
207
- out.push({
208
- id: j.id,
209
- attempt: j.attempts,
210
- input: j.input,
211
- enqueuedAt: new Date(j.enqueuedAt).toISOString(),
212
- visibilityExpiresAt: new Date(j.visibilityExpiresAt).toISOString(),
213
- });
214
- }
215
- return out;
216
- },
217
-
218
- async ack(jobId) {
219
- const j = jobs.get(jobId);
220
- if (j === undefined) return; // idempotent
221
- if (j.status === "acked") return;
222
- j.status = "acked";
223
- acked += 1;
224
- jobs.delete(jobId);
225
- },
226
-
227
- async nack(jobId, reason) {
228
- const j = jobs.get(jobId);
229
- if (j === undefined) return;
230
- if (reason === "permanent") {
231
- j.status = "dead-letter";
232
- deadLetter += 1;
233
- nacked += 1;
234
- // Keep in map so inspect() can see DLQ items; SQS/etc. would
235
- // move it to a real DLQ.
236
- return;
237
- }
238
- // transient + timeout: return to pending. attempts stays bumped.
239
- j.status = "pending";
240
- j.visibilityExpiresAt = 0;
241
- nacked += 1;
242
- },
243
-
244
- async extendVisibility(jobId, additionalMs) {
245
- const j = jobs.get(jobId);
246
- if (j === undefined) {
247
- throw new QueueProtocolError(`extendVisibility: unknown jobId "${jobId}"`);
248
- }
249
- if (j.status !== "in-flight") {
250
- throw new QueueProtocolError(
251
- `extendVisibility: job "${jobId}" is not in flight (status=${j.status})`,
252
- );
253
- }
254
- j.visibilityExpiresAt += additionalMs;
255
- },
256
-
257
- async stats() {
258
- reclaimExpired();
259
- let pending = 0;
260
- let inFlight = 0;
261
- for (const j of jobs.values()) {
262
- if (j.status === "pending") pending += 1;
263
- if (j.status === "in-flight") inFlight += 1;
264
- }
265
- return { pending, inFlight, acked, nacked, deadLetter };
266
- },
267
-
268
- inspect() {
269
- return [...jobs.values()].map((j) => ({
270
- id: j.id,
271
- status: j.status,
272
- attempts: j.attempts,
273
- }));
274
- },
275
- };
276
- }