@gencow/core 0.1.10 → 0.1.11
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 +25 -0
- package/dist/rls-db.js +20 -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 +24 -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/src/crud.ts
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { eq, or, and, ilike, desc, asc, inArray, count, sql, type SQL } from "drizzle-orm";
|
|
2
|
+
import type { PgDatabase, PgTable, AnyPgColumn } from "drizzle-orm/pg-core";
|
|
3
|
+
|
|
4
|
+
type CrudOptions<T extends PgTable> = {
|
|
5
|
+
searchFields?: (keyof T["_"]["columns"])[];
|
|
6
|
+
softDelete?: { field: keyof T["_"]["columns"] };
|
|
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
|
+
|
|
16
|
+
export function gencowCrud(db: PgDatabase<any, any, any>) {
|
|
17
|
+
return function createCrud<T extends PgTable>(table: T, options?: CrudOptions<T>) {
|
|
18
|
+
const anyTable = table as any;
|
|
19
|
+
const pk = anyTable["id"] as AnyPgColumn;
|
|
20
|
+
|
|
21
|
+
if (!pk) {
|
|
22
|
+
throw new Error(`[gencowCrud] Table ${anyTable["_"]["name"]} must have an 'id' column.`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function create(data: any): Promise<any> {
|
|
26
|
+
let insertData = { ...data };
|
|
27
|
+
if (options?.hooks?.beforeCreate) {
|
|
28
|
+
insertData = await options.hooks.beforeCreate(insertData);
|
|
29
|
+
}
|
|
30
|
+
const [result] = await db.insert(anyTable).values(insertData).returning();
|
|
31
|
+
return result;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function findById(id: any): Promise<any | null> {
|
|
35
|
+
let whereCond: SQL | undefined = eq(pk, id);
|
|
36
|
+
|
|
37
|
+
if (options?.softDelete) {
|
|
38
|
+
const sdField = anyTable[options.softDelete.field as string] as AnyPgColumn;
|
|
39
|
+
whereCond = and(whereCond, sql`${sdField} IS NULL`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const [result] = await db.select().from(anyTable).where(whereCond).limit(1);
|
|
43
|
+
return result || null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function list(params?: {
|
|
47
|
+
page?: number;
|
|
48
|
+
limit?: number;
|
|
49
|
+
search?: string;
|
|
50
|
+
filters?: Record<string, any>;
|
|
51
|
+
orderBy?: { field: string; direction: 'asc' | 'desc' }[];
|
|
52
|
+
includeDeleted?: boolean;
|
|
53
|
+
}) {
|
|
54
|
+
const page = Math.max(1, params?.page || 1);
|
|
55
|
+
const limit = Math.min(
|
|
56
|
+
Math.max(1, params?.limit || options?.defaultLimit || 20),
|
|
57
|
+
options?.maxLimit || 100
|
|
58
|
+
);
|
|
59
|
+
const offset = (page - 1) * limit;
|
|
60
|
+
|
|
61
|
+
const conditions: SQL[] = [];
|
|
62
|
+
|
|
63
|
+
// Soft delete
|
|
64
|
+
if (options?.softDelete && !params?.includeDeleted) {
|
|
65
|
+
conditions.push(sql`${anyTable[options.softDelete.field as string]} IS NULL`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Search
|
|
69
|
+
if (params?.search && options?.searchFields?.length) {
|
|
70
|
+
const searchConds = options.searchFields.map(
|
|
71
|
+
(f) => ilike(anyTable[f as string] as AnyPgColumn, `%${params!.search}%`)
|
|
72
|
+
);
|
|
73
|
+
conditions.push(or(...searchConds)!);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Filters
|
|
77
|
+
if (params?.filters && options?.allowedFilters) {
|
|
78
|
+
for (const [k, v] of Object.entries(params.filters)) {
|
|
79
|
+
if (options.allowedFilters.includes(k as keyof T["_"]["columns"])) {
|
|
80
|
+
conditions.push(eq(anyTable[k] as AnyPgColumn, v));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
|
|
86
|
+
|
|
87
|
+
// Order By
|
|
88
|
+
const orderByArgs = (params?.orderBy || []).map((o) => {
|
|
89
|
+
const col = anyTable[o.field] as AnyPgColumn;
|
|
90
|
+
return o.direction === 'desc' ? desc(col) : asc(col);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// Base count
|
|
94
|
+
const [{ count: total }] = await db.select({ count: count() }).from(anyTable).where(whereClause);
|
|
95
|
+
|
|
96
|
+
const results = await db.select()
|
|
97
|
+
.from(anyTable)
|
|
98
|
+
.where(whereClause)
|
|
99
|
+
.orderBy(...orderByArgs)
|
|
100
|
+
.limit(limit)
|
|
101
|
+
.offset(offset);
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
results,
|
|
105
|
+
page,
|
|
106
|
+
limit,
|
|
107
|
+
total: Number(total),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function update(id: any, data: any): Promise<any> {
|
|
112
|
+
let updateData = { ...data };
|
|
113
|
+
if (options?.hooks?.beforeUpdate) {
|
|
114
|
+
updateData = await options.hooks.beforeUpdate(updateData);
|
|
115
|
+
}
|
|
116
|
+
const [result] = await db.update(anyTable)
|
|
117
|
+
.set(updateData)
|
|
118
|
+
.where(eq(pk, id))
|
|
119
|
+
.returning();
|
|
120
|
+
return result;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function deleteOne(id: any): Promise<void> {
|
|
124
|
+
if (options?.softDelete) {
|
|
125
|
+
const sdField = options.softDelete.field as string;
|
|
126
|
+
await db.update(anyTable)
|
|
127
|
+
.set({ [sdField]: new Date() } as any)
|
|
128
|
+
.where(eq(pk, id));
|
|
129
|
+
} else {
|
|
130
|
+
await db.delete(anyTable).where(eq(pk, id));
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function restore(id: any): Promise<void> {
|
|
135
|
+
if (options?.softDelete) {
|
|
136
|
+
const sdField = options.softDelete.field as string;
|
|
137
|
+
await db.update(anyTable)
|
|
138
|
+
.set({ [sdField]: null } as any)
|
|
139
|
+
.where(eq(pk, id));
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function bulkCreate(dataArray: any[]): Promise<any[]> {
|
|
144
|
+
let insertData = [...dataArray];
|
|
145
|
+
if (options?.hooks?.beforeCreate) {
|
|
146
|
+
insertData = await Promise.all(insertData.map((d) => options.hooks!.beforeCreate!(d)));
|
|
147
|
+
}
|
|
148
|
+
return await db.insert(anyTable).values(insertData).returning();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function bulkDelete(ids: any[]): Promise<void> {
|
|
152
|
+
if (ids.length === 0) return;
|
|
153
|
+
if (options?.softDelete) {
|
|
154
|
+
const sdField = options.softDelete.field as string;
|
|
155
|
+
await db.update(anyTable)
|
|
156
|
+
.set({ [sdField]: new Date() } as any)
|
|
157
|
+
.where(inArray(pk, ids));
|
|
158
|
+
} else {
|
|
159
|
+
await db.delete(anyTable).where(inArray(pk, ids));
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
create,
|
|
165
|
+
findById,
|
|
166
|
+
list,
|
|
167
|
+
update,
|
|
168
|
+
deleteOne,
|
|
169
|
+
restore,
|
|
170
|
+
bulkCreate,
|
|
171
|
+
bulkDelete,
|
|
172
|
+
};
|
|
173
|
+
};
|
|
174
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -9,7 +9,7 @@ export type { GencowCtx, AuthCtx, UserIdentity, QueryDef, MutationDef, RealtimeC
|
|
|
9
9
|
export { query, mutation, httpAction, invalidateQueries, buildRealtimeCtx, subscribe, unsubscribe, registerClient, deregisterClient, handleWsMessage, getQueryHandler, getQueryDef, getRegisteredQueries, getRegisteredMutations, getRegisteredHttpActions } from "./reactive";
|
|
10
10
|
export type { Storage } from "./storage";
|
|
11
11
|
export { createScheduler, getSchedulerInfo } from "./scheduler";
|
|
12
|
-
export type { Scheduler } from "./scheduler";
|
|
12
|
+
export type { Scheduler, ScheduleOptions, FailedJob } from "./scheduler";
|
|
13
13
|
export { v, parseArgs, GencowValidationError } from "./v";
|
|
14
14
|
export type { Validator, Infer, InferArgs } from "./v";
|
|
15
15
|
export { withRetry } from "./retry";
|
|
@@ -19,10 +19,10 @@ export type { CronJobsBuilder, CronJobDef, IntervalOptions, DailyOptions, Weekly
|
|
|
19
19
|
export { defineAuth } from "./auth-config";
|
|
20
20
|
export type { GencowAuthConfig, AuthEmailVerification } from "./auth-config";
|
|
21
21
|
|
|
22
|
-
// ───
|
|
23
|
-
export {
|
|
24
|
-
export
|
|
25
|
-
export {
|
|
22
|
+
// ─── RLS + CRUD Factory ───────────
|
|
23
|
+
export { ownerRls } from "./rls";
|
|
24
|
+
export { createRlsDb } from "./rls-db";
|
|
25
|
+
export { gencowCrud } from "./crud";
|
|
26
26
|
|
|
27
27
|
|
|
28
28
|
|
package/src/rls-db.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { sql } from "drizzle-orm";
|
|
2
|
+
import type { PgDatabase } from "drizzle-orm/pg-core";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* JWT payload.sub(userId)로 RLS 세션 변수를 주입하는 DB 래퍼.
|
|
6
|
+
* Supabase createDrizzle 패턴 참고.
|
|
7
|
+
*
|
|
8
|
+
* set_config(name, value, is_local=true)
|
|
9
|
+
* → is_local=true: 현재 트랜잭션에서만 유효
|
|
10
|
+
* → PgBouncer transaction 모드에서 안전
|
|
11
|
+
*/
|
|
12
|
+
export function createRlsDb(db: PgDatabase<any, any, any>, userId: string) {
|
|
13
|
+
return {
|
|
14
|
+
...db,
|
|
15
|
+
transaction: (async (callback: any, ...rest: any[]) => {
|
|
16
|
+
return await db.transaction(async (tx) => {
|
|
17
|
+
await tx.execute(
|
|
18
|
+
sql`SELECT set_config('app.current_user_id', ${userId}, true)`
|
|
19
|
+
);
|
|
20
|
+
return await callback(tx);
|
|
21
|
+
}, ...rest as any);
|
|
22
|
+
}) as typeof db.transaction,
|
|
23
|
+
};
|
|
24
|
+
}
|
package/src/rls.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { sql, type SQL } from "drizzle-orm";
|
|
2
|
+
import { pgPolicy, type AnyPgColumn } from "drizzle-orm/pg-core";
|
|
3
|
+
|
|
4
|
+
export function ownerRls(
|
|
5
|
+
userIdColumn: AnyPgColumn,
|
|
6
|
+
options?: { read?: "public" },
|
|
7
|
+
) {
|
|
8
|
+
const isOwner = sql`${userIdColumn} = current_setting('app.current_user_id')`;
|
|
9
|
+
return [
|
|
10
|
+
pgPolicy("rls-select", { for: "select", using: options?.read === "public" ? sql`true` : isOwner }),
|
|
11
|
+
pgPolicy("rls-insert", { for: "insert", withCheck: isOwner }),
|
|
12
|
+
pgPolicy("rls-update", { for: "update", using: isOwner, withCheck: isOwner }),
|
|
13
|
+
pgPolicy("rls-delete", { for: "delete", using: isOwner }),
|
|
14
|
+
];
|
|
15
|
+
}
|
package/src/scheduler.ts
CHANGED
|
@@ -4,11 +4,26 @@ import * as cron from "node-cron";
|
|
|
4
4
|
|
|
5
5
|
type ActionHandler = (args: any) => Promise<any>;
|
|
6
6
|
|
|
7
|
+
/** runAfter/runAt 옵션 — onError dead-letter 콜백 지원 */
|
|
8
|
+
export interface ScheduleOptions {
|
|
9
|
+
/** 실패 시 호출할 action 이름 (dead-letter 패턴) */
|
|
10
|
+
onError?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** 실패한 작업의 기록 */
|
|
14
|
+
export interface FailedJob {
|
|
15
|
+
id: string;
|
|
16
|
+
action: string;
|
|
17
|
+
args: any;
|
|
18
|
+
error: string;
|
|
19
|
+
failedAt: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
7
22
|
export interface Scheduler {
|
|
8
23
|
/** Schedule a function to run after a delay — Convex의 ctx.scheduler.runAfter() */
|
|
9
|
-
runAfter(ms: number, action: string, args?: any): string;
|
|
24
|
+
runAfter(ms: number, action: string, args?: any, options?: ScheduleOptions): string;
|
|
10
25
|
/** Schedule a function at a specific time — Convex의 ctx.scheduler.runAt() */
|
|
11
|
-
runAt(timestamp: number | Date, action: string, args?: any): string;
|
|
26
|
+
runAt(timestamp: number | Date, action: string, args?: any, options?: ScheduleOptions): string;
|
|
12
27
|
/** Cancel a scheduled function */
|
|
13
28
|
cancel(jobId: string): boolean;
|
|
14
29
|
/** Register a cron job — Convex의 cronJobs() */
|
|
@@ -33,6 +48,9 @@ export interface Scheduler {
|
|
|
33
48
|
* // Schedule (Convex-style)
|
|
34
49
|
* scheduler.runAfter(5 * 60 * 1000, 'emails.send', { to: 'user@test.com' });
|
|
35
50
|
*
|
|
51
|
+
* // Schedule with error callback (dead-letter 패턴)
|
|
52
|
+
* scheduler.runAfter(0, 'pipeline.step2', args, { onError: 'pipeline.onStepError' });
|
|
53
|
+
*
|
|
36
54
|
* // Cron (Convex-style)
|
|
37
55
|
* scheduler.cron('daily-cleanup', '0 2 * * *', async () => { ... });
|
|
38
56
|
*/
|
|
@@ -41,17 +59,28 @@ export interface Scheduler {
|
|
|
41
59
|
// @gencow/core가 다중 resolve되어도 단일 배열 공유.
|
|
42
60
|
|
|
43
61
|
interface CronInfoEntry { name: string; pattern: string; registeredAt: string }
|
|
44
|
-
interface PendingJobEntry {
|
|
62
|
+
interface PendingJobEntry {
|
|
63
|
+
id: string;
|
|
64
|
+
action: string;
|
|
65
|
+
scheduledAt: string;
|
|
66
|
+
status?: "pending" | "running" | "completed" | "failed";
|
|
67
|
+
}
|
|
45
68
|
|
|
46
69
|
const _cronInfo: CronInfoEntry[] =
|
|
47
70
|
(globalThis as any).__gencow_cronInfo ??= [];
|
|
48
71
|
const _pendingJobs: PendingJobEntry[] =
|
|
49
72
|
(globalThis as any).__gencow_pendingJobs ??= [];
|
|
73
|
+
const _failedJobs: FailedJob[] =
|
|
74
|
+
(globalThis as any).__gencow_failedJobs ??= [];
|
|
75
|
+
|
|
76
|
+
/** 최대 보관할 실패 작업 수 (메모리 보호) */
|
|
77
|
+
const MAX_FAILED_JOBS = 100;
|
|
50
78
|
|
|
51
79
|
export function getSchedulerInfo() {
|
|
52
80
|
return {
|
|
53
81
|
crons: _cronInfo,
|
|
54
82
|
pendingJobs: _pendingJobs,
|
|
83
|
+
failedJobs: _failedJobs,
|
|
55
84
|
};
|
|
56
85
|
}
|
|
57
86
|
|
|
@@ -66,42 +95,85 @@ export function createScheduler(): Scheduler {
|
|
|
66
95
|
return `job_${++jobCounter}_${Date.now()}`;
|
|
67
96
|
}
|
|
68
97
|
|
|
69
|
-
|
|
98
|
+
/** 실패 기록 추가 (dead-letter 레지스트리) */
|
|
99
|
+
function recordFailure(id: string, action: string, args: any, error: Error): void {
|
|
100
|
+
_failedJobs.push({
|
|
101
|
+
id,
|
|
102
|
+
action,
|
|
103
|
+
args,
|
|
104
|
+
error: error.message,
|
|
105
|
+
failedAt: new Date().toISOString(),
|
|
106
|
+
});
|
|
107
|
+
// 메모리 보호: 오래된 실패 기록 정리
|
|
108
|
+
while (_failedJobs.length > MAX_FAILED_JOBS) {
|
|
109
|
+
_failedJobs.shift();
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function executeAction(action: string, args: any): Promise<void> {
|
|
70
114
|
const handler = actions.get(action);
|
|
71
115
|
if (!handler) {
|
|
72
116
|
console.error(`[scheduler] Action "${action}" not registered`);
|
|
73
|
-
|
|
74
|
-
}
|
|
75
|
-
try {
|
|
76
|
-
await handler(args);
|
|
77
|
-
console.log(`[scheduler] Action "${action}" completed`);
|
|
78
|
-
} catch (error) {
|
|
79
|
-
console.error(`[scheduler] Action "${action}" failed:`, error);
|
|
117
|
+
throw new Error(`Action "${action}" not registered`);
|
|
80
118
|
}
|
|
119
|
+
await handler(args);
|
|
120
|
+
console.log(`[scheduler] Action "${action}" completed`);
|
|
81
121
|
}
|
|
82
122
|
|
|
83
123
|
return {
|
|
84
|
-
runAfter(ms: number, action: string, args?: any): string {
|
|
124
|
+
runAfter(ms: number, action: string, args?: any, options?: ScheduleOptions): string {
|
|
85
125
|
const id = generateId();
|
|
86
|
-
|
|
126
|
+
const jobEntry: PendingJobEntry = { id, action, scheduledAt: new Date().toISOString(), status: "pending" };
|
|
127
|
+
_pendingJobs.push(jobEntry);
|
|
128
|
+
|
|
87
129
|
const timer = setTimeout(async () => {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
130
|
+
jobEntry.status = "running";
|
|
131
|
+
try {
|
|
132
|
+
await executeAction(action, args);
|
|
133
|
+
jobEntry.status = "completed";
|
|
134
|
+
} catch (error) {
|
|
135
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
136
|
+
jobEntry.status = "failed";
|
|
137
|
+
console.error(`[scheduler] Action "${action}" failed (job: ${id}):`, err.message);
|
|
138
|
+
|
|
139
|
+
// 실패 기록 보관 (dead-letter)
|
|
140
|
+
recordFailure(id, action, args, err);
|
|
141
|
+
|
|
142
|
+
// onError 콜백 실행 — 다른 action에 에러 정보 전달
|
|
143
|
+
if (options?.onError) {
|
|
144
|
+
try {
|
|
145
|
+
await executeAction(options.onError, {
|
|
146
|
+
failedAction: action,
|
|
147
|
+
failedJobId: id,
|
|
148
|
+
error: err.message,
|
|
149
|
+
originalArgs: args,
|
|
150
|
+
});
|
|
151
|
+
console.log(`[scheduler] onError handler "${options.onError}" completed for "${action}"`);
|
|
152
|
+
} catch (onErrorErr) {
|
|
153
|
+
console.error(`[scheduler] onError handler "${options.onError}" also failed:`, onErrorErr);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
} finally {
|
|
157
|
+
timers.delete(id);
|
|
158
|
+
// completed/failed 기록은 잠시 유지 후 정리 (status 조회 가능하도록)
|
|
159
|
+
setTimeout(() => {
|
|
160
|
+
const idx = _pendingJobs.findIndex((j) => j.id === id);
|
|
161
|
+
if (idx >= 0) _pendingJobs.splice(idx, 1);
|
|
162
|
+
}, 60_000); // 1분 후 정리
|
|
163
|
+
}
|
|
92
164
|
}, ms);
|
|
93
165
|
timers.set(id, timer);
|
|
94
166
|
console.log(
|
|
95
|
-
`[scheduler] Scheduled "${action}" to run after ${ms}ms (id: ${id})`
|
|
167
|
+
`[scheduler] Scheduled "${action}" to run after ${ms}ms (id: ${id})${options?.onError ? ` [onError: ${options.onError}]` : ""}`
|
|
96
168
|
);
|
|
97
169
|
return id;
|
|
98
170
|
},
|
|
99
171
|
|
|
100
|
-
runAt(timestamp: number | Date, action: string, args?: any): string {
|
|
172
|
+
runAt(timestamp: number | Date, action: string, args?: any, options?: ScheduleOptions): string {
|
|
101
173
|
const target =
|
|
102
174
|
timestamp instanceof Date ? timestamp.getTime() : timestamp;
|
|
103
175
|
const ms = Math.max(0, target - Date.now());
|
|
104
|
-
return this.runAfter(ms, action, args);
|
|
176
|
+
return this.runAfter(ms, action, args, options);
|
|
105
177
|
},
|
|
106
178
|
|
|
107
179
|
cancel(jobId: string): boolean {
|
|
@@ -109,6 +181,8 @@ export function createScheduler(): Scheduler {
|
|
|
109
181
|
if (timer) {
|
|
110
182
|
clearTimeout(timer);
|
|
111
183
|
timers.delete(jobId);
|
|
184
|
+
const idx = _pendingJobs.findIndex((j) => j.id === jobId);
|
|
185
|
+
if (idx >= 0) _pendingJobs.splice(idx, 1);
|
|
112
186
|
console.log(`[scheduler] Cancelled job ${jobId}`);
|
|
113
187
|
return true;
|
|
114
188
|
}
|
|
@@ -124,7 +198,10 @@ export function createScheduler(): Scheduler {
|
|
|
124
198
|
try {
|
|
125
199
|
await handler();
|
|
126
200
|
} catch (error) {
|
|
127
|
-
|
|
201
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
202
|
+
console.error(`[scheduler] Cron "${name}" failed:`, err.message);
|
|
203
|
+
// cron 실패도 dead-letter에 기록
|
|
204
|
+
recordFailure(`cron_${name}_${Date.now()}`, `cron:${name}`, {}, err);
|
|
128
205
|
}
|
|
129
206
|
});
|
|
130
207
|
cronJobs.set(name, task);
|