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
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ BETTER_AUTH_SECRET=replace-with-a-random-secret-at-least-32-characters
DATABASE_URL=postgres://root:root123@localhost:5432/Knowhere
# Optional. Defaults to DATABASE_URL when unset.
NEWSLETTER_DATABASE_URL=
# Per-instance PostgreSQL pool limits. The defaults are sized for Vercel and staging.
DATABASE_POOL_MAX=2
NEWSLETTER_DATABASE_POOL_MAX=1
DATABASE_POOL_IDLE_TIMEOUT_MILLISECONDS=10000
DATABASE_POOL_CONNECTION_TIMEOUT_MILLISECONDS=5000
# Set this to true only for local/self-hosted PostgreSQL without SSL.
# UNSAFE_DB_SSL_ENABLED=true
BILLING_ENABLED=false
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ Required for startup:
| `BETTER_AUTH_SECRET` | Random secret with at least 32 characters. |
| `DATABASE_URL` | PostgreSQL connection URL for dashboard auth/account data. |
| `NEWSLETTER_DATABASE_URL` | Optional PostgreSQL connection URL for newsletter subscription data. Falls back to `DATABASE_URL` when unset. |
| `DATABASE_POOL_MAX` | Maximum dashboard auth/account database connections per app instance. Defaults to `2`. |
| `NEWSLETTER_DATABASE_POOL_MAX` | Maximum newsletter database connections per app instance. Defaults to `1`. |
| `DATABASE_POOL_IDLE_TIMEOUT_MILLISECONDS` | Time before an idle database connection is closed. Defaults to `10000`. |
| `DATABASE_POOL_CONNECTION_TIMEOUT_MILLISECONDS` | Time allowed to acquire a new database connection. Defaults to `5000`. |
| `UNSAFE_DB_SSL_ENABLED` | Optional escape hatch for local/self-hosted PostgreSQL without SSL. Set to `true` only when the database does not support SSL. Defaults to `false`, so hosted SaaS keeps SSL enabled without extra config. |

Email/password registration is enabled for self-hosted deployments. The login page defaults to SSO plus Resend-backed email links; set `PASSWORD_LOGIN_ENABLED=true` only when you want to expose the password-login entry point. OAuth and Resend-backed magic-link login are optional add-ons. Password reset emails also use Resend; signed-in OAuth users can set a password from dashboard settings.
Expand Down Expand Up @@ -88,6 +92,8 @@ Optional:

Do not commit `.env.local`, `.env.production`, or any other real environment file.

Both database pools are bounded per application instance. On Vercel, the app registers each pool with the Vercel Functions lifecycle so idle connections can be released before a function instance is suspended.

## Quality Commands

```bash
Expand Down
52 changes: 52 additions & 0 deletions lib/db/database-pool.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { createDatabasePool } from "@lib/db/database-pool";
import { attachDatabasePool } from "@vercel/functions";
import type { Pool } from "pg";
import { afterEach, describe, expect, it, vi } from "vitest";

vi.mock("@vercel/functions", (): { attachDatabasePool: ReturnType<typeof vi.fn> } => ({
attachDatabasePool: vi.fn(),
}));

afterEach((): void => {
vi.clearAllMocks();
vi.unstubAllEnvs();
});

describe("createDatabasePool", (): void => {
it("creates a bounded pool without Vercel lifecycle registration outside Vercel", async (): Promise<void> => {
vi.stubEnv("VERCEL", undefined);

const pool: Pool = createDatabasePool({
connectionString: "postgres://user:pass@example.com:5432/knowhere",
connectionTimeoutMillis: 5_000,
idleTimeoutMillis: 10_000,
max: 2,
ssl: false,
});

expect(pool.options).toMatchObject({
connectionTimeoutMillis: 5_000,
idleTimeoutMillis: 10_000,
max: 2,
});
expect(attachDatabasePool).not.toHaveBeenCalled();
await pool.end();
});

it("registers the bounded pool with the Vercel lifecycle", async (): Promise<void> => {
vi.stubEnv("VERCEL", "1");

const pool: Pool = createDatabasePool({
connectionString: "postgres://user:pass@example.com:5432/knowhere",
connectionTimeoutMillis: 5_000,
idleTimeoutMillis: 10_000,
max: 1,
ssl: false,
});

expect(attachDatabasePool).toHaveBeenCalledOnce();
expect(attachDatabasePool).toHaveBeenCalledWith(pool);
expect(pool.options.max).toBe(1);
await pool.end();
});
});
12 changes: 12 additions & 0 deletions lib/db/database-pool.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { attachDatabasePool } from "@vercel/functions";
import { Pool, type PoolConfig } from "pg";

export function createDatabasePool(configuration: PoolConfig): Pool {
const pool: Pool = new Pool(configuration);

if (process.env.VERCEL === "1") {
attachDatabasePool(pool);
}

return pool;
}
58 changes: 58 additions & 0 deletions lib/db/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { beforeEach, describe, expect, it, type Mock, vi } from "vitest";

interface PoolStub {
readonly pool: string;
}

interface DatabaseStub {
readonly database: string;
}

const createDatabasePool: Mock<() => PoolStub> = vi.fn((): PoolStub => ({ pool: "main" }));
const drizzle: Mock<() => DatabaseStub> = vi.fn((): DatabaseStub => ({ database: "main" }));

vi.mock("@lib/db/database-pool", (): { createDatabasePool: typeof createDatabasePool } => ({
createDatabasePool,
}));
vi.mock("@lib/db/database-ssl", (): { getDatabaseSslConfig: () => boolean } => ({
getDatabaseSslConfig: (): boolean => false,
}));
vi.mock("@lib/db/auth-schema", (): { user: string } => ({ user: "auth-schema" }));
vi.mock("@lib/db/schema", (): { account: string } => ({ account: "app-schema" }));
vi.mock("drizzle-orm/node-postgres", (): { drizzle: typeof drizzle } => ({ drizzle }));
vi.mock("@lib/env", (): { env: Readonly<Record<string, number | string>> } => ({
env: {
DATABASE_POOL_CONNECTION_TIMEOUT_MILLISECONDS: 5_000,
DATABASE_POOL_IDLE_TIMEOUT_MILLISECONDS: 10_000,
DATABASE_POOL_MAX: 2,
DATABASE_URL: "postgres://main.example/knowhere",
UNSAFE_DB_SSL_ENABLED: "false",
},
}));

beforeEach((): void => {
vi.clearAllMocks();
vi.resetModules();
});

describe("main database", (): void => {
it("uses the bounded main pool configuration", async (): Promise<void> => {
const databaseModule: typeof import("@lib/db") = await import("@lib/db");

expect(createDatabasePool).toHaveBeenCalledWith({
connectionString: "postgres://main.example/knowhere",
connectionTimeoutMillis: 5_000,
idleTimeoutMillis: 10_000,
max: 2,
ssl: false,
});
expect(drizzle).toHaveBeenCalledWith({
client: { pool: "main" },
schema: {
account: "app-schema",
user: "auth-schema",
},
});
expect(databaseModule.db).toEqual({ database: "main" });
});
});
7 changes: 5 additions & 2 deletions lib/db/index.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import * as authSchema from "@lib/db/auth-schema";
import { createDatabasePool } from "@lib/db/database-pool";
import { getDatabaseSslConfig } from "@lib/db/database-ssl";
import * as appSchema from "@lib/db/schema";
import { env } from "@lib/env";
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";

// Connection pool for dashboard auth/account data.
const pool = new Pool({
const pool: ReturnType<typeof createDatabasePool> = createDatabasePool({
connectionString: env.DATABASE_URL,
connectionTimeoutMillis: env.DATABASE_POOL_CONNECTION_TIMEOUT_MILLISECONDS,
idleTimeoutMillis: env.DATABASE_POOL_IDLE_TIMEOUT_MILLISECONDS,
max: env.DATABASE_POOL_MAX,
ssl: getDatabaseSslConfig(env.UNSAFE_DB_SSL_ENABLED),
});

Expand Down
61 changes: 61 additions & 0 deletions lib/db/newsletter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { beforeEach, describe, expect, it, type Mock, vi } from "vitest";

interface PoolStub {
readonly pool: string;
}

interface DatabaseStub {
readonly database: string;
}

const createDatabasePool: Mock<() => PoolStub> = vi.fn((): PoolStub => ({ pool: "newsletter" }));
const drizzle: Mock<() => DatabaseStub> = vi.fn((): DatabaseStub => ({ database: "newsletter" }));

vi.mock("@lib/db/database-pool", (): { createDatabasePool: typeof createDatabasePool } => ({
createDatabasePool,
}));
vi.mock("@lib/db/database-ssl", (): { getDatabaseSslConfig: () => boolean } => ({
getDatabaseSslConfig: (): boolean => false,
}));
vi.mock("@lib/db/newsletter-schema", (): { subscription: string } => ({
subscription: "newsletter-schema",
}));
vi.mock("drizzle-orm/node-postgres", (): { drizzle: typeof drizzle } => ({ drizzle }));
vi.mock("@lib/env", (): { env: Readonly<Record<string, number | string>> } => ({
env: {
DATABASE_POOL_CONNECTION_TIMEOUT_MILLISECONDS: 5_000,
DATABASE_POOL_IDLE_TIMEOUT_MILLISECONDS: 10_000,
DATABASE_URL: "postgres://main.example/knowhere",
NEWSLETTER_DATABASE_POOL_MAX: 1,
NEWSLETTER_DATABASE_URL: "postgres://newsletter.example/knowhere",
UNSAFE_DB_SSL_ENABLED: "false",
},
}));

beforeEach((): void => {
vi.clearAllMocks();
vi.resetModules();
});

describe("newsletter database", (): void => {
it("uses the independent bounded newsletter pool", async (): Promise<void> => {
const newsletterModule: typeof import("@lib/db/newsletter") = await import(
"@lib/db/newsletter"
);

expect(createDatabasePool).toHaveBeenCalledWith({
connectionString: "postgres://newsletter.example/knowhere",
connectionTimeoutMillis: 5_000,
idleTimeoutMillis: 10_000,
max: 1,
ssl: false,
});
expect(drizzle).toHaveBeenCalledWith({
client: { pool: "newsletter" },
schema: {
subscription: "newsletter-schema",
},
});
expect(newsletterModule.newsletterDb).toEqual({ database: "newsletter" });
});
});
7 changes: 5 additions & 2 deletions lib/db/newsletter.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
import { createDatabasePool } from "@lib/db/database-pool";
import { getDatabaseSslConfig } from "@lib/db/database-ssl";
import * as newsletterSchema from "@lib/db/newsletter-schema";
import { env } from "@lib/env";
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";

function getNewsletterDatabaseUrl(): string {
return env.NEWSLETTER_DATABASE_URL ?? env.DATABASE_URL;
}

const newsletterPool = new Pool({
const newsletterPool: ReturnType<typeof createDatabasePool> = createDatabasePool({
connectionString: getNewsletterDatabaseUrl(),
connectionTimeoutMillis: env.DATABASE_POOL_CONNECTION_TIMEOUT_MILLISECONDS,
idleTimeoutMillis: env.DATABASE_POOL_IDLE_TIMEOUT_MILLISECONDS,
max: env.NEWSLETTER_DATABASE_POOL_MAX,
ssl: getDatabaseSslConfig(env.UNSAFE_DB_SSL_ENABLED),
});

Expand Down
29 changes: 29 additions & 0 deletions lib/env.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,35 @@ describe("env.AUTH_COOKIE_PREFIX", () => {
});
});

describe("env database pools", (): void => {
it("uses the bounded staging and Vercel pool defaults", async (): Promise<void> => {
const envModule: EnvModule = await loadEnv({
DATABASE_POOL_CONNECTION_TIMEOUT_MILLISECONDS: undefined,
DATABASE_POOL_IDLE_TIMEOUT_MILLISECONDS: undefined,
DATABASE_POOL_MAX: undefined,
NEWSLETTER_DATABASE_POOL_MAX: undefined,
});

expect(envModule.env.DATABASE_POOL_MAX).toBe(2);
expect(envModule.env.NEWSLETTER_DATABASE_POOL_MAX).toBe(1);
expect(envModule.env.DATABASE_POOL_IDLE_TIMEOUT_MILLISECONDS).toBe(10_000);
expect(envModule.env.DATABASE_POOL_CONNECTION_TIMEOUT_MILLISECONDS).toBe(5_000);
});

it("rejects non-positive database pool values", async (): Promise<void> => {
const consoleError: ReturnType<typeof vi.spyOn> = vi
.spyOn(console, "error")
.mockImplementation((): void => {});

await expect(
loadEnv({
DATABASE_POOL_MAX: "0",
})
).rejects.toThrow("Invalid environment variables");
expect(consoleError).toHaveBeenCalled();
});
});

describe("env.OPENAI_ADS", () => {
it("normalizes blank OpenAI Ads values to undefined", async () => {
const { env } = await loadEnv({
Expand Down
11 changes: 11 additions & 0 deletions lib/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,18 @@ function normalizeOptionalUrl(value: unknown): unknown {
return normalizeOptionalString(value);
}

const POSITIVE_INTEGER: z.ZodCoercedNumber = z.coerce.number().int().positive();

export const env = createEnv({
server: {
BETTER_AUTH_SECRET: z.string().min(32),
BETTER_AUTH_URL: z.url(),
DATABASE_URL: z.url(),
NEWSLETTER_DATABASE_URL: z.preprocess(normalizeOptionalUrl, z.url().optional()),
DATABASE_POOL_MAX: POSITIVE_INTEGER.default(2),
NEWSLETTER_DATABASE_POOL_MAX: POSITIVE_INTEGER.default(1),
DATABASE_POOL_IDLE_TIMEOUT_MILLISECONDS: POSITIVE_INTEGER.default(10_000),
DATABASE_POOL_CONNECTION_TIMEOUT_MILLISECONDS: POSITIVE_INTEGER.default(5_000),
UNSAFE_DB_SSL_ENABLED: z.string().default("false"),
GA_MEASUREMENT_ID: z
.string()
Expand Down Expand Up @@ -91,6 +97,11 @@ export const env = createEnv({
BETTER_AUTH_URL: process.env.BETTER_AUTH_URL,
DATABASE_URL: process.env.DATABASE_URL,
NEWSLETTER_DATABASE_URL: process.env.NEWSLETTER_DATABASE_URL,
DATABASE_POOL_MAX: process.env.DATABASE_POOL_MAX,
NEWSLETTER_DATABASE_POOL_MAX: process.env.NEWSLETTER_DATABASE_POOL_MAX,
DATABASE_POOL_IDLE_TIMEOUT_MILLISECONDS: process.env.DATABASE_POOL_IDLE_TIMEOUT_MILLISECONDS,
DATABASE_POOL_CONNECTION_TIMEOUT_MILLISECONDS:
process.env.DATABASE_POOL_CONNECTION_TIMEOUT_MILLISECONDS,
UNSAFE_DB_SSL_ENABLED: process.env.UNSAFE_DB_SSL_ENABLED,
GA_MEASUREMENT_ID: process.env.GA_MEASUREMENT_ID,
OPENAI_ADS_PIXEL_ID: process.env.OPENAI_ADS_PIXEL_ID,
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@
"@tanstack/react-query": "^5.62.8",
"@tanstack/react-query-devtools": "^5.62.8",
"@tanstack/react-table": "^8.21.3",
"@vercel/functions": "3.9.3",
"better-auth": "^1.4.7",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
Expand Down
Loading
Loading