From 7587f01c4f4a3a6efccb379c8909bb44ed68adc5 Mon Sep 17 00:00:00 2001 From: suguanYang Date: Wed, 12 Aug 2026 19:33:43 +0800 Subject: [PATCH 1/3] feat: bound dashboard database pools for Vercel --- .env.example | 5 ++ README.md | 6 ++ lib/db/database-pool.test.ts | 51 ++++++++++++ lib/db/database-pool.ts | 12 +++ lib/db/index.test.ts | 46 +++++++++++ lib/db/index.ts | 7 +- lib/db/newsletter.test.ts | 45 +++++++++++ lib/db/newsletter.ts | 7 +- lib/env.test.ts | 27 +++++++ lib/env.ts | 10 +++ package.json | 1 + pnpm-lock.yaml | 152 +++++++++++++++++++++++++++++++++++ 12 files changed, 365 insertions(+), 4 deletions(-) create mode 100644 lib/db/database-pool.test.ts create mode 100644 lib/db/database-pool.ts create mode 100644 lib/db/index.test.ts create mode 100644 lib/db/newsletter.test.ts diff --git a/.env.example b/.env.example index 531c8dcd..13766fc9 100644 --- a/.env.example +++ b/.env.example @@ -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_MS=10000 +DATABASE_POOL_CONNECTION_TIMEOUT_MS=5000 # Set this to true only for local/self-hosted PostgreSQL without SSL. # UNSAFE_DB_SSL_ENABLED=true BILLING_ENABLED=false diff --git a/README.md b/README.md index 41d7aee0..cd0fd550 100644 --- a/README.md +++ b/README.md @@ -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_MS` | Time before an idle database connection is closed. Defaults to `10000`. | +| `DATABASE_POOL_CONNECTION_TIMEOUT_MS` | 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. @@ -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 diff --git a/lib/db/database-pool.test.ts b/lib/db/database-pool.test.ts new file mode 100644 index 00000000..16e61d53 --- /dev/null +++ b/lib/db/database-pool.test.ts @@ -0,0 +1,51 @@ +import { createDatabasePool } from "@lib/db/database-pool"; +import { attachDatabasePool } from "@vercel/functions"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@vercel/functions", () => ({ + attachDatabasePool: vi.fn(), +})); + +afterEach(() => { + vi.clearAllMocks(); + vi.unstubAllEnvs(); +}); + +describe("createDatabasePool", () => { + it("creates a bounded pool without Vercel lifecycle registration outside Vercel", async () => { + vi.stubEnv("VERCEL", undefined); + + const 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 () => { + vi.stubEnv("VERCEL", "1"); + + const 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(); + }); +}); diff --git a/lib/db/database-pool.ts b/lib/db/database-pool.ts new file mode 100644 index 00000000..3fa60768 --- /dev/null +++ b/lib/db/database-pool.ts @@ -0,0 +1,12 @@ +import { attachDatabasePool } from "@vercel/functions"; +import { Pool, type PoolConfig } from "pg"; + +export function createDatabasePool(config: PoolConfig): Pool { + const pool = new Pool(config); + + if (process.env.VERCEL === "1") { + attachDatabasePool(pool); + } + + return pool; +} diff --git a/lib/db/index.test.ts b/lib/db/index.test.ts new file mode 100644 index 00000000..bfbe0cfa --- /dev/null +++ b/lib/db/index.test.ts @@ -0,0 +1,46 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const createDatabasePool = vi.fn(() => ({ pool: "main" })); +const drizzle = vi.fn(() => ({ database: "main" })); + +vi.mock("@lib/db/database-pool", () => ({ createDatabasePool })); +vi.mock("@lib/db/database-ssl", () => ({ getDatabaseSslConfig: () => false })); +vi.mock("@lib/db/auth-schema", () => ({ user: "auth-schema" })); +vi.mock("@lib/db/schema", () => ({ account: "app-schema" })); +vi.mock("drizzle-orm/node-postgres", () => ({ drizzle })); +vi.mock("@lib/env", () => ({ + env: { + DATABASE_POOL_CONNECTION_TIMEOUT_MS: 5_000, + DATABASE_POOL_IDLE_TIMEOUT_MS: 10_000, + DATABASE_POOL_MAX: 2, + DATABASE_URL: "postgres://main.example/knowhere", + UNSAFE_DB_SSL_ENABLED: "false", + }, +})); + +beforeEach(() => { + vi.clearAllMocks(); + vi.resetModules(); +}); + +describe("main database", () => { + it("uses the bounded main pool configuration", async () => { + const { 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(db).toEqual({ database: "main" }); + }); +}); diff --git a/lib/db/index.ts b/lib/db/index.ts index 0e46be12..7956812c 100644 --- a/lib/db/index.ts +++ b/lib/db/index.ts @@ -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 = createDatabasePool({ connectionString: env.DATABASE_URL, + connectionTimeoutMillis: env.DATABASE_POOL_CONNECTION_TIMEOUT_MS, + idleTimeoutMillis: env.DATABASE_POOL_IDLE_TIMEOUT_MS, + max: env.DATABASE_POOL_MAX, ssl: getDatabaseSslConfig(env.UNSAFE_DB_SSL_ENABLED), }); diff --git a/lib/db/newsletter.test.ts b/lib/db/newsletter.test.ts new file mode 100644 index 00000000..2c24ea21 --- /dev/null +++ b/lib/db/newsletter.test.ts @@ -0,0 +1,45 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const createDatabasePool = vi.fn(() => ({ pool: "newsletter" })); +const drizzle = vi.fn(() => ({ database: "newsletter" })); + +vi.mock("@lib/db/database-pool", () => ({ createDatabasePool })); +vi.mock("@lib/db/database-ssl", () => ({ getDatabaseSslConfig: () => false })); +vi.mock("@lib/db/newsletter-schema", () => ({ subscription: "newsletter-schema" })); +vi.mock("drizzle-orm/node-postgres", () => ({ drizzle })); +vi.mock("@lib/env", () => ({ + env: { + DATABASE_POOL_CONNECTION_TIMEOUT_MS: 5_000, + DATABASE_POOL_IDLE_TIMEOUT_MS: 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(() => { + vi.clearAllMocks(); + vi.resetModules(); +}); + +describe("newsletter database", () => { + it("uses the independent bounded newsletter pool", async () => { + const { newsletterDb } = 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(newsletterDb).toEqual({ database: "newsletter" }); + }); +}); diff --git a/lib/db/newsletter.ts b/lib/db/newsletter.ts index 13ac6c3d..d7309569 100644 --- a/lib/db/newsletter.ts +++ b/lib/db/newsletter.ts @@ -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 = createDatabasePool({ connectionString: getNewsletterDatabaseUrl(), + connectionTimeoutMillis: env.DATABASE_POOL_CONNECTION_TIMEOUT_MS, + idleTimeoutMillis: env.DATABASE_POOL_IDLE_TIMEOUT_MS, + max: env.NEWSLETTER_DATABASE_POOL_MAX, ssl: getDatabaseSslConfig(env.UNSAFE_DB_SSL_ENABLED), }); diff --git a/lib/env.test.ts b/lib/env.test.ts index c12b1e3b..eab724e2 100644 --- a/lib/env.test.ts +++ b/lib/env.test.ts @@ -52,6 +52,33 @@ describe("env.AUTH_COOKIE_PREFIX", () => { }); }); +describe("env database pools", () => { + it("uses the bounded staging and Vercel pool defaults", async () => { + const { env } = await loadEnv({ + DATABASE_POOL_CONNECTION_TIMEOUT_MS: undefined, + DATABASE_POOL_IDLE_TIMEOUT_MS: undefined, + DATABASE_POOL_MAX: undefined, + NEWSLETTER_DATABASE_POOL_MAX: undefined, + }); + + expect(env.DATABASE_POOL_MAX).toBe(2); + expect(env.NEWSLETTER_DATABASE_POOL_MAX).toBe(1); + expect(env.DATABASE_POOL_IDLE_TIMEOUT_MS).toBe(10_000); + expect(env.DATABASE_POOL_CONNECTION_TIMEOUT_MS).toBe(5_000); + }); + + it("rejects non-positive database pool values", async () => { + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + + 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({ diff --git a/lib/env.ts b/lib/env.ts index e5410f4c..846c9f7a 100644 --- a/lib/env.ts +++ b/lib/env.ts @@ -15,12 +15,18 @@ function normalizeOptionalUrl(value: unknown): unknown { return normalizeOptionalString(value); } +const positiveInteger = 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: positiveInteger.default(2), + NEWSLETTER_DATABASE_POOL_MAX: positiveInteger.default(1), + DATABASE_POOL_IDLE_TIMEOUT_MS: positiveInteger.default(10_000), + DATABASE_POOL_CONNECTION_TIMEOUT_MS: positiveInteger.default(5_000), UNSAFE_DB_SSL_ENABLED: z.string().default("false"), GA_MEASUREMENT_ID: z .string() @@ -91,6 +97,10 @@ 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_MS: process.env.DATABASE_POOL_IDLE_TIMEOUT_MS, + DATABASE_POOL_CONNECTION_TIMEOUT_MS: process.env.DATABASE_POOL_CONNECTION_TIMEOUT_MS, 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, diff --git a/package.json b/package.json index 551d5881..bdc69051 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a80dbd06..aec4744d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -140,6 +140,9 @@ importers: '@tanstack/react-table': specifier: ^8.21.3 version: 8.21.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@vercel/functions': + specifier: 3.9.3 + version: 3.9.3(@aws-sdk/credential-provider-web-identity@3.958.0) better-auth: specifier: ^1.4.7 version: 1.4.10(drizzle-kit@0.31.8)(drizzle-orm@0.45.1(@neondatabase/serverless@1.0.2)(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.17.2))(next@16.1.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(pg@8.17.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vitest@4.1.5(@types/node@20.19.27)(vite@8.0.10(@types/node@20.19.27)(esbuild@0.25.12)(jiti@1.21.7)(yaml@2.8.2))) @@ -2465,6 +2468,29 @@ packages: '@types/use-sync-external-store@0.0.6': resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} + '@vercel/cli-config@0.2.3': + resolution: {integrity: sha512-Ggh0Wmi92TUkUexmSUPkkDtvJmbjUr7IvF5T3FkSsWrXXs3GFzOujfxFpECdJZpux1JG4SWDv9BT4w++TDgD6A==} + + '@vercel/cli-exec@1.0.1': + resolution: {integrity: sha512-g9XerViJ/paZujufXYcu5XYI2vU2rtB4sgdpjUHde5RnOkdmpu0ngH46LCFGHoPXO/C+qDPSczIHIRN+8Q2YKQ==} + engines: {node: '>= 18'} + + '@vercel/functions@3.9.3': + resolution: {integrity: sha512-cbzTdASCZDnufrABc8oO00e/FqlqFFSdld+iGZPhrWBDHP4Pu8ESKKYIzASqYvbYYUIWujBvEa5LLCcZzm7WEw==} + engines: {node: '>= 20'} + peerDependencies: + '@aws-sdk/credential-provider-web-identity': '*' + ws: '>=8' + peerDependenciesMeta: + '@aws-sdk/credential-provider-web-identity': + optional: true + ws: + optional: true + + '@vercel/oidc@3.8.4': + resolution: {integrity: sha512-FGNvVZ5pgX9FaBqkPt6VkYFZ6bWAMDzYi7nxW+1Xt+Z4fn5PuTULVwsxjKc+0uKhysyWBQmvsmM50Oh6C2/oMA==} + engines: {node: '>= 20'} + '@vitest/expect@4.1.5': resolution: {integrity: sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==} @@ -2946,6 +2972,10 @@ packages: eventemitter3@5.0.4: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + execa@5.1.1: + resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} + engines: {node: '>=10'} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} @@ -3021,6 +3051,10 @@ packages: resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} engines: {node: '>=6'} + get-stream@6.0.1: + resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} + engines: {node: '>=10'} + get-tsconfig@4.13.0: resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==} @@ -3043,6 +3077,10 @@ packages: resolution: {integrity: sha512-WemPi9/WfyMwZs+ZUXdiwcCh9Y+m7L+8vki9MzDw3jJ+W9Lc+12HGsd368Qc1vZi1xwW8BWMMsnK5efYKPdt4g==} engines: {node: '>=16.9.0'} + human-signals@2.1.0: + resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} + engines: {node: '>=10.17.0'} + husky@9.1.7: resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} engines: {node: '>=18'} @@ -3091,6 +3129,10 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + is-what@5.5.0: resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==} engines: {node: '>=18'} @@ -3102,6 +3144,9 @@ packages: resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} hasBin: true + jose@5.10.0: + resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==} + jose@6.1.3: resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==} @@ -3228,6 +3273,9 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} @@ -3236,6 +3284,10 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + mimic-function@5.0.1: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} @@ -3323,6 +3375,10 @@ packages: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} + npm-run-path@4.0.1: + resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} + engines: {node: '>=8'} + nuqs@2.8.6: resolution: {integrity: sha512-aRxeX68b4ULmhio8AADL2be1FWDy0EPqaByPvIYWrA7Pm07UjlrICp/VPlSnXJNAG0+3MQwv3OporO2sOXMVGA==} peerDependencies: @@ -3355,6 +3411,10 @@ packages: obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + onetime@7.0.0: resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} engines: {node: '>=18'} @@ -3365,6 +3425,10 @@ packages: openapi3-ts@4.5.0: resolution: {integrity: sha512-jaL+HgTq2Gj5jRcfdutgRGLosCy/hT8sQf6VOy+P+g36cZOjI1iukdPnijC+4CmeRzg/jEllJUboEic2FhxhtQ==} + os-paths@4.4.0: + resolution: {integrity: sha512-wrAwOeXp1RRMFfQY8Sy7VaGVmPocaLwSFOYCGKSyo8qmJ+/yaafCl5BCA1IQZWqFSRBrKDYFeR9d/VyQzfH/jg==} + engines: {node: '>= 6.0'} + path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -3711,6 +3775,9 @@ packages: siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} @@ -3762,6 +3829,10 @@ packages: resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} engines: {node: '>=12'} + strip-final-newline@2.0.0: + resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} + engines: {node: '>=6'} + strnum@2.1.2: resolution: {integrity: sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==} @@ -4035,6 +4106,14 @@ packages: resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} engines: {node: '>=18'} + xdg-app-paths@5.5.1: + resolution: {integrity: sha512-hI3flOB4PLZIy5prbtTpirobtPE2ZtZ52szO+2mM9Efp6ErM398La+C1lIpNWDfNoQk+6Lsi6nMcCwVB7pxeMQ==} + engines: {node: '>= 6.0'} + + xdg-portable@7.3.0: + resolution: {integrity: sha512-sqMMuL1rc0FmMBOzCpd0yuy9trqF2yTTVe+E9ogwCSWQCdDEtQUwrZPT6AxqtsFGRNxycgncbP/xmOOSPw5ZUw==} + engines: {node: '>= 6.0'} + xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} @@ -4044,6 +4123,9 @@ packages: engines: {node: '>= 14.6'} hasBin: true + zod@4.1.11: + resolution: {integrity: sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg==} + zod@4.3.4: resolution: {integrity: sha512-Zw/uYiiyF6pUT1qmKbZziChgNPRu+ZRneAsMUDU6IwmXdWt5JwcUfy2bvLOCUtz5UniaN/Zx5aFttZYbYc7O/A==} @@ -6421,6 +6503,27 @@ snapshots: '@types/use-sync-external-store@0.0.6': {} + '@vercel/cli-config@0.2.3': + dependencies: + xdg-app-paths: 5.5.1 + zod: 4.1.11 + + '@vercel/cli-exec@1.0.1': + dependencies: + execa: 5.1.1 + + '@vercel/functions@3.9.3(@aws-sdk/credential-provider-web-identity@3.958.0)': + dependencies: + '@vercel/oidc': 3.8.4 + optionalDependencies: + '@aws-sdk/credential-provider-web-identity': 3.958.0 + + '@vercel/oidc@3.8.4': + dependencies: + '@vercel/cli-config': 0.2.3 + '@vercel/cli-exec': 1.0.1 + jose: 5.10.0 + '@vitest/expect@4.1.5': dependencies: '@standard-schema/spec': 1.1.0 @@ -6796,6 +6899,18 @@ snapshots: eventemitter3@5.0.4: {} + execa@5.1.1: + dependencies: + cross-spawn: 7.0.6 + get-stream: 6.0.1 + human-signals: 2.1.0 + is-stream: 2.0.1 + merge-stream: 2.0.0 + npm-run-path: 4.0.1 + onetime: 5.1.2 + signal-exit: 3.0.7 + strip-final-newline: 2.0.0 + expect-type@1.3.0: {} fast-content-type-parse@2.0.1: {} @@ -6855,6 +6970,8 @@ snapshots: get-nonce@1.0.1: {} + get-stream@6.0.1: {} + get-tsconfig@4.13.0: dependencies: resolve-pkg-maps: 1.0.0 @@ -6875,6 +6992,8 @@ snapshots: hono@4.11.5: {} + human-signals@2.1.0: {} + husky@9.1.7: {} immer@10.2.0: {} @@ -6915,12 +7034,16 @@ snapshots: is-number@7.0.0: {} + is-stream@2.0.1: {} + is-what@5.5.0: {} isexe@2.0.0: {} jiti@1.21.7: {} + jose@5.10.0: {} + jose@6.1.3: {} json-schema-typed@8.0.2: {} @@ -7019,6 +7142,8 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + merge-stream@2.0.0: {} + merge2@1.4.1: {} micromatch@4.0.8: @@ -7026,6 +7151,8 @@ snapshots: braces: 3.0.3 picomatch: 2.3.1 + mimic-fn@2.1.0: {} + mimic-function@5.0.1: {} motion-dom@12.26.2: @@ -7105,6 +7232,10 @@ snapshots: normalize-path@3.0.0: {} + npm-run-path@4.0.1: + dependencies: + path-key: 3.1.1 + nuqs@2.8.6(next@16.1.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3): dependencies: '@standard-schema/spec': 1.0.0 @@ -7118,6 +7249,10 @@ snapshots: obug@2.1.1: {} + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + onetime@7.0.0: dependencies: mimic-function: 5.0.1 @@ -7128,6 +7263,8 @@ snapshots: dependencies: yaml: 2.8.2 + os-paths@4.4.0: {} + path-key@3.1.1: {} path-parse@1.0.7: {} @@ -7471,6 +7608,8 @@ snapshots: siginfo@2.0.0: {} + signal-exit@3.0.7: {} + signal-exit@4.1.0: {} slice-ansi@7.1.2: @@ -7515,6 +7654,8 @@ snapshots: dependencies: ansi-regex: 6.2.2 + strip-final-newline@2.0.0: {} + strnum@2.1.2: {} styled-jsx@5.1.6(react@19.2.3): @@ -7762,10 +7903,21 @@ snapshots: string-width: 7.2.0 strip-ansi: 7.1.2 + xdg-app-paths@5.5.1: + dependencies: + os-paths: 4.4.0 + xdg-portable: 7.3.0 + + xdg-portable@7.3.0: + dependencies: + os-paths: 4.4.0 + xtend@4.0.2: {} yaml@2.8.2: {} + zod@4.1.11: {} + zod@4.3.4: {} zustand@5.0.10(@types/react@19.2.9)(immer@11.1.8)(react@19.2.3)(use-sync-external-store@1.6.0(react@19.2.3)): From 21b3550dd5688394f79914f7ce50dff978d455e2 Mon Sep 17 00:00:00 2001 From: suguanYang Date: Wed, 12 Aug 2026 19:46:27 +0800 Subject: [PATCH 2/3] style: align database pool code with standards --- lib/db/database-pool.test.ts | 15 +++++++------ lib/db/database-pool.ts | 4 ++-- lib/db/index.test.ts | 40 ++++++++++++++++++++++------------ lib/db/index.ts | 2 +- lib/db/newsletter.test.ts | 42 +++++++++++++++++++++++++----------- lib/db/newsletter.ts | 2 +- lib/env.test.ts | 10 +++++---- lib/env.ts | 10 ++++----- 8 files changed, 78 insertions(+), 47 deletions(-) diff --git a/lib/db/database-pool.test.ts b/lib/db/database-pool.test.ts index 16e61d53..b5f0ad18 100644 --- a/lib/db/database-pool.test.ts +++ b/lib/db/database-pool.test.ts @@ -1,21 +1,22 @@ 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", () => ({ +vi.mock("@vercel/functions", (): { attachDatabasePool: ReturnType } => ({ attachDatabasePool: vi.fn(), })); -afterEach(() => { +afterEach((): void => { vi.clearAllMocks(); vi.unstubAllEnvs(); }); -describe("createDatabasePool", () => { - it("creates a bounded pool without Vercel lifecycle registration outside Vercel", async () => { +describe("createDatabasePool", (): void => { + it("creates a bounded pool without Vercel lifecycle registration outside Vercel", async (): Promise => { vi.stubEnv("VERCEL", undefined); - const pool = createDatabasePool({ + const pool: Pool = createDatabasePool({ connectionString: "postgres://user:pass@example.com:5432/knowhere", connectionTimeoutMillis: 5_000, idleTimeoutMillis: 10_000, @@ -32,10 +33,10 @@ describe("createDatabasePool", () => { await pool.end(); }); - it("registers the bounded pool with the Vercel lifecycle", async () => { + it("registers the bounded pool with the Vercel lifecycle", async (): Promise => { vi.stubEnv("VERCEL", "1"); - const pool = createDatabasePool({ + const pool: Pool = createDatabasePool({ connectionString: "postgres://user:pass@example.com:5432/knowhere", connectionTimeoutMillis: 5_000, idleTimeoutMillis: 10_000, diff --git a/lib/db/database-pool.ts b/lib/db/database-pool.ts index 3fa60768..20cd62fc 100644 --- a/lib/db/database-pool.ts +++ b/lib/db/database-pool.ts @@ -1,8 +1,8 @@ import { attachDatabasePool } from "@vercel/functions"; import { Pool, type PoolConfig } from "pg"; -export function createDatabasePool(config: PoolConfig): Pool { - const pool = new Pool(config); +export function createDatabasePool(configuration: PoolConfig): Pool { + const pool: Pool = new Pool(configuration); if (process.env.VERCEL === "1") { attachDatabasePool(pool); diff --git a/lib/db/index.test.ts b/lib/db/index.test.ts index bfbe0cfa..f9c21d7a 100644 --- a/lib/db/index.test.ts +++ b/lib/db/index.test.ts @@ -1,14 +1,26 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, type Mock, vi } from "vitest"; -const createDatabasePool = vi.fn(() => ({ pool: "main" })); -const drizzle = vi.fn(() => ({ database: "main" })); +interface PoolStub { + readonly pool: string; +} -vi.mock("@lib/db/database-pool", () => ({ createDatabasePool })); -vi.mock("@lib/db/database-ssl", () => ({ getDatabaseSslConfig: () => false })); -vi.mock("@lib/db/auth-schema", () => ({ user: "auth-schema" })); -vi.mock("@lib/db/schema", () => ({ account: "app-schema" })); -vi.mock("drizzle-orm/node-postgres", () => ({ drizzle })); -vi.mock("@lib/env", () => ({ +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> } => ({ env: { DATABASE_POOL_CONNECTION_TIMEOUT_MS: 5_000, DATABASE_POOL_IDLE_TIMEOUT_MS: 10_000, @@ -18,14 +30,14 @@ vi.mock("@lib/env", () => ({ }, })); -beforeEach(() => { +beforeEach((): void => { vi.clearAllMocks(); vi.resetModules(); }); -describe("main database", () => { - it("uses the bounded main pool configuration", async () => { - const { db } = await import("@lib/db"); +describe("main database", (): void => { + it("uses the bounded main pool configuration", async (): Promise => { + const databaseModule: typeof import("@lib/db") = await import("@lib/db"); expect(createDatabasePool).toHaveBeenCalledWith({ connectionString: "postgres://main.example/knowhere", @@ -41,6 +53,6 @@ describe("main database", () => { user: "auth-schema", }, }); - expect(db).toEqual({ database: "main" }); + expect(databaseModule.db).toEqual({ database: "main" }); }); }); diff --git a/lib/db/index.ts b/lib/db/index.ts index 7956812c..eeaaf3f9 100644 --- a/lib/db/index.ts +++ b/lib/db/index.ts @@ -6,7 +6,7 @@ import { env } from "@lib/env"; import { drizzle } from "drizzle-orm/node-postgres"; // Connection pool for dashboard auth/account data. -const pool = createDatabasePool({ +const pool: ReturnType = createDatabasePool({ connectionString: env.DATABASE_URL, connectionTimeoutMillis: env.DATABASE_POOL_CONNECTION_TIMEOUT_MS, idleTimeoutMillis: env.DATABASE_POOL_IDLE_TIMEOUT_MS, diff --git a/lib/db/newsletter.test.ts b/lib/db/newsletter.test.ts index 2c24ea21..0fe908c5 100644 --- a/lib/db/newsletter.test.ts +++ b/lib/db/newsletter.test.ts @@ -1,13 +1,27 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, type Mock, vi } from "vitest"; -const createDatabasePool = vi.fn(() => ({ pool: "newsletter" })); -const drizzle = vi.fn(() => ({ database: "newsletter" })); +interface PoolStub { + readonly pool: string; +} -vi.mock("@lib/db/database-pool", () => ({ createDatabasePool })); -vi.mock("@lib/db/database-ssl", () => ({ getDatabaseSslConfig: () => false })); -vi.mock("@lib/db/newsletter-schema", () => ({ subscription: "newsletter-schema" })); -vi.mock("drizzle-orm/node-postgres", () => ({ drizzle })); -vi.mock("@lib/env", () => ({ +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> } => ({ env: { DATABASE_POOL_CONNECTION_TIMEOUT_MS: 5_000, DATABASE_POOL_IDLE_TIMEOUT_MS: 10_000, @@ -18,14 +32,16 @@ vi.mock("@lib/env", () => ({ }, })); -beforeEach(() => { +beforeEach((): void => { vi.clearAllMocks(); vi.resetModules(); }); -describe("newsletter database", () => { - it("uses the independent bounded newsletter pool", async () => { - const { newsletterDb } = await import("@lib/db/newsletter"); +describe("newsletter database", (): void => { + it("uses the independent bounded newsletter pool", async (): Promise => { + const newsletterModule: typeof import("@lib/db/newsletter") = await import( + "@lib/db/newsletter" + ); expect(createDatabasePool).toHaveBeenCalledWith({ connectionString: "postgres://newsletter.example/knowhere", @@ -40,6 +56,6 @@ describe("newsletter database", () => { subscription: "newsletter-schema", }, }); - expect(newsletterDb).toEqual({ database: "newsletter" }); + expect(newsletterModule.newsletterDb).toEqual({ database: "newsletter" }); }); }); diff --git a/lib/db/newsletter.ts b/lib/db/newsletter.ts index d7309569..2c47789c 100644 --- a/lib/db/newsletter.ts +++ b/lib/db/newsletter.ts @@ -8,7 +8,7 @@ function getNewsletterDatabaseUrl(): string { return env.NEWSLETTER_DATABASE_URL ?? env.DATABASE_URL; } -const newsletterPool = createDatabasePool({ +const newsletterPool: ReturnType = createDatabasePool({ connectionString: getNewsletterDatabaseUrl(), connectionTimeoutMillis: env.DATABASE_POOL_CONNECTION_TIMEOUT_MS, idleTimeoutMillis: env.DATABASE_POOL_IDLE_TIMEOUT_MS, diff --git a/lib/env.test.ts b/lib/env.test.ts index eab724e2..f38f6235 100644 --- a/lib/env.test.ts +++ b/lib/env.test.ts @@ -52,8 +52,8 @@ describe("env.AUTH_COOKIE_PREFIX", () => { }); }); -describe("env database pools", () => { - it("uses the bounded staging and Vercel pool defaults", async () => { +describe("env database pools", (): void => { + it("uses the bounded staging and Vercel pool defaults", async (): Promise => { const { env } = await loadEnv({ DATABASE_POOL_CONNECTION_TIMEOUT_MS: undefined, DATABASE_POOL_IDLE_TIMEOUT_MS: undefined, @@ -67,8 +67,10 @@ describe("env database pools", () => { expect(env.DATABASE_POOL_CONNECTION_TIMEOUT_MS).toBe(5_000); }); - it("rejects non-positive database pool values", async () => { - const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + it("rejects non-positive database pool values", async (): Promise => { + const consoleError: ReturnType = vi + .spyOn(console, "error") + .mockImplementation((): void => {}); await expect( loadEnv({ diff --git a/lib/env.ts b/lib/env.ts index 846c9f7a..f0a616fe 100644 --- a/lib/env.ts +++ b/lib/env.ts @@ -15,7 +15,7 @@ function normalizeOptionalUrl(value: unknown): unknown { return normalizeOptionalString(value); } -const positiveInteger = z.coerce.number().int().positive(); +const POSITIVE_INTEGER: z.ZodCoercedNumber = z.coerce.number().int().positive(); export const env = createEnv({ server: { @@ -23,10 +23,10 @@ export const env = createEnv({ BETTER_AUTH_URL: z.url(), DATABASE_URL: z.url(), NEWSLETTER_DATABASE_URL: z.preprocess(normalizeOptionalUrl, z.url().optional()), - DATABASE_POOL_MAX: positiveInteger.default(2), - NEWSLETTER_DATABASE_POOL_MAX: positiveInteger.default(1), - DATABASE_POOL_IDLE_TIMEOUT_MS: positiveInteger.default(10_000), - DATABASE_POOL_CONNECTION_TIMEOUT_MS: positiveInteger.default(5_000), + DATABASE_POOL_MAX: POSITIVE_INTEGER.default(2), + NEWSLETTER_DATABASE_POOL_MAX: POSITIVE_INTEGER.default(1), + DATABASE_POOL_IDLE_TIMEOUT_MS: POSITIVE_INTEGER.default(10_000), + DATABASE_POOL_CONNECTION_TIMEOUT_MS: POSITIVE_INTEGER.default(5_000), UNSAFE_DB_SSL_ENABLED: z.string().default("false"), GA_MEASUREMENT_ID: z .string() From 1ae7f865b20faaf37c83a9d61e6bf2225b1ef201 Mon Sep 17 00:00:00 2001 From: suguanYang Date: Wed, 12 Aug 2026 19:52:04 +0800 Subject: [PATCH 3/3] style: use explicit pool timeout names --- .env.example | 4 ++-- README.md | 4 ++-- lib/db/index.test.ts | 4 ++-- lib/db/index.ts | 4 ++-- lib/db/newsletter.test.ts | 4 ++-- lib/db/newsletter.ts | 4 ++-- lib/env.test.ts | 14 +++++++------- lib/env.ts | 9 +++++---- 8 files changed, 24 insertions(+), 23 deletions(-) diff --git a/.env.example b/.env.example index 13766fc9..fda3b235 100644 --- a/.env.example +++ b/.env.example @@ -11,8 +11,8 @@ 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_MS=10000 -DATABASE_POOL_CONNECTION_TIMEOUT_MS=5000 +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 diff --git a/README.md b/README.md index cd0fd550..cdf22782 100644 --- a/README.md +++ b/README.md @@ -60,8 +60,8 @@ Required for startup: | `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_MS` | Time before an idle database connection is closed. Defaults to `10000`. | -| `DATABASE_POOL_CONNECTION_TIMEOUT_MS` | Time allowed to acquire a new database connection. Defaults to `5000`. | +| `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. diff --git a/lib/db/index.test.ts b/lib/db/index.test.ts index f9c21d7a..b49c49e5 100644 --- a/lib/db/index.test.ts +++ b/lib/db/index.test.ts @@ -22,8 +22,8 @@ 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> } => ({ env: { - DATABASE_POOL_CONNECTION_TIMEOUT_MS: 5_000, - DATABASE_POOL_IDLE_TIMEOUT_MS: 10_000, + 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", diff --git a/lib/db/index.ts b/lib/db/index.ts index eeaaf3f9..fe003c14 100644 --- a/lib/db/index.ts +++ b/lib/db/index.ts @@ -8,8 +8,8 @@ import { drizzle } from "drizzle-orm/node-postgres"; // Connection pool for dashboard auth/account data. const pool: ReturnType = createDatabasePool({ connectionString: env.DATABASE_URL, - connectionTimeoutMillis: env.DATABASE_POOL_CONNECTION_TIMEOUT_MS, - idleTimeoutMillis: env.DATABASE_POOL_IDLE_TIMEOUT_MS, + 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), }); diff --git a/lib/db/newsletter.test.ts b/lib/db/newsletter.test.ts index 0fe908c5..720f33b1 100644 --- a/lib/db/newsletter.test.ts +++ b/lib/db/newsletter.test.ts @@ -23,8 +23,8 @@ vi.mock("@lib/db/newsletter-schema", (): { subscription: string } => ({ vi.mock("drizzle-orm/node-postgres", (): { drizzle: typeof drizzle } => ({ drizzle })); vi.mock("@lib/env", (): { env: Readonly> } => ({ env: { - DATABASE_POOL_CONNECTION_TIMEOUT_MS: 5_000, - DATABASE_POOL_IDLE_TIMEOUT_MS: 10_000, + 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", diff --git a/lib/db/newsletter.ts b/lib/db/newsletter.ts index 2c47789c..6b56d92f 100644 --- a/lib/db/newsletter.ts +++ b/lib/db/newsletter.ts @@ -10,8 +10,8 @@ function getNewsletterDatabaseUrl(): string { const newsletterPool: ReturnType = createDatabasePool({ connectionString: getNewsletterDatabaseUrl(), - connectionTimeoutMillis: env.DATABASE_POOL_CONNECTION_TIMEOUT_MS, - idleTimeoutMillis: env.DATABASE_POOL_IDLE_TIMEOUT_MS, + 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), }); diff --git a/lib/env.test.ts b/lib/env.test.ts index f38f6235..b60d857a 100644 --- a/lib/env.test.ts +++ b/lib/env.test.ts @@ -54,17 +54,17 @@ describe("env.AUTH_COOKIE_PREFIX", () => { describe("env database pools", (): void => { it("uses the bounded staging and Vercel pool defaults", async (): Promise => { - const { env } = await loadEnv({ - DATABASE_POOL_CONNECTION_TIMEOUT_MS: undefined, - DATABASE_POOL_IDLE_TIMEOUT_MS: undefined, + 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(env.DATABASE_POOL_MAX).toBe(2); - expect(env.NEWSLETTER_DATABASE_POOL_MAX).toBe(1); - expect(env.DATABASE_POOL_IDLE_TIMEOUT_MS).toBe(10_000); - expect(env.DATABASE_POOL_CONNECTION_TIMEOUT_MS).toBe(5_000); + 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 => { diff --git a/lib/env.ts b/lib/env.ts index f0a616fe..e562316d 100644 --- a/lib/env.ts +++ b/lib/env.ts @@ -25,8 +25,8 @@ export const env = createEnv({ 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_MS: POSITIVE_INTEGER.default(10_000), - DATABASE_POOL_CONNECTION_TIMEOUT_MS: POSITIVE_INTEGER.default(5_000), + 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() @@ -99,8 +99,9 @@ export const env = createEnv({ 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_MS: process.env.DATABASE_POOL_IDLE_TIMEOUT_MS, - DATABASE_POOL_CONNECTION_TIMEOUT_MS: process.env.DATABASE_POOL_CONNECTION_TIMEOUT_MS, + 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,