sonamu 0.8.24 → 0.8.26

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 (88) hide show
  1. package/dist/api/__tests__/config.test.js +189 -0
  2. package/dist/api/config.d.ts.map +1 -1
  3. package/dist/api/config.js +7 -2
  4. package/dist/api/sonamu.d.ts.map +1 -1
  5. package/dist/api/sonamu.js +14 -10
  6. package/dist/auth/index.d.ts +1 -0
  7. package/dist/auth/index.d.ts.map +1 -1
  8. package/dist/auth/index.js +2 -1
  9. package/dist/auth/knex-adapter.d.ts +23 -0
  10. package/dist/auth/knex-adapter.d.ts.map +1 -0
  11. package/dist/auth/knex-adapter.js +163 -0
  12. package/dist/auth/plugins/wrappers/admin.d.ts +2 -2
  13. package/dist/bin/__tests__/ts-loader-register.test.js +45 -0
  14. package/dist/bin/cli.js +47 -9
  15. package/dist/bin/ts-loader-register.js +3 -29
  16. package/dist/bin/ts-loader-registration.d.ts +2 -0
  17. package/dist/bin/ts-loader-registration.d.ts.map +1 -0
  18. package/dist/bin/ts-loader-registration.js +42 -0
  19. package/dist/cone/cone-generator.js +3 -3
  20. package/dist/database/puri-subset.test-d.js +9 -1
  21. package/dist/database/puri-subset.types.d.ts +1 -1
  22. package/dist/database/puri-subset.types.d.ts.map +1 -1
  23. package/dist/database/puri-subset.types.js +1 -1
  24. package/dist/testing/fixture-generator.js +5 -5
  25. package/dist/ui/ai-client.js +2 -2
  26. package/dist/ui/api.d.ts.map +1 -1
  27. package/dist/ui/api.js +14 -14
  28. package/dist/ui/cdd-service.d.ts +15 -18
  29. package/dist/ui/cdd-service.d.ts.map +1 -1
  30. package/dist/ui/cdd-service.js +246 -222
  31. package/dist/ui/cdd-types.d.ts +41 -68
  32. package/dist/ui/cdd-types.d.ts.map +1 -1
  33. package/dist/ui/cdd-types.js +2 -2
  34. package/dist/ui-web/assets/index-CKo0Z2Iu.css +1 -0
  35. package/dist/ui-web/assets/{index-CxiydzeC.js → index-DK-2aacv.js} +83 -83
  36. package/dist/ui-web/index.html +2 -2
  37. package/package.json +6 -2
  38. package/src/api/__tests__/config.test.ts +225 -0
  39. package/src/api/config.ts +10 -4
  40. package/src/api/sonamu.ts +16 -13
  41. package/src/auth/index.ts +1 -0
  42. package/src/auth/knex-adapter.ts +208 -0
  43. package/src/bin/__tests__/ts-loader-register.test.ts +62 -0
  44. package/src/bin/cli.ts +52 -9
  45. package/src/bin/ts-loader-register.ts +2 -32
  46. package/src/bin/ts-loader-registration.ts +55 -0
  47. package/src/cone/cone-generator.ts +2 -2
  48. package/src/database/puri-subset.test-d.ts +102 -0
  49. package/src/database/puri-subset.types.ts +1 -1
  50. package/src/skills/commands/sonamu-skills.md +20 -0
  51. package/src/skills/sonamu/SKILL.md +179 -137
  52. package/src/skills/sonamu/ai-agents.md +69 -69
  53. package/src/skills/sonamu/api.md +147 -147
  54. package/src/skills/sonamu/auth-migration.md +220 -220
  55. package/src/skills/sonamu/auth-plugins.md +83 -83
  56. package/src/skills/sonamu/auth.md +106 -106
  57. package/src/skills/sonamu/cdd.md +65 -200
  58. package/src/skills/sonamu/cone.md +138 -138
  59. package/src/skills/sonamu/config.md +191 -191
  60. package/src/skills/sonamu/create-sonamu.md +66 -66
  61. package/src/skills/sonamu/database.md +158 -158
  62. package/src/skills/sonamu/entity-basic.md +292 -293
  63. package/src/skills/sonamu/entity-relations.md +246 -246
  64. package/src/skills/sonamu/entity-validation-checklist.md +124 -124
  65. package/src/skills/sonamu/fixture-cli.md +231 -231
  66. package/src/skills/sonamu/framework-change.md +37 -37
  67. package/src/skills/sonamu/frontend.md +223 -223
  68. package/src/skills/sonamu/i18n.md +82 -82
  69. package/src/skills/sonamu/migration.md +77 -77
  70. package/src/skills/sonamu/model.md +222 -222
  71. package/src/skills/sonamu/naite.md +86 -86
  72. package/src/skills/sonamu/project-init.md +228 -228
  73. package/src/skills/sonamu/puri.md +122 -122
  74. package/src/skills/sonamu/scaffolding.md +154 -154
  75. package/src/skills/sonamu/skill-contribution.md +124 -124
  76. package/src/skills/sonamu/subset.md +46 -46
  77. package/src/skills/sonamu/tasks.md +82 -82
  78. package/src/skills/sonamu/testing-devrunner.md +147 -147
  79. package/src/skills/sonamu/testing.md +673 -673
  80. package/src/skills/sonamu/upsert.md +79 -79
  81. package/src/skills/sonamu/vector.md +67 -67
  82. package/src/testing/fixture-generator.ts +4 -4
  83. package/src/ui/ai-client.ts +1 -1
  84. package/src/ui/api.ts +18 -17
  85. package/src/ui/cdd-service.ts +264 -254
  86. package/src/ui/cdd-types.ts +40 -75
  87. package/dist/ui-web/assets/index-BrQKU3j9.css +0 -1
  88. package/src/skills/sonamu/workflow.md +0 -317
@@ -5,8 +5,8 @@
5
5
  <link rel="icon" type="image/svg+xml" href="/sonamu-ui/setting.svg" />
6
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
7
  <title>{{projectName}}: Sonamu UI</title>
8
- <script type="module" crossorigin src="/sonamu-ui/assets/index-CxiydzeC.js"></script>
9
- <link rel="stylesheet" crossorigin href="/sonamu-ui/assets/index-BrQKU3j9.css">
8
+ <script type="module" crossorigin src="/sonamu-ui/assets/index-DK-2aacv.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/sonamu-ui/assets/index-CKo0Z2Iu.css">
10
10
  </head>
11
11
  <body>
12
12
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sonamu",
3
- "version": "0.8.24",
3
+ "version": "0.8.26",
4
4
  "description": "Sonamu — TypeScript Fullstack API Framework",
5
5
  "keywords": [
6
6
  "typescript",
@@ -55,6 +55,10 @@
55
55
  "import": "./dist/filter/index.js",
56
56
  "types": "./dist/filter/index.d.ts"
57
57
  },
58
+ "./auth/plugins": {
59
+ "import": "./dist/auth/plugins/index.js",
60
+ "types": "./dist/auth/plugins/index.d.ts"
61
+ },
58
62
  "./cdd-types": {
59
63
  "import": "./dist/ui/cdd-types.js",
60
64
  "types": "./dist/ui/cdd-types.d.ts"
@@ -123,8 +127,8 @@
123
127
  "tsicli": "^1.0.5",
124
128
  "vite": "7.3.0",
125
129
  "vitest": "^4.0.10",
126
- "@sonamu-kit/hmr-runner": "^0.1.1",
127
130
  "@sonamu-kit/hmr-hook": "^0.4.1",
131
+ "@sonamu-kit/hmr-runner": "^0.1.1",
128
132
  "@sonamu-kit/ts-loader": "^2.1.3",
129
133
  "@sonamu-kit/tasks": "^0.2.0"
130
134
  },
@@ -0,0 +1,225 @@
1
+ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { pathToFileURL } from "node:url";
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
+
7
+ const tsLoaderRegisterStateKey = Symbol.for("sonamu.ts-loader-register.state");
8
+
9
+ type TsLoaderRegisterState = {
10
+ registered: boolean;
11
+ };
12
+
13
+ type GlobalWithTsLoaderRegisterState = typeof globalThis & {
14
+ [tsLoaderRegisterStateKey]?: TsLoaderRegisterState;
15
+ };
16
+
17
+ function resetRegisterState() {
18
+ const globalState = globalThis as GlobalWithTsLoaderRegisterState;
19
+ delete globalState[tsLoaderRegisterStateKey];
20
+ delete process.env.SWCRC_PATH;
21
+ }
22
+
23
+ async function createTempRoot(): Promise<string> {
24
+ return mkdtemp(path.join(os.tmpdir(), "sonamu-config-test-"));
25
+ }
26
+
27
+ async function writeSourceFixture(rootPath: string): Promise<void> {
28
+ await mkdir(path.join(rootPath, "src"), { recursive: true });
29
+ await writeFile(
30
+ path.join(rootPath, "src", "support.ts"),
31
+ `
32
+ export const fastifyOptions = { keepAliveTimeout: 4321 };
33
+ export function customPlugin() {
34
+ return "plugin";
35
+ }
36
+ export function contextProvider(defaultContext) {
37
+ return defaultContext;
38
+ }
39
+ export function guardHandler() {}
40
+ `,
41
+ );
42
+ await writeFile(
43
+ path.join(rootPath, "src", "sonamu.config.ts"),
44
+ `
45
+ import { customPlugin, contextProvider, fastifyOptions, guardHandler } from "./support";
46
+
47
+ export default {
48
+ api: {
49
+ dir: "./src",
50
+ route: {
51
+ prefix: "/api",
52
+ },
53
+ },
54
+ i18n: {
55
+ defaultLocale: "ko",
56
+ supportedLocales: ["ko"],
57
+ },
58
+ sync: {
59
+ targets: ["web"],
60
+ },
61
+ server: {
62
+ fastify: fastifyOptions,
63
+ plugins: {
64
+ custom: customPlugin,
65
+ },
66
+ apiConfig: {
67
+ contextProvider,
68
+ guardHandler,
69
+ },
70
+ },
71
+ test: {
72
+ parallel: true,
73
+ maxWorkers: 3,
74
+ devRunner: {
75
+ enabled: true,
76
+ routePrefix: "/__test__",
77
+ },
78
+ },
79
+ };
80
+ `,
81
+ );
82
+ }
83
+
84
+ async function writeDistFixture(rootPath: string): Promise<void> {
85
+ await mkdir(path.join(rootPath, "dist"), { recursive: true });
86
+ await writeFile(
87
+ path.join(rootPath, "dist", "sonamu.config.js"),
88
+ `
89
+ export default {
90
+ api: {
91
+ dir: "./dist",
92
+ route: {
93
+ prefix: "/api",
94
+ },
95
+ },
96
+ i18n: {
97
+ defaultLocale: "en",
98
+ supportedLocales: ["en"],
99
+ },
100
+ sync: {
101
+ targets: ["web"],
102
+ },
103
+ server: {
104
+ apiConfig: {
105
+ contextProvider(defaultContext) {
106
+ return defaultContext;
107
+ },
108
+ guardHandler() {},
109
+ },
110
+ },
111
+ test: {
112
+ devRunner: {
113
+ enabled: false,
114
+ },
115
+ },
116
+ };
117
+ `,
118
+ );
119
+ }
120
+
121
+ describe("loadConfig", () => {
122
+ const tempRoots: string[] = [];
123
+ const originalHot = process.env.HOT;
124
+ const originalVitest = process.env.VITEST;
125
+ const originalSwcrcPath = process.env.SWCRC_PATH;
126
+
127
+ beforeEach(() => {
128
+ vi.resetModules();
129
+ vi.restoreAllMocks();
130
+ vi.unmock("../../bin/ts-loader-registration");
131
+ resetRegisterState();
132
+ });
133
+
134
+ afterEach(async () => {
135
+ vi.resetModules();
136
+ vi.restoreAllMocks();
137
+ vi.unmock("../../bin/ts-loader-registration");
138
+ resetRegisterState();
139
+
140
+ if (originalHot === undefined) {
141
+ delete process.env.HOT;
142
+ } else {
143
+ process.env.HOT = originalHot;
144
+ }
145
+
146
+ if (originalVitest === undefined) {
147
+ delete process.env.VITEST;
148
+ } else {
149
+ process.env.VITEST = originalVitest;
150
+ }
151
+
152
+ if (originalSwcrcPath === undefined) {
153
+ delete process.env.SWCRC_PATH;
154
+ } else {
155
+ process.env.SWCRC_PATH = originalSwcrcPath;
156
+ }
157
+
158
+ await Promise.all(
159
+ tempRoots.splice(0).map((rootPath) => rm(rootPath, { recursive: true, force: true })),
160
+ );
161
+ });
162
+
163
+ it("source config 로드 전에 ts-loader 등록을 보장한다", async () => {
164
+ const rootPath = await createTempRoot();
165
+ tempRoots.push(rootPath);
166
+ await writeSourceFixture(rootPath);
167
+
168
+ process.env.VITEST = "true";
169
+ const ensureTsLoaderRegistered = vi.fn(async () => {});
170
+ vi.doMock("../../bin/ts-loader-registration", () => ({
171
+ ensureTsLoaderRegistered,
172
+ }));
173
+
174
+ const { loadConfig } = await import("../config");
175
+ const config = await loadConfig(rootPath);
176
+
177
+ expect(ensureTsLoaderRegistered).toHaveBeenCalledTimes(1);
178
+ expect(ensureTsLoaderRegistered).toHaveBeenCalledWith(rootPath);
179
+ expect(config.test?.devRunner?.enabled).toBe(true);
180
+ });
181
+
182
+ it("dist config 로딩 경로는 기존과 동일하게 유지한다", async () => {
183
+ const rootPath = await createTempRoot();
184
+ tempRoots.push(rootPath);
185
+ await writeDistFixture(rootPath);
186
+
187
+ delete process.env.HOT;
188
+ delete process.env.VITEST;
189
+ const ensureTsLoaderRegistered = vi.fn(async () => {});
190
+ vi.doMock("../../bin/ts-loader-registration", () => ({
191
+ ensureTsLoaderRegistered,
192
+ }));
193
+
194
+ const { loadConfig } = await import("../config");
195
+ const config = await loadConfig(rootPath);
196
+
197
+ expect(ensureTsLoaderRegistered).not.toHaveBeenCalled();
198
+ expect(config.api.dir).toBe("./dist");
199
+ expect(config.test?.devRunner?.enabled).toBe(false);
200
+ });
201
+
202
+ it("source config가 확장자 없는 상대 import와 런타임 객체를 유지하며 반복 로드된다", async () => {
203
+ const rootPath = await createTempRoot();
204
+ tempRoots.push(rootPath);
205
+ await writeSourceFixture(rootPath);
206
+
207
+ process.env.VITEST = "true";
208
+ vi.unmock("../../bin/ts-loader-registration");
209
+ const { loadConfig } = await import("../config");
210
+ const supportModule = await import(
211
+ pathToFileURL(path.join(rootPath, "src", "support.ts")).href
212
+ );
213
+
214
+ const firstConfig = await loadConfig(rootPath);
215
+ const secondConfig = await loadConfig(rootPath);
216
+
217
+ expect(firstConfig.server.fastify).toBe(supportModule.fastifyOptions);
218
+ expect(firstConfig.server.plugins?.custom).toBe(supportModule.customPlugin);
219
+ expect(firstConfig.test?.parallel).toBe(true);
220
+ expect(firstConfig.test?.maxWorkers).toBe(3);
221
+ expect(firstConfig.test?.devRunner?.enabled).toBe(true);
222
+ expect(secondConfig.server.plugins?.custom).toBe(supportModule.customPlugin);
223
+ expect(secondConfig.server.fastify).toBe(supportModule.fastifyOptions);
224
+ });
225
+ });
package/src/api/config.ts CHANGED
@@ -289,10 +289,16 @@ export function defineConfig(config: Executable<SonamuConfig>): Promise<SonamuCo
289
289
  */
290
290
  export async function loadConfig(rootPath: string): Promise<SonamuConfig> {
291
291
  const start = performance.now();
292
- const configPath =
293
- process.env.HOT === "yes" || process.env.VITEST === "true"
294
- ? `${rootPath}/src/sonamu.config.ts`
295
- : `${rootPath}/dist/sonamu.config.js`;
292
+ const shouldLoadSourceConfig = process.env.HOT === "yes" || process.env.VITEST === "true";
293
+ const configPath = shouldLoadSourceConfig
294
+ ? `${rootPath}/src/sonamu.config.ts`
295
+ : `${rootPath}/dist/sonamu.config.js`;
296
+
297
+ if (shouldLoadSourceConfig) {
298
+ const { ensureTsLoaderRegistered } = await import("../bin/ts-loader-registration");
299
+ await ensureTsLoaderRegistered(rootPath);
300
+ }
301
+
296
302
  const { default: config } = await import(`file://${configPath}`);
297
303
  const importTime = performance.now() - start;
298
304
  process.env.NODE_ENV !== "test" &&
package/src/api/sonamu.ts CHANGED
@@ -9,7 +9,6 @@ import type { IncomingMessage, Server, ServerResponse } from "http";
9
9
  import mime, { lookup as mimeLookup } from "mime-types";
10
10
  import os from "os";
11
11
  import path from "path";
12
- import type { PoolConfig } from "pg";
13
12
  import type { ZodObject } from "zod";
14
13
  import {
15
14
  BASE_FIELD_MAPPINGS,
@@ -232,6 +231,22 @@ class SonamuClass {
232
231
  // Cache 초기화
233
232
  await this.initializeCache(this.config.server.cache, forTesting);
234
233
 
234
+ // BetterAuth 초기화
235
+ const authConfig = this.config.server.auth;
236
+ if (authConfig) {
237
+ // 사용자 설정과 기본값을 merge
238
+ const mergedFieldMappings = merge(BASE_FIELD_MAPPINGS, authConfig);
239
+
240
+ // better-auth 인스턴스 생성
241
+ const { betterAuth } = await import("better-auth");
242
+ const { sonamuKnexAdapter } = await import("../auth/knex-adapter");
243
+
244
+ this._auth = betterAuth({
245
+ database: sonamuKnexAdapter(),
246
+ ...mergedFieldMappings,
247
+ });
248
+ }
249
+
235
250
  // 테스팅인 경우 싱크 없이 중단
236
251
  if (forTesting) {
237
252
  this.isInitialized = true;
@@ -1243,18 +1258,6 @@ class SonamuClass {
1243
1258
 
1244
1259
  const basePath = options.basePath ?? "/api/auth";
1245
1260
 
1246
- // 사용자 설정과 기본값을 merge
1247
- const mergedFieldMappings = merge(BASE_FIELD_MAPPINGS, options);
1248
-
1249
- // better-auth 인스턴스 생성
1250
- const { betterAuth } = await import("better-auth");
1251
- const { Pool } = await import("pg");
1252
-
1253
- this._auth = betterAuth({
1254
- database: new Pool(DB.getDBConfig("w").connection as PoolConfig),
1255
- ...mergedFieldMappings,
1256
- });
1257
-
1258
1261
  // better-auth 라우트 등록
1259
1262
  server.route({
1260
1263
  method: ["GET", "POST"],
package/src/auth/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export type { GenerateBetterAuthEntitiesOptions } from "./auth-generator";
2
2
  export { generateBetterAuthEntities } from "./auth-generator";
3
3
  export { BASE_FIELD_MAPPINGS, betterAuthV1 } from "./better-auth-entities";
4
+ export { sonamuKnexAdapter } from "./knex-adapter";
4
5
 
5
6
  // 외부로는 wrappers만 export (admin, twoFactor 등 래퍼 함수와 SCHEMA)
6
7
  export * from "./plugins";
@@ -0,0 +1,208 @@
1
+ import type { BetterAuthOptions } from "better-auth";
2
+ import type {
3
+ AdapterFactoryCustomizeAdapterCreator,
4
+ DBTransactionAdapter,
5
+ } from "better-auth/adapters";
6
+ import { createAdapterFactory } from "better-auth/adapters";
7
+ import type { Knex } from "knex";
8
+ import { DB } from "../database/db";
9
+
10
+ interface CleanedWhere {
11
+ field: string;
12
+ value: string | number | boolean | string[] | number[] | Date | null;
13
+ operator: string;
14
+ connector: string;
15
+ }
16
+
17
+ /**
18
+ * better-auth용 Sonamu knex 어댑터
19
+ *
20
+ * better-auth의 모든 쿼리를 DB.getDB()를 통해 실행하여
21
+ * Sonamu 테스트 트랜잭션과 동일한 커넥션을 공유합니다.
22
+ */
23
+ export const sonamuKnexAdapter = () => {
24
+ let lazyOptions: BetterAuthOptions | null = null;
25
+
26
+ const createCustomAdapter = (
27
+ getDb: () => Knex | Knex.Transaction,
28
+ ): AdapterFactoryCustomizeAdapterCreator => {
29
+ return ({ getFieldName }) => ({
30
+ create: async ({ model, data }) => {
31
+ const [row] = await getDb()(model).insert(data).returning("*");
32
+ return row;
33
+ },
34
+
35
+ findOne: async ({ model, where }) => {
36
+ let query = getDb()(model);
37
+ query = applyWhere(query, where);
38
+ const row = await query.first();
39
+ return row ?? null;
40
+ },
41
+
42
+ findMany: async ({ model, where, limit, offset, sortBy }) => {
43
+ let query = getDb()(model);
44
+ if (where) {
45
+ query = applyWhere(query, where);
46
+ }
47
+ if (sortBy) {
48
+ const dbField = getFieldName({ model, field: sortBy.field });
49
+ query = query.orderBy(dbField, sortBy.direction);
50
+ }
51
+ if (limit) {
52
+ query = query.limit(limit);
53
+ }
54
+ if (offset) {
55
+ query = query.offset(offset);
56
+ }
57
+ return await query;
58
+ },
59
+
60
+ update: async ({ model, where, update }) => {
61
+ let query = getDb()(model);
62
+ query = applyWhere(query, where);
63
+ const [row] = await query.update(update).returning("*");
64
+ return row ?? null;
65
+ },
66
+
67
+ updateMany: async ({ model, where, update }) => {
68
+ let query = getDb()(model);
69
+ query = applyWhere(query, where);
70
+ const count = await query.update(update);
71
+ return count;
72
+ },
73
+
74
+ delete: async ({ model, where }) => {
75
+ let query = getDb()(model);
76
+ query = applyWhere(query, where);
77
+ await query.del();
78
+ },
79
+
80
+ deleteMany: async ({ model, where }) => {
81
+ let query = getDb()(model);
82
+ query = applyWhere(query, where);
83
+ const count = await query.del();
84
+ return count;
85
+ },
86
+
87
+ count: async ({ model, where }) => {
88
+ let query = getDb()(model);
89
+ if (where) {
90
+ query = applyWhere(query, where);
91
+ }
92
+ const [{ count }] = await query.count("* as count");
93
+ return Number(count);
94
+ },
95
+ });
96
+ };
97
+
98
+ const adapterConfig = {
99
+ adapterId: "sonamu-knex",
100
+ adapterName: "Sonamu Knex Adapter",
101
+ usePlural: false,
102
+ supportsJSON: true,
103
+ supportsDates: true,
104
+ supportsBooleans: true,
105
+ supportsNumericIds: false,
106
+ transaction: async <R>(cb: (trx: DBTransactionAdapter) => Promise<R>): Promise<R> => {
107
+ const db = DB.getDB("w");
108
+ return db.transaction(async (trx) => {
109
+ const options = lazyOptions;
110
+ if (!options) {
111
+ throw new Error("sonamuKnexAdapter: options not initialized");
112
+ }
113
+ return cb(
114
+ createAdapterFactory({
115
+ config: adapterConfig,
116
+ adapter: createCustomAdapter(() => trx),
117
+ })(options),
118
+ );
119
+ });
120
+ },
121
+ };
122
+
123
+ const adapterCreator = createAdapterFactory({
124
+ config: adapterConfig,
125
+ adapter: createCustomAdapter(() => DB.getDB("w")),
126
+ });
127
+
128
+ return (options: BetterAuthOptions) => {
129
+ lazyOptions = options;
130
+ return adapterCreator(options);
131
+ };
132
+ };
133
+
134
+ /**
135
+ * Better Auth의 공식 어댑터(Kysely, Drizzle, Prisma, MongoDB) 패턴에 맞춰
136
+ * AND 그룹과 OR 그룹을 분리한 뒤 top-level AND로 결합합니다.
137
+ * 결과: (A AND B AND ...) AND (C OR D OR ...)
138
+ */
139
+ export function applyWhere(
140
+ query: Knex.QueryBuilder,
141
+ conditions: CleanedWhere[],
142
+ ): Knex.QueryBuilder {
143
+ const andGroup = conditions.filter((c) => c.connector !== "OR");
144
+ const orGroup = conditions.filter((c) => c.connector === "OR");
145
+
146
+ if (andGroup.length > 0) {
147
+ for (const condition of andGroup) {
148
+ query = applyCondition(query, condition, "where");
149
+ }
150
+ }
151
+
152
+ if (orGroup.length > 0) {
153
+ query = query.where(function (this: Knex.QueryBuilder) {
154
+ for (let i = 0; i < orGroup.length; i++) {
155
+ applyCondition(this, orGroup[i], i === 0 ? "where" : "orWhere");
156
+ }
157
+ });
158
+ }
159
+
160
+ return query;
161
+ }
162
+
163
+ function applyCondition(
164
+ query: Knex.QueryBuilder,
165
+ condition: CleanedWhere,
166
+ method: "where" | "orWhere",
167
+ ): Knex.QueryBuilder {
168
+ const { field, value, operator } = condition;
169
+
170
+ switch (operator) {
171
+ case "eq":
172
+ if (value === null) {
173
+ return query[method === "orWhere" ? "orWhereNull" : "whereNull"](field);
174
+ }
175
+ return query[method](field, "=", value);
176
+ case "ne":
177
+ if (value === null) {
178
+ return query[method === "orWhere" ? "orWhereNotNull" : "whereNotNull"](field);
179
+ }
180
+ return query[method](field, "!=", value);
181
+ case "lt":
182
+ return query[method](field, "<", value);
183
+ case "lte":
184
+ return query[method](field, "<=", value);
185
+ case "gt":
186
+ return query[method](field, ">", value);
187
+ case "gte":
188
+ return query[method](field, ">=", value);
189
+ case "in":
190
+ return query[method === "orWhere" ? "orWhereIn" : "whereIn"](
191
+ field,
192
+ value as (string | number)[],
193
+ );
194
+ case "not_in":
195
+ return query[method === "orWhere" ? "orWhereNotIn" : "whereNotIn"](
196
+ field,
197
+ value as (string | number)[],
198
+ );
199
+ case "contains":
200
+ return query[method](field, "like", `%${value}%`);
201
+ case "starts_with":
202
+ return query[method](field, "like", `${value}%`);
203
+ case "ends_with":
204
+ return query[method](field, "like", `%${value}`);
205
+ default:
206
+ return query;
207
+ }
208
+ }
@@ -0,0 +1,62 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const tsLoaderRegisterStateKey = Symbol.for("sonamu.ts-loader-register.state");
4
+
5
+ type TsLoaderRegisterState = {
6
+ registered: boolean;
7
+ };
8
+
9
+ type GlobalWithTsLoaderRegisterState = typeof globalThis & {
10
+ [tsLoaderRegisterStateKey]?: TsLoaderRegisterState;
11
+ };
12
+
13
+ function resetRegisterState() {
14
+ const globalState = globalThis as GlobalWithTsLoaderRegisterState;
15
+ delete globalState[tsLoaderRegisterStateKey];
16
+ delete process.env.SWCRC_PATH;
17
+ }
18
+
19
+ describe("ensureTsLoaderRegistered", () => {
20
+ beforeEach(() => {
21
+ vi.resetModules();
22
+ vi.restoreAllMocks();
23
+ vi.unmock("node:module");
24
+ vi.unmock("../../utils/fs-utils.js");
25
+ resetRegisterState();
26
+ });
27
+
28
+ afterEach(() => {
29
+ vi.resetModules();
30
+ vi.restoreAllMocks();
31
+ vi.unmock("node:module");
32
+ vi.unmock("../../utils/fs-utils.js");
33
+ resetRegisterState();
34
+ });
35
+
36
+ it("프로젝트 .swcrc를 우선 사용하고 중복 등록하지 않는다", async () => {
37
+ const registerMock = vi.fn();
38
+ vi.doMock("node:module", () => ({
39
+ register: registerMock,
40
+ }));
41
+ vi.doMock("../../utils/fs-utils.js", () => ({
42
+ exists: vi.fn(async (candidate: string) => candidate === "/tmp/fixture-api/.swcrc"),
43
+ }));
44
+
45
+ const module = await import("../ts-loader-registration");
46
+
47
+ expect(registerMock).not.toHaveBeenCalled();
48
+
49
+ await module.ensureTsLoaderRegistered("/tmp/fixture-api");
50
+
51
+ expect(registerMock).toHaveBeenCalledTimes(1);
52
+ expect(registerMock).toHaveBeenCalledWith("@sonamu-kit/ts-loader/loader", {
53
+ parentURL: expect.stringContaining("/src/bin/ts-loader-registration"),
54
+ });
55
+ expect(process.env.SWCRC_PATH).toBe("/tmp/fixture-api/.swcrc");
56
+
57
+ await module.ensureTsLoaderRegistered("/tmp/another-api");
58
+
59
+ expect(registerMock).toHaveBeenCalledTimes(1);
60
+ expect(process.env.SWCRC_PATH).toBe("/tmp/fixture-api/.swcrc");
61
+ });
62
+ });