@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.
Files changed (73) hide show
  1. package/dist/crud.d.ts +2 -2
  2. package/dist/crud.js +225 -208
  3. package/dist/index.d.ts +5 -5
  4. package/dist/index.js +2 -2
  5. package/dist/reactive.js +10 -3
  6. package/dist/retry.js +1 -1
  7. package/dist/rls-db.d.ts +2 -2
  8. package/dist/rls-db.js +1 -5
  9. package/dist/scheduler.d.ts +2 -0
  10. package/dist/scheduler.js +16 -6
  11. package/dist/server.d.ts +0 -1
  12. package/dist/server.js +0 -1
  13. package/dist/storage.js +29 -22
  14. package/dist/v.d.ts +2 -2
  15. package/dist/workflow.js +4 -11
  16. package/dist/workflows-api.js +5 -12
  17. package/package.json +46 -42
  18. package/src/__tests__/auth.test.ts +90 -86
  19. package/src/__tests__/crons.test.ts +69 -67
  20. package/src/__tests__/crud-codegen-integration.test.ts +164 -170
  21. package/src/__tests__/crud-owner-rls.test.ts +308 -301
  22. package/src/__tests__/crud.test.ts +694 -711
  23. package/src/__tests__/dist-exports.test.ts +120 -120
  24. package/src/__tests__/fixtures/basic/auth.ts +16 -16
  25. package/src/__tests__/fixtures/basic/drizzle.config.ts +1 -4
  26. package/src/__tests__/fixtures/basic/index.ts +1 -1
  27. package/src/__tests__/fixtures/basic/schema.ts +1 -1
  28. package/src/__tests__/fixtures/basic/tasks.ts +4 -4
  29. package/src/__tests__/fixtures/common/auth-schema.ts +38 -34
  30. package/src/__tests__/helpers/basic-rls-fixture.ts +80 -78
  31. package/src/__tests__/helpers/pglite-migrations.ts +2 -5
  32. package/src/__tests__/helpers/pglite-rls-session.ts +13 -16
  33. package/src/__tests__/helpers/seed-like-fill.ts +50 -44
  34. package/src/__tests__/helpers/test-gencow-ctx-rls.ts +4 -7
  35. package/src/__tests__/httpaction.test.ts +91 -91
  36. package/src/__tests__/image-optimization.test.ts +570 -574
  37. package/src/__tests__/load.test.ts +321 -308
  38. package/src/__tests__/network-sim.test.ts +238 -215
  39. package/src/__tests__/reactive.test.ts +380 -358
  40. package/src/__tests__/retry.test.ts +99 -84
  41. package/src/__tests__/rls-crud-basic.test.ts +172 -245
  42. package/src/__tests__/rls-crud-no-owner-rls-pglite.test.ts +81 -81
  43. package/src/__tests__/rls-custom-mutation-handlers.test.ts +47 -94
  44. package/src/__tests__/rls-custom-query-handlers.test.ts +92 -92
  45. package/src/__tests__/rls-db-leased-connection.test.ts +2 -6
  46. package/src/__tests__/rls-session-and-policies.test.ts +181 -199
  47. package/src/__tests__/scheduler-durable-v2.test.ts +199 -181
  48. package/src/__tests__/scheduler-durable.test.ts +117 -117
  49. package/src/__tests__/scheduler-exec.test.ts +258 -246
  50. package/src/__tests__/scheduler.test.ts +129 -111
  51. package/src/__tests__/storage.test.ts +282 -269
  52. package/src/__tests__/tsconfig.json +6 -6
  53. package/src/__tests__/validator.test.ts +236 -232
  54. package/src/__tests__/workflow.test.ts +309 -286
  55. package/src/__tests__/ws-integration.test.ts +223 -218
  56. package/src/__tests__/ws-scale.test.ts +168 -159
  57. package/src/auth-config.ts +18 -18
  58. package/src/auth.ts +106 -106
  59. package/src/crons.ts +77 -77
  60. package/src/crud.ts +523 -479
  61. package/src/index.ts +69 -5
  62. package/src/reactive.ts +357 -331
  63. package/src/retry.ts +51 -54
  64. package/src/rls-db.ts +195 -205
  65. package/src/rls.ts +33 -36
  66. package/src/scheduler.ts +237 -211
  67. package/src/server.ts +0 -1
  68. package/src/storage.ts +632 -593
  69. package/src/v.ts +119 -114
  70. package/src/workflow-types.ts +67 -70
  71. package/src/workflow.ts +99 -116
  72. package/src/workflows-api.ts +231 -241
  73. 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
- /** userId 컬럼의 DB 이름 (e.g. "user_id") */
12
- columnName: string;
13
- /** true면 SELECT에 userId 필터 생략 (누구나 읽기 가능) */
14
- readPublic: boolean;
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
- return _ownerRlsRegistry.get(table);
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
- _ownerRlsRegistry.set(table, meta);
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
- userIdColumn: AnyPgColumn,
66
- options?: { read?: "public" },
67
- ) {
68
- // S3 방어: userIdColumn.name 미존재 시 명확한 에러
69
- const colName = (userIdColumn as any).name;
70
- if (!colName) {
71
- throw new Error(
72
- "[ownerRls] userIdColumn must have a .name property. " +
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
- /** `missing_ok` avoids errors before first `set_config` and matches PG custom GUC behavior in PGlite. */
78
- const isOwner = sql`${userIdColumn} = current_setting('app.current_user_id', true)`;
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
- // ── 앱 레벨 메타데이터: crud()가 런타임에 읽음 ──
81
- const meta: OwnerRlsMeta = {
82
- columnName: colName,
83
- readPublic: options?.read === "public",
84
- };
77
+ // ── 앱 레벨 메타데이터: crud()가 런타임에 읽음 ──
78
+ const meta: OwnerRlsMeta = {
79
+ columnName: colName,
80
+ readPublic: options?.read === "public",
81
+ };
85
82
 
86
- const policies = [
87
- pgPolicy("rls-select", { for: "select", using: options?.read === "public" ? sql`true` : isOwner }),
88
- pgPolicy("rls-insert", { for: "insert", withCheck: isOwner }),
89
- pgPolicy("rls-update", { for: "update", using: isOwner, withCheck: isOwner }),
90
- pgPolicy("rls-delete", { for: "delete", using: isOwner }),
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
- // N2 정리: non-enumerable _ownerRlsMeta 마커 제거 (dead code).
94
- // ownerRls()는 pgTable extraConfig 콜백에서 호출되며, 이 시점에서는
95
- // 테이블 참조가 프록시이므로 WeakMap 등록 불가능.
96
- // 실제 메타데이터 등록은 crud()의 detectOwnerMeta() fallback에서 수행.
90
+ // N2 정리: non-enumerable _ownerRlsMeta 마커 제거 (dead code).
91
+ // ownerRls()는 pgTable extraConfig 콜백에서 호출되며, 이 시점에서는
92
+ // 테이블 참조가 프록시이므로 WeakMap 등록 불가능.
93
+ // 실제 메타데이터 등록은 crud()의 detectOwnerMeta() fallback에서 수행.
97
94
 
98
- return policies;
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
- /** 실패 시 호출할 action 이름 (dead-letter 패턴) */
10
- onError?: string;
9
+ /** 실패 시 호출할 action 이름 (dead-letter 패턴) */
10
+ onError?: string;
11
11
  }
12
12
 
13
13
  /** scheduled job의 DB 영속화를 위한 콜백 */
14
14
  export interface ScheduledJobRecord {
15
- id: string;
16
- action: string;
17
- args: unknown;
18
- runAt: Date;
19
- onErrorAction?: string;
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
- /** Job을 DB에 영속화 (INSERT) */
32
- persistJob?: (job: ScheduledJobRecord) => Promise<void>;
33
- /** Job을 DB에서 제거 (DELETE) — cancel/완료 시 */
34
- removeJob?: (jobId: string) => Promise<boolean>;
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
- id: string;
40
- action: string;
41
- args: any;
42
- error: string;
43
- failedAt: string;
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
- /** 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 by name 선언적 crons.ts 문자열 액션 실행용 */
58
- executeAction(name: string, args?: any): Promise<void>;
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 { name: string; pattern: string; registeredAt: string }
95
+ interface CronInfoEntry {
96
+ name: string;
97
+ pattern: string;
98
+ registeredAt: string;
99
+ }
94
100
  interface PendingJobEntry {
95
- id: string;
96
- action: string;
97
- scheduledAt: string;
98
- status?: "pending" | "running" | "completed" | "failed";
101
+ id: string;
102
+ action: string;
103
+ scheduledAt: string;
104
+ status?: "pending" | "running" | "completed" | "failed";
99
105
  }
100
106
 
101
- const _cronInfo: CronInfoEntry[] =
102
- (globalThis as any).__gencow_cronInfo ??= [];
103
- const _pendingJobs: PendingJobEntry[] =
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
- return {
113
- crons: _cronInfo,
114
- pendingJobs: _pendingJobs,
115
- failedJobs: _failedJobs,
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
- const timers = new Map<string, NodeJS.Timeout>();
121
- const isDurable = !!(options?.persistJob && options?.removeJob);
122
- const cronJobs = new Map<string, cron.ScheduledTask>();
123
- const actions = new Map<string, ActionHandler>();
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
- let jobCounter = 0;
128
+ let jobCounter = 0;
126
129
 
127
- function generateId(): string {
128
- return `job_${++jobCounter}_${Date.now()}`;
129
- }
130
+ function generateId(): string {
131
+ return `job_${++jobCounter}_${Date.now()}`;
132
+ }
130
133
 
131
- /** 실패 기록 추가 (dead-letter 레지스트리) */
132
- function recordFailure(id: string, action: string, args: any, error: Error): void {
133
- _failedJobs.push({
134
- id,
135
- action,
136
- args,
137
- error: error.message,
138
- failedAt: new Date().toISOString(),
139
- });
140
- // 메모리 보호: 오래된 실패 기록 정리
141
- while (_failedJobs.length > MAX_FAILED_JOBS) {
142
- _failedJobs.shift();
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
- async function executeAction(action: string, args: any): Promise<void> {
147
- const handler = actions.get(action);
148
- if (!handler) {
149
- console.error(`[scheduler] Action "${action}" not registered`);
150
- throw new Error(`Action "${action}" not registered`);
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
- /** @internal 인메모리 setTimeout 기반 스케줄링 (로컬 dev + durable fallback) */
157
- function scheduleInMemory(id: string, ms: number, action: string, args: any, scheduleOpts: ScheduleOptions | undefined, jobEntry: PendingJobEntry): void {
158
- const timer = setTimeout(async () => {
159
- jobEntry.status = "running";
160
- try {
161
- await executeAction(action, args);
162
- jobEntry.status = "completed";
163
- } catch (error) {
164
- const err = error instanceof Error ? error : new Error(String(error));
165
- jobEntry.status = "failed";
166
- console.error(`[scheduler] Action "${action}" failed (job: ${id}):`, err.message);
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
- // 실패 기록 보관 (dead-letter)
169
- recordFailure(id, action, args, err);
178
+ // 실패 기록 보관 (dead-letter)
179
+ recordFailure(id, action, args, err);
170
180
 
171
- // onError 콜백 실행 — 다른 action에 에러 정보 전달
172
- if (scheduleOpts?.onError) {
173
- try {
174
- await executeAction(scheduleOpts.onError, {
175
- failedAction: action,
176
- failedJobId: id,
177
- error: err.message,
178
- originalArgs: args,
179
- });
180
- console.log(`[scheduler] onError handler "${scheduleOpts.onError}" completed for "${action}"`);
181
- } catch (onErrorErr) {
182
- console.error(`[scheduler] onError handler "${scheduleOpts.onError}" also failed:`, onErrorErr);
183
- }
184
- }
185
- } finally {
186
- timers.delete(id);
187
- // completed/failed 기록은 잠시 유지 후 정리 (status 조회 가능하도록)
188
- setTimeout(() => {
189
- const idx = _pendingJobs.findIndex((j) => j.id === id);
190
- if (idx >= 0) _pendingJobs.splice(idx, 1);
191
- }, 60_000); // 1분 후 정리
192
- }
193
- }, ms);
194
- timers.set(id, timer);
195
- console.log(
196
- `[scheduler] Scheduled "${action}" to run after ${ms}ms (id: ${id})${scheduleOpts?.onError ? ` [onError: ${scheduleOpts.onError}]` : ""}`
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
- // ── Durable mode: DB에 영속화, 실행은 외부 폴러에 위임 ──
207
- if (isDurable) {
208
- const runAt = new Date(Date.now() + ms);
209
- options!.persistJob!({
210
- id,
211
- action,
212
- args: args ?? {},
213
- runAt,
214
- onErrorAction: scheduleOpts?.onError,
215
- }).then(() => {
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
- // ── 인메모리 mode: 기존 setTimeout 동작 ──
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
- return id;
231
- },
244
+ });
245
+ return id;
246
+ }
232
247
 
233
- runAt(timestamp: number | Date, action: string, args?: any, options?: ScheduleOptions): string {
234
- const target =
235
- timestamp instanceof Date ? timestamp.getTime() : timestamp;
236
- const ms = Math.max(0, target - Date.now());
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
- cancel(jobId: string): boolean {
241
- // ── Durable mode: DB에서도 제거 ──
242
- if (isDurable) {
243
- const idx = _pendingJobs.findIndex((j) => j.id === jobId);
244
- if (idx >= 0) {
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
- // ── 인메모리 mode ──
258
- const timer = timers.get(jobId);
259
- if (timer) {
260
- clearTimeout(timer);
261
- timers.delete(jobId);
262
- const idx = _pendingJobs.findIndex((j) => j.id === jobId);
263
- if (idx >= 0) _pendingJobs.splice(idx, 1);
264
- console.log(`[scheduler] Cancelled job ${jobId}`);
265
- return true;
266
- }
267
- return false;
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
- cron(name: string, pattern: string, handler: () => Promise<void>): void {
271
- if (cronJobs.has(name)) {
272
- cronJobs.get(name)!.stop();
273
- }
274
- const task = cron.schedule(pattern, async () => {
275
- console.log(`[scheduler] Cron "${name}" triggered`);
276
- try {
277
- await handler();
278
- } catch (error) {
279
- const err = error instanceof Error ? error : new Error(String(error));
280
- console.error(`[scheduler] Cron "${name}" failed:`, err.message);
281
- // cron 실패도 dead-letter에 기록
282
- recordFailure(`cron_${name}_${Date.now()}`, `cron:${name}`, {}, err);
283
- }
284
- });
285
- cronJobs.set(name, task);
286
- _cronInfo.push({ name, pattern, registeredAt: new Date().toISOString() });
287
- console.log(`[scheduler] Registered cron "${name}" with pattern "${pattern}"`);
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
- registerAction(name: string, handler: ActionHandler): void {
291
- actions.set(name, handler);
292
- },
316
+ async executeActionStrict(name: string, args?: any): Promise<void> {
317
+ await executeAction(name, args);
318
+ },
293
319
 
294
- async executeAction(name: string, args?: any): Promise<void> {
295
- try {
296
- await executeAction(name, args);
297
- } catch (error) {
298
- // 공개 API는 에러를 삼긴다 (호출자 크래시 방지)
299
- // handler 에러도 로깅 (내부 함수는 미등록만 로깅, handler 에러는 미로깅)
300
- const msg = error instanceof Error ? error.message : String(error);
301
- console.error(`[scheduler] executeAction("${name}") failed: ${msg}`);
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";