@gencow/core 0.1.28 → 0.1.29
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/auth-config.d.ts +92 -5
- package/dist/config.d.ts +107 -0
- package/dist/config.js +12 -0
- package/dist/context.d.ts +139 -0
- package/dist/context.js +3 -0
- package/dist/crud.d.ts +5 -5
- package/dist/crud.js +19 -35
- package/dist/document-types.d.ts +2 -2
- package/dist/http-action.d.ts +77 -0
- package/dist/http-action.js +41 -0
- package/dist/index.d.ts +21 -5
- package/dist/index.js +12 -3
- package/dist/platform-capacity-profile.d.ts +19 -0
- package/dist/platform-capacity-profile.js +94 -0
- package/dist/procedure.d.ts +58 -0
- package/dist/procedure.js +115 -0
- package/dist/rag-schema.d.ts +449 -540
- package/dist/reactive-mutation-types.d.ts +11 -0
- package/dist/reactive-mutation-types.js +1 -0
- package/dist/reactive-mutation.d.ts +51 -0
- package/dist/reactive-mutation.js +75 -0
- package/dist/reactive-query-types.d.ts +12 -0
- package/dist/reactive-query-types.js +1 -0
- package/dist/reactive-query.d.ts +14 -0
- package/dist/reactive-query.js +28 -0
- package/dist/reactive-realtime.d.ts +48 -0
- package/dist/reactive-realtime.js +236 -0
- package/dist/reactive.d.ts +16 -5
- package/dist/reactive.js +65 -0
- package/dist/runtime-env-policy.js +1 -1
- package/dist/server.d.ts +1 -1
- package/dist/storage-metering.d.ts +13 -0
- package/dist/storage-metering.js +18 -0
- package/dist/storage.d.ts +3 -1
- package/dist/storage.js +11 -7
- package/dist/wake-app-result.d.ts +22 -0
- package/dist/wake-app-result.js +11 -0
- package/dist/workflow-types.d.ts +13 -1
- package/dist/workflow.d.ts +1 -1
- package/dist/workflow.js +136 -11
- package/dist/workflows-api.js +71 -3
- package/package.json +4 -1
- package/src/auth-config.ts +104 -3
- package/src/config.ts +119 -0
- package/src/context.ts +152 -0
- package/src/crud.ts +18 -35
- package/src/document-types.ts +9 -2
- package/src/http-action.ts +101 -0
- package/src/index.ts +77 -19
- package/src/platform-capacity-profile.ts +114 -0
- package/src/procedure.ts +283 -0
- package/src/reactive-mutation-types.ts +13 -0
- package/src/reactive-mutation.ts +115 -0
- package/src/reactive-query-types.ts +14 -0
- package/src/reactive-query.ts +48 -0
- package/src/reactive-realtime.ts +267 -0
- package/src/runtime-env-policy.ts +1 -1
- package/src/server.ts +6 -1
- package/src/storage-metering.ts +35 -0
- package/src/storage.ts +14 -6
- package/src/wake-app-result.ts +37 -0
- package/src/workflow-types.ts +13 -1
- package/src/workflow.ts +166 -12
- package/src/workflows-api.ts +82 -3
- package/src/reactive.ts +0 -593
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { InferArgs } from "./v.js";
|
|
2
|
+
import type { GencowCtx } from "./context.js";
|
|
3
|
+
export type MutationHandler<TArgs = any, TReturn = any> = (ctx: GencowCtx, args: TArgs) => Promise<TReturn>;
|
|
4
|
+
export interface MutationDef<TSchema = any, TReturn = any> {
|
|
5
|
+
handler: MutationHandler<InferArgs<TSchema>, TReturn>;
|
|
6
|
+
argsSchema?: TSchema;
|
|
7
|
+
/** true = 인증 없이 접근 가능, false(기본) = auth 필수 (Secure by Default) */
|
|
8
|
+
isPublic: boolean;
|
|
9
|
+
_args?: InferArgs<TSchema>;
|
|
10
|
+
_return?: TReturn;
|
|
11
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { type InferArgs } from "./v.js";
|
|
2
|
+
import type { MutationDef, MutationHandler } from "./reactive-mutation-types.js";
|
|
3
|
+
declare global {
|
|
4
|
+
var __gencow_mutationRegistry: (MutationDef<any, any> & {
|
|
5
|
+
name: string;
|
|
6
|
+
})[];
|
|
7
|
+
}
|
|
8
|
+
export declare const mutationRegistry: (MutationDef<any, any> & {
|
|
9
|
+
name: string;
|
|
10
|
+
})[];
|
|
11
|
+
/**
|
|
12
|
+
* mutation — 데이터 변경 함수를 선언적으로 등록합니다.
|
|
13
|
+
*
|
|
14
|
+
* 3가지 호출 방식 지원 (query와 동일한 패턴 우선):
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* ```typescript
|
|
18
|
+
* // ✅ 권장: query와 동일한 (name, def) 패턴
|
|
19
|
+
* mutation("tasks.create", {
|
|
20
|
+
* args: { title: v.string() },
|
|
21
|
+
* handler: async (ctx, args) => { ... },
|
|
22
|
+
* });
|
|
23
|
+
*
|
|
24
|
+
* // ✅ 객체 스타일 (하위 호환)
|
|
25
|
+
* mutation({
|
|
26
|
+
* name: "tasks.create",
|
|
27
|
+
* handler: async (ctx) => { ... },
|
|
28
|
+
* });
|
|
29
|
+
*
|
|
30
|
+
* // ⚠️ Legacy 배열 스타일 (비권장)
|
|
31
|
+
* mutation(["tasks.list"], handler, "tasks.create");
|
|
32
|
+
* ```
|
|
33
|
+
*
|
|
34
|
+
* @note invalidates 필드는 deprecated — 전달해도 무시됩니다.
|
|
35
|
+
* 리얼타임 UI 갱신에는 ctx.realtime.emit() 또는 ctx.realtime.refresh()를 사용하세요.
|
|
36
|
+
*/
|
|
37
|
+
export declare function mutation<TSchema = any, TReturn = any>(nameOrInvalidatesOrDef: string | string[] | {
|
|
38
|
+
name?: string;
|
|
39
|
+
args?: TSchema;
|
|
40
|
+
public?: boolean;
|
|
41
|
+
invalidates?: string[];
|
|
42
|
+
handler: MutationHandler<InferArgs<TSchema>, TReturn>;
|
|
43
|
+
}, handlerOrDef?: MutationHandler<InferArgs<TSchema>, TReturn> | {
|
|
44
|
+
invalidates?: string[];
|
|
45
|
+
args?: TSchema;
|
|
46
|
+
public?: boolean;
|
|
47
|
+
handler: MutationHandler<InferArgs<TSchema>, TReturn>;
|
|
48
|
+
}, name?: string): MutationDef<TSchema, TReturn>;
|
|
49
|
+
export declare function getRegisteredMutations(): (MutationDef & {
|
|
50
|
+
name: string;
|
|
51
|
+
})[];
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
if (!globalThis.__gencow_mutationRegistry)
|
|
2
|
+
globalThis.__gencow_mutationRegistry = [];
|
|
3
|
+
export const mutationRegistry = globalThis.__gencow_mutationRegistry;
|
|
4
|
+
let mutationCounter = 0;
|
|
5
|
+
/**
|
|
6
|
+
* mutation — 데이터 변경 함수를 선언적으로 등록합니다.
|
|
7
|
+
*
|
|
8
|
+
* 3가지 호출 방식 지원 (query와 동일한 패턴 우선):
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* ```typescript
|
|
12
|
+
* // ✅ 권장: query와 동일한 (name, def) 패턴
|
|
13
|
+
* mutation("tasks.create", {
|
|
14
|
+
* args: { title: v.string() },
|
|
15
|
+
* handler: async (ctx, args) => { ... },
|
|
16
|
+
* });
|
|
17
|
+
*
|
|
18
|
+
* // ✅ 객체 스타일 (하위 호환)
|
|
19
|
+
* mutation({
|
|
20
|
+
* name: "tasks.create",
|
|
21
|
+
* handler: async (ctx) => { ... },
|
|
22
|
+
* });
|
|
23
|
+
*
|
|
24
|
+
* // ⚠️ Legacy 배열 스타일 (비권장)
|
|
25
|
+
* mutation(["tasks.list"], handler, "tasks.create");
|
|
26
|
+
* ```
|
|
27
|
+
*
|
|
28
|
+
* @note invalidates 필드는 deprecated — 전달해도 무시됩니다.
|
|
29
|
+
* 리얼타임 UI 갱신에는 ctx.realtime.emit() 또는 ctx.realtime.refresh()를 사용하세요.
|
|
30
|
+
*/
|
|
31
|
+
export function mutation(nameOrInvalidatesOrDef, handlerOrDef, name) {
|
|
32
|
+
let argsSchema;
|
|
33
|
+
let actualHandler;
|
|
34
|
+
let mutName;
|
|
35
|
+
let isPublic = false;
|
|
36
|
+
if (typeof nameOrInvalidatesOrDef === "string") {
|
|
37
|
+
// New primary style: mutation("name", { args?, public?, handler })
|
|
38
|
+
mutName = nameOrInvalidatesOrDef;
|
|
39
|
+
const def = handlerOrDef;
|
|
40
|
+
actualHandler = def.handler;
|
|
41
|
+
argsSchema = def.args;
|
|
42
|
+
isPublic = def.public === true;
|
|
43
|
+
}
|
|
44
|
+
else if (Array.isArray(nameOrInvalidatesOrDef)) {
|
|
45
|
+
// Legacy style: mutation([...], handler, "name") — invalidates ignored
|
|
46
|
+
actualHandler = handlerOrDef;
|
|
47
|
+
mutName = name || `mutation_${++mutationCounter}`;
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
// Object style: mutation({ name?, args?, public?, handler })
|
|
51
|
+
actualHandler = nameOrInvalidatesOrDef.handler;
|
|
52
|
+
argsSchema = nameOrInvalidatesOrDef.args;
|
|
53
|
+
isPublic = nameOrInvalidatesOrDef.public === true;
|
|
54
|
+
mutName =
|
|
55
|
+
nameOrInvalidatesOrDef.name ||
|
|
56
|
+
(typeof name === "string" ? name : "") ||
|
|
57
|
+
`mutation_${++mutationCounter}`;
|
|
58
|
+
}
|
|
59
|
+
// 이름 미지정 시 경고 — 디버깅 지원
|
|
60
|
+
if (mutName.startsWith("mutation_")) {
|
|
61
|
+
console.warn(`[gencow] mutation registered without explicit name → "${mutName}". ` +
|
|
62
|
+
`Use mutation("myMutation", { handler }) for better debugging.`);
|
|
63
|
+
}
|
|
64
|
+
const def = {
|
|
65
|
+
name: mutName,
|
|
66
|
+
handler: actualHandler,
|
|
67
|
+
argsSchema,
|
|
68
|
+
isPublic,
|
|
69
|
+
};
|
|
70
|
+
mutationRegistry.push(def);
|
|
71
|
+
return def;
|
|
72
|
+
}
|
|
73
|
+
export function getRegisteredMutations() {
|
|
74
|
+
return [...mutationRegistry];
|
|
75
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { InferArgs } from "./v.js";
|
|
2
|
+
import type { GencowCtx } from "./context.js";
|
|
3
|
+
export type QueryHandler<TArgs = any, TReturn = any> = (ctx: GencowCtx, args: TArgs) => Promise<TReturn>;
|
|
4
|
+
export interface QueryDef<TSchema = any, TReturn = any> {
|
|
5
|
+
key: string;
|
|
6
|
+
handler: QueryHandler<InferArgs<TSchema>, TReturn>;
|
|
7
|
+
argsSchema?: TSchema;
|
|
8
|
+
/** true = 인증 없이 접근 가능, false(기본) = auth 필수 (Secure by Default) */
|
|
9
|
+
isPublic: boolean;
|
|
10
|
+
_args?: InferArgs<TSchema>;
|
|
11
|
+
_return?: TReturn;
|
|
12
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type InferArgs } from "./v.js";
|
|
2
|
+
import type { QueryDef, QueryHandler } from "./reactive-query-types.js";
|
|
3
|
+
declare global {
|
|
4
|
+
var __gencow_queryRegistry: Map<string, QueryDef<any, any>>;
|
|
5
|
+
}
|
|
6
|
+
export declare const queryRegistry: Map<string, QueryDef<any, any>>;
|
|
7
|
+
export declare function query<TSchema = any, TReturn = any>(key: string, handlerOrDef: QueryHandler<InferArgs<TSchema>, TReturn> | {
|
|
8
|
+
args?: TSchema;
|
|
9
|
+
public?: boolean;
|
|
10
|
+
handler: QueryHandler<InferArgs<TSchema>, TReturn>;
|
|
11
|
+
}): QueryDef<TSchema, TReturn>;
|
|
12
|
+
export declare function getQueryHandler(key: string): QueryHandler | undefined;
|
|
13
|
+
export declare function getQueryDef(key: string): QueryDef | undefined;
|
|
14
|
+
export declare function getRegisteredQueries(): string[];
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
if (!globalThis.__gencow_queryRegistry)
|
|
2
|
+
globalThis.__gencow_queryRegistry = new Map();
|
|
3
|
+
export const queryRegistry = globalThis.__gencow_queryRegistry;
|
|
4
|
+
export function query(key, handlerOrDef) {
|
|
5
|
+
let handler;
|
|
6
|
+
let argsSchema;
|
|
7
|
+
let isPublic = false;
|
|
8
|
+
if (typeof handlerOrDef === "function") {
|
|
9
|
+
handler = handlerOrDef;
|
|
10
|
+
}
|
|
11
|
+
else {
|
|
12
|
+
handler = handlerOrDef.handler;
|
|
13
|
+
argsSchema = handlerOrDef.args;
|
|
14
|
+
isPublic = handlerOrDef.public === true;
|
|
15
|
+
}
|
|
16
|
+
const def = { key, handler, argsSchema, isPublic };
|
|
17
|
+
queryRegistry.set(key, def);
|
|
18
|
+
return def;
|
|
19
|
+
}
|
|
20
|
+
export function getQueryHandler(key) {
|
|
21
|
+
return queryRegistry.get(key)?.handler;
|
|
22
|
+
}
|
|
23
|
+
export function getQueryDef(key) {
|
|
24
|
+
return queryRegistry.get(key);
|
|
25
|
+
}
|
|
26
|
+
export function getRegisteredQueries() {
|
|
27
|
+
return Array.from(queryRegistry.keys());
|
|
28
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { WSContext } from "hono/ws";
|
|
2
|
+
import type { GencowCtx, RealtimeCtx, RealtimeNotifyEvent } from "./context.js";
|
|
3
|
+
import type { QueryDef } from "./reactive-query-types.js";
|
|
4
|
+
declare global {
|
|
5
|
+
var __gencow_subscribers: Map<string, Set<WSContext>>;
|
|
6
|
+
var __gencow_connectedClients: Set<WSContext>;
|
|
7
|
+
}
|
|
8
|
+
export declare const subscribers: Map<string, Set<WSContext<unknown>>>;
|
|
9
|
+
/**
|
|
10
|
+
* Every WebSocket client that ever establishes a connection is tracked here.
|
|
11
|
+
* This allows broadcasting invalidation events to clients that never subscribed
|
|
12
|
+
* to a specific query (e.g. the admin dashboard's raw WebSocket connection).
|
|
13
|
+
*/
|
|
14
|
+
export declare const connectedClients: Set<WSContext<unknown>>;
|
|
15
|
+
/** Stable subscription key for WebSocket / invalidate fanout (also used by crud for query keys). */
|
|
16
|
+
export declare function buildQuerySubscriptionKey(queryKey: string, args?: unknown): string;
|
|
17
|
+
export declare function subscriptionKeyMatchesQueryKey(subscriptionKey: string, queryKey: string): boolean;
|
|
18
|
+
export declare function subscribe(queryKey: string, ws: WSContext): void;
|
|
19
|
+
export declare function unsubscribe(ws: WSContext): void;
|
|
20
|
+
/** Register a raw WS connection without any query subscription (e.g. admin dashboard) */
|
|
21
|
+
export declare function registerClient(ws: WSContext): void;
|
|
22
|
+
export declare function deregisterClient(ws: WSContext): void;
|
|
23
|
+
/**
|
|
24
|
+
* mutation 실행 시점에 생성되는 RealtimeCtx.
|
|
25
|
+
* emit(): 데이터를 직접 push (초고빈도 mutation용).
|
|
26
|
+
* refresh(): queryKey를 pending 큐에 추가, mutation 완료 후 서버가 query re-run하여 push.
|
|
27
|
+
*
|
|
28
|
+
* 💡 Batching: 같은 queryKey에 대한 emit이 50ms 내에 여러 번 호출되면
|
|
29
|
+
* 마지막 데이터만 push하여 불필요한 전송을 방지합니다.
|
|
30
|
+
*
|
|
31
|
+
* ⚠️ 매 mutation 호출마다 새로 생성해야 합니다 (debounce timer가 mutation scope에 격리).
|
|
32
|
+
*
|
|
33
|
+
* @param options.httpCallback BaaS 모드: Platform WS Gateway에 HTTP로 emit 전달.
|
|
34
|
+
* 설정되면 WS 직접 push 대신 이 콜백을 호출.
|
|
35
|
+
* 로컬 dev에서는 미설정 → 기존 WS 직접 push 유지.
|
|
36
|
+
* @param options.queryMap query 레지스트리 — refresh()에서 query handler를 찾아 re-run.
|
|
37
|
+
* @param options.buildCtxForRefresh refresh 시 query handler에 전달할 ctx 생성 함수.
|
|
38
|
+
*/
|
|
39
|
+
export declare function buildRealtimeCtx(options?: {
|
|
40
|
+
httpCallback?: (event: RealtimeNotifyEvent) => void;
|
|
41
|
+
queryMap?: Map<string, QueryDef<any, any>>;
|
|
42
|
+
buildCtxForRefresh?: () => GencowCtx;
|
|
43
|
+
}): RealtimeCtx & {
|
|
44
|
+
_hasEmitted: boolean;
|
|
45
|
+
_pendingRefresh: string[];
|
|
46
|
+
_flushRefresh: () => Promise<void>;
|
|
47
|
+
};
|
|
48
|
+
export declare function handleWsMessage(ws: WSContext, raw: string | ArrayBuffer): void;
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { queryRegistry } from "./reactive-query.js";
|
|
2
|
+
if (!globalThis.__gencow_subscribers)
|
|
3
|
+
globalThis.__gencow_subscribers = new Map();
|
|
4
|
+
if (!globalThis.__gencow_connectedClients)
|
|
5
|
+
globalThis.__gencow_connectedClients = new Set();
|
|
6
|
+
export const subscribers = globalThis.__gencow_subscribers;
|
|
7
|
+
/**
|
|
8
|
+
* Every WebSocket client that ever establishes a connection is tracked here.
|
|
9
|
+
* This allows broadcasting invalidation events to clients that never subscribed
|
|
10
|
+
* to a specific query (e.g. the admin dashboard's raw WebSocket connection).
|
|
11
|
+
*/
|
|
12
|
+
export const connectedClients = globalThis.__gencow_connectedClients;
|
|
13
|
+
const SUBSCRIPTION_KEY_SEPARATOR = "::";
|
|
14
|
+
function normalizeForStableJson(value) {
|
|
15
|
+
if (value === undefined)
|
|
16
|
+
return undefined;
|
|
17
|
+
if (value === null)
|
|
18
|
+
return null;
|
|
19
|
+
if (value instanceof Date)
|
|
20
|
+
return value.toISOString();
|
|
21
|
+
if (Array.isArray(value))
|
|
22
|
+
return value.map((item) => normalizeForStableJson(item));
|
|
23
|
+
if (typeof value === "object") {
|
|
24
|
+
const source = value;
|
|
25
|
+
const sorted = {};
|
|
26
|
+
for (const key of Object.keys(source).sort()) {
|
|
27
|
+
const normalized = normalizeForStableJson(source[key]);
|
|
28
|
+
if (normalized !== undefined)
|
|
29
|
+
sorted[key] = normalized;
|
|
30
|
+
}
|
|
31
|
+
return sorted;
|
|
32
|
+
}
|
|
33
|
+
return value;
|
|
34
|
+
}
|
|
35
|
+
function isEmptyPlainObject(value) {
|
|
36
|
+
return !!value && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length === 0;
|
|
37
|
+
}
|
|
38
|
+
/** Stable subscription key for WebSocket / invalidate fanout (also used by crud for query keys). */
|
|
39
|
+
export function buildQuerySubscriptionKey(queryKey, args) {
|
|
40
|
+
const normalized = normalizeForStableJson(args);
|
|
41
|
+
if (normalized === undefined || isEmptyPlainObject(normalized))
|
|
42
|
+
return queryKey;
|
|
43
|
+
return `${queryKey}${SUBSCRIPTION_KEY_SEPARATOR}${JSON.stringify(normalized)}`;
|
|
44
|
+
}
|
|
45
|
+
export function subscriptionKeyMatchesQueryKey(subscriptionKey, queryKey) {
|
|
46
|
+
return subscriptionKey === queryKey || subscriptionKey.startsWith(`${queryKey}${SUBSCRIPTION_KEY_SEPARATOR}`);
|
|
47
|
+
}
|
|
48
|
+
export function subscribe(queryKey, ws) {
|
|
49
|
+
connectedClients.add(ws);
|
|
50
|
+
if (!subscribers.has(queryKey)) {
|
|
51
|
+
subscribers.set(queryKey, new Set());
|
|
52
|
+
}
|
|
53
|
+
subscribers.get(queryKey).add(ws);
|
|
54
|
+
}
|
|
55
|
+
export function unsubscribe(ws) {
|
|
56
|
+
connectedClients.delete(ws);
|
|
57
|
+
for (const clients of subscribers.values()) {
|
|
58
|
+
clients.delete(ws);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/** Register a raw WS connection without any query subscription (e.g. admin dashboard) */
|
|
62
|
+
export function registerClient(ws) {
|
|
63
|
+
connectedClients.add(ws);
|
|
64
|
+
}
|
|
65
|
+
export function deregisterClient(ws) {
|
|
66
|
+
connectedClients.delete(ws);
|
|
67
|
+
for (const clients of subscribers.values()) {
|
|
68
|
+
clients.delete(ws);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function sendInvalidateToLocalSubscribers(queryKeys) {
|
|
72
|
+
const targets = new Map();
|
|
73
|
+
for (const queryKey of queryKeys) {
|
|
74
|
+
for (const [subscriptionKey, clients] of subscribers) {
|
|
75
|
+
if (!subscriptionKeyMatchesQueryKey(subscriptionKey, queryKey))
|
|
76
|
+
continue;
|
|
77
|
+
for (const ws of clients) {
|
|
78
|
+
if (!targets.has(ws))
|
|
79
|
+
targets.set(ws, new Set());
|
|
80
|
+
targets.get(ws).add(subscriptionKey);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
for (const [ws, keys] of targets) {
|
|
85
|
+
try {
|
|
86
|
+
ws.send(JSON.stringify({ type: "invalidate", queries: [...keys] }));
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
deregisterClient(ws);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* mutation 실행 시점에 생성되는 RealtimeCtx.
|
|
95
|
+
* emit(): 데이터를 직접 push (초고빈도 mutation용).
|
|
96
|
+
* refresh(): queryKey를 pending 큐에 추가, mutation 완료 후 서버가 query re-run하여 push.
|
|
97
|
+
*
|
|
98
|
+
* 💡 Batching: 같은 queryKey에 대한 emit이 50ms 내에 여러 번 호출되면
|
|
99
|
+
* 마지막 데이터만 push하여 불필요한 전송을 방지합니다.
|
|
100
|
+
*
|
|
101
|
+
* ⚠️ 매 mutation 호출마다 새로 생성해야 합니다 (debounce timer가 mutation scope에 격리).
|
|
102
|
+
*
|
|
103
|
+
* @param options.httpCallback BaaS 모드: Platform WS Gateway에 HTTP로 emit 전달.
|
|
104
|
+
* 설정되면 WS 직접 push 대신 이 콜백을 호출.
|
|
105
|
+
* 로컬 dev에서는 미설정 → 기존 WS 직접 push 유지.
|
|
106
|
+
* @param options.queryMap query 레지스트리 — refresh()에서 query handler를 찾아 re-run.
|
|
107
|
+
* @param options.buildCtxForRefresh refresh 시 query handler에 전달할 ctx 생성 함수.
|
|
108
|
+
*/
|
|
109
|
+
export function buildRealtimeCtx(options) {
|
|
110
|
+
const pendingEmits = new Map();
|
|
111
|
+
const _pendingRefresh = [];
|
|
112
|
+
let _hasEmitted = false;
|
|
113
|
+
return {
|
|
114
|
+
emit(queryKey, data) {
|
|
115
|
+
_hasEmitted = true;
|
|
116
|
+
// 기존 pending timer가 있으면 취소 (debounce)
|
|
117
|
+
const existing = pendingEmits.get(queryKey);
|
|
118
|
+
if (existing)
|
|
119
|
+
clearTimeout(existing.timer);
|
|
120
|
+
const timer = setTimeout(() => {
|
|
121
|
+
pendingEmits.delete(queryKey);
|
|
122
|
+
// BaaS 모드: Platform WS Gateway에 HTTP callback
|
|
123
|
+
if (options?.httpCallback) {
|
|
124
|
+
options.httpCallback({ type: "emit", queryKey, data });
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
// 로컬 dev: WS 직접 push (기존 동작)
|
|
128
|
+
const clients = subscribers.get(queryKey);
|
|
129
|
+
if (!clients || clients.size === 0)
|
|
130
|
+
return;
|
|
131
|
+
const message = JSON.stringify({
|
|
132
|
+
type: "query:updated",
|
|
133
|
+
query: queryKey,
|
|
134
|
+
data,
|
|
135
|
+
});
|
|
136
|
+
for (const ws of clients) {
|
|
137
|
+
try {
|
|
138
|
+
ws.send(message);
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
clients.delete(ws);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}, 50); // 50ms batch window
|
|
145
|
+
pendingEmits.set(queryKey, { data, timer });
|
|
146
|
+
},
|
|
147
|
+
invalidate(queryKey) {
|
|
148
|
+
_hasEmitted = true;
|
|
149
|
+
const queryKeys = Array.isArray(queryKey) ? [...new Set(queryKey)] : [queryKey];
|
|
150
|
+
if (options?.httpCallback) {
|
|
151
|
+
options.httpCallback({ type: "invalidate", queryKeys });
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
sendInvalidateToLocalSubscribers(queryKeys);
|
|
155
|
+
},
|
|
156
|
+
refresh(queryKey) {
|
|
157
|
+
_hasEmitted = true; // 경고 억제
|
|
158
|
+
if (!_pendingRefresh.includes(queryKey)) {
|
|
159
|
+
_pendingRefresh.push(queryKey);
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
get _hasEmitted() {
|
|
163
|
+
return _hasEmitted;
|
|
164
|
+
},
|
|
165
|
+
get _pendingRefresh() {
|
|
166
|
+
return [..._pendingRefresh];
|
|
167
|
+
},
|
|
168
|
+
async _flushRefresh() {
|
|
169
|
+
if (_pendingRefresh.length === 0)
|
|
170
|
+
return;
|
|
171
|
+
// queryMap이 없으면 refresh 동작 불가 (로그 경고)
|
|
172
|
+
const qMap = options?.queryMap ?? queryRegistry;
|
|
173
|
+
for (const key of _pendingRefresh) {
|
|
174
|
+
const queryDef = qMap.get(key);
|
|
175
|
+
if (!queryDef) {
|
|
176
|
+
console.warn(`[gencow] refresh("${key}"): query not found in registry. Skipping.`);
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
try {
|
|
180
|
+
// refresh용 ctx 생성 (mutation ctx와 동일한 DB/auth 스코프)
|
|
181
|
+
if (!options?.buildCtxForRefresh) {
|
|
182
|
+
console.warn(`[gencow] ⚠️ refresh("${key}"): buildCtxForRefresh not provided. ` +
|
|
183
|
+
`Query handler will receive an empty ctx — ctx.db will be undefined. ` +
|
|
184
|
+
`This is a framework configuration error. ` +
|
|
185
|
+
`💡 Ensure buildRealtimeCtx() receives a buildCtxForRefresh callback.`);
|
|
186
|
+
}
|
|
187
|
+
const refreshCtx = options?.buildCtxForRefresh?.() ?? {};
|
|
188
|
+
const result = await queryDef.handler(refreshCtx, {});
|
|
189
|
+
// emit과 동일한 경로로 push
|
|
190
|
+
if (options?.httpCallback) {
|
|
191
|
+
options.httpCallback({ type: "emit", queryKey: key, data: result });
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
const clients = subscribers.get(key);
|
|
195
|
+
if (clients && clients.size > 0) {
|
|
196
|
+
const message = JSON.stringify({
|
|
197
|
+
type: "query:updated",
|
|
198
|
+
query: key,
|
|
199
|
+
data: result,
|
|
200
|
+
});
|
|
201
|
+
for (const ws of clients) {
|
|
202
|
+
try {
|
|
203
|
+
ws.send(message);
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
clients.delete(ws);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
catch (e) {
|
|
213
|
+
console.warn(`[gencow] refresh("${key}") failed:`, e instanceof Error ? e.message : e);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
_pendingRefresh.length = 0;
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
export function handleWsMessage(ws, raw) {
|
|
221
|
+
try {
|
|
222
|
+
const msg = typeof raw === "string" ? JSON.parse(raw) : JSON.parse(raw.toString());
|
|
223
|
+
if (msg.type === "subscribe" && msg.query) {
|
|
224
|
+
subscribe(msg.query, ws);
|
|
225
|
+
ws.send(JSON.stringify({ type: "subscribed", query: msg.query }));
|
|
226
|
+
}
|
|
227
|
+
if (msg.type === "unsubscribe" && msg.query) {
|
|
228
|
+
const clients = subscribers.get(msg.query);
|
|
229
|
+
if (clients)
|
|
230
|
+
clients.delete(ws);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
// ignore malformed messages
|
|
235
|
+
}
|
|
236
|
+
}
|
package/dist/reactive.d.ts
CHANGED
|
@@ -33,6 +33,11 @@ export interface RealtimeCtx {
|
|
|
33
33
|
* ctx.realtime.emit("tasks.list", freshList);
|
|
34
34
|
*/
|
|
35
35
|
emit(queryKey: string, data: unknown): void;
|
|
36
|
+
/**
|
|
37
|
+
* 특정 queryKey 구독자에게 재조회 신호만 보냅니다.
|
|
38
|
+
* private/RLS query처럼 서버가 안전하게 full payload를 만들 수 없는 경우 기본값입니다.
|
|
39
|
+
*/
|
|
40
|
+
invalidate(queryKey: string | string[]): void;
|
|
36
41
|
/**
|
|
37
42
|
* 수동 mutation에서 리얼타임 업데이트의 기본 권장 방식.
|
|
38
43
|
* mutation handler 완료 후 서버가 해당 query를 re-run하여 결과를 push합니다.
|
|
@@ -51,6 +56,14 @@ export interface RealtimeCtx {
|
|
|
51
56
|
*/
|
|
52
57
|
refresh(queryKey: string): void;
|
|
53
58
|
}
|
|
59
|
+
export type RealtimeNotifyEvent = {
|
|
60
|
+
type: "emit";
|
|
61
|
+
queryKey: string;
|
|
62
|
+
data: unknown;
|
|
63
|
+
} | {
|
|
64
|
+
type: "invalidate";
|
|
65
|
+
queryKeys: string[];
|
|
66
|
+
};
|
|
54
67
|
/**
|
|
55
68
|
* 사용자 함수(query/mutation)에 주입되는 컨텍스트.
|
|
56
69
|
* Convex의 ctx 패턴과 동일하게, 이 객체를 통해서만 DB/Storage/Auth에 접근 가능.
|
|
@@ -188,6 +201,8 @@ declare global {
|
|
|
188
201
|
var __gencow_connectedClients: Set<WSContext>;
|
|
189
202
|
var __gencow_httpActionRegistry: HttpActionDef[];
|
|
190
203
|
}
|
|
204
|
+
export declare function buildQuerySubscriptionKey(queryKey: string, args?: unknown): string;
|
|
205
|
+
export declare function subscriptionKeyMatchesQueryKey(subscriptionKey: string, queryKey: string): boolean;
|
|
191
206
|
export declare function query<TSchema = any, TReturn = any>(key: string, handlerOrDef: QueryHandler<InferArgs<TSchema>, TReturn> | {
|
|
192
207
|
args?: TSchema;
|
|
193
208
|
public?: boolean;
|
|
@@ -285,11 +300,7 @@ export declare function deregisterClient(ws: WSContext): void;
|
|
|
285
300
|
* @param options.buildCtxForRefresh refresh 시 query handler에 전달할 ctx 생성 함수.
|
|
286
301
|
*/
|
|
287
302
|
export declare function buildRealtimeCtx(options?: {
|
|
288
|
-
httpCallback?: (event:
|
|
289
|
-
type: "emit";
|
|
290
|
-
queryKey: string;
|
|
291
|
-
data: unknown;
|
|
292
|
-
}) => void;
|
|
303
|
+
httpCallback?: (event: RealtimeNotifyEvent) => void;
|
|
293
304
|
queryMap?: Map<string, QueryDef<any, any>>;
|
|
294
305
|
buildCtxForRefresh?: () => GencowCtx;
|
|
295
306
|
}): RealtimeCtx & {
|
package/dist/reactive.js
CHANGED
|
@@ -18,6 +18,40 @@ const subscribers = globalThis.__gencow_subscribers;
|
|
|
18
18
|
* to a specific query (e.g. the admin dashboard's raw WebSocket connection).
|
|
19
19
|
*/
|
|
20
20
|
const connectedClients = globalThis.__gencow_connectedClients;
|
|
21
|
+
const SUBSCRIPTION_KEY_SEPARATOR = "::";
|
|
22
|
+
function normalizeForStableJson(value) {
|
|
23
|
+
if (value === undefined)
|
|
24
|
+
return undefined;
|
|
25
|
+
if (value === null)
|
|
26
|
+
return null;
|
|
27
|
+
if (value instanceof Date)
|
|
28
|
+
return value.toISOString();
|
|
29
|
+
if (Array.isArray(value))
|
|
30
|
+
return value.map((item) => normalizeForStableJson(item));
|
|
31
|
+
if (typeof value === "object") {
|
|
32
|
+
const source = value;
|
|
33
|
+
const sorted = {};
|
|
34
|
+
for (const key of Object.keys(source).sort()) {
|
|
35
|
+
const normalized = normalizeForStableJson(source[key]);
|
|
36
|
+
if (normalized !== undefined)
|
|
37
|
+
sorted[key] = normalized;
|
|
38
|
+
}
|
|
39
|
+
return sorted;
|
|
40
|
+
}
|
|
41
|
+
return value;
|
|
42
|
+
}
|
|
43
|
+
function isEmptyPlainObject(value) {
|
|
44
|
+
return !!value && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length === 0;
|
|
45
|
+
}
|
|
46
|
+
export function buildQuerySubscriptionKey(queryKey, args) {
|
|
47
|
+
const normalized = normalizeForStableJson(args);
|
|
48
|
+
if (normalized === undefined || isEmptyPlainObject(normalized))
|
|
49
|
+
return queryKey;
|
|
50
|
+
return `${queryKey}${SUBSCRIPTION_KEY_SEPARATOR}${JSON.stringify(normalized)}`;
|
|
51
|
+
}
|
|
52
|
+
export function subscriptionKeyMatchesQueryKey(subscriptionKey, queryKey) {
|
|
53
|
+
return subscriptionKey === queryKey || subscriptionKey.startsWith(`${queryKey}${SUBSCRIPTION_KEY_SEPARATOR}`);
|
|
54
|
+
}
|
|
21
55
|
// ─── Public API (Convex-style) ──────────────────────────
|
|
22
56
|
export function query(key, handlerOrDef) {
|
|
23
57
|
let handler;
|
|
@@ -167,6 +201,28 @@ export function deregisterClient(ws) {
|
|
|
167
201
|
clients.delete(ws);
|
|
168
202
|
}
|
|
169
203
|
}
|
|
204
|
+
function sendInvalidateToLocalSubscribers(queryKeys) {
|
|
205
|
+
const targets = new Map();
|
|
206
|
+
for (const queryKey of queryKeys) {
|
|
207
|
+
for (const [subscriptionKey, clients] of subscribers) {
|
|
208
|
+
if (!subscriptionKeyMatchesQueryKey(subscriptionKey, queryKey))
|
|
209
|
+
continue;
|
|
210
|
+
for (const ws of clients) {
|
|
211
|
+
if (!targets.has(ws))
|
|
212
|
+
targets.set(ws, new Set());
|
|
213
|
+
targets.get(ws).add(subscriptionKey);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
for (const [ws, keys] of targets) {
|
|
218
|
+
try {
|
|
219
|
+
ws.send(JSON.stringify({ type: "invalidate", queries: [...keys] }));
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
deregisterClient(ws);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
170
226
|
/**
|
|
171
227
|
* mutation 실행 시점에 생성되는 RealtimeCtx.
|
|
172
228
|
* emit(): 데이터를 직접 push (초고빈도 mutation용).
|
|
@@ -221,6 +277,15 @@ export function buildRealtimeCtx(options) {
|
|
|
221
277
|
}, 50); // 50ms batch window
|
|
222
278
|
pendingEmits.set(queryKey, { data, timer });
|
|
223
279
|
},
|
|
280
|
+
invalidate(queryKey) {
|
|
281
|
+
_hasEmitted = true;
|
|
282
|
+
const queryKeys = Array.isArray(queryKey) ? [...new Set(queryKey)] : [queryKey];
|
|
283
|
+
if (options?.httpCallback) {
|
|
284
|
+
options.httpCallback({ type: "invalidate", queryKeys });
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
sendInvalidateToLocalSubscribers(queryKeys);
|
|
288
|
+
},
|
|
224
289
|
refresh(queryKey) {
|
|
225
290
|
_hasEmitted = true; // 경고 억제
|
|
226
291
|
if (!_pendingRefresh.includes(queryKey)) {
|
|
@@ -36,7 +36,7 @@ const RESERVED_TENANT_RUNTIME_ENV_KEYS = new Set([
|
|
|
36
36
|
"MIMALLOC_PURGE_DELAY",
|
|
37
37
|
"NODE_PATH",
|
|
38
38
|
]);
|
|
39
|
-
const RESERVED_TENANT_RUNTIME_ENV_PREFIXES = ["__GENCOW_", "GENCOW_DOCUMENT_", "GENCOW_WARM_"];
|
|
39
|
+
const RESERVED_TENANT_RUNTIME_ENV_PREFIXES = ["__GENCOW_", "GENCOW_DOCUMENT_", "GENCOW_TEMPLATE_", "GENCOW_WARM_"];
|
|
40
40
|
export function isReservedTenantRuntimeEnvKey(key) {
|
|
41
41
|
const normalized = key.trim();
|
|
42
42
|
return (RESERVED_TENANT_RUNTIME_ENV_KEYS.has(normalized) ||
|
package/dist/server.d.ts
CHANGED
|
@@ -6,5 +6,5 @@
|
|
|
6
6
|
* bundled into user functions which run in Firecracker.
|
|
7
7
|
*/
|
|
8
8
|
export { createStorage, storageRoutes } from "./storage.js";
|
|
9
|
-
export type { StorageImageTierConfig, StoredFile } from "./storage.js";
|
|
9
|
+
export type { StorageImageTierConfig, StorageImageTransformMetric, StorageMeteringOptions, StoredFile, } from "./storage.js";
|
|
10
10
|
export { createScheduler, getSchedulerInfo } from "./scheduler.js";
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface StorageImageTransformMetric {
|
|
2
|
+
transformCount: number;
|
|
3
|
+
sourceBytes: number;
|
|
4
|
+
outputBytes: number;
|
|
5
|
+
format: string;
|
|
6
|
+
autoWebp: boolean;
|
|
7
|
+
}
|
|
8
|
+
export interface StorageMeteringOptions {
|
|
9
|
+
onImageTransform?: (metric: StorageImageTransformMetric) => void | Promise<void>;
|
|
10
|
+
}
|
|
11
|
+
export declare function recordStorageImageTransform(options: StorageMeteringOptions | undefined, cachePath: string, metric: Omit<StorageImageTransformMetric, "transformCount" | "outputBytes">): Promise<{
|
|
12
|
+
size: number;
|
|
13
|
+
}>;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import * as fs from "fs/promises";
|
|
2
|
+
export async function recordStorageImageTransform(options, cachePath, metric) {
|
|
3
|
+
const stats = await fs.stat(cachePath);
|
|
4
|
+
if (!options?.onImageTransform)
|
|
5
|
+
return stats;
|
|
6
|
+
try {
|
|
7
|
+
await options.onImageTransform({
|
|
8
|
+
...metric,
|
|
9
|
+
transformCount: 1,
|
|
10
|
+
outputBytes: stats.size,
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
catch (error) {
|
|
14
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
15
|
+
console.warn(`[storage] image transform metering failed: ${msg.slice(0, 120)}`);
|
|
16
|
+
}
|
|
17
|
+
return stats;
|
|
18
|
+
}
|