diff --git a/apps/web/modules/nova-web/components/thread.tsx b/apps/web/modules/nova-web/components/thread.tsx
index 87fc3ba..0e030d3 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 0000000..df4c0c7
--- /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 (
+
+ {nodes.map((node) => (
+
+ ))}
+
+ )
+}
+
+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 ? (
+
+ ) : 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 (
+
+ )
+}
+
+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 377dff6..3f54f33 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 5bafaad..2330cc6 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/migrate-deploy.ts b/packages/db/migrate-deploy.ts
new file mode 100644
index 0000000..a968394
--- /dev/null
+++ b/packages/db/migrate-deploy.ts
@@ -0,0 +1,161 @@
+#!/usr/bin/env bun
+/**
+ * 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 = 6
+const BASE_DELAY_MS = 4_000
+
+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,
+ 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("ECONNREFUSED")
+ || output.includes("Can't reach database server")
+ || output.includes("Connection terminated")
+ || output.includes("server closed the connection")
+ || output.includes("timeout expired")
+ )
+}
+
+function sleep(ms: number) {
+ return new Promise((resolve) => setTimeout(resolve, ms))
+}
+
+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(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)
+ }
+
+ 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 a00ff6b..17d1921 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 ./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 cc6ce0d..53627ff 100644
--- a/packages/db/prisma.config.ts
+++ b/packages/db/prisma.config.ts
@@ -1,4 +1,46 @@
-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.
+ * 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,
+ 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"
+}
export default defineConfig({
schema: "prisma/schema.prisma",
@@ -6,8 +48,6 @@ export default defineConfig({
path: "prisma/migrations",
},
datasource: {
- url:
- process.env["DATABASE_URL"] ??
- "postgresql://postgres:postgres@localhost:5432/postgres",
+ url: migrateDatabaseUrl(),
},
-});
+})