@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,451 +7,473 @@
7
7
  */
8
8
 
9
9
  import { describe, it, expect, mock, beforeEach } from "bun:test";
10
- import { buildRealtimeCtx, subscribe, deregisterClient, registerClient } from "../reactive";
11
- import type { GencowCtx } from "../reactive";
10
+ import { buildRealtimeCtx, subscribe, deregisterClient, registerClient } from "../reactive.js";
11
+ import type { GencowCtx } from "../reactive.js";
12
12
 
13
13
  // ─── Mock WebSocket (Bun-style WSContext) ────────────────────────────────────
14
14
 
15
15
  function makeMockWs() {
16
- const sent: string[] = [];
17
- return {
18
- send: (msg: string) => sent.push(msg),
19
- readyState: 1,
20
- _sent: sent,
21
- } as any;
16
+ const sent: string[] = [];
17
+ return {
18
+ send: (msg: string) => sent.push(msg),
19
+ readyState: 1,
20
+ _sent: sent,
21
+ } as any;
22
22
  }
23
23
 
24
24
  // ─── buildRealtimeCtx ────────────────────────────────────────────────────────
25
25
 
26
26
  describe("buildRealtimeCtx()", () => {
27
- it("각 mutation 호출마다 독립적인 인스턴스를 반환한다", () => {
28
- const a = buildRealtimeCtx();
29
- const b = buildRealtimeCtx();
30
- expect(a).not.toBe(b);
31
- });
27
+ it("각 mutation 호출마다 독립적인 인스턴스를 반환한다", () => {
28
+ const a = buildRealtimeCtx();
29
+ const b = buildRealtimeCtx();
30
+ expect(a).not.toBe(b);
31
+ });
32
32
 
33
- it("emit() 호출 시 해당 queryKey 구독자에게 query:updated 메시지를 push한다", async () => {
34
- const ws = makeMockWs();
35
- subscribe("test.list", ws);
33
+ it("emit() 호출 시 해당 queryKey 구독자에게 query:updated 메시지를 push한다", async () => {
34
+ const ws = makeMockWs();
35
+ subscribe("test.list", ws);
36
36
 
37
- const rt = buildRealtimeCtx();
38
- rt.emit("test.list", [{ id: 1, title: "Task A" }]);
37
+ const rt = buildRealtimeCtx();
38
+ rt.emit("test.list", [{ id: 1, title: "Task A" }]);
39
39
 
40
- // 50ms debounce 대기
41
- await new Promise(r => setTimeout(r, 60));
40
+ // 50ms debounce 대기
41
+ await new Promise((r) => setTimeout(r, 60));
42
42
 
43
- expect(ws._sent).toHaveLength(1);
44
- const msg = JSON.parse(ws._sent[0]);
45
- expect(msg.type).toBe("query:updated");
46
- expect(msg.query).toBe("test.list");
47
- expect(msg.data).toEqual([{ id: 1, title: "Task A" }]);
43
+ expect(ws._sent).toHaveLength(1);
44
+ const msg = JSON.parse(ws._sent[0]);
45
+ expect(msg.type).toBe("query:updated");
46
+ expect(msg.query).toBe("test.list");
47
+ expect(msg.data).toEqual([{ id: 1, title: "Task A" }]);
48
48
 
49
- deregisterClient(ws);
50
- });
49
+ deregisterClient(ws);
50
+ });
51
51
 
52
- it("50ms 내 동일 queryKey에 대한 연속 emit은 마지막 데이터만 push한다 (debounce)", async () => {
53
- const ws = makeMockWs();
54
- subscribe("test.debounce", ws);
52
+ it("50ms 내 동일 queryKey에 대한 연속 emit은 마지막 데이터만 push한다 (debounce)", async () => {
53
+ const ws = makeMockWs();
54
+ subscribe("test.debounce", ws);
55
55
 
56
- const rt = buildRealtimeCtx();
57
- rt.emit("test.debounce", [{ id: 1 }]); // 무시됨
58
- rt.emit("test.debounce", [{ id: 2 }]); // 무시됨
59
- rt.emit("test.debounce", [{ id: 3 }]); // 최종 emit
56
+ const rt = buildRealtimeCtx();
57
+ rt.emit("test.debounce", [{ id: 1 }]); // 무시됨
58
+ rt.emit("test.debounce", [{ id: 2 }]); // 무시됨
59
+ rt.emit("test.debounce", [{ id: 3 }]); // 최종 emit
60
60
 
61
- // 50ms debounce 대기
62
- await new Promise(r => setTimeout(r, 80));
61
+ // 50ms debounce 대기
62
+ await new Promise((r) => setTimeout(r, 80));
63
63
 
64
- expect(ws._sent).toHaveLength(1);
65
- const msg = JSON.parse(ws._sent[0]);
66
- expect(msg.data).toEqual([{ id: 3 }]); // 마지막 값만 전달
64
+ expect(ws._sent).toHaveLength(1);
65
+ const msg = JSON.parse(ws._sent[0]);
66
+ expect(msg.data).toEqual([{ id: 3 }]); // 마지막 값만 전달
67
67
 
68
- deregisterClient(ws);
69
- });
68
+ deregisterClient(ws);
69
+ });
70
70
 
71
- it("서로 다른 queryKey emit은 각각 독립적으로 처리된다", async () => {
72
- const ws = makeMockWs();
73
- subscribe("alpha.list", ws);
74
- subscribe("beta.list", ws);
71
+ it("서로 다른 queryKey emit은 각각 독립적으로 처리된다", async () => {
72
+ const ws = makeMockWs();
73
+ subscribe("alpha.list", ws);
74
+ subscribe("beta.list", ws);
75
75
 
76
- const rt = buildRealtimeCtx();
77
- rt.emit("alpha.list", [{ id: 10 }]);
78
- rt.emit("beta.list", [{ id: 20 }]);
76
+ const rt = buildRealtimeCtx();
77
+ rt.emit("alpha.list", [{ id: 10 }]);
78
+ rt.emit("beta.list", [{ id: 20 }]);
79
79
 
80
- await new Promise(r => setTimeout(r, 80));
80
+ await new Promise((r) => setTimeout(r, 80));
81
81
 
82
- expect(ws._sent).toHaveLength(2);
83
- const queries = ws._sent.map((s: string) => JSON.parse(s).query);
84
- expect(queries).toContain("alpha.list");
85
- expect(queries).toContain("beta.list");
82
+ expect(ws._sent).toHaveLength(2);
83
+ const queries = ws._sent.map((s: string) => JSON.parse(s).query);
84
+ expect(queries).toContain("alpha.list");
85
+ expect(queries).toContain("beta.list");
86
86
 
87
- deregisterClient(ws);
88
- });
87
+ deregisterClient(ws);
88
+ });
89
89
 
90
- it("구독자가 없으면 아무것도 전송하지 않는다", async () => {
91
- const rt = buildRealtimeCtx();
92
- rt.emit("no.subscribers", [{ id: 99 }]);
90
+ it("구독자가 없으면 아무것도 전송하지 않는다", async () => {
91
+ const rt = buildRealtimeCtx();
92
+ rt.emit("no.subscribers", [{ id: 99 }]);
93
93
 
94
- await new Promise(r => setTimeout(r, 80));
95
- // no assertion needed — just must not throw
96
- });
94
+ await new Promise((r) => setTimeout(r, 80));
95
+ // no assertion needed — just must not throw
96
+ });
97
97
  });
98
98
 
99
99
  // ─── refresh() API ──────────────────────────────────────────────────────────
100
100
 
101
101
  describe("refresh() — 서버 쿼리 재실행 요청 큐", () => {
102
- it("refresh()로 등록된 queryKey가 _pendingRefresh에 큐잉된다", () => {
103
- const rt = buildRealtimeCtx();
104
- rt.refresh("tasks.list");
105
-
106
- expect((rt as any)._pendingRefresh).toContain("tasks.list");
107
- });
108
-
109
- it("동일 queryKey 중복 refresh는 한 번만 큐잉된다", () => {
110
- const rt = buildRealtimeCtx();
111
- rt.refresh("tasks.list");
112
- rt.refresh("tasks.list");
113
- rt.refresh("tasks.list");
114
-
115
- const count = (rt as any)._pendingRefresh.filter((k: string) => k === "tasks.list").length;
116
- expect(count).toBe(1);
117
- });
118
-
119
- it("서로 다른 queryKey는 각각 큐잉된다", () => {
120
- const rt = buildRealtimeCtx();
121
- rt.refresh("tasks.list");
122
- rt.refresh("users.list");
123
-
124
- expect((rt as any)._pendingRefresh).toContain("tasks.list");
125
- expect((rt as any)._pendingRefresh).toContain("users.list");
126
- });
102
+ it("refresh()로 등록된 queryKey가 _pendingRefresh에 큐잉된다", () => {
103
+ const rt = buildRealtimeCtx();
104
+ rt.refresh("tasks.list");
105
+
106
+ expect((rt as any)._pendingRefresh).toContain("tasks.list");
107
+ });
108
+
109
+ it("동일 queryKey 중복 refresh는 한 번만 큐잉된다", () => {
110
+ const rt = buildRealtimeCtx();
111
+ rt.refresh("tasks.list");
112
+ rt.refresh("tasks.list");
113
+ rt.refresh("tasks.list");
114
+
115
+ const count = (rt as any)._pendingRefresh.filter((k: string) => k === "tasks.list").length;
116
+ expect(count).toBe(1);
117
+ });
118
+
119
+ it("서로 다른 queryKey는 각각 큐잉된다", () => {
120
+ const rt = buildRealtimeCtx();
121
+ rt.refresh("tasks.list");
122
+ rt.refresh("users.list");
123
+
124
+ expect((rt as any)._pendingRefresh).toContain("tasks.list");
125
+ expect((rt as any)._pendingRefresh).toContain("users.list");
126
+ });
127
127
  });
128
128
 
129
129
  // ─── emit()과 refresh() 병행 시나리오 ───────────────────────────────────────
130
130
 
131
131
  describe("emit()과 refresh() 병행", () => {
132
- it("emit()은 query:updated를 구독자에게 즉시 push한다", async () => {
133
- const wsSubscribed = makeMockWs();
134
- subscribe("items.list", wsSubscribed);
132
+ it("emit()은 query:updated를 구독자에게 즉시 push한다", async () => {
133
+ const wsSubscribed = makeMockWs();
134
+ subscribe("items.list", wsSubscribed);
135
135
 
136
- const rt = buildRealtimeCtx();
137
- rt.emit("items.list", [{ id: 1 }]);
138
- await new Promise(r => setTimeout(r, 80));
136
+ const rt = buildRealtimeCtx();
137
+ rt.emit("items.list", [{ id: 1 }]);
138
+ await new Promise((r) => setTimeout(r, 80));
139
139
 
140
- expect(wsSubscribed._sent.some((s: string) => JSON.parse(s).type === "query:updated")).toBe(true);
140
+ expect(wsSubscribed._sent.some((s: string) => JSON.parse(s).type === "query:updated")).toBe(true);
141
141
 
142
- deregisterClient(wsSubscribed);
143
- });
142
+ deregisterClient(wsSubscribed);
143
+ });
144
144
 
145
- it("emit() 호출 후 _hasEmitted가 true로 설정된다", () => {
146
- const rt = buildRealtimeCtx();
147
- expect((rt as any)._hasEmitted).toBe(false);
145
+ it("emit() 호출 후 _hasEmitted가 true로 설정된다", () => {
146
+ const rt = buildRealtimeCtx();
147
+ expect((rt as any)._hasEmitted).toBe(false);
148
148
 
149
- const ws = makeMockWs();
150
- subscribe("flag.test", ws);
151
- rt.emit("flag.test", [{ id: 1 }]);
149
+ const ws = makeMockWs();
150
+ subscribe("flag.test", ws);
151
+ rt.emit("flag.test", [{ id: 1 }]);
152
152
 
153
- expect((rt as any)._hasEmitted).toBe(true);
153
+ expect((rt as any)._hasEmitted).toBe(true);
154
154
 
155
- deregisterClient(ws);
156
- });
155
+ deregisterClient(ws);
156
+ });
157
157
  });
158
158
 
159
159
  // ─── Secure by Default: public 플래그 테스트 ─────────────────────────────────
160
160
 
161
- import { query, mutation, getQueryDef, getRegisteredMutations } from "../reactive";
161
+ import { query, mutation, getQueryDef, getRegisteredMutations } from "../reactive.js";
162
162
 
163
163
  describe("Secure by Default — public 플래그", () => {
164
- it("query() 기본값은 isPublic === false (auth 필수)", () => {
165
- const q = query("sectest.private", {
166
- handler: async () => [],
167
- });
168
- expect(q.isPublic).toBe(false);
164
+ it("query() 기본값은 isPublic === false (auth 필수)", () => {
165
+ const q = query("sectest.private", {
166
+ handler: async () => [],
169
167
  });
168
+ expect(q.isPublic).toBe(false);
169
+ });
170
170
 
171
- it("query({ public: true }) 시 isPublic === true", () => {
172
- const q = query("sectest.public", {
173
- public: true,
174
- handler: async () => [],
175
- });
176
- expect(q.isPublic).toBe(true);
171
+ it("query({ public: true }) 시 isPublic === true", () => {
172
+ const q = query("sectest.public", {
173
+ public: true,
174
+ handler: async () => [],
177
175
  });
178
-
179
- it("query() legacy handler 형식도 isPublic === false", () => {
180
- const q = query("sectest.legacy", async () => []);
181
- expect(q.isPublic).toBe(false);
176
+ expect(q.isPublic).toBe(true);
177
+ });
178
+
179
+ it("query() legacy handler 형식도 isPublic === false", () => {
180
+ const q = query("sectest.legacy", async () => []);
181
+ expect(q.isPublic).toBe(false);
182
+ });
183
+
184
+ it("getQueryDef()로 조회해도 isPublic 정보가 유지된다", () => {
185
+ query("sectest.lookup", { public: true, handler: async () => "ok" });
186
+ const def = getQueryDef("sectest.lookup");
187
+ expect(def).toBeDefined();
188
+ expect(def!.isPublic).toBe(true);
189
+ });
190
+
191
+ it("mutation() 기본값은 isPublic === false", () => {
192
+ const m = mutation({
193
+ name: "sectest.mut.private",
194
+ handler: async () => ({ ok: true }),
182
195
  });
183
-
184
- it("getQueryDef()로 조회해도 isPublic 정보가 유지된다", () => {
185
- query("sectest.lookup", { public: true, handler: async () => "ok" });
186
- const def = getQueryDef("sectest.lookup");
187
- expect(def).toBeDefined();
188
- expect(def!.isPublic).toBe(true);
189
- });
190
-
191
- it("mutation() 기본값은 isPublic === false", () => {
192
- const m = mutation({
193
- name: "sectest.mut.private",
194
- handler: async () => ({ ok: true }),
195
- });
196
- expect(m.isPublic).toBe(false);
197
- });
198
-
199
- it("mutation({ public: true }) 시 isPublic === true", () => {
200
- const m = mutation({
201
- name: "sectest.mut.public",
202
- public: true,
203
- handler: async () => ({ ok: true }),
204
- });
205
- expect(m.isPublic).toBe(true);
206
- });
207
-
208
- it("mutation() legacy array 형식도 isPublic === false", () => {
209
- const m = mutation(["some.key"], async () => ({ ok: true }), "sectest.mut.legacy");
210
- expect(m.isPublic).toBe(false);
211
- });
212
-
213
- it("getRegisteredMutations()에서 isPublic 정보가 노출된다", () => {
214
- const all = getRegisteredMutations();
215
- const pub = all.find(m => m.name === "sectest.mut.public");
216
- const priv = all.find(m => m.name === "sectest.mut.private");
217
- expect(pub?.isPublic).toBe(true);
218
- expect(priv?.isPublic).toBe(false);
196
+ expect(m.isPublic).toBe(false);
197
+ });
198
+
199
+ it("mutation({ public: true }) 시 isPublic === true", () => {
200
+ const m = mutation({
201
+ name: "sectest.mut.public",
202
+ public: true,
203
+ handler: async () => ({ ok: true }),
219
204
  });
205
+ expect(m.isPublic).toBe(true);
206
+ });
207
+
208
+ it("mutation() legacy array 형식도 isPublic === false", () => {
209
+ const m = mutation(["some.key"], async () => ({ ok: true }), "sectest.mut.legacy");
210
+ expect(m.isPublic).toBe(false);
211
+ });
212
+
213
+ it("getRegisteredMutations()에서 isPublic 정보가 노출된다", () => {
214
+ const all = getRegisteredMutations();
215
+ const pub = all.find((m) => m.name === "sectest.mut.public");
216
+ const priv = all.find((m) => m.name === "sectest.mut.private");
217
+ expect(pub?.isPublic).toBe(true);
218
+ expect(priv?.isPublic).toBe(false);
219
+ });
220
220
  });
221
221
 
222
222
  // ─── mutation("name", def) 새 시그니처 테스트 ────────────────────────────────
223
223
 
224
224
  describe("mutation(name, def) — query와 동일 패턴", () => {
225
- it("mutation('name', { handler })로 등록하면 name이 올바르게 설정된다", () => {
226
- const m = mutation("newsig.basic", {
227
- handler: async () => ({ ok: true }),
228
- });
229
- expect((m as any).name || (getRegisteredMutations().find(x => x.handler === (m as any).handler) as any)?.name).toBeDefined();
230
- const all = getRegisteredMutations();
231
- const found = all.find(x => x.name === "newsig.basic");
232
- expect(found).toBeDefined();
233
- expect(found!.isPublic).toBe(false);
225
+ it("mutation('name', { handler })로 등록하면 name이 올바르게 설정된다", () => {
226
+ const m = mutation("newsig.basic", {
227
+ handler: async () => ({ ok: true }),
234
228
  });
235
-
236
- it("mutation('name', { public: true }) 등록하면 isPublic === true", () => {
237
- const m = mutation("newsig.public", {
238
- public: true,
239
- handler: async () => ({ ok: true }),
240
- });
241
- expect(m.isPublic).toBe(true);
229
+ expect(
230
+ (m as any).name ||
231
+ (getRegisteredMutations().find((x) => x.handler === (m as any).handler) as any)?.name,
232
+ ).toBeDefined();
233
+ const all = getRegisteredMutations();
234
+ const found = all.find((x) => x.name === "newsig.basic");
235
+ expect(found).toBeDefined();
236
+ expect(found!.isPublic).toBe(false);
237
+ });
238
+
239
+ it("mutation('name', { public: true })로 등록하면 isPublic === true", () => {
240
+ const m = mutation("newsig.public", {
241
+ public: true,
242
+ handler: async () => ({ ok: true }),
242
243
  });
244
+ expect(m.isPublic).toBe(true);
245
+ });
243
246
 
244
- it("invalidates 미지정 시 빈 배열이 기본값 (하위호환)", () => {
245
- const m = mutation("newsig.noInvalidates", {
246
- handler: async () => ({ ok: true }),
247
- });
248
- const all = getRegisteredMutations();
249
- const found = all.find(x => x.name === "newsig.noInvalidates");
250
- // invalidates는 deprecated이지만 빈 배열로 유지 (하위호환)
251
- expect(found).toBeDefined();
247
+ it("invalidates 미지정 시 빈 배열이 기본값 (하위호환)", () => {
248
+ const m = mutation("newsig.noInvalidates", {
249
+ handler: async () => ({ ok: true }),
252
250
  });
253
-
254
- it("invalidates 지정해도 무시된다 (deprecated)", () => {
255
- const m = mutation("newsig.withInvalidates", {
256
- invalidates: ["tasks.list", "tasks.get"],
257
- handler: async () => ({ ok: true }),
258
- });
259
- const all = getRegisteredMutations();
260
- const found = all.find(x => x.name === "newsig.withInvalidates");
261
- // invalidates deprecated — 전달해도 런타임에서 무시됨
262
- expect(found).toBeDefined();
251
+ const all = getRegisteredMutations();
252
+ const found = all.find((x) => x.name === "newsig.noInvalidates");
253
+ // invalidates는 deprecated이지만 빈 배열로 유지 (하위호환)
254
+ expect(found).toBeDefined();
255
+ });
256
+
257
+ it("invalidates 지정해도 무시된다 (deprecated)", () => {
258
+ const m = mutation("newsig.withInvalidates", {
259
+ invalidates: ["tasks.list", "tasks.get"],
260
+ handler: async () => ({ ok: true }),
263
261
  });
264
-
265
- it("기존 객체 스타일도 여전히 동작한다 (하위 호환)", () => {
266
- const m = mutation({
267
- name: "newsig.compat.object",
268
- handler: async () => ({ ok: true }),
269
- });
270
- const all = getRegisteredMutations();
271
- const found = all.find(x => x.name === "newsig.compat.object");
272
- expect(found).toBeDefined();
273
- });
274
-
275
- it("기존 배열 스타일도 여전히 동작한다 (하위 호환)", () => {
276
- const m = mutation(["b.list"], async () => ({ ok: true }), "newsig.compat.array");
277
- const all = getRegisteredMutations();
278
- const found = all.find(x => x.name === "newsig.compat.array");
279
- expect(found).toBeDefined();
280
- });
281
-
282
- it("이름 미지정 시 console.warn이 호출된다", () => {
283
- const warnSpy = mock(() => {});
284
- const originalWarn = console.warn;
285
- console.warn = warnSpy;
286
-
287
- mutation(["c.list"], async () => ({ ok: true }));
288
-
289
- expect(warnSpy).toHaveBeenCalled();
290
- const warnMsg = warnSpy.mock.calls[0][0] as string;
291
- expect(warnMsg).toContain("[gencow]");
292
- expect(warnMsg).toContain("without explicit name");
293
-
294
- console.warn = originalWarn;
262
+ const all = getRegisteredMutations();
263
+ const found = all.find((x) => x.name === "newsig.withInvalidates");
264
+ // invalidates는 deprecated — 전달해도 런타임에서 무시됨
265
+ expect(found).toBeDefined();
266
+ });
267
+
268
+ it("기존 객체 스타일도 여전히 동작한다 (하위 호환)", () => {
269
+ const m = mutation({
270
+ name: "newsig.compat.object",
271
+ handler: async () => ({ ok: true }),
295
272
  });
273
+ const all = getRegisteredMutations();
274
+ const found = all.find((x) => x.name === "newsig.compat.object");
275
+ expect(found).toBeDefined();
276
+ });
277
+
278
+ it("기존 배열 스타일도 여전히 동작한다 (하위 호환)", () => {
279
+ const m = mutation(["b.list"], async () => ({ ok: true }), "newsig.compat.array");
280
+ const all = getRegisteredMutations();
281
+ const found = all.find((x) => x.name === "newsig.compat.array");
282
+ expect(found).toBeDefined();
283
+ });
284
+
285
+ it("이름 미지정 시 console.warn이 호출된다", () => {
286
+ const warnSpy = mock(() => {});
287
+ const originalWarn = console.warn;
288
+ console.warn = warnSpy;
289
+
290
+ mutation(["c.list"], async () => ({ ok: true }));
291
+
292
+ expect(warnSpy).toHaveBeenCalled();
293
+ const warnMsg = warnSpy.mock.calls[0][0] as string;
294
+ expect(warnMsg).toContain("[gencow]");
295
+ expect(warnMsg).toContain("without explicit name");
296
+
297
+ console.warn = originalWarn;
298
+ });
296
299
  });
297
300
 
298
301
  // ─── _flushRefresh — buildCtxForRefresh 통합 테스트 ──────────────────────────
299
302
 
300
303
  describe("_flushRefresh() — query re-run via buildCtxForRefresh", () => {
304
+ it("buildCtxForRefresh 전달 시 query handler가 정상 ctx로 re-run된다", async () => {
305
+ // 테스트용 query 등록
306
+ const testQueryKey = "flush.test.rerun";
307
+ const handler = mock(async (ctx: any) => {
308
+ // ctx.db가 존재하는지 확인 (이전 버그: ctx = {} → crash)
309
+ if (!ctx.db) throw new Error("ctx.db is undefined!");
310
+ return [{ id: 1, count: ctx.db.mockValue }];
311
+ });
312
+
313
+ query(testQueryKey, { public: true, handler });
314
+
315
+ // buildCtxForRefresh 콜백 제공
316
+ const mockDb = { mockValue: 42 };
317
+ const rt = buildRealtimeCtx({
318
+ buildCtxForRefresh: () =>
319
+ ({
320
+ db: mockDb,
321
+ auth: {
322
+ getUserIdentity: () => null,
323
+ requireAuth: () => {
324
+ throw new Error();
325
+ },
326
+ },
327
+ realtime: { emit: () => {}, refresh: () => {} },
328
+ }) as any,
329
+ });
301
330
 
302
- it("buildCtxForRefresh 전달 시 query handler가 정상 ctx로 re-run된다", async () => {
303
- // 테스트용 query 등록
304
- const testQueryKey = "flush.test.rerun";
305
- const handler = mock(async (ctx: any) => {
306
- // ctx.db가 존재하는지 확인 (이전 버그: ctx = {} → crash)
307
- if (!ctx.db) throw new Error("ctx.db is undefined!");
308
- return [{ id: 1, count: ctx.db.mockValue }];
309
- });
310
-
311
- query(testQueryKey, { public: true, handler });
312
-
313
- // buildCtxForRefresh 콜백 제공
314
- const mockDb = { mockValue: 42 };
315
- const rt = buildRealtimeCtx({
316
- buildCtxForRefresh: () => ({
317
- db: mockDb,
318
- auth: { getUserIdentity: () => null, requireAuth: () => { throw new Error(); } },
319
- realtime: { emit: () => {}, refresh: () => {} },
320
- } as any),
321
- });
322
-
323
- rt.refresh(testQueryKey);
324
- await rt._flushRefresh();
325
-
326
- // handler가 호출됐는지 확인
327
- expect(handler).toHaveBeenCalledTimes(1);
328
- // ctx.db가 올바르게 전달됐는지 확인
329
- const callCtx = handler.mock.calls[0][0];
330
- expect(callCtx.db).toBe(mockDb);
331
+ rt.refresh(testQueryKey);
332
+ await rt._flushRefresh();
333
+
334
+ // handler 호출됐는지 확인
335
+ expect(handler).toHaveBeenCalledTimes(1);
336
+ // ctx.db 올바르게 전달됐는지 확인
337
+ const callCtx = handler.mock.calls[0][0];
338
+ expect(callCtx.db).toBe(mockDb);
339
+ });
340
+
341
+ it("buildCtxForRefresh 미전달 시 ({} as ctx) 사용 + 경고 로그 출력", async () => {
342
+ const testQueryKey = "flush.test.noCallback";
343
+ const handler = mock(async (_ctx: any) => [{ id: 1 }]);
344
+ query(testQueryKey, { public: true, handler });
345
+
346
+ const warnSpy = mock(() => {});
347
+ const originalWarn = console.warn;
348
+ console.warn = warnSpy;
349
+
350
+ // buildCtxForRefresh 없이 생성
351
+ const rt = buildRealtimeCtx();
352
+ rt.refresh(testQueryKey);
353
+ await rt._flushRefresh();
354
+
355
+ // 경고 출력 확인
356
+ const warnCalls = warnSpy.mock.calls.map((c) => String(c[0]));
357
+ const hasWarning = warnCalls.some((msg) => msg.includes("buildCtxForRefresh not provided"));
358
+ expect(hasWarning).toBe(true);
359
+
360
+ console.warn = originalWarn;
361
+ });
362
+
363
+ it("refresh 결과가 WS 구독자에게 query:updated로 push된다", async () => {
364
+ const testQueryKey = "flush.test.push";
365
+ const freshData = [{ id: 99, name: "Refreshed" }];
366
+ query(testQueryKey, {
367
+ public: true,
368
+ handler: async () => freshData,
331
369
  });
332
370
 
333
- it("buildCtxForRefresh 미전달 ({} as ctx) 사용 + 경고 로그 출력", async () => {
334
- const testQueryKey = "flush.test.noCallback";
335
- const handler = mock(async (_ctx: any) => [{ id: 1 }]);
336
- query(testQueryKey, { public: true, handler });
371
+ const ws = makeMockWs();
372
+ subscribe(testQueryKey, ws);
373
+
374
+ const rt = buildRealtimeCtx({
375
+ buildCtxForRefresh: () =>
376
+ ({
377
+ db: {},
378
+ auth: {
379
+ getUserIdentity: () => null,
380
+ requireAuth: () => {
381
+ throw new Error();
382
+ },
383
+ },
384
+ realtime: { emit: () => {}, refresh: () => {} },
385
+ }) as any,
386
+ });
387
+
388
+ rt.refresh(testQueryKey);
389
+ await rt._flushRefresh();
337
390
 
338
- const warnSpy = mock(() => {});
339
- const originalWarn = console.warn;
340
- console.warn = warnSpy;
391
+ // 50ms debounce 대기
392
+ await new Promise((r) => setTimeout(r, 80));
341
393
 
342
- // buildCtxForRefresh 없이 생성
343
- const rt = buildRealtimeCtx();
344
- rt.refresh(testQueryKey);
345
- await rt._flushRefresh();
394
+ expect(ws._sent.length).toBeGreaterThanOrEqual(1);
395
+ const msg = JSON.parse(ws._sent[ws._sent.length - 1]);
396
+ expect(msg.type).toBe("query:updated");
397
+ expect(msg.query).toBe(testQueryKey);
398
+ expect(msg.data).toEqual(freshData);
346
399
 
347
- // 경고 출력 확인
348
- const warnCalls = warnSpy.mock.calls.map(c => String(c[0]));
349
- const hasWarning = warnCalls.some(msg =>
350
- msg.includes("buildCtxForRefresh not provided")
351
- );
352
- expect(hasWarning).toBe(true);
400
+ deregisterClient(ws);
401
+ });
353
402
 
354
- console.warn = originalWarn;
403
+ it("httpCallback 모드에서 refresh 결과가 callback으로 전달된다", async () => {
404
+ const testQueryKey = "flush.test.http";
405
+ const freshData = [{ id: 77, title: "HTTP Push" }];
406
+ query(testQueryKey, {
407
+ public: true,
408
+ handler: async () => freshData,
355
409
  });
356
410
 
357
- it("refresh 결과가 WS 구독자에게 query:updated로 push된다", async () => {
358
- const testQueryKey = "flush.test.push";
359
- const freshData = [{ id: 99, name: "Refreshed" }];
360
- query(testQueryKey, {
361
- public: true,
362
- handler: async () => freshData,
363
- });
364
-
365
- const ws = makeMockWs();
366
- subscribe(testQueryKey, ws);
367
-
368
- const rt = buildRealtimeCtx({
369
- buildCtxForRefresh: () => ({
370
- db: {},
371
- auth: { getUserIdentity: () => null, requireAuth: () => { throw new Error(); } },
372
- realtime: { emit: () => {}, refresh: () => {} },
373
- } as any),
374
- });
375
-
376
- rt.refresh(testQueryKey);
377
- await rt._flushRefresh();
378
-
379
- // 50ms debounce 대기
380
- await new Promise(r => setTimeout(r, 80));
381
-
382
- expect(ws._sent.length).toBeGreaterThanOrEqual(1);
383
- const msg = JSON.parse(ws._sent[ws._sent.length - 1]);
384
- expect(msg.type).toBe("query:updated");
385
- expect(msg.query).toBe(testQueryKey);
386
- expect(msg.data).toEqual(freshData);
387
-
388
- deregisterClient(ws);
411
+ const httpCallback = mock((_event: any) => {});
412
+
413
+ const rt = buildRealtimeCtx({
414
+ httpCallback,
415
+ buildCtxForRefresh: () =>
416
+ ({
417
+ db: {},
418
+ auth: {
419
+ getUserIdentity: () => null,
420
+ requireAuth: () => {
421
+ throw new Error();
422
+ },
423
+ },
424
+ realtime: { emit: () => {}, refresh: () => {} },
425
+ }) as any,
389
426
  });
390
427
 
391
- it("httpCallback 모드에서 refresh 결과가 callback으로 전달된다", async () => {
392
- const testQueryKey = "flush.test.http";
393
- const freshData = [{ id: 77, title: "HTTP Push" }];
394
- query(testQueryKey, {
395
- public: true,
396
- handler: async () => freshData,
397
- });
398
-
399
- const httpCallback = mock((_event: any) => {});
400
-
401
- const rt = buildRealtimeCtx({
402
- httpCallback,
403
- buildCtxForRefresh: () => ({
404
- db: {},
405
- auth: { getUserIdentity: () => null, requireAuth: () => { throw new Error(); } },
406
- realtime: { emit: () => {}, refresh: () => {} },
407
- } as any),
408
- });
409
-
410
- rt.refresh(testQueryKey);
411
- await rt._flushRefresh();
412
-
413
- expect(httpCallback).toHaveBeenCalledTimes(1);
414
- const event = httpCallback.mock.calls[0][0];
415
- expect(event.type).toBe("emit");
416
- expect(event.queryKey).toBe(testQueryKey);
417
- expect(event.data).toEqual(freshData);
428
+ rt.refresh(testQueryKey);
429
+ await rt._flushRefresh();
430
+
431
+ expect(httpCallback).toHaveBeenCalledTimes(1);
432
+ const event = httpCallback.mock.calls[0][0];
433
+ expect(event.type).toBe("emit");
434
+ expect(event.queryKey).toBe(testQueryKey);
435
+ expect(event.data).toEqual(freshData);
436
+ });
437
+
438
+ it("flush _pendingRefresh가 비워진다", async () => {
439
+ const testQueryKey = "flush.test.clear";
440
+ query(testQueryKey, { public: true, handler: async () => [] });
441
+
442
+ const rt = buildRealtimeCtx({
443
+ buildCtxForRefresh: () =>
444
+ ({
445
+ db: {},
446
+ auth: {
447
+ getUserIdentity: () => null,
448
+ requireAuth: () => {
449
+ throw new Error();
450
+ },
451
+ },
452
+ realtime: { emit: () => {}, refresh: () => {} },
453
+ }) as any,
418
454
  });
419
455
 
420
- it("flush 후 _pendingRefresh가 비워진다", async () => {
421
- const testQueryKey = "flush.test.clear";
422
- query(testQueryKey, { public: true, handler: async () => [] });
456
+ rt.refresh(testQueryKey);
457
+ expect(rt._pendingRefresh).toHaveLength(1);
423
458
 
424
- const rt = buildRealtimeCtx({
425
- buildCtxForRefresh: () => ({
426
- db: {},
427
- auth: { getUserIdentity: () => null, requireAuth: () => { throw new Error(); } },
428
- realtime: { emit: () => {}, refresh: () => {} },
429
- } as any),
430
- });
459
+ await rt._flushRefresh();
460
+ expect(rt._pendingRefresh).toHaveLength(0);
461
+ });
431
462
 
432
- rt.refresh(testQueryKey);
433
- expect(rt._pendingRefresh).toHaveLength(1);
463
+ it("미등록 queryKey refresh는 무시되고 경고 출력", async () => {
464
+ const warnSpy = mock(() => {});
465
+ const originalWarn = console.warn;
466
+ console.warn = warnSpy;
434
467
 
435
- await rt._flushRefresh();
436
- expect(rt._pendingRefresh).toHaveLength(0);
468
+ const rt = buildRealtimeCtx({
469
+ buildCtxForRefresh: () => ({}) as any,
437
470
  });
471
+ rt.refresh("nonexistent.query.key.xyz");
472
+ await rt._flushRefresh();
438
473
 
439
- it("미등록 queryKey refresh는 무시되고 경고 출력", async () => {
440
- const warnSpy = mock(() => {});
441
- const originalWarn = console.warn;
442
- console.warn = warnSpy;
443
-
444
- const rt = buildRealtimeCtx({
445
- buildCtxForRefresh: () => ({} as any),
446
- });
447
- rt.refresh("nonexistent.query.key.xyz");
448
- await rt._flushRefresh();
474
+ const hasWarning = warnSpy.mock.calls.some((c) => String(c[0]).includes("query not found in registry"));
475
+ expect(hasWarning).toBe(true);
449
476
 
450
- const hasWarning = warnSpy.mock.calls.some(c =>
451
- String(c[0]).includes("query not found in registry")
452
- );
453
- expect(hasWarning).toBe(true);
454
-
455
- console.warn = originalWarn;
456
- });
477
+ console.warn = originalWarn;
478
+ });
457
479
  });