@gencow/core 0.1.18 → 0.1.19

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/storage.js CHANGED
@@ -18,8 +18,9 @@ function formatBytes(bytes) {
18
18
  // ─── Implementation ─────────────────────────────────────
19
19
  const metaStore = new Map();
20
20
  /**
21
- * files 테이블 자동 생성 (이미 존재하면 무시)
21
+ * _system_files 테이블 자동 생성 (이미 존재하면 무시)
22
22
  * admin.ts의 CREATE_FILES_TABLE_SQL과 동일한 스키마 사용
23
+ * ⚠️ 테이블명 '_system_files' — 사용자 테이블과 네이밍 충돌 방지 (2026-04-10)
23
24
  */
24
25
  let filesTableEnsured = false;
25
26
  async function ensureFilesTable(rawSql) {
@@ -31,9 +32,9 @@ async function ensureFilesTable(rawSql) {
31
32
  IF NOT EXISTS (
32
33
  SELECT 1 FROM information_schema.tables
33
34
  WHERE table_schema = current_schema()
34
- AND table_name = 'files'
35
+ AND table_name = '_system_files'
35
36
  ) THEN
36
- CREATE TABLE files (
37
+ CREATE TABLE _system_files (
37
38
  id SERIAL PRIMARY KEY,
38
39
  storage_id TEXT NOT NULL,
39
40
  name TEXT NOT NULL,
@@ -53,7 +54,7 @@ async function ensureFilesTable(rawSql) {
53
54
  async function checkStorageQuota(rawSql, newFileSize, quota) {
54
55
  if (quota <= 0)
55
56
  return; // 무제한
56
- const rows = await rawSql(`SELECT COALESCE(SUM(CAST(size AS BIGINT)), 0) AS total FROM files`);
57
+ const rows = await rawSql(`SELECT COALESCE(SUM(CAST(size AS BIGINT)), 0) AS total FROM _system_files`);
57
58
  const currentUsage = Number(rows[0]?.total || "0");
58
59
  const projectedUsage = currentUsage + newFileSize;
59
60
  if (projectedUsage > quota) {
@@ -69,7 +70,7 @@ async function checkStorageQuota(rawSql, newFileSize, quota) {
69
70
  */
70
71
  async function recordFileToDb(rawSql, storageId, name, size, type, uploadedBy = "api") {
71
72
  await ensureFilesTable(rawSql);
72
- await rawSql(`INSERT INTO files (storage_id, name, size, type, uploaded_by, created_at)
73
+ await rawSql(`INSERT INTO _system_files (storage_id, name, size, type, uploaded_by, created_at)
73
74
  VALUES ($1, $2, $3, $4, $5, NOW())`, [storageId, name, String(size), type, uploadedBy]);
74
75
  }
75
76
  /**
@@ -170,7 +171,7 @@ export function createStorage(dir = "./uploads", options) {
170
171
  // DB에서도 삭제 (rawSql 있을 때만)
171
172
  if (rawSql) {
172
173
  try {
173
- await rawSql(`DELETE FROM files WHERE storage_id = $1`, [storageId]);
174
+ await rawSql(`DELETE FROM _system_files WHERE storage_id = $1`, [storageId]);
174
175
  }
175
176
  catch { /* 삭제 실패 무시 — 파일은 이미 제거됨 */ }
176
177
  }
@@ -190,7 +191,7 @@ export function storageRoutes(storage, rawSql, storageDir) {
190
191
  // Fallback: DB lookup when in-memory meta is missing (e.g. after server restart)
191
192
  if (!meta && rawSql) {
192
193
  try {
193
- const rows = await rawSql(`SELECT storage_id, name, size, type FROM files WHERE storage_id = $1 LIMIT 1`, [id]);
194
+ const rows = await rawSql(`SELECT storage_id, name, size, type FROM _system_files WHERE storage_id = $1 LIMIT 1`, [id]);
194
195
  if (rows.length > 0) {
195
196
  const row = rows[0];
196
197
  const dir = storageDir || "./uploads";
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@gencow/core",
3
- "version": "0.1.18",
3
+ "version": "0.1.19",
4
4
  "description": "Gencow core library — defineQuery, defineMutation, reactive subscriptions",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -206,3 +206,116 @@ describe("createStorage()", () => {
206
206
  });
207
207
  });
208
208
  });
209
+
210
+ // ─── _system_files 테이블 리네이밍 관련 테스트 ────────────
211
+ // 2026-04-10 WSOD 사고 후 추가: files → _system_files 리네이밍 검증
212
+ // 📄 참고: docs/analysis/analysis-files-page-wsod.md
213
+
214
+ describe("_system_files 시스템 테이블 네이밍", () => {
215
+ it("rawSql로 실행되는 모든 SQL에 old 'files' 테이블 참조가 없다", async () => {
216
+ const executedSql: string[] = [];
217
+ const mockRawSql = async (sql: string) => {
218
+ executedSql.push(sql);
219
+ if (sql.includes("SUM")) return [{ total: "0" }];
220
+ if (sql.includes("INSERT")) return [];
221
+ return [];
222
+ };
223
+
224
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "gencow-naming-"));
225
+ const storage = createStorage(tmpDir, { rawSql: mockRawSql });
226
+
227
+ // store 트리거 (ensureFilesTable + checkQuota + recordFileToDb)
228
+ const file = new File(["test"], "test.txt", { type: "text/plain" });
229
+ try { await storage.store(file); } catch {}
230
+
231
+ // 모든 실행된 SQL에서 old 테이블명 'files'가 아닌 '_system_files'만 참조하는지 확인
232
+ // (files 단독 참조를 찾되, _system_files는 통과)
233
+ for (const sql of executedSql) {
234
+ // "FROM files", "INTO files", "TABLE files" 같은 old 패턴이 없어야 함
235
+ expect(sql).not.toMatch(/\bFROM\s+files\b(?!_)/i);
236
+ expect(sql).not.toMatch(/\bINTO\s+files\b(?!_)/i);
237
+ expect(sql).not.toMatch(/\bTABLE\s+files\b(?!_)/i);
238
+ }
239
+
240
+ await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
241
+ });
242
+
243
+ it("쿼터 검증 SQL이 _system_files 테이블을 참조한다", async () => {
244
+ const executedSql: string[] = [];
245
+ const mockRawSql = async (sql: string) => {
246
+ executedSql.push(sql);
247
+ if (sql.includes("SUM")) return [{ total: "0" }];
248
+ if (sql.includes("INSERT")) return [];
249
+ return [];
250
+ };
251
+
252
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "gencow-quota-"));
253
+ const storage = createStorage(tmpDir, {
254
+ rawSql: mockRawSql,
255
+ storageQuota: 1000000000,
256
+ });
257
+
258
+ const file = new File(["small"], "small.txt");
259
+ try { await storage.store(file); } catch {}
260
+
261
+ // SUM 쿼리가 _system_files 테이블을 참조하는지 확인
262
+ const sumSql = executedSql.find(s => s.includes("SUM"));
263
+ if (sumSql) {
264
+ expect(sumSql).toContain("_system_files");
265
+ expect(sumSql).not.toMatch(/FROM\s+files[^_]/);
266
+ }
267
+
268
+ await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
269
+ });
270
+
271
+ it("recordFileToDb SQL이 _system_files 테이블에 INSERT한다", async () => {
272
+ const executedSql: string[] = [];
273
+ const mockRawSql = async (sql: string) => {
274
+ executedSql.push(sql);
275
+ if (sql.includes("SUM")) return [{ total: "0" }];
276
+ return [];
277
+ };
278
+
279
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "gencow-insert-"));
280
+ const storage = createStorage(tmpDir, { rawSql: mockRawSql });
281
+
282
+ const file = new File(["data"], "data.txt", { type: "text/plain" });
283
+ try { await storage.store(file); } catch {}
284
+
285
+ const insertSql = executedSql.find(s => s.includes("INSERT"));
286
+ if (insertSql) {
287
+ expect(insertSql).toContain("_system_files");
288
+ expect(insertSql).not.toMatch(/INTO\s+files[^_]/);
289
+ }
290
+
291
+ await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
292
+ });
293
+
294
+ it("delete SQL이 _system_files 테이블에서 삭제한다", async () => {
295
+ const executedSql: string[] = [];
296
+ const mockRawSql = async (sql: string) => {
297
+ executedSql.push(sql);
298
+ if (sql.includes("SUM")) return [{ total: "0" }];
299
+ return [];
300
+ };
301
+
302
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "gencow-delete-"));
303
+ const storage = createStorage(tmpDir, { rawSql: mockRawSql });
304
+
305
+ // store 후 delete
306
+ const file = new File(["delete-me"], "del.txt");
307
+ let storageId: string | undefined;
308
+ try { storageId = await storage.store(file); } catch {}
309
+ if (storageId) {
310
+ try { await storage.delete(storageId); } catch {}
311
+ }
312
+
313
+ const deleteSql = executedSql.find(s => s.includes("DELETE"));
314
+ if (deleteSql) {
315
+ expect(deleteSql).toContain("_system_files");
316
+ expect(deleteSql).not.toMatch(/FROM\s+files[^_]/);
317
+ }
318
+
319
+ await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
320
+ });
321
+ });
@@ -281,4 +281,39 @@ describe("parseArgs()", () => {
281
281
  expect(err.statusCode).toBe(400);
282
282
  expect(err.name).toBe("GencowValidationError");
283
283
  });
284
+
285
+ // ─── 빈 스키마 passthrough (FormData 업로드 버그 회귀 방지) ────
286
+
287
+ it("빈 스키마 {} → args 전체 passthrough (FormData file 필드 보존)", () => {
288
+ const schema = {};
289
+ const args = { file: new File(["hello"], "test.txt"), _mutation: "upload.store" };
290
+ const result = parseArgs(schema, args);
291
+ // 빈 스키마이므로 args가 그대로 반환되어야 함 (file 포함)
292
+ expect(result).toBe(args); // 참조 동일
293
+ expect(result.file).toBeInstanceOf(File);
294
+ expect(result._mutation).toBe("upload.store");
295
+ });
296
+
297
+ it("빈 스키마 {} + 일반 객체 → passthrough", () => {
298
+ const schema = {};
299
+ const args = { name: "test", count: 42, nested: { a: 1 } };
300
+ const result = parseArgs(schema, args);
301
+ expect(result).toBe(args);
302
+ expect(result.name).toBe("test");
303
+ expect(result.count).toBe(42);
304
+ });
305
+
306
+ it("빈 스키마 {} + 빈 args {} → 빈 객체 반환", () => {
307
+ const result = parseArgs({}, {});
308
+ expect(result).toEqual({});
309
+ });
310
+
311
+ it("키가 있는 스키마는 여전히 지정된 키만 추출 (file 제거됨)", () => {
312
+ const schema = { title: v.string() };
313
+ const args = { title: "hello", file: "should-be-stripped", extra: 123 };
314
+ const result = parseArgs(schema, args);
315
+ expect(result).toEqual({ title: "hello" });
316
+ expect(result.file).toBeUndefined();
317
+ expect(result.extra).toBeUndefined();
318
+ });
284
319
  });
package/src/storage.ts CHANGED
@@ -52,8 +52,9 @@ export interface Storage {
52
52
  const metaStore = new Map<string, StorageFile>();
53
53
 
54
54
  /**
55
- * files 테이블 자동 생성 (이미 존재하면 무시)
55
+ * _system_files 테이블 자동 생성 (이미 존재하면 무시)
56
56
  * admin.ts의 CREATE_FILES_TABLE_SQL과 동일한 스키마 사용
57
+ * ⚠️ 테이블명 '_system_files' — 사용자 테이블과 네이밍 충돌 방지 (2026-04-10)
57
58
  */
58
59
  let filesTableEnsured = false;
59
60
 
@@ -67,9 +68,9 @@ async function ensureFilesTable(
67
68
  IF NOT EXISTS (
68
69
  SELECT 1 FROM information_schema.tables
69
70
  WHERE table_schema = current_schema()
70
- AND table_name = 'files'
71
+ AND table_name = '_system_files'
71
72
  ) THEN
72
- CREATE TABLE files (
73
+ CREATE TABLE _system_files (
73
74
  id SERIAL PRIMARY KEY,
74
75
  storage_id TEXT NOT NULL,
75
76
  name TEXT NOT NULL,
@@ -95,7 +96,7 @@ async function checkStorageQuota(
95
96
  if (quota <= 0) return; // 무제한
96
97
 
97
98
  const rows = await rawSql(
98
- `SELECT COALESCE(SUM(CAST(size AS BIGINT)), 0) AS total FROM files`,
99
+ `SELECT COALESCE(SUM(CAST(size AS BIGINT)), 0) AS total FROM _system_files`,
99
100
  );
100
101
  const currentUsage = Number((rows[0] as Record<string, string>)?.total || "0");
101
102
  const projectedUsage = currentUsage + newFileSize;
@@ -124,7 +125,7 @@ async function recordFileToDb(
124
125
  ): Promise<void> {
125
126
  await ensureFilesTable(rawSql);
126
127
  await rawSql(
127
- `INSERT INTO files (storage_id, name, size, type, uploaded_by, created_at)
128
+ `INSERT INTO _system_files (storage_id, name, size, type, uploaded_by, created_at)
128
129
  VALUES ($1, $2, $3, $4, $5, NOW())`,
129
130
  [storageId, name, String(size), type, uploadedBy],
130
131
  );
@@ -258,7 +259,7 @@ export function createStorage(
258
259
  if (rawSql) {
259
260
  try {
260
261
  await rawSql(
261
- `DELETE FROM files WHERE storage_id = $1`,
262
+ `DELETE FROM _system_files WHERE storage_id = $1`,
262
263
  [storageId],
263
264
  );
264
265
  } catch { /* 삭제 실패 무시 — 파일은 이미 제거됨 */ }
@@ -286,7 +287,7 @@ export function storageRoutes(
286
287
  if (!meta && rawSql) {
287
288
  try {
288
289
  const rows = await rawSql(
289
- `SELECT storage_id, name, size, type FROM files WHERE storage_id = $1 LIMIT 1`,
290
+ `SELECT storage_id, name, size, type FROM _system_files WHERE storage_id = $1 LIMIT 1`,
290
291
  [id]
291
292
  );
292
293
  if (rows.length > 0) {
package/src/v.ts CHANGED
@@ -135,11 +135,15 @@ export function parseArgs(schema: any, args: any): any {
135
135
 
136
136
  // Shorthand object — e.g. { id: v.number(), title: v.optional(v.string()) }
137
137
  if (typeof schema === "object" && schema !== null) {
138
+ // Empty schema {} → passthrough all args (e.g. FormData with file field)
139
+ const schemaKeys = Object.keys(schema);
140
+ if (schemaKeys.length === 0) return args;
141
+
138
142
  if (typeof args !== "object" || args === null) {
139
143
  throw new GencowValidationError("Expected an object for arguments");
140
144
  }
141
145
  const result: any = {};
142
- for (const key in schema) {
146
+ for (const key of schemaKeys) {
143
147
  const validator = schema[key];
144
148
  if (validator && typeof validator.parse === "function") {
145
149
  try {