@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/src/crons.ts ADDED
@@ -0,0 +1,131 @@
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
+ export interface CronJobDef {
22
+ /** 크론 잡 이름 */
23
+ name: string;
24
+ /** cron 표현식 (예: "0 2 * * *") */
25
+ pattern: string;
26
+ /** 실행할 액션 또는 핸들러 */
27
+ action: string | (() => Promise<void>);
28
+ }
29
+
30
+ export interface IntervalOptions {
31
+ /** 분 단위 */
32
+ minutes?: number;
33
+ /** 시간 단위 */
34
+ hours?: number;
35
+ /** 초 단위 */
36
+ seconds?: number;
37
+ }
38
+
39
+ export interface DailyOptions {
40
+ /** 실행 시각 (0-23, 로컬 시간) */
41
+ hour: number;
42
+ /** 분 (0-59, 기본 0) */
43
+ minute?: number;
44
+ }
45
+
46
+ export interface WeeklyOptions {
47
+ /** 요일 (0=일, 1=월, ..., 6=토) */
48
+ dayOfWeek: number;
49
+ /** 시각 (0-23) */
50
+ hour: number;
51
+ /** 분 (0-59, 기본 0) */
52
+ minute?: number;
53
+ }
54
+
55
+ export interface CronJobsBuilder {
56
+ /** 일정 간격으로 실행 */
57
+ interval(name: string, options: IntervalOptions, action: string | (() => Promise<void>)): CronJobsBuilder;
58
+ /** 매일 특정 시각에 실행 */
59
+ daily(name: string, options: DailyOptions, action: string | (() => Promise<void>)): CronJobsBuilder;
60
+ /** 매주 특정 요일/시각에 실행 */
61
+ weekly(name: string, options: WeeklyOptions, action: string | (() => Promise<void>)): CronJobsBuilder;
62
+ /** cron 표현식으로 직접 지정 */
63
+ cron(name: string, pattern: string, action: string | (() => Promise<void>)): CronJobsBuilder;
64
+ /** 등록된 크론 잡 목록 (서버 내부에서 사용) */
65
+ getJobs(): CronJobDef[];
66
+ }
67
+
68
+ // ─── 빌더 구현 ──────────────────────────────────────────
69
+
70
+ /**
71
+ * 선언적 크론 잡 빌더
72
+ *
73
+ * @example
74
+ * const crons = cronJobs();
75
+ * crons.interval("sync", { minutes: 15 }, "metrics.collect");
76
+ * crons.daily("report", { hour: 9 }, "reports.generate");
77
+ * crons.cron("custom", "0 30 * * *", "custom.handler");
78
+ * export default crons;
79
+ */
80
+ export function cronJobs(): CronJobsBuilder {
81
+ const jobs: CronJobDef[] = [];
82
+
83
+ const builder: CronJobsBuilder = {
84
+ interval(name, options, action) {
85
+ const pattern = intervalToPattern(options);
86
+ jobs.push({ name, pattern, action });
87
+ return builder;
88
+ },
89
+
90
+ daily(name, options, action) {
91
+ const minute = options.minute ?? 0;
92
+ const pattern = `${minute} ${options.hour} * * *`;
93
+ jobs.push({ name, pattern, action });
94
+ return builder;
95
+ },
96
+
97
+ weekly(name, options, action) {
98
+ const minute = options.minute ?? 0;
99
+ const pattern = `${minute} ${options.hour} * * ${options.dayOfWeek}`;
100
+ jobs.push({ name, pattern, action });
101
+ return builder;
102
+ },
103
+
104
+ cron(name, pattern, action) {
105
+ jobs.push({ name, pattern, action });
106
+ return builder;
107
+ },
108
+
109
+ getJobs() {
110
+ return [...jobs];
111
+ },
112
+ };
113
+
114
+ return builder;
115
+ }
116
+
117
+ // ─── 유틸리티 ───────────────────────────────────────────
118
+
119
+ function intervalToPattern(options: IntervalOptions): string {
120
+ if (options.seconds) {
121
+ // node-cron은 초 단위 지원: "*/N * * * * *"
122
+ return `*/${options.seconds} * * * * *`;
123
+ }
124
+ if (options.minutes) {
125
+ return `*/${options.minutes} * * * *`;
126
+ }
127
+ if (options.hours) {
128
+ return `0 */${options.hours} * * *`;
129
+ }
130
+ throw new Error("interval: minutes, hours, 또는 seconds 중 하나를 지정하세요");
131
+ }
package/src/db.ts ADDED
@@ -0,0 +1,18 @@
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
+
9
+ let pgliteInstance: PGlite | null = null;
10
+
11
+ /** @deprecated Use ctx.db instead */
12
+ export async function createDb(dataDir: string = "./data") {
13
+ if (!pgliteInstance) {
14
+ pgliteInstance = new PGlite(dataDir);
15
+ }
16
+ const db = drizzle(pgliteInstance);
17
+ return { db, client: pgliteInstance };
18
+ }
package/src/index.ts ADDED
@@ -0,0 +1,20 @@
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
+
8
+ export type { GencowCtx, AuthCtx, UserIdentity, QueryDef, MutationDef, RealtimeCtx } from "./reactive";
9
+ export { query, mutation, invalidateQueries, buildRealtimeCtx, subscribe, unsubscribe, registerClient, deregisterClient, handleWsMessage, getQueryHandler, getQueryDef, getRegisteredQueries, getRegisteredMutations } from "./reactive";
10
+ export type { Storage } from "./storage";
11
+ export { createScheduler, getSchedulerInfo } from "./scheduler";
12
+ export type { Scheduler } from "./scheduler";
13
+ export { v, parseArgs, GencowValidationError } from "./v";
14
+ export type { Validator, Infer, InferArgs } from "./v";
15
+ export { withRetry } from "./retry";
16
+ export type { RetryOptions } from "./retry";
17
+ export { cronJobs } from "./crons";
18
+ export type { CronJobsBuilder, CronJobDef, IntervalOptions, DailyOptions, WeeklyOptions } from "./crons";
19
+
20
+
@@ -0,0 +1,280 @@
1
+ import type { WSContext } from "hono/ws";
2
+ import type { Storage } from "./storage";
3
+ import type { Scheduler } from "./scheduler";
4
+ import { type Validator, type InferArgs } from "./v";
5
+
6
+ // ─── GencowCtx — 사용자 함수에 주입되는 컨텍스트 ──────────
7
+
8
+ export interface UserIdentity {
9
+ id: string;
10
+ email: string;
11
+ name?: string;
12
+ }
13
+
14
+ export interface AuthCtx {
15
+ /** 현재 유저 반환 (비로그인 시 null) — Convex의 ctx.auth.getUserIdentity() */
16
+ getUserIdentity(): UserIdentity | null;
17
+ /** 현재 유저 반환 (비로그인 시 401 throw) */
18
+ requireAuth(): UserIdentity;
19
+ }
20
+
21
+ /**
22
+ * mutation handler 내에서 구독자들에게 데이터를 즉시 push합니다.
23
+ * 클라이언트는 이 데이터를 받아 re-fetch 없이 state를 업데이트합니다.
24
+ */
25
+ export interface RealtimeCtx {
26
+ /**
27
+ * @param queryKey - 업데이트할 쿼리 키 (예: "tasks.list")
28
+ * @param data - push할 데이터 (해당 query 결과와 동일한 타입)
29
+ *
30
+ * @example
31
+ * const freshList = await ctx.db.select().from(tasks);
32
+ * ctx.realtime.emit("tasks.list", freshList);
33
+ */
34
+ emit(queryKey: string, data: unknown): void;
35
+ }
36
+
37
+ /**
38
+ * 사용자 함수(query/mutation)에 주입되는 컨텍스트.
39
+ * Convex의 ctx 패턴과 동일하게, 이 객체를 통해서만 DB/Storage/Auth에 접근 가능.
40
+ * fs, child_process 등 원시 Node.js API는 노출되지 않음.
41
+ */
42
+ export interface GencowCtx {
43
+ /** Drizzle DB 인스턴스 — ctx.db.select().from(table) */
44
+ db: any; // typed per-app via generic
45
+ /** 인증 컨텍스트 — ctx.auth.getUserIdentity() */
46
+ auth: AuthCtx;
47
+ /** 파일 스토리지 — ctx.storage.store(), ctx.storage.getUrl() */
48
+ storage: Storage;
49
+ /** 스케줄러 — ctx.scheduler.runAfter(), ctx.scheduler.cron() */
50
+ scheduler: Scheduler;
51
+ /** 실시간 push — ctx.realtime.emit(queryKey, data) */
52
+ realtime: RealtimeCtx;
53
+ /** 재시도 — ctx.retry(fn, opts) — exponential backoff + jitter */
54
+ retry: <T>(fn: () => Promise<T>, options?: import("./retry").RetryOptions) => Promise<T>;
55
+ }
56
+
57
+ // ─── Types ──────────────────────────────────────────────
58
+
59
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
60
+ type QueryHandler<TArgs = any, TReturn = any> = (ctx: GencowCtx, args: TArgs) => Promise<TReturn>;
61
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
62
+ type MutationHandler<TArgs = any, TReturn = any> = (ctx: GencowCtx, args: TArgs) => Promise<TReturn>;
63
+
64
+ export interface QueryDef<TSchema = any, TReturn = any> {
65
+ key: string;
66
+ handler: QueryHandler<InferArgs<TSchema>, TReturn>;
67
+ argsSchema?: TSchema;
68
+ _args?: InferArgs<TSchema>;
69
+ _return?: TReturn;
70
+ }
71
+
72
+ export interface MutationDef<TSchema = any, TReturn = any> {
73
+ invalidates: string[];
74
+ handler: MutationHandler<InferArgs<TSchema>, TReturn>;
75
+ argsSchema?: TSchema;
76
+ _args?: InferArgs<TSchema>;
77
+ _return?: TReturn;
78
+ }
79
+
80
+ // ─── Registry ───────────────────────────────────────────
81
+
82
+ const queryRegistry = new Map<string, QueryDef<any, any>>();
83
+ const mutationRegistry: (MutationDef<any, any> & { name: string })[] = [];
84
+ const subscribers = new Map<string, Set<WSContext>>();
85
+
86
+ /**
87
+ * Every WebSocket client that ever establishes a connection is tracked here.
88
+ * This allows broadcasting invalidation events to clients that never subscribed
89
+ * to a specific query (e.g. the admin dashboard's raw WebSocket connection).
90
+ */
91
+ const connectedClients = new Set<WSContext>();
92
+
93
+ // ─── Public API (Convex-style) ──────────────────────────
94
+
95
+ export function query<TSchema = any, TReturn = any>(
96
+ key: string,
97
+ handlerOrDef: QueryHandler<InferArgs<TSchema>, TReturn> | { args?: TSchema; handler: QueryHandler<InferArgs<TSchema>, TReturn> }
98
+ ): QueryDef<TSchema, TReturn> {
99
+ let handler: QueryHandler<InferArgs<TSchema>, TReturn>;
100
+ let argsSchema: TSchema | undefined;
101
+
102
+ if (typeof handlerOrDef === "function") {
103
+ handler = handlerOrDef;
104
+ } else {
105
+ handler = handlerOrDef.handler;
106
+ argsSchema = handlerOrDef.args;
107
+ }
108
+
109
+ const def: QueryDef<TSchema, TReturn> = { key, handler, argsSchema };
110
+ queryRegistry.set(key, def);
111
+ return def;
112
+ }
113
+
114
+ let mutationCounter = 0;
115
+
116
+ export function mutation<TSchema = any, TReturn = any>(
117
+ invalidatesOrDef: string[] | { name?: string; args?: TSchema; invalidates: string[]; handler: MutationHandler<InferArgs<TSchema>, TReturn> },
118
+ handler?: MutationHandler<InferArgs<TSchema>, TReturn>,
119
+ name?: string
120
+ ): MutationDef<TSchema, TReturn> {
121
+ let invalidates: string[];
122
+ let argsSchema: TSchema | undefined;
123
+ let actualHandler: MutationHandler<InferArgs<TSchema>, TReturn>;
124
+ let mutName: string;
125
+
126
+ if (Array.isArray(invalidatesOrDef)) {
127
+ // Legacy style: mutation([...], handler, "name")
128
+ invalidates = invalidatesOrDef;
129
+ actualHandler = handler!;
130
+ mutName = name || `mutation_${++mutationCounter}`;
131
+ } else {
132
+ // New object style: mutation({ name?, invalidates, args?, handler })
133
+ invalidates = invalidatesOrDef.invalidates;
134
+ actualHandler = invalidatesOrDef.handler;
135
+ argsSchema = invalidatesOrDef.args;
136
+ mutName = invalidatesOrDef.name || name || `mutation_${++mutationCounter}`;
137
+ }
138
+ const def: MutationDef<TSchema, TReturn> & { name: string } = {
139
+ name: mutName,
140
+ invalidates,
141
+ handler: actualHandler,
142
+ argsSchema
143
+ };
144
+ mutationRegistry.push(def);
145
+ return def;
146
+ }
147
+
148
+ // ─── WebSocket subscription management ──────────────────
149
+
150
+ export function subscribe(queryKey: string, ws: WSContext) {
151
+ connectedClients.add(ws);
152
+ if (!subscribers.has(queryKey)) {
153
+ subscribers.set(queryKey, new Set());
154
+ }
155
+ subscribers.get(queryKey)!.add(ws);
156
+ }
157
+
158
+ export function unsubscribe(ws: WSContext) {
159
+ connectedClients.delete(ws);
160
+ for (const clients of subscribers.values()) {
161
+ clients.delete(ws);
162
+ }
163
+ }
164
+
165
+ /** Register a raw WS connection without any query subscription (e.g. admin dashboard) */
166
+ export function registerClient(ws: WSContext) {
167
+ connectedClients.add(ws);
168
+ }
169
+
170
+ export function deregisterClient(ws: WSContext) {
171
+ connectedClients.delete(ws);
172
+ for (const clients of subscribers.values()) {
173
+ clients.delete(ws);
174
+ }
175
+ }
176
+
177
+ /**
178
+ * After a mutation, re-run invalidated queries and push results
179
+ * to all subscribers — Convex의 자동 reactive 업데이트 재현
180
+ */
181
+ /**
182
+ * mutation 실행 시점에 생성되는 RealtimeCtx.
183
+ * emit() 호출 시 해당 queryKey를 구독 중인 WebSocket 클라이언트들에게
184
+ * data를 즉시 push합니다.
185
+ *
186
+ * 💡 Batching: 같은 queryKey에 대한 emit이 50ms 내에 여러 번 호출되면
187
+ * 마지막 데이터만 push하여 불필요한 전송을 방지합니다.
188
+ *
189
+ * ⚠️ 매 mutation 호출마다 새로 생성해야 합니다 (debounce timer가 mutation scope에 격리).
190
+ */
191
+ export function buildRealtimeCtx(): RealtimeCtx {
192
+ const pendingEmits = new Map<string, { data: unknown; timer: ReturnType<typeof setTimeout> }>();
193
+
194
+ return {
195
+ emit(queryKey: string, data: unknown) {
196
+ // 기존 pending timer가 있으면 취소 (debounce)
197
+ const existing = pendingEmits.get(queryKey);
198
+ if (existing) clearTimeout(existing.timer);
199
+
200
+ const timer = setTimeout(() => {
201
+ pendingEmits.delete(queryKey);
202
+ const clients = subscribers.get(queryKey);
203
+ if (!clients || clients.size === 0) return;
204
+
205
+ const message = JSON.stringify({
206
+ type: "query:updated",
207
+ query: queryKey,
208
+ data,
209
+ });
210
+ for (const ws of clients) {
211
+ try { ws.send(message); } catch { clients.delete(ws); }
212
+ }
213
+ }, 50); // 50ms batch window
214
+
215
+ pendingEmits.set(queryKey, { data, timer });
216
+ }
217
+ };
218
+ }
219
+
220
+ /**
221
+ * mutation이 끝난 후 호출되는 legacy fallback.
222
+ * `ctx.realtime.emit()`을 사용하는 새 mutation에서는 빈 배열([])을 전달하면 됩니다.
223
+ *
224
+ * invalidate 신호만 broadcast하여 클라이언트가 re-fetch 여부를 결정하게 합니다.
225
+ * (서버에서 쿼리를 재실행하지 않으므로 DB 부하 없음)
226
+ *
227
+ * @deprecated ctx.realtime.emit() 사용 권장
228
+ */
229
+ export async function invalidateQueries(
230
+ queryKeys: string[],
231
+ ctx: GencowCtx
232
+ ): Promise<void> {
233
+ if (queryKeys.length === 0) return; // emit() 방식에서는 no-op
234
+
235
+ // invalidate 신호만 broadcast (re-fetch는 클라이언트 결정에 맡김)
236
+ const invalidateMsg = JSON.stringify({ type: "invalidate", queries: queryKeys });
237
+ for (const ws of connectedClients) {
238
+ try { ws.send(invalidateMsg); } catch { connectedClients.delete(ws); }
239
+ }
240
+ }
241
+
242
+
243
+ // ─── WebSocket message handler ──────────────────────────
244
+
245
+ export function handleWsMessage(ws: WSContext, raw: string | ArrayBuffer) {
246
+ try {
247
+ const msg =
248
+ typeof raw === "string" ? JSON.parse(raw) : JSON.parse(raw.toString());
249
+
250
+ if (msg.type === "subscribe" && msg.query) {
251
+ subscribe(msg.query, ws);
252
+ ws.send(
253
+ JSON.stringify({ type: "subscribed", query: msg.query })
254
+ );
255
+ }
256
+
257
+ if (msg.type === "unsubscribe" && msg.query) {
258
+ const clients = subscribers.get(msg.query);
259
+ if (clients) clients.delete(ws);
260
+ }
261
+ } catch {
262
+ // ignore malformed messages
263
+ }
264
+ }
265
+
266
+ export function getQueryHandler(key: string): QueryHandler | undefined {
267
+ return queryRegistry.get(key)?.handler;
268
+ }
269
+
270
+ export function getQueryDef(key: string): QueryDef | undefined {
271
+ return queryRegistry.get(key);
272
+ }
273
+
274
+ export function getRegisteredQueries(): string[] {
275
+ return Array.from(queryRegistry.keys());
276
+ }
277
+
278
+ export function getRegisteredMutations(): (MutationDef & { name: string })[] {
279
+ return [...mutationRegistry];
280
+ }
package/src/retry.ts ADDED
@@ -0,0 +1,112 @@
1
+ /**
2
+ * packages/core/src/retry.ts
3
+ *
4
+ * Retry 유틸리티 — exponential backoff + jitter
5
+ *
6
+ * 외부 API(LLM, 결제, 이메일 등) 호출 실패 시 자동 재시도.
7
+ * ctx.retry() 또는 독립 함수 withRetry()로 사용 가능.
8
+ *
9
+ * @example
10
+ * // 독립 함수
11
+ * import { withRetry } from "@gencow/core";
12
+ * const result = await withRetry(() => fetch("https://api.example.com"), {
13
+ * maxAttempts: 5,
14
+ * initialBackoffMs: 500,
15
+ * });
16
+ *
17
+ * // ctx에 내장
18
+ * export const charge = mutation({
19
+ * handler: async (ctx, args) => {
20
+ * const result = await ctx.retry(
21
+ * () => stripe.charges.create({ amount: args.amount }),
22
+ * { maxAttempts: 3, initialBackoffMs: 500 },
23
+ * );
24
+ * return result;
25
+ * },
26
+ * });
27
+ */
28
+
29
+ // ─── 타입 ───────────────────────────────────────────────
30
+
31
+ export interface RetryOptions {
32
+ /** 최대 시도 횟수 (기본 3) */
33
+ maxAttempts?: number;
34
+ /** 첫 번째 재시도 대기 시간 (ms, 기본 1000) */
35
+ initialBackoffMs?: number;
36
+ /** backoff 배수 (기본 2 → 1s, 2s, 4s, 8s, ...) */
37
+ base?: number;
38
+ /** 최대 대기 시간 (ms, 기본 30000) */
39
+ maxBackoffMs?: number;
40
+ /** 재시도할 에러인지 판단 (기본: 모든 에러 재시도) */
41
+ shouldRetry?: (error: unknown, attempt: number) => boolean;
42
+ /** 재시도 시 호출되는 콜백 (로깅용) */
43
+ onRetry?: (error: unknown, attempt: number, delayMs: number) => void;
44
+ }
45
+
46
+ // ─── 기본값 ─────────────────────────────────────────────
47
+
48
+ const DEFAULT_MAX_ATTEMPTS = 3;
49
+ const DEFAULT_INITIAL_BACKOFF_MS = 1000;
50
+ const DEFAULT_BASE = 2;
51
+ const DEFAULT_MAX_BACKOFF_MS = 30_000;
52
+ const JITTER_MIN = 0.75;
53
+ const JITTER_MAX = 1.25;
54
+
55
+ // ─── 핵심 함수 ──────────────────────────────────────────
56
+
57
+ /**
58
+ * 재시도 가능한 비동기 함수 실행
59
+ *
60
+ * Exponential backoff + jitter로 재시도.
61
+ * delay = min(initialBackoffMs × base^attempt × jitter, maxBackoffMs)
62
+ */
63
+ export async function withRetry<T>(
64
+ fn: () => Promise<T>,
65
+ options: RetryOptions = {},
66
+ ): Promise<T> {
67
+ const {
68
+ maxAttempts = DEFAULT_MAX_ATTEMPTS,
69
+ initialBackoffMs = DEFAULT_INITIAL_BACKOFF_MS,
70
+ base = DEFAULT_BASE,
71
+ maxBackoffMs = DEFAULT_MAX_BACKOFF_MS,
72
+ shouldRetry,
73
+ onRetry,
74
+ } = options;
75
+
76
+ if (maxAttempts < 1) {
77
+ throw new Error(`withRetry: maxAttempts must be >= 1, got ${maxAttempts}`);
78
+ }
79
+
80
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
81
+ try {
82
+ return await fn();
83
+ } catch (error: unknown) {
84
+ const isLastAttempt = attempt === maxAttempts - 1;
85
+
86
+ // 마지막 시도면 에러 throw
87
+ if (isLastAttempt) throw error;
88
+
89
+ // shouldRetry가 있으면 판단
90
+ if (shouldRetry && !shouldRetry(error, attempt)) throw error;
91
+
92
+ // 딜레이 계산: exponential backoff + jitter
93
+ const jitter = JITTER_MIN + Math.random() * (JITTER_MAX - JITTER_MIN);
94
+ const rawDelay = initialBackoffMs * Math.pow(base, attempt) * jitter;
95
+ const delay = Math.min(rawDelay, maxBackoffMs);
96
+
97
+ // 콜백 호출
98
+ if (onRetry) onRetry(error, attempt + 1, delay);
99
+
100
+ await sleep(delay);
101
+ }
102
+ }
103
+
104
+ // TypeScript를 위한 unreachable — 실제로 여기에 도달하지 않음
105
+ throw new Error("withRetry: unreachable");
106
+ }
107
+
108
+ // ─── 내부 유틸리티 ──────────────────────────────────────
109
+
110
+ function sleep(ms: number): Promise<void> {
111
+ return new Promise(resolve => setTimeout(resolve, ms));
112
+ }
@@ -0,0 +1,129 @@
1
+ import * as cron from "node-cron";
2
+
3
+ // ─── Types ──────────────────────────────────────────────
4
+
5
+ type ActionHandler = (args: any) => Promise<any>;
6
+
7
+ export interface Scheduler {
8
+ /** Schedule a function to run after a delay — Convex의 ctx.scheduler.runAfter() */
9
+ runAfter(ms: number, action: string, args?: any): string;
10
+ /** Schedule a function at a specific time — Convex의 ctx.scheduler.runAt() */
11
+ runAt(timestamp: number | Date, action: string, args?: any): string;
12
+ /** Cancel a scheduled function */
13
+ cancel(jobId: string): boolean;
14
+ /** Register a cron job — Convex의 cronJobs() */
15
+ cron(name: string, pattern: string, handler: () => Promise<void>): void;
16
+ /** Register an action handler */
17
+ registerAction(name: string, handler: ActionHandler): void;
18
+ }
19
+
20
+ // ─── Implementation ─────────────────────────────────────
21
+
22
+ /**
23
+ * Create a scheduler instance — Convex scheduler 패턴 재현
24
+ *
25
+ * @example
26
+ * const scheduler = createScheduler();
27
+ *
28
+ * // Register actions
29
+ * scheduler.registerAction('emails.send', async (args) => { ... });
30
+ *
31
+ * // Schedule (Convex-style)
32
+ * scheduler.runAfter(5 * 60 * 1000, 'emails.send', { to: 'user@test.com' });
33
+ *
34
+ * // Cron (Convex-style)
35
+ * scheduler.cron('daily-cleanup', '0 2 * * *', async () => { ... });
36
+ */
37
+ // Module-level state for dashboard introspection
38
+ const _cronInfo: { name: string; pattern: string; registeredAt: string }[] = [];
39
+ const _pendingJobs: { id: string; action: string; scheduledAt: string }[] = [];
40
+
41
+ export function getSchedulerInfo() {
42
+ return {
43
+ crons: _cronInfo,
44
+ pendingJobs: _pendingJobs,
45
+ };
46
+ }
47
+
48
+ export function createScheduler(): Scheduler {
49
+ const timers = new Map<string, NodeJS.Timeout>();
50
+ const cronJobs = new Map<string, cron.ScheduledTask>();
51
+ const actions = new Map<string, ActionHandler>();
52
+
53
+ let jobCounter = 0;
54
+
55
+ function generateId(): string {
56
+ return `job_${++jobCounter}_${Date.now()}`;
57
+ }
58
+
59
+ async function executeAction(action: string, args: any) {
60
+ const handler = actions.get(action);
61
+ if (!handler) {
62
+ console.error(`[scheduler] Action "${action}" not registered`);
63
+ return;
64
+ }
65
+ try {
66
+ await handler(args);
67
+ console.log(`[scheduler] Action "${action}" completed`);
68
+ } catch (error) {
69
+ console.error(`[scheduler] Action "${action}" failed:`, error);
70
+ }
71
+ }
72
+
73
+ return {
74
+ runAfter(ms: number, action: string, args?: any): string {
75
+ const id = generateId();
76
+ _pendingJobs.push({ id, action, scheduledAt: new Date().toISOString() });
77
+ const timer = setTimeout(async () => {
78
+ await executeAction(action, args);
79
+ timers.delete(id);
80
+ const idx = _pendingJobs.findIndex((j) => j.id === id);
81
+ if (idx >= 0) _pendingJobs.splice(idx, 1);
82
+ }, ms);
83
+ timers.set(id, timer);
84
+ console.log(
85
+ `[scheduler] Scheduled "${action}" to run after ${ms}ms (id: ${id})`
86
+ );
87
+ return id;
88
+ },
89
+
90
+ runAt(timestamp: number | Date, action: string, args?: any): string {
91
+ const target =
92
+ timestamp instanceof Date ? timestamp.getTime() : timestamp;
93
+ const ms = Math.max(0, target - Date.now());
94
+ return this.runAfter(ms, action, args);
95
+ },
96
+
97
+ cancel(jobId: string): boolean {
98
+ const timer = timers.get(jobId);
99
+ if (timer) {
100
+ clearTimeout(timer);
101
+ timers.delete(jobId);
102
+ console.log(`[scheduler] Cancelled job ${jobId}`);
103
+ return true;
104
+ }
105
+ return false;
106
+ },
107
+
108
+ cron(name: string, pattern: string, handler: () => Promise<void>): void {
109
+ if (cronJobs.has(name)) {
110
+ cronJobs.get(name)!.stop();
111
+ }
112
+ const task = cron.schedule(pattern, async () => {
113
+ console.log(`[scheduler] Cron "${name}" triggered`);
114
+ try {
115
+ await handler();
116
+ } catch (error) {
117
+ console.error(`[scheduler] Cron "${name}" failed:`, error);
118
+ }
119
+ });
120
+ cronJobs.set(name, task);
121
+ _cronInfo.push({ name, pattern, registeredAt: new Date().toISOString() });
122
+ console.log(`[scheduler] Registered cron "${name}" with pattern "${pattern}"`);
123
+ },
124
+
125
+ registerAction(name: string, handler: ActionHandler): void {
126
+ actions.set(name, handler);
127
+ },
128
+ };
129
+ }