Skip to Content
We are live but in Staging 🎉
Connect & Adapters

Connect & wire adapters

DataK³ splits into two planes:

  • Control plane — HTTP/JSON on api.data.dodil.io, gRPC on rpc.data.dodil.io:443: buckets, pipelines, collections, keys, admin.
  • Data planewire 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

WireEndpointClient examples
Postgrespg.uk-lon-1.dodil.io:5432psql, pg, psycopg, pgx, Prisma
S3 (SigV4)https://object.uk-lon-1.dodil.ioAWS CLI, boto3, rclone
Bolt (Cypher)bolt+s://bolt.uk-lon-1.dodil.io:7687neo4j drivers, cypher-shell
Qdranthttps://qdrant.uk-lon-1.dodil.ioqdrant-client
Pineconehttps://pinecone.uk-lon-1.dodil.iopinecone SDKs
GraphQLhttps://gql.uk-lon-1.dodil.io/graphqlany HTTP client
HTTP (Tables)https://table.uk-lon-1.dodil.ioany HTTP client
gRPC (Tables)table-rpc.uk-lon-1.dodil.io:443generated 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=require as the floor. That is what dodil data connect emits. Do not use prefer: 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, prefer verify-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. Plain bolt:// still connects but is unencrypted — keep it for local-dev or an explicitly insecure example. neo4j+s:// and neo4j:// 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-instance bolt schemes.

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:

ClientRecommendedWhy
Go (pgx), Node (node-postgres), JVM (pgJDBC)sslmode=verify-fullThese use the runtime’s system trust store — it works with zero setup
libpq family (psql, psycopg, SQLAlchemy)sslmode=verify-full sslrootcert=systemlibpq 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 accountclient_id / client_secret, for headless/CI use (also usable via dodil 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:

WireAPI keyService accountBearer JWT
Postgres, Boltuser = dk_… id, password = secretuser = SA client_id, password = client_secretany user, password = the JWT
GraphQL, Qdrant, Pinecone (HTTP)Authorization: Basic base64(dk_id:secret) — or Bearer dk_… full tokenAuthorization: Basic base64(client_id:client_secret)Authorization: Bearer <jwt>
gRPC (Tables)api-key metadata dk_id:secretapi-key metadata client_id:client_secretauthorization: Bearer <jwt> metadata
S3 (objects)Authorization: Bearer dk_…not SigV4SigV4 with client_id/client_secretAuthorization: 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 "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-id header 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-key carries the db id — it is a routing key, not a credential — and the credential rides separately in Authorization. A client configured the way the vendor’s docs describe (api_key alone) gets a 401 with WWW-Authenticate: Basic realm="tables-gateway qdrant", Bearer. Drop the api-key header instead and you get 400 tables-gateway: missing 'api-key' db header. On GraphQL and the Tables HTTP door the routing header is x-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_threshold or a score > 0.8 filter ported from either product selects the wrong end of the list. The same raw distance is what GraphQL’s _score carries.

Unsupported is loud, never silent — for filters. Both vector wires reject a filter clause they cannot lower with a 400 naming 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, and CREATE SCHEMA public / SET search_path are accepted no-ops. A non-public schema qualifier is not rewritten and will fail to resolve.
  • pg_catalog is 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, and information_schema.tables / .columns (synthesized live from SHOW 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 as PostgreSQL 16.0 (Dodil Tables adapterd).
  • Prisma is a named target. prisma db push / migrate run 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 emit CREATE TABLE. SQLAlchemy’s create_all existence probe, psycopg 3’s per-type resolution probe and Django’s set_config('TimeZone', …) connect statement are handled the same way, each because it otherwise aborted the connection.
  • No FK or sequence enforcement (CHECK and UNIQUE indexes are enforced — see Constraints: what is enforced). FOREIGN KEY DDL is accepted unenforced with a NOTICE; 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 _rowid primary key is hidden from pg metadata so ORM model discovery doesn’t pick up a phantom column — the plane-level DESCRIBE still shows it.
  • A prepared read is executed to learn its result schema. Statement- and portal-level Describe run the query (with parameters bound to NULL at 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, and get_collection reports fabricated default HNSW/optimizer/WAL knobs as if they were live.
  • Pinecone needs the proprietary X-Dodil-Index header — without it every index collapses onto one table named vectors; X-Dodil-Metric sets the distance per request.
  • Qdrant is seven routes, Pinecone is fourget_collections, fetch, create_index, describe_index and much else 404. Filters are a flat AND of a small operator set; anything outside it is a 400.

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_t returns an Int row count, not the inserted rows. The other two mutations are loadGraph / releaseGraph.
  • No pagination beyond limit. No offset, no cursors, no Relay connections, no totalCount, no aggregates or count.
  • where is a flat AND of per-column comparisons. Operators are _eq, _neq, _gt, _gte, _lt, _lte, and _like on strings only; booleans get _eq / _neq only. 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 elseNUMERIC/DECIMAL, DATE, TIMESTAMP, JSON, BYTES and any unknown type — is carried as String in 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.
  • _similar needs 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, no GET /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 / ROLLBACK answer a FAILURE reading “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. Use session.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 10

dodil 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