@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 +56 -0
- package/dist/auth.js +107 -0
- package/dist/crons.d.ts +70 -0
- package/dist/crons.js +72 -0
- package/dist/db.d.ts +13 -0
- package/dist/db.js +16 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +11 -0
- package/dist/reactive.d.ts +113 -0
- package/dist/reactive.js +175 -0
- package/dist/retry.d.ts +48 -0
- package/dist/retry.js +75 -0
- package/dist/scheduler.d.ts +27 -0
- package/dist/scheduler.js +100 -0
- package/dist/server.d.ts +11 -0
- package/dist/server.js +11 -0
- package/dist/storage.d.ts +33 -0
- package/dist/storage.js +98 -0
- package/dist/v.d.ts +48 -0
- package/dist/v.js +137 -0
- package/package.json +39 -0
- package/src/__tests__/crons.test.ts +81 -0
- package/src/__tests__/load.test.ts +394 -0
- package/src/__tests__/network-sim.test.ts +296 -0
- package/src/__tests__/reactive.test.ts +172 -0
- package/src/__tests__/retry.test.ts +98 -0
- package/src/__tests__/ws-integration.test.ts +304 -0
- package/src/__tests__/ws-scale.test.ts +232 -0
- package/src/auth.ts +155 -0
- package/src/crons.ts +131 -0
- package/src/db.ts +18 -0
- package/src/index.ts +20 -0
- package/src/reactive.ts +280 -0
- package/src/retry.ts +112 -0
- package/src/scheduler.ts +129 -0
- package/src/server.ts +11 -0
- package/src/storage.ts +149 -0
- package/src/v.ts +158 -0
package/dist/retry.d.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
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
|
+
export interface RetryOptions {
|
|
29
|
+
/** 최대 시도 횟수 (기본 3) */
|
|
30
|
+
maxAttempts?: number;
|
|
31
|
+
/** 첫 번째 재시도 대기 시간 (ms, 기본 1000) */
|
|
32
|
+
initialBackoffMs?: number;
|
|
33
|
+
/** backoff 배수 (기본 2 → 1s, 2s, 4s, 8s, ...) */
|
|
34
|
+
base?: number;
|
|
35
|
+
/** 최대 대기 시간 (ms, 기본 30000) */
|
|
36
|
+
maxBackoffMs?: number;
|
|
37
|
+
/** 재시도할 에러인지 판단 (기본: 모든 에러 재시도) */
|
|
38
|
+
shouldRetry?: (error: unknown, attempt: number) => boolean;
|
|
39
|
+
/** 재시도 시 호출되는 콜백 (로깅용) */
|
|
40
|
+
onRetry?: (error: unknown, attempt: number, delayMs: number) => void;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* 재시도 가능한 비동기 함수 실행
|
|
44
|
+
*
|
|
45
|
+
* Exponential backoff + jitter로 재시도.
|
|
46
|
+
* delay = min(initialBackoffMs × base^attempt × jitter, maxBackoffMs)
|
|
47
|
+
*/
|
|
48
|
+
export declare function withRetry<T>(fn: () => Promise<T>, options?: RetryOptions): Promise<T>;
|
package/dist/retry.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
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
|
+
const DEFAULT_MAX_ATTEMPTS = 3;
|
|
30
|
+
const DEFAULT_INITIAL_BACKOFF_MS = 1000;
|
|
31
|
+
const DEFAULT_BASE = 2;
|
|
32
|
+
const DEFAULT_MAX_BACKOFF_MS = 30_000;
|
|
33
|
+
const JITTER_MIN = 0.75;
|
|
34
|
+
const JITTER_MAX = 1.25;
|
|
35
|
+
// ─── 핵심 함수 ──────────────────────────────────────────
|
|
36
|
+
/**
|
|
37
|
+
* 재시도 가능한 비동기 함수 실행
|
|
38
|
+
*
|
|
39
|
+
* Exponential backoff + jitter로 재시도.
|
|
40
|
+
* delay = min(initialBackoffMs × base^attempt × jitter, maxBackoffMs)
|
|
41
|
+
*/
|
|
42
|
+
export async function withRetry(fn, options = {}) {
|
|
43
|
+
const { maxAttempts = DEFAULT_MAX_ATTEMPTS, initialBackoffMs = DEFAULT_INITIAL_BACKOFF_MS, base = DEFAULT_BASE, maxBackoffMs = DEFAULT_MAX_BACKOFF_MS, shouldRetry, onRetry, } = options;
|
|
44
|
+
if (maxAttempts < 1) {
|
|
45
|
+
throw new Error(`withRetry: maxAttempts must be >= 1, got ${maxAttempts}`);
|
|
46
|
+
}
|
|
47
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
48
|
+
try {
|
|
49
|
+
return await fn();
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
const isLastAttempt = attempt === maxAttempts - 1;
|
|
53
|
+
// 마지막 시도면 에러 throw
|
|
54
|
+
if (isLastAttempt)
|
|
55
|
+
throw error;
|
|
56
|
+
// shouldRetry가 있으면 판단
|
|
57
|
+
if (shouldRetry && !shouldRetry(error, attempt))
|
|
58
|
+
throw error;
|
|
59
|
+
// 딜레이 계산: exponential backoff + jitter
|
|
60
|
+
const jitter = JITTER_MIN + Math.random() * (JITTER_MAX - JITTER_MIN);
|
|
61
|
+
const rawDelay = initialBackoffMs * Math.pow(base, attempt) * jitter;
|
|
62
|
+
const delay = Math.min(rawDelay, maxBackoffMs);
|
|
63
|
+
// 콜백 호출
|
|
64
|
+
if (onRetry)
|
|
65
|
+
onRetry(error, attempt + 1, delay);
|
|
66
|
+
await sleep(delay);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
// TypeScript를 위한 unreachable — 실제로 여기에 도달하지 않음
|
|
70
|
+
throw new Error("withRetry: unreachable");
|
|
71
|
+
}
|
|
72
|
+
// ─── 내부 유틸리티 ──────────────────────────────────────
|
|
73
|
+
function sleep(ms) {
|
|
74
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
75
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
type ActionHandler = (args: any) => Promise<any>;
|
|
2
|
+
export interface Scheduler {
|
|
3
|
+
/** Schedule a function to run after a delay — Convex의 ctx.scheduler.runAfter() */
|
|
4
|
+
runAfter(ms: number, action: string, args?: any): string;
|
|
5
|
+
/** Schedule a function at a specific time — Convex의 ctx.scheduler.runAt() */
|
|
6
|
+
runAt(timestamp: number | Date, action: string, args?: any): string;
|
|
7
|
+
/** Cancel a scheduled function */
|
|
8
|
+
cancel(jobId: string): boolean;
|
|
9
|
+
/** Register a cron job — Convex의 cronJobs() */
|
|
10
|
+
cron(name: string, pattern: string, handler: () => Promise<void>): void;
|
|
11
|
+
/** Register an action handler */
|
|
12
|
+
registerAction(name: string, handler: ActionHandler): void;
|
|
13
|
+
}
|
|
14
|
+
export declare function getSchedulerInfo(): {
|
|
15
|
+
crons: {
|
|
16
|
+
name: string;
|
|
17
|
+
pattern: string;
|
|
18
|
+
registeredAt: string;
|
|
19
|
+
}[];
|
|
20
|
+
pendingJobs: {
|
|
21
|
+
id: string;
|
|
22
|
+
action: string;
|
|
23
|
+
scheduledAt: string;
|
|
24
|
+
}[];
|
|
25
|
+
};
|
|
26
|
+
export declare function createScheduler(): Scheduler;
|
|
27
|
+
export {};
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import * as cron from "node-cron";
|
|
2
|
+
// ─── Implementation ─────────────────────────────────────
|
|
3
|
+
/**
|
|
4
|
+
* Create a scheduler instance — Convex scheduler 패턴 재현
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* const scheduler = createScheduler();
|
|
8
|
+
*
|
|
9
|
+
* // Register actions
|
|
10
|
+
* scheduler.registerAction('emails.send', async (args) => { ... });
|
|
11
|
+
*
|
|
12
|
+
* // Schedule (Convex-style)
|
|
13
|
+
* scheduler.runAfter(5 * 60 * 1000, 'emails.send', { to: 'user@test.com' });
|
|
14
|
+
*
|
|
15
|
+
* // Cron (Convex-style)
|
|
16
|
+
* scheduler.cron('daily-cleanup', '0 2 * * *', async () => { ... });
|
|
17
|
+
*/
|
|
18
|
+
// Module-level state for dashboard introspection
|
|
19
|
+
const _cronInfo = [];
|
|
20
|
+
const _pendingJobs = [];
|
|
21
|
+
export function getSchedulerInfo() {
|
|
22
|
+
return {
|
|
23
|
+
crons: _cronInfo,
|
|
24
|
+
pendingJobs: _pendingJobs,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
export function createScheduler() {
|
|
28
|
+
const timers = new Map();
|
|
29
|
+
const cronJobs = new Map();
|
|
30
|
+
const actions = new Map();
|
|
31
|
+
let jobCounter = 0;
|
|
32
|
+
function generateId() {
|
|
33
|
+
return `job_${++jobCounter}_${Date.now()}`;
|
|
34
|
+
}
|
|
35
|
+
async function executeAction(action, args) {
|
|
36
|
+
const handler = actions.get(action);
|
|
37
|
+
if (!handler) {
|
|
38
|
+
console.error(`[scheduler] Action "${action}" not registered`);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
await handler(args);
|
|
43
|
+
console.log(`[scheduler] Action "${action}" completed`);
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
console.error(`[scheduler] Action "${action}" failed:`, error);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
runAfter(ms, action, args) {
|
|
51
|
+
const id = generateId();
|
|
52
|
+
_pendingJobs.push({ id, action, scheduledAt: new Date().toISOString() });
|
|
53
|
+
const timer = setTimeout(async () => {
|
|
54
|
+
await executeAction(action, args);
|
|
55
|
+
timers.delete(id);
|
|
56
|
+
const idx = _pendingJobs.findIndex((j) => j.id === id);
|
|
57
|
+
if (idx >= 0)
|
|
58
|
+
_pendingJobs.splice(idx, 1);
|
|
59
|
+
}, ms);
|
|
60
|
+
timers.set(id, timer);
|
|
61
|
+
console.log(`[scheduler] Scheduled "${action}" to run after ${ms}ms (id: ${id})`);
|
|
62
|
+
return id;
|
|
63
|
+
},
|
|
64
|
+
runAt(timestamp, action, args) {
|
|
65
|
+
const target = timestamp instanceof Date ? timestamp.getTime() : timestamp;
|
|
66
|
+
const ms = Math.max(0, target - Date.now());
|
|
67
|
+
return this.runAfter(ms, action, args);
|
|
68
|
+
},
|
|
69
|
+
cancel(jobId) {
|
|
70
|
+
const timer = timers.get(jobId);
|
|
71
|
+
if (timer) {
|
|
72
|
+
clearTimeout(timer);
|
|
73
|
+
timers.delete(jobId);
|
|
74
|
+
console.log(`[scheduler] Cancelled job ${jobId}`);
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
return false;
|
|
78
|
+
},
|
|
79
|
+
cron(name, pattern, handler) {
|
|
80
|
+
if (cronJobs.has(name)) {
|
|
81
|
+
cronJobs.get(name).stop();
|
|
82
|
+
}
|
|
83
|
+
const task = cron.schedule(pattern, async () => {
|
|
84
|
+
console.log(`[scheduler] Cron "${name}" triggered`);
|
|
85
|
+
try {
|
|
86
|
+
await handler();
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
console.error(`[scheduler] Cron "${name}" failed:`, error);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
cronJobs.set(name, task);
|
|
93
|
+
_cronInfo.push({ name, pattern, registeredAt: new Date().toISOString() });
|
|
94
|
+
console.log(`[scheduler] Registered cron "${name}" with pattern "${pattern}"`);
|
|
95
|
+
},
|
|
96
|
+
registerAction(name, handler) {
|
|
97
|
+
actions.set(name, handler);
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Platform Core — Server Implementations
|
|
3
|
+
*
|
|
4
|
+
* Includes Node.js specific modules (e.g. fs, path, crypto) needed for the
|
|
5
|
+
* executing server. Excluded from client-side core (`index.ts`) so they aren't
|
|
6
|
+
* bundled into user functions which run in Firecracker.
|
|
7
|
+
*/
|
|
8
|
+
export { createDb } from "./db";
|
|
9
|
+
export { createStorage, storageRoutes } from "./storage";
|
|
10
|
+
export { createScheduler, getSchedulerInfo } from "./scheduler";
|
|
11
|
+
export { authMiddleware, authRoutes, getUsers } from "./auth";
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Platform Core — Server Implementations
|
|
3
|
+
*
|
|
4
|
+
* Includes Node.js specific modules (e.g. fs, path, crypto) needed for the
|
|
5
|
+
* executing server. Excluded from client-side core (`index.ts`) so they aren't
|
|
6
|
+
* bundled into user functions which run in Firecracker.
|
|
7
|
+
*/
|
|
8
|
+
export { createDb } from "./db";
|
|
9
|
+
export { createStorage, storageRoutes } from "./storage";
|
|
10
|
+
export { createScheduler, getSchedulerInfo } from "./scheduler";
|
|
11
|
+
export { authMiddleware, authRoutes, getUsers } from "./auth";
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
interface StorageFile {
|
|
2
|
+
id: string;
|
|
3
|
+
name: string;
|
|
4
|
+
size: number;
|
|
5
|
+
type: string;
|
|
6
|
+
path: string;
|
|
7
|
+
}
|
|
8
|
+
export interface Storage {
|
|
9
|
+
/** Store a file and return a storageId — Convex의 ctx.storage.store() */
|
|
10
|
+
store(file: File | Blob, filename?: string): Promise<string>;
|
|
11
|
+
/** Store from raw buffer */
|
|
12
|
+
storeBuffer(buffer: Buffer, filename: string, type?: string): Promise<string>;
|
|
13
|
+
/** Get a serving URL for the file — Convex의 ctx.storage.getUrl() */
|
|
14
|
+
getUrl(storageId: string): string;
|
|
15
|
+
/** Get file metadata */
|
|
16
|
+
getMeta(storageId: string): Promise<StorageFile | null>;
|
|
17
|
+
/** Delete a stored file — Convex의 ctx.storage.delete() */
|
|
18
|
+
delete(storageId: string): Promise<void>;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Create a storage instance — Convex storage 패턴 재현
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* const storage = createStorage('./uploads');
|
|
25
|
+
* const id = await storage.store(file);
|
|
26
|
+
* const url = storage.getUrl(id);
|
|
27
|
+
*/
|
|
28
|
+
export declare function createStorage(dir?: string): Storage;
|
|
29
|
+
/**
|
|
30
|
+
* Hono routes for serving stored files
|
|
31
|
+
*/
|
|
32
|
+
export declare function storageRoutes(storage: ReturnType<typeof createStorage>, rawSql?: (sql: string, params?: any[]) => Promise<any[]>, storageDir?: string): (c: any) => Promise<any>;
|
|
33
|
+
export {};
|
package/dist/storage.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import * as fs from "fs/promises";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import * as crypto from "crypto";
|
|
4
|
+
// ─── Implementation ─────────────────────────────────────
|
|
5
|
+
const metaStore = new Map();
|
|
6
|
+
/**
|
|
7
|
+
* Create a storage instance — Convex storage 패턴 재현
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* const storage = createStorage('./uploads');
|
|
11
|
+
* const id = await storage.store(file);
|
|
12
|
+
* const url = storage.getUrl(id);
|
|
13
|
+
*/
|
|
14
|
+
export function createStorage(dir = "./uploads") {
|
|
15
|
+
// Ensure directory exists
|
|
16
|
+
fs.mkdir(dir, { recursive: true }).catch(() => { });
|
|
17
|
+
return {
|
|
18
|
+
async store(file, filename) {
|
|
19
|
+
const id = crypto.randomUUID();
|
|
20
|
+
const name = filename || (file instanceof File ? file.name : `${id}.bin`);
|
|
21
|
+
const filePath = path.join(dir, id);
|
|
22
|
+
const arrayBuffer = await file.arrayBuffer();
|
|
23
|
+
await fs.writeFile(filePath, Buffer.from(arrayBuffer));
|
|
24
|
+
metaStore.set(id, {
|
|
25
|
+
id,
|
|
26
|
+
name,
|
|
27
|
+
size: file.size,
|
|
28
|
+
type: file.type || "application/octet-stream",
|
|
29
|
+
path: filePath,
|
|
30
|
+
});
|
|
31
|
+
return id;
|
|
32
|
+
},
|
|
33
|
+
async storeBuffer(buffer, filename, type = "application/octet-stream") {
|
|
34
|
+
const id = crypto.randomUUID();
|
|
35
|
+
const filePath = path.join(dir, id);
|
|
36
|
+
await fs.writeFile(filePath, buffer);
|
|
37
|
+
metaStore.set(id, {
|
|
38
|
+
id,
|
|
39
|
+
name: filename,
|
|
40
|
+
size: buffer.length,
|
|
41
|
+
type,
|
|
42
|
+
path: filePath,
|
|
43
|
+
});
|
|
44
|
+
return id;
|
|
45
|
+
},
|
|
46
|
+
getUrl(storageId) {
|
|
47
|
+
return `/api/storage/${storageId}`;
|
|
48
|
+
},
|
|
49
|
+
async getMeta(storageId) {
|
|
50
|
+
return metaStore.get(storageId) || null;
|
|
51
|
+
},
|
|
52
|
+
async delete(storageId) {
|
|
53
|
+
const meta = metaStore.get(storageId);
|
|
54
|
+
if (meta) {
|
|
55
|
+
await fs.unlink(meta.path).catch(() => { });
|
|
56
|
+
metaStore.delete(storageId);
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Hono routes for serving stored files
|
|
63
|
+
*/
|
|
64
|
+
export function storageRoutes(storage, rawSql, storageDir) {
|
|
65
|
+
return async (c) => {
|
|
66
|
+
const id = c.req.param("id");
|
|
67
|
+
let meta = await storage.getMeta(id);
|
|
68
|
+
// Fallback: DB lookup when in-memory meta is missing (e.g. after server restart)
|
|
69
|
+
if (!meta && rawSql) {
|
|
70
|
+
try {
|
|
71
|
+
const rows = await rawSql(`SELECT storage_id, name, size, type FROM files WHERE storage_id = $1 LIMIT 1`, [id]);
|
|
72
|
+
if (rows.length > 0) {
|
|
73
|
+
const row = rows[0];
|
|
74
|
+
const dir = storageDir || "./uploads";
|
|
75
|
+
meta = {
|
|
76
|
+
id: row.storage_id,
|
|
77
|
+
name: row.name,
|
|
78
|
+
size: Number(row.size),
|
|
79
|
+
type: row.type || "application/octet-stream",
|
|
80
|
+
path: path.join(dir, row.storage_id),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
catch { /* fallthrough to 404 */ }
|
|
85
|
+
}
|
|
86
|
+
if (!meta) {
|
|
87
|
+
return c.json({ error: "Not found" }, 404);
|
|
88
|
+
}
|
|
89
|
+
const file = await fs.readFile(meta.path);
|
|
90
|
+
return new Response(file, {
|
|
91
|
+
headers: {
|
|
92
|
+
"Content-Type": meta.type,
|
|
93
|
+
"Content-Disposition": `attachment; filename="${encodeURIComponent(meta.name)}"; filename*=UTF-8''${encodeURIComponent(meta.name)}`,
|
|
94
|
+
"Content-Length": String(meta.size),
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
};
|
|
98
|
+
}
|
package/dist/v.d.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* packages/core/src/v.ts
|
|
3
|
+
*
|
|
4
|
+
* Convex-style schema validator for API arguments.
|
|
5
|
+
* Provides both runtime validation and TypeScript type inference.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Validation-specific error — thrown by parseArgs on invalid input.
|
|
9
|
+
* Allows the server dispatcher to safely distinguish 400 from 500
|
|
10
|
+
* without fragile string-matching on the error message.
|
|
11
|
+
*/
|
|
12
|
+
export declare class GencowValidationError extends Error {
|
|
13
|
+
readonly statusCode = 400;
|
|
14
|
+
constructor(message: string);
|
|
15
|
+
}
|
|
16
|
+
export type Validator<T = any> = {
|
|
17
|
+
parse(val: any): T;
|
|
18
|
+
_type: T;
|
|
19
|
+
};
|
|
20
|
+
export type Infer<T> = T extends Validator<infer U> ? U : never;
|
|
21
|
+
export declare const v: {
|
|
22
|
+
string(): Validator<string>;
|
|
23
|
+
number(): Validator<number>;
|
|
24
|
+
boolean(): Validator<boolean>;
|
|
25
|
+
any(): Validator<any>;
|
|
26
|
+
optional<T>(inner: Validator<T>): Validator<T | undefined>;
|
|
27
|
+
object<T extends Record<string, Validator>>(fields: T): Validator<{ [K in keyof T]: Infer<T[K]>; }>;
|
|
28
|
+
array<T>(inner: Validator<T>): Validator<T[]>;
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Infer TypeScript type from a validator schema or a shorthand record of validators.
|
|
32
|
+
* Correctly handles optional properties (Validator<T | undefined> → optional key).
|
|
33
|
+
*/
|
|
34
|
+
export type InferArgs<T> = T extends Validator<infer U> ? U : T extends Record<string, Validator> ? {
|
|
35
|
+
[K in keyof T as T[K] extends Validator<infer U> ? (undefined extends U ? never : K) : K]: T[K] extends Validator<infer U> ? U : any;
|
|
36
|
+
} & {
|
|
37
|
+
[K in keyof T as T[K] extends Validator<infer U> ? (undefined extends U ? K : never) : never]?: T[K] extends Validator<infer U> ? U : any;
|
|
38
|
+
} : T;
|
|
39
|
+
/**
|
|
40
|
+
* Validates and parses arguments against a schema at runtime.
|
|
41
|
+
*
|
|
42
|
+
* Supports:
|
|
43
|
+
* - Full validators: `v.object({ id: v.number() })`, `v.string()`, `v.any()`, etc.
|
|
44
|
+
* - Shorthand record: `{ id: v.number(), title: v.string() }` (Convex style)
|
|
45
|
+
*
|
|
46
|
+
* Throws `GencowValidationError` (HTTP 400) on validation failure.
|
|
47
|
+
*/
|
|
48
|
+
export declare function parseArgs(schema: any, args: any): any;
|
package/dist/v.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* packages/core/src/v.ts
|
|
3
|
+
*
|
|
4
|
+
* Convex-style schema validator for API arguments.
|
|
5
|
+
* Provides both runtime validation and TypeScript type inference.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Validation-specific error — thrown by parseArgs on invalid input.
|
|
9
|
+
* Allows the server dispatcher to safely distinguish 400 from 500
|
|
10
|
+
* without fragile string-matching on the error message.
|
|
11
|
+
*/
|
|
12
|
+
export class GencowValidationError extends Error {
|
|
13
|
+
statusCode = 400;
|
|
14
|
+
constructor(message) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.name = "GencowValidationError";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export const v = {
|
|
20
|
+
string() {
|
|
21
|
+
return {
|
|
22
|
+
parse: (val) => {
|
|
23
|
+
if (typeof val !== "string")
|
|
24
|
+
throw new Error(`Expected string, got ${typeof val}`);
|
|
25
|
+
return val;
|
|
26
|
+
},
|
|
27
|
+
_type: "",
|
|
28
|
+
};
|
|
29
|
+
},
|
|
30
|
+
number() {
|
|
31
|
+
return {
|
|
32
|
+
parse: (val) => {
|
|
33
|
+
// Accept numeric strings (e.g. from URL params / form data)
|
|
34
|
+
const n = typeof val === "string" ? Number(val) : val;
|
|
35
|
+
if (typeof n !== "number" || isNaN(n))
|
|
36
|
+
throw new Error(`Expected number, got ${typeof val}`);
|
|
37
|
+
return n;
|
|
38
|
+
},
|
|
39
|
+
_type: 0,
|
|
40
|
+
};
|
|
41
|
+
},
|
|
42
|
+
boolean() {
|
|
43
|
+
return {
|
|
44
|
+
parse: (val) => {
|
|
45
|
+
if (typeof val !== "boolean")
|
|
46
|
+
throw new Error(`Expected boolean, got ${typeof val}`);
|
|
47
|
+
return val;
|
|
48
|
+
},
|
|
49
|
+
_type: false,
|
|
50
|
+
};
|
|
51
|
+
},
|
|
52
|
+
any() {
|
|
53
|
+
return {
|
|
54
|
+
parse: (val) => val,
|
|
55
|
+
_type: null,
|
|
56
|
+
};
|
|
57
|
+
},
|
|
58
|
+
optional(inner) {
|
|
59
|
+
return {
|
|
60
|
+
parse: (val) => {
|
|
61
|
+
if (val === undefined || val === null)
|
|
62
|
+
return undefined;
|
|
63
|
+
return inner.parse(val);
|
|
64
|
+
},
|
|
65
|
+
_type: undefined,
|
|
66
|
+
};
|
|
67
|
+
},
|
|
68
|
+
object(fields) {
|
|
69
|
+
return {
|
|
70
|
+
parse: (val) => {
|
|
71
|
+
if (typeof val !== "object" || val === null)
|
|
72
|
+
throw new Error("Expected object");
|
|
73
|
+
const result = {};
|
|
74
|
+
for (const key in fields) {
|
|
75
|
+
result[key] = fields[key].parse(val[key]);
|
|
76
|
+
}
|
|
77
|
+
return result;
|
|
78
|
+
},
|
|
79
|
+
_type: {},
|
|
80
|
+
};
|
|
81
|
+
},
|
|
82
|
+
array(inner) {
|
|
83
|
+
return {
|
|
84
|
+
parse: (val) => {
|
|
85
|
+
if (!Array.isArray(val))
|
|
86
|
+
throw new Error("Expected array");
|
|
87
|
+
return val.map((item) => inner.parse(item));
|
|
88
|
+
},
|
|
89
|
+
_type: [],
|
|
90
|
+
};
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
/**
|
|
94
|
+
* Validates and parses arguments against a schema at runtime.
|
|
95
|
+
*
|
|
96
|
+
* Supports:
|
|
97
|
+
* - Full validators: `v.object({ id: v.number() })`, `v.string()`, `v.any()`, etc.
|
|
98
|
+
* - Shorthand record: `{ id: v.number(), title: v.string() }` (Convex style)
|
|
99
|
+
*
|
|
100
|
+
* Throws `GencowValidationError` (HTTP 400) on validation failure.
|
|
101
|
+
*/
|
|
102
|
+
export function parseArgs(schema, args) {
|
|
103
|
+
if (!schema)
|
|
104
|
+
return args;
|
|
105
|
+
// Direct Validator (has a .parse method)
|
|
106
|
+
if (typeof schema.parse === "function") {
|
|
107
|
+
try {
|
|
108
|
+
return schema.parse(args);
|
|
109
|
+
}
|
|
110
|
+
catch (e) {
|
|
111
|
+
throw new GencowValidationError(e.message);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
// Shorthand object — e.g. { id: v.number(), title: v.optional(v.string()) }
|
|
115
|
+
if (typeof schema === "object" && schema !== null) {
|
|
116
|
+
if (typeof args !== "object" || args === null) {
|
|
117
|
+
throw new GencowValidationError("Expected an object for arguments");
|
|
118
|
+
}
|
|
119
|
+
const result = {};
|
|
120
|
+
for (const key in schema) {
|
|
121
|
+
const validator = schema[key];
|
|
122
|
+
if (validator && typeof validator.parse === "function") {
|
|
123
|
+
try {
|
|
124
|
+
result[key] = validator.parse(args[key]);
|
|
125
|
+
}
|
|
126
|
+
catch (e) {
|
|
127
|
+
throw new GencowValidationError(`Argument "${key}": ${e.message}`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
result[key] = args[key];
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return result;
|
|
135
|
+
}
|
|
136
|
+
return args;
|
|
137
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@gencow/core",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Gencow core library — defineQuery, defineMutation, reactive subscriptions",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"import": "./dist/index.js",
|
|
11
|
+
"types": "./dist/index.d.ts"
|
|
12
|
+
},
|
|
13
|
+
"./server": {
|
|
14
|
+
"import": "./dist/server.js",
|
|
15
|
+
"types": "./dist/server.d.ts"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"dist/",
|
|
20
|
+
"src/"
|
|
21
|
+
],
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "tsc",
|
|
24
|
+
"typecheck": "tsc --noEmit",
|
|
25
|
+
"prepublishOnly": "npm run build"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@electric-sql/pglite": "^0.3.15",
|
|
29
|
+
"drizzle-orm": "^0.45.1",
|
|
30
|
+
"hono": "^4.12.0",
|
|
31
|
+
"node-cron": "^4.2.1"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@types/bun": "^1.3.9",
|
|
35
|
+
"@types/node": "^25.3.0",
|
|
36
|
+
"@types/node-cron": "^3.0.11",
|
|
37
|
+
"typescript": "^5.9.3"
|
|
38
|
+
}
|
|
39
|
+
}
|