@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.
- package/.turbo/turbo-build.log +88 -0
- package/dist/compression-service-Bm1VBnhT.mjs +18 -0
- package/dist/compression-service-Bm1VBnhT.mjs.map +1 -0
- package/dist/compression-service-Bn86iTJe.cjs +35 -0
- package/dist/compression-service-Bn86iTJe.cjs.map +1 -0
- package/dist/compression-service-CiF7Px08.d.cts +15 -0
- package/dist/compression-service-CiF7Px08.d.cts.map +1 -0
- package/dist/compression-service-DI7ZXVxH.d.mts +15 -0
- package/dist/compression-service-DI7ZXVxH.d.mts.map +1 -0
- package/dist/errors.cjs +9 -0
- package/dist/errors.d.cts +2 -0
- package/dist/errors.d.mts +2 -0
- package/dist/errors.mjs +2 -0
- package/dist/index-Ch8xM6Xt.d.cts +60 -0
- package/dist/index-Ch8xM6Xt.d.cts.map +1 -0
- package/dist/index-DBGtgXEd.d.mts +60 -0
- package/dist/index-DBGtgXEd.d.mts.map +1 -0
- package/dist/logger-service-1J5r_akj.mjs +8 -0
- package/dist/logger-service-1J5r_akj.mjs.map +1 -0
- package/dist/logger-service-BF2pZOHN.d.mts +12 -0
- package/dist/logger-service-BF2pZOHN.d.mts.map +1 -0
- package/dist/logger-service-CbN12RhO.d.cts +12 -0
- package/dist/logger-service-CbN12RhO.d.cts.map +1 -0
- package/dist/logger-service-cx8vzkXs.cjs +19 -0
- package/dist/logger-service-cx8vzkXs.cjs.map +1 -0
- package/dist/middleware-CAI0cnW2.d.mts +10 -0
- package/dist/middleware-CAI0cnW2.d.mts.map +1 -0
- package/dist/middleware-CYcctmlY.d.cts +10 -0
- package/dist/middleware-CYcctmlY.d.cts.map +1 -0
- package/dist/multipart.cjs +244 -0
- package/dist/multipart.cjs.map +1 -0
- package/dist/multipart.d.cts +2 -0
- package/dist/multipart.d.mts +2 -0
- package/dist/multipart.mjs +243 -0
- package/dist/multipart.mjs.map +1 -0
- package/dist/normalize-callback-BNBZZ1jT.cjs +44 -0
- package/dist/normalize-callback-BNBZZ1jT.cjs.map +1 -0
- package/dist/normalize-callback-DQ6C4gaV.mjs +33 -0
- package/dist/normalize-callback-DQ6C4gaV.mjs.map +1 -0
- package/dist/oneshot.cjs +64 -0
- package/dist/oneshot.cjs.map +1 -0
- package/dist/oneshot.d.cts +28 -0
- package/dist/oneshot.d.cts.map +1 -0
- package/dist/oneshot.d.mts +28 -0
- package/dist/oneshot.d.mts.map +1 -0
- package/dist/oneshot.mjs +63 -0
- package/dist/oneshot.mjs.map +1 -0
- package/dist/pipeline.cjs +16 -0
- package/dist/pipeline.cjs.map +1 -0
- package/dist/pipeline.d.cts +9 -0
- package/dist/pipeline.d.cts.map +1 -0
- package/dist/pipeline.d.mts +9 -0
- package/dist/pipeline.d.mts.map +1 -0
- package/dist/pipeline.mjs +14 -0
- package/dist/pipeline.mjs.map +1 -0
- package/dist/progress.cjs +0 -0
- package/dist/progress.d.cts +3 -0
- package/dist/progress.d.mts +3 -0
- package/dist/progress.mjs +1 -0
- package/dist/services.cjs +8 -0
- package/dist/services.d.cts +3 -0
- package/dist/services.d.mts +3 -0
- package/dist/services.mjs +3 -0
- package/dist/upload-error-B2ISUc_k.d.cts +48 -0
- package/dist/upload-error-B2ISUc_k.d.cts.map +1 -0
- package/dist/upload-error-BUexBh08.cjs +119 -0
- package/dist/upload-error-BUexBh08.cjs.map +1 -0
- package/dist/upload-error-jol-eoDW.d.mts +48 -0
- package/dist/upload-error-jol-eoDW.d.mts.map +1 -0
- package/dist/upload-error-zDvpxT9X.mjs +72 -0
- package/dist/upload-error-zDvpxT9X.mjs.map +1 -0
- package/dist/upload-event-C9TOVp5l.d.mts +36 -0
- package/dist/upload-event-C9TOVp5l.d.mts.map +1 -0
- package/dist/upload-event-D77olieX.d.cts +36 -0
- package/dist/upload-event-D77olieX.d.cts.map +1 -0
- package/package.json +70 -0
- package/src/errors/index.ts +10 -0
- package/src/errors/upload-error.test.ts +218 -0
- package/src/errors/upload-error.ts +89 -0
- package/src/multipart/chunk-stream.test.ts +79 -0
- package/src/multipart/chunk-stream.ts +37 -0
- package/src/multipart/circuit-breaker.test.ts +95 -0
- package/src/multipart/circuit-breaker.ts +68 -0
- package/src/multipart/index.test.ts +283 -0
- package/src/multipart/index.ts +119 -0
- package/src/multipart/upload-stream.test.ts +336 -0
- package/src/multipart/upload-stream.ts +246 -0
- package/src/oneshot/index.test.ts +153 -0
- package/src/oneshot/index.ts +76 -0
- package/src/oneshot/upload.test.ts +130 -0
- package/src/oneshot/upload.ts +52 -0
- package/src/pipeline/compress.test.ts +69 -0
- package/src/pipeline/compress.ts +8 -0
- package/src/pipeline/index.ts +3 -0
- package/src/pipeline/middleware.test.ts +102 -0
- package/src/pipeline/middleware.ts +30 -0
- package/src/progress/getprogress.test.ts +102 -0
- package/src/progress/index.ts +10 -0
- package/src/progress/upload-event.test.ts +102 -0
- package/src/progress/upload-event.ts +37 -0
- package/src/scaffold.test.ts +5 -0
- package/src/services/compression-service.test.ts +68 -0
- package/src/services/compression-service.ts +31 -0
- package/src/services/index.ts +11 -0
- package/src/services/logger-service-integration.test.ts +98 -0
- package/src/services/logger-service.test.ts +40 -0
- package/src/services/logger-service.ts +17 -0
- package/src/utils/abort-interop.test.ts +65 -0
- package/src/utils/abort-interop.ts +14 -0
- package/src/utils/normalize-callback.test.ts +46 -0
- package/src/utils/normalize-callback.ts +18 -0
- package/tsconfig.json +8 -0
- package/tsdown.config.ts +16 -0
- 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
|