Skip to main content
Version: 0.5.x

Runtime Schema

ComposeDB automatically generates the runtime GraphQL schema used by applications to interact with their composites.

Objects

Document objects

Documents are uniquely identifiable objects in the graph using GraphQL's Global Object Identification specification with the Node interface. All document objects contain an id: ID! field representing their unique stream ID.

The other fields present in document objects are generated based on the model's definition and possibly added views.

For example, using the following Schema definition:

enum PostStatus {
ARCHIVED
DRAFT
PUBLISHED
}

type Post @createModel(accountRelation: LIST, description: "A simple post") {
author: DID! @documentAccount
status: PostStatus!
publicationDate: DateTime
title: String! @string(minLength: 5, maxLength: 100)
text: String! @string(minLength: 5, maxLength: 10000)
}

This runtime schema will be generated:

# ℹ️ Some types are omitted in this example for brevity

type CeramicAccount implements Node {
# Default fields always present on the CeramicAccount object
id: ID!
isViewer: Boolean!
# Added connection to Post documents controlled by the account
postList: PostConnection
}

enum PostStatus {
ARCHIVED
DRAFT
PUBLISHED
}

type Post implements Node {
# The ID field representing the document stream ID is always added to document objects
id: ID!
# DID scalar field converted to CeramicAccount object
author: CeramicAccount!
# Other fields defined in the schema
status: PostStatus!
publicationDate: DateTime
title: String!
text: String!
}

type PostConnection {
edges: [PostEdge]
pageInfo: PageInfo
}

type PostEdge {
cursor: String
node: Post
}

Embedded objects

Embedded objects can only be accessed from the document storing them.

Example Schema definition:

type ImageSource {
src: URI!
alt: String! @string(minLength: 5, maxLength: 100)
width: Int
height: Int
}

type ImageMetadata {
original: ImageSource!
alternatives: [ImageSource!]!
}

type Profile @createModel(accountRelation: SINGLE, description: "A basic profile") {
displayName: String! @string(minLength: 5, maxLength: 100)
avatar: ImageMetadata
}

type Post @createModel(accountRelation: LIST, description: "A simple post") {
title: String! @string(minLength: 5, maxLength: 100)
cover: ImageMetadata
}

This runtime schema will be generated:

# ℹ️ Some types are omitted in this example for brevity

type PostImageMetadata {
original: PostImageSource!
alternatives: [PostImageSource!]!
}

type PostImageSource {
src: URI!
alt: String!
width: Int
height: Int
}

type Post implements Node {
id: ID!
title: String!
cover: PostImageMetadata
}

type ProfileImageMetadata {
original: ProfileImageSource!
alternatives: [ProfileImageSource!]!
}

type ProfileImageSource {
src: URI!
alt: String!
width: Int
height: Int
}

type Profile implements Node {
id: ID!
displayName: String!
avatar: ProfileImageMetadata
}

In the runtime schema above, the ImageMetadata and ImageSources objects from the schema definition are generated as PostImageMetadata, PostImageSources, ProfileImageMetadata and ProfileImageSources to avoid naming conflicts between embedded objects.

Using the setCommomEmbeds() method of the Composite class, it is possible to specify that embedded objects can be safely shared by multiple documents, generating the following runtime schema:

# ℹ️ Some types are omitted in this example for brevity

type ImageMetadata {
original: ImageSource!
alternatives: [ImageSource!]!
}

type ImageSource {
src: URI!
alt: String!
width: Int
height: Int
}

type Post implements Node {
id: ID!
title: String!
cover: ImageMetadata
}

type Profile implements Node {
id: ID!
displayName: String!
avatar: ImageMetadata
}

CeramicAccount object

The CeramicAccount object is generated to represent any DID and its associated documents in the network.

Similar to Document objects, all CeramicAccount objects are uniquely identifiable objects in the graph using GraphQL's Global Object Identification specification with the Node interface, with their id: ID! field representing their unique DID string.

In addition to the id: ID! field, the isViewer: Boolean! field representing whether the account is the viewer associated to the ComposeDB client are always present, while other fields are generated based on the models present in the composite.

Relations to all documents controlled by the given DID for the models present in the composite are automatically generated, while relations from documents using a DID scalar field can be explicitly added using the @accountReference directive.

Example Schema definition:

type Profile @createModel(accountRelation: SINGLE, description: "A basic profile") {
displayName: String! @string(minLength: 5, maxLength: 100)
}

type Meeting @createModel(accountRelation: LIST, description: "Meeting event") {
# @documentAccount represents the account controlling the document
self: DID! @documentAccount
# @accountReference signals the field needs to be indexed and queryable on the CeramicAccount object
other: DID! @accountReference
date: Date
}

This runtime schema will be generated:

# ℹ️ Some types are omitted in this example for brevity

type CeramicAccount implements Node {
# The following fields are always present
id: ID!
isViewer: Boolean!
# The Meeting relation is a connection because the Meeting account relation is LIST
# The meetingList connection allows to access all the Meeting documents controlled by the account
meetingList: MeetingConnection
meetingListCount: Int!
# The otherOfMeetingList connection allows to access all the Meeting documents where the account DID is set as the value of the "other" field
otherOfMeetingList: MeetingConnection
otherOfMeetingListCount: Int!
# The Profile relation is a single object because the Profile account relation is SINGLE
profile: Profile
}

type Meeting implements Node {
id: ID!
# DID scalars are turned into CeramicAccount objects so their relations can be accessed
self: CeramicAccount!
other: CeramicAccount!
date: Date
}

type MeetingConnection {
edges: [MeetingEdge]
pageInfo: PageInfo
}

type MeetingEdge {
cursor: String
node: Meeting
}

type Profile implements Node {
id: ID!
displayName: String!
}

Query object

The Query object represents the root object to perform GraphQL queries, it always contains the following two fields:

In addition to these fields, the ComposeDB runtime will generate connections for all models defined in the composite.

Example Schema definition:

type Profile @createModel(accountRelation: SINGLE, description: "A basic profile") {
displayName: String! @string(minLength: 5, maxLength: 100)
}

type Post @createModel(accountRelation: LIST, description: "A simple post") {
text: String! @string(minLength: 5, maxLength: 100)
}

This runtime schema will be generated:

# ℹ️ Some types are omitted in this example for brevity

type Query {
node(id: ID!): Node
viewer: CeramicAccount
# ℹ️ Connection arguments are omitted in this example for brevity
postIndex: PostConnection
postCount: Int!
profileIndex: ProfileConnection
profileCount: Int!
}

type CeramicAccount implements Node {
id: ID!
isViewer: Boolean!
postList: PostConnection
postListCount: Int!
profile: Profile
}

type Post implements Node {
id: ID!
text: String!
}

type PostConnection {
edges: [PostEdge]
pageInfo: PageInfo
}

type PostEdge {
cursor: String
node: Post
}

type Profile implements Node {
id: ID!
displayName: String!
}

type ProfileConnection {
edges: [ProfileEdge]
pageInfo: PageInfo
}

type ProfileEdge {
cursor: String
node: Profile
}

Connections

ComposeDB implements Relay's Connection specification to represent one-to-many relationships between nodes (Document objects and DID accounts) in the graph.

Connection objects are generated for all models in the composite, supporting the Connection arguments and possibly additional arguments for filtering and sorting the associated documents.

Inputs

GraphQL differentiates objects handled in queries from objects used to perform mutations and arguments, using input types.

ComposeDB generates input types based on models present in the composite as described below.

Filtering

Filtering inputs can be used as arguments to Connection queries in order to filter the documents returned by the query based on the value of fields present in the document identified using the @createIndex directive.

Filters support two types of conditions: value conditions that apply to a single field in a document and logical conditions that combine multiple conditions to create more complex filters.

For example, using the following Schema definition:

enum PostStatus {
ARCHIVED
DRAFT
PUBLISHED
}

type Post
@createModel(accountRelation: LIST, description: "A simple post")
@createIndex(fields: [{ path: ["status"] }])
@createIndex(fields: [{ path: ["publicationDate"] }])
@createIndex(fields: [{ path: ["title"] }]) {
author: DID! @documentAccount
status: PostStatus!
publicationDate: DateTime
title: String! @string(minLength: 5, maxLength: 100)
text: String! @string(minLength: 5, maxLength: 10000)
}

This runtime schema will be generated:

# ℹ️ Some types are omitted in this example for brevity

enum PostStatus {
ARCHIVED
DRAFT
PUBLISHED
}

type Post implements Node {
id: ID!
author: CeramicAccount!
status: PostStatus!
publicationDate: DateTime
title: String!
text: String!
}

type PostConnection {
edges: [PostEdge]
pageInfo: PageInfo
}

type PostEdge {
cursor: String
node: Post
}

# High-level filter conditions for Post documents
input PostFiltersInput {
where: PostObjectFilterInput
and: [PostFiltersInput!]
or: [PostFiltersInput!]
not: PostFiltersInput
}

# Filter conditions for fields in Post documents
input PostObjectFilterInput {
status: PostStatusValueFilterInput
publicationDate: StringValueFilterInput
title: StringValueFilterInput
}

# Generated value filter for the PostStatus enum
input PostStatusValueFilterInput {
isNull: Boolean
equalTo: PostStatus
notEqualTo: PostStatus
in: [PostStatus!]
notIn: [PostStatus!]
}

# Generic string value filter
input StringValueFilterInput {
isNull: Boolean
equalTo: String
notEqualTo: String
in: [String!]
notIn: [String!]
lessThan: String
lessThanOrEqualTo: String
greaterThan: String
greaterThanOrEqualTo: String
}

type Query {
node(id: ID!): Node
viewer: CeramicAccount
# ℹ️ Other connection arguments are omitted in this example for brevity
postIndex(filters: PostFiltersInput): PostConnection
}

Value conditions

Value conditions apply to the value of a single field in a document. They are generated based on the value type (such as Boolean, String, Float...), with different value types supporting different conditions.

The following table describes all the available conditions and the matching SQL statement, where (value) is used as a placeholder for a single value and ...values for a list of values:

GraphQL inputGenerated SQL
isNull: BooleanIS NULL / IS NOT NULL
equalTo: (value)= (value)
notEqualTo: (value)!= (value)
in: [...values]IN (...values)
notIn: [...values]NOT IN (...values)
lessThan: (value)< (value)
lessThanOrEqualTo: (value)<= (value)
greaterThan: (value)> (value)
greaterThanOrEqualTo: (value)>= (value)

Even though the generated GraphQL input types support multiple condition fields, ComposeDB does not support ambiguous conditions.

In most cases, only a single condition can be present in the input. The exception is when using the lessThan, lessThanOrEqualTo, greaterThan and greaterThanOrEqualTo where two matching boundaries can be set together, as in the examples below:

// ❌ Invalid input with two conditions making the filter ambiguous
{ "isNull": true, "equalTo": "test" }
// ✅ Valid input with a single condition
{ "isNull": true }
// ✅ Valid input with a single condition
{ "equalTo": "test" }
// ✅ Valid input with range conditions
{ "greaterThan": 5, "lessThanOrEqualTo": 10 }
// ❌ Invalid input with ambiguous conditions
{ "greaterThan": 5, "greaterThanOrEqualTo": 10 }

Logical conditions

Beyond using the where keyword to match object fields with value conditions, the and, or and not keywords can be used to create more complex conditions, for example:

// ✅ Valid input with conditions on multiple fields
{
"where": {
"status": { "notEqualTo": "DRAFT" },
"publicationDate": { "greaterThanOrEqualTo": "2023-07-01", "lessThan": "2023-08-01" }
}
}
// ✅ Valid input with nested logical filters
{
"or": [
{
"where": {
"status": { "equalTo": "PUBLISHED" }
}
},
{
"not": {
"where": {
"publicationDate": { "greaterThanOrEqualTo": "2023-07-01" }
}
}
}
]
}

Only one key/value pair can be provided per object, filters such as the following are not supported:

// ❌ Invalid input with multiple keys, see previous example for correct syntax
{
"not": {
"where": {
"publicationDate": { "greaterThanOrEqualTo": "2023-07-01" }
}
},
"or": [
{
"where": {
"status": { "equalTo": "PUBLISHED" }
}
}
]
}

Sorting

Similar to filtering inputs, sorting inputs can be used as arguments to Connection queries in order to order the documents returned by the query based on the value of fields present in the document identified using the @createIndex directive.

For example, using the following Schema definition:

enum PostStatus {
ARCHIVED
DRAFT
PUBLISHED
}

type Post
@createModel(accountRelation: LIST, description: "A simple post")
@createIndex(fields: [{ path: ["status"] }])
@createIndex(fields: [{ path: ["publicationDate"] }]) {
author: DID! @documentAccount
status: PostStatus!
publicationDate: DateTime
title: String! @string(minLength: 5, maxLength: 100)
text: String! @string(minLength: 5, maxLength: 10000)
}

This runtime schema will be generated:

# ℹ️ Some types are omitted in this example for brevity

enum PostStatus {
ARCHIVED
DRAFT
PUBLISHED
}

type Post implements Node {
id: ID!
author: CeramicAccount!
status: PostStatus!
publicationDate: DateTime
title: String!
text: String!
}

type PostConnection {
edges: [PostEdge]
pageInfo: PageInfo
}

type PostEdge {
cursor: String
node: Post
}

input PostSortingInput {
status: SortOrder
publicationDate: SortOrder
}

enum SortOrder {
ASC
DESC
}

type Query {
node(id: ID!): Node
viewer: CeramicAccount
# ℹ️ Other connection arguments are omitted in this example for brevity
postIndex(sorting: PostSortingInput): PostConnection
}

Multiple fields can be set in the sorting input, for example:

// ✅ Valid input with multiple fields
{ "publicationDate": "DESC", "title": "ASC" }

Document creation

Document creation uses two input objects: one for the content fields and another one wrapping it.

For example, using the following Schema definition:

enum PostStatus {
ARCHIVED
DRAFT
PUBLISHED
}

type Post @createModel(accountRelation: LIST, description: "A simple post") {
author: DID! @documentAccount
status: PostStatus!
publicationDate: DateTime
title: String! @string(minLength: 5, maxLength: 100)
text: String! @string(minLength: 5, maxLength: 10000)
}
# ℹ️ Some types are omitted in this example for brevity

enum PostStatus {
ARCHIVED
DRAFT
PUBLISHED
}

type Post implements Node {
id: ID!
author: CeramicAccount!
status: PostStatus!
publicationDate: DateTime
title: String!
text: String!
}

# Post input based on content fields
input PostInput {
status: PostStatus!
publicationDate: DateTime
title: String!
text: String!
}

# High-level input type
input CreatePostInput {
content: PostInput!
clientMutationId: String
}

type Mutation {
createPost(input: CreatePostInput!): CreatePostPayload
}

Document update

Similart to document creation, document update uses two input objects for the content fields and another one wrapping it, as well as an options object.

For example, using the following Schema definition:

enum PostStatus {
ARCHIVED
DRAFT
PUBLISHED
}

type Post @createModel(accountRelation: LIST, description: "A simple post") {
author: DID! @documentAccount
status: PostStatus!
publicationDate: DateTime
title: String! @string(minLength: 5, maxLength: 100)
text: String! @string(minLength: 5, maxLength: 10000)
}
# ℹ️ Some types are omitted in this example for brevity

enum PostStatus {
ARCHIVED
DRAFT
PUBLISHED
}

type Post implements Node {
id: ID!
author: CeramicAccount!
status: PostStatus!
publicationDate: DateTime
title: String!
text: String!
}

# Partial Post input with all content fields set as optional
input PartialPostInput {
status: PostStatus
publicationDate: DateTime
title: String
text: String
}

# Generic input for update options
input UpdateOptionsInput {
# Set to `true` to replace existing contents rather than doing a shallow merge (default)
replace: Boolean = false
# Expected current version of the document, mutation fails if there is a mismatch
version: CeramicCommitID
}

# High-level input type
input UpdatePostInput {
# ID of the document to update
id: ID!
content: PartialPostInput!
options: UpdateOptionsInput
clientMutationId: String
}

type Mutation {
updatePost(input: UpdatePostInput!): UpdatePostPayload
}