Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commit6f3ce18unknown_key

fix(db): dual-driver support — Neon HTTPS + postgres.js TCP

fix(db): dual-driver support — Neon HTTPS + postgres.js TCP

Root cause of the gluecron.com 502 outage: the Vultr box has DATABASE_URL
pointing at a local Postgres (127.0.0.1:5432), but the codebase only ever
imported @neondatabase/serverless. That driver constructs an HTTPS endpoint
from the URL and 'fetch()' it — which produces ERR_INVALID_URL for any
non-Neon host.

Fix: detect the URL host once at first DB access. Route *.neon.tech URLs
to the Neon HTTP driver (unchanged behaviour for hosted users); route
everything else (localhost, RDS, Supabase, plain Postgres) to postgres.js
over TCP via drizzle-orm/postgres-js.

Now the same codebase works for:
- Hosted on Neon (e.g. gluecron.fly.dev)
- Single-VPS self-host with local Postgres (gluecron.com on Vultr)
- Any standard Postgres (RDS, Supabase, on-prem, dev laptop)

Files
- src/db/index.ts: getDb() picks driver based on isNeonUrl()
- src/db/migrate.ts: same dual logic, refactored away from tagged templates
  to a unified Exec function so both drivers handle the same call shape
- src/__tests__/db-driver.test.ts: 8 cases covering Neon variants,
  localhost, RDS, Supabase, malformed URLs, and substring spoofing
- package.json: + postgres@3.4.9 (postgres.js TCP driver)

Tests: 1234 pass / 1 unrelated pre-existing fail.
Claude committed on May 3, 2026Parent: c424421
5 files changed+208806f3ce18a17f629c780442691af1f64b3c5e1f345
5 changed files+208−80
Modifiedbun.lock+3−0View fileUnifiedSplit
1313 "highlight.js": "^11.11.0",
1414 "hono": "^4.7.0",
1515 "marked": "^18.0.0",
16 "postgres": "^3.4.9",
1617 },
1718 "devDependencies": {
1819 "@types/bun": "^1.2.0",
164165
165166 "pg-types": ["pg-types@4.1.0", "", { "dependencies": { "pg-int8": "1.0.1", "pg-numeric": "1.0.2", "postgres-array": "~3.0.1", "postgres-bytea": "~3.0.0", "postgres-date": "~2.1.0", "postgres-interval": "^3.0.0", "postgres-range": "^1.1.1" } }, "sha512-o2XFanIMy/3+mThw69O8d4n1E5zsLhdO+OPqswezu7Z5ekP4hYDqlDjlmOpYMbzY2Br0ufCwJLdDIXeNVwcWFg=="],
166167
168 "postgres": ["postgres@3.4.9", "", {}, "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw=="],
169
167170 "postgres-array": ["postgres-array@3.0.4", "", {}, "sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ=="],
168171
169172 "postgres-bytea": ["postgres-bytea@3.0.0", "", { "dependencies": { "obuf": "~1.1.2" } }, "sha512-CNd4jim9RFPkObHSjVHlVrxoVQXz7quwNFpz7RY1okNNme49+sVyiTvTRobiLV548Hx/hb1BG+iE7h9493WzFw=="],
Modifiedpackage.json+2−1View fileUnifiedSplit
2020 "drizzle-orm": "^0.39.0",
2121 "highlight.js": "^11.11.0",
2222 "hono": "^4.7.0",
23 "marked": "^18.0.0"
23 "marked": "^18.0.0",
24 "postgres": "^3.4.9"
2425 },
2526 "devDependencies": {
2627 "@types/bun": "^1.2.0",
Addedsrc/__tests__/db-driver.test.ts+58−0View fileUnifiedSplit
1/**
2 * Tests for the dual-driver detection in src/db/index.ts.
3 * Verifies which DATABASE_URL hosts route to Neon vs postgres.js.
4 */
5import { describe, expect, it } from "bun:test";
6import { isNeonUrl } from "../db";
7
8describe("isNeonUrl", () => {
9 it("true for Neon US-East endpoint", () => {
10 expect(
11 isNeonUrl(
12 "postgresql://user:pw@ep-cool-name-123.us-east-2.aws.neon.tech/db"
13 )
14 ).toBe(true);
15 });
16
17 it("true for Neon EU endpoint with sslmode", () => {
18 expect(
19 isNeonUrl(
20 "postgres://u:p@ep-x.eu-central-1.aws.neon.tech/d?sslmode=require"
21 )
22 ).toBe(true);
23 });
24
25 it("true for bare neon.tech root", () => {
26 expect(isNeonUrl("postgresql://u:p@neon.tech/db")).toBe(true);
27 });
28
29 it("false for localhost", () => {
30 expect(isNeonUrl("postgresql://u:p@127.0.0.1:5432/db")).toBe(false);
31 expect(isNeonUrl("postgresql://u:p@localhost:5432/db")).toBe(false);
32 });
33
34 it("false for RDS", () => {
35 expect(
36 isNeonUrl(
37 "postgres://u:p@db.example.us-east-1.rds.amazonaws.com:5432/d"
38 )
39 ).toBe(false);
40 });
41
42 it("false for Supabase", () => {
43 expect(
44 isNeonUrl("postgres://u:p@db.abcd.supabase.co:5432/postgres")
45 ).toBe(false);
46 });
47
48 it("false for malformed URL (graceful fallback to TCP driver)", () => {
49 expect(isNeonUrl("not-a-url")).toBe(false);
50 expect(isNeonUrl("")).toBe(false);
51 });
52
53 it("does not match domains that merely contain 'neon.tech' as a substring", () => {
54 expect(isNeonUrl("postgres://u:p@evil-neon.tech.example.com/db")).toBe(
55 false
56 );
57 });
58});
Modifiedsrc/db/index.ts+56−15View fileUnifiedSplit
1/**
2 * Drizzle DB connection — dual-driver, auto-detected from DATABASE_URL.
3 *
4 * - Neon serverless (HTTPS over fetch) — for *.neon.tech hosts
5 * - postgres.js (TCP) — for localhost / self-hosted Postgres
6 *
7 * Lets the same codebase run on Neon's hosted DB AND on a local Postgres
8 * sitting next to the bun process on a single VPS. Picks the right driver
9 * by inspecting the URL host once, at first access.
10 */
11
112import { neon } from "@neondatabase/serverless";
2import { drizzle, type NeonHttpDatabase } from "drizzle-orm/neon-http";
13import { drizzle as drizzleNeon, type NeonHttpDatabase } from "drizzle-orm/neon-http";
14import { drizzle as drizzlePg, type PostgresJsDatabase } from "drizzle-orm/postgres-js";
15import postgres from "postgres";
316import * as schema from "./schema";
417import { config } from "../lib/config";
518
6let _db: NeonHttpDatabase<typeof schema> | null = null;
7
8export function getDb(): NeonHttpDatabase<typeof schema> {
9 if (!_db) {
10 if (!config.databaseUrl) {
11 throw new Error(
12 "DATABASE_URL is not set. Set it in your environment or .env file."
13 );
14 }
15 const sql = neon(config.databaseUrl);
16 _db = drizzle(sql, { schema });
19type AnyDb = NeonHttpDatabase<typeof schema> | PostgresJsDatabase<typeof schema>;
20
21let _db: AnyDb | null = null;
22
23/**
24 * Returns true when the URL points at Neon's serverless HTTPS endpoint.
25 * Anything else (localhost, RDS, Supabase TCP, plain Postgres) goes through
26 * the postgres.js driver.
27 */
28export function isNeonUrl(url: string): boolean {
29 try {
30 const u = new URL(url);
31 return /(^|\.)neon\.tech$/i.test(u.hostname);
32 } catch {
33 return false;
1734 }
35}
36
37export function getDb(): AnyDb {
38 if (_db) return _db;
39
40 if (!config.databaseUrl) {
41 throw new Error(
42 "DATABASE_URL is not set. Set it in your environment or .env file."
43 );
44 }
45
46 const url = config.databaseUrl;
47
48 if (isNeonUrl(url)) {
49 const sql = neon(url);
50 _db = drizzleNeon(sql, { schema });
51 } else {
52 // postgres.js — TCP driver for self-hosted / non-Neon Postgres.
53 // max=10 keeps the connection pool modest on small VPS deployments.
54 const client = postgres(url, { max: 10, prepare: false });
55 _db = drizzlePg(client, { schema });
56 }
57
1858 return _db;
1959}
2060
21// Re-export as `db` for convenience — will throw on access without DATABASE_URL
22export const db = new Proxy({} as NeonHttpDatabase<typeof schema>, {
61// Re-export as `db` for convenience — proxies to the chosen driver lazily.
62// Will throw on first access if DATABASE_URL is unset.
63export const db = new Proxy({} as AnyDb, {
2364 get(_target, prop, receiver) {
24 return Reflect.get(getDb(), prop, receiver);
65 return Reflect.get(getDb(), prop, receiver) as unknown;
2566 },
2667});
Modifiedsrc/db/migrate.ts+89−64View fileUnifiedSplit
11/**
22 * Migration runner — executes every `drizzle/*.sql` file in order.
33 * Tracks applied migrations in a dedicated table so repeat runs are idempotent.
4 *
5 * Dual-driver: uses Neon HTTP for *.neon.tech URLs, postgres.js TCP for
6 * everything else (localhost, RDS, Supabase, plain Postgres).
47 */
58
69import { readdir, readFile } from "fs/promises";
710import { join } from "path";
811import { neon } from "@neondatabase/serverless";
12import postgres from "postgres";
913import { config } from "../lib/config";
14import { isNeonUrl } from "./index";
15
16type Exec = (query: string, params?: unknown[]) => Promise<unknown>;
1017
1118async function runMigrations() {
1219 if (!config.databaseUrl) {
1320 throw new Error("DATABASE_URL is not set");
1421 }
15 const sql = neon(config.databaseUrl);
16
17 // Ensure tracking table exists
18 await sql`
19 CREATE TABLE IF NOT EXISTS "_migrations" (
20 "name" text PRIMARY KEY,
21 "applied_at" timestamp DEFAULT now() NOT NULL
22 )
23 `;
24
25 const applied = await sql`SELECT name FROM _migrations`;
26 const appliedSet = new Set(
27 (applied as Array<{ name: string }>).map((r) => r.name)
28 );
29
30 const migrationsDir = join(process.cwd(), "drizzle");
31 const files = (await readdir(migrationsDir))
32 .filter((f) => f.endsWith(".sql"))
33 .sort();
34
35 if (files.length === 0) {
36 console.log("No migration files found.");
37 return;
22
23 let exec: Exec;
24 let cleanup: (() => Promise<void>) | null = null;
25
26 if (isNeonUrl(config.databaseUrl)) {
27 const neonSql = neon(config.databaseUrl);
28 exec = (q, p = []) => neonSql(q, p);
29 } else {
30 const client = postgres(config.databaseUrl, { max: 1, prepare: false });
31 exec = (q, p) =>
32 p && p.length > 0 ? client.unsafe(q, p as never[]) : client.unsafe(q);
33 cleanup = async () => {
34 await client.end({ timeout: 5 });
35 };
3836 }
3937
40 for (const file of files) {
41 if (appliedSet.has(file)) {
42 console.log(`[migrate] ${file} — already applied, skipping`);
43 continue;
38 try {
39 // Ensure tracking table exists
40 await exec(`
41 CREATE TABLE IF NOT EXISTS "_migrations" (
42 "name" text PRIMARY KEY,
43 "applied_at" timestamp DEFAULT now() NOT NULL
44 )
45 `);
46
47 const appliedRows = (await exec(`SELECT name FROM _migrations`)) as Array<{
48 name: string;
49 }>;
50 const appliedSet = new Set(appliedRows.map((r) => r.name));
51
52 const migrationsDir = join(process.cwd(), "drizzle");
53 const files = (await readdir(migrationsDir))
54 .filter((f) => f.endsWith(".sql"))
55 .sort();
56
57 if (files.length === 0) {
58 console.log("No migration files found.");
59 return;
4460 }
4561
46 console.log(`[migrate] applying ${file}...`);
47 const content = await readFile(join(migrationsDir, file), "utf8");
48
49 // Split on the drizzle breakpoint marker. Each chunk is one statement.
50 const statements = content
51 .split(/-->\s*statement-breakpoint/)
52 .map((s) => s.trim())
53 .filter((s) => s.length > 0);
54
55 for (const raw of statements) {
56 const stripped = raw
57 .split("\n")
58 .filter((line) => !line.trim().startsWith("--"))
59 .join("\n")
60 .trim();
61 if (!stripped) continue;
62
63 try {
64 await sql(stripped);
65 } catch (err) {
66 const msg = err instanceof Error ? err.message : String(err);
67 if (
68 msg.includes("already exists") ||
69 msg.includes("duplicate_column") ||
70 msg.includes("duplicate_object")
71 ) {
72 console.warn(
73 `[migrate] ${file}: ${msg.slice(0, 120)} (treated as applied)`
62 for (const file of files) {
63 if (appliedSet.has(file)) {
64 console.log(`[migrate] ${file} — already applied, skipping`);
65 continue;
66 }
67
68 console.log(`[migrate] applying ${file}...`);
69 const content = await readFile(join(migrationsDir, file), "utf8");
70
71 // Split on the drizzle breakpoint marker. Each chunk is one statement.
72 const statements = content
73 .split(/-->\s*statement-breakpoint/)
74 .map((s) => s.trim())
75 .filter((s) => s.length > 0);
76
77 for (const raw of statements) {
78 const stripped = raw
79 .split("\n")
80 .filter((line) => !line.trim().startsWith("--"))
81 .join("\n")
82 .trim();
83 if (!stripped) continue;
84
85 try {
86 await exec(stripped);
87 } catch (err) {
88 const msg = err instanceof Error ? err.message : String(err);
89 if (
90 msg.includes("already exists") ||
91 msg.includes("duplicate_column") ||
92 msg.includes("duplicate_object")
93 ) {
94 console.warn(
95 `[migrate] ${file}: ${msg.slice(0, 120)} (treated as applied)`
96 );
97 continue;
98 }
99 console.error(
100 `[migrate] ${file} failed on statement:\n${stripped.slice(0, 500)}\n\n${msg}`
74101 );
75 continue;
102 throw err;
76103 }
77 console.error(
78 `[migrate] ${file} failed on statement:\n${stripped.slice(0, 500)}\n\n${msg}`
79 );
80 throw err;
81104 }
105
106 await exec(`INSERT INTO _migrations (name) VALUES ($1)`, [file]);
107 console.log(`[migrate] ${file} applied`);
82108 }
83109
84 await sql`INSERT INTO _migrations (name) VALUES (${file})`;
85 console.log(`[migrate] ${file} applied`);
110 console.log("[migrate] all migrations complete");
111 } finally {
112 if (cleanup) await cleanup();
86113 }
87
88 console.log("[migrate] all migrations complete");
89114}
90115
91116runMigrations().catch((err) => {
92117