@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,95 @@
1
+ import { describe, expect, it } from "@effect/vitest"
2
+ import { Effect } from "effect"
3
+ import { CircuitOpenError } from "../errors/upload-error.js"
4
+ import { makeCircuitBreaker } from "./circuit-breaker.js"
5
+
6
+ const realDelay = (ms: number) => Effect.promise(() => new Promise<void>(r => setTimeout(r, ms)))
7
+
8
+ describe("makeCircuitBreaker", () => {
9
+ it.effect("starts Closed and allows parts through", () =>
10
+ Effect.gen(function* () {
11
+ const cb = yield* makeCircuitBreaker({ threshold: 3, cooldown: 1000 })
12
+ yield* cb.guard
13
+ })
14
+ )
15
+
16
+ it.effect("opens circuit after threshold consecutive failures", () =>
17
+ Effect.gen(function* () {
18
+ const cb = yield* makeCircuitBreaker({ threshold: 2, cooldown: 1000 })
19
+ const event1 = yield* cb.onFailure
20
+ expect(event1).toBeNull()
21
+ const event2 = yield* cb.onFailure
22
+ expect(event2).not.toBeNull()
23
+ expect(event2!._tag).toBe("CircuitOpen")
24
+ expect(event2!.failedParts).toBe(2)
25
+ })
26
+ )
27
+
28
+ it.effect("guard fails with CircuitOpenError when circuit is Open", () =>
29
+ Effect.gen(function* () {
30
+ const cb = yield* makeCircuitBreaker({ threshold: 1, cooldown: 1000 })
31
+ yield* cb.onFailure
32
+ const result = yield* Effect.exit(cb.guard)
33
+ expect(result._tag).toBe("Failure")
34
+ const err = (result as any).cause.error
35
+ expect(err).toBeInstanceOf(CircuitOpenError)
36
+ })
37
+ )
38
+
39
+ it.effect("guard transitions Open → HalfOpen when cooldown elapsed", () =>
40
+ Effect.gen(function* () {
41
+ const cb = yield* makeCircuitBreaker({ threshold: 1, cooldown: 10 })
42
+ yield* cb.onFailure
43
+ yield* realDelay(25)
44
+ yield* cb.guard
45
+ yield* cb.onSuccess
46
+ yield* cb.guard
47
+ })
48
+ )
49
+
50
+ it.effect("onSuccess transitions HalfOpen → Closed", () =>
51
+ Effect.gen(function* () {
52
+ const cb = yield* makeCircuitBreaker({ threshold: 1, cooldown: 10 })
53
+ yield* cb.onFailure
54
+ yield* realDelay(25)
55
+ yield* cb.guard
56
+ yield* cb.onSuccess
57
+ yield* cb.guard
58
+ const event = yield* cb.onFailure
59
+ expect(event).not.toBeNull()
60
+ })
61
+ )
62
+
63
+ it.effect("onFailure in HalfOpen re-opens the circuit", () =>
64
+ Effect.gen(function* () {
65
+ const cb = yield* makeCircuitBreaker({ threshold: 1, cooldown: 10 })
66
+ yield* cb.onFailure
67
+ yield* realDelay(25)
68
+ yield* cb.guard
69
+ const event = yield* cb.onFailure
70
+ expect(event).not.toBeNull()
71
+ expect(event!._tag).toBe("CircuitOpen")
72
+ })
73
+ )
74
+
75
+ it.effect("failures below threshold do NOT open circuit", () =>
76
+ Effect.gen(function* () {
77
+ const cb = yield* makeCircuitBreaker({ threshold: 3, cooldown: 1000 })
78
+ const e1 = yield* cb.onFailure
79
+ const e2 = yield* cb.onFailure
80
+ expect(e1).toBeNull()
81
+ expect(e2).toBeNull()
82
+ yield* cb.guard
83
+ })
84
+ )
85
+
86
+ it.effect("onSuccess in Closed resets consecutive failure counter", () =>
87
+ Effect.gen(function* () {
88
+ const cb = yield* makeCircuitBreaker({ threshold: 2, cooldown: 1000 })
89
+ yield* cb.onFailure
90
+ yield* cb.onSuccess
91
+ const e = yield* cb.onFailure
92
+ expect(e).toBeNull()
93
+ })
94
+ )
95
+ })
@@ -0,0 +1,68 @@
1
+ import { Effect, Ref } from "effect"
2
+ import { CircuitOpenError } from "../errors/upload-error.js"
3
+ import type { CircuitOpen } from "../progress/upload-event.js"
4
+
5
+ export interface CircuitBreakerConfig {
6
+ readonly threshold: number
7
+ readonly cooldown: number
8
+ }
9
+
10
+ type CircuitState =
11
+ | { readonly _tag: "Closed"; readonly consecutiveFailures: number }
12
+ | { readonly _tag: "Open"; readonly openedAt: number }
13
+ | { readonly _tag: "HalfOpen" }
14
+
15
+ export interface CircuitBreaker {
16
+ readonly guard: Effect.Effect<void, CircuitOpenError>
17
+ readonly onSuccess: Effect.Effect<void>
18
+ readonly onFailure: Effect.Effect<CircuitOpen | null>
19
+ }
20
+
21
+ export const makeCircuitBreaker = (config: CircuitBreakerConfig): Effect.Effect<CircuitBreaker> =>
22
+ Effect.gen(function* () {
23
+ const refState = yield* Ref.make<CircuitState>({ _tag: "Closed", consecutiveFailures: 0 })
24
+
25
+ const guard: Effect.Effect<void, CircuitOpenError> = Effect.gen(function* () {
26
+ const blocked = yield* Ref.modify(refState, (state): [boolean, CircuitState] => {
27
+ if (state._tag !== "Open") return [false, state]
28
+ const elapsed = Date.now() - state.openedAt
29
+ if (elapsed < config.cooldown) return [true, state]
30
+ return [false, { _tag: "HalfOpen" as const }]
31
+ })
32
+ if (blocked) {
33
+ return yield* Effect.fail(new CircuitOpenError(config.threshold))
34
+ }
35
+ })
36
+
37
+ const onSuccess: Effect.Effect<void> = Ref.update(refState, state =>
38
+ state._tag === "HalfOpen" || state._tag === "Closed"
39
+ ? { _tag: "Closed" as const, consecutiveFailures: 0 }
40
+ : state
41
+ )
42
+
43
+ const onFailure: Effect.Effect<CircuitOpen | null> = Ref.modify(refState, (state): [CircuitOpen | null, CircuitState] => {
44
+ if (state._tag === "Closed") {
45
+ const newFailures = state.consecutiveFailures + 1
46
+ if (newFailures >= config.threshold) {
47
+ const event: CircuitOpen = {
48
+ _tag: "CircuitOpen",
49
+ failedParts: newFailures,
50
+ timestamp: Date.now(),
51
+ }
52
+ return [event, { _tag: "Open" as const, openedAt: Date.now() }]
53
+ }
54
+ return [null, { _tag: "Closed" as const, consecutiveFailures: newFailures }]
55
+ }
56
+ if (state._tag === "HalfOpen") {
57
+ const event: CircuitOpen = {
58
+ _tag: "CircuitOpen",
59
+ failedParts: config.threshold,
60
+ timestamp: Date.now(),
61
+ }
62
+ return [event, { _tag: "Open" as const, openedAt: Date.now() }]
63
+ }
64
+ return [null, state]
65
+ })
66
+
67
+ return { guard, onSuccess, onFailure }
68
+ })
@@ -0,0 +1,283 @@
1
+ import { describe, expect, it } from "@effect/vitest"
2
+ import { Cause, Effect, Exit, Option } from "effect"
3
+ import { AbortError, CompleteUploadError, InitiateUploadError } from "../errors/upload-error.js"
4
+ import { compress } from "../pipeline/compress.js"
5
+ import { compose, type Transform } from "../pipeline/middleware.js"
6
+ import { uploadMultipart } from "./index.js"
7
+ import { uploadMultipartEffect } from "./upload-stream.js"
8
+
9
+ // Helper: create a ReadableStream from a Uint8Array
10
+ const fromBytes = (bytes: Uint8Array): ReadableStream<Uint8Array> =>
11
+ new ReadableStream({
12
+ start(c) {
13
+ c.enqueue(bytes)
14
+ c.close()
15
+ },
16
+ })
17
+
18
+ // Helper: read all events from the ReadableStream
19
+ const readAllEvents = async <T>(rs: ReadableStream<T>): Promise<T[]> => {
20
+ const reader = rs.getReader()
21
+ const events: T[] = []
22
+ while (true) {
23
+ const { done, value } = await reader.read()
24
+ if (done) break
25
+ events.push(value)
26
+ }
27
+ return events
28
+ }
29
+
30
+ describe("uploadMultipart — Dual API entry point", () => {
31
+ it.effect("happy path: result resolves with UploadCompleted, events contains all events", () =>
32
+ Effect.gen(function* () {
33
+ const { result, events } = uploadMultipart({
34
+ stream: fromBytes(new Uint8Array(20).fill(1)),
35
+ chunkSize: 10,
36
+ uploadPart: (n) => `etag-${n}`,
37
+ completeUpload: () => {},
38
+ })
39
+
40
+ const [uploadResult, evts] = yield* Effect.all([
41
+ Effect.promise(() => result),
42
+ Effect.promise(() => readAllEvents(events)),
43
+ ])
44
+
45
+ expect(uploadResult._tag).toBe("UploadCompleted")
46
+ expect(uploadResult.totalParts).toBe(2)
47
+
48
+ const partEvents = evts.filter((e) => e._tag === "PartCompleted")
49
+ expect(partEvents).toHaveLength(2)
50
+ expect(evts.find((e) => e._tag === "UploadCompleted")).toBeDefined()
51
+ })
52
+ )
53
+
54
+ it.effect("getProgress tracks bytesUploaded; totalBytes is Some when provided", () =>
55
+ Effect.gen(function* () {
56
+ const { result, getProgress } = uploadMultipart({
57
+ stream: fromBytes(new Uint8Array(30).fill(1)),
58
+ chunkSize: 10,
59
+ uploadPart: () => "etag",
60
+ completeUpload: () => {},
61
+ totalBytes: 30,
62
+ })
63
+
64
+ yield* Effect.promise(() => result)
65
+
66
+ const progress = yield* Effect.promise(() => getProgress())
67
+ expect(progress.bytesUploaded).toBe(30)
68
+ expect(progress.totalBytes).toEqual(Option.some(30))
69
+ })
70
+ )
71
+
72
+ it.effect("getProgress returns None for totalBytes when not provided", () =>
73
+ Effect.gen(function* () {
74
+ const { result, getProgress } = uploadMultipart({
75
+ stream: fromBytes(new Uint8Array(10).fill(1)),
76
+ chunkSize: 10,
77
+ uploadPart: () => "etag",
78
+ completeUpload: () => {},
79
+ })
80
+
81
+ yield* Effect.promise(() => result)
82
+
83
+ const progress = yield* Effect.promise(() => getProgress())
84
+ expect(progress.totalBytes).toEqual(Option.none())
85
+ })
86
+ )
87
+
88
+ it.effect("abort signal: result rejects with AbortError, events stream closes cleanly", () =>
89
+ Effect.gen(function* () {
90
+ const controller = new AbortController()
91
+ const { result, events } = uploadMultipart({
92
+ stream: fromBytes(new Uint8Array(10).fill(1)),
93
+ chunkSize: 10,
94
+ uploadPart: () =>
95
+ new Promise<string>((_resolve) => {
96
+ setTimeout(() => controller.abort(), 5)
97
+ }),
98
+ completeUpload: () => {},
99
+ signal: controller.signal,
100
+ })
101
+
102
+ // result rejects with AbortError
103
+ const resultExit = yield* Effect.exit(
104
+ Effect.tryPromise({
105
+ try: () => result,
106
+ catch: (e) => e,
107
+ })
108
+ )
109
+ expect(Exit.isFailure(resultExit)).toBe(true)
110
+ if (Exit.isFailure(resultExit)) {
111
+ const errOption = Cause.failureOption(resultExit.cause)
112
+ expect(errOption._tag).toBe("Some")
113
+ const err = (errOption as { _tag: "Some"; value: unknown }).value
114
+ expect(err).toBeInstanceOf(AbortError)
115
+ expect((err as AbortError)._tag).toBe("AbortError")
116
+ }
117
+
118
+ // events ReadableStream closes cleanly (no throw)
119
+ const evts = yield* Effect.promise(() => readAllEvents(events))
120
+ // Stream should close without throwing — length may be 0 (aborted before any parts complete)
121
+ expect(Array.isArray(evts)).toBe(true)
122
+ })
123
+ )
124
+
125
+ it(".effect property points to uploadMultipartEffect", () => {
126
+ expect(uploadMultipart.effect).toBe(uploadMultipartEffect)
127
+ })
128
+
129
+ it.effect("applies plain Transform pipeline before chunking (data reaches uploadPart transformed)", () =>
130
+ Effect.gen(function* () {
131
+ const received: Uint8Array[] = []
132
+ // Transform: replace every byte with 0xAA
133
+ const markerTransform: Transform = (stream) =>
134
+ stream.pipeThrough(
135
+ new TransformStream<Uint8Array, Uint8Array>({
136
+ transform(chunk, controller) {
137
+ controller.enqueue(new Uint8Array(chunk.length).fill(0xaa))
138
+ },
139
+ })
140
+ )
141
+
142
+ const { result } = uploadMultipart({
143
+ stream: new ReadableStream({
144
+ start(c) { c.enqueue(new Uint8Array([1, 2, 3])); c.close() },
145
+ }),
146
+ chunkSize: 3,
147
+ pipeline: markerTransform,
148
+ uploadPart: (_, chunk) => {
149
+ received.push(chunk)
150
+ return "etag-1"
151
+ },
152
+ completeUpload: () => {},
153
+ })
154
+
155
+ yield* Effect.promise(() => result)
156
+ expect(received).toHaveLength(1)
157
+ expect(Array.from(received[0]!)).toEqual([0xaa, 0xaa, 0xaa])
158
+ })
159
+ )
160
+
161
+ it.effect("applies Effect pipeline (compress) before chunking — PartCompleted.bytesUploaded reflects compressed size", () =>
162
+ Effect.gen(function* () {
163
+ const received: Uint8Array[] = []
164
+ const original = new Uint8Array([1, 2, 3, 4, 5])
165
+
166
+ const { result } = uploadMultipart({
167
+ stream: new ReadableStream({
168
+ start(c) { c.enqueue(original); c.close() },
169
+ }),
170
+ chunkSize: 4096, // large enough to receive all compressed output in one part
171
+ pipeline: compress("deflate-raw"),
172
+ uploadPart: (_, chunk) => {
173
+ received.push(chunk)
174
+ return "etag-1"
175
+ },
176
+ completeUpload: () => {},
177
+ })
178
+
179
+ yield* Effect.promise(() => result)
180
+ expect(received).toHaveLength(1)
181
+ // Compressed output is non-empty
182
+ expect(received[0]!.length).toBeGreaterThan(0)
183
+ // Compressed bytes differ from raw input
184
+ expect(Array.from(received[0]!)).not.toEqual(Array.from(original))
185
+ })
186
+ )
187
+
188
+ it.effect("compose(compress()) can be passed as pipeline — same as compress() directly", () =>
189
+ Effect.gen(function* () {
190
+ const received: Uint8Array[] = []
191
+
192
+ const { result } = uploadMultipart({
193
+ stream: new ReadableStream({
194
+ start(c) { c.enqueue(new Uint8Array([10, 20, 30])); c.close() },
195
+ }),
196
+ chunkSize: 4096,
197
+ pipeline: compose(compress("deflate-raw")),
198
+ uploadPart: (_, chunk) => {
199
+ received.push(chunk)
200
+ return "etag-1"
201
+ },
202
+ completeUpload: () => {},
203
+ })
204
+
205
+ yield* Effect.promise(() => result)
206
+ expect(received).toHaveLength(1)
207
+ expect(received[0]!.length).toBeGreaterThan(0)
208
+ })
209
+ )
210
+
211
+ it.effect("initiate callback: UploadInitiated event emitted first, uploadId resolves to correct value", () =>
212
+ Effect.gen(function* () {
213
+ const { result, events, uploadId } = uploadMultipart({
214
+ stream: fromBytes(new Uint8Array(10).fill(1)),
215
+ chunkSize: 10,
216
+ initiate: () => Promise.resolve({ uploadId: "upload-abc-123" }),
217
+ uploadPart: () => "etag-1",
218
+ completeUpload: (uid, _parts) => {
219
+ expect(uid).toBe("upload-abc-123")
220
+ },
221
+ })
222
+
223
+ yield* Effect.promise(() => result)
224
+ const resolvedId = yield* Effect.promise(() => uploadId)
225
+ expect(resolvedId).toBe("upload-abc-123")
226
+
227
+ const evts = yield* Effect.promise(() => readAllEvents(events))
228
+ const initiatedEvent = evts.find(e => e._tag === "UploadInitiated")
229
+ expect(initiatedEvent).toMatchObject({ _tag: "UploadInitiated", uploadId: "upload-abc-123" })
230
+ expect(evts[0]!._tag).toBe("UploadInitiated")
231
+
232
+ const completedEvent = evts.find(e => e._tag === "UploadCompleted")
233
+ expect(completedEvent).toMatchObject({ _tag: "UploadCompleted", uploadId: "upload-abc-123" })
234
+ })
235
+ )
236
+
237
+ it.effect("no initiate: no UploadInitiated event, uploadId resolves to empty string", () =>
238
+ Effect.gen(function* () {
239
+ const { result, events, uploadId } = uploadMultipart({
240
+ stream: fromBytes(new Uint8Array(10).fill(1)),
241
+ chunkSize: 10,
242
+ uploadPart: () => "etag-1",
243
+ completeUpload: () => {},
244
+ })
245
+
246
+ yield* Effect.promise(() => result)
247
+ const resolvedId = yield* Effect.promise(() => uploadId)
248
+ expect(resolvedId).toBe("")
249
+
250
+ const evts = yield* Effect.promise(() => readAllEvents(events))
251
+ expect(evts.find(e => e._tag === "UploadInitiated")).toBeUndefined()
252
+ })
253
+ )
254
+
255
+ it.effect("initiate failure: result rejects with InitiateUploadError, uploadId resolves to empty string", () =>
256
+ Effect.gen(function* () {
257
+ const cause = new Error("initiation failed")
258
+ const { result, uploadId } = uploadMultipart({
259
+ stream: fromBytes(new Uint8Array(10).fill(1)),
260
+ chunkSize: 10,
261
+ initiate: () => { throw cause },
262
+ uploadPart: () => "etag-1",
263
+ completeUpload: () => {},
264
+ })
265
+
266
+ const resultExit = yield* Effect.exit(
267
+ Effect.tryPromise({
268
+ try: () => result,
269
+ catch: (e) => e,
270
+ })
271
+ )
272
+ expect(Exit.isFailure(resultExit)).toBe(true)
273
+ if (Exit.isFailure(resultExit)) {
274
+ const err = (Cause.failureOption(resultExit.cause) as { _tag: "Some"; value: unknown }).value
275
+ expect(err).toBeInstanceOf(InitiateUploadError)
276
+ expect((err as InitiateUploadError).cause).toBe(cause)
277
+ }
278
+
279
+ const resolvedId = yield* Effect.promise(() => uploadId)
280
+ expect(resolvedId).toBe("")
281
+ })
282
+ )
283
+ })
@@ -0,0 +1,119 @@
1
+ import { Cause, Effect, Exit, Option, Ref, Stream } from "effect"
2
+ import type { UploadCompleted, UploadEvent } from "../progress/upload-event.js"
3
+ import type { Transform } from "../pipeline/middleware.js"
4
+ import { CompressionServiceLive } from "../services/compression-service.js"
5
+ import { LoggerServiceLive } from "../services/logger-service.js"
6
+ import { uploadMultipartEffect, type CompletedPart, type UploadMultipartOptions } from "./upload-stream.js"
7
+
8
+ export type UploadResult = UploadCompleted
9
+ export type { CompletedPart, UploadMultipartOptions }
10
+
11
+ export interface Progress {
12
+ readonly bytesUploaded: number
13
+ readonly totalBytes: Option.Option<number>
14
+ }
15
+
16
+ export interface MultipartPublicOptions extends UploadMultipartOptions {
17
+ readonly totalBytes?: number
18
+ readonly pipeline?: Transform | Effect.Effect<Transform, unknown, unknown>
19
+ }
20
+
21
+ export const uploadMultipart = (
22
+ options: MultipartPublicOptions
23
+ ): {
24
+ events: ReadableStream<UploadEvent>
25
+ result: Promise<UploadResult>
26
+ getProgress: (() => Promise<Progress>) & { effect: Effect.Effect<Progress> }
27
+ uploadId: Promise<string>
28
+ } => {
29
+ const refProgress = Effect.runSync(
30
+ Ref.make<Progress>({
31
+ bytesUploaded: 0,
32
+ totalBytes: options.totalBytes !== undefined ? Option.some(options.totalBytes) : Option.none(),
33
+ })
34
+ )
35
+
36
+ let resolveUploadId!: (id: string) => void
37
+ const uploadIdPromise: Promise<string> = new Promise<string>((resolve) => {
38
+ resolveUploadId = resolve
39
+ })
40
+
41
+ const collected: Promise<ReadonlyArray<UploadEvent>> = (async () => {
42
+ // Step 1: resolve pipeline to get the processed stream
43
+ let processedStream = options.stream
44
+ if (options.pipeline !== undefined) {
45
+ if (typeof options.pipeline === "function") {
46
+ processedStream = options.pipeline(options.stream)
47
+ } else {
48
+ // Effect pipeline — resolve with CompressionServiceLive
49
+ const transform = await Effect.runPromise(
50
+ Effect.provide(
51
+ options.pipeline as Effect.Effect<Transform, unknown, never>,
52
+ CompressionServiceLive
53
+ )
54
+ )
55
+ processedStream = transform(options.stream)
56
+ }
57
+ }
58
+
59
+ // Step 2: run upload with processedStream
60
+ const program = uploadMultipartEffect({ ...options, stream: processedStream }).pipe(
61
+ Stream.tap((event) => {
62
+ if (event._tag === "UploadInitiated") {
63
+ return Effect.sync(() => resolveUploadId(event.uploadId))
64
+ }
65
+ if (event._tag === "PartCompleted") {
66
+ return Ref.update(refProgress, (p) => ({
67
+ ...p,
68
+ bytesUploaded: p.bytesUploaded + event.bytesUploaded,
69
+ }))
70
+ }
71
+ return Effect.void
72
+ }),
73
+ Stream.provideLayer(LoggerServiceLive)
74
+ )
75
+
76
+ const exit = await Stream.runCollect(program).pipe(
77
+ Effect.map((chunk) => Array.from(chunk)),
78
+ Effect.runPromiseExit
79
+ )
80
+ if (Exit.isSuccess(exit)) return exit.value
81
+ return Promise.reject(Cause.squash(exit.cause))
82
+ })()
83
+
84
+ // Rejection is surfaced via `result`; suppress the propagated rejection from .finally()
85
+ collected.finally(() => resolveUploadId("")).catch(() => {})
86
+
87
+ // events: ReadableStream built from collected array; closes cleanly on error
88
+ const events = new ReadableStream<UploadEvent>({
89
+ async start(controller) {
90
+ try {
91
+ const evts = await collected
92
+ for (const event of evts) controller.enqueue(event)
93
+ controller.close()
94
+ } catch (_) {
95
+ // Close cleanly — upload errors surface via `result` only
96
+ controller.close()
97
+ }
98
+ },
99
+ })
100
+
101
+ // result: resolves with UploadCompleted, rejects with UploadError on failure
102
+ const result: Promise<UploadResult> = collected.then((evts) => {
103
+ const last = evts[evts.length - 1]
104
+ if (last === undefined) {
105
+ return Promise.reject(new Error("uploadMultipart: stream ended without emitting an event"))
106
+ }
107
+ return last as UploadResult
108
+ })
109
+
110
+ const getProgress = Object.assign(
111
+ (): Promise<Progress> => Effect.runPromise(Ref.get(refProgress)),
112
+ { effect: Ref.get(refProgress) }
113
+ )
114
+
115
+ return { events, result, getProgress, uploadId: uploadIdPromise }
116
+ }
117
+
118
+ // Effect escape hatch — LoggerService layer left open for user composition
119
+ uploadMultipart.effect = uploadMultipartEffect