@gencow/core 0.1.17 → 0.1.19

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/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
 
package/src/storage.ts CHANGED
@@ -52,8 +52,9 @@ export interface Storage {
52
52
  const metaStore = new Map<string, StorageFile>();
53
53
 
54
54
  /**
55
- * files 테이블 자동 생성 (이미 존재하면 무시)
55
+ * _system_files 테이블 자동 생성 (이미 존재하면 무시)
56
56
  * admin.ts의 CREATE_FILES_TABLE_SQL과 동일한 스키마 사용
57
+ * ⚠️ 테이블명 '_system_files' — 사용자 테이블과 네이밍 충돌 방지 (2026-04-10)
57
58
  */
58
59
  let filesTableEnsured = false;
59
60
 
@@ -67,9 +68,9 @@ async function ensureFilesTable(
67
68
  IF NOT EXISTS (
68
69
  SELECT 1 FROM information_schema.tables
69
70
  WHERE table_schema = current_schema()
70
- AND table_name = 'files'
71
+ AND table_name = '_system_files'
71
72
  ) THEN
72
- CREATE TABLE files (
73
+ CREATE TABLE _system_files (
73
74
  id SERIAL PRIMARY KEY,
74
75
  storage_id TEXT NOT NULL,
75
76
  name TEXT NOT NULL,
@@ -95,7 +96,7 @@ async function checkStorageQuota(
95
96
  if (quota <= 0) return; // 무제한
96
97
 
97
98
  const rows = await rawSql(
98
- `SELECT COALESCE(SUM(CAST(size AS BIGINT)), 0) AS total FROM files`,
99
+ `SELECT COALESCE(SUM(CAST(size AS BIGINT)), 0) AS total FROM _system_files`,
99
100
  );
100
101
  const currentUsage = Number((rows[0] as Record<string, string>)?.total || "0");
101
102
  const projectedUsage = currentUsage + newFileSize;
@@ -124,7 +125,7 @@ async function recordFileToDb(
124
125
  ): Promise<void> {
125
126
  await ensureFilesTable(rawSql);
126
127
  await rawSql(
127
- `INSERT INTO files (storage_id, name, size, type, uploaded_by, created_at)
128
+ `INSERT INTO _system_files (storage_id, name, size, type, uploaded_by, created_at)
128
129
  VALUES ($1, $2, $3, $4, $5, NOW())`,
129
130
  [storageId, name, String(size), type, uploadedBy],
130
131
  );
@@ -258,7 +259,7 @@ export function createStorage(
258
259
  if (rawSql) {
259
260
  try {
260
261
  await rawSql(
261
- `DELETE FROM files WHERE storage_id = $1`,
262
+ `DELETE FROM _system_files WHERE storage_id = $1`,
262
263
  [storageId],
263
264
  );
264
265
  } catch { /* 삭제 실패 무시 — 파일은 이미 제거됨 */ }
@@ -286,7 +287,7 @@ export function storageRoutes(
286
287
  if (!meta && rawSql) {
287
288
  try {
288
289
  const rows = await rawSql(
289
- `SELECT storage_id, name, size, type FROM files WHERE storage_id = $1 LIMIT 1`,
290
+ `SELECT storage_id, name, size, type FROM _system_files WHERE storage_id = $1 LIMIT 1`,
290
291
  [id]
291
292
  );
292
293
  if (rows.length > 0) {
package/src/v.ts CHANGED
@@ -135,11 +135,15 @@ export function parseArgs(schema: any, args: any): any {
135
135
 
136
136
  // Shorthand object — e.g. { id: v.number(), title: v.optional(v.string()) }
137
137
  if (typeof schema === "object" && schema !== null) {
138
+ // Empty schema {} → passthrough all args (e.g. FormData with file field)
139
+ const schemaKeys = Object.keys(schema);
140
+ if (schemaKeys.length === 0) return args;
141
+
138
142
  if (typeof args !== "object" || args === null) {
139
143
  throw new GencowValidationError("Expected an object for arguments");
140
144
  }
141
145
  const result: any = {};
142
- for (const key in schema) {
146
+ for (const key of schemaKeys) {
143
147
  const validator = schema[key];
144
148
  if (validator && typeof validator.parse === "function") {
145
149
  try {