@gencow/core 0.1.24 → 0.1.26
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 +5 -5
- package/dist/index.js +2 -2
- 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.js +4 -11
- package/dist/workflows-api.js +5 -12
- package/package.json +45 -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 -120
- 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 +47 -41
- 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 +309 -286
- 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 +69 -5
- 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 +67 -70
- package/src/workflow.ts +99 -116
- package/src/workflows-api.ts +231 -241
- package/dist/db.d.ts +0 -13
- package/dist/db.js +0 -16
- 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?: (
|
|
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 {
|
|
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 = [
|
|
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
|
|
143
|
-
|
|
144
|
-
case
|
|
145
|
-
|
|
146
|
-
case
|
|
147
|
-
|
|
148
|
-
case
|
|
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
|
|
163
|
+
case "nin":
|
|
153
164
|
if (!Array.isArray(value) || value.length === 0)
|
|
154
165
|
return undefined;
|
|
155
166
|
return notInArray(col, value);
|
|
156
|
-
case
|
|
157
|
-
if (typeof value !==
|
|
167
|
+
case "like":
|
|
168
|
+
if (typeof value !== "string")
|
|
158
169
|
return undefined;
|
|
159
170
|
return like(col, value);
|
|
160
|
-
case
|
|
161
|
-
if (typeof value !==
|
|
171
|
+
case "ilike":
|
|
172
|
+
if (typeof value !== "string")
|
|
162
173
|
return undefined;
|
|
163
174
|
return ilike(col, value);
|
|
164
|
-
default:
|
|
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 ===
|
|
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 ===
|
|
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 ===
|
|
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 ===
|
|
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 ===
|
|
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 ===
|
|
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 ?? [
|
|
355
|
+
const enabledMethods = new Set(options?.methods ?? ["list", "get", "create", "update", "remove"]);
|
|
344
356
|
// ── list ──────────────────────────────────────
|
|
345
|
-
const listDef = !enabledMethods.has(
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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(
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
return
|
|
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(
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
insertData.userId;
|
|
431
|
-
|
|
432
|
-
|
|
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
|
-
|
|
436
|
-
|
|
437
|
-
|
|
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
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
.
|
|
492
|
-
|
|
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
|
-
|
|
501
|
-
|
|
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
|
-
|
|
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(
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
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
|
-
|
|
527
|
-
|
|
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
|
-
|
|
531
|
-
|
|
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,14 +4,14 @@
|
|
|
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";
|
|
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";
|
|
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
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";
|
|
14
|
+
export type { WorkflowCtx, WorkflowHandler, WorkflowOptions, WorkflowDef, WorkflowResumePayload, WorkflowStartResult, WorkflowSignalResult, WorkflowStatus, WorkflowDerivedStatus, WorkflowSummary, WorkflowSnapshot, WorkflowStepSnapshot, WorkflowListArgs, WorkflowDuration, } from "./workflow-types.js";
|
|
15
15
|
export { loadWorkflowSnapshot } from "./workflows-api.js";
|
|
16
16
|
export { v, parseArgs, GencowValidationError } from "./v.js";
|
|
17
17
|
export type { Validator, Infer, InferArgs } from "./v.js";
|
package/dist/index.js
CHANGED
|
@@ -4,9 +4,9 @@
|
|
|
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";
|
|
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
10
|
export { deriveWorkflowStatus } from "./workflow-types.js";
|
|
11
11
|
export { loadWorkflowSnapshot } from "./workflows-api.js";
|
|
12
12
|
export { v, parseArgs, GencowValidationError } from "./v.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 =
|
|
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() {
|
|
228
|
-
|
|
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