Zero to a Vector-Search Notes App in One Shell Session
This is the hands-on half of the BaaS series. In one shell session we are going to provision a database, define a schema with vector search, seed it with embeddings, branch for a feature, merge it back, and use time-travel to debug a regression. Everything except embedding generation runs on HeliosDB BaaS — there is no backend to write, and the "backend" code we do write is a tiny Node.js script that forwards user requests after attaching a scoped API key.
Prerequisites: a terminal with curl and jq, Node.js 20+, and an embedding endpoint. We will use OpenAI's text-embedding-3-small (1536 dim) for the demo; the same code works against any embedding provider that returns a fixed-dimension float array.
Step 1: Provision the Database
One POST creates the account, the organisation, the database, and an API key:
curl -sS -X POST https://cloud.heliosdb.com/api/v1/quick-start \
-H "Content-Type: application/json" \
-d '{
"email": "me@example.com",
"password":"'\$(openssl rand -base64 18)'",
"username":"me",
"org_name":"notes-demo",
"db_name": "main"
}' | tee /tmp/quickstart.json | jq
Stash the identifiers we'll need for every subsequent call:
export HDB_ORG=$(jq -r '.data.org_id' /tmp/quickstart.json)
export HDB_DB=$(jq -r '.data.db_id' /tmp/quickstart.json)
export HDB_KEY=$(jq -r '.data.api_key' /tmp/quickstart.json)
export HDB="https://cloud.heliosdb.com/api/v1/organizations/$HDB_ORG/databases/$HDB_DB"
echo "Database ready at $HDB"
No local install. No migration tool. The database is live and accepts arbitrary SQL. On the free "Verified" tier this gives you up to 5 databases, 50 MB storage, 500 queries per hour, and 10 branches per database — more than enough to finish this tutorial.
Step 2: Define the Schema
A notes app needs three columns beyond identity: the title, the body, and the embedding we'll use for semantic search. Vector columns are first-class:
curl -sS -X POST "$HDB/execute" \
-H "X-API-Key: $HDB_KEY" \
-H "Content-Type: application/json" \
-d '{"sql":
"CREATE TABLE notes (
id SERIAL PRIMARY KEY,
user_id UUID NOT NULL,
title TEXT NOT NULL,
body TEXT NOT NULL,
embedding VECTOR(1536),
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX notes_user_idx ON notes(user_id);
CREATE INDEX notes_embedding_hnsw ON notes USING hnsw(embedding vector_cosine_ops);
CREATE INDEX notes_body_fts ON notes USING bm25(body);"
}' | jq
Three index strategies in one statement: a B-tree on user_id for scoped queries, an HNSW index for vector similarity, and a BM25 index for keyword search. The BM25 and HNSW indexes can be combined with Reciprocal Rank Fusion (RRF) or Maximal Marginal Relevance (MMR) in a single query — a topic for a later post.
Step 3: Embed and Insert
We need a tiny script to compute embeddings and POST rows. Save as seed.mjs:
// seed.mjs
const { HDB, HDB_KEY } = process.env;
const OPENAI_KEY = process.env.OPENAI_API_KEY;
const userId = crypto.randomUUID();
const notes = [
{ title: "Vectors 101", body: "Dense vectors encode semantic meaning as floats." },
{ title: "Cosine distance", body: "Cosine distance measures angle between two vectors." },
{ title: "Monday shopping", body: "Eggs, milk, coffee, flour, oranges." },
{ title: "HNSW graphs", body: "HNSW builds a navigable small-world graph for ANN." },
{ title: "Weekend plan", body: "Hike Saturday, dinner with parents Sunday." }
];
async function embed(text) {
const r = await fetch("https://api.openai.com/v1/embeddings", {
method: "POST",
headers: {
"Authorization": `Bearer ${OPENAI_KEY}`,
"Content-Type": "application/json"
},
body: JSON.stringify({ model: "text-embedding-3-small", input: text })
});
const j = await r.json();
return j.data[0].embedding;
}
async function insert(note) {
const vec = await embed(`${note.title}. ${note.body}`);
const vecLit = "[" + vec.join(",") + "]";
const sql = `INSERT INTO notes (user_id, title, body, embedding)
VALUES ('${userId}', $1, $2, '${vecLit}'::vector)`;
const r = await fetch(`${HDB}/execute`, {
method: "POST",
headers: {
"X-API-Key": HDB_KEY,
"Content-Type": "application/json"
},
body: JSON.stringify({ sql, params: [note.title, note.body] })
});
console.log(note.title, "=>", r.status);
}
for (const n of notes) await insert(n);
Run it:
OPENAI_API_KEY=sk-... node seed.mjs
# Vectors 101 => 200
# Cosine distance => 200
# Monday shopping => 200
# HNSW graphs => 200
# Weekend plan => 200
Step 4: Semantic Search
Now ask the database a question in vector form. Embed the query text with the same model, then post an ORDER BY embedding <=> $1 query:
// search.mjs — takes a query string, returns top-3 semantic matches
const query = process.argv[2] || "how do approximate-nearest-neighbour structures work?";
const vec = await embed(query);
const vecLit = "[" + vec.join(",") + "]";
const sql = `
SELECT title, body,
1 - (embedding <=> '${vecLit}'::vector) AS similarity
FROM notes
ORDER BY embedding <=> '${vecLit}'::vector
LIMIT 3
`;
const r = await fetch(`${HDB}/execute`, {
method: "POST",
headers: { "X-API-Key": HDB_KEY, "Content-Type": "application/json" },
body: JSON.stringify({ sql })
});
console.log(JSON.stringify(await r.json(), null, 2));
$ node search.mjs "how does approximate nearest neighbour search work?"
{
"rows": [
{ "title": "HNSW graphs", "similarity": 0.83 },
{ "title": "Vectors 101", "similarity": 0.61 },
{ "title": "Cosine distance", "similarity": 0.55 }
]
}
"Monday shopping" and "Weekend plan" are nowhere near the top. We have semantic search, and the closest thing to a backend we've written is a script that forwards HTTP calls.
Step 5: Per-User Scoped API Keys
In production you do not want your app's admin key sitting in a mobile client. Issue a per-user scoped key from a small token-exchange endpoint. The key's scope is the database; you enforce per-user isolation with a RLS policy or a column predicate.
// Called by your login endpoint after the user authenticates
async function keyForUser(userId) {
const r = await fetch(`${HDB}/api-keys`, {
method: "POST",
headers: { "X-API-Key": ADMIN_KEY, "Content-Type": "application/json" },
body: JSON.stringify({
name: `user:${userId}`,
scopes: ["read", "write"]
})
});
const { api_key } = await r.json();
// Returned ONCE. Ship to client, never persist the raw value server-side.
return api_key;
}
Rotate a user's key on password change; revoke it on logout or account suspension. Every key's blast radius is a single database, and you can list and revoke all of them from the same endpoint:
GET ${HDB}/api-keys # list
DELETE ${HDB}/api-keys/{key_id} # revoke
Step 6: Branch for a Feature
Say you want to add a tags TEXT[] column and backfill it. On a traditional database this means a migration script, a downtime window, or a blue-green deploy. With branching it is three calls:
# Create a branch off the current state of main
curl -sS -X POST "$HDB/branches" \
-H "X-API-Key: $HDB_KEY" \
-d '{"name": "feat-tags", "as_of": "now"}' | jq
# Switch to it
curl -sS -X POST "$HDB/branches/switch" \
-H "X-API-Key: $HDB_KEY" \
-d '{"name": "feat-tags"}'
# Run the migration on the branch only
curl -sS -X POST "$HDB/execute" \
-H "X-API-Key: $HDB_KEY" \
-d '{"sql": "ALTER TABLE notes ADD COLUMN tags TEXT[] DEFAULT ARRAY[]::TEXT[];"}'
Run your test suite against the branch (same API key, same URL, the engine resolves against the active branch). When it's green, merge:
curl -sS -X POST "$HDB/branches/merge" \
-H "X-API-Key: $HDB_KEY" \
-d '{"source": "feat-tags", "target": "main"}'
Branching is a copy-on-write operation at the storage layer — O(1) regardless of how large the table is. You can keep dozens of branches open concurrently on the free tier (10 on Verified, 20 on Trusted).
Step 7: Time-Travel for Debugging
Twenty minutes after the merge, a user reports that the body of one of their notes got truncated. You don't know when. You can query the database as of a point in time:
# How did the row look an hour ago?
curl -sS -X POST "$HDB/query-at" \
-H "X-API-Key: $HDB_KEY" \
-d "{
\"sql\": \"SELECT title, body FROM notes WHERE id = 42\",
\"as_of\": \"$(date -u -d '1 hour ago' +%Y-%m-%dT%H:%M:%SZ)\"
}" | jq
The free-tier lookback is 24 hours; Starter is 7 days; Pro is 90. The SQL form also works directly: SELECT * FROM notes AS OF TIMESTAMP '2026-05-07T10:00:00Z' WHERE id = 42.
Combined with branching, time-travel gives you a post-mortem workflow most databases can't: create a branch from the moment before the bug hit, reproduce, fix forward, merge. No backup restore, no downtime.
Step 8: Run the Same SQL Self-Hosted
Updated Oct 2026: the original version of this step used an unofficial image name and a self-hosted quick-start call. The official image is now ghcr.io/heliosdatabase/heliosdb-nano, and account provisioning (/api/v1/quick-start, API keys) is a HeliosDB Cloud feature — a self-hosted Nano container does not provision tenants. The step below reflects what the container actually does.
Run HeliosDB Nano locally in a Docker container:
docker run -d --name helios-local \
-p 5432:5432 \
-e HELIOSDB_PASSWORD=change-me \
-v helios_data:/data \
ghcr.io/heliosdatabase/heliosdb-nano:latest
psql "postgresql://postgres:change-me@localhost:5432/postgres?sslmode=require"
The same schema, the same SQL — tables, VECTOR columns, CREATE BRANCH, AS OF TIMESTAMP — now runs against your own container over the PostgreSQL wire protocol, so any Postgres driver works unchanged. The container also serves Nano's own REST API (/v1/branches/<branch>/query, vector stores, time-travel) on port 8080; it does not use the database password, so the image keeps it on the container's loopback unless you set HELIOSDB_HTTP_LISTEN=0.0.0.0 on a trusted network.
That is the portability guarantee from the previous article: the data model and the SQL travel with you. What changes between the managed service and a self-hosted engine is the account layer — API keys and provisioning — not the queries.
Step 9: Scale Up When You Need To
When traffic outgrows the free tier, move to Starter or Pro on the managed service — same URL, same code. When you need a single-tenant deployment or a custom region, put HeliosDB Lite on a VM or Kubernetes behind your own load balancer and point your BASE_URL at it. When you need distributed storage and multi-region writes, graduate to HeliosDB Full. The REST surface is identical across all three.
What You Didn't Write
Look back at the tutorial. You did not write: a connection-pool library, a migration runner, an SDK, a per-environment auth scheme, a secrets-rotation job, a read-replica failover client, or a vector-search service. The closest thing to a "backend" in this walkthrough is seed.mjs — a 30-line script that forwards HTTP requests. Everything else is the database.
That is what "backend as a service" should mean. Not a managed add-on, not a layer on top of Postgres, not a vendor SDK — a database that is already a backend.
Previous in this series: Database-Agnostic by Default. Next: the three-protocol architecture and when to pick which surface. Reference: BaaS page and the OpenAPI 3.1 spec.