gencow 0.1.114 → 0.1.116

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 CHANGED
@@ -1718,6 +1718,10 @@ ${BOLD}BaaS commands (login required):${RESET}
1718
1718
  ${GREEN}env set K=V${RESET} Set cloud env var ${DIM}(hot-reload, no restart)${RESET}
1719
1719
  ${GREEN}env unset KEY${RESET} Remove cloud env var
1720
1720
  ${GREEN}env push${RESET} Push .env to cloud
1721
+ ${GREEN}files upload${RESET} Upload files to app storage ${DIM}(--recursive for dirs)${RESET}
1722
+ ${GREEN}files list${RESET} List uploaded files
1723
+ ${GREEN}files delete${RESET} Delete uploaded file ${DIM}(--yes to skip confirm)${RESET}
1724
+ ${GREEN}files url${RESET} Get serving URL for a file
1721
1725
  ${GREEN}domain set${RESET} Connect custom domain ${DIM}(myapp.com)${RESET}
1722
1726
  ${GREEN}domain status${RESET} Check domain DNS/TLS status
1723
1727
  ${GREEN}domain remove${RESET} Disconnect custom domain
@@ -2981,6 +2985,297 @@ ${BOLD}Examples:${RESET}
2981
2985
  },
2982
2986
 
2983
2987
 
2988
+ // ── files ─────────────────────────────────────────────
2989
+ async files(...filesArgs) {
2990
+ const subCmd = filesArgs[0] || "help";
2991
+ const restArgs = filesArgs.slice(1);
2992
+
2993
+ // help는 인증/앱 정보 없이도 표시
2994
+ if (subCmd === "help" || subCmd === "--help" || subCmd === "-h") {
2995
+ log(`\n${BOLD}${CYAN}gencow files${RESET} — 파일 관리\n`);
2996
+ log(` ${CYAN}upload${RESET} <경로...> [--recursive|-r] 파일 업로드`);
2997
+ log(` ${CYAN}list${RESET} 파일 목록`);
2998
+ log(` ${CYAN}delete${RESET} <storage_id> [--yes|-y] 파일 삭제`);
2999
+ log(` ${CYAN}url${RESET} <storage_id> 서빙 URL 출력`);
3000
+ log(`\n ${DIM}옵션:${RESET}`);
3001
+ log(` ${DIM}--app, -a <앱이름> 대상 앱 지정 (기본: gencow.json)${RESET}\n`);
3002
+ return;
3003
+ }
3004
+
3005
+ const creds = requireCreds();
3006
+
3007
+ // 앱 ID 결정 + --app 플래그 위치 추적 (paths 필터링용)
3008
+ let appId = null;
3009
+ const flagIndices = new Set(); // --app와 그 값의 인덱스
3010
+ for (let i = 0; i < restArgs.length; i++) {
3011
+ if (restArgs[i] === "--app" || restArgs[i] === "-a") {
3012
+ flagIndices.add(i);
3013
+ flagIndices.add(i + 1);
3014
+ appId = restArgs[++i];
3015
+ }
3016
+ }
3017
+
3018
+ if (!appId) {
3019
+ const gencowJsonPath = resolve(process.cwd(), "gencow.json");
3020
+ if (existsSync(gencowJsonPath)) {
3021
+ const gencowJson = JSON.parse(readFileSync(gencowJsonPath, "utf8"));
3022
+ appId = gencowJson.appId || gencowJson.appName;
3023
+ }
3024
+ }
3025
+
3026
+ if (!appId) {
3027
+ error("앱 ID를 찾을 수 없습니다. gencow deploy를 먼저 실행하세요.");
3028
+ return;
3029
+ }
3030
+
3031
+ const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB
3032
+
3033
+ function fmtFileSize(bytes) {
3034
+ if (bytes < 1024) return `${bytes} B`;
3035
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
3036
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
3037
+ }
3038
+
3039
+ function fmtDate(d) {
3040
+ return new Date(d).toLocaleString("ko-KR", {
3041
+ year: "numeric", month: "2-digit", day: "2-digit",
3042
+ hour: "2-digit", minute: "2-digit",
3043
+ });
3044
+ }
3045
+
3046
+ /** 파일 서빙 URL 생성 */
3047
+ function getFileUrl(storageId) {
3048
+ return `${getAppUrl(appId, creds.platformUrl)}/api/storage/${storageId}`;
3049
+ }
3050
+
3051
+ /** 단일 파일 업로드 */
3052
+ async function uploadFile(filePath) {
3053
+ const fileName = basename(filePath);
3054
+ const stat = statSync(filePath);
3055
+
3056
+ // 로컬 크기 검증 (서버 왕복 방지)
3057
+ if (stat.size > MAX_FILE_SIZE) {
3058
+ error(`${fileName}: ${fmtFileSize(stat.size)} — 50MB 제한 초과`);
3059
+ return null;
3060
+ }
3061
+
3062
+ info(`${fileName} (${fmtFileSize(stat.size)}) 업로드 중...`);
3063
+
3064
+ const fileBuffer = readFileSync(filePath);
3065
+ const blob = new Blob([fileBuffer]);
3066
+
3067
+ const formData = new FormData();
3068
+ formData.append("file", blob, fileName);
3069
+
3070
+ try {
3071
+ const res = await fetch(
3072
+ `${creds.platformUrl}/platform/files/upload?appName=${appId}`,
3073
+ {
3074
+ method: "POST",
3075
+ headers: {
3076
+ "Authorization": `Bearer ${creds.apiKey}`,
3077
+ },
3078
+ body: formData,
3079
+ }
3080
+ );
3081
+
3082
+ const data = await res.json().catch(() => ({}));
3083
+ if (!res.ok) {
3084
+ error(`${fileName}: ${data.error || res.statusText}`);
3085
+ return null;
3086
+ }
3087
+
3088
+ success(`${fileName} → ${data.storageId}`);
3089
+ info(` URL: ${getFileUrl(data.storageId)}`);
3090
+ return data;
3091
+ } catch (err) {
3092
+ error(`${fileName}: ${err.message}`);
3093
+ return null;
3094
+ }
3095
+ }
3096
+
3097
+ /** 디렉토리 재귀 파일 수집 */
3098
+ function collectFiles(dirPath) {
3099
+ const results = [];
3100
+ const entries = readdirSync(dirPath, { withFileTypes: true });
3101
+ for (const entry of entries) {
3102
+ const full = resolve(dirPath, entry.name);
3103
+ if (entry.isDirectory()) {
3104
+ results.push(...collectFiles(full));
3105
+ } else if (entry.isFile()) {
3106
+ results.push(full);
3107
+ }
3108
+ }
3109
+ return results;
3110
+ }
3111
+
3112
+ switch (subCmd) {
3113
+ case "upload":
3114
+ case "up": {
3115
+ const recursive = restArgs.includes("--recursive") || restArgs.includes("-r");
3116
+ const paths = restArgs.filter((a, i) => !a.startsWith("-") && !flagIndices.has(i));
3117
+
3118
+ if (paths.length === 0) {
3119
+ error("사용법: gencow files upload <파일|폴더 경로...> [--recursive|-r]");
3120
+ return;
3121
+ }
3122
+
3123
+ let filesToUpload = [];
3124
+ for (const p of paths) {
3125
+ const resolved = resolve(process.cwd(), p);
3126
+ if (!existsSync(resolved)) {
3127
+ error(`파일을 찾을 수 없습니다: ${p}`);
3128
+ continue;
3129
+ }
3130
+ const stat = statSync(resolved);
3131
+ if (stat.isDirectory()) {
3132
+ if (!recursive) {
3133
+ error(`${p}는 디렉토리입니다. --recursive (-r) 옵션을 사용하세요.`);
3134
+ continue;
3135
+ }
3136
+ filesToUpload.push(...collectFiles(resolved));
3137
+ } else {
3138
+ filesToUpload.push(resolved);
3139
+ }
3140
+ }
3141
+
3142
+ if (filesToUpload.length === 0) {
3143
+ error("업로드할 파일이 없습니다.");
3144
+ return;
3145
+ }
3146
+
3147
+ log(`\n${BOLD}${CYAN}파일 업로드${RESET} — ${appId}\n`);
3148
+ info(`${filesToUpload.length}개 파일 업로드 시작...\n`);
3149
+
3150
+ let uploaded = 0;
3151
+ let failed = 0;
3152
+ for (const filePath of filesToUpload) {
3153
+ const result = await uploadFile(filePath);
3154
+ if (result) uploaded++;
3155
+ else failed++;
3156
+ }
3157
+
3158
+ log("");
3159
+ if (uploaded > 0) success(`${uploaded}개 파일 업로드 완료`);
3160
+ if (failed > 0) warn(`${failed}개 파일 실패`);
3161
+ break;
3162
+ }
3163
+
3164
+ case "list":
3165
+ case "ls": {
3166
+ log(`\n${BOLD}${CYAN}파일 목록${RESET} — ${appId}\n`);
3167
+
3168
+ const res = await fetch(
3169
+ `${creds.platformUrl}/platform/files/list`,
3170
+ {
3171
+ method: "POST",
3172
+ headers: {
3173
+ "Authorization": `Bearer ${creds.apiKey}`,
3174
+ "Content-Type": "application/json",
3175
+ },
3176
+ body: JSON.stringify({ appName: appId }),
3177
+ }
3178
+ );
3179
+
3180
+ const data = await res.json().catch(() => []);
3181
+ if (!res.ok) {
3182
+ error(data.error || "파일 목록 조회 실패");
3183
+ return;
3184
+ }
3185
+
3186
+ const files = Array.isArray(data) ? data : [];
3187
+ if (files.length === 0) {
3188
+ info("저장된 파일이 없습니다.");
3189
+ log("");
3190
+ return;
3191
+ }
3192
+
3193
+ // 테이블 형식 출력
3194
+ const idWidth = Math.max(12, ...files.map(f => (f.storage_id || "").length));
3195
+ const nameWidth = Math.max(8, ...files.map(f => (f.name || "").length).map(l => Math.min(l, 40)));
3196
+
3197
+ log(` ${DIM}${"ID".padEnd(idWidth)} ${"이름".padEnd(nameWidth)} ${"크기".padStart(10)} ${"소스".padEnd(10)} 업로드 시각${RESET}`);
3198
+ log(` ${DIM}${"─".repeat(idWidth)} ${"─".repeat(nameWidth)} ${"─".repeat(10)} ${"─".repeat(10)} ${"─".repeat(16)}${RESET}`);
3199
+
3200
+ for (const f of files) {
3201
+ const name = (f.name || "").length > 40 ? f.name.slice(0, 37) + "..." : (f.name || "");
3202
+ const size = fmtFileSize(Number(f.size) || 0).padStart(10);
3203
+ const source = (f.uploaded_by || "api").padEnd(10);
3204
+ const date = f.created_at ? fmtDate(f.created_at) : "-";
3205
+ log(` ${(f.storage_id || "").padEnd(idWidth)} ${name.padEnd(nameWidth)} ${size} ${source} ${date}`);
3206
+ }
3207
+ log(`\n ${DIM}총 ${files.length}개 파일${RESET}\n`);
3208
+ break;
3209
+ }
3210
+
3211
+ case "delete":
3212
+ case "rm": {
3213
+ const storageId = restArgs.find((a, i) => !a.startsWith("-") && !flagIndices.has(i));
3214
+ if (!storageId) {
3215
+ error("사용법: gencow files delete <storage_id>");
3216
+ return;
3217
+ }
3218
+
3219
+ // 확인 프롬프트
3220
+ const skipConfirm = restArgs.includes("--yes") || restArgs.includes("-y");
3221
+ if (!skipConfirm) {
3222
+ const { createInterface } = await import("readline");
3223
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
3224
+ const answer = await new Promise(resolve => {
3225
+ rl.question(`\n ${YELLOW}정말 삭제하시겠습니까?${RESET} ${DIM}${storageId}${RESET} (y/N): `, a => {
3226
+ rl.close();
3227
+ resolve(a.trim().toLowerCase());
3228
+ });
3229
+ });
3230
+ if (answer !== "y" && answer !== "yes") {
3231
+ info("삭제를 취소했습니다.");
3232
+ return;
3233
+ }
3234
+ }
3235
+
3236
+ const res = await fetch(
3237
+ `${creds.platformUrl}/platform/files/delete`,
3238
+ {
3239
+ method: "POST",
3240
+ headers: {
3241
+ "Authorization": `Bearer ${creds.apiKey}`,
3242
+ "Content-Type": "application/json",
3243
+ },
3244
+ body: JSON.stringify({ appName: appId, storageId }),
3245
+ }
3246
+ );
3247
+
3248
+ const data = await res.json().catch(() => ({}));
3249
+ if (res.ok && data.success) {
3250
+ success(`파일 삭제 완료: ${storageId}`);
3251
+ } else {
3252
+ error(`삭제 실패: ${data.error || res.statusText}`);
3253
+ }
3254
+ break;
3255
+ }
3256
+
3257
+ case "url": {
3258
+ const storageId = restArgs.find((a, i) => !a.startsWith("-") && !flagIndices.has(i));
3259
+ if (!storageId) {
3260
+ error("사용법: gencow files url <storage_id>");
3261
+ return;
3262
+ }
3263
+ log(getFileUrl(storageId));
3264
+ break;
3265
+ }
3266
+
3267
+ default:
3268
+ if (subCmd !== "help") error(`알 수 없는 하위 명령: ${subCmd}`);
3269
+ log(`\n${BOLD}${CYAN}gencow files${RESET} — 파일 관리\n`);
3270
+ log(` ${CYAN}upload${RESET} <경로...> [--recursive|-r] 파일 업로드`);
3271
+ log(` ${CYAN}list${RESET} 파일 목록`);
3272
+ log(` ${CYAN}delete${RESET} <storage_id> [--yes|-y] 파일 삭제`);
3273
+ log(` ${CYAN}url${RESET} <storage_id> 서빙 URL 출력`);
3274
+ log(`\n ${DIM}옵션:${RESET}`);
3275
+ log(` ${DIM}--app, -a <앱이름> 대상 앱 지정 (기본: gencow.json)${RESET}\n`);
3276
+ }
3277
+ },
3278
+
2984
3279
  // ── backup ─────────────────────────────────────────────
2985
3280
  async backup(...backupArgs) {
2986
3281
  const creds = requireCreds();
package/core/index.js CHANGED
@@ -1402,7 +1402,6 @@ function query(key, handlerOrDef) {
1402
1402
  }
1403
1403
  var mutationCounter = 0;
1404
1404
  function mutation(nameOrInvalidatesOrDef, handlerOrDef, name) {
1405
- let invalidates;
1406
1405
  let argsSchema;
1407
1406
  let actualHandler;
1408
1407
  let mutName;
@@ -1410,16 +1409,13 @@ function mutation(nameOrInvalidatesOrDef, handlerOrDef, name) {
1410
1409
  if (typeof nameOrInvalidatesOrDef === "string") {
1411
1410
  mutName = nameOrInvalidatesOrDef;
1412
1411
  const def2 = handlerOrDef;
1413
- invalidates = def2.invalidates || [];
1414
1412
  actualHandler = def2.handler;
1415
1413
  argsSchema = def2.args;
1416
1414
  isPublic = def2.public === true;
1417
1415
  } else if (Array.isArray(nameOrInvalidatesOrDef)) {
1418
- invalidates = nameOrInvalidatesOrDef;
1419
1416
  actualHandler = handlerOrDef;
1420
1417
  mutName = name || `mutation_${++mutationCounter}`;
1421
1418
  } else {
1422
- invalidates = nameOrInvalidatesOrDef.invalidates;
1423
1419
  actualHandler = nameOrInvalidatesOrDef.handler;
1424
1420
  argsSchema = nameOrInvalidatesOrDef.args;
1425
1421
  isPublic = nameOrInvalidatesOrDef.public === true;
@@ -1432,7 +1428,6 @@ function mutation(nameOrInvalidatesOrDef, handlerOrDef, name) {
1432
1428
  }
1433
1429
  const def = {
1434
1430
  name: mutName,
1435
- invalidates,
1436
1431
  handler: actualHandler,
1437
1432
  argsSchema,
1438
1433
  isPublic
@@ -1477,8 +1472,11 @@ function deregisterClient(ws) {
1477
1472
  }
1478
1473
  function buildRealtimeCtx(options) {
1479
1474
  const pendingEmits = /* @__PURE__ */ new Map();
1475
+ const _pendingRefresh = [];
1476
+ let _hasEmitted = false;
1480
1477
  return {
1481
1478
  emit(queryKey, data) {
1479
+ _hasEmitted = true;
1482
1480
  const existing = pendingEmits.get(queryKey);
1483
1481
  if (existing) clearTimeout(existing.timer);
1484
1482
  const timer = setTimeout(() => {
@@ -1503,24 +1501,58 @@ function buildRealtimeCtx(options) {
1503
1501
  }
1504
1502
  }, 50);
1505
1503
  pendingEmits.set(queryKey, { data, timer });
1504
+ },
1505
+ refresh(queryKey) {
1506
+ _hasEmitted = true;
1507
+ if (!_pendingRefresh.includes(queryKey)) {
1508
+ _pendingRefresh.push(queryKey);
1509
+ }
1510
+ },
1511
+ get _hasEmitted() {
1512
+ return _hasEmitted;
1513
+ },
1514
+ get _pendingRefresh() {
1515
+ return [..._pendingRefresh];
1516
+ },
1517
+ async _flushRefresh() {
1518
+ if (_pendingRefresh.length === 0) return;
1519
+ const qMap = options?.queryMap ?? queryRegistry;
1520
+ for (const key of _pendingRefresh) {
1521
+ const queryDef = qMap.get(key);
1522
+ if (!queryDef) {
1523
+ console.warn(`[gencow] refresh("${key}"): query not found in registry. Skipping.`);
1524
+ continue;
1525
+ }
1526
+ try {
1527
+ const refreshCtx = options?.buildCtxForRefresh?.() ?? {};
1528
+ const result = await queryDef.handler(refreshCtx, {});
1529
+ if (options?.httpCallback) {
1530
+ options.httpCallback({ type: "emit", queryKey: key, data: result });
1531
+ } else {
1532
+ const clients = subscribers.get(key);
1533
+ if (clients && clients.size > 0) {
1534
+ const message = JSON.stringify({
1535
+ type: "query:updated",
1536
+ query: key,
1537
+ data: result
1538
+ });
1539
+ for (const ws of clients) {
1540
+ try {
1541
+ ws.send(message);
1542
+ } catch {
1543
+ clients.delete(ws);
1544
+ }
1545
+ }
1546
+ }
1547
+ }
1548
+ } catch (e) {
1549
+ console.warn(`[gencow] refresh("${key}") failed:`, e instanceof Error ? e.message : e);
1550
+ }
1551
+ }
1552
+ _pendingRefresh.length = 0;
1506
1553
  }
1507
1554
  };
1508
1555
  }
1509
- async function invalidateQueries(queryKeys, ctx, httpInvalidateCallback) {
1510
- if (queryKeys.length === 0) return;
1511
- if (httpInvalidateCallback) {
1512
- httpInvalidateCallback(queryKeys);
1513
- return;
1514
- }
1515
- const invalidateMsg = JSON.stringify({ type: "invalidate", queries: queryKeys });
1516
- for (const ws of connectedClients) {
1517
- try {
1518
- ws.send(invalidateMsg);
1519
- } catch {
1520
- connectedClients.delete(ws);
1521
- }
1522
- }
1523
- }
1524
1556
  function handleWsMessage(ws, raw) {
1525
1557
  try {
1526
1558
  const msg = typeof raw === "string" ? JSON.parse(raw) : JSON.parse(raw.toString());
@@ -2075,7 +2107,6 @@ function crud(table, options) {
2075
2107
  });
2076
2108
  const createDef = !enabledMethods.has("create") ? void 0 : mutation(`${prefix}.create`, {
2077
2109
  public: isPublic,
2078
- invalidates: [],
2079
2110
  handler: async (ctx, args) => {
2080
2111
  const user = isPublic ? null : ctx.auth.requireAuth();
2081
2112
  let insertData = { ...args };
@@ -2095,7 +2126,6 @@ function crud(table, options) {
2095
2126
  });
2096
2127
  const updateDef = !enabledMethods.has("update") ? void 0 : mutation(`${prefix}.update`, {
2097
2128
  public: isPublic,
2098
- invalidates: [],
2099
2129
  handler: async (ctx, args) => {
2100
2130
  if (!isPublic) ctx.auth.requireAuth();
2101
2131
  const { id, ...updates } = args;
@@ -2121,7 +2151,6 @@ function crud(table, options) {
2121
2151
  });
2122
2152
  const removeDef = !enabledMethods.has("remove") ? void 0 : mutation(`${prefix}.remove`, {
2123
2153
  public: isPublic,
2124
- invalidates: [],
2125
2154
  handler: async (ctx, args) => {
2126
2155
  if (!isPublic) ctx.auth.requireAuth();
2127
2156
  if (options?.softDelete) {
@@ -2164,7 +2193,6 @@ export {
2164
2193
  getSchedulerInfo,
2165
2194
  handleWsMessage,
2166
2195
  httpAction,
2167
- invalidateQueries,
2168
2196
  mutation,
2169
2197
  ownerRls,
2170
2198
  parseArgs,