@gencow/core 0.1.8 → 0.1.10

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.
@@ -0,0 +1,324 @@
1
+ /**
2
+ * packages/core/src/__tests__/table.test.ts
3
+ *
4
+ * Tests for gencowTable(), ownerFilter(), and table access registry.
5
+ *
6
+ * Run: bun test packages/core/src/__tests__/table.test.ts
7
+ */
8
+
9
+ import { describe, it, expect, beforeEach } from "bun:test";
10
+ import {
11
+ gencowTable,
12
+ ownerFilter,
13
+ getTableAccessMeta,
14
+ isGencowTable,
15
+ getAllGencowTables,
16
+ _resetTableRegistry,
17
+ } from "../table";
18
+ import type { GencowCtx } from "../reactive";
19
+ import { pgTable, serial, text, integer } from "drizzle-orm/pg-core";
20
+ import { eq } from "drizzle-orm";
21
+
22
+ // ─── Test Helpers ────────────────────────────────────────
23
+
24
+ function makeCtx(userId: string, role = "user"): GencowCtx {
25
+ return {
26
+ auth: {
27
+ getUserIdentity: () => ({ id: userId, email: `${userId}@test.com`, name: "Test" }),
28
+ requireAuth: () => ({ id: userId, email: `${userId}@test.com`, name: "Test", role }),
29
+ },
30
+ db: {},
31
+ unsafeDb: {},
32
+ storage: {} as any,
33
+ scheduler: {} as any,
34
+ realtime: {} as any,
35
+ retry: {} as any,
36
+ };
37
+ }
38
+
39
+ function makeAnonCtx(): GencowCtx {
40
+ return {
41
+ auth: {
42
+ getUserIdentity: () => null,
43
+ requireAuth: () => { throw new Error("Authentication required"); },
44
+ },
45
+ db: {},
46
+ unsafeDb: {},
47
+ storage: {} as any,
48
+ scheduler: {} as any,
49
+ realtime: {} as any,
50
+ retry: {} as any,
51
+ };
52
+ }
53
+
54
+ // ─── Tests ───────────────────────────────────────────────
55
+
56
+ describe("gencowTable()", () => {
57
+ beforeEach(() => {
58
+ _resetTableRegistry();
59
+ });
60
+
61
+ it("유효한 Drizzle 테이블을 반환한다", () => {
62
+ const table = gencowTable("test_tasks", {
63
+ id: serial("id").primaryKey(),
64
+ title: text("title").notNull(),
65
+ }, {
66
+ filter: () => true,
67
+ });
68
+
69
+ // Drizzle 테이블은 Symbol 기반 내부 속성을 가짐
70
+ expect(table).toBeDefined();
71
+ expect(typeof table).toBe("object");
72
+ });
73
+
74
+ it("filter 메타데이터를 레지스트리에 저장한다", () => {
75
+ const filterFn = (ctx: GencowCtx) => true;
76
+ const table = gencowTable("meta_test", {
77
+ id: serial("id").primaryKey(),
78
+ }, {
79
+ filter: filterFn,
80
+ });
81
+
82
+ const meta = getTableAccessMeta(table);
83
+ expect(meta).toBeDefined();
84
+ expect(meta!.tableName).toBe("meta_test");
85
+ expect(meta!.filter).toBe(filterFn);
86
+ });
87
+
88
+ it("filter 옵션이 없으면 에러를 던진다", () => {
89
+ expect(() => {
90
+ gencowTable("no_filter", {
91
+ id: serial("id").primaryKey(),
92
+ }, {} as any);
93
+ }).toThrow("requires a filter option");
94
+ });
95
+
96
+ it("filter가 함수가 아니면 에러를 던진다", () => {
97
+ expect(() => {
98
+ gencowTable("bad_filter", {
99
+ id: serial("id").primaryKey(),
100
+ }, {
101
+ filter: "not a function" as any,
102
+ });
103
+ }).toThrow("requires a filter option");
104
+ });
105
+
106
+ it("options 자체가 없으면 에러를 던진다", () => {
107
+ expect(() => {
108
+ (gencowTable as any)("no_opts", {
109
+ id: serial("id").primaryKey(),
110
+ });
111
+ }).toThrow();
112
+ });
113
+
114
+ it("fieldAccess 메타데이터도 저장한다", () => {
115
+ const readCheck = (ctx: GencowCtx) => true;
116
+ const table = gencowTable("field_test", {
117
+ id: serial("id").primaryKey(),
118
+ salary: integer("salary"),
119
+ }, {
120
+ filter: () => true,
121
+ fieldAccess: {
122
+ salary: { read: readCheck },
123
+ },
124
+ });
125
+
126
+ const meta = getTableAccessMeta(table);
127
+ expect(meta!.fieldAccess).toBeDefined();
128
+ expect(meta!.fieldAccess!.salary.read).toBe(readCheck);
129
+ });
130
+
131
+ it("filter 함수가 ctx를 받아 SQL 조건을 반환할 수 있다", () => {
132
+ const table = gencowTable("sql_filter", {
133
+ id: serial("id").primaryKey(),
134
+ userId: text("user_id").notNull(),
135
+ }, {
136
+ filter: (ctx) => {
137
+ const user = ctx.auth.requireAuth();
138
+ return eq((table as any).userId, user.id);
139
+ },
140
+ });
141
+
142
+ const meta = getTableAccessMeta(table);
143
+ const ctx = makeCtx("user-123");
144
+ const result = meta!.filter(ctx);
145
+ // Should return a SQL condition, not a boolean
146
+ expect(result).not.toBe(true);
147
+ expect(result).not.toBe(false);
148
+ expect(typeof result).toBe("object"); // Drizzle SQL object
149
+ });
150
+
151
+ it("filter가 true를 반환하면 전체 접근", () => {
152
+ const table = gencowTable("public_table", {
153
+ id: serial("id").primaryKey(),
154
+ }, {
155
+ filter: () => true,
156
+ });
157
+
158
+ const meta = getTableAccessMeta(table);
159
+ expect(meta!.filter({} as GencowCtx)).toBe(true);
160
+ });
161
+
162
+ it("filter가 false를 반환하면 전체 차단", () => {
163
+ const table = gencowTable("blocked_table", {
164
+ id: serial("id").primaryKey(),
165
+ }, {
166
+ filter: () => false,
167
+ });
168
+
169
+ const meta = getTableAccessMeta(table);
170
+ expect(meta!.filter({} as GencowCtx)).toBe(false);
171
+ });
172
+
173
+ it("RBAC filter — admin은 true, 일반은 SQL 조건", () => {
174
+ const table = gencowTable("rbac_test", {
175
+ id: serial("id").primaryKey(),
176
+ userId: text("user_id").notNull(),
177
+ }, {
178
+ filter: (ctx) => {
179
+ const user = ctx.auth.requireAuth() as any;
180
+ if (user.role === "admin") return true;
181
+ return eq((table as any).userId, user.id);
182
+ },
183
+ });
184
+
185
+ const meta = getTableAccessMeta(table);
186
+
187
+ // admin → true
188
+ const adminCtx = makeCtx("admin-1", "admin");
189
+ expect(meta!.filter(adminCtx)).toBe(true);
190
+
191
+ // user → SQL condition
192
+ const userCtx = makeCtx("user-1", "user");
193
+ const result = meta!.filter(userCtx);
194
+ expect(result).not.toBe(true);
195
+ expect(typeof result).toBe("object");
196
+ });
197
+
198
+ it("requireAuth 포함 filter — 비인증 시 에러", () => {
199
+ const table = gencowTable("auth_required", {
200
+ id: serial("id").primaryKey(),
201
+ userId: text("user_id").notNull(),
202
+ }, {
203
+ filter: (ctx) => {
204
+ ctx.auth.requireAuth(); // throws if not authenticated
205
+ return true;
206
+ },
207
+ });
208
+
209
+ const meta = getTableAccessMeta(table);
210
+ const anonCtx = makeAnonCtx();
211
+ expect(() => meta!.filter(anonCtx)).toThrow("Authentication required");
212
+ });
213
+ });
214
+
215
+ // ─── ownerFilter() ──────────────────────────────────────
216
+
217
+ describe("ownerFilter()", () => {
218
+ beforeEach(() => {
219
+ _resetTableRegistry();
220
+ });
221
+
222
+ it("기본 컬럼명 userId로 필터를 생성한다", () => {
223
+ const table = gencowTable("owner_default", {
224
+ id: serial("id").primaryKey(),
225
+ userId: text("user_id").notNull(),
226
+ }, ownerFilter("userId"));
227
+
228
+ const meta = getTableAccessMeta(table);
229
+ expect(meta).toBeDefined();
230
+ expect(meta!.tableName).toBe("owner_default");
231
+
232
+ // Filter should work with auth ctx
233
+ const ctx = makeCtx("user-abc");
234
+ const result = meta!.filter(ctx);
235
+ expect(result).not.toBe(true); // Returns SQL condition, not boolean
236
+ expect(typeof result).toBe("object");
237
+ });
238
+
239
+ it("커스텀 컬럼명을 지원한다 (ownerId)", () => {
240
+ const table = gencowTable("owner_custom", {
241
+ id: serial("id").primaryKey(),
242
+ ownerId: text("owner_id").notNull(),
243
+ }, ownerFilter("ownerId"));
244
+
245
+ const meta = getTableAccessMeta(table);
246
+ expect(meta).toBeDefined();
247
+
248
+ const ctx = makeCtx("user-xyz");
249
+ const result = meta!.filter(ctx);
250
+ expect(typeof result).toBe("object"); // SQL condition
251
+ });
252
+
253
+ it("존재하지 않는 컬럼명이면 에러", () => {
254
+ expect(() => {
255
+ gencowTable("owner_bad", {
256
+ id: serial("id").primaryKey(),
257
+ title: text("title"),
258
+ }, ownerFilter("nonExistentColumn"));
259
+ }).toThrow("not found on table");
260
+ });
261
+
262
+ it("비인증 사용자 시 requireAuth에서 에러", () => {
263
+ const table = gencowTable("owner_auth", {
264
+ id: serial("id").primaryKey(),
265
+ userId: text("user_id").notNull(),
266
+ }, ownerFilter("userId"));
267
+
268
+ const meta = getTableAccessMeta(table);
269
+ const anonCtx = makeAnonCtx();
270
+ expect(() => meta!.filter(anonCtx)).toThrow("Authentication required");
271
+ });
272
+ });
273
+
274
+ // ─── isGencowTable / getAllGencowTables ──────────────────
275
+
276
+ describe("isGencowTable() / getAllGencowTables()", () => {
277
+ beforeEach(() => {
278
+ _resetTableRegistry();
279
+ });
280
+
281
+ it("gencowTable로 생성한 테이블은 true", () => {
282
+ const table = gencowTable("is_test", {
283
+ id: serial("id").primaryKey(),
284
+ }, { filter: () => true });
285
+
286
+ expect(isGencowTable(table)).toBe(true);
287
+ });
288
+
289
+ it("pgTable로 생성한 테이블은 false", () => {
290
+ const table = pgTable("plain_pg", {
291
+ id: serial("id").primaryKey(),
292
+ });
293
+
294
+ expect(isGencowTable(table)).toBe(false);
295
+ });
296
+
297
+ it("getTableAccessMeta — pgTable은 undefined 반환", () => {
298
+ const table = pgTable("no_meta", {
299
+ id: serial("id").primaryKey(),
300
+ });
301
+
302
+ expect(getTableAccessMeta(table)).toBeUndefined();
303
+ });
304
+
305
+ it("getAllGencowTables — 등록된 모든 테이블 반환", () => {
306
+ gencowTable("all_a", { id: serial("id").primaryKey() }, { filter: () => true });
307
+ gencowTable("all_b", { id: serial("id").primaryKey() }, { filter: () => true });
308
+
309
+ const all = getAllGencowTables();
310
+ expect(all.size).toBe(2);
311
+
312
+ const names = Array.from(all.values()).map(m => m.tableName);
313
+ expect(names).toContain("all_a");
314
+ expect(names).toContain("all_b");
315
+ });
316
+
317
+ it("_resetTableRegistry — 레지스트리 초기화", () => {
318
+ gencowTable("reset_test", { id: serial("id").primaryKey() }, { filter: () => true });
319
+ expect(getAllGencowTables().size).toBe(1);
320
+
321
+ _resetTableRegistry();
322
+ expect(getAllGencowTables().size).toBe(0);
323
+ });
324
+ });
@@ -0,0 +1,284 @@
1
+ /**
2
+ * packages/core/src/__tests__/validator.test.ts
3
+ *
4
+ * Tests for the `v` validator module — runtime argument validation.
5
+ *
6
+ * Run: bun test packages/core/src/__tests__/validator.test.ts
7
+ */
8
+
9
+ import { describe, it, expect } from "bun:test";
10
+ import { v, parseArgs, GencowValidationError } from "../v";
11
+
12
+ // ─── v.string() ─────────────────────────────────────────
13
+
14
+ describe("v.string()", () => {
15
+ const validator = v.string();
16
+
17
+ it("문자열 통과", () => {
18
+ expect(validator.parse("hello")).toBe("hello");
19
+ });
20
+
21
+ it("빈 문자열 통과", () => {
22
+ expect(validator.parse("")).toBe("");
23
+ });
24
+
25
+ it("숫자 → 에러", () => {
26
+ expect(() => validator.parse(42)).toThrow("Expected string");
27
+ });
28
+
29
+ it("불리언 → 에러", () => {
30
+ expect(() => validator.parse(true)).toThrow("Expected string");
31
+ });
32
+
33
+ it("null → 에러", () => {
34
+ expect(() => validator.parse(null)).toThrow("Expected string");
35
+ });
36
+
37
+ it("undefined → 에러", () => {
38
+ expect(() => validator.parse(undefined)).toThrow("Expected string");
39
+ });
40
+ });
41
+
42
+ // ─── v.number() ─────────────────────────────────────────
43
+
44
+ describe("v.number()", () => {
45
+ const validator = v.number();
46
+
47
+ it("정수 통과", () => {
48
+ expect(validator.parse(42)).toBe(42);
49
+ });
50
+
51
+ it("실수 통과", () => {
52
+ expect(validator.parse(3.14)).toBe(3.14);
53
+ });
54
+
55
+ it("0 통과", () => {
56
+ expect(validator.parse(0)).toBe(0);
57
+ });
58
+
59
+ it("음수 통과", () => {
60
+ expect(validator.parse(-10)).toBe(-10);
61
+ });
62
+
63
+ it("숫자 문자열 → 자동 변환", () => {
64
+ expect(validator.parse("42")).toBe(42);
65
+ });
66
+
67
+ it("비숫자 문자열 → 에러", () => {
68
+ expect(() => validator.parse("abc")).toThrow("Expected number");
69
+ });
70
+
71
+ it("불리언 → 에러", () => {
72
+ expect(() => validator.parse(true)).toThrow("Expected number");
73
+ });
74
+
75
+ it("NaN 입력 → 에러", () => {
76
+ expect(() => validator.parse(NaN)).toThrow("Expected number");
77
+ });
78
+ });
79
+
80
+ // ─── v.boolean() ────────────────────────────────────────
81
+
82
+ describe("v.boolean()", () => {
83
+ const validator = v.boolean();
84
+
85
+ it("true 통과", () => {
86
+ expect(validator.parse(true)).toBe(true);
87
+ });
88
+
89
+ it("false 통과", () => {
90
+ expect(validator.parse(false)).toBe(false);
91
+ });
92
+
93
+ it("문자열 → 에러", () => {
94
+ expect(() => validator.parse("true")).toThrow("Expected boolean");
95
+ });
96
+
97
+ it("숫자 → 에러", () => {
98
+ expect(() => validator.parse(1)).toThrow("Expected boolean");
99
+ });
100
+ });
101
+
102
+ // ─── v.any() ────────────────────────────────────────────
103
+
104
+ describe("v.any()", () => {
105
+ const validator = v.any();
106
+
107
+ it("모든 값 통과 — 문자열", () => {
108
+ expect(validator.parse("hello")).toBe("hello");
109
+ });
110
+
111
+ it("모든 값 통과 — 숫자", () => {
112
+ expect(validator.parse(42)).toBe(42);
113
+ });
114
+
115
+ it("모든 값 통과 — null", () => {
116
+ expect(validator.parse(null)).toBe(null);
117
+ });
118
+
119
+ it("모든 값 통과 — undefined", () => {
120
+ expect(validator.parse(undefined)).toBe(undefined);
121
+ });
122
+
123
+ it("모든 값 통과 — 객체", () => {
124
+ const obj = { key: "value" };
125
+ expect(validator.parse(obj)).toBe(obj);
126
+ });
127
+ });
128
+
129
+ // ─── v.optional() ───────────────────────────────────────
130
+
131
+ describe("v.optional()", () => {
132
+ const validator = v.optional(v.string());
133
+
134
+ it("문자열 통과", () => {
135
+ expect(validator.parse("hello")).toBe("hello");
136
+ });
137
+
138
+ it("undefined → undefined 반환", () => {
139
+ expect(validator.parse(undefined)).toBe(undefined);
140
+ });
141
+
142
+ it("null → undefined 반환", () => {
143
+ expect(validator.parse(null)).toBe(undefined);
144
+ });
145
+
146
+ it("숫자 → 에러 (내부 validator 적용)", () => {
147
+ expect(() => validator.parse(42)).toThrow("Expected string");
148
+ });
149
+ });
150
+
151
+ // ─── v.array() ──────────────────────────────────────────
152
+
153
+ describe("v.array()", () => {
154
+ const validator = v.array(v.string());
155
+
156
+ it("문자열 배열 통과", () => {
157
+ expect(validator.parse(["a", "b", "c"])).toEqual(["a", "b", "c"]);
158
+ });
159
+
160
+ it("빈 배열 통과", () => {
161
+ expect(validator.parse([])).toEqual([]);
162
+ });
163
+
164
+ it("비배열 → 에러", () => {
165
+ expect(() => validator.parse("not array")).toThrow("Expected array");
166
+ });
167
+
168
+ it("원소 타입 불일치 → 에러", () => {
169
+ expect(() => validator.parse([1, 2, 3])).toThrow("Expected string");
170
+ });
171
+
172
+ it("혼합 배열 → 에러", () => {
173
+ expect(() => validator.parse(["ok", 42])).toThrow("Expected string");
174
+ });
175
+ });
176
+
177
+ // ─── v.object() ─────────────────────────────────────────
178
+
179
+ describe("v.object()", () => {
180
+ const validator = v.object({
181
+ name: v.string(),
182
+ age: v.number(),
183
+ });
184
+
185
+ it("유효한 객체 통과", () => {
186
+ expect(validator.parse({ name: "Alice", age: 30 })).toEqual({ name: "Alice", age: 30 });
187
+ });
188
+
189
+ it("비객체 → 에러", () => {
190
+ expect(() => validator.parse("string")).toThrow("Expected object");
191
+ });
192
+
193
+ it("null → 에러", () => {
194
+ expect(() => validator.parse(null)).toThrow("Expected object");
195
+ });
196
+
197
+ it("필드 타입 불일치 → 에러", () => {
198
+ expect(() => validator.parse({ name: 42, age: 30 })).toThrow("Expected string");
199
+ });
200
+
201
+ it("필드 누락 → inner validator 에러", () => {
202
+ expect(() => validator.parse({ name: "Alice" })).toThrow("Expected number");
203
+ });
204
+ });
205
+
206
+ // ─── 복합 중첩 검증 ────────────────────────────────────
207
+
208
+ describe("복합 중첩 검증", () => {
209
+ it("v.optional(v.array(v.string()))", () => {
210
+ const validator = v.optional(v.array(v.string()));
211
+ expect(validator.parse(undefined)).toBe(undefined);
212
+ expect(validator.parse(["a", "b"])).toEqual(["a", "b"]);
213
+ expect(() => validator.parse([1])).toThrow("Expected string");
214
+ });
215
+
216
+ it("v.array(v.object(...))", () => {
217
+ const validator = v.array(v.object({
218
+ id: v.number(),
219
+ label: v.string(),
220
+ }));
221
+ expect(validator.parse([
222
+ { id: 1, label: "A" },
223
+ { id: 2, label: "B" },
224
+ ])).toEqual([
225
+ { id: 1, label: "A" },
226
+ { id: 2, label: "B" },
227
+ ]);
228
+ });
229
+
230
+ it("v.object({ tags: v.array(v.string()), meta: v.optional(v.object(...)) })", () => {
231
+ const validator = v.object({
232
+ tags: v.array(v.string()),
233
+ meta: v.optional(v.object({ key: v.string() })),
234
+ });
235
+ expect(validator.parse({ tags: ["a"], meta: undefined })).toEqual({ tags: ["a"], meta: undefined });
236
+ expect(validator.parse({ tags: [], meta: { key: "val" } })).toEqual({ tags: [], meta: { key: "val" } });
237
+ });
238
+ });
239
+
240
+ // ─── parseArgs() ────────────────────────────────────────
241
+
242
+ describe("parseArgs()", () => {
243
+ it("schema 없으면 args 그대로 반환", () => {
244
+ expect(parseArgs(null, { foo: 1 })).toEqual({ foo: 1 });
245
+ expect(parseArgs(undefined, "hello")).toBe("hello");
246
+ });
247
+
248
+ it("Validator 스키마로 직접 파싱", () => {
249
+ const schema = v.string();
250
+ expect(parseArgs(schema, "hello")).toBe("hello");
251
+ });
252
+
253
+ it("Validator 스키마 검증 실패 → GencowValidationError", () => {
254
+ const schema = v.string();
255
+ expect(() => parseArgs(schema, 42)).toThrow(GencowValidationError);
256
+ });
257
+
258
+ it("Shorthand record 스키마 (Convex 스타일)", () => {
259
+ const schema = { title: v.string(), count: v.number() };
260
+ expect(parseArgs(schema, { title: "Hello", count: 5 })).toEqual({ title: "Hello", count: 5 });
261
+ });
262
+
263
+ it("Shorthand record 검증 실패 → GencowValidationError + 필드명 포함", () => {
264
+ const schema = { title: v.string(), count: v.number() };
265
+ try {
266
+ parseArgs(schema, { title: 42, count: 5 });
267
+ expect(true).toBe(false); // 여기 도달하면 안 됨
268
+ } catch (e) {
269
+ expect(e).toBeInstanceOf(GencowValidationError);
270
+ expect((e as Error).message).toContain("title");
271
+ }
272
+ });
273
+
274
+ it("Shorthand record에 비객체 전달 → GencowValidationError", () => {
275
+ const schema = { title: v.string() };
276
+ expect(() => parseArgs(schema, "not an object")).toThrow(GencowValidationError);
277
+ });
278
+
279
+ it("GencowValidationError.statusCode === 400", () => {
280
+ const err = new GencowValidationError("test");
281
+ expect(err.statusCode).toBe(400);
282
+ expect(err.name).toBe("GencowValidationError");
283
+ });
284
+ });
package/src/index.ts CHANGED
@@ -19,4 +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";
26
+
27
+
22
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[]>;
@@ -68,8 +72,10 @@ export interface AIContext {
68
72
  }
69
73
 
70
74
  export interface GencowCtx {
71
- /** Drizzle DB 인스턴스 — ctx.db.select().from(table) */
75
+ /** Drizzle DB 인스턴스 (scoped) — 스키마 filter 자동 적용, execute 차단 */
72
76
  db: any; // typed per-app via generic
77
+ /** Raw Drizzle DB — 필터 없음, execute 허용. ⚠️ 이름이 곧 경고. */
78
+ unsafeDb: any;
73
79
  /** 인증 컨텍스트 — ctx.auth.getUserIdentity() */
74
80
  auth: AuthCtx;
75
81
  /** 파일 스토리지 — ctx.storage.store(), ctx.storage.getUrl() */
package/src/scheduler.ts CHANGED
@@ -15,6 +15,8 @@ export interface Scheduler {
15
15
  cron(name: string, pattern: string, handler: () => Promise<void>): void;
16
16
  /** Register an action handler */
17
17
  registerAction(name: string, handler: ActionHandler): void;
18
+ /** Execute a registered action by name — 선언적 crons.ts 문자열 액션 실행용 */
19
+ executeAction(name: string, args?: any): Promise<void>;
18
20
  }
19
21
 
20
22
  // ─── Implementation ─────────────────────────────────────
@@ -34,9 +36,17 @@ export interface Scheduler {
34
36
  * // Cron (Convex-style)
35
37
  * scheduler.cron('daily-cleanup', '0 2 * * *', async () => { ... });
36
38
  */
37
- // Module-level state for dashboard introspection
38
- const _cronInfo: { name: string; pattern: string; registeredAt: string }[] = [];
39
- const _pendingJobs: { id: string; action: string; scheduledAt: string }[] = [];
39
+ // ─── globalThis 기반 레지스트리 (모듈 중복 방지) ─────────
40
+ // query/mutation 레지스트리(globalThis.__gencow_*)와 동일 패턴.
41
+ // @gencow/core가 다중 resolve되어도 단일 배열 공유.
42
+
43
+ interface CronInfoEntry { name: string; pattern: string; registeredAt: string }
44
+ interface PendingJobEntry { id: string; action: string; scheduledAt: string }
45
+
46
+ const _cronInfo: CronInfoEntry[] =
47
+ (globalThis as any).__gencow_cronInfo ??= [];
48
+ const _pendingJobs: PendingJobEntry[] =
49
+ (globalThis as any).__gencow_pendingJobs ??= [];
40
50
 
41
51
  export function getSchedulerInfo() {
42
52
  return {
@@ -125,5 +135,9 @@ export function createScheduler(): Scheduler {
125
135
  registerAction(name: string, handler: ActionHandler): void {
126
136
  actions.set(name, handler);
127
137
  },
138
+
139
+ async executeAction(name: string, args?: any): Promise<void> {
140
+ await executeAction(name, args);
141
+ },
128
142
  };
129
143
  }