@gencow/core 0.1.24 → 0.1.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/crud.d.ts +2 -2
- package/dist/crud.js +225 -208
- package/dist/index.d.ts +5 -5
- package/dist/index.js +2 -2
- package/dist/reactive.js +10 -3
- package/dist/retry.js +1 -1
- package/dist/rls-db.d.ts +2 -2
- package/dist/rls-db.js +1 -5
- package/dist/scheduler.d.ts +2 -0
- package/dist/scheduler.js +16 -6
- package/dist/server.d.ts +0 -1
- package/dist/server.js +0 -1
- package/dist/storage.js +29 -22
- package/dist/v.d.ts +2 -2
- package/dist/workflow.js +4 -11
- package/dist/workflows-api.js +5 -12
- package/package.json +45 -42
- package/src/__tests__/auth.test.ts +90 -86
- package/src/__tests__/crons.test.ts +69 -67
- package/src/__tests__/crud-codegen-integration.test.ts +164 -170
- package/src/__tests__/crud-owner-rls.test.ts +308 -301
- package/src/__tests__/crud.test.ts +694 -711
- package/src/__tests__/dist-exports.test.ts +120 -120
- package/src/__tests__/fixtures/basic/auth.ts +16 -16
- package/src/__tests__/fixtures/basic/drizzle.config.ts +1 -4
- package/src/__tests__/fixtures/basic/index.ts +1 -1
- package/src/__tests__/fixtures/basic/schema.ts +1 -1
- package/src/__tests__/fixtures/basic/tasks.ts +4 -4
- package/src/__tests__/fixtures/common/auth-schema.ts +38 -34
- package/src/__tests__/helpers/basic-rls-fixture.ts +80 -78
- package/src/__tests__/helpers/pglite-migrations.ts +2 -5
- package/src/__tests__/helpers/pglite-rls-session.ts +13 -16
- package/src/__tests__/helpers/seed-like-fill.ts +47 -41
- package/src/__tests__/helpers/test-gencow-ctx-rls.ts +4 -7
- package/src/__tests__/httpaction.test.ts +91 -91
- package/src/__tests__/image-optimization.test.ts +570 -574
- package/src/__tests__/load.test.ts +321 -308
- package/src/__tests__/network-sim.test.ts +238 -215
- package/src/__tests__/reactive.test.ts +380 -358
- package/src/__tests__/retry.test.ts +99 -84
- package/src/__tests__/rls-crud-basic.test.ts +172 -245
- package/src/__tests__/rls-crud-no-owner-rls-pglite.test.ts +81 -81
- package/src/__tests__/rls-custom-mutation-handlers.test.ts +47 -94
- package/src/__tests__/rls-custom-query-handlers.test.ts +92 -92
- package/src/__tests__/rls-db-leased-connection.test.ts +2 -6
- package/src/__tests__/rls-session-and-policies.test.ts +181 -199
- package/src/__tests__/scheduler-durable-v2.test.ts +199 -181
- package/src/__tests__/scheduler-durable.test.ts +117 -117
- package/src/__tests__/scheduler-exec.test.ts +258 -246
- package/src/__tests__/scheduler.test.ts +129 -111
- package/src/__tests__/storage.test.ts +282 -269
- package/src/__tests__/tsconfig.json +6 -6
- package/src/__tests__/validator.test.ts +236 -232
- package/src/__tests__/workflow.test.ts +309 -286
- package/src/__tests__/ws-integration.test.ts +223 -218
- package/src/__tests__/ws-scale.test.ts +168 -159
- package/src/auth-config.ts +18 -18
- package/src/auth.ts +106 -106
- package/src/crons.ts +77 -77
- package/src/crud.ts +523 -479
- package/src/index.ts +69 -5
- package/src/reactive.ts +357 -331
- package/src/retry.ts +51 -54
- package/src/rls-db.ts +195 -205
- package/src/rls.ts +33 -36
- package/src/scheduler.ts +237 -211
- package/src/server.ts +0 -1
- package/src/storage.ts +632 -593
- package/src/v.ts +119 -114
- package/src/workflow-types.ts +67 -70
- package/src/workflow.ts +99 -116
- package/src/workflows-api.ts +231 -241
- package/dist/db.d.ts +0 -13
- package/dist/db.js +0 -16
- 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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
27
|
+
it("각 mutation 호출마다 독립적인 인스턴스를 반환한다", () => {
|
|
28
|
+
const a = buildRealtimeCtx();
|
|
29
|
+
const b = buildRealtimeCtx();
|
|
30
|
+
expect(a).not.toBe(b);
|
|
31
|
+
});
|
|
32
32
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
33
|
+
it("emit() 호출 시 해당 queryKey 구독자에게 query:updated 메시지를 push한다", async () => {
|
|
34
|
+
const ws = makeMockWs();
|
|
35
|
+
subscribe("test.list", ws);
|
|
36
36
|
|
|
37
|
-
|
|
38
|
-
|
|
37
|
+
const rt = buildRealtimeCtx();
|
|
38
|
+
rt.emit("test.list", [{ id: 1, title: "Task A" }]);
|
|
39
39
|
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
// 50ms debounce 대기
|
|
41
|
+
await new Promise((r) => setTimeout(r, 60));
|
|
42
42
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
50
|
-
|
|
49
|
+
deregisterClient(ws);
|
|
50
|
+
});
|
|
51
51
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
52
|
+
it("50ms 내 동일 queryKey에 대한 연속 emit은 마지막 데이터만 push한다 (debounce)", async () => {
|
|
53
|
+
const ws = makeMockWs();
|
|
54
|
+
subscribe("test.debounce", ws);
|
|
55
55
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
62
|
-
|
|
61
|
+
// 50ms debounce 대기
|
|
62
|
+
await new Promise((r) => setTimeout(r, 80));
|
|
63
63
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
64
|
+
expect(ws._sent).toHaveLength(1);
|
|
65
|
+
const msg = JSON.parse(ws._sent[0]);
|
|
66
|
+
expect(msg.data).toEqual([{ id: 3 }]); // 마지막 값만 전달
|
|
67
67
|
|
|
68
|
-
|
|
69
|
-
|
|
68
|
+
deregisterClient(ws);
|
|
69
|
+
});
|
|
70
70
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
71
|
+
it("서로 다른 queryKey emit은 각각 독립적으로 처리된다", async () => {
|
|
72
|
+
const ws = makeMockWs();
|
|
73
|
+
subscribe("alpha.list", ws);
|
|
74
|
+
subscribe("beta.list", ws);
|
|
75
75
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
76
|
+
const rt = buildRealtimeCtx();
|
|
77
|
+
rt.emit("alpha.list", [{ id: 10 }]);
|
|
78
|
+
rt.emit("beta.list", [{ id: 20 }]);
|
|
79
79
|
|
|
80
|
-
|
|
80
|
+
await new Promise((r) => setTimeout(r, 80));
|
|
81
81
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
88
|
-
|
|
87
|
+
deregisterClient(ws);
|
|
88
|
+
});
|
|
89
89
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
90
|
+
it("구독자가 없으면 아무것도 전송하지 않는다", async () => {
|
|
91
|
+
const rt = buildRealtimeCtx();
|
|
92
|
+
rt.emit("no.subscribers", [{ id: 99 }]);
|
|
93
93
|
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
132
|
+
it("emit()은 query:updated를 구독자에게 즉시 push한다", async () => {
|
|
133
|
+
const wsSubscribed = makeMockWs();
|
|
134
|
+
subscribe("items.list", wsSubscribed);
|
|
135
135
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
136
|
+
const rt = buildRealtimeCtx();
|
|
137
|
+
rt.emit("items.list", [{ id: 1 }]);
|
|
138
|
+
await new Promise((r) => setTimeout(r, 80));
|
|
139
139
|
|
|
140
|
-
|
|
140
|
+
expect(wsSubscribed._sent.some((s: string) => JSON.parse(s).type === "query:updated")).toBe(true);
|
|
141
141
|
|
|
142
|
-
|
|
143
|
-
|
|
142
|
+
deregisterClient(wsSubscribed);
|
|
143
|
+
});
|
|
144
144
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
145
|
+
it("emit() 호출 후 _hasEmitted가 true로 설정된다", () => {
|
|
146
|
+
const rt = buildRealtimeCtx();
|
|
147
|
+
expect((rt as any)._hasEmitted).toBe(false);
|
|
148
148
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
149
|
+
const ws = makeMockWs();
|
|
150
|
+
subscribe("flag.test", ws);
|
|
151
|
+
rt.emit("flag.test", [{ id: 1 }]);
|
|
152
152
|
|
|
153
|
-
|
|
153
|
+
expect((rt as any)._hasEmitted).toBe(true);
|
|
154
154
|
|
|
155
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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
|
-
|
|
339
|
-
|
|
340
|
-
console.warn = warnSpy;
|
|
391
|
+
// 50ms debounce 대기
|
|
392
|
+
await new Promise((r) => setTimeout(r, 80));
|
|
341
393
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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
|
-
|
|
421
|
-
|
|
422
|
-
query(testQueryKey, { public: true, handler: async () => [] });
|
|
456
|
+
rt.refresh(testQueryKey);
|
|
457
|
+
expect(rt._pendingRefresh).toHaveLength(1);
|
|
423
458
|
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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
|
-
|
|
433
|
-
|
|
463
|
+
it("미등록 queryKey refresh는 무시되고 경고 출력", async () => {
|
|
464
|
+
const warnSpy = mock(() => {});
|
|
465
|
+
const originalWarn = console.warn;
|
|
466
|
+
console.warn = warnSpy;
|
|
434
467
|
|
|
435
|
-
|
|
436
|
-
|
|
468
|
+
const rt = buildRealtimeCtx({
|
|
469
|
+
buildCtxForRefresh: () => ({}) as any,
|
|
437
470
|
});
|
|
471
|
+
rt.refresh("nonexistent.query.key.xyz");
|
|
472
|
+
await rt._flushRefresh();
|
|
438
473
|
|
|
439
|
-
|
|
440
|
-
|
|
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
|
-
|
|
451
|
-
|
|
452
|
-
);
|
|
453
|
-
expect(hasWarning).toBe(true);
|
|
454
|
-
|
|
455
|
-
console.warn = originalWarn;
|
|
456
|
-
});
|
|
477
|
+
console.warn = originalWarn;
|
|
478
|
+
});
|
|
457
479
|
});
|