@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,267 @@
|
|
|
1
|
+
import type { WSContext } from "hono/ws";
|
|
2
|
+
import type { GencowCtx, RealtimeCtx, RealtimeNotifyEvent } from "./context.js";
|
|
3
|
+
import { queryRegistry } from "./reactive-query.js";
|
|
4
|
+
import type { QueryDef } from "./reactive-query-types.js";
|
|
5
|
+
|
|
6
|
+
// ─── WS / subscription + subscription-key helpers (globalThis) ─
|
|
7
|
+
//
|
|
8
|
+
// globalThis 기반 — 서버 번들(인라인)과 node_modules/@gencow/core 양쪽에서
|
|
9
|
+
// 동일한 레지스트리 인스턴스를 공유. Dual-Module Registry 버그 방지.
|
|
10
|
+
// See: docs/analysis/analysis-dual-module-registry.md
|
|
11
|
+
|
|
12
|
+
declare global {
|
|
13
|
+
var __gencow_subscribers: Map<string, Set<WSContext>>;
|
|
14
|
+
var __gencow_connectedClients: Set<WSContext>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (!globalThis.__gencow_subscribers) globalThis.__gencow_subscribers = new Map();
|
|
18
|
+
if (!globalThis.__gencow_connectedClients) globalThis.__gencow_connectedClients = new Set();
|
|
19
|
+
|
|
20
|
+
export const subscribers = globalThis.__gencow_subscribers;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Every WebSocket client that ever establishes a connection is tracked here.
|
|
24
|
+
* This allows broadcasting invalidation events to clients that never subscribed
|
|
25
|
+
* to a specific query (e.g. the admin dashboard's raw WebSocket connection).
|
|
26
|
+
*/
|
|
27
|
+
export const connectedClients = globalThis.__gencow_connectedClients;
|
|
28
|
+
|
|
29
|
+
const SUBSCRIPTION_KEY_SEPARATOR = "::";
|
|
30
|
+
|
|
31
|
+
function normalizeForStableJson(value: unknown): unknown {
|
|
32
|
+
if (value === undefined) return undefined;
|
|
33
|
+
if (value === null) return null;
|
|
34
|
+
if (value instanceof Date) return value.toISOString();
|
|
35
|
+
if (Array.isArray(value)) return value.map((item) => normalizeForStableJson(item));
|
|
36
|
+
if (typeof value === "object") {
|
|
37
|
+
const source = value as Record<string, unknown>;
|
|
38
|
+
const sorted: Record<string, unknown> = {};
|
|
39
|
+
for (const key of Object.keys(source).sort()) {
|
|
40
|
+
const normalized = normalizeForStableJson(source[key]);
|
|
41
|
+
if (normalized !== undefined) sorted[key] = normalized;
|
|
42
|
+
}
|
|
43
|
+
return sorted;
|
|
44
|
+
}
|
|
45
|
+
return value;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function isEmptyPlainObject(value: unknown): boolean {
|
|
49
|
+
return !!value && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length === 0;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Stable subscription key for WebSocket / invalidate fanout (also used by crud for query keys). */
|
|
53
|
+
export function buildQuerySubscriptionKey(queryKey: string, args?: unknown): string {
|
|
54
|
+
const normalized = normalizeForStableJson(args);
|
|
55
|
+
if (normalized === undefined || isEmptyPlainObject(normalized)) return queryKey;
|
|
56
|
+
return `${queryKey}${SUBSCRIPTION_KEY_SEPARATOR}${JSON.stringify(normalized)}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function subscriptionKeyMatchesQueryKey(subscriptionKey: string, queryKey: string): boolean {
|
|
60
|
+
return subscriptionKey === queryKey || subscriptionKey.startsWith(`${queryKey}${SUBSCRIPTION_KEY_SEPARATOR}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function subscribe(queryKey: string, ws: WSContext) {
|
|
64
|
+
connectedClients.add(ws);
|
|
65
|
+
if (!subscribers.has(queryKey)) {
|
|
66
|
+
subscribers.set(queryKey, new Set());
|
|
67
|
+
}
|
|
68
|
+
subscribers.get(queryKey)!.add(ws);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function unsubscribe(ws: WSContext) {
|
|
72
|
+
connectedClients.delete(ws);
|
|
73
|
+
for (const clients of subscribers.values()) {
|
|
74
|
+
clients.delete(ws);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Register a raw WS connection without any query subscription (e.g. admin dashboard) */
|
|
79
|
+
export function registerClient(ws: WSContext) {
|
|
80
|
+
connectedClients.add(ws);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function deregisterClient(ws: WSContext) {
|
|
84
|
+
connectedClients.delete(ws);
|
|
85
|
+
for (const clients of subscribers.values()) {
|
|
86
|
+
clients.delete(ws);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function sendInvalidateToLocalSubscribers(queryKeys: string[]): void {
|
|
91
|
+
const targets = new Map<WSContext, Set<string>>();
|
|
92
|
+
for (const queryKey of queryKeys) {
|
|
93
|
+
for (const [subscriptionKey, clients] of subscribers) {
|
|
94
|
+
if (!subscriptionKeyMatchesQueryKey(subscriptionKey, queryKey)) continue;
|
|
95
|
+
for (const ws of clients) {
|
|
96
|
+
if (!targets.has(ws)) targets.set(ws, new Set());
|
|
97
|
+
targets.get(ws)!.add(subscriptionKey);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
for (const [ws, keys] of targets) {
|
|
103
|
+
try {
|
|
104
|
+
ws.send(JSON.stringify({ type: "invalidate", queries: [...keys] }));
|
|
105
|
+
} catch {
|
|
106
|
+
deregisterClient(ws);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* mutation 실행 시점에 생성되는 RealtimeCtx.
|
|
113
|
+
* emit(): 데이터를 직접 push (초고빈도 mutation용).
|
|
114
|
+
* refresh(): queryKey를 pending 큐에 추가, mutation 완료 후 서버가 query re-run하여 push.
|
|
115
|
+
*
|
|
116
|
+
* 💡 Batching: 같은 queryKey에 대한 emit이 50ms 내에 여러 번 호출되면
|
|
117
|
+
* 마지막 데이터만 push하여 불필요한 전송을 방지합니다.
|
|
118
|
+
*
|
|
119
|
+
* ⚠️ 매 mutation 호출마다 새로 생성해야 합니다 (debounce timer가 mutation scope에 격리).
|
|
120
|
+
*
|
|
121
|
+
* @param options.httpCallback BaaS 모드: Platform WS Gateway에 HTTP로 emit 전달.
|
|
122
|
+
* 설정되면 WS 직접 push 대신 이 콜백을 호출.
|
|
123
|
+
* 로컬 dev에서는 미설정 → 기존 WS 직접 push 유지.
|
|
124
|
+
* @param options.queryMap query 레지스트리 — refresh()에서 query handler를 찾아 re-run.
|
|
125
|
+
* @param options.buildCtxForRefresh refresh 시 query handler에 전달할 ctx 생성 함수.
|
|
126
|
+
*/
|
|
127
|
+
export function buildRealtimeCtx(options?: {
|
|
128
|
+
httpCallback?: (event: RealtimeNotifyEvent) => void;
|
|
129
|
+
queryMap?: Map<string, QueryDef<any, any>>;
|
|
130
|
+
buildCtxForRefresh?: () => GencowCtx;
|
|
131
|
+
}): RealtimeCtx & { _hasEmitted: boolean; _pendingRefresh: string[]; _flushRefresh: () => Promise<void> } {
|
|
132
|
+
const pendingEmits = new Map<string, { data: unknown; timer: ReturnType<typeof setTimeout> }>();
|
|
133
|
+
const _pendingRefresh: string[] = [];
|
|
134
|
+
let _hasEmitted = false;
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
emit(queryKey: string, data: unknown) {
|
|
138
|
+
_hasEmitted = true;
|
|
139
|
+
// 기존 pending timer가 있으면 취소 (debounce)
|
|
140
|
+
const existing = pendingEmits.get(queryKey);
|
|
141
|
+
if (existing) clearTimeout(existing.timer);
|
|
142
|
+
|
|
143
|
+
const timer = setTimeout(() => {
|
|
144
|
+
pendingEmits.delete(queryKey);
|
|
145
|
+
|
|
146
|
+
// BaaS 모드: Platform WS Gateway에 HTTP callback
|
|
147
|
+
if (options?.httpCallback) {
|
|
148
|
+
options.httpCallback({ type: "emit", queryKey, data });
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// 로컬 dev: WS 직접 push (기존 동작)
|
|
153
|
+
const clients = subscribers.get(queryKey);
|
|
154
|
+
if (!clients || clients.size === 0) return;
|
|
155
|
+
|
|
156
|
+
const message = JSON.stringify({
|
|
157
|
+
type: "query:updated",
|
|
158
|
+
query: queryKey,
|
|
159
|
+
data,
|
|
160
|
+
});
|
|
161
|
+
for (const ws of clients) {
|
|
162
|
+
try {
|
|
163
|
+
ws.send(message);
|
|
164
|
+
} catch {
|
|
165
|
+
clients.delete(ws);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}, 50); // 50ms batch window
|
|
169
|
+
|
|
170
|
+
pendingEmits.set(queryKey, { data, timer });
|
|
171
|
+
},
|
|
172
|
+
|
|
173
|
+
invalidate(queryKey: string | string[]) {
|
|
174
|
+
_hasEmitted = true;
|
|
175
|
+
const queryKeys = Array.isArray(queryKey) ? [...new Set(queryKey)] : [queryKey];
|
|
176
|
+
if (options?.httpCallback) {
|
|
177
|
+
options.httpCallback({ type: "invalidate", queryKeys });
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
sendInvalidateToLocalSubscribers(queryKeys);
|
|
181
|
+
},
|
|
182
|
+
|
|
183
|
+
refresh(queryKey: string) {
|
|
184
|
+
_hasEmitted = true; // 경고 억제
|
|
185
|
+
if (!_pendingRefresh.includes(queryKey)) {
|
|
186
|
+
_pendingRefresh.push(queryKey);
|
|
187
|
+
}
|
|
188
|
+
},
|
|
189
|
+
|
|
190
|
+
get _hasEmitted() {
|
|
191
|
+
return _hasEmitted;
|
|
192
|
+
},
|
|
193
|
+
get _pendingRefresh() {
|
|
194
|
+
return [..._pendingRefresh];
|
|
195
|
+
},
|
|
196
|
+
|
|
197
|
+
async _flushRefresh() {
|
|
198
|
+
if (_pendingRefresh.length === 0) return;
|
|
199
|
+
|
|
200
|
+
// queryMap이 없으면 refresh 동작 불가 (로그 경고)
|
|
201
|
+
const qMap = options?.queryMap ?? queryRegistry;
|
|
202
|
+
|
|
203
|
+
for (const key of _pendingRefresh) {
|
|
204
|
+
const queryDef = qMap.get(key);
|
|
205
|
+
if (!queryDef) {
|
|
206
|
+
console.warn(`[gencow] refresh("${key}"): query not found in registry. Skipping.`);
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
try {
|
|
210
|
+
// refresh용 ctx 생성 (mutation ctx와 동일한 DB/auth 스코프)
|
|
211
|
+
if (!options?.buildCtxForRefresh) {
|
|
212
|
+
console.warn(
|
|
213
|
+
`[gencow] ⚠️ refresh("${key}"): buildCtxForRefresh not provided. ` +
|
|
214
|
+
`Query handler will receive an empty ctx — ctx.db will be undefined. ` +
|
|
215
|
+
`This is a framework configuration error. ` +
|
|
216
|
+
`💡 Ensure buildRealtimeCtx() receives a buildCtxForRefresh callback.`,
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
const refreshCtx = options?.buildCtxForRefresh?.() ?? ({} as GencowCtx);
|
|
220
|
+
const result = await queryDef.handler(refreshCtx, {});
|
|
221
|
+
|
|
222
|
+
// emit과 동일한 경로로 push
|
|
223
|
+
if (options?.httpCallback) {
|
|
224
|
+
options.httpCallback({ type: "emit", queryKey: key, data: result });
|
|
225
|
+
} else {
|
|
226
|
+
const clients = subscribers.get(key);
|
|
227
|
+
if (clients && clients.size > 0) {
|
|
228
|
+
const message = JSON.stringify({
|
|
229
|
+
type: "query:updated",
|
|
230
|
+
query: key,
|
|
231
|
+
data: result,
|
|
232
|
+
});
|
|
233
|
+
for (const ws of clients) {
|
|
234
|
+
try {
|
|
235
|
+
ws.send(message);
|
|
236
|
+
} catch {
|
|
237
|
+
clients.delete(ws);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
} catch (e) {
|
|
243
|
+
console.warn(`[gencow] refresh("${key}") failed:`, e instanceof Error ? e.message : e);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
_pendingRefresh.length = 0;
|
|
247
|
+
},
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export function handleWsMessage(ws: WSContext, raw: string | ArrayBuffer) {
|
|
252
|
+
try {
|
|
253
|
+
const msg = typeof raw === "string" ? JSON.parse(raw) : JSON.parse(raw.toString());
|
|
254
|
+
|
|
255
|
+
if (msg.type === "subscribe" && msg.query) {
|
|
256
|
+
subscribe(msg.query, ws);
|
|
257
|
+
ws.send(JSON.stringify({ type: "subscribed", query: msg.query }));
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (msg.type === "unsubscribe" && msg.query) {
|
|
261
|
+
const clients = subscribers.get(msg.query);
|
|
262
|
+
if (clients) clients.delete(ws);
|
|
263
|
+
}
|
|
264
|
+
} catch {
|
|
265
|
+
// ignore malformed messages
|
|
266
|
+
}
|
|
267
|
+
}
|
|
@@ -37,7 +37,7 @@ const RESERVED_TENANT_RUNTIME_ENV_KEYS = new Set([
|
|
|
37
37
|
"NODE_PATH",
|
|
38
38
|
]);
|
|
39
39
|
|
|
40
|
-
const RESERVED_TENANT_RUNTIME_ENV_PREFIXES = ["__GENCOW_", "GENCOW_DOCUMENT_", "GENCOW_WARM_"];
|
|
40
|
+
const RESERVED_TENANT_RUNTIME_ENV_PREFIXES = ["__GENCOW_", "GENCOW_DOCUMENT_", "GENCOW_TEMPLATE_", "GENCOW_WARM_"];
|
|
41
41
|
|
|
42
42
|
export function isReservedTenantRuntimeEnvKey(key: string): boolean {
|
|
43
43
|
const normalized = key.trim();
|
package/src/server.ts
CHANGED
|
@@ -6,5 +6,10 @@
|
|
|
6
6
|
* bundled into user functions which run in Firecracker.
|
|
7
7
|
*/
|
|
8
8
|
export { createStorage, storageRoutes } from "./storage.js";
|
|
9
|
-
export type {
|
|
9
|
+
export type {
|
|
10
|
+
StorageImageTierConfig,
|
|
11
|
+
StorageImageTransformMetric,
|
|
12
|
+
StorageMeteringOptions,
|
|
13
|
+
StoredFile,
|
|
14
|
+
} from "./storage.js";
|
|
10
15
|
export { createScheduler, getSchedulerInfo } from "./scheduler.js";
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import * as fs from "fs/promises";
|
|
2
|
+
|
|
3
|
+
export interface StorageImageTransformMetric {
|
|
4
|
+
transformCount: number;
|
|
5
|
+
sourceBytes: number;
|
|
6
|
+
outputBytes: number;
|
|
7
|
+
format: string;
|
|
8
|
+
autoWebp: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface StorageMeteringOptions {
|
|
12
|
+
onImageTransform?: (metric: StorageImageTransformMetric) => void | Promise<void>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function recordStorageImageTransform(
|
|
16
|
+
options: StorageMeteringOptions | undefined,
|
|
17
|
+
cachePath: string,
|
|
18
|
+
metric: Omit<StorageImageTransformMetric, "transformCount" | "outputBytes">,
|
|
19
|
+
): Promise<{ size: number }> {
|
|
20
|
+
const stats = await fs.stat(cachePath);
|
|
21
|
+
if (!options?.onImageTransform) return stats;
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
await options.onImageTransform({
|
|
25
|
+
...metric,
|
|
26
|
+
transformCount: 1,
|
|
27
|
+
outputBytes: stats.size,
|
|
28
|
+
});
|
|
29
|
+
} catch (error) {
|
|
30
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
31
|
+
console.warn(`[storage] image transform metering failed: ${msg.slice(0, 120)}`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return stats;
|
|
35
|
+
}
|
package/src/storage.ts
CHANGED
|
@@ -9,8 +9,11 @@ import {
|
|
|
9
9
|
loadStorageMeta,
|
|
10
10
|
} from "./storage-shared.js";
|
|
11
11
|
import type { Storage, StorageFile, StorageOptions, StoredFile } from "./storage-shared.js";
|
|
12
|
+
import { recordStorageImageTransform } from "./storage-metering.js";
|
|
13
|
+
import type { StorageMeteringOptions } from "./storage-metering.js";
|
|
12
14
|
|
|
13
15
|
export type { Storage, StorageFile, StorageOptions, StoredFile } from "./storage-shared.js";
|
|
16
|
+
export type { StorageImageTransformMetric, StorageMeteringOptions } from "./storage-metering.js";
|
|
14
17
|
|
|
15
18
|
// ─── Implementation ─────────────────────────────────────
|
|
16
19
|
|
|
@@ -60,6 +63,8 @@ async function checkStorageQuota(
|
|
|
60
63
|
): Promise<void> {
|
|
61
64
|
if (quota <= 0) return; // 무제한
|
|
62
65
|
|
|
66
|
+
await ensureFilesTable(rawSql);
|
|
67
|
+
|
|
63
68
|
const rows = await rawSql(`SELECT COALESCE(SUM(CAST(size AS BIGINT)), 0) AS total FROM _system_files`);
|
|
64
69
|
const currentUsage = Number((rows[0] as Record<string, string>)?.total || "0");
|
|
65
70
|
const projectedUsage = currentUsage + newFileSize;
|
|
@@ -465,6 +470,7 @@ export function storageRoutes(
|
|
|
465
470
|
rawSql?: (sql: string, params?: unknown[]) => Promise<unknown[]>,
|
|
466
471
|
storageDir?: string,
|
|
467
472
|
tierConfig?: StorageImageTierConfig,
|
|
473
|
+
meteringOptions?: StorageMeteringOptions,
|
|
468
474
|
) {
|
|
469
475
|
const baseTierConfig: Required<StorageImageTierConfig> = {
|
|
470
476
|
autoWebp: tierConfig?.autoWebp ?? true,
|
|
@@ -736,15 +742,17 @@ export function storageRoutes(
|
|
|
736
742
|
|
|
737
743
|
// 변환 실행 → 캐시에 저장
|
|
738
744
|
await pipeline.toFile(cachePath);
|
|
745
|
+
const cacheStats = await recordStorageImageTransform(meteringOptions, cachePath, {
|
|
746
|
+
sourceBytes: meta.size,
|
|
747
|
+
format: outputFormat || meta.type.replace(/^image\//, ""),
|
|
748
|
+
autoWebp: isAutoWebp,
|
|
749
|
+
});
|
|
739
750
|
|
|
740
751
|
// WebP/AVIF가 원본보다 큰 경우 → 캐시 삭제 + 원본 서빙
|
|
741
752
|
// (Static Deploy와 동일 전략 — apps.ts L840-847)
|
|
742
|
-
if (isAutoWebp) {
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
await fs.unlink(cachePath).catch(() => {});
|
|
746
|
-
return serveOriginal(c, meta);
|
|
747
|
-
}
|
|
753
|
+
if (isAutoWebp && cacheStats.size >= meta.size) {
|
|
754
|
+
await fs.unlink(cachePath).catch(() => {});
|
|
755
|
+
return serveOriginal(c, meta);
|
|
748
756
|
}
|
|
749
757
|
|
|
750
758
|
return serveCachedFile(c, cachePath, transformParams, isAutoWebp, meta);
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export type WakeAppSuccessStatus = "already_running" | "woke";
|
|
2
|
+
export type WakeAppDeferredStatus = "capacity_rejected" | "queue_timeout";
|
|
3
|
+
|
|
4
|
+
export type WakeAppSuccessResult = {
|
|
5
|
+
ok: true;
|
|
6
|
+
status: WakeAppSuccessStatus;
|
|
7
|
+
port: number;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type WakeAppDeferredResult = {
|
|
11
|
+
ok: false;
|
|
12
|
+
status: WakeAppDeferredStatus;
|
|
13
|
+
retryAfterSec: number;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type WakeAppBootFailedResult = {
|
|
17
|
+
ok: false;
|
|
18
|
+
status: "boot_failed";
|
|
19
|
+
error: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type WakeAppResult = WakeAppSuccessResult | WakeAppDeferredResult | WakeAppBootFailedResult;
|
|
23
|
+
|
|
24
|
+
export const DEFAULT_WAKE_RETRY_AFTER_SEC = 30;
|
|
25
|
+
|
|
26
|
+
export function buildWakeAppSuccessResult(status: WakeAppSuccessStatus, port: number): WakeAppSuccessResult {
|
|
27
|
+
return { ok: true, status, port };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function buildWakeAppBootFailedResult(error: unknown): WakeAppBootFailedResult {
|
|
31
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
32
|
+
return { ok: false, status: "boot_failed", error: message };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function isWakeAppDeferredResult(result: WakeAppResult): result is WakeAppDeferredResult {
|
|
36
|
+
return !result.ok && (result.status === "capacity_rejected" || result.status === "queue_timeout");
|
|
37
|
+
}
|
package/src/workflow-types.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { GencowCtx } from "./
|
|
1
|
+
import type { GencowCtx } from "./context.js";
|
|
2
2
|
import type { WorkflowDocumentServicesCtx } from "./document-types.js";
|
|
3
3
|
import type { InferArgs } from "./v.js";
|
|
4
4
|
|
|
@@ -29,6 +29,7 @@ export interface WorkflowSummary {
|
|
|
29
29
|
derivedStatus: WorkflowDerivedStatus;
|
|
30
30
|
currentStep: string | null;
|
|
31
31
|
error: string | null;
|
|
32
|
+
errorCode: string | null;
|
|
32
33
|
retryCount: number;
|
|
33
34
|
maxRetries: number;
|
|
34
35
|
maxDurationMs: number;
|
|
@@ -81,6 +82,9 @@ export interface WorkflowResumePayload {
|
|
|
81
82
|
export interface WorkflowCtx extends Omit<GencowCtx, "services"> {
|
|
82
83
|
workflowId: string;
|
|
83
84
|
workflowName: string;
|
|
85
|
+
runId?: string;
|
|
86
|
+
attempt?: number;
|
|
87
|
+
stepAttempt?: number;
|
|
84
88
|
services: GencowCtx["services"] & WorkflowDocumentServicesCtx;
|
|
85
89
|
step<TResult>(name: string, run: () => Promise<TResult>): Promise<TResult>;
|
|
86
90
|
sleep(duration: WorkflowDuration): Promise<void>;
|
|
@@ -95,7 +99,11 @@ export type WorkflowHandler<TArgs, TReturn> = (wf: WorkflowCtx, args: TArgs) =>
|
|
|
95
99
|
export interface WorkflowOptions<TSchema = any, TReturn = any> {
|
|
96
100
|
args?: TSchema;
|
|
97
101
|
public?: boolean;
|
|
102
|
+
version?: string;
|
|
98
103
|
maxDuration?: WorkflowDuration;
|
|
104
|
+
maxActiveDuration?: WorkflowDuration;
|
|
105
|
+
lifecycleTimeout?: WorkflowDuration;
|
|
106
|
+
concurrency?: number;
|
|
99
107
|
retries?: number;
|
|
100
108
|
handler: WorkflowHandler<InferArgs<TSchema>, TReturn>;
|
|
101
109
|
}
|
|
@@ -104,7 +112,11 @@ export interface WorkflowDef<TSchema = any, TReturn = any> {
|
|
|
104
112
|
name: string;
|
|
105
113
|
argsSchema?: TSchema;
|
|
106
114
|
isPublic: boolean;
|
|
115
|
+
version?: string;
|
|
107
116
|
maxDurationMs: number;
|
|
117
|
+
maxActiveDurationMs: number;
|
|
118
|
+
lifecycleTimeoutMs: number | null;
|
|
119
|
+
concurrency: number | null;
|
|
108
120
|
maxRetries: number;
|
|
109
121
|
handler: WorkflowHandler<InferArgs<TSchema>, TReturn>;
|
|
110
122
|
}
|
package/src/workflow.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { sql } from "drizzle-orm";
|
|
2
|
-
import { mutation } from "./reactive.js";
|
|
3
|
-
import type { MutationDef } from "./reactive.js";
|
|
2
|
+
import { mutation } from "./reactive-mutation.js";
|
|
3
|
+
import type { MutationDef } from "./reactive-mutation-types.js";
|
|
4
4
|
import type {
|
|
5
5
|
WorkflowDef,
|
|
6
6
|
WorkflowDuration,
|
|
@@ -82,9 +82,137 @@ export function parseWorkflowDurationMs(raw: WorkflowDuration, label = "workflow
|
|
|
82
82
|
return parseDurationString(raw, label);
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
-
function normalizeMaxDurationMs(maxDuration: WorkflowDuration | undefined): number {
|
|
85
|
+
function normalizeMaxDurationMs(maxDuration: WorkflowDuration | undefined, label = "workflow() maxDuration"): number {
|
|
86
86
|
if (maxDuration == null) return DEFAULT_WORKFLOW_MAX_DURATION_MS;
|
|
87
|
-
return parseWorkflowDurationMs(maxDuration,
|
|
87
|
+
return parseWorkflowDurationMs(maxDuration, label);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function normalizeOptionalDurationMs(
|
|
91
|
+
duration: WorkflowDuration | undefined,
|
|
92
|
+
label: string,
|
|
93
|
+
): number | null {
|
|
94
|
+
return duration == null ? null : parseWorkflowDurationMs(duration, label);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function normalizeConcurrency(concurrency: number | undefined): number | null {
|
|
98
|
+
if (concurrency == null) return null;
|
|
99
|
+
if (!Number.isFinite(concurrency) || concurrency <= 0) {
|
|
100
|
+
throw new Error(`workflow() concurrency must be a positive finite number, got "${concurrency}"`);
|
|
101
|
+
}
|
|
102
|
+
return Math.floor(concurrency);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function isMissingWorkflowV2SchemaError(error: unknown): boolean {
|
|
106
|
+
const code =
|
|
107
|
+
error && typeof error === "object" && "code" in error ? String((error as { code?: unknown }).code) : "";
|
|
108
|
+
const cause =
|
|
109
|
+
error && typeof error === "object" && "cause" in error ? (error as { cause?: unknown }).cause : null;
|
|
110
|
+
const causeCode =
|
|
111
|
+
cause && typeof cause === "object" && "code" in cause ? String((cause as { code?: unknown }).code) : "";
|
|
112
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
113
|
+
return (
|
|
114
|
+
code === "42P01" ||
|
|
115
|
+
code === "42703" ||
|
|
116
|
+
causeCode === "42P01" ||
|
|
117
|
+
causeCode === "42703" ||
|
|
118
|
+
message.includes('relation "_gencow_workflow_runs_v2" does not exist') ||
|
|
119
|
+
message.includes("relation _gencow_workflow_runs_v2 does not exist") ||
|
|
120
|
+
message.includes('relation "_gencow_workflow_outbox_v2" does not exist') ||
|
|
121
|
+
message.includes("relation _gencow_workflow_outbox_v2 does not exist") ||
|
|
122
|
+
message.includes('column "max_active_duration_ms"') ||
|
|
123
|
+
message.includes('column "retry_count"') ||
|
|
124
|
+
message.includes('column "user_id"')
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function tryInsertWorkflowV2WakeOutbox(
|
|
129
|
+
db: { execute: (query: ReturnType<typeof sql>) => Promise<unknown> },
|
|
130
|
+
workflowId: string,
|
|
131
|
+
): Promise<void> {
|
|
132
|
+
try {
|
|
133
|
+
await db.execute(sql`
|
|
134
|
+
INSERT INTO _gencow_workflow_outbox_v2 (
|
|
135
|
+
id,
|
|
136
|
+
run_id,
|
|
137
|
+
kind,
|
|
138
|
+
available_at,
|
|
139
|
+
status
|
|
140
|
+
)
|
|
141
|
+
VALUES (
|
|
142
|
+
${`start:${workflowId}`},
|
|
143
|
+
${workflowId},
|
|
144
|
+
'wake_run',
|
|
145
|
+
NOW(),
|
|
146
|
+
'pending'
|
|
147
|
+
)
|
|
148
|
+
ON CONFLICT (id) DO NOTHING
|
|
149
|
+
`);
|
|
150
|
+
} catch (error) {
|
|
151
|
+
if (!isMissingWorkflowV2SchemaError(error)) throw error;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function tryInsertWorkflowV2Run(options: {
|
|
156
|
+
db: { execute: (query: ReturnType<typeof sql>) => Promise<unknown> };
|
|
157
|
+
workflowId: string;
|
|
158
|
+
workflowName: string;
|
|
159
|
+
workflowVersion?: string | null;
|
|
160
|
+
args: unknown;
|
|
161
|
+
userId: string | null;
|
|
162
|
+
maxActiveDurationMs: number;
|
|
163
|
+
lifecycleTimeoutMs: number | null;
|
|
164
|
+
maxRetries: number;
|
|
165
|
+
}): Promise<boolean> {
|
|
166
|
+
try {
|
|
167
|
+
await options.db.execute(sql`
|
|
168
|
+
INSERT INTO _gencow_workflow_runs_v2 (
|
|
169
|
+
id,
|
|
170
|
+
workflow_name,
|
|
171
|
+
workflow_version,
|
|
172
|
+
args_json,
|
|
173
|
+
user_id,
|
|
174
|
+
max_active_duration_ms,
|
|
175
|
+
lifecycle_deadline_at,
|
|
176
|
+
retry_count,
|
|
177
|
+
max_retries,
|
|
178
|
+
max_attempts
|
|
179
|
+
)
|
|
180
|
+
VALUES (
|
|
181
|
+
${options.workflowId},
|
|
182
|
+
${options.workflowName},
|
|
183
|
+
${options.workflowVersion ?? null},
|
|
184
|
+
${JSON.stringify(options.args)}::jsonb,
|
|
185
|
+
${options.userId},
|
|
186
|
+
${options.maxActiveDurationMs},
|
|
187
|
+
CASE
|
|
188
|
+
WHEN ${options.lifecycleTimeoutMs}::bigint IS NULL THEN NULL
|
|
189
|
+
ELSE NOW() + (${options.lifecycleTimeoutMs}::bigint * INTERVAL '1 millisecond')
|
|
190
|
+
END,
|
|
191
|
+
0,
|
|
192
|
+
${options.maxRetries},
|
|
193
|
+
${options.maxRetries + 1}
|
|
194
|
+
)
|
|
195
|
+
`);
|
|
196
|
+
await tryInsertWorkflowV2WakeOutbox(options.db, options.workflowId);
|
|
197
|
+
return true;
|
|
198
|
+
} catch (error) {
|
|
199
|
+
if (isMissingWorkflowV2SchemaError(error)) return false;
|
|
200
|
+
throw error;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function tryDeleteWorkflowV2Run(
|
|
205
|
+
db: { execute: (query: ReturnType<typeof sql>) => Promise<unknown> },
|
|
206
|
+
workflowId: string,
|
|
207
|
+
): Promise<void> {
|
|
208
|
+
try {
|
|
209
|
+
await db.execute(sql`
|
|
210
|
+
DELETE FROM _gencow_workflow_runs_v2
|
|
211
|
+
WHERE id = ${workflowId}
|
|
212
|
+
`);
|
|
213
|
+
} catch (error) {
|
|
214
|
+
if (!isMissingWorkflowV2SchemaError(error)) throw error;
|
|
215
|
+
}
|
|
88
216
|
}
|
|
89
217
|
|
|
90
218
|
export function getWorkflowResumeActionName(name: string): string {
|
|
@@ -119,14 +247,25 @@ export function workflow<TSchema = any, TReturn = any>(
|
|
|
119
247
|
): MutationDef<TSchema, WorkflowStartResult> {
|
|
120
248
|
registerWorkflowsApi();
|
|
121
249
|
|
|
122
|
-
const
|
|
250
|
+
const maxActiveDurationMs = normalizeMaxDurationMs(
|
|
251
|
+
options.maxActiveDuration ?? options.maxDuration,
|
|
252
|
+
options.maxActiveDuration == null ? "workflow() maxDuration" : "workflow() maxActiveDuration",
|
|
253
|
+
);
|
|
254
|
+
const lifecycleTimeoutMs = normalizeOptionalDurationMs(
|
|
255
|
+
options.lifecycleTimeout,
|
|
256
|
+
"workflow() lifecycleTimeout",
|
|
257
|
+
);
|
|
123
258
|
const maxRetries = clampRetries(options.retries);
|
|
124
259
|
|
|
125
260
|
const def: WorkflowDef<TSchema, TReturn> = {
|
|
126
261
|
name,
|
|
127
262
|
argsSchema: options.args,
|
|
128
263
|
isPublic: options.public === true,
|
|
129
|
-
|
|
264
|
+
version: options.version,
|
|
265
|
+
maxDurationMs: maxActiveDurationMs,
|
|
266
|
+
maxActiveDurationMs,
|
|
267
|
+
lifecycleTimeoutMs,
|
|
268
|
+
concurrency: normalizeConcurrency(options.concurrency),
|
|
130
269
|
maxRetries,
|
|
131
270
|
handler: options.handler,
|
|
132
271
|
};
|
|
@@ -159,16 +298,28 @@ export function workflow<TSchema = any, TReturn = any>(
|
|
|
159
298
|
${workflowId},
|
|
160
299
|
${name},
|
|
161
300
|
${JSON.stringify(persistedArgs)}::jsonb,
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
301
|
+
${realtimeToken},
|
|
302
|
+
'pending',
|
|
303
|
+
0,
|
|
304
|
+
${maxRetries},
|
|
305
|
+
${maxActiveDurationMs},
|
|
306
|
+
${ownerId}
|
|
168
307
|
)
|
|
169
308
|
`);
|
|
309
|
+
let insertedWorkflowV2 = false;
|
|
170
310
|
|
|
171
311
|
try {
|
|
312
|
+
insertedWorkflowV2 = await tryInsertWorkflowV2Run({
|
|
313
|
+
db: ctx.unsafeDb,
|
|
314
|
+
workflowId,
|
|
315
|
+
workflowName: name,
|
|
316
|
+
workflowVersion: options.version ?? null,
|
|
317
|
+
args: persistedArgs,
|
|
318
|
+
userId: ownerId,
|
|
319
|
+
maxActiveDurationMs,
|
|
320
|
+
lifecycleTimeoutMs,
|
|
321
|
+
maxRetries,
|
|
322
|
+
});
|
|
172
323
|
const scheduledJobId = ctx.scheduler.runAfter(0, resumeAction, { workflowId });
|
|
173
324
|
return {
|
|
174
325
|
id: workflowId,
|
|
@@ -181,6 +332,9 @@ export function workflow<TSchema = any, TReturn = any>(
|
|
|
181
332
|
DELETE FROM _gencow_workflows
|
|
182
333
|
WHERE id = ${workflowId}
|
|
183
334
|
`);
|
|
335
|
+
if (insertedWorkflowV2) {
|
|
336
|
+
await tryDeleteWorkflowV2Run(ctx.unsafeDb, workflowId);
|
|
337
|
+
}
|
|
184
338
|
throw error;
|
|
185
339
|
}
|
|
186
340
|
},
|