Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 13 additions & 6 deletions docs/DEPLOY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 3 additions & 2 deletions next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/**"] },
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 3 additions & 1 deletion playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions scripts/credits.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ if (!email || !Number.isInteger(amount) || amount === 0) {
console.error("usage: pnpm credits <owner-email> <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) {
Expand Down
12 changes: 10 additions & 2 deletions scripts/migrate.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
3 changes: 2 additions & 1 deletion scripts/wipe-local.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
8 changes: 7 additions & 1 deletion src/app/api/upload/route.ts
Original file line number Diff line number Diff line change
@@ -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<NextResponse> {
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<NextResponse> {
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");
Expand Down
8 changes: 6 additions & 2 deletions src/db/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,12 @@ export type Db = LibSQLDatabase<typeof schema>;
/** 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" };
}

Expand Down
21 changes: 16 additions & 5 deletions src/generation/upload.ts
Original file line number Diff line number Diff line change
@@ -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<Driver> | 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<Driver> {
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 }> {
Expand Down
16 changes: 16 additions & 0 deletions src/instrumentation-node.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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");
}
20 changes: 8 additions & 12 deletions src/instrumentation.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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();
}
Loading