@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.
@@ -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 invalidateQueries() simplification.
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, invalidateQueries, subscribe, deregisterClient, registerClient } from "../reactive";
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
- // ─── invalidateQueries (simplified) ─────────────────────────────────────────
99
+ // ─── refresh() API ──────────────────────────────────────────────────────────
100
100
 
101
- describe("invalidateQueries() — simplified broadcast-only", () => {
102
- it(" 배열이면 아무것도 전송하지 않는다 (emit() 방식 no-op)", async () => {
103
- const ws = makeMockWs();
104
- registerClient(ws);
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(ws._sent).toHaveLength(0);
110
- deregisterClient(ws);
106
+ expect((rt as any)._pendingRefresh).toContain("tasks.list");
111
107
  });
112
108
 
113
- it("queryKeys가 있으면 connectedClients 전체에 invalidate broadcast를 보낸다", async () => {
114
- const ws = makeMockWs();
115
- registerClient(ws);
116
-
117
- const mockCtx = {} as GencowCtx;
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
- deregisterClient(ws);
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("서버에서 쿼리를 재실행하지 않는다 (query:updated 미전송)", async () => {
129
- const ws = makeMockWs();
130
- registerClient(ws);
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
- deregisterClient(ws);
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
- // ─── buildRealtimeCtx + invalidateQueries 공존 시나리오 ─────────────────────
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
- // 2. invalidateQueries() — ALL connectedClients에 invalidate broadcast
163
- const mockCtx = {} as GencowCtx;
164
- await invalidateQueries(["items.list"], mockCtx);
142
+ deregisterClient(wsSubscribed);
143
+ });
165
144
 
166
- expect(wsConnected._sent).toHaveLength(1);
167
- expect(JSON.parse(wsConnected._sent[0]).type).toBe("invalidate");
145
+ it("emit() 호출 후 _hasEmitted가 true로 설정된다", () => {
146
+ const rt = buildRealtimeCtx();
147
+ expect((rt as any)._hasEmitted).toBe(false);
168
148
 
169
- deregisterClient(wsSubscribed);
170
- deregisterClient(wsConnected);
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.invalidates.length === 0 && x.handler === (m as any).handler) as any)?.name).toBeDefined();
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
- expect(found!.invalidates).toEqual([]);
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
- expect(found!.invalidates).toEqual(["tasks.list", "tasks.get"]);
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, invalidateQueries, buildRealtimeCtx, subscribe, unsubscribe, registerClient, deregisterClient, handleWsMessage, getQueryHandler, getQueryDef, getRegisteredQueries, getRegisteredMutations, getRegisteredHttpActions } from "./reactive";
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: string[]; handler: MutationHandler<InferArgs<TSchema>, TReturn> },
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", { invalidates?, args?, public?, handler })
286
+ // New primary style: mutation("name", { args?, public?, handler })
267
287
  mutName = nameOrInvalidatesOrDef;
268
- const def = handlerOrDef as { invalidates?: string[]; args?: TSchema; public?: boolean; handler: MutationHandler<InferArgs<TSchema>, TReturn> };
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?, invalidates, args?, public?, handler })
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() 호출 해당 queryKey를 구독 중인 WebSocket 클라이언트들에게
389
- * data즉시 push합니다.
400
+ * emit(): 데이터를 직접 push (초고빈도 mutation용).
401
+ * refresh(): queryKeypending 큐에 추가, 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
- }): RealtimeCtx {
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
- * mutation이 끝난 후 호출되는 legacy fallback.
441
- * `ctx.realtime.emit()`을 사용하는 새 mutation에서는 빈 배열([]) 전달하면 됩니다.
442
- *
443
- * invalidate 신호만 broadcast하여 클라이언트가 re-fetch 여부를 결정하게 합니다.
444
- * (서버에서 쿼리를 재실행하지 않으므로 DB 부하 없음)
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
- // 로컬 dev: WS 직접 broadcast (기존 동작)
463
- const invalidateMsg = JSON.stringify({ type: "invalidate", queries: queryKeys });
464
- for (const ws of connectedClients) {
465
- try { ws.send(invalidateMsg); } catch { connectedClients.delete(ws); }
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