@gencow/core 0.1.0

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.d.ts ADDED
@@ -0,0 +1,56 @@
1
+ import type { Context, Next } from "hono";
2
+ interface User {
3
+ id: string;
4
+ email: string;
5
+ name?: string;
6
+ }
7
+ interface AuthConfig {
8
+ jwtSecret: string;
9
+ }
10
+ /**
11
+ * Auth middleware that injects `c.get('auth')` into context
12
+ *
13
+ * @example
14
+ * app.use('*', authMiddleware({ jwtSecret: 'secret' }));
15
+ *
16
+ * // In query/mutation:
17
+ * const user = c.get('auth').requireAuth();
18
+ */
19
+ export declare function authMiddleware(config: AuthConfig): (c: Context, next: Next) => Promise<void>;
20
+ export declare function authRoutes(config: AuthConfig): {
21
+ /** POST /auth/signup — 회원가입 */
22
+ signup(c: Context): Promise<(Response & import("hono").TypedResponse<{
23
+ error: string;
24
+ }, 400, "json">) | (Response & import("hono").TypedResponse<{
25
+ error: string;
26
+ }, 409, "json">) | (Response & import("hono").TypedResponse<{
27
+ token: string;
28
+ user: {
29
+ id: `${string}-${string}-${string}-${string}-${string}`;
30
+ email: any;
31
+ name: any;
32
+ };
33
+ }, import("hono/utils/http-status").ContentfulStatusCode, "json">)>;
34
+ /** POST /auth/login — 로그인 */
35
+ login(c: Context): Promise<(Response & import("hono").TypedResponse<{
36
+ error: string;
37
+ }, 401, "json">) | (Response & import("hono").TypedResponse<{
38
+ token: string;
39
+ user: {
40
+ id: string;
41
+ email: string;
42
+ name: string | undefined;
43
+ };
44
+ }, import("hono/utils/http-status").ContentfulStatusCode, "json">)>;
45
+ /** GET /auth/me — 현재 유저 정보 */
46
+ me(c: Context): Promise<Response & import("hono").TypedResponse<{
47
+ id: string;
48
+ email: string;
49
+ name?: string | undefined;
50
+ }, import("hono/utils/http-status").ContentfulStatusCode, "json">>;
51
+ };
52
+ /** Get all registered users (for admin dashboard) */
53
+ export declare function getUsers(): (User & {
54
+ createdAt: string;
55
+ })[];
56
+ export {};
package/dist/auth.js ADDED
@@ -0,0 +1,107 @@
1
+ import { HTTPException } from "hono/http-exception";
2
+ import { sign, verify } from "hono/utils/jwt/jwt";
3
+ // ─── In-memory user store (POC용, 프로덕션에서는 Drizzle 테이블 사용) ──
4
+ const users = new Map();
5
+ // ─── Simple password hashing (POC용) ────────────────────
6
+ async function hashPassword(password) {
7
+ const encoder = new TextEncoder();
8
+ const data = encoder.encode(password);
9
+ const hash = await crypto.subtle.digest("SHA-256", data);
10
+ return Array.from(new Uint8Array(hash))
11
+ .map((b) => b.toString(16).padStart(2, "0"))
12
+ .join("");
13
+ }
14
+ // ─── Auth middleware — Convex ctx.auth 패턴 재현 ─────────
15
+ /**
16
+ * Auth middleware that injects `c.get('auth')` into context
17
+ *
18
+ * @example
19
+ * app.use('*', authMiddleware({ jwtSecret: 'secret' }));
20
+ *
21
+ * // In query/mutation:
22
+ * const user = c.get('auth').requireAuth();
23
+ */
24
+ export function authMiddleware(config) {
25
+ return async (c, next) => {
26
+ let currentUser = null;
27
+ // Extract JWT from Authorization header
28
+ const authHeader = c.req.header("Authorization");
29
+ if (authHeader?.startsWith("Bearer ")) {
30
+ const token = authHeader.slice(7);
31
+ try {
32
+ const payload = (await verify(token, config.jwtSecret, "HS256"));
33
+ currentUser = {
34
+ id: payload.sub,
35
+ email: payload.email,
36
+ name: payload.name,
37
+ };
38
+ }
39
+ catch {
40
+ // Invalid token — continue as unauthenticated
41
+ }
42
+ }
43
+ const authContext = {
44
+ getUserIdentity: () => currentUser,
45
+ requireAuth: () => {
46
+ if (!currentUser) {
47
+ throw new HTTPException(401, { message: "Authentication required" });
48
+ }
49
+ return currentUser;
50
+ },
51
+ };
52
+ c.set("auth", authContext);
53
+ await next();
54
+ };
55
+ }
56
+ // ─── Auth routes — 회원가입/로그인/프로필 ────────────────
57
+ export function authRoutes(config) {
58
+ return {
59
+ /** POST /auth/signup — 회원가입 */
60
+ async signup(c) {
61
+ const { email, password, name } = await c.req.json();
62
+ if (!email || !password) {
63
+ return c.json({ error: "Email and password required" }, 400);
64
+ }
65
+ if (users.has(email)) {
66
+ return c.json({ error: "User already exists" }, 409);
67
+ }
68
+ const id = crypto.randomUUID();
69
+ const passwordHash = await hashPassword(password);
70
+ users.set(email, { id, email, name, passwordHash, createdAt: new Date().toISOString() });
71
+ const token = await sign({ sub: id, email, name, exp: Math.floor(Date.now() / 1000) + 86400 }, config.jwtSecret);
72
+ return c.json({ token, user: { id, email, name } });
73
+ },
74
+ /** POST /auth/login — 로그인 */
75
+ async login(c) {
76
+ const { email, password } = await c.req.json();
77
+ const user = users.get(email);
78
+ if (!user) {
79
+ return c.json({ error: "Invalid credentials" }, 401);
80
+ }
81
+ const hash = await hashPassword(password);
82
+ if (hash !== user.passwordHash) {
83
+ return c.json({ error: "Invalid credentials" }, 401);
84
+ }
85
+ const token = await sign({
86
+ sub: user.id,
87
+ email: user.email,
88
+ name: user.name,
89
+ exp: Math.floor(Date.now() / 1000) + 86400,
90
+ }, config.jwtSecret);
91
+ return c.json({
92
+ token,
93
+ user: { id: user.id, email: user.email, name: user.name },
94
+ });
95
+ },
96
+ /** GET /auth/me — 현재 유저 정보 */
97
+ async me(c) {
98
+ const auth = c.get("auth");
99
+ const user = auth.requireAuth();
100
+ return c.json(user);
101
+ },
102
+ };
103
+ }
104
+ /** Get all registered users (for admin dashboard) */
105
+ export function getUsers() {
106
+ return Array.from(users.values()).map(({ passwordHash, ...user }) => user);
107
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * packages/core/src/crons.ts
3
+ *
4
+ * 선언적 Cron Jobs — Convex cronJobs() 패턴
5
+ *
6
+ * gencow/crons.ts 파일에 선언하면 서버 시작 시 자동 등록됩니다.
7
+ *
8
+ * @example
9
+ * // gencow/crons.ts
10
+ * import { cronJobs } from "@gencow/core";
11
+ *
12
+ * const crons = cronJobs();
13
+ * crons.interval("sync-metrics", { minutes: 15 }, "metrics.collect");
14
+ * crons.daily("cleanup", { hour: 2 }, "admin.cleanup");
15
+ * crons.cron("custom", "0 * * * *", "hourly.task");
16
+ * export default crons;
17
+ */
18
+ export interface CronJobDef {
19
+ /** 크론 잡 이름 */
20
+ name: string;
21
+ /** cron 표현식 (예: "0 2 * * *") */
22
+ pattern: string;
23
+ /** 실행할 액션 또는 핸들러 */
24
+ action: string | (() => Promise<void>);
25
+ }
26
+ export interface IntervalOptions {
27
+ /** 분 단위 */
28
+ minutes?: number;
29
+ /** 시간 단위 */
30
+ hours?: number;
31
+ /** 초 단위 */
32
+ seconds?: number;
33
+ }
34
+ export interface DailyOptions {
35
+ /** 실행 시각 (0-23, 로컬 시간) */
36
+ hour: number;
37
+ /** 분 (0-59, 기본 0) */
38
+ minute?: number;
39
+ }
40
+ export interface WeeklyOptions {
41
+ /** 요일 (0=일, 1=월, ..., 6=토) */
42
+ dayOfWeek: number;
43
+ /** 시각 (0-23) */
44
+ hour: number;
45
+ /** 분 (0-59, 기본 0) */
46
+ minute?: number;
47
+ }
48
+ export interface CronJobsBuilder {
49
+ /** 일정 간격으로 실행 */
50
+ interval(name: string, options: IntervalOptions, action: string | (() => Promise<void>)): CronJobsBuilder;
51
+ /** 매일 특정 시각에 실행 */
52
+ daily(name: string, options: DailyOptions, action: string | (() => Promise<void>)): CronJobsBuilder;
53
+ /** 매주 특정 요일/시각에 실행 */
54
+ weekly(name: string, options: WeeklyOptions, action: string | (() => Promise<void>)): CronJobsBuilder;
55
+ /** cron 표현식으로 직접 지정 */
56
+ cron(name: string, pattern: string, action: string | (() => Promise<void>)): CronJobsBuilder;
57
+ /** 등록된 크론 잡 목록 (서버 내부에서 사용) */
58
+ getJobs(): CronJobDef[];
59
+ }
60
+ /**
61
+ * 선언적 크론 잡 빌더
62
+ *
63
+ * @example
64
+ * const crons = cronJobs();
65
+ * crons.interval("sync", { minutes: 15 }, "metrics.collect");
66
+ * crons.daily("report", { hour: 9 }, "reports.generate");
67
+ * crons.cron("custom", "0 30 * * *", "custom.handler");
68
+ * export default crons;
69
+ */
70
+ export declare function cronJobs(): CronJobsBuilder;
package/dist/crons.js ADDED
@@ -0,0 +1,72 @@
1
+ /**
2
+ * packages/core/src/crons.ts
3
+ *
4
+ * 선언적 Cron Jobs — Convex cronJobs() 패턴
5
+ *
6
+ * gencow/crons.ts 파일에 선언하면 서버 시작 시 자동 등록됩니다.
7
+ *
8
+ * @example
9
+ * // gencow/crons.ts
10
+ * import { cronJobs } from "@gencow/core";
11
+ *
12
+ * const crons = cronJobs();
13
+ * crons.interval("sync-metrics", { minutes: 15 }, "metrics.collect");
14
+ * crons.daily("cleanup", { hour: 2 }, "admin.cleanup");
15
+ * crons.cron("custom", "0 * * * *", "hourly.task");
16
+ * export default crons;
17
+ */
18
+ // ─── 빌더 구현 ──────────────────────────────────────────
19
+ /**
20
+ * 선언적 크론 잡 빌더
21
+ *
22
+ * @example
23
+ * const crons = cronJobs();
24
+ * crons.interval("sync", { minutes: 15 }, "metrics.collect");
25
+ * crons.daily("report", { hour: 9 }, "reports.generate");
26
+ * crons.cron("custom", "0 30 * * *", "custom.handler");
27
+ * export default crons;
28
+ */
29
+ export function cronJobs() {
30
+ const jobs = [];
31
+ const builder = {
32
+ interval(name, options, action) {
33
+ const pattern = intervalToPattern(options);
34
+ jobs.push({ name, pattern, action });
35
+ return builder;
36
+ },
37
+ daily(name, options, action) {
38
+ const minute = options.minute ?? 0;
39
+ const pattern = `${minute} ${options.hour} * * *`;
40
+ jobs.push({ name, pattern, action });
41
+ return builder;
42
+ },
43
+ weekly(name, options, action) {
44
+ const minute = options.minute ?? 0;
45
+ const pattern = `${minute} ${options.hour} * * ${options.dayOfWeek}`;
46
+ jobs.push({ name, pattern, action });
47
+ return builder;
48
+ },
49
+ cron(name, pattern, action) {
50
+ jobs.push({ name, pattern, action });
51
+ return builder;
52
+ },
53
+ getJobs() {
54
+ return [...jobs];
55
+ },
56
+ };
57
+ return builder;
58
+ }
59
+ // ─── 유틸리티 ───────────────────────────────────────────
60
+ function intervalToPattern(options) {
61
+ if (options.seconds) {
62
+ // node-cron은 초 단위 지원: "*/N * * * * *"
63
+ return `*/${options.seconds} * * * * *`;
64
+ }
65
+ if (options.minutes) {
66
+ return `*/${options.minutes} * * * *`;
67
+ }
68
+ if (options.hours) {
69
+ return `0 */${options.hours} * * *`;
70
+ }
71
+ throw new Error("interval: minutes, hours, 또는 seconds 중 하나를 지정하세요");
72
+ }
package/dist/db.d.ts ADDED
@@ -0,0 +1,13 @@
1
+ /**
2
+ * @deprecated — 레거시 싱글톤 DB 인스턴스.
3
+ * 새 코드에서는 ctx.db를 사용하세요.
4
+ * 서버의 createDatabase() (database.ts)가 실제 DB 연결을 관리합니다.
5
+ */
6
+ import { PGlite } from "@electric-sql/pglite";
7
+ /** @deprecated Use ctx.db instead */
8
+ export declare function createDb(dataDir?: string): Promise<{
9
+ db: import("drizzle-orm/pglite").PgliteDatabase<Record<string, never>> & {
10
+ $client: PGlite;
11
+ };
12
+ client: PGlite;
13
+ }>;
package/dist/db.js ADDED
@@ -0,0 +1,16 @@
1
+ /**
2
+ * @deprecated — 레거시 싱글톤 DB 인스턴스.
3
+ * 새 코드에서는 ctx.db를 사용하세요.
4
+ * 서버의 createDatabase() (database.ts)가 실제 DB 연결을 관리합니다.
5
+ */
6
+ import { PGlite } from "@electric-sql/pglite";
7
+ import { drizzle } from "drizzle-orm/pglite";
8
+ let pgliteInstance = null;
9
+ /** @deprecated Use ctx.db instead */
10
+ export async function createDb(dataDir = "./data") {
11
+ if (!pgliteInstance) {
12
+ pgliteInstance = new PGlite(dataDir);
13
+ }
14
+ const db = drizzle(pgliteInstance);
15
+ return { db, client: pgliteInstance };
16
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Platform Core — Convex-like Backend Framework
3
+ *
4
+ * Provides: query, mutation, storage, scheduler, auth
5
+ * All with Convex-compatible DX patterns.
6
+ */
7
+ export type { GencowCtx, AuthCtx, UserIdentity, QueryDef, MutationDef, RealtimeCtx } from "./reactive";
8
+ export { query, mutation, invalidateQueries, buildRealtimeCtx, subscribe, unsubscribe, registerClient, deregisterClient, handleWsMessage, getQueryHandler, getQueryDef, getRegisteredQueries, getRegisteredMutations } from "./reactive";
9
+ export type { Storage } from "./storage";
10
+ export { createScheduler, getSchedulerInfo } from "./scheduler";
11
+ export type { Scheduler } from "./scheduler";
12
+ export { v, parseArgs, GencowValidationError } from "./v";
13
+ export type { Validator, Infer, InferArgs } from "./v";
14
+ export { withRetry } from "./retry";
15
+ export type { RetryOptions } from "./retry";
16
+ export { cronJobs } from "./crons";
17
+ export type { CronJobsBuilder, CronJobDef, IntervalOptions, DailyOptions, WeeklyOptions } from "./crons";
package/dist/index.js ADDED
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Platform Core — Convex-like Backend Framework
3
+ *
4
+ * Provides: query, mutation, storage, scheduler, auth
5
+ * All with Convex-compatible DX patterns.
6
+ */
7
+ export { query, mutation, invalidateQueries, buildRealtimeCtx, subscribe, unsubscribe, registerClient, deregisterClient, handleWsMessage, getQueryHandler, getQueryDef, getRegisteredQueries, getRegisteredMutations } from "./reactive";
8
+ export { createScheduler, getSchedulerInfo } from "./scheduler";
9
+ export { v, parseArgs, GencowValidationError } from "./v";
10
+ export { withRetry } from "./retry";
11
+ export { cronJobs } from "./crons";
@@ -0,0 +1,113 @@
1
+ import type { WSContext } from "hono/ws";
2
+ import type { Storage } from "./storage";
3
+ import type { Scheduler } from "./scheduler";
4
+ import { type InferArgs } from "./v";
5
+ export interface UserIdentity {
6
+ id: string;
7
+ email: string;
8
+ name?: string;
9
+ }
10
+ export interface AuthCtx {
11
+ /** 현재 유저 반환 (비로그인 시 null) — Convex의 ctx.auth.getUserIdentity() */
12
+ getUserIdentity(): UserIdentity | null;
13
+ /** 현재 유저 반환 (비로그인 시 401 throw) */
14
+ requireAuth(): UserIdentity;
15
+ }
16
+ /**
17
+ * mutation handler 내에서 구독자들에게 데이터를 즉시 push합니다.
18
+ * 클라이언트는 이 데이터를 받아 re-fetch 없이 state를 업데이트합니다.
19
+ */
20
+ export interface RealtimeCtx {
21
+ /**
22
+ * @param queryKey - 업데이트할 쿼리 키 (예: "tasks.list")
23
+ * @param data - push할 데이터 (해당 query 결과와 동일한 타입)
24
+ *
25
+ * @example
26
+ * const freshList = await ctx.db.select().from(tasks);
27
+ * ctx.realtime.emit("tasks.list", freshList);
28
+ */
29
+ emit(queryKey: string, data: unknown): void;
30
+ }
31
+ /**
32
+ * 사용자 함수(query/mutation)에 주입되는 컨텍스트.
33
+ * Convex의 ctx 패턴과 동일하게, 이 객체를 통해서만 DB/Storage/Auth에 접근 가능.
34
+ * fs, child_process 등 원시 Node.js API는 노출되지 않음.
35
+ */
36
+ export interface GencowCtx {
37
+ /** Drizzle DB 인스턴스 — ctx.db.select().from(table) */
38
+ db: any;
39
+ /** 인증 컨텍스트 — ctx.auth.getUserIdentity() */
40
+ auth: AuthCtx;
41
+ /** 파일 스토리지 — ctx.storage.store(), ctx.storage.getUrl() */
42
+ storage: Storage;
43
+ /** 스케줄러 — ctx.scheduler.runAfter(), ctx.scheduler.cron() */
44
+ scheduler: Scheduler;
45
+ /** 실시간 push — ctx.realtime.emit(queryKey, data) */
46
+ realtime: RealtimeCtx;
47
+ /** 재시도 — ctx.retry(fn, opts) — exponential backoff + jitter */
48
+ retry: <T>(fn: () => Promise<T>, options?: import("./retry").RetryOptions) => Promise<T>;
49
+ }
50
+ type QueryHandler<TArgs = any, TReturn = any> = (ctx: GencowCtx, args: TArgs) => Promise<TReturn>;
51
+ type MutationHandler<TArgs = any, TReturn = any> = (ctx: GencowCtx, args: TArgs) => Promise<TReturn>;
52
+ export interface QueryDef<TSchema = any, TReturn = any> {
53
+ key: string;
54
+ handler: QueryHandler<InferArgs<TSchema>, TReturn>;
55
+ argsSchema?: TSchema;
56
+ _args?: InferArgs<TSchema>;
57
+ _return?: TReturn;
58
+ }
59
+ export interface MutationDef<TSchema = any, TReturn = any> {
60
+ invalidates: string[];
61
+ handler: MutationHandler<InferArgs<TSchema>, TReturn>;
62
+ argsSchema?: TSchema;
63
+ _args?: InferArgs<TSchema>;
64
+ _return?: TReturn;
65
+ }
66
+ export declare function query<TSchema = any, TReturn = any>(key: string, handlerOrDef: QueryHandler<InferArgs<TSchema>, TReturn> | {
67
+ args?: TSchema;
68
+ handler: QueryHandler<InferArgs<TSchema>, TReturn>;
69
+ }): QueryDef<TSchema, TReturn>;
70
+ export declare function mutation<TSchema = any, TReturn = any>(invalidatesOrDef: string[] | {
71
+ name?: string;
72
+ args?: TSchema;
73
+ invalidates: string[];
74
+ handler: MutationHandler<InferArgs<TSchema>, TReturn>;
75
+ }, handler?: MutationHandler<InferArgs<TSchema>, TReturn>, name?: string): MutationDef<TSchema, TReturn>;
76
+ export declare function subscribe(queryKey: string, ws: WSContext): void;
77
+ export declare function unsubscribe(ws: WSContext): void;
78
+ /** Register a raw WS connection without any query subscription (e.g. admin dashboard) */
79
+ export declare function registerClient(ws: WSContext): void;
80
+ export declare function deregisterClient(ws: WSContext): void;
81
+ /**
82
+ * After a mutation, re-run invalidated queries and push results
83
+ * to all subscribers — Convex의 자동 reactive 업데이트 재현
84
+ */
85
+ /**
86
+ * mutation 실행 시점에 생성되는 RealtimeCtx.
87
+ * emit() 호출 시 해당 queryKey를 구독 중인 WebSocket 클라이언트들에게
88
+ * data를 즉시 push합니다.
89
+ *
90
+ * 💡 Batching: 같은 queryKey에 대한 emit이 50ms 내에 여러 번 호출되면
91
+ * 마지막 데이터만 push하여 불필요한 전송을 방지합니다.
92
+ *
93
+ * ⚠️ 매 mutation 호출마다 새로 생성해야 합니다 (debounce timer가 mutation scope에 격리).
94
+ */
95
+ export declare function buildRealtimeCtx(): RealtimeCtx;
96
+ /**
97
+ * mutation이 끝난 후 호출되는 legacy fallback.
98
+ * `ctx.realtime.emit()`을 사용하는 새 mutation에서는 빈 배열([])을 전달하면 됩니다.
99
+ *
100
+ * invalidate 신호만 broadcast하여 클라이언트가 re-fetch 여부를 결정하게 합니다.
101
+ * (서버에서 쿼리를 재실행하지 않으므로 DB 부하 없음)
102
+ *
103
+ * @deprecated ctx.realtime.emit() 사용 권장
104
+ */
105
+ export declare function invalidateQueries(queryKeys: string[], ctx: GencowCtx): Promise<void>;
106
+ export declare function handleWsMessage(ws: WSContext, raw: string | ArrayBuffer): void;
107
+ export declare function getQueryHandler(key: string): QueryHandler | undefined;
108
+ export declare function getQueryDef(key: string): QueryDef | undefined;
109
+ export declare function getRegisteredQueries(): string[];
110
+ export declare function getRegisteredMutations(): (MutationDef & {
111
+ name: string;
112
+ })[];
113
+ export {};
@@ -0,0 +1,175 @@
1
+ // ─── Registry ───────────────────────────────────────────
2
+ const queryRegistry = new Map();
3
+ const mutationRegistry = [];
4
+ const subscribers = new Map();
5
+ /**
6
+ * Every WebSocket client that ever establishes a connection is tracked here.
7
+ * This allows broadcasting invalidation events to clients that never subscribed
8
+ * to a specific query (e.g. the admin dashboard's raw WebSocket connection).
9
+ */
10
+ const connectedClients = new Set();
11
+ // ─── Public API (Convex-style) ──────────────────────────
12
+ export function query(key, handlerOrDef) {
13
+ let handler;
14
+ let argsSchema;
15
+ if (typeof handlerOrDef === "function") {
16
+ handler = handlerOrDef;
17
+ }
18
+ else {
19
+ handler = handlerOrDef.handler;
20
+ argsSchema = handlerOrDef.args;
21
+ }
22
+ const def = { key, handler, argsSchema };
23
+ queryRegistry.set(key, def);
24
+ return def;
25
+ }
26
+ let mutationCounter = 0;
27
+ export function mutation(invalidatesOrDef, handler, name) {
28
+ let invalidates;
29
+ let argsSchema;
30
+ let actualHandler;
31
+ let mutName;
32
+ if (Array.isArray(invalidatesOrDef)) {
33
+ // Legacy style: mutation([...], handler, "name")
34
+ invalidates = invalidatesOrDef;
35
+ actualHandler = handler;
36
+ mutName = name || `mutation_${++mutationCounter}`;
37
+ }
38
+ else {
39
+ // New object style: mutation({ name?, invalidates, args?, handler })
40
+ invalidates = invalidatesOrDef.invalidates;
41
+ actualHandler = invalidatesOrDef.handler;
42
+ argsSchema = invalidatesOrDef.args;
43
+ mutName = invalidatesOrDef.name || name || `mutation_${++mutationCounter}`;
44
+ }
45
+ const def = {
46
+ name: mutName,
47
+ invalidates,
48
+ handler: actualHandler,
49
+ argsSchema
50
+ };
51
+ mutationRegistry.push(def);
52
+ return def;
53
+ }
54
+ // ─── WebSocket subscription management ──────────────────
55
+ export function subscribe(queryKey, ws) {
56
+ connectedClients.add(ws);
57
+ if (!subscribers.has(queryKey)) {
58
+ subscribers.set(queryKey, new Set());
59
+ }
60
+ subscribers.get(queryKey).add(ws);
61
+ }
62
+ export function unsubscribe(ws) {
63
+ connectedClients.delete(ws);
64
+ for (const clients of subscribers.values()) {
65
+ clients.delete(ws);
66
+ }
67
+ }
68
+ /** Register a raw WS connection without any query subscription (e.g. admin dashboard) */
69
+ export function registerClient(ws) {
70
+ connectedClients.add(ws);
71
+ }
72
+ export function deregisterClient(ws) {
73
+ connectedClients.delete(ws);
74
+ for (const clients of subscribers.values()) {
75
+ clients.delete(ws);
76
+ }
77
+ }
78
+ /**
79
+ * After a mutation, re-run invalidated queries and push results
80
+ * to all subscribers — Convex의 자동 reactive 업데이트 재현
81
+ */
82
+ /**
83
+ * mutation 실행 시점에 생성되는 RealtimeCtx.
84
+ * emit() 호출 시 해당 queryKey를 구독 중인 WebSocket 클라이언트들에게
85
+ * data를 즉시 push합니다.
86
+ *
87
+ * 💡 Batching: 같은 queryKey에 대한 emit이 50ms 내에 여러 번 호출되면
88
+ * 마지막 데이터만 push하여 불필요한 전송을 방지합니다.
89
+ *
90
+ * ⚠️ 매 mutation 호출마다 새로 생성해야 합니다 (debounce timer가 mutation scope에 격리).
91
+ */
92
+ export function buildRealtimeCtx() {
93
+ const pendingEmits = new Map();
94
+ return {
95
+ emit(queryKey, data) {
96
+ // 기존 pending timer가 있으면 취소 (debounce)
97
+ const existing = pendingEmits.get(queryKey);
98
+ if (existing)
99
+ clearTimeout(existing.timer);
100
+ const timer = setTimeout(() => {
101
+ pendingEmits.delete(queryKey);
102
+ const clients = subscribers.get(queryKey);
103
+ if (!clients || clients.size === 0)
104
+ return;
105
+ const message = JSON.stringify({
106
+ type: "query:updated",
107
+ query: queryKey,
108
+ data,
109
+ });
110
+ for (const ws of clients) {
111
+ try {
112
+ ws.send(message);
113
+ }
114
+ catch {
115
+ clients.delete(ws);
116
+ }
117
+ }
118
+ }, 50); // 50ms batch window
119
+ pendingEmits.set(queryKey, { data, timer });
120
+ }
121
+ };
122
+ }
123
+ /**
124
+ * mutation이 끝난 후 호출되는 legacy fallback.
125
+ * `ctx.realtime.emit()`을 사용하는 새 mutation에서는 빈 배열([])을 전달하면 됩니다.
126
+ *
127
+ * invalidate 신호만 broadcast하여 클라이언트가 re-fetch 여부를 결정하게 합니다.
128
+ * (서버에서 쿼리를 재실행하지 않으므로 DB 부하 없음)
129
+ *
130
+ * @deprecated ctx.realtime.emit() 사용 권장
131
+ */
132
+ export async function invalidateQueries(queryKeys, ctx) {
133
+ if (queryKeys.length === 0)
134
+ return; // emit() 방식에서는 no-op
135
+ // invalidate 신호만 broadcast (re-fetch는 클라이언트 결정에 맡김)
136
+ const invalidateMsg = JSON.stringify({ type: "invalidate", queries: queryKeys });
137
+ for (const ws of connectedClients) {
138
+ try {
139
+ ws.send(invalidateMsg);
140
+ }
141
+ catch {
142
+ connectedClients.delete(ws);
143
+ }
144
+ }
145
+ }
146
+ // ─── WebSocket message handler ──────────────────────────
147
+ export function handleWsMessage(ws, raw) {
148
+ try {
149
+ const msg = typeof raw === "string" ? JSON.parse(raw) : JSON.parse(raw.toString());
150
+ if (msg.type === "subscribe" && msg.query) {
151
+ subscribe(msg.query, ws);
152
+ ws.send(JSON.stringify({ type: "subscribed", query: msg.query }));
153
+ }
154
+ if (msg.type === "unsubscribe" && msg.query) {
155
+ const clients = subscribers.get(msg.query);
156
+ if (clients)
157
+ clients.delete(ws);
158
+ }
159
+ }
160
+ catch {
161
+ // ignore malformed messages
162
+ }
163
+ }
164
+ export function getQueryHandler(key) {
165
+ return queryRegistry.get(key)?.handler;
166
+ }
167
+ export function getQueryDef(key) {
168
+ return queryRegistry.get(key);
169
+ }
170
+ export function getRegisteredQueries() {
171
+ return Array.from(queryRegistry.keys());
172
+ }
173
+ export function getRegisteredMutations() {
174
+ return [...mutationRegistry];
175
+ }