gencow 0.1.92 → 0.1.94
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/bin/gencow.mjs +169 -48
- package/core/index.js +160 -102
- package/dashboard/assets/index-ClOoaZmW.css +1 -0
- package/dashboard/assets/index-D4Ftx-0N.js +406 -0
- package/dashboard/index.html +2 -2
- package/lib/__tests__/readme-codegen.test.ts +5 -5
- package/lib/readme-codegen.mjs +63 -24
- package/package.json +31 -30
- package/server/index.js +13314 -42860
- package/server/index.js.map +4 -4
- package/templates/SECURITY.md +64 -85
- package/templates/admin-tool/README.md +4 -4
- package/templates/admin-tool/items.ts +4 -83
- package/templates/ai-chat/ai.ts +71 -21
- package/templates/ai-chat/schema.ts +1 -1
- package/templates/ai.ts +71 -21
- package/templates/default/README.md +7 -16
- package/templates/default/index.ts +20 -22
- package/templates/fullstack/ai.ts +71 -21
- package/templates/fullstack/schema.ts +1 -1
- package/templates/task-app/schema.ts +1 -1
- package/dashboard/assets/index-B4Nbb9vO.css +0 -1
- package/dashboard/assets/index-BWosOI9I.js +0 -411
package/bin/gencow.mjs
CHANGED
|
@@ -296,10 +296,56 @@ process.exit(0);
|
|
|
296
296
|
try {
|
|
297
297
|
writeFileSync(extractTsPath, script);
|
|
298
298
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
299
|
+
let outStr;
|
|
300
|
+
try {
|
|
301
|
+
outStr = execSync(`bun .gencow-extract.ts`, {
|
|
302
|
+
cwd: serverRoot,
|
|
303
|
+
stdio: ["pipe", "pipe", "pipe"] // stderr도 캡처
|
|
304
|
+
}).toString();
|
|
305
|
+
} catch (execError) {
|
|
306
|
+
// bun 실행 자체가 실패한 경우 — stderr에서 구체적 원인 추출
|
|
307
|
+
const stderr = execError.stderr?.toString() || "";
|
|
308
|
+
const stdout = execError.stdout?.toString() || "";
|
|
309
|
+
|
|
310
|
+
error("API Codegen failed — gencow/index.ts를 파싱할 수 없습니다.");
|
|
311
|
+
log("");
|
|
312
|
+
|
|
313
|
+
// stderr에서 핵심 에러 줄 추출 (타입 에러, import 에러 등)
|
|
314
|
+
const errorLines = stderr.split("\n").filter(line =>
|
|
315
|
+
line.includes("error:") ||
|
|
316
|
+
line.includes("Error:") ||
|
|
317
|
+
line.includes("Cannot find") ||
|
|
318
|
+
line.includes("is not assignable") ||
|
|
319
|
+
line.includes("does not exist") ||
|
|
320
|
+
line.includes("Module not found")
|
|
321
|
+
).slice(0, 5); // 최대 5줄
|
|
322
|
+
|
|
323
|
+
if (errorLines.length > 0) {
|
|
324
|
+
error("원인:");
|
|
325
|
+
for (const line of errorLines) {
|
|
326
|
+
info(` ${RED}${line.trim()}${RESET}`);
|
|
327
|
+
}
|
|
328
|
+
} else if (stderr.trim()) {
|
|
329
|
+
// 패턴 매칭 안 되면 stderr 처음 3줄 출력
|
|
330
|
+
const firstLines = stderr.trim().split("\n").slice(0, 3);
|
|
331
|
+
for (const line of firstLines) {
|
|
332
|
+
info(` ${DIM}${line}${RESET}`);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
log("");
|
|
337
|
+
info(`${YELLOW}해결 방법:${RESET}`);
|
|
338
|
+
info(" 1. gencow/index.ts에서 TypeScript 에러를 수정하세요");
|
|
339
|
+
info(" 2. import 경로가 올바른지 확인하세요");
|
|
340
|
+
info(" 3. @gencow/core 패키지가 설치되어 있는지 확인하세요");
|
|
341
|
+
log("");
|
|
342
|
+
|
|
343
|
+
// 빈 스텁 api.ts 생성 — IDE가 즉시 에러를 잡을 수 있도록
|
|
344
|
+
const stubContent = `/**\n * ⚠️ API Codegen Failed\n *\n * gencow/index.ts에서 에러가 발생하여 api.ts를 생성할 수 없었습니다.\n * 콘솔의 에러 메시지를 확인하고 수정한 후 gencow dev를 재시작하세요.\n *\n * 이 파일은 자동 생성됩니다 — 직접 수정하지 마세요.\n */\nexport const api = {} as const;\n`;
|
|
345
|
+
writeFileSync(apiTsPath, stubContent);
|
|
346
|
+
warn(`빈 스텁 ${config.functionsDir}/api.ts 생성됨 (에러 수정 후 자동 재생성)`);
|
|
347
|
+
return; // 에러를 throw하지 않고 조용히 반환 — dev 서버는 계속 실행
|
|
348
|
+
}
|
|
303
349
|
|
|
304
350
|
const match = outStr.match(/GENCOW_API_JSON=({.*})/);
|
|
305
351
|
if (!match) throw new Error("Could not parse registry output");
|
|
@@ -382,7 +428,11 @@ process.exit(0);
|
|
|
382
428
|
|
|
383
429
|
|
|
384
430
|
} catch (e) {
|
|
385
|
-
|
|
431
|
+
error(`API Codegen failed: ${e.message}`);
|
|
432
|
+
// 빈 스텁 api.ts 생성 — 예기치 않은 에러에서도 IDE가 즉시 에러를 잡을 수 있도록
|
|
433
|
+
const stubContent = `/**\n * ⚠️ API Codegen Failed: ${e.message.replace(/\*/g, "")}\n *\n * 에러를 수정한 후 gencow dev를 재시작하세요.\n * 이 파일은 자동 생성됩니다 — 직접 수정하지 마세요.\n */\nexport const api = {} as const;\n`;
|
|
434
|
+
try { writeFileSync(apiTsPath, stubContent); } catch { /* ignore */ }
|
|
435
|
+
warn(`빈 스텁 ${config.functionsDir}/api.ts 생성됨`);
|
|
386
436
|
} finally {
|
|
387
437
|
try { unlinkSync(extractTsPath); } catch { }
|
|
388
438
|
}
|
|
@@ -726,7 +776,7 @@ export default defineConfig({
|
|
|
726
776
|
if (existsSync(envPath) && force) {
|
|
727
777
|
info(".env 이미 존재 — 건너뜁니다.");
|
|
728
778
|
} else {
|
|
729
|
-
const envContent = `# Gencow Environment Variables\n# Add your environment variables here\n`;
|
|
779
|
+
const envContent = `# Gencow Environment Variables\n# Add your environment variables here\n\n# AI 기능 사용 시 필수 (ctx.ai.chat, ai.chat 등)\n# https://platform.openai.com/api-keys 에서 발급\nOPENAI_API_KEY=sk-your-key-here\n`;
|
|
730
780
|
writeFileSync(envPath, envContent);
|
|
731
781
|
success("Created .env");
|
|
732
782
|
}
|
|
@@ -1359,7 +1409,7 @@ ${hasPrompt ? `
|
|
|
1359
1409
|
|
|
1360
1410
|
// tar.gz 생성: gencow/ 폴더 전체 (schema.ts + import된 파일들)
|
|
1361
1411
|
execSync(
|
|
1362
|
-
`tar -czf "${tmpBundle}" -C "${process.cwd()}" "${functionsDir.replace(/^\.\//,'')}/"`,
|
|
1412
|
+
`COPYFILE_DISABLE=1 tar -czf "${tmpBundle}" -C "${process.cwd()}" "${functionsDir.replace(/^\.\//,'')}/"`,
|
|
1363
1413
|
{ stdio: ["ignore", "pipe", "pipe"] }
|
|
1364
1414
|
);
|
|
1365
1415
|
|
|
@@ -1941,10 +1991,10 @@ ${BOLD}Examples:${RESET}
|
|
|
1941
1991
|
exec(`cp "${src}" "${dst}"`, { cwd: process.cwd() });
|
|
1942
1992
|
}
|
|
1943
1993
|
}
|
|
1944
|
-
exec(`tar -czf "${tmpBundle}" .`, { cwd: tmpDir });
|
|
1994
|
+
exec(`COPYFILE_DISABLE=1 tar -czf "${tmpBundle}" .`, { cwd: tmpDir });
|
|
1945
1995
|
exec(`rm -rf "${tmpDir}"`, { cwd: process.cwd() });
|
|
1946
1996
|
} else {
|
|
1947
|
-
exec(`tar -czf "${tmpBundle}" ${filesToPack.join(" ")}`, { cwd: process.cwd() });
|
|
1997
|
+
exec(`COPYFILE_DISABLE=1 tar -czf "${tmpBundle}" ${filesToPack.join(" ")}`, { cwd: process.cwd() });
|
|
1948
1998
|
}
|
|
1949
1999
|
} catch (e) {
|
|
1950
2000
|
error(`패키징 실패: ${e.message}`);
|
|
@@ -2046,7 +2096,7 @@ ${BOLD}Examples:${RESET}
|
|
|
2046
2096
|
|
|
2047
2097
|
// 재배포
|
|
2048
2098
|
info("재배포 시도...");
|
|
2049
|
-
exec(`tar -czf "${tmpBundle}" ${filesToPack.join(" ")}`, { cwd: process.cwd() });
|
|
2099
|
+
exec(`COPYFILE_DISABLE=1 tar -czf "${tmpBundle}" ${filesToPack.join(" ")}`, { cwd: process.cwd() });
|
|
2050
2100
|
const retryBuffer = readFileSync(tmpBundle);
|
|
2051
2101
|
|
|
2052
2102
|
const retryStartTime = Date.now();
|
|
@@ -2217,7 +2267,44 @@ ${BOLD}Examples:${RESET}
|
|
|
2217
2267
|
&& (existsSync(resolve(parentDir, "gencow.config.ts")) || existsSync(resolve(parentDir, "package.json")));
|
|
2218
2268
|
const detectedBackend = hasCwdBackend || hasParentBackend;
|
|
2219
2269
|
const backendRoot = hasCwdBackend ? process.cwd() : (hasParentBackend ? parentDir : null);
|
|
2220
|
-
|
|
2270
|
+
|
|
2271
|
+
// ── 빈 백엔드 자동 감지: gencow/ 내 query/mutation/httpAction 호출이 없으면 skip ──
|
|
2272
|
+
// schema.ts + 빈 API 파일만 있는 경우에도 올바르게 감지
|
|
2273
|
+
let isBackendEmpty = false;
|
|
2274
|
+
if (detectedBackend && backendRoot) {
|
|
2275
|
+
const gencowDir = resolve(backendRoot, "gencow");
|
|
2276
|
+
try {
|
|
2277
|
+
const scanTsFiles = (dir) => {
|
|
2278
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
2279
|
+
const files = [];
|
|
2280
|
+
for (const e of entries) {
|
|
2281
|
+
const p = resolve(dir, e.name);
|
|
2282
|
+
if (e.isDirectory() && e.name !== "node_modules") files.push(...scanTsFiles(p));
|
|
2283
|
+
else if (e.isFile() && (e.name.endsWith(".ts") || e.name.endsWith(".tsx"))) files.push(p);
|
|
2284
|
+
}
|
|
2285
|
+
return files;
|
|
2286
|
+
};
|
|
2287
|
+
const tsFiles = scanTsFiles(gencowDir);
|
|
2288
|
+
// query(, mutation(, httpAction( 호출이 하나라도 있으면 실질적 백엔드
|
|
2289
|
+
const hasApiCalls = tsFiles.some(f => {
|
|
2290
|
+
const src = readFileSync(f, "utf8");
|
|
2291
|
+
return /\b(query|mutation|httpAction)\s*\(/.test(src);
|
|
2292
|
+
});
|
|
2293
|
+
if (!hasApiCalls) {
|
|
2294
|
+
isBackendEmpty = true;
|
|
2295
|
+
}
|
|
2296
|
+
} catch {
|
|
2297
|
+
// 스캔 실패 시 안전하게 백엔드 있다고 가정
|
|
2298
|
+
isBackendEmpty = false;
|
|
2299
|
+
}
|
|
2300
|
+
}
|
|
2301
|
+
|
|
2302
|
+
const shouldDeployBackend = detectedBackend && !noBackend && !isBackendEmpty;
|
|
2303
|
+
|
|
2304
|
+
if (isBackendEmpty && detectedBackend) {
|
|
2305
|
+
info(`${DIM}gencow/ 감지됨 — API 함수(query/mutation) 없음 → 백엔드 배포 건너뜀${RESET}`);
|
|
2306
|
+
info(`${DIM}💡 백엔드가 필요하면 gencow/ 내 .ts 파일에 query() 또는 mutation()을 정의하세요.${RESET}`);
|
|
2307
|
+
}
|
|
2221
2308
|
|
|
2222
2309
|
if (shouldDeployBackend) {
|
|
2223
2310
|
log(`\n${BOLD}${CYAN}Gencow Deploy (Fullstack)${RESET}\n`);
|
|
@@ -2358,10 +2445,10 @@ ${BOLD}Examples:${RESET}
|
|
|
2358
2445
|
exec(`cp "${src}" "${dst}"`, { cwd: backendRoot });
|
|
2359
2446
|
}
|
|
2360
2447
|
}
|
|
2361
|
-
exec(`tar -czf "${tmpBackendBundle}" .`, { cwd: tmpDir });
|
|
2448
|
+
exec(`COPYFILE_DISABLE=1 tar -czf "${tmpBackendBundle}" .`, { cwd: tmpDir });
|
|
2362
2449
|
exec(`rm -rf "${tmpDir}"`, { cwd: backendRoot });
|
|
2363
2450
|
} else {
|
|
2364
|
-
exec(`tar -czf "${tmpBackendBundle}" ${backendFiles.join(" ")}`, { cwd: backendRoot });
|
|
2451
|
+
exec(`COPYFILE_DISABLE=1 tar -czf "${tmpBackendBundle}" ${backendFiles.join(" ")}`, { cwd: backendRoot });
|
|
2365
2452
|
}
|
|
2366
2453
|
} catch (e) {
|
|
2367
2454
|
error(`백엔드 패키징 실패: ${e.message}`);
|
|
@@ -2452,7 +2539,7 @@ ${BOLD}Examples:${RESET}
|
|
|
2452
2539
|
mkdirSync(dirname(tmpBundle), { recursive: true });
|
|
2453
2540
|
|
|
2454
2541
|
try {
|
|
2455
|
-
exec(`tar -czf "${tmpBundle}" -C "${resolve(process.cwd(), targetDir)}" .`, { cwd: process.cwd() });
|
|
2542
|
+
exec(`COPYFILE_DISABLE=1 tar -czf "${tmpBundle}" -C "${resolve(process.cwd(), targetDir)}" .`, { cwd: process.cwd() });
|
|
2456
2543
|
} catch (e) {
|
|
2457
2544
|
error(`패키징 실패: ${e.message}`);
|
|
2458
2545
|
process.exit(1);
|
|
@@ -3366,56 +3453,90 @@ process.exit(0);
|
|
|
3366
3453
|
return;
|
|
3367
3454
|
}
|
|
3368
3455
|
|
|
3369
|
-
// ── Follow 모드: WebSocket 실시간 스트리밍 ──
|
|
3456
|
+
// ── Follow 모드: WebSocket 실시간 스트리밍 (지수 백오프 재연결) ──
|
|
3370
3457
|
const config = loadConfig();
|
|
3371
3458
|
const port = process.env.PORT || config.port || 5456;
|
|
3372
|
-
const wsUrl = `ws://localhost:${port}/ws`;
|
|
3373
3459
|
|
|
3374
3460
|
log(`\n${BOLD}${CYAN}Gencow Logs${RESET} ${DIM}— streaming (Ctrl+C to stop)${RESET}\n`);
|
|
3375
|
-
info(`Connecting to ${DIM}${wsUrl}${RESET}...`);
|
|
3376
3461
|
|
|
3377
|
-
const
|
|
3378
|
-
const
|
|
3462
|
+
const FOLLOW_BASE_RECONNECT_MS = 3000;
|
|
3463
|
+
const FOLLOW_MAX_RECONNECT_MS = 30000;
|
|
3464
|
+
const FOLLOW_MAX_FAILURES = 5;
|
|
3465
|
+
let followFailures = 0;
|
|
3466
|
+
let followTimer = null;
|
|
3467
|
+
let followWs = null;
|
|
3379
3468
|
|
|
3380
|
-
|
|
3381
|
-
|
|
3382
|
-
|
|
3383
|
-
});
|
|
3469
|
+
async function connectFollowStream() {
|
|
3470
|
+
const wsUrl = `ws://localhost:${port}/ws`;
|
|
3471
|
+
info(`Connecting to ${DIM}${wsUrl}${RESET}...`);
|
|
3384
3472
|
|
|
3385
|
-
|
|
3473
|
+
const { WebSocket: WS } = await import("ws");
|
|
3386
3474
|
try {
|
|
3387
|
-
|
|
3475
|
+
followWs = new WS(wsUrl);
|
|
3476
|
+
} catch (e) {
|
|
3477
|
+
error(`WebSocket error: ${e.message}`);
|
|
3478
|
+
scheduleFollowReconnect();
|
|
3479
|
+
return;
|
|
3480
|
+
}
|
|
3388
3481
|
|
|
3389
|
-
|
|
3390
|
-
|
|
3391
|
-
|
|
3482
|
+
followWs.on("open", () => {
|
|
3483
|
+
followFailures = 0; // 성공 → 실패 카운터 리셋
|
|
3484
|
+
success("Connected — streaming logs...\n");
|
|
3485
|
+
followWs.send(JSON.stringify({ type: "log:subscribe" }));
|
|
3486
|
+
});
|
|
3392
3487
|
|
|
3393
|
-
|
|
3394
|
-
|
|
3395
|
-
const
|
|
3396
|
-
|
|
3397
|
-
|
|
3398
|
-
|
|
3399
|
-
|
|
3488
|
+
followWs.on("message", (raw) => {
|
|
3489
|
+
try {
|
|
3490
|
+
const msg = JSON.parse(raw.toString());
|
|
3491
|
+
|
|
3492
|
+
if (msg.type === "log:subscribed") {
|
|
3493
|
+
return; // 구독 확인 — 무시
|
|
3494
|
+
}
|
|
3495
|
+
|
|
3496
|
+
if (msg.type === "log:entry") {
|
|
3497
|
+
const ts = msg.timestamp?.slice(11, 19) || "";
|
|
3498
|
+
const lvl = msg.level === "error" ? `${RED}ERR${RESET}`
|
|
3499
|
+
: msg.level === "warn" ? `${YELLOW}WRN${RESET}`
|
|
3500
|
+
: `${DIM}INF${RESET}`;
|
|
3501
|
+
const src = msg.source ? ` ${DIM}[${msg.source}]${RESET}` : "";
|
|
3502
|
+
log(` ${DIM}${ts}${RESET} ${lvl}${src} ${msg.message}`);
|
|
3503
|
+
}
|
|
3504
|
+
} catch { /* ignore non-JSON */ }
|
|
3505
|
+
});
|
|
3506
|
+
|
|
3507
|
+
followWs.on("error", () => {
|
|
3508
|
+
// 에러는 close 이벤트로 처리
|
|
3509
|
+
});
|
|
3510
|
+
|
|
3511
|
+
followWs.on("close", () => {
|
|
3512
|
+
followFailures++;
|
|
3513
|
+
if (followFailures > FOLLOW_MAX_FAILURES) {
|
|
3514
|
+
error(`연결 불가 — 앱 상태를 확인하세요.`);
|
|
3515
|
+
info(`서버가 실행 중인지 확인: gencow dev`);
|
|
3516
|
+
process.exit(1);
|
|
3400
3517
|
}
|
|
3401
|
-
|
|
3402
|
-
|
|
3518
|
+
const delay = Math.min(FOLLOW_BASE_RECONNECT_MS * Math.pow(2, followFailures - 1), FOLLOW_MAX_RECONNECT_MS);
|
|
3519
|
+
const delaySec = (delay / 1000).toFixed(0);
|
|
3520
|
+
warn(`연결 끊김 — ${delaySec}초 후 재연결... (${followFailures}/${FOLLOW_MAX_FAILURES})`);
|
|
3521
|
+
scheduleFollowReconnect(delay);
|
|
3522
|
+
});
|
|
3523
|
+
}
|
|
3403
3524
|
|
|
3404
|
-
|
|
3405
|
-
|
|
3406
|
-
|
|
3407
|
-
|
|
3408
|
-
|
|
3525
|
+
function scheduleFollowReconnect(delayMs = FOLLOW_BASE_RECONNECT_MS) {
|
|
3526
|
+
if (followTimer) return;
|
|
3527
|
+
followTimer = setTimeout(() => {
|
|
3528
|
+
followTimer = null;
|
|
3529
|
+
connectFollowStream().catch(() => { });
|
|
3530
|
+
}, delayMs);
|
|
3531
|
+
}
|
|
3409
3532
|
|
|
3410
|
-
|
|
3411
|
-
log(`\n${DIM} Connection closed.${RESET}\n`);
|
|
3412
|
-
process.exit(0);
|
|
3413
|
-
});
|
|
3533
|
+
connectFollowStream().catch(() => { });
|
|
3414
3534
|
|
|
3415
3535
|
// Ctrl+C 처리
|
|
3416
3536
|
process.on("SIGINT", () => {
|
|
3417
3537
|
log(`\n\n${DIM} Stopped streaming.${RESET}\n`);
|
|
3418
|
-
|
|
3538
|
+
if (followWs) followWs.close();
|
|
3539
|
+
if (followTimer) clearTimeout(followTimer);
|
|
3419
3540
|
process.exit(0);
|
|
3420
3541
|
});
|
|
3421
3542
|
},
|
|
@@ -3838,7 +3959,7 @@ process.exit(0);
|
|
|
3838
3959
|
|
|
3839
3960
|
// 지수 백오프 재연결 상수
|
|
3840
3961
|
const BASE_RECONNECT_MS = 3000;
|
|
3841
|
-
const MAX_RECONNECT_MS =
|
|
3962
|
+
const MAX_RECONNECT_MS = 30000; // 30s cap (기존 60s → 30s로 축소)
|
|
3842
3963
|
const MAX_RECONNECT_FAILURES = 5;
|
|
3843
3964
|
let reconnectFailures = 0;
|
|
3844
3965
|
|
package/core/index.js
CHANGED
|
@@ -1880,131 +1880,188 @@ function ownerRls(userIdColumn, options) {
|
|
|
1880
1880
|
// ../core/src/rls-db.ts
|
|
1881
1881
|
import { sql as sql2 } from "drizzle-orm";
|
|
1882
1882
|
function createRlsDb(db, userId) {
|
|
1883
|
-
return {
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1883
|
+
return new Proxy(db, {
|
|
1884
|
+
get(target, prop, receiver) {
|
|
1885
|
+
if (prop === "transaction") {
|
|
1886
|
+
return async (callback, ...rest) => {
|
|
1887
|
+
return await target.transaction(async (tx) => {
|
|
1888
|
+
await tx.execute(
|
|
1889
|
+
sql2`SELECT set_config('app.current_user_id', ${userId}, true)`
|
|
1890
|
+
);
|
|
1891
|
+
return await callback(tx);
|
|
1892
|
+
}, ...rest);
|
|
1893
|
+
};
|
|
1894
|
+
}
|
|
1895
|
+
const value = Reflect.get(target, prop, receiver);
|
|
1896
|
+
return typeof value === "function" ? value.bind(target) : value;
|
|
1897
|
+
}
|
|
1898
|
+
});
|
|
1894
1899
|
}
|
|
1895
1900
|
|
|
1896
1901
|
// ../core/src/crud.ts
|
|
1897
|
-
import { eq,
|
|
1898
|
-
function
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1902
|
+
import { eq, desc, asc, ilike, or, and, count as drizzleCount, getTableName } from "drizzle-orm";
|
|
1903
|
+
function detectIdType(column) {
|
|
1904
|
+
const colType = column.dataType;
|
|
1905
|
+
if (colType === "string") return v.string();
|
|
1906
|
+
return v.number();
|
|
1907
|
+
}
|
|
1908
|
+
function crud(table, options) {
|
|
1909
|
+
const anyTable = table;
|
|
1910
|
+
const tableName = getTableName(table);
|
|
1911
|
+
const prefix = options?.prefix || tableName;
|
|
1912
|
+
const isPublic = options?.public ?? false;
|
|
1913
|
+
const useRealtime = options?.realtime ?? true;
|
|
1914
|
+
const defaultLimit = options?.defaultLimit ?? 20;
|
|
1915
|
+
const maxLimit = options?.maxLimit ?? 100;
|
|
1916
|
+
const pk = anyTable["id"];
|
|
1917
|
+
if (!pk) {
|
|
1918
|
+
throw new Error(`[crud] Table "${tableName}" must have an 'id' column.`);
|
|
1919
|
+
}
|
|
1920
|
+
const idValidator = detectIdType(pk);
|
|
1921
|
+
const createdAtCol = anyTable["createdAt"];
|
|
1922
|
+
const defaultOrderCol = createdAtCol || pk;
|
|
1923
|
+
const userIdCol = anyTable["userId"];
|
|
1924
|
+
function buildWhereConditions(args) {
|
|
1925
|
+
const conditions = [];
|
|
1926
|
+
if (options?.softDelete) {
|
|
1927
|
+
const sdField = anyTable[options.softDelete.field];
|
|
1928
|
+
conditions.push(eq(sdField, null));
|
|
1904
1929
|
}
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
const [result] = await db.insert(anyTable).values(insertData).returning();
|
|
1911
|
-
return result;
|
|
1930
|
+
if (args?.search && options?.searchFields?.length) {
|
|
1931
|
+
const searchConds = options.searchFields.map(
|
|
1932
|
+
(f) => ilike(anyTable[f], `%${args.search}%`)
|
|
1933
|
+
);
|
|
1934
|
+
conditions.push(or(...searchConds));
|
|
1912
1935
|
}
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1936
|
+
if (args?.filters && options?.allowedFilters?.length) {
|
|
1937
|
+
for (const [key, value] of Object.entries(args.filters)) {
|
|
1938
|
+
if (options.allowedFilters.includes(key) && anyTable[key]) {
|
|
1939
|
+
conditions.push(eq(anyTable[key], value));
|
|
1940
|
+
}
|
|
1918
1941
|
}
|
|
1919
|
-
const [result] = await db.select().from(anyTable).where(whereCond).limit(1);
|
|
1920
|
-
return result || null;
|
|
1921
1942
|
}
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
)
|
|
1943
|
+
return conditions.length > 0 ? and(...conditions) : void 0;
|
|
1944
|
+
}
|
|
1945
|
+
async function fetchListWithTotal(db, whereClause) {
|
|
1946
|
+
const [data, countResult] = await Promise.all([
|
|
1947
|
+
db.select().from(anyTable).where(whereClause).orderBy(desc(defaultOrderCol)),
|
|
1948
|
+
db.select({ count: drizzleCount() }).from(anyTable).where(whereClause)
|
|
1949
|
+
]);
|
|
1950
|
+
return { data, total: Number(countResult[0]?.count ?? 0) };
|
|
1951
|
+
}
|
|
1952
|
+
const listDef = query(`${prefix}.list`, {
|
|
1953
|
+
public: isPublic,
|
|
1954
|
+
args: {
|
|
1955
|
+
page: v.optional(v.number()),
|
|
1956
|
+
limit: v.optional(v.number()),
|
|
1957
|
+
search: v.optional(v.string()),
|
|
1958
|
+
orderBy: v.optional(v.string()),
|
|
1959
|
+
orderDir: v.optional(v.string()),
|
|
1960
|
+
filters: v.optional(v.any())
|
|
1961
|
+
},
|
|
1962
|
+
handler: async (ctx, args) => {
|
|
1963
|
+
if (!isPublic) ctx.auth.requireAuth();
|
|
1964
|
+
const page = Math.max(1, args?.page || 1);
|
|
1965
|
+
const limit = Math.min(Math.max(1, args?.limit || defaultLimit), maxLimit);
|
|
1928
1966
|
const offset = (page - 1) * limit;
|
|
1929
|
-
const
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
);
|
|
1937
|
-
conditions.push(or(...searchConds));
|
|
1938
|
-
}
|
|
1939
|
-
if (params?.filters && options?.allowedFilters) {
|
|
1940
|
-
for (const [k, v2] of Object.entries(params.filters)) {
|
|
1941
|
-
if (options.allowedFilters.includes(k)) {
|
|
1942
|
-
conditions.push(eq(anyTable[k], v2));
|
|
1943
|
-
}
|
|
1944
|
-
}
|
|
1967
|
+
const whereClause = buildWhereConditions(args);
|
|
1968
|
+
let orderByClause;
|
|
1969
|
+
if (args?.orderBy && anyTable[args.orderBy]) {
|
|
1970
|
+
const col = anyTable[args.orderBy];
|
|
1971
|
+
orderByClause = args.orderDir === "asc" ? asc(col) : desc(col);
|
|
1972
|
+
} else {
|
|
1973
|
+
orderByClause = desc(defaultOrderCol);
|
|
1945
1974
|
}
|
|
1946
|
-
const
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
});
|
|
1951
|
-
const [{ count: total }] = await db.select({ count: count() }).from(anyTable).where(whereClause);
|
|
1952
|
-
const results = await db.select().from(anyTable).where(whereClause).orderBy(...orderByArgs).limit(limit).offset(offset);
|
|
1975
|
+
const [results, countResult] = await Promise.all([
|
|
1976
|
+
ctx.db.select().from(anyTable).where(whereClause).orderBy(orderByClause).limit(limit).offset(offset),
|
|
1977
|
+
ctx.db.select({ count: drizzleCount() }).from(anyTable).where(whereClause)
|
|
1978
|
+
]);
|
|
1953
1979
|
return {
|
|
1954
|
-
results,
|
|
1955
|
-
|
|
1956
|
-
limit,
|
|
1957
|
-
total: Number(total)
|
|
1980
|
+
data: results,
|
|
1981
|
+
total: Number(countResult[0]?.count ?? 0)
|
|
1958
1982
|
};
|
|
1959
1983
|
}
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
}
|
|
1968
|
-
async function deleteOne(id) {
|
|
1984
|
+
});
|
|
1985
|
+
const getDef = query(`${prefix}.get`, {
|
|
1986
|
+
public: isPublic,
|
|
1987
|
+
args: { id: idValidator },
|
|
1988
|
+
handler: async (ctx, args) => {
|
|
1989
|
+
if (!isPublic) ctx.auth.requireAuth();
|
|
1990
|
+
let whereCond = eq(pk, args.id);
|
|
1969
1991
|
if (options?.softDelete) {
|
|
1970
|
-
const sdField = options.softDelete.field;
|
|
1971
|
-
|
|
1972
|
-
} else {
|
|
1973
|
-
await db.delete(anyTable).where(eq(pk, id));
|
|
1992
|
+
const sdField = anyTable[options.softDelete.field];
|
|
1993
|
+
whereCond = and(whereCond, eq(sdField, null));
|
|
1974
1994
|
}
|
|
1995
|
+
const [result] = await ctx.db.select().from(anyTable).where(whereCond).limit(1);
|
|
1996
|
+
return result ?? null;
|
|
1975
1997
|
}
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1998
|
+
});
|
|
1999
|
+
const createDef = mutation(`${prefix}.create`, {
|
|
2000
|
+
public: isPublic,
|
|
2001
|
+
invalidates: [],
|
|
2002
|
+
handler: async (ctx, args) => {
|
|
2003
|
+
const user = isPublic ? null : ctx.auth.requireAuth();
|
|
2004
|
+
let insertData = { ...args };
|
|
2005
|
+
if (userIdCol && user && !insertData.userId) {
|
|
2006
|
+
insertData.userId = user.id;
|
|
1980
2007
|
}
|
|
1981
|
-
}
|
|
1982
|
-
async function bulkCreate(dataArray) {
|
|
1983
|
-
let insertData = [...dataArray];
|
|
1984
2008
|
if (options?.hooks?.beforeCreate) {
|
|
1985
|
-
insertData = await
|
|
2009
|
+
insertData = await options.hooks.beforeCreate(insertData);
|
|
2010
|
+
}
|
|
2011
|
+
const [result] = await ctx.db.insert(anyTable).values(insertData).returning();
|
|
2012
|
+
if (useRealtime) {
|
|
2013
|
+
const listResult = await fetchListWithTotal(ctx.db);
|
|
2014
|
+
ctx.realtime.emit(`${prefix}.list`, listResult);
|
|
1986
2015
|
}
|
|
1987
|
-
return
|
|
2016
|
+
return result;
|
|
2017
|
+
}
|
|
2018
|
+
});
|
|
2019
|
+
const updateDef = mutation(`${prefix}.update`, {
|
|
2020
|
+
public: isPublic,
|
|
2021
|
+
invalidates: [],
|
|
2022
|
+
handler: async (ctx, args) => {
|
|
2023
|
+
if (!isPublic) ctx.auth.requireAuth();
|
|
2024
|
+
const { id, ...updates } = args;
|
|
2025
|
+
let updateData = { ...updates };
|
|
2026
|
+
if (anyTable["updatedAt"]) {
|
|
2027
|
+
updateData.updatedAt = /* @__PURE__ */ new Date();
|
|
2028
|
+
}
|
|
2029
|
+
if (options?.hooks?.beforeUpdate) {
|
|
2030
|
+
updateData = await options.hooks.beforeUpdate(updateData);
|
|
2031
|
+
}
|
|
2032
|
+
const [result] = await ctx.db.update(anyTable).set(updateData).where(eq(pk, id)).returning();
|
|
2033
|
+
if (useRealtime) {
|
|
2034
|
+
const listResult = await fetchListWithTotal(ctx.db);
|
|
2035
|
+
ctx.realtime.emit(`${prefix}.list`, listResult);
|
|
2036
|
+
ctx.realtime.emit(`${prefix}.get`, result);
|
|
2037
|
+
}
|
|
2038
|
+
return result;
|
|
1988
2039
|
}
|
|
1989
|
-
|
|
1990
|
-
|
|
2040
|
+
});
|
|
2041
|
+
const removeDef = mutation(`${prefix}.remove`, {
|
|
2042
|
+
public: isPublic,
|
|
2043
|
+
invalidates: [],
|
|
2044
|
+
handler: async (ctx, args) => {
|
|
2045
|
+
if (!isPublic) ctx.auth.requireAuth();
|
|
1991
2046
|
if (options?.softDelete) {
|
|
1992
2047
|
const sdField = options.softDelete.field;
|
|
1993
|
-
await db.update(anyTable).set({ [sdField]: /* @__PURE__ */ new Date() }).where(
|
|
2048
|
+
await ctx.db.update(anyTable).set({ [sdField]: /* @__PURE__ */ new Date() }).where(eq(pk, args.id));
|
|
1994
2049
|
} else {
|
|
1995
|
-
await db.delete(anyTable).where(
|
|
2050
|
+
await ctx.db.delete(anyTable).where(eq(pk, args.id));
|
|
2051
|
+
}
|
|
2052
|
+
if (useRealtime) {
|
|
2053
|
+
const listResult = await fetchListWithTotal(ctx.db);
|
|
2054
|
+
ctx.realtime.emit(`${prefix}.list`, listResult);
|
|
1996
2055
|
}
|
|
2056
|
+
return { success: true };
|
|
1997
2057
|
}
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
bulkCreate,
|
|
2006
|
-
bulkDelete
|
|
2007
|
-
};
|
|
2058
|
+
});
|
|
2059
|
+
return {
|
|
2060
|
+
list: listDef,
|
|
2061
|
+
get: getDef,
|
|
2062
|
+
create: createDef,
|
|
2063
|
+
update: updateDef,
|
|
2064
|
+
remove: removeDef
|
|
2008
2065
|
};
|
|
2009
2066
|
}
|
|
2010
2067
|
export {
|
|
@@ -2013,9 +2070,10 @@ export {
|
|
|
2013
2070
|
createRlsDb,
|
|
2014
2071
|
createScheduler,
|
|
2015
2072
|
cronJobs,
|
|
2073
|
+
crud,
|
|
2016
2074
|
defineAuth,
|
|
2017
2075
|
deregisterClient,
|
|
2018
|
-
gencowCrud,
|
|
2076
|
+
crud as gencowCrud,
|
|
2019
2077
|
getQueryDef,
|
|
2020
2078
|
getQueryHandler,
|
|
2021
2079
|
getRegisteredHttpActions,
|