@gencow/core 0.1.24 → 0.1.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/crud.d.ts +2 -2
- package/dist/crud.js +225 -208
- package/dist/index.d.ts +5 -5
- package/dist/index.js +2 -2
- package/dist/reactive.js +10 -3
- package/dist/retry.js +1 -1
- package/dist/rls-db.d.ts +2 -2
- package/dist/rls-db.js +1 -5
- package/dist/scheduler.d.ts +2 -0
- package/dist/scheduler.js +16 -6
- package/dist/server.d.ts +0 -1
- package/dist/server.js +0 -1
- package/dist/storage.js +29 -22
- package/dist/v.d.ts +2 -2
- package/dist/workflow.js +4 -11
- package/dist/workflows-api.js +5 -12
- package/package.json +45 -42
- package/src/__tests__/auth.test.ts +90 -86
- package/src/__tests__/crons.test.ts +69 -67
- package/src/__tests__/crud-codegen-integration.test.ts +164 -170
- package/src/__tests__/crud-owner-rls.test.ts +308 -301
- package/src/__tests__/crud.test.ts +694 -711
- package/src/__tests__/dist-exports.test.ts +120 -120
- package/src/__tests__/fixtures/basic/auth.ts +16 -16
- package/src/__tests__/fixtures/basic/drizzle.config.ts +1 -4
- package/src/__tests__/fixtures/basic/index.ts +1 -1
- package/src/__tests__/fixtures/basic/schema.ts +1 -1
- package/src/__tests__/fixtures/basic/tasks.ts +4 -4
- package/src/__tests__/fixtures/common/auth-schema.ts +38 -34
- package/src/__tests__/helpers/basic-rls-fixture.ts +80 -78
- package/src/__tests__/helpers/pglite-migrations.ts +2 -5
- package/src/__tests__/helpers/pglite-rls-session.ts +13 -16
- package/src/__tests__/helpers/seed-like-fill.ts +47 -41
- package/src/__tests__/helpers/test-gencow-ctx-rls.ts +4 -7
- package/src/__tests__/httpaction.test.ts +91 -91
- package/src/__tests__/image-optimization.test.ts +570 -574
- package/src/__tests__/load.test.ts +321 -308
- package/src/__tests__/network-sim.test.ts +238 -215
- package/src/__tests__/reactive.test.ts +380 -358
- package/src/__tests__/retry.test.ts +99 -84
- package/src/__tests__/rls-crud-basic.test.ts +172 -245
- package/src/__tests__/rls-crud-no-owner-rls-pglite.test.ts +81 -81
- package/src/__tests__/rls-custom-mutation-handlers.test.ts +47 -94
- package/src/__tests__/rls-custom-query-handlers.test.ts +92 -92
- package/src/__tests__/rls-db-leased-connection.test.ts +2 -6
- package/src/__tests__/rls-session-and-policies.test.ts +181 -199
- package/src/__tests__/scheduler-durable-v2.test.ts +199 -181
- package/src/__tests__/scheduler-durable.test.ts +117 -117
- package/src/__tests__/scheduler-exec.test.ts +258 -246
- package/src/__tests__/scheduler.test.ts +129 -111
- package/src/__tests__/storage.test.ts +282 -269
- package/src/__tests__/tsconfig.json +6 -6
- package/src/__tests__/validator.test.ts +236 -232
- package/src/__tests__/workflow.test.ts +309 -286
- package/src/__tests__/ws-integration.test.ts +223 -218
- package/src/__tests__/ws-scale.test.ts +168 -159
- package/src/auth-config.ts +18 -18
- package/src/auth.ts +106 -106
- package/src/crons.ts +77 -77
- package/src/crud.ts +523 -479
- package/src/index.ts +69 -5
- package/src/reactive.ts +357 -331
- package/src/retry.ts +51 -54
- package/src/rls-db.ts +195 -205
- package/src/rls.ts +33 -36
- package/src/scheduler.ts +237 -211
- package/src/server.ts +0 -1
- package/src/storage.ts +632 -593
- package/src/v.ts +119 -114
- package/src/workflow-types.ts +67 -70
- package/src/workflow.ts +99 -116
- package/src/workflows-api.ts +231 -241
- package/dist/db.d.ts +0 -13
- package/dist/db.js +0 -16
- 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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
id: string;
|
|
10
|
+
email: string;
|
|
11
|
+
name?: string;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
export interface AuthCtx {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
67
|
-
|
|
66
|
+
role: "user" | "system" | "assistant";
|
|
67
|
+
content: string;
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
export interface AIResult {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
228
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
|
|
319
|
-
|
|
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
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
368
|
+
method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
|
|
369
|
+
path: string;
|
|
370
|
+
public?: boolean;
|
|
371
|
+
handler: HttpActionHandler;
|
|
354
372
|
}): HttpActionDef {
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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
|
-
|
|
384
|
+
return [...httpActionRegistry];
|
|
367
385
|
}
|
|
368
386
|
|
|
369
387
|
// ─── WebSocket subscription management ──────────────────
|
|
370
388
|
|
|
371
389
|
export function subscribe(queryKey: string, ws: WSContext) {
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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
|
-
|
|
406
|
+
connectedClients.add(ws);
|
|
389
407
|
}
|
|
390
408
|
|
|
391
409
|
export function deregisterClient(ws: WSContext) {
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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
|
-
|
|
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
|
-
|
|
521
|
-
|
|
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
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
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
|
-
|
|
567
|
+
return queryRegistry.get(key)?.handler;
|
|
542
568
|
}
|
|
543
569
|
|
|
544
570
|
export function getQueryDef(key: string): QueryDef | undefined {
|
|
545
|
-
|
|
571
|
+
return queryRegistry.get(key);
|
|
546
572
|
}
|
|
547
573
|
|
|
548
574
|
export function getRegisteredQueries(): string[] {
|
|
549
|
-
|
|
575
|
+
return Array.from(queryRegistry.keys());
|
|
550
576
|
}
|
|
551
577
|
|
|
552
578
|
export function getRegisteredMutations(): (MutationDef & { name: string })[] {
|
|
553
|
-
|
|
579
|
+
return [...mutationRegistry];
|
|
554
580
|
}
|