S
supero.docs
Documentation/Foundations/Connect Your Database

Connect Your Database

Register the tables in a database you already run as object types in a Supero project, then read them where they are — synced into the platform store, or served live from the source on every request.

A connector registering source tables as object types, with per-table sync and live read modes
One connector, one source, and a per-table decision: copy the rows in, or serve them from the source on every read.

What It Is For

Most applications worth building sit on data that already exists — a Postgres instance behind an internal tool, a MySQL database under a product that predates you, a Snowflake or Databricks warehouse the analytics team curates. The conventional answer is a migration: copy the data somewhere new, keep both copies in step, and hope nothing writes to the old one. A connector is the alternative. You register the tables you care about as object types in a project and read them where they are.
This is the right shape whenever another system remains the writer of record. A billing platform, a point-of-sale terminal, an ETL job landing rows every hour — none of them will start writing through your new application, and a design that assumes they will fails in its second week. A connector lets you build the application, the roles, the tenancy and the admin surface around a table you do not own.
It is also right when the data is too large or too governed to copy: a read-only warehouse type gives users a filtered, role-scoped view of a table that never leaves the warehouse. If you are creating the data rather than mirroring it, you want an ordinary object type instead — see Schema Design.

The Shape

A connector is a record you POST to /api/v1/connectors, in three parts: a source (where rows come from), a target (which object types they land in, under what read and write mode) and a trigger (what makes it run).
The type discriminator is source.plugin, one of http_api, csv_file, database or webhook. There is no connector_type field and no top-level db_type — for the database plugin the engine lives at source.config.db_type. table_names is always a list; there is no singular table_name, even for a single-table source.
target.tables carries the interesting decisions. Each entry maps one source table onto one object type: source is the real table name, schema the target type, mode the per-table read and write mode. A live entry also carries a live block — field_map, columns with their sql_type, primary_key, the tenant_binding that resolves a row to a tenant, and hidden_fields.
json
POST /api/v1/connectors

{
  "name": "ops-postgres",
  "display_name": "Operations Postgres",
  "enabled": true,
  "fq_name": ["acme", "ops-postgres"],
  "parent_type": "domain",
  "schema_namespace": "ops",
  "project_uuid": "9f3c1b6a-2e5d-4f81-b0a7-3c5e9d1f2a44",

  "source": {
    "plugin": "database",
    "config": {
      "db_type": "postgresql",
      "host": "db.internal.example",
      "port": 5432,
      "database": "operations",
      "schema": "public",
      "username": "supero_reader",
      "password": "<supplied at create time>",
      "query_type": "table",
      "table_names": ["customers", "orders"],
      "ssl_mode": "require"
    }
  },

  "target": {
    "namespace": "ops",
    "tables": [
      {
        "source": "customers",
        "schema": "customer_mirror",
        "operation": "upsert",
        "mode": "sync",
        "stream_class": "entity",
        "batch_size": 50,
        "on_error": "skip",
        "enabled": true
      },
      {
        "source": "orders",
        "schema": "order_live",
        "operation": "upsert",
        "mode": "live_read",
        "stream_class": "entity",
        "enabled": true,
        "live": {
          "source": "orders",
          "primary_key": ["order_id"],
          "field_map": {
            "order_id": "order_id",
            "customer_id": "customer_id",
            "status": "status",
            "total": "total"
          },
          "columns": [
            { "name": "order_id", "sql_type": "int" },
            { "name": "customer_id", "sql_type": "int" },
            { "name": "status", "sql_type": "varchar" },
            { "name": "total", "sql_type": "int" }
          ],
          "tenant_binding": { "mode": "fixed", "tenant": "acme-ops" },
          "hidden_fields": ["internal_margin"]
        }
      }
    ]
  },

  "trigger": { "type": "manual", "allow_manual": true }
}

ℹ️ Mode is per table, not per connector

One connector can mirror a slow-moving reference table into the platform store and serve a high-volume transactional table live from the source, in the same body.

How It Works

Three per-table modes, differing in where a read is served from and what happens to a write.
modeA read is served fromA writeAvailable on
syncThe platform's own store, populated by connector runsLands in the platform store, not in the sourceAll four source plugins
live_readThe source, on every requestRejected — the schema is read-onlyThe database and http_api plugins
live_readwriteThe source, on every requestGoes to the sourceThe database plugin only
Live reads work on two source plugins: database (served by the SQL planner and the database and warehouse readers) and http_api (served by the REST planner and reader). Live writes are database-only — live_readwrite on an http_api table is rejected at create rather than accepted and then failing on the first write, because there is no generic write-back for an arbitrary REST endpoint. csv_file and webhook are batch-sync only, and binding either of them live is rejected for the same reason. Read-only connector schemas carry read_only: true and expose operations: ["read"] and nothing else.
  1. 1

    Upload the object type first

    POST /api/v1/schemas/upload with the attributes you want exposed. A connector maps onto a type; it does not invent one from the source catalog.

  2. 2

    Create the connector

    POST /api/v1/connectors with the body above. Create validates the shape but does not dial the source: a 201 means the payload was accepted, nothing more.

  3. 3

    Confirm the mode persisted

    GET /api/v1/connectors/{id} and read target.tables[].mode and .live back. If they did not round-trip, the live surface is not wired.

  4. 4

    Read through the normal CRUD surface

    POST /api/v1/crud/{domain}/query, list-bulk and the aggregate endpoints all serve live rows. There is no live-specific endpoint and no second client to build.

  5. 5

    Check the stamp

    A live response carries access_mode: "live-ro" for an OLTP source or "warehouse" for a warehouse.

⚠️ 200 with zero rows is a wiring failure, not an empty table

When a live connector's binding cannot be resolved for the calling identity, the read silently falls back to the platform's own local store — which is empty — and returns a healthy-looking HTTP 200 with an empty results array. Nothing errors. The tell is the envelope: a real live read is stamped access_mode live-ro or warehouse, a fallback read carries no stamp at all. Assert on the stamp, never on the status code.

What You Get

  • •The full read surface over source tables — /query, list-bulk and the aggregate endpoints, on the same paths as native types.
  • •Filter pushdown: a filter on a mapped column becomes typed SQL in the source dialect, not a client-side scan.
  • •Cursor pagination as elsewhere — limit plus cursor. page and page_size are silently ignored here too.
  • •Read-only enforcement with teeth: a write to a warehouse type is rejected, not quietly landed in a local store the live read cannot see.
  • •access_mode stamping on every live envelope, so a UI can tell live from local without asking the connector API.
  • •hidden_fields, so a source column never reaches a response, and tenant_binding, so a live row resolves to a tenant.
  • •Roles, access policies and field policy apply to connector-backed types exactly as to native ones — see Access Control.
  • •Four source plugins, and three trigger types: manual, schedule with a five-part cron, and webhook.

💡 Proven sources

Databricks and Snowflake as read-only warehouse sources; PostgreSQL and a REST source on the live-ro path; MSSQL, MySQL, Postgres and CSV in sync mode. Salesforce has no working example anywhere in the verified corpus — treat an unproven engine as an integration project, not a configuration step.

What You Decide

ℹ️ Test, discover and run do exist — but they are not the same as a healthy read

The control plane exposes POST /api/v1/connectors/test-config (check a configuration before you save it), POST /api/v1/connectors/{id}/test, POST /api/v1/connectors/{id}/discover with GET /api/v1/connectors/{id}/discover/{execution_id}/results, POST /api/v1/connectors/{id}/run, and GET /api/v1/connectors/{id}/status. These are what the console calls. Note that they are not exercised by the end-to-end suite, so treat their finer behaviour as less settled than the CRUD surface — and treat a passing test as evidence about credentials and reachability, not as proof that a later read will be correctly scoped.
What a passing connection test does not settle is the object type. The attributes are yours to author, and for live tables so are the field_map, the columns with their sql_type, and the primary_key. A column added upstream stays invisible until you add it.
  • •Reachability. The source must be reachable from wherever the platform runs. A VPN, a firewall rule or a private subnet is a networking task you own — and it presents as an unstamped empty read, not a connection error.
  • •Freshness. sync mode gives you a copy exactly as current as its last run; the trigger cadence is the whole of your freshness guarantee.
  • •Write ownership. In live_readwrite both your application and the original system can write the same rows. Conflict rules and reconciliation are yours; the connector does not arbitrate.
  • •Tenant resolution. tenant_binding decides which tenant a live row belongs to. Getting it wrong is not an error, it is rows shown to the wrong audience — verify it under a non-admin role.