From b8816f830985411dc7a18ce8dda70b369cd7c0cd Mon Sep 17 00:00:00 2001 From: Yash Dewasthale Date: Thu, 8 Oct 2026 20:52:08 +0530 Subject: [PATCH 1/3] feat: update database migration scripts and enhance workspace panel in Nova web: - Changed database migration command in package.json for both web and db packages to use bun for improved performance. - Added support for additional file types (prisma, sql, yml/yaml) in language detection logic. - Replaced the existing thread details sidebar with a new WorkspacePanel component for better project management and visibility. - Introduced a new WorkspacePanel component to manage local projects and files, enhancing user experience in the Nova web interface. - Updated prisma.config.ts to improve database URL handling for migrations, ensuring compatibility with various environments. --- .../modules/nova-web/components/thread.tsx | 64 +- .../nova-web/components/workspace-panel.tsx | 822 ++++++++++++++++++ apps/web/modules/nova-web/working-steps.ts | 3 + apps/web/package.json | 2 +- packages/db/package.json | 2 +- packages/db/prisma.config.ts | 48 +- 6 files changed, 881 insertions(+), 60 deletions(-) create mode 100644 apps/web/modules/nova-web/components/workspace-panel.tsx diff --git a/apps/web/modules/nova-web/components/thread.tsx b/apps/web/modules/nova-web/components/thread.tsx index 87fc3ba7..0e030d3b 100644 --- a/apps/web/modules/nova-web/components/thread.tsx +++ b/apps/web/modules/nova-web/components/thread.tsx @@ -16,6 +16,7 @@ import { WorkedFor, } from "@/modules/nova-web/components/timeline" import { WorkingProcess } from "@/modules/nova-web/components/working-process" +import { WorkspacePanel } from "@/modules/nova-web/components/workspace-panel" import type { HarnessProvider, NovaAgentMode, @@ -292,59 +293,16 @@ export function ThreadView({ ) : null} {detailsOpen ? ( - +
+ +
) : null} diff --git a/apps/web/modules/nova-web/components/workspace-panel.tsx b/apps/web/modules/nova-web/components/workspace-panel.tsx new file mode 100644 index 00000000..df4c0c7e --- /dev/null +++ b/apps/web/modules/nova-web/components/workspace-panel.tsx @@ -0,0 +1,822 @@ +"use client" + +import { useEffect, useMemo, useState } from "react" +import { + Check, + ChevronDown, + ChevronRight, + Circle, + Diff, + File as FileIcon, + FileCode2, + Folder, + FolderOpen, + Globe, + LayoutDashboard, + ListTodo, + Loader2, + Plus, + Settings2, + X, +} from "lucide-react" + +import { cn } from "@/lib/utils" +import type { LocalProjectDto } from "@/modules/nova-web/api" +import { languageFromPath, type WorkingStep } from "@/modules/nova-web/working-steps" +import { + getLocalWorkspace, + readLocalWorkspaceFile, +} from "@/modules/nova-web/local-workspace" +import type { LocalAttachment } from "@/modules/nova/attachments/contracts" +import type { SessionDetail, TimelineEntry } from "@/modules/nova-web/types" +import { sessionUiLabel } from "@/modules/nova-web/types" + +type PanelTab = + | { id: "overview"; kind: "overview"; title: string } + | { id: "files"; kind: "files"; title: string } + | { id: "diffs"; kind: "diffs"; title: string } + | { id: "setup"; kind: "setup"; title: string } + | { id: "browser"; kind: "browser"; title: string } + | { id: string; kind: "file"; title: string; path: string } + +type OverviewTodo = { + id: string + content: string + status: "pending" | "in_progress" | "completed" +} + +type OverviewTask = { + id: string + title: string + status: "working" | "done" | "failed" | "idle" + detail?: string +} + +function langTone(lang: string) { + const l = lang.toLowerCase() + if (l === "tsx" || l === "jsx") return "bg-[#f472b6]/15 text-[#f9a8d4]" + if (l === "ts" || l === "js") return "bg-[#2dd4bf]/15 text-[#5eead4]" + if (l === "css") return "bg-sky-400/15 text-sky-300" + if (l === "json") return "bg-amber-400/15 text-amber-200" + if (l === "prisma") return "bg-violet-400/15 text-violet-200" + if (l === "md") return "bg-white/[0.06] text-[#b0b0b0]" + return "bg-white/[0.06] text-[#b0b0b0]" +} + +function FileBadge({ path }: { path: string }) { + const lang = languageFromPath(path) + const name = path.split("/").pop() || path + return ( + + + {lang} + + {name} + + ) +} + +function buildTodos(steps: WorkingStep[], streaming: boolean): OverviewTodo[] { + if (steps.length === 0 && streaming) { + return [{ id: "start", content: "Start turn", status: "in_progress" }] + } + return steps.map((step, index) => { + let content = step.label + if (step.kind === "write" && step.meta?.path) content = `Update ${step.meta.path}` + if (step.kind === "read" && step.meta?.path) content = `Read ${step.meta.path}` + if (step.kind === "explore") content = step.label + if (step.kind === "thinking") content = step.status === "active" ? "Think through the approach" : "Finish reasoning" + return { + id: step.id || `todo-${index}`, + content, + status: step.status === "active" ? "in_progress" : "completed", + } + }) +} + +function buildTasks( + steps: WorkingStep[], + timeline: TimelineEntry[], + streaming: boolean, +): OverviewTask[] { + const fromSteps: OverviewTask[] = steps + .filter((step) => step.kind === "explore" || step.kind === "tool" || step.kind === "write" || step.kind === "read") + .map((step) => ({ + id: `step-${step.id}`, + title: step.kind === "write" + ? `Edit ${step.meta?.path?.split("/").pop() || "file"}` + : step.kind === "read" + ? `Read ${step.meta?.path?.split("/").pop() || "file"}` + : step.label, + status: step.status === "active" ? "working" : "done", + detail: step.meta?.path, + })) + + const fromTimeline: OverviewTask[] = timeline + .flatMap((entry) => { + if (entry.kind !== "activity") return [] + if (entry.type !== "action" && entry.type !== "result" && entry.type !== "plan") return [] + return [{ + id: `act-${entry.id}`, + title: entry.title || entry.type, + status: (entry.status === "failed" + ? "failed" + : entry.status === "working" + ? "working" + : "done") as OverviewTask["status"], + detail: entry.body ?? undefined, + }] + }) + .slice(-8) + + const merged = [...fromTimeline, ...fromSteps] + if (streaming && merged.every((task) => task.status !== "working")) { + merged.push({ + id: "live-turn", + title: "Nova turn", + status: "working", + detail: "In progress", + }) + } + return merged.slice(-12) +} + +function collectTouchedPaths( + steps: WorkingStep[], + localFiles: LocalAttachment[], + localProject: LocalProjectDto | null | undefined, +): string[] { + const paths: string[] = [] + const push = (path?: string | null) => { + if (!path) return + if (!paths.includes(path)) paths.push(path) + } + for (const step of steps) { + push(step.meta?.path) + for (const child of step.children ?? []) push(child.path) + } + for (const file of localFiles) push(file.name) + // Prefer project-relative paths that look like source files recently attached + if (localProject?.paths) { + for (const name of localFiles.map((f) => f.name)) { + const match = localProject.paths.find((p) => p === name || p.endsWith(`/${name}`) || p.split("/").pop() === name) + if (match) push(match) + } + } + return paths.slice(0, 24) +} + +type TreeNode = { + name: string + path: string + kind: "dir" | "file" + children?: TreeNode[] +} + +function buildTree(paths: string[]): TreeNode[] { + const root: TreeNode[] = [] + for (const path of paths) { + const parts = path.split("/").filter(Boolean) + let level = root + let acc = "" + for (let i = 0; i < parts.length; i += 1) { + const part = parts[i]! + acc = acc ? `${acc}/${part}` : part + const isFile = i === parts.length - 1 + let node = level.find((item) => item.name === part && item.kind === (isFile ? "file" : "dir")) + if (!node) { + node = { name: part, path: acc, kind: isFile ? "file" : "dir", children: isFile ? undefined : [] } + level.push(node) + } + if (!isFile) level = node.children! + } + } + const sortNodes = (nodes: TreeNode[]) => { + nodes.sort((a, b) => { + if (a.kind !== b.kind) return a.kind === "dir" ? -1 : 1 + return a.name.localeCompare(b.name) + }) + for (const node of nodes) if (node.children) sortNodes(node.children) + } + sortNodes(root) + return root +} + +function TreeView({ + nodes, + depth = 0, + onOpen, + activePath, +}: { + nodes: TreeNode[] + depth?: number + onOpen: (path: string) => void + activePath?: string | null +}) { + return ( + + ) +} + +function TreeRow({ + node, + depth, + onOpen, + activePath, +}: { + node: TreeNode + depth: number + onOpen: (path: string) => void + activePath?: string | null +}) { + const [open, setOpen] = useState(depth < 1) + if (node.kind === "dir") { + return ( +
  • + + {open && node.children?.length ? ( + + ) : null} +
  • + ) + } + return ( +
  • + +
  • + ) +} + +function OverviewPane({ + session, + todos, + tasks, + touchedPaths, + localProject, + streaming, + onOpenPath, +}: { + session: SessionDetail + todos: OverviewTodo[] + tasks: OverviewTask[] + touchedPaths: string[] + localProject?: LocalProjectDto | null + streaming: boolean + onOpenPath: (path: string) => void +}) { + const done = todos.filter((t) => t.status === "completed").length + const total = todos.length + const [todosOpen, setTodosOpen] = useState(true) + const [tasksOpen, setTasksOpen] = useState(true) + + return ( +
    +
    +

    Thread

    +

    {session.objective}

    +

    + {streaming ? "Working" : sessionUiLabel(session.activeRunId ? "working" : session.status)} + {localProject ? ` · ${localProject.displayName}` : ""} +

    +
    + +
    + + {todosOpen ? ( +
      + {todos.length === 0 ? ( +
    • No todos yet for this turn.
    • + ) : ( + todos.map((todo) => ( +
    • + {todo.status === "completed" ? ( + + ) : todo.status === "in_progress" ? ( + + ) : ( + + )} + + {todo.content} + +
    • + )) + )} +
    + ) : null} +
    + +
    + + {tasksOpen ? ( +
      + {tasks.length === 0 ? ( +
    • No tasks yet.
    • + ) : ( + tasks.map((task) => ( +
    • +
      + + {task.title} + {task.status} +
      + {task.detail ? ( + + ) : null} +
    • + )) + )} +
    + ) : null} +
    + +
    +

    + + Files in play +

    + {touchedPaths.length === 0 ? ( +

    No files touched this turn yet.

    + ) : ( +
      + {touchedPaths.map((path) => ( +
    • + +
    • + ))} +
    + )} +
    +
    + ) +} + +function FilesPane({ + localProject, + onOpenPath, + activePath, +}: { + localProject?: LocalProjectDto | null + onOpenPath: (path: string) => void + activePath?: string | null +}) { + const [query, setQuery] = useState("") + const paths = localProject?.paths ?? [] + const filtered = useMemo(() => { + const q = query.trim().toLowerCase() + if (!q) return paths + return paths.filter((path) => path.toLowerCase().includes(q)) + }, [paths, query]) + const tree = useMemo(() => buildTree(filtered.slice(0, 800)), [filtered]) + + if (!localProject) { + return ( +
    + +

    No local project linked

    +

    + Open a folder from @ Files to browse and open project files here. +

    +
    + ) + } + + return ( +
    +
    +

    {localProject.displayName}

    +

    + {localProject.fileCount} files{localProject.truncated ? " · partial" : ""} +

    + setQuery(event.target.value)} + placeholder="Search files" + className="mt-2 w-full rounded-md border border-white/[0.08] bg-[#141414] px-2.5 py-1.5 text-[12.5px] text-[#e8e8e8] outline-none placeholder:text-[#4a4a4a] focus:border-[#2dd4bf]/40" + /> +
    +
    + {tree.length === 0 ? ( +

    No files match.

    + ) : ( + + )} +
    +
    + ) +} + +function FileViewerPane({ + path, + content, + loading, + error, +}: { + path: string + content: string | null + loading: boolean + error: string | null +}) { + return ( +
    +
    + + {path} +
    +
    + {loading ? ( +

    + Reading… +

    + ) : error ? ( +

    {error}

    + ) : content == null ? ( +

    No content

    + ) : ( +
    +            {content.split("\n").map((line, index) => (
    +              
    + {index + 1} + {line || " "} +
    + ))} +
    + )} +
    +
    + ) +} + +function PlaceholderPane({ + title, + body, +}: { + title: string + body: string +}) { + return ( +
    +

    {title}

    +

    {body}

    +
    + ) +} + +export function WorkspacePanel({ + session, + timeline, + workingSteps, + streaming, + localProject, + localFiles = [], + className, +}: { + session: SessionDetail + timeline: TimelineEntry[] + workingSteps: WorkingStep[] + streaming: boolean + localProject?: LocalProjectDto | null + localFiles?: LocalAttachment[] + className?: string +}) { + const [tabs, setTabs] = useState([ + { id: "overview", kind: "overview", title: "Overview" }, + ]) + const [activeId, setActiveId] = useState("overview") + const [menuOpen, setMenuOpen] = useState(false) + const [fileBodies, setFileBodies] = useState>({}) + + const todos = useMemo(() => buildTodos(workingSteps, streaming), [workingSteps, streaming]) + const tasks = useMemo(() => buildTasks(workingSteps, timeline, streaming), [workingSteps, timeline, streaming]) + const touchedPaths = useMemo( + () => collectTouchedPaths(workingSteps, localFiles, localProject), + [workingSteps, localFiles, localProject], + ) + + const activeTab = tabs.find((tab) => tab.id === activeId) ?? tabs[0]! + + // Auto-open files Nova is actively writing/reading. + useEffect(() => { + const hot = workingSteps + .filter((step) => step.status === "active" && step.meta?.path && (step.kind === "write" || step.kind === "read")) + .map((step) => step.meta!.path!) + for (const path of hot.slice(0, 3)) { + openFileTab(path, false) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [workingSteps]) + + function ensureTab(tab: PanelTab, activate = true) { + setTabs((current) => (current.some((item) => item.id === tab.id) ? current : [...current, tab])) + if (activate) setActiveId(tab.id) + setMenuOpen(false) + } + + function openFileTab(path: string, activate = true) { + const id = `file:${path}` + ensureTab({ id, kind: "file", title: path.split("/").pop() || path, path }, activate) + void loadFileBody(path) + } + + async function loadFileBody(path: string) { + setFileBodies((current) => ({ + ...current, + [path]: { content: current[path]?.content ?? null, loading: true, error: null }, + })) + + // Prefer attached local file content already in memory. + const attached = localFiles.find((file) => file.name === path || file.name.endsWith(`/${path}`) || path.endsWith(file.name)) + if (attached?.text) { + setFileBodies((current) => ({ + ...current, + [path]: { content: attached.text!, loading: false, error: null }, + })) + return + } + if (attached?.dataBase64 && attached.kind === "image") { + setFileBodies((current) => ({ + ...current, + [path]: { content: `[image ${attached.mediaType} · ${attached.size} bytes]`, loading: false, error: null }, + })) + return + } + + // Local workspace handle + if (getLocalWorkspace()) { + const read = await readLocalWorkspaceFile(path) + if (read.ok) { + setFileBodies((current) => ({ + ...current, + [path]: { + content: read.attachment.text ?? (read.attachment.dataBase64 ? `[binary ${read.attachment.mediaType}]` : ""), + loading: false, + error: null, + }, + })) + return + } + setFileBodies((current) => ({ + ...current, + [path]: { content: null, loading: false, error: read.error }, + })) + return + } + + setFileBodies((current) => ({ + ...current, + [path]: { + content: null, + loading: false, + error: "Re-open the local project folder to view this file, or attach it from @ Files.", + }, + })) + } + + function closeTab(id: string) { + if (id === "overview") return + setTabs((current) => { + const next = current.filter((tab) => tab.id !== id) + if (activeId === id) { + const index = current.findIndex((tab) => tab.id === id) + const fallback = next[Math.max(0, index - 1)] ?? next[0] + if (fallback) setActiveId(fallback.id) + } + return next.length ? next : [{ id: "overview", kind: "overview", title: "Overview" }] + }) + } + + const menuItems: Array<{ id: PanelTab["kind"]; label: string; icon: typeof LayoutDashboard; description: string }> = [ + { id: "overview", label: "Overview", icon: LayoutDashboard, description: "Todos, tasks, files in play" }, + { id: "files", label: "Files", icon: FolderOpen, description: "Browse the local project" }, + { id: "diffs", label: "Diffs", icon: Diff, description: "Changes this turn" }, + { id: "setup", label: "Setup", icon: Settings2, description: "Thread harness settings" }, + { id: "browser", label: "Browser", icon: Globe, description: "Preview surface" }, + ] + + return ( + + ) +} diff --git a/apps/web/modules/nova-web/working-steps.ts b/apps/web/modules/nova-web/working-steps.ts index 377dff60..3f54f335 100644 --- a/apps/web/modules/nova-web/working-steps.ts +++ b/apps/web/modules/nova-web/working-steps.ts @@ -55,6 +55,9 @@ export function languageFromPath(path: string): string { if (ext === "go") return "go" if (ext === "rs") return "rs" if (ext === "swift") return "swift" + if (ext === "prisma") return "prisma" + if (ext === "sql") return "sql" + if (ext === "yml" || ext === "yaml") return "yml" return ext || "file" } diff --git a/apps/web/package.json b/apps/web/package.json index 5bafaada..2330cc6d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -10,7 +10,7 @@ "lint": "eslint", "typecheck": "tsc --noEmit", "db:generate": "prisma generate --schema ../../packages/db/prisma/schema.prisma", - "db:migrate": "prisma migrate deploy --config ../../packages/db/prisma.config.ts" + "db:migrate": "bun run --cwd ../../packages/db db:migrate" }, "dependencies": { "@ai-sdk/anthropic": "^4.0.49", diff --git a/packages/db/package.json b/packages/db/package.json index a00ff6b3..8c37dbf1 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -13,7 +13,7 @@ "typecheck": "echo 'db typecheck pending'", "db:clean": "rm -rf src/generated", "db:generate": "prisma generate --schema ./prisma/schema.prisma", - "db:migrate": "prisma migrate deploy --schema ./prisma/schema.prisma" + "db:migrate": "bun run ./scripts/migrate-deploy.ts" }, "dependencies": { "@prisma/adapter-pg": "^7.8.0", diff --git a/packages/db/prisma.config.ts b/packages/db/prisma.config.ts index cc6ce0db..056bb2c6 100644 --- a/packages/db/prisma.config.ts +++ b/packages/db/prisma.config.ts @@ -1,4 +1,44 @@ -import { defineConfig } from "prisma/config"; +import { defineConfig } from "prisma/config" + +/** + * Prefer a direct (unpooled) Postgres URL for Prisma Migrate. + * Neon/PgBouncer pooler URLs cannot hold session-scoped advisory locks, which + * surfaces on Vercel as P1002: "Timed out trying to acquire a postgres advisory lock". + * + * App runtime keeps using DATABASE_URL (pooled) via @prisma/adapter-pg. + */ +function migrateDatabaseUrl(): string { + const candidates = [ + process.env.DATABASE_URL_UNPOOLED, + process.env.DIRECT_URL, + process.env.DIRECT_DATABASE_URL, + process.env.POSTGRES_URL_NON_POOLING, + process.env.DATABASE_URL, + ] + + for (const value of candidates) { + const url = value?.trim() + if (!url) continue + return stripPooler(url) + } + + return "postgresql://postgres:postgres@localhost:5432/postgres" +} + +/** Neon pooler host: ep-xxx-pooler.region... → ep-xxx.region... */ +function stripPooler(url: string): string { + try { + const parsed = new URL(url) + if (parsed.hostname.includes("-pooler.")) { + parsed.hostname = parsed.hostname.replace("-pooler.", ".") + } + // PgBouncer query flags are meaningless / harmful on a direct connection. + parsed.searchParams.delete("pgbouncer") + return parsed.toString() + } catch { + return url.replace("-pooler.", ".") + } +} export default defineConfig({ schema: "prisma/schema.prisma", @@ -6,8 +46,6 @@ export default defineConfig({ path: "prisma/migrations", }, datasource: { - url: - process.env["DATABASE_URL"] ?? - "postgresql://postgres:postgres@localhost:5432/postgres", + url: migrateDatabaseUrl(), }, -}); +}) From 4985a98c4bb1f9e613c6e8e967b9e84d05de5700 Mon Sep 17 00:00:00 2001 From: Yash Dewasthale Date: Thu, 8 Oct 2026 20:54:06 +0530 Subject: [PATCH 2/3] fix(db): ship migrate-deploy script outside ignored scripts/ Root .gitignore matches scripts/, so packages/db/scripts/migrate-deploy.ts never reached Vercel and db:migrate failed with Module not found. Move the helper to packages/db/migrate-deploy.ts and point the package script at it. --- packages/db/migrate-deploy.ts | 93 +++++++++++++++++++++++++++++++++++ packages/db/package.json | 2 +- 2 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 packages/db/migrate-deploy.ts diff --git a/packages/db/migrate-deploy.ts b/packages/db/migrate-deploy.ts new file mode 100644 index 00000000..f9958518 --- /dev/null +++ b/packages/db/migrate-deploy.ts @@ -0,0 +1,93 @@ +#!/usr/bin/env bun +/** + * prisma migrate deploy with retries for transient advisory-lock timeouts (P1002). + * Uses packages/db/prisma.config.ts (direct/unpooled URL for Neon). + */ +import { spawn } from "node:child_process" +import path from "node:path" + +const ROOT = path.resolve(import.meta.dir) +const MAX_ATTEMPTS = 5 +const BASE_DELAY_MS = 3_000 + +function runMigrate(): Promise<{ code: number; output: string }> { + return new Promise((resolve) => { + const child = spawn( + "bunx", + ["prisma", "migrate", "deploy", "--config", "./prisma.config.ts"], + { + cwd: ROOT, + env: process.env, + stdio: ["ignore", "pipe", "pipe"], + }, + ) + + let output = "" + const onChunk = (chunk: Buffer) => { + const text = chunk.toString() + output += text + process.stdout.write(text) + } + child.stdout.on("data", onChunk) + child.stderr.on("data", onChunk) + child.on("close", (code) => resolve({ code: code ?? 1, output })) + child.on("error", (error) => { + const message = error instanceof Error ? error.message : String(error) + console.error("[db:migrate]", message) + resolve({ code: 1, output: message }) + }) + }) +} + +function isRetryable(output: string): boolean { + return ( + output.includes("P1002") + || output.includes("advisory lock") + || output.includes("Timed out trying to acquire") + || output.includes("40P01") + || output.includes("ECONNRESET") + || output.includes("Can't reach database server") + ) +} + +function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +const url = + process.env.DATABASE_URL_UNPOOLED + || process.env.DIRECT_URL + || process.env.DIRECT_DATABASE_URL + || process.env.POSTGRES_URL_NON_POOLING + || process.env.DATABASE_URL + || "" + +if (url.includes("-pooler.") && !process.env.DATABASE_URL_UNPOOLED && !process.env.DIRECT_URL) { + console.warn( + "[db:migrate] DATABASE_URL looks pooled (Neon -pooler). " + + "prisma.config.ts will strip -pooler for migrate. " + + "Prefer setting DATABASE_URL_UNPOOLED or DIRECT_URL on Vercel.", + ) +} + +let attempt = 0 +while (attempt < MAX_ATTEMPTS) { + attempt += 1 + console.log(`[db:migrate] attempt ${attempt}/${MAX_ATTEMPTS}`) + const { code, output } = await runMigrate() + if (code === 0) { + console.log("[db:migrate] success") + process.exit(0) + } + + if (!isRetryable(output) || attempt >= MAX_ATTEMPTS) { + console.error("[db:migrate] failed") + process.exit(code) + } + + const delay = BASE_DELAY_MS * attempt + console.warn(`[db:migrate] retryable error; waiting ${delay}ms`) + await sleep(delay) +} + +process.exit(1) diff --git a/packages/db/package.json b/packages/db/package.json index 8c37dbf1..17d19211 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -13,7 +13,7 @@ "typecheck": "echo 'db typecheck pending'", "db:clean": "rm -rf src/generated", "db:generate": "prisma generate --schema ./prisma/schema.prisma", - "db:migrate": "bun run ./scripts/migrate-deploy.ts" + "db:migrate": "bun run ./migrate-deploy.ts" }, "dependencies": { "@prisma/adapter-pg": "^7.8.0", From 662cc134ef4d0970a60398ea19610df7d4abf73d Mon Sep 17 00:00:00 2001 From: Yash Dewasthale Date: Thu, 8 Oct 2026 21:13:20 +0530 Subject: [PATCH 3/3] fix(db): stop P1002 migrate lock timeouts on Vercel/Neon Disable Prisma migrate advisory locks on Vercel/CI (concurrent previews contend on pg_advisory_lock), force the unpooled DB URL into the migrate child env, and keep retries for cold-start connectivity blips. --- packages/db/migrate-deploy.ts | 110 +++++++++++++++++++++++++++------- packages/db/prisma.config.ts | 32 +++++----- 2 files changed, 106 insertions(+), 36 deletions(-) diff --git a/packages/db/migrate-deploy.ts b/packages/db/migrate-deploy.ts index f9958518..a9683948 100644 --- a/packages/db/migrate-deploy.ts +++ b/packages/db/migrate-deploy.ts @@ -1,23 +1,71 @@ #!/usr/bin/env bun /** - * prisma migrate deploy with retries for transient advisory-lock timeouts (P1002). - * Uses packages/db/prisma.config.ts (direct/unpooled URL for Neon). + * prisma migrate deploy hardened for Neon + Vercel. + * + * - Prefer unpooled / direct DB URL (PgBouncer cannot hold session advisory locks) + * - Strip Neon `-pooler.` hosts when only DATABASE_URL is set + * - On Vercel/CI, disable migrate advisory locking (P1002 under concurrent previews) + * - Retry transient connectivity / lock errors */ import { spawn } from "node:child_process" import path from "node:path" const ROOT = path.resolve(import.meta.dir) -const MAX_ATTEMPTS = 5 -const BASE_DELAY_MS = 3_000 +const MAX_ATTEMPTS = 6 +const BASE_DELAY_MS = 4_000 -function runMigrate(): Promise<{ code: number; output: string }> { +function stripPooler(url: string): string { + try { + const parsed = new URL(url) + // ep-xxx-pooler.region.aws.neon.tech → ep-xxx.region.aws.neon.tech + if (parsed.hostname.includes("-pooler.")) { + parsed.hostname = parsed.hostname.replace("-pooler.", ".") + } + // Some Neon URLs use pooler as a label elsewhere + parsed.hostname = parsed.hostname.replace(/\.pooler\./g, ".") + parsed.searchParams.delete("pgbouncer") + parsed.searchParams.delete("connection_limit") + parsed.searchParams.delete("pool_timeout") + return parsed.toString() + } catch { + return url.replace("-pooler.", ".").replace(".pooler.", ".") + } +} + +function redactedHost(url: string): string { + try { + return new URL(url).hostname + } catch { + return "(invalid-url)" + } +} + +function resolveMigrateUrl(): string { + const candidates = [ + process.env.DATABASE_URL_UNPOOLED, + process.env.DIRECT_URL, + process.env.DIRECT_DATABASE_URL, + process.env.POSTGRES_URL_NON_POOLING, + process.env.DATABASE_URL, + ] + for (const value of candidates) { + const raw = value?.trim() + if (!raw) continue + return stripPooler(raw) + } + throw new Error( + "No database URL set for migrate. Expected DATABASE_URL_UNPOOLED, DIRECT_URL, or DATABASE_URL.", + ) +} + +function runMigrate(env: NodeJS.ProcessEnv): Promise<{ code: number; output: string }> { return new Promise((resolve) => { const child = spawn( "bunx", ["prisma", "migrate", "deploy", "--config", "./prisma.config.ts"], { cwd: ROOT, - env: process.env, + env, stdio: ["ignore", "pipe", "pipe"], }, ) @@ -46,7 +94,11 @@ function isRetryable(output: string): boolean { || output.includes("Timed out trying to acquire") || output.includes("40P01") || output.includes("ECONNRESET") + || output.includes("ECONNREFUSED") || output.includes("Can't reach database server") + || output.includes("Connection terminated") + || output.includes("server closed the connection") + || output.includes("timeout expired") ) } @@ -54,32 +106,48 @@ function sleep(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)) } -const url = - process.env.DATABASE_URL_UNPOOLED - || process.env.DIRECT_URL - || process.env.DIRECT_DATABASE_URL - || process.env.POSTGRES_URL_NON_POOLING - || process.env.DATABASE_URL - || "" - -if (url.includes("-pooler.") && !process.env.DATABASE_URL_UNPOOLED && !process.env.DIRECT_URL) { - console.warn( - "[db:migrate] DATABASE_URL looks pooled (Neon -pooler). " - + "prisma.config.ts will strip -pooler for migrate. " - + "Prefer setting DATABASE_URL_UNPOOLED or DIRECT_URL on Vercel.", - ) +const onVercel = process.env.VERCEL === "1" || process.env.CI === "true" +const migrateUrl = resolveMigrateUrl() + +console.log(`[db:migrate] host=${redactedHost(migrateUrl)} vercel=${onVercel ? "yes" : "no"}`) + +// Force both the Prisma config candidates and the classic env name to the direct URL. +const env: NodeJS.ProcessEnv = { + ...process.env, + DATABASE_URL: migrateUrl, + DATABASE_URL_UNPOOLED: migrateUrl, + DIRECT_URL: migrateUrl, + DIRECT_DATABASE_URL: migrateUrl, +} + +// Session advisory locks break under Neon pooler and concurrent Vercel previews. +// migrate deploy is still serialized by _prisma_migrations rows; disabling the lock +// avoids P1002 when another preview holds/drops the lock mid-deploy. +if (onVercel || process.env.PRISMA_MIGRATE_DISABLE_LOCK === "1") { + env.PRISMA_SCHEMA_DISABLE_ADVISORY_LOCK = "1" + console.log("[db:migrate] PRISMA_SCHEMA_DISABLE_ADVISORY_LOCK=1") } let attempt = 0 while (attempt < MAX_ATTEMPTS) { attempt += 1 console.log(`[db:migrate] attempt ${attempt}/${MAX_ATTEMPTS}`) - const { code, output } = await runMigrate() + const { code, output } = await runMigrate(env) if (code === 0) { console.log("[db:migrate] success") process.exit(0) } + // If lock failed even with disable flag unset path, force-disable and retry immediately. + if ( + (output.includes("P1002") || output.includes("advisory lock")) + && env.PRISMA_SCHEMA_DISABLE_ADVISORY_LOCK !== "1" + ) { + env.PRISMA_SCHEMA_DISABLE_ADVISORY_LOCK = "1" + console.warn("[db:migrate] enabling PRISMA_SCHEMA_DISABLE_ADVISORY_LOCK after lock timeout") + continue + } + if (!isRetryable(output) || attempt >= MAX_ATTEMPTS) { console.error("[db:migrate] failed") process.exit(code) diff --git a/packages/db/prisma.config.ts b/packages/db/prisma.config.ts index 056bb2c6..53627ffc 100644 --- a/packages/db/prisma.config.ts +++ b/packages/db/prisma.config.ts @@ -6,7 +6,24 @@ import { defineConfig } from "prisma/config" * surfaces on Vercel as P1002: "Timed out trying to acquire a postgres advisory lock". * * App runtime keeps using DATABASE_URL (pooled) via @prisma/adapter-pg. + * migrate-deploy.ts also overwrites DATABASE_URL to the direct URL for the child process. */ +function stripPooler(url: string): string { + try { + const parsed = new URL(url) + if (parsed.hostname.includes("-pooler.")) { + parsed.hostname = parsed.hostname.replace("-pooler.", ".") + } + parsed.hostname = parsed.hostname.replace(/\.pooler\./g, ".") + parsed.searchParams.delete("pgbouncer") + parsed.searchParams.delete("connection_limit") + parsed.searchParams.delete("pool_timeout") + return parsed.toString() + } catch { + return url.replace("-pooler.", ".").replace(".pooler.", ".") + } +} + function migrateDatabaseUrl(): string { const candidates = [ process.env.DATABASE_URL_UNPOOLED, @@ -25,21 +42,6 @@ function migrateDatabaseUrl(): string { return "postgresql://postgres:postgres@localhost:5432/postgres" } -/** Neon pooler host: ep-xxx-pooler.region... → ep-xxx.region... */ -function stripPooler(url: string): string { - try { - const parsed = new URL(url) - if (parsed.hostname.includes("-pooler.")) { - parsed.hostname = parsed.hostname.replace("-pooler.", ".") - } - // PgBouncer query flags are meaningless / harmful on a direct connection. - parsed.searchParams.delete("pgbouncer") - return parsed.toString() - } catch { - return url.replace("-pooler.", ".") - } -} - export default defineConfig({ schema: "prisma/schema.prisma", migrations: {