Heapstore

Heapstore is a Unisave component that lets you access the database directly from the game client. It is the best choice for storage of data, when you don't need complicated database queries or backend logic. As it can be used together with facets and entities, it's best to start using Unisave with Heapstore and then learn the other, lower-level approaches when needed.

[image of how the game client directly reads and writes
documents in the database over the internet, through heapstore]

Database structure

The ArangoDB database used by Unisave organizes data into collections and documents.

A database document is a JSON object, storing data about some entity (e.g. a player). A document might look like this:

{
    "_id": "players/567349",
    "_key": "567349",
    "_rev": "_fJRP0i---",
    "name": "John Doe",
    "score": 42,
    "isBanned": false,
    "registeredAt": "2023-05-31T08:21:40Z"
}

You can define your own fields (like name, score, isBanned, createdAt) and store your data in them. Each document might have different fields and you specify them when the document is created or updated.

Documents are organized into collections. Collection is a list of documents that somehow belong together. You can, for example, create a collection named players for all your registered players.

[image of the collection-document-data structure]

A document is identified by its _key. It has to be unique within the collection and can either be provided explicitly, or is auto-generated by the database during document creation. Since you typically want to also know the document's collection, it's best to use the _id field to identify a document. The _id field is simply the collection name and document key separated by a slash.

Enabling Heapstore

Heapstore is by default disabled, since without any security rules, it allows unrestricted access to the database.

To enable Heapstore, open the Unisave window Tools > Unisave > Unisave Window, go to the Backend Code tab, and in the list of disabled backend folders enable HeapstoreBackend.

[screenshot of what needs to be clicked]

Alternatively you can navigate to the backend folder definition file and set uploading to always:

Assets/Plugins/Unisave/Heapstore/Backend/HeapstoreBackend.asset

Warning: Make sure you specify proper security rules before you release your game. Without security rules, any client can modify and read all documents in the database.

Interacting with Heapstore

Heapstore was designed to be used from inside MonoBehaviour scripts.

The following code can be used to insert new documents into a collection:

using System;
using UnityEngine;
using Unisave.Heapstore;
using LightJson;

class MyEventLogger : MonoBehaviour
{
    public void LogEvent(string name)
    {
        this.Collection("events")
            .Add(new JsonObject {
                ["name"] = name,
                ["time"] = DateTime.UtcNow
            });
    }
}

Notice the using Unisave.Heapstore; statement. It is needed for the this.Collection(...) method to be available.

Handling the response

If you want to get the created document back and store it in a variable, you can define a Then callback to be called after the operation finishes.

using System;
using UnityEngine;
using Unisave.Heapstore;
using LightJson;

class MyEventLogger : MonoBehaviour
{
    public void LogEvent(string name)
    {
        Debug.Log("Storing event...");

        this.Collection("events")
            .Add(new JsonObject {
                ["name"] = name,
                ["time"] = DateTime.UtcNow
            })
            .Then(document => {
                Debug.Log(document);
            });
        
        Debug.Log("End of the function.");
    }
}

Don't forget that the operation takes some time (around 0.1 seconds). The Then callback only registers the code to be called after the operation finishes. This code is called long after the function finishes. The log from the code above would be:

[18:16:55] Storing event...
[18:16:55] End of the function.
(~0.1 second pause)
[18:16:56] {"name":"levelFinished","time":"2023-05-31T18:56:34.000Z"}

Asynchronous programming

TODO: Asnyc-await description should be moved to a separate documentation page. Link it from here and here keep only the very basics.

Using the Then callback might be ok for simple operations, but when you want to chain a series of operations, the code would get really messy:

void MyFunction()
{
    DoSomething()
        .Then(a => {
            DoSomething()
                .Then(b => {
                    DoSomething()
                        .Then(c => {
                            // oh, man!
                        });
                });
        });
}

What you really want is something like this:

void MyFunction()
{
    var a = DoSomething();
    var b = DoSomething();
    var c = DoSomething();
}

Luckily, C# has the keywords async and await that let you write asynchronous code almost like the regular synchronous code you are used to. You just need to mark MyFunction to be an async (asynchronous) function and then await all the inner operations:

async void MyFunction()
{
    var a = await DoSomething();
    var b = await DoSomething();
    var c = await DoSomething();
}

The C# compiler translates this to something very similar to the Then chain.

Note: If you are familiar with Unity coroutines, then think of async void like IEnumerable and similarly of await like yield return. Unity executes asynchronous functions just like coroutines, except that they cooperate better with C#.

We can now rewrite our document-inserting code to use async-await:

using System;
using UnityEngine;
using Unisave.Heapstore;
using LightJson;

class MyEventLogger : MonoBehaviour
{
    // /!\ async /!\
    public async void LogEvent(string name)
    {
        Debug.Log("Storing event...");

        //              /!\ await /!\
        Document document = await this
            .Collection("events")
            .Add(new JsonObject {
                ["name"] = name,
                ["time"] = DateTime.UtcNow
            });
        
        Debug.Log(document);
        
        Debug.Log("End of the function.");
    }
}

The log from the code above will be:

[18:16:55] Storing event...
(~0.1 second pause inside 'await')
[18:16:56] {"name":"levelFinished","time":"2023-05-31T18:56:34.000Z"}
[18:16:56] End of the function.

When using Heapstore, it is common that you want to make a series of operations, one after the other. Say, fetching a document, reading its content, then fetching some other document. For this reason will the rest of this documentation use only the async-await approach.

Working with a document

A document is fully identified by its ID. We can use IDs to read and write specific documents. We can either get to a document in two steps, specifying a collection and the key, or in one step, specifying only the ID:

using System;
using UnityEngine;
using Unisave.Heapstore;

class MyController : MonoBehaviour
{
    async void Start()
    {
        // specify collection and document key separately
        var result = await this
            .Collection("players")
            .Document("john")
            .DO_SOME_ACTION(); // Get, Set, Delete
        
        // or specify only the document ID
        var result = await this
            .Document("players/john")
            .DO_SOME_ACTION(); // Get, Set, Delete
        
        Debug.Log(result);
    }
}

Get

To read a document, you use the Get() method:

Document document = await this
    .Document("players/john")
    .Get();

If the document does not exist, the method will return a null. If the document exists, the returned object can be used to retrieve the document contents.

You can get the document metadata:

Debug.Log(document.Id); // "players/john"
Debug.Log(document.Collection); // "players"
Debug.Log(document.Key); // "john"

Note: If the collection does not exist, it is treated like if the document did not exist and null is returned.

Converting document to custom type

Imagine this is what the database document looks like:

{
    "_id": "players/john",
    "_key": "john",
    "_rev": "_fJRP0i---",
    "name": "John Doe",
    "score": 42,
    "isBanned": false,
    "registeredAt": "2023-05-31T08:21:40Z"
}

The fetched document contains information about a player. You can define a Player class and convert the document into that class instance.

using System;
using Unisave;

class Player
{
    [SerializeAs("_id")]
    public string id;

    public string name;
    public int score;
    public bool isBanned;
    public DateTime registeredAt;
}

To convert the document, use the As<T>() method:

Document document = await this
    .Document("players/john")
    .Get();

Player player = document.As<Player>();

Debug.Log(player.name); // "John Doe"
Debug.Log(player.id); // "players/john"

It is not necessary to specify the id field in the Player class, but it is useful if you want to later save the document, because for saving you need the document ID again.

The [SerializeAs("...")] attribute lets you name the C# class fields differently than the JSON object fields.

You can shorten the code above if you don't need the Document but only need the Player by using GetAs<T>():

Player player = await this
    .Document("players/john")
    .GetAs<Player>();

Working with raw JSON data

You can also work directly with the returned Document object and read and modify it as raw JSON. You can use the Data property that returns the document contents as a JsonObject:

Document document = await this
    .Document("players/john")
    .Get();

// read
string playerName = document.Data["name"];
int playerScore = document.Data["score"];

// modify
document.Data["isBanned"] = true;

Unisave uses the LightJson library and the JsonObject allows data access and modification.

The Data object does not contain the metadata fields, like _id, it contains only the user data.

Set

To write a document to the database you use the Set(...) method:

Document storedDocument = await this
    .Document("players/john")
    .Set(new JsonObject {
        ["name"] = "Johnny Doey",
        ["score"] = 100
    });

The Set method creates the document if it doesn't exist yet. Otherwise it replaces the existing document with the new content. This means that all other fields not specified in the Set method will be removed.

Note: If the collection does not exist, it gets automatically created.

The method also returns the state of the document after the write operation finishes. This is useful if you need to get the new _rev (revision) value.

The Set method accepts any value that can be serialized to a JSON object. This means you can use your custom Player class as well:

Player john = new Player {
    name = "John Doe",
    score = 100
};

await this
    .Document("players/john")
    .Set(john);

Special database fields (_id, _key) that would be present in the Set method body will be ignored. The true ID used is the ID passed to the Document method:

await this
    .Document("players/john") // this one matters
    .Set(new JsonObject {
        ["_id"] = "players/peter", // ignored
        ["name"] = "John"
    });

Update

The method Update is similar to Set, but it updates only the fields that are provided and leaves the others untouched.

Document updatedDocument = await this
    .Document("players/john")
    .Update(new JsonObject {
        ["name"] = "Johnny Doey"
        // score and other fields stay untouched
    });

Debug.Log(updatedDocument.Data["score"]); // 42

If the document does not exist, it will be created with the specified fields.

Add

So far, we've only created documents with fixed IDs. While this might be useful sometimes, in most cases you want to generate document IDs dynamically and then look up documents dynamically by other fields (e.g. looking up players by emails or logins). When a new player registers, you want to generate a new, random ID for them. We can let the database generate the ID for us.

The Add method lets you insert a new document into a collection.

Document peter = await this
    .Collection("players")
    .Add(new JsonObject {
        ["name"] = "Peter",
        ["score"] = 0
    });

Debug.Log(peter.Id); // "players/231687"

Delete

You can also delete a document by its ID, using the Delete method:

bool wasDeleted = await this
    .Document("players/231687")
    .Delete();

Debug.Log(wasDeleted); // true

The method returns true if the document existed and was actually deleted. If the document didn't exist, then no action was performed and false is returned instead.

Document queries

Queries let you specify a subset of documents in a collection and then fetch all of them at once.

Reading entire collection

The simplest query fetches all the documents in a collection:

List<Document> documents = await this
    .Collection("players")
    .Get();

The query returns a list of Document instances.

Note: The shown query is only for demonstration purposes. If there are lots of players, the query would only return the first 1000 of them. If you actually want to go through more than 1000 documents with a query, check out chunking, described below.

Filter

Typically, when you run queries, you want only some of the documents in a collection. This can be achieved by filtering the documents. For example, we can query in-game items that belong to some player:

string somePlayerId = "players/943579";

List<Document> documents = await this
    .Collection("items")
    .Filter("owner", "==", somePlayerId)
    .Get();

This query matches documents, that have the owner field equal to players/943579.

You can also add a middle argument to specify what comparison to perform:

.Filter("field", "==", value)
.Filter("field", "!=", value)
.Filter("field", ">", value)
.Filter("field", ">=", value)
.Filter("field", "<", value)
.Filter("field", "<=", value)
.Filter("field", "IN", arrayOfValues)
.Filter("field", "NOT IN", arrayOfValues)

A query can have multiple filters simultaneously. Only documents that pass all the filters will be returned:

List<Document> mediumPricedItems = await this
    .Collection("items")
    .Filter("price", ">=", 12)
    .Filter("price", "<=", 30)
    .Get();

Getting the first document

Sometimes your query returns at most one document. Say you want to get the player by their nickname. In such cases you can terminate the query with First() instead of Get():

string interestingNick = "John";

Document player = await this
    .Collection("players")
    .Filter("nickname", interestingNick)
    .First();

If the query matches no documents, it returns null. If it matches more than one document, then it returns the first one.

Converting query results to custom type

Let's say you have an Item class:

using System;
using Unisave;

class Item
{
    [SerializeAs("_id")]
    public string id;

    public string title;
    public int price;
    public float power;
}

You can ask the query to convert the results to your type by using GetAs<T>() or FirstAs<T>():

List<Item> mediumPricedItems = await this
    .Collection("items")
    .Filter("price", ">=", 12)
    .Filter("price", "<=", 30)
    .GetAs<Item>();

Item firstItem = await this
    .Collection("items")
    .FirstAs<Item>();

Sort

You can order the query result by a field:

List<Document> cheapestItems = await this
    .Collection("items")
    .Sort("price", "DESC")
    .Get();

The results can be sorted in the descending DESC or ascending ASC order.

By providing a list of tuples, you can sort by multiple fields simultaneously (i.e. when the first one equals, the second is used, and so on):

List<Document> cheapestItems = await this
    .Collection("items")
    .Sort(("price", "DESC"), ("name", "ASC"), ("_id", "ASC"))
    .Get();

Limit

The Limit(...) method can be used to limit the number of results returned by a query. It can be used to implement a leaderboard:

List<Document> topScores = await this
    .Collection("leaderboard")
    .Sort("score", "DESC")
    .Limit(10) // take only top 10
    .Get();

You can also use limit with two arguments Limit(skip, take) to display the next pages of the leaderboard (skip the first 50 documents and take the next 10 documents):

List<Document> topScores = await this
    .Collection("leaderboard")
    .Sort("score", "DESC")
    .Limit(50, 10) // skip 50, take 10
    .Get();

It's better to be explicit, when C# allows it and the code is more readable:

.Limit(skip: 50, take: 10)

Security rules

🚧 Under Construction: Heapstore security rules are currently being implemented so they don't work yet. This documentation describes how they're going to work once completed.

When you first enable Heapstore, it gives you full access to the database from any client. This saves time during development, but is not suitable for production. A malicious user could inspect the code of your game and reverse-engineer custom Heapstore requests that would let them view and modify any data in your database. Possibly deleting your players and ruining your business.

Note: Speaking from experience, about one in 30K players will try to break your game somehow. 💣

Security rules let you control, who has access to what data. They are statements like these:

  • Nobody can view or modify registered players.
  • The logged-in player can view and modify themselves.
  • The logged-in player cannot modify their isBanned field.

These three example rules are translated to actual security rules like this:

Match("players", "*") // collection name, document key (any)
    .Disallow(Operation.All)
    .Comment("Nobody can view or modify registered players.");

Match("players", "*") // collection name, document key (any)
    .Allow(Operation.Read | Operation.Update)
    .If(ctx => Auth.Id() != null && ctx.Document.Id == Auth.Id())
    .Comment("The logged-in player can view and modify themselves.");

Match("players", "*", "isBanned") // collection, document, field
    .Disallow(Operation.Update)
    .If(ctx => Auth.Id() != null && ctx.Document.Id == Auth.Id())
    .Comment("The logged-in player can view and modify themselves.");

Each rule starts with a selector Match(...) that states which collection, document, and field this rule applies to. If it applies to all collections, documents, or fields, you use the asterisk symbol "*".

Security rules have different specificity, so that when you acces a random player, the first, most-general rule applies, but when you access yourself, the second, more specific rule takes precendence.

Each rule has a flag, that is either ALLOW or DISALLOW. When the most specific rule is chosen, its flag determines whether the operation is permitted.

The rule also states which operation types it applies to (reads, writes, etc.) and the rule is ignored if the arriving Heapstore request is of different type.

A rule may contain a condition .If(...) and if that condition fails, the rule is ignored. If it succeeds, the rule is applied, which typically overrides some other, more general rule. The condition is evaluated each time a Heapstore request arrives to the server and is about to be handled.

The condition shown in the example uses the Authentication system of Unisave. The condition checks that someone is logged in Auth.Id() != null and that the logged-in player ID is the ID of the document we are trying to read or update ctx.Document.Id == Auth.Id(). If both conditions hold, we are the logged-in player who is trying to access their own player document and thus we are allowed to perform the operation.

The final .Comment(...) section is useful to make sense of the rule quickly and also for debugging purposes. It is optional, but highly recommended.

Creating security rules

Security rules are enforced on the backend server, which means they need to be uploaded to Unisave during backend server compilation. Unisave automatically uploads code that is located in so-called backend folders. You can create a backend folder in your Assets folder by right-clicking and choosing Create > Unisave > Backend folder.

Inside your backend folder, create a new C# file SecurityRules.cs with this content:

using System;
using Unisave;
using Unisave.Facades;
using Unisave.Heapstore.Backend;

public class SecurityRules : HeapstoreBootstrapper
{
    protected override void DefineSecurityRules()
    {
        Match("*", "*")
            .Disallow(Operation.All)
            .Comment("The database is locked by default.");

        // ... place your security rules here ...
    }
}

You put your security rules inside the DefineSecurityRules method, like the one already defined there.

Locking the database

When there are no security rules, any operation is permitted. In practise we want to go the other way around. Forbid everything and then allow only what is needed. For this reason, the first rule that we want to create is a rule that disallows all operations on the database:

Match("*", "*")
    .Disallow(Operation.All)
    .Comment("The database is locked by default.");

The rule matches all collections and all documents inside those collections and prevents everyone reading and writing data.

Later when we want some data to be publicly readable, we can add more specific rules that allow that. Say we have some configuration data in the configs collection. We can make the collection readable:

Match("configs", "*")
    .Allow(Operation.Read)
    .Comment("Configuration is publicly readable.");

The collection cannot be written or deleted because of the first rule. That rule still prevents all operations on all collections, unless overriden by a more specific rule.

Important: The rest of the documentation assumes the database is locked (the first rule is present). Therefore we will define rules such as "anyone can read player nicknames", implicitly assuming that reading any other field is disallowed because of the first security rule.

Static rules

Static rules are the rules without any .If(...) condition. They apply whenever the selector matches the incomming operation. They are useful for controlling what is accessible by the public (meaning players that are not logged in), but not only for that.

Here are a few examples of such rules:

Match("configs", "*")
    .Allow(Operation.Read)
    .Comment("Configuration is publicly readable.");
Match("errors", "*")
    .Allow(Operation.Create)
    .Comment("Anyone can report runtime errors, but not read them.");
Match("players", "*", "nickname")
    .Allow(Operation.Read)
    .Comment("Player nickname is publicly readable");
Match("players", "*", "password")
    .Disallow(Operation.Read)
    .Comment("Player's password hash never leaves the server.");

// NOTE: Even if the player is ourselves and we can read ourselves,
// this rule is more specific than such a rule.

Dynamic rules

Dynamic rules contain the .If(...) condition and that makes them applicable in certain contexts. A dynamic rule typically grants access to some data if something holds. For example, if the client making the request is the owner of that data.

The authentication system

In most cases, the condition will involve the logged-in player and so we need a login system. In Unisave, this is handled by the Authentication system. The system is accessible inside the backend code via the Auth facade and its only job is to remember "who is talking to us - who is sitting in front of the computer". The authentication system remembers the "who" part, by remembering a document ID in the database. The document with that ID represents the "person" or the "account", so this is typically some document from the players collection.

This knowledge is gained by the player logging-in somehow. Either filling out email and password, or using Steam authentication, or plenty of other ways. These credentials are verified by the backend server and when everything is fine the verification logic calls Auth.Login("players/123456"). It tells the authentication system, that this is the player that is now logged in.

We can then somewhere else (say in our security rules) ask the authentication system, who is logged in right now:

string documentId = Auth.Id(); // players/123456

If nobody is logged in, the authentication system returns null.

Note: Notice that the authentication system does not verify passwords, or talk to Steam. It really only remembers a document ID. That verification logic is complicated and depends on the authentication method, so it's split up into separate systems that you can choose to use, or implement on your own. Heapstore is, however, not capable enough to implement this, so you will need to use Facets instead.

Login-based restrictions

We already have the database locked, so nobody can read any player data. Now we want to let the logged-in player to read themselves. We can define the following security rule:

Match("players", "*")
    .Allow(Operation.Read)
    .If(ctx => Auth.Id() != null && ctx.Document.Id == Auth.Id())
    .Comment("A player can read themselves.");

Without the .If(...) condition, this rule would grant access to everyone to read any player they want. But this condition checks the incoming read request and asks, what is the ID of the document we are trying to read ctx.Document.Id. And if that ID is the same ID as the player currently logged-in, the read is allowed.

Note: The variable ctx means context, i.e. the context of the incoming Heapstore request (what operation, what document(s), etc...).

The part Auth.Id() != null is strictly not necessary, because there will never be a document with ID null. But it explicitly states the fact that we want someone to be logged in, which may be necessary in other login-related security rules. So it's a good practise to include the check.

Similarly, we might have documents that are "owned" by some player. Say a player owns some motorbikes. We can allow the logged-in player to read their own motorbikes:

Match("motorbikes", "*")
    .Allow(Operation.Read)
    .If(ctx => Auth.Id() != null && ctx.Document["owner"] == Auth.Id())
    .Comment("A player can read their own motorbikes.");

The part ctx.Document["owner"] returns the value of the owner field of the document about to be read.

Note: Notice that the Auth.Id() != null check here makes sense. Say we have some motorbikes that are not owned by anyone (say they have been confiscated and belong to the game developer). It's logical that their owner is null. Not adding this check would give the non-authenticated player access to these motorbikes.

Accessing database inside the condition

When a Heapstore operation comes to the server, the .If(...) condition is executed once for every rule that matches that operation. This means if there are 10 related rules, there are 10 condition executions. Even for queries that return hundreds of documents, see Query operation for more info. This means we can safely access the database from inside the rules and the performance won't suffer.

This lets us, for example, get the role of the logged-in player to test for more complicated situations. Say, an admin player can read and modify everything about all players:

async Task<bool> IsAdmin(SecurityRuleContext ctx)
{
    // someone has to be logged-in
    if (Auth.Id() == null)
        return false;
    
    // get the logged-in player
    Document player = await ctx.Heapstore
        .Document(Auth.Id())
        .Get();
    
    // is the logged-in player an admin?
    return player.Data["isAdmin"] == true;
}

Match("players", "*")
    .Allow(Operation.Read | Operation.Update)
    .If(IsAdmin)
    .Comment("Admins can read and modify every player.");

Now the condition is more complicated so we extract it into a separate IsAdmin function. We can access the database via the same Heapstore interface like we do from the game client, we just do await ctx.Heapstore instead of await this inside a MonoBehaviour. Since we are running inside the backend server this database request will bypass security rules and can access anything. Lastly, because now we need to await the database call, the IsAdmin function is async. So instead of returning bool we return Task<bool> (the same thing, just for an asynchronous function).

For more information on how to use Heapstore in the backend code, see Using Heapstore in your backend code.

Field-level readability

Query operation

TODO: query versus get, read and the value-domain conditions

Writing operations

Create

Update

Delete

Explicit document key

Rule specificity

  • by selector
  • has condition
  • order

Using Heapstore in your backend code

🚧 Under Construction: Backend-code API is currently being implemented so it doesn't work yet. This documentation describes how it's going to work once completed.

TODO: see Accessing database inside the condition to get started.

Limitations

  • Query
    • a single query can return no more then 1000 documents
    • one query works with one collection only, joins are not supported
  • Filter
    • filter clauses can only be in conjunction (you cannot say A or B, only A and B)
      • you can do multiple queries and join the results at the client
    • there can be at most 20 of filter clauses in a query
    • for IN and NOT IN, the array can have at most 20 items
  • Sort
    • sort at most by 20 fields

API Reference

CollectionReference
Identifies one collection of the database. Lets you access documents and perform queries.

Documents

DocumentReference
Identifies one document in the database (which may not exist though). The reference can be used to retrieve DocumentSnapshot instances and to create, update, or delete the document.

DocumentSnapshot
Represents a document at a specific point in time. It contains extra metadata about changes if there was some previous snapshot. It is never null, missing document is represented by a special state. It is also implicitly castable to Document.

Document
Similar to DocumentSnapshot, just missing the extra metadata. It is basically a glorified JsonObject. It's designed to be used in simpler usecases where temporal document differences are not needed to be watched. Also, here, non-existing document is represented by null.

It is primarily useful for queries that return at most one document. These queries can be executed by calling .First(), which cannot return an "empty" DocumentSnapshot, since a snapshot needs to know the document ID. But is not known for such a query.

Queries

Query
Identifies a list of documents in the database. It can be used to retrive them from the database as a QuerySnapshot instance. Analogous to DocumentReference, but for multiple documents.

QuerySnapshot
Represents the value of a query at a specific point in time. It conains extra metadata about changes from a possible previous snapshot. It is never null. Analogous to DocumentSnapshot, but for multiple documents. Implicitly castable to List<DocumentSnapshot>.

Exception Codes

Any Heapstore operation might throw a Unisave.Heapstore.Backend.HeapstoreException. This exception has the ErrorNumber integer value, that identifies what has gone wrong:

General errors

0 - ERROR_NO_ERROR
No error has occured.

1 - ERROR_FAILED
Will be raised when a general error occured.

2 - ERROR_INTERNAL
Will be raised when an internal error occured.

3 - ERROR_FORBIDDEN
Will be raised when you are missing permissions for the operation.

4 - ERROR_DISABLED
Will be raised when the Heapstore system is disabled. Typically raised when you forget to enable uploading of the Heapstore backend folder.

5 - ERROR_CANNOT_CONNECT
Will be raised when the server cannot be reached.

6 - ERROR_NOT_IMPLEMENTED
Will be raised when the requested operation is not implemented.

Document API errors

1000 - ERROR_DOCUMENT_MISSING
Will be raised when working with a document that does not exist (operations Get, Set, Update) and throwing is enabled. It is thrown either when the document does not exist, or the collection does not exist.

1001 - ERROR_COLLECTION_MISSING
Will be raised when adding a document into a collection that does not exist (operation Add) and throwing is enabled.

Query API errors

2000 - ERROR_QUERY_FILTER_INVALID_OPERATOR
Will be raised if the used filter clause contains unknown comparison operator.

2001 - ERROR_QUERY_FILTER_TOO_MANY_CLAUSES
Will be raised if there are too many filter clauses.

2002 - ERROR_QUERY_FILTER_IN_ARRAY_TOO_LARGE
Will be raised if the IN and NOT IN operator arguments contain too many options.

2003 - ERROR_QUERY_SORT_INVALID_DIRECTION
Will be raised if the sort direction value is invalid.

2004 - ERROR_QUERY_SORT_TOO_MANY_FIELDS
Will be raised if the sort clause contains too many fields to sort by.

Security rules errors

3000 - ERROR_RULE_...

Developer notes

🚧 This section lists what could be added in the future (but also may never be added).

Large responses and chunking
Design some API that will return AsyncEnumerable<T>, allow changing chunk size and do the paging by "last document ID", so that collection modifications do no re-iterate some elements (simple Limit would be vulnerable).

Connectivity outage exceptions
There already are such exceptions, see General errors. What is needed is some documentation section that will go into the detail on the strategy of handling connection outages properly and describes what can typically go wrong.

Offline mode
Firebase lets the client operate event when the connection to the server is lost. It stores modifications locally and syncs when the connection is regained. This would be a useful feature for mobile games.

Query listening
The ability to get changes to a query result in real time, as writes reach the database. When the change can be propagated immediately, it would use broadcasting. It would then use periodic polls (say 30s) to sync with external database changes (that don't go through Heapstore or are difficult to judge their impact on documents and queries).

Server-side API
Make unrestricted Heapstore calls from inside backend code. With similar API as the client code.

Collection creation & indexes
When an ArangoDB collection is created, it has no indices and is a document collection by default. There should be a configuration option to specify the collection type and also list the desired indexes to be created (and kept synchronized with the configuration).

Other smaller things

  • interop with entities (createdAt, updatedAt) and entities over any collection name
  • set/delete carefully that checks revisions and works only in online mode
  • serialization of anonymous types new { foo = 42 }