diff --git a/src/generators/node/database.test.ts b/src/generators/node/database.test.ts index c87d2c9..04572ef 100644 --- a/src/generators/node/database.test.ts +++ b/src/generators/node/database.test.ts @@ -72,6 +72,9 @@ describe('generateDatabase — src/database/index.ts', () => { const content = await read('src/database/index.ts'); expect(content).toContain('postgres'); expect(content).toContain('drizzle-orm/postgres-js'); + expect(content).toContain('onnotice'); + expect(content).toContain("'42P06'"); + expect(content).toContain("'42P07'"); expect(content).toContain('export'); }); @@ -81,6 +84,7 @@ describe('generateDatabase — src/database/index.ts', () => { const content = await read('src/database/index.ts'); expect(content).toContain('mysql2'); expect(content).toContain('drizzle-orm/mysql2'); + expect(content).toContain("mode: 'default'"); }); it('sqlite client uses @libsql/client', async () => { @@ -218,6 +222,7 @@ describe('generateDatabase — docker-compose.yml', () => { expect(await exists(join(tmpDir, 'docker-compose.yml'))).toBe(true); const content = await read('docker-compose.yml'); expect(content).toContain('postgres:16'); + expect(content).toContain('postgres_data:/var/lib/postgresql/data'); expect(content).toContain('pg_isready'); expect(content).toContain('healthcheck'); }); @@ -227,6 +232,9 @@ describe('generateDatabase — docker-compose.yml', () => { await generateDatabase(tmpDir, mysqlOptions); const content = await read('docker-compose.yml'); expect(content).toContain('mysql:8'); + expect(content).toContain('127.0.0.1:3307:3306'); + expect(content).not.toContain('3306:3306'); + expect(content).toContain('mysql_data:/var/lib/mysql'); expect(content).toContain('mysqladmin'); expect(content).toContain('healthcheck'); }); diff --git a/src/generators/node/database.ts b/src/generators/node/database.ts index 30211a6..00d06c1 100644 --- a/src/generators/node/database.ts +++ b/src/generators/node/database.ts @@ -110,7 +110,12 @@ function dbClientContent(database: GeneratorOptions['database']): string { import postgres from 'postgres'; import * as schema from './schema.js'; - const client = postgres(process.env.DATABASE_URL!); + const client = postgres(process.env.DATABASE_URL!, { + onnotice: (notice) => { + if (notice.code === '42P06' || notice.code === '42P07') return; + console.warn(notice); + }, + }); export const db = drizzle(client, { schema }); `; } @@ -122,7 +127,7 @@ function dbClientContent(database: GeneratorOptions['database']): string { import * as schema from './schema.js'; const pool = mysql.createPool(process.env.DATABASE_URL!); - export const db = drizzle(pool, { schema }); + export const db = drizzle(pool, { schema, mode: 'default' }); `; } @@ -264,7 +269,7 @@ async function generateDockerCompose(outputDir: string, options: GeneratorOption ports: - '5432:5432' volumes: - - db_data:/var/lib/postgresql/data + - postgres_data:/var/lib/postgresql/data healthcheck: test: ['CMD', 'pg_isready', '-U', 'app'] interval: 5s @@ -272,7 +277,7 @@ async function generateDockerCompose(outputDir: string, options: GeneratorOption retries: 5 volumes: - db_data: + postgres_data: ` : dedent` services: @@ -284,9 +289,9 @@ async function generateDockerCompose(outputDir: string, options: GeneratorOption MYSQL_USER: app MYSQL_PASSWORD: app ports: - - '3306:3306' + - '127.0.0.1:3307:3306' volumes: - - db_data:/var/lib/mysql + - mysql_data:/var/lib/mysql healthcheck: test: ['CMD', 'mysqladmin', 'ping', '-h', 'localhost', '-u', 'app', '--password=app'] interval: 5s @@ -294,7 +299,7 @@ async function generateDockerCompose(outputDir: string, options: GeneratorOption retries: 5 volumes: - db_data: + mysql_data: `; await writeFile(join(outputDir, 'docker-compose.yml'), content); } diff --git a/src/generators/node/projectBuilder.test.ts b/src/generators/node/projectBuilder.test.ts index 49774e4..789af23 100644 --- a/src/generators/node/projectBuilder.test.ts +++ b/src/generators/node/projectBuilder.test.ts @@ -1,4 +1,7 @@ import { describe, expect, it } from 'vitest'; +import { readFile, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; import type { GeneratorOptions } from '../interface.js'; import { NodeProjectBuilder } from './projectBuilder.js'; import type { BuildStep } from './projectBuilder.js'; @@ -10,6 +13,9 @@ const options: GeneratorOptions = { appExtensions: [], }; +const tmpDir = join(tmpdir(), 'cpa-project-builder-test'); +const read = (p: string) => readFile(join(tmpDir, p), 'utf-8'); + function spyStep(tracker: string[], label: string): BuildStep { return { execute: async () => { @@ -50,4 +56,31 @@ describe('NodeProjectBuilder', () => { expect(builder.addStep(spyStep([], 'x'))).toBe(builder); expect(builder.when(false, () => {})).toBe(builder); }); + + it('generates MySQL env example with the non-default host port', async () => { + await rm(tmpDir, { recursive: true, force: true }); + + await new NodeProjectBuilder(tmpDir, { ...options, database: 'mysql' }).addEnvExample().build(); + + const content = await read('.env.example'); + expect(content).toContain('DATABASE_URL=mysql://app:app@localhost:3307/test-app'); + expect(content).not.toContain('DATABASE_URL=mysql://app:app@localhost:3306/test-app'); + + await rm(tmpDir, { recursive: true, force: true }); + }); + + it('generates server entry that retries database startup', async () => { + await rm(tmpDir, { recursive: true, force: true }); + + await new NodeProjectBuilder(tmpDir, options).addServerEntry().build(); + + const content = await read('src/index.ts'); + expect(content).toContain('STARTUP_RETRY_ATTEMPTS = 60'); + expect(content).toContain('STARTUP_RETRY_DELAY_MS = 1000'); + expect(content).toContain('async function waitForDatabase()'); + expect(content).toContain('await waitForDatabase()'); + expect(content).toContain('Database is not ready yet'); + + await rm(tmpDir, { recursive: true, force: true }); + }); }); diff --git a/src/generators/node/projectBuilder.ts b/src/generators/node/projectBuilder.ts index 6ba8ef0..53fbd70 100644 --- a/src/generators/node/projectBuilder.ts +++ b/src/generators/node/projectBuilder.ts @@ -59,8 +59,27 @@ class ServerEntryStep implements BuildStep { import app from './app.js'; const PORT = ${envVarAccess('PORT', '3000')}; - - await runMigrations(); + const STARTUP_RETRY_ATTEMPTS = 60; + const STARTUP_RETRY_DELAY_MS = 1000; + + async function waitForDatabase(): Promise { + for (let attempt = 1; attempt <= STARTUP_RETRY_ATTEMPTS; attempt++) { + try { + await runMigrations(); + return; + } catch (error) { + if (attempt === STARTUP_RETRY_ATTEMPTS) throw error; + + const message = error instanceof Error ? error.message : String(error); + console.warn( + \`Database is not ready yet (\${attempt}/\${STARTUP_RETRY_ATTEMPTS}): \${message}\`, + ); + await new Promise((resolve) => setTimeout(resolve, STARTUP_RETRY_DELAY_MS)); + } + } + } + + await waitForDatabase(); app.listen(PORT, () => { console.log(\`Server running on port \${PORT}\`); }); @@ -88,7 +107,7 @@ class PackageJsonStep implements BuildStep { version: '0.1.0', type: 'module', scripts: { - 'dev': 'tsx src/index.ts', + 'dev': 'tsx watch --env-file=.env src/index.ts', 'build': 'tsc', 'typecheck': 'tsc --noEmit', 'db:migrate': 'drizzle-kit migrate', @@ -132,14 +151,19 @@ class TsConfigStep implements BuildStep { } class EnvExampleStep implements BuildStep { - async execute(outputDir: string, _options: GeneratorOptions): Promise { + async execute(outputDir: string, options: GeneratorOptions): Promise { + const databaseUrlExample: Record = { + postgres: `postgresql://app:app@localhost:5432/${options.projectName}`, + mysql: `mysql://app:app@localhost:3307/${options.projectName}`, + sqlite: 'file:./data.db', + }; await writeFile( join(outputDir, '.env.example'), dedent` PIPEDRIVE_CLIENT_ID= PIPEDRIVE_CLIENT_SECRET= PIPEDRIVE_REDIRECT_URI=http://localhost:3000/oauth/callback - DATABASE_URL= + DATABASE_URL=${databaseUrlExample[options.database]} PORT=3000 `, );