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 (thedata objectAPI / a presignedobject.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 stacks —
psql, 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
dodilCLI (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=toolboxStep 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.
Create a private bucket called $BUCKET that I can point my own Postgres, pgvector, Bolt, and object clients at.
data_bucket_createBucket 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://.
dodil data bucket create "$BUCKET"
# The endpoints are CONSTANTS for region uk-lon-1 — no lookup, no `data connect`:
# pg: postgresql://token:[email protected]:5432/$BUCKET?sslmode=require
# bolt: bolt+s://bolt.uk-lon-1.dodil.io:7687 (graph name passed per query)
# objects: https://object.uk-lon-1.dodil.io/$BUCKET/<key> (raw files — see Step 1b)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, yourdodil auth logintoken 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=requireis the floor — neverprefer. 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.prefermeans "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.requirerefuses to connect rather than downgrade. -
verify-fullis the production posture, and it is not the same thing.requireencrypts but does not check who answered;verify-fullvalidates 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), Pythonasyncpguse the runtime's own trust store —verify-fullworks with zero setup. - The libpq family (
psql,psycopg, SQLAlchemy) does not read the OS trust store — only~/.postgresql/root.crt. A baresslmode=verify-fullfails out of the box withroot certificate file … does not exist. Point it at a real bundle:sslrootcert=/etc/ssl/cert.pem(macOS), your distro'sca-certificates, orcertifi.where()from Python.sslrootcert=systemworks 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.
- Go (pgx), Node (
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.
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.
data_object_create→data_object_list→data_object_urlUploaded 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.
# upload a raw file as an object — same bucket as the tables
dodil data object create ./invoice.pdf --key raw/invoice.pdf -b "$BUCKET"
dodil data object list -b "$BUCKET" # raw/invoice.pdf
dodil data object show raw/invoice.pdf -b "$BUCKET"
# a short-lived URL any HTTP client can fetch — no DODIL SDK on the reader side
URL=$(dodil data object url raw/invoice.pdf -b "$BUCKET" --expires 600 -o json \
| python3 -c 'import sys,json; print(json.load(sys.stdin)["url"])')
curl -s "$URL" -o ./invoice.pdf # object.uk-lon-1.dodil.io/<bucket>/<key>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.
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.
data_pg→data_table_upsertservice_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.
# relational
dodil data pg -b "$BUCKET" "CREATE TABLE service_catalog (id BIGINT PRIMARY KEY, name TEXT, plane TEXT, region TEXT)"
dodil data pg -b "$BUCKET" "INSERT INTO service_catalog VALUES
(1,'DataK3','data','uk-lon-1'),(2,'Ignite','compute','uk-lon-1'),(3,'Models','inference','uk-lon-1')"
# vector (VECTOR(4) toy; production embeddings are VECTOR(2048) from Models jina-embeddings-v4 — same wire, longer literal)
dodil data pg -b "$BUCKET" "CREATE TABLE doc_embeddings (id BIGINT PRIMARY KEY, title TEXT, embedding VECTOR(4))"
dodil data pg -b "$BUCKET" "INSERT INTO doc_embeddings VALUES
(1,'sql over wire','[0.10,0.20,0.30,0.40]'),
(2,'vector knn','[0.90,0.10,0.05,0.02]'),
(3,'graph traverse','[0.11,0.19,0.31,0.39]')"
# graph = node table + edge table, then snapshot it
dodil data pg -b "$BUCKET" "CREATE TABLE svc_node (id BIGINT PRIMARY KEY, biz_key TEXT, name TEXT, kind TEXT)"
dodil data pg -b "$BUCKET" "CREATE TABLE svc_edge (id BIGINT PRIMARY KEY, src BIGINT, dst BIGINT, rel TEXT)"
dodil data pg -b "$BUCKET" "INSERT INTO svc_node VALUES
(1,'k3','DataK3','service'),(2,'ignite','Ignite','service'),(3,'models','Models','service'),(10,'uk-lon-1','uk-lon-1','region')"
dodil data pg -b "$BUCKET" "INSERT INTO svc_edge VALUES
(100,1,10,'runs_on'),(101,2,10,'runs_on'),(102,3,10,'runs_on'),(103,2,1,'reads'),(104,3,1,'reads')"
dodil data pg -b "$BUCKET" "CREATE GRAPH svc_graph NODES (svc_node KEY id) EDGES (svc_edge SRC src DST dst)"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:
In $BUCKET, find the doc_embeddings row nearest to the vector [0.10,0.20,0.30,0.40].
data_vsearchTop-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.
dodil data vsearch -b "$BUCKET" --table doc_embeddings --column embedding \
--vector "0.10,0.20,0.30,0.40" --metric cosine --top-k 3
# → 1 (0.0), 3 (0.00065), 2 (0.732) — same ranking, same rowsAlready 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.ioandhttps://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 standardAuthorization: Bearerheader — 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 a400 missing 'api-key' db header; a bad token is401.
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"]) # → 3Same 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 | regionTIP
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 + scoresEight 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=requireconnects (TLS 1.3); the certificate is a public Let's Encrypt chain coveringpg.uk-lon-1.dodil.ioandbolt.uk-lon-1.dodil.io. Withpsycopg,verify-fullsucceeds whensslrootcertnames a real bundle and fails withsslrootcert=systemon a macOS/conda OpenSSL — which is why this page names the bundle explicitly instead of recommendingsystem. - 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. asyncpgconnects over TLS with the URL'ssslmode=requireand withssl=True(its default context verifies). The oldssl=Falseadvice is obsolete and has been removed.- One trap while the CLI catches up:
dodil data connectstill prints the pre-TLS forms — a DSN ending?sslmode=preferandDATA_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$1tripsasyncpg's type introspection on thevectortype. Inline the literal into the SQL (shown above); scalar$1binds for ints/text work fine.psycopgandnode-postgresbind 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 authOne-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.