@gencow/core 0.1.10 → 0.1.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/crud.d.ts +42 -0
- package/dist/crud.js +130 -0
- package/dist/index.d.ts +4 -4
- package/dist/index.js +4 -3
- package/dist/rls-db.d.ts +10 -0
- package/dist/rls-db.js +25 -0
- package/dist/rls.d.ts +4 -0
- package/dist/rls.js +11 -0
- package/dist/scheduler.d.ts +20 -2
- package/dist/scheduler.js +70 -19
- package/package.json +38 -37
- package/src/__tests__/scheduler-exec.test.ts +78 -5
- package/src/crud.ts +174 -0
- package/src/index.ts +5 -5
- package/src/rls-db.ts +29 -0
- package/src/rls.ts +15 -0
- package/src/scheduler.ts +98 -21
- package/src/__tests__/scoped-db.test.ts +0 -442
- package/src/__tests__/table.test.ts +0 -324
- package/src/scoped-db.ts +0 -416
- package/src/table.ts +0 -165
package/dist/crud.d.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { PgDatabase, PgTable } from "drizzle-orm/pg-core";
|
|
2
|
+
type CrudOptions<T extends PgTable> = {
|
|
3
|
+
searchFields?: (keyof T["_"]["columns"])[];
|
|
4
|
+
softDelete?: {
|
|
5
|
+
field: keyof T["_"]["columns"];
|
|
6
|
+
};
|
|
7
|
+
allowedFilters?: (keyof T["_"]["columns"])[];
|
|
8
|
+
defaultLimit?: number;
|
|
9
|
+
maxLimit?: number;
|
|
10
|
+
hooks?: {
|
|
11
|
+
beforeCreate?: (data: any) => any | Promise<any>;
|
|
12
|
+
beforeUpdate?: (data: any) => any | Promise<any>;
|
|
13
|
+
};
|
|
14
|
+
};
|
|
15
|
+
export declare function gencowCrud(db: PgDatabase<any, any, any>): <T extends PgTable>(table: T, options?: CrudOptions<T>) => {
|
|
16
|
+
create: (data: any) => Promise<any>;
|
|
17
|
+
findById: (id: any) => Promise<any | null>;
|
|
18
|
+
list: (params?: {
|
|
19
|
+
page?: number;
|
|
20
|
+
limit?: number;
|
|
21
|
+
search?: string;
|
|
22
|
+
filters?: Record<string, any>;
|
|
23
|
+
orderBy?: {
|
|
24
|
+
field: string;
|
|
25
|
+
direction: "asc" | "desc";
|
|
26
|
+
}[];
|
|
27
|
+
includeDeleted?: boolean;
|
|
28
|
+
}) => Promise<{
|
|
29
|
+
results: {
|
|
30
|
+
[x: string]: any;
|
|
31
|
+
}[];
|
|
32
|
+
page: number;
|
|
33
|
+
limit: number;
|
|
34
|
+
total: number;
|
|
35
|
+
}>;
|
|
36
|
+
update: (id: any, data: any) => Promise<any>;
|
|
37
|
+
deleteOne: (id: any) => Promise<void>;
|
|
38
|
+
restore: (id: any) => Promise<void>;
|
|
39
|
+
bulkCreate: (dataArray: any[]) => Promise<any[]>;
|
|
40
|
+
bulkDelete: (ids: any[]) => Promise<void>;
|
|
41
|
+
};
|
|
42
|
+
export {};
|
package/dist/crud.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { eq, or, and, ilike, desc, asc, inArray, count, sql } from "drizzle-orm";
|
|
2
|
+
export function gencowCrud(db) {
|
|
3
|
+
return function createCrud(table, options) {
|
|
4
|
+
const anyTable = table;
|
|
5
|
+
const pk = anyTable["id"];
|
|
6
|
+
if (!pk) {
|
|
7
|
+
throw new Error(`[gencowCrud] Table ${anyTable["_"]["name"]} must have an 'id' column.`);
|
|
8
|
+
}
|
|
9
|
+
async function create(data) {
|
|
10
|
+
let insertData = { ...data };
|
|
11
|
+
if (options?.hooks?.beforeCreate) {
|
|
12
|
+
insertData = await options.hooks.beforeCreate(insertData);
|
|
13
|
+
}
|
|
14
|
+
const [result] = await db.insert(anyTable).values(insertData).returning();
|
|
15
|
+
return result;
|
|
16
|
+
}
|
|
17
|
+
async function findById(id) {
|
|
18
|
+
let whereCond = eq(pk, id);
|
|
19
|
+
if (options?.softDelete) {
|
|
20
|
+
const sdField = anyTable[options.softDelete.field];
|
|
21
|
+
whereCond = and(whereCond, sql `${sdField} IS NULL`);
|
|
22
|
+
}
|
|
23
|
+
const [result] = await db.select().from(anyTable).where(whereCond).limit(1);
|
|
24
|
+
return result || null;
|
|
25
|
+
}
|
|
26
|
+
async function list(params) {
|
|
27
|
+
const page = Math.max(1, params?.page || 1);
|
|
28
|
+
const limit = Math.min(Math.max(1, params?.limit || options?.defaultLimit || 20), options?.maxLimit || 100);
|
|
29
|
+
const offset = (page - 1) * limit;
|
|
30
|
+
const conditions = [];
|
|
31
|
+
// Soft delete
|
|
32
|
+
if (options?.softDelete && !params?.includeDeleted) {
|
|
33
|
+
conditions.push(sql `${anyTable[options.softDelete.field]} IS NULL`);
|
|
34
|
+
}
|
|
35
|
+
// Search
|
|
36
|
+
if (params?.search && options?.searchFields?.length) {
|
|
37
|
+
const searchConds = options.searchFields.map((f) => ilike(anyTable[f], `%${params.search}%`));
|
|
38
|
+
conditions.push(or(...searchConds));
|
|
39
|
+
}
|
|
40
|
+
// Filters
|
|
41
|
+
if (params?.filters && options?.allowedFilters) {
|
|
42
|
+
for (const [k, v] of Object.entries(params.filters)) {
|
|
43
|
+
if (options.allowedFilters.includes(k)) {
|
|
44
|
+
conditions.push(eq(anyTable[k], v));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
|
|
49
|
+
// Order By
|
|
50
|
+
const orderByArgs = (params?.orderBy || []).map((o) => {
|
|
51
|
+
const col = anyTable[o.field];
|
|
52
|
+
return o.direction === 'desc' ? desc(col) : asc(col);
|
|
53
|
+
});
|
|
54
|
+
// Base count
|
|
55
|
+
const [{ count: total }] = await db.select({ count: count() }).from(anyTable).where(whereClause);
|
|
56
|
+
const results = await db.select()
|
|
57
|
+
.from(anyTable)
|
|
58
|
+
.where(whereClause)
|
|
59
|
+
.orderBy(...orderByArgs)
|
|
60
|
+
.limit(limit)
|
|
61
|
+
.offset(offset);
|
|
62
|
+
return {
|
|
63
|
+
results,
|
|
64
|
+
page,
|
|
65
|
+
limit,
|
|
66
|
+
total: Number(total),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
async function update(id, data) {
|
|
70
|
+
let updateData = { ...data };
|
|
71
|
+
if (options?.hooks?.beforeUpdate) {
|
|
72
|
+
updateData = await options.hooks.beforeUpdate(updateData);
|
|
73
|
+
}
|
|
74
|
+
const [result] = await db.update(anyTable)
|
|
75
|
+
.set(updateData)
|
|
76
|
+
.where(eq(pk, id))
|
|
77
|
+
.returning();
|
|
78
|
+
return result;
|
|
79
|
+
}
|
|
80
|
+
async function deleteOne(id) {
|
|
81
|
+
if (options?.softDelete) {
|
|
82
|
+
const sdField = options.softDelete.field;
|
|
83
|
+
await db.update(anyTable)
|
|
84
|
+
.set({ [sdField]: new Date() })
|
|
85
|
+
.where(eq(pk, id));
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
await db.delete(anyTable).where(eq(pk, id));
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
async function restore(id) {
|
|
92
|
+
if (options?.softDelete) {
|
|
93
|
+
const sdField = options.softDelete.field;
|
|
94
|
+
await db.update(anyTable)
|
|
95
|
+
.set({ [sdField]: null })
|
|
96
|
+
.where(eq(pk, id));
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
async function bulkCreate(dataArray) {
|
|
100
|
+
let insertData = [...dataArray];
|
|
101
|
+
if (options?.hooks?.beforeCreate) {
|
|
102
|
+
insertData = await Promise.all(insertData.map((d) => options.hooks.beforeCreate(d)));
|
|
103
|
+
}
|
|
104
|
+
return await db.insert(anyTable).values(insertData).returning();
|
|
105
|
+
}
|
|
106
|
+
async function bulkDelete(ids) {
|
|
107
|
+
if (ids.length === 0)
|
|
108
|
+
return;
|
|
109
|
+
if (options?.softDelete) {
|
|
110
|
+
const sdField = options.softDelete.field;
|
|
111
|
+
await db.update(anyTable)
|
|
112
|
+
.set({ [sdField]: new Date() })
|
|
113
|
+
.where(inArray(pk, ids));
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
await db.delete(anyTable).where(inArray(pk, ids));
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
create,
|
|
121
|
+
findById,
|
|
122
|
+
list,
|
|
123
|
+
update,
|
|
124
|
+
deleteOne,
|
|
125
|
+
restore,
|
|
126
|
+
bulkCreate,
|
|
127
|
+
bulkDelete,
|
|
128
|
+
};
|
|
129
|
+
};
|
|
130
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -8,7 +8,7 @@ export type { GencowCtx, AuthCtx, UserIdentity, QueryDef, MutationDef, RealtimeC
|
|
|
8
8
|
export { query, mutation, httpAction, invalidateQueries, buildRealtimeCtx, subscribe, unsubscribe, registerClient, deregisterClient, handleWsMessage, getQueryHandler, getQueryDef, getRegisteredQueries, getRegisteredMutations, getRegisteredHttpActions } from "./reactive";
|
|
9
9
|
export type { Storage } from "./storage";
|
|
10
10
|
export { createScheduler, getSchedulerInfo } from "./scheduler";
|
|
11
|
-
export type { Scheduler } from "./scheduler";
|
|
11
|
+
export type { Scheduler, ScheduleOptions, FailedJob } from "./scheduler";
|
|
12
12
|
export { v, parseArgs, GencowValidationError } from "./v";
|
|
13
13
|
export type { Validator, Infer, InferArgs } from "./v";
|
|
14
14
|
export { withRetry } from "./retry";
|
|
@@ -17,6 +17,6 @@ export { cronJobs } from "./crons";
|
|
|
17
17
|
export type { CronJobsBuilder, CronJobDef, IntervalOptions, DailyOptions, WeeklyOptions } from "./crons";
|
|
18
18
|
export { defineAuth } from "./auth-config";
|
|
19
19
|
export type { GencowAuthConfig, AuthEmailVerification } from "./auth-config";
|
|
20
|
-
export {
|
|
21
|
-
export
|
|
22
|
-
export {
|
|
20
|
+
export { ownerRls } from "./rls";
|
|
21
|
+
export { createRlsDb } from "./rls-db";
|
|
22
|
+
export { gencowCrud } from "./crud";
|
package/dist/index.js
CHANGED
|
@@ -10,6 +10,7 @@ export { v, parseArgs, GencowValidationError } from "./v";
|
|
|
10
10
|
export { withRetry } from "./retry";
|
|
11
11
|
export { cronJobs } from "./crons";
|
|
12
12
|
export { defineAuth } from "./auth-config";
|
|
13
|
-
// ───
|
|
14
|
-
export {
|
|
15
|
-
export {
|
|
13
|
+
// ─── RLS + CRUD Factory ───────────
|
|
14
|
+
export { ownerRls } from "./rls";
|
|
15
|
+
export { createRlsDb } from "./rls-db";
|
|
16
|
+
export { gencowCrud } from "./crud";
|
package/dist/rls-db.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { PgDatabase } from "drizzle-orm/pg-core";
|
|
2
|
+
/**
|
|
3
|
+
* JWT payload.sub(userId)로 RLS 세션 변수를 주입하는 DB 래퍼.
|
|
4
|
+
* Supabase createDrizzle 패턴 참고.
|
|
5
|
+
*
|
|
6
|
+
* set_config(name, value, is_local=true)
|
|
7
|
+
* → is_local=true: 현재 트랜잭션에서만 유효
|
|
8
|
+
* → PgBouncer transaction 모드에서 안전
|
|
9
|
+
*/
|
|
10
|
+
export declare function createRlsDb(db: PgDatabase<any, any, any>, userId: string): PgDatabase<any, any, any>;
|
package/dist/rls-db.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { sql } from "drizzle-orm";
|
|
2
|
+
/**
|
|
3
|
+
* JWT payload.sub(userId)로 RLS 세션 변수를 주입하는 DB 래퍼.
|
|
4
|
+
* Supabase createDrizzle 패턴 참고.
|
|
5
|
+
*
|
|
6
|
+
* set_config(name, value, is_local=true)
|
|
7
|
+
* → is_local=true: 현재 트랜잭션에서만 유효
|
|
8
|
+
* → PgBouncer transaction 모드에서 안전
|
|
9
|
+
*/
|
|
10
|
+
export function createRlsDb(db, userId) {
|
|
11
|
+
return new Proxy(db, {
|
|
12
|
+
get(target, prop, receiver) {
|
|
13
|
+
if (prop === "transaction") {
|
|
14
|
+
return async (callback, ...rest) => {
|
|
15
|
+
return await target.transaction(async (tx) => {
|
|
16
|
+
await tx.execute(sql `SELECT set_config('app.current_user_id', ${userId}, true)`);
|
|
17
|
+
return await callback(tx);
|
|
18
|
+
}, ...rest);
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
const value = Reflect.get(target, prop, receiver);
|
|
22
|
+
return typeof value === "function" ? value.bind(target) : value;
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
}
|
package/dist/rls.d.ts
ADDED
package/dist/rls.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { sql } from "drizzle-orm";
|
|
2
|
+
import { pgPolicy } from "drizzle-orm/pg-core";
|
|
3
|
+
export function ownerRls(userIdColumn, options) {
|
|
4
|
+
const isOwner = sql `${userIdColumn} = current_setting('app.current_user_id')`;
|
|
5
|
+
return [
|
|
6
|
+
pgPolicy("rls-select", { for: "select", using: options?.read === "public" ? sql `true` : isOwner }),
|
|
7
|
+
pgPolicy("rls-insert", { for: "insert", withCheck: isOwner }),
|
|
8
|
+
pgPolicy("rls-update", { for: "update", using: isOwner, withCheck: isOwner }),
|
|
9
|
+
pgPolicy("rls-delete", { for: "delete", using: isOwner }),
|
|
10
|
+
];
|
|
11
|
+
}
|
package/dist/scheduler.d.ts
CHANGED
|
@@ -1,9 +1,22 @@
|
|
|
1
1
|
type ActionHandler = (args: any) => Promise<any>;
|
|
2
|
+
/** runAfter/runAt 옵션 — onError dead-letter 콜백 지원 */
|
|
3
|
+
export interface ScheduleOptions {
|
|
4
|
+
/** 실패 시 호출할 action 이름 (dead-letter 패턴) */
|
|
5
|
+
onError?: string;
|
|
6
|
+
}
|
|
7
|
+
/** 실패한 작업의 기록 */
|
|
8
|
+
export interface FailedJob {
|
|
9
|
+
id: string;
|
|
10
|
+
action: string;
|
|
11
|
+
args: any;
|
|
12
|
+
error: string;
|
|
13
|
+
failedAt: string;
|
|
14
|
+
}
|
|
2
15
|
export interface Scheduler {
|
|
3
16
|
/** Schedule a function to run after a delay — Convex의 ctx.scheduler.runAfter() */
|
|
4
|
-
runAfter(ms: number, action: string, args?: any): string;
|
|
17
|
+
runAfter(ms: number, action: string, args?: any, options?: ScheduleOptions): string;
|
|
5
18
|
/** Schedule a function at a specific time — Convex의 ctx.scheduler.runAt() */
|
|
6
|
-
runAt(timestamp: number | Date, action: string, args?: any): string;
|
|
19
|
+
runAt(timestamp: number | Date, action: string, args?: any, options?: ScheduleOptions): string;
|
|
7
20
|
/** Cancel a scheduled function */
|
|
8
21
|
cancel(jobId: string): boolean;
|
|
9
22
|
/** Register a cron job — Convex의 cronJobs() */
|
|
@@ -25,6 +38,9 @@ export interface Scheduler {
|
|
|
25
38
|
* // Schedule (Convex-style)
|
|
26
39
|
* scheduler.runAfter(5 * 60 * 1000, 'emails.send', { to: 'user@test.com' });
|
|
27
40
|
*
|
|
41
|
+
* // Schedule with error callback (dead-letter 패턴)
|
|
42
|
+
* scheduler.runAfter(0, 'pipeline.step2', args, { onError: 'pipeline.onStepError' });
|
|
43
|
+
*
|
|
28
44
|
* // Cron (Convex-style)
|
|
29
45
|
* scheduler.cron('daily-cleanup', '0 2 * * *', async () => { ... });
|
|
30
46
|
*/
|
|
@@ -37,10 +53,12 @@ interface PendingJobEntry {
|
|
|
37
53
|
id: string;
|
|
38
54
|
action: string;
|
|
39
55
|
scheduledAt: string;
|
|
56
|
+
status?: "pending" | "running" | "completed" | "failed";
|
|
40
57
|
}
|
|
41
58
|
export declare function getSchedulerInfo(): {
|
|
42
59
|
crons: CronInfoEntry[];
|
|
43
60
|
pendingJobs: PendingJobEntry[];
|
|
61
|
+
failedJobs: FailedJob[];
|
|
44
62
|
};
|
|
45
63
|
export declare function createScheduler(): Scheduler;
|
|
46
64
|
export {};
|
package/dist/scheduler.js
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import * as cron from "node-cron";
|
|
2
2
|
const _cronInfo = globalThis.__gencow_cronInfo ??= [];
|
|
3
3
|
const _pendingJobs = globalThis.__gencow_pendingJobs ??= [];
|
|
4
|
+
const _failedJobs = globalThis.__gencow_failedJobs ??= [];
|
|
5
|
+
/** 최대 보관할 실패 작업 수 (메모리 보호) */
|
|
6
|
+
const MAX_FAILED_JOBS = 100;
|
|
4
7
|
export function getSchedulerInfo() {
|
|
5
8
|
return {
|
|
6
9
|
crons: _cronInfo,
|
|
7
10
|
pendingJobs: _pendingJobs,
|
|
11
|
+
failedJobs: _failedJobs,
|
|
8
12
|
};
|
|
9
13
|
}
|
|
10
14
|
export function createScheduler() {
|
|
@@ -15,45 +19,89 @@ export function createScheduler() {
|
|
|
15
19
|
function generateId() {
|
|
16
20
|
return `job_${++jobCounter}_${Date.now()}`;
|
|
17
21
|
}
|
|
22
|
+
/** 실패 기록 추가 (dead-letter 레지스트리) */
|
|
23
|
+
function recordFailure(id, action, args, error) {
|
|
24
|
+
_failedJobs.push({
|
|
25
|
+
id,
|
|
26
|
+
action,
|
|
27
|
+
args,
|
|
28
|
+
error: error.message,
|
|
29
|
+
failedAt: new Date().toISOString(),
|
|
30
|
+
});
|
|
31
|
+
// 메모리 보호: 오래된 실패 기록 정리
|
|
32
|
+
while (_failedJobs.length > MAX_FAILED_JOBS) {
|
|
33
|
+
_failedJobs.shift();
|
|
34
|
+
}
|
|
35
|
+
}
|
|
18
36
|
async function executeAction(action, args) {
|
|
19
37
|
const handler = actions.get(action);
|
|
20
38
|
if (!handler) {
|
|
21
39
|
console.error(`[scheduler] Action "${action}" not registered`);
|
|
22
|
-
|
|
23
|
-
}
|
|
24
|
-
try {
|
|
25
|
-
await handler(args);
|
|
26
|
-
console.log(`[scheduler] Action "${action}" completed`);
|
|
27
|
-
}
|
|
28
|
-
catch (error) {
|
|
29
|
-
console.error(`[scheduler] Action "${action}" failed:`, error);
|
|
40
|
+
throw new Error(`Action "${action}" not registered`);
|
|
30
41
|
}
|
|
42
|
+
await handler(args);
|
|
43
|
+
console.log(`[scheduler] Action "${action}" completed`);
|
|
31
44
|
}
|
|
32
45
|
return {
|
|
33
|
-
runAfter(ms, action, args) {
|
|
46
|
+
runAfter(ms, action, args, options) {
|
|
34
47
|
const id = generateId();
|
|
35
|
-
|
|
48
|
+
const jobEntry = { id, action, scheduledAt: new Date().toISOString(), status: "pending" };
|
|
49
|
+
_pendingJobs.push(jobEntry);
|
|
36
50
|
const timer = setTimeout(async () => {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
51
|
+
jobEntry.status = "running";
|
|
52
|
+
try {
|
|
53
|
+
await executeAction(action, args);
|
|
54
|
+
jobEntry.status = "completed";
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
58
|
+
jobEntry.status = "failed";
|
|
59
|
+
console.error(`[scheduler] Action "${action}" failed (job: ${id}):`, err.message);
|
|
60
|
+
// 실패 기록 보관 (dead-letter)
|
|
61
|
+
recordFailure(id, action, args, err);
|
|
62
|
+
// onError 콜백 실행 — 다른 action에 에러 정보 전달
|
|
63
|
+
if (options?.onError) {
|
|
64
|
+
try {
|
|
65
|
+
await executeAction(options.onError, {
|
|
66
|
+
failedAction: action,
|
|
67
|
+
failedJobId: id,
|
|
68
|
+
error: err.message,
|
|
69
|
+
originalArgs: args,
|
|
70
|
+
});
|
|
71
|
+
console.log(`[scheduler] onError handler "${options.onError}" completed for "${action}"`);
|
|
72
|
+
}
|
|
73
|
+
catch (onErrorErr) {
|
|
74
|
+
console.error(`[scheduler] onError handler "${options.onError}" also failed:`, onErrorErr);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
finally {
|
|
79
|
+
timers.delete(id);
|
|
80
|
+
// completed/failed 기록은 잠시 유지 후 정리 (status 조회 가능하도록)
|
|
81
|
+
setTimeout(() => {
|
|
82
|
+
const idx = _pendingJobs.findIndex((j) => j.id === id);
|
|
83
|
+
if (idx >= 0)
|
|
84
|
+
_pendingJobs.splice(idx, 1);
|
|
85
|
+
}, 60_000); // 1분 후 정리
|
|
86
|
+
}
|
|
42
87
|
}, ms);
|
|
43
88
|
timers.set(id, timer);
|
|
44
|
-
console.log(`[scheduler] Scheduled "${action}" to run after ${ms}ms (id: ${id})`);
|
|
89
|
+
console.log(`[scheduler] Scheduled "${action}" to run after ${ms}ms (id: ${id})${options?.onError ? ` [onError: ${options.onError}]` : ""}`);
|
|
45
90
|
return id;
|
|
46
91
|
},
|
|
47
|
-
runAt(timestamp, action, args) {
|
|
92
|
+
runAt(timestamp, action, args, options) {
|
|
48
93
|
const target = timestamp instanceof Date ? timestamp.getTime() : timestamp;
|
|
49
94
|
const ms = Math.max(0, target - Date.now());
|
|
50
|
-
return this.runAfter(ms, action, args);
|
|
95
|
+
return this.runAfter(ms, action, args, options);
|
|
51
96
|
},
|
|
52
97
|
cancel(jobId) {
|
|
53
98
|
const timer = timers.get(jobId);
|
|
54
99
|
if (timer) {
|
|
55
100
|
clearTimeout(timer);
|
|
56
101
|
timers.delete(jobId);
|
|
102
|
+
const idx = _pendingJobs.findIndex((j) => j.id === jobId);
|
|
103
|
+
if (idx >= 0)
|
|
104
|
+
_pendingJobs.splice(idx, 1);
|
|
57
105
|
console.log(`[scheduler] Cancelled job ${jobId}`);
|
|
58
106
|
return true;
|
|
59
107
|
}
|
|
@@ -69,7 +117,10 @@ export function createScheduler() {
|
|
|
69
117
|
await handler();
|
|
70
118
|
}
|
|
71
119
|
catch (error) {
|
|
72
|
-
|
|
120
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
121
|
+
console.error(`[scheduler] Cron "${name}" failed:`, err.message);
|
|
122
|
+
// cron 실패도 dead-letter에 기록
|
|
123
|
+
recordFailure(`cron_${name}_${Date.now()}`, `cron:${name}`, {}, err);
|
|
73
124
|
}
|
|
74
125
|
});
|
|
75
126
|
cronJobs.set(name, task);
|
package/package.json
CHANGED
|
@@ -1,40 +1,41 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
"./server": {
|
|
14
|
-
"import": "./dist/server.js",
|
|
15
|
-
"types": "./dist/server.d.ts"
|
|
16
|
-
}
|
|
2
|
+
"name": "@gencow/core",
|
|
3
|
+
"version": "0.1.12",
|
|
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
|
+
"require": "./dist/index.js",
|
|
12
|
+
"types": "./dist/index.d.ts"
|
|
17
13
|
},
|
|
18
|
-
"
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
"scripts": {
|
|
23
|
-
"build": "tsc",
|
|
24
|
-
"typecheck": "tsc --noEmit",
|
|
25
|
-
"prepublishOnly": "npm run build",
|
|
26
|
-
"postinstall": "tsc"
|
|
27
|
-
},
|
|
28
|
-
"dependencies": {
|
|
29
|
-
"@electric-sql/pglite": "^0.3.15",
|
|
30
|
-
"drizzle-orm": "^0.45.1",
|
|
31
|
-
"hono": "^4.12.0",
|
|
32
|
-
"node-cron": "^4.2.1"
|
|
33
|
-
},
|
|
34
|
-
"devDependencies": {
|
|
35
|
-
"@types/bun": "^1.3.9",
|
|
36
|
-
"@types/node": "^25.3.0",
|
|
37
|
-
"@types/node-cron": "^3.0.11",
|
|
38
|
-
"typescript": "^5.9.3"
|
|
14
|
+
"./server": {
|
|
15
|
+
"import": "./dist/server.js",
|
|
16
|
+
"require": "./dist/server.js",
|
|
17
|
+
"types": "./dist/server.d.ts"
|
|
39
18
|
}
|
|
40
|
-
}
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"dist/",
|
|
22
|
+
"src/"
|
|
23
|
+
],
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"@electric-sql/pglite": "^0.3.15",
|
|
26
|
+
"drizzle-orm": "^0.45.1",
|
|
27
|
+
"hono": "^4.12.0",
|
|
28
|
+
"node-cron": "^4.2.1"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@types/bun": "^1.3.9",
|
|
32
|
+
"@types/node": "^25.3.0",
|
|
33
|
+
"@types/node-cron": "^3.0.11",
|
|
34
|
+
"typescript": "^5.9.3"
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "tsc",
|
|
38
|
+
"typecheck": "tsc --noEmit",
|
|
39
|
+
"postinstall": "tsc"
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -142,10 +142,17 @@ describe("Scheduler 실행 — registerAction + executeAction", () => {
|
|
|
142
142
|
expect(result).toBe("Hello World");
|
|
143
143
|
});
|
|
144
144
|
|
|
145
|
-
it("미등록 action executeAction →
|
|
145
|
+
it("미등록 action executeAction → 에러를 throw한다", async () => {
|
|
146
146
|
const scheduler = createScheduler();
|
|
147
|
-
//
|
|
148
|
-
|
|
147
|
+
// 미등록 action은 에러를 throw해야 함
|
|
148
|
+
let threw = false;
|
|
149
|
+
try {
|
|
150
|
+
await scheduler.executeAction("nonexistent");
|
|
151
|
+
} catch (e) {
|
|
152
|
+
threw = true;
|
|
153
|
+
expect((e as Error).message).toContain("not registered");
|
|
154
|
+
}
|
|
155
|
+
expect(threw).toBe(true);
|
|
149
156
|
});
|
|
150
157
|
|
|
151
158
|
it("action 에러 시 다른 action에 영향 없음", async () => {
|
|
@@ -159,8 +166,15 @@ describe("Scheduler 실행 — registerAction + executeAction", () => {
|
|
|
159
166
|
secondRan = true;
|
|
160
167
|
});
|
|
161
168
|
|
|
162
|
-
// 실패하는 action
|
|
163
|
-
|
|
169
|
+
// 실패하는 action — 에러를 throw함
|
|
170
|
+
let threw = false;
|
|
171
|
+
try {
|
|
172
|
+
await scheduler.executeAction("failing");
|
|
173
|
+
} catch {
|
|
174
|
+
threw = true;
|
|
175
|
+
}
|
|
176
|
+
expect(threw).toBe(true);
|
|
177
|
+
|
|
164
178
|
// 정상 action은 여전히 동작
|
|
165
179
|
await scheduler.executeAction("healthy");
|
|
166
180
|
expect(secondRan).toBe(true);
|
|
@@ -244,3 +258,62 @@ describe("Scheduler 실행 — cron", () => {
|
|
|
244
258
|
expect(count).toBeGreaterThanOrEqual(2);
|
|
245
259
|
});
|
|
246
260
|
});
|
|
261
|
+
|
|
262
|
+
describe("Scheduler 실행 — onError dead-letter", () => {
|
|
263
|
+
it("runAfter 실패 시 onError action이 호출된다", async () => {
|
|
264
|
+
const scheduler = createScheduler();
|
|
265
|
+
let errorHandlerArgs: any = null;
|
|
266
|
+
|
|
267
|
+
scheduler.registerAction("failing.step", async () => {
|
|
268
|
+
throw new Error("Step failed!");
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
scheduler.registerAction("pipeline.onError", async (args) => {
|
|
272
|
+
errorHandlerArgs = args;
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
scheduler.runAfter(50, "failing.step", { input: "test" }, {
|
|
276
|
+
onError: "pipeline.onError",
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
280
|
+
|
|
281
|
+
expect(errorHandlerArgs).not.toBeNull();
|
|
282
|
+
expect(errorHandlerArgs.failedAction).toBe("failing.step");
|
|
283
|
+
expect(errorHandlerArgs.error).toBe("Step failed!");
|
|
284
|
+
expect(errorHandlerArgs.originalArgs).toEqual({ input: "test" });
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("실패한 작업이 failedJobs에 기록된다", async () => {
|
|
288
|
+
const scheduler = createScheduler();
|
|
289
|
+
|
|
290
|
+
scheduler.registerAction("record.fail", async () => {
|
|
291
|
+
throw new Error("Recorded failure");
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
scheduler.runAfter(50, "record.fail", { id: 1 });
|
|
295
|
+
|
|
296
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
297
|
+
|
|
298
|
+
const info = getSchedulerInfo();
|
|
299
|
+
const failed = info.failedJobs.find(
|
|
300
|
+
(j: any) => j.action === "record.fail"
|
|
301
|
+
);
|
|
302
|
+
expect(failed).toBeDefined();
|
|
303
|
+
expect(failed!.error).toBe("Recorded failure");
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it("pendingJobs에 status 필드가 포함된다", () => {
|
|
307
|
+
const scheduler = createScheduler();
|
|
308
|
+
scheduler.registerAction("noop", async () => {});
|
|
309
|
+
const id = scheduler.runAfter(10000, "noop");
|
|
310
|
+
|
|
311
|
+
const info = getSchedulerInfo();
|
|
312
|
+
const job = info.pendingJobs.find((j: any) => j.id === id);
|
|
313
|
+
expect(job).toBeDefined();
|
|
314
|
+
expect(job!.status).toBe("pending");
|
|
315
|
+
|
|
316
|
+
// cleanup
|
|
317
|
+
scheduler.cancel(id);
|
|
318
|
+
});
|
|
319
|
+
});
|