Skip to content
Postgres 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 so that the CLI and your application use the same extension requirements:

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

Update the Rust manifest and rent-extension.json together when requirements change. Run the generated validation test for your pack:

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

Validation reports invalid names, versions, schemas, duplicate contributions, dependency cycles, and mismatches 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()
    .filter_raw(compare(
        rank(
            &search,
            column(post::Post::search_document.name()),
            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.

An application-owned Slug type 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?;

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.