@gencow/core 0.1.9 → 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/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
- // ─── Data Isolation (gencowTable + scoped DB) ───────────
23
- export { gencowTable, ownerFilter, getTableAccessMeta, isGencowTable, getAllGencowTables } from "./table";
24
- export type { GencowTableOptions, AccessFilter, FieldAccessRule, TableAccessMeta } from "./table";
25
- export { createScopedDb, applyFieldAccess } from "./scoped-db";
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/reactive.ts CHANGED
@@ -58,8 +58,12 @@ export interface AIContext {
58
58
  chat: (opts: {
59
59
  model?: string;
60
60
  messages: AIMessage[];
61
+ /** System prompt — shorthand for adding a system message */
62
+ system?: string;
61
63
  temperature?: number;
62
64
  maxTokens?: number;
65
+ /** Response format — e.g. { type: "json_object" } for JSON mode */
66
+ responseFormat?: { type: string };
63
67
  }) => Promise<AIResult>;
64
68
  /** 텍스트 임베딩 (단일) — ctx.ai.embed("검색 텍스트") */
65
69
  embed: (text: string) => Promise<number[]>;
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 { id: string; action: string; scheduledAt: string }
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
- async function executeAction(action: string, args: any) {
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
- return;
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
- _pendingJobs.push({ id, action, scheduledAt: new Date().toISOString() });
126
+ const jobEntry: PendingJobEntry = { id, action, scheduledAt: new Date().toISOString(), status: "pending" };
127
+ _pendingJobs.push(jobEntry);
128
+
87
129
  const timer = setTimeout(async () => {
88
- await executeAction(action, args);
89
- timers.delete(id);
90
- const idx = _pendingJobs.findIndex((j) => j.id === id);
91
- if (idx >= 0) _pendingJobs.splice(idx, 1);
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
- console.error(`[scheduler] Cron "${name}" failed:`, error);
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);