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 .changeset/postgres-pool-options.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@easy-cms/db-postgres': minor
---

Postgres connection options: `prepare`, `statementTimeoutMs`, `idleTimeoutMs`, `connectTimeoutMs` and `applicationName`. Connections are now named `easy-cms` in `pg_stat_activity` (or `PGAPPNAME`). Options left unset still come from the URL and the `PG*` environment variables. The Databases guide covers pool sizing, serverless and poolers such as PgBouncer.
38 changes: 38 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,44 @@ jobs:
- run: pnpm build
- run: pnpm --filter easy-cms-integration-tests exec vitest run --project postgres

pgbouncer:
needs: scope
if: needs.scope.outputs.full == 'true'
# The same suites through PgBouncer in transaction mode, as serverless hosts' poolers run:
# any server connection per transaction, no prepared statements.
runs-on: ubuntu-24.04
services:
postgres:
image: postgres:17
env:
POSTGRES_USER: easycms
POSTGRES_PASSWORD: easycms
POSTGRES_DB: easycms
options: >-
--health-cmd "pg_isready -U easycms" --health-interval 5s --health-timeout 5s --health-retries 10
pgbouncer:
image: edoburu/pgbouncer:v1.26.0-p0
env:
DATABASE_URL: postgres://easycms:easycms@postgres:5432/easycms
POOL_MODE: transaction
AUTH_TYPE: scram-sha-256
MAX_CLIENT_CONN: 200
DEFAULT_POOL_SIZE: 20
ports: ['6432:5432']
env:
POSTGRES_URL: postgres://easycms:easycms@localhost:6432/easycms
POSTGRES_PREPARE: 'false'
steps:
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v7
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build
- run: pnpm --filter easy-cms-integration-tests exec vitest run --project postgres

upgrade:
needs: scope
if: needs.scope.outputs.full == 'true'
Expand Down
2 changes: 1 addition & 1 deletion docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@
|---|---|---|---|
| [#153](https://github.com/maritonx/easy-cms/issues/153) | Claim scheduled jobs and webhook deliveries with `SKIP LOCKED` | area:core, release-blocker | เทสต์สอง instance บน Postgres เดียวกัน: ทุกงานรันครั้งเดียว; งานของ worker ที่ตายกลับเข้าคิวเมื่อ lease หมด ([ADR-0052](adr/0052-postgres-job-queue.md)) |
| [#154](https://github.com/maritonx/easy-cms/issues/154) | Run two instances against one Postgres in CI | area:core, area:release | CI รัน REST, login, upload ไป S3 และ jobs ผ่านสอง instance หลังตัวกระจายโหลด |
| [#155](https://github.com/maritonx/easy-cms/issues/155) | Configurable Postgres pool and `prepare`, tested behind PgBouncer | area:core | ตั้ง `prepare`, `statementTimeout`, `idleTimeout`, `connectTimeout`, `applicationName` (ค่าปริยาย `'easy-cms'`) ได้, CI รันเทสต์หลักผ่าน PgBouncer แบบ transaction pooling, เอกสารมีสูตรจำนวน connection และคำเตือนสำหรับ serverless ([ADR-0055](adr/0055-database-isolation.md)) |
| [#155](https://github.com/maritonx/easy-cms/issues/155) | Configurable Postgres pool and `prepare`, tested behind PgBouncer | area:core | ตั้ง `prepare`, `statementTimeoutMs`, `idleTimeoutMs`, `connectTimeoutMs`, `applicationName` (ค่าปริยาย `'easy-cms'`) ได้, CI รันเทสต์หลักผ่าน PgBouncer แบบ transaction pooling, เอกสารมีสูตรจำนวน connection และคำเตือนสำหรับ serverless ([ADR-0055](adr/0055-database-isolation.md)) |
| [#156](https://github.com/maritonx/easy-cms/issues/156) | Document database isolation levels and test a separate schema | area:docs, area:core | หน้า "Database isolation" (EN/TH) อธิบาย L0–L3, CI รันบน role ที่มี `search_path` เป็น schema ของตัวเอง และตรวจว่าไม่มีตารางใน `public` |
| [#157](https://github.com/maritonx/easy-cms/issues/157) | JSON logs with a request id | area:core | ตัวเลือกให้ log เป็น JSON บรรทัดละรายการ มี request id ที่ส่งกลับใน header ด้วย; ค่าปริยายยังอ่านง่ายสำหรับ dev |
| [#158](https://github.com/maritonx/easy-cms/issues/158) | `/healthz` and `/readyz` in the Next and Nuxt adapters | area:adapters | สอง endpoint ทำงานเหมือน standalone; `/readyz` เช็กฐานข้อมูลและ migration |
Expand Down
2 changes: 1 addition & 1 deletion docs/adr/0055-database-isolation.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
- **L1 ทำสองขั้น:**
1. ตอนนี้: เอกสาร `CREATE SCHEMA` + `ALTER ROLE … SET search_path` (ใช้ได้หลัง PgBouncer เพราะค่าอยู่ที่ role ไม่ใช่ startup parameter) และเทสต์ใน CI ว่าตารางทั้งหมดไปอยู่ใน schema นั้น
2. ทีหลัง: ตัวเลือก `schema` ใน `@easy-cms/db-postgres` ใช้ `pgSchema()` ให้ชื่อตารางระบุ schema เสมอ ไม่ขึ้นกับ `search_path` พร้อมคำสั่ง CLI ย้ายตารางของระบบที่ติดตั้งแล้ว (`ALTER TABLE … SET SCHEMA`)
- **ตัวเลือกของ pool:** `statementTimeout`, `idleTimeout`, `connectTimeout`, `applicationName` (ค่าปริยาย `'easy-cms'`) และ `prepare` ส่วน `statementTimeout` ไม่มีค่าปริยาย เพราะ migration และ backup อาจใช้เวลานาน เอกสารให้สูตร: instance × `max` ไม่เกิน 10–20% ของ `max_connections` และบน serverless ให้ใช้ pooler
- **ตัวเลือกของ pool:** `statementTimeoutMs`, `idleTimeoutMs`, `connectTimeoutMs` (มิลลิวินาที เหมือน `busyTimeoutMs` ของ SQLite), `applicationName` (ค่าปริยาย `'easy-cms'`) และ `prepare` ส่วน `statementTimeoutMs` ไม่มีค่าปริยาย เพราะ migration และ backup อาจใช้เวลานาน เอกสารให้สูตร: instance × `max` ไม่เกิน 10–20% ของ `max_connections` และบน serverless ให้ใช้ pooler
- **role แยกสำหรับ migration:** runtime ใช้ role ที่มีแค่ DML ได้ โดย `verify()` ต้องตรวจตาราง migrations โดยไม่ใช้ DDL ส่วน `easy-cms migrate` รันด้วย role เจ้าของ schema (connection string อีกตัว)

## ผลที่ตามมา
Expand Down
7 changes: 6 additions & 1 deletion packages/db-postgres/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ export default defineConfig({
|---|---|---|
| `url` | — | Connection string |
| `max` | `10` | Pool size |
| `prepare` | `true` | Prepared statements; `false` behind a pooler that can't keep them |
| `statementTimeoutMs` | — | Cancels longer statements (not behind PgBouncer: set it on the role) |
| `idleTimeoutMs` | — | Closes idle connections after this long |
| `connectTimeoutMs` | `30000` | Gives up opening a connection after this long |
| `applicationName` | `easy-cms` | Shown in `pg_stat_activity` |
| `pglite` | — | Data directory (relative to the project), `memory://`, or a `PGlite` instance. Needs `@electric-sql/pglite`. |
| `tablePrefix` | `ecms_` | Prefix of every table Easy CMS creates |
| `migrationDir` | `easy-cms/migrations` | Where migration files are written |
Expand All @@ -36,6 +41,6 @@ Text sorting follows the database collation (server defaults like `en_US.UTF-8`

## Links

[Databases](https://easy-cms-website.vercel.app/docs/databases) · [Documentation](https://easy-cms-website.vercel.app/docs) ([ภาษาไทย](https://easy-cms-website.vercel.app/th/docs)) · [GitHub](https://github.com/maritonx/easy-cms)
[Databases](https://easy-cms-website.vercel.app/docs/databases) (connections, poolers, serverless) · [Documentation](https://easy-cms-website.vercel.app/docs) ([ภาษาไทย](https://easy-cms-website.vercel.app/th/docs)) · [GitHub](https://github.com/maritonx/easy-cms)

MIT License
44 changes: 40 additions & 4 deletions packages/db-postgres/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,27 @@ export interface PostgresAdapterOptions extends DrizzleAdapterOptions {
readonly url?: string
/** Maximum connections in the pool. Default 10. */
readonly max?: number
/**
* Use prepared statements. Default true. Turn off behind a pooler in transaction mode that
* can't keep them (PgBouncer before 1.24, or with `max_prepared_statements = 0`, and some
* hosted poolers): it would run the next query on a server connection without the statement.
*/
readonly prepare?: boolean
/**
* Cancels a statement running longer than this (ms), sent as `statement_timeout` when a
* connection opens. No default: migrations and backups may take a while. PgBouncer refuses
* the parameter; set it on the role there (`ALTER ROLE … SET statement_timeout = …`).
*/
readonly statementTimeoutMs?: number
/** Closes a connection left idle this long (ms). Default: never. */
readonly idleTimeoutMs?: number
/** Gives up opening a connection after this long (ms). Default 30000. */
readonly connectTimeoutMs?: number
/**
* `application_name` of the connections, shown in `pg_stat_activity`. Default `PGAPPNAME`,
* then `'easy-cms'`.
*/
readonly applicationName?: string
/**
* Use PGlite (Postgres in WebAssembly) instead of a server: a data directory
* (relative to the project root), `memory://`, or a PGlite instance you manage.
Expand Down Expand Up @@ -122,15 +143,30 @@ export function postgres(options: PostgresAdapterOptions): DatabaseAdapter {
init: async (args) => {
const connection = options.pglite
? await pgliteConnection(options.pglite, args.cwd)
: postgresJsConnection(options.url as string, options.max ?? 10)
: postgresJsConnection(options.url as string, options)
return connectDatabase(postgresDialect, connection, args, options)
},
}
}

function postgresJsConnection(url: string, max: number): Connection {
// Keep Easy CMS quiet: Postgres NOTICEs (e.g. "relation already exists, skipping") are expected.
const client = postgresJs(url, { max, onnotice: () => {} })
function postgresJsConnection(url: string, options: PostgresAdapterOptions): Connection {
// Only what is set: postgres.js lets an option passed here win over the URL (`?prepare=false`)
// and the PG* environment variables.
const set = <T>(key: string, value: T | undefined) =>
value === undefined ? {} : { [key]: value }
const seconds = (ms: number | undefined) => (ms === undefined ? undefined : ms / 1000)
const client = postgresJs(url, {
max: options.max ?? 10,
...set('prepare', options.prepare),
...set('idle_timeout', seconds(options.idleTimeoutMs)),
...set('connect_timeout', seconds(options.connectTimeoutMs)),
connection: {
application_name: options.applicationName ?? (process.env.PGAPPNAME || 'easy-cms'),
...set('statement_timeout', options.statementTimeoutMs),
},
// Keep Easy CMS quiet: Postgres NOTICEs (e.g. "relation already exists, skipping") are expected.
onnotice: () => {},
})
const runner: SqlRunner = {
async query(text, params = []) {
return [...(await client.unsafe(text, [...params] as never[]))] as Record<string, unknown>[]
Expand Down
57 changes: 57 additions & 0 deletions packages/db-postgres/test/connection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import type { DatabaseInitArgs } from '@easy-cms/core'
import { afterEach, describe, expect, it, vi } from 'vitest'

// What the adapter asks postgres.js for; no server is needed.
const { client } = vi.hoisted(() => ({ client: vi.fn() }))
vi.mock('postgres', () => ({ default: client.mockReturnValue({ end: async () => {} }) }))
vi.mock('@easy-cms/drizzle', async (original) => ({
...(await original<typeof import('@easy-cms/drizzle')>()),
connectDatabase: async () => ({}),
}))
vi.mock('drizzle-orm/postgres-js', () => ({ drizzle: () => ({}) }))

const { postgres } = await import('../src/index.js')

async function optionsFor(options: Parameters<typeof postgres>[0]) {
client.mockClear()
await postgres(options).init({ cwd: '.' } as DatabaseInitArgs)
return client.mock.calls[0]?.[1]
}

describe('postgres.js options', () => {
afterEach(() => vi.unstubAllEnvs())

it('leaves what is not set to the URL and the PG* variables', async () => {
vi.stubEnv('PGAPPNAME', '')
const options = await optionsFor({ url: 'postgres://db/cms?prepare=false' })
expect(options).toMatchObject({ max: 10, connection: { application_name: 'easy-cms' } })
for (const key of ['prepare', 'idle_timeout', 'connect_timeout'])
expect(options).not.toHaveProperty(key)
expect(options.connection).not.toHaveProperty('statement_timeout')
})

it('passes the pool options, timeouts in seconds', async () => {
const options = await optionsFor({
url: 'postgres://db/cms',
max: 4,
prepare: false,
statementTimeoutMs: 15_000,
idleTimeoutMs: 20_000,
connectTimeoutMs: 2_500,
applicationName: 'blog',
})
expect(options).toMatchObject({
max: 4,
prepare: false,
idle_timeout: 20,
connect_timeout: 2.5,
connection: { application_name: 'blog', statement_timeout: 15_000 },
})
})

it('names the connections after PGAPPNAME when it is set', async () => {
vi.stubEnv('PGAPPNAME', 'cms-worker')
const options = await optionsFor({ url: 'postgres://db/cms' })
expect(options.connection.application_name).toBe('cms-worker')
})
})
18 changes: 14 additions & 4 deletions packages/integration/test/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
type SchemaMode,
silentLogger,
} from '@easy-cms/core'
import { postgres } from '@easy-cms/db-postgres'
import { type PostgresAdapterOptions, postgres } from '@easy-cms/db-postgres'
import { sqlite } from '@easy-cms/db-sqlite'
import { PGlite } from '@electric-sql/pglite'
import { createClient } from '@libsql/client'
Expand Down Expand Up @@ -81,8 +81,11 @@ afterAll(async () => {
}
})

/** The database adapter under test. Each project directory gets isolated tables. */
export function db(): DatabaseAdapter {
/**
* The database adapter under test. Each project directory gets isolated tables. `server` options
* apply to a Postgres server only.
*/
export function db(server: Partial<PostgresAdapterOptions> = {}): DatabaseAdapter {
return {
name: `test-${DIALECT}`,
init(args) {
Expand All @@ -92,7 +95,14 @@ export function db(): DatabaseAdapter {
pglite ??= new PGlite()
return postgres({ pglite, tablePrefix }).init(args)
}
return postgres({ url: process.env.POSTGRES_URL as string, max: 3, tablePrefix }).init(args)
return postgres({
url: process.env.POSTGRES_URL as string,
max: 3,
// CI also runs the suites through PgBouncer in transaction mode, without prepared statements.
prepare: process.env.POSTGRES_PREPARE !== 'false',
...server,
tablePrefix,
}).init(args)
},
}
}
Expand Down
27 changes: 27 additions & 0 deletions packages/integration/test/postgres-pool.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { defineConfig } from '@easy-cms/core'
import { describe, expect, it } from 'vitest'
import { DIALECT, db, open, rawQuery, SECRET, tempProject } from './helpers.js'

// Through PgBouncer, pg_stat_activity shows the pooler's server connections instead.
describe.runIf(DIALECT === 'postgres' && process.env.POSTGRES_PREPARE !== 'false')(
'Postgres connections',
() => {
it('are named after applicationName', async () => {
const name = `easy-cms-test-${process.pid}`
const config = defineConfig({
secret: SECRET,
db: db({ applicationName: name }),
collections: [{ slug: 'notes', fields: [{ name: 'text', type: 'text' }] }],
})
const cwd = tempProject()
const cms = await open(config, cwd)
await cms.find('notes')
const rows = await rawQuery(
cwd,
`select count(*)::int as n from pg_stat_activity where application_name = '${name}'`,
)
expect(rows[0]?.n).toBeGreaterThan(0)
await cms.destroy()
})
},
)
38 changes: 38 additions & 0 deletions website/guide/databases.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,44 @@ PGlite is handy locally and in tests: real Postgres with nothing to install. A c
db: process.env.DATABASE_URL ? postgres({ url: process.env.DATABASE_URL }) : postgres({ pglite: '.pglite' }),
```

### Connections

| Option | Default | |
|---|---|---|
| `max` | `10` | Connections in the pool, per instance |
| `prepare` | `true` | Prepared statements. Turn off behind a pooler that can't keep them |
| `statementTimeoutMs` | — | Cancels a statement that runs longer. Not behind PgBouncer (see below) |
| `idleTimeoutMs` | — | Closes connections left idle this long |
| `connectTimeoutMs` | `30000` | Gives up opening a connection after this long |
| `applicationName` | `easy-cms` | Shown in `pg_stat_activity`. `PGAPPNAME` also works |

Options in the URL (`?prepare=false`) and the `PG*` environment variables still apply when
the option isn't set. `statementTimeoutMs` has no default because migrations and backups can
take a while: `easy-cms migrate` and `easy-cms backup` read the same config, so leave them room.

**How many connections.** Every instance opens up to `max`, so the total is instances ×
`max`. Keep it under 10–20% of the server's `max_connections` (`SHOW max_connections`), leaving
room for your app, migrations and admin tools.

**Serverless and poolers.** Each serverless function instance has its own pool, and a burst of
traffic starts many of them: connect through a pooler (PgBouncer, or your host's: Neon,
Supabase, RDS Proxy) and keep `max` small, 1–3.

```ts
db: postgres({ url: process.env.DATABASE_POOLER_URL, max: 2, prepare: false })
```

- **Transaction mode** gives each transaction any server connection. Easy CMS keeps nothing
between transactions, so it works, tested in CI against PgBouncer.
- **`prepare: false`** unless the pooler keeps prepared statements across server connections:
PgBouncer does from 1.24 (`max_prepared_statements`, default 200). Otherwise queries fail
with "prepared statement … does not exist".
- **Settings on the role**, not the connection: PgBouncer refuses startup parameters such as
`statement_timeout`. Use `ALTER ROLE cms SET statement_timeout = '15s'` instead of
`statementTimeoutMs`.
- **Migrations** run through the pooler too, or over a direct connection if your host offers
one.

## Common options

| Option | Default | |
Expand Down
Loading
Loading