@gencow/core 0.1.28 → 0.1.30
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-json.d.ts +2 -0
- package/dist/workflow-json.js +5 -0
- package/dist/workflow-types.d.ts +13 -1
- package/dist/workflow.d.ts +1 -1
- package/dist/workflow.js +135 -12
- package/dist/workflows-api.js +72 -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-json.ts +6 -0
- package/src/workflow-types.ts +13 -1
- package/src/workflow.ts +163 -13
- package/src/workflows-api.ts +83 -3
- package/src/reactive.ts +0 -593
package/dist/storage.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { Storage, StorageOptions } from "./storage-shared.js";
|
|
2
|
+
import type { StorageMeteringOptions } from "./storage-metering.js";
|
|
2
3
|
export type { Storage, StorageFile, StorageOptions, StoredFile } from "./storage-shared.js";
|
|
4
|
+
export type { StorageImageTransformMetric, StorageMeteringOptions } from "./storage-metering.js";
|
|
3
5
|
/**
|
|
4
6
|
* Create a storage instance — Convex storage 패턴 재현
|
|
5
7
|
*
|
|
@@ -43,7 +45,7 @@ export interface StorageImageTierConfig {
|
|
|
43
45
|
* - 디스크 캐시: uploads/.cache/{uuid}_{params}.{ext}
|
|
44
46
|
* - 원본 보존: 원본 파일은 절대 수정하지 않음
|
|
45
47
|
*/
|
|
46
|
-
export declare function storageRoutes(storage: ReturnType<typeof createStorage>, rawSql?: (sql: string, params?: unknown[]) => Promise<unknown[]>, storageDir?: string, tierConfig?: StorageImageTierConfig): (c: {
|
|
48
|
+
export declare function storageRoutes(storage: ReturnType<typeof createStorage>, rawSql?: (sql: string, params?: unknown[]) => Promise<unknown[]>, storageDir?: string, tierConfig?: StorageImageTierConfig, meteringOptions?: StorageMeteringOptions): (c: {
|
|
47
49
|
req: {
|
|
48
50
|
param: (key: string) => string;
|
|
49
51
|
query: (key: string) => string | undefined;
|
package/dist/storage.js
CHANGED
|
@@ -3,6 +3,7 @@ import * as fsSync from "fs";
|
|
|
3
3
|
import * as path from "path";
|
|
4
4
|
import * as crypto from "crypto";
|
|
5
5
|
import { DEFAULT_STORAGE_QUOTA, MAX_FILE_SIZE, formatBytes, loadStorageMeta, } from "./storage-shared.js";
|
|
6
|
+
import { recordStorageImageTransform } from "./storage-metering.js";
|
|
6
7
|
// ─── Implementation ─────────────────────────────────────
|
|
7
8
|
const metaStore = new Map();
|
|
8
9
|
/**
|
|
@@ -42,6 +43,7 @@ async function ensureFilesTable(rawSql) {
|
|
|
42
43
|
async function checkStorageQuota(rawSql, newFileSize, quota) {
|
|
43
44
|
if (quota <= 0)
|
|
44
45
|
return; // 무제한
|
|
46
|
+
await ensureFilesTable(rawSql);
|
|
45
47
|
const rows = await rawSql(`SELECT COALESCE(SUM(CAST(size AS BIGINT)), 0) AS total FROM _system_files`);
|
|
46
48
|
const currentUsage = Number(rows[0]?.total || "0");
|
|
47
49
|
const projectedUsage = currentUsage + newFileSize;
|
|
@@ -343,7 +345,7 @@ async function getCacheEntryCount(cacheDir) {
|
|
|
343
345
|
* - 디스크 캐시: uploads/.cache/{uuid}_{params}.{ext}
|
|
344
346
|
* - 원본 보존: 원본 파일은 절대 수정하지 않음
|
|
345
347
|
*/
|
|
346
|
-
export function storageRoutes(storage, rawSql, storageDir, tierConfig) {
|
|
348
|
+
export function storageRoutes(storage, rawSql, storageDir, tierConfig, meteringOptions) {
|
|
347
349
|
const baseTierConfig = {
|
|
348
350
|
autoWebp: tierConfig?.autoWebp ?? true,
|
|
349
351
|
autoMaxWidth: tierConfig?.autoMaxWidth ?? 0, // 0 = 제한 없음
|
|
@@ -561,14 +563,16 @@ export function storageRoutes(storage, rawSql, storageDir, tierConfig) {
|
|
|
561
563
|
}
|
|
562
564
|
// 변환 실행 → 캐시에 저장
|
|
563
565
|
await pipeline.toFile(cachePath);
|
|
566
|
+
const cacheStats = await recordStorageImageTransform(meteringOptions, cachePath, {
|
|
567
|
+
sourceBytes: meta.size,
|
|
568
|
+
format: outputFormat || meta.type.replace(/^image\//, ""),
|
|
569
|
+
autoWebp: isAutoWebp,
|
|
570
|
+
});
|
|
564
571
|
// WebP/AVIF가 원본보다 큰 경우 → 캐시 삭제 + 원본 서빙
|
|
565
572
|
// (Static Deploy와 동일 전략 — apps.ts L840-847)
|
|
566
|
-
if (isAutoWebp) {
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
await fs.unlink(cachePath).catch(() => { });
|
|
570
|
-
return serveOriginal(c, meta);
|
|
571
|
-
}
|
|
573
|
+
if (isAutoWebp && cacheStats.size >= meta.size) {
|
|
574
|
+
await fs.unlink(cachePath).catch(() => { });
|
|
575
|
+
return serveOriginal(c, meta);
|
|
572
576
|
}
|
|
573
577
|
return serveCachedFile(c, cachePath, transformParams, isAutoWebp, meta);
|
|
574
578
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export type WakeAppSuccessStatus = "already_running" | "woke";
|
|
2
|
+
export type WakeAppDeferredStatus = "capacity_rejected" | "queue_timeout";
|
|
3
|
+
export type WakeAppSuccessResult = {
|
|
4
|
+
ok: true;
|
|
5
|
+
status: WakeAppSuccessStatus;
|
|
6
|
+
port: number;
|
|
7
|
+
};
|
|
8
|
+
export type WakeAppDeferredResult = {
|
|
9
|
+
ok: false;
|
|
10
|
+
status: WakeAppDeferredStatus;
|
|
11
|
+
retryAfterSec: number;
|
|
12
|
+
};
|
|
13
|
+
export type WakeAppBootFailedResult = {
|
|
14
|
+
ok: false;
|
|
15
|
+
status: "boot_failed";
|
|
16
|
+
error: string;
|
|
17
|
+
};
|
|
18
|
+
export type WakeAppResult = WakeAppSuccessResult | WakeAppDeferredResult | WakeAppBootFailedResult;
|
|
19
|
+
export declare const DEFAULT_WAKE_RETRY_AFTER_SEC = 30;
|
|
20
|
+
export declare function buildWakeAppSuccessResult(status: WakeAppSuccessStatus, port: number): WakeAppSuccessResult;
|
|
21
|
+
export declare function buildWakeAppBootFailedResult(error: unknown): WakeAppBootFailedResult;
|
|
22
|
+
export declare function isWakeAppDeferredResult(result: WakeAppResult): result is WakeAppDeferredResult;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export const DEFAULT_WAKE_RETRY_AFTER_SEC = 30;
|
|
2
|
+
export function buildWakeAppSuccessResult(status, port) {
|
|
3
|
+
return { ok: true, status, port };
|
|
4
|
+
}
|
|
5
|
+
export function buildWakeAppBootFailedResult(error) {
|
|
6
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
7
|
+
return { ok: false, status: "boot_failed", error: message };
|
|
8
|
+
}
|
|
9
|
+
export function isWakeAppDeferredResult(result) {
|
|
10
|
+
return !result.ok && (result.status === "capacity_rejected" || result.status === "queue_timeout");
|
|
11
|
+
}
|
package/dist/workflow-types.d.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
|
export type WorkflowStatus = "pending" | "running" | "completed" | "failed";
|
|
@@ -12,6 +12,7 @@ export interface WorkflowSummary {
|
|
|
12
12
|
derivedStatus: WorkflowDerivedStatus;
|
|
13
13
|
currentStep: string | null;
|
|
14
14
|
error: string | null;
|
|
15
|
+
errorCode: string | null;
|
|
15
16
|
retryCount: number;
|
|
16
17
|
maxRetries: number;
|
|
17
18
|
maxDurationMs: number;
|
|
@@ -57,6 +58,9 @@ export interface WorkflowResumePayload {
|
|
|
57
58
|
export interface WorkflowCtx extends Omit<GencowCtx, "services"> {
|
|
58
59
|
workflowId: string;
|
|
59
60
|
workflowName: string;
|
|
61
|
+
runId?: string;
|
|
62
|
+
attempt?: number;
|
|
63
|
+
stepAttempt?: number;
|
|
60
64
|
services: GencowCtx["services"] & WorkflowDocumentServicesCtx;
|
|
61
65
|
step<TResult>(name: string, run: () => Promise<TResult>): Promise<TResult>;
|
|
62
66
|
sleep(duration: WorkflowDuration): Promise<void>;
|
|
@@ -69,7 +73,11 @@ export type WorkflowHandler<TArgs, TReturn> = (wf: WorkflowCtx, args: TArgs) =>
|
|
|
69
73
|
export interface WorkflowOptions<TSchema = any, TReturn = any> {
|
|
70
74
|
args?: TSchema;
|
|
71
75
|
public?: boolean;
|
|
76
|
+
version?: string;
|
|
72
77
|
maxDuration?: WorkflowDuration;
|
|
78
|
+
maxActiveDuration?: WorkflowDuration;
|
|
79
|
+
lifecycleTimeout?: WorkflowDuration;
|
|
80
|
+
concurrency?: number;
|
|
73
81
|
retries?: number;
|
|
74
82
|
handler: WorkflowHandler<InferArgs<TSchema>, TReturn>;
|
|
75
83
|
}
|
|
@@ -77,7 +85,11 @@ export interface WorkflowDef<TSchema = any, TReturn = any> {
|
|
|
77
85
|
name: string;
|
|
78
86
|
argsSchema?: TSchema;
|
|
79
87
|
isPublic: boolean;
|
|
88
|
+
version?: string;
|
|
80
89
|
maxDurationMs: number;
|
|
90
|
+
maxActiveDurationMs: number;
|
|
91
|
+
lifecycleTimeoutMs: number | null;
|
|
92
|
+
concurrency: number | null;
|
|
81
93
|
maxRetries: number;
|
|
82
94
|
handler: WorkflowHandler<InferArgs<TSchema>, TReturn>;
|
|
83
95
|
}
|
package/dist/workflow.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { MutationDef } from "./reactive.js";
|
|
1
|
+
import type { MutationDef } from "./reactive-mutation-types.js";
|
|
2
2
|
import type { WorkflowDef, WorkflowDuration, WorkflowOptions, WorkflowStartResult } from "./workflow-types.js";
|
|
3
3
|
declare global {
|
|
4
4
|
var __gencow_workflowRegistry: Map<string, WorkflowDef<any, any>>;
|
package/dist/workflow.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { sql } from "drizzle-orm";
|
|
2
|
-
import { mutation } from "./reactive.js";
|
|
2
|
+
import { mutation } from "./reactive-mutation.js";
|
|
3
|
+
import { workflowJsonb } from "./workflow-json.js";
|
|
3
4
|
import { registerWorkflowsApi } from "./workflows-api.js";
|
|
4
5
|
const workflowRegistry = (globalThis.__gencow_workflowRegistry ??= new Map());
|
|
5
6
|
export const DEFAULT_WORKFLOW_MAX_DURATION_MS = 30 * 60 * 1000;
|
|
@@ -57,10 +58,112 @@ export function parseWorkflowDurationMs(raw, label = "workflow duration") {
|
|
|
57
58
|
}
|
|
58
59
|
return parseDurationString(raw, label);
|
|
59
60
|
}
|
|
60
|
-
function normalizeMaxDurationMs(maxDuration) {
|
|
61
|
+
function normalizeMaxDurationMs(maxDuration, label = "workflow() maxDuration") {
|
|
61
62
|
if (maxDuration == null)
|
|
62
63
|
return DEFAULT_WORKFLOW_MAX_DURATION_MS;
|
|
63
|
-
return parseWorkflowDurationMs(maxDuration,
|
|
64
|
+
return parseWorkflowDurationMs(maxDuration, label);
|
|
65
|
+
}
|
|
66
|
+
function normalizeOptionalDurationMs(duration, label) {
|
|
67
|
+
return duration == null ? null : parseWorkflowDurationMs(duration, label);
|
|
68
|
+
}
|
|
69
|
+
function normalizeConcurrency(concurrency) {
|
|
70
|
+
if (concurrency == null)
|
|
71
|
+
return null;
|
|
72
|
+
if (!Number.isFinite(concurrency) || concurrency <= 0) {
|
|
73
|
+
throw new Error(`workflow() concurrency must be a positive finite number, got "${concurrency}"`);
|
|
74
|
+
}
|
|
75
|
+
return Math.floor(concurrency);
|
|
76
|
+
}
|
|
77
|
+
function isMissingWorkflowV2SchemaError(error) {
|
|
78
|
+
const code = error && typeof error === "object" && "code" in error ? String(error.code) : "";
|
|
79
|
+
const cause = error && typeof error === "object" && "cause" in error ? error.cause : null;
|
|
80
|
+
const causeCode = cause && typeof cause === "object" && "code" in cause ? String(cause.code) : "";
|
|
81
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
82
|
+
return (code === "42P01" ||
|
|
83
|
+
code === "42703" ||
|
|
84
|
+
causeCode === "42P01" ||
|
|
85
|
+
causeCode === "42703" ||
|
|
86
|
+
message.includes('relation "_gencow_workflow_runs_v2" does not exist') ||
|
|
87
|
+
message.includes("relation _gencow_workflow_runs_v2 does not exist") ||
|
|
88
|
+
message.includes('relation "_gencow_workflow_outbox_v2" does not exist') ||
|
|
89
|
+
message.includes("relation _gencow_workflow_outbox_v2 does not exist") ||
|
|
90
|
+
message.includes('column "max_active_duration_ms"') ||
|
|
91
|
+
message.includes('column "retry_count"') ||
|
|
92
|
+
message.includes('column "user_id"'));
|
|
93
|
+
}
|
|
94
|
+
async function tryInsertWorkflowV2Run(options) {
|
|
95
|
+
try {
|
|
96
|
+
await options.db.execute(sql `
|
|
97
|
+
WITH inserted_run AS (
|
|
98
|
+
INSERT INTO _gencow_workflow_runs_v2 (
|
|
99
|
+
id,
|
|
100
|
+
workflow_name,
|
|
101
|
+
workflow_version,
|
|
102
|
+
args_json,
|
|
103
|
+
user_id,
|
|
104
|
+
max_active_duration_ms,
|
|
105
|
+
lifecycle_deadline_at,
|
|
106
|
+
retry_count,
|
|
107
|
+
max_retries,
|
|
108
|
+
max_attempts
|
|
109
|
+
)
|
|
110
|
+
VALUES (
|
|
111
|
+
${options.workflowId},
|
|
112
|
+
${options.workflowName},
|
|
113
|
+
${options.workflowVersion ?? null},
|
|
114
|
+
${workflowJsonb(options.args)},
|
|
115
|
+
${options.userId},
|
|
116
|
+
${options.maxActiveDurationMs},
|
|
117
|
+
CASE
|
|
118
|
+
WHEN ${options.lifecycleTimeoutMs}::bigint IS NULL THEN NULL
|
|
119
|
+
ELSE NOW() + (${options.lifecycleTimeoutMs}::bigint * INTERVAL '1 millisecond')
|
|
120
|
+
END,
|
|
121
|
+
0,
|
|
122
|
+
${options.maxRetries},
|
|
123
|
+
${options.maxRetries + 1}
|
|
124
|
+
)
|
|
125
|
+
RETURNING id
|
|
126
|
+
),
|
|
127
|
+
inserted_outbox AS (
|
|
128
|
+
INSERT INTO _gencow_workflow_outbox_v2 (
|
|
129
|
+
id,
|
|
130
|
+
run_id,
|
|
131
|
+
kind,
|
|
132
|
+
available_at,
|
|
133
|
+
status
|
|
134
|
+
)
|
|
135
|
+
SELECT
|
|
136
|
+
'start:' || inserted_run.id,
|
|
137
|
+
inserted_run.id,
|
|
138
|
+
'wake_run',
|
|
139
|
+
NOW(),
|
|
140
|
+
'pending'
|
|
141
|
+
FROM inserted_run
|
|
142
|
+
ON CONFLICT (id) DO NOTHING
|
|
143
|
+
RETURNING id
|
|
144
|
+
)
|
|
145
|
+
SELECT id
|
|
146
|
+
FROM inserted_run
|
|
147
|
+
`);
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
if (isMissingWorkflowV2SchemaError(error))
|
|
152
|
+
return false;
|
|
153
|
+
throw error;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
async function tryDeleteWorkflowV2Run(db, workflowId) {
|
|
157
|
+
try {
|
|
158
|
+
await db.execute(sql `
|
|
159
|
+
DELETE FROM _gencow_workflow_runs_v2
|
|
160
|
+
WHERE id = ${workflowId}
|
|
161
|
+
`);
|
|
162
|
+
}
|
|
163
|
+
catch (error) {
|
|
164
|
+
if (!isMissingWorkflowV2SchemaError(error))
|
|
165
|
+
throw error;
|
|
166
|
+
}
|
|
64
167
|
}
|
|
65
168
|
export function getWorkflowResumeActionName(name) {
|
|
66
169
|
return `${WORKFLOW_RESUME_ACTION_PREFIX}.${name}`;
|
|
@@ -85,13 +188,18 @@ export function getRegisteredWorkflows() {
|
|
|
85
188
|
*/
|
|
86
189
|
export function workflow(name, options) {
|
|
87
190
|
registerWorkflowsApi();
|
|
88
|
-
const
|
|
191
|
+
const maxActiveDurationMs = normalizeMaxDurationMs(options.maxActiveDuration ?? options.maxDuration, options.maxActiveDuration == null ? "workflow() maxDuration" : "workflow() maxActiveDuration");
|
|
192
|
+
const lifecycleTimeoutMs = normalizeOptionalDurationMs(options.lifecycleTimeout, "workflow() lifecycleTimeout");
|
|
89
193
|
const maxRetries = clampRetries(options.retries);
|
|
90
194
|
const def = {
|
|
91
195
|
name,
|
|
92
196
|
argsSchema: options.args,
|
|
93
197
|
isPublic: options.public === true,
|
|
94
|
-
|
|
198
|
+
version: options.version,
|
|
199
|
+
maxDurationMs: maxActiveDurationMs,
|
|
200
|
+
maxActiveDurationMs,
|
|
201
|
+
lifecycleTimeoutMs,
|
|
202
|
+
concurrency: normalizeConcurrency(options.concurrency),
|
|
95
203
|
maxRetries,
|
|
96
204
|
handler: options.handler,
|
|
97
205
|
};
|
|
@@ -120,16 +228,28 @@ export function workflow(name, options) {
|
|
|
120
228
|
VALUES (
|
|
121
229
|
${workflowId},
|
|
122
230
|
${name},
|
|
123
|
-
${
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
231
|
+
${workflowJsonb(persistedArgs)},
|
|
232
|
+
${realtimeToken},
|
|
233
|
+
'pending',
|
|
234
|
+
0,
|
|
235
|
+
${maxRetries},
|
|
236
|
+
${maxActiveDurationMs},
|
|
237
|
+
${ownerId}
|
|
130
238
|
)
|
|
131
239
|
`);
|
|
240
|
+
let insertedWorkflowV2 = false;
|
|
132
241
|
try {
|
|
242
|
+
insertedWorkflowV2 = await tryInsertWorkflowV2Run({
|
|
243
|
+
db: ctx.unsafeDb,
|
|
244
|
+
workflowId,
|
|
245
|
+
workflowName: name,
|
|
246
|
+
workflowVersion: options.version ?? null,
|
|
247
|
+
args: persistedArgs,
|
|
248
|
+
userId: ownerId,
|
|
249
|
+
maxActiveDurationMs,
|
|
250
|
+
lifecycleTimeoutMs,
|
|
251
|
+
maxRetries,
|
|
252
|
+
});
|
|
133
253
|
const scheduledJobId = ctx.scheduler.runAfter(0, resumeAction, { workflowId });
|
|
134
254
|
return {
|
|
135
255
|
id: workflowId,
|
|
@@ -143,6 +263,9 @@ export function workflow(name, options) {
|
|
|
143
263
|
DELETE FROM _gencow_workflows
|
|
144
264
|
WHERE id = ${workflowId}
|
|
145
265
|
`);
|
|
266
|
+
if (insertedWorkflowV2) {
|
|
267
|
+
await tryDeleteWorkflowV2Run(ctx.unsafeDb, workflowId);
|
|
268
|
+
}
|
|
146
269
|
throw error;
|
|
147
270
|
}
|
|
148
271
|
},
|
package/dist/workflows-api.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { sql } from "drizzle-orm";
|
|
2
|
-
import {
|
|
2
|
+
import { query } from "./reactive-query.js";
|
|
3
|
+
import { mutation } from "./reactive-mutation.js";
|
|
3
4
|
import { createWorkflowRealtimeToken, deserializeWorkflowValue, getWorkflowResumeActionName, getWorkflowRealtimeKey, serializeWorkflowValue, } from "./workflow.js";
|
|
5
|
+
import { workflowJsonb } from "./workflow-json.js";
|
|
4
6
|
import { GencowValidationError, v } from "./v.js";
|
|
5
7
|
import { deriveWorkflowStatus } from "./workflow-types.js";
|
|
6
8
|
const WORKFLOW_STATUSES = new Set(["pending", "running", "completed", "failed"]);
|
|
@@ -40,6 +42,7 @@ function mapWorkflowSummary(row) {
|
|
|
40
42
|
derivedStatus: deriveWorkflowStatus(row.status, row.current_step),
|
|
41
43
|
currentStep: row.current_step,
|
|
42
44
|
error: row.error,
|
|
45
|
+
errorCode: row.error_code,
|
|
43
46
|
retryCount: row.retry_count,
|
|
44
47
|
maxRetries: row.max_retries,
|
|
45
48
|
maxDurationMs: Number(row.max_duration_ms),
|
|
@@ -125,9 +128,66 @@ async function loadWorkflowSignalTarget(db, workflowId) {
|
|
|
125
128
|
FROM _gencow_workflows
|
|
126
129
|
WHERE id = ${workflowId}
|
|
127
130
|
LIMIT 1
|
|
128
|
-
|
|
131
|
+
`);
|
|
129
132
|
return rowsFromResult(result)[0] ?? null;
|
|
130
133
|
}
|
|
134
|
+
function isMissingWorkflowV2SignalSchemaError(error) {
|
|
135
|
+
const code = error && typeof error === "object" && "code" in error ? String(error.code) : "";
|
|
136
|
+
const cause = error && typeof error === "object" && "cause" in error ? error.cause : null;
|
|
137
|
+
const causeCode = cause && typeof cause === "object" && "code" in cause ? String(cause.code) : "";
|
|
138
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
139
|
+
return (code === "42P01" ||
|
|
140
|
+
code === "42703" ||
|
|
141
|
+
causeCode === "42P01" ||
|
|
142
|
+
causeCode === "42703" ||
|
|
143
|
+
((code === "23503" || causeCode === "23503") && message.includes("_gencow_workflow_signals_v2")) ||
|
|
144
|
+
message.includes('relation "_gencow_workflow_signals_v2" does not exist') ||
|
|
145
|
+
message.includes("relation _gencow_workflow_signals_v2 does not exist") ||
|
|
146
|
+
message.includes('relation "_gencow_workflow_runs_v2" does not exist') ||
|
|
147
|
+
message.includes("relation _gencow_workflow_runs_v2 does not exist"));
|
|
148
|
+
}
|
|
149
|
+
async function tryRecordWorkflowV2Signal(options) {
|
|
150
|
+
try {
|
|
151
|
+
await options.db.execute(sql `
|
|
152
|
+
WITH inserted AS (
|
|
153
|
+
INSERT INTO _gencow_workflow_signals_v2 (
|
|
154
|
+
id,
|
|
155
|
+
run_id,
|
|
156
|
+
event_name,
|
|
157
|
+
payload_json,
|
|
158
|
+
idempotency_key
|
|
159
|
+
)
|
|
160
|
+
VALUES (
|
|
161
|
+
${crypto.randomUUID()},
|
|
162
|
+
${options.workflowId},
|
|
163
|
+
${options.event},
|
|
164
|
+
${workflowJsonb(options.payload)},
|
|
165
|
+
${crypto.randomUUID()}
|
|
166
|
+
)
|
|
167
|
+
RETURNING run_id
|
|
168
|
+
)
|
|
169
|
+
UPDATE _gencow_workflow_runs_v2 run
|
|
170
|
+
SET
|
|
171
|
+
status = 'queued',
|
|
172
|
+
runnable_at = NOW(),
|
|
173
|
+
lease_owner = NULL,
|
|
174
|
+
lease_expires_at = NULL,
|
|
175
|
+
heartbeat_at = NULL,
|
|
176
|
+
updated_at = NOW()
|
|
177
|
+
FROM inserted
|
|
178
|
+
WHERE run.id = inserted.run_id
|
|
179
|
+
AND run.status = 'waiting'
|
|
180
|
+
AND run.completed_at IS NULL
|
|
181
|
+
AND run.cancel_requested_at IS NULL
|
|
182
|
+
`);
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
185
|
+
catch (error) {
|
|
186
|
+
if (isMissingWorkflowV2SignalSchemaError(error))
|
|
187
|
+
return false;
|
|
188
|
+
throw error;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
131
191
|
export async function loadWorkflowSnapshot(db, workflowId, options) {
|
|
132
192
|
const workflowResult = await db.execute(sql `
|
|
133
193
|
SELECT
|
|
@@ -138,6 +198,7 @@ export async function loadWorkflowSnapshot(db, workflowId, options) {
|
|
|
138
198
|
current_step,
|
|
139
199
|
result,
|
|
140
200
|
error,
|
|
201
|
+
error_code,
|
|
141
202
|
retry_count,
|
|
142
203
|
max_retries,
|
|
143
204
|
max_duration_ms,
|
|
@@ -237,9 +298,15 @@ export function registerWorkflowsApi() {
|
|
|
237
298
|
${crypto.randomUUID()},
|
|
238
299
|
${workflow.id},
|
|
239
300
|
${normalizedEvent},
|
|
240
|
-
|
|
301
|
+
${workflowJsonb(persistedPayload)}
|
|
241
302
|
)
|
|
242
303
|
`);
|
|
304
|
+
await tryRecordWorkflowV2Signal({
|
|
305
|
+
db: ctx.unsafeDb,
|
|
306
|
+
workflowId: workflow.id,
|
|
307
|
+
event: normalizedEvent,
|
|
308
|
+
payload: persistedPayload,
|
|
309
|
+
});
|
|
243
310
|
let scheduledJobId = null;
|
|
244
311
|
if (workflow.status === "pending" && workflow.current_step?.startsWith("wait:")) {
|
|
245
312
|
try {
|
|
@@ -279,6 +346,7 @@ export function registerWorkflowsApi() {
|
|
|
279
346
|
current_step,
|
|
280
347
|
result,
|
|
281
348
|
error,
|
|
349
|
+
error_code,
|
|
282
350
|
retry_count,
|
|
283
351
|
max_retries,
|
|
284
352
|
max_duration_ms,
|
|
@@ -300,6 +368,7 @@ export function registerWorkflowsApi() {
|
|
|
300
368
|
current_step,
|
|
301
369
|
result,
|
|
302
370
|
error,
|
|
371
|
+
error_code,
|
|
303
372
|
retry_count,
|
|
304
373
|
max_retries,
|
|
305
374
|
max_duration_ms,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gencow/core",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.30",
|
|
4
4
|
"description": "Gencow core library — defineQuery, defineMutation, reactive subscriptions",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -24,10 +24,13 @@
|
|
|
24
24
|
"scripts": {
|
|
25
25
|
"db:generate:fixture-basic": "drizzle-kit generate --config ./src/__tests__/fixtures/basic/drizzle.config.ts",
|
|
26
26
|
"build": "tsc",
|
|
27
|
+
"test:coverage": "node ../../scripts/run-package-coverage.mjs",
|
|
28
|
+
"coverage:compare": "node ../../scripts/compare-package-coverage.mjs",
|
|
27
29
|
"typecheck": "tsc --noEmit",
|
|
28
30
|
"prepublishOnly": "npm run build"
|
|
29
31
|
},
|
|
30
32
|
"dependencies": {
|
|
33
|
+
"@standard-schema/spec": "^1.1.0",
|
|
31
34
|
"node-cron": "^4.2.1"
|
|
32
35
|
},
|
|
33
36
|
"peerDependencies": {
|
package/src/auth-config.ts
CHANGED
|
@@ -10,6 +10,20 @@
|
|
|
10
10
|
|
|
11
11
|
// ─── Email Verification ──────────────────────────────────
|
|
12
12
|
|
|
13
|
+
export interface AuthHookContext {
|
|
14
|
+
db?: unknown;
|
|
15
|
+
request?: unknown;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface AuthUserLike {
|
|
19
|
+
id?: string;
|
|
20
|
+
email: string;
|
|
21
|
+
name?: string | null;
|
|
22
|
+
emailVerified?: boolean;
|
|
23
|
+
createdAt?: Date | string;
|
|
24
|
+
updatedAt?: Date | string;
|
|
25
|
+
}
|
|
26
|
+
|
|
13
27
|
export interface AuthEmailVerification {
|
|
14
28
|
/** 가입 시 인증 메일 자동 발송 (default: true) */
|
|
15
29
|
sendOnSignUp?: boolean;
|
|
@@ -19,18 +33,105 @@ export interface AuthEmailVerification {
|
|
|
19
33
|
autoSignInAfterVerification?: boolean;
|
|
20
34
|
/** 인증 메일 발송 함수 — 사용자가 직접 구현 */
|
|
21
35
|
sendVerificationEmail: (data: {
|
|
22
|
-
user:
|
|
36
|
+
user: AuthUserLike;
|
|
23
37
|
url: string;
|
|
24
38
|
token: string;
|
|
25
|
-
}) => Promise<void>;
|
|
39
|
+
}, context?: AuthHookContext) => Promise<void>;
|
|
40
|
+
/** 인증 직전 훅 — better-auth의 emailVerification.beforeEmailVerification으로 전달 */
|
|
41
|
+
beforeEmailVerification?: (user: AuthUserLike, context?: AuthHookContext) => Promise<void>;
|
|
42
|
+
/** 인증 완료 훅 — 환영 메일 등 idempotent 후처리에 사용 */
|
|
43
|
+
afterEmailVerification?: (user: AuthUserLike, context?: AuthHookContext) => Promise<void>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface AuthPasswordReset {
|
|
47
|
+
/** 비밀번호 재설정 메일 발송 함수 — reset URL은 반드시 사용자에게 전달되어야 한다. */
|
|
48
|
+
sendResetPassword: (data: {
|
|
49
|
+
user: AuthUserLike;
|
|
50
|
+
url: string;
|
|
51
|
+
token: string;
|
|
52
|
+
}, context?: AuthHookContext) => Promise<void>;
|
|
53
|
+
/** 비밀번호 재설정 완료 후 훅 */
|
|
54
|
+
onPasswordReset?: (data: { user: AuthUserLike }, context?: AuthHookContext) => Promise<void>;
|
|
55
|
+
/** reset token 유효 시간(초). default: better-auth 기본값 3600초 */
|
|
56
|
+
resetPasswordTokenExpiresIn?: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface AuthEvents {
|
|
60
|
+
/** 회원가입 성공 후 실행. 운영 알림처럼 실패해도 가입을 막지 않아야 하는 작업에 사용. */
|
|
61
|
+
afterSignUp?: (data: { user: AuthUserLike; inviteCode?: string | null }, context?: AuthHookContext) => Promise<void>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ─── Social Login / OAuth ───────────────────────────────
|
|
65
|
+
|
|
66
|
+
export interface SocialProviderConfig {
|
|
67
|
+
clientId: string;
|
|
68
|
+
clientSecret: string;
|
|
69
|
+
/** Provider-specific callback override. Most apps should leave this unset. */
|
|
70
|
+
redirectURI?: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface OAuthUserInfo {
|
|
74
|
+
id: string;
|
|
75
|
+
email: string;
|
|
76
|
+
name?: string;
|
|
77
|
+
image?: string;
|
|
78
|
+
emailVerified?: boolean;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface CustomOAuthProvider {
|
|
82
|
+
clientId: string;
|
|
83
|
+
clientSecret?: string;
|
|
84
|
+
authorizationUrl: string;
|
|
85
|
+
tokenUrl: string;
|
|
86
|
+
userInfoUrl: string;
|
|
87
|
+
scopes?: string[];
|
|
88
|
+
pkce?: boolean;
|
|
89
|
+
/** Provider-specific callback override. Most apps should leave this unset. */
|
|
90
|
+
redirectURI?: string;
|
|
91
|
+
/** Map provider profile responses such as Naver/Kakao into better-auth user fields. */
|
|
92
|
+
mapProfileToUser?: (profile: Record<string, unknown>) => Partial<OAuthUserInfo> | Promise<Partial<OAuthUserInfo>>;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export type KakaoConfig = SocialProviderConfig & Partial<CustomOAuthProvider>;
|
|
96
|
+
export type NaverConfig = SocialProviderConfig & Partial<CustomOAuthProvider>;
|
|
97
|
+
|
|
98
|
+
export type SocialProvidersConfig = {
|
|
99
|
+
google?: SocialProviderConfig;
|
|
100
|
+
apple?: SocialProviderConfig;
|
|
101
|
+
/**
|
|
102
|
+
* Kakao/Naver use Gencow's built-in OAuth endpoint defaults.
|
|
103
|
+
* Override any CustomOAuthProvider field when a provider dashboard requires it.
|
|
104
|
+
*/
|
|
105
|
+
kakao?: KakaoConfig;
|
|
106
|
+
naver?: NaverConfig;
|
|
107
|
+
/** Additional OAuth providers can be configured with explicit OAuth endpoints. */
|
|
108
|
+
[providerId: string]:
|
|
109
|
+
| SocialProviderConfig
|
|
110
|
+
| KakaoConfig
|
|
111
|
+
| NaverConfig
|
|
112
|
+
| CustomOAuthProvider
|
|
113
|
+
| undefined;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
export interface AuthOAuthConfig {
|
|
117
|
+
/**
|
|
118
|
+
* Frontend route that receives the short-lived OAuth handoff code.
|
|
119
|
+
* If omitted, Gencow uses APP_PUBLIC_DOMAIN + /auth/callback, then falls back to the auth server origin.
|
|
120
|
+
*/
|
|
121
|
+
callbackURL?: string;
|
|
122
|
+
/** Extra allowed frontend callback URLs for local development or multi-frontend apps. */
|
|
123
|
+
allowedCallbackURLs?: string[];
|
|
26
124
|
}
|
|
27
125
|
|
|
28
126
|
// ─── Auth Config ─────────────────────────────────────────
|
|
29
127
|
|
|
30
128
|
export interface GencowAuthConfig {
|
|
31
129
|
emailVerification?: AuthEmailVerification;
|
|
130
|
+
socialProviders?: SocialProvidersConfig;
|
|
131
|
+
oauth?: AuthOAuthConfig;
|
|
132
|
+
passwordReset?: AuthPasswordReset;
|
|
133
|
+
events?: AuthEvents;
|
|
32
134
|
// 확장 예정:
|
|
33
|
-
// socialProviders?: { ... }
|
|
34
135
|
// passwordPolicy?: { ... }
|
|
35
136
|
// sessionExpiry?: number
|
|
36
137
|
}
|