@gencow/core 0.1.11 → 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/dist/crud.d.ts +67 -25
- package/dist/crud.js +221 -101
- package/dist/index.d.ts +2 -1
- package/dist/index.js +3 -1
- package/dist/rls-db.d.ts +1 -16
- package/dist/rls-db.js +14 -9
- package/dist/scheduler.js +9 -1
- package/package.json +39 -38
- package/src/__tests__/crud.test.ts +527 -0
- package/src/__tests__/scheduler-exec.test.ts +15 -18
- package/src/crud.ts +269 -121
- package/src/index.ts +4 -1
- package/src/rls-db.ts +16 -11
- 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
package/src/crud.ts
CHANGED
|
@@ -1,174 +1,322 @@
|
|
|
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
|
-
|
|
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
|
-
|
|
35
|
-
|
|
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,
|
|
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
|
|
212
|
+
|
|
213
|
+
const [result] = await ctx.db.select().from(anyTable).where(whereCond).limit(1);
|
|
214
|
+
return result ?? null;
|
|
44
215
|
}
|
|
216
|
+
});
|
|
45
217
|
|
|
46
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
69
|
-
if (
|
|
70
|
-
|
|
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
|
-
//
|
|
77
|
-
if (
|
|
78
|
-
|
|
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
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
112
|
-
|
|
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
|
-
|
|
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
|
-
|
|
124
|
-
if (
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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(
|
|
300
|
+
.where(eq(pk, args.id));
|
|
158
301
|
} else {
|
|
159
|
-
await db.delete(anyTable).where(
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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 {
|
|
25
|
+
export { crud } from "./crud";
|
|
26
|
+
|
|
27
|
+
// Deprecated alias — 하위호환용, 향후 메이저 버전에서 제거 예정
|
|
28
|
+
export { crud as gencowCrud } from "./crud";
|
|
26
29
|
|
|
27
30
|
|
|
28
31
|
|
package/src/rls-db.ts
CHANGED
|
@@ -10,15 +10,20 @@ import type { PgDatabase } from "drizzle-orm/pg-core";
|
|
|
10
10
|
* → PgBouncer transaction 모드에서 안전
|
|
11
11
|
*/
|
|
12
12
|
export function createRlsDb(db: PgDatabase<any, any, any>, userId: string) {
|
|
13
|
-
return {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
13
|
+
return new Proxy(db, {
|
|
14
|
+
get(target, prop, receiver) {
|
|
15
|
+
if (prop === "transaction") {
|
|
16
|
+
return async (callback: any, ...rest: any[]) => {
|
|
17
|
+
return await target.transaction(async (tx) => {
|
|
18
|
+
await tx.execute(
|
|
19
|
+
sql`SELECT set_config('app.current_user_id', ${userId}, true)`
|
|
20
|
+
);
|
|
21
|
+
return await callback(tx);
|
|
22
|
+
}, ...rest as any);
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
const value = Reflect.get(target, prop, receiver);
|
|
26
|
+
return typeof value === "function" ? value.bind(target) : value;
|
|
27
|
+
}
|
|
28
|
+
});
|
|
24
29
|
}
|
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
|
-
|
|
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
|
}
|
package/dist/scoped-db.d.ts
DELETED
|
@@ -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;
|