From 149a2da542ef2c4895183dc5372f2102651a7e0c Mon Sep 17 00:00:00 2001 From: Artemiy Vereshchinskiy Date: Tue, 26 May 2026 01:02:09 +0700 Subject: [PATCH] Minor fixes --- .changeset/fair-hairs-turn.md | 10 + .github/workflows/release.yml | 4 +- docs/docs/get-started/get-api-key.mdx | 17 +- docs/docs/get-started/quick-tutorial.mdx | 614 +++++------------- docs/sidebars.ts | 2 +- ...ationship-patterns.suggestions.e2e.test.ts | 277 ++++++++ .../relationship-patterns.service.ts | 31 +- 7 files changed, 478 insertions(+), 477 deletions(-) create mode 100644 .changeset/fair-hairs-turn.md create mode 100644 packages/javascript-sdk/tests/relationship-patterns.suggestions.e2e.test.ts 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) + ) ) }