@gencow/core 0.1.18 → 0.1.21

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 (43) hide show
  1. package/dist/crud.d.ts +18 -0
  2. package/dist/crud.js +231 -50
  3. package/dist/index.d.ts +3 -2
  4. package/dist/index.js +2 -2
  5. package/dist/rls-db.d.ts +3 -5
  6. package/dist/rls-db.js +3 -5
  7. package/dist/rls.d.ts +44 -1
  8. package/dist/rls.js +62 -2
  9. package/dist/server.d.ts +1 -0
  10. package/dist/storage.d.ts +29 -2
  11. package/dist/storage.js +404 -15
  12. package/dist/v.js +5 -1
  13. package/package.json +42 -39
  14. package/src/__tests__/crud-owner-rls.test.ts +380 -0
  15. package/src/__tests__/fixtures/basic/auth.ts +32 -0
  16. package/src/__tests__/fixtures/basic/drizzle.config.ts +15 -0
  17. package/src/__tests__/fixtures/basic/index.ts +6 -0
  18. package/src/__tests__/fixtures/basic/migrations/0000_faithful_silver_sable.sql +66 -0
  19. package/src/__tests__/fixtures/basic/migrations/meta/0000_snapshot.json +438 -0
  20. package/src/__tests__/fixtures/basic/migrations/meta/_journal.json +13 -0
  21. package/src/__tests__/fixtures/basic/schema.ts +35 -0
  22. package/src/__tests__/fixtures/basic/tasks.ts +15 -0
  23. package/src/__tests__/fixtures/common/auth-schema.ts +63 -0
  24. package/src/__tests__/helpers/pglite-migrations.ts +35 -0
  25. package/src/__tests__/helpers/pglite-rls-session.ts +54 -0
  26. package/src/__tests__/helpers/seed-like-fill.ts +196 -0
  27. package/src/__tests__/helpers/test-gencow-ctx-rls.ts +53 -0
  28. package/src/__tests__/image-optimization.test.ts +652 -0
  29. package/src/__tests__/rls-crud-basic.test.ts +431 -0
  30. package/src/__tests__/storage.test.ts +113 -0
  31. package/src/__tests__/tsconfig.json +8 -0
  32. package/src/__tests__/validator.test.ts +35 -0
  33. package/src/crud.ts +270 -47
  34. package/src/index.ts +3 -2
  35. package/src/rls-db.ts +3 -5
  36. package/src/rls.ts +87 -3
  37. package/src/server.ts +1 -0
  38. package/src/storage.ts +481 -15
  39. package/src/v.ts +5 -1
  40. package/dist/scoped-db.d.ts +0 -34
  41. package/dist/scoped-db.js +0 -364
  42. package/dist/table.d.ts +0 -67
  43. package/dist/table.js +0 -98
package/dist/storage.d.ts CHANGED
@@ -35,15 +35,42 @@ export interface Storage {
35
35
  * const url = storage.getUrl(id);
36
36
  */
37
37
  export declare function createStorage(dir?: string, options?: StorageOptions): Storage;
38
+ /** Tier별 이미지 최적화 설정 — Platform에서 주입 */
39
+ export interface StorageImageTierConfig {
40
+ /** Auto WebP 허용 여부 (기본 true) */
41
+ autoWebp?: boolean;
42
+ /** Auto WebP 시 최대 폭 — 초과 시 자동 축소 (Hobby=1920, Pro=3840, Scale=무제한) */
43
+ autoMaxWidth?: number;
44
+ /** 리사이즈 허용 여부 */
45
+ resize?: boolean;
46
+ /** 허용 포맷 목록 */
47
+ formats?: string[];
48
+ /** 품질 조절 허용 여부 */
49
+ qualityControl?: boolean;
50
+ /** 캐시 상한 (MB) */
51
+ cacheMaxMB?: number;
52
+ /** 월간 변환 횟수 상한 (-1 = 무제한) */
53
+ transformsPerMonth?: number;
54
+ /** Auto WebP 품질 오버라이드 (1-100, 기본 75) — 앱별 설정에서 주입 */
55
+ autoQuality?: number;
56
+ }
38
57
  /**
39
- * Hono routes for serving stored files
58
+ * Hono routes for serving stored files + Image Optimization
40
59
  *
41
60
  * 인증 없이 public URL로 서빙 — Convex getUrl() 패턴과 동일.
42
61
  * 접근 제어는 URL을 반환하는 query/mutation 레벨에서 개발자가 구현.
62
+ *
63
+ * 이미지 최적화:
64
+ * - Auto WebP: Accept: image/webp 헤더 감지 → 자동 WebP 변환
65
+ * - URL 파라미터: ?w=300&h=200&f=webp&q=80&fit=cover
66
+ * - 디스크 캐시: uploads/.cache/{uuid}_{params}.{ext}
67
+ * - 원본 보존: 원본 파일은 절대 수정하지 않음
43
68
  */
44
- export declare function storageRoutes(storage: ReturnType<typeof createStorage>, rawSql?: (sql: string, params?: unknown[]) => Promise<unknown[]>, storageDir?: string): (c: {
69
+ export declare function storageRoutes(storage: ReturnType<typeof createStorage>, rawSql?: (sql: string, params?: unknown[]) => Promise<unknown[]>, storageDir?: string, tierConfig?: StorageImageTierConfig): (c: {
45
70
  req: {
46
71
  param: (key: string) => string;
72
+ query: (key: string) => string | undefined;
73
+ header: (name: string) => string | undefined;
47
74
  };
48
75
  json: (data: unknown, status?: number) => Response;
49
76
  body: (data: unknown, status: number, headers: Record<string, string>) => Response;
package/dist/storage.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import * as fs from "fs/promises";
2
+ import * as fsSync from "fs";
2
3
  import * as path from "path";
3
4
  import * as crypto from "crypto";
4
5
  // ─── Constants ──────────────────────────────────────────
@@ -18,8 +19,9 @@ function formatBytes(bytes) {
18
19
  // ─── Implementation ─────────────────────────────────────
19
20
  const metaStore = new Map();
20
21
  /**
21
- * files 테이블 자동 생성 (이미 존재하면 무시)
22
+ * _system_files 테이블 자동 생성 (이미 존재하면 무시)
22
23
  * admin.ts의 CREATE_FILES_TABLE_SQL과 동일한 스키마 사용
24
+ * ⚠️ 테이블명 '_system_files' — 사용자 테이블과 네이밍 충돌 방지 (2026-04-10)
23
25
  */
24
26
  let filesTableEnsured = false;
25
27
  async function ensureFilesTable(rawSql) {
@@ -31,9 +33,9 @@ async function ensureFilesTable(rawSql) {
31
33
  IF NOT EXISTS (
32
34
  SELECT 1 FROM information_schema.tables
33
35
  WHERE table_schema = current_schema()
34
- AND table_name = 'files'
36
+ AND table_name = '_system_files'
35
37
  ) THEN
36
- CREATE TABLE files (
38
+ CREATE TABLE _system_files (
37
39
  id SERIAL PRIMARY KEY,
38
40
  storage_id TEXT NOT NULL,
39
41
  name TEXT NOT NULL,
@@ -53,7 +55,7 @@ async function ensureFilesTable(rawSql) {
53
55
  async function checkStorageQuota(rawSql, newFileSize, quota) {
54
56
  if (quota <= 0)
55
57
  return; // 무제한
56
- const rows = await rawSql(`SELECT COALESCE(SUM(CAST(size AS BIGINT)), 0) AS total FROM files`);
58
+ const rows = await rawSql(`SELECT COALESCE(SUM(CAST(size AS BIGINT)), 0) AS total FROM _system_files`);
57
59
  const currentUsage = Number(rows[0]?.total || "0");
58
60
  const projectedUsage = currentUsage + newFileSize;
59
61
  if (projectedUsage > quota) {
@@ -69,7 +71,7 @@ async function checkStorageQuota(rawSql, newFileSize, quota) {
69
71
  */
70
72
  async function recordFileToDb(rawSql, storageId, name, size, type, uploadedBy = "api") {
71
73
  await ensureFilesTable(rawSql);
72
- await rawSql(`INSERT INTO files (storage_id, name, size, type, uploaded_by, created_at)
74
+ await rawSql(`INSERT INTO _system_files (storage_id, name, size, type, uploaded_by, created_at)
73
75
  VALUES ($1, $2, $3, $4, $5, NOW())`, [storageId, name, String(size), type, uploadedBy]);
74
76
  }
75
77
  /**
@@ -104,6 +106,8 @@ export function createStorage(dir = "./uploads", options) {
104
106
  const arrayBuffer = await file.arrayBuffer();
105
107
  await fs.writeFile(filePath, Buffer.from(arrayBuffer));
106
108
  const type = file.type || "application/octet-stream";
109
+ // .meta JSON 기록 — Platform 레벨 직접 서빙용 (앱 DB 접근 불필요)
110
+ await fs.writeFile(`${filePath}.meta`, JSON.stringify({ name, type, size: file.size })).catch(() => { });
107
111
  metaStore.set(id, {
108
112
  id,
109
113
  name,
@@ -136,6 +140,8 @@ export function createStorage(dir = "./uploads", options) {
136
140
  const id = crypto.randomUUID();
137
141
  const filePath = path.join(dir, id);
138
142
  await fs.writeFile(filePath, buffer);
143
+ // .meta JSON 기록 — Platform 레벨 직접 서빙용
144
+ await fs.writeFile(`${filePath}.meta`, JSON.stringify({ name: filename, type, size: buffer.length })).catch(() => { });
139
145
  metaStore.set(id, {
140
146
  id,
141
147
  name: filename,
@@ -165,32 +171,240 @@ export function createStorage(dir = "./uploads", options) {
165
171
  const meta = metaStore.get(storageId);
166
172
  if (meta) {
167
173
  await fs.unlink(meta.path).catch(() => { });
174
+ // .meta JSON 동시 삭제
175
+ await fs.unlink(`${meta.path}.meta`).catch(() => { });
168
176
  metaStore.delete(storageId);
169
177
  }
178
+ else {
179
+ // metaStore에 없어도 디스크에서 직접 삭제 시도
180
+ const filePath = path.join(dir, storageId);
181
+ await fs.unlink(filePath).catch(() => { });
182
+ await fs.unlink(`${filePath}.meta`).catch(() => { });
183
+ }
184
+ // 이미지 캐시 삭제 — uploads/.cache/{storageId}_* glob
185
+ const cacheDir = path.join(dir, ".cache");
186
+ try {
187
+ const entries = await fs.readdir(cacheDir);
188
+ const prefix = `${storageId}_`;
189
+ await Promise.all(entries
190
+ .filter(e => e.startsWith(prefix))
191
+ .map(e => fs.unlink(path.join(cacheDir, e)).catch(() => { })));
192
+ }
193
+ catch { /* .cache 디렉토리 미존재 시 무시 */ }
170
194
  // DB에서도 삭제 (rawSql 있을 때만)
171
195
  if (rawSql) {
172
196
  try {
173
- await rawSql(`DELETE FROM files WHERE storage_id = $1`, [storageId]);
197
+ await rawSql(`DELETE FROM _system_files WHERE storage_id = $1`, [storageId]);
174
198
  }
175
199
  catch { /* 삭제 실패 무시 — 파일은 이미 제거됨 */ }
176
200
  }
177
201
  },
178
202
  };
179
203
  }
204
+ // ─── Image Optimization Constants ───────────────────────
205
+ /** 변환 가능한 원본 MIME 타입 */
206
+ const TRANSFORMABLE_TYPES = new Set([
207
+ "image/png", "image/jpeg", "image/jpg", "image/webp",
208
+ ]);
209
+ /** 허용 출력 포맷 */
210
+ const ALLOWED_FORMATS = new Set(["webp", "avif", "jpeg", "png"]);
211
+ /** 허용 맞춤 모드 */
212
+ const ALLOWED_FIT_MODES = new Set(["cover", "contain", "fill", "inside"]);
213
+ /** 최대 치수 (px) — DoS 방지 */
214
+ const MAX_DIMENSION = 4096;
215
+ /** 기본 품질 */
216
+ const DEFAULT_QUALITY = 80;
217
+ /** Auto WebP 전용 설정 — JPEG에서도 효과를 내려면 q75 + effort 6 필요
218
+ * (테스트: 2.5MB JPEG → q80 effort4=더커짐, q75 effort6=15.6% 감소) */
219
+ const AUTO_WEBP_QUALITY = 75;
220
+ const AUTO_WEBP_EFFORT = 6;
221
+ /** 앱당 최대 캐시 엔트리 수 — 랜덤 파라미터 DoS 방지 */
222
+ const MAX_CACHE_ENTRIES = 1000;
223
+ // ─── Image Optimization Helpers ─────────────────────────
224
+ /** 동시 변환 세마포어 — CPU 폭발 방지 (최대 3 동시) */
225
+ let activeTransforms = 0;
226
+ const MAX_CONCURRENT_TRANSFORMS = 3;
227
+ const transformQueue = [];
228
+ function acquireTransformSlot() {
229
+ if (activeTransforms < MAX_CONCURRENT_TRANSFORMS) {
230
+ activeTransforms++;
231
+ return Promise.resolve();
232
+ }
233
+ return new Promise((resolve) => {
234
+ transformQueue.push(() => {
235
+ activeTransforms++;
236
+ resolve();
237
+ });
238
+ });
239
+ }
240
+ function releaseTransformSlot() {
241
+ activeTransforms--;
242
+ const next = transformQueue.shift();
243
+ if (next)
244
+ next();
245
+ }
246
+ /**
247
+ * 쿼리 파라미터를 파싱 + 정규화 + 검증
248
+ * Hono의 c.req.query()로 추출한 개별 값을 받아 정규화.
249
+ * @returns null if no transform params, parsed params otherwise
250
+ */
251
+ function parseTransformParams(wStr, hStr, fStr, qStr, fitStr) {
252
+ const params = {};
253
+ let hasParam = false;
254
+ if (wStr) {
255
+ const w = parseInt(wStr, 10);
256
+ if (isNaN(w) || w < 1 || w > MAX_DIMENSION)
257
+ return null;
258
+ params.w = w;
259
+ hasParam = true;
260
+ }
261
+ if (hStr) {
262
+ const h = parseInt(hStr, 10);
263
+ if (isNaN(h) || h < 1 || h > MAX_DIMENSION)
264
+ return null;
265
+ params.h = h;
266
+ hasParam = true;
267
+ }
268
+ if (fStr) {
269
+ const fmt = fStr.toLowerCase();
270
+ if (!ALLOWED_FORMATS.has(fmt))
271
+ return null;
272
+ params.f = fmt;
273
+ hasParam = true;
274
+ }
275
+ if (qStr) {
276
+ const q = parseInt(qStr, 10);
277
+ if (isNaN(q) || q < 1 || q > 100)
278
+ return null;
279
+ params.q = q;
280
+ hasParam = true;
281
+ }
282
+ if (fitStr) {
283
+ const fitMode = fitStr.toLowerCase();
284
+ if (!ALLOWED_FIT_MODES.has(fitMode))
285
+ return null;
286
+ params.fit = fitMode;
287
+ hasParam = true;
288
+ }
289
+ return hasParam ? params : null;
290
+ }
291
+ /**
292
+ * 변환 파라미터에서 캐시 키 생성
293
+ * 결정론적: 동일 파라미터 → 동일 키
294
+ */
295
+ function buildCacheKey(storageId, params, isAutoWebp, autoMaxWidth, autoQuality) {
296
+ if (isAutoWebp) {
297
+ // autoMaxWidth/autoQuality가 있으면 캐시 키에 포함 (Tier/앱별 다른 크기/품질)
298
+ const mwSuffix = autoMaxWidth ? `_mw${autoMaxWidth}` : "";
299
+ const qSuffix = autoQuality && autoQuality !== AUTO_WEBP_QUALITY ? `_q${autoQuality}` : "";
300
+ return `${storageId}_auto_webp${mwSuffix}${qSuffix}.webp`;
301
+ }
302
+ const parts = [storageId];
303
+ if (params.w)
304
+ parts.push(`w${params.w}`);
305
+ if (params.h)
306
+ parts.push(`h${params.h}`);
307
+ const fmt = params.f || "webp";
308
+ parts.push(`f${fmt}`);
309
+ parts.push(`q${params.q || DEFAULT_QUALITY}`);
310
+ if (params.fit)
311
+ parts.push(params.fit);
312
+ return `${parts.join("_")}.${fmt === "jpeg" ? "jpg" : fmt}`;
313
+ }
180
314
  /**
181
- * Hono routes for serving stored files
315
+ * 캐시 디렉토리의 엔트리 확인 — DoS 방지
316
+ */
317
+ async function getCacheEntryCount(cacheDir) {
318
+ try {
319
+ const entries = await fs.readdir(cacheDir);
320
+ return entries.length;
321
+ }
322
+ catch {
323
+ return 0;
324
+ }
325
+ }
326
+ /**
327
+ * Hono routes for serving stored files + Image Optimization
182
328
  *
183
329
  * 인증 없이 public URL로 서빙 — Convex getUrl() 패턴과 동일.
184
330
  * 접근 제어는 URL을 반환하는 query/mutation 레벨에서 개발자가 구현.
331
+ *
332
+ * 이미지 최적화:
333
+ * - Auto WebP: Accept: image/webp 헤더 감지 → 자동 WebP 변환
334
+ * - URL 파라미터: ?w=300&h=200&f=webp&q=80&fit=cover
335
+ * - 디스크 캐시: uploads/.cache/{uuid}_{params}.{ext}
336
+ * - 원본 보존: 원본 파일은 절대 수정하지 않음
185
337
  */
186
- export function storageRoutes(storage, rawSql, storageDir) {
338
+ export function storageRoutes(storage, rawSql, storageDir, tierConfig) {
339
+ const baseTierConfig = {
340
+ autoWebp: tierConfig?.autoWebp ?? true,
341
+ autoMaxWidth: tierConfig?.autoMaxWidth ?? 0, // 0 = 제한 없음
342
+ resize: tierConfig?.resize ?? true,
343
+ formats: tierConfig?.formats ?? ["webp", "avif", "jpeg", "png"],
344
+ qualityControl: tierConfig?.qualityControl ?? true,
345
+ cacheMaxMB: tierConfig?.cacheMaxMB ?? 1024, // 기본 1GB
346
+ transformsPerMonth: tierConfig?.transformsPerMonth ?? -1, // 무제한
347
+ autoQuality: tierConfig?.autoQuality ?? AUTO_WEBP_QUALITY,
348
+ };
349
+ /**
350
+ * 매 요청마다 uploads/.image-config.json 파일에서 앱별 이미지 설정을 읽어
351
+ * Tier config와 병합한 최종 config를 반환.
352
+ *
353
+ * 파일 기반 설계 이유:
354
+ * - env var 방식은 전파 파이프라인이 6단계(DB→provisioner→env→dist→handler)로 깨지기 쉬움
355
+ * - 파일은 대시보드에서 write → 다음 요청에서 즉시 read (2단계)
356
+ * - 어느 프로세스든(Platform/앱) 동일 파일을 읽으므로 일관성 보장
357
+ * - readFileSync ~0.01ms, 성능 영향 없음
358
+ */
359
+ function getImageConfig(uploadsDir) {
360
+ const config = { ...baseTierConfig };
361
+ try {
362
+ const raw = fsSync.readFileSync(path.join(uploadsDir, ".image-config.json"), "utf-8");
363
+ const appConfig = JSON.parse(raw);
364
+ // Tier ceiling 적용: min(앱 설정, Tier 최대값)
365
+ if (appConfig.autoMaxWidth > 0) {
366
+ config.autoMaxWidth = config.autoMaxWidth > 0
367
+ ? Math.min(appConfig.autoMaxWidth, config.autoMaxWidth)
368
+ : appConfig.autoMaxWidth;
369
+ }
370
+ if (appConfig.autoQuality > 0 && appConfig.autoQuality <= 100) {
371
+ config.autoQuality = appConfig.autoQuality;
372
+ }
373
+ if (appConfig.autoWebp === false) {
374
+ config.autoWebp = false;
375
+ }
376
+ }
377
+ catch { /* 파일 없음 or 파싱 실패 → Tier 기본값 유지 */ }
378
+ return config;
379
+ }
380
+ // sharp 모듈 캐시 (동적 import 1회만)
381
+ let sharpModule = null;
382
+ let sharpLoadAttempted = false;
383
+ async function getSharp() {
384
+ if (sharpLoadAttempted)
385
+ return sharpModule;
386
+ sharpLoadAttempted = true;
387
+ try {
388
+ // @ts-ignore — sharp는 런타임 선택적 의존성 (설치 안 된 환경에서도 빌드 가능)
389
+ sharpModule = (await import("sharp")).default;
390
+ }
391
+ catch {
392
+ // sharp 미설치 시 null → 원본 서빙 fallback
393
+ console.warn("[storage] sharp not available — image optimization disabled");
394
+ sharpModule = null;
395
+ }
396
+ return sharpModule;
397
+ }
187
398
  return async (c) => {
399
+ // 매 요청마다 uploads/.image-config.json에서 config 읽기 (파일 기반 hot-reload)
400
+ const dir = storageDir || "./uploads";
401
+ const config = getImageConfig(dir);
188
402
  const id = c.req.param("id");
189
403
  let meta = await storage.getMeta(id);
190
404
  // Fallback: DB lookup when in-memory meta is missing (e.g. after server restart)
191
405
  if (!meta && rawSql) {
192
406
  try {
193
- const rows = await rawSql(`SELECT storage_id, name, size, type FROM files WHERE storage_id = $1 LIMIT 1`, [id]);
407
+ const rows = await rawSql(`SELECT storage_id, name, size, type FROM _system_files WHERE storage_id = $1 LIMIT 1`, [id]);
194
408
  if (rows.length > 0) {
195
409
  const row = rows[0];
196
410
  const dir = storageDir || "./uploads";
@@ -208,21 +422,196 @@ export function storageRoutes(storage, rawSql, storageDir) {
208
422
  if (!meta) {
209
423
  return c.json({ error: "Not found" }, 404);
210
424
  }
211
- // Bun 런타임에서는 Bun.file()을 사용하여 바이너리 무결성 보장
212
- // Node의 fs.readFile()은 Bun에서 Buffer 인코딩 문제가 있을 수 있음
425
+ // ── 이미지 최적화 분기 ─────────────────────────────
426
+ const isTransformable = TRANSFORMABLE_TYPES.has(meta.type);
427
+ // Hono native query 파서로 파라미터 추출
428
+ const transformParams = isTransformable
429
+ ? parseTransformParams(c.req.query("w"), c.req.query("h"), c.req.query("f"), c.req.query("q"), c.req.query("fit"))
430
+ : null;
431
+ // Accept 헤더에서 WebP 지원 감지
432
+ const acceptHeader = c.req.header("accept") || "";
433
+ const clientAcceptsWebp = acceptHeader.includes("image/webp");
434
+ // Auto WebP: 파라미터 없지만 브라우저가 webp 지원 + 원본이 PNG/JPEG
435
+ const isAutoWebp = !transformParams
436
+ && isTransformable
437
+ && clientAcceptsWebp
438
+ && config.autoWebp
439
+ && (meta.type === "image/png" || meta.type === "image/jpeg" || meta.type === "image/jpg");
440
+ // 이미지 변환이 필요 없는 경우 → 원본 서빙 (기존 동작 100% 유지)
441
+ if (!transformParams && !isAutoWebp) {
442
+ return serveOriginal(c, meta);
443
+ }
444
+ // ── Tier 검증 (파라미터 변환 시) ──────────────────
445
+ if (transformParams) {
446
+ // 리사이즈 검증
447
+ if ((transformParams.w || transformParams.h) && !config.resize) {
448
+ return c.json({
449
+ error: "Image resize requires Pro plan",
450
+ code: "PLAN_LIMIT",
451
+ upgrade: "https://gencow.app/pricing",
452
+ }, 403);
453
+ }
454
+ // 포맷 검증
455
+ if (transformParams.f && !config.formats.includes(transformParams.f)) {
456
+ return c.json({
457
+ error: `Format "${transformParams.f}" requires a higher plan`,
458
+ code: "PLAN_LIMIT",
459
+ upgrade: "https://gencow.app/pricing",
460
+ allowed: { formats: config.formats },
461
+ }, 403);
462
+ }
463
+ // 품질 검증
464
+ if (transformParams.q && !config.qualityControl) {
465
+ return c.json({
466
+ error: "Quality control requires Pro plan",
467
+ code: "PLAN_LIMIT",
468
+ upgrade: "https://gencow.app/pricing",
469
+ }, 403);
470
+ }
471
+ // fit 검증 (resize와 함께만 동작)
472
+ if (transformParams.fit && !config.resize) {
473
+ return c.json({
474
+ error: "Fit mode requires Pro plan",
475
+ code: "PLAN_LIMIT",
476
+ upgrade: "https://gencow.app/pricing",
477
+ }, 403);
478
+ }
479
+ }
480
+ // ── sharp 로드 확인 ─────────────────────────────
481
+ const sharp = await getSharp();
482
+ if (!sharp) {
483
+ // sharp 미설치 → 원본 서빙 (graceful degradation)
484
+ return serveOriginal(c, meta);
485
+ }
486
+ // ── 캐시 확인 ───────────────────────────────────
487
+ const cacheDir = path.join(dir, ".cache");
488
+ const cacheKey = buildCacheKey(id, transformParams || {}, isAutoWebp, config.autoMaxWidth || undefined, config.autoQuality);
489
+ const cachePath = path.join(cacheDir, cacheKey);
490
+ // 캐시 HIT
491
+ try {
492
+ await fs.access(cachePath);
493
+ // 캐시 파일이 존재 → 바로 서빙
494
+ return serveCachedFile(c, cachePath, transformParams, isAutoWebp, meta);
495
+ }
496
+ catch {
497
+ // 캐시 MISS → 변환 필요
498
+ }
499
+ // ── 캐시 엔트리 수 제한 확인 ─────────────────────
500
+ const cacheCount = await getCacheEntryCount(cacheDir);
501
+ if (cacheCount >= MAX_CACHE_ENTRIES) {
502
+ // 캐시 상한 초과 → 변환은 수행하되 캐시 저장 안 함 (or 원본 반환)
503
+ // 보안상 원본 서빙으로 fallback
504
+ return serveOriginal(c, meta);
505
+ }
506
+ // ── 이미지 변환 수행 ─────────────────────────────
507
+ try {
508
+ await acquireTransformSlot();
509
+ try {
510
+ // 캐시 디렉토리 생성
511
+ await fs.mkdir(cacheDir, { recursive: true });
512
+ // sharp 파이프라인 구성
513
+ let pipeline = sharp(meta.path);
514
+ // 리사이즈
515
+ if (transformParams?.w || transformParams?.h) {
516
+ const fitMode = (transformParams.fit || "cover");
517
+ pipeline = pipeline.resize({
518
+ width: transformParams.w || undefined,
519
+ height: transformParams.h || undefined,
520
+ fit: fitMode,
521
+ withoutEnlargement: true, // 원본보다 크게 확대하지 않음
522
+ });
523
+ }
524
+ else if (isAutoWebp && config.autoMaxWidth > 0) {
525
+ // Auto WebP 시 Tier별 최대폭 제한 (Hobby=1920, Pro=3840, Scale=0=무제한)
526
+ // 원본이 maxWidth 이하면 리사이즈하지 않음 (withoutEnlargement)
527
+ pipeline = pipeline.resize({
528
+ width: config.autoMaxWidth,
529
+ withoutEnlargement: true,
530
+ });
531
+ }
532
+ // 출력 포맷 + 품질
533
+ const outputFormat = transformParams?.f || (isAutoWebp ? "webp" : null);
534
+ const quality = transformParams?.q ?? DEFAULT_QUALITY;
535
+ if (outputFormat === "webp") {
536
+ pipeline = pipeline.webp(isAutoWebp
537
+ ? { quality: config.autoQuality, effort: AUTO_WEBP_EFFORT, alphaQuality: 100 }
538
+ : { quality, alphaQuality: 100 });
539
+ }
540
+ else if (outputFormat === "avif") {
541
+ pipeline = pipeline.avif({ quality });
542
+ }
543
+ else if (outputFormat === "jpeg" || outputFormat === "jpg") {
544
+ pipeline = pipeline.jpeg({ quality, mozjpeg: true });
545
+ }
546
+ else if (outputFormat === "png") {
547
+ pipeline = pipeline.png({ compressionLevel: 9 });
548
+ }
549
+ // 변환 실행 → 캐시에 저장
550
+ await pipeline.toFile(cachePath);
551
+ // WebP/AVIF가 원본보다 큰 경우 → 캐시 삭제 + 원본 서빙
552
+ // (Static Deploy와 동일 전략 — apps.ts L840-847)
553
+ if (isAutoWebp) {
554
+ const cacheStats = await fs.stat(cachePath);
555
+ if (cacheStats.size >= meta.size) {
556
+ await fs.unlink(cachePath).catch(() => { });
557
+ return serveOriginal(c, meta);
558
+ }
559
+ }
560
+ return serveCachedFile(c, cachePath, transformParams, isAutoWebp, meta);
561
+ }
562
+ finally {
563
+ releaseTransformSlot();
564
+ }
565
+ }
566
+ catch (err) {
567
+ const msg = err instanceof Error ? err.message : String(err);
568
+ console.warn(`[storage] Image transform failed for ${id}: ${msg}`);
569
+ // 변환 실패 → 원본 서빙 (graceful degradation)
570
+ return serveOriginal(c, meta);
571
+ }
572
+ };
573
+ /** 원본 파일 서빙 (기존 동작) */
574
+ function serveOriginal(c, meta) {
213
575
  const headers = {
214
576
  "Content-Type": meta.type,
215
577
  "Content-Disposition": `inline; filename="${encodeURIComponent(meta.name)}"; filename*=UTF-8''${encodeURIComponent(meta.name)}`,
216
578
  "Cache-Control": "public, max-age=31536000, immutable",
217
579
  };
218
- // Bun은 Bun.file()로 직접 BunFile(Blob) 생성 → Response에 그대로 전달
219
580
  if (typeof globalThis.Bun !== "undefined") {
220
581
  const bunFile = Bun.file(meta.path);
221
582
  return new Response(bunFile, { headers });
222
583
  }
223
- // Node.js 폴백
224
- const file = await fs.readFile(meta.path);
584
+ // Node.js 폴백 — 동기 readFile (async context에서 호출되므로 안전)
585
+ const file = require("fs").readFileSync(meta.path);
225
586
  headers["Content-Length"] = String(file.byteLength);
226
587
  return c.body(file, 200, headers);
227
- };
588
+ }
589
+ /** 캐시된 변환 파일 서빙 */
590
+ function serveCachedFile(c, cachePath, params, isAutoWebp, originalMeta) {
591
+ const outputFormat = params?.f || (isAutoWebp ? "webp" : null);
592
+ const contentType = outputFormat
593
+ ? `image/${outputFormat === "jpg" ? "jpeg" : outputFormat}`
594
+ : originalMeta.type;
595
+ // 변환된 파일명: original.png → original.webp (또는 original_300x200.webp)
596
+ const baseName = originalMeta.name.replace(/\.[^.]+$/, "");
597
+ const ext = outputFormat || originalMeta.name.split(".").pop() || "bin";
598
+ const suffix = params?.w || params?.h
599
+ ? `_${params.w || "auto"}x${params.h || "auto"}`
600
+ : "";
601
+ const fileName = `${baseName}${suffix}.${ext}`;
602
+ const headers = {
603
+ "Content-Type": contentType,
604
+ "Content-Disposition": `inline; filename="${encodeURIComponent(fileName)}"; filename*=UTF-8''${encodeURIComponent(fileName)}`,
605
+ "Cache-Control": "public, max-age=31536000, immutable",
606
+ // Vary: Accept — Auto WebP 시 CDN/브라우저 캐시 분리
607
+ ...(isAutoWebp ? { "Vary": "Accept" } : {}),
608
+ };
609
+ if (typeof globalThis.Bun !== "undefined") {
610
+ const bunFile = Bun.file(cachePath);
611
+ return new Response(bunFile, { headers });
612
+ }
613
+ const file = require("fs").readFileSync(cachePath);
614
+ headers["Content-Length"] = String(file.byteLength);
615
+ return c.body(file, 200, headers);
616
+ }
228
617
  }
package/dist/v.js CHANGED
@@ -113,11 +113,15 @@ export function parseArgs(schema, args) {
113
113
  }
114
114
  // Shorthand object — e.g. { id: v.number(), title: v.optional(v.string()) }
115
115
  if (typeof schema === "object" && schema !== null) {
116
+ // Empty schema {} → passthrough all args (e.g. FormData with file field)
117
+ const schemaKeys = Object.keys(schema);
118
+ if (schemaKeys.length === 0)
119
+ return args;
116
120
  if (typeof args !== "object" || args === null) {
117
121
  throw new GencowValidationError("Expected an object for arguments");
118
122
  }
119
123
  const result = {};
120
- for (const key in schema) {
124
+ for (const key of schemaKeys) {
121
125
  const validator = schema[key];
122
126
  if (validator && typeof validator.parse === "function") {
123
127
  try {
package/package.json CHANGED
@@ -1,42 +1,45 @@
1
1
  {
2
- "name": "@gencow/core",
3
- "version": "0.1.18",
4
- "description": "Gencow core library — defineQuery, defineMutation, reactive subscriptions",
5
- "type": "module",
6
- "main": "dist/index.js",
7
- "types": "dist/index.d.ts",
8
- "exports": {
9
- ".": {
10
- "import": "./dist/index.js",
11
- "require": "./dist/index.js",
12
- "types": "./dist/index.d.ts"
13
- },
14
- "./server": {
15
- "import": "./dist/server.js",
16
- "require": "./dist/server.js",
17
- "types": "./dist/server.d.ts"
18
- }
2
+ "name": "@gencow/core",
3
+ "version": "0.1.21",
4
+ "description": "Gencow core library — defineQuery, defineMutation, reactive subscriptions",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./dist/index.js",
11
+ "require": "./dist/index.js",
12
+ "types": "./dist/index.d.ts"
19
13
  },
20
- "files": [
21
- "dist/",
22
- "src/"
23
- ],
24
- "scripts": {
25
- "build": "tsc",
26
- "typecheck": "tsc --noEmit",
27
- "prepublishOnly": "npm run build",
28
- "postinstall": "tsc"
29
- },
30
- "dependencies": {
31
- "@electric-sql/pglite": "^0.3.15",
32
- "drizzle-orm": "^0.45.1",
33
- "hono": "^4.12.0",
34
- "node-cron": "^4.2.1"
35
- },
36
- "devDependencies": {
37
- "@types/bun": "^1.3.9",
38
- "@types/node": "^25.3.0",
39
- "@types/node-cron": "^3.0.11",
40
- "typescript": "^5.9.3"
14
+ "./server": {
15
+ "import": "./dist/server.js",
16
+ "require": "./dist/server.js",
17
+ "types": "./dist/server.d.ts"
41
18
  }
42
- }
19
+ },
20
+ "files": [
21
+ "dist/",
22
+ "src/"
23
+ ],
24
+ "dependencies": {
25
+ "@electric-sql/pglite": "^0.3.15",
26
+ "drizzle-orm": "^0.45.1",
27
+ "hono": "^4.12.0",
28
+ "node-cron": "^4.2.1"
29
+ },
30
+ "devDependencies": {
31
+ "@types/bun": "^1.3.9",
32
+ "@types/node": "^25.3.0",
33
+ "@types/node-cron": "^3.0.11",
34
+ "drizzle-kit": "^0.31.10",
35
+ "drizzle-seed": "^0.3.1",
36
+ "typescript": "^5.9.3",
37
+ "uuid": "^13.0.0"
38
+ },
39
+ "scripts": {
40
+ "db:generate:fixture-basic": "drizzle-kit generate --config ./src/__tests__/fixtures/basic/drizzle.config.ts",
41
+ "build": "tsc",
42
+ "typecheck": "tsc --noEmit",
43
+ "postinstall": "tsc"
44
+ }
45
+ }