From 58c6edf95a08648ba9855e433695ffd7831de69a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Su=C3=A1rez=20Mart=C3=ADn?= Date: Sat, 19 Sep 2026 01:13:01 +0000 Subject: [PATCH 1/3] Accept the database under any integration prefix The Vercel marketplace lets the Turso connection choose its variable prefix, so the app and the scripts now find the libsql URL and token by value and shape (DATABASE_URL, TURSO_DATABASE_URL, or any _URL with a libsql value and its sibling token). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01QktEqYfYA1H2gQRbvG88pU --- scripts/credits.mjs | 6 ++++-- scripts/db-env.mjs | 24 ++++++++++++++++++++++++ scripts/migrate.mjs | 10 ++++++---- scripts/wipe-local.mjs | 5 +++-- src/db/__tests__/env.test.ts | 30 ++++++++++++++++++++++++++++++ src/db/client.ts | 13 +++++-------- src/db/env.ts | 34 ++++++++++++++++++++++++++++++++++ src/instrumentation-node.ts | 3 ++- 8 files changed, 108 insertions(+), 17 deletions(-) create mode 100644 scripts/db-env.mjs create mode 100644 src/db/__tests__/env.test.ts create mode 100644 src/db/env.ts 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..ca70701 --- /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|https?|wss?):\/\//.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..bd5d31f --- /dev/null +++ b/src/db/env.ts @@ -0,0 +1,34 @@ +/** 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 }; + +const REMOTE = /^(libsql|https?|wss?):\/\//; + +export function resolveDatabaseEnv(env: NodeJS.ProcessEnv = 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: NodeJS.ProcessEnv = 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(/\/[^/]*$/, ""); From d12721ebda8e742f6c1c15e1b1ada5b68e744c96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Su=C3=A1rez=20Mart=C3=ADn?= Date: Sat, 19 Sep 2026 01:14:09 +0000 Subject: [PATCH 2/3] Type the env resolver over a plain record so tests can pass fixtures Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01QktEqYfYA1H2gQRbvG88pU --- src/db/env.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/db/env.ts b/src/db/env.ts index bd5d31f..a810ddd 100644 --- a/src/db/env.ts +++ b/src/db/env.ts @@ -8,7 +8,7 @@ export type DatabaseEnv = { url: string; authToken?: string; source: string }; const REMOTE = /^(libsql|https?|wss?):\/\//; -export function resolveDatabaseEnv(env: NodeJS.ProcessEnv = process.env): DatabaseEnv { +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; @@ -29,6 +29,6 @@ export function resolveDatabaseEnv(env: NodeJS.ProcessEnv = process.env): Databa return { url: "file:./data/vitrina.db", source: "default" }; } -export function isFileDatabase(env: NodeJS.ProcessEnv = process.env): boolean { +export function isFileDatabase(env: Record = process.env): boolean { return resolveDatabaseEnv(env).url.startsWith("file:"); } From d779fe79648af471e0c7703c119dd0bf92ba8e62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Su=C3=A1rez=20Mart=C3=ADn?= Date: Sat, 19 Sep 2026 01:14:45 +0000 Subject: [PATCH 3/3] Only libsql or Turso URLs count as the database when scanning by value Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01QktEqYfYA1H2gQRbvG88pU --- scripts/db-env.mjs | 2 +- src/db/env.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/db-env.mjs b/scripts/db-env.mjs index ca70701..dd3981b 100644 --- a/scripts/db-env.mjs +++ b/scripts/db-env.mjs @@ -5,7 +5,7 @@ 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|https?|wss?):\/\//.test(value.trim()); + 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; diff --git a/src/db/env.ts b/src/db/env.ts index a810ddd..8bdfe8a 100644 --- a/src/db/env.ts +++ b/src/db/env.ts @@ -6,7 +6,8 @@ scripts/db-env.mjs. */ export type DatabaseEnv = { url: string; authToken?: string; source: string }; -const REMOTE = /^(libsql|https?|wss?):\/\//; +/* 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 => {