This module provides a base class and types for organizing storage logic based on Storex into modules. Each module:
- Defines and exposes some higher-level methods to work with your data (
createUser
,findPostsWithTags
, etc.). - Defines collections and their changes over time.
- Defines the lower-level operations it uses to implement its higher-level methods.
- Defines access rules for reading and manipulating data in a multi-user application (either cloud-based or decentralized).
This means you have extra metadata about your storage layer you can use to:
- Automatically generate web APIs with the same interface, allowing you to seamlessly move your storage logic between front- and back-end, as is for example done with the GraphQL schema and client.
- Automatically give hints on where to place indices.
- Generate graphs on which parts of your software perform what operations of your database.
- Compile access rules to more specific systems that'd otherwise lock you in, like Firebase/Firestore.
- Once we design different ways of specifying methods (in the future), this could even compile to Ethereum smart contracts to enable a massively improved workflow.
A StorageModule
is nothing more than a class accepting a storage manager, exposing a config, and some protected methods:
import { StorageModule, StorageModuleConfig, StorageModuleConstructorArgs } from '@worldbrain/storex-pattern-modules'
export class TodoListStorage extends StorageModule {
constructor(options : StorageModuleConstructorArgs & { myArg1 : string, ... }) {
super(options)
}
getConfig() : StorageModuleConfig { // This is the important part defining things other tools can use
return {
collections: {}
operations: {},
methods: {},
accessRules: {}
}
}
async createList(list : { label: string, items: Array<{label : string, done : boolean}> }) { // It's just a normal class, do whatever you want to its methods
}
}
The collections
field of the StorageModuleConfig
is an object containing collection definitions:
export class TodoListStorage extends StorageModule {
getConfig() : StorageModuleConfig {
return {
collections: {
todoList: {
version: new Date('2019-05-05'),
fields: {
label: { type: 'string' },
}
}
}
}
}
}
Each collection can contain a history
property containing previous versions of the collection. This can be used as long as you need information about these old versions in your applications (for example, to enable data migrations of old exported data).
The operations
field of the StorageModuleConfig
contains templates of the operations this module will execute. These templates contain placeholders that will be filled in once operation is executed.
This example builds on the one above, so collections
is omitted here for sake of brevity:
export class TodoListStorage extends StorageModule {
debug = true // You can optionally set this to true to see all the operations logged that your module is executing
getConfig() : StorageModuleConfig { // This is the important part defining things other tools can use
return {
collections: {
todoList: {
version: new Date('2019-05-05'),
fields: {
label: { type: 'string' },
}
}
},
operations: {
updateListTitle: {
// `operation` can be any operation passed in as the
// first arg to `storageManager.operation(<operation>)`,
// including one provided by middleware or a backend plugin
operation: 'updateObject',
collection: 'todoList',
args: [ // These are the arguments in `storageManager.operation(collection, ...<args>)`
{ id: '$id:pk' } // where clause,
{ title: '$title:string' } // fields to update
]
},
findSingleListByTitle: {
operation: 'findObject',
collection: 'todoList',
args: { // If args is not a list, it'll be simply the only argument
title: '$title:string',
}
},
createList: {
operation: 'createObject', // If the operation is `createObject`, `args` will automatically be filled in.
collection: 'todoList',
},
},
}
}
async createList(list : { title : string }) : Promise<{ id : string | number }> {
const { object } = await this.operation('createList', list)
return { id: object.id }
}
async updateListTitle(title : string, options : { id : string | number } | { oldTitle : string }) {
// Contrived example, but shows different ways to use `this.operation()`
if ('oldTitle' in options) {
const list = await this.operation('findSingleListByTitle', { title: options.oldTitle })
if (!list) {
throw new Error(`Could not find list with title '${options.oldTitle}'`)
}
await this.operation('updateListTitle', { id: list.id, title }) // These things passed in will be merged into where we've said '$id:pk' and '$title:string' before
} else {
await this.operation('updateListTitle', { id: options.id, title })
}
}
}
The args
property of an operation template can contain $placeholder:type
strings anywhere. The $placeholder
part will be later substituted by the corresponding property of the second argument given when this.operation()
is called. The type
is supposed to be one of the same types you use in field collection definition, in order to optionally provide some run-time type checking. However, this is not implemented yet.
As long as you're using your methods in the same application, you can call methods directly. However, once your business and storage logic start to live in separate places (like mobile clients and servers) you want to introduce some extra info on expected input and output to perform more validation (which is automatically done if you're using the GraphQL schema).
This example builds on the one above, so collections
and operations
are omitted here for sake of brevity:
export class TodoListStorage extends StorageModule {
getConfig() : StorageModuleConfig { // This is the important part defining things other tools can use
return {
methods: {
getList: { // Terminology borrowed from GraphQL, where 'query' only reads, and 'mutation' also writes.
type: 'query',
args: { id: 'number' }, // All these will be passed in as an object
returns: { collection: 'todoList' }
},
createList: {
type: 'mutation',
args: { title: 'string' }
},
updateListTitle: {
type: 'mutation',
args: {
title: { type: 'string', positional: true },
id: 'bool'
}
},
},
}
}
async createList(list : { title : string }) : Promise<{ id : string | number }> {
const { object } = await this.operation('createList', list)
return { id: object.id }
}
async updateListTitle(title : string, options : { id : string | number }) {
await this.operation('updateListTitle', { id: options.id, title })
}
}
For more example on its usage, see the unit tests of the GraphQL schema package and the method type definition
TBD