Skip to main content

Postgres connector

Extracts a Postgres table into canonical events with keyset pagination (never OFFSET): rows are ordered by (cursor_column, primary key) — or ctid when the table has no primary key — and fetched in pages of 50,000 rows through a server-side cursor, each page under a 5-minute statement_timeout. The session is read-only by construction (default_transaction_read_only = on): the connector never writes to your database — no temp tables, no DDL.

Prerequisites​

  • An API key with the admin scope.
  • A network path from Tessera to the database: a reachable host with a public address, or a bastion with a public address for the SSH tunnel. See Allowed destinations.
  • Permission to create a database role.

Allowed destinations​

Tessera connects only to public internet addresses. Before any connection, it checks config.host (or, with a tunnel, config.tunnel.host):

  • The host is one DNS name with at least two labels, or one IP address. Lists, socket paths, ports and spaces in the host are refused.
  • Private and cluster names are refused: localhost, and names ending in .local, .internal, .svc or .arpa.
  • Every address the name resolves to must be public. Private (RFC 1918), shared (100.64.0.0/10), loopback, link-local (including the cloud metadata address 169.254.169.254), unique-local and site-local IPv6, IPv4-mapped IPv6 and reserved addresses are refused. If one address is not public, the whole host is refused.
  • The port is the default (5432 for the database, 22 for the bastion) or 1024–65535.

Tessera resolves the name once and connects to the address it checked. With ssl_mode: verify-full, the certificate is still verified against the name. A database on a private network stays reachable through an SSH tunnel: the bastion needs a public address, and the bastion resolves the database host inside your network.

A refused destination fails tcp_or_tunnel with failure: configuration. A host name that does not resolve gets the same refusal. PUT /mapping returns 400 invalid_argument, and a sync fails with extraction_failed. Both carry a field_violation naming the refused field:

FieldRefused when
config/hostthe database host is malformed, private or cluster-internal, or does not resolve to public addresses
config/portthe database port is privileged and not 5432
config/tunnel/hostthe bastion host is malformed, private or cluster-internal, or does not resolve to public addresses. Use a bastion with a public address; the database behind it can stay private.
config/tunnel/portthe bastion port is privileged and not 22

Create the credential (least privilege)​

Create a dedicated reader that can connect, see the schema, and SELECT the one table — nothing else. No write privileges, no other tables, no superuser:

CREATE ROLE tessera_reader LOGIN PASSWORD '...';
GRANT CONNECT ON DATABASE app TO tessera_reader;
GRANT USAGE ON SCHEMA public TO tessera_reader;
GRANT SELECT ON public.orders TO tessera_reader;

These are exactly the privileges the connector uses: CONNECT to open the session (which then sets default_transaction_read_only = on), schema USAGE to resolve the table, and SELECT for the keyset-paginated reads — catalog lookups (pg_index, pg_attribute) need nothing more. The role needs LOGIN, and pg_hba.conf must allow it from Tessera's address over TLS. The console's Postgres form renders this template with your database, schema, table and username filled in (the password is never part of it).

Tessera verifies this posture: the read_only connection check confirms via has_table_privilege that the user holds no INSERT/UPDATE/DELETE/TRUNCATE on the table — a user with write privileges reports status: "warn" (the test still passes, but use a read-only user).

Create the source​

{
"name": "orders",
"kind": "postgres",
"config": {
"host": "db.internal.example.com",
"port": 5432,
"database": "app",
"schema": "public",
"table": "orders",
"ssl_mode": "require"
},
"credentials": {
"username": "tessera_reader",
"password": "..."
}
}

ssl_mode is require (default) or verify-full — plaintext connections are never allowed. Credentials are envelope-encrypted at rest and never readable through the API. The config object validates against the connector's registered schema:

{
"host": "db.internal.example.com",
"port": 5432,
"database": "app",
"schema": "public",
"table": "orders",
"ssl_mode": "require"
}

SSH tunnel​

For databases reachable only through a bastion, set config.tunnel and add the SSH private key to the credentials. All database TCP traffic then goes through an SSH local forward (direct-tcpip) established with that key:

{
"host": "db.internal.example.com",
"port": 5432,
"database": "app",
"table": "orders",
"tunnel": { "host": "bastion.example.com", "port": 22, "username": "tessera" }
}

Generate a dedicated key pair (e.g. ssh-keygen -t ed25519 -f tessera_tunnel -N ''), install the public half on the bastion user's authorized_keys, and paste the private half into credentials.tunnel_private_key. With a tunnel, prefer ssl_mode: require (verify-full would verify the certificate against the local forward address rather than the database host name).

Map columns to canonical events​

The mapping declares entity / event type / timestamp / attribute columns — see the quickstart's worked mapping. Two Postgres-specific rules:

Incremental syncs. Declare a mapping cursor on a timestamptz, timestamp, date, smallint, integer or bigint column. Incremental syncs apply the strict predicate cursor_column > watermark, composed with keyset pagination; the watermark only advances when a sync commits. An index on the cursor column keeps pages fast.

Type map. Every mapped column is converted per this normative table (generated from the connector's type map — code and docs cannot drift). Mapping an unsupported column fails PUT /sources/{id}/mapping with the column and its type named.

Postgres typeCanonical typeNotes
smallintintegerExact integers.
integerintegerExact integers.
bigintintegerExact 64-bit integers.
numericnumberConverted to float64 (values beyond float64 precision convert lossily). A column mapped as an entity or event id lands as an exact decimal instead (up to 38 digits before and after the point), so ids above 2^53 - 1 stay exact; a NaN, an infinity or a wider value is refused for its row only, counted in the sync's details (cast such a column to text in a view to keep its ids).
realnumberIEEE-754 floats.
double precisionnumberIEEE-754 float64, as-is.
booleanbooleanBooleans, as-is.
textstringUTF-8 strings, as-is (≤ 4096 chars per 002 §2).
varcharstringUTF-8 strings, as-is.
charstringBlank-padded strings, as returned by Postgres.
uuidstringCanonical lowercase-hex string form.
inetstringTextual address form.
citextstringUTF-8 strings, as-is.
enumstringThe enum label as a string (any CREATE TYPE … AS ENUM).
timestamptztimestampConverted to RFC 3339 UTC.
timestamptimestampNaive wall-clock time: the mapping's IANA timezone is required — without it, rows carrying a value are rejected.
datetimestampMidnight UTC of the date (YYYY-MM-DDT00:00:00Z).
text[]arrayArray of strings (≤ 64 items per 002 §2).
varchar[]arrayArray of strings (≤ 64 items).
smallint[]arrayArray of integers (≤ 64 items).
integer[]arrayArray of integers (≤ 64 items).
bigint[]arrayArray of integers (≤ 64 items).
numeric[]arrayArray of numbers (elements converted to float64).
jsonunsupportedCannot be mapped (flatten upstream); PUT /mapping fails naming the column.
jsonbunsupportedCannot be mapped (flatten upstream); PUT /mapping fails naming the column.
byteaunsupportedCannot be mapped; PUT /mapping fails naming the column.
tsvectorunsupportedCannot be mapped; PUT /mapping fails naming the column.
intervalunsupportedCannot be mapped; PUT /mapping fails naming the column.
moneyunsupportedCannot be mapped; PUT /mapping fails naming the column.
geometricunsupportedpoint/line/lseg/box/path/polygon/circle cannot be mapped; PUT /mapping fails naming the column.

A numeric id value the exact decimal cannot carry lands, with the other such rows of its fetched page, after that page's other rows and in a landed part of its own. Row numbers in the rejected-rows report follow that landed order.

Date and naive-timestamp semantics​

  • date values become midnight UTC of that date (2026-02-01 → 2026-02-01T00:00:00Z).
  • timestamp (without time zone) values are naive wall-clock times: the mapping's IANA timezone is required, and each value is converted from that zone to UTC. Without a timezone, rows carrying a value are rejected.
  • If your dates carry local-date semantics, map them through a naive timestamp column and set the mapping timezone instead of relying on the midnight-UTC convention.

Test the connection and run the first sync​

POST /sources/{id}/connection-diagnostics runs four checks in order, each tagged with the diagnostic stage it verifies:

CheckStageVerifies
tcp_or_tunnelnetworka TCP connection to the host, or the SSH tunnel to the bastion
authenticateauthenticationa TLS session with the stored username and password
table_readableauthorizationthe table exists and the user holds SELECT on it
read_onlyauthorizationthe user holds no write privilege (warn otherwise)

A failed check carries a typed failure cause and a detail naming the fix; later checks report not_attempted. Postgres's refusal decides the cause: a wrong password or unknown role is authentication; a missing CONNECT, LOGIN or pg_hba.conf entry, missing schema USAGE or missing SELECT is authorization; a missing database or table is not_found; an unreachable host, closed port, failed tunnel or refused TLS is network (a rejected tunnel key is authentication). Details never contain the password or the server's error text. The response's checked_at is the server time of the test, which is also recorded in the audit log as source.test_connection. POST /sources/{id}/test-connection runs the same checks and returns only their names, statuses and details. The full flow, as one runnable script (it needs a reachable database, so CI runs it against our hosted deployment rather than the local stack):

01-create-and-sync.sh
#!/usr/bin/env bash
# Postgres connector: create the source (vaulted read-only credentials),
# test the connection, first sync. Requires a reachable database (hosted
# runs only, not the local stack): set TESSERA_PG_HOST / TESSERA_PG_DB / TESSERA_PG_TABLE /
# TESSERA_PG_USER / TESSERA_PG_PASSWORD.
set -euo pipefail

: "${TESSERA_API_BASE:?e.g. https://api.staging.tessera.zone/v1}"
: "${TESSERA_API_KEY:?your tsk_test_… key}"
: "${TESSERA_PG_HOST:?database host}"
: "${TESSERA_PG_DB:?database name}"
: "${TESSERA_PG_TABLE:?table to extract}"
: "${TESSERA_PG_USER:?read-only user}"
: "${TESSERA_PG_PASSWORD:?password}"
AUTH=(-H "Authorization: Bearer $TESSERA_API_KEY")

SOURCE="$(curl -sS -X POST "$TESSERA_API_BASE/sources" "${AUTH[@]}" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: docs-postgres-source" \
-d '{
"name": "orders-db",
"kind": "postgres",
"config": {
"host": "'"$TESSERA_PG_HOST"'",
"database": "'"$TESSERA_PG_DB"'",
"table": "'"$TESSERA_PG_TABLE"'",
"ssl_mode": "require"
},
"credentials": {
"username": "'"$TESSERA_PG_USER"'",
"password": "'"$TESSERA_PG_PASSWORD"'"
}
}')"
SOURCE_ID="$(printf '%s' "$SOURCE" | python3 -c 'import json,sys; print(json.load(sys.stdin)["source_id"])')"

# Checks: tcp_or_tunnel, authenticate, table_readable, read_only (warns if
# the user holds write privileges — use a read-only role).
curl -sS -X POST "$TESSERA_API_BASE/sources/$SOURCE_ID/test-connection" "${AUTH[@]}" \
| python3 -c '
import json, sys
result = json.load(sys.stdin)
print("test-connection:", result["status"])
for check in result["checks"]:
print(" %s: %s" % (check["name"], check["status"]))
'

SYNC="$(curl -sS -X POST "$TESSERA_API_BASE/sources/$SOURCE_ID/syncs" "${AUTH[@]}" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: docs-postgres-first-sync" \
-d '{"mode": "full"}')"
SYNC_ID="$(printf '%s' "$SYNC" | python3 -c 'import json,sys; print(json.load(sys.stdin)["sync_id"])')"
for _ in $(seq 1 600); do
STATUS="$(curl -sS "$TESSERA_API_BASE/syncs/$SYNC_ID" "${AUTH[@]}" \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["status"])')"
if [ "$STATUS" = "succeeded" ] || [ "$STATUS" = "failed" ]; then break; fi
sleep 1
done
echo "first sync: $STATUS"

Download connect/postgres/01-create-and-sync.sh — this exact file runs in CI on every change (hosts shown are for this environment).

Rotate the credential​

The source holds the role's password, and the SSH tunnel private key when a tunnel is on. To rotate the password: set a new one on the role, or create a second role with the same grants, then PATCH /sources/{id} with credentials. The body replaces the stored credentials entirely, so repeat the username and, with a tunnel, the tunnel_private_key. Confirm POST /sources/{id}/connection-diagnostics passes before you retire the old password or role. A sync already running keeps the password it started with.

Troubleshooting​

SymptomCauseFix
tcp_or_tunnel fails with failure: networkhost unreachable from Tesserause the SSH tunnel or open the network path
tcp_or_tunnel fails with failure: configurationthe host, bastion or port is not an allowed destinationuse a public DNS name or address, or reach a private database through a bastion
authenticate fails with failure: authenticationwrong password or unknown rolereplace the credentials
authenticate fails with failure: authorizationno CONNECT, no LOGIN, or no pg_hba.conf entry for Tesseragrant CONNECT, allow the role to log in, add the pg_hba.conf rule
table_readable fails with failure: authorizationmissing schema USAGE or table SELECTrun the two grants above
read_only: warnthe user holds write privilegescreate the dedicated tessera_reader role above
PUT /mapping fails naming a columnunsupported type (jsonb, bytea, …)flatten/cast upstream (a generated column works well)
pages slow / statement timeoutsno index on the cursor columnindex it; keyset pagination then stays fast
rows rejected as invalid_timestampnaive timestamp without timezoneset the mapping's IANA timezone
rows rejected as missing_entity_id although the id is filledthe id is not an identifier; the sync's details count it as entity_ids_refused with a reasonsee the reasons
rows rejected (or participants dropped) with entity_ids_refused reason imprecise or non_finite on a numeric id columnan id with more than 38 digits before or after the point, or NaN/infinitycast the column to text in a view and map that; NaN is no id