rent
PostgreSQL extensions

Build a community extension pack

An extension pack has two jobs: describe what the PostgreSQL extension requires and expose safe Rust APIs for the operations applications use. Rent discovers community manifests through rent.toml, while application code registers the pack type explicitly.

Scaffold the pack

Run this from a Rent project:

rent extension init application_search

The command creates:

crates/rent-ext-application-search/
├── Cargo.toml
├── rent-extension.json
└── src/
    └── lib.rs

It also adds the manifest path to the project's rent.toml:

extension_manifests = [
  "crates/rent-ext-application-search/rent-extension.json",
]

That makes the pack available to rent extension check; there is no central registry to edit.

Describe server requirements

rent-extension.json is the CLI's declarative contract. It records the extension's version requirement, minimum PostgreSQL version, dependencies, installation schema, privileges, preload libraries, external binaries, provider enablement, supported upgrades, and contributed types, functions, operators, indexes, casts, methods, and owned database objects.

Start conservatively. If installation requires a preload library or superuser, declare it so preflight fails before a deployment reaches CREATE EXTENSION.

Implement the typed pack

The scaffold registers the pack with a minimal manifest. Extend it as the pack requires and keep rent-extension.json in step, because the generated conformance test asserts that the two manifests are equal:

use rent_extension::{ExtensionManifest, ExtensionPack};

pub struct ApplicationSearch;

impl ExtensionPack for ApplicationSearch {
    fn manifest() -> ExtensionManifest {
        let mut manifest = ExtensionManifest::new(
            "application_search",
            ">=1, <2",
        );
        manifest.minimum_postgres_major = 16;
        manifest
    }
}

Add domain methods using RegisteredPack::function, binary_operator, cast, and client. These helpers bind names from the pack while values remain normal SQL arguments.

use rent_extension::{Expression, RegisteredPack};

pub fn rank(
    pack: &RegisteredPack<ApplicationSearch>,
    document: Expression,
    query: Expression,
) -> Expression {
    pack.function("application_rank", [document, query])
}

Keep both contracts synchronized

The generated nextest compares the Rust manifest with rent-extension.json and runs Rent's pack conformance check. Update both representations when requirements change:

cargo nextest run --manifest-path crates/rent-ext-application-search/Cargo.toml

The test rejects invalid names, versions, schemas, duplicate contributions, dependency cycles, and drift between the CLI manifest and Rust extension-pack declarations.

Use the pack

Add the local crate to the application, then register it during client setup:

use rent::sql::ast::{Comparison, column, compare, value};

let mut registry = rent::extension::ExtensionRegistry::default();
let search = registry.register_pack::<ApplicationSearch>()?;

let ranked = client
    .post()
    .where_raw(compare(
        rank(
            &search,
            column(post::FIELD_SEARCH_DOCUMENT),
            search.value("rust databases"),
        ),
        Comparison::Gt,
        value(0.5_f64),
    ))
    .all()
    .await?;

Before migration, exercise the destination server:

rent extension check --provider self-hosted

Pack tests should include expression rendering, manifest validation, migration planning, and live behavior against every PostgreSQL distribution the pack claims to support.

Add an application-owned native field type

A pack can also supply a Rust value type for a native PostgreSQL column. For example, an application can keep URL slugs in citext: Rust validates the characters and length, while PostgreSQL provides case-insensitive equality and uniqueness. This is useful when Hello-Rust and hello-rust must identify the same published post.

The runnable community_codec example contains the complete Slug implementation. It implements PostgresValue to encode and decode PostgreSQL's binary and text formats, then uses rent::extension::postgres_value!(Slug) for the SQLx type/decoding bridge. Deserialization also validates the slug, so JSON input cannot bypass the constructor.

Register custom field codecs with the Rust schema API. RSL's built-in extension type names do not discover arbitrary Rust types automatically:

use rent_schema::{extension::ExtensionCodec, field};

let mut slug = ExtensionCodec::Custom.descriptor();
slug.extension = "citext".to_owned();
slug.sql_name = "citext".to_owned();
slug.rust_type = "crate::slug::Slug".to_owned();

let slug_field = field::string("slug")
    .extension_type(slug)
    .unique()
    .finish();

Generate a PostgreSQL-only client with Generator::backends([GenerationBackend::Postgres]). The application's codec module (or dependency crate) must exist at the declared Rust path. Rent does not add vector or spatial operators to an unrelated custom codec.

The generated API uses your value type:

use crate::slug::Slug;

let article = client
    .create_article()
    .id(1)
    .tenant_id(10)
    .slug(Slug::new("Hello-Rust")?)
    .save()
    .await?;

let same_article = client
    .article()
    .slug_eq(Slug::new("hello-rust")?)?
    .only()
    .await?;

From the Rent repository, run the complete example against a disposable local PostgreSQL instance that has citext available:

cargo run -p rent --example community_codec

Set DATABASE_URL in the process environment. The example creates and removes its own temporary database; the supplied role needs permission to create databases and install citext. It checks native equality, projections, updates, rollback, cross-tenant conflict rejection, and malformed data inserted by another writer. The extension matrix runs this same workflow through nextest.

A Rust constructor is not a database constraint. If every writer must obey your slug rules, add matching database CHECK constraints as well. A custom decoder should still reject malformed stored values without panicking or including their contents in an error message.

On this page