The problem — and why it matters

Your data is spread across four vendors because no single one did every job: an object store (S3/MinIO) for files and raw data, Postgres for rows, Pinecone (or Qdrant) for vectors, Neo4j for the graph. That is four bills, four sets of credentials, and four copies of the same records — plus the sync jobs whose only purpose is to stop those copies from drifting. Add one field and you change a schema in one system and an ETL job in three others; every question that needs a file and rows and similarity and relationships has to fan out across all four and stitch the answers back together in app code.

DataK3 collapses the four into one bucket — one copy of the rows, one place for the files, four access surfaces:

  • object storage — the bucket is an S3-style object store; raw files live at raw/… keys alongside the tables (the data object API / a presigned object.uk-lon-1.dodil.io/<bucket>/<key> URL any HTTP client can fetch),
  • a Postgres endpoint (pg.uk-lon-1.dodil.io:5432) — the real Postgres wire protocol,
  • pgvector on that same endpoint — <=> KNN, no separate vector service,
  • a Bolt endpoint (bolt.uk-lon-1.dodil.io:7687) — the Neo4j wire protocol.

And the consolidation is free at the driver layer — the point of this page. You do not rewrite your app or adopt a DODIL SDK: you keep psql, sqlx, diesel, psycopg, asyncpg, node-postgres, and your Neo4j driver exactly as they are, point them at fixed, region-derived endpoints (no discovery call), keep raw files in the same bucket via the object API, and retire the other three stores — and the sync jobs with them. One bucket your existing clients already speak to, one copy of the rows: no export, no proprietary SDK, no lock-in.

What you'll prove: define a schema, some vectors, and a small graph once (with your agent or the CLI), then read those identical rows back from six driver stackspsql, Rust sqlx + diesel, Python psycopg + asyncpg, TypeScript node-postgres, and a Neo4j driver — plus the pgvector <=> KNN path and Bolt/Cypher traversal, all against one bucket.

Prerequisites

  • The dodil CLI (dodil auth login) and, optionally, an agent over MCP — the define steps are one DODIL tool call each; the query steps are your own language's driver.
  • Whatever drivers you already use. Nothing DODIL-specific to install: these are the stock Postgres/Bolt clients from each ecosystem.

Set the bucket name once. The credential is your login (or service-account) token, used as the Postgres password; the Postgres user is the literal string token, and the database name is the bucket.

export BUCKET=toolbox

Step 1 — The endpoints are fixed constants (no discovery call)

A DataK3 bucket's endpoints are derived from its region — there is nothing to run to learn them, and nothing DODIL-specific for your app to call. Create the bucket, then point any driver at the known host: database name = the bucket, user = the literal token, password = your login (or service-account) token.

You

Create a private bucket called $BUCKET that I can point my own Postgres, pgvector, Bolt, and object clients at.

DODIL MCP tools called
data_bucket_create
Agent

Bucket toolbox created (private). Reach it at the fixed region uk-lon-1 endpoints — pg pg.uk-lon-1.dodil.io:5432, bolt bolt+s://bolt.uk-lon-1.dodil.io:7687, objects object.uk-lon-1.dodil.io/toolbox/<key>. DB name = toolbox; Postgres user = token; password = your login/SA token. Both wires are TLS: sslmode=require and bolt+s://.

Read the Postgres URL closely — it's the whole contract for every driver below:

postgresql://token:[email protected]:5432/toolbox?sslmode=require
             ^^^^^ ^^^^^^^^^^^^  ^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^  ^^^^^^^ ^^^^^^^^^^^^^^^
             user  password      host                    port  db      TLS mode
             (literal) (your token) (region uk-lon-1)            (=bucket)
  • User is the literal token. Password is your bearer token. For local dev, your dodil auth login token works as the password. For app or CI code, mint a service-account token and use it as the password (the same client-credentials token DODIL handlers already carry):
# app/CI: a service account scoped to k3.editor, then a client-credentials access token
dodil auth service-account create toolbox-app
dodil auth service-account grant-role toolbox-app --service k3-authorization-service --role k3.editor
# mint an access token (client_credentials) and export it as the DB password:
export DODIL_TOKEN=$(curl -s https://id.dodil.io/realms/dodil/protocol/openid-connect/token \
  -d grant_type=client_credentials \
  -d client_id="$DODIL_SERVICE_ACCOUNT_ID" -d client_secret="$DODIL_SERVICE_ACCOUNT_SECRET" \
  | python3 -c 'import sys,json; print(json.load(sys.stdin)["access_token"])')
  • sslmode=require is the floor — never prefer. The wire carries TLS (a public Let's Encrypt certificate, terminated at the region gateway). That matters more here than on a normal Postgres, because the password on this wire is a bearer token — in an app, a service-account secret that reaches every bucket in the org. prefer means "TLS if offered, plaintext otherwise", and it falls back silently: one misconfigured hop and that credential crosses the network in the clear with nothing in the logs to tell you. require refuses to connect rather than downgrade.

  • verify-full is the production posture, and it is not the same thing. require encrypts but does not check who answered; verify-full validates the certificate chain and hostname, which is what actually defeats an active man-in-the-middle. Support is uneven, so be precise:

    • Go (pgx), Node (node-postgres), JVM (pgJDBC), Python asyncpg use the runtime's own trust store — verify-full works with zero setup.
    • The libpq family (psql, psycopg, SQLAlchemy) does not read the OS trust store — only ~/.postgresql/root.crt. A bare sslmode=verify-full fails out of the box with root certificate file … does not exist. Point it at a real bundle: sslrootcert=/etc/ssl/cert.pem (macOS), your distro's ca-certificates, or certifi.where() from Python. sslrootcert=system works on libpq 16+ when that OpenSSL build has a system store configured — it is not universal, so don't rely on it in a copy-paste snippet.

    Don't pin our certificate: it rotates roughly every 60 days. The public chain is the contract.

Step 1b — Raw files live in the same bucket (objects)

The bucket is also an S3-style object store: raw files — PDFs, images, exports, model artifacts — sit at raw/… keys next to the tables, under one credential and one bill. Objects use the dodil data object API (or a presigned URL any HTTP client can fetch); the queryable rows use the wires above. No second blob bucket to provision, no cross-store sync to keep a file and its row in step.

You

Upload ./invoice.pdf to $BUCKET as raw/invoice.pdf, list the objects, and give me a 10-minute presigned URL I can fetch with curl.

DODIL MCP tools called
data_object_createdata_object_listdata_object_url
Agent

Uploaded raw/invoice.pdf. objects: raw/invoice.pdf. Presigned URL (expires 600s): https://object.uk-lon-1.dodil.io/toolbox/raw/invoice.pdf?X-K3-Token=…&X-K3-Expires=…&X-K3-Org=… — any HTTP client can GET it, no SDK.

NOTE

Objects vs tables — one bucket, two shapes. Raw/immutable data (uploads, exports, audit blobs) → objects at raw/…. Queryable rows → tables, over the wires above. The object write/list/presign is the one place here that's a dodil call rather than a stock driver; the read side is a plain presigned https:// GET from any client.

Step 2 — Define the data once (agent or CLI)

Define three things over the bucket: a relational table, a table with a VECTOR column, and a small graph built from a node table + an edge table. This is the only place we "author" — every driver afterwards just reads these same rows. The SQL below goes over the Postgres wire (dodil data pg), so it's the identical DDL your psql session would run.

You

In $BUCKET, create service_catalog(id,name,plane,region), doc_embeddings(id,title,embedding VECTOR(4)) and a small graph: svc_node + svc_edge, then CREATE GRAPH svc_graph. Insert the three services, three doc vectors, and the runs_on/reads edges.

DODIL MCP tools called
data_pgdata_table_upsert
Agent

service_catalog: 3 rows (k3/ignite/models). doc_embeddings: 3 rows with VECTOR(4) embeddings. svc_node: 4 nodes (3 services + region uk-lon-1); svc_edge: 5 edges (runs_on, reads). CREATE GRAPH svc_graph NODES(svc_node KEY id) EDGES(svc_edge SRC src DST dst) — edges snapshotted. Rows are read-your-writes immediately across SQL, vector KNN, and graph.

NOTE

Two DataK3 rules the drivers inherit. (1) Every table needs a PRIMARY KEY — writes are keyed, so re-runs are idempotent (dodil data table upsert is the managed keyed write if you'd rather not hand-write SQL). (2) CREATE GRAPH snapshots its edges — populate svc_node/svc_edge fully before creating svc_graph; edges you add later aren't traversed until you DROP GRAPH + re-create. Both hold no matter which driver does the writing.

Connect your tools

This is the whole point. Below, the same three tables read back from six driver stacks. Every connection string is the Step-1 URL; every query is ordinary SQL, pgvector, or Cypher. Nothing here imports a DODIL SDK.

psql — the Postgres CLI

The plainest proof that it's real Postgres wire. Paste the Postgres URL from Step 1 and go:

PGURL="postgresql://token:$DODIL_TOKEN@pg.uk-lon-1.dodil.io:5432/$BUCKET?sslmode=require"
 
# stronger: also authenticate the server. libpq needs the bundle named explicitly —
#   ?sslmode=verify-full&sslrootcert=/etc/ssl/cert.pem   (macOS; your distro's CA path on Linux)
 
psql "$PGURL" -c "SELECT version();"
#  PostgreSQL 16.0 (Dodil Tables adapterd) on x86_64, compiled by rustc, 64-bit
 
psql "$PGURL" -c "SELECT id, name, plane FROM service_catalog ORDER BY id;"
#  1 | DataK3 | data
#  2 | Ignite | compute
#  3 | Models | inference
 
# pgvector KNN over the SAME connection — nearest doc to a query vector
psql "$PGURL" -tAc \
  "SELECT id, title FROM doc_embeddings ORDER BY embedding <=> '[0.10,0.20,0.30,0.40]' LIMIT 3;"
#  1|sql over wire   (cosine <=> and Euclidean <-> both work)

Rust — sqlx (async)

sqlx speaks the Postgres protocol directly, and it reads sslmode from the URL — with the tls-native-tls (or tls-rustls) feature it uses the platform trust store, so require (and verify-full) need no extra TLS config.

// Cargo.toml: sqlx = { version = "0.8", features = ["runtime-tokio", "postgres"] }
use sqlx::postgres::PgPoolOptions;
 
#[derive(sqlx::FromRow, Debug)]
struct Service { id: i64, name: String, plane: String }
 
#[tokio::main]
async fn main() -> Result<(), sqlx::Error> {
    let url = std::env::var("PGURL").unwrap();  // the Step-1 URL, ?sslmode=require
    let pool = PgPoolOptions::new().max_connections(4).connect(&url).await?;
 
    let rows = sqlx::query_as::<_, Service>(
        "SELECT id, name, plane FROM service_catalog ORDER BY id")
        .fetch_all(&pool).await?;
    for s in &rows { println!("{s:?}"); }
 
    // pgvector KNN — bind the query vector as a text literal, cast to vector on the server
    let near: Vec<(i64, String)> = sqlx::query_as(
        "SELECT id, title FROM doc_embeddings ORDER BY embedding <=> $1::vector LIMIT 3")
        .bind("[0.10,0.20,0.30,0.40]")
        .fetch_all(&pool).await?;
    println!("nearest: {near:?}");
    Ok(())
}

For typed vectors instead of a text literal, add the pgvector crate (pgvector = { version = "0.4", features = ["sqlx"] }) and bind a pgvector::Vector.

Rust — diesel (ORM)

Same URL, Diesel's PgConnection:

// Cargo.toml: diesel = { version = "2", features = ["postgres"] }
use diesel::prelude::*;
 
table! { service_catalog (id) { id -> BigInt, name -> Text, plane -> Text, region -> Text } }
 
#[derive(Queryable, Debug)]
struct Service { id: i64, name: String, plane: String, region: String }
 
fn main() -> QueryResult<()> {
    let url = std::env::var("PGURL").unwrap();       // ?sslmode=require
    let mut conn = PgConnection::establish(&url).expect("connect toolbox");
 
    use service_catalog::dsl::*;
    let rows: Vec<Service> = service_catalog.order(id.asc()).load(&mut conn)?;
    for s in &rows { println!("{s:?}"); }
 
    // pgvector via diesel: run the KNN as raw SQL (Diesel has no native vector type)
    let near: Vec<(i64, String)> = diesel::sql_query(
        "SELECT id, title FROM doc_embeddings ORDER BY embedding <=> '[0.10,0.20,0.30,0.40]' LIMIT 3")
        .load::<(i64, String)>(&mut conn)?;   // via a QueryableByName row type
    println!("nearest: {near:?}");
    Ok(())
}

Python — psycopg (v3)

The pgvector query vector binds as an ordinary text parameter — psycopg's text protocol hands it to the server as-is:

# pip install psycopg
 
 
with psycopg.connect(os.environ["PGURL"]) as conn:      # ?sslmode=require → TLS, no fallback
    with conn.cursor() as cur:
        cur.execute("SELECT id, name, plane FROM service_catalog ORDER BY id")
        print(cur.fetchall())
        # → [(1,'DataK3','data'), (2,'Ignite','compute'), (3,'Models','inference')]
 
        cur.execute(
            "SELECT id, title FROM doc_embeddings ORDER BY embedding <=> %s LIMIT 3",
            ("[0.10,0.20,0.30,0.40]",))
        print(cur.fetchall())      # → [(1,'sql over wire'), (3,'graph traverse'), (2,'vector knn')]

Python — asyncpg

One asyncpg-specific adjustment remains: for the vector KNN inline the literal rather than binding it — asyncpg's binary/prepared path introspects the vector type and trips on it. Scalar binds ($1 for an int/text) are fine.

TLS needs nothing special. asyncpg reads sslmode from the URL, and ssl=True gives you a fully verifying context off Python's own trust store — so this driver gets verify-full behaviour for free, unlike its libpq cousins. (An older version of this page told you to pass ssl=False; that was correct only while the wire had no TLS, and it is now wrong.)

# pip install asyncpg
 
 
async def main():
    conn = await asyncpg.connect(os.environ["PGURL"])   # ?sslmode=require — TLS, no fallback
    # or, verifying the server as well:  await asyncpg.connect(url, ssl=True)
 
    rows = await conn.fetch("SELECT id, name, plane FROM service_catalog ORDER BY id")
    print([dict(r) for r in rows])
 
    # scalar binds work; for the vector, format the literal into the SQL:
    qv = "[0.10,0.20,0.30,0.40]"
    near = await conn.fetch(
        f"SELECT id, title FROM doc_embeddings ORDER BY embedding <=> '{qv}' LIMIT 3")
    print([dict(r) for r in near])
    await conn.close()
 
asyncio.run(main())

TypeScript — node-postgres (pg)

node-postgres uses Node's own CA bundle, so TLS is on by default and verifies the server with no configuration. The vector binds as a text parameter like psycopg:

// npm install pg
 
 
// ?sslmode=require in the URL; Node verifies the chain against its built-in roots.
const client = new pg.Client({ connectionString: process.env.PGURL });
await client.connect();
 
const cat = await client.query("SELECT id, name, plane FROM service_catalog ORDER BY id");
console.log(cat.rows);   // [{id:'1',name:'DataK3',…}, …]
 
const knn = await client.query(
  "SELECT id, title FROM doc_embeddings ORDER BY embedding <=> $1 LIMIT 3",
  ["[0.10,0.20,0.30,0.40]"]);
console.log(knn.rows);   // [{id:'1',title:'sql over wire'}, …]
 
await client.end();

The vector pillar — pgvector native, or your Qdrant / Pinecone client

The vector surface is pgvector on the same Postgres endpoint — no separate vector host, no discovery call. Any driver above does KNN with the <=> (cosine), <-> (Euclidean), or <#> (dot) operators against a VECTOR(n) column — the same rows your SQL SELECT reads. If you'd rather not embed client-side, dodil data vsearch (agent/CLI) embeds your query text with DODIL Models and runs the identical KNN:

You

In $BUCKET, find the doc_embeddings row nearest to the vector [0.10,0.20,0.30,0.40].

DODIL MCP tools called
data_vsearch
Agent

Top-3 by cosine: id 1 'sql over wire' (0.0), id 3 'graph traverse' (0.00065), id 2 'vector knn' (0.732) — identical to the pgvector <=> query the drivers run over the wire.

Already run a Qdrant or Pinecone client? Keep it. You don't have to rewrite to the pgvector idiom. DataK3 also speaks Qdrant's and Pinecone's own wire protocols over the same rows — a Qdrant collection and a Pinecone index each are a DataK3 table (id … PRIMARY KEY, vector vector(n)). A row you upsert through a Qdrant or Pinecone client is, that instant, a data sql row and a pgvector <=> candidate — one copy, three protocols. So you swap two things in your existing code — the endpoint and the api-key — and keep the rest. Two things to know before the snippets:

  • Endpoint. These aren't the pg/bolt hosts — the DataK3 vector gateways ride the same region domain as fixed constants: https://qdrant.uk-lon-1.dodil.io and https://pinecone.uk-lon-1.dodil.io (both on :443).
  • Auth is split. The api-key (Qdrant) / Api-Key (Pinecone) header carries your bucket (it selects the database), and your token rides a standard Authorization: Bearer header — the same login/SA token you use as the pg password. (On a bare-metal adapter the api-key doubles as the token; the DataK3 gateway separates them.) A missing key is a 400 missing 'api-key' db header; a bad token is 401.

Qdrant client — qdrant-client, same rows

The collection is a DataK3 table (id VARCHAR PRIMARY KEY, vector vector(n)); the metric you create it with is remembered and reported back. Set api_key to your bucket and hand the token to auth_token_provider (it becomes the Authorization: Bearer):

# pip install qdrant-client
 
from qdrant_client import QdrantClient
from qdrant_client.http import models as qm
 
BUCKET = os.environ["BUCKET"]
TOKEN  = os.environ["DODIL_TOKEN"]                     # same token as the pg password
QURL   = "https://qdrant.uk-lon-1.dodil.io:443"        # DataK3 Qdrant gateway (uk-lon-1)
 
qc = QdrantClient(url=QURL,
                  api_key=BUCKET,                       # api-key header = your bucket (db)
                  auth_token_provider=lambda: TOKEN,    # → Authorization: Bearer <token>
                  check_compatibility=False)
 
# a "collection" IS the DataK3 table (id VARCHAR PK, vector vector(4))
qc.create_collection("doc_vectors",
    vectors_config=qm.VectorParams(size=4, distance=qm.Distance.COSINE))
qc.upsert("doc_vectors", points=[
    qm.PointStruct(id=1, vector=[0.10, 0.20, 0.30, 0.40]),
    qm.PointStruct(id=2, vector=[0.90, 0.10, 0.05, 0.02]),
    qm.PointStruct(id=3, vector=[0.11, 0.19, 0.31, 0.39])])
print(qc.retrieve("doc_vectors", ids=[1], with_vectors=True)[0].vector)  # [0.1, 0.2, 0.3, 0.4]
 
# recent qdrant-client dropped the .search() helper; search the endpoint the gateway
# serves (POST /collections/<name>/points/search) directly:
hits = requests.post(f"{QURL}/collections/doc_vectors/points/search",
    headers={"api-key": BUCKET, "authorization": f"Bearer {TOKEN}"},
    json={"vector": [0.10, 0.20, 0.30, 0.40], "limit": 3}).json()["result"]
print([(h["id"], round(h["score"], 5)) for h in hits])
# → [(1, 0.0), (3, 0.00065), (2, 0.73232)]  — raw pgvector distances (asc, lower = closer)

The score is the raw pgvector distance, not Qdrant's similarity — identical to the numbers the psql/data vsearch paths above returned (0.0, 0.00065, 0.732), because it is the same <=> over the same rows. Read them straight back over SQL to prove it: psql "$PGURL" -c "SELECT id, vector FROM doc_vectors ORDER BY id;".

Pinecone client — pinecone, same rows

A Pinecone index is a DataK3 table (id VARCHAR PRIMARY KEY, values vector(n)), auto-created on first upsert (dimension taken from the first vector). Both headers ride the top-level client's additional_headers — the bearer token and the X-Dodil-Index header that names the table (default vectors; X-Dodil-Metric picks cosine/euclidean/dotproduct):

# pip install pinecone
 
from pinecone import Pinecone
 
BUCKET = os.environ["BUCKET"]
TOKEN  = os.environ["DODIL_TOKEN"]
 
pc = Pinecone(api_key=BUCKET,                          # Api-Key header = your bucket (db)
              additional_headers={
                  "Authorization": f"Bearer {TOKEN}",  # bearer credential
                  "X-Dodil-Index": "doc_vectors"})     # index = this DataK3 table
idx = pc.Index(host="https://pinecone.uk-lon-1.dodil.io")   # DataK3 Pinecone gateway
 
idx.upsert(vectors=[
    {"id": "v1", "values": [0.10, 0.20, 0.30, 0.40]},
    {"id": "v2", "values": [0.90, 0.10, 0.05, 0.02]},
    {"id": "v3", "values": [0.11, 0.19, 0.31, 0.39]}])
 
res = idx.query(vector=[0.10, 0.20, 0.30, 0.40], top_k=3)
print([(m["id"], round(m["score"], 5)) for m in res["matches"]])
# → [('v1', 0.0), ('v3', 0.00065), ('v2', 0.73232)]  — raw pgvector distances
print(idx.describe_index_stats()["total_vector_count"])   # → 3

Same story as Qdrant: the rows land in a DataK3 table you can immediately read over psql/data sql and rank with pgvector <=>SELECT id, values <=> '[0.10,0.20,0.30,0.40]' AS score FROM doc_vectors ORDER BY score LIMIT 3 returns the identical v1, v3, v2 with the identical distances. One copy of your vectors; Qdrant, Pinecone, pgvector, and data sql are four doors to it.

The graph pillar — Bolt / Cypher

The same bucket answers the Neo4j wire protocol. The graph is svc_graph (from Step 2); you traverse it in Cypher over Bolt, or with graph_neighbors() in SQL over the same Postgres connection — one query can even join the traversal back to your tables.

Use the bolt+s:// scheme, not neo4j+s://. Both mean TLS, but neo4j+s also asks for the routing protocol — a cluster-discovery handshake this plane does not implement. It fails immediately with routing is not supported (connect with bolt://). bolt+s is TLS-on-connect against the single endpoint, and the official drivers verify the certificate against public roots by default; bolt+ssc (skip verification) belongs only in local self-signed setups.

# cypher-shell — nodes within 2 hops of Ignite (node id 2)
cypher-shell -a bolt+s://bolt.uk-lon-1.dodil.io:7687 -u token -p "$DODIL_TOKEN" -d "$BUCKET" \
  "MATCH (a)-[*1..2]->(b) WHERE id(a)=2 RETURN b"
#  b: 1 (DataK3), 10 (uk-lon-1)

A Neo4j driver points at the same endpoint — the graph name is the database, node keys come back as the BIGINT ids from svc_node:

# pip install neo4j
from neo4j import GraphDatabase
 
 
drv = GraphDatabase.driver("bolt+s://bolt.uk-lon-1.dodil.io:7687",
                           auth=("token", os.environ["DODIL_TOKEN"]))
with drv.session(database=os.environ["BUCKET"]) as s:
    for rec in s.run("MATCH (a)-[*1..2]->(b) WHERE id(a)=2 RETURN b"):
        print(rec.data())      # {'b': 10}, {'b': 1}
drv.close()

And the same traversal in SQL, joined straight to svc_node for names — no second query, no second store:

psql "$PGURL" -tAc \
  "SELECT n.name, n.kind FROM graph_neighbors('svc_graph', 2) g JOIN svc_node n ON n.id = g.neighbor;"
#  DataK3   | service
#  uk-lon-1 | region

TIP

Cypher here is a subset. Anchor a traversal with WHERE id(a) = <key> (the node's BIGINT key), not with inline property maps like (a {name:'…'}) — those are rejected. Match on properties by joining the node table in SQL, or filter on the returned keys.

Test

Run live against DODIL on 2026-09-02 (org IHDIASH, throwaway buckets blogtools-probe for the driver paths and vecprobe0902 for the Qdrant/Pinecone frontends, both torn down after). What executed live vs. authored from the verified wire facts:

Executed live — round-trips proven end to end:

# the wire is real Postgres
psql "$PGURL" -c "SELECT version();"
#  → PostgreSQL 16.0 (Dodil Tables adapterd) on x86_64, compiled by rustc, 64-bit
 
# CREATE TABLE + INSERT + SELECT over the wire (psql)               → 3 rows back
# VECTOR(4) column + <=> (cosine) and <-> (Euclidean) KNN over psql → ranked 1,3,2
# CREATE GRAPH + graph_neighbors('svc_graph',2) joined to svc_node  → DataK3, uk-lon-1
# MATCH (a)-[*1..2]->(b) WHERE id(a)=2 over the Bolt engine         → nodes 10, 1
# psycopg (v3):      SELECT + pgvector <=> bound param              → 3 rows, ranked 1,3,2
# asyncpg:           SELECT + scalar bind live; vector via inline literal (see note)
# node-postgres (8): SELECT + pgvector <=> bound param              → 3 rows, ranked 1,3,2
# cross-transport:   dodil data sql (gRPC) + data vsearch (agent)   → identical rows/scores
 
# Qdrant frontend  https://qdrant.uk-lon-1.dodil.io:443 (qdrant-client 1.18):
#   create_collection + upsert + retrieve via the client; search via raw REST
#   POST /collections/<n>/points/search                             → 1,3,2 @ 0.0, 0.00065, 0.73232
# Pinecone frontend https://pinecone.uk-lon-1.dodil.io:443 (pinecone 9.1):
#   idx.upsert + idx.query + describe_index_stats                   → v1,v3,v2 @ same scores; count 3
# same-rows proof: rows written via the Qdrant/Pinecone frontends read straight back
#   over data sql / pgvector <=> (SELECT … FROM <collection|index>) → identical rows + scores

Eight wire paths confirmed against one bucket: psql, psycopg, asyncpg, node-postgres, the Bolt/Cypher engine, the gRPC/vsearch path, and the Qdrant and Pinecone vector frontends — all reading the same rows, with pgvector <=> scores matching to the digit across psql, psycopg, node-postgres, data vsearch, Qdrant, and Pinecone (0.0, 0.00065, 0.732).

The vector frontends are live — validated end to end this run. Both resolve on the region domain (qdrant.uk-lon-1.dodil.io / pinecone.uk-lon-1.dodil.io, :443) and are fronted by the DataK3 tables-gateway. Auth is split exactly as the snippets show — api-key/Api-Key header = the bucket, token on Authorization: Bearer (a missing key is 400 missing 'api-key' db header; the bucket alone without the bearer is 401). Round-trips run with the stock clients: qdrant-client create_collection + upsert + retrieve (the 1.18 client has dropped .search(), so search went over raw REST — noted in the snippet), and the pinecone SDK's upsert + query + describe_index_stats. The clincher for "same rows": a SELECT id, vector FROM <collection> and a pgvector <=> KNN over the Pinecone-written index both returned the identical rows and distances the REST calls did — one copy, four doors.

Authored from the verified connection facts (endpoint, protocol, and auth all confirmed live above; the toolchains just weren't in the test sandbox): the Rust sqlx and diesel snippets, and cypher-shell.

TLS update — 2026-09-09. Both wires now carry TLS, and the guidance above was rewritten against a live re-check rather than edited on faith:

  • pg: sslmode=require connects (TLS 1.3); the certificate is a public Let's Encrypt chain covering pg.uk-lon-1.dodil.io and bolt.uk-lon-1.dodil.io. With psycopg, verify-full succeeds when sslrootcert names a real bundle and fails with sslrootcert=system on a macOS/conda OpenSSL — which is why this page names the bundle explicitly instead of recommending system.
  • bolt: bolt+s:// completes the TLS handshake and authenticates; neo4j+s:// is rejected at the protocol level with "routing is not supported (connect with bolt://)" — it never worked here, TLS or not.
  • asyncpg connects over TLS with the URL's sslmode=require and with ssl=True (its default context verifies). The old ssl=False advice is obsolete and has been removed.
  • One trap while the CLI catches up: dodil data connect still prints the pre-TLS forms — a DSN ending ?sslmode=prefer and DATA_BOLT_URI="neo4j+s://…". Pasting them gets you a silent plaintext session and a Bolt URI that cannot connect at all. Use the forms on this page until the next CLI release ships the fix.

One driver gotcha survives, reproduced live:

  • asyncpg + pgvector param: binding the query vector as $1 trips asyncpg's type introspection on the vector type. Inline the literal into the SQL (shown above); scalar $1 binds for ints/text work fine. psycopg and node-postgres bind the vector param without issue.

Durability probe. DataK3 had a known issue where freshly written rows were visible for ~15–30s and then vanished from scans. It is resolved as of this run: rows written over the wire were re-queried at 192s and again at ~340s after insert and every row persisted (service_catalog 3, doc_embeddings 3, svc_node 4), confirmed across both the Postgres wire and the gRPC transport. Writes are durable.

Teardown. The probe bucket was deleted (dodil data bucket delete, confirmed absent from data bucket list); no service account was created for the validation (it ran on the live user session). In your own build, tear down with:

dodil data bucket delete "$BUCKET"
dodil auth service-account delete toolbox-app   # only if you minted one for app/CI auth

One-shot

Point any object / Postgres / pgvector / Bolt client at one DataK3 bucket — no export, no second store:
1. dodil data bucket create $BUCKET. Endpoints are FIXED constants for region uk-lon-1 (no
   `data connect`): DSN = postgresql://token:[email protected]:5432/$BUCKET?sslmode=require
   (user=token, password=login/SA token, db=bucket); bolt bolt+s://bolt.uk-lon-1.dodil.io:7687;
   objects https://object.uk-lon-1.dodil.io/$BUCKET/<key>. For app/CI, mint an SA k3.editor
   client_credentials token and use it as the password.
1b. Raw files → objects in the SAME bucket: dodil data object create ./f.pdf --key raw/f.pdf -b $BUCKET;
   dodil data object list/show -b $BUCKET; dodil data object url raw/f.pdf -b $BUCKET → a presigned
   https URL any HTTP client can GET (no SDK).
2. Define once (agent or `dodil data pg`): service_catalog (id PK,…); doc_embeddings
   (id PK,…, embedding VECTOR(4)); svc_node + svc_edge then CREATE GRAPH svc_graph
   NODES(svc_node KEY id) EDGES(svc_edge SRC src DST dst). Populate edges BEFORE CREATE GRAPH.
3. Query the SAME rows from your stack: psql; Rust sqlx/diesel; Python psycopg/asyncpg;
   TS node-postgres. SQL SELECT + pgvector `embedding <=> '[…]'` KNN over the one pg URL.
4. Graph: cypher-shell / a Neo4j driver at bolt+s://bolt.uk-lon-1.dodil.io:7687 (database=$BUCKET),
   `MATCH (a)-[*1..2]->(b) WHERE id(a)=<key>`; or graph_neighbors('svc_graph',<key>) JOINed in SQL.
   NEVER neo4j+s:// — that scheme asks for routing, which this plane refuses.
5. TLS: pg sslmode=require is the floor, verify-full the production posture — libpq (psql,
   psycopg, SQLAlchemy) needs an explicit sslrootcert (e.g. /etc/ssl/cert.pem); pgx, node-postgres,
   pgJDBC and asyncpg verify off the runtime trust store with no setup. Bolt: bolt+s:// verifies
   by default. Driver notes: asyncpg must inline the vector literal (scalar binds are fine);
   psql/psycopg/node-pg bind the vector as a text param. Cypher subset: anchor on WHERE id(a)=<key>.
6. Tear down: dodil data bucket delete $BUCKET (+ service-account delete if you made one).

Conclusion

The CLI was never the product — it's one client among many. A DataK3 bucket is a live Postgres endpoint, a Bolt endpoint, and a pgvector surface over one copy of your rows, and your existing drivers connect to all three with a plain connection string. You saw the same three tables read back from psql, sqlx, diesel, psycopg, asyncpg, node-postgres, a Neo4j driver, and pgvector <=> — no export step, no proprietary SDK, and no Pinecone or Neo4j sitting alongside Postgres waiting for a sync job to fail.

Define with your agent, query with your stack. When you're ready to put a service in front of those rows, the ship-a-DODIL-app flagship takes the same bucket from a git repo to a public endpoint — same org, same auth, same bucket.