@gencow/core 0.1.16 → 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
@@ -52,6 +52,13 @@ type CrudOptions<T extends PgTable> = {
52
52
  realtime?: boolean;
53
53
  /** 키 접두사 오버라이드 (기본: 테이블명) */
54
54
  prefix?: string;
55
+ /**
56
+ * 생성할 메서드 목록 (기본: 전체 5개)
57
+ * 지정하면 해당 메서드만 레지스트리에 등록되고 반환됨.
58
+ * api.ts codegen에도 지정된 메서드만 포함됨.
59
+ * @example crud(table, { methods: ['list', 'get', 'create'] })
60
+ */
61
+ methods?: ('list' | 'get' | 'create' | 'update' | 'remove')[];
55
62
  };
56
63
 
57
64
  // ─── Helpers ────────────────────────────────────────────
@@ -271,9 +278,13 @@ export function crud<T extends PgTable>(table: T, options?: CrudOptions<T>) {
271
278
  return { data, total: Number(countResult[0]?.count ?? 0) };
272
279
  }
273
280
 
281
+ // ── methods 필터링: 지정된 메서드만 레지스트리 등록 ──
282
+ // methods 옵션 미지정 시 전체 5개 등록 (하위호환)
283
+ const enabledMethods = new Set(options?.methods ?? ['list', 'get', 'create', 'update', 'remove']);
284
+
274
285
  // ── list ──────────────────────────────────────
275
286
 
276
- const listDef = query(`${prefix}.list`, {
287
+ const listDef = !enabledMethods.has('list') ? undefined : query(`${prefix}.list`, {
277
288
  public: isPublic,
278
289
  args: {
279
290
  page: v.optional(v.number()),
@@ -302,8 +313,6 @@ export function crud<T extends PgTable>(table: T, options?: CrudOptions<T>) {
302
313
  }
303
314
 
304
315
  // SELECT + COUNT 순차 실행 — 소규모 커넥션 풀 환경에서 동시 점유 방지
305
- // Note: data←→count 사이 INSERT/DELETE 시 total 불일치 가능 (BaaS 단일사용자/저부하에서 무시 가능)
306
- // TODO(P2): 대규모 시 db.transaction() 래핑 검토
307
316
  const results = await ctx.db.select()
308
317
  .from(anyTable)
309
318
  .where(whereClause)
@@ -323,7 +332,7 @@ export function crud<T extends PgTable>(table: T, options?: CrudOptions<T>) {
323
332
 
324
333
  // ── get ───────────────────────────────────────
325
334
 
326
- const getDef = query(`${prefix}.get`, {
335
+ const getDef = !enabledMethods.has('get') ? undefined : query(`${prefix}.get`, {
327
336
  public: isPublic,
328
337
  args: { id: idValidator },
329
338
  handler: async (ctx: any, args: any) => {
@@ -343,9 +352,8 @@ export function crud<T extends PgTable>(table: T, options?: CrudOptions<T>) {
343
352
 
344
353
  // ── create ────────────────────────────────────
345
354
 
346
- const createDef = mutation(`${prefix}.create`, {
355
+ const createDef = !enabledMethods.has('create') ? undefined : mutation(`${prefix}.create`, {
347
356
  public: isPublic,
348
- invalidates: [],
349
357
  handler: async (ctx: any, args: any) => {
350
358
  const user = isPublic ? null : ctx.auth.requireAuth();
351
359
 
@@ -364,7 +372,7 @@ export function crud<T extends PgTable>(table: T, options?: CrudOptions<T>) {
364
372
  const [result] = await ctx.db.insert(anyTable).values(insertData).returning();
365
373
 
366
374
  // Realtime push — { data, total } 형태로 emit
367
- if (useRealtime) {
375
+ if (useRealtime && enabledMethods.has('list')) {
368
376
  const listResult = await fetchListWithTotal(ctx.db);
369
377
  ctx.realtime.emit(`${prefix}.list`, listResult);
370
378
  }
@@ -375,9 +383,8 @@ export function crud<T extends PgTable>(table: T, options?: CrudOptions<T>) {
375
383
 
376
384
  // ── update ────────────────────────────────────
377
385
 
378
- const updateDef = mutation(`${prefix}.update`, {
386
+ const updateDef = !enabledMethods.has('update') ? undefined : mutation(`${prefix}.update`, {
379
387
  public: isPublic,
380
- invalidates: [],
381
388
  handler: async (ctx: any, args: any) => {
382
389
  if (!isPublic) ctx.auth.requireAuth();
383
390
 
@@ -402,9 +409,13 @@ export function crud<T extends PgTable>(table: T, options?: CrudOptions<T>) {
402
409
 
403
410
  // Realtime push (list + get 양쪽) — { data, total } 형태
404
411
  if (useRealtime) {
405
- const listResult = await fetchListWithTotal(ctx.db);
406
- ctx.realtime.emit(`${prefix}.list`, listResult);
407
- ctx.realtime.emit(`${prefix}.get`, result);
412
+ if (enabledMethods.has('list')) {
413
+ const listResult = await fetchListWithTotal(ctx.db);
414
+ ctx.realtime.emit(`${prefix}.list`, listResult);
415
+ }
416
+ if (enabledMethods.has('get')) {
417
+ ctx.realtime.emit(`${prefix}.get`, result);
418
+ }
408
419
  }
409
420
 
410
421
  return result;
@@ -413,9 +424,8 @@ export function crud<T extends PgTable>(table: T, options?: CrudOptions<T>) {
413
424
 
414
425
  // ── remove ────────────────────────────────────
415
426
 
416
- const removeDef = mutation(`${prefix}.remove`, {
427
+ const removeDef = !enabledMethods.has('remove') ? undefined : mutation(`${prefix}.remove`, {
417
428
  public: isPublic,
418
- invalidates: [],
419
429
  handler: async (ctx: any, args: any) => {
420
430
  if (!isPublic) ctx.auth.requireAuth();
421
431
 
@@ -429,7 +439,7 @@ export function crud<T extends PgTable>(table: T, options?: CrudOptions<T>) {
429
439
  }
430
440
 
431
441
  // Realtime push — { data, total } 형태
432
- if (useRealtime) {
442
+ if (useRealtime && enabledMethods.has('list')) {
433
443
  const listResult = await fetchListWithTotal(ctx.db);
434
444
  ctx.realtime.emit(`${prefix}.list`, listResult);
435
445
  }
@@ -438,6 +448,9 @@ export function crud<T extends PgTable>(table: T, options?: CrudOptions<T>) {
438
448
  }
439
449
  });
440
450
 
451
+ // 반환 객체는 항상 5개 키를 가지지만, 비활성 메서드는 undefined.
452
+ // 사용자가 destructure 시 undefined를 받으면 export하지 않으므로
453
+ // codegen의 레지스트리에 등록되지 않아 api.ts 불일치가 해소됨.
441
454
  return {
442
455
  list: listDef,
443
456
  get: getDef,
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