@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.
Files changed (77) hide show
  1. package/dist/crud.d.ts +2 -2
  2. package/dist/crud.js +225 -208
  3. package/dist/index.d.ts +7 -3
  4. package/dist/index.js +4 -1
  5. package/dist/reactive.js +10 -3
  6. package/dist/retry.js +1 -1
  7. package/dist/rls-db.d.ts +2 -2
  8. package/dist/rls-db.js +1 -5
  9. package/dist/scheduler.d.ts +2 -0
  10. package/dist/scheduler.js +16 -6
  11. package/dist/server.d.ts +0 -1
  12. package/dist/server.js +0 -1
  13. package/dist/storage.js +29 -22
  14. package/dist/v.d.ts +2 -2
  15. package/dist/workflow-types.d.ts +81 -0
  16. package/dist/workflow-types.js +12 -0
  17. package/dist/workflow.d.ts +30 -0
  18. package/dist/workflow.js +150 -0
  19. package/dist/workflows-api.d.ts +13 -0
  20. package/dist/workflows-api.js +321 -0
  21. package/package.json +46 -42
  22. package/src/__tests__/auth.test.ts +90 -86
  23. package/src/__tests__/crons.test.ts +69 -67
  24. package/src/__tests__/crud-codegen-integration.test.ts +164 -170
  25. package/src/__tests__/crud-owner-rls.test.ts +308 -301
  26. package/src/__tests__/crud.test.ts +694 -711
  27. package/src/__tests__/dist-exports.test.ts +120 -114
  28. package/src/__tests__/fixtures/basic/auth.ts +16 -16
  29. package/src/__tests__/fixtures/basic/drizzle.config.ts +1 -4
  30. package/src/__tests__/fixtures/basic/index.ts +1 -1
  31. package/src/__tests__/fixtures/basic/schema.ts +1 -1
  32. package/src/__tests__/fixtures/basic/tasks.ts +4 -4
  33. package/src/__tests__/fixtures/common/auth-schema.ts +38 -34
  34. package/src/__tests__/helpers/basic-rls-fixture.ts +80 -78
  35. package/src/__tests__/helpers/pglite-migrations.ts +2 -5
  36. package/src/__tests__/helpers/pglite-rls-session.ts +13 -16
  37. package/src/__tests__/helpers/seed-like-fill.ts +50 -44
  38. package/src/__tests__/helpers/test-gencow-ctx-rls.ts +4 -7
  39. package/src/__tests__/httpaction.test.ts +91 -91
  40. package/src/__tests__/image-optimization.test.ts +570 -574
  41. package/src/__tests__/load.test.ts +321 -308
  42. package/src/__tests__/network-sim.test.ts +238 -215
  43. package/src/__tests__/reactive.test.ts +380 -358
  44. package/src/__tests__/retry.test.ts +99 -84
  45. package/src/__tests__/rls-crud-basic.test.ts +172 -245
  46. package/src/__tests__/rls-crud-no-owner-rls-pglite.test.ts +81 -81
  47. package/src/__tests__/rls-custom-mutation-handlers.test.ts +47 -94
  48. package/src/__tests__/rls-custom-query-handlers.test.ts +92 -92
  49. package/src/__tests__/rls-db-leased-connection.test.ts +2 -6
  50. package/src/__tests__/rls-session-and-policies.test.ts +181 -199
  51. package/src/__tests__/scheduler-durable-v2.test.ts +199 -181
  52. package/src/__tests__/scheduler-durable.test.ts +117 -117
  53. package/src/__tests__/scheduler-exec.test.ts +258 -246
  54. package/src/__tests__/scheduler.test.ts +129 -111
  55. package/src/__tests__/storage.test.ts +282 -269
  56. package/src/__tests__/tsconfig.json +6 -6
  57. package/src/__tests__/validator.test.ts +236 -232
  58. package/src/__tests__/workflow.test.ts +606 -0
  59. package/src/__tests__/ws-integration.test.ts +223 -218
  60. package/src/__tests__/ws-scale.test.ts +168 -159
  61. package/src/auth-config.ts +18 -18
  62. package/src/auth.ts +106 -106
  63. package/src/crons.ts +77 -77
  64. package/src/crud.ts +523 -479
  65. package/src/index.ts +71 -6
  66. package/src/reactive.ts +357 -331
  67. package/src/retry.ts +51 -54
  68. package/src/rls-db.ts +195 -205
  69. package/src/rls.ts +33 -36
  70. package/src/scheduler.ts +237 -211
  71. package/src/server.ts +0 -1
  72. package/src/storage.ts +632 -593
  73. package/src/v.ts +119 -114
  74. package/src/workflow-types.ts +108 -0
  75. package/src/workflow.ts +188 -0
  76. package/src/workflows-api.ts +415 -0
  77. package/src/db.ts +0 -18
package/dist/rls-db.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { PgDatabase } from "drizzle-orm/pg-core";
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: PgDatabase<any, any, any>, rls: RlsSessionContext): PgDatabase<any, any, any>;
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)`);
@@ -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 = { id, action, scheduledAt: new Date().toISOString(), status: "pending" };
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
- }).then(() => {
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
- }).catch((err) => {
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.writeFile(`${filePath}.meta`, JSON.stringify({ name: filename, type, size: buffer.length })).catch(() => { });
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 = config.autoMaxWidth > 0
367
- ? Math.min(appConfig.autoMaxWidth, config.autoMaxWidth)
368
- : appConfig.autoMaxWidth;
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 { /* 파일 없음 or 파싱 실패 → Tier 기본값 유지 */ }
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 { /* fallthrough to 404 */ }
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
- && isTransformable
437
- && clientAcceptsWebp
438
- && config.autoWebp
439
- && (meta.type === "image/png" || meta.type === "image/jpeg" || meta.type === "image/jpg");
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 ? { "Vary": "Accept" } : {}),
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> ? (undefined extends U ? never : K) : K]: T[K] extends Validator<infer U> ? U : any;
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> ? (undefined extends U ? K : never) : never]?: T[K] extends Validator<infer U> ? U : any;
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 {};
@@ -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 {};