Skip to main content

core.database.postgres — pure-Verum PostgreSQL driver

core.database.postgres — codename spindle (postgres backend) — is a pure-Verum implementation of PostgreSQL's v3 wire protocol. The protocol encoder and decoder are written in Verum, without a libpq FFI shim. This does not remove the dependencies of the underlying network runtime.

The adapter implements the cross-vendor protocols defined in core.database.common.protocol (Adapter, Connection, affine Transaction, Pool, Row, Params) so handler code is portable across the SQLite / Postgres / MySQL backends — see the cross-vendor capability table.

Architectural layers​

┌─────────────────────────────────────────────────────────────┐
│ L7 PUBLIC API PgConnection, PgTransaction │
├─────────────────────────────────────────────────────────────┤
│ L6 SESSION cancel, listen/notify, copy │
├─────────────────────────────────────────────────────────────┤
│ L5 EXTENDED PROTOCOL Parse / Bind / Execute / Sync; │
│ per-connection prepared cache │
├─────────────────────────────────────────────────────────────┤
│ L4 AUTH SCRAM-SHA-256-PLUS, channel bind │
├─────────────────────────────────────────────────────────────┤
│ L3 WIRE FRAMES frontend / backend / message types │
├─────────────────────────────────────────────────────────────┤
│ L2 TLS via core.net.tls │
├─────────────────────────────────────────────────────────────┤
│ L1 TCP via core.net.tcp.TcpStream │
└─────────────────────────────────────────────────────────────┘

Opening a connection​

mount core.database.postgres.{PgConfig, PgConnection, connect};

// `new` takes the five connection fields positionally.
let cfg = PgConfig.new("localhost", 5432, "alice", "secret", "prod")
.with_application_name("reports")
.with_connect_timeout_ms(5000);

// Or, for a throwaway local instance:
let cfg = PgConfig.local_dev();

let mut conn = connect(&cfg)?;
let result = conn.simple_query(&"SELECT 1".into())?;

Synchronous PgPool checkouts require an explicit healthy or discard release. Async guards have separate channel-based return paths; automatic cleanup is not established by their affine declaration. See connection pools and release.

Affine PgTransaction​

PgTransaction follows the same declared affine discipline as the SQLite handle: at most one consuming use, with no @must_consume requirement. Callers must finish manual transactions through commit_tx or rollback_tx and handle errors; an unused handle is not rejected merely because it is affine. See transaction ownership and cleanup limits.

with_transaction attempts commit after the callback returns Ok, or best-effort rollback after Err. A rollback failure does not replace the original body error. Panic and cancellation cleanup are not established by this helper or by the affine declaration.

mount core.database.postgres.{
PgTransaction, PgTxOpts,
PgTkSerializable, PgAmReadOnly,
begin_tx, begin_tx_with, begin_tx_serializable, begin_tx_read_only,
commit_tx, rollback_tx,
with_transaction, with_transaction_serializable,
savepoint, release_savepoint, rollback_to_savepoint,
};

// Recommended — callback combinator.
with_transaction(&mut conn, |c| {
c.execute(&"INSERT INTO orders (id, total) VALUES (1, 100)".into())?;
c.execute(&"UPDATE accounts SET balance = balance - 100 WHERE id = 1".into())?;
Ok(())
})?;

PgTxOpts builder selects isolation + access mode + DEFERRABLE:

FieldValues
isolation: PgTxKindPgTkReadCommitted (default), PgTkRepeatableRead, PgTkSerializable
mode: PgAccessModePgAmReadWrite (default), PgAmReadOnly
deferrable: PgDeferrablePgDfNone (default), PgDfDeferrable, PgDfNotDeferrable

Defence-in-depth: commit_tx checks conn.tx_status() (driven by every server-side ReadyForQuery) and refuses to fire if the connection is in TxFailedTransaction (Postgres requires ROLLBACK after error) or TxIdle (no tx in progress) — surfaces DbMisuse(...) rather than emitting the silent-warning NOTICE.

rollback_tx returns Ok immediately for TxIdle, where the server has already ended the transaction. Otherwise it sends ROLLBACK, which can fail. Both terminal functions consume the handle even when they return an error; recover or discard the connection before reuse instead of attempting to reuse that token. In manual code, handle query errors before returning so that an early ? does not skip the rollback attempt.

Query cancellation​

Postgres protocol §6.1.7: cancellation must travel on a separate TCP connection (the original socket is held busy by the available query). The driver captures (process_id, secret_key) from the startup BackendKeyData frame and offers two entry points:

// Convenience — uses the connection's stored backend_key.
conn.cancel_running_query(&"localhost".into(), 5432)?;

// Standalone — for callers that hold the tuple outside a PgConnection
// (pool reservations, supervisor watchdogs, audit-log replay).
mount core.database.postgres.cancel_running_query_at;
cancel_running_query_at(&"localhost".into(), 5432, pid, secret_key)?;

Race semantics match Postgres: the server may complete the query before the cancel arrives, in which case the cancel is silently ignored. Don't treat cancel as guaranteed delivery.

LISTEN / NOTIFY​

// Subscribe.
conn.listen(&"orders_channel".into())?;

// Publish (single-quote-escapes the payload per SQL standard).
conn.notify(&"orders_channel".into(), &"order_id=42".into())?;

// Drain all pending notifications.
let pending = conn.take_notifications();
for n in pending.iter() {
let _channel = &n.channel;
let _payload = &n.payload;
let _sender_pid = n.process_id;
}

// Block until at least one arrives.
let batch = conn.wait_for_notification()?;

Implementation: read_message auto-demuxes BmNotification(...) frames into conn.notifications whenever the server pushes one between query rounds. Higher-level callers see only synchronous response streams; notifications accumulate transparently.

COPY FROM / TO — bulk-streaming​

5–10× faster than even pipelined INSERT for bulk ingest because the server skips per-row Parse+Bind round-trips:

mount core.database.postgres.copy.{copy_in, copy_out};

// Bulk ingest — one row per List<Text> entry, tab-separated.
let n_rows = copy_in(&"events".into())
.with_columns(["ts".into(), "user_id".into(), "kind".into()])
.run(&mut conn, &rows)?;

// Bulk export.
let chunks = copy_out(&"users".into())
.with_columns(["id".into(), "email".into()])
.run(&mut conn)?;

Driver methods on PgConnection (lower-level):

  • copy_in_bytes(sql, rows: &List<List<Byte>>) -> Result<Int, DbError>
  • copy_in_text_lines(sql, rows: &List<Text>) -> Result<Int, DbError> — auto-newline-terminates
  • copy_fail_in(reason: &Text) -> Result<(), DbError> — abort mid-stream
  • copy_out_bytes(sql) -> Result<List<List<Byte>>, DbError>

SCRAM-SHA-256-PLUS authentication​

core.database.postgres.auth.scram — pure-Verum SCRAM-SHA-256 client. Channel-binding (-PLUS) is preferred when a TLS layer is present. md5, password, trust flows are compile-time-rejected unless @allow_legacy_auth is opted in.

Extended protocol​

core.database.postgres.extended covers Parse / Bind / Describe / Execute / Sync. Per-connection LRU prepared-statement cache (size default 256) sits in core.database.postgres.stmt_cache so repeated queries with the same fingerprint = hash(normalised_sql_ast) skip the Parse round-trip.

Wire-protocol surface​

Frame builders + parsers live under core.database.postgres.wire:

ModuleSurface
wire.frameFrame { tag: Byte, payload: List<Byte> }; length-prefixed framing
wire.frontendstartup, simple_query, parse, bind, execute, sync, flush, terminate, cancel_request, copy_data, copy_done, copy_fail, SCRAM helpers
wire.backendparse_backend(frame, formats) → BackendMessage; BmAuth / BmReadyForQuery / BmRowDescription / BmDataRow / BmCommandComplete / BmError / BmNotice / BmNotification / BmCopyInResponse / BmCopyOutResponse / BmCopyData / BmCopyDone / BmParseComplete / BmBindComplete / BmCloseComplete / etc.
wire.typesBE_* / FE_* byte constants, TX_* status bytes, AUTH_* SASL sub-codes

All decoded &arena references live in connection-arena; recv_buf / last_formats reset at connection drop.

Binary type codecs​

core.database.postgres.codec carries the wire-level binary codec for every PostgreSQL built-in scalar plus arrays plus composites. The codec splits the encode and decode paths so parameter binding (Verum value → PG bytes) and row decoding (PG bytes → Verum value) can evolve independently per type.

The dispatch table supports_binary returns true for every type the binary path can carry; types not in the table fall back to the textual FmtText representation so the connection never gets stuck on an exotic OID.

Coverage matrix​

PG typeDecodeEncodeNotes
BOOL / BYTEA✓✓Pass-through.
INT2 / INT4 / INT8✓✓Network-byte-order.
FLOAT4 / FLOAT8✓✓IEEE 754.
TEXT / VARCHAR✓✓UTF-8.
UUID✓✓16 bytes.
DATE / TIME / TIMESTAMP[TZ]✓✓µs-precision Postgres epoch.
INTERVAL✓✓(months, days, µs) triple.
JSON / JSONB✓✓JSONB version byte preserved.
INET / CIDR✓✓Family-byte + prefix.
MACADDR / MACADDR8✓✓6 / 8 bytes.
BIT / VARBIT✓✓Length-prefixed.
NUMERIC✓✓ V0/V1V0 integer fast-path; V1 text → binary via Decimal.
tsvector✓—Decode-only V0; encode via to_tsvector server-side.
composite / record✓✓(type_oid, ArenaSlice) per field; auto-encode from Verum record values is V1.
array<T> for every T above✓✓Header + per-element dispatch.
range<T>✓✓(lower, upper, flags).

The wire codecs cover PostgreSQL built-in scalar, array and composite types, including NUMERIC and tsvector. Parameter binding uses these codecs without falling back to FmtText for built-in types.

NUMERIC parameter binding​

Three encoders cover the full surface:

// Integer fast-path — no Decimal allocation.
public fn encode_numeric_from_int(
buf: &mut WireBuf,
arena: &Arena,
n: Int,
) -> Bool;

// V1 — non-integer NUMERIC values.
public fn encode_numeric_from_decimal(
buf: &mut WireBuf,
arena: &Arena,
d: &Decimal,
) -> Bool;

// Convenience: Text → Decimal → encode.
public fn encode_numeric_from_text(
buf: &mut WireBuf,
arena: &Arena,
s: &Text,
) -> Bool;

The decimal path bridges through core.text.numeric.decimal, so non-integer binding is full-fidelity (no rounding) for any value within the V0 i64-coefficient range.

tsvector decode​

public type PgTsVector is { lexemes: List<PgTsLexeme> };
public type PgTsLexeme is { text: ArenaSlice, positions: List<PgTsPosition> };
public type PgTsPosition is { position: Int, weight: Int };

public fn decode_tsvector(slice: &ArenaSlice) -> Result<PgTsVector, DbError>;

Constants PG_TS_WEIGHT_{D,C,B,A} (0=D, 1=C, 2=B, 3=A) match the PG source. Encode is a V1 follow-up — most callers ship Text and let PG run to_tsvector server-side.

composite codec​

public type PgComposite is { fields: List<PgCompositeField> };
public type PgCompositeField is { type_oid: Int, value: Maybe<ArenaSlice> };

public fn decode_composite(
slice: &ArenaSlice,
) -> Result<PgComposite, DbError>;

public fn encode_composite(
buf: &mut WireBuf,
arena: &Arena,
fields: &List<PgCompositeField>,
) -> Bool;

Decoded fields surface as (type_oid, ArenaSlice) so callers dispatch via the existing per-OID decoder table sql# uses for top-level columns. Auto-encoding from a Verum record value (deriving type_oid and per-field encoder) is V1 and depends on the row-resolver.

Error model​

Every wire-decode failure becomes BackendDecodeError; every high-level operation surfaces a unified core.database.common.error.DbError. See core.database.postgres.error.dberr_from_pg for the server-side ErrorResponse → DbError translation (preserves SQLSTATE class

  • severity + message).

Typed row façade — core.database.postgres.row.Row​

Row is a column-name-keyed view over the wire-level (RowDescription, List<WireValue>) pair returned by PgConnection.simple_query / execute_prepared_typed. Eight typed getters cover the registered postgres scalar / array families with per-OID guards, NULL discipline, and TEXT + BINARY wire-format coverage on every path.

mount core.database.postgres.row.PgRow;

let guard = pool.acquire().await?;
let row_result = guard.query_one(
&"SELECT id, name, active, created_at FROM users WHERE id = $1".into(),
[Some(TvInt4(42))],
).await;
guard.release().await;
let row: PgRow = row_result?;

let id: Int = row.get_int("id")?;
let name: Text = row.get_text("name")?;
let active: Bool = row.get_bool("active")?;
let nickname: Maybe<Text> = row.get_text_opt("nickname")?; // nullable column
let created_at: Rfc3339Time = row.get_timestamp("created_at")?;

Getter coverage:

MethodOID familyWire formatsNULL handling
get_text / get_text_optTEXT, VARCHAR, BPCHARUTF-8 (TEXT + BINARY converge)_opt returns Ok(None)
get_boolBOOLt/f (TEXT) or 0x00/0x01 (BINARY)error
get_int / get_int_optINT2, INT4, INT8parse_int (TEXT) or sign-extending big-endian (BINARY)_opt returns Ok(None)
get_bytesBYTEA\x<hex> per bytea_output=hex (TEXT) or raw (BINARY)error
get_timestamp / get_timestamp_optTIMESTAMP, TIMESTAMPTZpostgres YYYY-MM-DD HH:MM:SS+00 → RFC 3339_opt returns Ok(None)
get_text_array_text (1009), _varchar (1015), _bpchar (1014){a,b,"c"} literal (TEXT) or length-prefix (BINARY)NULL elements collapse to ""

Plus introspection: column_count(), column_index(name) -> Maybe<Int>, is_null(name) -> Bool, Row.new(description, values) for adapter wiring. Errors via DbError.DecodeFailed { fault: DecodeFault { column_index, column_name, expected_type, found_oid, detail } } for type-family mismatches; DbError.Adapter("PG_ROW_COL_NOT_FOUND") for unknown column names.

NULL discipline. get_X errors when the column is SQL-NULL; get_X_opt returns Ok(None). Callers that query nullable columns MUST use the _opt form — using get_text on a NULL column is a programmer error.

Parameterised query API — AsyncPgPoolGuard​

Four methods on AsyncPgPoolGuard prepare and execute a statement, then attempt close_prepared after execution succeeds; an execution error can bypass that close attempt. Query results use the PgRow façade. Keep the guard until the operation completes, capture its result, then explicitly attempt release before propagating an ordinary query error. release().await returns no delivery acknowledgement or health verdict; panic and cancellation cleanup remain subject to the pool limitations.

let pool: AsyncPgPool = ...;
let conn = pool.acquire().await?;

// Capture the result so an ordinary query error does not skip release.
let query_result = conn.query(
&"SELECT * FROM users WHERE active = $1".into(),
[Some(TvBool(true))],
).await;
conn.release().await;
let rows: List<PgRow> = query_result?;
MethodSuccessful result
queryAll rows as List<PgRow>.
query_oneThe first PgRow; reports SQLSTATE 02000 when no row is available.
query_one_optThe first row as Maybe<PgRow>, with None when no row or row description is available.
execute_with_paramsThe command tag as Text, such as UPDATE 3.

Plus four parameter-less delegations forwarding to the inner AsyncPgConnection: execute(sql), simple_query(sql), ping(), prepare(sql).

Param encoding. Caller hands a List<Maybe<TypedValue>> — typically constructed via the Tv* constructors from core.database.postgres.wire.typed. None encodes as SQL NULL; Some(value) runs through the per-OID binary encoder and ends up in the Bind frame's parameter section.

Result projection. Every cell from execute_prepared_typed's List<List<TypedValue>> is mapped to a TEXT-format WireValue via the typed_row_to_wire_values helper, so the Row façade's TEXT-decode path handles both simple-query and prepared-statement-typed result sets uniformly.

What's NOT in this module (yet)​

  • TLS verify-full — TLS handshake works (PtmDisable mode), but verify-full mode currently rejects with a "TLS_PENDING" adapter error; depends on core.net.tls channel-binding wiring.
  • Logical replication consumer (core.database.postgres.replication) — pgoutput-format CDC stream. Spec §6.1.8.
  • Connection-pool resilience patterns (auto-reconnect + retry).
  • pgvector typed codec.

See also: stdlib → database for the cross-vendor surface, and stdlib → database (mysql) for the sister implementation.