Skip to content

# Migrating from Hasura v2 to Provisa

## Prerequisites

1. A running Hasura v2 instance (v2.x) with metadata exported.
2. Export metadata using the Hasura CLI:

       hasura metadata export --endpoint http://localhost:8080

   This creates a `metadata/` directory containing `sources.yaml`, `actions.yaml`, `cron_triggers.yaml`, `inherited_roles.yaml`, `remote_schemas.yaml`, etc.
3. Python 3.11+ with the `provisa` package installed.

## CLI Usage

    python -m provisa.hasura_v2 <metadata-dir> -o provisa.yaml

### Arguments

Argument | Required | Description
--- | --- | ---
`metadata_dir` | Yes | Path to the exported Hasura v2 metadata directory

### Options

Option | Default | Description
--- | --- | ---
`-o, --output FILE` | stdout | Output YAML file path
`--source-overrides FILE` | None | YAML file with per-source connection overrides
`--domain-map KEY=VAL ...` | None | Schema-to-domain mappings (e.g., `public=core hr=people`)
`--auth-env-file FILE` | None | Path to `.env` file with JWT/admin-secret auth configuration
`--dry-run` | off | Parse and validate without writing output

### Source Overrides File

A YAML file keyed by source name with connection properties to override:

    default:
      host: prod-db.example.com
      port: 5432
      database: myapp
      username: provisa_user
      password: "${env:PROD_DB_PASSWORD}"

### Auth Env File

A `.env`-style file holding the Hasura auth configuration to convert. The converter maps:

- JWT with `jwk_url` -> Provisa `provider: oauth`.
- JWT `claims_map` -> Provisa `role_mapping[]`.
- Admin secret -> Provisa `superuser`.
- Webhook auth -> warning emitted (no Provisa equivalent).

## Feature Parity Matrix

Hasura v2 Feature | Provisa Equivalent | Notes
--- | --- | ---
Sources (postgres, mysql, mssql, bigquery, citus) | `sources[]` | Kind mapped: pg/postgres -> postgresql, mssql -> sqlserver. Connection URL parsed into host/port/database/username/password. Pool settings preserved.
Tables (tracked tables) | `tables[]` | Schema + table name preserved. `source_id` links to source.
Custom table names (`custom_name`, `custom_root_fields.select`) | `tables[].alias` | First non-null of `select`, `select_by_pk`, `custom_name`.
Custom column names | `columns[].alias` | Maps `custom_column_names` dict to column aliases.
Select permissions (columns, filter) | `columns[].visible_to[]`, `rls_rules[]` | Column lists become `visible_to`. Wildcard (`*`) columns supported. Filters converted to SQL via `bool_expr_to_sql`.
Insert/Update permissions (columns) | `columns[].writable_by[]` | Column lists become `writable_by`. Roles upgraded to `write` capability.
Delete permissions | Role capability upgrade | Role gets `write` capability. No per-table delete mapping.
Object relationships | `relationships[]` with `cardinality: many-to-one` | Column mapping preserved.
Array relationships | `relationships[]` with `cardinality: one-to-many` | Column mapping preserved.
Computed fields | `functions[]` | Mapped to Function with `returns` pointing to the parent table ID.
Tracked functions | `functions[]` | `exposed_as` defaults to mutation. Schema preserved.
Actions (stored-procedure handler) | `functions[]` | Converted to a Function config where backed by a stored procedure.
Actions (webhook handler) | Not converted | Warning emitted, including the handler URL.
Cron triggers | Not converted | Warning emitted. (Runtime scheduled triggers exist, but the converter does not map them.)
Event triggers | Not converted | Warning emitted. (Runtime event triggers exist, but the converter does not map them.)
Inherited roles | `roles[].parent_role_id` | First role in `role_set` becomes parent. All child roles created.
Remote schemas | `sources[]` (`graphql_remote`) | Registered as a `graphql_remote` source. Name, URL, headers, and authentication configuration preserved.
Enum tables | Table created | `is_enum` flag not carried over (no Provisa equivalent).
Allow lists | Skipped | Not present in metadata model.

## Post-Conversion Steps

1. Review the output YAML. Check that sources, tables, and roles look correct.
2. Configure source connections. The converter parses connection URLs but defaults to `localhost` on parse failure. Use `--source-overrides` or edit the output directly.
3. Verify domain assignments. Without `--domain-map`, all tables land in `default`. Assign schemas to domains with `--domain-map public=core analytics=reporting`.
4. Check RLS rules. Filters are converted to SQL approximations. Complex boolean expressions (nested `_and`/`_or`/`_exists`) should be reviewed manually.
5. Review warnings. The converter prints a warning summary to stderr for features the converter does not map (event triggers, cron triggers, webhook-backed actions).
6. Set up auth. If your Hasura instance uses JWT/webhook auth, create an auth env file and re-run with `--auth-env-file`.
7. Test. Start the Provisa server and verify queries against your data sources.

## Common Issues and Troubleshooting

### Connection URL not parsed

If the source `database_url` is an environment variable reference (`{"from_env": "PG_URL"}`), the converter cannot resolve it at conversion time. The source will have placeholder values (`host: localhost`, `database: default`). Fix with `--source-overrides`.

### Wildcard columns

When a permission grants `columns: "*"`, the converter creates a single wildcard column entry. After conversion, you may want to replace it with explicit column lists by inspecting the actual database schema.

### Event trigger fidelity

Event triggers are converted with `operations` and `webhook_url` but Hasura-specific delivery guarantees (exactly-once, redelivery) do not have direct Provisa equivalents. Review the `event_triggers` section and configure your webhook infrastructure accordingly.

### Missing roles

Roles are collected only from permission entries. If a role exists in Hasura but has no permissions on any table or action, it will not appear in the output.

### Custom root fields

Only `select` and `select_by_pk` root fields are used for the table alias. Other custom root fields (`select_aggregate`, `insert`, `update`, `delete`) are not mapped.

## Example

Convert a typical Hasura v2 project with two schemas mapped to domains:

    # Export metadata from Hasura
    hasura metadata export --endpoint http://localhost:8080

    # Convert with domain mapping and source overrides
    python -m provisa.hasura_v2 metadata/ \
      -o provisa.yaml \
      --domain-map public=core hr=people \
      --source-overrides overrides.yaml \
      --auth-env-file auth.env

    # Dry run first to check for warnings
    python -m provisa.hasura_v2 metadata/ --dry-run

Output structure:

    sources:
      - id: default
        type: postgresql
        host: prod-db.example.com
        port: 5432
        database: myapp
        ...
    domains:
      - id: core
      - id: people
    tables:
      - source_id: default
        domain_id: core
        schema_name: public
        table_name: users
        columns:
          - name: id
            visible_to: [user, admin]
          - name: email
            visible_to: [admin]
            writable_by: [admin]
        alias: Users
    roles:
      - id: admin
        capabilities: [read, write]
        domain_access: ["*"]
      - id: user
        capabilities: [read]
        domain_access: ["*"]
    rls_rules:
      - table_id: default.public.users
        role_id: user
        filter: "id = x-hasura-user-id"
    relationships:
      - id: default.public.orders.user
        source_table_id: default.public.orders
        target_table_id: default.public.users
        source_column: user_id
        target_column: id
        cardinality: many-to-one