diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index 3c24f32..cac0d9b 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -42,12 +42,19 @@ docker compose exec vitrina node scripts/credits.mjs owner@example.com 500 "welc ## Vercel -1. Import the repo. Build command `pnpm build`, Node 22. -2. Database: Turso (`DATABASE_URL=libsql://…`, `DATABASE_AUTH_TOKEN`). A - file database does not survive on serverless. -3. Storage: create a Blob store, set `BLOB_READ_WRITE_TOKEN`, - `STORAGE_DRIVER=blob`, `NEXT_PUBLIC_STORAGE_DRIVER=blob`. -4. Set the remaining variables from the table. +1. Import the repo (framework Next.js, Node 22). The `vercel-build` script + runs the migrations against Turso and then builds; functions never migrate + on cold start. +2. Database: add the **Turso** integration (it injects `TURSO_DATABASE_URL` + and `TURSO_AUTH_TOKEN`, both honoured) or set `DATABASE_URL` / + `DATABASE_AUTH_TOKEN` yourself. A file database does not survive on + serverless. +3. Storage: Storage → create a **Blob** store and connect it to the project; + it injects `BLOB_READ_WRITE_TOKEN` and the app switches to Blob on its own. +4. Set `APP_SECRET`, `APP_URL` (the production URL), `HF_API_BASE_URL` and + `PLATFORM_API_KEY`. Stripe and mail variables as needed; the Stripe + marketplace integration only provides the keys, the three price ids and + the webhook endpoint are still yours to create. ## Stripe diff --git a/next.config.ts b/next.config.ts index bd31e98..6406f08 100644 --- a/next.config.ts +++ b/next.config.ts @@ -12,8 +12,9 @@ const nextConfig: NextConfig = { async headers() { return [{ source: "/(.*)", headers: SECURITY_HEADERS }]; }, - /* Standalone output is what the Dockerfile copies; it is harmless on Vercel. */ - output: "standalone", + /* Standalone output is what the Dockerfile copies. Vercel does its own + packaging and trips over it, so it is only on when not building there. */ + ...(process.env.VERCEL ? {} : { output: "standalone" as const }), /* Runtime data (the local database, uploads) must never ride along in the traced server output. */ outputFileTracingExcludes: { "*": ["./data/**"] }, diff --git a/package.json b/package.json index bf8f461..f2f355a 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,8 @@ "credits": "node scripts/credits.mjs", "mock:platform": "node scripts/mock-platform.mjs", "brand": "node scripts/build-brand-assets.mjs", - "wipe:local": "node scripts/wipe-local.mjs" + "wipe:local": "node scripts/wipe-local.mjs", + "vercel-build": "node scripts/migrate.mjs && next build" }, "dependencies": { "@libsql/client": "0.18.0", diff --git a/playwright.config.ts b/playwright.config.ts index 16886c9..35ef04a 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -29,7 +29,9 @@ export default defineConfig({ reuseExistingServer: false, }, { - command: `rm -rf data/e2e && node scripts/start-standalone.mjs`, + /* Migrate before the server listens: the boot migration also runs, but + the first request must not race it. */ + command: `rm -rf data/e2e && node scripts/migrate.mjs && node scripts/start-standalone.mjs`, port: PORT, timeout: 120_000, reuseExistingServer: false, diff --git a/scripts/credits.mjs b/scripts/credits.mjs index 2c5abc6..4774883 100644 --- a/scripts/credits.mjs +++ b/scripts/credits.mjs @@ -9,8 +9,8 @@ if (!email || !Number.isInteger(amount) || amount === 0) { console.error("usage: pnpm credits [note]"); process.exit(1); } -const url = process.env.DATABASE_URL?.trim() || "file:./data/vitrina.db"; -const client = createClient({ url, authToken: process.env.DATABASE_AUTH_TOKEN?.trim() || undefined }); +const url = process.env.DATABASE_URL?.trim() || process.env.TURSO_DATABASE_URL?.trim() || "file:./data/vitrina.db"; +const client = createClient({ url, authToken: process.env.DATABASE_AUTH_TOKEN?.trim() || process.env.TURSO_AUTH_TOKEN?.trim() || undefined }); const user = await client.execute({ sql: "select id from user where email = ?", args: [email] }); const userId = user.rows[0]?.id; if (!userId) { diff --git a/scripts/migrate.mjs b/scripts/migrate.mjs index c982934..5267d08 100644 --- a/scripts/migrate.mjs +++ b/scripts/migrate.mjs @@ -3,10 +3,18 @@ import { drizzle } from "drizzle-orm/libsql"; import { migrate } from "drizzle-orm/libsql/migrator"; import { mkdirSync } from "node:fs"; -const url = process.env.DATABASE_URL?.trim() || "file:./data/vitrina.db"; +const url = process.env.DATABASE_URL?.trim() || process.env.TURSO_DATABASE_URL?.trim() || "file:./data/vitrina.db"; +/* A serverless build with a file database would migrate a file nobody serves + from. Fail here, with the fix, rather than at the first request. */ +if (process.env.VERCEL && url.startsWith("file:")) { + console.error( + "[migrate] No remote database configured. On Vercel add the Turso integration (TURSO_DATABASE_URL / TURSO_AUTH_TOKEN) or set DATABASE_URL and DATABASE_AUTH_TOKEN in the project's environment variables, then redeploy.", + ); + process.exit(1); +} if (url.startsWith("file:")) mkdirSync(url.slice(5).replace(/\/[^/]*$/, "") || ".", { recursive: true }); -const client = createClient({ url, authToken: process.env.DATABASE_AUTH_TOKEN?.trim() || undefined }); +const client = createClient({ url, authToken: process.env.DATABASE_AUTH_TOKEN?.trim() || process.env.TURSO_AUTH_TOKEN?.trim() || undefined }); await migrate(drizzle(client), { migrationsFolder: "./drizzle" }); console.log(`[migrate] database up to date (${url.startsWith("file:") ? url : "remote"})`); client.close(); diff --git a/scripts/wipe-local.mjs b/scripts/wipe-local.mjs index 06edb0d..a266a49 100644 --- a/scripts/wipe-local.mjs +++ b/scripts/wipe-local.mjs @@ -18,7 +18,8 @@ const root = resolve(import.meta.dirname, ".."); const dataDir = resolve(root, process.env.DATA_DIR?.trim() || "./data"); const dryRun = args.has("--dry-run"); -const remoteDb = Boolean(process.env.DATABASE_URL?.trim() && !process.env.DATABASE_URL.trim().startsWith("file:")); +const dbUrl = process.env.DATABASE_URL?.trim() || process.env.TURSO_DATABASE_URL?.trim() || ""; +const remoteDb = Boolean(dbUrl && !dbUrl.startsWith("file:")); if (remoteDb) { console.error("[wipe-local] DATABASE_URL points at a remote database. This script only resets local files; unset it or point it at a file: URL."); process.exit(1); diff --git a/src/app/api/upload/route.ts b/src/app/api/upload/route.ts index c5956e0..e988e8a 100644 --- a/src/app/api/upload/route.ts +++ b/src/app/api/upload/route.ts @@ -1,13 +1,19 @@ import { NextResponse } from "next/server"; import { getViewer } from "@/server/session"; -import { ALLOWED_UPLOAD_TYPES, MAX_UPLOAD_BYTES, publicOrigin, storeLocal } from "@/server/storage"; +import { ALLOWED_UPLOAD_TYPES, MAX_UPLOAD_BYTES, publicOrigin, storageDriver, storeLocal } from "@/server/storage"; + +/** Tells the browser which store to send files to. */ +export async function GET(): Promise { + return NextResponse.json({ driver: storageDriver() }); +} /** Local storage driver: the browser posts the file here and gets back a URL under /api/files the platform can fetch. */ export async function POST(request: Request): Promise { const viewer = await getViewer(); if (!viewer) return NextResponse.json({ error: "Sign in to upload" }, { status: 401 }); + if (storageDriver() !== "local") return NextResponse.json({ error: "This studio uploads to Blob storage" }, { status: 409 }); const form = await request.formData().catch(() => null); const file = form?.get("file"); diff --git a/src/db/client.ts b/src/db/client.ts index 0fdce4e..0f3818d 100644 --- a/src/db/client.ts +++ b/src/db/client.ts @@ -8,8 +8,12 @@ export type Db = LibSQLDatabase; /** One connection per process. Local development and single-node self-hosting use a file; Turso (or any libsql server) takes a URL and a token. */ function resolveUrl(): { url: string; authToken?: string } { - const url = process.env.DATABASE_URL?.trim(); - if (url) return { url, authToken: process.env.DATABASE_AUTH_TOKEN?.trim() || undefined }; + /* DATABASE_URL first; the Vercel Turso integration injects TURSO_* names. */ + const url = process.env.DATABASE_URL?.trim() || process.env.TURSO_DATABASE_URL?.trim(); + if (url) { + const authToken = process.env.DATABASE_AUTH_TOKEN?.trim() || process.env.TURSO_AUTH_TOKEN?.trim() || undefined; + return { url, authToken }; + } return { url: "file:./data/vitrina.db" }; } diff --git a/src/generation/upload.ts b/src/generation/upload.ts index bc936f7..93899b6 100644 --- a/src/generation/upload.ts +++ b/src/generation/upload.ts @@ -1,13 +1,24 @@ import { put } from "@vercel/blob/client"; -/** Which store the browser sends files to. Set at build time from the same - variable the server reads, so the two never disagree. */ -export function storageDriver(): "blob" | "local" { - return process.env.NEXT_PUBLIC_STORAGE_DRIVER === "blob" ? "blob" : "local"; +type Driver = "blob" | "local"; + +let resolved: Promise | null = null; + +/** Which store the browser sends files to. NEXT_PUBLIC_STORAGE_DRIVER pins it + at build time; otherwise the server is asked once per page load, so a + deployment that only sets BLOB_READ_WRITE_TOKEN still uploads to Blob. */ +export function storageDriver(): Promise { + const pinned = process.env.NEXT_PUBLIC_STORAGE_DRIVER; + if (pinned === "blob" || pinned === "local") return Promise.resolve(pinned); + resolved ??= fetch("/api/upload") + .then((res) => res.json() as Promise<{ driver?: unknown }>) + .then((body) => (body.driver === "blob" ? "blob" : "local")) + .catch(() => "local" as const); + return resolved; } export async function uploadMedia(file: File): Promise<{ url: string }> { - return storageDriver() === "blob" ? uploadToBlob(file) : uploadToLocal(file); + return (await storageDriver()) === "blob" ? uploadToBlob(file) : uploadToLocal(file); } async function uploadToLocal(file: File): Promise<{ url: string }> { diff --git a/src/instrumentation-node.ts b/src/instrumentation-node.ts new file mode 100644 index 0000000..572414f --- /dev/null +++ b/src/instrumentation-node.ts @@ -0,0 +1,16 @@ +import { mkdir } from "node:fs/promises"; + +import { runMigrations } from "@/db/migrate"; + +/** Brings the database up to the current schema before the first request, so + a fresh container or a freshly cloned checkout needs no separate step. */ +export async function migrateOnBoot(): Promise { + const url = process.env.DATABASE_URL?.trim() || process.env.TURSO_DATABASE_URL?.trim() || "file:./data/vitrina.db"; + if (url.startsWith("file:")) { + const path = url.slice(5); + const dir = path.replace(/\/[^/]*$/, ""); + if (dir && dir !== path) await mkdir(dir, { recursive: true }); + } + await runMigrations(); + console.info("[vitrina] database ready"); +} diff --git a/src/instrumentation.ts b/src/instrumentation.ts index 9658b76..e8920d8 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -1,16 +1,12 @@ -/** Runs once when the Node server starts: the database is brought up to the - current schema before the first request, so a fresh container or a - freshly cloned checkout needs no separate migrate step. */ +/** Runs once when the Node server starts. The work lives in a Node-only + module loaded behind the runtime check, the way Next documents it, so + the edge bundle never sees a Node import. */ export async function register(): Promise { if (process.env.NEXT_RUNTIME !== "nodejs") return; if (process.env.NEXT_PHASE === "phase-production-build") return; - const { mkdir } = await import("node:fs/promises"); - const url = process.env.DATABASE_URL?.trim() || "file:./data/vitrina.db"; - if (url.startsWith("file:")) { - const dir = url.slice(5).replace(/\/[^/]*$/, ""); - if (dir && dir !== url.slice(5)) await mkdir(dir, { recursive: true }); - } - const { runMigrations } = await import("@/db/migrate"); - await runMigrations(); - console.info("[vitrina] database ready"); + /* On Vercel the build step migrates (see "vercel-build" in package.json): + many cold-starting functions must not race each other on the schema. */ + if (process.env.VERCEL) return; + const { migrateOnBoot } = await import("./instrumentation-node"); + await migrateOnBoot(); }