@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/dist/crud.d.ts CHANGED
@@ -72,11 +72,11 @@ type CrudOptions<T extends PgTable> = {
72
72
  * api.ts codegen에도 지정된 메서드만 포함됨.
73
73
  * @example crud(table, { methods: ['list', 'get', 'create'] })
74
74
  */
75
- methods?: ('list' | 'get' | 'create' | 'update' | 'remove')[];
75
+ methods?: ("list" | "get" | "create" | "update" | "remove")[];
76
76
  };
77
77
  /** 지원 연산자 목록 */
78
78
  declare const FILTER_OPS: readonly ["eq", "ne", "gt", "gte", "lt", "lte", "in", "nin", "like", "ilike"];
79
- type FilterOp = typeof FILTER_OPS[number];
79
+ type FilterOp = (typeof FILTER_OPS)[number];
80
80
  /**
81
81
  * 단일 필터 연산자를 Drizzle SQL 조건으로 변환.
82
82
  * 지원하지 않는 op이거나 값 타입이 맞지 않으면 undefined 반환 (묵살).
package/dist/crud.js CHANGED
@@ -34,7 +34,7 @@
34
34
  * 📄 ownerRls: docs/specs/spec-ownerrls-complete.md
35
35
  * 📦 참조: saas-js/drizzle-crud 팩토리 패턴
36
36
  */
37
- import { eq, ne, gt, gte, lt, lte, desc, asc, like, ilike, inArray, notInArray, or, and, count as drizzleCount, getTableName, getTableColumns } from "drizzle-orm";
37
+ import { eq, ne, gt, gte, lt, lte, desc, asc, like, ilike, inArray, notInArray, or, and, count as drizzleCount, getTableName, getTableColumns, } from "drizzle-orm";
38
38
  import { getTableConfig } from "drizzle-orm/pg-core";
39
39
  import { query, mutation } from "./reactive.js";
40
40
  import { v } from "./v.js";
@@ -95,7 +95,12 @@ function detectOwnerMeta(table) {
95
95
  const propName = resolvePropertyName(table, meta.columnName);
96
96
  const col = anyTable[propName];
97
97
  if (col) {
98
- return { column: col, columnName: meta.columnName, propertyName: propName, readPublic: meta.readPublic };
98
+ return {
99
+ column: col,
100
+ columnName: meta.columnName,
101
+ propertyName: propName,
102
+ readPublic: meta.readPublic,
103
+ };
99
104
  }
100
105
  }
101
106
  // 2. getTableConfig().policies로 pgPolicy 존재 감지 + convention 탐색
@@ -129,7 +134,7 @@ function detectOwnerMeta(table) {
129
134
  /** 재귀 필터 최대 깊이 — DoS 방어 (깊이 초과 시 조건 묵살) */
130
135
  const MAX_FILTER_DEPTH = 5;
131
136
  /** 지원 연산자 목록 */
132
- const FILTER_OPS = ['eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'in', 'nin', 'like', 'ilike'];
137
+ const FILTER_OPS = ["eq", "ne", "gt", "gte", "lt", "lte", "in", "nin", "like", "ilike"];
133
138
  function isValidFilterOp(op) {
134
139
  return FILTER_OPS.includes(op);
135
140
  }
@@ -139,29 +144,36 @@ function isValidFilterOp(op) {
139
144
  */
140
145
  export function applyFilterOp(col, op, value) {
141
146
  switch (op) {
142
- case 'eq': return eq(col, value);
143
- case 'ne': return ne(col, value);
144
- case 'gt': return gt(col, value);
145
- case 'gte': return gte(col, value);
146
- case 'lt': return lt(col, value);
147
- case 'lte': return lte(col, value);
148
- case 'in':
147
+ case "eq":
148
+ return eq(col, value);
149
+ case "ne":
150
+ return ne(col, value);
151
+ case "gt":
152
+ return gt(col, value);
153
+ case "gte":
154
+ return gte(col, value);
155
+ case "lt":
156
+ return lt(col, value);
157
+ case "lte":
158
+ return lte(col, value);
159
+ case "in":
149
160
  if (!Array.isArray(value) || value.length === 0)
150
161
  return undefined;
151
162
  return inArray(col, value);
152
- case 'nin':
163
+ case "nin":
153
164
  if (!Array.isArray(value) || value.length === 0)
154
165
  return undefined;
155
166
  return notInArray(col, value);
156
- case 'like':
157
- if (typeof value !== 'string')
167
+ case "like":
168
+ if (typeof value !== "string")
158
169
  return undefined;
159
170
  return like(col, value);
160
- case 'ilike':
161
- if (typeof value !== 'string')
171
+ case "ilike":
172
+ if (typeof value !== "string")
162
173
  return undefined;
163
174
  return ilike(col, value);
164
- default: return undefined;
175
+ default:
176
+ return undefined;
165
177
  }
166
178
  }
167
179
  /**
@@ -193,11 +205,11 @@ export function parseFilterNode(node, table, allowedFields, depth = 0) {
193
205
  const conditions = [];
194
206
  for (const [key, val] of Object.entries(node)) {
195
207
  // ── OR 논리 그룹 ──
196
- if (key === 'OR') {
208
+ if (key === "OR") {
197
209
  if (!Array.isArray(val))
198
210
  continue; // 잘못된 타입 → 묵살
199
211
  const orConds = val
200
- .filter((child) => child != null && typeof child === 'object' && !Array.isArray(child))
212
+ .filter((child) => child != null && typeof child === "object" && !Array.isArray(child))
201
213
  .map((child) => parseFilterNode(child, table, allowedFields, depth + 1))
202
214
  .filter((c) => c !== undefined);
203
215
  if (orConds.length > 0)
@@ -205,11 +217,11 @@ export function parseFilterNode(node, table, allowedFields, depth = 0) {
205
217
  continue;
206
218
  }
207
219
  // ── AND 논리 그룹 ──
208
- if (key === 'AND') {
220
+ if (key === "AND") {
209
221
  if (!Array.isArray(val))
210
222
  continue;
211
223
  const andConds = val
212
- .filter((child) => child != null && typeof child === 'object' && !Array.isArray(child))
224
+ .filter((child) => child != null && typeof child === "object" && !Array.isArray(child))
213
225
  .map((child) => parseFilterNode(child, table, allowedFields, depth + 1))
214
226
  .filter((c) => c !== undefined);
215
227
  if (andConds.length > 0)
@@ -224,7 +236,7 @@ export function parseFilterNode(node, table, allowedFields, depth = 0) {
224
236
  if (!col)
225
237
  continue;
226
238
  // 명시적 연산자: { op: 'gte', value: 10 }
227
- if (val != null && typeof val === 'object' && !Array.isArray(val) && 'op' in val) {
239
+ if (val != null && typeof val === "object" && !Array.isArray(val) && "op" in val) {
228
240
  const { op, value } = val;
229
241
  if (!isValidFilterOp(op))
230
242
  continue; // 미지원 op → 묵살
@@ -274,7 +286,7 @@ export function crud(table, options) {
274
286
  // ownerRls() 메타데이터가 감지되면 모든 CRUD에 userId 필터 자동 주입
275
287
  const ownerMeta = detectOwnerMeta(table);
276
288
  // 부트 로그용 레지스트리 등록 (S1: 중복 방지 — hot-reload/methods 분할 대응)
277
- if (ownerMeta && !_ownerRlsTables.some(t => t.tableName === tableName)) {
289
+ if (ownerMeta && !_ownerRlsTables.some((t) => t.tableName === tableName)) {
278
290
  _ownerRlsTables.push({
279
291
  tableName,
280
292
  columnName: ownerMeta.columnName,
@@ -305,7 +317,7 @@ export function crud(table, options) {
305
317
  // Dynamic filters — v3 재귀적 파싱 엔진 (OR/AND 중첩, 연산자 매핑)
306
318
  // allowedFilters 설정 시: 화이트리스트 필드만 허용 (재귀 검증)
307
319
  // allowedFilters 미설정 시: 필터 전체 무시 (Secure by Default — v2 호환)
308
- if (args?.filters && typeof args.filters === 'object' && options?.allowedFilters?.length) {
320
+ if (args?.filters && typeof args.filters === "object" && options?.allowedFilters?.length) {
309
321
  const allowedFields = options.allowedFilters;
310
322
  const filterCondition = parseFilterNode(args.filters, anyTable, allowedFields);
311
323
  if (filterCondition)
@@ -340,203 +352,208 @@ export function crud(table, options) {
340
352
  }
341
353
  // ── methods 필터링: 지정된 메서드만 레지스트리 등록 ──
342
354
  // methods 옵션 미지정 시 전체 5개 등록 (하위호환)
343
- const enabledMethods = new Set(options?.methods ?? ['list', 'get', 'create', 'update', 'remove']);
355
+ const enabledMethods = new Set(options?.methods ?? ["list", "get", "create", "update", "remove"]);
344
356
  // ── list ──────────────────────────────────────
345
- const listDef = !enabledMethods.has('list') ? undefined : query(`${prefix}.list`, {
346
- public: isPublic,
347
- args: {
348
- page: v.optional(v.number()),
349
- limit: v.optional(v.number()),
350
- search: v.optional(v.string()),
351
- orderBy: v.optional(v.string()),
352
- orderDir: v.optional(v.string()),
353
- filters: v.optional(v.any()),
354
- },
355
- handler: async (ctx, args) => {
356
- // S2: requireAuth 통합 — ownerRls(readPublic=false)이면 인증 필수
357
- const needsAuth = !isPublic || (ownerMeta && !ownerMeta.readPublic);
358
- const user = needsAuth ? ctx.auth.requireAuth() : null;
359
- const page = Math.max(1, args?.page || 1);
360
- const limit = Math.min(Math.max(1, args?.limit || defaultLimit), maxLimit);
361
- const offset = (page - 1) * limit;
362
- let whereClause = buildWhereConditions(args);
363
- // ── ownerRls Layer 1: list에 userId 필터 주입 ──
364
- if (ownerMeta && !ownerMeta.readPublic && user) {
365
- const ownerFilter = eq(ownerMeta.column, user.id);
366
- whereClause = whereClause ? and(whereClause, ownerFilter) : ownerFilter;
367
- }
368
- // Order
369
- let orderByClause;
370
- if (args?.orderBy && anyTable[args.orderBy]) {
371
- const col = anyTable[args.orderBy];
372
- orderByClause = args.orderDir === "asc" ? asc(col) : desc(col);
373
- }
374
- else {
375
- orderByClause = desc(defaultOrderCol);
376
- }
377
- // One transaction: SET LOCAL user id (createRlsDb) + list + count for RLS.
378
- return await inRlsOrPlainTx(ctx.db, async (tx) => {
379
- const results = await tx.select()
380
- .from(anyTable)
381
- .where(whereClause)
382
- .orderBy(orderByClause)
383
- .limit(limit)
384
- .offset(offset);
385
- const countResult = await tx.select({ count: drizzleCount() })
386
- .from(anyTable)
387
- .where(whereClause);
388
- return {
389
- data: results,
390
- total: Number(countResult[0]?.count ?? 0),
391
- };
392
- });
393
- }
394
- });
357
+ const listDef = !enabledMethods.has("list")
358
+ ? undefined
359
+ : query(`${prefix}.list`, {
360
+ public: isPublic,
361
+ args: {
362
+ page: v.optional(v.number()),
363
+ limit: v.optional(v.number()),
364
+ search: v.optional(v.string()),
365
+ orderBy: v.optional(v.string()),
366
+ orderDir: v.optional(v.string()),
367
+ filters: v.optional(v.any()),
368
+ },
369
+ handler: async (ctx, args) => {
370
+ // S2: requireAuth 통합 ownerRls(readPublic=false)이면 인증 필수
371
+ const needsAuth = !isPublic || (ownerMeta && !ownerMeta.readPublic);
372
+ const user = needsAuth ? ctx.auth.requireAuth() : null;
373
+ const page = Math.max(1, args?.page || 1);
374
+ const limit = Math.min(Math.max(1, args?.limit || defaultLimit), maxLimit);
375
+ const offset = (page - 1) * limit;
376
+ let whereClause = buildWhereConditions(args);
377
+ // ── ownerRls Layer 1: list에 userId 필터 주입 ──
378
+ if (ownerMeta && !ownerMeta.readPublic && user) {
379
+ const ownerFilter = eq(ownerMeta.column, user.id);
380
+ whereClause = whereClause ? and(whereClause, ownerFilter) : ownerFilter;
381
+ }
382
+ // Order
383
+ let orderByClause;
384
+ if (args?.orderBy && anyTable[args.orderBy]) {
385
+ const col = anyTable[args.orderBy];
386
+ orderByClause = args.orderDir === "asc" ? asc(col) : desc(col);
387
+ }
388
+ else {
389
+ orderByClause = desc(defaultOrderCol);
390
+ }
391
+ // One transaction: SET LOCAL user id (createRlsDb) + list + count for RLS.
392
+ return await inRlsOrPlainTx(ctx.db, async (tx) => {
393
+ const results = await tx
394
+ .select()
395
+ .from(anyTable)
396
+ .where(whereClause)
397
+ .orderBy(orderByClause)
398
+ .limit(limit)
399
+ .offset(offset);
400
+ const countResult = await tx.select({ count: drizzleCount() }).from(anyTable).where(whereClause);
401
+ return {
402
+ data: results,
403
+ total: Number(countResult[0]?.count ?? 0),
404
+ };
405
+ });
406
+ },
407
+ });
395
408
  // ── get ───────────────────────────────────────
396
- const getDef = !enabledMethods.has('get') ? undefined : query(`${prefix}.get`, {
397
- public: isPublic,
398
- args: { id: idValidator },
399
- handler: async (ctx, args) => {
400
- // S2: requireAuth 통합
401
- const needsAuth = !isPublic || (ownerMeta && !ownerMeta.readPublic);
402
- const user = needsAuth ? ctx.auth.requireAuth() : null;
403
- let whereCond = eq(pk, args.id);
404
- // ── ownerRls Layer 1: get에 userId 필터 주입 ──
405
- if (ownerMeta && !ownerMeta.readPublic && user) {
406
- whereCond = and(whereCond, eq(ownerMeta.column, user.id));
407
- }
408
- if (options?.softDelete) {
409
- const sdField = anyTable[options.softDelete.field];
410
- whereCond = and(whereCond, eq(sdField, null));
411
- }
412
- return await inRlsOrPlainTx(ctx.db, async (tx) => {
413
- const [result] = await tx.select().from(anyTable).where(whereCond).limit(1);
414
- return result ?? null;
415
- });
416
- }
417
- });
409
+ const getDef = !enabledMethods.has("get")
410
+ ? undefined
411
+ : query(`${prefix}.get`, {
412
+ public: isPublic,
413
+ args: { id: idValidator },
414
+ handler: async (ctx, args) => {
415
+ // S2: requireAuth 통합
416
+ const needsAuth = !isPublic || (ownerMeta && !ownerMeta.readPublic);
417
+ const user = needsAuth ? ctx.auth.requireAuth() : null;
418
+ let whereCond = eq(pk, args.id);
419
+ // ── ownerRls Layer 1: get에 userId 필터 주입 ──
420
+ if (ownerMeta && !ownerMeta.readPublic && user) {
421
+ whereCond = and(whereCond, eq(ownerMeta.column, user.id));
422
+ }
423
+ if (options?.softDelete) {
424
+ const sdField = anyTable[options.softDelete.field];
425
+ whereCond = and(whereCond, eq(sdField, null));
426
+ }
427
+ return await inRlsOrPlainTx(ctx.db, async (tx) => {
428
+ const [result] = await tx.select().from(anyTable).where(whereCond).limit(1);
429
+ return result ?? null;
430
+ });
431
+ },
432
+ });
418
433
  // ── create ────────────────────────────────────
419
- const createDef = !enabledMethods.has('create') ? undefined : mutation(`${prefix}.create`, {
420
- public: isPublic,
421
- handler: async (ctx, args) => {
422
- // ownerRls 테이블은 항상 인증 필수 (보안 우선)
423
- const user = (ownerMeta || !isPublic) ? ctx.auth.requireAuth() : null;
424
- let insertData = { ...args };
425
- // ── ownerRls Layer 1: create userId 강제 주입 ──
426
- // 명시적으로 타인 userId를 보낸 경우는 즉시 차단 (fail-closed).
427
- if (ownerMeta && user) {
428
- const requestedOwner = insertData[ownerMeta.propertyName] ??
429
- insertData[ownerMeta.columnName] ??
430
- insertData.userId;
431
- if (requestedOwner != null && requestedOwner !== user.id) {
432
- throw new Error("Forbidden: cannot create resource for another user");
434
+ const createDef = !enabledMethods.has("create")
435
+ ? undefined
436
+ : mutation(`${prefix}.create`, {
437
+ public: isPublic,
438
+ handler: async (ctx, args) => {
439
+ // ownerRls 테이블은 항상 인증 필수 (보안 우선)
440
+ const user = ownerMeta || !isPublic ? ctx.auth.requireAuth() : null;
441
+ let insertData = { ...args };
442
+ // ── ownerRls Layer 1: create 시 userId 강제 주입 ──
443
+ // 명시적으로 타인 userId를 보낸 경우는 즉시 차단 (fail-closed).
444
+ if (ownerMeta && user) {
445
+ const requestedOwner = insertData[ownerMeta.propertyName] ?? insertData[ownerMeta.columnName] ?? insertData.userId;
446
+ if (requestedOwner != null && requestedOwner !== user.id) {
447
+ throw new Error("Forbidden: cannot create resource for another user");
448
+ }
433
449
  }
434
- }
435
- // S1 수정: propertyName(JS명)을 키로 사용 Drizzle insert 호환
436
- // 사용자 입력을 덮어씀 (보안: 타인 ID로 데이터 생성 방지)
437
- if (ownerMeta && user) {
438
- insertData[ownerMeta.propertyName] = user.id;
439
- }
440
- else if (userIdCol && user && !insertData.userId) {
441
- // ownerRls 미적용 테이블의 기존 자동 주입 동작 유지
442
- insertData.userId = user.id;
443
- }
444
- // beforeCreate 훅
445
- if (options?.hooks?.beforeCreate) {
446
- insertData = await options.hooks.beforeCreate(insertData);
447
- }
448
- const [result] = await inRlsOrPlainTx(ctx.db, async (tx) => tx.insert(anyTable).values(insertData).returning());
449
- // Realtime push — { data, total } 형태로 emit
450
- // ownerRls 시 해당 사용자 데이터만 push (타 사용자에게 누출 방지)
451
- // S5: requireAuth로 확실한 userId 획득 (getUserIdentity null 방지)
452
- if (useRealtime && enabledMethods.has('list')) {
453
- const currentUserId = user?.id;
454
- const listResult = await fetchListWithTotal(ctx.db, undefined, currentUserId);
455
- ctx.realtime.emit(`${prefix}.list`, listResult);
456
- }
457
- return result;
458
- }
459
- });
460
- // ── update ────────────────────────────────────
461
- const updateDef = !enabledMethods.has('update') ? undefined : mutation(`${prefix}.update`, {
462
- public: isPublic,
463
- handler: async (ctx, args) => {
464
- // ownerRls 테이블은 항상 인증 필수
465
- const user = (ownerMeta || !isPublic) ? ctx.auth.requireAuth() : null;
466
- const { id, ...updates } = args;
467
- let updateData = { ...updates };
468
- // ── ownerRls Layer 1: update 시 userId 변경 차단 + 소유자 검증 ──
469
- let updateWhere = eq(pk, id);
470
- if (ownerMeta && user) {
471
- // WHERE id = ? AND user_id = ? (타인 데이터 수정 불가)
472
- updateWhere = and(eq(pk, id), eq(ownerMeta.column, user.id));
473
- // S1 수정: propertyName(JS명)으로 userId 변경 시도 차단
474
- delete updateData[ownerMeta.propertyName];
475
- // DB명도 방어적으로 삭제 (만약 클라이언트가 DB명으로 보낸 경우)
476
- if (ownerMeta.propertyName !== ownerMeta.columnName) {
477
- delete updateData[ownerMeta.columnName];
450
+ // S1 수정: propertyName(JS명)을 키로 사용 — Drizzle insert 호환
451
+ // 사용자 입력을 덮어씀 (보안: 타인 ID로 데이터 생성 방지)
452
+ if (ownerMeta && user) {
453
+ insertData[ownerMeta.propertyName] = user.id;
478
454
  }
479
- }
480
- // updatedAt 자동 갱신
481
- if (anyTable["updatedAt"]) {
482
- updateData.updatedAt = new Date();
483
- }
484
- // beforeUpdate
485
- if (options?.hooks?.beforeUpdate) {
486
- updateData = await options.hooks.beforeUpdate(updateData);
487
- }
488
- const [result] = await inRlsOrPlainTx(ctx.db, async (tx) => tx.update(anyTable)
489
- .set(updateData)
490
- .where(updateWhere)
491
- .returning());
492
- // Realtime push (list + get 양쪽) — { data, total } 형태
493
- // S5: ownerRls 시 user.id 사용 (getUserIdentity null 방지)
494
- if (useRealtime) {
495
- const currentUserId = ownerMeta ? user?.id : undefined;
496
- if (enabledMethods.has('list')) {
455
+ else if (userIdCol && user && !insertData.userId) {
456
+ // ownerRls 미적용 테이블의 기존 자동 주입 동작 유지
457
+ insertData.userId = user.id;
458
+ }
459
+ // beforeCreate 훅
460
+ if (options?.hooks?.beforeCreate) {
461
+ insertData = await options.hooks.beforeCreate(insertData);
462
+ }
463
+ const [result] = await inRlsOrPlainTx(ctx.db, async (tx) => tx.insert(anyTable).values(insertData).returning());
464
+ // Realtime push { data, total } 형태로 emit
465
+ // ownerRls 시 해당 사용자 데이터만 push (타 사용자에게 누출 방지)
466
+ // S5: requireAuth로 확실한 userId 획득 (getUserIdentity null 방지)
467
+ if (useRealtime && enabledMethods.has("list")) {
468
+ const currentUserId = user?.id;
497
469
  const listResult = await fetchListWithTotal(ctx.db, undefined, currentUserId);
498
470
  ctx.realtime.emit(`${prefix}.list`, listResult);
499
471
  }
500
- if (enabledMethods.has('get')) {
501
- ctx.realtime.emit(`${prefix}.get`, result);
472
+ return result;
473
+ },
474
+ });
475
+ // ── update ────────────────────────────────────
476
+ const updateDef = !enabledMethods.has("update")
477
+ ? undefined
478
+ : mutation(`${prefix}.update`, {
479
+ public: isPublic,
480
+ handler: async (ctx, args) => {
481
+ // ownerRls 테이블은 항상 인증 필수
482
+ const user = ownerMeta || !isPublic ? ctx.auth.requireAuth() : null;
483
+ const { id, ...updates } = args;
484
+ let updateData = { ...updates };
485
+ // ── ownerRls Layer 1: update 시 userId 변경 차단 + 소유자 검증 ──
486
+ let updateWhere = eq(pk, id);
487
+ if (ownerMeta && user) {
488
+ // WHERE id = ? AND user_id = ? (타인 데이터 수정 불가)
489
+ updateWhere = and(eq(pk, id), eq(ownerMeta.column, user.id));
490
+ // S1 수정: propertyName(JS명)으로 userId 변경 시도 차단
491
+ delete updateData[ownerMeta.propertyName];
492
+ // DB명도 방어적으로 삭제 (만약 클라이언트가 DB명으로 보낸 경우)
493
+ if (ownerMeta.propertyName !== ownerMeta.columnName) {
494
+ delete updateData[ownerMeta.columnName];
495
+ }
502
496
  }
503
- }
504
- return result;
505
- }
506
- });
497
+ // updatedAt 자동 갱신
498
+ if (anyTable["updatedAt"]) {
499
+ updateData.updatedAt = new Date();
500
+ }
501
+ // beforeUpdate 훅
502
+ if (options?.hooks?.beforeUpdate) {
503
+ updateData = await options.hooks.beforeUpdate(updateData);
504
+ }
505
+ const [result] = await inRlsOrPlainTx(ctx.db, async (tx) => tx.update(anyTable).set(updateData).where(updateWhere).returning());
506
+ // Realtime push (list + get 양쪽) — { data, total } 형태
507
+ // S5: ownerRls 시 user.id 사용 (getUserIdentity null 방지)
508
+ if (useRealtime) {
509
+ const currentUserId = ownerMeta ? user?.id : undefined;
510
+ if (enabledMethods.has("list")) {
511
+ const listResult = await fetchListWithTotal(ctx.db, undefined, currentUserId);
512
+ ctx.realtime.emit(`${prefix}.list`, listResult);
513
+ }
514
+ if (enabledMethods.has("get")) {
515
+ ctx.realtime.emit(`${prefix}.get`, result);
516
+ }
517
+ }
518
+ return result;
519
+ },
520
+ });
507
521
  // ── remove ────────────────────────────────────
508
- const removeDef = !enabledMethods.has('remove') ? undefined : mutation(`${prefix}.remove`, {
509
- public: isPublic,
510
- handler: async (ctx, args) => {
511
- // ownerRls 테이블은 항상 인증 필수
512
- const user = (ownerMeta || !isPublic) ? ctx.auth.requireAuth() : null;
513
- // ── ownerRls Layer 1: remove 시 소유자 검증 ──
514
- let deleteWhere = eq(pk, args.id);
515
- if (ownerMeta && user) {
516
- // WHERE id = ? AND user_id = ? (타인 데이터 삭제 불가)
517
- deleteWhere = and(eq(pk, args.id), eq(ownerMeta.column, user.id));
518
- }
519
- await inRlsOrPlainTx(ctx.db, async (tx) => {
520
- if (options?.softDelete) {
521
- const sdField = options.softDelete.field;
522
- await tx.update(anyTable)
523
- .set({ [sdField]: new Date() })
524
- .where(deleteWhere);
522
+ const removeDef = !enabledMethods.has("remove")
523
+ ? undefined
524
+ : mutation(`${prefix}.remove`, {
525
+ public: isPublic,
526
+ handler: async (ctx, args) => {
527
+ // ownerRls 테이블은 항상 인증 필수
528
+ const user = ownerMeta || !isPublic ? ctx.auth.requireAuth() : null;
529
+ // ── ownerRls Layer 1: remove 시 소유자 검증 ──
530
+ let deleteWhere = eq(pk, args.id);
531
+ if (ownerMeta && user) {
532
+ // WHERE id = ? AND user_id = ? (타인 데이터 삭제 불가)
533
+ deleteWhere = and(eq(pk, args.id), eq(ownerMeta.column, user.id));
525
534
  }
526
- else {
527
- await tx.delete(anyTable).where(deleteWhere);
535
+ await inRlsOrPlainTx(ctx.db, async (tx) => {
536
+ if (options?.softDelete) {
537
+ const sdField = options.softDelete.field;
538
+ await tx
539
+ .update(anyTable)
540
+ .set({ [sdField]: new Date() })
541
+ .where(deleteWhere);
542
+ }
543
+ else {
544
+ await tx.delete(anyTable).where(deleteWhere);
545
+ }
546
+ });
547
+ // Realtime push — { data, total } 형태
548
+ // S5: ownerRls 시 user.id 사용
549
+ if (useRealtime && enabledMethods.has("list")) {
550
+ const currentUserId = ownerMeta ? user?.id : undefined;
551
+ const listResult = await fetchListWithTotal(ctx.db, undefined, currentUserId);
552
+ ctx.realtime.emit(`${prefix}.list`, listResult);
528
553
  }
529
- });
530
- // Realtime push — { data, total } 형태
531
- // S5: ownerRls 시 user.id 사용
532
- if (useRealtime && enabledMethods.has('list')) {
533
- const currentUserId = ownerMeta ? user?.id : undefined;
534
- const listResult = await fetchListWithTotal(ctx.db, undefined, currentUserId);
535
- ctx.realtime.emit(`${prefix}.list`, listResult);
536
- }
537
- return { success: true };
538
- }
539
- });
554
+ return { success: true };
555
+ },
556
+ });
540
557
  // 반환 객체는 항상 5개 키를 가지지만, 비활성 메서드는 undefined.
541
558
  // 사용자가 destructure 시 undefined를 받으면 export하지 않으므로
542
559
  // codegen의 레지스트리에 등록되지 않아 api.ts 불일치가 해소됨.
package/dist/index.d.ts CHANGED
@@ -4,11 +4,15 @@
4
4
  * Provides: query, mutation, storage, scheduler, auth
5
5
  * All with Convex-compatible DX patterns.
6
6
  */
7
- export type { GencowCtx, AuthCtx, UserIdentity, QueryDef, MutationDef, RealtimeCtx, HttpActionDef, HttpActionRequest, HttpActionResponse, HttpActionHandler, AIContext, AIMessage, AIResult } from "./reactive.js";
8
- export { query, mutation, httpAction, buildRealtimeCtx, subscribe, unsubscribe, registerClient, deregisterClient, handleWsMessage, getQueryHandler, getQueryDef, getRegisteredQueries, getRegisteredMutations, getRegisteredHttpActions } from "./reactive.js";
7
+ export type { GencowCtx, AuthCtx, UserIdentity, QueryDef, MutationDef, RealtimeCtx, HttpActionDef, HttpActionRequest, HttpActionResponse, HttpActionHandler, AIContext, AIMessage, AIResult, } from "./reactive.js";
8
+ export { query, mutation, httpAction, buildRealtimeCtx, subscribe, unsubscribe, registerClient, deregisterClient, handleWsMessage, getQueryHandler, getQueryDef, getRegisteredQueries, getRegisteredMutations, getRegisteredHttpActions, } from "./reactive.js";
9
9
  export type { Storage } from "./storage.js";
10
10
  export { createScheduler, getSchedulerInfo } from "./scheduler.js";
11
- export type { Scheduler, ScheduleOptions, FailedJob, CreateSchedulerOptions, ScheduledJobRecord } from "./scheduler.js";
11
+ export type { Scheduler, ScheduleOptions, FailedJob, CreateSchedulerOptions, ScheduledJobRecord, } from "./scheduler.js";
12
+ export { workflow, getWorkflowDef, getRegisteredWorkflows, getWorkflowResumeActionName, getWorkflowRealtimeKey, createWorkflowRealtimeToken, serializeWorkflowValue, deserializeWorkflowValue, parseWorkflowDurationMs, DEFAULT_WORKFLOW_MAX_DURATION_MS, DEFAULT_WORKFLOW_MAX_RETRIES, WORKFLOW_RESUME_ACTION_PREFIX, WORKFLOW_REALTIME_KEY_PREFIX, } from "./workflow.js";
13
+ export { deriveWorkflowStatus } from "./workflow-types.js";
14
+ export type { WorkflowCtx, WorkflowHandler, WorkflowOptions, WorkflowDef, WorkflowResumePayload, WorkflowStartResult, WorkflowSignalResult, WorkflowStatus, WorkflowDerivedStatus, WorkflowSummary, WorkflowSnapshot, WorkflowStepSnapshot, WorkflowListArgs, WorkflowDuration, } from "./workflow-types.js";
15
+ export { loadWorkflowSnapshot } from "./workflows-api.js";
12
16
  export { v, parseArgs, GencowValidationError } from "./v.js";
13
17
  export type { Validator, Infer, InferArgs } from "./v.js";
14
18
  export { withRetry } from "./retry.js";
package/dist/index.js CHANGED
@@ -4,8 +4,11 @@
4
4
  * Provides: query, mutation, storage, scheduler, auth
5
5
  * All with Convex-compatible DX patterns.
6
6
  */
7
- export { query, mutation, httpAction, buildRealtimeCtx, subscribe, unsubscribe, registerClient, deregisterClient, handleWsMessage, getQueryHandler, getQueryDef, getRegisteredQueries, getRegisteredMutations, getRegisteredHttpActions } from "./reactive.js";
7
+ export { query, mutation, httpAction, buildRealtimeCtx, subscribe, unsubscribe, registerClient, deregisterClient, handleWsMessage, getQueryHandler, getQueryDef, getRegisteredQueries, getRegisteredMutations, getRegisteredHttpActions, } from "./reactive.js";
8
8
  export { createScheduler, getSchedulerInfo } from "./scheduler.js";
9
+ export { workflow, getWorkflowDef, getRegisteredWorkflows, getWorkflowResumeActionName, getWorkflowRealtimeKey, createWorkflowRealtimeToken, serializeWorkflowValue, deserializeWorkflowValue, parseWorkflowDurationMs, DEFAULT_WORKFLOW_MAX_DURATION_MS, DEFAULT_WORKFLOW_MAX_RETRIES, WORKFLOW_RESUME_ACTION_PREFIX, WORKFLOW_REALTIME_KEY_PREFIX, } from "./workflow.js";
10
+ export { deriveWorkflowStatus } from "./workflow-types.js";
11
+ export { loadWorkflowSnapshot } from "./workflows-api.js";
9
12
  export { v, parseArgs, GencowValidationError } from "./v.js";
10
13
  export { withRetry } from "./retry.js";
11
14
  export { cronJobs } from "./crons.js";
package/dist/reactive.js CHANGED
@@ -85,7 +85,10 @@ export function mutation(nameOrInvalidatesOrDef, handlerOrDef, name) {
85
85
  actualHandler = nameOrInvalidatesOrDef.handler;
86
86
  argsSchema = nameOrInvalidatesOrDef.args;
87
87
  isPublic = nameOrInvalidatesOrDef.public === true;
88
- mutName = nameOrInvalidatesOrDef.name || (typeof name === "string" ? name : "") || `mutation_${++mutationCounter}`;
88
+ mutName =
89
+ nameOrInvalidatesOrDef.name ||
90
+ (typeof name === "string" ? name : "") ||
91
+ `mutation_${++mutationCounter}`;
89
92
  }
90
93
  // 이름 미지정 시 경고 — 디버깅 지원
91
94
  if (mutName.startsWith("mutation_")) {
@@ -224,8 +227,12 @@ export function buildRealtimeCtx(options) {
224
227
  _pendingRefresh.push(queryKey);
225
228
  }
226
229
  },
227
- get _hasEmitted() { return _hasEmitted; },
228
- get _pendingRefresh() { return [..._pendingRefresh]; },
230
+ get _hasEmitted() {
231
+ return _hasEmitted;
232
+ },
233
+ get _pendingRefresh() {
234
+ return [..._pendingRefresh];
235
+ },
229
236
  async _flushRefresh() {
230
237
  if (_pendingRefresh.length === 0)
231
238
  return;
package/dist/retry.js CHANGED
@@ -71,5 +71,5 @@ export async function withRetry(fn, options = {}) {
71
71
  }
72
72
  // ─── 내부 유틸리티 ──────────────────────────────────────
73
73
  function sleep(ms) {
74
- return new Promise(resolve => setTimeout(resolve, ms));
74
+ return new Promise((resolve) => setTimeout(resolve, ms));
75
75
  }