@gencow/core 0.1.23 → 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 (77) hide show
  1. package/dist/crud.d.ts +2 -2
  2. package/dist/crud.js +225 -208
  3. package/dist/index.d.ts +7 -3
  4. package/dist/index.js +4 -1
  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-types.d.ts +81 -0
  16. package/dist/workflow-types.js +12 -0
  17. package/dist/workflow.d.ts +30 -0
  18. package/dist/workflow.js +150 -0
  19. package/dist/workflows-api.d.ts +13 -0
  20. package/dist/workflows-api.js +321 -0
  21. package/package.json +46 -42
  22. package/src/__tests__/auth.test.ts +90 -86
  23. package/src/__tests__/crons.test.ts +69 -67
  24. package/src/__tests__/crud-codegen-integration.test.ts +164 -170
  25. package/src/__tests__/crud-owner-rls.test.ts +308 -301
  26. package/src/__tests__/crud.test.ts +694 -711
  27. package/src/__tests__/dist-exports.test.ts +120 -114
  28. package/src/__tests__/fixtures/basic/auth.ts +16 -16
  29. package/src/__tests__/fixtures/basic/drizzle.config.ts +1 -4
  30. package/src/__tests__/fixtures/basic/index.ts +1 -1
  31. package/src/__tests__/fixtures/basic/schema.ts +1 -1
  32. package/src/__tests__/fixtures/basic/tasks.ts +4 -4
  33. package/src/__tests__/fixtures/common/auth-schema.ts +38 -34
  34. package/src/__tests__/helpers/basic-rls-fixture.ts +80 -78
  35. package/src/__tests__/helpers/pglite-migrations.ts +2 -5
  36. package/src/__tests__/helpers/pglite-rls-session.ts +13 -16
  37. package/src/__tests__/helpers/seed-like-fill.ts +50 -44
  38. package/src/__tests__/helpers/test-gencow-ctx-rls.ts +4 -7
  39. package/src/__tests__/httpaction.test.ts +91 -91
  40. package/src/__tests__/image-optimization.test.ts +570 -574
  41. package/src/__tests__/load.test.ts +321 -308
  42. package/src/__tests__/network-sim.test.ts +238 -215
  43. package/src/__tests__/reactive.test.ts +380 -358
  44. package/src/__tests__/retry.test.ts +99 -84
  45. package/src/__tests__/rls-crud-basic.test.ts +172 -245
  46. package/src/__tests__/rls-crud-no-owner-rls-pglite.test.ts +81 -81
  47. package/src/__tests__/rls-custom-mutation-handlers.test.ts +47 -94
  48. package/src/__tests__/rls-custom-query-handlers.test.ts +92 -92
  49. package/src/__tests__/rls-db-leased-connection.test.ts +2 -6
  50. package/src/__tests__/rls-session-and-policies.test.ts +181 -199
  51. package/src/__tests__/scheduler-durable-v2.test.ts +199 -181
  52. package/src/__tests__/scheduler-durable.test.ts +117 -117
  53. package/src/__tests__/scheduler-exec.test.ts +258 -246
  54. package/src/__tests__/scheduler.test.ts +129 -111
  55. package/src/__tests__/storage.test.ts +282 -269
  56. package/src/__tests__/tsconfig.json +6 -6
  57. package/src/__tests__/validator.test.ts +236 -232
  58. package/src/__tests__/workflow.test.ts +606 -0
  59. package/src/__tests__/ws-integration.test.ts +223 -218
  60. package/src/__tests__/ws-scale.test.ts +168 -159
  61. package/src/auth-config.ts +18 -18
  62. package/src/auth.ts +106 -106
  63. package/src/crons.ts +77 -77
  64. package/src/crud.ts +523 -479
  65. package/src/index.ts +71 -6
  66. package/src/reactive.ts +357 -331
  67. package/src/retry.ts +51 -54
  68. package/src/rls-db.ts +195 -205
  69. package/src/rls.ts +33 -36
  70. package/src/scheduler.ts +237 -211
  71. package/src/server.ts +0 -1
  72. package/src/storage.ts +632 -593
  73. package/src/v.ts +119 -114
  74. package/src/workflow-types.ts +108 -0
  75. package/src/workflow.ts +188 -0
  76. package/src/workflows-api.ts +415 -0
  77. 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
  });