├── .editorconfig ├── .gitignore ├── .travis.yml ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── code-of-conduct.md ├── package-lock.json ├── package.json ├── rollup.config.ts ├── src ├── decorators │ ├── SlothEntity.ts │ ├── SlothField.ts │ ├── SlothIndex.ts │ ├── SlothRel.ts │ ├── SlothURI.ts │ └── SlothView.ts ├── helpers │ ├── Dict.ts │ └── EntityConstructor.ts ├── models │ ├── BaseEntity.ts │ ├── PouchFactory.ts │ ├── ProtoData.ts │ ├── SlothData.ts │ ├── SlothDatabase.ts │ ├── changes.ts │ └── relationDescriptors.ts ├── slothdb.ts └── utils │ ├── getProtoData.ts │ ├── getSlothData.ts │ └── relationMappers.ts ├── test ├── integration │ ├── Album.test.ts │ ├── Album.ts │ ├── Artist.test.ts │ ├── Artist.ts │ ├── Author.test.ts │ ├── Author.ts │ ├── Track.ts │ ├── changes.test.ts │ ├── docKeys.test.ts │ ├── object.test.ts │ └── views.test.ts ├── unit │ ├── decorators │ │ ├── SlothField.test.ts │ │ ├── SlothRel.test.ts │ │ ├── SlothURI.test.ts │ │ └── SlothView.test.ts │ └── utils │ │ └── relationMappers.test.ts └── utils │ ├── assignProto.ts │ ├── delay.ts │ ├── emptyProtoData.ts │ └── localPouchFactory.ts ├── tools ├── gh-pages-publish.ts └── semantic-release-prepare.ts ├── tsconfig.json └── tslint.json /.editorconfig: -------------------------------------------------------------------------------- 1 | #root = true 2 | 3 | [*] 4 | indent_style = space 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | max_line_length = 100 10 | indent_size = 2 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | .nyc_output 4 | .DS_Store 5 | *.log 6 | .vscode 7 | .idea 8 | dist 9 | compiled 10 | .awcache 11 | .rpt2_cache 12 | docs 13 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | branches: 3 | only: 4 | - master 5 | - /^greenkeeper/.*$/ 6 | cache: 7 | yarn: true 8 | directories: 9 | - node_modules 10 | notifications: 11 | email: false 12 | node_js: 13 | - node 14 | script: 15 | - npm run test:prod && npm run build 16 | after_success: 17 | - npm run report-coverage 18 | - npm run deploy-docs 19 | - npm run semantic-release 20 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | We're really glad you're reading this, because we need volunteer developers to help this project come to fruition. 👏 2 | 3 | ## Instructions 4 | 5 | These steps will guide you through contributing to this project: 6 | 7 | - Fork the repo 8 | - Clone it and install dependencies 9 | 10 | git clone https://github.com/YOUR-USERNAME/typescript-library-starter 11 | npm install 12 | 13 | Keep in mind that after running `npm install` the git repo is reset. So a good way to cope with this is to have a copy of the folder to push the changes, and the other to try them. 14 | 15 | Make and commit your changes. Make sure the commands npm run build and npm run test:prod are working. 16 | 17 | Finally send a [GitHub Pull Request](https://github.com/alexjoverm/typescript-library-starter/compare?expand=1) with a clear list of what you've done (read more [about pull requests](https://help.github.com/articles/about-pull-requests/)). Make sure all of your commits are atomic (one feature per commit). 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2017 vinz243 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 | # SlothDB 2 | 3 | [![styled with prettier](https://img.shields.io/badge/styled_with-prettier-ff69b4.svg)](https://github.com/prettier/prettier) 4 | [![Greenkeeper badge](https://badges.greenkeeper.io/compactd/slothdb.svg)](https://greenkeeper.io/) 5 | [![Travis](https://img.shields.io/travis/compactd/slothdb.svg)](https://travis-ci.org/compactd/slothdb) 6 | [![Coveralls](https://img.shields.io/coveralls/compactd/slothdb.svg)](https://coveralls.io/github/compactd/slothdb) 7 | 8 | A typescript ORM that uses annotation and classes to describe the database 9 | 10 | ### Features 11 | 12 | - Built using annotations 13 | - Simple field support (update and read doc values) 14 | - URI fields - string fields which value depends on other fields 15 | - Versatile PouchDB support 16 | - Views and index support 17 | - Relation support : oneToMany, manyToOne and cascading removal 18 | 19 | ### Usage 20 | 21 | #### Describing the document schema 22 | 23 | Simply use an interface to describe the document schema, with at least an `_id` string field 24 | 25 | #### Describing the entity class 26 | 27 | The entity class needs to extend `BaseEntity` and requires the SlothEntity annotation (passing the database name). 28 | 29 | ```ts 30 | @SlothEntity('students') 31 | class StudentEnt extends BaseEntity { 32 | ``` 33 | 34 | #### Describing the fields 35 | 36 | Add your document fields to the entity class and decorate them using `SlothField`. The assigned value will be used as a default value. 37 | 38 | ```ts 39 | @SlothField() 40 | age: number = 18 41 | ``` 42 | 43 | #### Describing your URIs 44 | 45 | It is common practice to generate string URIs from the other document values and use it as an `_id` or on other indices for easier sorting and relationship description (especially oneToMany). The SlothURI decorator takes at least two arguments: the first one is the root, which is a constant string value. Using the database name when not describing relation is recommended. For example `students/john-doe` has the `students` root, but does not describe any relationship. If your document belongs to a parent document then a root that includes all documents types would be recommended, for example `university` would cover students, marks and courses. The other values are field names, included in you document, to be used to build the URI in the following order. Each specified field will then be stringified and slugified using `toString()` and `limax`. 46 | For example: 47 | 48 | ``` 49 | @SlothURI('students', 'surname', 'name') 50 | _id: string = '' 51 | ``` 52 | 53 | Please note we are assigning a default value to the `_id` field that will get ignored. 54 | 55 | This is the equivalent of a `students/:surname/:name` DocURI. 56 | 57 | #### PouchDB Factory 58 | 59 | A [PouchFactory](https://compactd.github.io/slothdb/globals.html#pouchfactory) is a simple function that returns a PouchDB instance for a given database name. Every function in [`SlothDatabase`](https://compactd.github.io/slothdb/classes/slothdatabase.html) except `withRoot` requires as an argument a PouchFactory. Entities are attached a PouchFactory in the constructor, so the entity functions (`save()`, `remove()`, etc) does not require a factory. A simple factory would be `(name: string) => new PouchDB(name)` 60 | 61 | #### Database operations 62 | 63 | ```ts 64 | const author1 = Author.create(factory, {...}) 65 | 66 | await author.save() 67 | 68 | author.age = 42 69 | 70 | await author.save() 71 | 72 | await author.remove() 73 | ``` 74 | 75 | #### Relationships 76 | 77 | SlothDB supports for now one type of relationship: belongsTo/oneToMany (which is the same relationship, but with a different perspective). 78 | 79 | The annotation [`SlothRel`](https://compactd.github.io/slothdb/globals.html#slothrel) can be used on the field that describes a belongsTo relationship, that-is-to-say the field value is a string representing the parent document `_id` field. The SlothField decorator is not usable with this annotation. If the target field is included in SlothURI, then the string value of this field (which is the `_id` of the parent document) will have its root removed in order to include it in the URI. The value is not slugified using limax, so `/` are not escaped. For example `students/mit/john-doe` will become `mit/john-doe` and a mark URI for this student would become `marks/mit/john-doe/chemistry/2018-04-20` whereas the original URI has only 3 parts (student, course, date). 80 | 81 | To describe a belongsTo relationship you can use SlothRel with a `belongsTo` object: 82 | 83 | ```ts 84 | @SlothRel({belongsTo: () => Student}) 85 | student_id: string = '' 86 | ``` 87 | 88 | The `belongsTo` value is just a simple function that returns the parent SlothDatabase instance, to avoid circular dependency conflicts. 89 | 90 | If the `cascade` option is not present or `true`, removing all child document of a single parent will also remove the parent. 91 | 92 | The annotation SlothRel can also be used on a non-document field, with the `hasMany` function, which returns the SlothDatabase instance of the child entity. The target field is a function that returns a child instance. This function should null, the annotation will replace it with an impl: 93 | 94 | ```ts 95 | @SlothRel({ hasMany: () => Album }) 96 | albums: () => Album 97 | ``` 98 | 99 | The SlothRel uses the `withRoot` function of `SlothDatabase` which return a SlothDatabase that prefixes the startkey argument of the allDocs calls with the current document `_id` hence the id needs to be described using the same root and the first key of the child's `_id` must be the parent id field. 100 | 101 | #### Views and indexes 102 | 103 | The [`SlothView`](https://compactd.github.io/slothdb/globals.html#slothview) annotation describes a CouchDB map function. It takes as an argument a function `(doc, emit) => void`, the view name (default to `by_`) and the optional design document identifier (default to `views`). Please note that this function does not modify any behavior of the target, so the decorated field requires another decorator (like [`SlothField`](https://compactd.github.io/slothdb/globals.html#slothfield) or [`SlothURI`](https://compactd.github.io/slothdb/globals.html#slothuri)) and the choice of the decorated field is purely semantic and decorating another field will only change the view name. **Depending on the typescript target, you might want to use es5 functions** (avoid fat-arrow functions). 104 | 105 | The [`SlothIndex`](https://compactd.github.io/slothdb/globals.html#slothindex) is a function that applies the SlothView decorator with `emit(doc['${key}'].toString())` as a function to create a basic index on the decorated field. 106 | 107 | The [`SlothDatabase`](https://compactd.github.io/slothdb/classes/slothdatabase.html) class takes as a third generic argument extending a string that describes the possible view values. The [`queryDocs`](https://compactd.github.io/slothdb/classes/slothdatabase.html#querydocs) function then takes as an argument the string constrained by the generic parameter. It is then recommended to use an enum to identify views: 108 | 109 | ```ts 110 | enum AuthorView { 111 | byName = 'views/by_name' 112 | byAge = 'views/by_age' 113 | } 114 | 115 | ... 116 | 117 | const Author = new SlothDatabase(AuthorEntity) 118 | 119 | const seniorAuthors = await Author.queryDocs(factory, AuthorView.byAge, 60, 130) 120 | ``` 121 | 122 | 123 | ## Full example 124 | 125 | 126 | ```ts 127 | interface IAuthor { 128 | _id: string, 129 | name: string 130 | } 131 | 132 | @SlothEntity('authors') 133 | class AuthorEntity extends BaseEntity { 134 | @SlothURI('library', 'author') 135 | _id: string = '' 136 | 137 | @SlothField() 138 | name: string = 'Unknown' 139 | } 140 | 141 | export const Author = new SlothDatabase(AuthorEntity) 142 | 143 | interface IBook { 144 | _id: string, 145 | name: string, 146 | author: string 147 | } 148 | 149 | export enum BookViews { 150 | ByName = 'views/by_name' 151 | } 152 | 153 | @SlothEntity('books') 154 | class BookEntity extend BaseEntity { 155 | @SlothURI('library', 'author', 'name') 156 | _id: string = '' 157 | 158 | @SlothIndex() 159 | @SlothField() 160 | name: string = 'Unknown' 161 | 162 | @SlothRel({belongsTo: Author}) 163 | author: string = 'library/unknown' 164 | } 165 | 166 | export const Book = new SlothDatabase(BookEntity) 167 | ``` 168 | Then to use 169 | 170 | ```ts 171 | const jrrTolkien = Author.create(factory, {name: 'JRR Tolkien'}) 172 | 173 | jrrTolkien._id === 'library/jrr-tolkien' 174 | jrrTolkien.name === 'JRR Tolkien' 175 | 176 | await jrrTolkien.exists() === false 177 | await jrrTolkien.save() 178 | await jrrTolkien.exists() === true 179 | 180 | const lotr = Book.create(factory, {name: 'The Lord Of The Rings', author: jrrTolkien._id}) 181 | 182 | lotr._id === 'library/jrr-tolkien/the-lord-of-the-rings' 183 | 184 | const golding = await Author.put(factory, {name: 'William Golding'}) 185 | 186 | await golding.exists() === true 187 | 188 | await Book.put(factory, {name: 'The Lord of The Flies', author: golding._id}) 189 | 190 | const booksStartingWithLord = await Author.queryDocs(factory, BookViews.ByName, 'The Lord of The') 191 | booksStartingWithLord.length === 2 192 | 193 | ``` 194 | 195 | ### NPM scripts 196 | 197 | - `npm t`: Run test suite 198 | - `npm start`: Run `npm run build` in watch mode 199 | - `npm run test:watch`: Run test suite in [interactive watch mode](http://facebook.github.io/jest/docs/cli.html#watch) 200 | - `npm run test:prod`: Run linting and generate coverage 201 | - `npm run build`: Generate bundles and typings, create docs 202 | - `npm run lint`: Lints code 203 | - `npm run commit`: Commit using conventional commit style ([husky](https://github.com/typicode/husky) will tell you to use it if you haven't :wink:) 204 | -------------------------------------------------------------------------------- /code-of-conduct.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at alexjovermorales@gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [http://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: http://contributor-covenant.org 74 | [version]: http://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "slothdb", 3 | "version": "2.1.0", 4 | "description": "", 5 | "keywords": [], 6 | "main": "dist/slothdb.umd.js", 7 | "module": "dist/slothdb.es5.js", 8 | "typings": "dist/types/slothdb.d.ts", 9 | "files": [ 10 | "dist" 11 | ], 12 | "author": "vinz243 ", 13 | "repository": { 14 | "type": "git", 15 | "url": "https://github.com/compactd/slothdb.git" 16 | }, 17 | "license": "MIT", 18 | "engines": { 19 | "node": ">=6.0.0" 20 | }, 21 | "scripts": { 22 | "lint": "tslint -t codeFrame 'src/**/*.ts' 'test/**/*.ts'", 23 | "prebuild": "rimraf dist", 24 | "build": "tsc --module commonjs && rollup -c rollup.config.ts && typedoc --exclude test/**.ts --out docs --target es6 --theme minimal --mode file src", 25 | "start": "rollup -c rollup.config.ts -w", 26 | "test": "jest", 27 | "test:watch": "jest --watch", 28 | "test:prod": "npm run lint && npm run test -- --coverage --no-cache", 29 | "deploy-docs": "ts-node tools/gh-pages-publish", 30 | "report-coverage": "cat ./coverage/lcov.info | coveralls", 31 | "commit": "git-cz", 32 | "semantic-release": "semantic-release", 33 | "semantic-release-prepare": "ts-node tools/semantic-release-prepare", 34 | "precommit": "lint-staged" 35 | }, 36 | "lint-staged": { 37 | "{src,test}/**/*.ts": [ 38 | "prettier --write --no-semi --single-quote", 39 | "git add" 40 | ] 41 | }, 42 | "config": { 43 | "commitizen": { 44 | "path": "node_modules/cz-conventional-changelog" 45 | }, 46 | "validate-commit-msg": { 47 | "types": "conventional-commit-types", 48 | "helpMessage": "Use \"npm run commit\" instead, we use conventional-changelog format :) (https://github.com/commitizen/cz-cli)" 49 | } 50 | }, 51 | "jest": { 52 | "transform": { 53 | ".(ts|tsx)": "/node_modules/ts-jest/preprocessor.js" 54 | }, 55 | "testRegex": "(/__tests__/.*|\\.(test|spec))\\.(ts|tsx|js)$", 56 | "moduleFileExtensions": [ 57 | "ts", 58 | "tsx", 59 | "js" 60 | ], 61 | "coveragePathIgnorePatterns": [ 62 | "/node_modules/", 63 | "/test/" 64 | ], 65 | "coverageThreshold": { 66 | "global": { 67 | "branches": 90, 68 | "functions": 95, 69 | "lines": 95, 70 | "statements": 95 71 | } 72 | }, 73 | "collectCoverage": true 74 | }, 75 | "devDependencies": { 76 | "@types/jest": "^23.0.0", 77 | "@types/joi": "^13.0.5", 78 | "@types/node": "^10.0.3", 79 | "@types/pouchdb": "^6.3.2", 80 | "@types/slug": "^0.9.0", 81 | "colors": "^1.1.2", 82 | "commitizen": "^2.9.6", 83 | "coveralls": "^3.0.0", 84 | "cross-env": "^5.0.1", 85 | "cz-conventional-changelog": "^2.0.0", 86 | "husky": "^0.14.0", 87 | "jest": "^22.0.2", 88 | "lint-staged": "^7.0.0", 89 | "lodash.camelcase": "^4.3.0", 90 | "pouchdb": "^6.4.3", 91 | "pouchdb-adapter-memory": "^6.4.3", 92 | "prettier": "^1.4.4", 93 | "prompt": "^1.0.0", 94 | "replace-in-file": "^3.0.0-beta.2", 95 | "rimraf": "^2.6.1", 96 | "rollup": "^0.58.0", 97 | "rollup-plugin-commonjs": "^9.1.0", 98 | "rollup-plugin-node-builtins": "^2.1.2", 99 | "rollup-plugin-node-resolve": "^3.0.0", 100 | "rollup-plugin-sourcemaps": "^0.4.2", 101 | "rollup-plugin-typescript2": "^0.13.0", 102 | "semantic-release": "^15.1.4", 103 | "ts-jest": "^22.0.0", 104 | "ts-node": "^6.0.0", 105 | "tslint": "^5.8.0", 106 | "tslint-config-prettier": "^1.1.0", 107 | "tslint-config-standard": "^7.0.0", 108 | "typedoc": "^0.12.0", 109 | "typescript": "^2.6.2", 110 | "validate-commit-msg": "^2.12.2" 111 | }, 112 | "dependencies": { 113 | "debug": "^3.1.0", 114 | "limax": "^1.6.0" 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /rollup.config.ts: -------------------------------------------------------------------------------- 1 | import resolve from 'rollup-plugin-node-resolve' 2 | import commonjs from 'rollup-plugin-commonjs' 3 | import sourceMaps from 'rollup-plugin-sourcemaps' 4 | import camelCase from 'lodash.camelcase' 5 | import typescript from 'rollup-plugin-typescript2' 6 | import builtins from 'rollup-plugin-node-builtins' 7 | 8 | const pkg = require('./package.json') 9 | 10 | const libraryName = 'slothdb' 11 | 12 | export default { 13 | input: `src/${libraryName}.ts`, 14 | output: [ 15 | { file: pkg.main, name: camelCase(libraryName), format: 'umd' }, 16 | { file: pkg.module, format: 'es' }, 17 | ], 18 | sourcemap: true, 19 | // Indicate here external modules you don't wanna include in your bundle (i.e.: 'lodash') 20 | external: [], 21 | watch: { 22 | include: 'src/**', 23 | }, 24 | plugins: [ 25 | // Compile TypeScript files 26 | typescript({ useTsconfigDeclarationDir: true }), 27 | // Allow bundling cjs modules (unlike webpack, rollup doesn't understand cjs) 28 | commonjs(), 29 | // Allow node_modules resolution, so you can use 'external' to control 30 | // which external modules to include in the bundle 31 | // https://github.com/rollup/rollup-plugin-node-resolve#usage 32 | resolve(), 33 | 34 | // Resolve source maps to the original source 35 | sourceMaps(), 36 | builtins() 37 | ], 38 | } 39 | -------------------------------------------------------------------------------- /src/decorators/SlothEntity.ts: -------------------------------------------------------------------------------- 1 | import BaseEntity from '../models/BaseEntity' 2 | import SlothData from '../models/SlothData' 3 | import PouchFactory from '../models/PouchFactory' 4 | import EntityConstructor from '../helpers/EntityConstructor' 5 | import getProtoData from '../utils/getProtoData' 6 | import ProtoData from '../models/ProtoData' 7 | 8 | function mapPropsOrDocToDocument({ fields }: ProtoData, data: any) { 9 | if (typeof data === 'string') { 10 | return {} 11 | } 12 | return fields.reduce( 13 | (props, { key, docKey }) => { 14 | if (!(key in data) && !(docKey in data)) { 15 | return props 16 | } 17 | if (key in data && docKey in data && key !== docKey) { 18 | throw new Error(`Both '${key}' and '${docKey}' exist on ${data}`) 19 | } 20 | return Object.assign({}, props, { 21 | [docKey]: key in data ? data[key] : data[docKey] 22 | }) 23 | }, 24 | {} as any 25 | ) 26 | } 27 | 28 | /** 29 | * This decorator is used to mark classes that will be an entity, a document 30 | * This function, by extending the constructor and defining this.sloth property 31 | * effectively allows the usage of other property decorators 32 | * @param name The database name for this entity 33 | * @typeparam S The database schema 34 | */ 35 | export default function SlothEntity(name: string) { 36 | return >(constructor: { 37 | new (factory: PouchFactory, idOrProps: Partial | string): T 38 | }): EntityConstructor => { 39 | const data = getProtoData(constructor.prototype, true) 40 | 41 | data.name = name 42 | 43 | const BaseEntity = constructor as EntityConstructor 44 | 45 | return class WrappedEntity extends BaseEntity { 46 | constructor(factory: PouchFactory, idOrProps: Partial | string) { 47 | super(factory, idOrProps) 48 | this.sloth.props = mapPropsOrDocToDocument( 49 | getProtoData(this), 50 | idOrProps 51 | ) 52 | } 53 | } as any 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/decorators/SlothField.ts: -------------------------------------------------------------------------------- 1 | import BaseEntity from '../models/BaseEntity' 2 | import getSlothData from '../utils/getSlothData' 3 | import getProtoData from '../utils/getProtoData' 4 | 5 | /** 6 | * SlothField decorator is used to mark a specific class property 7 | * as a document key. This introduces a few behaviors: 8 | * - setting the value in the constructor (or directly alongside declaration) 9 | * will set its default value 10 | * - mutating the value will update it in updatedProps 11 | * - accessing the value will first look into updatedProps, then props and then default values 12 | * @typeparam T the value type 13 | * @param docKeyName specifies the document key name, default to prop key name 14 | */ 15 | export default function SlothField(docKeyName?: string) { 16 | return function(this: any, target: object, key: string) { 17 | const docKey = docKeyName || key 18 | 19 | const desc = Reflect.getOwnPropertyDescriptor(target, key) 20 | 21 | if (desc) { 22 | if (desc.get || desc.set) { 23 | throw new Error('Cannot apply SlothField on top of another decorator') 24 | } 25 | } 26 | 27 | const data = getProtoData(target, true) 28 | 29 | data.fields.push({ key, docKey }) 30 | 31 | Reflect.deleteProperty(target, key) 32 | 33 | Reflect.defineProperty(target, key, { 34 | enumerable: true, 35 | get: function(): T | undefined { 36 | const { updatedProps, props = {}, defaultProps } = getSlothData(this) 37 | if (docKey in updatedProps) { 38 | return (updatedProps as any)[docKey] 39 | } 40 | if (docKey in props) { 41 | return (props as any)[docKey] 42 | } 43 | return (defaultProps as any)[docKey] 44 | }, 45 | set: function(value: T) { 46 | const { props, defaultProps, updatedProps } = getSlothData(this) 47 | 48 | if (!props) { 49 | defaultProps[docKey] = value 50 | 51 | return 52 | } 53 | 54 | if (docKey in defaultProps && value == null) { 55 | delete props[docKey] 56 | delete updatedProps[docKey] 57 | 58 | return 59 | } 60 | 61 | Object.assign(updatedProps, { [docKey]: value }) 62 | } 63 | }) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/decorators/SlothIndex.ts: -------------------------------------------------------------------------------- 1 | import BaseEntity from '../models/BaseEntity' 2 | import getSlothData from '../utils/getSlothData' 3 | import { join } from 'path' 4 | import getProtoData from '../utils/getProtoData' 5 | import SlothView from './SlothView' 6 | 7 | /** 8 | * Creates an index for a field. It's a view function that simply emits 9 | * the document key 10 | * 11 | * @see [[SlothDatabase.queryDocs]] 12 | * @export 13 | * @template S 14 | * @param {(doc: S, emit: Function) => void} fn the view function, as arrow or es5 function 15 | * @param {string} [docId='views'] the _design document identifier 16 | * @param {string} [viewId] the view identifier, default by_ 17 | * @returns the decorator to apply on the field 18 | */ 19 | export default function SlothIndex( 20 | viewId?: V, 21 | docId?: string 22 | ) { 23 | return (target: object, key: string) => { 24 | const field = getProtoData(target).fields.find(field => field.key === key) 25 | 26 | if (!field) { 27 | throw new Error('Please use SlothIndex on top of a SlothField') 28 | } 29 | 30 | SlothView( 31 | new Function( 32 | 'doc', 33 | 'emit', 34 | `emit(doc['${field.docKey}'].toString());` 35 | ) as any, 36 | viewId, 37 | docId 38 | )(target, key) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/decorators/SlothRel.ts: -------------------------------------------------------------------------------- 1 | import BaseEntity from '../models/BaseEntity' 2 | import getSlothData from '../utils/getSlothData' 3 | import getProtoData from '../utils/getProtoData' 4 | import { RelationDescriptor } from '../models/relationDescriptors' 5 | import SlothDatabase from '../models/SlothDatabase' 6 | 7 | /** 8 | * 9 | * SlothRel is used to indicate that a specific string property 10 | * corresponds to another entity identifier. The possible relations are: 11 | * 12 | * - belongsTo: one or several entities belongs to a parent entity 13 | * - hasMany: one entity has several other entities; the relation is stored the _id 14 | * 15 | * @see [[RelationDescriptor]] 16 | * @param rel the relation description 17 | */ 18 | export default function SlothRel(rel: RelationDescriptor) { 19 | return function(this: any, target: object, key: string) { 20 | const desc = Reflect.getOwnPropertyDescriptor(target, key) 21 | 22 | if (desc) { 23 | if (desc.get || desc.set) { 24 | throw new Error('Cannot apply SlothRel on top of another decorator') 25 | } 26 | } 27 | 28 | const { fields, rels } = getProtoData(target, true) 29 | 30 | if ('belongsTo' in rel) { 31 | fields.push({ key, docKey: key }) 32 | } 33 | 34 | rels.push({ ...rel, key }) 35 | 36 | Reflect.deleteProperty(target, key) 37 | 38 | Reflect.defineProperty(target, key, { 39 | get: function(this: any, target: any = this) { 40 | if ('hasMany' in rel) { 41 | return () => rel.hasMany().withRoot(this._id) 42 | } 43 | const { updatedProps, props } = getSlothData(target) 44 | if (key in updatedProps) { 45 | return (updatedProps as any)[key] 46 | } 47 | return (props as any)[key] 48 | }, 49 | set: function(value: string) { 50 | // Typescript calls this function before class decorator 51 | // Thus, when assigning default values in constructor we can get it and write it down 52 | // However this should only happen once to avoid missing bugs 53 | if (value === '') { 54 | return 55 | } 56 | 57 | Object.assign(getSlothData(this).updatedProps, { [key]: value }) 58 | } 59 | }) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/decorators/SlothURI.ts: -------------------------------------------------------------------------------- 1 | import BaseEntity from '../models/BaseEntity' 2 | import getSlothData from '../utils/getSlothData' 3 | import { join } from 'path' 4 | import getProtoData from '../utils/getProtoData' 5 | 6 | /** 7 | * The SlothURI decorator describes a class parameter that has no intrisic value 8 | * but which value depends on the other properties described as a path. 9 | * 10 | * The path has a root value, which is a constant and should be either the database name 11 | * pluralized, or a namespace. For example in library management system, the root could be 12 | * either `library` or `books`. It is recommended to use a namespace for relational 13 | * databases and the database name for orphans. 14 | * 15 | * The path components, following the root, should be slugified properties of the entity. 16 | * If the path needs to include another entity identifier, as often it needs to be in relational 17 | * database, then the root of the other entity id should be omitted, but path separator (/) 18 | * should NOT be escaped;, even in URLs. The path components are described using their property names. 19 | * 20 | * @param prefix the URI root 21 | * @param propsKeys key names to pick from the document 22 | * @typeparam S the document schema 23 | */ 24 | export default function SlothURI(prefix: string, ...propsKeys: (keyof S)[]) { 25 | return (target: object, key: string) => { 26 | const desc = Reflect.getOwnPropertyDescriptor(target, key) 27 | 28 | if (desc) { 29 | if (desc.get || desc.set) { 30 | throw new Error('Cannot apply SlothURI on top of another decorator') 31 | } 32 | } 33 | 34 | Reflect.deleteProperty(target, key) 35 | const { uris, fields } = getProtoData(target, true) 36 | 37 | uris.push({ 38 | name: key, 39 | prefix, 40 | propsKeys 41 | }) 42 | 43 | fields.push({ key, docKey: key }) 44 | 45 | Reflect.defineProperty(target, key, { 46 | get: function() { 47 | const { slug } = getSlothData(this) 48 | 49 | return join( 50 | prefix, 51 | ...propsKeys.map(propKey => { 52 | const value = (this as any)[propKey] 53 | 54 | if (!value) { 55 | throw new Error( 56 | `Key ${propKey} has no value, but is required for the ${key} URI` 57 | ) 58 | } 59 | 60 | const { rels } = getProtoData(target) 61 | const relation = rels.find(({ key }) => key === propKey) 62 | 63 | if (relation && 'belongsTo' in relation) { 64 | const splat: string[] = (value as string).split('/') 65 | 66 | if (splat.length > 1) { 67 | return splat.slice(1).join('/') 68 | } 69 | 70 | throw new Error(`URI '${value}' is invalid'`) 71 | } 72 | 73 | return slug(value.toString()) 74 | }) 75 | ) 76 | }, 77 | set: function(val: string) { 78 | if (val === '') { 79 | return 80 | } 81 | throw new Error(`Property ${key} is not writable`) 82 | } 83 | }) 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/decorators/SlothView.ts: -------------------------------------------------------------------------------- 1 | import BaseEntity from '../models/BaseEntity' 2 | import getSlothData from '../utils/getSlothData' 3 | import { join } from 'path' 4 | import getProtoData from '../utils/getProtoData' 5 | 6 | /** 7 | * Creates a view for a field. This function does not modify the 8 | * behavior of the current field, hence requires another decorator 9 | * such as SlothURI or SlothField. The view will be created by the SlothDatabase 10 | * 11 | * @export 12 | * @template S 13 | * @param {(doc: S, emit: Function) => void} fn the view function, as arrow or es5 function 14 | * @param {string} [docId='views'] the _design document identifier 15 | * @param {string} [viewId] the view identifier, default by_ 16 | * @returns the decorator to apply on the field 17 | */ 18 | export default function SlothView( 19 | fn: (doc: S, emit: Function) => void, 20 | viewId?: V, 21 | docId = 'views' 22 | ) { 23 | return (target: object, key: string) => { 24 | const desc = Reflect.getOwnPropertyDescriptor(target, key) 25 | 26 | if (desc) { 27 | if (!desc.get && !desc.set) { 28 | throw new Error('Required SlothView on top of another decorator') 29 | } 30 | } 31 | 32 | const fun = `function (__doc) { 33 | (${fn.toString()})(__doc, emit); 34 | }` 35 | 36 | const { views, fields } = getProtoData(target, true) 37 | 38 | const field = fields.find(field => field.key === key) 39 | 40 | if (!field) { 41 | throw new Error('Required SlothView on top of a SlothField') 42 | } 43 | 44 | views.push({ 45 | id: docId, 46 | name: viewId || `by_${field.docKey}`, 47 | function: fn, 48 | code: fun 49 | }) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/helpers/Dict.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Simple key value map 3 | * @typeparam V value type 4 | * @private 5 | */ 6 | export default interface Dict { 7 | [name: string]: V 8 | } 9 | -------------------------------------------------------------------------------- /src/helpers/EntityConstructor.ts: -------------------------------------------------------------------------------- 1 | import BaseEntity from '../models/BaseEntity' 2 | import PouchFactory from '../models/PouchFactory' 3 | 4 | /** 5 | * @private 6 | */ 7 | export default interface EntityConstructor< 8 | S extends { _id: string }, 9 | T extends BaseEntity 10 | > { 11 | new (factory: PouchFactory, idOrProps: Partial | string): T 12 | } 13 | -------------------------------------------------------------------------------- /src/models/BaseEntity.ts: -------------------------------------------------------------------------------- 1 | import PouchFactory from './PouchFactory' 2 | import getSlothData from '../utils/getSlothData' 3 | import getProtoData from '../utils/getProtoData' 4 | import Dict from '../helpers/Dict' 5 | import { RelationDescriptor } from './relationDescriptors' 6 | import { join } from 'path' 7 | import SlothData from './SlothData' 8 | import ProtoData from './ProtoData' 9 | import Debug from 'debug' 10 | 11 | const debug = Debug('slothdb') 12 | 13 | const limax = require('limax') 14 | const slug = (text: string) => limax(text, { separateNumbers: false }) 15 | 16 | /** 17 | * Base abstract entity, for all entitoies 18 | * The generic parameter S is the schema of the document 19 | * @typeparam S the document schema 20 | */ 21 | export default class BaseEntity { 22 | _id: string = '' 23 | 24 | private sloth: SlothData 25 | // tslint:disable-next-line:no-empty 26 | constructor(factory: PouchFactory, idOrProps: Partial | string) { 27 | const { name } = getProtoData(this) 28 | if (!name) { 29 | throw new Error('Please use SlothEntity') 30 | } 31 | if (typeof idOrProps === 'string') { 32 | this.sloth = { 33 | name, 34 | updatedProps: {}, 35 | defaultProps: {}, 36 | docId: idOrProps, 37 | factory, 38 | slug 39 | } 40 | } else { 41 | this.sloth = { 42 | name, 43 | defaultProps: {}, 44 | updatedProps: {}, 45 | docId: idOrProps._id, 46 | factory, 47 | slug 48 | } 49 | } 50 | } 51 | /** 52 | * Returns whether this document hhas unsaved updated properties 53 | */ 54 | isDirty() { 55 | const { docId, updatedProps } = getSlothData(this) 56 | 57 | return Object.keys(updatedProps).length > 0 || docId == null 58 | } 59 | 60 | /** 61 | * Returns a list of props following the entity schema 62 | */ 63 | getProps(): S { 64 | const { fields } = getProtoData(this) 65 | return fields.reduce( 66 | (props, { key }) => { 67 | return Object.assign({}, props, { [key]: (this as any)[key] }) 68 | }, 69 | {} as any 70 | ) 71 | } 72 | 73 | /** 74 | * Returns a list of props mapped with docKey 75 | */ 76 | getDocument() { 77 | const { fields } = getProtoData(this) 78 | return fields.reduce( 79 | (props, { key, docKey }) => { 80 | return Object.assign({}, props, { [docKey]: (this as any)[key] }) 81 | }, 82 | {} as any 83 | ) 84 | } 85 | 86 | /** 87 | * Saves document to database. If the document doesn't exist, 88 | * create it. If it exists, update it. If the _id was changed 89 | * (due to props changing), remove to old document and create a new one 90 | * 91 | * @returns a Promise resolving into document props 92 | * - If the document was updated, the _rev prop would be defined 93 | * and start with an index greater than 1 94 | * - If the document was created, the _rev prop would be defined 95 | * and start with 1 96 | * - If the document was not updated, because it is not dirty, 97 | * then no _rev property is returned 98 | */ 99 | async save(): Promise { 100 | debug('save document "%s"', this._id) 101 | 102 | const { fields } = getProtoData(this, false) 103 | const doc = this.getDocument() 104 | 105 | if (!this.isDirty()) { 106 | debug('document "%s" is not dirty, skippping saving', this._id) 107 | return doc 108 | } 109 | 110 | const { factory, name, docId } = getSlothData(this) 111 | const db = factory(name) 112 | 113 | try { 114 | const { _rev } = await db.get(this._id) 115 | 116 | debug( 117 | 'document "%s" already exists with revision "%s", updating...', 118 | this._id, 119 | _rev 120 | ) 121 | 122 | const { rev } = await db.put(Object.assign({}, doc, { _rev })) 123 | 124 | getSlothData(this).docId = this._id 125 | 126 | return Object.assign({}, doc, { _rev: rev }) 127 | } catch (err) { 128 | // Then document was not found 129 | 130 | if (err.name === 'not_found') { 131 | if (docId) { 132 | debug( 133 | 'document "%s" was renamed to "%s", removing old document', 134 | docId, 135 | this._id 136 | ) 137 | // We need to delete old doc 138 | const originalDoc = await db.get(docId) 139 | await db.remove(originalDoc) 140 | 141 | getSlothData(this).docId = this._id 142 | } 143 | 144 | const { rev, id } = await db.put(doc) 145 | debug('document "%s" rev "%s" was created', this._id, rev) 146 | 147 | getSlothData(this).docId = this._id 148 | 149 | return Object.assign({}, doc, { _rev: rev }) 150 | } 151 | 152 | throw err 153 | } 154 | } 155 | 156 | /** 157 | * Returns whether the specified document exists in database 158 | * Please note that this does not compare documents 159 | */ 160 | async exists() { 161 | const { name, factory } = getSlothData(this) 162 | try { 163 | await factory(name).get(this._id) 164 | 165 | return true 166 | } catch (err) { 167 | return false 168 | } 169 | } 170 | 171 | /** 172 | * Remove a document from the database 173 | * @returns a Promise that resolves into a boolean, true if document was removed, 174 | * false if the document doesn't have a docId in its slothdata 175 | */ 176 | async remove() { 177 | const { docId, factory, name } = getSlothData(this) 178 | 179 | if (!docId) { 180 | return false 181 | } 182 | 183 | const db = factory(name) 184 | const { _rev } = await db.get(docId) 185 | 186 | await db.remove(docId, _rev) 187 | 188 | getSlothData(this).docId = undefined 189 | 190 | await this.removeRelations() 191 | 192 | return true 193 | } 194 | 195 | /** 196 | * @private 197 | */ 198 | removeRelations() { 199 | const { rels } = getProtoData(this, false) 200 | 201 | return Promise.all( 202 | rels.map(rel => this.removeEntityRelation(rel)) 203 | ).then(() => { 204 | return 205 | }) 206 | } 207 | 208 | protected getProp(key: string) { 209 | return (this as any)[key] 210 | } 211 | 212 | protected getRelationDescriptor( 213 | keyName: keyof S 214 | ): RelationDescriptor & { key: string } { 215 | const { rels } = getProtoData(this) 216 | const rel = rels.find(rel => { 217 | return rel.key === keyName 218 | }) 219 | 220 | return rel! 221 | } 222 | 223 | protected getRelationEntity(keyName: keyof S) { 224 | const rel = this.getRelationDescriptor(keyName) 225 | const { factory } = getSlothData(this) 226 | 227 | if ('belongsTo' in rel) { 228 | return rel.belongsTo().findById(factory, this.getProp(keyName)) 229 | } 230 | 231 | throw new Error( 232 | `Cannot use getRelationEntity on ${keyName} as hasMany relation` 233 | ) 234 | } 235 | 236 | private async removeEntityRelation( 237 | rel: RelationDescriptor & { key: string } 238 | ) { 239 | const { factory, name } = getSlothData(this) 240 | 241 | if ('belongsTo' in rel && rel.cascade) { 242 | const relId = this.getProp(rel.key) 243 | 244 | const children = await factory(name).allDocs({ 245 | include_docs: false, 246 | startkey: join(relId, '/'), 247 | endkey: join(relId, '/\uffff') 248 | }) 249 | if (children.rows.length === 0) { 250 | try { 251 | const parent = await this.getRelationEntity(rel.key as any) 252 | 253 | await parent.remove() 254 | } catch (err) { 255 | if (err.message === 'missing') { 256 | return 257 | } 258 | throw err 259 | } 260 | } 261 | } 262 | 263 | if ('hasMany' in rel && rel.cascade !== false) { 264 | const db = rel.hasMany().withRoot(this._id) 265 | const children = await db.findAllDocs(factory) 266 | 267 | await Promise.all(children.map(child => child.remove())) 268 | } 269 | } 270 | } 271 | -------------------------------------------------------------------------------- /src/models/PouchFactory.ts: -------------------------------------------------------------------------------- 1 | type PouchFactory = (name: string) => PouchDB.Database 2 | 3 | export default PouchFactory 4 | -------------------------------------------------------------------------------- /src/models/ProtoData.ts: -------------------------------------------------------------------------------- 1 | import { RelationDescriptor } from './relationDescriptors' 2 | import SlothDatabase from './SlothDatabase' 3 | 4 | /** 5 | * This object is available for every instance 6 | * 7 | * @api private 8 | */ 9 | export default interface ProtoData { 10 | /** 11 | * A list of doc URIs 12 | * @see [[SlothURI]] 13 | */ 14 | uris: { 15 | name: string 16 | prefix: string 17 | propsKeys: string[] 18 | }[] 19 | /** 20 | * Database name 21 | */ 22 | name?: string 23 | /** 24 | * Fields 25 | */ 26 | fields: { 27 | key: string 28 | docKey: string 29 | }[] 30 | 31 | views: { 32 | id: string 33 | name: string 34 | function: Function 35 | code: string 36 | }[] 37 | 38 | rels: (RelationDescriptor & { key: string })[] 39 | } 40 | -------------------------------------------------------------------------------- /src/models/SlothData.ts: -------------------------------------------------------------------------------- 1 | import PouchFactory from './PouchFactory' 2 | 3 | /** 4 | * Represents the inherent sloth data for each entity 5 | * It is available via this 6 | * @api private 7 | * @typeparam S the document schema 8 | */ 9 | export default interface SlothData { 10 | /** 11 | * Database name, described by SlothEntity 12 | * @see [[SlothEntity]] 13 | */ 14 | name: string 15 | /** 16 | * Loaded properties from database or constructor 17 | */ 18 | props?: Partial 19 | /** 20 | * Properties updated at runtime 21 | */ 22 | updatedProps: Partial 23 | /** 24 | * Original document _id, only populated if passed in constructor 25 | */ 26 | docId?: string 27 | /** 28 | * A slug function that slugifies a string, should move this 29 | */ 30 | slug: (str: string) => string 31 | 32 | defaultProps: Partial 33 | 34 | factory: PouchFactory 35 | } 36 | -------------------------------------------------------------------------------- /src/models/SlothDatabase.ts: -------------------------------------------------------------------------------- 1 | import PouchFactory from './PouchFactory' 2 | import BaseEntity from './BaseEntity' 3 | import { Subscriber, ChangeAction, ActionType } from './changes' 4 | import EntityConstructor from '../helpers/EntityConstructor' 5 | import getProtoData from '../utils/getProtoData' 6 | import { join } from 'path' 7 | import Dict from '../helpers/Dict' 8 | import Debug from 'debug' 9 | 10 | const debug = Debug('slothdb') 11 | 12 | /** 13 | * This represent a Database 14 | * 15 | * @typeparam S the database schema 16 | * @typeparam E the Entity 17 | * @typeparam V the (optional) view type that defines a list of possible view IDs 18 | */ 19 | export default class SlothDatabase< 20 | S extends { _id: string }, 21 | E extends BaseEntity, 22 | V extends string = never 23 | > { 24 | _root: string 25 | /** 26 | * 27 | * @private 28 | * @type {string} 29 | * @memberof SlothDatabase 30 | */ 31 | _name: string 32 | /** 33 | * 34 | * 35 | * @type {T} 36 | * @memberof SlothDatabase 37 | * @private 38 | */ 39 | _model: EntityConstructor 40 | 41 | private _subscribers: { 42 | factory: any 43 | sub: Subscriber 44 | changes: PouchDB.Core.Changes 45 | }[] = [] 46 | 47 | private _setupPromise?: Promise 48 | 49 | /** 50 | * Create a new database instance 51 | * @param factory the pouch factory to use 52 | * @param name the database name 53 | * @param model the model constructor 54 | * @param root the root name, which is the startKey. I don't recommend it 55 | */ 56 | constructor(model: EntityConstructor, root: string = '') { 57 | this._model = model 58 | this._root = root 59 | 60 | const { name } = getProtoData(model.prototype) 61 | 62 | /* istanbul ignore if */ 63 | if (!name) { 64 | throw new Error('SlothEntity decorator is required') 65 | } 66 | 67 | this._name = name 68 | } 69 | 70 | /** 71 | * Join URI params provided as specified by SlothURI 72 | * Useful to recreate document id from URL params 73 | * Please note that 74 | * @param props the props 75 | * @param field 76 | */ 77 | joinURIParams(props: Partial, field = '_id') { 78 | const idURI = getProtoData(this._model.prototype).uris.find( 79 | ({ name }) => name === field 80 | ) 81 | if (!idURI) { 82 | throw new Error(`Field ${field} not found in URIs`) 83 | } 84 | return join( 85 | idURI.prefix, 86 | ...idURI.propsKeys.map(key => (props as any)[key]) 87 | ) 88 | } 89 | 90 | /** 91 | * Run a query 92 | * 93 | * @param factory the pouch factory 94 | * @param view the view identifier 95 | * @param startKey the optional startkey 96 | * @param endKey the optional endkey 97 | * @param includeDocs include_docs 98 | */ 99 | query( 100 | factory: PouchFactory, 101 | view: V, 102 | startKey?: any, 103 | endKey: any = typeof startKey === 'string' 104 | ? join(startKey, '\uffff') 105 | : undefined, 106 | includeDocs = false 107 | ): Promise> { 108 | return factory(this._name) 109 | .query(view, { 110 | startkey: startKey, 111 | endkey: endKey, 112 | include_docs: includeDocs 113 | }) 114 | .catch(err => { 115 | if (err.name === 'not_found') { 116 | debug(`Design document '%s' is missing, generating views...`, view) 117 | 118 | /* istanbul ignore if */ 119 | if (this._setupPromise) { 120 | this._setupPromise.then(() => { 121 | return this.query(factory, view, startKey, endKey, includeDocs) 122 | }) 123 | } 124 | 125 | this._setupPromise = this.initSetup(factory) 126 | 127 | return this._setupPromise.then(() => { 128 | debug('Created design documents') 129 | this._setupPromise = undefined 130 | return this.query(factory, view, startKey, endKey, includeDocs) 131 | }) 132 | } 133 | throw err 134 | }) 135 | } 136 | /** 137 | * Queries and maps docs to Entity objects 138 | * 139 | * @param factory the pouch factory 140 | * @param view the view identifier 141 | * @param startKey the optional startkey 142 | * @param endKey the optional endkey 143 | */ 144 | queryDocs( 145 | factory: PouchFactory, 146 | view: V, 147 | startKey?: any, 148 | endKey?: any 149 | ): Promise { 150 | return this.query( 151 | factory, 152 | view, 153 | startKey, 154 | endKey, 155 | true 156 | ).then(({ rows }) => { 157 | return rows.map(({ doc }) => new this._model(factory, doc as any)) 158 | }) 159 | } 160 | 161 | /** 162 | * Queries keys. Returns an array of emitted keys 163 | * 164 | * @param factory the pouch factory 165 | * @param view the view identifier 166 | * @param startKey the optional startkey 167 | * @param endKey the optional endkey 168 | */ 169 | queryKeys( 170 | factory: PouchFactory, 171 | view: V, 172 | startKey?: any, 173 | endKey?: any 174 | ): Promise { 175 | return this.query( 176 | factory, 177 | view, 178 | startKey, 179 | endKey, 180 | false 181 | ).then(({ rows }) => { 182 | return rows.map(({ key }) => key) 183 | }) 184 | } 185 | 186 | /** 187 | * Queries keys/_id map. Returns a map of emitted keys/ID 188 | * 189 | * @param factory the pouch factory 190 | * @param view the view identifier 191 | * @param startKey the optional startkey 192 | * @param endKey the optional endkey 193 | */ 194 | queryKeysIDs( 195 | factory: PouchFactory, 196 | view: V, 197 | startKey?: any, 198 | endKey?: any 199 | ): Promise> { 200 | return this.query( 201 | factory, 202 | view, 203 | startKey, 204 | endKey, 205 | false 206 | ).then(({ rows }) => { 207 | return rows.reduce( 208 | (acc, { key, id }) => ({ ...acc, [key]: id }), 209 | {} as Dict 210 | ) 211 | }) 212 | } 213 | 214 | /** 215 | * Returns a database that will only find entities with _id 216 | * starting with the root path 217 | * @param root the root path 218 | */ 219 | withRoot(root: string) { 220 | return new SlothDatabase(this._model, join(this._root, root)) 221 | } 222 | 223 | /** 224 | * Fetches all documents IDs for this database and return them 225 | * 226 | * @param {PouchFactory} factory the PouchDB factory to use 227 | * @param {string} [startKey=''] the startkey to use 228 | * @param {string} [endKey=path.join(startKey, '\uffff')] the endkey to use 229 | * @returns a promise that resolves into an array of string IDs 230 | * @see PouchDB#allDocs 231 | * @memberof SlothDatabase 232 | */ 233 | findAllIDs( 234 | factory: PouchFactory, 235 | startKey = this._root || '', 236 | endKey = join(startKey, '\uffff') 237 | ) { 238 | const db = factory(this._name) 239 | 240 | return db 241 | .allDocs({ 242 | include_docs: false, 243 | startkey: startKey, 244 | endkey: endKey 245 | }) 246 | .then(({ rows }) => { 247 | return rows.map(({ id }) => id) 248 | }) 249 | } 250 | 251 | /** 252 | * Fetches all documents for this database and map them with the model 253 | * 254 | * @param {PouchFactory} factory the PouchDB factory to use 255 | * @param {string} [startKey=''] the startkey to use 256 | * @param {string} [endKey=path.join(startKey, '\uffff')] the endkey to use 257 | * @returns a promise that resolves into an array of entity instances 258 | * @see PouchDB#allDocs 259 | * @memberof SlothDatabase 260 | */ 261 | findAllDocs( 262 | factory: PouchFactory, 263 | startKey = this._root || '', 264 | endKey = join(startKey, '\uffff') 265 | ) { 266 | const db = factory(this._name) 267 | 268 | return db 269 | .allDocs({ 270 | include_docs: true, 271 | startkey: startKey, 272 | endkey: endKey 273 | }) 274 | .then(({ rows }) => { 275 | return rows.map(({ doc }) => this.create(factory, doc as S)) 276 | }) 277 | } 278 | /** 279 | * Fetch a docuemt from the database 280 | * @param factory the PouchDB factory to use 281 | * @param id the document identifier to fetch 282 | * @return a promise resolving with the entity instance 283 | */ 284 | findById(factory: PouchFactory, id: string): Promise { 285 | return factory(this._name) 286 | .get(id) 287 | .then(res => { 288 | return new this._model(factory, res) 289 | }) 290 | } 291 | 292 | /** 293 | * Create a new model instance 294 | * @param factory The database factory to attach to the model 295 | * @param props the entity properties 296 | * @returns an entity instance 297 | */ 298 | create(factory: PouchFactory, props: Partial) { 299 | return new this._model(factory, props) 300 | } 301 | 302 | /** 303 | * Create a new model instance and save it to database 304 | * @param factory The database factory to attach to the model 305 | * @param props the entity properties 306 | * @returns an entity instance 307 | */ 308 | put(factory: PouchFactory, props: Partial) { 309 | const doc = new this._model(factory, props) 310 | return doc.save().then(() => doc) 311 | } 312 | 313 | /** 314 | * Subscribes a function to PouchDB changes, so that 315 | * the function will be called when changes are made 316 | * 317 | * @param factory the PouchDB factory 318 | * @param sub the subscriber function 319 | * @see [[Subscriber]] 320 | * @see [[ChangeAction]] 321 | */ 322 | subscribe(factory: PouchFactory, sub: Subscriber) { 323 | if (!this.getSubscriberFor(factory)) { 324 | debug('Creating changes ') 325 | const changes = factory(this._name) 326 | .changes({ 327 | since: 'now', 328 | live: true, 329 | include_docs: true 330 | }) 331 | .on('change', ({ deleted, doc, id }) => { 332 | if (deleted || !doc) { 333 | return this.dispatch(factory, { 334 | type: ActionType.REMOVED, 335 | payload: { [this._name]: id }, 336 | meta: {} 337 | }) 338 | } 339 | if (doc._rev.match(/^1-/)) { 340 | return this.dispatch(factory, { 341 | type: ActionType.ADDED, 342 | payload: { [this._name]: doc }, 343 | meta: { revision: doc._rev } 344 | }) 345 | } 346 | return this.dispatch(factory, { 347 | type: ActionType.CHANGED, 348 | payload: { [this._name]: doc }, 349 | meta: { revision: doc._rev } 350 | }) 351 | }) 352 | this._subscribers.push({ factory, sub, changes }) 353 | return 354 | } 355 | const { changes } = this.getSubscriberFor(factory)! 356 | this._subscribers.push({ factory, sub, changes }) 357 | } 358 | 359 | /** 360 | * Unsubscribe a subscriber, so it will not be called anymore 361 | * Possibly cancel PouchDB changes 362 | * 363 | * @param factory The pouchDB factory to unsubscribe from 364 | * @param sub the subscriber to unsubscribe 365 | */ 366 | cancel(factory: PouchFactory, sub: Subscriber) { 367 | const index = this._subscribers.findIndex( 368 | el => el.factory === factory && el.sub === sub 369 | ) 370 | 371 | const [{ changes }] = this._subscribers.splice(index, 1) 372 | 373 | if (!this.getSubscriberFor(factory)) { 374 | changes.cancel() 375 | } 376 | } 377 | 378 | /** 379 | * Creates view documents (if required) 380 | * @param factory 381 | */ 382 | async initSetup(factory: PouchFactory) { 383 | await this.setupViews(factory) 384 | } 385 | 386 | protected getSubscriberFor(factory: PouchFactory) { 387 | return this._subscribers.find(el => el.factory === factory) 388 | } 389 | 390 | protected dispatch(facto: PouchFactory, action: ChangeAction) { 391 | this._subscribers.forEach( 392 | ({ sub, factory }) => facto === factory && sub(action) 393 | ) 394 | } 395 | 396 | private setupViews(factory: PouchFactory): Promise { 397 | const { views } = getProtoData(this._model.prototype) 398 | const db = factory(this._name) 399 | 400 | const promises = views.map(({ name, id, code }) => async () => { 401 | const views = {} 402 | let _rev 403 | 404 | try { 405 | const doc = (await db.get(`_design/${id}`)) as any 406 | 407 | if (doc.views[name] && doc.views[name].map === code) { 408 | // view already exists and is up-to-date 409 | return 410 | } 411 | 412 | Object.assign(views, doc.views) 413 | 414 | _rev = doc._rev 415 | } catch (err) { 416 | // Do nothing 417 | } 418 | 419 | await db.put(Object.assign( 420 | {}, 421 | { 422 | _id: `_design/${id}`, 423 | views: { 424 | ...views, 425 | [name]: { 426 | map: code 427 | } 428 | } 429 | }, 430 | _rev ? { _rev } : {} 431 | ) as any) 432 | }) 433 | 434 | return promises.reduce((acc, fn) => { 435 | return acc.then(() => fn()) 436 | }, Promise.resolve()) 437 | } 438 | } 439 | -------------------------------------------------------------------------------- /src/models/changes.ts: -------------------------------------------------------------------------------- 1 | import Dict from '../helpers/Dict' 2 | 3 | /** 4 | * FSA Compliant changes 5 | */ 6 | 7 | /** 8 | * Action types, describes the type of event 9 | * (added, removed or changed) 10 | */ 11 | export enum ActionType { 12 | ADDED = '@@DOCUMENT_ADDED', 13 | REMOVED = '@@DOCUMENT_REMOVED', 14 | CHANGED = '@@DOCUMENT_CHANGED' 15 | } 16 | 17 | /** 18 | * @typeparam S the entity schema 19 | */ 20 | export type ChangeAction = 21 | | { 22 | type: ActionType.ADDED 23 | payload: Dict 24 | meta: { revision: string } 25 | } 26 | | { 27 | type: ActionType.CHANGED 28 | payload: Dict 29 | meta: { revision: string } 30 | } 31 | | { 32 | type: ActionType.REMOVED 33 | payload: Dict 34 | meta: {} 35 | } 36 | 37 | /** 38 | * A function that listens for changes actions, can be a redux dispatch 39 | */ 40 | export type Subscriber = (action: ChangeAction) => void 41 | -------------------------------------------------------------------------------- /src/models/relationDescriptors.ts: -------------------------------------------------------------------------------- 1 | import SlothDatabase from './SlothDatabase' 2 | 3 | /** 4 | * Describes a manyToOne or oneToOne relation where the entity 5 | * specified here is the parent and the entity owner is the child 6 | */ 7 | export type BelongsToDescriptor = { 8 | /** 9 | * Specify a database factory, a simple function that returns a database 10 | * This is useful for circular dependency 11 | * @type {SlothDatabase} the parent database factory 12 | */ 13 | belongsTo: () => SlothDatabase 14 | /** 15 | * Specify that no parent should have no children, so that whenever deleting 16 | * a child entity the parent is automatically deleted as well if it's an only child 17 | * 18 | * @type {boolean} 19 | */ 20 | cascade?: boolean 21 | } 22 | /** 23 | * Describes a oneToMany relation where the entity 24 | * specified here is the child entity, and the entity owner 25 | * is the parent 26 | */ 27 | export type HasManyDescriptor = { 28 | /** 29 | * Specify a database factory, a simple function that returns a database 30 | * This is useful for circular dependency 31 | * 32 | * @type {() => SlothDatabase} the child database factory 33 | */ 34 | hasMany: () => SlothDatabase 35 | 36 | /** 37 | * Specifies that whenever removing the parent entity, the children 38 | * should get removed as well 39 | * 40 | * Defaults to true, and recommend keeping it this way 41 | * @type {boolean} 42 | */ 43 | cascade?: boolean 44 | } 45 | 46 | /** 47 | * An relation descriptor, either hasMany or belongsTo 48 | * 49 | * @see [[SlothRel]] 50 | */ 51 | export type RelationDescriptor = HasManyDescriptor | BelongsToDescriptor 52 | -------------------------------------------------------------------------------- /src/slothdb.ts: -------------------------------------------------------------------------------- 1 | import SlothEntity from './decorators/SlothEntity' 2 | import SlothField from './decorators/SlothField' 3 | import SlothURI from './decorators/SlothURI' 4 | import SlothRel from './decorators/SlothRel' 5 | import BaseEntity from './models/BaseEntity' 6 | import { Subscriber, ActionType, ChangeAction } from './models/changes' 7 | import { 8 | BelongsToDescriptor, 9 | HasManyDescriptor 10 | } from './models/relationDescriptors' 11 | import PouchFactory from './models/PouchFactory' 12 | import { belongsToMapper } from './utils/relationMappers' 13 | import SlothDatabase from './models/SlothDatabase' 14 | import SlothView from './decorators/SlothView' 15 | import SlothIndex from './decorators/SlothIndex' 16 | 17 | export { 18 | SlothEntity, 19 | SlothURI, 20 | SlothRel, 21 | SlothField, 22 | BaseEntity, 23 | Subscriber, 24 | ActionType, 25 | ChangeAction, 26 | PouchFactory, 27 | BelongsToDescriptor, 28 | HasManyDescriptor, 29 | SlothDatabase, 30 | SlothView, 31 | SlothIndex, 32 | belongsToMapper 33 | } 34 | -------------------------------------------------------------------------------- /src/utils/getProtoData.ts: -------------------------------------------------------------------------------- 1 | import ProtoData from '../models/ProtoData' 2 | 3 | /** 4 | * Extract the ProtoData from a class prototype, 5 | * possibly creating it if needed 6 | * @see [[ProtoData]] 7 | * @param obj the object to extract data from 8 | * @param createIfNotFound create the protoData if it is undefined 9 | * @private 10 | */ 11 | export default function getProtoData( 12 | obj: any, 13 | createIfNotFound: boolean = false 14 | ) { 15 | const wrapped = obj as { __protoData?: ProtoData } 16 | 17 | if (!wrapped.__protoData) { 18 | if (createIfNotFound) { 19 | wrapped.__protoData = { 20 | uris: [], 21 | fields: [], 22 | rels: [], 23 | views: [] 24 | } 25 | } else { 26 | throw new Error(`Object ${wrapped} has no __protoData`) 27 | } 28 | } 29 | 30 | return wrapped.__protoData 31 | } 32 | -------------------------------------------------------------------------------- /src/utils/getSlothData.ts: -------------------------------------------------------------------------------- 1 | import SlothData from '../models/SlothData' 2 | import { inspect } from 'util' 3 | 4 | /** 5 | * Extract sloth data from an entity instance, 6 | * throwing an exception if it can't be found 7 | * @param obj the class instance to extract from 8 | * @typeparam S the entity schema 9 | * @private 10 | */ 11 | export default function getSlothData(obj: object) { 12 | const wrapped = obj as { sloth: SlothData } 13 | 14 | if (!wrapped.sloth) { 15 | throw new Error(`Class ${wrapped} does not extend SlothEntity`) 16 | } 17 | 18 | return wrapped.sloth 19 | } 20 | -------------------------------------------------------------------------------- /src/utils/relationMappers.ts: -------------------------------------------------------------------------------- 1 | import getProtoData from './getProtoData' 2 | import PouchFactory from '../models/PouchFactory' 3 | import BaseEntity from '../models/BaseEntity' 4 | 5 | export function belongsToMapper( 6 | target: any, 7 | keyName: string 8 | ) { 9 | return (factory: PouchFactory): Promise> => { 10 | const { rels } = getProtoData(target) 11 | 12 | const rel = rels.find(({ key }) => key === keyName) 13 | 14 | if (!rel) { 15 | throw new Error(`No relation available for ${keyName}`) 16 | } 17 | 18 | if ('belongsTo' in rel) { 19 | return rel.belongsTo().findById(factory, target[keyName]) 20 | } 21 | 22 | throw new Error('Unsupported relation') 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /test/integration/Album.test.ts: -------------------------------------------------------------------------------- 1 | import PouchDB from 'pouchdb' 2 | import Artist from './Artist' 3 | import Album from './Album' 4 | 5 | import { transpileModule } from 'typescript' 6 | const { compilerOptions } = require('../../tsconfig.json') 7 | 8 | PouchDB.plugin(require('pouchdb-adapter-memory')) 9 | 10 | test('typescript transpiles', () => { 11 | transpileModule('./Album.ts', { compilerOptions }) 12 | }) 13 | 14 | test('generate uri', async () => { 15 | const dbName = Date.now().toString(26) 16 | 17 | const factory = () => new PouchDB(dbName, { adapter: 'memory' }) 18 | 19 | const flatbushZombies = Artist.create(factory, { name: 'Flatbush Zombies' }) 20 | 21 | const betterOffDead = Album.create(factory, { 22 | name: 'BetterOffDead', 23 | artist: flatbushZombies._id 24 | }) 25 | 26 | expect(betterOffDead._id).toBe('library/flatbush-zombies/betteroffdead') 27 | }) 28 | 29 | test('updated uri', async () => { 30 | const dbName = Date.now().toString(26) 31 | 32 | const factory = () => new PouchDB(dbName, { adapter: 'memory' }) 33 | 34 | const flatbushZombies = Artist.create(factory, { name: 'Flatbush Zombies' }) 35 | 36 | const betterOffDead = Album.create(factory, { 37 | name: 'BetterOffDead', 38 | artist: 'library/flatbush' 39 | }) 40 | expect(betterOffDead._id).toBe('library/flatbush/betteroffdead') 41 | 42 | betterOffDead.artist = 'library/flatbush-zombies' 43 | 44 | expect(betterOffDead._id).toBe('library/flatbush-zombies/betteroffdead') 45 | }) 46 | 47 | test('remove parent if last child is removed', async () => { 48 | const dbName = Date.now().toString(26) 49 | const db = new PouchDB(dbName, { adapter: 'memory' }) 50 | 51 | const factory = () => new PouchDB(dbName, { adapter: 'memory' }) 52 | 53 | const flatbushZombies = Artist.create(factory, { name: 'Flatbush Zombies' }) 54 | 55 | await flatbushZombies.save() 56 | 57 | const betterOffDead = Album.create(factory, { 58 | name: 'BetterOffDead', 59 | artist: flatbushZombies._id 60 | }) 61 | 62 | await betterOffDead.save() 63 | 64 | expect(await db.get('library/flatbush-zombies/betteroffdead')).toMatchObject({ 65 | _id: 'library/flatbush-zombies/betteroffdead', 66 | artist: 'library/flatbush-zombies' 67 | }) 68 | 69 | expect(await db.get('library/flatbush-zombies')).toMatchObject({ 70 | _id: 'library/flatbush-zombies', 71 | name: 'Flatbush Zombies' 72 | }) 73 | 74 | await betterOffDead.remove() 75 | await expect(db.get('library/flatbush-zombies')).rejects.toMatchObject({ 76 | message: 'missing' 77 | }) 78 | await expect( 79 | db.get('library/flatbush-zombies/betteroffdead') 80 | ).rejects.toMatchObject({ 81 | message: 'missing' 82 | }) 83 | }) 84 | 85 | test('doesnt remove parent if still has children', async () => { 86 | const dbName = Date.now().toString(26) 87 | const db = new PouchDB(dbName, { adapter: 'memory' }) 88 | 89 | const factory = () => new PouchDB(dbName, { adapter: 'memory' }) 90 | 91 | const flatbushZombies = Artist.create(factory, { name: 'Flatbush Zombies' }) 92 | 93 | await flatbushZombies.save() 94 | 95 | const betterOffDead = Album.create(factory, { 96 | name: 'BetterOffDead', 97 | artist: flatbushZombies._id 98 | }) 99 | 100 | await betterOffDead.save() 101 | 102 | const vacationInHell = Album.create(factory, { 103 | name: 'Vacation In Hell', 104 | artist: flatbushZombies._id 105 | }) 106 | 107 | await vacationInHell.save() 108 | 109 | expect(await db.get('library/flatbush-zombies/betteroffdead')).toMatchObject({ 110 | _id: 'library/flatbush-zombies/betteroffdead', 111 | artist: 'library/flatbush-zombies' 112 | }) 113 | 114 | expect( 115 | await db.get('library/flatbush-zombies/vacation-in-hell') 116 | ).toMatchObject({ 117 | _id: 'library/flatbush-zombies/vacation-in-hell', 118 | artist: 'library/flatbush-zombies' 119 | }) 120 | 121 | expect(await db.get('library/flatbush-zombies')).toMatchObject({ 122 | _id: 'library/flatbush-zombies', 123 | name: 'Flatbush Zombies' 124 | }) 125 | 126 | await betterOffDead.remove() 127 | 128 | expect( 129 | await db.get('library/flatbush-zombies/vacation-in-hell') 130 | ).toMatchObject({ 131 | _id: 'library/flatbush-zombies/vacation-in-hell', 132 | artist: 'library/flatbush-zombies' 133 | }) 134 | 135 | expect(await db.get('library/flatbush-zombies')).toMatchObject({ 136 | _id: 'library/flatbush-zombies', 137 | name: 'Flatbush Zombies' 138 | }) 139 | 140 | await expect( 141 | db.get('library/flatbush-zombies/betteroffdead') 142 | ).rejects.toMatchObject({ 143 | message: 'missing' 144 | }) 145 | }) 146 | 147 | test('rels.artist - maps with artist', async () => { 148 | const dbName = Date.now().toString(26) 149 | const db = new PouchDB(dbName, { adapter: 'memory' }) 150 | 151 | const factory = () => new PouchDB(dbName, { adapter: 'memory' }) 152 | 153 | const flatbushZombies = Artist.create(factory, { name: 'Flatbush Zombies' }) 154 | 155 | await flatbushZombies.save() 156 | 157 | const betterOffDead = Album.create(factory, { 158 | name: 'BetterOffDead', 159 | artist: flatbushZombies._id 160 | }) 161 | 162 | const flatbush = await betterOffDead.rels.artist(factory) 163 | 164 | expect(flatbush._id).toBe('library/flatbush-zombies') 165 | expect(flatbush.name).toBe('Flatbush Zombies') 166 | }) 167 | 168 | test('joinURIParams', () => { 169 | expect( 170 | Album.joinURIParams({ name: 'betteroffdead', artist: 'flatbush-zombies' }) 171 | ).toBe('library/flatbush-zombies/betteroffdead') 172 | }) 173 | -------------------------------------------------------------------------------- /test/integration/Album.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BaseEntity, 3 | SlothDatabase, 4 | SlothEntity, 5 | SlothURI, 6 | SlothField, 7 | SlothRel, 8 | belongsToMapper 9 | } from '../../src/slothdb' 10 | import Artist from './Artist' 11 | import Track from './Track' 12 | 13 | export interface AlbumSchema { 14 | _id: string 15 | name: string 16 | artist: string 17 | } 18 | 19 | @SlothEntity('albums') 20 | class Album extends BaseEntity { 21 | @SlothField() name: string = '' 22 | @SlothRel({ belongsTo: () => Artist, cascade: true }) 23 | artist: string = '' 24 | 25 | @SlothURI('library', 'artist', 'name') 26 | _id: string = '' 27 | 28 | tracks: () => Track 29 | 30 | rels = { 31 | artist: belongsToMapper(this, 'artist') 32 | } 33 | } 34 | 35 | export default new SlothDatabase(Album) 36 | -------------------------------------------------------------------------------- /test/integration/Artist.test.ts: -------------------------------------------------------------------------------- 1 | import Artist from './Artist' 2 | import PouchDB from 'pouchdb' 3 | import Album from './Album' 4 | import Track from './Track' 5 | 6 | PouchDB.plugin(require('pouchdb-adapter-memory')) 7 | 8 | test("find artis's albums", async () => { 9 | const prefix = Date.now().toString(26) + '_' 10 | 11 | const factory = (name: string) => 12 | new PouchDB(prefix + name, { adapter: 'memory' }) 13 | 14 | const artist1 = Artist.create(factory, { name: 'artist 1' }) 15 | await artist1.save() 16 | 17 | const artist2 = Artist.create(factory, { name: 'artist 2' }) 18 | await artist2.save() 19 | 20 | const album1 = Album.create(factory, { 21 | artist: artist1._id, 22 | name: 'album 1' 23 | }) 24 | await album1.save() 25 | 26 | const album2 = Album.create(factory, { 27 | artist: artist1._id, 28 | name: 'album 2' 29 | }) 30 | await album2.save() 31 | 32 | const album3 = Album.create(factory, { 33 | artist: artist2._id, 34 | name: 'album 3' 35 | }) 36 | await album3.save() 37 | 38 | const artist1Albums = await artist1.albums().findAllIDs(factory) 39 | 40 | expect(artist1Albums).toEqual([ 41 | 'library/artist-1/album-1', 42 | 'library/artist-1/album-2' 43 | ]) 44 | }) 45 | 46 | test('removes children', async () => { 47 | const prefix = Date.now().toString(26) + '_' 48 | 49 | const factory = (name: string) => 50 | new PouchDB(prefix + name, { adapter: 'memory' }) 51 | 52 | const artist1 = Artist.create(factory, { name: 'artist 1' }) 53 | await artist1.save() 54 | 55 | const artist2 = Artist.create(factory, { name: 'artist 2' }) 56 | await artist2.save() 57 | 58 | const album1 = Album.create(factory, { 59 | artist: artist1._id, 60 | name: 'album 1' 61 | }) 62 | await album1.save() 63 | 64 | const album2 = Album.create(factory, { 65 | artist: artist1._id, 66 | name: 'album 2' 67 | }) 68 | await album2.save() 69 | 70 | const album3 = Album.create(factory, { 71 | artist: artist2._id, 72 | name: 'album 3' 73 | }) 74 | await album3.save() 75 | 76 | const artist1Albums = await artist1.albums().findAllIDs(factory) 77 | 78 | expect(artist1Albums).toEqual([ 79 | 'library/artist-1/album-1', 80 | 'library/artist-1/album-2' 81 | ]) 82 | 83 | await artist1.remove() 84 | 85 | const albums = await Album.findAllIDs(factory) 86 | 87 | expect(albums).toEqual(['library/artist-2/album-3']) 88 | }) 89 | 90 | test('removes childrens children', async () => { 91 | const prefix = Date.now().toString(26) + '_' 92 | 93 | const factory = (name: string) => 94 | new PouchDB(prefix + name, { adapter: 'memory' }) 95 | 96 | const artist1 = Artist.create(factory, { name: 'artist 1' }) 97 | await artist1.save() 98 | 99 | const artist2 = Artist.create(factory, { name: 'artist 2' }) 100 | await artist2.save() 101 | 102 | const album1 = Album.create(factory, { 103 | artist: artist1._id, 104 | name: 'album 1' 105 | }) 106 | await album1.save() 107 | 108 | const album2 = Album.create(factory, { 109 | artist: artist1._id, 110 | name: 'album 2' 111 | }) 112 | await album2.save() 113 | 114 | const album3 = Album.create(factory, { 115 | artist: artist2._id, 116 | name: 'album 3' 117 | }) 118 | await album3.save() 119 | 120 | const track1 = Track.create(factory, { 121 | name: 'track 1', 122 | album: album1._id, 123 | number: '01', 124 | artist: artist1._id 125 | }) 126 | await track1.save() 127 | 128 | const track2 = Track.create(factory, { 129 | name: 'track 2', 130 | album: album1._id, 131 | number: '02', 132 | artist: artist1._id 133 | }) 134 | await track2.save() 135 | 136 | const track3 = Track.create(factory, { 137 | name: 'track 3', 138 | album: album3._id, 139 | number: '01', 140 | artist: artist2._id 141 | }) 142 | await track3.save() 143 | { 144 | const tracks = await artist1.tracks().findAllIDs(factory) 145 | 146 | expect(tracks).toEqual([ 147 | 'library/artist-1/album-1/01/track-1', 148 | 'library/artist-1/album-1/02/track-2' 149 | ]) 150 | } 151 | 152 | { 153 | const albums = await Album.findAllIDs(factory) 154 | const tracks = await Track.findAllIDs(factory) 155 | 156 | expect(albums).toEqual([ 157 | 'library/artist-1/album-1', 158 | 'library/artist-1/album-2', 159 | 'library/artist-2/album-3' 160 | ]) 161 | expect(tracks).toEqual([ 162 | 'library/artist-1/album-1/01/track-1', 163 | 'library/artist-1/album-1/02/track-2', 164 | 'library/artist-2/album-3/01/track-3' 165 | ]) 166 | } 167 | await artist1.remove() 168 | { 169 | const albums = await Album.findAllIDs(factory) 170 | const tracks = await Track.findAllIDs(factory) 171 | 172 | expect(albums).toEqual(['library/artist-2/album-3']) 173 | expect(tracks).toEqual(['library/artist-2/album-3/01/track-3']) 174 | } 175 | }) 176 | -------------------------------------------------------------------------------- /test/integration/Artist.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BaseEntity, 3 | SlothDatabase, 4 | SlothEntity, 5 | SlothURI, 6 | SlothField, 7 | SlothRel 8 | } from '../../src/slothdb' 9 | 10 | import Track from './Track' 11 | import Album from './Album' 12 | 13 | export interface ArtistSchema { 14 | _id: string 15 | name: string 16 | age: number 17 | } 18 | 19 | @SlothEntity('artists') 20 | class Artist extends BaseEntity { 21 | @SlothField() name: string = '' 22 | 23 | @SlothURI('library', 'name') 24 | _id: string = '' 25 | 26 | @SlothRel({ hasMany: () => Album }) 27 | albums: () => Album 28 | 29 | @SlothRel({ hasMany: () => Track }) 30 | tracks: () => Track 31 | } 32 | 33 | export default new SlothDatabase(Artist) 34 | -------------------------------------------------------------------------------- /test/integration/Author.test.ts: -------------------------------------------------------------------------------- 1 | import AuthorDatabase from './Author' 2 | import localPouchFactory from '../utils/localPouchFactory' 3 | import PouchDB from 'pouchdb' 4 | 5 | PouchDB.plugin(require('pouchdb-adapter-memory')) 6 | 7 | test('creates a new author from props with valid props', () => { 8 | const grr = AuthorDatabase.create(localPouchFactory, { name: 'GRR Martin' }) 9 | expect(grr.name).toBe('GRR Martin') 10 | expect(grr._id).toBe('authors/grr-martin') 11 | }) 12 | 13 | test('exists returns false for a non-existing doc', async () => { 14 | const dbName = Date.now().toString(26) 15 | const factory = () => new PouchDB(dbName, { adapter: 'memory' }) 16 | 17 | const doc = AuthorDatabase.create(factory, { name: 'Foobar' }) 18 | 19 | expect(await doc.exists()).toBe(false) 20 | }) 21 | 22 | test('exists returns true for existing doc', async () => { 23 | const dbName = Date.now().toString(26) 24 | const factory = () => new PouchDB(dbName, { adapter: 'memory' }) 25 | 26 | await AuthorDatabase.put(factory, { name: 'Foobar' }) 27 | 28 | const doc = AuthorDatabase.create(factory, { name: 'Foobar' }) 29 | 30 | expect(await doc.exists()).toBe(true) 31 | }) 32 | 33 | test('find author by id', async () => { 34 | const dbName = Date.now().toString(26) 35 | const props = { _id: 'authors/grr-martin', name: 'GRR Martin' } 36 | 37 | const factory = () => new PouchDB(dbName, { adapter: 'memory' }) 38 | 39 | await factory().put(props) 40 | 41 | const doc = await AuthorDatabase.findById(factory, props._id) 42 | 43 | expect(doc._id).toBe(props._id) 44 | expect(doc.name).toBe(props.name) 45 | }) 46 | 47 | test('isDirty returns true with updated props', () => { 48 | const grr = AuthorDatabase.create(localPouchFactory, { 49 | name: 'GRR Martin', 50 | _id: 'authors/grr-martin' 51 | }) 52 | expect(grr.name).toBe('GRR Martin') 53 | expect(grr._id).toBe('authors/grr-martin') 54 | expect(grr.isDirty()).toBe(false) 55 | 56 | grr.name = 'grr martin' 57 | 58 | expect(grr.isDirty()).toBe(true) 59 | }) 60 | 61 | test('save creates, update and eventually remove old document', async () => { 62 | const dbName = Date.now().toString(26) 63 | const props = { name: 'GRR Martin', age: 69 } 64 | 65 | const factory = () => new PouchDB(dbName, { adapter: 'memory' }) 66 | 67 | const doc = await AuthorDatabase.create(factory, props) 68 | const originalId = 'authors/grr-martin' 69 | 70 | expect(doc._id).toBe(originalId) 71 | 72 | expect(doc.name).toBe(props.name) 73 | 74 | { 75 | const { _rev } = await doc.save() 76 | expect(_rev).toMatch(/^1-/) 77 | } 78 | 79 | { 80 | const { _rev } = await doc.save() 81 | expect(_rev).toBeUndefined() 82 | } 83 | 84 | { 85 | doc.age = 70 86 | const { _rev, age } = await doc.save() 87 | expect(_rev).toMatch(/^2-/) 88 | } 89 | 90 | { 91 | doc.name = 'George RR Martin' 92 | const newId = 'authors/george-rr-martin' 93 | expect(doc._id) 94 | 95 | const { _rev, _id } = await doc.save() 96 | 97 | expect(_rev).toMatch(/^1-/) 98 | expect(_id).toBe(newId) 99 | 100 | await expect(factory().get(originalId)).rejects.toMatchObject({ 101 | name: 'not_found' 102 | }) 103 | 104 | const newDoc = await factory().get(newId) 105 | 106 | expect(newDoc).toMatchObject({ 107 | name: 'George RR Martin', 108 | age: 70, 109 | _id: 'authors/george-rr-martin' 110 | }) 111 | } 112 | }) 113 | 114 | test('save creates, update and eventually remove old document', async () => { 115 | const dbName = Date.now().toString(26) 116 | const props = { name: 'GRR Martin', age: 69 } 117 | 118 | const factory = () => new PouchDB(dbName, { adapter: 'memory' }) 119 | 120 | const doc = await AuthorDatabase.create(factory, props) 121 | const originalId = 'authors/grr-martin' 122 | 123 | expect(doc._id).toBe(originalId) 124 | 125 | expect(doc.name).toBe(props.name) 126 | 127 | { 128 | const { _rev } = await doc.save() 129 | expect(_rev).toMatch(/^1-/) 130 | } 131 | 132 | { 133 | const { _rev } = await doc.save() 134 | expect(_rev).toBeUndefined() 135 | } 136 | 137 | { 138 | doc.age = 70 139 | const { _rev, age } = await doc.save() 140 | expect(_rev).toMatch(/^2-/) 141 | } 142 | 143 | { 144 | doc.name = 'George RR Martin' 145 | const newId = 'authors/george-rr-martin' 146 | expect(doc._id) 147 | 148 | const { _rev, _id } = await doc.save() 149 | 150 | expect(_rev).toMatch(/^1-/) 151 | expect(_id).toBe(newId) 152 | 153 | await expect(factory().get(originalId)).rejects.toMatchObject({ 154 | name: 'not_found' 155 | }) 156 | 157 | const newDoc = await factory().get(newId) 158 | 159 | expect(newDoc).toMatchObject({ 160 | name: 'George RR Martin', 161 | age: 70, 162 | _id: 'authors/george-rr-martin' 163 | }) 164 | } 165 | }) 166 | -------------------------------------------------------------------------------- /test/integration/Author.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BaseEntity, 3 | SlothDatabase, 4 | SlothEntity, 5 | SlothURI, 6 | SlothField, 7 | SlothRel 8 | } from '../../src/slothdb' 9 | 10 | export interface AuthorSchema { 11 | _id: string 12 | name: string 13 | age: number 14 | } 15 | 16 | @SlothEntity('authors') 17 | class Author extends BaseEntity { 18 | @SlothField() name: string = '' 19 | 20 | @SlothURI('authors', 'name') 21 | _id: string = '' 22 | 23 | @SlothField() age = 40 24 | } 25 | 26 | export default new SlothDatabase(Author) 27 | -------------------------------------------------------------------------------- /test/integration/Track.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BaseEntity, 3 | SlothDatabase, 4 | SlothEntity, 5 | SlothIndex, 6 | SlothURI, 7 | SlothField, 8 | SlothRel, 9 | SlothView, 10 | belongsToMapper 11 | } from '../../src/slothdb' 12 | import Artist from './Artist' 13 | import Album from './Album' 14 | 15 | export interface TrackSchema { 16 | _id: string 17 | name: string 18 | number: string 19 | artist: string 20 | album: string 21 | } 22 | 23 | export enum TrackViews { 24 | ByArtist = 'by_artist', 25 | ByAlbum = 'views/by_album' 26 | } 27 | 28 | const artist = belongsToMapper(() => Artist, 'album') 29 | const album = belongsToMapper(() => Album, 'artist') 30 | 31 | @SlothEntity('tracks') 32 | export class TrackEntity extends BaseEntity { 33 | @SlothURI('library', 'album', 'number', 'name') 34 | _id: string = '' 35 | 36 | @SlothIndex() 37 | @SlothField() 38 | name: string = 'Track Name' 39 | 40 | @SlothField() number: string = '00' 41 | 42 | @SlothRel({ belongsTo: () => Artist }) 43 | artist: string = '' 44 | 45 | @SlothView((doc: TrackSchema, emit) => emit(doc.album)) 46 | @SlothRel({ belongsTo: () => Album }) 47 | album: string = '' 48 | 49 | rels: { 50 | artist 51 | album 52 | } 53 | } 54 | 55 | export default new SlothDatabase( 56 | TrackEntity 57 | ) 58 | -------------------------------------------------------------------------------- /test/integration/changes.test.ts: -------------------------------------------------------------------------------- 1 | import Artist from './Artist' 2 | import PouchDB from 'pouchdb' 3 | import delay from '../utils/delay' 4 | 5 | PouchDB.plugin(require('pouchdb-adapter-memory')) 6 | 7 | describe('changes#subscribe', () => { 8 | test('fire one sub on document added', async () => { 9 | const prefix = Date.now().toString(26) + '_' 10 | 11 | const factory = (name: string) => 12 | new PouchDB(prefix + name, { adapter: 'memory' }) 13 | 14 | const subscriber = jest.fn() 15 | 16 | Artist.subscribe(factory, subscriber) 17 | 18 | await Artist.create(factory, { name: 'foo' }).save() 19 | 20 | let i = 0 21 | 22 | do { 23 | await delay(250) 24 | } while (i++ < 120 && subscriber.mock.calls.length === 0) 25 | 26 | const { calls } = subscriber.mock 27 | 28 | const [call1, ...never] = calls 29 | 30 | expect(never).toHaveLength(0) 31 | 32 | expect(call1[0].payload).toMatchObject({ 33 | artists: { _id: 'library/foo', name: 'foo' } 34 | }) 35 | }) 36 | 37 | test('fire one sub on document removed', async () => { 38 | const prefix = Date.now().toString(26) + '_' 39 | 40 | const factory = (name: string) => 41 | new PouchDB(prefix + name, { adapter: 'memory' }) 42 | 43 | const subscriber = jest.fn() 44 | 45 | const foo = Artist.create(factory, { name: 'foo' }) 46 | await foo.save() 47 | 48 | Artist.subscribe(factory, subscriber) 49 | 50 | await foo.remove() 51 | 52 | const { calls } = subscriber.mock 53 | 54 | const [call1, ...never] = calls 55 | 56 | expect(never).toHaveLength(0) 57 | 58 | expect(call1[0].payload).toMatchObject({ 59 | artists: 'library/foo' 60 | }) 61 | }) 62 | 63 | test('fire one sub on document changed', async () => { 64 | const prefix = Date.now().toString(26) + '_' 65 | 66 | const factory = (name: string) => 67 | new PouchDB(prefix + name, { adapter: 'memory' }) 68 | 69 | const subscriber = jest.fn() 70 | 71 | const foo = Artist.create(factory, { name: 'foo' }) 72 | const doc = await foo.save() 73 | 74 | Artist.subscribe(factory, subscriber) 75 | 76 | await factory('artists').put(Object.assign({}, doc, { foo: 'bar' })) 77 | await delay(10) 78 | 79 | const { calls } = subscriber.mock 80 | 81 | const [call1, ...never] = calls 82 | 83 | expect(never).toHaveLength(0) 84 | 85 | expect(call1[0].payload).toMatchObject({ 86 | artists: { foo: 'bar', _id: 'library/foo' } 87 | }) 88 | }) 89 | }) 90 | -------------------------------------------------------------------------------- /test/integration/docKeys.test.ts: -------------------------------------------------------------------------------- 1 | import PouchDB from 'pouchdb' 2 | import { 3 | SlothDatabase, 4 | SlothURI, 5 | BaseEntity, 6 | SlothEntity, 7 | SlothField 8 | } from '../../src/slothdb' 9 | 10 | PouchDB.plugin(require('pouchdb-adapter-memory')) 11 | 12 | describe('docKeys', () => { 13 | let prefix: string 14 | 15 | const factory = (pre: string) => (name: string) => 16 | new PouchDB(pre + name, { adapter: 'memory' }) 17 | 18 | interface IFoo { 19 | _id: string 20 | name: string 21 | bar: string 22 | } 23 | 24 | @SlothEntity('foos') 25 | class FooEntity extends BaseEntity { 26 | @SlothURI('foos', 'name', 'bar') 27 | _id: string 28 | @SlothField() name: string 29 | @SlothField('barz') bar: string 30 | } 31 | 32 | const Foo = new SlothDatabase(FooEntity) 33 | 34 | beforeEach(() => { 35 | prefix = '__' + Date.now().toString(16) + '_' 36 | }) 37 | 38 | test('getProps maps props', () => { 39 | const doc = Foo.create(factory(prefix), { name: 'Foo Bar', bar: 'barz' }) 40 | 41 | expect(doc.getProps()).toEqual({ 42 | _id: 'foos/foo-bar/barz', 43 | name: 'Foo Bar', 44 | bar: 'barz' 45 | }) 46 | }) 47 | 48 | test('getProps maps props from doc', () => { 49 | const doc = Foo.create(factory(prefix), { 50 | name: 'Foo Bar', 51 | barz: 'barz' 52 | } as any) 53 | 54 | expect(doc.getProps()).toEqual({ 55 | _id: 'foos/foo-bar/barz', 56 | name: 'Foo Bar', 57 | bar: 'barz' 58 | }) 59 | }) 60 | 61 | test('getDocument maps document', () => { 62 | const doc = Foo.create(factory(prefix), { name: 'Foo Bar', bar: 'barz' }) 63 | 64 | expect(doc.getDocument()).toEqual({ 65 | _id: 'foos/foo-bar/barz', 66 | name: 'Foo Bar', 67 | barz: 'barz' 68 | }) 69 | }) 70 | 71 | test('getDocument maps document from doc', () => { 72 | const doc = Foo.create(factory(prefix), { 73 | name: 'Foo Bar', 74 | barz: 'barz' 75 | } as any) 76 | 77 | expect(doc.getDocument()).toEqual({ 78 | _id: 'foos/foo-bar/barz', 79 | name: 'Foo Bar', 80 | barz: 'barz' 81 | }) 82 | }) 83 | }) 84 | -------------------------------------------------------------------------------- /test/integration/object.test.ts: -------------------------------------------------------------------------------- 1 | import Artist from './Artist' 2 | import Track, { TrackViews } from './Track' 3 | import PouchDB from 'pouchdb' 4 | import delay from '../utils/delay' 5 | import { 6 | SlothDatabase, 7 | SlothURI, 8 | BaseEntity, 9 | SlothEntity, 10 | SlothField 11 | } from '../../src/slothdb' 12 | 13 | PouchDB.plugin(require('pouchdb-adapter-memory')) 14 | 15 | describe('nested objects', () => { 16 | const prefix = Date.now().toString(26) + '_' 17 | 18 | const factory = (name: string) => 19 | new PouchDB(prefix + name, { adapter: 'memory' }) 20 | 21 | interface IFoo { 22 | _id: string 23 | name: string 24 | foo: { 25 | bar: string 26 | barz: string 27 | } 28 | } 29 | 30 | @SlothEntity('foos') 31 | class FooEntity extends BaseEntity { 32 | @SlothURI('foos', 'name') 33 | _id: string 34 | @SlothField() name: string 35 | @SlothField() 36 | foo: { 37 | bar: string 38 | barz: string 39 | } 40 | } 41 | 42 | const Foo = new SlothDatabase(FooEntity) 43 | 44 | test('put a document with a nested doc', async () => { 45 | const foo = await Foo.put(factory, { 46 | name: 'Foobar', 47 | foo: { 48 | bar: 'foobar', 49 | barz: 'foobarbarz' 50 | } 51 | }) 52 | 53 | expect(await factory('foos').get('foos/foobar')).toMatchObject({ 54 | name: 'Foobar', 55 | foo: { 56 | bar: 'foobar', 57 | barz: 'foobarbarz' 58 | } 59 | }) 60 | }) 61 | 62 | test('get a document with a nested doc', async () => { 63 | const { foo } = await Foo.findById(factory, 'foos/foobar') 64 | expect(foo).toEqual({ 65 | bar: 'foobar', 66 | barz: 'foobarbarz' 67 | }) 68 | }) 69 | 70 | test('update a document with a nested doc', async () => { 71 | const foo = await Foo.findById(factory, 'foos/foobar') 72 | 73 | foo.foo = { bar: 'bar', barz: 'barz' } 74 | 75 | expect(foo.foo).toEqual({ 76 | bar: 'bar', 77 | barz: 'barz' 78 | }) 79 | 80 | await foo.save() 81 | 82 | expect(await factory('foos').get('foos/foobar')).toMatchObject({ 83 | name: 'Foobar', 84 | foo: { 85 | bar: 'bar', 86 | barz: 'barz' 87 | } 88 | }) 89 | }) 90 | }) 91 | -------------------------------------------------------------------------------- /test/integration/views.test.ts: -------------------------------------------------------------------------------- 1 | import Artist from './Artist' 2 | import Track, { TrackViews } from './Track' 3 | import PouchDB from 'pouchdb' 4 | import delay from '../utils/delay' 5 | import { 6 | SlothEntity, 7 | BaseEntity, 8 | SlothURI, 9 | SlothField, 10 | SlothDatabase, 11 | SlothIndex 12 | } from '../../src/slothdb' 13 | 14 | PouchDB.plugin(require('pouchdb-adapter-memory')) 15 | 16 | describe('views', () => { 17 | const prefix = Date.now().toString(26) + '_' 18 | 19 | const factory = (name: string) => 20 | new PouchDB(prefix + name, { adapter: 'memory' }) 21 | 22 | beforeAll(async () => { 23 | await Track.put(factory, { 24 | name: 'Palm Trees', 25 | artist: 'library/flatbush-zombies', 26 | album: 'library/flatbush-zombies/betteroffdead', 27 | number: '12' 28 | }) 29 | await Track.put(factory, { 30 | name: 'Not Palm Trees', 31 | artist: 'library/not-flatbush-zombies', 32 | album: 'library/flatbush-zombies/betteroffdead-2', 33 | number: '12' 34 | }) 35 | await Track.put(factory, { 36 | name: 'Mocking Bird', 37 | artist: 'library/eminem', 38 | album: 'library/eminem/some-album-i-forgot', 39 | number: '12' 40 | }) 41 | }) 42 | 43 | test('create views', async () => { 44 | await Track.initSetup(factory) 45 | expect(await factory('tracks').get('_design/views')).toMatchObject({ 46 | views: { by_album: {} } 47 | }) 48 | }) 49 | 50 | test('doesnt recreate views', async () => { 51 | const tracks = factory('tracks') 52 | const { _rev } = await tracks.get('_design/views') 53 | await Track.initSetup(factory) 54 | expect(await tracks.get('_design/views')).toMatchObject({ 55 | views: { by_album: {} }, 56 | _rev 57 | }) 58 | }) 59 | 60 | test('creates views on a new database when querying docs', async () => { 61 | const prefix = Date.now().toString(26) + '_' 62 | 63 | const factory = (name: string) => 64 | new PouchDB(prefix + name, { adapter: 'memory' }) 65 | 66 | const docs = await Track.queryDocs(factory, TrackViews.ByAlbum) 67 | expect(docs).toHaveLength(0) 68 | }) 69 | 70 | test('creates views on a new database when querying keys', async () => { 71 | const prefix = Date.now().toString(26) + '_' 72 | 73 | const factory = (name: string) => 74 | new PouchDB(prefix + name, { adapter: 'memory' }) 75 | 76 | const docs = await Track.queryKeys(factory, TrackViews.ByAlbum) 77 | expect(docs).toHaveLength(0) 78 | }) 79 | 80 | test('creates views on a new database when querying keys ids', async () => { 81 | const prefix = Date.now().toString(26) + '_' 82 | 83 | const factory = (name: string) => 84 | new PouchDB(prefix + name, { adapter: 'memory' }) 85 | 86 | const docs = await Track.queryKeysIDs(factory, TrackViews.ByAlbum) 87 | expect(docs).toEqual({}) 88 | }) 89 | 90 | test('query by view', async () => { 91 | const docs = await Track.queryDocs( 92 | factory, 93 | TrackViews.ByAlbum, 94 | 'library/flatbush-zombies' 95 | ) 96 | 97 | expect(docs).toHaveLength(2) 98 | }) 99 | test('queryKeys', async () => { 100 | const docs = await Track.queryKeys( 101 | factory, 102 | TrackViews.ByAlbum, 103 | 'library/flatbush-zombies' 104 | ) 105 | 106 | expect(docs.length).toBe(2) 107 | expect(docs).toEqual([ 108 | 'library/flatbush-zombies/betteroffdead', 109 | 'library/flatbush-zombies/betteroffdead-2' 110 | ]) 111 | }) 112 | test('queryKeysIDs', async () => { 113 | const docs = await Track.queryKeysIDs( 114 | factory, 115 | TrackViews.ByAlbum, 116 | 'library/flatbush-zombies' 117 | ) 118 | expect(docs).toEqual({ 119 | 'library/flatbush-zombies/betteroffdead': 120 | 'library/flatbush-zombies/betteroffdead/12/palm-trees', 121 | 'library/flatbush-zombies/betteroffdead-2': 122 | 'library/flatbush-zombies/betteroffdead-2/12/not-palm-trees' 123 | }) 124 | }) 125 | }) 126 | 127 | describe('views and docKeys', () => { 128 | const prefix = Date.now().toString(26) + '_' 129 | 130 | const factory = (name: string) => 131 | new PouchDB(prefix + name, { adapter: 'memory' }) 132 | 133 | @SlothEntity('foos') 134 | class FooEnt extends BaseEntity { 135 | @SlothURI('foos', 'name') 136 | _id = '' 137 | 138 | @SlothIndex() 139 | @SlothField('not_name') 140 | name = '' 141 | } 142 | 143 | const Foo = new SlothDatabase(FooEnt) 144 | 145 | test('uses docKeys', async () => { 146 | await Foo.put(factory, { name: 'foo' }) 147 | await Foo.put(factory, { name: 'foobar' }) 148 | await Foo.put(factory, { name: 'bar' }) 149 | 150 | const keys = await Foo.queryKeys( 151 | factory, 152 | 'views/by_not_name', 153 | 'foo', 154 | 'foo\uffff' 155 | ) 156 | 157 | expect(keys).toEqual(['foo', 'foobar']) 158 | }) 159 | }) 160 | -------------------------------------------------------------------------------- /test/unit/decorators/SlothField.test.ts: -------------------------------------------------------------------------------- 1 | import SlothField from '../../../src/decorators/SlothField' 2 | import emptyProtoData from '../../utils/emptyProtoData' 3 | 4 | test('SlothField - fails on a defined property using get', () => { 5 | const object = {} 6 | 7 | Reflect.defineProperty(object, 'foo', { 8 | get: () => { 9 | return 'bar' 10 | } 11 | }) 12 | 13 | expect(() => SlothField()(object, 'foo')).toThrowError(/Cannot apply/) 14 | }) 15 | 16 | test('SlothField - fails on a defined property using set', () => { 17 | const object = {} 18 | 19 | Reflect.defineProperty(object, 'foo', { 20 | set: () => { 21 | return 'bar' 22 | } 23 | }) 24 | 25 | expect(() => SlothField()(object, 'foo')).toThrowError(/Cannot apply/) 26 | }) 27 | 28 | test('SlothField - uses updatedProps first', () => { 29 | const object = { 30 | foobar: '', 31 | sloth: { updatedProps: { foobar: 'barz' } } 32 | } 33 | 34 | SlothField()(object, 'foobar') 35 | 36 | expect(object.foobar).toBe('barz') 37 | 38 | const object2 = { 39 | foobar: '', 40 | sloth: { updatedProps: { foobar: undefined }, props: { foobar: 'barbarz' } } 41 | } 42 | 43 | SlothField()(object2, 'foobar') 44 | 45 | expect(object2.foobar).toBeUndefined() 46 | }) 47 | 48 | test('SlothField - uses props if not updated', () => { 49 | const object = { 50 | foobar: '', 51 | sloth: { updatedProps: {}, props: { foobar: 'barbarz' } } 52 | } 53 | 54 | SlothField()(object, 'foobar') 55 | 56 | expect(object.foobar).toBe('barbarz') 57 | 58 | const object2 = { 59 | foobar: 'bar', 60 | sloth: { updatedProps: {}, props: { foobar: undefined } } 61 | } 62 | 63 | SlothField()(object2, 'foobar') 64 | 65 | expect(object2.foobar).toBeUndefined() 66 | }) 67 | 68 | test('SlothField - uses default value', () => { 69 | const object: any = { 70 | foobar: '', 71 | sloth: { 72 | defaultProps: {}, 73 | updatedProps: {} 74 | }, 75 | __protoData: emptyProtoData({ 76 | fields: [{ key: 'foobar', docKey: 'foobar' }] 77 | }), 78 | props: null 79 | } 80 | 81 | SlothField()(object, 'foobar') 82 | object.foobar = 'default' 83 | 84 | expect(object.foobar).toBe('default') 85 | 86 | object.sloth.props = {} 87 | object.foobar = 'foobar' 88 | 89 | expect(object.foobar).toBe('foobar') 90 | object.foobar = null 91 | 92 | expect(object.foobar).toBe('default') 93 | }) 94 | 95 | test('SlothField - update updatedProps', () => { 96 | const object = { 97 | foobar: '', 98 | sloth: { updatedProps: {}, props: {}, defaultProps: {} } 99 | } 100 | 101 | SlothField()(object, 'foobar') 102 | object.foobar = 'ouch' 103 | 104 | expect((object as any).sloth.updatedProps.foobar).toBe('ouch') 105 | expect((object as any).sloth.props.foobar).toBeUndefined() 106 | expect(object.foobar).toBe('ouch') 107 | }) 108 | -------------------------------------------------------------------------------- /test/unit/decorators/SlothRel.test.ts: -------------------------------------------------------------------------------- 1 | import SlothRel from '../../../src/decorators/SlothRel' 2 | 3 | test('SlothRel - fails on top of another decorator', () => { 4 | const obj = {} 5 | 6 | Reflect.defineProperty(obj, 'foo', { get: () => 'bar' }) 7 | Reflect.defineProperty(obj, 'bar', { set: () => 'bar' }) 8 | Reflect.defineProperty(obj, 'barz', { value: 42 }) 9 | 10 | expect(() => SlothRel({} as any)(obj, 'foo')).toThrowError( 11 | /Cannot apply SlothRel/ 12 | ) 13 | expect(() => SlothRel({} as any)(obj, 'bar')).toThrowError( 14 | /Cannot apply SlothRel/ 15 | ) 16 | 17 | SlothRel({} as any)(obj, 'barz') 18 | }) 19 | -------------------------------------------------------------------------------- /test/unit/decorators/SlothURI.test.ts: -------------------------------------------------------------------------------- 1 | import SlothURI from '../../../src/decorators/SlothURI' 2 | import emptyProtoData from '../../utils/emptyProtoData' 3 | const slug = require('limax') 4 | 5 | test('SlothURI - returns correct url', () => { 6 | const object = { 7 | _id: '', 8 | valueInProp: 'notfoobar', 9 | updatedValue: 'notbarbarz', 10 | sloth: { slug } 11 | } 12 | 13 | SlothURI<{ 14 | valueInProp: string 15 | updatedValue: string 16 | }>('objects', 'valueInProp', 'updatedValue')(object, '_id') 17 | 18 | expect(object._id).toBe('objects/notfoobar/notbarbarz') 19 | }) 20 | 21 | test('SlothURI - pushes to uris', () => { 22 | const object = { 23 | _id: '', 24 | sloth: { 25 | props: {}, 26 | updatedProps: {} 27 | } 28 | } 29 | 30 | // tslint:disable-next-line:variable-name 31 | const __protoData = { 32 | uris: [], 33 | fields: [] 34 | } 35 | 36 | SlothURI<{ 37 | foo: string 38 | bar: string 39 | }>('objects', 'foo', 'bar')({ __protoData }, '_id') 40 | 41 | expect(__protoData.uris).toEqual([ 42 | { 43 | name: '_id', 44 | prefix: 'objects', 45 | propsKeys: ['foo', 'bar'] 46 | } 47 | ]) 48 | 49 | expect(__protoData.fields).toEqual([{ key: '_id', docKey: '_id' }]) 50 | }) 51 | 52 | test('SlothURI - throws if on top of another decorator', () => { 53 | const object = {} 54 | 55 | Reflect.defineProperty(object, '_id', { get: () => 'foo' }) 56 | Reflect.defineProperty(object, 'bar', { set: () => null }) 57 | 58 | expect(() => 59 | SlothURI<{ 60 | foo: string 61 | bar: string 62 | }>('objects', 'foo', 'bar')(object, '_id') 63 | ).toThrowError(/Cannot apply/) 64 | 65 | expect(() => 66 | SlothURI<{ 67 | foo: string 68 | bar: string 69 | }>('objects', 'foo', 'bar')(object, 'bar') 70 | ).toThrowError(/Cannot apply/) 71 | }) 72 | 73 | test('SlothURI - throw when updating', () => { 74 | const object = { 75 | _id: '', 76 | sloth: { 77 | props: {}, 78 | updatedProps: {} 79 | } 80 | } 81 | 82 | const desc = { 83 | uris: [] 84 | } 85 | 86 | SlothURI<{ 87 | foo: string 88 | bar: string 89 | }>('objects', 'foo', 'bar')(object, '_id') 90 | 91 | object._id = '' 92 | 93 | expect(() => (object._id = 'bar')).toThrow(/_id is not writable/) 94 | }) 95 | 96 | test('SlothURI - uses relations', () => { 97 | const object = { 98 | _id: '', 99 | foo: 'foos/foo/bar', 100 | bar: 'foobar', 101 | sloth: { 102 | props: {}, 103 | updatedProps: {}, 104 | slug: str => str 105 | }, 106 | __protoData: emptyProtoData({ 107 | rels: [ 108 | { 109 | belongsTo: {} as any, 110 | cascade: true, 111 | key: 'foo' 112 | } 113 | ] 114 | }) 115 | } 116 | 117 | SlothURI<{ 118 | foo: string 119 | bar: string 120 | }>('objects', 'foo', 'bar')(object, '_id') 121 | 122 | expect(object._id).toBe('objects/foo/bar/foobar') 123 | }) 124 | 125 | test('SlothURI - throws error if no value', () => { 126 | const object = { 127 | _id: '', 128 | bar: 'foobar', 129 | sloth: { 130 | props: {}, 131 | updatedProps: {}, 132 | slug: str => str 133 | }, 134 | __protoData: emptyProtoData({ 135 | rels: [ 136 | { 137 | belongsTo: {} as any, 138 | cascade: true, 139 | key: 'foo' 140 | } 141 | ] 142 | }) 143 | } 144 | 145 | SlothURI<{ 146 | foo: string 147 | bar: string 148 | }>('objects', 'foo', 'bar')(object, '_id') 149 | 150 | expect(() => object._id).toThrowError(/Key foo has no value/) 151 | }) 152 | 153 | test('SlothURI - throws error if URI is invalid', () => { 154 | const object = { 155 | _id: '', 156 | foo: 'foo', 157 | bar: 'foobar', 158 | sloth: { 159 | props: {}, 160 | updatedProps: {}, 161 | slug: str => str 162 | }, 163 | __protoData: emptyProtoData({ 164 | rels: [ 165 | { 166 | belongsTo: {} as any, 167 | cascade: true, 168 | key: 'foo' 169 | } 170 | ] 171 | }) 172 | } 173 | 174 | SlothURI<{ 175 | foo: string 176 | bar: string 177 | }>('objects', 'foo', 'bar')(object, '_id') 178 | 179 | expect(() => object._id).toThrowError(/URI 'foo' is invalid/) 180 | }) 181 | -------------------------------------------------------------------------------- /test/unit/decorators/SlothView.test.ts: -------------------------------------------------------------------------------- 1 | import { SlothView } from '../../../src/slothdb' 2 | import emptyProtoData from '../../utils/emptyProtoData' 3 | 4 | test('SlothView - fails without a decorator', () => { 5 | const obj = { foo: 'bar' } 6 | expect(() => SlothView(() => ({}))(obj, 'foo')).toThrowError( 7 | /Required SlothView/ 8 | ) 9 | }) 10 | test('SlothView - generates a working function for es5 view', () => { 11 | const proto = emptyProtoData({}) 12 | const obj = { __protoData: proto } 13 | 14 | Reflect.defineProperty(obj, 'foo', { get: () => 42 }) 15 | 16 | expect(() => 17 | SlothView(function(doc: { bar: string }, emit) { 18 | emit(doc.bar) 19 | })(obj, 'foo') 20 | ).toThrowError(/Required SlothView/) 21 | }) 22 | 23 | test('SlothView - generates a working function for es5 view', () => { 24 | const proto = emptyProtoData({ fields: [{ key: 'foo', docKey: 'foo' }] }) 25 | const obj = { __protoData: proto } 26 | 27 | Reflect.defineProperty(obj, 'foo', { get: () => 42 }) 28 | 29 | SlothView(function(doc: { bar: string }, emit) { 30 | emit(doc.bar) 31 | })(obj, 'foo') 32 | 33 | expect(proto.views).toHaveLength(1) 34 | 35 | const { views } = proto 36 | const [{ id, name, code }] = views 37 | 38 | expect(name).toBe('by_foo') 39 | 40 | let fun: Function 41 | 42 | const emit = jest.fn() 43 | 44 | // tslint:disable-next-line:no-eval 45 | eval('fun = ' + code) 46 | fun({ bar: 'barz' }) 47 | 48 | expect(emit).toHaveBeenCalledWith('barz') 49 | }) 50 | -------------------------------------------------------------------------------- /test/unit/utils/relationMappers.test.ts: -------------------------------------------------------------------------------- 1 | import { belongsToMapper } from '../../../src/utils/relationMappers' 2 | import emptyProtoData from '../../utils/emptyProtoData' 3 | 4 | describe('belongsToMapper', () => { 5 | test('Throws error if no relation available', () => { 6 | const mapper = belongsToMapper({ __protoData: emptyProtoData({}) }, 'foo') 7 | 8 | expect(() => mapper(null)).toThrowError(/No relation available/) 9 | }) 10 | 11 | test('Throws an error for unsupported relation', () => { 12 | const mapper = belongsToMapper( 13 | { 14 | __protoData: emptyProtoData({ 15 | rels: [ 16 | { 17 | unsupported: 'foo', 18 | key: 'foo' 19 | } as any 20 | ] 21 | }) 22 | }, 23 | 'foo' 24 | ) 25 | 26 | expect(() => mapper(null)).toThrowError(/Unsupported/) 27 | }) 28 | }) 29 | -------------------------------------------------------------------------------- /test/utils/assignProto.ts: -------------------------------------------------------------------------------- 1 | export default function(target: any, ...objects: any[]) { 2 | const res = Object.assign({}, target, ...objects) 3 | 4 | for (const obj in objects) { 5 | for (const name in Object.getOwnPropertyNames(obj)) { 6 | const desc = Object.getOwnPropertyDescriptor(obj, name) 7 | if (!desc) { 8 | return 9 | } 10 | res[name] = desc.value 11 | } 12 | } 13 | 14 | return res 15 | } 16 | -------------------------------------------------------------------------------- /test/utils/delay.ts: -------------------------------------------------------------------------------- 1 | export default function delay(duration) { 2 | return new Promise(function(resolve, reject) { 3 | setTimeout(function() { 4 | resolve() 5 | }, duration) 6 | }) 7 | } 8 | -------------------------------------------------------------------------------- /test/utils/emptyProtoData.ts: -------------------------------------------------------------------------------- 1 | import ProtoData from '../../src/models/ProtoData' 2 | 3 | export default function emptyProtoData(proto: Partial) { 4 | const base: ProtoData = { 5 | uris: [], 6 | fields: [], 7 | rels: [], 8 | views: [] 9 | } 10 | return Object.assign({}, base, proto) 11 | } 12 | -------------------------------------------------------------------------------- /test/utils/localPouchFactory.ts: -------------------------------------------------------------------------------- 1 | import PouchDB from 'pouchdb' 2 | 3 | PouchDB.plugin(require('pouchdb-adapter-memory')) 4 | 5 | export default function localPouchFactory(name: string) { 6 | return new PouchDB(name, { 7 | adapter: 'memory' 8 | }) 9 | } 10 | -------------------------------------------------------------------------------- /tools/gh-pages-publish.ts: -------------------------------------------------------------------------------- 1 | const { cd, exec, echo, touch } = require("shelljs") 2 | const { readFileSync } = require("fs") 3 | const url = require("url") 4 | 5 | let repoUrl 6 | let pkg = JSON.parse(readFileSync("package.json") as any) 7 | if (typeof pkg.repository === "object") { 8 | if (!pkg.repository.hasOwnProperty("url")) { 9 | throw new Error("URL does not exist in repository section") 10 | } 11 | repoUrl = pkg.repository.url 12 | } else { 13 | repoUrl = pkg.repository 14 | } 15 | 16 | let parsedUrl = url.parse(repoUrl) 17 | let repository = (parsedUrl.host || "") + (parsedUrl.path || "") 18 | let ghToken = process.env.GH_TOKEN 19 | 20 | echo("Deploying docs!!!") 21 | cd("docs") 22 | touch(".nojekyll") 23 | exec("git init") 24 | exec("git add .") 25 | exec('git config user.name "vinz243"') 26 | exec('git config user.email "vinz243@gmail.com"') 27 | exec('git commit -m "docs(docs): update gh-pages"') 28 | exec( 29 | `git push --force --quiet "https://${ghToken}@${repository}" master:gh-pages` 30 | ) 31 | echo("Docs deployed!!") 32 | -------------------------------------------------------------------------------- /tools/semantic-release-prepare.ts: -------------------------------------------------------------------------------- 1 | const path = require("path") 2 | const { fork } = require("child_process") 3 | const colors = require("colors") 4 | 5 | const { readFileSync, writeFileSync } = require("fs") 6 | const pkg = JSON.parse( 7 | readFileSync(path.resolve(__dirname, "..", "package.json")) 8 | ) 9 | 10 | pkg.scripts.prepush = "npm run test:prod && npm run build" 11 | pkg.scripts.commitmsg = "validate-commit-msg" 12 | 13 | writeFileSync( 14 | path.resolve(__dirname, "..", "package.json"), 15 | JSON.stringify(pkg, null, 2) 16 | ) 17 | 18 | // Call husky to set up the hooks 19 | fork(path.resolve(__dirname, "..", "node_modules", "husky", "bin", "install")) 20 | 21 | console.log() 22 | console.log(colors.green("Done!!")) 23 | console.log() 24 | 25 | if (pkg.repository.url.trim()) { 26 | console.log(colors.cyan("Now run:")) 27 | console.log(colors.cyan(" npm install -g semantic-release-cli")) 28 | console.log(colors.cyan(" semantic-release-cli setup")) 29 | console.log() 30 | console.log( 31 | colors.cyan('Important! Answer NO to "Generate travis.yml" question') 32 | ) 33 | console.log() 34 | console.log( 35 | colors.gray( 36 | 'Note: Make sure "repository.url" in your package.json is correct before' 37 | ) 38 | ) 39 | } else { 40 | console.log( 41 | colors.red( 42 | 'First you need to set the "repository.url" property in package.json' 43 | ) 44 | ) 45 | console.log(colors.cyan("Then run:")) 46 | console.log(colors.cyan(" npm install -g semantic-release-cli")) 47 | console.log(colors.cyan(" semantic-release-cli setup")) 48 | console.log() 49 | console.log( 50 | colors.cyan('Important! Answer NO to "Generate travis.yml" question') 51 | ) 52 | } 53 | 54 | console.log() 55 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "moduleResolution": "node", 4 | "target": "es6", 5 | "module":"es2015", 6 | "lib": ["es2015", "es2016", "es2017", "dom"], 7 | "strict": true, 8 | "sourceMap": true, 9 | "declaration": true, 10 | "allowSyntheticDefaultImports": true, 11 | "experimentalDecorators": true, 12 | "emitDecoratorMetadata": true, 13 | "declarationDir": "dist/types", 14 | "outDir": "dist/lib", 15 | "typeRoots": [ 16 | "node_modules/@types" 17 | ] 18 | }, 19 | "include": [ 20 | "src" 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "tslint-config-standard", 4 | "tslint-config-prettier" 5 | ] 6 | } --------------------------------------------------------------------------------