Files
caravel/.agents/skills/backend/references/data-layer-and-schema.md
T
Jon Staab 8c44d8cc0f
Docker / build-and-push-image (push) Successful in 51m48s
Add backend skill
2026-06-02 15:11:19 -07:00

7.4 KiB

Data layer and schema (deep detail)

This is the lookup-depth companion to the SKILL.md "data layer" section: read-helper assembly, transaction-helper conventions, the idempotency idioms in full, the Snapshot enum, the i64/timestamp modeling, the strict-< historical reads, and the schema/migration rules. All of it lives in query.rs, command.rs, models.rs, db.rs, and migrations/0001_init.sql.

Read assembly

Reads use query_as::<_, T> with T deriving sqlx::FromRow, returning typed structs (Tenant, Relay, Activity, Invoice, InvoiceItem, Bolt11). The SELECT * body is built by per-table select_tenant/select_relay/select_activity string helpers that append a trailing WHERE/ORDER clause; one-off reads inline the SQL but still go through query_as. Tenant-scoped reads take a tenant_pubkey param and filter on tenant_pubkey, but the _for_tenant suffix is not a reliable marker of tenant scoping: only two reads carry it (list_relays_for_tenant, list_invoices_for_tenant), while list_open_invoices, list_unbilled_invoice_items, and list_billable_activity are tenant-scoped without the suffix. (Note also that list_plans/get_plan are synchronous, not async.) Source: query.rs:6-16,58-262.

Transaction conventions

with_tx is the only primitive: it begins a tx on the pool, runs the async closure with a &mut Transaction, commits on Ok, and relies on Transaction's Drop to roll back on Err — there is no explicit rollback() call, so a closure that swallows an error and returns Ok will commit a partial write. Source: db.rs:60-68.

The *_tx helpers are private, suffixed _tx, take &mut Transaction as their first param, and run via .execute(&mut **tx). Public commands compose them inside one with_tx closure and never take a transaction. A *_tx that records a state change returns the constructed Activity, and the public command publishes it after commit. Some single-statement writes run directly on pool() with no transaction at all: create_tenant, update_tenant, the set_tenant_* setters, mark_invoice_notified, and insert_bolt11. Source: command.rs:14-82,146-183,466-704, db.rs:60-68.

The idempotency idioms in full

  • mark_activity_billed_tx runs UPDATE ... WHERE id = ? AND billed_at IS NULL and returns rows_affected() > 0 — a bool you must honor, because ignoring a false and inserting the invoice item anyway hits the UNIQUE(activity_id) index and fails.
  • insert_invoice_item_for_activity claims the activity first and only inserts the line item when the claim won, so a concurrent reconcile never double-bills.
  • Conditional monotonic UPDATEs are guarded on null markers: mark_invoice_paid_tx, mark_bolt11_settled_tx, and void_open_invoices_tx.
  • insert_intent_tx records the Stripe PaymentIntent with INSERT ... ON CONFLICT(id) DO NOTHING, making settlement idempotent on retried webhooks.
  • Renewal re-reads renewed_at inside the tx and advances it, so it is idempotent per period.
  • UNIQUE(invoice_item.activity_id) is the database backstop behind the claim.
  • insert_activity_tx requires the relay row to already exist in the same tx — it fetches the relay's tenant_pubkey — so insert/update the relay before logging.

Source: command.rs:279-335,563-704, migrations/0001_init.sql:111-112.

The Snapshot type

Snapshot is a serde-tagged enum keyed on resource_type, with one variant per resource that logs activity, wrapped in sqlx::types::Json on insert. Each Activity carries a JSON snapshot of the resource's plan+status. Add a variant (and its resource_type()) when a new resource type starts logging activity. Source: models.rs:8-24, command.rs:174-178.

Timestamp-vs-enum modeling and the i64 convention

Lifecycle is nullable timestamp markers, not status enums: an invoice is open while paid_at and voided_at are both null, a tenant is churned once churned_at is set, a bolt11 is settled once settled_at is set; invoice.method records provenance only when paid. Filter on the timestamps. Relay status is the exception — a free-form TEXT column with no CHECK constraint and no Rust enum, guarded only by the RELAY_STATUS_* consts (by contrast invoice.method does have a CHECK), so a typo'd status string would persist silently. Boolean-ish columns are i64 0/1, not Rust boollist_relays_pending_sync uses synced = 0 OR TRIM(sync_error) != ''. Source: models.rs:54-94,120-142, query.rs:81-121,206-240, migrations/0001_init.sql:32,48-60.

Strict-< historical lookups

get_relay_plan_before / get_latest_relay_activity_before use created_at < before (strict <, not <=) when reconstructing historical relay state from the activity log. A relay created exactly at a period boundary is intentionally not counted active in the prior period (its own creation/change charge covers that period); using <= would double-charge the creation period. list_billable_activity does not use a timestamp boundary at all — it has no before param and selects a tenant's unbilled activity via the billed_at IS NULL marker (plus an activity_type filter), reconciling off a precise marker rather than a timestamp watermark. Source: query.rs:108-121,206-218,225-240.

Schema and migration rules

The whole schema lives in a single migration, 0001_init.sql. Pre-release, schema changes are squashed into that file rather than appended as new files; migrations become append-only only after release. db::init runs create-if-missing plus WAL plus ./migrations. Relative sqlite:// DATABASE_URL paths are rewritten against CARGO_MANIFEST_DIR (the compile-time backend crate dir), not the process cwd. FK columns are named {model}_{pk}. Plans are hardcoded in-memory and synchronous — list_plans/get_plan are not a DB table, so a new plan is a code edit, not a migration. Source: db.rs:70-108, migrations/0001_init.sql:1-115, query.rs:20-54, root AGENTS.md:20-22.

A few "Ok with no write" cases

create_invoice and insert_invoice_items_for_renewal can legitimately return Ok with no write: a non-positive outstanding balance returns Ok(None) (credit carries forward) and empty renewal items returns early Ok(()). Don't treat a missing invoice as an error. insert_bolt11 uses INSERT ... RETURNING * with fetch_optional and returns Option<Bolt11>, so handle the Option rather than unwrapping. set_relay_status_tx (and update_relay) always reset synced = 0 as a side effect of any status/field change, re-queuing the relay for the infra reactor. Source: command.rs:185-223,300-335,443-464.

Sources

  • read assembly + _for_tenant suffix — backend/src/query.rs:6-16,58-262
  • transaction conventions — backend/src/command.rs:14-82,146-183,466-704, backend/src/db.rs:60-68
  • idempotency idioms — backend/src/command.rs:279-335,563-704, backend/migrations/0001_init.sql:111-112
  • Snapshotbackend/src/models.rs:8-24, backend/src/command.rs:174-178
  • timestamp/i64/status modeling — backend/src/models.rs:54-94,120-142, backend/src/query.rs:81-121, backend/migrations/0001_init.sql:32,48-60
  • strict-< historical reads (and the marker-based list_billable_activity) — backend/src/query.rs:108-121,206-218,225-240
  • schema/migration rules — backend/src/db.rs:70-108, backend/migrations/0001_init.sql:1-115, backend/src/query.rs:20-54, root AGENTS.md:20-22
  • Ok-with-no-write + side effects — backend/src/command.rs:185-223,300-335,443-464