Connect & wire adapters
DataK³ splits into two planes:
- Control plane — HTTP/JSON on
api.data.dodil.io, gRPC onrpc.data.dodil.io:443: buckets, pipelines, collections, keys, admin. - Data plane — wire adapters: the same bucket speaks the wire protocol of the tool you already use. Point a stock client at the endpoint and go.
Data-plane doors are region-scoped (<service>.<region>.dodil.io);
uk-lon-1 is the current production region. The control plane stays global.
┌───────────────────────────────────┐
psql ───────────►│ pg.uk-lon-1.dodil.io:5432 │
aws s3 / boto3 ─►│ object.uk-lon-1.dodil.io │ one
neo4j driver ───►│ bolt.uk-lon-1.dodil.io:7687 │ ───► bucket,
qdrant client ──►│ qdrant.uk-lon-1.dodil.io │ every
pinecone sdk ───►│ pinecone.uk-lon-1.dodil.io │ dimension
graphql ────────►│ gql.uk-lon-1.dodil.io/graphql │
http ───────────►│ table.uk-lon-1.dodil.io │
grpc ───────────►│ table-rpc.uk-lon-1.dodil.io:443 │
└───────────────────────────────────┘Endpoints
| Wire | Endpoint | Client examples |
|---|---|---|
| Postgres | pg.uk-lon-1.dodil.io:5432 | psql, pg, psycopg, pgx, Prisma |
| S3 (SigV4) | https://object.uk-lon-1.dodil.io | AWS CLI, boto3, rclone |
| Bolt (Cypher) | bolt+s://bolt.uk-lon-1.dodil.io:7687 | neo4j drivers, cypher-shell |
| Qdrant | https://qdrant.uk-lon-1.dodil.io | qdrant-client |
| Pinecone | https://pinecone.uk-lon-1.dodil.io | pinecone SDKs |
| GraphQL | https://gql.uk-lon-1.dodil.io/graphql | any HTTP client |
| HTTP (Tables) | https://table.uk-lon-1.dodil.io | any HTTP client |
| gRPC (Tables) | table-rpc.uk-lon-1.dodil.io:443 | generated stubs, grpcurl |
Endpoints are stable per region and derived client-side — there is no per-bucket endpoint-minting call. Each adapter is a translation layer, not a reimplementation of the product it imitates — read Wire fidelity before you port tuning knobs or score thresholds across.
The db id is the bucket name on every wire (Postgres dbname, S3 bucket,
Bolt database, the x-db-id header on GraphQL and the Tables HTTP door, the
Pinecone/Qdrant api-key header). Send the bare bucket name — the gateway
qualifies it to its internal tenant-scoped storage id from your authenticated
org; never build an org-prefixed name yourself.
Postgres and Bolt both enter on raw host ports (5432 / 7687), and both terminate TLS — the certificates are public Let’s Encrypt ones, so ordinary system trust roots validate them:
- Postgres — use
sslmode=requireas the floor. That is whatdodil data connectemits. Do not useprefer: it silently falls back to plaintext if the door ever stops offering TLS, and on this wire your password is your service-account secret — a silent downgrade would put that credential, and every row you read, in the clear. For production, preferverify-full, which also authenticates the server — see Choosing an sslmode. - Bolt — use the
bolt+s://scheme. TLS on connect, verified against public roots by the official drivers with nothing to configure. Plainbolt://still connects but is unencrypted — keep it for local-dev or an explicitly insecure example.neo4j+s://andneo4j://both still fail, and not for TLS reasons: those schemes ask for the routing protocol, which this adapter answers with “routing is not supported”. Use the single-instanceboltschemes.
Every other door is HTTPS-terminated at the edge.
Choosing an sslmode
require encrypts the connection but does not authenticate the server, so
it does not defend against an active machine-in-the-middle. verify-full adds
certificate-chain and hostname verification and is the recommended production
posture. What it takes to turn on depends on your driver:
| Client | Recommended | Why |
|---|---|---|
| Go (pgx), Node (node-postgres), JVM (pgJDBC) | sslmode=verify-full | These use the runtime’s system trust store — it works with zero setup |
| libpq family (psql, psycopg, SQLAlchemy) | sslmode=verify-full sslrootcert=system | libpq does not read the OS trust store; it only looks at ~/.postgresql/root.crt unless told otherwise |
sslrootcert=system needs libpq/psql 16+. On older clients point it at the
system bundle instead (for example /etc/ssl/cert.pem on macOS). Don’t pin our
specific certificate — it rotates roughly every 60 days; the public chain is the
contract.
Credentials — one story, every wire
Every adapter accepts three credential forms, dispatched by shape at the gateway:
- an API key — issue one on the bucket’s API Keys page; the id is
dk_…-prefixed, the secret is shown once at issue time. - a service account —
client_id/client_secret, for headless/CI use (also usable viadodil auth login --service-account-id … --service-account-secret …). - a bearer JWT — a short-lived token from
dodil auth login/ the OAuth client-credentials flow.
How each rides per wire:
| Wire | API key | Service account | Bearer JWT |
|---|---|---|---|
| Postgres, Bolt | user = dk_… id, password = secret | user = SA client_id, password = client_secret | any user, password = the JWT |
| GraphQL, Qdrant, Pinecone (HTTP) | Authorization: Basic base64(dk_id:secret) — or Bearer dk_… full token | Authorization: Basic base64(client_id:client_secret) | Authorization: Bearer <jwt> |
| gRPC (Tables) | api-key metadata dk_id:secret | api-key metadata client_id:client_secret | authorization: Bearer <jwt> metadata |
| S3 (objects) | Authorization: Bearer dk_… — not SigV4 | SigV4 with client_id/client_secret | Authorization: Bearer <jwt> |
An API key cannot SigV4-sign. Only an argon2 hash of the key’s secret is stored, and SigV4 needs the plaintext secret to recompute the HMAC. On the object door an API key rides as a bearer token instead; SigV4 needs a service account’s
client_id/client_secret, or a pre-signed URL.
The dispatch rule is mechanical: a JWT-shaped password/bearer (starts eyJ,
three dot-separated segments) is a token, a dk_-prefixed id is an API key,
anything else is treated as a service account client_id:client_secret pair.
An unrecognized shape is not a distinct error — it falls into the
service-account branch and fails authentication there (Postgres FATAL 28P01,
HTTP 401 with a WWW-Authenticate challenge, Bolt
Neo.ClientError.Security.Unauthorized).
All forms work on every wire: the gateway terminates your credential, enforces that your org owns the bucket, and forwards a short-lived signed session context to the data clusters, so raw credentials never leave the gateway tier — see Auth & Access.
Point a stock client at it
Every snippet below assumes an API key pair in DK_KEY_ID / DK_SECRET and
the bucket kb-prod.
psql
psql "postgresql://$DK_KEY_ID:$DK_SECRET@pg.uk-lon-1.dodil.io:5432/kb-prod?sslmode=require"
# \dt, \d <table>, SELECT, INSERT, COPY … FROM STDIN all work.The /v1/databases/{db}/… form is the canonical Tables REST shape —
/sql/query, /sql/execute, /vector/{table}/{upsert,query,fetch,delete,stats},
/residency/{load,release,state}, /graph/…. The older POST /v1/sql/execute
x-db-idheader form still works and is described in the gateway source as the legacy path-prefix style; prefer the path form.
On Qdrant and Pinecone you send two headers, not one.
api-keycarries the db id — it is a routing key, not a credential — and the credential rides separately inAuthorization. A client configured the way the vendor’s docs describe (api_keyalone) gets a401withWWW-Authenticate: Basic realm="tables-gateway qdrant", Bearer. Drop theapi-keyheader instead and you get400 tables-gateway: missing 'api-key' db header. On GraphQL and the Tables HTTP door the routing header isx-db-id, with the same split.
Wire fidelity — where the emulation diverges
The adapters are frontends, not reimplementations: each one translates the vendor’s wire calls onto the same tables/vector/graph plane every other wire reaches. That buys real drop-in behaviour for the core data-plane calls — and it means the control-plane and tuning surfaces of the original product either do not exist or answer with placeholders. This section is the honest list.
Scores are distances, not similarities. Every vector wire reports the plane’s raw KNN distance — cosine distance, L2 distance, or negative inner product — where lower is closer. Qdrant and Pinecone both return similarity scores where higher is better. The result ordering is correct; only the number’s sense is flipped, so a
score_thresholdor ascore > 0.8filter ported from either product selects the wrong end of the list. The same raw distance is what GraphQL’s_scorecarries.
Unsupported is loud, never silent — for filters. Both vector wires reject a filter clause they cannot lower with a
400naming the offending clause, rather than quietly returning unfiltered results. That guarantee covers filters only: unknown fields elsewhere in a request body are dropped by the JSON parser without comment (see each wire below).
Postgres
The most complete wire, and the only one offering prepared statements,
COPY … FROM STDIN, server-side cursors, savepoints and statement_timeout.
The dialect contract lives in
SQL → SQL Compatibility. Adapter-level
divergences a driver notices:
- There are no schemas. The bucket is the only namespace. A leading
public./"public".qualifier is stripped from every statement before it reaches the planner, andCREATE SCHEMA public/SET search_pathare accepted no-ops. A non-publicschema qualifier is not rewritten and will fail to resolve. pg_catalogis emulated, not real. The adapter intercepts the probes drivers and ORMs actually send —version(),current_database(),current_schema,set_config/current_setting,SHOW <guc>for a fixed list,SELECT 1,pg_type(11 known types),pg_namespace(one schema,public),pg_database, andinformation_schema.tables/.columns(synthesized live fromSHOW TABLES+DESCRIBE). Anything else that mentions a catalog table is not intercepted — it falls through to the planner and fails there. The server reports itself asPostgreSQL 16.0 (Dodil Tables adapterd).- Prisma is a named target.
prisma db push/migraterun a fixed describer battery before emitting DDL, and each of its ~12 queries is answered with the exact projected column set it reads by name (extensions, views, enums, sequences, functions, check constraints and foreign keys all answer empty). That is what makes the diff see an empty database and emitCREATE TABLE. SQLAlchemy’screate_allexistence probe, psycopg 3’s per-type resolution probe and Django’sset_config('TimeZone', …)connect statement are handled the same way, each because it otherwise aborted the connection. - No FK or sequence enforcement (
CHECKandUNIQUEindexes are enforced — see Constraints: what is enforced).FOREIGN KEYDDL is accepted unenforced with aNOTICE; the catalog reports no FKs back, so an ORM that round-trips its own migration will see those constraints vanish. - A keyless table’s hidden
_rowidprimary key is hidden from pg metadata so ORM model discovery doesn’t pick up a phantom column — the plane-levelDESCRIBEstill shows it. - A prepared read is executed to learn its result schema. Statement- and
portal-level
Describerun the query (with parameters bound toNULLat prepare time) because there is no local catalog to plan against. Reads are idempotent, so this is safe — but a driver that prepares aggressively issues more plane work than the statement count suggests.
Qdrant & Pinecone (vector wires)
Both are real drop-in frontends for the core calls — create, upsert, search,
delete — while their control-plane and tuning surfaces mostly 404 or answer
with placeholders. The load-bearing traps:
- Scores are raw distances (lower is closer), not the vendors’ similarities — invert any ported threshold (see the blockquote above).
- Qdrant collections usually serve exact scan, not ANN. The HNSW build needs
an integer PK and collections use
VARCHAR, so the index build is silently skipped, andget_collectionreports fabricated default HNSW/optimizer/WAL knobs as if they were live. - Pinecone needs the proprietary
X-Dodil-Indexheader — without it every index collapses onto one table namedvectors;X-Dodil-Metricsets the distance per request. - Qdrant is seven routes, Pinecone is four —
get_collections,fetch,create_index,describe_indexand much else404. Filters are a flat AND of a small operator set; anything outside it is a400.
The full per-route surface — every supported call, what 404s, the exact filter grammar, the three-table/namespace-mangling storage model, the extension headers and the companion-table leak — is on Vector → Wire Compatibility.
GraphQL
POST /graphql only, with x-db-id. The schema is generated per database at
runtime from SHOW TABLES + DESCRIBE + SHOW GRAPHS, and cached for
10 seconds — so new DDL appears within ten seconds, not instantly.
Introspection works, so codegen against a live database does.
Per table t you get t(where limit orderBy desc), input tWhere,
input tInsert, mutation insert_t(objects), and — when t has a vector
column — t_similar(vector topK metric) returning tWithScore. Per graph g
you get g_khop(startId depth), g_neighbors(startId),
g_shortestPath(fromId toId), plus a nested {edges}(depth) field on the node
table’s type and on its WithScore twin. That last part is what makes the
“relational + _score + traversal in one query” claim real — it is one field
selection, batched one rehydrate per level.
- Read-heavy: the only data mutation is
insert_{table}. There is no update, no delete, no upsert.insert_treturns anIntrow count, not the inserted rows. The other two mutations areloadGraph/releaseGraph. - No pagination beyond
limit. Nooffset, no cursors, no Relay connections, nototalCount, no aggregates orcount. whereis a flat AND of per-column comparisons. Operators are_eq,_neq,_gt,_gte,_lt,_lte, and_likeon strings only; booleans get_eq/_neqonly. There is no_and/_or/_not, no_in, no_ilike, and no filtering across a relationship. Vector columns are not filterable — the schema simply has no field for them.- Type coverage is four scalars. Integers →
Int, floats →Float, booleans →Boolean, vectors →[Float!], and everything else —NUMERIC/DECIMAL,DATE,TIMESTAMP,JSON,BYTESand any unknown type — is carried asStringin its exact canonical text form. - Nested traversal is capped at 50 parent rows and needs an integer key
column on the node table. Over the cap the field errors out asking you to
narrow the parent list with
limit/where. - Names that aren’t GraphQL-legal are dropped, not escaped. A table or
column outside
[_A-Za-z][_0-9A-Za-z]*, or any name starting__, is skipped from the schema with a log warning — from the client’s side it simply does not exist. _similarneeds a primary key on the table to rehydrate rows, and targets the table’s first vector column.- No subscriptions, no
@defer/@stream, no batched-operation arrays, noGET /graphql, and no hosted GraphiQL — this is an API endpoint, not a playground.
Bolt / Cypher
Bolt 5.0–5.4 (5.4 preferred) with a 4.4 fallback; 4.3 and below are refused at
the handshake. The Cypher subset, the bolt+s:// scheme requirement and the
supported clauses are documented under
Graph → Cypher. Two adapter limits bite any stock
driver immediately:
- Autocommit only.
BEGIN/COMMIT/ROLLBACKanswer aFAILUREreading “explicit transactions are not supported (v1 — autocommit RUN/PULL only)”. The neo4j drivers’ managed-transaction APIs —execute_query,execute_read,execute_write,begin_transaction— all open a transaction, so they fail. Usesession.run(...). - No query parameters. A non-empty parameter map is refused loudly rather than executed with the parameters ignored; inline the values into the query text.
After any FAILURE the session is in the Bolt failed state and ignores
everything but RESET and GOODBYE, exactly as the protocol specifies.
Resolve endpoints from the CLI
dodil data connect kb-prod # tabled endpoint summary
dodil data connect kb-prod -o psql # a ready psql connection URL
dodil data connect kb-prod -o env # export DATA_PG_URL=… etc.Query from the CLI
# SQL — omit the SQL to drop into a REPL
dodil data sql -b kb-prod "SELECT * FROM events LIMIT 10"
dodil data pg -b kb-prod "SELECT count(*) FROM events" # Postgres-wire transport
# Graph — create with SQL, query with Cypher (--graph/-g names the graph)
dodil data sql -b social "CREATE GRAPH g NODES (people KEY id) EDGES (follows SRC src DST dst)"
dodil data bolt -b social -g g "MATCH (a)-[:knows*1..3]->(b) WHERE id(a)=1 RETURN b"
# Vector — embed first, then KNN over a table's vector column
dodil data vsearch -b kb-prod -t docs --column embedding \
--vector "0.12,0.03,..." --top-k 10dodil data bolt and dodil data vsearch are CLI verbs, not wire clients:
both run over the tables-gateway’s typed gRPC facets (TablesGraph.RunCypher,
TablesVector.QueryVectors), not over the Bolt or Qdrant doors.
The console generates copy-paste snippets for each wire × language, pre-filled with your endpoints and key id — on each bucket’s API Keys tab, under how to connect.
See also
- Conventions — auth, headers, and the error envelope shared by every wire
- Auth & Access — API keys, service accounts, and why one credential works on every wire
- Vector → Wire Compatibility — the Qdrant / Pinecone adapter surface in full
- Storage → S3 Compatibility — the S3 byte-plane door and its SigV4 rules