atrg Migration Guide
This document provides detailed migration instructions for upgrading between major versions of at-rust-go (atrg). It is designed to be consumed by both humans and AI agents (LLMs) assisting with code migration.
For the machine-readable API surface, see also: llms.txt and llms-full.txt
---
Migrating from v0.1.x to v0.2.0
Release date: 2026-05-12
Summary of breaking changes
- AppState has a new required field:
extensions run_user_migrationsis deprecated; replaced byrun_isolated_migrationsAppConfighas a new field:admin_didsStreamConfighas a new field:cursorAuthSourceenum has a new variant:ApiKey- Internal migrations now use
_atrg_migrationstracking table instead of_sqlx_migrations NoneorSome("live".into())— start from the current time (v0.1 behavior, default)Some("auto".into())— resume from the last stored cursor positionSome("1234567890".into())— start from a specific timestamp in microsecondscrates/my-app-server/— write server (OAuth, XRPC, blobs)crates/my-app-aggregator/— firehose subscriber (feeds, search)crates/my-app-shared/— shared generated typesatrg-blob— content-addressed blob storage (S3 + filesystem)atrg-email— SMTP email delivery + OTP verificationrust-s3(optional, behindatrg-blob/s3feature) — S3 clientlettre(inatrg-email) — SMTP clientasync-trait(inatrg-blob) — async trait supportsha2(inatrg-blob,atrg-auth) — SHA-256 hashing- [ ] Update all
atrg-*dependencies in Cargo.toml to"0.2"(resolves to latest 0.2.x) - [ ] Add
extensions: Arc::new(Extensions::new())to any directAppStateconstruction (usually only in tests) - [ ] Add
admin_dids: vec![]to any directAppConfigconstruction (usually only in tests) - [ ] Add
cursor: Noneto any directStreamConfigconstruction - [ ] Replace
run_user_migrationscalls withrun_isolated_migrations(add tracking table name as third arg) - [ ] Add
AuthSource::ApiKeyarm if you match exhaustively onAuthSource - [ ] Run
cargo build— fix any remaining compilation errors (the compiler will tell you exactly what's missing) - [ ] Run your test suite
- [ ] Optionally:
DROP TABLE IF EXISTS _sqlx_migrations;from your database after verifying the upgrade - Keep your custom implementation — the framework's
RequireAuthextractor detects API keys by prefix pattern (contains('_')) and delegates toatrg_auth::api_keys::find_by_key. If your table schema differs, keep your ownfind_api_keyand middleware. - Migrate your table — alter columns to match atrg's schema (BIGINT timestamps, hex-encoded keys with
sha256-prefix hashes), then switch toatrg_auth::api_keys. key_hash:sha256-{hex}(SHA-256 of the full key, hex-encoded with prefix)expires_at,created_at,last_used_at: Unix epoch BIGINT (not RFC3339 TEXT)scopes: comma-separated TEXT (not JSON array)- Key format:
{prefix}{64 hex chars}(not base64url)
Change 1: AppState gains extensions field
BEFORE (v0.1.x):
use atrg_core::AppState;
use std::sync::Arc;
let state = AppState {
config: Arc::new(config),
db: pool,
http: reqwest::Client::new(),
identity: Arc::new(resolver),
};
AFTER (v0.2.0):
use atrg_core::{AppState, Extensions};
use std::sync::Arc;
let state = AppState {
config: Arc::new(config),
db: pool,
http: reqwest::Client::new(),
identity: Arc::new(resolver),
extensions: Arc::new(Extensions::new()), // ADD THIS LINE
};
If you construct AppState directly (typically only in tests — production code uses AtrgApp::run() which handles this automatically), add extensions: Arc::new(Extensions::new()) to the struct literal.
Search pattern to find affected code:
grep -rn "AppState {" --include="*.rs" | grep -v "extensions"
The Extensions type is re-exported from atrg_core::Extensions.
To store app-specific state (replacing once_cell or other global state patterns):
// In main.rs / startup:
struct MyAppState { db: PgPool, blobs: S3Client }
AtrgApp::new()
.with_extension(MyAppState { db, blobs })
.mount(routes())
.run()
.await
// In handlers:
async fn my_handler(State(state): State<AppState>) -> impl IntoResponse {
let app = state.extension::<MyAppState>();
// use app.db, app.blobs, etc.
}
Change 2: run_user_migrations deprecated
BEFORE (v0.1.x):
atrg_db::run_user_migrations(&pool, Path::new("./migrations")).await?;
AFTER (v0.2.0):
atrg_db::run_isolated_migrations(&pool, Path::new("./migrations"), "_app_migrations").await?;
The third argument is the tracking table name. Framework migrations now use _atrg_migrations and app migrations should use a different name (e.g. _app_migrations) to avoid conflicts.
If you have multiple binaries sharing a database (e.g. write server + aggregator), use different tracking table names:
// In write server:
atrg_db::run_isolated_migrations(&pool, Path::new("./server_migrations"), "_server_migrations").await?;
// In aggregator:
atrg_db::run_isolated_migrations(&pool, Path::new("./aggregator_migrations"), "_aggregator_migrations").await?;
The old run_user_migrations still works but emits a deprecation warning and will be removed in v0.3.0.
NOTE: If you relied on the default _sqlx_migrations tracking table for your app's migrations, they will NOT be automatically migrated to the new table name. The old migrations remain applied in the database — the new tracking table starts empty and will re-apply migrations. If your migrations are idempotent (use CREATE TABLE IF NOT EXISTS, INSERT ... ON CONFLICT DO NOTHING, etc.), this is safe. If not, you need to manually copy rows from _sqlx_migrations to your new tracking table before upgrading:
INSERT INTO _app_migrations (version, description, checksum, applied_at)
SELECT version, description, checksum, installed_on FROM _sqlx_migrations
WHERE version NOT IN (SELECT version FROM _app_migrations);
Change 3: AppConfig gains admin_dids field
BEFORE (v0.1.x):
AppConfig {
name: "my-app".into(),
host: "127.0.0.1".into(),
port: 3000,
secret_key: "...".into(),
cors_origins: vec![],
environment: "development".into(),
}
AFTER (v0.2.0):
AppConfig {
name: "my-app".into(),
host: "127.0.0.1".into(),
port: 3000,
secret_key: "...".into(),
cors_origins: vec![],
environment: "development".into(),
admin_dids: vec![], // ADD THIS LINE
}
This field defaults to empty via #[serde(default)] when loading from atrg.toml, so no config file changes are needed. Only affects code that constructs AppConfig struct literals directly (typically test code).
Search pattern:
grep -rn "AppConfig {" --include="*.rs" | grep -v "admin_dids"
To use admin bootstrapping, add to atrg.toml:
[app]
admin_dids = ["did:plc:your-admin-did"]
Or set the environment variable:
ATRG_APP__ADMIN_DIDS=did:plc:abc123,did:plc:def456
Change 4: StreamConfig gains cursor field
BEFORE (v0.1.x):
let stream_config = atrg_stream::StreamConfig {
host: "jetstream1.us-east.bsky.network".into(),
collections: vec!["app.bsky.feed.post".into()],
zstd_dict: None,
channel_capacity: 1024,
max_lag_events: 10_000,
};
AFTER (v0.2.0):
let stream_config = atrg_stream::StreamConfig {
host: "jetstream1.us-east.bsky.network".into(),
collections: vec!["app.bsky.feed.post".into()],
zstd_dict: None,
channel_capacity: 1024,
max_lag_events: 10_000,
cursor: None, // ADD THIS LINE — None means "start from live"
};
Only affects code that constructs StreamConfig directly. If you use AtrgApp::on_event() (the typical path), this is handled automatically.
Cursor options:
Change 5: AuthSource enum gains ApiKey variant
BEFORE (v0.1.x):
match session.source {
AuthSource::Atrg => { /* atrg session cookie/bearer */ }
AuthSource::AtprotoJwt => { /* PDS-issued JWT */ }
}
AFTER (v0.2.0):
match session.source {
AuthSource::Atrg => { /* atrg session cookie/bearer */ }
AuthSource::AtprotoJwt => { /* PDS-issued JWT */ }
AuthSource::ApiKey => { /* API key (programmatic access) */ } // ADD THIS ARM
}
If you match exhaustively on AuthSource, add the ApiKey variant. If you use a wildcard (_) catch-all, no change needed.
API keys are bearer tokens with a configurable prefix (default: atrg_). The RequireAuth extractor handles them transparently — no handler changes needed unless you inspect session.source.
Change 6: Internal migration tracking table
The framework's internal migrations (atrg_sessions, atrg_oauth_states) previously used the default _sqlx_migrations table. They now use _atrg_migrations.
Impact: On first startup after upgrading, the framework will re-run its internal migrations against the new tracking table. Since all internal migrations use CREATE TABLE IF NOT EXISTS and CREATE INDEX IF NOT EXISTS, this is safe and idempotent.
The old _sqlx_migrations table will remain in your database but is no longer used. You can safely drop it after verifying the upgrade:
DROP TABLE IF EXISTS _sqlx_migrations;
New features available after upgrading (no migration required)
These are additive features. No code changes needed to use the existing v0.1 functionality — but you can opt in to these new capabilities:
#### API Key Authentication
// Create an API key
let (full_key, api_key) = atrg_auth::api_keys::create_api_key(
&pool, "did:plc:user", "My Key", &["admin:*".into()], "atrg_"
).await?;
// Clients authenticate with: Authorization: Bearer atrg_xxxxxxxxxxxx
// RequireAuth extractor handles it automatically — no handler changes needed.
#### RBAC (Role-Based Access Control)
// Grant a role
atrg_auth::rbac::grant_role(&pool, "did:plc:user", "admin", None, None, None).await?;
// Check a role
let is_admin = atrg_auth::rbac::has_role(&pool, "did:plc:user", "admin", None).await?;
// Ban a DID (with optional TTL)
atrg_auth::rbac::ban_did(&pool, "did:plc:bad", Some("spam"), Some(86400), "did:plc:admin").await?;
// NOTE: RBAC tables (atrg_roles, atrg_bans) are NOT auto-created by the framework.
// You must create them yourself using the DDL constants:
// atrg_auth::rbac::CREATE_ROLES_TABLE_SQLITE (or _POSTGRES)
// atrg_auth::rbac::CREATE_BANS_TABLE_SQLITE (or _POSTGRES)
#### Blob Storage
# Add to Cargo.toml:
atrg-blob = "0.2"
use atrg_blob::{FileBlobStore, BlobStore};
let store = FileBlobStore::new("./blobs").await?;
let cid = store.put(b"hello world").await?;
let data = store.get(&cid).await?;
#### Event Router
use atrg_stream::EventRouterBuilder;
let router = EventRouterBuilder::new()
.on_create("app.bsky.feed.post", |event, state| Box::pin(async move {
println!("new post from {}: {}", event.did, event.rkey);
Ok(())
}))
.build();
AtrgApp::new()
.on_event(router)
.run()
.await
#### Cross-Origin Auth (SPA support)
# atrg.toml
[auth]
post_login_redirect = "https://my-spa.example.com/login"
After OAuth, the user is redirected to:
https://my-spa.example.com/login?token=SESSION_ID&did=did:plc:xxx&handle=user.bsky.social
The SPA stores the token and sends it as Authorization: Bearer SESSION_ID.
#### App-Specific Config Sections
# atrg.toml
[myapp]
database_url = "postgres://..."
admin_email = "admin@example.com"
[myapp.s3]
bucket = "my-blobs"
#[derive(serde::Deserialize)]
struct MyConfig {
database_url: String,
admin_email: String,
s3: S3Config,
}
let config: MyConfig = atrg_core::config::load_app_config("myapp")?;
#### Email / OTP
# Add to Cargo.toml:
atrg-email = "0.2"
// Send OTP (logs to stdout if SMTP not configured)
atrg_email::send_otp(&pool, None, "did:plc:user", "user@example.com").await?;
// Verify OTP
let valid = atrg_email::verify_otp(&pool, "did:plc:user", "user@example.com", "123456").await?;
#### Multi-Binary Template
atrg new my-app --template multi-binary
Generates a workspace with:
Dependency changes
New workspace crates in v0.2.0:
New external dependencies:
No external dependencies were removed or had breaking version bumps.
Complete upgrade checklist
Errata
#### v0.2.0 → v0.2.1
atrg-email, atrg-auth, atrg-stream, and atrg-core had non-exhaustive match pool { } blocks when only one database feature (sqlite or postgres) was active while DbPool still contained both variants. This caused error[E0004] at compile time.
Fixed in v0.2.1 by adding #[allow(unreachable_patterns)] _ => ... wildcard arms to all 23 match sites across 6 files. If you hit this on v0.2.0, upgrade to "0.2.1" or later.
Known integration patterns (not framework bugs)
These are patterns discovered during changala.app's migration to atrg 0.2.x. They are expected behaviors, not bugs.
#### Domain-specific RBAC stays in the app
atrg_auth::rbac provides generic building blocks: has_role, grant_role, revoke_role, ban_did, lift_ban. But domain-specific authorization logic that combines role checks with business queries (e.g. "is this user the class rep for this specific course" or "is this user enrolled in this course") must remain in the application. The framework's RBAC is the foundation — the app composes it.
Pattern:
// App-level helper that uses framework RBAC as a building block
async fn require_class_rep_or_admin(db: &PgPool, did: &str, course_id: i64) -> Result<(), XrpcError> {
// Check framework-level admin role first
if atrg_auth::rbac::has_role(pool, did, "admin", None).await? {
return Ok(());
}
// Fall back to domain-specific check
let is_rep = sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*) FROM courses WHERE id = $1 AND class_rep_did = $2"
).bind(course_id).bind(did).fetch_one(db).await?;
if is_rep > 0 { Ok(()) } else { Err(XrpcError::forbidden("not authorized")) }
}
#### API key table schema differences
If your app already has an api_keys table with a different schema (e.g. TEXT timestamps instead of BIGINT, base64-encoded keys instead of hex), you have two options:
atrg's API key schema uses: