§01|Overview

three guarantees. one binary.


Every satus generate run makes three promises to your database. They aren’t marketing copy—they map directly to three pieces of code in the CLI, and each one has a named exit code you can branch on in CI.

  1. The FK-cycle planner reorders inserts so every foreign key points at a row that already exists, even when your schema has cycles.
  2. The safety guard refuses to touch a database that already holds real user data, unless you explicitly opt out.
  3. The one-transaction guarantee means a failed run leaves your database byte-for-byte identical to how it started.

The rest of this page explains each one—what it does, why it exists, and where it stops.

§02|FK-cycle planner

cycles are normal. inserts still need an order.


Postgres lets you declare foreign keys in any topology, including cycles—users.primary_org_id → organizations.id and organizations.owner_id → users.id is the textbook example, but the pattern shows up anywhere a graph has bidirectional ownership (folders ↔ root_file, accounts ↔ default_card, threads ↔ latest_message). It compiles, it ships to production, and it quietly defeats every seed script that assumes a topological sort will work.

The planner runs in three phases:

  1. Introspect. Read pg_catalog to extract every table, column, FK, NOT NULL constraint, and DEFERRABLE status. No assumptions, no parsing of CREATE TABLE text—the source of truth is the live schema.
  2. Build the DAG, find the cycles. Treat tables as nodes and FKs as edges. A straightforward topological sort handles 100% of acyclic schemas. For the remainder, Tarjan’s algorithm enumerates every strongly-connected component.
  3. Break each cycle on the weakest edge. Inside a cycle, pick the FK whose column is nullable (or has a DEFAULT, or is declared DEFERRABLE INITIALLY DEFERRED). Insert that side first with the FK column left empty, insert the other side normally, then run a second pass that UPDATEs the empty column with the correct id. Referential integrity holds at every commit point.

When no edge in a cycle is breakable—every column on the cycle is NOT NULL with no DEFAULT and not DEFERRABLE—satus refuses to guess. It exits with code 10 (E_FK_CYCLE) and tells you which constraint to relax. We’d rather fail loudly than ship a workaround that violates an invariant you spent time declaring.

Cyclic FKs in the wild walks through a real-world example with the SQL the planner emits.

§03|Safety guard

ten thousand rows. then we stop and ask.


Before any write, satus counts user-table rows—every table outside pg_catalog, information_schema, and pg_toast—and refuses to run if the total exceeds 10,000. The intent is narrow: catch the case where DATABASE_URL was set to production by accident.

10,000 is deliberately conservative. A fresh development database sits at zero. A Docker container with the day’s migration applied sits in the low hundreds. A test database that someone already seeded is in the low thousands. Anything above five digits is almost always a database you didn’t mean to point at.

The guard is bypassable. Pass --force when you know what you’re doing—appending to a staging database that already has real fixtures, for example. The exit code on a guard trip is 11 (E_DB_NOT_EMPTY) so CI can distinguish “refused to run” from “tried and failed.”

Two things the guard is not: it isn’t a permission check (Postgres roles do that better), and it isn’t a rollback mechanism (the transaction guarantee below does that). It is one confirmation prompt, expressed as an exit code, between you and a mistake that costs a Slack apology.

§04|One transaction

all the rows. or none of them.


satus generate opens a single Postgres transaction, issues every INSERT and the FK back-patch UPDATEs inside it, and commits exactly once at the end. If anything fails—an LLM timeout, an unforeseen check constraint, a network blip, Ctrl-C—the transaction rolls back and your database is left in the state it was in before the run started.

This is plain Postgres ACID; we don’t implement a custom rollback. The value we add is that the run fits in one transaction. The planner pre-computes the entire insert order, the LLM calls happen ahead of writes so token failures abort before anything hits the database, and the back-patch pass is small enough to stay inside the transaction without inflating WAL.

Two practical consequences:

  • You don’t need cleanup scripts. A failed run is a no-op.
  • You can run satus generate in a tight CI loop against the same database without worrying about half-seeded state from a previous run.

The trade-off: very large seed runs hold a long-lived transaction. For datasets above ~50,000 rows we recommend planning with --dry first and reviewing the SQL—not because the transaction will fail, but so you know what you’re about to commit.

§05|What it isn't

three things satus does not do.


Knowing the edges of a tool is part of trusting it.

  • satus does not migrate your schema. It reads the schema you already have. Use prisma migrate, sqitch, flyway, or whatever your team standardised on—then point satus at the result.
  • satus does not anonymise production data. It generates new rows from scratch, profile-shaped and referentially correct. If you need to mask real PII, that’s a different category of tool (Snaplet’s subset feature, or a homegrown pg_dump + sed pipeline).
  • satus does not resell LLM tokens. You bring your own OPENAI_API_KEY; the request goes directly from your machine to your provider. Cost shows up on your dashboard, never ours.

Concept guide for satus 0.1.x. If anything here drifts from the CLI reference, the CLI reference wins—file an issue.