From 02a663905ca49e767c7aee12e3761c9d5149eefd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20Sodi=C4=87?= Date: Thu, 28 Nov 2024 17:51:43 +0100 Subject: [PATCH 1/7] Clean up Wasp ts config files --- .../StarterTemplates/Templating.hs | 1 - waspc/packages/wasp-config/eslint.config.js | 1 - waspc/packages/wasp-config/src/appSpec.ts | 6 ++---- waspc/packages/wasp-config/src/userApi.ts | 14 ++++++++------ waspc/src/Wasp/AppSpec.hs | 4 ++++ waspc/src/Wasp/Generator/NpmInstall.hs | 3 +-- waspc/src/Wasp/NodePackageFFI.hs | 7 +++++-- waspc/tools/install_packages_to_data_dir.sh | 3 +++ 8 files changed, 23 insertions(+), 16 deletions(-) diff --git a/waspc/cli/src/Wasp/Cli/Command/CreateNewProject/StarterTemplates/Templating.hs b/waspc/cli/src/Wasp/Cli/Command/CreateNewProject/StarterTemplates/Templating.hs index 0dc791d2bd..57ea86b0f0 100644 --- a/waspc/cli/src/Wasp/Cli/Command/CreateNewProject/StarterTemplates/Templating.hs +++ b/waspc/cli/src/Wasp/Cli/Command/CreateNewProject/StarterTemplates/Templating.hs @@ -52,7 +52,6 @@ replaceTemplatePlaceholdersInFileOnDisk appName projectName file = do ("__waspProjectName__", show projectName), ("__waspVersion__", defaultWaspVersionBounds) ] - -- TODO: We do this in all files, but not all files have all placeholders updateFileContentWith (replacePlaceholders waspTemplateReplacements) file where updateFileContentWith :: (Text -> Text) -> Path' Abs (File f) -> IO () diff --git a/waspc/packages/wasp-config/eslint.config.js b/waspc/packages/wasp-config/eslint.config.js index 8a580849a5..77235f6a06 100644 --- a/waspc/packages/wasp-config/eslint.config.js +++ b/waspc/packages/wasp-config/eslint.config.js @@ -5,7 +5,6 @@ import tseslint from "typescript-eslint"; export default [ pluginJs.configs.recommended, ...tseslint.configs.strict, - // Todo: explore typed-linting: https://typescript-eslint.io/getting-started/typed-linting { languageOptions: { globals: globals.node, diff --git a/waspc/packages/wasp-config/src/appSpec.ts b/waspc/packages/wasp-config/src/appSpec.ts index 074c620c28..4ba11d67e7 100644 --- a/waspc/packages/wasp-config/src/appSpec.ts +++ b/waspc/packages/wasp-config/src/appSpec.ts @@ -1,5 +1,5 @@ -/** This module is a mirror implementation of AppSpec Decls in TypeScript. - * The original implemention is in Haskell (waspc). +/** This module is a mirror implementation of FromJSON for AppSpec Decls in + * TypeScript. The original implemention is in Haskell (waspc). * * IMPORTANT: Do not change this file without updating the AppSpec in waspc. */ @@ -119,7 +119,6 @@ export type CrudOperationOptions = { } export type Wasp = { - // TODO: Check semver in export type system? version: string } @@ -166,7 +165,6 @@ export type EmailSender = { defaultFrom?: EmailFromField } -// TODO: duplication export type EmailProvider = 'SMTP' | 'SendGrid' | 'Mailgun' | 'Dummy' export type EmailFromField = { diff --git a/waspc/packages/wasp-config/src/userApi.ts b/waspc/packages/wasp-config/src/userApi.ts index 211c358a21..91c90a1f27 100644 --- a/waspc/packages/wasp-config/src/userApi.ts +++ b/waspc/packages/wasp-config/src/userApi.ts @@ -7,6 +7,8 @@ export class App { #userSpec: UserSpec; // NOTE: Using a non-public symbol gives us a pacakge-private property. + // It's not that important to hide it from the users, but we still don't want + // user's IDE to suggest it during autocompletion. [GET_USER_SPEC]() { return this.#userSpec } @@ -96,13 +98,13 @@ export type AppConfig = Pick export type ExtImport = | { - import: string - from: AppSpec.ExtImport['path'] - } + import: string + from: AppSpec.ExtImport['path'] + } | { - importDefault: string - from: AppSpec.ExtImport['path'] - } + importDefault: string + from: AppSpec.ExtImport['path'] + } export type ServerConfig = { setupFn?: ExtImport diff --git a/waspc/src/Wasp/AppSpec.hs b/waspc/src/Wasp/AppSpec.hs index 03087a37b9..44b4196601 100644 --- a/waspc/src/Wasp/AppSpec.hs +++ b/waspc/src/Wasp/AppSpec.hs @@ -58,6 +58,10 @@ import qualified Wasp.SemanticVersion as SV -- describing the web app specification with all the details needed to generate it. -- It is standalone and de-coupled from other parts of the compiler and knows nothing about them, -- instead other parts are using it: Analyzer produces AppSpec while Generator consumes it. +-- +-- IMPORTANT: Do not change this data structure without updating the AppSpec in +-- packages/wasp-config/src/appSpec.ts. That module is a TypeScript mirror +-- implementation of AppSpec's FromJSON. data AppSpec = AppSpec { -- | List of declarations like App, Page, Route, ... that describe the web app. decls :: [Decl], diff --git a/waspc/src/Wasp/Generator/NpmInstall.hs b/waspc/src/Wasp/Generator/NpmInstall.hs index 560525dce1..316b9fb6e7 100644 --- a/waspc/src/Wasp/Generator/NpmInstall.hs +++ b/waspc/src/Wasp/Generator/NpmInstall.hs @@ -1,6 +1,5 @@ module Wasp.Generator.NpmInstall - ( installProjectNpmDependencies, - installNpmDependenciesWithInstallRecord, + ( installNpmDependenciesWithInstallRecord, ) where diff --git a/waspc/src/Wasp/NodePackageFFI.hs b/waspc/src/Wasp/NodePackageFFI.hs index f2ef7ff312..f445671658 100644 --- a/waspc/src/Wasp/NodePackageFFI.hs +++ b/waspc/src/Wasp/NodePackageFFI.hs @@ -23,6 +23,8 @@ import Wasp.Data (DataDir) import qualified Wasp.Data as Data import qualified Wasp.Node.Version as NodeVersion +-- | This are the globally installed packages waspc runs directly from +-- their global installation path. data RunnablePackage = DeployPackage | TsInspectPackage @@ -36,6 +38,9 @@ data RunnablePackage PrismaPackage | WaspStudioPackage +-- | This are the globally installed packages waspc installs into +-- the user's project using `npm`. They are used/run from inside the project's +-- node_modules. data InstallablePackage = WaspConfigPackage data PackagesDir @@ -69,8 +74,6 @@ scriptInPackageDir = [relfile|dist/index.js|] -- If the package does not have its dependencies installed yet (for example, -- when the package is run for the first time after installing Wasp), we install -- the dependencies. --- TODO: How would it not have npm dependencies installed if we always to it in --- install_packages_to_data_dir.sh? getPackageProcessOptions :: RunnablePackage -> [String] -> IO P.CreateProcess getPackageProcessOptions package args = do NodeVersion.getAndCheckUserNodeVersion >>= \case diff --git a/waspc/tools/install_packages_to_data_dir.sh b/waspc/tools/install_packages_to_data_dir.sh index 730ce94138..3f5bc2daab 100755 --- a/waspc/tools/install_packages_to_data_dir.sh +++ b/waspc/tools/install_packages_to_data_dir.sh @@ -9,6 +9,9 @@ dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) for package in $(ls "$dir/../packages"); do package_dir="$dir/../packages/$package" if [[ -d "$package_dir" ]]; then + # We're only installing the dependencines here to verify that the build + # works, that's why the node_modules folder is removed immediately after. + # The real dependency installatino happens in Haskell. echo "Installing $package ($package_dir)" cd "$package_dir" npm install From f4e31889ba22630a36c886271442cd653360acad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20Sodi=C4=87?= Date: Fri, 29 Nov 2024 16:55:27 +0100 Subject: [PATCH 2/7] Add more refactors to the TS SDK --- waspc/packages/wasp-config/eslint.config.js | 19 +-- waspc/packages/wasp-config/src/appSpec.ts | 147 +++++++++++------- ...appers.ts => mapUserSpecToAppSpecDecls.ts} | 67 ++++---- waspc/packages/wasp-config/src/run.ts | 89 ++++++----- waspc/packages/wasp-config/src/userApi.ts | 15 +- 5 files changed, 204 insertions(+), 133 deletions(-) rename waspc/packages/wasp-config/src/{mappers.ts => mapUserSpecToAppSpecDecls.ts} (90%) diff --git a/waspc/packages/wasp-config/eslint.config.js b/waspc/packages/wasp-config/eslint.config.js index 77235f6a06..e8d837ff71 100644 --- a/waspc/packages/wasp-config/eslint.config.js +++ b/waspc/packages/wasp-config/eslint.config.js @@ -1,6 +1,6 @@ -import globals from "globals"; -import pluginJs from "@eslint/js"; -import tseslint from "typescript-eslint"; +import globals from 'globals' +import pluginJs from '@eslint/js' +import tseslint from 'typescript-eslint' export default [ pluginJs.configs.recommended, @@ -12,14 +12,15 @@ export default [ }, // global ignore { - ignores: ["node_modules/", "dist/"], + ignores: ['node_modules/', 'dist/'], }, { rules: { - "@typescript-eslint/no-unused-vars": "warn", - "@typescript-eslint/no-empty-function": "warn", - "no-empty": "warn", - "no-constant-condition": "warn", + '@typescript-eslint/no-unused-vars': 'warn', + '@typescript-eslint/no-empty-function': 'warn', + 'no-empty': 'warn', + 'no-constant-condition': 'warn', + 'object-shorthand': 'warn', }, }, -]; +] diff --git a/waspc/packages/wasp-config/src/appSpec.ts b/waspc/packages/wasp-config/src/appSpec.ts index 4ba11d67e7..4c0d85d5f6 100644 --- a/waspc/packages/wasp-config/src/appSpec.ts +++ b/waspc/packages/wasp-config/src/appSpec.ts @@ -22,7 +22,7 @@ export type DeclType = Decl['declType'] | 'Entity' export type Page = { component: ExtImport - authRequired?: boolean + authRequired: Optional } export type Route = { @@ -32,39 +32,39 @@ export type Route = { export type Action = { fn: ExtImport - entities?: Ref<'Entity'>[] - auth?: boolean + entities: Optional[]> + auth: Optional } export type Query = { fn: ExtImport - entities?: Ref<'Entity'>[] - auth?: boolean + entities: Optional[]> + auth: Optional } export type Job = { executor: JobExecutor perform: Perform - schedule?: Schedule - entities?: Ref<'Entity'>[] + schedule: Optional + entities: Optional[]> } export type Schedule = { cron: string - args?: object - executorOptions?: ExecutorOptions + args: Optional + executorOptions: Optional } export type Perform = { fn: ExtImport - executorOptions?: ExecutorOptions + executorOptions: Optional } export type Api = { fn: ExtImport - middlewareConfigFn?: ExtImport - entities?: Ref<'Entity'>[] + middlewareConfigFn: Optional + entities: Optional[]> httpRoute: HttpRoute - auth?: boolean + auth: Optional } export type ApiNamespace = { @@ -80,13 +80,13 @@ export type Crud = { export type App = { wasp: Wasp title: string - head?: string[] - auth?: Auth - server?: Server - client?: Client - db?: Db - emailSender?: EmailSender - webSocket?: WebSocket + head: Optional + auth: Optional + server: Optional + client: Optional + db: Optional + emailSender: Optional + webSocket: Optional } export type ExtImport = { @@ -98,7 +98,7 @@ export type ExtImport = { export type JobExecutor = 'PgBoss' export type ExecutorOptions = { - pgBoss?: object + pgBoss: Optional } export type HttpMethod = 'ALL' | 'GET' | 'POST' | 'PUT' | 'DELETE' @@ -106,16 +106,16 @@ export type HttpMethod = 'ALL' | 'GET' | 'POST' | 'PUT' | 'DELETE' export type HttpRoute = [HttpMethod, string] export type CrudOperations = { - get?: CrudOperationOptions - getAll?: CrudOperationOptions - create?: CrudOperationOptions - update?: CrudOperationOptions - delete?: CrudOperationOptions + get: Optional + getAll: Optional + create: Optional + update: Optional + delete: Optional } export type CrudOperationOptions = { - isPublic?: boolean - overrideFn?: ExtImport + isPublic: Optional + overrideFn: Optional } export type Wasp = { @@ -124,37 +124,37 @@ export type Wasp = { export type Auth = { userEntity: Ref<'Entity'> - externalAuthEntity?: Ref<'Entity'> + externalAuthEntity: Optional> methods: AuthMethods onAuthFailedRedirectTo: string - onAuthSucceededRedirectTo?: string - onBeforeSignup?: ExtImport - onAfterSignup?: ExtImport - onBeforeOAuthRedirect?: ExtImport - onBeforeLogin?: ExtImport - onAfterLogin?: ExtImport + onAuthSucceededRedirectTo: Optional + onBeforeSignup: Optional + onAfterSignup: Optional + onBeforeOAuthRedirect: Optional + onBeforeLogin: Optional + onAfterLogin: Optional } export type AuthMethods = { - usernameAndPassword?: UsernameAndPasswordConfig - discord?: ExternalAuthConfig - google?: ExternalAuthConfig - gitHub?: ExternalAuthConfig - keycloak?: ExternalAuthConfig - email?: EmailAuthConfig + usernameAndPassword: Optional + discord: Optional + google: Optional + gitHub: Optional + keycloak: Optional + email: Optional } export type UsernameAndPasswordConfig = { - userSignupFields?: ExtImport + userSignupFields: Optional } export type ExternalAuthConfig = { - configFn?: ExtImport - userSignupFields?: ExtImport + configFn: Optional + userSignupFields: Optional } export type EmailAuthConfig = { - userSignupFields?: ExtImport + userSignupFields: Optional fromField: EmailFromField emailVerification: EmailVerificationConfig passwordReset: PasswordResetConfig @@ -162,23 +162,23 @@ export type EmailAuthConfig = { export type EmailSender = { provider: EmailProvider - defaultFrom?: EmailFromField + defaultFrom: Optional } export type EmailProvider = 'SMTP' | 'SendGrid' | 'Mailgun' | 'Dummy' export type EmailFromField = { - name?: string + name: Optional email: string } export type EmailVerificationConfig = { - getEmailContentFn?: ExtImport + getEmailContentFn: Optional clientRoute: Ref<'Route'> } export type PasswordResetConfig = { - getEmailContentFn?: ExtImport + getEmailContentFn: Optional clientRoute: Ref<'Route'> } @@ -188,21 +188,56 @@ export type Ref = { } export type Server = { - setupFn?: ExtImport - middlewareConfigFn?: ExtImport + setupFn: Optional + middlewareConfigFn: Optional } export type Client = { - setupFn?: ExtImport - rootComponent?: ExtImport - baseDir?: `/${string}` + setupFn: Optional + rootComponent: Optional + baseDir: Optional<`/${string}`> } export type Db = { - seeds?: ExtImport[] + seeds: Optional } export type WebSocket = { fn: ExtImport - autoConnect?: boolean + autoConnect: Optional } + +/** + * We want to explicitly set all optional (Maybe) AppSpec fields to `undefined` + * (instead of using an optional field with a questionmark). + * + * Doing so doesn't change any functionality and ensures (at compile-time) we + * don't forget to include an existing optional field in a declaration object. + * + * For example, let's say `bar` is optional (both for the user and for the app + * spec). This would be the correct mapping code: + * ``` + * const { foo, bar } = userConfig + * const decl: SomeDecl = { + * foo: mapForAppSpec(foo), + * bar: mapForAppSpec(bar) + * } + * ``` + * The code below is wrong. It forgets to map `bar` even though it might exist + * in `userConfig`: + * ``` + * const { foo } = userConfig + * const decl: SomeDecl = { + * foo: mapForAppSpec(foo), + * } + * ``` + * If `bar` is an optional field of `SomeDecl` (`bar?: string`), TypeScript + * doesn't catch this error. + * + * If `bar` is a mandatory field of `SomeDecl` that can be set to `undefined` + * (`bar: Optional`), TypeScript catches the error. + * + * Explicitly setting optional fields to `undefined` doesn't impact JSON + * serialization since fields set to `undefined` are treated as missing fields. + */ +type Optional = T | undefined diff --git a/waspc/packages/wasp-config/src/mappers.ts b/waspc/packages/wasp-config/src/mapUserSpecToAppSpecDecls.ts similarity index 90% rename from waspc/packages/wasp-config/src/mappers.ts rename to waspc/packages/wasp-config/src/mapUserSpecToAppSpecDecls.ts index 711846b8b7..a0006d80be 100644 --- a/waspc/packages/wasp-config/src/mappers.ts +++ b/waspc/packages/wasp-config/src/mapUserSpecToAppSpecDecls.ts @@ -3,7 +3,7 @@ import * as AppSpec from './appSpec.js' import * as User from './userApi.js' -export function mapUserSpecToDecls( +export function mapUserSpecToAppSpecDecls( spec: User.UserSpec, entityNames: string[] ): AppSpec.Decl[] { @@ -132,12 +132,11 @@ function mapOperationConfig( config: User.ActionConfig | User.QueryConfig, parseEntityRef: RefParser<'Entity'> ): AppSpec.Action | AppSpec.Query { - // TODO: How to make sure I've destructured everything? const { fn, entities, auth } = config return { fn: mapExtImport(fn), - ...(entities && { entities: entities.map(parseEntityRef) }), - auth: auth, + entities: entities && entities.map(parseEntityRef), + auth, } } @@ -151,7 +150,6 @@ function mapExtImport(extImport: User.ExtImport): AppSpec.ExtImport { path: extImport.from, } } else { - const _exhaustiveCheck: never = extImport throw new Error( 'Invalid ExtImport: neither `import` nor `importDefault` is defined' ) @@ -166,9 +164,9 @@ function mapApiConfig( return { fn: mapExtImport(fn), middlewareConfigFn: middlewareConfigFn && mapExtImport(middlewareConfigFn), - ...(entities && { entities: entities.map(parseEntityRef) }), - httpRoute: httpRoute, - auth: auth, + entities: entities && entities.map(parseEntityRef), + httpRoute, + auth, } } @@ -228,9 +226,10 @@ function mapAuth( return { userEntity: parseEntityRef(userEntity), // TODO: Abstract away this pattern - ...(externalAuthEntity && { - externalAuthEntity: parseEntityRef(externalAuthEntity), - }), + externalAuthEntity: + externalAuthEntity === undefined + ? undefined + : parseEntityRef(externalAuthEntity), methods: mapAuthMethods(methods, parseRouteRef), onAuthFailedRedirectTo, onAuthSucceededRedirectTo, @@ -270,7 +269,7 @@ function mapUsernameAndPassword( } } -export function mapExternalAuth( +function mapExternalAuth( externalAuth: User.ExternalAuthConfig ): AppSpec.ExternalAuthConfig { const { configFn, userSignupFields } = externalAuth @@ -281,14 +280,21 @@ export function mapExternalAuth( } function mapEmailAuth( - email: User.EmailAuthConfig, + emailConfig: User.EmailAuthConfig, parseRouteRef: RefParser<'Route'> ): AppSpec.EmailAuthConfig { - const { userSignupFields, fromField, emailVerification, passwordReset } = - email + const { + userSignupFields, + fromField: { name, email }, + emailVerification, + passwordReset, + } = emailConfig return { userSignupFields: userSignupFields && mapExtImport(userSignupFields), - fromField, + fromField: { + name, + email, + }, emailVerification: mapEmailVerification(emailVerification, parseRouteRef), passwordReset: mapPasswordReset(passwordReset, parseRouteRef), } @@ -305,7 +311,7 @@ function mapEmailVerification( } } -export function mapPasswordReset( +function mapPasswordReset( passwordReset: User.PasswordResetConfig, parseRouteRef: RefParser<'Route'> ): AppSpec.PasswordResetConfig { @@ -326,25 +332,30 @@ function mapDb(db: User.DbConfig): AppSpec.Db { function mapEmailSender( emailSender: User.EmailSenderConfig ): AppSpec.EmailSender { - return emailSender + const { provider, defaultFrom } = emailSender + return { + provider, + defaultFrom: defaultFrom && { + name: defaultFrom.name, + email: defaultFrom.email, + }, + } } function mapServer(server: User.ServerConfig): AppSpec.Server { const { setupFn, middlewareConfigFn } = server return { - ...(setupFn && { setupFn: mapExtImport(setupFn) }), - ...(middlewareConfigFn && { - middlewareConfigFn: mapExtImport(middlewareConfigFn), - }), + setupFn: setupFn && mapExtImport(setupFn), + middlewareConfigFn: middlewareConfigFn && mapExtImport(middlewareConfigFn), } } function mapClient(client: User.ClientConfig): AppSpec.Client { const { setupFn, rootComponent, baseDir } = client return { - ...(setupFn && { setupFn: mapExtImport(setupFn) }), - ...(rootComponent && { rootComponent: mapExtImport(rootComponent) }), - ...(baseDir && { baseDir }), + setupFn: setupFn && mapExtImport(setupFn), + rootComponent: rootComponent && mapExtImport(rootComponent), + baseDir, } } @@ -362,10 +373,10 @@ function mapJob( ): AppSpec.Job { const { executor, perform, schedule, entities } = job return { - executor: executor, + executor, perform: mapPerform(perform), schedule: schedule && mapSchedule(schedule), - ...(entities && { entities: entities.map(parseEntityRef) }), + entities: entities && entities.map(parseEntityRef), } } @@ -381,7 +392,7 @@ function mapPerform(perform: User.Perform): AppSpec.Perform { const { fn, executorOptions } = perform return { fn: mapExtImport(fn), - ...(executorOptions && { executorOptions }), + executorOptions, } } diff --git a/waspc/packages/wasp-config/src/run.ts b/waspc/packages/wasp-config/src/run.ts index b810dc08bf..a172d48d35 100644 --- a/waspc/packages/wasp-config/src/run.ts +++ b/waspc/packages/wasp-config/src/run.ts @@ -2,51 +2,70 @@ import { writeFileSync } from 'fs' import { App } from './userApi.js' import { Decl } from './appSpec.js' -import { mapUserSpecToDecls } from './mappers.js' +import { mapUserSpecToAppSpecDecls } from './mapUserSpecToAppSpecDecls.js' import { GET_USER_SPEC } from './_private.js' -import { exit } from 'process' main() async function main() { - const { mainWaspJs, outputFile, entityNames } = parseProcessArguments( - process.argv - ) + const { + mainWaspJs, + outputFile: declsJsonOutputFile, + entityNames, + } = parseProcessArgsOrThrow(process.argv) - const app = await importApp(mainWaspJs) - const spec = analyzeApp(app, entityNames) + const result = await getAppDefinitionOrError(mainWaspJs) + if (result.status === 'error') { + console.error(result.error) + process.exit(1) + } + const { value: appDefinition } = result - writeFileSync(outputFile, serialize(spec)) -} + const decls = analyzeAppDefinition(appDefinition, entityNames) + const declsJson = getDeclsJson(decls) -async function importApp(mainWaspJs: string): Promise { - const app: unknown = (await import(mainWaspJs)).default - if (!app) { - console.error( - 'Could not load your app config. Make sure your *.wasp.ts file includes a default export of the app.' - ) - exit(1) - } - if (!isApp(app)) { - console.error( - 'The default export of your *.wasp.ts file must be an instance of App.' - ) - console.error('Make sure you export an object created with new App(...).') - exit(1) - } - return app + writeFileSync(declsJsonOutputFile, declsJson) } -function isApp(app: unknown): app is App { - return app instanceof App +async function getAppDefinitionOrError( + mainWaspJs: string +): Promise> { + const usersDefaultExport: unknown = (await import(mainWaspJs)).default + return getValidAppOrError(usersDefaultExport) } -function analyzeApp(app: App, entityNames: string[]): Decl[] { +function analyzeAppDefinition(app: App, entityNames: string[]): Decl[] { const userSpec = app[GET_USER_SPEC]() - return mapUserSpecToDecls(userSpec, entityNames) + return mapUserSpecToAppSpecDecls(userSpec, entityNames) +} + +function getDeclsJson(appConfig: Decl[]): string { + return JSON.stringify(appConfig) } -function parseProcessArguments(args: string[]): { +function getValidAppOrError(app: unknown): Result { + if (!app) { + return { + status: 'error', + error: + 'Could not load your app config. ' + + 'Make sure your *.wasp.ts file includes a default export of the app.', + } + } + + if (!(app instanceof App)) { + return { + status: 'error', + error: + 'The default export of your *.wasp.ts file must be an instance of App. ' + + 'Make sure you export an object created with new App(...).', + } + } + + return { status: 'ok', value: app } +} + +function parseProcessArgsOrThrow(args: string[]): { mainWaspJs: string outputFile: string entityNames: string[] @@ -68,7 +87,7 @@ function parseProcessArguments(args: string[]): { ) } - const entityNames = parseEntityNamesJson(entityNamesJson) + const entityNames = getValidEntityNamesOrThrow(entityNamesJson) return { mainWaspJs, @@ -77,7 +96,7 @@ function parseProcessArguments(args: string[]): { } } -function parseEntityNamesJson(entitiesJson: string): string[] { +function getValidEntityNamesOrThrow(entitiesJson: string): string[] { const entities = JSON.parse(entitiesJson) if (!Array.isArray(entities)) { throw new Error('The entities JSON must be an array of entity names.') @@ -85,6 +104,6 @@ function parseEntityNamesJson(entitiesJson: string): string[] { return entities } -function serialize(appConfig: Decl[]): string { - return JSON.stringify(appConfig) -} +type Result = + | { status: 'ok'; value: Value } + | { status: 'error'; error: Error } diff --git a/waspc/packages/wasp-config/src/userApi.ts b/waspc/packages/wasp-config/src/userApi.ts index 91c90a1f27..903cfa1443 100644 --- a/waspc/packages/wasp-config/src/userApi.ts +++ b/waspc/packages/wasp-config/src/userApi.ts @@ -15,7 +15,7 @@ export class App { constructor(name: string, config: AppConfig) { this.#userSpec = { - app: { name, config: config }, + app: { name, config }, actions: new Map(), apiNamespaces: new Map(), apis: new Map(), @@ -92,9 +92,11 @@ export class App { } } -export type WaspConfig = AppSpec.Wasp - -export type AppConfig = Pick +export type AppConfig = { + title: string + wasp: AppSpec.Wasp + head?: string[] +} export type ExtImport = | { @@ -205,7 +207,10 @@ export type QueryConfig = { auth?: boolean } -export type EmailSenderConfig = AppSpec.EmailSender +export type EmailSenderConfig = { + provider: AppSpec.EmailProvider + defaultFrom?: EmailFromField +} export type AuthConfig = { userEntity: string From 01038ec69a8dd607829f4affd12c488cf8da8def Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20Sodi=C4=87?= Date: Mon, 2 Dec 2024 21:45:37 +0100 Subject: [PATCH 3/7] Add even more refactors to the TS SDK --- waspc/packages/wasp-config/src/appSpec.ts | 9 +- .../src/mapUserSpecToAppSpecDecls.ts | 130 ++++++++---------- waspc/packages/wasp-config/tsconfig.json | 1 - 3 files changed, 65 insertions(+), 75 deletions(-) diff --git a/waspc/packages/wasp-config/src/appSpec.ts b/waspc/packages/wasp-config/src/appSpec.ts index 4c0d85d5f6..06063440e4 100644 --- a/waspc/packages/wasp-config/src/appSpec.ts +++ b/waspc/packages/wasp-config/src/appSpec.ts @@ -16,6 +16,11 @@ export type Decl = | { declType: 'ApiNamespace'; declName: string; declValue: ApiNamespace } | { declType: 'Crud'; declName: string; declValue: Crud } +export type GetDeclForType = Extract< + Decl, + { declType: T } +> + // NOTE: Entities are defined in the schema.prisma file, but they can still be // referenced. export type DeclType = Decl['declType'] | 'Entity' @@ -218,7 +223,7 @@ export type WebSocket = { * spec). This would be the correct mapping code: * ``` * const { foo, bar } = userConfig - * const decl: SomeDecl = { + * const decl: SomeDecl = { * foo: mapForAppSpec(foo), * bar: mapForAppSpec(bar) * } @@ -227,7 +232,7 @@ export type WebSocket = { * in `userConfig`: * ``` * const { foo } = userConfig - * const decl: SomeDecl = { + * const decl: SomeDecl = { * foo: mapForAppSpec(foo), * } * ``` diff --git a/waspc/packages/wasp-config/src/mapUserSpecToAppSpecDecls.ts b/waspc/packages/wasp-config/src/mapUserSpecToAppSpecDecls.ts index a0006d80be..7ca8cd5275 100644 --- a/waspc/packages/wasp-config/src/mapUserSpecToAppSpecDecls.ts +++ b/waspc/packages/wasp-config/src/mapUserSpecToAppSpecDecls.ts @@ -22,36 +22,43 @@ export function mapUserSpecToAppSpecDecls( routes, server, websocket, + cruds, } = spec const pageNames = Array.from(pages.keys()) const routeNames = Array.from(routes.keys()) + const parseEntityRef = makeRefParser('Entity', entityNames) const parsePageRef = makeRefParser('Page', pageNames) const parseRouteRef = makeRefParser('Route', routeNames) - // TODO: Try to build the entire object at once - const decls: AppSpec.Decl[] = [] - - // TODO: Find a way to make sure you've covered everything in compile time - for (const [pageName, pageConfig] of pages.entries()) { - decls.push({ - declType: 'Page', - declName: pageName, - declValue: mapPage(pageConfig), - }) - } - - for (const [routeName, routeConfig] of routes.entries()) { - decls.push({ - declType: 'Route', - declName: routeName, - declValue: mapRoute(routeConfig, parsePageRef), - }) - } - - decls.push({ - declType: 'App', + const pageDecls = mapToDecls(pages, 'Page', mapPage) + const routeDecls = mapToDecls(routes, 'Route', (routeConfig) => + mapRoute(routeConfig, parsePageRef) + ) + const actionDecls = mapToDecls(actions, 'Action', (actionConfig) => + mapOperationConfig(actionConfig, parseEntityRef) + ) + const queryDecls = mapToDecls(queries, 'Query', (queryConfig) => + mapOperationConfig(queryConfig, parseEntityRef) + ) + const apiDecls = mapToDecls(apis, 'Api', (apiConfig) => + mapApiConfig(apiConfig, parseEntityRef) + ) + const jobDecls = mapToDecls(jobs, 'Job', (jobConfig) => + mapJob(jobConfig, parseEntityRef) + ) + const apiNamespaceDecls = mapToDecls( + apiNamespaces, + 'ApiNamespace', + mapApiNamespace + ) + const crudDecls = mapToDecls(cruds, 'Crud', (crudConfig) => + mapCrud(crudConfig, parseEntityRef) + ) + + const appDecl = { + declType: 'App' as const, declName: app.name, declValue: mapApp( app.config, @@ -64,60 +71,39 @@ export function mapUserSpecToAppSpecDecls( emailSender, websocket ), - }) - - for (const [actionName, actionConfig] of actions.entries()) { - decls.push({ - declType: 'Action', - declName: actionName, - declValue: mapOperationConfig(actionConfig, parseEntityRef), - }) - } - - for (const [queryName, queryConfig] of queries.entries()) { - decls.push({ - declType: 'Query', - declName: queryName, - declValue: mapOperationConfig(queryConfig, parseEntityRef), - }) - } - - for (const [apiName, apiConfig] of apis.entries()) { - decls.push({ - declType: 'Api', - declName: apiName, - declValue: mapApiConfig(apiConfig, parseEntityRef), - }) - } - - for (const [jobName, jobConfig] of jobs.entries()) { - decls.push({ - declType: 'Job', - declName: jobName, - declValue: mapJob(jobConfig, parseEntityRef), - }) } - for (const [ - apiNamespaceName, - apiNamespaceConfig, - ] of apiNamespaces.entries()) { - decls.push({ - declType: 'ApiNamespace', - declName: apiNamespaceName, - declValue: mapApiNamespace(apiNamespaceConfig), - }) - } + return makeDeclsArray({ + App: [appDecl], + Page: pageDecls, + Route: routeDecls, + Action: actionDecls, + Query: queryDecls, + Api: apiDecls, + Job: jobDecls, + ApiNamespace: apiNamespaceDecls, + Crud: crudDecls, + }) +} - for (const [crudName, crudConfig] of spec.cruds.entries()) { - decls.push({ - declType: 'Crud', - declName: crudName, - declValue: mapCrud(crudConfig, parseEntityRef), - }) - } +function makeDeclsArray(decls: { + [Type in AppSpec.Decl['declType']]: AppSpec.GetDeclForType[] +}): AppSpec.Decl[] { + return Object.values(decls).flatMap((decl) => [...decl]) +} - return decls +function mapToDecls( + configs: Map, + type: DeclType, + configToDeclValue: ( + config: T + ) => AppSpec.GetDeclForType['declValue'] +) { + return [...configs].map(([name, config]) => ({ + declType: type, + declName: name, + declValue: configToDeclValue(config), + })) } function mapOperationConfig( diff --git a/waspc/packages/wasp-config/tsconfig.json b/waspc/packages/wasp-config/tsconfig.json index 0224b8b38b..759d826c99 100644 --- a/waspc/packages/wasp-config/tsconfig.json +++ b/waspc/packages/wasp-config/tsconfig.json @@ -20,6 +20,5 @@ "declaration": true, "lib": ["es2022"] }, - // better structure in output "include": ["src"], } From 3584c24af783a29a10360dd6184980c9048ee1ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20Sodi=C4=87?= Date: Mon, 2 Dec 2024 21:50:14 +0100 Subject: [PATCH 4/7] Turn Decl in appSpec.ts into a generic --- waspc/packages/wasp-config/src/appSpec.ts | 30 ++++++++++++++--------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/waspc/packages/wasp-config/src/appSpec.ts b/waspc/packages/wasp-config/src/appSpec.ts index 06063440e4..2006040b48 100644 --- a/waspc/packages/wasp-config/src/appSpec.ts +++ b/waspc/packages/wasp-config/src/appSpec.ts @@ -4,17 +4,25 @@ * IMPORTANT: Do not change this file without updating the AppSpec in waspc. */ -export type Decl = - | { declType: 'App'; declName: string; declValue: App } - | { declType: 'Page'; declName: string; declValue: Page } - | { declType: 'Route'; declName: string; declValue: Route } - | { declType: 'Query'; declName: string; declValue: Query } - | { declType: 'Action'; declName: string; declValue: Action } - | { declType: 'App'; declName: string; declValue: App } - | { declType: 'Job'; declName: string; declValue: Job } - | { declType: 'Api'; declName: string; declValue: Api } - | { declType: 'ApiNamespace'; declName: string; declValue: ApiNamespace } - | { declType: 'Crud'; declName: string; declValue: Crud } +export type Decl = { + [Type in keyof DeclTypeToValue]: { + declType: Type + declName: string + declValue: DeclTypeToValue[Type] + } +}[keyof DeclTypeToValue] + +export type DeclTypeToValue = { + App: App + Page: Page + Route: Route + Query: Query + Action: Action + Job: Job + Api: Api + ApiNamespace: ApiNamespace + Crud: Crud +} export type GetDeclForType = Extract< Decl, From e8badd27b3edb288bfa9790d4bd8b5449472e74f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20Sodi=C4=87?= Date: Mon, 9 Dec 2024 16:38:35 +0100 Subject: [PATCH 5/7] Replace case with fromMaybe --- waspc/src/Wasp/Project/Analyze.hs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/waspc/src/Wasp/Project/Analyze.hs b/waspc/src/Wasp/Project/Analyze.hs index 0004fad4c9..e34d00f7ca 100644 --- a/waspc/src/Wasp/Project/Analyze.hs +++ b/waspc/src/Wasp/Project/Analyze.hs @@ -13,6 +13,7 @@ import Control.Concurrent.Async (concurrently) import Control.Monad.Except (ExceptT (..), liftEither, runExceptT) import qualified Data.Aeson as Aeson import Data.List (find, isSuffixOf) +import Data.Maybe (fromMaybe) import StrongPath ( Abs, Dir, @@ -140,9 +141,11 @@ compileWaspTsFile waspProjectDir tsconfigNodeFileInWaspProjectDir waspFilePath = where outDir = waspProjectDir dotWaspDirInWaspProjectDir absCompiledWaspJsFile = outDir compiledWaspJsFileInDotWaspDir - compiledWaspJsFileInDotWaspDir = castFile $ case replaceRelExtension (basename waspFilePath) ".js" of - Just path -> path - Nothing -> error $ "Couldn't calculate the compiled JS file path for " ++ fromAbsFile waspFilePath ++ "." + compiledWaspJsFileInDotWaspDir = + castFile $ + fromMaybe + (error $ "Couldn't calculate the compiled JS file path for " ++ fromAbsFile waspFilePath ++ ".") + (replaceRelExtension (basename waspFilePath) ".js") executeMainWaspJsFileAndGetDeclsFile :: Path' Abs (Dir WaspProjectDir) -> @@ -257,6 +260,7 @@ findWaspFile waspDir = do findWaspTsFile files = WaspTs <$> findFileThatEndsWith ".wasp.ts" files findWaspLangFile files = WaspLang <$> findFileThatEndsWith ".wasp" files findFileThatEndsWith suffix files = castFile . (waspDir ) <$> find ((suffix `isSuffixOf`) . fromRelFile) files + fileNotFoundMessage = "Couldn't find the *.wasp or a *.wasp.ts file in the " ++ fromAbsDir waspDir ++ " directory" bothFilesFoundMessage = "Found both *.wasp and *.wasp.ts files in the project directory. " From ae52cd259c128b60ec59a52fc7313a603753915e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20Sodi=C4=87?= Date: Fri, 20 Dec 2024 11:00:37 +0100 Subject: [PATCH 6/7] Address PR comments --- waspc/packages/wasp-config/src/appSpec.ts | 9 +++++---- .../wasp-config/src/mapUserSpecToAppSpecDecls.ts | 2 +- waspc/packages/wasp-config/src/userApi.ts | 14 +++++++------- waspc/tools/install_packages_to_data_dir.sh | 2 +- 4 files changed, 14 insertions(+), 13 deletions(-) diff --git a/waspc/packages/wasp-config/src/appSpec.ts b/waspc/packages/wasp-config/src/appSpec.ts index 2006040b48..3742119513 100644 --- a/waspc/packages/wasp-config/src/appSpec.ts +++ b/waspc/packages/wasp-config/src/appSpec.ts @@ -221,11 +221,12 @@ export type WebSocket = { } /** - * We want to explicitly set all optional (Maybe) AppSpec fields to `undefined` - * (instead of using an optional field with a questionmark). + * We use this type for fields that are optional (Maybe) in AppSpec. + * We do this instead of `someField?:` because we want TypeScript to force us + * to explicitly set the field to `undefined`. * - * Doing so doesn't change any functionality and ensures (at compile-time) we - * don't forget to include an existing optional field in a declaration object. + * This way, if the AppSpec changes on the Haskell side, we won't forget to + * implement a proper mapping in TypeScript. * * For example, let's say `bar` is optional (both for the user and for the app * spec). This would be the correct mapping code: diff --git a/waspc/packages/wasp-config/src/mapUserSpecToAppSpecDecls.ts b/waspc/packages/wasp-config/src/mapUserSpecToAppSpecDecls.ts index 7ca8cd5275..f611531154 100644 --- a/waspc/packages/wasp-config/src/mapUserSpecToAppSpecDecls.ts +++ b/waspc/packages/wasp-config/src/mapUserSpecToAppSpecDecls.ts @@ -92,7 +92,7 @@ function makeDeclsArray(decls: { return Object.values(decls).flatMap((decl) => [...decl]) } -function mapToDecls( +function mapToDecls( configs: Map, type: DeclType, configToDeclValue: ( diff --git a/waspc/packages/wasp-config/src/userApi.ts b/waspc/packages/wasp-config/src/userApi.ts index 903cfa1443..02291b58ed 100644 --- a/waspc/packages/wasp-config/src/userApi.ts +++ b/waspc/packages/wasp-config/src/userApi.ts @@ -6,7 +6,7 @@ import { GET_USER_SPEC } from './_private.js' export class App { #userSpec: UserSpec; - // NOTE: Using a non-public symbol gives us a pacakge-private property. + // NOTE: Using a non-public symbol gives us a package-private property. // It's not that important to hide it from the users, but we still don't want // user's IDE to suggest it during autocompletion. [GET_USER_SPEC]() { @@ -100,13 +100,13 @@ export type AppConfig = { export type ExtImport = | { - import: string - from: AppSpec.ExtImport['path'] - } + import: string + from: AppSpec.ExtImport['path'] + } | { - importDefault: string - from: AppSpec.ExtImport['path'] - } + importDefault: string + from: AppSpec.ExtImport['path'] + } export type ServerConfig = { setupFn?: ExtImport diff --git a/waspc/tools/install_packages_to_data_dir.sh b/waspc/tools/install_packages_to_data_dir.sh index 3f5bc2daab..ce5894a99a 100755 --- a/waspc/tools/install_packages_to_data_dir.sh +++ b/waspc/tools/install_packages_to_data_dir.sh @@ -11,7 +11,7 @@ for package in $(ls "$dir/../packages"); do if [[ -d "$package_dir" ]]; then # We're only installing the dependencines here to verify that the build # works, that's why the node_modules folder is removed immediately after. - # The real dependency installatino happens in Haskell. + # The real dependency installation happens in Haskell. echo "Installing $package ($package_dir)" cd "$package_dir" npm install From cc187a97ac60cc35183ffaf0aaf2ff0a4b2cedfe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20Sodi=C4=87?= Date: Thu, 2 Jan 2025 14:17:02 +0100 Subject: [PATCH 7/7] Fix typo --- waspc/tools/install_packages_to_data_dir.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/waspc/tools/install_packages_to_data_dir.sh b/waspc/tools/install_packages_to_data_dir.sh index ce5894a99a..67acdcb579 100755 --- a/waspc/tools/install_packages_to_data_dir.sh +++ b/waspc/tools/install_packages_to_data_dir.sh @@ -9,7 +9,7 @@ dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) for package in $(ls "$dir/../packages"); do package_dir="$dir/../packages/$package" if [[ -d "$package_dir" ]]; then - # We're only installing the dependencines here to verify that the build + # We're only installing the dependencies here to verify that the build # works, that's why the node_modules folder is removed immediately after. # The real dependency installation happens in Haskell. echo "Installing $package ($package_dir)"