Skip to content

Commit 58b8aaf

Browse files
committed
fixup
Signed-off-by: Paolo Insogna <paolo@cowtech.it>
1 parent fd3200d commit 58b8aaf

10 files changed

Lines changed: 153 additions & 44 deletions

File tree

src/admin-app.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { vi } from 'vitest'
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
22

33
function mockAdminAppDependencies() {
44
vi.doMock('./config', () => ({

src/database/config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export type DatabaseConfig = {
2222
maxResultRows: number
2323
maxSerializedRequestBytes: number
2424
maxSqlBytes: number
25+
poolIsExternal: boolean
2526
poolConnectionString?: string
2627
poolMode?: string
2728
rootCert?: string
@@ -73,6 +74,7 @@ export function readConfig(env: NodeJS.ProcessEnv = process.env): DatabaseConfig
7374
10 * 1024 * 1024
7475
),
7576
maxSqlBytes: readPositiveInteger(env.DATABASE_WATT_MAX_SQL_BYTES, 1024 * 1024),
77+
poolIsExternal: Boolean(env.DATABASE_POOL_URL),
7678
poolConnectionString: env.DATABASE_POOL_URL || env.DATABASE_URL,
7779
poolMode: env.DATABASE_POOL_MODE,
7880
rootCert: readRootCert(env.DATABASE_SSL_ROOT_CERT),

src/database/destinations.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ export class DestinationResolver {
3636
return {
3737
connectionString,
3838
id: destination,
39-
isExternalPool: Boolean(process.env.DATABASE_POOL_URL),
39+
isExternalPool: this.config.poolIsExternal,
4040
maxConnections: this.config.destinationMaxConnections,
4141
poolMode: this.config.poolMode,
4242
}

src/database/pools.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,17 @@ export class PoolRegistry {
2121
private readonly config: DatabaseConfig
2222
private readonly pools = new Map<string, PoolEntry>()
2323
private pendingGlobalAcquisitions = 0
24+
private cachedStats?: PoolRegistryStats
2425
private readonly evictionInterval: NodeJS.Timeout
2526

2627
constructor(config: DatabaseConfig) {
2728
this.config = config
28-
this.evictionInterval = setInterval(() => {
29-
void this.evictIdlePools()
30-
}, Math.max(config.idlePoolTimeoutMs, 1_000))
29+
this.evictionInterval = setInterval(
30+
() => {
31+
void this.evictIdlePools()
32+
},
33+
Math.max(config.idlePoolTimeoutMs, 1_000)
34+
)
3135
this.evictionInterval.unref()
3236
}
3337

@@ -75,6 +79,10 @@ export class PoolRegistry {
7579
}
7680

7781
getStats(): PoolRegistryStats {
82+
if (this.cachedStats) {
83+
return this.cachedStats
84+
}
85+
7886
let inUseConnections = 0
7987
let totalConnections = 0
8088
let waitingRequests = 0
@@ -85,18 +93,25 @@ export class PoolRegistry {
8593
waitingRequests += entry.pool.waitingCount
8694
}
8795

88-
return {
96+
const stats = {
8997
inUseConnections,
9098
pools: this.pools.size,
9199
totalConnections,
92100
waitingRequests,
93101
}
102+
this.cachedStats = stats
103+
queueMicrotask(() => {
104+
this.cachedStats = undefined
105+
})
106+
107+
return stats
94108
}
95109

96110
async close(): Promise<void> {
97111
clearInterval(this.evictionInterval)
98112
const pools = [...this.pools.values()].map((entry) => entry.pool)
99113
this.pools.clear()
114+
this.cachedStats = undefined
100115
await Promise.allSettled(pools.map((pool) => pool.end()))
101116
}
102117

@@ -132,6 +147,7 @@ export class PoolRegistry {
132147
}
133148

134149
this.pools.set(destination.id, entry)
150+
this.cachedStats = undefined
135151
return entry
136152
}
137153

@@ -166,6 +182,7 @@ export class PoolRegistry {
166182
}
167183

168184
this.pools.delete(destination)
185+
this.cachedStats = undefined
169186
toEvict.push(entry.pool)
170187
}
171188

src/database/ssl.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
export function getSslSettings(
22
connectionString: string,
33
rootCert?: string
4-
): { ca?: string; rejectUnauthorized?: boolean } | false | undefined {
4+
): { ca?: string; rejectUnauthorized?: boolean } | boolean | undefined {
55
const sslMode = getSslMode(connectionString)
66

77
if (sslMode === 'disable') {
@@ -14,7 +14,11 @@ export function getSslSettings(
1414
}
1515
}
1616

17-
if (sslMode === 'require' || sslMode === 'prefer') {
17+
if (sslMode === 'require') {
18+
return true
19+
}
20+
21+
if (sslMode === 'prefer') {
1822
return {
1923
rejectUnauthorized: false,
2024
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { readConfig } from '../config.js'
3+
import { DestinationResolver } from '../destinations.js'
4+
5+
describe('database destination resolution', () => {
6+
it('derives single-tenant external pool state from parsed config', async () => {
7+
const resolver = new DestinationResolver(
8+
readConfig({
9+
DATABASE_POOL_URL: 'postgres://pooler',
10+
})
11+
)
12+
13+
const destination = await resolver.resolve('default')
14+
15+
expect(destination).toMatchObject({
16+
connectionString: 'postgres://pooler',
17+
isExternalPool: true,
18+
})
19+
})
20+
})

src/database/tests/index.test.ts

Lines changed: 27 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,21 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
33
import type { DatabaseErrorResponse } from '../errors.js'
44
import type { ApplicationContext } from '../index.js'
55

6-
type Handler = (data: unknown) => Promise<unknown>
7-
86
type MockClient = {
97
queries: Array<{ sql: string; values?: unknown[] }>
108
query: ReturnType<typeof vi.fn>
119
release: ReturnType<typeof vi.fn>
1210
}
1311

1412
type MockedEnvironment = {
15-
app: ApplicationContext,
13+
app: ApplicationContext
1614
messaging: ReturnType<typeof setupLoopbackMessaging>
1715
clients: MockClient[]
18-
pools: Array<{ config: Record<string, unknown>; ended: boolean; queries: Array<{ sql: string; values?: unknown[] }> }>
16+
pools: Array<{
17+
config: Record<string, unknown>
18+
ended: boolean
19+
queries: Array<{ sql: string; values?: unknown[] }>
20+
}>
1921
}
2022

2123
function createMockClient(rows: unknown[]): MockClient {
@@ -31,14 +33,11 @@ function createMockClient(rows: unknown[]): MockClient {
3133
return client
3234
}
3335

34-
async function loadApp(options: {
35-
env?: Record<string, string>
36-
queryRows?: unknown[]
37-
tenantRows?: unknown[]
38-
} = {}): Promise<MockedEnvironment> {
36+
async function loadApp(
37+
options: { env?: Record<string, string>; queryRows?: unknown[]; tenantRows?: unknown[] } = {}
38+
): Promise<MockedEnvironment> {
3939
vi.resetModules()
4040

41-
const handlers = new Map<string, Handler>()
4241
const clients: MockClient[] = []
4342
const pools: MockedEnvironment['pools'] = []
4443
const queryRows = options.queryRows || [{ ok: true }]
@@ -100,7 +99,7 @@ async function loadApp(options: {
10099
}))
101100

102101
// We need dynamic import due to the mocking of PostgreSQL modules above
103-
const {create} = await import('../index.js')
102+
const { create } = await import('../index.js')
104103

105104
const messaging = setupLoopbackMessaging('db')
106105
const app = create()
@@ -120,7 +119,7 @@ describe('database Watt application messaging handlers', () => {
120119
it('registers prefixed handlers and exposes no server', async () => {
121120
const { app } = await loadApp()
122121

123-
expect(app.isBackgroundApplication).toBe(true)
122+
expect(app.isBackgroundApplication).toBe(true)
124123
})
125124

126125
it('executes stateless queries against the single-tenant destination', async () => {
@@ -138,6 +137,19 @@ describe('database Watt application messaging handlers', () => {
138137
expect(clients[0].release).toHaveBeenCalledWith(undefined)
139138
})
140139

140+
it('marks single-tenant DATABASE_POOL_URL destinations as external pools', async () => {
141+
const { messaging, pools } = await loadApp({
142+
env: { DATABASE_POOL_URL: 'postgres://pooler' },
143+
})
144+
145+
await messaging.send('database', 'database.query', {
146+
destination: 'default',
147+
sql: 'SELECT 1',
148+
})
149+
150+
expect(pools[0].config).toMatchObject({ connectionString: 'postgres://pooler' })
151+
})
152+
141153
it('returns validation errors from malformed requests', async () => {
142154
const { messaging } = await loadApp()
143155

@@ -200,7 +212,9 @@ describe('database Watt application messaging handlers', () => {
200212
lockId: begin.lockId,
201213
sql: 'SELECT 1',
202214
})
203-
const commit = await messaging.send('database', 'database.commitTransaction', { lockId: begin.lockId })
215+
const commit = await messaging.send('database', 'database.commitTransaction', {
216+
lockId: begin.lockId,
217+
})
204218

205219
expect(commit).toEqual({ committed: true })
206220
expect(clients[0].queries.map((query) => query.sql)).toEqual([
@@ -300,5 +314,3 @@ describe('database Watt application messaging handlers', () => {
300314
})
301315
})
302316
})
303-
304-

src/database/tests/ssl.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { getSslSettings } from '../ssl.js'
3+
4+
describe('database SSL settings', () => {
5+
it('disables SSL when sslmode=disable', () => {
6+
expect(getSslSettings('postgres://user:pass@localhost/db?sslmode=disable')).toBe(false)
7+
})
8+
9+
it('uses certificate verification for sslmode=require without a root cert', () => {
10+
expect(getSslSettings('postgres://user:pass@localhost/db?sslmode=require')).toBe(true)
11+
})
12+
13+
it('uses the configured root certificate when present', () => {
14+
expect(getSslSettings('postgres://user:pass@localhost/db?sslmode=require', '<cert>')).toEqual({
15+
ca: '<cert>',
16+
})
17+
})
18+
19+
it('keeps sslmode=prefer compatible with poolers that use self-signed certificates', () => {
20+
expect(getSslSettings('postgres://user:pass@localhost/db?sslmode=prefer')).toEqual({
21+
rejectUnauthorized: false,
22+
})
23+
})
24+
})

src/internal/database/watt-connection.test.ts

Lines changed: 45 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,29 @@
11
import { removeGlobals, updateGlobals } from '@platformatic/globals'
2-
import { describe, expect, it } from 'vitest'
2+
import { afterEach, describe, expect, it, vi } from 'vitest'
33
import type { TenantConnectionOptions } from './pool'
4-
import {
5-
DatabaseWattPgExecutor,
6-
getWattPostgresConnection,
7-
} from './watt-connection'
4+
import { DatabaseWattPgExecutor, getWattPostgresConnection } from './watt-connection'
85

96
type SentWattMessage = {
107
application: string
118
data: Record<string, unknown>
129
message: string
1310
}
1411

15-
function installWattMessagingMock(
16-
responses: Record<string, unknown> = {}
17-
): { sent: SentWattMessage[] } {
12+
function installWattMessagingMock(responses: Record<string, unknown> = {}): {
13+
sent: SentWattMessage[]
14+
} {
1815
const sent: SentWattMessage[] = []
1916

2017
updateGlobals({
2118
messaging: {
19+
handle: vi.fn(),
20+
notify: vi.fn(),
2221
send: vi.fn(async (application: string, message: string, data: Record<string, unknown>) => {
2322
sent.push({ application, data, message })
2423
const response = responses[message]
2524
return response instanceof Promise ? response : response
2625
}),
27-
} as unknown as any
26+
},
2827
})
2928

3029
return { sent }
@@ -41,7 +40,10 @@ describe('Watt PostgreSQL connection adapter', () => {
4140
})
4241
const connection = await getWattPostgresConnection(createConnectionOptions())
4342

44-
const result = await connection.query<{ id: number }>({ text: 'SELECT $1::int as id', values: [1] })
43+
const result = await connection.query<{ id: number }>({
44+
text: 'SELECT $1::int as id',
45+
values: [1],
46+
})
4547

4648
expect(result.rows).toEqual([{ id: 1 }])
4749
expect(result.rowCount).toBe(1)
@@ -118,13 +120,42 @@ describe('Watt PostgreSQL connection adapter', () => {
118120
})
119121
})
120122

123+
it('preserves timeout response codes on pg DatabaseError', async () => {
124+
installWattMessagingMock({
125+
'database.query': {
126+
code: 'ACQUIRE_TIMEOUT',
127+
message: 'Timed out acquiring database connection',
128+
},
129+
})
130+
const connection = await getWattPostgresConnection(createConnectionOptions())
131+
132+
await expect(connection.query('SELECT 1')).rejects.toMatchObject({
133+
code: 'ACQUIRE_TIMEOUT',
134+
message: 'Timed out acquiring database connection',
135+
})
136+
})
137+
138+
it('maps server timeouts to PostgreSQL query-canceled SQLSTATE', async () => {
139+
installWattMessagingMock({
140+
'database.query': {
141+
code: 'SERVER_TIMEOUT',
142+
message: 'canceling statement due to statement timeout',
143+
},
144+
})
145+
const connection = await getWattPostgresConnection(createConnectionOptions())
146+
147+
await expect(connection.query('SELECT pg_sleep(10)')).rejects.toMatchObject({
148+
code: '57014',
149+
message: 'canceling statement due to statement timeout',
150+
})
151+
})
152+
121153
it('sends explicit cancellation when an AbortSignal fires', async () => {
122154
let resolveQuery!: (value: unknown) => void
123155
const { sent } = installWattMessagingMock({
124-
'database.query':
125-
new Promise((resolve) => {
126-
resolveQuery = resolve
127-
}),
156+
'database.query': new Promise((resolve) => {
157+
resolveQuery = resolve
158+
}),
128159
'database.cancel': { cancelled: true },
129160
})
130161
const connection = await getWattPostgresConnection(createConnectionOptions())

0 commit comments

Comments
 (0)