PostgreSQL
Configure, operate, and test Ridu’s official transactional store.
PostgreSQL 17 is Ridu’s official networked and multi-replica store. The adapter implements documents, globals, relationships, localization, auth, versions, tasks, locks, preferences, reference indexes, migrations, and readiness behind Ridu’s public store contracts.
Start a new PostgreSQL project
PostgreSQL is the scaffold default:
npm create ridu@latest my-appbun create ridu@latest my-apppnpm create ridu@latest my-appyarn create ridu my-appChoose Starter and PostgreSQL in the wizard, then run the exact install and dev commands it
prints. PostgreSQL is preselected, but the summary still makes the choice explicit before any files
are written.
Preselect every scaffold choice
For a non-interactive npm setup, pass every choice as an argument:
npm create ridu@latest -- \
--template starter \
--database postgres \
--module github.com/acme/content \
--scope @acme \
--package-manager npm \
--agent codex \
contentThe generated compose.yaml exposes PostgreSQL 17 on 127.0.0.1:54329, and the dev script starts it,
waits for pg_isready, performs safe additive development synchronization, and runs the API/admin.
To use a service you already operate, supply its secret only to the command and skip Compose:
DATABASE_URL="$DEVELOPMENT_DATABASE_URL" npm run dev -- --no-dockerDATABASE_URL="$DEVELOPMENT_DATABASE_URL" bun run dev -- --no-dockerDATABASE_URL="$DEVELOPMENT_DATABASE_URL" pnpm run dev --no-dockerDATABASE_URL="$DEVELOPMENT_DATABASE_URL" yarn run dev --no-dockerAdd PostgreSQL to an existing project
Use this path for a clean Ridu project that does not yet have committed migrations or data. Ridu does not provide a generic live SQLite/MongoDB-to-PostgreSQL migration facility; moving an existing dataset between adapters is an application-owned export, transform, validation, and cutover.
-
Replace the current adapter in
ridu.toml. This example starts from SQLite; if the project currently names MongoDB, replace that line instead:ridu.toml version = 1 database = "sqlite" database = "postgres" entry = "./cmd/server" admin = "./admin" -
Replace the old adapter import and store factory inside the existing
runtimeOptionsfunction. Keep the generated address, admin assets, handler options, and server options around it. Lazy opening keeps config resolution and generation offline:cmd/server/main.go import ( "context" "os" "github.com/acme/content/internal/adminassets" "github.com/riducms/ridu" "github.com/riducms/ridu/adapters/sqlite" "github.com/riducms/ridu/adapters/postgres" "github.com/riducms/ridu/store" ) func runtimeOptions(applicationConfig ridu.Config) []ridu.ExecuteOption { return []ridu.ExecuteOption{ ridu.WithStore(func(ctx context.Context) (store.Store, error) { return sqlite.Open(ctx, sqliteDatabasePath()) return postgres.OpenWithConfig(ctx, postgres.PoolConfig{ DatabaseURL: os.Getenv("DATABASE_URL"), AllowInsecureTransport: envBool("RIDU_ALLOW_INSECURE_DATABASE"), MaxUploadLockConnections: envInt32("RIDU_POSTGRES_UPLOAD_LOCK_CONNECTIONS"), }) }), ridu.WithAddress(env("RIDU_ADDRESS", ":8080")), ridu.WithHandlerOptions(ridu.HandlerOptions{ AdminAssets: adminassets.FS(), AllowedOrigins: envList("RIDU_ALLOWED_ORIGINS"), }), } }The duplicate return statements and adapter imports represent the before/after diff—the finished file contains only the PostgreSQL lines. The generated server template also supplies the
envBoolandenvInt32parsers; copy them from a fresh--database postgresscaffold if the current entry does not already have them. -
Either copy the generated PostgreSQL service from a disposable scaffold into
compose.yaml, or point at an existing PostgreSQL 17 service. The generated local service uses database/user/passwordridu, host port54329, andsslmode=disable; never carry that plaintext credential into production. -
Start the development loop against the selected service:
go mod tidy DATABASE_URL="$POSTGRES_URL" npm run dev -- --no-dockergo mod tidy DATABASE_URL="$POSTGRES_URL" bun run dev -- --no-dockergo mod tidy DATABASE_URL="$POSTGRES_URL" pnpm run dev --no-dockergo mod tidy DATABASE_URL="$POSTGRES_URL" yarn run dev --no-dockerUse the generated Compose URL for
$POSTGRES_URL, or an already managed development URL. Remove insecure admission when the service uses verified TLS.ridu devresolves the Go config, regenerates contracts, synchronizes safe additive changes, and starts the API and admin. Before deploying, create and verify immutable migration artifacts using the migration workflow below.
Connect the generated server
Generated applications open the store lazily so schema generation never needs database access.
The factory belongs inside runtimeOptions, between the surrounding address and handler settings,
as shown in the existing-project diff above. Do not open a database from a
package initializer or from content.Config().
Use encrypted transport outside an explicitly local environment:
export DATABASE_URL='postgres://ridu:[email protected]:5432/content?sslmode=verify-full'
export RIDU_ADDRESS=':8080'postgres.Open uses bounded defaults. Use OpenWithConfig when the application needs deliberate
pool or session tuning:
return postgres.OpenWithConfig(ctx, postgres.PoolConfig{
DatabaseURL: os.Getenv("DATABASE_URL"),
ApplicationName: "acme-content",
MaxConnections: 30,
MinConnections: 2,
MaxUploadLockConnections: 4,
ConnectTimeout: 10 * time.Second,
StatementTimeout: 45 * time.Second,
LockTimeout: 5 * time.Second,
IdleInTransactionSessionTimeout: 30 * time.Second,
})Size connections against the database service and the total replica count, not one process in isolation.
Pool defaults and limits
Zero values select the following defaults:
| Setting | Default | Purpose |
|---|---|---|
| Document connections | 20 maximum, 0 minimum | Bounds ordinary content and framework-state work per process. |
| Upload-lock connections | 4 maximum | Separate advisory-lock pool so staged uploads cannot consume the document connection needed to commit metadata. |
| Connection lifetime | 1 hour ± up to 5 minutes jitter | Rotates connections without synchronizing every replica. |
| Idle connection lifetime | 30 minutes | Releases unused capacity. |
| Pool health check | 1 minute | Checks idle connections. |
| Connect timeout | 10 seconds | Bounds startup and new connections. |
| Statement timeout | 60 seconds | Bounds server-side statements. |
| Lock timeout | 10 seconds | Bounds PostgreSQL lock waits and upload-lock admission. |
| Idle transaction timeout | 60 seconds | Prevents abandoned sessions from holding transaction state indefinitely. |
A negative duration explicitly disables that individual timeout; connection counts cannot be negative, minimum cannot exceed maximum, and server timeout values must use whole milliseconds. An unbounded setting should be a measured exception, not a routine fix for blocked work.
The upload-lock pool is additional to MaxConnections. Startup and readiness ping both pools. Loss
or exhaustion of either makes the instance unready before the next upload discovers it.
TLS is required by default
OpenWithConfig rejects plaintext and TLS-fallback URLs unless AllowInsecureTransport is true.
Use sslmode=require, verify-ca, or preferably verify-full in production. Modes such as
prefer, allow, and disable permit fallback and are rejected.
AllowInsecureTransport exists for local Unix sockets and development databases protected outside
PostgreSQL. Do not expose it as a silent production fallback. Protect credentials independently and
use a least-privilege application role.
Readiness checks
The store exposes connectivity, manifest, and exact-history checks:
| Check | Required state |
|---|---|
Ping(ctx) |
Both document and upload-lock pools can reach PostgreSQL. |
Ready(ctx, manifest) |
Ping succeeds, the immutable migration ledger exists, its latest complete digest exactly matches the executable manifest, and no phased migration work is incomplete. |
ReadyWithMigrationHistory(ctx, manifest, hash) |
Ordinary readiness passes and the complete ordered ledger matches the filename/digest fingerprint embedded by ridu build. |
ridu.Execute runs aggregate readiness before binding its listener and includes the same store
check in /readyz. Production uses the exact-history form, so it also rejects a missing, altered,
renamed, reordered, or additional artifact even when the final manifest happens to be unchanged.
An older binary becomes unready after a migration, and a new binary is unready before it.
Readiness does not inspect every physical table and index. Run ridu migrate status for complete
ledger, phase/step, and physical-schema drift inspection. See Production for
probe and drain behaviour.
Transactions and access remain atomic
Ridu’s operation engine owns the lifecycle; PostgreSQL supplies its transactional implementation. Caller filters and filtered access predicates are compiled into the same SQL query for reads, updates, and deletes. A local Go call, REST request, task, admin action, or plugin transport cannot bypass that predicate by choosing another adapter entry point.
Ordinary writes use a transaction. Read snapshots use repeatable-read, read-only transactions. Relationship admission and deletion coordinate row locks so a dangling target cannot commit; expected revisions produce stable conflicts without revealing access-filtered documents; hook or validation failure rolls persistence back; and joined mutations retain the outer transaction boundary.
Retryable PostgreSQL serialization and deadlock outcomes become stable operation conflicts rather than raw driver errors. Application callers should re-read and deliberately retry the operation instead of replaying a stale mutation blindly.
Schema ownership
Ridu owns the configured PostgreSQL schema, including its content tables, framework state,
migration ledgers, indexes, and declared ridu_plugin_<key>_... tables. Unrelated tables in that
schema are reported as drift.
When another system shares the database, give Ridu a dedicated schema through the connection
search_path:
postgres://ridu:[email protected]/content?sslmode=verify-full&search_path=ridu_contentDo not grant a plugin ownership of a core or unrelated table merely to hide drift. Plugin table prefixes are validated as part of descriptor resolution.
Development and production migrations
ridu dev can apply additive, non-destructive development synchronization. It pauses when a
possible rename needs explicit intent. Production uses committed immutable artifacts:
npm run ridu -- migrate create --name rename-post-title
npm run ridu -- migrate plan
npm run ridu -- migrate verify
npm run ridu -- migrate status
npm run ridu -- migrate upbun run ridu -- migrate create --name rename-post-title
bun run ridu -- migrate plan
bun run ridu -- migrate verify
bun run ridu -- migrate status
bun run ridu -- migrate uppnpm run ridu migrate create --name rename-post-title
pnpm run ridu migrate plan
pnpm run ridu migrate verify
pnpm run ridu migrate status
pnpm run ridu migrate upyarn run ridu migrate create --name rename-post-title
yarn run ridu migrate plan
yarn run ridu migrate verify
yarn run ridu migrate status
yarn run ridu migrate upCreation is offline. The database-backed commands use their own bounded pools, TLS policy, advisory-lock admission, and schema assertions. Read Migrations for exact command semantics, destructive and maintenance admission, resumable phases, and recovery.
Test against PostgreSQL
Use a disposable PostgreSQL 17 database for application tests that cover access rules, localization, transactions, relationship locks, optimistic conflicts, tasks, uploads, migrations, or recovery. Give each test suite its own database or schema and clean it afterward. Never give a test role access to production.
Run ridu migrate verify against a restored backup before deployment, then exercise the Local API,
REST or SDK paths your application depends on. Review Testing,
Releases and compatibility, and Security for the wider checks.