diff --git a/README.md b/README.md index bf47382..27b7989 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ workspace. Choosing another workspace also updates the Prisma CLI's active works - `elysia` - `nest` - `next` +- `turborepo` (Hello World Node.js server in `apps/server`, shared Prisma package in `packages/database`) - `svelte` (SvelteKit) - `astro` - `nuxt` diff --git a/src/commands/create-context.ts b/src/commands/create-context.ts index 3cd7b47..9b3332e 100644 --- a/src/commands/create-context.ts +++ b/src/commands/create-context.ts @@ -89,6 +89,11 @@ const promptForCreateTemplate = Effect.fn("Prompts.template")(function* (output: hint: "Structured Node API with controllers and services", }, { value: "next", label: "Next.js", hint: "Full-stack React app with App Router" }, + { + value: "turborepo", + label: "Monorepo (Turborepo)", + hint: "Hello World server with a shared Prisma database package", + }, { value: "svelte", label: "SvelteKit", hint: "Full-stack Svelte 5 app with Vite" }, { value: "astro", label: "Astro", hint: "Content-oriented web app with server routes" }, { value: "nuxt", label: "Nuxt", hint: "Full-stack Vue app with Nitro server routes" }, diff --git a/src/constants/dependencies.ts b/src/constants/dependencies.ts index 7413abe..0124ccd 100644 --- a/src/constants/dependencies.ts +++ b/src/constants/dependencies.ts @@ -22,6 +22,7 @@ export const dependencyVersionMap = { // stable Node or Bun ships yet. "temporal-polyfill": "^1.0.4", tsdown: "^0.22.14", + turbo: "2.10.12", tsx: "^4.21.0", typescript: "^5.9.3", } as const; @@ -54,7 +55,7 @@ function usesTsdown(template: CreateTemplate): boolean { export function getCreateTemplateDependencies( template: CreateTemplate, - _packageManager: PackageManager, + packageManager: PackageManager, ): CreateTemplateDependencyTarget[] { const dependencies = ["@prisma/composer", "@prisma/composer-prisma-cloud", "alchemy"]; const devDependencies: string[] = []; @@ -79,12 +80,29 @@ export function getCreateTemplateDependencies( if (template === "tanstack-start") { devDependencies.push("nitro"); } + if (template === "turborepo") devDependencies.push("turbo", "typescript"); - return [ + const targets: CreateTemplateDependencyTarget[] = [ { packageJsonPath: "package.json", dependencies, devDependencies, }, ]; + if (template === "turborepo") { + targets.push({ + packageJsonPath: "apps/server/package.json", + dependencies: [], + devDependencies: ["@types/node", "tsdown", "tsx", "typescript"], + customDependencies: { + "@repo/database": packageManager === "npm" ? "*" : "workspace:*", + }, + }); + targets.push({ + packageJsonPath: "packages/database/package.json", + dependencies: [], + devDependencies: ["typescript"], + }); + } + return targets; } diff --git a/src/tasks/install.ts b/src/tasks/install.ts index 9c87981..92085b0 100644 --- a/src/tasks/install.ts +++ b/src/tasks/install.ts @@ -161,11 +161,17 @@ export const writePrismaDependenciesEffect = Effect.fn("Dependencies.writePrisma packageManager: PackageManager, _authoring: AuthoringStyle, projectDir = process.cwd(), - options: { skillsSync?: boolean } = {}, + options: { skillsSync?: boolean; template?: CreateTemplate } = {}, ) { - const dependencies = [getDbPackages(provider)]; - if (provider === "postgres" && packageManager !== "deno") dependencies.push("temporal-polyfill"); - if (provider === "mongo") dependencies.push("arktype", "mongodb"); + const databaseDependencies = [getDbPackages(provider)]; + if (provider === "postgres" && packageManager !== "deno") { + databaseDependencies.push("temporal-polyfill"); + } + if (provider === "mongo") databaseDependencies.push("arktype", "mongodb"); + const dependencies = + options.template === "turborepo" + ? [getDbPackages(provider), ...(provider === "mongo" ? ["arktype"] : [])] + : [...databaseDependencies]; if (packageManager === "deno") dependencies.push("dotenv"); yield* addPackageDependencyEffect({ dependencies, @@ -173,6 +179,12 @@ export const writePrismaDependenciesEffect = Effect.fn("Dependencies.writePrisma scripts: getPrismaScriptMap(packageManager, options.skillsSync ?? true), projectDir, }); + if (options.template === "turborepo") { + yield* addPackageDependencyEffect({ + dependencies: databaseDependencies, + projectDir: path.join(projectDir, "packages/database"), + }); + } }); export const writeCreateTemplateDependenciesEffect = Effect.fn("Dependencies.writeTemplate")( @@ -189,7 +201,10 @@ export const writeCreateTemplateDependenciesEffect = Effect.fn("Dependencies.wri dependencies: target.dependencies, devDependencies: target.devDependencies, customDependencies: target.customDependencies, - scripts: getComposerScriptMap(opts.packageManager), + scripts: + target.packageJsonPath === "package.json" + ? getComposerScriptMap(opts.packageManager) + : undefined, projectDir: path.join(projectDir, path.dirname(target.packageJsonPath)), }); } diff --git a/src/tasks/prisma-setup/commands.ts b/src/tasks/prisma-setup/commands.ts index 017675b..ce32a23 100644 --- a/src/tasks/prisma-setup/commands.ts +++ b/src/tasks/prisma-setup/commands.ts @@ -2,14 +2,15 @@ import { log } from "@clack/prompts"; import { Effect, FileSystem } from "effect"; import path from "node:path"; -import type { AuthoringStyle, DatabaseProvider } from "../../types"; +import { getCreatePrismaSourceDir } from "../../templates/render-create-template"; +import type { AuthoringStyle, CreateTemplate, DatabaseProvider } from "../../types"; import { getLocalPackageBinaryArgs } from "../../utils/package-manager"; import { redactSecrets } from "../../utils/errors"; import { runPrismaJsonCommandEffect } from "../prisma-cli"; import type { PrismaSetupContext } from "./types"; -const getContractPath = (authoring: AuthoringStyle) => - `src/prisma/contract${authoring === "typescript" ? ".ts" : ".prisma"}`; +const getContractPath = (authoring: AuthoringStyle, template: CreateTemplate) => + `${getCreatePrismaSourceDir(template)}/contract${authoring === "typescript" ? ".ts" : ".prisma"}`; const getInitTarget = (provider: DatabaseProvider) => provider === "mongo" ? ("mongodb" as const) : ("postgres" as const); @@ -43,6 +44,7 @@ export const runPrismaInit = Effect.fn("PrismaSetup.init")(function* ( context: PrismaSetupContext, projectDir: string, force = false, + template: CreateTemplate = "minimal", ) { yield* runPrismaCli(context, projectDir, [ "orm", @@ -54,7 +56,7 @@ export const runPrismaInit = Effect.fn("PrismaSetup.init")(function* ( "--authoring", context.authoring, "--schema-path", - getContractPath(context.authoring), + getContractPath(context.authoring, template), "--skip-install", ]); if (context.packageManager === "deno") { diff --git a/src/tasks/setup-prisma.ts b/src/tasks/setup-prisma.ts index c368828..83a1364 100644 --- a/src/tasks/setup-prisma.ts +++ b/src/tasks/setup-prisma.ts @@ -63,7 +63,7 @@ export const executePrismaSetupContextEffect = Effect.fn("PrismaSetup.execute")( context.packageManager, context.authoring, projectDir, - { skillsSync: context.skillAgents.length > 0 }, + { skillsSync: context.skillAgents.length > 0, template }, ), "configure_project", "project_configuration_failed", @@ -85,7 +85,7 @@ export const executePrismaSetupContextEffect = Effect.fn("PrismaSetup.execute")( yield* Effect.sync(() => progress?.message("Preparing Prisma 8 project files...")); yield* atCreateStage( - runPrismaInit(context, projectDir, options.force), + runPrismaInit(context, projectDir, options.force, template), "initialize_prisma", "prisma_init_failed", ); diff --git a/src/templates/render-create-template.ts b/src/templates/render-create-template.ts index eb23e93..587c806 100644 --- a/src/templates/render-create-template.ts +++ b/src/templates/render-create-template.ts @@ -1,4 +1,5 @@ import { Effect } from "effect"; +import path from "node:path"; import { applicationRuntime } from "../runtime"; import { @@ -11,6 +12,13 @@ import { } from "../types"; import { renderTemplateTreeEffect, resolveTemplatesDirEffect } from "./shared"; +const DEFAULT_PRISMA_SOURCE_DIR = "src/prisma"; +const TURBOREPO_PRISMA_SOURCE_DIR = "packages/database/src"; + +export function getCreatePrismaSourceDir(template: CreateTemplate): string { + return template === "turborepo" ? TURBOREPO_PRISMA_SOURCE_DIR : DEFAULT_PRISMA_SOURCE_DIR; +} + type CreateTemplateContext = { projectName: string; template: CreateTemplate; @@ -37,6 +45,7 @@ const tsdownEntries: Partial> = { hono: "src/index.ts", elysia: "src/index.ts", nest: "src/main.ts", + turborepo: "src/index.ts", }; function createTemplateContext(options: ScaffoldCreateTemplateOptions): CreateTemplateContext { @@ -59,6 +68,20 @@ export const scaffoldCreateSharedTemplatesEffect = Effect.fn("Templates.scaffold templateRoot, outputDir: options.projectDir, context: createTemplateContext(options), + mapRelativeOutputPath(relativePath) { + if (options.template !== "turborepo") return relativePath; + if (relativePath === "tsdown.config.ts") { + return path.join("apps/server", relativePath); + } + const relativePrismaPath = path.relative(DEFAULT_PRISMA_SOURCE_DIR, relativePath); + if ( + relativePrismaPath === ".." || + relativePrismaPath.startsWith(`..${path.sep}`) || + path.isAbsolute(relativePrismaPath) + ) + return relativePath; + return path.join(TURBOREPO_PRISMA_SOURCE_DIR, relativePrismaPath); + }, }); }); diff --git a/src/templates/shared.ts b/src/templates/shared.ts index 7d6147a..6829d98 100644 --- a/src/templates/shared.ts +++ b/src/templates/shared.ts @@ -92,7 +92,12 @@ export const renderTemplateFileEffect = Effect.fn("Templates.renderFile")(functi export const renderTemplateTreeEffect = Effect.fn("Templates.renderTree")(function* < TContext, ->(opts: { templateRoot: string; outputDir: string; context: TContext }) { +>(opts: { + templateRoot: string; + outputDir: string; + context: TContext; + mapRelativeOutputPath?: (relativePath: string) => string; +}) { const fs = yield* FileSystem.FileSystem; const entries = yield* fs.readDirectory(opts.templateRoot, { recursive: true }); @@ -100,9 +105,13 @@ export const renderTemplateTreeEffect = Effect.fn("Templates.renderTree")(functi const templateFilePath = path.join(opts.templateRoot, relativePath); const info = yield* fs.stat(templateFilePath); if (info.type !== "File") continue; + const renderedRelativePath = stripHbsExtension(relativePath); yield* renderTemplateFileEffect({ templateFilePath, - outputPath: path.join(opts.outputDir, stripHbsExtension(relativePath)), + outputPath: path.join( + opts.outputDir, + opts.mapRelativeOutputPath?.(renderedRelativePath) ?? renderedRelativePath, + ), context: opts.context, }); } diff --git a/src/types.ts b/src/types.ts index 74b805d..39ffd38 100644 --- a/src/types.ts +++ b/src/types.ts @@ -12,6 +12,7 @@ export const createTemplates = [ "elysia", "nest", "next", + "turborepo", "svelte", "astro", "nuxt", diff --git a/templates/create/_package-manager/pnpm-workspace.yaml.hbs b/templates/create/_package-manager/pnpm-workspace.yaml.hbs index ca1c4ac..7340f76 100644 --- a/templates/create/_package-manager/pnpm-workspace.yaml.hbs +++ b/templates/create/_package-manager/pnpm-workspace.yaml.hbs @@ -1,4 +1,9 @@ {{#if (eq packageManager "pnpm")}} +{{#if (eq template "turborepo")}} +packages: + - "apps/*" + - "packages/*" +{{/if}} allowBuilds: esbuild: true msgpackr-extract: true 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..6563790 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 minimal Prisma 8 monorepo with Turborepo and a plain Node.js HTTP server. + +## Workspace layout + +- `apps/server` — Hello World server; `/users` reads from the shared database package +- `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 builds the server with tsdown, provisions Prisma Postgres when selected, applies migrations, and deploys the server to Prisma Compute. + +The home page returns `Hello World!` without querying the database. Visiting `/users` queries the shared Prisma package and inserts starter users idempotently from `packages/database/src/seed.ts`. + +{{#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/prisma.config.ts.hbs b/templates/create/_shared/prisma.config.ts.hbs index 7f51ab7..a46fefd 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: [{{#each skillAgents}}"{{this}}"{{#unless @last}}, {{/unless}}{{/each}}], }, 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..1a7d6be 100644 --- a/templates/create/_shared/service.ts.hbs +++ b/templates/create/_shared/service.ts.hbs @@ -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: node({ module: import.meta.url, entry: "./apps/server/dist/server.mjs" }), {{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/turborepo/.gitignore b/templates/create/turborepo/.gitignore new file mode 100644 index 0000000..db4bf34 --- /dev/null +++ b/templates/create/turborepo/.gitignore @@ -0,0 +1,9 @@ +node_modules +.turbo +dist +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/apps/server/package.json b/templates/create/turborepo/apps/server/package.json new file mode 100644 index 0000000..f603390 --- /dev/null +++ b/templates/create/turborepo/apps/server/package.json @@ -0,0 +1,15 @@ +{ + "name": "@repo/server", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsdown", + "start": "node dist/server.mjs", + "typecheck": "tsc --noEmit" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } +} diff --git a/templates/create/turborepo/apps/server/src/index.ts b/templates/create/turborepo/apps/server/src/index.ts new file mode 100644 index 0000000..f666206 --- /dev/null +++ b/templates/create/turborepo/apps/server/src/index.ts @@ -0,0 +1,31 @@ +import { createServer } from "node:http"; + +import { listUsers } from "@repo/database"; + +const port = Number(process.env.PORT ?? 3000); + +createServer(async (request, response) => { + if (request.url === "/") { + response.writeHead(200, { "content-type": "text/plain" }); + response.end("Hello World!"); + return; + } + + if (request.url !== "/users") { + response.writeHead(404, { "content-type": "text/plain" }); + response.end("Not found"); + return; + } + + try { + const users = await listUsers(); + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ users })); + } catch (error) { + console.error("Failed to query users:", error); + response.writeHead(500, { "content-type": "application/json" }); + response.end(JSON.stringify({ error: "Could not query users yet." })); + } +}).listen(port, "0.0.0.0", () => { + console.log(`Server running at http://localhost:${port}`); +}); diff --git a/templates/create/turborepo/apps/server/tsconfig.json b/templates/create/turborepo/apps/server/tsconfig.json new file mode 100644 index 0000000..3b5cc40 --- /dev/null +++ b/templates/create/turborepo/apps/server/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src/**/*.ts", "tsdown.config.ts"] +} diff --git a/templates/create/turborepo/package.json.hbs b/templates/create/turborepo/package.json.hbs new file mode 100644 index 0000000..459a7b8 --- /dev/null +++ b/templates/create/turborepo/package.json.hbs @@ -0,0 +1,20 @@ +{ + "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", + "start": "turbo run start", + "typecheck": "tsc --noEmit && 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..309a197 --- /dev/null +++ b/templates/create/turborepo/turbo.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://turbo.build/schema.json", + "globalDependencies": ["service.ts", "packages/database/src/**"], + "globalPassThroughEnv": ["PORT", "DATABASE_URL", "MONGODB_URL"], + "tasks": { + "build": { + "dependsOn": ["^build"], + "outputs": ["dist/**"] + }, + "dev": { + "cache": false, + "persistent": true + }, + "start": { + "cache": false, + "dependsOn": ["build"], + "persistent": true + }, + "typecheck": { + "dependsOn": ["^typecheck"] + } + } +} diff --git a/tests/dependencies.test.ts b/tests/dependencies.test.ts index 3a4b68d..9f81ff5 100644 --- a/tests/dependencies.test.ts +++ b/tests/dependencies.test.ts @@ -11,6 +11,7 @@ describe("Prisma 8 dependency versions", () => { expect(getDependencyVersion("prisma")).toBe("latest"); 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 2237017..0ed040a 100644 --- a/tests/e2e/create-prisma.e2e.test.ts +++ b/tests/e2e/create-prisma.e2e.test.ts @@ -140,10 +140,12 @@ async function verifyBuiltServer(projectDir: string, port: number, expectedStatu new Response(process.stderr).text(), ]); let failure: unknown; + let body = ""; try { const response = await fetchUntilResponding(`http://127.0.0.1:${port}`, Date.now() + 10_000); expect(response.status).toBe(expectedStatus); + body = await response.text(); } catch (error) { failure = error; } finally { @@ -157,9 +159,10 @@ async function verifyBuiltServer(projectDir: string, port: number, expectedStatu cause: failure, }); } + return body; } -async function verifyComposerDev(projectDir: string) { +async function verifyComposerDev(projectDir: string, usersPath = "") { const process = Bun.spawn({ cmd: ["bun", "run", "dev:composer"], cwd: projectDir, @@ -200,7 +203,7 @@ async function verifyComposerDev(projectDir: string) { // The endpoint frame can precede the app process binding its port, so // retry connection refusals until the deadline. - const response = await fetchUntilReady(appUrl, deadline); + const response = await fetchUntilReady(`${appUrl}${usersPath}`, 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"]); @@ -567,9 +570,12 @@ describe("create-prisma e2e", () => { TEST_TIMEOUT, ); - test( - "builds a Next.js app with a TypeScript-authored contract", - async () => { + test.each([ + ["Next.js", "next"], + ["Turborepo", "turborepo"], + ] as const)( + "builds a %s app with a TypeScript-authored contract", + async (_label, template) => { const rootDir = await mkdtemp(path.join(tmpdir(), "create-prisma-next-typescript-e2e-")); tempRoots.push(rootDir); const previousCwd = process.cwd(); @@ -577,7 +583,7 @@ describe("create-prisma e2e", () => { try { await runCreateCommand({ name: "next-typescript-app", - template: "next", + template, provider: "postgres", authoring: "typescript", packageManager: "bun", @@ -589,22 +595,23 @@ describe("create-prisma e2e", () => { } const projectDir = path.join(rootDir, "next-typescript-app"); + const prismaSourceDir = template === "turborepo" ? "packages/database/src" : "src/prisma"; const composerSource = await readFile( - path.join(projectDir, "src/prisma/composer.ts"), + path.join(projectDir, prismaSourceDir, "composer.ts"), "utf8", ); - const dbSource = await readFile(path.join(projectDir, "src/prisma/db.ts"), "utf8"); + const dbSource = await readFile(path.join(projectDir, prismaSourceDir, "db.ts"), "utf8"); expect(composerSource).toContain( 'import type { Contract } from "./generated/contract.d.ts";', ); expect(dbSource).toContain('import type { Contract } from "./generated/contract.d.ts";'); - expect(await pathExists(path.join(projectDir, "src/prisma/generated/contract.json"))).toBe( - true, - ); - expect(await pathExists(path.join(projectDir, "src/prisma/generated/contract.d.ts"))).toBe( - true, - ); + expect( + await pathExists(path.join(projectDir, prismaSourceDir, "generated/contract.json")), + ).toBe(true); + expect( + await pathExists(path.join(projectDir, prismaSourceDir, "generated/contract.d.ts")), + ).toBe(true); expect(await pathExists(path.join(projectDir, "prisma.config.ts"))).toBe(true); expect(await pathExists(path.join(projectDir, "migrations/app"))).toBe(true); expect( @@ -620,6 +627,13 @@ describe("create-prisma e2e", () => { await runCommand(projectDir, ["bun", "run", "build"]); await runCommand(projectDir, ["bunx", "tsc", "--noEmit"]); + if (template === "turborepo") { + await runCommand(projectDir, ["bun", "run", "typecheck"]); + const serverDir = path.join(projectDir, "apps/server"); + expect(await readdir(path.join(serverDir, "dist"))).toEqual(["server.mjs"]); + expect(await verifyBuiltServer(serverDir, 46_150, 200)).toBe("Hello World!"); + if (process.platform !== "win32") await verifyComposerDev(projectDir, "/users"); + } }, TEST_TIMEOUT, ); diff --git a/tests/install.test.ts b/tests/install.test.ts index f984ca8..c321fa6 100644 --- a/tests/install.test.ts +++ b/tests/install.test.ts @@ -23,6 +23,8 @@ import { } from "../src/utils/package-manager"; type PackageJson = { + name?: string; + workspaces?: string[]; packageManager?: string; dependencies?: Record; devDependencies?: Record; @@ -274,13 +276,18 @@ describe("generated templates", () => { if (packageManager === "bun") { expect(packageJson.packageManager).toBe("bun@1.4.1"); } - const dbSource = await readFile(path.join(projectDir, "src/prisma/db.ts"), "utf8"); + const prismaSourceRelative = + template === "turborepo" ? "packages/database/src" : "src/prisma"; + const dbSource = await readFile( + path.join(projectDir, prismaSourceRelative, "db.ts"), + "utf8", + ); const seedSource = await readFile( - path.join(projectDir, "src/prisma/seed.ts"), + path.join(projectDir, prismaSourceRelative, "seed.ts"), "utf8", ); const usersSource = await readFile( - path.join(projectDir, "src/prisma/users.ts"), + path.join(projectDir, prismaSourceRelative, "users.ts"), "utf8", ); const tsconfig = await readFile(path.join(projectDir, "tsconfig.json"), "utf8"); @@ -330,9 +337,64 @@ 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(projectDir, prismaSourceRelative, "starter-data.ts")), + ).toBe(false); + if (template === "turborepo") { + await writePrismaDependencies(provider, packageManager, authoring, projectDir, { + template, + }); + const server = await readPackageJson(path.join(projectDir, "apps/server")); + const database = await readPackageJson(path.join(projectDir, "packages/database")); + const root = await readPackageJson(projectDir); + expect(packageJson.workspaces).toEqual(["apps/*", "packages/*"]); + expect(packageJson.devDependencies?.turbo).toBe(dependencyVersionMap.turbo); + expect(server.name).toBe("@repo/server"); + expect(server.dependencies?.["@repo/database"]).toBe( + packageManager === "npm" ? "*" : "workspace:*", + ); + expect(database.name).toBe("@repo/database"); + expect(database.dependencies).toHaveProperty( + provider === "postgres" ? "@prisma/orm-postgres" : "@prisma/orm-mongo", + ); + expect(database.dependencies?.["temporal-polyfill"]).toBe( + provider === "postgres" ? dependencyVersionMap["temporal-polyfill"] : undefined, + ); + for (const name of ["arktype", "mongodb"] as const) { + expect(database.dependencies?.[name]).toBe( + provider === "mongo" ? dependencyVersionMap[name] : undefined, + ); + expect(root.dependencies?.[name]).toBe( + provider === "mongo" && name === "arktype" + ? dependencyVersionMap.arktype + : undefined, + ); + } + expect(root.dependencies?.["temporal-polyfill"]).toBeUndefined(); + expect(root.dependencies).toHaveProperty( + provider === "postgres" ? "@prisma/orm-postgres" : "@prisma/orm-mongo", + ); + expect(database.scripts).toEqual({ + typecheck: "tsc --noEmit --project tsconfig.json", + }); + expect(serviceSource).toContain('entry: "./apps/server/dist/server.mjs"'); + expect(serviceSource).not.toContain("nextjs"); + expect(server.scripts?.build).toBe("tsdown"); + expect(server.devDependencies?.tsdown).toBe(dependencyVersionMap.tsdown); + expect(await pathExists(path.join(projectDir, "apps/web"))).toBe(false); + for (const manifest of [root, server, database]) { + expect(manifest.dependencies?.next).toBeUndefined(); + expect(manifest.dependencies?.react).toBeUndefined(); + } + expect( + await readFile(path.join(projectDir, "apps/server/tsdown.config.ts"), "utf8"), + ).toContain('entry: { server: "src/index.ts" }'); + expect( + await readFile(path.join(projectDir, "apps/server/src/index.ts"), "utf8"), + ).toContain('response.end("Hello World!")'); + expect(dbSource).toContain('import service from "../../../service.ts"'); + expect(await pathExists(path.join(projectDir, "src/prisma"))).toBe(false); + } if (tsdownTemplates.has(template)) { expect(packageJson.engines?.node).toBe("^22.18.0 || >=24.11.0"); expect(packageJson.scripts?.build).toBe("tsdown"); @@ -389,7 +451,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(projectDir, prismaSourceRelative, "composer.ts"), "utf8", ); if (authoring === "typescript") { @@ -411,7 +473,7 @@ describe("generated templates", () => { expect(seedSource).not.toContain(".prisma-composer"); } if (authoring === "typescript") { - expect(prismaConfig).toContain('output: "./src/prisma/generated"'); + expect(prismaConfig).toContain(`output: "./${prismaSourceRelative}/generated"`); expect(dbSource).toContain( 'import type { Contract } from "./generated/contract.d.ts";', ); @@ -433,6 +495,9 @@ describe("generated templates", () => { : []; expect(await readFile(path.join(projectDir, "pnpm-workspace.yaml"), "utf8")).toBe( [ + ...(template === "turborepo" + ? ["packages:", ' - "apps/*"', ' - "packages/*"'] + : []), "allowBuilds:", " esbuild: true", " msgpackr-extract: true",