@gencow/core 0.1.23 → 0.1.25
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 +2 -2
- package/dist/crud.js +225 -208
- package/dist/index.d.ts +7 -3
- package/dist/index.js +4 -1
- package/dist/reactive.js +10 -3
- package/dist/retry.js +1 -1
- package/dist/rls-db.d.ts +2 -2
- package/dist/rls-db.js +1 -5
- package/dist/scheduler.d.ts +2 -0
- package/dist/scheduler.js +16 -6
- package/dist/server.d.ts +0 -1
- package/dist/server.js +0 -1
- package/dist/storage.js +29 -22
- package/dist/v.d.ts +2 -2
- package/dist/workflow-types.d.ts +81 -0
- package/dist/workflow-types.js +12 -0
- package/dist/workflow.d.ts +30 -0
- package/dist/workflow.js +150 -0
- package/dist/workflows-api.d.ts +13 -0
- package/dist/workflows-api.js +321 -0
- package/package.json +46 -42
- package/src/__tests__/auth.test.ts +90 -86
- package/src/__tests__/crons.test.ts +69 -67
- package/src/__tests__/crud-codegen-integration.test.ts +164 -170
- package/src/__tests__/crud-owner-rls.test.ts +308 -301
- package/src/__tests__/crud.test.ts +694 -711
- package/src/__tests__/dist-exports.test.ts +120 -114
- package/src/__tests__/fixtures/basic/auth.ts +16 -16
- package/src/__tests__/fixtures/basic/drizzle.config.ts +1 -4
- package/src/__tests__/fixtures/basic/index.ts +1 -1
- package/src/__tests__/fixtures/basic/schema.ts +1 -1
- package/src/__tests__/fixtures/basic/tasks.ts +4 -4
- package/src/__tests__/fixtures/common/auth-schema.ts +38 -34
- package/src/__tests__/helpers/basic-rls-fixture.ts +80 -78
- package/src/__tests__/helpers/pglite-migrations.ts +2 -5
- package/src/__tests__/helpers/pglite-rls-session.ts +13 -16
- package/src/__tests__/helpers/seed-like-fill.ts +50 -44
- package/src/__tests__/helpers/test-gencow-ctx-rls.ts +4 -7
- package/src/__tests__/httpaction.test.ts +91 -91
- package/src/__tests__/image-optimization.test.ts +570 -574
- package/src/__tests__/load.test.ts +321 -308
- package/src/__tests__/network-sim.test.ts +238 -215
- package/src/__tests__/reactive.test.ts +380 -358
- package/src/__tests__/retry.test.ts +99 -84
- package/src/__tests__/rls-crud-basic.test.ts +172 -245
- package/src/__tests__/rls-crud-no-owner-rls-pglite.test.ts +81 -81
- package/src/__tests__/rls-custom-mutation-handlers.test.ts +47 -94
- package/src/__tests__/rls-custom-query-handlers.test.ts +92 -92
- package/src/__tests__/rls-db-leased-connection.test.ts +2 -6
- package/src/__tests__/rls-session-and-policies.test.ts +181 -199
- package/src/__tests__/scheduler-durable-v2.test.ts +199 -181
- package/src/__tests__/scheduler-durable.test.ts +117 -117
- package/src/__tests__/scheduler-exec.test.ts +258 -246
- package/src/__tests__/scheduler.test.ts +129 -111
- package/src/__tests__/storage.test.ts +282 -269
- package/src/__tests__/tsconfig.json +6 -6
- package/src/__tests__/validator.test.ts +236 -232
- package/src/__tests__/workflow.test.ts +606 -0
- package/src/__tests__/ws-integration.test.ts +223 -218
- package/src/__tests__/ws-scale.test.ts +168 -159
- package/src/auth-config.ts +18 -18
- package/src/auth.ts +106 -106
- package/src/crons.ts +77 -77
- package/src/crud.ts +523 -479
- package/src/index.ts +71 -6
- package/src/reactive.ts +357 -331
- package/src/retry.ts +51 -54
- package/src/rls-db.ts +195 -205
- package/src/rls.ts +33 -36
- package/src/scheduler.ts +237 -211
- package/src/server.ts +0 -1
- package/src/storage.ts +632 -593
- package/src/v.ts +119 -114
- package/src/workflow-types.ts +108 -0
- package/src/workflow.ts +188 -0
- package/src/workflows-api.ts +415 -0
- package/src/db.ts +0 -18
package/src/crud.ts
CHANGED
|
@@ -35,7 +35,26 @@
|
|
|
35
35
|
* 📦 참조: saas-js/drizzle-crud 팩토리 패턴
|
|
36
36
|
*/
|
|
37
37
|
|
|
38
|
-
import {
|
|
38
|
+
import {
|
|
39
|
+
eq,
|
|
40
|
+
ne,
|
|
41
|
+
gt,
|
|
42
|
+
gte,
|
|
43
|
+
lt,
|
|
44
|
+
lte,
|
|
45
|
+
desc,
|
|
46
|
+
asc,
|
|
47
|
+
like,
|
|
48
|
+
ilike,
|
|
49
|
+
inArray,
|
|
50
|
+
notInArray,
|
|
51
|
+
or,
|
|
52
|
+
and,
|
|
53
|
+
count as drizzleCount,
|
|
54
|
+
getTableName,
|
|
55
|
+
getTableColumns,
|
|
56
|
+
type SQL,
|
|
57
|
+
} from "drizzle-orm";
|
|
39
58
|
import type { PgTable, AnyPgColumn } from "drizzle-orm/pg-core";
|
|
40
59
|
import { getTableConfig } from "drizzle-orm/pg-core";
|
|
41
60
|
import { query, mutation } from "./reactive.js";
|
|
@@ -48,41 +67,45 @@ import { getOwnerRlsMeta, registerOwnerRls } from "./rls.js";
|
|
|
48
67
|
const _ownerRlsTables: { tableName: string; columnName: string; readPublic: boolean }[] = [];
|
|
49
68
|
|
|
50
69
|
/** 부트 로그에서 ownerRls 감지 테이블 목록을 반환 */
|
|
51
|
-
export function getOwnerRlsTables(): readonly {
|
|
52
|
-
|
|
70
|
+
export function getOwnerRlsTables(): readonly {
|
|
71
|
+
tableName: string;
|
|
72
|
+
columnName: string;
|
|
73
|
+
readPublic: boolean;
|
|
74
|
+
}[] {
|
|
75
|
+
return _ownerRlsTables;
|
|
53
76
|
}
|
|
54
77
|
|
|
55
78
|
// ─── Types ──────────────────────────────────────────────
|
|
56
79
|
|
|
57
80
|
type CrudOptions<T extends PgTable> = {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
81
|
+
/** 검색 대상 필드 — list의 search 파라미터에 사용 */
|
|
82
|
+
searchFields?: (keyof T["_"]["columns"])[];
|
|
83
|
+
/** 소프트 삭제 컬럼 (e.g. deletedAt) */
|
|
84
|
+
softDelete?: { field: keyof T["_"]["columns"] };
|
|
85
|
+
/** 필터링 허용 필드 — allowedFilters에 없는 키는 무시 (보안) */
|
|
86
|
+
allowedFilters?: (keyof T["_"]["columns"])[];
|
|
87
|
+
/** 기본 페이지 크기 (default: 20) */
|
|
88
|
+
defaultLimit?: number;
|
|
89
|
+
/** 최대 페이지 크기 (default: 100) */
|
|
90
|
+
maxLimit?: number;
|
|
91
|
+
/** 라이프사이클 훅 */
|
|
92
|
+
hooks?: {
|
|
93
|
+
beforeCreate?: (data: any) => any | Promise<any>;
|
|
94
|
+
beforeUpdate?: (data: any) => any | Promise<any>;
|
|
95
|
+
};
|
|
96
|
+
/** true면 인증 없이 접근 가능 (기본: false — Secure by Default) */
|
|
97
|
+
public?: boolean;
|
|
98
|
+
/** true면 mutation 후 realtime push (기본: true) */
|
|
99
|
+
realtime?: boolean;
|
|
100
|
+
/** 키 접두사 오버라이드 (기본: 테이블명) */
|
|
101
|
+
prefix?: string;
|
|
102
|
+
/**
|
|
103
|
+
* 생성할 메서드 목록 (기본: 전체 5개)
|
|
104
|
+
* 지정하면 해당 메서드만 레지스트리에 등록되고 반환됨.
|
|
105
|
+
* api.ts codegen에도 지정된 메서드만 포함됨.
|
|
106
|
+
* @example crud(table, { methods: ['list', 'get', 'create'] })
|
|
107
|
+
*/
|
|
108
|
+
methods?: ("list" | "get" | "create" | "update" | "remove")[];
|
|
86
109
|
};
|
|
87
110
|
|
|
88
111
|
// ─── Helpers ────────────────────────────────────────────
|
|
@@ -93,22 +116,22 @@ type CrudOptions<T extends PgTable> = {
|
|
|
93
116
|
* text/uuid/varchar → v.string()
|
|
94
117
|
*/
|
|
95
118
|
function detectIdType(column: AnyPgColumn) {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
119
|
+
const colType = (column as any).dataType;
|
|
120
|
+
if (colType === "string") return v.string();
|
|
121
|
+
return v.number();
|
|
99
122
|
}
|
|
100
123
|
|
|
101
124
|
// ─── ownerRls 감지 ──────────────────────────────────────
|
|
102
125
|
|
|
103
126
|
interface OwnerMetaResolved {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
127
|
+
/** userId 역할을 하는 Drizzle 컬럼 참조 (WHERE 조건에 사용) */
|
|
128
|
+
column: AnyPgColumn;
|
|
129
|
+
/** userId 컬럼의 DB 이름 (e.g. "user_id") */
|
|
130
|
+
columnName: string;
|
|
131
|
+
/** userId 컬럼의 JS 프로퍼티 이름 (e.g. "userId" — INSERT/UPDATE 데이터 키에 사용) */
|
|
132
|
+
propertyName: string;
|
|
133
|
+
/** true면 SELECT에 userId 필터 생략 (누구나 읽기 가능) */
|
|
134
|
+
readPublic: boolean;
|
|
112
135
|
}
|
|
113
136
|
|
|
114
137
|
/**
|
|
@@ -117,15 +140,15 @@ interface OwnerMetaResolved {
|
|
|
117
140
|
* 매칭 실패 시 columnName을 그대로 반환 (best-effort).
|
|
118
141
|
*/
|
|
119
142
|
function resolvePropertyName(table: PgTable, columnName: string): string {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
}
|
|
125
|
-
} catch {
|
|
126
|
-
// 테스트 환경에서 getTableColumns 실패 가능 — fallback
|
|
143
|
+
try {
|
|
144
|
+
const columns = getTableColumns(table);
|
|
145
|
+
for (const [propName, col] of Object.entries(columns)) {
|
|
146
|
+
if ((col as any).name === columnName) return propName;
|
|
127
147
|
}
|
|
128
|
-
|
|
148
|
+
} catch {
|
|
149
|
+
// 테스트 환경에서 getTableColumns 실패 가능 — fallback
|
|
150
|
+
}
|
|
151
|
+
return columnName;
|
|
129
152
|
}
|
|
130
153
|
|
|
131
154
|
/**
|
|
@@ -139,50 +162,55 @@ function resolvePropertyName(table: PgTable, columnName: string): string {
|
|
|
139
162
|
* ownerRls가 적용된 것으로 간주.
|
|
140
163
|
*/
|
|
141
164
|
function detectOwnerMeta(table: PgTable): OwnerMetaResolved | null {
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
165
|
+
const anyTable = table as any;
|
|
166
|
+
|
|
167
|
+
// 1. WeakMap 레지스트리에서 조회 (가장 정확)
|
|
168
|
+
const meta = getOwnerRlsMeta(table);
|
|
169
|
+
if (meta) {
|
|
170
|
+
// propertyName 역매핑: DB명 "user_id" → JS명 "userId"
|
|
171
|
+
const propName = resolvePropertyName(table, meta.columnName);
|
|
172
|
+
const col = anyTable[propName] as AnyPgColumn | undefined;
|
|
173
|
+
if (col) {
|
|
174
|
+
return {
|
|
175
|
+
column: col,
|
|
176
|
+
columnName: meta.columnName,
|
|
177
|
+
propertyName: propName,
|
|
178
|
+
readPublic: meta.readPublic,
|
|
179
|
+
};
|
|
153
180
|
}
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
} catch {
|
|
182
|
-
// getTableConfig 실패 시 무시 (테스트 환경 등)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// 2. getTableConfig().policies로 pgPolicy 존재 감지 + convention 탐색
|
|
184
|
+
try {
|
|
185
|
+
const config = getTableConfig(table);
|
|
186
|
+
if (config.policies && config.policies.length > 0) {
|
|
187
|
+
// convention: userId 또는 user_id 컬럼 탐색 (JS 프로퍼티명)
|
|
188
|
+
const propName = anyTable["userId"] ? "userId" : anyTable["user_id"] ? "user_id" : null;
|
|
189
|
+
if (propName) {
|
|
190
|
+
const userIdCol = anyTable[propName] as AnyPgColumn;
|
|
191
|
+
const colName = (userIdCol as any).name || "user_id";
|
|
192
|
+
|
|
193
|
+
// 테이블에 메타 등록 (이후 호출에서 WeakMap으로 빠르게 조회)
|
|
194
|
+
registerOwnerRls(table, { columnName: colName, readPublic: false });
|
|
195
|
+
|
|
196
|
+
return { column: userIdCol, columnName: colName, propertyName: propName, readPublic: false };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// S2 방어: pgPolicy가 존재하지만 convention 컬럼(userId/user_id)이 없는 경우
|
|
200
|
+
// ownerRls()를 호출했으나 비표준 컬럼명(e.g. ownerId, authorId)을 사용한 것으로 의심
|
|
201
|
+
// → silent 무시 방지: 보안 경고 출력
|
|
202
|
+
const tblName = getTableName(table);
|
|
203
|
+
console.warn(
|
|
204
|
+
`[crud] ⚠️ Table "${tblName}" has ${config.policies.length} pgPolicy but no userId/user_id column found. ` +
|
|
205
|
+
`ownerRls auto-isolation will NOT be applied. ` +
|
|
206
|
+
`If you used ownerRls(), ensure the column is named 'userId' (JS) / 'user_id' (DB).`,
|
|
207
|
+
);
|
|
183
208
|
}
|
|
209
|
+
} catch {
|
|
210
|
+
// getTableConfig 실패 시 무시 (테스트 환경 등)
|
|
211
|
+
}
|
|
184
212
|
|
|
185
|
-
|
|
213
|
+
return null;
|
|
186
214
|
}
|
|
187
215
|
|
|
188
216
|
// ─── v3 Filter Engine — 재귀적 동적 필터링 ──────────────
|
|
@@ -191,11 +219,11 @@ function detectOwnerMeta(table: PgTable): OwnerMetaResolved | null {
|
|
|
191
219
|
const MAX_FILTER_DEPTH = 5;
|
|
192
220
|
|
|
193
221
|
/** 지원 연산자 목록 */
|
|
194
|
-
const FILTER_OPS = [
|
|
195
|
-
type FilterOp = typeof FILTER_OPS[number];
|
|
222
|
+
const FILTER_OPS = ["eq", "ne", "gt", "gte", "lt", "lte", "in", "nin", "like", "ilike"] as const;
|
|
223
|
+
type FilterOp = (typeof FILTER_OPS)[number];
|
|
196
224
|
|
|
197
225
|
function isValidFilterOp(op: string): op is FilterOp {
|
|
198
|
-
|
|
226
|
+
return (FILTER_OPS as readonly string[]).includes(op);
|
|
199
227
|
}
|
|
200
228
|
|
|
201
229
|
/**
|
|
@@ -203,27 +231,34 @@ function isValidFilterOp(op: string): op is FilterOp {
|
|
|
203
231
|
* 지원하지 않는 op이거나 값 타입이 맞지 않으면 undefined 반환 (묵살).
|
|
204
232
|
*/
|
|
205
233
|
export function applyFilterOp(col: AnyPgColumn, op: FilterOp, value: unknown): SQL | undefined {
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
234
|
+
switch (op) {
|
|
235
|
+
case "eq":
|
|
236
|
+
return eq(col, value);
|
|
237
|
+
case "ne":
|
|
238
|
+
return ne(col, value);
|
|
239
|
+
case "gt":
|
|
240
|
+
return gt(col, value as any);
|
|
241
|
+
case "gte":
|
|
242
|
+
return gte(col, value as any);
|
|
243
|
+
case "lt":
|
|
244
|
+
return lt(col, value as any);
|
|
245
|
+
case "lte":
|
|
246
|
+
return lte(col, value as any);
|
|
247
|
+
case "in":
|
|
248
|
+
if (!Array.isArray(value) || value.length === 0) return undefined;
|
|
249
|
+
return inArray(col, value);
|
|
250
|
+
case "nin":
|
|
251
|
+
if (!Array.isArray(value) || value.length === 0) return undefined;
|
|
252
|
+
return notInArray(col, value);
|
|
253
|
+
case "like":
|
|
254
|
+
if (typeof value !== "string") return undefined;
|
|
255
|
+
return like(col, value);
|
|
256
|
+
case "ilike":
|
|
257
|
+
if (typeof value !== "string") return undefined;
|
|
258
|
+
return ilike(col, value);
|
|
259
|
+
default:
|
|
260
|
+
return undefined;
|
|
261
|
+
}
|
|
227
262
|
}
|
|
228
263
|
|
|
229
264
|
/**
|
|
@@ -249,62 +284,66 @@ export function applyFilterOp(col: AnyPgColumn, op: FilterOp, value: unknown): S
|
|
|
249
284
|
* ```
|
|
250
285
|
*/
|
|
251
286
|
export function parseFilterNode(
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
287
|
+
node: Record<string, unknown>,
|
|
288
|
+
table: any,
|
|
289
|
+
allowedFields?: readonly string[],
|
|
290
|
+
depth: number = 0,
|
|
256
291
|
): SQL | undefined {
|
|
257
|
-
|
|
258
|
-
|
|
292
|
+
// DoS 방어: 재귀 깊이 제한 초과 시 조건 묵살
|
|
293
|
+
if (depth > MAX_FILTER_DEPTH) return undefined;
|
|
294
|
+
|
|
295
|
+
const conditions: SQL[] = [];
|
|
296
|
+
|
|
297
|
+
for (const [key, val] of Object.entries(node)) {
|
|
298
|
+
// ── OR 논리 그룹 ──
|
|
299
|
+
if (key === "OR") {
|
|
300
|
+
if (!Array.isArray(val)) continue; // 잘못된 타입 → 묵살
|
|
301
|
+
const orConds = val
|
|
302
|
+
.filter(
|
|
303
|
+
(child): child is Record<string, unknown> =>
|
|
304
|
+
child != null && typeof child === "object" && !Array.isArray(child),
|
|
305
|
+
)
|
|
306
|
+
.map((child) => parseFilterNode(child, table, allowedFields, depth + 1))
|
|
307
|
+
.filter((c): c is SQL => c !== undefined);
|
|
308
|
+
if (orConds.length > 0) conditions.push(or(...orConds)!);
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
259
311
|
|
|
260
|
-
|
|
312
|
+
// ── AND 논리 그룹 ──
|
|
313
|
+
if (key === "AND") {
|
|
314
|
+
if (!Array.isArray(val)) continue;
|
|
315
|
+
const andConds = val
|
|
316
|
+
.filter(
|
|
317
|
+
(child): child is Record<string, unknown> =>
|
|
318
|
+
child != null && typeof child === "object" && !Array.isArray(child),
|
|
319
|
+
)
|
|
320
|
+
.map((child) => parseFilterNode(child, table, allowedFields, depth + 1))
|
|
321
|
+
.filter((c): c is SQL => c !== undefined);
|
|
322
|
+
if (andConds.length > 0) conditions.push(and(...andConds)!);
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ── 컬럼 필터 ──
|
|
327
|
+
// allowedFields 화이트리스트 검증 (재귀적으로 OR/AND 내부에도 적용됨)
|
|
328
|
+
if (allowedFields && !allowedFields.includes(key)) continue;
|
|
261
329
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
continue;
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
// ── AND 논리 그룹 ──
|
|
276
|
-
if (key === 'AND') {
|
|
277
|
-
if (!Array.isArray(val)) continue;
|
|
278
|
-
const andConds = val
|
|
279
|
-
.filter((child): child is Record<string, unknown> =>
|
|
280
|
-
child != null && typeof child === 'object' && !Array.isArray(child))
|
|
281
|
-
.map((child) => parseFilterNode(child, table, allowedFields, depth + 1))
|
|
282
|
-
.filter((c): c is SQL => c !== undefined);
|
|
283
|
-
if (andConds.length > 0) conditions.push(and(...andConds)!);
|
|
284
|
-
continue;
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
// ── 컬럼 필터 ──
|
|
288
|
-
// allowedFields 화이트리스트 검증 (재귀적으로 OR/AND 내부에도 적용됨)
|
|
289
|
-
if (allowedFields && !allowedFields.includes(key)) continue;
|
|
290
|
-
|
|
291
|
-
const col = table[key] as AnyPgColumn | undefined;
|
|
292
|
-
if (!col) continue;
|
|
293
|
-
|
|
294
|
-
// 명시적 연산자: { op: 'gte', value: 10 }
|
|
295
|
-
if (val != null && typeof val === 'object' && !Array.isArray(val) && 'op' in val) {
|
|
296
|
-
const { op, value } = val as { op: string; value: unknown };
|
|
297
|
-
if (!isValidFilterOp(op)) continue; // 미지원 op → 묵살
|
|
298
|
-
const cond = applyFilterOp(col, op, value);
|
|
299
|
-
if (cond) conditions.push(cond);
|
|
300
|
-
continue;
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
// 암묵적 eq (v2 하위호환): { status: 'active' } → eq(status, 'active')
|
|
304
|
-
conditions.push(eq(col, val));
|
|
330
|
+
const col = table[key] as AnyPgColumn | undefined;
|
|
331
|
+
if (!col) continue;
|
|
332
|
+
|
|
333
|
+
// 명시적 연산자: { op: 'gte', value: 10 }
|
|
334
|
+
if (val != null && typeof val === "object" && !Array.isArray(val) && "op" in val) {
|
|
335
|
+
const { op, value } = val as { op: string; value: unknown };
|
|
336
|
+
if (!isValidFilterOp(op)) continue; // 미지원 op → 묵살
|
|
337
|
+
const cond = applyFilterOp(col, op, value);
|
|
338
|
+
if (cond) conditions.push(cond);
|
|
339
|
+
continue;
|
|
305
340
|
}
|
|
306
341
|
|
|
307
|
-
|
|
342
|
+
// 암묵적 eq (v2 하위호환): { status: 'active' } → eq(status, 'active')
|
|
343
|
+
conditions.push(eq(col, val));
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
return conditions.length > 0 ? and(...conditions) : undefined;
|
|
308
347
|
}
|
|
309
348
|
|
|
310
349
|
// ─── crud — Zero-Boilerplate API Factory ──────────
|
|
@@ -322,363 +361,368 @@ export function parseFilterNode(
|
|
|
322
361
|
* ```
|
|
323
362
|
*/
|
|
324
363
|
export function crud<T extends PgTable>(table: T, options?: CrudOptions<T>) {
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
364
|
+
const anyTable = table as any;
|
|
365
|
+
const tableName: string = getTableName(table); // Drizzle 공식 API
|
|
366
|
+
const prefix = options?.prefix || tableName;
|
|
367
|
+
const isPublic = options?.public ?? false;
|
|
368
|
+
const useRealtime = options?.realtime ?? true;
|
|
369
|
+
const defaultLimit = options?.defaultLimit ?? 20;
|
|
370
|
+
const maxLimit = options?.maxLimit ?? 100;
|
|
371
|
+
const pk = anyTable["id"] as AnyPgColumn;
|
|
372
|
+
|
|
373
|
+
if (!pk) {
|
|
374
|
+
throw new Error(`[crud] Table "${tableName}" must have an 'id' column.`);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// id 타입 자동 감지 (serial → v.number(), text/uuid → v.string())
|
|
378
|
+
const idValidator = detectIdType(pk);
|
|
379
|
+
|
|
380
|
+
// createdAt 컬럼 (정렬용, 없으면 id 사용)
|
|
381
|
+
const createdAtCol = anyTable["createdAt"] as AnyPgColumn | undefined;
|
|
382
|
+
const defaultOrderCol = createdAtCol || pk;
|
|
383
|
+
|
|
384
|
+
// userId 컬럼 (자동 주입용 — ownerRls 미적용 시 기존 동작)
|
|
385
|
+
const userIdCol = anyTable["userId"] as AnyPgColumn | undefined;
|
|
386
|
+
|
|
387
|
+
// ── ownerRls 감지: 2-Layer 방어의 Layer 1 ──
|
|
388
|
+
// ownerRls() 메타데이터가 감지되면 모든 CRUD에 userId 필터 자동 주입
|
|
389
|
+
const ownerMeta = detectOwnerMeta(table);
|
|
390
|
+
|
|
391
|
+
// 부트 로그용 레지스트리 등록 (S1: 중복 방지 — hot-reload/methods 분할 대응)
|
|
392
|
+
if (ownerMeta && !_ownerRlsTables.some((t) => t.tableName === tableName)) {
|
|
393
|
+
_ownerRlsTables.push({
|
|
394
|
+
tableName,
|
|
395
|
+
columnName: ownerMeta.columnName,
|
|
396
|
+
readPublic: ownerMeta.readPublic,
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// B1: public + ownerRls 조합 방어
|
|
401
|
+
// ownerRls가 감지된 테이블에서 public: true + readPublic: false 조합은
|
|
402
|
+
// 보안 의도와 모순 → 경고 출력 + CUD는 ownerRls 보호 유지
|
|
403
|
+
if (ownerMeta && isPublic && !ownerMeta.readPublic) {
|
|
404
|
+
console.warn(
|
|
405
|
+
`[crud] ⚠️ Table "${tableName}": ownerRls detected but public=true. ` +
|
|
406
|
+
`CUD operations will still enforce ownerRls (auth required). ` +
|
|
407
|
+
`Consider removing { public: true } or using ownerRls(col, { read: "public" }).`,
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// ── 내부 헬퍼: WHERE 조건 빌드 (list + count + realtime 공유) ──
|
|
412
|
+
function buildWhereConditions(args: any): SQL | undefined {
|
|
413
|
+
const conditions: SQL[] = [];
|
|
351
414
|
|
|
352
|
-
//
|
|
353
|
-
if (
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
columnName: ownerMeta.columnName,
|
|
357
|
-
readPublic: ownerMeta.readPublic,
|
|
358
|
-
});
|
|
415
|
+
// Soft delete
|
|
416
|
+
if (options?.softDelete) {
|
|
417
|
+
const sdField = anyTable[options.softDelete.field as string] as AnyPgColumn;
|
|
418
|
+
conditions.push(eq(sdField, null as any));
|
|
359
419
|
}
|
|
360
420
|
|
|
361
|
-
//
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
`CUD operations will still enforce ownerRls (auth required). ` +
|
|
368
|
-
`Consider removing { public: true } or using ownerRls(col, { read: "public" }).`
|
|
369
|
-
);
|
|
421
|
+
// Search
|
|
422
|
+
if (args?.search && options?.searchFields?.length) {
|
|
423
|
+
const searchConds = options.searchFields.map((f) =>
|
|
424
|
+
ilike(anyTable[f as string] as AnyPgColumn, `%${args.search}%`),
|
|
425
|
+
);
|
|
426
|
+
conditions.push(or(...searchConds)!);
|
|
370
427
|
}
|
|
371
428
|
|
|
372
|
-
//
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
if (args?.search && options?.searchFields?.length) {
|
|
384
|
-
const searchConds = options.searchFields.map(
|
|
385
|
-
(f) => ilike(anyTable[f as string] as AnyPgColumn, `%${args.search}%`)
|
|
386
|
-
);
|
|
387
|
-
conditions.push(or(...searchConds)!);
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
// Dynamic filters — v3 재귀적 파싱 엔진 (OR/AND 중첩, 연산자 매핑)
|
|
391
|
-
// allowedFilters 설정 시: 화이트리스트 필드만 허용 (재귀 검증)
|
|
392
|
-
// allowedFilters 미설정 시: 필터 전체 무시 (Secure by Default — v2 호환)
|
|
393
|
-
if (args?.filters && typeof args.filters === 'object' && options?.allowedFilters?.length) {
|
|
394
|
-
const allowedFields = options.allowedFilters as unknown as string[];
|
|
395
|
-
const filterCondition = parseFilterNode(
|
|
396
|
-
args.filters as Record<string, unknown>,
|
|
397
|
-
anyTable,
|
|
398
|
-
allowedFields,
|
|
399
|
-
);
|
|
400
|
-
if (filterCondition) conditions.push(filterCondition);
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
return conditions.length > 0 ? and(...conditions) : undefined;
|
|
429
|
+
// Dynamic filters — v3 재귀적 파싱 엔진 (OR/AND 중첩, 연산자 매핑)
|
|
430
|
+
// allowedFilters 설정 시: 화이트리스트 필드만 허용 (재귀 검증)
|
|
431
|
+
// allowedFilters 미설정 시: 필터 전체 무시 (Secure by Default — v2 호환)
|
|
432
|
+
if (args?.filters && typeof args.filters === "object" && options?.allowedFilters?.length) {
|
|
433
|
+
const allowedFields = options.allowedFilters as unknown as string[];
|
|
434
|
+
const filterCondition = parseFilterNode(
|
|
435
|
+
args.filters as Record<string, unknown>,
|
|
436
|
+
anyTable,
|
|
437
|
+
allowedFields,
|
|
438
|
+
);
|
|
439
|
+
if (filterCondition) conditions.push(filterCondition);
|
|
404
440
|
}
|
|
405
441
|
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
if (typeof db?.transaction === "function") {
|
|
409
|
-
return await db.transaction(fn);
|
|
410
|
-
}
|
|
411
|
-
return await fn(db);
|
|
412
|
-
}
|
|
442
|
+
return conditions.length > 0 ? and(...conditions) : undefined;
|
|
443
|
+
}
|
|
413
444
|
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
async function fetchListWithTotal(db: any, whereClause?: SQL, userId?: string) {
|
|
419
|
-
// ownerRls 적용 시 realtime emit에도 userId 필터 추가
|
|
420
|
-
// → 다른 사용자에게 타인 데이터가 push되지 않도록
|
|
421
|
-
let effectiveWhere = whereClause;
|
|
422
|
-
if (ownerMeta && userId && !ownerMeta.readPublic) {
|
|
423
|
-
const ownerFilter = eq(ownerMeta.column, userId);
|
|
424
|
-
effectiveWhere = effectiveWhere ? and(effectiveWhere, ownerFilter) : ownerFilter;
|
|
425
|
-
}
|
|
426
|
-
return await inRlsOrPlainTx(db, async (tx: any) => {
|
|
427
|
-
const data = await tx.select().from(anyTable).where(effectiveWhere).orderBy(desc(defaultOrderCol));
|
|
428
|
-
const countResult = await tx.select({ count: drizzleCount() }).from(anyTable).where(effectiveWhere);
|
|
429
|
-
return { data, total: Number(countResult[0]?.count ?? 0) };
|
|
430
|
-
});
|
|
445
|
+
/** Uses `db.transaction` when present (e.g. createRlsDb); else runs `fn(db)` for test mocks without `.transaction`. */
|
|
446
|
+
async function inRlsOrPlainTx<T>(db: any, fn: (tx: any) => Promise<T>): Promise<T> {
|
|
447
|
+
if (typeof db?.transaction === "function") {
|
|
448
|
+
return await db.transaction(fn);
|
|
431
449
|
}
|
|
450
|
+
return await fn(db);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// ── 내부 헬퍼: list+count 데이터 가져오기 (realtime push용 재사용) ──
|
|
454
|
+
// Runs inside db.transaction so createRlsDb() RLS session vars apply for RLS.
|
|
455
|
+
// ⚠️ limit/offset 없이 전체 SELECT — 대량 데이터 시 성능 저하 주의
|
|
456
|
+
// TODO(P2): realtime emit 시 invalidation 메시지만 전송하고 클라이언트가 re-fetch하는 패턴 검토
|
|
457
|
+
async function fetchListWithTotal(db: any, whereClause?: SQL, userId?: string) {
|
|
458
|
+
// ownerRls 적용 시 realtime emit에도 userId 필터 추가
|
|
459
|
+
// → 다른 사용자에게 타인 데이터가 push되지 않도록
|
|
460
|
+
let effectiveWhere = whereClause;
|
|
461
|
+
if (ownerMeta && userId && !ownerMeta.readPublic) {
|
|
462
|
+
const ownerFilter = eq(ownerMeta.column, userId);
|
|
463
|
+
effectiveWhere = effectiveWhere ? and(effectiveWhere, ownerFilter) : ownerFilter;
|
|
464
|
+
}
|
|
465
|
+
return await inRlsOrPlainTx(db, async (tx: any) => {
|
|
466
|
+
const data = await tx.select().from(anyTable).where(effectiveWhere).orderBy(desc(defaultOrderCol));
|
|
467
|
+
const countResult = await tx.select({ count: drizzleCount() }).from(anyTable).where(effectiveWhere);
|
|
468
|
+
return { data, total: Number(countResult[0]?.count ?? 0) };
|
|
469
|
+
});
|
|
470
|
+
}
|
|
432
471
|
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
472
|
+
// ── methods 필터링: 지정된 메서드만 레지스트리 등록 ──
|
|
473
|
+
// methods 옵션 미지정 시 전체 5개 등록 (하위호환)
|
|
474
|
+
const enabledMethods = new Set(options?.methods ?? ["list", "get", "create", "update", "remove"]);
|
|
436
475
|
|
|
437
|
-
|
|
476
|
+
// ── list ──────────────────────────────────────
|
|
438
477
|
|
|
439
|
-
|
|
478
|
+
const listDef = !enabledMethods.has("list")
|
|
479
|
+
? undefined
|
|
480
|
+
: query(`${prefix}.list`, {
|
|
440
481
|
public: isPublic,
|
|
441
482
|
args: {
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
483
|
+
page: v.optional(v.number()),
|
|
484
|
+
limit: v.optional(v.number()),
|
|
485
|
+
search: v.optional(v.string()),
|
|
486
|
+
orderBy: v.optional(v.string()),
|
|
487
|
+
orderDir: v.optional(v.string()),
|
|
488
|
+
filters: v.optional(v.any()),
|
|
448
489
|
},
|
|
449
490
|
handler: async (ctx: any, args: any) => {
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
});
|
|
491
|
+
// S2: requireAuth 통합 — ownerRls(readPublic=false)이면 인증 필수
|
|
492
|
+
const needsAuth = !isPublic || (ownerMeta && !ownerMeta.readPublic);
|
|
493
|
+
const user = needsAuth ? ctx.auth.requireAuth() : null;
|
|
494
|
+
|
|
495
|
+
const page = Math.max(1, args?.page || 1);
|
|
496
|
+
const limit = Math.min(Math.max(1, args?.limit || defaultLimit), maxLimit);
|
|
497
|
+
const offset = (page - 1) * limit;
|
|
498
|
+
|
|
499
|
+
let whereClause = buildWhereConditions(args);
|
|
500
|
+
|
|
501
|
+
// ── ownerRls Layer 1: list에 userId 필터 주입 ──
|
|
502
|
+
if (ownerMeta && !ownerMeta.readPublic && user) {
|
|
503
|
+
const ownerFilter = eq(ownerMeta.column, user.id);
|
|
504
|
+
whereClause = whereClause ? and(whereClause, ownerFilter) : ownerFilter;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// Order
|
|
508
|
+
let orderByClause;
|
|
509
|
+
if (args?.orderBy && anyTable[args.orderBy]) {
|
|
510
|
+
const col = anyTable[args.orderBy] as AnyPgColumn;
|
|
511
|
+
orderByClause = args.orderDir === "asc" ? asc(col) : desc(col);
|
|
512
|
+
} else {
|
|
513
|
+
orderByClause = desc(defaultOrderCol);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// One transaction: SET LOCAL user id (createRlsDb) + list + count for RLS.
|
|
517
|
+
return await inRlsOrPlainTx(ctx.db, async (tx: any) => {
|
|
518
|
+
const results = await tx
|
|
519
|
+
.select()
|
|
520
|
+
.from(anyTable)
|
|
521
|
+
.where(whereClause)
|
|
522
|
+
.orderBy(orderByClause)
|
|
523
|
+
.limit(limit)
|
|
524
|
+
.offset(offset);
|
|
525
|
+
const countResult = await tx.select({ count: drizzleCount() }).from(anyTable).where(whereClause);
|
|
526
|
+
|
|
527
|
+
return {
|
|
528
|
+
data: results,
|
|
529
|
+
total: Number(countResult[0]?.count ?? 0),
|
|
530
|
+
};
|
|
531
|
+
});
|
|
532
|
+
},
|
|
533
|
+
});
|
|
494
534
|
|
|
495
|
-
|
|
535
|
+
// ── get ───────────────────────────────────────
|
|
496
536
|
|
|
497
|
-
|
|
537
|
+
const getDef = !enabledMethods.has("get")
|
|
538
|
+
? undefined
|
|
539
|
+
: query(`${prefix}.get`, {
|
|
498
540
|
public: isPublic,
|
|
499
541
|
args: { id: idValidator },
|
|
500
542
|
handler: async (ctx: any, args: any) => {
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
543
|
+
// S2: requireAuth 통합
|
|
544
|
+
const needsAuth = !isPublic || (ownerMeta && !ownerMeta.readPublic);
|
|
545
|
+
const user = needsAuth ? ctx.auth.requireAuth() : null;
|
|
504
546
|
|
|
505
|
-
|
|
547
|
+
let whereCond: SQL = eq(pk, args.id);
|
|
506
548
|
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
549
|
+
// ── ownerRls Layer 1: get에 userId 필터 주입 ──
|
|
550
|
+
if (ownerMeta && !ownerMeta.readPublic && user) {
|
|
551
|
+
whereCond = and(whereCond, eq(ownerMeta.column, user.id))!;
|
|
552
|
+
}
|
|
511
553
|
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
554
|
+
if (options?.softDelete) {
|
|
555
|
+
const sdField = anyTable[options.softDelete.field as string] as AnyPgColumn;
|
|
556
|
+
whereCond = and(whereCond, eq(sdField, null as any))!;
|
|
557
|
+
}
|
|
516
558
|
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
}
|
|
522
|
-
|
|
559
|
+
return await inRlsOrPlainTx(ctx.db, async (tx: any) => {
|
|
560
|
+
const [result] = await tx.select().from(anyTable).where(whereCond).limit(1);
|
|
561
|
+
return result ?? null;
|
|
562
|
+
});
|
|
563
|
+
},
|
|
564
|
+
});
|
|
523
565
|
|
|
524
|
-
|
|
566
|
+
// ── create ────────────────────────────────────
|
|
525
567
|
|
|
526
|
-
|
|
568
|
+
const createDef = !enabledMethods.has("create")
|
|
569
|
+
? undefined
|
|
570
|
+
: mutation(`${prefix}.create`, {
|
|
527
571
|
public: isPublic,
|
|
528
572
|
handler: async (ctx: any, args: any) => {
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
if (requestedOwner != null && requestedOwner !== user.id) {
|
|
542
|
-
throw new Error("Forbidden: cannot create resource for another user");
|
|
543
|
-
}
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
// S1 수정: propertyName(JS명)을 키로 사용 — Drizzle insert 호환
|
|
547
|
-
// 사용자 입력을 덮어씀 (보안: 타인 ID로 데이터 생성 방지)
|
|
548
|
-
if (ownerMeta && user) {
|
|
549
|
-
insertData[ownerMeta.propertyName] = user.id;
|
|
550
|
-
} else if (userIdCol && user && !insertData.userId) {
|
|
551
|
-
// ownerRls 미적용 테이블의 기존 자동 주입 동작 유지
|
|
552
|
-
insertData.userId = user.id;
|
|
553
|
-
}
|
|
554
|
-
|
|
555
|
-
// beforeCreate 훅
|
|
556
|
-
if (options?.hooks?.beforeCreate) {
|
|
557
|
-
insertData = await options.hooks.beforeCreate(insertData);
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
const [result] = await inRlsOrPlainTx(ctx.db, async (tx: any) =>
|
|
561
|
-
tx.insert(anyTable).values(insertData).returning()
|
|
562
|
-
);
|
|
563
|
-
|
|
564
|
-
// Realtime push — { data, total } 형태로 emit
|
|
565
|
-
// ownerRls 시 해당 사용자 데이터만 push (타 사용자에게 누출 방지)
|
|
566
|
-
// S5: requireAuth로 확실한 userId 획득 (getUserIdentity null 방지)
|
|
567
|
-
if (useRealtime && enabledMethods.has('list')) {
|
|
568
|
-
const currentUserId = user?.id;
|
|
569
|
-
const listResult = await fetchListWithTotal(ctx.db, undefined, currentUserId);
|
|
570
|
-
ctx.realtime.emit(`${prefix}.list`, listResult);
|
|
573
|
+
// ownerRls 테이블은 항상 인증 필수 (보안 우선)
|
|
574
|
+
const user = ownerMeta || !isPublic ? ctx.auth.requireAuth() : null;
|
|
575
|
+
|
|
576
|
+
let insertData = { ...args };
|
|
577
|
+
|
|
578
|
+
// ── ownerRls Layer 1: create 시 userId 강제 주입 ──
|
|
579
|
+
// 명시적으로 타인 userId를 보낸 경우는 즉시 차단 (fail-closed).
|
|
580
|
+
if (ownerMeta && user) {
|
|
581
|
+
const requestedOwner =
|
|
582
|
+
insertData[ownerMeta.propertyName] ?? insertData[ownerMeta.columnName] ?? insertData.userId;
|
|
583
|
+
if (requestedOwner != null && requestedOwner !== user.id) {
|
|
584
|
+
throw new Error("Forbidden: cannot create resource for another user");
|
|
571
585
|
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// S1 수정: propertyName(JS명)을 키로 사용 — Drizzle insert 호환
|
|
589
|
+
// 사용자 입력을 덮어씀 (보안: 타인 ID로 데이터 생성 방지)
|
|
590
|
+
if (ownerMeta && user) {
|
|
591
|
+
insertData[ownerMeta.propertyName] = user.id;
|
|
592
|
+
} else if (userIdCol && user && !insertData.userId) {
|
|
593
|
+
// ownerRls 미적용 테이블의 기존 자동 주입 동작 유지
|
|
594
|
+
insertData.userId = user.id;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// beforeCreate 훅
|
|
598
|
+
if (options?.hooks?.beforeCreate) {
|
|
599
|
+
insertData = await options.hooks.beforeCreate(insertData);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
const [result] = await inRlsOrPlainTx(ctx.db, async (tx: any) =>
|
|
603
|
+
tx.insert(anyTable).values(insertData).returning(),
|
|
604
|
+
);
|
|
605
|
+
|
|
606
|
+
// Realtime push — { data, total } 형태로 emit
|
|
607
|
+
// ownerRls 시 해당 사용자 데이터만 push (타 사용자에게 누출 방지)
|
|
608
|
+
// S5: requireAuth로 확실한 userId 획득 (getUserIdentity null 방지)
|
|
609
|
+
if (useRealtime && enabledMethods.has("list")) {
|
|
610
|
+
const currentUserId = user?.id;
|
|
611
|
+
const listResult = await fetchListWithTotal(ctx.db, undefined, currentUserId);
|
|
612
|
+
ctx.realtime.emit(`${prefix}.list`, listResult);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
return result;
|
|
616
|
+
},
|
|
617
|
+
});
|
|
572
618
|
|
|
573
|
-
|
|
574
|
-
}
|
|
575
|
-
});
|
|
576
|
-
|
|
577
|
-
// ── update ────────────────────────────────────
|
|
619
|
+
// ── update ────────────────────────────────────
|
|
578
620
|
|
|
579
|
-
|
|
621
|
+
const updateDef = !enabledMethods.has("update")
|
|
622
|
+
? undefined
|
|
623
|
+
: mutation(`${prefix}.update`, {
|
|
580
624
|
public: isPublic,
|
|
581
625
|
handler: async (ctx: any, args: any) => {
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
}
|
|
600
|
-
}
|
|
601
|
-
|
|
602
|
-
// updatedAt 자동 갱신
|
|
603
|
-
if (anyTable["updatedAt"]) {
|
|
604
|
-
updateData.updatedAt = new Date();
|
|
626
|
+
// ownerRls 테이블은 항상 인증 필수
|
|
627
|
+
const user = ownerMeta || !isPublic ? ctx.auth.requireAuth() : null;
|
|
628
|
+
|
|
629
|
+
const { id, ...updates } = args;
|
|
630
|
+
|
|
631
|
+
let updateData = { ...updates };
|
|
632
|
+
|
|
633
|
+
// ── ownerRls Layer 1: update 시 userId 변경 차단 + 소유자 검증 ──
|
|
634
|
+
let updateWhere: SQL = eq(pk, id);
|
|
635
|
+
if (ownerMeta && user) {
|
|
636
|
+
// WHERE id = ? AND user_id = ? (타인 데이터 수정 불가)
|
|
637
|
+
updateWhere = and(eq(pk, id), eq(ownerMeta.column, user.id))!;
|
|
638
|
+
// S1 수정: propertyName(JS명)으로 userId 변경 시도 차단
|
|
639
|
+
delete updateData[ownerMeta.propertyName];
|
|
640
|
+
// DB명도 방어적으로 삭제 (만약 클라이언트가 DB명으로 보낸 경우)
|
|
641
|
+
if (ownerMeta.propertyName !== ownerMeta.columnName) {
|
|
642
|
+
delete updateData[ownerMeta.columnName];
|
|
605
643
|
}
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// updatedAt 자동 갱신
|
|
647
|
+
if (anyTable["updatedAt"]) {
|
|
648
|
+
updateData.updatedAt = new Date();
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// beforeUpdate 훅
|
|
652
|
+
if (options?.hooks?.beforeUpdate) {
|
|
653
|
+
updateData = await options.hooks.beforeUpdate(updateData);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
const [result] = await inRlsOrPlainTx(ctx.db, async (tx: any) =>
|
|
657
|
+
tx.update(anyTable).set(updateData).where(updateWhere).returning(),
|
|
658
|
+
);
|
|
659
|
+
|
|
660
|
+
// Realtime push (list + get 양쪽) — { data, total } 형태
|
|
661
|
+
// S5: ownerRls 시 user.id 사용 (getUserIdentity null 방지)
|
|
662
|
+
if (useRealtime) {
|
|
663
|
+
const currentUserId = ownerMeta ? user?.id : undefined;
|
|
664
|
+
if (enabledMethods.has("list")) {
|
|
665
|
+
const listResult = await fetchListWithTotal(ctx.db, undefined, currentUserId);
|
|
666
|
+
ctx.realtime.emit(`${prefix}.list`, listResult);
|
|
610
667
|
}
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
tx.update(anyTable)
|
|
614
|
-
.set(updateData)
|
|
615
|
-
.where(updateWhere)
|
|
616
|
-
.returning()
|
|
617
|
-
);
|
|
618
|
-
|
|
619
|
-
// Realtime push (list + get 양쪽) — { data, total } 형태
|
|
620
|
-
// S5: ownerRls 시 user.id 사용 (getUserIdentity null 방지)
|
|
621
|
-
if (useRealtime) {
|
|
622
|
-
const currentUserId = ownerMeta ? user?.id : undefined;
|
|
623
|
-
if (enabledMethods.has('list')) {
|
|
624
|
-
const listResult = await fetchListWithTotal(ctx.db, undefined, currentUserId);
|
|
625
|
-
ctx.realtime.emit(`${prefix}.list`, listResult);
|
|
626
|
-
}
|
|
627
|
-
if (enabledMethods.has('get')) {
|
|
628
|
-
ctx.realtime.emit(`${prefix}.get`, result);
|
|
629
|
-
}
|
|
668
|
+
if (enabledMethods.has("get")) {
|
|
669
|
+
ctx.realtime.emit(`${prefix}.get`, result);
|
|
630
670
|
}
|
|
671
|
+
}
|
|
631
672
|
|
|
632
|
-
|
|
633
|
-
}
|
|
634
|
-
|
|
673
|
+
return result;
|
|
674
|
+
},
|
|
675
|
+
});
|
|
635
676
|
|
|
636
|
-
|
|
677
|
+
// ── remove ────────────────────────────────────
|
|
637
678
|
|
|
638
|
-
|
|
679
|
+
const removeDef = !enabledMethods.has("remove")
|
|
680
|
+
? undefined
|
|
681
|
+
: mutation(`${prefix}.remove`, {
|
|
639
682
|
public: isPublic,
|
|
640
683
|
handler: async (ctx: any, args: any) => {
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
// ── ownerRls Layer 1: remove 시 소유자 검증 ──
|
|
645
|
-
let deleteWhere: SQL = eq(pk, args.id);
|
|
646
|
-
if (ownerMeta && user) {
|
|
647
|
-
// WHERE id = ? AND user_id = ? (타인 데이터 삭제 불가)
|
|
648
|
-
deleteWhere = and(eq(pk, args.id), eq(ownerMeta.column, user.id))!;
|
|
649
|
-
}
|
|
684
|
+
// ownerRls 테이블은 항상 인증 필수
|
|
685
|
+
const user = ownerMeta || !isPublic ? ctx.auth.requireAuth() : null;
|
|
650
686
|
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
ctx.realtime.emit(`${prefix}.list`, listResult);
|
|
687
|
+
// ── ownerRls Layer 1: remove 시 소유자 검증 ──
|
|
688
|
+
let deleteWhere: SQL = eq(pk, args.id);
|
|
689
|
+
if (ownerMeta && user) {
|
|
690
|
+
// WHERE id = ? AND user_id = ? (타인 데이터 삭제 불가)
|
|
691
|
+
deleteWhere = and(eq(pk, args.id), eq(ownerMeta.column, user.id))!;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
await inRlsOrPlainTx(ctx.db, async (tx: any) => {
|
|
695
|
+
if (options?.softDelete) {
|
|
696
|
+
const sdField = options.softDelete.field as string;
|
|
697
|
+
await tx
|
|
698
|
+
.update(anyTable)
|
|
699
|
+
.set({ [sdField]: new Date() } as any)
|
|
700
|
+
.where(deleteWhere);
|
|
701
|
+
} else {
|
|
702
|
+
await tx.delete(anyTable).where(deleteWhere);
|
|
668
703
|
}
|
|
704
|
+
});
|
|
669
705
|
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
706
|
+
// Realtime push — { data, total } 형태
|
|
707
|
+
// S5: ownerRls 시 user.id 사용
|
|
708
|
+
if (useRealtime && enabledMethods.has("list")) {
|
|
709
|
+
const currentUserId = ownerMeta ? user?.id : undefined;
|
|
710
|
+
const listResult = await fetchListWithTotal(ctx.db, undefined, currentUserId);
|
|
711
|
+
ctx.realtime.emit(`${prefix}.list`, listResult);
|
|
712
|
+
}
|
|
673
713
|
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
714
|
+
return { success: true };
|
|
715
|
+
},
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
// 반환 객체는 항상 5개 키를 가지지만, 비활성 메서드는 undefined.
|
|
719
|
+
// 사용자가 destructure 시 undefined를 받으면 export하지 않으므로
|
|
720
|
+
// codegen의 레지스트리에 등록되지 않아 api.ts 불일치가 해소됨.
|
|
721
|
+
return {
|
|
722
|
+
list: listDef,
|
|
723
|
+
get: getDef,
|
|
724
|
+
create: createDef,
|
|
725
|
+
update: updateDef,
|
|
726
|
+
remove: removeDef,
|
|
727
|
+
};
|
|
684
728
|
}
|