@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,102 @@
|
|
|
1
|
+
import { describe, expect, it } from "@effect/vitest"
|
|
2
|
+
import { Effect, Option } from "effect"
|
|
3
|
+
import { uploadMultipart, type Progress } from "../multipart/index.js"
|
|
4
|
+
|
|
5
|
+
// Helper: create a ReadableStream from repeated chunks with a delay
|
|
6
|
+
const slowStream = (chunkCount: number, chunkSize: number): ReadableStream<Uint8Array> =>
|
|
7
|
+
new ReadableStream({
|
|
8
|
+
async start(controller) {
|
|
9
|
+
for (let i = 0; i < chunkCount; i++) {
|
|
10
|
+
controller.enqueue(new Uint8Array(chunkSize).fill(i))
|
|
11
|
+
await new Promise((r) => setTimeout(r, 5))
|
|
12
|
+
}
|
|
13
|
+
controller.close()
|
|
14
|
+
},
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
describe("getProgress()", () => {
|
|
18
|
+
it.effect("getProgress() returns increasing bytesUploaded during an in-progress upload", () =>
|
|
19
|
+
Effect.gen(function* () {
|
|
20
|
+
let snapshotDuringUpload: Progress | null = null
|
|
21
|
+
|
|
22
|
+
const { result, getProgress } = uploadMultipart({
|
|
23
|
+
stream: slowStream(3, 10), // 3 parts × 10 bytes = 30 bytes total
|
|
24
|
+
chunkSize: 10,
|
|
25
|
+
uploadPart: async (partNumber, _chunk) => {
|
|
26
|
+
// Poll getProgress mid-upload (while part 2 is uploading — part 1 already completed)
|
|
27
|
+
if (partNumber === 2) {
|
|
28
|
+
snapshotDuringUpload = await getProgress()
|
|
29
|
+
}
|
|
30
|
+
return `etag-${partNumber}`
|
|
31
|
+
},
|
|
32
|
+
completeUpload: () => {},
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
yield* Effect.promise(() => result)
|
|
36
|
+
|
|
37
|
+
// snapshot taken while part 2 is uploading → part 1 completed → bytesUploaded ≥ 10
|
|
38
|
+
expect(snapshotDuringUpload).not.toBeNull()
|
|
39
|
+
expect((snapshotDuringUpload as unknown as Progress).bytesUploaded).toBeGreaterThanOrEqual(10)
|
|
40
|
+
|
|
41
|
+
// After completion, full 30 bytes accounted
|
|
42
|
+
const finalProgress = yield* Effect.promise(() => getProgress())
|
|
43
|
+
expect(finalProgress.bytesUploaded).toBe(30)
|
|
44
|
+
})
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
it.effect("calling getProgress() multiple times does not affect the upload", () =>
|
|
48
|
+
Effect.gen(function* () {
|
|
49
|
+
const { result, getProgress } = uploadMultipart({
|
|
50
|
+
stream: new ReadableStream({
|
|
51
|
+
start(c) {
|
|
52
|
+
c.enqueue(new Uint8Array(20).fill(1))
|
|
53
|
+
c.close()
|
|
54
|
+
},
|
|
55
|
+
}),
|
|
56
|
+
chunkSize: 10,
|
|
57
|
+
uploadPart: () => "etag",
|
|
58
|
+
completeUpload: () => {},
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
yield* Effect.promise(() => result)
|
|
62
|
+
|
|
63
|
+
const p1 = yield* Effect.promise(() => getProgress())
|
|
64
|
+
const p2 = yield* Effect.promise(() => getProgress())
|
|
65
|
+
const p3 = yield* Effect.promise(() => getProgress())
|
|
66
|
+
|
|
67
|
+
expect(p1.bytesUploaded).toBe(20)
|
|
68
|
+
expect(p2.bytesUploaded).toBe(20)
|
|
69
|
+
expect(p3.bytesUploaded).toBe(20)
|
|
70
|
+
expect(p1.totalBytes).toEqual(Option.none())
|
|
71
|
+
})
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
it.effect("getProgress.effect reads from Ref without launching the upload", () =>
|
|
75
|
+
Effect.gen(function* () {
|
|
76
|
+
const { result, getProgress } = uploadMultipart({
|
|
77
|
+
stream: new ReadableStream({
|
|
78
|
+
start(c) {
|
|
79
|
+
c.enqueue(new Uint8Array(15).fill(1))
|
|
80
|
+
c.close()
|
|
81
|
+
},
|
|
82
|
+
}),
|
|
83
|
+
chunkSize: 15,
|
|
84
|
+
uploadPart: () => "etag",
|
|
85
|
+
completeUpload: () => {},
|
|
86
|
+
totalBytes: 15,
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
// Call getProgress.effect BEFORE awaiting result — proves it doesn't launch the upload
|
|
90
|
+
const before = yield* getProgress.effect
|
|
91
|
+
expect(before.bytesUploaded).toBe(0)
|
|
92
|
+
expect(before.totalBytes).toEqual(Option.some(15))
|
|
93
|
+
|
|
94
|
+
yield* Effect.promise(() => result)
|
|
95
|
+
|
|
96
|
+
// After completion, Ref reflects final state
|
|
97
|
+
const after = yield* getProgress.effect
|
|
98
|
+
expect(after.bytesUploaded).toBe(15)
|
|
99
|
+
expect(after.totalBytes).toEqual(Option.some(15))
|
|
100
|
+
})
|
|
101
|
+
)
|
|
102
|
+
})
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { it, describe, expect } from "@effect/vitest"
|
|
2
|
+
import { Effect, Match, Option } from "effect"
|
|
3
|
+
import type { UploadEvent, ProgressTick } from "./upload-event.js"
|
|
4
|
+
import { uploadMultipart } from "../multipart/index.js"
|
|
5
|
+
|
|
6
|
+
describe("UploadEvent type system", () => {
|
|
7
|
+
it("exhaustive switch on _tag compiles and handles all variants", () => {
|
|
8
|
+
const handle = (event: UploadEvent): string => {
|
|
9
|
+
switch (event._tag) {
|
|
10
|
+
case "UploadInitiated":
|
|
11
|
+
return "initiated"
|
|
12
|
+
case "PartCompleted":
|
|
13
|
+
return "part"
|
|
14
|
+
case "ProgressTick":
|
|
15
|
+
return "progress"
|
|
16
|
+
case "UploadCompleted":
|
|
17
|
+
return "done"
|
|
18
|
+
case "CircuitOpen":
|
|
19
|
+
return "circuit"
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
const event: UploadEvent = {
|
|
23
|
+
_tag: "ProgressTick",
|
|
24
|
+
bytesUploaded: 500,
|
|
25
|
+
totalBytes: Option.none(),
|
|
26
|
+
timestamp: 0,
|
|
27
|
+
}
|
|
28
|
+
expect(handle(event)).toBe("progress")
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it.effect("Match.tag handles all variants exhaustively", () =>
|
|
32
|
+
Effect.gen(function* () {
|
|
33
|
+
const event: UploadEvent = {
|
|
34
|
+
_tag: "PartCompleted",
|
|
35
|
+
partNumber: 1,
|
|
36
|
+
etag: "abc",
|
|
37
|
+
bytesUploaded: 100,
|
|
38
|
+
timestamp: 0,
|
|
39
|
+
}
|
|
40
|
+
const result = Match.type<UploadEvent>().pipe(
|
|
41
|
+
Match.tag("UploadInitiated", (e) => `initiated:${e.uploadId}`),
|
|
42
|
+
Match.tag("PartCompleted", (e) => `part:${e.partNumber}`),
|
|
43
|
+
Match.tag("ProgressTick", (e) => `progress:${e.bytesUploaded}`),
|
|
44
|
+
Match.tag("UploadCompleted", (e) => `done:${e.totalParts}`),
|
|
45
|
+
Match.tag("CircuitOpen", (e) => `circuit:${e.failedParts}`),
|
|
46
|
+
Match.exhaustive
|
|
47
|
+
)(event)
|
|
48
|
+
expect(result).toBe("part:1")
|
|
49
|
+
})
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
it("all variants have _tag and timestamp fields", () => {
|
|
53
|
+
const variants: UploadEvent[] = [
|
|
54
|
+
{ _tag: "UploadInitiated", uploadId: "id", timestamp: 0 },
|
|
55
|
+
{ _tag: "PartCompleted", partNumber: 1, etag: "e", bytesUploaded: 10, timestamp: 1 },
|
|
56
|
+
{ _tag: "ProgressTick", bytesUploaded: 10, totalBytes: Option.some(100), timestamp: 2 },
|
|
57
|
+
{ _tag: "UploadCompleted", uploadId: "id", totalParts: 1, timestamp: 3 },
|
|
58
|
+
{ _tag: "CircuitOpen", failedParts: 3, timestamp: 4 },
|
|
59
|
+
]
|
|
60
|
+
for (const v of variants) {
|
|
61
|
+
expect(typeof v._tag).toBe("string")
|
|
62
|
+
expect(typeof v.timestamp).toBe("number")
|
|
63
|
+
}
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it.effect("uploadMultipart emits ProgressTick after each PartCompleted", () =>
|
|
67
|
+
Effect.gen(function* () {
|
|
68
|
+
const allEvents: UploadEvent[] = []
|
|
69
|
+
const { result, events } = uploadMultipart({
|
|
70
|
+
stream: new ReadableStream({
|
|
71
|
+
start(c) {
|
|
72
|
+
c.enqueue(new Uint8Array([1, 2, 3]))
|
|
73
|
+
c.enqueue(new Uint8Array([4, 5, 6]))
|
|
74
|
+
c.close()
|
|
75
|
+
},
|
|
76
|
+
}),
|
|
77
|
+
chunkSize: 3,
|
|
78
|
+
uploadPart: (_partNumber, _chunk) => "etag",
|
|
79
|
+
completeUpload: () => {},
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
const consumeEvents = async () => {
|
|
83
|
+
const reader = events.getReader()
|
|
84
|
+
while (true) {
|
|
85
|
+
const { done, value } = await reader.read()
|
|
86
|
+
if (done) break
|
|
87
|
+
allEvents.push(value)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
yield* Effect.promise(() => Promise.all([result, consumeEvents()]))
|
|
92
|
+
const progressTicks = allEvents.filter((e) => e._tag === "ProgressTick")
|
|
93
|
+
expect(progressTicks).toHaveLength(2)
|
|
94
|
+
const tick1 = progressTicks[0] as ProgressTick
|
|
95
|
+
const tick2 = progressTicks[1] as ProgressTick
|
|
96
|
+
expect(tick1.bytesUploaded).toBe(3)
|
|
97
|
+
expect(tick1.totalBytes).toStrictEqual(Option.none())
|
|
98
|
+
expect(tick2.bytesUploaded).toBe(6)
|
|
99
|
+
expect(tick2.totalBytes).toStrictEqual(Option.none())
|
|
100
|
+
})
|
|
101
|
+
)
|
|
102
|
+
})
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { Option } from "effect"
|
|
2
|
+
|
|
3
|
+
export interface UploadInitiated {
|
|
4
|
+
readonly _tag: "UploadInitiated"
|
|
5
|
+
readonly uploadId: string
|
|
6
|
+
readonly timestamp: number
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface UploadCompleted {
|
|
10
|
+
readonly _tag: "UploadCompleted"
|
|
11
|
+
readonly uploadId: string
|
|
12
|
+
readonly totalParts: number
|
|
13
|
+
readonly timestamp: number
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface PartCompleted {
|
|
17
|
+
readonly _tag: "PartCompleted"
|
|
18
|
+
readonly partNumber: number
|
|
19
|
+
readonly etag: string
|
|
20
|
+
readonly bytesUploaded: number
|
|
21
|
+
readonly timestamp: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface CircuitOpen {
|
|
25
|
+
readonly _tag: "CircuitOpen"
|
|
26
|
+
readonly failedParts: number
|
|
27
|
+
readonly timestamp: number
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ProgressTick {
|
|
31
|
+
readonly _tag: "ProgressTick"
|
|
32
|
+
readonly bytesUploaded: number
|
|
33
|
+
readonly totalBytes: Option.Option<number>
|
|
34
|
+
readonly timestamp: number
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type UploadEvent = UploadInitiated | UploadCompleted | PartCompleted | ProgressTick | CircuitOpen
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { it, describe, expect } from "@effect/vitest"
|
|
2
|
+
import { Cause, Effect, Layer } from "effect"
|
|
3
|
+
import {
|
|
4
|
+
CompressionService,
|
|
5
|
+
CompressionServiceLive,
|
|
6
|
+
CompressionUnavailableError,
|
|
7
|
+
} from "./compression-service.js"
|
|
8
|
+
|
|
9
|
+
describe("CompressionService", () => {
|
|
10
|
+
it.effect("CompressionServiceLive fails with typed CompressionUnavailableError when CompressionStream is absent", () =>
|
|
11
|
+
Effect.gen(function* () {
|
|
12
|
+
const AbsentLayer: Layer.Layer<CompressionService, CompressionUnavailableError> =
|
|
13
|
+
Layer.effect(CompressionService, Effect.fail(new CompressionUnavailableError()))
|
|
14
|
+
|
|
15
|
+
const result = yield* Effect.exit(
|
|
16
|
+
Effect.provide(
|
|
17
|
+
Effect.flatMap(CompressionService, (s) => Effect.succeed(s)),
|
|
18
|
+
AbsentLayer
|
|
19
|
+
)
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
expect(result._tag).toBe("Failure")
|
|
23
|
+
if (result._tag === "Failure") {
|
|
24
|
+
const failure = Cause.failureOption(result.cause)
|
|
25
|
+
expect(failure._tag).toBe("Some")
|
|
26
|
+
if (failure._tag === "Some") {
|
|
27
|
+
expect(failure.value).toBeInstanceOf(CompressionUnavailableError)
|
|
28
|
+
expect(failure.value._tag).toBe("CompressionUnavailableError")
|
|
29
|
+
expect(failure.value.message).toBe("globalThis.CompressionStream is not available in this environment")
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
})
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
it.effect("CompressionUnavailableError has correct _tag and message", () =>
|
|
36
|
+
Effect.gen(function* () {
|
|
37
|
+
const err = new CompressionUnavailableError()
|
|
38
|
+
expect(err._tag).toBe("CompressionUnavailableError")
|
|
39
|
+
expect(err.name).toBe("CompressionUnavailableError")
|
|
40
|
+
expect(err.message).toBe("globalThis.CompressionStream is not available in this environment")
|
|
41
|
+
})
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
it.effect("custom CompressionService Layer is used when provided", () =>
|
|
45
|
+
Effect.gen(function* () {
|
|
46
|
+
const mockStream = new ReadableStream<Uint8Array>()
|
|
47
|
+
let calledWith: ReadableStream<Uint8Array> | null = null
|
|
48
|
+
|
|
49
|
+
const TestLayer: Layer.Layer<CompressionService> = Layer.succeed(CompressionService, {
|
|
50
|
+
compress: (stream: ReadableStream<Uint8Array>, _algorithm: CompressionFormat): ReadableStream<Uint8Array> => {
|
|
51
|
+
calledWith = stream
|
|
52
|
+
return mockStream
|
|
53
|
+
},
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
const inputStream = new ReadableStream<Uint8Array>()
|
|
57
|
+
const result = yield* Effect.provide(
|
|
58
|
+
Effect.flatMap(CompressionService, (svc) =>
|
|
59
|
+
Effect.sync(() => svc.compress(inputStream, "deflate-raw"))
|
|
60
|
+
),
|
|
61
|
+
TestLayer
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
expect(result).toBe(mockStream)
|
|
65
|
+
expect(calledWith).toBe(inputStream)
|
|
66
|
+
})
|
|
67
|
+
)
|
|
68
|
+
})
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Context, Effect, Layer } from "effect"
|
|
2
|
+
|
|
3
|
+
export class CompressionUnavailableError extends Error {
|
|
4
|
+
readonly _tag = "CompressionUnavailableError" as const
|
|
5
|
+
constructor() {
|
|
6
|
+
super("globalThis.CompressionStream is not available in this environment")
|
|
7
|
+
this.name = "CompressionUnavailableError"
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class CompressionService extends Context.Tag("@tranquilload/CompressionService")<
|
|
12
|
+
CompressionService,
|
|
13
|
+
{ readonly compress: (stream: ReadableStream<Uint8Array>, algorithm: CompressionFormat) => ReadableStream<Uint8Array> }
|
|
14
|
+
>() {}
|
|
15
|
+
|
|
16
|
+
export const CompressionServiceLive: Layer.Layer<CompressionService, CompressionUnavailableError> =
|
|
17
|
+
Layer.effect(
|
|
18
|
+
CompressionService,
|
|
19
|
+
Effect.gen(function* () {
|
|
20
|
+
const cs = (globalThis as { CompressionStream?: unknown }).CompressionStream
|
|
21
|
+
if (typeof cs === "undefined") {
|
|
22
|
+
return yield* Effect.fail(new CompressionUnavailableError())
|
|
23
|
+
}
|
|
24
|
+
return {
|
|
25
|
+
compress: (stream, algorithm) =>
|
|
26
|
+
stream.pipeThrough(
|
|
27
|
+
new CompressionStream(algorithm) as unknown as TransformStream<Uint8Array, Uint8Array>
|
|
28
|
+
),
|
|
29
|
+
}
|
|
30
|
+
})
|
|
31
|
+
)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { describe, expect, it } from "@effect/vitest"
|
|
2
|
+
import { Effect, Layer, Stream } from "effect"
|
|
3
|
+
import { LoggerService, type LogLevel } from "./logger-service.js"
|
|
4
|
+
import { uploadOnce } from "../oneshot/index.js"
|
|
5
|
+
import { uploadMultipart } from "../multipart/index.js"
|
|
6
|
+
import { uploadOnceEffect } from "../oneshot/upload.js"
|
|
7
|
+
import { uploadMultipartEffect } from "../multipart/upload-stream.js"
|
|
8
|
+
|
|
9
|
+
// Helpers
|
|
10
|
+
const tinyStream = (bytes: number): ReadableStream<Uint8Array> =>
|
|
11
|
+
new ReadableStream({
|
|
12
|
+
start(c) {
|
|
13
|
+
c.enqueue(new Uint8Array(bytes).fill(1))
|
|
14
|
+
c.close()
|
|
15
|
+
},
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
type LogEntry = { level: LogLevel; message: string; data?: unknown }
|
|
19
|
+
|
|
20
|
+
const makeTestLayer = (received: LogEntry[]): Layer.Layer<LoggerService> =>
|
|
21
|
+
Layer.succeed(LoggerService, {
|
|
22
|
+
log: (level, message, data?) => {
|
|
23
|
+
received.push({ level, message, data })
|
|
24
|
+
},
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
describe("LoggerService integration", () => {
|
|
28
|
+
it.effect("uploadOnce.effect with custom LoggerService captures internal log entries", () =>
|
|
29
|
+
Effect.gen(function* () {
|
|
30
|
+
const received: LogEntry[] = []
|
|
31
|
+
|
|
32
|
+
yield* Stream.runDrain(
|
|
33
|
+
uploadOnceEffect({
|
|
34
|
+
stream: tinyStream(10),
|
|
35
|
+
upload: () => {},
|
|
36
|
+
}).pipe(Stream.provideLayer(makeTestLayer(received)))
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
// Expect "One-shot upload starting" and "One-shot upload completed"
|
|
40
|
+
expect(received).toHaveLength(2)
|
|
41
|
+
expect(received.some(e => e.message === "One-shot upload starting")).toBe(true)
|
|
42
|
+
expect(received.some(e => e.message === "One-shot upload completed")).toBe(true)
|
|
43
|
+
expect(received.every(e => e.level === "info")).toBe(true)
|
|
44
|
+
})
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
it.effect("uploadMultipart.effect with custom LoggerService captures part completion and final log", () =>
|
|
48
|
+
Effect.gen(function* () {
|
|
49
|
+
const received: LogEntry[] = []
|
|
50
|
+
|
|
51
|
+
yield* Stream.runDrain(
|
|
52
|
+
uploadMultipartEffect({
|
|
53
|
+
stream: tinyStream(20),
|
|
54
|
+
chunkSize: 10,
|
|
55
|
+
uploadPart: (_partNumber, _chunk) => "etag",
|
|
56
|
+
completeUpload: () => {},
|
|
57
|
+
}).pipe(Stream.provideLayer(makeTestLayer(received)))
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
// 2 parts → 2 "Part N completed" + 1 "Multipart upload completed"
|
|
61
|
+
const partLogs = received.filter(e => e.message.startsWith("Part ") && e.message.endsWith("completed"))
|
|
62
|
+
expect(partLogs.length).toBe(2)
|
|
63
|
+
expect(received.some(e => e.message === "Multipart upload completed")).toBe(true)
|
|
64
|
+
})
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
it.effect("Promise API (uploadOnce) auto-provides LoggerServiceLive — user log fn is never called", () =>
|
|
68
|
+
Effect.gen(function* () {
|
|
69
|
+
const received: LogEntry[] = []
|
|
70
|
+
|
|
71
|
+
// uploadOnce uses LoggerServiceLive (no-op) — custom logger receives nothing
|
|
72
|
+
const { result } = uploadOnce({
|
|
73
|
+
stream: tinyStream(10),
|
|
74
|
+
upload: () => {},
|
|
75
|
+
})
|
|
76
|
+
yield* Effect.promise(() => result)
|
|
77
|
+
|
|
78
|
+
// The custom logger was never invoked — Promise API is fully wired
|
|
79
|
+
expect(received).toHaveLength(0)
|
|
80
|
+
})
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
it.effect("Promise API (uploadMultipart) auto-provides LoggerServiceLive — user log fn is never called", () =>
|
|
84
|
+
Effect.gen(function* () {
|
|
85
|
+
const received: LogEntry[] = []
|
|
86
|
+
|
|
87
|
+
const { result } = uploadMultipart({
|
|
88
|
+
stream: tinyStream(20),
|
|
89
|
+
chunkSize: 10,
|
|
90
|
+
uploadPart: () => "etag",
|
|
91
|
+
completeUpload: () => {},
|
|
92
|
+
})
|
|
93
|
+
yield* Effect.promise(() => result)
|
|
94
|
+
|
|
95
|
+
expect(received).toHaveLength(0)
|
|
96
|
+
})
|
|
97
|
+
)
|
|
98
|
+
})
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { it, describe, expect } from "@effect/vitest"
|
|
2
|
+
import { Effect, Layer } from "effect"
|
|
3
|
+
import { LoggerService, LoggerServiceLive, type LogLevel } from "./logger-service.js"
|
|
4
|
+
|
|
5
|
+
describe("LoggerService", () => {
|
|
6
|
+
it.effect("LoggerServiceLive is a no-op (produces zero output)", () =>
|
|
7
|
+
Effect.gen(function* () {
|
|
8
|
+
const logger = yield* LoggerService
|
|
9
|
+
expect(() => logger.log("info", "test message", { key: "value" })).not.toThrow()
|
|
10
|
+
expect(() => logger.log("debug", "debug msg")).not.toThrow()
|
|
11
|
+
expect(() => logger.log("warn", "warn msg")).not.toThrow()
|
|
12
|
+
expect(() => logger.log("error", "error msg")).not.toThrow()
|
|
13
|
+
}).pipe(Effect.provide(LoggerServiceLive))
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
it.effect("custom LoggerService Layer receives structured log entries", () =>
|
|
17
|
+
Effect.gen(function* () {
|
|
18
|
+
const received: Array<{ level: LogLevel; message: string; data?: unknown }> = []
|
|
19
|
+
|
|
20
|
+
const TestLayer: Layer.Layer<LoggerService> = Layer.succeed(LoggerService, {
|
|
21
|
+
log: (level: LogLevel, message: string, data?: unknown): void => {
|
|
22
|
+
received.push({ level, message, data })
|
|
23
|
+
},
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
yield* Effect.provide(
|
|
27
|
+
Effect.gen(function* () {
|
|
28
|
+
const logger = yield* LoggerService
|
|
29
|
+
logger.log("info", "part completed", { partNumber: 1 })
|
|
30
|
+
logger.log("warn", "retry attempt", { attempt: 2 })
|
|
31
|
+
}),
|
|
32
|
+
TestLayer
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
expect(received).toHaveLength(2)
|
|
36
|
+
expect(received[0]).toEqual({ level: "info", message: "part completed", data: { partNumber: 1 } })
|
|
37
|
+
expect(received[1]).toEqual({ level: "warn", message: "retry attempt", data: { attempt: 2 } })
|
|
38
|
+
})
|
|
39
|
+
)
|
|
40
|
+
})
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Context, Layer } from "effect"
|
|
2
|
+
|
|
3
|
+
export type LogLevel = "debug" | "info" | "warn" | "error"
|
|
4
|
+
|
|
5
|
+
export class LoggerService extends Context.Tag("@tranquilload/LoggerService")<
|
|
6
|
+
LoggerService,
|
|
7
|
+
{ readonly log: (level: LogLevel, message: string, data?: unknown) => void }
|
|
8
|
+
>() {}
|
|
9
|
+
|
|
10
|
+
export const LoggerServiceLive: Layer.Layer<LoggerService> = Layer.succeed(
|
|
11
|
+
LoggerService,
|
|
12
|
+
{
|
|
13
|
+
log: (_level: LogLevel, _message: string, _data?: unknown): void => {
|
|
14
|
+
// intentional no-op
|
|
15
|
+
},
|
|
16
|
+
}
|
|
17
|
+
)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { Effect, Exit, Fiber } from "effect"
|
|
2
|
+
import { it, describe, expect } from "@effect/vitest"
|
|
3
|
+
import { fromAbortSignal } from "./abort-interop.js"
|
|
4
|
+
import { AbortError } from "../errors/upload-error.js"
|
|
5
|
+
|
|
6
|
+
describe("fromAbortSignal", () => {
|
|
7
|
+
it.effect("no signal: never settles on its own (race wins with other branch)", () =>
|
|
8
|
+
Effect.gen(function* () {
|
|
9
|
+
// fromAbortSignal with no signal never resolves; race with a succeeding Effect
|
|
10
|
+
const result = yield* Effect.race(Effect.succeed("winner"), fromAbortSignal())
|
|
11
|
+
expect(result).toBe("winner")
|
|
12
|
+
})
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
it.effect("signal already aborted: fails immediately with AbortError", () =>
|
|
16
|
+
Effect.gen(function* () {
|
|
17
|
+
const controller = new AbortController()
|
|
18
|
+
controller.abort()
|
|
19
|
+
const exit = yield* Effect.exit(fromAbortSignal(controller.signal))
|
|
20
|
+
expect(Exit.isFailure(exit)).toBe(true)
|
|
21
|
+
if (Exit.isFailure(exit)) {
|
|
22
|
+
const cause = exit.cause
|
|
23
|
+
// The error should be an AbortError
|
|
24
|
+
expect(cause._tag).toBe("Fail")
|
|
25
|
+
if (cause._tag === "Fail") {
|
|
26
|
+
expect(cause.error).toBeInstanceOf(AbortError)
|
|
27
|
+
expect((cause.error as AbortError)._tag).toBe("AbortError")
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
})
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
it.effect("controller.abort() after fromAbortSignal: fails with AbortError", () =>
|
|
34
|
+
Effect.gen(function* () {
|
|
35
|
+
const controller = new AbortController()
|
|
36
|
+
const fiber = yield* Effect.fork(fromAbortSignal(controller.signal))
|
|
37
|
+
yield* Effect.sync(() => controller.abort())
|
|
38
|
+
const exit = yield* Fiber.await(fiber)
|
|
39
|
+
expect(Exit.isFailure(exit)).toBe(true)
|
|
40
|
+
if (Exit.isFailure(exit)) {
|
|
41
|
+
const cause = exit.cause
|
|
42
|
+
expect(cause._tag).toBe("Fail")
|
|
43
|
+
if (cause._tag === "Fail") {
|
|
44
|
+
expect(cause.error).toBeInstanceOf(AbortError)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
})
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
it.effect("AbortError shape: _tag, instanceof Error, message", () =>
|
|
51
|
+
Effect.gen(function* () {
|
|
52
|
+
const controller = new AbortController()
|
|
53
|
+
controller.abort()
|
|
54
|
+
const exit = yield* Effect.exit(fromAbortSignal(controller.signal))
|
|
55
|
+
if (Exit.isFailure(exit) && exit.cause._tag === "Fail") {
|
|
56
|
+
const error = exit.cause.error as AbortError
|
|
57
|
+
expect(error._tag).toBe("AbortError")
|
|
58
|
+
expect(error).toBeInstanceOf(Error)
|
|
59
|
+
expect(error.message).toBe("Upload aborted")
|
|
60
|
+
} else {
|
|
61
|
+
expect.fail("Expected a Fail exit")
|
|
62
|
+
}
|
|
63
|
+
})
|
|
64
|
+
)
|
|
65
|
+
})
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { Effect } from "effect"
|
|
2
|
+
import { AbortError } from "../errors/upload-error.js"
|
|
3
|
+
|
|
4
|
+
export const fromAbortSignal = (signal?: AbortSignal): Effect.Effect<never, AbortError> =>
|
|
5
|
+
Effect.async<never, AbortError>((resume) => {
|
|
6
|
+
if (!signal) return
|
|
7
|
+
if (signal.aborted) {
|
|
8
|
+
resume(Effect.fail(new AbortError()))
|
|
9
|
+
return
|
|
10
|
+
}
|
|
11
|
+
const handler = (): void => resume(Effect.fail(new AbortError()))
|
|
12
|
+
signal.addEventListener("abort", handler, { once: true })
|
|
13
|
+
return Effect.sync(() => signal.removeEventListener("abort", handler))
|
|
14
|
+
})
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { Effect, Exit } from "effect"
|
|
2
|
+
import { it, describe, expect } from "@effect/vitest"
|
|
3
|
+
import { normalizeCallback } from "./normalize-callback.js"
|
|
4
|
+
|
|
5
|
+
describe("normalizeCallback", () => {
|
|
6
|
+
it.effect("plain value: succeeds with the value", () =>
|
|
7
|
+
Effect.gen(function* () {
|
|
8
|
+
const result = yield* normalizeCallback(() => 42)
|
|
9
|
+
expect(result).toBe(42)
|
|
10
|
+
})
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
it.effect("Promise: succeeds with resolved value", () =>
|
|
14
|
+
Effect.gen(function* () {
|
|
15
|
+
const result = yield* normalizeCallback(() => Promise.resolve("hello"))
|
|
16
|
+
expect(result).toBe("hello")
|
|
17
|
+
})
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
it.effect("Effect: passes through unchanged", () =>
|
|
21
|
+
Effect.gen(function* () {
|
|
22
|
+
const result = yield* normalizeCallback(() => Effect.succeed(true))
|
|
23
|
+
expect(result).toBe(true)
|
|
24
|
+
})
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
it.effect("throwing function: fails in error channel (not unhandled)", () =>
|
|
28
|
+
Effect.gen(function* () {
|
|
29
|
+
const exit = yield* Effect.exit(
|
|
30
|
+
normalizeCallback(() => {
|
|
31
|
+
throw new Error("boom")
|
|
32
|
+
})
|
|
33
|
+
)
|
|
34
|
+
expect(Exit.isFailure(exit)).toBe(true)
|
|
35
|
+
})
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
it.effect("Promise rejection: fails in error channel", () =>
|
|
39
|
+
Effect.gen(function* () {
|
|
40
|
+
const exit = yield* Effect.exit(
|
|
41
|
+
normalizeCallback(() => Promise.reject(new Error("async fail")))
|
|
42
|
+
)
|
|
43
|
+
expect(Exit.isFailure(exit)).toBe(true)
|
|
44
|
+
})
|
|
45
|
+
)
|
|
46
|
+
})
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Effect } from "effect"
|
|
2
|
+
|
|
3
|
+
export const normalizeCallback = <A, E = never>(
|
|
4
|
+
fn: () => A | Promise<A> | Effect.Effect<A, E>
|
|
5
|
+
): Effect.Effect<A, E | unknown> =>
|
|
6
|
+
Effect.suspend((): Effect.Effect<A, E | unknown> => {
|
|
7
|
+
let result: A | Promise<A> | Effect.Effect<A, E>
|
|
8
|
+
try {
|
|
9
|
+
result = fn()
|
|
10
|
+
} catch (e) {
|
|
11
|
+
return Effect.fail(e)
|
|
12
|
+
}
|
|
13
|
+
if (Effect.isEffect(result)) return result
|
|
14
|
+
if (result instanceof Promise) {
|
|
15
|
+
return Effect.tryPromise({ try: () => result as Promise<A>, catch: (e) => e })
|
|
16
|
+
}
|
|
17
|
+
return Effect.succeed(result)
|
|
18
|
+
})
|