From 6fb299ed76d71c4e04bed86465f45b6fde72a884 Mon Sep 17 00:00:00 2001 From: Netlify Bot Date: Thu, 18 Jun 2026 12:29:25 +0100 Subject: [PATCH 1/6] feat(database): support credentials rotation --- packages/database/prod/src/main.test.ts | 32 +++++++- packages/database/prod/src/main.ts | 105 +++++++++++++++++++----- 2 files changed, 114 insertions(+), 23 deletions(-) diff --git a/packages/database/prod/src/main.test.ts b/packages/database/prod/src/main.test.ts index cc13b91f9..b3ba6eff6 100644 --- a/packages/database/prod/src/main.test.ts +++ b/packages/database/prod/src/main.test.ts @@ -93,7 +93,7 @@ describe('getDatabase', () => { const result = getDatabase() - expect(mockWaddlerNeonHttp).toHaveBeenCalledWith({ client: 'neon-http-client' }) + expect(mockWaddlerNeonHttp).toHaveBeenCalledWith({ client: expect.any(Function) }) expect(mockNeonPool).toHaveBeenCalledWith({ connectionString: CONNECTION_STRING }) expect(mockWaddlerNodePostgres).not.toHaveBeenCalled() expect(mockPgPool).not.toHaveBeenCalled() @@ -102,7 +102,7 @@ describe('getDatabase', () => { driver: 'serverless', sql: 'neon-http-sql', pool: expect.any(Object), - httpClient: 'neon-http-client', + httpClient: expect.any(Function), connectionString: CONNECTION_STRING, }) }) @@ -152,6 +152,34 @@ describe('getDatabase', () => { expect(mockPgPool).toHaveBeenCalledWith({ connectionString: CONNECTION_STRING }) expect(result.connectionString).toBe(CONNECTION_STRING) }) + + it('re-resolves NETLIFY_DB_URL on use so rotated credentials take effect', () => { + process.env.NETLIFY_DB_URL = CONNECTION_STRING + process.env.NETLIFY_DB_DRIVER = 'serverless' + // Return a callable (with the connection string attached) so the httpClient + // Proxy can be invoked. + mockNeon.mockImplementation((connectionString: string) => Object.assign(() => undefined, { connectionString })) + + // A connection obtained once, as with the common module-scope pattern. + const db = getDatabase() + expect(db.connectionString).toBe(CONNECTION_STRING) + expect(mockNeon).toHaveBeenLastCalledWith(CONNECTION_STRING) + + // Credentials rotate: NETLIFY_DB_URL is refreshed with a new value. + process.env.NETLIFY_DB_URL = OTHER_CONNECTION_STRING + + // The live connection string reflects the rotation immediately... + expect(db.connectionString).toBe(OTHER_CONNECTION_STRING) + + // ...and the next query rebuilds the underlying client for the new + // credentials instead of reusing the stale one. + expect(db.driver).toBe('serverless') + if (db.driver !== 'serverless') return + // The real call signature is tagged-template only; cast to invoke it with a + // plain string in the test, which is all the apply trap needs. + ;(db.httpClient as unknown as (query: string) => unknown)('select 1') + expect(mockNeon).toHaveBeenLastCalledWith(OTHER_CONNECTION_STRING) + }) }) describe('getConnectionString', () => { diff --git a/packages/database/prod/src/main.ts b/packages/database/prod/src/main.ts index 1911e845c..fe3e3ce4c 100644 --- a/packages/database/prod/src/main.ts +++ b/packages/database/prod/src/main.ts @@ -1,5 +1,4 @@ import { neon, neonConfig, Pool as NeonPool } from '@neondatabase/serverless' -import type { NeonQueryFunction } from '@neondatabase/serverless' import ws from 'ws' import pg from 'pg' import type { SQL } from 'waddler' @@ -10,6 +9,8 @@ import { getEnvironment } from '@netlify/runtime-utils' import { MissingDatabaseConnectionError } from './environment.js' +type NeonHttpClient = ReturnType + export interface GetDatabaseOptions { connectionString?: string debug?: boolean @@ -26,11 +27,7 @@ export interface ServerlessDatabaseConnection { driver: 'serverless' sql: SQL pool: NeonPool - // Using because neon() returns NeonQueryFunction, - // which is incompatible with drizzle-orm's NeonHttpClient (NeonQueryFunction) - // due to contravariance in the transaction method's generic parameters. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - httpClient: NeonQueryFunction + httpClient: NeonHttpClient connectionString: string } @@ -47,44 +44,110 @@ export function getConnectionString(): string { return connectionString } +function ensureNeonWebSocket(): void { + // We can remove this, and the dependency on `ws`, once we stop supporting + // Node.js 22. + /* eslint-disable n/no-unsupported-features/node-builtins */ + if (!neonConfig.webSocketConstructor && typeof WebSocket === 'undefined') { + neonConfig.webSocketConstructor = ws as unknown as typeof WebSocket + } + /* eslint-enable n/no-unsupported-features/node-builtins */ +} + +// Returns an HTTP client that lazily loads the connection string from the +// environment and, when it changes, rebuilds the underlying Neon client. +// +// A Proxy is used because the client is both callable (tagged-template queries) +// and carries methods (`query`, `transaction`, `unsafe`). +function createRefreshingHttpClient(readConnectionString: () => string): NeonHttpClient { + let cachedConnectionString: string | undefined + let cachedClient: NeonHttpClient | undefined + + const getClient = (): NeonHttpClient => { + const connectionString = readConnectionString() + + if (connectionString !== cachedConnectionString || cachedClient === undefined) { + cachedConnectionString = connectionString + cachedClient = neon(connectionString) + } + + return cachedClient + } + + // Construct eagerly so getDatabase() fails fast and the client is warm. + getClient() + + // The Proxy target is a throwaway callable — it only has to be a callable + // object for the traps to attach. + const target = (() => undefined) as unknown as NeonHttpClient + + return new Proxy(target, { + apply: (_target, _thisArg, args: unknown[]) => (getClient() as (...callArgs: unknown[]) => unknown)(...args), + get: (_target, prop) => { + const value = (getClient() as unknown as Record)[prop] + + // Data property: nothing to defer, hand back the value as-is. + if (typeof value !== 'function') { + return value + } + + // Method: return a wrapper so the call is deferred to a freshly-resolved + // client. This is what makes a captured method reference use the updated + // credentials after rotation. + return (...args: unknown[]): unknown => { + const fn = (getClient() as unknown as Record)[prop] as ( + ...callArgs: unknown[] + ) => unknown + + return fn.apply(getClient(), args) + } + }, + }) +} + export function getDatabase(options: GetDatabaseOptions = {}): DatabaseConnection { const env = getEnvironment() - const connectionString = options.connectionString ?? env.get('NETLIFY_DB_URL') + const { connectionString: override } = options - if (!connectionString) { - throw new MissingDatabaseConnectionError() + const readConnectionString = (): string => { + const connectionString = override ?? env.get('NETLIFY_DB_URL') + if (!connectionString) { + throw new MissingDatabaseConnectionError() + } + + return connectionString } + const connectionString = readConnectionString() const driver = env.get('NETLIFY_DB_DRIVER') if (driver === 'serverless') { - // We can remove this, and the dependency on `ws`, once we stop supporting - // Node.js 22. - /* eslint-disable n/no-unsupported-features/node-builtins */ - if (!neonConfig.webSocketConstructor && typeof WebSocket === 'undefined') { - neonConfig.webSocketConstructor = ws as unknown as typeof WebSocket - } - /* eslint-enable n/no-unsupported-features/node-builtins */ + ensureNeonWebSocket() - const httpClient = neon(connectionString) + const httpClient = createRefreshingHttpClient(readConnectionString) + const pool = new NeonPool({ connectionString }) return { driver: 'serverless', sql: waddlerNeonHttp({ client: httpClient }), - pool: new NeonPool({ connectionString }), + pool, httpClient, - connectionString, + get connectionString() { + return readConnectionString() + }, } } - // Default ("server"): node-postgres for long-running servers + // Default ("server"): node-postgres for long-running servers. const pool = new pg.Pool({ connectionString }) return { driver: 'server', sql: waddlerNodePostgres({ client: pool }), pool, - connectionString, + get connectionString() { + return readConnectionString() + }, } } From 0cefd46942da174e56d058830b722e75beeb64db Mon Sep 17 00:00:00 2001 From: Netlify Bot Date: Thu, 18 Jun 2026 12:37:55 +0100 Subject: [PATCH 2/6] chore: fmt --- packages/database/prod/src/main.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/database/prod/src/main.test.ts b/packages/database/prod/src/main.test.ts index b3ba6eff6..3a7443974 100644 --- a/packages/database/prod/src/main.test.ts +++ b/packages/database/prod/src/main.test.ts @@ -174,9 +174,9 @@ describe('getDatabase', () => { // ...and the next query rebuilds the underlying client for the new // credentials instead of reusing the stale one. expect(db.driver).toBe('serverless') - if (db.driver !== 'serverless') return - // The real call signature is tagged-template only; cast to invoke it with a - // plain string in the test, which is all the apply trap needs. + if (db.driver !== 'serverless') + return // The real call signature is tagged-template only; cast to invoke it with a + // plain string in the test, which is all the apply trap needs. ;(db.httpClient as unknown as (query: string) => unknown)('select 1') expect(mockNeon).toHaveBeenLastCalledWith(OTHER_CONNECTION_STRING) }) From 09b48a4f2e90f10e702edaf0643047b7886d4230 Mon Sep 17 00:00:00 2001 From: Netlify Bot Date: Thu, 18 Jun 2026 12:49:05 +0100 Subject: [PATCH 3/6] add comment --- packages/database/prod/src/main.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/database/prod/src/main.ts b/packages/database/prod/src/main.ts index fe3e3ce4c..7a7372cff 100644 --- a/packages/database/prod/src/main.ts +++ b/packages/database/prod/src/main.ts @@ -125,6 +125,11 @@ export function getDatabase(options: GetDatabaseOptions = {}): DatabaseConnectio ensureNeonWebSocket() const httpClient = createRefreshingHttpClient(readConnectionString) + + // Unlike the stateless HTTP client, a pool holds live, already-authenticated + // connections, so it can't transparently swap credentials, asthat would mean + // tearing down the pool and interrupting in-flight queries. It stays pinned + // to the connection string at construction. const pool = new NeonPool({ connectionString }) return { From 962e41d8093a10e867302c718397bfbb4013ba20 Mon Sep 17 00:00:00 2001 From: Netlify Bot Date: Thu, 18 Jun 2026 12:56:06 +0100 Subject: [PATCH 4/6] use `Reflect.get` --- packages/database/prod/src/main.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/database/prod/src/main.ts b/packages/database/prod/src/main.ts index 7a7372cff..a01892094 100644 --- a/packages/database/prod/src/main.ts +++ b/packages/database/prod/src/main.ts @@ -83,8 +83,8 @@ function createRefreshingHttpClient(readConnectionString: () => string): NeonHtt return new Proxy(target, { apply: (_target, _thisArg, args: unknown[]) => (getClient() as (...callArgs: unknown[]) => unknown)(...args), - get: (_target, prop) => { - const value = (getClient() as unknown as Record)[prop] + get: (_target, prop, receiver) => { + const value: unknown = Reflect.get(getClient(), prop, receiver) // Data property: nothing to defer, hand back the value as-is. if (typeof value !== 'function') { @@ -95,9 +95,7 @@ function createRefreshingHttpClient(readConnectionString: () => string): NeonHtt // client. This is what makes a captured method reference use the updated // credentials after rotation. return (...args: unknown[]): unknown => { - const fn = (getClient() as unknown as Record)[prop] as ( - ...callArgs: unknown[] - ) => unknown + const fn = Reflect.get(getClient(), prop, receiver) as (...callArgs: unknown[]) => unknown return fn.apply(getClient(), args) } From ec8b39fd2d9d78786afac24a7d9046a713f6ffd0 Mon Sep 17 00:00:00 2001 From: Netlify Bot Date: Mon, 22 Jun 2026 11:51:10 +0100 Subject: [PATCH 5/6] lint --- packages/database/prod/src/main.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/database/prod/src/main.test.ts b/packages/database/prod/src/main.test.ts index f13dcf190..3a7443974 100644 --- a/packages/database/prod/src/main.test.ts +++ b/packages/database/prod/src/main.test.ts @@ -9,7 +9,7 @@ const { mockWaddlerNodePostgres, mockWaddlerNeonHttp, mockPgPool, mockNeonPool, mockPgPool: vi.fn(), mockNeonPool: vi.fn(), mockNeon: vi.fn().mockReturnValue('neon-http-client'), - mockNeonConfig: {}, + mockNeonConfig: {} as Record, }), ) From 6d0e840388f7cdb875797a83165ea35ccdf09b4b Mon Sep 17 00:00:00 2001 From: Netlify Bot Date: Mon, 22 Jun 2026 12:05:59 +0100 Subject: [PATCH 6/6] lint --- packages/database/prod/src/main.test.ts | 2 +- packages/database/prod/src/main.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/database/prod/src/main.test.ts b/packages/database/prod/src/main.test.ts index 3a7443974..f13dcf190 100644 --- a/packages/database/prod/src/main.test.ts +++ b/packages/database/prod/src/main.test.ts @@ -9,7 +9,7 @@ const { mockWaddlerNodePostgres, mockWaddlerNeonHttp, mockPgPool, mockNeonPool, mockPgPool: vi.fn(), mockNeonPool: vi.fn(), mockNeon: vi.fn().mockReturnValue('neon-http-client'), - mockNeonConfig: {} as Record, + mockNeonConfig: {}, }), ) diff --git a/packages/database/prod/src/main.ts b/packages/database/prod/src/main.ts index ba88943b8..9e5ead1d1 100644 --- a/packages/database/prod/src/main.ts +++ b/packages/database/prod/src/main.ts @@ -49,7 +49,7 @@ function ensureNeonWebSocket(): void { // Node.js 22. /* eslint-disable n/no-unsupported-features/node-builtins */ if (!neonConfig.webSocketConstructor && typeof WebSocket === 'undefined') { - neonConfig.webSocketConstructor = ws as unknown as typeof WebSocket + neonConfig.webSocketConstructor = ws } /* eslint-enable n/no-unsupported-features/node-builtins */ }