@tranquilload/core 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 (114) hide show
  1. package/.turbo/turbo-build.log +88 -0
  2. package/dist/compression-service-Bm1VBnhT.mjs +18 -0
  3. package/dist/compression-service-Bm1VBnhT.mjs.map +1 -0
  4. package/dist/compression-service-Bn86iTJe.cjs +35 -0
  5. package/dist/compression-service-Bn86iTJe.cjs.map +1 -0
  6. package/dist/compression-service-CiF7Px08.d.cts +15 -0
  7. package/dist/compression-service-CiF7Px08.d.cts.map +1 -0
  8. package/dist/compression-service-DI7ZXVxH.d.mts +15 -0
  9. package/dist/compression-service-DI7ZXVxH.d.mts.map +1 -0
  10. package/dist/errors.cjs +9 -0
  11. package/dist/errors.d.cts +2 -0
  12. package/dist/errors.d.mts +2 -0
  13. package/dist/errors.mjs +2 -0
  14. package/dist/index-Ch8xM6Xt.d.cts +60 -0
  15. package/dist/index-Ch8xM6Xt.d.cts.map +1 -0
  16. package/dist/index-DBGtgXEd.d.mts +60 -0
  17. package/dist/index-DBGtgXEd.d.mts.map +1 -0
  18. package/dist/logger-service-1J5r_akj.mjs +8 -0
  19. package/dist/logger-service-1J5r_akj.mjs.map +1 -0
  20. package/dist/logger-service-BF2pZOHN.d.mts +12 -0
  21. package/dist/logger-service-BF2pZOHN.d.mts.map +1 -0
  22. package/dist/logger-service-CbN12RhO.d.cts +12 -0
  23. package/dist/logger-service-CbN12RhO.d.cts.map +1 -0
  24. package/dist/logger-service-cx8vzkXs.cjs +19 -0
  25. package/dist/logger-service-cx8vzkXs.cjs.map +1 -0
  26. package/dist/middleware-CAI0cnW2.d.mts +10 -0
  27. package/dist/middleware-CAI0cnW2.d.mts.map +1 -0
  28. package/dist/middleware-CYcctmlY.d.cts +10 -0
  29. package/dist/middleware-CYcctmlY.d.cts.map +1 -0
  30. package/dist/multipart.cjs +244 -0
  31. package/dist/multipart.cjs.map +1 -0
  32. package/dist/multipart.d.cts +2 -0
  33. package/dist/multipart.d.mts +2 -0
  34. package/dist/multipart.mjs +243 -0
  35. package/dist/multipart.mjs.map +1 -0
  36. package/dist/normalize-callback-BNBZZ1jT.cjs +44 -0
  37. package/dist/normalize-callback-BNBZZ1jT.cjs.map +1 -0
  38. package/dist/normalize-callback-DQ6C4gaV.mjs +33 -0
  39. package/dist/normalize-callback-DQ6C4gaV.mjs.map +1 -0
  40. package/dist/oneshot.cjs +64 -0
  41. package/dist/oneshot.cjs.map +1 -0
  42. package/dist/oneshot.d.cts +28 -0
  43. package/dist/oneshot.d.cts.map +1 -0
  44. package/dist/oneshot.d.mts +28 -0
  45. package/dist/oneshot.d.mts.map +1 -0
  46. package/dist/oneshot.mjs +63 -0
  47. package/dist/oneshot.mjs.map +1 -0
  48. package/dist/pipeline.cjs +16 -0
  49. package/dist/pipeline.cjs.map +1 -0
  50. package/dist/pipeline.d.cts +9 -0
  51. package/dist/pipeline.d.cts.map +1 -0
  52. package/dist/pipeline.d.mts +9 -0
  53. package/dist/pipeline.d.mts.map +1 -0
  54. package/dist/pipeline.mjs +14 -0
  55. package/dist/pipeline.mjs.map +1 -0
  56. package/dist/progress.cjs +0 -0
  57. package/dist/progress.d.cts +3 -0
  58. package/dist/progress.d.mts +3 -0
  59. package/dist/progress.mjs +1 -0
  60. package/dist/services.cjs +8 -0
  61. package/dist/services.d.cts +3 -0
  62. package/dist/services.d.mts +3 -0
  63. package/dist/services.mjs +3 -0
  64. package/dist/upload-error-B2ISUc_k.d.cts +48 -0
  65. package/dist/upload-error-B2ISUc_k.d.cts.map +1 -0
  66. package/dist/upload-error-BUexBh08.cjs +119 -0
  67. package/dist/upload-error-BUexBh08.cjs.map +1 -0
  68. package/dist/upload-error-jol-eoDW.d.mts +48 -0
  69. package/dist/upload-error-jol-eoDW.d.mts.map +1 -0
  70. package/dist/upload-error-zDvpxT9X.mjs +72 -0
  71. package/dist/upload-error-zDvpxT9X.mjs.map +1 -0
  72. package/dist/upload-event-C9TOVp5l.d.mts +36 -0
  73. package/dist/upload-event-C9TOVp5l.d.mts.map +1 -0
  74. package/dist/upload-event-D77olieX.d.cts +36 -0
  75. package/dist/upload-event-D77olieX.d.cts.map +1 -0
  76. package/package.json +70 -0
  77. package/src/errors/index.ts +10 -0
  78. package/src/errors/upload-error.test.ts +218 -0
  79. package/src/errors/upload-error.ts +89 -0
  80. package/src/multipart/chunk-stream.test.ts +79 -0
  81. package/src/multipart/chunk-stream.ts +37 -0
  82. package/src/multipart/circuit-breaker.test.ts +95 -0
  83. package/src/multipart/circuit-breaker.ts +68 -0
  84. package/src/multipart/index.test.ts +283 -0
  85. package/src/multipart/index.ts +119 -0
  86. package/src/multipart/upload-stream.test.ts +336 -0
  87. package/src/multipart/upload-stream.ts +246 -0
  88. package/src/oneshot/index.test.ts +153 -0
  89. package/src/oneshot/index.ts +76 -0
  90. package/src/oneshot/upload.test.ts +130 -0
  91. package/src/oneshot/upload.ts +52 -0
  92. package/src/pipeline/compress.test.ts +69 -0
  93. package/src/pipeline/compress.ts +8 -0
  94. package/src/pipeline/index.ts +3 -0
  95. package/src/pipeline/middleware.test.ts +102 -0
  96. package/src/pipeline/middleware.ts +30 -0
  97. package/src/progress/getprogress.test.ts +102 -0
  98. package/src/progress/index.ts +10 -0
  99. package/src/progress/upload-event.test.ts +102 -0
  100. package/src/progress/upload-event.ts +37 -0
  101. package/src/scaffold.test.ts +5 -0
  102. package/src/services/compression-service.test.ts +68 -0
  103. package/src/services/compression-service.ts +31 -0
  104. package/src/services/index.ts +11 -0
  105. package/src/services/logger-service-integration.test.ts +98 -0
  106. package/src/services/logger-service.test.ts +40 -0
  107. package/src/services/logger-service.ts +17 -0
  108. package/src/utils/abort-interop.test.ts +65 -0
  109. package/src/utils/abort-interop.ts +14 -0
  110. package/src/utils/normalize-callback.test.ts +46 -0
  111. package/src/utils/normalize-callback.ts +18 -0
  112. package/tsconfig.json +8 -0
  113. package/tsdown.config.ts +16 -0
  114. package/vitest.config.ts +7 -0
@@ -0,0 +1,336 @@
1
+ import { describe, expect, it } from "@effect/vitest"
2
+ import { Cause, Effect, Fiber, Ref, Schedule, Stream, TestClock } from "effect"
3
+ import { AbortError, CircuitOpenError, CompleteUploadError, MaxRetriesExceededError, PartUploadError, PresignedUrlError, ReconcileError } from "../errors/upload-error.js"
4
+ import type { UploadEvent } from "../progress/upload-event.js"
5
+ import { LoggerServiceLive } from "../services/logger-service.js"
6
+ import { uploadMultipartEffect, type CompletedPart } from "./upload-stream.js"
7
+
8
+ const fromBytes = (bytes: Uint8Array): ReadableStream<Uint8Array> =>
9
+ new ReadableStream({ start: c => { c.enqueue(bytes); c.close() } })
10
+
11
+ const run = (options: Parameters<typeof uploadMultipartEffect>[0]) =>
12
+ Stream.runCollect(uploadMultipartEffect(options)).pipe(
13
+ Effect.map(chunk => Array.from(chunk)),
14
+ Effect.provide(LoggerServiceLive)
15
+ )
16
+
17
+ describe("uploadMultipartEffect", () => {
18
+ it.effect("emits PartCompleted per chunk and UploadCompleted at end", () =>
19
+ Effect.gen(function* () {
20
+ const etags = ["etag-1", "etag-2", "etag-3"]
21
+ const receivedParts: CompletedPart[] = []
22
+
23
+ const events = yield* run({
24
+ stream: fromBytes(new Uint8Array(30).fill(1)),
25
+ chunkSize: 10,
26
+ uploadPart: (partNumber, chunk) => {
27
+ expect(chunk.length).toBeLessThanOrEqual(10)
28
+ return etags[partNumber - 1]!
29
+ },
30
+ completeUpload: (_uploadId, parts) => { receivedParts.push(...parts) },
31
+ })
32
+
33
+ const partEvents = events.filter(e => e._tag === "PartCompleted")
34
+ const completeEvent = events.find(e => e._tag === "UploadCompleted")
35
+
36
+ expect(partEvents).toHaveLength(3)
37
+ expect(partEvents[0]).toMatchObject({ _tag: "PartCompleted", partNumber: 1, etag: "etag-1", bytesUploaded: 10 })
38
+ expect(partEvents[1]).toMatchObject({ _tag: "PartCompleted", partNumber: 2, etag: "etag-2", bytesUploaded: 10 })
39
+ expect(partEvents[2]).toMatchObject({ _tag: "PartCompleted", partNumber: 3, etag: "etag-3", bytesUploaded: 10 })
40
+ expect(completeEvent).toMatchObject({ _tag: "UploadCompleted", totalParts: 3 })
41
+
42
+ expect(receivedParts).toHaveLength(3)
43
+ expect(receivedParts.map(p => p.partNumber).sort()).toEqual([1, 2, 3])
44
+ })
45
+ )
46
+
47
+ it.effect("limits concurrent parts to maxConcurrency", () =>
48
+ Effect.gen(function* () {
49
+ const refConcurrent = yield* Ref.make(0)
50
+ const refMaxObserved = yield* Ref.make(0)
51
+
52
+ const uploadPart = (_partNumber: number, _chunk: Uint8Array): Effect.Effect<string, never> =>
53
+ Effect.gen(function* () {
54
+ yield* Ref.update(refConcurrent, n => n + 1)
55
+ const current = yield* Ref.get(refConcurrent)
56
+ yield* Ref.update(refMaxObserved, max => Math.max(max, current))
57
+ yield* Effect.yieldNow()
58
+ yield* Ref.update(refConcurrent, n => n - 1)
59
+ return `etag-${_partNumber}`
60
+ }) as Effect.Effect<string, never>
61
+
62
+ yield* run({
63
+ stream: fromBytes(new Uint8Array(60).fill(1)),
64
+ chunkSize: 10,
65
+ uploadPart,
66
+ completeUpload: () => {},
67
+ maxConcurrency: 3,
68
+ })
69
+
70
+ const maxObserved = yield* Ref.get(refMaxObserved)
71
+ expect(maxObserved).toBeLessThanOrEqual(3)
72
+ expect(maxObserved).toBeGreaterThanOrEqual(1)
73
+ })
74
+ )
75
+
76
+ it.effect("retries on failure and emits PartCompleted on eventual success", () =>
77
+ Effect.gen(function* () {
78
+ const refAttempts = yield* Ref.make(0)
79
+
80
+ const events = yield* run({
81
+ stream: fromBytes(new Uint8Array(10).fill(1)),
82
+ chunkSize: 10,
83
+ uploadPart: (_partNumber, _chunk) => Effect.gen(function* () {
84
+ const attempts = yield* Ref.updateAndGet(refAttempts, n => n + 1)
85
+ if (attempts < 2) return yield* Effect.fail(new PartUploadError(1, attempts, new Error("transient")) as never)
86
+ return "etag-ok"
87
+ }) as Effect.Effect<string, PartUploadError>,
88
+ completeUpload: () => {},
89
+ retrySchedule: Schedule.recurs(2),
90
+ })
91
+
92
+ const partEvent = events.find(e => e._tag === "PartCompleted")
93
+ expect(partEvent).toMatchObject({ _tag: "PartCompleted", etag: "etag-ok" })
94
+ expect(yield* Ref.get(refAttempts)).toBe(2)
95
+ })
96
+ )
97
+
98
+ it.effect("fails with PartUploadError on single-attempt failure (no retries)", () =>
99
+ Effect.gen(function* () {
100
+ const cause = new Error("immediate failure")
101
+ const result = yield* run({
102
+ stream: fromBytes(new Uint8Array(10).fill(1)),
103
+ chunkSize: 10,
104
+ uploadPart: () => Promise.reject(cause),
105
+ completeUpload: () => {},
106
+ retrySchedule: Schedule.recurs(0),
107
+ }).pipe(Effect.flip)
108
+
109
+ expect(result).toBeInstanceOf(PartUploadError)
110
+ expect((result as PartUploadError).partNumber).toBe(1)
111
+ expect((result as PartUploadError).attempt).toBe(1)
112
+ expect((result as PartUploadError).cause).toBe(cause)
113
+ })
114
+ )
115
+
116
+ it.effect("fails with MaxRetriesExceededError when retries exhausted", () =>
117
+ Effect.gen(function* () {
118
+ const cause = new Error("permanent failure")
119
+ const result = yield* run({
120
+ stream: fromBytes(new Uint8Array(10).fill(1)),
121
+ chunkSize: 10,
122
+ uploadPart: () => Promise.reject(cause),
123
+ completeUpload: () => {},
124
+ retrySchedule: Schedule.recurs(1),
125
+ }).pipe(Effect.flip)
126
+
127
+ expect(result).toBeInstanceOf(MaxRetriesExceededError)
128
+ expect((result as MaxRetriesExceededError).partNumber).toBe(1)
129
+ expect((result as MaxRetriesExceededError).totalAttempts).toBe(2)
130
+ expect((result as MaxRetriesExceededError).cause).toBe(cause)
131
+ })
132
+ )
133
+
134
+ it.effect("wraps completeUpload non-UploadError in CompleteUploadError", () =>
135
+ Effect.gen(function* () {
136
+ const cause = new TypeError("network down")
137
+ const result = yield* run({
138
+ stream: fromBytes(new Uint8Array(10).fill(1)),
139
+ chunkSize: 10,
140
+ uploadPart: () => "etag-1",
141
+ completeUpload: () => { throw cause },
142
+ }).pipe(Effect.flip)
143
+
144
+ expect(result).toBeInstanceOf(CompleteUploadError)
145
+ expect((result as CompleteUploadError)._tag).toBe("CompleteUploadError")
146
+ expect((result as CompleteUploadError).cause).toBe(cause)
147
+ })
148
+ )
149
+
150
+ it.effect("fails with AbortError when signal is aborted", () =>
151
+ Effect.gen(function* () {
152
+ const controller = new AbortController()
153
+
154
+ const uploadPart = () => new Promise<string>((_resolve) => {
155
+ setTimeout(() => controller.abort(), 5)
156
+ })
157
+
158
+ const result = yield* run({
159
+ stream: fromBytes(new Uint8Array(10).fill(1)),
160
+ chunkSize: 10,
161
+ uploadPart,
162
+ completeUpload: () => {},
163
+ signal: controller.signal,
164
+ }).pipe(Effect.flip)
165
+
166
+ expect(result).toBeInstanceOf(AbortError)
167
+ })
168
+ )
169
+
170
+ it.effect("default schedule retries 3 total attempts (1 initial + 2 retries)", () =>
171
+ Effect.gen(function* () {
172
+ let attempts = 0
173
+ const cause = new Error("permanent")
174
+
175
+ const fiber = yield* Effect.fork(run({
176
+ stream: fromBytes(new Uint8Array(10).fill(1)),
177
+ chunkSize: 10,
178
+ // No retrySchedule → DEFAULT_RETRY_SCHEDULE = exponential(100ms) + recurs(2) = 3 total
179
+ uploadPart: () => { attempts++; throw cause },
180
+ completeUpload: () => {},
181
+ }).pipe(Effect.flip))
182
+
183
+ // Advance TestClock to let exponential backoff proceed (100ms + 200ms)
184
+ yield* TestClock.adjust("500 millis")
185
+
186
+ const result = yield* Fiber.join(fiber)
187
+
188
+ expect(attempts).toBe(3)
189
+ expect(result).toBeInstanceOf(MaxRetriesExceededError)
190
+ expect((result as MaxRetriesExceededError).totalAttempts).toBe(3)
191
+ expect((result as MaxRetriesExceededError).cause).toBe(cause)
192
+ })
193
+ )
194
+
195
+ it.effect("Schedule.whileInput allows differentiating by original error type", () =>
196
+ Effect.gen(function* () {
197
+ let attempts = 0
198
+ const cause = new PresignedUrlError(new Error("presigned URL expired"))
199
+
200
+ // Schedule that only retries when the cause is NOT a PresignedUrlError
201
+ // uploadPart errors are wrapped in PartUploadError by upload-stream.ts,
202
+ // so err.cause holds the original error thrown by the callback
203
+ const scheduleNoRetryForPresigned = Schedule.whileInput(
204
+ Schedule.recurs(2),
205
+ (err: PartUploadError) => !(err.cause instanceof PresignedUrlError)
206
+ )
207
+
208
+ const result = yield* run({
209
+ stream: fromBytes(new Uint8Array(10).fill(1)),
210
+ chunkSize: 10,
211
+ retrySchedule: scheduleNoRetryForPresigned,
212
+ uploadPart: () => { attempts++; throw cause },
213
+ completeUpload: () => {},
214
+ }).pipe(Effect.flip)
215
+
216
+ // Schedule.whileInput returns false on first attempt → no retries → 1 attempt only
217
+ expect(attempts).toBe(1)
218
+ // 1 attempt only → PartUploadError (not MaxRetriesExceededError — totalAttempts <= 1)
219
+ expect(result).toBeInstanceOf(PartUploadError)
220
+ expect((result as PartUploadError).cause).toBe(cause)
221
+ })
222
+ )
223
+ })
224
+
225
+ describe("uploadMultipartEffect with reconcileCompletedParts", () => {
226
+ it.effect("skipped parts emit PartCompleted with reconciled etag, uploadPart not called for them", () =>
227
+ Effect.gen(function* () {
228
+ const uploadedPartNumbers: number[] = []
229
+
230
+ const events = yield* run({
231
+ stream: fromBytes(new Uint8Array(30).fill(1)),
232
+ chunkSize: 10,
233
+ reconcileCompletedParts: () => [
234
+ { partNumber: 1, etag: "etag-reconciled-1" },
235
+ { partNumber: 2, etag: "etag-reconciled-2" },
236
+ ],
237
+ uploadPart: (n) => { uploadedPartNumbers.push(n); return `etag-fresh-${n}` },
238
+ completeUpload: () => {},
239
+ })
240
+
241
+ expect(uploadedPartNumbers).toEqual([3])
242
+
243
+ const partEvents = events.filter(e => e._tag === "PartCompleted")
244
+ expect(partEvents).toHaveLength(3)
245
+ expect(partEvents.find(e => e._tag === "PartCompleted" && e.partNumber === 1)).toMatchObject({ partNumber: 1, etag: "etag-reconciled-1" })
246
+ expect(partEvents.find(e => e._tag === "PartCompleted" && e.partNumber === 2)).toMatchObject({ partNumber: 2, etag: "etag-reconciled-2" })
247
+ expect(partEvents.find(e => e._tag === "PartCompleted" && e.partNumber === 3)).toMatchObject({ partNumber: 3, etag: "etag-fresh-3" })
248
+ })
249
+ )
250
+
251
+ it.effect("completeUpload receives all parts (reconciled + new)", () =>
252
+ Effect.gen(function* () {
253
+ let receivedParts: CompletedPart[] = []
254
+
255
+ yield* run({
256
+ stream: fromBytes(new Uint8Array(20).fill(1)),
257
+ chunkSize: 10,
258
+ reconcileCompletedParts: () => [{ partNumber: 1, etag: "etag-reconciled-1" }],
259
+ uploadPart: () => "etag-fresh-2",
260
+ completeUpload: (_uploadId, parts) => { receivedParts = [...parts] },
261
+ })
262
+
263
+ expect(receivedParts).toHaveLength(2)
264
+ expect(receivedParts.find(p => p.partNumber === 1)).toMatchObject({ partNumber: 1, etag: "etag-reconciled-1" })
265
+ expect(receivedParts.find(p => p.partNumber === 2)).toMatchObject({ partNumber: 2, etag: "etag-fresh-2" })
266
+ })
267
+ )
268
+
269
+ it.effect("empty reconcile: all parts uploaded normally", () =>
270
+ Effect.gen(function* () {
271
+ const uploadedPartNumbers: number[] = []
272
+
273
+ yield* run({
274
+ stream: fromBytes(new Uint8Array(20).fill(1)),
275
+ chunkSize: 10,
276
+ reconcileCompletedParts: () => [],
277
+ uploadPart: (n) => { uploadedPartNumbers.push(n); return `etag-${n}` },
278
+ completeUpload: () => {},
279
+ })
280
+
281
+ expect(uploadedPartNumbers.sort()).toEqual([1, 2])
282
+ })
283
+ )
284
+
285
+ it.effect("reconcileCompletedParts throws: fails with ReconcileError", () =>
286
+ Effect.gen(function* () {
287
+ const cause = new Error("reconcile failed")
288
+
289
+ const result = yield* run({
290
+ stream: fromBytes(new Uint8Array(10).fill(1)),
291
+ chunkSize: 10,
292
+ reconcileCompletedParts: () => { throw cause },
293
+ uploadPart: () => "etag",
294
+ completeUpload: () => {},
295
+ }).pipe(Effect.flip)
296
+
297
+ expect(result).toBeInstanceOf(ReconcileError)
298
+ expect((result as ReconcileError).cause).toBe(cause)
299
+ })
300
+ )
301
+ })
302
+
303
+ describe("uploadMultipartEffect with circuitBreaker", () => {
304
+ it.effect("opens circuit after threshold consecutive failures, emits CircuitOpen event", () =>
305
+ Effect.gen(function* () {
306
+ const received: UploadEvent[] = []
307
+
308
+ // threshold=1: circuit opens on the very first part failure
309
+ // (with unbounded concurrency, only 1 part completes its failure cycle
310
+ // before Stream.mapEffect terminates the stream)
311
+ const stream = uploadMultipartEffect({
312
+ stream: fromBytes(new Uint8Array(30).fill(1)),
313
+ chunkSize: 10,
314
+ maxConcurrency: 1,
315
+ uploadPart: () => Effect.fail(new PartUploadError(0, 1, new Error("network error"))),
316
+ completeUpload: () => {},
317
+ retrySchedule: Schedule.once,
318
+ circuitBreaker: { threshold: 1, cooldown: 5000 },
319
+ })
320
+
321
+ const exit = yield* Stream.runForEach(
322
+ stream,
323
+ (event) => Effect.sync(() => received.push(event))
324
+ ).pipe(Effect.exit, Effect.provide(LoggerServiceLive))
325
+
326
+ expect(exit._tag).toBe("Failure")
327
+
328
+ const circuitOpenEvent = received.find(e => e._tag === "CircuitOpen")
329
+ expect(circuitOpenEvent).toBeDefined()
330
+ expect(circuitOpenEvent!.failedParts).toBe(1)
331
+
332
+ const err = Cause.squash((exit as any).cause)
333
+ expect(err).toBeInstanceOf(CircuitOpenError)
334
+ })
335
+ )
336
+ })
@@ -0,0 +1,246 @@
1
+ import { Cause, Effect, Exit, Option, Ref, Schedule, Stream } from "effect"
2
+ import type { UploadError } from "../errors/upload-error.js"
3
+ import { CircuitOpenError, CompleteUploadError, InitiateUploadError, MaxRetriesExceededError, PartUploadError, ReconcileError } from "../errors/upload-error.js"
4
+ import type { CircuitOpen, PartCompleted, ProgressTick, UploadCompleted, UploadEvent, UploadInitiated } from "../progress/upload-event.js"
5
+ import { LoggerService } from "../services/logger-service.js"
6
+ import { fromAbortSignal } from "../utils/abort-interop.js"
7
+ import { normalizeCallback } from "../utils/normalize-callback.js"
8
+ import { makeCircuitBreaker, type CircuitBreakerConfig } from "./circuit-breaker.js"
9
+ import { chunkStream } from "./chunk-stream.js"
10
+
11
+ export interface CompletedPart {
12
+ readonly partNumber: number
13
+ readonly etag: string
14
+ }
15
+
16
+ export interface UploadMultipartOptions {
17
+ readonly stream: ReadableStream<Uint8Array>
18
+ readonly chunkSize: number
19
+ readonly uploadPart: (
20
+ partNumber: number,
21
+ chunk: Uint8Array
22
+ ) => string | Promise<string> | Effect.Effect<string, UploadError>
23
+ readonly completeUpload: (
24
+ uploadId: string,
25
+ parts: ReadonlyArray<CompletedPart>
26
+ ) => void | Promise<void> | Effect.Effect<void, UploadError>
27
+ readonly initiate?: () =>
28
+ | { uploadId: string }
29
+ | Promise<{ uploadId: string }>
30
+ | Effect.Effect<{ uploadId: string }, UploadError>
31
+ readonly reconcileCompletedParts?: () =>
32
+ | ReadonlyArray<CompletedPart>
33
+ | Promise<ReadonlyArray<CompletedPart>>
34
+ | Effect.Effect<ReadonlyArray<CompletedPart>, UploadError>
35
+ readonly maxConcurrency?: number
36
+ readonly signal?: AbortSignal
37
+ readonly retrySchedule?: Schedule.Schedule<unknown, PartUploadError>
38
+ readonly circuitBreaker?: CircuitBreakerConfig
39
+ }
40
+
41
+ const DEFAULT_MAX_CONCURRENCY = 4
42
+
43
+ // 3 total attempts: 1 initial + 2 retries, with exponential backoff
44
+ const DEFAULT_RETRY_SCHEDULE = Schedule.exponential("100 millis").pipe(
45
+ Schedule.compose(Schedule.recurs(2))
46
+ )
47
+
48
+ export const uploadMultipartEffect = (
49
+ options: UploadMultipartOptions
50
+ ): Stream.Stream<UploadEvent, UploadError, LoggerService> => {
51
+ const {
52
+ stream,
53
+ chunkSize,
54
+ uploadPart,
55
+ completeUpload,
56
+ initiate,
57
+ reconcileCompletedParts,
58
+ maxConcurrency = DEFAULT_MAX_CONCURRENCY,
59
+ signal,
60
+ retrySchedule = DEFAULT_RETRY_SCHEDULE,
61
+ } = options
62
+
63
+ return Stream.unwrap(
64
+ Effect.gen(function* () {
65
+ const logger = yield* LoggerService
66
+ const semaphore = yield* Effect.makeSemaphore(maxConcurrency)
67
+ const refParts = yield* Ref.make<CompletedPart[]>([])
68
+ const refBytesUploaded = yield* Ref.make(0)
69
+ const refUploadId = yield* Ref.make("")
70
+ const breaker = options.circuitBreaker
71
+ ? yield* makeCircuitBreaker(options.circuitBreaker)
72
+ : null
73
+
74
+ const reconciledMap: Map<number, string> = reconcileCompletedParts
75
+ ? new Map(
76
+ (yield* normalizeCallback(reconcileCompletedParts).pipe(
77
+ Effect.mapError((cause): UploadError => new ReconcileError(cause))
78
+ )).map(p => [p.partNumber, p.etag])
79
+ )
80
+ : new Map()
81
+
82
+ const initiateStream: Stream.Stream<UploadEvent, UploadError, never> = initiate
83
+ ? Stream.fromEffect(
84
+ normalizeCallback(initiate).pipe(
85
+ Effect.mapError((cause): UploadError => new InitiateUploadError(cause)),
86
+ Effect.flatMap(({ uploadId }) =>
87
+ Ref.set(refUploadId, uploadId).pipe(
88
+ Effect.as({
89
+ _tag: "UploadInitiated" as const,
90
+ uploadId,
91
+ timestamp: Date.now(),
92
+ } satisfies UploadInitiated)
93
+ )
94
+ )
95
+ )
96
+ )
97
+ : Stream.empty
98
+
99
+ const makeUploadOne = (
100
+ partNumber: number,
101
+ chunk: Uint8Array
102
+ ): Effect.Effect<PartCompleted, UploadError> =>
103
+ Effect.gen(function* () {
104
+ const reconciledEtag = reconciledMap.get(partNumber)
105
+ if (reconciledEtag !== undefined) {
106
+ const event: PartCompleted = {
107
+ _tag: "PartCompleted" as const,
108
+ partNumber,
109
+ etag: reconciledEtag,
110
+ bytesUploaded: chunk.length,
111
+ timestamp: Date.now(),
112
+ }
113
+ yield* Ref.update(refParts, parts => [...parts, { partNumber, etag: reconciledEtag }])
114
+ yield* Effect.sync(() => logger.log("info", `Part ${partNumber} skipped (reconciled)`))
115
+ return event
116
+ }
117
+
118
+ const refAttempts = yield* Ref.make(0)
119
+
120
+ const single: Effect.Effect<string, PartUploadError> = Effect.gen(function* () {
121
+ yield* Ref.update(refAttempts, n => n + 1)
122
+ const attempt = yield* Ref.get(refAttempts)
123
+ return yield* normalizeCallback(() => uploadPart(partNumber, chunk)).pipe(
124
+ Effect.mapError(
125
+ (cause): PartUploadError => new PartUploadError(partNumber, attempt, cause)
126
+ )
127
+ )
128
+ })
129
+
130
+ const etag = yield* Effect.retry(single, retrySchedule).pipe(
131
+ Effect.catchAll(err =>
132
+ Effect.gen(function* () {
133
+ const totalAttempts = yield* Ref.get(refAttempts)
134
+ if (totalAttempts <= 1) {
135
+ return yield* Effect.fail(err)
136
+ }
137
+ return yield* Effect.fail(
138
+ new MaxRetriesExceededError(partNumber, totalAttempts, err.cause)
139
+ )
140
+ })
141
+ )
142
+ )
143
+
144
+ const event: PartCompleted = {
145
+ _tag: "PartCompleted" as const,
146
+ partNumber,
147
+ etag,
148
+ bytesUploaded: chunk.length,
149
+ timestamp: Date.now(),
150
+ }
151
+
152
+ yield* Ref.update(refParts, parts => [...parts, { partNumber, etag }])
153
+ yield* Effect.sync(() => logger.log("info", `Part ${partNumber} completed`))
154
+ return event
155
+ })
156
+
157
+ const partsStream: Stream.Stream<UploadEvent, UploadError, never> = chunkStream(
158
+ stream,
159
+ chunkSize
160
+ ).pipe(
161
+ Stream.mapError((cause): UploadError => new PartUploadError(0, 0, cause)),
162
+ Stream.zipWithIndex,
163
+ Stream.mapEffect(
164
+ ([chunk, idx]) => {
165
+ const partNumber = Number(idx) + 1
166
+
167
+ if (!breaker) {
168
+ const partEffect = semaphore.withPermits(1)(
169
+ makeUploadOne(partNumber, chunk)
170
+ )
171
+ return signal ? Effect.raceFirst(partEffect, fromAbortSignal(signal)) : partEffect
172
+ }
173
+
174
+ const partEffect = Effect.gen(function* () {
175
+ yield* breaker.guard
176
+ return yield* semaphore.withPermits(1)(
177
+ Effect.gen(function* () {
178
+ const exit = yield* Effect.exit(makeUploadOne(partNumber, chunk))
179
+ if (Exit.isSuccess(exit)) {
180
+ yield* breaker.onSuccess
181
+ return exit.value
182
+ }
183
+ const circuitEvent = yield* breaker.onFailure
184
+ if (circuitEvent !== null) {
185
+ return yield* Effect.fail(new CircuitOpenError(circuitEvent.failedParts))
186
+ }
187
+ return yield* Effect.fail(Cause.squash(exit.cause) as UploadError)
188
+ })
189
+ )
190
+ })
191
+
192
+ return signal ? Effect.raceFirst(partEffect, fromAbortSignal(signal)) : partEffect
193
+ },
194
+ { concurrency: "unbounded" }
195
+ ),
196
+ Stream.flatMap(
197
+ (event): Stream.Stream<UploadEvent, UploadError, never> => {
198
+ const tickEffect = Ref.updateAndGet(refBytesUploaded, (n) => n + event.bytesUploaded).pipe(
199
+ Effect.map(
200
+ (total): ProgressTick => ({
201
+ _tag: "ProgressTick" as const,
202
+ bytesUploaded: total,
203
+ totalBytes: Option.none(),
204
+ timestamp: Date.now(),
205
+ })
206
+ )
207
+ )
208
+ return Stream.concat(Stream.make(event), Stream.fromEffect(tickEffect))
209
+ }
210
+ ),
211
+ Stream.catchAll((err: UploadError): Stream.Stream<UploadEvent, UploadError, never> => {
212
+ if (breaker && err._tag === "CircuitOpenError") {
213
+ const event: UploadEvent = {
214
+ _tag: "CircuitOpen",
215
+ failedParts: err.failedParts,
216
+ timestamp: Date.now(),
217
+ }
218
+ return Stream.concat(Stream.succeed(event), Stream.fail(err))
219
+ }
220
+ return Stream.fail(err)
221
+ })
222
+ )
223
+
224
+ const finalEffect: Effect.Effect<UploadEvent, UploadError, never> = Effect.gen(
225
+ function* () {
226
+ const uploadId = yield* Ref.get(refUploadId)
227
+ const parts = yield* Ref.get(refParts)
228
+ yield* normalizeCallback(() => completeUpload(uploadId, parts)).pipe(
229
+ Effect.mapError(
230
+ (cause): UploadError => new CompleteUploadError(cause)
231
+ )
232
+ )
233
+ yield* Effect.sync(() => logger.log("info", "Multipart upload completed"))
234
+ return {
235
+ _tag: "UploadCompleted" as const,
236
+ uploadId,
237
+ totalParts: parts.length,
238
+ timestamp: Date.now(),
239
+ } satisfies UploadCompleted
240
+ }
241
+ )
242
+
243
+ return Stream.concat(initiateStream, partsStream.pipe(Stream.concat(Stream.fromEffect(finalEffect))))
244
+ })
245
+ )
246
+ }