@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 +8 -7
- package/dist/v.js +5 -1
- package/package.json +1 -1
- package/src/__tests__/storage.test.ts +113 -0
- package/src/__tests__/validator.test.ts +35 -0
- package/src/storage.ts +8 -7
- package/src/v.ts +5 -1
package/dist/storage.js
CHANGED
|
@@ -18,8 +18,9 @@ function formatBytes(bytes) {
|
|
|
18
18
|
// ─── Implementation ─────────────────────────────────────
|
|
19
19
|
const metaStore = new Map();
|
|
20
20
|
/**
|
|
21
|
-
*
|
|
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 = '
|
|
35
|
+
AND table_name = '_system_files'
|
|
35
36
|
) THEN
|
|
36
|
-
CREATE TABLE
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
@@ -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
|
-
*
|
|
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 = '
|
|
71
|
+
AND table_name = '_system_files'
|
|
71
72
|
) THEN
|
|
72
|
-
CREATE TABLE
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
146
|
+
for (const key of schemaKeys) {
|
|
143
147
|
const validator = schema[key];
|
|
144
148
|
if (validator && typeof validator.parse === "function") {
|
|
145
149
|
try {
|