Skip to main content

Common Pitfalls

The mistakes that come up most often when building with Forge.

Generated code

Never edit frontend/src/lib/forge/ (Svelte) or frontend/src/forge/ (Dioxus). forge generate overwrites those files. Fix the Rust source instead.

Run forge generate after every backend change. Skipping it causes runtime deserialization errors that can be hard to trace.

#[forge::model] must come before #[derive(...)]:

// Wrong — derive expands before model sees the struct
#[derive(Debug, Clone)]
#[forge::model]
pub struct Item { ... }

// Right
#[forge::model]
#[derive(Debug, Clone)]
pub struct Item { ... }

Macros and registration

Handler functions must be pub async fn. Private functions fail codegen silently.

Don't include the handler kind in the function name. heartbeat, not heartbeat_daemon — the macro appends the suffix, producing HeartbeatDaemonDaemon.

Register every handler in main.rs via .register_*::<NameType>() or .auto_register(). The macro generates the inventory entry, but doesn't wire it in.

New handler files need a pub mod line. When you add a file under src/functions/, append pub mod <name>; to src/functions/mod.rs. forge new <kind> <name> does this for you.

public and unscoped are orthogonal. public skips authentication. unscoped skips the compile-time row-filter check (WHERE user_id = ...). A query can be authenticated but unscoped (admin dashboard), or public without being unscoped.

Database

Always use sqlx::query!() / query_as!(). Never use sqlx::query() or sqlx::query_as::<_, T>() — those skip compile-time checking.

Use ctx.db() for queries, ctx.conn() for mutations:

// Query handler — ForgeDb is a sqlx Executor
sqlx::query_as!(User, "SELECT ...", id).fetch_one(ctx.db()).await?

// Mutation handler — ForgeConn for transactional writes
let mut conn = ctx.conn().await?;
sqlx::query_as!(User, "SELECT ...", id).fetch_one(&mut conn).await?

Cast enums explicitly in SELECT:

SELECT role as "role: UserRole" FROM users WHERE id = $1

Enable reactivity in migrations, not in code:

SELECT forge_enable_reactivity('items');

Never hand-write triggers for this.

Avoid SELECT * in subscribed queries. Explicit column lists unlock row-level invalidation tracking. SELECT * forces table-level invalidation.

Workflows

Use ctx.sleep(), not tokio::sleep. Only ctx.sleep() persists across restarts.

Step names are cache keys — renaming breaks resume. If you need to change step names, bump the workflow version instead.

A signature mismatch at startup blocks runs and flips /_api/ready to 503. Check for in-flight runs before removing an old version. Drain first, then remove the code.

Always set a timeout on wait_for_event so stalled runs become observable rather than silently waiting forever.

Authentication

Drop ctx.conn() before calling issue_token_pair(). Token issuance needs its own connection. Holding an open connection can deadlock the pool.

JWT secret must be at least 32 bytes. Startup fails with a config error pointing at openssl rand -base64 32.

audience_required defaults to true when auth is enabled. Adding jwt_audience to an existing project breaks tokens that don't carry an aud claim. Set audience_required = false while migrating, then re-enable once all clients issue tokens with aud.

Reserve Forbidden for real permission violations. Using it for billing or plan-state rejections triggers the global auth error handler and logs the user out. Use InvalidArgument for business-rule rejections.

Frontend

Never call refetch() on an SSE-backed store. The stream pushes updates — calling refetch creates a second subscription.

Don't fetch inside $effect / use_effect. This causes race conditions and leaks. Use the subscription hooks instead.

Don't wrap runes helpers in toReactive. listTodos$() and similar helpers already manage their lifecycle via $effect roots. Wrapping them reintroduces the leaks the rune form eliminates.

Set export const ssr = false; in +layout.ts. SSE, EventSource, and localStorage are not available server-side.

Route mutation errors to a global handler (onMutationError in Svelte, on_mutation_error in Dioxus). Silent failures confuse users.

Error handling on the frontend

Match errors by code string, not by message text:

if (error.code === 'NOT_FOUND') { ... }
if (error.code === 'RATE_LIMITED') { ... }

Rate-limit retry delay is top-level, not under details:

// Wrong
error.details?.retry_after_secs

// Right
error.retryAfterSecs

Operations

SQLX_OFFLINE=true is required for raw cargo check / cargo build. Use eval "$(forge env)" in your shell rc to set this automatically. In CI, pass --no-prepare to forge check — the cache should already be correct.

Migrate before swapping traffic. /_api/ready returns migrations_ok=false when code ships before forge migrate up. Run migrations first, then route traffic to the new binary.

PostgreSQL < 18 is a startup hard-fail. Upgrade local Docker images and managed-DB engines before bumping the framework version.

Don't edit framework tables directly. Never INSERT / UPDATE into forge_jobs, forge_workflow_runs, or forge_signals_events by hand. Use ctx.dispatch_job(), ctx.start_workflow(), ctx.record_signal(). forge check fails the build on manual writes.

Default rate-limit mode is per-node. A 10/min limit with key = "user" becomes effectively 10 × node_count across the cluster. Set [rate_limit] mode = "strict" in forge.toml for cluster-exact counts when accuracy matters (billing quotas, etc.).

Custom routes

Routes mount under /_api. A route declared as /export/csv is served at /_api/export/csv. Document the full path — the prefix is a common off-by-one bug.

Never .unwrap() AuthContext in custom handlers. Unauthenticated requests still reach your handler. Use match auth.user_id() with an early 401 return. The workspace clippy::unwrap_used deny will catch this at compile time anyway.

Large integers across the wire

TypeScript represents every number as IEEE-754 double, so values above Number.MAX_SAFE_INTEGER (2⁵³ − 1, ~9e15) silently lose precision after JSON.parse. Forge codegen maps Rust i64 / u64 to TS number. For IDs or counters that genuinely need 64-bit precision, declare the field as String in Rust and convert at the boundary, or use serde_with::DisplayFromStr. Anything under i32 range (±2.1e9) is safe as-is.