@gencow/core 0.1.17 → 0.1.18
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.js +0 -3
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/reactive.d.ts +35 -22
- package/dist/reactive.js +66 -46
- package/dist/scoped-db.d.ts +34 -0
- package/dist/scoped-db.js +364 -0
- package/dist/table.d.ts +67 -0
- package/dist/table.js +98 -0
- package/package.json +1 -1
- package/src/__tests__/crud-codegen-integration.test.ts +0 -1
- package/src/__tests__/load.test.ts +13 -31
- package/src/__tests__/reactive.test.ts +44 -62
- package/src/crud.ts +0 -3
- package/src/index.ts +1 -1
- package/src/reactive.ts +89 -50
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* packages/core/src/__tests__/reactive.test.ts
|
|
3
3
|
*
|
|
4
|
-
* Tests for ctx.realtime.emit() push model and
|
|
4
|
+
* Tests for ctx.realtime.emit() push model and refresh() API.
|
|
5
5
|
*
|
|
6
6
|
* Run: bun test packages/core/src/__tests__/reactive.test.ts
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { describe, it, expect, mock, beforeEach } from "bun:test";
|
|
10
|
-
import { buildRealtimeCtx,
|
|
10
|
+
import { buildRealtimeCtx, subscribe, deregisterClient, registerClient } from "../reactive";
|
|
11
11
|
import type { GencowCtx } from "../reactive";
|
|
12
12
|
|
|
13
13
|
// ─── Mock WebSocket (Bun-style WSContext) ────────────────────────────────────
|
|
@@ -96,78 +96,63 @@ describe("buildRealtimeCtx()", () => {
|
|
|
96
96
|
});
|
|
97
97
|
});
|
|
98
98
|
|
|
99
|
-
// ───
|
|
99
|
+
// ─── refresh() API ──────────────────────────────────────────────────────────
|
|
100
100
|
|
|
101
|
-
describe("
|
|
102
|
-
it("
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
const mockCtx = {} as GencowCtx;
|
|
107
|
-
await invalidateQueries([], mockCtx);
|
|
101
|
+
describe("refresh() — 서버 쿼리 재실행 요청 큐", () => {
|
|
102
|
+
it("refresh()로 등록된 queryKey가 _pendingRefresh에 큐잉된다", () => {
|
|
103
|
+
const rt = buildRealtimeCtx();
|
|
104
|
+
rt.refresh("tasks.list");
|
|
108
105
|
|
|
109
|
-
expect(
|
|
110
|
-
deregisterClient(ws);
|
|
106
|
+
expect((rt as any)._pendingRefresh).toContain("tasks.list");
|
|
111
107
|
});
|
|
112
108
|
|
|
113
|
-
it("
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
await invalidateQueries(["tasks.list"], mockCtx);
|
|
119
|
-
|
|
120
|
-
expect(ws._sent).toHaveLength(1);
|
|
121
|
-
const msg = JSON.parse(ws._sent[0]);
|
|
122
|
-
expect(msg.type).toBe("invalidate");
|
|
123
|
-
expect(msg.queries).toEqual(["tasks.list"]);
|
|
109
|
+
it("동일 queryKey 중복 refresh는 한 번만 큐잉된다", () => {
|
|
110
|
+
const rt = buildRealtimeCtx();
|
|
111
|
+
rt.refresh("tasks.list");
|
|
112
|
+
rt.refresh("tasks.list");
|
|
113
|
+
rt.refresh("tasks.list");
|
|
124
114
|
|
|
125
|
-
|
|
115
|
+
const count = (rt as any)._pendingRefresh.filter((k: string) => k === "tasks.list").length;
|
|
116
|
+
expect(count).toBe(1);
|
|
126
117
|
});
|
|
127
118
|
|
|
128
|
-
it("
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
const mockCtx = {} as GencowCtx;
|
|
133
|
-
await invalidateQueries(["tasks.list", "tasks.get"], mockCtx);
|
|
134
|
-
|
|
135
|
-
// query:updated 메시지가 없어야 함
|
|
136
|
-
const types = ws._sent.map((s: string) => JSON.parse(s).type);
|
|
137
|
-
expect(types.every((t: string) => t === "invalidate")).toBe(true);
|
|
119
|
+
it("서로 다른 queryKey는 각각 큐잉된다", () => {
|
|
120
|
+
const rt = buildRealtimeCtx();
|
|
121
|
+
rt.refresh("tasks.list");
|
|
122
|
+
rt.refresh("users.list");
|
|
138
123
|
|
|
139
|
-
|
|
124
|
+
expect((rt as any)._pendingRefresh).toContain("tasks.list");
|
|
125
|
+
expect((rt as any)._pendingRefresh).toContain("users.list");
|
|
140
126
|
});
|
|
141
127
|
});
|
|
142
128
|
|
|
143
|
-
// ───
|
|
144
|
-
|
|
145
|
-
describe("emit() 방식과 legacy invalidateQueries() 혼용", () => {
|
|
146
|
-
it("emit()은 query:updated, invalidateQueries()는 invalidate를 각각 전송한다", async () => {
|
|
147
|
-
const wsSubscribed = makeMockWs(); // query 구독 클라이언트
|
|
148
|
-
const wsConnected = makeMockWs(); // 연결만 된 클라이언트 (대시보드)
|
|
129
|
+
// ─── emit()과 refresh() 병행 시나리오 ───────────────────────────────────────
|
|
149
130
|
|
|
131
|
+
describe("emit()과 refresh() 병행", () => {
|
|
132
|
+
it("emit()은 query:updated를 구독자에게 즉시 push한다", async () => {
|
|
133
|
+
const wsSubscribed = makeMockWs();
|
|
150
134
|
subscribe("items.list", wsSubscribed);
|
|
151
|
-
registerClient(wsConnected);
|
|
152
135
|
|
|
153
|
-
// 1. emit() — 구독자에게만 query:updated
|
|
154
136
|
const rt = buildRealtimeCtx();
|
|
155
137
|
rt.emit("items.list", [{ id: 1 }]);
|
|
156
138
|
await new Promise(r => setTimeout(r, 80));
|
|
157
139
|
|
|
158
140
|
expect(wsSubscribed._sent.some((s: string) => JSON.parse(s).type === "query:updated")).toBe(true);
|
|
159
|
-
// connectedClients에만 등록된 ws는 query:updated 미수신
|
|
160
|
-
expect(wsConnected._sent).toHaveLength(0);
|
|
161
141
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
await invalidateQueries(["items.list"], mockCtx);
|
|
142
|
+
deregisterClient(wsSubscribed);
|
|
143
|
+
});
|
|
165
144
|
|
|
166
|
-
|
|
167
|
-
|
|
145
|
+
it("emit() 호출 후 _hasEmitted가 true로 설정된다", () => {
|
|
146
|
+
const rt = buildRealtimeCtx();
|
|
147
|
+
expect((rt as any)._hasEmitted).toBe(false);
|
|
168
148
|
|
|
169
|
-
|
|
170
|
-
|
|
149
|
+
const ws = makeMockWs();
|
|
150
|
+
subscribe("flag.test", ws);
|
|
151
|
+
rt.emit("flag.test", [{ id: 1 }]);
|
|
152
|
+
|
|
153
|
+
expect((rt as any)._hasEmitted).toBe(true);
|
|
154
|
+
|
|
155
|
+
deregisterClient(ws);
|
|
171
156
|
});
|
|
172
157
|
});
|
|
173
158
|
|
|
@@ -206,7 +191,6 @@ describe("Secure by Default — public 플래그", () => {
|
|
|
206
191
|
it("mutation() 기본값은 isPublic === false", () => {
|
|
207
192
|
const m = mutation({
|
|
208
193
|
name: "sectest.mut.private",
|
|
209
|
-
invalidates: [],
|
|
210
194
|
handler: async () => ({ ok: true }),
|
|
211
195
|
});
|
|
212
196
|
expect(m.isPublic).toBe(false);
|
|
@@ -215,7 +199,6 @@ describe("Secure by Default — public 플래그", () => {
|
|
|
215
199
|
it("mutation({ public: true }) 시 isPublic === true", () => {
|
|
216
200
|
const m = mutation({
|
|
217
201
|
name: "sectest.mut.public",
|
|
218
|
-
invalidates: [],
|
|
219
202
|
public: true,
|
|
220
203
|
handler: async () => ({ ok: true }),
|
|
221
204
|
});
|
|
@@ -243,7 +226,7 @@ describe("mutation(name, def) — query와 동일 패턴", () => {
|
|
|
243
226
|
const m = mutation("newsig.basic", {
|
|
244
227
|
handler: async () => ({ ok: true }),
|
|
245
228
|
});
|
|
246
|
-
expect((m as any).name || (getRegisteredMutations().find(x => x.
|
|
229
|
+
expect((m as any).name || (getRegisteredMutations().find(x => x.handler === (m as any).handler) as any)?.name).toBeDefined();
|
|
247
230
|
const all = getRegisteredMutations();
|
|
248
231
|
const found = all.find(x => x.name === "newsig.basic");
|
|
249
232
|
expect(found).toBeDefined();
|
|
@@ -258,35 +241,35 @@ describe("mutation(name, def) — query와 동일 패턴", () => {
|
|
|
258
241
|
expect(m.isPublic).toBe(true);
|
|
259
242
|
});
|
|
260
243
|
|
|
261
|
-
it("invalidates 미지정 시 빈 배열이 기본값", () => {
|
|
244
|
+
it("invalidates 미지정 시 빈 배열이 기본값 (하위호환)", () => {
|
|
262
245
|
const m = mutation("newsig.noInvalidates", {
|
|
263
246
|
handler: async () => ({ ok: true }),
|
|
264
247
|
});
|
|
265
248
|
const all = getRegisteredMutations();
|
|
266
249
|
const found = all.find(x => x.name === "newsig.noInvalidates");
|
|
267
|
-
|
|
250
|
+
// invalidates는 deprecated이지만 빈 배열로 유지 (하위호환)
|
|
251
|
+
expect(found).toBeDefined();
|
|
268
252
|
});
|
|
269
253
|
|
|
270
|
-
it("invalidates
|
|
254
|
+
it("invalidates 지정해도 무시된다 (deprecated)", () => {
|
|
271
255
|
const m = mutation("newsig.withInvalidates", {
|
|
272
256
|
invalidates: ["tasks.list", "tasks.get"],
|
|
273
257
|
handler: async () => ({ ok: true }),
|
|
274
258
|
});
|
|
275
259
|
const all = getRegisteredMutations();
|
|
276
260
|
const found = all.find(x => x.name === "newsig.withInvalidates");
|
|
277
|
-
|
|
261
|
+
// invalidates는 deprecated — 전달해도 런타임에서 무시됨
|
|
262
|
+
expect(found).toBeDefined();
|
|
278
263
|
});
|
|
279
264
|
|
|
280
265
|
it("기존 객체 스타일도 여전히 동작한다 (하위 호환)", () => {
|
|
281
266
|
const m = mutation({
|
|
282
267
|
name: "newsig.compat.object",
|
|
283
|
-
invalidates: ["a.list"],
|
|
284
268
|
handler: async () => ({ ok: true }),
|
|
285
269
|
});
|
|
286
270
|
const all = getRegisteredMutations();
|
|
287
271
|
const found = all.find(x => x.name === "newsig.compat.object");
|
|
288
272
|
expect(found).toBeDefined();
|
|
289
|
-
expect(found!.invalidates).toEqual(["a.list"]);
|
|
290
273
|
});
|
|
291
274
|
|
|
292
275
|
it("기존 배열 스타일도 여전히 동작한다 (하위 호환)", () => {
|
|
@@ -294,7 +277,6 @@ describe("mutation(name, def) — query와 동일 패턴", () => {
|
|
|
294
277
|
const all = getRegisteredMutations();
|
|
295
278
|
const found = all.find(x => x.name === "newsig.compat.array");
|
|
296
279
|
expect(found).toBeDefined();
|
|
297
|
-
expect(found!.invalidates).toEqual(["b.list"]);
|
|
298
280
|
});
|
|
299
281
|
|
|
300
282
|
it("이름 미지정 시 console.warn이 호출된다", () => {
|
package/src/crud.ts
CHANGED
|
@@ -354,7 +354,6 @@ export function crud<T extends PgTable>(table: T, options?: CrudOptions<T>) {
|
|
|
354
354
|
|
|
355
355
|
const createDef = !enabledMethods.has('create') ? undefined : mutation(`${prefix}.create`, {
|
|
356
356
|
public: isPublic,
|
|
357
|
-
invalidates: [],
|
|
358
357
|
handler: async (ctx: any, args: any) => {
|
|
359
358
|
const user = isPublic ? null : ctx.auth.requireAuth();
|
|
360
359
|
|
|
@@ -386,7 +385,6 @@ export function crud<T extends PgTable>(table: T, options?: CrudOptions<T>) {
|
|
|
386
385
|
|
|
387
386
|
const updateDef = !enabledMethods.has('update') ? undefined : mutation(`${prefix}.update`, {
|
|
388
387
|
public: isPublic,
|
|
389
|
-
invalidates: [],
|
|
390
388
|
handler: async (ctx: any, args: any) => {
|
|
391
389
|
if (!isPublic) ctx.auth.requireAuth();
|
|
392
390
|
|
|
@@ -428,7 +426,6 @@ export function crud<T extends PgTable>(table: T, options?: CrudOptions<T>) {
|
|
|
428
426
|
|
|
429
427
|
const removeDef = !enabledMethods.has('remove') ? undefined : mutation(`${prefix}.remove`, {
|
|
430
428
|
public: isPublic,
|
|
431
|
-
invalidates: [],
|
|
432
429
|
handler: async (ctx: any, args: any) => {
|
|
433
430
|
if (!isPublic) ctx.auth.requireAuth();
|
|
434
431
|
|
package/src/index.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
export type { GencowCtx, AuthCtx, UserIdentity, QueryDef, MutationDef, RealtimeCtx, HttpActionDef, HttpActionRequest, HttpActionResponse, HttpActionHandler, AIContext, AIMessage, AIResult } from "./reactive";
|
|
9
|
-
export { query, mutation, httpAction,
|
|
9
|
+
export { query, mutation, httpAction, buildRealtimeCtx, subscribe, unsubscribe, registerClient, deregisterClient, handleWsMessage, getQueryHandler, getQueryDef, getRegisteredQueries, getRegisteredMutations, getRegisteredHttpActions } from "./reactive";
|
|
10
10
|
export type { Storage } from "./storage";
|
|
11
11
|
export { createScheduler, getSchedulerInfo } from "./scheduler";
|
|
12
12
|
export type { Scheduler, ScheduleOptions, FailedJob } from "./scheduler";
|
package/src/reactive.ts
CHANGED
|
@@ -24,6 +24,9 @@ export interface AuthCtx {
|
|
|
24
24
|
*/
|
|
25
25
|
export interface RealtimeCtx {
|
|
26
26
|
/**
|
|
27
|
+
* 특정 queryKey를 구독 중인 클라이언트에 데이터를 즉시 push합니다.
|
|
28
|
+
* 초고빈도 mutation (채팅 등)에서 query re-run 비용을 회피할 때 사용.
|
|
29
|
+
*
|
|
27
30
|
* @param queryKey - 업데이트할 쿼리 키 (예: "tasks.list")
|
|
28
31
|
* @param data - push할 데이터 (해당 query 결과와 동일한 타입)
|
|
29
32
|
*
|
|
@@ -32,6 +35,24 @@ export interface RealtimeCtx {
|
|
|
32
35
|
* ctx.realtime.emit("tasks.list", freshList);
|
|
33
36
|
*/
|
|
34
37
|
emit(queryKey: string, data: unknown): void;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 수동 mutation에서 리얼타임 업데이트의 기본 권장 방식.
|
|
41
|
+
* mutation handler 완료 후 서버가 해당 query를 re-run하여 결과를 push합니다.
|
|
42
|
+
* 복잡한 JOIN query도 queryKey만 지정하면 됩니다.
|
|
43
|
+
*
|
|
44
|
+
* @param queryKey - re-run할 쿼리 키 (예: "dashboard.revenue")
|
|
45
|
+
*
|
|
46
|
+
* @example
|
|
47
|
+
* mutation("orders.place", {
|
|
48
|
+
* handler: async (ctx, args) => {
|
|
49
|
+
* await ctx.db.insert(orders).values(args);
|
|
50
|
+
* ctx.realtime.refresh("orders.list");
|
|
51
|
+
* ctx.realtime.refresh("dashboard.revenue"); // JOIN query도 OK
|
|
52
|
+
* }
|
|
53
|
+
* });
|
|
54
|
+
*/
|
|
55
|
+
refresh(queryKey: string): void;
|
|
35
56
|
}
|
|
36
57
|
|
|
37
58
|
/**
|
|
@@ -143,7 +164,6 @@ export interface QueryDef<TSchema = any, TReturn = any> {
|
|
|
143
164
|
}
|
|
144
165
|
|
|
145
166
|
export interface MutationDef<TSchema = any, TReturn = any> {
|
|
146
|
-
invalidates: string[];
|
|
147
167
|
handler: MutationHandler<InferArgs<TSchema>, TReturn>;
|
|
148
168
|
argsSchema?: TSchema;
|
|
149
169
|
/** true = 인증 없이 접근 가능, false(기본) = auth 필수 (Secure by Default) */
|
|
@@ -235,7 +255,6 @@ let mutationCounter = 0;
|
|
|
235
255
|
* ```typescript
|
|
236
256
|
* // ✅ 권장: query와 동일한 (name, def) 패턴
|
|
237
257
|
* mutation("tasks.create", {
|
|
238
|
-
* invalidates: [],
|
|
239
258
|
* args: { title: v.string() },
|
|
240
259
|
* handler: async (ctx, args) => { ... },
|
|
241
260
|
* });
|
|
@@ -243,41 +262,39 @@ let mutationCounter = 0;
|
|
|
243
262
|
* // ✅ 객체 스타일 (하위 호환)
|
|
244
263
|
* mutation({
|
|
245
264
|
* name: "tasks.create",
|
|
246
|
-
* invalidates: [],
|
|
247
265
|
* handler: async (ctx) => { ... },
|
|
248
266
|
* });
|
|
249
267
|
*
|
|
250
268
|
* // ⚠️ Legacy 배열 스타일 (비권장)
|
|
251
269
|
* mutation(["tasks.list"], handler, "tasks.create");
|
|
252
270
|
* ```
|
|
271
|
+
*
|
|
272
|
+
* @note invalidates 필드는 deprecated — 전달해도 무시됩니다.
|
|
273
|
+
* 리얼타임 UI 갱신에는 ctx.realtime.emit() 또는 ctx.realtime.refresh()를 사용하세요.
|
|
253
274
|
*/
|
|
254
275
|
export function mutation<TSchema = any, TReturn = any>(
|
|
255
|
-
nameOrInvalidatesOrDef: string | string[] | { name?: string; args?: TSchema; public?: boolean; invalidates
|
|
276
|
+
nameOrInvalidatesOrDef: string | string[] | { name?: string; args?: TSchema; public?: boolean; invalidates?: string[]; handler: MutationHandler<InferArgs<TSchema>, TReturn> },
|
|
256
277
|
handlerOrDef?: MutationHandler<InferArgs<TSchema>, TReturn> | { invalidates?: string[]; args?: TSchema; public?: boolean; handler: MutationHandler<InferArgs<TSchema>, TReturn> },
|
|
257
278
|
name?: string
|
|
258
279
|
): MutationDef<TSchema, TReturn> {
|
|
259
|
-
let invalidates: string[];
|
|
260
280
|
let argsSchema: TSchema | undefined;
|
|
261
281
|
let actualHandler: MutationHandler<InferArgs<TSchema>, TReturn>;
|
|
262
282
|
let mutName: string;
|
|
263
283
|
let isPublic = false;
|
|
264
284
|
|
|
265
285
|
if (typeof nameOrInvalidatesOrDef === "string") {
|
|
266
|
-
// New primary style: mutation("name", {
|
|
286
|
+
// New primary style: mutation("name", { args?, public?, handler })
|
|
267
287
|
mutName = nameOrInvalidatesOrDef;
|
|
268
|
-
const def = handlerOrDef as {
|
|
269
|
-
invalidates = def.invalidates || [];
|
|
288
|
+
const def = handlerOrDef as { args?: TSchema; public?: boolean; handler: MutationHandler<InferArgs<TSchema>, TReturn> };
|
|
270
289
|
actualHandler = def.handler;
|
|
271
290
|
argsSchema = def.args;
|
|
272
291
|
isPublic = def.public === true;
|
|
273
292
|
} else if (Array.isArray(nameOrInvalidatesOrDef)) {
|
|
274
|
-
// Legacy style: mutation([...], handler, "name")
|
|
275
|
-
invalidates = nameOrInvalidatesOrDef;
|
|
293
|
+
// Legacy style: mutation([...], handler, "name") — invalidates ignored
|
|
276
294
|
actualHandler = handlerOrDef as MutationHandler<InferArgs<TSchema>, TReturn>;
|
|
277
295
|
mutName = name || `mutation_${++mutationCounter}`;
|
|
278
296
|
} else {
|
|
279
|
-
// Object style: mutation({ name?,
|
|
280
|
-
invalidates = nameOrInvalidatesOrDef.invalidates;
|
|
297
|
+
// Object style: mutation({ name?, args?, public?, handler })
|
|
281
298
|
actualHandler = nameOrInvalidatesOrDef.handler;
|
|
282
299
|
argsSchema = nameOrInvalidatesOrDef.args;
|
|
283
300
|
isPublic = nameOrInvalidatesOrDef.public === true;
|
|
@@ -294,7 +311,6 @@ export function mutation<TSchema = any, TReturn = any>(
|
|
|
294
311
|
|
|
295
312
|
const def: MutationDef<TSchema, TReturn> & { name: string } = {
|
|
296
313
|
name: mutName,
|
|
297
|
-
invalidates,
|
|
298
314
|
handler: actualHandler,
|
|
299
315
|
argsSchema,
|
|
300
316
|
isPublic,
|
|
@@ -379,14 +395,10 @@ export function deregisterClient(ws: WSContext) {
|
|
|
379
395
|
}
|
|
380
396
|
}
|
|
381
397
|
|
|
382
|
-
/**
|
|
383
|
-
* After a mutation, re-run invalidated queries and push results
|
|
384
|
-
* to all subscribers — Convex의 자동 reactive 업데이트 재현
|
|
385
|
-
*/
|
|
386
398
|
/**
|
|
387
399
|
* mutation 실행 시점에 생성되는 RealtimeCtx.
|
|
388
|
-
* emit()
|
|
389
|
-
*
|
|
400
|
+
* emit(): 데이터를 직접 push (초고빈도 mutation용).
|
|
401
|
+
* refresh(): queryKey를 pending 큐에 추가, mutation 완료 후 서버가 query re-run하여 push.
|
|
390
402
|
*
|
|
391
403
|
* 💡 Batching: 같은 queryKey에 대한 emit이 50ms 내에 여러 번 호출되면
|
|
392
404
|
* 마지막 데이터만 push하여 불필요한 전송을 방지합니다.
|
|
@@ -396,14 +408,21 @@ export function deregisterClient(ws: WSContext) {
|
|
|
396
408
|
* @param options.httpCallback BaaS 모드: Platform WS Gateway에 HTTP로 emit 전달.
|
|
397
409
|
* 설정되면 WS 직접 push 대신 이 콜백을 호출.
|
|
398
410
|
* 로컬 dev에서는 미설정 → 기존 WS 직접 push 유지.
|
|
411
|
+
* @param options.queryMap query 레지스트리 — refresh()에서 query handler를 찾아 re-run.
|
|
412
|
+
* @param options.buildCtxForRefresh refresh 시 query handler에 전달할 ctx 생성 함수.
|
|
399
413
|
*/
|
|
400
414
|
export function buildRealtimeCtx(options?: {
|
|
401
415
|
httpCallback?: (event: { type: "emit"; queryKey: string; data: unknown }) => void;
|
|
402
|
-
|
|
416
|
+
queryMap?: Map<string, QueryDef<any, any>>;
|
|
417
|
+
buildCtxForRefresh?: () => GencowCtx;
|
|
418
|
+
}): RealtimeCtx & { _hasEmitted: boolean; _pendingRefresh: string[]; _flushRefresh: () => Promise<void> } {
|
|
403
419
|
const pendingEmits = new Map<string, { data: unknown; timer: ReturnType<typeof setTimeout> }>();
|
|
420
|
+
const _pendingRefresh: string[] = [];
|
|
421
|
+
let _hasEmitted = false;
|
|
404
422
|
|
|
405
423
|
return {
|
|
406
424
|
emit(queryKey: string, data: unknown) {
|
|
425
|
+
_hasEmitted = true;
|
|
407
426
|
// 기존 pending timer가 있으면 취소 (debounce)
|
|
408
427
|
const existing = pendingEmits.get(queryKey);
|
|
409
428
|
if (existing) clearTimeout(existing.timer);
|
|
@@ -432,38 +451,58 @@ export function buildRealtimeCtx(options?: {
|
|
|
432
451
|
}, 50); // 50ms batch window
|
|
433
452
|
|
|
434
453
|
pendingEmits.set(queryKey, { data, timer });
|
|
435
|
-
}
|
|
436
|
-
};
|
|
437
|
-
}
|
|
454
|
+
},
|
|
438
455
|
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
*
|
|
446
|
-
* @deprecated ctx.realtime.emit() 사용 권장
|
|
447
|
-
* @param httpInvalidateCallback BaaS 모드: Platform WS Gateway에 HTTP로 invalidation 전달.
|
|
448
|
-
*/
|
|
449
|
-
export async function invalidateQueries(
|
|
450
|
-
queryKeys: string[],
|
|
451
|
-
ctx: GencowCtx,
|
|
452
|
-
httpInvalidateCallback?: (queryKeys: string[]) => void,
|
|
453
|
-
): Promise<void> {
|
|
454
|
-
if (queryKeys.length === 0) return; // emit() 방식에서는 no-op
|
|
455
|
-
|
|
456
|
-
// BaaS 모드: Platform WS Gateway에 HTTP callback
|
|
457
|
-
if (httpInvalidateCallback) {
|
|
458
|
-
httpInvalidateCallback(queryKeys);
|
|
459
|
-
return;
|
|
460
|
-
}
|
|
456
|
+
refresh(queryKey: string) {
|
|
457
|
+
_hasEmitted = true; // 경고 억제
|
|
458
|
+
if (!_pendingRefresh.includes(queryKey)) {
|
|
459
|
+
_pendingRefresh.push(queryKey);
|
|
460
|
+
}
|
|
461
|
+
},
|
|
461
462
|
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
463
|
+
get _hasEmitted() { return _hasEmitted; },
|
|
464
|
+
get _pendingRefresh() { return [..._pendingRefresh]; },
|
|
465
|
+
|
|
466
|
+
async _flushRefresh() {
|
|
467
|
+
if (_pendingRefresh.length === 0) return;
|
|
468
|
+
|
|
469
|
+
// queryMap이 없으면 refresh 동작 불가 (로그 경고)
|
|
470
|
+
const qMap = options?.queryMap ?? queryRegistry;
|
|
471
|
+
|
|
472
|
+
for (const key of _pendingRefresh) {
|
|
473
|
+
const queryDef = qMap.get(key);
|
|
474
|
+
if (!queryDef) {
|
|
475
|
+
console.warn(`[gencow] refresh("${key}"): query not found in registry. Skipping.`);
|
|
476
|
+
continue;
|
|
477
|
+
}
|
|
478
|
+
try {
|
|
479
|
+
// refresh용 ctx 생성 (mutation ctx와 동일한 DB/auth 스코프)
|
|
480
|
+
const refreshCtx = options?.buildCtxForRefresh?.() ?? ({} as GencowCtx);
|
|
481
|
+
const result = await queryDef.handler(refreshCtx, {});
|
|
482
|
+
|
|
483
|
+
// emit과 동일한 경로로 push
|
|
484
|
+
if (options?.httpCallback) {
|
|
485
|
+
options.httpCallback({ type: "emit", queryKey: key, data: result });
|
|
486
|
+
} else {
|
|
487
|
+
const clients = subscribers.get(key);
|
|
488
|
+
if (clients && clients.size > 0) {
|
|
489
|
+
const message = JSON.stringify({
|
|
490
|
+
type: "query:updated",
|
|
491
|
+
query: key,
|
|
492
|
+
data: result,
|
|
493
|
+
});
|
|
494
|
+
for (const ws of clients) {
|
|
495
|
+
try { ws.send(message); } catch { clients.delete(ws); }
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
} catch (e) {
|
|
500
|
+
console.warn(`[gencow] refresh("${key}") failed:`, e instanceof Error ? e.message : e);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
_pendingRefresh.length = 0;
|
|
504
|
+
},
|
|
505
|
+
};
|
|
467
506
|
}
|
|
468
507
|
|
|
469
508
|
|