mirror of
https://github.com/wasp-lang/wasp.git
synced 2024-08-16 18:50:44 +03:00
Merge branch 'main' into wasp-ai
This commit is contained in:
commit
16fa3bd487
12
wasp-ai/.gitignore
vendored
12
wasp-ai/.gitignore
vendored
@ -1,3 +1,11 @@
|
||||
/.wasp/
|
||||
/.env.server
|
||||
/.env.client
|
||||
|
||||
# We ignore env files recognized and used by Wasp.
|
||||
.env.server
|
||||
.env.client
|
||||
|
||||
# To be extra safe, we by default ignore any files with `.env` extension in them.
|
||||
# If this is too agressive for you, consider allowing specific files with `!` operator,
|
||||
# or modify/delete these two lines.
|
||||
*.env
|
||||
*.env.*
|
||||
|
@ -1,5 +1,223 @@
|
||||
# Changelog
|
||||
|
||||
## 0.11.4
|
||||
|
||||
### 🎉 [New Feature] Signup Fields Customization
|
||||
|
||||
We added an API for extending the default signup form with custom fields. This allows you to add fields like `age`, `address`, etc. to your signup form.
|
||||
|
||||
You first need to define the `auth.signup.additionalFields` property in your `.wasp` file:
|
||||
```wasp
|
||||
app crudTesting {
|
||||
// ...
|
||||
auth: {
|
||||
userEntity: User,
|
||||
methods: {
|
||||
usernameAndPassword: {},
|
||||
},
|
||||
onAuthFailedRedirectTo: "/login",
|
||||
signup: {
|
||||
additionalFields: import { fields } from "@server/auth.js",
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Then, you need to define the `fields` object in your `auth.js` file:
|
||||
```js
|
||||
import { defineAdditionalSignupFields } from '@wasp/auth/index.js'
|
||||
|
||||
export const fields = defineAdditionalSignupFields({
|
||||
address: (data) => {
|
||||
// Validate the address field
|
||||
if (typeof data.address !== 'string') {
|
||||
throw new Error('Address is required.')
|
||||
}
|
||||
if (data.address.length < 10) {
|
||||
throw new Error('Address must be at least 10 characters long.')
|
||||
}
|
||||
// Return the address field
|
||||
return data.address
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Finally, you can extend the `SignupForm` component on the client:
|
||||
```jsx
|
||||
import { SignupForm } from "@wasp/auth/forms/Signup";
|
||||
|
||||
export const SignupPage = () => {
|
||||
return (
|
||||
<div className="container">
|
||||
<main>
|
||||
<h1>Signup</h1>
|
||||
<SignupForm
|
||||
additionalFields={[
|
||||
{
|
||||
name: "address",
|
||||
label: "Address",
|
||||
type: "input",
|
||||
validations: {
|
||||
required: "Address is required",
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
### 🎉 [New Feature] Support for PostgreSQL Extensions
|
||||
|
||||
Wasp now supports PostgreSQL extensions! You can enable them in your `main.wasp` file:
|
||||
|
||||
```wasp
|
||||
app todoApp {
|
||||
// ...
|
||||
db: {
|
||||
system: PostgreSQL,
|
||||
prisma: {
|
||||
clientPreviewFeatures: ["postgresqlExtensions"],
|
||||
dbExtensions: [{
|
||||
name: "pgvector",
|
||||
// map: "vector", (optional)
|
||||
// schema: "public", (optional)
|
||||
// version: "0.1.0", (optiona)
|
||||
}]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This will add the necessary Prisma configuration to your `schema.prisma` file. Keep in mind that your database needs to support the extension you want to use. For example, if you want to use the `pgvector` extension, you need to install it in your database first.
|
||||
|
||||
### 🎉 [New Feature] Added Typescript support for Jobs
|
||||
|
||||
Now you can type your async jobs better and receive all the benefits of Typescript. When you define a job, Wasp will generate a generic type which you can use to type your job function:
|
||||
|
||||
```wasp
|
||||
job simplePrintJob {
|
||||
executor: PgBoss,
|
||||
perform: {
|
||||
fn: import { simplePrint } from "@server/jobs.js",
|
||||
},
|
||||
entities: [Task]
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
import { SimplePrintJob } from "@wasp/jobs/simplePrintJob";
|
||||
import { Task } from "@wasp/entities";
|
||||
|
||||
export const simplePrint: SimplePrintJob<
|
||||
{ name: string },
|
||||
{ tasks: Task[] }
|
||||
> = async (args, context) => {
|
||||
// 👆 args are typed e.g. { name: string }
|
||||
// 👆 context is typed e.g. { entitites: { Task: ... } }
|
||||
const tasks = await context.entities.Task.findMany({});
|
||||
return {
|
||||
tasks,
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
When you use the job, you can pass the arguments and receive the result with the correct types:
|
||||
|
||||
```typescript
|
||||
import { simplePrintJob } from "@wasp/jobs/simplePrintJob.js";
|
||||
|
||||
...
|
||||
const job = await simplePrintJob.submit({ name: "John" })
|
||||
...
|
||||
const result = await result.pgBoss.details()
|
||||
// 👆 result is typed e.g. { tasks: Task[] }
|
||||
```
|
||||
|
||||
## 0.11.3
|
||||
|
||||
### 🎉 [New Feature] Type-safe links
|
||||
|
||||
Wasp now offers a way to link to pages in your app in a type-safe way. This means that you can't accidentally link to a page that doesn't exist, or pass the wrong arguments to a page.
|
||||
|
||||
After you defined your routes:
|
||||
|
||||
```wasp
|
||||
route TaskRoute { path: "/task/:id", to: TaskPage }
|
||||
```
|
||||
|
||||
You can get the benefits of type-safe links by using the `Link` component from `@wasp/router`:
|
||||
|
||||
```jsx
|
||||
import { Link } from '@wasp/router'
|
||||
|
||||
export const TaskList = () => {
|
||||
// ...
|
||||
|
||||
return (
|
||||
<div>
|
||||
{tasks.map((task) => (
|
||||
<Link
|
||||
key={task.id}
|
||||
to="/task/:id"
|
||||
{/* 👆 You must provide a valid path here */}
|
||||
params={{ id: task.id }}>
|
||||
{/* 👆 All the params must be correctly passed in */}
|
||||
{task.description}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
You can also get all the pages in your app with the `routes` object:
|
||||
|
||||
```jsx
|
||||
import { routes } from '@wasp/router'
|
||||
|
||||
const linkToTask = routes.TaskRoute({ params: { id: 1 } })
|
||||
```
|
||||
|
||||
### 🐞 Bug fixes
|
||||
- Fixes API types exports for TypeScript users.
|
||||
- Default .gitignore that comes with new Wasp project (`wasp new`) is now more aggressive when ignoring .env files, ensuring they don't get committed by accident (wrong name, wrong location, ...).
|
||||
|
||||
|
||||
## 0.11.2
|
||||
|
||||
### 🎉 [New Feature] waspls Code Scaffolding
|
||||
|
||||
When an external import is missing its implementation, waspls now offers a Code Action to quickly scaffold the missing JavaScript or TypeScript function:
|
||||
|
||||
```wasp
|
||||
query getTasks {
|
||||
fn: import { getTasks } from "@server/queries.js",
|
||||
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
// ERROR: `getTasks` is not exported from `src/server/queries.ts`
|
||||
entities: [Task],
|
||||
}
|
||||
```
|
||||
|
||||
Using the code action (pressing <kbd>Ctrl</kbd> + <kbd>.</kbd> or clicking the lightbulb 💡 icon in VSCode) will add the following code to `src/server/queries.ts`:
|
||||
|
||||
```ts
|
||||
import { GetTasks } from '@wasp/queries/types'
|
||||
|
||||
import GetTasksInput = void
|
||||
import GetTasksOutput = void
|
||||
|
||||
export const getTasks: GetTasks<GetTasksInput, GetTasksOutput> = async (args, context) => {
|
||||
// Implementation goes here
|
||||
}
|
||||
```
|
||||
|
||||
### 🐞 Bug fixes / 🔧 small improvements
|
||||
- Wasp copied over the `.env.server` instead of `.env.client` to the client app `.env` file. This prevented using the `.env.client` file in the client app.
|
||||
- waspls thought that importing `"@client/file.jsx"` could mean `"@client/file.tsx"`, which could hide some missing import diagnostics and cause go-to definition to jump to the wrong file.
|
||||
|
||||
|
||||
## 0.11.1
|
||||
|
||||
### 🎉 [New feature] Prisma client preview flags
|
||||
|
@ -65,6 +65,8 @@ If that is the case, relax and feel free to get yourself a cup of coffee! When s
|
||||
|
||||
:warning: You may need to run `cabal update` before attempting to build if it has been some time since your last update.
|
||||
|
||||
:warning: If you are on Mac and get "Couldn't figure out LLVM version!" error message while building, make sure you have LLVM installed and that it is correctly exposed via env vars (PATH, LDFLAGS, CPPFLAGS). The easiest way to do it is by just running `brew install llvm@13`, this should install LLVM and also set up env vars in your `~/.zshrc`.
|
||||
|
||||
### Test
|
||||
```
|
||||
cabal test
|
||||
|
@ -18,7 +18,7 @@ const MainPage = () => {
|
||||
<div className="buttons">
|
||||
<a
|
||||
className="button button-filled"
|
||||
href="https://wasp-lang.dev/docs/tutorials/todo-app"
|
||||
href="https://wasp-lang.dev/docs/tutorial/create"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
|
@ -3,6 +3,9 @@
|
||||
datasource db {
|
||||
provider = "{= datasourceProvider =}"
|
||||
url = {=& datasourceUrl =}
|
||||
{=# dbExtensions =}
|
||||
extensions = {=& . =}
|
||||
{=/ dbExtensions =}
|
||||
}
|
||||
|
||||
generator client {
|
||||
|
@ -7,6 +7,7 @@ import {
|
||||
type State,
|
||||
type CustomizationOptions,
|
||||
type ErrorMessage,
|
||||
type AdditionalSignupFields,
|
||||
} from './types'
|
||||
import { LoginSignupForm } from './internal/common/LoginSignupForm'
|
||||
import { MessageError, MessageSuccess } from './internal/Message'
|
||||
@ -39,9 +40,11 @@ export const AuthContext = createContext({
|
||||
setSuccessMessage: (successMessage: string | null) => {},
|
||||
})
|
||||
|
||||
function Auth ({ state, appearance, logo, socialLayout = 'horizontal' }: {
|
||||
function Auth ({ state, appearance, logo, socialLayout = 'horizontal', additionalSignupFields }: {
|
||||
state: State;
|
||||
} & CustomizationOptions) {
|
||||
} & CustomizationOptions & {
|
||||
additionalSignupFields?: AdditionalSignupFields;
|
||||
}) {
|
||||
const [errorMessage, setErrorMessage] = useState<ErrorMessage | null>(null);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@ -82,6 +85,7 @@ function Auth ({ state, appearance, logo, socialLayout = 'horizontal' }: {
|
||||
<LoginSignupForm
|
||||
state={state}
|
||||
socialButtonsDirection={socialButtonsDirection}
|
||||
additionalSignupFields={additionalSignupFields}
|
||||
/>
|
||||
)}
|
||||
{=# isEmailAuthEnabled =}
|
||||
|
@ -1,17 +1,23 @@
|
||||
import Auth from './Auth'
|
||||
import { type CustomizationOptions, State } from './types'
|
||||
import {
|
||||
type CustomizationOptions,
|
||||
type AdditionalSignupFields,
|
||||
State,
|
||||
} from './types'
|
||||
|
||||
export function SignupForm({
|
||||
appearance,
|
||||
logo,
|
||||
socialLayout,
|
||||
}: CustomizationOptions) {
|
||||
additionalFields,
|
||||
}: CustomizationOptions & { additionalFields?: AdditionalSignupFields; }) {
|
||||
return (
|
||||
<Auth
|
||||
appearance={appearance}
|
||||
logo={logo}
|
||||
socialLayout={socialLayout}
|
||||
state={State.Signup}
|
||||
additionalSignupFields={additionalFields}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
@ -14,9 +14,10 @@ export const FormLabel = styled('label', {
|
||||
display: 'block',
|
||||
fontSize: '$sm',
|
||||
fontWeight: '500',
|
||||
marginBottom: '0.5rem',
|
||||
})
|
||||
|
||||
export const FormInput = styled('input', {
|
||||
const commonInputStyles = {
|
||||
display: 'block',
|
||||
lineHeight: '1.5rem',
|
||||
fontSize: '$sm',
|
||||
@ -44,7 +45,18 @@ export const FormInput = styled('input', {
|
||||
paddingBottom: '0.375rem',
|
||||
paddingLeft: '0.75rem',
|
||||
paddingRight: '0.75rem',
|
||||
margin: 0,
|
||||
}
|
||||
|
||||
export const FormInput = styled('input', commonInputStyles)
|
||||
|
||||
export const FormTextarea = styled('textarea', commonInputStyles)
|
||||
|
||||
export const FormError = styled('div', {
|
||||
display: 'block',
|
||||
fontSize: '$sm',
|
||||
fontWeight: '500',
|
||||
color: '$formErrorText',
|
||||
marginTop: '0.5rem',
|
||||
})
|
||||
|
||||
|
@ -1,10 +1,23 @@
|
||||
{{={= =}=}}
|
||||
import { useContext, type FormEvent } from 'react'
|
||||
import { styled } from '../../../../stitches.config'
|
||||
import config from '../../../../config.js'
|
||||
import { useContext } from 'react'
|
||||
import { useForm, UseFormReturn } from 'react-hook-form'
|
||||
|
||||
import { AuthContext } from '../../Auth'
|
||||
import { Form, FormInput, FormItemGroup, FormLabel, SubmitButton } from '../Form'
|
||||
import {
|
||||
Form,
|
||||
FormInput,
|
||||
FormItemGroup,
|
||||
FormLabel,
|
||||
FormError,
|
||||
FormTextarea,
|
||||
SubmitButton,
|
||||
} from '../Form'
|
||||
import type {
|
||||
AdditionalSignupFields,
|
||||
AdditionalSignupField,
|
||||
AdditionalSignupFieldRenderFn,
|
||||
FormState,
|
||||
} from '../../types'
|
||||
{=# isSocialAuthEnabled =}
|
||||
import * as SocialIcons from '../social/SocialIcons'
|
||||
import { SocialButton } from '../social/SocialButton'
|
||||
@ -97,12 +110,23 @@ const googleSignInUrl = `${config.apiUrl}{= googleSignInPath =}`
|
||||
const gitHubSignInUrl = `${config.apiUrl}{= gitHubSignInPath =}`
|
||||
{=/ isGitHubAuthEnabled =}
|
||||
|
||||
{=!
|
||||
// Since we allow users to add additional fields to the signup form, we don't
|
||||
// know the exact shape of the form values. We are assuming that the form values
|
||||
// will be a flat object with string values.
|
||||
=}
|
||||
export type LoginSignupFormFields = {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
export const LoginSignupForm = ({
|
||||
state,
|
||||
socialButtonsDirection = 'horizontal',
|
||||
additionalSignupFields,
|
||||
}: {
|
||||
state: 'login' | 'signup',
|
||||
socialButtonsDirection?: 'horizontal' | 'vertical';
|
||||
state: 'login' | 'signup'
|
||||
socialButtonsDirection?: 'horizontal' | 'vertical'
|
||||
additionalSignupFields?: AdditionalSignupFields
|
||||
}) => {
|
||||
const {
|
||||
isLoading,
|
||||
@ -110,16 +134,19 @@ export const LoginSignupForm = ({
|
||||
setSuccessMessage,
|
||||
setIsLoading,
|
||||
} = useContext(AuthContext)
|
||||
const cta = state === 'login' ? 'Log in' : 'Sign up';
|
||||
const isLogin = state === 'login'
|
||||
const cta = isLogin ? 'Log in' : 'Sign up';
|
||||
{=# isAnyPasswordBasedAuthEnabled =}
|
||||
const history = useHistory();
|
||||
const onErrorHandler = (error) => {
|
||||
setErrorMessage({ title: error.message, description: error.data?.data?.message })
|
||||
};
|
||||
{=/ isAnyPasswordBasedAuthEnabled =}
|
||||
const hookForm = useForm<LoginSignupFormFields>()
|
||||
const { register, formState: { errors }, handleSubmit: hookFormHandleSubmit } = hookForm
|
||||
{=# isUsernameAndPasswordAuthEnabled =}
|
||||
const { handleSubmit, usernameFieldVal, passwordFieldVal, setUsernameFieldVal, setPasswordFieldVal } = useUsernameAndPassword({
|
||||
isLogin: state === 'login',
|
||||
const { handleSubmit } = useUsernameAndPassword({
|
||||
isLogin,
|
||||
onError: onErrorHandler,
|
||||
onSuccess() {
|
||||
history.push('{= onAuthSucceededRedirectTo =}')
|
||||
@ -127,10 +154,11 @@ export const LoginSignupForm = ({
|
||||
});
|
||||
{=/ isUsernameAndPasswordAuthEnabled =}
|
||||
{=# isEmailAuthEnabled =}
|
||||
const { handleSubmit, emailFieldVal, passwordFieldVal, setEmailFieldVal, setPasswordFieldVal } = useEmail({
|
||||
isLogin: state === 'login',
|
||||
const { handleSubmit } = useEmail({
|
||||
isLogin,
|
||||
onError: onErrorHandler,
|
||||
showEmailVerificationPending() {
|
||||
hookForm.reset()
|
||||
setSuccessMessage(`You've signed up successfully! Check your email for the confirmation link.`)
|
||||
},
|
||||
onLoginSuccess() {
|
||||
@ -145,13 +173,12 @@ export const LoginSignupForm = ({
|
||||
});
|
||||
{=/ isEmailAuthEnabled =}
|
||||
{=# isAnyPasswordBasedAuthEnabled =}
|
||||
async function onSubmit (event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
async function onSubmit (data) {
|
||||
setIsLoading(true);
|
||||
setErrorMessage(null);
|
||||
setSuccessMessage(null);
|
||||
try {
|
||||
await handleSubmit();
|
||||
await handleSubmit(data);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@ -184,41 +211,49 @@ export const LoginSignupForm = ({
|
||||
</OrContinueWith>
|
||||
{=/ areBothSocialAndPasswordBasedAuthEnabled =}
|
||||
{=# isAnyPasswordBasedAuthEnabled =}
|
||||
<Form onSubmit={onSubmit}>
|
||||
<Form onSubmit={hookFormHandleSubmit(onSubmit)}>
|
||||
{=# isUsernameAndPasswordAuthEnabled =}
|
||||
<FormItemGroup>
|
||||
<FormLabel>Username</FormLabel>
|
||||
<FormInput
|
||||
{...register('username', {
|
||||
required: 'Username is required',
|
||||
})}
|
||||
type="text"
|
||||
required
|
||||
value={usernameFieldVal}
|
||||
onChange={e => setUsernameFieldVal(e.target.value)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{errors.username && <FormError>{errors.username.message}</FormError>}
|
||||
</FormItemGroup>
|
||||
{=/ isUsernameAndPasswordAuthEnabled =}
|
||||
{=# isEmailAuthEnabled =}
|
||||
<FormItemGroup>
|
||||
<FormLabel>E-mail</FormLabel>
|
||||
<FormInput
|
||||
{...register('email', {
|
||||
required: 'Email is required',
|
||||
})}
|
||||
type="email"
|
||||
required
|
||||
value={emailFieldVal}
|
||||
onChange={e => setEmailFieldVal(e.target.value)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{errors.email && <FormError>{errors.email.message}</FormError>}
|
||||
</FormItemGroup>
|
||||
{=/ isEmailAuthEnabled =}
|
||||
<FormItemGroup>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormInput
|
||||
{...register('password', {
|
||||
required: 'Password is required',
|
||||
})}
|
||||
type="password"
|
||||
required
|
||||
value={passwordFieldVal}
|
||||
onChange={e => setPasswordFieldVal(e.target.value)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{errors.password && <FormError>{errors.password.message}</FormError>}
|
||||
</FormItemGroup>
|
||||
<AdditionalFormFields
|
||||
hookForm={hookForm}
|
||||
formState={{ isLoading }}
|
||||
additionalSignupFields={additionalSignupFields}
|
||||
/>
|
||||
<FormItemGroup>
|
||||
<SubmitButton type="submit" disabled={isLoading}>{cta}</SubmitButton>
|
||||
</FormItemGroup>
|
||||
@ -226,3 +261,76 @@ export const LoginSignupForm = ({
|
||||
{=/ isAnyPasswordBasedAuthEnabled =}
|
||||
</>)
|
||||
}
|
||||
|
||||
function AdditionalFormFields({
|
||||
hookForm,
|
||||
formState: { isLoading },
|
||||
additionalSignupFields,
|
||||
}: {
|
||||
hookForm: UseFormReturn<LoginSignupFormFields>;
|
||||
formState: FormState;
|
||||
additionalSignupFields: AdditionalSignupFields;
|
||||
}) {
|
||||
const {
|
||||
register,
|
||||
formState: { errors },
|
||||
} = hookForm;
|
||||
|
||||
function renderField<ComponentType extends React.JSXElementConstructor<any>>(
|
||||
field: AdditionalSignupField,
|
||||
// Ideally we would use ComponentType here, but it doesn't work with react-hook-form
|
||||
Component: any,
|
||||
props?: React.ComponentProps<ComponentType>
|
||||
) {
|
||||
return (
|
||||
<FormItemGroup key={field.name}>
|
||||
<FormLabel>{field.label}</FormLabel>
|
||||
<Component
|
||||
{...register(field.name, field.validations)}
|
||||
{...props}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{errors[field.name] && (
|
||||
<FormError>{errors[field.name].message}</FormError>
|
||||
)}
|
||||
</FormItemGroup>
|
||||
);
|
||||
}
|
||||
|
||||
if (areAdditionalFieldsRenderFn(additionalSignupFields)) {
|
||||
return additionalSignupFields(hookForm, { isLoading })
|
||||
}
|
||||
|
||||
return (
|
||||
additionalSignupFields &&
|
||||
additionalSignupFields.map((field) => {
|
||||
if (isFieldRenderFn(field)) {
|
||||
return field(hookForm, { isLoading })
|
||||
}
|
||||
switch (field.type) {
|
||||
case 'input':
|
||||
return renderField<typeof FormInput>(field, FormInput, {
|
||||
type: 'text',
|
||||
})
|
||||
case 'textarea':
|
||||
return renderField<typeof FormTextarea>(field, FormTextarea)
|
||||
default:
|
||||
throw new Error(
|
||||
`Unsupported additional signup field type: ${field.type}`
|
||||
)
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function isFieldRenderFn(
|
||||
additionalSignupField: AdditionalSignupField | AdditionalSignupFieldRenderFn
|
||||
): additionalSignupField is AdditionalSignupFieldRenderFn {
|
||||
return typeof additionalSignupField === 'function'
|
||||
}
|
||||
|
||||
function areAdditionalFieldsRenderFn(
|
||||
additionalSignupFields: AdditionalSignupFields
|
||||
): additionalSignupFields is AdditionalSignupFieldRenderFn {
|
||||
return typeof additionalSignupFields === 'function'
|
||||
}
|
||||
|
@ -1,21 +1,21 @@
|
||||
import { useContext } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { requestPasswordReset } from '../../../email/actions/passwordReset.js'
|
||||
import { useState, useContext, FormEvent } from 'react'
|
||||
import { Form, FormItemGroup, FormLabel, FormInput, SubmitButton } from '../Form'
|
||||
import { Form, FormItemGroup, FormLabel, FormInput, SubmitButton, FormError } from '../Form'
|
||||
import { AuthContext } from '../../Auth'
|
||||
|
||||
export const ForgotPasswordForm = () => {
|
||||
const { register, handleSubmit, reset, formState: { errors } } = useForm<{ email: string }>()
|
||||
const { isLoading, setErrorMessage, setSuccessMessage, setIsLoading } = useContext(AuthContext)
|
||||
const [email, setEmail] = useState('')
|
||||
|
||||
const onSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
const onSubmit = async (data) => {
|
||||
setIsLoading(true)
|
||||
setErrorMessage(null)
|
||||
setSuccessMessage(null)
|
||||
try {
|
||||
await requestPasswordReset({ email })
|
||||
await requestPasswordReset(data)
|
||||
reset()
|
||||
setSuccessMessage('Check your email for a password reset link.')
|
||||
setEmail('')
|
||||
} catch (error) {
|
||||
setErrorMessage({
|
||||
title: error.message,
|
||||
@ -28,16 +28,17 @@ export const ForgotPasswordForm = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form onSubmit={onSubmit}>
|
||||
<Form onSubmit={handleSubmit(onSubmit)}>
|
||||
<FormItemGroup>
|
||||
<FormLabel>E-mail</FormLabel>
|
||||
<FormInput
|
||||
{...register('email', {
|
||||
required: 'Email is required',
|
||||
})}
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{errors.email && <FormError>{errors.email.message}</FormError>}
|
||||
</FormItemGroup>
|
||||
<FormItemGroup>
|
||||
<SubmitButton type="submit" disabled={isLoading}>
|
||||
|
@ -1,19 +1,16 @@
|
||||
import { useContext } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { resetPassword } from '../../../email/actions/passwordReset.js'
|
||||
import { useState, useContext, FormEvent } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import { Form, FormItemGroup, FormLabel, FormInput, SubmitButton } from '../Form'
|
||||
import { Form, FormItemGroup, FormLabel, FormInput, SubmitButton, FormError } from '../Form'
|
||||
import { AuthContext } from '../../Auth'
|
||||
|
||||
export const ResetPasswordForm = () => {
|
||||
const { register, handleSubmit, reset, formState: { errors } } = useForm<{ password: string; passwordConfirmation: string }>()
|
||||
const { isLoading, setErrorMessage, setSuccessMessage, setIsLoading } = useContext(AuthContext)
|
||||
const location = useLocation()
|
||||
const token = new URLSearchParams(location.search).get('token')
|
||||
const [password, setPassword] = useState('')
|
||||
const [passwordConfirmation, setPasswordConfirmation] = useState('')
|
||||
|
||||
const onSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
|
||||
const onSubmit = async (data) => {
|
||||
if (!token) {
|
||||
setErrorMessage({
|
||||
title:
|
||||
@ -22,7 +19,7 @@ export const ResetPasswordForm = () => {
|
||||
return
|
||||
}
|
||||
|
||||
if (!password || password !== passwordConfirmation) {
|
||||
if (!data.password || data.password !== data.passwordConfirmation) {
|
||||
setErrorMessage({ title: `Passwords don't match!` })
|
||||
return
|
||||
}
|
||||
@ -31,10 +28,9 @@ export const ResetPasswordForm = () => {
|
||||
setErrorMessage(null)
|
||||
setSuccessMessage(null)
|
||||
try {
|
||||
await resetPassword({ password, token })
|
||||
await resetPassword({ password: data.password, token })
|
||||
reset()
|
||||
setSuccessMessage('Your password has been reset.')
|
||||
setPassword('')
|
||||
setPasswordConfirmation('')
|
||||
} catch (error) {
|
||||
setErrorMessage({
|
||||
title: error.message,
|
||||
@ -47,26 +43,32 @@ export const ResetPasswordForm = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form onSubmit={onSubmit}>
|
||||
<Form onSubmit={handleSubmit(onSubmit)}>
|
||||
<FormItemGroup>
|
||||
<FormLabel>New password</FormLabel>
|
||||
<FormInput
|
||||
{...register('password', {
|
||||
required: 'Password is required',
|
||||
})}
|
||||
type="password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{errors.passwordConfirmation && (
|
||||
<FormError>{errors.passwordConfirmation.message}</FormError>
|
||||
)}
|
||||
</FormItemGroup>
|
||||
<FormItemGroup>
|
||||
<FormLabel>Confirm new password</FormLabel>
|
||||
<FormInput
|
||||
{...register('passwordConfirmation', {
|
||||
required: 'Password confirmation is required',
|
||||
})}
|
||||
type="password"
|
||||
required
|
||||
value={passwordConfirmation}
|
||||
onChange={(e) => setPasswordConfirmation(e.target.value)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{errors.passwordConfirmation && (
|
||||
<FormError>{errors.passwordConfirmation.message}</FormError>
|
||||
)}
|
||||
</FormItemGroup>
|
||||
<FormItemGroup>
|
||||
<SubmitButton type="submit" disabled={isLoading}>
|
||||
|
@ -1,4 +1,3 @@
|
||||
import { useState } from 'react'
|
||||
import { signup } from '../../../email/actions/signup'
|
||||
import { login } from '../../../email/actions/login'
|
||||
|
||||
@ -15,25 +14,20 @@ export function useEmail({
|
||||
isLogin: boolean
|
||||
isEmailVerificationRequired: boolean
|
||||
}) {
|
||||
const [emailFieldVal, setEmailFieldVal] = useState('')
|
||||
const [passwordFieldVal, setPasswordFieldVal] = useState('')
|
||||
|
||||
async function handleSubmit() {
|
||||
async function handleSubmit(data) {
|
||||
try {
|
||||
if (isLogin) {
|
||||
await login({ email: emailFieldVal, password: passwordFieldVal })
|
||||
await login(data)
|
||||
onLoginSuccess()
|
||||
} else {
|
||||
await signup({ email: emailFieldVal, password: passwordFieldVal })
|
||||
await signup(data)
|
||||
if (isEmailVerificationRequired) {
|
||||
showEmailVerificationPending()
|
||||
} else {
|
||||
await login({ email: emailFieldVal, password: passwordFieldVal })
|
||||
await login(data)
|
||||
onLoginSuccess()
|
||||
}
|
||||
}
|
||||
setEmailFieldVal('')
|
||||
setPasswordFieldVal('')
|
||||
} catch (err: unknown) {
|
||||
onError(err as Error)
|
||||
}
|
||||
@ -41,9 +35,5 @@ export function useEmail({
|
||||
|
||||
return {
|
||||
handleSubmit,
|
||||
emailFieldVal,
|
||||
passwordFieldVal,
|
||||
setEmailFieldVal,
|
||||
setPasswordFieldVal,
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,3 @@
|
||||
import { useState } from 'react'
|
||||
import signup from '../../../signup'
|
||||
import login from '../../../login'
|
||||
|
||||
@ -11,21 +10,13 @@ export function useUsernameAndPassword({
|
||||
onSuccess: () => void
|
||||
isLogin: boolean
|
||||
}) {
|
||||
const [usernameFieldVal, setUsernameFieldVal] = useState('')
|
||||
const [passwordFieldVal, setPasswordFieldVal] = useState('')
|
||||
|
||||
async function handleSubmit() {
|
||||
async function handleSubmit(data) {
|
||||
try {
|
||||
if (!isLogin) {
|
||||
await signup({
|
||||
username: usernameFieldVal,
|
||||
password: passwordFieldVal,
|
||||
})
|
||||
await signup(data)
|
||||
}
|
||||
await login(usernameFieldVal, passwordFieldVal)
|
||||
await login(data.username, data.password)
|
||||
|
||||
setUsernameFieldVal('')
|
||||
setPasswordFieldVal('')
|
||||
onSuccess()
|
||||
} catch (err: unknown) {
|
||||
onError(err as Error)
|
||||
@ -34,9 +25,5 @@ export function useUsernameAndPassword({
|
||||
|
||||
return {
|
||||
handleSubmit,
|
||||
usernameFieldVal,
|
||||
passwordFieldVal,
|
||||
setUsernameFieldVal,
|
||||
setPasswordFieldVal,
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,7 @@
|
||||
{{={= =}=}}
|
||||
import { createTheme } from '@stitches/react'
|
||||
import { UseFormReturn, RegisterOptions } from 'react-hook-form'
|
||||
import type { LoginSignupFormFields } from './internal/common/LoginSignupForm'
|
||||
|
||||
export enum State {
|
||||
Login = 'login',
|
||||
@ -21,3 +23,23 @@ export type ErrorMessage = {
|
||||
title: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export type FormState = {
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
export type AdditionalSignupFieldRenderFn = (
|
||||
hookForm: UseFormReturn<LoginSignupFormFields>,
|
||||
formState: FormState
|
||||
) => React.ReactNode
|
||||
|
||||
export type AdditionalSignupField = {
|
||||
name: string
|
||||
label: string
|
||||
type: 'input' | 'textarea'
|
||||
validations?: RegisterOptions<LoginSignupFormFields>
|
||||
}
|
||||
|
||||
export type AdditionalSignupFields =
|
||||
| (AdditionalSignupField | AdditionalSignupFieldRenderFn)[]
|
||||
| AdditionalSignupFieldRenderFn
|
||||
|
@ -1,2 +1,2 @@
|
||||
// todo(filip): turn into a proper import/path
|
||||
export { type SanitizedUser as User } from '../../../server/src/_types/'
|
||||
export type { SanitizedUser as User } from '../../../server/src/_types/'
|
||||
|
@ -1,6 +1,12 @@
|
||||
{{={= =}=}}
|
||||
import React from 'react'
|
||||
import { Route, Switch, BrowserRouter as Router } from 'react-router-dom'
|
||||
import { interpolatePath } from './router/linkHelpers'
|
||||
import type {
|
||||
RouteDefinitionsToRoutes,
|
||||
OptionalRouteOptions,
|
||||
ParamValue,
|
||||
} from './router/types'
|
||||
{=# rootComponent.isDefined =}
|
||||
{=& rootComponent.importStatement =}
|
||||
{=/ rootComponent.isDefined =}
|
||||
@ -17,15 +23,43 @@ import createAuthRequiredPage from "./auth/pages/createAuthRequiredPage"
|
||||
import OAuthCodeExchange from "./auth/pages/OAuthCodeExchange"
|
||||
{=/ isExternalAuthEnabled =}
|
||||
|
||||
export const routes = {
|
||||
{=# routes =}
|
||||
{= name =}: {
|
||||
to: "{= urlPath =}",
|
||||
component: {= targetComponent =},
|
||||
{=# hasUrlParams =}
|
||||
build: (
|
||||
options: {
|
||||
params: {{=# urlParams =}{= name =}{=# isOptional =}?{=/ isOptional =}: ParamValue;{=/ urlParams =}}
|
||||
} & OptionalRouteOptions,
|
||||
) => interpolatePath("{= urlPath =}", options.params, options.search, options.hash),
|
||||
{=/ hasUrlParams =}
|
||||
{=^ hasUrlParams =}
|
||||
build: (
|
||||
options?: OptionalRouteOptions,
|
||||
) => interpolatePath("{= urlPath =}", undefined, options.search, options.hash),
|
||||
{=/ hasUrlParams =}
|
||||
},
|
||||
{=/ routes =}
|
||||
} as const;
|
||||
|
||||
export type Routes = RouteDefinitionsToRoutes<typeof routes>
|
||||
|
||||
const router = (
|
||||
<Router>
|
||||
{=# rootComponent.isDefined =}
|
||||
<{= rootComponent.importIdentifier =}>
|
||||
{=/ rootComponent.isDefined =}
|
||||
<Switch>
|
||||
{=# routes =}
|
||||
<Route exact path="{= urlPath =}" component={ {= targetComponent =} }/>
|
||||
{=/ routes =}
|
||||
{Object.entries(routes).map(([routeKey, route]) => (
|
||||
<Route
|
||||
exact
|
||||
key={routeKey}
|
||||
path={route.to}
|
||||
component={route.component}
|
||||
/>
|
||||
))}
|
||||
{=# isExternalAuthEnabled =}
|
||||
{=# externalAuthProviders =}
|
||||
{=# authProviderEnabled =}
|
||||
@ -43,3 +77,5 @@ const router = (
|
||||
)
|
||||
|
||||
export default router
|
||||
|
||||
export { Link } from './router/Link'
|
20
waspc/data/Generator/templates/react-app/src/router/Link.tsx
Normal file
20
waspc/data/Generator/templates/react-app/src/router/Link.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import { useMemo } from 'react'
|
||||
import { Link as RouterLink } from 'react-router-dom'
|
||||
import { type Routes } from '../router'
|
||||
import { interpolatePath } from './linkHelpers'
|
||||
|
||||
type RouterLinkProps = Parameters<typeof RouterLink>[0]
|
||||
|
||||
export function Link(
|
||||
{ to, params, search, hash, ...restOfProps }: Omit<RouterLinkProps, "to">
|
||||
& {
|
||||
search?: Record<string, string>;
|
||||
hash?: string;
|
||||
}
|
||||
& Routes
|
||||
) {
|
||||
const toPropWithParams = useMemo(() => {
|
||||
return interpolatePath(to, params, search, hash)
|
||||
}, [to, params])
|
||||
return <RouterLink to={toPropWithParams} {...restOfProps} />
|
||||
}
|
@ -0,0 +1,44 @@
|
||||
import type { Params, Search } from "./types";
|
||||
|
||||
export function interpolatePath(
|
||||
path: string,
|
||||
params?: Params,
|
||||
search?: Search,
|
||||
hash?: string
|
||||
) {
|
||||
const interpolatedPath = params ? interpolatePathParams(path, params) : path
|
||||
const interpolatedSearch = search
|
||||
? `?${new URLSearchParams(search).toString()}`
|
||||
: ''
|
||||
const interpolatedHash = hash ? `#${hash}` : ''
|
||||
return interpolatedPath + interpolatedSearch + interpolatedHash
|
||||
}
|
||||
|
||||
function interpolatePathParams(path: string, params: Params) {
|
||||
function mapPathPart(part: string) {
|
||||
if (part.startsWith(":")) {
|
||||
const paramName = extractParamNameFromPathPart(part);
|
||||
return params[paramName];
|
||||
}
|
||||
return part;
|
||||
}
|
||||
|
||||
const interpolatedPath = path
|
||||
.split("/")
|
||||
.map(mapPathPart)
|
||||
.filter(isValidPathPart)
|
||||
.join("/");
|
||||
|
||||
return path.startsWith("/") ? `/${interpolatedPath}` : interpolatedPath;
|
||||
}
|
||||
|
||||
function isValidPathPart(part: any): boolean {
|
||||
return !!part;
|
||||
}
|
||||
|
||||
function extractParamNameFromPathPart(paramString: string) {
|
||||
if (paramString.endsWith("?")) {
|
||||
return paramString.slice(1, -1);
|
||||
}
|
||||
return paramString.slice(1);
|
||||
}
|
36
waspc/data/Generator/templates/react-app/src/router/types.ts
Normal file
36
waspc/data/Generator/templates/react-app/src/router/types.ts
Normal file
@ -0,0 +1,36 @@
|
||||
export type RouteDefinitionsToRoutes<Routes extends RoutesDefinition> =
|
||||
RouteDefinitionsToRoutesObj<Routes>[keyof RouteDefinitionsToRoutesObj<Routes>]
|
||||
|
||||
export type OptionalRouteOptions = {
|
||||
search?: Search
|
||||
hash?: string
|
||||
}
|
||||
|
||||
export type ParamValue = string | number
|
||||
export type Params = { [name: string]: ParamValue }
|
||||
export type Search =
|
||||
| string[][]
|
||||
| Record<string, string>
|
||||
| string
|
||||
| URLSearchParams
|
||||
|
||||
type RouteDefinitionsToRoutesObj<Routes extends RoutesDefinition> = {
|
||||
[K in keyof Routes]: {
|
||||
to: Routes[K]['to']
|
||||
} & ParamsFromBuildFn<Routes[K]['build']>
|
||||
}
|
||||
|
||||
type RoutesDefinition = {
|
||||
[name: string]: {
|
||||
to: string
|
||||
build: BuildFn
|
||||
}
|
||||
}
|
||||
|
||||
type BuildFn = (params: unknown) => string
|
||||
|
||||
type ParamsFromBuildFn<BF extends BuildFn> = Parameters<BF>[0] extends {
|
||||
params: infer Params
|
||||
}
|
||||
? { params: Params }
|
||||
: { params?: never }
|
@ -12,6 +12,7 @@ export const {
|
||||
gray500: 'gainsboro',
|
||||
gray400: '#f0f0f0',
|
||||
red: '#FED7D7',
|
||||
darkRed: '#fa3838',
|
||||
green: '#C6F6D5',
|
||||
|
||||
brand: '$waspYellow',
|
||||
@ -23,6 +24,7 @@ export const {
|
||||
|
||||
submitButtonText: 'black',
|
||||
|
||||
formErrorText: '$darkRed',
|
||||
},
|
||||
fontSizes: {
|
||||
sm: '0.875rem'
|
||||
|
@ -10,7 +10,7 @@ import { Query } from '../../queries'
|
||||
import config from '../../config'
|
||||
import { HttpMethod, Route } from '../../types'
|
||||
|
||||
export { type Route } from '../../types'
|
||||
export type { Route } from '../../types'
|
||||
|
||||
export type MockQuery = <Input, Output, MockOutput extends Output>(
|
||||
query: Query<Input, Output>,
|
||||
|
@ -4,7 +4,9 @@
|
||||
// Temporary loosen the type checking until we can address all the errors.
|
||||
"jsx": "preserve",
|
||||
"allowJs": true,
|
||||
"strict": false
|
||||
"strict": false,
|
||||
// Allow importing pages with the .tsx extension.
|
||||
"allowImportingTsExtensions": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
|
7
waspc/data/Generator/templates/server/prettier.config.js
Normal file
7
waspc/data/Generator/templates/server/prettier.config.js
Normal file
@ -0,0 +1,7 @@
|
||||
// Used for internal Wasp development only, not copied to generated app.
|
||||
module.exports = {
|
||||
trailingComma: 'es5',
|
||||
tabWidth: 2,
|
||||
semi: false,
|
||||
singleQuote: true,
|
||||
}
|
@ -66,7 +66,7 @@ type EntityMap<Entities extends _Entity[]> = {
|
||||
[EntityName in Entities[number]["_entityName"]]: PrismaDelegate[EntityName]
|
||||
}
|
||||
|
||||
type PrismaDelegate = {
|
||||
export type PrismaDelegate = {
|
||||
{=# entities =}
|
||||
"{= name =}": typeof prisma.{= prismaIdentifier =},
|
||||
{=/ entities =}
|
||||
|
@ -1,35 +1,43 @@
|
||||
export type Payload = void | SuperJSONValue;
|
||||
export type Payload = void | SuperJSONValue
|
||||
|
||||
// The part below was copied from SuperJSON and slightly modified:
|
||||
// https://github.com/blitz-js/superjson/blob/ae7dbcefe5d3ece5b04be0c6afe6b40f3a44a22a/src/types.ts
|
||||
//
|
||||
// We couldn't use SuperJSON's types directly because:
|
||||
// 1. They aren't exported publicly.
|
||||
// 2. They have a werid quirk that turns `SuperJSONValue` into `any`.
|
||||
// See why here:
|
||||
// 2. They have a werid quirk that turns `SuperJSONValue` into `any`.
|
||||
// See why here:
|
||||
// https://github.com/blitz-js/superjson/pull/36#issuecomment-669239876
|
||||
//
|
||||
// We changed the code as little as possible to make future comparisons easier.
|
||||
export type JSONValue = PrimitiveJSONValue | JSONArray | JSONObject
|
||||
|
||||
type PrimitiveJSONValue = string | number | boolean | undefined | null;
|
||||
|
||||
type JSONValue = PrimitiveJSONValue | JSONArray | JSONObject;
|
||||
|
||||
interface JSONArray extends Array<JSONValue> {
|
||||
export interface JSONObject {
|
||||
[key: string]: JSONValue
|
||||
}
|
||||
|
||||
interface JSONObject {
|
||||
[key: string]: JSONValue;
|
||||
}
|
||||
type PrimitiveJSONValue = string | number | boolean | undefined | null
|
||||
|
||||
type SerializableJSONValue = Symbol | Set<SuperJSONValue> | Map<SuperJSONValue, SuperJSONValue> | undefined | bigint | Date | RegExp;
|
||||
interface JSONArray extends Array<JSONValue> {}
|
||||
|
||||
// Here's where we excluded `ClassInstance` (which was `any`) from the union.
|
||||
type SuperJSONValue = JSONValue | SerializableJSONValue | SuperJSONArray | SuperJSONObject;
|
||||
type SerializableJSONValue =
|
||||
| Symbol
|
||||
| Set<SuperJSONValue>
|
||||
| Map<SuperJSONValue, SuperJSONValue>
|
||||
| undefined
|
||||
| bigint
|
||||
| Date
|
||||
| RegExp
|
||||
|
||||
interface SuperJSONArray extends Array<SuperJSONValue> {
|
||||
}
|
||||
// Here's where we excluded `ClassInstance` (which was `any`) from the union.
|
||||
type SuperJSONValue =
|
||||
| JSONValue
|
||||
| SerializableJSONValue
|
||||
| SuperJSONArray
|
||||
| SuperJSONObject
|
||||
|
||||
interface SuperJSONArray extends Array<SuperJSONValue> {}
|
||||
|
||||
interface SuperJSONObject {
|
||||
[key: string]: SuperJSONValue;
|
||||
[key: string]: SuperJSONValue
|
||||
}
|
||||
|
@ -2,7 +2,7 @@
|
||||
|
||||
import { type ParamsDictionary as ExpressParams, type Query as ExpressQuery } from 'express-serve-static-core'
|
||||
|
||||
export { ParamsDictionary as ExpressParams, Query as ExpressQuery } from 'express-serve-static-core'
|
||||
export type { ParamsDictionary as ExpressParams, Query as ExpressQuery } from 'express-serve-static-core'
|
||||
|
||||
import {
|
||||
{=# allEntities =}
|
||||
|
@ -22,6 +22,14 @@ app.use((err, _req, res, next) => {
|
||||
return res.status(err.statusCode).json({ message: err.message, data: err.data })
|
||||
}
|
||||
|
||||
// This forwards the error to the default express error handler.
|
||||
// As described by expressjs documentation, the default error handler sets response status
|
||||
// to err.status or err.statusCode if it is 4xx or 5xx, and if not, sets it to 500.
|
||||
// It won't add any more info to it if server is running in production, which is exactly what we want,
|
||||
// we want to share as little info as possible when error happens in production, for security reasons,
|
||||
// so they will get only status code if set, or 500 if not, no extra info.
|
||||
// In development it will also share the error stack though, which is useful.
|
||||
// If the user wants to put more information about the error into the response, they should use HttpError.
|
||||
return next(err)
|
||||
})
|
||||
|
||||
|
7
waspc/data/Generator/templates/server/src/auth/index.ts
Normal file
7
waspc/data/Generator/templates/server/src/auth/index.ts
Normal file
@ -0,0 +1,7 @@
|
||||
{{={= =}=}}
|
||||
{=# isEmailAuthEnabled =}
|
||||
export { defineAdditionalSignupFields } from './providers/email/types.js';
|
||||
{=/ isEmailAuthEnabled =}
|
||||
{=# isLocalAuthEnabled =}
|
||||
export { defineAdditionalSignupFields } from './providers/local/types.js';
|
||||
{=/ isLocalAuthEnabled =}
|
@ -11,6 +11,7 @@ import {
|
||||
isEmailResendAllowed,
|
||||
} from "../../utils.js";
|
||||
import { GetVerificationEmailContentFn } from './types.js';
|
||||
import { validateAndGetAdditionalFields } from '../../utils.js'
|
||||
|
||||
export function getSignupRoute({
|
||||
fromField,
|
||||
@ -41,8 +42,11 @@ export function getSignupRoute({
|
||||
}
|
||||
await deleteUser(existingUser);
|
||||
}
|
||||
|
||||
const additionalFields = await validateAndGetAdditionalFields(userFields);
|
||||
|
||||
const user = await createUser({
|
||||
...additionalFields,
|
||||
email: userFields.email,
|
||||
password: userFields.password,
|
||||
});
|
||||
|
@ -1,3 +1,5 @@
|
||||
import { createDefineAdditionalSignupFieldsFn } from '../types.js'
|
||||
|
||||
export type GetVerificationEmailContentFn = (params: { verificationLink: string }) => EmailContent;
|
||||
|
||||
export type GetPasswordResetEmailContentFn = (params: { passwordResetLink: string }) => EmailContent;
|
||||
@ -11,3 +13,5 @@ type EmailContent = {
|
||||
export const tokenVerificationErrors = {
|
||||
TokenExpiredError: 'TokenExpiredError',
|
||||
};
|
||||
|
||||
export const defineAdditionalSignupFields = createDefineAdditionalSignupFieldsFn<"email" | "password">()
|
||||
|
@ -1,11 +1,15 @@
|
||||
{{={= =}=}}
|
||||
import { handleRejection } from '../../../utils.js'
|
||||
import { createUser } from '../../utils.js'
|
||||
import { validateAndGetAdditionalFields } from '../../utils.js'
|
||||
|
||||
export default handleRejection(async (req, res) => {
|
||||
const userFields = req.body || {}
|
||||
|
||||
const additionalFields = await validateAndGetAdditionalFields(userFields)
|
||||
|
||||
await createUser({
|
||||
...additionalFields,
|
||||
username: userFields.username,
|
||||
password: userFields.password,
|
||||
})
|
||||
|
@ -0,0 +1,3 @@
|
||||
import { createDefineAdditionalSignupFieldsFn } from '../types.js'
|
||||
|
||||
export const defineAdditionalSignupFields = createDefineAdditionalSignupFieldsFn<"username" | "password">()
|
@ -1,4 +1,6 @@
|
||||
import type { Router, Request } from "express"
|
||||
import type { Router, Request } from 'express'
|
||||
import type { User } from '../../entities'
|
||||
import type { Expand } from '../../universal/types'
|
||||
|
||||
export type ProviderConfig = {
|
||||
// Unique provider identifier, used as part of URL paths
|
||||
@ -17,3 +19,23 @@ export type InitData = {
|
||||
}
|
||||
|
||||
export type RequestWithWasp = Request & { wasp?: { [key: string]: any } }
|
||||
|
||||
export function createDefineAdditionalSignupFieldsFn<
|
||||
// Wasp already includes these fields in the signup process
|
||||
ExistingFields extends keyof User,
|
||||
PossibleAdditionalFields = Expand<
|
||||
Partial<Omit<User, ExistingFields>>
|
||||
>
|
||||
>() {
|
||||
return function defineFields(config: {
|
||||
[key in keyof PossibleAdditionalFields]: FieldGetter<
|
||||
PossibleAdditionalFields[key]
|
||||
>
|
||||
}) {
|
||||
return config
|
||||
}
|
||||
}
|
||||
|
||||
type FieldGetter<T> = (
|
||||
data: { [key: string]: unknown }
|
||||
) => Promise<T | undefined> | T | undefined
|
||||
|
@ -12,6 +12,19 @@ import { isValidEmail } from '../core/auth/validators.js'
|
||||
import { emailSender } from '../email/index.js';
|
||||
import { Email } from '../email/core/types.js';
|
||||
{=/ isEmailAuthEnabled =}
|
||||
{=# additionalSignupFields.isDefined =}
|
||||
{=& additionalSignupFields.importStatement =}
|
||||
{=/ additionalSignupFields.isDefined =}
|
||||
|
||||
{=# additionalSignupFields.isDefined =}
|
||||
const _waspAdditionalSignupFieldsConfig = {= additionalSignupFields.importIdentifier =}
|
||||
{=/ additionalSignupFields.isDefined =}
|
||||
{=^ additionalSignupFields.isDefined =}
|
||||
import { createDefineAdditionalSignupFieldsFn } from './providers/types.js'
|
||||
const _waspAdditionalSignupFieldsConfig = {} as ReturnType<
|
||||
ReturnType<typeof createDefineAdditionalSignupFieldsFn<never>>
|
||||
>
|
||||
{=/ additionalSignupFields.isDefined =}
|
||||
|
||||
type {= userEntityUpper =}Id = {= userEntityUpper =}['id']
|
||||
|
||||
@ -218,4 +231,23 @@ function rethrowPossiblePrismaError(e: unknown): void {
|
||||
|
||||
function throwValidationError(message: string): void {
|
||||
throw new HttpError(422, 'Validation failed', { message })
|
||||
}
|
||||
}
|
||||
|
||||
export async function validateAndGetAdditionalFields(data: {
|
||||
[key: string]: unknown
|
||||
}) {
|
||||
const {
|
||||
password: _password,
|
||||
...sanitizedData
|
||||
} = data;
|
||||
const result: Record<string, any> = {};
|
||||
for (const [field, getFieldValue] of Object.entries(_waspAdditionalSignupFieldsConfig)) {
|
||||
try {
|
||||
const value = await getFieldValue(sanitizedData)
|
||||
result[field] = value
|
||||
} catch (e) {
|
||||
throwValidationError(e.message)
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
@ -1,16 +0,0 @@
|
||||
{{={= =}=}}
|
||||
import prisma from '../dbClient.js'
|
||||
import { createJob } from './{= executorJobRelFP =}'
|
||||
{=& jobPerformFnImportStatement =}
|
||||
|
||||
export const {= jobName =} = createJob({
|
||||
jobName: "{= jobName =}",
|
||||
jobFn: {= jobPerformFnName =},
|
||||
defaultJobOptions: {=& jobPerformOptions =},
|
||||
jobSchedule: {=& jobSchedule =},
|
||||
entities: {
|
||||
{=# entities =}
|
||||
{= name =}: prisma.{= prismaIdentifier =},
|
||||
{=/ entities =}
|
||||
},
|
||||
})
|
21
waspc/data/Generator/templates/server/src/jobs/_job.ts
Normal file
21
waspc/data/Generator/templates/server/src/jobs/_job.ts
Normal file
@ -0,0 +1,21 @@
|
||||
{{={= =}=}}
|
||||
import prisma from '../dbClient.js'
|
||||
import type { JSONValue, JSONObject } from '../_types/serialization.js'
|
||||
import { createJob, type JobFn } from './{= jobExecutorRelativePath =}'
|
||||
{=& jobPerformFnImportStatement =}
|
||||
|
||||
const entities = {
|
||||
{=# entities =}
|
||||
{= name =}: prisma.{= prismaIdentifier =},
|
||||
{=/ entities =}
|
||||
};
|
||||
|
||||
export type {= typeName =}<Input extends JSONObject, Output extends JSONValue | void> = JobFn<Input, Output, typeof entities>
|
||||
|
||||
export const {= jobName =} = createJob({
|
||||
jobName: "{= jobName =}",
|
||||
jobFn: {= jobPerformFnName =},
|
||||
defaultJobOptions: {=& jobPerformOptions =},
|
||||
jobSchedule: {=& jobSchedule =},
|
||||
entities,
|
||||
})
|
@ -1,36 +0,0 @@
|
||||
/**
|
||||
* This is a definition of a job (think draft or invocable computation), not the running instance itself.
|
||||
* This can be submitted one or more times to be executed by some job executor via the same instance.
|
||||
* Once submitted, you get a SubmittedJob to track it later.
|
||||
*/
|
||||
export class Job {
|
||||
#jobName
|
||||
#executorName
|
||||
|
||||
/**
|
||||
* @param {string} jobName - Job name, which should be unique per executor.
|
||||
* @param {string} executorName - The name of the executor that will run submitted jobs.
|
||||
*/
|
||||
constructor(jobName, executorName) {
|
||||
this.#jobName = jobName
|
||||
this.#executorName = executorName
|
||||
}
|
||||
|
||||
get jobName() {
|
||||
return this.#jobName
|
||||
}
|
||||
|
||||
get executorName() {
|
||||
return this.#executorName
|
||||
}
|
||||
|
||||
// NOTE: Subclasses must implement this method.
|
||||
delay(...args) {
|
||||
throw new Error('Subclasses must implement this method')
|
||||
}
|
||||
|
||||
// NOTE: Subclasses must implement this method.
|
||||
async submit(...args) {
|
||||
throw new Error('Subclasses must implement this method')
|
||||
}
|
||||
}
|
@ -1,29 +0,0 @@
|
||||
/**
|
||||
* This is the result of submitting a Job to some executor.
|
||||
* It can be used by callers to track things, or call executor-specific subclass functionality.
|
||||
*/
|
||||
export class SubmittedJob {
|
||||
#job
|
||||
#jobId
|
||||
|
||||
/**
|
||||
* @param {Job} job - The Job that submitted work to an executor.
|
||||
* @param {string} jobId - A UUID for a submitted job in that executor's ecosystem.
|
||||
*/
|
||||
constructor(job, jobId) {
|
||||
this.#job = job
|
||||
this.#jobId = jobId
|
||||
}
|
||||
|
||||
get jobId() {
|
||||
return this.#jobId
|
||||
}
|
||||
|
||||
get jobName() {
|
||||
return this.#job.jobName
|
||||
}
|
||||
|
||||
get executorName() {
|
||||
return this.#job.executorName
|
||||
}
|
||||
}
|
28
waspc/data/Generator/templates/server/src/jobs/core/job.ts
Normal file
28
waspc/data/Generator/templates/server/src/jobs/core/job.ts
Normal file
@ -0,0 +1,28 @@
|
||||
/**
|
||||
* This is a definition of a job (think draft or invocable computation), not the running instance itself.
|
||||
* This can be submitted one or more times to be executed by some job executor via the same instance.
|
||||
* Once submitted, you get a SubmittedJob to track it later.
|
||||
*/
|
||||
export class Job {
|
||||
public readonly jobName: string
|
||||
public readonly executorName: string | symbol
|
||||
|
||||
constructor(jobName: string, executorName: string | symbol) {
|
||||
this.jobName = jobName
|
||||
this.executorName = executorName
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This is the result of submitting a Job to some executor.
|
||||
* It can be used by callers to track things, or call executor-specific subclass functionality.
|
||||
*/
|
||||
export class SubmittedJob {
|
||||
public readonly job: Job
|
||||
public readonly jobId: string
|
||||
|
||||
constructor(job: Job, jobId: string) {
|
||||
this.job = job
|
||||
this.jobId = jobId
|
||||
}
|
||||
}
|
@ -12,46 +12,51 @@ function createPgBoss() {
|
||||
if (process.env.PG_BOSS_NEW_OPTIONS) {
|
||||
try {
|
||||
pgBossNewOptions = JSON.parse(process.env.PG_BOSS_NEW_OPTIONS)
|
||||
}
|
||||
catch {
|
||||
console.error("Environment variable PG_BOSS_NEW_OPTIONS was not parsable by JSON.parse()!")
|
||||
} catch {
|
||||
console.error(
|
||||
'Environment variable PG_BOSS_NEW_OPTIONS was not parsable by JSON.parse()!'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return new PgBoss(pgBossNewOptions)
|
||||
}
|
||||
|
||||
let resolvePgBossStarted, rejectPgBossStarted
|
||||
let resolvePgBossStarted: (boss: PgBoss) => void
|
||||
let rejectPgBossStarted: (boss: PgBoss) => void
|
||||
// Code that wants to access pg-boss must wait until it has been started.
|
||||
export const pgBossStarted = new Promise((resolve, reject) => {
|
||||
export const pgBossStarted = new Promise<PgBoss>((resolve, reject) => {
|
||||
resolvePgBossStarted = resolve
|
||||
rejectPgBossStarted = reject
|
||||
})
|
||||
|
||||
// Ensure pg-boss can only be started once during a server's lifetime.
|
||||
const PgBossStatus = {
|
||||
Unstarted: 'Unstarted',
|
||||
Starting: 'Starting',
|
||||
Started: 'Started',
|
||||
Error: 'Error'
|
||||
enum PgBossStatus {
|
||||
Unstarted = 'Unstarted',
|
||||
Starting = 'Starting',
|
||||
Started = 'Started',
|
||||
Error = 'Error',
|
||||
}
|
||||
let pgBossStatus = PgBossStatus.Unstarted
|
||||
|
||||
let pgBossStatus: PgBossStatus = PgBossStatus.Unstarted
|
||||
|
||||
/**
|
||||
* Prepares the target PostgreSQL database and begins job monitoring.
|
||||
* If the required database objects do not exist in the specified database,
|
||||
* `boss.start()` will automatically create them.
|
||||
* Ref: https://github.com/timgit/pg-boss/blob/master/docs/readme.md#start
|
||||
*
|
||||
*
|
||||
* After making this call, we can send pg-boss jobs and they will be persisted and acted upon.
|
||||
* This should only be called once during a server's lifetime.
|
||||
*/
|
||||
export async function startPgBoss() {
|
||||
if (pgBossStatus !== PgBossStatus.Unstarted) { return }
|
||||
export async function startPgBoss(): Promise<void> {
|
||||
// Ensure pg-boss can only be started once during a server's lifetime.
|
||||
if (pgBossStatus !== PgBossStatus.Unstarted) {
|
||||
return
|
||||
}
|
||||
pgBossStatus = PgBossStatus.Starting
|
||||
console.log('Starting pg-boss...')
|
||||
|
||||
boss.on('error', error => console.error(error))
|
||||
boss.on('error', (error) => console.error(error))
|
||||
try {
|
||||
await boss.start()
|
||||
} catch (error) {
|
@ -1,120 +0,0 @@
|
||||
import { pgBossStarted } from './pgBoss.js'
|
||||
import { Job } from '../Job.js'
|
||||
import { SubmittedJob } from '../SubmittedJob.js'
|
||||
|
||||
export const PG_BOSS_EXECUTOR_NAME = Symbol('PgBoss')
|
||||
|
||||
/**
|
||||
* A pg-boss specific SubmittedJob that adds additional pg-boss functionality.
|
||||
*/
|
||||
class PgBossSubmittedJob extends SubmittedJob {
|
||||
constructor(boss, job, jobId) {
|
||||
super(job, jobId)
|
||||
this.pgBoss = {
|
||||
cancel: () => boss.cancel(jobId),
|
||||
resume: () => boss.resume(jobId),
|
||||
details: () => boss.getJobById(jobId),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This is a class repesenting a job that can be submitted to pg-boss.
|
||||
* It is not yet submitted until the caller invokes `submit()` on an instance.
|
||||
* The caller can make as many calls to `submit()` as they wish.
|
||||
*/
|
||||
class PgBossJob extends Job {
|
||||
#defaultJobOptions
|
||||
#startAfter
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} jobName - The name of the Job. This is what will show up in the pg-boss DB tables.
|
||||
* @param {object} defaultJobOptions - Default options passed to `boss.send()`.
|
||||
* Ref: https://github.com/timgit/pg-boss/blob/master/docs/readme.md#sendname-data-options
|
||||
* @param {int | string | date} startAfter - Defers job execution. See `delay()` below for more.
|
||||
*/
|
||||
constructor(jobName, defaultJobOptions, startAfter = undefined) {
|
||||
super(jobName, PG_BOSS_EXECUTOR_NAME)
|
||||
this.#defaultJobOptions = defaultJobOptions
|
||||
this.#startAfter = startAfter
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {int | string | date} startAfter - Defers job execution by either:
|
||||
* - int: Seconds to delay starting the job [Default: 0]
|
||||
* - string: Start after a UTC Date time string in 8601 format
|
||||
* - Date: Start after a Date object
|
||||
*/
|
||||
delay(startAfter) {
|
||||
return new PgBossJob(this.jobName, this.#defaultJobOptions, startAfter)
|
||||
}
|
||||
|
||||
/**
|
||||
* Submits the job to pg-boss.
|
||||
* @param {object} jobArgs - The job arguments supplied by the user for their perform callback.
|
||||
* @param {object} jobOptions - pg-boss specific options for `boss.send()`, which can override their defaultJobOptions.
|
||||
*/
|
||||
async submit(jobArgs, jobOptions = {}) {
|
||||
const boss = await pgBossStarted
|
||||
const jobId = await boss.send(this.jobName, jobArgs,
|
||||
{ ...this.#defaultJobOptions, ...(this.#startAfter && { startAfter: this.#startAfter }), ...jobOptions })
|
||||
return new PgBossSubmittedJob(boss, this, jobId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an instance of PgBossJob and initializes the PgBoss executor by registering this job function.
|
||||
* We expect this to be called once per job name. If called multiple times with the same name and different
|
||||
* functions, we will override the previous calls.
|
||||
* @param {string} jobName - The user-defined job name in their .wasp file.
|
||||
* @param {fn} jobFn - The user-defined async job callback function.
|
||||
* @param {object} defaultJobOptions - pg-boss specific options for `boss.send()` applied to every `submit()` invocation,
|
||||
* which can overriden in that call.
|
||||
* @param {object} jobSchedule [Optional] - The 5-field cron string, job function JSON arg, and `boss.send()` options when invoking the job.
|
||||
* @param {array} entities - Entities used by job, passed into callback context.
|
||||
*/
|
||||
export function createJob({ jobName, jobFn, defaultJobOptions, jobSchedule, entities } = {}) {
|
||||
// NOTE(shayne): We are not awaiting `pgBossStarted` here since we need to return an instance to the job
|
||||
// template, or else the NodeJS module bootstrapping process will block and fail as it would then depend
|
||||
// on a runtime resolution of the promise in `startServer()`.
|
||||
// Since `pgBossStarted` will resolve in the future, it may appear possible to send pg-boss
|
||||
// a job before we actually have registered the handler via `boss.work()`. However, even if NodeJS does
|
||||
// not execute this callback before any job `submit()` calls, this is not a problem since pg-boss allows you
|
||||
// to submit jobs even if there are no workers registered.
|
||||
// Once they are registered, they will just start on the first job in their queue.
|
||||
pgBossStarted.then(async (boss) => {
|
||||
// As a safety precaution against undefined behavior of registering different
|
||||
// functions for the same job name, remove all registered functions first.
|
||||
await boss.offWork(jobName)
|
||||
|
||||
// This tells pg-boss to run given worker function when job with that name is submitted.
|
||||
// Ref: https://github.com/timgit/pg-boss/blob/master/docs/readme.md#work
|
||||
await boss.work(jobName, pgBossCallbackWrapper(jobFn, entities))
|
||||
|
||||
// If a job schedule is provided, we should schedule the recurring job.
|
||||
// If the schedule name already exists, it's updated to the provided cron expression, arguments, and options.
|
||||
// Ref: https://github.com/timgit/pg-boss/blob/master/docs/readme.md#scheduling
|
||||
if (jobSchedule) {
|
||||
const options = { ...defaultJobOptions, ...jobSchedule.options }
|
||||
await boss.schedule(jobName, jobSchedule.cron, jobSchedule.args || null, options)
|
||||
}
|
||||
})
|
||||
|
||||
return new PgBossJob(jobName, defaultJobOptions)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps the normal pg-boss callback function to inject entities, as well as extract
|
||||
* the `data` property so the arguments passed into the job are the exact same as those received.
|
||||
*
|
||||
* @param {fn} jobFn - The user-defined async job callback function.
|
||||
* @param {array} entities - Entities used by job, passed into callback context.
|
||||
* @returns a function that accepts the pg-boss callback arguments and invokes the user-defined callback.
|
||||
*/
|
||||
function pgBossCallbackWrapper(jobFn, entities) {
|
||||
return (args) => {
|
||||
const context = { entities }
|
||||
return jobFn(args.data, context)
|
||||
}
|
||||
}
|
@ -0,0 +1,206 @@
|
||||
import PgBoss from 'pg-boss'
|
||||
import { pgBossStarted } from './pgBoss.js'
|
||||
import { Job, SubmittedJob } from '../job.js'
|
||||
import type { JSONValue, JSONObject } from '../../../_types/serialization.js'
|
||||
import { PrismaDelegate } from '../../../_types/index.js'
|
||||
|
||||
export const PG_BOSS_EXECUTOR_NAME = Symbol('PgBoss')
|
||||
|
||||
/**
|
||||
* Creates an instance of PgBossJob and initializes the PgBoss executor by registering this job function.
|
||||
* We expect this to be called once per job name. If called multiple times with the same name and different
|
||||
* functions, we will override the previous calls.
|
||||
*/
|
||||
export function createJob<
|
||||
Input extends JSONObject,
|
||||
Output extends JSONValue | void,
|
||||
Entities extends Partial<PrismaDelegate>
|
||||
>({
|
||||
jobName,
|
||||
jobFn,
|
||||
defaultJobOptions,
|
||||
jobSchedule,
|
||||
entities,
|
||||
}: {
|
||||
// jobName - The user-defined job name in their .wasp file.
|
||||
jobName: Parameters<PgBoss['schedule']>[0]
|
||||
// jobFn - The user-defined async job callback function.
|
||||
jobFn: JobFn<Input, Output, Entities>
|
||||
// defaultJobOptions - pg-boss specific options for `boss.send()` applied to every `submit()` invocation,
|
||||
// which can overriden in that call.
|
||||
defaultJobOptions: PgBoss.Schedule['options']
|
||||
jobSchedule?: {
|
||||
cron: Parameters<PgBoss['schedule']>[1]
|
||||
args: Parameters<PgBoss['schedule']>[2]
|
||||
options: Parameters<PgBoss['schedule']>[3]
|
||||
}
|
||||
// Entities used by job, passed into callback context.
|
||||
entities: Entities
|
||||
}) {
|
||||
// NOTE(shayne): We are not awaiting `pgBossStarted` here since we need to return an instance to the job
|
||||
// template, or else the NodeJS module bootstrapping process will block and fail as it would then depend
|
||||
// on a runtime resolution of the promise in `startServer()`.
|
||||
// Since `pgBossStarted` will resolve in the future, it may appear possible to send pg-boss
|
||||
// a job before we actually have registered the handler via `boss.work()`. However, even if NodeJS does
|
||||
// not execute this callback before any job `submit()` calls, this is not a problem since pg-boss allows you
|
||||
// to submit jobs even if there are no workers registered.
|
||||
// Once they are registered, they will just start on the first job in their queue.
|
||||
pgBossStarted.then(async (boss) => {
|
||||
// As a safety precaution against undefined behavior of registering different
|
||||
// functions for the same job name, remove all registered functions first.
|
||||
await boss.offWork(jobName)
|
||||
|
||||
// This tells pg-boss to run given worker function when job with that name is submitted.
|
||||
// Ref: https://github.com/timgit/pg-boss/blob/master/docs/readme.md#work
|
||||
await boss.work<Input, Output>(
|
||||
jobName,
|
||||
pgBossCallbackWrapper<Input, Output, Entities>(jobFn, entities)
|
||||
)
|
||||
|
||||
// If a job schedule is provided, we should schedule the recurring job.
|
||||
// If the schedule name already exists, it's updated to the provided cron expression, arguments, and options.
|
||||
// Ref: https://github.com/timgit/pg-boss/blob/master/docs/readme.md#scheduling
|
||||
if (jobSchedule) {
|
||||
const options: PgBoss.ScheduleOptions = {
|
||||
...defaultJobOptions,
|
||||
...jobSchedule.options,
|
||||
}
|
||||
await boss.schedule(
|
||||
jobName,
|
||||
jobSchedule.cron,
|
||||
jobSchedule.args || null,
|
||||
options
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
return new PgBossJob<Input, Output>(jobName, defaultJobOptions)
|
||||
}
|
||||
|
||||
export type JobFn<
|
||||
Input extends JSONObject,
|
||||
Output extends JSONValue | void,
|
||||
Entities extends Partial<PrismaDelegate>
|
||||
> = (data: Input, context: { entities: Entities }) => Promise<Output>
|
||||
|
||||
/**
|
||||
* This is an interface repesenting a job that can be submitted to pg-boss.
|
||||
* It is not yet submitted until the caller invokes `submit()` on an instance.
|
||||
* The caller can make as many calls to `submit()` as they wish.
|
||||
*/
|
||||
class PgBossJob<
|
||||
Input extends JSONObject,
|
||||
Output extends JSONValue | void
|
||||
> extends Job {
|
||||
public readonly defaultJobOptions: Parameters<PgBoss['send']>[2]
|
||||
public readonly startAfter: number | string | Date
|
||||
|
||||
constructor(
|
||||
jobName: string,
|
||||
defaultJobOptions: Parameters<PgBoss['send']>[2],
|
||||
startAfter?: number | string | Date
|
||||
) {
|
||||
super(jobName, PG_BOSS_EXECUTOR_NAME)
|
||||
this.defaultJobOptions = defaultJobOptions
|
||||
this.startAfter = startAfter
|
||||
}
|
||||
delay(startAfter: number | string | Date) {
|
||||
return new PgBossJob<Input, Output>(
|
||||
this.jobName,
|
||||
this.defaultJobOptions,
|
||||
startAfter
|
||||
)
|
||||
}
|
||||
async submit(jobArgs: Input, jobOptions: Parameters<PgBoss['send']>[2] = {}) {
|
||||
const boss = await pgBossStarted
|
||||
const jobId = await (boss.send as any)(this.jobName, jobArgs, {
|
||||
...this.defaultJobOptions,
|
||||
...(this.startAfter && { startAfter: this.startAfter }),
|
||||
...jobOptions,
|
||||
})
|
||||
return new PgBossSubmittedJob<Input, Output>(boss, this, jobId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A pg-boss specific SubmittedJob that adds additional pg-boss functionality.
|
||||
*/
|
||||
class PgBossSubmittedJob<
|
||||
Input extends JSONObject,
|
||||
Output extends JSONValue | void
|
||||
> extends SubmittedJob {
|
||||
public readonly pgBoss: {
|
||||
readonly cancel: () => ReturnType<PgBoss['cancel']>
|
||||
readonly resume: () => ReturnType<PgBoss['resume']>
|
||||
readonly details: () => Promise<PgBossDetails<Input, Output> | null>
|
||||
}
|
||||
|
||||
constructor(
|
||||
boss: PgBoss,
|
||||
job: PgBossJob<Input, Output>,
|
||||
jobId: SubmittedJob['jobId']
|
||||
) {
|
||||
super(job, jobId)
|
||||
this.pgBoss = {
|
||||
cancel: () => boss.cancel(jobId),
|
||||
resume: () => boss.resume(jobId),
|
||||
// Coarcing here since pg-boss typings are not precise enough.
|
||||
details: () =>
|
||||
boss.getJobById(jobId) as Promise<PgBossDetails<Input, Output> | null>,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps the normal pg-boss callback function to inject entities, as well as extract
|
||||
* the `data` property so the arguments passed into the job are the exact same as those received.
|
||||
*/
|
||||
function pgBossCallbackWrapper<
|
||||
Input extends JSONObject,
|
||||
Output extends JSONValue | void,
|
||||
Entities extends Partial<PrismaDelegate>
|
||||
>(
|
||||
// jobFn - The user-defined async job callback function.
|
||||
jobFn: JobFn<Input, Output, Entities>,
|
||||
// Entities used by job, passed into callback context.
|
||||
entities: Entities
|
||||
) {
|
||||
return (args: { data: Input }) => {
|
||||
const context = { entities }
|
||||
return jobFn(args.data, context)
|
||||
}
|
||||
}
|
||||
|
||||
// Overrides the default pg-boss JobWithMetadata type to provide more
|
||||
// type safety.
|
||||
type PgBossDetails<
|
||||
Input extends JSONObject,
|
||||
Output extends JSONValue | void
|
||||
> = Omit<PgBoss.JobWithMetadata<Input>, 'state' | 'output'> & {
|
||||
data: Input
|
||||
} & (
|
||||
| {
|
||||
state: 'completed'
|
||||
output: JobOutputToMetadataOutput<Output>
|
||||
}
|
||||
| {
|
||||
state: 'failed'
|
||||
output: object
|
||||
}
|
||||
| {
|
||||
state: 'created' | 'retry' | 'active' | 'expired' | 'cancelled'
|
||||
output: null
|
||||
}
|
||||
)
|
||||
|
||||
// pg-boss wraps primitive values in an object with a `value` property.
|
||||
// https://github.com/timgit/pg-boss/blob/master/src/manager.js#L526
|
||||
type JobOutputToMetadataOutput<JobOutput> = JobOutput extends
|
||||
| null
|
||||
| undefined
|
||||
| void
|
||||
| Function
|
||||
? null
|
||||
: JobOutput extends object
|
||||
? JobOutput
|
||||
: { value: JobOutput }
|
@ -1,47 +0,0 @@
|
||||
import { sleep } from '../../utils.js'
|
||||
import { Job } from './Job.js'
|
||||
import { SubmittedJob } from './SubmittedJob.js'
|
||||
|
||||
export const SIMPLE_EXECUTOR_NAME = Symbol('Simple')
|
||||
|
||||
/**
|
||||
* A simple job mainly intended for testing. It will not submit work to any
|
||||
* job executor, but instead will simply invoke the underlying perform function.
|
||||
* It does not support `schedule`. It does not require any extra NPM dependencies
|
||||
* or infrastructure, however.
|
||||
*/
|
||||
class SimpleJob extends Job {
|
||||
#jobFn
|
||||
#delaySeconds
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} jobName - Name of the Job.
|
||||
* @param {fn} jobFn - The Job function to execute.
|
||||
* @param {int} delaySeconds - The number of seconds to delay invoking the Job function.
|
||||
*/
|
||||
constructor(jobName, jobFn, delaySeconds = 0) {
|
||||
super(jobName, SIMPLE_EXECUTOR_NAME)
|
||||
this.#jobFn = jobFn
|
||||
this.#delaySeconds = delaySeconds
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {int} delaySeconds - Used to delay the processing of the job by some number of seconds.
|
||||
*/
|
||||
delay(delaySeconds) {
|
||||
return new SimpleJob(this.jobName, this.#jobFn, delaySeconds)
|
||||
}
|
||||
|
||||
async submit(jobArgs) {
|
||||
sleep(this.#delaySeconds * 1000).then(() => this.#jobFn(jobArgs))
|
||||
// NOTE: Dumb random ID generator, mainly so we don't have to add `uuid`
|
||||
// as a dependency in the server generator for something nobody will likely use.
|
||||
const jobId = (Math.random() + 1).toString(36).substring(7)
|
||||
return new SubmittedJob(this, jobId)
|
||||
}
|
||||
}
|
||||
|
||||
export function createJob({ jobName, jobFn } = {}) {
|
||||
return new SimpleJob(jobName, jobFn)
|
||||
}
|
@ -11,7 +11,7 @@ export type ServerSetupFnContext = {
|
||||
}
|
||||
|
||||
export type { Application } from 'express'
|
||||
export { Server } from 'http'
|
||||
export type { Server } from 'http'
|
||||
|
||||
{=# isExternalAuthEnabled =}
|
||||
export type { GetUserFieldsFn } from '../auth/providers/oauth/types';
|
||||
|
12
waspc/data/Lsp/templates/ts/action.fn.ts
Normal file
12
waspc/data/Lsp/templates/ts/action.fn.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { {{upperDeclName}} } from '@wasp/actions/types'
|
||||
|
||||
type {{upperDeclName}}Input = void
|
||||
type {{upperDeclName}}Output = void
|
||||
|
||||
{{#named?}}export {{/named?}}const {{name}}: {{upperDeclName}}<{{upperDeclName}}Input, {{upperDeclName}}Output> = async (args, context) => {
|
||||
// Implementation goes here
|
||||
}
|
||||
|
||||
{{#default?}}
|
||||
export default {{name}}
|
||||
{{/default?}}
|
7
waspc/data/Lsp/templates/ts/operation.fn.js
Normal file
7
waspc/data/Lsp/templates/ts/operation.fn.js
Normal file
@ -0,0 +1,7 @@
|
||||
{{#named?}}export {{/named?}}const {{name}} = async (args, context) => {
|
||||
// Implementation goes here
|
||||
}
|
||||
|
||||
{{#default?}}
|
||||
export default {{name}}
|
||||
{{/default?}}
|
9
waspc/data/Lsp/templates/ts/page.component.jsx
Normal file
9
waspc/data/Lsp/templates/ts/page.component.jsx
Normal file
@ -0,0 +1,9 @@
|
||||
{{#named?}}export {{/named?}}function {{name}}() {
|
||||
return (
|
||||
<div>Hello world!</div>
|
||||
)
|
||||
}
|
||||
|
||||
{{#default?}}
|
||||
export default {{name}}
|
||||
{{/default?}}
|
12
waspc/data/Lsp/templates/ts/query.fn.ts
Normal file
12
waspc/data/Lsp/templates/ts/query.fn.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { {{upperDeclName}} } from '@wasp/queries/types'
|
||||
|
||||
type {{upperDeclName}}Input = void
|
||||
type {{upperDeclName}}Output = void
|
||||
|
||||
{{#named?}}export {{/named?}}const {{name}}: {{upperDeclName}}<{{upperDeclName}}Input, {{upperDeclName}}Output> = async (args, context) => {
|
||||
// Implementation goes here
|
||||
}
|
||||
|
||||
{{#default?}}
|
||||
export default {{name}}
|
||||
{{/default?}}
|
@ -28,7 +28,12 @@ waspComplexTest = do
|
||||
<++> addClientSetup
|
||||
<++> addServerSetup
|
||||
<++> addGoogleAuth
|
||||
<++> sequence
|
||||
[ -- Prerequisite for jobs
|
||||
setDbToPSQL
|
||||
]
|
||||
<++> addJob
|
||||
<++> addTsJob
|
||||
<++> addAction
|
||||
<++> addQuery
|
||||
<++> addApi
|
||||
@ -95,8 +100,7 @@ addServerSetup = do
|
||||
addJob :: ShellCommandBuilder [ShellCommand]
|
||||
addJob = do
|
||||
sequence
|
||||
[ setDbToPSQL,
|
||||
appendToWaspFile jobDecl,
|
||||
[ appendToWaspFile jobDecl,
|
||||
createFile jobFile "./src/server/jobs" "bar.js"
|
||||
]
|
||||
where
|
||||
@ -117,6 +121,32 @@ addJob = do
|
||||
"}"
|
||||
]
|
||||
|
||||
addTsJob :: ShellCommandBuilder [ShellCommand]
|
||||
addTsJob = do
|
||||
sequence
|
||||
[ appendToWaspFile jobDecl,
|
||||
createFile jobFile "./src/server/jobs" "returnHello.ts"
|
||||
]
|
||||
where
|
||||
jobDecl =
|
||||
unlines
|
||||
[ "job ReturnHelloJob {",
|
||||
" executor: PgBoss,",
|
||||
" perform: {",
|
||||
" fn: import { returnHello } from \"@server/jobs/returnHello.js\",",
|
||||
" },",
|
||||
" entities: [User],",
|
||||
"}"
|
||||
]
|
||||
|
||||
jobFile =
|
||||
unlines
|
||||
[ "import { ReturnHelloJob } from '@wasp/jobs/ReturnHelloJob'",
|
||||
"export const returnHello: ReturnHelloJob<{ name: string }, string> = async (args) => {",
|
||||
" return args.name",
|
||||
"}"
|
||||
]
|
||||
|
||||
addServerEnvFile :: ShellCommandBuilder [ShellCommand]
|
||||
addServerEnvFile = do
|
||||
sequence [createFile envFileContents "./" ".env.server"]
|
||||
|
@ -20,12 +20,6 @@ waspBuild/.wasp/build/server/src/core/HttpError.js
|
||||
waspBuild/.wasp/build/server/src/dbClient.ts
|
||||
waspBuild/.wasp/build/server/src/dbSeed/types.ts
|
||||
waspBuild/.wasp/build/server/src/entities/index.ts
|
||||
waspBuild/.wasp/build/server/src/jobs/core/Job.js
|
||||
waspBuild/.wasp/build/server/src/jobs/core/SubmittedJob.js
|
||||
waspBuild/.wasp/build/server/src/jobs/core/allJobs.js
|
||||
waspBuild/.wasp/build/server/src/jobs/core/pgBoss/pgBoss.js
|
||||
waspBuild/.wasp/build/server/src/jobs/core/pgBoss/pgBossJob.js
|
||||
waspBuild/.wasp/build/server/src/jobs/core/simpleJob.js
|
||||
waspBuild/.wasp/build/server/src/middleware/globalMiddleware.ts
|
||||
waspBuild/.wasp/build/server/src/middleware/index.ts
|
||||
waspBuild/.wasp/build/server/src/middleware/operations.ts
|
||||
@ -68,7 +62,10 @@ waspBuild/.wasp/build/web-app/src/queries/core.js
|
||||
waspBuild/.wasp/build/web-app/src/queries/index.d.ts
|
||||
waspBuild/.wasp/build/web-app/src/queries/index.js
|
||||
waspBuild/.wasp/build/web-app/src/queryClient.js
|
||||
waspBuild/.wasp/build/web-app/src/router.jsx
|
||||
waspBuild/.wasp/build/web-app/src/router.tsx
|
||||
waspBuild/.wasp/build/web-app/src/router/Link.tsx
|
||||
waspBuild/.wasp/build/web-app/src/router/linkHelpers.ts
|
||||
waspBuild/.wasp/build/web-app/src/router/types.ts
|
||||
waspBuild/.wasp/build/web-app/src/storage.ts
|
||||
waspBuild/.wasp/build/web-app/src/test/index.ts
|
||||
waspBuild/.wasp/build/web-app/src/test/vitest/helpers.tsx
|
||||
|
@ -74,14 +74,14 @@
|
||||
"file",
|
||||
"server/src/_types/index.ts"
|
||||
],
|
||||
"9a8b137a7bcfcf994927032964cf3ea31327352b7fc711dcfa6b9eecea721fe5"
|
||||
"b6b5d08ff823cf66d47932167a4008307acb53cef2e409f26621e8b268ba8008"
|
||||
],
|
||||
[
|
||||
[
|
||||
"file",
|
||||
"server/src/_types/serialization.ts"
|
||||
],
|
||||
"69f96a24a9c1af30c9f6f3b9f4aef7f3f62e226ef0864332527e74c542e47467"
|
||||
"73dc983c9a02cab8e2f189f1ddedd9cdcd47bf403c9dc56efba8be895b6980a1"
|
||||
],
|
||||
[
|
||||
[
|
||||
@ -102,7 +102,7 @@
|
||||
"file",
|
||||
"server/src/app.js"
|
||||
],
|
||||
"12291f83f685eeb81a8c082961120d4bddb91985092eb142964e554a21d3384c"
|
||||
"86504ede1daeca35cc93f665ca8ac2fdf46ecaff02f6d3a7810a14d2bc71e16a"
|
||||
],
|
||||
[
|
||||
[
|
||||
@ -146,48 +146,6 @@
|
||||
],
|
||||
"c59b97b122b977b5171686c92ee5ff2d80d397c2e83cc0915affb6ee136406fb"
|
||||
],
|
||||
[
|
||||
[
|
||||
"file",
|
||||
"server/src/jobs/core/Job.js"
|
||||
],
|
||||
"e0e5d5e802a29032bfc8426097950722ac0dc7931d08641c1c2b02c262e6cdcc"
|
||||
],
|
||||
[
|
||||
[
|
||||
"file",
|
||||
"server/src/jobs/core/SubmittedJob.js"
|
||||
],
|
||||
"75753277b6bd2c1d2e9ea0e80a71c72c84fa18bb7d61da25d798b3ef247e06bd"
|
||||
],
|
||||
[
|
||||
[
|
||||
"file",
|
||||
"server/src/jobs/core/allJobs.js"
|
||||
],
|
||||
"90b1b3012216900efa82fff14911dcbf195fa2e449edb3b24ab80db0065d796f"
|
||||
],
|
||||
[
|
||||
[
|
||||
"file",
|
||||
"server/src/jobs/core/pgBoss/pgBoss.js"
|
||||
],
|
||||
"9821963d90b39058285343834c70e6f825d3d7696c738fd95539614b5e7d7b94"
|
||||
],
|
||||
[
|
||||
[
|
||||
"file",
|
||||
"server/src/jobs/core/pgBoss/pgBossJob.js"
|
||||
],
|
||||
"ff6040d051c916eb080a2f2c37fd5135f588782387faeae51115d1a7abd1ad8b"
|
||||
],
|
||||
[
|
||||
[
|
||||
"file",
|
||||
"server/src/jobs/core/simpleJob.js"
|
||||
],
|
||||
"36fe173d9f5128859196bfd3a661983df2d95eb34d165a469b840982b06cf59b"
|
||||
],
|
||||
[
|
||||
[
|
||||
"file",
|
||||
@ -242,7 +200,7 @@
|
||||
"file",
|
||||
"server/src/types/index.ts"
|
||||
],
|
||||
"1fd50e251e340a5bc8c51369766e8c889cf892cdbe6593b4d58a6ee585b6d2cc"
|
||||
"1958cfc3e3b5f59490168797e4b8dcdc38f32346e734f90df3fb6baa264b36b5"
|
||||
],
|
||||
[
|
||||
[
|
||||
@ -312,7 +270,7 @@
|
||||
"file",
|
||||
"web-app/package.json"
|
||||
],
|
||||
"ee9766b7c88b3d4ac36b1dd4b3237ea750e4c26ad27bcd18006225707d042e18"
|
||||
"8bacfb3d4e24886405c2a8fa94be0be7d3ec4b882063e5f22667893811cd4371"
|
||||
],
|
||||
[
|
||||
[
|
||||
@ -403,7 +361,7 @@
|
||||
"file",
|
||||
"web-app/src/ext-src/MainPage.jsx"
|
||||
],
|
||||
"7244c106359f088fdcc0d4a76ee63277f1cea63cbe7aac5e5c39a17df693b1e2"
|
||||
"8ee7fe1352719facadf0935eb45df8661ba13015277ea80be5a9200c66a31bde"
|
||||
],
|
||||
[
|
||||
[
|
||||
@ -492,9 +450,30 @@
|
||||
[
|
||||
[
|
||||
"file",
|
||||
"web-app/src/router.jsx"
|
||||
"web-app/src/router.tsx"
|
||||
],
|
||||
"c1188ee948f821f5e3e7e741f54b503afffe4bdb24b8f528c32aee0990cf5457"
|
||||
"79192ef1e636c2573815ca7151cf8e3a76ebb00a0b0ee3e804cbb60cba5f7197"
|
||||
],
|
||||
[
|
||||
[
|
||||
"file",
|
||||
"web-app/src/router/Link.tsx"
|
||||
],
|
||||
"7b6214295d59d8dffbd61b82f9dab2b080b2d7ebe98cc7d9f9e8c229f99a890d"
|
||||
],
|
||||
[
|
||||
[
|
||||
"file",
|
||||
"web-app/src/router/linkHelpers.ts"
|
||||
],
|
||||
"c296ed5e7924ad1173f4f0fb4dcce053cffd5812612069b5f62d1bf9e96495cf"
|
||||
],
|
||||
[
|
||||
[
|
||||
"file",
|
||||
"web-app/src/router/types.ts"
|
||||
],
|
||||
"7f08b262987c17f953c4b95814631a7aaac82eb77660bcb247ef7bf866846fe1"
|
||||
],
|
||||
[
|
||||
[
|
||||
@ -515,7 +494,7 @@
|
||||
"file",
|
||||
"web-app/src/test/vitest/helpers.tsx"
|
||||
],
|
||||
"a0576215b0786c6e082b8a83f6418a3acb084bfb673a152baad8e8a7fc46fcae"
|
||||
"a38e55c9999a87ab497538bcad7c880f32a4d27f2227ae326cb76eb0848b89e9"
|
||||
],
|
||||
[
|
||||
[
|
||||
@ -564,7 +543,7 @@
|
||||
"file",
|
||||
"web-app/tsconfig.json"
|
||||
],
|
||||
"887c55937264ea8b2c538340962c3011091cf3eb6b9d39523acbe8ebcdd35474"
|
||||
"f1b31ca75b2b32c5b0441aec4fcd7f285c18346761ba1640761d6253d65e3580"
|
||||
],
|
||||
[
|
||||
[
|
||||
|
@ -1 +1 @@
|
||||
{"npmDepsForServer":{"dependencies":[{"name":"cookie-parser","version":"~1.4.6"},{"name":"cors","version":"^2.8.5"},{"name":"express","version":"~4.18.1"},{"name":"morgan","version":"~1.10.0"},{"name":"@prisma/client","version":"4.12.0"},{"name":"jsonwebtoken","version":"^8.5.1"},{"name":"secure-password","version":"^4.0.0"},{"name":"dotenv","version":"16.0.2"},{"name":"helmet","version":"^6.0.0"},{"name":"patch-package","version":"^6.4.7"},{"name":"uuid","version":"^9.0.0"},{"name":"lodash.merge","version":"^4.6.2"},{"name":"rate-limiter-flexible","version":"^2.4.1"},{"name":"superjson","version":"^1.12.2"}],"devDependencies":[{"name":"nodemon","version":"^2.0.19"},{"name":"standard","version":"^17.0.0"},{"name":"prisma","version":"4.12.0"},{"name":"typescript","version":"^5.1.0"},{"name":"@types/express","version":"^4.17.13"},{"name":"@types/express-serve-static-core","version":"^4.17.13"},{"name":"@types/node","version":"^18.11.9"},{"name":"@tsconfig/node18","version":"^1.0.1"},{"name":"@types/uuid","version":"^9.0.0"},{"name":"@types/cors","version":"^2.8.5"}]},"npmDepsForWebApp":{"dependencies":[{"name":"axios","version":"^1.4.0"},{"name":"react","version":"^18.2.0"},{"name":"react-dom","version":"^18.2.0"},{"name":"@tanstack/react-query","version":"^4.29.0"},{"name":"react-router-dom","version":"^5.3.3"},{"name":"@prisma/client","version":"4.12.0"},{"name":"superjson","version":"^1.12.2"},{"name":"mitt","version":"3.0.0"}],"devDependencies":[{"name":"vite","version":"^4.3.9"},{"name":"typescript","version":"^5.1.0"},{"name":"@types/react","version":"^18.0.37"},{"name":"@types/react-dom","version":"^18.0.11"},{"name":"@types/react-router-dom","version":"^5.3.3"},{"name":"@vitejs/plugin-react-swc","version":"^3.0.0"},{"name":"dotenv","version":"^16.0.3"},{"name":"@tsconfig/vite-react","version":"^2.0.0"},{"name":"vitest","version":"^0.29.3"},{"name":"@vitest/ui","version":"^0.29.3"},{"name":"jsdom","version":"^21.1.1"},{"name":"@testing-library/react","version":"^14.0.0"},{"name":"@testing-library/jest-dom","version":"^5.16.5"},{"name":"msw","version":"^1.1.0"}]}}
|
||||
{"npmDepsForServer":{"dependencies":[{"name":"cookie-parser","version":"~1.4.6"},{"name":"cors","version":"^2.8.5"},{"name":"express","version":"~4.18.1"},{"name":"morgan","version":"~1.10.0"},{"name":"@prisma/client","version":"4.12.0"},{"name":"jsonwebtoken","version":"^8.5.1"},{"name":"secure-password","version":"^4.0.0"},{"name":"dotenv","version":"16.0.2"},{"name":"helmet","version":"^6.0.0"},{"name":"patch-package","version":"^6.4.7"},{"name":"uuid","version":"^9.0.0"},{"name":"lodash.merge","version":"^4.6.2"},{"name":"rate-limiter-flexible","version":"^2.4.1"},{"name":"superjson","version":"^1.12.2"}],"devDependencies":[{"name":"nodemon","version":"^2.0.19"},{"name":"standard","version":"^17.0.0"},{"name":"prisma","version":"4.12.0"},{"name":"typescript","version":"^5.1.0"},{"name":"@types/express","version":"^4.17.13"},{"name":"@types/express-serve-static-core","version":"^4.17.13"},{"name":"@types/node","version":"^18.11.9"},{"name":"@tsconfig/node18","version":"^1.0.1"},{"name":"@types/uuid","version":"^9.0.0"},{"name":"@types/cors","version":"^2.8.5"}]},"npmDepsForWebApp":{"dependencies":[{"name":"axios","version":"^1.4.0"},{"name":"react","version":"^18.2.0"},{"name":"react-dom","version":"^18.2.0"},{"name":"@tanstack/react-query","version":"^4.29.0"},{"name":"react-router-dom","version":"^5.3.3"},{"name":"@prisma/client","version":"4.12.0"},{"name":"superjson","version":"^1.12.2"},{"name":"mitt","version":"3.0.0"},{"name":"react-hook-form","version":"^7.45.4"}],"devDependencies":[{"name":"vite","version":"^4.3.9"},{"name":"typescript","version":"^5.1.0"},{"name":"@types/react","version":"^18.0.37"},{"name":"@types/react-dom","version":"^18.0.11"},{"name":"@types/react-router-dom","version":"^5.3.3"},{"name":"@vitejs/plugin-react-swc","version":"^3.0.0"},{"name":"dotenv","version":"^16.0.3"},{"name":"@tsconfig/vite-react","version":"^2.0.0"},{"name":"vitest","version":"^0.29.3"},{"name":"@vitest/ui","version":"^0.29.3"},{"name":"jsdom","version":"^21.1.1"},{"name":"@testing-library/react","version":"^14.0.0"},{"name":"@testing-library/jest-dom","version":"^5.16.5"},{"name":"msw","version":"^1.1.0"}]}}
|
@ -36,7 +36,7 @@ type EntityMap<Entities extends _Entity[]> = {
|
||||
[EntityName in Entities[number]["_entityName"]]: PrismaDelegate[EntityName]
|
||||
}
|
||||
|
||||
type PrismaDelegate = {
|
||||
export type PrismaDelegate = {
|
||||
}
|
||||
|
||||
type Context<Entities extends _Entity[]> = Expand<{
|
||||
|
@ -1,35 +1,43 @@
|
||||
export type Payload = void | SuperJSONValue;
|
||||
export type Payload = void | SuperJSONValue
|
||||
|
||||
// The part below was copied from SuperJSON and slightly modified:
|
||||
// https://github.com/blitz-js/superjson/blob/ae7dbcefe5d3ece5b04be0c6afe6b40f3a44a22a/src/types.ts
|
||||
//
|
||||
// We couldn't use SuperJSON's types directly because:
|
||||
// 1. They aren't exported publicly.
|
||||
// 2. They have a werid quirk that turns `SuperJSONValue` into `any`.
|
||||
// See why here:
|
||||
// 2. They have a werid quirk that turns `SuperJSONValue` into `any`.
|
||||
// See why here:
|
||||
// https://github.com/blitz-js/superjson/pull/36#issuecomment-669239876
|
||||
//
|
||||
// We changed the code as little as possible to make future comparisons easier.
|
||||
export type JSONValue = PrimitiveJSONValue | JSONArray | JSONObject
|
||||
|
||||
type PrimitiveJSONValue = string | number | boolean | undefined | null;
|
||||
|
||||
type JSONValue = PrimitiveJSONValue | JSONArray | JSONObject;
|
||||
|
||||
interface JSONArray extends Array<JSONValue> {
|
||||
export interface JSONObject {
|
||||
[key: string]: JSONValue
|
||||
}
|
||||
|
||||
interface JSONObject {
|
||||
[key: string]: JSONValue;
|
||||
}
|
||||
type PrimitiveJSONValue = string | number | boolean | undefined | null
|
||||
|
||||
type SerializableJSONValue = Symbol | Set<SuperJSONValue> | Map<SuperJSONValue, SuperJSONValue> | undefined | bigint | Date | RegExp;
|
||||
interface JSONArray extends Array<JSONValue> {}
|
||||
|
||||
// Here's where we excluded `ClassInstance` (which was `any`) from the union.
|
||||
type SuperJSONValue = JSONValue | SerializableJSONValue | SuperJSONArray | SuperJSONObject;
|
||||
type SerializableJSONValue =
|
||||
| Symbol
|
||||
| Set<SuperJSONValue>
|
||||
| Map<SuperJSONValue, SuperJSONValue>
|
||||
| undefined
|
||||
| bigint
|
||||
| Date
|
||||
| RegExp
|
||||
|
||||
interface SuperJSONArray extends Array<SuperJSONValue> {
|
||||
}
|
||||
// Here's where we excluded `ClassInstance` (which was `any`) from the union.
|
||||
type SuperJSONValue =
|
||||
| JSONValue
|
||||
| SerializableJSONValue
|
||||
| SuperJSONArray
|
||||
| SuperJSONObject
|
||||
|
||||
interface SuperJSONArray extends Array<SuperJSONValue> {}
|
||||
|
||||
interface SuperJSONObject {
|
||||
[key: string]: SuperJSONValue;
|
||||
[key: string]: SuperJSONValue
|
||||
}
|
||||
|
@ -22,6 +22,14 @@ app.use((err, _req, res, next) => {
|
||||
return res.status(err.statusCode).json({ message: err.message, data: err.data })
|
||||
}
|
||||
|
||||
// This forwards the error to the default express error handler.
|
||||
// As described by expressjs documentation, the default error handler sets response status
|
||||
// to err.status or err.statusCode if it is 4xx or 5xx, and if not, sets it to 500.
|
||||
// It won't add any more info to it if server is running in production, which is exactly what we want,
|
||||
// we want to share as little info as possible when error happens in production, for security reasons,
|
||||
// so they will get only status code if set, or 500 if not, no extra info.
|
||||
// In development it will also share the error stack though, which is useful.
|
||||
// If the user wants to put more information about the error into the response, they should use HttpError.
|
||||
return next(err)
|
||||
})
|
||||
|
||||
|
@ -1,36 +0,0 @@
|
||||
/**
|
||||
* This is a definition of a job (think draft or invocable computation), not the running instance itself.
|
||||
* This can be submitted one or more times to be executed by some job executor via the same instance.
|
||||
* Once submitted, you get a SubmittedJob to track it later.
|
||||
*/
|
||||
export class Job {
|
||||
#jobName
|
||||
#executorName
|
||||
|
||||
/**
|
||||
* @param {string} jobName - Job name, which should be unique per executor.
|
||||
* @param {string} executorName - The name of the executor that will run submitted jobs.
|
||||
*/
|
||||
constructor(jobName, executorName) {
|
||||
this.#jobName = jobName
|
||||
this.#executorName = executorName
|
||||
}
|
||||
|
||||
get jobName() {
|
||||
return this.#jobName
|
||||
}
|
||||
|
||||
get executorName() {
|
||||
return this.#executorName
|
||||
}
|
||||
|
||||
// NOTE: Subclasses must implement this method.
|
||||
delay(...args) {
|
||||
throw new Error('Subclasses must implement this method')
|
||||
}
|
||||
|
||||
// NOTE: Subclasses must implement this method.
|
||||
async submit(...args) {
|
||||
throw new Error('Subclasses must implement this method')
|
||||
}
|
||||
}
|
@ -1,29 +0,0 @@
|
||||
/**
|
||||
* This is the result of submitting a Job to some executor.
|
||||
* It can be used by callers to track things, or call executor-specific subclass functionality.
|
||||
*/
|
||||
export class SubmittedJob {
|
||||
#job
|
||||
#jobId
|
||||
|
||||
/**
|
||||
* @param {Job} job - The Job that submitted work to an executor.
|
||||
* @param {string} jobId - A UUID for a submitted job in that executor's ecosystem.
|
||||
*/
|
||||
constructor(job, jobId) {
|
||||
this.#job = job
|
||||
this.#jobId = jobId
|
||||
}
|
||||
|
||||
get jobId() {
|
||||
return this.#jobId
|
||||
}
|
||||
|
||||
get jobName() {
|
||||
return this.#job.jobName
|
||||
}
|
||||
|
||||
get executorName() {
|
||||
return this.#job.executorName
|
||||
}
|
||||
}
|
@ -1,4 +0,0 @@
|
||||
|
||||
// This module exports all jobs and is imported by the server to ensure
|
||||
// any schedules that are not referenced are still loaded by NodeJS.
|
||||
|
@ -1,69 +0,0 @@
|
||||
import PgBoss from 'pg-boss'
|
||||
import config from '../../../config.js'
|
||||
|
||||
const boss = createPgBoss()
|
||||
|
||||
function createPgBoss() {
|
||||
let pgBossNewOptions = {
|
||||
connectionString: config.databaseUrl,
|
||||
}
|
||||
|
||||
// Add an escape hatch for advanced configuration of pg-boss to overwrite our defaults.
|
||||
if (process.env.PG_BOSS_NEW_OPTIONS) {
|
||||
try {
|
||||
pgBossNewOptions = JSON.parse(process.env.PG_BOSS_NEW_OPTIONS)
|
||||
}
|
||||
catch {
|
||||
console.error("Environment variable PG_BOSS_NEW_OPTIONS was not parsable by JSON.parse()!")
|
||||
}
|
||||
}
|
||||
|
||||
return new PgBoss(pgBossNewOptions)
|
||||
}
|
||||
|
||||
let resolvePgBossStarted, rejectPgBossStarted
|
||||
// Code that wants to access pg-boss must wait until it has been started.
|
||||
export const pgBossStarted = new Promise((resolve, reject) => {
|
||||
resolvePgBossStarted = resolve
|
||||
rejectPgBossStarted = reject
|
||||
})
|
||||
|
||||
// Ensure pg-boss can only be started once during a server's lifetime.
|
||||
const PgBossStatus = {
|
||||
Unstarted: 'Unstarted',
|
||||
Starting: 'Starting',
|
||||
Started: 'Started',
|
||||
Error: 'Error'
|
||||
}
|
||||
let pgBossStatus = PgBossStatus.Unstarted
|
||||
|
||||
/**
|
||||
* Prepares the target PostgreSQL database and begins job monitoring.
|
||||
* If the required database objects do not exist in the specified database,
|
||||
* `boss.start()` will automatically create them.
|
||||
* Ref: https://github.com/timgit/pg-boss/blob/master/docs/readme.md#start
|
||||
*
|
||||
* After making this call, we can send pg-boss jobs and they will be persisted and acted upon.
|
||||
* This should only be called once during a server's lifetime.
|
||||
*/
|
||||
export async function startPgBoss() {
|
||||
if (pgBossStatus !== PgBossStatus.Unstarted) { return }
|
||||
pgBossStatus = PgBossStatus.Starting
|
||||
console.log('Starting pg-boss...')
|
||||
|
||||
boss.on('error', error => console.error(error))
|
||||
try {
|
||||
await boss.start()
|
||||
} catch (error) {
|
||||
console.error('pg-boss failed to start!')
|
||||
console.error(error)
|
||||
pgBossStatus = PgBossStatus.Error
|
||||
rejectPgBossStarted(boss)
|
||||
return
|
||||
}
|
||||
|
||||
resolvePgBossStarted(boss)
|
||||
|
||||
console.log('pg-boss started!')
|
||||
pgBossStatus = PgBossStatus.Started
|
||||
}
|
@ -1,120 +0,0 @@
|
||||
import { pgBossStarted } from './pgBoss.js'
|
||||
import { Job } from '../Job.js'
|
||||
import { SubmittedJob } from '../SubmittedJob.js'
|
||||
|
||||
export const PG_BOSS_EXECUTOR_NAME = Symbol('PgBoss')
|
||||
|
||||
/**
|
||||
* A pg-boss specific SubmittedJob that adds additional pg-boss functionality.
|
||||
*/
|
||||
class PgBossSubmittedJob extends SubmittedJob {
|
||||
constructor(boss, job, jobId) {
|
||||
super(job, jobId)
|
||||
this.pgBoss = {
|
||||
cancel: () => boss.cancel(jobId),
|
||||
resume: () => boss.resume(jobId),
|
||||
details: () => boss.getJobById(jobId),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This is a class repesenting a job that can be submitted to pg-boss.
|
||||
* It is not yet submitted until the caller invokes `submit()` on an instance.
|
||||
* The caller can make as many calls to `submit()` as they wish.
|
||||
*/
|
||||
class PgBossJob extends Job {
|
||||
#defaultJobOptions
|
||||
#startAfter
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} jobName - The name of the Job. This is what will show up in the pg-boss DB tables.
|
||||
* @param {object} defaultJobOptions - Default options passed to `boss.send()`.
|
||||
* Ref: https://github.com/timgit/pg-boss/blob/master/docs/readme.md#sendname-data-options
|
||||
* @param {int | string | date} startAfter - Defers job execution. See `delay()` below for more.
|
||||
*/
|
||||
constructor(jobName, defaultJobOptions, startAfter = undefined) {
|
||||
super(jobName, PG_BOSS_EXECUTOR_NAME)
|
||||
this.#defaultJobOptions = defaultJobOptions
|
||||
this.#startAfter = startAfter
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {int | string | date} startAfter - Defers job execution by either:
|
||||
* - int: Seconds to delay starting the job [Default: 0]
|
||||
* - string: Start after a UTC Date time string in 8601 format
|
||||
* - Date: Start after a Date object
|
||||
*/
|
||||
delay(startAfter) {
|
||||
return new PgBossJob(this.jobName, this.#defaultJobOptions, startAfter)
|
||||
}
|
||||
|
||||
/**
|
||||
* Submits the job to pg-boss.
|
||||
* @param {object} jobArgs - The job arguments supplied by the user for their perform callback.
|
||||
* @param {object} jobOptions - pg-boss specific options for `boss.send()`, which can override their defaultJobOptions.
|
||||
*/
|
||||
async submit(jobArgs, jobOptions = {}) {
|
||||
const boss = await pgBossStarted
|
||||
const jobId = await boss.send(this.jobName, jobArgs,
|
||||
{ ...this.#defaultJobOptions, ...(this.#startAfter && { startAfter: this.#startAfter }), ...jobOptions })
|
||||
return new PgBossSubmittedJob(boss, this, jobId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an instance of PgBossJob and initializes the PgBoss executor by registering this job function.
|
||||
* We expect this to be called once per job name. If called multiple times with the same name and different
|
||||
* functions, we will override the previous calls.
|
||||
* @param {string} jobName - The user-defined job name in their .wasp file.
|
||||
* @param {fn} jobFn - The user-defined async job callback function.
|
||||
* @param {object} defaultJobOptions - pg-boss specific options for `boss.send()` applied to every `submit()` invocation,
|
||||
* which can overriden in that call.
|
||||
* @param {object} jobSchedule [Optional] - The 5-field cron string, job function JSON arg, and `boss.send()` options when invoking the job.
|
||||
* @param {array} entities - Entities used by job, passed into callback context.
|
||||
*/
|
||||
export function createJob({ jobName, jobFn, defaultJobOptions, jobSchedule, entities } = {}) {
|
||||
// NOTE(shayne): We are not awaiting `pgBossStarted` here since we need to return an instance to the job
|
||||
// template, or else the NodeJS module bootstrapping process will block and fail as it would then depend
|
||||
// on a runtime resolution of the promise in `startServer()`.
|
||||
// Since `pgBossStarted` will resolve in the future, it may appear possible to send pg-boss
|
||||
// a job before we actually have registered the handler via `boss.work()`. However, even if NodeJS does
|
||||
// not execute this callback before any job `submit()` calls, this is not a problem since pg-boss allows you
|
||||
// to submit jobs even if there are no workers registered.
|
||||
// Once they are registered, they will just start on the first job in their queue.
|
||||
pgBossStarted.then(async (boss) => {
|
||||
// As a safety precaution against undefined behavior of registering different
|
||||
// functions for the same job name, remove all registered functions first.
|
||||
await boss.offWork(jobName)
|
||||
|
||||
// This tells pg-boss to run given worker function when job with that name is submitted.
|
||||
// Ref: https://github.com/timgit/pg-boss/blob/master/docs/readme.md#work
|
||||
await boss.work(jobName, pgBossCallbackWrapper(jobFn, entities))
|
||||
|
||||
// If a job schedule is provided, we should schedule the recurring job.
|
||||
// If the schedule name already exists, it's updated to the provided cron expression, arguments, and options.
|
||||
// Ref: https://github.com/timgit/pg-boss/blob/master/docs/readme.md#scheduling
|
||||
if (jobSchedule) {
|
||||
const options = { ...defaultJobOptions, ...jobSchedule.options }
|
||||
await boss.schedule(jobName, jobSchedule.cron, jobSchedule.args || null, options)
|
||||
}
|
||||
})
|
||||
|
||||
return new PgBossJob(jobName, defaultJobOptions)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps the normal pg-boss callback function to inject entities, as well as extract
|
||||
* the `data` property so the arguments passed into the job are the exact same as those received.
|
||||
*
|
||||
* @param {fn} jobFn - The user-defined async job callback function.
|
||||
* @param {array} entities - Entities used by job, passed into callback context.
|
||||
* @returns a function that accepts the pg-boss callback arguments and invokes the user-defined callback.
|
||||
*/
|
||||
function pgBossCallbackWrapper(jobFn, entities) {
|
||||
return (args) => {
|
||||
const context = { entities }
|
||||
return jobFn(args.data, context)
|
||||
}
|
||||
}
|
@ -1,47 +0,0 @@
|
||||
import { sleep } from '../../utils.js'
|
||||
import { Job } from './Job.js'
|
||||
import { SubmittedJob } from './SubmittedJob.js'
|
||||
|
||||
export const SIMPLE_EXECUTOR_NAME = Symbol('Simple')
|
||||
|
||||
/**
|
||||
* A simple job mainly intended for testing. It will not submit work to any
|
||||
* job executor, but instead will simply invoke the underlying perform function.
|
||||
* It does not support `schedule`. It does not require any extra NPM dependencies
|
||||
* or infrastructure, however.
|
||||
*/
|
||||
class SimpleJob extends Job {
|
||||
#jobFn
|
||||
#delaySeconds
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} jobName - Name of the Job.
|
||||
* @param {fn} jobFn - The Job function to execute.
|
||||
* @param {int} delaySeconds - The number of seconds to delay invoking the Job function.
|
||||
*/
|
||||
constructor(jobName, jobFn, delaySeconds = 0) {
|
||||
super(jobName, SIMPLE_EXECUTOR_NAME)
|
||||
this.#jobFn = jobFn
|
||||
this.#delaySeconds = delaySeconds
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {int} delaySeconds - Used to delay the processing of the job by some number of seconds.
|
||||
*/
|
||||
delay(delaySeconds) {
|
||||
return new SimpleJob(this.jobName, this.#jobFn, delaySeconds)
|
||||
}
|
||||
|
||||
async submit(jobArgs) {
|
||||
sleep(this.#delaySeconds * 1000).then(() => this.#jobFn(jobArgs))
|
||||
// NOTE: Dumb random ID generator, mainly so we don't have to add `uuid`
|
||||
// as a dependency in the server generator for something nobody will likely use.
|
||||
const jobId = (Math.random() + 1).toString(36).substring(7)
|
||||
return new SubmittedJob(this, jobId)
|
||||
}
|
||||
}
|
||||
|
||||
export function createJob({ jobName, jobFn } = {}) {
|
||||
return new SimpleJob(jobName, jobFn)
|
||||
}
|
@ -10,6 +10,6 @@ export type ServerSetupFnContext = {
|
||||
}
|
||||
|
||||
export type { Application } from 'express'
|
||||
export { Server } from 'http'
|
||||
export type { Server } from 'http'
|
||||
|
||||
|
||||
|
@ -18,6 +18,7 @@
|
||||
"mitt": "3.0.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "^7.45.4",
|
||||
"react-router-dom": "^5.3.3",
|
||||
"superjson": "^1.12.2"
|
||||
},
|
||||
|
@ -18,7 +18,7 @@ const MainPage = () => {
|
||||
<div className="buttons">
|
||||
<a
|
||||
className="button button-filled"
|
||||
href="https://wasp-lang.dev/docs/tutorials/todo-app"
|
||||
href="https://wasp-lang.dev/docs/tutorial/create"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
|
@ -1,16 +0,0 @@
|
||||
import React from 'react'
|
||||
import { Route, Switch, BrowserRouter as Router } from 'react-router-dom'
|
||||
|
||||
|
||||
import MainPage from './ext-src/MainPage.jsx'
|
||||
|
||||
|
||||
const router = (
|
||||
<Router>
|
||||
<Switch>
|
||||
<Route exact path="/" component={ MainPage }/>
|
||||
</Switch>
|
||||
</Router>
|
||||
)
|
||||
|
||||
export default router
|
43
waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/router.tsx
generated
Normal file
43
waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/router.tsx
generated
Normal file
@ -0,0 +1,43 @@
|
||||
import React from 'react'
|
||||
import { Route, Switch, BrowserRouter as Router } from 'react-router-dom'
|
||||
import { interpolatePath } from './router/linkHelpers'
|
||||
import type {
|
||||
RouteDefinitionsToRoutes,
|
||||
OptionalRouteOptions,
|
||||
ParamValue,
|
||||
} from './router/types'
|
||||
|
||||
|
||||
import MainPage from './ext-src/MainPage.jsx'
|
||||
|
||||
|
||||
export const routes = {
|
||||
RootRoute: {
|
||||
to: "/",
|
||||
component: MainPage,
|
||||
build: (
|
||||
options?: OptionalRouteOptions,
|
||||
) => interpolatePath("/", undefined, options.search, options.hash),
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type Routes = RouteDefinitionsToRoutes<typeof routes>
|
||||
|
||||
const router = (
|
||||
<Router>
|
||||
<Switch>
|
||||
{Object.entries(routes).map(([routeKey, route]) => (
|
||||
<Route
|
||||
exact
|
||||
key={routeKey}
|
||||
path={route.to}
|
||||
component={route.component}
|
||||
/>
|
||||
))}
|
||||
</Switch>
|
||||
</Router>
|
||||
)
|
||||
|
||||
export default router
|
||||
|
||||
export { Link } from './router/Link'
|
20
waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/router/Link.tsx
generated
Normal file
20
waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/router/Link.tsx
generated
Normal file
@ -0,0 +1,20 @@
|
||||
import { useMemo } from 'react'
|
||||
import { Link as RouterLink } from 'react-router-dom'
|
||||
import { type Routes } from '../router'
|
||||
import { interpolatePath } from './linkHelpers'
|
||||
|
||||
type RouterLinkProps = Parameters<typeof RouterLink>[0]
|
||||
|
||||
export function Link(
|
||||
{ to, params, search, hash, ...restOfProps }: Omit<RouterLinkProps, "to">
|
||||
& {
|
||||
search?: Record<string, string>;
|
||||
hash?: string;
|
||||
}
|
||||
& Routes
|
||||
) {
|
||||
const toPropWithParams = useMemo(() => {
|
||||
return interpolatePath(to, params, search, hash)
|
||||
}, [to, params])
|
||||
return <RouterLink to={toPropWithParams} {...restOfProps} />
|
||||
}
|
44
waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/router/linkHelpers.ts
generated
Normal file
44
waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/router/linkHelpers.ts
generated
Normal file
@ -0,0 +1,44 @@
|
||||
import type { Params, Search } from "./types";
|
||||
|
||||
export function interpolatePath(
|
||||
path: string,
|
||||
params?: Params,
|
||||
search?: Search,
|
||||
hash?: string
|
||||
) {
|
||||
const interpolatedPath = params ? interpolatePathParams(path, params) : path
|
||||
const interpolatedSearch = search
|
||||
? `?${new URLSearchParams(search).toString()}`
|
||||
: ''
|
||||
const interpolatedHash = hash ? `#${hash}` : ''
|
||||
return interpolatedPath + interpolatedSearch + interpolatedHash
|
||||
}
|
||||
|
||||
function interpolatePathParams(path: string, params: Params) {
|
||||
function mapPathPart(part: string) {
|
||||
if (part.startsWith(":")) {
|
||||
const paramName = extractParamNameFromPathPart(part);
|
||||
return params[paramName];
|
||||
}
|
||||
return part;
|
||||
}
|
||||
|
||||
const interpolatedPath = path
|
||||
.split("/")
|
||||
.map(mapPathPart)
|
||||
.filter(isValidPathPart)
|
||||
.join("/");
|
||||
|
||||
return path.startsWith("/") ? `/${interpolatedPath}` : interpolatedPath;
|
||||
}
|
||||
|
||||
function isValidPathPart(part: any): boolean {
|
||||
return !!part;
|
||||
}
|
||||
|
||||
function extractParamNameFromPathPart(paramString: string) {
|
||||
if (paramString.endsWith("?")) {
|
||||
return paramString.slice(1, -1);
|
||||
}
|
||||
return paramString.slice(1);
|
||||
}
|
36
waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/router/types.ts
generated
Normal file
36
waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/router/types.ts
generated
Normal file
@ -0,0 +1,36 @@
|
||||
export type RouteDefinitionsToRoutes<Routes extends RoutesDefinition> =
|
||||
RouteDefinitionsToRoutesObj<Routes>[keyof RouteDefinitionsToRoutesObj<Routes>]
|
||||
|
||||
export type OptionalRouteOptions = {
|
||||
search?: Search
|
||||
hash?: string
|
||||
}
|
||||
|
||||
export type ParamValue = string | number
|
||||
export type Params = { [name: string]: ParamValue }
|
||||
export type Search =
|
||||
| string[][]
|
||||
| Record<string, string>
|
||||
| string
|
||||
| URLSearchParams
|
||||
|
||||
type RouteDefinitionsToRoutesObj<Routes extends RoutesDefinition> = {
|
||||
[K in keyof Routes]: {
|
||||
to: Routes[K]['to']
|
||||
} & ParamsFromBuildFn<Routes[K]['build']>
|
||||
}
|
||||
|
||||
type RoutesDefinition = {
|
||||
[name: string]: {
|
||||
to: string
|
||||
build: BuildFn
|
||||
}
|
||||
}
|
||||
|
||||
type BuildFn = (params: unknown) => string
|
||||
|
||||
type ParamsFromBuildFn<BF extends BuildFn> = Parameters<BF>[0] extends {
|
||||
params: infer Params
|
||||
}
|
||||
? { params: Params }
|
||||
: { params?: never }
|
@ -10,7 +10,7 @@ import { Query } from '../../queries'
|
||||
import config from '../../config'
|
||||
import { HttpMethod, Route } from '../../types'
|
||||
|
||||
export { type Route } from '../../types'
|
||||
export type { Route } from '../../types'
|
||||
|
||||
export type MockQuery = <Input, Output, MockOutput extends Output>(
|
||||
query: Query<Input, Output>,
|
||||
|
@ -4,7 +4,9 @@
|
||||
// Temporary loosen the type checking until we can address all the errors.
|
||||
"jsx": "preserve",
|
||||
"allowJs": true,
|
||||
"strict": false
|
||||
"strict": false,
|
||||
// Allow importing pages with the .tsx extension.
|
||||
"allowImportingTsExtensions": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
|
@ -1,7 +1,7 @@
|
||||
app waspBuild {
|
||||
db: { system: PostgreSQL },
|
||||
wasp: {
|
||||
version: "^0.11.1"
|
||||
version: "^0.11.4"
|
||||
},
|
||||
title: "waspBuild"
|
||||
}
|
||||
|
@ -18,7 +18,7 @@ const MainPage = () => {
|
||||
<div className="buttons">
|
||||
<a
|
||||
className="button button-filled"
|
||||
href="https://wasp-lang.dev/docs/tutorials/todo-app"
|
||||
href="https://wasp-lang.dev/docs/tutorial/create"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
|
@ -21,12 +21,6 @@ waspCompile/.wasp/out/server/src/core/HttpError.js
|
||||
waspCompile/.wasp/out/server/src/dbClient.ts
|
||||
waspCompile/.wasp/out/server/src/dbSeed/types.ts
|
||||
waspCompile/.wasp/out/server/src/entities/index.ts
|
||||
waspCompile/.wasp/out/server/src/jobs/core/Job.js
|
||||
waspCompile/.wasp/out/server/src/jobs/core/SubmittedJob.js
|
||||
waspCompile/.wasp/out/server/src/jobs/core/allJobs.js
|
||||
waspCompile/.wasp/out/server/src/jobs/core/pgBoss/pgBoss.js
|
||||
waspCompile/.wasp/out/server/src/jobs/core/pgBoss/pgBossJob.js
|
||||
waspCompile/.wasp/out/server/src/jobs/core/simpleJob.js
|
||||
waspCompile/.wasp/out/server/src/middleware/globalMiddleware.ts
|
||||
waspCompile/.wasp/out/server/src/middleware/index.ts
|
||||
waspCompile/.wasp/out/server/src/middleware/operations.ts
|
||||
@ -70,7 +64,10 @@ waspCompile/.wasp/out/web-app/src/queries/core.js
|
||||
waspCompile/.wasp/out/web-app/src/queries/index.d.ts
|
||||
waspCompile/.wasp/out/web-app/src/queries/index.js
|
||||
waspCompile/.wasp/out/web-app/src/queryClient.js
|
||||
waspCompile/.wasp/out/web-app/src/router.jsx
|
||||
waspCompile/.wasp/out/web-app/src/router.tsx
|
||||
waspCompile/.wasp/out/web-app/src/router/Link.tsx
|
||||
waspCompile/.wasp/out/web-app/src/router/linkHelpers.ts
|
||||
waspCompile/.wasp/out/web-app/src/router/types.ts
|
||||
waspCompile/.wasp/out/web-app/src/storage.ts
|
||||
waspCompile/.wasp/out/web-app/src/test/index.ts
|
||||
waspCompile/.wasp/out/web-app/src/test/vitest/helpers.tsx
|
||||
|
@ -81,14 +81,14 @@
|
||||
"file",
|
||||
"server/src/_types/index.ts"
|
||||
],
|
||||
"9a8b137a7bcfcf994927032964cf3ea31327352b7fc711dcfa6b9eecea721fe5"
|
||||
"b6b5d08ff823cf66d47932167a4008307acb53cef2e409f26621e8b268ba8008"
|
||||
],
|
||||
[
|
||||
[
|
||||
"file",
|
||||
"server/src/_types/serialization.ts"
|
||||
],
|
||||
"69f96a24a9c1af30c9f6f3b9f4aef7f3f62e226ef0864332527e74c542e47467"
|
||||
"73dc983c9a02cab8e2f189f1ddedd9cdcd47bf403c9dc56efba8be895b6980a1"
|
||||
],
|
||||
[
|
||||
[
|
||||
@ -109,7 +109,7 @@
|
||||
"file",
|
||||
"server/src/app.js"
|
||||
],
|
||||
"12291f83f685eeb81a8c082961120d4bddb91985092eb142964e554a21d3384c"
|
||||
"86504ede1daeca35cc93f665ca8ac2fdf46ecaff02f6d3a7810a14d2bc71e16a"
|
||||
],
|
||||
[
|
||||
[
|
||||
@ -153,48 +153,6 @@
|
||||
],
|
||||
"c59b97b122b977b5171686c92ee5ff2d80d397c2e83cc0915affb6ee136406fb"
|
||||
],
|
||||
[
|
||||
[
|
||||
"file",
|
||||
"server/src/jobs/core/Job.js"
|
||||
],
|
||||
"e0e5d5e802a29032bfc8426097950722ac0dc7931d08641c1c2b02c262e6cdcc"
|
||||
],
|
||||
[
|
||||
[
|
||||
"file",
|
||||
"server/src/jobs/core/SubmittedJob.js"
|
||||
],
|
||||
"75753277b6bd2c1d2e9ea0e80a71c72c84fa18bb7d61da25d798b3ef247e06bd"
|
||||
],
|
||||
[
|
||||
[
|
||||
"file",
|
||||
"server/src/jobs/core/allJobs.js"
|
||||
],
|
||||
"90b1b3012216900efa82fff14911dcbf195fa2e449edb3b24ab80db0065d796f"
|
||||
],
|
||||
[
|
||||
[
|
||||
"file",
|
||||
"server/src/jobs/core/pgBoss/pgBoss.js"
|
||||
],
|
||||
"9821963d90b39058285343834c70e6f825d3d7696c738fd95539614b5e7d7b94"
|
||||
],
|
||||
[
|
||||
[
|
||||
"file",
|
||||
"server/src/jobs/core/pgBoss/pgBossJob.js"
|
||||
],
|
||||
"ff6040d051c916eb080a2f2c37fd5135f588782387faeae51115d1a7abd1ad8b"
|
||||
],
|
||||
[
|
||||
[
|
||||
"file",
|
||||
"server/src/jobs/core/simpleJob.js"
|
||||
],
|
||||
"36fe173d9f5128859196bfd3a661983df2d95eb34d165a469b840982b06cf59b"
|
||||
],
|
||||
[
|
||||
[
|
||||
"file",
|
||||
@ -249,7 +207,7 @@
|
||||
"file",
|
||||
"server/src/types/index.ts"
|
||||
],
|
||||
"1fd50e251e340a5bc8c51369766e8c889cf892cdbe6593b4d58a6ee585b6d2cc"
|
||||
"1958cfc3e3b5f59490168797e4b8dcdc38f32346e734f90df3fb6baa264b36b5"
|
||||
],
|
||||
[
|
||||
[
|
||||
@ -326,7 +284,7 @@
|
||||
"file",
|
||||
"web-app/package.json"
|
||||
],
|
||||
"8a2249588d7cf9ac7c6c8cb979727c264446581f60c7062e5852aef4c1d8a675"
|
||||
"c2b7000a7380cce059cdafe67fa755a8e5f4d1de5e13eb11e545a3ee4d32db0d"
|
||||
],
|
||||
[
|
||||
[
|
||||
@ -417,7 +375,7 @@
|
||||
"file",
|
||||
"web-app/src/ext-src/MainPage.jsx"
|
||||
],
|
||||
"7244c106359f088fdcc0d4a76ee63277f1cea63cbe7aac5e5c39a17df693b1e2"
|
||||
"8ee7fe1352719facadf0935eb45df8661ba13015277ea80be5a9200c66a31bde"
|
||||
],
|
||||
[
|
||||
[
|
||||
@ -506,9 +464,30 @@
|
||||
[
|
||||
[
|
||||
"file",
|
||||
"web-app/src/router.jsx"
|
||||
"web-app/src/router.tsx"
|
||||
],
|
||||
"c1188ee948f821f5e3e7e741f54b503afffe4bdb24b8f528c32aee0990cf5457"
|
||||
"79192ef1e636c2573815ca7151cf8e3a76ebb00a0b0ee3e804cbb60cba5f7197"
|
||||
],
|
||||
[
|
||||
[
|
||||
"file",
|
||||
"web-app/src/router/Link.tsx"
|
||||
],
|
||||
"7b6214295d59d8dffbd61b82f9dab2b080b2d7ebe98cc7d9f9e8c229f99a890d"
|
||||
],
|
||||
[
|
||||
[
|
||||
"file",
|
||||
"web-app/src/router/linkHelpers.ts"
|
||||
],
|
||||
"c296ed5e7924ad1173f4f0fb4dcce053cffd5812612069b5f62d1bf9e96495cf"
|
||||
],
|
||||
[
|
||||
[
|
||||
"file",
|
||||
"web-app/src/router/types.ts"
|
||||
],
|
||||
"7f08b262987c17f953c4b95814631a7aaac82eb77660bcb247ef7bf866846fe1"
|
||||
],
|
||||
[
|
||||
[
|
||||
@ -529,7 +508,7 @@
|
||||
"file",
|
||||
"web-app/src/test/vitest/helpers.tsx"
|
||||
],
|
||||
"a0576215b0786c6e082b8a83f6418a3acb084bfb673a152baad8e8a7fc46fcae"
|
||||
"a38e55c9999a87ab497538bcad7c880f32a4d27f2227ae326cb76eb0848b89e9"
|
||||
],
|
||||
[
|
||||
[
|
||||
@ -578,7 +557,7 @@
|
||||
"file",
|
||||
"web-app/tsconfig.json"
|
||||
],
|
||||
"887c55937264ea8b2c538340962c3011091cf3eb6b9d39523acbe8ebcdd35474"
|
||||
"f1b31ca75b2b32c5b0441aec4fcd7f285c18346761ba1640761d6253d65e3580"
|
||||
],
|
||||
[
|
||||
[
|
||||
|
@ -1 +1 @@
|
||||
{"npmDepsForServer":{"dependencies":[{"name":"cookie-parser","version":"~1.4.6"},{"name":"cors","version":"^2.8.5"},{"name":"express","version":"~4.18.1"},{"name":"morgan","version":"~1.10.0"},{"name":"@prisma/client","version":"4.12.0"},{"name":"jsonwebtoken","version":"^8.5.1"},{"name":"secure-password","version":"^4.0.0"},{"name":"dotenv","version":"16.0.2"},{"name":"helmet","version":"^6.0.0"},{"name":"patch-package","version":"^6.4.7"},{"name":"uuid","version":"^9.0.0"},{"name":"lodash.merge","version":"^4.6.2"},{"name":"rate-limiter-flexible","version":"^2.4.1"},{"name":"superjson","version":"^1.12.2"}],"devDependencies":[{"name":"nodemon","version":"^2.0.19"},{"name":"standard","version":"^17.0.0"},{"name":"prisma","version":"4.12.0"},{"name":"typescript","version":"^5.1.0"},{"name":"@types/express","version":"^4.17.13"},{"name":"@types/express-serve-static-core","version":"^4.17.13"},{"name":"@types/node","version":"^18.11.9"},{"name":"@tsconfig/node18","version":"^1.0.1"},{"name":"@types/uuid","version":"^9.0.0"},{"name":"@types/cors","version":"^2.8.5"}]},"npmDepsForWebApp":{"dependencies":[{"name":"axios","version":"^1.4.0"},{"name":"react","version":"^18.2.0"},{"name":"react-dom","version":"^18.2.0"},{"name":"@tanstack/react-query","version":"^4.29.0"},{"name":"react-router-dom","version":"^5.3.3"},{"name":"@prisma/client","version":"4.12.0"},{"name":"superjson","version":"^1.12.2"},{"name":"mitt","version":"3.0.0"}],"devDependencies":[{"name":"vite","version":"^4.3.9"},{"name":"typescript","version":"^5.1.0"},{"name":"@types/react","version":"^18.0.37"},{"name":"@types/react-dom","version":"^18.0.11"},{"name":"@types/react-router-dom","version":"^5.3.3"},{"name":"@vitejs/plugin-react-swc","version":"^3.0.0"},{"name":"dotenv","version":"^16.0.3"},{"name":"@tsconfig/vite-react","version":"^2.0.0"},{"name":"vitest","version":"^0.29.3"},{"name":"@vitest/ui","version":"^0.29.3"},{"name":"jsdom","version":"^21.1.1"},{"name":"@testing-library/react","version":"^14.0.0"},{"name":"@testing-library/jest-dom","version":"^5.16.5"},{"name":"msw","version":"^1.1.0"}]}}
|
||||
{"npmDepsForServer":{"dependencies":[{"name":"cookie-parser","version":"~1.4.6"},{"name":"cors","version":"^2.8.5"},{"name":"express","version":"~4.18.1"},{"name":"morgan","version":"~1.10.0"},{"name":"@prisma/client","version":"4.12.0"},{"name":"jsonwebtoken","version":"^8.5.1"},{"name":"secure-password","version":"^4.0.0"},{"name":"dotenv","version":"16.0.2"},{"name":"helmet","version":"^6.0.0"},{"name":"patch-package","version":"^6.4.7"},{"name":"uuid","version":"^9.0.0"},{"name":"lodash.merge","version":"^4.6.2"},{"name":"rate-limiter-flexible","version":"^2.4.1"},{"name":"superjson","version":"^1.12.2"}],"devDependencies":[{"name":"nodemon","version":"^2.0.19"},{"name":"standard","version":"^17.0.0"},{"name":"prisma","version":"4.12.0"},{"name":"typescript","version":"^5.1.0"},{"name":"@types/express","version":"^4.17.13"},{"name":"@types/express-serve-static-core","version":"^4.17.13"},{"name":"@types/node","version":"^18.11.9"},{"name":"@tsconfig/node18","version":"^1.0.1"},{"name":"@types/uuid","version":"^9.0.0"},{"name":"@types/cors","version":"^2.8.5"}]},"npmDepsForWebApp":{"dependencies":[{"name":"axios","version":"^1.4.0"},{"name":"react","version":"^18.2.0"},{"name":"react-dom","version":"^18.2.0"},{"name":"@tanstack/react-query","version":"^4.29.0"},{"name":"react-router-dom","version":"^5.3.3"},{"name":"@prisma/client","version":"4.12.0"},{"name":"superjson","version":"^1.12.2"},{"name":"mitt","version":"3.0.0"},{"name":"react-hook-form","version":"^7.45.4"}],"devDependencies":[{"name":"vite","version":"^4.3.9"},{"name":"typescript","version":"^5.1.0"},{"name":"@types/react","version":"^18.0.37"},{"name":"@types/react-dom","version":"^18.0.11"},{"name":"@types/react-router-dom","version":"^5.3.3"},{"name":"@vitejs/plugin-react-swc","version":"^3.0.0"},{"name":"dotenv","version":"^16.0.3"},{"name":"@tsconfig/vite-react","version":"^2.0.0"},{"name":"vitest","version":"^0.29.3"},{"name":"@vitest/ui","version":"^0.29.3"},{"name":"jsdom","version":"^21.1.1"},{"name":"@testing-library/react","version":"^14.0.0"},{"name":"@testing-library/jest-dom","version":"^5.16.5"},{"name":"msw","version":"^1.1.0"}]}}
|
@ -36,7 +36,7 @@ type EntityMap<Entities extends _Entity[]> = {
|
||||
[EntityName in Entities[number]["_entityName"]]: PrismaDelegate[EntityName]
|
||||
}
|
||||
|
||||
type PrismaDelegate = {
|
||||
export type PrismaDelegate = {
|
||||
}
|
||||
|
||||
type Context<Entities extends _Entity[]> = Expand<{
|
||||
|
@ -1,35 +1,43 @@
|
||||
export type Payload = void | SuperJSONValue;
|
||||
export type Payload = void | SuperJSONValue
|
||||
|
||||
// The part below was copied from SuperJSON and slightly modified:
|
||||
// https://github.com/blitz-js/superjson/blob/ae7dbcefe5d3ece5b04be0c6afe6b40f3a44a22a/src/types.ts
|
||||
//
|
||||
// We couldn't use SuperJSON's types directly because:
|
||||
// 1. They aren't exported publicly.
|
||||
// 2. They have a werid quirk that turns `SuperJSONValue` into `any`.
|
||||
// See why here:
|
||||
// 2. They have a werid quirk that turns `SuperJSONValue` into `any`.
|
||||
// See why here:
|
||||
// https://github.com/blitz-js/superjson/pull/36#issuecomment-669239876
|
||||
//
|
||||
// We changed the code as little as possible to make future comparisons easier.
|
||||
export type JSONValue = PrimitiveJSONValue | JSONArray | JSONObject
|
||||
|
||||
type PrimitiveJSONValue = string | number | boolean | undefined | null;
|
||||
|
||||
type JSONValue = PrimitiveJSONValue | JSONArray | JSONObject;
|
||||
|
||||
interface JSONArray extends Array<JSONValue> {
|
||||
export interface JSONObject {
|
||||
[key: string]: JSONValue
|
||||
}
|
||||
|
||||
interface JSONObject {
|
||||
[key: string]: JSONValue;
|
||||
}
|
||||
type PrimitiveJSONValue = string | number | boolean | undefined | null
|
||||
|
||||
type SerializableJSONValue = Symbol | Set<SuperJSONValue> | Map<SuperJSONValue, SuperJSONValue> | undefined | bigint | Date | RegExp;
|
||||
interface JSONArray extends Array<JSONValue> {}
|
||||
|
||||
// Here's where we excluded `ClassInstance` (which was `any`) from the union.
|
||||
type SuperJSONValue = JSONValue | SerializableJSONValue | SuperJSONArray | SuperJSONObject;
|
||||
type SerializableJSONValue =
|
||||
| Symbol
|
||||
| Set<SuperJSONValue>
|
||||
| Map<SuperJSONValue, SuperJSONValue>
|
||||
| undefined
|
||||
| bigint
|
||||
| Date
|
||||
| RegExp
|
||||
|
||||
interface SuperJSONArray extends Array<SuperJSONValue> {
|
||||
}
|
||||
// Here's where we excluded `ClassInstance` (which was `any`) from the union.
|
||||
type SuperJSONValue =
|
||||
| JSONValue
|
||||
| SerializableJSONValue
|
||||
| SuperJSONArray
|
||||
| SuperJSONObject
|
||||
|
||||
interface SuperJSONArray extends Array<SuperJSONValue> {}
|
||||
|
||||
interface SuperJSONObject {
|
||||
[key: string]: SuperJSONValue;
|
||||
[key: string]: SuperJSONValue
|
||||
}
|
||||
|
@ -22,6 +22,14 @@ app.use((err, _req, res, next) => {
|
||||
return res.status(err.statusCode).json({ message: err.message, data: err.data })
|
||||
}
|
||||
|
||||
// This forwards the error to the default express error handler.
|
||||
// As described by expressjs documentation, the default error handler sets response status
|
||||
// to err.status or err.statusCode if it is 4xx or 5xx, and if not, sets it to 500.
|
||||
// It won't add any more info to it if server is running in production, which is exactly what we want,
|
||||
// we want to share as little info as possible when error happens in production, for security reasons,
|
||||
// so they will get only status code if set, or 500 if not, no extra info.
|
||||
// In development it will also share the error stack though, which is useful.
|
||||
// If the user wants to put more information about the error into the response, they should use HttpError.
|
||||
return next(err)
|
||||
})
|
||||
|
||||
|
@ -1,36 +0,0 @@
|
||||
/**
|
||||
* This is a definition of a job (think draft or invocable computation), not the running instance itself.
|
||||
* This can be submitted one or more times to be executed by some job executor via the same instance.
|
||||
* Once submitted, you get a SubmittedJob to track it later.
|
||||
*/
|
||||
export class Job {
|
||||
#jobName
|
||||
#executorName
|
||||
|
||||
/**
|
||||
* @param {string} jobName - Job name, which should be unique per executor.
|
||||
* @param {string} executorName - The name of the executor that will run submitted jobs.
|
||||
*/
|
||||
constructor(jobName, executorName) {
|
||||
this.#jobName = jobName
|
||||
this.#executorName = executorName
|
||||
}
|
||||
|
||||
get jobName() {
|
||||
return this.#jobName
|
||||
}
|
||||
|
||||
get executorName() {
|
||||
return this.#executorName
|
||||
}
|
||||
|
||||
// NOTE: Subclasses must implement this method.
|
||||
delay(...args) {
|
||||
throw new Error('Subclasses must implement this method')
|
||||
}
|
||||
|
||||
// NOTE: Subclasses must implement this method.
|
||||
async submit(...args) {
|
||||
throw new Error('Subclasses must implement this method')
|
||||
}
|
||||
}
|
@ -1,29 +0,0 @@
|
||||
/**
|
||||
* This is the result of submitting a Job to some executor.
|
||||
* It can be used by callers to track things, or call executor-specific subclass functionality.
|
||||
*/
|
||||
export class SubmittedJob {
|
||||
#job
|
||||
#jobId
|
||||
|
||||
/**
|
||||
* @param {Job} job - The Job that submitted work to an executor.
|
||||
* @param {string} jobId - A UUID for a submitted job in that executor's ecosystem.
|
||||
*/
|
||||
constructor(job, jobId) {
|
||||
this.#job = job
|
||||
this.#jobId = jobId
|
||||
}
|
||||
|
||||
get jobId() {
|
||||
return this.#jobId
|
||||
}
|
||||
|
||||
get jobName() {
|
||||
return this.#job.jobName
|
||||
}
|
||||
|
||||
get executorName() {
|
||||
return this.#job.executorName
|
||||
}
|
||||
}
|
@ -1,4 +0,0 @@
|
||||
|
||||
// This module exports all jobs and is imported by the server to ensure
|
||||
// any schedules that are not referenced are still loaded by NodeJS.
|
||||
|
@ -1,120 +0,0 @@
|
||||
import { pgBossStarted } from './pgBoss.js'
|
||||
import { Job } from '../Job.js'
|
||||
import { SubmittedJob } from '../SubmittedJob.js'
|
||||
|
||||
export const PG_BOSS_EXECUTOR_NAME = Symbol('PgBoss')
|
||||
|
||||
/**
|
||||
* A pg-boss specific SubmittedJob that adds additional pg-boss functionality.
|
||||
*/
|
||||
class PgBossSubmittedJob extends SubmittedJob {
|
||||
constructor(boss, job, jobId) {
|
||||
super(job, jobId)
|
||||
this.pgBoss = {
|
||||
cancel: () => boss.cancel(jobId),
|
||||
resume: () => boss.resume(jobId),
|
||||
details: () => boss.getJobById(jobId),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This is a class repesenting a job that can be submitted to pg-boss.
|
||||
* It is not yet submitted until the caller invokes `submit()` on an instance.
|
||||
* The caller can make as many calls to `submit()` as they wish.
|
||||
*/
|
||||
class PgBossJob extends Job {
|
||||
#defaultJobOptions
|
||||
#startAfter
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} jobName - The name of the Job. This is what will show up in the pg-boss DB tables.
|
||||
* @param {object} defaultJobOptions - Default options passed to `boss.send()`.
|
||||
* Ref: https://github.com/timgit/pg-boss/blob/master/docs/readme.md#sendname-data-options
|
||||
* @param {int | string | date} startAfter - Defers job execution. See `delay()` below for more.
|
||||
*/
|
||||
constructor(jobName, defaultJobOptions, startAfter = undefined) {
|
||||
super(jobName, PG_BOSS_EXECUTOR_NAME)
|
||||
this.#defaultJobOptions = defaultJobOptions
|
||||
this.#startAfter = startAfter
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {int | string | date} startAfter - Defers job execution by either:
|
||||
* - int: Seconds to delay starting the job [Default: 0]
|
||||
* - string: Start after a UTC Date time string in 8601 format
|
||||
* - Date: Start after a Date object
|
||||
*/
|
||||
delay(startAfter) {
|
||||
return new PgBossJob(this.jobName, this.#defaultJobOptions, startAfter)
|
||||
}
|
||||
|
||||
/**
|
||||
* Submits the job to pg-boss.
|
||||
* @param {object} jobArgs - The job arguments supplied by the user for their perform callback.
|
||||
* @param {object} jobOptions - pg-boss specific options for `boss.send()`, which can override their defaultJobOptions.
|
||||
*/
|
||||
async submit(jobArgs, jobOptions = {}) {
|
||||
const boss = await pgBossStarted
|
||||
const jobId = await boss.send(this.jobName, jobArgs,
|
||||
{ ...this.#defaultJobOptions, ...(this.#startAfter && { startAfter: this.#startAfter }), ...jobOptions })
|
||||
return new PgBossSubmittedJob(boss, this, jobId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an instance of PgBossJob and initializes the PgBoss executor by registering this job function.
|
||||
* We expect this to be called once per job name. If called multiple times with the same name and different
|
||||
* functions, we will override the previous calls.
|
||||
* @param {string} jobName - The user-defined job name in their .wasp file.
|
||||
* @param {fn} jobFn - The user-defined async job callback function.
|
||||
* @param {object} defaultJobOptions - pg-boss specific options for `boss.send()` applied to every `submit()` invocation,
|
||||
* which can overriden in that call.
|
||||
* @param {object} jobSchedule [Optional] - The 5-field cron string, job function JSON arg, and `boss.send()` options when invoking the job.
|
||||
* @param {array} entities - Entities used by job, passed into callback context.
|
||||
*/
|
||||
export function createJob({ jobName, jobFn, defaultJobOptions, jobSchedule, entities } = {}) {
|
||||
// NOTE(shayne): We are not awaiting `pgBossStarted` here since we need to return an instance to the job
|
||||
// template, or else the NodeJS module bootstrapping process will block and fail as it would then depend
|
||||
// on a runtime resolution of the promise in `startServer()`.
|
||||
// Since `pgBossStarted` will resolve in the future, it may appear possible to send pg-boss
|
||||
// a job before we actually have registered the handler via `boss.work()`. However, even if NodeJS does
|
||||
// not execute this callback before any job `submit()` calls, this is not a problem since pg-boss allows you
|
||||
// to submit jobs even if there are no workers registered.
|
||||
// Once they are registered, they will just start on the first job in their queue.
|
||||
pgBossStarted.then(async (boss) => {
|
||||
// As a safety precaution against undefined behavior of registering different
|
||||
// functions for the same job name, remove all registered functions first.
|
||||
await boss.offWork(jobName)
|
||||
|
||||
// This tells pg-boss to run given worker function when job with that name is submitted.
|
||||
// Ref: https://github.com/timgit/pg-boss/blob/master/docs/readme.md#work
|
||||
await boss.work(jobName, pgBossCallbackWrapper(jobFn, entities))
|
||||
|
||||
// If a job schedule is provided, we should schedule the recurring job.
|
||||
// If the schedule name already exists, it's updated to the provided cron expression, arguments, and options.
|
||||
// Ref: https://github.com/timgit/pg-boss/blob/master/docs/readme.md#scheduling
|
||||
if (jobSchedule) {
|
||||
const options = { ...defaultJobOptions, ...jobSchedule.options }
|
||||
await boss.schedule(jobName, jobSchedule.cron, jobSchedule.args || null, options)
|
||||
}
|
||||
})
|
||||
|
||||
return new PgBossJob(jobName, defaultJobOptions)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps the normal pg-boss callback function to inject entities, as well as extract
|
||||
* the `data` property so the arguments passed into the job are the exact same as those received.
|
||||
*
|
||||
* @param {fn} jobFn - The user-defined async job callback function.
|
||||
* @param {array} entities - Entities used by job, passed into callback context.
|
||||
* @returns a function that accepts the pg-boss callback arguments and invokes the user-defined callback.
|
||||
*/
|
||||
function pgBossCallbackWrapper(jobFn, entities) {
|
||||
return (args) => {
|
||||
const context = { entities }
|
||||
return jobFn(args.data, context)
|
||||
}
|
||||
}
|
@ -1,47 +0,0 @@
|
||||
import { sleep } from '../../utils.js'
|
||||
import { Job } from './Job.js'
|
||||
import { SubmittedJob } from './SubmittedJob.js'
|
||||
|
||||
export const SIMPLE_EXECUTOR_NAME = Symbol('Simple')
|
||||
|
||||
/**
|
||||
* A simple job mainly intended for testing. It will not submit work to any
|
||||
* job executor, but instead will simply invoke the underlying perform function.
|
||||
* It does not support `schedule`. It does not require any extra NPM dependencies
|
||||
* or infrastructure, however.
|
||||
*/
|
||||
class SimpleJob extends Job {
|
||||
#jobFn
|
||||
#delaySeconds
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} jobName - Name of the Job.
|
||||
* @param {fn} jobFn - The Job function to execute.
|
||||
* @param {int} delaySeconds - The number of seconds to delay invoking the Job function.
|
||||
*/
|
||||
constructor(jobName, jobFn, delaySeconds = 0) {
|
||||
super(jobName, SIMPLE_EXECUTOR_NAME)
|
||||
this.#jobFn = jobFn
|
||||
this.#delaySeconds = delaySeconds
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {int} delaySeconds - Used to delay the processing of the job by some number of seconds.
|
||||
*/
|
||||
delay(delaySeconds) {
|
||||
return new SimpleJob(this.jobName, this.#jobFn, delaySeconds)
|
||||
}
|
||||
|
||||
async submit(jobArgs) {
|
||||
sleep(this.#delaySeconds * 1000).then(() => this.#jobFn(jobArgs))
|
||||
// NOTE: Dumb random ID generator, mainly so we don't have to add `uuid`
|
||||
// as a dependency in the server generator for something nobody will likely use.
|
||||
const jobId = (Math.random() + 1).toString(36).substring(7)
|
||||
return new SubmittedJob(this, jobId)
|
||||
}
|
||||
}
|
||||
|
||||
export function createJob({ jobName, jobFn } = {}) {
|
||||
return new SimpleJob(jobName, jobFn)
|
||||
}
|
@ -10,6 +10,6 @@ export type ServerSetupFnContext = {
|
||||
}
|
||||
|
||||
export type { Application } from 'express'
|
||||
export { Server } from 'http'
|
||||
export type { Server } from 'http'
|
||||
|
||||
|
||||
|
@ -18,6 +18,7 @@
|
||||
"mitt": "3.0.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "^7.45.4",
|
||||
"react-router-dom": "^5.3.3",
|
||||
"superjson": "^1.12.2"
|
||||
},
|
||||
|
@ -18,7 +18,7 @@ const MainPage = () => {
|
||||
<div className="buttons">
|
||||
<a
|
||||
className="button button-filled"
|
||||
href="https://wasp-lang.dev/docs/tutorials/todo-app"
|
||||
href="https://wasp-lang.dev/docs/tutorial/create"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
|
@ -1,16 +0,0 @@
|
||||
import React from 'react'
|
||||
import { Route, Switch, BrowserRouter as Router } from 'react-router-dom'
|
||||
|
||||
|
||||
import MainPage from './ext-src/MainPage.jsx'
|
||||
|
||||
|
||||
const router = (
|
||||
<Router>
|
||||
<Switch>
|
||||
<Route exact path="/" component={ MainPage }/>
|
||||
</Switch>
|
||||
</Router>
|
||||
)
|
||||
|
||||
export default router
|
43
waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/src/router.tsx
generated
Normal file
43
waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/src/router.tsx
generated
Normal file
@ -0,0 +1,43 @@
|
||||
import React from 'react'
|
||||
import { Route, Switch, BrowserRouter as Router } from 'react-router-dom'
|
||||
import { interpolatePath } from './router/linkHelpers'
|
||||
import type {
|
||||
RouteDefinitionsToRoutes,
|
||||
OptionalRouteOptions,
|
||||
ParamValue,
|
||||
} from './router/types'
|
||||
|
||||
|
||||
import MainPage from './ext-src/MainPage.jsx'
|
||||
|
||||
|
||||
export const routes = {
|
||||
RootRoute: {
|
||||
to: "/",
|
||||
component: MainPage,
|
||||
build: (
|
||||
options?: OptionalRouteOptions,
|
||||
) => interpolatePath("/", undefined, options.search, options.hash),
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type Routes = RouteDefinitionsToRoutes<typeof routes>
|
||||
|
||||
const router = (
|
||||
<Router>
|
||||
<Switch>
|
||||
{Object.entries(routes).map(([routeKey, route]) => (
|
||||
<Route
|
||||
exact
|
||||
key={routeKey}
|
||||
path={route.to}
|
||||
component={route.component}
|
||||
/>
|
||||
))}
|
||||
</Switch>
|
||||
</Router>
|
||||
)
|
||||
|
||||
export default router
|
||||
|
||||
export { Link } from './router/Link'
|
20
waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/src/router/Link.tsx
generated
Normal file
20
waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/src/router/Link.tsx
generated
Normal file
@ -0,0 +1,20 @@
|
||||
import { useMemo } from 'react'
|
||||
import { Link as RouterLink } from 'react-router-dom'
|
||||
import { type Routes } from '../router'
|
||||
import { interpolatePath } from './linkHelpers'
|
||||
|
||||
type RouterLinkProps = Parameters<typeof RouterLink>[0]
|
||||
|
||||
export function Link(
|
||||
{ to, params, search, hash, ...restOfProps }: Omit<RouterLinkProps, "to">
|
||||
& {
|
||||
search?: Record<string, string>;
|
||||
hash?: string;
|
||||
}
|
||||
& Routes
|
||||
) {
|
||||
const toPropWithParams = useMemo(() => {
|
||||
return interpolatePath(to, params, search, hash)
|
||||
}, [to, params])
|
||||
return <RouterLink to={toPropWithParams} {...restOfProps} />
|
||||
}
|
@ -0,0 +1,44 @@
|
||||
import type { Params, Search } from "./types";
|
||||
|
||||
export function interpolatePath(
|
||||
path: string,
|
||||
params?: Params,
|
||||
search?: Search,
|
||||
hash?: string
|
||||
) {
|
||||
const interpolatedPath = params ? interpolatePathParams(path, params) : path
|
||||
const interpolatedSearch = search
|
||||
? `?${new URLSearchParams(search).toString()}`
|
||||
: ''
|
||||
const interpolatedHash = hash ? `#${hash}` : ''
|
||||
return interpolatedPath + interpolatedSearch + interpolatedHash
|
||||
}
|
||||
|
||||
function interpolatePathParams(path: string, params: Params) {
|
||||
function mapPathPart(part: string) {
|
||||
if (part.startsWith(":")) {
|
||||
const paramName = extractParamNameFromPathPart(part);
|
||||
return params[paramName];
|
||||
}
|
||||
return part;
|
||||
}
|
||||
|
||||
const interpolatedPath = path
|
||||
.split("/")
|
||||
.map(mapPathPart)
|
||||
.filter(isValidPathPart)
|
||||
.join("/");
|
||||
|
||||
return path.startsWith("/") ? `/${interpolatedPath}` : interpolatedPath;
|
||||
}
|
||||
|
||||
function isValidPathPart(part: any): boolean {
|
||||
return !!part;
|
||||
}
|
||||
|
||||
function extractParamNameFromPathPart(paramString: string) {
|
||||
if (paramString.endsWith("?")) {
|
||||
return paramString.slice(1, -1);
|
||||
}
|
||||
return paramString.slice(1);
|
||||
}
|
36
waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/src/router/types.ts
generated
Normal file
36
waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/src/router/types.ts
generated
Normal file
@ -0,0 +1,36 @@
|
||||
export type RouteDefinitionsToRoutes<Routes extends RoutesDefinition> =
|
||||
RouteDefinitionsToRoutesObj<Routes>[keyof RouteDefinitionsToRoutesObj<Routes>]
|
||||
|
||||
export type OptionalRouteOptions = {
|
||||
search?: Search
|
||||
hash?: string
|
||||
}
|
||||
|
||||
export type ParamValue = string | number
|
||||
export type Params = { [name: string]: ParamValue }
|
||||
export type Search =
|
||||
| string[][]
|
||||
| Record<string, string>
|
||||
| string
|
||||
| URLSearchParams
|
||||
|
||||
type RouteDefinitionsToRoutesObj<Routes extends RoutesDefinition> = {
|
||||
[K in keyof Routes]: {
|
||||
to: Routes[K]['to']
|
||||
} & ParamsFromBuildFn<Routes[K]['build']>
|
||||
}
|
||||
|
||||
type RoutesDefinition = {
|
||||
[name: string]: {
|
||||
to: string
|
||||
build: BuildFn
|
||||
}
|
||||
}
|
||||
|
||||
type BuildFn = (params: unknown) => string
|
||||
|
||||
type ParamsFromBuildFn<BF extends BuildFn> = Parameters<BF>[0] extends {
|
||||
params: infer Params
|
||||
}
|
||||
? { params: Params }
|
||||
: { params?: never }
|
@ -10,7 +10,7 @@ import { Query } from '../../queries'
|
||||
import config from '../../config'
|
||||
import { HttpMethod, Route } from '../../types'
|
||||
|
||||
export { type Route } from '../../types'
|
||||
export type { Route } from '../../types'
|
||||
|
||||
export type MockQuery = <Input, Output, MockOutput extends Output>(
|
||||
query: Query<Input, Output>,
|
||||
|
@ -4,7 +4,9 @@
|
||||
// Temporary loosen the type checking until we can address all the errors.
|
||||
"jsx": "preserve",
|
||||
"allowJs": true,
|
||||
"strict": false
|
||||
"strict": false,
|
||||
// Allow importing pages with the .tsx extension.
|
||||
"allowImportingTsExtensions": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
|
@ -1,6 +1,6 @@
|
||||
app waspCompile {
|
||||
wasp: {
|
||||
version: "^0.11.1"
|
||||
version: "^0.11.4"
|
||||
},
|
||||
title: "waspCompile"
|
||||
}
|
||||
|
@ -18,7 +18,7 @@ const MainPage = () => {
|
||||
<div className="buttons">
|
||||
<a
|
||||
className="button button-filled"
|
||||
href="https://wasp-lang.dev/docs/tutorials/todo-app"
|
||||
href="https://wasp-lang.dev/docs/tutorial/create"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
|
@ -49,15 +49,15 @@ waspComplexTest/.wasp/out/server/src/ext-src/actions/bar.js
|
||||
waspComplexTest/.wasp/out/server/src/ext-src/apiNamespaces.ts
|
||||
waspComplexTest/.wasp/out/server/src/ext-src/apis.ts
|
||||
waspComplexTest/.wasp/out/server/src/ext-src/jobs/bar.js
|
||||
waspComplexTest/.wasp/out/server/src/ext-src/jobs/returnHello.ts
|
||||
waspComplexTest/.wasp/out/server/src/ext-src/myServerSetupCode.js
|
||||
waspComplexTest/.wasp/out/server/src/ext-src/queries/bar.js
|
||||
waspComplexTest/.wasp/out/server/src/jobs/MySpecialJob.js
|
||||
waspComplexTest/.wasp/out/server/src/jobs/core/Job.js
|
||||
waspComplexTest/.wasp/out/server/src/jobs/core/SubmittedJob.js
|
||||
waspComplexTest/.wasp/out/server/src/jobs/core/allJobs.js
|
||||
waspComplexTest/.wasp/out/server/src/jobs/core/pgBoss/pgBoss.js
|
||||
waspComplexTest/.wasp/out/server/src/jobs/core/pgBoss/pgBossJob.js
|
||||
waspComplexTest/.wasp/out/server/src/jobs/core/simpleJob.js
|
||||
waspComplexTest/.wasp/out/server/src/jobs/MySpecialJob.ts
|
||||
waspComplexTest/.wasp/out/server/src/jobs/ReturnHelloJob.ts
|
||||
waspComplexTest/.wasp/out/server/src/jobs/core/allJobs.ts
|
||||
waspComplexTest/.wasp/out/server/src/jobs/core/job.ts
|
||||
waspComplexTest/.wasp/out/server/src/jobs/core/pgBoss/pgBoss.ts
|
||||
waspComplexTest/.wasp/out/server/src/jobs/core/pgBoss/pgBossJob.ts
|
||||
waspComplexTest/.wasp/out/server/src/middleware/globalMiddleware.ts
|
||||
waspComplexTest/.wasp/out/server/src/middleware/index.ts
|
||||
waspComplexTest/.wasp/out/server/src/middleware/operations.ts
|
||||
@ -130,7 +130,10 @@ waspComplexTest/.wasp/out/web-app/src/queries/core.js
|
||||
waspComplexTest/.wasp/out/web-app/src/queries/index.d.ts
|
||||
waspComplexTest/.wasp/out/web-app/src/queries/index.js
|
||||
waspComplexTest/.wasp/out/web-app/src/queryClient.js
|
||||
waspComplexTest/.wasp/out/web-app/src/router.jsx
|
||||
waspComplexTest/.wasp/out/web-app/src/router.tsx
|
||||
waspComplexTest/.wasp/out/web-app/src/router/Link.tsx
|
||||
waspComplexTest/.wasp/out/web-app/src/router/linkHelpers.ts
|
||||
waspComplexTest/.wasp/out/web-app/src/router/types.ts
|
||||
waspComplexTest/.wasp/out/web-app/src/stitches.config.js
|
||||
waspComplexTest/.wasp/out/web-app/src/storage.ts
|
||||
waspComplexTest/.wasp/out/web-app/src/test/index.ts
|
||||
@ -158,6 +161,7 @@ waspComplexTest/src/server/actions/bar.js
|
||||
waspComplexTest/src/server/apiNamespaces.ts
|
||||
waspComplexTest/src/server/apis.ts
|
||||
waspComplexTest/src/server/jobs/bar.js
|
||||
waspComplexTest/src/server/jobs/returnHello.ts
|
||||
waspComplexTest/src/server/myServerSetupCode.js
|
||||
waspComplexTest/src/server/queries/bar.js
|
||||
waspComplexTest/src/server/tsconfig.json
|
||||
|
@ -88,14 +88,14 @@
|
||||
"file",
|
||||
"server/src/_types/index.ts"
|
||||
],
|
||||
"9e8542f1b36712bbf3d633400377fed4da6fd15e69394ae521935c33cbdbbe36"
|
||||
"aa5f2c417b5732f732241362fb456b8d068a270d245d692e6530defbb035e778"
|
||||
],
|
||||
[
|
||||
[
|
||||
"file",
|
||||
"server/src/_types/serialization.ts"
|
||||
],
|
||||
"69f96a24a9c1af30c9f6f3b9f4aef7f3f62e226ef0864332527e74c542e47467"
|
||||
"73dc983c9a02cab8e2f189f1ddedd9cdcd47bf403c9dc56efba8be895b6980a1"
|
||||
],
|
||||
[
|
||||
[
|
||||
@ -123,14 +123,14 @@
|
||||
"file",
|
||||
"server/src/apis/types.ts"
|
||||
],
|
||||
"68417d37842d9186a6a9d942d65f8a18f2da8c0a9cf8a0dd5f1a4b087f6d4e20"
|
||||
"858df39f218f37d2500f8ea9847e6ff7a2e1b58214bbe717fe6b48651e7fc6a6"
|
||||
],
|
||||
[
|
||||
[
|
||||
"file",
|
||||
"server/src/app.js"
|
||||
],
|
||||
"12291f83f685eeb81a8c082961120d4bddb91985092eb142964e554a21d3384c"
|
||||
"86504ede1daeca35cc93f665ca8ac2fdf46ecaff02f6d3a7810a14d2bc71e16a"
|
||||
],
|
||||
[
|
||||
[
|
||||
@ -179,14 +179,14 @@
|
||||
"file",
|
||||
"server/src/auth/providers/types.ts"
|
||||
],
|
||||
"9859bf91e0abe0aaadf7b8c74c573607f6085a5414071836cef6ba84b2bebb69"
|
||||
"323555d76755fe32b21084f063caf931faabcb5937c279cc706bbecad3361d43"
|
||||
],
|
||||
[
|
||||
[
|
||||
"file",
|
||||
"server/src/auth/utils.ts"
|
||||
],
|
||||
"b611de9a6b546f6f1cec4497a4cb525150399131dfba32b53ba34afb04e62e96"
|
||||
"1cfb0c8095ba0ed7686229b22e8a9edd971526d50660f88d2cbb988af9b5af30"
|
||||
],
|
||||
[
|
||||
[
|
||||
@ -328,6 +328,13 @@
|
||||
],
|
||||
"83c606a3eee7608155cdb2c2a20a38f851a82987e060ce25b196b467092c4740"
|
||||
],
|
||||
[
|
||||
[
|
||||
"file",
|
||||
"server/src/ext-src/jobs/returnHello.ts"
|
||||
],
|
||||
"d10f5cd03a04f6db430b74f98834a3f128ed2c4663828d05ca04d1fccf3a0f0f"
|
||||
],
|
||||
[
|
||||
[
|
||||
"file",
|
||||
@ -345,51 +352,44 @@
|
||||
[
|
||||
[
|
||||
"file",
|
||||
"server/src/jobs/MySpecialJob.js"
|
||||
"server/src/jobs/MySpecialJob.ts"
|
||||
],
|
||||
"9bf6a5f7005d3ab4ca933fb239ef21f13ba68aef30d8767230d6cc03911ca0e1"
|
||||
"f75ff9a6f207fdb8adc94843d78ef0f7504588f8bdb686347692ece3284d9aa8"
|
||||
],
|
||||
[
|
||||
[
|
||||
"file",
|
||||
"server/src/jobs/core/Job.js"
|
||||
"server/src/jobs/ReturnHelloJob.ts"
|
||||
],
|
||||
"e0e5d5e802a29032bfc8426097950722ac0dc7931d08641c1c2b02c262e6cdcc"
|
||||
"d3d5cf184c0d984ea9ed4f6e461f9ff2058cfdffe51738fc66f64c33abc1306a"
|
||||
],
|
||||
[
|
||||
[
|
||||
"file",
|
||||
"server/src/jobs/core/SubmittedJob.js"
|
||||
"server/src/jobs/core/allJobs.ts"
|
||||
],
|
||||
"75753277b6bd2c1d2e9ea0e80a71c72c84fa18bb7d61da25d798b3ef247e06bd"
|
||||
"10a3ef50675a8de2d6c86b4677471c673a2f89f01670ec9e9c98e1dd3754f167"
|
||||
],
|
||||
[
|
||||
[
|
||||
"file",
|
||||
"server/src/jobs/core/allJobs.js"
|
||||
"server/src/jobs/core/job.ts"
|
||||
],
|
||||
"b5ddc268dfe8f1f7d96d775c1d8d407a647a45ed4937a87da7eb1eb50f9a4674"
|
||||
"c10df70eb886d6cd132b8a0b4f619dc500bbd4ec3174ef156cdad41be8cce021"
|
||||
],
|
||||
[
|
||||
[
|
||||
"file",
|
||||
"server/src/jobs/core/pgBoss/pgBoss.js"
|
||||
"server/src/jobs/core/pgBoss/pgBoss.ts"
|
||||
],
|
||||
"9821963d90b39058285343834c70e6f825d3d7696c738fd95539614b5e7d7b94"
|
||||
"4ca3ab19aa84a0ac4f62f6d9b3ee2304fd41ab6649320473d975ae438e3c3cfd"
|
||||
],
|
||||
[
|
||||
[
|
||||
"file",
|
||||
"server/src/jobs/core/pgBoss/pgBossJob.js"
|
||||
"server/src/jobs/core/pgBoss/pgBossJob.ts"
|
||||
],
|
||||
"ff6040d051c916eb080a2f2c37fd5135f588782387faeae51115d1a7abd1ad8b"
|
||||
],
|
||||
[
|
||||
[
|
||||
"file",
|
||||
"server/src/jobs/core/simpleJob.js"
|
||||
],
|
||||
"36fe173d9f5128859196bfd3a661983df2d95eb34d165a469b840982b06cf59b"
|
||||
"700b83862734d7ee752f685381de30fa730580fb950fbc61d168f51d1fe44bb4"
|
||||
],
|
||||
[
|
||||
[
|
||||
@ -501,7 +501,7 @@
|
||||
"file",
|
||||
"server/src/types/index.ts"
|
||||
],
|
||||
"04e01e653bd889436903843cf1dae0a6ba0ecbc1c369beef0e90fae4d0c21127"
|
||||
"0044d2263b936cbc20a278afa4aa61cd9d85821325f490b3cee8bbfb068ed836"
|
||||
],
|
||||
[
|
||||
[
|
||||
@ -536,7 +536,7 @@
|
||||
"file",
|
||||
"web-app/.env"
|
||||
],
|
||||
"d9649485a674ce3c6b8d668a1253cde3bd8695cb3d2d3a6b7c573a3e2805c7ec"
|
||||
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
],
|
||||
[
|
||||
[
|
||||
@ -578,7 +578,7 @@
|
||||
"file",
|
||||
"web-app/package.json"
|
||||
],
|
||||
"fc5d4d8e4a3ed36972eac6891122dfd3d8edb4074a8df041162dcb38bb6f6d2d"
|
||||
"7857f3a9fc85cd39f16dbd9362bccc5e5662b5b216da45b843ebca6bba4142b5"
|
||||
],
|
||||
[
|
||||
[
|
||||
@ -655,7 +655,7 @@
|
||||
"file",
|
||||
"web-app/src/auth/forms/Auth.tsx"
|
||||
],
|
||||
"d40cf940a499fdd4b137dcf9f3cd4fbe0bbab4b7c44eb7819b41daeaa861050b"
|
||||
"fc6c204f73999f556eab441772e66dd4dbd433bd69aa2c9e64e713fb2921a886"
|
||||
],
|
||||
[
|
||||
[
|
||||
@ -669,14 +669,14 @@
|
||||
"file",
|
||||
"web-app/src/auth/forms/Signup.tsx"
|
||||
],
|
||||
"cacd5348e84d42bc142f6c6e00051a8e86f6b17eb037b2772772f073925bf570"
|
||||
"a38124a9a250a603ef6d04dbcb46c04084ace676e9de5fc31f229405d87f47d4"
|
||||
],
|
||||
[
|
||||
[
|
||||
"file",
|
||||
"web-app/src/auth/forms/internal/Form.tsx"
|
||||
],
|
||||
"ce6b409fda73d88e762b27aef7038618961e61e6ef8e5f66912325ab88a8223e"
|
||||
"b9b21954b919f173b751c0078aed8303cc15d88df9e9874228efcae7976f26cd"
|
||||
],
|
||||
[
|
||||
[
|
||||
@ -690,7 +690,7 @@
|
||||
"file",
|
||||
"web-app/src/auth/forms/internal/common/LoginSignupForm.tsx"
|
||||
],
|
||||
"c92bab325f51159c3d1bb285c7e807acbb85069c5b16afc3a35fbd0121c91b6a"
|
||||
"f211f57dca3f10f08a3e618d27bf8006d26f6832bb7e40f8a22ae44f2d42531e"
|
||||
],
|
||||
[
|
||||
[
|
||||
@ -711,7 +711,7 @@
|
||||
"file",
|
||||
"web-app/src/auth/forms/types.ts"
|
||||
],
|
||||
"c4066fbd39ec20a3d43be9f9d5762d555dbc006057579ac168a67b2678918a13"
|
||||
"992ca4b2c8e30536636143c556e6bdcc5d5d0d86c1eb2e119171e25d5c33b4e3"
|
||||
],
|
||||
[
|
||||
[
|
||||
@ -753,7 +753,7 @@
|
||||
"file",
|
||||
"web-app/src/auth/types.ts"
|
||||
],
|
||||
"b6572845796b4217c142b855fc72652d5dfd67fd317b0ed5487fa70ba3d92219"
|
||||
"0d37136807f6d196015d07b65ab56280ae5f56cac9be84992318886550ce4ad3"
|
||||
],
|
||||
[
|
||||
[
|
||||
@ -802,7 +802,7 @@
|
||||
"file",
|
||||
"web-app/src/ext-src/MainPage.jsx"
|
||||
],
|
||||
"7244c106359f088fdcc0d4a76ee63277f1cea63cbe7aac5e5c39a17df693b1e2"
|
||||
"8ee7fe1352719facadf0935eb45df8661ba13015277ea80be5a9200c66a31bde"
|
||||
],
|
||||
[
|
||||
[
|
||||
@ -905,16 +905,37 @@
|
||||
[
|
||||
[
|
||||
"file",
|
||||
"web-app/src/router.jsx"
|
||||
"web-app/src/router.tsx"
|
||||
],
|
||||
"ba66454c9f0ca79ed5bc6af694b5b9f748db75cf82309f2600c78069a3b9d0f7"
|
||||
"1b167573635d206d53a8598ae39a81b140cd017c6c71ce13bf57c9a04b5b8160"
|
||||
],
|
||||
[
|
||||
[
|
||||
"file",
|
||||
"web-app/src/router/Link.tsx"
|
||||
],
|
||||
"7b6214295d59d8dffbd61b82f9dab2b080b2d7ebe98cc7d9f9e8c229f99a890d"
|
||||
],
|
||||
[
|
||||
[
|
||||
"file",
|
||||
"web-app/src/router/linkHelpers.ts"
|
||||
],
|
||||
"c296ed5e7924ad1173f4f0fb4dcce053cffd5812612069b5f62d1bf9e96495cf"
|
||||
],
|
||||
[
|
||||
[
|
||||
"file",
|
||||
"web-app/src/router/types.ts"
|
||||
],
|
||||
"7f08b262987c17f953c4b95814631a7aaac82eb77660bcb247ef7bf866846fe1"
|
||||
],
|
||||
[
|
||||
[
|
||||
"file",
|
||||
"web-app/src/stitches.config.js"
|
||||
],
|
||||
"7de37836b80021870f286ff14d275e2ca7a1c2aa113ba5a5624ed0c77e178f76"
|
||||
"f238234a9db89d6a34c7a8c7c948a58c011da8e167ff94d72e7c6808beb4e177"
|
||||
],
|
||||
[
|
||||
[
|
||||
@ -935,7 +956,7 @@
|
||||
"file",
|
||||
"web-app/src/test/vitest/helpers.tsx"
|
||||
],
|
||||
"a0576215b0786c6e082b8a83f6418a3acb084bfb673a152baad8e8a7fc46fcae"
|
||||
"a38e55c9999a87ab497538bcad7c880f32a4d27f2227ae326cb76eb0848b89e9"
|
||||
],
|
||||
[
|
||||
[
|
||||
@ -984,7 +1005,7 @@
|
||||
"file",
|
||||
"web-app/tsconfig.json"
|
||||
],
|
||||
"887c55937264ea8b2c538340962c3011091cf3eb6b9d39523acbe8ebcdd35474"
|
||||
"f1b31ca75b2b32c5b0441aec4fcd7f285c18346761ba1640761d6253d65e3580"
|
||||
],
|
||||
[
|
||||
[
|
||||
|
@ -1 +1 @@
|
||||
{"npmDepsForServer":{"dependencies":[{"name":"cookie-parser","version":"~1.4.6"},{"name":"cors","version":"^2.8.5"},{"name":"express","version":"~4.18.1"},{"name":"morgan","version":"~1.10.0"},{"name":"@prisma/client","version":"4.12.0"},{"name":"jsonwebtoken","version":"^8.5.1"},{"name":"secure-password","version":"^4.0.0"},{"name":"dotenv","version":"16.0.2"},{"name":"helmet","version":"^6.0.0"},{"name":"patch-package","version":"^6.4.7"},{"name":"uuid","version":"^9.0.0"},{"name":"lodash.merge","version":"^4.6.2"},{"name":"rate-limiter-flexible","version":"^2.4.1"},{"name":"superjson","version":"^1.12.2"},{"name":"passport","version":"0.6.0"},{"name":"passport-google-oauth20","version":"2.0.0"},{"name":"pg-boss","version":"^8.4.2"},{"name":"@sendgrid/mail","version":"^7.7.0"},{"name":"react-redux","version":"^7.1.3"},{"name":"redux","version":"^4.0.5"}],"devDependencies":[{"name":"nodemon","version":"^2.0.19"},{"name":"standard","version":"^17.0.0"},{"name":"prisma","version":"4.12.0"},{"name":"typescript","version":"^5.1.0"},{"name":"@types/express","version":"^4.17.13"},{"name":"@types/express-serve-static-core","version":"^4.17.13"},{"name":"@types/node","version":"^18.11.9"},{"name":"@tsconfig/node18","version":"^1.0.1"},{"name":"@types/uuid","version":"^9.0.0"},{"name":"@types/cors","version":"^2.8.5"}]},"npmDepsForWebApp":{"dependencies":[{"name":"axios","version":"^1.4.0"},{"name":"react","version":"^18.2.0"},{"name":"react-dom","version":"^18.2.0"},{"name":"@tanstack/react-query","version":"^4.29.0"},{"name":"react-router-dom","version":"^5.3.3"},{"name":"@prisma/client","version":"4.12.0"},{"name":"superjson","version":"^1.12.2"},{"name":"mitt","version":"3.0.0"},{"name":"@stitches/react","version":"^1.2.8"},{"name":"react-redux","version":"^7.1.3"},{"name":"redux","version":"^4.0.5"}],"devDependencies":[{"name":"vite","version":"^4.3.9"},{"name":"typescript","version":"^5.1.0"},{"name":"@types/react","version":"^18.0.37"},{"name":"@types/react-dom","version":"^18.0.11"},{"name":"@types/react-router-dom","version":"^5.3.3"},{"name":"@vitejs/plugin-react-swc","version":"^3.0.0"},{"name":"dotenv","version":"^16.0.3"},{"name":"@tsconfig/vite-react","version":"^2.0.0"},{"name":"vitest","version":"^0.29.3"},{"name":"@vitest/ui","version":"^0.29.3"},{"name":"jsdom","version":"^21.1.1"},{"name":"@testing-library/react","version":"^14.0.0"},{"name":"@testing-library/jest-dom","version":"^5.16.5"},{"name":"msw","version":"^1.1.0"}]}}
|
||||
{"npmDepsForServer":{"dependencies":[{"name":"cookie-parser","version":"~1.4.6"},{"name":"cors","version":"^2.8.5"},{"name":"express","version":"~4.18.1"},{"name":"morgan","version":"~1.10.0"},{"name":"@prisma/client","version":"4.12.0"},{"name":"jsonwebtoken","version":"^8.5.1"},{"name":"secure-password","version":"^4.0.0"},{"name":"dotenv","version":"16.0.2"},{"name":"helmet","version":"^6.0.0"},{"name":"patch-package","version":"^6.4.7"},{"name":"uuid","version":"^9.0.0"},{"name":"lodash.merge","version":"^4.6.2"},{"name":"rate-limiter-flexible","version":"^2.4.1"},{"name":"superjson","version":"^1.12.2"},{"name":"passport","version":"0.6.0"},{"name":"passport-google-oauth20","version":"2.0.0"},{"name":"pg-boss","version":"^8.4.2"},{"name":"@sendgrid/mail","version":"^7.7.0"},{"name":"react-redux","version":"^7.1.3"},{"name":"redux","version":"^4.0.5"}],"devDependencies":[{"name":"nodemon","version":"^2.0.19"},{"name":"standard","version":"^17.0.0"},{"name":"prisma","version":"4.12.0"},{"name":"typescript","version":"^5.1.0"},{"name":"@types/express","version":"^4.17.13"},{"name":"@types/express-serve-static-core","version":"^4.17.13"},{"name":"@types/node","version":"^18.11.9"},{"name":"@tsconfig/node18","version":"^1.0.1"},{"name":"@types/uuid","version":"^9.0.0"},{"name":"@types/cors","version":"^2.8.5"}]},"npmDepsForWebApp":{"dependencies":[{"name":"axios","version":"^1.4.0"},{"name":"react","version":"^18.2.0"},{"name":"react-dom","version":"^18.2.0"},{"name":"@tanstack/react-query","version":"^4.29.0"},{"name":"react-router-dom","version":"^5.3.3"},{"name":"@prisma/client","version":"4.12.0"},{"name":"superjson","version":"^1.12.2"},{"name":"mitt","version":"3.0.0"},{"name":"react-hook-form","version":"^7.45.4"},{"name":"@stitches/react","version":"^1.2.8"},{"name":"react-redux","version":"^7.1.3"},{"name":"redux","version":"^4.0.5"}],"devDependencies":[{"name":"vite","version":"^4.3.9"},{"name":"typescript","version":"^5.1.0"},{"name":"@types/react","version":"^18.0.37"},{"name":"@types/react-dom","version":"^18.0.11"},{"name":"@types/react-router-dom","version":"^5.3.3"},{"name":"@vitejs/plugin-react-swc","version":"^3.0.0"},{"name":"dotenv","version":"^16.0.3"},{"name":"@tsconfig/vite-react","version":"^2.0.0"},{"name":"vitest","version":"^0.29.3"},{"name":"@vitest/ui","version":"^0.29.3"},{"name":"jsdom","version":"^21.1.1"},{"name":"@testing-library/react","version":"^14.0.0"},{"name":"@testing-library/jest-dom","version":"^5.16.5"},{"name":"msw","version":"^1.1.0"}]}}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user