diff --git a/README.md b/README.md index dd0364eb..92808aea 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,6 @@ [![build status](https://github.com/ravendb/ravendb-nodejs-client/actions/workflows/RavenClient.yml/badge.svg?branch=v7.0)](https://github.com/ravendb/ravendb-nodejs-client/actions) [![Known Vulnerabilities](https://snyk.io/test/github/ravendb/ravendb-nodejs-client/badge.svg)](https://snyk.io/test/github/ravendb/ravendb-nodejs-client) - ## Installation ```bash @@ -13,11 +12,11 @@ npm install --save ravendb ## Releases -* [Click here](https://github.com/ravendb/ravendb-nodejs-client/releases) to view all Releases and Changelog. +- [Click here](https://github.com/ravendb/ravendb-nodejs-client/releases) to view all Releases and Changelog. ## Documentation -* This readme provides short examples for the following: +- This readme provides short examples for the following: [Getting started](#getting-started), [Asynchronous call types](#supported-asynchronous-call-types), [Crud example](#crud-example), @@ -31,52 +30,60 @@ npm install --save ravendb [Suggestions](#suggestions), [Patching](#advanced-patching), [Subscriptions](#subscriptions), + [AI Agents](#ai-agents), [Using object literals](#using-object-literals-for-entities), [Using classes](#using-classes-for-entities), [Typescript usage](#usage-with-typescript), [Working with secure server](#working-with-a-secure-server), - [Building & running tests](#building) + [Building & running tests](#building) -* For more information go to the online [RavenDB Documentation](https://ravendb.net/docs/article-page/latest/nodejs/client-api/what-is-a-document-store). +- For more information go to the online [RavenDB Documentation](https://ravendb.net/docs/article-page/latest/nodejs/client-api/what-is-a-document-store). ## Getting started 1. Require the `DocumentStore` class from the ravendb package + ```javascript -const { DocumentStore } = require('ravendb'); +const { DocumentStore } = require("ravendb"); ``` + or (using ES6 / Typescript imports) + ```javascript -import { DocumentStore } from 'ravendb'; +import { DocumentStore } from "ravendb"; ``` 2. Initialize the document store (you should have a single DocumentStore instance per application) + ```javascript -const store = new DocumentStore('http://live-test.ravendb.net', 'databaseName'); +const store = new DocumentStore("http://live-test.ravendb.net", "databaseName"); store.initialize(); ``` 3. Open a session + ```javascript const session = store.openSession(); ``` 4. Call `saveChanges()` when you're done + ```javascript session - .load('users/1-A') // Load document - .then((user) => { - user.password = PBKDF2('new password'); // Update data - }) - .then(() => session.saveChanges()) // Save changes - .then(() => { - // Data is now persisted - // You can proceed e.g. finish web request + .load("users/1-A") // Load document + .then((user) => { + user.password = PBKDF2("new password"); // Update data + }) + .then(() => session.saveChanges()) // Save changes + .then(() => { + // Data is now persisted + // You can proceed e.g. finish web request }); ``` 5. When you have finished using the session and the document store objects, -make sure to dispose of them properly to free up resources: + make sure to dispose of them properly to free up resources: + ```javascript session.dispose(); store.dispose(); @@ -88,26 +95,30 @@ Most methods on the session object are asynchronous and return a Promise. Either use `async & await` or `.then()` with callback functions. 1. async / await + ```javascript const session = store.openSession(); -let user = await session.load('users/1-A'); -user.password = PBKDF2('new password'); +let user = await session.load("users/1-A"); +user.password = PBKDF2("new password"); await session.saveChanges(); ``` 2. .then & callback functions + ```javascript -session.load('Users/1-A') - .then((user) => { - user.password = PBKDF2('new password'); - }) - .then(() => session.saveChanges()) - .then(() => { - // here session is complete - }); +session + .load("Users/1-A") + .then((user) => { + user.password = PBKDF2("new password"); + }) + .then(() => session.saveChanges()) + .then(() => { + // here session is complete + }); ``` ->##### Related tests: +> ##### Related tests: +> > [async and await](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Documents/ReadmeSamples.ts#L55) > [then and callbacks](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Documents/ReadmeSamples.ts#L72) @@ -117,22 +128,23 @@ session.load('Users/1-A') ```javascript let product = { - id: null, - title: 'iPhone X', - price: 999.99, - currency: 'USD', - storage: 64, - manufacturer: 'Apple', - in_stock: true, - last_update: new Date('2017-10-01T00:00:00') + id: null, + title: "iPhone X", + price: 999.99, + currency: "USD", + storage: 64, + manufacturer: "Apple", + in_stock: true, + last_update: new Date("2017-10-01T00:00:00"), }; -await session.store(product, 'products/1-A'); +await session.store(product, "products/1-A"); console.log(product.id); // products/1-A await session.saveChanges(); ``` ->##### Related tests: +> ##### Related tests: +> > [store()](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Documents/SessionApiTests.ts#L21) > [ID generation - session.store()](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Documents/IdGeneration.ts#L9) > [store document with @metadata](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Issues/RDBC_213.ts#L16) @@ -141,12 +153,13 @@ await session.saveChanges(); ### Load documents ```javascript -const product = await session.load('products/1-A'); +const product = await session.load("products/1-A"); console.log(product.title); // iPhone X -console.log(product.id); // products/1-A +console.log(product.id); // products/1-A ``` > ##### Related tests: +> > [load()](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Documents/SessionApiTests.ts#L64) ### Load documents with include @@ -159,11 +172,9 @@ console.log(product.id); // products/1-A // } const session = store.openSession(); -const user1 = await session - .include("kids") - .load("users/1"); - // Document users/1 and all docs referenced in "kids" - // will be fetched from the server in a single request. +const user1 = await session.include("kids").load("users/1"); +// Document users/1 and all docs referenced in "kids" +// will be fetched from the server in a single request. const user2 = await session.load("users/2"); // this won't call server again @@ -172,7 +183,8 @@ assert.ok(user2); assert.equal(session.advanced.numberOfRequests, 1); ``` ->##### Related tests: +> ##### Related tests: +> > [can load with includes](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Ported/Documents/LoadTest.ts#L29) > [loading data with include](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Documents/ReadmeSamples.ts#L128) > [loading data with passing includes](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Documents/ReadmeSamples.ts#L148) @@ -180,38 +192,42 @@ assert.equal(session.advanced.numberOfRequests, 1); ### Update documents ```javascript -let product = await session.load('products/1-A'); +let product = await session.load("products/1-A"); product.in_stock = false; product.last_update = new Date(); await session.saveChanges(); // ... -product = await session.load('products/1-A'); -console.log(product.in_stock); // false +product = await session.load("products/1-A"); +console.log(product.in_stock); // false console.log(product.last_update); // the current date ``` ->##### Related tests: +> ##### Related tests: +> > [update document](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Documents/ReadmeSamples.ts#L170) > [update document metadata](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Issues/RDBC_213.ts#L35) ### Delete documents 1. Using entity + ```javascript -let product = await session.load('products/1-A'); +let product = await session.load("products/1-A"); await session.delete(product); await session.saveChanges(); -product = await session.load('products/1-A'); +product = await session.load("products/1-A"); console.log(product); // null ``` 2. Using document ID + ```javascript -await session.delete('products/1-A'); +await session.delete("products/1-A"); ``` ->##### Related tests: +> ##### Related tests: +> > [delete doc by entity](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Ported/DeleteTest.ts#L20) > [delete doc by ID](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Ported/DeleteTest.ts#L38) > [onBeforeDelete is called before delete by ID](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Ported/Issues/RavenDB_15492.ts#L16) @@ -220,21 +236,28 @@ await session.delete('products/1-A'); ## Query documents -1. Use `query()` session method: +1. Use `query()` session method: + +Query by collection: -Query by collection: ```javascript -const query = session.query({ collection: 'products' }); +const query = session.query({ collection: "products" }); ``` -Query by index name: + +Query by index name: + ```javascript -const query = session.query({ indexName: 'productsByCategory' }); +const query = session.query({ indexName: "productsByCategory" }); ``` -Query by index: + +Query by index: + ```javascript const query = session.query(Product, Product_ByName); ``` -Query by entity type: + +Query by entity type: + ```javascript import { User } from "./models"; const query = session.query(User); @@ -242,56 +265,63 @@ const query = session.query(User); 2. Build up the query - apply search conditions, set ordering, etc. Query supports chaining calls: + ```javascript query - .waitForNonStaleResults() - .usingDefaultOperator('AND') - .whereEquals('manufacturer', 'Apple') - .whereEquals('in_stock', true) - .whereBetween('last_update', new Date('2022-11-01T00:00:00'), new Date()) - .orderBy('price'); + .waitForNonStaleResults() + .usingDefaultOperator("AND") + .whereEquals("manufacturer", "Apple") + .whereEquals("in_stock", true) + .whereBetween("last_update", new Date("2022-11-01T00:00:00"), new Date()) + .orderBy("price"); ``` 3. Execute the query to get results: + ```javascript const results = await query.all(); // get all results // ... const firstResult = await query.first(); // gets first result // ... -const single = await query.single(); // gets single result +const single = await query.single(); // gets single result ``` ### Query methods overview #### selectFields() - projections using a single field + ```javascript // RQL // from users select name // Query -const userNames = await session.query({ collection: "users" }) - .selectFields("name") - .all(); +const userNames = await session + .query({ collection: "users" }) + .selectFields("name") + .all(); // Sample results // John, Stefanie, Thomas ``` ->##### Related tests: +> ##### Related tests: +> > [projections single field](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Documents/ReadmeSamples.ts#L341) > [query single property](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Ported/QueryTest.ts#L231) > [retrieve camel case with projection](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Documents/CustomKeyCaseConventionsTests.ts#L288) -> [can_project_id_field](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Ported/Issues/RavenDB_14811.ts#L58) +> [can_project_id_field](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Ported/Issues/RavenDB_14811.ts#L58) #### selectFields() - projections using multiple fields + ```javascript // RQL // from users select name, age // Query -await session.query({ collection: "users" }) - .selectFields([ "name", "age" ]) - .all(); +await session + .query({ collection: "users" }) + .selectFields(["name", "age"]) + .all(); // Sample results // [ { name: 'John', age: 30 }, @@ -299,40 +329,43 @@ await session.query({ collection: "users" }) // { name: 'Thomas', age: 25 } ] ``` ->##### Related tests: +> ##### Related tests: +> > [projections multiple fields](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Documents/ReadmeSamples.ts#L349) > [query with projection](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Ported/QueryTest.ts#L318) > [retrieve camel case with projection](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Documents/CustomKeyCaseConventionsTests.ts#L288) -> [can_project_id_field](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Ported/Issues/RavenDB_14811.ts#L58) +> [can_project_id_field](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Ported/Issues/RavenDB_14811.ts#L58) #### distinct() + ```javascript // RQL // from users select distinct age // Query -await session.query({ collection: "users" }) - .selectFields("age") - .distinct() - .all(); +await session + .query({ collection: "users" }) + .selectFields("age") + .distinct() + .all(); // Sample results // [ 30, 25 ] ``` ->##### Related tests: +> ##### Related tests: +> > [distinct](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Documents/ReadmeSamples.ts#L360) > [query distinct](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Ported/QueryTest.ts#L350) #### whereEquals() / whereNotEquals() + ```javascript // RQL -// from users where age = 30 +// from users where age = 30 // Query -await session.query({ collection: "users" }) - .whereEquals("age", 30) - .all(); +await session.query({ collection: "users" }).whereEquals("age", 30).all(); // Saple results // [ User { @@ -342,19 +375,22 @@ await session.query({ collection: "users" }) // registeredAt: 2017-11-10T23:00:00.000Z } ] ``` ->##### Related tests: +> ##### Related tests: +> > [where equals](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Documents/ReadmeSamples.ts#L369) > [where not equals](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Ported/QueryTest.ts#L451) #### whereIn() + ```javascript // RQL // from users where name in ("John", "Thomas") // Query -await session.query({ collection: "users" }) - .whereIn("name", ["John", "Thomas"]) - .all(); +await session + .query({ collection: "users" }) + .whereIn("name", ["John", "Thomas"]) + .all(); // Sample results // [ User { @@ -370,20 +406,19 @@ await session.query({ collection: "users" }) // id: 'users/3-A' } ] ``` ->##### Related tests: +> ##### Related tests: +> > [where in](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Documents/ReadmeSamples.ts#L377) > [query with where in](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Ported/QueryTest.ts#L256) - #### whereStartsWith() / whereEndsWith() + ```javascript // RQL // from users where startsWith(name, 'J') // Query -await session.query({ collection: "users" }) - .whereStartsWith("name", "J") - .all(); +await session.query({ collection: "users" }).whereStartsWith("name", "J").all(); // Sample results // [ User { @@ -393,18 +428,21 @@ await session.query({ collection: "users" }) // registeredAt: 2017-11-10T23:00:00.000Z } ] ``` ->##### Related tests: +> ##### Related tests: +> > [query with where clause](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Ported/QueryTest.ts#L148) #### whereBetween() + ```javascript // RQL // from users where registeredAt between '2016-01-01' and '2017-01-01' // Query -await session.query({ collection: "users" }) - .whereBetween("registeredAt", new Date(2016, 0, 1), new Date(2017, 0, 1)) - .all(); +await session + .query({ collection: "users" }) + .whereBetween("registeredAt", new Date(2016, 0, 1), new Date(2017, 0, 1)) + .all(); // Sample results // [ User { @@ -414,19 +452,19 @@ await session.query({ collection: "users" }) // id: 'users/3-A' } ] ``` ->##### Related tests: +> ##### Related tests: +> > [where between](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Documents/ReadmeSamples.ts#L385) > [query with where between](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Ported/QueryTest.ts#L265) #### whereGreaterThan() / whereGreaterThanOrEqual() / whereLessThan() / whereLessThanOrEqual() + ```javascript // RQL // from users where age > 29 // Query -await session.query({ collection: "users" }) - .whereGreaterThan("age", 29) - .all(); +await session.query({ collection: "users" }).whereGreaterThan("age", 29).all(); // Sample results // [ User { @@ -437,23 +475,24 @@ await session.query({ collection: "users" }) // id: 'users/1-A' } ] ``` ->##### Related tests: +> ##### Related tests: +> > [where greater than](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Documents/ReadmeSamples.ts#L393) > [query with where less than](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Ported/QueryTest.ts#L275) > [query with where less than or equal](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Ported/QueryTest.ts#L285) > [query with where greater than](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Ported/QueryTest.ts#L294) -> [query with where greater than or equal](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Ported/QueryTest.ts#L304) +> [query with where greater than or equal](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Ported/QueryTest.ts#L304) #### whereExists() + Checks if the field exists. + ```javascript // RQL // from users where exists("age") // Query -await session.query({ collection: "users" }) - .whereExists("kids") - .all(); +await session.query({ collection: "users" }).whereExists("kids").all(); // Sample results // [ User { @@ -464,19 +503,22 @@ await session.query({ collection: "users" }) // id: 'users/1-A' } ] ``` ->##### Related tests: +> ##### Related tests: +> > [where exists](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Documents/ReadmeSamples.ts#L401) > [query where exists](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Ported/QueryTest.ts#L503) #### containsAny() / containsAll() + ```javascript // RQL // from users where kids in ('Mara') // Query -await session.query({ collection: "users" }) - .containsAll("kids", ["Mara", "Dmitri"]) - .all(); +await session + .query({ collection: "users" }) + .containsAll("kids", ["Mara", "Dmitri"]) + .all(); // Sample results // [ User { @@ -487,20 +529,24 @@ await session.query({ collection: "users" }) // id: 'users/1-A' } ] ``` ->##### Related tests: +> ##### Related tests: +> > [where contains any](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Documents/ReadmeSamples.ts#L409) > [queries with contains](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Ported/ContainsTest.ts#L19) #### search() + Perform full-text search. + ```javascript // RQL // from users where search(kids, 'Mara') // Query -await session.query({ collection: "users" }) - .search("kids", "Mara Dmitri") - .all(); +await session + .query({ collection: "users" }) + .search("kids", "Mara Dmitri") + .all(); // Sample results // [ User { @@ -511,25 +557,28 @@ await session.query({ collection: "users" }) // id: 'users/1-A' } ] ``` ->##### Related tests: +> ##### Related tests: +> > [search()](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Documents/ReadmeSamples.ts#L417) > [query search with or](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Ported/QueryTest.ts#L362) -> [query_CreateClausesForQueryDynamicallyWithOnBeforeQueryEvent](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Ported/QueryTest.ts#L30) +> [query_CreateClausesForQueryDynamicallyWithOnBeforeQueryEvent](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Ported/QueryTest.ts#L30) #### openSubclause() / closeSubclause() + ```javascript // RQL // from users where exists(kids) or (age = 25 and name != Thomas) // Query -await session.query({ collection: "users" }) - .whereExists("kids") - .orElse() - .openSubclause() - .whereEquals("age", 25) - .whereNotEquals("name", "Thomas") - .closeSubclause() - .all(); +await session + .query({ collection: "users" }) + .whereExists("kids") + .orElse() + .openSubclause() + .whereEquals("age", 25) + .whereNotEquals("name", "Thomas") + .closeSubclause() + .all(); // Sample results // [ User { @@ -545,20 +594,19 @@ await session.query({ collection: "users" }) // id: 'users/2-A' } ] ``` ->##### Related tests: +> ##### Related tests: +> > [subclause](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Documents/ReadmeSamples.ts#L425) > [working with subclause](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Ported/Issues/RavenDB_5669.ts#L40) #### not() + ```javascript // RQL // from users where age != 25 // Query -await session.query({ collection: "users" }) - .not() - .whereEquals("age", 25) - .all(); +await session.query({ collection: "users" }).not().whereEquals("age", 25).all(); // Sample results // [ User { @@ -569,21 +617,24 @@ await session.query({ collection: "users" }) // id: 'users/1-A' } ] ``` ->##### Related tests: +> ##### Related tests: +> > [not()](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Documents/ReadmeSamples.ts#L438) > [query where not](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Ported/QueryTest.ts#L451) #### orElse() / andAlso() + ```javascript // RQL // from users where exists(kids) or age < 30 // Query -await session.query({ collection: "users" }) - .whereExists("kids") - .orElse() - .whereLessThan("age", 30) - .all(); +await session + .query({ collection: "users" }) + .whereExists("kids") + .orElse() + .whereLessThan("age", 30) + .all(); // Sample results // [ User { @@ -604,23 +655,27 @@ await session.query({ collection: "users" }) // id: 'users/2-A' } ] ``` ->##### Related tests: +> ##### Related tests: +> > [orElse](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Documents/ReadmeSamples.ts#L447) > [working with subclause](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Ported/Issues/RavenDB_5669.ts#L40) #### usingDefaultOperator() + If neither `andAlso()` nor `orElse()` is called then the default operator between the query filtering conditions will be `AND` . You can override that with `usingDefaultOperator` which must be called before any other where conditions. + ```javascript // RQL // from users where exists(kids) or age < 29 // Query -await session.query({ collection: "users" }) - .usingDefaultOperator("OR") // override the default 'AND' operator - .whereExists("kids") - .whereLessThan("age", 29) - .all(); +await session + .query({ collection: "users" }) + .usingDefaultOperator("OR") // override the default 'AND' operator + .whereExists("kids") + .whereLessThan("age", 29) + .all(); // Sample results // [ User { @@ -641,20 +696,20 @@ await session.query({ collection: "users" }) // id: 'users/2-A' } ] ``` ->##### Related tests: +> ##### Related tests: +> > [set default operator](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Documents/ReadmeSamples.ts#L457) > [AND is used when default operator is not set](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Issues/RDBC_649.ts#L36) -> [set default operator to OR](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Issues/RDBC_649.ts#L45) +> [set default operator to OR](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Issues/RDBC_649.ts#L45) #### orderBy() / orderByDesc() / orderByScore() / randomOrdering() + ```javascript // RQL // from users order by age // Query -await session.query({ collection: "users" }) - .orderBy("age") - .all(); +await session.query({ collection: "users" }).orderBy("age").all(); // Sample results // [ User { @@ -675,7 +730,8 @@ await session.query({ collection: "users" }) // id: 'users/1-A' } ] ``` ->##### Related tests: +> ##### Related tests: +> > [orderBy()](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Documents/ReadmeSamples.ts#L467) > [orderByDesc()](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Documents/ReadmeSamples.ts#L477) > [query random order](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Ported/QueryTest.ts#L451) @@ -683,16 +739,19 @@ await session.query({ collection: "users" }) > [query with boost - order by score](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Ported/QueryTest.ts#L517) #### take() + Limit the number of query results. + ```javascript // RQL // from users order by age // Query -await session.query({ collection: "users" }) - .orderBy("age") - .take(2) // only the first 2 entries will be returned - .all(); +await session + .query({ collection: "users" }) + .orderBy("age") + .take(2) // only the first 2 entries will be returned + .all(); // Sample results // [ User { @@ -707,23 +766,27 @@ await session.query({ collection: "users" }) // id: 'users/3-A' } ] ``` ->##### Related tests: +> ##### Related tests: +> > [take()](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Documents/ReadmeSamples.ts#L487) > [query skip take](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Ported/QueryTest.ts#L385) > [canUseOffsetWithCollectionQuery](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Ported/Issues/RavenDB_17551.ts#L17) #### skip() + Skip a specified number of results from the start. + ```javascript // RQL // from users order by age // Query -await session.query({ collection: "users" }) - .orderBy("age") - .take(1) // return only 1 result - .skip(1) // skip the first result, return the second result - .all(); +await session + .query({ collection: "users" }) + .orderBy("age") + .take(1) // return only 1 result + .skip(1) // skip the first result, return the second result + .all(); // Sample results // [ User { @@ -733,20 +796,24 @@ await session.query({ collection: "users" }) // id: 'users/3-A' } ] ``` ->##### Related tests: +> ##### Related tests: +> > [skip()](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Documents/ReadmeSamples.ts#L496) > [query skip take](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Ported/QueryTest.ts#L385) -> [canUseOffsetWithCollectionQuery](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Ported/Issues/RavenDB_17551.ts#L17) +> [canUseOffsetWithCollectionQuery](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Ported/Issues/RavenDB_17551.ts#L17) #### Getting query statistics -Use the `statistics()` method to obtain query statistics. + +Use the `statistics()` method to obtain query statistics. + ```javascript // Query let stats: QueryStatistics; -const results = await session.query({ collection: "users" }) - .whereGreaterThan("age", 29) - .statistics(s => stats = s) - .all(); +const results = await session + .query({ collection: "users" }) + .whereGreaterThan("age", 29) + .statistics((s) => (stats = s)) + .all(); // Sample results // QueryStatistics { @@ -761,10 +828,12 @@ const results = await session.query({ collection: "users" }) // resultEtag: 8426908718162809000 } ``` ->##### Related tests: -> [can get stats](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Documents/ReadmeSamples.ts#L506) +> ##### Related tests: +> +> [can get stats](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Documents/ReadmeSamples.ts#L506) #### all() / first() / single() / count() + `all()` - returns all results `first()` - first result only @@ -773,13 +842,15 @@ const results = await session.query({ collection: "users" }) `count()` - returns the number of entries in the results (not affected by `take()`) ->##### Related tests: +> ##### Related tests: +> > [query first and single](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Ported/QueryTest.ts#L467) > [query count](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Ported/QueryTest.ts#L834) ## Attachments #### Store attachments + ```javascript const doc = new User({ name: "John" }); @@ -793,44 +864,56 @@ const fileStream = fs.createReadStream("../photo.png"); session.advanced.attachments.store(doc, "photo.png", fileStream, "image/png"); // OR store attachment using document ID -session.advanced.attachments.store(doc.id, "photo.png", fileStream, "image/png"); +session.advanced.attachments.store( + doc.id, + "photo.png", + fileStream, + "image/png" +); // Persist all changes await session.saveChanges(); ``` ->##### Related tests: +> ##### Related tests: +> > [store attachment](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Documents/ReadmeSamples.ts#L203) > [can put attachments](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Ported/Attachments/AttachmentsSessionTest.ts#L26) > [checkIfHasChangesIsTrueAfterAddingAttachment](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Ported/Issues/RavenDB_16985.ts#L17) > [store many attachments and docs with bulk insert](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Ported/Attachments/BulkInsertAttachmentsTest.ts#L105) #### Get attachments + ```javascript // Get an attachment -const attachment = await session.advanced.attachments.get(documentId, "photo.png") +const attachment = await session.advanced.attachments.get( + documentId, + "photo.png" +); // Attachment.details contains information about the attachment: -// { +// { // name: 'photo.png', // documentId: 'users/1-A', // contentType: 'image/png', // hash: 'MvUEcrFHSVDts5ZQv2bQ3r9RwtynqnyJzIbNYzu1ZXk=', // changeVector: '"A:3-K5TR36dafUC98AItzIa6ow"', -// size: 4579 +// size: 4579 // } // Attachment.data is a Readable. See https://nodejs.org/api/stream.html#class-streamreadable attachment.data - .pipe(fs.createWriteStream("photo.png")) - .on("finish", () => next()); + .pipe(fs.createWriteStream("photo.png")) + .on("finish", () => next()); ``` ->##### Related tests: +> ##### Related tests: +> > [get attachment](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Documents/ReadmeSamples.ts#L241) > [can get & delete attachments](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Ported/Attachments/AttachmentsSessionTest.ts#L133) #### Check if attachment exists + ```javascript await session.advanced.attachments.exists(doc.id, "photo.png"); // true @@ -839,11 +922,13 @@ await session.advanced.attachments.exists(doc.id, "not_there.avi"); // false ``` ->##### Related tests: +> ##### Related tests: +> > [attachment exists](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Documents/ReadmeSamples.ts#L258) > [attachment exists 2](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Ported/Attachments/AttachmentsSessionTest.ts#L316) #### Get attachment names + ```javascript // Use a loaded entity to determine attachments' names await session.advanced.attachments.getNames(doc); @@ -854,13 +939,16 @@ await session.advanced.attachments.getNames(doc); // contentType: 'image/png', // size: 4579 } ] ``` ->##### Related tests: + +> ##### Related tests: +> > [get attachment names](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Documents/ReadmeSamples.ts#L266) > [get attachment names 2](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Ported/Attachments/AttachmentsSessionTest.ts#L288) -> + ## TimeSeries -#### Store time series +#### Store time series + ```javascript const session = store.openSession(); @@ -874,7 +962,8 @@ tsf.append(new Date(), 120); await session.saveChanges(); ``` ->##### Related tests: +> ##### Related tests: +> > [can use time series](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Documents/ReadmeSamples.ts#L759) > [canCreateSimpleTimeSeries](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Ported/TimeSeries/TimeSeriesSessionTest.ts#L18) > [usingDifferentTags](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Ported/TimeSeries/TimeSeriesSessionTest.ts#L217) @@ -883,6 +972,7 @@ await session.saveChanges(); > [shouldDeleteTimeSeriesUponDocumentDeletion](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Ported/TimeSeries/TimeSeriesSessionTest.ts#L729) #### Get time series for document + ```javascript const session = store.openSession(); @@ -893,7 +983,8 @@ const tsf = session.timeSeriesFor("users/1", "heartbeat"); const heartbeats = await tsf.get(); ``` ->##### Related tests: +> ##### Related tests: +> > [canCreateSimpleTimeSeries](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Ported/TimeSeries/TimeSeriesSessionTest.ts#L18) > [canStoreLargeNumberOfValues](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Ported/TimeSeries/TimeSeriesSessionTest.ts#L430) > [canRequestNonExistingTimeSeriesRange](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Ported/TimeSeries/TimeSeriesSessionTest.ts#L544) @@ -907,10 +998,17 @@ const heartbeats = await tsf.get(); const bulkInsert = store.bulkInsert(); // Store multiple documents -for (const name of ["Anna", "Maria", "Miguel", "Emanuel", "Dayanara", "Aleida"]) { - const user = new User({ name }); - await bulkInsert.store(user); - // The data stored in bulkInsert will be streamed to the server in batches +for (const name of [ + "Anna", + "Maria", + "Miguel", + "Emanuel", + "Dayanara", + "Aleida", +]) { + const user = new User({ name }); + await bulkInsert.store(user); + // The data stored in bulkInsert will be streamed to the server in batches } // Sample documents stored: @@ -925,11 +1023,12 @@ for (const name of ["Anna", "Maria", "Miguel", "Emanuel", "Dayanara", "Aleida"]) await bulkInsert.finish(); ``` ->##### Related tests: +> ##### Related tests: +> > [bulk insert example](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Documents/ReadmeSamples.ts#L279) > [simple bulk insert should work](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Ported/BulkInsert/BulkInsertsTest.ts#L23) > [bulk insert can be aborted](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Ported/BulkInsert/BulkInsertsTest.ts#L95) -> [can modify metadata with bulk insert](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Ported/BulkInsert/BulkInsertsTest.ts#L136) +> [can modify metadata with bulk insert](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Ported/BulkInsert/BulkInsertsTest.ts#L136) ## Changes API @@ -942,23 +1041,23 @@ const changes = store.changes(); // Subscribe for all documents, or for specific collection (or other database items) const docsChanges = changes.forAllDocuments(); -// Handle changes events -docsChanges.on("data", change => { - // A sample change data recieved: - // { type: 'Put', - // id: 'users/1-A', - // collectionName: 'Users', - // changeVector: 'A:2-QCawZTDbuEa4HUBORhsWYA' } +// Handle changes events +docsChanges.on("data", (change) => { + // A sample change data recieved: + // { type: 'Put', + // id: 'users/1-A', + // collectionName: 'Users', + // changeVector: 'A:2-QCawZTDbuEa4HUBORhsWYA' } }); -docsChanges.on("error", err => { - // handle errors -}) +docsChanges.on("error", (err) => { + // handle errors +}); { - const session = store.openSession(); - await session.store(new User({ name: "Starlord" })); - await session.saveChanges(); + const session = store.openSession(); + await session.store(new User({ name: "Starlord" })); + await session.saveChanges(); } // ... @@ -966,79 +1065,89 @@ docsChanges.on("error", err => { changes.dispose(); ``` ->##### Related tests: +> ##### Related tests: +> > [listen to changes](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Documents/ReadmeSamples.ts#L306) > [can obtain single document changes](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Ported/Server/Documents/Notifications/ChangesTest.ts#L25) > [can obtain all documents changes](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Ported/Server/Documents/Notifications/ChangesTest.ts#L93) > [can obtain notification about documents starting with](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Ported/Server/Documents/Notifications/ChangesTest.ts#L255) -> [can obtain notification about documents in collection](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Ported/Server/Documents/Notifications/ChangesTest.ts#L312) +> [can obtain notification about documents in collection](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Ported/Server/Documents/Notifications/ChangesTest.ts#L312) ## Streaming #### Stream documents by ID prefix + ```javascript // Filter streamed results by passing an ID prefix // The stream() method returns a Node.js ReadableStream const userStream = await session.advanced.stream("users/"); // Handle stream events with callback functions -userStream.on("data", user => { - // Get only documents with ID that starts with 'users/' - // i.e.: User { name: 'John', id: 'users/1-A' } +userStream.on("data", (user) => { + // Get only documents with ID that starts with 'users/' + // i.e.: User { name: 'John', id: 'users/1-A' } }); -userStream.on("error", err => { - // handle errors -}) +userStream.on("error", (err) => { + // handle errors +}); ``` ->##### Related tests: +> ##### Related tests: +> > [can stream users by prefix](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Documents/ReadmeSamples.ts#L525) -> [can stream documents starting with](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Ported/Core/Streaming/DocumentStreaming.ts#L39) +> [can stream documents starting with](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Ported/Core/Streaming/DocumentStreaming.ts#L39) + +#### Stream documents by query -#### Stream documents by query ```javascript // Define a query -const query = session.query({ collection: "users" }).whereGreaterThan("age", 29); +const query = session + .query({ collection: "users" }) + .whereGreaterThan("age", 29); let streamQueryStats; // Call stream() to execute the query, it returns a Node.js ReadableStream. // Can get query stats by passing a stats callback to stream() method -const queryStream = await session.advanced.stream(query, _ => streamQueryStats = _); +const queryStream = await session.advanced.stream( + query, + (_) => (streamQueryStats = _) +); // Handle stream events with callback functions -queryStream.on("data", user => { - // Only documents matching the query are received - // These entities are Not tracked by the session +queryStream.on("data", (user) => { + // Only documents matching the query are received + // These entities are Not tracked by the session }); // Can get query stats by using an event listener -queryStream.once("stats", queryStats => { - // Sample stats: - // { resultEtag: 7464021133404493000, - // isStale: false, - // indexName: 'Auto/users/Byage', - // totalResults: 1, - // indexTimestamp: 2018-10-01T09:04:07.145Z } +queryStream.once("stats", (queryStats) => { + // Sample stats: + // { resultEtag: 7464021133404493000, + // isStale: false, + // indexName: 'Auto/users/Byage', + // totalResults: 1, + // indexTimestamp: 2018-10-01T09:04:07.145Z } }); // Stream emits an 'end' event when there is no more data to read queryStream.on("end", () => { - // Get info from 'streamQueryStats', the stats object - const totalResults = streamQueryStats.totalResults; - const indexUsed = streamQueryStats.indexName; + // Get info from 'streamQueryStats', the stats object + const totalResults = streamQueryStats.totalResults; + const indexUsed = streamQueryStats.indexName; }); -queryStream.on("error", err => { - // handle errors +queryStream.on("error", (err) => { + // handle errors }); ``` ->##### Related tests: +> ##### Related tests: +> > [can stream query and get stats](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Documents/ReadmeSamples.ts#L546) > [can stream query results](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Ported/Core/Streaming/QueryStreaming.ts#L76) > [can stream query results with query statistics](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Ported/Core/Streaming/QueryStreaming.ts#L140) -> [can stream raw query results](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Ported/Core/Streaming/QueryStreaming.ts#L192) +> [can stream raw query results](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Ported/Core/Streaming/QueryStreaming.ts#L192) ## Revisions @@ -1047,9 +1156,9 @@ NOTE: Please make sure revisions are enabled before trying the below. ```javascript const session = store.openSession(); const user = { - name: "Marcin", - age: 30, - pet: "Cat" + name: "Marcin", + age: 30, + pet: "Cat", }; // Store a document @@ -1077,11 +1186,12 @@ const revisions = await session.advanced.revisions.getFor("users/1"); // id: 'users/1' } ] ``` ->##### Related tests: +> ##### Related tests: +> > [can get revisions](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Documents/ReadmeSamples.ts#L737) > [canGetRevisionsByDate](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Issues/RavenDB_11770.ts#L21) > [can handle revisions](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Ported/RevisionsTest.ts#L35) -> [canGetRevisionsByChangeVectors](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Ported/RevisionsTest.ts#L149) +> [canGetRevisionsByChangeVectors](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Ported/RevisionsTest.ts#L149) ## Suggestions @@ -1108,17 +1218,17 @@ Suggest options for similar/misspelled terms // Static index definition class UsersIndex extends AbstractJavaScriptIndexCreationTask { - constructor() { - super(); - this.map(User, doc => { - return { - name: doc.name - } - }); - - // Enable the suggestion feature on index-field 'name' - this.suggestion("name"); - } + constructor() { + super(); + this.map(User, (doc) => { + return { + name: doc.name, + }; + }); + + // Enable the suggestion feature on index-field 'name' + this.suggestion("name"); + } } // ... @@ -1127,22 +1237,24 @@ const session = store.openSession(); // Query for similar terms to 'John' // Note: the term 'John' itself will Not be part of the results -const suggestedNameTerms = await session.query(User, UsersIndex) - .suggestUsing(x => x.byField("name", "John")) - .execute(); +const suggestedNameTerms = await session + .query(User, UsersIndex) + .suggestUsing((x) => x.byField("name", "John")) + .execute(); // Sample results: // { name: { name: 'name', suggestions: [ 'johne', 'johm', 'jon' ] } } ``` ->##### Related tests: +> ##### Related tests: +> > [can suggest](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Documents/ReadmeSamples.ts#L581) > [canChainSuggestions](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Issues/RavenDB_9584.ts#L19) > [canUseAliasInSuggestions](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Issues/RavenDB_9584.ts#L42) > [canUseSuggestionsWithAutoIndex](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Issues/RavenDB_9584.ts#L60) > [can suggest using linq](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Ported/Suggestions/SuggestionsTest.ts#L39) > [can suggest using multiple words](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Ported/Suggestions/SuggestionsTest.ts#L78) -> [can get suggestions with options](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Ported/Suggestions/SuggestionsTest.ts#L125) +> [can get suggestions with options](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Ported/Suggestions/SuggestionsTest.ts#L125) ## Advanced patching @@ -1156,7 +1268,8 @@ session.advanced.patch("users/1", "underAge", false); await session.saveChanges(); ``` ->##### Related tests: +> ##### Related tests: +> > [can use advanced.patch](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Documents/ReadmeSamples.ts#L708) > [can patch](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Ported/FirstClassPatchTest.ts#L18) > [can patch complex](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Ported/FirstClassPatchTest.ts#L93) @@ -1164,7 +1277,7 @@ await session.saveChanges(); > [can increment](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Ported/FirstClassPatchTest.ts#L268) > [patchWillUpdateTrackedDocumentAfterSaveChanges](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Ported/Issues/RavenDB_11552.ts#L27) > [can patch single document](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Ported/PatchTest.ts#L24) -> [can patch multiple documents](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Ported/PatchTest.ts#L71) +> [can patch multiple documents](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Ported/PatchTest.ts#L71) ## Subscriptions @@ -1173,86 +1286,199 @@ await session.saveChanges(); // Documents that match the query will be send to the client worker upon opening a connection const subscriptionName = await store.subscriptions.create({ - query: "from users where age >= 30" + query: "from users where age >= 30", }); // Open a connection // Create a subscription worker that will consume document batches sent from the server // Documents are sent from the last document that was processed for this subscription -const subscriptionWorker = store.subscriptions.getSubscriptionWorker({ subscriptionName }); +const subscriptionWorker = store.subscriptions.getSubscriptionWorker({ + subscriptionName, +}); // Worker handles incoming batches subscriptionWorker.on("batch", (batch, callback) => { - try { - // Process the incoming batch items - // Sample batch.items: - // [ Item { - // changeVector: 'A:2-r6nkF5nZtUKhcPEk6/LL+Q', - // id: 'users/1-A', - // rawResult: - // { name: 'John', - // age: 30, - // registeredAt: '2017-11-11T00:00:00.0000000', - // kids: [Array], - // '@metadata': [Object], - // id: 'users/1-A' }, - // rawMetadata: - // { '@collection': 'Users', - // '@nested-object-types': [Object], - // 'Raven-Node-Type': 'User', - // '@change-vector': 'A:2-r6nkF5nZtUKhcPEk6/LL+Q', - // '@id': 'users/1-A', - // '@last-modified': '2018-10-18T11:15:51.4882011Z' }, - // exceptionMessage: undefined } ] - // ... - - // Call the callback once you're done - // The worker will send an acknowledgement to the server, so that server can send next batch - callback(); - - } catch(err) { - // If processing fails for a particular batch then pass the error to the callback - callback(err); - } + try { + // Process the incoming batch items + // Sample batch.items: + // [ Item { + // changeVector: 'A:2-r6nkF5nZtUKhcPEk6/LL+Q', + // id: 'users/1-A', + // rawResult: + // { name: 'John', + // age: 30, + // registeredAt: '2017-11-11T00:00:00.0000000', + // kids: [Array], + // '@metadata': [Object], + // id: 'users/1-A' }, + // rawMetadata: + // { '@collection': 'Users', + // '@nested-object-types': [Object], + // 'Raven-Node-Type': 'User', + // '@change-vector': 'A:2-r6nkF5nZtUKhcPEk6/LL+Q', + // '@id': 'users/1-A', + // '@last-modified': '2018-10-18T11:15:51.4882011Z' }, + // exceptionMessage: undefined } ] + // ... + + // Call the callback once you're done + // The worker will send an acknowledgement to the server, so that server can send next batch + callback(); + } catch (err) { + // If processing fails for a particular batch then pass the error to the callback + callback(err); + } }); -subscriptionWorker.on("error", err => { - // handle errors +subscriptionWorker.on("error", (err) => { + // handle errors }); -// Subscription event types: -'batch', 'error', 'end', 'unexpectedSubscriptionError', 'afterAcknowledgment', 'connectionRetry' +// Subscription event types: +"batch", + "error", + "end", + "unexpectedSubscriptionError", + "afterAcknowledgment", + "connectionRetry"; ``` ->##### Related tests: +> ##### Related tests: +> > [can subscribe](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Documents/ReadmeSamples.ts#L607) > [should stream all documents](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Ported/Subscriptions/SubscriptionsBasicTest.ts#L143) > [should send all new and modified docs](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Ported/Subscriptions/SubscriptionsBasicTest.ts#L202) > [should respect max doc count in batch](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Ported/Subscriptions/SubscriptionsBasicTest.ts#L263) > [can disable subscription](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Ported/Subscriptions/SubscriptionsBasicTest.ts#L345) -> [can delete subscription](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Ported/Subscriptions/SubscriptionsBasicTest.ts#L52) +> [can delete subscription](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Ported/Subscriptions/SubscriptionsBasicTest.ts#L52) + +## AI Agents + +The AI Agent feature allows you to create and manage AI agents that can interact with your database and perform custom actions. AI agents can use database queries, handle tool calls, and maintain conversation history with configurable retention policies. + +### Key Components + +**AiOperations**: The main entry point for AI functionality, accessible via `documentStore.ai`. + +**AiAgentConfiguration**: Defines an AI agent with system prompt and behavior, database query tools (executed server-side), action tools (executed client-side), output schema/sample object, and persistence and trimming configurations. + +**IAiConversationOperations**: Interface for conducting conversations with AI agents, supporting sending user prompts, handling required actions, and getting structured responses. + +### Basic Usage + +```typescript +import { DocumentStore, AiAgentConfiguration, AiAgentToolQuery } from "ravendb"; + +// Initialize store +const store = new DocumentStore(["http://localhost:8080"], "MyDatabase"); +store.initialize(); + +// Create an AI agent +const config = new AiAgentConfiguration( + "CustomerSupportAgent", + "MyOpenAIConnection", + "You are a helpful customer support assistant." +); + +// Add database query tools +config.queries.push( + new AiAgentToolQuery( + "get_orders", + "Get customer orders", + "from Orders where CustomerId = $customerId" + ) +); + +// Set output schema +config.sampleObject = JSON.stringify({ + answer: "Response to customer", + confidence: 0.95, + relatedOrders: ["order1", "order2"], +}); + +// Create the agent +const result = await store.ai.createAgent(config); + +// Start a conversation +const conversation = store.ai.startConversation(result.identifier, (builder) => + builder.addParameter("customerId", "customers/123") +); + +conversation.setUserPrompt("What's the status of my recent orders?"); + +// Run the conversation +let status = await conversation.run(); + +// Handle any required actions +while (status === "ActionRequired") { + const actions = conversation.requiredActions(); + for (const action of actions) { + // Handle the action and provide response + conversation.addActionResponse(action.toolId, "Action completed"); + } + status = await conversation.run(); +} + +// Get the final answer +const answer = conversation.answer; +console.log("AI Response:", answer); +``` + +### Advanced Features + +**Custom Action Tools**: + +```typescript +config.actions.push( + new AiAgentToolAction("send_email", "Sends an email to the customer") +); +``` + +**Conversation Persistence**: + +```typescript +config.persistence = new AiAgentPersistenceConfiguration("chats/", 86400); // 1 day +``` + +**Chat History Trimming**: + +```typescript +config.chatTrimming = new AiAgentChatTrimmingConfiguration( + new AiAgentSummarizationByTokens() // or AiAgentTruncateChat() +); +``` + +**Resuming Conversations**: + +```typescript +const existingConversation = store.ai.resumeConversation( + conversationId, + changeVector +); +``` ## Using object literals for entities To comfortably use object literals as entities, -configure the collection name that will be used in the store conventions. +configure the collection name that will be used in the store conventions. -This must be done *before* calling `initialize()` on the DocumentStore instance, -else, your entities will be created in the *@empty* collection. +This must be done _before_ calling `initialize()` on the DocumentStore instance, +else, your entities will be created in the _@empty_ collection. ```javascript const store = new DocumentStore(urls, database); // Configure the collection name that will be used -store.conventions.findCollectionNameForObjectLiteral = entity => entity["collection"]; +store.conventions.findCollectionNameForObjectLiteral = (entity) => + entity["collection"]; // ... store.initialize(); // Sample object literal const user = { - collection: "Users", - name: "John" + collection: "Users", + name: "John", }; session = store.openSession(); @@ -1262,72 +1488,86 @@ await session.saveChanges(); // The document will be stored in the 'Users' collection ``` ->##### Related tests: +> ##### Related tests: +> > [using object literals for entities](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Documents/ReadmeSamples.ts#L644) > [using object literals](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Documents/SessionApiTests.ts#L108) -> [handle custom entity naming conventions + object literals](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Ported/BulkInsert/BulkInsertsTest.ts#L220) +> [handle custom entity naming conventions + object literals](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Ported/BulkInsert/BulkInsertsTest.ts#L220) ## Using classes for entities 1. Define your model as class. Attributes should be just public properties: + ```javascript export class Product { - constructor( - id = null, - title = '', - price = 0, - currency = 'USD', - storage = 0, - manufacturer = '', - in_stock = false, - last_update = null - ) { - Object.assign(this, { - title, - price, - currency, - storage, - manufacturer, - in_stock, - last_update: last_update || new Date() - }); - } + constructor( + id = null, + title = "", + price = 0, + currency = "USD", + storage = 0, + manufacturer = "", + in_stock = false, + last_update = null + ) { + Object.assign(this, { + title, + price, + currency, + storage, + manufacturer, + in_stock, + last_update: last_update || new Date(), + }); + } } ``` 2. To store a document pass its instance to `store()`. - The collection name will automatically be detected from the entity's class name. + The collection name will automatically be detected from the entity's class name. + ```javascript -import { Product } from "./models"; +import { Product } from "./models"; let product = new Product( - null, 'iPhone X', 999.99, 'USD', 64, 'Apple', true, new Date('2017-10-01T00:00:00')); + null, + "iPhone X", + 999.99, + "USD", + 64, + "Apple", + true, + new Date("2017-10-01T00:00:00") +); product = await session.store(product); -console.log(product instanceof Product); // true -console.log(product.id.includes('products/')); // true +console.log(product instanceof Product); // true +console.log(product.id.includes("products/")); // true await session.saveChanges(); ``` -3. Loading a document +3. Loading a document + ```javascript -const product = await session.load('products/1-A'); +const product = await session.load("products/1-A"); console.log(product instanceof Product); // true -console.log(product.id); // products/1-A +console.log(product.id); // products/1-A ``` -4. Querying for documents +4. Querying for documents + ```javascript -const products = await session.query({ collection: 'products' }).all(); +const products = await session.query({ collection: "products" }).all(); products.forEach((product) => { - console.log(product instanceof Product); // true - console.log(product.id.includes('products/')); // true + console.log(product instanceof Product); // true + console.log(product.id.includes("products/")); // true }); -``` +``` ->##### Related tests: -> [using classes](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Documents/SessionApiTests.ts#L173) +> ##### Related tests: +> +> [using classes](https://github.com/ravendb/ravendb-nodejs-client/blob/5c14565d0c307d22e134530c8d63b09dfddcfb5b/test/Documents/SessionApiTests.ts#L173) ## Usage with TypeScript @@ -1338,51 +1578,65 @@ TypeScript typings are embedded into the package (see `types` property in `packa export class Product { constructor( public id: string = null, - public title: string = '', + public title: string = "", public price: number = 0, - public currency: string = 'USD', + public currency: string = "USD", public storage: number = 0, - public manufacturer: string = '', + public manufacturer: string = "", public in_stock: boolean = false, public last_update: Date = null ) {} } // file app.ts -import {Product} from "models/product"; -import {DocumentStore, IDocumentStore, IDocumentSession, IDocumentQuery, DocumentConstructor, QueryOperators} from 'ravendb'; - -const store: IDocumentStore = new DocumentStore('url', 'database name'); +import { Product } from "models/product"; +import { + DocumentStore, + IDocumentStore, + IDocumentSession, + IDocumentQuery, + DocumentConstructor, + QueryOperators, +} from "ravendb"; + +const store: IDocumentStore = new DocumentStore("url", "database name"); let session: IDocumentSession; store.initialize(); (async (): Promise => { let product = new Product( - null, 'iPhone X', 999.99, 'USD', 64, 'Apple', true, new Date('2017-10-01T00:00:00') + null, + "iPhone X", + 999.99, + "USD", + 64, + "Apple", + true, + new Date("2017-10-01T00:00:00") ); await session.store(product); await session.saveChanges(); - console.log(product instanceof Product); // true - console.log(product.id.includes('products/')); // true + console.log(product instanceof Product); // true + console.log(product.id.includes("products/")); // true - product = await session.load('products/1-A'); + product = await session.load("products/1-A"); console.log(product instanceof Product); // true - console.log(product.id); // products/1-A + console.log(product.id); // products/1-A let products: Product[] = await session - .query({ collection: 'Products' }) + .query({ collection: "Products" }) .waitForNonStaleResults() - .whereEquals('manufacturer', 'Apple') - .whereEquals('in_stock', true) - .whereBetween('last_update', new Date('2017-10-01T00:00:00'), new Date()) - .whereGreaterThanOrEqual('storage', 64) + .whereEquals("manufacturer", "Apple") + .whereEquals("in_stock", true) + .whereBetween("last_update", new Date("2017-10-01T00:00:00"), new Date()) + .whereGreaterThanOrEqual("storage", 64) .all(); products.forEach((product: Product): void => { - console.log(product instanceof Product); // true - console.log(product.id.includes('products/')); // true + console.log(product instanceof Product); // true + console.log(product.id.includes("products/")); // true }); })(); ``` @@ -1391,8 +1645,9 @@ store.initialize(); 1. Fill auth options object. Pass the contents of the pem/pfx certificate, specify its type, and (optionally) a passphrase: + ```javascript -const {DocumentStore, Certificate} = require('ravendb'); +const { DocumentStore, Certificate } = require("ravendb"); const certificate = ` -----BEGIN CERTIFICATE----- @@ -1406,29 +1661,29 @@ const certificate = ` let authOptions = { certificate, type: "pem", - password: "my passphrase" // optional + password: "my passphrase", // optional }; -``` +``` PFX certificates content should be passed as a `Buffer` object: ```javascript -const {DocumentStore} = require('ravendb'); -const fs = require('fs'); +const { DocumentStore } = require("ravendb"); +const fs = require("fs"); -const certificate = './cert.pfx'; +const certificate = "./cert.pfx"; let authOptions = { certificate: fs.readFileSync(certificate), type: "pfx", - password: 'my passphrase' // optional + password: "my passphrase", // optional }; -``` +``` 2. Pass auth options as third argument to `DocumentStore` constructor: ```javascript -let store = new DocumentStore('url', 'databaseName', authOptions); +let store = new DocumentStore("url", "databaseName", authOptions); store.initialize(); ``` @@ -1443,15 +1698,15 @@ npm run build ```bash # To run the suite, set the following environment variables: -# +# # - Location of RavenDB server binary: -# RAVENDB_TEST_SERVER_PATH="C:\\work\\test\\Server\\Raven.Server.exe" +# RAVENDB_TEST_SERVER_PATH="C:\\work\\test\\Server\\Raven.Server.exe" # # - Certificate path for tests requiring a secure server: # RAVENDB_TEST_SERVER_CERTIFICATE_PATH="C:\\work\\test\\cluster.server.certificate.pfx" # -# - Certificate hostname: +# - Certificate hostname: # RAVENDB_TEST_SERVER_HOSTNAME="a.nodejstest.development.run" # -npm test +npm test ``` diff --git a/examples/ai-agent-example.ts b/examples/ai-agent-example.ts new file mode 100644 index 00000000..bac07612 --- /dev/null +++ b/examples/ai-agent-example.ts @@ -0,0 +1,142 @@ +import { DocumentStore } from "../src/Documents/DocumentStore.js"; +import { + AiAgentConfiguration, + AiAgentToolQuery, + AiAgentToolAction, + AiAgentPersistenceConfiguration +} from "../src/Documents/Operations/AI/Agents/index.js"; + +// Example schema for AI agent responses +interface CustomerSupportResponse { + answer: string; + relevant: boolean; + relatedOrderIds: string[]; + suggestedActions: string[]; +} + +async function demonstrateAiAgentFeature() { + // Initialize document store + const store = new DocumentStore(["http://localhost:8080"], "TestDB"); + store.initialize(); + + try { + // Create an AI agent configuration + const agentConfig = new AiAgentConfiguration( + "CustomerSupportAgent", + "OpenAI-GPT4", + "You are a helpful customer support assistant for an e-commerce platform. " + + "Answer customer questions using the available tools to query order and product information." + ); + + // Add query tools (database-side) + agentConfig.queries.push( + new AiAgentToolQuery( + "get_customer_orders", + "Retrieves orders for a specific customer", + "from Orders where CustomerId = $customerId" + ), + new AiAgentToolQuery( + "get_product_info", + "Gets detailed product information", + "from Products where Id = $productId" + ) + ); + + // Add action tools (client-side) + agentConfig.actions.push( + new AiAgentToolAction( + "send_email", + "Sends an email to the customer" + ), + new AiAgentToolAction( + "create_support_ticket", + "Creates a support ticket for escalation" + ) + ); + + // Configure persistence + agentConfig.persistence = new AiAgentPersistenceConfiguration("chats/", 86400); // 1 day expiration + + // Add required parameters + agentConfig.parameters.add("customerId"); + + // Set sample schema for the AI response format + agentConfig.sampleObject = JSON.stringify({ + answer: "Answer to the customer question", + relevant: true, + relatedOrderIds: ["orders/1", "orders/2"], + suggestedActions: ["check_shipping", "contact_support"] + }); + + // Create the agent + const result = await store.ai.createAgent(agentConfig); + console.log(`Agent created with ID: ${result.identifier}`); + + // Start a conversation + const conversation = store.ai.startConversation( + result.identifier, + builder => builder.addParameter("customerId", "customers/123") + ); + + // Set initial user prompt + conversation.setUserPrompt("I want to check the status of my recent orders and see if any have shipping delays."); + + // Run the conversation + let conversationResult = await conversation.run(); + + while (conversationResult === "ActionRequired") { + // Handle required actions + const actions = conversation.requiredActions(); + console.log(`AI requested ${actions.length} actions:`); + + for (const action of actions) { + console.log(`- ${action.name}: ${action.arguments}`); + + // Simulate handling the action + if (action.name === "send_email") { + conversation.addActionResponse(action.toolId, "Email sent successfully"); + } else if (action.name === "create_support_ticket") { + conversation.addActionResponse(action.toolId, JSON.stringify({ + ticketId: "TICKET-123", + status: "created" + })); + } + } + + // Continue the conversation + conversationResult = await conversation.run(); + } + + // Get the final answer + const answer = conversation.answer; + console.log("AI Response:", answer); + + // Continue conversation with follow-up + conversation.setUserPrompt("Can you also help me track my latest order?"); + conversationResult = await conversation.run(); + + if (conversationResult === "Done") { + console.log("Follow-up response:", conversation.answer); + } + + // Example of resuming an existing conversation + const existingConversation = store.ai.resumeConversation( + conversation.id, + conversation.changeVector + ); + + existingConversation.setUserPrompt("Thank you for your help!"); + await existingConversation.run(); + console.log("Final response:", existingConversation.answer); + + } finally { + store.dispose(); + } +} + +// Example usage +if (require.main === module) { + demonstrateAiAgentFeature().catch(console.error); +} + +export { demonstrateAiAgentFeature }; diff --git a/src/Documents/AI/AiAgentParametersBuilder.ts b/src/Documents/AI/AiAgentParametersBuilder.ts new file mode 100644 index 00000000..41996f20 --- /dev/null +++ b/src/Documents/AI/AiAgentParametersBuilder.ts @@ -0,0 +1,16 @@ +export interface IAiAgentParametersBuilder { + addParameter(key: string, value: any): IAiAgentParametersBuilder; +} + +export class AiAgentParametersBuilder implements IAiAgentParametersBuilder { + private readonly _parameters = new Map(); + + public addParameter(key: string, value: any): IAiAgentParametersBuilder { + this._parameters.set(key, value); + return this; + } + + public getParameters(): Record | null { + return this._parameters.size === 0 ? null : Object.fromEntries(this._parameters); + } +} diff --git a/src/Documents/AI/AiConversation.ts b/src/Documents/AI/AiConversation.ts new file mode 100644 index 00000000..f9395623 --- /dev/null +++ b/src/Documents/AI/AiConversation.ts @@ -0,0 +1,161 @@ +import { IAiConversationOperations } from "./IAiConversationOperations.js"; +import { AiConversationResult } from "./AiConversationResult.js"; +import { AiAgentActionRequest, AiAgentActionResponse, RunConversationOperation } from "../Operations/AI/Agents/RunConversationOperation.js"; +import { StringUtil } from "../../Utility/StringUtil.js"; +import { throwError } from "../../Exceptions/index.js"; +import { MaintenanceOperationExecutor } from "../Operations/MaintenanceOperationExecutor.js"; + +interface NewConversationOptions { + type: "new"; + agentId: string; + parameters?: Record; +} + +interface ExistingConversationOptions { + type: "existing"; + conversationId: string; + changeVector: string; +} + +type ConversationOptions = NewConversationOptions | ExistingConversationOptions; + +export class AiConversation implements IAiConversationOperations { + private readonly _maintenanceExecutor: MaintenanceOperationExecutor; + private readonly _agentId?: string; + private readonly _parameters?: Record; + + private _conversationId?: string; + private _actionRequests?: AiAgentActionRequest[]; + private _actionResponses: AiAgentActionResponse[] = []; + private _userPrompt?: string; + private _changeVector?: string; + private _answer?: T; + + private constructor(maintenanceExecutor: MaintenanceOperationExecutor, options: ConversationOptions) { + this._maintenanceExecutor = maintenanceExecutor; + + if (options.type === "new") { + if (StringUtil.isNullOrEmpty(options.agentId)) { + throwError("InvalidArgumentException", "agentId cannot be null or empty"); + } + this._agentId = options.agentId; + this._parameters = options.parameters; + } else { + if (StringUtil.isNullOrEmpty(options.conversationId)) { + throwError("InvalidArgumentException", "conversationId cannot be null or empty"); + } + this._conversationId = options.conversationId; + this._changeVector = options.changeVector; + } + } + + public static start(maintenanceExecutor: MaintenanceOperationExecutor, agentId: string, parameters?: Record): AiConversation { + return new AiConversation(maintenanceExecutor, { + type: "new", + agentId, + parameters + }); + } + + public static resume(maintenanceExecutor: MaintenanceOperationExecutor, conversationId: string, changeVector: string): AiConversation { + return new AiConversation(maintenanceExecutor, { + type: "existing", + conversationId, + changeVector + }); + } + + public get id(): string { + if (!this._conversationId) { + throwError("InvalidOperationException", "This is a new conversation, the ID wasn't set yet, you have to call run"); + } + return this._conversationId; + } + + public get answer(): T { + if (!this._answer) { + throwError("InvalidOperationException", "You have to call run first"); + } + return this._answer; + } + + public get changeVector(): string { + return this._changeVector ?? ""; + } + + public requiredActions(): AiAgentActionRequest[] { + if (!this._actionRequests) { + throwError("InvalidOperationException", "You have to call run first"); + } + return this._actionRequests; + } + + public addActionResponse(actionId: string, actionResponse: string): void; + public addActionResponse(actionId: string, actionResponse: TResponse): void; + public addActionResponse(actionId: string, actionResponse: string | TResponse): void { + let content: string; + + if (typeof actionResponse === "string") { + content = actionResponse; + } else { + // For object responses, we need to serialize them + content = JSON.stringify(actionResponse); + } + + this._actionResponses.push({ + toolId: actionId, + content: content + }); + } + + public setUserPrompt(userPrompt: string): void { + if (StringUtil.isNullOrEmpty(userPrompt)) { + throwError("InvalidArgumentException", "userPrompt cannot be null or empty"); + } + this._userPrompt = userPrompt; + } + + public async run(token?: AbortSignal): Promise { + let operation: RunConversationOperation; + + if (!this._conversationId) { + operation = new RunConversationOperation(this._agentId!, this._userPrompt!, this._parameters); + } else { + // we allow to run the conversation only if it is the first run with no user prompt or tool requests + // this way we can fetch the pending actions + if (this._actionRequests && !this._userPrompt && this._actionResponses.length === 0) { + return AiConversationResult.Done; + } + + operation = new RunConversationOperation( + this._conversationId, + this._userPrompt, + this._actionResponses, + this._changeVector + ); + } + + try { + const result = await this._maintenanceExecutor.send(operation); + + this._conversationId = result.conversationId; + this._changeVector = result.changeVector; + this._answer = result.response; + this._actionRequests = result.actionRequests || []; + + // Reset for next turn + this._actionResponses = []; + this._userPrompt = undefined; + + return (this._actionRequests && this._actionRequests.length > 0) + ? AiConversationResult.ActionRequired + : AiConversationResult.Done; + + } catch (e: any) { + if (e.name === "ConcurrencyException") { + throwError("ConcurrencyException", `The conversation was modified by another operation. ChangeVector: ${this._changeVector}`); + } + throw e; + } + } +} diff --git a/src/Documents/AI/AiConversationResult.ts b/src/Documents/AI/AiConversationResult.ts new file mode 100644 index 00000000..2aaafac1 --- /dev/null +++ b/src/Documents/AI/AiConversationResult.ts @@ -0,0 +1,14 @@ +/** + * Represents the result of a single conversation turn. + */ +export enum AiConversationResult { + /** + * The conversation has completed and a final answer is available. + */ + Done = "Done", + + /** + * Further interaction is required, such as responding to tool requests. + */ + ActionRequired = "ActionRequired" +} diff --git a/src/Documents/AI/AiOperations.ts b/src/Documents/AI/AiOperations.ts new file mode 100644 index 00000000..59c0a38b --- /dev/null +++ b/src/Documents/AI/AiOperations.ts @@ -0,0 +1,112 @@ +import { IDocumentStore } from "../IDocumentStore.js"; +import { MaintenanceOperationExecutor } from "../Operations/MaintenanceOperationExecutor.js"; +import { StringUtil } from "../../Utility/StringUtil.js"; +import { AiAgentConfiguration } from "../Operations/AI/Agents/AiAgentConfiguration.js"; +import { AiAgentConfigurationResult } from "../Operations/AI/Agents/AiAgentConfigurationResult.js"; +import { AddOrUpdateAiAgentOperation } from "../Operations/AI/Agents/AddOrUpdateAiAgentOperation.js"; +import { GetAiAgentOperation, GetAiAgentsResponse } from "../Operations/AI/Agents/GetAiAgentOperation.js"; +import { DeleteAiAgentOperation } from "../Operations/AI/Agents/DeleteAiAgentOperation.js"; +import { AiConversation } from "./AiConversation.js"; +import { IAiConversationOperations } from "./IAiConversationOperations.js"; +import { AiAgentParametersBuilder, IAiAgentParametersBuilder } from "./AiAgentParametersBuilder.js"; + +/** + * Manages AI agents and conversation interactions in a specific RavenDB database. + */ +export class AiOperations { + private readonly _store: IDocumentStore; + private readonly _databaseName: string; + private readonly _executor: MaintenanceOperationExecutor; + + /** + * Initializes a new instance of AiOperations for a given document store and optional database name. + */ + public constructor(store: IDocumentStore, databaseName?: string) { + this._store = store; + this._databaseName = databaseName || store.database; + this._executor = this._store.maintenance.forDatabase(this._databaseName); + } + + /** + * Gets the underlying maintenance operation executor + */ + public get maintenance(): MaintenanceOperationExecutor { + return this._executor; + } + + /** + * Returns an AiOperations instance for a different database. + */ + public forDatabase(databaseName: string): AiOperations { + if (StringUtil.equalsIgnoreCase(this._databaseName, databaseName)) { + return this; + } + return new AiOperations(this._store, databaseName); + } + + /** + * Creates or updates an AI agent configuration (with the given schema) on the database. + */ + public async createAgent( + configuration: AiAgentConfiguration, + schemaType?: { new(): TSchema } + ): Promise { + const operation = new AddOrUpdateAiAgentOperation(configuration, schemaType); + return await this._executor.send(operation); + } + + /** + * Retrieves the AI agent configuration for a specific agent. + */ + public async getAgent(agentId: string): Promise { + const operation = new GetAiAgentOperation(agentId); + const response = await this._executor.send(operation); + return response.aiAgents && response.aiAgents.length > 0 ? response.aiAgents[0] : null; + } + + /** + * Retrieves all AI agents and their configurations. + */ + public async getAgents(): Promise { + const operation = new GetAiAgentOperation(); + return await this._executor.send(operation); + } + + /** + * Deletes an AI agent configuration. + */ + public async deleteAgent(identifier: string): Promise { + const operation = new DeleteAiAgentOperation(identifier); + return await this._executor.send(operation); + } + + /** + * Starts a new conversation with an AI agent. + */ + public startConversation(agentId: string): IAiConversationOperations; + public startConversation(agentId: string, parameters: Record): IAiConversationOperations; + public startConversation(agentId: string, parametersBuilder: (builder: IAiAgentParametersBuilder) => void): IAiConversationOperations; + public startConversation( + agentId: string, + parametersOrBuilder?: Record | ((builder: IAiAgentParametersBuilder) => void) + ): IAiConversationOperations { + let parameters: Record | undefined; + + if (typeof parametersOrBuilder === "function") { + const builder = new AiAgentParametersBuilder(); + parametersOrBuilder(builder); + parameters = builder.getParameters() ?? undefined; + } else { + parameters = parametersOrBuilder; + } + + return AiConversation.start(this._executor, agentId, parameters); + } + + /** + * Resumes an existing conversation with an AI agent. + */ + public resumeConversation(conversationId: string, changeVector: string | null = null): IAiConversationOperations { + return AiConversation.resume(this._executor, conversationId, changeVector); + } +} diff --git a/src/Documents/AI/IAiConversationOperations.ts b/src/Documents/AI/IAiConversationOperations.ts new file mode 100644 index 00000000..c3296f22 --- /dev/null +++ b/src/Documents/AI/IAiConversationOperations.ts @@ -0,0 +1,48 @@ +import { AiConversationResult } from "./AiConversationResult.js"; +import { AiAgentActionRequest } from "../Operations/AI/Agents/RunConversationOperation.js"; + +export interface IAiConversationOperations { + /** + * The identifier of this conversation. + */ + readonly id: string; + + /** + * The most recent answer generated by the AI agent. + */ + readonly answer: T; + + /** + * The current RavenDB change vector for this conversation document, + * used to detect concurrent modifications. + */ + readonly changeVector: string; + + /** + * Retrieves the list of action-tool requests + * that the AI agent needs you to execute. + */ + requiredActions(): AiAgentActionRequest[]; + + /** + * Adds a string response for a given action request. + */ + addActionResponse(actionId: string, actionResponse: string): void; + + /** + * Adds a typed response object for a given action request. + */ + addActionResponse(actionId: string, actionResponse: TResponse): void; + + /** + * Executes one "turn" of the conversation: + * sends the current prompt, processes any required actions, + * and awaits the agent's reply. + */ + run(token?: AbortSignal): Promise; + + /** + * Sets the next user prompt to send to the AI agent. + */ + setUserPrompt(userPrompt: string): void; +} diff --git a/src/Documents/AI/index.ts b/src/Documents/AI/index.ts new file mode 100644 index 00000000..281dcc9b --- /dev/null +++ b/src/Documents/AI/index.ts @@ -0,0 +1,5 @@ +export { AiOperations } from "./AiOperations.js"; +export type { IAiConversationOperations } from "./IAiConversationOperations.js"; +export { AiConversation } from "./AiConversation.js"; +export { AiConversationResult } from "./AiConversationResult.js"; +export { AiAgentParametersBuilder, type IAiAgentParametersBuilder } from "./AiAgentParametersBuilder.js"; diff --git a/src/Documents/DocumentStoreBase.ts b/src/Documents/DocumentStoreBase.ts index 2b5c45fc..a795ebf3 100644 --- a/src/Documents/DocumentStoreBase.ts +++ b/src/Documents/DocumentStoreBase.ts @@ -39,6 +39,7 @@ import { IAbstractIndexCreationTask } from "./Indexes/IAbstractIndexCreationTask import { StringUtil } from "../Utility/StringUtil.js"; import { IHiLoIdGenerator } from "./Identity/IHiLoIdGenerator.js"; import { BulkInsertOptions } from "./BulkInsert/BulkInsertOptions.js"; +import { AiOperations } from "./AI/AiOperations.js"; export abstract class DocumentStoreBase extends EventEmitter @@ -110,6 +111,16 @@ export abstract class DocumentStoreBase return this._timeSeriesOperation; } + private _aiOperations: AiOperations; + + public get ai() { + if (!this._aiOperations) { + this._aiOperations = new AiOperations(this); + } + + return this._aiOperations; + } + private _conventions: DocumentConventions; public get conventions() { diff --git a/src/Documents/IDocumentStore.ts b/src/Documents/IDocumentStore.ts index 75d5e172..bd2595b0 100644 --- a/src/Documents/IDocumentStore.ts +++ b/src/Documents/IDocumentStore.ts @@ -27,6 +27,7 @@ import { IAbstractIndexCreationTask } from "./Indexes/IAbstractIndexCreationTask import { TimeSeriesOperations } from "./TimeSeries/TimeSeriesOperations.js"; import { IHiLoIdGenerator } from "./Identity/IHiLoIdGenerator.js"; import { BulkInsertOptions } from "./BulkInsert/BulkInsertOptions.js"; +import { AiOperations } from "./AI/AiOperations.js"; export interface SessionEventsProxy { addSessionListener(eventName: "failedRequest", eventHandler: (eventArgs: FailedRequestEventArgs) => void): this; @@ -245,6 +246,8 @@ export interface IDocumentStore extends IDisposable, timeSeries: TimeSeriesOperations; + ai: AiOperations; + /** * Gets the conventions */ diff --git a/src/Documents/Operations/AI/Agents/AddOrUpdateAiAgentOperation.ts b/src/Documents/Operations/AI/Agents/AddOrUpdateAiAgentOperation.ts new file mode 100644 index 00000000..2176247d --- /dev/null +++ b/src/Documents/Operations/AI/Agents/AddOrUpdateAiAgentOperation.ts @@ -0,0 +1,116 @@ +import { IMaintenanceOperation, OperationResultType } from "../../OperationAbstractions.js"; +import { DocumentConventions } from "../../../Conventions/DocumentConventions.js"; +import { RavenCommand } from "../../../../Http/RavenCommand.js"; +import { HttpRequestParameters } from "../../../../Primitives/Http.js"; +import { ServerNode } from "../../../../Http/ServerNode.js"; +import { Stream } from "node:stream"; +import { JsonSerializer } from "../../../../Mapping/Json/Serializer.js"; +import { HeadersBuilder } from "../../../../Utility/HttpUtil.js"; +import { StringUtil } from "../../../../Utility/StringUtil.js"; +import { throwError } from "../../../../Exceptions/index.js"; +import { ObjectUtil } from "../../../../Utility/ObjectUtil.js"; +import { IRaftCommand } from "../../../../Http/IRaftCommand.js"; +import { RaftIdGenerator } from "../../../../Utility/RaftIdGenerator.js"; +import { AiAgentConfiguration } from "./AiAgentConfiguration.js"; +import { AiAgentConfigurationResult } from "./AiAgentConfigurationResult.js"; + +export class AddOrUpdateAiAgentOperation implements IMaintenanceOperation { + private readonly _configuration: AiAgentConfiguration; + private readonly _sampleSchema?: any; + + public constructor(configuration: AiAgentConfiguration); + public constructor(configuration: AiAgentConfiguration, schemaType?: { new(): any }); + public constructor(configuration: AiAgentConfiguration, schemaType?: { new(): any }) { + if (!configuration) { + throwError("InvalidArgumentException", "configuration cannot be null"); + } + + if (!configuration.outputSchema && !configuration.sampleObject && !schemaType) { + throwError("InvalidArgumentException", "Please provide a non-empty value for either outputSchema or sampleObject or schemaType"); + } + + this._configuration = configuration; + if (schemaType) { + this._sampleSchema = new schemaType(); + } + } + + public get resultType(): OperationResultType { + return "CommandResult"; + } + + public getCommand(conventions: DocumentConventions): RavenCommand { + return new AddOrUpdateAiAgentCommand(this._configuration, this._sampleSchema, conventions); + } +} + +export class AddOrUpdateAiAgentCommand extends RavenCommand implements IRaftCommand { + private readonly _configuration: AiAgentConfiguration; + private readonly _sampleSchema?: any; + private readonly _conventions: DocumentConventions; + + public constructor(configuration: AiAgentConfiguration, sampleSchema: any, conventions: DocumentConventions) { + super(); + this._configuration = configuration; + this._sampleSchema = sampleSchema; + this._conventions = conventions; + } + + public get isReadRequest(): boolean { + return false; + } + + public createRequest(node: ServerNode): HttpRequestParameters { + const uri = `${node.url}/databases/${node.database}/admin/ai/agent`; + + // Create a copy of the configuration to avoid modifying the original + const configToSend = { ...this._configuration }; + + // Set sample object if not provided but we have a schema type + if (!configToSend.sampleObject && this._sampleSchema) { + configToSend.sampleObject = JSON.stringify(this._sampleSchema); + } + + // Convert Set to Array for JSON serialization + const bodyToSerialize = { + ...configToSend, + parameters: configToSend.parameters ? Array.from(configToSend.parameters) : [] + }; + + const bodyJson = ObjectUtil.transformObjectKeys(bodyToSerialize, { + defaultTransform: ObjectUtil.pascal + }); + + const body = JsonSerializer.getDefault().serialize(bodyJson); + + const headers = HeadersBuilder + .create() + .typeAppJson() + .build(); + + return { + method: "PUT", + uri, + body, + headers + }; + } + + public async setResponseAsync(bodyStream: Stream, fromCache: boolean): Promise { + if (!bodyStream) { + return ""; + } + + let body: string = ""; + const result = await this._defaultPipeline() + .collectBody(b => body = b) + .process(bodyStream); + + this.result = result as AiAgentConfigurationResult; + return body; + } + + public getRaftUniqueRequestId(): string { + return RaftIdGenerator.newId(); + } +} diff --git a/src/Documents/Operations/AI/Agents/AiAgentChatTrimmingConfiguration.ts b/src/Documents/Operations/AI/Agents/AiAgentChatTrimmingConfiguration.ts new file mode 100644 index 00000000..328515a2 --- /dev/null +++ b/src/Documents/Operations/AI/Agents/AiAgentChatTrimmingConfiguration.ts @@ -0,0 +1,55 @@ +import { IRavenObject } from "../../../../Types/IRavenObject.js"; + +/** + * Defines configuration options for reducing the size of the AI agent's chat history. + */ +export class AiAgentChatTrimmingConfiguration implements IRavenObject { + public constructor(tokensConfig?: AiAgentSummarizationByTokens, historyConfig?: AiAgentHistoryConfiguration); + public constructor(truncateConfig?: AiAgentTruncateChat, historyConfig?: AiAgentHistoryConfiguration); + public constructor(configOrTruncate?: AiAgentSummarizationByTokens | AiAgentTruncateChat, historyConfig?: AiAgentHistoryConfiguration) { + if (configOrTruncate) { + if (configOrTruncate instanceof AiAgentSummarizationByTokens) { + this.tokens = configOrTruncate; + } else if (configOrTruncate instanceof AiAgentTruncateChat) { + this.truncate = configOrTruncate; + } + } + if (historyConfig) { + this.history = historyConfig; + } + } + + public tokens: AiAgentSummarizationByTokens | null; + public truncate: AiAgentTruncateChat | null; + public history: AiAgentHistoryConfiguration | null; +} + +/** + * Configuration settings for AI agent conversation summarization. + */ +export class AiAgentSummarizationByTokens implements IRavenObject { + private static readonly DEFAULT_MAX_TOKENS_BEFORE_SUMMARIZATION = 32 * 1024; + + public summarizationTaskBeginningPrompt: string; + public summarizationTaskEndPrompt: string; + public resultPrefix: string; + public maxTokensBeforeSummarization: number = AiAgentSummarizationByTokens.DEFAULT_MAX_TOKENS_BEFORE_SUMMARIZATION; + public maxTokensAfterSummarization: number = 1024; +} + +/** + * Configuration for truncating the AI chat history based on message count. + */ +export class AiAgentTruncateChat implements IRavenObject { + private static readonly DEFAULT_MESSAGES_LENGTH_BEFORE_TRUNCATE = 500; + + public messagesLengthBeforeTruncate: number = AiAgentTruncateChat.DEFAULT_MESSAGES_LENGTH_BEFORE_TRUNCATE; + public messagesLengthAfterTruncate: number = AiAgentTruncateChat.DEFAULT_MESSAGES_LENGTH_BEFORE_TRUNCATE / 2; +} + +/** + * Defines the configuration for retention and expiration of AI agent chat history documents. + */ +export class AiAgentHistoryConfiguration implements IRavenObject { + public historyExpirationInSec: number; +} diff --git a/src/Documents/Operations/AI/Agents/AiAgentConfiguration.ts b/src/Documents/Operations/AI/Agents/AiAgentConfiguration.ts new file mode 100644 index 00000000..a0d126e8 --- /dev/null +++ b/src/Documents/Operations/AI/Agents/AiAgentConfiguration.ts @@ -0,0 +1,85 @@ +import { IRavenObject } from "../../../../Types/IRavenObject.js"; +import { AiAgentToolQuery } from "./AiAgentToolQuery.js"; +import { AiAgentToolAction } from "./AiAgentToolAction.js"; +import { AiAgentPersistenceConfiguration } from "./AiAgentPersistenceConfiguration.js"; +import { AiAgentChatTrimmingConfiguration } from "./AiAgentChatTrimmingConfiguration.js"; + +/** + * Defines the configuration for an AI agent in RavenDB, including the system prompt, + * tools (queries/actions), output schema, persistence settings, and connection string. + */ +export class AiAgentConfiguration implements IRavenObject { + public constructor(name?: string, connectionStringName?: string, systemPrompt?: string) { + if (name) this.name = name; + if (connectionStringName) this.connectionStringName = connectionStringName; + if (systemPrompt) this.systemPrompt = systemPrompt; + + this.queries = []; + this.actions = []; + this.parameters = new Set(); + } + + /** + * The identifier of the AI agent configuration. + */ + public identifier: string; + + /** + * The name of the AI agent configuration. + */ + public name: string; + + /** + * The name of the connection string used to connect to the AI provider. + */ + public connectionStringName: string; + + /** + * The prompt that guides the behavior and purpose of the AI agent. + */ + public systemPrompt: string; + + /** + * A sample object (as string) describing an example for an AI agent's output. + * This allows validation and parsing of the AI-generated response according to a known format. + */ + public sampleObject: string; + + /** + * A JSON schema describing the expected structure of the AI agent's output. + * This allows validation and parsing of the AI-generated response according to a known format. + */ + public outputSchema: string; + + /** + * Database-side tools: predefined queries that RavenDB executes to fetch data directly during chat. + * The agent decides when to call them based on user input and context. + * When the agent calls them, it gets an actual data from the database based on these queries. + */ + public queries: AiAgentToolQuery[]; + + /** + * Model-side tools: callable actions where the AI agent fills parameters and invokes the tool as part of reasoning. + * The agent decides when to call them based on user input and context. + * When the agent calls them, it expects the user to provide "answers" for them. + */ + public actions: AiAgentToolAction[]; + + /** + * Controls persistence behavior of chats - whether the chat history will be persistent or not + */ + public persistence: AiAgentPersistenceConfiguration; + + /** + * Names of the required parameters that are used in the agent's queries and actions. + * Which has to be provided by the user each time we start a new chat. + */ + public parameters: Set; + + /** + * Configuration for reducing the chat messages list of the AI agent. + */ + public chatTrimming: AiAgentChatTrimmingConfiguration | null; + + public maxModelIterationsPerCall: number; +} diff --git a/src/Documents/Operations/AI/Agents/AiAgentConfigurationResult.ts b/src/Documents/Operations/AI/Agents/AiAgentConfigurationResult.ts new file mode 100644 index 00000000..d469b111 --- /dev/null +++ b/src/Documents/Operations/AI/Agents/AiAgentConfigurationResult.ts @@ -0,0 +1,4 @@ +export interface AiAgentConfigurationResult { + identifier: string; + raftCommandIndex: number; +} diff --git a/src/Documents/Operations/AI/Agents/AiAgentPersistenceConfiguration.ts b/src/Documents/Operations/AI/Agents/AiAgentPersistenceConfiguration.ts new file mode 100644 index 00000000..40004dc1 --- /dev/null +++ b/src/Documents/Operations/AI/Agents/AiAgentPersistenceConfiguration.ts @@ -0,0 +1,29 @@ +import { IRavenObject } from "../../../../Types/IRavenObject.js"; + +/** + * Configuration for persisting chat history in RavenDB. + * Defines where chat sessions should be stored and optionally how long they should be retained (expiration). + */ +export class AiAgentPersistenceConfiguration implements IRavenObject { + public constructor(conversationIdPrefix?: string, expires?: number) { + if (conversationIdPrefix) { + this.conversationIdPrefix = conversationIdPrefix; + } + if (expires !== undefined) { + this.conversationExpirationInSec = expires; + } + } + + /** + * The prefix of the conversation ID. + * This is typically like "chats/" or "conversations/". + * This allows separation between different types of persisted AI conversations. + */ + public conversationIdPrefix: string; + + /** + * Optional expiration duration. If provided, chat documents will expire (and be deleted) + * automatically after this time has passed since creation. + */ + public conversationExpirationInSec: number; +} diff --git a/src/Documents/Operations/AI/Agents/AiAgentToolAction.ts b/src/Documents/Operations/AI/Agents/AiAgentToolAction.ts new file mode 100644 index 00000000..75794164 --- /dev/null +++ b/src/Documents/Operations/AI/Agents/AiAgentToolAction.ts @@ -0,0 +1,37 @@ +import { IRavenObject } from "../../../../Types/IRavenObject.js"; + +/** + * Represents a tool action that can be invoked by an AI agent. + * Includes metadata such as name, description, and optional parameters schema or sample. + * Tool actions represent external functions whose results are provided by the user + */ +export class AiAgentToolAction implements IRavenObject { + public constructor(name?: string, description?: string) { + if (name) this.name = name; + if (description) this.description = description; + } + + /** + * The name of the tool action. + * This is the function identifier that the AI uses when invoking the tool. + */ + public name: string; + + /** + * The description of the tool action. + * Helps the AI understand when and why to use this action. + */ + public description: string; + + /** + * A sample object representing the parameters for this tool. + * This should be a JSON-formatted string, showing an example of valid parameters. + */ + public parametersSampleObject: string; + + /** + * The JSON schema for the parameters expected by this tool. + * This schema is used to validate and assist the AI in forming correct tool calls. + */ + public parametersSchema: string; +} diff --git a/src/Documents/Operations/AI/Agents/AiAgentToolQuery.ts b/src/Documents/Operations/AI/Agents/AiAgentToolQuery.ts new file mode 100644 index 00000000..6d14b826 --- /dev/null +++ b/src/Documents/Operations/AI/Agents/AiAgentToolQuery.ts @@ -0,0 +1,45 @@ +import { IRavenObject } from "../../../../Types/IRavenObject.js"; + +/** + * Represents a query tool that can be invoked by an AI agent. + * The tool includes a name, description, query string, and parameter schema or sample object. + * When invoked by the AI model, the query is expected to be executed by the server (database), + * and its results provided back to the model. + */ +export class AiAgentToolQuery implements IRavenObject { + public constructor(name?: string, description?: string, query?: string) { + if (name) this.name = name; + if (description) this.description = description; + if (query) this.query = query; + } + + /** + * The name of the tool query. + * This is the identifier used by the AI to reference this specific query. + */ + public name: string; + + /** + * The description of the tool query. + * Used by the AI to understand when to invoke this query. + */ + public description: string; + + /** + * The actual query string (RQL) that represents this tool. + * This query will not be executed by the database when the model requests for this tool. + */ + public query: string; + + /** + * A sample object representing the parameters for this tool. + * This should be a JSON-formatted string, showing an example of valid parameters. + */ + public parametersSampleObject?: string; + + /** + * The JSON schema for the parameters expected by this tool. + * This schema is used to validate and assist the AI in forming correct tool calls. + */ + public parametersSchema?: string; +} diff --git a/src/Documents/Operations/AI/Agents/DeleteAiAgentOperation.ts b/src/Documents/Operations/AI/Agents/DeleteAiAgentOperation.ts new file mode 100644 index 00000000..a3a273ea --- /dev/null +++ b/src/Documents/Operations/AI/Agents/DeleteAiAgentOperation.ts @@ -0,0 +1,70 @@ +import { IMaintenanceOperation, OperationResultType } from "../../OperationAbstractions.js"; +import { DocumentConventions } from "../../../Conventions/DocumentConventions.js"; +import { RavenCommand } from "../../../../Http/RavenCommand.js"; +import { HttpRequestParameters } from "../../../../Primitives/Http.js"; +import { ServerNode } from "../../../../Http/ServerNode.js"; +import { Stream } from "node:stream"; +import { StringUtil } from "../../../../Utility/StringUtil.js"; +import { throwError } from "../../../../Exceptions/index.js"; +import { IRaftCommand } from "../../../../Http/IRaftCommand.js"; +import { RaftIdGenerator } from "../../../../Utility/RaftIdGenerator.js"; +import { AiAgentConfigurationResult } from "./AiAgentConfigurationResult.js"; + +export class DeleteAiAgentOperation implements IMaintenanceOperation { + private readonly _identifier: string; + + public constructor(identifier: string) { + if (StringUtil.isNullOrEmpty(identifier)) { + throwError("InvalidArgumentException", "identifier cannot be null or empty"); + } + this._identifier = identifier; + } + + public get resultType(): OperationResultType { + return "CommandResult"; + } + + public getCommand(conventions: DocumentConventions): RavenCommand { + return new DeleteAiAgentCommand(this._identifier); + } +} + +export class DeleteAiAgentCommand extends RavenCommand implements IRaftCommand { + private readonly _identifier: string; + + public constructor(identifier: string) { + super(); + this._identifier = identifier; + } + + public get isReadRequest(): boolean { + return false; + } + + public createRequest(node: ServerNode): HttpRequestParameters { + const uri = `${node.url}/databases/${node.database}/admin/ai/agent?id=${encodeURIComponent(this._identifier)}`; + + return { + method: "DELETE", + uri + }; + } + + public async setResponseAsync(bodyStream: Stream, fromCache: boolean): Promise { + if (!bodyStream) { + return ""; + } + + let body: string = ""; + const result = await this._defaultPipeline() + .collectBody(b => body = b) + .process(bodyStream); + + this.result = result as AiAgentConfigurationResult; + return body; + } + + public getRaftUniqueRequestId(): string { + return RaftIdGenerator.newId(); + } +} diff --git a/src/Documents/Operations/AI/Agents/GetAiAgentOperation.ts b/src/Documents/Operations/AI/Agents/GetAiAgentOperation.ts new file mode 100644 index 00000000..2c8f4cf8 --- /dev/null +++ b/src/Documents/Operations/AI/Agents/GetAiAgentOperation.ts @@ -0,0 +1,73 @@ +import { IMaintenanceOperation, OperationResultType } from "../../OperationAbstractions.js"; +import { DocumentConventions } from "../../../Conventions/DocumentConventions.js"; +import { RavenCommand } from "../../../../Http/RavenCommand.js"; +import { HttpRequestParameters } from "../../../../Primitives/Http.js"; +import { ServerNode } from "../../../../Http/ServerNode.js"; +import { Stream } from "node:stream"; +import { StringUtil } from "../../../../Utility/StringUtil.js"; +import { throwError } from "../../../../Exceptions/index.js"; +import { AiAgentConfiguration } from "./AiAgentConfiguration.js"; + +export class GetAiAgentsResponse { + public aiAgents: AiAgentConfiguration[]; +} + +export class GetAiAgentOperation implements IMaintenanceOperation { + private readonly _agentId?: string; + + public constructor(agentId?: string) { + if (agentId && StringUtil.isNullOrEmpty(agentId)) { + throwError("InvalidArgumentException", "agentId cannot be empty"); + } + this._agentId = agentId; + } + + public get resultType(): OperationResultType { + return "CommandResult"; + } + + public getCommand(conventions: DocumentConventions): RavenCommand { + return new GetAiAgentCommand(this._agentId); + } +} + +export class GetAiAgentCommand extends RavenCommand { + private readonly _agentId?: string; + + public constructor(agentId?: string) { + super(); + this._agentId = agentId; + } + + public get isReadRequest(): boolean { + return true; + } + + public createRequest(node: ServerNode): HttpRequestParameters { + let uri = `${node.url}/databases/${node.database}/admin/ai/agent`; + + if (this._agentId) { + uri += `?agentId=${encodeURIComponent(this._agentId)}`; + } + + return { + method: "GET", + uri + }; + } + + public async setResponseAsync(bodyStream: Stream, fromCache: boolean): Promise { + if (!bodyStream) { + this.result = new GetAiAgentsResponse(); + return ""; + } + + let body: string = ""; + const result = await this._defaultPipeline() + .collectBody(b => body = b) + .process(bodyStream); + + this.result = result as GetAiAgentsResponse; + return body; + } +} diff --git a/src/Documents/Operations/AI/Agents/RunConversationOperation.ts b/src/Documents/Operations/AI/Agents/RunConversationOperation.ts new file mode 100644 index 00000000..b4327d4b --- /dev/null +++ b/src/Documents/Operations/AI/Agents/RunConversationOperation.ts @@ -0,0 +1,227 @@ +import { IRavenObject } from "../../../../Types/IRavenObject.js"; +import { IMaintenanceOperation, OperationResultType } from "../../OperationAbstractions.js"; +import { DocumentConventions } from "../../../Conventions/DocumentConventions.js"; +import { RavenCommand } from "../../../../Http/RavenCommand.js"; +import { HttpRequestParameters } from "../../../../Primitives/Http.js"; +import { ServerNode } from "../../../../Http/ServerNode.js"; +import { Stream } from "node:stream"; +import { JsonSerializer } from "../../../../Mapping/Json/Serializer.js"; +import { HeadersBuilder } from "../../../../Utility/HttpUtil.js"; +import { StringUtil } from "../../../../Utility/StringUtil.js"; +import { throwError } from "../../../../Exceptions/index.js"; +import { ObjectUtil } from "../../../../Utility/ObjectUtil.js"; +import { IRaftCommand } from "../../../../Http/IRaftCommand.js"; +import { RaftIdGenerator } from "../../../../Utility/RaftIdGenerator.js"; + +export class AiAgentActionRequest implements IRavenObject { + public name: string; + public toolId: string; + public arguments: string; +} + +export class AiAgentActionResponse implements IRavenObject { + public toolId: string; + public content: string; +} + +export class AiUsage implements IRavenObject { + public promptTokens: number = 0; + public completionTokens: number = 0; + public totalTokens: number = 0; + public cachedTokens: number = 0; +} + +export class ConversationResult { + public conversationId: string; + public changeVector: string; + public response: TSchema; + public usage: AiUsage; + public actionRequests: AiAgentActionRequest[]; +} + +interface ConversationRequestBody extends IRavenObject { + parameters?: Record; + actionResponses?: AiAgentActionResponse[]; + userPrompt?: string; +} + +export class RunConversationOperation implements IMaintenanceOperation> { + private readonly _agentId?: string; + private readonly _userPrompt?: string; + private readonly _parameters?: Record; + private readonly _conversationId?: string; + private readonly _actionResponses?: AiAgentActionResponse[]; + private readonly _changeVector?: string; + + public constructor(agentId: string, userPrompt: string, parameters?: Record); + public constructor(conversationId: string, userPrompt?: string, actionResponses?: AiAgentActionResponse[], changeVector?: string); + public constructor( + agentIdOrConversationId: string, + userPrompt?: string, + parametersOrActionResponses?: Record | AiAgentActionResponse[], + changeVector?: string + ) { + if (changeVector !== undefined || Array.isArray(parametersOrActionResponses)) { + // Constructor overload: conversationId-based + if (StringUtil.isNullOrEmpty(agentIdOrConversationId)) { + throwError("InvalidArgumentException", "conversationId cannot be null or empty"); + } + + this._conversationId = agentIdOrConversationId; + this._userPrompt = userPrompt; + this._actionResponses = parametersOrActionResponses as AiAgentActionResponse[]; + this._changeVector = changeVector; + } else { + // Constructor overload: agentId-based + if (StringUtil.isNullOrEmpty(agentIdOrConversationId)) { + throwError("InvalidArgumentException", "agentId cannot be null or empty"); + } + if (StringUtil.isNullOrEmpty(userPrompt)) { + throwError("InvalidArgumentException", "userPrompt cannot be null or empty"); + } + + this._agentId = agentIdOrConversationId; + this._userPrompt = userPrompt; + this._parameters = parametersOrActionResponses as Record; + } + } + + public get resultType(): OperationResultType { + return "CommandResult"; + } + + public getCommand(conventions: DocumentConventions): RavenCommand> { + return new RunConversationCommand( + this._conversationId ?? null, + this._agentId ?? null, + this._userPrompt ?? null, + this._parameters ?? null, + this._actionResponses ?? null, + this._changeVector ?? null, + conventions + ); + } +} + +export class RunConversationCommand extends RavenCommand> implements IRaftCommand { + private readonly _conversationId?: string; + private readonly _agentId?: string; + private readonly _prompt?: string; + private readonly _parameters?: Record; + private readonly _actionResponses?: AiAgentActionResponse[]; + private readonly _changeVector?: string; + private readonly _conventions: DocumentConventions; + + public constructor( + conversationId: string | null, + agentId: string | null, + prompt: string | null, + parameters: Record | null, + actionResponses: AiAgentActionResponse[] | null, + changeVector: string | null, + conventions: DocumentConventions + ) { + super(); + this._conversationId = conversationId ?? undefined; + this._agentId = agentId ?? undefined; + this._prompt = prompt ?? undefined; + this._parameters = parameters ?? undefined; + this._actionResponses = actionResponses ?? undefined; + this._changeVector = changeVector ?? undefined; + this._conventions = conventions; + } + + public get isReadRequest(): boolean { + return false; + } + + public createRequest(node: ServerNode): HttpRequestParameters { + let uri = `${node.url}/databases/${node.database}/ai/agent`; + + if (this._conversationId) { + uri += `?conversationId=${encodeURIComponent(this._conversationId)}`; + } else if (this._agentId) { + uri += `?agentId=${encodeURIComponent(this._agentId)}`; + } + + const requestBody: ConversationRequestBody = {}; + + if (this._parameters) { + requestBody.parameters = this._parameters; + } + + if (this._actionResponses && this._actionResponses.length > 0) { + requestBody.actionResponses = this._actionResponses; + } + + if (this._prompt) { + requestBody.userPrompt = this._prompt; + } + + const bodyJson = ObjectUtil.transformObjectKeys(requestBody, { + defaultTransform: ObjectUtil.pascal, + ignorePaths: [/^Parameters\./], + }); + + const body = JsonSerializer.getDefault().serialize(bodyJson); + + const headers = HeadersBuilder + .create() + .typeAppJson(); + + if (this._changeVector) { + headers.with("If-Match", this._changeVector); + } + + return { + method: "POST", + uri, + body, + headers: headers.build() + }; + } + + public async setResponseAsync(bodyStream: Stream, fromCache: boolean): Promise { + if (!bodyStream) { + return ""; + } + + let body: string = ""; + const result = await this._defaultPipeline() + .collectBody(b => body = b) + .process(bodyStream); + + this.result = this._convertResult(result, this._conventions); + return body; + } + + private _convertResult(response: any, conventions: DocumentConventions): ConversationResult { + const result = new ConversationResult(); + + if (response.conversationId) { + result.conversationId = response.conversationId; + } + + if (response.changeVector) { + result.changeVector = response.changeVector; + } + + if (response.response) { + result.response = response.response; + } + + if (response.usage) { + result.usage = response.usage; + } + + if (response.actionRequests && Array.isArray(response.actionRequests)) { + result.actionRequests = response.actionRequests; + } + + return result; + } + + public getRaftUniqueRequestId(): string { + return RaftIdGenerator.newId(); + } +} diff --git a/src/Documents/Operations/AI/Agents/index.ts b/src/Documents/Operations/AI/Agents/index.ts new file mode 100644 index 00000000..42a357a0 --- /dev/null +++ b/src/Documents/Operations/AI/Agents/index.ts @@ -0,0 +1,21 @@ +export { AiAgentConfiguration } from "./AiAgentConfiguration.js"; +export type { AiAgentConfigurationResult } from "./AiAgentConfigurationResult.js"; +export { AiAgentToolAction } from "./AiAgentToolAction.js"; +export { AiAgentToolQuery } from "./AiAgentToolQuery.js"; +export { AiAgentPersistenceConfiguration } from "./AiAgentPersistenceConfiguration.js"; +export { + AiAgentChatTrimmingConfiguration, + AiAgentSummarizationByTokens, + AiAgentTruncateChat, + AiAgentHistoryConfiguration +} from "./AiAgentChatTrimmingConfiguration.js"; +export { + RunConversationOperation, + ConversationResult, + AiAgentActionRequest, + AiAgentActionResponse, + AiUsage +} from "./RunConversationOperation.js"; +export { GetAiAgentOperation, GetAiAgentsResponse } from "./GetAiAgentOperation.js"; +export { AddOrUpdateAiAgentOperation } from "./AddOrUpdateAiAgentOperation.js"; +export { DeleteAiAgentOperation } from "./DeleteAiAgentOperation.js"; diff --git a/src/index.ts b/src/index.ts index 0aa9fe04..0c492cf0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -113,6 +113,11 @@ export * from "./Documents/Operations/OperationAbstractions.js"; export { CompactDatabaseOperation } from "./Documents/Operations/CompactDatabaseOperation.js"; export { PutConnectionStringOperation } from "./Documents/Operations/ConnectionStrings/PutConnectionStringOperation.js"; export type { PutConnectionStringResult } from "./Documents/Operations/ConnectionStrings/PutConnectionStringOperation.js"; + +// AI Operations +export * from "./Documents/AI/index.js"; +export * from "./Documents/Operations/AI/Agents/index.js"; + export { PatchOperation } from "./Documents/Operations/PatchOperation.js"; export { DeleteSorterOperation } from "./Documents/Operations/Sorters/DeleteSorterOperation.js"; export { PutSortersOperation } from "./Documents/Operations/Sorters/PutSortersOperation.js";