@gencow/core 0.1.0

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.
@@ -0,0 +1,81 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { cronJobs } from "../crons";
3
+
4
+ describe("cronJobs 빌더", () => {
5
+ test("interval — minutes → cron 패턴 변환", () => {
6
+ const crons = cronJobs();
7
+ crons.interval("test", { minutes: 15 }, "test.action");
8
+ const jobs = crons.getJobs();
9
+ expect(jobs).toHaveLength(1);
10
+ expect(jobs[0].name).toBe("test");
11
+ expect(jobs[0].pattern).toBe("*/15 * * * *");
12
+ expect(jobs[0].action).toBe("test.action");
13
+ });
14
+
15
+ test("interval — hours → cron 패턴 변환", () => {
16
+ const crons = cronJobs();
17
+ crons.interval("hourly", { hours: 6 }, "cleanup");
18
+ expect(crons.getJobs()[0].pattern).toBe("0 */6 * * *");
19
+ });
20
+
21
+ test("interval — seconds → 6자리 cron 패턴", () => {
22
+ const crons = cronJobs();
23
+ crons.interval("fast", { seconds: 30 }, "tick");
24
+ expect(crons.getJobs()[0].pattern).toBe("*/30 * * * * *");
25
+ });
26
+
27
+ test("daily — 특정 시각", () => {
28
+ const crons = cronJobs();
29
+ crons.daily("report", { hour: 9, minute: 30 }, "reports.gen");
30
+ const job = crons.getJobs()[0];
31
+ expect(job.pattern).toBe("30 9 * * *");
32
+ });
33
+
34
+ test("daily — minute 기본값 0", () => {
35
+ const crons = cronJobs();
36
+ crons.daily("cleanup", { hour: 2 }, "admin.cleanup");
37
+ expect(crons.getJobs()[0].pattern).toBe("0 2 * * *");
38
+ });
39
+
40
+ test("weekly — 요일 + 시각", () => {
41
+ const crons = cronJobs();
42
+ crons.weekly("monday-report", { dayOfWeek: 1, hour: 9 }, "reports.weekly");
43
+ expect(crons.getJobs()[0].pattern).toBe("0 9 * * 1");
44
+ });
45
+
46
+ test("cron — 직접 패턴 지정", () => {
47
+ const crons = cronJobs();
48
+ crons.cron("custom", "0 */2 * * *", "custom.handler");
49
+ expect(crons.getJobs()[0].pattern).toBe("0 */2 * * *");
50
+ });
51
+
52
+ test("체이닝 지원", () => {
53
+ const crons = cronJobs();
54
+ crons
55
+ .interval("a", { minutes: 5 }, "a.action")
56
+ .daily("b", { hour: 3 }, "b.action")
57
+ .cron("c", "0 * * * *", "c.action");
58
+ expect(crons.getJobs()).toHaveLength(3);
59
+ });
60
+
61
+ test("인라인 핸들러 지원", () => {
62
+ const crons = cronJobs();
63
+ const fn = async () => { /* noop */ };
64
+ crons.interval("inline", { minutes: 1 }, fn);
65
+ expect(typeof crons.getJobs()[0].action).toBe("function");
66
+ });
67
+
68
+ test("getJobs는 복사본 반환 (원본 보호)", () => {
69
+ const crons = cronJobs();
70
+ crons.interval("test", { minutes: 1 }, "test");
71
+ const jobs1 = crons.getJobs();
72
+ const jobs2 = crons.getJobs();
73
+ expect(jobs1).not.toBe(jobs2); // 서로 다른 참조
74
+ expect(jobs1).toEqual(jobs2); // 내용은 동일
75
+ });
76
+
77
+ test("interval — 옵션 없으면 에러", () => {
78
+ const crons = cronJobs();
79
+ expect(() => crons.interval("bad", {}, "bad")).toThrow("minutes, hours, 또는 seconds");
80
+ });
81
+ });
@@ -0,0 +1,394 @@
1
+ /**
2
+ * packages/core/src/__tests__/load.test.ts
3
+ *
4
+ * Load / stress tests for ctx.realtime.emit() push model.
5
+ *
6
+ * Run: bun test packages/core/src/__tests__/load.test.ts
7
+ */
8
+
9
+ import { describe, it, expect } from "bun:test";
10
+ import {
11
+ buildRealtimeCtx,
12
+ invalidateQueries,
13
+ subscribe,
14
+ registerClient,
15
+ deregisterClient,
16
+ } from "../reactive";
17
+ import type { GencowCtx } from "../reactive";
18
+
19
+ // ─── Mock WSContext ───────────────────────────────────────────────────────────
20
+
21
+ function makeMockWs(id = 0) {
22
+ let sendCount = 0;
23
+ let lastPayloadBytes = 0;
24
+ return {
25
+ send: (msg: string) => {
26
+ sendCount++;
27
+ lastPayloadBytes = msg.length;
28
+ },
29
+ readyState: 1,
30
+ id,
31
+ get sendCount() { return sendCount; },
32
+ get lastPayloadBytes() { return lastPayloadBytes; },
33
+ } as any;
34
+ }
35
+
36
+ // 메시지 타입을 추적하는 WS (비교 테스트용)
37
+ function makeTrackedWs(id = 0) {
38
+ type Msg = { type: string; hasData: boolean };
39
+ const received: Msg[] = [];
40
+ return {
41
+ send: (msg: string) => {
42
+ const parsed = JSON.parse(msg);
43
+ received.push({
44
+ type: parsed.type,
45
+ hasData: parsed.data !== undefined,
46
+ });
47
+ },
48
+ readyState: 1,
49
+ id,
50
+ get received() { return received; },
51
+ get sendCount() { return received.length; },
52
+ } as any;
53
+ }
54
+
55
+ const wait = (ms: number) => new Promise(r => setTimeout(r, ms));
56
+
57
+ function makeUniqueKey(prefix: string) {
58
+ return `${prefix}.${Math.random().toString(36).slice(2)}`;
59
+ }
60
+
61
+ // ─── 1. 다수 구독자 throughput ────────────────────────────────────────────────
62
+
63
+ describe("[Load] 다수 구독자 emit 처리량", () => {
64
+ it("1,000명 구독자에게 emit이 150ms 이내에 완료된다", async () => {
65
+ const N = 1_000;
66
+ const key = makeUniqueKey("load1k");
67
+ const clients = Array.from({ length: N }, (_, i) => makeMockWs(i));
68
+ clients.forEach(ws => subscribe(key, ws));
69
+
70
+ const rt = buildRealtimeCtx();
71
+ const start = performance.now();
72
+ rt.emit(key, [{ id: 1, title: "Task 1" }]);
73
+ await wait(60);
74
+ const elapsed = performance.now() - start;
75
+
76
+ console.log(`[throughput] 1,000 subscribers → ${elapsed.toFixed(1)}ms`);
77
+ expect(clients.filter(ws => ws.sendCount === 1).length).toBe(N);
78
+ expect(elapsed).toBeLessThan(150);
79
+ clients.forEach(ws => deregisterClient(ws));
80
+ });
81
+
82
+ it("5,000명 구독자에게 emit이 300ms 이내에 완료된다", async () => {
83
+ const N = 5_000;
84
+ const key = makeUniqueKey("load5k");
85
+ const clients = Array.from({ length: N }, (_, i) => makeMockWs(i));
86
+ clients.forEach(ws => subscribe(key, ws));
87
+
88
+ const rt = buildRealtimeCtx();
89
+ const start = performance.now();
90
+ rt.emit(key, [{ id: 1 }]);
91
+ await wait(60);
92
+ const elapsed = performance.now() - start;
93
+
94
+ console.log(`[throughput] 5,000 subscribers → ${elapsed.toFixed(1)}ms`);
95
+ expect(clients.filter(ws => ws.sendCount === 1).length).toBe(N);
96
+ expect(elapsed).toBeLessThan(300);
97
+ clients.forEach(ws => deregisterClient(ws));
98
+ });
99
+
100
+ it("10,000명 구독자에게 emit이 500ms 이내에 완료된다", async () => {
101
+ const N = 10_000;
102
+ const key = makeUniqueKey("load10k");
103
+ const clients = Array.from({ length: N }, (_, i) => makeMockWs(i));
104
+ clients.forEach(ws => subscribe(key, ws));
105
+
106
+ const rt = buildRealtimeCtx();
107
+ const start = performance.now();
108
+ rt.emit(key, [{ id: 1 }]);
109
+ await wait(60);
110
+ const elapsed = performance.now() - start;
111
+
112
+ console.log(`[throughput] 10,000 subscribers → ${elapsed.toFixed(1)}ms`);
113
+ expect(clients.filter(ws => ws.sendCount === 1).length).toBe(N);
114
+ expect(elapsed).toBeLessThan(500);
115
+ clients.forEach(ws => deregisterClient(ws));
116
+ });
117
+
118
+ it("50,000명 구독자에게 emit이 2,000ms 이내에 완료된다", async () => {
119
+ const N = 50_000;
120
+ const key = makeUniqueKey("load50k");
121
+ const clients = Array.from({ length: N }, (_, i) => makeMockWs(i));
122
+ clients.forEach(ws => subscribe(key, ws));
123
+
124
+ const rt = buildRealtimeCtx();
125
+ const start = performance.now();
126
+ rt.emit(key, [{ id: 1 }]);
127
+ await wait(60);
128
+ const elapsed = performance.now() - start;
129
+
130
+ console.log(`[throughput] 50,000 subscribers → ${elapsed.toFixed(1)}ms`);
131
+ expect(clients.filter(ws => ws.sendCount === 1).length).toBe(N);
132
+ expect(elapsed).toBeLessThan(2_000);
133
+ clients.forEach(ws => deregisterClient(ws));
134
+ });
135
+
136
+ it("100,000명 구독자에게 emit이 5,000ms 이내에 완료된다", async () => {
137
+ const N = 100_000;
138
+ const key = makeUniqueKey("load100k");
139
+ const clients = Array.from({ length: N }, (_, i) => makeMockWs(i));
140
+ clients.forEach(ws => subscribe(key, ws));
141
+
142
+ const rt = buildRealtimeCtx();
143
+ const start = performance.now();
144
+ rt.emit(key, [{ id: 1 }]);
145
+ await wait(60);
146
+ const elapsed = performance.now() - start;
147
+
148
+ console.log(`[throughput]100,000 subscribers → ${elapsed.toFixed(1)}ms`);
149
+ expect(clients.filter(ws => ws.sendCount === 1).length).toBe(N);
150
+ expect(elapsed).toBeLessThan(5_000);
151
+ clients.forEach(ws => deregisterClient(ws));
152
+ });
153
+ });
154
+
155
+ // ─── 2. 동시 다수 queryKey emit 처리량 ─────────────────────────────────────
156
+
157
+ describe("[Load] 동시 다수 queryKey emit", () => {
158
+ it("100개 queryKey × 100명 구독자 emit을 200ms 이내에 처리한다", async () => {
159
+ const KEYS = 100;
160
+ const SUBS_PER_KEY = 100;
161
+ const keyMap: { key: string; clients: any[] }[] = [];
162
+
163
+ for (let k = 0; k < KEYS; k++) {
164
+ const key = makeUniqueKey(`multi.${k}`);
165
+ const clients = Array.from({ length: SUBS_PER_KEY }, (_, i) => makeMockWs(k * 1000 + i));
166
+ clients.forEach(ws => subscribe(key, ws));
167
+ keyMap.push({ key, clients });
168
+ }
169
+
170
+ const rt = buildRealtimeCtx();
171
+ const start = performance.now();
172
+ for (const { key } of keyMap) {
173
+ rt.emit(key, [{ id: Math.random() }]);
174
+ }
175
+ await wait(80);
176
+ const elapsed = performance.now() - start;
177
+
178
+ console.log(`[multi-key] 100 keys × 100 subs → ${elapsed.toFixed(1)}ms`);
179
+
180
+ const totalReceived = keyMap.reduce(
181
+ (acc, { clients }) => acc + clients.filter(ws => ws.sendCount === 1).length,
182
+ 0
183
+ );
184
+ expect(totalReceived).toBe(KEYS * SUBS_PER_KEY);
185
+ expect(elapsed).toBeLessThan(200);
186
+
187
+ keyMap.forEach(({ clients }) => clients.forEach(ws => deregisterClient(ws)));
188
+ });
189
+ });
190
+
191
+ // ─── 3. debounce 효율 ────────────────────────────────────────────────────────
192
+
193
+ describe("[Load] 빠른 연속 emit debounce 효율", () => {
194
+ it("50ms 이내 100번 연속 emit은 단 1회만 전송된다", async () => {
195
+ const key = makeUniqueKey("debounce.stress");
196
+ const ws = makeMockWs();
197
+ subscribe(key, ws);
198
+
199
+ const rt = buildRealtimeCtx();
200
+ for (let i = 0; i < 100; i++) {
201
+ rt.emit(key, [{ id: i, title: `Task ${i}` }]);
202
+ }
203
+ await wait(80);
204
+
205
+ console.log(`[debounce] 100 rapid emits → ${ws.sendCount} actual sends`);
206
+ expect(ws.sendCount).toBe(1);
207
+
208
+ deregisterClient(ws);
209
+ });
210
+
211
+ it("debounce 효율: 1,000번 emit → 전송 횟수 감소율 99% 이상", async () => {
212
+ const key = makeUniqueKey("debounce.efficiency");
213
+ const ws = makeMockWs();
214
+ subscribe(key, ws);
215
+
216
+ const rt = buildRealtimeCtx();
217
+ const RAPID = 1_000;
218
+ const emitStart = performance.now();
219
+ for (let i = 0; i < RAPID; i++) {
220
+ rt.emit(key, Array.from({ length: 10 }, (_, j) => ({ id: j, value: i })));
221
+ }
222
+ const emitTime = performance.now() - emitStart;
223
+ await wait(80);
224
+
225
+ const reductionRate = ((RAPID - ws.sendCount) / RAPID) * 100;
226
+ console.log(`[debounce] emits: ${RAPID}, sends: ${ws.sendCount}, reduction: ${reductionRate.toFixed(1)}%, loop: ${emitTime.toFixed(1)}ms`);
227
+
228
+ expect(reductionRate).toBeGreaterThanOrEqual(99);
229
+ expect(ws.sendCount).toBe(1);
230
+ expect(emitTime).toBeLessThan(100);
231
+
232
+ deregisterClient(ws);
233
+ });
234
+ });
235
+
236
+ // ─── 4. connect/disconnect 반복 안정성 ─────────────────────────────────────
237
+
238
+ describe("[Load] 클라이언트 connect/disconnect 안정성", () => {
239
+ it("10,000번 connect/disconnect 반복 후 메모리 안정적이다", () => {
240
+ const CYCLES = 10_000;
241
+ const key = makeUniqueKey("churn.list");
242
+ const before = process.memoryUsage().heapUsed;
243
+
244
+ for (let i = 0; i < CYCLES; i++) {
245
+ const ws = makeMockWs(i);
246
+ registerClient(ws);
247
+ subscribe(key, ws);
248
+ deregisterClient(ws);
249
+ }
250
+
251
+ const after = process.memoryUsage().heapUsed;
252
+ const diffMB = (after - before) / 1024 / 1024;
253
+ console.log(`[churn] 10k connect/disconnect → heap diff: ${diffMB.toFixed(2)}MB`);
254
+
255
+ expect(diffMB).toBeLessThan(50);
256
+ });
257
+
258
+ it("zombie ws(send throw)는 emit 후 자동 정리되고 이후 정상 클라이언트는 영향 없다", async () => {
259
+ const key = makeUniqueKey("dead.cleanup");
260
+ let throwCount = 0;
261
+
262
+ const zombieWs = {
263
+ send: () => { throwCount++; throw new Error("WebSocket closed"); },
264
+ readyState: 3,
265
+ } as any;
266
+ subscribe(key, zombieWs);
267
+
268
+ const rt = buildRealtimeCtx();
269
+ rt.emit(key, [{ id: 1 }]);
270
+ await wait(80);
271
+ expect(throwCount).toBe(1);
272
+
273
+ // zombie 정리 후 정상 클라이언트 추가
274
+ const normalWs = makeMockWs();
275
+ subscribe(key, normalWs);
276
+
277
+ const rt2 = buildRealtimeCtx();
278
+ rt2.emit(key, [{ id: 2 }]);
279
+ await wait(80);
280
+
281
+ expect(normalWs.sendCount).toBe(1);
282
+ deregisterClient(normalWs);
283
+ });
284
+ });
285
+
286
+ // ─── 5. 대용량 페이로드 성능 ─────────────────────────────────────────────────
287
+
288
+ describe("[Load] 대용량 페이로드 emit 성능", () => {
289
+ it("10,000행 데이터셋을 100명 구독자에게 500ms 이내에 전송한다", async () => {
290
+ const N_ROWS = 10_000;
291
+ const N_SUBS = 100;
292
+ const key = makeUniqueKey("large.payload");
293
+
294
+ const largeDataset = Array.from({ length: N_ROWS }, (_, i) => ({
295
+ id: i,
296
+ title: `Task ${i}`,
297
+ description: `Description for task ${i} to simulate real content length`,
298
+ done: i % 2 === 0,
299
+ createdAt: new Date().toISOString(),
300
+ tags: ["alpha", "beta", "gamma"],
301
+ }));
302
+
303
+ const clients = Array.from({ length: N_SUBS }, (_, i) => makeMockWs(i));
304
+ clients.forEach(ws => subscribe(key, ws));
305
+
306
+ const rt = buildRealtimeCtx();
307
+ const start = performance.now();
308
+ rt.emit(key, largeDataset);
309
+ await wait(80);
310
+ const elapsed = performance.now() - start;
311
+
312
+ const payloadKB = clients[0]?.lastPayloadBytes / 1024;
313
+ console.log(`[large payload] ${N_ROWS} rows × ${N_SUBS} subs → ${elapsed.toFixed(1)}ms, payload: ${payloadKB.toFixed(1)}KB`);
314
+
315
+ const received = clients.filter(ws => ws.sendCount === 1).length;
316
+ expect(received).toBe(N_SUBS);
317
+ expect(elapsed).toBeLessThan(500);
318
+
319
+ clients.forEach(ws => deregisterClient(ws));
320
+ });
321
+ });
322
+
323
+ // ─── 6. invalidateQueries broadcast 확장성 ──────────────────────────────────
324
+
325
+ describe("[Load] invalidateQueries broadcast 확장성", () => {
326
+ it("1,000개 connectedClients에 broadcast를 50ms 이내에 완료한다", async () => {
327
+ const N = 1_000;
328
+ const clients = Array.from({ length: N }, (_, i) => makeMockWs(i));
329
+ clients.forEach(ws => registerClient(ws));
330
+
331
+ const mockCtx = {} as GencowCtx;
332
+ const start = performance.now();
333
+ await invalidateQueries(["tasks.list", "tasks.get"], mockCtx);
334
+ const elapsed = performance.now() - start;
335
+
336
+ console.log(`[broadcast] 1,000 connectedClients → ${elapsed.toFixed(1)}ms`);
337
+
338
+ const received = clients.filter(ws => ws.sendCount === 1).length;
339
+ expect(received).toBe(N);
340
+ expect(elapsed).toBeLessThan(50);
341
+
342
+ clients.forEach(ws => deregisterClient(ws));
343
+ });
344
+ });
345
+
346
+ // ─── 7. push vs legacy 비교 ─────────────────────────────────────────────────
347
+
348
+ describe("[Load] Push 방식 vs Legacy invalidateQueries 비교", () => {
349
+ it("emit(push)는 query:updated+data를, legacy는 invalidate 신호만 전달한다", async () => {
350
+ const N_SUBS = 500;
351
+ const keyPush = makeUniqueKey("compare.push");
352
+ const keyLegacy = makeUniqueKey("compare.legacy");
353
+
354
+ // push: 구독 클라이언트 (query:updated 수신 예상)
355
+ 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
+ pushClients.forEach(ws => subscribe(keyPush, ws));
360
+ legacyClients.forEach(ws => registerClient(ws)); // subscribe 안 함 → invalidate만 수신
361
+
362
+ const data = Array.from({ length: 100 }, (_, i) => ({ id: i, title: `T${i}` }));
363
+
364
+ // Push path: emit → 50ms debounce → query:updated 전송
365
+ const pushStart = performance.now();
366
+ buildRealtimeCtx().emit(keyPush, data);
367
+ await wait(80);
368
+ const pushElapsed = performance.now() - pushStart;
369
+
370
+ // Legacy path: invalidate broadcast 즉시 전송
371
+ const legacyStart = performance.now();
372
+ await invalidateQueries([keyLegacy], {} as GencowCtx);
373
+ const legacyElapsed = performance.now() - legacyStart;
374
+
375
+ const pushWithData = pushClients.filter(ws =>
376
+ ws.received.some((m: any) => m.type === "query:updated" && m.hasData)
377
+ ).length;
378
+
379
+ const legacyWithSignal = legacyClients.filter(ws =>
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)`);
385
+
386
+ // 핵심 검증
387
+ expect(pushWithData).toBe(N_SUBS); // 500/500이 data 포함 메시지 수신
388
+ expect(legacyWithSignal).toBe(N_SUBS); // 500/500이 신호 수신
389
+ expect(legacyElapsed).toBeLessThan(pushElapsed); // legacy가 더 즉각적 (debounce 없음)
390
+
391
+ pushClients.forEach(ws => deregisterClient(ws));
392
+ legacyClients.forEach(ws => deregisterClient(ws));
393
+ });
394
+ });