diff --git a/eslint.config.mjs b/eslint.config.mjs index ba65e6c..e07bfcb 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -26,7 +26,10 @@ export default defineConfig([ 'scripts/**', '**/*/htmx.*.min.js', 'src/templates/types/**', - 'src/templates/svelte/**' + 'src/templates/svelte/**', + 'test-scaffold/**/*.json', + 'test-scaffold/**/*.config.*', + 'test-scaffold/.prettierrc.*' ] }, diff --git a/src/commands/formatProject.ts b/src/commands/formatProject.ts index 3db43ee..4acafbd 100644 --- a/src/commands/formatProject.ts +++ b/src/commands/formatProject.ts @@ -1,6 +1,5 @@ -import { exit } from 'process'; +import { exit } from 'node:process'; import { spinner } from '@clack/prompts'; -import { $ } from 'bun'; import { green, red } from 'picocolors'; import { PackageManager } from '../types'; import { formatCommands, formatNoInstallCommands } from '../utils/commandMaps'; @@ -17,15 +16,26 @@ export const formatProject = async ({ installDependenciesNow }: FormatProjectProps) => { const spin = spinner(); + const fmt = installDependenciesNow + ? formatCommands[packageManager] + : formatNoInstallCommands[packageManager]; + const parts = fmt.split(/\s+/); + const bin = parts[0]; + if (bin === undefined) throw new Error('Empty format command'); + const args = parts.slice(1); try { - const fmt = installDependenciesNow - ? formatCommands[packageManager] - : formatNoInstallCommands[packageManager]; - spin.start('Formatting files…'); - const [bin, ...args] = fmt.split(' '); - await $`${bin} ${args}`.cwd(projectName).quiet(); + const proc = Bun.spawnSync([bin, ...args], { + cwd: projectName, + stderr: 'pipe', + stdout: 'ignore' + }); + if (!proc.success) { + const errMsg = + proc.stderr?.toString().trim() ?? `Exit code ${proc.exitCode}`; + throw new Error(errMsg); + } spin.stop(green('Files formatted')); } catch (err) { spin.cancel(red('Failed to format files')); diff --git a/src/commands/initializeGit.ts b/src/commands/initializeGit.ts index 7e4a676..a118e2d 100644 --- a/src/commands/initializeGit.ts +++ b/src/commands/initializeGit.ts @@ -1,18 +1,38 @@ import { spinner } from '@clack/prompts'; import { $ } from 'bun'; import { green, red } from 'picocolors'; +import { abort } from '../utils/abort'; +import { checkGitInstalled } from '../utils/checkGitInstalled'; + +const initializeRepository = async ( + projectName: string, + spin: ReturnType +) => { + spin.stop(); + spin.start('Initializing git repository…'); + + await $`git init -b main`.cwd(projectName).quiet(); + await $`git add -A`.cwd(projectName).quiet(); + await $`git commit -m "Initial commit"`.cwd(projectName).quiet(); + + spin.stop(green('Git repo initialized')); +}; export const initializeGit = async (projectName: string) => { const spin = spinner(); - try { - spin.start('Initializing git repository…'); + spin.start('Checking git availability...'); + const isGitInstalled = await checkGitInstalled(); - await $`git init -b main`.cwd(projectName).quiet(); - await $`git add -A`.cwd(projectName).quiet(); - await $`git commit -m "Initial commit"`.cwd(projectName).quiet(); + if (!isGitInstalled) { + spin.stop( + red('Git is not installed. Please install git before proceeding.') + ); + abort(); + } - spin.stop(green('Git repo initialized')); + try { + await initializeRepository(projectName, spin); } catch (err) { spin.cancel(red('Failed to initialize git')); throw err; diff --git a/src/commands/installDependencies.ts b/src/commands/installDependencies.ts index eb4ef50..581b8cf 100644 --- a/src/commands/installDependencies.ts +++ b/src/commands/installDependencies.ts @@ -1,6 +1,5 @@ import { exit } from 'process'; import { spinner } from '@clack/prompts'; -import { $ } from 'bun'; import { green, red } from 'picocolors'; import { PackageManager } from '../types'; import { installCommands } from '../utils/commandMaps'; @@ -11,11 +10,23 @@ export const installDependencies = async ( ) => { const spin = spinner(); const cmd = installCommands[packageManager]; + const parts = cmd.split(/\s+/); + const bin = parts[0]; + if (bin === undefined) throw new Error('Empty install command'); + const args = parts.slice(1); try { spin.start('Installing dependencies'); - const [bin, ...args] = cmd.split(' '); - await $`${bin} ${args}`.cwd(projectName).quiet(); + const proc = Bun.spawnSync([bin, ...args], { + cwd: projectName, + stderr: 'pipe', + stdout: 'ignore' + }); + if (!proc.success) { + const errMsg = + proc.stderr?.toString().trim() ?? `Exit code ${proc.exitCode}`; + throw new Error(errMsg); + } spin.stop(green('Dependencies installed')); } catch (err) { spin.cancel(red('Installation failed')); diff --git a/src/data.ts b/src/data.ts index dc6ce27..0e39374 100644 --- a/src/data.ts +++ b/src/data.ts @@ -187,6 +187,24 @@ export const eslintReactDependencies: AvailableDependency[] = [ value: 'zod-validation-error' } ]; + +export const prismaRuntimeDependencies: AvailableDependency[] = [ + { + latestVersion: '6.2.0', + value: '@prisma/client' + } +]; + +export const prismaDevDependencies: AvailableDependency[] = [ + { + latestVersion: '6.2.0', + value: 'prisma' + }, + { + latestVersion: '1.2.1', + value: '@prisma/extension-accelerate' + } +]; export const frontendLabels: FrontendLabels = { html: 'HTML', htmx: 'HTMX', diff --git a/src/generators/configurations/generateEnv.ts b/src/generators/configurations/generateEnv.ts index 2b1bf8b..c3f29d9 100644 --- a/src/generators/configurations/generateEnv.ts +++ b/src/generators/configurations/generateEnv.ts @@ -4,7 +4,7 @@ import { CreateConfiguration } from '../../types'; type GenerateEnvProps = Pick< CreateConfiguration, - 'databaseEngine' | 'databaseHost' | 'projectName' + 'databaseEngine' | 'databaseHost' | 'projectName' | 'databaseDirectory' > & { envVariables?: string[]; }; @@ -12,25 +12,33 @@ type GenerateEnvProps = Pick< const databaseURLS = { cockroachdb: 'postgresql://root@localhost:26257/database', gel: 'gel://admin@localhost:5656/main?tls_security=insecure', - mariadb: 'mariadb://user:userpassword@localhost:3306/database', - mongodb: 'mongodb://user:password@localhost:27017/database', - mssql: 'Server=localhost,1433;Database=master;User Id=sa;Password=SApassword1;Encrypt=true;TrustServerCertificate=true', - mysql: 'mysql://user:userpassword@localhost:3306/database', - postgresql: 'postgresql://user:password@localhost:5432/database', - singlestore: 'mysql://root:password@localhost:3306/database' + mariadb: 'mysql://root:rootpassword@localhost:3306/database', + mongodb: + 'mongodb://root:rootpassword@127.0.0.1:27017/database?authSource=admin&directConnection=true', + mssql: + 'Server=localhost,1433;Database=master;User Id=sa;Password=SApassword1;Encrypt=true;TrustServerCertificate=true', + mysql: 'mysql://root:rootpassword@localhost:3306/database', + postgresql: 'postgresql://postgres:rootpassword@localhost:5432/database', + singlestore: 'mysql://root:rootpassword@localhost:3306/database' } as const; export const generateEnv = ({ databaseEngine, databaseHost, + databaseDirectory = 'db', envVariables = [], projectName }: GenerateEnvProps) => { const vars = [...envVariables]; if ( - databaseEngine !== 'sqlite' && + databaseEngine === 'sqlite' && + (databaseHost === 'none' || databaseHost === undefined) + ) { + vars.push(`DATABASE_URL=file:./${databaseDirectory}/database.sqlite`); + } else if ( databaseEngine !== 'none' && + databaseEngine !== 'sqlite' && databaseEngine !== undefined && (databaseHost === 'none' || databaseHost === undefined) ) { diff --git a/src/generators/configurations/generatePackageJson.ts b/src/generators/configurations/generatePackageJson.ts index 8631140..6592281 100644 --- a/src/generators/configurations/generatePackageJson.ts +++ b/src/generators/configurations/generatePackageJson.ts @@ -8,10 +8,13 @@ import { defaultDependencies, defaultPlugins, eslintAndPrettierDependencies, - eslintReactDependencies + eslintReactDependencies, + prismaDevDependencies, + prismaRuntimeDependencies } from '../../data'; import type { CreateConfiguration, PackageJson } from '../../types'; import { getPackageVersions } from '../../utils/getPackageVersion'; +import { toDockerProjectName } from '../../utils/toDockerProjectName'; import { versions } from '../../versions'; import { computeFlags } from '../project/computeFlags'; @@ -25,6 +28,7 @@ type CreatePackageJsonProps = Pick< | 'orm' | 'frontendDirectories' | 'codeQualityTool' + | 'databaseDirectory' > & { projectName: string; latest: boolean; @@ -33,13 +37,13 @@ type CreatePackageJsonProps = Pick< const dbClientCommands = { cockroachdb: 'cockroach sql --insecure --database=database', gel: 'gel -H localhost -P 5656 -u admin --tls-security insecure -b main', - mariadb: 'MYSQL_PWD=userpassword mariadb -h127.0.0.1 -u user database', + mariadb: 'MYSQL_PWD=rootpassword mariadb -h127.0.0.1 -u root database', mongodb: - 'mongosh -u user -p password --authenticationDatabase admin database', + 'mongosh -u root -p rootpassword --authenticationDatabase admin database', mssql: '/opt/mssql-tools18/bin/sqlcmd -C -S localhost -U sa -P SApassword1', - mysql: 'MYSQL_PWD=userpassword mysql -h127.0.0.1 -u user database', - postgresql: 'psql -h localhost -U user -d database', - singlestore: 'singlestore -u root -ppassword -D database' + mysql: 'MYSQL_PWD=rootpassword mysql -h127.0.0.1 -u root database', + postgresql: 'psql -h localhost -U postgres -d database', + singlestore: 'singlestore -u root -prootpassword -D database' } as const; export const createPackageJson = async ({ @@ -52,7 +56,8 @@ export const createPackageJson = async ({ useTailwind, latest, frontendDirectories, - codeQualityTool + codeQualityTool, + databaseDirectory }: CreatePackageJsonProps) => { const flags = computeFlags(frontendDirectories); const isLocal = !databaseHost || databaseHost === 'none'; @@ -248,33 +253,58 @@ export const createPackageJson = async ({ versions['elysia-scoped-state'] ); } - if (orm === 'drizzle') { dependencies['drizzle-orm'] = resolveVersion( 'drizzle-orm', versions['drizzle-orm'] ); + devDependencies['drizzle-kit'] = resolveVersion( + 'drizzle-kit', + versions['drizzle-kit'] + ); } + const usesAccelerate = + orm === 'prisma' && + (databaseHost === 'neon' || databaseHost === 'planetscale'); - switch (databaseHost) { - case 'neon': - dependencies['@neondatabase/serverless'] = resolveVersion( - '@neondatabase/serverless', - versions['@neondatabase/serverless'] - ); - break; - case 'planetscale': - dependencies['@planetscale/database'] = resolveVersion( - '@planetscale/database', - versions['@planetscale/database'] + if (orm === 'prisma') { + prismaRuntimeDependencies.forEach((dep) => { + dependencies[dep.value] = resolveVersion( + dep.value, + dep.latestVersion ); - break; - case 'turso': - dependencies['@libsql/client'] = resolveVersion( - '@libsql/client', - versions['@libsql/client'] + }); + + prismaDevDependencies.forEach((dep) => { + if (dep.value === '@prisma/extension-accelerate' && !usesAccelerate) + return; + devDependencies[dep.value] = resolveVersion( + dep.value, + dep.latestVersion ); - break; + }); + } + if (orm === 'drizzle') { + switch (databaseHost) { + case 'neon': + dependencies['@neondatabase/serverless'] = resolveVersion( + '@neondatabase/serverless', + versions['@neondatabase/serverless'] + ); + break; + case 'planetscale': + dependencies['@planetscale/database'] = resolveVersion( + '@planetscale/database', + versions['@planetscale/database'] + ); + break; + case 'turso': + dependencies['@libsql/client'] = resolveVersion( + '@libsql/client', + versions['@libsql/client'] + ); + break; + } } if (latest) s.stop(green('Package versions resolved')); @@ -296,7 +326,8 @@ export const createPackageJson = async ({ databaseEngine !== 'sqlite' ) { const clientCmd = dbClientCommands[databaseEngine]; - const dockerPrefix = `docker compose -p ${databaseEngine} -f db/docker-compose.db.yml`; + const composeFile = `${databaseDirectory ?? 'db'}/docker-compose.db.yml`; + const dockerPrefix = `docker compose -p ${toDockerProjectName(projectName)} -f ${composeFile}`; scripts['db:up'] = `${dockerPrefix} up -d --wait db`; scripts['db:down'] = `${dockerPrefix} down`; @@ -347,9 +378,11 @@ export const createPackageJson = async ({ ); } - if (isLocalDb && databaseEngine === 'sqlite') { - scripts['db:sqlite'] = 'sqlite3 db/database.sqlite'; - scripts['db:init'] = 'sqlite3 db/database.sqlite < db/init.sql'; + if (isLocal && databaseEngine === 'sqlite') { + const dbDir = databaseDirectory ?? 'db'; + scripts['db:sqlite'] = `sqlite3 ${dbDir}/database.sqlite`; + scripts['db:init'] = + `sqlite3 ${dbDir}/database.sqlite < ${dbDir}/init.sql`; } if (orm === 'drizzle') { @@ -357,6 +390,21 @@ export const createPackageJson = async ({ scripts['db:push'] = 'drizzle-kit push'; } + if (orm === 'prisma') { + const schemaPath = databaseDirectory + ? `${databaseDirectory}/schema.prisma` + : 'db/schema.prisma'; + scripts['postinstall'] = `prisma generate --schema ${schemaPath}`; + scripts['db:generate'] = `prisma generate --schema ${schemaPath}`; + scripts['db:push'] = `prisma db push --schema ${schemaPath}`; + scripts['db:studio'] = `prisma studio --schema ${schemaPath}`; + scripts['db:migrate'] = `prisma migrate dev --schema ${schemaPath}`; + scripts['db:migrate:deploy'] = + `prisma migrate deploy --schema ${schemaPath}`; + scripts['db:migrate:reset'] = + `prisma migrate reset --schema ${schemaPath}`; + } + const packageJson: PackageJson = { dependencies, devDependencies, diff --git a/src/generators/configurations/generatePrismaClient.ts b/src/generators/configurations/generatePrismaClient.ts new file mode 100644 index 0000000..df5db7e --- /dev/null +++ b/src/generators/configurations/generatePrismaClient.ts @@ -0,0 +1,58 @@ +import { writeFileSync } from 'fs'; +import { join } from 'path'; +import type { DatabaseHost } from '../../types'; + +type GeneratePrismaClientProps = { + databaseHost: DatabaseHost; + databaseDirectory: string; + projectName: string; +}; + +const buildClientModule = (databaseHost: DatabaseHost) => { + const usesAccelerate = + databaseHost === 'neon' || databaseHost === 'planetscale'; + const clientImport = usesAccelerate + ? `import { PrismaClient } from '@prisma/client/edge'` + : `import { PrismaClient } from '@prisma/client'`; + const accelerateImport = usesAccelerate + ? `import { withAccelerate } from '@prisma/extension-accelerate'\n` + : ''; + + const instantiate = usesAccelerate + ? `const prisma = + globalForPrisma.prisma ?? + new PrismaClient({ + datasources: { + db: { url: process.env.DATABASE_URL } + } + }).$extends(withAccelerate())` + : `const prisma = globalForPrisma.prisma ?? new PrismaClient()`; + + return `${clientImport} +${accelerateImport}const globalForPrisma = globalThis as unknown as { + prisma: PrismaClient | undefined +} + +${instantiate} + +if (process.env.NODE_ENV !== 'production') { + globalForPrisma.prisma = prisma +} + +export const prismaClient = prisma +export default prismaClient +`; +}; + +export const generatePrismaClient = ({ + databaseHost, + databaseDirectory, + projectName +}: GeneratePrismaClientProps) => { + const contents = buildClientModule(databaseHost); + writeFileSync( + join(projectName, databaseDirectory, 'client.ts'), + contents, + 'utf-8' + ); +}; diff --git a/src/generators/configurations/scaffoldConfigurationFiles.ts b/src/generators/configurations/scaffoldConfigurationFiles.ts index 04bf18b..059743b 100644 --- a/src/generators/configurations/scaffoldConfigurationFiles.ts +++ b/src/generators/configurations/scaffoldConfigurationFiles.ts @@ -15,6 +15,7 @@ type AddConfigurationProps = Pick< | 'projectName' | 'databaseEngine' | 'databaseHost' + | 'databaseDirectory' > & { templatesDirectory: string; envVariables: string[] | undefined; @@ -24,8 +25,9 @@ export const scaffoldConfigurationFiles = ({ tailwind, templatesDirectory, databaseEngine, - envVariables, databaseHost, + databaseDirectory, + envVariables, codeQualityTool, frontends, initializeGitNow, @@ -70,6 +72,7 @@ export const scaffoldConfigurationFiles = ({ ); generateEnv({ + databaseDirectory, databaseEngine, databaseHost, envVariables, diff --git a/src/generators/db/dockerInitTemplates.ts b/src/generators/db/dockerInitTemplates.ts index c433809..5144f6d 100644 --- a/src/generators/db/dockerInitTemplates.ts +++ b/src/generators/db/dockerInitTemplates.ts @@ -124,28 +124,28 @@ export const initTemplates = { wait: 'until gel query -H localhost -P 5656 -u admin --tls-security insecure "select 1"; do sleep 1; done' }, mariadb: { - cli: 'MYSQL_PWD=userpassword mariadb -h127.0.0.1 -u user database -e', + cli: 'MYSQL_PWD=rootpassword mariadb -h127.0.0.1 -u root database -e', wait: 'until mariadb-admin ping -h127.0.0.1 --silent; do sleep 1; done' }, mongodb: { - cli: 'mongosh -u user -p password --authenticationDatabase admin database --eval', - wait: 'for i in $(seq 1 60); do mongosh -u user -p password --authenticationDatabase admin --eval "db.adminCommand(\\"ping\\")" --quiet 2>/dev/null && exit 0; sleep 1; done; exit 1' + cli: 'mongosh -u root -p rootpassword --authenticationDatabase admin database --eval', + wait: 'for i in $(seq 1 60); do mongosh -u root -p rootpassword --authenticationDatabase admin --eval "db.adminCommand(\\"ping\\")" --quiet 2>/dev/null && exit 0; sleep 1; done; exit 1' }, mssql: { cli: '/opt/mssql-tools18/bin/sqlcmd -C -S localhost -U sa -P SApassword1 -Q', wait: 'until /opt/mssql-tools18/bin/sqlcmd -C -S localhost -U sa -P SApassword1 -Q "SELECT 1" >/dev/null 2>&1; do sleep 1; done' }, mysql: { - cli: 'MYSQL_PWD=userpassword mysql -h127.0.0.1 -u user database -e', + cli: 'MYSQL_PWD=rootpassword mysql -h127.0.0.1 -u root database -e', wait: 'until mysqladmin ping -h127.0.0.1 --silent; do sleep 1; done' }, postgresql: { - cli: 'psql -U user -d database -c', - wait: 'until pg_isready -U user -h localhost --quiet; do sleep 1; done' + cli: 'psql -U postgres -d database -c', + wait: 'until pg_isready -U postgres -h localhost --quiet; do sleep 1; done' }, singlestore: { - cli: 'singlestore -u root -ppassword -e "CREATE DATABASE IF NOT EXISTS \\`database\\`" > /dev/null && singlestore -u root -ppassword -D database -e', - wait: 'until singlestore -u root -ppassword -e "SELECT 1" >/dev/null 2>&1; do sleep 1; done' + cli: 'singlestore -u root -prootpassword -e "CREATE DATABASE IF NOT EXISTS \\`database\\`" > /dev/null && singlestore -u root -prootpassword -D database -e', + wait: 'until singlestore -u root -prootpassword -e "SELECT 1" >/dev/null 2>&1; do sleep 1; done' } } as const; export const userTables = { diff --git a/src/generators/db/generateDockerContainer.ts b/src/generators/db/generateDockerContainer.ts index be707ae..6438a82 100644 --- a/src/generators/db/generateDockerContainer.ts +++ b/src/generators/db/generateDockerContainer.ts @@ -45,9 +45,7 @@ const templates: Record< mariadb: { env: { MYSQL_DATABASE: 'database', - MYSQL_PASSWORD: 'userpassword', - MYSQL_ROOT_PASSWORD: 'rootpassword', - MYSQL_USER: 'user' + MYSQL_ROOT_PASSWORD: 'rootpassword' }, healthcheck: { startPeriod: '5s', @@ -60,12 +58,12 @@ const templates: Record< mongodb: { env: { MONGO_INITDB_DATABASE: 'database', - MONGO_INITDB_ROOT_PASSWORD: 'password', - MONGO_INITDB_ROOT_USERNAME: 'user' + MONGO_INITDB_ROOT_PASSWORD: 'rootpassword', + MONGO_INITDB_ROOT_USERNAME: 'root' }, healthcheck: { startPeriod: '5s', - test: 'mongosh -u user -p password --authenticationDatabase admin --eval "db.adminCommand(\'ping\')" --quiet' + test: 'mongosh -u root -p rootpassword --authenticationDatabase admin --eval "db.adminCommand(\'ping\')" --quiet' }, image: 'mongo:7.0', port: '27017:27017', @@ -87,9 +85,7 @@ const templates: Record< mysql: { env: { MYSQL_DATABASE: 'database', - MYSQL_PASSWORD: 'userpassword', - MYSQL_ROOT_PASSWORD: 'rootpassword', - MYSQL_USER: 'user' + MYSQL_ROOT_PASSWORD: 'rootpassword' }, healthcheck: { startPeriod: '5s', @@ -102,12 +98,12 @@ const templates: Record< postgresql: { env: { POSTGRES_DB: 'database', - POSTGRES_PASSWORD: 'password', - POSTGRES_USER: 'user' + POSTGRES_PASSWORD: 'rootpassword', + POSTGRES_USER: 'postgres' }, healthcheck: { startPeriod: '5s', - test: 'pg_isready -U user -h localhost --quiet' + test: 'pg_isready -U postgres -h localhost --quiet' }, image: 'postgres:15', port: '5432:5432', @@ -115,11 +111,11 @@ const templates: Record< }, singlestore: { env: { - ROOT_PASSWORD: 'password' + ROOT_PASSWORD: 'rootpassword' }, healthcheck: { startPeriod: '30s', - test: 'singlestore -u root -ppassword -e "SELECT 1" >/dev/null 2>&1' + test: 'singlestore -u root -prootpassword -e "SELECT 1" >/dev/null 2>&1' }, image: 'ghcr.io/singlestore-labs/singlestoredb-dev', // NOTE: No tag specified due to data persistence platform: 'linux/amd64', // Required for ARM64 (Apple Silicon); no-op on amd64 diff --git a/src/generators/db/generateHandlers.ts b/src/generators/db/generateHandlers.ts index ce7500d..4a2fbd9 100644 --- a/src/generators/db/generateHandlers.ts +++ b/src/generators/db/generateHandlers.ts @@ -1,12 +1,19 @@ -import { CreateConfiguration } from '../../types'; -import { getAuthTemplate, getCountTemplate } from './handlerTemplates'; +import type { CreateConfiguration } from '../../types'; +import { + getAuthTemplate, + getCountTemplate, + type DriverConfigurationKey +} from './handlerTemplates'; type GenerateDBHandlersProps = Pick< CreateConfiguration, - 'databaseEngine' | 'databaseHost' | 'orm' + 'databaseDirectory' | 'databaseEngine' | 'databaseHost' | 'orm' > & { usesAuth: boolean }; +const defaultDbDir = 'db'; + export const generateDBHandlers = ({ + databaseDirectory = defaultDbDir, databaseEngine, databaseHost, orm, @@ -20,9 +27,12 @@ export const generateDBHandlers = ({ const host = databaseHost && databaseHost !== 'none' ? databaseHost : 'local'; - const ormKey = orm === 'drizzle' ? 'drizzle' : 'sql'; - const key = `${databaseEngine}:${ormKey}:${host}` as const; + let ormKey = 'sql'; + if (orm === 'drizzle') ormKey = 'drizzle'; + else if (orm === 'prisma') ormKey = 'prisma'; + const key = `${databaseEngine}:${ormKey}:${host}` as DriverConfigurationKey; - // @ts-expect-error - TODO: Finish the other templates - return usesAuth ? getAuthTemplate(key) : getCountTemplate(key); + return usesAuth + ? getAuthTemplate(key, databaseDirectory) + : getCountTemplate(key, databaseDirectory); }; diff --git a/src/generators/db/generatePrismaSchema.ts b/src/generators/db/generatePrismaSchema.ts new file mode 100644 index 0000000..a2db41e --- /dev/null +++ b/src/generators/db/generatePrismaSchema.ts @@ -0,0 +1,100 @@ +import type { + AuthOption, + AvailablePrismaDialect, + DatabaseHost +} from '../../types'; + +type DialectConfig = { + provider: string; + countHistoryId: string; +}; + +const DIALECTS: Record = { + cockroachdb: { + countHistoryId: 'Int @id @default(sequence())', + provider: 'cockroachdb' + }, + mariadb: { + countHistoryId: 'Int @id @default(autoincrement())', + provider: 'mysql' + }, + mongodb: { + countHistoryId: 'String @id @default(auto()) @map("_id") @db.ObjectId', + provider: 'mongodb' + }, + mssql: { + countHistoryId: 'Int @id @default(autoincrement())', + provider: 'sqlserver' + }, + mysql: { + countHistoryId: 'Int @id @default(autoincrement())', + provider: 'mysql' + }, + postgresql: { + countHistoryId: 'Int @id @default(autoincrement())', + provider: 'postgresql' + }, + sqlite: { + countHistoryId: 'Int @id @default(autoincrement())', + provider: 'sqlite' + } +}; + +type GeneratePrismaSchemaProps = { + authOption: AuthOption; + databaseEngine: AvailablePrismaDialect; + databaseHost: DatabaseHost; +}; + +const buildGeneratorBlock = (databaseHost: DatabaseHost) => { + const needsDriverAdapters = + databaseHost === 'neon' || databaseHost === 'planetscale'; + const previewFeatures = needsDriverAdapters + ? '\n previewFeatures = ["driverAdapters"]' + : ''; + + return `generator client { + provider = "prisma-client-js"${previewFeatures} +}`; +}; + +const buildDatasourceBlock = (cfg: DialectConfig) => `datasource db { + provider = "${cfg.provider}" + url = env("DATABASE_URL") +}`; + +const buildUserModel = () => `model User { + auth_sub String @id + metadata Json + created_at DateTime @default(now()) + + @@map("users") +}`; + +const buildCountHistoryModel = (cfg: DialectConfig) => `model CountHistory { + uid ${cfg.countHistoryId} + count Int + created_at DateTime @default(now()) + + @@map("count_history") +}`; + +export const generatePrismaSchema = ({ + authOption, + databaseEngine, + databaseHost +}: GeneratePrismaSchemaProps) => { + const cfg = DIALECTS[databaseEngine]; + if (!cfg) { + throw new Error( + `Unsupported Prisma dialect "${databaseEngine}" encountered while generating schema.` + ); + } + + const generatorBlock = buildGeneratorBlock(databaseHost); + const datasourceBlock = buildDatasourceBlock(cfg); + const modelBlock = + authOption === 'abs' ? buildUserModel() : buildCountHistoryModel(cfg); + + return [generatorBlock, datasourceBlock, modelBlock].join('\n\n'); +}; diff --git a/src/generators/db/handlerTemplates.ts b/src/generators/db/handlerTemplates.ts index 48c0d1d..587f74f 100644 --- a/src/generators/db/handlerTemplates.ts +++ b/src/generators/db/handlerTemplates.ts @@ -277,6 +277,27 @@ const mysqlSqlQueryOperations: QueryOperations = { ` }; +const prismaQueryOperations: QueryOperations = { + insertHistory: `const newHistory = await db.countHistory.create({ + data: { count } +}) + return newHistory`, + insertUser: `const newUser = await db.user.upsert({ + where: { auth_sub: authSub }, + update: { metadata: userIdentity }, + create: { auth_sub: authSub, metadata: userIdentity } +}) + return newUser`, + selectHistory: `const history = await db.countHistory.findUnique({ + where: { uid } +}) + return history`, + selectUser: `const user = await db.user.findUnique({ + where: { auth_sub: authSub } +}) + return user` +}; + const mysqlDrizzleQueryOperations: QueryOperations = { insertHistory: `const [row] = await db .insert(schema.countHistory) @@ -367,6 +388,11 @@ const mysqlPlanetScaleQueryOperations: QueryOperations = { }; const driverConfigurations = { + 'cockroachdb:prisma:local': { + dbType: 'PrismaClient', + importLines: `import type { PrismaClient } from '@prisma/client'`, + queries: prismaQueryOperations + }, 'cockroachdb:sql:local': { dbType: 'SQL', importLines: ``, @@ -390,11 +416,21 @@ import { schema } from '../../../db/schema' import { schema } from '../../../db/schema'`, queries: mysqlDrizzleQueryOperations }, + 'mariadb:prisma:local': { + dbType: 'PrismaClient', + importLines: `import type { PrismaClient } from '@prisma/client'`, + queries: prismaQueryOperations + }, 'mariadb:sql:local': { dbType: 'SQL', importLines: ``, queries: mysqlSqlQueryOperations }, + 'mongodb:prisma:local': { + dbType: 'PrismaClient', + importLines: `import type { PrismaClient } from '@prisma/client'`, + queries: prismaQueryOperations + }, 'mongodb:sql:local': { dbType: 'Db', importLines: ``, @@ -406,6 +442,11 @@ import { schema } from '../../../db/schema'`, import { schema } from '../../../db/schema'`, queries: drizzleQueryOperations }, + 'mssql:prisma:local': { + dbType: 'PrismaClient', + importLines: `import type { PrismaClient } from '@prisma/client'`, + queries: prismaQueryOperations + }, 'mssql:sql:local': { dbType: 'ConnectionPool', importLines: ``, @@ -423,6 +464,16 @@ import { schema } from '../../../db/schema'`, import { schema } from '../../../db/schema'`, queries: mysqlDrizzleQueryOperations }, + 'mysql:prisma:local': { + dbType: 'PrismaClient', + importLines: `import type { PrismaClient } from '@prisma/client'`, + queries: prismaQueryOperations + }, + 'mysql:prisma:planetscale': { + dbType: 'PrismaClient', + importLines: `import type { PrismaClient } from '@prisma/client'`, + queries: prismaQueryOperations + }, 'mysql:sql:local': { dbType: 'SQL', importLines: ``, @@ -451,6 +502,21 @@ import { schema } from '../../../db/schema'`, import { schema } from '../../../db/schema'`, queries: drizzleQueryOperations }, + 'postgresql:prisma:local': { + dbType: 'PrismaClient', + importLines: `import type { PrismaClient } from '@prisma/client'`, + queries: prismaQueryOperations + }, + 'postgresql:prisma:neon': { + dbType: 'PrismaClient', + importLines: `import type { PrismaClient } from '@prisma/client'`, + queries: prismaQueryOperations + }, + 'postgresql:prisma:planetscale': { + dbType: 'PrismaClient', + importLines: `import type { PrismaClient } from '@prisma/client'`, + queries: prismaQueryOperations + }, 'postgresql:sql:local': { dbType: 'SQL', importLines: ``, @@ -472,6 +538,11 @@ import { schema } from '../../../db/schema'`, import { schema } from '../../../db/schema'`, queries: mysqlDrizzleQueryOperations }, + 'singlestore:prisma:local': { + dbType: 'PrismaClient', + importLines: `import type { PrismaClient } from '@prisma/client'`, + queries: prismaQueryOperations + }, 'singlestore:sql:local': { dbType: 'Pool', importLines: `import { RowDataPacket } from 'mysql2/promise' @@ -490,6 +561,16 @@ import { schema } from '../../../db/schema'`, import { schema } from '../../../db/schema'`, queries: drizzleQueryOperations }, + 'sqlite:prisma:local': { + dbType: 'PrismaClient', + importLines: `import type { PrismaClient } from '@prisma/client'`, + queries: prismaQueryOperations + }, + 'sqlite:prisma:turso': { + dbType: 'PrismaClient', + importLines: `import type { PrismaClient } from '@prisma/client'`, + queries: prismaQueryOperations + }, 'sqlite:sql:local': { dbType: 'Database', importLines: ``, @@ -502,20 +583,45 @@ import { schema } from '../../../db/schema'`, } } as const; -type DriverConfigurationKey = keyof typeof driverConfigurations; - -export const getAuthTemplate = (key: DriverConfigurationKey) => { +export type DriverConfigurationKey = keyof typeof driverConfigurations; + +const replaceDbPath = ( + importLines: string, + databaseDirectory: string +): string => + importLines.replace( + /\.\.\/\.\.\/\.\.\/db\//g, + `../../../${databaseDirectory}/` + ); + +export const getAuthTemplate = ( + key: DriverConfigurationKey, + databaseDirectory = 'db' +) => { const configuration = driverConfigurations[key]; if (!configuration) throw new Error(`Unsupported driver configuration: ${key}`); - return buildSqlAuthTemplate(configuration); + const config = { + ...configuration, + importLines: replaceDbPath(configuration.importLines, databaseDirectory) + }; + + return buildSqlAuthTemplate(config); }; -export const getCountTemplate = (key: DriverConfigurationKey) => { +export const getCountTemplate = ( + key: DriverConfigurationKey, + databaseDirectory = 'db' +) => { const configuration = driverConfigurations[key]; if (!configuration) throw new Error(`Unsupported driver configuration: ${key}`); - return buildSqlCountTemplate(configuration); + const config = { + ...configuration, + importLines: replaceDbPath(configuration.importLines, databaseDirectory) + }; + + return buildSqlCountTemplate(config); }; diff --git a/src/generators/db/scaffoldDatabase.ts b/src/generators/db/scaffoldDatabase.ts index d85bb79..3a715fb 100644 --- a/src/generators/db/scaffoldDatabase.ts +++ b/src/generators/db/scaffoldDatabase.ts @@ -1,14 +1,19 @@ import { mkdirSync, writeFileSync } from 'fs'; -import { join } from 'path'; +import { join, resolve } from 'path'; import { $ } from 'bun'; -import { dim, yellow } from 'picocolors'; -import { isDrizzleDialect } from '../../typeGuards'; +import { isDrizzleDialect, isPrismaDialect } from '../../typeGuards'; import type { CreateConfiguration } from '../../types'; +import { checkDockerInstalled } from '../../utils/checkDockerInstalled'; import { checkSqliteInstalled } from '../../utils/checkSqliteInstalled'; +import { resolveDockerExe } from '../../utils/checkDockerInstalled'; +import { toDockerProjectName } from '../../utils/toDockerProjectName'; import { createDrizzleConfig } from '../configurations/generateDrizzleConfig'; +import { generatePrismaClient } from '../configurations/generatePrismaClient'; import { generateDatabaseTypes } from './generateDatabaseTypes'; +import { generateDockerContainer } from './generateDockerContainer'; import { generateDrizzleSchema } from './generateDrizzleSchema'; import { generateDBHandlers } from './generateHandlers'; +import { generatePrismaSchema } from './generatePrismaSchema'; import { generateSqliteSchema } from './generateSqliteSchema'; import { scaffoldDocker } from './scaffoldDocker'; @@ -46,6 +51,7 @@ export const scaffoldDatabase = async ({ ? 'userHandlers.ts' : 'countHistoryHandlers.ts'; const dbHandlers = generateDBHandlers({ + databaseDirectory, databaseEngine, databaseHost, orm, @@ -63,26 +69,29 @@ export const scaffoldDatabase = async ({ join(projectDatabaseDirectory, 'schema.sql'), sqliteSchema ); - await $`sqlite3 ${databaseDirectory}/database.sqlite ".read ${join( - databaseDirectory, - 'schema.sql' - )}"`.cwd(projectName); + const schemaPath = `${databaseDirectory}/schema.sql`; + await $`sqlite3 ${databaseDirectory}/database.sqlite ".read ${schemaPath}"`.cwd( + projectName + ); } + let dockerFreshInstall = false; + // For Prisma, we need to create the docker-compose file but skip schema initialization + // For Drizzle and no ORM, we use scaffoldDocker which handles schema initialization if ( (databaseHost === 'none' || databaseHost === undefined) && databaseEngine !== 'sqlite' && databaseEngine !== undefined && databaseEngine !== 'none' ) { - const { dockerFreshInstall } = await scaffoldDocker({ + const result = await scaffoldDocker({ authOption, + databaseDirectory, databaseEngine, projectDatabaseDirectory, projectName }); - - return { dockerFreshInstall }; + dockerFreshInstall = result.dockerFreshInstall; } if (orm === 'drizzle') { @@ -107,14 +116,124 @@ export const scaffoldDatabase = async ({ }); writeFileSync(join(typesDirectory, 'databaseTypes.ts'), drizzleTypes); - return { dockerFreshInstall: false }; + return { dockerFreshInstall }; } - if (orm === 'prisma') { - console.warn( - `${dim('│')}\n${yellow('▲')} Prisma support is not implemented yet` - ); + if (orm !== 'prisma') return { dockerFreshInstall }; + + if (!isPrismaDialect(databaseEngine)) { + throw new Error('Internal type error: Expected a Prisma dialect'); + } + + const prismaSchema = generatePrismaSchema({ + authOption, + databaseEngine, + databaseHost + }); + + const schemaPath = join(projectDatabaseDirectory, 'schema.prisma'); + writeFileSync(schemaPath, prismaSchema); + + generatePrismaClient({ + databaseDirectory, + databaseHost, + projectName + }); + + const schemaArg = `${databaseDirectory}/schema.prisma`; + const projectCwd = join(projectName); + + try { + await $`npx prisma generate --schema ${schemaArg}`.cwd(projectCwd); + } catch (error) { + console.error('Error generating Prisma client:', error); + } + + const isLocalDatabase = !databaseHost || databaseHost === 'none'; + if (!isLocalDatabase) return { dockerFreshInstall }; + + // For non-SQLite databases, ensure Docker container is running before migrations + if (databaseEngine !== 'sqlite') { + try { + await $`bun db:reset`.cwd(projectCwd).nothrow(); + await $`bun db:up`.cwd(projectCwd); + const { initTemplates } = await import('./dockerInitTemplates'); + if (databaseEngine in initTemplates) { + const waitCommand = + initTemplates[databaseEngine as keyof typeof initTemplates] + ?.wait; + if (waitCommand) { + await $`docker compose -p ${toDockerProjectName(projectName)} -f ${databaseDirectory}/docker-compose.db.yml exec -T db bash -lc '${waitCommand}'` + .cwd(projectCwd) + .nothrow(); + } + } else { + await new Promise((resolve) => setTimeout(resolve, 5000)); + } + // Brief delay for MongoDB to fully stabilize before Prisma connections + if (databaseEngine === 'mongodb') { + await new Promise((resolve) => setTimeout(resolve, 3000)); + } + } catch (error) { + console.error('Error starting database container:', error); + } + } + + if (databaseEngine === 'sqlite') { + const result = await $`npx prisma db push --schema ${schemaArg}` + .cwd(projectCwd) + .nothrow(); + if (result.exitCode !== 0) + console.error('Error running Prisma migrations:', result.stderr); + + return { dockerFreshInstall }; + } + + if (databaseEngine === 'mongodb') { + const docker = resolveDockerExe(); + const projectPath = resolve(projectCwd); + const network = `${toDockerProjectName(projectName)}_default`; + const mongoUrl = + 'mongodb://root:rootpassword@db:27017/database?authSource=admin'; + + const result = await $`${docker} run --rm --network ${network} -v ${projectPath}:/app -w /app -e "DATABASE_URL=${mongoUrl}" node:20-slim npx prisma db push --schema ${schemaArg}` + .nothrow(); + + if (result.exitCode !== 0) + console.error('Error running Prisma migrations:', result.stderr); + + return { dockerFreshInstall }; + } + + // Run prisma migrate from inside Docker to connect via network (avoids host connection auth issues) + const dockerNetworkUrls: Record = { + cockroachdb: 'postgresql://root@db:26257/database', + mariadb: 'mysql://root:rootpassword@db:3306/database', + mssql: + 'Server=db,1433;Database=master;User Id=sa;Password=SApassword1;Encrypt=true;TrustServerCertificate=true', + mysql: 'mysql://root:rootpassword@db:3306/database', + postgresql: 'postgresql://postgres:rootpassword@db:5432/database' + }; + const dbUrl = dockerNetworkUrls[databaseEngine]; + + if (dbUrl !== undefined) { + const docker = resolveDockerExe(); + const projectPath = resolve(projectCwd); + const network = `${toDockerProjectName(projectName)}_default`; + + const result = await $`${docker} run --rm --network ${network} -v ${projectPath}:/app -w /app -e "DATABASE_URL=${dbUrl}" node:20-slim npx prisma migrate dev --name init --skip-generate --schema ${schemaArg}` + .nothrow(); + + if (result.exitCode !== 0) + console.error('Error running Prisma migrations:', result.stderr); + } else { + const result = + await $`npx prisma migrate dev --name init --skip-generate --schema ${schemaArg}` + .cwd(projectCwd) + .nothrow(); + if (result.exitCode !== 0) + console.error('Error running Prisma migrations:', result.stderr); } - return { dockerFreshInstall: false }; + return { dockerFreshInstall }; }; diff --git a/src/generators/db/scaffoldDocker.ts b/src/generators/db/scaffoldDocker.ts index 592e696..44452d2 100644 --- a/src/generators/db/scaffoldDocker.ts +++ b/src/generators/db/scaffoldDocker.ts @@ -10,6 +10,7 @@ import { resolveDockerExe, shutdownDockerDaemon } from '../../utils/checkDockerInstalled'; +import { toDockerProjectName } from '../../utils/toDockerProjectName'; import { countHistoryTables, initTemplates, @@ -69,17 +70,19 @@ const verifyDockerContainer = async ({ }; type ScaffoldDockerProps = { + authOption: AuthOption; + databaseDirectory: string; databaseEngine: DatabaseEngine; projectDatabaseDirectory: string; - authOption: AuthOption; projectName: string; }; export const scaffoldDocker = async ({ + authOption, + databaseDirectory, databaseEngine, projectDatabaseDirectory, - projectName, - authOption + projectName }: ScaffoldDockerProps): Promise<{ dockerFreshInstall: boolean }> => { if ( databaseEngine === undefined || @@ -101,6 +104,8 @@ export const scaffoldDocker = async ({ ); const docker = resolveDockerExe(); + const composeFile = `${databaseDirectory}/docker-compose.db.yml`; + const projectFlag = toDockerProjectName(projectName); const spin = spinner(); spin.start(`Starting ${databaseEngine} container`); @@ -123,7 +128,35 @@ export const scaffoldDocker = async ({ }); try { - await dockerAction(); + const hasSchemaInit = databaseEngine in userTables; + if (hasSchemaInit) { + const dbKey = databaseEngine as keyof typeof userTables; + const { wait, cli } = initTemplates[dbKey]; + const usesAuth = authOption !== undefined && authOption !== 'none'; + const dbCommand = usesAuth + ? userTables[dbKey] + : countHistoryTables[dbKey]; + await $`${docker} compose -p ${projectFlag} -f ${composeFile} up -d db` + .cwd(projectName) + .quiet(); + spin.message(`Initializing ${databaseEngine} schema`); + await $`${docker} compose -p ${projectFlag} -f ${composeFile} exec -T db \ + bash -lc '${wait} && ${cli} "${dbCommand}"'` + .cwd(projectName) + .quiet(); + spin.message(`Stopping ${databaseEngine} container`); + await $`${docker} compose -p ${projectFlag} -f ${composeFile} down` + .cwd(projectName) + .quiet(); + } else { + await $`${docker} compose -p ${projectFlag} -f ${composeFile} up -d --wait db` + .cwd(projectName) + .quiet(); + spin.message(`Stopping ${databaseEngine} container`); + await $`${docker} compose -p ${projectFlag} -f ${composeFile} down` + .cwd(projectName) + .quiet(); + } spin.stop(green('Docker container verified')); } catch (err) { spin.cancel(red('Docker setup failed')); diff --git a/src/generators/project/generateDBBlock.ts b/src/generators/project/generateDBBlock.ts index 302f3eb..7d08c5c 100644 --- a/src/generators/project/generateDBBlock.ts +++ b/src/generators/project/generateDBBlock.ts @@ -1,4 +1,4 @@ -import { availableDrizzleDialects } from '../../data'; +import { availableDrizzleDialects, availablePrismaDialects } from '../../data'; import type { CreateConfiguration } from '../../types'; type DBExpr = { expr: string }; @@ -48,13 +48,17 @@ const remoteDrizzleInit: Record = { }; const drizzleDialectSet = new Set([...availableDrizzleDialects]); +const prismaDialectSet = new Set([...availablePrismaDialects]); type GenerateDBBlockProps = Pick< CreateConfiguration, - 'databaseEngine' | 'orm' | 'databaseHost' + 'databaseDirectory' | 'databaseEngine' | 'orm' | 'databaseHost' >; +const defaultDbDir = 'db'; + export const generateDBBlock = ({ + databaseDirectory = defaultDbDir, databaseEngine, orm, databaseHost @@ -69,43 +73,61 @@ export const generateDBBlock = ({ const engineGroup = connectionMap[databaseEngine]; if (!engineGroup) return ''; - if (orm !== 'drizzle') { + if (orm !== 'drizzle' && orm !== 'prisma') { const hostCfg = engineGroup[hostKey]; - return hostCfg ? `const db = ${hostCfg.expr}` : ''; + if (!hostCfg) return ''; + const expr = hostCfg.expr.replace('db/', `${databaseDirectory}/`); + + return `const db = ${expr}`; } - if (!drizzleDialectSet.has(databaseEngine)) return ''; + if (orm === 'drizzle') { + if (!drizzleDialectSet.has(databaseEngine)) return ''; - const expr = engineGroup[hostKey]?.expr ?? remoteDrizzleInit[hostKey]; - if (!expr) return ''; + let expr = engineGroup[hostKey]?.expr ?? remoteDrizzleInit[hostKey]; + if (!expr) return ''; + expr = expr.replace('db/', `${databaseDirectory}/`); - if ( - (databaseEngine === 'mysql' || databaseEngine === 'mariadb') && - databaseHost !== 'planetscale' - ) { - return ` + if ( + (databaseEngine === 'mysql' || databaseEngine === 'mariadb') && + databaseHost !== 'planetscale' + ) { + return ` const pool = createPool(getEnv("DATABASE_URL")) const db = drizzle(pool, { schema, mode: 'default' }) `; - } + } - if (databaseEngine === 'mssql' && hostKey === 'none') { - return ` + if (databaseEngine === 'mssql' && hostKey === 'none') { + return ` const pool = await connect(getEnv("DATABASE_URL")) const db = drizzle({ client: pool }, { schema }) `; - } + } - if (databaseEngine === 'postgresql' && databaseHost === 'neon') { - return ` - const sql = neon(getEnv('DATABASE_URL')); + if (databaseEngine === 'postgresql' && databaseHost === 'neon') { + return ` +const sql = neon(getEnv('DATABASE_URL')); const db = drizzle(sql, { schema }); `; - } + } + + const mysqlMode = + databaseHost === 'planetscale' ? 'planetscale' : 'default'; - return ` + return ` const pool = ${expr} -const db = drizzle(pool, { schema }) +const db = drizzle(pool, { schema, mode: '${mysqlMode}' }) `; + } + + if (orm === 'prisma') { + if (!prismaDialectSet.has(databaseEngine)) return ''; + + return `const prisma = (await import('../../${databaseDirectory}/client')).default +const db = prisma`; + } + + return ''; }; diff --git a/src/generators/project/generateImportsBlock.ts b/src/generators/project/generateImportsBlock.ts index c4d09e7..fa47cf4 100644 --- a/src/generators/project/generateImportsBlock.ts +++ b/src/generators/project/generateImportsBlock.ts @@ -12,9 +12,12 @@ type GenerateImportsBlockProps = { authOption: CreateConfiguration['authOption']; databaseEngine: CreateConfiguration['databaseEngine']; databaseHost: CreateConfiguration['databaseHost']; + databaseDirectory: CreateConfiguration['databaseDirectory']; frontendDirectories: CreateConfiguration['frontendDirectories']; }; +const defaultDbDir = 'db'; + export const generateImportsBlock = ({ backendDirectory, deps, @@ -23,6 +26,7 @@ export const generateImportsBlock = ({ authOption, databaseEngine, databaseHost, + databaseDirectory = defaultDbDir, frontendDirectories }: GenerateImportsBlockProps) => { const rawImports: string[] = []; @@ -111,15 +115,15 @@ export const generateImportsBlock = ({ databaseEngine !== undefined && databaseEngine !== 'none'; const noOrm = orm === undefined || orm === 'none'; + const schemaImport = `import { schema } from '../../${databaseDirectory}/schema'`; + const ormImports = { drizzle: [ `import { Elysia } from 'elysia'`, ...(databaseEngine === 'sqlite' && !isRemoteHost ? [] : [`import { getEnv } from '@absolutejs/absolute'`]), - ...(authOption === 'abs' - ? [`import { schema } from '../../db/schema'`] - : [`import { schema } from '../../db/schema'`]) + schemaImport ] } as const; @@ -237,6 +241,14 @@ export const generateImportsBlock = ({ rawImports.push(...ormImports[orm]); } + if (orm === 'prisma') { + rawImports.push(`import type { Elysia } from 'elysia'`); + rawImports.push(`import type { PrismaClient } from '@prisma/client'`); + if (authOption === 'abs') { + rawImports.push(`import type { User } from '@prisma/client'`); + } + } + if ( orm === 'drizzle' && isRemoteHost && @@ -280,41 +292,82 @@ export const generateImportsBlock = ({ rawImports.push(`import { vueImports } from './utils/vueImporter'`); } - const importMap = new Map< - string, - { defaultImport: string | null; namedImports: Set } - >(); + type ImportEntry = { + defaultImport: string | null; + typeOnlyNames: Set; + valueNames: Set; + }; + + const importMap = new Map(); for (const stmt of rawImports) { - const match = stmt.match(/^import\s+(.+)\s+from\s+['"](.+)['"];?/); - if (!match) continue; - const [, importClause, modulePath] = match; + const typeMatch = stmt.match( + /^import\s+type\s+\{([^}]+)\}\s+from\s+['"](.+)['"];?/ + ); + const valueMatch = stmt.match(/^import\s+(.+)\s+from\s+['"](.+)['"];?/); + if (typeMatch) { + const [, names, modulePath] = typeMatch; + if (!names || !modulePath) continue; + const entry = importMap.get(modulePath) ?? { + defaultImport: null, + typeOnlyNames: new Set(), + valueNames: new Set() + }; + importMap.set(modulePath, entry); + names + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + .forEach((name) => entry.typeOnlyNames.add(name)); + continue; + } + if (!valueMatch) continue; + const [, importClause, modulePath] = valueMatch; if (!importClause || !modulePath) continue; const entry = importMap.get(modulePath) ?? { defaultImport: null, - namedImports: new Set() + typeOnlyNames: new Set(), + valueNames: new Set() }; importMap.set(modulePath, entry); - void (importClause.startsWith('{') - ? importClause - .slice(1, -1) - .split(',') - .map((segment) => segment.trim()) - .filter(Boolean) - .forEach((name) => entry.namedImports.add(name)) - : (entry.defaultImport = importClause.trim())); + if (importClause.startsWith('{')) { + importClause + .slice(1, -1) + .split(',') + .map((segment) => segment.trim()) + .filter(Boolean) + .forEach((name) => entry.valueNames.add(name)); + } else { + entry.defaultImport = importClause.trim(); + } } return Array.from(importMap.entries()) .sort(([a], [b]) => a.localeCompare(b)) - .map(([path, { defaultImport, namedImports }]) => { + .map(([path, { defaultImport, typeOnlyNames, valueNames }]) => { + const typeOnlyNotValue = [...typeOnlyNames].filter( + (name) => !valueNames.has(name) + ); + const hasValues = valueNames.size > 0 || defaultImport !== null; + if (!hasValues && typeOnlyNotValue.length > 0) { + return `import type { ${typeOnlyNotValue.sort().join(', ')} } from '${path}'`; + } const parts: string[] = []; if (defaultImport) parts.push(defaultImport); - if (namedImports.size) - parts.push(`{ ${[...namedImports].sort().join(', ')} }`); - + if (valueNames.size > 0 || typeOnlyNotValue.length > 0) { + const sortedValues = [...valueNames].sort(); + const named = [ + ...typeOnlyNotValue.map((n) => `type ${n}`), + ...sortedValues + ].sort((a, b) => { + const aNorm = a.replace(/^type /, ''); + const bNorm = b.replace(/^type /, ''); + return aNorm.localeCompare(bNorm); + }); + parts.push(`{ ${named.join(', ')} }`); + } return `import ${parts.join(', ')} from '${path}'`; }) .join('\n'); diff --git a/src/generators/project/generateServer.ts b/src/generators/project/generateServer.ts index 7cf7cc7..70bb363 100644 --- a/src/generators/project/generateServer.ts +++ b/src/generators/project/generateServer.ts @@ -1,4 +1,4 @@ -import { writeFileSync } from 'fs'; +import { mkdirSync, writeFileSync } from 'fs'; import { join } from 'path'; import type { CreateConfiguration } from '../../types'; import { collectDependencies } from './collectDependencies'; @@ -19,6 +19,7 @@ type CreateServerFileProps = Pick< | 'orm' | 'assetsDirectory' | 'frontendDirectories' + | 'databaseDirectory' > & { backendDirectory: string; publicDirectory: string; @@ -31,6 +32,7 @@ export const generateServerFile = ({ buildDirectory, databaseEngine, databaseHost, + databaseDirectory, orm, assetsDirectory, frontendDirectories, @@ -45,6 +47,7 @@ export const generateServerFile = ({ const importsBlock = generateImportsBlock({ authOption, backendDirectory, + databaseDirectory, databaseEngine, databaseHost, deps, @@ -63,7 +66,12 @@ export const generateServerFile = ({ let dbBlock = ''; if (databaseEngine && databaseEngine !== 'none') { - dbBlock = generateDBBlock({ databaseEngine, databaseHost, orm }); + dbBlock = generateDBBlock({ + databaseDirectory, + databaseEngine, + databaseHost, + orm + }); } const useBlock = deps @@ -112,6 +120,22 @@ export const generateServerFile = ({ frontendDirectories }); + let lifecycleCleanup = ''; + if (orm === 'prisma' && databaseEngine && databaseEngine !== 'none') { + lifecycleCleanup = ` +// Graceful shutdown for Prisma +process.on('SIGINT', async () => { + await prisma.$disconnect() + process.exit(0) +}) + +process.on('SIGTERM', async () => { + await prisma.$disconnect() + process.exit(0) +}) +`; + } + const hmrBlock = ` if ( typeof result.hmrState !== 'string' && @@ -132,7 +156,10 @@ ${useBlock}${authOption === 'abs' ? `\n${guardBlock}` : ''} const { request } = err console.error(\`Server error on \${request.method} \${request.url}: \${err.message}\`) }); +${lifecycleCleanup} ${hmrBlock} `; + + mkdirSync(backendDirectory, { recursive: true }); writeFileSync(serverFilePath, content); }; diff --git a/src/generators/project/scaffoldBackend.ts b/src/generators/project/scaffoldBackend.ts index 0c87bfd..cb229c1 100644 --- a/src/generators/project/scaffoldBackend.ts +++ b/src/generators/project/scaffoldBackend.ts @@ -10,6 +10,7 @@ type ScaffoldBackendProps = Pick< | 'absProviders' | 'authOption' | 'buildDirectory' + | 'databaseDirectory' | 'databaseEngine' | 'databaseHost' | 'frontendDirectories' @@ -27,6 +28,7 @@ export const scaffoldBackend = ({ absProviders, backendDirectory, buildDirectory, + databaseDirectory, databaseEngine, databaseHost, frontendDirectories, @@ -40,6 +42,7 @@ export const scaffoldBackend = ({ authOption, backendDirectory, buildDirectory, + databaseDirectory, databaseEngine, databaseHost, frontendDirectories, diff --git a/src/scaffold.ts b/src/scaffold.ts index 4eb3555..bae84b5 100644 --- a/src/scaffold.ts +++ b/src/scaffold.ts @@ -61,6 +61,7 @@ export const scaffold = async ({ scaffoldConfigurationFiles({ codeQualityTool, + databaseDirectory, databaseEngine, databaseHost, envVariables, @@ -76,6 +77,7 @@ export const scaffold = async ({ codeQualityTool, databaseEngine, databaseHost, + databaseDirectory, frontendDirectories, latest, orm, @@ -90,6 +92,7 @@ export const scaffold = async ({ authOption, backendDirectory, buildDirectory, + databaseDirectory, databaseEngine, databaseHost, frontendDirectories, diff --git a/src/templates/svelte/composables/counter.svelte.ts b/src/templates/svelte/composables/counter.svelte.ts index 5b6de3c..cd99548 100644 --- a/src/templates/svelte/composables/counter.svelte.ts +++ b/src/templates/svelte/composables/counter.svelte.ts @@ -1,3 +1,4 @@ +// @ts-nocheck - Svelte 5 runes ($state) are processed at build time export const counter = (initialCount: number) => { let count = $state(initialCount); diff --git a/src/typeGuards.ts b/src/typeGuards.ts index 9eaa43c..ca1a58c 100644 --- a/src/typeGuards.ts +++ b/src/typeGuards.ts @@ -8,6 +8,7 @@ import { import type { AuthOption, AvailableDrizzleDialect, + AvailablePrismaDialect, CodeQualityTool, DatabaseEngine, DatabaseHost, @@ -38,7 +39,9 @@ export const isFrontend = (value: string | undefined): value is Frontend => value !== undefined && Object.keys(frontendLabels).includes(value); export const isORM = (value: string | undefined): value is ORM => value === 'drizzle' || value === 'prisma' || value === undefined; -export const isPrismaDialect = (value: string | undefined): value is string => +export const isPrismaDialect = ( + value: string | undefined +): value is AvailablePrismaDialect => availablePrismaDialects.some((dialect) => dialect === value); export const isValidAuthOption = ( value: string | undefined diff --git a/src/utils/checkGitInstalled.ts b/src/utils/checkGitInstalled.ts new file mode 100644 index 0000000..61d88f0 --- /dev/null +++ b/src/utils/checkGitInstalled.ts @@ -0,0 +1,11 @@ +import { $ } from 'bun'; + +export const checkGitInstalled = async () => { + try { + await $`git --version`.quiet(); + + return true; + } catch { + return false; + } +}; diff --git a/src/utils/toDockerProjectName.ts b/src/utils/toDockerProjectName.ts new file mode 100644 index 0000000..d62f98b --- /dev/null +++ b/src/utils/toDockerProjectName.ts @@ -0,0 +1,5 @@ +export const toDockerProjectName = (name: string): string => + name + .replace(/[^a-zA-Z0-9_-]/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') || 'db'; diff --git a/src/versions.ts b/src/versions.ts index 1c724d1..f4d3c04 100644 --- a/src/versions.ts +++ b/src/versions.ts @@ -29,6 +29,7 @@ export const versions = { '@typescript-eslint/parser': '8.56.0', autoprefixer: '10.4.24', /* ── ORM ──────────────────────────────────────────────── */ + 'drizzle-kit': '0.31.8', 'drizzle-orm': '0.45.1', elysia: '1.4.25', 'elysia-rate-limit': '4.5.0', diff --git a/tsconfig.json b/tsconfig.json index 18678d5..d05841d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -28,5 +28,5 @@ "src/templates/htmx/htmx.*.min.js", "src/templates/svelte/**/*.svelte.ts" ], - "include": ["src/**/*"] + "include": ["src/**/*", "tests/**/*", "test-scaffold/**/*"] }