@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 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 { gencowTable, ownerFilter, getTableAccessMeta, isGencowTable, getAllGencowTables } from "./table";
21
- export type { GencowTableOptions, AccessFilter, FieldAccessRule, TableAccessMeta } from "./table";
22
- export { createScopedDb, applyFieldAccess } from "./scoped-db";
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
- // ─── Data Isolation (gencowTable + scoped DB) ───────────
14
- export { gencowTable, ownerFilter, getTableAccessMeta, isGencowTable, getAllGencowTables } from "./table";
15
- export { createScopedDb, applyFieldAccess } from "./scoped-db";
13
+ // ─── RLS + CRUD Factory ───────────
14
+ export { ownerRls } from "./rls";
15
+ export { createRlsDb } from "./rls-db";
16
+ export { gencowCrud } from "./crud";
@@ -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
@@ -0,0 +1,4 @@
1
+ import { type AnyPgColumn } from "drizzle-orm/pg-core";
2
+ export declare function ownerRls(userIdColumn: AnyPgColumn, options?: {
3
+ read?: "public";
4
+ }): import("drizzle-orm/pg-core").PgPolicy[];
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
+ }
@@ -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
- return;
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
- _pendingJobs.push({ id, action, scheduledAt: new Date().toISOString() });
48
+ const jobEntry = { id, action, scheduledAt: new Date().toISOString(), status: "pending" };
49
+ _pendingJobs.push(jobEntry);
36
50
  const timer = setTimeout(async () => {
37
- await executeAction(action, args);
38
- timers.delete(id);
39
- const idx = _pendingJobs.findIndex((j) => j.id === id);
40
- if (idx >= 0)
41
- _pendingJobs.splice(idx, 1);
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
- console.error(`[scheduler] Cron "${name}" failed:`, error);
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
- "name": "@gencow/core",
3
- "version": "0.1.10",
4
- "description": "Gencow core library — defineQuery, defineMutation, reactive subscriptions",
5
- "type": "module",
6
- "main": "dist/index.js",
7
- "types": "dist/index.d.ts",
8
- "exports": {
9
- ".": {
10
- "import": "./dist/index.js",
11
- "types": "./dist/index.d.ts"
12
- },
13
- "./server": {
14
- "import": "./dist/server.js",
15
- "types": "./dist/server.d.ts"
16
- }
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
- "files": [
19
- "dist/",
20
- "src/"
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 → 에러 없이 무시", async () => {
145
+ it("미등록 action executeAction → 에러를 throw한다", async () => {
146
146
  const scheduler = createScheduler();
147
- // 에러 없이 조용히 실패해야
148
- await scheduler.executeAction("nonexistent");
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
- await scheduler.executeAction("failing");
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
+ });