@gencow/core 0.1.17 → 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/crud.js +0 -3
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/reactive.d.ts +35 -22
- package/dist/reactive.js +66 -46
- package/dist/scoped-db.d.ts +34 -0
- package/dist/scoped-db.js +364 -0
- package/dist/storage.js +8 -7
- package/dist/table.d.ts +67 -0
- package/dist/table.js +98 -0
- package/dist/v.js +5 -1
- package/package.json +1 -1
- package/src/__tests__/crud-codegen-integration.test.ts +0 -1
- package/src/__tests__/load.test.ts +13 -31
- package/src/__tests__/reactive.test.ts +44 -62
- package/src/__tests__/storage.test.ts +113 -0
- package/src/__tests__/validator.test.ts +35 -0
- package/src/crud.ts +0 -3
- package/src/index.ts +1 -1
- package/src/reactive.ts +89 -50
- package/src/storage.ts +8 -7
- package/src/v.ts +5 -1
|
@@ -9,7 +9,6 @@
|
|
|
9
9
|
import { describe, it, expect } from "bun:test";
|
|
10
10
|
import {
|
|
11
11
|
buildRealtimeCtx,
|
|
12
|
-
invalidateQueries,
|
|
13
12
|
subscribe,
|
|
14
13
|
registerClient,
|
|
15
14
|
deregisterClient,
|
|
@@ -320,44 +319,40 @@ describe("[Load] 대용량 페이로드 emit 성능", () => {
|
|
|
320
319
|
});
|
|
321
320
|
});
|
|
322
321
|
|
|
323
|
-
// ─── 6.
|
|
322
|
+
// ─── 6. emit broadcast 확장성 (컨넥튰드 클라이언트 기반) ─────────────────────────
|
|
324
323
|
|
|
325
|
-
describe("[Load]
|
|
326
|
-
it("1,000개
|
|
324
|
+
describe("[Load] emit broadcast 확장성", () => {
|
|
325
|
+
it("1,000개 구독자에게 emit이 50ms 이내에 완료된다", async () => {
|
|
327
326
|
const N = 1_000;
|
|
327
|
+
const key = makeUniqueKey("broadcast1k");
|
|
328
328
|
const clients = Array.from({ length: N }, (_, i) => makeMockWs(i));
|
|
329
|
-
clients.forEach(ws =>
|
|
329
|
+
clients.forEach(ws => subscribe(key, ws));
|
|
330
330
|
|
|
331
|
-
const mockCtx = {} as GencowCtx;
|
|
332
331
|
const start = performance.now();
|
|
333
|
-
|
|
332
|
+
buildRealtimeCtx().emit(key, [{ id: 1 }, { id: 2 }]);
|
|
333
|
+
await wait(60);
|
|
334
334
|
const elapsed = performance.now() - start;
|
|
335
335
|
|
|
336
|
-
console.log(`[broadcast] 1,000
|
|
336
|
+
console.log(`[broadcast] 1,000 subscribers → ${elapsed.toFixed(1)}ms`);
|
|
337
337
|
|
|
338
338
|
const received = clients.filter(ws => ws.sendCount === 1).length;
|
|
339
339
|
expect(received).toBe(N);
|
|
340
|
-
expect(elapsed).toBeLessThan(
|
|
340
|
+
expect(elapsed).toBeLessThan(100); // emit has 50ms debounce
|
|
341
341
|
|
|
342
342
|
clients.forEach(ws => deregisterClient(ws));
|
|
343
343
|
});
|
|
344
344
|
});
|
|
345
345
|
|
|
346
|
-
// ─── 7.
|
|
346
|
+
// ─── 7. Push emit 포맷 검증 ───────────────────────────────────────────────
|
|
347
347
|
|
|
348
|
-
describe("[Load] Push
|
|
349
|
-
it("emit(push)
|
|
348
|
+
describe("[Load] Push emit 포맷 검증", () => {
|
|
349
|
+
it("emit(push)은 query:updated+data를 실제 페이로드와 함께 전달한다", async () => {
|
|
350
350
|
const N_SUBS = 500;
|
|
351
351
|
const keyPush = makeUniqueKey("compare.push");
|
|
352
|
-
const keyLegacy = makeUniqueKey("compare.legacy");
|
|
353
352
|
|
|
354
353
|
// push: 구독 클라이언트 (query:updated 수신 예상)
|
|
355
354
|
const pushClients = Array.from({ length: N_SUBS }, (_, i) => makeTrackedWs(i));
|
|
356
|
-
// legacy: connectedClients 전용 (invalidate broadcast 수신 예상)
|
|
357
|
-
const legacyClients = Array.from({ length: N_SUBS }, (_, i) => makeTrackedWs(i + N_SUBS));
|
|
358
|
-
|
|
359
355
|
pushClients.forEach(ws => subscribe(keyPush, ws));
|
|
360
|
-
legacyClients.forEach(ws => registerClient(ws)); // subscribe 안 함 → invalidate만 수신
|
|
361
356
|
|
|
362
357
|
const data = Array.from({ length: 100 }, (_, i) => ({ id: i, title: `T${i}` }));
|
|
363
358
|
|
|
@@ -367,28 +362,15 @@ describe("[Load] Push 방식 vs Legacy invalidateQueries 비교", () => {
|
|
|
367
362
|
await wait(80);
|
|
368
363
|
const pushElapsed = performance.now() - pushStart;
|
|
369
364
|
|
|
370
|
-
// Legacy path: invalidate broadcast 즉시 전송
|
|
371
|
-
const legacyStart = performance.now();
|
|
372
|
-
await invalidateQueries([keyLegacy], {} as GencowCtx);
|
|
373
|
-
const legacyElapsed = performance.now() - legacyStart;
|
|
374
|
-
|
|
375
365
|
const pushWithData = pushClients.filter(ws =>
|
|
376
366
|
ws.received.some((m: any) => m.type === "query:updated" && m.hasData)
|
|
377
367
|
).length;
|
|
378
368
|
|
|
379
|
-
|
|
380
|
-
ws.received.some((m: any) => m.type === "invalidate")
|
|
381
|
-
).length;
|
|
382
|
-
|
|
383
|
-
console.log(`[compare] Push : ${pushElapsed.toFixed(1)}ms → ${pushWithData}/${N_SUBS} with data (query:updated)`);
|
|
384
|
-
console.log(`[compare] Legacy: ${legacyElapsed.toFixed(1)}ms → ${legacyWithSignal}/${N_SUBS} signal only (invalidate)`);
|
|
369
|
+
console.log(`[push] ${pushElapsed.toFixed(1)}ms → ${pushWithData}/${N_SUBS} with data (query:updated)`);
|
|
385
370
|
|
|
386
371
|
// 핵심 검증
|
|
387
372
|
expect(pushWithData).toBe(N_SUBS); // 500/500이 data 포함 메시지 수신
|
|
388
|
-
expect(legacyWithSignal).toBe(N_SUBS); // 500/500이 신호 수신
|
|
389
|
-
expect(legacyElapsed).toBeLessThan(pushElapsed); // legacy가 더 즉각적 (debounce 없음)
|
|
390
373
|
|
|
391
374
|
pushClients.forEach(ws => deregisterClient(ws));
|
|
392
|
-
legacyClients.forEach(ws => deregisterClient(ws));
|
|
393
375
|
});
|
|
394
376
|
});
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* packages/core/src/__tests__/reactive.test.ts
|
|
3
3
|
*
|
|
4
|
-
* Tests for ctx.realtime.emit() push model and
|
|
4
|
+
* Tests for ctx.realtime.emit() push model and refresh() API.
|
|
5
5
|
*
|
|
6
6
|
* Run: bun test packages/core/src/__tests__/reactive.test.ts
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { describe, it, expect, mock, beforeEach } from "bun:test";
|
|
10
|
-
import { buildRealtimeCtx,
|
|
10
|
+
import { buildRealtimeCtx, subscribe, deregisterClient, registerClient } from "../reactive";
|
|
11
11
|
import type { GencowCtx } from "../reactive";
|
|
12
12
|
|
|
13
13
|
// ─── Mock WebSocket (Bun-style WSContext) ────────────────────────────────────
|
|
@@ -96,78 +96,63 @@ describe("buildRealtimeCtx()", () => {
|
|
|
96
96
|
});
|
|
97
97
|
});
|
|
98
98
|
|
|
99
|
-
// ───
|
|
99
|
+
// ─── refresh() API ──────────────────────────────────────────────────────────
|
|
100
100
|
|
|
101
|
-
describe("
|
|
102
|
-
it("
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
const mockCtx = {} as GencowCtx;
|
|
107
|
-
await invalidateQueries([], mockCtx);
|
|
101
|
+
describe("refresh() — 서버 쿼리 재실행 요청 큐", () => {
|
|
102
|
+
it("refresh()로 등록된 queryKey가 _pendingRefresh에 큐잉된다", () => {
|
|
103
|
+
const rt = buildRealtimeCtx();
|
|
104
|
+
rt.refresh("tasks.list");
|
|
108
105
|
|
|
109
|
-
expect(
|
|
110
|
-
deregisterClient(ws);
|
|
106
|
+
expect((rt as any)._pendingRefresh).toContain("tasks.list");
|
|
111
107
|
});
|
|
112
108
|
|
|
113
|
-
it("
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
await invalidateQueries(["tasks.list"], mockCtx);
|
|
119
|
-
|
|
120
|
-
expect(ws._sent).toHaveLength(1);
|
|
121
|
-
const msg = JSON.parse(ws._sent[0]);
|
|
122
|
-
expect(msg.type).toBe("invalidate");
|
|
123
|
-
expect(msg.queries).toEqual(["tasks.list"]);
|
|
109
|
+
it("동일 queryKey 중복 refresh는 한 번만 큐잉된다", () => {
|
|
110
|
+
const rt = buildRealtimeCtx();
|
|
111
|
+
rt.refresh("tasks.list");
|
|
112
|
+
rt.refresh("tasks.list");
|
|
113
|
+
rt.refresh("tasks.list");
|
|
124
114
|
|
|
125
|
-
|
|
115
|
+
const count = (rt as any)._pendingRefresh.filter((k: string) => k === "tasks.list").length;
|
|
116
|
+
expect(count).toBe(1);
|
|
126
117
|
});
|
|
127
118
|
|
|
128
|
-
it("
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
const mockCtx = {} as GencowCtx;
|
|
133
|
-
await invalidateQueries(["tasks.list", "tasks.get"], mockCtx);
|
|
134
|
-
|
|
135
|
-
// query:updated 메시지가 없어야 함
|
|
136
|
-
const types = ws._sent.map((s: string) => JSON.parse(s).type);
|
|
137
|
-
expect(types.every((t: string) => t === "invalidate")).toBe(true);
|
|
119
|
+
it("서로 다른 queryKey는 각각 큐잉된다", () => {
|
|
120
|
+
const rt = buildRealtimeCtx();
|
|
121
|
+
rt.refresh("tasks.list");
|
|
122
|
+
rt.refresh("users.list");
|
|
138
123
|
|
|
139
|
-
|
|
124
|
+
expect((rt as any)._pendingRefresh).toContain("tasks.list");
|
|
125
|
+
expect((rt as any)._pendingRefresh).toContain("users.list");
|
|
140
126
|
});
|
|
141
127
|
});
|
|
142
128
|
|
|
143
|
-
// ───
|
|
144
|
-
|
|
145
|
-
describe("emit() 방식과 legacy invalidateQueries() 혼용", () => {
|
|
146
|
-
it("emit()은 query:updated, invalidateQueries()는 invalidate를 각각 전송한다", async () => {
|
|
147
|
-
const wsSubscribed = makeMockWs(); // query 구독 클라이언트
|
|
148
|
-
const wsConnected = makeMockWs(); // 연결만 된 클라이언트 (대시보드)
|
|
129
|
+
// ─── emit()과 refresh() 병행 시나리오 ───────────────────────────────────────
|
|
149
130
|
|
|
131
|
+
describe("emit()과 refresh() 병행", () => {
|
|
132
|
+
it("emit()은 query:updated를 구독자에게 즉시 push한다", async () => {
|
|
133
|
+
const wsSubscribed = makeMockWs();
|
|
150
134
|
subscribe("items.list", wsSubscribed);
|
|
151
|
-
registerClient(wsConnected);
|
|
152
135
|
|
|
153
|
-
// 1. emit() — 구독자에게만 query:updated
|
|
154
136
|
const rt = buildRealtimeCtx();
|
|
155
137
|
rt.emit("items.list", [{ id: 1 }]);
|
|
156
138
|
await new Promise(r => setTimeout(r, 80));
|
|
157
139
|
|
|
158
140
|
expect(wsSubscribed._sent.some((s: string) => JSON.parse(s).type === "query:updated")).toBe(true);
|
|
159
|
-
// connectedClients에만 등록된 ws는 query:updated 미수신
|
|
160
|
-
expect(wsConnected._sent).toHaveLength(0);
|
|
161
141
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
await invalidateQueries(["items.list"], mockCtx);
|
|
142
|
+
deregisterClient(wsSubscribed);
|
|
143
|
+
});
|
|
165
144
|
|
|
166
|
-
|
|
167
|
-
|
|
145
|
+
it("emit() 호출 후 _hasEmitted가 true로 설정된다", () => {
|
|
146
|
+
const rt = buildRealtimeCtx();
|
|
147
|
+
expect((rt as any)._hasEmitted).toBe(false);
|
|
168
148
|
|
|
169
|
-
|
|
170
|
-
|
|
149
|
+
const ws = makeMockWs();
|
|
150
|
+
subscribe("flag.test", ws);
|
|
151
|
+
rt.emit("flag.test", [{ id: 1 }]);
|
|
152
|
+
|
|
153
|
+
expect((rt as any)._hasEmitted).toBe(true);
|
|
154
|
+
|
|
155
|
+
deregisterClient(ws);
|
|
171
156
|
});
|
|
172
157
|
});
|
|
173
158
|
|
|
@@ -206,7 +191,6 @@ describe("Secure by Default — public 플래그", () => {
|
|
|
206
191
|
it("mutation() 기본값은 isPublic === false", () => {
|
|
207
192
|
const m = mutation({
|
|
208
193
|
name: "sectest.mut.private",
|
|
209
|
-
invalidates: [],
|
|
210
194
|
handler: async () => ({ ok: true }),
|
|
211
195
|
});
|
|
212
196
|
expect(m.isPublic).toBe(false);
|
|
@@ -215,7 +199,6 @@ describe("Secure by Default — public 플래그", () => {
|
|
|
215
199
|
it("mutation({ public: true }) 시 isPublic === true", () => {
|
|
216
200
|
const m = mutation({
|
|
217
201
|
name: "sectest.mut.public",
|
|
218
|
-
invalidates: [],
|
|
219
202
|
public: true,
|
|
220
203
|
handler: async () => ({ ok: true }),
|
|
221
204
|
});
|
|
@@ -243,7 +226,7 @@ describe("mutation(name, def) — query와 동일 패턴", () => {
|
|
|
243
226
|
const m = mutation("newsig.basic", {
|
|
244
227
|
handler: async () => ({ ok: true }),
|
|
245
228
|
});
|
|
246
|
-
expect((m as any).name || (getRegisteredMutations().find(x => x.
|
|
229
|
+
expect((m as any).name || (getRegisteredMutations().find(x => x.handler === (m as any).handler) as any)?.name).toBeDefined();
|
|
247
230
|
const all = getRegisteredMutations();
|
|
248
231
|
const found = all.find(x => x.name === "newsig.basic");
|
|
249
232
|
expect(found).toBeDefined();
|
|
@@ -258,35 +241,35 @@ describe("mutation(name, def) — query와 동일 패턴", () => {
|
|
|
258
241
|
expect(m.isPublic).toBe(true);
|
|
259
242
|
});
|
|
260
243
|
|
|
261
|
-
it("invalidates 미지정 시 빈 배열이 기본값", () => {
|
|
244
|
+
it("invalidates 미지정 시 빈 배열이 기본값 (하위호환)", () => {
|
|
262
245
|
const m = mutation("newsig.noInvalidates", {
|
|
263
246
|
handler: async () => ({ ok: true }),
|
|
264
247
|
});
|
|
265
248
|
const all = getRegisteredMutations();
|
|
266
249
|
const found = all.find(x => x.name === "newsig.noInvalidates");
|
|
267
|
-
|
|
250
|
+
// invalidates는 deprecated이지만 빈 배열로 유지 (하위호환)
|
|
251
|
+
expect(found).toBeDefined();
|
|
268
252
|
});
|
|
269
253
|
|
|
270
|
-
it("invalidates
|
|
254
|
+
it("invalidates 지정해도 무시된다 (deprecated)", () => {
|
|
271
255
|
const m = mutation("newsig.withInvalidates", {
|
|
272
256
|
invalidates: ["tasks.list", "tasks.get"],
|
|
273
257
|
handler: async () => ({ ok: true }),
|
|
274
258
|
});
|
|
275
259
|
const all = getRegisteredMutations();
|
|
276
260
|
const found = all.find(x => x.name === "newsig.withInvalidates");
|
|
277
|
-
|
|
261
|
+
// invalidates는 deprecated — 전달해도 런타임에서 무시됨
|
|
262
|
+
expect(found).toBeDefined();
|
|
278
263
|
});
|
|
279
264
|
|
|
280
265
|
it("기존 객체 스타일도 여전히 동작한다 (하위 호환)", () => {
|
|
281
266
|
const m = mutation({
|
|
282
267
|
name: "newsig.compat.object",
|
|
283
|
-
invalidates: ["a.list"],
|
|
284
268
|
handler: async () => ({ ok: true }),
|
|
285
269
|
});
|
|
286
270
|
const all = getRegisteredMutations();
|
|
287
271
|
const found = all.find(x => x.name === "newsig.compat.object");
|
|
288
272
|
expect(found).toBeDefined();
|
|
289
|
-
expect(found!.invalidates).toEqual(["a.list"]);
|
|
290
273
|
});
|
|
291
274
|
|
|
292
275
|
it("기존 배열 스타일도 여전히 동작한다 (하위 호환)", () => {
|
|
@@ -294,7 +277,6 @@ describe("mutation(name, def) — query와 동일 패턴", () => {
|
|
|
294
277
|
const all = getRegisteredMutations();
|
|
295
278
|
const found = all.find(x => x.name === "newsig.compat.array");
|
|
296
279
|
expect(found).toBeDefined();
|
|
297
|
-
expect(found!.invalidates).toEqual(["b.list"]);
|
|
298
280
|
});
|
|
299
281
|
|
|
300
282
|
it("이름 미지정 시 console.warn이 호출된다", () => {
|
|
@@ -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/crud.ts
CHANGED
|
@@ -354,7 +354,6 @@ export function crud<T extends PgTable>(table: T, options?: CrudOptions<T>) {
|
|
|
354
354
|
|
|
355
355
|
const createDef = !enabledMethods.has('create') ? undefined : mutation(`${prefix}.create`, {
|
|
356
356
|
public: isPublic,
|
|
357
|
-
invalidates: [],
|
|
358
357
|
handler: async (ctx: any, args: any) => {
|
|
359
358
|
const user = isPublic ? null : ctx.auth.requireAuth();
|
|
360
359
|
|
|
@@ -386,7 +385,6 @@ export function crud<T extends PgTable>(table: T, options?: CrudOptions<T>) {
|
|
|
386
385
|
|
|
387
386
|
const updateDef = !enabledMethods.has('update') ? undefined : mutation(`${prefix}.update`, {
|
|
388
387
|
public: isPublic,
|
|
389
|
-
invalidates: [],
|
|
390
388
|
handler: async (ctx: any, args: any) => {
|
|
391
389
|
if (!isPublic) ctx.auth.requireAuth();
|
|
392
390
|
|
|
@@ -428,7 +426,6 @@ export function crud<T extends PgTable>(table: T, options?: CrudOptions<T>) {
|
|
|
428
426
|
|
|
429
427
|
const removeDef = !enabledMethods.has('remove') ? undefined : mutation(`${prefix}.remove`, {
|
|
430
428
|
public: isPublic,
|
|
431
|
-
invalidates: [],
|
|
432
429
|
handler: async (ctx: any, args: any) => {
|
|
433
430
|
if (!isPublic) ctx.auth.requireAuth();
|
|
434
431
|
|
package/src/index.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
export type { GencowCtx, AuthCtx, UserIdentity, QueryDef, MutationDef, RealtimeCtx, HttpActionDef, HttpActionRequest, HttpActionResponse, HttpActionHandler, AIContext, AIMessage, AIResult } from "./reactive";
|
|
9
|
-
export { query, mutation, httpAction,
|
|
9
|
+
export { query, mutation, httpAction, buildRealtimeCtx, subscribe, unsubscribe, registerClient, deregisterClient, handleWsMessage, getQueryHandler, getQueryDef, getRegisteredQueries, getRegisteredMutations, getRegisteredHttpActions } from "./reactive";
|
|
10
10
|
export type { Storage } from "./storage";
|
|
11
11
|
export { createScheduler, getSchedulerInfo } from "./scheduler";
|
|
12
12
|
export type { Scheduler, ScheduleOptions, FailedJob } from "./scheduler";
|