From 3000fa63441ee2f3f187c1bb69408d72b45358cb Mon Sep 17 00:00:00 2001 From: Artemiy Vereshchinskiy Date: Sun, 24 May 2026 18:32:16 +0700 Subject: [PATCH] Add relationship patterns suggestions --- .changeset/afraid-dryers-try.md | 10 + docs/docs/concepts/bring-your-own-vectors.mdx | 143 +++ docs/docs/concepts/index.mdx | 307 +++++++ docs/docs/concepts/pricing.md | 95 -- docs/docs/concepts/relationships.mdx | 131 ++- docs/docs/concepts/semantic-search.mdx | 120 ++- docs/docs/tutorials/byov-when-and-why.mdx | 236 +++++ .../docs/tutorials/is-rushdb-right-for-me.mdx | 380 ++++++++ docs/docusaurus.config.ts | 22 + docs/package.json | 1 + docs/sidebars.ts | 8 + docs/static/robots.txt | 8 + platform/core/.env.example | 11 +- .../run-side-effect.interceptor.ts | 56 +- .../core/ai/embedding-backfill.scheduler.ts | 5 +- platform/core/src/core/core.module.ts | 3 + .../src/core/entity/entity-query.service.ts | 69 ++ .../core/src/core/entity/entity.controller.ts | 40 +- .../core/src/core/entity/entity.module.ts | 4 +- .../core/src/core/entity/entity.service.ts | 122 ++- .../import-export/import-export.module.ts | 2 + .../entity/import-export/import.controller.ts | 12 +- .../src/core/ku-events/ku-events.constants.ts | 6 +- .../relationship-patterns.controller.ts | 83 ++ .../relationship-patterns.module.ts | 23 + .../relationship-patterns.repository.ts | 167 ++++ .../relationship-patterns.scheduler.ts | 27 + .../relationship-patterns.service.ts | 845 ++++++++++++++++++ .../relationship-patterns.types.ts | 57 ++ .../core/src/core/relationships/controller.ts | 5 + .../dashboard/mcp-oauth/mcp-oauth.service.ts | 32 +- .../pg/0002_relationship_patterns.sql | 38 + .../pg/0003_relationship_pattern_mode.sql | 1 + .../sql/migrations/pg/meta/_journal.json | 16 +- .../sqlite/0002_relationship_patterns.sql | 38 + .../sqlite/0003_relationship_pattern_mode.sql | 1 + .../sql/migrations/sqlite/meta/_journal.json | 16 +- .../core/src/database/sql/schema/pg.schema.ts | 48 +- .../src/database/sql/schema/sqlite.schema.ts | 48 +- .../core/src/database/sql/schema/types.ts | 6 + .../src/components/billing/KuUsageHistory.tsx | 13 +- platform/dashboard/src/elements/Menu.tsx | 5 +- .../projects/components/GraphView.tsx | 472 +++++++--- .../projects/components/ProjectTabs.tsx | 45 +- .../projects/stores/current-project.ts | 20 + .../records/hooks/useRecordMutations.ts | 10 +- .../features/relationship-patterns/hooks.ts | 85 ++ .../features/relationship-patterns/types.ts | 47 + .../src/layout/ProjectLayout/index.tsx | 3 + platform/dashboard/src/lib/api.ts | 57 ++ platform/dashboard/src/lib/formatters.ts | 7 + platform/dashboard/src/lib/queryKeys.ts | 1 + platform/dashboard/src/lib/router.ts | 1 + .../src/pages/project/relationships.tsx | 385 ++++++++ pnpm-lock.yaml | 180 +++- 55 files changed, 4244 insertions(+), 329 deletions(-) create mode 100644 .changeset/afraid-dryers-try.md create mode 100644 docs/docs/concepts/bring-your-own-vectors.mdx create mode 100644 docs/docs/concepts/index.mdx delete mode 100644 docs/docs/concepts/pricing.md create mode 100644 docs/docs/tutorials/byov-when-and-why.mdx create mode 100644 docs/docs/tutorials/is-rushdb-right-for-me.mdx create mode 100644 docs/static/robots.txt create mode 100644 platform/core/src/core/relationship-patterns/relationship-patterns.controller.ts create mode 100644 platform/core/src/core/relationship-patterns/relationship-patterns.module.ts create mode 100644 platform/core/src/core/relationship-patterns/relationship-patterns.repository.ts create mode 100644 platform/core/src/core/relationship-patterns/relationship-patterns.scheduler.ts create mode 100644 platform/core/src/core/relationship-patterns/relationship-patterns.service.ts create mode 100644 platform/core/src/core/relationship-patterns/relationship-patterns.types.ts create mode 100644 platform/core/src/database/sql/migrations/pg/0002_relationship_patterns.sql create mode 100644 platform/core/src/database/sql/migrations/pg/0003_relationship_pattern_mode.sql create mode 100644 platform/core/src/database/sql/migrations/sqlite/0002_relationship_patterns.sql create mode 100644 platform/core/src/database/sql/migrations/sqlite/0003_relationship_pattern_mode.sql create mode 100644 platform/dashboard/src/features/relationship-patterns/hooks.ts create mode 100644 platform/dashboard/src/features/relationship-patterns/types.ts create mode 100644 platform/dashboard/src/pages/project/relationships.tsx diff --git a/.changeset/afraid-dryers-try.md b/.changeset/afraid-dryers-try.md new file mode 100644 index 00000000..f03b4b11 --- /dev/null +++ b/.changeset/afraid-dryers-try.md @@ -0,0 +1,10 @@ +--- +'rushdb-dashboard': minor +'rushdb-core': minor +'rushdb-docs': minor +'@rushdb/javascript-sdk': minor +'@rushdb/mcp-server': minor +'@rushdb/skills': minor +--- + +Add relationship patterns suggestions diff --git a/docs/docs/concepts/bring-your-own-vectors.mdx b/docs/docs/concepts/bring-your-own-vectors.mdx new file mode 100644 index 00000000..77e555c2 --- /dev/null +++ b/docs/docs/concepts/bring-your-own-vectors.mdx @@ -0,0 +1,143 @@ +--- +sidebar_position: 10 +--- + +# Bring Your Own Vectors (BYOV) + +By default, RushDB generates embeddings server-side when you create an **embedding index** on a string property. With **BYOV** (Bring Your Own Vectors) you compute the embeddings yourself and push them alongside your records. RushDB stores, indexes, and searches them — you stay in full control of the model and the pipeline. + +## Why BYOV? + +| Scenario | Why BYOV helps | +| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | +| Domain-specific or fine-tuned model | Use any model — a locally fine-tuned LLM, a multimodal encoder, a document-structure model — without configuring it server-side | +| Compliance / data residency | Raw text never leaves your infrastructure; only the numeric vector is sent to RushDB | +| Multimodal embeddings | Encode images, audio, or structured documents into vectors before storing them | +| Existing ML pipeline | Re-use vectors already produced by your data pipeline | +| Reproducibility | Lock embedding logic to a specific model version; no coupling to server-side model upgrades | + +## Managed vs. External + +| Aspect | Managed | External (BYOV) | +| ------------------------------- | ---------------------------------- | --------------------------------------------------------- | +| `sourceType` | `managed` (default) | `external` | +| Who generates embeddings | RushDB server | Your application | +| Search input | Natural-language `query` string | Pre-computed `queryVector` array | +| `dimensions` required on create | No — uses server default | **Yes** — must match your model | +| Initial index status | `pending` → `ready` after backfill | `awaiting_vectors` → `ready` once first vector is written | +| Backfill existing records | Automatic | Manual via `upsertVectors` or inline writes | + +Both index types can coexist on the same `(label, propertyName)` pair. + +## Write Flows + +There are two ways to push vectors into an external index. + +### Option A — Inline at write time + +Attach vectors directly inside any record create or import call. The index must already exist before vectors are written. + +```typescript +await db.records.create('Article', { + title: 'Understanding Graph RAG', + body: 'Graphs provide context that plain vector search lacks...', + __vectors: [ + { + propertyName: 'body', + vector: await embed('Understanding Graph RAG...') // your embedding function + } + ] +}) +``` + +This is the lowest-latency path: one round-trip creates the record and stores its vector. + +### Option B — Upsert after the fact + +Push vectors separately, useful for seeding an index from an existing dataset or syncing after a batch embedding job. + +```typescript +await db.ai.indexes.upsertVectors(indexId, { + items: [ + { recordId: 'rec_001', vector: [0.1, 0.2, ...] }, + { recordId: 'rec_002', vector: [0.7, 0.8, ...] } + ] +}) +``` + +The upsert call is **idempotent** — re-running it with the same `recordId` replaces the stored vector. + +## Searching with a Pre-computed Vector + +Once vectors are stored, search with `queryVector` instead of `query`: + +```typescript +const results = await db.ai.search({ + label: 'Article', + propertyName: 'body', + queryVector: await embed('graph databases and retrieval'), // your embedding function + limit: 10 +}) +``` + +The result shape is the same as a managed semantic search — records ranked by cosine (or euclidean) similarity, with an optional `__score` field. + +## Lifecycle + +```mermaid +graph LR + A["Create external index
sourceType: external
dimensions: N"] --> B["awaiting_vectors"] + B --> C["Write first vector
(inline or upsertVectors)"] + C --> D["ready"] + D --> E["Search with queryVector"] +``` + +An external index stays in `awaiting_vectors` until at least one vector has been written. After that it is `ready` and searchable. + +--- + +## Implementation Reference + +
+ + TypeScript SDK + + db.ai.indexes · upsertVectors · ai.search + + + + Python SDK + + db.ai.indexes · upsert_vectors · ai.search + + + + REST API + + POST /ai/indexes · /vectors/upsert · BYOV guide + + +
+ +
+ + + Tutorial — BYOV External Embeddings + + + Step-by-step walkthrough with TypeScript, Python, and Shell examples + + +
diff --git a/docs/docs/concepts/index.mdx b/docs/docs/concepts/index.mdx new file mode 100644 index 00000000..a9ff5373 --- /dev/null +++ b/docs/docs/concepts/index.mdx @@ -0,0 +1,307 @@ +--- +sidebar_position: 0 +title: Main Concepts +description: The fundamental ideas behind RushDB — how it stores, links, and queries your data. +--- + +import Tabs from '@site/src/components/LanguageTabs' +import TabItem from '@theme/TabItem' + +# Main Concepts + +RushDB is a **graph database built for developers and AI agents**. You push raw data — JSON objects, nested trees, flat arrays — and RushDB automatically infers types, decomposes nested structures into linked records, and builds a fully traversable graph. No schema definitions. No migration files. No manual relationship wiring. + +This page gives you the mental model you need to work with RushDB effectively. Each section links to a deeper reference when you're ready for it. + +--- + +## Records + +A **Record** is the fundamental unit of data in RushDB. Think of it as a typed row in a table, a document in a document store, or a node in a graph — but with seamless relationship traversal built in. + +Every record has: + +- A **label** — a category name like `User`, `Article`, or `Product` +- **Properties** — typed key-value fields (`name: "John"`, `score: 4.9`, `active: true`) +- A system-generated **ID** (UUIDv7) — lexicographically sortable, embeds a creation timestamp + +```typescript +{ + // ── generated on write ────────────────────────────────────── + "__id": "01968aa4-22c1-781a-8e8c-8fe6be6c3fd4", // UUIDv7; embeds creation timestamp + "__label": "User", // record type + "__proptypes": { // types inferred from your data + "name": "string", + "email": "string", + "rating": "number", + "emailConfirmed": "boolean", + "registeredAt": "datetime" + }, + + // ── written by you ────────────────────────────────────────── + "name": "John Galt", + "email": "john.galt@example.com", + "rating": 4.98, + "emailConfirmed": true, + "registeredAt": "2022-07-19T08:30:28.000Z" +} +``` + +Records never require a predefined schema. RushDB infers the type of every value at write time. + +→ [Records in depth](./records.md) + +--- + +## Labels + +A **Label** is the type name assigned to a record — `User`, `Car`, `Invoice`. Labels work like table names in a relational database but without the rigidity: there is no table to define ahead of time. + +Key characteristics: + +- Every record has exactly **one custom label** (required) +- Labels are **case-sensitive** (`User` ≠ `user`) +- When importing nested JSON, child objects automatically inherit their label from the **parent key name** — no manual assignment needed + +Labels are the primary lens for filtering: `labels: ["User", "Admin"]` in a search query. + +→ [Labels in depth](./labels.md) + +--- + +## Properties + +A **Property** is a named, typed field shared across all records that carry it. In RushDB's internal graph model, properties are first-class nodes — not just columns — which means the same `color` property node connects every `Car`, `Jacket`, and `House` record that has a color field. + +Property nodes hold **only the `(name, type)` pair** — no values. Actual values live exclusively on the Record node. This means Property nodes act as schema/metadata guardrails: they define what fields exist and what types they carry, while records remain the sole source of truth for user-defined data. + +This design enables a capability unique to RushDB: **discovering relationships between otherwise unconnected records** by finding records that share the same property value — without duplicating any data in the property layer. + +Supported types: `string`, `number`, `boolean`, `datetime`, `null`, and arrays of a consistent type. + +→ [Properties in depth](./properties.md) + +--- + +## Relationships + +**Relationships** are the edges that connect records in the graph. They are first-class citizens — not foreign key references, not join tables — which means traversing them is fast regardless of dataset size. + +RushDB manages two relationship types automatically: + +| Type | Created by | Purpose | +| --------------------------------------------- | --------------- | --------------------------------------- | +| **Default** (`__RUSHDB__RELATION__DEFAULT__`) | Data ingestion | Parent → child links for nested objects | +| **Value** (`__RUSHDB__RELATION__VALUE__`) | Property system | Property node → Record connections | + +You can also define **custom relationships** of any type and direction between any two records — created, updated, and deleted through the API or SDKs at any time. + +→ [Relationships in depth](./relationships.mdx) + +--- + +## Data Ingestion + +RushDB accepts data the way it arrives — no upfront schema required. The ingestion pipeline does five things automatically: + +1. **Parse** — Walk the input with a breadth-first algorithm. Each nested object becomes a separate record. +2. **Infer types** — Every value is classified as `string`, `number`, `boolean`, `datetime`, or `null`. +3. **Assign labels** — Top-level records use the label you provide; nested objects derive theirs from the parent key name. +4. **Wire relationships** — Parent and child records are linked with default relationships. +5. **Index properties** — Property nodes are created and connected to each record via value relationships. + +A single `importJson` call on this input: + +```json +{ + "company": { + "name": "Acme Corp", + "founded": "2020-01-15T00:00:00Z", + "departments": [ + { "name": "Engineering", "headcount": 42 }, + { "name": "Design", "headcount": 12 } + ] + } +} +``` + +produces **4 linked records** (`Company`, `Department` × 2) — all typed, all connected. + +For flat, row-shaped data (CSV-like), use `createMany` instead — it skips the BFS decomposition and is the fastest write path. + +→ [Data Ingestion in depth](./data-ingestion.mdx) + +--- + +## Search + +All queries in RushDB use a single, composable **SearchQuery** structure. The `where` clause supports exact match, range, text contains, negation, boolean logic, and **multi-hop graph traversal** — querying across relationships in the same expression: + + + + +```typescript +const { data } = await db.records.find({ + labels: ['Article'], + where: { + status: 'published', + Author: { + // traverse the relationship to Author records + country: 'Germany' + } + }, + orderBy: { registeredAt: 'desc' }, + limit: 20 +}) +``` + + + + +```python +results = db.records.find({ + "labels": ["Article"], + "where": { + "status": "published", + "Author": { # traverse the relationship to Author records + "country": "Germany" + } + }, + "orderBy": { "registeredAt": "desc" }, + "limit": 20 +}) +``` + + + + +```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": { + "status": "published", + "Author": { "country": "Germany" } + }, + "orderBy": { "registeredAt": "desc" }, + "limit": 20 + }' +``` + + + + +→ [Search in depth](./search/introduction.md) + +--- + +## Semantic Search + +RushDB lets you search by **meaning**, not just exact values. Create an embedding index on any string property and every record that carries that property becomes searchable by natural-language similarity — while still composing with all standard field filters. + + + + +```typescript +const { data } = await db.ai.search({ + propertyName: 'description', + query: 'space exploration', + labels: ['Movie'], + where: { genre: 'sci-fi', year: { $gte: 2000 } }, + limit: 10 +}) +``` + + + + +```python +results = db.ai.search({ + "propertyName": "description", + "query": "space exploration", + "labels": ["Movie"], + "where": { "genre": "sci-fi", "year": { "$gte": 2000 } }, + "limit": 10 +}) +``` + + + + +```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": "description", + "query": "space exploration", + "labels": ["Movie"], + "where": { "genre": "sci-fi", "year": { "$gte": 2000 } }, + "limit": 10 + }' +``` + + + + +Two modes are available: + +- **Managed** — RushDB generates and stores embeddings automatically. +- **External (BYOV)** — Your application supplies pre-computed vectors; RushDB stores and indexes them. + +Both modes use Neo4j's native vector index and compose fully with the structured `where` clause. + +→ [Semantic Search in depth](./semantic-search.mdx) · [Bring Your Own Vectors](./bring-your-own-vectors.mdx) + +--- + +## Transactions + +A **Transaction** groups any number of read and write operations into a single atomic unit. Either all operations succeed and are committed, or none of them persist. + +RushDB transactions are built on Neo4j's native ACID guarantees: + +- **Atomicity** — All-or-nothing. No partial writes. +- **Consistency** — The database moves from one valid state to another. +- **Isolation** — Concurrent transactions do not interfere with each other. +- **Durability** — Committed changes survive system failures. + +Transactions have a configurable TTL. If the client does not commit before expiry, the transaction is automatically rolled back. + +→ [Transactions in depth](./transactions.mdx) + +--- + +## Storage Architecture + +RushDB uses a **dual storage model**: + +- **Neo4j** — stores all records, properties, and relationships. Graph traversal, vector similarity search, and ACID transactions all run here. +- **SQL** (SQLite locally, PostgreSQL in production) — stores operational metadata: users, workspaces, projects, and API tokens. + +This separation keeps account management in a familiar relational layer while keeping all knowledge graph operations in Neo4j, where they are most efficient. + +→ [Storage in depth](./storage.md) + +--- + +## RushDB as Agent Memory + +RushDB is designed to serve as **structured long-term memory for AI agents**. It provides three memory layers out of the box: + +| Layer | What it stores | RushDB primitive | +| -------------- | -------------------------------------------------------- | ------------------------------ | +| **Episodic** | Facts, events, entities, and their connections | Records + Relationships | +| **Semantic** | Meaning encoded as dense vectors | Vector Properties + AI Indexes | +| **Structural** | Schema: what labels, properties, and relationships exist | Ontology API | + +A typical agent retrieval flow: + +1. Call the **Ontology API** to discover what labels and properties exist in the current project. +2. Use a structured **`where` filter** to narrow candidates by known field values. +3. Apply a **`vector.similarity`** aggregation to re-rank by semantic relevance. +4. Return scored, fully structured records — not raw text chunks — to the agent. + +→ [Agent Memory Model in depth](./agent-memory-model.md) · [Ontology & Schema Discovery](./ontology-schema-discovery.md) diff --git a/docs/docs/concepts/pricing.md b/docs/docs/concepts/pricing.md deleted file mode 100644 index 8b74182a..00000000 --- a/docs/docs/concepts/pricing.md +++ /dev/null @@ -1,95 +0,0 @@ ---- -sidebar_position: 9 ---- - -# Pricing - -## Simple, Predictable Pricing - -RushDB pricing is based on [Knowledge Units (KU)](./knowledge-units.md) — a single unit that represents the structured knowledge created and maintained from your data. No infrastructure tiers, no node counts, no storage pricing. - -``` -You pay for knowledge created. Nothing else. -``` - -## Plans - -### Free - -- **100,000 KU / month** included -- Up to 2 projects -- Self-hosted support -- Bring Your Own Cloud (BYOC) — connect to your own Neo4j or Aura instance -- Community support -- No credit card required - -Perfect for prototypes, side projects, and getting started. - -### Pro — $29/month - -- **10,000,000 KU / month** included -- Overage at **$3 per additional million KU** — no hard stop, apps keep running -- Unlimited projects -- Priority support -- Team members (up to 3, then $10/member) -- Bring Your Own Cloud (BYOC) — connect to your own Neo4j or Aura instance - -Ideal for production applications and growing teams. Predictable base cost, pay-as-you-go beyond the included allowance. - -### Scale — from $99/month - -- **Usage-based** — $99 platform fee + **$2 per million KU** consumed -- No included KU bundle — cheaper per-KU rate than Pro at volume -- SLA guarantee -- Advanced support -- Unlimited team members -- Bring Your Own Cloud (BYOC) — connect to your own Neo4j or Aura instance - -For high-volume or highly variable workloads where you want the lowest per-KU rate without worrying about tiers. The $2/M KU rate on Scale is 33% cheaper than Pro's overage rate. - -### Enterprise - -- **Platform license** — flat fee, unlimited KU -- Bring Your Own Cloud (BYOC) -- Embedded / OEM use -- Dedicated support and SLA -- Custom contract - -For organisations embedding RushDB into their products or needing full data sovereignty. - -## Estimating Your KU Usage - -Use this formula to estimate your monthly KU consumption: - -``` -estimated KU ≈ records_per_day × 30 × avg_fields_per_record × nesting_factor -``` - -**Example:** -- 1,000 records/day -- 10 fields per record on average -- Flat structure (nesting factor ≈ 1.0) - -``` -1,000 × 30 × 10 × 1.0 = 300,000 KU/month → Pro plan -``` - -The interactive KU Calculator on the [pricing page](https://rushdb.com/pricing) can help you get a more precise estimate. - -## Self-Hosted - -Running RushDB on your own infrastructure? Self-hosted mode is **free and unlimited** — no KU limits, no billing. See the [self-hosting guide](../get-started/quick-tutorial) to get started. - -## FAQ - -**Can I exceed my plan's KU limit?** -On the Free plan, writes are blocked when the limit is reached — reads always continue. On Pro, overage is billed at $3 per million KU beyond the 10M included. On Scale there is no hard limit — you pay $2 per million KU consumed on top of the $99/month base. - -**Does deleting data reduce my KU usage?** -KU from creation operations is never reversed. However, once data is deleted, its ongoing stored footprint stops contributing to KU from that point forward. - -**Do read operations consume KU?** -Standard read and search operations do not consume KU. Heavy analytical operations (multi-hop traversals, vector similarity search at scale) may consume a small amount of KU. - -**Is there a free trial for paid plans?** -Yes — start on the Free plan with no credit card. Upgrade at any time and your remaining free KU carries over for the rest of the billing period. diff --git a/docs/docs/concepts/relationships.mdx b/docs/docs/concepts/relationships.mdx index 388a0812..93db5dc3 100644 --- a/docs/docs/concepts/relationships.mdx +++ b/docs/docs/concepts/relationships.mdx @@ -1,6 +1,7 @@ --- sidebar_position: 5 --- + # Relationships In RushDB, relationships are the connections that link Records together, creating a powerful graph structure that represents both the data itself and how different pieces of data relate to one another. These connections enable intuitive data modeling that aligns with how we naturally think about information and its associations. @@ -20,6 +21,7 @@ graph TD ``` These relationships are automatically created during the data import process when nested objects are detected. Learn more at [REST API - Import Data](../rest-api/records/import-data) or through the language-specific SDKs: + - [TypeScript SDK](../typescript-sdk/records/import-data) - [Python SDK](../python-sdk/records/import-data) @@ -42,16 +44,18 @@ This structure allows for finding connections between otherwise unrelated record Beyond the built-in relationships that RushDB creates automatically during data import, users can define and reconstruct relationships manually in any direction and of any type needed. This flexibility enables sophisticated data modeling that precisely captures your domain's relationship semantics. You can create, modify, and delete relationships programmatically using the [REST API](../rest-api/relationships) or through the language-specific SDKs: + - [TypeScript SDK](../typescript-sdk/relationships) - [Python SDK](../python-sdk/relationships) This capability allows you to: + - Define domain-specific relationship types (e.g., "BELONGS_TO", "MANAGES", "DEPENDS_ON") - Create relationships between previously unconnected records - Build complex graph structures that evolve over time - Restructure relationships as your data model changes -Bulk creation and many-to-many caution +### Bulk creation and many-to-many caution RushDB supports a bulk relationship creation endpoint (`POST /relationships/create-many`) that can either: @@ -60,6 +64,101 @@ RushDB supports a bulk relationship creation endpoint (`POST /relationships/crea The many-to-many mode is opt-in and guarded: the request must set a flag (e.g. `manyToMany`) and provide non-empty `where` filters for both sides; otherwise the server requires keys to perform a safe equality join. This prevents accidental, unbounded cartesian products which can be expensive to execute and store. +## Suggested Relationships in the Dashboard + +Imported data is often structurally useful but semantically incomplete. + +For nested JSON, RushDB can already see the parent-child structure and creates default relationships automatically. For flat data imported from systems like MongoDB, PostgreSQL exports, CSV, or external APIs, related records may arrive as separate collections with reference fields such as `userId`, `orderId`, or `addressRef`. In both cases, the ontology can describe the labels, properties, and existing edges, but it may not know the domain-specific relationship names you want to keep long term. + +The **Relationships** tab in the Dashboard helps close that gap. RushDB analyzes the project ontology after writes, suggests relationship patterns, and lets you approve only the patterns that match your model. + +> Suggested relationships require LLM analysis to be configured for the project. Without it, RushDB still creates default relationships for nested imports and supports manual relationship creation through the API and SDKs. + +```mermaid +sequenceDiagram + participant App as App / Import + participant RushDB + participant Ontology as Ontology API + participant LLM as Relationship Analyzer + participant UI as Dashboard + + App->>RushDB: Write or import records + RushDB-->>App: Write response returned + RushDB->>Ontology: Refresh schema snapshot + RushDB->>LLM: Analyze labels, fields, relationships + LLM-->>RushDB: Suggested patterns + UI->>RushDB: User approves a pattern + RushDB->>RushDB: Create or retype matching relationships + RushDB->>RushDB: Apply approved pattern to future writes +``` + +### Two kinds of suggestions + +RushDB distinguishes between two common cases. + +| Suggestion kind | When it appears | What approval does | +| ------------------- | ------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | +| **Match fields** | Two labels have reference-like fields, such as `ORDER.userId` and `USER.userId` | Creates relationships between matching records now and applies the same pattern to future writes | +| **Rename existing** | Nested JSON already created default relationships, but the edge type is generic | Replaces matching default relationships with a semantic type, such as `DEPARTMENT` -> `HAS_PROJECT` -> `PROJECT` | + +### Example: flat imported collections + +If users and orders are imported separately, no graph edge exists yet: + +```json +{ + "USER": [ + { "userId": "usr_001", "name": "Ava Chen" }, + { "userId": "usr_002", "name": "Noah Smith" } + ], + "ORDER": [ + { "orderId": "ord_101", "userId": "usr_001", "total": 129.5 }, + { "orderId": "ord_102", "userId": "usr_002", "total": 48.0 } + ] +} +``` + +The analyzer can suggest a **Match fields** pattern: + +```mermaid +graph LR + U["USER
userId = usr_001"] -->|"PLACED_ORDER"| O["ORDER
userId = usr_001"] +``` + +Approving the pattern creates matching relationships for existing records and keeps applying the same rule as more orders arrive. + +### Example: nested import with generic edges + +Nested payloads already contain structure: + +```json +{ + "DEPARTMENT": [ + { + "name": "Engineering", + "PROJECT": [{ "name": "Search relevance" }, { "name": "Ontology explorer" }] + } + ] +} +``` + +RushDB imports this as `DEPARTMENT` connected to `PROJECT` through default relationships. The analyzer should not invent a field match like `DEPARTMENT.name` -> `PROJECT.name`; those fields are descriptive, not references. Instead, it can suggest a **Rename existing** pattern: + +```mermaid +graph LR + BeforeA["DEPARTMENT"] -->|"__RUSHDB__RELATION__DEFAULT__"| BeforeB["PROJECT"] + AfterA["DEPARTMENT"] -->|"HAS_PROJECT"| AfterB["PROJECT"] +``` + +Approving this kind of pattern retypes the existing default relationships into semantic relationships. Future nested imports with the same structure can be upgraded the same way. + +### Why this helps + +- **Less manual stitching:** You do not need to write one-off relationship scripts for every imported collection. +- **Safer graph evolution:** Suggestions stay in draft form until approved, and ignored suggestions can be removed later if you want them reconsidered. +- **Better ontology for agents:** Semantic relationship types make schema discovery more useful. An agent can reason over `USER -> PLACED_ORDER -> ORDER` more reliably than over a generic default edge. +- **Lower write latency:** Relationship discovery and application run as side effects after writes, so record writes can return without waiting for graph enrichment to finish. + ## Nested Data Example Consider this JSON structure: @@ -84,6 +183,7 @@ Consider this JSON structure: ``` When imported into RushDB, this is transformed into a graph structure with: + - 3 Records (Person, Contact, and Address) - 8 Properties (Name, Age, Email, Phone, Street, City, State, ZipCode) - Default relationships connecting Person to Contact and Person to Address @@ -130,17 +230,32 @@ This relationship model provides several advantages: Each interface covers creating, querying, and managing relationships — pick the one that fits your stack: -
- +
+ TypeScript SDK - attach · detach · relationships API + + attach · detach · relationships API + - + Python SDK - attach · detach · relationships API + + attach · detach · relationships API + - + REST API - POST /relationships · bulk create + + POST /relationships · bulk create +
diff --git a/docs/docs/concepts/semantic-search.mdx b/docs/docs/concepts/semantic-search.mdx index e7480dad..2155d545 100644 --- a/docs/docs/concepts/semantic-search.mdx +++ b/docs/docs/concepts/semantic-search.mdx @@ -2,6 +2,9 @@ sidebar_position: 9 --- +import Tabs from '@site/src/components/LanguageTabs' +import TabItem from '@theme/TabItem' + # Semantic Search RushDB lets you search records by **meaning**, not just exact field values. Create an embedding index on any string property and every record that carries that property becomes searchable by natural-language similarity — while still supporting all the usual field filters, pagination, and graph traversal. @@ -23,25 +26,75 @@ graph LR ## Managed vs. External Indexes -| Aspect | Managed | External (BYOV) | -|---|---|---| -| Embeddings generated by | RushDB (server-side) | Your application | -| Write flow | Automatic on record create/update | Supply vectors via `upsertVectors` or inline on write | -| Search input | Natural-language `query` string | Pre-computed `queryVector` array | -| Model control | RushDB-managed model | Any model, any dimension | +| Aspect | Managed | External (BYOV) | +| ----------------------- | --------------------------------- | ----------------------------------------------------- | +| Embeddings generated by | RushDB (server-side) | Your application | +| Write flow | Automatic on record create/update | Supply vectors via `upsertVectors` or inline on write | +| Search input | Natural-language `query` string | Pre-computed `queryVector` array | +| Model control | RushDB-managed model | Any model, any dimension | Both types store vectors on the value relationship between the property node and the record node, using Neo4j's native vector index for fast retrieval. +## Why Vectors Live on Property–Record Edges + +RushDB stores embeddings **on the edge** between the Property node and the Record node, not on the record itself. This is a deliberate design choice with several concrete benefits: + +- **One index spans every label.** A Property node (e.g. `description:string`) is shared across all records that carry that field, regardless of their label. A vector index on the Property→Record edge therefore covers `Article`, `Product`, `Movie`, and any other label with a `description` field — from a single index, with no duplication. +- **Records stay clean.** Record nodes contain only typed scalar properties. Embedding arrays — which can be hundreds or thousands of floats — live on the relationship layer. This keeps record storage lean and read performance high for non-vector queries. +- **Graph traversal and similarity compose naturally.** Because the vector is a property of a graph edge, Neo4j can traverse relationships and apply cosine scoring in the same query step. Multi-hop traversal followed by semantic re-ranking requires no extra join or post-processing pass. +- **Automatic candidate scoping.** Only records actually connected to the indexed Property node are ever considered for similarity ranking. Tenant isolation, label filtering, and `where` prefiltering all reduce the candidate set through normal graph traversal — no metadata tricks required. + ## Combining with Field Filters Semantic search is not an either/or — it composes with RushDB's structured query capabilities. Pass a `where` clause to pre-filter candidates before similarity ranking: + + + +```typescript +const { data } = await db.ai.search({ + propertyName: 'description', + query: 'space exploration', + labels: ['Movie'], + where: { genre: 'sci-fi', year: { $gte: 2000 } }, + limit: 10 +}) +// results ranked by cosine similarity, scoped to sci-fi films from 2000+ ``` -Search "space exploration" - WHERE genre = "sci-fi" AND year >= 2000 - LIMIT 10 + + + + +```python +results = db.ai.search({ + "propertyName": "description", + "query": "space exploration", + "labels": ["Movie"], + "where": { "genre": "sci-fi", "year": { "$gte": 2000 } }, + "limit": 10 +}) +# results.data ranked by cosine similarity, scoped to sci-fi films from 2000+ +``` + + + + +```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": "description", + "query": "space exploration", + "labels": ["Movie"], + "where": { "genre": "sci-fi", "year": { "$gte": 2000 } }, + "limit": 10 + }' ``` + + + This narrows the vector search to only matching records, keeping results precise and fast. ## Two Ways to Search Semantically @@ -62,21 +115,21 @@ For advanced use cases, add a `vector.similarity.cosine` or `vector.similarity.e ## Index Lifecycle -| State | Description | -|---|---| -| `pending` | Index created, backfill not yet started. | -| `indexing` | Backfill in progress — existing records are being embedded. | -| `ready` | All records indexed. New records are embedded on write automatically. | +| State | Description | +| ---------- | --------------------------------------------------------------------- | +| `pending` | Index created, backfill not yet started. | +| `indexing` | Backfill in progress — existing records are being embedded. | +| `ready` | All records indexed. New records are embedded on write automatically. | You can check index status at any time and list all indexes for a project. ## When to Use Semantic Search -| Scenario | Approach | -|---|---| -| User knows the exact value | Structured `where` filter | -| User describes what they want in natural language | `db.ai.search()` with `query` | -| Combine meaning + exact constraints | `db.ai.search()` with `where` pre-filter | +| Scenario | Approach | +| -------------------------------------------------------- | -------------------------------------------------------- | +| User knows the exact value | Structured `where` filter | +| User describes what they want in natural language | `db.ai.search()` with `query` | +| Combine meaning + exact constraints | `db.ai.search()` with `where` pre-filter | | Need groupBy, collect, or multi-hop alongside similarity | `db.records.find()` with `vector.similarity` aggregation | → See also [Agent Memory Model](./agent-memory-model.md) for how semantic search fits into the three-layer retrieval stack. @@ -87,17 +140,32 @@ You can check index status at any time and list all indexes for a project. Each interface covers search, indexing, and BYOV — pick the one that fits your stack: -
- +
+ TypeScript SDK - db.ai.search · db.ai.indexes · BYOV + + db.ai.search · db.ai.indexes · BYOV + - + Python SDK - db.ai.search · db.ai.indexes · BYOV + + db.ai.search · db.ai.indexes · BYOV + - + REST API - POST /ai/search · /ai/indexes · BYOV + + POST /ai/search · /ai/indexes · BYOV +
diff --git a/docs/docs/tutorials/byov-when-and-why.mdx b/docs/docs/tutorials/byov-when-and-why.mdx new file mode 100644 index 00000000..6693b78c --- /dev/null +++ b/docs/docs/tutorials/byov-when-and-why.mdx @@ -0,0 +1,236 @@ +--- +sidebar_position: 36 +title: 'BYOV in Practice: When and Why to Bring Your Own Vectors' +description: A case study showing exactly when BYOV makes sense, when it doesn't, and a complete walkthrough of a real scenario where you already have vectors from your own pipeline. +tags: [AI, BYOV, Embeddings, Case Study, TypeScript] +--- + +import Tabs from '@site/src/components/LanguageTabs' +import TabItem from '@theme/TabItem' + +# BYOV in Practice: When and Why to Bring Your Own Vectors + +> "I looked over the docs, but am struggling to understand how to apply it in practice. Is there a case study I can read?" + +That's a fair question. This tutorial exists to answer it. + +BYOV is one of those features that feels abstract until you hit the problem it solves — at which point it becomes obvious. This walkthrough describes a realistic scenario, explains the decision, and shows the full implementation. + +--- + +## The scenario + +A team is building a **job listing search product**. They have: + +- A PostgreSQL database with ~200 000 job postings (title, description, company, location, salary range) +- An existing ML pipeline that produces 768-dimensional embeddings using a **domain-specific model fine-tuned on job descriptions** — better recall than a generic model +- A compliance requirement: raw job descriptions must not leave their infrastructure (the fine-tuned model runs on-premise) + +They want to migrate to RushDB to get graph-based relationships (company → department → role, applicant → skills → jobs), unified filtering, and semantic search — all in one place. + +--- + +## Why managed embeddings don't fit here + +Managed embeddings are the right default. You point RushDB at a property, it handles the rest — generation, storage, backfill, and search. + +But this team has two hard blockers: + +| Blocker | Why managed doesn't solve it | +| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| Fine-tuned domain model | RushDB's managed models are general-purpose. A fine-tuned job-description model consistently outperforms them on this dataset. | +| Compliance: no raw text off-prem | Managed embeddings require RushDB to call an embedding model (OpenAI, etc.) with the raw text. That's off-prem. | + +If neither of these applies to you — if a general-purpose model is good enough and compliance is not a concern — **use managed embeddings**. They're simpler. + +BYOV makes sense when: + +- You have or need a fine-tuned, specialized, or multimodal embedding model +- Compliance prevents raw text from leaving your infrastructure +- You already produce vectors in a separate pipeline and want to avoid double-embedding +- You need different embedding models for different fields on the same record + +--- + +## The plan + +``` +[PostgreSQL] ──► [embedding pipeline] ──► [RushDB] + raw jobs on-prem model store records + vectors + (768-dim, cosine) search with queryVector +``` + +1. Create an external embedding index in RushDB +2. Import existing jobs with inline vectors in a batch +3. Keep the pipeline running: new jobs → embed → write to RushDB +4. Search using pre-computed query vectors + +--- + +## Step 1: Create the external index + +```typescript +import RushDB from '@rushdb/javascript-sdk' + +const db = new RushDB(process.env.RUSHDB_API_KEY!) + +const index = await db.ai.indexes.create({ + label: 'Job', + propertyName: 'description', + external: true, // your pipeline supplies vectors + similarityFunction: 'cosine', + dimensions: 768 // must match your model's output +}) + +console.log(index.status) // 'awaiting_vectors' +``` + +The index starts with status `awaiting_vectors`. It becomes `ready` once the first vector is written. + +--- + +## Step 2: Backfill existing records + +Your PostgreSQL table has 200 000 rows. The on-prem embedding pipeline has already produced vectors for all of them. Now load them into RushDB in batches: + +```typescript +import RushDB from '@rushdb/javascript-sdk' + +const db = new RushDB(process.env.RUSHDB_API_KEY!) + +// Hypothetical: your existing pipeline's embed function +import { embedBatch } from './embedding-pipeline' +import { fetchJobsPage } from './postgres' + +const BATCH_SIZE = 100 + +async function backfill() { + let offset = 0 + + while (true) { + const rows = await fetchJobsPage({ limit: BATCH_SIZE, offset }) + if (rows.length === 0) break + + // Embed the descriptions using your on-prem model + const vectors = await embedBatch(rows.map((r) => r.description)) + + // Write records with inline vectors — one round-trip per batch + await db.records.createMany( + rows.map((row, i) => ({ + __label: 'Job', + id: row.id, // preserve your existing IDs + title: row.title, + description: row.description, + company: row.company, + location: row.location, + salaryMin: row.salary_min, + salaryMax: row.salary_max, + __vectors: [ + { + propertyName: 'description', + vector: vectors[i] + } + ] + })) + ) + + offset += BATCH_SIZE + console.log(`Imported ${offset} jobs`) + } +} + +backfill() +``` + +`__vectors` is the inline write path — the record and its vector are stored in one call. No separate upsert step needed. + +--- + +## Step 3: Keep the pipeline current + +For new jobs added after migration, the same pattern applies. In your ingestion handler: + +```typescript +async function ingestJob(job: JobRow) { + // Embed on-prem before sending to RushDB + const [vector] = await embedBatch([job.description]) + + await db.records.create('Job', { + id: job.id, + title: job.title, + description: job.description, + company: job.company, + location: job.location, + salaryMin: job.salary_min, + salaryMax: job.salary_max, + __vectors: [{ propertyName: 'description', vector }] + }) +} +``` + +The vector travels with the record. The raw description stays on-prem. + +--- + +## Step 4: Search + +At query time, embed the user's search query on-prem and pass the resulting vector to RushDB: + +```typescript +async function searchJobs(userQuery: string, location?: string) { + // Embed the query with the same model — consistency is critical + const [queryVector] = await embedBatch([userQuery]) + + const results = await db.ai.search({ + label: 'Job', + propertyName: 'description', + queryVector, // pre-computed vector, not a text string + where: location ? { location: { $contains: location } } : undefined, + limit: 20 + }) + + return results.data +} + +// Usage +const jobs = await searchJobs('senior backend engineer distributed systems', 'Berlin') +``` + +`where` clauses, pagination, and `orderBy` work exactly the same as with managed search. The only difference is `queryVector` instead of `query`. + +--- + +## What you get + +After migration: + +- Semantic search that outperforms generic models on job-specific vocabulary +- Raw text never left your network — only vectors did +- Graph relationships (Company → Department → Job) queryable from the same API +- Unified filter + vector search — e.g. "jobs semantically similar to X, in Berlin, salary > 80k" + +--- + +## When you don't need BYOV + +If you find yourself asking "should I use BYOV?" — you probably don't need it yet. Ask these questions: + +| Question | If yes → | +| ------------------------------------------------------------------------------ | ----------- | +| Is a general-purpose model (OpenAI, Cohere, etc.) good enough for my use case? | Use managed | +| Can I send raw text to a third-party embedding API? | Use managed | +| Do I want the simplest possible setup? | Use managed | +| Do I have a fine-tuned or multimodal model I can't replace? | Use BYOV | +| Do compliance rules prevent raw text from leaving my infrastructure? | Use BYOV | +| Am I already producing vectors in a separate pipeline? | Use BYOV | + +Managed embeddings are the right default for most projects. BYOV is the escape hatch for when you've hit a wall that managed can't cross. + +--- + +## Further reading + +- [BYOV concept page](/concepts/bring-your-own-vectors) — the what and how +- [BYOV External Embeddings tutorial](/tutorials/byov-external-embeddings) — step-by-step with TypeScript, Python, and Shell +- [REST API: Advanced Indexing](/rest-api/ai/advanced-indexing) — full BYOV API reference including `upsertVectors` +- [Semantic Search concept](/concepts/semantic-search) — managed vs. external comparison diff --git a/docs/docs/tutorials/is-rushdb-right-for-me.mdx b/docs/docs/tutorials/is-rushdb-right-for-me.mdx new file mode 100644 index 00000000..f3b6522c --- /dev/null +++ b/docs/docs/tutorials/is-rushdb-right-for-me.mdx @@ -0,0 +1,380 @@ +--- +sidebar_position: 5 +title: 'Is RushDB Right for My Problem? A Practical Decision Guide' +description: A scenario-driven catalog showing seven problems RushDB is designed for, where it outperforms fragmented stacks, and where it isn't the right fit. +tags: [Getting Started, Architecture, Case Study, TypeScript] +--- + +import Tabs from '@site/src/components/LanguageTabs' +import TabItem from '@theme/TabItem' + +# Is RushDB Right for My Problem? + +Before committing to a new database, you want to know: **will this actually make my life easier, or just move complexity around?** + +This tutorial answers that by showing seven concrete scenarios where RushDB fits well, then tells you honestly where it does not. + +--- + +## Scenario 1 — Rapid prototyping: from idea to working app + +**The problem:** every new side project, prototype, or internal tool hits the same wall — before you can build the interesting part you have to set up a database schema, configure indexes, wire up a search layer, and manage migrations. By the time the infrastructure is ready, the momentum is gone. + +RushDB removes that wall entirely. One API key, one connection line, and you have a persistent, typed, searchable, relationship-aware database that accepts any JSON you throw at it. + +```typescript +import RushDB from '@rushdb/javascript-sdk' + +const db = new RushDB(process.env.RUSHDB_API_KEY!) +// That's it. Persistent storage is ready. +// No schema. No migrations. No index configuration. +``` + +Push anything: + +```typescript +// A product catalogue +await db.records.importJson({ label: 'PRODUCT', data: products }) + +// A knowledge base +await db.records.importJson({ label: 'ARTICLE', data: articles }) + +// A recipe collection with nested ingredients and steps +await db.records.importJson({ + label: 'RECIPE', + data: { + name: 'Sourdough', + prepTime: 30, + ingredients: [ + { name: 'flour', amount: 500, unit: 'g' }, + { name: 'water', amount: 375, unit: 'ml' } + ], + steps: [{ order: 1, text: 'Mix flour and water...' }] + } +}) +// RushDB creates RECIPE + INGREDIENT + STEP records and wires the relationships. +// Types inferred. IDs assigned. Ready to query. +``` + +Semantic search, relationship traversal, and faceted filtering are available on the same data immediately — no extra configuration: + +```typescript +// Find recipes semantically similar to "quick weeknight dinner" +// (after creating a managed index — one more call) +const results = await db.ai.search({ + label: 'RECIPE', + propertyName: 'name', + query: 'quick weeknight dinner', + where: { prepTime: { $lte: 30 } } +}) +``` + +### Works the same way for any domain + +| What you're building | What you push | What you get | +| ----------------------------- | ----------------------------- | ------------------------------------------------------------------------ | +| Product catalogue | Nested product JSON | Filterable, searchable catalogue with category relationships | +| Knowledge management platform | Articles, tags, authors | Full-text + semantic search, author graphs, tag traversal | +| Community platform | Users, posts, topics | Relationship-aware feeds, expertise graphs, similar-post recommendations | +| Personal CRM | Contacts, interactions, notes | Connected timeline, semantic recall, relationship history | +| Internal tool / dashboard | Any CSV or API response | Typed, queryable dataset in seconds | + +### Vibe-coding with AI assistants + +If you use an AI coding assistant (Claude, Copilot, Cursor, Windsurf), install `@rushdb/skills` once and your assistant already knows how RushDB works — the query syntax, data modeling patterns, relationship design, faceted search, and memory layer usage: + +```bash +npx skills add rush-db/rushdb --path packages/skills +# or: npm install @rushdb/skills +``` + +After that, describing what you want is enough. The assistant constructs the correct `importJson`, `find`, `attach`, and `ai.search` calls without you having to look anything up. The typical flow: + +``` +You: "I want to build a recipe app with ingredient relationships + and the ability to search by dietary preferences" +Assistant: [writes importJson, creates embedding index, writes ai.search — all correct] +You: "Add a filter for prep time under 30 minutes" +Assistant: [adds where: { prepTime: { $lte: 30 } } to the existing search call] +``` + +No docs lookup. No trial and error on the query shape. Just describe and iterate. + +**Go deeper:** [Thinking in Graphs](/tutorials/thinking-in-graphs) · [Customer 360](/tutorials/customer-360) · [`@rushdb/skills` on npm](https://www.npmjs.com/package/@rushdb/skills) + +--- + +## Scenario 2 — Faceted search experiences + +**The problem:** you want to build a filter sidebar — "filter by category, price range, brand, rating" — without knowing the full list of valid values upfront, and without maintaining a separate index. + +RushDB's **Properties API** and **Labels API** expose the shape of your stored data as live metadata. No extra indexing step. No sync job. + +```typescript +// What labels exist and how many records each has? +const labels = await db.labels.find() +// → [{ name: 'PRODUCT', count: 12400 }, { name: 'CATEGORY', count: 38 }, ...] + +// What values does the 'status' property have across all PRODUCT records? +const statusValues = await db.properties.values({ + labels: ['PRODUCT'], + where: { name: 'status' } +}) +// → { values: ['available', 'out_of_stock', 'discontinued'] } + +// What is the price range across filtered products? +const priceRange = await db.properties.values({ + labels: ['PRODUCT'], + where: { name: 'price', category: 'Electronics' } // pre-filtered +}) +// → { min: 9.99, max: 3299.00 } +``` + +Wire these directly to your filter UI — every facet is always in sync with the actual data, zero maintenance. + +**Go deeper:** [Properties API reference](/rest-api/properties) · [Labels API reference](/rest-api/labels) + +--- + +## Scenario 3 — RAG in a few lines of code + +**The problem:** you want retrieval-augmented generation but you do not want to operate a vector database, a chunking pipeline, an embedding sync job, and an LLM orchestration layer separately. + +```typescript +// 1. Push documents — RushDB infers types, assigns IDs +await db.records.createMany( + 'CHUNK', + chunks.map((c) => ({ + source: c.filename, + section: c.heading, + body: c.text + })) +) + +// 2. Create a managed embedding index on the 'body' field +// RushDB embeds every record automatically and backfills existing ones +await db.ai.indexes.create({ + label: 'CHUNK', + propertyName: 'body', + sourceType: 'managed' +}) + +// 3. Retrieve relevant chunks at query time — filtered + ranked by similarity +const context = await db.ai.search({ + label: 'CHUNK', + propertyName: 'body', + query: userQuestion, + where: { source: { $in: allowedSources } }, // access-control filter + limit: 5 +}) + +// 4. Pass to your LLM of choice +const answer = await llm.complete({ + system: 'Answer based on the context provided.', + context: context.data.map((c) => c.body).join('\n\n'), + user: userQuestion +}) +``` + +No Pinecone, no LangChain retriever, no sync pipeline. When documents change, update the record — the embedding updates automatically. + +**Go deeper:** [RAG Pipeline in Minutes](/tutorials/rag-pipeline) · [Hybrid Retrieval](/tutorials/hybrid-retrieval) + +--- + +## Scenario 4 — Knowledge graphs from messy data + +**The problem:** you have heterogeneous, loosely structured data from multiple sources — CSV exports, API responses, scraped JSON — and you want a traversable, queryable knowledge graph without writing a schema upfront. + +```typescript +// Import a CSV of companies — types inferred, no schema needed +await db.records.importCsv({ + label: 'COMPANY', + data: csvString, + options: { suggestTypes: true, capitalizeLabels: true } +}) + +// Import a JSON API response — nested objects become linked records automatically +await db.records.importJson({ + label: 'INCIDENT', + data: apiResponse, // arbitrarily nested — RushDB handles it + options: { mergeBy: ['incidentId'], mergeStrategy: 'append' } +}) + +// Now traverse: which companies are linked to open incidents in region EU? +const exposed = await db.records.find({ + labels: ['COMPANY'], + where: { + region: 'EU', + incidents: { + // relationship-scoped filter + status: 'open', + severity: { $gte: 3 } + } + } +}) +``` + +Different sources, different shapes, unified graph — without a single migration. + +**Go deeper:** [Data Ingestion concepts](/concepts/data-ingestion) · [Research Knowledge Graph](/tutorials/research-knowledge-graph) · [Supply Chain Traceability](/tutorials/supply-chain-traceability) + +--- + +## Scenario 5 — Analytical workloads over connected data + +**The problem:** you need cohort analysis, aggregations, and time-bucketing across records — but the grouping dimensions cross relationship boundaries (e.g., "active users by plan tier, broken down by their most-used feature"). + +RushDB's `select`, `groupBy`, and relationship-aware `where` compose into analytical queries without leaving the SDK: + +```typescript +// Monthly signups per plan tier — time-bucketed +const signupsByMonth = await db.records.find({ + labels: ['USER'], + where: { createdAt: { $gte: '2025-01-01' } }, + groupBy: ['$record.planTier'], + select: { + planTier: '$record.planTier', + signups: { $count: '*' }, + month: { $timeBucket: { field: '$record.createdAt', unit: 'month' } } + }, + orderBy: { month: 'asc' } +}) + +// Aggregate across relationships: avg order value per customer segment +const avgOrderBySegment = await db.records.find({ + labels: ['ORDER'], + where: { + status: 'completed', + customer: { segment: { $in: ['enterprise', 'pro'] } } // traverse ORDER → CUSTOMER + }, + groupBy: ['$customer.segment'], + select: { + segment: '$customer.segment', + avgValue: { $avg: '$record.total' }, + orderCount: { $count: '*' } + } +}) +``` + +**Go deeper:** [Select Expressions](/concepts/search/select) · [SearchQuery Advanced Patterns](/tutorials/searchquery-advanced-patterns) + +--- + +## Scenario 6 — Persistent memory for agents and LLM apps + +**The problem:** agent memory that lives in application code or in-process variables disappears on restart, does not survive microservice boundaries, and cannot be queried by other services. Flat vector retrieval returns similar chunks but loses the connections between them. + +RushDB stores three kinds of agent memory as a connected graph: + +```typescript +// FACT — durable property of an entity (never stale, queryable) +await db.records.create('FACT', { + subject: 'user:u_42', + predicate: 'prefers', + object: 'TypeScript', + confidence: 0.95, + source: 'inferred_from_stack' +}) + +// EPISODE — time-stamped interaction (persists across restarts) +await db.records.importJson({ + label: 'EPISODE', + data: { + sessionId: 's_789', + timestamp: new Date().toISOString(), + userMessage: 'How do I configure the embedding model?', + agentResponse: '...', + documentsReferenced: [{ docId: 'doc_config', title: 'Embedding Configuration' }] + } +}) + +// Later — another service, after restart, across microservices: +const recentContext = await db.records.find({ + labels: ['EPISODE'], + where: { + sessionId: 's_789', + timestamp: { $gte: oneHourAgo } + }, + orderBy: { timestamp: 'desc' }, + limit: 10 +}) +``` + +Memory is a graph node. It can be linked to other facts, episodes, and documents. Any service with the API key can read it. No restart clears it. + +**Go deeper:** [RushDB as a Memory Layer](/tutorials/memory-layer) · [Building Team Memory](/tutorials/building-team-memory) · [Episodic Memory](/tutorials/episodic-memory) + +--- + +## Scenario 7 — Second brain and MCP context for LLMs + +**The problem:** LLMs forget context between sessions, lose decisions made in past conversations, and cannot reason over the history of a project or team. Each new session starts from zero. + +RushDB acts as a persistent second brain — and exposes it to any LLM client via the **MCP server** without any custom integration code. + +```typescript +// Store a decision made during a planning session +await db.records.importJson({ + label: 'DECISION', + data: { + title: 'Use BYOV for legal document embeddings', + rationale: + 'Compliance requires raw text to stay on-prem. Fine-tuned legal model gives 18% better recall.', + madeBy: 'team:engineering', + madeAt: new Date().toISOString(), + linkedTo: [ + { type: 'PROJECT', id: 'proj_legal_search' }, + { type: 'CONSTRAINT', description: 'Data residency: EU-only' } + ] + } +}) +``` + +When the same team opens a new session — weeks later, different LLM client, different developer — the MCP server gives the model instant access to that decision, its rationale, and everything it links to. The intelligence does not live in anyone's chat history. It lives in the graph. + +```yaml +# mcp.yaml — drop this next to your project, connect any MCP-compatible LLM client +servers: + rushdb: + type: http + url: https://api.rushdb.com/mcp + headers: + Authorization: Bearer ${RUSHDB_API_KEY} +``` + +**Go deeper:** [MCP Server introduction](/mcp-server/introduction) · [Agent Safe Query Planning](/tutorials/agent-safe-query-planning) · [Agent Skills with OpenClaw](/tutorials/agent-skills-with-openclaw) + +--- + +## Where RushDB is not the right fit + +| Scenario | Better fit | +| --------------------------------------------------------------------- | ---------------------------- | +| Pure transactional workloads (banking ledgers, inventory) | PostgreSQL / CockroachDB | +| Time-series data at very high write rates (metrics, sensor streams) | InfluxDB / TimescaleDB | +| Blob / file storage | S3 + metadata index | +| Simple CRUD with flat, stable schema and no search requirements | Any relational DB | +| Data warehouse / analytical queries over hundreds of millions of rows | BigQuery / Redshift / DuckDB | + +RushDB is not a general-purpose database. It is designed for data that is **connected, evolving, and queried in multiple dimensions at once**. + +--- + +## Quick self-assessment + +Score one point for each row that describes your problem: + +``` +□ Data is nested or comes from multiple sources with inconsistent shapes +□ Relationships between records matter as much as the records themselves +□ You want full-text or semantic search co-located with field filters +□ Your schema changes faster than ALTER TABLE migrations allow +□ You need agent or LLM memory that survives restarts and service boundaries +□ You want to expose a knowledge graph to an LLM without custom integration +□ You are building faceted search and don't want a separate indexing pipeline +``` + +**3 or more:** RushDB is a strong fit. Start with the [quick tutorial](/get-started/quick-tutorial). +**1–2:** RushDB may help, but a simpler tool might too. Read [Thinking in Graphs](/tutorials/thinking-in-graphs) to calibrate. +**0:** You probably don't need RushDB right now. diff --git a/docs/docusaurus.config.ts b/docs/docusaurus.config.ts index d5b277f2..a66eb691 100644 --- a/docs/docusaurus.config.ts +++ b/docs/docusaurus.config.ts @@ -176,6 +176,28 @@ const config: Config = { plugins: [ tailwindPlugin, require('./plugins/tutorials-data.cjs'), + [ + '@docusaurus/plugin-client-redirects', + { + redirects: [ + // Old basic-concepts/ → concepts/ + { from: '/basic-concepts/properties', to: '/concepts/properties' }, + { from: '/basic-concepts/records', to: '/concepts/records' }, + { from: '/basic-concepts/relations', to: '/concepts/relationships' }, + { from: '/basic-concepts/transactions', to: '/concepts/transactions' }, + // Old advanced/ → concepts/ + { from: '/advanced/properties', to: '/concepts/properties' }, + { from: '/advanced/data-types', to: '/concepts/properties' }, + { from: '/advanced/records', to: '/concepts/records' }, + { from: '/advanced/relationships', to: '/concepts/relationships' }, + { from: '/advanced/querying-data', to: '/concepts/search/introduction' }, + { from: '/advanced/search-aggregation', to: '/concepts/search/introduction' }, + { from: '/advanced/enhanced-typescript', to: '/typescript-sdk/introduction' }, + // Old python-sdk/records-api → python-sdk/records/ + { from: '/python-sdk/records-api', to: '/python-sdk/records/create-records' } + ] + } + ], async function pluginLlmsTxt(context) { return { name: 'llms-txt-plugin', diff --git a/docs/package.json b/docs/package.json index 30121145..75383d24 100644 --- a/docs/package.json +++ b/docs/package.json @@ -17,6 +17,7 @@ }, "dependencies": { "@docusaurus/core": "^3.8.1", + "@docusaurus/plugin-client-redirects": "^3.8.1", "@docusaurus/preset-classic": "^3.8.1", "@docusaurus/theme-mermaid": "^3.8.1", "@mdx-js/react": "^3.1.0", diff --git a/docs/sidebars.ts b/docs/sidebars.ts index 50640a4b..8c1ac668 100644 --- a/docs/sidebars.ts +++ b/docs/sidebars.ts @@ -50,12 +50,14 @@ const sidebars: SidebarsConfig = { label: 'Core Concepts', collapsed: false, items: [ + 'concepts/index', 'concepts/records', 'concepts/data-ingestion', 'concepts/labels', 'concepts/properties', 'concepts/relationships', 'concepts/semantic-search', + 'concepts/bring-your-own-vectors', 'concepts/storage', 'concepts/transactions', { @@ -71,6 +73,12 @@ const sidebars: SidebarsConfig = { ] } ] + }, + { + type: 'category', + label: 'Pricing & Billing', + collapsed: false, + items: ['concepts/knowledge-units', 'concepts/billing-model'] } ] } diff --git a/docs/static/robots.txt b/docs/static/robots.txt new file mode 100644 index 00000000..107bee30 --- /dev/null +++ b/docs/static/robots.txt @@ -0,0 +1,8 @@ +User-agent: * +Allow: / + +# Prevent crawlers from following API example URLs embedded in LLM-oriented text files. +# Blocks /llms-full.txt, /llms-concepts-full.txt, etc. but keeps /llms.txt accessible for AI crawlers. +Disallow: /llms- + +Sitemap: https://docs.rushdb.com/sitemap.xml diff --git a/platform/core/.env.example b/platform/core/.env.example index 29f13209..d14d2b1b 100644 --- a/platform/core/.env.example +++ b/platform/core/.env.example @@ -119,6 +119,16 @@ SQL_DB_TYPE=sqlite # RUSHDB_EMBEDDING_MAX_RUNTIME_MS=50000 # Maximum wall-clock ms the backfill scheduler spends on a single embedding index per tick (default: 50000). +# Relationship suggestions (OpenAI-compatible chat completions) +# RUSHDB_LLM_BASE_URL=https://api.openai.com/v1 +# Base URL of the OpenAI-compatible chat completions endpoint. + +# RUSHDB_LLM_API_KEY=sk-... +# API key (Bearer token) for relationship pattern suggestions. + +# RUSHDB_LLM_MODEL=gpt-4.1-mini +# Chat model identifier used to infer relationship candidates from ontology. + # --- Pagination --- # RUSHDB_PAGINATION_DEFAULT_LIMIT=100 @@ -126,4 +136,3 @@ SQL_DB_TYPE=sqlite # RUSHDB_PAGINATION_MAX_LIMIT=1000 # Maximum allowed `limit` value. Requests above this are clamped. - diff --git a/platform/core/src/common/interceptors/run-side-effect.interceptor.ts b/platform/core/src/common/interceptors/run-side-effect.interceptor.ts index a5c41bc3..b3ab995a 100644 --- a/platform/core/src/common/interceptors/run-side-effect.interceptor.ts +++ b/platform/core/src/common/interceptors/run-side-effect.interceptor.ts @@ -1,15 +1,27 @@ -import { CallHandler, ExecutionContext, Injectable, Logger, mixin, NestInterceptor } from '@nestjs/common' +import { + CallHandler, + ExecutionContext, + Injectable, + Logger, + mixin, + NestInterceptor, + Optional +} from '@nestjs/common' import { Session, Transaction } from 'neo4j-driver' import { Observable } from 'rxjs' import { tap } from 'rxjs/operators' import { isDevMode } from '@/common/utils/isDevMode' +import { AiService } from '@/core/ai/ai.service' +import { RelationshipPatternsService } from '@/core/relationship-patterns/relationship-patterns.service' import { ProjectService } from '@/dashboard/project/project.service' import { dbContextStorage } from '@/database/db-context' import { NeogmaService } from '@/database/neogma/neogma.service' export enum ESideEffectType { - RECOUNT_PROJECT_STRUCTURE = 'recountProjectNodes' + RECOUNT_PROJECT_STRUCTURE = 'recountProjectNodes', + RECALCULATE_ONTOLOGY_CACHE = 'recalculateOntologyCache', + RELATIONSHIP_AUTOMATION_AFTER_WRITE = 'relationshipAutomationAfterWrite' } /** @@ -41,7 +53,11 @@ export const RunSideEffectMixin = (sideEffects: ESideEffectType[]) => { class RunSideEffectInterceptor implements NestInterceptor { constructor( readonly neogmaService: NeogmaService, - readonly projectService: ProjectService + readonly projectService: ProjectService, + @Optional() + readonly relationshipPatternsService?: RelationshipPatternsService, + @Optional() + readonly aiService?: AiService ) {} intercept(context: ExecutionContext, next: CallHandler): Observable { const request = context.switchToHttp().getRequest() @@ -87,14 +103,46 @@ export const RunSideEffectMixin = (sideEffects: ESideEffectType[]) => { this.projectService.recomputeProjectNodes(projectId, transaction, externalTransaction) }) + const recalculateOntologyCacheSideEffect = () => ({ + init: async () => { + if (!this.aiService) { + return + } + await this.aiService.getOntology({ + projectId, + force: true, + transaction: externalTransaction ?? transaction + }) + } + }) + + const relationshipAutomationSideEffect = () => ({ + init: async () => { + if (!this.relationshipPatternsService) { + return + } + await this.relationshipPatternsService.markAfterWrite(projectId) + await this.relationshipPatternsService.applyApprovedPatterns(projectId, transaction) + } + }) + sideEffects.forEach((sideEffectName) => { switch (sideEffectName) { case ESideEffectType.RECOUNT_PROJECT_STRUCTURE: sideEffectsList.push(recountProjectStructureSideEffect()) + break + case ESideEffectType.RECALCULATE_ONTOLOGY_CACHE: + sideEffectsList.push(recalculateOntologyCacheSideEffect()) + break + case ESideEffectType.RELATIONSHIP_AUTOMATION_AFTER_WRITE: + sideEffectsList.push(relationshipAutomationSideEffect()) + break } }) - await Promise.all(sideEffectsList.map((sideEffect) => sideEffect.init())) + for (const sideEffect of sideEffectsList) { + await sideEffect.init() + } if (transaction.isOpen()) { await transaction.commit() diff --git a/platform/core/src/core/ai/embedding-backfill.scheduler.ts b/platform/core/src/core/ai/embedding-backfill.scheduler.ts index 68170649..5e9876dd 100644 --- a/platform/core/src/core/ai/embedding-backfill.scheduler.ts +++ b/platform/core/src/core/ai/embedding-backfill.scheduler.ts @@ -3,6 +3,7 @@ import { ConfigService } from '@nestjs/config' import { Cron } from '@nestjs/schedule' import { int as neo4jInt } from 'neo4j-driver' +import { isDevMode } from '@/common/utils/isDevMode' import { AiQueryService } from '@/core/ai/ai-query.service' import { EmbeddingIndexRepository } from '@/core/ai/embedding-index.repository' import { EmbeddingProviderService } from '@/core/ai/embedding-provider.service' @@ -48,7 +49,9 @@ export class EmbeddingBackfillScheduler { private async backfillPending(): Promise { const pending = await this.embeddingIndexRepository.findPending() - this.logger.debug(`[backfillPending] pending indexes: ${pending.length}`) + + isDevMode(() => this.logger.debug(`[backfillPending] pending indexes: ${pending.length}`)) + if (pending.length === 0) { return } diff --git a/platform/core/src/core/core.module.ts b/platform/core/src/core/core.module.ts index e50609c3..8038ab2c 100755 --- a/platform/core/src/core/core.module.ts +++ b/platform/core/src/core/core.module.ts @@ -6,6 +6,7 @@ import { EntityModule } from '@/core/entity/entity.module' import { ImportExportModule } from '@/core/entity/import-export/import-export.module' import { PropertyModule } from '@/core/property/property.module' import { QueryModule } from '@/core/query/query.module' +import { RelationshipPatternsModule } from '@/core/relationship-patterns/relationship-patterns.module' import { TransactionModule } from '@/core/transactions/transaction.module' import { AuthMiddleware } from '@/dashboard/auth/middlewares/auth.middleware' import { TokenModule } from '@/dashboard/token/token.module' @@ -18,6 +19,7 @@ import { SessionAndTransactionAttachMiddleware } from '@/database/session-and-tr BillingPolicyModule, AiModule, EntityModule, + RelationshipPatternsModule, PropertyModule, ImportExportModule, TransactionModule, @@ -29,6 +31,7 @@ import { SessionAndTransactionAttachMiddleware } from '@/database/session-and-tr BillingPolicyModule, AiModule, EntityModule, + RelationshipPatternsModule, PropertyModule, ImportExportModule, TransactionModule, diff --git a/platform/core/src/core/entity/entity-query.service.ts b/platform/core/src/core/entity/entity-query.service.ts index f5dcf7f8..339da8b2 100755 --- a/platform/core/src/core/entity/entity-query.service.ts +++ b/platform/core/src/core/entity/entity-query.service.ts @@ -767,6 +767,45 @@ export class EntityQueryService { ) .append(`{ batchSize: 5000, params: { projectId: $projectId }, batchMode: "SINGLE", retries: 5 }`) .append(')') + .append('YIELD total, committedOperations, failedOperations, errorMessages') + .append('RETURN total, committedOperations, failedOperations, errorMessages') + + return queryBuilder.getQuery() + } + + retypeRelationsByLabels({ + sourceLabel, + targetLabel, + sourceRelationType, + targetRelationType, + direction + }: { + sourceLabel: string + targetLabel: string + sourceRelationType?: string + targetRelationType?: string + direction?: TRelationDirection + }) { + const safeSourceLabel = this.sanitizeNeo4jIdentifier(sourceLabel) + const safeTargetLabel = this.sanitizeNeo4jIdentifier(targetLabel) + const sourceRelType = sourceRelationType ? sourceRelationType : RUSHDB_RELATION_DEFAULT + const targetRelType = targetRelationType ? targetRelationType : RUSHDB_RELATION_DEFAULT + const relPattern = + direction === RELATION_DIRECTION_IN ? `<-[newRel:${targetRelType}]-` : `-[newRel:${targetRelType}]->` + const queryBuilder = new QueryBuilder() + + queryBuilder + .append('CALL apoc.periodic.iterate(') + .append( + `'MATCH (s:${RUSHDB_LABEL_RECORD}:\`${safeSourceLabel}\` { ${projectIdInline()} })-[:${sourceRelType}]-(t:${RUSHDB_LABEL_RECORD}:\`${safeTargetLabel}\` { ${projectIdInline()} }) RETURN DISTINCT s, t',` + ) + .append( + `'MERGE (s)${relPattern}(t) WITH s, t OPTIONAL MATCH (s)-[old:${sourceRelType}]-(t) DELETE old RETURN count(*)',` + ) + .append(`{ batchSize: 5000, params: { projectId: $projectId }, batchMode: "SINGLE", retries: 5 }`) + .append(')') + .append('YIELD total, committedOperations, failedOperations, errorMessages') + .append('RETURN total, committedOperations, failedOperations, errorMessages') return queryBuilder.getQuery() } @@ -797,6 +836,36 @@ export class EntityQueryService { ) .append(`{ batchSize: 5000, params: { projectId: $projectId }, batchMode: "SINGLE", retries: 5 }`) .append(')') + .append('YIELD total, committedOperations, failedOperations, errorMessages') + .append('RETURN total, committedOperations, failedOperations, errorMessages') + + return queryBuilder.getQuery() + } + + deleteRelationsByLabels({ + sourceLabel, + targetLabel, + relationType + }: { + sourceLabel: string + targetLabel: string + relationType?: string + }) { + const safeSourceLabel = this.sanitizeNeo4jIdentifier(sourceLabel) + const safeTargetLabel = this.sanitizeNeo4jIdentifier(targetLabel) + const relType = relationType ? relationType : RUSHDB_RELATION_DEFAULT + const queryBuilder = new QueryBuilder() + + queryBuilder + .append('CALL apoc.periodic.iterate(') + .append( + `'MATCH (s:${RUSHDB_LABEL_RECORD}:\`${safeSourceLabel}\` { ${projectIdInline()} })-[rel:${relType}]-(t:${RUSHDB_LABEL_RECORD}:\`${safeTargetLabel}\` { ${projectIdInline()} }) RETURN rel',` + ) + .append(`'DELETE rel RETURN count(*)',`) + .append(`{ batchSize: 5000, params: { projectId: $projectId }, batchMode: "SINGLE", retries: 5 }`) + .append(')') + .append('YIELD total, committedOperations, failedOperations, errorMessages') + .append('RETURN total, committedOperations, failedOperations, errorMessages') return queryBuilder.getQuery() } diff --git a/platform/core/src/core/entity/entity.controller.ts b/platform/core/src/core/entity/entity.controller.ts index 443f9050..5e263abc 100755 --- a/platform/core/src/core/entity/entity.controller.ts +++ b/platform/core/src/core/entity/entity.controller.ts @@ -93,7 +93,13 @@ export class EntityController { @ApiBearerAuth() @UseGuards(PlanLimitsGuard, IsRelatedToProjectGuard(), EntityWriteGuard) @UsePipes(ValidationPipe(createEntitySchema, 'body'), PropertyValuesPipe) - @UseInterceptors(RunSideEffectMixin([ESideEffectType.RECOUNT_PROJECT_STRUCTURE])) + @UseInterceptors( + RunSideEffectMixin([ + ESideEffectType.RECOUNT_PROJECT_STRUCTURE, + ESideEffectType.RECALCULATE_ONTOLOGY_CACHE, + ESideEffectType.RELATIONSHIP_AUTOMATION_AFTER_WRITE + ]) + ) @HttpCode(HttpStatus.CREATED) @AuthGuard('project') async create( @@ -137,7 +143,13 @@ export class EntityController { @UseGuards(PlanLimitsGuard, IsRelatedToProjectGuard(), EntityWriteGuard) @AuthGuard('project') @UsePipes(ValidationPipe(editEntitySchema, 'body'), PropertyValuesPipe) - @UseInterceptors(RunSideEffectMixin([ESideEffectType.RECOUNT_PROJECT_STRUCTURE])) + @UseInterceptors( + RunSideEffectMixin([ + ESideEffectType.RECOUNT_PROJECT_STRUCTURE, + ESideEffectType.RECALCULATE_ONTOLOGY_CACHE, + ESideEffectType.RELATIONSHIP_AUTOMATION_AFTER_WRITE + ]) + ) @HttpCode(HttpStatus.CREATED) async update( @Param('entityId') entityId: string, @@ -190,7 +202,13 @@ export class EntityController { @UseGuards(PlanLimitsGuard, IsRelatedToProjectGuard(), EntityWriteGuard) @AuthGuard('project') @UsePipes(ValidationPipe(editEntitySchema, 'body'), PropertyValuesPipe) - @UseInterceptors(RunSideEffectMixin([ESideEffectType.RECOUNT_PROJECT_STRUCTURE])) + @UseInterceptors( + RunSideEffectMixin([ + ESideEffectType.RECOUNT_PROJECT_STRUCTURE, + ESideEffectType.RECALCULATE_ONTOLOGY_CACHE, + ESideEffectType.RELATIONSHIP_AUTOMATION_AFTER_WRITE + ]) + ) @HttpCode(HttpStatus.CREATED) async set( @Param('entityId') entityId: string, @@ -233,7 +251,13 @@ export class EntityController { @ApiBearerAuth() @UseGuards(IsRelatedToProjectGuard()) @AuthGuard('project') - @UseInterceptors(RunSideEffectMixin([ESideEffectType.RECOUNT_PROJECT_STRUCTURE])) + @UseInterceptors( + RunSideEffectMixin([ + ESideEffectType.RECOUNT_PROJECT_STRUCTURE, + ESideEffectType.RECALCULATE_ONTOLOGY_CACHE, + ESideEffectType.RELATIONSHIP_AUTOMATION_AFTER_WRITE + ]) + ) async delete( @PreferredTransactionDecorator() transaction: Transaction, @Body() searchQuery: SearchDto = {}, @@ -258,7 +282,13 @@ export class EntityController { @ApiBearerAuth() @UseGuards(IsRelatedToProjectGuard()) @AuthGuard('project') - @UseInterceptors(RunSideEffectMixin([ESideEffectType.RECOUNT_PROJECT_STRUCTURE])) + @UseInterceptors( + RunSideEffectMixin([ + ESideEffectType.RECOUNT_PROJECT_STRUCTURE, + ESideEffectType.RECALCULATE_ONTOLOGY_CACHE, + ESideEffectType.RELATIONSHIP_AUTOMATION_AFTER_WRITE + ]) + ) async deleteById( @Param('entityId') entityId: string, @PreferredTransactionDecorator() transaction: Transaction, diff --git a/platform/core/src/core/entity/entity.module.ts b/platform/core/src/core/entity/entity.module.ts index f3b99f52..77e01a47 100755 --- a/platform/core/src/core/entity/entity.module.ts +++ b/platform/core/src/core/entity/entity.module.ts @@ -7,6 +7,7 @@ import { EntityController } from '@/core/entity/entity.controller' import { EntityService } from '@/core/entity/entity.service' import { LabelsController } from '@/core/labels/controller' import { PropertyModule } from '@/core/property/property.module' +import { RelationshipPatternsModule } from '@/core/relationship-patterns/relationship-patterns.module' import { RelationshipsController } from '@/core/relationships/controller' import { TransactionModule } from '@/core/transactions/transaction.module' import { ProjectModule } from '@/dashboard/project/project.module' @@ -23,7 +24,8 @@ import { WorkspaceModule } from '@/dashboard/workspace/workspace.module' // Core modules forwardRef(() => PropertyModule), forwardRef(() => TransactionModule), - forwardRef(() => AiModule) + forwardRef(() => AiModule), + forwardRef(() => RelationshipPatternsModule) ], providers: [EntityService, EntityQueryService, EmbeddingIndexRepository], exports: [EntityService, EntityQueryService], diff --git a/platform/core/src/core/entity/entity.service.ts b/platform/core/src/core/entity/entity.service.ts index f0b92983..4c516731 100755 --- a/platform/core/src/core/entity/entity.service.ts +++ b/platform/core/src/core/entity/entity.service.ts @@ -409,7 +409,7 @@ export class EntityService { projectId: string transaction: Transaction manyToMany?: boolean - }): Promise { + }): Promise { const query = this.entityQueryService.createRelationsByKeys({ sourceLabel: source.label, sourceKey: source.key, @@ -422,7 +422,67 @@ export class EntityService { manyToMany }) - await transaction.run(query, { projectId }) + const result = await transaction.run(query, { projectId }) + const row = result.records[0] + const failedOperations = row?.get('failedOperations') + const failedCount = + typeof failedOperations?.toNumber === 'function' ? + failedOperations.toNumber() + : Number(failedOperations ?? 0) + + if (failedCount > 0) { + const errorMessages = row?.get('errorMessages') + throw new Error(`Relationship creation failed: ${JSON.stringify(errorMessages ?? {})}`) + } + + const committedOperations = row?.get('committedOperations') + return typeof committedOperations?.toNumber === 'function' ? + committedOperations.toNumber() + : Number(committedOperations ?? 0) + } + + async retypeRelationsByLabels({ + source, + target, + sourceType, + targetType, + direction, + projectId, + transaction + }: { + source: { label: string } + target: { label: string } + sourceType?: string + targetType?: string + direction?: TRelationDirection + projectId: string + transaction: Transaction + }): Promise { + const query = this.entityQueryService.retypeRelationsByLabels({ + sourceLabel: source.label, + targetLabel: target.label, + sourceRelationType: sourceType, + targetRelationType: targetType, + direction: direction === 'in' ? 'in' : 'out' + }) + + const result = await transaction.run(query, { projectId }) + const row = result.records[0] + const failedOperations = row?.get('failedOperations') + const failedCount = + typeof failedOperations?.toNumber === 'function' ? + failedOperations.toNumber() + : Number(failedOperations ?? 0) + + if (failedCount > 0) { + const errorMessages = row?.get('errorMessages') + throw new Error(`Relationship retype failed: ${JSON.stringify(errorMessages ?? {})}`) + } + + const committedOperations = row?.get('committedOperations') + return typeof committedOperations?.toNumber === 'function' ? + committedOperations.toNumber() + : Number(committedOperations ?? 0) } async deleteRelationsByKeys({ @@ -441,7 +501,7 @@ export class EntityService { projectId: string transaction: Transaction manyToMany?: boolean - }): Promise { + }): Promise { const query = this.entityQueryService.deleteRelationsByKeys({ sourceLabel: source.label, sourceKey: source.key, @@ -454,7 +514,61 @@ export class EntityService { manyToMany }) - await transaction.run(query, { projectId }) + const result = await transaction.run(query, { projectId }) + const row = result.records[0] + const failedOperations = row?.get('failedOperations') + const failedCount = + typeof failedOperations?.toNumber === 'function' ? + failedOperations.toNumber() + : Number(failedOperations ?? 0) + + if (failedCount > 0) { + const errorMessages = row?.get('errorMessages') + throw new Error(`Relationship deletion failed: ${JSON.stringify(errorMessages ?? {})}`) + } + + const committedOperations = row?.get('committedOperations') + return typeof committedOperations?.toNumber === 'function' ? + committedOperations.toNumber() + : Number(committedOperations ?? 0) + } + + async deleteRelationsByLabels({ + source, + target, + type, + projectId, + transaction + }: { + source: { label: string } + target: { label: string } + type?: string + projectId: string + transaction: Transaction + }): Promise { + const query = this.entityQueryService.deleteRelationsByLabels({ + sourceLabel: source.label, + targetLabel: target.label, + relationType: type + }) + + const result = await transaction.run(query, { projectId }) + const row = result.records[0] + const failedOperations = row?.get('failedOperations') + const failedCount = + typeof failedOperations?.toNumber === 'function' ? + failedOperations.toNumber() + : Number(failedOperations ?? 0) + + if (failedCount > 0) { + const errorMessages = row?.get('errorMessages') + throw new Error(`Relationship deletion failed: ${JSON.stringify(errorMessages ?? {})}`) + } + + const committedOperations = row?.get('committedOperations') + return typeof committedOperations?.toNumber === 'function' ? + committedOperations.toNumber() + : Number(committedOperations ?? 0) } async delete({ diff --git a/platform/core/src/core/entity/import-export/import-export.module.ts b/platform/core/src/core/entity/import-export/import-export.module.ts index 2192f321..f5217551 100644 --- a/platform/core/src/core/entity/import-export/import-export.module.ts +++ b/platform/core/src/core/entity/import-export/import-export.module.ts @@ -8,6 +8,7 @@ import { ExportService } from '@/core/entity/import-export/export.service' import { ImportController } from '@/core/entity/import-export/import.controller' import { ImportService } from '@/core/entity/import-export/import.service' import { PropertyModule } from '@/core/property/property.module' +import { RelationshipPatternsModule } from '@/core/relationship-patterns/relationship-patterns.module' import { TransactionModule } from '@/core/transactions/transaction.module' import { ProjectModule } from '@/dashboard/project/project.module' import { TokenModule } from '@/dashboard/token/token.module' @@ -25,6 +26,7 @@ import { WorkspaceModule } from '@/dashboard/workspace/workspace.module' BillingPolicyModule, forwardRef(() => EntityModule), forwardRef(() => PropertyModule), + forwardRef(() => RelationshipPatternsModule), forwardRef(() => TransactionModule) ], providers: [ImportService, ExportService], diff --git a/platform/core/src/core/entity/import-export/import.controller.ts b/platform/core/src/core/entity/import-export/import.controller.ts index f2dba752..2a3896cc 100644 --- a/platform/core/src/core/entity/import-export/import.controller.ts +++ b/platform/core/src/core/entity/import-export/import.controller.ts @@ -107,7 +107,11 @@ export class ImportController { @ApiBearerAuth() @UseGuards(PlanLimitsGuard, EntityWriteGuard) @UseInterceptors( - RunSideEffectMixin([ESideEffectType.RECOUNT_PROJECT_STRUCTURE]), + RunSideEffectMixin([ + ESideEffectType.RECOUNT_PROJECT_STRUCTURE, + ESideEffectType.RECALCULATE_ONTOLOGY_CACHE, + ESideEffectType.RELATIONSHIP_AUTOMATION_AFTER_WRITE + ]), TransformResponseInterceptor ) @UsePipes(ValidationPipe(importJsonSchema, 'body')) @@ -139,7 +143,11 @@ export class ImportController { @Post('/records/import/csv') @ApiBearerAuth() @UseInterceptors( - RunSideEffectMixin([ESideEffectType.RECOUNT_PROJECT_STRUCTURE]), + RunSideEffectMixin([ + ESideEffectType.RECOUNT_PROJECT_STRUCTURE, + ESideEffectType.RECALCULATE_ONTOLOGY_CACHE, + ESideEffectType.RELATIONSHIP_AUTOMATION_AFTER_WRITE + ]), TransformResponseInterceptor ) @UsePipes(ValidationPipe(importCsvSchema, 'body')) diff --git a/platform/core/src/core/ku-events/ku-events.constants.ts b/platform/core/src/core/ku-events/ku-events.constants.ts index 6d801536..4f9d1df5 100644 --- a/platform/core/src/core/ku-events/ku-events.constants.ts +++ b/platform/core/src/core/ku-events/ku-events.constants.ts @@ -11,5 +11,9 @@ export enum KuOperation { /** Emitted daily by the storage-footprint scheduler for every project. * Count = current number of records stored. The billing service uses * this to apply a prorated daily charge for ongoing data-at-rest. */ - STORAGE_FOOTPRINT = 'storage_footprint' + STORAGE_FOOTPRINT = 'storage_footprint', + /** Emitted each time an LLM-powered relationship pattern analysis runs + * (either triggered manually by the user or via the background scheduler). + * Reflects the compute cost of ontology-to-candidate LLM inference. */ + RELATIONSHIP_ANALYSIS = 'relationship_analysis' } diff --git a/platform/core/src/core/relationship-patterns/relationship-patterns.controller.ts b/platform/core/src/core/relationship-patterns/relationship-patterns.controller.ts new file mode 100644 index 00000000..c93a4924 --- /dev/null +++ b/platform/core/src/core/relationship-patterns/relationship-patterns.controller.ts @@ -0,0 +1,83 @@ +import { + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + Post, + Query, + Request, + UseInterceptors +} from '@nestjs/common' +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger' +import { Transaction } from 'neo4j-driver' + +import { NotFoundInterceptor } from '@/common/interceptors/not-found.interceptor' +import { TransformResponseInterceptor } from '@/common/interceptors/transform-response.interceptor' +import { PlatformRequest } from '@/common/types/request' +import { toBoolean } from '@/common/utils/toBolean' +import { AuthGuard } from '@/dashboard/auth/guards/global-auth.guard' +import { DataInterceptor } from '@/database/interceptors/data.interceptor' +import { PreferredTransactionDecorator } from '@/database/preferred-transaction.decorator' + +import { RelationshipPatternsService } from './relationship-patterns.service' +import { RelationshipPatternDto, RelationshipPatternListResponse } from './relationship-patterns.types' + +@Controller('relationships/patterns') +@ApiTags('Relationship Patterns') +@UseInterceptors(TransformResponseInterceptor, NotFoundInterceptor, DataInterceptor) +export class RelationshipPatternsController { + constructor(private readonly relationshipPatternsService: RelationshipPatternsService) {} + + @Get() + @ApiBearerAuth() + @AuthGuard('project') + async list( + @PreferredTransactionDecorator() transaction: Transaction, + @Request() request: PlatformRequest + ): Promise { + return this.relationshipPatternsService.list(request.projectId, transaction) + } + + @Post('/analyze') + @ApiBearerAuth() + @AuthGuard('project') + @HttpCode(HttpStatus.ACCEPTED) + async analyze(@Request() request: PlatformRequest): Promise<{ queued: true }> { + await this.relationshipPatternsService.forceAnalysis(request.projectId, request.workspaceId) + return { queued: true } + } + + @Post('/:id/approve') + @ApiBearerAuth() + @AuthGuard('project') + async approve( + @Param('id') id: string, + @Request() request: PlatformRequest + ): Promise { + return this.relationshipPatternsService.approve(request.projectId, id) + } + + @Post('/:id/ignore') + @ApiBearerAuth() + @AuthGuard('project') + async ignore( + @Param('id') id: string, + @Request() request: PlatformRequest + ): Promise { + return this.relationshipPatternsService.ignore(request.projectId, id) + } + + @Delete('/:id') + @ApiBearerAuth() + @AuthGuard('project') + async delete( + @Param('id') id: string, + @Request() request: PlatformRequest, + @Query('deleteExisting') deleteExisting?: string + ): Promise<{ deleted: true }> { + await this.relationshipPatternsService.delete(request.projectId, id, toBoolean(deleteExisting)) + return { deleted: true } + } +} diff --git a/platform/core/src/core/relationship-patterns/relationship-patterns.module.ts b/platform/core/src/core/relationship-patterns/relationship-patterns.module.ts new file mode 100644 index 00000000..452f6e75 --- /dev/null +++ b/platform/core/src/core/relationship-patterns/relationship-patterns.module.ts @@ -0,0 +1,23 @@ +import { forwardRef, Module } from '@nestjs/common' + +import { AiModule } from '@/core/ai/ai.module' +import { EntityModule } from '@/core/entity/entity.module' +import { ProjectRepository } from '@/dashboard/project/model/project.repository' + +import { RelationshipPatternsController } from './relationship-patterns.controller' +import { RelationshipPatternsRepository } from './relationship-patterns.repository' +import { RelationshipPatternsScheduler } from './relationship-patterns.scheduler' +import { RelationshipPatternsService } from './relationship-patterns.service' + +@Module({ + imports: [forwardRef(() => AiModule), forwardRef(() => EntityModule)], + providers: [ + RelationshipPatternsRepository, + RelationshipPatternsService, + RelationshipPatternsScheduler, + ProjectRepository + ], + exports: [RelationshipPatternsService], + controllers: [RelationshipPatternsController] +}) +export class RelationshipPatternsModule {} diff --git a/platform/core/src/core/relationship-patterns/relationship-patterns.repository.ts b/platform/core/src/core/relationship-patterns/relationship-patterns.repository.ts new file mode 100644 index 00000000..2fecb008 --- /dev/null +++ b/platform/core/src/core/relationship-patterns/relationship-patterns.repository.ts @@ -0,0 +1,167 @@ +import { Injectable } from '@nestjs/common' +import { and, eq, inArray, lte } from 'drizzle-orm' +import { uuidv7 } from 'uuidv7' + +import { SqlService } from '@/database/sql/sql.service' + +import type { + InsertRelationshipAnalysisQueueRow, + InsertRelationshipPatternRow, + RelationshipAnalysisQueueRow, + RelationshipPatternRow +} from '@/database/sql/schema/types' + +@Injectable() +export class RelationshipPatternsRepository { + constructor(private readonly sql: SqlService) {} + + private get db() { + return this.sql.db + } + + private get patterns() { + return this.sql.tables.relationshipPatterns + } + + private get queue() { + return this.sql.tables.relationshipAnalysisQueue + } + + async findByProjectId(projectId: string): Promise { + return this.db.select().from(this.patterns).where(eq(this.patterns.projectId, projectId)) + } + + async findById(id: string, projectId?: string): Promise { + const where = + projectId ? + and(eq(this.patterns.id, id), eq(this.patterns.projectId, projectId)) + : eq(this.patterns.id, id) + const rows = await this.db.select().from(this.patterns).where(where) + return rows[0] + } + + async findApproved(projectId: string): Promise { + return this.db + .select() + .from(this.patterns) + .where(and(eq(this.patterns.projectId, projectId), eq(this.patterns.status, 'approved'))) + } + + async upsertCandidate( + data: Omit & { + id?: string + } + ): Promise { + const now = new Date().toISOString() + const id = data.id ?? uuidv7() + const insertData: InsertRelationshipPatternRow = { + ...data, + id, + createdAt: now, + updatedAt: now + } + + await this.db + .insert(this.patterns) + .values(insertData) + .onConflictDoUpdate({ + target: [this.patterns.projectId, this.patterns.signatureHash], + set: { + sourceLabel: insertData.sourceLabel, + sourceKey: insertData.sourceKey, + sourceWhere: insertData.sourceWhere, + targetLabel: insertData.targetLabel, + targetKey: insertData.targetKey, + targetWhere: insertData.targetWhere, + direction: insertData.direction, + type: insertData.type, + mode: insertData.mode, + confidence: insertData.confidence, + origin: insertData.origin, + rationale: insertData.rationale, + sampleMatchCount: insertData.sampleMatchCount, + lastAnalyzedAt: insertData.lastAnalyzedAt, + lastError: null, + updatedAt: now + } + }) + + const rows = await this.db + .select() + .from(this.patterns) + .where( + and(eq(this.patterns.projectId, data.projectId), eq(this.patterns.signatureHash, data.signatureHash)) + ) + return rows[0] + } + + async updatePattern( + id: string, + data: Partial>, + projectId?: string + ): Promise { + const where = + projectId ? + and(eq(this.patterns.id, id), eq(this.patterns.projectId, projectId)) + : eq(this.patterns.id, id) + await this.db + .update(this.patterns) + .set({ ...data, updatedAt: new Date().toISOString() }) + .where(where) + return this.findById(id, projectId) + } + + async deletePattern(id: string, projectId?: string): Promise { + const where = + projectId ? + and(eq(this.patterns.id, id), eq(this.patterns.projectId, projectId)) + : eq(this.patterns.id, id) + await this.db.delete(this.patterns).where(where) + } + + async getQueue(projectId: string): Promise { + const rows = await this.db.select().from(this.queue).where(eq(this.queue.projectId, projectId)) + return rows[0] + } + + async enqueueAnalysis(projectId: string, notBefore: string): Promise { + const now = new Date().toISOString() + const data: InsertRelationshipAnalysisQueueRow = { + projectId, + requestedAt: now, + notBefore, + status: 'pending', + createdAt: now, + updatedAt: now + } + + await this.db + .insert(this.queue) + .values(data) + .onConflictDoUpdate({ + target: this.queue.projectId, + set: { + requestedAt: now, + notBefore, + status: 'pending', + lastError: null, + updatedAt: now + } + }) + } + + async findDueAnalysis(nowIso: string, limit = 10): Promise { + return this.db + .select() + .from(this.queue) + .where(and(inArray(this.queue.status, ['pending', 'error']), lte(this.queue.notBefore, nowIso))) + .limit(limit) + } + + async updateQueue(projectId: string, data: Partial): Promise { + await this.db + .update(this.queue) + .set({ ...data, updatedAt: new Date().toISOString() }) + .where(eq(this.queue.projectId, projectId)) + } +} diff --git a/platform/core/src/core/relationship-patterns/relationship-patterns.scheduler.ts b/platform/core/src/core/relationship-patterns/relationship-patterns.scheduler.ts new file mode 100644 index 00000000..0aa6db61 --- /dev/null +++ b/platform/core/src/core/relationship-patterns/relationship-patterns.scheduler.ts @@ -0,0 +1,27 @@ +import { Injectable, Logger } from '@nestjs/common' +import { Cron } from '@nestjs/schedule' + +import { RelationshipPatternsService } from './relationship-patterns.service' + +@Injectable() +export class RelationshipPatternsScheduler { + private readonly logger = new Logger(RelationshipPatternsScheduler.name) + private running = false + + constructor(private readonly relationshipPatternsService: RelationshipPatternsService) {} + + @Cron('* * * * *') + async processDueAnalysis(): Promise { + if (this.running) { + return + } + this.running = true + try { + await this.relationshipPatternsService.processDueAnalysis() + } catch (error) { + this.logger.error('[RelationshipPatternsScheduler] failed', error) + } finally { + this.running = false + } + } +} diff --git a/platform/core/src/core/relationship-patterns/relationship-patterns.service.ts b/platform/core/src/core/relationship-patterns/relationship-patterns.service.ts new file mode 100644 index 00000000..b41ef806 --- /dev/null +++ b/platform/core/src/core/relationship-patterns/relationship-patterns.service.ts @@ -0,0 +1,845 @@ +import { forwardRef, Inject, Injectable, Logger } from '@nestjs/common' +import { ConfigService } from '@nestjs/config' +import axios from 'axios' +import { Transaction } from 'neo4j-driver' + +import { AiService } from '@/core/ai/ai.service' +import { OntologyItem } from '@/core/ai/ai.types' +import { estimateTokens } from '@/core/ai/embedding.utils' +import { RUSHDB_RELATION_DEFAULT } from '@/core/common/constants' +import { EntityService } from '@/core/entity/entity.service' +import { TRelationDirection } from '@/core/entity/entity.types' +import { KuOperation } from '@/core/ku-events/ku-events.constants' +import { KuEventsService } from '@/core/ku-events/ku-events.service' +import { ProjectRepository } from '@/dashboard/project/model/project.repository' +import { NeogmaService } from '@/database/neogma/neogma.service' + +import { createHash } from 'crypto' + +import { RelationshipPatternsRepository } from './relationship-patterns.repository' +import { + RelationshipPatternCandidate, + RelationshipPatternDto, + RelationshipPatternEndpoint, + RelationshipPatternListResponse, + RelationshipPatternMode, + RelationshipPatternStatus +} from './relationship-patterns.types' + +import type { RelationshipPatternRow } from '@/database/sql/schema/types' + +const ANALYSIS_DEBOUNCE_MS = 60_000 +const MAX_LLM_CANDIDATES = 20 + +@Injectable() +export class RelationshipPatternsService { + private readonly logger = new Logger(RelationshipPatternsService.name) + private readonly runningAnalysis = new Set() + private readonly runningApply = new Set() + + constructor( + private readonly repository: RelationshipPatternsRepository, + private readonly configService: ConfigService, + private readonly aiService: AiService, + private readonly neogmaService: NeogmaService, + private readonly kuEventsService: KuEventsService, + private readonly projectRepository: ProjectRepository, + @Inject(forwardRef(() => EntityService)) + private readonly entityService: EntityService + ) {} + + async list(projectId: string, transaction: Transaction): Promise { + const [patterns, ontology, analysis] = await Promise.all([ + this.repository.findByProjectId(projectId), + this.aiService.getOntology({ projectId, transaction }), + this.repository.getQueue(projectId) + ]) + + return { + patterns: patterns.map((row) => this.toDto(row)), + relationships: this.summarizeExistingRelationships(ontology), + analysis: + analysis ? + { + status: analysis.status, + requestedAt: analysis.requestedAt, + notBefore: analysis.notBefore, + lastRunAt: analysis.lastRunAt, + lastError: analysis.lastError + } + : undefined + } + } + + private summarizeExistingRelationships( + ontology: OntologyItem[] + ): RelationshipPatternListResponse['relationships'] { + const grouped = new Map< + string, + RelationshipPatternListResponse['relationships'][number]['relationships'] + >() + const seen = new Set() + + for (const item of ontology) { + for (const relationship of item.relationships) { + const sourceLabel = relationship.direction === 'in' ? relationship.label : item.label + const targetLabel = relationship.direction === 'in' ? item.label : relationship.label + const key = `${sourceLabel}:${relationship.type}:${targetLabel}` + + if (seen.has(key)) { + continue + } + + seen.add(key) + const relationships = grouped.get(sourceLabel) ?? [] + relationships.push({ + label: targetLabel, + type: relationship.type, + direction: 'out' + }) + grouped.set(sourceLabel, relationships) + } + } + + return [...grouped.entries()].map(([label, relationships]) => ({ label, relationships })) + } + + async markAfterWrite(projectId: string): Promise { + if (!this.analysisEnabled()) { + return + } + const queue = await this.repository.getQueue(projectId) + const now = Date.now() + const lastRunAt = queue?.lastRunAt ? new Date(queue.lastRunAt).getTime() : 0 + const nextAllowedAt = + lastRunAt && now - lastRunAt < ANALYSIS_DEBOUNCE_MS ? lastRunAt + ANALYSIS_DEBOUNCE_MS : now + const existingNotBefore = queue?.status === 'pending' ? new Date(queue.notBefore).getTime() : undefined + const notBefore = new Date( + existingNotBefore && existingNotBefore < nextAllowedAt ? existingNotBefore : nextAllowedAt + ).toISOString() + await this.repository.enqueueAnalysis(projectId, notBefore) + } + + async forceAnalysis(projectId: string, workspaceId?: string): Promise { + await this.repository.enqueueAnalysis(projectId, new Date().toISOString()) + this.runAnalysisForProject(projectId, workspaceId, true).catch((error) => { + this.logger.error(`[RelationshipAnalysis] project ${projectId} failed`, error) + }) + } + + async approve(projectId: string, id: string): Promise { + const pattern = await this.repository.updatePattern( + id, + { + status: 'approved', + lastError: null + }, + projectId + ) + if (!pattern) { + return undefined + } + + this.applyPatternInFreshTransaction(pattern).catch((error) => { + this.logger.error(`[RelationshipPattern] backfill failed for pattern ${id}`, error) + }) + + return this.toDto(pattern) + } + + async ignore(projectId: string, id: string): Promise { + const pattern = await this.repository.updatePattern( + id, + { + status: 'ignored', + lastError: null + }, + projectId + ) + return pattern ? this.toDto(pattern) : undefined + } + + async delete(projectId: string, id: string, deleteExisting = false): Promise { + const pattern = await this.repository.findById(id, projectId) + if (!pattern) { + return + } + + await this.repository.deletePattern(id, projectId) + + if (deleteExisting) { + this.deleteRelationshipsInFreshTransaction(pattern).catch((error) => { + this.logger.error(`[RelationshipPattern] relationship cleanup failed for pattern ${id}`, error) + }) + } + } + + async applyApprovedPatterns(projectId: string, transaction: Transaction): Promise { + if (this.runningApply.has(projectId)) { + return + } + + this.runningApply.add(projectId) + try { + const patterns = await this.repository.findApproved(projectId) + for (const pattern of patterns) { + await this.applyPattern(pattern, transaction) + } + } finally { + this.runningApply.delete(projectId) + } + } + + async processDueAnalysis(): Promise { + const due = await this.repository.findDueAnalysis(new Date().toISOString()) + await Promise.allSettled(due.map((queueRow) => this.runAnalysisForProject(queueRow.projectId))) + } + + async runAnalysisForProject(projectId: string, workspaceId?: string, isManual = false): Promise { + if (this.runningAnalysis.has(projectId)) { + return + } + this.runningAnalysis.add(projectId) + + await this.repository.updateQueue(projectId, { status: 'running', lastError: null }) + + const session = this.neogmaService.createSession('relationship-analysis') + const transaction = session.beginTransaction({ timeout: 60_000 }) + + try { + const ontology = await this.aiService.getOntology({ projectId, force: true, transaction }) + await transaction.commit() + await this.neogmaService.closeSession(session, 'relationship-analysis') + + // Resolve workspaceId for KU billing — supplied by the controller for manual + // refreshes; looked up from the project record for scheduler-triggered runs. + const resolvedWorkspaceId = + workspaceId ?? (await this.projectRepository.findById(projectId))?.workspaceId + + const existingPatterns = await this.repository.findByProjectId(projectId) + const { candidates, promptTokens, completionTokens, totalTokens } = await this.suggestCandidates( + ontology, + existingPatterns + ) + const validCandidates = this.dedupeInverseCandidates( + candidates + .map((candidate) => this.validateCandidate(candidate, ontology)) + .filter((candidate): candidate is RelationshipPatternCandidate => Boolean(candidate)) + ) + .filter((candidate) => !this.hasExistingPatternForJoin(candidate, existingPatterns)) + .slice(0, MAX_LLM_CANDIDATES) + + for (const candidate of validCandidates) { + const row = this.candidateToInsert(projectId, candidate) + await this.repository.upsertCandidate(row) + } + + if (resolvedWorkspaceId) { + this.kuEventsService.emit(resolvedWorkspaceId, projectId, KuOperation.RELATIONSHIP_ANALYSIS, { + model: this.configService.get('RUSHDB_LLM_MODEL'), + promptTokens, + completionTokens, + totalTokens, + candidateCount: validCandidates.length, + trigger: isManual ? 'manual' : 'scheduler' + }) + } + + await this.repository.updateQueue(projectId, { + status: 'idle', + lastRunAt: new Date().toISOString(), + lastError: null + }) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + await this.repository.updateQueue(projectId, { + status: 'error', + lastRunAt: new Date().toISOString(), + lastError: message + }) + try { + if (transaction.isOpen()) { + await transaction.rollback() + } + } catch { + /* empty */ + } + try { + await this.neogmaService.closeSession(session, 'relationship-analysis') + } catch { + /* empty */ + } + throw error + } finally { + this.runningAnalysis.delete(projectId) + } + } + + private async suggestCandidates( + ontology: OntologyItem[], + existingPatterns: RelationshipPatternRow[] + ): Promise<{ + candidates: RelationshipPatternCandidate[] + promptTokens: number + completionTokens: number + totalTokens: number + }> { + const empty = { candidates: [], promptTokens: 0, completionTokens: 0, totalTokens: 0 } + const apiKey = this.configService.get('RUSHDB_LLM_API_KEY') + const model = this.configService.get('RUSHDB_LLM_MODEL') + const baseUrl = this.configService.get('RUSHDB_LLM_BASE_URL') ?? 'https://api.openai.com/v1' + + if (!apiKey || !model) { + return empty + } + + const response = await axios.post( + `${baseUrl.replace(/\/$/, '')}/chat/completions`, + { + model, + temperature: 0, + response_format: { type: 'json_object' }, + messages: [ + { + role: 'system', + content: + 'You infer graph relationship patterns for RushDB. Return only JSON: {"candidates":[...]}. ' + + 'Every candidate must include mode: "join_pattern" or "retype_existing_relationship". ' + + 'Use "join_pattern" only for high-confidence foreign-key-like joins where one label has a reference field matching another label key. ' + + 'Use "retype_existing_relationship" when ontology already shows a RUSHDB_DEFAULT_RELATION between labels; in that case source.key and target.key are optional and the task is to rename existing structure semantically. ' + + 'For retype_existing_relationship, infer the semantic relationship from the existing graph structure and label meanings, not from same-named descriptive properties. ' + + 'Do not suggest joins based only on common descriptive fields such as name, title, label, description, status, country, or type. ' + + 'If a default relationship already connects two labels, prefer retype_existing_relationship over join_pattern. ' + + 'Return ONE canonical candidate per semantic relationship; never return both A->B and B->A for the same relationship. ' + + 'Do not suggest a relationship when the same mode and label/key pair already appears in existingPatterns, even if you would use a different synonym or inverse type. ' + + 'Choose source as the natural actor/owner/parent and target as the natural object/action/child. ' + + 'If both labels represent equal peers and direction is semantically ambiguous, choose a neutral symmetric type and use deterministic alphabetical label/key ordering for source and target. ' + + 'Relationship type must read naturally from source to target. Do not choose a direction where the target would appear to act on or create the source. ' + + 'Each candidate must include source.label, target.label, mode, direction "out", type, confidence 0..1, and rationale. join_pattern must also include source.key and target.key. Return at most 8 candidates.' + }, + { + role: 'user', + content: JSON.stringify({ + ontology: this.compactOntologyForRelationshipAnalysis(ontology), + existingPatterns: this.compactExistingPatternsForRelationshipAnalysis(existingPatterns), + relationshipTypeRules: + 'Use uppercase Neo4j-safe verb phrases from source to target. Do not use inverse duplicate types for the same relationship.' + }) + } + ] + }, + { + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json' + }, + timeout: 30_000 + } + ) + + const content = response.data?.choices?.[0]?.message?.content + if (!content) { + return empty + } + + const usage = response.data?.usage + const systemContent = + 'You infer graph relationship patterns for RushDB. Return only JSON: {"candidates":[...]}. ' + const userContent = JSON.stringify({ + ontology: this.compactOntologyForRelationshipAnalysis(ontology), + existingPatterns: this.compactExistingPatternsForRelationshipAnalysis(existingPatterns) + }) + const promptTokens = usage?.prompt_tokens ?? estimateTokens(systemContent + userContent) + const completionTokens = usage?.completion_tokens ?? estimateTokens(content) + const totalTokens = usage?.total_tokens ?? promptTokens + completionTokens + + const parsed = JSON.parse(content) + return { + candidates: Array.isArray(parsed?.candidates) ? parsed.candidates : [], + promptTokens, + completionTokens, + totalTokens + } + } + + private compactOntologyForRelationshipAnalysis(ontology: OntologyItem[]) { + return ontology.map((item) => ({ + label: item.label, + count: item.count, + properties: item.properties.map((property) => ({ + name: property.name, + type: property.type, + values: property.values?.slice(0, 3) + })), + relationships: item.relationships + })) + } + + private compactExistingPatternsForRelationshipAnalysis(patterns: RelationshipPatternRow[]) { + return patterns.map((pattern) => ({ + status: pattern.status, + mode: pattern.mode, + source: { label: pattern.sourceLabel, key: pattern.sourceKey }, + target: { label: pattern.targetLabel, key: pattern.targetKey }, + direction: pattern.direction, + type: pattern.type + })) + } + + private dedupeInverseCandidates( + candidates: RelationshipPatternCandidate[] + ): RelationshipPatternCandidate[] { + const byJoin = new Map() + + for (const candidate of candidates) { + const mode = this.normalizePatternMode(candidate.mode) + const endpoints = + mode === 'retype_existing_relationship' ? + [candidate.source.label, candidate.target.label].sort() + : [ + `${candidate.source.label}.${candidate.source.key}`, + `${candidate.target.label}.${candidate.target.key}` + ].sort() + const key = `${mode}|${endpoints.join('|')}` + const normalizedCandidate = + this.isSymmetricRelationship(candidate) ? this.normalizeSymmetricCandidate(candidate) : candidate + const existing = byJoin.get(key) + + if (!existing || this.scoreCandidate(normalizedCandidate) > this.scoreCandidate(existing)) { + byJoin.set(key, normalizedCandidate) + } + } + + return [...byJoin.values()] + } + + private isSymmetricRelationship(candidate: RelationshipPatternCandidate): boolean { + const type = this.normalizeRelationType(candidate.type) + return [ + 'FRIEND', + 'FRIENDS', + 'FRIEND_OF', + 'CONNECTED_TO', + 'RELATED_TO', + 'PEER_OF', + 'COLLEAGUE_OF' + ].includes(type) + } + + private normalizeSymmetricCandidate(candidate: RelationshipPatternCandidate): RelationshipPatternCandidate { + const left = `${candidate.source.label}.${candidate.source.key}` + const right = `${candidate.target.label}.${candidate.target.key}` + if (left.localeCompare(right) <= 0) { + return { ...candidate, direction: 'out' } + } + + return { + ...candidate, + source: candidate.target, + target: candidate.source, + direction: 'out' + } + } + + private hasExistingPatternForJoin( + candidate: RelationshipPatternCandidate, + patterns: RelationshipPatternRow[] + ): boolean { + const mode = this.normalizePatternMode(candidate.mode) + const candidateKey = + mode === 'retype_existing_relationship' ? + this.unorderedLabelKey(candidate.source.label, candidate.target.label) + : this.unorderedJoinKey( + candidate.source.label, + candidate.source.key, + candidate.target.label, + candidate.target.key + ) + + return patterns.some( + (pattern) => + pattern.mode === mode && + (mode === 'retype_existing_relationship' ? + this.unorderedLabelKey(pattern.sourceLabel, pattern.targetLabel) === candidateKey + : this.unorderedJoinKey( + pattern.sourceLabel, + pattern.sourceKey, + pattern.targetLabel, + pattern.targetKey + ) === candidateKey) + ) + } + + private unorderedLabelKey(sourceLabel: string, targetLabel: string): string { + return [sourceLabel, targetLabel].sort().join('|') + } + + private unorderedJoinKey( + sourceLabel: string, + sourceKey: string | null | undefined, + targetLabel: string, + targetKey: string | null | undefined + ): string { + return [`${sourceLabel}.${sourceKey ?? ''}`, `${targetLabel}.${targetKey ?? ''}`].sort().join('|') + } + + private scoreCandidate(candidate: RelationshipPatternCandidate): number { + let score = this.normalizeConfidence(candidate.confidence) + const type = this.normalizeRelationType(candidate.type) + const source = candidate.source.label.toUpperCase() + const target = candidate.target.label.toUpperCase() + + if (type.includes(target) || type.includes(this.singularize(target))) { + score += 0.2 + } + if (type.includes(source) && !type.includes(target)) { + score -= 0.2 + } + + return score + } + + private singularize(value: string): string { + return value.endsWith('S') ? value.slice(0, -1) : value + } + + private analysisEnabled(): boolean { + return ( + Boolean(this.configService.get('RUSHDB_LLM_API_KEY')) && + Boolean(this.configService.get('RUSHDB_LLM_MODEL')) + ) + } + + private validateCandidate( + candidate: RelationshipPatternCandidate, + ontology: OntologyItem[] + ): RelationshipPatternCandidate | undefined { + if (!candidate?.source?.label || !candidate?.target?.label) { + return undefined + } + + const source = ontology.find((item) => item.label === candidate.source.label) + const target = ontology.find((item) => item.label === candidate.target.label) + if (!source || !target || source.label === target.label) { + return undefined + } + + const mode = this.normalizePatternMode(candidate.mode) + const hasDefaultRelationship = this.hasDefaultRelationshipBetween(source, target) + + if (mode === 'retype_existing_relationship') { + if (!hasDefaultRelationship) { + return undefined + } + } else { + if (hasDefaultRelationship) { + return undefined + } + + if (!candidate.source.key || !candidate.target.key) { + return undefined + } + + const sourceHasKey = source.properties.some((property) => property.name === candidate.source.key) + const targetHasKey = target.properties.some((property) => property.name === candidate.target.key) + if (!sourceHasKey || !targetHasKey || !this.isSafeJoinCandidate(candidate)) { + return undefined + } + } + + return { + source: this.normalizeEndpoint(candidate.source), + target: this.normalizeEndpoint(candidate.target), + direction: candidate.direction === 'in' ? 'in' : 'out', + type: this.normalizeRelationType(candidate.type), + mode, + confidence: this.calibrateConfidence(candidate, mode), + rationale: typeof candidate.rationale === 'string' ? candidate.rationale.slice(0, 500) : undefined, + sampleMatchCount: + typeof candidate.sampleMatchCount === 'number' ? Math.max(0, candidate.sampleMatchCount) : undefined + } + } + + private normalizePatternMode(mode?: string) { + return mode === 'retype_existing_relationship' ? 'retype_existing_relationship' : 'join_pattern' + } + + 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) + ) + } + + private isSafeJoinCandidate(candidate: RelationshipPatternCandidate): boolean { + const sourceKey = candidate.source.key ?? '' + const targetKey = candidate.target.key ?? '' + const generic = new Set(['name', 'title', 'label', 'description', 'status', 'country', 'type']) + if (sourceKey === targetKey && generic.has(sourceKey.toLowerCase())) { + return false + } + + const sourceLabel = this.normalizeReferenceToken(candidate.source.label) + const targetLabel = this.normalizeReferenceToken(candidate.target.label) + const sourceKeyToken = this.normalizeReferenceToken(sourceKey) + const targetKeyToken = this.normalizeReferenceToken(targetKey) + const identityLike = /(id|ref|key|token|email)$/i + return ( + this.hasLabelSpecificReference(candidate) || + identityLike.test(sourceKey) || + identityLike.test(targetKey) + ) + } + + private calibrateConfidence( + candidate: RelationshipPatternCandidate, + mode: RelationshipPatternMode + ): number { + const confidence = this.normalizeConfidence(candidate.confidence) + if (mode !== 'join_pattern') { + return confidence + } + + return this.hasLabelSpecificReference(candidate) ? Math.min(confidence, 0.95) : Math.min(confidence, 0.75) + } + + private hasLabelSpecificReference(candidate: RelationshipPatternCandidate): boolean { + const sourceLabel = this.normalizeReferenceToken(candidate.source.label) + const targetLabel = this.normalizeReferenceToken(candidate.target.label) + const sourceKeyToken = this.normalizeReferenceToken(candidate.source.key ?? '') + const targetKeyToken = this.normalizeReferenceToken(candidate.target.key ?? '') + + return sourceKeyToken.includes(targetLabel) || targetKeyToken.includes(sourceLabel) + } + + private normalizeReferenceToken(value: string): string { + return value + .toLowerCase() + .replace(/[^a-z0-9]/g, '') + .replace(/s$/, '') + } + + private normalizeEndpoint(endpoint: RelationshipPatternEndpoint): RelationshipPatternEndpoint { + return { + label: String(endpoint.label).trim(), + key: endpoint.key ? String(endpoint.key).trim() : undefined, + where: endpoint.where + } + } + + private normalizeRelationType(type?: string): string { + const normalized = String(type || RUSHDB_RELATION_DEFAULT) + .trim() + .toUpperCase() + .replace(/\s+/g, '_') + .replace(/[^A-Z0-9_]/g, '') + return normalized || RUSHDB_RELATION_DEFAULT + } + + private normalizeConfidence(confidence?: number): number { + if (typeof confidence !== 'number' || Number.isNaN(confidence)) { + return 0 + } + return Math.max(0, Math.min(1, confidence)) + } + + private candidateToInsert(projectId: string, candidate: RelationshipPatternCandidate) { + const now = new Date().toISOString() + const mode = this.normalizePatternMode(candidate.mode) + const sourceWhere = candidate.source.where ? JSON.stringify(candidate.source.where) : null + const targetWhere = candidate.target.where ? JSON.stringify(candidate.target.where) : null + const signatureHash = this.signatureHash({ + mode, + sourceLabel: candidate.source.label, + sourceKey: mode === 'join_pattern' ? candidate.source.key : null, + sourceWhere, + targetLabel: candidate.target.label, + targetKey: mode === 'join_pattern' ? candidate.target.key : null, + targetWhere, + direction: candidate.direction, + type: candidate.type + }) + + return { + projectId, + sourceLabel: candidate.source.label, + sourceKey: mode === 'join_pattern' ? candidate.source.key : null, + sourceWhere, + targetLabel: candidate.target.label, + targetKey: mode === 'join_pattern' ? candidate.target.key : null, + targetWhere, + direction: candidate.direction ?? 'out', + type: candidate.type ?? RUSHDB_RELATION_DEFAULT, + mode, + confidence: Math.round(this.normalizeConfidence(candidate.confidence) * 10_000), + status: 'suggested' as RelationshipPatternStatus, + origin: 'llm', + signatureHash, + rationale: candidate.rationale, + sampleMatchCount: candidate.sampleMatchCount, + lastAnalyzedAt: now, + lastError: null + } + } + + private signatureHash(value: unknown): string { + return createHash('sha256').update(JSON.stringify(value)).digest('hex') + } + + private toDto(row: RelationshipPatternRow): RelationshipPatternDto { + return { + id: row.id, + status: row.status as RelationshipPatternDto['status'], + origin: row.origin as RelationshipPatternDto['origin'], + source: { + label: row.sourceLabel, + key: row.sourceKey ?? undefined, + where: row.sourceWhere ? JSON.parse(row.sourceWhere) : undefined + }, + target: { + label: row.targetLabel, + key: row.targetKey ?? undefined, + where: row.targetWhere ? JSON.parse(row.targetWhere) : undefined + }, + direction: row.direction as RelationshipPatternDto['direction'], + type: row.type, + mode: this.normalizePatternMode(row.mode), + confidence: row.confidence / 10_000, + rationale: row.rationale ?? undefined, + sampleMatchCount: row.sampleMatchCount ?? undefined, + lastAppliedAt: row.lastAppliedAt ?? undefined, + lastAnalyzedAt: row.lastAnalyzedAt ?? undefined, + lastError: row.lastError ?? undefined, + createdAt: row.createdAt, + updatedAt: row.updatedAt + } + } + + private async applyPattern(pattern: RelationshipPatternRow, transaction: Transaction): Promise { + try { + const source = { + label: pattern.sourceLabel, + key: pattern.sourceKey, + where: pattern.sourceWhere ? JSON.parse(pattern.sourceWhere) : undefined + } + const target = { + label: pattern.targetLabel, + key: pattern.targetKey, + where: pattern.targetWhere ? JSON.parse(pattern.targetWhere) : undefined + } + const direction = pattern.direction as TRelationDirection + const appliedCount = + pattern.mode === 'retype_existing_relationship' ? + await this.entityService.retypeRelationsByLabels({ + source: { label: pattern.sourceLabel }, + target: { label: pattern.targetLabel }, + sourceType: RUSHDB_RELATION_DEFAULT, + targetType: pattern.type, + direction, + projectId: pattern.projectId, + transaction + }) + : await this.entityService.createRelationsByKeys({ + source, + target, + type: pattern.type, + direction, + projectId: pattern.projectId, + transaction + }) + if (pattern.mode === 'join_pattern' && pattern.type !== RUSHDB_RELATION_DEFAULT) { + await this.entityService.deleteRelationsByKeys({ + source, + target, + type: RUSHDB_RELATION_DEFAULT, + projectId: pattern.projectId, + transaction + }) + } + await this.aiService.getOntology({ + projectId: pattern.projectId, + force: true, + transaction + }) + await this.repository.updatePattern(pattern.id, { + lastAppliedAt: new Date().toISOString(), + sampleMatchCount: appliedCount, + lastError: null + }) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + await this.repository.updatePattern(pattern.id, { lastError: message }) + throw error + } + } + + private async deletePatternRelationships( + pattern: RelationshipPatternRow, + transaction: Transaction + ): Promise { + if (pattern.mode === 'retype_existing_relationship') { + await this.entityService.deleteRelationsByLabels({ + source: { label: pattern.sourceLabel }, + target: { label: pattern.targetLabel }, + type: pattern.type, + projectId: pattern.projectId, + transaction + }) + return + } + + await this.entityService.deleteRelationsByKeys({ + source: { + label: pattern.sourceLabel, + key: pattern.sourceKey, + where: pattern.sourceWhere ? JSON.parse(pattern.sourceWhere) : undefined + }, + target: { + label: pattern.targetLabel, + key: pattern.targetKey, + where: pattern.targetWhere ? JSON.parse(pattern.targetWhere) : undefined + }, + type: pattern.type, + direction: pattern.direction as TRelationDirection, + projectId: pattern.projectId, + transaction + }) + } + + private async applyPatternInFreshTransaction(pattern: RelationshipPatternRow): Promise { + const session = this.neogmaService.createSession('relationship-pattern-apply') + const transaction = session.beginTransaction({ timeout: 60_000 }) + try { + await this.applyPattern(pattern, transaction) + await transaction.commit() + } catch (error) { + if (transaction.isOpen()) { + await transaction.rollback() + } + throw error + } finally { + await this.neogmaService.closeSession(session, 'relationship-pattern-apply') + } + } + + private async deleteRelationshipsInFreshTransaction(pattern: RelationshipPatternRow): Promise { + const session = this.neogmaService.createSession('relationship-pattern-delete') + const transaction = session.beginTransaction({ timeout: 60_000 }) + try { + await this.deletePatternRelationships(pattern, transaction) + await this.aiService.getOntology({ + projectId: pattern.projectId, + force: true, + transaction + }) + await transaction.commit() + } catch (error) { + if (transaction.isOpen()) { + await transaction.rollback() + } + throw error + } finally { + await this.neogmaService.closeSession(session, 'relationship-pattern-delete') + } + } +} diff --git a/platform/core/src/core/relationship-patterns/relationship-patterns.types.ts b/platform/core/src/core/relationship-patterns/relationship-patterns.types.ts new file mode 100644 index 00000000..948c5815 --- /dev/null +++ b/platform/core/src/core/relationship-patterns/relationship-patterns.types.ts @@ -0,0 +1,57 @@ +import type { Where } from '@/core/common/types' + +export type RelationshipPatternStatus = 'suggested' | 'approved' | 'ignored' | 'error' +export type RelationshipPatternOrigin = 'llm' | 'manual' +export type RelationshipPatternDirection = 'in' | 'out' +export type RelationshipPatternMode = 'join_pattern' | 'retype_existing_relationship' + +export type RelationshipPatternEndpoint = { + label: string + key?: string + where?: Where +} + +export type RelationshipPatternDto = { + id: string + status: RelationshipPatternStatus + origin: RelationshipPatternOrigin + source: RelationshipPatternEndpoint + target: RelationshipPatternEndpoint + direction: RelationshipPatternDirection + type: string + mode: RelationshipPatternMode + confidence: number + rationale?: string + sampleMatchCount?: number + lastAppliedAt?: string + lastAnalyzedAt?: string + lastError?: string + createdAt: string + updatedAt: string +} + +export type RelationshipPatternCandidate = { + source: RelationshipPatternEndpoint + target: RelationshipPatternEndpoint + direction?: RelationshipPatternDirection + type?: string + mode?: RelationshipPatternMode + confidence?: number + rationale?: string + sampleMatchCount?: number +} + +export type RelationshipPatternListResponse = { + patterns: RelationshipPatternDto[] + relationships: Array<{ + label: string + relationships: Array<{ label: string; type: string; direction: string }> + }> + analysis?: { + status: string + requestedAt?: string + notBefore?: string + lastRunAt?: string + lastError?: string + } +} diff --git a/platform/core/src/core/relationships/controller.ts b/platform/core/src/core/relationships/controller.ts index 2dfd0419..20247981 100755 --- a/platform/core/src/core/relationships/controller.ts +++ b/platform/core/src/core/relationships/controller.ts @@ -18,6 +18,7 @@ import { ApiBearerAuth, ApiParam, ApiTags } from '@nestjs/swagger' import { Transaction } from 'neo4j-driver' import { NotFoundInterceptor } from '@/common/interceptors/not-found.interceptor' +import { ESideEffectType, RunSideEffectMixin } from '@/common/interceptors/run-side-effect.interceptor' import { TransformResponseInterceptor } from '@/common/interceptors/transform-response.interceptor' import { PlatformRequest } from '@/common/types/request' import { ValidationPipe } from '@/common/validation/validation.pipe' @@ -65,6 +66,7 @@ export class RelationshipsController { ) @UsePipes(ValidationPipe(createRelationSchema, 'body')) @AuthGuard('project') + @UseInterceptors(RunSideEffectMixin([ESideEffectType.RECALCULATE_ONTOLOGY_CACHE])) @HttpCode(HttpStatus.CREATED) async attach( @Param('entityId') entityId: string, @@ -92,6 +94,7 @@ export class RelationshipsController { ) @UsePipes(ValidationPipe(deleteRelationsSchema, 'body')) @AuthGuard('project') + @UseInterceptors(RunSideEffectMixin([ESideEffectType.RECALCULATE_ONTOLOGY_CACHE])) @HttpCode(HttpStatus.OK) async detach( @Param('entityId') entityId: string, @@ -110,6 +113,7 @@ export class RelationshipsController { @UseGuards(PlanLimitsGuard, IsRelatedToProjectGuard()) @AuthGuard('project') @UsePipes(ValidationPipe(createRelationsByKeysSchema, 'body')) + @UseInterceptors(RunSideEffectMixin([ESideEffectType.RECALCULATE_ONTOLOGY_CACHE])) @HttpCode(HttpStatus.CREATED) async createMany( @Body() @@ -174,6 +178,7 @@ export class RelationshipsController { @UseGuards(IsRelatedToProjectGuard()) @AuthGuard('project') @UsePipes(ValidationPipe(createRelationsByKeysSchema, 'body')) + @UseInterceptors(RunSideEffectMixin([ESideEffectType.RECALCULATE_ONTOLOGY_CACHE])) @HttpCode(HttpStatus.OK) async deleteMany( @Body() diff --git a/platform/core/src/dashboard/mcp-oauth/mcp-oauth.service.ts b/platform/core/src/dashboard/mcp-oauth/mcp-oauth.service.ts index 51ff2d76..4718abbc 100644 --- a/platform/core/src/dashboard/mcp-oauth/mcp-oauth.service.ts +++ b/platform/core/src/dashboard/mcp-oauth/mcp-oauth.service.ts @@ -336,22 +336,32 @@ export class McpOauthService { const rawRefreshToken = randomBytes(32).toString('hex') const hashedRefreshToken = createHash('sha256').update(rawRefreshToken).digest('hex') const now = new Date() - await this.oauthRepository.createRefreshToken({ - id: hashedRefreshToken, - consentId: codeRow.consentId, - clientId: codeRow.clientId, - userId: consentRow.userId, - projectId: consentRow.projectId, - scope: consentRow.scope, - createdAt: now.toISOString(), - expiresAt: new Date(now.getTime() + REFRESH_TOKEN_TTL_MS).toISOString() - }) + + let issuedRefreshToken: string | undefined + try { + await this.oauthRepository.createRefreshToken({ + id: hashedRefreshToken, + consentId: codeRow.consentId, + clientId: codeRow.clientId, + userId: consentRow.userId, + projectId: consentRow.projectId, + scope: consentRow.scope, + createdAt: now.toISOString(), + expiresAt: new Date(now.getTime() + REFRESH_TOKEN_TTL_MS).toISOString() + }) + issuedRefreshToken = rawRefreshToken + } catch (err) { + this.logger.warn( + '[OAuth] Failed to persist refresh token (table may not exist yet), proceeding without it', + err + ) + } return { access_token: accessToken, token_type: 'bearer', expires_in: ACCESS_TOKEN_TTL_S, - refresh_token: rawRefreshToken, + ...(issuedRefreshToken ? { refresh_token: issuedRefreshToken } : {}), scope: consentRow.scope } } diff --git a/platform/core/src/database/sql/migrations/pg/0002_relationship_patterns.sql b/platform/core/src/database/sql/migrations/pg/0002_relationship_patterns.sql new file mode 100644 index 00000000..523f4108 --- /dev/null +++ b/platform/core/src/database/sql/migrations/pg/0002_relationship_patterns.sql @@ -0,0 +1,38 @@ +CREATE TABLE "relationship_analysis_queue" ( + "project_id" text PRIMARY KEY NOT NULL, + "requested_at" text NOT NULL, + "not_before" text NOT NULL, + "status" text DEFAULT 'pending' NOT NULL, + "last_run_at" text, + "last_error" text, + "created_at" text NOT NULL, + "updated_at" text NOT NULL +); +--> statement-breakpoint +CREATE TABLE "relationship_patterns" ( + "id" text PRIMARY KEY NOT NULL, + "project_id" text NOT NULL, + "source_label" text NOT NULL, + "source_key" text, + "source_where" text, + "target_label" text NOT NULL, + "target_key" text, + "target_where" text, + "direction" text DEFAULT 'out' NOT NULL, + "type" text NOT NULL, + "confidence" integer DEFAULT 0 NOT NULL, + "status" text DEFAULT 'suggested' NOT NULL, + "origin" text DEFAULT 'llm' NOT NULL, + "signature_hash" text NOT NULL, + "rationale" text, + "sample_match_count" integer, + "last_applied_at" text, + "last_analyzed_at" text, + "last_error" text, + "created_at" text NOT NULL, + "updated_at" text NOT NULL +); +--> statement-breakpoint +ALTER TABLE "relationship_analysis_queue" ADD CONSTRAINT "relationship_analysis_queue_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "relationship_patterns" ADD CONSTRAINT "relationship_patterns_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "rel_pattern_signature_uniq" ON "relationship_patterns" USING btree ("project_id","signature_hash"); diff --git a/platform/core/src/database/sql/migrations/pg/0003_relationship_pattern_mode.sql b/platform/core/src/database/sql/migrations/pg/0003_relationship_pattern_mode.sql new file mode 100644 index 00000000..75019d7b --- /dev/null +++ b/platform/core/src/database/sql/migrations/pg/0003_relationship_pattern_mode.sql @@ -0,0 +1 @@ +ALTER TABLE "relationship_patterns" ADD COLUMN "mode" text DEFAULT 'join_pattern' NOT NULL; diff --git a/platform/core/src/database/sql/migrations/pg/meta/_journal.json b/platform/core/src/database/sql/migrations/pg/meta/_journal.json index 43e30ca0..dd781a05 100644 --- a/platform/core/src/database/sql/migrations/pg/meta/_journal.json +++ b/platform/core/src/database/sql/migrations/pg/meta/_journal.json @@ -12,9 +12,23 @@ { "idx": 1, "version": "7", - "when": 1748822400000, + "when": 1779408000000, "tag": "0001_oauth_refresh_tokens", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1779494400000, + "tag": "0002_relationship_patterns", + "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1779580800000, + "tag": "0003_relationship_pattern_mode", + "breakpoints": true } ] } diff --git a/platform/core/src/database/sql/migrations/sqlite/0002_relationship_patterns.sql b/platform/core/src/database/sql/migrations/sqlite/0002_relationship_patterns.sql new file mode 100644 index 00000000..6621d7d6 --- /dev/null +++ b/platform/core/src/database/sql/migrations/sqlite/0002_relationship_patterns.sql @@ -0,0 +1,38 @@ +CREATE TABLE `relationship_analysis_queue` ( + `project_id` text PRIMARY KEY NOT NULL, + `requested_at` text NOT NULL, + `not_before` text NOT NULL, + `status` text DEFAULT 'pending' NOT NULL, + `last_run_at` text, + `last_error` text, + `created_at` text NOT NULL, + `updated_at` text NOT NULL, + FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `relationship_patterns` ( + `id` text PRIMARY KEY NOT NULL, + `project_id` text NOT NULL, + `source_label` text NOT NULL, + `source_key` text, + `source_where` text, + `target_label` text NOT NULL, + `target_key` text, + `target_where` text, + `direction` text DEFAULT 'out' NOT NULL, + `type` text NOT NULL, + `confidence` integer DEFAULT 0 NOT NULL, + `status` text DEFAULT 'suggested' NOT NULL, + `origin` text DEFAULT 'llm' NOT NULL, + `signature_hash` text NOT NULL, + `rationale` text, + `sample_match_count` integer, + `last_applied_at` text, + `last_analyzed_at` text, + `last_error` text, + `created_at` text NOT NULL, + `updated_at` text NOT NULL, + FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `rel_pattern_signature_uniq` ON `relationship_patterns` (`project_id`,`signature_hash`); diff --git a/platform/core/src/database/sql/migrations/sqlite/0003_relationship_pattern_mode.sql b/platform/core/src/database/sql/migrations/sqlite/0003_relationship_pattern_mode.sql new file mode 100644 index 00000000..cf9a6a0a --- /dev/null +++ b/platform/core/src/database/sql/migrations/sqlite/0003_relationship_pattern_mode.sql @@ -0,0 +1 @@ +ALTER TABLE `relationship_patterns` ADD `mode` text DEFAULT 'join_pattern' NOT NULL; diff --git a/platform/core/src/database/sql/migrations/sqlite/meta/_journal.json b/platform/core/src/database/sql/migrations/sqlite/meta/_journal.json index 4156f48e..005a127e 100644 --- a/platform/core/src/database/sql/migrations/sqlite/meta/_journal.json +++ b/platform/core/src/database/sql/migrations/sqlite/meta/_journal.json @@ -12,9 +12,23 @@ { "idx": 1, "version": "6", - "when": 1748822400000, + "when": 1779408000000, "tag": "0001_oauth_refresh_tokens", "breakpoints": true + }, + { + "idx": 2, + "version": "6", + "when": 1779494400000, + "tag": "0002_relationship_patterns", + "breakpoints": true + }, + { + "idx": 3, + "version": "6", + "when": 1779580800000, + "tag": "0003_relationship_pattern_mode", + "breakpoints": true } ] } diff --git a/platform/core/src/database/sql/schema/pg.schema.ts b/platform/core/src/database/sql/schema/pg.schema.ts index c09ee587..923c8dd0 100644 --- a/platform/core/src/database/sql/schema/pg.schema.ts +++ b/platform/core/src/database/sql/schema/pg.schema.ts @@ -197,6 +197,50 @@ export const embeddingIndexes = pgTable( ] ) +export const relationshipPatterns = pgTable( + 'relationship_patterns', + { + id: text('id').primaryKey(), + projectId: text('project_id') + .notNull() + .references(() => projects.id, { onDelete: 'cascade' }), + sourceLabel: text('source_label').notNull(), + sourceKey: text('source_key'), + sourceWhere: text('source_where'), + targetLabel: text('target_label').notNull(), + targetKey: text('target_key'), + targetWhere: text('target_where'), + direction: text('direction').notNull().default('out'), + type: text('type').notNull(), + confidence: integer('confidence').notNull().default(0), + status: text('status').notNull().default('suggested'), + origin: text('origin').notNull().default('llm'), + mode: text('mode').notNull().default('join_pattern'), + signatureHash: text('signature_hash').notNull(), + rationale: text('rationale'), + sampleMatchCount: integer('sample_match_count'), + lastAppliedAt: text('last_applied_at'), + lastAnalyzedAt: text('last_analyzed_at'), + lastError: text('last_error'), + createdAt: text('created_at').notNull(), + updatedAt: text('updated_at').notNull() + }, + (t) => [uniqueIndex('rel_pattern_signature_uniq').on(t.projectId, t.signatureHash)] +) + +export const relationshipAnalysisQueue = pgTable('relationship_analysis_queue', { + projectId: text('project_id') + .primaryKey() + .references(() => projects.id, { onDelete: 'cascade' }), + requestedAt: text('requested_at').notNull(), + notBefore: text('not_before').notNull(), + status: text('status').notNull().default('pending'), + lastRunAt: text('last_run_at'), + lastError: text('last_error'), + createdAt: text('created_at').notNull(), + updatedAt: text('updated_at').notNull() +}) + export const pgSchema = { users, workspaces, @@ -210,7 +254,9 @@ export const pgSchema = { oauthConsents, oauthCodes, oauthRefreshTokens, - embeddingIndexes + embeddingIndexes, + relationshipPatterns, + relationshipAnalysisQueue } export type PgSchema = typeof pgSchema diff --git a/platform/core/src/database/sql/schema/sqlite.schema.ts b/platform/core/src/database/sql/schema/sqlite.schema.ts index c4c07438..53904b14 100644 --- a/platform/core/src/database/sql/schema/sqlite.schema.ts +++ b/platform/core/src/database/sql/schema/sqlite.schema.ts @@ -203,6 +203,50 @@ export const embeddingIndexes = sqliteTable( ] ) +export const relationshipPatterns = sqliteTable( + 'relationship_patterns', + { + id: text('id').primaryKey(), + projectId: text('project_id') + .notNull() + .references(() => projects.id, { onDelete: 'cascade' }), + sourceLabel: text('source_label').notNull(), + sourceKey: text('source_key'), + sourceWhere: text('source_where'), + targetLabel: text('target_label').notNull(), + targetKey: text('target_key'), + targetWhere: text('target_where'), + direction: text('direction').notNull().default('out'), + type: text('type').notNull(), + confidence: integer('confidence').notNull().default(0), + status: text('status').notNull().default('suggested'), + origin: text('origin').notNull().default('llm'), + mode: text('mode').notNull().default('join_pattern'), + signatureHash: text('signature_hash').notNull(), + rationale: text('rationale'), + sampleMatchCount: integer('sample_match_count'), + lastAppliedAt: text('last_applied_at'), + lastAnalyzedAt: text('last_analyzed_at'), + lastError: text('last_error'), + createdAt: text('created_at').notNull(), + updatedAt: text('updated_at').notNull() + }, + (t) => [uniqueIndex('rel_pattern_signature_uniq').on(t.projectId, t.signatureHash)] +) + +export const relationshipAnalysisQueue = sqliteTable('relationship_analysis_queue', { + projectId: text('project_id') + .primaryKey() + .references(() => projects.id, { onDelete: 'cascade' }), + requestedAt: text('requested_at').notNull(), + notBefore: text('not_before').notNull(), + status: text('status').notNull().default('pending'), + lastRunAt: text('last_run_at'), + lastError: text('last_error'), + createdAt: text('created_at').notNull(), + updatedAt: text('updated_at').notNull() +}) + export const sqliteSchema = { users, workspaces, @@ -216,7 +260,9 @@ export const sqliteSchema = { oauthConsents, oauthCodes, oauthRefreshTokens, - embeddingIndexes + embeddingIndexes, + relationshipPatterns, + relationshipAnalysisQueue } export type SqliteSchema = typeof sqliteSchema diff --git a/platform/core/src/database/sql/schema/types.ts b/platform/core/src/database/sql/schema/types.ts index f0bc37fc..ca30b332 100644 --- a/platform/core/src/database/sql/schema/types.ts +++ b/platform/core/src/database/sql/schema/types.ts @@ -43,3 +43,9 @@ export type InsertOauthRefreshTokenRow = typeof sqliteSchema.oauthRefreshTokens. export type EmbeddingIndexRow = typeof sqliteSchema.embeddingIndexes.$inferSelect export type InsertEmbeddingIndexRow = typeof sqliteSchema.embeddingIndexes.$inferInsert + +export type RelationshipPatternRow = typeof sqliteSchema.relationshipPatterns.$inferSelect +export type InsertRelationshipPatternRow = typeof sqliteSchema.relationshipPatterns.$inferInsert + +export type RelationshipAnalysisQueueRow = typeof sqliteSchema.relationshipAnalysisQueue.$inferSelect +export type InsertRelationshipAnalysisQueueRow = typeof sqliteSchema.relationshipAnalysisQueue.$inferInsert diff --git a/platform/dashboard/src/components/billing/KuUsageHistory.tsx b/platform/dashboard/src/components/billing/KuUsageHistory.tsx index 1d44f1bb..7544cb8a 100644 --- a/platform/dashboard/src/components/billing/KuUsageHistory.tsx +++ b/platform/dashboard/src/components/billing/KuUsageHistory.tsx @@ -27,7 +27,8 @@ const OPERATION_LABELS: Record = { relationship_created: 'Relationship Created', storage_footprint: 'Daily Storage Footprint', compute_operation: 'Compute Operation', - knowledge_deleted: 'Knowledge Deleted' + knowledge_deleted: 'Knowledge Deleted', + relationship_analysis: 'Relationship Analysis' } // Format metadata for display in the events list @@ -74,6 +75,16 @@ function formatMetadata(operation: string, metadata: Record | n return `type: ${metadata.type}` } + // Relationship analysis - show trigger, token count, candidate count + if (operation === 'relationship_analysis') { + const parts: string[] = [] + if (metadata.trigger) parts.push(String(metadata.trigger)) + if (typeof metadata.totalTokens === 'number') + parts.push(`${metadata.totalTokens.toLocaleString()} tokens`) + if (typeof metadata.candidateCount === 'number') parts.push(`${metadata.candidateCount} candidates`) + return parts.join(' • ') + } + // Default: show simple key-value pairs (excluding count and complex objects) return Object.entries(metadata) .filter(([key, value]) => { diff --git a/platform/dashboard/src/elements/Menu.tsx b/platform/dashboard/src/elements/Menu.tsx index 8488a7ae..bda71a08 100644 --- a/platform/dashboard/src/elements/Menu.tsx +++ b/platform/dashboard/src/elements/Menu.tsx @@ -102,6 +102,7 @@ export function Menu({ children, trigger, className, + modal, open, onOpenChange, ...contentProps @@ -109,9 +110,9 @@ export function Menu({ children?: ReactNode trigger?: ReactNode } & MenuContentProps & - Pick) { + Pick) { return ( - + {trigger ? {trigger} : null} diff --git a/platform/dashboard/src/features/projects/components/GraphView.tsx b/platform/dashboard/src/features/projects/components/GraphView.tsx index dd6645c1..59bb0ed0 100644 --- a/platform/dashboard/src/features/projects/components/GraphView.tsx +++ b/platform/dashboard/src/features/projects/components/GraphView.tsx @@ -2,7 +2,7 @@ import type { FC } from 'react' import React, { useRef, useCallback, useState, useEffect, useMemo } from 'react' import { useStore } from '@nanostores/react' -import { EyeOff, RotateCcw, ScanSearch, Square, Box } from 'lucide-react' +import { Layers, RotateCcw, ScanSearch } from 'lucide-react' import { ForceGraph2D, ForceGraph3D } from 'react-force-graph' import SpriteText from 'three-spritetext' import * as THREE from 'three' @@ -16,17 +16,20 @@ import { } from '~/features/projects/hooks/useProjectQueries' import { Button } from '~/elements/Button' import { Tooltip } from '~/elements/Tooltip' -import { CheckboxField } from '~/elements/Checkbox' +import { Checkbox } from '~/elements/Checkbox' +import { Tab, Tabs, TabsList } from '~/elements/Tabs' +import { IconButton } from '~/elements/IconButton' +import { Menu, MenuTitle } from '~/elements/Menu' import { $sheetProperty, $sheetRecordId, type PropertySheetData } from '~/features/projects/stores/id.ts' import { getLabelColor } from '~/features/labels' import type { DBRecord, DBRecordInstance } from '@rushdb/javascript-sdk' import { type Relation } from '@rushdb/javascript-sdk' -type GraphMode = '2d' | '3d' +export type GraphMode = '2d' | '3d' type GraphNodeKind = 'record' | 'property' type GraphLinkKind = 'record-relation' | 'property-value' -type GraphNode = { +export type GraphNode = { id: string kind: GraphNodeKind label: string @@ -39,7 +42,7 @@ type GraphNode = { connectedRecordIds?: string[] } -type GraphLink = { +export type GraphLink = { id: string kind: GraphLinkKind source: string @@ -57,6 +60,28 @@ type HoverHighlights = { linkIds: Set } +function LayerToggle({ + checked, + description, + label, + onCheckedChange +}: { + checked: boolean + description: string + label: string + onCheckedChange: () => void +}) { + return ( + + ) +} + function getGraphRefId(ref: unknown): string | undefined { if (typeof ref === 'string') return ref if (ref && typeof ref === 'object' && 'id' in (ref as Record)) { @@ -237,10 +262,49 @@ function getNodeHoverLabel(node: GraphNode): string { const HEADER_HEIGHT = 182 const FOOTER_HEIGHT = 61 +function renderLinkLabel2D({ + ctx, + globalScale, + highlighted, + labelScale = 1, + link, + visible = highlighted +}: { + ctx: CanvasRenderingContext2D + globalScale: number + highlighted: boolean + labelScale?: number + link: GraphLink + visible?: boolean +}) { + if (!visible || !link.relationType || link.kind !== 'record-relation') { + return + } + + const source = link.source as unknown as { x?: number; y?: number } + const target = link.target as unknown as { x?: number; y?: number } + const sourceX = source.x ?? 0 + const sourceY = source.y ?? 0 + const targetX = target.x ?? 0 + const targetY = target.y ?? 0 + const x = (sourceX + targetX) / 2 + const y = (sourceY + targetY) / 2 + const fontSize = Math.min(Math.max((8 * labelScale) / globalScale, 2.5), 11 * labelScale) + + ctx.save() + ctx.font = `${fontSize}px monospace` + ctx.fillStyle = highlighted ? '#d9e2ec' : '#9aa7b8' + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + ctx.fillText(link.relationType, x, y) + ctx.restore() +} + export const GraphView: FC = () => { const fgRef = useRef(null) const pinned2DPositionsRef = useRef>({}) const pinned3DPositionsRef = useRef>({}) + const draggingNodeIdRef = useRef(undefined) const { data: relationsResult } = useFilteredRecordRelationsQuery() const { data: recordsResult } = useFilteredRecordsQuery() @@ -254,6 +318,8 @@ export const GraphView: FC = () => { const [showProperties, setShowProperties] = useState(true) const [showPropertyLinks, setShowPropertyLinks] = useState(true) const [showRecordLinks, setShowRecordLinks] = useState(true) + const [showRecordLabels, setShowRecordLabels] = useState(true) + const [showRelationshipTypes, setShowRelationshipTypes] = useState(true) const [hoveredNodeId, setHoveredNodeId] = useState(undefined) const [hoveredLinkId, setHoveredLinkId] = useState(undefined) @@ -397,6 +463,10 @@ export const GraphView: FC = () => { ) useEffect(() => { + if (draggingNodeIdRef.current) { + return + } + if (graphMode === '2d') { // Apply saved 2D pinned positions when available. visibleGraphData.nodes.forEach((node) => { @@ -430,46 +500,6 @@ export const GraphView: FC = () => { }) }, [graphMode, visibleGraphData]) - useEffect(() => { - if (graphMode !== '3d') return - - visibleGraphData.nodes.forEach((node) => { - const object3D = (node as any).__threeObj as THREE.Object3D | undefined - if (!object3D) return - - const highlighted = isNodeHighlighted(node.id) - const dimmed = hasHoverSelection && !highlighted - - object3D.scale.setScalar( - dimmed ? 0.85 - : highlighted ? 1.2 - : 1 - ) - - object3D.traverse((child) => { - const material = (child as any).material - if (!material) return - - const materials = Array.isArray(material) ? material : [material] - materials.forEach((mat: any) => { - if (typeof mat.opacity === 'number') { - mat.transparent = dimmed - mat.opacity = dimmed ? 0.2 : 1 - } - if (mat.emissive && typeof mat.emissive.setHex === 'function') { - mat.emissive.setHex( - dimmed ? 0x000000 - : highlighted && hasHoverSelection ? 0x1a1a1a - : 0x000000 - ) - } - }) - }) - }) - - fgRef.current?.refresh?.() - }, [graphMode, hasHoverSelection, isNodeHighlighted, visibleGraphData.nodes]) - const focusNode = useCallback( (node: any) => { // ForceGraph3D supports cameraPosition, while ForceGraph2D uses centerAt/zoom. @@ -575,8 +605,64 @@ export const GraphView: FC = () => { }, []) const fitGraph = useCallback(() => { - fgRef.current?.zoomToFit?.(400, 30) - }, []) + if (graphMode !== '3d') { + fgRef.current?.zoomToFit?.(400, 30) + return + } + + const positionedNodes = visibleGraphData.nodes + .map((node) => { + const mutable = node as any + return { + x: Number(mutable.x), + y: Number(mutable.y), + z: Number(mutable.z) + } + }) + .filter((node) => Number.isFinite(node.x) && Number.isFinite(node.y) && Number.isFinite(node.z)) + + if (!positionedNodes.length) { + fgRef.current?.zoomToFit?.(400, 30) + return + } + + const center = positionedNodes.reduce( + (acc, node) => ({ + x: acc.x + node.x / positionedNodes.length, + y: acc.y + node.y / positionedNodes.length, + z: acc.z + node.z / positionedNodes.length + }), + { x: 0, y: 0, z: 0 } + ) + const distances = positionedNodes + .map((node) => Math.hypot(node.x - center.x, node.y - center.y, node.z - center.z)) + .sort((a, b) => a - b) + const mainClusterRadius = + distances[Math.min(distances.length - 1, Math.floor(distances.length * 0.9))] ?? 80 + const distance = Math.min(900, Math.max(120, mainClusterRadius * 2.4)) + const camera = fgRef.current?.camera?.() + const direction = new THREE.Vector3( + camera?.position?.x ?? 0, + camera?.position?.y ?? 0, + camera?.position?.z ?? 1 + ) + .sub(new THREE.Vector3(center.x, center.y, center.z)) + .normalize() + + if (!Number.isFinite(direction.x) || !Number.isFinite(direction.y) || !Number.isFinite(direction.z)) { + direction.set(0, 0, 1) + } + + fgRef.current?.cameraPosition?.( + { + x: center.x + direction.x * distance, + y: center.y + direction.y * distance, + z: center.z + direction.z * distance + }, + center, + 500 + ) + }, [graphMode, visibleGraphData.nodes]) const reheatSimulation = useCallback(() => { // Relayout should start from a clean simulation state with no pinned nodes. @@ -593,33 +679,67 @@ export const GraphView: FC = () => { fgRef.current?.d3ReheatSimulation?.() }, [visibleGraphData.nodes]) - const createNodeObject3D = useCallback((node: GraphNode) => { - const size = 9 + const createNodeObject3D = useCallback( + (node: GraphNode) => { + const size = 9 + + if (node.kind === 'property') { + return new THREE.Mesh( + new THREE.BoxGeometry(size, size, size), + new THREE.MeshLambertMaterial({ color: node.color }) + ) + } + + const group = new THREE.Group() - if (node.kind === 'property') { - return new THREE.Mesh( - new THREE.BoxGeometry(size, size, size), + const sphere = new THREE.Mesh( + new THREE.SphereGeometry(size / 2, 20, 20), new THREE.MeshLambertMaterial({ color: node.color }) ) - } + group.add(sphere) + + if (showRecordLabels) { + // Keep record labels visible in 3D with a dimmed color to reduce visual noise. + const label = new SpriteText(node.label) + label.color = '#9aa7b8' + label.textHeight = 1.45 + label.position.set(0, -6.3, 0) + group.add(label) + } - const group = new THREE.Group() + return group + }, + [showRecordLabels] + ) - const sphere = new THREE.Mesh( - new THREE.SphereGeometry(size / 2, 20, 20), - new THREE.MeshLambertMaterial({ color: node.color }) - ) - group.add(sphere) + const createLinkObject3D = useCallback( + (link: GraphLink) => { + if (!showRelationshipTypes || !link.relationType || link.kind !== 'record-relation') { + return undefined + } + + const label = new SpriteText(link.relationType) + const highlighted = !hasHoverSelection || hoveredLinkId === link.id + label.color = highlighted ? '#d9e2ec' : '#9aa7b8' + label.textHeight = highlighted ? 1.25 : 1 + return label + }, + [hasHoverSelection, hoveredLinkId, showRelationshipTypes] + ) - // Keep record labels always visible in 3D with a dimmed color to reduce visual noise. - const label = new SpriteText(node.label) - label.color = '#9aa7b8' - label.textHeight = 2.2 - label.position.set(0, -7.5, 0) - group.add(label) + const updateLinkObjectPosition3D = useCallback( + (object: THREE.Object3D | undefined, { start, end }: any) => { + if (!object) { + return false + } - return group - }, []) + object.position.x = start.x + (end.x - start.x) * 0.52 + object.position.y = start.y + (end.y - start.y) * 0.52 + object.position.z = start.z + (end.z - start.z) * 0.52 + return true + }, + [] + ) const renderNode2D = useCallback( (node: GraphNode, ctx: CanvasRenderingContext2D, globalScale: number) => { @@ -650,16 +770,16 @@ export const GraphView: FC = () => { ctx.fill() } - if (node.kind === 'record') { + if (node.kind === 'record' && showRecordLabels) { const fontSize = Math.max(8 / globalScale, 2.5) - ctx.font = `${fontSize}px Sans-Serif` + ctx.font = `${fontSize}px monospace` ctx.fillStyle = dimmed ? withOpacity('#9aa7b8', 0.2) : '#9aa7b8' ctx.textAlign = 'center' ctx.fillText(node.label, x, y + 9) } ctx.restore() }, - [hasHoverSelection, isNodeHighlighted] + [hasHoverSelection, isNodeHighlighted, showRecordLabels] ) const renderNodePointerArea2D = useCallback( @@ -683,62 +803,100 @@ export const GraphView: FC = () => { [] ) + const renderLink2D = useCallback( + (link: GraphLink, ctx: CanvasRenderingContext2D, globalScale: number) => { + renderLinkLabel2D({ + ctx, + globalScale, + highlighted: isLinkHighlighted(link.id), + link, + visible: showRelationshipTypes && isLinkHighlighted(link.id) + }) + }, + [isLinkHighlighted, showRelationshipTypes] + ) + const renderCommonGraphControls = ( <> -
-
- Graph Layers -
-
- setShowProperties((v) => !v)} - className="mb-0" - /> - setShowPropertyLinks((v) => !v)} - className="mb-0" - /> - setShowRecordLinks((v) => !v)} - className="mb-0" - /> -
-
- -
- + setGraphMode((current) => (current === '3d' ? '2d' : '3d'))} + - {graphMode === '3d' ? - - : } - {graphMode === '3d' ? 'Switch 2D' : 'Switch 3D'} - + + } > - Toggle between 2D and 3D graph view - - - + - Fit Graph - - - +
) @@ -762,8 +920,19 @@ export const GraphView: FC = () => { `} {renderCommonGraphControls} + setGraphMode(value as GraphMode)} + value={graphMode} + > + + 2D + 3D + + + {selectedProperty && ( -
+
Selected Property
{selectedProperty.name}
Alt + click node to hide it
@@ -777,42 +946,63 @@ export const GraphView: FC = () => { showNavInfo={false} graphData={visibleGraphData} linkWidth={(link: GraphLink) => { - const highlighted = isLinkHighlighted(link.id) - const base = link.kind === 'record-relation' ? 1.05 : 0.55 - return highlighted ? base * 1.2 : base + return 0 }} - linkOpacity={hasHoverSelection ? 0.14 : 0.3} + linkOpacity={1} linkColor={(link: GraphLink) => { const highlighted = isLinkHighlighted(link.id) - const base = link.kind === 'record-relation' ? 'rgba(210,220,230,0.52)' : 'rgba(210,220,230,0.2)' - return highlighted ? base : 'rgba(140,150,160,0.08)' + if (highlighted) { + return link.kind === 'record-relation' ? '#d8e0e8' : '#86909b' + } + return link.kind === 'record-relation' ? '#5f6872' : '#3f4852' }} - nodeOpacity={1} - nodeRelSize={6} + nodeOpacity={hasHoverSelection ? 0.24 : 1} + nodeRelSize={4.5} nodeId={'id'} linkSource={'source'} linkTarget={'target'} nodeResolution={24} height={canvasSize.height} width={canvasSize.width} - nodeColor={(node: GraphNode) => node.color} + nodeColor={(node: GraphNode) => + !hasHoverSelection || isNodeHighlighted(node.id) ? node.color : withOpacity(node.color, 0.22) + } linkDirectionalArrowRelPos={1} - linkDirectionalArrowLength={(link: GraphLink) => (link.kind === 'record-relation' ? 2.2 : 0)} - linkDirectionalArrowResolution={16} + linkDirectionalArrowLength={(link: GraphLink) => (link.kind === 'record-relation' ? 1.15 : 0)} + linkDirectionalArrowColor={(link: GraphLink) => + isLinkHighlighted(link.id) ? '#d8e0e8' : '#5f6872' + } + linkDirectionalArrowResolution={12} nodeThreeObject={createNodeObject3D as any} + linkThreeObject={createLinkObject3D as any} + linkThreeObjectExtend + linkPositionUpdate={updateLinkObjectPosition3D as any} onLinkClick={handleLinkClick} onNodeHover={(node: GraphNode | null) => { + if (draggingNodeIdRef.current) { + return + } setHoveredLinkId(undefined) setHoveredNodeId(node?.id) }} onLinkHover={(link: GraphLink | null) => { + if (draggingNodeIdRef.current) { + return + } setHoveredNodeId(undefined) setHoveredLinkId(link?.id) }} + onNodeDrag={(node: any) => { + draggingNodeIdRef.current = String(node.id ?? '') + node.fx = node.x + node.fy = node.y + node.fz = node.z + }} onNodeDragEnd={(node: any) => { node.fx = node.x node.fy = node.y node.fz = node.z + draggingNodeIdRef.current = undefined if (node?.id && Number.isFinite(node.x) && Number.isFinite(node.y) && Number.isFinite(node.z)) { pinned3DPositionsRef.current[String(node.id)] = { @@ -846,22 +1036,46 @@ export const GraphView: FC = () => { } return hasHoverSelection ? 'rgba(140,150,160,0.1)' : 'rgba(210,220,230,0.2)' }} + nodeRelSize={5} + linkDirectionalArrowRelPos={1} + linkDirectionalArrowLength={(link: GraphLink) => (link.kind === 'record-relation' ? 2.2 : 0)} + linkDirectionalArrowColor={(link: GraphLink) => { + if (isLinkHighlighted(link.id)) { + return 'rgb(210,220,230)' + } + return hasHoverSelection ? 'rgb(140,150,160)' : 'rgb(210,220,230)' + }} + linkCanvasObjectMode={() => 'after'} + linkCanvasObject={renderLink2D as any} nodeCanvasObject={renderNode2D as any} nodePointerAreaPaint={renderNodePointerArea2D as any} cooldownTicks={120} onNodeHover={(node: GraphNode | null) => { + if (draggingNodeIdRef.current) { + return + } setHoveredLinkId(undefined) setHoveredNodeId(node?.id) }} onLinkHover={(link: GraphLink | null) => { + if (draggingNodeIdRef.current) { + return + } setHoveredNodeId(undefined) setHoveredLinkId(link?.id) }} + onNodeDrag={(node: any) => { + draggingNodeIdRef.current = String(node.id ?? '') + node.fx = node.x + node.fy = node.y + node.fz = undefined + }} onNodeDragEnd={(node: any) => { // 2D mode persists manually dragged positions. node.fx = node.x node.fy = node.y node.fz = undefined + draggingNodeIdRef.current = undefined if (node?.id && Number.isFinite(node.x) && Number.isFinite(node.y)) { pinned2DPositionsRef.current[String(node.id)] = { diff --git a/platform/dashboard/src/features/projects/components/ProjectTabs.tsx b/platform/dashboard/src/features/projects/components/ProjectTabs.tsx index d50f4042..d605ad24 100644 --- a/platform/dashboard/src/features/projects/components/ProjectTabs.tsx +++ b/platform/dashboard/src/features/projects/components/ProjectTabs.tsx @@ -1,4 +1,4 @@ -import { Book, Database, Key, Search, Settings, UploadIcon, Wallet2 } from 'lucide-react' +import { Book, Database, Key, Search, Settings, UploadIcon, Waypoints } from 'lucide-react' import { PageTab, PageTabs } from '~/layout/RootLayout/PageTabs' import { getRoutePath } from '~/lib/router' @@ -6,6 +6,7 @@ import { getRoutePath } from '~/lib/router' import type { Project } from '../types' import { useStore } from '@nanostores/react' import { $user } from '~/features/auth/stores/user.ts' +import type { JSX } from 'react' import { useMemo } from 'react' import { usePlatformSettings } from '~/features/auth/hooks/useAuthQueries' @@ -18,7 +19,12 @@ export function ProjectTabs({ project }: { project: Project }) { const projectIsInactive = project.status === 'pending' || project.status === 'provisioning' const tabs = useMemo(() => { - const projectsTabs = + const projectsTabs: { + href: ReturnType + icon: JSX.Element + label: string + dataTour?: string + }[] = projectIsInactive ? [] : [ @@ -27,22 +33,12 @@ export function ProjectTabs({ project }: { project: Project }) { icon: , label: 'Records' }, - - { - href: getRoutePath('projectImportData', { - id: project.id - }), - icon: , - label: 'Import Data', - dataTour: 'project-import-data-chip' - }, { - href: getRoutePath('projectTokens', { + href: getRoutePath('projectRelationships', { id: project.id }), - icon: , - label: 'API Keys', - dataTour: 'project-token-chip' + icon: , + label: 'Relationships' } ] @@ -56,6 +52,25 @@ export function ProjectTabs({ project }: { project: Project }) { }) } + projectsTabs.push( + { + href: getRoutePath('projectImportData', { + id: project.id + }), + icon: , + label: 'Import Data', + dataTour: 'project-import-data-chip' + }, + { + href: getRoutePath('projectTokens', { + id: project.id + }), + icon: , + label: 'API Keys', + dataTour: 'project-token-chip' + } + ) + if (isOwner) { projectsTabs.push({ href: getRoutePath('projectSettings', { diff --git a/platform/dashboard/src/features/projects/stores/current-project.ts b/platform/dashboard/src/features/projects/stores/current-project.ts index ccadd9c6..2edc81eb 100644 --- a/platform/dashboard/src/features/projects/stores/current-project.ts +++ b/platform/dashboard/src/features/projects/stores/current-project.ts @@ -17,6 +17,13 @@ import { $currentProjectId } from './id' export const $recordView = atom('table') +const RECORD_VIEW_SEARCH_PARAM = 'view' +const recordViewValues = new Set(['table', 'graph', 'raw-api']) + +function isRecordViewType(value?: string): value is RecordViewType { + return recordViewValues.has(value as RecordViewType) +} + export const $recordRawApiEntity = persistentAtom('records:raw-api:entity', 'records') export const $recordsOrderBy = atom() @@ -52,6 +59,9 @@ $searchParams.subscribe((value) => { } // Workaround to apply filters after project page is loaded and query params isn't empty. setTimeout(() => $currentProjectFilters.set(filters), 10) + + const view = value[RECORD_VIEW_SEARCH_PARAM] + $recordView.set(isRecordViewType(view) ? view : 'table') }) export const incrementRecordsPage = action($currentProjectRecordsSkip, 'incrementPage', (store) => { @@ -158,3 +168,13 @@ $currentProjectFilters.subscribe(() => { $recordsOrderBy.subscribe(() => { $currentProjectRecordsSkip.set(0) }) + +$recordView.subscribe((view) => { + const page = $router.get() + + if (!isProjectPage(page) || $searchParams.get()[RECORD_VIEW_SEARCH_PARAM] === view) { + return + } + + changeSearchParam(RECORD_VIEW_SEARCH_PARAM, view) +}) diff --git a/platform/dashboard/src/features/records/hooks/useRecordMutations.ts b/platform/dashboard/src/features/records/hooks/useRecordMutations.ts index e9752d98..72edebe9 100644 --- a/platform/dashboard/src/features/records/hooks/useRecordMutations.ts +++ b/platform/dashboard/src/features/records/hooks/useRecordMutations.ts @@ -6,7 +6,7 @@ import type { DBRecord, DBRecordCreationOptions, PropertyDraft } from '@rushdb/j import { toast } from '~/elements/Toast' import { api } from '~/lib/api' import { queryKeys } from '~/lib/queryKeys' -import { $currentProjectId } from '~/features/projects/stores/id' +import { $currentProjectId, $sheetRecordId } from '~/features/projects/stores/id' import { $currentProjectFilters, $recordsOrderBy, @@ -38,8 +38,14 @@ export const useDeleteRecordMutation = () => { const params = useCurrentRecordParams() return useMutation({ mutationFn: ({ id }: { id: DBRecord['__id'] }) => api.records.deleteById({ id }), - onSuccess() { + onSuccess(_, { id }) { if (!params.projectId) return + if ($sheetRecordId.get() === id) { + $sheetRecordId.set(undefined) + } + queryClient.removeQueries({ queryKey: queryKeys.projects.record(id) }) + queryClient.removeQueries({ queryKey: queryKeys.projects.recordFields(id) }) + queryClient.removeQueries({ queryKey: queryKeys.projects.recordRelated(id) }) queryClient.invalidateQueries({ queryKey: queryKeys.projects.labels(params.projectId) }) queryClient.invalidateQueries({ queryKey: queryKeys.projects.fields(params.projectId, { diff --git a/platform/dashboard/src/features/relationship-patterns/hooks.ts b/platform/dashboard/src/features/relationship-patterns/hooks.ts new file mode 100644 index 00000000..6f05fe72 --- /dev/null +++ b/platform/dashboard/src/features/relationship-patterns/hooks.ts @@ -0,0 +1,85 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useStore } from '@nanostores/react' + +import { toast } from '~/elements/Toast' +import { $currentProjectId } from '~/features/projects/stores/id' +import { api } from '~/lib/api' +import { queryKeys } from '~/lib/queryKeys' + +export const useRelationshipPatternsQuery = () => { + const projectId = useStore($currentProjectId) + return useQuery({ + queryKey: queryKeys.projects.relationshipPatterns(projectId!), + queryFn: () => api.relationshipPatterns.list({ projectId: projectId! }), + enabled: !!projectId, + refetchInterval: (query) => { + const status = query.state.data?.analysis?.status + const hasPendingApply = query.state.data?.patterns.some( + (pattern) => pattern.status === 'approved' && !pattern.lastAppliedAt && !pattern.lastError + ) + return status === 'pending' || status === 'running' || hasPendingApply ? 5000 : false + } + }) +} + +export const useAnalyzeRelationshipPatternsMutation = () => { + const queryClient = useQueryClient() + const projectId = useStore($currentProjectId) + return useMutation({ + mutationFn: () => api.relationshipPatterns.analyze({ projectId: projectId! }), + onSuccess() { + if (projectId) { + queryClient.invalidateQueries({ queryKey: queryKeys.projects.relationshipPatterns(projectId) }) + } + toast({ title: 'Relationship analysis queued' }) + } + }) +} + +export const useApproveRelationshipPatternMutation = () => { + const queryClient = useQueryClient() + const projectId = useStore($currentProjectId) + return useMutation({ + mutationFn: (id: string) => api.relationshipPatterns.approve({ projectId: projectId!, id }), + onSuccess() { + if (projectId) { + queryClient.invalidateQueries({ queryKey: queryKeys.projects.relationshipPatterns(projectId) }) + queryClient.invalidateQueries({ queryKey: ['projects', projectId, 'records'] }) + queryClient.invalidateQueries({ queryKey: ['projects', projectId, 'record-relations'] }) + queryClient.invalidateQueries({ queryKey: ['records'] }) + } + toast({ title: 'Relationship pattern approved' }) + } + }) +} + +export const useIgnoreRelationshipPatternMutation = () => { + const queryClient = useQueryClient() + const projectId = useStore($currentProjectId) + return useMutation({ + mutationFn: (id: string) => api.relationshipPatterns.ignore({ projectId: projectId!, id }), + onSuccess() { + if (projectId) { + queryClient.invalidateQueries({ queryKey: queryKeys.projects.relationshipPatterns(projectId) }) + } + } + }) +} + +export const useDeleteRelationshipPatternMutation = () => { + const queryClient = useQueryClient() + const projectId = useStore($currentProjectId) + return useMutation({ + mutationFn: ({ id, deleteExisting }: { id: string; deleteExisting?: boolean }) => + api.relationshipPatterns.delete({ projectId: projectId!, id, deleteExisting }), + onSuccess() { + if (projectId) { + queryClient.invalidateQueries({ queryKey: queryKeys.projects.relationshipPatterns(projectId) }) + queryClient.invalidateQueries({ queryKey: ['projects', projectId, 'records'] }) + queryClient.invalidateQueries({ queryKey: ['projects', projectId, 'record-relations'] }) + queryClient.invalidateQueries({ queryKey: ['records'] }) + } + toast({ title: 'Relationship pattern deleted' }) + } + }) +} diff --git a/platform/dashboard/src/features/relationship-patterns/types.ts b/platform/dashboard/src/features/relationship-patterns/types.ts new file mode 100644 index 00000000..99d626e1 --- /dev/null +++ b/platform/dashboard/src/features/relationship-patterns/types.ts @@ -0,0 +1,47 @@ +import type { SearchQuery } from '@rushdb/javascript-sdk' + +export type RelationshipPatternStatus = 'suggested' | 'approved' | 'ignored' | 'error' +export type RelationshipPatternDirection = 'in' | 'out' +export type RelationshipPatternMode = 'join_pattern' | 'retype_existing_relationship' + +export type RelationshipPatternEndpoint = { + label: string + key?: string + where?: SearchQuery['where'] +} + +export type RelationshipPattern = { + id: string + status: RelationshipPatternStatus + origin: 'llm' | 'manual' + source: RelationshipPatternEndpoint + target: RelationshipPatternEndpoint + direction: RelationshipPatternDirection + type: string + mode: RelationshipPatternMode + confidence: number + rationale?: string + sampleMatchCount?: number + lastAppliedAt?: string + lastAnalyzedAt?: string + lastError?: string + createdAt: string + updatedAt: string +} + +export type ExistingRelationshipSummary = { + label: string + relationships: Array<{ label: string; type: string; direction: string }> +} + +export type RelationshipPatternsResponse = { + patterns: RelationshipPattern[] + relationships: ExistingRelationshipSummary[] + analysis?: { + status: string + requestedAt?: string + notBefore?: string + lastRunAt?: string + lastError?: string + } +} diff --git a/platform/dashboard/src/layout/ProjectLayout/index.tsx b/platform/dashboard/src/layout/ProjectLayout/index.tsx index 397ad0e2..c7a38b97 100644 --- a/platform/dashboard/src/layout/ProjectLayout/index.tsx +++ b/platform/dashboard/src/layout/ProjectLayout/index.tsx @@ -14,6 +14,7 @@ import { $router, getRoutePath, isProjectPage, redirectRoute } from '~/lib/route import { ProjectSettings } from '~/pages/project/settings' import { ProjectTokens } from '~/pages/project/tokens' import { ProjectIndexes } from '~/pages/project/indexes' +import { ProjectRelationships } from '~/pages/project/relationships' import { ProjectRecordsPage } from '~/pages/project/records' import { ProjectHelpPage } from '~/pages/project/help' import { ImportRecords } from '~/features/records/components/ImportRecords.tsx' @@ -26,6 +27,8 @@ function ProjectRoutes({ project }: { project: Project }) { return case 'projectIndexes': return + case 'projectRelationships': + return case 'projectSettings': return case 'projectImportData': diff --git a/platform/dashboard/src/lib/api.ts b/platform/dashboard/src/lib/api.ts index 3f2cb90c..5a8961ef 100644 --- a/platform/dashboard/src/lib/api.ts +++ b/platform/dashboard/src/lib/api.ts @@ -23,6 +23,7 @@ import type { CreateEmbeddingIndexParams, EmbeddingIndexStats } from '~/features/indexes/types' +import type { RelationshipPatternsResponse } from '~/features/relationship-patterns/types' import type { Project, ProjectStats, WithProjectID } from '~/features/projects/types' import type { ProjectToken } from '~/features/tokens/types' import type { @@ -217,6 +218,62 @@ export const api = { return rushDBInstance.relationships.find({ ...searchQuery, ...pagination }) } }, + relationshipPatterns: { + async list({ projectId, init }: WithProjectID & WithInit) { + return fetcher(`/api/v1/relationships/patterns`, { + ...init, + headers: { + 'x-project-id': projectId + }, + method: 'GET' + }) + }, + async analyze({ projectId, init }: WithProjectID & WithInit) { + return fetcher<{ queued: true }>(`/api/v1/relationships/patterns/analyze`, { + ...init, + body: JSON.stringify({}), + headers: { + 'x-project-id': projectId + }, + method: 'POST' + }) + }, + async approve({ projectId, id, init }: WithProjectID & WithInit & { id: string }) { + return fetcher(`/api/v1/relationships/patterns/${id}/approve`, { + ...init, + body: JSON.stringify({}), + headers: { + 'x-project-id': projectId + }, + method: 'POST' + }) + }, + async ignore({ projectId, id, init }: WithProjectID & WithInit & { id: string }) { + return fetcher(`/api/v1/relationships/patterns/${id}/ignore`, { + ...init, + body: JSON.stringify({}), + headers: { + 'x-project-id': projectId + }, + method: 'POST' + }) + }, + async delete({ + projectId, + id, + deleteExisting, + init + }: WithProjectID & WithInit & { id: string; deleteExisting?: boolean }) { + const params = deleteExisting ? '?deleteExisting=true' : '' + return fetcher<{ deleted: true }>(`/api/v1/relationships/patterns/${id}${params}`, { + ...init, + headers: { + 'x-project-id': projectId + }, + method: 'DELETE' + }) + } + }, workspaces: { workspace({ id }: Pick, init: RequestInit): Promise { return fetcher(`/api/v1/workspaces/${id}`, init) diff --git a/platform/dashboard/src/lib/formatters.ts b/platform/dashboard/src/lib/formatters.ts index 4d99ef7c..8ef188ad 100644 --- a/platform/dashboard/src/lib/formatters.ts +++ b/platform/dashboard/src/lib/formatters.ts @@ -22,3 +22,10 @@ export const collectDateToString = (collectDate: DatetimeObject) => { } export const formatIsoToLocal = (iso: ISO8601) => new Date(iso).toLocaleDateString() + +export const dateTimeFormatter = new Intl.DateTimeFormat(undefined, { + dateStyle: 'medium', + timeStyle: 'short' +}) + +export const formatIsoToLocalDateTime = (iso: ISO8601 | string) => dateTimeFormatter.format(new Date(iso)) diff --git a/platform/dashboard/src/lib/queryKeys.ts b/platform/dashboard/src/lib/queryKeys.ts index bf7852e0..55fb5715 100644 --- a/platform/dashboard/src/lib/queryKeys.ts +++ b/platform/dashboard/src/lib/queryKeys.ts @@ -30,6 +30,7 @@ export const queryKeys = { suggestedFields: (projectId: string, params: { labels: string[]; filters: Filter[] }) => ['projects', projectId, 'suggested-fields', params] as const, indexes: (projectId: string) => ['projects', projectId, 'indexes'] as const, + relationshipPatterns: (projectId: string) => ['projects', projectId, 'relationship-patterns'] as const, stats: (projectId: string) => ['projects', projectId, 'stats'] as const, records: ( projectId: string, diff --git a/platform/dashboard/src/lib/router.ts b/platform/dashboard/src/lib/router.ts index 9d3ca211..ea606384 100644 --- a/platform/dashboard/src/lib/router.ts +++ b/platform/dashboard/src/lib/router.ts @@ -25,6 +25,7 @@ export const projectRoutes = { projectImportData: '/projects/:id/import', projectTokens: '/projects/:id/tokens', projectIndexes: '/projects/:id/indexes', + projectRelationships: '/projects/:id/relationships', projectUsers: '/projects/:id/users', projectHelp: '/projects/:id/help', projectBilling: '/projects/:id/billing' diff --git a/platform/dashboard/src/pages/project/relationships.tsx b/platform/dashboard/src/pages/project/relationships.tsx new file mode 100644 index 00000000..281f764e --- /dev/null +++ b/platform/dashboard/src/pages/project/relationships.tsx @@ -0,0 +1,385 @@ +import { Ban, Check, Info, RefreshCw, Trash2 } from 'lucide-react' +import { useState } from 'react' + +import type { Project } from '~/features/projects/types' +import type { ExistingRelationshipSummary, RelationshipPattern } from '~/features/relationship-patterns/types' + +import { Badge } from '~/elements/Badge' +import { Button } from '~/elements/Button' +import { Card } from '~/elements/Card' +import { Checkbox } from '~/elements/Checkbox' +import { ConfirmDialog } from '~/elements/ConfirmDialog' +import { Close, Dialog, DialogFooter, DialogTitle } from '~/elements/Dialog' +import { IconButton } from '~/elements/IconButton' +import { Label } from '~/elements/Label' +import { NothingFound } from '~/elements/NothingFound' +import { PageContent, PageHeader, PageTitle } from '~/elements/PageHeader' +import { Skeleton } from '~/elements/Skeleton' +import { Tab, Tabs, TabsContent, TabsList } from '~/elements/Tabs' +import { Tooltip } from '~/elements/Tooltip' +import { + useAnalyzeRelationshipPatternsMutation, + useApproveRelationshipPatternMutation, + useDeleteRelationshipPatternMutation, + useIgnoreRelationshipPatternMutation, + useRelationshipPatternsQuery +} from '~/features/relationship-patterns/hooks' +import { formatIsoToLocalDateTime } from '~/lib/formatters' +import { cn } from '~/lib/utils' + +function RelationshipPath({ + sourceLabel, + sourceKey, + targetLabel, + targetKey, + type +}: { + sourceLabel: string + sourceKey?: string + targetLabel: string + targetKey?: string + type: string +}) { + return ( +
+ + + {type} + + +
+ ) +} + +function PatternPath({ pattern }: { pattern: RelationshipPattern }) { + return ( +
+ + + {pattern.mode === 'retype_existing_relationship' ? 'Rename existing' : 'Match fields'} + +
+ ) +} + +function DeletePatternDialog({ + pattern, + onDelete, + loading +}: { + pattern: RelationshipPattern + onDelete: (deleteExisting: boolean) => Promise + loading: boolean +}) { + const [open, setOpen] = useState(false) + const [deleteExisting, setDeleteExisting] = useState(true) + + return ( + { + setOpen(nextOpen) + if (nextOpen) { + setDeleteExisting(true) + } + }} + open={open} + trigger={ + + + + } + > + Delete relationship pattern +
+

Future writes will stop using this relationship pattern.

+
+ setDeleteExisting(checked === true)} + /> + +
+
+ + + + + + +
+ ) +} + +function PatternCard({ pattern }: { pattern: RelationshipPattern }) { + const approve = useApproveRelationshipPatternMutation() + const ignore = useIgnoreRelationshipPatternMutation() + const remove = useDeleteRelationshipPatternMutation() + + return ( +
  • +
    + +
    + {pattern.status === 'suggested' || pattern.status === 'ignored' || pattern.status === 'error' ? +
    + {Math.round(pattern.confidence * 100)}% + + + + } + > + Confidence Score + {pattern.rationale ? + {pattern.rationale} + : null} + {pattern.lastError ? + {pattern.lastError} + : null} + +
    + : null} + {pattern.status === 'suggested' ? + <> + + + + : null} + {pattern.status === 'approved' ? + remove.mutateAsync({ id: pattern.id, deleteExisting })} + pattern={pattern} + /> + : null} + {pattern.status === 'ignored' || pattern.status === 'error' ? + remove.mutateAsync({ id: pattern.id })} + title="Delete ignored pattern" + description="This removes the ignored state. If the same relationship is suggested again in a future analysis, it will appear as a suggestion." + trigger={ + + } + /> + : null} +
    +
    +
  • + ) +} + +function ExistingRelationships({ relationships }: { relationships: ExistingRelationshipSummary[] }) { + if (!relationships.length) { + return + } + + return ( + +
      + {relationships.map((item) => + item.relationships.map((relationship) => { + const sourceLabel = relationship.direction === 'in' ? relationship.label : item.label + const targetLabel = relationship.direction === 'in' ? item.label : relationship.label + + return ( +
    • + +
    • + ) + }) + )} +
    +
    + ) +} + +function ExistingRelationshipsSection({ + isPending, + relationships +}: { + isPending: boolean + relationships: ExistingRelationshipSummary[] +}) { + return ( +
    +
    +

    Existing relationships

    +
    + {isPending ? + + + + : } +
    + ) +} + +function PatternsSection({ patterns, loading }: { patterns: RelationshipPattern[]; loading: boolean }) { + return ( + <> + {loading ? +
    + + + + + + +
    + : patterns.length ? + +
      + {patterns.map((pattern) => ( + + ))} +
    +
    + : } + + ) +} + +function TabLabel({ count, label }: { count: number; label: string }) { + return ( + <> + {label} + + {count} + + + ) +} + +export function ProjectRelationships({ projectId }: { projectId: Project['id'] }) { + void projectId + const { data, isPending } = useRelationshipPatternsQuery() + const analyze = useAnalyzeRelationshipPatternsMutation() + const patterns = data?.patterns ?? [] + const suggested = patterns.filter((pattern) => pattern.status === 'suggested') + const approved = patterns.filter((pattern) => pattern.status === 'approved') + const ignored = patterns.filter((pattern) => pattern.status === 'ignored' || pattern.status === 'error') + const lastAnalyzedAt = data?.analysis?.lastRunAt + const isAnalyzing = + analyze.isPending || data?.analysis?.status === 'pending' || data?.analysis?.status === 'running' + + return ( + <> + +
    + Relationships +
    +
    + + + +
    +
    +

    Suggested relationships

    +

    + RushDB analyzes your ontology after writes and suggests relationship patterns between records + that look connected. Some patterns match reference fields; others rename imported default + relationships when nested data already created the structure. Approving a pattern applies it now + and keeps applying it to future writes. +

    +
    + + +
    + + + + + + + + + + + +
    + {isAnalyzing ? + Exploring graph... + : lastAnalyzedAt ? + + Last analyzed {formatIsoToLocalDateTime(lastAnalyzedAt)} + + : null} + +
    +
    + + + + + + + + + +
    +
    +
    + + ) +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 184f2a04..cc084f17 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -88,6 +88,9 @@ importers: '@docusaurus/core': specifier: ^3.8.1 version: 3.8.1(@mdx-js/react@3.1.0(@types/react@19.1.0)(react@19.1.0))(@swc/core@1.10.1(@swc/helpers@0.5.15))(acorn@8.15.0)(esbuild@0.21.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.7.2) + '@docusaurus/plugin-client-redirects': + specifier: ^3.8.1 + version: 3.8.1(@mdx-js/react@3.1.0(@types/react@19.1.0)(react@19.1.0))(@swc/core@1.10.1(@swc/helpers@0.5.15))(acorn@8.15.0)(esbuild@0.21.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.7.2) '@docusaurus/preset-classic': specifier: ^3.8.1 version: 3.8.1(@algolia/client-search@5.18.0)(@mdx-js/react@3.1.0(@types/react@19.1.0)(react@19.1.0))(@swc/core@1.10.1(@swc/helpers@0.5.15))(@types/react@19.1.0)(acorn@8.15.0)(esbuild@0.21.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(search-insights@2.17.3)(typescript@5.7.2) @@ -238,7 +241,7 @@ importers: version: 2.2.3(@nestjs/common@9.4.0(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.13)(rxjs@7.6.0))(@nestjs/core@9.4.0(@nestjs/common@9.4.0(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.13)(rxjs@7.6.0))(reflect-metadata@0.1.13)(rxjs@7.6.0))(reflect-metadata@0.1.13) '@nestjs/serve-static': specifier: ^4.0.2 - version: 4.0.2(@fastify/static@6.10.1)(@nestjs/common@9.4.0(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.13)(rxjs@7.6.0))(@nestjs/core@9.4.0(@nestjs/common@9.4.0(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.13)(rxjs@7.6.0))(reflect-metadata@0.1.13)(rxjs@7.6.0))(express@4.21.2)(fastify@4.28.1) + version: 4.0.2(@fastify/static@6.10.1)(@nestjs/common@9.4.0(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.13)(rxjs@7.6.0))(@nestjs/core@9.4.0(@nestjs/common@9.4.0(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.13)(rxjs@7.6.0))(reflect-metadata@0.1.13)(rxjs@7.6.0))(express@4.22.2)(fastify@4.28.1) '@nestjs/swagger': specifier: 6.3.0 version: 6.3.0(@fastify/static@6.10.1)(@nestjs/common@9.4.0(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.13)(rxjs@7.6.0))(@nestjs/core@9.4.0(@nestjs/common@9.4.0(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.13)(rxjs@7.6.0))(reflect-metadata@0.1.13)(rxjs@7.6.0))(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.13) @@ -2241,6 +2244,13 @@ packages: react: 19.1.0 react-dom: 19.1.0 + '@docusaurus/plugin-client-redirects@3.8.1': + resolution: {integrity: sha512-F+86R7PBn6VNgy/Ux8w3ZRypJGJEzksbejQKlbTC8u6uhBUhfdXWkDp6qdOisIoW0buY5nLqucvZt1zNJzhJhA==} + engines: {node: '>=18.0'} + peerDependencies: + react: 19.1.0 + react-dom: 19.1.0 + '@docusaurus/plugin-content-blog@3.8.1': resolution: {integrity: sha512-vNTpMmlvNP9n3hGEcgPaXyvTljanAKIUkuG9URQ1DeuDup0OR7Ltvoc8yrmH+iMZJbcQGhUJF+WjHLwuk8HSdw==} engines: {node: '>=18.0'} @@ -5639,6 +5649,10 @@ packages: resolution: {integrity: sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + body-parser@1.20.5: + resolution: {integrity: sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + body-parser@2.2.0: resolution: {integrity: sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==} engines: {node: '>=18'} @@ -7540,6 +7554,10 @@ packages: resolution: {integrity: sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==} engines: {node: '>= 0.10.0'} + express@4.22.2: + resolution: {integrity: sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==} + engines: {node: '>= 0.10.0'} + express@5.1.0: resolution: {integrity: sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==} engines: {node: '>= 18'} @@ -8258,6 +8276,10 @@ packages: resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} engines: {node: '>= 0.8'} + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + http-parser-js@0.5.8: resolution: {integrity: sha512-SGeBX54F94Wgu5RH3X5jsDtf4eHyRogWX1XGT3b4HuW3tQPM4AaBzoUji/4AAJNXCEOWZ5O0DgZmJw1947gD5Q==} @@ -11497,6 +11519,10 @@ packages: resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==} engines: {node: '>=0.6'} + qs@6.15.2: + resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==} + engines: {node: '>=0.6'} + quad-indices@2.0.1: resolution: {integrity: sha512-6jtmCsEbGAh5npThXrBaubbTjPcF0rMbn57XCJVI7LkW8PUT56V+uIrRCCWCn85PSgJC9v8Pm5tnJDwmOBewvA==} @@ -11540,6 +11566,10 @@ packages: resolution: {integrity: sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==} engines: {node: '>= 0.8'} + raw-body@2.5.3: + resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==} + engines: {node: '>= 0.8'} + raw-body@3.0.1: resolution: {integrity: sha512-9G8cA+tuMS75+6G/TzW8OtLzmBDMo8p1JRxN5AZ+LAp8uxGA8V8GZm4GQ4/N5QNQEnLmg6SS7wyuSmbKepiKqA==} engines: {node: '>= 0.10'} @@ -12460,6 +12490,10 @@ packages: resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} engines: {node: '>= 0.8'} + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + std-env@3.8.0: resolution: {integrity: sha512-Bc3YwwCB+OzldMxOXJIIvC6cPRWr/LxOp48CdQTOkPyk/t4JWWJbrilwBd7RJzKV8QW7tJkcgAmeuLLJugl5/w==} @@ -15991,6 +16025,38 @@ snapshots: - uglify-js - webpack-cli + '@docusaurus/plugin-client-redirects@3.8.1(@mdx-js/react@3.1.0(@types/react@19.1.0)(react@19.1.0))(@swc/core@1.10.1(@swc/helpers@0.5.15))(acorn@8.15.0)(esbuild@0.21.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.7.2)': + dependencies: + '@docusaurus/core': 3.8.1(@mdx-js/react@3.1.0(@types/react@19.1.0)(react@19.1.0))(@swc/core@1.10.1(@swc/helpers@0.5.15))(acorn@8.15.0)(esbuild@0.21.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.7.2) + '@docusaurus/logger': 3.8.1 + '@docusaurus/utils': 3.8.1(@swc/core@1.10.1(@swc/helpers@0.5.15))(acorn@8.15.0)(esbuild@0.21.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@docusaurus/utils-common': 3.8.1(@swc/core@1.10.1(@swc/helpers@0.5.15))(acorn@8.15.0)(esbuild@0.21.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@docusaurus/utils-validation': 3.8.1(@swc/core@1.10.1(@swc/helpers@0.5.15))(acorn@8.15.0)(esbuild@0.21.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + eta: 2.2.0 + fs-extra: 11.2.0 + lodash: 4.17.21 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + tslib: 2.8.1 + transitivePeerDependencies: + - '@docusaurus/faster' + - '@mdx-js/react' + - '@parcel/css' + - '@rspack/core' + - '@swc/core' + - '@swc/css' + - acorn + - bufferutil + - csso + - debug + - esbuild + - lightningcss + - supports-color + - typescript + - uglify-js + - utf-8-validate + - webpack-cli + '@docusaurus/plugin-content-blog@3.8.1(@docusaurus/plugin-content-docs@3.8.1(@mdx-js/react@3.1.0(@types/react@19.1.0)(react@19.1.0))(@swc/core@1.10.1(@swc/helpers@0.5.15))(acorn@8.15.0)(esbuild@0.21.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.7.2))(@mdx-js/react@3.1.0(@types/react@19.1.0)(react@19.1.0))(@swc/core@1.10.1(@swc/helpers@0.5.15))(acorn@8.15.0)(esbuild@0.21.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.7.2)': dependencies: '@docusaurus/core': 3.8.1(@mdx-js/react@3.1.0(@types/react@19.1.0)(react@19.1.0))(@swc/core@1.10.1(@swc/helpers@0.5.15))(acorn@8.15.0)(esbuild@0.21.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.7.2) @@ -17019,7 +17085,7 @@ snapshots: jest-util: 29.7.0 slash: 3.0.0 - '@jest/core@29.7.0(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.15))(@types/node@20.5.1)(typescript@5.7.2))': + '@jest/core@29.7.0(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.15))(@types/node@18.19.68)(typescript@5.7.2))': dependencies: '@jest/console': 29.7.0 '@jest/reporters': 29.7.0 @@ -17033,7 +17099,7 @@ snapshots: exit: 0.1.2 graceful-fs: 4.2.11 jest-changed-files: 29.7.0 - jest-config: 29.7.0(@types/node@20.19.2)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.15))(@types/node@18.19.68)(typescript@5.7.2)) + jest-config: 29.7.0(@types/node@20.19.2)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.15))(@types/node@20.5.1)(typescript@5.7.2)) jest-haste-map: 29.7.0 jest-message-util: 29.7.0 jest-regex-util: 29.6.3 @@ -17550,14 +17616,14 @@ snapshots: transitivePeerDependencies: - chokidar - '@nestjs/serve-static@4.0.2(@fastify/static@6.10.1)(@nestjs/common@9.4.0(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.13)(rxjs@7.6.0))(@nestjs/core@9.4.0(@nestjs/common@9.4.0(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.13)(rxjs@7.6.0))(reflect-metadata@0.1.13)(rxjs@7.6.0))(express@4.21.2)(fastify@4.28.1)': + '@nestjs/serve-static@4.0.2(@fastify/static@6.10.1)(@nestjs/common@9.4.0(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.13)(rxjs@7.6.0))(@nestjs/core@9.4.0(@nestjs/common@9.4.0(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.13)(rxjs@7.6.0))(reflect-metadata@0.1.13)(rxjs@7.6.0))(express@4.22.2)(fastify@4.28.1)': dependencies: '@nestjs/common': 9.4.0(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.13)(rxjs@7.6.0) '@nestjs/core': 9.4.0(@nestjs/common@9.4.0(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.13)(rxjs@7.6.0))(reflect-metadata@0.1.13)(rxjs@7.6.0) path-to-regexp: 0.2.5 optionalDependencies: '@fastify/static': 6.10.1 - express: 4.21.2 + express: 4.22.2 fastify: 4.28.1 '@nestjs/swagger@6.3.0(@fastify/static@6.10.1)(@nestjs/common@9.4.0(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.13)(rxjs@7.6.0))(@nestjs/core@9.4.0(@nestjs/common@9.4.0(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.13)(rxjs@7.6.0))(reflect-metadata@0.1.13)(rxjs@7.6.0))(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.13)': @@ -20202,6 +20268,24 @@ snapshots: transitivePeerDependencies: - supports-color + body-parser@1.20.5: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + http-errors: 2.0.1 + iconv-lite: 0.4.24 + on-finished: 2.4.1 + qs: 6.15.2 + raw-body: 2.5.3 + type-is: 1.6.18 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + optional: true + body-parser@2.2.0: dependencies: bytes: 3.1.2 @@ -20941,13 +21025,13 @@ snapshots: optionalDependencies: typescript: 5.7.2 - create-jest@29.7.0(@types/node@18.19.68)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.15))(@types/node@20.5.1)(typescript@5.7.2)): + create-jest@29.7.0(@types/node@18.19.68)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.15))(@types/node@18.19.68)(typescript@5.7.2)): dependencies: '@jest/types': 29.6.3 chalk: 4.1.2 exit: 0.1.2 graceful-fs: 4.2.11 - jest-config: 29.7.0(@types/node@18.19.68)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.15))(@types/node@20.5.1)(typescript@5.7.2)) + jest-config: 29.7.0(@types/node@18.19.68)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.15))(@types/node@18.19.68)(typescript@5.7.2)) jest-util: 29.7.0 prompts: 2.4.2 transitivePeerDependencies: @@ -22367,6 +22451,43 @@ snapshots: transitivePeerDependencies: - supports-color + express@4.22.2: + dependencies: + accepts: 1.3.8 + array-flatten: 1.1.1 + body-parser: 1.20.5 + content-disposition: 0.5.4 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.0.6 + debug: 2.6.9 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 1.3.1 + fresh: 0.5.2 + http-errors: 2.0.1 + merge-descriptors: 1.0.3 + methods: 1.1.2 + on-finished: 2.4.1 + parseurl: 1.3.3 + path-to-regexp: 0.1.12 + proxy-addr: 2.0.7 + qs: 6.15.2 + range-parser: 1.2.1 + safe-buffer: 5.2.1 + send: 0.19.0 + serve-static: 1.16.2 + setprototypeof: 1.2.0 + statuses: 2.0.2 + type-is: 1.6.18 + utils-merge: 1.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + optional: true + express@5.1.0: dependencies: accepts: 2.0.0 @@ -23391,6 +23512,15 @@ snapshots: statuses: 2.0.1 toidentifier: 1.0.1 + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + optional: true + http-parser-js@0.5.8: {} http-proxy-middleware@2.0.7(@types/express@4.17.21): @@ -23910,16 +24040,16 @@ snapshots: - babel-plugin-macros - supports-color - jest-cli@29.7.0(@types/node@18.19.68)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.15))(@types/node@20.5.1)(typescript@5.7.2)): + jest-cli@29.7.0(@types/node@18.19.68)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.15))(@types/node@18.19.68)(typescript@5.7.2)): dependencies: - '@jest/core': 29.7.0(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.15))(@types/node@20.5.1)(typescript@5.7.2)) + '@jest/core': 29.7.0(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.15))(@types/node@18.19.68)(typescript@5.7.2)) '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 chalk: 4.1.2 - create-jest: 29.7.0(@types/node@18.19.68)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.15))(@types/node@20.5.1)(typescript@5.7.2)) + create-jest: 29.7.0(@types/node@18.19.68)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.15))(@types/node@18.19.68)(typescript@5.7.2)) exit: 0.1.2 import-local: 3.2.0 - jest-config: 29.7.0(@types/node@18.19.68)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.15))(@types/node@20.5.1)(typescript@5.7.2)) + jest-config: 29.7.0(@types/node@18.19.68)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.15))(@types/node@18.19.68)(typescript@5.7.2)) jest-util: 29.7.0 jest-validate: 29.7.0 yargs: 17.7.2 @@ -23929,7 +24059,7 @@ snapshots: - supports-color - ts-node - jest-config@29.7.0(@types/node@18.19.68)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.15))(@types/node@20.5.1)(typescript@5.7.2)): + jest-config@29.7.0(@types/node@18.19.68)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.15))(@types/node@18.19.68)(typescript@5.7.2)): dependencies: '@babel/core': 7.26.0 '@jest/test-sequencer': 29.7.0 @@ -23960,7 +24090,7 @@ snapshots: - babel-plugin-macros - supports-color - jest-config@29.7.0(@types/node@20.19.2)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.15))(@types/node@18.19.68)(typescript@5.7.2)): + jest-config@29.7.0(@types/node@20.19.2)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.15))(@types/node@20.5.1)(typescript@5.7.2)): dependencies: '@babel/core': 7.26.0 '@jest/test-sequencer': 29.7.0 @@ -24218,10 +24348,10 @@ snapshots: jest@29.5.0(@types/node@18.19.68)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.15))(@types/node@20.5.1)(typescript@5.7.2)): dependencies: - '@jest/core': 29.7.0(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.15))(@types/node@20.5.1)(typescript@5.7.2)) + '@jest/core': 29.7.0(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.15))(@types/node@18.19.68)(typescript@5.7.2)) '@jest/types': 29.6.3 import-local: 3.2.0 - jest-cli: 29.7.0(@types/node@18.19.68)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.15))(@types/node@20.5.1)(typescript@5.7.2)) + jest-cli: 29.7.0(@types/node@18.19.68)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.15))(@types/node@18.19.68)(typescript@5.7.2)) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -24230,10 +24360,10 @@ snapshots: jest@29.7.0(@types/node@18.19.68)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.15))(@types/node@18.19.68)(typescript@5.7.2)): dependencies: - '@jest/core': 29.7.0(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.15))(@types/node@20.5.1)(typescript@5.7.2)) + '@jest/core': 29.7.0(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.15))(@types/node@18.19.68)(typescript@5.7.2)) '@jest/types': 29.6.3 import-local: 3.2.0 - jest-cli: 29.7.0(@types/node@18.19.68)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.15))(@types/node@20.5.1)(typescript@5.7.2)) + jest-cli: 29.7.0(@types/node@18.19.68)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.15))(@types/node@18.19.68)(typescript@5.7.2)) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -27456,6 +27586,11 @@ snapshots: dependencies: side-channel: 1.1.0 + qs@6.15.2: + dependencies: + side-channel: 1.1.0 + optional: true + quad-indices@2.0.1: dependencies: an-array: 1.0.0 @@ -27499,6 +27634,14 @@ snapshots: iconv-lite: 0.4.24 unpipe: 1.0.0 + raw-body@2.5.3: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.4.24 + unpipe: 1.0.0 + optional: true + raw-body@3.0.1: dependencies: bytes: 3.1.2 @@ -28628,6 +28771,9 @@ snapshots: statuses@2.0.1: {} + statuses@2.0.2: + optional: true + std-env@3.8.0: {} stream-chain@2.2.5: {}