@gencow/core 0.1.12 → 0.1.14
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/crud.d.ts +67 -25
- package/dist/crud.js +219 -98
- package/dist/index.d.ts +2 -1
- package/dist/index.js +3 -1
- package/dist/scheduler.js +9 -1
- package/package.json +39 -38
- package/src/__tests__/crud-codegen-integration.test.ts +253 -0
- package/src/__tests__/crud.test.ts +527 -0
- package/src/__tests__/dist-exports.test.ts +170 -0
- package/src/__tests__/scheduler-exec.test.ts +15 -18
- package/src/crud.ts +268 -119
- package/src/index.ts +4 -1
- package/src/scheduler.ts +8 -1
- package/dist/scoped-db.d.ts +0 -34
- package/dist/scoped-db.js +0 -364
- package/dist/table.d.ts +0 -67
- package/dist/table.js +0 -98
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* packages/core/src/__tests__/dist-exports.test.ts
|
|
3
|
+
*
|
|
4
|
+
* dist/ 빌드 산출물의 필수 export 검증 — npm publish 전 안전망.
|
|
5
|
+
*
|
|
6
|
+
* 이 테스트의 존재 이유:
|
|
7
|
+
* 2026-04-02 사고: 소스코드에 crud v2가 완전 구현되어 있었으나,
|
|
8
|
+
* dist/index.js에 `crud` export가 누락된 구버전이 npm에 배포됨.
|
|
9
|
+
* crud.test.ts(16 pass)가 src/crud.ts를 직접 import하므로
|
|
10
|
+
* dist/ 빌드 결과물의 정합성은 검증하지 못했음.
|
|
11
|
+
*
|
|
12
|
+
* 검증 항목:
|
|
13
|
+
* 1. dist/index.js 파일 존재
|
|
14
|
+
* 2. 필수 named export 존재 (crud, query, mutation, v, ...)
|
|
15
|
+
* 3. gencowCrud deprecated alias 존재 (하위호환)
|
|
16
|
+
* 4. crud가 실제 함수인지 (typeof === "function")
|
|
17
|
+
* 5. dist/index.js의 crud와 src/crud.ts의 crud가 동일 함수인지
|
|
18
|
+
*
|
|
19
|
+
* Run: bun test packages/core/src/__tests__/dist-exports.test.ts
|
|
20
|
+
*
|
|
21
|
+
* @see docs/analysis/analysis-test032-gencow-crud-api-mismatch.md
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { describe, it, expect } from "bun:test";
|
|
25
|
+
import { existsSync } from "fs";
|
|
26
|
+
import { resolve, dirname } from "path";
|
|
27
|
+
import { fileURLToPath } from "url";
|
|
28
|
+
|
|
29
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
30
|
+
const CORE_ROOT = resolve(__dirname, "../..");
|
|
31
|
+
const DIST_INDEX = resolve(CORE_ROOT, "dist/index.js");
|
|
32
|
+
|
|
33
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
34
|
+
// 1. dist/ 파일 존재 확인
|
|
35
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
36
|
+
|
|
37
|
+
describe("dist/ 빌드 산출물 존재", () => {
|
|
38
|
+
it("dist/index.js 파일이 존재한다", () => {
|
|
39
|
+
expect(existsSync(DIST_INDEX)).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("dist/crud.js 파일이 존재한다", () => {
|
|
43
|
+
expect(existsSync(resolve(CORE_ROOT, "dist/crud.js"))).toBe(true);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("dist/reactive.js 파일이 존재한다", () => {
|
|
47
|
+
expect(existsSync(resolve(CORE_ROOT, "dist/reactive.js"))).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("dist/v.js 파일이 존재한다", () => {
|
|
51
|
+
expect(existsSync(resolve(CORE_ROOT, "dist/v.js"))).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
56
|
+
// 2. 필수 named export 존재 확인
|
|
57
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
58
|
+
|
|
59
|
+
describe("dist/index.js 필수 export", () => {
|
|
60
|
+
let distModule: Record<string, unknown>;
|
|
61
|
+
|
|
62
|
+
// dist/index.js를 dynamic import (빌드 결과물 검증)
|
|
63
|
+
it("dist/index.js를 import할 수 있다", async () => {
|
|
64
|
+
distModule = await import(DIST_INDEX);
|
|
65
|
+
expect(distModule).toBeDefined();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// ── Core API exports ──────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
it("crud export가 존재하고 함수이다", async () => {
|
|
71
|
+
if (!distModule) distModule = await import(DIST_INDEX);
|
|
72
|
+
expect(distModule.crud).toBeDefined();
|
|
73
|
+
expect(typeof distModule.crud).toBe("function");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("gencowCrud deprecated alias가 존재하고 crud와 동일하다", async () => {
|
|
77
|
+
if (!distModule) distModule = await import(DIST_INDEX);
|
|
78
|
+
expect(distModule.gencowCrud).toBeDefined();
|
|
79
|
+
expect(distModule.gencowCrud).toBe(distModule.crud);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("query export가 존재하고 함수이다", async () => {
|
|
83
|
+
if (!distModule) distModule = await import(DIST_INDEX);
|
|
84
|
+
expect(distModule.query).toBeDefined();
|
|
85
|
+
expect(typeof distModule.query).toBe("function");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("mutation export가 존재하고 함수이다", async () => {
|
|
89
|
+
if (!distModule) distModule = await import(DIST_INDEX);
|
|
90
|
+
expect(distModule.mutation).toBeDefined();
|
|
91
|
+
expect(typeof distModule.mutation).toBe("function");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("v export가 존재하고 객체이다", async () => {
|
|
95
|
+
if (!distModule) distModule = await import(DIST_INDEX);
|
|
96
|
+
expect(distModule.v).toBeDefined();
|
|
97
|
+
expect(typeof distModule.v).toBe("object");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("httpAction export가 존재하고 함수이다", async () => {
|
|
101
|
+
if (!distModule) distModule = await import(DIST_INDEX);
|
|
102
|
+
expect(distModule.httpAction).toBeDefined();
|
|
103
|
+
expect(typeof distModule.httpAction).toBe("function");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// ── Registry exports (codegen 의존) ────────────────────────
|
|
107
|
+
|
|
108
|
+
it("getRegisteredQueries export가 존재한다", async () => {
|
|
109
|
+
if (!distModule) distModule = await import(DIST_INDEX);
|
|
110
|
+
expect(distModule.getRegisteredQueries).toBeDefined();
|
|
111
|
+
expect(typeof distModule.getRegisteredQueries).toBe("function");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("getRegisteredMutations export가 존재한다", async () => {
|
|
115
|
+
if (!distModule) distModule = await import(DIST_INDEX);
|
|
116
|
+
expect(distModule.getRegisteredMutations).toBeDefined();
|
|
117
|
+
expect(typeof distModule.getRegisteredMutations).toBe("function");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// ── RLS + Auth exports ─────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
it("ownerRls export가 존재한다", async () => {
|
|
123
|
+
if (!distModule) distModule = await import(DIST_INDEX);
|
|
124
|
+
expect(distModule.ownerRls).toBeDefined();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("defineAuth export가 존재한다", async () => {
|
|
128
|
+
if (!distModule) distModule = await import(DIST_INDEX);
|
|
129
|
+
expect(distModule.defineAuth).toBeDefined();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("cronJobs export가 존재한다", async () => {
|
|
133
|
+
if (!distModule) distModule = await import(DIST_INDEX);
|
|
134
|
+
expect(distModule.cronJobs).toBeDefined();
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
139
|
+
// 3. dist/crud.js가 v2 구현인지 확인 (커링 구조가 아닌지)
|
|
140
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
141
|
+
|
|
142
|
+
describe("dist/crud.js — v2 구현 검증", () => {
|
|
143
|
+
it("crud(table) 시그니처이다 (커링 gencowCrud(db)(table) 아님)", async () => {
|
|
144
|
+
const distModule = await import(DIST_INDEX);
|
|
145
|
+
const { crud } = distModule;
|
|
146
|
+
|
|
147
|
+
// v2: crud(table, options?) → { list, get, create, update, remove }
|
|
148
|
+
// 구버전: gencowCrud(db) → (table, options?) → { create, findById, list, ... }
|
|
149
|
+
|
|
150
|
+
// 실제 Drizzle 테이블로 테스트
|
|
151
|
+
const { pgTable, serial, text } = await import("drizzle-orm/pg-core");
|
|
152
|
+
const testTable = pgTable("dist_smoke_test", {
|
|
153
|
+
id: serial("id").primaryKey(),
|
|
154
|
+
name: text("name"),
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
const result = crud(testTable, { public: true });
|
|
158
|
+
|
|
159
|
+
// v2는 { list, get, create, update, remove } 반환
|
|
160
|
+
expect(result).toHaveProperty("list");
|
|
161
|
+
expect(result).toHaveProperty("get");
|
|
162
|
+
expect(result).toHaveProperty("create");
|
|
163
|
+
expect(result).toHaveProperty("update");
|
|
164
|
+
expect(result).toHaveProperty("remove");
|
|
165
|
+
|
|
166
|
+
// 구버전(커링)은 함수를 반환하므로 이 검증이 실패함
|
|
167
|
+
expect(typeof result).toBe("object");
|
|
168
|
+
expect(typeof result.list).not.toBe("undefined");
|
|
169
|
+
});
|
|
170
|
+
});
|
|
@@ -142,17 +142,17 @@ describe("Scheduler 실행 — registerAction + executeAction", () => {
|
|
|
142
142
|
expect(result).toBe("Hello World");
|
|
143
143
|
});
|
|
144
144
|
|
|
145
|
-
it("미등록 action executeAction →
|
|
145
|
+
it("미등록 action executeAction → throw 안 함, console.error 출력", async () => {
|
|
146
146
|
const scheduler = createScheduler();
|
|
147
|
-
//
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
expect(
|
|
147
|
+
// 공개 API는 에러를 삼기고 console.error만 출력
|
|
148
|
+
const errors: string[] = [];
|
|
149
|
+
const origError = console.error;
|
|
150
|
+
console.error = (...args: any[]) => { errors.push(args.map(String).join(" ")); };
|
|
151
|
+
|
|
152
|
+
await scheduler.executeAction("nonexistent"); // throw하지 않음
|
|
153
|
+
|
|
154
|
+
console.error = origError;
|
|
155
|
+
expect(errors.some(e => e.includes("nonexistent"))).toBe(true);
|
|
156
156
|
});
|
|
157
157
|
|
|
158
158
|
it("action 에러 시 다른 action에 영향 없음", async () => {
|
|
@@ -166,14 +166,11 @@ describe("Scheduler 실행 — registerAction + executeAction", () => {
|
|
|
166
166
|
secondRan = true;
|
|
167
167
|
});
|
|
168
168
|
|
|
169
|
-
// 실패하는 action — 에러를
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
threw = true;
|
|
175
|
-
}
|
|
176
|
-
expect(threw).toBe(true);
|
|
169
|
+
// 실패하는 action — 공개 API는 throw하지 않음 (에러를 삼김)
|
|
170
|
+
const origError = console.error;
|
|
171
|
+
console.error = () => {}; // suppress expected error log
|
|
172
|
+
await scheduler.executeAction("failing"); // throw하지 않음
|
|
173
|
+
console.error = origError;
|
|
177
174
|
|
|
178
175
|
// 정상 action은 여전히 동작
|
|
179
176
|
await scheduler.executeAction("healthy");
|
package/src/crud.ts
CHANGED
|
@@ -1,174 +1,323 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
/**
|
|
2
|
+
* packages/core/src/crud.ts — Zero-Boilerplate CRUD API Auto-Generator (v2)
|
|
3
|
+
*
|
|
4
|
+
* 사용자 코드 5줄로 완전한 CRUD API를 자동 생성:
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* ```ts
|
|
8
|
+
* import { crud } from "@gencow/core";
|
|
9
|
+
* import { tasks } from "./schema";
|
|
10
|
+
*
|
|
11
|
+
* // query(tasks.list), query(tasks.get), mutation(tasks.create/update/remove) 자동 등록
|
|
12
|
+
* export const { list, get, create, update, remove } = crud(tasks);
|
|
13
|
+
* ```
|
|
14
|
+
*
|
|
15
|
+
* list 반환값: `{ data: T[], total: number }`
|
|
16
|
+
* → 프론트: `const result = useQuery(api.tasks.list); result?.data.map(...)`
|
|
17
|
+
* → total은 동일 필터 조건의 전체 레코드 수 (페이지네이션 UI 지원)
|
|
18
|
+
*
|
|
19
|
+
* 내부적으로 query()/mutation()을 호출하여 글로벌 레지스트리에 등록하므로,
|
|
20
|
+
* 프론트엔드에서 useQuery(api.tasks.list) / useMutation(api.tasks.create) 로 바로 사용 가능.
|
|
21
|
+
*
|
|
22
|
+
* 📄 Spec: docs/specs/spec-crud-v2-enhancements.md
|
|
23
|
+
* 📦 참조: saas-js/drizzle-crud 팩토리 패턴
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { eq, desc, asc, ilike, or, and, count as drizzleCount, getTableName, getTableColumns, type SQL } from "drizzle-orm";
|
|
27
|
+
import type { PgTable, AnyPgColumn } from "drizzle-orm/pg-core";
|
|
28
|
+
import { query, mutation } from "./reactive";
|
|
29
|
+
import { v } from "./v";
|
|
30
|
+
|
|
31
|
+
// ─── Types ──────────────────────────────────────────────
|
|
3
32
|
|
|
4
33
|
type CrudOptions<T extends PgTable> = {
|
|
34
|
+
/** 검색 대상 필드 — list의 search 파라미터에 사용 */
|
|
5
35
|
searchFields?: (keyof T["_"]["columns"])[];
|
|
36
|
+
/** 소프트 삭제 컬럼 (e.g. deletedAt) */
|
|
6
37
|
softDelete?: { field: keyof T["_"]["columns"] };
|
|
38
|
+
/** 필터링 허용 필드 — allowedFilters에 없는 키는 무시 (보안) */
|
|
7
39
|
allowedFilters?: (keyof T["_"]["columns"])[];
|
|
40
|
+
/** 기본 페이지 크기 (default: 20) */
|
|
8
41
|
defaultLimit?: number;
|
|
42
|
+
/** 최대 페이지 크기 (default: 100) */
|
|
9
43
|
maxLimit?: number;
|
|
44
|
+
/** 라이프사이클 훅 */
|
|
10
45
|
hooks?: {
|
|
11
46
|
beforeCreate?: (data: any) => any | Promise<any>;
|
|
12
47
|
beforeUpdate?: (data: any) => any | Promise<any>;
|
|
13
48
|
};
|
|
49
|
+
/** true면 인증 없이 접근 가능 (기본: false — Secure by Default) */
|
|
50
|
+
public?: boolean;
|
|
51
|
+
/** true면 mutation 후 realtime push (기본: true) */
|
|
52
|
+
realtime?: boolean;
|
|
53
|
+
/** 키 접두사 오버라이드 (기본: 테이블명) */
|
|
54
|
+
prefix?: string;
|
|
14
55
|
};
|
|
15
56
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
57
|
+
// ─── Helpers ────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* id 컬럼의 Drizzle dataType을 검사하여 적절한 validator를 반환.
|
|
61
|
+
* serial/integer/bigint → v.number()
|
|
62
|
+
* text/uuid/varchar → v.string()
|
|
63
|
+
*/
|
|
64
|
+
function detectIdType(column: AnyPgColumn) {
|
|
65
|
+
const colType = (column as any).dataType;
|
|
66
|
+
if (colType === "string") return v.string();
|
|
67
|
+
return v.number();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ─── crud — Zero-Boilerplate API Factory ──────────
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 테이블을 받아 query/mutation을 자동 등록하고,
|
|
74
|
+
* { list, get, create, update, remove } 를 반환한다.
|
|
75
|
+
*
|
|
76
|
+
* list 반환값: `{ data: T[], total: number }`
|
|
77
|
+
*
|
|
78
|
+
* @example
|
|
79
|
+
* ```ts
|
|
80
|
+
* export const { list, get, create, update, remove } = crud(tasks);
|
|
81
|
+
* export const { list, get, create, update, remove } = crud(tasks, { public: true });
|
|
82
|
+
* ```
|
|
83
|
+
*/
|
|
84
|
+
export function crud<T extends PgTable>(table: T, options?: CrudOptions<T>) {
|
|
85
|
+
const anyTable = table as any;
|
|
86
|
+
const tableName: string = getTableName(table); // Drizzle 공식 API
|
|
87
|
+
const prefix = options?.prefix || tableName;
|
|
88
|
+
const isPublic = options?.public ?? false;
|
|
89
|
+
const useRealtime = options?.realtime ?? true;
|
|
90
|
+
const defaultLimit = options?.defaultLimit ?? 20;
|
|
91
|
+
const maxLimit = options?.maxLimit ?? 100;
|
|
92
|
+
const pk = anyTable["id"] as AnyPgColumn;
|
|
93
|
+
|
|
94
|
+
if (!pk) {
|
|
95
|
+
throw new Error(`[crud] Table "${tableName}" must have an 'id' column.`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// id 타입 자동 감지 (serial → v.number(), text/uuid → v.string())
|
|
99
|
+
const idValidator = detectIdType(pk);
|
|
100
|
+
|
|
101
|
+
// createdAt 컬럼 (정렬용, 없으면 id 사용)
|
|
102
|
+
const createdAtCol = anyTable["createdAt"] as AnyPgColumn | undefined;
|
|
103
|
+
const defaultOrderCol = createdAtCol || pk;
|
|
104
|
+
|
|
105
|
+
// userId 컬럼 (자동 주입용)
|
|
106
|
+
const userIdCol = anyTable["userId"] as AnyPgColumn | undefined;
|
|
107
|
+
|
|
108
|
+
// ── 내부 헬퍼: WHERE 조건 빌드 (list + count + realtime 공유) ──
|
|
109
|
+
function buildWhereConditions(args: any): SQL | undefined {
|
|
110
|
+
const conditions: SQL[] = [];
|
|
111
|
+
|
|
112
|
+
// Soft delete
|
|
113
|
+
if (options?.softDelete) {
|
|
114
|
+
const sdField = anyTable[options.softDelete.field as string] as AnyPgColumn;
|
|
115
|
+
conditions.push(eq(sdField, null as any));
|
|
23
116
|
}
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
return result;
|
|
117
|
+
|
|
118
|
+
// Search
|
|
119
|
+
if (args?.search && options?.searchFields?.length) {
|
|
120
|
+
const searchConds = options.searchFields.map(
|
|
121
|
+
(f) => ilike(anyTable[f as string] as AnyPgColumn, `%${args.search}%`)
|
|
122
|
+
);
|
|
123
|
+
conditions.push(or(...searchConds)!);
|
|
32
124
|
}
|
|
33
125
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
126
|
+
// Dynamic filters (allowedFilters 화이트리스트만 허용)
|
|
127
|
+
if (args?.filters && options?.allowedFilters?.length) {
|
|
128
|
+
for (const [key, value] of Object.entries(args.filters as Record<string, unknown>)) {
|
|
129
|
+
if (options.allowedFilters.includes(key as any) && anyTable[key]) {
|
|
130
|
+
conditions.push(eq(anyTable[key] as AnyPgColumn, value));
|
|
131
|
+
}
|
|
132
|
+
// allowedFilters에 없는 키는 무시 (보안)
|
|
40
133
|
}
|
|
41
|
-
|
|
42
|
-
const [result] = await db.select().from(anyTable).where(whereCond).limit(1);
|
|
43
|
-
return result || null;
|
|
44
134
|
}
|
|
45
135
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
limit?: number;
|
|
49
|
-
search?: string;
|
|
50
|
-
filters?: Record<string, any>;
|
|
51
|
-
orderBy?: { field: string; direction: 'asc' | 'desc' }[];
|
|
52
|
-
includeDeleted?: boolean;
|
|
53
|
-
}) {
|
|
54
|
-
const page = Math.max(1, params?.page || 1);
|
|
55
|
-
const limit = Math.min(
|
|
56
|
-
Math.max(1, params?.limit || options?.defaultLimit || 20),
|
|
57
|
-
options?.maxLimit || 100
|
|
58
|
-
);
|
|
59
|
-
const offset = (page - 1) * limit;
|
|
136
|
+
return conditions.length > 0 ? and(...conditions) : undefined;
|
|
137
|
+
}
|
|
60
138
|
|
|
61
|
-
|
|
139
|
+
// ── 내부 헬퍼: list+count 데이터 가져오기 (realtime push용 재사용) ──
|
|
140
|
+
// 순차 실행: 소규모 커넥션 풀 환경에서 동시 점유 방지
|
|
141
|
+
// ⚠️ limit/offset 없이 전체 SELECT — 대량 데이터 시 성능 저하 주의
|
|
142
|
+
// TODO(P2): realtime emit 시 invalidation 메시지만 전송하고 클라이언트가 re-fetch하는 패턴 검토
|
|
143
|
+
async function fetchListWithTotal(db: any, whereClause?: SQL) {
|
|
144
|
+
const data = await db.select().from(anyTable).where(whereClause).orderBy(desc(defaultOrderCol));
|
|
145
|
+
const countResult = await db.select({ count: drizzleCount() }).from(anyTable).where(whereClause);
|
|
146
|
+
return { data, total: Number(countResult[0]?.count ?? 0) };
|
|
147
|
+
}
|
|
62
148
|
|
|
63
|
-
|
|
64
|
-
if (options?.softDelete && !params?.includeDeleted) {
|
|
65
|
-
conditions.push(sql`${anyTable[options.softDelete.field as string]} IS NULL`);
|
|
66
|
-
}
|
|
149
|
+
// ── list ──────────────────────────────────────
|
|
67
150
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
151
|
+
const listDef = query(`${prefix}.list`, {
|
|
152
|
+
public: isPublic,
|
|
153
|
+
args: {
|
|
154
|
+
page: v.optional(v.number()),
|
|
155
|
+
limit: v.optional(v.number()),
|
|
156
|
+
search: v.optional(v.string()),
|
|
157
|
+
orderBy: v.optional(v.string()),
|
|
158
|
+
orderDir: v.optional(v.string()),
|
|
159
|
+
filters: v.optional(v.any()),
|
|
160
|
+
},
|
|
161
|
+
handler: async (ctx: any, args: any) => {
|
|
162
|
+
if (!isPublic) ctx.auth.requireAuth();
|
|
75
163
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
164
|
+
const page = Math.max(1, args?.page || 1);
|
|
165
|
+
const limit = Math.min(Math.max(1, args?.limit || defaultLimit), maxLimit);
|
|
166
|
+
const offset = (page - 1) * limit;
|
|
167
|
+
|
|
168
|
+
const whereClause = buildWhereConditions(args);
|
|
169
|
+
|
|
170
|
+
// Order
|
|
171
|
+
let orderByClause;
|
|
172
|
+
if (args?.orderBy && anyTable[args.orderBy]) {
|
|
173
|
+
const col = anyTable[args.orderBy] as AnyPgColumn;
|
|
174
|
+
orderByClause = args.orderDir === "asc" ? asc(col) : desc(col);
|
|
175
|
+
} else {
|
|
176
|
+
orderByClause = desc(defaultOrderCol);
|
|
83
177
|
}
|
|
84
178
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
//
|
|
88
|
-
const
|
|
89
|
-
const col = anyTable[o.field] as AnyPgColumn;
|
|
90
|
-
return o.direction === 'desc' ? desc(col) : asc(col);
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
// Base count
|
|
94
|
-
const [{ count: total }] = await db.select({ count: count() }).from(anyTable).where(whereClause);
|
|
95
|
-
|
|
96
|
-
const results = await db.select()
|
|
179
|
+
// SELECT + COUNT 순차 실행 — 소규모 커넥션 풀 환경에서 동시 점유 방지
|
|
180
|
+
// Note: data←→count 사이 INSERT/DELETE 시 total 불일치 가능 (BaaS 단일사용자/저부하에서 무시 가능)
|
|
181
|
+
// TODO(P2): 대규모 시 db.transaction() 래핑 검토
|
|
182
|
+
const results = await ctx.db.select()
|
|
97
183
|
.from(anyTable)
|
|
98
184
|
.where(whereClause)
|
|
99
|
-
.orderBy(
|
|
185
|
+
.orderBy(orderByClause)
|
|
100
186
|
.limit(limit)
|
|
101
187
|
.offset(offset);
|
|
188
|
+
const countResult = await ctx.db.select({ count: drizzleCount() })
|
|
189
|
+
.from(anyTable)
|
|
190
|
+
.where(whereClause);
|
|
102
191
|
|
|
103
192
|
return {
|
|
104
|
-
results,
|
|
105
|
-
|
|
106
|
-
limit,
|
|
107
|
-
total: Number(total),
|
|
193
|
+
data: results,
|
|
194
|
+
total: Number(countResult[0]?.count ?? 0),
|
|
108
195
|
};
|
|
109
196
|
}
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// ── get ───────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
const getDef = query(`${prefix}.get`, {
|
|
202
|
+
public: isPublic,
|
|
203
|
+
args: { id: idValidator },
|
|
204
|
+
handler: async (ctx: any, args: any) => {
|
|
205
|
+
if (!isPublic) ctx.auth.requireAuth();
|
|
206
|
+
|
|
207
|
+
let whereCond: SQL = eq(pk, args.id);
|
|
208
|
+
|
|
209
|
+
if (options?.softDelete) {
|
|
210
|
+
const sdField = anyTable[options.softDelete.field as string] as AnyPgColumn;
|
|
211
|
+
whereCond = and(whereCond, eq(sdField, null as any))!;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const [result] = await ctx.db.select().from(anyTable).where(whereCond).limit(1);
|
|
215
|
+
return result ?? null;
|
|
216
|
+
}
|
|
217
|
+
});
|
|
110
218
|
|
|
111
|
-
|
|
112
|
-
|
|
219
|
+
// ── create ────────────────────────────────────
|
|
220
|
+
|
|
221
|
+
const createDef = mutation(`${prefix}.create`, {
|
|
222
|
+
public: isPublic,
|
|
223
|
+
invalidates: [],
|
|
224
|
+
handler: async (ctx: any, args: any) => {
|
|
225
|
+
const user = isPublic ? null : ctx.auth.requireAuth();
|
|
226
|
+
|
|
227
|
+
let insertData = { ...args };
|
|
228
|
+
|
|
229
|
+
// userId 자동 주입 (테이블에 userId 컬럼이 있고 인증된 경우)
|
|
230
|
+
if (userIdCol && user && !insertData.userId) {
|
|
231
|
+
insertData.userId = user.id;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// beforeCreate 훅
|
|
235
|
+
if (options?.hooks?.beforeCreate) {
|
|
236
|
+
insertData = await options.hooks.beforeCreate(insertData);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const [result] = await ctx.db.insert(anyTable).values(insertData).returning();
|
|
240
|
+
|
|
241
|
+
// Realtime push — { data, total } 형태로 emit
|
|
242
|
+
if (useRealtime) {
|
|
243
|
+
const listResult = await fetchListWithTotal(ctx.db);
|
|
244
|
+
ctx.realtime.emit(`${prefix}.list`, listResult);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return result;
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
// ── update ────────────────────────────────────
|
|
252
|
+
|
|
253
|
+
const updateDef = mutation(`${prefix}.update`, {
|
|
254
|
+
public: isPublic,
|
|
255
|
+
invalidates: [],
|
|
256
|
+
handler: async (ctx: any, args: any) => {
|
|
257
|
+
if (!isPublic) ctx.auth.requireAuth();
|
|
258
|
+
|
|
259
|
+
const { id, ...updates } = args;
|
|
260
|
+
|
|
261
|
+
let updateData = { ...updates };
|
|
262
|
+
|
|
263
|
+
// updatedAt 자동 갱신
|
|
264
|
+
if (anyTable["updatedAt"]) {
|
|
265
|
+
updateData.updatedAt = new Date();
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// beforeUpdate 훅
|
|
113
269
|
if (options?.hooks?.beforeUpdate) {
|
|
114
270
|
updateData = await options.hooks.beforeUpdate(updateData);
|
|
115
271
|
}
|
|
116
|
-
|
|
272
|
+
|
|
273
|
+
const [result] = await ctx.db.update(anyTable)
|
|
117
274
|
.set(updateData)
|
|
118
275
|
.where(eq(pk, id))
|
|
119
276
|
.returning();
|
|
120
|
-
return result;
|
|
121
|
-
}
|
|
122
277
|
|
|
123
|
-
|
|
124
|
-
if (
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
.where(eq(pk, id));
|
|
129
|
-
} else {
|
|
130
|
-
await db.delete(anyTable).where(eq(pk, id));
|
|
278
|
+
// Realtime push (list + get 양쪽) — { data, total } 형태
|
|
279
|
+
if (useRealtime) {
|
|
280
|
+
const listResult = await fetchListWithTotal(ctx.db);
|
|
281
|
+
ctx.realtime.emit(`${prefix}.list`, listResult);
|
|
282
|
+
ctx.realtime.emit(`${prefix}.get`, result);
|
|
131
283
|
}
|
|
132
|
-
}
|
|
133
284
|
|
|
134
|
-
|
|
135
|
-
if (options?.softDelete) {
|
|
136
|
-
const sdField = options.softDelete.field as string;
|
|
137
|
-
await db.update(anyTable)
|
|
138
|
-
.set({ [sdField]: null } as any)
|
|
139
|
-
.where(eq(pk, id));
|
|
140
|
-
}
|
|
285
|
+
return result;
|
|
141
286
|
}
|
|
287
|
+
});
|
|
142
288
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
289
|
+
// ── remove ────────────────────────────────────
|
|
290
|
+
|
|
291
|
+
const removeDef = mutation(`${prefix}.remove`, {
|
|
292
|
+
public: isPublic,
|
|
293
|
+
invalidates: [],
|
|
294
|
+
handler: async (ctx: any, args: any) => {
|
|
295
|
+
if (!isPublic) ctx.auth.requireAuth();
|
|
150
296
|
|
|
151
|
-
async function bulkDelete(ids: any[]): Promise<void> {
|
|
152
|
-
if (ids.length === 0) return;
|
|
153
297
|
if (options?.softDelete) {
|
|
154
298
|
const sdField = options.softDelete.field as string;
|
|
155
|
-
await db.update(anyTable)
|
|
299
|
+
await ctx.db.update(anyTable)
|
|
156
300
|
.set({ [sdField]: new Date() } as any)
|
|
157
|
-
.where(
|
|
301
|
+
.where(eq(pk, args.id));
|
|
158
302
|
} else {
|
|
159
|
-
await db.delete(anyTable).where(
|
|
303
|
+
await ctx.db.delete(anyTable).where(eq(pk, args.id));
|
|
160
304
|
}
|
|
305
|
+
|
|
306
|
+
// Realtime push — { data, total } 형태
|
|
307
|
+
if (useRealtime) {
|
|
308
|
+
const listResult = await fetchListWithTotal(ctx.db);
|
|
309
|
+
ctx.realtime.emit(`${prefix}.list`, listResult);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return { success: true };
|
|
161
313
|
}
|
|
314
|
+
});
|
|
162
315
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
restore,
|
|
170
|
-
bulkCreate,
|
|
171
|
-
bulkDelete,
|
|
172
|
-
};
|
|
316
|
+
return {
|
|
317
|
+
list: listDef,
|
|
318
|
+
get: getDef,
|
|
319
|
+
create: createDef,
|
|
320
|
+
update: updateDef,
|
|
321
|
+
remove: removeDef,
|
|
173
322
|
};
|
|
174
323
|
}
|