PostgreSQL Implementation
PostgreSQL adapter implementation using Drizzle ORM for SveltyCMS.
On this page
PostgreSQL support is production-ready. All adapter modules (Auth, CRUD, Content, Media, System, Widgets, Themes, Batch, Transactions, Performance, Cache, Collections, Multi-Tenancy) are fully implemented with Drizzle ORM.
Overview
SveltyCMS supports PostgreSQL as an alternative to MongoDB and MariaDB through the database adapter pattern. The PostgreSQL adapter uses Drizzle ORM with the postgres.js driver for optimal performance and type safety.
Architecture
The PostgreSQL adapter follows a modular pattern, leveraging Drizzle ORM for schema-agnostic operations and raw SQL for initial schema setup.
File Structure
src/databases/postgresql/
βββ postgres-adapter.ts # Concrete adapter (entry point)
βββ adapter-core.ts # Connection, pool, date normalization, registry
βββ fts-adapter.ts # Full-text search (ILIKE + tsvector paths)
βββ schema.ts # Drizzle ORM schema definitions
βββ transaction-module.ts # Drizzle transaction wrapper
Schema Initialization (Boot Bootstrap)
Unlike MongoDB which is schema-less, PostgreSQL requires table definitions. SveltyCMS renders the system schema at boot directly from the single declarative source of truth (src/databases/system-schema-spec.ts) via src/databases/core/system-schema-bootstrap.ts β there are no per-engine migration files to keep in sync, so the three relational engines can never drift.
sequenceDiagram
participant S as Setup Wizard / System Init
participant A as PostgreSQLAdapter
participant B as Schema Bootstrap (core)
participant D as PostgreSQL DB
S->>A: connect()
A->>B: bootstrapSystemSchema("postgresql", sql)
B->>D: CREATE TABLE IF NOT EXISTS ...
D-->>B: Tables Created/Verified
B-->>A: { success: true }
A-->>S: Ready
Note over B,D: skipped when the stored fingerprint matches
An unchanged release skips this pass entirely: bootstrapSystemSchema compares a sha-256 of SYSTEM_SCHEMA (per dialect) stored in svelty_schema_state and returns { success: true, skipped: true } without touching the database β measured 2026-09-22: 118 statements / 124 ms per boot β ~2 ms. Any spec change re-runs the pass, and a pass with a failed statement does not store the marker, so a half-applied schema is retried instead of trusted.
Connection Pool Configuration (2026-09 Hardening)
SveltyCMS configures the postgres.js driver with production-tuned connection pool settings. All timing values are configurable via environment variables so operators can tune without code changes:
| Setting | Default | Env Var Override | Purpose |
|---|---|---|---|
max |
tier-based | DATABASE_MAX_CONNECTIONS |
Maximum connections in the pool β hw.dbPoolSize (2 per budget core, DB_POOL_SIZE), floored at 20 on small hosts and 32 above. Raise for large servers; keep low on shared hosts. |
idle_timeout |
30s | β | Idle seconds before a pooled connection is closed; the config objectβs idle_timeout overrides it. Shorten it behind PgBouncer in transaction mode, where idle connections waste pooler slots. |
connect_timeout |
10s | β | TCP connect timeout. 3Γ faster failure detection on unreachable hosts vs. the postgres.js default 30s. |
max_lifetime |
60 min | β | Halves pool recreation overhead vs. the previous 30 min default. |
keepalive |
true | β | Prevents silent connection drops from cloud NAT/proxies/firewalls. |
keepaliveInitialDelayMillis |
10 000 | β | Starts keepalive pings after 10 s idle. |
pipeline |
true | β | Postgres.js query pipelining β batches concurrent queries into single TCP round-trips (up to 3Γ throughput on parallel workloads). |
prepare |
true | DATABASE_PREPARE=false |
Server-side prepared statements. Disable for PgBouncer transaction mode (DATABASE_PREPARE=false). |
statement_timeout |
30s | β | Safety limit for individual queries. |
application_name |
sveltycms |
β | Identifies the CMS in pg_stat_activity. |
When to change idle_timeout: the 30-second default is optimal for direct PostgreSQL
connections and most managed-database services (Supabase, RDS, Neon). Shorten it only when a
connection pooler like PgBouncer in transaction mode sits between the CMS and Postgres β there
the pooler handles recycling and idle CMS connections waste pooler slots.
Write Pipelining & Coalescing: Concurrent single-row inserts for one collection join one UNNEST statement (default on; SVELTY_WRITE_COALESCING=0 restores pipelined single-row inserts). A one-row wave does not block the next wave. postgres.js still pipelines those single-row statements.
Connection Configuration
PostgreSQL connections are configured in config/private.ts or during the setup wizard:
// Configuration Object
{
type: 'postgresql',
host: 'localhost',
port: '5432',
name: 'sveltycms',
user: 'postgres',
password: 'your_password'
}
Drizzle Schema
The PostgreSQL schema mirrors the CMS data model but is optimized for relational performance.
Key Implementation Details
| Feature | Implementation |
|---|---|
| Primary Key | varchar(36) (UUID compatible strings) |
| Timestamps | timestamp() with ISODateString conversion |
| JSON fields | jsonb() for binary JSON with GIN indexing |
| Multi-Tenancy | tenantId indexed columns on all tables |
| Connection Pool | Keepalive (10s), pipeline (batch), idle 30s, lifetime 60min, timeout 10s |
Current Implementation Status
β All Modules Implemented
| Module | Status | Notes |
|---|---|---|
auth.* |
β Complete | Users, sessions, tokens, roles with compound indexes |
crud.* |
β Complete | Full CRUD with query builder and batch operations |
content.* |
β Complete | Nodes, drafts, revisions with JSONB data storage |
media.* |
β Complete | File metadata, folders, thumbnails |
batch.* |
β Complete | Transactional batch operations |
performance.* |
β Complete | Latency tracking and health monitoring |
tenants.* |
β Complete | Full CRUD (create, getById, update, delete, list) |
| cleanupExpired | β Complete | TTL-equivalent cleanup for sessions/tokens |
versioning |
β Complete | Atomic getVersion and incrementVersion |
PostgreSQL-Specific Optimizations
-
JSONB (not JSON): All 20 metadata columns use binary JSONB with efficient containment operators (
@>,?,?|) -
GIN Indexes: the 5 spec GIN indexes on high-query JSONB columns (
content_nodes.data,content_nodes.metadata,media_items.metadata,roles.permissions,auth_users.roleIds), plus ajsonb_path_ops<table>_data_ginon every dynamic collection table (createModel; opt out withschema.jsonIndex === false) -
Trigram Search: High-performance
ilikesearching for media filenames usingpg_trgmGIN indexes (media_items_filename_trgm_idx). -
gen_random_uuid(): Native UUID generation via pgcrypto extension -
Tenants Table: Full multi-tenancy with quota, usage, and settings JSONB columns
-
Website Tokens:
website_tokensvia sharedrelational-system.tsβ SHA-256 hash at rest, unique index ontoken, compound{ tenantId, name }, parallel list+count. See Credential Storage. -
Raw
findById: Tagged-template prepared SELECT (stable SQL text β postgres.js statement cache) β FIND ONE ~1.3β1.5k RPS. -
Raw
INSERTfast path (no-read-back): One-statement insert via a flatunsafe(text, values, { prepare: true })template (postgres.js 3.x removedsql.joinβ the earlier implementation silently fell back to Drizzle on every insert, ~225 RPS). Quotes identifiers (unquoted camelCase folds to lowercase) and binds dates/objects as strings (describe-phase Bind quirk). The returned row is synthesized from the prepared values + Drizzle column defaults (status 'draft',isDeleted false, timestamps, NULLs) instead ofRETURNING *β exact parity for CMS tables (no triggers/generated columns; the Drizzle def mirrors the DDL) and it removes the row materialization + jsonb parse from the write round trip. Measured: 217 β ~290β310 RPS on a loaded host (+21% same-run); raw SQL alone ~413 (92% of the 450 RPS 1c ceiling). -
Raw multi-VALUES
insertMany: one prepared statement per chunk (union column set;undefinedvalues bind as literalDEFAULTto keep the prepared bind count exact) β BULK INSERT (100) 87 β 233 RPS (+168%) vs the old Drizzle.returning()path. -
Targeted column UPDATE:
rawUpdateReturningbuildsSETonly for prepared keys. SharedprepareUpdateValues()never writescreatedAtand omits an emptydata={}blob on partial updates. -
Partial updates merge the JSONB blob: the
datacolumnβsSETexpression becomesCOALESCE("data",'{}'::jsonb) || $n::jsonb(shallow, keeps explicit nulls) whenever the write is a patch rather than a complete document β so aPATCH {views: 1}no longer replacestitle/body/β¦ in the same row. The replace form is kept forreplaceData: true/skipReturning: truecallers, and the same merge coverscrud.updateMany,batch.bulkUpdateand query-builderupdateMany. See Write Path. -
Indexed dynamic-field filters (containment + GIN): an equality filter on a field that lives in the
datablob (i.e. not materialized into a column) is emitted asdata @> '{"field": value}'::jsonbβ the form thejsonb_path_opsGIN index serves β instead ofdata->>'field' = $1, which can only be a sequential scan.createModelprovisions<table>_data_ginfor every dynamic collection table;schema.jsonIndex === falseopts out for write-only collections. Measured at 100k rows: 7.9β10.9 ms Seq Scan β 0.1β1.7 ms Bitmap Index Scan (index build 347 ms once; write cost within noise). JSON type fidelity is preserved by probing the raw value, so{views: 101}matches a stored number and not a stored"101"(SQLite and MongoDB already agreed; MariaDBβs text-based JSON comparison remains looser). Dotted paths probe a nested object (a.bβ{"a":{"b":β¦}}), matching thedata#>>'{a,b}'extraction form. -
Prepared JSON/array binds:
bindPgParam()serializes objects and arrays as JSON text; JSON/jsonb columns use$n::jsonbso postgres.js does not emit PG array literals{β¦}(those force a describe-phase recompile). -
crud.aggregate()(native translation): MongoDB-style pipelines compile to one statement viacore/aggregation-translator.tsβ$match,$group(scalar_idplus$sum/$avg/$min/$max/$count),$sort,$skip,$limit,$countand include-only$project.$min/$maxon a non-materialized field are refused witherror.code === "NOT_SUPPORTED"(the->>extraction is TEXT βMINover{10, 2}returns"10"β and there is nomin(jsonb)); declare the fieldindexed/materialize: trueto aggregate it.SUM/AVGcast the extraction tonumeric(getJsonNumericField), and any other unsupported stage fails closed the same way. -
Numeric range filters on blob fields:
$gt/$gte/$lt/$ltecompile tojsonb_typeof(data->'x') = 'number' AND data->'x' >= '4'::jsonb(getJsonCompare) β comparing the extracted TEXT was lexicographic (views >= 4missed stored16/32), and thejsonb_typeofguard is required because jsonb orders String below Number, so an unguarded<would admit stored strings.
Development Guide
To extend the PostgreSQL adapter:
1. Schema Updates
Modify src/databases/postgresql/schema.ts. For schema shape changes that affect fresh installs, update the shared declarative spec src/databases/system-schema-spec.ts once β the boot bootstrap (src/databases/core/system-schema-bootstrap.ts) renders it for all three SQL engines. The specβschema.ts drift guard lives in tests/unit/databases/schema-spec-parity.test.ts.
2. Implementing Methods
Implement dialect hooks in src/databases/postgresql/adapter-core.ts; the shared CRUD implementation lives in src/databases/core/sql-adapter-core.ts and the concrete entry point is postgres-adapter.ts. Use the wrap() helper for consistent error handling and logging.
public readonly crud = {
findOne: async (collection: string, query: Record<string, unknown>) => {
return this.wrap(async () => {
const table = this.getTable(collection);
const where = this.mapQuery(table, query);
const result = await this.db!.select().from(table).where(where).limit(1);
return result[0] || null;
}, 'CRUD_FIND_ONE_FAILED');
}
};
Performance Benchmarks
The PostgreSQL adapter is optimized for relational efficiency and JSONB search performance. For detailed performance metrics and comparisons, please refer to the Performance Benchmarks document.
Shared list / count contract (SqlAdapterCore)
PostgreSQL inherits findPage and count({ mode }) from SqlAdapterCore:
| Feature | Implementation |
|---|---|
findPage |
limit + 1 hasMore; optional total |
count estimate |
SELECT GREATEST(reltuples::bigint, 0) FROM pg_class WHERE relname = $1 |
| Count cache | 30s L1 via createCountCachedCrud (all engines) |
Measured (2026-08-04, Docker PG, no Redis): FIND PAGE 1.006 ms vs legacy LIST+COUNT 4.845 ms (~4.8Γ); COUNT CACHED 0.024 ms vs exact 2.160 ms (~90Γ). See Performance Architecture.
Key PostgreSQL highlights:
- JSONB with GIN Indexing: 0.063 ms NATIVE UPSERT, 0.083 ms FIND MANY, 1.201 ms FIND ONE.
- Partial Indexes: Optimized performance for active sessions and tokens.
- Connection Health: Robust pooling via
postgres.jswith integrated health checks.
Key Optimizations
- JSONB with GIN Indexing: All 20 metadata columns use PostgreSQLβs binary
jsonbtype with 5 GIN indexes for sub-5ms search on complex containment queries. - Partial Indexes: Only index active sessions and unconsumed tokens β reduces index size and speeds up common auth queries.
- Connection Health: Optimized pooling via
postgres.jswith health checks, reconnection, keepalive, and query pipelining for up to 3Γ throughput on parallel workloads. - TTL-Equivalent Cleanup:
cleanupExpiredData()method purges expired sessions and consumed tokens (equivalent to MongoDBβs TTL indexes). - Tamper-Evident Logs: Relational structure with transactional integrity for cryptographic audit log chaining.
π Production Scaling with PgBouncer (Enterprise)
For high-concurrency Postgres deployments or managed databases with connection limits (RDS, Azure, Cloud SQL), SveltyCMS recommends deploying PgBouncer as a connection proxy layer. This follows the same pattern used at scale by Instagram, OpenAI, and other Postgres-heavy platforms.
Why PgBouncer
- Connection Multiplexing: PgBouncer sits between your app instances and Postgres, pooling hundreds of lightweight client connections onto a small pool of real Postgres backend connections. This prevents βtoo many clientsβ errors on managed DBs (often limited to 50-200 connections).
- Lower Latency: App connections to PgBouncer are cheap (Unix socket or localhost). Real Postgres connection setup (~50ms) is avoided on every request β PgBouncer reuses existing backend connections.
- Reduced Postgres CPU: Fewer Postgres backend processes mean fewer context switches and less memory pressure in Postgresβs multi-process model.
- Horizontal Scaling: Adding more app instances doesnβt require increasing Postgres
max_connectionsβ they all share PgBouncerβs pool.
Deployment Pattern
Deploy PgBouncer as a sidecar on each app instance (localhost:6432) or as a central proxy behind a lightweight load balancer. The sidecar pattern minimizes network latency.
Recommended Configuration
Create /etc/pgbouncer/pgbouncer.ini:
[databases]
sveltycms = host=127.0.0.1 port=5432 dbname=sveltycms
[pgbouncer]
listen_addr = 127.0.0.1
listen_port = 6432
auth_type = md5
auth_file = /etc/pgbouncer/userlist.txt
;; Transaction pooling β best efficiency for web workloads.
;; Connections are returned to the pool immediately after each transaction.
pool_mode = transaction
;; Conservative defaults. Tune based on CPU cores and workload.
default_pool_size = 25
max_client_conn = 500
;; Cleanup between client uses (prevents session state leakage)
server_reset_query = DISCARD ALL
;; Logging for observability
log_connections = 1
log_disconnections = 1
stats_period = 60
SveltyCMS Integration
When using PgBouncer in transaction mode, set the following in your environment or config/private.ts:
# Point the app to PgBouncer instead of direct Postgres
DB_HOST=127.0.0.1
DB_PORT=6432
# Required: Disable server-side prepared statements when behind PgBouncer
# (PgBouncer tx mode cannot guarantee the same backend connection between
# prepare and execute, causing "prepared statement does not exist" errors)
DATABASE_PREPARE=false
The adapter automatically detects DATABASE_PREPARE=false and sets prepare: false in the postgres.js driver. This trades the ~40% parse/plan win from server-side prepared statements for seamless PgBouncer compatibility. The query planner cache in Postgres still provides significant optimization.
Session State Limitations: Transaction pooling does NOT preserve session-level state (SET statements, temporary tables, LISTEN/NOTIFY, certain cursor types, advisory locks). SveltyCMS does not depend on any of these β all state is managed at the application layer via our cache, auth sessions, and LocalCMS SDK.
Monitoring
Check PgBouncer health via its admin console:
psql -h 127.0.0.1 -p 6432 -U pgbouncer pgbouncer
> SHOW POOLS;
> SHOW STATS;
> SHOW CLIENTS;
SveltyCMSβs built-in ConnectionPoolOptions and getConnectionPoolStats() work transparently whether connecting directly or through PgBouncer. For production, monitor:
cl_waiting(should stay near 0 β increasedefault_pool_sizeif growing)avg_wait_time(should be < 1ms)- Postgres
activeconnections (should be stable, not climbing)