Add TimescaleDB persistence
Use this when: notes must survive a process restart.
Before you begin: a running development TimescaleDB instance and an understanding of the notes application.
You will add: a connection, a table, an owner-scoped repository, and migrations. The snippets are implementation pieces, not a drop-in replacement for the public memory store.
flowchart LR
input["Your service"] --> scope["Session / transaction"]
scope --> repo["Owner-scoped repository"]
repo --> table[("Notes table")]You have notes living in memory, and you want them to still be there after the
process restarts. This guide takes the shortest real path to that: one connection,
one entity, one owner-scoped repository, one migration. It uses the same crates as
the starter in examples/htmx-starter, so every API below is exercised by source
you can open.
The examples persist a note as id, owner, and title. That is a new persisted
shape, not a drop-in replacement for whatever your in-memory note type already is;
rename the field (title → body, and so on) to match your domain and adjust the
column and Validate bounds to suit.
One design fact shapes the rest. Repository<E, F> gives you mechanics — resource
identity checks, typed filters, bounded pagination, row mapping, unique-violation
translation, soft-delete filtering. It does not add a WHERE owner = … for
you. Owner isolation is application policy you write in the repository, with the
schema as a backstop. Remember that and you avoid the one bug that really hurts.
Snippet convention
Snippets are fragments, not complete crates. // fragment: lines say what is
assumed; names like NoteService are yours. The entity, repository, and connect
shapes were compiled in a throwaway crate against this checkout.
Prerequisites
- PostgreSQL with the TimescaleDB extension already provisioned. The framework
never runs
CREATE EXTENSION;connectfails ifextversionis absent. Required gate: stable TimescaleDB 2.x, minor ≥ 2.13 (pre-release versions are rejected). - Crates:
iet-core,iet-db-timescale,iet-db-macros, anduuidwith thev7feature. Addsqlx(postgres, a runtime,migrate,macros) only if you use the embedded migrator. Addiet-config+iet-config-envif you load settings through the typed loader. - Toolchain: the workspace declares
rust-version = "1.85". That is a declared bound; the repository has since been checked with rustc 1.98.1, not 1.85.
The minimal path
1. Set the connection URL and connect
One setting is required: DATABASE_URL. Everything else has a default defined in
crates/features/iet-db-timescale/src/config.rs (pool size, acquire/idle/lifetime,
statement and lock timeouts); open that file for the current values and bounds.
// fragment: reads APP_DATABASE_URL; the derive prefix is DATABASE, so
// EnvSource::snapshot("APP") sees pre-stripped keys.
use DatabaseConfig;
let config: DatabaseConfig =
new.load?;
let database = connect.await?;
connect is connect_with_health_check(config, true), so SQLx pings each pooled
checkout by default. connect_named(&config, "app_primary", true) additionally
sets a bounded telemetry identity if you run more than one handle. Statement and
lock timeouts are set per connection. If a connection cannot be validated, the
pool is closed before the error is returned.
2. Declare the entity
Persistent emits schema metadata; TimescaleEntity emits the field enum and the
row mapping the generic repository consumes.
// fragment: inside a private `entity` module; `NoteField` is generated by the
// `TimescaleEntity` derive and lives in the same module.
pub
#[field(operators = "…")]is an allowlist.ownerpermits equality (all owner scoping needs);titlepermits equality and aLIKE-backed substring search.Containsis only legal onString.idanddeleted_atare readable but not writable through generated entity operations, soid: 0is a fine placeholder in an insert.hard_deletemeans nodeleted_at. Without it,Persistentadds a nullabledeleted_atto the metadata and active reads filterdeleted_at IS NULL; your hand-written DDL must then include that column.- New entities should default to a non-null
uuid::Uuidid (UUID v7).Persistentinfers it as the primary key and generated inserts callUuid::now_v7(). The integer id above is for existing/external schemas, matching the starter.
3. Read and write it with owner isolation
Every repository method takes &mut CallScope<'_, TimescaleSession>. The scope
carries the trusted CallContext and the exclusive session borrow; there is no
ambient connection to reach for.
// fragment: `AppError`, `AppResult`, `ErrorKind`, `CallScope`, `ResourceId` from
// iet-core; `Repository`, `Filter`, `Operator`, `Value` from iet-db-timescale;
// `TimescaleSession`; `NoteEntity`, `NoteField` from the entity module.
use ;
use ;
/// `owner_id` is the authenticated principal's subject (`actor.subject`),
/// never a value read straight from a request. Invalid UUID text is an auth
/// failure, not "not found".
The main operations are page, find_by_id, insert_entity,
update_filtered, and delete_filtered, all checking the resource first.
page/list enforce 1 <= limit <= 100 and offset <= 200000. In real code
NoteEntity stays crate-private and these methods implement your repository
port rather than being pub inherent methods.
The trap: find_by_id and delete_by_id use the primary key only. If you
read with find_by_id and trust the result, you may have another owner's row.
Filter it in Rust (as the starter's task repository does) or use an owner filter:
// fragment: owner-scoped read.
let owner = owner?;
Ok
For mutations, prefer update_filtered / delete_filtered so the owner predicate
travels in the same statement. The schema is the second layer: the starter's
app_tasks uses a composite foreign key FOREIGN KEY(owner, project_id) REFERENCES app_projects(owner, id), so a row cannot point at another owner's
project even if a service bug tried.
4. Create the table with a paired migration
Apply the table before you run the repository from step 3, or its queries will
fail. A reviewed pair is the reliable path: Migrations::load requires
NNNN_name.up.sql and NNNN_name.down.sql (positive version, lowercase name,
both directions present, non-empty). iet-db-cli reads that directory and the
_sqlx_migrations ledger.
Point the CLI at a development database first. These are development-only loopback credentials, not a production pattern:
# development-only loopback credentials; keep real credentials out of source control
Scaffold the pair from the workspace root:
In this checkout new writes unpadded names: migrations/1_notes.up.sql and
migrations/1_notes.down.sql. Replace the template SQL with DDL that matches
NoteEntity:
-- migrations/1_notes.up.sql
(
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
owner UUID NOT NULL,
title TEXT NOT NULL CHECK (length(title) BETWEEN 1 AND 120)
);
ON app_notes(owner, id);
-- migrations/1_notes.down.sql
app_notes;
Then inspect and apply:
status prints one applied|pending <version> <description> line per migration,
for example pending 1 notes before apply and applied 1 notes after. apply is
guarded by --approved, and Migrations::status runs first and rejects checksum
drift, unknown versions, failed rows, and a missing earlier version.
rollback --target N --approved reverts down to (and excluding) version N;
--target 0 reverts everything. The loader parses any positive numeric prefix, so
hand-written 0001_notes.up.sql is also valid; new simply does not zero-pad.
The framework does not validate your schema at application startup. status
and verify inspect the migration ledger, not live table shape. If you want a
startup check (the starter has one), it is application code you write.
Once the pair is applied, you are ready to exercise step 5 against the real table. This guide has not run a live insert, so treat that as the next test to run rather than a demonstrated result.
Other migration styles (advanced, linked for reference). The starter instead
embeds SQLx migrators per implementation crate and assembles them in its
migrate command; see
examples/htmx-starter/crates/project-impl-timescale/src/lib.rs and
examples/htmx-starter/servers/web/src/database.rs. That path uses plain
NNNN_name.sql files and app-owned schema validation. Separately, you can derive
a pair from Persistent metadata with
iet_db_timescale::generation::EntityRegistry (register, then initial or
diff); it is a conservative planner (tables, nullable additions, declared
indexes) and rejects destructive or hypertable-dimension changes.
5. Run it through a scope and a transaction
// fragment: `actor: &Principal`, `self.database: TimescaleDatabase`.
use ;
let context = CallContext ;
// read (one bounded page: limit 20, offset 0)
let mut session = self.database.session;
let notes = self
.repository
.list_page
.await?;
// write
let mut session = self.database.session;
let mut transaction = enter.await?;
let result = self
.repository
.create
.await;
transaction.finish.await?;
CallScope::new borrows the session mutably, and every generated operation calls
scope.require_resource(&self.resource). Building the repository with resource A
and running it against a session for resource B gives a deterministic Internal
session.resource_mismatch instead of silently querying the wrong database.
TransactionScope::finish marks rollback-only on an inner error, commits only when
this call owns the transaction, and surfaces commit/rollback failures.
Expected outcomes and tests
- Missing extension →
Unavailabletimescale.extension_missing; old or pre-release version →timescale.version. insert_entitywithid: 0returns the assigned id (identity column orUuid::now_v7()), and unique violations becomeConflictentity.duplicate.page/listoutsidelimit 1..=100oroffset <= 200000return Validation; a type-mismatched operator, disallowed operator,Containson non-text, or text over 4096 bytes returnsquery.filter; filter nesting is capped (depth 8, 100 nodes).- A wrong
ResourceIdyieldsInternalsession.resource_mismatch. Migrations::statusdetects checksum drift, unknown versions, failed rows, and a missing earlier version. Down-file checksums are not independently recorded by SQLx, so do not claim down-drift verification.- The ignored live test
crates/features/iet-db-timescale/tests/repository.rs::ordinary_crud_works_without_tenant_context(needs a disposableFW_TEST_TIMESCALE_URL) verifies insert, rollback, duplicate rejection, soft-delete hiding, bounds, and resource mismatch. Never point it at production.
Compile without a database:
Common mistakes
- Expecting the repository to filter by owner. It does not. Guard reads that
use
find_by_id, and prefer*_filteredmutations. - Reaching for
database.pool()in a repository. Business repositories use the scoped session; the pool bypasses resource identity and shared transaction semantics and is for infrastructure and migrations. - Forgetting
hard_deleteor thedeleted_atcolumn. The metadata and your DDL must agree. - Passing a UUID that isn't v7. Identifier values are validated; non-v7/RFC values are rejected.
- Retrying a mutation after an error. Commit failures leave the session in an unknown state on purpose; surface the error.
- Mixing the pair loader with plain filenames.
Migrations::loadneeds.up.sql+.down.sql;sqlx::migrate!does not.
Advanced choices (after the basic path works)
- Soft delete. Drop
hard_deleteand adddeleted_at: Option<chrono::DateTime<chrono::Utc>>; active reads then exclude deleted rows. Use#[persistent(index(..., unique))]for partial uniqueness. - Connect tuning and telemetry.
connect_with_health_check(&config, false)skips the per-checkout ping (stale connections then surface as normal operation errors);connect_namedlabels the handle;database.record_pool_metrics()emitsfw_db_pool_connections/fw_db_pool_idlewhen telemetry is enabled. - Hypertables and richer metadata. Add
#[persistent(table = "…", time_column = "observed_at")](the column must be a non-nulltimestamptz, and primary keys/unique indexes must include it), and declareforeign_key/indexmetadata so generation can emit and validate them.
Limitations
- No implicit tenancy. A declared owner column is ordinary data; the repository predicate is the policy and a composite foreign key is the backstop.
- No automatic
CREATE EXTENSIONand no fallback to plain PostgreSQL. - SQLx manages per-step transactions and locking, not atomic whole-batch rollback; Timescale DDL must be authored for the engine.
- The derived diff covers tables, nullable additions, and declared indexes only; constraint drift and destructive changes need review.
- These crates do not provide generic multi-tenant predicates or composite-key
CRUD beyond
page/find_by_id.
Where to go next
- Protect a mutation that writes a note: Build an HTMX page.
- Let the user log in who owns those notes: Compose authentication.