sonamu 0.13.1 → 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.
Files changed (50) hide show
  1. package/dist/api/decorators.d.ts +2 -3
  2. package/dist/api/decorators.d.ts.map +1 -1
  3. package/dist/api/decorators.js +1 -1
  4. package/dist/api/sonamu.d.ts.map +1 -1
  5. package/dist/api/sonamu.js +5 -5
  6. package/dist/auth/plugins/entity-definitions/two-factor.d.ts.map +1 -1
  7. package/dist/auth/plugins/entity-definitions/two-factor.js +19 -1
  8. package/dist/auth/plugins/wrappers/two-factor.d.ts.map +1 -1
  9. package/dist/auth/plugins/wrappers/two-factor.js +4 -2
  10. package/dist/database/base-model.d.ts.map +1 -1
  11. package/dist/database/base-model.js +8 -3
  12. package/dist/database/puri.d.ts +8 -3
  13. package/dist/database/puri.d.ts.map +1 -1
  14. package/dist/database/puri.js +120 -15
  15. package/dist/database/puri.types.d.ts +5 -2
  16. package/dist/database/puri.types.d.ts.map +1 -1
  17. package/dist/database/puri.types.js +1 -1
  18. package/dist/storage/buffered-file.d.ts +3 -3
  19. package/dist/storage/buffered-file.d.ts.map +1 -1
  20. package/dist/storage/buffered-file.js +2 -2
  21. package/dist/storage/index.d.ts +1 -1
  22. package/dist/storage/index.d.ts.map +1 -1
  23. package/dist/storage/index.js +1 -1
  24. package/dist/storage/storage-manager.d.ts +3 -4
  25. package/dist/storage/storage-manager.d.ts.map +1 -1
  26. package/dist/storage/storage-manager.js +2 -2
  27. package/dist/storage/types.d.ts +3 -2
  28. package/dist/storage/types.d.ts.map +1 -1
  29. package/dist/storage/uploaded-file.d.ts +3 -3
  30. package/dist/storage/uploaded-file.d.ts.map +1 -1
  31. package/dist/storage/uploaded-file.js +1 -1
  32. package/package.json +1 -1
  33. package/src/api/__tests__/sonamu.storage.test.ts +145 -0
  34. package/src/api/decorators.ts +2 -3
  35. package/src/api/sonamu.ts +6 -6
  36. package/src/auth/__tests__/plugin-schema-mapping.test.ts +49 -0
  37. package/src/auth/plugins/entity-definitions/two-factor.ts +8 -0
  38. package/src/auth/plugins/wrappers/two-factor.ts +2 -0
  39. package/src/database/__tests__/puri-hydration.test.ts +226 -0
  40. package/src/database/base-model.ts +7 -3
  41. package/src/database/puri.ts +204 -15
  42. package/src/database/puri.types.test-d.ts +29 -0
  43. package/src/database/puri.types.ts +10 -2
  44. package/src/storage/__tests__/storage-manager.test.ts +64 -0
  45. package/src/storage/buffered-file.ts +3 -3
  46. package/src/storage/index.ts +1 -1
  47. package/src/storage/storage-manager.ts +4 -5
  48. package/src/storage/storage.test-d.ts +47 -0
  49. package/src/storage/types.ts +3 -2
  50. package/src/storage/uploaded-file.ts +4 -4
@@ -922,3 +922,32 @@ describe("Puri ensureJoin methods", () => {
922
922
  });
923
923
  });
924
924
  });
925
+
926
+ describe("Puri 중첩 결과 실행 타입", () => {
927
+ it("then, catch, finally가 동일한 중첩 레코드 배열을 반환한다", () => {
928
+ type Query = Puri<MockSchema, { users: MockSchema["users"] }, MockSchema["users"]>;
929
+ // SAFETY: 실행 없이 공개 쿼리 메서드의 반환 타입만 검사합니다.
930
+ const query = {} as Query;
931
+ const selected = query.select({ user: { id: "users.id", name: "users.name" } });
932
+ type Rows = { user: { id: number; name: string } }[];
933
+ expectTypeOf(selected.then()).resolves.toEqualTypeOf<Rows>();
934
+ expectTypeOf(selected.catch()).resolves.toEqualTypeOf<Rows>();
935
+ expectTypeOf(selected.finally()).resolves.toEqualTypeOf<Rows>();
936
+ expectTypeOf(selected.first().finally()).resolves.toEqualTypeOf<Rows[number]>();
937
+ });
938
+ });
939
+
940
+ describe("이름이 다른 관계 그룹의 null 추론", () => {
941
+ it("명시한 id의 nullable LEFT JOIN 원본을 그룹 nullability에 반영한다", () => {
942
+ type Tables = {
943
+ users: MockSchema["users"];
944
+ staffs: MockSchema["employees"] & LeftJoinedMarker;
945
+ };
946
+ type Selected = ParseSelectObject<Tables, { manager: { id: "staffs.id"; name: "users.name" } }>;
947
+ expectTypeOf<Selected>().toEqualTypeOf<{ manager: { id: number; name: string } | null }>();
948
+ type Nested = ParseSelectObject<Tables, { location: { manager: { id: "staffs.id" } } }>;
949
+ expectTypeOf<Nested>().toEqualTypeOf<{ location: { manager: { id: number } | null } }>();
950
+ type Plain = ParseSelectObject<Tables, { profile: { id: "users.department_id" } }>;
951
+ expectTypeOf<Plain>().toEqualTypeOf<{ profile: { id: number | null } }>();
952
+ });
953
+ });
@@ -208,6 +208,14 @@ export type ParseSelectObject<
208
208
  TSelect extends SelectObject<TTables>,
209
209
  > = ParseSelectObjectWithPath<TTables, TSelect, "">;
210
210
 
211
+ // 별칭이 다른 관계 그룹도 명시한 id의 nullable JOIN 원본을 따릅니다.
212
+ type IsNullableSelectGroup<TTables, TSelect, Path extends string> =
213
+ IsNullableJoinedTable<TTables, Path> extends true
214
+ ? true
215
+ : TSelect extends { id: `${infer Alias}.${string}` }
216
+ ? IsNullableJoinedTable<TTables, Alias>
217
+ : false;
218
+
211
219
  // 경로를 추적하면서 Select 결과 타입을 추론합니다.
212
220
  type ParseSelectObjectWithPath<
213
221
  TTables extends object,
@@ -218,7 +226,7 @@ type ParseSelectObjectWithPath<
218
226
  ? ParseSqlExpression<TSelect[K]>
219
227
  : IsNestedObject<TSelect[K]> extends true
220
228
  ? TSelect[K] extends NestedSelectObject<TTables>
221
- ? IsNullableJoinedTable<TTables, JoinPath<Prefix, K & string>> extends true // 주어진 테이블이 FK nullable에 leftJoin되었는지 여부에 따라 select 결과 객체의 타입이 달라집니다.
229
+ ? IsNullableSelectGroup<TTables, TSelect[K], JoinPath<Prefix, K & string>> extends true // 주어진 테이블이 FK nullable에 leftJoin되었는지 여부에 따라 select 결과 객체의 타입이 달라집니다.
222
230
  ? Expand<ParseSelectObjectInner<TTables, TSelect[K], JoinPath<Prefix, K & string>>> | null // 만약 해당한다면 해당 객체 자체는 nullable 하며,
223
231
  : Expand<ParseSelectObjectInner<TTables, TSelect[K], JoinPath<Prefix, K & string>>> // 그렇지 않다면 non-nullable 합니다.
224
232
  : never
@@ -237,7 +245,7 @@ type ParseSelectObjectInner<
237
245
  ? ParseSqlExpression<TSelect[K]>
238
246
  : IsNestedObject<TSelect[K]> extends true
239
247
  ? TSelect[K] extends NestedSelectObject<TTables>
240
- ? IsNullableJoinedTable<TTables, JoinPath<Prefix, K & string>> extends true
248
+ ? IsNullableSelectGroup<TTables, TSelect[K], JoinPath<Prefix, K & string>> extends true
241
249
  ? Expand<ParseSelectObjectInner<TTables, TSelect[K], JoinPath<Prefix, K & string>>> | null
242
250
  : Expand<ParseSelectObjectInner<TTables, TSelect[K], JoinPath<Prefix, K & string>>>
243
251
  : never
@@ -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 DriverKey } from "./drivers";
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 디스크 이름 ("fs" 또는 "s3")
38
+ * @param diskName 설정에 등록한 디스크 이름
39
39
  * @param key 저장 경로 (예: 'uploads/avatar.png')
40
40
  * @returns 저장된 파일의 URL
41
41
  */
42
- async saveToDisk(diskName: DriverKey, key: string): Promise<string> {
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);
@@ -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 DriverKey } from "./drivers";
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<DriverKey, Disk> = new 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 디스크 이름 (없으면 default)
16
+ * @param diskName 설정에 등록한 디스크 이름
18
17
  */
19
- use(diskName: DriverKey): Disk {
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
+ });
@@ -1,6 +1,7 @@
1
1
  import { type DriverContract } from "flydrive/types";
2
2
 
3
- import { type DriverKey } from "./drivers";
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<DriverKey, () => DriverContract>;
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 DriverKey } from "./drivers";
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: DriverKey;
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: DriverKey;
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(): DriverKey {
38
+ get diskName(): DiskName {
39
39
  return this.diskNameValue;
40
40
  }
41
41