@gencow/core 0.1.17 → 0.1.19
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.js +0 -3
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/reactive.d.ts +35 -22
- package/dist/reactive.js +66 -46
- package/dist/scoped-db.d.ts +34 -0
- package/dist/scoped-db.js +364 -0
- package/dist/storage.js +8 -7
- package/dist/table.d.ts +67 -0
- package/dist/table.js +98 -0
- package/dist/v.js +5 -1
- package/package.json +1 -1
- package/src/__tests__/crud-codegen-integration.test.ts +0 -1
- package/src/__tests__/load.test.ts +13 -31
- package/src/__tests__/reactive.test.ts +44 -62
- package/src/__tests__/storage.test.ts +113 -0
- package/src/__tests__/validator.test.ts +35 -0
- package/src/crud.ts +0 -3
- package/src/index.ts +1 -1
- package/src/reactive.ts +89 -50
- package/src/storage.ts +8 -7
- package/src/v.ts +5 -1
package/src/reactive.ts
CHANGED
|
@@ -24,6 +24,9 @@ export interface AuthCtx {
|
|
|
24
24
|
*/
|
|
25
25
|
export interface RealtimeCtx {
|
|
26
26
|
/**
|
|
27
|
+
* 특정 queryKey를 구독 중인 클라이언트에 데이터를 즉시 push합니다.
|
|
28
|
+
* 초고빈도 mutation (채팅 등)에서 query re-run 비용을 회피할 때 사용.
|
|
29
|
+
*
|
|
27
30
|
* @param queryKey - 업데이트할 쿼리 키 (예: "tasks.list")
|
|
28
31
|
* @param data - push할 데이터 (해당 query 결과와 동일한 타입)
|
|
29
32
|
*
|
|
@@ -32,6 +35,24 @@ export interface RealtimeCtx {
|
|
|
32
35
|
* ctx.realtime.emit("tasks.list", freshList);
|
|
33
36
|
*/
|
|
34
37
|
emit(queryKey: string, data: unknown): void;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 수동 mutation에서 리얼타임 업데이트의 기본 권장 방식.
|
|
41
|
+
* mutation handler 완료 후 서버가 해당 query를 re-run하여 결과를 push합니다.
|
|
42
|
+
* 복잡한 JOIN query도 queryKey만 지정하면 됩니다.
|
|
43
|
+
*
|
|
44
|
+
* @param queryKey - re-run할 쿼리 키 (예: "dashboard.revenue")
|
|
45
|
+
*
|
|
46
|
+
* @example
|
|
47
|
+
* mutation("orders.place", {
|
|
48
|
+
* handler: async (ctx, args) => {
|
|
49
|
+
* await ctx.db.insert(orders).values(args);
|
|
50
|
+
* ctx.realtime.refresh("orders.list");
|
|
51
|
+
* ctx.realtime.refresh("dashboard.revenue"); // JOIN query도 OK
|
|
52
|
+
* }
|
|
53
|
+
* });
|
|
54
|
+
*/
|
|
55
|
+
refresh(queryKey: string): void;
|
|
35
56
|
}
|
|
36
57
|
|
|
37
58
|
/**
|
|
@@ -143,7 +164,6 @@ export interface QueryDef<TSchema = any, TReturn = any> {
|
|
|
143
164
|
}
|
|
144
165
|
|
|
145
166
|
export interface MutationDef<TSchema = any, TReturn = any> {
|
|
146
|
-
invalidates: string[];
|
|
147
167
|
handler: MutationHandler<InferArgs<TSchema>, TReturn>;
|
|
148
168
|
argsSchema?: TSchema;
|
|
149
169
|
/** true = 인증 없이 접근 가능, false(기본) = auth 필수 (Secure by Default) */
|
|
@@ -235,7 +255,6 @@ let mutationCounter = 0;
|
|
|
235
255
|
* ```typescript
|
|
236
256
|
* // ✅ 권장: query와 동일한 (name, def) 패턴
|
|
237
257
|
* mutation("tasks.create", {
|
|
238
|
-
* invalidates: [],
|
|
239
258
|
* args: { title: v.string() },
|
|
240
259
|
* handler: async (ctx, args) => { ... },
|
|
241
260
|
* });
|
|
@@ -243,41 +262,39 @@ let mutationCounter = 0;
|
|
|
243
262
|
* // ✅ 객체 스타일 (하위 호환)
|
|
244
263
|
* mutation({
|
|
245
264
|
* name: "tasks.create",
|
|
246
|
-
* invalidates: [],
|
|
247
265
|
* handler: async (ctx) => { ... },
|
|
248
266
|
* });
|
|
249
267
|
*
|
|
250
268
|
* // ⚠️ Legacy 배열 스타일 (비권장)
|
|
251
269
|
* mutation(["tasks.list"], handler, "tasks.create");
|
|
252
270
|
* ```
|
|
271
|
+
*
|
|
272
|
+
* @note invalidates 필드는 deprecated — 전달해도 무시됩니다.
|
|
273
|
+
* 리얼타임 UI 갱신에는 ctx.realtime.emit() 또는 ctx.realtime.refresh()를 사용하세요.
|
|
253
274
|
*/
|
|
254
275
|
export function mutation<TSchema = any, TReturn = any>(
|
|
255
|
-
nameOrInvalidatesOrDef: string | string[] | { name?: string; args?: TSchema; public?: boolean; invalidates
|
|
276
|
+
nameOrInvalidatesOrDef: string | string[] | { name?: string; args?: TSchema; public?: boolean; invalidates?: string[]; handler: MutationHandler<InferArgs<TSchema>, TReturn> },
|
|
256
277
|
handlerOrDef?: MutationHandler<InferArgs<TSchema>, TReturn> | { invalidates?: string[]; args?: TSchema; public?: boolean; handler: MutationHandler<InferArgs<TSchema>, TReturn> },
|
|
257
278
|
name?: string
|
|
258
279
|
): MutationDef<TSchema, TReturn> {
|
|
259
|
-
let invalidates: string[];
|
|
260
280
|
let argsSchema: TSchema | undefined;
|
|
261
281
|
let actualHandler: MutationHandler<InferArgs<TSchema>, TReturn>;
|
|
262
282
|
let mutName: string;
|
|
263
283
|
let isPublic = false;
|
|
264
284
|
|
|
265
285
|
if (typeof nameOrInvalidatesOrDef === "string") {
|
|
266
|
-
// New primary style: mutation("name", {
|
|
286
|
+
// New primary style: mutation("name", { args?, public?, handler })
|
|
267
287
|
mutName = nameOrInvalidatesOrDef;
|
|
268
|
-
const def = handlerOrDef as {
|
|
269
|
-
invalidates = def.invalidates || [];
|
|
288
|
+
const def = handlerOrDef as { args?: TSchema; public?: boolean; handler: MutationHandler<InferArgs<TSchema>, TReturn> };
|
|
270
289
|
actualHandler = def.handler;
|
|
271
290
|
argsSchema = def.args;
|
|
272
291
|
isPublic = def.public === true;
|
|
273
292
|
} else if (Array.isArray(nameOrInvalidatesOrDef)) {
|
|
274
|
-
// Legacy style: mutation([...], handler, "name")
|
|
275
|
-
invalidates = nameOrInvalidatesOrDef;
|
|
293
|
+
// Legacy style: mutation([...], handler, "name") — invalidates ignored
|
|
276
294
|
actualHandler = handlerOrDef as MutationHandler<InferArgs<TSchema>, TReturn>;
|
|
277
295
|
mutName = name || `mutation_${++mutationCounter}`;
|
|
278
296
|
} else {
|
|
279
|
-
// Object style: mutation({ name?,
|
|
280
|
-
invalidates = nameOrInvalidatesOrDef.invalidates;
|
|
297
|
+
// Object style: mutation({ name?, args?, public?, handler })
|
|
281
298
|
actualHandler = nameOrInvalidatesOrDef.handler;
|
|
282
299
|
argsSchema = nameOrInvalidatesOrDef.args;
|
|
283
300
|
isPublic = nameOrInvalidatesOrDef.public === true;
|
|
@@ -294,7 +311,6 @@ export function mutation<TSchema = any, TReturn = any>(
|
|
|
294
311
|
|
|
295
312
|
const def: MutationDef<TSchema, TReturn> & { name: string } = {
|
|
296
313
|
name: mutName,
|
|
297
|
-
invalidates,
|
|
298
314
|
handler: actualHandler,
|
|
299
315
|
argsSchema,
|
|
300
316
|
isPublic,
|
|
@@ -379,14 +395,10 @@ export function deregisterClient(ws: WSContext) {
|
|
|
379
395
|
}
|
|
380
396
|
}
|
|
381
397
|
|
|
382
|
-
/**
|
|
383
|
-
* After a mutation, re-run invalidated queries and push results
|
|
384
|
-
* to all subscribers — Convex의 자동 reactive 업데이트 재현
|
|
385
|
-
*/
|
|
386
398
|
/**
|
|
387
399
|
* mutation 실행 시점에 생성되는 RealtimeCtx.
|
|
388
|
-
* emit()
|
|
389
|
-
*
|
|
400
|
+
* emit(): 데이터를 직접 push (초고빈도 mutation용).
|
|
401
|
+
* refresh(): queryKey를 pending 큐에 추가, mutation 완료 후 서버가 query re-run하여 push.
|
|
390
402
|
*
|
|
391
403
|
* 💡 Batching: 같은 queryKey에 대한 emit이 50ms 내에 여러 번 호출되면
|
|
392
404
|
* 마지막 데이터만 push하여 불필요한 전송을 방지합니다.
|
|
@@ -396,14 +408,21 @@ export function deregisterClient(ws: WSContext) {
|
|
|
396
408
|
* @param options.httpCallback BaaS 모드: Platform WS Gateway에 HTTP로 emit 전달.
|
|
397
409
|
* 설정되면 WS 직접 push 대신 이 콜백을 호출.
|
|
398
410
|
* 로컬 dev에서는 미설정 → 기존 WS 직접 push 유지.
|
|
411
|
+
* @param options.queryMap query 레지스트리 — refresh()에서 query handler를 찾아 re-run.
|
|
412
|
+
* @param options.buildCtxForRefresh refresh 시 query handler에 전달할 ctx 생성 함수.
|
|
399
413
|
*/
|
|
400
414
|
export function buildRealtimeCtx(options?: {
|
|
401
415
|
httpCallback?: (event: { type: "emit"; queryKey: string; data: unknown }) => void;
|
|
402
|
-
|
|
416
|
+
queryMap?: Map<string, QueryDef<any, any>>;
|
|
417
|
+
buildCtxForRefresh?: () => GencowCtx;
|
|
418
|
+
}): RealtimeCtx & { _hasEmitted: boolean; _pendingRefresh: string[]; _flushRefresh: () => Promise<void> } {
|
|
403
419
|
const pendingEmits = new Map<string, { data: unknown; timer: ReturnType<typeof setTimeout> }>();
|
|
420
|
+
const _pendingRefresh: string[] = [];
|
|
421
|
+
let _hasEmitted = false;
|
|
404
422
|
|
|
405
423
|
return {
|
|
406
424
|
emit(queryKey: string, data: unknown) {
|
|
425
|
+
_hasEmitted = true;
|
|
407
426
|
// 기존 pending timer가 있으면 취소 (debounce)
|
|
408
427
|
const existing = pendingEmits.get(queryKey);
|
|
409
428
|
if (existing) clearTimeout(existing.timer);
|
|
@@ -432,38 +451,58 @@ export function buildRealtimeCtx(options?: {
|
|
|
432
451
|
}, 50); // 50ms batch window
|
|
433
452
|
|
|
434
453
|
pendingEmits.set(queryKey, { data, timer });
|
|
435
|
-
}
|
|
436
|
-
};
|
|
437
|
-
}
|
|
454
|
+
},
|
|
438
455
|
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
*
|
|
446
|
-
* @deprecated ctx.realtime.emit() 사용 권장
|
|
447
|
-
* @param httpInvalidateCallback BaaS 모드: Platform WS Gateway에 HTTP로 invalidation 전달.
|
|
448
|
-
*/
|
|
449
|
-
export async function invalidateQueries(
|
|
450
|
-
queryKeys: string[],
|
|
451
|
-
ctx: GencowCtx,
|
|
452
|
-
httpInvalidateCallback?: (queryKeys: string[]) => void,
|
|
453
|
-
): Promise<void> {
|
|
454
|
-
if (queryKeys.length === 0) return; // emit() 방식에서는 no-op
|
|
455
|
-
|
|
456
|
-
// BaaS 모드: Platform WS Gateway에 HTTP callback
|
|
457
|
-
if (httpInvalidateCallback) {
|
|
458
|
-
httpInvalidateCallback(queryKeys);
|
|
459
|
-
return;
|
|
460
|
-
}
|
|
456
|
+
refresh(queryKey: string) {
|
|
457
|
+
_hasEmitted = true; // 경고 억제
|
|
458
|
+
if (!_pendingRefresh.includes(queryKey)) {
|
|
459
|
+
_pendingRefresh.push(queryKey);
|
|
460
|
+
}
|
|
461
|
+
},
|
|
461
462
|
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
463
|
+
get _hasEmitted() { return _hasEmitted; },
|
|
464
|
+
get _pendingRefresh() { return [..._pendingRefresh]; },
|
|
465
|
+
|
|
466
|
+
async _flushRefresh() {
|
|
467
|
+
if (_pendingRefresh.length === 0) return;
|
|
468
|
+
|
|
469
|
+
// queryMap이 없으면 refresh 동작 불가 (로그 경고)
|
|
470
|
+
const qMap = options?.queryMap ?? queryRegistry;
|
|
471
|
+
|
|
472
|
+
for (const key of _pendingRefresh) {
|
|
473
|
+
const queryDef = qMap.get(key);
|
|
474
|
+
if (!queryDef) {
|
|
475
|
+
console.warn(`[gencow] refresh("${key}"): query not found in registry. Skipping.`);
|
|
476
|
+
continue;
|
|
477
|
+
}
|
|
478
|
+
try {
|
|
479
|
+
// refresh용 ctx 생성 (mutation ctx와 동일한 DB/auth 스코프)
|
|
480
|
+
const refreshCtx = options?.buildCtxForRefresh?.() ?? ({} as GencowCtx);
|
|
481
|
+
const result = await queryDef.handler(refreshCtx, {});
|
|
482
|
+
|
|
483
|
+
// emit과 동일한 경로로 push
|
|
484
|
+
if (options?.httpCallback) {
|
|
485
|
+
options.httpCallback({ type: "emit", queryKey: key, data: result });
|
|
486
|
+
} else {
|
|
487
|
+
const clients = subscribers.get(key);
|
|
488
|
+
if (clients && clients.size > 0) {
|
|
489
|
+
const message = JSON.stringify({
|
|
490
|
+
type: "query:updated",
|
|
491
|
+
query: key,
|
|
492
|
+
data: result,
|
|
493
|
+
});
|
|
494
|
+
for (const ws of clients) {
|
|
495
|
+
try { ws.send(message); } catch { clients.delete(ws); }
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
} catch (e) {
|
|
500
|
+
console.warn(`[gencow] refresh("${key}") failed:`, e instanceof Error ? e.message : e);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
_pendingRefresh.length = 0;
|
|
504
|
+
},
|
|
505
|
+
};
|
|
467
506
|
}
|
|
468
507
|
|
|
469
508
|
|
package/src/storage.ts
CHANGED
|
@@ -52,8 +52,9 @@ export interface Storage {
|
|
|
52
52
|
const metaStore = new Map<string, StorageFile>();
|
|
53
53
|
|
|
54
54
|
/**
|
|
55
|
-
*
|
|
55
|
+
* _system_files 테이블 자동 생성 (이미 존재하면 무시)
|
|
56
56
|
* admin.ts의 CREATE_FILES_TABLE_SQL과 동일한 스키마 사용
|
|
57
|
+
* ⚠️ 테이블명 '_system_files' — 사용자 테이블과 네이밍 충돌 방지 (2026-04-10)
|
|
57
58
|
*/
|
|
58
59
|
let filesTableEnsured = false;
|
|
59
60
|
|
|
@@ -67,9 +68,9 @@ async function ensureFilesTable(
|
|
|
67
68
|
IF NOT EXISTS (
|
|
68
69
|
SELECT 1 FROM information_schema.tables
|
|
69
70
|
WHERE table_schema = current_schema()
|
|
70
|
-
AND table_name = '
|
|
71
|
+
AND table_name = '_system_files'
|
|
71
72
|
) THEN
|
|
72
|
-
CREATE TABLE
|
|
73
|
+
CREATE TABLE _system_files (
|
|
73
74
|
id SERIAL PRIMARY KEY,
|
|
74
75
|
storage_id TEXT NOT NULL,
|
|
75
76
|
name TEXT NOT NULL,
|
|
@@ -95,7 +96,7 @@ async function checkStorageQuota(
|
|
|
95
96
|
if (quota <= 0) return; // 무제한
|
|
96
97
|
|
|
97
98
|
const rows = await rawSql(
|
|
98
|
-
`SELECT COALESCE(SUM(CAST(size AS BIGINT)), 0) AS total FROM
|
|
99
|
+
`SELECT COALESCE(SUM(CAST(size AS BIGINT)), 0) AS total FROM _system_files`,
|
|
99
100
|
);
|
|
100
101
|
const currentUsage = Number((rows[0] as Record<string, string>)?.total || "0");
|
|
101
102
|
const projectedUsage = currentUsage + newFileSize;
|
|
@@ -124,7 +125,7 @@ async function recordFileToDb(
|
|
|
124
125
|
): Promise<void> {
|
|
125
126
|
await ensureFilesTable(rawSql);
|
|
126
127
|
await rawSql(
|
|
127
|
-
`INSERT INTO
|
|
128
|
+
`INSERT INTO _system_files (storage_id, name, size, type, uploaded_by, created_at)
|
|
128
129
|
VALUES ($1, $2, $3, $4, $5, NOW())`,
|
|
129
130
|
[storageId, name, String(size), type, uploadedBy],
|
|
130
131
|
);
|
|
@@ -258,7 +259,7 @@ export function createStorage(
|
|
|
258
259
|
if (rawSql) {
|
|
259
260
|
try {
|
|
260
261
|
await rawSql(
|
|
261
|
-
`DELETE FROM
|
|
262
|
+
`DELETE FROM _system_files WHERE storage_id = $1`,
|
|
262
263
|
[storageId],
|
|
263
264
|
);
|
|
264
265
|
} catch { /* 삭제 실패 무시 — 파일은 이미 제거됨 */ }
|
|
@@ -286,7 +287,7 @@ export function storageRoutes(
|
|
|
286
287
|
if (!meta && rawSql) {
|
|
287
288
|
try {
|
|
288
289
|
const rows = await rawSql(
|
|
289
|
-
`SELECT storage_id, name, size, type FROM
|
|
290
|
+
`SELECT storage_id, name, size, type FROM _system_files WHERE storage_id = $1 LIMIT 1`,
|
|
290
291
|
[id]
|
|
291
292
|
);
|
|
292
293
|
if (rows.length > 0) {
|
package/src/v.ts
CHANGED
|
@@ -135,11 +135,15 @@ export function parseArgs(schema: any, args: any): any {
|
|
|
135
135
|
|
|
136
136
|
// Shorthand object — e.g. { id: v.number(), title: v.optional(v.string()) }
|
|
137
137
|
if (typeof schema === "object" && schema !== null) {
|
|
138
|
+
// Empty schema {} → passthrough all args (e.g. FormData with file field)
|
|
139
|
+
const schemaKeys = Object.keys(schema);
|
|
140
|
+
if (schemaKeys.length === 0) return args;
|
|
141
|
+
|
|
138
142
|
if (typeof args !== "object" || args === null) {
|
|
139
143
|
throw new GencowValidationError("Expected an object for arguments");
|
|
140
144
|
}
|
|
141
145
|
const result: any = {};
|
|
142
|
-
for (const key
|
|
146
|
+
for (const key of schemaKeys) {
|
|
143
147
|
const validator = schema[key];
|
|
144
148
|
if (validator && typeof validator.parse === "function") {
|
|
145
149
|
try {
|