@panorama-industries/effect-vercel-queue 0.0.0-stage → 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.
Files changed (123) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +13 -2
  3. package/dist/contracts/message-handling.d.ts +128 -0
  4. package/dist/contracts/message-handling.d.ts.map +1 -0
  5. package/dist/contracts/message-handling.js +4 -0
  6. package/dist/contracts/message-handling.js.map +1 -0
  7. package/dist/contracts/queue-api.d.ts +255 -0
  8. package/dist/contracts/queue-api.d.ts.map +1 -0
  9. package/dist/contracts/queue-api.js +18 -0
  10. package/dist/contracts/queue-api.js.map +1 -0
  11. package/dist/contracts/queue-config.d.ts +87 -0
  12. package/dist/contracts/queue-config.d.ts.map +1 -0
  13. package/dist/contracts/queue-config.js +7 -0
  14. package/dist/contracts/queue-config.js.map +1 -0
  15. package/dist/contracts/queue-consumer.d.ts +30 -0
  16. package/dist/contracts/queue-consumer.d.ts.map +1 -0
  17. package/dist/contracts/queue-consumer.js +15 -0
  18. package/dist/contracts/queue-consumer.js.map +1 -0
  19. package/dist/contracts/queue-domain.d.ts +171 -0
  20. package/dist/contracts/queue-domain.d.ts.map +1 -0
  21. package/dist/contracts/queue-domain.js +137 -0
  22. package/dist/contracts/queue-domain.js.map +1 -0
  23. package/dist/contracts/queue-poller.d.ts +169 -0
  24. package/dist/contracts/queue-poller.d.ts.map +1 -0
  25. package/dist/contracts/queue-poller.js +10 -0
  26. package/dist/contracts/queue-poller.js.map +1 -0
  27. package/dist/contracts/queue-producer.d.ts +23 -0
  28. package/dist/contracts/queue-producer.d.ts.map +1 -0
  29. package/dist/contracts/queue-producer.js +5 -0
  30. package/dist/contracts/queue-producer.js.map +1 -0
  31. package/dist/contracts/topic.d.ts +47 -0
  32. package/dist/contracts/topic.d.ts.map +1 -0
  33. package/dist/contracts/topic.js +34 -0
  34. package/dist/contracts/topic.js.map +1 -0
  35. package/dist/implementation/queue-api-live.d.ts +16 -0
  36. package/dist/implementation/queue-api-live.d.ts.map +1 -0
  37. package/dist/implementation/queue-api-live.js +326 -0
  38. package/dist/implementation/queue-api-live.js.map +1 -0
  39. package/dist/implementation/queue-config-live.d.ts +37 -0
  40. package/dist/implementation/queue-config-live.d.ts.map +1 -0
  41. package/dist/implementation/queue-config-live.js +99 -0
  42. package/dist/implementation/queue-config-live.js.map +1 -0
  43. package/dist/implementation/queue-consumer-live.d.ts +14 -0
  44. package/dist/implementation/queue-consumer-live.d.ts.map +1 -0
  45. package/dist/implementation/queue-consumer-live.js +56 -0
  46. package/dist/implementation/queue-consumer-live.js.map +1 -0
  47. package/dist/implementation/queue-live.d.ts +15 -0
  48. package/dist/implementation/queue-live.d.ts.map +1 -0
  49. package/dist/implementation/queue-live.js +16 -0
  50. package/dist/implementation/queue-live.js.map +1 -0
  51. package/dist/implementation/queue-poller-live.d.ts +14 -0
  52. package/dist/implementation/queue-poller-live.d.ts.map +1 -0
  53. package/dist/implementation/queue-poller-live.js +66 -0
  54. package/dist/implementation/queue-poller-live.js.map +1 -0
  55. package/dist/implementation/queue-producer-live.d.ts +9 -0
  56. package/dist/implementation/queue-producer-live.d.ts.map +1 -0
  57. package/dist/implementation/queue-producer-live.js +54 -0
  58. package/dist/implementation/queue-producer-live.js.map +1 -0
  59. package/dist/index.d.ts +16 -0
  60. package/dist/index.d.ts.map +1 -0
  61. package/dist/index.js +16 -0
  62. package/dist/index.js.map +1 -0
  63. package/dist/internal/cloud-event.d.ts +46 -0
  64. package/dist/internal/cloud-event.d.ts.map +1 -0
  65. package/dist/internal/cloud-event.js +140 -0
  66. package/dist/internal/cloud-event.js.map +1 -0
  67. package/dist/internal/lease.d.ts +23 -0
  68. package/dist/internal/lease.d.ts.map +1 -0
  69. package/dist/internal/lease.js +44 -0
  70. package/dist/internal/lease.js.map +1 -0
  71. package/dist/internal/multipart.d.ts +23 -0
  72. package/dist/internal/multipart.d.ts.map +1 -0
  73. package/dist/internal/multipart.js +205 -0
  74. package/dist/internal/multipart.js.map +1 -0
  75. package/dist/internal/process.d.ts +25 -0
  76. package/dist/internal/process.d.ts.map +1 -0
  77. package/dist/internal/process.js +95 -0
  78. package/dist/internal/process.js.map +1 -0
  79. package/dist/internal/queue-protocol.d.ts +38 -0
  80. package/dist/internal/queue-protocol.d.ts.map +1 -0
  81. package/dist/internal/queue-protocol.js +119 -0
  82. package/dist/internal/queue-protocol.js.map +1 -0
  83. package/dist/internal/version.d.ts +4 -0
  84. package/dist/internal/version.d.ts.map +1 -0
  85. package/dist/internal/version.js +4 -0
  86. package/dist/internal/version.js.map +1 -0
  87. package/dist/retry-policy.d.ts +26 -0
  88. package/dist/retry-policy.d.ts.map +1 -0
  89. package/dist/retry-policy.js +34 -0
  90. package/dist/retry-policy.js.map +1 -0
  91. package/dist/testing/index.d.ts +2 -0
  92. package/dist/testing/index.d.ts.map +1 -0
  93. package/dist/testing/index.js +2 -0
  94. package/dist/testing/index.js.map +1 -0
  95. package/dist/testing/test-queue.d.ts +74 -0
  96. package/dist/testing/test-queue.d.ts.map +1 -0
  97. package/dist/testing/test-queue.js +253 -0
  98. package/dist/testing/test-queue.js.map +1 -0
  99. package/package.json +59 -3
  100. package/src/contracts/message-handling.ts +68 -0
  101. package/src/contracts/queue-api.ts +199 -0
  102. package/src/contracts/queue-config.ts +47 -0
  103. package/src/contracts/queue-consumer.ts +40 -0
  104. package/src/contracts/queue-domain.ts +202 -0
  105. package/src/contracts/queue-poller.ts +76 -0
  106. package/src/contracts/queue-producer.ts +40 -0
  107. package/src/contracts/topic.ts +76 -0
  108. package/src/implementation/queue-api-live.ts +637 -0
  109. package/src/implementation/queue-config-live.ts +178 -0
  110. package/src/implementation/queue-consumer-live.ts +112 -0
  111. package/src/implementation/queue-live.ts +32 -0
  112. package/src/implementation/queue-poller-live.ts +130 -0
  113. package/src/implementation/queue-producer-live.ts +89 -0
  114. package/src/index.ts +19 -0
  115. package/src/internal/cloud-event.ts +244 -0
  116. package/src/internal/lease.ts +84 -0
  117. package/src/internal/multipart.ts +240 -0
  118. package/src/internal/process.ts +194 -0
  119. package/src/internal/queue-protocol.ts +169 -0
  120. package/src/internal/version.ts +5 -0
  121. package/src/retry-policy.ts +48 -0
  122. package/src/testing/index.ts +1 -0
  123. package/src/testing/test-queue.ts +469 -0
@@ -0,0 +1,199 @@
1
+ import type { VercelApiError, VercelRegion } from "@panorama-industries/effect-vercel-core";
2
+ import type { Array, Duration, Effect, Option } from "effect";
3
+
4
+ import { Context, Data, Schema } from "effect";
5
+
6
+ import type {
7
+ BatchSizeOutOfRange,
8
+ ConcurrencyKey,
9
+ ConsumerDiscoveryFailed,
10
+ ConsumerGroupName,
11
+ ConsumerRegistryNotConfigured,
12
+ CursorFallback,
13
+ IdempotencyKey,
14
+ MessageAlreadyProcessed,
15
+ MessageCorrupted,
16
+ MessageHandle,
17
+ MessageId,
18
+ MessageNotAvailable,
19
+ MessageNotFound,
20
+ ReceivedMessage,
21
+ TopicName,
22
+ } from "./queue-domain.ts";
23
+
24
+ /** Maximum number of messages one receive call may lease: an integer from 1 to 10. */
25
+ export const ReceiveLimit = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 10 })).pipe(
26
+ Schema.brand("ReceiveLimit"),
27
+ );
28
+
29
+ /** Maximum number of messages one receive call may lease: an integer from 1 to 10. */
30
+ export type ReceiveLimit = typeof ReceiveLimit.Type;
31
+
32
+ /** Options for publishing one message, shared by single and batch sends. */
33
+ export interface PublishOptions {
34
+ /** Drops a later publish carrying the same key while the first is retained. */
35
+ readonly idempotencyKey?: IdempotencyKey;
36
+
37
+ /** How long the message is kept: 1 minute to 7 days, default 24 hours. */
38
+ readonly retention?: Duration.Input;
39
+
40
+ /** Delay before the message becomes visible: up to 7 days and never beyond its retention. */
41
+ readonly delay?: Duration.Input;
42
+
43
+ /** Custom headers stored with the message. */
44
+ readonly headers?: Readonly<Record<string, string>>;
45
+
46
+ /** **Experimental.** See {@link ConcurrencyKey}. */
47
+ readonly experimentalConcurrencyKey?: ConcurrencyKey;
48
+ }
49
+
50
+ /** A serialized message ready to publish. */
51
+ export interface OutgoingMessage extends PublishOptions {
52
+ readonly body: Uint8Array;
53
+ readonly contentType: string;
54
+ }
55
+
56
+ /** Outcome of publishing one message. */
57
+ export type SendResult = Data.TaggedEnum<{
58
+ /** The queue accepted the message and assigned it an identifier. */
59
+ Sent: { readonly messageId: MessageId };
60
+ /** The queue accepted the message for deferred processing; it has no identifier yet. */
61
+ Deferred: {};
62
+ }>;
63
+
64
+ /** Constructors and matchers for {@link SendResult}. */
65
+ export const SendResult = Data.taggedEnum<SendResult>();
66
+
67
+ /**
68
+ * Outcome for one entry of a batch send, in input order.
69
+ *
70
+ * A request-level failure returns no per-entry results and may happen after the queue accepted
71
+ * some entries; set an idempotency key on every entry from the first attempt to make retries safe.
72
+ */
73
+ export type BatchSendResult = Data.TaggedEnum<{
74
+ Sent: { readonly messageId: MessageId };
75
+ Deferred: {};
76
+ /** The queue rejected this entry; the others may still have been accepted. */
77
+ Failed: {
78
+ readonly status: number;
79
+ readonly message: string;
80
+ /** Whether publishing this entry again may succeed. */
81
+ readonly retryable: boolean;
82
+ };
83
+ }>;
84
+
85
+ /** Constructors and matchers for {@link BatchSendResult}. */
86
+ export const BatchSendResult = Data.taggedEnum<BatchSendResult>();
87
+
88
+ /** Result of extending a lease with an experimental handoff request. */
89
+ export const HandoffOutcome = Schema.Literals(["accepted", "not_immediate", "limit_reached"]);
90
+
91
+ /** Result of extending a lease with an experimental handoff request. */
92
+ export type HandoffOutcome = typeof HandoffOutcome.Type;
93
+
94
+ /** Options for leasing messages from a consumer group. */
95
+ export interface ReceiveOptions {
96
+ /** Maximum messages to lease; the queue's default is 1. */
97
+ readonly limit?: ReceiveLimit;
98
+
99
+ /** How long leased messages stay invisible to other consumers. */
100
+ readonly visibilityTimeout?: Duration.Input;
101
+
102
+ /** Where a consumer group with no stored position starts reading. */
103
+ readonly cursorFallback?: CursorFallback;
104
+
105
+ /** Region to receive from; defaults to `QueueConfig.region`. */
106
+ readonly region?: VercelRegion;
107
+ }
108
+
109
+ /** Options for leasing one message by identifier. */
110
+ export interface ReceiveByIdOptions {
111
+ readonly visibilityTimeout?: Duration.Input;
112
+
113
+ /** Region that holds the message; defaults to `QueueConfig.region`. */
114
+ readonly region?: VercelRegion;
115
+ }
116
+
117
+ /** Failures every queue call can report. */
118
+ export type QueueRequestError = VercelApiError | ConsumerRegistryNotConfigured;
119
+
120
+ /** Failures of publishing. */
121
+ export type QueueSendError = QueueRequestError | ConsumerDiscoveryFailed;
122
+
123
+ /** Failures of publishing a batch. */
124
+ export type QueueSendBatchError = QueueSendError | BatchSizeOutOfRange;
125
+
126
+ /** Failures of leasing a specific message. */
127
+ export type QueueReceiveByIdError =
128
+ | QueueRequestError
129
+ | MessageNotFound
130
+ | MessageNotAvailable
131
+ | MessageAlreadyProcessed
132
+ | MessageCorrupted;
133
+
134
+ /** Failures of settling a lease: acknowledging it or changing its visibility. */
135
+ export type QueueLeaseError = QueueRequestError | MessageNotFound | MessageNotAvailable;
136
+
137
+ /**
138
+ * The Vercel Queues HTTP API, one operation per endpoint, with bodies as bytes.
139
+ *
140
+ * Most code uses `QueueProducer`, `QueueConsumer` or `QueuePoller`, which add typed topics, leases
141
+ * and tracing on top of this service.
142
+ */
143
+ export class QueueApi extends Context.Service<
144
+ QueueApi,
145
+ {
146
+ /** Publish one message (`POST /api/v3/topic/{topic}`). */
147
+ readonly send: (
148
+ topic: TopicName,
149
+ message: OutgoingMessage,
150
+ ) => Effect.Effect<SendResult, QueueSendError>;
151
+
152
+ /** Publish 1 to 100 messages in one request (`POST /api/v3/topic/{topic}/batch`). */
153
+ readonly sendBatch: (
154
+ topic: TopicName,
155
+ messages: Array.NonEmptyReadonlyArray<OutgoingMessage>,
156
+ ) => Effect.Effect<ReadonlyArray<BatchSendResult>, QueueSendBatchError>;
157
+
158
+ /**
159
+ * Lease the next available messages of a consumer group. An empty array means the queue had
160
+ * nothing to deliver.
161
+ */
162
+ readonly receive: (
163
+ topic: TopicName,
164
+ consumerGroup: ConsumerGroupName,
165
+ options?: ReceiveOptions,
166
+ ) => Effect.Effect<ReadonlyArray<ReceivedMessage>, QueueRequestError | MessageCorrupted>;
167
+
168
+ /** Lease one message by identifier, as push delivery does for messages sent without a body. */
169
+ readonly receiveById: (
170
+ topic: TopicName,
171
+ consumerGroup: ConsumerGroupName,
172
+ messageId: MessageId,
173
+ options?: ReceiveByIdOptions,
174
+ ) => Effect.Effect<ReceivedMessage, QueueReceiveByIdError>;
175
+
176
+ /**
177
+ * Acknowledge a leased message so the consumer group never receives it again.
178
+ *
179
+ * `experimentalIfCoalesced` acknowledges only if no more than that many messages have been
180
+ * coalesced into the delivery; otherwise it fails with `ConcurrencyCoalescedPending`.
181
+ */
182
+ readonly acknowledge: (
183
+ handle: MessageHandle,
184
+ options?: { readonly experimentalIfCoalesced?: number },
185
+ ) => Effect.Effect<void, QueueLeaseError>;
186
+
187
+ /**
188
+ * Set how long a leased message stays invisible, counted from now. A zero timeout releases the
189
+ * lease for immediate redelivery.
190
+ *
191
+ * @returns The handoff outcome when `experimentalHandoff` was requested, otherwise none.
192
+ */
193
+ readonly changeVisibility: (
194
+ handle: MessageHandle,
195
+ visibilityTimeout: Duration.Input,
196
+ options?: { readonly experimentalHandoff?: boolean },
197
+ ) => Effect.Effect<Option.Option<HandoffOutcome>, QueueLeaseError>;
198
+ }
199
+ >()("@effect-vercel/queue/QueueApi") {}
@@ -0,0 +1,47 @@
1
+ import type { DeploymentId, VercelRegion } from "@panorama-industries/effect-vercel-core";
2
+ import type { Option, Redacted } from "effect";
3
+
4
+ import { Context, Data } from "effect";
5
+
6
+ /** How the client authenticates to the queue service. */
7
+ export type QueueAuth = Data.TaggedEnum<{
8
+ /** Use the Vercel OIDC token of the current request (production and `vercel env pull`). */
9
+ Oidc: {};
10
+ /** Use a fixed bearer token, such as the local broker's `VERCEL_QUEUE_TOKEN`. */
11
+ Token: { readonly token: Redacted.Redacted<string> };
12
+ }>;
13
+
14
+ /** Constructors and matchers for {@link QueueAuth}. */
15
+ export const QueueAuth = Data.taggedEnum<QueueAuth>();
16
+
17
+ /** Where and as whom the queue client talks to Vercel Queues. */
18
+ export class QueueConfig extends Context.Service<
19
+ QueueConfig,
20
+ {
21
+ /** Region for sends and receives that do not name one. */
22
+ readonly region: VercelRegion;
23
+
24
+ /**
25
+ * Base URL of the queue service in a region; the client appends `/api/v3/...`. The default is
26
+ * `https://{region}.vercel-queue.com`; the local broker ignores the region.
27
+ */
28
+ readonly baseUrl: (region: VercelRegion) => URL;
29
+
30
+ readonly auth: QueueAuth;
31
+
32
+ /**
33
+ * Deployment sent with consumer calls (receive, acknowledge, change visibility) so the queue
34
+ * routes them to this deployment's consumer registration. None for the local broker.
35
+ */
36
+ readonly deploymentId: Option.Option<DeploymentId>;
37
+
38
+ /**
39
+ * Whether sends are pinned to {@link deploymentId}, so only this deployment's consumers receive
40
+ * them. Pinning is the default on Vercel; turning it off lets newer deployments consume.
41
+ */
42
+ readonly pinSends: boolean;
43
+
44
+ /** Extra headers sent with every request. */
45
+ readonly headers: Readonly<Record<string, string>>;
46
+ }
47
+ >()("@effect-vercel/queue/QueueConfig") {}
@@ -0,0 +1,40 @@
1
+ import type { Effect } from "effect";
2
+ import type { HttpServerRequest, HttpServerResponse } from "effect/http";
3
+
4
+ import { Context } from "effect";
5
+
6
+ import type { ConsumeOptions, MessageHandler } from "./message-handling.ts";
7
+ import type { Topic } from "./topic.ts";
8
+
9
+ /**
10
+ * Handles push deliveries: Vercel Queues calls a function route with a CloudEvent, and the route
11
+ * leases, processes and settles the message.
12
+ *
13
+ * ```ts
14
+ * export const POST = HttpEffect.toWebHandlerLayer(
15
+ * QueueConsumer.use((consumer) => consumer.handler(Orders, fulfil)),
16
+ * AppLayer,
17
+ * ).handler;
18
+ * ```
19
+ */
20
+ export class QueueConsumer extends Context.Service<
21
+ QueueConsumer,
22
+ {
23
+ /**
24
+ * Build the HTTP handler for a push-delivered topic.
25
+ *
26
+ * Accepts structured (`com.vercel.queue.v1beta`) and binary (`com.vercel.queue.v2beta`)
27
+ * CloudEvents. Responds 200 when the message was processed or was already settled elsewhere,
28
+ * 400 for a malformed CloudEvent, and 500 to request redelivery.
29
+ */
30
+ readonly handler: <A, I, E, R, R2 = never>(
31
+ topic: Topic<A, I>,
32
+ handle: MessageHandler<A, E, R>,
33
+ options?: ConsumeOptions<E, R2>,
34
+ ) => Effect.Effect<
35
+ HttpServerResponse.HttpServerResponse,
36
+ never,
37
+ HttpServerRequest.HttpServerRequest | R | R2
38
+ >;
39
+ }
40
+ >()("@effect-vercel/queue/QueueConsumer") {}
@@ -0,0 +1,202 @@
1
+ import { VercelRegion } from "@panorama-industries/effect-vercel-core";
2
+ import { Schema } from "effect";
3
+
4
+ /** Name of a queue topic: letters, digits, `_` and `-`. */
5
+ export const TopicName = Schema.String.check(Schema.isPattern(/^[A-Za-z0-9_-]+$/)).pipe(
6
+ Schema.brand("TopicName"),
7
+ );
8
+
9
+ /** Name of a queue topic: letters, digits, `_` and `-`. */
10
+ export type TopicName = typeof TopicName.Type;
11
+
12
+ /** Name of a consumer group; each group receives every message of a topic once. */
13
+ export const ConsumerGroupName = Schema.String.check(Schema.isPattern(/^[A-Za-z0-9_-]+$/)).pipe(
14
+ Schema.brand("ConsumerGroupName"),
15
+ );
16
+
17
+ /** Name of a consumer group; each group receives every message of a topic once. */
18
+ export type ConsumerGroupName = typeof ConsumerGroupName.Type;
19
+
20
+ /** Identifier the queue assigns to a message when it accepts it. */
21
+ export const MessageId = Schema.NonEmptyString.pipe(Schema.brand("MessageId"));
22
+
23
+ /** Identifier the queue assigns to a message when it accepts it. */
24
+ export type MessageId = typeof MessageId.Type;
25
+
26
+ /** Proof of one lease on a message; acknowledging or extending the lease requires it. */
27
+ export const ReceiptHandle = Schema.NonEmptyString.pipe(Schema.brand("ReceiptHandle"));
28
+
29
+ /** Proof of one lease on a message. */
30
+ export type ReceiptHandle = typeof ReceiptHandle.Type;
31
+
32
+ /** Key the queue uses to drop duplicate publishes of the same logical message. */
33
+ export const IdempotencyKey = Schema.NonEmptyString.pipe(Schema.brand("IdempotencyKey"));
34
+
35
+ /** Key the queue uses to drop duplicate publishes of the same logical message. */
36
+ export type IdempotencyKey = typeof IdempotencyKey.Type;
37
+
38
+ /**
39
+ * **Experimental.** Key that serializes processing: at most one message per key is leased at a
40
+ * time, and messages published while one is in flight may be coalesced into it.
41
+ */
42
+ export const ConcurrencyKey = Schema.NonEmptyString.pipe(Schema.brand("ConcurrencyKey"));
43
+
44
+ /** **Experimental.** Key that serializes processing of messages that share it. */
45
+ export type ConcurrencyKey = typeof ConcurrencyKey.Type;
46
+
47
+ /** Where a consumer group with no stored position starts reading a topic. */
48
+ export const CursorFallback = Schema.Literals(["earliest", "latest"]);
49
+
50
+ /** Where a consumer group with no stored position starts reading a topic. */
51
+ export type CursorFallback = typeof CursorFallback.Type;
52
+
53
+ /** Everything needed to acknowledge a leased message or change its visibility. */
54
+ export class MessageHandle extends Schema.Class<MessageHandle>(
55
+ "@effect-vercel/queue/MessageHandle",
56
+ )({
57
+ topic: TopicName,
58
+ consumerGroup: ConsumerGroupName,
59
+ messageId: MessageId,
60
+ receiptHandle: ReceiptHandle,
61
+ /** Region that issued the lease; follow-up calls must go to the same region. */
62
+ region: VercelRegion,
63
+ }) {}
64
+
65
+ /** **Experimental.** Present when a handler run is a rerun caused by coalesced messages. */
66
+ export const ExperimentalCoalesced = Schema.Struct({
67
+ /** Which rerun of this delivery is executing, from 1. */
68
+ rerun: Schema.Int,
69
+ /** How many messages had been coalesced into this delivery when its acknowledgment was refused. */
70
+ coalescedCount: Schema.Int,
71
+ });
72
+
73
+ /** Facts about one delivery of a message, passed to message handlers. */
74
+ export class MessageMetadata extends Schema.Class<MessageMetadata>(
75
+ "@effect-vercel/queue/MessageMetadata",
76
+ )({
77
+ messageId: MessageId,
78
+ /** Number of times the message has been delivered, including this delivery (from 1). */
79
+ deliveryCount: Schema.Int,
80
+ createdAt: Schema.DateTimeUtc,
81
+ /** When the message expires and stops being delivered, set by its retention. */
82
+ expiresAt: Schema.DateTimeUtc,
83
+ topic: TopicName,
84
+ consumerGroup: ConsumerGroupName,
85
+ region: VercelRegion,
86
+ /** **Experimental.** How many handoffs preceded this delivery. */
87
+ experimentalHandoffCount: Schema.optionalKey(Schema.Int),
88
+ experimentalCoalesced: Schema.optionalKey(ExperimentalCoalesced),
89
+ }) {}
90
+
91
+ /** A leased message as returned by the queue, before its payload is decoded. */
92
+ export class ReceivedMessage extends Schema.Class<ReceivedMessage>(
93
+ "@effect-vercel/queue/ReceivedMessage",
94
+ )({
95
+ handle: MessageHandle,
96
+ metadata: MessageMetadata,
97
+ contentType: Schema.String,
98
+ body: Schema.Uint8Array,
99
+ }) {}
100
+
101
+ /** The message does not exist, or has expired (HTTP 404). */
102
+ export class MessageNotFound extends Schema.TaggedError<MessageNotFound>()("MessageNotFound", {
103
+ messageId: Schema.String,
104
+ }) {}
105
+
106
+ /** Another consumer holds the lease; retry once it is released. */
107
+ export class MessageLocked extends Schema.TaggedError<MessageLocked>()("MessageLocked", {
108
+ /** Delay the queue suggested before retrying, when it sent one. */
109
+ retryAfter: Schema.Option(Schema.Duration),
110
+ }) {}
111
+
112
+ /**
113
+ * **Experimental.** The acknowledgment was refused because messages with the same concurrency key
114
+ * were coalesced into this delivery after it started.
115
+ */
116
+ export class ConcurrencyCoalescedPending extends Schema.TaggedError<ConcurrencyCoalescedPending>()(
117
+ "ConcurrencyCoalescedPending",
118
+ {
119
+ coalescedCount: Schema.Int,
120
+ },
121
+ ) {}
122
+
123
+ /** The message exists but cannot be leased or settled right now, for no more specific reason. */
124
+ export class MessageUnavailable extends Schema.TaggedError<MessageUnavailable>()(
125
+ "MessageUnavailable",
126
+ {
127
+ message: Schema.String,
128
+ },
129
+ ) {}
130
+
131
+ /** The message cannot be leased or settled by this consumer (HTTP 409). */
132
+ export class MessageNotAvailable extends Schema.TaggedError<MessageNotAvailable>()(
133
+ "MessageNotAvailable",
134
+ {
135
+ messageId: Schema.String,
136
+ reason: Schema.Union([MessageLocked, ConcurrencyCoalescedPending, MessageUnavailable]),
137
+ },
138
+ ) {}
139
+
140
+ /** The message was already acknowledged by this consumer group (HTTP 410). */
141
+ export class MessageAlreadyProcessed extends Schema.TaggedError<MessageAlreadyProcessed>()(
142
+ "MessageAlreadyProcessed",
143
+ {
144
+ messageId: Schema.String,
145
+ },
146
+ ) {}
147
+
148
+ /** The queue returned a message whose headers or body could not be read. */
149
+ export class MessageCorrupted extends Schema.TaggedError<MessageCorrupted>()("MessageCorrupted", {
150
+ messageId: Schema.String,
151
+ message: Schema.String,
152
+ }) {}
153
+
154
+ /**
155
+ * The queue could not reach the deployment that consumes this topic (HTTP 502,
156
+ * `consumer_discovery_failed`), usually because the pinned deployment is gone.
157
+ */
158
+ export class ConsumerDiscoveryFailed extends Schema.TaggedError<ConsumerDiscoveryFailed>()(
159
+ "ConsumerDiscoveryFailed",
160
+ {
161
+ message: Schema.String,
162
+ },
163
+ ) {}
164
+
165
+ /**
166
+ * The project has no consumer registry, so messages cannot be routed to consumers (HTTP 503,
167
+ * `consumer_registry_not_configured`).
168
+ */
169
+ export class ConsumerRegistryNotConfigured extends Schema.TaggedError<ConsumerRegistryNotConfigured>()(
170
+ "ConsumerRegistryNotConfigured",
171
+ {
172
+ message: Schema.String,
173
+ },
174
+ ) {}
175
+
176
+ /** A batch send had no entries or more than the queue's limit of 100. */
177
+ export class BatchSizeOutOfRange extends Schema.TaggedError<BatchSizeOutOfRange>()(
178
+ "BatchSizeOutOfRange",
179
+ {
180
+ size: Schema.Int,
181
+ maximum: Schema.Int,
182
+ },
183
+ ) {}
184
+
185
+ /** A payload did not match its topic's schema when it was published. */
186
+ export class PayloadEncodeFailed extends Schema.TaggedError<PayloadEncodeFailed>()(
187
+ "PayloadEncodeFailed",
188
+ {
189
+ topic: Schema.String,
190
+ message: Schema.String,
191
+ },
192
+ ) {}
193
+
194
+ /** A delivered message body did not match its topic's schema. */
195
+ export class PayloadDecodeFailed extends Schema.TaggedError<PayloadDecodeFailed>()(
196
+ "PayloadDecodeFailed",
197
+ {
198
+ topic: Schema.String,
199
+ messageId: Schema.String,
200
+ message: Schema.String,
201
+ },
202
+ ) {}
@@ -0,0 +1,76 @@
1
+ import type { Effect } from "effect";
2
+
3
+ import { Context, Data } from "effect";
4
+
5
+ import type { ConsumeOptions, MessageHandler } from "./message-handling.ts";
6
+ import type {
7
+ QueueLeaseError,
8
+ QueueRequestError,
9
+ ReceiveByIdOptions,
10
+ ReceiveOptions,
11
+ } from "./queue-api.ts";
12
+ import type { ConsumerGroupName, MessageCorrupted, MessageId } from "./queue-domain.ts";
13
+ import type { Topic } from "./topic.ts";
14
+
15
+ /** What a poll did. */
16
+ export type ReceiveOutcome = Data.TaggedEnum<{
17
+ /** This many messages were leased and handled. */
18
+ Processed: { readonly count: number };
19
+ /** The consumer group had nothing to deliver. */
20
+ Empty: {};
21
+ /** The requested message does not exist or has expired. */
22
+ NotFound: { readonly messageId: MessageId };
23
+ /** Another consumer holds the requested message. */
24
+ NotAvailable: { readonly messageId: MessageId };
25
+ /** The requested message was already acknowledged by this consumer group. */
26
+ AlreadyProcessed: { readonly messageId: MessageId };
27
+ }>;
28
+
29
+ /** Constructors and matchers for {@link ReceiveOutcome}. */
30
+ export const ReceiveOutcome = Data.taggedEnum<ReceiveOutcome>();
31
+
32
+ /** Options for one poll: lease options plus how to handle the leased messages. */
33
+ export type PollOptions<E, R> = ConsumeOptions<E, R> &
34
+ Pick<ReceiveOptions, "limit" | "cursorFallback" | "region"> & {
35
+ /**
36
+ * How many leased messages are handled at once. Default: `"unbounded"`. Messages waiting their
37
+ * turn keep their leases renewed, so a lower value never lets a lease expire. In a function,
38
+ * keep `limit × handler time / concurrency` well under `maxDuration`.
39
+ */
40
+ readonly concurrency?: number | "unbounded";
41
+ };
42
+
43
+ /** Options for polling one message by identifier. */
44
+ export type PollByIdOptions<E, R> = ConsumeOptions<E, R> & Pick<ReceiveByIdOptions, "region">;
45
+
46
+ /**
47
+ * Pulls messages from a consumer group and handles them, for workloads that run outside push
48
+ * delivery such as workers and cron jobs.
49
+ */
50
+ export class QueuePoller extends Context.Service<
51
+ QueuePoller,
52
+ {
53
+ /**
54
+ * Lease up to `limit` messages and handle them, concurrently by default.
55
+ *
56
+ * @returns The outcome, or the handler's error when the error policy answered `Fail`. Also
57
+ * fails with `MessageNotFound` or `MessageNotAvailable` when a lease was lost before the
58
+ * message could be acknowledged.
59
+ */
60
+ readonly receive: <A, I, E, R, R2 = never>(
61
+ topic: Topic<A, I>,
62
+ consumerGroup: ConsumerGroupName,
63
+ handle: MessageHandler<A, E, R>,
64
+ options?: PollOptions<E, R2>,
65
+ ) => Effect.Effect<ReceiveOutcome, E | QueueLeaseError | MessageCorrupted, R | R2>;
66
+
67
+ /** Lease one message by identifier and handle it. */
68
+ readonly receiveById: <A, I, E, R, R2 = never>(
69
+ topic: Topic<A, I>,
70
+ consumerGroup: ConsumerGroupName,
71
+ messageId: MessageId,
72
+ handle: MessageHandler<A, E, R>,
73
+ options?: PollByIdOptions<E, R2>,
74
+ ) => Effect.Effect<ReceiveOutcome, E | QueueRequestError | MessageCorrupted, R | R2>;
75
+ }
76
+ >()("@effect-vercel/queue/QueuePoller") {}
@@ -0,0 +1,40 @@
1
+ import type { Array, Effect } from "effect";
2
+
3
+ import { Context } from "effect";
4
+
5
+ import type {
6
+ BatchSendResult,
7
+ PublishOptions,
8
+ QueueSendBatchError,
9
+ QueueSendError,
10
+ SendResult,
11
+ } from "./queue-api.ts";
12
+ import type { PayloadEncodeFailed } from "./queue-domain.ts";
13
+ import type { Topic } from "./topic.ts";
14
+
15
+ /** One payload of a batch send with its publish options. */
16
+ export interface BatchEntry<A> extends PublishOptions {
17
+ readonly payload: A;
18
+ }
19
+
20
+ /** Publishes typed payloads to topics. */
21
+ export class QueueProducer extends Context.Service<
22
+ QueueProducer,
23
+ {
24
+ /** Encode a payload with its topic's schema and publish it, in a `vqs.send` producer span. */
25
+ readonly send: <A, I>(
26
+ topic: Topic<A, I>,
27
+ payload: A,
28
+ options?: PublishOptions,
29
+ ) => Effect.Effect<SendResult, QueueSendError | PayloadEncodeFailed>;
30
+
31
+ /**
32
+ * Encode and publish 1 to 100 payloads in one request. Inspect every result: a batch can mix
33
+ * sent, deferred and failed entries.
34
+ */
35
+ readonly sendBatch: <A, I>(
36
+ topic: Topic<A, I>,
37
+ entries: Array.NonEmptyReadonlyArray<BatchEntry<A>>,
38
+ ) => Effect.Effect<ReadonlyArray<BatchSendResult>, QueueSendBatchError | PayloadEncodeFailed>;
39
+ }
40
+ >()("@effect-vercel/queue/QueueProducer") {}
@@ -0,0 +1,76 @@
1
+ import { Schema, SchemaTransformation } from "effect";
2
+
3
+ import { TopicName } from "./queue-domain.ts";
4
+
5
+ /**
6
+ * A queue topic and the schema of its payloads, shared by the code that publishes to it and the
7
+ * code that consumes it.
8
+ *
9
+ * @template A - Payload type handlers receive and producers send.
10
+ * @template I - Encoded form of the payload before it is serialized to bytes.
11
+ */
12
+ export interface Topic<A, I = A> {
13
+ readonly name: TopicName;
14
+
15
+ /** Schema of the payload, as given by the topic's author. */
16
+ readonly schema: Schema.Codec<A, I>;
17
+
18
+ /** Content type sent with every message of this topic. */
19
+ readonly contentType: string;
20
+
21
+ /** Converts between the payload and the bytes stored in the queue. */
22
+ readonly wire: Schema.Codec<A, Uint8Array>;
23
+ }
24
+
25
+ const utf8Encoder = new TextEncoder();
26
+ const utf8Decoder = new TextDecoder();
27
+
28
+ const Utf8String = Schema.Uint8Array.pipe(
29
+ Schema.decodeTo(
30
+ Schema.String,
31
+ SchemaTransformation.transform<string, Uint8Array>({
32
+ decode: (bytes) => utf8Decoder.decode(bytes),
33
+ encode: (text) => utf8Encoder.encode(text),
34
+ }),
35
+ ),
36
+ );
37
+
38
+ /**
39
+ * Define a topic whose payloads travel as JSON (`application/json`).
40
+ *
41
+ * @param name - Topic name; an invalid name is a defect raised where the topic is defined.
42
+ * @param schema - Payload schema; its encoded form must be JSON-serializable.
43
+ */
44
+ const json = <A, I>(name: string, schema: Schema.Codec<A, I>): Topic<A, I> => ({
45
+ name: TopicName.make(name),
46
+ schema,
47
+ contentType: "application/json",
48
+ wire: Utf8String.pipe(Schema.decodeTo(Schema.fromJsonString(schema))),
49
+ });
50
+
51
+ /**
52
+ * Define a topic whose payloads are raw bytes (`application/octet-stream`).
53
+ *
54
+ * @param name - Topic name; an invalid name is a defect raised where the topic is defined.
55
+ */
56
+ const bytes = (name: string): Topic<Uint8Array> => ({
57
+ name: TopicName.make(name),
58
+ schema: Schema.Uint8Array,
59
+ contentType: "application/octet-stream",
60
+ wire: Schema.Uint8Array,
61
+ });
62
+
63
+ /** Constructors for {@link Topic} definitions. */
64
+ export const Topic = { json, bytes };
65
+
66
+ /** Type helpers for {@link Topic}. */
67
+ export declare namespace Topic {
68
+ /** Any topic, regardless of payload type. */
69
+ export type Any = Topic<any, any>;
70
+
71
+ /** Payload type of a topic, like `Schema.Type`. */
72
+ export type Type<T extends Any> = T extends Topic<infer A, any> ? A : never;
73
+
74
+ /** Encoded payload type of a topic, like `Schema.Encoded`. */
75
+ export type Encoded<T extends Any> = T extends Topic<any, infer I> ? I : never;
76
+ }