@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/src/v.ts
CHANGED
|
@@ -11,106 +11,111 @@
|
|
|
11
11
|
* without fragile string-matching on the error message.
|
|
12
12
|
*/
|
|
13
13
|
export class GencowValidationError extends Error {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
14
|
+
readonly statusCode = 400;
|
|
15
|
+
constructor(message: string) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.name = "GencowValidationError";
|
|
18
|
+
}
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
export type Validator<T = any> = {
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
parse(val: any): T;
|
|
23
|
+
_type: T; // Ghost property for TypeScript inference
|
|
24
24
|
};
|
|
25
25
|
|
|
26
26
|
export type Infer<T> = T extends Validator<infer U> ? U : never;
|
|
27
27
|
|
|
28
28
|
export const v = {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
29
|
+
string(): Validator<string> {
|
|
30
|
+
return {
|
|
31
|
+
parse: (val) => {
|
|
32
|
+
if (typeof val !== "string") throw new Error(`Expected string, got ${typeof val}`);
|
|
33
|
+
return val;
|
|
34
|
+
},
|
|
35
|
+
_type: "" as string,
|
|
36
|
+
};
|
|
37
|
+
},
|
|
38
|
+
number(): Validator<number> {
|
|
39
|
+
return {
|
|
40
|
+
parse: (val) => {
|
|
41
|
+
// Accept numeric strings (e.g. from URL params / form data)
|
|
42
|
+
const n = typeof val === "string" ? Number(val) : val;
|
|
43
|
+
if (typeof n !== "number" || isNaN(n)) throw new Error(`Expected number, got ${typeof val}`);
|
|
44
|
+
return n;
|
|
45
|
+
},
|
|
46
|
+
_type: 0 as number,
|
|
47
|
+
};
|
|
48
|
+
},
|
|
49
|
+
boolean(): Validator<boolean> {
|
|
50
|
+
return {
|
|
51
|
+
parse: (val) => {
|
|
52
|
+
if (typeof val !== "boolean") throw new Error(`Expected boolean, got ${typeof val}`);
|
|
53
|
+
return val;
|
|
54
|
+
},
|
|
55
|
+
_type: false as boolean,
|
|
56
|
+
};
|
|
57
|
+
},
|
|
58
|
+
any(): Validator<any> {
|
|
59
|
+
return {
|
|
60
|
+
parse: (val) => val,
|
|
61
|
+
_type: null as any,
|
|
62
|
+
};
|
|
63
|
+
},
|
|
64
|
+
optional<T>(inner: Validator<T>): Validator<T | undefined> {
|
|
65
|
+
return {
|
|
66
|
+
parse: (val) => {
|
|
67
|
+
if (val === undefined || val === null) return undefined;
|
|
68
|
+
return inner.parse(val);
|
|
69
|
+
},
|
|
70
|
+
_type: undefined as T | undefined,
|
|
71
|
+
};
|
|
72
|
+
},
|
|
73
|
+
object<T extends Record<string, Validator>>(fields: T): Validator<{ [K in keyof T]: Infer<T[K]> }> {
|
|
74
|
+
return {
|
|
75
|
+
parse: (val) => {
|
|
76
|
+
if (typeof val !== "object" || val === null) throw new Error("Expected object");
|
|
77
|
+
const result: any = {};
|
|
78
|
+
for (const key in fields) {
|
|
79
|
+
result[key] = fields[key].parse((val as any)[key]);
|
|
80
|
+
}
|
|
81
|
+
return result;
|
|
82
|
+
},
|
|
83
|
+
_type: {} as any,
|
|
84
|
+
};
|
|
85
|
+
},
|
|
86
|
+
array<T>(inner: Validator<T>): Validator<T[]> {
|
|
87
|
+
return {
|
|
88
|
+
parse: (val) => {
|
|
89
|
+
if (!Array.isArray(val)) throw new Error("Expected array");
|
|
90
|
+
return val.map((item) => inner.parse(item));
|
|
91
|
+
},
|
|
92
|
+
_type: [] as T[],
|
|
93
|
+
};
|
|
94
|
+
},
|
|
95
95
|
};
|
|
96
96
|
|
|
97
97
|
/**
|
|
98
98
|
* Infer TypeScript type from a validator schema or a shorthand record of validators.
|
|
99
99
|
* Correctly handles optional properties (Validator<T | undefined> → optional key).
|
|
100
100
|
*/
|
|
101
|
-
export type InferArgs<T> =
|
|
101
|
+
export type InferArgs<T> =
|
|
102
|
+
T extends Validator<infer U>
|
|
102
103
|
? U
|
|
103
104
|
: T extends Record<string, Validator>
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
105
|
+
? {
|
|
106
|
+
[K in keyof T as T[K] extends Validator<infer U>
|
|
107
|
+
? undefined extends U
|
|
108
|
+
? never
|
|
109
|
+
: K
|
|
110
|
+
: K]: T[K] extends Validator<infer U> ? U : any;
|
|
111
|
+
} & {
|
|
112
|
+
[K in keyof T as T[K] extends Validator<infer U>
|
|
113
|
+
? undefined extends U
|
|
114
|
+
? K
|
|
115
|
+
: never
|
|
116
|
+
: never]?: T[K] extends Validator<infer U> ? U : any;
|
|
117
|
+
}
|
|
118
|
+
: T;
|
|
114
119
|
|
|
115
120
|
/**
|
|
116
121
|
* Validates and parses arguments against a schema at runtime.
|
|
@@ -122,41 +127,41 @@ export type InferArgs<T> = T extends Validator<infer U>
|
|
|
122
127
|
* Throws `GencowValidationError` (HTTP 400) on validation failure.
|
|
123
128
|
*/
|
|
124
129
|
export function parseArgs(schema: any, args: any): any {
|
|
125
|
-
|
|
130
|
+
if (!schema) return args;
|
|
126
131
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
}
|
|
132
|
+
// Direct Validator (has a .parse method)
|
|
133
|
+
if (typeof schema.parse === "function") {
|
|
134
|
+
try {
|
|
135
|
+
return schema.parse(args);
|
|
136
|
+
} catch (e: any) {
|
|
137
|
+
throw new GencowValidationError(e.message);
|
|
134
138
|
}
|
|
139
|
+
}
|
|
135
140
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
+
// Shorthand object — e.g. { id: v.number(), title: v.optional(v.string()) }
|
|
142
|
+
if (typeof schema === "object" && schema !== null) {
|
|
143
|
+
// Empty schema {} → passthrough all args (e.g. FormData with file field)
|
|
144
|
+
const schemaKeys = Object.keys(schema);
|
|
145
|
+
if (schemaKeys.length === 0) return args;
|
|
141
146
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
}
|
|
154
|
-
} else {
|
|
155
|
-
result[key] = args[key];
|
|
156
|
-
}
|
|
147
|
+
if (typeof args !== "object" || args === null) {
|
|
148
|
+
throw new GencowValidationError("Expected an object for arguments");
|
|
149
|
+
}
|
|
150
|
+
const result: any = {};
|
|
151
|
+
for (const key of schemaKeys) {
|
|
152
|
+
const validator = schema[key];
|
|
153
|
+
if (validator && typeof validator.parse === "function") {
|
|
154
|
+
try {
|
|
155
|
+
result[key] = validator.parse(args[key]);
|
|
156
|
+
} catch (e: any) {
|
|
157
|
+
throw new GencowValidationError(`Argument "${key}": ${e.message}`);
|
|
157
158
|
}
|
|
158
|
-
|
|
159
|
+
} else {
|
|
160
|
+
result[key] = args[key];
|
|
161
|
+
}
|
|
159
162
|
}
|
|
163
|
+
return result;
|
|
164
|
+
}
|
|
160
165
|
|
|
161
|
-
|
|
166
|
+
return args;
|
|
162
167
|
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { GencowCtx } from "./reactive.js";
|
|
2
|
+
import type { InferArgs } from "./v.js";
|
|
3
|
+
|
|
4
|
+
export type WorkflowStatus = "pending" | "running" | "completed" | "failed";
|
|
5
|
+
export type WorkflowDuration = number | string;
|
|
6
|
+
export type WorkflowDerivedStatus = WorkflowStatus | "queued" | "waiting" | "sleeping";
|
|
7
|
+
|
|
8
|
+
export function deriveWorkflowStatus(
|
|
9
|
+
status: WorkflowStatus,
|
|
10
|
+
currentStep: string | null | undefined,
|
|
11
|
+
): WorkflowDerivedStatus {
|
|
12
|
+
if (status !== "pending") {
|
|
13
|
+
return status;
|
|
14
|
+
}
|
|
15
|
+
if (currentStep?.startsWith("sleep#")) {
|
|
16
|
+
return "sleeping";
|
|
17
|
+
}
|
|
18
|
+
if (currentStep?.startsWith("wait:")) {
|
|
19
|
+
return "waiting";
|
|
20
|
+
}
|
|
21
|
+
return "queued";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface WorkflowSummary {
|
|
25
|
+
id: string;
|
|
26
|
+
name: string;
|
|
27
|
+
status: WorkflowStatus;
|
|
28
|
+
derivedStatus: WorkflowDerivedStatus;
|
|
29
|
+
currentStep: string | null;
|
|
30
|
+
error: string | null;
|
|
31
|
+
retryCount: number;
|
|
32
|
+
maxRetries: number;
|
|
33
|
+
maxDurationMs: number;
|
|
34
|
+
startedAt: string;
|
|
35
|
+
updatedAt: string;
|
|
36
|
+
completedAt: string | null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface WorkflowStepSnapshot {
|
|
40
|
+
name: string;
|
|
41
|
+
status: WorkflowStatus;
|
|
42
|
+
output: unknown;
|
|
43
|
+
error: string | null;
|
|
44
|
+
startedAt: string | null;
|
|
45
|
+
updatedAt: string;
|
|
46
|
+
completedAt: string | null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface WorkflowSnapshot extends WorkflowSummary {
|
|
50
|
+
args: unknown;
|
|
51
|
+
result: unknown;
|
|
52
|
+
steps: WorkflowStepSnapshot[];
|
|
53
|
+
/** Opaque realtime channel for exact-id workflow updates. */
|
|
54
|
+
realtimeKey: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface WorkflowListArgs {
|
|
58
|
+
limit?: number;
|
|
59
|
+
status?: WorkflowDerivedStatus;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface WorkflowStartResult {
|
|
63
|
+
id: string;
|
|
64
|
+
name: string;
|
|
65
|
+
status: "pending";
|
|
66
|
+
scheduledJobId: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface WorkflowSignalResult {
|
|
70
|
+
ok: boolean;
|
|
71
|
+
workflowId: string;
|
|
72
|
+
event: string;
|
|
73
|
+
scheduledJobId: string | null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface WorkflowResumePayload {
|
|
77
|
+
workflowId: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface WorkflowCtx extends GencowCtx {
|
|
81
|
+
workflowId: string;
|
|
82
|
+
workflowName: string;
|
|
83
|
+
step<TResult>(name: string, run: () => Promise<TResult>): Promise<TResult>;
|
|
84
|
+
sleep(duration: WorkflowDuration): Promise<void>;
|
|
85
|
+
waitForEvent<TPayload = unknown>(name: string, timeout?: WorkflowDuration): Promise<TPayload>;
|
|
86
|
+
parallel<TTasks extends ReadonlyArray<() => Promise<any>>>(
|
|
87
|
+
tasks: TTasks,
|
|
88
|
+
): Promise<{ [K in keyof TTasks]: Awaited<ReturnType<TTasks[K]>> }>;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export type WorkflowHandler<TArgs, TReturn> = (wf: WorkflowCtx, args: TArgs) => Promise<TReturn>;
|
|
92
|
+
|
|
93
|
+
export interface WorkflowOptions<TSchema = any, TReturn = any> {
|
|
94
|
+
args?: TSchema;
|
|
95
|
+
public?: boolean;
|
|
96
|
+
maxDuration?: WorkflowDuration;
|
|
97
|
+
retries?: number;
|
|
98
|
+
handler: WorkflowHandler<InferArgs<TSchema>, TReturn>;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface WorkflowDef<TSchema = any, TReturn = any> {
|
|
102
|
+
name: string;
|
|
103
|
+
argsSchema?: TSchema;
|
|
104
|
+
isPublic: boolean;
|
|
105
|
+
maxDurationMs: number;
|
|
106
|
+
maxRetries: number;
|
|
107
|
+
handler: WorkflowHandler<InferArgs<TSchema>, TReturn>;
|
|
108
|
+
}
|
package/src/workflow.ts
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { sql } from "drizzle-orm";
|
|
2
|
+
import { mutation } from "./reactive.js";
|
|
3
|
+
import type { MutationDef } from "./reactive.js";
|
|
4
|
+
import type {
|
|
5
|
+
WorkflowDef,
|
|
6
|
+
WorkflowDuration,
|
|
7
|
+
WorkflowOptions,
|
|
8
|
+
WorkflowStartResult,
|
|
9
|
+
} from "./workflow-types.js";
|
|
10
|
+
import { registerWorkflowsApi } from "./workflows-api.js";
|
|
11
|
+
|
|
12
|
+
declare global {
|
|
13
|
+
// eslint-disable-next-line no-var
|
|
14
|
+
var __gencow_workflowRegistry: Map<string, WorkflowDef<any, any>>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const workflowRegistry = (globalThis.__gencow_workflowRegistry ??= new Map<string, WorkflowDef<any, any>>());
|
|
18
|
+
|
|
19
|
+
export const DEFAULT_WORKFLOW_MAX_DURATION_MS = 30 * 60 * 1000;
|
|
20
|
+
export const DEFAULT_WORKFLOW_MAX_RETRIES = 3;
|
|
21
|
+
export const WORKFLOW_RESUME_ACTION_PREFIX = "__gencow.workflow.resume";
|
|
22
|
+
export const WORKFLOW_REALTIME_KEY_PREFIX = "__gencow.workflow.state";
|
|
23
|
+
|
|
24
|
+
type SerializedWorkflowValue = { __gencowUndefined: true } | { value: unknown };
|
|
25
|
+
|
|
26
|
+
function isSerializedWorkflowValue(value: unknown): value is SerializedWorkflowValue {
|
|
27
|
+
return !!value && typeof value === "object" && ("__gencowUndefined" in value || "value" in value);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function serializeWorkflowValue(value: unknown): SerializedWorkflowValue {
|
|
31
|
+
const payload = value === undefined ? { __gencowUndefined: true as const } : { value };
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
return JSON.parse(JSON.stringify(payload)) as SerializedWorkflowValue;
|
|
35
|
+
} catch (error) {
|
|
36
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
37
|
+
throw new Error(
|
|
38
|
+
`workflow() only persists JSON-serializable values. Failed to serialize workflow payload: ${reason}`,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function deserializeWorkflowValue(value: unknown): unknown {
|
|
44
|
+
if (!isSerializedWorkflowValue(value)) return value;
|
|
45
|
+
if ("__gencowUndefined" in value) return undefined;
|
|
46
|
+
return value.value;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function clampRetries(retries: number | undefined): number {
|
|
50
|
+
if (retries == null) return DEFAULT_WORKFLOW_MAX_RETRIES;
|
|
51
|
+
if (!Number.isFinite(retries) || retries < 0) {
|
|
52
|
+
throw new Error(`workflow() retries must be a non-negative finite number, got "${retries}"`);
|
|
53
|
+
}
|
|
54
|
+
return Math.floor(retries);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function parseDurationString(raw: string, label: string): number {
|
|
58
|
+
const normalized = raw.trim().toLowerCase();
|
|
59
|
+
const match = normalized.match(/^(\d+)(ms|s|m|h|d)$/);
|
|
60
|
+
if (!match) {
|
|
61
|
+
throw new Error(`${label} must be a number of ms or a string like "30m", "90s", "1h" — got "${raw}"`);
|
|
62
|
+
}
|
|
63
|
+
const value = Number(match[1]);
|
|
64
|
+
const unit = match[2];
|
|
65
|
+
const unitMs =
|
|
66
|
+
unit === "ms" ? 1 : unit === "s" ? 1_000 : unit === "m" ? 60_000 : unit === "h" ? 3_600_000 : 86_400_000;
|
|
67
|
+
return value * unitMs;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function parseWorkflowDurationMs(raw: WorkflowDuration, label = "workflow duration"): number {
|
|
71
|
+
if (typeof raw === "number") {
|
|
72
|
+
if (!Number.isFinite(raw) || raw <= 0) {
|
|
73
|
+
throw new Error(`${label} must be a positive finite number, got "${raw}"`);
|
|
74
|
+
}
|
|
75
|
+
return Math.floor(raw);
|
|
76
|
+
}
|
|
77
|
+
if (typeof raw !== "string") {
|
|
78
|
+
throw new Error(
|
|
79
|
+
`${label} must be a positive finite number or a string like "30m", "90s", "1h" — got "${String(raw)}"`,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
return parseDurationString(raw, label);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function normalizeMaxDurationMs(maxDuration: WorkflowDuration | undefined): number {
|
|
86
|
+
if (maxDuration == null) return DEFAULT_WORKFLOW_MAX_DURATION_MS;
|
|
87
|
+
return parseWorkflowDurationMs(maxDuration, "workflow() maxDuration");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function getWorkflowResumeActionName(name: string): string {
|
|
91
|
+
return `${WORKFLOW_RESUME_ACTION_PREFIX}.${name}`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function createWorkflowRealtimeToken(): string {
|
|
95
|
+
return crypto.randomUUID().replace(/-/g, "");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function getWorkflowRealtimeKey(workflowId: string, realtimeToken: string): string {
|
|
99
|
+
return `${WORKFLOW_REALTIME_KEY_PREFIX}.${workflowId}.${realtimeToken}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function getWorkflowDef(name: string): WorkflowDef | undefined {
|
|
103
|
+
return workflowRegistry.get(name);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function getRegisteredWorkflows(): WorkflowDef[] {
|
|
107
|
+
return Array.from(workflowRegistry.values());
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* workflow() — durable multi-step execution with step memoization.
|
|
112
|
+
*
|
|
113
|
+
* The returned value is still a mutation definition, so existing API codegen and
|
|
114
|
+
* frontend hooks keep working without extra workflow-specific tooling.
|
|
115
|
+
*/
|
|
116
|
+
export function workflow<TSchema = any, TReturn = any>(
|
|
117
|
+
name: string,
|
|
118
|
+
options: WorkflowOptions<TSchema, TReturn>,
|
|
119
|
+
): MutationDef<TSchema, WorkflowStartResult> {
|
|
120
|
+
registerWorkflowsApi();
|
|
121
|
+
|
|
122
|
+
const maxDurationMs = normalizeMaxDurationMs(options.maxDuration);
|
|
123
|
+
const maxRetries = clampRetries(options.retries);
|
|
124
|
+
|
|
125
|
+
const def: WorkflowDef<TSchema, TReturn> = {
|
|
126
|
+
name,
|
|
127
|
+
argsSchema: options.args,
|
|
128
|
+
isPublic: options.public === true,
|
|
129
|
+
maxDurationMs,
|
|
130
|
+
maxRetries,
|
|
131
|
+
handler: options.handler,
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
workflowRegistry.set(name, def);
|
|
135
|
+
|
|
136
|
+
return mutation<TSchema, WorkflowStartResult>(name, {
|
|
137
|
+
args: options.args,
|
|
138
|
+
public: options.public,
|
|
139
|
+
handler: async (ctx, args) => {
|
|
140
|
+
const workflowId = crypto.randomUUID();
|
|
141
|
+
const resumeAction = getWorkflowResumeActionName(name);
|
|
142
|
+
const ownerId = ctx.auth.getUserIdentity()?.id ?? null;
|
|
143
|
+
const persistedArgs = serializeWorkflowValue(args ?? {});
|
|
144
|
+
const realtimeToken = createWorkflowRealtimeToken();
|
|
145
|
+
|
|
146
|
+
await ctx.unsafeDb.execute(sql`
|
|
147
|
+
INSERT INTO _gencow_workflows (
|
|
148
|
+
id,
|
|
149
|
+
name,
|
|
150
|
+
args,
|
|
151
|
+
realtime_token,
|
|
152
|
+
status,
|
|
153
|
+
retry_count,
|
|
154
|
+
max_retries,
|
|
155
|
+
max_duration_ms,
|
|
156
|
+
user_id
|
|
157
|
+
)
|
|
158
|
+
VALUES (
|
|
159
|
+
${workflowId},
|
|
160
|
+
${name},
|
|
161
|
+
${JSON.stringify(persistedArgs)}::jsonb,
|
|
162
|
+
${realtimeToken},
|
|
163
|
+
'pending',
|
|
164
|
+
0,
|
|
165
|
+
${maxRetries},
|
|
166
|
+
${maxDurationMs},
|
|
167
|
+
${ownerId}
|
|
168
|
+
)
|
|
169
|
+
`);
|
|
170
|
+
|
|
171
|
+
try {
|
|
172
|
+
const scheduledJobId = ctx.scheduler.runAfter(0, resumeAction, { workflowId });
|
|
173
|
+
return {
|
|
174
|
+
id: workflowId,
|
|
175
|
+
name,
|
|
176
|
+
status: "pending",
|
|
177
|
+
scheduledJobId,
|
|
178
|
+
};
|
|
179
|
+
} catch (error) {
|
|
180
|
+
await ctx.unsafeDb.execute(sql`
|
|
181
|
+
DELETE FROM _gencow_workflows
|
|
182
|
+
WHERE id = ${workflowId}
|
|
183
|
+
`);
|
|
184
|
+
throw error;
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
});
|
|
188
|
+
}
|