Create maintain and enhance, the Best database, for JavaScript Developers!
- A graph is a natural data storage technique, that connects nodes (neurons) with relationships (synapses) 🧠
- Ace unites the following lovely features, in a free, graph db:
- Embeded (no network latency between the application server and the database)
- Memory Storage (query millions of nodes in less then 9ms 😳)
- File Storage (append memory storage to files)
- Transactions (start, continue, cancel or complete txn's)
- Queue (provides read and write concurrency)
- Schema (define data shape w/ JSON)
- Migrations (simple and powerful schema migrations within and between environments)
- TypeScript and JSDoc (provides helpful editor intellisense & is based on your schema)
- CLI (geneate types, perform migrations and much more)
- Data Types (string, number, boolean, iso, hash, encrypt)
- ORM (the query language and the ORM are bundled in the typesafe function,
ace())
- Download Ace
- To get started you'll need an existing NodeJS application, or in bash start a new application with
npm initornpm create vite@latest - As a dependency in you
package.jsonadd"@ace/db": "git+https://github.com/acedatabasefoundation/db.git",- There are more features I'd love to add before putting this on NPM
- Bash:
npm i
- To get started you'll need an existing NodeJS application, or in bash start a new application with
- Create a WhatsApp Graph
await ace({
dir,
env,
req: {
do: 'SchemaAdd',
how: {
nodes: { // Nodes we'd love to add to the schema
User: {
// B/c the dataTyps is encrypt, the name witten to file (the graph) will be encrypted and we can still do things like find all users with a name of Chris
name: { is: 'Prop', options: { dataType: 'encrypt', mustBeDefined: true } },
// Even though the dataType is encrypt, we can still place unique index to ensure get users by email is fast
email: { is: 'Prop', options: { dataType: 'encrypt', uniqueIndex: true } },
// B/c the dataType is hash, to file (the graph) the password will be encrypted, it cannot be decrypted but we can check if a provided password is correct
password: { is: 'Prop', options: { dataType: 'hash', uniqueIndex: true } },
// B/c the default is true, if we do an insert without an isAwesome prop it will default to true
isAwesome: { is: 'Prop', options: { dataType: 'boolean', default: true } },
// The iso dataType is an isoString (date time string format that includes timezone) & the default now means that when a User is added to the graph, if a createdAt is not provided it will be the now timestamp
createdAt: { is: 'Prop', options: { dataType: 'iso', mustBeDefined: true, default: 'now' } },
// sortIndex will ensure reads are fast wheb we ask Ace for users sorted by age
age: { is: 'Prop', options: { dataType: 'number', sortIndex: true } },
// BidirectionalRelationshipProp means from both perspectives of the relationship, we use the same. Example: We are friends and from your and my perspective, we are friends
friends: { is: 'BidirectionalRelationshipProp', options: { has: 'many', node: 'User', relationship: 'isFriendsWith' } },
// ForwardRelationshipProp means from this properties (following) perspective of the relationship (isFollowing) we are in the direction of the relationship name. Example: I follow you, from my perspective I am following and from your perspective I am a follower
following: { is: 'ForwardRelationshipProp', options: { has: 'many', node: 'User', relationship: 'isFollowing' } },
// ReverseRelationshipProp means from this properties (followers) perspective of the relationship (isFollowing) this property is backwards with the relationship name
followers: { is: 'ReverseRelationshipProp', options: { has: 'many', node: 'User', relationship: 'isFollowing' } },
},
},
relationships: { // Relationships we'd love to add to the schema
isFriendsWith: { is: 'ManyToMany' },
isFollowing: { // relationships can also have props
is: 'ManyToMany',
props: { // relationship props must start with an underscore, this is super helpful when it comes to queries so that it's easy to identify if the prop is a node prop (does not start with an underscor) or a relationship prop
_createdAt: { is: 'RelationshipProp', options: { dataType: 'iso', default: 'now' } }
}
},
}
}
}
})- Add 2 User nodes to graph
- Add a relationship between the 2 nodes
- Details about the example below:
- When an id starts with
_:this is an enum identifying id which can be used in relaitonships - For a relationship insert we need to know the direction, who is following who, in this case
ChrisisInLoveWithMercyb/cais followingb reqis shown as an array in the example below, if you'd love Ace to do 1 thing use an object & if you'd love Ace to do multiple things use an array and the items will be done in the provided order
- When an id starts with
await ace({
dir,
env,
req: [
{ do: 'NodeInsert', how: { node: 'User', props: { id: '_:chris', name: 'Chis' } } },
{ do: 'NodeInsert', how: { node: 'User', props: { id: '_:mercy', name: 'Mercy' } } },
{ do: 'RelationshipInsert', how: { relationship: 'isFollowing', props: { a: '_:chris', b: '_:mercy' } } }
]
})- All the options below are with
Update, so if theiddoes not exist an error will throw, to avoid the throw, switchUpdateforUpsertbelow
- At
id: 1update the name of that node toChristopher - At
id: 2update the name of that node toMiss Lovely - At
id: 3update the direction of that relationship
await ace({
dir,
env,
req: [
{ do: 'NodeUpdate', how: { node: 'User', props: { id: 1, name: 'Chistopher' } } },
{ do: 'NodeUpdate', how: { node: 'User', props: { id: 2, name: 'Miss Lovely' } } },
{ do: 'RelationshipUpdate', how: { relationship: 'isFollowing', props: { id: 3, a: '_:mercy', b: '_:chris' } } }
]
})- Here are the many different ways we may delete data:
await ace({
dir,
env,
req: [
{ do: 'NodeDelete', how: [ 1, 2 ] }, // delete the nodes that have an id of 1 and 2
{ do: 'NodePropDelete', how: { ids: [ 3, 4 ], props: [ 'name' ] } }, // delete the prop of name from nodes 3 and 4
{ do: 'RelationshipDelete', how: { _ids: [ 5 ] } }, // delete the relationship with _id 5
{ do: 'RelationshipPropDelete', how: { _ids: [ 6 ], props: [ '_createdAt' ] } }, // delete the _createdAt prop from _id 6
{ do: 'SchemaDeleteNodes', how: [ 'Actor' ] }, // Delete the Actor node in the schema AND delete all Actor data from graph
{ do: 'SchemaDeleteNodeProps', how: [ { node: 'Actor', prop: 'name' } ] }, // Delete the name prop from the Actor node in the schema AND delete all Actor > name props in the graph
]
})- Notice the
aliasbelow atreq > how > resValue > name?! - In the schema the property is
namebut in the response it will befullName - Note:
reqin the code example above is an array but below is an object, if you'd love Ace to do 1 thing use an object & if you'd love Ace to do multiple things use an array and the items will be done in the provided order
const { products } = await ace({
dir,
env,
req: {
do: 'NodeQuery',
how: {
node: 'Movie',
resKey: 'movies',
resValue: {
id: true,
name: { alias: 'fullName' },
}
}
}
})- Notice the
'*'@resValuebelow?! - A value of
'*'as theresValuewill ensure that all the none relationship properties for a movie are included - A value of
'**'as theresValuewill ensure that all the none relationship properties for a movie are included and one level deep of relationship props (and all their none relationship properties will be included) - A value of
'***'as theresValuewill ensure that all the none relationship properties for a movie are included and TWO levels deep of relationship props (and all their none relationship properties will be included)
const { products } = await ace({
dir,
env,
req: {
do: 'NodeQuery',
how: {
node: 'Movie',
resKey: 'movies',
resValue: '*'
}
}
})- Ace can query nodes (meaning we start from a node) like the example above where we start at the
moviesnode or can start at a relationship like this: - Relationships sit between 2 nodes, so when we query by a relationship the first 2 props point to the 2 nodes this relationship sits between
const { hasStaredIn } = await ace({
dir,
env,
req: {
do: 'RelationshipQuery',
how: {
relationship: 'hasStaredIn',
resKey: 'hasStaredIn',
resValue: {
movie: '*',
actor: '**'
}
}
}
})- Find Node by
id
const { users } = await ace({
dir,
env,
req: {
do: 'NodeQuery',
how: {
node: 'User',
resKey: 'users',
resValue: {
$o: { findById: 1 },
id: true,
name: true,
}
}
}
})- Find the first user that has a name of
Chris
const { users } = await ace({
dir,
env,
req: {
do: 'NodeQuery',
how: {
node: 'User',
resKey: 'users',
resValue: {
$o: { findByPropValue: [ { prop: 'name' }, 'equals', 'Chris' ] },
id: true,
name: true,
}
}
}
})- Find the first user that has a name of
Chrisand an email that ends in@gmail.com - To do this as an
orswitchfindByAndtofindByOr
const { users } = await ace({
dir,
env,
req: {
do: 'NodeQuery',
how: {
node: 'User',
resKey: 'users',
resValue: {
$o: { findByAnd: [ [ { prop: 'name' }, 'equals', 'Chris' ], [ { prop: 'email' }, 'endsWith', '@gmail.com' ] ] },
id: true,
name: true,
email: true,
}
}
}
})- Filter users that have a name of
Chris
const { users } = await ace({
dir,
env,
req: {
do: 'NodeQuery',
how: {
node: 'User',
resKey: 'users',
resValue: {
$o: { filterByPropValue: [ { prop: 'name' }, 'equals', 'Chris' ] },
id: true,
name: true,
}
}
}
})- Filter users that have a name of
Chrisand an email that ends in@gmail.com - To do this as an
orswitchfilterByAndtofilterByOr
const { users } = await ace({
dir,
env,
req: {
do: 'NodeQuery',
how: {
node: 'User',
resKey: 'users',
resValue: {
$o: { filterByAnd: [ [ { prop: 'name' }, 'equals', 'Chris' ], [ { prop: 'email' }, 'endsWith', '@gmail.com' ] ] },
id: true,
name: true,
email: true,
}
}
}
})- Skip the first 9 users and then show the next 9 users
const { users } = await ace({
dir,
env,
req: {
do: 'NodeQuery',
how: {
node: 'User',
resKey: 'users',
resValue: {
$o: { limit: { count: 9, skip: 9 } },
id: true,
name: true,
}
}
}
})- Sort users
ascby name - To sort descending switch
ascfordsc
const { users } = await ace({
dir,
env,
req: {
do: 'NodeQuery',
how: {
node: 'User',
resKey: 'users',
resValue: {
$o: { sort: { how: 'asc', prop: 'name' } },
id: true,
name: true,
}
}
}
})- There can be multiple options in
$o - There is a default order that Ace does the options (default order be found here @ defaultQueryOptionsFlow)
- For example the default flow is to do sort then limit, but if you'd love limit then sort:
const { users } = await ace({
dir,
env,
req: {
do: 'NodeQuery',
how: {
node: 'User',
resKey: 'users',
resValue: {
$o: {
flow: [ 'limit', 'sort' ],
limit: { count: 9 },
sort: { how: 'asc', prop: 'name' }
},
id: true,
name: true,
}
}
}
})- Add a
count(of all users) property to each user object in the response:
const { users } = await ace({
dir,
env,
req: {
do: 'NodeQuery',
how: {
node: 'User',
resKey: 'users',
resValue: {
$o: { countAsProp: 'count' },
id: true,
name: true,
}
}
}
})- Add a
countproperty ADJACENT to theusersproperty - Notice the const is now
const { users, userCount }?!
const { users, userCount } = await ace({
dir,
env,
req: {
do: 'NodeQuery',
how: {
node: 'User',
resKey: 'users',
resValue: {
$o: { countAdjToRes: 'userCount' },
id: true,
name: true,
}
}
}
})- At the provided
resKeygive thecount:
const { userCount } = await ace({
dir,
env,
req: {
do: 'NodeQuery',
how: {
node: 'User',
resKey: 'userCount',
resValue: {
$o: { countAsRes: true },
}
}
}
})- Add a
totalRevenue(of all accounts) property to each account object in the response:
const { accounts } = await ace({
dir,
env,
req: {
do: 'NodeQuery',
how: {
node: 'BankAccount',
resKey: 'accounts',
resValue: {
$o: {
sumAsProp: { computeProp: 'revenue', newProp: 'totalRevenue' }
},
id: true,
revenue: true,
}
}
}
})- Get the sum revenue and then place it at the provided
resKey:
const { totalRevenue } = await ace({
dir,
env,
req: {
do: 'NodeQuery',
how: {
node: 'BankAccount',
resKey: 'totalRevenue',
resValue: {
$o: { sumAsRes: 'revenue' }
}
}
}
})- Add an
avgRevenue(of all accounts) property to each account object in the response:
const { accounts } = await ace({
dir,
env,
req: {
do: 'NodeQuery',
how: {
node: 'BankAccount',
resKey: 'accounts',
resValue: {
$o: {
avgAsProp: { computeProp: 'revenue', newProp: 'avgRevenue' }
},
id: true,
revenue: true,
}
}
}
})- Get the average revenue and then place it at the provided
resKey:
const { avgRevenue } = await ace({
dir,
env,
req: {
do: 'NodeQuery',
how: {
node: 'BankAccount',
resKey: 'avgRevenue',
resValue: {
$o: { avgAsRes: 'revenue' }
}
}
}
})- All examples below will use
min - If you'd love
maxjust switchmintomaxanywhere you seeminin the code examples - Calculate lowest value for the
revenueprop (of all accounts) and then add theminRevenuethat will hold this lowest value to each account object in the response:
const { accounts } = await ace({
dir,
env,
req: {
do: 'NodeQuery',
how: {
node: 'BankAccount',
resKey: 'accounts',
resValue: {
$o: { minAmtAsProp: { computeProp: 'revenue', newProp: 'minRevenue' } },
revenue: true,
}
}
}
})- Calculate lowest value for the
revenueprop (of all accounts) and then provide that node in the response - Notice how the const changed from
accountsabove toaccountbelow b/c now we are only returning one node (the least revenue node), so we removed thesfromaccountsin theresKey
const { account } = await ace({
dir,
env,
req: {
do: 'NodeQuery',
how: {
node: 'BankAccount',
resKey: 'account',
resValue: {
$o: { minNodeAsRes: 'revenue' },
id: true,
revenue: true,
}
}
}
})- Calculate lowest value for the
revenueprop (of all accounts) and then provide that value as the response:
const { lowestRevenue } = await ace({
dir,
env,
req: {
do: 'NodeQuery',
how: {
node: 'BankAccount',
resKey: 'lowestRevenue',
resValue: {
$o: { minAmtAsRes: 'revenue' }
}
}
}
})- Add a profit column to each account in the response
- The first property in the newProps object is the name of the new props, below it is
profit - In the
profitvalue you seesubtractbelow, the other options are[ add, subtract, multiply, divide ]
const { accounts } = await ace({
dir,
env,
req: {
do: 'NodeQuery',
how: {
node: 'BankAccount',
resKey: 'accounts',
resValue: {
$o: {
newProps: {
profit: { subtract: [ 'revenue', 'expenses' ] },
}
},
revenue: true,
expenses: true,
}
}
}
})- Schema
- Must Be Defined
- Default Value
- Must Be Unique
- Unique Index
- Sort Index
- Relationship Props
- Example: Imagine a graph with an Actor node, a Movie node and a actedIn relationship. An example of a relationship prop would be salary. The prop does not really fit on either node, but perfectly with the relationship
- Encrypt PII
- Can still be searched and filtered on
- Data on file is encrypted
- Create the schema
import { ace } from '@ace/db'
await ace({
// directory that holds graph information
dir: './ace',
// current environment (allows the ability to have different schema versions in different environements) (recommend something like process.env.NODE_ENV here)
env: 'local',
// req array order is the order the graph will be updated
req: [
// empty any existing items in the graph
{ do: 'EmptyGraph' },
// add nodes, relationships and props to schema
{
do: 'SchemaAdd',
how: {
nodes: {
User:
name: { is: 'Prop', options: { dataType: 'string', mustBeDefined: true } }, // User prop
friends: { is: 'BidirectionalRelationshipProp', options: { has: 'many', node: 'User', relationship: 'isFriendsWith' } } // User prop
},
},
relationships: {
isFriendsWith: { is: 'ManyToMany' },
}
}
}
]
})- View updates in
schemasfolder- Navigate to
./ace/schemas/and viewdetails.jsonand1.json - Each schema alteration will create a new file in this directory with the updated schema and update the
details.json
- Navigate to
- In bash do:
ace types- This will update your types to include your updated schema
- Mutate and Query Graph
- Press
Control+Spaceto get intellisense
- Press
const res = await ace({
dir: './ace',
req: [
// insert nodes
{ do: 'NodeInsert', how: { node: 'User', props: { id: '_:Alpha', name: 'Alpha' } } },
{ do: 'NodeInsert', how: { node: 'User', props: { id: '_:Omega', name: 'Omega' } } },
// insert relationships
{ do: 'RelationshipInsert', how: { relationship: 'isFriendsWith', props: { a: '_:Alpha', b: '_:Omega' } } },
// query users
{
do: 'NodeQuery',
how: {
node: 'User',
resKey: 'users', // in the response, @ key users, put the value of this query
resValue: { // how the query value will be formatted
id: true,
name: true,
friends: {
id: true,
name: true,
}
}
}
}
]
})- Log Response
console.log(res)
{
"$ace": {
"enumIds": { // aligns enum id's from mutations above w/ graph id's
"_:Alpha": 1,
"_:Omega": 2
},
},
"users": [ // this is "users" b/c of the resKey above
{
"id": 1,
"name": "Alpha",
"friends": [
{
"id": 2,
"name": "Omega"
}
]
},
{
"id": 2,
"name": "Omega",
"friends": [
{
"id": 1,
"name": "Alpha"
}
]
}
]
}- Data is stored in memory and in the directory you specify on your application server
- This allows for no network latency between your application server and your database
await ace({ dir: './ace', req: [ ... ] }) // dir = the directory, starting from your applications package.json- Memory
- Ace puts writes into a sorted array to allow binary searching later, and an append only file
- If the application server restarts, the array is rebuilt thanks to the append only file
- File
- To the specified directory:
- When a request or a transaction is succesful, data updates are:
- Appended to the append only file
- Writes are fast b/c file append is fast
- Added to the append only file map (memory)
- Appended to the append only file
- When a request or a transaction is succesful, data updates are:
- To the specified directory:
- When a call to
ace()starts a transaction (txn) the response will include atxnIdas seen below - Use the
txnIdto continue, cancel or complete a txn - If a txn has been running for 9 seconds, ace will cancel it, throw a timeout error, and begin the next request in the queue
import { ace } from '@ace/db'
// start txn
const res = await ace({ txn: { do: 'Start' }, dir: './ace', req: { ... } })
// continue txn
await ace({ txn: { id: res.$ace.txnId }, dir: './ace', req: { ... } })
// cancel txn
await ace({ txn: { id: res.$ace.txnId, do: 'Cancel' }, dir: './ace' })
// complete txn
await ace({ txn: { id: res.$ace.txnId, do: 'Complete' }, dir: './ace', req: { ... } })
// completing a txn after cancelling it does not make sense btw, above is just to show all available txn options- If a request or a transaction (txn) is in progress, incoming requests are put into a queue
- The difference between a request and a txn is:
- A request is a call to
ace()that does not start a txn - A txn is a call to
ace()that starts a txn or continues a txn
- A request is a call to
- Specify the nodes and relationships of your graph
- If schema changes are made via
ace(), data changes are applied and a version of the new schema is put into the directory you choose
- With our cli you may simply apply different versions of your schema to your graph
- Thanks to our CLI and your schema
ace typesgenerates types for TypeScript developers and JSDoc typedefs for JavaScript developers - Types are based on your schema and provide guidance during calls to
ace()
ace help
- Show this message
ace jwks
- A jwk (JSON Web Key) is like a password. JWKs helps Ace do cryptography
- Use ACE_PRIVATE_JWK to create a hash, use ACE_PUBLIC_JWK to verify a hash and use ACE_CRYPT_JWK to encrypt and decrypt information
- Ace recommends storing JWKs in your .env file as a string and then closing this terminal window
ace trash:empty
- Empty trash folder
- When ace() EmptyGraph is called, items are moved into the trash folder: [ directory ]/trash/[ timestamp ]
ace token
- The Ace CLI could read and write to the directory that holds your graph without calling your server
- But the server holds a request queue that ensures all requests happen one at a time, in the order they are recieved
- So the Ace CLI calls your graph by calling an endpoint on your server, so that the CLI requests goes into the queue
- To ensure the endpoint to your graph, on your server, is only accessible to the ClI, use this token
- Ace recommends storing this token in your .env file as a string and then closing this terminal window
ace types
- Creates enums, types (TS) and typedefs (JSDoc)
- To access types or enums in your application just add to any file:
- import { td, enums } from "@ace/db"
ace schema:push
- Example:
- Local schema version is 3
- Local application code is pushed to production and includes [ directory ]/schemas/[1,2,3].json
- Goal: Set production schema from version 1 to version 3
- Bash: ace schema:push
- First Ace will update graph data to reflect [ directory ]/schemas/2.json
- Then Ace will update graph data to reflect [ directory ]/schemas/3.json
- Aim version can be above or below the current version
ace version
- Prints your currently downloaded Ace Graph Database Version- string
- number
- boolean
- iso
- Equivalent to:
(new Date()).toISOString()
- Equivalent to:
- hash
- Get jwks via
ace jwks - When doing an inserrt, update, or upsert to
ace()for a hash data type send a private jwk - When doing a match query to
ace()on a hashed value send the public jwk
- Get jwks via
- The shape of the data in the query is the shape of the object in the response
- In the example below:
- The
resKeyor Response Key isproductsso they're stored atconst { products } - Each product will have
id,nameandamountin the response
- The
const { products } = await ace({
env: process.env.NODE_ENV,
dir: process.env.ACE_DIRECTORY,
req: {
do: 'NodeQuery',
how: {
node: 'Product',
resKey: 'products',
resValue: {
id: true,
name: true,
price: { alias: 'amount' },
}
}
}
})