@gencow/core 0.1.7 → 0.1.9

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/storage.ts CHANGED
@@ -7,10 +7,14 @@ import * as crypto from "crypto";
7
7
  /** 파일 업로드 최대 크기: 50MB (하드코딩 — 사용자가 오버라이드 불가) */
8
8
  const MAX_FILE_SIZE = 50 * 1024 * 1024;
9
9
 
10
+ /** 기본 스토리지 쿼터: 1GB */
11
+ const DEFAULT_STORAGE_QUOTA = 1024 * 1024 * 1024;
12
+
10
13
  function formatBytes(bytes: number): string {
11
14
  if (bytes < 1024) return `${bytes}B`;
12
15
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
13
- return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
16
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
17
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)}GB`;
14
18
  }
15
19
 
16
20
  // ─── Types ──────────────────────────────────────────────
@@ -23,6 +27,13 @@ interface StorageFile {
23
27
  path: string;
24
28
  }
25
29
 
30
+ export interface StorageOptions {
31
+ /** Raw SQL 실행 함수 — DB 자동 기록 + 쿼터 검증에 필요 */
32
+ rawSql?: (sql: string, params?: unknown[]) => Promise<unknown[]>;
33
+ /** 앱별 스토리지 쿼터 (bytes). 0 = 무제한. 기본: 1GB */
34
+ storageQuota?: number;
35
+ }
36
+
26
37
  export interface Storage {
27
38
  /** Store a file and return a storageId — Convex의 ctx.storage.store() */
28
39
  store(file: File | Blob, filename?: string): Promise<string>;
@@ -40,27 +51,120 @@ export interface Storage {
40
51
 
41
52
  const metaStore = new Map<string, StorageFile>();
42
53
 
54
+ /**
55
+ * files 테이블 자동 생성 (이미 존재하면 무시)
56
+ * admin.ts의 CREATE_FILES_TABLE_SQL과 동일한 스키마 사용
57
+ */
58
+ let filesTableEnsured = false;
59
+
60
+ async function ensureFilesTable(
61
+ rawSql: (sql: string, params?: unknown[]) => Promise<unknown[]>,
62
+ ): Promise<void> {
63
+ if (filesTableEnsured) return;
64
+ await rawSql(`
65
+ DO $$
66
+ BEGIN
67
+ IF NOT EXISTS (
68
+ SELECT 1 FROM information_schema.tables
69
+ WHERE table_schema = current_schema()
70
+ AND table_name = 'files'
71
+ ) THEN
72
+ CREATE TABLE files (
73
+ id SERIAL PRIMARY KEY,
74
+ storage_id TEXT NOT NULL,
75
+ name TEXT NOT NULL,
76
+ size TEXT NOT NULL,
77
+ type TEXT NOT NULL DEFAULT 'application/octet-stream',
78
+ uploaded_by TEXT DEFAULT 'api',
79
+ created_at TIMESTAMP DEFAULT NOW()
80
+ );
81
+ END IF;
82
+ END$$;
83
+ `);
84
+ filesTableEnsured = true;
85
+ }
86
+
87
+ /**
88
+ * 스토리지 쿼터 검증 — 현재 총 사용량 + 새 파일 크기가 쿼터를 초과하는지 확인
89
+ */
90
+ async function checkStorageQuota(
91
+ rawSql: (sql: string, params?: unknown[]) => Promise<unknown[]>,
92
+ newFileSize: number,
93
+ quota: number,
94
+ ): Promise<void> {
95
+ if (quota <= 0) return; // 무제한
96
+
97
+ const rows = await rawSql(
98
+ `SELECT COALESCE(SUM(CAST(size AS BIGINT)), 0) AS total FROM files`,
99
+ );
100
+ const currentUsage = Number((rows[0] as Record<string, string>)?.total || "0");
101
+ const projectedUsage = currentUsage + newFileSize;
102
+
103
+ if (projectedUsage > quota) {
104
+ throw new Error(
105
+ `Storage quota exceeded: ${formatBytes(currentUsage)} used + ${formatBytes(newFileSize)} new = ${formatBytes(projectedUsage)}, ` +
106
+ `quota is ${formatBytes(quota)}. Delete files or increase quota.`
107
+ );
108
+ }
109
+ }
110
+
111
+ /**
112
+ * DB에 파일 메타데이터 기록 — store() 호출 시 자동 수행
113
+ *
114
+ * 대시보드 업로드(admin.ts)는 uploaded_by='dashboard'로 별도 INSERT하므로,
115
+ * 여기서는 skipDbRecord 옵션으로 중복 방지 가능.
116
+ */
117
+ async function recordFileToDb(
118
+ rawSql: (sql: string, params?: unknown[]) => Promise<unknown[]>,
119
+ storageId: string,
120
+ name: string,
121
+ size: number,
122
+ type: string,
123
+ uploadedBy: string = "api",
124
+ ): Promise<void> {
125
+ await ensureFilesTable(rawSql);
126
+ await rawSql(
127
+ `INSERT INTO files (storage_id, name, size, type, uploaded_by, created_at)
128
+ VALUES ($1, $2, $3, $4, $5, NOW())`,
129
+ [storageId, name, String(size), type, uploadedBy],
130
+ );
131
+ }
132
+
43
133
  /**
44
134
  * Create a storage instance — Convex storage 패턴 재현
45
135
  *
136
+ * @param dir - 파일 저장 디렉토리
137
+ * @param options - DB 연동 + 쿼터 옵션
138
+ *
46
139
  * @example
47
- * const storage = createStorage('./uploads');
140
+ * const storage = createStorage('./uploads', { rawSql, storageQuota: 1024 * 1024 * 1024 });
48
141
  * const id = await storage.store(file);
49
142
  * const url = storage.getUrl(id);
50
143
  */
51
- export function createStorage(dir: string = "./uploads"): Storage {
144
+ export function createStorage(
145
+ dir: string = "./uploads",
146
+ options?: StorageOptions,
147
+ ): Storage {
148
+ const rawSql = options?.rawSql;
149
+ const quota = options?.storageQuota ?? DEFAULT_STORAGE_QUOTA;
150
+
52
151
  // Ensure directory exists
53
152
  fs.mkdir(dir, { recursive: true }).catch(() => { });
54
153
 
55
154
  return {
56
155
  async store(file: File | Blob, filename?: string): Promise<string> {
57
- // 크기 제한 검증
156
+ // 크기 제한 검증 (단일 파일)
58
157
  if (file.size > MAX_FILE_SIZE) {
59
158
  throw new Error(
60
159
  `File too large: ${formatBytes(file.size)} exceeds limit of ${formatBytes(MAX_FILE_SIZE)}`
61
160
  );
62
161
  }
63
162
 
163
+ // 스토리지 쿼터 검증 (앱 전체 용량)
164
+ if (rawSql) {
165
+ await checkStorageQuota(rawSql, file.size, quota);
166
+ }
167
+
64
168
  const id = crypto.randomUUID();
65
169
  const name = filename || (file instanceof File ? file.name : `${id}.bin`);
66
170
  const filePath = path.join(dir, id);
@@ -68,14 +172,27 @@ export function createStorage(dir: string = "./uploads"): Storage {
68
172
  const arrayBuffer = await file.arrayBuffer();
69
173
  await fs.writeFile(filePath, Buffer.from(arrayBuffer));
70
174
 
175
+ const type = file.type || "application/octet-stream";
176
+
71
177
  metaStore.set(id, {
72
178
  id,
73
179
  name,
74
180
  size: file.size,
75
- type: file.type || "application/octet-stream",
181
+ type,
76
182
  path: filePath,
77
183
  });
78
184
 
185
+ // DB 자동 기록 (rawSql 있을 때만)
186
+ if (rawSql) {
187
+ try {
188
+ await recordFileToDb(rawSql, id, name, file.size, type, "api");
189
+ } catch (e: unknown) {
190
+ // DB 기록 실패해도 파일 저장은 성공 — 로그만 남김
191
+ const msg = e instanceof Error ? e.message : String(e);
192
+ console.warn(`[storage] DB record failed for ${id}: ${msg}`);
193
+ }
194
+ }
195
+
79
196
  return id;
80
197
  },
81
198
 
@@ -84,16 +201,21 @@ export function createStorage(dir: string = "./uploads"): Storage {
84
201
  filename: string,
85
202
  type: string = "application/octet-stream"
86
203
  ): Promise<string> {
87
- const id = crypto.randomUUID();
88
- const filePath = path.join(dir, id);
89
-
90
- // 크기 제한 검증
204
+ // 크기 제한 검증 (단일 파일)
91
205
  if (buffer.length > MAX_FILE_SIZE) {
92
206
  throw new Error(
93
207
  `File too large: ${formatBytes(buffer.length)} exceeds limit of ${formatBytes(MAX_FILE_SIZE)}`
94
208
  );
95
209
  }
96
210
 
211
+ // 스토리지 쿼터 검증 (앱 전체 용량)
212
+ if (rawSql) {
213
+ await checkStorageQuota(rawSql, buffer.length, quota);
214
+ }
215
+
216
+ const id = crypto.randomUUID();
217
+ const filePath = path.join(dir, id);
218
+
97
219
  await fs.writeFile(filePath, buffer);
98
220
 
99
221
  metaStore.set(id, {
@@ -104,6 +226,16 @@ export function createStorage(dir: string = "./uploads"): Storage {
104
226
  path: filePath,
105
227
  });
106
228
 
229
+ // DB 자동 기록
230
+ if (rawSql) {
231
+ try {
232
+ await recordFileToDb(rawSql, id, filename, buffer.length, type, "api");
233
+ } catch (e: unknown) {
234
+ const msg = e instanceof Error ? e.message : String(e);
235
+ console.warn(`[storage] DB record failed for ${id}: ${msg}`);
236
+ }
237
+ }
238
+
107
239
  return id;
108
240
  },
109
241
 
@@ -121,19 +253,32 @@ export function createStorage(dir: string = "./uploads"): Storage {
121
253
  await fs.unlink(meta.path).catch(() => { });
122
254
  metaStore.delete(storageId);
123
255
  }
256
+
257
+ // DB에서도 삭제 (rawSql 있을 때만)
258
+ if (rawSql) {
259
+ try {
260
+ await rawSql(
261
+ `DELETE FROM files WHERE storage_id = $1`,
262
+ [storageId],
263
+ );
264
+ } catch { /* 삭제 실패 무시 — 파일은 이미 제거됨 */ }
265
+ }
124
266
  },
125
267
  };
126
268
  }
127
269
 
128
270
  /**
129
271
  * Hono routes for serving stored files
272
+ *
273
+ * 인증 없이 public URL로 서빙 — Convex getUrl() 패턴과 동일.
274
+ * 접근 제어는 URL을 반환하는 query/mutation 레벨에서 개발자가 구현.
130
275
  */
131
276
  export function storageRoutes(
132
277
  storage: ReturnType<typeof createStorage>,
133
- rawSql?: (sql: string, params?: any[]) => Promise<any[]>,
278
+ rawSql?: (sql: string, params?: unknown[]) => Promise<unknown[]>,
134
279
  storageDir?: string,
135
280
  ) {
136
- return async (c: any) => {
281
+ return async (c: { req: { param: (key: string) => string }; json: (data: unknown, status?: number) => Response; body: (data: unknown, status: number, headers: Record<string, string>) => Response }) => {
137
282
  const id = c.req.param("id");
138
283
  let meta = await storage.getMeta(id);
139
284
 
@@ -145,7 +290,7 @@ export function storageRoutes(
145
290
  [id]
146
291
  );
147
292
  if (rows.length > 0) {
148
- const row = rows[0];
293
+ const row = rows[0] as Record<string, string>;
149
294
  const dir = storageDir || "./uploads";
150
295
  meta = {
151
296
  id: row.storage_id,
package/src/table.ts ADDED
@@ -0,0 +1,165 @@
1
+ /**
2
+ * packages/core/src/table.ts
3
+ *
4
+ * gencowTable() — Drizzle pgTable wrapper with schema-level access control.
5
+ * Inspired by KeystoneJS's declarative filter pattern.
6
+ *
7
+ * Run tests: bun test packages/core/src/__tests__/table.test.ts
8
+ */
9
+
10
+ import { pgTable } from "drizzle-orm/pg-core";
11
+ import { eq } from "drizzle-orm";
12
+ import type { GencowCtx } from "./reactive";
13
+ import type { SQL } from "drizzle-orm";
14
+
15
+ // ─── Types ──────────────────────────────────────────────
16
+
17
+ /**
18
+ * Access control filter function.
19
+ * Returns a Drizzle SQL condition, `true` (allow all), or `false` (deny all).
20
+ * Can be async (e.g., for team membership lookups).
21
+ */
22
+ export type AccessFilter = (ctx: GencowCtx) => SQL | boolean | Promise<SQL | boolean>;
23
+
24
+ /** Field-level access control: per-field read permission. */
25
+ export interface FieldAccessRule {
26
+ read: (ctx: GencowCtx) => boolean;
27
+ }
28
+
29
+ /** Options for gencowTable — access control metadata. */
30
+ export interface GencowTableOptions {
31
+ /** Row-level filter — auto-injected into all queries on this table */
32
+ filter: AccessFilter;
33
+ /** Field-level access — unauthorized fields are nullified in results */
34
+ fieldAccess?: Record<string, FieldAccessRule>;
35
+ }
36
+
37
+ /** Stored metadata for a gencowTable. */
38
+ export interface TableAccessMeta {
39
+ filter: AccessFilter;
40
+ fieldAccess?: Record<string, FieldAccessRule>;
41
+ tableName: string;
42
+ }
43
+
44
+ // Internal marker for ownerFilter
45
+ interface OwnerFilterOptions extends GencowTableOptions {
46
+ _ownerColumn: string;
47
+ }
48
+
49
+ function isOwnerFilter(opts: GencowTableOptions): opts is OwnerFilterOptions {
50
+ return "_ownerColumn" in opts;
51
+ }
52
+
53
+ // ─── Global Registry ────────────────────────────────────
54
+
55
+ declare global {
56
+ // eslint-disable-next-line no-var
57
+ var __gencow_tableAccessRegistry: Map<any, TableAccessMeta>;
58
+ }
59
+
60
+ if (!globalThis.__gencow_tableAccessRegistry) {
61
+ globalThis.__gencow_tableAccessRegistry = new Map();
62
+ }
63
+
64
+ const tableAccessRegistry = globalThis.__gencow_tableAccessRegistry;
65
+
66
+ // ─── gencowTable() ──────────────────────────────────────
67
+
68
+ /**
69
+ * Drizzle pgTable wrapper with schema-level access control.
70
+ *
71
+ * Creates a standard Drizzle pgTable (100% compatible) and registers
72
+ * filter + fieldAccess metadata that ctx.db Proxy uses to auto-inject
73
+ * WHERE clauses.
74
+ *
75
+ * @param name - PostgreSQL table name
76
+ * @param columns - Drizzle column definitions (same as pgTable)
77
+ * @param options - Access control (filter required, fieldAccess optional)
78
+ */
79
+ export function gencowTable<T extends Record<string, any>>(
80
+ name: string,
81
+ columns: T,
82
+ options: GencowTableOptions,
83
+ ): ReturnType<typeof pgTable<string, T>> {
84
+ if (!options || (typeof options.filter !== "function" && !isOwnerFilter(options))) {
85
+ throw new Error(
86
+ `[gencow] gencowTable("${name}") requires a filter option. ` +
87
+ `Use ownerFilter("userId") for simple user isolation, ` +
88
+ `or { filter: () => true } for public tables.`
89
+ );
90
+ }
91
+
92
+ // Create standard Drizzle pgTable — fully compatible
93
+ const table = pgTable(name, columns);
94
+
95
+ // Resolve ownerFilter to a concrete filter bound to this table
96
+ let filter: AccessFilter;
97
+ if (isOwnerFilter(options)) {
98
+ const columnName = options._ownerColumn;
99
+ const col = (table as any)[columnName];
100
+ if (!col) {
101
+ throw new Error(
102
+ `[gencow] ownerFilter("${columnName}"): column "${columnName}" not found on table "${name}". ` +
103
+ `Available columns: ${Object.keys(table as any).filter(k => !k.startsWith("_") && !k.startsWith("$")).join(", ")}`
104
+ );
105
+ }
106
+ filter = (ctx: GencowCtx) => {
107
+ const user = ctx.auth.requireAuth();
108
+ return eq(col, user.id);
109
+ };
110
+ } else {
111
+ filter = options.filter;
112
+ }
113
+
114
+ // Store access control metadata keyed by the table object reference
115
+ tableAccessRegistry.set(table, {
116
+ filter,
117
+ fieldAccess: options.fieldAccess,
118
+ tableName: name,
119
+ });
120
+
121
+ return table;
122
+ }
123
+
124
+ // ─── ownerFilter() helper ───────────────────────────────
125
+
126
+ /**
127
+ * Convenience helper for userId-based isolation.
128
+ *
129
+ * Usage:
130
+ * export const tasks = gencowTable("tasks", { ... }, ownerFilter("userId"));
131
+ * export const files = gencowTable("files", { ... }, ownerFilter("ownerId"));
132
+ *
133
+ * @param columnName - Name of the user ID column (default: "userId")
134
+ */
135
+ export function ownerFilter(columnName: string = "userId"): GencowTableOptions {
136
+ // Return a marker object — gencowTable() resolves this to a concrete filter
137
+ // by binding to the actual table column at registration time.
138
+ return {
139
+ _ownerColumn: columnName,
140
+ // Placeholder filter — replaced by gencowTable()
141
+ filter: () => { throw new Error("[gencow] ownerFilter placeholder should not be called directly"); },
142
+ } as OwnerFilterOptions;
143
+ }
144
+
145
+ // ─── Lookup ─────────────────────────────────────────────
146
+
147
+ /** Get access control metadata for a table. Returns undefined for plain pgTable. */
148
+ export function getTableAccessMeta(table: any): TableAccessMeta | undefined {
149
+ return tableAccessRegistry.get(table);
150
+ }
151
+
152
+ /** Check if a table has gencowTable metadata registered. */
153
+ export function isGencowTable(table: any): boolean {
154
+ return tableAccessRegistry.has(table);
155
+ }
156
+
157
+ /** Get all registered gencowTables (for audit/boot-time checks). */
158
+ export function getAllGencowTables(): Map<any, TableAccessMeta> {
159
+ return new Map(tableAccessRegistry);
160
+ }
161
+
162
+ /** Reset registry (for testing only). */
163
+ export function _resetTableRegistry(): void {
164
+ tableAccessRegistry.clear();
165
+ }