@panorama-industries/effect-vercel-queue 0.0.0-stage → 0.2.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,637 @@
1
+ import type { VercelRegion } from "@panorama-industries/effect-vercel-core";
2
+ import type { HttpClientError, HttpClientResponse } from "effect/http";
3
+
4
+ import {
5
+ ApiRateLimited,
6
+ ApiUnauthorized,
7
+ ApiUnexpectedResponse,
8
+ VercelApiError,
9
+ VercelOidc,
10
+ vercelApiErrorFromHttpClientError,
11
+ vercelApiErrorFromResponse,
12
+ withVercelApi,
13
+ } from "@panorama-industries/effect-vercel-core";
14
+ import {
15
+ Array as Arr,
16
+ Clock,
17
+ DateTime,
18
+ Duration,
19
+ Effect,
20
+ Layer,
21
+ Option,
22
+ Predicate,
23
+ Random,
24
+ Schedule,
25
+ Schema,
26
+ Stream,
27
+ } from "effect";
28
+ import { HttpClient, HttpClientRequest } from "effect/http";
29
+
30
+ import type {
31
+ OutgoingMessage,
32
+ QueueLeaseError,
33
+ QueueReceiveByIdError,
34
+ QueueRequestError,
35
+ QueueSendBatchError,
36
+ QueueSendError,
37
+ ReceiveByIdOptions,
38
+ ReceiveOptions,
39
+ } from "../contracts/queue-api.ts";
40
+ import type { ConsumerGroupName, TopicName } from "../contracts/queue-domain.ts";
41
+ import type { MultipartPart } from "../internal/multipart.ts";
42
+ import type { QueuePartHeaders } from "../internal/queue-protocol.ts";
43
+
44
+ import { BatchSendResult, HandoffOutcome, QueueApi, SendResult } from "../contracts/queue-api.ts";
45
+ import { QueueAuth, QueueConfig } from "../contracts/queue-config.ts";
46
+ import {
47
+ BatchSizeOutOfRange,
48
+ ConcurrencyCoalescedPending,
49
+ ConsumerDiscoveryFailed,
50
+ ConsumerRegistryNotConfigured,
51
+ MessageAlreadyProcessed,
52
+ MessageCorrupted,
53
+ MessageHandle,
54
+ MessageId,
55
+ MessageLocked,
56
+ MessageMetadata,
57
+ MessageNotAvailable,
58
+ MessageNotFound,
59
+ MessageUnavailable,
60
+ ReceiptHandle,
61
+ ReceivedMessage,
62
+ } from "../contracts/queue-domain.ts";
63
+ import {
64
+ boundaryFromContentType,
65
+ decodeMultipartMixed,
66
+ encodeMultipartMixed,
67
+ } from "../internal/multipart.ts";
68
+ import {
69
+ HeaderSet,
70
+ apiBaseUrl,
71
+ coalescedPendingCount,
72
+ errorBodyOf,
73
+ errorCodeHeader,
74
+ messageHeaders,
75
+ parseDate,
76
+ queuePartHeaders,
77
+ retryAfterFromHeader,
78
+ topicPath,
79
+ wholeSeconds,
80
+ } from "../internal/queue-protocol.ts";
81
+ import { userAgent } from "../internal/version.ts";
82
+
83
+ type Response = HttpClientResponse.HttpClientResponse;
84
+
85
+ const defaultRetention = Duration.hours(24);
86
+
87
+ const leaseCallAttempts = 3;
88
+
89
+ const SendResponse = Schema.fromJsonString(
90
+ Schema.Struct({ messageId: Schema.NullOr(Schema.String) }),
91
+ );
92
+
93
+ const BatchResponse = Schema.fromJsonString(
94
+ Schema.Struct({
95
+ results: Schema.Array(
96
+ Schema.Union([
97
+ Schema.Struct({ status: Schema.Literal("sent"), messageId: MessageId }),
98
+ Schema.Struct({ status: Schema.Literal("deferred") }),
99
+ Schema.Struct({
100
+ status: Schema.Literal("failed"),
101
+ statusCode: Schema.Int,
102
+ error: Schema.String,
103
+ retryable: Schema.Boolean,
104
+ }),
105
+ ]),
106
+ ),
107
+ }),
108
+ );
109
+
110
+ const decodeHandoffOutcome = Schema.decodeUnknownOption(HandoffOutcome);
111
+
112
+ const header = (response: Response, name: string): string | undefined =>
113
+ response.headers[name.toLowerCase()];
114
+
115
+ const unexpected = (operation: string, status: number, message: string) =>
116
+ new VercelApiError({
117
+ operation,
118
+ errorCode: Option.none(),
119
+ reason: new ApiUnexpectedResponse({ message, status }),
120
+ });
121
+
122
+ const isRetryableLeaseError = (error: unknown): boolean =>
123
+ error instanceof VercelApiError &&
124
+ (Predicate.isTagged(error.reason, "ApiNetworkFailure") ||
125
+ (Predicate.isTagged(error.reason, "ApiRateLimited") && Option.isSome(error.reason.retryAfter)));
126
+
127
+ const leaseRetry = Schedule.forever.pipe(
128
+ Schedule.setInputType<unknown>(),
129
+ Schedule.while(
130
+ ({ input, attempt }) => attempt < leaseCallAttempts && isRetryableLeaseError(input),
131
+ ),
132
+ Schedule.modifyDelay(({ input, attempt }) => {
133
+ if (
134
+ input instanceof VercelApiError &&
135
+ Predicate.isTagged(input.reason, "ApiRateLimited") &&
136
+ Option.isSome(input.reason.retryAfter)
137
+ ) {
138
+ return Effect.succeed(input.reason.retryAfter.value);
139
+ }
140
+ const base = 250 * attempt;
141
+ return Effect.map(Random.nextBetween(base / 2, base), Duration.millis);
142
+ }),
143
+ );
144
+
145
+ const maxBatchSize = 100;
146
+
147
+ /**
148
+ * Implements {@link QueueApi} over HTTP with the same requests, headers and status handling as
149
+ * `@vercel/queue`.
150
+ *
151
+ * Requires `HttpClient`, `QueueConfig` and `VercelOidc` (used only with `QueueAuth.Oidc`). A
152
+ * missing OIDC token fails calls with `VercelApiError` (`ApiUnauthorized`). Acknowledging and
153
+ * changing visibility make up to 3 attempts when the request never reached the queue or was
154
+ * rate limited with a `Retry-After` delay; nothing else is retried.
155
+ */
156
+ export const QueueApiLive: Layer.Layer<
157
+ QueueApi,
158
+ never,
159
+ HttpClient.HttpClient | QueueConfig | VercelOidc
160
+ > = Layer.effect(
161
+ QueueApi,
162
+ Effect.gen(function* () {
163
+ const config = yield* QueueConfig;
164
+ const oidc = yield* VercelOidc;
165
+ const httpClient = yield* HttpClient.HttpClient;
166
+
167
+ const token = QueueAuth.$match(config.auth, {
168
+ Token: ({ token }) => Effect.succeed(token),
169
+ Oidc: () => oidc.token,
170
+ });
171
+
172
+ const deploymentHeader = (pinned: boolean) =>
173
+ HttpClientRequest.setHeaders(
174
+ pinned
175
+ ? Option.match(config.deploymentId, {
176
+ onNone: () => ({}),
177
+ onSome: (id) => ({ "vqs-deployment-id": id }),
178
+ })
179
+ : {},
180
+ );
181
+
182
+ const execute = (
183
+ operation: string,
184
+ region: VercelRegion,
185
+ request: HttpClientRequest.HttpClientRequest,
186
+ ): Effect.Effect<Response, VercelApiError> =>
187
+ httpClient.pipe(
188
+ withVercelApi({ baseUrl: apiBaseUrl(config.baseUrl(region)), token, userAgent }),
189
+ HttpClient.mapRequestEffect((request) =>
190
+ Effect.map(DateTime.now, (now) =>
191
+ HttpClientRequest.setHeader(request, "vqs-client-ts", DateTime.formatIso(now)),
192
+ ),
193
+ ),
194
+ (client) => client.execute(request),
195
+ Effect.catchTags({
196
+ HttpClientError: (error: HttpClientError.HttpClientError) =>
197
+ vercelApiErrorFromHttpClientError(error, { operation, errorCodeHeader }),
198
+ OidcTokenUnavailable: (error) =>
199
+ Effect.fail(
200
+ new VercelApiError({
201
+ operation,
202
+ errorCode: Option.none(),
203
+ reason: new ApiUnauthorized({ message: error.message }),
204
+ }),
205
+ ),
206
+ }),
207
+ );
208
+
209
+ const readErrorBody = (response: Response) =>
210
+ Effect.map(
211
+ Effect.orElseSucceed(response.text, () => ""),
212
+ (text) => errorBodyOf(text, header(response, "content-type")),
213
+ );
214
+
215
+ const failCommon = Effect.fnUntraced(function* (
216
+ operation: string,
217
+ response: Response,
218
+ ): Effect.fn.Return<never, QueueRequestError> {
219
+ const body = yield* readErrorBody(response);
220
+ if (
221
+ response.status === 503 &&
222
+ header(response, errorCodeHeader) === "consumer_registry_not_configured"
223
+ ) {
224
+ return yield* new ConsumerRegistryNotConfigured({
225
+ message: body.message || "Consumer registry not configured",
226
+ });
227
+ }
228
+ const now = yield* Clock.currentTimeMillis;
229
+ const error = yield* Effect.flip(
230
+ vercelApiErrorFromResponse(response, { operation, errorCodeHeader }),
231
+ );
232
+ if (Predicate.isTagged(error.reason, "ApiRateLimited")) {
233
+ return yield* new VercelApiError({
234
+ operation: error.operation,
235
+ errorCode: error.errorCode,
236
+ reason: new ApiRateLimited({
237
+ message: error.reason.message,
238
+ retryAfter: retryAfterFromHeader(header(response, "retry-after"), now),
239
+ }),
240
+ });
241
+ }
242
+ return yield* error;
243
+ });
244
+
245
+ const drain = (response: Response) => Effect.ignore(response.text);
246
+
247
+ const readJson = <A>(
248
+ operation: string,
249
+ response: Response,
250
+ schema: Schema.Codec<A, string>,
251
+ ): Effect.Effect<A, VercelApiError> =>
252
+ Effect.flatMap(
253
+ Effect.catch(response.text, (error) =>
254
+ vercelApiErrorFromHttpClientError(error, { operation }),
255
+ ),
256
+ (text) =>
257
+ Effect.mapError(Schema.decodeUnknownEffect(schema)(text), (error) =>
258
+ unexpected(operation, response.status, `Invalid response body: ${error.message}`),
259
+ ),
260
+ );
261
+
262
+ const toReceivedMessage = Effect.fnUntraced(function* (
263
+ part: QueuePartHeaders,
264
+ body: Uint8Array,
265
+ location: Pick<MessageHandle, "topic" | "consumerGroup" | "region">,
266
+ ): Effect.fn.Return<ReceivedMessage, MessageCorrupted> {
267
+ const corrupted = (message: string) =>
268
+ new MessageCorrupted({ messageId: part.messageId, message });
269
+ const createdAt = yield* Option.match(parseDate(part.timestamp), {
270
+ onNone: () => Effect.fail(corrupted(`Invalid Vqs-Timestamp: ${part.timestamp}`)),
271
+ onSome: Effect.succeed,
272
+ });
273
+ const expiresAt = yield* Option.match(part.expiresAt, {
274
+ onNone: () => Effect.succeed(DateTime.addDuration(createdAt, defaultRetention)),
275
+ onSome: (raw) =>
276
+ Option.match(parseDate(raw), {
277
+ onNone: () => Effect.fail(corrupted(`Invalid Vqs-Expires-At: ${raw}`)),
278
+ onSome: Effect.succeed,
279
+ }),
280
+ });
281
+ const messageId = MessageId.make(part.messageId);
282
+ return new ReceivedMessage({
283
+ handle: new MessageHandle({
284
+ ...location,
285
+ messageId,
286
+ receiptHandle: ReceiptHandle.make(part.receiptHandle),
287
+ }),
288
+ metadata: new MessageMetadata({
289
+ ...location,
290
+ messageId,
291
+ deliveryCount: part.deliveryCount,
292
+ createdAt,
293
+ expiresAt,
294
+ ...Option.match(part.handoffCount, {
295
+ onNone: () => ({}),
296
+ onSome: (experimentalHandoffCount) => ({ experimentalHandoffCount }),
297
+ }),
298
+ }),
299
+ contentType: part.contentType,
300
+ body,
301
+ });
302
+ });
303
+
304
+ const multipartParts = (
305
+ operation: string,
306
+ response: Response,
307
+ ): Stream.Stream<MultipartPart, VercelApiError> =>
308
+ Option.match(
309
+ Option.flatMap(
310
+ Option.fromUndefinedOr(header(response, "content-type")),
311
+ boundaryFromContentType,
312
+ ),
313
+ {
314
+ onNone: () =>
315
+ Stream.fail(
316
+ unexpected(
317
+ operation,
318
+ response.status,
319
+ "Expected a multipart/mixed response with a boundary",
320
+ ),
321
+ ),
322
+ onSome: (boundary) =>
323
+ response.stream.pipe(
324
+ decodeMultipartMixed(boundary),
325
+ Stream.catchTags({
326
+ HttpClientError: (error: HttpClientError.HttpClientError) =>
327
+ Stream.fromEffect(vercelApiErrorFromHttpClientError(error, { operation })),
328
+ MultipartMixedError: (error) =>
329
+ Stream.fail(unexpected(operation, response.status, error.message)),
330
+ }),
331
+ ),
332
+ },
333
+ );
334
+
335
+ const consumerHeaders = (
336
+ visibilityTimeout: Duration.Input | undefined,
337
+ extra: Readonly<Record<string, string>>,
338
+ ) => {
339
+ const headers = new HeaderSet({ accept: "multipart/mixed" });
340
+ for (const [name, value] of Object.entries(config.headers)) headers.set(name, value);
341
+ if (visibilityTimeout !== undefined) {
342
+ headers.set("vqs-visibility-timeout-seconds", String(wholeSeconds(visibilityTimeout)));
343
+ }
344
+ for (const [name, value] of Object.entries(extra)) headers.set(name, value);
345
+ return headers.toRecord();
346
+ };
347
+
348
+ const send = Effect.fn("QueueApi.send")(function* (
349
+ topic: TopicName,
350
+ message: OutgoingMessage,
351
+ ): Effect.fn.Return<SendResult, QueueSendError> {
352
+ const operation = "QueueApi.send";
353
+ const request = HttpClientRequest.post(topicPath(topic)).pipe(
354
+ HttpClientRequest.setHeaders(messageHeaders(config.headers, message).toRecord()),
355
+ deploymentHeader(config.pinSends),
356
+ HttpClientRequest.bodyUint8Array(message.body, message.contentType),
357
+ );
358
+ const response = yield* execute(operation, config.region, request);
359
+ if (response.status < 200 || response.status > 299) {
360
+ if (
361
+ response.status === 502 &&
362
+ header(response, errorCodeHeader) === "consumer_discovery_failed"
363
+ ) {
364
+ const body = yield* readErrorBody(response);
365
+ return yield* new ConsumerDiscoveryFailed({
366
+ message: body.message || "Consumer discovery failed",
367
+ });
368
+ }
369
+ return yield* failCommon(operation, response);
370
+ }
371
+ if (response.status === 202) {
372
+ yield* drain(response);
373
+ return SendResult.Deferred();
374
+ }
375
+ const { messageId } = yield* readJson(operation, response, SendResponse);
376
+ if (messageId === null) return SendResult.Deferred();
377
+ if (messageId === "") {
378
+ return yield* unexpected(operation, response.status, "Empty messageId in response");
379
+ }
380
+ return SendResult.Sent({ messageId: MessageId.make(messageId) });
381
+ });
382
+
383
+ const sendBatch = Effect.fn("QueueApi.sendBatch")(function* (
384
+ topic: TopicName,
385
+ messages: Arr.NonEmptyReadonlyArray<OutgoingMessage>,
386
+ ): Effect.fn.Return<ReadonlyArray<BatchSendResult>, QueueSendBatchError> {
387
+ const operation = "QueueApi.sendBatch";
388
+ if (messages.length === 0 || messages.length > maxBatchSize) {
389
+ return yield* new BatchSizeOutOfRange({ size: messages.length, maximum: maxBatchSize });
390
+ }
391
+ const boundary = `vqs-${crypto.randomUUID()}`;
392
+ const body = encodeMultipartMixed(
393
+ boundary,
394
+ messages.map((message) => ({
395
+ headers: messageHeaders(config.headers, message).sorted(),
396
+ body: message.body,
397
+ })),
398
+ );
399
+ const request = HttpClientRequest.post(topicPath(topic, "batch")).pipe(
400
+ HttpClientRequest.setHeaders(config.headers),
401
+ deploymentHeader(config.pinSends),
402
+ HttpClientRequest.bodyUint8Array(body, `multipart/mixed; boundary=${boundary}`),
403
+ );
404
+ const response = yield* execute(operation, config.region, request);
405
+ if (response.status < 200 || response.status > 299) {
406
+ return yield* failCommon(operation, response);
407
+ }
408
+ const { results } = yield* readJson(operation, response, BatchResponse);
409
+ if (results.length !== messages.length) {
410
+ return yield* unexpected(
411
+ operation,
412
+ response.status,
413
+ `Invalid batch response: expected ${messages.length} results, received ${results.length}`,
414
+ );
415
+ }
416
+ return results.map((result): BatchSendResult =>
417
+ "messageId" in result
418
+ ? BatchSendResult.Sent({ messageId: result.messageId })
419
+ : "statusCode" in result
420
+ ? BatchSendResult.Failed({
421
+ status: result.statusCode,
422
+ message: result.error,
423
+ retryable: result.retryable,
424
+ })
425
+ : BatchSendResult.Deferred(),
426
+ );
427
+ });
428
+
429
+ const receive = Effect.fn("QueueApi.receive")(function* (
430
+ topic: TopicName,
431
+ consumerGroup: ConsumerGroupName,
432
+ options: ReceiveOptions = {},
433
+ ): Effect.fn.Return<ReadonlyArray<ReceivedMessage>, QueueRequestError | MessageCorrupted> {
434
+ const operation = "QueueApi.receive";
435
+ const region = options.region ?? config.region;
436
+ const request = HttpClientRequest.post(topicPath(topic, "consumer", consumerGroup)).pipe(
437
+ HttpClientRequest.setHeaders(
438
+ consumerHeaders(options.visibilityTimeout, {
439
+ ...(options.limit === undefined ? {} : { "vqs-max-messages": String(options.limit) }),
440
+ ...(options.cursorFallback === undefined
441
+ ? {}
442
+ : { "vqs-cursor-fallback": options.cursorFallback }),
443
+ }),
444
+ ),
445
+ deploymentHeader(true),
446
+ );
447
+ const response = yield* execute(operation, region, request);
448
+ if (response.status === 204) {
449
+ yield* drain(response);
450
+ return [];
451
+ }
452
+ if (response.status < 200 || response.status > 299) {
453
+ return yield* failCommon(operation, response);
454
+ }
455
+ const location = { topic, consumerGroup, region };
456
+ const messages = yield* multipartParts(operation, response).pipe(
457
+ Stream.mapEffect((part) =>
458
+ Option.match(queuePartHeaders(part.headers), {
459
+ onNone: () =>
460
+ Effect.as(
461
+ Effect.logWarning("Missing required queue headers in multipart part"),
462
+ Option.none<ReceivedMessage>(),
463
+ ),
464
+ onSome: (headers) =>
465
+ toReceivedMessage(headers, part.body, location).pipe(
466
+ Effect.map(Option.some),
467
+ Effect.catchTag("MessageCorrupted", (error) =>
468
+ Effect.as(
469
+ Effect.logWarning(
470
+ `Skipping corrupted message ${error.messageId}: ${error.message}`,
471
+ ),
472
+ Option.none<ReceivedMessage>(),
473
+ ),
474
+ ),
475
+ ),
476
+ }),
477
+ ),
478
+ Stream.runCollect,
479
+ );
480
+ return Arr.getSomes(messages);
481
+ });
482
+
483
+ const receiveById = Effect.fn("QueueApi.receiveById")(function* (
484
+ topic: TopicName,
485
+ consumerGroup: ConsumerGroupName,
486
+ messageId: MessageId,
487
+ options: ReceiveByIdOptions = {},
488
+ ): Effect.fn.Return<ReceivedMessage, QueueReceiveByIdError> {
489
+ const operation = "QueueApi.receiveById";
490
+ const region = options.region ?? config.region;
491
+ const request = HttpClientRequest.post(
492
+ topicPath(topic, "consumer", consumerGroup, "id", messageId),
493
+ ).pipe(
494
+ HttpClientRequest.setHeaders(consumerHeaders(options.visibilityTimeout, {})),
495
+ deploymentHeader(true),
496
+ );
497
+ const response = yield* execute(operation, region, request);
498
+ if (response.status < 200 || response.status > 299) {
499
+ const body = yield* readErrorBody(response);
500
+ if (response.status === 404) return yield* new MessageNotFound({ messageId });
501
+ if (response.status === 409) {
502
+ const now = yield* Clock.currentTimeMillis;
503
+ return yield* new MessageNotAvailable({
504
+ messageId,
505
+ reason: Option.match(body.originalMessageId, {
506
+ onNone: () =>
507
+ new MessageLocked({
508
+ retryAfter: retryAfterFromHeader(header(response, "retry-after"), now),
509
+ }),
510
+ onSome: (original) =>
511
+ new MessageUnavailable({
512
+ message: `This message was a duplicate - use originalMessageId: ${original}`,
513
+ }),
514
+ }),
515
+ });
516
+ }
517
+ if (response.status === 410) return yield* new MessageAlreadyProcessed({ messageId });
518
+ return yield* failCommon(operation, response);
519
+ }
520
+ const first = yield* Stream.runHead(multipartParts(operation, response));
521
+ if (Option.isNone(first)) return yield* new MessageNotFound({ messageId });
522
+ const headers = queuePartHeaders(first.value.headers);
523
+ if (Option.isNone(headers)) {
524
+ return yield* new MessageCorrupted({
525
+ messageId,
526
+ message: "Missing required queue headers in response",
527
+ });
528
+ }
529
+ return yield* toReceivedMessage(headers.value, first.value.body, {
530
+ topic,
531
+ consumerGroup,
532
+ region,
533
+ });
534
+ });
535
+
536
+ const leaseRequest = (
537
+ operation: string,
538
+ handle: MessageHandle,
539
+ request: HttpClientRequest.HttpClientRequest,
540
+ defaultMessage: string,
541
+ coalesced: boolean,
542
+ ) =>
543
+ Effect.gen(function* () {
544
+ const response = yield* execute(operation, handle.region, request);
545
+ if (response.status >= 200 && response.status <= 299) return response;
546
+ const body = yield* readErrorBody(response);
547
+ if (response.status === 404) {
548
+ return yield* new MessageNotFound({ messageId: handle.messageId });
549
+ }
550
+ if (response.status === 409) {
551
+ const count = coalesced
552
+ ? coalescedPendingCount(
553
+ header(response, errorCodeHeader),
554
+ header(response, "vqs-experimental-coalesced"),
555
+ body.text,
556
+ )
557
+ : Option.none();
558
+ return yield* new MessageNotAvailable({
559
+ messageId: handle.messageId,
560
+ reason: Option.match(count, {
561
+ onNone: () => new MessageUnavailable({ message: body.message || defaultMessage }),
562
+ onSome: (coalescedCount) => new ConcurrencyCoalescedPending({ coalescedCount }),
563
+ }),
564
+ });
565
+ }
566
+ return yield* failCommon(operation, response);
567
+ }).pipe(Effect.retry(leaseRetry));
568
+
569
+ const leasePath = (handle: MessageHandle) =>
570
+ topicPath(handle.topic, "consumer", handle.consumerGroup, "lease", handle.receiptHandle);
571
+
572
+ const notAvailableMessage =
573
+ "Invalid receipt handle, message not in correct state, or already processed";
574
+
575
+ const acknowledge = Effect.fn("QueueApi.acknowledge")(function* (
576
+ handle: MessageHandle,
577
+ options: { readonly experimentalIfCoalesced?: number } = {},
578
+ ): Effect.fn.Return<void, QueueLeaseError> {
579
+ const ifCoalesced = options.experimentalIfCoalesced;
580
+ const request = HttpClientRequest.delete(leasePath(handle)).pipe(
581
+ HttpClientRequest.setHeaders(config.headers),
582
+ deploymentHeader(true),
583
+ HttpClientRequest.setHeaders(
584
+ ifCoalesced !== undefined && ifCoalesced > 0
585
+ ? { "vqs-experimental-if-coalesced": String(ifCoalesced) }
586
+ : {},
587
+ ),
588
+ );
589
+ const response = yield* leaseRequest(
590
+ "QueueApi.acknowledge",
591
+ handle,
592
+ request,
593
+ notAvailableMessage,
594
+ true,
595
+ );
596
+ yield* drain(response);
597
+ });
598
+
599
+ const changeVisibility = Effect.fn("QueueApi.changeVisibility")(function* (
600
+ handle: MessageHandle,
601
+ visibilityTimeout: Duration.Input,
602
+ options: { readonly experimentalHandoff?: boolean } = {},
603
+ ): Effect.fn.Return<Option.Option<HandoffOutcome>, QueueLeaseError> {
604
+ const headers = new HeaderSet({ "content-type": "application/json" });
605
+ for (const [name, value] of Object.entries(config.headers)) headers.set(name, value);
606
+ const request = HttpClientRequest.patch(leasePath(handle)).pipe(
607
+ HttpClientRequest.setHeaders(headers.toRecord()),
608
+ deploymentHeader(true),
609
+ HttpClientRequest.setHeaders(
610
+ options.experimentalHandoff === true ? { "vqs-experimental-handoff": "1" } : {},
611
+ ),
612
+ HttpClientRequest.bodyText(
613
+ JSON.stringify({ visibilityTimeoutSeconds: wholeSeconds(visibilityTimeout) }),
614
+ headers.get("content-type") ?? "application/json",
615
+ ),
616
+ );
617
+ const response = yield* leaseRequest(
618
+ "QueueApi.changeVisibility",
619
+ handle,
620
+ request,
621
+ notAvailableMessage,
622
+ false,
623
+ );
624
+ yield* drain(response);
625
+ return decodeHandoffOutcome(header(response, "vqs-experimental-handoff"));
626
+ });
627
+
628
+ return QueueApi.of({
629
+ send,
630
+ sendBatch,
631
+ receive,
632
+ receiveById,
633
+ acknowledge,
634
+ changeVisibility,
635
+ });
636
+ }),
637
+ );