diff --git a/scripts/credits.mjs b/scripts/credits.mjs index 4774883..8897690 100644 --- a/scripts/credits.mjs +++ b/scripts/credits.mjs @@ -3,14 +3,16 @@ import { createClient } from "@libsql/client"; import { randomUUID } from "node:crypto"; +import { resolveDatabaseEnv } from "./db-env.mjs"; + const [email, amountText, note = "manual grant"] = process.argv.slice(2); const amount = Number(amountText); if (!email || !Number.isInteger(amount) || amount === 0) { console.error("usage: pnpm credits [note]"); process.exit(1); } -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 { url, authToken } = resolveDatabaseEnv(); +const client = createClient({ url, authToken }); 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/db-env.mjs b/scripts/db-env.mjs new file mode 100644 index 0000000..dd3981b --- /dev/null +++ b/scripts/db-env.mjs @@ -0,0 +1,24 @@ +/* Finds the database the environment describes, whatever the integration + called it. Order: DATABASE_URL, TURSO_DATABASE_URL, then any variable + ending in _URL whose value is a libsql/Turso URL (the Vercel marketplace + lets the prefix be chosen, so the name is not fixed). The token is the + sibling with the same prefix ending in _AUTH_TOKEN or _TOKEN. Shared by + the scripts; src/db/env.ts is the same logic for the app. */ +export function resolveDatabaseEnv(env = process.env) { + const isRemote = (value) => typeof value === "string" && /^(libsql:\/\/|wss?:\/\/|https?:\/\/[^/]*turso\.io)/.test(value.trim()); + const pick = (urlKey, tokenKeys) => { + const url = env[urlKey]?.trim(); + if (!url) return null; + const token = tokenKeys.map((key) => env[key]?.trim()).find(Boolean); + return { url, authToken: token || undefined, source: urlKey }; + }; + const direct = pick("DATABASE_URL", ["DATABASE_AUTH_TOKEN", "DATABASE_TOKEN"]) ?? pick("TURSO_DATABASE_URL", ["TURSO_AUTH_TOKEN", "TURSO_TOKEN"]); + if (direct) return direct; + for (const key of Object.keys(env).sort()) { + if (!key.endsWith("_URL") || !isRemote(env[key])) continue; + const prefix = key.slice(0, -"_URL".length).replace(/_DATABASE$/, ""); + const found = pick(key, [`${prefix}_DATABASE_AUTH_TOKEN`, `${prefix}_AUTH_TOKEN`, `${prefix}_DATABASE_TOKEN`, `${prefix}_TOKEN`]); + if (found) return found; + } + return { url: "file:./data/vitrina.db", authToken: undefined, source: "default" }; +} diff --git a/scripts/migrate.mjs b/scripts/migrate.mjs index 5267d08..4b2a1ef 100644 --- a/scripts/migrate.mjs +++ b/scripts/migrate.mjs @@ -3,18 +3,20 @@ 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() || process.env.TURSO_DATABASE_URL?.trim() || "file:./data/vitrina.db"; +import { resolveDatabaseEnv } from "./db-env.mjs"; + +const { url, authToken, source } = resolveDatabaseEnv(); /* 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.", + "[migrate] No remote database configured. On Vercel connect the Turso integration to this project (any variable prefix works) 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() || process.env.TURSO_AUTH_TOKEN?.trim() || undefined }); +const client = createClient({ url, authToken }); await migrate(drizzle(client), { migrationsFolder: "./drizzle" }); -console.log(`[migrate] database up to date (${url.startsWith("file:") ? url : "remote"})`); +console.log(`[migrate] database up to date (${url.startsWith("file:") ? url : `remote via ${source}`})`); client.close(); diff --git a/scripts/wipe-local.mjs b/scripts/wipe-local.mjs index a266a49..3a9f1b0 100644 --- a/scripts/wipe-local.mjs +++ b/scripts/wipe-local.mjs @@ -13,13 +13,14 @@ import { existsSync, readdirSync, rmSync } from "node:fs"; import { resolve } from "node:path"; import { createInterface } from "node:readline/promises"; +import { resolveDatabaseEnv } from "./db-env.mjs"; + const args = new Set(process.argv.slice(2)); const root = resolve(import.meta.dirname, ".."); const dataDir = resolve(root, process.env.DATA_DIR?.trim() || "./data"); const dryRun = args.has("--dry-run"); -const dbUrl = process.env.DATABASE_URL?.trim() || process.env.TURSO_DATABASE_URL?.trim() || ""; -const remoteDb = Boolean(dbUrl && !dbUrl.startsWith("file:")); +const remoteDb = !resolveDatabaseEnv().url.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/db/__tests__/env.test.ts b/src/db/__tests__/env.test.ts new file mode 100644 index 0000000..f531a88 --- /dev/null +++ b/src/db/__tests__/env.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; + +import { resolveDatabaseEnv } from "../env"; + +describe("resolveDatabaseEnv", () => { + it("defaults to the local file", () => { + expect(resolveDatabaseEnv({}).url).toBe("file:./data/vitrina.db"); + }); + + it("prefers DATABASE_URL, then TURSO_DATABASE_URL", () => { + expect(resolveDatabaseEnv({ DATABASE_URL: "libsql://a", DATABASE_AUTH_TOKEN: "t1", TURSO_DATABASE_URL: "libsql://b" })).toMatchObject({ + url: "libsql://a", + authToken: "t1", + }); + expect(resolveDatabaseEnv({ TURSO_DATABASE_URL: "libsql://b", TURSO_AUTH_TOKEN: "t2" })).toMatchObject({ url: "libsql://b", authToken: "t2" }); + }); + + it("finds a marketplace-prefixed pair by value", () => { + expect(resolveDatabaseEnv({ STORAGE_URL: "libsql://c.turso.io", STORAGE_AUTH_TOKEN: "t3", OTHER_URL: "https://example.com" })).toMatchObject({ + url: "libsql://c.turso.io", + authToken: "t3", + source: "STORAGE_URL", + }); + expect(resolveDatabaseEnv({ MYDB_DATABASE_URL: "libsql://d", MYDB_TOKEN: "t4" })).toMatchObject({ url: "libsql://d", authToken: "t4" }); + }); + + it("ignores unrelated _URL variables", () => { + expect(resolveDatabaseEnv({ NEXT_PUBLIC_SITE_URL: "https://vitrina.app", APP_URL: "https://vitrina.app" }).url).toBe("file:./data/vitrina.db"); + }); +}); diff --git a/src/db/client.ts b/src/db/client.ts index 0f3818d..9a85040 100644 --- a/src/db/client.ts +++ b/src/db/client.ts @@ -1,20 +1,17 @@ import { createClient, type Client } from "@libsql/client"; import { drizzle, type LibSQLDatabase } from "drizzle-orm/libsql"; +import { resolveDatabaseEnv } from "./env"; import * as schema from "./schema"; 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. */ + use a file; Turso (or any libsql server) takes a URL and a token, under + whatever names the integration chose (see ./env). */ function resolveUrl(): { url: string; authToken?: string } { - /* 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" }; + const { url, authToken } = resolveDatabaseEnv(); + return { url, authToken }; } declare global { diff --git a/src/db/env.ts b/src/db/env.ts new file mode 100644 index 0000000..8bdfe8a --- /dev/null +++ b/src/db/env.ts @@ -0,0 +1,35 @@ +/** Finds the database the environment describes, whatever the integration + called it. Order: DATABASE_URL, TURSO_DATABASE_URL, then any variable + ending in _URL whose value is a libsql/Turso URL — the Vercel marketplace + lets the prefix be chosen, so the name is not fixed. The token is the + sibling with the same prefix ending in _AUTH_TOKEN or _TOKEN. Mirrors + scripts/db-env.mjs. */ +export type DatabaseEnv = { url: string; authToken?: string; source: string }; + +/* A libsql URL, or Turso over HTTPS. A plain https URL is somebody else's. */ +const REMOTE = /^(libsql:\/\/|wss?:\/\/|https?:\/\/[^/]*turso\.io)/; + +export function resolveDatabaseEnv(env: Record = process.env): DatabaseEnv { + const pick = (urlKey: string, tokenKeys: string[]): DatabaseEnv | null => { + const url = env[urlKey]?.trim(); + if (!url) return null; + const authToken = tokenKeys.map((key) => env[key]?.trim()).find(Boolean); + return { url, authToken: authToken || undefined, source: urlKey }; + }; + const direct = + pick("DATABASE_URL", ["DATABASE_AUTH_TOKEN", "DATABASE_TOKEN"]) ?? + pick("TURSO_DATABASE_URL", ["TURSO_AUTH_TOKEN", "TURSO_TOKEN"]); + if (direct) return direct; + for (const key of Object.keys(env).sort()) { + const value = env[key]; + if (!key.endsWith("_URL") || !value || !REMOTE.test(value.trim())) continue; + const prefix = key.slice(0, -"_URL".length).replace(/_DATABASE$/, ""); + const found = pick(key, [`${prefix}_DATABASE_AUTH_TOKEN`, `${prefix}_AUTH_TOKEN`, `${prefix}_DATABASE_TOKEN`, `${prefix}_TOKEN`]); + if (found) return found; + } + return { url: "file:./data/vitrina.db", source: "default" }; +} + +export function isFileDatabase(env: Record = process.env): boolean { + return resolveDatabaseEnv(env).url.startsWith("file:"); +} diff --git a/src/instrumentation-node.ts b/src/instrumentation-node.ts index 572414f..cc27ff8 100644 --- a/src/instrumentation-node.ts +++ b/src/instrumentation-node.ts @@ -1,11 +1,12 @@ import { mkdir } from "node:fs/promises"; +import { resolveDatabaseEnv } from "@/db/env"; 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"; + const { url } = resolveDatabaseEnv(); if (url.startsWith("file:")) { const path = url.slice(5); const dir = path.replace(/\/[^/]*$/, "");