All docs

Docs · Data & state

Outlets

A stack can read and write the database its owner already runs — the CRM, the ticket table, the orders schema — through an outlet: a named connection the stack declares once, with the credential held by the chassis. The op writes its own SQL with every value bound as a parameter; the chassis owns the pool, bounds rows, bytes and time, and hands the op JSON. The stack never sees a DSN, a password or a socket.

An inlet lets an external system speak into a stack; an outlet lets a stack speak out to one. PostgreSQL is the first driver.

Authoring

One file per outlet under the reserved OUTLETS/ subtree:

OPS/<stack>/
  OUTLETS/
    crm.yaml        where to connect, and the ceilings
driver: postgres
secret: CRM_DSN      # the NAME of a tenant secret; its value is a postgres:// DSN
access: read         # read (default) refuses outlet://crm/exec; write allows it
max_rows: 500        # tightens the node ceiling, never raises it
timeout: 5000        # ms; caps every call on this outlet
egress: relay        # direct | relay; omitted = the node's --outlet-egress

The declaration is code: it deploys with txco apply beside the ops that use it. The credential is not — set it once, as a secret, scoped to the stack or to the whole tenant:

txco auth tenant secrets set --stack support CRM_DSN   # prompts for the value

Several stacks may declare secret: CRM_DSN and share the tenant’s credential; give one of them a stack-scoped CRM_DSN with a narrower database role and it silently uses that instead. Nothing in the ops changes.

Calling

WHEN @web.req.url.query.email.0 != ""
  EXEC "outlet://crm/query"
    WITH sql = "SELECT id, name, plan FROM customers WHERE email = $1 LIMIT 10",
         args = &array(@web.req.url.query.email.0),
         into = "_crm"

query runs the statement in a READ ONLY transaction; exec (on an outlet declared access: write) may change rows and runs in an explicit transaction. Both take sql, args (positional binds for $1, $2 …), into (default _outlet, private to the flow) and timeout.

A read lands at into:

{
  "_crm": {
    "ok": true,
    "outlet": "crm",
    "columns": [
      "id",
      "name",
      "plan"
    ],
    "rows": [
      {
        "id": 123,
        "name": "Alice",
        "plan": "team"
      }
    ],
    "count": 1
  }
}

columns gives SELECT order, which object keys don’t. A write returns {"ok": true, "outlet": "crm", "rows_affected": 1}, plus columns, rows and count when the statement has a RETURNING clause.

Every failure is data at the same path, and the op still merges:

{
  "_crm": {
    "ok": false,
    "outlet": "crm",
    "error": {
      "code": "txco_outlet_timeout",
      "message": "outlet operation timed out"
    }
  }
}

Gate the next op on ._crm.ok == true. A missing path compares as false, so gate an error responder on ._crm.error.code != "" rather than on ok == false, or it fires on requests where the lookup never ran.

codemeaning
txco_outlet_not_declaredthe outlet isn’t in the stack’s declaration
txco_outlet_missing_secretthe named secret isn’t set for this stack or tenant
txco_outlet_invalid_requestan argument the driver can’t bind, a bad into, or exec on a read outlet
txco_outlet_result_too_largethe row or byte ceiling was crossed; no rows are returned, and an exec rolled back
txco_outlet_outcome_unknownthe connection was lost while a write was committing; verify before retrying
txco_outlet_connect_failedDNS, TLS, refused, or blocked by the egress policy
txco_outlet_auth_failedthe database rejected the credentials
txco_outlet_timeoutdeadline passed, pool wait included
txco_outlet_constraintconstraint violation (SQLSTATE class 23)
txco_outlet_query_failedany other database error; sqlstate included
txco_outlet_unavailableshutdown, failover, too many connections

Messages are fixed strings; the database’s own error text, which names hosts and users, never reaches the envelope.

Polling

outlet://<name>/query may LOOP: every failure is data, each pass pays the dispatch fuel plus the rows it returns, and the whole loop runs under the op’s deadline.

EXEC "outlet://crm/query"
  WITH sql = "SELECT id FROM exports WHERE job = $1 AND done = true",
       args = &array(.job), into = "_export"
  LOOP EVERY "2s" UNTIL ._export.count == 1 MAX 30

A loop merges every pass, and the merge appends arrays: rows from every pass accumulate (empty polls add nothing), and so do the entries of columns. Read ok and count — scalars, so the last pass wins — and treat rows as everything the poll saw.

outlet://<name>/exec is not admitted to LOOP: a pass ending in txco_outlet_outcome_unknown must not be followed by another attempt at the same write. Repeat a write with an explicit next op.

Arguments and types

args accepts null, booleans, strings, numbers, and a homogeneous array of those — so WHERE id = ANY($1) takes a list of any length. An integral number binds as an integer; nulls are allowed anywhere in an array.

Results map deterministically, with no silent precision loss: int8 beyond ±2^53 and numeric arrive as strings, json/jsonb as the JSON value, bytea as base64, dates and timestamps as strings (2006-01-02, RFC 3339 in UTC for timestamptz), arrays as arrays, uuid/inet as strings.

What apply does

Nothing connects at apply time: a database that is unreachable right now must never block a deploy. What apply checks it checks from source alone, and the chassis checks it again before activation:

  • every OUTLETS/<name>.yaml parses and names a driver built into the chassis;
  • every EXEC "outlet://…" names an outlet its own stack declares;
  • outlet://<name>/exec is refused on an outlet declared access: read;
  • sql is a string literal in the op — not a path, not &concat(...). Values reach the statement only through args, and the statement a stack runs against an external system is readable in its source;
  • one statement per call, and the leading verb matches the operation (SELECT/WITH for query; INSERT/UPDATE/DELETE also for exec; never DDL).

That last check is lint, not the boundary. txco lint runs the same checks offline.

Runtime model

  • query cannot mutate. It runs in BEGIN READ ONLY, so the database refuses a write even inside WITH x AS (INSERT … RETURNING …) SELECT …, and the row stays unchanged.
  • exec either committed or it didn’t. The statement runs in an explicit transaction: begin, execute, read the returned rows, check them against the ceilings, commit. A ceiling crossed by the returned rows rolls back, so ok: false means the chassis did not commit. The one case it can’t settle — a connection lost while COMMIT is in flight — is txco_outlet_outcome_unknown, which the chassis never retries.
  • A ceiling is an error, never a prefix. A result over the row or byte ceiling comes back with no rows at all, so nothing can be mistaken for a complete answer. Put LIMIT in the SQL.
  • The database role is the boundary. The role behind the DSN decides what an outlet can reach; give it only the tables and verbs the stack needs.
  • Every dial goes through the egress policy. The default private policy blocks loopback, private and link-local address space, so a DSN cannot point the chassis at internal services. txco dev runs the policy open so a local database works; a self-hosted txco serve needs --egress-allow-cidrs to reach a database on its LAN.
  • Pools are per node, and idle at zero. Nothing opens until an op runs; a pool closes after --outlet-idle-close unused. A database sees roughly nodes × --outlet-pool-max-conns connections. Rotating the secret or redeploying the declaration switches to a new pool without a restart. The driver’s execution mode works behind transaction-mode poolers; prefer the provider’s pooler.
  • Egress: direct or relayed. By default a connection leaves from the node that runs the op. With egress: relay (or a node whose --outlet-egress is relay) the node asks one of its configured relays on the fleet’s private network to make the connection, so it originates from the relay’s address — the one a customer allowlists. Only the dial changes: the node still resolves the host, checks the address against the egress policy, owns the pool and runs the statement, and TLS runs end to end through the relay (use sslmode=verify-full so the certificate is checked against the DSN hostname). A relay is asked for an exact ip:port, never a hostname. A node with no relays configured answers such an outlet with txco_outlet_connect_failed. When the relays live on a private network the host isn’t on, --outlet-egress-wg-config names a standard WireGuard configuration (a peer config issued by fly wireguard create, or any client config) and the chassis runs the tunnel itself, in process, without a kernel interface or root.
  • The trace records the call, not the data. Outlet, driver, operation, a fingerprint of the statement, duration, rows, bytes and the error code — never the SQL text, the values, the rows or the DSN. It also says whether the dial was direct or relay.

Limits

knobdefaultwhat it bounds
--outlet-max-rows500rows per result (a declaration’s max_rows clamps under it)
--outlet-max-bytes1 MiBbytes per result, measured while rows stream
--outlet-pool-max-conns4connections per outlet per node (minimum is always 0)
--outlet-pool-idle60show long an unused connection stays open
--outlet-idle-close5mhow long an unused pool stays open
--outlet-pg-exec-modedescribe-execpgx execution mode: describe-exec (a describe round trip per statement, never stale, safe behind any pooler) or cache-describe (one round trip; a schema change under a cached statement fails once)
--outlet-egressdirectwhat a declaration without egress gets: direct or relay
--outlet-egress-relaysnoneSOCKS5 relays (ip:port on the fleet’s private network, or on the tunnel) for relay, tried in order
--outlet-egress-wg-confignonea wg-quick file the chassis brings up in-process (userspace WireGuard, no root) to reach the relays by their tunnel addresses

Calls run under the ordinary per-op timeout (WITH timeout and the declaration’s timeout can only lower it). Rows returned are charged fuel per MiB, like notebook reads.

A worked example lives at examples/outlet-postgres/: a lookup by email, a probe that runs without a database (the missing secret comes back as data), and a README for running it against a real one.

Edit this page · View as markdown