Merge branch 'main' into wasp-ai

This commit is contained in:
Mihovil Ilakovac 2023-09-14 12:23:29 +02:00
commit 16fa3bd487
462 changed files with 26369 additions and 7151 deletions

12
wasp-ai/.gitignore vendored
View File

@ -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.*

View File

@ -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

View File

@ -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

View File

@ -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"
>

View File

@ -3,6 +3,9 @@
datasource db {
provider = "{= datasourceProvider =}"
url = {=& datasourceUrl =}
{=# dbExtensions =}
extensions = {=& . =}
{=/ dbExtensions =}
}
generator client {

View File

@ -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 =}

View File

@ -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}
/>
)
}

View File

@ -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',
})

View File

@ -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'
}

View File

@ -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}>

View File

@ -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}>

View File

@ -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,
}
}

View File

@ -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,
}
}

View File

@ -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

View File

@ -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/'

View File

@ -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'

View 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} />
}

View 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);
}

View 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 }

View File

@ -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'

View File

@ -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>,

View File

@ -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" }]

View 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,
}

View File

@ -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 =}

View File

@ -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
}

View File

@ -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 =}

View File

@ -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)
})

View File

@ -0,0 +1,7 @@
{{={= =}=}}
{=# isEmailAuthEnabled =}
export { defineAdditionalSignupFields } from './providers/email/types.js';
{=/ isEmailAuthEnabled =}
{=# isLocalAuthEnabled =}
export { defineAdditionalSignupFields } from './providers/local/types.js';
{=/ isLocalAuthEnabled =}

View File

@ -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,
});

View File

@ -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">()

View File

@ -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,
})

View File

@ -0,0 +1,3 @@
import { createDefineAdditionalSignupFieldsFn } from '../types.js'
export const defineAdditionalSignupFields = createDefineAdditionalSignupFieldsFn<"username" | "password">()

View File

@ -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

View File

@ -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;
}

View File

@ -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 =}
},
})

View 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,
})

View File

@ -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')
}
}

View File

@ -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
}
}

View 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
}
}

View File

@ -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) {

View File

@ -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)
}
}

View File

@ -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 }

View File

@ -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)
}

View File

@ -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';

View 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?}}

View File

@ -0,0 +1,7 @@
{{#named?}}export {{/named?}}const {{name}} = async (args, context) => {
// Implementation goes here
}
{{#default?}}
export default {{name}}
{{/default?}}

View File

@ -0,0 +1,9 @@
{{#named?}}export {{/named?}}function {{name}}() {
return (
<div>Hello world!</div>
)
}
{{#default?}}
export default {{name}}
{{/default?}}

View 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?}}

View File

@ -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"]

View File

@ -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

View File

@ -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"
],
[
[

View File

@ -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"}]}}

View File

@ -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<{

View File

@ -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
}

View File

@ -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)
})

View File

@ -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')
}
}

View File

@ -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
}
}

View File

@ -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.

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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)
}

View File

@ -10,6 +10,6 @@ export type ServerSetupFnContext = {
}
export type { Application } from 'express'
export { Server } from 'http'
export type { Server } from 'http'

View File

@ -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"
},

View File

@ -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"
>

View File

@ -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

View 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'

View 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} />
}

View 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);
}

View 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 }

View File

@ -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>,

View File

@ -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" }]

View File

@ -1,7 +1,7 @@
app waspBuild {
db: { system: PostgreSQL },
wasp: {
version: "^0.11.1"
version: "^0.11.4"
},
title: "waspBuild"
}

View File

@ -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"
>

View File

@ -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

View File

@ -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"
],
[
[

View File

@ -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"}]}}

View File

@ -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<{

View File

@ -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
}

View File

@ -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)
})

View File

@ -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')
}
}

View File

@ -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
}
}

View File

@ -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.

View File

@ -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)
}
}

View File

@ -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)
}

View File

@ -10,6 +10,6 @@ export type ServerSetupFnContext = {
}
export type { Application } from 'express'
export { Server } from 'http'
export type { Server } from 'http'

View File

@ -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"
},

View File

@ -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"
>

View File

@ -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

View 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'

View 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} />
}

View 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);
}

View 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 }

View File

@ -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>,

View File

@ -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" }]

View File

@ -1,6 +1,6 @@
app waspCompile {
wasp: {
version: "^0.11.1"
version: "^0.11.4"
},
title: "waspCompile"
}

View File

@ -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"
>

View File

@ -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

View File

@ -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"
],
[
[

View File

@ -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