diff --git a/README.md b/README.md index e82633a..cba79f5 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ yarn dlx create-prisma@latest my-app bunx create-prisma@latest my-app ``` -The CLI initializes Prisma 8 with `prisma@next`, installs dependencies, emits the contract, and generates a deployable Composer app. PostgreSQL projects use Composer's native Prisma Postgres provider, including migrations and a typed runtime client. +The CLI initializes Prisma 8 with the compatible consolidated Prisma CLI, installs dependencies, emits the contract, and generates a deployable Composer app. PostgreSQL projects use Composer's native Prisma Postgres provider, including migrations and a typed runtime client. The deployment prompt is: @@ -34,6 +34,7 @@ workspace. Choosing another workspace also updates the Prisma CLI's active works - `elysia` - `nest` - `next` +- `turborepo` (Next.js monorepo with a shared Prisma database package) - `svelte` (SvelteKit) - `astro` - `nuxt` diff --git a/src/commands/create.ts b/src/commands/create.ts index 003128c..be43114 100644 --- a/src/commands/create.ts +++ b/src/commands/create.ts @@ -159,6 +159,11 @@ async function promptForCreateTemplate(output: Writable): Promise { const dependencies = [getDbPackages(provider)]; - if (provider === "postgres" && packageManager !== "deno") dependencies.push("temporal-polyfill"); + if (provider === "postgres" && packageManager !== "deno" && template !== "turborepo") { + dependencies.push("temporal-polyfill"); + } if (provider === "mongo") dependencies.push("arktype", "mongodb"); if (packageManager === "deno") dependencies.push("dotenv"); @@ -156,6 +159,15 @@ export async function writePrismaDependencies( scripts: getPrismaScriptMap(packageManager), projectDir, }); + + if (template === "turborepo" && packageManager !== "deno") { + const databaseDependencies = [getDbPackages(provider)]; + if (provider === "postgres") databaseDependencies.push("temporal-polyfill"); + await addPackageDependency({ + dependencies: databaseDependencies, + projectDir: path.join(projectDir, "packages/database"), + }); + } } export async function writeCreateTemplateDependencies(opts: { @@ -174,7 +186,10 @@ export async function writeCreateTemplateDependencies(opts: { dependencies: target.dependencies, devDependencies: target.devDependencies, customDependencies: target.customDependencies, - scripts: getComposerScriptMap(packageManager), + scripts: + target.packageJsonPath === "package.json" + ? getComposerScriptMap(packageManager) + : undefined, projectDir: path.join(projectDir, path.dirname(target.packageJsonPath)), }); } diff --git a/src/tasks/setup-prisma.ts b/src/tasks/setup-prisma.ts index 4ace2e3..023b531 100644 --- a/src/tasks/setup-prisma.ts +++ b/src/tasks/setup-prisma.ts @@ -12,7 +12,10 @@ import { type CreateFailureStage, } from "../create-outcome"; import type { CreateNextStep } from "../result"; -import { scaffoldCreateSharedTemplates } from "../templates/render-create-template"; +import { + getCreatePrismaSourceDir, + scaffoldCreateSharedTemplates, +} from "../templates/render-create-template"; import { AuthoringStyleSchema, DatabaseProviderSchema, @@ -221,8 +224,10 @@ export async function collectPrismaSetupContext( }; } -function getContractPath(authoring: AuthoringStyle) { - return `src/prisma/contract${authoring === "typescript" ? ".ts" : ".prisma"}`; +function getContractPath(authoring: AuthoringStyle, template: CreateTemplate) { + return `${getCreatePrismaSourceDir(template)}/contract${ + authoring === "typescript" ? ".ts" : ".prisma" + }`; } function getInitTarget(provider: DatabaseProvider): "postgres" | "mongodb" { @@ -235,7 +240,11 @@ function getPrismaCliInvocation(packageManager: PackageManager, args: string[]) return getPackageExecutionArgs(packageManager, [packageName, ...args]); } -async function runPrismaInit(context: PrismaSetupContext, projectDir: string): Promise { +async function runPrismaInit( + context: PrismaSetupContext, + projectDir: string, + template: CreateTemplate, +): Promise { const args = [ "orm", "init", @@ -246,7 +255,7 @@ async function runPrismaInit(context: PrismaSetupContext, projectDir: string): P "--authoring", context.authoring, "--schema-path", - getContractPath(context.authoring), + getContractPath(context.authoring, template), "--skip-install", ]; const invocation = getPrismaCliInvocation(context.packageManager, args); @@ -460,7 +469,7 @@ export async function executePrismaSetupContext( try { progress?.message("Preparing Prisma 8 project files..."); - await runPrismaInit(context, projectDir); + await runPrismaInit(context, projectDir, template); setupStage = "configure_project"; setupReason = "project_configuration_failed"; @@ -477,6 +486,7 @@ export async function executePrismaSetupContext( context.packageManager, context.authoring, projectDir, + template, ); await ensureComposerTypeScriptOptions(projectDir); if (context.databaseProvider === "mongo") await ensureMongoEnvironment(projectDir); diff --git a/src/templates/render-create-template.ts b/src/templates/render-create-template.ts index fe4cd98..31d1cb7 100644 --- a/src/templates/render-create-template.ts +++ b/src/templates/render-create-template.ts @@ -1,6 +1,11 @@ +import path from "node:path"; + import type { AuthoringStyle, CreateTemplate, DatabaseProvider, PackageManager } from "../types"; import { renderTemplateTree, resolveTemplatesDir } from "./shared"; +const DEFAULT_PRISMA_SOURCE_DIR = "src/prisma"; +const TURBOREPO_PRISMA_SOURCE_DIR = "packages/database/src"; + type CreateTemplateContext = { projectName: string; template: CreateTemplate; @@ -17,6 +22,10 @@ function getCreateSharedTemplateDir(): string { return resolveTemplatesDir("templates/create/_shared"); } +export function getCreatePrismaSourceDir(template: CreateTemplate): string { + return template === "turborepo" ? TURBOREPO_PRISMA_SOURCE_DIR : DEFAULT_PRISMA_SOURCE_DIR; +} + function createTemplateContext( projectName: string, template: CreateTemplate, @@ -46,6 +55,18 @@ export async function scaffoldCreateSharedTemplates(opts: { templateRoot: getCreateSharedTemplateDir(), outputDir: projectDir, context: createTemplateContext(projectName, template, provider, authoring, packageManager), + mapRelativeOutputPath(relativePath) { + if (template !== "turborepo") return relativePath; + const relativePrismaPath = path.relative(DEFAULT_PRISMA_SOURCE_DIR, relativePath); + if ( + relativePrismaPath === "" || + relativePrismaPath === ".." || + relativePrismaPath.startsWith(`..${path.sep}`) + ) { + return relativePath; + } + return path.join(TURBOREPO_PRISMA_SOURCE_DIR, relativePrismaPath); + }, }); } @@ -77,4 +98,11 @@ export async function scaffoldCreateFrameworkTemplate(opts: { outputDir: projectDir, context, }); + if (template === "turborepo") { + await renderTemplateTree({ + templateRoot: getCreateTemplateDir("next"), + outputDir: path.join(projectDir, "apps/web"), + context, + }); + } } diff --git a/src/templates/shared.ts b/src/templates/shared.ts index cc0126f..37b6639 100644 --- a/src/templates/shared.ts +++ b/src/templates/shared.ts @@ -12,6 +12,7 @@ import { } from "../utils/package-manager"; Handlebars.registerHelper("eq", (left: unknown, right: unknown) => left === right); +Handlebars.registerHelper("or", (...args: unknown[]) => args.slice(0, -1).some(Boolean)); Handlebars.registerHelper( "runScriptCommand", (packageManager: PackageManager | undefined, scriptName: string) => @@ -126,13 +127,17 @@ export async function renderTemplateTree(opts: { templateRoot: string; outputDir: string; context: TContext; + mapRelativeOutputPath?: (relativePath: string) => string; }): Promise { - const { templateRoot, outputDir, context } = opts; + const { templateRoot, outputDir, context, mapRelativeOutputPath } = opts; const templateFiles = await getTemplateFilesRecursively(templateRoot); for (const templateFilePath of templateFiles) { const relativeTemplatePath = path.relative(templateRoot, templateFilePath); - const relativeOutputPath = stripHbsExtension(relativeTemplatePath); + const renderedRelativePath = stripHbsExtension(relativeTemplatePath); + const relativeOutputPath = mapRelativeOutputPath + ? mapRelativeOutputPath(renderedRelativePath) + : renderedRelativePath; const outputPath = path.join(outputDir, relativeOutputPath); await renderTemplateFile({ templateFilePath, diff --git a/src/types.ts b/src/types.ts index 55e5446..8fbeb25 100644 --- a/src/types.ts +++ b/src/types.ts @@ -11,6 +11,7 @@ export const createTemplates = [ "elysia", "nest", "next", + "turborepo", "svelte", "astro", "nuxt", diff --git a/templates/create/_shared/.gitattributes.hbs b/templates/create/_shared/.gitattributes.hbs index fe00c01..298270d 100644 --- a/templates/create/_shared/.gitattributes.hbs +++ b/templates/create/_shared/.gitattributes.hbs @@ -1,11 +1,11 @@ {{#if (eq authoring "typescript")}} -src/prisma/generated/contract.json linguist-generated -src/prisma/generated/contract.d.ts linguist-generated +{{#if (eq template "turborepo")}}packages/database/src{{else}}src/prisma{{/if}}/generated/contract.json linguist-generated +{{#if (eq template "turborepo")}}packages/database/src{{else}}src/prisma{{/if}}/generated/contract.d.ts linguist-generated {{else}} -src/prisma/contract.json linguist-generated -src/prisma/contract.d.ts linguist-generated +{{#if (eq template "turborepo")}}packages/database/src{{else}}src/prisma{{/if}}/contract.json linguist-generated +{{#if (eq template "turborepo")}}packages/database/src{{else}}src/prisma{{/if}}/contract.d.ts linguist-generated {{/if}} -src/prisma/ops.json linguist-generated -src/prisma/migration.json linguist-generated +{{#if (eq template "turborepo")}}packages/database/src{{else}}src/prisma{{/if}}/ops.json linguist-generated +{{#if (eq template "turborepo")}}packages/database/src{{else}}src/prisma{{/if}}/migration.json linguist-generated migrations/snapshots/**/contract.json linguist-generated migrations/snapshots/**/contract.d.ts linguist-generated diff --git a/templates/create/_shared/README.md.hbs b/templates/create/_shared/README.md.hbs index 8e79b84..ef42d32 100644 --- a/templates/create/_shared/README.md.hbs +++ b/templates/create/_shared/README.md.hbs @@ -31,6 +31,50 @@ deno task contract:emit ``` Prisma Compute does not support Deno deployments yet. +{{else if (eq template "turborepo")}} +A Prisma 8 monorepo powered by Turborepo, Next.js, and Prisma Composer. + +## Workspace layout + +- `apps/web` — Next.js application +- `packages/database` — Prisma contract, generated artifacts, runtime client, and seed data +- `module.ts` and `service.ts` — Composer deployment topology + +## Run locally + +```bash +{{runScriptCommand packageManager "dev:composer"}} +``` + +This builds the workspace and starts it with Composer. PostgreSQL projects get a local Prisma Postgres database and apply the committed migrations automatically. + +## Deploy + +```bash +{{runScriptCommand packageManager "deploy"}} +``` + +The deploy script runs the Turborepo build, provisions Prisma Postgres when selected, applies migrations, and deploys the Next.js app to Prisma Compute. + +The starter users are inserted idempotently from `packages/database/src/seed.ts` on the first database query through the Composer service binding. + +{{#if (eq provider "mongo")}} +MongoDB is not provisioned by Composer. Set `MONGODB_URL` before running Composer locally or deploying. +{{/if}} + +## Prisma + +- Contract: `packages/database/src/contract{{#if (eq authoring "typescript")}}.ts{{else}}.prisma{{/if}}` +- Prisma and Composer config: `prisma.config.ts` +- Composer app: `module.ts` and `service.ts` + +After changing the contract, run: + +```bash +{{runScriptCommand packageManager "contract:emit"}} +``` + +To run the workspace's development tasks directly, use `{{runScriptCommand packageManager "dev"}}`. This direct mode requires `{{#if (eq provider "postgres")}}DATABASE_URL{{else}}MONGODB_URL{{/if}}`. {{else}} A minimal {{template}} app with Prisma 8 and Prisma Composer. diff --git a/templates/create/_shared/module.ts.hbs b/templates/create/_shared/module.ts.hbs index 6c24897..72b060d 100644 --- a/templates/create/_shared/module.ts.hbs +++ b/templates/create/_shared/module.ts.hbs @@ -3,7 +3,7 @@ import { module } from "@prisma/composer"; {{#if (eq provider "postgres")}} import { postgres } from "@prisma/composer-prisma-cloud/orm"; -import { appContract } from "./src/prisma/composer.ts"; +import { appContract } from "./{{#if (eq template "turborepo")}}packages/database/src{{else}}src/prisma{{/if}}/composer.ts"; {{else}} import { envSecret } from "@prisma/composer-prisma-cloud"; {{/if}} diff --git a/templates/create/_shared/pnpm-workspace.yaml.hbs b/templates/create/_shared/pnpm-workspace.yaml.hbs index ca1c4ac..8ec37a4 100644 --- a/templates/create/_shared/pnpm-workspace.yaml.hbs +++ b/templates/create/_shared/pnpm-workspace.yaml.hbs @@ -1,8 +1,13 @@ {{#if (eq packageManager "pnpm")}} +{{#if (eq template "turborepo")}} +packages: + - "apps/*" + - "packages/*" +{{/if}} allowBuilds: esbuild: true msgpackr-extract: true -{{#if (eq template "next")}} +{{#if (or (eq template "next") (eq template "turborepo"))}} sharp: true unrs-resolver: true {{else if (eq template "astro")}} diff --git a/templates/create/_shared/prisma-composer.config.ts.hbs b/templates/create/_shared/prisma-composer.config.ts.hbs index e4fe7cc..d087eac 100644 --- a/templates/create/_shared/prisma-composer.config.ts.hbs +++ b/templates/create/_shared/prisma-composer.config.ts.hbs @@ -1,13 +1,13 @@ {{#unless (eq packageManager "deno")}} import { defineConfig } from "@prisma/composer/config"; import { nodeBuild } from "@prisma/composer/node/control"; -{{#if (eq template "next")}} +{{#if (or (eq template "next") (eq template "turborepo"))}} import { nextjsBuild } from "@prisma/composer/nextjs/control"; {{/if}} import { prismaCloud, prismaState } from "@prisma/composer-prisma-cloud/control"; export default defineConfig({ - extensions: [prismaCloud(), nodeBuild(){{#if (eq template "next")}}, nextjsBuild(){{/if}}], + extensions: [prismaCloud(), nodeBuild(){{#if (or (eq template "next") (eq template "turborepo"))}}, nextjsBuild(){{/if}}], state: prismaState(), }); {{/unless}} diff --git a/templates/create/_shared/prisma.config.ts.hbs b/templates/create/_shared/prisma.config.ts.hbs index 847929e..4fa7618 100644 --- a/templates/create/_shared/prisma.config.ts.hbs +++ b/templates/create/_shared/prisma.config.ts.hbs @@ -5,9 +5,9 @@ import { defineConfig as ormConfig } from "@prisma/orm-{{#if (eq provider "postg export default definePrismaConfig({ orm: ormConfig({ - contract: "./src/prisma/contract{{#if (eq authoring "typescript")}}.ts{{else}}.prisma{{/if}}", + contract: "./{{#if (eq template "turborepo")}}packages/database/src{{else}}src/prisma{{/if}}/contract{{#if (eq authoring "typescript")}}.ts{{else}}.prisma{{/if}}", {{#if (eq authoring "typescript")}} - output: "./src/prisma/generated", + output: "./{{#if (eq template "turborepo")}}packages/database/src{{else}}src/prisma{{/if}}/generated", {{/if}} db: { connection: process.env.{{#if (eq provider "postgres")}}DATABASE_URL{{else}}MONGODB_URL{{/if}}!, @@ -23,9 +23,9 @@ export default definePrismaConfig({ agents: ["claude", "cursor", "agents", "devin"], }, orm: ormConfig({ - contract: "./src/prisma/contract{{#if (eq authoring "typescript")}}.ts{{else}}.prisma{{/if}}", + contract: "./{{#if (eq template "turborepo")}}packages/database/src{{else}}src/prisma{{/if}}/contract{{#if (eq authoring "typescript")}}.ts{{else}}.prisma{{/if}}", {{#if (eq authoring "typescript")}} - output: "./src/prisma/generated", + output: "./{{#if (eq template "turborepo")}}packages/database/src{{else}}src/prisma{{/if}}/generated", {{/if}} db: { connection: process.env.{{#if (eq provider "postgres")}}DATABASE_URL{{else}}MONGODB_URL{{/if}}!, diff --git a/templates/create/_shared/service.ts.hbs b/templates/create/_shared/service.ts.hbs index 3866e57..0369156 100644 --- a/templates/create/_shared/service.ts.hbs +++ b/templates/create/_shared/service.ts.hbs @@ -1,5 +1,5 @@ {{#unless (eq packageManager "deno")}} -{{#if (eq template "next")}} +{{#if (or (eq template "next") (eq template "turborepo"))}} import nextjs from "@prisma/composer/nextjs"; {{else}} import node from "@prisma/composer/node"; @@ -12,7 +12,7 @@ import { compute } from "@prisma/composer-prisma-cloud"; {{#if (eq provider "postgres")}} import { postgres } from "@prisma/composer-prisma-cloud/orm"; -import { appContract } from "./src/prisma/composer.ts"; +import { appContract } from "./{{#if (eq template "turborepo")}}packages/database/src{{else}}src/prisma{{/if}}/composer.ts"; {{/if}} export default compute({ @@ -29,6 +29,8 @@ export default compute({ {{/if}} {{#if (eq template "next")}} build: nextjs({ module: import.meta.url, appDir: "." }), +{{else if (eq template "turborepo")}} + build: nextjs({ module: import.meta.url, appDir: "./apps/web" }), {{else if (eq template "svelte")}} build: node({ module: import.meta.url, dir: "./build", entry: "index.js" }), {{else if (eq template "astro")}} diff --git a/templates/create/_shared/src/prisma/db.ts.hbs b/templates/create/_shared/src/prisma/db.ts.hbs index 26e0e9d..576d3d0 100644 --- a/templates/create/_shared/src/prisma/db.ts.hbs +++ b/templates/create/_shared/src/prisma/db.ts.hbs @@ -14,7 +14,7 @@ export const db = postgres({ contractJson, url: databaseUrl }); {{else}} import "temporal-polyfill/global"; -import service from "../../service.ts"; +import service from "{{#if (eq template "turborepo")}}../../../service.ts{{else}}../../service.ts{{/if}}"; import type { Contract } from "./{{#if (eq authoring "typescript")}}generated/{{/if}}contract.d.ts"; import contractJson from "./{{#if (eq authoring "typescript")}}generated/{{/if}}contract.json" with { type: "json" }; @@ -35,7 +35,7 @@ export const db = {{else}} import mongo from "@prisma/orm-mongo/runtime"; -import service from "../../service.ts"; +import service from "{{#if (eq template "turborepo")}}../../../service.ts{{else}}../../service.ts{{/if}}"; import type { Contract } from "./{{#if (eq authoring "typescript")}}generated/{{/if}}contract.d.ts"; import contractJson from "./{{#if (eq authoring "typescript")}}generated/{{/if}}contract.json" with { type: "json" }; diff --git a/templates/create/next/.yarnrc.yml.hbs b/templates/create/next/.yarnrc.yml.hbs index faa4013..ce95a41 100644 --- a/templates/create/next/.yarnrc.yml.hbs +++ b/templates/create/next/.yarnrc.yml.hbs @@ -1,3 +1,5 @@ {{#if (eq packageManager "yarn")}} +{{#unless (eq template "turborepo")}} nodeLinker: node-modules +{{/unless}} {{/if}} diff --git a/templates/create/next/next.config.ts b/templates/create/next/next.config.ts deleted file mode 100644 index 68a6c64..0000000 --- a/templates/create/next/next.config.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { NextConfig } from "next"; - -const nextConfig: NextConfig = { - output: "standalone", -}; - -export default nextConfig; diff --git a/templates/create/next/next.config.ts.hbs b/templates/create/next/next.config.ts.hbs new file mode 100644 index 0000000..2b863ea --- /dev/null +++ b/templates/create/next/next.config.ts.hbs @@ -0,0 +1,19 @@ +{{#if (eq template "turborepo")}} +import path from "node:path"; +import { fileURLToPath } from "node:url"; +{{/if}} +import type { NextConfig } from "next"; + +{{#if (eq template "turborepo")}} +const monorepoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +{{/if}} + +const nextConfig: NextConfig = { + output: "standalone", +{{#if (eq template "turborepo")}} + outputFileTracingRoot: monorepoRoot, + transpilePackages: ["@repo/database"], +{{/if}} +}; + +export default nextConfig; diff --git a/templates/create/next/package.json.hbs b/templates/create/next/package.json.hbs index 2a5336e..9cdd85b 100644 --- a/templates/create/next/package.json.hbs +++ b/templates/create/next/package.json.hbs @@ -1,18 +1,24 @@ { - "name": "{{projectName}}", + "name": "{{#if (eq template "turborepo")}}@repo/web{{else}}{{projectName}}{{/if}}", "version": "0.1.0", "private": true, + {{#unless (eq template "turborepo")}} {{#if (packageManagerManifestValue packageManager)}} "packageManager": "{{packageManagerManifestValue packageManager}}", {{/if}} + {{/unless}} "type": "module", "scripts": { "dev": "next dev", "build": "next build", "start": "next start", - "lint": "eslint" + "lint": "eslint", + "typecheck": "tsc --noEmit" }, "dependencies": { + {{#if (eq template "turborepo")}} + "@repo/database": "{{#if (eq packageManager "npm")}}*{{else}}workspace:*{{/if}}", + {{/if}} "next": "16.1.6", "react": "19.2.3", "react-dom": "19.2.3" diff --git a/templates/create/next/src/app/page.tsx.hbs b/templates/create/next/src/app/page.tsx.hbs index eaf1828..07015f9 100644 --- a/templates/create/next/src/app/page.tsx.hbs +++ b/templates/create/next/src/app/page.tsx.hbs @@ -2,7 +2,7 @@ export const dynamic = "force-dynamic"; export default async function Home() { - const { listUsers } = await import("../prisma/users"); + const { listUsers } = await import("{{#if (eq template "turborepo")}}@repo/database{{else}}../prisma/users{{/if}}"); const formatter = new Intl.DateTimeFormat("en", { dateStyle: "medium", timeStyle: "short", @@ -12,12 +12,12 @@ export default async function Home() { return (
-

Next.js + Prisma 8

+

{{#if (eq template "turborepo")}}Turborepo + {{/if}}Next.js + Prisma 8

Users from your database, loaded on the server.

- This page reads from src/app/page.tsx using the Prisma 8 helper in{" "} - src/prisma/users.ts. + This page reads from {{#if (eq template "turborepo")}}apps/web/{{/if}}src/app/page.tsx using the Prisma 8 helper in{" "} + {{#if (eq template "turborepo")}}packages/database/src/users.ts{{else}}src/prisma/users.ts{{/if}}.

diff --git a/templates/create/turborepo/.gitignore b/templates/create/turborepo/.gitignore new file mode 100644 index 0000000..18e47e2 --- /dev/null +++ b/templates/create/turborepo/.gitignore @@ -0,0 +1,10 @@ +node_modules +.turbo +apps/web/.next +apps/web/out +coverage +.env +.env.* +!.env.example +*.tsbuildinfo +.DS_Store diff --git a/templates/create/turborepo/.yarnrc.yml.hbs b/templates/create/turborepo/.yarnrc.yml.hbs new file mode 100644 index 0000000..faa4013 --- /dev/null +++ b/templates/create/turborepo/.yarnrc.yml.hbs @@ -0,0 +1,3 @@ +{{#if (eq packageManager "yarn")}} +nodeLinker: node-modules +{{/if}} diff --git a/templates/create/turborepo/package.json.hbs b/templates/create/turborepo/package.json.hbs new file mode 100644 index 0000000..8f63c5a --- /dev/null +++ b/templates/create/turborepo/package.json.hbs @@ -0,0 +1,21 @@ +{ + "name": "{{projectName}}", + "version": "0.1.0", + "private": true, + {{#if (packageManagerManifestValue packageManager)}} + "packageManager": "{{packageManagerManifestValue packageManager}}", + {{/if}} + "type": "module", + "workspaces": [ + "apps/*", + "packages/*" + ], + "scripts": { + "build": "turbo run build", + "dev": "turbo run dev --parallel", + "lint": "turbo run lint", + "start": "turbo run start --parallel", + "typecheck": "turbo run typecheck" + }, + "devDependencies": {} +} diff --git a/templates/create/turborepo/packages/database/package.json b/templates/create/turborepo/packages/database/package.json new file mode 100644 index 0000000..9aee6b2 --- /dev/null +++ b/templates/create/turborepo/packages/database/package.json @@ -0,0 +1,13 @@ +{ + "name": "@repo/database", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/users.ts", + "./db": "./src/db.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit --project tsconfig.json" + } +} diff --git a/templates/create/turborepo/packages/database/tsconfig.json b/templates/create/turborepo/packages/database/tsconfig.json new file mode 100644 index 0000000..a5cb75c --- /dev/null +++ b/templates/create/turborepo/packages/database/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src/**/*.ts"] +} diff --git a/templates/create/turborepo/tsconfig.json b/templates/create/turborepo/tsconfig.json new file mode 100644 index 0000000..5b74628 --- /dev/null +++ b/templates/create/turborepo/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "ESNext", + "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, + "noEmit": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true, + "types": ["node"] + }, + "include": [ + "module.ts", + "service.ts", + "prisma.config.ts", + "prisma-composer.config.ts", + "packages/**/*.ts" + ], + "exclude": ["node_modules"] +} diff --git a/templates/create/turborepo/turbo.json b/templates/create/turborepo/turbo.json new file mode 100644 index 0000000..4a8a491 --- /dev/null +++ b/templates/create/turborepo/turbo.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://turbo.build/schema.json", + "tasks": { + "build": { + "dependsOn": ["^build"], + "outputs": [".next/**", "!.next/cache/**", "dist/**"] + }, + "dev": { + "cache": false, + "persistent": true + }, + "lint": { + "dependsOn": ["^lint"] + }, + "start": { + "cache": false, + "dependsOn": ["build"], + "persistent": true + }, + "typecheck": { + "dependsOn": ["^typecheck"] + } + } +} diff --git a/tests/dependencies.test.ts b/tests/dependencies.test.ts index f6f021a..986d6cf 100644 --- a/tests/dependencies.test.ts +++ b/tests/dependencies.test.ts @@ -11,6 +11,7 @@ describe("Prisma 8 dependency versions", () => { expect(getDependencyVersion("prisma")).toBe("8.0.0-rc.12"); expect(getDependencyVersion("alchemy")).toBe("2.0.0-beta.74"); expect(getDependencyVersion("effect")).toBe("4.0.0-rc.112"); + expect(getDependencyVersion("turbo")).toBe("2.10.12"); }); test("returns undefined for dependencies missing from the version map", () => { diff --git a/tests/e2e/create-prisma.e2e.test.ts b/tests/e2e/create-prisma.e2e.test.ts index 5e56291..5a958d6 100644 --- a/tests/e2e/create-prisma.e2e.test.ts +++ b/tests/e2e/create-prisma.e2e.test.ts @@ -96,7 +96,13 @@ async function fetchUntilReady(url: string, deadline: number): Promise } } -async function verifyComposerDev(projectDir: string) { +async function verifyComposerDev( + projectDir: string, + verifyResponse: (response: Response) => Promise = async (response) => { + const body = (await response.json()) as { users: Array<{ name: string }> }; + expect(body.users.map((user) => user.name)).toEqual(["Alice", "Bob", "Carol"]); + }, +) { const process = Bun.spawn({ cmd: ["bun", "run", "dev:composer"], cwd: projectDir, @@ -139,8 +145,7 @@ async function verifyComposerDev(projectDir: string) { // retry connection refusals until the deadline. const response = await fetchUntilReady(appUrl, deadline); expect(response.status).toBe(200); - const body = (await response.json()) as { users: Array<{ name: string }> }; - expect(body.users.map((user) => user.name)).toEqual(["Alice", "Bob", "Carol"]); + await verifyResponse(response); return; } } finally { @@ -427,6 +432,68 @@ describe("create-prisma e2e", () => { TEST_TIMEOUT, ); + test( + "builds and runs a Composer-backed Turborepo monorepo", + async () => { + const rootDir = await mkdtemp(path.join(tmpdir(), "create-prisma-turborepo-e2e-")); + tempRoots.push(rootDir); + const previousCwd = process.cwd(); + process.chdir(rootDir); + try { + await runCreateCommand({ + name: "turborepo-app", + template: "turborepo", + provider: "postgres", + authoring: "psl", + packageManager: "bun", + deploy: false, + yes: true, + }); + } finally { + process.chdir(previousCwd); + } + + const projectDir = path.join(rootDir, "turborepo-app"); + const rootPackageJson = JSON.parse( + await readFile(path.join(projectDir, "package.json"), "utf8"), + ) as Record; + const webPackageJson = JSON.parse( + await readFile(path.join(projectDir, "apps/web/package.json"), "utf8"), + ) as Record; + const databasePackageJson = JSON.parse( + await readFile(path.join(projectDir, "packages/database/package.json"), "utf8"), + ) as Record; + const serviceSource = await readFile(path.join(projectDir, "service.ts"), "utf8"); + + expect(rootPackageJson.workspaces).toEqual(["apps/*", "packages/*"]); + expect(rootPackageJson.devDependencies.turbo).toBe("2.10.12"); + expect(webPackageJson.dependencies["@repo/database"]).toBe("workspace:*"); + expect(databasePackageJson.dependencies["@prisma/orm-postgres"]).toBe("8.0.0-rc.8"); + expect(databasePackageJson.dependencies["temporal-polyfill"]).toBe("^1.0.4"); + expect(serviceSource).toContain('appDir: "./apps/web"'); + expect(await pathExists(path.join(projectDir, "packages/database/src/contract.json"))).toBe( + true, + ); + expect(await pathExists(path.join(projectDir, "migrations/app"))).toBe(true); + + await runCommand(projectDir, ["bun", "run", "build"]); + await runCommand(projectDir, ["bun", "run", "typecheck"]); + + const requiredServerFiles = JSON.parse( + await readFile(path.join(projectDir, "apps/web/.next/required-server-files.json"), "utf8"), + ) as { relativeAppDir?: string }; + expect(requiredServerFiles.relativeAppDir).toBe("apps/web"); + + await verifyComposerDev(projectDir, async (response) => { + const body = await response.text(); + expect(body).toContain("Alice"); + expect(body).toContain("Bob"); + expect(body).toContain("Carol"); + }); + }, + TEST_TIMEOUT, + ); + test( "generates and checks a minimal Deno Prisma Postgres app", async () => { diff --git a/tests/install.test.ts b/tests/install.test.ts index a84bfb6..6a8ec32 100644 --- a/tests/install.test.ts +++ b/tests/install.test.ts @@ -18,6 +18,8 @@ import { } from "../src/utils/package-manager"; type PackageJson = { + name?: string; + workspaces?: string[]; dependencies?: Record; devDependencies?: Record; scripts?: Record; @@ -156,15 +158,13 @@ describe("generated templates", () => { await writeCreateTemplateDependencies({ template, packageManager, projectDir }); const packageJson = await readPackageJson(projectDir); - const dbSource = await readFile(path.join(projectDir, "src/prisma/db.ts"), "utf8"); - const seedSource = await readFile( - path.join(projectDir, "src/prisma/seed.ts"), - "utf8", - ); - const usersSource = await readFile( - path.join(projectDir, "src/prisma/users.ts"), - "utf8", + const prismaSourceDir = path.join( + projectDir, + template === "turborepo" ? "packages/database/src" : "src/prisma", ); + const dbSource = await readFile(path.join(prismaSourceDir, "db.ts"), "utf8"); + const seedSource = await readFile(path.join(prismaSourceDir, "seed.ts"), "utf8"); + const usersSource = await readFile(path.join(prismaSourceDir, "users.ts"), "utf8"); const tsconfig = await readFile(path.join(projectDir, "tsconfig.json"), "utf8"); if (packageManager === "deno") { @@ -209,9 +209,41 @@ describe("generated templates", () => { expect(seedSource).toContain("await connectDatabase()"); expect(seedSource).toContain("export function seed()"); expect(usersSource).toContain("await seed()"); - expect(await pathExists(path.join(projectDir, "src/prisma/starter-data.ts"))).toBe( - false, - ); + expect(await pathExists(path.join(prismaSourceDir, "starter-data.ts"))).toBe(false); + if (template === "turborepo") { + const webPackageJson = await readPackageJson(path.join(projectDir, "apps/web")); + const databasePackageJson = await readPackageJson( + path.join(projectDir, "packages/database"), + ); + const nextConfig = await readFile( + path.join(projectDir, "apps/web/next.config.ts"), + "utf8", + ); + const pageSource = await readFile( + path.join(projectDir, "apps/web/src/app/page.tsx"), + "utf8", + ); + + expect(packageJson.workspaces).toEqual(["apps/*", "packages/*"]); + expect(packageJson.devDependencies?.turbo).toBe(dependencyVersionMap.turbo); + expect(webPackageJson.name).toBe("@repo/web"); + expect(webPackageJson.dependencies?.["@repo/database"]).toBe( + packageManager === "npm" ? "*" : "workspace:*", + ); + expect(databasePackageJson.name).toBe("@repo/database"); + expect(databasePackageJson.devDependencies?.typescript).toBe( + dependencyVersionMap.typescript, + ); + expect(databasePackageJson.scripts).toEqual({ + typecheck: "tsc --noEmit --project tsconfig.json", + }); + expect(nextConfig).toContain("outputFileTracingRoot: monorepoRoot"); + expect(nextConfig).toContain('transpilePackages: ["@repo/database"]'); + expect(pageSource).toContain('import("@repo/database")'); + expect(serviceSource).toContain('appDir: "./apps/web"'); + expect(dbSource).toContain('import service from "../../../service.ts"'); + expect(await pathExists(path.join(projectDir, "src/prisma"))).toBe(false); + } if (template === "elysia") { const serverSource = await readFile(path.join(projectDir, "src/index.ts"), "utf8"); expect(serverSource).toContain('adapter: "Bun" in globalThis ? undefined : node()'); @@ -245,7 +277,7 @@ describe("generated templates", () => { expect(prismaConfig).toContain("connection: process.env.DATABASE_URL!"); expect(seedSource).toContain("conflictOn: { email: user.email }"); const composerSource = await readFile( - path.join(projectDir, "src/prisma/composer.ts"), + path.join(prismaSourceDir, "composer.ts"), "utf8", ); if (authoring === "typescript") { @@ -267,7 +299,13 @@ describe("generated templates", () => { expect(seedSource).not.toContain(".prisma-composer"); } if (authoring === "typescript") { - expect(prismaConfig).toContain('output: "./src/prisma/generated"'); + expect(prismaConfig).toContain( + `output: "./${ + template === "turborepo" + ? "packages/database/src/generated" + : "src/prisma/generated" + }"`, + ); expect(dbSource).toContain( 'import type { Contract } from "./generated/contract.d.ts";', ); @@ -282,13 +320,16 @@ describe("generated templates", () => { if (packageManager === "pnpm") { expect(packageJson.pnpm).toBeUndefined(); const frameworkBuildAllowances = - template === "next" + template === "next" || template === "turborepo" ? [" sharp: true", " unrs-resolver: true"] : template === "astro" ? [" sharp: true"] : []; expect(await readFile(path.join(projectDir, "pnpm-workspace.yaml"), "utf8")).toBe( [ + ...(template === "turborepo" + ? ["packages:", ' - "apps/*"', ' - "packages/*"'] + : []), "allowBuilds:", " esbuild: true", " msgpackr-extract: true",