@gencow/core 0.1.23 → 0.1.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/crud.d.ts +2 -2
- package/dist/crud.js +225 -208
- package/dist/index.d.ts +7 -3
- package/dist/index.js +4 -1
- package/dist/reactive.js +10 -3
- package/dist/retry.js +1 -1
- package/dist/rls-db.d.ts +2 -2
- package/dist/rls-db.js +1 -5
- package/dist/scheduler.d.ts +2 -0
- package/dist/scheduler.js +16 -6
- package/dist/server.d.ts +0 -1
- package/dist/server.js +0 -1
- package/dist/storage.js +29 -22
- package/dist/v.d.ts +2 -2
- package/dist/workflow-types.d.ts +81 -0
- package/dist/workflow-types.js +12 -0
- package/dist/workflow.d.ts +30 -0
- package/dist/workflow.js +150 -0
- package/dist/workflows-api.d.ts +13 -0
- package/dist/workflows-api.js +321 -0
- package/package.json +46 -42
- package/src/__tests__/auth.test.ts +90 -86
- package/src/__tests__/crons.test.ts +69 -67
- package/src/__tests__/crud-codegen-integration.test.ts +164 -170
- package/src/__tests__/crud-owner-rls.test.ts +308 -301
- package/src/__tests__/crud.test.ts +694 -711
- package/src/__tests__/dist-exports.test.ts +120 -114
- package/src/__tests__/fixtures/basic/auth.ts +16 -16
- package/src/__tests__/fixtures/basic/drizzle.config.ts +1 -4
- package/src/__tests__/fixtures/basic/index.ts +1 -1
- package/src/__tests__/fixtures/basic/schema.ts +1 -1
- package/src/__tests__/fixtures/basic/tasks.ts +4 -4
- package/src/__tests__/fixtures/common/auth-schema.ts +38 -34
- package/src/__tests__/helpers/basic-rls-fixture.ts +80 -78
- package/src/__tests__/helpers/pglite-migrations.ts +2 -5
- package/src/__tests__/helpers/pglite-rls-session.ts +13 -16
- package/src/__tests__/helpers/seed-like-fill.ts +50 -44
- package/src/__tests__/helpers/test-gencow-ctx-rls.ts +4 -7
- package/src/__tests__/httpaction.test.ts +91 -91
- package/src/__tests__/image-optimization.test.ts +570 -574
- package/src/__tests__/load.test.ts +321 -308
- package/src/__tests__/network-sim.test.ts +238 -215
- package/src/__tests__/reactive.test.ts +380 -358
- package/src/__tests__/retry.test.ts +99 -84
- package/src/__tests__/rls-crud-basic.test.ts +172 -245
- package/src/__tests__/rls-crud-no-owner-rls-pglite.test.ts +81 -81
- package/src/__tests__/rls-custom-mutation-handlers.test.ts +47 -94
- package/src/__tests__/rls-custom-query-handlers.test.ts +92 -92
- package/src/__tests__/rls-db-leased-connection.test.ts +2 -6
- package/src/__tests__/rls-session-and-policies.test.ts +181 -199
- package/src/__tests__/scheduler-durable-v2.test.ts +199 -181
- package/src/__tests__/scheduler-durable.test.ts +117 -117
- package/src/__tests__/scheduler-exec.test.ts +258 -246
- package/src/__tests__/scheduler.test.ts +129 -111
- package/src/__tests__/storage.test.ts +282 -269
- package/src/__tests__/tsconfig.json +6 -6
- package/src/__tests__/validator.test.ts +236 -232
- package/src/__tests__/workflow.test.ts +606 -0
- package/src/__tests__/ws-integration.test.ts +223 -218
- package/src/__tests__/ws-scale.test.ts +168 -159
- package/src/auth-config.ts +18 -18
- package/src/auth.ts +106 -106
- package/src/crons.ts +77 -77
- package/src/crud.ts +523 -479
- package/src/index.ts +71 -6
- package/src/reactive.ts +357 -331
- package/src/retry.ts +51 -54
- package/src/rls-db.ts +195 -205
- package/src/rls.ts +33 -36
- package/src/scheduler.ts +237 -211
- package/src/server.ts +0 -1
- package/src/storage.ts +632 -593
- package/src/v.ts +119 -114
- package/src/workflow-types.ts +108 -0
- package/src/workflow.ts +188 -0
- package/src/workflows-api.ts +415 -0
- package/src/db.ts +0 -18
package/dist/rls-db.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { PgAsyncDatabase } from "drizzle-orm/pg-core";
|
|
2
2
|
/**
|
|
3
3
|
* RLS DB wrapper — execution paths for `withRlsConnection`:
|
|
4
4
|
* 1. **Reuse outer Drizzle transaction** (`reuseOuterConnection`): same connection, apply GUCs then run `fn`.
|
|
@@ -44,4 +44,4 @@ export declare function withRlsLeasedConnection<T>(leased: {
|
|
|
44
44
|
*
|
|
45
45
|
* `db.transaction()` still injects the same variables at the start of the callback transaction.
|
|
46
46
|
*/
|
|
47
|
-
export declare function createRlsDb(db:
|
|
47
|
+
export declare function createRlsDb(db: PgAsyncDatabase<any, any, any, any>, rls: RlsSessionContext): PgAsyncDatabase<any, any, any, any>;
|
package/dist/rls-db.js
CHANGED
|
@@ -1,11 +1,7 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
2
|
import { sql } from "drizzle-orm";
|
|
3
3
|
const gucNameRe = /^app\.[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)*$/;
|
|
4
|
-
const RESERVED_VARS_KEYS = new Set([
|
|
5
|
-
"app.current_user_id",
|
|
6
|
-
"app.current_user_role",
|
|
7
|
-
"app.tenant_id",
|
|
8
|
-
]);
|
|
4
|
+
const RESERVED_VARS_KEYS = new Set(["app.current_user_id", "app.current_user_role", "app.tenant_id"]);
|
|
9
5
|
function assertSafeGucName(key) {
|
|
10
6
|
if (!gucNameRe.test(key)) {
|
|
11
7
|
throw new Error(`createRlsDb: GUC name "${key}" is invalid — use lowercase app.* names (e.g. app.org_id)`);
|
package/dist/scheduler.d.ts
CHANGED
|
@@ -45,6 +45,8 @@ export interface Scheduler {
|
|
|
45
45
|
cron(name: string, pattern: string, handler: () => Promise<void>): void;
|
|
46
46
|
/** Register an action handler */
|
|
47
47
|
registerAction(name: string, handler: ActionHandler): void;
|
|
48
|
+
/** Execute a registered action and propagate errors to the caller */
|
|
49
|
+
executeActionStrict(name: string, args?: any): Promise<void>;
|
|
48
50
|
/** Execute a registered action by name — 선언적 crons.ts 문자열 액션 실행용 */
|
|
49
51
|
executeAction(name: string, args?: any): Promise<void>;
|
|
50
52
|
}
|
package/dist/scheduler.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as cron from "node-cron";
|
|
2
|
-
const _cronInfo = globalThis.__gencow_cronInfo ??= [];
|
|
3
|
-
const _pendingJobs = globalThis.__gencow_pendingJobs ??= [];
|
|
4
|
-
const _failedJobs = globalThis.__gencow_failedJobs ??= [];
|
|
2
|
+
const _cronInfo = (globalThis.__gencow_cronInfo ??= []);
|
|
3
|
+
const _pendingJobs = (globalThis.__gencow_pendingJobs ??= []);
|
|
4
|
+
const _failedJobs = (globalThis.__gencow_failedJobs ??= []);
|
|
5
5
|
/** 최대 보관할 실패 작업 수 (메모리 보호) */
|
|
6
6
|
const MAX_FAILED_JOBS = 100;
|
|
7
7
|
export function getSchedulerInfo() {
|
|
@@ -89,7 +89,12 @@ export function createScheduler(options) {
|
|
|
89
89
|
return {
|
|
90
90
|
runAfter(ms, action, args, scheduleOpts) {
|
|
91
91
|
const id = generateId();
|
|
92
|
-
const jobEntry = {
|
|
92
|
+
const jobEntry = {
|
|
93
|
+
id,
|
|
94
|
+
action,
|
|
95
|
+
scheduledAt: new Date().toISOString(),
|
|
96
|
+
status: "pending",
|
|
97
|
+
};
|
|
93
98
|
_pendingJobs.push(jobEntry);
|
|
94
99
|
// ── Durable mode: DB에 영속화, 실행은 외부 폴러에 위임 ──
|
|
95
100
|
if (isDurable) {
|
|
@@ -100,10 +105,12 @@ export function createScheduler(options) {
|
|
|
100
105
|
args: args ?? {},
|
|
101
106
|
runAt,
|
|
102
107
|
onErrorAction: scheduleOpts?.onError,
|
|
103
|
-
})
|
|
108
|
+
})
|
|
109
|
+
.then(() => {
|
|
104
110
|
console.log(`[scheduler] Persisted "${action}" to run at ${runAt.toISOString()} (id: ${id}, durable)` +
|
|
105
111
|
`${scheduleOpts?.onError ? ` [onError: ${scheduleOpts.onError}]` : ""}`);
|
|
106
|
-
})
|
|
112
|
+
})
|
|
113
|
+
.catch((err) => {
|
|
107
114
|
console.error(`[scheduler] Failed to persist job ${id}:`, err instanceof Error ? err.message : err);
|
|
108
115
|
// DB persist 실패 → 인메모리 fallback
|
|
109
116
|
scheduleInMemory(id, ms, action, args, scheduleOpts, jobEntry);
|
|
@@ -171,6 +178,9 @@ export function createScheduler(options) {
|
|
|
171
178
|
registerAction(name, handler) {
|
|
172
179
|
actions.set(name, handler);
|
|
173
180
|
},
|
|
181
|
+
async executeActionStrict(name, args) {
|
|
182
|
+
await executeAction(name, args);
|
|
183
|
+
},
|
|
174
184
|
async executeAction(name, args) {
|
|
175
185
|
try {
|
|
176
186
|
await executeAction(name, args);
|
package/dist/server.d.ts
CHANGED
|
@@ -5,7 +5,6 @@
|
|
|
5
5
|
* executing server. Excluded from client-side core (`index.ts`) so they aren't
|
|
6
6
|
* bundled into user functions which run in Firecracker.
|
|
7
7
|
*/
|
|
8
|
-
export { createDb } from "./db.js";
|
|
9
8
|
export { createStorage, storageRoutes } from "./storage.js";
|
|
10
9
|
export type { StorageImageTierConfig } from "./storage.js";
|
|
11
10
|
export { createScheduler, getSchedulerInfo } from "./scheduler.js";
|
package/dist/server.js
CHANGED
|
@@ -5,7 +5,6 @@
|
|
|
5
5
|
* executing server. Excluded from client-side core (`index.ts`) so they aren't
|
|
6
6
|
* bundled into user functions which run in Firecracker.
|
|
7
7
|
*/
|
|
8
|
-
export { createDb } from "./db.js";
|
|
9
8
|
export { createStorage, storageRoutes } from "./storage.js";
|
|
10
9
|
export { createScheduler, getSchedulerInfo } from "./scheduler.js";
|
|
11
10
|
export { authMiddleware, authRoutes, getUsers } from "./auth.js";
|
package/dist/storage.js
CHANGED
|
@@ -141,7 +141,9 @@ export function createStorage(dir = "./uploads", options) {
|
|
|
141
141
|
const filePath = path.join(dir, id);
|
|
142
142
|
await fs.writeFile(filePath, buffer);
|
|
143
143
|
// .meta JSON 기록 — Platform 레벨 직접 서빙용
|
|
144
|
-
await fs
|
|
144
|
+
await fs
|
|
145
|
+
.writeFile(`${filePath}.meta`, JSON.stringify({ name: filename, type, size: buffer.length }))
|
|
146
|
+
.catch(() => { });
|
|
145
147
|
metaStore.set(id, {
|
|
146
148
|
id,
|
|
147
149
|
name: filename,
|
|
@@ -187,25 +189,27 @@ export function createStorage(dir = "./uploads", options) {
|
|
|
187
189
|
const entries = await fs.readdir(cacheDir);
|
|
188
190
|
const prefix = `${storageId}_`;
|
|
189
191
|
await Promise.all(entries
|
|
190
|
-
.filter(e => e.startsWith(prefix))
|
|
191
|
-
.map(e => fs.unlink(path.join(cacheDir, e)).catch(() => { })));
|
|
192
|
+
.filter((e) => e.startsWith(prefix))
|
|
193
|
+
.map((e) => fs.unlink(path.join(cacheDir, e)).catch(() => { })));
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
/* .cache 디렉토리 미존재 시 무시 */
|
|
192
197
|
}
|
|
193
|
-
catch { /* .cache 디렉토리 미존재 시 무시 */ }
|
|
194
198
|
// DB에서도 삭제 (rawSql 있을 때만)
|
|
195
199
|
if (rawSql) {
|
|
196
200
|
try {
|
|
197
201
|
await rawSql(`DELETE FROM _system_files WHERE storage_id = $1`, [storageId]);
|
|
198
202
|
}
|
|
199
|
-
catch {
|
|
203
|
+
catch {
|
|
204
|
+
/* 삭제 실패 무시 — 파일은 이미 제거됨 */
|
|
205
|
+
}
|
|
200
206
|
}
|
|
201
207
|
},
|
|
202
208
|
};
|
|
203
209
|
}
|
|
204
210
|
// ─── Image Optimization Constants ───────────────────────
|
|
205
211
|
/** 변환 가능한 원본 MIME 타입 */
|
|
206
|
-
const TRANSFORMABLE_TYPES = new Set([
|
|
207
|
-
"image/png", "image/jpeg", "image/jpg", "image/webp",
|
|
208
|
-
]);
|
|
212
|
+
const TRANSFORMABLE_TYPES = new Set(["image/png", "image/jpeg", "image/jpg", "image/webp"]);
|
|
209
213
|
/** 허용 출력 포맷 */
|
|
210
214
|
const ALLOWED_FORMATS = new Set(["webp", "avif", "jpeg", "png"]);
|
|
211
215
|
/** 허용 맞춤 모드 */
|
|
@@ -363,9 +367,10 @@ export function storageRoutes(storage, rawSql, storageDir, tierConfig) {
|
|
|
363
367
|
const appConfig = JSON.parse(raw);
|
|
364
368
|
// Tier ceiling 적용: min(앱 설정, Tier 최대값)
|
|
365
369
|
if (appConfig.autoMaxWidth > 0) {
|
|
366
|
-
config.autoMaxWidth =
|
|
367
|
-
|
|
368
|
-
|
|
370
|
+
config.autoMaxWidth =
|
|
371
|
+
config.autoMaxWidth > 0
|
|
372
|
+
? Math.min(appConfig.autoMaxWidth, config.autoMaxWidth)
|
|
373
|
+
: appConfig.autoMaxWidth;
|
|
369
374
|
}
|
|
370
375
|
if (appConfig.autoQuality > 0 && appConfig.autoQuality <= 100) {
|
|
371
376
|
config.autoQuality = appConfig.autoQuality;
|
|
@@ -374,7 +379,9 @@ export function storageRoutes(storage, rawSql, storageDir, tierConfig) {
|
|
|
374
379
|
config.autoWebp = false;
|
|
375
380
|
}
|
|
376
381
|
}
|
|
377
|
-
catch {
|
|
382
|
+
catch {
|
|
383
|
+
/* 파일 없음 or 파싱 실패 → Tier 기본값 유지 */
|
|
384
|
+
}
|
|
378
385
|
return config;
|
|
379
386
|
}
|
|
380
387
|
// sharp 모듈 캐시 (동적 import 1회만)
|
|
@@ -417,7 +424,9 @@ export function storageRoutes(storage, rawSql, storageDir, tierConfig) {
|
|
|
417
424
|
};
|
|
418
425
|
}
|
|
419
426
|
}
|
|
420
|
-
catch {
|
|
427
|
+
catch {
|
|
428
|
+
/* fallthrough to 404 */
|
|
429
|
+
}
|
|
421
430
|
}
|
|
422
431
|
if (!meta) {
|
|
423
432
|
return c.json({ error: "Not found" }, 404);
|
|
@@ -432,11 +441,11 @@ export function storageRoutes(storage, rawSql, storageDir, tierConfig) {
|
|
|
432
441
|
const acceptHeader = c.req.header("accept") || "";
|
|
433
442
|
const clientAcceptsWebp = acceptHeader.includes("image/webp");
|
|
434
443
|
// Auto WebP: 파라미터 없지만 브라우저가 webp 지원 + 원본이 PNG/JPEG
|
|
435
|
-
const isAutoWebp = !transformParams
|
|
436
|
-
&&
|
|
437
|
-
&&
|
|
438
|
-
|
|
439
|
-
|
|
444
|
+
const isAutoWebp = !transformParams &&
|
|
445
|
+
isTransformable &&
|
|
446
|
+
clientAcceptsWebp &&
|
|
447
|
+
config.autoWebp &&
|
|
448
|
+
(meta.type === "image/png" || meta.type === "image/jpeg" || meta.type === "image/jpg");
|
|
440
449
|
// 이미지 변환이 필요 없는 경우 → 원본 서빙 (기존 동작 100% 유지)
|
|
441
450
|
if (!transformParams && !isAutoWebp) {
|
|
442
451
|
return serveOriginal(c, meta);
|
|
@@ -595,16 +604,14 @@ export function storageRoutes(storage, rawSql, storageDir, tierConfig) {
|
|
|
595
604
|
// 변환된 파일명: original.png → original.webp (또는 original_300x200.webp)
|
|
596
605
|
const baseName = originalMeta.name.replace(/\.[^.]+$/, "");
|
|
597
606
|
const ext = outputFormat || originalMeta.name.split(".").pop() || "bin";
|
|
598
|
-
const suffix = params?.w || params?.h
|
|
599
|
-
? `_${params.w || "auto"}x${params.h || "auto"}`
|
|
600
|
-
: "";
|
|
607
|
+
const suffix = params?.w || params?.h ? `_${params.w || "auto"}x${params.h || "auto"}` : "";
|
|
601
608
|
const fileName = `${baseName}${suffix}.${ext}`;
|
|
602
609
|
const headers = {
|
|
603
610
|
"Content-Type": contentType,
|
|
604
611
|
"Content-Disposition": `inline; filename="${encodeURIComponent(fileName)}"; filename*=UTF-8''${encodeURIComponent(fileName)}`,
|
|
605
612
|
"Cache-Control": "public, max-age=31536000, immutable",
|
|
606
613
|
// Vary: Accept — Auto WebP 시 CDN/브라우저 캐시 분리
|
|
607
|
-
...(isAutoWebp ? {
|
|
614
|
+
...(isAutoWebp ? { Vary: "Accept" } : {}),
|
|
608
615
|
};
|
|
609
616
|
if (typeof globalThis.Bun !== "undefined") {
|
|
610
617
|
const bunFile = Bun.file(cachePath);
|
package/dist/v.d.ts
CHANGED
|
@@ -32,9 +32,9 @@ export declare const v: {
|
|
|
32
32
|
* Correctly handles optional properties (Validator<T | undefined> → optional key).
|
|
33
33
|
*/
|
|
34
34
|
export type InferArgs<T> = T extends Validator<infer U> ? U : T extends Record<string, Validator> ? {
|
|
35
|
-
[K in keyof T as T[K] extends Validator<infer U> ?
|
|
35
|
+
[K in keyof T as T[K] extends Validator<infer U> ? undefined extends U ? never : K : K]: T[K] extends Validator<infer U> ? U : any;
|
|
36
36
|
} & {
|
|
37
|
-
[K in keyof T as T[K] extends Validator<infer U> ?
|
|
37
|
+
[K in keyof T as T[K] extends Validator<infer U> ? undefined extends U ? K : never : never]?: T[K] extends Validator<infer U> ? U : any;
|
|
38
38
|
} : T;
|
|
39
39
|
/**
|
|
40
40
|
* Validates and parses arguments against a schema at runtime.
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { GencowCtx } from "./reactive.js";
|
|
2
|
+
import type { InferArgs } from "./v.js";
|
|
3
|
+
export type WorkflowStatus = "pending" | "running" | "completed" | "failed";
|
|
4
|
+
export type WorkflowDuration = number | string;
|
|
5
|
+
export type WorkflowDerivedStatus = WorkflowStatus | "queued" | "waiting" | "sleeping";
|
|
6
|
+
export declare function deriveWorkflowStatus(status: WorkflowStatus, currentStep: string | null | undefined): WorkflowDerivedStatus;
|
|
7
|
+
export interface WorkflowSummary {
|
|
8
|
+
id: string;
|
|
9
|
+
name: string;
|
|
10
|
+
status: WorkflowStatus;
|
|
11
|
+
derivedStatus: WorkflowDerivedStatus;
|
|
12
|
+
currentStep: string | null;
|
|
13
|
+
error: string | null;
|
|
14
|
+
retryCount: number;
|
|
15
|
+
maxRetries: number;
|
|
16
|
+
maxDurationMs: number;
|
|
17
|
+
startedAt: string;
|
|
18
|
+
updatedAt: string;
|
|
19
|
+
completedAt: string | null;
|
|
20
|
+
}
|
|
21
|
+
export interface WorkflowStepSnapshot {
|
|
22
|
+
name: string;
|
|
23
|
+
status: WorkflowStatus;
|
|
24
|
+
output: unknown;
|
|
25
|
+
error: string | null;
|
|
26
|
+
startedAt: string | null;
|
|
27
|
+
updatedAt: string;
|
|
28
|
+
completedAt: string | null;
|
|
29
|
+
}
|
|
30
|
+
export interface WorkflowSnapshot extends WorkflowSummary {
|
|
31
|
+
args: unknown;
|
|
32
|
+
result: unknown;
|
|
33
|
+
steps: WorkflowStepSnapshot[];
|
|
34
|
+
/** Opaque realtime channel for exact-id workflow updates. */
|
|
35
|
+
realtimeKey: string;
|
|
36
|
+
}
|
|
37
|
+
export interface WorkflowListArgs {
|
|
38
|
+
limit?: number;
|
|
39
|
+
status?: WorkflowDerivedStatus;
|
|
40
|
+
}
|
|
41
|
+
export interface WorkflowStartResult {
|
|
42
|
+
id: string;
|
|
43
|
+
name: string;
|
|
44
|
+
status: "pending";
|
|
45
|
+
scheduledJobId: string;
|
|
46
|
+
}
|
|
47
|
+
export interface WorkflowSignalResult {
|
|
48
|
+
ok: boolean;
|
|
49
|
+
workflowId: string;
|
|
50
|
+
event: string;
|
|
51
|
+
scheduledJobId: string | null;
|
|
52
|
+
}
|
|
53
|
+
export interface WorkflowResumePayload {
|
|
54
|
+
workflowId: string;
|
|
55
|
+
}
|
|
56
|
+
export interface WorkflowCtx extends GencowCtx {
|
|
57
|
+
workflowId: string;
|
|
58
|
+
workflowName: string;
|
|
59
|
+
step<TResult>(name: string, run: () => Promise<TResult>): Promise<TResult>;
|
|
60
|
+
sleep(duration: WorkflowDuration): Promise<void>;
|
|
61
|
+
waitForEvent<TPayload = unknown>(name: string, timeout?: WorkflowDuration): Promise<TPayload>;
|
|
62
|
+
parallel<TTasks extends ReadonlyArray<() => Promise<any>>>(tasks: TTasks): Promise<{
|
|
63
|
+
[K in keyof TTasks]: Awaited<ReturnType<TTasks[K]>>;
|
|
64
|
+
}>;
|
|
65
|
+
}
|
|
66
|
+
export type WorkflowHandler<TArgs, TReturn> = (wf: WorkflowCtx, args: TArgs) => Promise<TReturn>;
|
|
67
|
+
export interface WorkflowOptions<TSchema = any, TReturn = any> {
|
|
68
|
+
args?: TSchema;
|
|
69
|
+
public?: boolean;
|
|
70
|
+
maxDuration?: WorkflowDuration;
|
|
71
|
+
retries?: number;
|
|
72
|
+
handler: WorkflowHandler<InferArgs<TSchema>, TReturn>;
|
|
73
|
+
}
|
|
74
|
+
export interface WorkflowDef<TSchema = any, TReturn = any> {
|
|
75
|
+
name: string;
|
|
76
|
+
argsSchema?: TSchema;
|
|
77
|
+
isPublic: boolean;
|
|
78
|
+
maxDurationMs: number;
|
|
79
|
+
maxRetries: number;
|
|
80
|
+
handler: WorkflowHandler<InferArgs<TSchema>, TReturn>;
|
|
81
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export function deriveWorkflowStatus(status, currentStep) {
|
|
2
|
+
if (status !== "pending") {
|
|
3
|
+
return status;
|
|
4
|
+
}
|
|
5
|
+
if (currentStep?.startsWith("sleep#")) {
|
|
6
|
+
return "sleeping";
|
|
7
|
+
}
|
|
8
|
+
if (currentStep?.startsWith("wait:")) {
|
|
9
|
+
return "waiting";
|
|
10
|
+
}
|
|
11
|
+
return "queued";
|
|
12
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { MutationDef } from "./reactive.js";
|
|
2
|
+
import type { WorkflowDef, WorkflowDuration, WorkflowOptions, WorkflowStartResult } from "./workflow-types.js";
|
|
3
|
+
declare global {
|
|
4
|
+
var __gencow_workflowRegistry: Map<string, WorkflowDef<any, any>>;
|
|
5
|
+
}
|
|
6
|
+
export declare const DEFAULT_WORKFLOW_MAX_DURATION_MS: number;
|
|
7
|
+
export declare const DEFAULT_WORKFLOW_MAX_RETRIES = 3;
|
|
8
|
+
export declare const WORKFLOW_RESUME_ACTION_PREFIX = "__gencow.workflow.resume";
|
|
9
|
+
export declare const WORKFLOW_REALTIME_KEY_PREFIX = "__gencow.workflow.state";
|
|
10
|
+
type SerializedWorkflowValue = {
|
|
11
|
+
__gencowUndefined: true;
|
|
12
|
+
} | {
|
|
13
|
+
value: unknown;
|
|
14
|
+
};
|
|
15
|
+
export declare function serializeWorkflowValue(value: unknown): SerializedWorkflowValue;
|
|
16
|
+
export declare function deserializeWorkflowValue(value: unknown): unknown;
|
|
17
|
+
export declare function parseWorkflowDurationMs(raw: WorkflowDuration, label?: string): number;
|
|
18
|
+
export declare function getWorkflowResumeActionName(name: string): string;
|
|
19
|
+
export declare function createWorkflowRealtimeToken(): string;
|
|
20
|
+
export declare function getWorkflowRealtimeKey(workflowId: string, realtimeToken: string): string;
|
|
21
|
+
export declare function getWorkflowDef(name: string): WorkflowDef | undefined;
|
|
22
|
+
export declare function getRegisteredWorkflows(): WorkflowDef[];
|
|
23
|
+
/**
|
|
24
|
+
* workflow() — durable multi-step execution with step memoization.
|
|
25
|
+
*
|
|
26
|
+
* The returned value is still a mutation definition, so existing API codegen and
|
|
27
|
+
* frontend hooks keep working without extra workflow-specific tooling.
|
|
28
|
+
*/
|
|
29
|
+
export declare function workflow<TSchema = any, TReturn = any>(name: string, options: WorkflowOptions<TSchema, TReturn>): MutationDef<TSchema, WorkflowStartResult>;
|
|
30
|
+
export {};
|
package/dist/workflow.js
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { sql } from "drizzle-orm";
|
|
2
|
+
import { mutation } from "./reactive.js";
|
|
3
|
+
import { registerWorkflowsApi } from "./workflows-api.js";
|
|
4
|
+
const workflowRegistry = (globalThis.__gencow_workflowRegistry ??= new Map());
|
|
5
|
+
export const DEFAULT_WORKFLOW_MAX_DURATION_MS = 30 * 60 * 1000;
|
|
6
|
+
export const DEFAULT_WORKFLOW_MAX_RETRIES = 3;
|
|
7
|
+
export const WORKFLOW_RESUME_ACTION_PREFIX = "__gencow.workflow.resume";
|
|
8
|
+
export const WORKFLOW_REALTIME_KEY_PREFIX = "__gencow.workflow.state";
|
|
9
|
+
function isSerializedWorkflowValue(value) {
|
|
10
|
+
return !!value && typeof value === "object" && ("__gencowUndefined" in value || "value" in value);
|
|
11
|
+
}
|
|
12
|
+
export function serializeWorkflowValue(value) {
|
|
13
|
+
const payload = value === undefined ? { __gencowUndefined: true } : { value };
|
|
14
|
+
try {
|
|
15
|
+
return JSON.parse(JSON.stringify(payload));
|
|
16
|
+
}
|
|
17
|
+
catch (error) {
|
|
18
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
19
|
+
throw new Error(`workflow() only persists JSON-serializable values. Failed to serialize workflow payload: ${reason}`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export function deserializeWorkflowValue(value) {
|
|
23
|
+
if (!isSerializedWorkflowValue(value))
|
|
24
|
+
return value;
|
|
25
|
+
if ("__gencowUndefined" in value)
|
|
26
|
+
return undefined;
|
|
27
|
+
return value.value;
|
|
28
|
+
}
|
|
29
|
+
function clampRetries(retries) {
|
|
30
|
+
if (retries == null)
|
|
31
|
+
return DEFAULT_WORKFLOW_MAX_RETRIES;
|
|
32
|
+
if (!Number.isFinite(retries) || retries < 0) {
|
|
33
|
+
throw new Error(`workflow() retries must be a non-negative finite number, got "${retries}"`);
|
|
34
|
+
}
|
|
35
|
+
return Math.floor(retries);
|
|
36
|
+
}
|
|
37
|
+
function parseDurationString(raw, label) {
|
|
38
|
+
const normalized = raw.trim().toLowerCase();
|
|
39
|
+
const match = normalized.match(/^(\d+)(ms|s|m|h|d)$/);
|
|
40
|
+
if (!match) {
|
|
41
|
+
throw new Error(`${label} must be a number of ms or a string like "30m", "90s", "1h" — got "${raw}"`);
|
|
42
|
+
}
|
|
43
|
+
const value = Number(match[1]);
|
|
44
|
+
const unit = match[2];
|
|
45
|
+
const unitMs = unit === "ms" ? 1 : unit === "s" ? 1_000 : unit === "m" ? 60_000 : unit === "h" ? 3_600_000 : 86_400_000;
|
|
46
|
+
return value * unitMs;
|
|
47
|
+
}
|
|
48
|
+
export function parseWorkflowDurationMs(raw, label = "workflow duration") {
|
|
49
|
+
if (typeof raw === "number") {
|
|
50
|
+
if (!Number.isFinite(raw) || raw <= 0) {
|
|
51
|
+
throw new Error(`${label} must be a positive finite number, got "${raw}"`);
|
|
52
|
+
}
|
|
53
|
+
return Math.floor(raw);
|
|
54
|
+
}
|
|
55
|
+
if (typeof raw !== "string") {
|
|
56
|
+
throw new Error(`${label} must be a positive finite number or a string like "30m", "90s", "1h" — got "${String(raw)}"`);
|
|
57
|
+
}
|
|
58
|
+
return parseDurationString(raw, label);
|
|
59
|
+
}
|
|
60
|
+
function normalizeMaxDurationMs(maxDuration) {
|
|
61
|
+
if (maxDuration == null)
|
|
62
|
+
return DEFAULT_WORKFLOW_MAX_DURATION_MS;
|
|
63
|
+
return parseWorkflowDurationMs(maxDuration, "workflow() maxDuration");
|
|
64
|
+
}
|
|
65
|
+
export function getWorkflowResumeActionName(name) {
|
|
66
|
+
return `${WORKFLOW_RESUME_ACTION_PREFIX}.${name}`;
|
|
67
|
+
}
|
|
68
|
+
export function createWorkflowRealtimeToken() {
|
|
69
|
+
return crypto.randomUUID().replace(/-/g, "");
|
|
70
|
+
}
|
|
71
|
+
export function getWorkflowRealtimeKey(workflowId, realtimeToken) {
|
|
72
|
+
return `${WORKFLOW_REALTIME_KEY_PREFIX}.${workflowId}.${realtimeToken}`;
|
|
73
|
+
}
|
|
74
|
+
export function getWorkflowDef(name) {
|
|
75
|
+
return workflowRegistry.get(name);
|
|
76
|
+
}
|
|
77
|
+
export function getRegisteredWorkflows() {
|
|
78
|
+
return Array.from(workflowRegistry.values());
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* workflow() — durable multi-step execution with step memoization.
|
|
82
|
+
*
|
|
83
|
+
* The returned value is still a mutation definition, so existing API codegen and
|
|
84
|
+
* frontend hooks keep working without extra workflow-specific tooling.
|
|
85
|
+
*/
|
|
86
|
+
export function workflow(name, options) {
|
|
87
|
+
registerWorkflowsApi();
|
|
88
|
+
const maxDurationMs = normalizeMaxDurationMs(options.maxDuration);
|
|
89
|
+
const maxRetries = clampRetries(options.retries);
|
|
90
|
+
const def = {
|
|
91
|
+
name,
|
|
92
|
+
argsSchema: options.args,
|
|
93
|
+
isPublic: options.public === true,
|
|
94
|
+
maxDurationMs,
|
|
95
|
+
maxRetries,
|
|
96
|
+
handler: options.handler,
|
|
97
|
+
};
|
|
98
|
+
workflowRegistry.set(name, def);
|
|
99
|
+
return mutation(name, {
|
|
100
|
+
args: options.args,
|
|
101
|
+
public: options.public,
|
|
102
|
+
handler: async (ctx, args) => {
|
|
103
|
+
const workflowId = crypto.randomUUID();
|
|
104
|
+
const resumeAction = getWorkflowResumeActionName(name);
|
|
105
|
+
const ownerId = ctx.auth.getUserIdentity()?.id ?? null;
|
|
106
|
+
const persistedArgs = serializeWorkflowValue(args ?? {});
|
|
107
|
+
const realtimeToken = createWorkflowRealtimeToken();
|
|
108
|
+
await ctx.unsafeDb.execute(sql `
|
|
109
|
+
INSERT INTO _gencow_workflows (
|
|
110
|
+
id,
|
|
111
|
+
name,
|
|
112
|
+
args,
|
|
113
|
+
realtime_token,
|
|
114
|
+
status,
|
|
115
|
+
retry_count,
|
|
116
|
+
max_retries,
|
|
117
|
+
max_duration_ms,
|
|
118
|
+
user_id
|
|
119
|
+
)
|
|
120
|
+
VALUES (
|
|
121
|
+
${workflowId},
|
|
122
|
+
${name},
|
|
123
|
+
${JSON.stringify(persistedArgs)}::jsonb,
|
|
124
|
+
${realtimeToken},
|
|
125
|
+
'pending',
|
|
126
|
+
0,
|
|
127
|
+
${maxRetries},
|
|
128
|
+
${maxDurationMs},
|
|
129
|
+
${ownerId}
|
|
130
|
+
)
|
|
131
|
+
`);
|
|
132
|
+
try {
|
|
133
|
+
const scheduledJobId = ctx.scheduler.runAfter(0, resumeAction, { workflowId });
|
|
134
|
+
return {
|
|
135
|
+
id: workflowId,
|
|
136
|
+
name,
|
|
137
|
+
status: "pending",
|
|
138
|
+
scheduledJobId,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
await ctx.unsafeDb.execute(sql `
|
|
143
|
+
DELETE FROM _gencow_workflows
|
|
144
|
+
WHERE id = ${workflowId}
|
|
145
|
+
`);
|
|
146
|
+
throw error;
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { WorkflowSnapshot } from "./workflow-types.js";
|
|
2
|
+
declare global {
|
|
3
|
+
var __gencow_workflowsApiRegistered: boolean | undefined;
|
|
4
|
+
}
|
|
5
|
+
type WorkflowDbLike = {
|
|
6
|
+
execute: (query: unknown) => Promise<unknown>;
|
|
7
|
+
};
|
|
8
|
+
export declare function loadWorkflowSnapshot(db: WorkflowDbLike, workflowId: string, options?: {
|
|
9
|
+
viewerUserId?: string | null;
|
|
10
|
+
requireViewerMatch?: boolean;
|
|
11
|
+
}): Promise<WorkflowSnapshot | null>;
|
|
12
|
+
export declare function registerWorkflowsApi(): void;
|
|
13
|
+
export {};
|