@gencow/core 0.1.24 → 0.1.25

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.
Files changed (73) hide show
  1. package/dist/crud.d.ts +2 -2
  2. package/dist/crud.js +225 -208
  3. package/dist/index.d.ts +5 -5
  4. package/dist/index.js +2 -2
  5. package/dist/reactive.js +10 -3
  6. package/dist/retry.js +1 -1
  7. package/dist/rls-db.d.ts +2 -2
  8. package/dist/rls-db.js +1 -5
  9. package/dist/scheduler.d.ts +2 -0
  10. package/dist/scheduler.js +16 -6
  11. package/dist/server.d.ts +0 -1
  12. package/dist/server.js +0 -1
  13. package/dist/storage.js +29 -22
  14. package/dist/v.d.ts +2 -2
  15. package/dist/workflow.js +4 -11
  16. package/dist/workflows-api.js +5 -12
  17. package/package.json +46 -42
  18. package/src/__tests__/auth.test.ts +90 -86
  19. package/src/__tests__/crons.test.ts +69 -67
  20. package/src/__tests__/crud-codegen-integration.test.ts +164 -170
  21. package/src/__tests__/crud-owner-rls.test.ts +308 -301
  22. package/src/__tests__/crud.test.ts +694 -711
  23. package/src/__tests__/dist-exports.test.ts +120 -120
  24. package/src/__tests__/fixtures/basic/auth.ts +16 -16
  25. package/src/__tests__/fixtures/basic/drizzle.config.ts +1 -4
  26. package/src/__tests__/fixtures/basic/index.ts +1 -1
  27. package/src/__tests__/fixtures/basic/schema.ts +1 -1
  28. package/src/__tests__/fixtures/basic/tasks.ts +4 -4
  29. package/src/__tests__/fixtures/common/auth-schema.ts +38 -34
  30. package/src/__tests__/helpers/basic-rls-fixture.ts +80 -78
  31. package/src/__tests__/helpers/pglite-migrations.ts +2 -5
  32. package/src/__tests__/helpers/pglite-rls-session.ts +13 -16
  33. package/src/__tests__/helpers/seed-like-fill.ts +50 -44
  34. package/src/__tests__/helpers/test-gencow-ctx-rls.ts +4 -7
  35. package/src/__tests__/httpaction.test.ts +91 -91
  36. package/src/__tests__/image-optimization.test.ts +570 -574
  37. package/src/__tests__/load.test.ts +321 -308
  38. package/src/__tests__/network-sim.test.ts +238 -215
  39. package/src/__tests__/reactive.test.ts +380 -358
  40. package/src/__tests__/retry.test.ts +99 -84
  41. package/src/__tests__/rls-crud-basic.test.ts +172 -245
  42. package/src/__tests__/rls-crud-no-owner-rls-pglite.test.ts +81 -81
  43. package/src/__tests__/rls-custom-mutation-handlers.test.ts +47 -94
  44. package/src/__tests__/rls-custom-query-handlers.test.ts +92 -92
  45. package/src/__tests__/rls-db-leased-connection.test.ts +2 -6
  46. package/src/__tests__/rls-session-and-policies.test.ts +181 -199
  47. package/src/__tests__/scheduler-durable-v2.test.ts +199 -181
  48. package/src/__tests__/scheduler-durable.test.ts +117 -117
  49. package/src/__tests__/scheduler-exec.test.ts +258 -246
  50. package/src/__tests__/scheduler.test.ts +129 -111
  51. package/src/__tests__/storage.test.ts +282 -269
  52. package/src/__tests__/tsconfig.json +6 -6
  53. package/src/__tests__/validator.test.ts +236 -232
  54. package/src/__tests__/workflow.test.ts +309 -286
  55. package/src/__tests__/ws-integration.test.ts +223 -218
  56. package/src/__tests__/ws-scale.test.ts +168 -159
  57. package/src/auth-config.ts +18 -18
  58. package/src/auth.ts +106 -106
  59. package/src/crons.ts +77 -77
  60. package/src/crud.ts +523 -479
  61. package/src/index.ts +69 -5
  62. package/src/reactive.ts +357 -331
  63. package/src/retry.ts +51 -54
  64. package/src/rls-db.ts +195 -205
  65. package/src/rls.ts +33 -36
  66. package/src/scheduler.ts +237 -211
  67. package/src/server.ts +0 -1
  68. package/src/storage.ts +632 -593
  69. package/src/v.ts +119 -114
  70. package/src/workflow-types.ts +67 -70
  71. package/src/workflow.ts +99 -116
  72. package/src/workflows-api.ts +231 -241
  73. package/src/db.ts +0 -18
package/src/reactive.ts CHANGED
@@ -6,16 +6,16 @@ import { type Validator, type InferArgs } from "./v.js";
6
6
  // ─── GencowCtx — 사용자 함수에 주입되는 컨텍스트 ──────────
7
7
 
8
8
  export interface UserIdentity {
9
- id: string;
10
- email: string;
11
- name?: string;
9
+ id: string;
10
+ email: string;
11
+ name?: string;
12
12
  }
13
13
 
14
14
  export interface AuthCtx {
15
- /** 현재 유저 반환 (비로그인 시 null) — Convex의 ctx.auth.getUserIdentity() */
16
- getUserIdentity(): UserIdentity | null;
17
- /** 현재 유저 반환 (비로그인 시 401 throw) */
18
- requireAuth(): UserIdentity;
15
+ /** 현재 유저 반환 (비로그인 시 null) — Convex의 ctx.auth.getUserIdentity() */
16
+ getUserIdentity(): UserIdentity | null;
17
+ /** 현재 유저 반환 (비로그인 시 401 throw) */
18
+ requireAuth(): UserIdentity;
19
19
  }
20
20
 
21
21
  /**
@@ -23,36 +23,36 @@ export interface AuthCtx {
23
23
  * 클라이언트는 이 데이터를 받아 re-fetch 없이 state를 업데이트합니다.
24
24
  */
25
25
  export interface RealtimeCtx {
26
- /**
27
- * 특정 queryKey를 구독 중인 클라이언트에 데이터를 즉시 push합니다.
28
- * 초고빈도 mutation (채팅 등)에서 query re-run 비용을 회피할 때 사용.
29
- *
30
- * @param queryKey - 업데이트할 쿼리 키 (예: "tasks.list")
31
- * @param data - push할 데이터 (해당 query 결과와 동일한 타입)
32
- *
33
- * @example
34
- * const freshList = await ctx.db.select().from(tasks);
35
- * ctx.realtime.emit("tasks.list", freshList);
36
- */
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;
26
+ /**
27
+ * 특정 queryKey를 구독 중인 클라이언트에 데이터를 즉시 push합니다.
28
+ * 초고빈도 mutation (채팅 등)에서 query re-run 비용을 회피할 때 사용.
29
+ *
30
+ * @param queryKey - 업데이트할 쿼리 키 (예: "tasks.list")
31
+ * @param data - push할 데이터 (해당 query 결과와 동일한 타입)
32
+ *
33
+ * @example
34
+ * const freshList = await ctx.db.select().from(tasks);
35
+ * ctx.realtime.emit("tasks.list", freshList);
36
+ */
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;
56
56
  }
57
57
 
58
58
  /**
@@ -63,59 +63,57 @@ export interface RealtimeCtx {
63
63
  // ─── AI Context ─────────────────────────────────────────
64
64
 
65
65
  export interface AIMessage {
66
- role: "user" | "system" | "assistant";
67
- content: string;
66
+ role: "user" | "system" | "assistant";
67
+ content: string;
68
68
  }
69
69
 
70
70
  export interface AIResult {
71
- text: string;
72
- usage: { promptTokens: number; completionTokens: number; totalTokens: number };
73
- creditsCharged: number;
74
- model: string;
71
+ text: string;
72
+ usage: { promptTokens: number; completionTokens: number; totalTokens: number };
73
+ creditsCharged: number;
74
+ model: string;
75
75
  }
76
76
 
77
77
  export interface AIContext {
78
- /** AI 텍스트 생성 */
79
- chat: (opts: {
80
- model?: string;
81
- messages: AIMessage[];
82
- /** System prompt — shorthand for adding a system message */
83
- system?: string;
84
- temperature?: number;
85
- maxTokens?: number;
86
- /** Response format — e.g. { type: "json_object" } for JSON mode */
87
- responseFormat?: { type: string };
88
- }) => Promise<AIResult>;
89
- /** 텍스트 임베딩 (단일) */
90
- embed: (text: string) => Promise<number[]>;
91
- /** 배치 임베딩 */
92
- embedMany: (texts: string[]) => Promise<number[][]>;
78
+ /** AI 텍스트 생성 */
79
+ chat: (opts: {
80
+ model?: string;
81
+ messages: AIMessage[];
82
+ /** System prompt — shorthand for adding a system message */
83
+ system?: string;
84
+ temperature?: number;
85
+ maxTokens?: number;
86
+ /** Response format — e.g. { type: "json_object" } for JSON mode */
87
+ responseFormat?: { type: string };
88
+ }) => Promise<AIResult>;
89
+ /** 텍스트 임베딩 (단일) */
90
+ embed: (text: string) => Promise<number[]>;
91
+ /** 배치 임베딩 */
92
+ embedMany: (texts: string[]) => Promise<number[][]>;
93
93
  }
94
94
 
95
95
  export interface GencowCtx {
96
- /** Drizzle DB 인스턴스 (scoped) — 스키마 filter 자동 적용, execute 차단 */
97
- db: any; // typed per-app via generic
98
- /** Raw Drizzle DB — 필터 없음, execute 허용. ⚠️ 이름이 곧 경고. */
99
- unsafeDb: any;
100
- /** 인증 컨텍스트 — ctx.auth.getUserIdentity() */
101
- auth: AuthCtx;
102
- /** 파일 스토리지 — ctx.storage.store(), ctx.storage.getUrl() */
103
- storage: Storage;
104
- /** 스케줄러 — ctx.scheduler.runAfter(), ctx.scheduler.cron() */
105
- scheduler: Scheduler;
106
- /** 실시간 push — ctx.realtime.emit(queryKey, data) */
107
- realtime: RealtimeCtx;
108
- /** 재시도 — ctx.retry(fn, opts) — exponential backoff + jitter */
109
- retry: <T>(fn: () => Promise<T>, options?: import("./retry.js").RetryOptions) => Promise<T>;
110
- /** AI 헬퍼 */
111
- ai?: AIContext;
96
+ /** Drizzle DB 인스턴스 (scoped) — 스키마 filter 자동 적용, execute 차단 */
97
+ db: any; // typed per-app via generic
98
+ /** Raw Drizzle DB — 필터 없음, execute 허용. ⚠️ 이름이 곧 경고. */
99
+ unsafeDb: any;
100
+ /** 인증 컨텍스트 — ctx.auth.getUserIdentity() */
101
+ auth: AuthCtx;
102
+ /** 파일 스토리지 — ctx.storage.store(), ctx.storage.getUrl() */
103
+ storage: Storage;
104
+ /** 스케줄러 — ctx.scheduler.runAfter(), ctx.scheduler.cron() */
105
+ scheduler: Scheduler;
106
+ /** 실시간 push — ctx.realtime.emit(queryKey, data) */
107
+ realtime: RealtimeCtx;
108
+ /** 재시도 — ctx.retry(fn, opts) — exponential backoff + jitter */
109
+ retry: <T>(fn: () => Promise<T>, options?: import("./retry.js").RetryOptions) => Promise<T>;
110
+ /** AI 헬퍼 */
111
+ ai?: AIContext;
112
112
  }
113
113
 
114
114
  // ─── Types ──────────────────────────────────────────────
115
115
 
116
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
117
116
  type QueryHandler<TArgs = any, TReturn = any> = (ctx: GencowCtx, args: TArgs) => Promise<TReturn>;
118
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
119
117
  type MutationHandler<TArgs = any, TReturn = any> = (ctx: GencowCtx, args: TArgs) => Promise<TReturn>;
120
118
 
121
119
  /**
@@ -125,63 +123,63 @@ type MutationHandler<TArgs = any, TReturn = any> = (ctx: GencowCtx, args: TArgs)
125
123
  export type HttpActionHandler = (ctx: GencowCtx, req: HttpActionRequest) => Promise<HttpActionResponse>;
126
124
 
127
125
  export interface HttpActionRequest {
128
- /** HTTP method (GET, POST, PUT, DELETE, PATCH) */
129
- method: string;
130
- /** Full URL */
131
- url: string;
132
- /** URL path (e.g. /api/cli/auth-start) */
133
- path: string;
134
- /** Route params (e.g. { id: "abc" } for /api/apps/:id) */
135
- params: Record<string, string>;
136
- /** Query string params */
137
- query: Record<string, string>;
138
- /** Request headers */
139
- headers: Record<string, string>;
140
- /** Parse JSON body */
141
- json: <T = unknown>() => Promise<T>;
142
- /** Parse form data */
143
- formData: () => Promise<FormData>;
144
- /** Raw body as ArrayBuffer */
145
- arrayBuffer: () => Promise<ArrayBuffer>;
146
- /** Raw body as text */
147
- text: () => Promise<string>;
126
+ /** HTTP method (GET, POST, PUT, DELETE, PATCH) */
127
+ method: string;
128
+ /** Full URL */
129
+ url: string;
130
+ /** URL path (e.g. /api/cli/auth-start) */
131
+ path: string;
132
+ /** Route params (e.g. { id: "abc" } for /api/apps/:id) */
133
+ params: Record<string, string>;
134
+ /** Query string params */
135
+ query: Record<string, string>;
136
+ /** Request headers */
137
+ headers: Record<string, string>;
138
+ /** Parse JSON body */
139
+ json: <T = unknown>() => Promise<T>;
140
+ /** Parse form data */
141
+ formData: () => Promise<FormData>;
142
+ /** Raw body as ArrayBuffer */
143
+ arrayBuffer: () => Promise<ArrayBuffer>;
144
+ /** Raw body as text */
145
+ text: () => Promise<string>;
148
146
  }
149
147
 
150
148
  export interface HttpActionResponse {
151
- status?: number;
152
- headers?: Record<string, string>;
153
- body?: unknown;
149
+ status?: number;
150
+ headers?: Record<string, string>;
151
+ body?: unknown;
154
152
  }
155
153
 
156
154
  export interface QueryDef<TSchema = any, TReturn = any> {
157
- key: string;
158
- handler: QueryHandler<InferArgs<TSchema>, TReturn>;
159
- argsSchema?: TSchema;
160
- /** true = 인증 없이 접근 가능, false(기본) = auth 필수 (Secure by Default) */
161
- isPublic: boolean;
162
- _args?: InferArgs<TSchema>;
163
- _return?: TReturn;
155
+ key: string;
156
+ handler: QueryHandler<InferArgs<TSchema>, TReturn>;
157
+ argsSchema?: TSchema;
158
+ /** true = 인증 없이 접근 가능, false(기본) = auth 필수 (Secure by Default) */
159
+ isPublic: boolean;
160
+ _args?: InferArgs<TSchema>;
161
+ _return?: TReturn;
164
162
  }
165
163
 
166
164
  export interface MutationDef<TSchema = any, TReturn = any> {
167
- handler: MutationHandler<InferArgs<TSchema>, TReturn>;
168
- argsSchema?: TSchema;
169
- /** true = 인증 없이 접근 가능, false(기본) = auth 필수 (Secure by Default) */
170
- isPublic: boolean;
171
- _args?: InferArgs<TSchema>;
172
- _return?: TReturn;
165
+ handler: MutationHandler<InferArgs<TSchema>, TReturn>;
166
+ argsSchema?: TSchema;
167
+ /** true = 인증 없이 접근 가능, false(기본) = auth 필수 (Secure by Default) */
168
+ isPublic: boolean;
169
+ _args?: InferArgs<TSchema>;
170
+ _return?: TReturn;
173
171
  }
174
172
 
175
173
  /** httpAction 정의. 커스텀 HTTP 엔드포인트를 선언적으로 등록. */
176
174
  export interface HttpActionDef {
177
- /** HTTP method */
178
- method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
179
- /** URL path (Hono 패턴 — /api/cli/:code 형태 지원) */
180
- path: string;
181
- /** true = 인증 없이 접근 가능, false(기본) = auth 필수 */
182
- isPublic: boolean;
183
- /** 핸들러 */
184
- handler: HttpActionHandler;
175
+ /** HTTP method */
176
+ method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
177
+ /** URL path (Hono 패턴 — /api/cli/:code 형태 지원) */
178
+ path: string;
179
+ /** true = 인증 없이 접근 가능, false(기본) = auth 필수 */
180
+ isPublic: boolean;
181
+ /** 핸들러 */
182
+ handler: HttpActionHandler;
185
183
  }
186
184
 
187
185
  // ─── Registry ───────────────────────────────────────────
@@ -191,16 +189,11 @@ export interface HttpActionDef {
191
189
  // See: docs/analysis/analysis-dual-module-registry.md
192
190
 
193
191
  declare global {
194
- // eslint-disable-next-line no-var
195
- var __gencow_queryRegistry: Map<string, QueryDef<any, any>>;
196
- // eslint-disable-next-line no-var
197
- var __gencow_mutationRegistry: (MutationDef<any, any> & { name: string })[];
198
- // eslint-disable-next-line no-var
199
- var __gencow_subscribers: Map<string, Set<WSContext>>;
200
- // eslint-disable-next-line no-var
201
- var __gencow_connectedClients: Set<WSContext>;
202
- // eslint-disable-next-line no-var
203
- var __gencow_httpActionRegistry: HttpActionDef[];
192
+ var __gencow_queryRegistry: Map<string, QueryDef<any, any>>;
193
+ var __gencow_mutationRegistry: (MutationDef<any, any> & { name: string })[];
194
+ var __gencow_subscribers: Map<string, Set<WSContext>>;
195
+ var __gencow_connectedClients: Set<WSContext>;
196
+ var __gencow_httpActionRegistry: HttpActionDef[];
204
197
  }
205
198
 
206
199
  if (!globalThis.__gencow_queryRegistry) globalThis.__gencow_queryRegistry = new Map();
@@ -224,24 +217,26 @@ const connectedClients = globalThis.__gencow_connectedClients;
224
217
  // ─── Public API (Convex-style) ──────────────────────────
225
218
 
226
219
  export function query<TSchema = any, TReturn = any>(
227
- key: string,
228
- handlerOrDef: QueryHandler<InferArgs<TSchema>, TReturn> | { args?: TSchema; public?: boolean; handler: QueryHandler<InferArgs<TSchema>, TReturn> }
220
+ key: string,
221
+ handlerOrDef:
222
+ | QueryHandler<InferArgs<TSchema>, TReturn>
223
+ | { args?: TSchema; public?: boolean; handler: QueryHandler<InferArgs<TSchema>, TReturn> },
229
224
  ): QueryDef<TSchema, TReturn> {
230
- let handler: QueryHandler<InferArgs<TSchema>, TReturn>;
231
- let argsSchema: TSchema | undefined;
232
- let isPublic = false;
233
-
234
- if (typeof handlerOrDef === "function") {
235
- handler = handlerOrDef;
236
- } else {
237
- handler = handlerOrDef.handler;
238
- argsSchema = handlerOrDef.args;
239
- isPublic = handlerOrDef.public === true;
240
- }
241
-
242
- const def: QueryDef<TSchema, TReturn> = { key, handler, argsSchema, isPublic };
243
- queryRegistry.set(key, def);
244
- return def;
225
+ let handler: QueryHandler<InferArgs<TSchema>, TReturn>;
226
+ let argsSchema: TSchema | undefined;
227
+ let isPublic = false;
228
+
229
+ if (typeof handlerOrDef === "function") {
230
+ handler = handlerOrDef;
231
+ } else {
232
+ handler = handlerOrDef.handler;
233
+ argsSchema = handlerOrDef.args;
234
+ isPublic = handlerOrDef.public === true;
235
+ }
236
+
237
+ const def: QueryDef<TSchema, TReturn> = { key, handler, argsSchema, isPublic };
238
+ queryRegistry.set(key, def);
239
+ return def;
245
240
  }
246
241
 
247
242
  let mutationCounter = 0;
@@ -273,50 +268,73 @@ let mutationCounter = 0;
273
268
  * 리얼타임 UI 갱신에는 ctx.realtime.emit() 또는 ctx.realtime.refresh()를 사용하세요.
274
269
  */
275
270
  export function mutation<TSchema = any, TReturn = any>(
276
- nameOrInvalidatesOrDef: string | string[] | { name?: string; args?: TSchema; public?: boolean; invalidates?: string[]; handler: MutationHandler<InferArgs<TSchema>, TReturn> },
277
- handlerOrDef?: MutationHandler<InferArgs<TSchema>, TReturn> | { invalidates?: string[]; args?: TSchema; public?: boolean; handler: MutationHandler<InferArgs<TSchema>, TReturn> },
278
- name?: string
271
+ nameOrInvalidatesOrDef:
272
+ | string
273
+ | string[]
274
+ | {
275
+ name?: string;
276
+ args?: TSchema;
277
+ public?: boolean;
278
+ invalidates?: string[];
279
+ handler: MutationHandler<InferArgs<TSchema>, TReturn>;
280
+ },
281
+ handlerOrDef?:
282
+ | MutationHandler<InferArgs<TSchema>, TReturn>
283
+ | {
284
+ invalidates?: string[];
285
+ args?: TSchema;
286
+ public?: boolean;
287
+ handler: MutationHandler<InferArgs<TSchema>, TReturn>;
288
+ },
289
+ name?: string,
279
290
  ): MutationDef<TSchema, TReturn> {
280
- let argsSchema: TSchema | undefined;
281
- let actualHandler: MutationHandler<InferArgs<TSchema>, TReturn>;
282
- let mutName: string;
283
- let isPublic = false;
284
-
285
- if (typeof nameOrInvalidatesOrDef === "string") {
286
- // New primary style: mutation("name", { args?, public?, handler })
287
- mutName = nameOrInvalidatesOrDef;
288
- const def = handlerOrDef as { args?: TSchema; public?: boolean; handler: MutationHandler<InferArgs<TSchema>, TReturn> };
289
- actualHandler = def.handler;
290
- argsSchema = def.args;
291
- isPublic = def.public === true;
292
- } else if (Array.isArray(nameOrInvalidatesOrDef)) {
293
- // Legacy style: mutation([...], handler, "name") — invalidates ignored
294
- actualHandler = handlerOrDef as MutationHandler<InferArgs<TSchema>, TReturn>;
295
- mutName = name || `mutation_${++mutationCounter}`;
296
- } else {
297
- // Object style: mutation({ name?, args?, public?, handler })
298
- actualHandler = nameOrInvalidatesOrDef.handler;
299
- argsSchema = nameOrInvalidatesOrDef.args;
300
- isPublic = nameOrInvalidatesOrDef.public === true;
301
- mutName = nameOrInvalidatesOrDef.name || (typeof name === "string" ? name : "") || `mutation_${++mutationCounter}`;
302
- }
303
-
304
- // 이름 미지정 시 경고 — 디버깅 지원
305
- if (mutName.startsWith("mutation_")) {
306
- console.warn(
307
- `[gencow] mutation registered without explicit name → "${mutName}". ` +
308
- `Use mutation("myMutation", { handler }) for better debugging.`
309
- );
310
- }
311
-
312
- const def: MutationDef<TSchema, TReturn> & { name: string } = {
313
- name: mutName,
314
- handler: actualHandler,
315
- argsSchema,
316
- isPublic,
291
+ let argsSchema: TSchema | undefined;
292
+ let actualHandler: MutationHandler<InferArgs<TSchema>, TReturn>;
293
+ let mutName: string;
294
+ let isPublic = false;
295
+
296
+ if (typeof nameOrInvalidatesOrDef === "string") {
297
+ // New primary style: mutation("name", { args?, public?, handler })
298
+ mutName = nameOrInvalidatesOrDef;
299
+ const def = handlerOrDef as {
300
+ args?: TSchema;
301
+ public?: boolean;
302
+ handler: MutationHandler<InferArgs<TSchema>, TReturn>;
317
303
  };
318
- mutationRegistry.push(def);
319
- return def;
304
+ actualHandler = def.handler;
305
+ argsSchema = def.args;
306
+ isPublic = def.public === true;
307
+ } else if (Array.isArray(nameOrInvalidatesOrDef)) {
308
+ // Legacy style: mutation([...], handler, "name") — invalidates ignored
309
+ actualHandler = handlerOrDef as MutationHandler<InferArgs<TSchema>, TReturn>;
310
+ mutName = name || `mutation_${++mutationCounter}`;
311
+ } else {
312
+ // Object style: mutation({ name?, args?, public?, handler })
313
+ actualHandler = nameOrInvalidatesOrDef.handler;
314
+ argsSchema = nameOrInvalidatesOrDef.args;
315
+ isPublic = nameOrInvalidatesOrDef.public === true;
316
+ mutName =
317
+ nameOrInvalidatesOrDef.name ||
318
+ (typeof name === "string" ? name : "") ||
319
+ `mutation_${++mutationCounter}`;
320
+ }
321
+
322
+ // 이름 미지정 시 경고 — 디버깅 지원
323
+ if (mutName.startsWith("mutation_")) {
324
+ console.warn(
325
+ `[gencow] mutation registered without explicit name → "${mutName}". ` +
326
+ `Use mutation("myMutation", { handler }) for better debugging.`,
327
+ );
328
+ }
329
+
330
+ const def: MutationDef<TSchema, TReturn> & { name: string } = {
331
+ name: mutName,
332
+ handler: actualHandler,
333
+ argsSchema,
334
+ isPublic,
335
+ };
336
+ mutationRegistry.push(def);
337
+ return def;
320
338
  }
321
339
 
322
340
  // ─── httpAction (커스텀 HTTP 엔드포인트) ────────────────
@@ -347,52 +365,52 @@ export function mutation<TSchema = any, TReturn = any>(
347
365
  * ```
348
366
  */
349
367
  export function httpAction(def: {
350
- method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
351
- path: string;
352
- public?: boolean;
353
- handler: HttpActionHandler;
368
+ method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
369
+ path: string;
370
+ public?: boolean;
371
+ handler: HttpActionHandler;
354
372
  }): HttpActionDef {
355
- const actionDef: HttpActionDef = {
356
- method: def.method,
357
- path: def.path,
358
- isPublic: def.public === true,
359
- handler: def.handler,
360
- };
361
- httpActionRegistry.push(actionDef);
362
- return actionDef;
373
+ const actionDef: HttpActionDef = {
374
+ method: def.method,
375
+ path: def.path,
376
+ isPublic: def.public === true,
377
+ handler: def.handler,
378
+ };
379
+ httpActionRegistry.push(actionDef);
380
+ return actionDef;
363
381
  }
364
382
 
365
383
  export function getRegisteredHttpActions(): HttpActionDef[] {
366
- return [...httpActionRegistry];
384
+ return [...httpActionRegistry];
367
385
  }
368
386
 
369
387
  // ─── WebSocket subscription management ──────────────────
370
388
 
371
389
  export function subscribe(queryKey: string, ws: WSContext) {
372
- connectedClients.add(ws);
373
- if (!subscribers.has(queryKey)) {
374
- subscribers.set(queryKey, new Set());
375
- }
376
- subscribers.get(queryKey)!.add(ws);
390
+ connectedClients.add(ws);
391
+ if (!subscribers.has(queryKey)) {
392
+ subscribers.set(queryKey, new Set());
393
+ }
394
+ subscribers.get(queryKey)!.add(ws);
377
395
  }
378
396
 
379
397
  export function unsubscribe(ws: WSContext) {
380
- connectedClients.delete(ws);
381
- for (const clients of subscribers.values()) {
382
- clients.delete(ws);
383
- }
398
+ connectedClients.delete(ws);
399
+ for (const clients of subscribers.values()) {
400
+ clients.delete(ws);
401
+ }
384
402
  }
385
403
 
386
404
  /** Register a raw WS connection without any query subscription (e.g. admin dashboard) */
387
405
  export function registerClient(ws: WSContext) {
388
- connectedClients.add(ws);
406
+ connectedClients.add(ws);
389
407
  }
390
408
 
391
409
  export function deregisterClient(ws: WSContext) {
392
- connectedClients.delete(ws);
393
- for (const clients of subscribers.values()) {
394
- clients.delete(ws);
395
- }
410
+ connectedClients.delete(ws);
411
+ for (const clients of subscribers.values()) {
412
+ clients.delete(ws);
413
+ }
396
414
  }
397
415
 
398
416
  /**
@@ -412,143 +430,151 @@ export function deregisterClient(ws: WSContext) {
412
430
  * @param options.buildCtxForRefresh refresh 시 query handler에 전달할 ctx 생성 함수.
413
431
  */
414
432
  export function buildRealtimeCtx(options?: {
415
- httpCallback?: (event: { type: "emit"; queryKey: string; data: unknown }) => void;
416
- queryMap?: Map<string, QueryDef<any, any>>;
417
- buildCtxForRefresh?: () => GencowCtx;
433
+ httpCallback?: (event: { type: "emit"; queryKey: string; data: unknown }) => void;
434
+ queryMap?: Map<string, QueryDef<any, any>>;
435
+ buildCtxForRefresh?: () => GencowCtx;
418
436
  }): RealtimeCtx & { _hasEmitted: boolean; _pendingRefresh: string[]; _flushRefresh: () => Promise<void> } {
419
- const pendingEmits = new Map<string, { data: unknown; timer: ReturnType<typeof setTimeout> }>();
420
- const _pendingRefresh: string[] = [];
421
- let _hasEmitted = false;
422
-
423
- return {
424
- emit(queryKey: string, data: unknown) {
425
- _hasEmitted = true;
426
- // 기존 pending timer가 있으면 취소 (debounce)
427
- const existing = pendingEmits.get(queryKey);
428
- if (existing) clearTimeout(existing.timer);
429
-
430
- const timer = setTimeout(() => {
431
- pendingEmits.delete(queryKey);
432
-
433
- // BaaS 모드: Platform WS Gateway에 HTTP callback
434
- if (options?.httpCallback) {
435
- options.httpCallback({ type: "emit", queryKey, data });
436
- return;
437
- }
438
-
439
- // 로컬 dev: WS 직접 push (기존 동작)
440
- const clients = subscribers.get(queryKey);
441
- if (!clients || clients.size === 0) return;
442
-
443
- const message = JSON.stringify({
444
- type: "query:updated",
445
- query: queryKey,
446
- data,
447
- });
448
- for (const ws of clients) {
449
- try { ws.send(message); } catch { clients.delete(ws); }
450
- }
451
- }, 50); // 50ms batch window
452
-
453
- pendingEmits.set(queryKey, { data, timer });
454
- },
455
-
456
- refresh(queryKey: string) {
457
- _hasEmitted = true; // 경고 억제
458
- if (!_pendingRefresh.includes(queryKey)) {
459
- _pendingRefresh.push(queryKey);
460
- }
461
- },
462
-
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;
437
+ const pendingEmits = new Map<string, { data: unknown; timer: ReturnType<typeof setTimeout> }>();
438
+ const _pendingRefresh: string[] = [];
439
+ let _hasEmitted = false;
440
+
441
+ return {
442
+ emit(queryKey: string, data: unknown) {
443
+ _hasEmitted = true;
444
+ // 기존 pending timer가 있으면 취소 (debounce)
445
+ const existing = pendingEmits.get(queryKey);
446
+ if (existing) clearTimeout(existing.timer);
447
+
448
+ const timer = setTimeout(() => {
449
+ pendingEmits.delete(queryKey);
450
+
451
+ // BaaS 모드: Platform WS Gateway에 HTTP callback
452
+ if (options?.httpCallback) {
453
+ options.httpCallback({ type: "emit", queryKey, data });
454
+ return;
455
+ }
471
456
 
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
- }
457
+ // 로컬 dev: WS 직접 push (기존 동작)
458
+ const clients = subscribers.get(queryKey);
459
+ if (!clients || clients.size === 0) return;
460
+
461
+ const message = JSON.stringify({
462
+ type: "query:updated",
463
+ query: queryKey,
464
+ data,
465
+ });
466
+ for (const ws of clients) {
467
+ try {
468
+ ws.send(message);
469
+ } catch {
470
+ clients.delete(ws);
471
+ }
472
+ }
473
+ }, 50); // 50ms batch window
474
+
475
+ pendingEmits.set(queryKey, { data, timer });
476
+ },
477
+
478
+ refresh(queryKey: string) {
479
+ _hasEmitted = true; // 경고 억제
480
+ if (!_pendingRefresh.includes(queryKey)) {
481
+ _pendingRefresh.push(queryKey);
482
+ }
483
+ },
484
+
485
+ get _hasEmitted() {
486
+ return _hasEmitted;
487
+ },
488
+ get _pendingRefresh() {
489
+ return [..._pendingRefresh];
490
+ },
491
+
492
+ async _flushRefresh() {
493
+ if (_pendingRefresh.length === 0) return;
494
+
495
+ // queryMap이 없으면 refresh 동작 불가 (로그 경고)
496
+ const qMap = options?.queryMap ?? queryRegistry;
497
+
498
+ for (const key of _pendingRefresh) {
499
+ const queryDef = qMap.get(key);
500
+ if (!queryDef) {
501
+ console.warn(`[gencow] refresh("${key}"): query not found in registry. Skipping.`);
502
+ continue;
503
+ }
504
+ try {
505
+ // refresh용 ctx 생성 (mutation ctx와 동일한 DB/auth 스코프)
506
+ if (!options?.buildCtxForRefresh) {
507
+ console.warn(
508
+ `[gencow] ⚠️ refresh("${key}"): buildCtxForRefresh not provided. ` +
509
+ `Query handler will receive an empty ctx — ctx.db will be undefined. ` +
510
+ `This is a framework configuration error. ` +
511
+ `💡 Ensure buildRealtimeCtx() receives a buildCtxForRefresh callback.`,
512
+ );
513
+ }
514
+ const refreshCtx = options?.buildCtxForRefresh?.() ?? ({} as GencowCtx);
515
+ const result = await queryDef.handler(refreshCtx, {});
516
+
517
+ // emit과 동일한 경로로 push
518
+ if (options?.httpCallback) {
519
+ options.httpCallback({ type: "emit", queryKey: key, data: result });
520
+ } else {
521
+ const clients = subscribers.get(key);
522
+ if (clients && clients.size > 0) {
523
+ const message = JSON.stringify({
524
+ type: "query:updated",
525
+ query: key,
526
+ data: result,
527
+ });
528
+ for (const ws of clients) {
478
529
  try {
479
- // refresh용 ctx 생성 (mutation ctx와 동일한 DB/auth 스코프)
480
- if (!options?.buildCtxForRefresh) {
481
- console.warn(
482
- `[gencow] ⚠️ refresh("${key}"): buildCtxForRefresh not provided. ` +
483
- `Query handler will receive an empty ctx — ctx.db will be undefined. ` +
484
- `This is a framework configuration error. ` +
485
- `💡 Ensure buildRealtimeCtx() receives a buildCtxForRefresh callback.`
486
- );
487
- }
488
- const refreshCtx = options?.buildCtxForRefresh?.() ?? ({} as GencowCtx);
489
- const result = await queryDef.handler(refreshCtx, {});
490
-
491
- // emit과 동일한 경로로 push
492
- if (options?.httpCallback) {
493
- options.httpCallback({ type: "emit", queryKey: key, data: result });
494
- } else {
495
- const clients = subscribers.get(key);
496
- if (clients && clients.size > 0) {
497
- const message = JSON.stringify({
498
- type: "query:updated",
499
- query: key,
500
- data: result,
501
- });
502
- for (const ws of clients) {
503
- try { ws.send(message); } catch { clients.delete(ws); }
504
- }
505
- }
506
- }
507
- } catch (e) {
508
- console.warn(`[gencow] refresh("${key}") failed:`, e instanceof Error ? e.message : e);
530
+ ws.send(message);
531
+ } catch {
532
+ clients.delete(ws);
509
533
  }
534
+ }
510
535
  }
511
- _pendingRefresh.length = 0;
512
- },
513
- };
536
+ }
537
+ } catch (e) {
538
+ console.warn(`[gencow] refresh("${key}") failed:`, e instanceof Error ? e.message : e);
539
+ }
540
+ }
541
+ _pendingRefresh.length = 0;
542
+ },
543
+ };
514
544
  }
515
545
 
516
-
517
546
  // ─── WebSocket message handler ──────────────────────────
518
547
 
519
548
  export function handleWsMessage(ws: WSContext, raw: string | ArrayBuffer) {
520
- try {
521
- const msg =
522
- typeof raw === "string" ? JSON.parse(raw) : JSON.parse(raw.toString());
523
-
524
- if (msg.type === "subscribe" && msg.query) {
525
- subscribe(msg.query, ws);
526
- ws.send(
527
- JSON.stringify({ type: "subscribed", query: msg.query })
528
- );
529
- }
549
+ try {
550
+ const msg = typeof raw === "string" ? JSON.parse(raw) : JSON.parse(raw.toString());
530
551
 
531
- if (msg.type === "unsubscribe" && msg.query) {
532
- const clients = subscribers.get(msg.query);
533
- if (clients) clients.delete(ws);
534
- }
535
- } catch {
536
- // ignore malformed messages
552
+ if (msg.type === "subscribe" && msg.query) {
553
+ subscribe(msg.query, ws);
554
+ ws.send(JSON.stringify({ type: "subscribed", query: msg.query }));
555
+ }
556
+
557
+ if (msg.type === "unsubscribe" && msg.query) {
558
+ const clients = subscribers.get(msg.query);
559
+ if (clients) clients.delete(ws);
537
560
  }
561
+ } catch {
562
+ // ignore malformed messages
563
+ }
538
564
  }
539
565
 
540
566
  export function getQueryHandler(key: string): QueryHandler | undefined {
541
- return queryRegistry.get(key)?.handler;
567
+ return queryRegistry.get(key)?.handler;
542
568
  }
543
569
 
544
570
  export function getQueryDef(key: string): QueryDef | undefined {
545
- return queryRegistry.get(key);
571
+ return queryRegistry.get(key);
546
572
  }
547
573
 
548
574
  export function getRegisteredQueries(): string[] {
549
- return Array.from(queryRegistry.keys());
575
+ return Array.from(queryRegistry.keys());
550
576
  }
551
577
 
552
578
  export function getRegisteredMutations(): (MutationDef & { name: string })[] {
553
- return [...mutationRegistry];
579
+ return [...mutationRegistry];
554
580
  }