sonamu 0.13.2 → 0.13.3
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/dist/api/decorators.d.ts +2 -3
- package/dist/api/decorators.d.ts.map +1 -1
- package/dist/api/decorators.js +1 -1
- package/dist/api/sonamu.d.ts.map +1 -1
- package/dist/api/sonamu.js +5 -5
- package/dist/database/base-model.d.ts.map +1 -1
- package/dist/database/base-model.js +8 -3
- package/dist/database/puri.d.ts +8 -3
- package/dist/database/puri.d.ts.map +1 -1
- package/dist/database/puri.js +120 -15
- package/dist/database/puri.types.d.ts +5 -2
- package/dist/database/puri.types.d.ts.map +1 -1
- package/dist/database/puri.types.js +1 -1
- package/dist/storage/buffered-file.d.ts +3 -3
- package/dist/storage/buffered-file.d.ts.map +1 -1
- package/dist/storage/buffered-file.js +2 -2
- package/dist/storage/index.d.ts +1 -1
- package/dist/storage/index.d.ts.map +1 -1
- package/dist/storage/index.js +1 -1
- package/dist/storage/storage-manager.d.ts +3 -4
- package/dist/storage/storage-manager.d.ts.map +1 -1
- package/dist/storage/storage-manager.js +2 -2
- package/dist/storage/types.d.ts +3 -2
- package/dist/storage/types.d.ts.map +1 -1
- package/dist/storage/uploaded-file.d.ts +3 -3
- package/dist/storage/uploaded-file.d.ts.map +1 -1
- package/dist/storage/uploaded-file.js +1 -1
- package/package.json +1 -1
- package/src/api/__tests__/sonamu.storage.test.ts +145 -0
- package/src/api/decorators.ts +2 -3
- package/src/api/sonamu.ts +6 -6
- package/src/database/__tests__/puri-hydration.test.ts +226 -0
- package/src/database/base-model.ts +7 -3
- package/src/database/puri.ts +204 -15
- package/src/database/puri.types.test-d.ts +29 -0
- package/src/database/puri.types.ts +10 -2
- package/src/storage/__tests__/storage-manager.test.ts +64 -0
- package/src/storage/buffered-file.ts +3 -3
- package/src/storage/index.ts +1 -1
- package/src/storage/storage-manager.ts +4 -5
- package/src/storage/storage.test-d.ts +47 -0
- package/src/storage/types.ts +3 -2
- package/src/storage/uploaded-file.ts +4 -4
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import { type MultipartFile } from "@fastify/multipart";
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
7
|
+
|
|
8
|
+
import { Sonamu } from "../../api/sonamu";
|
|
9
|
+
import { BufferedFile, drivers, StorageManager, StreamedFile } from "../index";
|
|
10
|
+
|
|
11
|
+
describe("이름 붙인 디스크", () => {
|
|
12
|
+
const originalStorage = Sonamu["storageValue"];
|
|
13
|
+
let directory: string;
|
|
14
|
+
|
|
15
|
+
beforeEach(async () => {
|
|
16
|
+
directory = await mkdtemp(path.join(tmpdir(), "sonamu-storage-"));
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
afterEach(async () => {
|
|
20
|
+
Reflect.set(Sonamu, "storageValue", originalStorage);
|
|
21
|
+
await rm(directory, { recursive: true, force: true });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("버퍼 업로드와 스트림 파일 다운로드가 같은 이름의 디스크를 사용한다", async () => {
|
|
25
|
+
const factory = vi.fn(
|
|
26
|
+
drivers.fs({
|
|
27
|
+
location: directory,
|
|
28
|
+
visibility: "public",
|
|
29
|
+
urlBuilder: {
|
|
30
|
+
async generateURL(key) {
|
|
31
|
+
return `https://cdn.example.com/${key}`;
|
|
32
|
+
},
|
|
33
|
+
async generateSignedURL(key) {
|
|
34
|
+
return `https://cdn.example.com/${key}?signed`;
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
}),
|
|
38
|
+
);
|
|
39
|
+
const manager = new StorageManager({ drivers: { images: factory } });
|
|
40
|
+
Reflect.set(Sonamu, "storageValue", manager);
|
|
41
|
+
expect(factory).not.toHaveBeenCalled();
|
|
42
|
+
|
|
43
|
+
// SAFETY: BufferedFile 생성자가 읽는 필드는 filename과 mimetype뿐이며 raw는 이 테스트에서 사용하지 않는다.
|
|
44
|
+
const multipart = { filename: "hello.txt", mimetype: "text/plain" } as MultipartFile;
|
|
45
|
+
const buffer = new BufferedFile(multipart, Buffer.from("hello"));
|
|
46
|
+
expect(await buffer.saveToDisk("images", "hello.txt")).toBe(
|
|
47
|
+
"https://cdn.example.com/hello.txt",
|
|
48
|
+
);
|
|
49
|
+
expect(await readFile(path.join(directory, "hello.txt"), "utf8")).toBe("hello");
|
|
50
|
+
const streamed = new StreamedFile({
|
|
51
|
+
filename: "hello.txt",
|
|
52
|
+
mimetype: "text/plain",
|
|
53
|
+
size: 5,
|
|
54
|
+
key: "hello.txt",
|
|
55
|
+
diskName: "images",
|
|
56
|
+
url: "https://cdn.example.com/hello.txt",
|
|
57
|
+
signedUrl: "https://cdn.example.com/hello.txt?signed",
|
|
58
|
+
});
|
|
59
|
+
expect(streamed.diskName).toBe("images");
|
|
60
|
+
expect(await streamed.download()).toEqual(Buffer.from("hello"));
|
|
61
|
+
expect(factory).toHaveBeenCalledTimes(1);
|
|
62
|
+
expect(() => manager.use("missing")).toThrow('Unknown disk: "missing". Available: images');
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -3,7 +3,7 @@ import { createHash } from "crypto";
|
|
|
3
3
|
import { type MultipartFile } from "@fastify/multipart";
|
|
4
4
|
|
|
5
5
|
import { BaseFile } from "./base-file";
|
|
6
|
-
import { type
|
|
6
|
+
import { type DiskName } from "./types";
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* Buffer 모드로 업로드된 파일
|
|
@@ -35,11 +35,11 @@ export class BufferedFile extends BaseFile {
|
|
|
35
35
|
|
|
36
36
|
/**
|
|
37
37
|
* 파일을 디스크에 저장
|
|
38
|
-
* @param diskName 디스크 이름
|
|
38
|
+
* @param diskName 설정에 등록한 디스크 이름
|
|
39
39
|
* @param key 저장 경로 (예: 'uploads/avatar.png')
|
|
40
40
|
* @returns 저장된 파일의 URL
|
|
41
41
|
*/
|
|
42
|
-
async saveToDisk(diskName:
|
|
42
|
+
async saveToDisk(diskName: DiskName, key: string): Promise<string> {
|
|
43
43
|
// 순환 의존성 방지를 위해 동적 import
|
|
44
44
|
const { Sonamu } = await import("../api/sonamu");
|
|
45
45
|
const disk = Sonamu.storage.use(diskName);
|
package/src/storage/index.ts
CHANGED
|
@@ -3,5 +3,5 @@ export { BaseFile } from "./base-file";
|
|
|
3
3
|
export { BufferedFile } from "./buffered-file";
|
|
4
4
|
export { type DriverKey, drivers } from "./drivers";
|
|
5
5
|
export { StorageManager } from "./storage-manager";
|
|
6
|
-
export type { KeyGenerator, StorageConfig } from "./types";
|
|
6
|
+
export type { DiskName, KeyGenerator, StorageConfig } from "./types";
|
|
7
7
|
export { UploadedFile as StreamedFile } from "./uploaded-file";
|
|
@@ -1,22 +1,21 @@
|
|
|
1
1
|
import { Disk } from "flydrive";
|
|
2
2
|
|
|
3
3
|
import { assertDefined } from "../utils/utils";
|
|
4
|
-
import { type
|
|
5
|
-
import { type StorageConfig } from "./types";
|
|
4
|
+
import { type DiskName, type StorageConfig } from "./types";
|
|
6
5
|
|
|
7
6
|
/**
|
|
8
7
|
* 여러 디스크를 관리하는 매니저
|
|
9
8
|
*/
|
|
10
9
|
export class StorageManager {
|
|
11
|
-
private disks: Map<
|
|
10
|
+
private disks: Map<DiskName, Disk> = new Map();
|
|
12
11
|
|
|
13
12
|
constructor(private config: StorageConfig) {}
|
|
14
13
|
|
|
15
14
|
/**
|
|
16
15
|
* 디스크 인스턴스 반환 (lazy initialization)
|
|
17
|
-
* @param diskName 디스크 이름
|
|
16
|
+
* @param diskName 설정에 등록한 디스크 이름
|
|
18
17
|
*/
|
|
19
|
-
use(diskName:
|
|
18
|
+
use(diskName: DiskName): Disk {
|
|
20
19
|
if (!this.disks.has(diskName)) {
|
|
21
20
|
const factory = this.config.drivers[diskName];
|
|
22
21
|
if (!factory) {
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { describe, expectTypeOf, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { type SonamuConfig } from "../api/config";
|
|
4
|
+
import { type UploadDecoratorOptions } from "../api/decorators";
|
|
5
|
+
import { Sonamu } from "../api/sonamu";
|
|
6
|
+
import {
|
|
7
|
+
type BufferedFile,
|
|
8
|
+
type DriverKey,
|
|
9
|
+
drivers,
|
|
10
|
+
StorageManager,
|
|
11
|
+
type StorageConfig,
|
|
12
|
+
StreamedFile,
|
|
13
|
+
} from "./index";
|
|
14
|
+
|
|
15
|
+
describe("스토리지 디스크 이름", () => {
|
|
16
|
+
it("드라이버 종류와 독립적인 디스크 이름을 모든 업로드 경로에서 허용한다", () => {
|
|
17
|
+
const factory = drivers.fs({ location: "/tmp", visibility: "public" });
|
|
18
|
+
const storage = { drivers: { images: factory, private: factory } } satisfies StorageConfig;
|
|
19
|
+
expectTypeOf(storage).toExtend<NonNullable<SonamuConfig["server"]["storage"]>>();
|
|
20
|
+
const manager = new StorageManager(storage);
|
|
21
|
+
expectTypeOf(manager.use).parameter(0).toEqualTypeOf<string>();
|
|
22
|
+
expectTypeOf(Sonamu.storage.use).parameter(0).toEqualTypeOf<string>();
|
|
23
|
+
expectTypeOf<BufferedFile["saveToDisk"]>().parameter(0).toEqualTypeOf<string>();
|
|
24
|
+
expectTypeOf<StreamedFile["diskName"]>().toEqualTypeOf<string>();
|
|
25
|
+
expectTypeOf<DriverKey>().toEqualTypeOf<"fs" | "s3">();
|
|
26
|
+
const options = { consume: "stream", destination: "images" } satisfies UploadDecoratorOptions;
|
|
27
|
+
expectTypeOf(options).toExtend<UploadDecoratorOptions>();
|
|
28
|
+
expectTypeOf(
|
|
29
|
+
new StreamedFile({
|
|
30
|
+
filename: "image.jpg",
|
|
31
|
+
mimetype: "image/jpeg",
|
|
32
|
+
size: 1,
|
|
33
|
+
url: "https://cdn.example.com/image.jpg",
|
|
34
|
+
signedUrl: "",
|
|
35
|
+
key: "image.jpg",
|
|
36
|
+
diskName: "images",
|
|
37
|
+
}),
|
|
38
|
+
).toEqualTypeOf<StreamedFile>();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("fs나 s3라는 이름의 디스크를 반드시 설정할 필요가 없다", () => {
|
|
42
|
+
const factory = drivers.fs({ location: "/tmp", visibility: "public" });
|
|
43
|
+
expectTypeOf({ drivers: { images: factory } }).toExtend<StorageConfig>();
|
|
44
|
+
expectTypeOf({ drivers: { fs: factory } }).toExtend<StorageConfig>();
|
|
45
|
+
expectTypeOf({ drivers: { s3: factory } }).toExtend<StorageConfig>();
|
|
46
|
+
});
|
|
47
|
+
});
|
package/src/storage/types.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type DriverContract } from "flydrive/types";
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
/** storage.drivers에 등록한 디스크 이름. 드라이버 종류(fs·s3)와는 독립적이다. */
|
|
4
|
+
export type DiskName = string;
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Stream 모드에서 파일 키 생성 함수 타입
|
|
@@ -15,7 +16,7 @@ export type KeyGenerator = (file: {
|
|
|
15
16
|
*/
|
|
16
17
|
export type StorageConfig = {
|
|
17
18
|
/** 디스크별 드라이버 팩토리 */
|
|
18
|
-
drivers: Record<
|
|
19
|
+
drivers: Record<DiskName, () => DriverContract>;
|
|
19
20
|
/** stream 모드의 전역 키 생성 함수 (생략 시 timestamp-random 기본값) */
|
|
20
21
|
keyGenerator?: KeyGenerator;
|
|
21
22
|
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { BaseFile } from "./base-file";
|
|
2
|
-
import { type
|
|
2
|
+
import { type DiskName } from "./types";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Stream 모드로 업로드된 파일
|
|
@@ -7,7 +7,7 @@ import { type DriverKey } from "./drivers";
|
|
|
7
7
|
*/
|
|
8
8
|
export class UploadedFile extends BaseFile {
|
|
9
9
|
private keyValue: string;
|
|
10
|
-
private diskNameValue:
|
|
10
|
+
private diskNameValue: DiskName;
|
|
11
11
|
|
|
12
12
|
constructor(params: {
|
|
13
13
|
filename: string;
|
|
@@ -16,7 +16,7 @@ export class UploadedFile extends BaseFile {
|
|
|
16
16
|
url: string;
|
|
17
17
|
signedUrl: string;
|
|
18
18
|
key: string;
|
|
19
|
-
diskName:
|
|
19
|
+
diskName: DiskName;
|
|
20
20
|
}) {
|
|
21
21
|
super({
|
|
22
22
|
filename: params.filename,
|
|
@@ -35,7 +35,7 @@ export class UploadedFile extends BaseFile {
|
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
/** 저장된 디스크 이름 */
|
|
38
|
-
get diskName():
|
|
38
|
+
get diskName(): DiskName {
|
|
39
39
|
return this.diskNameValue;
|
|
40
40
|
}
|
|
41
41
|
|