Spined graph universe
Constructor Spined(params)
The constructor of a Spined universe data handler (by convention always named sp
in this documentation) is the imported Spined
module that only need one mandatory parameter: a Redis data source.
import IORedis from 'ioredis'
import Spined from 'spined'
const redis = new IORedis()
const sp = Spined({ redis })
params = { redis, extend?, extendFrom?, options? }
redis
— redis client instance used to store dataextend?
— dimensions model extensionsextendFrom?
— read dimensions extensions from a directoryoptions?
— TODO
Spined handler properties
sp.redis
- Return the redis connection
sp.extend
- Return the current model extensions (merge of constructor parameter and loaded from file)
sp.options
- Return the constructor parameters options
Spined handler methods
JSON
?)
sp.createSingularity(The method stores a JSON data object as a new singularity Entity.
This is the only way for an entity to get the SINGULARITY trait
const paris = await sp.createSingularity({
name: 'Paris,
country: 'FR',
attractions: {
'Eiffel Tower' => '48.8584° N, 2.2945° E',
'Louvre' => '48.8606° N, 2.3376° E',
},
airports: [ 'CDG', 'ORL' ]
});
JSON
— the JSON data object to be stored- Return — Promise of the new created Entity
<$$id>
)
sp.referenceFrame(The method retrieve the implicit Reference Frame associated with the Singularity Entity having the meta id <$$id>
. This type of Reference Frame have only one source, the Singularity Entity itself.
The source Entity will be accessible with alias
default
and0
const ref = await sp.referenceFrame('715980bd-152d-43b8-b958-9e1fae3862a4');
$$id
— an entity meta id (this entity need to have the SINGULARITY trait)- Return — Promise of a Reference Frame
sp.defineReferenceFrame([...entities] | { alias: entity1, ... })
The method can create a complex Reference Frame from one or more source Entities. At least one of the source Entities own Reference Frame sources need to have the OMNISCIENT trait.
The method can simply take as arguments a list of entities. Source aliases in this case will be the index in the list (and also default
for the first source entity).
Another way, is to call the method with a one dimension object with aliases as keys and entities as values. This allow a Reference Frame with custom alias accessor.
const ref = await sp.defineReferenceFrame([ e1, e2]);
// or
const ref = await sp.defineReferenceFrame({
monument: OneTowerEntity,
role: OneArchitectEntity,
});
Spined handler helpers
sp.isReferenceFrame(arg)
- Return true if the argument is a Spined ReferenceFrame
sp.isEntity(arg)
- Return true if the argument is a Spined Entity
sp.isDimension(arg)
- Return true if the argument is a Spined Dimension
sp.isIndex(arg)
- Return true if the argument is a Spined Index
sp.isResultset(arg)
- Return true if the argument is a Spined Resultset
sp.get$$id(arg)
- Return the $$id if the argument is a Spined Entity or the arg itself
Reference Frame
Be sure to check the introduction on the Reference Frame concept
Reference Frame definition
There are only two ways to get a Reference Frame in the graph universe:
- by calling the sp.referenceFrame() method on the graph universe handler to retrieve the implicit reference frame of a Singularity Entity
- by calling the sp.defineReferenceFrame() method on the graph universe handler with one or more Entities
Reference Frame properties
ref[alias]
You can directly get a source entity of a Reference Frame using its corresponding alias. If no aliases where defined, you can access it using the index of the source entities in the list parameters of drawReferenceFrame()
.
ref.default
The first source entity is always accessible via the default
property
ref.isOmniscient
- Return true if the Reference Frame is said to be
Omniscient
. Its the case only if at least one source entities has theOMNISCIENT
trait.
You can only define a new Reference Frame using the sp.defineReferenceFrame()
method from entities inside an Omniscient
Reference Frame.
ref.isUnbounded
- Return true if the Reference Frame is said to be
Unbounded
. Its the case only if at least one source entities has theUNBOUNDED
trait.
You can only change Entities traits (using ent.addTrait$()
or ent.addTrait$()
inside an Unbounded
Reference Frame.
ref.spined
- Return the Spined Graph Universe handler
ref.aliases
- Return the aliases name to access the source entities of the Reference Frame
ref.sources
- Return an Object of all sources entities with aliases as keys.
Entity
Be sure to check the introduction on the Entity concept
In this documentation
ent
is always an Spined specific Entity
Entity creation
There are only three ways to create a new entity:
- by calling the
sp.createSingularity()
method on the graph universe handler - by calling the
ent.duplicate$()
method describe below - by adding a new entity into an existing dimension with the method
dim.createEntity
Entity properties
ent.<any>
The JSON data object keys stored as an Entity automatically defines corresponding getter properties.
const ent = await john.update$({ age: 34, hobbies: ['music', 'painting'] });
console.log(john.name); // logs John Doe
console.log(john.age); // logs 34
console.log(john.hobbies); // logs [ 'music', 'painting' ]
Since all meta properties, directly hanlded by Spined are prefixed with $$, you should avoid using first level properties in the JSON data object to be stored starting with $$. Similarly, all Entity methods are ended with the '$' character, so first level properties should also avoid this pattern. Note that such data would still be saved but with not direct getter using the
ent.<any>
syntax. You may use the syntaxent.$$json.$$<any>
to access them in these cases.
ent.$$id
A unique id is assigned for each newly created Entity. You can access this meta attribute using the handler $$id
.
For this alpha relase of spined, $$id̀
follow the uuid convention.
[ TODO byt not yet implemented : seq / cuid / short uuid / etc ]
ent.$$createdAt
Any new Entity is created with the current timestamp stored in the $$createdAt
meta attribute
ent.$$updatedAt
Anytime an Entity is updated, the current timestamp is retain in the $$updatedAt
meta attribute
ent.$$json
Entity handlers in node.js are not standard JavaScript Object. To get a pure JSON object of the data stored of any Entity you can call the meta attribute $$json
const paris = await sp.createSingularity({
name: 'Paris,
country: 'FR',
airports: [ 'CDG', 'ORL' ]
});
console.log(paris.$$json)
//
//
ent.$$json$$
Shortcut to get $$json
object but with with $$id̀
, $$createdAt
, $$updatedAt
meta attributes value included.
ent.$$traits
Return the list of all Traits of the Entity ent
ent.$$dimensions
Return a Promise of the list of all Dimensions names of the Entity ent
Entity traits
Entity can be decorated by Traits that assert non default behaviors.
SINGULARITY
Only Entities created using the sp.createSingularity()
method have this trait.
Only Singularity Entities can be retrieved only by knowing their $$id to create a new Reference Frame.
A more numdate explanation is that Singularity Entities are the only direct entry points to the graph universe.
OMNISCIENT
Entities created using the sp.createSingularity()
method have this trait by default. This trait can also be added or removed using the ent.addTrait$()
and ent.delTrait$()
methods.
At least one Omniscient Entity need to be in the current Reference Frame for a new Reference Frame being defined using the sp.drawReferenceFrame()
UNBOUNDED
This trait can only be added or removed using the ent.addTrait$()
and ent.delTrait$()
methods.
At least one Omniscient Entity or Unbounded Entity need to be in the current Reference Frame for ent.addTrait$()
and ent.delTrait$()
methods to be available.
Unbounded Reference Frame can also overwrite some meta properties ($$id, $$created, ...) if the graph universe was created with the corresponding options
Warning: NOT FULLY IMPLEMENTED YET
PERPETUAL
This trait can only be added or removed using the ent.addTrait$()
and ent.delTrait$()
methods.
You cannot use the ent.delete$
method on Pertetual Entities.
IMMUTABLE
This trait can only be added or removed using the ent.addTrait$()
and ent.delTrait$()
methods.
You cannot use the ent.update$
method on Immutable Entities.
INVISIBLE
This trait can only be added or removed using the ent.addTrait$()
and ent.delTrait$()
methods.
You cannot use the ent.$$jon
ent.<any>
properties on Invisible Entities. Custom extension properties and methods are still accessible.
Entity CRUD methods
ent.duplicate$()
ent.get$()
ent.update$()
ent.inc$()
ent.delete$()
Entity other methods
ent2
)
ent.be$(Return true if ent2
is strictly the same Entity as ent
(return false for two different Entities with different $$id even if the stored JSON data object is identical).
NOT yet IMPLEMENTED
<name>
)
ent.assignDimension$(Assign a new dimension the the Entity ent
(Return a Promise).
<name>
)
ent.revokeDimension$(Revoke a new dimension the the Entity ent
(Return a Promise).
<name>
)
ent.hasDimension$(A Promise of a boolean, true
if the dimension <name>
of the Entity ent
exists.
<name>
)
ent.dimension$(A Promise of the dimension <name>
of the Entity ent
.
<trait>
)
ent.addTraits$( <trait>
)
ent.delTraits$(Dimension
Be sure to check the introduction on the Dimension concept
In this documentation
dim
is always an Spined specific Dimension
Dimension creation
There is only one way to add a new dimension to a specific Entity :
- by calling the
ent.assignDimension$(<name>)
method on an Entity
Dimension properties
dim.name
Return the name of the dimension dim
.
Dimension names are unique for each Entity
dim.schema
TODO
dim.indexes
TODO
dim.extend
TODO
dim.$$id
Each Dimension has also a unique meta id. You can access it using the handler id
.
Dimension mutation methods
<JSON>
)
dim.createEntity(The method stores a JSON data object as a new Entity and directly add this new Entity to the Dimension dim
.
ent
)
dim.addEntity(Add the existing Entity ent
to the Dimension dim
.
ent
)
dim.removeEntity(Remove the existing Entity ent
to the Dimension dim
.
WARNING, GARBAGE collection is not yet implemented...
dim.addIndex()
dim.removeIndex()
dim.setSchema()
Dimension query methods
ent
|<$$id>
)
dim.has(Return true if the Entity ent
(or the meta id <$$id>
) is part of the dimension dim
.
<$$id>
)
dim.retrieve(Return the Entity with the meta id <$$id>
which is part of the dimension dim
.
dim.all()
Return a new Resultset with all Entities of the dimension dim
.
Dimension alias methods
Some useful shortcuts alias methods are provide as syntaxic sugar:
dim.filter => dim.all().filter
dim.order => dim.all().order
dim.where => dim.all().where
dim.iterate => dim.all().iterate
dim.first => dim.all().first
dim.ids => dim.all().ids
These last two shorcuts provide the same results as their long version but are slightly more optimized when called directly from the dim handler :
- dim.count => dim.all().count
- dim.has => dim.all().has
Index
Be sure to check the introduction on the Index concept
Index creation
There are only two ways to have an Index defined on a Dimension :
- by calling the
dim.addIndex()
method on a Dimension - by adding an Index definition when assigning a new dimension with
ent.
Index definition
In this documentation
idx
is always an Spined specific Index
Index types
You can define different kind of Index on Dimension and they are classified by type. Index types are currently either 'U', 'S' or 'C' which are respectively "SET", "UNIQUE" and "CURSOR" Indexes.
Index indentifier
Index identifier are formed by the concatenation of their type (U
, S
, or C
) the separator :
, an optional Index argument followed by :
and a JSONata query.
Index JSONata query
Indexes are created on a specific JSONata query that map every Entity JSON document to a value from the JSONata query evaluation. In most cases, that would be just the value of a specific property inside the JSON document but any complex of JSONata query is possible.
You can also use the meta properties $$createdAt
and $$updateAt
in the JSONata query.
complex JSONata query may slightly impact performance when you add a very large number of new Entities in batch to an indexed Dimension.
S
(SET)
Index type Type S
Indexes, are just a subset of the Dimension that verify a specific JSONata query (i.e. evaluate to truthy value).
This type of index is well suited to query really fast a Dimension subset that verify a particular criteria. For example, you could set a Index on the "city" property and be able to search or count Entities efficiently on any specific city subset.
<value>
)
idx.all(Return a new Resultset of Entities that verify <value>
for the Index idx
.
S
alias methods
- idx.ids(
<str>
) => idx.all(<str>
).ids - idx.count(
<str>
) => idx.all(<str>
).count - idx.first(
<str>
) => idx.all(<str>
).first - idx.iterate(
<str>
) => idx.all(<str>
).iterate
U
(UNIQUE)
Index type You can declare that all Entities in a Dimension verify a unicity condition for a specific JSONata query result (typically used to guarantee that a property value is unique for each Entities of the Dimension like email
in a "Account" Dimension, but any query can be used).
<value>
)
idx.find(Return a Promise of the Entity for which is indexed unique value is <value>
. Returns null if no entity is found.
<value>
)
idx.findElseThrow(Same as find but throw if no Entity is found.
C
(CURSOR)
Index type Cursor Indexes are used create an ordered set of all Entities in a Dimension. The JSONata query is used to calculate the key to sort all their Entities. The sort order can be alphanumeric or numeric depending the identifier argument, respectively AlphaNum
or Num
.
If you don't mention the sort order mapping, it default to
AlphaNum
except on the meta attributes $$createdAt and $$updatedAt for which it isNum
Cursor Indexes are primaryly used as argument to the rs.order() method.
idx.rank(id)
Return a promise of the rank the Entity with id id
in the ordered by set.
Resultset
Be sure to check the introduction on the Resultset concept
In this documentation
rs
is always an Spined specific Resultset
Resultset query methods
Query methods are always returning a Resultset, so you can chained them
<S:idx>
, <value>
)
rs.where(Return a new Resultset of Entities that are included in the Set Index <S:idx>
for the value <value>
.
<C:idx>
)
rs.order(Return a new Resultset of Entities using the order defined with the Cursor Index <C:idx>
.
<JSONata>
)
rs.filter(Return a new Resultset of Entities that verify the <JSONata>
query. See https://jsonata.org/ for all the possibilities.
For example, to filter only Entities with a property primaryName
containing the substring Jean
, you shoudl write :
rs.filter('$contains(primaryName, "Jean")')
The
filter
methods has an O(N) complexity (all Entities of the called Resultset need to be tested). You should avoid to use it for very large Resultset. Use Set Indexese instead for faster queries.
TODO note chaining
<fct>
)
rs.sort(TODO
Resultset solving methods
Resultset solving methods always return a promise or an AsyncGenerator.
<$$id>
)
rs.has(Return a promise of a boolean. True if Entity with id <$$id>
is part of the Resultset rs
.
const has = await rs.has('5236e2ab-71f2-4bce-9d39-2ae4d3ca0ab8')
console.log(has) // true
rs.ids()
Return a promise of a list of all Entities meta ids part of the Resultset rs
.
const ids = await rs.ids()
console.log(ids) // ['5236e2ab-71f2-4bce-9d39-2ae4d3ca0ab8', '47bd05a9-10e5-4b5d-aa8f-3a1f955dfd07' ]
rs.count()
Return a promise of a number of Entities part of the Resultset rs
.
const count = await rs.count()
console.log(count) // 2
rs.first()
Return a promise of the first Entity part of the Resultset rs
.
const ent = await rs.first()
rs.iterate()
Return a async generator of all Entities part of the Resultset rs
.
Return AsyncGenerator of all results
for await (const ent of all.iterate()) {
console.log(ent.$$json)
}
<args>
})
rs.connection({ Return a promise of a connection closely following the specifications (https://relay.dev/graphql/connections.htm).
<args>
is an Object with either first
or last
as mandatory properties with the integer value <n>
. The connection will return an Object with properties edges
and fields
properties, respectively the first
or last
<n>
"edge" in the Resultset.
An "edge" is just an Object ecapsulating an Entity and its "cursor".
If first
is used, you can pass a cursor in the parameters after
to get the edges after the Entities cursor. Respectively you can pass a cursor in before
when you use last
.
desc
is a boolean (by default false) to indicates that the ordered resulset is understood as descendant. Finally you can use afterRank
that take the absolute rank instead of a cursor.
// in Graphql resolvers
books: async (parent, args, ctx) => {
const rs = parent.dimension$('book')).all()
return connection(rs.order('C:$$createdAt'), args)
}
Resultset alias methods
Some useful shortcuts alias methods are provide as syntaxic sugar:
- rs.ids(
<JSONata>
) => rs.filter(<JSONata>
).ids() - rs.count(
<JSONata>
) => rs.filter(<JSONata>
).count() - rs.first(
<JSONata>
) => rs.filter(<JSONata>
).first()
- rs.iterate(
<C:idx>
) => rs.order(<C:idx>
).iterate() - rs.first(
<C:idx>
) => rs.order(<C:idx>
).first()
Dimension extensions
A dimension name can be statically extended with schema constraints, triggers and behavioral methods.
Any concrete dimension of an Entity having that identical name would be bound with these extensions.
TODO extensions are not yet fully documented
Loading extensions
The current most convenient way to add extensions is to use the extendFrom
option of the Spined()
Constructor.
The option take a directory as argument. Every <dimension name>.js
files in this directory with a default Object export will inject all extensions under the properties schema
, triggers
, getters
, methods
, options
for all Dimensions whith that name.
Very simple extension file :
const extensions = {
schema: {
title: 'Account',
type: 'object',
required: ['email'],
properties: {
email: { type: 'string' }
}
},
triggers: {
beforeDelete: ($ref, attributes) => !attributes.isActive
},
getters: {
fullName: ($ref, self) => () => self.firstName + ' ' + self.lastName,
}
methods: {
toString: ($ref, self) => () => {
return `${self.slug}`
}
}
}
export default extensions
schema extension
You can bind a JSONschema to a dimension name. Entities JSON data will need to verify the JSONschema to be able to added to the dimension.
triggers extensions
$ref
, attributes
) => attributes
beforeCreate: ( $ref
, attributes
) => attributes
beforeUpdate: ( $ref
, attributes
) => boolean
beforeDelete: (getters extensions
getterName
: ($ref
, self
) => () => Any
methods extensions
methodName
: ($ref
, self
) => (Any args) => Any
options extensions
inMemoryTtl
Debugging
TODO
Format
Redis Storage format
Low level storage of data is handled by Redis. Data is stored with a predicatable and open sourced simple format that doesn't depends on the current actual implementation of Spined (though they are both optimized to work well together).
- Every Entity data and meta data is a serialized JSON object value stored at the key
<entity id>
- Every Dimension data and meta data is a serialized JSON object value stored at the key
<dimension id>
- A Redis hash with key
<entity id>.D
is storing all<dimension id>
and their names of an Entity. - A Redis set
<entity id>.M
is recording all<dimension id>
in which an Entity is included - A Redis set
<dimension id>.E
is recording all<entity id>
belonging in a Dimension - A Redis set
<dimension id>.I
is recording all indexes of a Dimension
Other specialized set with key <dimension id>.<long hash>
are used to optimize resultset requests but they are not necessary to restore the data stored with Spined.