diff --git a/docs/commands/functions.md b/docs/commands/functions.md index 11f5da7b1ba..9a560a54e0f 100644 --- a/docs/commands/functions.md +++ b/docs/commands/functions.md @@ -79,6 +79,7 @@ netlify functions:create - `language` (*string*) - function language - `name` (*string*) - function name - `offline` (*boolean*) - Disables any features that require network access +- `template` (*string*) - bundled template to use (skips the interactive template picker) - `url` (*string*) - pull template from URL - `debug` (*boolean*) - Print debugging information - `auth` (*string*) - Netlify auth token - can be used to run this command without logging in @@ -89,6 +90,7 @@ netlify functions:create netlify functions:create netlify functions:create hello-world netlify functions:create --name hello-world +netlify functions:create --language typescript --template hello-world ``` --- diff --git a/functions-templates/typescript/database-drizzle/.netlify-function-template.mjs b/functions-templates/typescript/database-drizzle/.netlify-function-template.mjs new file mode 100644 index 00000000000..962163c7c1a --- /dev/null +++ b/functions-templates/typescript/database-drizzle/.netlify-function-template.mjs @@ -0,0 +1,5 @@ +export default { + name: 'database-drizzle', + description: 'Query a Netlify Database table with Drizzle ORM', + functionType: 'serverless', +} diff --git a/functions-templates/typescript/database-drizzle/package-lock.json b/functions-templates/typescript/database-drizzle/package-lock.json new file mode 100644 index 00000000000..64a2366d52d --- /dev/null +++ b/functions-templates/typescript/database-drizzle/package-lock.json @@ -0,0 +1,520 @@ +{ + "name": "{{name}}", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "{{name}}", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@netlify/database": "latest", + "@netlify/functions": "^5.2.0", + "@types/node": "^22.0.0", + "drizzle-orm": "beta", + "typescript": "^4.5.5" + } + }, + "node_modules/@neondatabase/serverless": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@neondatabase/serverless/-/serverless-1.1.0.tgz", + "integrity": "sha512-r3ZZhRjEcfEdKIZnoB1RusNgvHuaBRqfCzV4Gi+5A9yUX0S4HTws/ASWqt13wL4y4I+0rqsWGdA2w7EQXHi3+Q==", + "license": "MIT", + "engines": { + "node": ">=19.0.0" + } + }, + "node_modules/@netlify/database": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@netlify/database/-/database-0.7.0.tgz", + "integrity": "sha512-oCJVGauiLIMuxPSF4QZV+F3weU3GnclCRQcqU0lSrNqXmxJXiIehomaXHVzXGOZ7QJhVb/RRc2K1n9qIP3EwJg==", + "license": "MIT", + "dependencies": { + "@neondatabase/serverless": "^1.1.0", + "@netlify/runtime-utils": "2.3.0", + "pg": "^8.13.0", + "waddler": "^0.1.1", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20.6.1" + } + }, + "node_modules/@netlify/functions": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@netlify/functions/-/functions-5.2.0.tgz", + "integrity": "sha512-Pj93qeQd1tkQ5xm9gWJZmBf/1riLYqYHc0OzFukrJomrj82Ott53Rr/Q88H1ms5cF+P5QXRKWmA2JSxSybKfjA==", + "license": "MIT", + "dependencies": { + "@netlify/types": "2.6.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@netlify/runtime-utils": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@netlify/runtime-utils/-/runtime-utils-2.3.0.tgz", + "integrity": "sha512-cW8weDvsKV7zfia2m5EcBy6KILGoPD+eYZ3qWNGnIo05DGF28goPES0xKSDkNYgAF/2rRSIhie2qcBhbGVgSRg==", + "license": "MIT", + "engines": { + "node": "^18.14.0 || >=20" + } + }, + "node_modules/@netlify/types": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@netlify/types/-/types-2.6.0.tgz", + "integrity": "sha512-yD20EizHJDQxajJ66Vo8RTwLwR2jMNVxufPG8MHd2AScX8jW4z0VPnnJHArq2GYPFTFZRHmiAhDrXr5m8zof6w==", + "license": "MIT", + "engines": { + "node": "^18.14.0 || >=20" + } + }, + "node_modules/@types/node": { + "version": "22.19.17", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", + "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/drizzle-orm": { + "version": "1.0.0-beta.22", + "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-1.0.0-beta.22.tgz", + "integrity": "sha512-F+DZyVIvH0oVKa/w08Cle1xfoH+pc+htIXHG/frnMLG72aby9NYYr9oc+9XvghnoO4umxFItduz0OMmQJMnenw==", + "license": "Apache-2.0", + "peerDependencies": { + "@aws-sdk/client-rds-data": ">=3", + "@cloudflare/workers-types": ">=4", + "@effect/sql": "^0.48.5", + "@effect/sql-pg": "^0.49.7", + "@electric-sql/pglite": ">=0.2.0", + "@libsql/client": ">=0.10.0", + "@libsql/client-wasm": ">=0.10.0", + "@neondatabase/serverless": ">=0.10.0", + "@op-engineering/op-sqlite": ">=2", + "@opentelemetry/api": "^1.4.1", + "@planetscale/database": ">=1.13", + "@sinclair/typebox": ">=0.34.8", + "@sqlitecloud/drivers": ">=1.0.653", + "@tidbcloud/serverless": "*", + "@tursodatabase/database": ">=0.2.1", + "@tursodatabase/database-common": ">=0.2.1", + "@tursodatabase/database-wasm": ">=0.2.1", + "@types/better-sqlite3": "*", + "@types/mssql": "^9.1.4", + "@types/pg": "*", + "@types/sql.js": "*", + "@upstash/redis": ">=1.34.7", + "@vercel/postgres": ">=0.8.0", + "@xata.io/client": "*", + "arktype": ">=2.0.0", + "better-sqlite3": ">=9.3.0", + "bun-types": "*", + "expo-sqlite": ">=14.0.0", + "gel": ">=2", + "mssql": "^11.0.1", + "mysql2": ">=2", + "pg": ">=8", + "postgres": ">=3", + "sql.js": ">=1", + "sqlite3": ">=5", + "typebox": ">=1.0.0", + "valibot": ">=1.0.0-beta.7", + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "@aws-sdk/client-rds-data": { + "optional": true + }, + "@cloudflare/workers-types": { + "optional": true + }, + "@effect/sql": { + "optional": true + }, + "@effect/sql-pg": { + "optional": true + }, + "@electric-sql/pglite": { + "optional": true + }, + "@libsql/client": { + "optional": true + }, + "@libsql/client-wasm": { + "optional": true + }, + "@neondatabase/serverless": { + "optional": true + }, + "@op-engineering/op-sqlite": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@planetscale/database": { + "optional": true + }, + "@sinclair/typebox": { + "optional": true + }, + "@sqlitecloud/drivers": { + "optional": true + }, + "@tidbcloud/serverless": { + "optional": true + }, + "@tursodatabase/database": { + "optional": true + }, + "@tursodatabase/database-common": { + "optional": true + }, + "@tursodatabase/database-wasm": { + "optional": true + }, + "@types/better-sqlite3": { + "optional": true + }, + "@types/mssql": { + "optional": true + }, + "@types/pg": { + "optional": true + }, + "@types/sql.js": { + "optional": true + }, + "@upstash/redis": { + "optional": true + }, + "@vercel/postgres": { + "optional": true + }, + "@xata.io/client": { + "optional": true + }, + "arktype": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "bun-types": { + "optional": true + }, + "expo-sqlite": { + "optional": true + }, + "gel": { + "optional": true + }, + "mssql": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "pg": { + "optional": true + }, + "postgres": { + "optional": true + }, + "sql.js": { + "optional": true + }, + "sqlite3": { + "optional": true + }, + "typebox": { + "optional": true + }, + "valibot": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.12.0", + "pg-pool": "^3.13.0", + "pg-protocol": "^1.13.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.3.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", + "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz", + "integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz", + "integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz", + "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/typescript": { + "version": "4.9.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", + "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=4.2.0" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/waddler": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/waddler/-/waddler-0.1.1.tgz", + "integrity": "sha512-lBJXYFBLEpYe+scAeCJmLj6Iqweuq1whM6Am3I9WfopOCFxvKz8Nq5hXoy8/b3zwJqHIQMglFIvM4skRydSpZg==", + "license": "MIT", + "peerDependencies": { + "@clickhouse/client": "^1.11.2", + "@duckdb/node-api": "^1.1.2-alpha.4", + "@electric-sql/pglite": "^0.2.17", + "@libsql/client": "^0.15.4", + "@libsql/client-wasm": "^0.15.4", + "@neondatabase/serverless": "^1.0.0", + "@planetscale/database": "^1.19.0", + "@tidbcloud/serverless": "^0.2.0", + "@vercel/postgres": "^0.10.0", + "@xata.io/client": "^0.30.1", + "better-sqlite3": "^11.9.1", + "bun-types": "*", + "duckdb": "^1.2.1", + "gel": "^2.0.2", + "mysql2": "^3.14.0", + "pg": "^8.14.0", + "pg-query-stream": "^4.8.0", + "postgres": "^3.4.5" + }, + "peerDependenciesMeta": { + "@clickhouse/client": { + "optional": true + }, + "@cloudflare/workers-types": { + "optional": true + }, + "@duckdb/node-api": { + "optional": true + }, + "@electric-sql/pglite": { + "optional": true + }, + "@libsql/client": { + "optional": true + }, + "@libsql/client-wasm": { + "optional": true + }, + "@neondatabase/serverless": { + "optional": true + }, + "@op-engineering/op-sqlite": { + "optional": true + }, + "@planetscale/database": { + "optional": true + }, + "@tidbcloud/serverless": { + "optional": true + }, + "@vercel/postgres": { + "optional": true + }, + "@xata.io/client": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "bun-types": { + "optional": true + }, + "duckdb": { + "optional": true + }, + "gel": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "pg": { + "optional": true + }, + "pg-query-stream": { + "optional": true + }, + "postgres": { + "optional": true + } + } + }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + } + } +} diff --git a/functions-templates/typescript/database-drizzle/package.json b/functions-templates/typescript/database-drizzle/package.json new file mode 100644 index 00000000000..98541d95f67 --- /dev/null +++ b/functions-templates/typescript/database-drizzle/package.json @@ -0,0 +1,25 @@ +{ + "name": "{{name}}", + "version": "1.0.0", + "description": "netlify functions:create - query Netlify Database with Drizzle ORM", + "main": "{{name}}.mts", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [ + "netlify", + "serverless", + "typescript", + "database", + "drizzle" + ], + "author": "Netlify", + "license": "MIT", + "dependencies": { + "@netlify/database": "latest", + "@netlify/functions": "^5.2.0", + "@types/node": "^22.0.0", + "drizzle-orm": "beta", + "typescript": "^4.5.5" + } +} diff --git a/functions-templates/typescript/database-drizzle/{{name}}.mts b/functions-templates/typescript/database-drizzle/{{name}}.mts new file mode 100644 index 00000000000..e9358bd60b1 --- /dev/null +++ b/functions-templates/typescript/database-drizzle/{{name}}.mts @@ -0,0 +1,26 @@ +import { drizzle } from 'drizzle-orm/netlify-db' + +import { planets } from '../../db/schema' + +export default async () => { + try { + const db = drizzle() + const rows = await db.select().from(planets) + + return Response.json({ planets: rows }) + } catch (error) { + const details = error instanceof Error ? error.message : String(error) + + return Response.json( + { + error: "Couldn't query the database. If you haven't set up the schema yet, run `netlify database init`.", + details, + }, + { status: 500 }, + ) + } +} + +export const config = { + path: '/planets', +} diff --git a/functions-templates/typescript/database/.netlify-function-template.mjs b/functions-templates/typescript/database/.netlify-function-template.mjs new file mode 100644 index 00000000000..63304a66dde --- /dev/null +++ b/functions-templates/typescript/database/.netlify-function-template.mjs @@ -0,0 +1,5 @@ +export default { + name: 'database', + description: 'Query a Netlify Database table with raw SQL via `db.sql`', + functionType: 'serverless', +} diff --git a/functions-templates/typescript/database/package-lock.json b/functions-templates/typescript/database/package-lock.json new file mode 100644 index 00000000000..cfaee629d28 --- /dev/null +++ b/functions-templates/typescript/database/package-lock.json @@ -0,0 +1,357 @@ +{ + "name": "{{name}}", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "{{name}}", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@netlify/database": "latest", + "@netlify/functions": "^5.2.0", + "@types/node": "^22.0.0", + "typescript": "^4.5.5" + } + }, + "node_modules/@neondatabase/serverless": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@neondatabase/serverless/-/serverless-1.1.0.tgz", + "integrity": "sha512-r3ZZhRjEcfEdKIZnoB1RusNgvHuaBRqfCzV4Gi+5A9yUX0S4HTws/ASWqt13wL4y4I+0rqsWGdA2w7EQXHi3+Q==", + "license": "MIT", + "engines": { + "node": ">=19.0.0" + } + }, + "node_modules/@netlify/database": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@netlify/database/-/database-0.7.0.tgz", + "integrity": "sha512-oCJVGauiLIMuxPSF4QZV+F3weU3GnclCRQcqU0lSrNqXmxJXiIehomaXHVzXGOZ7QJhVb/RRc2K1n9qIP3EwJg==", + "license": "MIT", + "dependencies": { + "@neondatabase/serverless": "^1.1.0", + "@netlify/runtime-utils": "2.3.0", + "pg": "^8.13.0", + "waddler": "^0.1.1", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20.6.1" + } + }, + "node_modules/@netlify/functions": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@netlify/functions/-/functions-5.2.0.tgz", + "integrity": "sha512-Pj93qeQd1tkQ5xm9gWJZmBf/1riLYqYHc0OzFukrJomrj82Ott53Rr/Q88H1ms5cF+P5QXRKWmA2JSxSybKfjA==", + "license": "MIT", + "dependencies": { + "@netlify/types": "2.6.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@netlify/runtime-utils": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@netlify/runtime-utils/-/runtime-utils-2.3.0.tgz", + "integrity": "sha512-cW8weDvsKV7zfia2m5EcBy6KILGoPD+eYZ3qWNGnIo05DGF28goPES0xKSDkNYgAF/2rRSIhie2qcBhbGVgSRg==", + "license": "MIT", + "engines": { + "node": "^18.14.0 || >=20" + } + }, + "node_modules/@netlify/types": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@netlify/types/-/types-2.6.0.tgz", + "integrity": "sha512-yD20EizHJDQxajJ66Vo8RTwLwR2jMNVxufPG8MHd2AScX8jW4z0VPnnJHArq2GYPFTFZRHmiAhDrXr5m8zof6w==", + "license": "MIT", + "engines": { + "node": "^18.14.0 || >=20" + } + }, + "node_modules/@types/node": { + "version": "22.19.17", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", + "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.12.0", + "pg-pool": "^3.13.0", + "pg-protocol": "^1.13.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.3.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", + "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz", + "integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz", + "integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz", + "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/typescript": { + "version": "4.9.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", + "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=4.2.0" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/waddler": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/waddler/-/waddler-0.1.1.tgz", + "integrity": "sha512-lBJXYFBLEpYe+scAeCJmLj6Iqweuq1whM6Am3I9WfopOCFxvKz8Nq5hXoy8/b3zwJqHIQMglFIvM4skRydSpZg==", + "license": "MIT", + "peerDependencies": { + "@clickhouse/client": "^1.11.2", + "@duckdb/node-api": "^1.1.2-alpha.4", + "@electric-sql/pglite": "^0.2.17", + "@libsql/client": "^0.15.4", + "@libsql/client-wasm": "^0.15.4", + "@neondatabase/serverless": "^1.0.0", + "@planetscale/database": "^1.19.0", + "@tidbcloud/serverless": "^0.2.0", + "@vercel/postgres": "^0.10.0", + "@xata.io/client": "^0.30.1", + "better-sqlite3": "^11.9.1", + "bun-types": "*", + "duckdb": "^1.2.1", + "gel": "^2.0.2", + "mysql2": "^3.14.0", + "pg": "^8.14.0", + "pg-query-stream": "^4.8.0", + "postgres": "^3.4.5" + }, + "peerDependenciesMeta": { + "@clickhouse/client": { + "optional": true + }, + "@cloudflare/workers-types": { + "optional": true + }, + "@duckdb/node-api": { + "optional": true + }, + "@electric-sql/pglite": { + "optional": true + }, + "@libsql/client": { + "optional": true + }, + "@libsql/client-wasm": { + "optional": true + }, + "@neondatabase/serverless": { + "optional": true + }, + "@op-engineering/op-sqlite": { + "optional": true + }, + "@planetscale/database": { + "optional": true + }, + "@tidbcloud/serverless": { + "optional": true + }, + "@vercel/postgres": { + "optional": true + }, + "@xata.io/client": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "bun-types": { + "optional": true + }, + "duckdb": { + "optional": true + }, + "gel": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "pg": { + "optional": true + }, + "pg-query-stream": { + "optional": true + }, + "postgres": { + "optional": true + } + } + }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + } + } +} diff --git a/functions-templates/typescript/database/package.json b/functions-templates/typescript/database/package.json new file mode 100644 index 00000000000..9ddd3d70681 --- /dev/null +++ b/functions-templates/typescript/database/package.json @@ -0,0 +1,23 @@ +{ + "name": "{{name}}", + "version": "1.0.0", + "description": "netlify functions:create - query Netlify Database with raw SQL", + "main": "{{name}}.mts", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [ + "netlify", + "serverless", + "typescript", + "database" + ], + "author": "Netlify", + "license": "MIT", + "dependencies": { + "@netlify/database": "latest", + "@netlify/functions": "^5.2.0", + "@types/node": "^22.0.0", + "typescript": "^4.5.5" + } +} diff --git a/functions-templates/typescript/database/{{name}}.mts b/functions-templates/typescript/database/{{name}}.mts new file mode 100644 index 00000000000..15e42d2f61a --- /dev/null +++ b/functions-templates/typescript/database/{{name}}.mts @@ -0,0 +1,24 @@ +import { getDatabase } from '@netlify/database' + +export default async () => { + try { + const db = getDatabase() + const planets = await db.sql`SELECT * FROM planets ORDER BY mass_kg` + + return Response.json({ planets }) + } catch (error) { + const details = error instanceof Error ? error.message : String(error) + + return Response.json( + { + error: "Couldn't query the database. If you haven't set up the schema yet, run `netlify database init`.", + details, + }, + { status: 500 }, + ) + } +} + +export const config = { + path: '/planets', +} diff --git a/package-lock.json b/package-lock.json index 79f0c3c98dd..733a791d6ac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -170,6 +170,7 @@ "serialize-javascript": "7.0.5", "strip-ansi": "7.1.2", "temp-dir": "3.0.0", + "tmp-promise": "^3.0.3", "tree-kill": "1.2.2", "tsx": "4.20.6", "typescript": "5.8.3", @@ -18187,6 +18188,8 @@ }, "node_modules/tmp-promise": { "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tmp-promise/-/tmp-promise-3.0.3.tgz", + "integrity": "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==", "license": "MIT", "dependencies": { "tmp": "^0.2.0" diff --git a/package.json b/package.json index 4a2cd94b16b..f8d02eff60b 100644 --- a/package.json +++ b/package.json @@ -211,6 +211,7 @@ "serialize-javascript": "7.0.5", "strip-ansi": "7.1.2", "temp-dir": "3.0.0", + "tmp-promise": "^3.0.3", "tree-kill": "1.2.2", "tsx": "4.20.6", "typescript": "5.8.3", diff --git a/src/commands/database/database.ts b/src/commands/database/database.ts index 67ef3961e4f..01004450fd9 100644 --- a/src/commands/database/database.ts +++ b/src/commands/database/database.ts @@ -106,27 +106,13 @@ export const createDatabaseCommand = (program: BaseCommand) => { dbCommand .command('init') - .description('Deprecated: databases are auto-provisioned via `@netlify/database`') - .action(async (_options: Record, _command: BaseCommand) => { - const { log, chalk } = await import('../../utils/command-helpers.js') - - log() - log( - chalk.yellow( - '`netlify db init` is no longer available. Databases are now provisioned automatically when @netlify/database is detected in your project.', - ), - ) - log() - log('To get started, run:') - log(` ${chalk.cyan('npm install @netlify/database')}`) - log() - log( - `If you have an existing database from the Netlify DB extension, visit ${chalk.cyan( - 'https://ntl.fyi/db-migration', - )} for migration instructions.`, - ) - log() + .description('Interactive setup: install the package, scaffold a starter migration, and verify the database') + .option('-y, --yes', 'Non-interactive mode. Accepts the defaults for every prompt.', false) + .action(async (options: { yes?: boolean }, command: BaseCommand) => { + const { initDatabase } = await import('./db-init.js') + await initDatabase(options, command) }) + .addExamples(['netlify database init', 'netlify database init --yes']) dbCommand .command('connect') diff --git a/src/commands/database/db-init.ts b/src/commands/database/db-init.ts new file mode 100644 index 00000000000..9bee328153f --- /dev/null +++ b/src/commands/database/db-init.ts @@ -0,0 +1,362 @@ +import { mkdir, readdir, writeFile } from 'fs/promises' +import { join } from 'path' + +import { applyMigrations } from '@netlify/dev' +import inquirer from 'inquirer' + +import { chalk, log, netlifyCommand } from '../../utils/command-helpers.js' +import { startSpinner, stopSpinner } from '../../lib/spinner.js' +import { isInteractive } from '../../utils/scripted-commands.js' +import BaseCommand from '../base-command.js' +import { generateNextPrefix } from './db-migration-new.js' +import { carefullyWriteFile, spawnAsync } from './legacy/utils.js' +import { connectRawClient, describeError } from './util/db-connection.js' +import { + DRIZZLE_SCHEMA_TS, + DRIZZLE_SEED_SQL, + drizzleConfigTs, + SEED_MIGRATION_NAME, + STARTER_MIGRATION_NAME, + STARTER_MIGRATION_SQL, + STARTER_TABLE, +} from './util/init-data.js' +import { resolveMigrationsDirectory } from './util/migrations-path.js' +import { hasDependency } from './util/package-json.js' +import { getPackageManager, installPackages, type PackageEntry, type PmInfo } from './util/packages.js' +import { relativeToProject } from './util/paths.js' +import { PgClientExecutor } from './util/pg-client-executor.js' +import { formatQueryResult } from './util/psql-formatter.js' + +export interface DatabaseInitOptions { + yes?: boolean +} + +const NETLIFY_DATABASE_PACKAGE = '@netlify/database' +const DRIZZLE_ORM_PACKAGE = 'drizzle-orm' +const DRIZZLE_KIT_PACKAGE = 'drizzle-kit' +const DOCS_URL = 'https://ntl.fyi/database' + +type QueryStyle = 'raw' | 'drizzle' + +const sectionHeading = (title: string): void => { + log('') + log(chalk.bold(title)) +} + +const info = (text: string): void => { + log(chalk.gray(text)) +} + +const success = (text: string): void => { + log(chalk.green(`✓ ${text}`)) +} + +const readDirectoryEntries = async (dir: string): Promise => { + try { + return await readdir(dir) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [] + throw error + } +} + +const promptForQueryStyle = async (interactive: boolean): Promise => { + if (!interactive) { + return 'drizzle' + } + + log('') + const { queryStyle } = await inquirer.prompt<{ queryStyle: QueryStyle }>([ + { + type: 'list', + name: 'queryStyle', + message: 'What is your preferred style?', + default: 'drizzle', + choices: [ + { name: 'Drizzle ORM (recommended)', value: 'drizzle' }, + { name: 'Direct SQL', value: 'raw' }, + ], + }, + ]) + + return queryStyle +} + +const promptForStarter = async (interactive: boolean): Promise => { + if (!interactive) { + return true + } + + log('') + log('To see the database in action, we can create some migrations with sample data') + log('and then query it. Alternatively, you can do this yourself at any time.') + + log('') + const { answer } = await inquirer.prompt<{ answer: boolean }>([ + { + type: 'confirm', + name: 'answer', + message: 'Do you want to create sample data?', + default: true, + }, + ]) + return answer +} + +const installDependencies = async ( + pm: PmInfo, + projectRoot: string, + migrationsDirectory: string, + queryStyle: QueryStyle, +): Promise => { + sectionHeading('Install dependencies') + info("We'll install the dependencies you need to use Netlify Database") + info('') + info(`${NETLIFY_DATABASE_PACKAGE} is the main interface for Netlify Database, letting you initialize and query the`) + info('database with no configuration.') + + if (queryStyle === 'drizzle') { + info('') + info('Drizzle is added too so your schema lives in TypeScript and migrations are generated from it.') + } + + const toInstall: PackageEntry[] = [] + const confirmedNames: string[] = [NETLIFY_DATABASE_PACKAGE] + if (!(await hasDependency(NETLIFY_DATABASE_PACKAGE, projectRoot))) { + toInstall.push({ pkg: `${NETLIFY_DATABASE_PACKAGE}@latest` }) + } + if (queryStyle === 'drizzle') { + confirmedNames.push(DRIZZLE_ORM_PACKAGE, DRIZZLE_KIT_PACKAGE) + if (!(await hasDependency(DRIZZLE_ORM_PACKAGE, projectRoot))) { + toInstall.push({ pkg: `${DRIZZLE_ORM_PACKAGE}@beta` }) + } + if (!(await hasDependency(DRIZZLE_KIT_PACKAGE, projectRoot))) { + toInstall.push({ pkg: `${DRIZZLE_KIT_PACKAGE}@beta`, dev: true }) + } + } + + await installPackages(pm, projectRoot, toInstall) + for (const name of confirmedNames) { + success(`${name} is installed`) + } + + if (queryStyle === 'drizzle') { + const out = relativeToProject(projectRoot, migrationsDirectory) + await carefullyWriteFile(join(projectRoot, 'drizzle.config.ts'), drizzleConfigTs(out), projectRoot) + success('drizzle.config.ts ready') + } +} + +// Scaffolds the schema + first migration for the chosen ORM and returns the +// path so it can be logged. Called only when the user opts in to the starter. +const scaffoldStarter = async ( + pm: PmInfo, + projectRoot: string, + migrationsDirectory: string, + queryStyle: QueryStyle, +): Promise => { + sectionHeading('Create a starter migration') + + if (queryStyle === 'drizzle') { + info("I'll scaffold a `planets` schema in TypeScript, run `drizzle-kit generate` against it, and add a") + info('seed migration with the eight planets in our solar system. 🪐') + + const schemaDir = join(projectRoot, 'db') + await mkdir(schemaDir, { recursive: true }) + await carefullyWriteFile(join(schemaDir, 'schema.ts'), DRIZZLE_SCHEMA_TS, projectRoot) + success('db/schema.ts ready') + + log('') + info(`Running \`drizzle-kit generate --name ${STARTER_MIGRATION_NAME}\` against your schema...`) + const [runner, ...runnerArgs] = pm.remoteRunArgs + await spawnAsync(runner, [...runnerArgs, 'drizzle-kit', 'generate', '--name', STARTER_MIGRATION_NAME], { + stdio: 'inherit', + shell: true, + cwd: projectRoot, + }) + + // The seed's timestamp is generated AFTER drizzle-kit runs, so it + // lexicographically sorts after whatever drizzle-kit produced. If they + // happen to land in the same second, `create_planets` < `seed_planets` + // alphabetically still runs the CREATE TABLE first. We use the same + // directory layout drizzle-kit uses so both migrations look consistent + // in the migrations/ folder. + const seedDirName = `${generateNextPrefix([], 'timestamp')}_${SEED_MIGRATION_NAME}` + const seedDir = join(migrationsDirectory, seedDirName) + await mkdir(seedDir, { recursive: true }) + await writeFile(join(seedDir, 'migration.sql'), DRIZZLE_SEED_SQL, { flag: 'wx' }) + log('') + success(`Created seed migration ${seedDirName}/migration.sql`) + + return + } + + info("I'll create a migration that sets up a `planets` table where we'll store data about the planets in our") + info('solar system.') + await mkdir(migrationsDirectory, { recursive: true }) + const fileName = `${generateNextPrefix([], 'timestamp')}_${STARTER_MIGRATION_NAME}.sql` + await writeFile(join(migrationsDirectory, fileName), STARTER_MIGRATION_SQL, { flag: 'wx' }) + success(`Created ${fileName}`) +} + +interface QueryResult { + fields: Parameters[0] + rows: Record[] + rowCount: number | null + command: string +} + +const applyAndQuery = async ( + projectRoot: string, + migrationsDirectory: string, +): Promise<{ applied: string[]; query: QueryResult | null }> => { + log('') + const spinner = startSpinner({ text: 'Applying the migration to the local database' }) + let connection: Awaited> | undefined + try { + connection = await connectRawClient(projectRoot) + const applied = await applyMigrations(new PgClientExecutor(connection.client), migrationsDirectory) + stopSpinner({ + spinner, + text: applied.length === 0 ? 'No new migrations to apply' : `Applied ${String(applied.length)} migration(s)`, + }) + + let query: QueryResult | null = null + try { + const result = await connection.client.query>(`SELECT * FROM ${STARTER_TABLE}`) + query = { + fields: result.fields, + rows: result.rows, + rowCount: result.rowCount, + command: result.command, + } + } catch { + // The table may not exist (e.g. Drizzle's schema failed to compile). We + // skip rendering the query block when that happens. + } + + return { applied, query } + } catch (err) { + stopSpinner({ spinner, error: true, text: `Failed to apply migrations: ${describeError(err)}` }) + throw err + } finally { + if (connection) await connection.cleanup() + } +} + +const renderQueryBlock = (query: QueryResult): void => { + info("We have data! Let's run a command that lets you run one-shot queries using SQL:") + log('') + log(` ${chalk.cyan(`$ ${netlifyCommand()} database connect --query "SELECT * FROM ${STARTER_TABLE}"`)}`) + log('') + + const formatted = formatQueryResult(query.fields, query.rows, query.rowCount, query.command) + for (const line of formatted.split('\n')) { + log(` ${line}`) + } +} + +const printNextSteps = (orm: QueryStyle, withStarter: boolean): void => { + log('') + log('A few commands to try from here:') + log('') + log(' • Check the state of your database, including applied and pending migrations:') + log(` ${chalk.cyan(`${netlifyCommand()} database status`)}`) + log('') + log(' • Open an interactive Postgres REPL for querying and introspecting the database:') + log(` ${chalk.cyan(`${netlifyCommand()} database connect`)}`) + log('') + + if (withStarter) { + log(' • Run a one-shot query:') + log(` ${chalk.cyan(`${netlifyCommand()} database connect --query "SELECT * FROM ${STARTER_TABLE}"`)}`) + } else if (orm === 'drizzle') { + log(' • Define your tables in `db/schema.ts`, then generate a migration from them:') + log(` ${chalk.cyan('npx drizzle-kit generate')}`) + } else { + log(' • Create your first migration:') + log(` ${chalk.cyan(`${netlifyCommand()} database migrations new`)}`) + } + log('') + + const template = orm === 'drizzle' ? 'database-drizzle' : 'database' + log(` • Scaffold a function that queries the \`${STARTER_TABLE}\` table:`) + log(` ${chalk.cyan(`${netlifyCommand()} functions:create --language typescript --template ${template}`)}`) + log('') + + if (withStarter) { + log(' • Wipe local data and restore the database to a blank state:') + log(` ${chalk.cyan(`${netlifyCommand()} database reset`)}`) + log('') + } + + log(' • Deploy your project (and its migrations) to Netlify:') + log(` ${chalk.cyan(`${netlifyCommand()} deploy`)}`) + + log('') + log(`To explore more of Netlify Database, visit ${chalk.cyan(DOCS_URL)}.`) +} + +export const initDatabase = async (options: DatabaseInitOptions, command: BaseCommand) => { + const projectRoot = command.netlify.site.root ?? command.project.root ?? command.project.baseDirectory + if (!projectRoot) { + throw new Error('Could not determine the project root directory.') + } + const yes = options.yes ?? false + const interactive = isInteractive() && !yes + const pm = getPackageManager(command) + + log(chalk.bold('Netlify Database')) + info('A fully managed Postgres database built into the Netlify platform. We automatically handle provisioning,') + info('migrations, and branching for you, so you can focus on building your application.') + + const migrationsDirectory = resolveMigrationsDirectory(command) + const existingMigrations = (await readDirectoryEntries(migrationsDirectory)).filter((name) => !name.startsWith('.')) + if (existingMigrations.length > 0) { + log('') + info( + `It looks like you already have migrations set up in ${chalk.bold( + relativeToProject(projectRoot, migrationsDirectory), + )}.`, + ) + info(`Run ${chalk.cyan(`${netlifyCommand()} database status`)} to see their current state.`) + return + } + + log('') + info('Database migrations are ordered SQL files that define and evolve your schema.') + info( + `Netlify manages and applies migrations for you. Read more at ${chalk.cyan( + 'https://ntl.fyi/database-migrations', + )}.`, + ) + info('') + info('Do you want to write SQL queries directly in your application and author migrations yourself, or') + info('do you want an ORM (Drizzle) to write the schema in code and generate the migrations for you?') + + const queryStyle = await promptForQueryStyle(interactive) + + await installDependencies(pm, projectRoot, migrationsDirectory, queryStyle) + + const withStarter = await promptForStarter(interactive) + + if (withStarter) { + await scaffoldStarter(pm, projectRoot, migrationsDirectory, queryStyle) + + sectionHeading('Apply the migration') + info( + 'Applying the migration to your local database. This step is handled automatically by Netlify when you deploy.', + ) + const { query } = await applyAndQuery(projectRoot, migrationsDirectory) + + if (query) { + sectionHeading('Query data') + renderQueryBlock(query) + } else { + info(`Could not query the \`${STARTER_TABLE}\` table. If you expected it to exist, check your migrations.`) + } + } + + sectionHeading('🎉 You are all set! 🎉') + printNextSteps(queryStyle, withStarter) +} diff --git a/src/commands/database/db-migration-new.ts b/src/commands/database/db-migration-new.ts index 79bf624bbbb..f5b44c4cbe1 100644 --- a/src/commands/database/db-migration-new.ts +++ b/src/commands/database/db-migration-new.ts @@ -6,6 +6,7 @@ import inquirer from 'inquirer' import { log, logJson } from '../../utils/command-helpers.js' import BaseCommand from '../base-command.js' import { resolveMigrationsDirectory } from './util/migrations-path.js' +import { utcTimestampPrefix } from './util/timestamp.js' export type NumberingScheme = 'sequential' | 'timestamp' @@ -46,16 +47,7 @@ export const detectNumberingScheme = (existingNames: string[]): NumberingScheme export const generateNextPrefix = (existingNames: string[], scheme: NumberingScheme): string => { if (scheme === 'timestamp') { - const now = new Date() - const pad = (n: number, width = 2) => String(n).padStart(width, '0') - return [ - now.getFullYear(), - pad(now.getMonth() + 1), - pad(now.getDate()), - pad(now.getHours()), - pad(now.getMinutes()), - pad(now.getSeconds()), - ].join('') + return utcTimestampPrefix() } const prefixes = existingNames.map((name) => { diff --git a/src/commands/database/db-status.ts b/src/commands/database/db-status.ts index 47f7584cf14..346024c3384 100644 --- a/src/commands/database/db-status.ts +++ b/src/commands/database/db-status.ts @@ -1,5 +1,5 @@ -import { readFile, readdir } from 'fs/promises' -import { join, relative, sep } from 'path' +import { readdir } from 'fs/promises' +import { join } from 'path' import { chalk, log, logJson, netlifyCommand } from '../../utils/command-helpers.js' import BaseCommand from '../base-command.js' @@ -12,6 +12,8 @@ import { import { readApiErrorMessage } from './util/api-errors.js' import { connectToDatabase, detectExistingLocalConnectionString } from './util/db-connection.js' import { resolveMigrationsDirectory } from './util/migrations-path.js' +import { hasDependency } from './util/package-json.js' +import { relativeToProject } from './util/paths.js' import { fileExistsAsync } from '../../lib/fs.js' export interface DatabaseStatusOptions { @@ -149,19 +151,6 @@ const connectionStringHasCredentials = (connectionString: string): boolean => { } } -const isNetlifyDatabasePackageInstalled = async (projectRoot: string): Promise => { - try { - const raw = await readFile(`${projectRoot}/package.json`, 'utf-8') - const pkg = JSON.parse(raw) as { - dependencies?: Record - devDependencies?: Record - } - return Boolean(pkg.dependencies?.[NETLIFY_DATABASE_PACKAGE] ?? pkg.devDependencies?.[NETLIFY_DATABASE_PACKAGE]) - } catch { - return false - } -} - const fetchBranchConnectionString = async (ctx: ServerContext, branchId: string): Promise => { const token = ctx.accessToken.replace('Bearer ', '') const url = new URL( @@ -322,9 +311,7 @@ const renderPretty = (params: RenderParams) => { log('') log('') - const relativePath = relative(projectRoot, migrationsDirectory) - const isInsideProject = relativePath !== '' && !relativePath.startsWith('..') - const displayPath = (isInsideProject ? relativePath : migrationsDirectory).split(sep).join('/') + const displayPath = relativeToProject(projectRoot, migrationsDirectory) log(` ${STATUS_INFO} ${chalk.bold('Migrations directory')}`) log(chalk.gray(`${INDENT}Migration files in this directory are automatically applied when deploying to Netlify.`)) log(`${INDENT}${displayPath}`) @@ -390,7 +377,7 @@ export const statusDb = async (options: DatabaseStatusOptions, command: BaseComm const migrationsDirectory = resolveMigrationsDirectory(command) const local = await readLocalMigrations(migrationsDirectory) - const packageInstalled = await isNetlifyDatabasePackageInstalled(buildDir) + const packageInstalled = await hasDependency(NETLIFY_DATABASE_PACKAGE, buildDir) const siteId = command.siteId const accessToken = command.netlify.api.accessToken diff --git a/src/commands/database/util/db-connection.ts b/src/commands/database/util/db-connection.ts index e4a2a9e0328..2b523d7cd03 100644 --- a/src/commands/database/util/db-connection.ts +++ b/src/commands/database/util/db-connection.ts @@ -40,15 +40,61 @@ export function detectExistingLocalConnectionString(buildDir: string): string | return stored ?? null } +// Unwraps AggregateError's inner errors into a single readable string. pg's +// connection errors show up this way when the server resolves to multiple +// addresses (IPv4/IPv6) and every attempt fails — the outer message is empty +// without this. +export const describeError = (err: unknown): string => { + if (err && typeof err === 'object' && 'errors' in err && Array.isArray((err as AggregateError).errors)) { + const inner = (err as AggregateError).errors + .map((e) => (e instanceof Error ? e.message : String(e))) + .filter((msg) => msg.length > 0) + if (inner.length > 0) return inner.join('; ') + } + if (err instanceof Error) return err.message || err.name || 'unknown error' + return String(err) +} + +// Detects pg "can't reach the server" errors. pg wraps multi-address attempts +// (IPv4 + IPv6) in an AggregateError whose outer message is empty, so we also +// unwrap .errors when present. +function isConnectionUnreachableError(err: unknown): boolean { + if (!err || typeof err !== 'object') return false + const code = (err as NodeJS.ErrnoException).code + if (code === 'ECONNREFUSED' || code === 'ENOTFOUND' || code === 'EHOSTUNREACH') return true + if ('errors' in err && Array.isArray((err as AggregateError).errors)) { + return (err as AggregateError).errors.some(isConnectionUnreachableError) + } + return false +} + export async function connectRawClient(buildDir: string, urlOverride?: string): Promise { const existing = urlOverride ?? detectExistingLocalConnectionString(buildDir) + // Explicit overrides (NETLIFY_DB_URL env var, or a urlOverride argument) are + // user-supplied and should never be silently discarded on a connection + // failure — let the error propagate. A persisted `dbConnectionString` in + // LocalState is different: it's a stale record of a prior `netlify dev` run + // that may not be running anymore, and we should recover by falling back to + // starting a fresh NetlifyDev. + const isUserOverride = Boolean(urlOverride ?? process.env.NETLIFY_DB_URL) + if (existing) { - const client = new Client({ connectionString: existing }) - await client.connect() - return { - client, - connectionString: existing, - cleanup: () => client.end(), + try { + const client = new Client({ connectionString: existing }) + await client.connect() + return { + client, + connectionString: existing, + cleanup: () => client.end(), + } + } catch (err) { + if (isUserOverride || !isConnectionUnreachableError(err)) { + throw err + } + // Persisted connection string points at a port that nothing is listening + // on. Drop the stale record so subsequent calls don't hit the same dead + // end, and fall through to the NetlifyDev.start() path. + new LocalState(buildDir).delete('dbConnectionString') } } diff --git a/src/commands/database/util/init-data.ts b/src/commands/database/util/init-data.ts new file mode 100644 index 00000000000..72d877986a9 --- /dev/null +++ b/src/commands/database/util/init-data.ts @@ -0,0 +1,51 @@ +export const STARTER_TABLE = 'planets' +export const STARTER_MIGRATION_NAME = 'create_planets' +export const SEED_MIGRATION_NAME = 'seed_planets' + +const PLANETS_INSERT_VALUES = ` ('Mercury', 3.30e23, 167), + ('Venus', 4.87e24, 464), + ('Earth', 5.97e24, 15), + ('Mars', 6.42e23, -65), + ('Jupiter', 1.898e27, -110), + ('Saturn', 5.68e26, -140), + ('Uranus', 8.68e25, -195), + ('Neptune', 1.02e26, -200)` + +export const STARTER_MIGRATION_SQL = `-- Starter migration scaffolded by "netlify database init". +CREATE TABLE IF NOT EXISTS ${STARTER_TABLE} ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + mass_kg DOUBLE PRECISION NOT NULL, + temperature_celsius INTEGER NOT NULL +); + +INSERT INTO ${STARTER_TABLE} (name, mass_kg, temperature_celsius) VALUES +${PLANETS_INSERT_VALUES}; +` + +export const DRIZZLE_SCHEMA_TS = `import { doublePrecision, integer, pgTable, serial, text } from 'drizzle-orm/pg-core' + +export const ${STARTER_TABLE} = pgTable('${STARTER_TABLE}', { + id: serial('id').primaryKey(), + name: text('name').notNull(), + massKg: doublePrecision('mass_kg').notNull(), + temperatureCelsius: integer('temperature_celsius').notNull(), +}) +` + +export const DRIZZLE_SEED_SQL = `-- Seed data scaffolded by "netlify database init". +INSERT INTO ${STARTER_TABLE} (name, mass_kg, temperature_celsius) VALUES +${PLANETS_INSERT_VALUES}; +` + +export const drizzleConfigTs = (migrationsOutPath: string): string => `import { defineConfig } from 'drizzle-kit' + +export default defineConfig({ + dialect: 'postgresql', + schema: './db/schema.ts', + out: '${migrationsOutPath}', + dbCredentials: { + url: process.env.NETLIFY_DATABASE_URL!, + }, +}) +` diff --git a/src/commands/database/util/package-json.ts b/src/commands/database/util/package-json.ts new file mode 100644 index 00000000000..9a077d630b4 --- /dev/null +++ b/src/commands/database/util/package-json.ts @@ -0,0 +1,20 @@ +import { readFile } from 'fs/promises' +import { join } from 'path' + +interface PackageJson { + dependencies?: Record + devDependencies?: Record +} + +// Returns true when `pkg` is listed in `dependencies` or `devDependencies` of +// the project's package.json. Returns false when the file can't be read or +// parsed, so callers can safely treat absence-by-error as "not installed". +export const hasDependency = async (pkg: string, projectRoot: string): Promise => { + try { + const raw = await readFile(join(projectRoot, 'package.json'), 'utf-8') + const json = JSON.parse(raw) as PackageJson + return Boolean(json.dependencies?.[pkg] ?? json.devDependencies?.[pkg]) + } catch { + return false + } +} diff --git a/src/commands/database/util/packages.ts b/src/commands/database/util/packages.ts new file mode 100644 index 00000000000..d2a577b58f2 --- /dev/null +++ b/src/commands/database/util/packages.ts @@ -0,0 +1,81 @@ +import { log } from '../../../utils/command-helpers.js' +import BaseCommand from '../../base-command.js' +import { spawnAsync } from '../legacy/utils.js' + +export type PkgManagerName = 'npm' | 'yarn' | 'pnpm' | 'bun' + +export interface PmInfo { + name: PkgManagerName + // argv for a "run this external binary" invocation, e.g. ['npx'] for npm or + // ['yarn', 'dlx'] for yarn. Sourced from `@netlify/build-info`. + remoteRunArgs: string[] +} + +export interface PackageEntry { + pkg: string + dev?: boolean +} + +export const getPackageManager = (command: BaseCommand): PmInfo => { + const detected = command.project.packageManager + return { + name: (detected?.name as PkgManagerName | undefined) ?? 'npm', + remoteRunArgs: detected?.remotePackageCommand ?? ['npx'], + } +} + +export const buildAddArgs = (name: PkgManagerName, pkgs: string[], dev: boolean, quiet: boolean): string[] => { + switch (name) { + case 'yarn': + return ['add', ...(quiet ? ['--silent'] : []), ...(dev ? ['-D'] : []), ...pkgs] + case 'pnpm': + return ['add', ...(quiet ? ['--reporter=append-only', '--loglevel=warn'] : []), ...(dev ? ['-D'] : []), ...pkgs] + case 'bun': + return ['add', ...(quiet ? ['--silent'] : []), ...(dev ? ['--dev'] : []), ...pkgs] + default: + return [ + 'install', + ...(quiet ? ['--loglevel=warn', '--no-audit', '--no-fund', '--no-progress'] : []), + ...(dev ? ['--save-dev'] : []), + ...pkgs, + ] + } +} + +export const installCommand = (name: PkgManagerName, pkg: string, dev = false): string => + `${name} ${buildAddArgs(name, [pkg], dev, false).join(' ')}` + +export const installPackages = async (pm: PmInfo, projectRoot: string, entries: PackageEntry[]): Promise => { + if (entries.length === 0) return + + const prod = entries.filter((entry) => !entry.dev).map((entry) => entry.pkg) + const dev = entries.filter((entry) => entry.dev).map((entry) => entry.pkg) + + log('') + log('----- 📦 ⏳ -----') + + try { + if (prod.length > 0) { + await spawnAsync(pm.name, buildAddArgs(pm.name, prod, false, true), { + stdio: 'inherit', + shell: true, + cwd: projectRoot, + }) + } + if (dev.length > 0) { + await spawnAsync(pm.name, buildAddArgs(pm.name, dev, true, true), { + stdio: 'inherit', + shell: true, + cwd: projectRoot, + }) + } + } catch (error) { + log('----- 📦 ❌ -----') + + throw error + } + + log('----- 📦 ✅ -----') + log('') + log('') +} diff --git a/src/commands/database/util/paths.ts b/src/commands/database/util/paths.ts new file mode 100644 index 00000000000..d3759431bf3 --- /dev/null +++ b/src/commands/database/util/paths.ts @@ -0,0 +1,12 @@ +import { relative, sep } from 'path' + +// Returns a POSIX-normalized path from `from` to `to`. When `to` is inside +// `from`, the relative path is used (e.g. `netlify/database/migrations`). +// When `to` is outside `from`, the original `to` is returned, normalized to +// forward slashes. Returns `.` when the two match exactly. +export const relativeToProject = (from: string, to: string): string => { + const rel = relative(from, to) + if (rel === '') return '.' + const isInside = !rel.startsWith('..') + return (isInside ? rel : to).split(sep).join('/') +} diff --git a/src/commands/database/util/timestamp.ts b/src/commands/database/util/timestamp.ts new file mode 100644 index 00000000000..f84f4c57b4e --- /dev/null +++ b/src/commands/database/util/timestamp.ts @@ -0,0 +1,7 @@ +// Returns the current time as a compact `YYYYMMDDHHMMSS` string in UTC. Used +// for migration prefixes so lexicographic ordering matches chronological +// ordering regardless of the developer's local timezone (otherwise two +// developers in different zones could produce prefixes that sort out of +// order from each other). +export const utcTimestampPrefix = (date: Date = new Date()): string => + date.toISOString().replace('T', '').replaceAll('-', '').replaceAll(':', '').slice(0, 14) diff --git a/src/commands/functions/functions-create.ts b/src/commands/functions/functions-create.ts index ac65874bde6..42d5da8eded 100644 --- a/src/commands/functions/functions-create.ts +++ b/src/commands/functions/functions-create.ts @@ -162,7 +162,7 @@ const formatRegistryArrayForInquirer = async function (lang, funcType) { * @param {'edge' | 'serverless'} funcType */ // @ts-expect-error TS(7031) FIXME: Binding element 'languageFromFlag' implicitly has ... Remove this comment to see the full error message -const pickTemplate = async function ({ language: languageFromFlag }, funcType) { +const pickTemplate = async function ({ language: languageFromFlag, template: templateFromFlag }, funcType) { const specialCommands = [ new inquirer.Separator(), { @@ -204,6 +204,18 @@ const pickTemplate = async function ({ language: languageFromFlag }, funcType) { return logAndThrowError(`Invalid language: ${language}`) } + if (templateFromFlag) { + const match = templatesForLanguage.find( + (entry: { value?: { name?: string } }) => entry.value?.name === templateFromFlag, + ) + if (!match) { + return logAndThrowError( + `Template "${templateFromFlag}" not found for language "${language}". Run \`netlify functions:create\` without --template to browse available templates.`, + ) + } + return match.value + } + const { chosenTemplate } = await inquirer.prompt({ name: 'chosenTemplate', message: 'Pick a template', @@ -741,8 +753,63 @@ const ensureFunctionPathIsOk = function (functionsDir, name) { return functionPath } +// Scans `functions-templates/` for a template whose `.mjs` metadata +// `name` matches. Returns its `functionType` and the language folder it lives +// in, or null if nothing matches. Used to skip the funcType/language prompts +// when the user passes `--template`. +const resolveTemplateMetadata = async ( + templateName: string, + languageHint?: string, +): Promise<{ functionType: 'edge' | 'serverless'; language: string } | null> => { + const langs = languageHint + ? [languageHint] + : (languages.map((lang) => lang.value as string | undefined).filter(Boolean) as string[]) + for (const lang of langs) { + let folders + try { + folders = await readdir(path.join(templatesDir, lang), { withFileTypes: true }) + } catch { + continue + } + for (const folder of folders) { + if (!folder.isDirectory()) continue + try { + const templatePath = path.join(templatesDir, lang, folder.name, '.netlify-function-template.mjs') + const mod = (await import(pathToFileURL(templatePath).href)) as { + default?: { name?: string; functionType?: 'edge' | 'serverless' } + } + const template = mod.default + if (template?.name === templateName && template.functionType) { + return { functionType: template.functionType, language: lang } + } + } catch { + // ignore templates we can't load + } + } + } + return null +} + export const functionsCreate = async (name: string, options: OptionValues, command: BaseCommand) => { - const functionType = await selectTypeOfFunc() + let functionType: 'edge' | 'serverless' + + if (typeof options.template === 'string') { + const resolved = await resolveTemplateMetadata(options.template, options.language as string | undefined) + if (!resolved) { + return logAndThrowError( + `Template "${options.template}" not found${ + options.language ? ` for language "${options.language as string}"` : '' + }.`, + ) + } + functionType = resolved.functionType + if (!options.language) { + options.language = resolved.language + } + } else { + functionType = await selectTypeOfFunc() + } + const functionsDir = functionType === 'edge' ? await ensureEdgeFuncDirExists(command) : await ensureFunctionDirExists(command) diff --git a/src/commands/functions/functions.ts b/src/commands/functions/functions.ts index 12a8d8d6bc1..e151f704126 100644 --- a/src/commands/functions/functions.ts +++ b/src/commands/functions/functions.ts @@ -29,11 +29,13 @@ export const createFunctionsCommand = (program: BaseCommand) => { .option('-n, --name ', 'function name') .option('-u, --url ', 'pull template from URL') .option('-l, --language ', 'function language') + .option('-t, --template ', 'bundled template to use (skips the interactive template picker)') .option('-o, --offline', 'Disables any features that require network access') .addExamples([ 'netlify functions:create', 'netlify functions:create hello-world', 'netlify functions:create --name hello-world', + 'netlify functions:create --language typescript --template hello-world', ]) .action(async (name: string, options: OptionValues, command: BaseCommand) => { const { functionsCreate } = await import('./functions-create.js') diff --git a/tests/unit/commands/database/db-init.test.ts b/tests/unit/commands/database/db-init.test.ts new file mode 100644 index 00000000000..9dbfedc73ff --- /dev/null +++ b/tests/unit/commands/database/db-init.test.ts @@ -0,0 +1,312 @@ +import { promises as fs } from 'node:fs' +import { join } from 'node:path' + +import tmp from 'tmp-promise' +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' + +// Integration-style tests: we let the real filesystem do its thing (via +// `tmp-promise`) and assert on the resulting file tree, but mock the slow +// and networky boundaries — `spawnAsync` (so no real `npm install` or +// `drizzle-kit generate` runs) and `connectRawClient` (so no real Postgres +// is booted). The `spawnAsync` mock simulates drizzle-kit's output by +// writing a synthetic migration file, which is enough to exercise the +// seed-prefix logic and the apply / query invocation. + +const { + mockSpawnAsync, + mockConnectRawClient, + mockInquirerPrompt, + mockIsInteractive, + mockFormatQueryResult, + mockApplyMigrations, + mockClientQuery, + mockCleanup, + logMessages, +} = vi.hoisted(() => ({ + mockSpawnAsync: vi.fn(), + mockConnectRawClient: vi.fn(), + mockInquirerPrompt: vi.fn(), + mockIsInteractive: vi.fn().mockReturnValue(true), + mockFormatQueryResult: vi.fn(), + mockApplyMigrations: vi.fn(), + mockClientQuery: vi.fn(), + mockCleanup: vi.fn().mockResolvedValue(undefined), + logMessages: [] as string[], +})) + +vi.mock('inquirer', () => ({ + default: { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + prompt: (...args: unknown[]) => mockInquirerPrompt(...args), + }, +})) + +vi.mock('../../../../src/commands/database/legacy/utils.js', async (importOriginal) => ({ + ...(await importOriginal()), + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + spawnAsync: (...args: unknown[]) => mockSpawnAsync(...args), +})) + +vi.mock('../../../../src/commands/database/util/db-connection.js', () => ({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + connectRawClient: (...args: unknown[]) => mockConnectRawClient(...args), +})) + +vi.mock('../../../../src/commands/database/util/psql-formatter.js', () => ({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + formatQueryResult: (...args: unknown[]) => mockFormatQueryResult(...args), +})) + +vi.mock('../../../../src/utils/scripted-commands.js', () => ({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + isInteractive: () => mockIsInteractive(), +})) + +vi.mock('../../../../src/lib/spinner.js', () => ({ + startSpinner: () => ({ stop: () => undefined, error: () => undefined }), + stopSpinner: () => undefined, +})) + +vi.mock('@netlify/dev', () => ({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + applyMigrations: (...args: unknown[]) => mockApplyMigrations(...args), +})) + +vi.mock('../../../../src/utils/command-helpers.js', async () => ({ + ...(await vi.importActual('../../../../src/utils/command-helpers.js')), + log: (...args: string[]) => { + logMessages.push(args.join(' ')) + }, +})) + +import { initDatabase } from '../../../../src/commands/database/db-init.js' +import { utcTimestampPrefix } from '../../../../src/commands/database/util/timestamp.js' + +let tmpDir: tmp.DirectoryResult | undefined + +const projectRoot = (): string => { + if (!tmpDir) throw new Error('tmp dir not initialized — did beforeEach run?') + return tmpDir.path +} + +function createCommand(projectRoot: string) { + return { + project: { root: projectRoot, baseDirectory: undefined, packageManager: null }, + netlify: { + site: { root: projectRoot }, + config: { db: { migrations: { path: join(projectRoot, 'netlify', 'database', 'migrations') } } }, + }, + } as unknown as Parameters[1] +} + +const setPrompts = (...responses: Record[]) => { + const queue = [...responses] + mockInquirerPrompt.mockImplementation(() => { + const next = queue.shift() + if (!next) throw new Error('Unexpected inquirer.prompt call — no response queued') + return Promise.resolve(next) + }) +} + +const exists = async (path: string): Promise => { + try { + await fs.access(path) + return true + } catch { + return false + } +} + +const readMigrations = async (projectRoot: string): Promise => { + try { + return await fs.readdir(join(projectRoot, 'netlify', 'database', 'migrations')) + } catch { + return [] + } +} + +beforeEach(async () => { + tmpDir = await tmp.dir({ unsafeCleanup: true }) + await fs.writeFile(join(tmpDir.path, 'package.json'), JSON.stringify({ name: 'test-project' })) + + logMessages.length = 0 + vi.clearAllMocks() + + // Simulate `drizzle-kit generate` by writing a timestamp-prefixed directory + // with a migration.sql under the configured out dir. Anything else (package + // installs) is a no-op. + mockSpawnAsync.mockImplementation(async (_cmd: string, args: string[], options: { cwd?: string }) => { + const argv = args + if (argv.includes('drizzle-kit') && argv.includes('generate')) { + const cwd = options.cwd ?? projectRoot() + const migrationsDir = join(cwd, 'netlify', 'database', 'migrations') + await fs.mkdir(migrationsDir, { recursive: true }) + const name = argv[argv.indexOf('--name') + 1] + const prefix = utcTimestampPrefix() + const dirName = `${prefix}_${name}` + await fs.mkdir(join(migrationsDir, dirName), { recursive: true }) + await fs.writeFile(join(migrationsDir, dirName, 'migration.sql'), 'CREATE TABLE planets (id serial primary key);') + } + return 0 + }) + + mockApplyMigrations.mockResolvedValue(['0001_stub_applied']) + mockClientQuery.mockResolvedValue({ + fields: [{ name: 'id' }, { name: 'name' }], + rows: [{ id: 3, name: 'Earth' }], + rowCount: 1, + command: 'SELECT', + }) + mockConnectRawClient.mockResolvedValue({ + client: { query: mockClientQuery }, + connectionString: 'postgres://localhost/stub', + cleanup: mockCleanup, + }) + mockFormatQueryResult.mockReturnValue(' id | name\n----+-------\n 3 | Earth\n(1 row)') + mockIsInteractive.mockReturnValue(true) +}) + +afterEach(async () => { + if (tmpDir) { + await tmpDir.cleanup() + tmpDir = undefined + } +}) + +describe('initDatabase (integration)', () => { + test('raw SQL + starter writes a timestamp-prefixed migration with a CREATE TABLE and seed data', async () => { + setPrompts({ queryStyle: 'raw' }, { answer: true }) + + await initDatabase({}, createCommand(projectRoot())) + + const migrations = await readMigrations(projectRoot()) + const starter = migrations.find((name) => /^\d{14}_create_planets\.sql$/.test(name)) + if (!starter) throw new Error('starter migration not found') + + const sql = await fs.readFile(join(projectRoot(), 'netlify', 'database', 'migrations', starter), 'utf-8') + expect(sql).toContain('CREATE TABLE IF NOT EXISTS planets') + expect(sql).toContain("'Earth'") + expect(sql).toContain("'Jupiter'") + + // No Drizzle scaffolding for the raw path. + expect(await exists(join(projectRoot(), 'drizzle.config.ts'))).toBe(false) + expect(await exists(join(projectRoot(), 'db', 'schema.ts'))).toBe(false) + + // Full apply + query cycle ran; next steps point at the raw template. + expect(mockConnectRawClient).toHaveBeenCalledOnce() + expect(mockApplyMigrations).toHaveBeenCalledOnce() + expect(logMessages.join('\n')).toContain('functions:create --language typescript --template database') + }) + + test('Drizzle + starter writes schema/config, runs drizzle-kit generate, and seeds after it', async () => { + setPrompts({ queryStyle: 'drizzle' }, { answer: true }) + + await initDatabase({}, createCommand(projectRoot())) + + // Drizzle config points at the project's migrations dir. + const config = await fs.readFile(join(projectRoot(), 'drizzle.config.ts'), 'utf-8') + expect(config).toContain("out: 'netlify/database/migrations'") + expect(config).toContain('drizzle-kit') + + // Schema was scaffolded (only when withStarter=true). + const schema = await fs.readFile(join(projectRoot(), 'db', 'schema.ts'), 'utf-8') + expect(schema).toContain("pgTable('planets'") + expect(schema).toContain('doublePrecision') + + // drizzle-kit generate was invoked with --name create_planets. + const generate = mockSpawnAsync.mock.calls.find((call) => { + const argv = call[1] as string[] + return argv.includes('drizzle-kit') && argv.includes('generate') + }) + if (!generate) throw new Error('drizzle-kit generate was not invoked') + const argv = generate[1] as string[] + expect(argv).toContain('--name') + expect(argv[argv.indexOf('--name') + 1]).toBe('create_planets') + + // Both the drizzle-kit output and our seed file exist, and the seed sorts + // lexicographically AFTER the drizzle-kit directory (regression for the + // "0001_seed_planets runs before 2026…_create_planets" bug). + const entries = (await readMigrations(projectRoot())).sort() + const createDir = entries.find((name) => name.includes('create_planets')) + const seedDir = entries.find((name) => name.includes('seed_planets')) + if (!createDir || !seedDir) throw new Error('expected both the drizzle-kit migration and the seed migration') + expect(seedDir.localeCompare(createDir)).toBeGreaterThan(0) + + // Drizzle-style seed uses the directory layout, matching drizzle-kit's + // own output format. + const seedPath = join(projectRoot(), 'netlify', 'database', 'migrations', seedDir, 'migration.sql') + const seedSql = await fs.readFile(seedPath, 'utf-8') + expect(seedSql).toContain("'Earth'") + + expect(logMessages.join('\n')).toContain('functions:create --language typescript --template database-drizzle') + }) + + test('Drizzle without starter scaffolds drizzle.config.ts only (no schema, no migration, no generate)', async () => { + setPrompts({ queryStyle: 'drizzle' }, { answer: false }) + + await initDatabase({}, createCommand(projectRoot())) + + expect(await exists(join(projectRoot(), 'drizzle.config.ts'))).toBe(true) + expect(await exists(join(projectRoot(), 'db', 'schema.ts'))).toBe(false) + expect(await readMigrations(projectRoot())).toHaveLength(0) + + const generate = mockSpawnAsync.mock.calls.find((call) => (call[1] as string[]).includes('drizzle-kit')) + expect(generate).toBeUndefined() + expect(mockConnectRawClient).not.toHaveBeenCalled() + + const output = logMessages.join('\n') + expect(output).toContain('drizzle-kit generate') + expect(output).not.toContain('database connect --query "SELECT * FROM planets"') + }) + + test('raw without starter writes nothing extra; next steps point at `database migrations new`', async () => { + setPrompts({ queryStyle: 'raw' }, { answer: false }) + + await initDatabase({}, createCommand(projectRoot())) + + expect(await exists(join(projectRoot(), 'drizzle.config.ts'))).toBe(false) + expect(await exists(join(projectRoot(), 'db', 'schema.ts'))).toBe(false) + expect(await readMigrations(projectRoot())).toHaveLength(0) + expect(mockConnectRawClient).not.toHaveBeenCalled() + + const output = logMessages.join('\n') + expect(output).toContain('database migrations new') + expect(output).not.toContain('database connect --query "SELECT * FROM planets"') + }) + + test('aborts when migrations already exist; nothing on disk changes', async () => { + const migrationsDir = join(projectRoot(), 'netlify', 'database', 'migrations') + await fs.mkdir(migrationsDir, { recursive: true }) + await fs.writeFile(join(migrationsDir, '0001_existing.sql'), '-- pre-existing') + + await initDatabase({}, createCommand(projectRoot())) + + expect(await readMigrations(projectRoot())).toEqual(['0001_existing.sql']) + expect(mockInquirerPrompt).not.toHaveBeenCalled() + expect(mockSpawnAsync).not.toHaveBeenCalled() + expect(mockConnectRawClient).not.toHaveBeenCalled() + expect(logMessages.join('\n')).toContain('you already have migrations set up') + }) + + test('non-interactive (no TTY) runs the full Drizzle flow without prompting', async () => { + mockIsInteractive.mockReturnValue(false) + + await initDatabase({}, createCommand(projectRoot())) + + expect(mockInquirerPrompt).not.toHaveBeenCalled() + expect(await exists(join(projectRoot(), 'drizzle.config.ts'))).toBe(true) + expect(await exists(join(projectRoot(), 'db', 'schema.ts'))).toBe(true) + expect((await readMigrations(projectRoot())).some((name) => name.includes('seed_planets'))).toBe(true) + expect(mockConnectRawClient).toHaveBeenCalledOnce() + expect(mockApplyMigrations).toHaveBeenCalledOnce() + }) + + test('throws when project root cannot be determined', async () => { + const command = { + project: { root: undefined, baseDirectory: undefined, packageManager: null }, + netlify: { site: { root: undefined }, config: {} }, + } as unknown as Parameters[1] + + await expect(initDatabase({}, command)).rejects.toThrow('Could not determine the project root') + }) +})