@crewhaus/queue-protocol 0.1.4 → 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.
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Section 30 — Postgres adapter for `@crewhaus/queue-protocol`. Uses
3
+ * advisory locks + a job table for visibility timeouts:
4
+ * pull → SELECT … FOR UPDATE SKIP LOCKED
5
+ * ack → DELETE FROM jobs WHERE id = $1
6
+ * nack(transient) → UPDATE jobs SET visibility_expires_at = NOW()
7
+ * nack(permanent) → INSERT INTO dead_letter_jobs + DELETE
8
+ * extendVisibility → UPDATE jobs SET visibility_expires_at = NOW() + ...
9
+ *
10
+ * v0 throws when `pg` isn't installed; the contract holds with a stub
11
+ * client.
12
+ */
13
+ import type { QueueAdapter } from "../index";
14
+ export type PostgresAdapterOptions = {
15
+ readonly tableName: string;
16
+ readonly deadLetterTable?: string;
17
+ readonly _client?: PostgresClientLike;
18
+ };
19
+ export type PostgresClientLike = {
20
+ query<T = unknown>(text: string, params?: unknown[]): Promise<{
21
+ rows: T[];
22
+ }>;
23
+ };
24
+ export declare function createPostgresAdapter<TInput = unknown>(opts: PostgresAdapterOptions): QueueAdapter<TInput>;
@@ -0,0 +1,87 @@
1
+ import { QueueProtocolError } from "../index";
2
+ export function createPostgresAdapter(opts) {
3
+ if (!opts.tableName)
4
+ throw new QueueProtocolError("postgres adapter requires tableName");
5
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(opts.tableName)) {
6
+ throw new QueueProtocolError(`postgres adapter: invalid tableName "${opts.tableName}"`);
7
+ }
8
+ const client = opts._client ?? requireClient();
9
+ let acked = 0;
10
+ let nacked = 0;
11
+ let deadLetter = 0;
12
+ const dlqTable = opts.deadLetterTable;
13
+ return {
14
+ kind: "postgres",
15
+ async pull(pullOpts) {
16
+ const visibilitySec = Math.ceil((pullOpts.visibilityTimeoutMs ?? 60_000) / 1000);
17
+ const result = await client.query(`WITH leased AS (
18
+ SELECT id FROM ${opts.tableName}
19
+ WHERE visibility_expires_at <= NOW()
20
+ ORDER BY enqueued_at ASC
21
+ LIMIT $1
22
+ FOR UPDATE SKIP LOCKED
23
+ )
24
+ UPDATE ${opts.tableName} t
25
+ SET visibility_expires_at = NOW() + INTERVAL '${visibilitySec} seconds',
26
+ attempt = attempt + 1
27
+ FROM leased
28
+ WHERE t.id = leased.id
29
+ RETURNING t.id, t.payload, t.enqueued_at, t.visibility_expires_at, t.attempt`, [pullOpts.maxBatch]);
30
+ const out = [];
31
+ for (const row of result.rows) {
32
+ let parsed;
33
+ try {
34
+ parsed = JSON.parse(row.payload);
35
+ }
36
+ catch {
37
+ parsed = row.payload;
38
+ }
39
+ out.push({
40
+ id: row.id,
41
+ input: parsed,
42
+ enqueuedAt: new Date(row.enqueued_at).toISOString(),
43
+ visibilityExpiresAt: new Date(row.visibility_expires_at).toISOString(),
44
+ attempt: row.attempt,
45
+ });
46
+ }
47
+ return out;
48
+ },
49
+ async ack(jobId) {
50
+ await client.query(`DELETE FROM ${opts.tableName} WHERE id = $1`, [jobId]);
51
+ acked++;
52
+ },
53
+ async nack(jobId, reason) {
54
+ if (reason === "permanent" && dlqTable) {
55
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(dlqTable)) {
56
+ throw new QueueProtocolError(`postgres adapter: invalid deadLetterTable "${dlqTable}"`);
57
+ }
58
+ await client.query(`INSERT INTO ${dlqTable} (id, payload, enqueued_at)
59
+ SELECT id, payload, enqueued_at FROM ${opts.tableName} WHERE id = $1`, [jobId]);
60
+ await client.query(`DELETE FROM ${opts.tableName} WHERE id = $1`, [jobId]);
61
+ deadLetter++;
62
+ }
63
+ else {
64
+ await client.query(`UPDATE ${opts.tableName} SET visibility_expires_at = NOW() WHERE id = $1`, [jobId]);
65
+ }
66
+ nacked++;
67
+ },
68
+ async extendVisibility(jobId, additionalMs) {
69
+ const sec = Math.ceil(additionalMs / 1000);
70
+ await client.query(`UPDATE ${opts.tableName} SET visibility_expires_at = NOW() + INTERVAL '${sec} seconds' WHERE id = $1`, [jobId]);
71
+ },
72
+ async stats() {
73
+ const pendingRes = await client.query(`SELECT COUNT(*)::text AS count FROM ${opts.tableName} WHERE visibility_expires_at <= NOW()`);
74
+ const inFlightRes = await client.query(`SELECT COUNT(*)::text AS count FROM ${opts.tableName} WHERE visibility_expires_at > NOW()`);
75
+ return {
76
+ pending: Number.parseInt(pendingRes.rows[0]?.count ?? "0", 10),
77
+ inFlight: Number.parseInt(inFlightRes.rows[0]?.count ?? "0", 10),
78
+ acked,
79
+ nacked,
80
+ deadLetter,
81
+ };
82
+ },
83
+ };
84
+ }
85
+ function requireClient() {
86
+ throw new QueueProtocolError("postgres adapter requires `pg` to be installed and DATABASE_URL configured. Pass an explicit `_client` to use a stub.");
87
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Section 30 — Redis Streams adapter for `@crewhaus/queue-protocol`.
3
+ *
4
+ * Maps to Redis Streams + consumer groups:
5
+ * pull → XREADGROUP
6
+ * ack → XACK
7
+ * nack(transient) → XADD back to the stream tail
8
+ * nack(permanent) → XADD to the dead-letter stream + XACK on main
9
+ * extendVisibility → no-op (Redis Streams uses pending-list min-idle-time
10
+ * instead of per-message visibility timeouts; consumers
11
+ * refresh by re-reading from the pending list)
12
+ *
13
+ * v0 throws when `ioredis` is not installed; the contract is correct +
14
+ * tested with stub clients.
15
+ */
16
+ import type { QueueAdapter } from "../index";
17
+ export type RedisStreamsAdapterOptions = {
18
+ readonly streamKey: string;
19
+ readonly consumerGroup: string;
20
+ readonly consumerName: string;
21
+ readonly deadLetterStream?: string;
22
+ readonly _client?: RedisClientLike;
23
+ };
24
+ export type RedisClientLike = {
25
+ xreadgroup(group: string, consumer: string, count: number, blockMs: number, streamKey: string, id: string): Promise<Array<{
26
+ stream: string;
27
+ messages: Array<{
28
+ id: string;
29
+ fields: Record<string, string>;
30
+ }>;
31
+ }> | null>;
32
+ xack(streamKey: string, group: string, ...ids: string[]): Promise<number>;
33
+ xadd(streamKey: string, id: string, ...fields: string[]): Promise<string>;
34
+ };
35
+ export declare function createRedisStreamsAdapter<TInput = unknown>(opts: RedisStreamsAdapterOptions): QueueAdapter<TInput>;
@@ -0,0 +1,68 @@
1
+ import { QueueProtocolError } from "../index";
2
+ export function createRedisStreamsAdapter(opts) {
3
+ if (!opts.streamKey)
4
+ throw new QueueProtocolError("redis-streams adapter requires streamKey");
5
+ if (!opts.consumerGroup)
6
+ throw new QueueProtocolError("redis-streams adapter requires consumerGroup");
7
+ const client = opts._client ?? requireClient();
8
+ let acked = 0;
9
+ let nacked = 0;
10
+ let deadLetter = 0;
11
+ return {
12
+ kind: "redis-streams",
13
+ async pull(pullOpts) {
14
+ const res = await client.xreadgroup(opts.consumerGroup, opts.consumerName, pullOpts.maxBatch,
15
+ // PullOptions carries no long-poll knob; read non-blocking and let the
16
+ // consumer drive polling cadence.
17
+ 0, opts.streamKey, ">");
18
+ if (!res)
19
+ return [];
20
+ const out = [];
21
+ const now = Date.now();
22
+ for (const stream of res) {
23
+ for (const m of stream.messages) {
24
+ let parsed;
25
+ try {
26
+ parsed = JSON.parse(m.fields["payload"] ?? "{}");
27
+ }
28
+ catch {
29
+ parsed = m.fields["payload"];
30
+ }
31
+ out.push({
32
+ id: m.id,
33
+ input: parsed,
34
+ enqueuedAt: new Date(now).toISOString(),
35
+ visibilityExpiresAt: new Date(now + pullOpts.visibilityTimeoutMs).toISOString(),
36
+ attempt: 1,
37
+ });
38
+ }
39
+ }
40
+ return out;
41
+ },
42
+ async ack(jobId) {
43
+ await client.xack(opts.streamKey, opts.consumerGroup, jobId);
44
+ acked++;
45
+ },
46
+ async nack(jobId, reason) {
47
+ if (reason === "permanent" && opts.deadLetterStream) {
48
+ await client.xadd(opts.deadLetterStream, "*", "payload", JSON.stringify({ jobId }));
49
+ deadLetter++;
50
+ }
51
+ else {
52
+ // Re-publish to the tail; the consumer-group will redeliver.
53
+ await client.xadd(opts.streamKey, "*", "payload", JSON.stringify({ retry: jobId }));
54
+ }
55
+ await client.xack(opts.streamKey, opts.consumerGroup, jobId);
56
+ nacked++;
57
+ },
58
+ async extendVisibility(_jobId, _additionalMs) {
59
+ // Redis Streams uses min-idle-time; nothing to do here.
60
+ },
61
+ async stats() {
62
+ return { pending: 0, inFlight: 0, acked, nacked, deadLetter };
63
+ },
64
+ };
65
+ }
66
+ function requireClient() {
67
+ throw new QueueProtocolError("redis-streams adapter requires `ioredis` to be installed and a Redis URL configured. Pass an explicit `_client` to use a stub.");
68
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Section 30 — SQS adapter for `@crewhaus/queue-protocol`. Production
3
+ * deployments using AWS SQS use this; the contract is identical to the
4
+ * in-memory adapter (pull/ack/nack/extendVisibility/stats).
5
+ *
6
+ * v0 ships with the *abstraction* and credential plumbing; the actual
7
+ * `@aws-sdk/client-sqs` calls are gated on the AWS SDK being installed
8
+ * and `AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY` (or the IAM
9
+ * instance-profile chain) being available. The implementation throws
10
+ * `QueueProtocolError("sqs adapter requires …")` when those are missing
11
+ * so a single misconfigured spec fails loud at boot rather than silently
12
+ * dropping jobs.
13
+ *
14
+ * The interface is correct + tested via the contract corpus; the live
15
+ * SQS smoke is gated behind `AWS_ACCESS_KEY_ID` + `SQS_QUEUE_URL` and
16
+ * runs only in deployments where those are set.
17
+ */
18
+ import type { QueueAdapter } from "../index";
19
+ export type SqsAdapterOptions = {
20
+ readonly queueUrl: string;
21
+ readonly region: string;
22
+ readonly accessKeyId?: string;
23
+ readonly secretAccessKey?: string;
24
+ /** Test override: inject a fake SQS client. */
25
+ readonly _client?: SqsClientLike;
26
+ };
27
+ export type SqsClientLike = {
28
+ receiveMessage(input: {
29
+ QueueUrl: string;
30
+ MaxNumberOfMessages: number;
31
+ VisibilityTimeout: number;
32
+ WaitTimeSeconds: number;
33
+ }): Promise<{
34
+ Messages?: Array<{
35
+ MessageId?: string;
36
+ ReceiptHandle?: string;
37
+ Body?: string;
38
+ }>;
39
+ }>;
40
+ deleteMessage(input: {
41
+ QueueUrl: string;
42
+ ReceiptHandle: string;
43
+ }): Promise<void>;
44
+ changeMessageVisibility(input: {
45
+ QueueUrl: string;
46
+ ReceiptHandle: string;
47
+ VisibilityTimeout: number;
48
+ }): Promise<void>;
49
+ };
50
+ export declare function createSqsAdapter<TInput = unknown>(opts: SqsAdapterOptions): QueueAdapter<TInput>;
@@ -0,0 +1,100 @@
1
+ import { QueueProtocolError } from "../index";
2
+ export function createSqsAdapter(opts) {
3
+ if (!opts.queueUrl) {
4
+ throw new QueueProtocolError("sqs adapter requires queueUrl");
5
+ }
6
+ const client = opts._client ?? requireSdkClient(opts);
7
+ // Map ReceiptHandle by jobId so ack/nack/extendVisibility can find it.
8
+ const receiptByJobId = new Map();
9
+ let acked = 0;
10
+ let nacked = 0;
11
+ let deadLetter = 0;
12
+ return {
13
+ kind: "sqs",
14
+ async pull(pullOpts) {
15
+ const result = await client.receiveMessage({
16
+ QueueUrl: opts.queueUrl,
17
+ MaxNumberOfMessages: Math.min(10, pullOpts.maxBatch),
18
+ VisibilityTimeout: Math.ceil(pullOpts.visibilityTimeoutMs / 1000),
19
+ // PullOptions carries no long-poll knob; use SQS short polling.
20
+ WaitTimeSeconds: 0,
21
+ });
22
+ const out = [];
23
+ const now = Date.now();
24
+ for (const m of result.Messages ?? []) {
25
+ if (!m.MessageId || !m.ReceiptHandle || m.Body === undefined)
26
+ continue;
27
+ receiptByJobId.set(m.MessageId, m.ReceiptHandle);
28
+ let parsed;
29
+ try {
30
+ parsed = JSON.parse(m.Body);
31
+ }
32
+ catch {
33
+ parsed = m.Body;
34
+ }
35
+ out.push({
36
+ id: m.MessageId,
37
+ input: parsed,
38
+ enqueuedAt: new Date(now).toISOString(),
39
+ visibilityExpiresAt: new Date(now + pullOpts.visibilityTimeoutMs).toISOString(),
40
+ attempt: 1,
41
+ });
42
+ }
43
+ return out;
44
+ },
45
+ async ack(jobId) {
46
+ const handle = receiptByJobId.get(jobId);
47
+ if (!handle)
48
+ throw new QueueProtocolError(`sqs ack: receipt for ${jobId} not found`);
49
+ await client.deleteMessage({ QueueUrl: opts.queueUrl, ReceiptHandle: handle });
50
+ receiptByJobId.delete(jobId);
51
+ acked++;
52
+ },
53
+ async nack(jobId, reason) {
54
+ const handle = receiptByJobId.get(jobId);
55
+ if (!handle)
56
+ throw new QueueProtocolError(`sqs nack: receipt for ${jobId} not found`);
57
+ if (reason === "permanent") {
58
+ // Delete from the main queue; the redrive policy on the SQS side
59
+ // moves it to the dead-letter queue automatically when configured.
60
+ await client.deleteMessage({ QueueUrl: opts.queueUrl, ReceiptHandle: handle });
61
+ deadLetter++;
62
+ }
63
+ else {
64
+ // Reset visibility timeout to 0 so the message is immediately re-driven.
65
+ await client.changeMessageVisibility({
66
+ QueueUrl: opts.queueUrl,
67
+ ReceiptHandle: handle,
68
+ VisibilityTimeout: 0,
69
+ });
70
+ }
71
+ receiptByJobId.delete(jobId);
72
+ nacked++;
73
+ },
74
+ async extendVisibility(jobId, additionalMs) {
75
+ const handle = receiptByJobId.get(jobId);
76
+ if (!handle)
77
+ throw new QueueProtocolError(`sqs extendVisibility: receipt for ${jobId} not found`);
78
+ await client.changeMessageVisibility({
79
+ QueueUrl: opts.queueUrl,
80
+ ReceiptHandle: handle,
81
+ VisibilityTimeout: Math.ceil(additionalMs / 1000),
82
+ });
83
+ },
84
+ async stats() {
85
+ // SQS doesn't expose accurate counters cheaply; we return the local
86
+ // counters for in-flight + acked/nacked + 0 for pending (consumers
87
+ // should query CloudWatch for that).
88
+ return {
89
+ pending: 0,
90
+ inFlight: receiptByJobId.size,
91
+ acked,
92
+ nacked,
93
+ deadLetter,
94
+ };
95
+ },
96
+ };
97
+ }
98
+ function requireSdkClient(_opts) {
99
+ throw new QueueProtocolError("sqs adapter requires `@aws-sdk/client-sqs` to be installed and AWS credentials configured. Pass an explicit `_client` to use a stub.");
100
+ }
@@ -0,0 +1,115 @@
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
+ export { createSqsAdapter, type SqsAdapterOptions, type SqsClientLike, } from "./adapters/sqs";
34
+ export { createRedisStreamsAdapter, type RedisStreamsAdapterOptions, type RedisClientLike, } from "./adapters/redis-streams";
35
+ export { createPostgresAdapter, type PostgresAdapterOptions, type PostgresClientLike, } from "./adapters/postgres";
36
+ export declare class QueueProtocolError extends CrewhausError {
37
+ readonly name = "QueueProtocolError";
38
+ constructor(message: string, cause?: unknown);
39
+ }
40
+ export type JobId = string;
41
+ export type Job<TInput = unknown> = {
42
+ readonly id: JobId;
43
+ /** 1-indexed; bumped each time the job is pulled (so retries see attempt > 1). */
44
+ readonly attempt: number;
45
+ readonly input: TInput;
46
+ readonly enqueuedAt: string;
47
+ /** ISO8601 timestamp when the visibility lease expires. */
48
+ readonly visibilityExpiresAt: string;
49
+ };
50
+ export type NackReason = "transient" | "permanent" | "timeout";
51
+ export type PullOptions = {
52
+ readonly maxBatch: number;
53
+ readonly visibilityTimeoutMs: number;
54
+ };
55
+ export interface QueueAdapter<TInput = unknown> {
56
+ /** The adapter's identifier ("in-memory" | "sqs" | "redis-streams" | "postgres" | …). */
57
+ readonly kind: string;
58
+ pull(opts: PullOptions): Promise<ReadonlyArray<Job<TInput>>>;
59
+ ack(jobId: JobId): Promise<void>;
60
+ nack(jobId: JobId, reason: NackReason): Promise<void>;
61
+ extendVisibility(jobId: JobId, additionalMs: number): Promise<void>;
62
+ /**
63
+ * Snapshot counts for observability + tests. Stable contract:
64
+ * pending — visible-and-enqueued
65
+ * inFlight — leased to a consumer (visibilityExpiresAt > now)
66
+ * acked / nacked — terminal state counts since adapter creation
67
+ * deadLetter — permanent-nack count (subset of nacked)
68
+ */
69
+ stats(): Promise<{
70
+ pending: number;
71
+ inFlight: number;
72
+ acked: number;
73
+ nacked: number;
74
+ deadLetter: number;
75
+ }>;
76
+ }
77
+ /**
78
+ * Adapters that also act as producers (the in-memory tester does;
79
+ * SQS/Redis Streams adapters keep enqueue inside their respective
80
+ * client SDKs and don't need this interface) implement this.
81
+ */
82
+ export interface QueueProducer<TInput = unknown> {
83
+ enqueue(input: TInput): Promise<JobId>;
84
+ }
85
+ type InternalJob<TInput> = {
86
+ id: JobId;
87
+ input: TInput;
88
+ enqueuedAt: number;
89
+ attempts: number;
90
+ /** 0 = not in flight, otherwise the wall-clock ms when the lease expires. */
91
+ visibilityExpiresAt: number;
92
+ /** terminal status, undefined while live. */
93
+ status: "pending" | "in-flight" | "acked" | "nacked" | "dead-letter";
94
+ };
95
+ export type InMemoryQueueOptions<TInput = unknown> = {
96
+ /**
97
+ * Test-time clock injection. Defaults to `Date.now`. Returning a
98
+ * monotonic count is the cleanest way to drive the visibility-timeout
99
+ * branches deterministically.
100
+ */
101
+ readonly now?: () => number;
102
+ readonly initialJobs?: ReadonlyArray<TInput>;
103
+ /** Test seam: override the JobId generator. */
104
+ readonly newJobId?: (counter: number) => JobId;
105
+ };
106
+ export interface InMemoryQueueAdapter<TInput = unknown> extends QueueAdapter<TInput>, QueueProducer<TInput> {
107
+ readonly kind: "in-memory";
108
+ /** Test helper: list the queue's view of jobs. */
109
+ inspect(): ReadonlyArray<{
110
+ readonly id: JobId;
111
+ readonly status: InternalJob<TInput>["status"];
112
+ readonly attempts: number;
113
+ }>;
114
+ }
115
+ export declare function createInMemoryQueue<TInput>(opts?: InMemoryQueueOptions<TInput>): InMemoryQueueAdapter<TInput>;
package/dist/index.js ADDED
@@ -0,0 +1,163 @@
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
+ // Section 30 — additional adapter family members
34
+ export { createSqsAdapter, } from "./adapters/sqs";
35
+ export { createRedisStreamsAdapter, } from "./adapters/redis-streams";
36
+ export { createPostgresAdapter, } from "./adapters/postgres";
37
+ export class QueueProtocolError extends CrewhausError {
38
+ name = "QueueProtocolError";
39
+ constructor(message, cause) {
40
+ super("runtime", message, cause);
41
+ }
42
+ }
43
+ export function createInMemoryQueue(opts = {}) {
44
+ const now = opts.now ?? Date.now;
45
+ const newJobId = opts.newJobId ?? ((c) => `job_${c.toString(16).padStart(8, "0")}`);
46
+ const jobs = new Map();
47
+ let counter = 0;
48
+ let acked = 0;
49
+ let nacked = 0;
50
+ let deadLetter = 0;
51
+ function makeJob(input) {
52
+ counter += 1;
53
+ return {
54
+ id: newJobId(counter),
55
+ input,
56
+ enqueuedAt: now(),
57
+ attempts: 0,
58
+ visibilityExpiresAt: 0,
59
+ status: "pending",
60
+ };
61
+ }
62
+ for (const seed of opts.initialJobs ?? []) {
63
+ const j = makeJob(seed);
64
+ jobs.set(j.id, j);
65
+ }
66
+ function reclaimExpired() {
67
+ const t = now();
68
+ for (const j of jobs.values()) {
69
+ if (j.status === "in-flight" && j.visibilityExpiresAt <= t) {
70
+ // Visibility lease expired without ack/nack — return to pending.
71
+ j.status = "pending";
72
+ j.visibilityExpiresAt = 0;
73
+ }
74
+ }
75
+ }
76
+ return {
77
+ kind: "in-memory",
78
+ async enqueue(input) {
79
+ const j = makeJob(input);
80
+ jobs.set(j.id, j);
81
+ return j.id;
82
+ },
83
+ async pull(pullOpts) {
84
+ reclaimExpired();
85
+ const t = now();
86
+ const out = [];
87
+ // Stable order: insertion (oldest pending first).
88
+ for (const j of jobs.values()) {
89
+ if (out.length >= pullOpts.maxBatch)
90
+ break;
91
+ if (j.status !== "pending")
92
+ continue;
93
+ j.attempts += 1;
94
+ j.status = "in-flight";
95
+ j.visibilityExpiresAt = t + pullOpts.visibilityTimeoutMs;
96
+ out.push({
97
+ id: j.id,
98
+ attempt: j.attempts,
99
+ input: j.input,
100
+ enqueuedAt: new Date(j.enqueuedAt).toISOString(),
101
+ visibilityExpiresAt: new Date(j.visibilityExpiresAt).toISOString(),
102
+ });
103
+ }
104
+ return out;
105
+ },
106
+ async ack(jobId) {
107
+ const j = jobs.get(jobId);
108
+ if (j === undefined)
109
+ return; // idempotent
110
+ if (j.status === "acked")
111
+ return;
112
+ j.status = "acked";
113
+ acked += 1;
114
+ jobs.delete(jobId);
115
+ },
116
+ async nack(jobId, reason) {
117
+ const j = jobs.get(jobId);
118
+ if (j === undefined)
119
+ return;
120
+ if (reason === "permanent") {
121
+ j.status = "dead-letter";
122
+ deadLetter += 1;
123
+ nacked += 1;
124
+ // Keep in map so inspect() can see DLQ items; SQS/etc. would
125
+ // move it to a real DLQ.
126
+ return;
127
+ }
128
+ // transient + timeout: return to pending. attempts stays bumped.
129
+ j.status = "pending";
130
+ j.visibilityExpiresAt = 0;
131
+ nacked += 1;
132
+ },
133
+ async extendVisibility(jobId, additionalMs) {
134
+ const j = jobs.get(jobId);
135
+ if (j === undefined) {
136
+ throw new QueueProtocolError(`extendVisibility: unknown jobId "${jobId}"`);
137
+ }
138
+ if (j.status !== "in-flight") {
139
+ throw new QueueProtocolError(`extendVisibility: job "${jobId}" is not in flight (status=${j.status})`);
140
+ }
141
+ j.visibilityExpiresAt += additionalMs;
142
+ },
143
+ async stats() {
144
+ reclaimExpired();
145
+ let pending = 0;
146
+ let inFlight = 0;
147
+ for (const j of jobs.values()) {
148
+ if (j.status === "pending")
149
+ pending += 1;
150
+ if (j.status === "in-flight")
151
+ inFlight += 1;
152
+ }
153
+ return { pending, inFlight, acked, nacked, deadLetter };
154
+ },
155
+ inspect() {
156
+ return [...jobs.values()].map((j) => ({
157
+ id: j.id,
158
+ status: j.status,
159
+ attempts: j.attempts,
160
+ }));
161
+ },
162
+ };
163
+ }
package/package.json CHANGED
@@ -1,18 +1,21 @@
1
1
  {
2
2
  "name": "@crewhaus/queue-protocol",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "type": "module",
5
5
  "description": "Abstract queue interface + in-memory adapter for the BATCH target shape (Section 23 BATCH)",
6
- "main": "src/index.ts",
7
- "types": "src/index.ts",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
8
  "exports": {
9
- ".": "./src/index.ts"
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
10
13
  },
11
14
  "scripts": {
12
15
  "test": "bun test src"
13
16
  },
14
17
  "dependencies": {
15
- "@crewhaus/errors": "0.1.4"
18
+ "@crewhaus/errors": "0.1.5"
16
19
  },
17
20
  "license": "Apache-2.0",
18
21
  "author": {
@@ -32,5 +35,5 @@
32
35
  "publishConfig": {
33
36
  "access": "public"
34
37
  },
35
- "files": ["src", "README.md", "LICENSE", "NOTICE"]
38
+ "files": ["dist", "README.md", "LICENSE", "NOTICE"]
36
39
  }