App Backends Postgres Adapter
postgres-url is the reference adapter for a Postgres server you operate. Give it a URL and an admin
role and it runs each backend as a separate database on that server. It implements all 25 operations of
the Adapter SPI and is exported from
@hellmai/lumenflow-packs-app-backends/adapters/postgres-url. It is generic Postgres: it uses no
feature specific to a hosting provider.
It promises only what the server provides. See What it guarantees and what it does not.
Prerequisites
Section titled “Prerequisites”- Postgres 16 or 17. The adapter uses role-membership options introduced in 16, and by default
accepts major versions
16and17(engineVersions). On a server outside that set the first call fails withunsupported_capability. - An admin role with
CREATEDBandCREATEROLE.SUPERUSERis not required. The adapter uses this role to create databases and per-backend roles, and never hands it to an application. - If
anonandauthenticatedalready exist, the admin role needsADMIN OPTIONon both (GRANT anon TO <admin> WITH ADMIN OPTION). When the adapter creates them itself, it already has it. An existing role with login, superuser or bypass-RLS rights is refused. - The
pgpackage, version 8.11 or later, installed next to the pack. It is an optional peer dependency and the adapter loads it on first use. Without it the first call fails with an instruction to install it. - A sealing port and an object store port. See Sealing and custody.
Configuration
Section titled “Configuration”parsePostgresUrl splits a postgres:// or postgresql:// URL into host, port, user, password (as a
Secret), database and TLS settings (sslmode). The URL is consumed there and never stored.
ConfigError lists every problem in a configuration at once.
PostgresUrlAdapterConfig:
| Field | Meaning |
|---|---|
host, port | The server. Port defaults to 5432. |
tls | true, false or an object with ca, rejectUnauthorized and servername, passed to pg. |
admin | { user, password }. password must be a Secret (new Secret('...')), which renders as a placeholder when logged. |
sealing | Required. A SealingPort. |
objectStore | Required. An ObjectStorePort, which resolves sealed export sinks and import sources. |
connectionSource | Optional. A ConnectionSourcePort that resolves the sealed reference adopt takes into a login on an existing database. Without it adopt answers unsupported_capability. |
registryDatabase | Created on first use. Default app_backends_operator. |
maintenanceDatabase | An existing database to connect to before the registry exists. Default postgres. |
engineVersions | Accepted major versions, declared in the capability document. Default ['16', '17']. |
region | The region triple the adapter declares. Default { residency: 'unspecified', provider: 'external-postgres', location: 'external' }. |
restorePointBeforeMigration | Take a restore point before applying a migration. Default true. |
restorePointRetention | Restore points kept per backend; older ones are dropped. Default 5. |
limits | maxConnectionsPerBackend (20), statementTimeoutMs (30 000), idleInTransactionTimeoutMs (60 000), lockTimeoutMs (5 000), maxDatabaseSizeGb (50), maxBackends (null: derived from the server’s max_connections), capacityHighWater (0.85). |
connectionRefTtlMs | How long a sealed connection reference is valid. Default one hour. |
exportRetentionMs | How long an export manifest says its objects are kept. Default seven days. |
adapterId | Default postgres-url. |
clock, logger | For tests and log routing. createRedactingLogger writes JSON lines with known secrets removed. |
executor | A SqlExecutor. Default: the pg-backed one, createPgSqlExecutor(). |
The adapter never imports a database driver itself. It talks to the server through the SqlExecutor
port, so the driver is replaceable. createPgSqlExecutor(), also at
@hellmai/lumenflow-packs-app-backends/adapters/postgres-url/pg, loads pg by dynamic import, opens one
client per session (no pool), sets statement and connection timeouts, and turns every failure into a
SqlError with passwords and connection strings removed.
Adopting an existing database
Section titled “Adopting an existing database”A database that existed before the pack can be registered with adopt and then exported and
verified. Give the adapter a connectionSource, which holds the logins and resolves a sealed
reference for the caller:
The adapter proves the login works before it registers anything, seals the password, and stores the
server, port, database and user in its registry with ownership adopted. It creates no schema,
table or role in the adopted database, so the idempotency store and catalog lock of a provisioned
backend are not needed there. Export and verify read the database in one read-only, repeatable-read
transaction. The adopted login needs only the privileges to read what it should export.
A read_only adoption refuses migrations, restore points, restore, credential rotation, access-mode
and limit changes, import, connection references, usage and deprovision with policy_violation,
and never drops the database. To move the data onto a provisioned backend, export the adopted backend,
import the export into a provisioned one and verify the two. See the guarantees in
Adopting an existing database.
The isolation model
Section titled “The isolation model”- A database per backend. Each backend is its own database on the server. There is no schema-per-tenant and no shared tables.
- Opaque names. Database names, role names and every handle reference are random. None is derived from a workspace, tenant or app identifier, because catalog names in a shared server are readable by every connected role. The mapping from a handle to its database lives in the registry database.
- Per-backend login roles. Each backend has three login roles, for the data API, the auth server and
the migrator. They have no
SUPERUSER,CREATEDB,CREATEROLE,BYPASSRLSorREPLICATION, do not inherit, have connection limits, and each backend’s roles can connect only to that backend’s database. Passwords are random, are set on the server as SCRAM verifiers (never in clear) and are held sealed. - Shared
NOLOGINroles.anonandauthenticatedare created once per server with no login. The data API role canSET ROLEto them without inheriting their privileges. They are what a request runs as, with the verified claims available to policies. - Deny by default.
CONNECTis revoked from everyone but the backend’s own three roles. Thepublicschema is revoked fromPUBLICand usable only byanonandauthenticated. A table gets no access foranonorauthenticateduntil a migration grants it and defines policies, so a table without policies is unreadable and unwritable by the end-user roles. - Migrations are constrained. They run as the migrator role, never the admin, inside one
transaction. An admission lint refuses, with a typed finding that names the statement, anything that
would escape the backend: extensions outside the allowlist (
pgcrypto,uuid-ossp,citext,pg_trgm,btree_gist),COPY ... PROGRAMand server-side fileCOPY,lo_importandlo_export, server file functions and roles, role management,SET ROLEandSET SESSION AUTHORIZATION,ALTER SYSTEM, untrusted procedural languages, foreign data wrappers,CREATE,DROPandALTER DATABASE, the adapter’s ownlf_metaschema,FORCE ROW LEVEL SECURITY, transaction control, and statements that cannot run in a transaction (VACUUM,CREATE INDEX CONCURRENTLY). The lint is a second line of defence; the first is that the migrator role holds no such privilege. - A ledger inside the backend. The migration ledger lives in the backend’s own
lf_metaschema, so a restore carries it with the data it describes.
What it guarantees and what it does not
Section titled “What it guarantees and what it does not”The adapter declares this capability document, built by buildCapabilities(config):
| Capability | Value |
|---|---|
engineFamily | postgres, versions from engineVersions |
rowLevelPolicies | true |
transactionalMigrations | true |
pitr | supported: false, window and granularity 0 |
restorePoints | true |
branching | false (no branch method) |
exportFormats | ['neutral-jsonl-v1'] |
crossRegionBackups | false |
encryptionAtRest | false |
scaleToZero | false, coldStartP95Ms is null |
placement | ['external'] |
regions | The single configured region |
extensions | The allowlist above |
What follows from that, in plain terms:
- No point-in-time recovery. The adapter cannot prove that the server archives its write-ahead log, so
it declares
pitr.supportedfalse.recoveryWindowand apoint_in_timerestore returnunsupported_capability. If you operate PITR on your server, that is yours; the adapter does not claim it. - Restore points are logical clones, not backups. A restore point is a
CREATE DATABASE ... TEMPLATEcopy on the same server. Postgres will not copy a database with sessions, so creating one briefly disconnects the backend: new connections are blocked, existing sessions are ended, the copy is made, and connections are allowed again. Plan restore points, including the one taken before each migration, with that interruption in mind. Losing the server loses its restore points. Back up the server separately. - Which sessions a deprovision or restore point ends. Before it drops or copies a database, the
adapter ends the client sessions it is allowed to signal: those of the backend’s own login roles and
any other role the admin holds the privileges of. It does not signal superuser sessions, sessions of
roles the admin is not a member of, or server processes such as autovacuum workers. Postgres cancels
autovacuum workers by itself when a database is dropped or copied, so they never block. A superuser
or otherwise unsignalable client session that stays connected makes the call return
provider_unavailable(retryable, outcomenone, orpartialwhen earlier steps already took effect) and name that role class. End that session, then repeat the call, or runreconcile()for an interrupted deprovision. The adapter does not report this asinternal, and it does not need SUPERUSER or the Postgres 18pg_signal_autovacuum_workerrole. A deprovision blocked by such a session leaves the database refusing connections (statedeprovisioning) until a repeated call orreconcile()completes it; there is no way to undo a deprovision. Before it ends sessions, deprovision stops new connections to the database, so a client that keeps reconnecting cannot hold the drop open.swapandsetAccessMode(freeze) do not end a superuser session: such a session stays connected, and one that began before the database default changed can still write. Freeze fails withprovider_unavailable(outcomepartial) when a session of the backend’s own login roles could not be ended. - Restore is never in place.
restoreclones a restore point into a new candidate backend that shares the original’s login roles, so no new credentials are needed.swappromotes it and keeps the previous database untildiscard. - One export format. Only
neutral-jsonl-v1is declared and implemented.postgres-custom-dumpis a valid value in the contract that this adapter does not offer, so an export in it returnsunsupported_capability. Export writes amanifest.json, onetables/<name>.jsonlper table and asequences.jsonl. It carries tables, primary keys, sequences, grants to the end-user roles and policies. It does not carry indexes other than the primary key, other constraints, views, functions, triggers or user-defined types; those come back through migrations, and the manifest lists what was not carried. Export reads inside one read-only, repeatable-read transaction and changes nothing. The format is specified in full in Export format, together with a standalone verifier and an importer into plain Postgres (app-backends-export verifyandimport) that need no adapter, control plane or LumenFlow configuration. - No branching, no cross-region backups, no scale to zero.
- Encryption at rest is not claimed. The adapter cannot know how the server’s storage is protected.
- Credential rotation works for
data_api,authandmigrator.operatorandsigning_keyreturnunsupported_capability: the admin credential belongs to your host configuration and signing keys belong to the auth server in the data plane. - Usage reports storage only.
requests,connectionSecondsandegressGbare 0 because the adapter does not see data-plane traffic. - Capacity.
cellCapacity(operator only) reports the fraction ofmaxBackendsin use when you set it, and otherwise the fraction of the server’smax_connectionsthe backends could use at their per-backend connection cap.acceptingNewis false once utilization reachescapacityHighWater.
Sealing and custody
Section titled “Sealing and custody”The adapter never keeps a credential in clear and never returns one. Two ports are yours to supply.
SealingPort wraps credentials for storage in the registry. It has two methods:
SealContext is { workspaceId, tenantSubject, appId, backendId, purpose }. Unsealing under another
context must fail. Production custody adapters (a file key, a vault, a key management service) are
supplied separately; this pack depends only on the interface.
createInMemorySealing() is a test double that does not encrypt, only binds a value to its context. Do
not use it outside tests.
ObjectStorePort resolves a sealed sink or source into an object you can write to or read from:
resolveSink(ref) returns { put(name, text) } and resolveSource(ref) returns { get(name), list() }.
The adapter never sees the location or its credentials.
connectionFor(handle, role) returns a SealedConnectionRef. The adapter also exposes two helpers for the
data plane, which is the only code that should call them: resolveConnection(ref, access) and
resolveConnectionAsOperator(handle, role) return a ResolvedConnection whose password is a Secret,
pointing at the backend’s current database (a swap changes which database that is, without new
credentials). A reference that is not the caller’s own returns not_found.
Running the conformance suite against a disposable Postgres
Section titled “Running the conformance suite against a disposable Postgres”The conformance harness for this adapter is exported from
@hellmai/lumenflow-packs-app-backends/adapters/postgres-url/conformance. It builds the adapter itself,
so the fault injector, the clock, log capture and the object store are wired in, and it implements the
data, policy, SQL, fault and object-store probes against a real server.
-
Start a throwaway server and an admin role. For example, with a container runtime:
Then connect as the superuser of that container and create the admin role the adapter will use:
-
Run the suite:
-
Stop the container.
Notes:
- The harness uses the admin password as the secret canary: no result, error, log line or export object may
contain it. Use a strong random password. A weak one such as
postgresalso appears in ordinary output and would be reported as a leak. createPostgresUrlConformanceHarnesstakesadminUrl, an optionalregionand optionaladapterConfig(limits, retention and similar). The harness owns the sealing, object store, executor, logger and clock.- The harness’s sealing and object store are in-memory test doubles. A passing run says the adapter honours the SPI against a real Postgres server; it does not say anything about your production custody or object storage.
- Leave the run to finish. Each case deprovisions what it created. The registry database and the shared roles remain on the server.
- If you run the repository’s own real-server tests, the disposable container they start is labelled with its owner and removed on stop, on SIGINT, SIGTERM and SIGHUP and on exit. A run killed with SIGKILL cannot clean up, so the next start removes labelled containers whose owner on this host is proven dead. Live, unlabelled and other-host containers are never touched.
- A run is only evidence if
report.conformantis true over the whole catalogue. Do not useonlyfor the run you rely on.