Node Prisma migrations walkthrough

A newsletter signup app walks one PostgreSQL database through an additive Prisma change and a populated table drop without losing the subscriber list. Tokay runs it as one Web Service and rehearses each migration against a copy of your data first.

Last updated

Use this walkthrough when you want to see the difference between a routine schema update and a release that deserves a human decision. Three Git tags hold the versions, and the setup below turns them into three commits in one standalone repository.

The download contains the final version for inspection. Start the full walkthrough with the Git setup below so you can deploy all three versions in order.

Runtime
Node.js
Framework
Express and Prisma
Service types
Web Service
Resources
PostgreSQL
Required secrets
None

What it does

The Field Notes page collects a name and email, rejects duplicate addresses, and lists the people already reading.

The second version adds Confirm signup beside each unconfirmed reader. Your existing names remain while the new field begins empty.

The final version removes page visit tracking as a privacy cleanup. The page keeps subscriber and confirmation data because those records live in a different table.

Deploy it

Bring this folder to Tokay any way you like. Paste or upload it in the dashboard, push it with git, or hand it to your AI agent along with our agent instructions. Tokay figures out the rest.

Working in Claude, ChatGPT, VS Code, or another MCP client? Connect the Tokay MCP server and ask your agent to deploy this example. It signs in through your browser, so there is no token to paste.

New to Tokay? The getting started guide walks you through your first deploy.

Prepare the walkthrough repository

The Tokay examples repository contains many apps. Extract this example at v1 into its own repository before you push it, so Tokay sees only the newsletter app.

git clone https://github.com/tokayio/tokay-examples.git tokay-examples-source
mkdir prisma-walkthrough
git -C tokay-examples-source archive v1 node-prisma-migrations-walkthrough \
  | tar -x -C prisma-walkthrough --strip-components=1
git -C prisma-walkthrough init -b main
git -C prisma-walkthrough add README.md package.json prisma.config.ts prisma src tsconfig.json
git -C prisma-walkthrough commit -m "Start newsletter signups"

Keep tokay-examples-source beside prisma-walkthrough. The later steps copy v2-additive and v3-destructive from that clone.

Start the walkthrough with v1

  1. From the prepared prisma-walkthrough repository, choose Add code, then Push with Git in Tokay.
  2. Create a repository and follow the Git setup commands shown by Tokay.
  3. Push the v1 commit to the remote shown in those instructions.
git push tokay main
  1. Choose the detected Web Service and a Project, then choose Deploy.
  2. Open the walkthrough Service's Environment page. Confirm the app database for DATABASE_URL.
  3. When DATABASE_URL says Good to go, follow the deployment tracker and choose Go Live if that action appears.

Try it

  1. Open Field Notes and add yourself. From prisma-walkthrough, replace its files with the additive version, commit them, and push.
git -C ../tokay-examples-source archive v2-additive node-prisma-migrations-walkthrough \
  | tar -x --strip-components=1
git add README.md package.json prisma.config.ts prisma src tsconfig.json
git commit -m "Add signup confirmation"
git push tokay main

Confirm your existing signup after the safe migration goes live.

  1. Open Project Settings and change Migration Safety to Strict. Replace the files with the destructive version, commit them, and push.
git -C ../tokay-examples-source archive v3-destructive node-prisma-migrations-walkthrough \
  | tar -x --strip-components=1
git add README.md package.json prisma.config.ts prisma src tsconfig.json
git commit -m "Remove page visit tracking"
git push tokay main
  1. Choose Review release and inspect the populated public.PageVisit drop. Apply it with downtime, then reopen Field Notes and confirm your signup remains. In a production Project, the release also shows its restore point under Database snapshots.

An agent sees the same stop as ACTION_REQUIRED with action CONFIRM_RELEASE. The agent must show you the rehearsal evidence and wait for your decision.

What protects your data

The Prisma migration files stay with the code, but each change is tested against a copy of your data before production. This lets you see what the migration will do with the data shape you actually have.

The v2-additive migration only adds a nullable field. Under Normal mode, the successful rehearsal lets this release continue automatically.

Before v3-destructive, you change Migration Safety to Strict. The rehearsal finds a populated table drop and pauses the release. Your production database has not changed at this point.

If you confirm, Tokay takes a restorable production snapshot, applies the migration during the stated downtime window, and starts the new version. The snapshot gives you an explicit recovery option if the result is not what you expected.

How it works

The newsletter page lets you create and confirm signups while Prisma stores them in PostgreSQL. One fictional signup appears only when the list is empty, so it does not return after you add real data.

Every database shape is committed under prisma/migrations. The three Git tags let the same app and database move through a safe additive change and a deliberate destructive change.

You may notice a placeholder URL in prisma.config.ts. Prisma 7 requires a URL while generating the client, even though that step never connects to a database. Migrations and the running app always receive the real DATABASE_URL from Tokay.

Secrets

You do not need to create a secret. Tokay supplies DATABASE_URL from the managed PostgreSQL database you confirm.

Grow from here

Try the Streamlit support dashboard when you want a private team app in front of managed data.

prisma/migrations/20260719090000_init/migration.sql Raw

CREATE TABLE "Signup" (
  "id" UUID NOT NULL DEFAULT gen_random_uuid(),
  "name" VARCHAR(100) NOT NULL,
  "email" VARCHAR(254) NOT NULL,
  "createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

  CONSTRAINT "Signup_pkey" PRIMARY KEY ("id")
);

CREATE TABLE "PageVisit" (
  "id" BIGSERIAL NOT NULL,
  "path" VARCHAR(200) NOT NULL,
  "visitedAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

  CONSTRAINT "PageVisit_pkey" PRIMARY KEY ("id")
);

CREATE UNIQUE INDEX "Signup_email_key" ON "Signup"("email");

prisma/migrations/20260719100000_add_confirmation/migration.sql Raw

ALTER TABLE "Signup" ADD COLUMN "confirmedAt" TIMESTAMPTZ(3);

prisma/migrations/20260719110000_remove_page_visits/migration.sql Raw

DROP TABLE "PageVisit";

prisma/migrations/migration_lock.toml Raw

provider = "postgresql"

prisma/schema.prisma Raw

generator client {
  provider = "prisma-client"
  output   = "../src/generated/prisma"
}

datasource db {
  provider = "postgresql"
}

model Signup {
  id          String    @id @default(uuid()) @db.Uuid
  name        String    @db.VarChar(100)
  email       String    @unique @db.VarChar(254)
  createdAt   DateTime  @default(now()) @db.Timestamptz(3)
  confirmedAt DateTime? @db.Timestamptz(3)
}

src/server.ts Raw

import process from "node:process"
import { PrismaPg } from "@prisma/adapter-pg"
import express from "express"
import { Prisma, PrismaClient } from "./generated/prisma/client"

const databaseUrl = process.env.DATABASE_URL
if (!databaseUrl) throw new Error("DATABASE_URL is required")

const adapter = new PrismaPg({ connectionString: databaseUrl })
const prisma = new PrismaClient({ adapter })
const app = express()
const port = Number(process.env.PORT || 3000)

function escapeHtml(value: unknown) {
  const entities: Record<string, string> = {
    "&": "&amp;",
    "<": "&lt;",
    ">": "&gt;",
    "'": "&#39;",
    '"': "&quot;",
  }
  return String(value).replace(/[&<>'"]/g, character => entities[character])
}

function formatDate(value: Date) {
  return new Intl.DateTimeFormat("en", {
    month: "short",
    day: "numeric",
    year: "numeric",
    timeZone: "UTC",
  }).format(value)
}

async function seed() {
  if (await prisma.signup.count() === 0) {
    await prisma.signup.create({
      data: {
        name: "Mina Shah",
        email: "mina@example.test",
      },
    })
  }
}

app.use(express.urlencoded({ extended: false }))

app.get("/health", async (_request, response) => {
  await prisma.$queryRaw`SELECT 1`
  response.json({ ok: true })
})

app.get("/", async (_request, response) => {
  const signups = await prisma.signup.findMany({ orderBy: { createdAt: "desc" } })
  const cards = signups.map(signup => `
    <li>
      <span>${escapeHtml(signup.name).slice(0, 1).toUpperCase()}</span>
      <div><strong>${escapeHtml(signup.name)}</strong><small>${escapeHtml(signup.email)}</small></div>
      <div class="status">${signup.confirmedAt
        ? `<b>Confirmed</b><time>${formatDate(signup.confirmedAt)}</time>`
        : `<form action="/signups/${encodeURIComponent(signup.id)}/confirm" method="post"><button class="confirm">Confirm signup</button></form>`
      }</div>
    </li>
  `).join("")

  response.send(`<!doctype html>
    <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Field Notes</title>
    <style>
      :root{font-family:ui-sans-serif,system-ui,sans-serif;background:#f2efe8;color:#25312d}*{box-sizing:border-box}body{margin:0}main{width:min(1060px,calc(100% - 36px));margin:auto;padding:32px 0 90px}nav{display:flex;justify-content:space-between;align-items:center;padding-bottom:28px;border-bottom:1px solid #c8c4ba}nav strong{font:600 1.25rem Georgia,serif}nav span{color:#65716b;font-size:.84rem}.hero{display:grid;grid-template-columns:1.2fr .8fr;gap:64px;padding:76px 0 56px}.eyebrow{color:#bd553e;font-size:.72rem;font-weight:800;letter-spacing:.14em;text-transform:uppercase}h1{max-width:680px;margin:12px 0 22px;font:500 clamp(3.3rem,8vw,7.4rem)/.88 Georgia,serif;letter-spacing:-.055em}.intro{max-width:580px;color:#59645f;font-size:1.06rem;line-height:1.6}.panel{align-self:end;padding:27px;border-radius:18px;background:#fffdf8;box-shadow:0 20px 55px #5c514314}.panel h2{margin:0 0 7px;font:500 1.85rem Georgia,serif}.panel p{margin:0 0 20px;color:#6c756f;line-height:1.5}label{display:block;margin:14px 0 6px;font-size:.76rem;font-weight:800}input,button{width:100%;padding:12px 13px;border:1px solid #c9c5bb;border-radius:8px;background:white;font:inherit}button{margin-top:18px;border:0;background:#305f50;color:white;font-weight:800;cursor:pointer}.list-head{display:flex;justify-content:space-between;align-items:end;margin-bottom:14px}.list-head h2{margin:0;font:500 2rem Georgia,serif}.list-head span{color:#69736e}ul{display:grid;gap:10px;margin:0;padding:0;list-style:none}li{display:grid;grid-template-columns:44px 1fr auto;gap:14px;align-items:center;padding:15px 18px;border-radius:12px;background:#fffdf8}li>span{display:grid;width:42px;height:42px;place-items:center;border-radius:50%;background:#e2e7dc;color:#305f50;font-family:Georgia,serif;font-weight:700}li strong,li small{display:block}li small,time{margin-top:3px;color:#747d78;font-size:.8rem}.status{text-align:right}.status b{display:block;color:#305f50;font-size:.78rem}.confirm{width:auto;margin:0;padding:8px 10px;background:#e4eadd;color:#305f50;font-size:.78rem}.privacy{margin:24px 0 0;color:#69736e;font-size:.82rem}@media(max-width:760px){.hero{grid-template-columns:1fr;gap:28px;padding-top:50px}li{grid-template-columns:44px 1fr}.status{grid-column:2;text-align:left}}
    </style></head><body><main><nav><strong>Field Notes</strong><span>Small letters about considered work</span></nav>
      <section class="hero"><div><p class="eyebrow">A quieter inbox</p><h1>One useful letter each month.</h1><p class="intro">A short field note for people building thoughtful products. Practical observations, good references, and no urgency theater.</p></div>
      <form class="panel" action="/signups" method="post"><h2>Join the list</h2><p>Leave a name and email. The next note arrives at the beginning of the month.</p><label for="name">Name</label><input id="name" name="name" maxlength="100" required><label for="email">Email</label><input id="email" name="email" type="email" maxlength="254" required><button>Join Field Notes</button></form></section>
      <section><div class="list-head"><h2>People already reading</h2><span>${signups.length} ${signups.length === 1 ? "reader" : "readers"}</span></div><ul>${cards}</ul><p class="privacy">Field Notes stores signup details. It does not record page visits.</p></section>
    </main></body></html>`)
})

app.post("/signups", async (request, response) => {
  const name = String(request.body.name || "").trim()
  const email = String(request.body.email || "").trim().toLowerCase()
  if (!name || name.length > 100 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) || email.length > 254) {
    return response.status(400).send("Enter a name and a valid email address.")
  }

  try {
    await prisma.signup.create({ data: { name, email } })
    response.redirect(303, "/")
  } catch (error) {
    if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
      return response.status(409).send("That email is already on the list.")
    }
    throw error
  }
})

app.post("/signups/:id/confirm", async (request, response) => {
  const signup = await prisma.signup.findUnique({ where: { id: request.params.id } })
  if (!signup) return response.status(404).send("That signup was not found.")
  if (!signup.confirmedAt) {
    await prisma.signup.update({
      where: { id: signup.id },
      data: { confirmedAt: new Date() },
    })
  }
  response.redirect(303, "/")
})

app.use((error: unknown, _request: express.Request, response: express.Response, _next: express.NextFunction) => {
  console.error(error)
  response.status(500).send("Field Notes could not complete that request.")
})

await seed()
const server = app.listen(port, "0.0.0.0", () => console.log(`Field Notes ready on port ${port}`))

process.on("SIGTERM", () => server.close(() => void prisma.$disconnect().finally(() => process.exit(0))))

package.json Raw

{
  "name": "node-prisma-migrations-walkthrough",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "engines": {
    "node": ">=24"
  },
  "scripts": {
    "start": "tsx src/server.ts",
    "typecheck": "tsc --noEmit"
  },
  "dependencies": {
    "@prisma/adapter-pg": "7.8.0",
    "@prisma/client": "7.8.0",
    "dotenv": "17.4.2",
    "express": "5.2.1",
    "pg": "8.22.0"
  },
  "devDependencies": {
    "@types/express": "5.0.6",
    "@types/node": "24.13.3",
    "@types/pg": "8.20.0",
    "prisma": "7.8.0",
    "tsx": "4.23.1",
    "typescript": "7.0.2"
  }
}

prisma.config.ts Raw

import "dotenv/config"
import { defineConfig } from "prisma/config"

// Client generation reads this config but never connects to the database.
// Tokay supplies the real URL for rehearsal, migration, and runtime work.
const databaseUrl = process.env.DATABASE_URL || "postgresql://build:build@127.0.0.1:5432/build"

export default defineConfig({
  schema: "prisma/schema.prisma",
  migrations: {
    path: "prisma/migrations",
  },
  datasource: {
    url: databaseUrl,
  },
})

tsconfig.json Raw

{
  "compilerOptions": {
    "target": "ES2024",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "strict": true,
    "noEmit": true,
    "skipLibCheck": true,
    "allowImportingTsExtensions": true,
    "types": ["node"]
  },
  "include": ["src/**/*.ts", "prisma.config.ts"]
}

Built from revision 52c7b7f

Guide: Deploy database migrations safely