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
6 changes: 4 additions & 2 deletions scripts/credits.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <owner-email> <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) {
Expand Down
24 changes: 24 additions & 0 deletions scripts/db-env.mjs
Original file line number Diff line number Diff line change
@@ -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" };
}
10 changes: 6 additions & 4 deletions scripts/migrate.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
5 changes: 3 additions & 2 deletions scripts/wipe-local.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
30 changes: 30 additions & 0 deletions src/db/__tests__/env.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
13 changes: 5 additions & 8 deletions src/db/client.ts
Original file line number Diff line number Diff line change
@@ -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<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. */
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 {
Expand Down
35 changes: 35 additions & 0 deletions src/db/env.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | undefined> = 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<string, string | undefined> = process.env): boolean {
return resolveDatabaseEnv(env).url.startsWith("file:");
}
3 changes: 2 additions & 1 deletion src/instrumentation-node.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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(/\/[^/]*$/, "");
Expand Down
Loading