├── .gitignore ├── .travis.yml ├── License ├── README.md ├── docs ├── 0-start-here.md ├── collections.md ├── middleware.md ├── operations.md ├── plugins.md └── registry.md ├── package.json ├── ts ├── fields │ ├── index.ts │ ├── random-key.ts │ └── types.ts ├── index.tests.ts ├── index.ts ├── registry.test.ts ├── registry.ts ├── types │ ├── backend-features.ts │ ├── backend.test.ts │ ├── backend.ts │ ├── collections.ts │ ├── errors.ts │ ├── fields.ts │ ├── index.ts │ ├── indices.ts │ ├── manager.ts │ ├── middleware.test.ts │ ├── middleware.ts │ ├── migrations.ts │ └── relationships.ts ├── utils.test.ts └── utils.ts └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | lib/ 3 | yarn.lock 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - '12' 4 | install: 5 | - yarn install --frozen-lockfile --network-concurrency 1 6 | script: 7 | - yarn test 8 | cache: 9 | yarn: true 10 | directories: 11 | - ~/.cache/yarn 12 | - node_modules 13 | notifications: 14 | if: branch = master AND type = push 15 | email: 16 | on_success: never 17 | on_failure: always 18 | -------------------------------------------------------------------------------- /License: -------------------------------------------------------------------------------- 1 | Copyright 2019 World Brain (@ worldbrain.io) 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Storex is a minimal storage layer as a foundation for easing common problems around storing and moving data around. Allowing you to describe your data layout as a graph and providing different plugins, it helps you interact with (No)SQL databases, data migration, offline first applications architecture, creating and consuming REST/GraphQL APIs, permission management, finding optimization opportunaties and more. The aim is to provide a minimalistic common ground/language for working with your data, providing packages for solving the most common problems around data, while giving you easy access to the underlying machinery to do the things that are specific to your application. Everything together that means that every problem you encounter while rapidly iterating towards a serious product, from choosing a suitable DB to suddenly realizing you need to migrate your data model, or even switch DBs, will get a ton easier because you don't have to solve them for the 109th time yourself. 2 | 3 | *This project started as the storage layer for [Memex](https://memex.garden/), a tool to organize your web-research for yourself and collaboratively. The design of this storage layer was done by [Vincent den Boer](https://www.vdenboer.com/) and you can [read about the original thoughts behind it here](https://www.vdenboer.com/blog/storex-modular-storage).* 4 | 5 | **Status:** This has been powering Memex in production for over 3 years, facilitating our experiments with peer-to-peer sync and our eventual transition to the cloud. It allows us to share a lot of infrastructure and business logic across the browser, our React Native app and our Firestore backend. However, we don't have the resources to properly launch this as a quality open source project with awesome docs and fixes for some of the design issues we discovered over the years. The recommended way to use it for now is to fork it and include it as submodules in your own project. It's easy to understand, so it's encouraged that you take the time to understand what's happening so you can modify it to fit your exact needs. With this in mind, the instructions below are outdated because we don't actively publish new versions to NPM. That being said, the API is stable and will be until Storex 1.0 in which we can finally fix a bunch of small things! 6 | 7 | Installation 8 | ============ 9 | 10 | Storex is a collection of Node.js modules (written in TypeScript) meant to be used both client- and server-side. To start, you need the core and a backend: 11 | ``` 12 | $ npm install @worldbrain/storex --save 13 | 14 | $ # For a client-side DB 15 | $ npm install @worldbrain/storex-backend-dexie --save # IndexedDB through Dexie library 16 | 17 | $ # For a server-side SQL DB 18 | $ npm install @worldbrain/storex-backend-sequelize --save # MySQL, PostgreSQL, SQLite, MSSQL through Sequelize 19 | 20 | $ # For a Firestore mBaaS DB 21 | $ npm install @worldbrain/storex-backend-firestore --save 22 | ``` 23 | 24 | Basic usage 25 | =========== 26 | 27 | First, configure a StorageBackend and set up the StorageManager, which will be the main point of access to define, query and manipulate your data. For more in-depth information on how to do all of this, please refer to [the docs](./docs/0-start-here.md). 28 | 29 | ```typescript 30 | import StorageManager from 'storex' 31 | import { DexieStorageBackend } from 'storex-backend-dexie' 32 | 33 | const storageBackend = new DexieStorageBackend({dbName: 'my-awesome-product'}) 34 | const storageManager = new StorageManager({ backend: storageBackend }) 35 | storageManager.registry.registerCollections({ 36 | user: { 37 | version: new Date(2018, 11, 11), 38 | fields: { 39 | identifier: { type: 'string' }, 40 | isActive: { type: 'boolean' }, 41 | }, 42 | indices: [ 43 | { field: 'identifier' }, 44 | ] 45 | }, 46 | todoList: { 47 | version: new Date(2018, 7, 11), 48 | fields: { 49 | title: { type: 'string' }, 50 | }, 51 | relationships: [ 52 | {childOf: 'user'} # creates one-to-many relationship 53 | ], 54 | indices: [] 55 | }, 56 | todoListEntry: { 57 | version: new Date(2018, 7, 11), 58 | fields: { 59 | content: {type: 'text'}, 60 | done: {type: 'boolean'} 61 | }, 62 | relationships: [ 63 | {childOf: 'todoList', reverseAlias: 'entries'} 64 | ] 65 | } 66 | }) 67 | await storageManager.finishInitialization() 68 | 69 | const user = await storageManager.collection('user').createObject({ 70 | identifier: 'email:boo@example.com', 71 | isActive: true, 72 | todoLists: [{ 73 | title: 'Procrastinate this as much as possible', 74 | entries: [ 75 | {content: 'Write intro article', done: true}, 76 | {content: 'Write docs', done: false}, 77 | {content: 'Publish article', done: false}, 78 | ] 79 | }] 80 | }) 81 | # user now contains things generated by underlying backend, like ids and random keys if you have such fields 82 | console.log(user.id) 83 | 84 | await storageManager.collection('todoList').findObjects({user: user.id}) # You can also use MongoDB-like queries 85 | ``` 86 | 87 | Further documentation 88 | ===================== 89 | 90 | You can [find the docs here](./docs/0-start-here.md). Also, we'll be writing more and more automated tests which also serve as documentation. 91 | 92 | Storex ecosystem 93 | ================ 94 | 95 | The power of Storex comes from having modular packages that can be recombined in different contexts based on the core 'language' or patterns Storex provides to talk about data. Officially supported packages will be included in the `@worldbrain` npm namespace. This helps us to endorse patterns that emerge throughout the ecosystem in a controlled, collectively governed way. These are the currently supported packages: 96 | 97 | * [Storage modules](https://github.com/WorldBrain/storex-pattern-modules): An pattern of organizing your storage logic modules so other parts of your application and tooling can have more meta-information about the application. This includes standard ways of defining your data structure and schema changes, describing how you access your data, describing higher-level methods, and describing access rules. This allows for really cool stuff like automatically detecting operations on fields that are not indexed, automatically generating client-server APIs for moving your data around, generating rules for other systems (like Firebase) to manage user rights, and a lot more. 98 | * [Schema migrations](https://github.com/WorldBrain/storex-schema-migrations): The functionality you need, provided in a modular and adaptable way, to describe and execute schema migration as your application evolves. Written with a diverse context in mind, like generating SQL scripts for DBAs, running migration in Serverless environments, converting exported data on import, etc. 99 | * [Client-server communication usign GraphQL](https://github.com/WorldBrain/storex-graphql-schema/): This allows you to move your storage layer, expressed in [storage modules](https://github.com/WorldBrain/storex-pattern-modules), server-side transparently by using GraphQL as the transport layer with your back-end. You consume this API with the [client](https://github.com/WorldBrain/storex-graphql-client) so the business logic consuming your storage logic can stay exactly the same, making GraphQL an implementation detail, not something running throughout your code. 100 | * [Data tools](https://github.com/WorldBrain/storex-data-tools): A collection of data tools allowing you to apply schema migration on live data (coming in through the network or read from files), generate large sets of fake, complex data for testing purposes, and tools for dumping and loading test fixtures. 101 | * [Schema visualization using Graphviz](https://github.com/WorldBrain/storex-visualize-graphviz): Still in its infancy, this creates a GraphViz DOT files showing you the relationships between your collections. 102 | 103 | Also, current officially supported back-ends are: 104 | * [Dexie](https://github.com/WorldBrain/storex-backend-dexie): Manages interaction with IndexedDB for you, so your application can run fully client-side. Use for your daily development workflow, for fully client-side applications, or offline-first applications. 105 | * [Sequelize](https://github.com/WorldBrain/storex-backend-sequelize): Interact with any SQL database supported by Sequelize, such as MySQL or PostgreSQL. Meant to be used server-side. 106 | * [Firestore](https://github.com/WorldBrain/storex-backend-firestore): Store your data in Firestore, so you don't have to build a whole back-end yourself. Can be used directly from the client-side, or inside Cloud Functions. Includes a Security Rule generator that removes a lot of the pain of writing secure and maintainable security rules. 107 | 108 | Status and future development 109 | ============================= 110 | 111 | In addtion to the functionality described above, these are some features to highlight that are implemented and used in production: 112 | 113 | - **One DB abstraction layer for client-side, server-side and mBaaS code:** Use the Dexie backend for IndexedDB client-side applications, the Sequelize backend for server-side SQL-based databases, or the Firestore backend for mBaaS-based applications. This allows you to write storage-related business logic portable between front- and back-end, while easily switching to non-SQL storage back-ends later if you so desire, so you can flexible adjust your architecture as your application evolves. 114 | - **Defining data in a DB-agnostic way as a graph of collections**: By registering your data collections with the StorageManager, you can have an easily introspectable representation of your data model 115 | - **Automatic creation of relationships in DB-agnostic way**: One-to-one, one-to-many and many-to-many relationships declared in DB-agnostic ways are automatically being taken care of by the underlying StorageBackend on creation. 116 | - **MongoDB-style querying:** Basic CRUD operations take MongoDB-style queries, which will then be translated by the underlying StorageBackend. 117 | - **Client-side full-text search using Dexie backend:** By passing a stemmer into the Dexie storage backend, you can full-text search text fields using the fastest client-side full-text search engine yet! 118 | - **Run automated storage-related tests in memory:** Using the Dexie back-end, you can pass in a fake IndexedDB implementation to run your storage in-memory for faster automated and manual testing. 119 | - **Version management of data models:** For each collection, you can pass in an array of different date-versioned collection versions, and you'll be able to iterate over your data model versions through time. 120 | 121 | The following items are on the roadmap in no particular order: 122 | 123 | - [Synching functionality for offline-first and p2p applications](https://github.com/WorldBrain/storex/issues/8) (in development) 124 | - [Relationship fetching & filtering](https://github.com/WorldBrain/storex/issues/4): This would allow passing in an extra option to find(One)Object(s) signalling the back-end to also fetch relationship, which would translate to JOINs in SQL databases and use other configurable methods in other kinds of databases. Also, you could filter by relationships, like `collection('user').findObjects({'email.active': true})`. 125 | - **Field types for handling user uploads:** Allowing you to reference user uploads in your data-model, while choosing your own back-end to host them. 126 | - **A caching layer:** Allows you to cache certain explicitly-configured queries in stores like Memcache and Redis 127 | - **Composite back-end writing to multiple back-ends at once:** When you're switching databases or cloud providers, there may be period where your application needs to the exact same data to multiple database systems at once. 128 | - **Assisting migrations from one database to another:** Creating standard procedures allowing copying data from one database to another with any paradigm translations that might be needed. 129 | - **Server-side full-text search server integration":** Allow for example to store your data in MondoDB, but your full-text index in ElasticSearch. 130 | - **Query analytics:** Report query performance and production usage patterns to your anaylics backend to give you insight into possible optimization opportunities (such as what kind of indices to create.) 131 | 132 | Also, Storex was built with decentralization in mind. The first available backends are Dexie allowing you to user data client side, Sequelize for storing data server-side in relational databases (MySQL, PostgreSQL, AWS Aurora), and Firestore for quickly buiding cloud-based MVPs that can be relatively easily be migrated to other solutions. In the future, we see it possible to create backends for decentralized systems like [DAT](https://datproject.org/) to ease the transition and integration between centralized and decentralized back-ends as easy as possible. 133 | 134 | Contributing 135 | ============ 136 | 137 | There are different ways you can contribute: 138 | 139 | - **Report bugs:** The most simple way. Scream at us :) If you can find the time though, we'd really appreciate it if you could attach a PR with a failing unit test. 140 | - **Tackle outstanding bugs:** Great way to dip your toes in the water. Some of the reported bugs may already have failing unit tests attached to them! 141 | - **Propose new features:** Open an issue to describe a new feature you'd like to see. Please take the time to explain your reasoning behind the request, how it would benefit the Storex ecosystem and things like use-cases if relevant. 142 | - **Implement new features:** We try our best to make extensive descriptions of how features should work, and a clear decision process if the features are yet to be designed. Features to be implemented [can be found here](https://github.com/WorldBrain/storex/issues?q=is%3Aissue+is%3Aopen+label%3Aenhancement) with help how to pick up those features. 143 | 144 | ### Building for development 145 | 146 | Storex is written in [Typescript](https://www.typescriptlang.org/). As such, there's a lot of type information serving as documentation for the reader of the code. To start writing code, do the following: 147 | 148 | 1) Check out the Storex workspace repo: `git clone --recursive git@github.com:WorldBrain/storex-workspace.git`. 149 | 2) Run `yarn bootstrap` inside the checked out repo. 150 | 3) cd to any package you'd like to work on, like `cd packages/@worldbrain/storex-backend-dexie` 151 | 4) Create your own feature branch if necessay: `git checkout -b feature/my-awesome-feature` 152 | 5) Run `yarn test:watch` 153 | 154 | More info on https://github.com/WorldBrain/storex-workspace 155 | 156 | Getting in touch 157 | ================ 158 | 159 | For guidance on how to contribute, providing insight into your use cases, partnerships or any other collaboration opportunities, don't hesitate to get in touch [here](mailto:hello@youapt.eu). 160 | -------------------------------------------------------------------------------- /docs/0-start-here.md: -------------------------------------------------------------------------------- 1 | What Storex is and is not 2 | ========================= 3 | 4 | Every application needs to deal with data storage. We need to deal with databases, transport layers, serialization, access management, etc. These problems are not new, yet we keep reinventing them with every new language and framework out there, having to deal with pieces not exactly fitting together the right way with every new application we build. Storex wants to provide a structured way to talk about your data and what needs to happen with it, while leaving the heavy lifting to the Storex backends and your application logic. Through this, it enables you to postpone important decisions as possible, fit everything together as you need, and integrate it into existing applications easily without it wanting to dominate your application (it's a collection of libraries, not a framework.) 5 | 6 | These are some of the principles behind Storex: 7 | 8 | * Common problems around data should be easy, really specific ones should be possible by diving under the hood 9 | * The core should provide way to talk about data problems, not actually solve them 10 | * Backends should be able to provide functionality specific to them, and common operations and patterns should be able to flow into the core in a controlled way 11 | * Packagages should do one thing, and do it well 12 | * No unintented side-effects: no global variables or evil import-time code, so everything is easy to isolate and run in parallel if needed 13 | 14 | From this flows: 15 | 16 | * Storex is not a framework 17 | * Storex is not an ActiveRecord implementation (although you could build one on top, even though I believe they're are anti-patterns encoraging the mixing of business- with storage logic) 18 | 19 | How it works 20 | ============ 21 | 22 | You initialize a StorageBackend imported from its respective package: 23 | 24 | ```typescript 25 | import { DexieStorageBackend } from '@worldbrain/storex-backend-dexie' 26 | const storageBackend = new DexieStorageBackend({dbName: 'my-awesome-product'}) 27 | ``` 28 | 29 | You construct the storage manager, which will give you access to the StorageRegistry and the collection objects to access your data: 30 | 31 | ```typescript 32 | const storageManager = new StorageManager({ backend: storageBackend }) 33 | 34 | # More info about this below 35 | storageManager.registry.registerCollections({ 36 | user: { ... }, 37 | todoList: { ... }, 38 | }) 39 | 40 | # This links together relationships you defined between different collections and tells the back-end to connect 41 | await storageManager.finishInitialization() 42 | 43 | # You can access meta-data about your collections and relationships between them here 44 | storageManager.registry.collections.user.relationships 45 | 46 | # You can manipulate and access your data here 47 | const { object } = await storageManager.collection('user').createObject({ name: 'Fred', age: 36 }) 48 | const users = await storageManager.collection('user').findObjects({ name: 'Bob', age: { $gt: 30 }, ... }) 49 | ``` 50 | 51 | Under the hood, the collection methods are convenience methods that call the central `storageManager.operation(...)` method: 52 | ```typescript 53 | await storageManager.operation('createObject', 'user', { name: 'bla' }) 54 | await storageManager.operation('executeBatch', [ 55 | { operation: 'createObject', collection: 'user', args: { name: 'Diane' } }, 56 | { operation: 'createObject', collection: 'user', args: { name: 'Jack' } }, 57 | ]) 58 | ``` 59 | 60 | All of the operations then are sent through the configured [middleware](./middleware.md), similar to Express middlewares, allowing for things like logging, normalization, etc. before actually actually executing `storageBackend.operation(...)`. The base class of the storage back-end then checks whether the operation you're trying to do is supported, after which it executes the operation. 61 | 62 | Next steps 63 | ========== 64 | 65 | To harness the full power of Storex, you'll probably want to: 66 | * Organize your storage logic into [storage modules](https://github.com/WorldBrain/storex-pattern-modules) 67 | * Understand [schema migrations](https://github.com/WorldBrain/storex-schema-migrations) 68 | * Take a look at the [front-end boilerplate](https://github.com/WorldBrain/storex-frontend-boilerplate) to understand how you can set up an application that you can flexibly deploy in multiple configurations including in-memory, with GraphQL and an RDBMS, or Firestore. 69 | 70 | In-depth documentation 71 | ====================== 72 | 73 | * [Defining collections](./collections.md): This is about the steps above where you interact with storageManager.registry, describing the various options for your collections, fields and relationships. 74 | * [Interacting with data](./operations.md): How to query and manipulate your data. 75 | * [Introspecting collections](./registry.md): How you can use the available meta-data about your data in your applications and when writing a back-end. 76 | * [Using and writing middleware](./middleware.md): You can transform operations that your application does before they arrived at the `StorageBackend`. 77 | * [Using and writing backend plugins](./plugins.md): Backend-specific operations are implemented using plugins. Read how to use and write them here. 78 | * [Performing schema migrations](https://github.com/WorldBrain/storex-schema-migrations): Safely and DB-agnosticly migrate your data as your schema changes 79 | * [Visualizing your data schema](https://github.com/WorldBrain/storex-visualize-graphviz): Still very primitive, but this generates a DOT file you can render with GraphViz of your data schema 80 | -------------------------------------------------------------------------------- /docs/collections.md: -------------------------------------------------------------------------------- 1 | Collections are defined by calls to the StorageRegistry.registerCollection(s) function: 2 | 3 | ```typescript 4 | storageManager.registry.registerCollections({ 5 | user: { 6 | version: new Date(2018, 11, 11), 7 | fields: { 8 | identifier: { type: 'string' }, 9 | isActive: { type: 'boolean' }, 10 | }, 11 | indices: [ 12 | { field: 'identifier' }, 13 | ] 14 | }, 15 | todoList: { 16 | version: new Date(2018, 7, 11), 17 | fields: { 18 | title: { type: 'string' }, 19 | }, 20 | relationships: [ 21 | {childOf: 'user'} # creates one-to-many relationship 22 | ], 23 | indices: [] 24 | }, 25 | }) 26 | ``` 27 | 28 | The versions are grouped by date, which could be a release date for example. This allows Storex to give you a history of all your collection versions for data model migration purposes. To access the list of versions, you can use the StorageRegistry.collectionsByVersion map of which the key is the timestamp, and the value is an object mapping collectionName -> collectionDefinition. 29 | 30 | Field types 31 | =========== 32 | 33 | Currently built-in field types are `string`, `text`, `json`, `datetime`, `timestamp`, `boolean`, `float`, `int`, `blob` and `binary`. 34 | 35 | **String and text fields:** If you place an index `text` field, it will be marked to be indexed for full-text search. 36 | 37 | **JSON fields:** You can pass in JSON-serializable values here, which will be stored by the back-end in the fast, and possibly queryable way. Serialization and deserialization happens automatically on store/retrieval if needed. 38 | 39 | **Datetime and timestamp fields:** Depending on preferences, this allows you to either store/retrieve Date objects, or milisecond timestamps. 40 | 41 | **Blob and binary fields:** TODO 42 | 43 | ### Custom fields 44 | 45 | You can register your own custom field types, which allows you to do some pre-storage/post-retrieval processing, optionally allowing you to signal to the back-end which of the above primitive field types to store its value as. An example can be found [here](../ts/fields/random-key.ts). 46 | 47 | Relationships 48 | ============= 49 | 50 | You can define three kinds of relationships between collections. `singleChildOf`, `childOf` and `connects`. Each of allows you to multiple connected objects in one call. In the future they will allow also you to fetch related objects when retrieving data, and to filter objects based on their relationships. 51 | 52 | ### singleChildOf 53 | 54 | Creates a one-to-one relationship: 55 | ```typescript 56 | storageManager.registry.registerCollections({ 57 | email: { 58 | version: new Date(2018, 11, 11), 59 | fields: { 60 | address: { type: 'string' }, 61 | isActive: { type: 'boolean' }, 62 | }, 63 | indices: [] 64 | }, 65 | activationCode: { 66 | version: new Date(2018, 7, 11), 67 | fields: { 68 | key: { type: 'string' }, 69 | }, 70 | relationships: [ 71 | { 72 | childOf: 'email', 73 | // alias: 'emailToBeActivated', // Defaults to the name of the parent collection 74 | // reverseAlias: 'code' // Defaults to the name of this collection, used to create child objects directly when creating the parent objects 75 | } 76 | ], 77 | indices: [] 78 | }, 79 | }) 80 | await storageManger.finishInitialization() 81 | 82 | const email = await storageManager.collection('email').createObject({address: 'boo@bla.com', isActive: false, activationCode: {key: 'thekey'}}) 83 | console.log(email.activationCode) 84 | 85 | // The parent objects pk is stored on the child object on the configured alias field 86 | const key = await storageManager.collection('activationCode').findOneObject({email: email.id'}) 87 | ``` 88 | 89 | ### childOf 90 | 91 | Creates a one-to-many relationship: 92 | ```typescript 93 | storageManager.registry.registerCollections({ 94 | user: { 95 | version: new Date(2018, 11, 11), 96 | fields: { 97 | displayName: { type: 'string' }, 98 | }, 99 | indices: [], 100 | }, 101 | email: { 102 | version: new Date(2018, 11, 11), 103 | fields: { 104 | address: { type: 'string' }, 105 | isPrimary: { type: 'boolean' }, 106 | isActive: { type: 'boolean' }, 107 | }, 108 | relationships: [ 109 | { 110 | childOf: 'user', 111 | // alias: 'owner', // Defaults to the name of the parent collection 112 | // reverseAlias: 'usedEmails' // Defaults to the plural name of this collection, used to create child objects directly when creating the parent objects 113 | } 114 | ], 115 | indices: [], 116 | }, 117 | }) 118 | await storageManger.finishInitialization() 119 | 120 | const user = await storageManager.collection('user').createObject({displayName: 'Joe', emails: [ 121 | {address: 'joe@primary.com', isPrimary: true, isActive: true}, 122 | {address: 'joe@secondary.com', isPrimary: false, isActive: true}, 123 | ]}) 124 | 125 | // The parent objects pk is stored on child objects on the configured alias field 126 | const emails = await storageManager.collection('activationCode').findObjects({user: user.id'}) 127 | ``` 128 | 129 | ### connects 130 | 131 | Creates a many-to-many relationship by explictly defining a connection between them: 132 | ```typescript 133 | storageManager.registry.registerCollections({ 134 | user: { 135 | version: new Date(2018, 11, 11), 136 | fields: { 137 | displayName: { type: 'string' }, 138 | }, 139 | indices: [], 140 | }, 141 | newsletter: { 142 | version: new Date(2018, 11, 11), 143 | fields: { 144 | title: { type: 'string' }, 145 | }, 146 | relationships: [ 147 | { 148 | childOf: 'user', 149 | alias: 'owner', 150 | }, 151 | ], 152 | indices: [], 153 | }, 154 | subscription: { 155 | version: new Date(2018, 11, 11), 156 | fields: { 157 | isActive: { type: 'boolean' } 158 | }, 159 | relationships: [ 160 | { 161 | connects: ['user', 'newsletter'], 162 | aliases: ['subscriber', 'newsletter'], 163 | reverseAliases: ['newsletterSubscriptions', 'subscriptions'], 164 | }, 165 | ], 166 | indices: [], 167 | } 168 | }) 169 | await storageManger.finishInitialization() 170 | 171 | // For now, you'll have to create and retrieve parents, children and connections between them manually until we figure out a nice way to make this easier. 172 | const user = await storageManager.collection('user').createObject({displayName: 'Joe'}) 173 | const newsletter = await storageManager.collection('newsletter').createObject({title: 'The blahoo'}) 174 | const subscription = await storageManager.collection('newsletter').createObject({subscriber: user.id, newsletter: newsletter.id}) 175 | ``` 176 | 177 | Indices 178 | ======= 179 | 180 | TBD 181 | -------------------------------------------------------------------------------- /docs/middleware.md: -------------------------------------------------------------------------------- 1 | All CRUD operations under the hood use `StorageManager.operation()`, which then runs the operations through all configured middleware before calling `StorageBackend.operation()`. This is used by the device-to-device Sync functionality for example to log all database modifications to a separate log. `StorageMiddleware` usage looks like this: 2 | 3 | ```typescript 4 | import { StorageMiddleware } from '@worldbrain/storex/lib/types/middleware' 5 | 6 | export class LogMiddleware implements StorageMiddleware { 7 | public log : Array<{ operation : any, result : any }> = [] 8 | 9 | async process ({operation, next} : { operation : any[], next : { process : Function } }) { 10 | const result = await next.process({operation}) 11 | this.log.append({ operation, result }) 12 | return result 13 | } 14 | } 15 | 16 | // Setup your storageManager here 17 | const logMiddleware = new LogMiddleware() 18 | storageManager.setMiddleware([ 19 | logMiddleware, 20 | ]) 21 | await storageManager.collection('user').createObject({ name: 'Joe' }) 22 | console.log(logMiddleware.log) 23 | ``` 24 | -------------------------------------------------------------------------------- /docs/operations.md: -------------------------------------------------------------------------------- 1 | Data manipulation and retrieval 2 | =============================== 3 | 4 | Once you've registered your collection models and have initialized the StorageManager, you can begin playing with your data. For now, there are only basic CRUD operation, but we'll extend the functionality offered across different backends as needs and good patterns emerge. For anything you can't do through the core Storex API, the backends you're using should expose the lower layers for you to play around with (like the Sequelize models for SQL databases or the Dexie object for IndexedDB.) 5 | 6 | ## collection(name : string).createObject(``[, ``]) 7 | 8 | Creates a new object and any necessary child objects, cleaning and preparing necessay fields before inserting them into the DB. For the creation of objects with children, see [Relations](./collections.md#relationships). `` for now is empty, but will be the place to pass additional instructions to the back-end while creating the objects. 9 | 10 | **Returns**: Object with database generated and cleaned fields (auto-increment primary keys, random keys, etc.) and child objects created. 11 | 12 | **Example**: 13 | 14 | ```typescript 15 | storageManager.registry.registerCollections({ 16 | email: { 17 | version: new Date(2018, 11, 11), 18 | fields: { 19 | address: { type: 'string' }, 20 | isActive: { type: 'boolean' }, 21 | activationCode: { type: 'random-key' } 22 | }, 23 | indices: [] 24 | }, 25 | }) 26 | 27 | const email = await storageManager.collection('email').createObject({address: 'boo@bla.com', isActive: false}) 28 | console.log(email.activationCode) // Some random string 29 | ``` 30 | 31 | ## collection(name : string).updateObjects(``, ``[, ``]) 32 | 33 | 34 | Updates all objects matching ``, which a MongoDB-like filter. 35 | 36 | **Example**: 37 | 38 | ```typescript 39 | storageManager.registry.registerCollections({ 40 | email: { 41 | version: new Date(2018, 11, 11), 42 | fields: { 43 | address: { type: 'string' }, 44 | isActive: { type: 'boolean' }, 45 | activationCode: { type: 'random-key' } 46 | }, 47 | indices: [] 48 | }, 49 | }) 50 | 51 | const email = await storageManager.collection('email').createObject({address: 'boo@bla.com', isActive: false}) 52 | console.log(email.isActive) // false 53 | 54 | await storageManager.collection('email').updateObjects({address: 'boo@bla.com'}, {isActive: true}) 55 | console.log((await storageManager.collection('email').findOneObject({address: 'boo@bla.com'})).isActive) // false 56 | ``` 57 | 58 | ## collection(name : string).deleteObjects(``[, ``]) 59 | 60 | Deletes all objects from the database matching ``, which a MongoDB-like filter. Pass in `{}` as `` to delete all objects from DB. 61 | 62 | **Example**: 63 | 64 | ```typescript 65 | storageManager.registry.registerCollections({ 66 | email: { 67 | version: new Date(2018, 11, 11), 68 | fields: { 69 | address: { type: 'string' }, 70 | isActive: { type: 'boolean' }, 71 | activationCode: { type: 'random-key' } 72 | }, 73 | indices: [] 74 | }, 75 | }) 76 | 77 | const email = await storageManager.collection('email').createObject({address: 'boo@bla.com', isActive: false}) 78 | await storageManager.collection('email').deleteObjects({address: 'boo@bla.com'}) 79 | ``` 80 | 81 | ## collection(name : string).findObjects(``[, ``]) 82 | 83 | Fetches all objects from a collection matching `filter`. Currently supported `options` for Dexie back-end include: 84 | * `limit`: number 85 | * `skip`: number, skip the first x number of objects 86 | * `reverse`: boolean, reverse ordering of result set 87 | * `ignoreCase`: array of field names, field names to ignore case for while searching 88 | 89 | Example: 90 | 91 | ```typescript 92 | storageManager.registry.registerCollections({ 93 | email: { 94 | version: new Date(2018, 11, 11), 95 | fields: { 96 | address: { type: 'string' }, 97 | isActive: { type: 'boolean' }, 98 | activationCode: { type: 'random-key' } 99 | }, 100 | indices: [] 101 | }, 102 | }) 103 | 104 | await storageManager.collection('email').createObject({address: 'foo@bla.com', isActive: false}) 105 | await storageManager.collection('email').createObject({address: 'bar@bla.com', isActive: false}) 106 | console.log(await storageManager.collection('email').findObjects({isActive: false})) 107 | # [{address: 'foo@bla.com', isActive: false}, {address: 'bar@bla.com', isActive: false}] 108 | ``` 109 | 110 | ## collection(name : string).findOneObject(``[, ``]) 111 | 112 | Fetches a single objects from a collection matching `filter`. Currently supported `options` for Dexie back-end include: 113 | * `reverse`: boolean, reverse ordering of result set 114 | * `ignoreCase`: array of field names, field names to ignore case for while searching 115 | 116 | Example: 117 | 118 | ```typescript 119 | storageManager.registry.registerCollections({ 120 | email: { 121 | version: new Date(2018, 11, 11), 122 | fields: { 123 | address: { type: 'string' }, 124 | isActive: { type: 'boolean' }, 125 | activationCode: { type: 'random-key' } 126 | }, 127 | indices: [] 128 | }, 129 | }) 130 | 131 | await storageManager.collection('email').createObject({address: 'foo@bla.com', isActive: false}) 132 | await storageManager.collection('email').createObject({address: 'bar@bla.com', isActive: false}) 133 | console.log(await storageManager.collection('email').findObjects({address: 'foo@bla.com'})) 134 | # {address: 'foo@bla.com', isActive: false} 135 | ``` 136 | -------------------------------------------------------------------------------- /docs/plugins.md: -------------------------------------------------------------------------------- 1 | Storex allows you to implement custom operations on the back-end for application-specific functionality that you need to do on a lower level (directly execute an SQL statement for example), or include functionality you don't always want to include in your application (altering your schema structure for data migrations as an example.) 2 | 3 | ```typescript 4 | import StorageManager from "@worldbrain/storex"; 5 | import { StorageBackendPlugin } from "@worldbrain/storex/lib/backend"; 6 | import { SequelizeStorageBackend } from "@worldbrain/storex-backend-sequelize"; 7 | 8 | class TestSequelizeStorageBackendPlugin extends StorageBackendPlugin { 9 | install(backend : SequelizeStorageBackend) { 10 | super.install(backend) 11 | backend.registerOperation('myproject:sequelize.doSomething', async (foo, bar) => { 12 | backend.sequelize[backend.defaultDatabase] // Do something with the sequelize object 13 | return 'spam' 14 | }) 15 | } 16 | } 17 | 18 | const backend = new SequelizeStorageBackend(...) 19 | backend.use(new TestSequelizeStorageBackendPlugin()) 20 | const storageManager = new StorageManager({backend}) 21 | console.log(await storageManager.operation('myproject:sequelize.doSomething', 'foo, 'bar')) // 'spam' 22 | ``` 23 | 24 | Operation identifiers 25 | ===================== 26 | 27 | The identifiers are namespaced as follows `:.`. You can omit `` and ``, but the rules are: 28 | * If no `project` or `backend` is specified, you're registering a standardized operation like `alterSchema`, which is defined in an internal constant named `PLUGGABLE_CORE_OPERATIONS` in `@worldbrain/storex/ts/types/backend.ts`. 29 | * If `backend` is defined, it should be an operation defined on `backend.pluggableOperations`. 30 | * If you specify only a `project`, which might be the name of a plugin, or the application you're building on top of Storex, you can name your operation anything you want, unless you also specify `backend`, in which case it should be an operation defined on `backend.pluggableOperations`. 31 | -------------------------------------------------------------------------------- /docs/registry.md: -------------------------------------------------------------------------------- 1 | Coming soon! 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@worldbrain/storex", 3 | "version": "0.4.1", 4 | "license": "MIT", 5 | "description": "Storex core module - a storage layer without assumptions", 6 | "main": "lib/index.js", 7 | "typings": "lib/index", 8 | "scripts": { 9 | "prepare": "tsc", 10 | "prepare:watch": "npm run prepare -- -w", 11 | "test": "mocha --require ts-node/register \"ts/**/*.test.ts\"", 12 | "test:watch": "mocha -r source-map-support/register -r ts-node/register \"ts/**/*.test.ts\" --watch --watch-extensions ts" 13 | }, 14 | "keywords": [ 15 | "storage", 16 | "graph", 17 | "database", 18 | "typescript" 19 | ], 20 | "author": "Vincent den Boer", 21 | "repository": { 22 | "type": "git", 23 | "url": "https://github.com/WorldBrain/storex.git" 24 | }, 25 | "dependencies": { 26 | "event-emitter": "^0.3.5", 27 | "events": "^3.0.0", 28 | "lodash": "^4.17.11", 29 | "pluralize": "^7.0.0", 30 | "randombytes": "^2.0.6", 31 | "source-map-support": "0.5.16" 32 | }, 33 | "devDependencies": { 34 | "@types/chai": "^4.0.6", 35 | "@types/events": "^1.2.0", 36 | "@types/mocha": "^2.2.44", 37 | "@types/node": "^10.12.11", 38 | "chai": "^4.1.2", 39 | "expect": "^23.5.0", 40 | "mocha": "^4.0.1", 41 | "sinon": "^4.1.2", 42 | "ts-node": "^7.0.1", 43 | "typescript": "^3.7.5" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /ts/fields/index.ts: -------------------------------------------------------------------------------- 1 | import { PrimitiveFieldType } from '../types' 2 | import { RandomKeyField } from './random-key' 3 | import { Field } from './types' 4 | 5 | export class UrlField extends Field { 6 | primitiveType = 'string' as PrimitiveFieldType 7 | } 8 | 9 | export class MediaField extends Field { 10 | primitiveType = 'binary' as PrimitiveFieldType 11 | } 12 | 13 | export class FieldTypeRegistry { 14 | public fieldTypes : {[name : string] : {new () : Field}} = {} 15 | 16 | registerType(name : string, type : {new () : Field}) { 17 | this.fieldTypes[name] = type 18 | return this 19 | } 20 | 21 | registerTypes(fieldTypes : {[name : string] : {new () : Field}}) { 22 | Object.assign(this.fieldTypes, fieldTypes) 23 | return this 24 | } 25 | } 26 | 27 | export function createDefaultFieldTypeRegistry() { 28 | const registry = new FieldTypeRegistry() 29 | return registry.registerTypes({ 30 | 'random-key': RandomKeyField, 31 | 'url': UrlField, 32 | }) 33 | } 34 | -------------------------------------------------------------------------------- /ts/fields/random-key.ts: -------------------------------------------------------------------------------- 1 | import * as rawRandomBytes from 'randombytes' 2 | const randomBytes = rawRandomBytes.default || rawRandomBytes 3 | import { PrimitiveFieldType } from '../types' 4 | import { Field } from './types' 5 | 6 | export class RandomKeyField extends Field { 7 | primitiveType = 'string' 8 | length = 20 9 | 10 | async prepareForStorage(input): Promise { 11 | if (input) { 12 | return input 13 | } 14 | 15 | return await this.generateCode() 16 | } 17 | 18 | async generateCode() { 19 | const bytes = (await new Promise((resolve, reject) => { 20 | randomBytes(this.length, (err, result) => err ? reject(err) : resolve(result)) 21 | })) as any 22 | return bytes.toString('hex') 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /ts/fields/types.ts: -------------------------------------------------------------------------------- 1 | import { PrimitiveFieldType } from "../types" 2 | 3 | export abstract class Field { 4 | abstract primitiveType : PrimitiveFieldType 5 | 6 | async prepareForStorage(input) { 7 | return input 8 | } 9 | 10 | async prepareFromStorage(stored) { 11 | return stored 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /ts/index.tests.ts: -------------------------------------------------------------------------------- 1 | import expect from 'expect' 2 | import StorageManager from '.' 3 | import { StorageBackend, FieldType, CollectionFields, Relationship, PrimitiveFieldType } from './types' 4 | import { StorageBackendFeatureSupport } from './types/backend-features'; 5 | import { FieldTypeRegistry } from './fields'; 6 | import { Field } from './fields/types'; 7 | 8 | export type StorexBackendTestBackendCreator = (context: StorexBackendTestContext) => Promise 9 | export interface StorexBackendTestContext { 10 | cleanupFunction?: () => Promise 11 | } 12 | 13 | type TestContext = { backend: StorageBackend } 14 | function makeTestFactory(backendCreator: StorexBackendTestBackendCreator) { 15 | type FactoryOptions = { shouldSupport?: Array } 16 | type TestFunction = (context: TestContext) => Promise 17 | 18 | function factory(description: string, test?: TestFunction): void 19 | function factory(description: string, options: FactoryOptions, maybeTest?: TestFunction): void 20 | function factory(description: string, testOrOptions?: FactoryOptions | TestFunction, maybeTest?: TestFunction): void { 21 | const test = typeof testOrOptions !== 'function' ? maybeTest : testOrOptions 22 | const options = typeof testOrOptions !== 'function' ? testOrOptions : {} 23 | 24 | it(description, test && (async function () { 25 | const context: StorexBackendTestContext = {} 26 | const backend = await backendCreator(context) 27 | await skipIfNotSupported({ backend, shouldSupport: options.shouldSupport, testContext: this }) 28 | 29 | const testContext = this 30 | try { 31 | await test.call(testContext, { backend }) 32 | } finally { 33 | if (context.cleanupFunction) { 34 | await context.cleanupFunction() 35 | } 36 | } 37 | })) 38 | } 39 | 40 | return factory 41 | } 42 | 43 | export async function createTestStorageManager(backend: StorageBackend) { 44 | const storageManager = new StorageManager({ backend }) 45 | storageManager.registry.registerCollections({ 46 | user: { 47 | version: new Date(2018, 7, 31), 48 | fields: { 49 | identifier: { type: 'string' }, 50 | passwordHash: { type: 'string', optional: true }, 51 | isActive: { type: 'boolean' }, 52 | }, 53 | indices: [ 54 | { field: 'identifier' }, 55 | ] 56 | }, 57 | userEmail: { 58 | version: new Date(2018, 7, 31), 59 | fields: { 60 | email: { type: 'string' }, 61 | isVerified: { type: 'boolean' }, 62 | isPrimary: { type: 'boolean' }, 63 | }, 64 | relationships: [ 65 | { childOf: 'user', reverseAlias: 'emails' } 66 | ], 67 | indices: [ 68 | { field: [{ relationship: 'user' }, 'email'], unique: true } 69 | ] 70 | }, 71 | userEmailVerificationCode: { 72 | version: new Date(2018, 7, 31), 73 | fields: { 74 | code: { type: 'string' }, 75 | expiry: { type: 'datetime', optional: true } 76 | }, 77 | relationships: [ 78 | { singleChildOf: 'userEmail', reverseAlias: 'verificationCode' } 79 | ], 80 | indices: [ 81 | { field: 'code', unique: true } 82 | ] 83 | }, 84 | newsletter: { 85 | version: new Date(2018, 7, 31), 86 | fields: { 87 | name: { type: 'string' } 88 | }, 89 | indices: [ 90 | ] 91 | }, 92 | newsletterSubscription: { 93 | version: new Date(2018, 7, 31), 94 | fields: { 95 | }, 96 | relationships: [ 97 | { connects: ['user', 'newsletter'] } 98 | ], 99 | indices: [ 100 | ] 101 | } 102 | }) 103 | await storageManager.finishInitialization() 104 | 105 | return storageManager 106 | } 107 | 108 | export function generateTestObject( 109 | { email = 'blub@bla.com', passwordHash = 'hashed!', expires }: 110 | { email: string, passwordHash: string, expires: number }) { 111 | return { 112 | identifier: `email:${email}`, 113 | passwordHash, 114 | isActive: false, 115 | emails: [ 116 | { 117 | email, 118 | isVerified: false, 119 | isPrimary: true, 120 | verificationCode: { 121 | code: 'bla', 122 | expires 123 | } 124 | } 125 | ] 126 | } 127 | } 128 | 129 | async function skipIfNotSupported(options: { 130 | backend: StorageBackend, testContext?: Mocha.ITestCallbackContext, shouldSupport?: string[], 131 | }) { 132 | for (const feature of options.shouldSupport || []) { 133 | if (!options.backend.supports(feature)) { 134 | options.testContext.skip() 135 | } 136 | } 137 | } 138 | 139 | export function testStorageBackend(backendCreator: StorexBackendTestBackendCreator, { fullTextSearch }: { fullTextSearch?: boolean } = {}) { 140 | describe('Setup', () => { 141 | testStorageBackendSetup(backendCreator) 142 | }) 143 | 144 | describe('Individual operations', () => { 145 | testStorageBackendOperations(backendCreator) 146 | }) 147 | 148 | if (fullTextSearch) { 149 | describe('Full text search', () => { 150 | testStorageBackendFullTextSearch(backendCreator) 151 | }) 152 | } 153 | } 154 | 155 | export function testStorageBackendSetup(backendCreator: StorexBackendTestBackendCreator) { 156 | const it = makeTestFactory(backendCreator) 157 | 158 | it('should throw no errors trying to set up a collection with indexed fields', async (context: TestContext) => { 159 | const storageManager = new StorageManager({ backend: context.backend }) 160 | storageManager.registry.registerCollections({ 161 | pages: { 162 | version: new Date(2018, 9, 13), 163 | fields: { 164 | url: { type: 'string' }, 165 | text: { type: 'text' }, 166 | }, 167 | indices: [{ field: 'text' }] 168 | } 169 | }) 170 | await storageManager.finishInitialization() 171 | await storageManager.backend.migrate() 172 | }) 173 | 174 | it('should throw no errors trying to set up a collection with indexed childOf relations', async (context: TestContext) => { 175 | const storageManager = new StorageManager({ backend: context.backend }) 176 | storageManager.registry.registerCollections({ 177 | page: { 178 | version: new Date(2018, 9, 13), 179 | fields: { 180 | text: { type: 'text' }, 181 | }, 182 | }, 183 | note: { 184 | version: new Date(2018, 9, 13), 185 | fields: {}, 186 | relationships: [ 187 | { childOf: 'page' } 188 | ], 189 | indices: [ 190 | { field: { relationship: 'page' } } 191 | ] 192 | } 193 | 194 | }) 195 | await storageManager.finishInitialization() 196 | await storageManager.backend.migrate() 197 | }) 198 | 199 | it('should throw no errors trying to set up a collection with indexed childOf relations with custom aliases', async (context: TestContext) => { 200 | const storageManager = new StorageManager({ backend: context.backend }) 201 | storageManager.registry.registerCollections({ 202 | page: { 203 | version: new Date(2018, 9, 13), 204 | fields: { 205 | text: { type: 'text' }, 206 | }, 207 | }, 208 | note: { 209 | version: new Date(2018, 9, 13), 210 | fields: {}, 211 | relationships: [ 212 | { childOf: 'page', alias: 'thePage' } 213 | ], 214 | indices: [ 215 | { field: { relationship: 'thePage' } } 216 | ] 217 | } 218 | }) 219 | await storageManager.finishInitialization() 220 | await storageManager.backend.migrate() 221 | }) 222 | 223 | it('should throw no errors trying to set up a collection with indexed childOf relations with custom aliases and fieldNames', { shouldSupport: ['customFieldNames'] }, async (context: TestContext) => { 224 | const storageManager = new StorageManager({ backend: context.backend }) 225 | storageManager.registry.registerCollections({ 226 | page: { 227 | version: new Date(2018, 9, 13), 228 | fields: { 229 | text: { type: 'text' }, 230 | }, 231 | }, 232 | note: { 233 | version: new Date(2018, 9, 13), 234 | fields: {}, 235 | relationships: [ 236 | { childOf: 'page', alias: 'thePage', fieldName: 'thePageField' } 237 | ], 238 | indices: [ 239 | { field: { relationship: 'thePage' } } 240 | ] 241 | } 242 | }) 243 | await storageManager.finishInitialization() 244 | await storageManager.backend.migrate() 245 | }) 246 | 247 | it('should throw no errors trying to set up a collection with indexed singleChildOf relations', async (context: TestContext) => { 248 | const storageManager = new StorageManager({ backend: context.backend }) 249 | storageManager.registry.registerCollections({ 250 | page: { 251 | version: new Date(2018, 9, 13), 252 | fields: { 253 | text: { type: 'text' }, 254 | }, 255 | }, 256 | note: { 257 | version: new Date(2018, 9, 13), 258 | fields: {}, 259 | relationships: [ 260 | { childOf: 'page' } 261 | ], 262 | indices: [ 263 | { field: { relationship: 'page' } } 264 | ] 265 | } 266 | 267 | }) 268 | await storageManager.finishInitialization() 269 | await storageManager.backend.migrate() 270 | }) 271 | 272 | it('should throw no errors trying to set up a collection with indexed singleChildOf relations with custom aliases', async (context: TestContext) => { 273 | const storageManager = new StorageManager({ backend: context.backend }) 274 | storageManager.registry.registerCollections({ 275 | page: { 276 | version: new Date(2018, 9, 13), 277 | fields: { 278 | text: { type: 'text' }, 279 | }, 280 | }, 281 | note: { 282 | version: new Date(2018, 9, 13), 283 | fields: {}, 284 | relationships: [ 285 | { childOf: 'page', alias: 'thePage' } 286 | ], 287 | indices: [ 288 | { field: { relationship: 'thePage' } } 289 | ] 290 | } 291 | }) 292 | await storageManager.finishInitialization() 293 | await storageManager.backend.migrate() 294 | }) 295 | 296 | it('should throw no errors trying to set up a collection with indexed singleChildOf relations with custom aliases and fieldNames', async (context: TestContext) => { 297 | const storageManager = new StorageManager({ backend: context.backend }) 298 | storageManager.registry.registerCollections({ 299 | page: { 300 | version: new Date(2018, 9, 13), 301 | fields: { 302 | text: { type: 'text' }, 303 | }, 304 | }, 305 | note: { 306 | version: new Date(2018, 9, 13), 307 | fields: {}, 308 | relationships: [ 309 | { childOf: 'page', alias: 'thePage', fieldName: 'thePageField' } 310 | ], 311 | indices: [ 312 | { field: { relationship: 'thePage' } } 313 | ] 314 | } 315 | }) 316 | await storageManager.finishInitialization() 317 | await storageManager.backend.migrate() 318 | }) 319 | } 320 | 321 | export function testStorageBackendFullTextSearch(backendCreator: StorexBackendTestBackendCreator) { 322 | const it = makeTestFactory(backendCreator) 323 | 324 | const createTestStorageManager = async function (options: { context: TestContext }) { 325 | const storageManager = new StorageManager({ backend: options.context.backend }) 326 | storageManager.registry.registerCollections({ 327 | pages: { 328 | version: new Date(2018, 9, 13), 329 | fields: { 330 | url: { type: 'string' }, 331 | text: { type: 'text' }, 332 | }, 333 | indices: [{ field: 'text' }] 334 | } 335 | }) 336 | await storageManager.finishInitialization() 337 | await storageManager.backend.migrate() 338 | return storageManager 339 | } 340 | 341 | it('should do full-text search of whole words', async function (context: TestContext) { 342 | if (!context.backend.supports('fullTextSearch')) { 343 | this.skip() 344 | } 345 | 346 | const storageManager = await createTestStorageManager({ context }) 347 | await storageManager.collection('pages').createObject({ 348 | url: 'https://www.test.com/', 349 | text: 'testing this stuff is not always easy' 350 | }) 351 | 352 | const results = await storageManager.collection('pages').findObjects({ text: ['easy'] }) 353 | expect(results).toMatchObject([ 354 | { 355 | url: 'https://www.test.com/', 356 | text: 'testing this stuff is not always easy', 357 | } 358 | ]) 359 | }) 360 | } 361 | 362 | export function testStorageBackendOperations(backendCreator: StorexBackendTestBackendCreator) { 363 | const it = makeTestFactory(backendCreator) 364 | 365 | async function setupUserAdminTest(options: { context: TestContext }) { 366 | const storageManager = await createTestStorageManager(options.context.backend) 367 | await storageManager.backend.migrate() 368 | return { backend: options.context.backend, storageManager } 369 | } 370 | 371 | async function setupChildOfTest(options: { 372 | backend: StorageBackend, 373 | userFields?: CollectionFields, emailFields?: CollectionFields, 374 | relationshipType?: 'childOf' | 'singleChildOf', 375 | relationshipOptions?: Partial 376 | }) { 377 | const relationshipOptions = options.relationshipOptions || {} 378 | const relationshipType = options.relationshipType || 'childOf' 379 | 380 | const storageManager = new StorageManager({ backend: options.backend }) 381 | storageManager.registry.registerCollections({ 382 | user: { 383 | version: new Date(2019, 1, 1), 384 | fields: options.userFields || { 385 | displayName: { type: 'string' } 386 | } 387 | }, 388 | email: { 389 | version: new Date(2019, 1, 1), 390 | fields: options.emailFields || { 391 | address: { type: 'string' } 392 | }, 393 | relationships: [ 394 | { [relationshipType as any]: 'user', ...relationshipOptions } as any 395 | ] 396 | } 397 | }) 398 | await storageManager.finishInitialization() 399 | await storageManager.backend.migrate() 400 | return { storageManager } 401 | } 402 | 403 | async function setupSimpleTest(options: { 404 | context: TestContext, fieldType?: FieldType, 405 | fieldTypes?: FieldTypeRegistry, fields?: CollectionFields 406 | shouldSupport?: string[], testContext?: Mocha.ITestCallbackContext 407 | }) { 408 | await skipIfNotSupported({ backend: options.context.backend, ...options }) 409 | const storageManager = new StorageManager({ backend: options.context.backend, fieldTypes: options.fieldTypes }) 410 | storageManager.registry.registerCollections({ 411 | object: { 412 | version: new Date(2019, 1, 1), 413 | fields: options.fields || { 414 | field: { type: options.fieldType } 415 | } 416 | }, 417 | }) 418 | await storageManager.finishInitialization() 419 | await storageManager.backend.migrate() 420 | return { storageManager } 421 | } 422 | 423 | describe('creating and finding', () => { 424 | it('should be able to create objects and find them again by pk', async function (context: TestContext) { 425 | const { storageManager } = await setupUserAdminTest({ context }) 426 | const { object } = await storageManager.collection('user').createObject({ identifier: 'email:joe@doe.com', isActive: true }) 427 | expect(object.id).not.toBe(undefined) 428 | const foundObject = await storageManager.collection('user').findOneObject({ id: object.id }) 429 | expect(foundObject).toEqual({ 430 | id: object.id, 431 | identifier: 'email:joe@doe.com', isActive: true, 432 | passwordHash: null, 433 | }) 434 | 435 | expect(await storageManager.collection('user').findOneObject({ 436 | id: typeof object.id === 'string' ? object.id + 'bla' : object.id + 1 437 | })).toEqual(null) 438 | }) 439 | 440 | it('should be able to create objects and find them again by string field', async function (context: TestContext) { 441 | const { storageManager } = await setupUserAdminTest({ context }) 442 | const { object } = await storageManager.collection('user').createObject({ identifier: 'email:joe@doe.com', isActive: true }) 443 | expect(object.id).not.toBe(undefined) 444 | 445 | const foundObject = await storageManager.collection('user').findOneObject({ identifier: 'email:joe@doe.com' }) 446 | expect(foundObject).toEqual({ 447 | id: object.id, 448 | identifier: 'email:joe@doe.com', isActive: true, 449 | passwordHash: null, 450 | }) 451 | 452 | expect(await storageManager.collection('user').findOneObject({ identifier: 'email:bla!!!' })).toEqual(null) 453 | }) 454 | 455 | it('should be able to create objects and find them again by boolean field', async function (context: TestContext) { 456 | const { storageManager } = await setupUserAdminTest({ context }) 457 | const { object } = await storageManager.collection('user').createObject({ identifier: 'email:joe@doe.com', isActive: true }) 458 | expect(object.id).not.toBe(undefined) 459 | const foundObject = await storageManager.collection('user').findOneObject({ isActive: true }) 460 | expect(foundObject).toEqual({ 461 | id: object.id, 462 | identifier: 'email:joe@doe.com', isActive: true, 463 | passwordHash: null, 464 | }) 465 | expect(await storageManager.collection('user').findOneObject({ isActive: false })).toBe(null) 466 | }) 467 | 468 | it('should be able to bulk create objects and find them again', { shouldSupport: ['rawCreateObjects'] }, async function (context: TestContext) { 469 | const { storageManager } = await setupUserAdminTest({ context }) 470 | const data = [ 471 | { identifier: 'email:joe@doe.com', isActive: true, passwordHash: '123' }, 472 | { identifier: 'email:jane@doe.com', isActive: true, passwordHash: '456' }, 473 | ] 474 | const { objects } = await storageManager.operation('rawCreateObjects', 'user', data, { withNestedObjects: false }) 475 | 476 | const foundObjects = await storageManager.collection('user').findObjects({ isActive: true }) 477 | expect(foundObjects).toEqual(data.map((record, index) => { 478 | record['id'] = index + 1; 479 | return record 480 | })) 481 | expect(await storageManager.collection('user').findOneObject({ isActive: false })).toBe(null) 482 | }) 483 | }) 484 | 485 | describe('optional fields', () => { 486 | it('should set fields to null when omitted during saving', async function (context: TestContext) { 487 | const { storageManager } = await setupSimpleTest({ 488 | context, fields: { 489 | test: { type: 'string', optional: true } 490 | } 491 | }) 492 | 493 | const { object: createdObject } = await storageManager.collection('object').createObject({}) 494 | expect(createdObject.id).not.toBe(undefined) 495 | expect(createdObject).toEqual({ 496 | id: createdObject.id, 497 | test: null, 498 | }) 499 | }) 500 | 501 | it('should set fields to null upon retrieval when omitted during saving', async function (context: TestContext) { 502 | const { storageManager } = await setupSimpleTest({ 503 | context, fields: { 504 | test: { type: 'string', optional: true } 505 | } 506 | }) 507 | 508 | const { object: createdObject } = await storageManager.collection('object').createObject({}) 509 | const retrievedObject = await storageManager.collection('object').findObject({ id: createdObject.id }) 510 | expect(retrievedObject).toEqual({ 511 | id: createdObject.id, 512 | test: null, 513 | }) 514 | }) 515 | }) 516 | 517 | describe('where clause operators', () => { 518 | function operatorTests(fieldType: FieldType) { 519 | describe(`field type: ${fieldType}`, () => { 520 | it('should be able to find by $lt operator', async function (context: TestContext) { 521 | const { storageManager } = await setupSimpleTest({ 522 | context, 523 | fieldType 524 | }) 525 | 526 | await storageManager.collection('object').createObject({ field: 1 }) 527 | await storageManager.collection('object').createObject({ field: 2 }) 528 | await storageManager.collection('object').createObject({ field: 3 }) 529 | const results = await storageManager.collection('object').findObjects({ field: { $lt: 3 } }) 530 | expect(results).toContainEqual(expect.objectContaining({ field: 1 })) 531 | expect(results).toContainEqual(expect.objectContaining({ field: 2 })) 532 | expect(results).not.toContainEqual(expect.objectContaining({ field: 3 })) 533 | }) 534 | 535 | it('should be able to find by $lte operator', async function (context: TestContext) { 536 | const { storageManager } = await setupSimpleTest({ 537 | context, 538 | fieldType 539 | }) 540 | 541 | await storageManager.collection('object').createObject({ field: 1 }) 542 | await storageManager.collection('object').createObject({ field: 2 }) 543 | await storageManager.collection('object').createObject({ field: 3 }) 544 | const results = await storageManager.collection('object').findObjects({ field: { $lte: 2 } }) 545 | expect(results).toContainEqual(expect.objectContaining({ field: 1 })) 546 | expect(results).toContainEqual(expect.objectContaining({ field: 2 })) 547 | expect(results).not.toContainEqual(expect.objectContaining({ field: 3 })) 548 | }) 549 | 550 | it('should be able to find by $gt operator', async function (context: TestContext) { 551 | const { storageManager } = await setupSimpleTest({ 552 | context, 553 | fieldType 554 | }) 555 | 556 | await storageManager.collection('object').createObject({ field: 1 }) 557 | await storageManager.collection('object').createObject({ field: 2 }) 558 | await storageManager.collection('object').createObject({ field: 3 }) 559 | const results = await storageManager.collection('object').findObjects({ field: { $gt: 1 } }) 560 | expect(results).toContainEqual(expect.objectContaining({ field: 2 })) 561 | expect(results).toContainEqual(expect.objectContaining({ field: 3 })) 562 | expect(results).not.toContainEqual(expect.objectContaining({ field: 1 })) 563 | }) 564 | 565 | it('should be able to find by $gte operator', async function (context: TestContext) { 566 | const { storageManager } = await setupSimpleTest({ 567 | context, 568 | fieldType 569 | }) 570 | 571 | await storageManager.collection('object').createObject({ field: 1 }) 572 | await storageManager.collection('object').createObject({ field: 2 }) 573 | await storageManager.collection('object').createObject({ field: 3 }) 574 | const results = await storageManager.collection('object').findObjects({ field: { $gte: 2 } }) 575 | expect(results).toContainEqual(expect.objectContaining({ field: 2 })) 576 | expect(results).toContainEqual(expect.objectContaining({ field: 3 })) 577 | expect(results).not.toContainEqual(expect.objectContaining({ field: 1 })) 578 | }) 579 | }) 580 | } 581 | 582 | operatorTests('int') 583 | operatorTests('timestamp') 584 | }) 585 | 586 | describe('sorting', () => { 587 | it('should be able to order results in ascending order', async function (context: TestContext) { 588 | const { storageManager } = await setupSimpleTest({ 589 | context, 590 | fieldType: 'int', shouldSupport: ['singleFieldSorting'], testContext: this 591 | }) 592 | 593 | await storageManager.collection('object').createObject({ field: 2 }) 594 | await storageManager.collection('object').createObject({ field: 1 }) 595 | await storageManager.collection('object').createObject({ field: 3 }) 596 | expect(await storageManager.collection('object').findObjects({ field: { $gte: 1 } }, { order: [['field', 'asc']] })).toEqual([ 597 | expect.objectContaining({ field: 1 }), 598 | expect.objectContaining({ field: 2 }), 599 | expect.objectContaining({ field: 3 }), 600 | ]) 601 | }) 602 | 603 | it('should be able to order results in descending order', async function (context: TestContext) { 604 | const { storageManager } = await setupSimpleTest({ 605 | context, 606 | fieldType: 'int', shouldSupport: ['singleFieldSorting'], testContext: this 607 | }) 608 | 609 | await storageManager.collection('object').createObject({ field: 2 }) 610 | await storageManager.collection('object').createObject({ field: 1 }) 611 | await storageManager.collection('object').createObject({ field: 3 }) 612 | expect(await storageManager.collection('object').findObjects({ field: { $gte: 1 } }, { order: [['field', 'desc']] })).toEqual([ 613 | expect.objectContaining({ field: 3 }), 614 | expect.objectContaining({ field: 2 }), 615 | expect.objectContaining({ field: 1 }), 616 | ]) 617 | }) 618 | }) 619 | 620 | describe('limiting', () => { 621 | it('should be able to limit ascending results', async function (context: TestContext) { 622 | const { storageManager } = await setupSimpleTest({ 623 | context, 624 | fieldType: 'int', shouldSupport: ['singleFieldSorting', 'resultLimiting'], testContext: this 625 | }) 626 | 627 | await storageManager.collection('object').createObject({ field: 2 }) 628 | await storageManager.collection('object').createObject({ field: 1 }) 629 | await storageManager.collection('object').createObject({ field: 3 }) 630 | expect(await storageManager.collection('object').findObjects({ field: { $gte: 1 } }, { order: [['field', 'asc']], limit: 2 })).toEqual([ 631 | expect.objectContaining({ field: 1 }), 632 | expect.objectContaining({ field: 2 }), 633 | ]) 634 | }) 635 | 636 | it('should be able to limit descending results', async function (context: TestContext) { 637 | const { storageManager } = await setupSimpleTest({ 638 | context, 639 | fieldType: 'int', shouldSupport: ['singleFieldSorting', 'resultLimiting'], testContext: this 640 | }) 641 | 642 | await storageManager.collection('object').createObject({ field: 2 }) 643 | await storageManager.collection('object').createObject({ field: 1 }) 644 | await storageManager.collection('object').createObject({ field: 3 }) 645 | expect(await storageManager.collection('object').findObjects({ field: { $gte: 1 } }, { order: [['field', 'desc']], limit: 2 })).toEqual([ 646 | expect.objectContaining({ field: 3 }), 647 | expect.objectContaining({ field: 2 }), 648 | ]) 649 | }) 650 | }) 651 | 652 | describe('updating', () => { 653 | it('should be able to update objects by string pk', async function (context: TestContext) { 654 | const { storageManager } = await setupUserAdminTest({ context }) 655 | const { object } = await storageManager.collection('user').createObject({ identifier: 'email:joe@doe.com', isActive: false }) 656 | expect(object.id).not.toBe(undefined) 657 | await storageManager.collection('user').updateOneObject({ id: object.id }, { isActive: true }) 658 | const foundObject = await storageManager.collection('user').findOneObject({ id: object.id }) 659 | expect(foundObject).toEqual({ 660 | id: object.id, 661 | identifier: 'email:joe@doe.com', isActive: true, 662 | passwordHash: null, 663 | }) 664 | }) 665 | 666 | it('should be able to update objects by string field', async function (context: TestContext) { 667 | const { storageManager } = await setupUserAdminTest({ context }) 668 | const { object } = await storageManager.collection('user').createObject({ identifier: 'email:joe@doe.com', isActive: false }) 669 | expect(object.id).not.toBe(undefined) 670 | await storageManager.collection('user').updateObjects({ identifier: 'email:joe@doe.com' }, { isActive: true }) 671 | const foundObject = await storageManager.collection('user').findOneObject({ id: object.id }) 672 | expect(foundObject).toEqual({ 673 | id: object.id, 674 | identifier: 'email:joe@doe.com', isActive: true, 675 | passwordHash: null, 676 | }) 677 | }) 678 | }) 679 | 680 | describe('batching', () => { 681 | it('should correctly do batch operations containing only creates', { shouldSupport: ['executeBatch'] }, async function (context: TestContext) { 682 | const { storageManager } = await setupChildOfTest({ backend: context.backend }) 683 | const { info } = await storageManager.operation('executeBatch', [ 684 | { 685 | placeholder: 'jane', 686 | operation: 'createObject', 687 | collection: 'user', 688 | args: { 689 | displayName: 'Jane' 690 | } 691 | }, 692 | { 693 | placeholder: 'joe', 694 | operation: 'createObject', 695 | collection: 'user', 696 | args: { 697 | displayName: 'Joe' 698 | } 699 | }, 700 | { 701 | placeholder: 'joeEmail', 702 | operation: 'createObject', 703 | collection: 'email', 704 | args: { 705 | address: 'joe@doe.com' 706 | }, 707 | replace: [{ 708 | path: 'user', 709 | placeholder: 'joe', 710 | }] 711 | }, 712 | ]) 713 | 714 | expect(info).toEqual({ 715 | jane: { 716 | object: expect.objectContaining({ 717 | id: expect.anything(), 718 | displayName: 'Jane', 719 | }) 720 | }, 721 | joe: { 722 | object: expect.objectContaining({ 723 | id: expect.anything(), 724 | displayName: 'Joe', 725 | }) 726 | }, 727 | joeEmail: { 728 | object: expect.objectContaining({ 729 | id: expect.anything(), 730 | user: expect.anything(), 731 | address: 'joe@doe.com' 732 | }) 733 | } 734 | }) 735 | expect(info['joeEmail']['object']['user']).toEqual(info['joe']['object']['id']) 736 | }) 737 | 738 | it('should ignore empty batch operations', { shouldSupport: ['executeBatch'] }, async function (context: TestContext) { 739 | const { storageManager } = await setupChildOfTest({ backend: context.backend }) 740 | await storageManager.operation('executeBatch', []) 741 | }) 742 | 743 | it('should support batch operations with compound primary keys') 744 | 745 | it('should support batches with updateObjects operations', { shouldSupport: ['executeBatch'] }, async function (context: TestContext) { 746 | const { storageManager } = await setupChildOfTest({ backend: context.backend }) 747 | const { object: object1 } = await storageManager.collection('user').createObject({ displayName: 'Jack' }) 748 | const { object: object2 } = await storageManager.collection('user').createObject({ displayName: 'Jane' }) 749 | await storageManager.operation('executeBatch', [ 750 | { operation: 'updateObjects', collection: 'user', where: { id: object1.id }, updates: { displayName: 'Jack 2' } }, 751 | { operation: 'updateObjects', collection: 'user', where: { id: object2.id }, updates: { displayName: 'Jane 2' } }, 752 | ]) 753 | expect([ 754 | await storageManager.collection('user').findOneObject({ id: object1.id }), 755 | await storageManager.collection('user').findOneObject({ id: object2.id }), 756 | ]).toEqual([ 757 | { id: object1.id, displayName: 'Jack 2' }, 758 | { id: object2.id, displayName: 'Jane 2' }, 759 | ]) 760 | }) 761 | 762 | it('should support batches with deleteObjects operations', { shouldSupport: ['executeBatch'] }, async function (context: TestContext) { 763 | const { storageManager } = await setupChildOfTest({ backend: context.backend }) 764 | const { object: object1 } = await storageManager.collection('user').createObject({ displayName: 'Jack' }) 765 | const { object: object2 } = await storageManager.collection('user').createObject({ displayName: 'Jane' }) 766 | await storageManager.operation('executeBatch', [ 767 | { operation: 'deleteObjects', collection: 'user', where: { id: object1.id } }, 768 | { operation: 'deleteObjects', collection: 'user', where: { id: object2.id } }, 769 | ]) 770 | expect([ 771 | await storageManager.collection('user').findOneObject({ id: object1.id }), 772 | await storageManager.collection('user').findOneObject({ id: object2.id }), 773 | ]).toEqual([ 774 | null, 775 | null, 776 | ]) 777 | }) 778 | }) 779 | 780 | describe('deletion', () => { 781 | it('should be able to delete single objects by pk', async function (context: TestContext) { 782 | const { storageManager } = await setupSimpleTest({ 783 | context, 784 | fieldType: 'int' 785 | }) 786 | const { object: object1 } = await storageManager.collection('object').createObject({ field: 1 }) 787 | const { object: object2 } = await storageManager.collection('object').createObject({ field: 2 }) 788 | await storageManager.collection('object').deleteOneObject(object1) 789 | expect(await storageManager.collection('object').findObjects({})).toEqual([ 790 | expect.objectContaining({ id: object2.id }) 791 | ]) 792 | }) 793 | 794 | it('should be able to delete multiple objects by pk', async function (context: TestContext) { 795 | const { storageManager } = await setupSimpleTest({ 796 | context, 797 | fieldType: 'int' 798 | }) 799 | const { object: object1 } = await storageManager.collection('object').createObject({ field: 1 }) 800 | const { object: object2 } = await storageManager.collection('object').createObject({ field: 2 }) 801 | const { object: object3 } = await storageManager.collection('object').createObject({ field: 3 }) 802 | await storageManager.collection('object').deleteObjects({ id: { $in: [object1.id, object2.id] } }) 803 | const results = await storageManager.collection('object').findObjects({}) 804 | expect(await storageManager.collection('object').findObjects({})).toEqual([ 805 | expect.objectContaining({ id: object3.id }) 806 | ]) 807 | }) 808 | }) 809 | 810 | describe('counting', () => { 811 | it('should be able to count all objects', { shouldSupport: ['count'] }, async function (context: TestContext) { 812 | const { storageManager } = await setupSimpleTest({ 813 | context, 814 | fieldType: 'int' 815 | }) 816 | const { object: object1 } = await storageManager.collection('object').createObject({ field: 1 }) 817 | const { object: object2 } = await storageManager.collection('object').createObject({ field: 2 }) 818 | expect(await storageManager.collection('object').countObjects({})).toEqual(2) 819 | }) 820 | 821 | it('should be able to count objects filtered by field equality', { shouldSupport: ['count'] }, async function (context: TestContext) { 822 | const { storageManager } = await setupSimpleTest({ 823 | context, 824 | fieldType: 'int' 825 | }) 826 | const { object: object1 } = await storageManager.collection('object').createObject({ field: 1 }) 827 | const { object: object2 } = await storageManager.collection('object').createObject({ field: 2 }) 828 | expect(await storageManager.collection('object').countObjects({ field: 2 })).toEqual(1) 829 | }) 830 | 831 | it('should be able to count objects filtered by field $lt comparison', { shouldSupport: ['count'] }, async function (context: TestContext) { 832 | const { storageManager } = await setupSimpleTest({ 833 | context, 834 | fieldType: 'int' 835 | }) 836 | const { object: object1 } = await storageManager.collection('object').createObject({ field: 1 }) 837 | const { object: object2 } = await storageManager.collection('object').createObject({ field: 2 }) 838 | expect(await storageManager.collection('object').countObjects({ field: { $lt: 2 } })).toEqual(1) 839 | }) 840 | }) 841 | 842 | describe('relationships', () => { 843 | for (const relationshipType of ['childOf', 'singleChildOf'] as ('childOf' | 'singleChildOf')[]) { 844 | it(`should correctly store objects with ${relationshipType} relationships with a custom fieldName`, { shouldSupport: ['customFieldNames'] }, async function (context: TestContext) { 845 | const { storageManager } = await setupChildOfTest({ 846 | backend: context.backend, 847 | relationshipType, relationshipOptions: { fieldName: 'userId' } 848 | }) 849 | 850 | const { object: user } = await storageManager.collection('user').createObject({ displayName: 'Joe' }) 851 | const { object: email } = await storageManager.collection('email').createObject({ user: user.id, address: 'joe@joe.com' }) 852 | expect(email).toEqual({ id: expect.anything(), address: 'joe@joe.com', user: user.id }) 853 | }) 854 | 855 | it(`should correctly find objects filtered by ${relationshipType} relationships with a custom fieldName`, { shouldSupport: ['customFieldNames'] }, async function (context: TestContext) { 856 | const { storageManager } = await setupChildOfTest({ 857 | backend: context.backend, 858 | relationshipType, relationshipOptions: { fieldName: 'userId' } 859 | }) 860 | 861 | const { object: user } = await storageManager.collection('user').createObject({ displayName: 'Joe' }) 862 | const { object: email } = await storageManager.collection('email').createObject({ user: user.id, address: 'joe@joe.com' }) 863 | expect(await storageManager.collection('email').findObject({ user: user.id })).toEqual(email) 864 | }) 865 | 866 | it(`should correctly delete objects filtered by ${relationshipType} relationships with a custom fieldName`, { shouldSupport: ['customFieldNames'] }, async function (context: TestContext) { 867 | const { storageManager } = await setupChildOfTest({ 868 | backend: context.backend, 869 | relationshipType, relationshipOptions: { fieldName: 'userId' } 870 | }) 871 | 872 | const { object: user } = await storageManager.collection('user').createObject({ displayName: 'Joe' }) 873 | const { object: user2 } = await storageManager.collection('user').createObject({ displayName: 'Jack' }) 874 | const { object: email } = await storageManager.collection('email').createObject({ user: user.id, address: 'joe@joe.com' }) 875 | const { object: email2 } = await storageManager.collection('email').createObject({ user: user2.id, address: 'jack@joe.com' }) 876 | 877 | await storageManager.collection('email').deleteObjects({ user: user2.id }) 878 | expect(await storageManager.collection('email').findObject({ user: user.id })).toEqual(email) 879 | expect(await storageManager.collection('email').findObject({ user: user2.id })).toEqual(null) 880 | }) 881 | } 882 | }) 883 | 884 | describe('custom fields', () => { 885 | class ReadWriteCustomField extends Field { 886 | primitiveType: PrimitiveFieldType = 'string' 887 | 888 | async prepareForStorage(value: any) { 889 | return `Stored: ${value}` 890 | } 891 | 892 | async prepareFromStorage(value: any) { 893 | return `Found: ${value}` 894 | } 895 | } 896 | class WriteOnlyCustomField extends Field { 897 | primitiveType: PrimitiveFieldType = 'string' 898 | 899 | async prepareForStorage(value: any) { 900 | return `Stored: ${value}` 901 | } 902 | } 903 | 904 | async function setupTest(context: TestContext, options: { customField: new () => Field }) { 905 | const fieldTypes = new FieldTypeRegistry() 906 | fieldTypes.registerType('random-key', options.customField) 907 | 908 | const { storageManager } = await setupSimpleTest({ 909 | context, fieldTypes, fields: { 910 | fieldString: { type: 'string' }, 911 | fieldCustom: { type: 'random-key', optional: true }, 912 | } 913 | }) 914 | return { storageManager } 915 | } 916 | 917 | it('should correctly process custom fields on create and find', { shouldSupport: ['customFields'] }, async (context: TestContext) => { 918 | const { storageManager } = await setupTest(context, { customField: ReadWriteCustomField }) 919 | const { object: newObject } = await storageManager.collection('object').createObject({ 920 | fieldString: 'test', 921 | fieldCustom: 'bla' 922 | }) 923 | expect(newObject).toEqual({ 924 | id: expect.anything(), 925 | fieldString: 'test', 926 | fieldCustom: 'bla' 927 | }) 928 | 929 | const foundObjects = await storageManager.collection('object').findObjects({}) 930 | expect(foundObjects).toEqual([{ 931 | id: newObject.id, 932 | fieldString: 'test', 933 | fieldCustom: 'Found: Stored: bla', 934 | }]) 935 | }) 936 | 937 | it( 938 | 'should not try to process custom fields that are not present on object when ' + 939 | 'doing an update on an object without a custom field that modifies both reads and writes present', 940 | { shouldSupport: ['customFields'] }, 941 | async (context: TestContext) => { 942 | const { storageManager } = await setupTest(context, { customField: ReadWriteCustomField }) 943 | const { object: newObject } = await storageManager.collection('object').createObject({ 944 | fieldString: 'test' 945 | }) 946 | expect(newObject).toEqual({ 947 | id: expect.anything(), 948 | fieldString: 'test', 949 | fieldCustom: null, 950 | }) 951 | 952 | const foundObjectsBeforeUpdate = await storageManager.collection('object').findObjects({}) 953 | expect(foundObjectsBeforeUpdate).toEqual([{ 954 | id: newObject.id, 955 | fieldString: 'test', 956 | fieldCustom: 'Found: Stored: undefined', 957 | }]) 958 | 959 | await storageManager.collection('object').updateObjects({ id: newObject.id }, { fieldString: 'new test' }) 960 | 961 | const foundObjectAfterUpdate = await storageManager.collection('object').findObjects({}) 962 | expect(foundObjectAfterUpdate).toEqual([{ 963 | id: newObject.id, 964 | fieldString: 'new test', 965 | fieldCustom: 'Found: Stored: undefined', 966 | }]) 967 | }) 968 | 969 | it( 970 | 'should not try to process custom fields that are not present on object when ' + 971 | 'doing an update on an object with a custom field that modifies both reads and writes present', 972 | { shouldSupport: ['customFields'] }, 973 | async (context: TestContext) => { 974 | const { storageManager } = await setupTest(context, { customField: ReadWriteCustomField }) 975 | const { object: newObject } = await storageManager.collection('object').createObject({ 976 | fieldString: 'test', 977 | fieldCustom: 'bla' 978 | }) 979 | expect(newObject).toEqual({ 980 | id: expect.anything(), 981 | fieldString: 'test', 982 | fieldCustom: 'bla', 983 | }) 984 | 985 | const foundObjectsBeforeUpdate = await storageManager.collection('object').findObjects({}) 986 | expect(foundObjectsBeforeUpdate).toEqual([{ 987 | id: newObject.id, 988 | fieldString: 'test', 989 | fieldCustom: 'Found: Stored: bla', 990 | }]) 991 | 992 | await storageManager.collection('object').updateObjects({ id: newObject.id }, { fieldString: 'new test' }) 993 | 994 | const foundObjectAfterUpdate = await storageManager.collection('object').findObjects({}) 995 | expect(foundObjectAfterUpdate).toEqual([{ 996 | id: newObject.id, 997 | fieldString: 'new test', 998 | fieldCustom: 'Found: Stored: bla', 999 | }]) 1000 | }) 1001 | 1002 | it( 1003 | 'should not try to process custom fields that are not present on object when ' + 1004 | 'doing an update without a custom field that only modifies writes present', 1005 | { shouldSupport: ['customFields'] }, 1006 | async (context: TestContext) => { 1007 | const { storageManager } = await setupTest(context, { customField: WriteOnlyCustomField }) 1008 | const { object: newObject } = await storageManager.collection('object').createObject({ 1009 | fieldString: 'test' 1010 | }) 1011 | expect(newObject).toEqual({ 1012 | id: expect.anything(), 1013 | fieldString: 'test', 1014 | fieldCustom: null, 1015 | }) 1016 | 1017 | const foundObjectsBeforeUpdate = await storageManager.collection('object').findObjects({}) 1018 | expect(foundObjectsBeforeUpdate).toEqual([{ 1019 | id: newObject.id, 1020 | fieldString: 'test', 1021 | fieldCustom: 'Stored: undefined', 1022 | }]) 1023 | 1024 | await storageManager.collection('object').updateObjects({ id: newObject.id }, { fieldString: 'new test' }) 1025 | 1026 | const foundObjectAfterUpdate = await storageManager.collection('object').findObjects({}) 1027 | expect(foundObjectAfterUpdate).toEqual([{ 1028 | id: newObject.id, 1029 | fieldString: 'new test', 1030 | fieldCustom: 'Stored: undefined', 1031 | }]) 1032 | }) 1033 | 1034 | it( 1035 | 'should not try to process custom fields that are not present on object when ' + 1036 | 'doing an update with a custom field that only modifies writes present', 1037 | { shouldSupport: ['customFields'] }, 1038 | async (context: TestContext) => { 1039 | const { storageManager } = await setupTest(context, { customField: WriteOnlyCustomField }) 1040 | const { object: newObject } = await storageManager.collection('object').createObject({ 1041 | fieldString: 'test', 1042 | fieldCustom: 'bla', 1043 | }) 1044 | expect(newObject).toEqual({ 1045 | id: expect.anything(), 1046 | fieldString: 'test', 1047 | fieldCustom: 'bla', 1048 | }) 1049 | 1050 | const foundObjectsBeforeUpdate = await storageManager.collection('object').findObjects({}) 1051 | expect(foundObjectsBeforeUpdate).toEqual([{ 1052 | id: newObject.id, 1053 | fieldString: 'test', 1054 | fieldCustom: 'Stored: bla', 1055 | }]) 1056 | 1057 | await storageManager.collection('object').updateObjects({ id: newObject.id }, { fieldString: 'new test' }) 1058 | 1059 | const foundObjectAfterUpdate = await storageManager.collection('object').findObjects({}) 1060 | expect(foundObjectAfterUpdate).toEqual([{ 1061 | id: newObject.id, 1062 | fieldString: 'new test', 1063 | fieldCustom: 'Stored: bla', 1064 | }]) 1065 | }) 1066 | }) 1067 | } 1068 | -------------------------------------------------------------------------------- /ts/index.ts: -------------------------------------------------------------------------------- 1 | import fromPairs from 'lodash/fromPairs' 2 | import StorageRegistry from './registry' 3 | import { createDefaultFieldTypeRegistry, FieldTypeRegistry } from './fields' 4 | import { StorageMiddleware, StorageMiddlewareContext } from './types/middleware' 5 | import { StorageBackend, COLLECTION_OPERATIONS } from './types/backend' 6 | import StorageManagerInterface, { StorageCollection } from './types/manager' 7 | 8 | export { default as StorageRegistry } from './registry' 9 | 10 | const COLLECTION_OPERATION_ALIASES = { 11 | findOneObject: 'findObject', 12 | findAllObjects: 'findObjects', 13 | updateOneObject: 'updateObject', 14 | updateAllObject: 'updateObjects', 15 | deleteOneObject: 'deleteObject', 16 | deleteAllObjects: 'deleteObjects', 17 | } 18 | 19 | export default class StorageManager implements StorageManagerInterface { 20 | public registry: StorageRegistry 21 | public backend: StorageBackend 22 | private _middleware: StorageMiddleware[] = [] 23 | 24 | constructor({ backend, fieldTypes }: { backend: StorageBackend, fieldTypes?: FieldTypeRegistry }) { 25 | this.registry = new StorageRegistry({ fieldTypes: fieldTypes || createDefaultFieldTypeRegistry() }) 26 | this.backend = backend 27 | this.backend.configure({ registry: this.registry }) 28 | } 29 | 30 | setMiddleware(middleware: StorageMiddleware[]) { 31 | this._middleware = middleware 32 | this._middleware.reverse() 33 | } 34 | 35 | async finishInitialization() { 36 | await this.registry.finishInitialization() 37 | } 38 | 39 | collection(collectionName: string): StorageCollection { 40 | const operation = operationName => (...args) => this.operation(operationName, collectionName, ...args) 41 | return fromPairs([ 42 | ...Array.from(COLLECTION_OPERATIONS).map( 43 | operationName => [operationName, operation(operationName)] 44 | ), 45 | ...Object.entries(COLLECTION_OPERATION_ALIASES).map( 46 | ([alias, target]) => [alias, operation(target)] 47 | ) 48 | ]) 49 | } 50 | 51 | async operation(operationName: string, ...args) { 52 | let next = { 53 | process: ({ operation }: StorageMiddlewareContext | Omit) => 54 | this.backend.operation(operation[0], ...operation.slice(1)) 55 | } 56 | 57 | let extraData = {} 58 | for (const middleware of this._middleware) { 59 | next = ((currentNext, currentMiddleware: StorageMiddleware) => ({ 60 | process: async args => { 61 | extraData = { ...extraData, ...(args.extraData || {}) } 62 | const result = await currentMiddleware.process({ ...args, extraData, next: currentNext }) 63 | return result 64 | } 65 | }))(next, middleware) 66 | } 67 | 68 | return next.process({ operation: [operationName, ...args], extraData }) 69 | } 70 | } 71 | 72 | export * from './types' 73 | -------------------------------------------------------------------------------- /ts/registry.test.ts: -------------------------------------------------------------------------------- 1 | import expect from 'expect' 2 | import StorageRegistry from "./registry"; 3 | import { FieldTypeRegistry } from "./fields"; 4 | import { CollectionDefinitionMap } from './types'; 5 | 6 | const COLL_DEFS: CollectionDefinitionMap = { 7 | foo: [ 8 | { 9 | version: new Date(2019, 1, 1), 10 | fields: { 11 | spam: { type: 'string' }, 12 | }, 13 | }, 14 | { 15 | version: new Date(2019, 1, 2), 16 | fields: { 17 | spam: { type: 'string' }, 18 | eggs: { type: 'string' }, 19 | }, 20 | }, 21 | ], 22 | bar: [ 23 | { 24 | version: new Date(2019, 1, 1), 25 | fields: { 26 | one: { type: 'string' }, 27 | }, 28 | }, 29 | { 30 | version: new Date(2019, 1, 2), 31 | fields: { 32 | one: { type: 'string' }, 33 | two: { type: 'string' }, 34 | }, 35 | }, 36 | ], 37 | } 38 | 39 | async function createTestRegistry() { 40 | const registry = new StorageRegistry({fieldTypes: new FieldTypeRegistry}) 41 | registry.registerCollections(COLL_DEFS) 42 | await registry.finishInitialization() 43 | return registry 44 | } 45 | 46 | describe('Storage registry', () => { 47 | it('should sort collections by version, taking the latest as the definitive version', async () => { 48 | const registryA = new StorageRegistry({ 49 | fieldTypes: new FieldTypeRegistry(), 50 | }) 51 | const registryB = new StorageRegistry({ 52 | fieldTypes: new FieldTypeRegistry(), 53 | }) 54 | 55 | registryA.registerCollections({ 56 | foo: (COLL_DEFS.foo as Array).reverse(), 57 | bar: (COLL_DEFS.bar as Array).reverse(), 58 | }) 59 | registryB.registerCollections({ 60 | foo: COLL_DEFS.foo, 61 | bar: COLL_DEFS.bar, 62 | }) 63 | 64 | for (const reg of [registryA, registryB]) { 65 | expect(reg.collections.foo).toEqual( 66 | expect.objectContaining({ 67 | version: COLL_DEFS.foo[1].version, 68 | fields: expect.objectContaining({ 69 | spam: { type: 'string' }, 70 | eggs: { type: 'string' }, 71 | }), 72 | }), 73 | ) 74 | 75 | expect(reg.collections.bar).toEqual( 76 | expect.objectContaining({ 77 | version: COLL_DEFS.bar[1].version, 78 | fields: expect.objectContaining({ 79 | one: { type: 'string' }, 80 | two: { type: 'string' }, 81 | }), 82 | }), 83 | ) 84 | } 85 | }) 86 | 87 | it('should retrieve collections by version', async () => { 88 | const registry = await createTestRegistry() 89 | 90 | expect(registry.getCollectionsByVersion(new Date(2019, 1, 1))).toEqual({ 91 | foo: expect.objectContaining({ 92 | version: new Date(2019, 1, 1), 93 | fields: { 94 | id: expect.objectContaining({ 95 | type: 'auto-pk' 96 | }), 97 | spam: {type: 'string'} 98 | } 99 | }), 100 | bar: expect.objectContaining({ 101 | version: new Date(2019, 1, 1), 102 | fields: { 103 | id: expect.objectContaining({ 104 | type: 'auto-pk' 105 | }), 106 | one: {type: 'string'} 107 | } 108 | }) 109 | }) 110 | 111 | expect(registry.getCollectionsByVersion(new Date(2019, 1, 2))).toEqual({ 112 | foo: expect.objectContaining({ 113 | version: new Date(2019, 1, 2), 114 | fields: { 115 | id: expect.objectContaining({ 116 | type: 'auto-pk' 117 | }), 118 | spam: {type: 'string'}, 119 | eggs: {type: 'string'}, 120 | } 121 | }), 122 | bar: expect.objectContaining({ 123 | version: new Date(2019, 1, 2), 124 | fields: { 125 | id: expect.objectContaining({ 126 | type: 'auto-pk' 127 | }), 128 | one: {type: 'string'}, 129 | two: {type: 'string'}, 130 | } 131 | }) 132 | }) 133 | }) 134 | 135 | it('should be able to generate the schema history', async () => { 136 | const registry = await createTestRegistry() 137 | expect(registry.getSchemaHistory()).toEqual([ 138 | { 139 | version: new Date(2019, 1, 1), 140 | collections: registry.getCollectionsByVersion(new Date(2019, 1, 1)) 141 | }, 142 | { 143 | version: new Date(2019, 1, 2), 144 | collections: registry.getCollectionsByVersion(new Date(2019, 1, 2)) 145 | }, 146 | ]) 147 | }) 148 | }) 149 | -------------------------------------------------------------------------------- /ts/registry.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events' 2 | import fromPairs from 'lodash/fromPairs' 3 | import sortBy from 'lodash/sortBy' 4 | import pluralize from 'pluralize' 5 | import { 6 | isConnectsRelationship, 7 | isChildOfRelationship, 8 | isRelationshipReference, 9 | getChildOfRelationshipTarget, 10 | SingleChildOfRelationship, 11 | } from './types/relationships' 12 | import { CollectionDefinitions, CollectionDefinition, CollectionDefinitionMap } from './types/collections' 13 | import { IndexSourceField } from './types/indices' 14 | import { FieldTypeRegistry } from './fields' 15 | 16 | export interface RegistryCollections { 17 | [collName: string]: CollectionDefinition 18 | } 19 | 20 | export interface RegistryCollectionsVersionMap { 21 | [collVersion: number]: CollectionDefinition[] 22 | } 23 | 24 | export type SchemaHistory = SchemaHistoryEntry[] 25 | export interface SchemaHistoryEntry { 26 | version: Date 27 | collections: RegistryCollections 28 | } 29 | 30 | export default class StorageRegistry extends EventEmitter { 31 | public collections: RegistryCollections = {} 32 | public _collectionsByVersion: RegistryCollectionsVersionMap = {} 33 | public _collectionVersionMap: { [name: string]: RegistryCollections } = {} 34 | public fieldTypes: FieldTypeRegistry 35 | 36 | constructor({ fieldTypes }: { fieldTypes?: FieldTypeRegistry } = {}) { 37 | super() 38 | 39 | this.fieldTypes = fieldTypes || new FieldTypeRegistry() 40 | } 41 | 42 | registerCollection(name: string, defs: CollectionDefinitions) { 43 | if (!(defs instanceof Array)) { 44 | defs = [defs] 45 | } 46 | 47 | defs 48 | .sort((a, b) => a.version.getTime() - b.version.getTime()) 49 | .forEach(def => { 50 | this.collections[name] = def 51 | def.name = name 52 | def.indices = def.indices || [] 53 | 54 | this._preprocessFieldTypes(def) 55 | this._preprocessCollectionRelationships(name, def) 56 | this._preprocessCollectionIndices(name, def) 57 | 58 | // Needs to happen after the relationships, so we can detect primary keys based on relationships 59 | this._autoAssignCollectionPk(def) 60 | 61 | const version = def.version.getTime() 62 | this._collectionsByVersion[version] = 63 | this._collectionsByVersion[version] || [] 64 | this._collectionsByVersion[version].push(def) 65 | 66 | this._collectionVersionMap[version] = this._collectionVersionMap[version] || {} 67 | this._collectionVersionMap[version][name] = def 68 | }) 69 | 70 | this.emit('registered-collection', { collection: this.collections[name] }) 71 | } 72 | 73 | registerCollections(collections: CollectionDefinitionMap) { 74 | for (const [name, def] of Object.entries(collections)) { 75 | this.registerCollection(name, def) 76 | } 77 | } 78 | 79 | async finishInitialization() { 80 | this._connectReverseRelationships() 81 | return Promise.all( 82 | this.listeners('initialized').map( 83 | list => list.call(this), 84 | ), 85 | ).then(() => { }) 86 | } 87 | 88 | get collectionVersionMap() { 89 | this._deprecationWarning('StorageRegistry.collectionVersionMap is deprecated, use StorageRegistry.getCollectionsByVersion() instead') 90 | return this._collectionVersionMap 91 | } 92 | 93 | getCollectionsByVersion(targetVersion: Date): RegistryCollections { 94 | const collections = {} 95 | for (const collectionDefinitions of Object.values(this.collectionVersionMap)) { 96 | for (const [collectionName, collectionDefinition] of Object.entries(collectionDefinitions)) { 97 | const savedCollectionDefinition = collections[collectionName] 98 | if (!savedCollectionDefinition || (collectionDefinition.version.getTime() > savedCollectionDefinition.version.getTime())) { 99 | if (collectionDefinition.version.getTime() <= targetVersion.getTime()) { 100 | collections[collectionName] = collectionDefinition 101 | } 102 | } 103 | } 104 | } 105 | return collections 106 | } 107 | 108 | get collectionsByVersion() { 109 | this._deprecationWarning('StorageRegistry.collectionsByVersion is deprecated, use StorageRegistry.getSchemaHistory() instead') 110 | return this._collectionsByVersion 111 | } 112 | 113 | getSchemaHistory(): SchemaHistory { 114 | const entries = Object.entries(this._collectionsByVersion) 115 | const sorted = sortBy(entries, ([version]) => parseInt(version)) 116 | return sorted.map(([version, collectionsArray]) => { 117 | const collections = fromPairs(collectionsArray.map( 118 | collection => [collection.name, collection] 119 | )) 120 | return { version: new Date(parseInt(version)), collections } 121 | }) 122 | } 123 | 124 | _preprocessFieldTypes(def: CollectionDefinition) { 125 | def.fieldsWithCustomType = [] 126 | 127 | const fields = def.fields 128 | Object.entries(fields).forEach(([fieldName, fieldDef]) => { 129 | const FieldType = this.fieldTypes.fieldTypes[fieldDef.type] 130 | if (!FieldType) { 131 | return 132 | } 133 | 134 | fieldDef.fieldObject = new FieldType() 135 | def.fieldsWithCustomType.push(fieldName) 136 | }) 137 | } 138 | 139 | /** 140 | * Handles mutating a collection's definition to flag all fields that are declared to be 141 | * indexable as indexed fields. 142 | */ 143 | _preprocessCollectionIndices(collectionName: string, def: CollectionDefinition) { 144 | const flagField = (fieldName: string, indexDefIndex: number) => { 145 | if (!def.fields[fieldName]) { 146 | if (def.relationshipsByAlias[fieldName]) { 147 | return 148 | } 149 | throw new Error(`Flagging field ${fieldName} of collection ${collectionName} as index, but field does not exist`) 150 | } 151 | def.fields[fieldName]._index = indexDefIndex 152 | } 153 | const flagIndexSourceField = (indexSource: IndexSourceField, indexDefIndex: number) => { 154 | if (typeof indexSource === 'string') { 155 | flagField(indexSource, indexDefIndex) 156 | } 157 | } 158 | 159 | const indices = def.indices || [] 160 | indices.forEach((indexDef, indexDefIndex) => { 161 | const { field: indexSourceFields } = indexDef 162 | // Compound indexes need to flag all specified fields 163 | if (indexSourceFields instanceof Array) { 164 | indexSourceFields.forEach(indexSource => { flagIndexSourceField(indexSource, indexDefIndex) }) 165 | } else { 166 | flagIndexSourceField(indexSourceFields, indexDefIndex) 167 | } 168 | }) 169 | } 170 | 171 | _autoAssignCollectionPk(def: CollectionDefinition) { 172 | const indices = def.indices || [] 173 | indices.forEach(({ field: indexSourceFields, pk: isPk }, indexDefIndex) => { 174 | if (isPk) { 175 | def.pkIndex = indexSourceFields 176 | } 177 | }) 178 | if (!def.pkIndex) { 179 | indices.unshift({ field: 'id', pk: true }) 180 | def.pkIndex = 'id' 181 | } 182 | if (typeof def.pkIndex === 'string' && !def.fields[def.pkIndex] && !def.relationshipsByAlias[def.pkIndex]) { 183 | def.fields[def.pkIndex] = { type: 'auto-pk' } 184 | } 185 | } 186 | 187 | /** 188 | * Creates the fields and indices for relationships 189 | */ 190 | _preprocessCollectionRelationships(name: string, def: CollectionDefinition) { 191 | def.relationships = def.relationships || [] 192 | def.relationshipsByAlias = {} 193 | def.reverseRelationshipsByAlias = {} 194 | for (const relationship of def.relationships) { 195 | if (isConnectsRelationship(relationship)) { 196 | relationship.aliases = relationship.aliases || relationship.connects 197 | relationship.fieldNames = relationship.fieldNames || [ 198 | `${relationship.aliases[0]}Rel`, 199 | `${relationship.aliases[1]}Rel` 200 | ] 201 | 202 | relationship.reverseAliases = relationship.reverseAliases || [ 203 | pluralize(relationship.connects[1]), 204 | pluralize(relationship.connects[0]), 205 | ] 206 | } else if (isChildOfRelationship(relationship)) { 207 | relationship.sourceCollection = name 208 | relationship.targetCollection = getChildOfRelationshipTarget(relationship) 209 | relationship.single = !!(relationship).singleChildOf 210 | relationship.alias = relationship.alias || relationship.targetCollection 211 | def.relationshipsByAlias[relationship.alias] = relationship 212 | 213 | if (!relationship.reverseAlias) { 214 | relationship.reverseAlias = relationship.single ? name : pluralize(name) 215 | } 216 | 217 | relationship.fieldName = relationship.fieldName || `${relationship.alias}Rel` 218 | } else { 219 | throw new Error("Invalid relationship detected: " + JSON.stringify(relationship)) 220 | } 221 | } 222 | } 223 | 224 | _connectReverseRelationships() { 225 | Object.values(this.collections).forEach(sourceCollectionDef => { 226 | for (const relationship of sourceCollectionDef.relationships) { 227 | if (isConnectsRelationship(relationship)) { 228 | const connected = [this.collections[relationship.connects[0]], this.collections[relationship.connects[1]]] 229 | for (let idx = 0; idx < connected.length; ++idx) { 230 | if (!connected[idx]) { 231 | throw new Error( 232 | `Collection '${sourceCollectionDef.name!}' defined ` + 233 | `a 'connects' relation involving non-existing ` + 234 | `collection '${relationship.connects[idx]}` 235 | ) 236 | } 237 | } 238 | 239 | connected[0].reverseRelationshipsByAlias[relationship.reverseAliases[0]] = relationship 240 | connected[1].reverseRelationshipsByAlias[relationship.reverseAliases[1]] = relationship 241 | } else if (isChildOfRelationship(relationship)) { 242 | const targetCollectionDef = this.collections[relationship.targetCollection] 243 | if (!targetCollectionDef) { 244 | const relationshipType = relationship.single ? 'singleChildOf' : 'childOf' 245 | throw new Error( 246 | `Collection '${sourceCollectionDef.name!}' defined ` + 247 | `a '${relationshipType}' relationship to non-existing ` + 248 | `collection '${relationship.targetCollection}` 249 | ) 250 | } 251 | targetCollectionDef.reverseRelationshipsByAlias[relationship.reverseAlias] = relationship 252 | } 253 | } 254 | }) 255 | } 256 | 257 | _deprecationWarning(message) { 258 | console.warn(`DEPRECATED: ${message}`) 259 | } 260 | } 261 | -------------------------------------------------------------------------------- /ts/types/backend-features.ts: -------------------------------------------------------------------------------- 1 | export interface StorageBackendFeatureSupport { 2 | transaction?: boolean 3 | count?: boolean 4 | ignoreCase?: boolean 5 | fullTextSearch?: boolean 6 | rawCreateObjects?: boolean 7 | createWithRelationships?: boolean 8 | updateWithRelationships?: boolean 9 | relationshipFetching?: boolean 10 | crossRelationshipQueries?: boolean 11 | executeBatch?: boolean 12 | batchCreates?: boolean 13 | resultLimiting?: boolean 14 | singleFieldSorting?: boolean 15 | multiFieldSorting?: boolean 16 | sortWithIndependentRangeFilter?: boolean 17 | collectionGrouping?: boolean 18 | collectionNesting?: boolean 19 | customFields?: boolean 20 | customFieldNames?: boolean 21 | streamObjects?: boolean 22 | } 23 | -------------------------------------------------------------------------------- /ts/types/backend.test.ts: -------------------------------------------------------------------------------- 1 | import expect from 'expect' 2 | import { StorageBackend, StorageBackendPlugin, _parseIdentifier, _validateOperationRegistration } from "./backend"; 3 | import StorageManager from '..'; 4 | 5 | class DummyStorageBackend extends StorageBackend { 6 | readonly type = 'dummy' 7 | dummmy = 'test' 8 | 9 | async createObject() { } 10 | async rawCreateObjects() { } 11 | async findObjects() { return [] } 12 | async updateObjects() { } 13 | async deleteObjects() { } 14 | } 15 | 16 | describe('Backend base class', () => { 17 | describe('Core operations', () => { 18 | it('should be able to update objects correctly', async () => { 19 | const backend = new DummyStorageBackend() 20 | const updates = [] 21 | backend.updateObjects = async (...args) => { updates.push(args) } 22 | 23 | const storageManager = new StorageManager({ backend }) 24 | storageManager.registry.registerCollections({ 25 | user: { 26 | version: new Date(2018, 7, 31), 27 | fields: { 28 | identifier: { type: 'string' }, 29 | passwordHash: { type: 'string', optional: true }, 30 | isActive: { type: 'boolean' }, 31 | }, 32 | indices: [ 33 | { field: 'identifier' }, 34 | ] 35 | }, 36 | }) 37 | await storageManager.finishInitialization() 38 | 39 | await backend.updateObject('user', { id: 1, identifier: 'foo' }, { identifier: 'bla' }, 'options' as any) 40 | expect(updates).toEqual([ 41 | ['user', { id: 1 }, { identifier: 'bla' }, 'options'] 42 | ]) 43 | }) 44 | }) 45 | 46 | describe('Core operations', () => { 47 | it('should be able to update objects with compound primary keys correctly', async () => { 48 | const backend = new DummyStorageBackend() 49 | const updates = [] 50 | backend.updateObjects = async (...args) => { updates.push(args) } 51 | 52 | const storageManager = new StorageManager({ backend }) 53 | storageManager.registry.registerCollections({ 54 | user: { 55 | version: new Date(2018, 7, 31), 56 | fields: { 57 | identifier: { type: 'string' }, 58 | passwordHash: { type: 'string', optional: true }, 59 | isActive: { type: 'boolean' }, 60 | }, 61 | indices: [ 62 | { field: ['identifier', 'isActive'], pk: true }, 63 | ] 64 | }, 65 | }) 66 | await storageManager.finishInitialization() 67 | 68 | await backend.updateObject('user', { identifier: 'foo', isActive: true, passwordHash: 'muahaha' }, { identifier: 'bla' }, 'options' as any) 69 | expect(updates).toEqual([ 70 | ['user', { identifier: 'foo', isActive: true }, { identifier: 'bla' }, 'options'] 71 | ]) 72 | }) 73 | }) 74 | 75 | describe('Plugins', () => { 76 | it('should register plugins correctly', async () => { 77 | const operationCalls = [] 78 | class DummyStorageBackendPlugin extends StorageBackendPlugin { 79 | install(backend: DummyStorageBackend) { 80 | super.install(backend) 81 | backend.registerOperation('myproject:dummy.doSomething', async (...args) => { 82 | operationCalls.push({ args }) 83 | return this.backend.dummmy 84 | }) 85 | } 86 | } 87 | 88 | const backend = new DummyStorageBackend() 89 | backend.use(new DummyStorageBackendPlugin()) 90 | expect(await backend.operation('myproject:dummy.doSomething', 'foo', 'bar')).toEqual('test') 91 | expect(operationCalls).toEqual([{ args: ['foo', 'bar'] }]) 92 | }) 93 | 94 | it('should allow registering standard top-level operations', async () => { 95 | expect(_validateOperationRegistration( 96 | 'alterSchema', { type: 'dummy', pluggableOperations: new Set() } as StorageBackend 97 | )).toEqual(true) 98 | }) 99 | 100 | it('should prevent registering unknown top-level operations', async () => { 101 | expect(() => _validateOperationRegistration( 102 | 'bla', { type: 'dummy', pluggableOperations: new Set() } as StorageBackend 103 | )).toThrow(`Cannot register non-standard top-level operation 'bla'`) 104 | }) 105 | 106 | it('should allow registering well-known backend-specific operations', async () => { 107 | expect(_validateOperationRegistration( 108 | 'dummy.bla', { type: 'dummy', pluggableOperations: new Set(['bla']) } as StorageBackend 109 | )).toEqual(true) 110 | }) 111 | 112 | it('should prevent registering unknown backend-specific operations', async () => { 113 | expect(() => _validateOperationRegistration( 114 | 'dummy.bla', { type: 'dummy', pluggableOperations: new Set(['foo']) } as StorageBackend 115 | )).toThrow(`Cannot register non-standard backend-specific operation 'dummy.bla'`) 116 | }) 117 | }) 118 | }) 119 | 120 | describe('Operation identifier parsing', () => { 121 | it('should parse a simple name', () => { 122 | expect(_parseIdentifier('operation')).toEqual({ 123 | project: null, 124 | backend: null, 125 | operation: 'operation' 126 | }) 127 | }) 128 | 129 | it('should parse a name with a backend', () => { 130 | expect(_parseIdentifier('backend.operation')).toEqual({ 131 | project: null, 132 | backend: 'backend', 133 | operation: 'operation' 134 | }) 135 | }) 136 | 137 | it('should parse a name with a project and a backend', () => { 138 | expect(_parseIdentifier('project:backend.operation')).toEqual({ 139 | project: 'project', 140 | backend: 'backend', 141 | operation: 'operation' 142 | }) 143 | }) 144 | 145 | it('should parse a name with a project', () => { 146 | expect(_parseIdentifier('project:operation')).toEqual({ 147 | project: 'project', 148 | backend: null, 149 | operation: 'operation' 150 | }) 151 | }) 152 | }) -------------------------------------------------------------------------------- /ts/types/backend.ts: -------------------------------------------------------------------------------- 1 | import StorageRegistry from "../registry" 2 | import { StorageBackendFeatureSupport } from "./backend-features"; 3 | import { isRelationshipReference } from "./relationships"; 4 | 5 | export type CreateSingleOptions = DBNameOptions 6 | export type CreateSingleResult = { object?: any } 7 | export type CreateManyResult = { objects?: any[] } 8 | export type CreateManyOptions = DBNameOptions & { withNestedObjects?: false } 9 | export type FindSingleOptions = DBNameOptions & IgnoreCaseOptions & ReverseOptions & SortingOptions & { fields?: string[] } 10 | export type FindManyOptions = FindSingleOptions & PaginationOptions & SortingOptions 11 | export type CountOptions = DBNameOptions & IgnoreCaseOptions 12 | export type UpdateManyOptions = DBNameOptions 13 | export type UpdateManyResult = any 14 | export type UpdateSingleOptions = DBNameOptions 15 | export type UpdateSingleResult = any 16 | export type DeleteSingleOptions = DBNameOptions 17 | export type DeleteSingleResult = any 18 | export type DeleteManyOptions = DBNameOptions & { limit?: number } 19 | export type DeleteManyResult = any 20 | export type OperationBatch = Array 21 | export type BatchOperation = CreateObjectBatchOperation | UpdateObjectsBatchOperation | DeleteObjectsBatchOperation 22 | export type BatchOperationBase = { operation: string, placeholder?: string, replace?: { path: string, placeholder: string }[] } 23 | export type CollectionBatchOperation = BatchOperationBase & { collection: string } 24 | export type CreateObjectBatchOperation = CollectionBatchOperation & { operation: 'createObject', args: any } 25 | export type UpdateObjectsBatchOperation = CollectionBatchOperation & { operation: 'updateObjects', where: any, updates: any } 26 | export type DeleteObjectsBatchOperation = CollectionBatchOperation & { operation: 'deleteObjects', where: any } 27 | 28 | export type IgnoreCaseOptions = { ignoreCase?: string[] } 29 | export type ReverseOptions = { reverse?: boolean } 30 | export type DBNameOptions = { database?: string } 31 | export type PaginationOptions = { limit?: number, skip?: number } 32 | export type SortingOptions = { order?: Array<[string, 'asc' | 'desc']> } 33 | 34 | export const COLLECTION_OPERATIONS = new Set([ 35 | 'createObject', 36 | 'findObject', 37 | 'findObjects', 38 | 'countObjects', 39 | 'updateObject', 40 | 'updateObjects', 41 | 'deleteObject', 42 | 'deleteObjects', 43 | ]) 44 | const CORE_OPERATIONS = COLLECTION_OPERATIONS 45 | const PLUGGABLE_CORE_OPERATIONS = new Set([ 46 | 'alterSchema' 47 | ]) 48 | const IDENTIFIER_REGEX = /(?:([a-zA-Z]+)(\:))?(?:([a-zA-Z]+)(\.))?([a-zA-Z]+)/ 49 | export function _parseIdentifier(identifier: string) { 50 | const parts = IDENTIFIER_REGEX.exec(identifier) 51 | return { 52 | project: parts[1] || null, 53 | backend: parts[3] || null, 54 | operation: parts[5], 55 | } 56 | } 57 | 58 | export abstract class StorageBackend { 59 | readonly type: string = null 60 | readonly pluggableOperations: Set = new Set() 61 | features: StorageBackendFeatureSupport = {} 62 | customFeatures: { [name: string]: true } = {} 63 | protected registry: StorageRegistry 64 | private operations = {} 65 | 66 | configure({ registry }: { registry: StorageRegistry }) { 67 | this.registry = registry 68 | 69 | // TODO: Compile this away in production builds 70 | for (const key of Object.keys(this.customFeatures)) { 71 | if (key.indexOf('.') === -1) { 72 | throw new Error(`Custom storage backend features must be namespaced with a '.', e.g. 'dexie.getVersionHistory'`) 73 | } 74 | } 75 | } 76 | 77 | use(plugin: StorageBackendPlugin) { 78 | plugin.install(this) 79 | } 80 | 81 | async cleanup(): Promise { } 82 | async migrate({ database }: { database?} = {}): Promise { } 83 | 84 | abstract createObject(collection: string, object, options?: CreateSingleOptions): Promise 85 | 86 | abstract findObjects(collection: string, query, options?: FindManyOptions): Promise> 87 | async findObject(collection: string, query, options?: FindSingleOptions): Promise { 88 | const objects = await this.findObjects(collection, query, { ...options, limit: 1 }) 89 | if (!objects.length) { 90 | return null 91 | } 92 | 93 | return objects[0] 94 | } 95 | 96 | /** 97 | * Note that this is a naive implementation that is not very space efficient. 98 | * It is recommended to override this implementation in storex backends with 99 | * DB-native queries. 100 | */ 101 | async countObjects(collection: string, query, options?: CountOptions): Promise { 102 | const objects = await this.findObjects(collection, query) 103 | 104 | return objects.length 105 | } 106 | 107 | abstract updateObjects(collection: string, query, updates, options?: UpdateManyOptions): Promise 108 | async updateObject(collection: string, object, updates, options?: UpdateSingleOptions): Promise { 109 | const definition = this.registry.collections[collection] 110 | if (typeof definition.pkIndex === 'string') { 111 | return await this.updateObjects(collection, { [definition.pkIndex]: object[definition.pkIndex] }, updates, options) 112 | } else if (definition.pkIndex instanceof Array) { 113 | const where = {} 114 | for (let pkField of definition.pkIndex) { 115 | if (isRelationshipReference(pkField)) { 116 | throw new Error('Updating single objects with relation pks is not supported yet') 117 | } 118 | where[pkField] = object[pkField] 119 | } 120 | return await this.updateObjects(collection, where, updates, options) 121 | } else { 122 | throw new Error('Updating single objects with relation pks is not supported yet') 123 | } 124 | } 125 | 126 | abstract deleteObjects(collection: string, query, options?: DeleteManyOptions): Promise 127 | async deleteObject(collection: string, object, options?: DeleteSingleOptions): Promise { 128 | const definition = this.registry.collections[collection] 129 | if (typeof definition.pkIndex === 'string') { 130 | await this.deleteObjects(collection, { [definition.pkIndex]: object[definition.pkIndex] }, { ...(options || {}), limit: 1 }) 131 | } else { 132 | throw new Error('Updating single objects with compound pks is not supported yet') 133 | } 134 | } 135 | 136 | async executeBatch(batch: OperationBatch): Promise<{ info }> { 137 | throw new Error('Not implemented') 138 | } 139 | 140 | supports(feature: string) { 141 | return CORE_OPERATIONS.has(feature) || !!this.features[feature] || !!this.customFeatures[feature] || !!this.operations[feature] 142 | } 143 | 144 | async operation(operation: string, ...args) { 145 | const unsupported = (reason: 'no-support-detected' | 'no-method-found' | 'no-registration-found') => { 146 | throw new Error(`Unsupported storage backend operation: ${operation} (${reason})`) 147 | } 148 | 149 | if (!this.supports(operation)) { 150 | unsupported('no-support-detected') 151 | } 152 | 153 | const parsedIdentifier = _parseIdentifier(operation) 154 | if (!parsedIdentifier.project && !parsedIdentifier.backend) { 155 | const method: Function = this[parsedIdentifier.operation] 156 | if (!method) { 157 | unsupported('no-method-found') 158 | } 159 | return method.apply(this, args) 160 | } 161 | 162 | const registeredOperation = this.operations[operation] 163 | if (!registeredOperation) { 164 | unsupported('no-registration-found') 165 | } 166 | 167 | return registeredOperation(...args) 168 | } 169 | 170 | registerOperation(identifier: string, operation: (...args) => Promise) { 171 | _validateOperationRegistration(identifier, this) 172 | 173 | this.operations[identifier] = operation 174 | } 175 | } 176 | 177 | export class StorageBackendPlugin { 178 | public backend: Backend 179 | 180 | install(backend: Backend) { 181 | this.backend = backend as any as Backend 182 | } 183 | } 184 | 185 | export function _validateOperationRegistration(identifier, backend: StorageBackend) { 186 | const parsedIdentifier = _parseIdentifier(identifier) 187 | if (!parsedIdentifier.project) { 188 | if (!parsedIdentifier.backend && !PLUGGABLE_CORE_OPERATIONS.has(identifier)) { 189 | throw new Error(`Cannot register non-standard top-level operation '${identifier}'`) 190 | } 191 | if (parsedIdentifier.backend && !backend.pluggableOperations.has(parsedIdentifier.operation)) { 192 | throw new Error(`Cannot register non-standard backend-specific operation '${identifier}'`) 193 | } 194 | } 195 | 196 | return true 197 | } -------------------------------------------------------------------------------- /ts/types/collections.ts: -------------------------------------------------------------------------------- 1 | import { Field } from '../fields/types' 2 | import { FieldType } from "./fields" 3 | import { IndexDefinition, IndexSourceFields } from './indices' 4 | import { Relationships, RelationshipsByAlias } from './relationships' 5 | import { MigrationRunner } from './migrations' 6 | 7 | export type CollectionDefinitionMap = {[name : string] : CollectionDefinitions} 8 | 9 | export type CollectionDefinitions = 10 | | CollectionDefinition[] 11 | | CollectionDefinition 12 | 13 | export interface CollectionFields { 14 | [fieldName: string]: CollectionField 15 | } 16 | 17 | export interface CollectionField { 18 | type: FieldType 19 | optional?: boolean 20 | fieldObject?: Field 21 | _index?: number 22 | } 23 | 24 | export interface CollectionDefinition { 25 | version: Date 26 | fields: CollectionFields 27 | relationships?: Relationships 28 | indices?: IndexDefinition[] 29 | uniqueTogether?: string[][] 30 | groupBy? : { key : string, subcollectionName : string }[] 31 | 32 | // These are automatically deduced 33 | pkIndex?: IndexSourceFields 34 | relationshipsByAlias?: RelationshipsByAlias 35 | reverseRelationshipsByAlias?: RelationshipsByAlias 36 | fieldsWithCustomType?: string[] 37 | migrate?: MigrationRunner 38 | name?: string 39 | watch?: boolean // TODO: move this out of Storex 40 | backup?: boolean 41 | } 42 | -------------------------------------------------------------------------------- /ts/types/errors.ts: -------------------------------------------------------------------------------- 1 | export class StorexError extends Error { 2 | constructor(msg: string) { 3 | super(msg) 4 | 5 | // Manually fixes TS issue that comes with extending from `Error`: 6 | // https://github.com/Microsoft/TypeScript-wiki/blob/master/Breaking-Changes.md#extending-built-ins-like-error-array-and-map-may-no-longer-work 7 | Object.setPrototypeOf(this, new.target.prototype) 8 | } 9 | } 10 | 11 | export class DeletionTooBroadError extends StorexError { 12 | public deletionTooBroad = true 13 | 14 | constructor(public collection : string, public query: any, public limit : number, public actual : number) { 15 | super( 16 | `You wanted to delete only ${limit} objects from the ${collection} collection, but you almost deleted ${actual}!` + 17 | `Phew, that was close, you owe me a beer! Oh, and you can find the query you tried to execute as the .query property of this error.` 18 | ) 19 | } 20 | } 21 | 22 | export class UnimplementedError extends StorexError {} 23 | export class InvalidOptionsError extends StorexError {} 24 | -------------------------------------------------------------------------------- /ts/types/fields.ts: -------------------------------------------------------------------------------- 1 | export type PrimitiveFieldType = 2 | | 'auto-pk' 3 | | 'foreign-key' 4 | | 'text' 5 | | 'json' 6 | | 'datetime' 7 | | 'timestamp' 8 | | 'string' 9 | | 'boolean' 10 | | 'float' 11 | | 'int' 12 | | 'blob' 13 | | 'binary' 14 | 15 | export type FieldType = 16 | | PrimitiveFieldType 17 | | 'random-key' 18 | | 'url' 19 | | 'media' 20 | -------------------------------------------------------------------------------- /ts/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from './collections' 2 | export * from './fields' 3 | export * from './backend' 4 | export * from './indices' 5 | export * from './relationships' 6 | export * from './errors' 7 | -------------------------------------------------------------------------------- /ts/types/indices.ts: -------------------------------------------------------------------------------- 1 | import { RelationshipReference } from "./relationships" 2 | 3 | export type IndexSourceField = string | RelationshipReference 4 | export type IndexSourceFields = IndexSourceField | IndexSourceField[] 5 | 6 | export interface IndexDefinition { 7 | /** 8 | * Points to a corresponding field name defined in the `fields` part of the collection definition. 9 | * In the case of a compound index, this should be a pair of fields expressed as an `Array`. 10 | */ 11 | field: IndexSourceFields 12 | /** 13 | * Denotes whether or not this index should be a primary key. There should only be one index 14 | * with this flag set. 15 | */ 16 | pk?: boolean 17 | /** 18 | * Denotes the index being enforced as unique. 19 | */ 20 | unique?: boolean 21 | /** 22 | * Denotes the primary key index will be auto-incremented. 23 | * Only used if `pk` flag also set. Implies `unique` flag set. 24 | */ 25 | autoInc?: boolean 26 | /** 27 | * Sets a custom name for the corresponding index created to afford full-text search. 28 | * Note that this will only be used if the corresponding field definition in `fields` is 29 | * of `type` `'text'`. 30 | */ 31 | fullTextIndexName?: string 32 | } 33 | -------------------------------------------------------------------------------- /ts/types/manager.ts: -------------------------------------------------------------------------------- 1 | import { CreateManyOptions, CreateManyResult, StorageRegistry } from ".."; 2 | import { 3 | StorageBackend, 4 | CreateSingleOptions, 5 | FindSingleOptions, 6 | FindManyOptions, 7 | CountOptions, 8 | UpdateManyOptions, 9 | UpdateSingleOptions, 10 | DeleteManyOptions, 11 | DeleteSingleOptions, 12 | CreateSingleResult, 13 | DeleteSingleResult, 14 | DeleteManyResult, 15 | UpdateSingleResult, 16 | UpdateManyResult, 17 | } from './backend' 18 | 19 | export interface StorageCollection { 20 | createObject(object, options?: CreateSingleOptions): Promise 21 | rawCreateObjects(objects, options?: CreateManyOptions): Promise 22 | findOneObject(query, options?: FindSingleOptions): Promise 23 | findObject(query, options?: FindSingleOptions): Promise 24 | findObjects(query, options?: FindManyOptions): Promise> 25 | findAllObjects(query, options?: FindManyOptions): Promise> 26 | countObjects(query, options?: CountOptions): Promise 27 | updateOneObject(object, updates, options?: UpdateSingleOptions): Promise 28 | updateObjects(query, updates, options?: UpdateManyOptions): Promise 29 | deleteOneObject(object, options?: DeleteSingleOptions): Promise 30 | deleteObjects(query, options?: DeleteManyOptions): Promise 31 | } 32 | 33 | export interface StorageCollectionMap { 34 | [name: string]: StorageCollection 35 | } 36 | 37 | export default interface StorageManagerInterface { 38 | registry: StorageRegistry 39 | backend: StorageBackend 40 | 41 | finishInitialization(): Promise 42 | collection(collectionName: string): StorageCollection 43 | operation(operationName: string, ...args): Promise 44 | } -------------------------------------------------------------------------------- /ts/types/middleware.test.ts: -------------------------------------------------------------------------------- 1 | import expect from 'expect' 2 | import { createTestStorageManager, generateTestObject } from '../index.tests' 3 | import { StorageMiddleware } from './middleware'; 4 | 5 | describe('Middleware', () => { 6 | it('it should run middleware in the right order', async () => { 7 | const calls = [] 8 | const middleware: StorageMiddleware[] = [ 9 | { 10 | process: async ({ operation, next }) => { 11 | calls.push({ which: 'first', operation }) 12 | return { ...await next.process({ operation }), first: 1 } 13 | } 14 | }, 15 | { 16 | process: async ({ operation, next }) => { 17 | calls.push({ which: 'second', operation }) 18 | return { ...await next.process({ operation }), second: 2 } 19 | } 20 | }, 21 | ] 22 | 23 | const storageManager = await createTestStorageManager({ 24 | configure: () => null, 25 | operation: async (...args) => ({ args }) 26 | } as any) 27 | storageManager.setMiddleware(middleware) 28 | 29 | expect(await storageManager.collection('user').createObject({ foo: 'test' })).toEqual({ 30 | args: ['createObject', 'user', { foo: 'test' }], 31 | first: 1, 32 | second: 2, 33 | }) 34 | expect(calls).toEqual([ 35 | { which: 'first', operation: ['createObject', 'user', { foo: 'test' }] }, 36 | { which: 'second', operation: ['createObject', 'user', { foo: 'test' }] }, 37 | ]) 38 | }) 39 | 40 | it('it pass extra info between middleware', async () => { 41 | const calls = [] 42 | const middleware: StorageMiddleware[] = [ 43 | { 44 | process: async ({ operation, next, extraData }) => { 45 | calls.push({ which: 'first', operation, extraData }) 46 | return { ...await next.process({ operation, extraData: { foo: 5 } }), first: 1 } 47 | } 48 | }, 49 | { 50 | process: async ({ operation, next, extraData }) => { 51 | calls.push({ which: 'second', operation, extraData }) 52 | return { ...await next.process({ operation, extraData: { bar: 10 } }), second: 2 } 53 | } 54 | }, 55 | ] 56 | 57 | const storageManager = await createTestStorageManager({ 58 | configure: () => null, 59 | operation: async (...args) => ({ args }) 60 | } as any) 61 | storageManager.setMiddleware(middleware) 62 | 63 | expect(await storageManager.collection('user').createObject({ foo: 'test' })).toEqual({ 64 | args: ['createObject', 'user', { foo: 'test' }], 65 | first: 1, 66 | second: 2, 67 | }) 68 | expect(calls).toEqual([ 69 | { which: 'first', operation: ['createObject', 'user', { foo: 'test' }], extraData: {} }, 70 | { which: 'second', operation: ['createObject', 'user', { foo: 'test' }], extraData: { foo: 5 } }, 71 | ]) 72 | }) 73 | }) 74 | -------------------------------------------------------------------------------- /ts/types/middleware.ts: -------------------------------------------------------------------------------- 1 | export interface StorageMiddleware { 2 | process(context: StorageMiddlewareContext) 3 | } 4 | export interface StorageMiddlewareContext { 5 | operation: any[] 6 | extraData: { [key: string]: any } 7 | next: { process: (context: { operation: any[], extraData?: { [key: string]: any } }) => any } 8 | } 9 | -------------------------------------------------------------------------------- /ts/types/migrations.ts: -------------------------------------------------------------------------------- 1 | // TODO 2 | export interface MigrationRunner { 3 | (): Promise 4 | _seen?: boolean 5 | } 6 | -------------------------------------------------------------------------------- /ts/types/relationships.ts: -------------------------------------------------------------------------------- 1 | export interface RelationshipType { 2 | } 3 | 4 | export interface ChildOfRelationship extends RelationshipType { 5 | alias?: string 6 | sourceCollection?: string 7 | targetCollection?: string // = singleChildOf || childOf 8 | fieldName?: string 9 | reverseAlias?: string 10 | single?: boolean 11 | optional?: boolean 12 | } 13 | 14 | export interface MultipleChildOfRelationship extends ChildOfRelationship { 15 | childOf: string 16 | } 17 | export interface SingleChildOfRelationship extends ChildOfRelationship { 18 | singleChildOf: string 19 | } 20 | export const isChildOfRelationship = 21 | (relationship): relationship is ChildOfRelationship => 22 | !!(relationship).childOf || 23 | !!(relationship).singleChildOf 24 | export const getChildOfRelationshipTarget = (relationship: ChildOfRelationship) => 25 | (relationship).singleChildOf || 26 | (relationship).childOf 27 | 28 | export interface ConnectsRelationship extends RelationshipType { 29 | connects: [string, string] 30 | aliases?: [string, string] 31 | fieldNames?: [string, string] 32 | reverseAliases?: [string, string] 33 | } 34 | export const isConnectsRelationship = 35 | (relationship: Relationship): relationship is ConnectsRelationship => 36 | !!(relationship).connects 37 | export const isConnectsCollection = (relationships: Relationships) => { 38 | for (const relationship of relationships) { 39 | if (isConnectsRelationship(relationship)) { 40 | return true 41 | } 42 | } 43 | 44 | return false 45 | } 46 | export const getOtherCollectionOfConnectsRelationship = 47 | (relationship: ConnectsRelationship, thisCollection) => 48 | relationship.connects[relationship.connects[0] == thisCollection ? 1 : 0] 49 | 50 | export type Relationship = SingleChildOfRelationship | MultipleChildOfRelationship | ConnectsRelationship 51 | export type Relationships = Relationship[] 52 | export type RelationshipsByAlias = { [alias: string]: Relationship } 53 | export type RelationshipReference = { relationship: string } 54 | export const isRelationshipReference = (reference): reference is RelationshipReference => !!(reference).relationship 55 | -------------------------------------------------------------------------------- /ts/utils.test.ts: -------------------------------------------------------------------------------- 1 | import expect from 'expect' 2 | import omit from 'lodash/omit' 3 | import { createTestStorageManager, generateTestObject } from './index.tests' 4 | import { dissectCreateObjectOperation, convertCreateObjectDissectionToBatch, setIn, setObjectPk, getObjectWithoutPk, getObjectPk } from './utils'; 5 | import StorageManager from '.'; 6 | import { CollectionDefinitionMap } from './types'; 7 | 8 | describe('Create object operation dissecting', () => { 9 | it('should correctly dissect a createObject operation with no relationships', async () => { 10 | const storageManager = await createTestStorageManager({ 11 | configure: () => null 12 | } as any) 13 | 14 | const testObject = generateTestObject({email: 'foo@test.com', passwordHash: 'notahash', expires: 10}) 15 | delete testObject['emails'] 16 | expect(dissectCreateObjectOperation({ 17 | operation: 'createObject', 18 | collection: 'user', 19 | args: testObject 20 | }, storageManager.registry)).toEqual({ 21 | objects: [ 22 | { 23 | placeholder: 1, 24 | collection: 'user', 25 | path: [], 26 | object: omit(testObject, 'emails'), 27 | relations: {} 28 | }, 29 | ] 30 | }) 31 | }) 32 | 33 | it('should correctly dissect a createObject operation childOf relationships', async () => { 34 | const storageManager = await createTestStorageManager({ 35 | configure: () => null 36 | } as any) 37 | 38 | const testObject = generateTestObject({email: 'foo@test.com', passwordHash: 'notahash', expires: 10}) 39 | delete testObject.emails[0].verificationCode 40 | expect(dissectCreateObjectOperation({ 41 | operation: 'createObject', 42 | collection: 'user', 43 | args: testObject 44 | }, storageManager.registry)).toEqual({ 45 | objects: [ 46 | { 47 | placeholder: 1, 48 | collection: 'user', 49 | path: [], 50 | object: omit(testObject, 'emails'), 51 | relations: {}, 52 | }, 53 | { 54 | placeholder: 2, 55 | collection: 'userEmail', 56 | path: ['emails', 0], 57 | object: omit(testObject.emails[0], 'verificationCode'), 58 | relations: { 59 | user: 1 60 | }, 61 | }, 62 | ] 63 | }) 64 | }) 65 | 66 | it('should correctly dissect a createObject operation childOf and singleChildOf relationships', async () => { 67 | const storageManager = await createTestStorageManager({ 68 | configure: () => null 69 | } as any) 70 | 71 | const testObject = generateTestObject({email: 'foo@test.com', passwordHash: 'notahash', expires: 10}) 72 | expect(dissectCreateObjectOperation({ 73 | operation: 'createObject', 74 | collection: 'user', 75 | args: testObject 76 | }, storageManager.registry)).toEqual({ 77 | objects: [ 78 | { 79 | placeholder: 1, 80 | collection: 'user', 81 | path: [], 82 | object: omit(testObject, 'emails'), 83 | relations: {}, 84 | }, 85 | { 86 | placeholder: 2, 87 | collection: 'userEmail', 88 | path: ['emails', 0], 89 | object: omit(testObject.emails[0], 'verificationCode'), 90 | relations: { 91 | user: 1 92 | }, 93 | }, 94 | { 95 | placeholder: 3, 96 | collection: 'userEmailVerificationCode', 97 | path: ['emails', 0, 'verificationCode'], 98 | object: omit(testObject.emails[0].verificationCode), 99 | relations: { 100 | userEmail: 2 101 | }, 102 | }, 103 | ] 104 | }) 105 | }) 106 | 107 | it('should correctly dissect a createObject operation childOf and singleChildOf relationships with custom placeholder generation', async () => { 108 | const storageManager = await createTestStorageManager({ 109 | configure: () => null 110 | } as any) 111 | 112 | const testObject = generateTestObject({email: 'foo@test.com', passwordHash: 'notahash', expires: 10}) 113 | expect(dissectCreateObjectOperation({ 114 | operation: 'createObject', 115 | collection: 'user', 116 | args: testObject 117 | }, storageManager.registry, { 118 | generatePlaceholder: (() => { 119 | let placeholdersGenerated = 0 120 | return () => `custom-${++placeholdersGenerated}` 121 | })(), 122 | })).toEqual({ 123 | objects: [ 124 | { 125 | placeholder: 'custom-1', 126 | collection: 'user', 127 | path: [], 128 | object: omit(testObject, 'emails'), 129 | relations: {}, 130 | }, 131 | { 132 | placeholder: 'custom-2', 133 | collection: 'userEmail', 134 | path: ['emails', 0], 135 | object: omit(testObject.emails[0], 'verificationCode'), 136 | relations: { 137 | user: 'custom-1' 138 | }, 139 | }, 140 | { 141 | placeholder: 'custom-3', 142 | collection: 'userEmailVerificationCode', 143 | path: ['emails', 0, 'verificationCode'], 144 | object: omit(testObject.emails[0].verificationCode), 145 | relations: { 146 | userEmail: 'custom-2' 147 | }, 148 | }, 149 | ] 150 | }) 151 | }) 152 | }) 153 | 154 | describe('Converting dissected create operation to batch', () => { 155 | it('should work', async () => { 156 | const storageManager = await createTestStorageManager({ 157 | configure: () => null 158 | } as any) 159 | 160 | const testObject = generateTestObject({email: 'foo@test.com', passwordHash: 'notahash', expires: 10}) 161 | const dissection = dissectCreateObjectOperation({ 162 | operation: 'createObject', 163 | collection: 'user', 164 | args: testObject 165 | }, storageManager.registry) 166 | expect(convertCreateObjectDissectionToBatch(dissection)).toEqual([ 167 | { 168 | placeholder: '1', 169 | operation: 'createObject', 170 | collection: 'user', 171 | args: omit(testObject, 'emails'), 172 | replace: [], 173 | }, 174 | { 175 | placeholder: '2', 176 | operation: 'createObject', 177 | collection: 'userEmail', 178 | args: omit(testObject.emails[0], 'verificationCode'), 179 | replace: [{ 180 | path: 'user', 181 | placeholder: '1', 182 | }] 183 | }, 184 | { 185 | placeholder: '3', 186 | operation: 'createObject', 187 | collection: 'userEmailVerificationCode', 188 | args: testObject.emails[0].verificationCode, 189 | replace: [{ 190 | path: 'userEmail', 191 | placeholder: '2', 192 | }] 193 | }, 194 | ]) 195 | }) 196 | 197 | it('should work keep placeholders', async () => { 198 | const storageManager = await createTestStorageManager({ 199 | configure: () => null 200 | } as any) 201 | 202 | const testObject = generateTestObject({email: 'foo@test.com', passwordHash: 'notahash', expires: 10}) 203 | const dissection = dissectCreateObjectOperation({ 204 | operation: 'createObject', 205 | collection: 'user', 206 | args: testObject 207 | }, storageManager.registry, {generatePlaceholder: (() => { 208 | let placeholdersGenerated = 0 209 | return () => `custom-${++placeholdersGenerated}` 210 | })()}) 211 | expect(convertCreateObjectDissectionToBatch(dissection)).toEqual([ 212 | { 213 | placeholder: 'custom-1', 214 | operation: 'createObject', 215 | collection: 'user', 216 | args: omit(testObject, 'emails'), 217 | replace: [], 218 | }, 219 | { 220 | placeholder: 'custom-2', 221 | operation: 'createObject', 222 | collection: 'userEmail', 223 | args: omit(testObject.emails[0], 'verificationCode'), 224 | replace: [{ 225 | path: 'user', 226 | placeholder: 'custom-1', 227 | }] 228 | }, 229 | { 230 | placeholder: 'custom-3', 231 | operation: 'createObject', 232 | collection: 'userEmailVerificationCode', 233 | args: testObject.emails[0].verificationCode, 234 | replace: [{ 235 | path: 'userEmail', 236 | placeholder: 'custom-2', 237 | }] 238 | }, 239 | ]) 240 | }) 241 | }) 242 | 243 | describe('setIn()', () => { 244 | it('should modify an object by path', () => { 245 | const obj = {x: {foo: [{bar: 3}, {bar: 5}]}} 246 | setIn(obj, ['x', 'foo', 1, 'bar'], 10) 247 | expect(obj).toEqual({ 248 | x: {foo: [ 249 | {bar: 3}, 250 | {bar: 10} 251 | ]} 252 | }) 253 | }) 254 | }) 255 | 256 | describe('Primary key utils', () => { 257 | async function setupTest(config : {collections : CollectionDefinitionMap}) { 258 | const backend = { 259 | configure: () => null, 260 | operation: async (...args) => ({args}) 261 | } as any 262 | const storageManager = new StorageManager({backend}) 263 | storageManager.registry.registerCollections(config.collections) 264 | return { storageManager } 265 | } 266 | 267 | describe('getObjectPk()', () => { 268 | it('should work for an object with a single field pk', async () => { 269 | const { storageManager } = await setupTest({collections: { 270 | user: { 271 | version: new Date('2019-02-19'), 272 | fields: { 273 | displayName: {type: 'string'} 274 | } 275 | } 276 | }}) 277 | expect(getObjectPk({id: 1, displayName: 'Joe'}, 'user', storageManager.registry)).toEqual(1) 278 | }) 279 | 280 | it('should work for an object with a compound pk', async () => { 281 | const { storageManager } = await setupTest({collections: { 282 | user: { 283 | version: new Date('2019-02-19'), 284 | fields: { 285 | firstName: {type: 'string'}, 286 | lastName: {type: 'string'}, 287 | email: {type: 'string'} 288 | }, 289 | pkIndex: ['firstName', 'lastName'] 290 | } 291 | }}) 292 | expect(getObjectPk({firstName: 'Joe', lastName: 'Doe', email: 'bla@bla.com'}, 'user', storageManager.registry)).toEqual(['Joe', 'Doe']) 293 | }) 294 | }) 295 | 296 | describe('getObjectWithoutPk()', () => { 297 | it('should work for an object with a single field pk', async () => { 298 | const { storageManager } = await setupTest({collections: { 299 | user: { 300 | version: new Date('2019-02-19'), 301 | fields: { 302 | displayName: {type: 'string'} 303 | } 304 | } 305 | }}) 306 | expect(getObjectWithoutPk({id: 1, displayName: 'Joe'}, 'user', storageManager.registry)).toEqual({displayName: 'Joe'}) 307 | }) 308 | 309 | it('should work for an object with a compound pk', async () => { 310 | const { storageManager } = await setupTest({collections: { 311 | user: { 312 | version: new Date('2019-02-19'), 313 | fields: { 314 | firstName: {type: 'string'}, 315 | lastName: {type: 'string'}, 316 | email: {type: 'string'} 317 | }, 318 | pkIndex: ['firstName', 'lastName'] 319 | } 320 | }}) 321 | expect(getObjectWithoutPk({firstName: 'Joe', lastName: 'Doe', email: 'bla@bla.com'}, 'user', storageManager.registry)).toEqual({email: 'bla@bla.com'}) 322 | }) 323 | }) 324 | 325 | describe('setObjectPk()', () => { 326 | it('should work for an object with a single field pk', async () => { 327 | const { storageManager } = await setupTest({collections: { 328 | user: { 329 | version: new Date('2019-02-19'), 330 | fields: { 331 | displayName: {type: 'string'} 332 | } 333 | } 334 | }}) 335 | 336 | const object = {displayName: 'Joe'} 337 | const returned = setObjectPk(object, 2, 'user', storageManager.registry) 338 | expect(object).toEqual({id: 2, displayName: 'Joe'}) 339 | expect(returned).toEqual(object) 340 | }) 341 | 342 | it('should work for an object with a compound pk', async () => { 343 | const { storageManager } = await setupTest({collections: { 344 | user: { 345 | version: new Date('2019-02-19'), 346 | fields: { 347 | firstName: {type: 'string'}, 348 | lastName: {type: 'string'}, 349 | email: {type: 'string'} 350 | }, 351 | pkIndex: ['firstName', 'lastName'] 352 | } 353 | }}) 354 | 355 | const object = {email: 'joe@doe.com'} 356 | const returned = setObjectPk(object, ['Joe', 'Doe'], 'user', storageManager.registry) 357 | expect(object).toEqual({firstName: 'Joe', lastName: 'Doe', email: 'joe@doe.com'}) 358 | expect(returned).toEqual(object) 359 | }) 360 | }) 361 | }) 362 | -------------------------------------------------------------------------------- /ts/utils.ts: -------------------------------------------------------------------------------- 1 | import pickBy from 'lodash/fp/pickBy' 2 | import StorageRegistry from './registry' 3 | import { 4 | isChildOfRelationship, 5 | isConnectsRelationship, 6 | isRelationshipReference, 7 | IndexSourceField, 8 | } from './types' 9 | import type StorageManagerInterface from './types/manager' 10 | 11 | import internalPluralize from 'pluralize' 12 | 13 | export function pluralize(singular: string) { 14 | return internalPluralize(singular) 15 | } 16 | 17 | export type CreateObjectDissection = { objects: any[] } 18 | 19 | export function dissectCreateObjectOperation( 20 | operationDefinition, 21 | registry: StorageRegistry, 22 | options: { generatePlaceholder?: () => string | number } = {}, 23 | ): CreateObjectDissection { 24 | const objectsByPlaceholder = {} 25 | options.generatePlaceholder = 26 | options.generatePlaceholder || 27 | (() => { 28 | let placeholdersCreated = 0 29 | return () => ++placeholdersCreated 30 | })() 31 | 32 | const dissect = (collection: string, object, relations = {}, path = []) => { 33 | const collectionDefinition = registry.collections[collection] 34 | if (!collectionDefinition) { 35 | throw new Error(`Unknown collection: ${collection}`) 36 | } 37 | 38 | const lonelyObject = pickBy((value, key) => { 39 | return !collectionDefinition.reverseRelationshipsByAlias[key] 40 | }, object) 41 | 42 | const placeholder = options.generatePlaceholder() 43 | objectsByPlaceholder[placeholder] = lonelyObject 44 | const dissection = [ 45 | { 46 | placeholder, 47 | collection, 48 | path, 49 | object: lonelyObject, 50 | relations, 51 | }, 52 | ] 53 | 54 | for (const reverseRelationshipAlias in collectionDefinition.reverseRelationshipsByAlias) { 55 | let toCreate = object[reverseRelationshipAlias] 56 | if (!toCreate) { 57 | continue 58 | } 59 | 60 | const reverseRelationship = 61 | collectionDefinition.reverseRelationshipsByAlias[ 62 | reverseRelationshipAlias 63 | ] 64 | if (isChildOfRelationship(reverseRelationship)) { 65 | if (reverseRelationship.single) { 66 | toCreate = [toCreate] 67 | } 68 | 69 | let childCount = 0 70 | for (const objectToCreate of toCreate) { 71 | const childPath = [reverseRelationshipAlias] as Array< 72 | string | number 73 | > 74 | if (!reverseRelationship.single) { 75 | childPath.push(childCount) 76 | childCount += 1 77 | } 78 | 79 | dissection.push( 80 | ...dissect( 81 | reverseRelationship.sourceCollection, 82 | objectToCreate, 83 | { [reverseRelationship.alias]: placeholder }, 84 | [...path, ...childPath], 85 | ), 86 | ) 87 | } 88 | } else if (isConnectsRelationship(reverseRelationship)) { 89 | if (object[reverseRelationshipAlias]) { 90 | throw new Error( 91 | 'Sorry, creating connects relationships through put is not supported yet :(', 92 | ) 93 | } 94 | } else { 95 | throw new Error( 96 | `Sorry, but I have no idea what kind of relationship you're trying to create`, 97 | ) 98 | } 99 | } 100 | 101 | return dissection 102 | } 103 | 104 | return { 105 | objects: dissect( 106 | operationDefinition.collection, 107 | operationDefinition.args, 108 | ), 109 | } 110 | } 111 | 112 | export function convertCreateObjectDissectionToBatch( 113 | dissection: CreateObjectDissection, 114 | ) { 115 | const converted = [] 116 | for (const step of dissection.objects) { 117 | converted.push({ 118 | operation: 'createObject', 119 | collection: step.collection, 120 | placeholder: step.placeholder.toString(), 121 | args: step.object, 122 | replace: Object.entries(step.relations).map(([key, value]) => ({ 123 | path: key, 124 | placeholder: value.toString(), 125 | })), 126 | }) 127 | } 128 | return converted 129 | } 130 | 131 | export function reconstructCreatedObjectFromBatchResult(args: { 132 | object 133 | collection: string 134 | storageRegistry: StorageRegistry 135 | operationDissection: CreateObjectDissection 136 | batchResultInfo 137 | }) { 138 | for (const step of args.operationDissection.objects) { 139 | const collectionDefiniton = 140 | args.storageRegistry.collections[args.collection] 141 | const pkIndex = collectionDefiniton.pkIndex 142 | setIn( 143 | args.object, 144 | [...step.path, pkIndex], 145 | args.batchResultInfo[step.placeholder].object[pkIndex as string], 146 | ) 147 | } 148 | } 149 | 150 | export function setIn(obj, path: Array, value) { 151 | for (const part of path.slice(0, -1)) { 152 | obj = obj[part] 153 | } 154 | obj[path.slice(-1)[0]] = value 155 | } 156 | 157 | export function getObjectPk( 158 | object, 159 | collection: string, 160 | registry: StorageRegistry, 161 | ) { 162 | const pkIndex = registry.collections[collection].pkIndex 163 | if (typeof pkIndex === 'string') { 164 | return object[pkIndex] 165 | } 166 | if (isRelationshipReference(pkIndex)) { 167 | throw new Error( 168 | `Getting object PKs of objects with relationships as PKs is not supported yet`, 169 | ) 170 | } 171 | 172 | const pk = [] 173 | for (const indexField of pkIndex) { 174 | if (typeof indexField === 'string') { 175 | pk.push(object[indexField]) 176 | } else { 177 | throw new Error( 178 | `getObject() called with relationship as pk, which is not supported yet.`, 179 | ) 180 | } 181 | } 182 | return pk 183 | } 184 | 185 | export function getObjectWithoutPk( 186 | object, 187 | collection: string, 188 | registry: StorageRegistry, 189 | ) { 190 | object = { ...object } 191 | 192 | const pkIndex = registry.collections[collection].pkIndex 193 | if (typeof pkIndex === 'string') { 194 | delete object[pkIndex] 195 | return object 196 | } 197 | if (isRelationshipReference(pkIndex)) { 198 | throw new Error( 199 | `Getting objects without PKs of objects with relationships as PKs is not supported yet`, 200 | ) 201 | } 202 | 203 | for (const indexField of pkIndex) { 204 | if (typeof indexField === 'string') { 205 | delete object[indexField] 206 | } else { 207 | throw new Error( 208 | `getObject() called with relationship as pk, which is not supported yet.`, 209 | ) 210 | } 211 | } 212 | return object 213 | } 214 | 215 | export function setObjectPk( 216 | object, 217 | pk, 218 | collection: string, 219 | registry: StorageRegistry, 220 | ) { 221 | const collectionDefinition = registry.collections[collection] 222 | if (!collectionDefinition) { 223 | throw new Error( 224 | `Could not find collection definition for '${collection}'`, 225 | ) 226 | } 227 | 228 | const pkIndex = collectionDefinition.pkIndex 229 | if (typeof pkIndex === 'string') { 230 | object[pkIndex] = pk 231 | return object 232 | } 233 | if (isRelationshipReference(pkIndex)) { 234 | throw new Error( 235 | `Setting object PKs of objects with relationships as PKs is not supported yet`, 236 | ) 237 | } 238 | 239 | let indexFieldIdx = 0 240 | for (const indexField of pkIndex) { 241 | if (typeof indexField === 'string') { 242 | object[indexField] = pk[indexFieldIdx++] 243 | } else { 244 | throw new Error( 245 | `setObjectPk() called with relationship as pk, which is not supported yet.`, 246 | ) 247 | } 248 | } 249 | 250 | return object 251 | } 252 | 253 | export function getObjectWhereByPk( 254 | storageRegistry: StorageRegistry, 255 | collection: string, 256 | pk: number | string | Array, 257 | ): { [field: string]: number | string } { 258 | const getPkField = (indexSourceField: IndexSourceField) => { 259 | return typeof indexSourceField === 'object' && 260 | 'relationship' in indexSourceField 261 | ? indexSourceField.relationship 262 | : indexSourceField 263 | } 264 | 265 | const collectionDefinition = storageRegistry.collections[collection] 266 | const pkIndex = collectionDefinition.pkIndex! 267 | const where: { [field: string]: number | string } = {} 268 | if (pkIndex instanceof Array) { 269 | for (let index = 0; index < pkIndex.length; index++) { 270 | const pkField = getPkField(pkIndex[index]) 271 | where[pkField] = pk[index] 272 | } 273 | } else { 274 | where[getPkField(pkIndex)] = pk as number | string 275 | } 276 | 277 | return where 278 | } 279 | 280 | export async function getObjectByPk( 281 | storageManager: StorageManagerInterface, 282 | collection: string, 283 | pk: number | string | Array, 284 | ): Promise { 285 | const where = getObjectWhereByPk(storageManager.registry, collection, pk) 286 | return storageManager.operation('findObject', collection, where) 287 | } 288 | 289 | export async function updateOrCreate(params: { 290 | storageManager: StorageManagerInterface 291 | executeOperation?: ( 292 | operationName: string, 293 | ...operationArgs: any[] 294 | ) => Promise 295 | collection: string 296 | where?: { [key: string]: any } 297 | updates: { [key: string]: any } 298 | }): Promise<{ opPerformed: 'create' | 'update' }> { 299 | const executeOperation = 300 | params.executeOperation ?? 301 | ((...args) => params.storageManager.operation(...args)) 302 | const existingObject = 303 | params.where && 304 | (await params.executeOperation( 305 | 'findObject', 306 | params.collection, 307 | params.where, 308 | )) 309 | if (existingObject) { 310 | const pk = getObjectPk( 311 | existingObject, 312 | params.collection, 313 | params.storageManager.registry, 314 | ) 315 | const where = getObjectWhereByPk( 316 | params.storageManager.registry, 317 | params.collection, 318 | pk, 319 | ) 320 | await executeOperation('updateObject', params.collection, where, { 321 | ...params.where, 322 | ...params.updates, 323 | }) 324 | return { opPerformed: 'update' } 325 | } else { 326 | await executeOperation('createObject', params.collection, { 327 | ...(params.where ?? {}), 328 | ...params.updates, 329 | }) 330 | return { opPerformed: 'create' } 331 | } 332 | } 333 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.5", 3 | "compilerOptions": { 4 | "target": "es5", 5 | "module": "commonjs", 6 | "moduleResolution": "node", 7 | "noImplicitAny": false, 8 | "allowSyntheticDefaultImports": true, 9 | "esModuleInterop": true, 10 | "removeComments": true, 11 | "lib": [ 12 | "es2017", 13 | "dom" 14 | ], 15 | "noLib": false, 16 | "preserveConstEnums": true, 17 | "declaration": true, 18 | "sourceMap": true, 19 | "suppressImplicitAnyIndexErrors": true, 20 | "typeRoots": [ 21 | "./node_modules/@types" 22 | ], 23 | "outDir": "lib" 24 | }, 25 | "filesGlob": [ 26 | "./ts/**/*.ts", 27 | "!./node_modules/**/*.ts" 28 | ] 29 | } 30 | --------------------------------------------------------------------------------