@gencow/core 0.1.12 → 0.1.13

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/src/crud.ts CHANGED
@@ -1,174 +1,322 @@
1
- import { eq, or, and, ilike, desc, asc, inArray, count, sql, type SQL } from "drizzle-orm";
2
- import type { PgDatabase, PgTable, AnyPgColumn } from "drizzle-orm/pg-core";
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
- export function gencowCrud(db: PgDatabase<any, any, any>) {
17
- return function createCrud<T extends PgTable>(table: T, options?: CrudOptions<T>) {
18
- const anyTable = table as any;
19
- const pk = anyTable["id"] as AnyPgColumn;
20
-
21
- if (!pk) {
22
- throw new Error(`[gencowCrud] Table ${anyTable["_"]["name"]} must have an 'id' column.`);
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
- async function create(data: any): Promise<any> {
26
- let insertData = { ...data };
27
- if (options?.hooks?.beforeCreate) {
28
- insertData = await options.hooks.beforeCreate(insertData);
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)!);
124
+ }
125
+
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에 없는 키는 무시 (보안)
29
133
  }
30
- const [result] = await db.insert(anyTable).values(insertData).returning();
31
- return result;
32
134
  }
33
135
 
34
- async function findById(id: any): Promise<any | null> {
35
- let whereCond: SQL | undefined = eq(pk, id);
36
-
136
+ return conditions.length > 0 ? and(...conditions) : undefined;
137
+ }
138
+
139
+ // ── 내부 헬퍼: list+count 데이터 가져오기 (realtime push용 재사용) ──
140
+ async function fetchListWithTotal(db: any, whereClause?: SQL) {
141
+ const [data, countResult] = await Promise.all([
142
+ db.select().from(anyTable).where(whereClause).orderBy(desc(defaultOrderCol)),
143
+ db.select({ count: drizzleCount() }).from(anyTable).where(whereClause),
144
+ ]);
145
+ return { data, total: Number(countResult[0]?.count ?? 0) };
146
+ }
147
+
148
+ // ── list ──────────────────────────────────────
149
+
150
+ const listDef = query(`${prefix}.list`, {
151
+ public: isPublic,
152
+ args: {
153
+ page: v.optional(v.number()),
154
+ limit: v.optional(v.number()),
155
+ search: v.optional(v.string()),
156
+ orderBy: v.optional(v.string()),
157
+ orderDir: v.optional(v.string()),
158
+ filters: v.optional(v.any()),
159
+ },
160
+ handler: async (ctx: any, args: any) => {
161
+ if (!isPublic) ctx.auth.requireAuth();
162
+
163
+ const page = Math.max(1, args?.page || 1);
164
+ const limit = Math.min(Math.max(1, args?.limit || defaultLimit), maxLimit);
165
+ const offset = (page - 1) * limit;
166
+
167
+ const whereClause = buildWhereConditions(args);
168
+
169
+ // Order
170
+ let orderByClause;
171
+ if (args?.orderBy && anyTable[args.orderBy]) {
172
+ const col = anyTable[args.orderBy] as AnyPgColumn;
173
+ orderByClause = args.orderDir === "asc" ? asc(col) : desc(col);
174
+ } else {
175
+ orderByClause = desc(defaultOrderCol);
176
+ }
177
+
178
+ // SELECT + COUNT 병렬 실행
179
+ const [results, countResult] = await Promise.all([
180
+ ctx.db.select()
181
+ .from(anyTable)
182
+ .where(whereClause)
183
+ .orderBy(orderByClause)
184
+ .limit(limit)
185
+ .offset(offset),
186
+ ctx.db.select({ count: drizzleCount() })
187
+ .from(anyTable)
188
+ .where(whereClause),
189
+ ]);
190
+
191
+ return {
192
+ data: results,
193
+ total: Number(countResult[0]?.count ?? 0),
194
+ };
195
+ }
196
+ });
197
+
198
+ // ── get ───────────────────────────────────────
199
+
200
+ const getDef = query(`${prefix}.get`, {
201
+ public: isPublic,
202
+ args: { id: idValidator },
203
+ handler: async (ctx: any, args: any) => {
204
+ if (!isPublic) ctx.auth.requireAuth();
205
+
206
+ let whereCond: SQL = eq(pk, args.id);
207
+
37
208
  if (options?.softDelete) {
38
209
  const sdField = anyTable[options.softDelete.field as string] as AnyPgColumn;
39
- whereCond = and(whereCond, sql`${sdField} IS NULL`);
210
+ whereCond = and(whereCond, eq(sdField, null as any))!;
40
211
  }
41
-
42
- const [result] = await db.select().from(anyTable).where(whereCond).limit(1);
43
- return result || null;
212
+
213
+ const [result] = await ctx.db.select().from(anyTable).where(whereCond).limit(1);
214
+ return result ?? null;
44
215
  }
216
+ });
45
217
 
46
- async function list(params?: {
47
- page?: number;
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;
218
+ // ── create ────────────────────────────────────
60
219
 
61
- const conditions: SQL[] = [];
220
+ const createDef = mutation(`${prefix}.create`, {
221
+ public: isPublic,
222
+ invalidates: [],
223
+ handler: async (ctx: any, args: any) => {
224
+ const user = isPublic ? null : ctx.auth.requireAuth();
62
225
 
63
- // Soft delete
64
- if (options?.softDelete && !params?.includeDeleted) {
65
- conditions.push(sql`${anyTable[options.softDelete.field as string]} IS NULL`);
66
- }
226
+ let insertData = { ...args };
67
227
 
68
- // Search
69
- if (params?.search && options?.searchFields?.length) {
70
- const searchConds = options.searchFields.map(
71
- (f) => ilike(anyTable[f as string] as AnyPgColumn, `%${params!.search}%`)
72
- );
73
- conditions.push(or(...searchConds)!);
228
+ // userId 자동 주입 (테이블에 userId 컬럼이 있고 인증된 경우)
229
+ if (userIdCol && user && !insertData.userId) {
230
+ insertData.userId = user.id;
74
231
  }
75
232
 
76
- // Filters
77
- if (params?.filters && options?.allowedFilters) {
78
- for (const [k, v] of Object.entries(params.filters)) {
79
- if (options.allowedFilters.includes(k as keyof T["_"]["columns"])) {
80
- conditions.push(eq(anyTable[k] as AnyPgColumn, v));
81
- }
82
- }
233
+ // beforeCreate 훅
234
+ if (options?.hooks?.beforeCreate) {
235
+ insertData = await options.hooks.beforeCreate(insertData);
83
236
  }
84
237
 
85
- const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
86
-
87
- // Order By
88
- const orderByArgs = (params?.orderBy || []).map((o) => {
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()
97
- .from(anyTable)
98
- .where(whereClause)
99
- .orderBy(...orderByArgs)
100
- .limit(limit)
101
- .offset(offset);
238
+ const [result] = await ctx.db.insert(anyTable).values(insertData).returning();
102
239
 
103
- return {
104
- results,
105
- page,
106
- limit,
107
- total: Number(total),
108
- };
240
+ // Realtime push — { data, total } 형태로 emit
241
+ if (useRealtime) {
242
+ const listResult = await fetchListWithTotal(ctx.db);
243
+ ctx.realtime.emit(`${prefix}.list`, listResult);
244
+ }
245
+
246
+ return result;
109
247
  }
248
+ });
249
+
250
+ // ── update ────────────────────────────────────
251
+
252
+ const updateDef = mutation(`${prefix}.update`, {
253
+ public: isPublic,
254
+ invalidates: [],
255
+ handler: async (ctx: any, args: any) => {
256
+ if (!isPublic) ctx.auth.requireAuth();
257
+
258
+ const { id, ...updates } = args;
110
259
 
111
- async function update(id: any, data: any): Promise<any> {
112
- let updateData = { ...data };
260
+ let updateData = { ...updates };
261
+
262
+ // updatedAt 자동 갱신
263
+ if (anyTable["updatedAt"]) {
264
+ updateData.updatedAt = new Date();
265
+ }
266
+
267
+ // beforeUpdate 훅
113
268
  if (options?.hooks?.beforeUpdate) {
114
269
  updateData = await options.hooks.beforeUpdate(updateData);
115
270
  }
116
- const [result] = await db.update(anyTable)
271
+
272
+ const [result] = await ctx.db.update(anyTable)
117
273
  .set(updateData)
118
274
  .where(eq(pk, id))
119
275
  .returning();
120
- return result;
121
- }
122
276
 
123
- async function deleteOne(id: any): Promise<void> {
124
- if (options?.softDelete) {
125
- const sdField = options.softDelete.field as string;
126
- await db.update(anyTable)
127
- .set({ [sdField]: new Date() } as any)
128
- .where(eq(pk, id));
129
- } else {
130
- await db.delete(anyTable).where(eq(pk, id));
277
+ // Realtime push (list + get 양쪽) { data, total } 형태
278
+ if (useRealtime) {
279
+ const listResult = await fetchListWithTotal(ctx.db);
280
+ ctx.realtime.emit(`${prefix}.list`, listResult);
281
+ ctx.realtime.emit(`${prefix}.get`, result);
131
282
  }
132
- }
133
283
 
134
- async function restore(id: any): Promise<void> {
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
- }
284
+ return result;
141
285
  }
286
+ });
142
287
 
143
- async function bulkCreate(dataArray: any[]): Promise<any[]> {
144
- let insertData = [...dataArray];
145
- if (options?.hooks?.beforeCreate) {
146
- insertData = await Promise.all(insertData.map((d) => options.hooks!.beforeCreate!(d)));
147
- }
148
- return await db.insert(anyTable).values(insertData).returning();
149
- }
288
+ // ── remove ────────────────────────────────────
289
+
290
+ const removeDef = mutation(`${prefix}.remove`, {
291
+ public: isPublic,
292
+ invalidates: [],
293
+ handler: async (ctx: any, args: any) => {
294
+ if (!isPublic) ctx.auth.requireAuth();
150
295
 
151
- async function bulkDelete(ids: any[]): Promise<void> {
152
- if (ids.length === 0) return;
153
296
  if (options?.softDelete) {
154
297
  const sdField = options.softDelete.field as string;
155
- await db.update(anyTable)
298
+ await ctx.db.update(anyTable)
156
299
  .set({ [sdField]: new Date() } as any)
157
- .where(inArray(pk, ids));
300
+ .where(eq(pk, args.id));
158
301
  } else {
159
- await db.delete(anyTable).where(inArray(pk, ids));
302
+ await ctx.db.delete(anyTable).where(eq(pk, args.id));
303
+ }
304
+
305
+ // Realtime push — { data, total } 형태
306
+ if (useRealtime) {
307
+ const listResult = await fetchListWithTotal(ctx.db);
308
+ ctx.realtime.emit(`${prefix}.list`, listResult);
160
309
  }
310
+
311
+ return { success: true };
161
312
  }
313
+ });
162
314
 
163
- return {
164
- create,
165
- findById,
166
- list,
167
- update,
168
- deleteOne,
169
- restore,
170
- bulkCreate,
171
- bulkDelete,
172
- };
315
+ return {
316
+ list: listDef,
317
+ get: getDef,
318
+ create: createDef,
319
+ update: updateDef,
320
+ remove: removeDef,
173
321
  };
174
322
  }
package/src/index.ts CHANGED
@@ -22,7 +22,10 @@ export type { GencowAuthConfig, AuthEmailVerification } from "./auth-config";
22
22
  // ─── RLS + CRUD Factory ───────────
23
23
  export { ownerRls } from "./rls";
24
24
  export { createRlsDb } from "./rls-db";
25
- export { gencowCrud } from "./crud";
25
+ export { crud } from "./crud";
26
+
27
+ // Deprecated alias — 하위호환용, 향후 메이저 버전에서 제거 예정
28
+ export { crud as gencowCrud } from "./crud";
26
29
 
27
30
 
28
31
 
package/src/scheduler.ts CHANGED
@@ -214,7 +214,14 @@ export function createScheduler(): Scheduler {
214
214
  },
215
215
 
216
216
  async executeAction(name: string, args?: any): Promise<void> {
217
- await executeAction(name, args);
217
+ try {
218
+ await executeAction(name, args);
219
+ } catch (error) {
220
+ // 공개 API는 에러를 삼긴다 (호출자 크래시 방지)
221
+ // handler 에러도 로깅 (내부 함수는 미등록만 로깅, handler 에러는 미로깅)
222
+ const msg = error instanceof Error ? error.message : String(error);
223
+ console.error(`[scheduler] executeAction("${name}") failed: ${msg}`);
224
+ }
218
225
  },
219
226
  };
220
227
  }
@@ -1,34 +0,0 @@
1
- /**
2
- * packages/core/src/scoped-db.ts
3
- *
4
- * Creates a scoped (Proxy-wrapped) Drizzle DB instance that auto-injects
5
- * schema-level access control filters from gencowTable() metadata.
6
- *
7
- * Key behaviors:
8
- * - .select().from(gencowTable) → auto-inject filter into WHERE
9
- * - .insert(table) / .update(table) / .delete(table) → inject filter for writes
10
- * - .leftJoin(table) / .innerJoin(table) → detect and inject filter
11
- * - .execute() → blocked (throws Error)
12
- * - .query.tableName.findMany() → inject filter into relational queries
13
- *
14
- * Run tests: bun test packages/core/src/__tests__/scoped-db.test.ts
15
- */
16
- import type { GencowCtx } from "./reactive";
17
- /**
18
- * Wrap a Drizzle DB instance with access control Proxy.
19
- *
20
- * @param db - Raw Drizzle DB instance
21
- * @param ctx - GencowCtx (provides auth for filter evaluation)
22
- * @returns Proxy-wrapped DB with auto-filter injection
23
- */
24
- export declare function createScopedDb(db: any, ctx: GencowCtx): any;
25
- /**
26
- * Apply field-level access control to query results.
27
- * Nullifies fields that the current user is not authorized to read.
28
- *
29
- * @param result - Query result (array or single object)
30
- * @param table - The gencowTable used in the query
31
- * @param ctx - GencowCtx for auth checks
32
- * @returns Filtered result with unauthorized fields set to null
33
- */
34
- export declare function applyFieldAccess(result: any, table: any, ctx: GencowCtx): any;