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.

← Back to Blog