@gencow/core 0.1.12 → 0.1.13

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 CHANGED
@@ -1,42 +1,84 @@
1
- import type { PgDatabase, PgTable } from "drizzle-orm/pg-core";
1
+ /**
2
+ * packages/core/src/crud.ts — Zero-Boilerplate CRUD API Auto-Generator (v2)
3
+ *
4
+ * 사용자 코드 5줄로 완전한 CRUD API를 자동 생성:
5
+ *
6
+ * @example
7
+ * ```ts
8
+ * import { crud } from "@gencow/core";
9
+ * import { tasks } from "./schema";
10
+ *
11
+ * // query(tasks.list), query(tasks.get), mutation(tasks.create/update/remove) 자동 등록
12
+ * export const { list, get, create, update, remove } = crud(tasks);
13
+ * ```
14
+ *
15
+ * list 반환값: `{ data: T[], total: number }`
16
+ * → 프론트: `const result = useQuery(api.tasks.list); result?.data.map(...)`
17
+ * → total은 동일 필터 조건의 전체 레코드 수 (페이지네이션 UI 지원)
18
+ *
19
+ * 내부적으로 query()/mutation()을 호출하여 글로벌 레지스트리에 등록하므로,
20
+ * 프론트엔드에서 useQuery(api.tasks.list) / useMutation(api.tasks.create) 로 바로 사용 가능.
21
+ *
22
+ * 📄 Spec: docs/specs/spec-crud-v2-enhancements.md
23
+ * 📦 참조: saas-js/drizzle-crud 팩토리 패턴
24
+ */
25
+ import type { PgTable } from "drizzle-orm/pg-core";
2
26
  type CrudOptions<T extends PgTable> = {
27
+ /** 검색 대상 필드 — list의 search 파라미터에 사용 */
3
28
  searchFields?: (keyof T["_"]["columns"])[];
29
+ /** 소프트 삭제 컬럼 (e.g. deletedAt) */
4
30
  softDelete?: {
5
31
  field: keyof T["_"]["columns"];
6
32
  };
33
+ /** 필터링 허용 필드 — allowedFilters에 없는 키는 무시 (보안) */
7
34
  allowedFilters?: (keyof T["_"]["columns"])[];
35
+ /** 기본 페이지 크기 (default: 20) */
8
36
  defaultLimit?: number;
37
+ /** 최대 페이지 크기 (default: 100) */
9
38
  maxLimit?: number;
39
+ /** 라이프사이클 훅 */
10
40
  hooks?: {
11
41
  beforeCreate?: (data: any) => any | Promise<any>;
12
42
  beforeUpdate?: (data: any) => any | Promise<any>;
13
43
  };
44
+ /** true면 인증 없이 접근 가능 (기본: false — Secure by Default) */
45
+ public?: boolean;
46
+ /** true면 mutation 후 realtime push (기본: true) */
47
+ realtime?: boolean;
48
+ /** 키 접두사 오버라이드 (기본: 테이블명) */
49
+ prefix?: string;
14
50
  };
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;
51
+ /**
52
+ * 테이블을 받아 query/mutation을 자동 등록하고,
53
+ * { list, get, create, update, remove } 를 반환한다.
54
+ *
55
+ * list 반환값: `{ data: T[], total: number }`
56
+ *
57
+ * @example
58
+ * ```ts
59
+ * export const { list, get, create, update, remove } = crud(tasks);
60
+ * export const { list, get, create, update, remove } = crud(tasks, { public: true });
61
+ * ```
62
+ */
63
+ export declare function crud<T extends PgTable>(table: T, options?: CrudOptions<T>): {
64
+ list: import("./reactive").QueryDef<{
65
+ page: import("./v").Validator<number | undefined>;
66
+ limit: import("./v").Validator<number | undefined>;
67
+ search: import("./v").Validator<string | undefined>;
68
+ orderBy: import("./v").Validator<string | undefined>;
69
+ orderDir: import("./v").Validator<string | undefined>;
70
+ filters: import("./v").Validator<any>;
71
+ }, {
72
+ data: any;
34
73
  total: number;
35
74
  }>;
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>;
75
+ get: import("./reactive").QueryDef<{
76
+ id: import("./v").Validator<string> | import("./v").Validator<number>;
77
+ }, any>;
78
+ create: import("./reactive").MutationDef<any, any>;
79
+ update: import("./reactive").MutationDef<any, any>;
80
+ remove: import("./reactive").MutationDef<any, {
81
+ success: boolean;
82
+ }>;
41
83
  };
42
84
  export {};
package/dist/crud.js CHANGED
@@ -1,130 +1,250 @@
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.`);
1
+ /**
2
+ * packages/core/src/crud.ts — Zero-Boilerplate CRUD API Auto-Generator (v2)
3
+ *
4
+ * 사용자 코드 5줄로 완전한 CRUD API를 자동 생성:
5
+ *
6
+ * @example
7
+ * ```ts
8
+ * import { crud } from "@gencow/core";
9
+ * import { tasks } from "./schema";
10
+ *
11
+ * // query(tasks.list), query(tasks.get), mutation(tasks.create/update/remove) 자동 등록
12
+ * export const { list, get, create, update, remove } = crud(tasks);
13
+ * ```
14
+ *
15
+ * list 반환값: `{ data: T[], total: number }`
16
+ * → 프론트: `const result = useQuery(api.tasks.list); result?.data.map(...)`
17
+ * → total은 동일 필터 조건의 전체 레코드 수 (페이지네이션 UI 지원)
18
+ *
19
+ * 내부적으로 query()/mutation()을 호출하여 글로벌 레지스트리에 등록하므로,
20
+ * 프론트엔드에서 useQuery(api.tasks.list) / useMutation(api.tasks.create) 로 바로 사용 가능.
21
+ *
22
+ * 📄 Spec: docs/specs/spec-crud-v2-enhancements.md
23
+ * 📦 참조: saas-js/drizzle-crud 팩토리 패턴
24
+ */
25
+ import { eq, desc, asc, ilike, or, and, count as drizzleCount, getTableName } from "drizzle-orm";
26
+ import { query, mutation } from "./reactive";
27
+ import { v } from "./v";
28
+ // ─── Helpers ────────────────────────────────────────────
29
+ /**
30
+ * id 컬럼의 Drizzle dataType을 검사하여 적절한 validator를 반환.
31
+ * serial/integer/bigint → v.number()
32
+ * text/uuid/varchar → v.string()
33
+ */
34
+ function detectIdType(column) {
35
+ const colType = column.dataType;
36
+ if (colType === "string")
37
+ return v.string();
38
+ return v.number();
39
+ }
40
+ // ─── crud — Zero-Boilerplate API Factory ──────────
41
+ /**
42
+ * 테이블을 받아 query/mutation을 자동 등록하고,
43
+ * { list, get, create, update, remove } 를 반환한다.
44
+ *
45
+ * list 반환값: `{ data: T[], total: number }`
46
+ *
47
+ * @example
48
+ * ```ts
49
+ * export const { list, get, create, update, remove } = crud(tasks);
50
+ * export const { list, get, create, update, remove } = crud(tasks, { public: true });
51
+ * ```
52
+ */
53
+ export function crud(table, options) {
54
+ const anyTable = table;
55
+ const tableName = getTableName(table); // Drizzle 공식 API
56
+ const prefix = options?.prefix || tableName;
57
+ const isPublic = options?.public ?? false;
58
+ const useRealtime = options?.realtime ?? true;
59
+ const defaultLimit = options?.defaultLimit ?? 20;
60
+ const maxLimit = options?.maxLimit ?? 100;
61
+ const pk = anyTable["id"];
62
+ if (!pk) {
63
+ throw new Error(`[crud] Table "${tableName}" must have an 'id' column.`);
64
+ }
65
+ // id 타입 자동 감지 (serial → v.number(), text/uuid → v.string())
66
+ const idValidator = detectIdType(pk);
67
+ // createdAt 컬럼 (정렬용, 없으면 id 사용)
68
+ const createdAtCol = anyTable["createdAt"];
69
+ const defaultOrderCol = createdAtCol || pk;
70
+ // userId 컬럼 (자동 주입용)
71
+ const userIdCol = anyTable["userId"];
72
+ // ── 내부 헬퍼: WHERE 조건 빌드 (list + count + realtime 공유) ──
73
+ function buildWhereConditions(args) {
74
+ const conditions = [];
75
+ // Soft delete
76
+ if (options?.softDelete) {
77
+ const sdField = anyTable[options.softDelete.field];
78
+ conditions.push(eq(sdField, null));
8
79
  }
9
- async function create(data) {
10
- let insertData = { ...data };
11
- if (options?.hooks?.beforeCreate) {
12
- insertData = await options.hooks.beforeCreate(insertData);
80
+ // Search
81
+ if (args?.search && options?.searchFields?.length) {
82
+ const searchConds = options.searchFields.map((f) => ilike(anyTable[f], `%${args.search}%`));
83
+ conditions.push(or(...searchConds));
84
+ }
85
+ // Dynamic filters (allowedFilters 화이트리스트만 허용)
86
+ if (args?.filters && options?.allowedFilters?.length) {
87
+ for (const [key, value] of Object.entries(args.filters)) {
88
+ if (options.allowedFilters.includes(key) && anyTable[key]) {
89
+ conditions.push(eq(anyTable[key], value));
90
+ }
91
+ // allowedFilters에 없는 키는 무시 (보안)
13
92
  }
14
- const [result] = await db.insert(anyTable).values(insertData).returning();
15
- return result;
16
93
  }
17
- async function findById(id) {
18
- let whereCond = eq(pk, id);
94
+ return conditions.length > 0 ? and(...conditions) : undefined;
95
+ }
96
+ // ── 내부 헬퍼: list+count 데이터 가져오기 (realtime push용 재사용) ──
97
+ async function fetchListWithTotal(db, whereClause) {
98
+ const [data, countResult] = await Promise.all([
99
+ db.select().from(anyTable).where(whereClause).orderBy(desc(defaultOrderCol)),
100
+ db.select({ count: drizzleCount() }).from(anyTable).where(whereClause),
101
+ ]);
102
+ return { data, total: Number(countResult[0]?.count ?? 0) };
103
+ }
104
+ // ── list ──────────────────────────────────────
105
+ const listDef = query(`${prefix}.list`, {
106
+ public: isPublic,
107
+ args: {
108
+ page: v.optional(v.number()),
109
+ limit: v.optional(v.number()),
110
+ search: v.optional(v.string()),
111
+ orderBy: v.optional(v.string()),
112
+ orderDir: v.optional(v.string()),
113
+ filters: v.optional(v.any()),
114
+ },
115
+ handler: async (ctx, args) => {
116
+ if (!isPublic)
117
+ ctx.auth.requireAuth();
118
+ const page = Math.max(1, args?.page || 1);
119
+ const limit = Math.min(Math.max(1, args?.limit || defaultLimit), maxLimit);
120
+ const offset = (page - 1) * limit;
121
+ const whereClause = buildWhereConditions(args);
122
+ // Order
123
+ let orderByClause;
124
+ if (args?.orderBy && anyTable[args.orderBy]) {
125
+ const col = anyTable[args.orderBy];
126
+ orderByClause = args.orderDir === "asc" ? asc(col) : desc(col);
127
+ }
128
+ else {
129
+ orderByClause = desc(defaultOrderCol);
130
+ }
131
+ // SELECT + COUNT 병렬 실행
132
+ const [results, countResult] = await Promise.all([
133
+ ctx.db.select()
134
+ .from(anyTable)
135
+ .where(whereClause)
136
+ .orderBy(orderByClause)
137
+ .limit(limit)
138
+ .offset(offset),
139
+ ctx.db.select({ count: drizzleCount() })
140
+ .from(anyTable)
141
+ .where(whereClause),
142
+ ]);
143
+ return {
144
+ data: results,
145
+ total: Number(countResult[0]?.count ?? 0),
146
+ };
147
+ }
148
+ });
149
+ // ── get ───────────────────────────────────────
150
+ const getDef = query(`${prefix}.get`, {
151
+ public: isPublic,
152
+ args: { id: idValidator },
153
+ handler: async (ctx, args) => {
154
+ if (!isPublic)
155
+ ctx.auth.requireAuth();
156
+ let whereCond = eq(pk, args.id);
19
157
  if (options?.softDelete) {
20
158
  const sdField = anyTable[options.softDelete.field];
21
- whereCond = and(whereCond, sql `${sdField} IS NULL`);
159
+ whereCond = and(whereCond, eq(sdField, null));
22
160
  }
23
- const [result] = await db.select().from(anyTable).where(whereCond).limit(1);
24
- return result || null;
161
+ const [result] = await ctx.db.select().from(anyTable).where(whereCond).limit(1);
162
+ return result ?? null;
25
163
  }
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`);
164
+ });
165
+ // ── create ────────────────────────────────────
166
+ const createDef = mutation(`${prefix}.create`, {
167
+ public: isPublic,
168
+ invalidates: [],
169
+ handler: async (ctx, args) => {
170
+ const user = isPublic ? null : ctx.auth.requireAuth();
171
+ let insertData = { ...args };
172
+ // userId 자동 주입 (테이블에 userId 컬럼이 있고 인증된 경우)
173
+ if (userIdCol && user && !insertData.userId) {
174
+ insertData.userId = user.id;
34
175
  }
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));
176
+ // beforeCreate 훅
177
+ if (options?.hooks?.beforeCreate) {
178
+ insertData = await options.hooks.beforeCreate(insertData);
39
179
  }
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
- }
180
+ const [result] = await ctx.db.insert(anyTable).values(insertData).returning();
181
+ // Realtime push { data, total } 형태로 emit
182
+ if (useRealtime) {
183
+ const listResult = await fetchListWithTotal(ctx.db);
184
+ ctx.realtime.emit(`${prefix}.list`, listResult);
47
185
  }
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
- };
186
+ return result;
68
187
  }
69
- async function update(id, data) {
70
- let updateData = { ...data };
188
+ });
189
+ // ── update ────────────────────────────────────
190
+ const updateDef = mutation(`${prefix}.update`, {
191
+ public: isPublic,
192
+ invalidates: [],
193
+ handler: async (ctx, args) => {
194
+ if (!isPublic)
195
+ ctx.auth.requireAuth();
196
+ const { id, ...updates } = args;
197
+ let updateData = { ...updates };
198
+ // updatedAt 자동 갱신
199
+ if (anyTable["updatedAt"]) {
200
+ updateData.updatedAt = new Date();
201
+ }
202
+ // beforeUpdate 훅
71
203
  if (options?.hooks?.beforeUpdate) {
72
204
  updateData = await options.hooks.beforeUpdate(updateData);
73
205
  }
74
- const [result] = await db.update(anyTable)
206
+ const [result] = await ctx.db.update(anyTable)
75
207
  .set(updateData)
76
208
  .where(eq(pk, id))
77
209
  .returning();
210
+ // Realtime push (list + get 양쪽) — { data, total } 형태
211
+ if (useRealtime) {
212
+ const listResult = await fetchListWithTotal(ctx.db);
213
+ ctx.realtime.emit(`${prefix}.list`, listResult);
214
+ ctx.realtime.emit(`${prefix}.get`, result);
215
+ }
78
216
  return result;
79
217
  }
80
- async function deleteOne(id) {
218
+ });
219
+ // ── remove ────────────────────────────────────
220
+ const removeDef = mutation(`${prefix}.remove`, {
221
+ public: isPublic,
222
+ invalidates: [],
223
+ handler: async (ctx, args) => {
224
+ if (!isPublic)
225
+ ctx.auth.requireAuth();
81
226
  if (options?.softDelete) {
82
227
  const sdField = options.softDelete.field;
83
- await db.update(anyTable)
228
+ await ctx.db.update(anyTable)
84
229
  .set({ [sdField]: new Date() })
85
- .where(eq(pk, id));
230
+ .where(eq(pk, args.id));
86
231
  }
87
232
  else {
88
- await db.delete(anyTable).where(eq(pk, id));
233
+ await ctx.db.delete(anyTable).where(eq(pk, args.id));
89
234
  }
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));
235
+ // Realtime push — { data, total } 형태
236
+ if (useRealtime) {
237
+ const listResult = await fetchListWithTotal(ctx.db);
238
+ ctx.realtime.emit(`${prefix}.list`, listResult);
117
239
  }
240
+ return { success: true };
118
241
  }
119
- return {
120
- create,
121
- findById,
122
- list,
123
- update,
124
- deleteOne,
125
- restore,
126
- bulkCreate,
127
- bulkDelete,
128
- };
242
+ });
243
+ return {
244
+ list: listDef,
245
+ get: getDef,
246
+ create: createDef,
247
+ update: updateDef,
248
+ remove: removeDef,
129
249
  };
130
250
  }
package/dist/index.d.ts CHANGED
@@ -19,4 +19,5 @@ export { defineAuth } from "./auth-config";
19
19
  export type { GencowAuthConfig, AuthEmailVerification } from "./auth-config";
20
20
  export { ownerRls } from "./rls";
21
21
  export { createRlsDb } from "./rls-db";
22
- export { gencowCrud } from "./crud";
22
+ export { crud } from "./crud";
23
+ export { crud as gencowCrud } from "./crud";
package/dist/index.js CHANGED
@@ -13,4 +13,6 @@ export { defineAuth } from "./auth-config";
13
13
  // ─── RLS + CRUD Factory ───────────
14
14
  export { ownerRls } from "./rls";
15
15
  export { createRlsDb } from "./rls-db";
16
- export { gencowCrud } from "./crud";
16
+ export { crud } from "./crud";
17
+ // Deprecated alias — 하위호환용, 향후 메이저 버전에서 제거 예정
18
+ export { crud as gencowCrud } from "./crud";
package/dist/scheduler.js CHANGED
@@ -131,7 +131,15 @@ export function createScheduler() {
131
131
  actions.set(name, handler);
132
132
  },
133
133
  async executeAction(name, args) {
134
- await executeAction(name, args);
134
+ try {
135
+ await executeAction(name, args);
136
+ }
137
+ catch (error) {
138
+ // 공개 API는 에러를 삼긴다 (호출자 크래시 방지)
139
+ // handler 에러도 로깅 (내부 함수는 미등록만 로깅, handler 에러는 미로깅)
140
+ const msg = error instanceof Error ? error.message : String(error);
141
+ console.error(`[scheduler] executeAction("${name}") failed: ${msg}`);
142
+ }
135
143
  },
136
144
  };
137
145
  }
package/package.json CHANGED
@@ -1,41 +1,42 @@
1
1
  {
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"
2
+ "name": "@gencow/core",
3
+ "version": "0.1.13",
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"
13
+ },
14
+ "./server": {
15
+ "import": "./dist/server.js",
16
+ "require": "./dist/server.js",
17
+ "types": "./dist/server.d.ts"
18
+ }
13
19
  },
14
- "./server": {
15
- "import": "./dist/server.js",
16
- "require": "./dist/server.js",
17
- "types": "./dist/server.d.ts"
20
+ "files": [
21
+ "dist/",
22
+ "src/"
23
+ ],
24
+ "scripts": {
25
+ "build": "tsc",
26
+ "typecheck": "tsc --noEmit",
27
+ "prepublishOnly": "npm run build",
28
+ "postinstall": "tsc"
29
+ },
30
+ "dependencies": {
31
+ "@electric-sql/pglite": "^0.3.15",
32
+ "drizzle-orm": "^0.45.1",
33
+ "hono": "^4.12.0",
34
+ "node-cron": "^4.2.1"
35
+ },
36
+ "devDependencies": {
37
+ "@types/bun": "^1.3.9",
38
+ "@types/node": "^25.3.0",
39
+ "@types/node-cron": "^3.0.11",
40
+ "typescript": "^5.9.3"
18
41
  }
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
- }
42
+ }