diff --git a/.changeset/fair-hairs-turn.md b/.changeset/fair-hairs-turn.md
new file mode 100644
index 00000000..9e6f4cc9
--- /dev/null
+++ b/.changeset/fair-hairs-turn.md
@@ -0,0 +1,10 @@
+---
+'@rushdb/javascript-sdk': patch
+'rushdb-core': patch
+'rushdb-docs': patch
+'@rushdb/mcp-server': patch
+'@rushdb/skills': patch
+'rushdb-dashboard': patch
+---
+
+Minor fixes
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index bf8b79c6..c4b4d39a 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -198,7 +198,7 @@ jobs:
{ "name": "RUSHDB_AES_256_ENCRYPTION_KEY", "value": "${{ secrets.RUSHDB_AES_256_ENCRYPTION_KEY }}" },
{ "name": "RUSHDB_JWT_PRIVATE_KEY_BASE64", "value": "${{ secrets.RUSHDB_JWT_PRIVATE_KEY_BASE64 }}" },
{ "name": "RUSHDB_JWT_PUBLIC_KEY_BASE64", "value": "${{ secrets.RUSHDB_JWT_PUBLIC_KEY_BASE64 }}" },
- { "name": "RUSHDB_JWT_KID", "value": "${{ vars.RUSHDB_JWT_KID }}" },
+ { "name": "RUSHDB_JWT_KID", "value": "${{ secrets.RUSHDB_JWT_KID }}" },
{ "name": "RUSHDB_SELF_HOSTED", "value": "false" },
{ "name": "RUSHDB_SERVE_STATIC", "value": "false" },
{ "name": "NEO4J_URL", "value": "${{ secrets.NEO4J_URL }}" },
@@ -219,7 +219,7 @@ jobs:
{ "name": "SQL_DB_URL", "value": "${{ secrets.SQL_DB_URL }}" },
{ "name": "RUSHDB_PUBLIC_URL", "value": "${{ vars.RUSHDB_PUBLIC_URL }}" },
{ "name": "RUSHDB_OAUTH_ISSUER", "value": "${{ vars.RUSHDB_PUBLIC_URL }}" },
- { "name": "RUSHDB_EMBEDDING_BASE_URL", "value": "${{ secrets.RUSHDB_EMBEDDING_BASE_URL }}" },
+ { "name": "RUSHDB_EMBEDDING_BASE_URL", "value": "${{ vars.RUSHDB_EMBEDDING_BASE_URL }}" },
{ "name": "RUSHDB_EMBEDDING_API_KEY", "value": "${{ secrets.RUSHDB_EMBEDDING_API_KEY }}" },
{ "name": "RUSHDB_EMBEDDING_MODEL", "value": "${{ vars.RUSHDB_EMBEDDING_MODEL }}" },
{ "name": "RUSHDB_EMBEDDING_DIMENSIONS", "value": "${{ vars.RUSHDB_EMBEDDING_DIMENSIONS }}" },
diff --git a/docs/docs/get-started/get-api-key.mdx b/docs/docs/get-started/get-api-key.mdx
index 765ff383..39834033 100644
--- a/docs/docs/get-started/get-api-key.mdx
+++ b/docs/docs/get-started/get-api-key.mdx
@@ -36,8 +36,8 @@ Your API token will be displayed only once. Make sure to:
Example of using environment variables:
-import Tabs from '@site/src/components/LanguageTabs';
-import TabItem from '@theme/TabItem';
+import Tabs from '@site/src/components/LanguageTabs'
+import TabItem from '@theme/TabItem'
@@ -45,7 +45,8 @@ import TabItem from '@theme/TabItem';
import os
db = RushDB(api_key=os.environ['RUSHDB_API_KEY'])
-```
+
+````
```typescript
@@ -54,7 +55,8 @@ const db = new RushDB();
// Or pass it explicitly:
// const db = new RushDB(process.env.RUSHDB_API_KEY);
-```
+````
+
```bash
@@ -62,8 +64,10 @@ const db = new RushDB();
export RUSHDB_API_KEY='your-api-key'
# Use in requests
+
curl -H "Authorization: Bearer $RUSHDB_API_KEY" \
- https://api.rushdb.com/api/v1/records
+ https://api.rushdb.com/api/v1/records
+
```
@@ -78,5 +82,8 @@ curl -H "Authorization: Bearer $RUSHDB_API_KEY" \
## Next Steps
- Follow the [Quick Tutorial](../get-started/quick-tutorial) to start using your token
+- Connect an AI agent via the [MCP Server](../mcp-server/quickstart)
+- Bootstrap agent memory with the [Agent Setup guide](https://rushdb.com/agent-setup)
- Learn about [RushDB](../concepts/records)
- Check out the [Basic Concepts](../concepts/records)
+```
diff --git a/docs/docs/get-started/quick-tutorial.mdx b/docs/docs/get-started/quick-tutorial.mdx
index fba3a560..9ab42a20 100644
--- a/docs/docs/get-started/quick-tutorial.mdx
+++ b/docs/docs/get-started/quick-tutorial.mdx
@@ -1,541 +1,223 @@
---
-title: Quick Tutorial
-description: Get started with RushDB in minutes
+title: Quick Start
+description: The fastest path to RushDB — pick your preferred tool.
sidebar_position: 1
---
-# Quick Tutorial
+# Quick Start
-In this tutorial you'll build a small **knowledge base** — push a batch of articles, search by meaning, filter by field value, link articles to their authors, and wrap multi-step writes in a transaction.
+RushDB is a graph database built for agents and structured data. Pick the path that fits how you work:
-Every concept introduced here applies equally to agent memory, product catalogs, document stores, or any other data shape you throw at RushDB.
+| Path | Best for | Time |
+| --------------------------- | -------------------------------- | ------ |
+| [Web AI connector](#web-ai) | ChatGPT or Claude.ai, no install | ~1 min |
+| [Local MCP](#local-mcp) | Claude Desktop, Cursor, VS Code | ~3 min |
+| [Skills](#copilot-skills) | Any MCP-capable agent in VS Code | ~2 min |
+| [SDK / REST](#sdk) | Custom apps and pipelines | ~5 min |
-## Prerequisites
+Need an API key first? → [Get API Key](./get-api-key)
-- A RushDB account and API token — see [Get API Key](../get-started/get-api-key)
-- TypeScript/JavaScript, Python, or any HTTP client
-
-import Tabs from '@site/src/components/LanguageTabs'
-import TabItem from '@theme/TabItem'
-
-## Step 1: Initialize
-
-
-
-```python
-from rushdb import RushDB
-
-db = RushDB("RUSHDB_API_KEY")
-
-````
-
-
-```typescript
-import RushDB from '@rushdb/javascript-sdk';
-
-const db = new RushDB('RUSHDB_API_KEY');
-````
-
-
-
-```bash
-export RUSHDB_API_KEY="your-api-key"
-```
-
-
-
-## Step 2: Push [Records](../concepts/records.md)
-
-Use `importJson` to push a batch of articles in one call. RushDB infers field types automatically and returns record instances you can use immediately.
+---
-
-
-```python
-articles = db.records.create_many(
- label="ARTICLE",
- data=[
- {
- "title": "Getting started with graph databases",
- "content": "Graph databases model data as nodes and edges, making relationship queries fast and intuitive.",
- "tags": ["databases", "graphs"],
- "author": "alice"
- },
- {
- "title": "Vector search explained",
- "content": "Vector embeddings let you search by semantic meaning rather than exact keywords.",
- "tags": ["ai", "search"],
- "author": "bob"
- },
- {
- "title": "Building AI agents with persistent memory",
- "content": "Agents that remember past interactions can reason across sessions and personalize responses.",
- "tags": ["ai", "agents"],
- "author": "alice"
- }
- ],
- options={ "returnResult": True }
-)
-```
-
-
-```typescript
-const { data: articles } = await db.records.importJson({
- label: 'ARTICLE',
- data: [
- {
- title: 'Getting started with graph databases',
- content: 'Graph databases model data as nodes and edges, making relationship queries fast and intuitive.',
- tags: ['databases', 'graphs'],
- author: 'alice'
- },
- {
- title: 'Vector search explained',
- content: 'Vector embeddings let you search by semantic meaning rather than exact keywords.',
- tags: ['ai', 'search'],
- author: 'bob'
- },
- {
- title: 'Building AI agents with persistent memory',
- content: 'Agents that remember past interactions can reason across sessions and personalize responses.',
- tags: ['ai', 'agents'],
- author: 'alice'
- }
- ],
- options: { returnResult: true }
-})
-```
-
-
-```bash
-curl -X POST "https://api.rushdb.com/api/v1/records/import/json" \
- -H "Authorization: Bearer $RUSHDB_API_KEY" \
- -H "Content-Type: application/json" \
- -d '{
- "label": "ARTICLE",
- "data": [
- {
- "title": "Getting started with graph databases",
- "content": "Graph databases model data as nodes and edges, making relationship queries fast and intuitive.",
- "tags": ["databases", "graphs"],
- "author": "alice"
- },
- {
- "title": "Vector search explained",
- "content": "Vector embeddings let you search by semantic meaning rather than exact keywords.",
- "tags": ["ai", "search"],
- "author": "bob"
- },
- {
- "title": "Building AI agents with persistent memory",
- "content": "Agents that remember past interactions can reason across sessions and personalize responses.",
- "tags": ["ai", "agents"],
- "author": "alice"
- }
- ]
- }'
-```
-
-
+## Web AI connector \{#web-ai\}
-:::tip Same pattern for agent memory
-Swap `label: 'ARTICLE'` for `label: 'MEMORY'` and push conversation snippets, tool results, or any structured context. The rest of the tutorial — semantic search, filters, relationships — applies unchanged.
-:::
+Connect to ChatGPT or Claude.ai using the hosted MCP endpoint. No local install, no API key needed for web clients — just OAuth.
-## Step 3: Semantic Search
+**ChatGPT:**
-Create an embedding index on the `content` field, wait for backfill to complete, then search by meaning.
+1. Open **Settings → Connectors → Add connector**
+2. Enter URL: `https://mcp.rushdb.com/mcp`
+3. Sign in with your RushDB account
-
-
-```python
-import time
+**Claude.ai:**
-# Create the embedding index
+1. Open **Settings → Integrations → Add integration**
+2. Enter URL: `https://mcp.rushdb.com/mcp`
+3. Authorize with your RushDB account
-index = db.ai.indexes.create({
-"label": "ARTICLE",
-"propertyName": "content"
-}).data
+Then verify the connection:
-# Poll until all records are indexed
+> "Call getOntologyMarkdown and show me what's in my RushDB project."
-stats = db.ai.indexes.stats(index["id"]).data
-while stats["indexedRecords"] < stats["totalRecords"]:
-time.sleep(0.5)
-stats = db.ai.indexes.stats(index["id"]).data
+→ [Full MCP client list and options](../mcp-server/quickstart)
-# Search by meaning
+---
-results = db.ai.search({
-"propertyName": "content",
-"query": "how do agents remember things across conversations",
-"labels": ["ARTICLE"],
-"limit": 3
-}).data
+## Local MCP \{#local-mcp\}
-for result in results:
-print(f"[{result['__score']:.3f}] {result['title']}")
+Works with Claude Desktop, Cursor, VS Code, or any stdio-based MCP client. Requires a RushDB API key.
-# [0.891] Building AI agents with persistent memory
+**Claude Desktop** — edit `~/Library/Application Support/Claude/claude_desktop_config.json`:
-# [0.743] Vector search explained
+```json
+{
+ "mcpServers": {
+ "rushdb": {
+ "command": "npx",
+ "args": ["-y", "@rushdb/mcp-server"],
+ "env": { "RUSHDB_API_KEY": "your-api-key-here" }
+ }
+ }
+}
+```
-# [0.612] Getting started with graph databases
+**Cursor** — add to `.cursor/mcp.json`:
-````
-
-
-```typescript
-// Create the embedding index — backfill starts immediately
-const { data: index } = await db.ai.indexes.create({
- label: 'ARTICLE',
- propertyName: 'content'
-})
-
-// Poll until all records are indexed
-let stats = await db.ai.indexes.stats(index.id)
-while (stats.data.indexedRecords < stats.data.totalRecords) {
- await new Promise(r => setTimeout(r, 500))
- stats = await db.ai.indexes.stats(index.id)
+```json
+{
+ "mcpServers": {
+ "rushdb": {
+ "command": "npx",
+ "args": ["-y", "@rushdb/mcp-server"],
+ "env": { "RUSHDB_API_KEY": "your-api-key-here" }
+ }
+ }
}
+```
-// Search by meaning — returns results sorted by cosine similarity
-const { data: results } = await db.ai.search({
- propertyName: 'content',
- query: 'how do agents remember things across conversations',
- labels: ['ARTICLE'],
- limit: 3
-})
-
-for (const result of results) {
- console.log(`[${result.__score.toFixed(3)}] ${result.title}`)
-}
-// [0.891] Building AI agents with persistent memory
-// [0.743] Vector search explained
-// [0.612] Getting started with graph databases
-````
+**VS Code** — add to `.vscode/mcp.json`:
-
-
-```bash
-# Create the embedding index
-INDEX_ID=$(curl -s -X POST "https://api.rushdb.com/api/v1/ai/indexes" \
- -H "Authorization: Bearer $RUSHDB_API_KEY" \
- -H "Content-Type: application/json" \
- -d '{"label": "ARTICLE", "propertyName": "content"}' \
- | jq -r '.data.id')
+```json
+{
+ "servers": {
+ "rushdb": {
+ "type": "stdio",
+ "command": "npx",
+ "args": ["-y", "@rushdb/mcp-server"],
+ "env": { "RUSHDB_API_KEY": "your-api-key-here" }
+ }
+ }
+}
+```
-# Check stats — wait until indexedRecords == totalRecords
+After connecting, bootstrap agent memory in one prompt:
-curl "https://api.rushdb.com/api/v1/ai/indexes/$INDEX_ID/stats" \
- -H "Authorization: Bearer $RUSHDB_API_KEY"
+> "Set up RushDB as my persistent memory layer."
-# Search by meaning
+The agent calls `getOntologyMarkdown`, creates a `SESSION` record, and confirms recall is working. Full bootstrap prompt and memory model: [rushdb.com/agent-setup](https://rushdb.com/agent-setup).
-curl -X POST "https://api.rushdb.com/api/v1/ai/search" \
- -H "Authorization: Bearer $RUSHDB_API_KEY" \
- -H "Content-Type: application/json" \
- -d '{
-"propertyName": "content",
-"query": "how do agents remember things across conversations",
-"labels": ["ARTICLE"],
-"limit": 3
-}'
+:::tip Verify the connection
+Ask: _"Call getOntologyMarkdown and show me what labels exist in my project."_ A working connection returns the schema; an empty project returns an empty ontology — both are correct responses.
+:::
-````
-
-
+---
-Each result includes a `__score` field (0–1) — cosine similarity between the query embedding and the record's content embedding. Higher is more relevant.
+## Skills \{#copilot-skills\}
-## Step 4: Structured Query
+Install the RushDB skills to give your agent structured domain knowledge — so it knows how to query, model data, and manage agent memory without you explaining it every session.
-Use `records.find` to filter by exact field values. This is independent of the embedding index.
+Compatible with Claude, GitHub Copilot, Cursor, Windsurf, and any [Agent Skills](https://agentskills.io/)-compatible client.
-
-
-```python
-ai_articles = db.records.find({
- "labels": ["ARTICLE"],
- "where": {
- "tags": { "$in": ["ai"] }
- }
-})
+| Skill | What it enables |
+| ----------------------- | ---------------------------------------------------------------- |
+| `rushdb-agent-memory` | Store sessions, decisions, and context; recall by meaning |
+| `rushdb-query-builder` | Build `findRecords` filters, aggregations, and semantic searches |
+| `rushdb-data-modeling` | Design labels, properties, relationships, and nested schemas |
+| `rushdb-faceted-search` | Build faceted filter UIs from property metadata |
-print([a.data["title"] for a in ai_articles])
-# ['Vector search explained', 'Building AI agents with persistent memory']
-````
+**Install:**
-
-
-```typescript
-const aiArticles = await db.records.find({
- labels: ['ARTICLE'],
- where: {
- tags: { $in: ['ai'] }
- }
-})
+```bash
+npx skills add rush-db/rushdb --path packages/skills
+```
-console.log(aiArticles.map(a => a.data.title))
-// ['Vector search explained', 'Building AI agents with persistent memory']
+Or via npm:
-````
-
-
```bash
-curl -X POST "https://api.rushdb.com/api/v1/records/search" \
- -H "Authorization: Bearer $RUSHDB_API_KEY" \
- -H "Content-Type: application/json" \
- -d '{
- "labels": ["ARTICLE"],
- "where": {
- "tags": { "$in": ["ai"] }
- }
- }'
-````
+npm install @rushdb/skills
+```
-
-
+Source and docs: [github.com/rush-db/rushdb — packages/skills](https://github.com/rush-db/rushdb/tree/main/packages/skills)
-See the [Where clause reference](../concepts/search/where.md) for the full list of operators (`$gt`, `$lt`, `$contains`, `$not`, and more).
+:::note
+Skills work best when the MCP server is also connected. The skills tell the agent _how_ to use RushDB; the MCP server gives it the _tools_ to do so.
+:::
-## Step 5: Semantic Search with Filter
+---
-Combine a `where` filter with `db.ai.search` to scope semantic search to a subset of records. RushDB narrows candidates by field values first, then ranks them by cosine similarity.
+## SDK \{#sdk\}
-
-
-```python
-# Only search within AI-tagged articles
-filtered = db.ai.search({
- "propertyName": "content",
- "query": "memory and learning",
- "labels": ["ARTICLE"],
- "where": {
- "tags": { "$in": ["ai"] }
- },
- "limit": 5
-}).data
+For custom apps, data pipelines, or direct programmatic control.
-for result in filtered:
-print(f"[{result['__score']:.3f}] {result['title']}")
+import Tabs from '@site/src/components/LanguageTabs'
+import TabItem from '@theme/TabItem'
-````
-
-
+
+
```typescript
-// Only search within AI-tagged articles
-const { data: filtered } = await db.ai.search({
- propertyName: 'content',
- query: 'memory and learning',
- labels: ['ARTICLE'],
- where: {
- tags: { $in: ['ai'] }
- },
- limit: 5
-})
-
-for (const result of filtered) {
- console.log(`[${result.__score.toFixed(3)}] ${result.title}`)
-}
-````
-
-
-
-```bash
-curl -X POST "https://api.rushdb.com/api/v1/ai/search" \
- -H "Authorization: Bearer $RUSHDB_API_KEY" \
- -H "Content-Type: application/json" \
- -d '{
- "propertyName": "content",
- "query": "memory and learning",
- "labels": ["ARTICLE"],
- "where": {
- "tags": { "$in": ["ai"] }
- },
- "limit": 5
- }'
-```
-
-
+import RushDB from '@rushdb/javascript-sdk';
-:::note
-Without a `where` clause, RushDB still performs exact semantic ranking over the label-scoped candidates. Adding `where` reduces the candidate set first, which can improve latency on larger datasets.
-:::
+const db = new RushDB('RUSHDB_API_KEY');
-## Step 6: [Relationships](../concepts/relationships.mdx)
+// Push data
+await db.records.importJson({
+label: 'MEMORY',
+data: [{ content: 'RushDB stores structured memory for AI agents.' }]
+});
-Link each article to an author record. Relationships are first-class in RushDB — they have a type, a direction, and can carry their own properties.
+// Query by meaning
+const { data: results } = await db.ai.search({
+propertyName: 'content',
+query: 'how agents remember things',
+labels: ['MEMORY']
+});
-
-
+````
+
+
```python
-# Create an author record
-author = db.records.create(
- label="AUTHOR",
- data={"name": "Alice", "email": "alice@example.com"}
-)
+from rushdb import RushDB
-# Attach alice's articles to the author
+db = RushDB('RUSHDB_API_KEY')
-for article in [a for a in articles if a.data.get("author") == "alice"]:
-article.attach(
-target=author,
-options={"type": "WRITTEN_BY", "direction": "out"}
+# Push data
+db.records.create_many(
+ label='MEMORY',
+ data=[{'content': 'RushDB stores structured memory for AI agents.'}]
)
-````
-
-
-```typescript
-// Create an author record
-const author = await db.records.create({
- label: 'AUTHOR',
- data: { name: 'Alice', email: 'alice@example.com' }
-})
-
-// Attach alice's articles to the author
-for (const article of articles.filter(a => a.data.author === 'alice')) {
- await article.attach({
- target: author,
- options: { type: 'WRITTEN_BY' }
- })
-}
+# Query by meaning
+results = db.ai.search({
+ 'propertyName': 'content',
+ 'query': 'how agents remember things',
+ 'labels': ['MEMORY']
+}).data
````
-
+
```bash
-# Create an author record — save the returned __id
-AUTHOR_ID=$(curl -s -X POST "https://api.rushdb.com/api/v1/records" \
+curl -X POST "https://api.rushdb.com/api/v1/records/import/json" \
-H "Authorization: Bearer $RUSHDB_API_KEY" \
-H "Content-Type: application/json" \
- -d '{
- "label": "AUTHOR",
- "data": {"name": "Alice", "email": "alice@example.com"}
- }' | jq -r '.data.__id')
-
-# Attach an article to the author (replace ARTICLE_ID with the actual ID)
-
-curl -X POST "https://api.rushdb.com/api/v1/relationships/$ARTICLE_ID" \
- -H "Authorization: Bearer $RUSHDB_API_KEY" \
- -H "Content-Type: application/json" \
- -d '{
- "targetIds": ["'"$AUTHOR_ID"'"],
-"type": "WRITTEN_BY"
-}'
-
-````
+ -d '{"label": "MEMORY", "data": [{"content": "RushDB stores structured memory for AI agents."}]}'
+```
-## Step 7: [Transactions](../concepts/transactions.mdx) (Optional)
-
-Wrap multiple writes in a transaction to guarantee all-or-nothing atomicity.
+→ [TypeScript SDK](../typescript-sdk/introduction) · [Python SDK](../python-sdk/introduction) · [REST API](../rest-api/introduction)
-
-
-```python
-with db.tx.begin() as tx:
- new_article = db.records.create(
- label="ARTICLE",
- data={
- "title": "Transactions made simple",
- "content": "ACID guarantees ensure your data stays consistent even when things fail.",
- "tags": ["databases"],
- "author": "bob"
- },
- transaction=tx
- )
-
- new_article.attach(
- target=author,
- options={"type": "WRITTEN_BY", "direction": "out"},
- transaction=tx
- )
- # Commits automatically on exit; rolls back on exception
-````
+---
-
-
-```typescript
-const tx = await db.tx.begin()
-
-try {
-const newArticle = await db.records.create({
-label: 'ARTICLE',
-data: {
-title: 'Transactions made simple',
-content: 'ACID guarantees ensure your data stays consistent even when things fail.',
-tags: ['databases'],
-author: 'bob'
-},
-transaction: tx
-})
-
-await newArticle.attach({
-target: author,
-options: { type: 'WRITTEN_BY' },
-transaction: tx
-})
-
-await tx.commit()
-console.log('Article created and linked — committed.')
-} catch (err) {
-await tx.rollback()
-console.error('Rolled back:', err)
-}
+## What to build
-````
-
-
-```bash
-# Begin transaction
-TX_ID=$(curl -s -X POST "https://api.rushdb.com/api/v1/transactions" \
- -H "Authorization: Bearer $RUSHDB_API_KEY" \
- -H "Content-Type: application/json" \
- | jq -r '.id')
+| Use case | Guide |
+| ------------------------------------------- | --------------------------------------------------------------------- |
+| Agent memory — sessions, decisions, context | [Episodic Memory for Multi-Step Agents](../tutorials/episodic-memory) |
+| GraphRAG — graph + vector search | [GraphRAG Tutorial](../tutorials/graphrag) |
+| Semantic search over your data | [Semantic Search in 5 Minutes](../tutorials/ai-semantic-search) |
+| Hybrid filter + semantic search | [Hybrid Retrieval](../tutorials/hybrid-retrieval) |
+| Explore an unknown dataset | [Discovery Queries](../tutorials/discovery-queries) |
+| Safe agent query planning | [Agent-Safe Query Planning](../tutorials/agent-safe-query-planning) |
-# Create article within transaction
-NEW_ARTICLE_ID=$(curl -s -X POST "https://api.rushdb.com/api/v1/records" \
- -H "Authorization: Bearer $RUSHDB_API_KEY" \
- -H "Content-Type: application/json" \
- -H "X-Transaction-ID: $TX_ID" \
- -d '{
- "label": "ARTICLE",
- "data": {
- "title": "Transactions made simple",
- "content": "ACID guarantees ensure your data stays consistent even when things fail.",
- "tags": ["databases"],
- "author": "bob"
- }
- }' | jq -r '.data.__id')
+---
-# Attach to author within same transaction
-curl -X POST "https://api.rushdb.com/api/v1/relationships/$NEW_ARTICLE_ID" \
- -H "Authorization: Bearer $RUSHDB_API_KEY" \
- -H "Content-Type: application/json" \
- -H "X-Transaction-ID: $TX_ID" \
- -d '{
- "targetIds": ["'"$AUTHOR_ID"'"],
- "type": "WRITTEN_BY"
- }'
-
-# Commit
-curl -X POST "https://api.rushdb.com/api/v1/transactions/$TX_ID/commit" \
- -H "Authorization: Bearer $RUSHDB_API_KEY"
-````
+## Concepts to know
-
-
+Three ideas explain how RushDB works:
-## Next Steps
+- **Records** — a node with a label and any JSON properties. No schema required upfront.
+- **Labels** — the record's type (`SESSION`, `DECISION`, `ARTICLE`). You define them by pushing data.
+- **Relationships** — named, directed edges between records. Auto-created from nested JSON.
-- [Records](../concepts/records.md) — data model and field types
-- [Labels](../concepts/labels.md) — organizing records by category
-- [Relationships](../concepts/relationships.mdx) — connecting records into a graph
-- [Search & Querying](../concepts/search/introduction.md) — where clauses, ordering, pagination
-- [Transactions](../concepts/transactions.mdx) — atomicity and consistency
-- [TypeScript SDK](../typescript-sdk/introduction)
-- [Python SDK](../python-sdk/introduction)
-- [REST API](../rest-api/introduction)
+→ [Data model overview](../concepts/data-ingestion.mdx) · [Relationships](../concepts/relationships.mdx) · [Search](../concepts/search/introduction.md)
diff --git a/docs/sidebars.ts b/docs/sidebars.ts
index 8c1ac668..6bcfe71b 100644
--- a/docs/sidebars.ts
+++ b/docs/sidebars.ts
@@ -41,7 +41,7 @@ const sidebars: SidebarsConfig = {
type: 'category',
label: 'Getting Started',
collapsed: false,
- items: ['index', 'get-started/get-api-key', 'get-started/quick-tutorial']
+ items: ['index', 'get-started/quick-tutorial', 'get-started/get-api-key']
},
'concepts/agent-memory-model',
'concepts/ontology-schema-discovery',
diff --git a/packages/javascript-sdk/tests/relationship-patterns.suggestions.e2e.test.ts b/packages/javascript-sdk/tests/relationship-patterns.suggestions.e2e.test.ts
new file mode 100644
index 00000000..0947e0f9
--- /dev/null
+++ b/packages/javascript-sdk/tests/relationship-patterns.suggestions.e2e.test.ts
@@ -0,0 +1,277 @@
+/**
+ * E2E tests for relationship-pattern suggestion validation logic.
+ *
+ * These tests must be run locally against a live RushDB instance
+ * (with RUSHDB_API_KEY + RUSHDB_API_URL in packages/javascript-sdk/.env)
+ * and an LLM configured in the platform (RUSHDB_LLM_API_KEY / RUSHDB_LLM_MODEL).
+ *
+ * They are NOT run in CI.
+ *
+ * Run with:
+ * pnpm jest --rootDir packages/javascript-sdk -i relationship-patterns.suggestions.e2e.test.ts
+ */
+import path from 'path'
+import dotenv from 'dotenv'
+
+dotenv.config({ path: path.resolve(__dirname, '../.env') })
+
+import RushDB from '../src/index.node'
+
+// -----------------------------------------------------------------
+// Helpers
+// -----------------------------------------------------------------
+
+const sleep = (ms: number) => new Promise((res) => setTimeout(res, ms))
+
+type AnalysisResult = {
+ patterns: Array<{
+ id: string
+ mode: string
+ status: string
+ source: { label: string; key?: string }
+ target: { label: string; key?: string }
+ type: string
+ confidence: number
+ }>
+ relationships: Array<{
+ label: string
+ relationships: Array<{ label: string; type: string; direction: string }>
+ }>
+ analysis?: {
+ status: string
+ lastRunAt?: string
+ lastError?: string
+ }
+}
+
+// -----------------------------------------------------------------
+// Suite
+// -----------------------------------------------------------------
+
+describe('relationship-patterns suggestion validation (e2e)', () => {
+ const apiKey = process.env.RUSHDB_API_KEY
+ const apiUrl = (process.env.RUSHDB_API_URL || 'http://localhost:3000').replace(/\/$/, '')
+
+ if (!apiKey) {
+ it('skips because RUSHDB_API_KEY is not set', () => {
+ expect(true).toBe(true)
+ })
+ return
+ }
+
+ const db = new RushDB(apiKey, { url: apiUrl })
+
+ // Unique suffix per run → unique labels so tests don't collide with prior runs
+ const suffix = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 5)}`
+ const tenantId = `rp-e2e-${suffix}`
+
+ // Label names scoped to this run
+ const WRITER_LABEL = `RpWriter${suffix}`
+ const BOOK_LABEL = `RpBook${suffix}`
+ const PUBLISHER_LABEL = `RpPublisher${suffix}`
+ const EDITION_LABEL = `RpEdition${suffix}`
+
+ jest.setTimeout(120_000)
+
+ // ------------------------------------------------------------------
+ // Raw HTTP helper (relationship-patterns API has no SDK wrapper)
+ // ------------------------------------------------------------------
+ async function patternsApi(urlPath: string, method = 'GET', body?: unknown): Promise {
+ const res = await fetch(`${apiUrl}${urlPath}`, {
+ method,
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${apiKey}`
+ },
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {})
+ })
+ return res.json()
+ }
+
+ /**
+ * Polls GET /relationships/patterns until analysis.status is 'idle' or 'error'.
+ * Throws if the deadline is reached without completion.
+ */
+ async function waitForAnalysis(maxMs = 90_000): Promise {
+ const deadline = Date.now() + maxMs
+ while (Date.now() < deadline) {
+ const raw = (await patternsApi('/relationships/patterns')) as { data?: AnalysisResult }
+ const data = raw?.data
+ const status = data?.analysis?.status
+ if (status === 'idle' || status === 'error') {
+ return data as AnalysisResult
+ }
+ await sleep(3_000)
+ }
+ throw new Error('Analysis did not reach idle/error status within timeout')
+ }
+
+ // ------------------------------------------------------------------
+ // Data setup
+ // ------------------------------------------------------------------
+
+ beforeAll(async () => {
+ // --- Scenario A data: Writer → Book with a shared writerId FK field
+ // + an explicit WROTE semantic relationship
+ await db.records.createMany({
+ label: WRITER_LABEL,
+ data: [
+ { writerId: 'w1', name: 'Alice', tenantId },
+ { writerId: 'w2', name: 'Bob', tenantId }
+ ],
+ options: { returnResult: false }
+ })
+
+ await db.records.createMany({
+ label: BOOK_LABEL,
+ data: [
+ { writerId: 'w1', title: 'The First Novel', genre: 'fiction', tenantId },
+ { writerId: 'w2', title: 'Data Structures', genre: 'non-fiction', tenantId }
+ ],
+ options: { returnResult: false }
+ })
+
+ // Creates semantic WROTE relationships: Writer -[WROTE]-> Book matched by writerId
+ await db.relationships.createMany({
+ source: { label: WRITER_LABEL, key: 'writerId', where: { tenantId } },
+ target: { label: BOOK_LABEL, key: 'writerId', where: { tenantId } },
+ type: 'WROTE',
+ direction: 'out'
+ })
+
+ // --- Scenario B data: Publisher → [DEFAULT] → Edition (via importJson nesting)
+ await db.records.importJson({
+ label: PUBLISHER_LABEL,
+ data: {
+ name: 'Acme Press',
+ country: 'US',
+ foundedYear: 1990,
+ tenantId,
+ editions: [
+ { title: 'First Edition', year: 2020, tenantId },
+ { title: 'Second Edition', year: 2021, tenantId }
+ ]
+ },
+ options: { suggestTypes: true }
+ })
+ })
+
+ afterAll(async () => {
+ // Delete all records from this test run by tenantId.
+ // Using no `labels` filter covers the nested records that importJson creates
+ // from field names (e.g. `editions`) which differ from our EDITION_LABEL constant.
+ await db.records.delete({ where: { tenantId } }).catch(() => {})
+ })
+
+ // ------------------------------------------------------------------
+ // Tests
+ // ------------------------------------------------------------------
+
+ /**
+ * Scenario A — regression guard for the hasSemanticRelationshipBetween fix.
+ *
+ * When a semantic (non-default) relationship already exists between two labels,
+ * the pattern engine must NOT suggest a join_pattern for that label pair.
+ *
+ * WRITER -[WROTE]-> BOOK already exists. The LLM may notice the writerId FK
+ * field and try to suggest a join, but validateCandidate must reject it.
+ */
+ it('does NOT suggest join_pattern for label pairs that already have a semantic relationship', async () => {
+ // Force a fresh analysis for this project
+ await patternsApi('/relationships/patterns/analyze', 'POST')
+
+ const result = await waitForAnalysis()
+
+ if (result.analysis?.status === 'error') {
+ throw new Error(
+ `Analysis failed (check RUSHDB_LLM_API_KEY / RUSHDB_LLM_MODEL on the server): ${result.analysis.lastError}`
+ )
+ }
+
+ expect(result.analysis?.status).toBe('idle')
+
+ // No join_pattern should exist for the Writer-Book label pair in either direction
+ const invalidJoins = (result.patterns ?? []).filter(
+ (p) =>
+ p.mode === 'join_pattern' &&
+ ((p.source?.label === WRITER_LABEL && p.target?.label === BOOK_LABEL) ||
+ (p.source?.label === BOOK_LABEL && p.target?.label === WRITER_LABEL))
+ )
+
+ expect(invalidJoins).toHaveLength(0)
+ })
+
+ /**
+ * Scenario B — regression guard for the bidirectional hasDefaultRelationshipBetween fix.
+ *
+ * When a DEFAULT relationship exists between two labels (created by importJson nesting),
+ * the pattern engine must be willing to suggest retype_existing_relationship for that
+ * pair regardless of which side "owns" the relationship in the ontology.
+ *
+ * This test verifies the invariant: every retype_existing_relationship pattern in the
+ * result must correspond to a label pair that actually has a default relationship in
+ * the current ontology. A regression in hasDefaultRelationshipBetween would cause
+ * retype patterns to appear for pairs without a default rel, or incorrectly block them
+ * (both are caught by the invariant check below).
+ *
+ * Additionally, we assert that no join_pattern is suggested for the
+ * Publisher-Edition pair once a retype has been accepted — future iterations should
+ * not re-propose a join where a retype already exists.
+ */
+ it('retype_existing_relationship patterns are only suggested for default-connected label pairs', async () => {
+ const result = await waitForAnalysis()
+
+ if (result.analysis?.status === 'error') {
+ throw new Error(
+ `Analysis failed (check RUSHDB_LLM_API_KEY / RUSHDB_LLM_MODEL on the server): ${result.analysis.lastError}`
+ )
+ }
+
+ const ontology = result.relationships ?? []
+ const DEFAULT_TYPE_PREFIXES = ['RUSHDB_DEFAULT_RELATION', 'RUSHDB_RELATION']
+
+ const isDefaultType = (type: string) =>
+ DEFAULT_TYPE_PREFIXES.some((prefix) => type === prefix || type.startsWith(prefix))
+
+ const hasDefaultBetween = (labelA: string, labelB: string): boolean => {
+ const entryA = ontology.find((o) => o.label === labelA)
+ const entryB = ontology.find((o) => o.label === labelB)
+ return (
+ entryA?.relationships.some((r) => r.label === labelB && isDefaultType(r.type)) ||
+ entryB?.relationships.some((r) => r.label === labelA && isDefaultType(r.type)) ||
+ false
+ )
+ }
+
+ const retypers = (result.patterns ?? []).filter((p) => p.mode === 'retype_existing_relationship')
+
+ // Invariant: every retype suggestion whose labels still exist in the current
+ // ontology must have a default rel between its label pair.
+ // Patterns from previous test runs whose records have since been deleted are
+ // skipped — they are stale rows in Postgres that no longer have live graph
+ // data, so they cannot be validated against the current ontology.
+ for (const pattern of retypers) {
+ const { label: srcLabel } = pattern.source
+ const { label: tgtLabel } = pattern.target
+
+ const srcInOntology = ontology.some((o) => o.label === srcLabel)
+ const tgtInOntology = ontology.some((o) => o.label === tgtLabel)
+
+ // Skip stale patterns (one or both labels absent from current ontology)
+ if (!srcInOntology || !tgtInOntology) continue
+
+ expect(hasDefaultBetween(srcLabel, tgtLabel)).toBe(true)
+ }
+
+ // The Publisher-Edition pair must NOT appear as a join_pattern — they are connected
+ // by a default relationship so only a retype (not a join) is appropriate
+ const publisherEditionJoins = (result.patterns ?? []).filter(
+ (p) =>
+ p.mode === 'join_pattern' &&
+ ((p.source?.label === PUBLISHER_LABEL && p.target?.label === EDITION_LABEL) ||
+ (p.source?.label === EDITION_LABEL && p.target?.label === PUBLISHER_LABEL))
+ )
+
+ expect(publisherEditionJoins).toHaveLength(0)
+ })
+})
diff --git a/platform/core/src/core/relationship-patterns/relationship-patterns.service.ts b/platform/core/src/core/relationship-patterns/relationship-patterns.service.ts
index b41ef806..f88d5c29 100644
--- a/platform/core/src/core/relationship-patterns/relationship-patterns.service.ts
+++ b/platform/core/src/core/relationship-patterns/relationship-patterns.service.ts
@@ -536,6 +536,10 @@ export class RelationshipPatternsService {
return undefined
}
+ if (this.hasSemanticRelationshipBetween(source, target)) {
+ return undefined
+ }
+
if (!candidate.source.key || !candidate.target.key) {
return undefined
}
@@ -564,10 +568,31 @@ export class RelationshipPatternsService {
return mode === 'retype_existing_relationship' ? 'retype_existing_relationship' : 'join_pattern'
}
+ private isDefaultRelationType(type: string): boolean {
+ return type === RUSHDB_RELATION_DEFAULT || type === 'RUSHDB_DEFAULT_RELATION'
+ }
+
private hasDefaultRelationshipBetween(source: OntologyItem, target: OntologyItem): boolean {
- const isDefault = (type: string) => type === RUSHDB_RELATION_DEFAULT || type === 'RUSHDB_DEFAULT_RELATION'
- return source.relationships.some(
- (relationship) => relationship.label === target.label && isDefault(relationship.type)
+ return (
+ source.relationships.some(
+ (relationship) => relationship.label === target.label && this.isDefaultRelationType(relationship.type)
+ ) ||
+ target.relationships.some(
+ (relationship) => relationship.label === source.label && this.isDefaultRelationType(relationship.type)
+ )
+ )
+ }
+
+ private hasSemanticRelationshipBetween(source: OntologyItem, target: OntologyItem): boolean {
+ return (
+ source.relationships.some(
+ (relationship) =>
+ relationship.label === target.label && !this.isDefaultRelationType(relationship.type)
+ ) ||
+ target.relationships.some(
+ (relationship) =>
+ relationship.label === source.label && !this.isDefaultRelationType(relationship.type)
+ )
)
}