fix: use percent-encoded DATABASE_URL instead of split DB_PASSWORD trick
Drizzle ORM mutates client.options (parsers/serializers) after the postgres client is created, which causes the separately-passed password option to be lost on the actual connection attempt. Root cause confirmed on VPS: raw postgres.js query succeeded while drizzle.execute() failed with auth error. Fix: encode the password directly in DATABASE_URL (%23 = #, %5D = ], %3D = =). postgres.js decodes percent-encoding correctly. No separate DB_PASSWORD env var needed in the app container anymore. DB_PASSWORD is still used by the Postgres container (POSTGRES_PASSWORD). Coolify env var to set: DATABASE_URL=postgres://wc:<encoded-pass>@db:5432/worldcup Also adds resolver-level isMissingTable() guards so the app returns empty results instead of GraphQL errors on a fresh deploy before sync runs. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+10
-7
@@ -1,7 +1,13 @@
|
||||
# ── Production (Coolify) ────────────────────────────────────────────────────
|
||||
# DB_PASSWORD is passed separately so special characters never need URL-encoding.
|
||||
# DATABASE_URL is constructed inside docker-compose.yml and does NOT need to be
|
||||
# set in Coolify — only DB_PASSWORD is required.
|
||||
# DATABASE_URL must include the password. Special characters must be
|
||||
# percent-encoded so the URL parser handles them correctly:
|
||||
# # → %23 ] → %5D = → %3D @ → %40
|
||||
#
|
||||
# Example with password "p#ss]w=rd":
|
||||
# DATABASE_URL=postgres://wc:p%23ss%5Dw%3Drd@db:5432/worldcup
|
||||
#
|
||||
# DB_PASSWORD is used ONLY by the Postgres container (no encoding needed).
|
||||
DATABASE_URL=postgres://wc:changeme@db:5432/worldcup
|
||||
DB_PASSWORD=changeme
|
||||
|
||||
# Traefik (set TRAEFIK_ENABLED=true when deploying behind Traefik)
|
||||
@@ -9,8 +15,5 @@ TRAEFIK_ENABLED=false
|
||||
TRAEFIK_HOST=worldcup.example.com
|
||||
NETWORK_NAME=traefik-network
|
||||
|
||||
# ── Local development ────────────────────────────────────────────────────────
|
||||
# Set DATABASE_URL when running pnpm dev or pnpm sync on the host directly.
|
||||
# The password can be plain-text here since it goes through the postgres driver,
|
||||
# not URL parsing, when DB_PASSWORD is unset.
|
||||
# ── Local development ─────────────────────────────────────────────────────────
|
||||
# DATABASE_URL=postgres://wc:wc@localhost:5432/worldcup
|
||||
|
||||
+1
-2
@@ -6,8 +6,7 @@ services:
|
||||
db:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
DATABASE_URL: postgres://wc@db:5432/worldcup
|
||||
DB_PASSWORD: ${DB_PASSWORD}
|
||||
DATABASE_URL: ${DATABASE_URL}
|
||||
NODE_ENV: production
|
||||
labels:
|
||||
- "traefik.enable=${TRAEFIK_ENABLED:-false}"
|
||||
|
||||
+6
-8
@@ -2,14 +2,12 @@ import { drizzle } from 'drizzle-orm/postgres-js'
|
||||
import postgres from 'postgres'
|
||||
import * as schema from './schema'
|
||||
|
||||
const connectionString = process.env.DATABASE_URL ?? 'postgres://wc:wc@localhost:5432/worldcup'
|
||||
|
||||
// DB_PASSWORD is passed separately to avoid URL-encoding issues with special chars
|
||||
// (e.g. #, ], = in passwords break URL parsing). Falls back to password in DATABASE_URL for local dev.
|
||||
const client = postgres(connectionString, {
|
||||
max: 10,
|
||||
...(process.env.DB_PASSWORD ? { password: process.env.DB_PASSWORD } : {}),
|
||||
})
|
||||
// Passwords with special chars (#, ], =) must be percent-encoded in DATABASE_URL.
|
||||
// postgres.js decodes them correctly (e.g. %23 → #). No separate DB_PASSWORD needed.
|
||||
const client = postgres(
|
||||
process.env.DATABASE_URL ?? 'postgres://wc:wc@localhost:5432/worldcup',
|
||||
{ max: 10 }
|
||||
)
|
||||
export const db = drizzle(client, { schema })
|
||||
|
||||
export * from './schema'
|
||||
|
||||
@@ -59,74 +59,98 @@ async function hydrateMatch(row: typeof matches.$inferSelect) {
|
||||
}
|
||||
}
|
||||
|
||||
function isMissingTable(e: unknown): boolean {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
return msg.includes('relation') && msg.includes('does not exist')
|
||||
}
|
||||
|
||||
export const resolvers = {
|
||||
Query: {
|
||||
async tournaments() {
|
||||
const rows = await db.select().from(tournaments).orderBy(desc(tournaments.year))
|
||||
return rows.map(r => ({ ...r, avgGoalsPerGame: r.avgGoalsPerGame ? parseFloat(r.avgGoalsPerGame) : null }))
|
||||
try {
|
||||
const rows = await db.select().from(tournaments).orderBy(desc(tournaments.year))
|
||||
return rows.map(r => ({ ...r, avgGoalsPerGame: r.avgGoalsPerGame ? parseFloat(r.avgGoalsPerGame) : null }))
|
||||
} catch (e) { if (isMissingTable(e)) return []; throw e }
|
||||
},
|
||||
async tournament(_: unknown, { year }: { year: number }) {
|
||||
const rows = await db.select().from(tournaments).where(eq(tournaments.year, year)).limit(1)
|
||||
if (!rows[0]) return null
|
||||
return { ...rows[0], avgGoalsPerGame: rows[0].avgGoalsPerGame ? parseFloat(rows[0].avgGoalsPerGame) : null }
|
||||
try {
|
||||
const rows = await db.select().from(tournaments).where(eq(tournaments.year, year)).limit(1)
|
||||
if (!rows[0]) return null
|
||||
return { ...rows[0], avgGoalsPerGame: rows[0].avgGoalsPerGame ? parseFloat(rows[0].avgGoalsPerGame) : null }
|
||||
} catch (e) { if (isMissingTable(e)) return null; throw e }
|
||||
},
|
||||
async matches(_: unknown, args: { year?: number; group?: string; round?: string; isQuali?: boolean }) {
|
||||
const conditions = []
|
||||
if (args.year) conditions.push(eq(matches.tournamentYear, args.year))
|
||||
if (args.group) conditions.push(eq(matches.groupName, args.group))
|
||||
if (args.round) conditions.push(eq(matches.round, args.round))
|
||||
if (args.isQuali != null) conditions.push(eq(matches.isQualiPlayoff, args.isQuali))
|
||||
const rows = await db.select().from(matches)
|
||||
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
||||
.orderBy(asc(matches.date), asc(matches.id))
|
||||
return Promise.all(rows.map(hydrateMatch))
|
||||
try {
|
||||
const conditions = []
|
||||
if (args.year) conditions.push(eq(matches.tournamentYear, args.year))
|
||||
if (args.group) conditions.push(eq(matches.groupName, args.group))
|
||||
if (args.round) conditions.push(eq(matches.round, args.round))
|
||||
if (args.isQuali != null) conditions.push(eq(matches.isQualiPlayoff, args.isQuali))
|
||||
const rows = await db.select().from(matches)
|
||||
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
||||
.orderBy(asc(matches.date), asc(matches.id))
|
||||
return Promise.all(rows.map(hydrateMatch))
|
||||
} catch (e) { if (isMissingTable(e)) return []; throw e }
|
||||
},
|
||||
async match(_: unknown, { id }: { id: number }) {
|
||||
const rows = await db.select().from(matches).where(eq(matches.id, id)).limit(1)
|
||||
return rows[0] ? hydrateMatch(rows[0]) : null
|
||||
try {
|
||||
const rows = await db.select().from(matches).where(eq(matches.id, id)).limit(1)
|
||||
return rows[0] ? hydrateMatch(rows[0]) : null
|
||||
} catch (e) { if (isMissingTable(e)) return null; throw e }
|
||||
},
|
||||
async liveMatches() {
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
const rows = await db.select().from(matches)
|
||||
.where(and(eq(matches.date, today), eq(matches.isQualiPlayoff, false)))
|
||||
.orderBy(asc(matches.id))
|
||||
const hydrated = await Promise.all(rows.map(hydrateMatch))
|
||||
return hydrated.filter(m => m.isLive)
|
||||
try {
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
const rows = await db.select().from(matches)
|
||||
.where(and(eq(matches.date, today), eq(matches.isQualiPlayoff, false)))
|
||||
.orderBy(asc(matches.id))
|
||||
const hydrated = await Promise.all(rows.map(hydrateMatch))
|
||||
return hydrated.filter(m => m.isLive)
|
||||
} catch (e) { if (isMissingTable(e)) return []; throw e }
|
||||
},
|
||||
async recentMatches(_: unknown, { limit = 10 }: { limit?: number }) {
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
const rows = await db.select().from(matches)
|
||||
.where(and(
|
||||
lt(matches.date, today),
|
||||
isNotNull(matches.scoreFtHome),
|
||||
eq(matches.isQualiPlayoff, false),
|
||||
))
|
||||
.orderBy(desc(matches.date), desc(matches.id))
|
||||
.limit(limit)
|
||||
return Promise.all(rows.map(hydrateMatch))
|
||||
try {
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
const rows = await db.select().from(matches)
|
||||
.where(and(
|
||||
lt(matches.date, today),
|
||||
isNotNull(matches.scoreFtHome),
|
||||
eq(matches.isQualiPlayoff, false),
|
||||
))
|
||||
.orderBy(desc(matches.date), desc(matches.id))
|
||||
.limit(limit)
|
||||
return Promise.all(rows.map(hydrateMatch))
|
||||
} catch (e) { if (isMissingTable(e)) return []; throw e }
|
||||
},
|
||||
async upcomingMatches(_: unknown, { limit = 10 }: { limit?: number }) {
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
const rows = await db.select().from(matches)
|
||||
.where(and(
|
||||
gte(matches.date, today),
|
||||
sql`${matches.scoreFtHome} IS NULL`,
|
||||
eq(matches.isQualiPlayoff, false),
|
||||
))
|
||||
.orderBy(asc(matches.date), asc(matches.id))
|
||||
.limit(limit)
|
||||
return Promise.all(rows.map(hydrateMatch))
|
||||
try {
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
const rows = await db.select().from(matches)
|
||||
.where(and(
|
||||
gte(matches.date, today),
|
||||
sql`${matches.scoreFtHome} IS NULL`,
|
||||
eq(matches.isQualiPlayoff, false),
|
||||
))
|
||||
.orderBy(asc(matches.date), asc(matches.id))
|
||||
.limit(limit)
|
||||
return Promise.all(rows.map(hydrateMatch))
|
||||
} catch (e) { if (isMissingTable(e)) return []; throw e }
|
||||
},
|
||||
async teams() {
|
||||
const rows = await db.select().from(teams).orderBy(asc(teams.name))
|
||||
return rows.map(teamWithSlug)
|
||||
try {
|
||||
const rows = await db.select().from(teams).orderBy(asc(teams.name))
|
||||
return rows.map(teamWithSlug)
|
||||
} catch (e) { if (isMissingTable(e)) return []; throw e }
|
||||
},
|
||||
async team(_: unknown, { slug }: { slug: string }) {
|
||||
const rows = await db.select().from(teams)
|
||||
const found = rows.find(r => slugify(r.name) === slug)
|
||||
return found ? teamWithSlug(found) : null
|
||||
try {
|
||||
const rows = await db.select().from(teams)
|
||||
const found = rows.find(r => slugify(r.name) === slug)
|
||||
return found ? teamWithSlug(found) : null
|
||||
} catch (e) { if (isMissingTable(e)) return null; throw e }
|
||||
},
|
||||
async topScorers(_: unknown, { year, limit = 20 }: { year?: number; limit?: number }) {
|
||||
try {
|
||||
const conditions = year
|
||||
? sql`AND m.tournament_year = ${year} AND m.is_quali_playoff = false`
|
||||
: sql`AND m.is_quali_playoff = false`
|
||||
@@ -153,6 +177,7 @@ export const resolvers = {
|
||||
tournaments: r.tournaments,
|
||||
team: r.team_id ? await getTeamById(r.team_id as number) : null,
|
||||
})))
|
||||
} catch (e) { if (isMissingTable(e)) return []; throw e }
|
||||
},
|
||||
async player(_: unknown, { name }: { name: string }) {
|
||||
const rows = await db.execute(sql`
|
||||
@@ -210,6 +235,7 @@ export const resolvers = {
|
||||
})))
|
||||
},
|
||||
async groupStandings(_: unknown, { year }: { year: number }) {
|
||||
try {
|
||||
const rows = await db.select({
|
||||
groupName: groupStandings.groupName,
|
||||
pos: groupStandings.pos,
|
||||
@@ -237,6 +263,7 @@ export const resolvers = {
|
||||
pts: r.pts ?? 0,
|
||||
team: (await getTeamById(r.teamId))!,
|
||||
})))
|
||||
} catch (e) { if (isMissingTable(e)) return []; throw e }
|
||||
},
|
||||
async stadiums(_: unknown, { year }: { year?: number }) {
|
||||
const rows = year
|
||||
@@ -255,26 +282,20 @@ export const resolvers = {
|
||||
}))
|
||||
},
|
||||
async tournamentStats() {
|
||||
const [totals] = await db.execute(sql`
|
||||
SELECT
|
||||
COUNT(DISTINCT t.year)::int AS total_tournaments,
|
||||
COUNT(DISTINCT m.id)::int AS total_matches,
|
||||
COALESCE(SUM(m.score_ft_home + m.score_ft_away), 0)::int AS total_goals,
|
||||
ROUND(COALESCE(SUM(m.score_ft_home + m.score_ft_away), 0)::numeric / NULLIF(COUNT(DISTINCT m.id), 0), 2)::float AS avg_goals
|
||||
FROM tournaments t
|
||||
LEFT JOIN matches m ON m.tournament_year = t.year AND m.is_quali_playoff = false AND m.score_ft_home IS NOT NULL
|
||||
`)
|
||||
const r = totals as Record<string, unknown>
|
||||
return {
|
||||
totalTournaments: r.total_tournaments,
|
||||
totalMatches: r.total_matches,
|
||||
totalGoals: r.total_goals,
|
||||
avgGoalsPerGame: r.avg_goals,
|
||||
mostGoalsInTournament: null,
|
||||
highestScoringMatch: null,
|
||||
biggestWin: null,
|
||||
mostTitles: null,
|
||||
}
|
||||
const empty = { totalTournaments: 0, totalMatches: 0, totalGoals: 0, avgGoalsPerGame: null, mostGoalsInTournament: null, highestScoringMatch: null, biggestWin: null, mostTitles: null }
|
||||
try {
|
||||
const [totals] = await db.execute(sql`
|
||||
SELECT
|
||||
COUNT(DISTINCT t.year)::int AS total_tournaments,
|
||||
COUNT(DISTINCT m.id)::int AS total_matches,
|
||||
COALESCE(SUM(m.score_ft_home + m.score_ft_away), 0)::int AS total_goals,
|
||||
ROUND(COALESCE(SUM(m.score_ft_home + m.score_ft_away), 0)::numeric / NULLIF(COUNT(DISTINCT m.id), 0), 2)::float AS avg_goals
|
||||
FROM tournaments t
|
||||
LEFT JOIN matches m ON m.tournament_year = t.year AND m.is_quali_playoff = false AND m.score_ft_home IS NOT NULL
|
||||
`)
|
||||
const r = totals as Record<string, unknown>
|
||||
return { ...empty, totalTournaments: r.total_tournaments, totalMatches: r.total_matches, totalGoals: r.total_goals, avgGoalsPerGame: r.avg_goals }
|
||||
} catch (e) { if (isMissingTable(e)) return empty; throw e }
|
||||
},
|
||||
async goalsByMinute() {
|
||||
const rows = await db.execute(sql`
|
||||
|
||||
+1
-4
@@ -73,10 +73,7 @@ function parseScore(score: RawScore | undefined) {
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const client = postgres(DATABASE_URL, {
|
||||
max: 5,
|
||||
...(process.env.DB_PASSWORD ? { password: process.env.DB_PASSWORD } : {}),
|
||||
})
|
||||
const client = postgres(DATABASE_URL, { max: 5 })
|
||||
const db = drizzle(client)
|
||||
|
||||
console.log('Creating tables...')
|
||||
|
||||
Reference in New Issue
Block a user