Implement Wasp SDK generation (#1626)

This commit is contained in:
Filip Sodić 2024-01-29 16:08:15 +01:00 committed by GitHub
parent bc9e1b6bce
commit cbd7310b16
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
308 changed files with 9281 additions and 3892 deletions

View File

@ -22,13 +22,7 @@ import qualified Wasp.Generator
import Wasp.Generator.Monad (GeneratorWarning (GeneratorNeedsMigrationWarning))
import qualified Wasp.Message as Msg
import Wasp.Project (CompileError, CompileWarning, WaspProjectDir)
import Wasp.Project.Common
( buildDirInDotWaspDir,
dotWaspDirInWaspProjectDir,
extClientCodeDirInWaspProjectDir,
extServerCodeDirInWaspProjectDir,
extSharedCodeDirInWaspProjectDir,
)
import Wasp.Project.Common (buildDirInDotWaspDir, dotWaspDirInWaspProjectDir)
-- | Builds Wasp project that the current working directory is part of.
-- Does all the steps, from analysis to generation, and at the end writes generated code
@ -71,9 +65,7 @@ buildIO waspProjectDir buildDir = compileIOWithOptions options waspProjectDir bu
where
options =
CompileOptions
{ externalClientCodeDirPath = waspProjectDir </> extClientCodeDirInWaspProjectDir,
externalServerCodeDirPath = waspProjectDir </> extServerCodeDirInWaspProjectDir,
externalSharedCodeDirPath = waspProjectDir </> extSharedCodeDirInWaspProjectDir,
{ waspProjectDirPath = waspProjectDir,
isBuild = True,
sendMessage = cliSendMessage,
-- Ignore "DB needs migration warnings" during build, as that is not a required step.

View File

@ -26,7 +26,7 @@ import qualified Wasp.Generator
import qualified Wasp.Message as Msg
import Wasp.Project (CompileError, CompileWarning, WaspProjectDir)
import qualified Wasp.Project
import Wasp.Project.Common (dotWaspDirInWaspProjectDir, extClientCodeDirInWaspProjectDir, extServerCodeDirInWaspProjectDir, extSharedCodeDirInWaspProjectDir, generatedCodeDirInDotWaspDir)
import Wasp.Project.Common (dotWaspDirInWaspProjectDir, generatedCodeDirInDotWaspDir)
-- | Same like 'compileWithOptions', but with default compile options.
compile :: Command [CompileWarning]
@ -115,9 +115,7 @@ compileIOWithOptions options waspProjectDir outDir =
defaultCompileOptions :: Path' Abs (Dir WaspProjectDir) -> CompileOptions
defaultCompileOptions waspProjectDir =
CompileOptions
{ externalServerCodeDirPath = waspProjectDir </> extServerCodeDirInWaspProjectDir,
externalClientCodeDirPath = waspProjectDir </> extClientCodeDirInWaspProjectDir,
externalSharedCodeDirPath = waspProjectDir </> extSharedCodeDirInWaspProjectDir,
{ waspProjectDirPath = waspProjectDir,
isBuild = False,
sendMessage = cliSendMessage,
generatorWarningsFilter = id

View File

@ -15,9 +15,12 @@ import Wasp.Cli.Command.Message (cliSendMessageC)
import Wasp.Cli.Command.Require (InWaspProject (InWaspProject), require)
import Wasp.Cli.Command.Watch (watch)
import qualified Wasp.Generator
import Wasp.Generator.Common (ProjectRootDir)
import qualified Wasp.Message as Msg
import Wasp.Project.Common (dotWaspDirInWaspProjectDir, generatedCodeDirInDotWaspDir)
import Wasp.Project.Common
( WaspProjectDir,
dotWaspDirInWaspProjectDir,
generatedCodeDirInDotWaspDir,
)
test :: [String] -> Command ()
test [] = throwError $ CommandError "Not enough arguments" "Expected: wasp test client <args>"
@ -25,7 +28,7 @@ test ("client" : args) = watchAndTest $ Wasp.Generator.testWebApp args
test ("server" : _args) = throwError $ CommandError "Invalid arguments" "Server testing not yet implemented."
test _ = throwError $ CommandError "Invalid arguments" "Expected: wasp test client <args>"
watchAndTest :: (Path' Abs (Dir ProjectRootDir) -> IO (Either String ())) -> Command ()
watchAndTest :: (Path' Abs (Dir WaspProjectDir) -> IO (Either String ())) -> Command ()
watchAndTest testRunner = do
InWaspProject waspRoot <- require
let outDir = waspRoot </> dotWaspDirInWaspProjectDir </> generatedCodeDirInDotWaspDir
@ -39,7 +42,7 @@ watchAndTest testRunner = do
watchOrStartResult <- liftIO $ do
ongoingCompilationResultMVar <- newMVar (warnings, [])
let watchWaspProjectSource = watch waspRoot outDir ongoingCompilationResultMVar
watchWaspProjectSource `race` testRunner outDir
watchWaspProjectSource `race` testRunner waspRoot
case watchOrStartResult of
Left () -> error "This should never happen, listening for file changes should never end but it did."

View File

@ -19,7 +19,7 @@ import Wasp.Cli.Message (cliSendMessage)
import qualified Wasp.Generator.Common as Wasp.Generator
import qualified Wasp.Message as Msg
import Wasp.Project (CompileError, CompileWarning, WaspProjectDir)
import Wasp.Project.Common (srcDirInWaspProjectDir)
import Wasp.Project.Common (extPublicDirInWaspProjectDir, srcDirInWaspProjectDir)
-- TODO: Idea: Read .gitignore file, and ignore everything from it. This will then also cover the
-- .wasp dir, and users can easily add any custom stuff they want ignored. But, we also have to
@ -39,7 +39,8 @@ watch ::
watch waspProjectDir outDir ongoingCompilationResultMVar = FSN.withManager $ \mgr -> do
chan <- newChan
_ <- watchFilesAtTopLevelOfWaspProjectDir mgr chan
_ <- watchFilesAtAllLevelsOfSrcDirInWaspProjectDir mgr chan
_ <- watchFilesAtAllLevelsOfDirInWaspProjectDir mgr chan srcDirInWaspProjectDir
_ <- watchFilesAtAllLevelsOfDirInWaspProjectDir mgr chan extPublicDirInWaspProjectDir
listenForEvents chan =<< getCurrentTime
where
watchFilesAtTopLevelOfWaspProjectDir mgr chan =
@ -53,8 +54,8 @@ watch waspProjectDir outDir ongoingCompilationResultMVar = FSN.withManager $ \mg
where
filename = FP.takeFileName $ FSN.eventPath event
watchFilesAtAllLevelsOfSrcDirInWaspProjectDir mgr chan =
FSN.watchTreeChan mgr (SP.fromAbsDir $ waspProjectDir </> srcDirInWaspProjectDir) eventFilter chan
watchFilesAtAllLevelsOfDirInWaspProjectDir mgr chan dirInWaspProjectDir =
FSN.watchTreeChan mgr (SP.fromAbsDir $ waspProjectDir </> dirInWaspProjectDir) eventFilter chan
where
eventFilter :: FSN.Event -> Bool
eventFilter event =

View File

@ -5,6 +5,8 @@
"react": "^18.2.0"
},
"devDependencies": {
"typescript": "^5.1.0",
"vite": "^4.3.9",
"@types/react": "^18.0.37",
"prisma": "4.16.2"
},

View File

@ -1,4 +1,4 @@
import { throwIfNotValidAbsoluteURL } from './universal/validators.mjs';
import { throwIfNotValidAbsoluteURL } from 'wasp/universal/validators';
console.info("🔍 Validating environment variables...");
throwIfNotValidAbsoluteURL(process.env.REACT_APP_API_URL, 'Environemnt variable REACT_APP_API_URL');

View File

@ -1,10 +0,0 @@
{{={= =}=}}
import { createAction } from './core'
{=& operationTypeImportStmt =}
const action = createAction<{= operationTypeName =}>(
'{= actionRoute =}',
{=& entitiesArray =},
)
export default action

View File

@ -1,13 +0,0 @@
import { type Action } from '.'
import type { Expand, _Awaited, _ReturnType } from '../universal/types'
export function createAction<BackendAction extends GenericBackendAction>(
actionRoute: string,
entitiesUsed: unknown[]
): ActionFor<BackendAction>
type ActionFor<BackendAction extends GenericBackendAction> = Expand<
Action<Parameters<BackendAction>[0], _Awaited<_ReturnType<BackendAction>>>
>
type GenericBackendAction = (args: never, context: any) => unknown

View File

@ -1,35 +0,0 @@
import { callOperation, makeOperationRoute } from '../operations'
import {
registerActionInProgress,
registerActionDone,
} from '../operations/resources'
export function createAction(relativeActionRoute, entitiesUsed) {
const actionRoute = makeOperationRoute(relativeActionRoute)
async function internalAction(args, specificOptimisticUpdateDefinitions) {
registerActionInProgress(specificOptimisticUpdateDefinitions)
try {
// The `return await` is not redundant here. If we removed the await, the
// `finally` block would execute before the action finishes, prematurely
// registering the action as done.
return await callOperation(actionRoute, args)
} finally {
await registerActionDone(entitiesUsed, specificOptimisticUpdateDefinitions)
}
}
// We expose (and document) a restricted version of the API for our users,
// while also attaching the full "internal" API to the exposed action. By
// doing this, we can easily use the internal API of an action a users passes
// into our system (e.g., through the `useAction` hook) without needing a
// lookup table.
//
// While it does technically allow our users to access the interal API, it
// shouldn't be a problem in practice. Still, if it turns out to be a problem,
// we can always hide it using a Symbol.
const action = (args) => internalAction(args, [])
action.internal = internalAction
return action
}

View File

@ -1,269 +0,0 @@
import {
QueryClient,
QueryKey,
useMutation,
UseMutationOptions,
useQueryClient,
} from '@tanstack/react-query'
import { type Query } from '../queries';
export type Action<Input, Output> =
[Input] extends [never] ?
(args?: unknown) => Promise<Output> :
(args: Input) => Promise<Output>
/**
* An options object passed into the `useAction` hook and used to enhance the
* action with extra options.
*
*/
export type ActionOptions<ActionInput> = {
optimisticUpdates: OptimisticUpdateDefinition<ActionInput, any>[]
}
/**
* A documented (public) way to define optimistic updates.
*/
export type OptimisticUpdateDefinition<ActionInput, CachedData> = {
getQuerySpecifier: GetQuerySpecifier<ActionInput, CachedData>
updateQuery: UpdateQuery<ActionInput, CachedData>
}
/**
* A function that takes an item and returns a Wasp Query specifier.
*/
export type GetQuerySpecifier<ActionInput, CachedData> = (item: ActionInput) => QuerySpecifier<unknown, CachedData>
/**
* A function that takes an item and the previous state of the cache, and returns
* the desired (new) state of the cache.
*/
export type UpdateQuery<ActionInput, CachedData> = (item: ActionInput, oldData: CachedData | undefined) => CachedData
/**
* A public query specifier used for addressing Wasp queries. See our docs for details:
* https://wasp-lang.dev/docs/data-model/operations/actions#the-useaction-hook-and-optimistic-updates
*/
export type QuerySpecifier<Input, Output> = [Query<Input, Output>, ...any[]]
/**
* A hook for adding extra behavior to a Wasp Action (e.g., optimistic updates).
*
* @param actionFn The Wasp Action you wish to enhance/decorate.
* @param actionOptions An options object for enhancing/decorating the given Action.
* @returns A decorated Action with added behavior but an unchanged API.
*/
export function useAction<Input = unknown, Output = unknown>(
actionFn: Action<Input, Output>,
actionOptions?: ActionOptions<Input>
): typeof actionFn {
const queryClient = useQueryClient();
let mutationFn = actionFn
let options = {}
if (actionOptions?.optimisticUpdates) {
const optimisticUpdatesDefinitions = actionOptions.optimisticUpdates.map(translateToInternalDefinition)
mutationFn = makeOptimisticUpdateMutationFn(actionFn, optimisticUpdatesDefinitions)
options = makeRqOptimisticUpdateOptions(queryClient, optimisticUpdatesDefinitions)
}
// NOTE: We decided to hide React Query's extra mutation features (e.g.,
// isLoading, onSuccess and onError callbacks, synchronous mutate) and only
// expose a simple async function whose API matches the original Action.
// We did this to avoid cluttering the API with stuff we're not sure we need
// yet (e.g., isLoading), to postpone the action vs mutation dilemma, and to
// clearly separate our opinionated API from React Query's lower-level
// advanced API (which users can also use)
const mutation = useMutation(mutationFn, options)
return (args) => mutation.mutateAsync(args)
}
/**
* An internal (undocumented, private, desugared) way of defining optimistic updates.
*/
type InternalOptimisticUpdateDefinition<ActionInput, CachedData> = {
getQueryKey: (item: ActionInput) => QueryKey,
updateQuery: UpdateQuery<ActionInput, CachedData>;
}
/**
* An UpdateQuery function "instantiated" with a specific item. It only takes
* the current state of the cache and returns the desired (new) state of the
* cache.
*/
type SpecificUpdateQuery<CachedData> = (oldData: CachedData) => CachedData
/**
* A specific, "instantiated" optimistic update definition which contains a
* fully-constructed query key and a specific update function.
*/
type SpecificOptimisticUpdateDefinition<Item> = {
queryKey: QueryKey;
updateQuery: SpecificUpdateQuery<Item>;
}
type InternalAction<Input, Output> = Action<Input, Output> & {
internal<CachedData extends unknown>(
item: Input,
optimisticUpdateDefinitions: SpecificOptimisticUpdateDefinition<CachedData>[]
): Promise<Output>
}
/**
* Translates/Desugars a public optimistic update definition object into a
* definition object our system uses internally.
*
* @param publicOptimisticUpdateDefinition An optimistic update definition
* object that's a part of the public API:
* https://wasp-lang.dev/docs/data-model/operations/actions#the-useaction-hook-and-optimistic-updates
* @returns An internally-used optimistic update definition object.
*/
function translateToInternalDefinition<Item, CachedData>(
publicOptimisticUpdateDefinition: OptimisticUpdateDefinition<Item, CachedData>
): InternalOptimisticUpdateDefinition<Item, CachedData> {
const { getQuerySpecifier, updateQuery } = publicOptimisticUpdateDefinition
const definitionErrors = []
if (typeof getQuerySpecifier !== 'function') {
definitionErrors.push('`getQuerySpecifier` is not a function.')
}
if (typeof updateQuery !== 'function') {
definitionErrors.push('`updateQuery` is not a function.')
}
if (definitionErrors.length) {
throw new TypeError(`Invalid optimistic update definition: ${definitionErrors.join(', ')}.`)
}
return {
getQueryKey: (item) => getRqQueryKeyFromSpecifier(getQuerySpecifier(item)),
updateQuery,
}
}
/**
* Creates a function that performs an action while telling it about the
* optimistic updates it caused.
*
* @param actionFn The Wasp Action.
* @param optimisticUpdateDefinitions The optimisitc updates the action causes.
* @returns An decorated action which performs optimistic updates.
*/
function makeOptimisticUpdateMutationFn<Input, Output, CachedData>(
actionFn: Action<Input, Output>,
optimisticUpdateDefinitions: InternalOptimisticUpdateDefinition<Input, CachedData>[]
): typeof actionFn {
return function performActionWithOptimisticUpdates(item) {
const specificOptimisticUpdateDefinitions = optimisticUpdateDefinitions.map(
generalDefinition => getOptimisticUpdateDefinitionForSpecificItem(generalDefinition, item)
)
return (actionFn as InternalAction<Input, Output>).internal(item, specificOptimisticUpdateDefinitions)
}
}
/**
* Given a ReactQuery query client and our internal definition of optimistic
* updates, this function constructs an object describing those same optimistic
* updates in a format we can pass into React Query's useMutation hook. In other
* words, it translates our optimistic updates definition into React Query's
* optimistic updates definition. Check their docs for details:
* https://tanstack.com/query/v4/docs/guides/optimistic-updates?from=reactQueryV3&original=https://react-query-v3.tanstack.com/guides/optimistic-updates
*
* @param queryClient The QueryClient instance used by React Query.
* @param optimisticUpdateDefinitions A list containing internal optimistic
* updates definition objects (i.e., a list where each object carries the
* instructions for performing particular optimistic update).
* @returns An object containing 'onMutate' and 'onError' functions
* corresponding to the given optimistic update definitions (check the docs
* linked above for details).
*/
function makeRqOptimisticUpdateOptions<ActionInput, CachedData>(
queryClient: QueryClient,
optimisticUpdateDefinitions: InternalOptimisticUpdateDefinition<ActionInput, CachedData>[]
): Pick<UseMutationOptions, "onMutate" | "onError"> {
async function onMutate(item) {
const specificOptimisticUpdateDefinitions = optimisticUpdateDefinitions.map(
generalDefinition => getOptimisticUpdateDefinitionForSpecificItem(generalDefinition, item)
)
// Cancel any outgoing refetches (so they don't overwrite our optimistic update).
// Theoretically, we can be a bit faster. Instead of awaiting the
// cancellation of all queries, we could cancel and update them in parallel.
// However, awaiting cancellation hasn't yet proven to be a performance bottleneck.
await Promise.all(specificOptimisticUpdateDefinitions.map(
({ queryKey }) => queryClient.cancelQueries(queryKey)
))
// We're using a Map to correctly serialize query keys that contain objects.
const previousData = new Map()
specificOptimisticUpdateDefinitions.forEach(({ queryKey, updateQuery }) => {
// Snapshot the currently cached value.
const previousDataForQuery: CachedData = queryClient.getQueryData(queryKey)
// Attempt to optimistically update the cache using the new value.
try {
queryClient.setQueryData(queryKey, updateQuery)
} catch (e) {
console.error("The `updateQuery` function threw an exception, skipping optimistic update:")
console.error(e)
}
// Remember the snapshotted value to restore in case of an error.
previousData.set(queryKey, previousDataForQuery)
})
return { previousData }
}
function onError(_err, _item, context) {
// All we do in case of an error is roll back all optimistic updates. We ensure
// not to do anything else because React Query rethrows the error. This allows
// the programmer to handle the error as they usually would (i.e., we want the
// error handling to work as it would if the programmer wasn't using optimistic
// updates).
context.previousData.forEach(async (data, queryKey) => {
await queryClient.cancelQueries(queryKey)
queryClient.setQueryData(queryKey, data)
})
}
return {
onMutate,
onError,
}
}
/**
* Constructs the definition for optimistically updating a specific item. It
* uses a closure over the updated item to construct an item-specific query key
* (e.g., useful when the query key depends on an ID).
*
* @param optimisticUpdateDefinition The general, "uninstantiated" optimistic
* update definition with a function for constructing the query key.
* @param item The item triggering the Action/optimistic update (i.e., the
* argument passed to the Action).
* @returns A specific optimistic update definition which corresponds to the
* provided definition and closes over the provided item.
*/
function getOptimisticUpdateDefinitionForSpecificItem<ActionInput, CachedData>(
optimisticUpdateDefinition: InternalOptimisticUpdateDefinition<ActionInput, CachedData>,
item: ActionInput
): SpecificOptimisticUpdateDefinition<CachedData> {
const { getQueryKey, updateQuery } = optimisticUpdateDefinition
return {
queryKey: getQueryKey(item),
updateQuery: (old) => updateQuery(item, old)
}
}
/**
* Translates a Wasp query specifier to a query cache key used by React Query.
*
* @param querySpecifier A query specifier that's a part of the public API:
* https://wasp-lang.dev/docs/data-model/operations/actions#the-useaction-hook-and-optimistic-updates
* @returns A cache key React Query internally uses for addressing queries.
*/
function getRqQueryKeyFromSpecifier(querySpecifier: QuerySpecifier<unknown, unknown>): QueryKey {
const [queryFn, ...otherKeys] = querySpecifier
return [...(queryFn as any).queryCacheKey, ...otherKeys]
}

View File

@ -1,104 +0,0 @@
import axios, { type AxiosError } from 'axios'
import config from './config'
import { storage } from './storage'
import { apiEventsEmitter } from './api/events'
const api = axios.create({
baseURL: config.apiUrl,
})
const WASP_APP_AUTH_SESSION_ID_NAME = 'sessionId'
let waspAppAuthSessionId = storage.get(WASP_APP_AUTH_SESSION_ID_NAME) as string | undefined
export function setSessionId(sessionId: string): void {
waspAppAuthSessionId = sessionId
storage.set(WASP_APP_AUTH_SESSION_ID_NAME, sessionId)
apiEventsEmitter.emit('sessionId.set')
}
export function getSessionId(): string | undefined {
return waspAppAuthSessionId
}
export function clearSessionId(): void {
waspAppAuthSessionId = undefined
storage.remove(WASP_APP_AUTH_SESSION_ID_NAME)
apiEventsEmitter.emit('sessionId.clear')
}
export function removeLocalUserData(): void {
waspAppAuthSessionId = undefined
storage.clear()
apiEventsEmitter.emit('sessionId.clear')
}
api.interceptors.request.use((request) => {
const sessionId = getSessionId()
if (sessionId) {
request.headers['Authorization'] = `Bearer ${sessionId}`
}
return request
})
api.interceptors.response.use(undefined, (error) => {
if (error.response?.status === 401) {
clearSessionId()
}
return Promise.reject(error)
})
// This handler will run on other tabs (not the active one calling API functions),
// and will ensure they know about auth session ID changes.
// Ref: https://developer.mozilla.org/en-US/docs/Web/API/Window/storage_event
// "Note: This won't work on the same page that is making the changes — it is really a way
// for other pages on the domain using the storage to sync any changes that are made."
window.addEventListener('storage', (event) => {
if (event.key === storage.getPrefixedKey(WASP_APP_AUTH_SESSION_ID_NAME)) {
if (!!event.newValue) {
waspAppAuthSessionId = event.newValue
apiEventsEmitter.emit('sessionId.set')
} else {
waspAppAuthSessionId = undefined
apiEventsEmitter.emit('sessionId.clear')
}
}
})
/**
* Takes an error returned by the app's API (as returned by axios), and transforms into a more
* standard format to be further used by the client. It is also assumed that given API
* error has been formatted as implemented by HttpError on the server.
*/
export function handleApiError(error: AxiosError<{ message?: string, data?: unknown }>): void {
if (error?.response) {
// If error came from HTTP response, we capture most informative message
// and also add .statusCode information to it.
// If error had JSON response, we assume it is of format { message, data } and
// add that info to the error.
// TODO: We might want to use HttpError here instead of just Error, since
// HttpError is also used on server to throw errors like these.
// That would require copying HttpError code to web-app also and using it here.
const responseJson = error.response?.data
const responseStatusCode = error.response.status
throw new WaspHttpError(responseStatusCode, responseJson?.message ?? error.message, responseJson)
} else {
// If any other error, we just propagate it.
throw error
}
}
class WaspHttpError extends Error {
statusCode: number
data: unknown
constructor (statusCode: number, message: string, data: unknown) {
super(message)
this.statusCode = statusCode
this.data = data
}
}
export default api

View File

@ -1,95 +0,0 @@
import { styled } from '../../../stitches.config'
export const Form = styled('form', {
marginTop: '1.5rem',
})
export const FormItemGroup = styled('div', {
'& + div': {
marginTop: '1.5rem',
},
})
export const FormLabel = styled('label', {
display: 'block',
fontSize: '$sm',
fontWeight: '500',
marginBottom: '0.5rem',
})
const commonInputStyles = {
display: 'block',
lineHeight: '1.5rem',
fontSize: '$sm',
borderWidth: '1px',
borderColor: '$gray600',
backgroundColor: '#f8f4ff',
boxShadow: '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
'&:focus': {
borderWidth: '1px',
borderColor: '$gray700',
boxShadow: '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
},
'&:disabled': {
opacity: 0.5,
cursor: 'not-allowed',
backgroundColor: '$gray400',
borderColor: '$gray400',
color: '$gray500',
},
borderRadius: '0.375rem',
width: '100%',
paddingTop: '0.375rem',
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',
})
export const SubmitButton = styled('button', {
display: 'flex',
justifyContent: 'center',
width: '100%',
borderWidth: '1px',
borderColor: '$brand',
backgroundColor: '$brand',
color: '$submitButtonText',
padding: '0.5rem 0.75rem',
boxShadow: '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
fontWeight: '600',
fontSize: '$sm',
lineHeight: '1.25rem',
borderRadius: '0.375rem',
// TODO(matija): extract this into separate BaseButton component and then inherit it.
'&:hover': {
backgroundColor: '$brandAccent',
borderColor: '$brandAccent',
},
'&:disabled': {
opacity: 0.5,
cursor: 'not-allowed',
backgroundColor: '$gray400',
borderColor: '$gray400',
color: '$gray500',
},
transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)',
transitionDuration: '100ms',
})

View File

@ -1,18 +0,0 @@
import { styled } from '../../../stitches.config'
export const Message = styled('div', {
padding: '0.5rem 0.75rem',
borderRadius: '0.375rem',
marginTop: '1rem',
background: '$gray400',
})
export const MessageError = styled(Message, {
background: '$errorBackground',
color: '$errorText',
})
export const MessageSuccess = styled(Message, {
background: '$successBackground',
color: '$successText',
})

View File

@ -1,14 +0,0 @@
import { setSessionId } from '../../api'
import { invalidateAndRemoveQueries } from '../../operations/resources'
export async function initSession(sessionId: string): Promise<void> {
setSessionId(sessionId)
// We need to invalidate queries after login in order to get the correct user
// data in the React components (using `useAuth`).
// Redirects after login won't work properly without this.
// TODO(filip): We are currently removing all the queries, but we should
// remove only non-public, user-dependent queries - public queries are
// expected not to change in respect to the currently logged in user.
await invalidateAndRemoveQueries()
}

View File

@ -1,17 +0,0 @@
import api, { removeLocalUserData } from '../api'
import { invalidateAndRemoveQueries } from '../operations/resources'
export default async function logout(): Promise<void> {
try {
await api.post('/auth/logout')
} finally {
// Even if the logout request fails, we still want to remove the local user data
// in case the logout failed because of a network error and the user walked away
// from the computer.
removeLocalUserData()
// TODO(filip): We are currently invalidating and removing all the queries, but
// we should remove only the non-public, user-dependent ones.
await invalidateAndRemoveQueries()
}
}

View File

@ -2,9 +2,9 @@
import React, { useEffect, useRef } from 'react'
import { useHistory } from 'react-router-dom'
import config from '../../config.js'
import api from '../../api'
import { initSession } from '../helpers/user'
import config from 'wasp/core/config'
import api from 'wasp/api'
import { initSession } from 'wasp/auth/helpers/user'
// After a user authenticates via an Oauth 2.0 provider, this is the page that
// the provider should redirect them to, while providing query string parameters

View File

@ -2,7 +2,7 @@
import React from 'react'
import { Redirect } from 'react-router-dom'
import useAuth from '../useAuth'
import useAuth from 'wasp/auth/useAuth'
const createAuthRequiredPage = (Page) => {

View File

@ -1,2 +0,0 @@
// todo(filip): turn into a proper import/path
export type { SanitizedUser as User, ProviderName, DeserializedAuthIdentity } from '../../../server/src/_types/'

View File

@ -1,40 +0,0 @@
{{={= =}=}}
import { deserialize as superjsonDeserialize } from 'superjson'
import { useQuery } from '../queries'
import api, { handleApiError } from '../api'
import { HttpMethod } from '../types'
import type { User } from './types'
import { addMetadataToQuery } from '../queries/core'
export const getMe = createUserGetter()
export default function useAuth(queryFnArgs?: unknown, config?: any) {
return useQuery(getMe, queryFnArgs, config)
}
function createUserGetter() {
const getMeRelativePath = 'auth/me'
const getMeRoute = { method: HttpMethod.Get, path: `/${getMeRelativePath}` }
async function getMe(): Promise<User | null> {
try {
const response = await api.get(getMeRoute.path)
return superjsonDeserialize(response.data)
} catch (error) {
if (error.response?.status === 401) {
return null
} else {
handleApiError(error)
}
}
}
addMetadataToQuery(getMe, {
relativeQueryPath: getMeRelativePath,
queryRoute: getMeRoute,
entitiesUsed: {=& entitiesGetMeDependsOn =},
})
return getMe
}

View File

@ -1,27 +0,0 @@
// We decided not to deduplicate these helper functions in the server and the client.
// We have them duplicated in this file and in data/Generator/templates/server/src/auth/user.ts
// If you are changing the logic here, make sure to change it there as well.
import type { User, ProviderName, DeserializedAuthIdentity } from './types'
export function getEmail(user: User): string | null {
return findUserIdentity(user, "email")?.providerUserId ?? null;
}
export function getUsername(user: User): string | null {
return findUserIdentity(user, "username")?.providerUserId ?? null;
}
export function getFirstProviderUserId(user?: User): string | null {
if (!user || !user.auth || !user.auth.identities || user.auth.identities.length === 0) {
return null;
}
return user.auth.identities[0].providerUserId ?? null;
}
export function findUserIdentity(user: User, providerName: ProviderName): DeserializedAuthIdentity | undefined {
return user.auth.identities.find(
(identity) => identity.providerName === providerName
);
}

View File

@ -14,7 +14,7 @@ import {
{=/ setupFn.isDefined =}
{=# areWebSocketsUsed =}
import { WebSocketProvider } from './webSocket/WebSocketProvider'
import { WebSocketProvider } from 'wasp/webSocket/WebSocketProvider'
{=/ areWebSocketsUsed =}
startApp()

View File

@ -1,22 +0,0 @@
import api, { handleApiError } from '../api'
import { HttpMethod } from '../types'
import {
serialize as superjsonSerialize,
deserialize as superjsonDeserialize,
} from 'superjson'
export type OperationRoute = { method: HttpMethod, path: string }
export async function callOperation(operationRoute: OperationRoute & { method: HttpMethod.Post }, args: any) {
try {
const superjsonArgs = superjsonSerialize(args)
const response = await api.post(operationRoute.path, superjsonArgs)
return superjsonDeserialize(response.data)
} catch (error) {
handleApiError(error)
}
}
export function makeOperationRoute(relativeOperationRoute: string): OperationRoute {
return { method: HttpMethod.Post, path: `/${relativeOperationRoute}` }
}

View File

@ -1,81 +0,0 @@
import { queryClientInitialized } from '../queryClient'
import { makeUpdateHandlersMap } from './updateHandlersMap'
import { hashQueryKey } from '@tanstack/react-query'
// Map where key is resource name and value is Set
// containing query ids of all the queries that use
// that resource.
const resourceToQueryCacheKeys = new Map()
const updateHandlers = makeUpdateHandlersMap(hashQueryKey)
/**
* Remembers that specified query is using specified resources.
* If called multiple times for same query, resources are added, not reset.
* @param {string[]} queryCacheKey - Unique key under used to identify query in the cache.
* @param {string[]} resources - Names of resources that query is using.
*/
export function addResourcesUsedByQuery(queryCacheKey, resources) {
for (const resource of resources) {
let cacheKeys = resourceToQueryCacheKeys.get(resource)
if (!cacheKeys) {
cacheKeys = new Set()
resourceToQueryCacheKeys.set(resource, cacheKeys)
}
cacheKeys.add(queryCacheKey)
}
}
export function registerActionInProgress(optimisticUpdateTuples) {
optimisticUpdateTuples.forEach(
({ queryKey, updateQuery }) => updateHandlers.add(queryKey, updateQuery)
)
}
export async function registerActionDone(resources, optimisticUpdateTuples) {
optimisticUpdateTuples.forEach(({ queryKey }) => updateHandlers.remove(queryKey))
await invalidateQueriesUsing(resources)
}
export function getActiveOptimisticUpdates(queryKey) {
return updateHandlers.getUpdateHandlers(queryKey)
}
export async function invalidateAndRemoveQueries() {
const queryClient = await queryClientInitialized
// If we don't reset the queries before removing them, Wasp will stay on
// the same page. The user would have to manually refresh the page to "finish"
// logging out.
// When a query is removed, the `Observer` is removed as well, and the components
// that are using the query are not re-rendered. This is why we need to reset
// the queries, so that the `Observer` is re-created and the components are re-rendered.
// For more details: https://github.com/wasp-lang/wasp/pull/1014/files#r1111862125
queryClient.resetQueries()
// If we don't remove the queries after invalidating them, the old query data
// remains in the cache, casuing a potential privacy issue.
queryClient.removeQueries()
}
/**
* Invalidates all queries that are using specified resources.
* @param {string[]} resources - Names of resources.
*/
async function invalidateQueriesUsing(resources) {
const queryClient = await queryClientInitialized
const queryCacheKeysToInvalidate = getQueriesUsingResources(resources)
queryCacheKeysToInvalidate.forEach(
queryCacheKey => queryClient.invalidateQueries(queryCacheKey)
)
}
/**
* @param {string} resource - Resource name.
* @returns {string[]} Array of "query cache keys" of queries that use specified resource.
*/
function getQueriesUsingResource(resource) {
return Array.from(resourceToQueryCacheKeys.get(resource) || [])
}
function getQueriesUsingResources(resources) {
return Array.from(new Set(resources.flatMap(getQueriesUsingResource)))
}

View File

@ -1,11 +0,0 @@
{{={= =}=}}
import { createQuery } from './core'
{=& operationTypeImportStmt =}
const query = createQuery<{= operationTypeName =}>(
'{= queryRoute =}',
{=& entitiesArray =},
)
export default query

View File

@ -1,23 +0,0 @@
import { type Query } from '.'
import { Route } from '../types';
import type { Expand, _Awaited, _ReturnType } from '../universal/types'
export function createQuery<BackendQuery extends GenericBackendQuery>(
queryRoute: string,
entitiesUsed: any[]
): QueryFor<BackendQuery>
export function addMetadataToQuery(
query: (...args: any[]) => Promise<unknown>,
metadata: {
relativeQueryPath: string;
queryRoute: Route;
entitiesUsed: string[];
},
): void
type QueryFor<BackendQuery extends GenericBackendQuery> = Expand<
Query<Parameters<BackendQuery>[0], _Awaited<_ReturnType<BackendQuery>>>
>
type GenericBackendQuery = (args: never, context: any) => unknown

View File

@ -1,27 +0,0 @@
import { callOperation, makeOperationRoute } from '../operations'
import {
addResourcesUsedByQuery,
getActiveOptimisticUpdates,
} from '../operations/resources'
export function createQuery(relativeQueryPath, entitiesUsed) {
const queryRoute = makeOperationRoute(relativeQueryPath)
async function query(queryKey, queryArgs) {
const serverResult = await callOperation(queryRoute, queryArgs)
return getActiveOptimisticUpdates(queryKey).reduce(
(result, update) => update(result),
serverResult,
)
}
addMetadataToQuery(query, { relativeQueryPath, queryRoute, entitiesUsed })
return query
}
export function addMetadataToQuery(query, { relativeQueryPath, queryRoute, entitiesUsed }) {
query.queryCacheKey = [relativeQueryPath]
query.route = queryRoute
addResourcesUsedByQuery(query.queryCacheKey, entitiesUsed)
}

View File

@ -1,10 +0,0 @@
import { UseQueryResult } from "@tanstack/react-query";
export type Query<Input, Output> = {
(queryCacheKey: string[], args: Input): Promise<Output>
}
export function useQuery<Input, Output>(
queryFn: Query<Input, Output>,
queryFnArgs?: Input, options?: any
): UseQueryResult<Output, Error>

View File

@ -1,18 +0,0 @@
import { useQuery as rqUseQuery } from '@tanstack/react-query'
export { configureQueryClient } from '../queryClient'
export function useQuery(queryFn, queryFnArgs, options) {
if (typeof queryFn !== 'function') {
throw new TypeError('useQuery requires queryFn to be a function.')
}
if (!queryFn.queryCacheKey) {
throw new TypeError('queryFn needs to have queryCacheKey property defined.')
}
const queryKey = queryFnArgs !== undefined ? [...queryFn.queryCacheKey, queryFnArgs] : queryFn.queryCacheKey
return rqUseQuery({
queryKey,
queryFn: () => queryFn(queryKey, queryFnArgs),
...options
})
}

View File

@ -1,18 +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 =}
{=# isAuthEnabled =}
import createAuthRequiredPage from "wasp/auth/pages/createAuthRequiredPage"
import createAuthRequiredPage from "./auth/pages/createAuthRequiredPage"
{=/ isAuthEnabled =}
{=# pagesToImport =}
@ -23,29 +17,14 @@ import createAuthRequiredPage from "wasp/auth/pages/createAuthRequiredPage"
import OAuthCodeExchange from "./auth/pages/OAuthCodeExchange"
{=/ isExternalAuthEnabled =}
export const routes = {
import { routes } from 'wasp/router'
export const routeNameToRouteComponent = {
{=# 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 =}
},
{= name =}: {= targetComponent =},
{=/ routes =}
} as const;
export type Routes = RouteDefinitionsToRoutes<typeof routes>
const router = (
<Router basename="{= baseDir =}">
{=# rootComponent.isDefined =}
@ -57,7 +36,7 @@ const router = (
exact
key={routeKey}
path={route.to}
component={route.component}
component={routeNameToRouteComponent[routeKey]}
/>
))}
{=# isExternalAuthEnabled =}
@ -77,5 +56,3 @@ const router = (
)
export default router
export { Link } from './router/Link'

View File

@ -1,4 +1,8 @@
import matchers from '@testing-library/jest-dom/matchers'
import { expect } from 'vitest'
import { afterEach } from 'vitest'
import { cleanup } from '@testing-library/react'
import '@testing-library/jest-dom/vitest'
expect.extend(matchers)
// runs a clean after each test case (e.g. clearing jsdom)
afterEach(() => {
cleanup();
})

View File

@ -1,9 +0,0 @@
// NOTE: This is enough to cover Operations and our APIs (src/Wasp/AppSpec/Api.hs).
export enum HttpMethod {
Get = 'GET',
Post = 'POST',
Put = 'PUT',
Delete = 'DELETE',
}
export type Route = { method: HttpMethod; path: string }

View File

@ -6,7 +6,7 @@
"allowJs": true,
"strict": false,
// Allow importing pages with the .tsx extension.
"allowImportingTsExtensions": true
"allowImportingTsExtensions": true,
},
"include": [
"src"

View File

@ -2,8 +2,11 @@
/// <reference types="vitest" />
import { mergeConfig } from "vite";
import react from "@vitejs/plugin-react";
import { defaultExclude } from "vitest/config"
{=# customViteConfig.isDefined =}
// Ignoring the TS error because we are importing a file outside of TS root dir.
// @ts-ignore
{=& customViteConfig.importStatement =}
const _waspUserProvidedConfig = {=& customViteConfig.importIdentifier =}
{=/ customViteConfig.isDefined =}
@ -27,12 +30,16 @@ const defaultViteConfig = {
outDir: "build",
},
test: {
globals: true,
environment: "jsdom",
setupFiles: ["./src/test/vitest/setup.ts"],
// vitest is running from the root of the project, so we need
// to specify the path to the setup file relative to the root.
setupFiles: {=& vitest.setupFilesArray =},
exclude: [
...defaultExclude,
"{= vitest.excludeWaspArtefactsPattern =}",
]
},
// resolve: {
// dedupe: ["react", "react-dom"],
// },
};
// https://vitejs.dev/config/

View File

@ -2,7 +2,7 @@ import axios, { type AxiosError } from 'axios'
import config from 'wasp/core/config'
import { storage } from 'wasp/core/storage'
import { apiEventsEmitter } from 'wasp/api/events'
import { apiEventsEmitter } from './events.js'
const api = axios.create({
baseURL: config.apiUrl,

View File

@ -1,5 +1,5 @@
{{={= =}=}}
import api, { handleApiError } from '../../../api';
import api, { handleApiError } from 'wasp/api';
import { initSession } from '../../helpers/user';
export async function login(data: { email: string; password: string }): Promise<void> {

View File

@ -1,5 +1,5 @@
{{={= =}=}}
import api, { handleApiError } from '../../../api';
import api, { handleApiError } from 'wasp/api';
export async function requestPasswordReset(data: { email: string; }): Promise<{ success: boolean }> {
try {

View File

@ -1,5 +1,5 @@
{{={= =}=}}
import api, { handleApiError } from '../../../api';
import api, { handleApiError } from 'wasp/api';
export async function signup(data: { email: string; password: string }): Promise<{ success: boolean }> {
try {

View File

@ -1,5 +1,5 @@
{{={= =}=}}
import api, { handleApiError } from '../../../api'
import api, { handleApiError } from 'wasp/api'
export async function verifyEmail(data: {
token: string

View File

@ -1,7 +1,7 @@
{{={= =}=}}
import { useState, createContext } from 'react'
import { createTheme } from '@stitches/react'
import { styled } from '../../stitches.config'
import { styled } from 'wasp/core/stitches.config'
import {
type State,

View File

@ -1,8 +1,8 @@
{{={= =}=}}
import { useContext } from 'react'
import { useForm, UseFormReturn } from 'react-hook-form'
import { styled } from '../../../../stitches.config'
import config from '../../../../config'
import { styled } from 'wasp/core/stitches.config'
import config from 'wasp/core/config'
import { AuthContext } from '../../Auth'
import {

View File

@ -1,4 +1,4 @@
import { styled } from '../../../../stitches.config'
import { styled } from 'wasp/core/stitches.config'
export const SocialButton = styled('a', {
display: 'flex',

View File

@ -1,6 +1,6 @@
{{={= =}=}}
import config from '../../config.js'
import config from 'wasp/core/config'
import { SocialButton } from '../forms/internal/social/SocialButton'
import * as SocialIcons from '../forms/internal/social/SocialIcons'

View File

@ -1,7 +1,7 @@
import jwt from 'jsonwebtoken'
import util from 'util'
import config from '../config.js'
import config from 'wasp/server/config'
const jwtSign = util.promisify(jwt.sign)
const jwtVerify = util.promisify(jwt.verify)

View File

@ -1,5 +1,5 @@
{{={= =}=}}
import api, { handleApiError } from '../api'
import api, { handleApiError } from 'wasp/api'
import { initSession } from './helpers/user'
export default async function login(username: string, password: string): Promise<void> {

View File

@ -1,9 +1,8 @@
{{={= =}=}}
import { Lucia } from "lucia";
import { PrismaAdapter } from "@lucia-auth/adapter-prisma";
import prisma from '../dbClient.js'
import config from '../config.js'
import { type {= userEntityUpper =} } from "../entities/index.js"
import prisma from 'wasp/server/dbClient'
import { type {= userEntityUpper =} } from "wasp/entities"
const prismaAdapter = new PrismaAdapter(
// Using `as any` here since Lucia's model types are not compatible with Prisma 4

View File

@ -1,7 +1,7 @@
{{={= =}=}}
import type { Router, Request } from 'express'
import type { Prisma } from '@prisma/client'
import type { Expand } from '../../universal/types'
import type { Expand } from 'wasp/universal/types'
import type { ProviderName } from '../utils'
type UserEntityCreateInput = Prisma.{= userEntityUpper =}CreateInput

View File

@ -1,8 +1,8 @@
{{={= =}=}}
import { Request as ExpressRequest } from "express";
import { type {= userEntityUpper =} } from "../entities/index.js"
import { type SanitizedUser } from '../_types/index.js'
import { type {= userEntityUpper =} } from "wasp/entities"
import { type SanitizedUser } from 'wasp/server/_types'
import { auth } from "./lucia.js";
import type { Session } from "lucia";
@ -11,7 +11,7 @@ import {
deserializeAndSanitizeProviderData,
} from "./utils.js";
import prisma from '../dbClient.js';
import prisma from 'wasp/server/dbClient';
// Creates a new session for the `authId` in the database
export async function createSession(authId: string): Promise<Session> {

View File

@ -1,5 +1,5 @@
{{={= =}=}}
import api, { handleApiError } from '../api'
import api, { handleApiError } from 'wasp/api'
export default async function signup(userFields: { username: string; password: string }): Promise<void> {
try {

View File

@ -1,8 +1,9 @@
{{={= =}=}}
import { deserialize as superjsonDeserialize } from 'superjson'
import { useQuery } from 'wasp/rpc'
import api, { handleApiError } from 'wasp/api'
import { HttpMethod } from 'wasp/types'
import type { User } from './types'
import type { User } from './types'
import { addMetadataToQuery } from 'wasp/rpc/queries'
export const getMe = createUserGetter()
@ -31,7 +32,7 @@ function createUserGetter() {
addMetadataToQuery(getMe, {
relativeQueryPath: getMeRelativePath,
queryRoute: getMeRoute,
entitiesUsed: ['User'],
entitiesUsed: {=& entitiesGetMeDependsOn =},
})
return getMe

View File

@ -1,8 +1,4 @@
// We decided not to deduplicate these helper functions in the server and the client.
// We have them duplicated in this file and in data/Generator/templates/server/src/auth/user.ts
// If you are changing the logic here, make sure to change it there as well.
import type { User, ProviderName, DeserializedAuthIdentity } from './types'
import type { User, ProviderName, DeserializedAuthIdentity } from './types'
export function getEmail(user: User): string | null {
return findUserIdentity(user, "email")?.providerUserId ?? null;

View File

@ -1,14 +1,15 @@
{{={= =}=}}
import { hashPassword } from './password.js'
import { verify } from './jwt.js'
import AuthError from '../core/AuthError.js'
import HttpError from '../core/HttpError.js'
import prisma from '../server/dbClient.js'
import { sleep } from '../server/utils'
import AuthError from 'wasp/core/AuthError'
import HttpError from 'wasp/core/HttpError'
import prisma from 'wasp/server/dbClient'
import { sleep } from 'wasp/server/utils'
import {
type User,
type Auth,
type AuthIdentity,
} from '../entities'
type {= userEntityUpper =},
type {= authEntityUpper =},
type {= authIdentityEntityUpper =},
} from 'wasp/entities'
import { Prisma } from '@prisma/client';
import { throwValidationError } from './validation.js'
@ -46,13 +47,13 @@ export type ProviderName = keyof PossibleProviderData
export const contextWithUserEntity = {
entities: {
User: prisma.user
{= userEntityUpper =}: prisma.{= userEntityLower =}
}
}
export const authConfig = {
failureRedirectPath: "/login",
successRedirectPath: "/",
failureRedirectPath: "{= failureRedirectPath =}",
successRedirectPath: "{= successRedirectPath =}",
}
/**
@ -76,8 +77,8 @@ export function createProviderId(providerName: ProviderName, providerUserId: str
}
}
export async function findAuthIdentity(providerId: ProviderId): Promise<AuthIdentity | null> {
return prisma.authIdentity.findUnique({
export async function findAuthIdentity(providerId: ProviderId): Promise<{= authIdentityEntityUpper =} | null> {
return prisma.{= authIdentityEntityLower =}.findUnique({
where: {
providerName_providerUserId: providerId,
}
@ -96,7 +97,7 @@ export async function updateAuthIdentityProviderData<PN extends ProviderName>(
providerId: ProviderId,
existingProviderData: PossibleProviderData[PN],
providerDataUpdates: Partial<PossibleProviderData[PN]>,
): Promise<AuthIdentity> {
): Promise<{= authIdentityEntityUpper =}> {
// We are doing the sanitization here only on updates to avoid
// hashing the password multiple times.
const sanitizedProviderDataUpdates = await sanitizeProviderData(providerDataUpdates);
@ -105,7 +106,7 @@ export async function updateAuthIdentityProviderData<PN extends ProviderName>(
...sanitizedProviderDataUpdates,
}
const serializedProviderData = await serializeProviderData<PN>(newProviderData);
return prisma.authIdentity.update({
return prisma.{= authIdentityEntityLower =}.update({
where: {
providerName_providerUserId: providerId,
},
@ -113,31 +114,31 @@ export async function updateAuthIdentityProviderData<PN extends ProviderName>(
});
}
type FindAuthWithUserResult = Auth & {
user: User
type FindAuthWithUserResult = {= authEntityUpper =} & {
{= userFieldOnAuthEntityName =}: {= userEntityUpper =}
}
export async function findAuthWithUserBy(
where: Prisma.AuthWhereInput
where: Prisma.{= authEntityUpper =}WhereInput
): Promise<FindAuthWithUserResult> {
return prisma.auth.findFirst({ where, include: { user: true }});
return prisma.{= authEntityLower =}.findFirst({ where, include: { {= userFieldOnAuthEntityName =}: true }});
}
export async function createUser(
providerId: ProviderId,
serializedProviderData?: string,
userFields?: PossibleUserFields,
): Promise<User & {
auth: Auth
): Promise<{= userEntityUpper =} & {
auth: {= authEntityUpper =}
}> {
return prisma.user.create({
return prisma.{= userEntityLower =}.create({
data: {
// Using any here to prevent type errors when userFields are not
// defined. We want Prisma to throw an error in that case.
...(userFields ?? {} as any),
auth: {
{= authFieldOnUserEntityName =}: {
create: {
identities: {
{= identitiesFieldOnAuthEntityName =}: {
create: {
providerName: providerId.providerName,
providerUserId: providerId.providerUserId,
@ -150,13 +151,13 @@ export async function createUser(
// We need to include the Auth entity here because we need `authId`
// to be able to create a session.
include: {
auth: true,
{= authFieldOnUserEntityName =}: true,
},
})
}
export async function deleteUserByAuthId(authId: string): Promise<{ count: number }> {
return prisma.user.deleteMany({ where: { auth: {
return prisma.{= userEntityLower =}.deleteMany({ where: { auth: {
id: authId,
} } })
}
@ -213,7 +214,7 @@ export function rethrowPossibleAuthError(e: unknown): void {
// Prisma code P2003 is for foreign key constraint failure
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2003') {
console.error(e)
console.info(`🐝 This error can happen if you have some relation on your User entity
console.info(`🐝 This error can happen if you have some relation on your {= userEntityUpper =} entity
but you didn't specify the "onDelete" behaviour to either "Cascade" or "SetNull".
Read more at: https://www.prisma.io/docs/orm/prisma-schema/data-model/relations/referential-actions`)
throw new HttpError(500, 'Save failed', {

View File

@ -1,4 +1,4 @@
import HttpError from 'wasp/core/HttpError'
import HttpError from 'wasp/core/HttpError';
export const PASSWORD_FIELD = 'password';
const USERNAME_FIELD = 'username';

View File

@ -1,5 +1,7 @@
class AuthError extends Error {
constructor (message, data, ...params) {
public data: unknown
constructor (message: string, data?: unknown, ...params: unknown[]) {
super(message, ...params)
if (Error.captureStackTrace) {

View File

@ -1,5 +1,8 @@
class HttpError extends Error {
constructor (statusCode, message, data, ...params) {
public statusCode: number
public data: unknown
constructor (statusCode: number, message?: string, data?: Record<string, unknown>, ...params: unknown[]) {
super(message, ...params)
if (Error.captureStackTrace) {

View File

@ -1,7 +1,4 @@
import { randomInt } from 'node:crypto'
import prisma from '../server/dbClient.js'
import { handleRejection } from '../utils.js'
import { handleRejection } from 'wasp/server/utils'
import { getSessionAndUserFromBearerToken } from 'wasp/auth/session'
import { throwInvalidCredentialsError } from 'wasp/auth/utils'

View File

@ -1,8 +1,8 @@
{{={= =}=}}
import { createAction } from "../actions/core";
import { useAction } from "../actions";
import { createQuery } from "../queries/core";
import { useQuery } from "../queries";
import { createAction } from "wasp/rpc/actions/core";
import { useAction } from "wasp/rpc";
import { createQuery } from "wasp/rpc/queries/core";
import { useQuery } from "wasp/rpc";
import {
{=# operations.Get =}
GetQueryResolved,
@ -19,7 +19,7 @@ import {
{=# operations.Delete =}
DeleteActionResolved,
{=/ operations.Delete =}
} from '../../../server/src/crud/{= name =}'
} from 'wasp/server/crud/{= name =}'
function createCrud() {
{=# operations.Get =}

View File

@ -1,5 +1,5 @@
{{={= =}=}}
import { EmailFromField } from "./types";
import { EmailFromField } from "wasp/email/core/types";
// Formats an email address and an optional name into a string that can be used
// as the "from" field in an email.

View File

@ -1,4 +1,4 @@
import { DummyEmailProvider, EmailSender } from "../types.js";
import { DummyEmailProvider, EmailSender } from "wasp/email/core/types";
import { getDefaultFromField } from "../helpers.js";
const yellowColor = "\x1b[33m%s\x1b[0m";

View File

@ -1,6 +1,6 @@
import { NodeMailgun } from "ts-mailgun";
import { getDefaultFromField } from "../helpers.js";
import type { MailgunEmailProvider, EmailSender } from "../types.js";
import type { MailgunEmailProvider, EmailSender } from "wasp/email/core/types";
export function initMailgunEmailSender(
config: MailgunEmailProvider

View File

@ -1,6 +1,6 @@
import SendGrid from "@sendgrid/mail";
import { getDefaultFromField } from "../helpers.js";
import type { SendGridProvider, EmailSender } from "../types.js";
import type { SendGridProvider, EmailSender } from "wasp/email/core/types";
export function initSendGridEmailSender(
provider: SendGridProvider

View File

@ -1,6 +1,6 @@
import { createTransport } from "nodemailer";
import { formatFromField, getDefaultFromField } from "../helpers.js";
import type { SMTPEmailProvider, EmailSender } from "../types.js";
import type { SMTPEmailProvider, EmailSender } from "wasp/email/core/types";
export function initSmtpEmailSender(config: SMTPEmailProvider): EmailSender {
const transporter = createTransport({

View File

@ -0,0 +1,14 @@
{{={= =}=}}
import prisma from 'wasp/server/dbClient'
import type { JSONValue, JSONObject } from 'wasp/server/_types/serialization'
import { type JobFn } from '{= jobExecutorTypesImportPath =}'
{=! Used in framework code, shouldn't be public =}
export const entities = {
{=# entities =}
{= name =}: prisma.{= prismaIdentifier =},
{=/ entities =}
};
{=! Used by users, should be public =}
export type {= typeName =}<Input extends JSONObject, Output extends JSONValue | void> = JobFn<Input, Output, typeof entities>

View File

@ -0,0 +1,8 @@
import { PrismaDelegate } from 'wasp/server/_types'
import type { JSONValue, JSONObject } from 'wasp/server/_types/serialization'
export type JobFn<
Input extends JSONObject,
Output extends JSONValue | void,
Entities extends Partial<PrismaDelegate>
> = (data: Input, context: { entities: Entities }) => Promise<Output>

View File

@ -3,7 +3,7 @@ import { HttpMethod } from 'wasp/types'
import {
serialize as superjsonSerialize,
deserialize as superjsonDeserialize,
} from 'superjson'
} from 'superjson'
export type OperationRoute = { method: HttpMethod, path: string }

View File

@ -9,23 +9,142 @@
"types": "tsc --declaration --emitDeclarationOnly --stripInternal --declarationDir dist"
},
"exports": {
"./core/HttpError": "./core/HttpError.js",
"./core/AuthError": "./core/AuthError.js",
"./core/config": "./core/config.js",
"./core/stitches.config": "./core/stitches.config.js",
"./core/storage": "./core/storage.ts",
"./rpc": "./rpc/index.ts",
"./rpc/queries": "./rpc/queries/index.ts",
"./rpc/actions": "./rpc/actions/index.ts",
"./rpc/queryClient": "./rpc/queryClient.ts",
"./types": "./types/index.ts",
"./auth/*": "./auth/*",
"./api": "./api/index.ts",
"./api/*": "./api/*",
"./operations": "./operations/index.ts",
"./operations/*": "./operations/*",
"./universal/url": "./universal/url.ts",
"./universal/types": "./universal/url.ts"
{=! todo(filip): Check all exports when done with SDK generation =}
{=! Some of the statements in the comments might become incorrect. =}
{=! "our code" means: "web-app", "server" or "SDK", or "some combination of the three". =}
{=! Used by users, documented. =}
"./core/HttpError": "./dist/core/HttpError.js",
{=! Used by users, documented. =}
"./core/AuthError": "./dist/core/AuthError.js",
{=! Used by our code, uncodumented (but accessible) for users. =}
"./core/config": "./dist/core/config.js",
{=! Used by our code, uncodumented (but accessible) for users. =}
"./core/stitches.config": "./dist/core/stitches.config.js",
{=! Used by our code, uncodumented (but accessible) for users. =}
"./core/storage": "./dist/core/storage.js",
"./core/auth": "./dist/core/auth.js",
{=! Used by users, documented. =}
"./rpc": "./dist/rpc/index.js",
{=! Used by users, documented. =}
"./rpc/queries": "./dist/rpc/queries/index.js",
{=! Used by our code, uncodumented (but accessible) for users. =}
"./rpc/queries/core": "./dist/rpc/queries/core.js",
{=! Used by users, documented. =}
"./rpc/actions": "./dist/rpc/actions/index.js",
{=! Used by our code, uncodumented (but accessible) for users. =}
"./rpc/actions/core": "./dist/rpc/actions/core.js",
{=! Used by our code, uncodumented (but accessible) for users. =}
"./rpc/queryClient": "./dist/rpc/queryClient.js",
{=! Used by users, documented. =}
"./types": "./dist/types/index.js",
{=! Used by user, documented. =}
"./auth": "./dist/auth/index.js",
{=! Used by users, documented. =}
"./auth/types": "./dist/auth/types.js",
{=! Used by users, documented. =}
"./auth/login": "./dist/auth/login.js",
{=! Used by users, documented. =}
"./auth/logout": "./dist/auth/logout.js",
{=! Used by users, documented. =}
"./auth/signup": "./dist/auth/signup.js",
{=! Used by users, documented. =}
"./auth/useAuth": "./dist/auth/useAuth.js",
{=! Used by users, documented. =}
"./auth/user": "./dist/auth/user.js",
{=! Used by users, documented. =}
"./auth/email": "./dist/auth/email/index.js",
{=! Used by our code, uncodumented (but accessible) for users. =}
"./auth/helpers/user": "./dist/auth/helpers/user.js",
{=! Used by our code, uncodumented (but accessible) for users. =}
"./auth/session": "./dist/auth/session.js",
{=! Used by our code, uncodumented (but accessible) for users. =}
"./auth/providers/types": "./dist/auth/providers/types.js",
{=! Used by user, documented. =}
"./auth/utils": "./dist/auth/utils.js",
{=! Used by our code, uncodumented (but accessible) for users. =}
"./auth/password": "./dist/auth/password.js",
{=! Used by our code, uncodumented (but accessible) for users. =}
"./auth/jwt": "./dist/auth/jwt.js",
{=! Used by user, documented. =}
"./auth/validation": "./dist/auth/validation.js",
{=! Used by users, documented. =}
"./auth/forms/Login": "./dist/auth/forms/Login.jsx",
{=! Used by users, documented. =}
"./auth/forms/Signup": "./dist/auth/forms/Signup.jsx",
{=! Used by users, documented. =}
"./auth/forms/VerifyEmail": "./dist/auth/forms/VerifyEmail.jsx",
{=! Used by users, documented. =}
"./auth/forms/ForgotPassword": "./dist/auth/forms/ForgotPassword.jsx",
{=! Used by users, documented. =}
"./auth/forms/ResetPassword": "./dist/auth/forms/ResetPassword.jsx",
{=! Used by users, documented. =}
"./auth/forms/internal/Form": "./dist/auth/forms/internal/Form.jsx",
{=! Used by users, documented. =}
"./auth/helpers/*": "./dist/auth/helpers/*.jsx",
{=! Used by our code, uncodumented (but accessible) for users. =}
"./auth/pages/createAuthRequiredPage": "./dist/auth/pages/createAuthRequiredPage.jsx",
{=! Used by users, documented. =}
"./api": "./dist/api/index.js",
{=! Used by our framework code (Websockets), undocumented (but accessible) for users. =}
"./api/events": "./dist/api/events.js",
{=! Used by users, documented. =}
"./operations": "./dist/operations/index.js",
{=! If we import a symbol like "import something form 'wasp/something'", we must =}
{=! expose it here (which leaks it to our users). We could avoid this by =}
{=! using relative imports inside SDK code (instead of library imports), =}
{=! but I didn't have time to implement it. =}
"./ext-src/*": "./dist/ext-src/*.js",
{=! Used by our code, uncodumented (but accessible) for users. =}
"./operations/*": "./dist/operations/*",
{=! Used by our code, uncodumented (but accessible) for users. =}
"./universal/url": "./dist/universal/url.js",
{=! Used by our code, uncodumented (but accessible) for users. =}
"./universal/types": "./dist/universal/types.js",
{=! Used by our code, uncodumented (but accessible) for users. =}
"./universal/validators": "./dist/universal/validators.js",
{=! Used by users and by our code, documented =}
"./server/dbClient": "./dist/server/dbClient.js",
{=! Used by users and by our code, documented. =}
"./server/config": "./dist/server/config.js",
{=! Used by users and by our code, documented. =}
"./server/types": "./dist/server/types/index.js",
{=! Used by users and by our code, documented. =}
"./server/middleware": "./dist/server/middleware/index.js",
{=! Parts are used by users, documented. Parts are probably used by our code, undocumented (but accessible). =}
"./server/utils": "./dist/server/utils.js",
{=! Used by our code, uncodumented (but accessible) for users. =}
"./server/actions": "./dist/server/actions/index.js",
{=! Used by our code, uncodumented (but accessible) for users. =}
"./server/queries": "./dist/server/queries/index.js",
{=! Used by users and also by our code, documented. =}
"./server/auth/email": "./dist/server/auth/email/index.js",
{=! Used by users, documented. =}
"./dbSeed/types": "./dist/dbSeed/types.js",
{=! Used by users, documented. =}
"./test": "./dist/test/index.js",
{=! Used by our code, uncodumented (but accessible) for users. =}
"./test/*": "./dist/test/*.js",
"./crud/*": "./dist/crud/*.js",
{=! Used by our code, uncodumented (but accessible) for users. =}
"./server/crud/*": "./dist/server/crud/*",
"./email": "./dist/email/index.js",
{=! Used by our code, uncodumented (but accessible) for users. =}
"./email/core/types": "./dist/email/core/types.js",
{=! Used by users, documented. =}
"./server/auth/email/utils": "./dist/server/auth/email/utils.js",
{=! Parts are used by users and documented (types), other parts are used by the framework code (entities). =}
"./jobs/*": "./dist/jobs/*.js",
{=! Used by our code, uncodumented (but accessible) for users. =}
{=! Todo(filip): This export becomes problematic once we start supporting different executors =}
"./jobs/pgBoss/types": "./dist/jobs/pgBoss/types.js",
{=! Used by users, documented. =}
"./router": "./dist/router/index.js",
{=! Used by users, documented. =}
"./server/webSocket": "./dist/server/webSocket/index.js",
{=! Used by users, documented. =}
"./webSocket": "./dist/webSocket/index.js",
{=! Used by our code, uncodumented (but accessible) for users. =}
"./webSocket/WebSocketProvider": "./dist/webSocket/WebSocketProvider.jsx"
},
"license": "ISC",
"include": [

View File

@ -1,7 +1,7 @@
import { useMemo } from 'react'
import { Link as RouterLink } from 'react-router-dom'
import { type Routes } from '../router'
import { interpolatePath } from './linkHelpers'
import { type Routes } from './index'
type RouterLinkProps = Parameters<typeof RouterLink>[0]

View File

@ -0,0 +1,31 @@
{{={= =}=}}
import { interpolatePath } from './linkHelpers'
import type {
RouteDefinitionsToRoutes,
OptionalRouteOptions,
ParamValue,
} from './types'
export const routes = {
{=# routes =}
{= name =}: {
to: "{= urlPath =}",
{=# 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>
export { Link } from './Link'

Some files were not shown because too many files have changed in this diff Show More