@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.
Files changed (77) hide show
  1. package/dist/crud.d.ts +2 -2
  2. package/dist/crud.js +225 -208
  3. package/dist/index.d.ts +7 -3
  4. package/dist/index.js +4 -1
  5. package/dist/reactive.js +10 -3
  6. package/dist/retry.js +1 -1
  7. package/dist/rls-db.d.ts +2 -2
  8. package/dist/rls-db.js +1 -5
  9. package/dist/scheduler.d.ts +2 -0
  10. package/dist/scheduler.js +16 -6
  11. package/dist/server.d.ts +0 -1
  12. package/dist/server.js +0 -1
  13. package/dist/storage.js +29 -22
  14. package/dist/v.d.ts +2 -2
  15. package/dist/workflow-types.d.ts +81 -0
  16. package/dist/workflow-types.js +12 -0
  17. package/dist/workflow.d.ts +30 -0
  18. package/dist/workflow.js +150 -0
  19. package/dist/workflows-api.d.ts +13 -0
  20. package/dist/workflows-api.js +321 -0
  21. package/package.json +46 -42
  22. package/src/__tests__/auth.test.ts +90 -86
  23. package/src/__tests__/crons.test.ts +69 -67
  24. package/src/__tests__/crud-codegen-integration.test.ts +164 -170
  25. package/src/__tests__/crud-owner-rls.test.ts +308 -301
  26. package/src/__tests__/crud.test.ts +694 -711
  27. package/src/__tests__/dist-exports.test.ts +120 -114
  28. package/src/__tests__/fixtures/basic/auth.ts +16 -16
  29. package/src/__tests__/fixtures/basic/drizzle.config.ts +1 -4
  30. package/src/__tests__/fixtures/basic/index.ts +1 -1
  31. package/src/__tests__/fixtures/basic/schema.ts +1 -1
  32. package/src/__tests__/fixtures/basic/tasks.ts +4 -4
  33. package/src/__tests__/fixtures/common/auth-schema.ts +38 -34
  34. package/src/__tests__/helpers/basic-rls-fixture.ts +80 -78
  35. package/src/__tests__/helpers/pglite-migrations.ts +2 -5
  36. package/src/__tests__/helpers/pglite-rls-session.ts +13 -16
  37. package/src/__tests__/helpers/seed-like-fill.ts +50 -44
  38. package/src/__tests__/helpers/test-gencow-ctx-rls.ts +4 -7
  39. package/src/__tests__/httpaction.test.ts +91 -91
  40. package/src/__tests__/image-optimization.test.ts +570 -574
  41. package/src/__tests__/load.test.ts +321 -308
  42. package/src/__tests__/network-sim.test.ts +238 -215
  43. package/src/__tests__/reactive.test.ts +380 -358
  44. package/src/__tests__/retry.test.ts +99 -84
  45. package/src/__tests__/rls-crud-basic.test.ts +172 -245
  46. package/src/__tests__/rls-crud-no-owner-rls-pglite.test.ts +81 -81
  47. package/src/__tests__/rls-custom-mutation-handlers.test.ts +47 -94
  48. package/src/__tests__/rls-custom-query-handlers.test.ts +92 -92
  49. package/src/__tests__/rls-db-leased-connection.test.ts +2 -6
  50. package/src/__tests__/rls-session-and-policies.test.ts +181 -199
  51. package/src/__tests__/scheduler-durable-v2.test.ts +199 -181
  52. package/src/__tests__/scheduler-durable.test.ts +117 -117
  53. package/src/__tests__/scheduler-exec.test.ts +258 -246
  54. package/src/__tests__/scheduler.test.ts +129 -111
  55. package/src/__tests__/storage.test.ts +282 -269
  56. package/src/__tests__/tsconfig.json +6 -6
  57. package/src/__tests__/validator.test.ts +236 -232
  58. package/src/__tests__/workflow.test.ts +606 -0
  59. package/src/__tests__/ws-integration.test.ts +223 -218
  60. package/src/__tests__/ws-scale.test.ts +168 -159
  61. package/src/auth-config.ts +18 -18
  62. package/src/auth.ts +106 -106
  63. package/src/crons.ts +77 -77
  64. package/src/crud.ts +523 -479
  65. package/src/index.ts +71 -6
  66. package/src/reactive.ts +357 -331
  67. package/src/retry.ts +51 -54
  68. package/src/rls-db.ts +195 -205
  69. package/src/rls.ts +33 -36
  70. package/src/scheduler.ts +237 -211
  71. package/src/server.ts +0 -1
  72. package/src/storage.ts +632 -593
  73. package/src/v.ts +119 -114
  74. package/src/workflow-types.ts +108 -0
  75. package/src/workflow.ts +188 -0
  76. package/src/workflows-api.ts +415 -0
  77. 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 { eq, ne, gt, gte, lt, lte, desc, asc, like, ilike, inArray, notInArray, or, and, count as drizzleCount, getTableName, getTableColumns, type SQL } from "drizzle-orm";
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 { tableName: string; columnName: string; readPublic: boolean }[] {
52
- return _ownerRlsTables;
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
- /** 검색 대상 필드 — list의 search 파라미터에 사용 */
59
- searchFields?: (keyof T["_"]["columns"])[];
60
- /** 소프트 삭제 컬럼 (e.g. deletedAt) */
61
- softDelete?: { field: keyof T["_"]["columns"] };
62
- /** 필터링 허용 필드 — allowedFilters에 없는 키는 무시 (보안) */
63
- allowedFilters?: (keyof T["_"]["columns"])[];
64
- /** 기본 페이지 크기 (default: 20) */
65
- defaultLimit?: number;
66
- /** 최대 페이지 크기 (default: 100) */
67
- maxLimit?: number;
68
- /** 라이프사이클 훅 */
69
- hooks?: {
70
- beforeCreate?: (data: any) => any | Promise<any>;
71
- beforeUpdate?: (data: any) => any | Promise<any>;
72
- };
73
- /** true면 인증 없이 접근 가능 (기본: false — Secure by Default) */
74
- public?: boolean;
75
- /** true면 mutation 후 realtime push (기본: true) */
76
- realtime?: boolean;
77
- /** 키 접두사 오버라이드 (기본: 테이블명) */
78
- prefix?: string;
79
- /**
80
- * 생성할 메서드 목록 (기본: 전체 5개)
81
- * 지정하면 해당 메서드만 레지스트리에 등록되고 반환됨.
82
- * api.ts codegen에도 지정된 메서드만 포함됨.
83
- * @example crud(table, { methods: ['list', 'get', 'create'] })
84
- */
85
- methods?: ('list' | 'get' | 'create' | 'update' | 'remove')[];
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
- const colType = (column as any).dataType;
97
- if (colType === "string") return v.string();
98
- return v.number();
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
- /** userId 역할을 하는 Drizzle 컬럼 참조 (WHERE 조건에 사용) */
105
- column: AnyPgColumn;
106
- /** userId 컬럼의 DB 이름 (e.g. "user_id") */
107
- columnName: string;
108
- /** userId 컬럼의 JS 프로퍼티 이름 (e.g. "userId" — INSERT/UPDATE 데이터 키에 사용) */
109
- propertyName: string;
110
- /** true면 SELECT에 userId 필터 생략 (누구나 읽기 가능) */
111
- readPublic: boolean;
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
- try {
121
- const columns = getTableColumns(table);
122
- for (const [propName, col] of Object.entries(columns)) {
123
- if ((col as any).name === columnName) return propName;
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
- return columnName;
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
- const anyTable = table as any;
143
-
144
- // 1. WeakMap 레지스트리에서 조회 (가장 정확)
145
- const meta = getOwnerRlsMeta(table);
146
- if (meta) {
147
- // propertyName 역매핑: DB명 "user_id" → JS명 "userId"
148
- const propName = resolvePropertyName(table, meta.columnName);
149
- const col = anyTable[propName] as AnyPgColumn | undefined;
150
- if (col) {
151
- return { column: col, columnName: meta.columnName, propertyName: propName, readPublic: meta.readPublic };
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
- // 2. getTableConfig().policies로 pgPolicy 존재 감지 + convention 탐색
156
- try {
157
- const config = getTableConfig(table);
158
- if (config.policies && config.policies.length > 0) {
159
- // convention: userId 또는 user_id 컬럼 탐색 (JS 프로퍼티명)
160
- const propName = anyTable["userId"] ? "userId" : anyTable["user_id"] ? "user_id" : null;
161
- if (propName) {
162
- const userIdCol = anyTable[propName] as AnyPgColumn;
163
- const colName = (userIdCol as any).name || "user_id";
164
-
165
- // 테이블에 메타 등록 (이후 호출에서 WeakMap으로 빠르게 조회)
166
- registerOwnerRls(table, { columnName: colName, readPublic: false });
167
-
168
- return { column: userIdCol, columnName: colName, propertyName: propName, readPublic: false };
169
- }
170
-
171
- // S2 방어: pgPolicy가 존재하지만 convention 컬럼(userId/user_id)이 없는 경우
172
- // ownerRls()를 호출했으나 비표준 컬럼명(e.g. ownerId, authorId) 사용한 것으로 의심
173
- // silent 무시 방지: 보안 경고 출력
174
- const tblName = getTableName(table);
175
- console.warn(
176
- `[crud] ⚠️ Table "${tblName}" has ${config.policies.length} pgPolicy but no userId/user_id column found. ` +
177
- `ownerRls auto-isolation will NOT be applied. ` +
178
- `If you used ownerRls(), ensure the column is named 'userId' (JS) / 'user_id' (DB).`
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
- return null;
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 = ['eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'in', 'nin', 'like', 'ilike'] as const;
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
- return (FILTER_OPS as readonly string[]).includes(op);
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
- switch (op) {
207
- case 'eq': return eq(col, value);
208
- case 'ne': return ne(col, value);
209
- case 'gt': return gt(col, value as any);
210
- case 'gte': return gte(col, value as any);
211
- case 'lt': return lt(col, value as any);
212
- case 'lte': return lte(col, value as any);
213
- case 'in':
214
- if (!Array.isArray(value) || value.length === 0) return undefined;
215
- return inArray(col, value);
216
- case 'nin':
217
- if (!Array.isArray(value) || value.length === 0) return undefined;
218
- return notInArray(col, value);
219
- case 'like':
220
- if (typeof value !== 'string') return undefined;
221
- return like(col, value);
222
- case 'ilike':
223
- if (typeof value !== 'string') return undefined;
224
- return ilike(col, value);
225
- default: return undefined;
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
- node: Record<string, unknown>,
253
- table: any,
254
- allowedFields?: readonly string[],
255
- depth: number = 0,
287
+ node: Record<string, unknown>,
288
+ table: any,
289
+ allowedFields?: readonly string[],
290
+ depth: number = 0,
256
291
  ): SQL | undefined {
257
- // DoS 방어: 재귀 깊이 제한 초과 시 조건 묵살
258
- if (depth > MAX_FILTER_DEPTH) return undefined;
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
- const conditions: SQL[] = [];
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
- for (const [key, val] of Object.entries(node)) {
263
- // ── OR 논리 그룹 ──
264
- if (key === 'OR') {
265
- if (!Array.isArray(val)) continue; // 잘못된 타입 묵살
266
- const orConds = val
267
- .filter((child): child is Record<string, unknown> =>
268
- child != null && typeof child === 'object' && !Array.isArray(child))
269
- .map((child) => parseFilterNode(child, table, allowedFields, depth + 1))
270
- .filter((c): c is SQL => c !== undefined);
271
- if (orConds.length > 0) conditions.push(or(...orConds)!);
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
- return conditions.length > 0 ? and(...conditions) : undefined;
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
- const anyTable = table as any;
326
- const tableName: string = getTableName(table); // Drizzle 공식 API
327
- const prefix = options?.prefix || tableName;
328
- const isPublic = options?.public ?? false;
329
- const useRealtime = options?.realtime ?? true;
330
- const defaultLimit = options?.defaultLimit ?? 20;
331
- const maxLimit = options?.maxLimit ?? 100;
332
- const pk = anyTable["id"] as AnyPgColumn;
333
-
334
- if (!pk) {
335
- throw new Error(`[crud] Table "${tableName}" must have an 'id' column.`);
336
- }
337
-
338
- // id 타입 자동 감지 (serial → v.number(), text/uuid → v.string())
339
- const idValidator = detectIdType(pk);
340
-
341
- // createdAt 컬럼 (정렬용, 없으면 id 사용)
342
- const createdAtCol = anyTable["createdAt"] as AnyPgColumn | undefined;
343
- const defaultOrderCol = createdAtCol || pk;
344
-
345
- // userId 컬럼 (자동 주입용 — ownerRls 미적용 시 기존 동작)
346
- const userIdCol = anyTable["userId"] as AnyPgColumn | undefined;
347
-
348
- // ── ownerRls 감지: 2-Layer 방어의 Layer 1 ──
349
- // ownerRls() 메타데이터가 감지되면 모든 CRUD에 userId 필터 자동 주입
350
- const ownerMeta = detectOwnerMeta(table);
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
- // 부트 로그용 레지스트리 등록 (S1: 중복 방지 — hot-reload/methods 분할 대응)
353
- if (ownerMeta && !_ownerRlsTables.some(t => t.tableName === tableName)) {
354
- _ownerRlsTables.push({
355
- tableName,
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
- // B1: public + ownerRls 조합 방어
362
- // ownerRls가 감지된 테이블에서 public: true + readPublic: false 조합은
363
- // 보안 의도와 모순 → 경고 출력 + CUD는 ownerRls 보호 유지
364
- if (ownerMeta && isPublic && !ownerMeta.readPublic) {
365
- console.warn(
366
- `[crud] ⚠️ Table "${tableName}": ownerRls detected but public=true. ` +
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
- // ── 내부 헬퍼: WHERE 조건 빌드 (list + count + realtime 공유) ──
373
- function buildWhereConditions(args: any): SQL | undefined {
374
- const conditions: SQL[] = [];
375
-
376
- // Soft delete
377
- if (options?.softDelete) {
378
- const sdField = anyTable[options.softDelete.field as string] as AnyPgColumn;
379
- conditions.push(eq(sdField, null as any));
380
- }
381
-
382
- // Search
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
- /** Uses `db.transaction` when present (e.g. createRlsDb); else runs `fn(db)` for test mocks without `.transaction`. */
407
- async function inRlsOrPlainTx<T>(db: any, fn: (tx: any) => Promise<T>): Promise<T> {
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
- // ── 내부 헬퍼: list+count 데이터 가져오기 (realtime push용 재사용) ──
415
- // Runs inside db.transaction so createRlsDb() RLS session vars apply for RLS.
416
- // ⚠️ limit/offset 없이 전체 SELECT — 대량 데이터 시 성능 저하 주의
417
- // TODO(P2): realtime emit 시 invalidation 메시지만 전송하고 클라이언트가 re-fetch하는 패턴 검토
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
- // ── methods 필터링: 지정된 메서드만 레지스트리 등록 ──
434
- // methods 옵션 미지정 시 전체 5개 등록 (하위호환)
435
- const enabledMethods = new Set(options?.methods ?? ['list', 'get', 'create', 'update', 'remove']);
472
+ // ── methods 필터링: 지정된 메서드만 레지스트리 등록 ──
473
+ // methods 옵션 미지정 시 전체 5개 등록 (하위호환)
474
+ const enabledMethods = new Set(options?.methods ?? ["list", "get", "create", "update", "remove"]);
436
475
 
437
- // ── list ──────────────────────────────────────
476
+ // ── list ──────────────────────────────────────
438
477
 
439
- const listDef = !enabledMethods.has('list') ? undefined : query(`${prefix}.list`, {
478
+ const listDef = !enabledMethods.has("list")
479
+ ? undefined
480
+ : query(`${prefix}.list`, {
440
481
  public: isPublic,
441
482
  args: {
442
- page: v.optional(v.number()),
443
- limit: v.optional(v.number()),
444
- search: v.optional(v.string()),
445
- orderBy: v.optional(v.string()),
446
- orderDir: v.optional(v.string()),
447
- filters: v.optional(v.any()),
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
- // S2: requireAuth 통합 — ownerRls(readPublic=false)이면 인증 필수
451
- const needsAuth = !isPublic || (ownerMeta && !ownerMeta.readPublic);
452
- const user = needsAuth ? ctx.auth.requireAuth() : null;
453
-
454
- const page = Math.max(1, args?.page || 1);
455
- const limit = Math.min(Math.max(1, args?.limit || defaultLimit), maxLimit);
456
- const offset = (page - 1) * limit;
457
-
458
- let whereClause = buildWhereConditions(args);
459
-
460
- // ── ownerRls Layer 1: list에 userId 필터 주입 ──
461
- if (ownerMeta && !ownerMeta.readPublic && user) {
462
- const ownerFilter = eq(ownerMeta.column, user.id);
463
- whereClause = whereClause ? and(whereClause, ownerFilter) : ownerFilter;
464
- }
465
-
466
- // Order
467
- let orderByClause;
468
- if (args?.orderBy && anyTable[args.orderBy]) {
469
- const col = anyTable[args.orderBy] as AnyPgColumn;
470
- orderByClause = args.orderDir === "asc" ? asc(col) : desc(col);
471
- } else {
472
- orderByClause = desc(defaultOrderCol);
473
- }
474
-
475
- // One transaction: SET LOCAL user id (createRlsDb) + list + count for RLS.
476
- return await inRlsOrPlainTx(ctx.db, async (tx: any) => {
477
- const results = await tx.select()
478
- .from(anyTable)
479
- .where(whereClause)
480
- .orderBy(orderByClause)
481
- .limit(limit)
482
- .offset(offset);
483
- const countResult = await tx.select({ count: drizzleCount() })
484
- .from(anyTable)
485
- .where(whereClause);
486
-
487
- return {
488
- data: results,
489
- total: Number(countResult[0]?.count ?? 0),
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
- // ── get ───────────────────────────────────────
535
+ // ── get ───────────────────────────────────────
496
536
 
497
- const getDef = !enabledMethods.has('get') ? undefined : query(`${prefix}.get`, {
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
- // S2: requireAuth 통합
502
- const needsAuth = !isPublic || (ownerMeta && !ownerMeta.readPublic);
503
- const user = needsAuth ? ctx.auth.requireAuth() : null;
543
+ // S2: requireAuth 통합
544
+ const needsAuth = !isPublic || (ownerMeta && !ownerMeta.readPublic);
545
+ const user = needsAuth ? ctx.auth.requireAuth() : null;
504
546
 
505
- let whereCond: SQL = eq(pk, args.id);
547
+ let whereCond: SQL = eq(pk, args.id);
506
548
 
507
- // ── ownerRls Layer 1: get에 userId 필터 주입 ──
508
- if (ownerMeta && !ownerMeta.readPublic && user) {
509
- whereCond = and(whereCond, eq(ownerMeta.column, user.id))!;
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
- if (options?.softDelete) {
513
- const sdField = anyTable[options.softDelete.field as string] as AnyPgColumn;
514
- whereCond = and(whereCond, eq(sdField, null as any))!;
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
- return await inRlsOrPlainTx(ctx.db, async (tx: any) => {
518
- const [result] = await tx.select().from(anyTable).where(whereCond).limit(1);
519
- return result ?? null;
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
- // ── create ────────────────────────────────────
566
+ // ── create ────────────────────────────────────
525
567
 
526
- const createDef = !enabledMethods.has('create') ? undefined : mutation(`${prefix}.create`, {
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
- // ownerRls 테이블은 항상 인증 필수 (보안 우선)
530
- const user = (ownerMeta || !isPublic) ? ctx.auth.requireAuth() : null;
531
-
532
- let insertData = { ...args };
533
-
534
- // ── ownerRls Layer 1: create 시 userId 강제 주입 ──
535
- // 명시적으로 타인 userId를 보낸 경우는 즉시 차단 (fail-closed).
536
- if (ownerMeta && user) {
537
- const requestedOwner =
538
- insertData[ownerMeta.propertyName] ??
539
- insertData[ownerMeta.columnName] ??
540
- insertData.userId;
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
- return result;
574
- }
575
- });
576
-
577
- // ── update ────────────────────────────────────
619
+ // ── update ────────────────────────────────────
578
620
 
579
- const updateDef = !enabledMethods.has('update') ? undefined : mutation(`${prefix}.update`, {
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
- // ownerRls 테이블은 항상 인증 필수
583
- const user = (ownerMeta || !isPublic) ? ctx.auth.requireAuth() : null;
584
-
585
- const { id, ...updates } = args;
586
-
587
- let updateData = { ...updates };
588
-
589
- // ── ownerRls Layer 1: update 시 userId 변경 차단 + 소유자 검증 ──
590
- let updateWhere: SQL = eq(pk, id);
591
- if (ownerMeta && user) {
592
- // WHERE id = ? AND user_id = ? (타인 데이터 수정 불가)
593
- updateWhere = and(eq(pk, id), eq(ownerMeta.column, user.id))!;
594
- // S1 수정: propertyName(JS명)으로 userId 변경 시도 차단
595
- delete updateData[ownerMeta.propertyName];
596
- // DB명도 방어적으로 삭제 (만약 클라이언트가 DB명으로 보낸 경우)
597
- if (ownerMeta.propertyName !== ownerMeta.columnName) {
598
- delete updateData[ownerMeta.columnName];
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
- // beforeUpdate 훅
608
- if (options?.hooks?.beforeUpdate) {
609
- updateData = await options.hooks.beforeUpdate(updateData);
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
- const [result] = await inRlsOrPlainTx(ctx.db, async (tx: any) =>
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
- return result;
633
- }
634
- });
673
+ return result;
674
+ },
675
+ });
635
676
 
636
- // ── remove ────────────────────────────────────
677
+ // ── remove ────────────────────────────────────
637
678
 
638
- const removeDef = !enabledMethods.has('remove') ? undefined : mutation(`${prefix}.remove`, {
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
- // ownerRls 테이블은 항상 인증 필수
642
- const user = (ownerMeta || !isPublic) ? ctx.auth.requireAuth() : null;
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
- await inRlsOrPlainTx(ctx.db, async (tx: any) => {
652
- if (options?.softDelete) {
653
- const sdField = options.softDelete.field as string;
654
- await tx.update(anyTable)
655
- .set({ [sdField]: new Date() } as any)
656
- .where(deleteWhere);
657
- } else {
658
- await tx.delete(anyTable).where(deleteWhere);
659
- }
660
- });
661
-
662
- // Realtime push — { data, total } 형태
663
- // S5: ownerRls user.id 사용
664
- if (useRealtime && enabledMethods.has('list')) {
665
- const currentUserId = ownerMeta ? user?.id : undefined;
666
- const listResult = await fetchListWithTotal(ctx.db, undefined, currentUserId);
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
- return { success: true };
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
- // 반환 객체는 항상 5개 키를 가지지만, 비활성 메서드는 undefined.
675
- // 사용자가 destructure 시 undefined를 받으면 export하지 않으므로
676
- // codegen의 레지스트리에 등록되지 않아 api.ts 불일치가 해소됨.
677
- return {
678
- list: listDef,
679
- get: getDef,
680
- create: createDef,
681
- update: updateDef,
682
- remove: removeDef,
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
  }