@gencow/core 0.1.24 → 0.1.25

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 (73) hide show
  1. package/dist/crud.d.ts +2 -2
  2. package/dist/crud.js +225 -208
  3. package/dist/index.d.ts +5 -5
  4. package/dist/index.js +2 -2
  5. package/dist/reactive.js +10 -3
  6. package/dist/retry.js +1 -1
  7. package/dist/rls-db.d.ts +2 -2
  8. package/dist/rls-db.js +1 -5
  9. package/dist/scheduler.d.ts +2 -0
  10. package/dist/scheduler.js +16 -6
  11. package/dist/server.d.ts +0 -1
  12. package/dist/server.js +0 -1
  13. package/dist/storage.js +29 -22
  14. package/dist/v.d.ts +2 -2
  15. package/dist/workflow.js +4 -11
  16. package/dist/workflows-api.js +5 -12
  17. package/package.json +46 -42
  18. package/src/__tests__/auth.test.ts +90 -86
  19. package/src/__tests__/crons.test.ts +69 -67
  20. package/src/__tests__/crud-codegen-integration.test.ts +164 -170
  21. package/src/__tests__/crud-owner-rls.test.ts +308 -301
  22. package/src/__tests__/crud.test.ts +694 -711
  23. package/src/__tests__/dist-exports.test.ts +120 -120
  24. package/src/__tests__/fixtures/basic/auth.ts +16 -16
  25. package/src/__tests__/fixtures/basic/drizzle.config.ts +1 -4
  26. package/src/__tests__/fixtures/basic/index.ts +1 -1
  27. package/src/__tests__/fixtures/basic/schema.ts +1 -1
  28. package/src/__tests__/fixtures/basic/tasks.ts +4 -4
  29. package/src/__tests__/fixtures/common/auth-schema.ts +38 -34
  30. package/src/__tests__/helpers/basic-rls-fixture.ts +80 -78
  31. package/src/__tests__/helpers/pglite-migrations.ts +2 -5
  32. package/src/__tests__/helpers/pglite-rls-session.ts +13 -16
  33. package/src/__tests__/helpers/seed-like-fill.ts +50 -44
  34. package/src/__tests__/helpers/test-gencow-ctx-rls.ts +4 -7
  35. package/src/__tests__/httpaction.test.ts +91 -91
  36. package/src/__tests__/image-optimization.test.ts +570 -574
  37. package/src/__tests__/load.test.ts +321 -308
  38. package/src/__tests__/network-sim.test.ts +238 -215
  39. package/src/__tests__/reactive.test.ts +380 -358
  40. package/src/__tests__/retry.test.ts +99 -84
  41. package/src/__tests__/rls-crud-basic.test.ts +172 -245
  42. package/src/__tests__/rls-crud-no-owner-rls-pglite.test.ts +81 -81
  43. package/src/__tests__/rls-custom-mutation-handlers.test.ts +47 -94
  44. package/src/__tests__/rls-custom-query-handlers.test.ts +92 -92
  45. package/src/__tests__/rls-db-leased-connection.test.ts +2 -6
  46. package/src/__tests__/rls-session-and-policies.test.ts +181 -199
  47. package/src/__tests__/scheduler-durable-v2.test.ts +199 -181
  48. package/src/__tests__/scheduler-durable.test.ts +117 -117
  49. package/src/__tests__/scheduler-exec.test.ts +258 -246
  50. package/src/__tests__/scheduler.test.ts +129 -111
  51. package/src/__tests__/storage.test.ts +282 -269
  52. package/src/__tests__/tsconfig.json +6 -6
  53. package/src/__tests__/validator.test.ts +236 -232
  54. package/src/__tests__/workflow.test.ts +309 -286
  55. package/src/__tests__/ws-integration.test.ts +223 -218
  56. package/src/__tests__/ws-scale.test.ts +168 -159
  57. package/src/auth-config.ts +18 -18
  58. package/src/auth.ts +106 -106
  59. package/src/crons.ts +77 -77
  60. package/src/crud.ts +523 -479
  61. package/src/index.ts +69 -5
  62. package/src/reactive.ts +357 -331
  63. package/src/retry.ts +51 -54
  64. package/src/rls-db.ts +195 -205
  65. package/src/rls.ts +33 -36
  66. package/src/scheduler.ts +237 -211
  67. package/src/server.ts +0 -1
  68. package/src/storage.ts +632 -593
  69. package/src/v.ts +119 -114
  70. package/src/workflow-types.ts +67 -70
  71. package/src/workflow.ts +99 -116
  72. package/src/workflows-api.ts +231 -241
  73. package/src/db.ts +0 -18
@@ -7,370 +7,383 @@
7
7
  */
8
8
 
9
9
  import { describe, it, expect } from "bun:test";
10
- import {
11
- buildRealtimeCtx,
12
- subscribe,
13
- registerClient,
14
- deregisterClient,
15
- } from "../reactive";
16
- import type { GencowCtx } from "../reactive";
10
+ import { buildRealtimeCtx, subscribe, registerClient, deregisterClient } from "../reactive.js";
11
+ import type { GencowCtx } from "../reactive.js";
17
12
 
18
13
  // ─── Mock WSContext ───────────────────────────────────────────────────────────
19
14
 
20
15
  function makeMockWs(id = 0) {
21
- let sendCount = 0;
22
- let lastPayloadBytes = 0;
23
- return {
24
- send: (msg: string) => {
25
- sendCount++;
26
- lastPayloadBytes = msg.length;
27
- },
28
- readyState: 1,
29
- id,
30
- get sendCount() { return sendCount; },
31
- get lastPayloadBytes() { return lastPayloadBytes; },
32
- } as any;
16
+ let sendCount = 0;
17
+ let lastPayloadBytes = 0;
18
+ return {
19
+ send: (msg: string) => {
20
+ sendCount++;
21
+ lastPayloadBytes = msg.length;
22
+ },
23
+ readyState: 1,
24
+ id,
25
+ get sendCount() {
26
+ return sendCount;
27
+ },
28
+ get lastPayloadBytes() {
29
+ return lastPayloadBytes;
30
+ },
31
+ } as any;
33
32
  }
34
33
 
35
34
  // 메시지 타입을 추적하는 WS (비교 테스트용)
36
35
  function makeTrackedWs(id = 0) {
37
- type Msg = { type: string; hasData: boolean };
38
- const received: Msg[] = [];
39
- return {
40
- send: (msg: string) => {
41
- const parsed = JSON.parse(msg);
42
- received.push({
43
- type: parsed.type,
44
- hasData: parsed.data !== undefined,
45
- });
46
- },
47
- readyState: 1,
48
- id,
49
- get received() { return received; },
50
- get sendCount() { return received.length; },
51
- } as any;
36
+ type Msg = { type: string; hasData: boolean };
37
+ const received: Msg[] = [];
38
+ return {
39
+ send: (msg: string) => {
40
+ const parsed = JSON.parse(msg);
41
+ received.push({
42
+ type: parsed.type,
43
+ hasData: parsed.data !== undefined,
44
+ });
45
+ },
46
+ readyState: 1,
47
+ id,
48
+ get received() {
49
+ return received;
50
+ },
51
+ get sendCount() {
52
+ return received.length;
53
+ },
54
+ } as any;
52
55
  }
53
56
 
54
- const wait = (ms: number) => new Promise(r => setTimeout(r, ms));
57
+ const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
55
58
 
56
59
  function makeUniqueKey(prefix: string) {
57
- return `${prefix}.${Math.random().toString(36).slice(2)}`;
60
+ return `${prefix}.${Math.random().toString(36).slice(2)}`;
58
61
  }
59
62
 
60
63
  // ─── 1. 다수 구독자 throughput ────────────────────────────────────────────────
61
64
 
62
65
  describe("[Load] 다수 구독자 emit 처리량", () => {
63
- it("1,000명 구독자에게 emit이 150ms 이내에 완료된다", async () => {
64
- const N = 1_000;
65
- const key = makeUniqueKey("load1k");
66
- const clients = Array.from({ length: N }, (_, i) => makeMockWs(i));
67
- clients.forEach(ws => subscribe(key, ws));
68
-
69
- const rt = buildRealtimeCtx();
70
- const start = performance.now();
71
- rt.emit(key, [{ id: 1, title: "Task 1" }]);
72
- await wait(60);
73
- const elapsed = performance.now() - start;
74
-
75
- console.log(`[throughput] 1,000 subscribers → ${elapsed.toFixed(1)}ms`);
76
- expect(clients.filter(ws => ws.sendCount === 1).length).toBe(N);
77
- expect(elapsed).toBeLessThan(150);
78
- clients.forEach(ws => deregisterClient(ws));
79
- });
80
-
81
- it("5,000명 구독자에게 emit이 300ms 이내에 완료된다", async () => {
82
- const N = 5_000;
83
- const key = makeUniqueKey("load5k");
84
- const clients = Array.from({ length: N }, (_, i) => makeMockWs(i));
85
- clients.forEach(ws => subscribe(key, ws));
86
-
87
- const rt = buildRealtimeCtx();
88
- const start = performance.now();
89
- rt.emit(key, [{ id: 1 }]);
90
- await wait(60);
91
- const elapsed = performance.now() - start;
92
-
93
- console.log(`[throughput] 5,000 subscribers → ${elapsed.toFixed(1)}ms`);
94
- expect(clients.filter(ws => ws.sendCount === 1).length).toBe(N);
95
- expect(elapsed).toBeLessThan(300);
96
- clients.forEach(ws => deregisterClient(ws));
97
- });
98
-
99
- it("10,000명 구독자에게 emit이 500ms 이내에 완료된다", async () => {
100
- const N = 10_000;
101
- const key = makeUniqueKey("load10k");
102
- const clients = Array.from({ length: N }, (_, i) => makeMockWs(i));
103
- clients.forEach(ws => subscribe(key, ws));
104
-
105
- const rt = buildRealtimeCtx();
106
- const start = performance.now();
107
- rt.emit(key, [{ id: 1 }]);
108
- await wait(60);
109
- const elapsed = performance.now() - start;
110
-
111
- console.log(`[throughput] 10,000 subscribers → ${elapsed.toFixed(1)}ms`);
112
- expect(clients.filter(ws => ws.sendCount === 1).length).toBe(N);
113
- expect(elapsed).toBeLessThan(500);
114
- clients.forEach(ws => deregisterClient(ws));
115
- });
116
-
117
- it("50,000명 구독자에게 emit이 2,000ms 이내에 완료된다", async () => {
118
- const N = 50_000;
119
- const key = makeUniqueKey("load50k");
120
- const clients = Array.from({ length: N }, (_, i) => makeMockWs(i));
121
- clients.forEach(ws => subscribe(key, ws));
122
-
123
- const rt = buildRealtimeCtx();
124
- const start = performance.now();
125
- rt.emit(key, [{ id: 1 }]);
126
- await wait(60);
127
- const elapsed = performance.now() - start;
128
-
129
- console.log(`[throughput] 50,000 subscribers → ${elapsed.toFixed(1)}ms`);
130
- expect(clients.filter(ws => ws.sendCount === 1).length).toBe(N);
131
- expect(elapsed).toBeLessThan(2_000);
132
- clients.forEach(ws => deregisterClient(ws));
133
- });
134
-
135
- it("100,000명 구독자에게 emit이 5,000ms 이내에 완료된다", async () => {
136
- const N = 100_000;
137
- const key = makeUniqueKey("load100k");
138
- const clients = Array.from({ length: N }, (_, i) => makeMockWs(i));
139
- clients.forEach(ws => subscribe(key, ws));
140
-
141
- const rt = buildRealtimeCtx();
142
- const start = performance.now();
143
- rt.emit(key, [{ id: 1 }]);
144
- await wait(60);
145
- const elapsed = performance.now() - start;
146
-
147
- console.log(`[throughput]100,000 subscribers → ${elapsed.toFixed(1)}ms`);
148
- expect(clients.filter(ws => ws.sendCount === 1).length).toBe(N);
149
- expect(elapsed).toBeLessThan(5_000);
150
- clients.forEach(ws => deregisterClient(ws));
151
- });
66
+ it("1,000명 구독자에게 emit이 150ms 이내에 완료된다", async () => {
67
+ const N = 1_000;
68
+ const key = makeUniqueKey("load1k");
69
+ const clients = Array.from({ length: N }, (_, i) => makeMockWs(i));
70
+ clients.forEach((ws) => subscribe(key, ws));
71
+
72
+ const rt = buildRealtimeCtx();
73
+ const start = performance.now();
74
+ rt.emit(key, [{ id: 1, title: "Task 1" }]);
75
+ await wait(60);
76
+ const elapsed = performance.now() - start;
77
+
78
+ console.log(`[throughput] 1,000 subscribers → ${elapsed.toFixed(1)}ms`);
79
+ expect(clients.filter((ws) => ws.sendCount === 1).length).toBe(N);
80
+ expect(elapsed).toBeLessThan(150);
81
+ clients.forEach((ws) => deregisterClient(ws));
82
+ });
83
+
84
+ it("5,000명 구독자에게 emit이 300ms 이내에 완료된다", async () => {
85
+ const N = 5_000;
86
+ const key = makeUniqueKey("load5k");
87
+ const clients = Array.from({ length: N }, (_, i) => makeMockWs(i));
88
+ clients.forEach((ws) => subscribe(key, ws));
89
+
90
+ const rt = buildRealtimeCtx();
91
+ const start = performance.now();
92
+ rt.emit(key, [{ id: 1 }]);
93
+ await wait(60);
94
+ const elapsed = performance.now() - start;
95
+
96
+ console.log(`[throughput] 5,000 subscribers → ${elapsed.toFixed(1)}ms`);
97
+ expect(clients.filter((ws) => ws.sendCount === 1).length).toBe(N);
98
+ expect(elapsed).toBeLessThan(300);
99
+ clients.forEach((ws) => deregisterClient(ws));
100
+ });
101
+
102
+ it("10,000명 구독자에게 emit이 500ms 이내에 완료된다", async () => {
103
+ const N = 10_000;
104
+ const key = makeUniqueKey("load10k");
105
+ const clients = Array.from({ length: N }, (_, i) => makeMockWs(i));
106
+ clients.forEach((ws) => subscribe(key, ws));
107
+
108
+ const rt = buildRealtimeCtx();
109
+ const start = performance.now();
110
+ rt.emit(key, [{ id: 1 }]);
111
+ await wait(60);
112
+ const elapsed = performance.now() - start;
113
+
114
+ console.log(`[throughput] 10,000 subscribers → ${elapsed.toFixed(1)}ms`);
115
+ expect(clients.filter((ws) => ws.sendCount === 1).length).toBe(N);
116
+ expect(elapsed).toBeLessThan(500);
117
+ clients.forEach((ws) => deregisterClient(ws));
118
+ });
119
+
120
+ it("50,000명 구독자에게 emit이 2,000ms 이내에 완료된다", async () => {
121
+ const N = 50_000;
122
+ const key = makeUniqueKey("load50k");
123
+ const clients = Array.from({ length: N }, (_, i) => makeMockWs(i));
124
+ clients.forEach((ws) => subscribe(key, ws));
125
+
126
+ const rt = buildRealtimeCtx();
127
+ const start = performance.now();
128
+ rt.emit(key, [{ id: 1 }]);
129
+ await wait(60);
130
+ const elapsed = performance.now() - start;
131
+
132
+ console.log(`[throughput] 50,000 subscribers → ${elapsed.toFixed(1)}ms`);
133
+ expect(clients.filter((ws) => ws.sendCount === 1).length).toBe(N);
134
+ expect(elapsed).toBeLessThan(2_000);
135
+ clients.forEach((ws) => deregisterClient(ws));
136
+ });
137
+
138
+ it("100,000명 구독자에게 emit이 5,000ms 이내에 완료된다", async () => {
139
+ const N = 100_000;
140
+ const key = makeUniqueKey("load100k");
141
+ const clients = Array.from({ length: N }, (_, i) => makeMockWs(i));
142
+ clients.forEach((ws) => subscribe(key, ws));
143
+
144
+ const rt = buildRealtimeCtx();
145
+ const start = performance.now();
146
+ rt.emit(key, [{ id: 1 }]);
147
+ await wait(60);
148
+ const elapsed = performance.now() - start;
149
+
150
+ console.log(`[throughput]100,000 subscribers → ${elapsed.toFixed(1)}ms`);
151
+ expect(clients.filter((ws) => ws.sendCount === 1).length).toBe(N);
152
+ expect(elapsed).toBeLessThan(5_000);
153
+ clients.forEach((ws) => deregisterClient(ws));
154
+ });
152
155
  });
153
156
 
154
157
  // ─── 2. 동시 다수 queryKey emit 처리량 ─────────────────────────────────────
155
158
 
156
159
  describe("[Load] 동시 다수 queryKey emit", () => {
157
- it("100개 queryKey × 100명 구독자 emit을 200ms 이내에 처리한다", async () => {
158
- const KEYS = 100;
159
- const SUBS_PER_KEY = 100;
160
- const keyMap: { key: string; clients: any[] }[] = [];
161
-
162
- for (let k = 0; k < KEYS; k++) {
163
- const key = makeUniqueKey(`multi.${k}`);
164
- const clients = Array.from({ length: SUBS_PER_KEY }, (_, i) => makeMockWs(k * 1000 + i));
165
- clients.forEach(ws => subscribe(key, ws));
166
- keyMap.push({ key, clients });
167
- }
168
-
169
- const rt = buildRealtimeCtx();
170
- const start = performance.now();
171
- for (const { key } of keyMap) {
172
- rt.emit(key, [{ id: Math.random() }]);
173
- }
174
- await wait(80);
175
- const elapsed = performance.now() - start;
176
-
177
- console.log(`[multi-key] 100 keys × 100 subs → ${elapsed.toFixed(1)}ms`);
178
-
179
- const totalReceived = keyMap.reduce(
180
- (acc, { clients }) => acc + clients.filter(ws => ws.sendCount === 1).length,
181
- 0
182
- );
183
- expect(totalReceived).toBe(KEYS * SUBS_PER_KEY);
184
- expect(elapsed).toBeLessThan(200);
185
-
186
- keyMap.forEach(({ clients }) => clients.forEach(ws => deregisterClient(ws)));
187
- });
160
+ it("100개 queryKey × 100명 구독자 emit을 200ms 이내에 처리한다", async () => {
161
+ const KEYS = 100;
162
+ const SUBS_PER_KEY = 100;
163
+ const keyMap: { key: string; clients: any[] }[] = [];
164
+
165
+ for (let k = 0; k < KEYS; k++) {
166
+ const key = makeUniqueKey(`multi.${k}`);
167
+ const clients = Array.from({ length: SUBS_PER_KEY }, (_, i) => makeMockWs(k * 1000 + i));
168
+ clients.forEach((ws) => subscribe(key, ws));
169
+ keyMap.push({ key, clients });
170
+ }
171
+
172
+ const rt = buildRealtimeCtx();
173
+ const start = performance.now();
174
+ for (const { key } of keyMap) {
175
+ rt.emit(key, [{ id: Math.random() }]);
176
+ }
177
+ await wait(80);
178
+ const elapsed = performance.now() - start;
179
+
180
+ console.log(`[multi-key] 100 keys × 100 subs → ${elapsed.toFixed(1)}ms`);
181
+
182
+ const totalReceived = keyMap.reduce(
183
+ (acc, { clients }) => acc + clients.filter((ws) => ws.sendCount === 1).length,
184
+ 0,
185
+ );
186
+ expect(totalReceived).toBe(KEYS * SUBS_PER_KEY);
187
+ expect(elapsed).toBeLessThan(200);
188
+
189
+ keyMap.forEach(({ clients }) => clients.forEach((ws) => deregisterClient(ws)));
190
+ });
188
191
  });
189
192
 
190
193
  // ─── 3. debounce 효율 ────────────────────────────────────────────────────────
191
194
 
192
195
  describe("[Load] 빠른 연속 emit debounce 효율", () => {
193
- it("50ms 이내 100번 연속 emit은 단 1회만 전송된다", async () => {
194
- const key = makeUniqueKey("debounce.stress");
195
- const ws = makeMockWs();
196
- subscribe(key, ws);
197
-
198
- const rt = buildRealtimeCtx();
199
- for (let i = 0; i < 100; i++) {
200
- rt.emit(key, [{ id: i, title: `Task ${i}` }]);
201
- }
202
- await wait(80);
203
-
204
- console.log(`[debounce] 100 rapid emits → ${ws.sendCount} actual sends`);
205
- expect(ws.sendCount).toBe(1);
206
-
207
- deregisterClient(ws);
208
- });
209
-
210
- it("debounce 효율: 1,000번 emit → 전송 횟수 감소율 99% 이상", async () => {
211
- const key = makeUniqueKey("debounce.efficiency");
212
- const ws = makeMockWs();
213
- subscribe(key, ws);
214
-
215
- const rt = buildRealtimeCtx();
216
- const RAPID = 1_000;
217
- const emitStart = performance.now();
218
- for (let i = 0; i < RAPID; i++) {
219
- rt.emit(key, Array.from({ length: 10 }, (_, j) => ({ id: j, value: i })));
220
- }
221
- const emitTime = performance.now() - emitStart;
222
- await wait(80);
223
-
224
- const reductionRate = ((RAPID - ws.sendCount) / RAPID) * 100;
225
- console.log(`[debounce] emits: ${RAPID}, sends: ${ws.sendCount}, reduction: ${reductionRate.toFixed(1)}%, loop: ${emitTime.toFixed(1)}ms`);
226
-
227
- expect(reductionRate).toBeGreaterThanOrEqual(99);
228
- expect(ws.sendCount).toBe(1);
229
- expect(emitTime).toBeLessThan(100);
230
-
231
- deregisterClient(ws);
232
- });
196
+ it("50ms 이내 100번 연속 emit은 단 1회만 전송된다", async () => {
197
+ const key = makeUniqueKey("debounce.stress");
198
+ const ws = makeMockWs();
199
+ subscribe(key, ws);
200
+
201
+ const rt = buildRealtimeCtx();
202
+ for (let i = 0; i < 100; i++) {
203
+ rt.emit(key, [{ id: i, title: `Task ${i}` }]);
204
+ }
205
+ await wait(80);
206
+
207
+ console.log(`[debounce] 100 rapid emits → ${ws.sendCount} actual sends`);
208
+ expect(ws.sendCount).toBe(1);
209
+
210
+ deregisterClient(ws);
211
+ });
212
+
213
+ it("debounce 효율: 1,000번 emit → 전송 횟수 감소율 99% 이상", async () => {
214
+ const key = makeUniqueKey("debounce.efficiency");
215
+ const ws = makeMockWs();
216
+ subscribe(key, ws);
217
+
218
+ const rt = buildRealtimeCtx();
219
+ const RAPID = 1_000;
220
+ const emitStart = performance.now();
221
+ for (let i = 0; i < RAPID; i++) {
222
+ rt.emit(
223
+ key,
224
+ Array.from({ length: 10 }, (_, j) => ({ id: j, value: i })),
225
+ );
226
+ }
227
+ const emitTime = performance.now() - emitStart;
228
+ await wait(80);
229
+
230
+ const reductionRate = ((RAPID - ws.sendCount) / RAPID) * 100;
231
+ console.log(
232
+ `[debounce] emits: ${RAPID}, sends: ${ws.sendCount}, reduction: ${reductionRate.toFixed(1)}%, loop: ${emitTime.toFixed(1)}ms`,
233
+ );
234
+
235
+ expect(reductionRate).toBeGreaterThanOrEqual(99);
236
+ expect(ws.sendCount).toBe(1);
237
+ expect(emitTime).toBeLessThan(100);
238
+
239
+ deregisterClient(ws);
240
+ });
233
241
  });
234
242
 
235
243
  // ─── 4. connect/disconnect 반복 안정성 ─────────────────────────────────────
236
244
 
237
245
  describe("[Load] 클라이언트 connect/disconnect 안정성", () => {
238
- it("10,000번 connect/disconnect 반복 후 메모리 안정적이다", () => {
239
- const CYCLES = 10_000;
240
- const key = makeUniqueKey("churn.list");
241
- const before = process.memoryUsage().heapUsed;
242
-
243
- for (let i = 0; i < CYCLES; i++) {
244
- const ws = makeMockWs(i);
245
- registerClient(ws);
246
- subscribe(key, ws);
247
- deregisterClient(ws);
248
- }
249
-
250
- const after = process.memoryUsage().heapUsed;
251
- const diffMB = (after - before) / 1024 / 1024;
252
- console.log(`[churn] 10k connect/disconnect → heap diff: ${diffMB.toFixed(2)}MB`);
253
-
254
- expect(diffMB).toBeLessThan(50);
255
- });
256
-
257
- it("zombie ws(send throw)는 emit 후 자동 정리되고 이후 정상 클라이언트는 영향 없다", async () => {
258
- const key = makeUniqueKey("dead.cleanup");
259
- let throwCount = 0;
260
-
261
- const zombieWs = {
262
- send: () => { throwCount++; throw new Error("WebSocket closed"); },
263
- readyState: 3,
264
- } as any;
265
- subscribe(key, zombieWs);
266
-
267
- const rt = buildRealtimeCtx();
268
- rt.emit(key, [{ id: 1 }]);
269
- await wait(80);
270
- expect(throwCount).toBe(1);
271
-
272
- // zombie 정리 후 정상 클라이언트 추가
273
- const normalWs = makeMockWs();
274
- subscribe(key, normalWs);
275
-
276
- const rt2 = buildRealtimeCtx();
277
- rt2.emit(key, [{ id: 2 }]);
278
- await wait(80);
279
-
280
- expect(normalWs.sendCount).toBe(1);
281
- deregisterClient(normalWs);
282
- });
246
+ it("10,000번 connect/disconnect 반복 후 메모리 안정적이다", () => {
247
+ const CYCLES = 10_000;
248
+ const key = makeUniqueKey("churn.list");
249
+ const before = process.memoryUsage().heapUsed;
250
+
251
+ for (let i = 0; i < CYCLES; i++) {
252
+ const ws = makeMockWs(i);
253
+ registerClient(ws);
254
+ subscribe(key, ws);
255
+ deregisterClient(ws);
256
+ }
257
+
258
+ const after = process.memoryUsage().heapUsed;
259
+ const diffMB = (after - before) / 1024 / 1024;
260
+ console.log(`[churn] 10k connect/disconnect → heap diff: ${diffMB.toFixed(2)}MB`);
261
+
262
+ expect(diffMB).toBeLessThan(50);
263
+ });
264
+
265
+ it("zombie ws(send throw)는 emit 후 자동 정리되고 이후 정상 클라이언트는 영향 없다", async () => {
266
+ const key = makeUniqueKey("dead.cleanup");
267
+ let throwCount = 0;
268
+
269
+ const zombieWs = {
270
+ send: () => {
271
+ throwCount++;
272
+ throw new Error("WebSocket closed");
273
+ },
274
+ readyState: 3,
275
+ } as any;
276
+ subscribe(key, zombieWs);
277
+
278
+ const rt = buildRealtimeCtx();
279
+ rt.emit(key, [{ id: 1 }]);
280
+ await wait(80);
281
+ expect(throwCount).toBe(1);
282
+
283
+ // zombie 정리 후 정상 클라이언트 추가
284
+ const normalWs = makeMockWs();
285
+ subscribe(key, normalWs);
286
+
287
+ const rt2 = buildRealtimeCtx();
288
+ rt2.emit(key, [{ id: 2 }]);
289
+ await wait(80);
290
+
291
+ expect(normalWs.sendCount).toBe(1);
292
+ deregisterClient(normalWs);
293
+ });
283
294
  });
284
295
 
285
296
  // ─── 5. 대용량 페이로드 성능 ─────────────────────────────────────────────────
286
297
 
287
298
  describe("[Load] 대용량 페이로드 emit 성능", () => {
288
- it("10,000행 데이터셋을 100명 구독자에게 500ms 이내에 전송한다", async () => {
289
- const N_ROWS = 10_000;
290
- const N_SUBS = 100;
291
- const key = makeUniqueKey("large.payload");
292
-
293
- const largeDataset = Array.from({ length: N_ROWS }, (_, i) => ({
294
- id: i,
295
- title: `Task ${i}`,
296
- description: `Description for task ${i} to simulate real content length`,
297
- done: i % 2 === 0,
298
- createdAt: new Date().toISOString(),
299
- tags: ["alpha", "beta", "gamma"],
300
- }));
301
-
302
- const clients = Array.from({ length: N_SUBS }, (_, i) => makeMockWs(i));
303
- clients.forEach(ws => subscribe(key, ws));
304
-
305
- const rt = buildRealtimeCtx();
306
- const start = performance.now();
307
- rt.emit(key, largeDataset);
308
- await wait(80);
309
- const elapsed = performance.now() - start;
310
-
311
- const payloadKB = clients[0]?.lastPayloadBytes / 1024;
312
- console.log(`[large payload] ${N_ROWS} rows × ${N_SUBS} subs → ${elapsed.toFixed(1)}ms, payload: ${payloadKB.toFixed(1)}KB`);
313
-
314
- const received = clients.filter(ws => ws.sendCount === 1).length;
315
- expect(received).toBe(N_SUBS);
316
- expect(elapsed).toBeLessThan(500);
317
-
318
- clients.forEach(ws => deregisterClient(ws));
319
- });
299
+ it("10,000행 데이터셋을 100명 구독자에게 500ms 이내에 전송한다", async () => {
300
+ const N_ROWS = 10_000;
301
+ const N_SUBS = 100;
302
+ const key = makeUniqueKey("large.payload");
303
+
304
+ const largeDataset = Array.from({ length: N_ROWS }, (_, i) => ({
305
+ id: i,
306
+ title: `Task ${i}`,
307
+ description: `Description for task ${i} to simulate real content length`,
308
+ done: i % 2 === 0,
309
+ createdAt: new Date().toISOString(),
310
+ tags: ["alpha", "beta", "gamma"],
311
+ }));
312
+
313
+ const clients = Array.from({ length: N_SUBS }, (_, i) => makeMockWs(i));
314
+ clients.forEach((ws) => subscribe(key, ws));
315
+
316
+ const rt = buildRealtimeCtx();
317
+ const start = performance.now();
318
+ rt.emit(key, largeDataset);
319
+ await wait(80);
320
+ const elapsed = performance.now() - start;
321
+
322
+ const payloadKB = clients[0]?.lastPayloadBytes / 1024;
323
+ console.log(
324
+ `[large payload] ${N_ROWS} rows × ${N_SUBS} subs → ${elapsed.toFixed(1)}ms, payload: ${payloadKB.toFixed(1)}KB`,
325
+ );
326
+
327
+ const received = clients.filter((ws) => ws.sendCount === 1).length;
328
+ expect(received).toBe(N_SUBS);
329
+ expect(elapsed).toBeLessThan(500);
330
+
331
+ clients.forEach((ws) => deregisterClient(ws));
332
+ });
320
333
  });
321
334
 
322
335
  // ─── 6. emit broadcast 확장성 (컨넥튰드 클라이언트 기반) ─────────────────────────
323
336
 
324
337
  describe("[Load] emit broadcast 확장성", () => {
325
- it("1,000개 구독자에게 emit이 50ms 이내에 완료된다", async () => {
326
- const N = 1_000;
327
- const key = makeUniqueKey("broadcast1k");
328
- const clients = Array.from({ length: N }, (_, i) => makeMockWs(i));
329
- clients.forEach(ws => subscribe(key, ws));
338
+ it("1,000개 구독자에게 emit이 50ms 이내에 완료된다", async () => {
339
+ const N = 1_000;
340
+ const key = makeUniqueKey("broadcast1k");
341
+ const clients = Array.from({ length: N }, (_, i) => makeMockWs(i));
342
+ clients.forEach((ws) => subscribe(key, ws));
330
343
 
331
- const start = performance.now();
332
- buildRealtimeCtx().emit(key, [{ id: 1 }, { id: 2 }]);
333
- await wait(60);
334
- const elapsed = performance.now() - start;
344
+ const start = performance.now();
345
+ buildRealtimeCtx().emit(key, [{ id: 1 }, { id: 2 }]);
346
+ await wait(60);
347
+ const elapsed = performance.now() - start;
335
348
 
336
- console.log(`[broadcast] 1,000 subscribers → ${elapsed.toFixed(1)}ms`);
349
+ console.log(`[broadcast] 1,000 subscribers → ${elapsed.toFixed(1)}ms`);
337
350
 
338
- const received = clients.filter(ws => ws.sendCount === 1).length;
339
- expect(received).toBe(N);
340
- expect(elapsed).toBeLessThan(100); // emit has 50ms debounce
351
+ const received = clients.filter((ws) => ws.sendCount === 1).length;
352
+ expect(received).toBe(N);
353
+ expect(elapsed).toBeLessThan(100); // emit has 50ms debounce
341
354
 
342
- clients.forEach(ws => deregisterClient(ws));
343
- });
355
+ clients.forEach((ws) => deregisterClient(ws));
356
+ });
344
357
  });
345
358
 
346
359
  // ─── 7. Push emit 포맷 검증 ───────────────────────────────────────────────
347
360
 
348
361
  describe("[Load] Push emit 포맷 검증", () => {
349
- it("emit(push)은 query:updated+data를 실제 페이로드와 함께 전달한다", async () => {
350
- const N_SUBS = 500;
351
- const keyPush = makeUniqueKey("compare.push");
362
+ it("emit(push)은 query:updated+data를 실제 페이로드와 함께 전달한다", async () => {
363
+ const N_SUBS = 500;
364
+ const keyPush = makeUniqueKey("compare.push");
352
365
 
353
- // push: 구독 클라이언트 (query:updated 수신 예상)
354
- const pushClients = Array.from({ length: N_SUBS }, (_, i) => makeTrackedWs(i));
355
- pushClients.forEach(ws => subscribe(keyPush, ws));
366
+ // push: 구독 클라이언트 (query:updated 수신 예상)
367
+ const pushClients = Array.from({ length: N_SUBS }, (_, i) => makeTrackedWs(i));
368
+ pushClients.forEach((ws) => subscribe(keyPush, ws));
356
369
 
357
- const data = Array.from({ length: 100 }, (_, i) => ({ id: i, title: `T${i}` }));
370
+ const data = Array.from({ length: 100 }, (_, i) => ({ id: i, title: `T${i}` }));
358
371
 
359
- // Push path: emit → 50ms debounce → query:updated 전송
360
- const pushStart = performance.now();
361
- buildRealtimeCtx().emit(keyPush, data);
362
- await wait(80);
363
- const pushElapsed = performance.now() - pushStart;
372
+ // Push path: emit → 50ms debounce → query:updated 전송
373
+ const pushStart = performance.now();
374
+ buildRealtimeCtx().emit(keyPush, data);
375
+ await wait(80);
376
+ const pushElapsed = performance.now() - pushStart;
364
377
 
365
- const pushWithData = pushClients.filter(ws =>
366
- ws.received.some((m: any) => m.type === "query:updated" && m.hasData)
367
- ).length;
378
+ const pushWithData = pushClients.filter((ws) =>
379
+ ws.received.some((m: any) => m.type === "query:updated" && m.hasData),
380
+ ).length;
368
381
 
369
- console.log(`[push] ${pushElapsed.toFixed(1)}ms → ${pushWithData}/${N_SUBS} with data (query:updated)`);
382
+ console.log(`[push] ${pushElapsed.toFixed(1)}ms → ${pushWithData}/${N_SUBS} with data (query:updated)`);
370
383
 
371
- // 핵심 검증
372
- expect(pushWithData).toBe(N_SUBS); // 500/500이 data 포함 메시지 수신
384
+ // 핵심 검증
385
+ expect(pushWithData).toBe(N_SUBS); // 500/500이 data 포함 메시지 수신
373
386
 
374
- pushClients.forEach(ws => deregisterClient(ws));
375
- });
387
+ pushClients.forEach((ws) => deregisterClient(ws));
388
+ });
376
389
  });