@gencow/core 0.1.24 → 0.1.25
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 +2 -2
- package/dist/crud.js +225 -208
- package/dist/index.d.ts +5 -5
- package/dist/index.js +2 -2
- package/dist/reactive.js +10 -3
- package/dist/retry.js +1 -1
- package/dist/rls-db.d.ts +2 -2
- package/dist/rls-db.js +1 -5
- package/dist/scheduler.d.ts +2 -0
- package/dist/scheduler.js +16 -6
- package/dist/server.d.ts +0 -1
- package/dist/server.js +0 -1
- package/dist/storage.js +29 -22
- package/dist/v.d.ts +2 -2
- package/dist/workflow.js +4 -11
- package/dist/workflows-api.js +5 -12
- package/package.json +46 -42
- package/src/__tests__/auth.test.ts +90 -86
- package/src/__tests__/crons.test.ts +69 -67
- package/src/__tests__/crud-codegen-integration.test.ts +164 -170
- package/src/__tests__/crud-owner-rls.test.ts +308 -301
- package/src/__tests__/crud.test.ts +694 -711
- package/src/__tests__/dist-exports.test.ts +120 -120
- package/src/__tests__/fixtures/basic/auth.ts +16 -16
- package/src/__tests__/fixtures/basic/drizzle.config.ts +1 -4
- package/src/__tests__/fixtures/basic/index.ts +1 -1
- package/src/__tests__/fixtures/basic/schema.ts +1 -1
- package/src/__tests__/fixtures/basic/tasks.ts +4 -4
- package/src/__tests__/fixtures/common/auth-schema.ts +38 -34
- package/src/__tests__/helpers/basic-rls-fixture.ts +80 -78
- package/src/__tests__/helpers/pglite-migrations.ts +2 -5
- package/src/__tests__/helpers/pglite-rls-session.ts +13 -16
- package/src/__tests__/helpers/seed-like-fill.ts +50 -44
- package/src/__tests__/helpers/test-gencow-ctx-rls.ts +4 -7
- package/src/__tests__/httpaction.test.ts +91 -91
- package/src/__tests__/image-optimization.test.ts +570 -574
- package/src/__tests__/load.test.ts +321 -308
- package/src/__tests__/network-sim.test.ts +238 -215
- package/src/__tests__/reactive.test.ts +380 -358
- package/src/__tests__/retry.test.ts +99 -84
- package/src/__tests__/rls-crud-basic.test.ts +172 -245
- package/src/__tests__/rls-crud-no-owner-rls-pglite.test.ts +81 -81
- package/src/__tests__/rls-custom-mutation-handlers.test.ts +47 -94
- package/src/__tests__/rls-custom-query-handlers.test.ts +92 -92
- package/src/__tests__/rls-db-leased-connection.test.ts +2 -6
- package/src/__tests__/rls-session-and-policies.test.ts +181 -199
- package/src/__tests__/scheduler-durable-v2.test.ts +199 -181
- package/src/__tests__/scheduler-durable.test.ts +117 -117
- package/src/__tests__/scheduler-exec.test.ts +258 -246
- package/src/__tests__/scheduler.test.ts +129 -111
- package/src/__tests__/storage.test.ts +282 -269
- package/src/__tests__/tsconfig.json +6 -6
- package/src/__tests__/validator.test.ts +236 -232
- package/src/__tests__/workflow.test.ts +309 -286
- package/src/__tests__/ws-integration.test.ts +223 -218
- package/src/__tests__/ws-scale.test.ts +168 -159
- package/src/auth-config.ts +18 -18
- package/src/auth.ts +106 -106
- package/src/crons.ts +77 -77
- package/src/crud.ts +523 -479
- package/src/index.ts +69 -5
- package/src/reactive.ts +357 -331
- package/src/retry.ts +51 -54
- package/src/rls-db.ts +195 -205
- package/src/rls.ts +33 -36
- package/src/scheduler.ts +237 -211
- package/src/server.ts +0 -1
- package/src/storage.ts +632 -593
- package/src/v.ts +119 -114
- package/src/workflow-types.ts +67 -70
- package/src/workflow.ts +99 -116
- package/src/workflows-api.ts +231 -241
- package/src/db.ts +0 -18
package/src/rls.ts
CHANGED
|
@@ -8,10 +8,10 @@ import { pgPolicy, type AnyPgColumn, type PgTable } from "drizzle-orm/pg-core";
|
|
|
8
8
|
// WeakMap이므로 테이블이 GC되면 메타데이터도 자동 해제.
|
|
9
9
|
|
|
10
10
|
export interface OwnerRlsMeta {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
11
|
+
/** userId 컬럼의 DB 이름 (e.g. "user_id") */
|
|
12
|
+
columnName: string;
|
|
13
|
+
/** true면 SELECT에 userId 필터 생략 (누구나 읽기 가능) */
|
|
14
|
+
readPublic: boolean;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
const _ownerRlsRegistry = new WeakMap<PgTable, OwnerRlsMeta>();
|
|
@@ -21,7 +21,7 @@ const _ownerRlsRegistry = new WeakMap<PgTable, OwnerRlsMeta>();
|
|
|
21
21
|
* ownerRls()가 호출되지 않은 테이블은 undefined를 반환.
|
|
22
22
|
*/
|
|
23
23
|
export function getOwnerRlsMeta(table: PgTable): OwnerRlsMeta | undefined {
|
|
24
|
-
|
|
24
|
+
return _ownerRlsRegistry.get(table);
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
/**
|
|
@@ -30,7 +30,7 @@ export function getOwnerRlsMeta(table: PgTable): OwnerRlsMeta | undefined {
|
|
|
30
30
|
* 임시 프록시 객체가 전달될 수 있음 → registerOwnerRls()로 사후 등록.
|
|
31
31
|
*/
|
|
32
32
|
export function registerOwnerRls(table: PgTable, meta: OwnerRlsMeta): void {
|
|
33
|
-
|
|
33
|
+
_ownerRlsRegistry.set(table, meta);
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
// ─── ownerRls — DB-level RLS 정책 선언 + 앱 레벨 메타데이터 등록 ──
|
|
@@ -61,39 +61,36 @@ export function registerOwnerRls(table: PgTable, meta: OwnerRlsMeta): void {
|
|
|
61
61
|
* }, (t) => ownerRls(t.userId, { read: "public" }));
|
|
62
62
|
* ```
|
|
63
63
|
*/
|
|
64
|
-
export function ownerRls(
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
) {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
"Ensure you pass a valid Drizzle column reference (e.g. t.userId)."
|
|
74
|
-
);
|
|
75
|
-
}
|
|
64
|
+
export function ownerRls(userIdColumn: AnyPgColumn, options?: { read?: "public" }) {
|
|
65
|
+
// S3 방어: userIdColumn.name 미존재 시 명확한 에러
|
|
66
|
+
const colName = (userIdColumn as any).name;
|
|
67
|
+
if (!colName) {
|
|
68
|
+
throw new Error(
|
|
69
|
+
"[ownerRls] userIdColumn must have a .name property. " +
|
|
70
|
+
"Ensure you pass a valid Drizzle column reference (e.g. t.userId).",
|
|
71
|
+
);
|
|
72
|
+
}
|
|
76
73
|
|
|
77
|
-
|
|
78
|
-
|
|
74
|
+
/** `missing_ok` avoids errors before first `set_config` and matches PG custom GUC behavior in PGlite. */
|
|
75
|
+
const isOwner = sql`${userIdColumn} = current_setting('app.current_user_id', true)`;
|
|
79
76
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
77
|
+
// ── 앱 레벨 메타데이터: crud()가 런타임에 읽음 ──
|
|
78
|
+
const meta: OwnerRlsMeta = {
|
|
79
|
+
columnName: colName,
|
|
80
|
+
readPublic: options?.read === "public",
|
|
81
|
+
};
|
|
85
82
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
83
|
+
const policies = [
|
|
84
|
+
pgPolicy("rls-select", { for: "select", using: options?.read === "public" ? sql`true` : isOwner }),
|
|
85
|
+
pgPolicy("rls-insert", { for: "insert", withCheck: isOwner }),
|
|
86
|
+
pgPolicy("rls-update", { for: "update", using: isOwner, withCheck: isOwner }),
|
|
87
|
+
pgPolicy("rls-delete", { for: "delete", using: isOwner }),
|
|
88
|
+
];
|
|
92
89
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
90
|
+
// N2 정리: non-enumerable _ownerRlsMeta 마커 제거 (dead code).
|
|
91
|
+
// ownerRls()는 pgTable extraConfig 콜백에서 호출되며, 이 시점에서는
|
|
92
|
+
// 테이블 참조가 프록시이므로 WeakMap 등록 불가능.
|
|
93
|
+
// 실제 메타데이터 등록은 crud()의 detectOwnerMeta() fallback에서 수행.
|
|
97
94
|
|
|
98
|
-
|
|
95
|
+
return policies;
|
|
99
96
|
}
|
package/src/scheduler.ts
CHANGED
|
@@ -6,17 +6,17 @@ type ActionHandler = (args: any) => Promise<any>;
|
|
|
6
6
|
|
|
7
7
|
/** runAfter/runAt 옵션 — onError dead-letter 콜백 지원 */
|
|
8
8
|
export interface ScheduleOptions {
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
/** 실패 시 호출할 action 이름 (dead-letter 패턴) */
|
|
10
|
+
onError?: string;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
/** scheduled job의 DB 영속화를 위한 콜백 */
|
|
14
14
|
export interface ScheduledJobRecord {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
15
|
+
id: string;
|
|
16
|
+
action: string;
|
|
17
|
+
args: unknown;
|
|
18
|
+
runAt: Date;
|
|
19
|
+
onErrorAction?: string;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
/**
|
|
@@ -28,34 +28,36 @@ export interface ScheduledJobRecord {
|
|
|
28
28
|
* 콜백 미설정 시 기존 인메모리 setTimeout 동작 유지 (로컬 dev).
|
|
29
29
|
*/
|
|
30
30
|
export interface CreateSchedulerOptions {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
31
|
+
/** Job을 DB에 영속화 (INSERT) */
|
|
32
|
+
persistJob?: (job: ScheduledJobRecord) => Promise<void>;
|
|
33
|
+
/** Job을 DB에서 제거 (DELETE) — cancel/완료 시 */
|
|
34
|
+
removeJob?: (jobId: string) => Promise<boolean>;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
/** 실패한 작업의 기록 */
|
|
38
38
|
export interface FailedJob {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
39
|
+
id: string;
|
|
40
|
+
action: string;
|
|
41
|
+
args: any;
|
|
42
|
+
error: string;
|
|
43
|
+
failedAt: string;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
export interface Scheduler {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
47
|
+
/** Schedule a function to run after a delay — Convex의 ctx.scheduler.runAfter() */
|
|
48
|
+
runAfter(ms: number, action: string, args?: any, options?: ScheduleOptions): string;
|
|
49
|
+
/** Schedule a function at a specific time — Convex의 ctx.scheduler.runAt() */
|
|
50
|
+
runAt(timestamp: number | Date, action: string, args?: any, options?: ScheduleOptions): string;
|
|
51
|
+
/** Cancel a scheduled function */
|
|
52
|
+
cancel(jobId: string): boolean;
|
|
53
|
+
/** Register a cron job — Convex의 cronJobs() */
|
|
54
|
+
cron(name: string, pattern: string, handler: () => Promise<void>): void;
|
|
55
|
+
/** Register an action handler */
|
|
56
|
+
registerAction(name: string, handler: ActionHandler): void;
|
|
57
|
+
/** Execute a registered action and propagate errors to the caller */
|
|
58
|
+
executeActionStrict(name: string, args?: any): Promise<void>;
|
|
59
|
+
/** Execute a registered action by name — 선언적 crons.ts 문자열 액션 실행용 */
|
|
60
|
+
executeAction(name: string, args?: any): Promise<void>;
|
|
59
61
|
}
|
|
60
62
|
|
|
61
63
|
// ─── Implementation ─────────────────────────────────────
|
|
@@ -90,216 +92,240 @@ export interface Scheduler {
|
|
|
90
92
|
// query/mutation 레지스트리(globalThis.__gencow_*)와 동일 패턴.
|
|
91
93
|
// @gencow/core가 다중 resolve되어도 단일 배열 공유.
|
|
92
94
|
|
|
93
|
-
interface CronInfoEntry {
|
|
95
|
+
interface CronInfoEntry {
|
|
96
|
+
name: string;
|
|
97
|
+
pattern: string;
|
|
98
|
+
registeredAt: string;
|
|
99
|
+
}
|
|
94
100
|
interface PendingJobEntry {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
101
|
+
id: string;
|
|
102
|
+
action: string;
|
|
103
|
+
scheduledAt: string;
|
|
104
|
+
status?: "pending" | "running" | "completed" | "failed";
|
|
99
105
|
}
|
|
100
106
|
|
|
101
|
-
const _cronInfo: CronInfoEntry[] =
|
|
102
|
-
|
|
103
|
-
const
|
|
104
|
-
(globalThis as any).__gencow_pendingJobs ??= [];
|
|
105
|
-
const _failedJobs: FailedJob[] =
|
|
106
|
-
(globalThis as any).__gencow_failedJobs ??= [];
|
|
107
|
+
const _cronInfo: CronInfoEntry[] = ((globalThis as any).__gencow_cronInfo ??= []);
|
|
108
|
+
const _pendingJobs: PendingJobEntry[] = ((globalThis as any).__gencow_pendingJobs ??= []);
|
|
109
|
+
const _failedJobs: FailedJob[] = ((globalThis as any).__gencow_failedJobs ??= []);
|
|
107
110
|
|
|
108
111
|
/** 최대 보관할 실패 작업 수 (메모리 보호) */
|
|
109
112
|
const MAX_FAILED_JOBS = 100;
|
|
110
113
|
|
|
111
114
|
export function getSchedulerInfo() {
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
115
|
+
return {
|
|
116
|
+
crons: _cronInfo,
|
|
117
|
+
pendingJobs: _pendingJobs,
|
|
118
|
+
failedJobs: _failedJobs,
|
|
119
|
+
};
|
|
117
120
|
}
|
|
118
121
|
|
|
119
122
|
export function createScheduler(options?: CreateSchedulerOptions): Scheduler {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
123
|
+
const timers = new Map<string, NodeJS.Timeout>();
|
|
124
|
+
const isDurable = !!(options?.persistJob && options?.removeJob);
|
|
125
|
+
const cronJobs = new Map<string, cron.ScheduledTask>();
|
|
126
|
+
const actions = new Map<string, ActionHandler>();
|
|
124
127
|
|
|
125
|
-
|
|
128
|
+
let jobCounter = 0;
|
|
126
129
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
+
function generateId(): string {
|
|
131
|
+
return `job_${++jobCounter}_${Date.now()}`;
|
|
132
|
+
}
|
|
130
133
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
}
|
|
134
|
+
/** 실패 기록 추가 (dead-letter 레지스트리) */
|
|
135
|
+
function recordFailure(id: string, action: string, args: any, error: Error): void {
|
|
136
|
+
_failedJobs.push({
|
|
137
|
+
id,
|
|
138
|
+
action,
|
|
139
|
+
args,
|
|
140
|
+
error: error.message,
|
|
141
|
+
failedAt: new Date().toISOString(),
|
|
142
|
+
});
|
|
143
|
+
// 메모리 보호: 오래된 실패 기록 정리
|
|
144
|
+
while (_failedJobs.length > MAX_FAILED_JOBS) {
|
|
145
|
+
_failedJobs.shift();
|
|
144
146
|
}
|
|
147
|
+
}
|
|
145
148
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
}
|
|
152
|
-
await handler(args);
|
|
153
|
-
console.log(`[scheduler] Action "${action}" completed`);
|
|
149
|
+
async function executeAction(action: string, args: any): Promise<void> {
|
|
150
|
+
const handler = actions.get(action);
|
|
151
|
+
if (!handler) {
|
|
152
|
+
console.error(`[scheduler] Action "${action}" not registered`);
|
|
153
|
+
throw new Error(`Action "${action}" not registered`);
|
|
154
154
|
}
|
|
155
|
+
await handler(args);
|
|
156
|
+
console.log(`[scheduler] Action "${action}" completed`);
|
|
157
|
+
}
|
|
155
158
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
159
|
+
/** @internal 인메모리 setTimeout 기반 스케줄링 (로컬 dev + durable fallback) */
|
|
160
|
+
function scheduleInMemory(
|
|
161
|
+
id: string,
|
|
162
|
+
ms: number,
|
|
163
|
+
action: string,
|
|
164
|
+
args: any,
|
|
165
|
+
scheduleOpts: ScheduleOptions | undefined,
|
|
166
|
+
jobEntry: PendingJobEntry,
|
|
167
|
+
): void {
|
|
168
|
+
const timer = setTimeout(async () => {
|
|
169
|
+
jobEntry.status = "running";
|
|
170
|
+
try {
|
|
171
|
+
await executeAction(action, args);
|
|
172
|
+
jobEntry.status = "completed";
|
|
173
|
+
} catch (error) {
|
|
174
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
175
|
+
jobEntry.status = "failed";
|
|
176
|
+
console.error(`[scheduler] Action "${action}" failed (job: ${id}):`, err.message);
|
|
167
177
|
|
|
168
|
-
|
|
169
|
-
|
|
178
|
+
// 실패 기록 보관 (dead-letter)
|
|
179
|
+
recordFailure(id, action, args, err);
|
|
170
180
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
return {
|
|
201
|
-
runAfter(ms: number, action: string, args?: any, scheduleOpts?: ScheduleOptions): string {
|
|
202
|
-
const id = generateId();
|
|
203
|
-
const jobEntry: PendingJobEntry = { id, action, scheduledAt: new Date().toISOString(), status: "pending" };
|
|
204
|
-
_pendingJobs.push(jobEntry);
|
|
181
|
+
// onError 콜백 실행 — 다른 action에 에러 정보 전달
|
|
182
|
+
if (scheduleOpts?.onError) {
|
|
183
|
+
try {
|
|
184
|
+
await executeAction(scheduleOpts.onError, {
|
|
185
|
+
failedAction: action,
|
|
186
|
+
failedJobId: id,
|
|
187
|
+
error: err.message,
|
|
188
|
+
originalArgs: args,
|
|
189
|
+
});
|
|
190
|
+
console.log(`[scheduler] onError handler "${scheduleOpts.onError}" completed for "${action}"`);
|
|
191
|
+
} catch (onErrorErr) {
|
|
192
|
+
console.error(`[scheduler] onError handler "${scheduleOpts.onError}" also failed:`, onErrorErr);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
} finally {
|
|
196
|
+
timers.delete(id);
|
|
197
|
+
// completed/failed 기록은 잠시 유지 후 정리 (status 조회 가능하도록)
|
|
198
|
+
setTimeout(() => {
|
|
199
|
+
const idx = _pendingJobs.findIndex((j) => j.id === id);
|
|
200
|
+
if (idx >= 0) _pendingJobs.splice(idx, 1);
|
|
201
|
+
}, 60_000); // 1분 후 정리
|
|
202
|
+
}
|
|
203
|
+
}, ms);
|
|
204
|
+
timers.set(id, timer);
|
|
205
|
+
console.log(
|
|
206
|
+
`[scheduler] Scheduled "${action}" to run after ${ms}ms (id: ${id})${scheduleOpts?.onError ? ` [onError: ${scheduleOpts.onError}]` : ""}`,
|
|
207
|
+
);
|
|
208
|
+
}
|
|
205
209
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
console.log(
|
|
217
|
-
`[scheduler] Persisted "${action}" to run at ${runAt.toISOString()} (id: ${id}, durable)` +
|
|
218
|
-
`${scheduleOpts?.onError ? ` [onError: ${scheduleOpts.onError}]` : ""}`
|
|
219
|
-
);
|
|
220
|
-
}).catch((err) => {
|
|
221
|
-
console.error(`[scheduler] Failed to persist job ${id}:`, err instanceof Error ? err.message : err);
|
|
222
|
-
// DB persist 실패 → 인메모리 fallback
|
|
223
|
-
scheduleInMemory(id, ms, action, args, scheduleOpts, jobEntry);
|
|
224
|
-
});
|
|
225
|
-
return id;
|
|
226
|
-
}
|
|
210
|
+
return {
|
|
211
|
+
runAfter(ms: number, action: string, args?: any, scheduleOpts?: ScheduleOptions): string {
|
|
212
|
+
const id = generateId();
|
|
213
|
+
const jobEntry: PendingJobEntry = {
|
|
214
|
+
id,
|
|
215
|
+
action,
|
|
216
|
+
scheduledAt: new Date().toISOString(),
|
|
217
|
+
status: "pending",
|
|
218
|
+
};
|
|
219
|
+
_pendingJobs.push(jobEntry);
|
|
227
220
|
|
|
228
|
-
|
|
221
|
+
// ── Durable mode: DB에 영속화, 실행은 외부 폴러에 위임 ──
|
|
222
|
+
if (isDurable) {
|
|
223
|
+
const runAt = new Date(Date.now() + ms);
|
|
224
|
+
options!.persistJob!({
|
|
225
|
+
id,
|
|
226
|
+
action,
|
|
227
|
+
args: args ?? {},
|
|
228
|
+
runAt,
|
|
229
|
+
onErrorAction: scheduleOpts?.onError,
|
|
230
|
+
})
|
|
231
|
+
.then(() => {
|
|
232
|
+
console.log(
|
|
233
|
+
`[scheduler] Persisted "${action}" to run at ${runAt.toISOString()} (id: ${id}, durable)` +
|
|
234
|
+
`${scheduleOpts?.onError ? ` [onError: ${scheduleOpts.onError}]` : ""}`,
|
|
235
|
+
);
|
|
236
|
+
})
|
|
237
|
+
.catch((err) => {
|
|
238
|
+
console.error(
|
|
239
|
+
`[scheduler] Failed to persist job ${id}:`,
|
|
240
|
+
err instanceof Error ? err.message : err,
|
|
241
|
+
);
|
|
242
|
+
// DB persist 실패 → 인메모리 fallback
|
|
229
243
|
scheduleInMemory(id, ms, action, args, scheduleOpts, jobEntry);
|
|
230
|
-
|
|
231
|
-
|
|
244
|
+
});
|
|
245
|
+
return id;
|
|
246
|
+
}
|
|
232
247
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
return this.runAfter(ms, action, args, options);
|
|
238
|
-
},
|
|
248
|
+
// ── 인메모리 mode: 기존 setTimeout 동작 ──
|
|
249
|
+
scheduleInMemory(id, ms, action, args, scheduleOpts, jobEntry);
|
|
250
|
+
return id;
|
|
251
|
+
},
|
|
239
252
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
_pendingJobs.splice(idx, 1);
|
|
246
|
-
options!.removeJob!(jobId).catch((err) => {
|
|
247
|
-
console.error(`[scheduler] Failed to remove persisted job ${jobId}:`, err instanceof Error ? err.message : err);
|
|
248
|
-
});
|
|
249
|
-
console.log(`[scheduler] Cancelled job ${jobId} (durable)`);
|
|
250
|
-
return true;
|
|
251
|
-
}
|
|
252
|
-
// pendingJobs에 없어도 DB에는 있을 수 있으므로 삭제 시도
|
|
253
|
-
options!.removeJob!(jobId).catch(() => {});
|
|
254
|
-
return false;
|
|
255
|
-
}
|
|
253
|
+
runAt(timestamp: number | Date, action: string, args?: any, options?: ScheduleOptions): string {
|
|
254
|
+
const target = timestamp instanceof Date ? timestamp.getTime() : timestamp;
|
|
255
|
+
const ms = Math.max(0, target - Date.now());
|
|
256
|
+
return this.runAfter(ms, action, args, options);
|
|
257
|
+
},
|
|
256
258
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
259
|
+
cancel(jobId: string): boolean {
|
|
260
|
+
// ── Durable mode: DB에서도 제거 ──
|
|
261
|
+
if (isDurable) {
|
|
262
|
+
const idx = _pendingJobs.findIndex((j) => j.id === jobId);
|
|
263
|
+
if (idx >= 0) {
|
|
264
|
+
_pendingJobs.splice(idx, 1);
|
|
265
|
+
options!.removeJob!(jobId).catch((err) => {
|
|
266
|
+
console.error(
|
|
267
|
+
`[scheduler] Failed to remove persisted job ${jobId}:`,
|
|
268
|
+
err instanceof Error ? err.message : err,
|
|
269
|
+
);
|
|
270
|
+
});
|
|
271
|
+
console.log(`[scheduler] Cancelled job ${jobId} (durable)`);
|
|
272
|
+
return true;
|
|
273
|
+
}
|
|
274
|
+
// pendingJobs에 없어도 DB에는 있을 수 있으므로 삭제 시도
|
|
275
|
+
options!.removeJob!(jobId).catch(() => {});
|
|
276
|
+
return false;
|
|
277
|
+
}
|
|
269
278
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
}
|
|
279
|
+
// ── 인메모리 mode ──
|
|
280
|
+
const timer = timers.get(jobId);
|
|
281
|
+
if (timer) {
|
|
282
|
+
clearTimeout(timer);
|
|
283
|
+
timers.delete(jobId);
|
|
284
|
+
const idx = _pendingJobs.findIndex((j) => j.id === jobId);
|
|
285
|
+
if (idx >= 0) _pendingJobs.splice(idx, 1);
|
|
286
|
+
console.log(`[scheduler] Cancelled job ${jobId}`);
|
|
287
|
+
return true;
|
|
288
|
+
}
|
|
289
|
+
return false;
|
|
290
|
+
},
|
|
291
|
+
|
|
292
|
+
cron(name: string, pattern: string, handler: () => Promise<void>): void {
|
|
293
|
+
if (cronJobs.has(name)) {
|
|
294
|
+
cronJobs.get(name)!.stop();
|
|
295
|
+
}
|
|
296
|
+
const task = cron.schedule(pattern, async () => {
|
|
297
|
+
console.log(`[scheduler] Cron "${name}" triggered`);
|
|
298
|
+
try {
|
|
299
|
+
await handler();
|
|
300
|
+
} catch (error) {
|
|
301
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
302
|
+
console.error(`[scheduler] Cron "${name}" failed:`, err.message);
|
|
303
|
+
// cron 실패도 dead-letter에 기록
|
|
304
|
+
recordFailure(`cron_${name}_${Date.now()}`, `cron:${name}`, {}, err);
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
cronJobs.set(name, task);
|
|
308
|
+
_cronInfo.push({ name, pattern, registeredAt: new Date().toISOString() });
|
|
309
|
+
console.log(`[scheduler] Registered cron "${name}" with pattern "${pattern}"`);
|
|
310
|
+
},
|
|
311
|
+
|
|
312
|
+
registerAction(name: string, handler: ActionHandler): void {
|
|
313
|
+
actions.set(name, handler);
|
|
314
|
+
},
|
|
289
315
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
316
|
+
async executeActionStrict(name: string, args?: any): Promise<void> {
|
|
317
|
+
await executeAction(name, args);
|
|
318
|
+
},
|
|
293
319
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
320
|
+
async executeAction(name: string, args?: any): Promise<void> {
|
|
321
|
+
try {
|
|
322
|
+
await executeAction(name, args);
|
|
323
|
+
} catch (error) {
|
|
324
|
+
// 공개 API는 에러를 삼긴다 (호출자 크래시 방지)
|
|
325
|
+
// handler 에러도 로깅 (내부 함수는 미등록만 로깅, handler 에러는 미로깅)
|
|
326
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
327
|
+
console.error(`[scheduler] executeAction("${name}") failed: ${msg}`);
|
|
328
|
+
}
|
|
329
|
+
},
|
|
330
|
+
};
|
|
305
331
|
}
|
package/src/server.ts
CHANGED
|
@@ -5,7 +5,6 @@
|
|
|
5
5
|
* executing server. Excluded from client-side core (`index.ts`) so they aren't
|
|
6
6
|
* bundled into user functions which run in Firecracker.
|
|
7
7
|
*/
|
|
8
|
-
export { createDb } from "./db.js";
|
|
9
8
|
export { createStorage, storageRoutes } from "./storage.js";
|
|
10
9
|
export type { StorageImageTierConfig } from "./storage.js";
|
|
11
10
|
export { createScheduler, getSchedulerInfo } from "./scheduler.js";
|