Skip to content

Commit 5c38dec

Browse files
AINATIVEM-44 move docker-compose into database generator, add builder pattern docs
1 parent 33b9c38 commit 5c38dec

6 files changed

Lines changed: 105 additions & 87 deletions

File tree

CLAUDE.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,32 @@ cli.ts (collects prompts)
7070
- **New generator**: add `src/generators/node/<feature>.ts` + `.test.ts`, call from `nodeGenerator`
7171
- **Modify generated scaffold**: edit template strings in the corresponding generator file
7272

73+
## Builder Pattern
74+
75+
The scaffold generator uses `NodeProjectBuilder` + `BuildStep` (`src/generators/node/projectBuilder.ts`) to compose features without scattering conditional logic across generators.
76+
77+
**How it works:**
78+
- `BuildStep` interface: `execute(outputDir: string, options: GeneratorOptions): Promise<void>`
79+
- Each feature is a private class implementing `BuildStep` (e.g. `OAuthStep`, `DatabaseStep`)
80+
- `NodeProjectBuilder` queues steps and runs them in order via `.build()`
81+
- Named methods like `.addOAuth()`, `.addDatabase()` push steps unconditionally
82+
- `when(condition, fn)` adds steps conditionally at the call site in `index.ts` — never inside `execute()`
83+
84+
**Adding a new feature:**
85+
1. Create a private `BuildStep` class in `projectBuilder.ts`
86+
2. Add a named method to `NodeProjectBuilder`: `addMyFeature(): this { return this.addStep(new MyFeatureStep()); }`
87+
3. Call it in `index.ts`, via `when()` if conditional:
88+
```ts
89+
.when(options.webhooks, b => b.addWebhooks())
90+
```
91+
92+
**Rule: never put conditional logic inside `execute()`** — use `when()` at the call site. Steps must be unconditional internally; the builder chain controls what runs.
93+
94+
**File content helpers:**
95+
- Use `SourceFileBuilder` (`src/utils/sourceFileBuilder.ts`) for TypeScript files with conditional imports or blocks — handles deduplication and formatting automatically
96+
- Use `RouterMountBuilder` (`src/utils/templates.ts`) to accumulate `app.use()` calls conditionally
97+
- Use plain `dedent` for static content (YAML, JSON, `.env`, SQL)
98+
7399
## MVP Scope
74100

75101
The initial implementation targets:

src/generators/node/database.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,3 +184,30 @@ describe('generateDatabase — drizzle.config.ts', () => {
184184
expect(content).toContain('sqlite');
185185
});
186186
});
187+
188+
describe('generateDatabase — docker-compose.yml', () => {
189+
it('generates docker-compose.yml for postgres with healthcheck', async () => {
190+
const { generateDatabase } = await import('./database.js');
191+
await generateDatabase(tmpDir, pgOptions);
192+
expect(await exists(join(tmpDir, 'docker-compose.yml'))).toBe(true);
193+
const content = await read('docker-compose.yml');
194+
expect(content).toContain('postgres:16');
195+
expect(content).toContain('pg_isready');
196+
expect(content).toContain('healthcheck');
197+
});
198+
199+
it('generates docker-compose.yml for mysql with healthcheck', async () => {
200+
const { generateDatabase } = await import('./database.js');
201+
await generateDatabase(tmpDir, mysqlOptions);
202+
const content = await read('docker-compose.yml');
203+
expect(content).toContain('mysql:8');
204+
expect(content).toContain('mysqladmin');
205+
expect(content).toContain('healthcheck');
206+
});
207+
208+
it('does not generate docker-compose.yml for sqlite', async () => {
209+
const { generateDatabase } = await import('./database.js');
210+
await generateDatabase(tmpDir, sqliteOptions);
211+
expect(await exists(join(tmpDir, 'docker-compose.yml'))).toBe(false);
212+
});
213+
});

src/generators/node/database.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ export async function generateDatabase(outputDir: string, options: GeneratorOpti
99
await generateMigrate(outputDir, options);
1010
await generateMigrationSql(outputDir, options);
1111
await generateDrizzleConfig(outputDir, options);
12+
if (options.database === 'postgres' || options.database === 'mysql') {
13+
await generateDockerCompose(outputDir, options);
14+
}
1215
}
1316

1417
async function generateSchema(outputDir: string, options: GeneratorOptions): Promise<void> {
@@ -232,6 +235,55 @@ function migrationSqlContent(database: GeneratorOptions['database']): string {
232235
`;
233236
}
234237

238+
async function generateDockerCompose(outputDir: string, options: GeneratorOptions): Promise<void> {
239+
const content =
240+
options.database === 'postgres'
241+
? dedent`
242+
services:
243+
db:
244+
image: postgres:16
245+
environment:
246+
POSTGRES_USER: app
247+
POSTGRES_PASSWORD: app
248+
POSTGRES_DB: ${options.projectName}
249+
ports:
250+
- '5432:5432'
251+
volumes:
252+
- db_data:/var/lib/postgresql/data
253+
healthcheck:
254+
test: ['CMD', 'pg_isready', '-U', 'app']
255+
interval: 5s
256+
timeout: 5s
257+
retries: 5
258+
259+
volumes:
260+
db_data:
261+
`
262+
: dedent`
263+
services:
264+
db:
265+
image: mysql:8
266+
environment:
267+
MYSQL_ROOT_PASSWORD: app
268+
MYSQL_DATABASE: ${options.projectName}
269+
MYSQL_USER: app
270+
MYSQL_PASSWORD: app
271+
ports:
272+
- '3306:3306'
273+
volumes:
274+
- db_data:/var/lib/mysql
275+
healthcheck:
276+
test: ['CMD', 'mysqladmin', 'ping', '-h', 'localhost', '-u', 'app', '--password=app']
277+
interval: 5s
278+
timeout: 5s
279+
retries: 5
280+
281+
volumes:
282+
db_data:
283+
`;
284+
await writeFile(join(outputDir, 'docker-compose.yml'), content);
285+
}
286+
235287
async function generateDrizzleConfig(outputDir: string, options: GeneratorOptions): Promise<void> {
236288
const dialectMap: Record<GeneratorOptions['database'], string> = {
237289
postgres: 'postgresql',

src/generators/node/index.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,6 @@ export const nodeGenerator: Generator = {
88
.addDatabase()
99
.addApp()
1010
.when(options.webhooks, (b) => b.addWebhooks())
11-
.when(options.database === 'postgres', (b) => b.addPostgres())
12-
.when(options.database === 'mysql', (b) => b.addMySQL())
1311
.when(options.appExtensions.length > 0, (b) => b.addAppExtensions())
1412
.addPipedriveClient()
1513
.addServerEntry()

src/generators/node/projectBuilder.test.ts

Lines changed: 0 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -67,24 +67,3 @@ describe('NodeProjectBuilder', () => {
6767
});
6868
});
6969

70-
describe('PostgresDockerStep', () => {
71-
it('generates docker-compose.yml with healthcheck', async () => {
72-
await new NodeProjectBuilder(tmpDir, options).addPostgres().build();
73-
expect(await exists(join(tmpDir, 'docker-compose.yml'))).toBe(true);
74-
const content = await read('docker-compose.yml');
75-
expect(content).toContain('postgres:16');
76-
expect(content).toContain('healthcheck');
77-
expect(content).toContain('pg_isready');
78-
});
79-
});
80-
81-
describe('MySQLDockerStep', () => {
82-
it('generates docker-compose.yml with healthcheck', async () => {
83-
const mysqlOptions: GeneratorOptions = { ...options, database: 'mysql' };
84-
await new NodeProjectBuilder(tmpDir, mysqlOptions).addMySQL().build();
85-
const content = await read('docker-compose.yml');
86-
expect(content).toContain('mysql:8');
87-
expect(content).toContain('healthcheck');
88-
expect(content).toContain('mysqladmin');
89-
});
90-
});

src/generators/node/projectBuilder.ts

Lines changed: 0 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -146,64 +146,6 @@ class EnvExampleStep implements BuildStep {
146146
}
147147
}
148148

149-
class PostgresDockerStep implements BuildStep {
150-
async execute(outputDir: string, options: GeneratorOptions): Promise<void> {
151-
await writeFile(
152-
join(outputDir, 'docker-compose.yml'),
153-
dedent`
154-
services:
155-
db:
156-
image: postgres:16
157-
environment:
158-
POSTGRES_USER: app
159-
POSTGRES_PASSWORD: app
160-
POSTGRES_DB: ${options.projectName}
161-
ports:
162-
- '5432:5432'
163-
volumes:
164-
- db_data:/var/lib/postgresql/data
165-
healthcheck:
166-
test: ['CMD', 'pg_isready', '-U', 'app']
167-
interval: 5s
168-
timeout: 5s
169-
retries: 5
170-
171-
volumes:
172-
db_data:
173-
`,
174-
);
175-
}
176-
}
177-
178-
class MySQLDockerStep implements BuildStep {
179-
async execute(outputDir: string, options: GeneratorOptions): Promise<void> {
180-
await writeFile(
181-
join(outputDir, 'docker-compose.yml'),
182-
dedent`
183-
services:
184-
db:
185-
image: mysql:8
186-
environment:
187-
MYSQL_ROOT_PASSWORD: app
188-
MYSQL_DATABASE: ${options.projectName}
189-
MYSQL_USER: app
190-
MYSQL_PASSWORD: app
191-
ports:
192-
- '3306:3306'
193-
volumes:
194-
- db_data:/var/lib/mysql
195-
healthcheck:
196-
test: ['CMD', 'mysqladmin', 'ping', '-h', 'localhost', '-u', 'app', '--password=app']
197-
interval: 5s
198-
timeout: 5s
199-
retries: 5
200-
201-
volumes:
202-
db_data:
203-
`,
204-
);
205-
}
206-
}
207149

208150
export class NodeProjectBuilder {
209151
private steps: BuildStep[] = [];
@@ -230,12 +172,6 @@ export class NodeProjectBuilder {
230172
addWebhooks(): this {
231173
return this.addStep(new WebhooksStep());
232174
}
233-
addPostgres(): this {
234-
return this.addStep(new PostgresDockerStep());
235-
}
236-
addMySQL(): this {
237-
return this.addStep(new MySQLDockerStep());
238-
}
239175
addAppExtensions(): this {
240176
return this.addStep(new AppExtensionsStep());
241177
}

0 commit comments

Comments
 (0)