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