mirror of https://github.com/Budibase/budibase.git
148 changed files with 31993 additions and 0 deletions
@ -0,0 +1,12 @@ |
|||
{ |
|||
"presets": ["@babel/preset-env"], |
|||
"sourceMaps": "inline", |
|||
"retainLines": true, |
|||
"plugins": [ |
|||
["@babel/plugin-transform-runtime", |
|||
{ |
|||
"regenerator": true |
|||
} |
|||
] |
|||
] |
|||
} |
|||
@ -0,0 +1,11 @@ |
|||
{ |
|||
"env": { |
|||
"browser": true, |
|||
"es6": true |
|||
}, |
|||
"extends": "eslint:recommended", |
|||
"parser": "babel-eslint", |
|||
"parserOptions": { |
|||
"sourceType": "module" |
|||
} |
|||
} |
|||
@ -0,0 +1,55 @@ |
|||
# Logs |
|||
logs |
|||
*.log |
|||
|
|||
# Runtime data |
|||
pids |
|||
*.pid |
|||
*.seed |
|||
|
|||
# Directory for instrumented libs generated by jscoverage/JSCover |
|||
lib-cov |
|||
|
|||
# Coverage directory used by tools like istanbul |
|||
coverage |
|||
|
|||
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) |
|||
.grunt |
|||
|
|||
# node-waf configuration |
|||
.lock-wscript |
|||
|
|||
# Compiled binary addons (http://nodejs.org/api/addons.html) |
|||
build/Release |
|||
.eslintcache |
|||
|
|||
# Dependency directory |
|||
# https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git |
|||
node_modules |
|||
node_modules_ubuntu |
|||
node_modules_windows |
|||
app/node_modules |
|||
|
|||
# OSX |
|||
.DS_Store |
|||
|
|||
# flow-typed |
|||
flow-typed/npm/* |
|||
!flow-typed/npm/module_vx.x.x.js |
|||
|
|||
# App packaged |
|||
release |
|||
app/main.prod.js |
|||
app/main.prod.js.map |
|||
app/renderer.prod.js |
|||
app/renderer.prod.js.map |
|||
app/style.css |
|||
app/style.css.map |
|||
dist |
|||
dist-test |
|||
dll |
|||
main.js |
|||
main.js.map |
|||
|
|||
.idea |
|||
npm-debug.log.* |
|||
@ -0,0 +1,11 @@ |
|||
sudo: required |
|||
|
|||
notifications: |
|||
slack: budibase:Nx2QNi9CP87Nn7ah2A4Qdzyy |
|||
|
|||
script: |
|||
- npm install |
|||
- npm install -g jest |
|||
- node node_modules/eslint/bin/eslint src/**/*.js |
|||
- jest |
|||
|
|||
@ -0,0 +1,14 @@ |
|||
{ |
|||
// Use IntelliSense to learn about possible attributes. |
|||
// Hover to view descriptions of existing attributes. |
|||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 |
|||
"version": "0.2.0", |
|||
"configurations": [ |
|||
{ |
|||
"type": "node", |
|||
"request": "launch", |
|||
"name": "Launch Program", |
|||
"program": "${workspaceFolder}\\index.js" |
|||
} |
|||
] |
|||
} |
|||
@ -0,0 +1,5 @@ |
|||
Contributors |
|||
=== |
|||
|
|||
* Michael Shanks - [@mikebudi](https://github.com/mjashanks) |
|||
* Daniel Loudon - [@danbudi](https://github.com/danbudi) |
|||
@ -0,0 +1,22 @@ |
|||
### Contributing to budibase-core |
|||
|
|||
* The contributors are listed in [AUTHORS.md](https://github.com/budibase/budibase-core/blob/master/AUTHORS.md) (add yourself). |
|||
|
|||
* This project uses a modified version of the MPLv2 license, see [LICENSE](https://github.com/budibase/budibase-core/blob/master/LICENSE). |
|||
|
|||
* We use the [C4 (Collective Code Construction Contract)](https://rfc.zeromq.org/spec:42/C4/) process for contributions. |
|||
Please read this if you are unfamiliar with it. |
|||
|
|||
* Please maintain the existing code style. |
|||
|
|||
* Please try to keep your commits small and focussed. |
|||
|
|||
* If the project diverges from your branch, please rebase instead of merging. This makes the commit graph easier to read. |
|||
|
|||
#### p.S... |
|||
|
|||
I am using contribution guidelines from the fantastic [ZeroMQ](https://github.com/zeromq) community. If you are interested why, it's because I believe in the ethos laid out by this community, and written about in depth in the book ["Social Architecture"](https://www.amazon.com/Social-Architecture-Building-line-Communities/dp/1533112452) by Pieter Hintjens. |
|||
|
|||
I am very much open to evolving this to suit our needs. |
|||
|
|||
Love from [Mike](https://github.com/mikebudi). |
|||
@ -0,0 +1,373 @@ |
|||
Mozilla Public License Version 2.0 |
|||
================================== |
|||
|
|||
1. Definitions |
|||
-------------- |
|||
|
|||
1.1. "Contributor" |
|||
means each individual or legal entity that creates, contributes to |
|||
the creation of, or owns Covered Software. |
|||
|
|||
1.2. "Contributor Version" |
|||
means the combination of the Contributions of others (if any) used |
|||
by a Contributor and that particular Contributor's Contribution. |
|||
|
|||
1.3. "Contribution" |
|||
means Covered Software of a particular Contributor. |
|||
|
|||
1.4. "Covered Software" |
|||
means Source Code Form to which the initial Contributor has attached |
|||
the notice in Exhibit A, the Executable Form of such Source Code |
|||
Form, and Modifications of such Source Code Form, in each case |
|||
including portions thereof. |
|||
|
|||
1.5. "Incompatible With Secondary Licenses" |
|||
means |
|||
|
|||
(a) that the initial Contributor has attached the notice described |
|||
in Exhibit B to the Covered Software; or |
|||
|
|||
(b) that the Covered Software was made available under the terms of |
|||
version 1.1 or earlier of the License, but not also under the |
|||
terms of a Secondary License. |
|||
|
|||
1.6. "Executable Form" |
|||
means any form of the work other than Source Code Form. |
|||
|
|||
1.7. "Larger Work" |
|||
means a work that combines Covered Software with other material, in |
|||
a separate file or files, that is not Covered Software. |
|||
|
|||
1.8. "License" |
|||
means this document. |
|||
|
|||
1.9. "Licensable" |
|||
means having the right to grant, to the maximum extent possible, |
|||
whether at the time of the initial grant or subsequently, any and |
|||
all of the rights conveyed by this License. |
|||
|
|||
1.10. "Modifications" |
|||
means any of the following: |
|||
|
|||
(a) any file in Source Code Form that results from an addition to, |
|||
deletion from, or modification of the contents of Covered |
|||
Software; or |
|||
|
|||
(b) any new file in Source Code Form that contains any Covered |
|||
Software. |
|||
|
|||
1.11. "Patent Claims" of a Contributor |
|||
means any patent claim(s), including without limitation, method, |
|||
process, and apparatus claims, in any patent Licensable by such |
|||
Contributor that would be infringed, but for the grant of the |
|||
License, by the making, using, selling, offering for sale, having |
|||
made, import, or transfer of either its Contributions or its |
|||
Contributor Version. |
|||
|
|||
1.12. "Secondary License" |
|||
means either the GNU General Public License, Version 2.0, the GNU |
|||
Lesser General Public License, Version 2.1, the GNU Affero General |
|||
Public License, Version 3.0, or any later versions of those |
|||
licenses. |
|||
|
|||
1.13. "Source Code Form" |
|||
means the form of the work preferred for making modifications. |
|||
|
|||
1.14. "You" (or "Your") |
|||
means an individual or a legal entity exercising rights under this |
|||
License. For legal entities, "You" includes any entity that |
|||
controls, is controlled by, or is under common control with You. For |
|||
purposes of this definition, "control" means (a) the power, direct |
|||
or indirect, to cause the direction or management of such entity, |
|||
whether by contract or otherwise, or (b) ownership of more than |
|||
fifty percent (50%) of the outstanding shares or beneficial |
|||
ownership of such entity. |
|||
|
|||
2. License Grants and Conditions |
|||
-------------------------------- |
|||
|
|||
2.1. Grants |
|||
|
|||
Each Contributor hereby grants You a world-wide, royalty-free, |
|||
non-exclusive license: |
|||
|
|||
(a) under intellectual property rights (other than patent or trademark) |
|||
Licensable by such Contributor to use, reproduce, make available, |
|||
modify, display, perform, distribute, and otherwise exploit its |
|||
Contributions, either on an unmodified basis, with Modifications, or |
|||
as part of a Larger Work; and |
|||
|
|||
(b) under Patent Claims of such Contributor to make, use, sell, offer |
|||
for sale, have made, import, and otherwise transfer either its |
|||
Contributions or its Contributor Version. |
|||
|
|||
2.2. Effective Date |
|||
|
|||
The licenses granted in Section 2.1 with respect to any Contribution |
|||
become effective for each Contribution on the date the Contributor first |
|||
distributes such Contribution. |
|||
|
|||
2.3. Limitations on Grant Scope |
|||
|
|||
The licenses granted in this Section 2 are the only rights granted under |
|||
this License. No additional rights or licenses will be implied from the |
|||
distribution or licensing of Covered Software under this License. |
|||
Notwithstanding Section 2.1(b) above, no patent license is granted by a |
|||
Contributor: |
|||
|
|||
(a) for any code that a Contributor has removed from Covered Software; |
|||
or |
|||
|
|||
(b) for infringements caused by: (i) Your and any other third party's |
|||
modifications of Covered Software, or (ii) the combination of its |
|||
Contributions with other software (except as part of its Contributor |
|||
Version); or |
|||
|
|||
(c) under Patent Claims infringed by Covered Software in the absence of |
|||
its Contributions. |
|||
|
|||
This License does not grant any rights in the trademarks, service marks, |
|||
or logos of any Contributor (except as may be necessary to comply with |
|||
the notice requirements in Section 3.4). |
|||
|
|||
2.4. Subsequent Licenses |
|||
|
|||
No Contributor makes additional grants as a result of Your choice to |
|||
distribute the Covered Software under a subsequent version of this |
|||
License (see Section 10.2) or under the terms of a Secondary License (if |
|||
permitted under the terms of Section 3.3). |
|||
|
|||
2.5. Representation |
|||
|
|||
Each Contributor represents that the Contributor believes its |
|||
Contributions are its original creation(s) or it has sufficient rights |
|||
to grant the rights to its Contributions conveyed by this License. |
|||
|
|||
2.6. Fair Use |
|||
|
|||
This License is not intended to limit any rights You have under |
|||
applicable copyright doctrines of fair use, fair dealing, or other |
|||
equivalents. |
|||
|
|||
2.7. Conditions |
|||
|
|||
Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted |
|||
in Section 2.1. |
|||
|
|||
3. Responsibilities |
|||
------------------- |
|||
|
|||
3.1. Distribution of Source Form |
|||
|
|||
All distribution of Covered Software in Source Code Form, including any |
|||
Modifications that You create or to which You contribute, must be under |
|||
the terms of this License. You must inform recipients that the Source |
|||
Code Form of the Covered Software is governed by the terms of this |
|||
License, and how they can obtain a copy of this License. You may not |
|||
attempt to alter or restrict the recipients' rights in the Source Code |
|||
Form. |
|||
|
|||
3.2. Distribution of Executable Form |
|||
|
|||
If You distribute Covered Software in Executable Form then: |
|||
|
|||
(a) such Covered Software must also be made available in Source Code |
|||
Form, as described in Section 3.1, and You must inform recipients of |
|||
the Executable Form how they can obtain a copy of such Source Code |
|||
Form by reasonable means in a timely manner, at a charge no more |
|||
than the cost of distribution to the recipient; and |
|||
|
|||
(b) You may distribute such Executable Form under the terms of this |
|||
License, or sublicense it under different terms, provided that the |
|||
license for the Executable Form does not attempt to limit or alter |
|||
the recipients' rights in the Source Code Form under this License. |
|||
|
|||
3.3. Distribution of a Larger Work |
|||
|
|||
You may create and distribute a Larger Work under terms of Your choice, |
|||
provided that You also comply with the requirements of this License for |
|||
the Covered Software. If the Larger Work is a combination of Covered |
|||
Software with a work governed by one or more Secondary Licenses, and the |
|||
Covered Software is not Incompatible With Secondary Licenses, this |
|||
License permits You to additionally distribute such Covered Software |
|||
under the terms of such Secondary License(s), so that the recipient of |
|||
the Larger Work may, at their option, further distribute the Covered |
|||
Software under the terms of either this License or such Secondary |
|||
License(s). |
|||
|
|||
3.4. Notices |
|||
|
|||
You may not remove or alter the substance of any license notices |
|||
(including copyright notices, patent notices, disclaimers of warranty, |
|||
or limitations of liability) contained within the Source Code Form of |
|||
the Covered Software, except that You may alter any license notices to |
|||
the extent required to remedy known factual inaccuracies. |
|||
|
|||
3.5. Application of Additional Terms |
|||
|
|||
You may choose to offer, and to charge a fee for, warranty, support, |
|||
indemnity or liability obligations to one or more recipients of Covered |
|||
Software. However, You may do so only on Your own behalf, and not on |
|||
behalf of any Contributor. You must make it absolutely clear that any |
|||
such warranty, support, indemnity, or liability obligation is offered by |
|||
You alone, and You hereby agree to indemnify every Contributor for any |
|||
liability incurred by such Contributor as a result of warranty, support, |
|||
indemnity or liability terms You offer. You may include additional |
|||
disclaimers of warranty and limitations of liability specific to any |
|||
jurisdiction. |
|||
|
|||
4. Inability to Comply Due to Statute or Regulation |
|||
--------------------------------------------------- |
|||
|
|||
If it is impossible for You to comply with any of the terms of this |
|||
License with respect to some or all of the Covered Software due to |
|||
statute, judicial order, or regulation then You must: (a) comply with |
|||
the terms of this License to the maximum extent possible; and (b) |
|||
describe the limitations and the code they affect. Such description must |
|||
be placed in a text file included with all distributions of the Covered |
|||
Software under this License. Except to the extent prohibited by statute |
|||
or regulation, such description must be sufficiently detailed for a |
|||
recipient of ordinary skill to be able to understand it. |
|||
|
|||
5. Termination |
|||
-------------- |
|||
|
|||
5.1. The rights granted under this License will terminate automatically |
|||
if You fail to comply with any of its terms. However, if You become |
|||
compliant, then the rights granted under this License from a particular |
|||
Contributor are reinstated (a) provisionally, unless and until such |
|||
Contributor explicitly and finally terminates Your grants, and (b) on an |
|||
ongoing basis, if such Contributor fails to notify You of the |
|||
non-compliance by some reasonable means prior to 60 days after You have |
|||
come back into compliance. Moreover, Your grants from a particular |
|||
Contributor are reinstated on an ongoing basis if such Contributor |
|||
notifies You of the non-compliance by some reasonable means, this is the |
|||
first time You have received notice of non-compliance with this License |
|||
from such Contributor, and You become compliant prior to 30 days after |
|||
Your receipt of the notice. |
|||
|
|||
5.2. If You initiate litigation against any entity by asserting a patent |
|||
infringement claim (excluding declaratory judgment actions, |
|||
counter-claims, and cross-claims) alleging that a Contributor Version |
|||
directly or indirectly infringes any patent, then the rights granted to |
|||
You by any and all Contributors for the Covered Software under Section |
|||
2.1 of this License shall terminate. |
|||
|
|||
5.3. In the event of termination under Sections 5.1 or 5.2 above, all |
|||
end user license agreements (excluding distributors and resellers) which |
|||
have been validly granted by You or Your distributors under this License |
|||
prior to termination shall survive termination. |
|||
|
|||
************************************************************************ |
|||
* * |
|||
* 6. Disclaimer of Warranty * |
|||
* ------------------------- * |
|||
* * |
|||
* Covered Software is provided under this License on an "as is" * |
|||
* basis, without warranty of any kind, either expressed, implied, or * |
|||
* statutory, including, without limitation, warranties that the * |
|||
* Covered Software is free of defects, merchantable, fit for a * |
|||
* particular purpose or non-infringing. The entire risk as to the * |
|||
* quality and performance of the Covered Software is with You. * |
|||
* Should any Covered Software prove defective in any respect, You * |
|||
* (not any Contributor) assume the cost of any necessary servicing, * |
|||
* repair, or correction. This disclaimer of warranty constitutes an * |
|||
* essential part of this License. No use of any Covered Software is * |
|||
* authorized under this License except under this disclaimer. * |
|||
* * |
|||
************************************************************************ |
|||
|
|||
************************************************************************ |
|||
* * |
|||
* 7. Limitation of Liability * |
|||
* -------------------------- * |
|||
* * |
|||
* Under no circumstances and under no legal theory, whether tort * |
|||
* (including negligence), contract, or otherwise, shall any * |
|||
* Contributor, or anyone who distributes Covered Software as * |
|||
* permitted above, be liable to You for any direct, indirect, * |
|||
* special, incidental, or consequential damages of any character * |
|||
* including, without limitation, damages for lost profits, loss of * |
|||
* goodwill, work stoppage, computer failure or malfunction, or any * |
|||
* and all other commercial damages or losses, even if such party * |
|||
* shall have been informed of the possibility of such damages. This * |
|||
* limitation of liability shall not apply to liability for death or * |
|||
* personal injury resulting from such party's negligence to the * |
|||
* extent applicable law prohibits such limitation. Some * |
|||
* jurisdictions do not allow the exclusion or limitation of * |
|||
* incidental or consequential damages, so this exclusion and * |
|||
* limitation may not apply to You. * |
|||
* * |
|||
************************************************************************ |
|||
|
|||
8. Litigation |
|||
------------- |
|||
|
|||
Any litigation relating to this License may be brought only in the |
|||
courts of a jurisdiction where the defendant maintains its principal |
|||
place of business and such litigation shall be governed by laws of that |
|||
jurisdiction, without reference to its conflict-of-law provisions. |
|||
Nothing in this Section shall prevent a party's ability to bring |
|||
cross-claims or counter-claims. |
|||
|
|||
9. Miscellaneous |
|||
---------------- |
|||
|
|||
This License represents the complete agreement concerning the subject |
|||
matter hereof. If any provision of this License is held to be |
|||
unenforceable, such provision shall be reformed only to the extent |
|||
necessary to make it enforceable. Any law or regulation which provides |
|||
that the language of a contract shall be construed against the drafter |
|||
shall not be used to construe this License against a Contributor. |
|||
|
|||
10. Versions of the License |
|||
--------------------------- |
|||
|
|||
10.1. New Versions |
|||
|
|||
Mozilla Foundation is the license steward. Except as provided in Section |
|||
10.3, no one other than the license steward has the right to modify or |
|||
publish new versions of this License. Each version will be given a |
|||
distinguishing version number. |
|||
|
|||
10.2. Effect of New Versions |
|||
|
|||
You may distribute the Covered Software under the terms of the version |
|||
of the License under which You originally received the Covered Software, |
|||
or under the terms of any subsequent version published by the license |
|||
steward. |
|||
|
|||
10.3. Modified Versions |
|||
|
|||
If you create software not governed by this License, and you want to |
|||
create a new license for such software, you may create and use a |
|||
modified version of this License if you rename the license and remove |
|||
any references to the name of the license steward (except to note that |
|||
such modified license differs from this License). |
|||
|
|||
10.4. Distributing Source Code Form that is Incompatible With Secondary |
|||
Licenses |
|||
|
|||
If You choose to distribute Source Code Form that is Incompatible With |
|||
Secondary Licenses under the terms of this version of the License, the |
|||
notice described in Exhibit B of this License must be attached. |
|||
|
|||
Exhibit A - Source Code Form License Notice |
|||
------------------------------------------- |
|||
|
|||
This Source Code Form is subject to the terms of the Mozilla Public |
|||
License, v. 2.0. If a copy of the MPL was not distributed with this |
|||
file, You can obtain one at http://mozilla.org/MPL/2.0/. |
|||
|
|||
If it is not possible or desirable to put the notice in a particular |
|||
file, then You may include the notice in a location (such as a LICENSE |
|||
file in a relevant directory) where a recipient would be likely to look |
|||
for such a notice. |
|||
|
|||
You may add additional accurate notices of copyright ownership. |
|||
|
|||
Exhibit B - "Incompatible With Secondary Licenses" Notice |
|||
--------------------------------------------------------- |
|||
|
|||
This Source Code Form is "Incompatible With Secondary Licenses", as |
|||
defined by the Mozilla Public License, v. 2.0. |
|||
File diff suppressed because it is too large
@ -0,0 +1,83 @@ |
|||
{ |
|||
"name": "budibase-core", |
|||
"version": "0.0.1", |
|||
"description": "core javascript library for budibase", |
|||
"main": "/dist/budibase-core.umd.js", |
|||
"module": "dist/budibase-core.esm.js", |
|||
"directories": { |
|||
"test": "test" |
|||
}, |
|||
"scripts": { |
|||
"test": "jest", |
|||
"build": "rollup -c rollup.config.js" |
|||
}, |
|||
"keywords": [ |
|||
"budibase" |
|||
], |
|||
"author": "Michael Shanks", |
|||
"license": "MPL-2.0", |
|||
"jest": { |
|||
"globals": { |
|||
"GLOBALS": { |
|||
"client": "web" |
|||
} |
|||
}, |
|||
"testURL": "http://jest-breaks-if-this-does-not-exist", |
|||
"moduleNameMapper": { |
|||
"\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "<rootDir>/internals/mocks/fileMock.js", |
|||
"\\.(css|less|sass|scss)$": "identity-obj-proxy" |
|||
}, |
|||
"moduleFileExtensions": [ |
|||
"js" |
|||
], |
|||
"moduleDirectories": [ |
|||
"node_modules", |
|||
"app/node_modules" |
|||
], |
|||
"transform": { |
|||
"^.+\\.js$": "babel-jest" |
|||
}, |
|||
"transformIgnorePatterns": [ |
|||
"/node_modules/(?!svelte).+\\.js$" |
|||
] |
|||
}, |
|||
"devDependencies": { |
|||
"@babel/cli": "^7.4.4", |
|||
"@babel/core": "^7.4.5", |
|||
"@babel/plugin-transform-runtime": "^7.4.4", |
|||
"@babel/preset-env": "^7.4.5", |
|||
"@babel/runtime": "^7.4.5", |
|||
"argon2": "^0.20.1", |
|||
"babel-eslint": "^10.0.2", |
|||
"babel-jest": "^23.6.0", |
|||
"babel-plugin-transform-es2015-modules-commonjs": "^6.26.2", |
|||
"cross-env": "^5.1.4", |
|||
"eslint": "^5.3.0", |
|||
"eslint-config-airbnb": "^17.1.0", |
|||
"eslint-plugin-import": "^2.14.0", |
|||
"eslint-plugin-jsx-a11y": "^6.1.1", |
|||
"eslint-plugin-react": "^7.11.0", |
|||
"jest": "^24.8.0", |
|||
"readable-stream": "^3.1.1", |
|||
"regenerator-runtime": "^0.11.1", |
|||
"rimraf": "^2.6.2", |
|||
"rollup": "^1.12.0", |
|||
"rollup-plugin-commonjs": "^10.0.0", |
|||
"rollup-plugin-node-builtins": "^2.1.2", |
|||
"rollup-plugin-node-globals": "^1.4.0", |
|||
"rollup-plugin-node-resolve": "^5.0.0" |
|||
}, |
|||
"dependencies": { |
|||
"@nx-js/compiler-util": "^2.0.0", |
|||
"date-fns": "^1.29.0", |
|||
"lodash": "^4.17.11", |
|||
"lunr": "^2.3.5", |
|||
"safe-buffer": "^5.1.2", |
|||
"shortid": "^2.2.8" |
|||
}, |
|||
"devEngines": { |
|||
"node": ">=7.x", |
|||
"npm": ">=4.x", |
|||
"yarn": ">=0.21.3" |
|||
} |
|||
} |
|||
File diff suppressed because it is too large
@ -0,0 +1,32 @@ |
|||
## Getting Started |
|||
|
|||
Install requires [node-gyp](https://github.com/nodejs/node-gyp), due to a dependancy on [argon2](https://github.com/ranisalt/node-argon2) |
|||
|
|||
### For node gyp on windows |
|||
|
|||
`npm install --global --production windows-build-tools` |
|||
and this might help: https://github.com/nodejs/node-gyp/issues/1278 |
|||
|
|||
### For node gyp on ubuntu |
|||
|
|||
`sudo apt-get install build-essentials` |
|||
|
|||
Once you have this, try... |
|||
|
|||
`npm install` |
|||
|
|||
Next, run the tests. Install jest, globally: |
|||
|
|||
`npm install -g jest` |
|||
|
|||
And finally, run |
|||
|
|||
`jest` |
|||
|
|||
## Documentation |
|||
|
|||
A work in progress, lives here: https://github.com/Budibase/docs/blob/master/budibase-core.md |
|||
|
|||
|
|||
|
|||
|
|||
@ -0,0 +1,76 @@ |
|||
import builtins from 'rollup-plugin-node-builtins'; |
|||
import resolve from 'rollup-plugin-node-resolve'; |
|||
import commonjs from 'rollup-plugin-commonjs'; |
|||
import nodeglobals from 'rollup-plugin-node-globals'; |
|||
|
|||
const lodash_fp_exports = ["union", "reduce", "isUndefined", "cloneDeep", "split", "some", "map", "filter", "isEmpty", "countBy", "includes", "last", "find", "constant", |
|||
"take", "first", "intersection", "mapValues", "isNull", "has", "isNumber", "isString", "isBoolean", "isDate", "isArray", "isObject", "clone", "values", "keyBy", |
|||
"keys", "orderBy", "concat", "reverse", "difference", "merge", "flatten", "each", "pull", "join", "defaultCase", "uniqBy", "every", "uniqWith", "isFunction", "groupBy", |
|||
"differenceBy", "intersectionBy", "isEqual", "max"]; |
|||
|
|||
const lodash_exports = ["toNumber", "flow", "isArray", "join", "replace", "trim", "dropRight", "takeRight", "head", "isUndefined", "isNull", "isNaN", "reduce", "isEmpty", |
|||
"constant", "tail", "includes", "startsWith", "findIndex", "isInteger", "isDate", "isString", "split", "clone", "keys", "isFunction", "merge", "has", "isBoolean", "isNumber", |
|||
"isObjectLike", "assign", "some", "each", "find", "orderBy", "union", "cloneDeep"]; |
|||
|
|||
const globals = { |
|||
"lodash/fp": "fp", |
|||
lodash: "_", |
|||
lunr: "lunr", |
|||
"safe-buffer": "safe_buffer", |
|||
shortid:"shortid", |
|||
"@nx-js/compiler-util":"compiler_util" |
|||
} |
|||
|
|||
module.exports = { |
|||
input: 'src/index.js', |
|||
output: [ |
|||
{ |
|||
file: 'dist/budibase-core.cjs.js', |
|||
format: 'cjs', |
|||
sourcemap: 'inline', |
|||
globals |
|||
}, |
|||
/*{ |
|||
file: 'dist/budibase-core.iife.js', |
|||
format: 'iife', |
|||
sourcemap: 'inline', |
|||
globals: [ |
|||
|
|||
] |
|||
}*/, |
|||
{ |
|||
file: 'dist/budibase-core.esm.mjs', |
|||
format: 'esm', |
|||
sourcemap: 'inline', |
|||
globals |
|||
}, |
|||
{ |
|||
file: 'dist/budibase-core.umd.js', |
|||
format: 'umd', |
|||
name: "budibase-core", |
|||
sourcemap: 'inline', |
|||
globals |
|||
} |
|||
], |
|||
plugins: [ |
|||
nodeglobals(), |
|||
builtins(), |
|||
resolve({ |
|||
preferBuiltins:true |
|||
}), |
|||
commonjs({ |
|||
namedExports: { |
|||
"lodash/fp": lodash_fp_exports, |
|||
"lodash":lodash_exports, |
|||
"shortid": ["generate"] |
|||
} |
|||
}) |
|||
], |
|||
external: [ |
|||
"lodash", "lodash/fp", "date-fns", |
|||
"lunr", "safe-buffer", "shortid", |
|||
"@nx-js/compiler-util" |
|||
] |
|||
}; |
|||
|
|||
|
|||
@ -0,0 +1,15 @@ |
|||
import { has } from 'lodash'; |
|||
import { ConflictError } from '../common/errors'; |
|||
|
|||
export const createBehaviourSources = () => { |
|||
const sources = {}; |
|||
const register = (name, funcsObj) => { |
|||
if (has(sources, name)) { |
|||
throw new ConflictError(`Source '${name}' already exists`); |
|||
} |
|||
|
|||
sources[name] = funcsObj; |
|||
}; |
|||
sources.register = register; |
|||
return sources; |
|||
}; |
|||
@ -0,0 +1,15 @@ |
|||
import { permission } from '../authApi/permissions'; |
|||
import { apiWrapperSync } from '../common/apiWrapper'; |
|||
import { events } from '../common/events'; |
|||
|
|||
export const executeAction = app => (actionName, options) => { |
|||
apiWrapperSync( |
|||
app, |
|||
events.actionsApi.execute, |
|||
permission.executeAction.isAuthorized(actionName), |
|||
{ actionName, options }, |
|||
app.actions[actionName], options, |
|||
); |
|||
}; |
|||
|
|||
export const _executeAction = (behaviourSources, action, options) => behaviourSources[action.behaviourSource][action.behaviourName](options); |
|||
@ -0,0 +1,7 @@ |
|||
import { executeAction } from './execute'; |
|||
|
|||
export const getActionsApi = app => ({ |
|||
execute: executeAction(app), |
|||
}); |
|||
|
|||
export default getActionsApi; |
|||
@ -0,0 +1,74 @@ |
|||
import { |
|||
isFunction, filter, map, |
|||
uniqBy, keys, difference, |
|||
join, reduce, find, |
|||
} from 'lodash/fp'; |
|||
import { compileExpression, compileCode } from '../common/compileCode'; |
|||
import { $ } from '../common'; |
|||
import { _executeAction } from './execute'; |
|||
import { BadRequestError, NotFoundError } from '../common/errors'; |
|||
|
|||
export const initialiseActions = (subscribe, behaviourSources, actions, triggers, apis) => { |
|||
validateSources(behaviourSources, actions); |
|||
subscribeTriggers(subscribe, behaviourSources, actions, triggers, apis); |
|||
return createActionsCollection(behaviourSources, actions); |
|||
}; |
|||
|
|||
const createActionsCollection = (behaviourSources, actions) => $(actions, [ |
|||
reduce((all, a) => { |
|||
all[a.name] = opts => _executeAction(behaviourSources, a, opts); |
|||
return all; |
|||
}, {}), |
|||
]); |
|||
|
|||
const subscribeTriggers = (subscribe, behaviourSources, actions, triggers, apis) => { |
|||
const createOptions = (optionsCreator, eventContext) => { |
|||
if (!optionsCreator) return {}; |
|||
const create = compileCode(optionsCreator); |
|||
return create({ context: eventContext, apis }); |
|||
}; |
|||
|
|||
const shouldRunTrigger = (trigger, eventContext) => { |
|||
if (!trigger.condition) return true; |
|||
const shouldRun = compileExpression(trigger.condition); |
|||
return shouldRun({ context: eventContext }); |
|||
}; |
|||
|
|||
for (let trig of triggers) { |
|||
subscribe(trig.eventName, async (ev, ctx) => { |
|||
if (shouldRunTrigger(trig, ctx)) { |
|||
await _executeAction( |
|||
behaviourSources, |
|||
find(a => a.name === trig.actionName)(actions), |
|||
createOptions(trig.optionsCreator, ctx), |
|||
); |
|||
} |
|||
}); |
|||
} |
|||
}; |
|||
|
|||
const validateSources = (behaviourSources, actions) => { |
|||
const declaredSources = $(actions, [ |
|||
uniqBy(a => a.behaviourSource), |
|||
map(a => a.behaviourSource), |
|||
]); |
|||
|
|||
const suppliedSources = keys(behaviourSources); |
|||
|
|||
const missingSources = difference( |
|||
declaredSources, suppliedSources, |
|||
); |
|||
|
|||
if (missingSources.length > 0) { |
|||
throw new BadRequestError(`Declared behaviour sources are not supplied: ${join(', ', missingSources)}`); |
|||
} |
|||
|
|||
const missingBehaviours = $(actions, [ |
|||
filter(a => !isFunction(behaviourSources[a.behaviourSource][a.behaviourName])), |
|||
map(a => `Action: ${a.name} : ${a.behaviourSource}.${a.behaviourName}`), |
|||
]); |
|||
|
|||
if (missingBehaviours.length > 0) { |
|||
throw new NotFoundError(`Missing behaviours: could not find behaviour functions: ${join(', ', missingBehaviours)}`); |
|||
} |
|||
}; |
|||
@ -0,0 +1,27 @@ |
|||
import { isNothing } from '../common'; |
|||
|
|||
export const getDatabaseManager = databaseManager => ({ |
|||
createEmptyMasterDb: createEmptyMasterDb(databaseManager), |
|||
createEmptyInstanceDb: createEmptyInstanceDb(databaseManager), |
|||
getInstanceDbRootConfig: databaseManager.getInstanceDbRootConfig, |
|||
masterDatastoreConfig: getMasterDatastoreConfig(databaseManager), |
|||
getInstanceDatastoreConfig: getInstanceDatastoreConfig(databaseManager), |
|||
}); |
|||
|
|||
const getMasterDatastoreConfig = databaseManager => databaseManager.getDatastoreConfig('master'); |
|||
|
|||
const getInstanceDatastoreConfig = databaseManager => (applicationId, instanceId) => databaseManager.getDatastoreConfig( |
|||
applicationId, instanceId, |
|||
); |
|||
|
|||
const createEmptyMasterDb = databaseManager => async () => await databaseManager.createEmptyDb('master'); |
|||
|
|||
const createEmptyInstanceDb = databaseManager => async (applicationId, instanceId) => { |
|||
if (isNothing(applicationId)) { throw new Error('CreateDb: application id not supplied'); } |
|||
if (isNothing(instanceId)) { throw new Error('CreateDb: instance id not supplied'); } |
|||
|
|||
return await databaseManager.createEmptyDb( |
|||
applicationId, |
|||
instanceId, |
|||
); |
|||
}; |
|||
@ -0,0 +1,27 @@ |
|||
import { has } from 'lodash'; |
|||
|
|||
const publish = handlers => async (eventName, context = {}) => { |
|||
if (!has(handlers, eventName)) return; |
|||
|
|||
for (const handler of handlers[eventName]) { |
|||
await handler(eventName, context); |
|||
} |
|||
}; |
|||
|
|||
const subscribe = handlers => (eventName, handler) => { |
|||
if (!has(handlers, eventName)) { |
|||
handlers[eventName] = []; |
|||
} |
|||
handlers[eventName].push(handler); |
|||
}; |
|||
|
|||
export const createEventAggregator = () => { |
|||
const handlers = {}; |
|||
const eventAggregator = ({ |
|||
publish: publish(handlers), |
|||
subscribe: subscribe(handlers), |
|||
}); |
|||
return eventAggregator; |
|||
}; |
|||
|
|||
export default createEventAggregator; |
|||
@ -0,0 +1,36 @@ |
|||
import { retry } from '../common/index'; |
|||
import { NotFoundError } from '../common/errors'; |
|||
|
|||
const createJson = originalCreateFile => async (key, obj, retries = 5, delay = 500) => await retry(originalCreateFile, retries, delay, key, JSON.stringify(obj)); |
|||
|
|||
const createNewFile = originalCreateFile => async (path, content, retries = 5, delay = 500) => await retry(originalCreateFile, retries, delay, path, content); |
|||
|
|||
const loadJson = datastore => async (key, retries = 5, delay = 500) => { |
|||
try { |
|||
return await retry(JSON.parse, retries, delay, await datastore.loadFile(key)); |
|||
} catch (err) { |
|||
throw new NotFoundError(err.message); |
|||
} |
|||
} |
|||
|
|||
const updateJson = datastore => async (key, obj, retries = 5, delay = 500) => { |
|||
try { |
|||
return await retry(datastore.updateFile, retries, delay, key, JSON.stringify(obj)); |
|||
} catch (err) { |
|||
throw new NotFoundError(err.message); |
|||
} |
|||
} |
|||
|
|||
export const setupDatastore = (datastore) => { |
|||
const originalCreateFile = datastore.createFile; |
|||
datastore.loadJson = loadJson(datastore); |
|||
datastore.createJson = createJson(originalCreateFile); |
|||
datastore.updateJson = updateJson(datastore); |
|||
datastore.createFile = createNewFile(originalCreateFile); |
|||
if (datastore.createEmptyDb) { delete datastore.createEmptyDb; } |
|||
return datastore; |
|||
}; |
|||
|
|||
export { createEventAggregator } from './eventAggregator'; |
|||
|
|||
export default setupDatastore; |
|||
@ -0,0 +1,58 @@ |
|||
import { filter } from 'lodash/fp'; |
|||
import { configFolder, appDefinitionFile, $ } from '../common'; |
|||
import { TRANSACTIONS_FOLDER } from '../transactions/transactionsCommon'; |
|||
import { AUTH_FOLDER, USERS_LIST_FILE, ACCESS_LEVELS_FILE } from '../authApi/authCommon'; |
|||
import { initialiseRootCollections } from '../collectionApi/initialise'; |
|||
import { initialiseIndex } from '../indexing/initialiseIndex'; |
|||
import { getFlattenedHierarchy, isGlobalIndex, isSingleRecord } from '../templateApi/hierarchy'; |
|||
import { _save } from '../recordApi/save'; |
|||
import { getNew } from '../recordApi/getNew'; |
|||
|
|||
export const initialiseData = async (datastore, applicationDefinition, accessLevels) => { |
|||
await datastore.createFolder(configFolder); |
|||
await datastore.createJson(appDefinitionFile, applicationDefinition); |
|||
|
|||
await initialiseRootCollections(datastore, applicationDefinition.hierarchy); |
|||
await initialiseRootIndexes(datastore, applicationDefinition.hierarchy); |
|||
|
|||
await initialiseRootSingleRecords(datastore, applicationDefinition.hierarchy); |
|||
|
|||
await datastore.createFolder(TRANSACTIONS_FOLDER); |
|||
|
|||
await datastore.createFolder(AUTH_FOLDER); |
|||
|
|||
await datastore.createJson(USERS_LIST_FILE, []); |
|||
|
|||
await datastore.createJson( |
|||
ACCESS_LEVELS_FILE, |
|||
accessLevels ? accessLevels : { version: 0, levels: [] }); |
|||
}; |
|||
|
|||
const initialiseRootIndexes = async (datastore, hierarchy) => { |
|||
const flathierarchy = getFlattenedHierarchy(hierarchy); |
|||
const globalIndexes = $(flathierarchy, [ |
|||
filter(isGlobalIndex), |
|||
]); |
|||
|
|||
for (const index of globalIndexes) { |
|||
if (!await datastore.exists(index.nodeKey())) { await initialiseIndex(datastore, '', index); } |
|||
} |
|||
}; |
|||
|
|||
const initialiseRootSingleRecords = async (datastore, hierachy) => { |
|||
const flathierarchy = getFlattenedHierarchy(hierachy); |
|||
const singleRecords = $(flathierarchy, [ |
|||
filter(isSingleRecord), |
|||
]); |
|||
|
|||
/* for (let record of singleRecords) { |
|||
const result = getNew({ datastore: datastore, hierarchy: appDefinition.hierarchy }) |
|||
(record.nodeKey(), |
|||
record.name |
|||
); |
|||
|
|||
_save({ datastore: datastore, hierarchy: appDefinition.hierarchy }, |
|||
result |
|||
); |
|||
} */ |
|||
}; |
|||
@ -0,0 +1,49 @@ |
|||
import { clone, find, split } from 'lodash/fp'; |
|||
import { joinKey, $ } from '../common'; |
|||
// 5 minutes
|
|||
export const tempCodeExpiryLength = 5 * 60 * 1000; |
|||
|
|||
export const AUTH_FOLDER = '/.auth'; |
|||
export const USERS_LIST_FILE = joinKey(AUTH_FOLDER, 'users.json'); |
|||
export const userAuthFile = username => joinKey(AUTH_FOLDER, `auth_${username}.json`); |
|||
export const USERS_LOCK_FILE = joinKey(AUTH_FOLDER, 'users_lock'); |
|||
export const ACCESS_LEVELS_FILE = joinKey(AUTH_FOLDER, 'access_levels.json'); |
|||
export const ACCESS_LEVELS_LOCK_FILE = joinKey(AUTH_FOLDER, 'access_levels_lock'); |
|||
|
|||
export const permissionTypes = { |
|||
CREATE_RECORD: 'create record', |
|||
UPDATE_RECORD: 'update record', |
|||
READ_RECORD: 'read record', |
|||
DELETE_RECORD: 'delete record', |
|||
READ_INDEX: 'read index', |
|||
MANAGE_INDEX: 'manage index', |
|||
MANAGE_COLLECTION: 'manage collection', |
|||
WRITE_TEMPLATES: 'write templates', |
|||
CREATE_USER: 'create user', |
|||
SET_PASSWORD: 'set password', |
|||
CREATE_TEMPORARY_ACCESS: 'create temporary access', |
|||
ENABLE_DISABLE_USER: 'enable or disable user', |
|||
WRITE_ACCESS_LEVELS: 'write access levels', |
|||
LIST_USERS: 'list users', |
|||
LIST_ACCESS_LEVELS: 'list access levels', |
|||
EXECUTE_ACTION: 'execute action', |
|||
SET_USER_ACCESS_LEVELS: 'set user access levels', |
|||
}; |
|||
|
|||
export const getUserByName = (users, name) => $(users, [ |
|||
find(u => u.name.toLowerCase() === name.toLowerCase()), |
|||
]); |
|||
|
|||
export const stripUserOfSensitiveStuff = (user) => { |
|||
const stripped = clone(user); |
|||
delete stripped.tempCode; |
|||
return stripped; |
|||
}; |
|||
|
|||
export const parseTemporaryCode = fullCode => $(fullCode, [ |
|||
split(':'), |
|||
parts => ({ |
|||
id: parts[1], |
|||
code: parts[2], |
|||
}), |
|||
]); |
|||
@ -0,0 +1,117 @@ |
|||
import { |
|||
find, filter, some, |
|||
map, flatten, |
|||
} from 'lodash/fp'; |
|||
import { generate } from 'shortid'; |
|||
import { _getUsers } from './getUsers'; |
|||
import { |
|||
getUserByName, userAuthFile, |
|||
parseTemporaryCode, |
|||
} from './authCommon'; |
|||
import { _loadAccessLevels } from './loadAccessLevels'; |
|||
import { |
|||
isNothingOrEmpty, $, apiWrapper, events, |
|||
} from '../common'; |
|||
import { alwaysAuthorized } from './permissions'; |
|||
|
|||
const dummyHash = '$argon2i$v=19$m=4096,t=3,p=1$UZRo409UYBGjHJS3CV6Uxw$rU84qUqPeORFzKYmYY0ceBLDaPO+JWSH4PfNiKXfIKk'; |
|||
|
|||
export const authenticate = app => async (username, password) => apiWrapper( |
|||
app, |
|||
events.authApi.authenticate, |
|||
alwaysAuthorized, |
|||
{ username, password }, |
|||
_authenticate, app, username, password, |
|||
); |
|||
|
|||
export const _authenticate = async (app, username, password) => { |
|||
if (isNothingOrEmpty(username) || isNothingOrEmpty(password)) { return null; } |
|||
|
|||
const allUsers = await _getUsers(app); |
|||
let user = getUserByName( |
|||
allUsers, |
|||
username, |
|||
); |
|||
|
|||
const notAUser = 'not-a-user'; |
|||
// continue with non-user - so time to verify remains consistent
|
|||
// with verification of a valid user
|
|||
if (!user || !user.enabled) { user = notAUser; } |
|||
|
|||
let userAuth; |
|||
try { |
|||
userAuth = await app.datastore.loadJson( |
|||
userAuthFile(username), |
|||
); |
|||
} catch (_) { |
|||
userAuth = { accessLevels: [], passwordHash: dummyHash }; |
|||
} |
|||
|
|||
const permissions = await buildUserPermissions(app, user.accessLevels); |
|||
|
|||
const verified = await app.crypto.verify( |
|||
userAuth.passwordHash, |
|||
password, |
|||
); |
|||
|
|||
if (user === notAUser) { return null; } |
|||
|
|||
return verified |
|||
? { |
|||
...user, permissions, temp: false, isUser: true, |
|||
} |
|||
: null; |
|||
}; |
|||
|
|||
export const authenticateTemporaryAccess = app => async (tempAccessCode) => { |
|||
if (isNothingOrEmpty(tempAccessCode)) { return null; } |
|||
|
|||
const temp = parseTemporaryCode(tempAccessCode); |
|||
let user = $(await _getUsers(app), [ |
|||
find(u => u.temporaryAccessId === temp.id), |
|||
]); |
|||
|
|||
const notAUser = 'not-a-user'; |
|||
if (!user || !user.enabled) { user = notAUser; } |
|||
|
|||
let userAuth; |
|||
try { |
|||
userAuth = await app.datastore.loadJson( |
|||
userAuthFile(user.name), |
|||
); |
|||
} catch (e) { |
|||
userAuth = { |
|||
temporaryAccessHash: dummyHash, |
|||
temporaryAccessExpiryEpoch: (await app.getEpochTime() + 10000), |
|||
}; |
|||
} |
|||
|
|||
if (userAuth.temporaryAccessExpiryEpoch < await app.getEpochTime()) { user = notAUser; } |
|||
|
|||
const tempCode = !temp.code ? generate() : temp.code; |
|||
const verified = await app.crypto.verify( |
|||
userAuth.temporaryAccessHash, |
|||
tempCode, |
|||
); |
|||
|
|||
if (user === notAUser) { return null; } |
|||
|
|||
return verified |
|||
? { |
|||
...user, |
|||
permissions: [], |
|||
temp: true, |
|||
isUser: true, |
|||
} |
|||
: null; |
|||
}; |
|||
|
|||
export const buildUserPermissions = async (app, userAccessLevels) => { |
|||
const allAccessLevels = await _loadAccessLevels(app); |
|||
|
|||
return $(allAccessLevels.levels, [ |
|||
filter(l => some(ua => l.name === ua)(userAccessLevels)), |
|||
map(l => l.permissions), |
|||
flatten, |
|||
]); |
|||
}; |
|||
@ -0,0 +1,79 @@ |
|||
import { generate } from 'shortid'; |
|||
import { |
|||
tempCodeExpiryLength, USERS_LOCK_FILE, |
|||
USERS_LIST_FILE, userAuthFile, |
|||
getUserByName, |
|||
} from './authCommon'; |
|||
import { |
|||
getLock, isNolock, |
|||
releaseLock, |
|||
} from '../common/lock'; |
|||
import { apiWrapper, events } from '../common'; |
|||
import { alwaysAuthorized } from './permissions'; |
|||
|
|||
export const createTemporaryAccess = app => async userName => apiWrapper( |
|||
app, |
|||
events.authApi.createTemporaryAccess, |
|||
alwaysAuthorized, |
|||
{ userName }, |
|||
_createTemporaryAccess, app, userName, |
|||
); |
|||
|
|||
export const _createTemporaryAccess = async (app, userName) => { |
|||
const tempCode = await getTemporaryCode(app); |
|||
|
|||
const lock = await getLock( |
|||
app, USERS_LOCK_FILE, 1000, 2, |
|||
); |
|||
|
|||
if (isNolock(lock)) { throw new Error('Unable to create temporary access, could not get lock - try again'); } |
|||
|
|||
try { |
|||
const users = await app.datastore.loadJson(USERS_LIST_FILE); |
|||
|
|||
const user = getUserByName(users, userName); |
|||
user.temporaryAccessId = tempCode.temporaryAccessId; |
|||
|
|||
await app.datastore.updateJson( |
|||
USERS_LIST_FILE, |
|||
users, |
|||
); |
|||
} finally { |
|||
await releaseLock(app, lock); |
|||
} |
|||
|
|||
const userAuth = await app.datastore.loadJson( |
|||
userAuthFile(userName), |
|||
); |
|||
userAuth.temporaryAccessHash = tempCode.temporaryAccessHash; |
|||
|
|||
userAuth.temporaryAccessExpiryEpoch = tempCode.temporaryAccessExpiryEpoch; |
|||
|
|||
await app.datastore.updateJson( |
|||
userAuthFile(userName), |
|||
userAuth, |
|||
); |
|||
|
|||
return tempCode.tempCode; |
|||
}; |
|||
|
|||
export const getTemporaryCode = async (app) => { |
|||
const tempCode = generate() |
|||
+ generate() |
|||
+ generate() |
|||
+ generate(); |
|||
|
|||
const tempId = generate(); |
|||
|
|||
return { |
|||
temporaryAccessHash: await app.crypto.hash( |
|||
tempCode, |
|||
), |
|||
temporaryAccessExpiryEpoch: |
|||
(await app.getEpochTime()) + tempCodeExpiryLength, |
|||
tempCode: `tmp:${tempId}:${tempCode}`, |
|||
temporaryAccessId: tempId, |
|||
}; |
|||
}; |
|||
|
|||
export const looksLikeTemporaryCode = code => code.startsWith('tmp:'); |
|||
@ -0,0 +1,96 @@ |
|||
import { join, some } from 'lodash/fp'; |
|||
import { validateUser } from './validateUser'; |
|||
import { getNewUserAuth } from './getNewUser'; |
|||
import { |
|||
getLock, isNolock, releaseLock, apiWrapper, events, |
|||
insensitiveEquals, isNonEmptyString, |
|||
} from '../common'; |
|||
import { |
|||
USERS_LOCK_FILE, stripUserOfSensitiveStuff, |
|||
USERS_LIST_FILE, userAuthFile, |
|||
} from './authCommon'; |
|||
import { getTemporaryCode } from './createTemporaryAccess'; |
|||
import { isValidPassword } from './setPassword'; |
|||
import { permission } from './permissions'; |
|||
import { BadRequestError } from '../common/errors'; |
|||
|
|||
export const createUser = app => async (user, password = null) => apiWrapper( |
|||
app, |
|||
events.authApi.createUser, |
|||
permission.createUser.isAuthorized, |
|||
{ user, password }, |
|||
_createUser, app, user, password, |
|||
); |
|||
|
|||
export const _createUser = async (app, user, password = null) => { |
|||
const lock = await getLock( |
|||
app, USERS_LOCK_FILE, 1000, 2, |
|||
); |
|||
|
|||
if (isNolock(lock)) { throw new Error('Unable to create user, could not get lock - try again'); } |
|||
|
|||
const users = await app.datastore.loadJson(USERS_LIST_FILE); |
|||
|
|||
const userErrors = validateUser(app)([...users, user], user); |
|||
if (userErrors.length > 0) { throw new BadRequestError(`User is invalid. ${join('; ')(userErrors)}`); } |
|||
|
|||
const { auth, tempCode, temporaryAccessId } = await getAccess( |
|||
app, password, |
|||
); |
|||
user.tempCode = tempCode; |
|||
user.temporaryAccessId = temporaryAccessId; |
|||
|
|||
if (some(u => insensitiveEquals(u.name, user.name))(users)) { |
|||
throw new BadRequestError('User already exists'); |
|||
} |
|||
|
|||
users.push( |
|||
stripUserOfSensitiveStuff(user), |
|||
); |
|||
|
|||
await app.datastore.updateJson( |
|||
USERS_LIST_FILE, |
|||
users, |
|||
); |
|||
|
|||
try { |
|||
await app.datastore.createJson( |
|||
userAuthFile(user.name), |
|||
auth, |
|||
); |
|||
} catch (_) { |
|||
await app.datastore.updateJson( |
|||
userAuthFile(user.name), |
|||
auth, |
|||
); |
|||
} |
|||
|
|||
await releaseLock(app, lock); |
|||
|
|||
return user; |
|||
}; |
|||
|
|||
const getAccess = async (app, password) => { |
|||
const auth = getNewUserAuth(app)(); |
|||
|
|||
if (isNonEmptyString(password)) { |
|||
if (isValidPassword(password)) { |
|||
auth.passwordHash = await app.crypto.hash(password); |
|||
auth.temporaryAccessHash = ''; |
|||
auth.temporaryAccessId = ''; |
|||
auth.temporaryAccessExpiryEpoch = 0; |
|||
return { auth }; |
|||
} |
|||
throw new BadRequestError('Password does not meet requirements'); |
|||
} else { |
|||
const tempAccess = await getTemporaryCode(app); |
|||
auth.temporaryAccessHash = tempAccess.temporaryAccessHash; |
|||
auth.temporaryAccessExpiryEpoch = tempAccess.temporaryAccessExpiryEpoch; |
|||
auth.passwordHash = ''; |
|||
return ({ |
|||
auth, |
|||
tempCode: tempAccess.tempCode, |
|||
temporaryAccessId: tempAccess.temporaryAccessId, |
|||
}); |
|||
} |
|||
}; |
|||
@ -0,0 +1,49 @@ |
|||
import { |
|||
getLock, |
|||
isNolock, releaseLock, |
|||
} from '../common/lock'; |
|||
import { USERS_LOCK_FILE, USERS_LIST_FILE, getUserByName } from './authCommon'; |
|||
import { apiWrapper, events } from '../common'; |
|||
import { permission } from './permissions'; |
|||
import { NotFoundError } from '../common/errors'; |
|||
|
|||
export const enableUser = app => async username => apiWrapper( |
|||
app, |
|||
events.authApi.enableUser, |
|||
permission.enableDisableUser.isAuthorized, |
|||
{ username }, |
|||
_enableUser, app, username, |
|||
); |
|||
|
|||
export const disableUser = app => async username => apiWrapper( |
|||
app, |
|||
events.authApi.disableUser, |
|||
permission.enableDisableUser.isAuthorized, |
|||
{ username }, |
|||
_disableUser, app, username, |
|||
); |
|||
|
|||
export const _enableUser = async (app, username) => await toggleUser(app, username, true); |
|||
|
|||
export const _disableUser = async (app, username) => await toggleUser(app, username, false); |
|||
|
|||
const toggleUser = async (app, username, enabled) => { |
|||
const lock = await getLock(app, USERS_LOCK_FILE, 1000, 1, 0); |
|||
|
|||
const actionName = enabled ? 'enable' : 'disable'; |
|||
|
|||
if (isNolock(lock)) { throw new Error(`Could not ${actionName} user - cannot get lock`); } |
|||
|
|||
try { |
|||
const users = await app.datastore.loadJson(USERS_LIST_FILE); |
|||
const user = getUserByName(users, username); |
|||
if (!user) { throw new NotFoundError(`Could not find user to ${actionName}`); } |
|||
|
|||
if (user.enabled === !enabled) { |
|||
user.enabled = enabled; |
|||
await app.datastore.updateJson(USERS_LIST_FILE, users); |
|||
} |
|||
} finally { |
|||
releaseLock(app, lock); |
|||
} |
|||
}; |
|||
@ -0,0 +1,45 @@ |
|||
import { |
|||
filter, values, each, keys, |
|||
} from 'lodash/fp'; |
|||
import { permission } from './permissions'; |
|||
import { |
|||
getFlattenedHierarchy, |
|||
isIndex, isRecord, |
|||
} from '../templateApi/hierarchy'; |
|||
import { $ } from '../common'; |
|||
|
|||
export const generateFullPermissions = (app) => { |
|||
const allNodes = getFlattenedHierarchy(app.hierarchy); |
|||
const accessLevel = { permissions: [] }; |
|||
|
|||
const recordNodes = $(allNodes, [ |
|||
filter(isRecord), |
|||
]); |
|||
|
|||
for (const n of recordNodes) { |
|||
permission.createRecord.add(n.nodeKey(), accessLevel); |
|||
permission.updateRecord.add(n.nodeKey(), accessLevel); |
|||
permission.deleteRecord.add(n.nodeKey(), accessLevel); |
|||
permission.readRecord.add(n.nodeKey(), accessLevel); |
|||
} |
|||
|
|||
const indexNodes = $(allNodes, [ |
|||
filter(isIndex), |
|||
]); |
|||
|
|||
for (const n of indexNodes) { |
|||
permission.readIndex.add(n.nodeKey(), accessLevel); |
|||
} |
|||
|
|||
for (const a of keys(app.actions)) { |
|||
permission.executeAction.add(a, accessLevel); |
|||
} |
|||
|
|||
$(permission, [ |
|||
values, |
|||
filter(p => !p.isNode), |
|||
each(p => p.add(accessLevel)), |
|||
]); |
|||
|
|||
return accessLevel.permissions; |
|||
}; |
|||
@ -0,0 +1,5 @@ |
|||
export const getNewAccessLevel = () => () => ({ |
|||
name: '', |
|||
permissions: [], |
|||
default:false |
|||
}); |
|||
@ -0,0 +1,31 @@ |
|||
import { apiWrapperSync, events } from '../common'; |
|||
import { permission } from './permissions'; |
|||
|
|||
export const getNewUser = app => () => apiWrapperSync( |
|||
app, |
|||
events.authApi.getNewUser, |
|||
permission.createUser.isAuthorized, |
|||
{}, |
|||
_getNewUser, app, |
|||
); |
|||
|
|||
export const _getNewUser = () => ({ |
|||
name: '', |
|||
accessLevels: [], |
|||
enabled: true, |
|||
temporaryAccessId: '', |
|||
}); |
|||
|
|||
export const getNewUserAuth = app => () => apiWrapperSync( |
|||
app, |
|||
events.authApi.getNewUserAuth, |
|||
permission.createUser.isAuthorized, |
|||
{}, |
|||
_getNewUserAuth, app, |
|||
); |
|||
|
|||
export const _getNewUserAuth = () => ({ |
|||
passwordHash: '', |
|||
temporaryAccessHash: '', |
|||
temporaryAccessExpiryEpoch: 0, |
|||
}); |
|||
@ -0,0 +1,19 @@ |
|||
import { map } from 'lodash/fp'; |
|||
import { |
|||
USERS_LIST_FILE, |
|||
stripUserOfSensitiveStuff, |
|||
} from './authCommon'; |
|||
import { $, apiWrapper, events } from '../common'; |
|||
import { permission } from './permissions'; |
|||
|
|||
export const getUsers = app => async () => apiWrapper( |
|||
app, |
|||
events.authApi.getUsers, |
|||
permission.listUsers.isAuthorized, |
|||
{}, |
|||
_getUsers, app, |
|||
); |
|||
|
|||
export const _getUsers = async app => $(await app.datastore.loadJson(USERS_LIST_FILE), [ |
|||
map(stripUserOfSensitiveStuff), |
|||
]); |
|||
@ -0,0 +1,48 @@ |
|||
import { |
|||
authenticate, |
|||
authenticateTemporaryAccess, |
|||
} from './authenticate'; |
|||
import { createTemporaryAccess } from './createTemporaryAccess'; |
|||
import { createUser } from './createUser'; |
|||
import { enableUser, disableUser } from './enableUser'; |
|||
import { loadAccessLevels } from './loadAccessLevels'; |
|||
import { getNewAccessLevel } from './getNewAccessLevel'; |
|||
import { getNewUser, getNewUserAuth } from './getNewUser'; |
|||
import { getUsers } from './getUsers'; |
|||
import { isAuthorized } from './isAuthorized'; |
|||
import { saveAccessLevels } from './saveAccessLevels'; |
|||
import { |
|||
changeMyPassword, |
|||
scorePassword, setPasswordFromTemporaryCode, |
|||
isValidPassword, |
|||
} from './setPassword'; |
|||
import { validateUser } from './validateUser'; |
|||
import { validateAccessLevels } from './validateAccessLevels'; |
|||
import { generateFullPermissions } from './generateFullPermissions'; |
|||
import { setUserAccessLevels } from './setUserAccessLevels'; |
|||
|
|||
export const getAuthApi = app => ({ |
|||
authenticate: authenticate(app), |
|||
authenticateTemporaryAccess: authenticateTemporaryAccess(app), |
|||
createTemporaryAccess: createTemporaryAccess(app), |
|||
createUser: createUser(app), |
|||
loadAccessLevels: loadAccessLevels(app), |
|||
enableUser: enableUser(app), |
|||
disableUser: disableUser(app), |
|||
getNewAccessLevel: getNewAccessLevel(app), |
|||
getNewUser: getNewUser(app), |
|||
getNewUserAuth: getNewUserAuth(app), |
|||
getUsers: getUsers(app), |
|||
saveAccessLevels: saveAccessLevels(app), |
|||
isAuthorized: isAuthorized(app), |
|||
changeMyPassword: changeMyPassword(app), |
|||
setPasswordFromTemporaryCode: setPasswordFromTemporaryCode(app), |
|||
scorePassword, |
|||
isValidPassword: isValidPassword(app), |
|||
validateUser: validateUser(app), |
|||
validateAccessLevels: validateAccessLevels(app), |
|||
generateFullPermissions: () => generateFullPermissions(app), |
|||
setUserAccessLevels: setUserAccessLevels(app), |
|||
}); |
|||
|
|||
export default getAuthApi; |
|||
@ -0,0 +1,50 @@ |
|||
import { values, includes, some } from 'lodash/fp'; |
|||
import { permissionTypes } from './authCommon'; |
|||
import { |
|||
$, isNothing, apiWrapperSync, events, |
|||
} from '../common'; |
|||
import { getNodeByKeyOrNodeKey, isNode } from '../templateApi/hierarchy'; |
|||
import { alwaysAuthorized } from './permissions'; |
|||
|
|||
export const isAuthorized = app => (permissionType, resourceKey) => apiWrapperSync( |
|||
app, |
|||
events.authApi.isAuthorized, |
|||
alwaysAuthorized, |
|||
{ resourceKey, permissionType }, |
|||
_isAuthorized, app, permissionType, resourceKey, |
|||
); |
|||
|
|||
export const _isAuthorized = (app, permissionType, resourceKey) => { |
|||
if (!app.user) { |
|||
return false; |
|||
} |
|||
|
|||
const validType = $(permissionTypes, [ |
|||
values, |
|||
includes(permissionType), |
|||
]); |
|||
|
|||
if (!validType) { |
|||
return false; |
|||
} |
|||
|
|||
const permMatchesResource = (userperm) => { |
|||
const nodeKey = isNothing(resourceKey) |
|||
? null |
|||
: isNode(app.hierarchy, resourceKey) |
|||
? getNodeByKeyOrNodeKey( |
|||
app.hierarchy, resourceKey, |
|||
).nodeKey() |
|||
: resourceKey; |
|||
|
|||
return (userperm.type === permissionType) |
|||
&& ( |
|||
isNothing(resourceKey) |
|||
|| nodeKey === userperm.nodeKey |
|||
); |
|||
}; |
|||
|
|||
return $(app.user.permissions, [ |
|||
some(permMatchesResource), |
|||
]); |
|||
}; |
|||
@ -0,0 +1,13 @@ |
|||
import { ACCESS_LEVELS_FILE } from './authCommon'; |
|||
import { apiWrapper, events } from '../common'; |
|||
import { permission } from './permissions'; |
|||
|
|||
export const loadAccessLevels = app => async () => apiWrapper( |
|||
app, |
|||
events.authApi.loadAccessLevels, |
|||
permission.listAccessLevels.isAuthorized, |
|||
{}, |
|||
_loadAccessLevels, app, |
|||
); |
|||
|
|||
export const _loadAccessLevels = async app => await app.datastore.loadJson(ACCESS_LEVELS_FILE); |
|||
@ -0,0 +1,74 @@ |
|||
import { permissionTypes } from './authCommon'; |
|||
import { isAuthorized } from './isAuthorized'; |
|||
|
|||
export const temporaryAccessPermissions = () => ([{ type: permissionTypes.SET_PASSWORD }]); |
|||
|
|||
const nodePermission = type => ({ |
|||
add: (nodeKey, accessLevel) => accessLevel.permissions.push({ type, nodeKey }), |
|||
isAuthorized: resourceKey => app => isAuthorized(app)(type, resourceKey), |
|||
isNode: true, |
|||
get: nodeKey => ({ type, nodeKey }), |
|||
}); |
|||
|
|||
const staticPermission = type => ({ |
|||
add: accessLevel => accessLevel.permissions.push({ type }), |
|||
isAuthorized: app => isAuthorized(app)(type), |
|||
isNode: false, |
|||
get: () => ({ type }), |
|||
}); |
|||
|
|||
const createRecord = nodePermission(permissionTypes.CREATE_RECORD); |
|||
|
|||
const updateRecord = nodePermission(permissionTypes.UPDATE_RECORD); |
|||
|
|||
const deleteRecord = nodePermission(permissionTypes.DELETE_RECORD); |
|||
|
|||
const readRecord = nodePermission(permissionTypes.READ_RECORD); |
|||
|
|||
const writeTemplates = staticPermission(permissionTypes.WRITE_TEMPLATES); |
|||
|
|||
const createUser = staticPermission(permissionTypes.CREATE_USER); |
|||
|
|||
const setPassword = staticPermission(permissionTypes.SET_PASSWORD); |
|||
|
|||
const readIndex = nodePermission(permissionTypes.READ_INDEX); |
|||
|
|||
const manageIndex = staticPermission(permissionTypes.MANAGE_INDEX); |
|||
|
|||
const manageCollection = staticPermission(permissionTypes.MANAGE_COLLECTION); |
|||
|
|||
const createTemporaryAccess = staticPermission(permissionTypes.CREATE_TEMPORARY_ACCESS); |
|||
|
|||
const enableDisableUser = staticPermission(permissionTypes.ENABLE_DISABLE_USER); |
|||
|
|||
const writeAccessLevels = staticPermission(permissionTypes.WRITE_ACCESS_LEVELS); |
|||
|
|||
const listUsers = staticPermission(permissionTypes.LIST_USERS); |
|||
|
|||
const listAccessLevels = staticPermission(permissionTypes.LIST_ACCESS_LEVELS); |
|||
|
|||
const setUserAccessLevels = staticPermission(permissionTypes.SET_USER_ACCESS_LEVELS); |
|||
|
|||
const executeAction = nodePermission(permissionTypes.EXECUTE_ACTION); |
|||
|
|||
export const alwaysAuthorized = () => true; |
|||
|
|||
export const permission = { |
|||
createRecord, |
|||
updateRecord, |
|||
deleteRecord, |
|||
readRecord, |
|||
writeTemplates, |
|||
createUser, |
|||
setPassword, |
|||
readIndex, |
|||
createTemporaryAccess, |
|||
enableDisableUser, |
|||
writeAccessLevels, |
|||
listUsers, |
|||
listAccessLevels, |
|||
manageIndex, |
|||
manageCollection, |
|||
executeAction, |
|||
setUserAccessLevels, |
|||
}; |
|||
@ -0,0 +1,49 @@ |
|||
import { join, map } from 'lodash/fp'; |
|||
import { |
|||
getLock, releaseLock, $, |
|||
isNolock, apiWrapper, events, |
|||
} from '../common'; |
|||
import { |
|||
ACCESS_LEVELS_LOCK_FILE, |
|||
ACCESS_LEVELS_FILE, |
|||
} from './authCommon'; |
|||
import { validateAccessLevels } from './validateAccessLevels'; |
|||
import { permission } from './permissions'; |
|||
|
|||
export const saveAccessLevels = app => async accessLevels => apiWrapper( |
|||
app, |
|||
events.authApi.saveAccessLevels, |
|||
permission.writeAccessLevels.isAuthorized, |
|||
{ accessLevels }, |
|||
_saveAccessLevels, app, accessLevels, |
|||
); |
|||
|
|||
export const _saveAccessLevels = async (app, accessLevels) => { |
|||
const validationErrors = validateAccessLevels(app)(accessLevels.levels); |
|||
if (validationErrors.length > 0) { |
|||
const errs = $(validationErrors, [ |
|||
map(e => e.error), |
|||
join(', '), |
|||
]); |
|||
throw new Error( |
|||
`Access Levels Invalid: ${errs}`, |
|||
); |
|||
} |
|||
|
|||
const lock = await getLock( |
|||
app, ACCESS_LEVELS_LOCK_FILE, 2000, 2, |
|||
); |
|||
|
|||
if (isNolock(lock)) { throw new Error('Could not get lock to save access levels'); } |
|||
|
|||
try { |
|||
const existing = await app.datastore.loadJson(ACCESS_LEVELS_FILE); |
|||
if (existing.version !== accessLevels.version) { throw new Error('Access levels have already been updated, since you loaded'); } |
|||
|
|||
accessLevels.version++; |
|||
|
|||
app.datastore.updateJson(ACCESS_LEVELS_FILE, accessLevels); |
|||
} finally { |
|||
await releaseLock(app, lock); |
|||
} |
|||
}; |
|||
@ -0,0 +1,153 @@ |
|||
import { find } from 'lodash/fp'; |
|||
import { userAuthFile, parseTemporaryCode } from './authCommon'; |
|||
import { |
|||
isSomething, $, apiWrapper, apiWrapperSync, events, |
|||
} from '../common'; |
|||
import { _getUsers } from './getUsers'; |
|||
import { alwaysAuthorized } from './permissions'; |
|||
|
|||
export const isValidPassword = app => password => apiWrapperSync( |
|||
app, |
|||
events.authApi.isValidPassword, |
|||
alwaysAuthorized, |
|||
{ password }, |
|||
_isValidPassword, app, password, |
|||
); |
|||
|
|||
export const _isValidPassword = (app, password) => scorePassword(password).score > 30; |
|||
|
|||
export const changeMyPassword = app => async (currentPw, newpassword) => apiWrapper( |
|||
app, |
|||
events.authApi.changeMyPassword, |
|||
alwaysAuthorized, |
|||
{ currentPw, newpassword }, |
|||
_changeMyPassword, app, currentPw, newpassword, |
|||
); |
|||
|
|||
export const _changeMyPassword = async (app, currentPw, newpassword) => { |
|||
const existingAuth = await app.datastore.loadJson( |
|||
userAuthFile(app.user.name), |
|||
); |
|||
|
|||
if (isSomething(existingAuth.passwordHash)) { |
|||
const verified = await app.crypto.verify( |
|||
existingAuth.passwordHash, |
|||
currentPw, |
|||
); |
|||
|
|||
if (verified) { |
|||
await await doSet( |
|||
app, existingAuth, |
|||
app.user.name, newpassword, |
|||
); |
|||
return true; |
|||
} |
|||
} |
|||
|
|||
return false; |
|||
}; |
|||
|
|||
export const setPasswordFromTemporaryCode = app => async (tempCode, newpassword) => apiWrapper( |
|||
app, |
|||
events.authApi.setPasswordFromTemporaryCode, |
|||
alwaysAuthorized, |
|||
{ tempCode, newpassword }, |
|||
_setPasswordFromTemporaryCode, app, tempCode, newpassword, |
|||
); |
|||
|
|||
|
|||
export const _setPasswordFromTemporaryCode = async (app, tempCode, newpassword) => { |
|||
const currentTime = await app.getEpochTime(); |
|||
|
|||
const temp = parseTemporaryCode(tempCode); |
|||
|
|||
const user = $(await _getUsers(app), [ |
|||
find(u => u.temporaryAccessId === temp.id), |
|||
]); |
|||
|
|||
if (!user) { return false; } |
|||
|
|||
const existingAuth = await app.datastore.loadJson( |
|||
userAuthFile(user.name), |
|||
); |
|||
|
|||
if (isSomething(existingAuth.temporaryAccessHash) |
|||
&& existingAuth.temporaryAccessExpiryEpoch > currentTime) { |
|||
const verified = await app.crypto.verify( |
|||
existingAuth.temporaryAccessHash, |
|||
temp.code, |
|||
); |
|||
|
|||
if (verified) { |
|||
await doSet( |
|||
app, existingAuth, |
|||
user.name, newpassword, |
|||
); |
|||
return true; |
|||
} |
|||
} |
|||
|
|||
return false; |
|||
}; |
|||
|
|||
const doSet = async (app, auth, username, newpassword) => { |
|||
auth.temporaryAccessHash = ''; |
|||
auth.temporaryAccessExpiryEpoch = 0; |
|||
auth.passwordHash = await app.crypto.hash( |
|||
newpassword, |
|||
); |
|||
await app.datastore.updateJson( |
|||
userAuthFile(username), |
|||
auth, |
|||
); |
|||
}; |
|||
|
|||
export const scorePassword = app => password => apiWrapperSync( |
|||
app, |
|||
events.authApi.scorePassword, |
|||
alwaysAuthorized, |
|||
{ password }, |
|||
_scorePassword, password, |
|||
); |
|||
|
|||
export const _scorePassword = (password) => { |
|||
// from https://stackoverflow.com/questions/948172/password-strength-meter
|
|||
// thank you https://stackoverflow.com/users/46617/tm-lv
|
|||
|
|||
let score = 0; |
|||
if (!password) { return score; } |
|||
|
|||
// award every unique letter until 5 repetitions
|
|||
const letters = new Object(); |
|||
for (let i = 0; i < password.length; i++) { |
|||
letters[password[i]] = (letters[password[i]] || 0) + 1; |
|||
score += 5.0 / letters[password[i]]; |
|||
} |
|||
|
|||
// bonus points for mixing it up
|
|||
const variations = { |
|||
digits: /\d/.test(password), |
|||
lower: /[a-z]/.test(password), |
|||
upper: /[A-Z]/.test(password), |
|||
nonWords: /\W/.test(password), |
|||
}; |
|||
|
|||
let variationCount = 0; |
|||
for (const check in variations) { |
|||
variationCount += (variations[check] == true) ? 1 : 0; |
|||
} |
|||
score += (variationCount - 1) * 10; |
|||
|
|||
const strengthText = score > 80 |
|||
? 'strong' |
|||
: score > 60 |
|||
? 'good' |
|||
: score >= 30 |
|||
? 'weak' |
|||
: 'very weak'; |
|||
|
|||
return { |
|||
score: parseInt(score), |
|||
strengthText, |
|||
}; |
|||
}; |
|||
@ -0,0 +1,49 @@ |
|||
import { difference, map, join } from 'lodash/fp'; |
|||
import { |
|||
getLock, isNolock, releaseLock, $, |
|||
apiWrapper, events, |
|||
} from '../common'; |
|||
import { |
|||
USERS_LOCK_FILE, ACCESS_LEVELS_FILE, |
|||
getUserByName, USERS_LIST_FILE, |
|||
} from './authCommon'; |
|||
import { permission } from './permissions'; |
|||
import { NotFoundError } from '../common/errors'; |
|||
|
|||
export const setUserAccessLevels = app => async (userName, accessLevels) => apiWrapper( |
|||
app, |
|||
events.authApi.setUserAccessLevels, |
|||
permission.setUserAccessLevels.isAuthorized, |
|||
{ userName, accessLevels }, |
|||
_setUserAccessLevels, app, userName, accessLevels, |
|||
); |
|||
|
|||
export const _setUserAccessLevels = async (app, username, accessLevels) => { |
|||
const lock = await getLock(app, USERS_LOCK_FILE, 1000, 1, 0); |
|||
|
|||
const actualAccessLevels = $( |
|||
await app.datastore.loadJson(ACCESS_LEVELS_FILE), |
|||
[ |
|||
l => l.levels, |
|||
map(l => l.name), |
|||
], |
|||
); |
|||
|
|||
const missing = difference(accessLevels)(actualAccessLevels); |
|||
if (missing.length > 0) { |
|||
throw new Error(`Invalid access levels supplied: ${join(', ', missing)}`); |
|||
} |
|||
|
|||
if (isNolock(lock)) { throw new Error('Could set user access levels cannot get lock'); } |
|||
|
|||
try { |
|||
const users = await app.datastore.loadJson(USERS_LIST_FILE); |
|||
const user = getUserByName(users, username); |
|||
if (!user) { throw new NotFoundError(`Could not find user with ${username}`); } |
|||
|
|||
user.accessLevels = accessLevels; |
|||
await app.datastore.updateJson(USERS_LIST_FILE, users); |
|||
} finally { |
|||
releaseLock(app, lock); |
|||
} |
|||
}; |
|||
@ -0,0 +1,75 @@ |
|||
import { |
|||
values, includes, map, concat, isEmpty, uniqWith, some, |
|||
flatten, filter, |
|||
} from 'lodash/fp'; |
|||
import { applyRuleSet, makerule } from '../common/validationCommon'; |
|||
import { permissionTypes } from './authCommon'; |
|||
import { |
|||
$, isSomething, insensitiveEquals, |
|||
isNonEmptyString, apiWrapperSync, events, |
|||
} from '../common'; |
|||
import { getNode } from '../templateApi/hierarchy'; |
|||
import { alwaysAuthorized } from './permissions'; |
|||
|
|||
const isAllowedType = t => $(permissionTypes, [ |
|||
values, |
|||
includes(t), |
|||
]); |
|||
|
|||
const isRecordOrIndexType = t => some(p => p === t)([ |
|||
permissionTypes.CREATE_RECORD, |
|||
permissionTypes.UPDATE_RECORD, |
|||
permissionTypes.DELETE_RECORD, |
|||
permissionTypes.READ_RECORD, |
|||
permissionTypes.READ_INDEX, |
|||
permissionTypes.EXECUTE_ACTION, |
|||
]); |
|||
|
|||
|
|||
const permissionRules = app => ([ |
|||
makerule('type', 'type must be one of allowed types', |
|||
p => isAllowedType(p.type)), |
|||
makerule('nodeKey', 'record and index permissions must include a valid nodeKey', |
|||
p => (!isRecordOrIndexType(p.type)) |
|||
|| isSomething(getNode(app.hierarchy, p.nodeKey))), |
|||
]); |
|||
|
|||
const applyPermissionRules = app => applyRuleSet(permissionRules(app)); |
|||
|
|||
const accessLevelRules = allLevels => ([ |
|||
makerule('name', 'name must be set', |
|||
l => isNonEmptyString(l.name)), |
|||
makerule('name', 'access level names must be unique', |
|||
l => isEmpty(l.name) |
|||
|| filter(a => insensitiveEquals(l.name, a.name))(allLevels).length === 1), |
|||
]); |
|||
|
|||
const applyLevelRules = allLevels => applyRuleSet(accessLevelRules(allLevels)); |
|||
|
|||
export const validateAccessLevel = app => (allLevels, level) => { |
|||
const errs = $(level.permissions, [ |
|||
map(applyPermissionRules(app)), |
|||
flatten, |
|||
concat( |
|||
applyLevelRules(allLevels)(level), |
|||
), |
|||
]); |
|||
|
|||
return errs; |
|||
}; |
|||
|
|||
export const validateAccessLevels = app => allLevels => apiWrapperSync( |
|||
app, |
|||
events.authApi.validateAccessLevels, |
|||
alwaysAuthorized, |
|||
{ allLevels }, |
|||
_validateAccessLevels, app, allLevels, |
|||
); |
|||
|
|||
export const _validateAccessLevels = (app, allLevels) => $(allLevels, [ |
|||
map(l => validateAccessLevel(app)(allLevels, l)), |
|||
flatten, |
|||
uniqWith((x, y) => x.field === y.field |
|||
&& x.item === y.item |
|||
&& x.error === y.error), |
|||
]); |
|||
@ -0,0 +1,39 @@ |
|||
import { |
|||
map, uniqWith, |
|||
flatten, filter, |
|||
} from 'lodash/fp'; |
|||
import { applyRuleSet, makerule } from '../common/validationCommon'; |
|||
import { |
|||
$, insensitiveEquals, apiWrapper, events, |
|||
isNonEmptyString, all, |
|||
} from '../common'; |
|||
import { alwaysAuthorized } from './permissions'; |
|||
|
|||
const userRules = allUsers => [ |
|||
makerule('name', 'username must be set', |
|||
u => isNonEmptyString(u.name)), |
|||
makerule('accessLevels', 'user must have at least one access level', |
|||
u => u.accessLevels.length > 0), |
|||
makerule('name', 'username must be unique', |
|||
u => filter(u2 => insensitiveEquals(u2.name, u.name))(allUsers).length === 1), |
|||
makerule('accessLevels', 'access levels must only contain stings', |
|||
u => all(isNonEmptyString)(u.accessLevels)), |
|||
]; |
|||
|
|||
export const validateUser = () => (allusers, user) => applyRuleSet(userRules(allusers))(user); |
|||
|
|||
export const validateUsers = app => allUsers => apiWrapper( |
|||
app, |
|||
events.authApi.validateUsers, |
|||
alwaysAuthorized, |
|||
{ allUsers }, |
|||
_validateUsers, app, allUsers, |
|||
); |
|||
|
|||
export const _validateUsers = (app, allUsers) => $(allUsers, [ |
|||
map(l => validateUser(app)(allUsers, l)), |
|||
flatten, |
|||
uniqWith((x, y) => x.field === y.field |
|||
&& x.item === y.item |
|||
&& x.error === y.error), |
|||
]); |
|||
@ -0,0 +1,79 @@ |
|||
import { includes } from 'lodash/fp'; |
|||
import { getNodeForCollectionPath } from '../templateApi/hierarchy'; |
|||
import { |
|||
safeKey, apiWrapper, |
|||
events, joinKey, |
|||
} from '../common'; |
|||
import { _deleteRecord } from '../recordApi/delete'; |
|||
import { getAllIdsIterator, getAllIdsShardKey } from '../indexing/allIds'; |
|||
import { permission } from '../authApi/permissions'; |
|||
|
|||
export const deleteCollection = (app, disableCleanup = false) => async key => apiWrapper( |
|||
app, |
|||
events.collectionApi.delete, |
|||
permission.manageCollection.isAuthorized, |
|||
{ key }, |
|||
_deleteCollection, app, key, disableCleanup, |
|||
); |
|||
|
|||
|
|||
export const _deleteCollection = async (app, key, disableCleanup) => { |
|||
key = safeKey(key); |
|||
const node = getNodeForCollectionPath(app.hierarchy)(key); |
|||
|
|||
await deleteRecords(app, key); |
|||
await deleteAllIdsFolders(app, node, key); |
|||
await deleteCollectionFolder(app, key); |
|||
if (!disableCleanup) { await app.cleanupTransactions(); } |
|||
}; |
|||
|
|||
const deleteCollectionFolder = async (app, key) => await app.datastore.deleteFolder(key); |
|||
|
|||
|
|||
const deleteAllIdsFolders = async (app, node, key) => { |
|||
await app.datastore.deleteFolder( |
|||
joinKey( |
|||
key, 'allids', |
|||
node.nodeId, |
|||
), |
|||
); |
|||
|
|||
await app.datastore.deleteFolder( |
|||
joinKey(key, 'allids'), |
|||
); |
|||
}; |
|||
|
|||
const deleteRecords = async (app, key) => { |
|||
const deletedAllIdsShards = []; |
|||
const deleteAllIdsShard = async (recordId) => { |
|||
const shardKey = getAllIdsShardKey( |
|||
app.hierarchy, key, recordId, |
|||
); |
|||
|
|||
if (includes(shardKey)(deletedAllIdsShards)) { |
|||
return; |
|||
} |
|||
|
|||
deletedAllIdsShards.push(shardKey); |
|||
|
|||
await app.datastore.deleteFile(shardKey); |
|||
}; |
|||
|
|||
const iterate = await getAllIdsIterator(app)(key); |
|||
|
|||
let ids = await iterate(); |
|||
while (!ids.done) { |
|||
if (ids.result.collectionKey === key) { |
|||
for (const id of ids.result.ids) { |
|||
await _deleteRecord( |
|||
app, |
|||
joinKey(key, id), |
|||
true, |
|||
); |
|||
await deleteAllIdsShard(id); |
|||
} |
|||
} |
|||
|
|||
ids = await iterate(); |
|||
} |
|||
}; |
|||
@ -0,0 +1,19 @@ |
|||
import { getNodeForCollectionPath } from '../templateApi/hierarchy'; |
|||
import { |
|||
isNothing, safeKey, apiWrapperSync, events, |
|||
} from '../common'; |
|||
import { alwaysAuthorized } from '../authApi/permissions'; |
|||
|
|||
export const getAllowedRecordTypes = app => key => apiWrapperSync( |
|||
app, |
|||
events.collectionApi.getAllowedRecordTypes, |
|||
alwaysAuthorized, |
|||
{ key }, |
|||
_getAllowedRecordTypes, app, key, |
|||
); |
|||
|
|||
const _getAllowedRecordTypes = (app, key) => { |
|||
key = safeKey(key); |
|||
const node = getNodeForCollectionPath(app.hierarchy)(key); |
|||
return isNothing(node) ? [] : [node.name]; |
|||
}; |
|||
@ -0,0 +1,11 @@ |
|||
import { getAllIdsIterator } from '../indexing/allIds'; |
|||
import { getAllowedRecordTypes } from './getAllowedRecordTypes'; |
|||
import { deleteCollection } from './delete'; |
|||
|
|||
export const getCollectionApi = app => ({ |
|||
getAllowedRecordTypes: getAllowedRecordTypes(app), |
|||
getAllIdsIterator: getAllIdsIterator(app), |
|||
delete: deleteCollection(app), |
|||
}); |
|||
|
|||
export default getCollectionApi; |
|||
@ -0,0 +1,61 @@ |
|||
import { filter } from 'lodash/fp'; |
|||
import { |
|||
getFlattenedHierarchy, |
|||
isCollectionRecord, |
|||
isRoot, |
|||
getExactNodeForPath, |
|||
} from '../templateApi/hierarchy'; |
|||
import { $, allTrue, joinKey } from '../common'; |
|||
|
|||
const ensureCollectionIsInitialised = async (datastore, node, parentKey) => { |
|||
if (!await datastore.exists(parentKey)) { |
|||
await datastore.createFolder(parentKey); |
|||
await datastore.createFolder( |
|||
joinKey(parentKey, 'allids'), |
|||
); |
|||
await datastore.createFolder( |
|||
joinKey( |
|||
parentKey, |
|||
'allids', |
|||
node.nodeId.toString(), |
|||
), |
|||
); |
|||
} |
|||
}; |
|||
|
|||
export const initialiseRootCollections = async (datastore, hierarchy) => { |
|||
const rootCollectionRecord = allTrue( |
|||
n => isRoot(n.parent()), |
|||
isCollectionRecord, |
|||
); |
|||
|
|||
const flathierarchy = getFlattenedHierarchy(hierarchy); |
|||
|
|||
const collectionRecords = $(flathierarchy, [ |
|||
filter(rootCollectionRecord), |
|||
]); |
|||
|
|||
for (const col of collectionRecords) { |
|||
await ensureCollectionIsInitialised( |
|||
datastore, |
|||
col, |
|||
col.collectionPathRegx(), |
|||
); |
|||
} |
|||
}; |
|||
|
|||
export const initialiseChildCollections = async (app, recordKey) => { |
|||
const childCollectionRecords = $(recordKey, [ |
|||
getExactNodeForPath(app.hierarchy), |
|||
n => n.children, |
|||
filter(isCollectionRecord), |
|||
]); |
|||
|
|||
for (const child of childCollectionRecords) { |
|||
await ensureCollectionIsInitialised( |
|||
app.datastore, |
|||
child, |
|||
joinKey(recordKey, child.collectionName), |
|||
); |
|||
} |
|||
}; |
|||
@ -0,0 +1,116 @@ |
|||
import { cloneDeep, isUndefined } from 'lodash/fp'; |
|||
import { generate } from 'shortid'; |
|||
import { UnauthorisedError } from './errors'; |
|||
|
|||
export const apiWrapper = async (app, eventNamespace, isAuthorized, eventContext, func, ...params) => { |
|||
pushCallStack(app, eventNamespace); |
|||
|
|||
if (!isAuthorized(app)) { |
|||
handleNotAuthorized(app, eventContext, eventNamespace); |
|||
return; |
|||
} |
|||
|
|||
const startDate = Date.now(); |
|||
const elapsed = () => (Date.now() - startDate); |
|||
|
|||
try { |
|||
await app.publish( |
|||
eventNamespace.onBegin, |
|||
eventContext, |
|||
); |
|||
|
|||
const result = await func(...params); |
|||
|
|||
await publishComplete(app, eventContext, eventNamespace, elapsed, result); |
|||
return result; |
|||
} catch (error) { |
|||
await publishError(app, eventContext, eventNamespace, elapsed, error); |
|||
throw error; |
|||
} |
|||
}; |
|||
|
|||
export const apiWrapperSync = (app, eventNamespace, isAuthorized, eventContext, func, ...params) => { |
|||
pushCallStack(app, eventNamespace); |
|||
|
|||
if (!isAuthorized(app)) { |
|||
handleNotAuthorized(app, eventContext, eventNamespace); |
|||
return; |
|||
} |
|||
|
|||
const startDate = Date.now(); |
|||
const elapsed = () => (Date.now() - startDate); |
|||
|
|||
try { |
|||
app.publish( |
|||
eventNamespace.onBegin, |
|||
eventContext, |
|||
); |
|||
|
|||
const result = func(...params); |
|||
|
|||
publishComplete(app, eventContext, eventNamespace, elapsed, result); |
|||
return result; |
|||
} catch (error) { |
|||
publishError(app, eventContext, eventNamespace, elapsed, error); |
|||
throw error; |
|||
} |
|||
}; |
|||
|
|||
const handleNotAuthorized = (app, eventContext, eventNamespace) => { |
|||
const err = new UnauthorisedError(`Unauthorized: ${eventNamespace}`); |
|||
publishError(app, eventContext, eventNamespace, () => 0, err); |
|||
throw err; |
|||
}; |
|||
|
|||
const pushCallStack = (app, eventNamespace, seedCallId) => { |
|||
const callId = generate(); |
|||
|
|||
const createCallStack = () => ({ |
|||
seedCallId: !isUndefined(seedCallId) |
|||
? seedCallId |
|||
: callId, |
|||
threadCallId: callId, |
|||
stack: [], |
|||
}); |
|||
|
|||
if (isUndefined(app.calls)) { |
|||
app.calls = createCallStack(); |
|||
} |
|||
|
|||
app.calls.stack.push({ |
|||
namespace: eventNamespace, |
|||
callId, |
|||
}); |
|||
}; |
|||
|
|||
const popCallStack = (app) => { |
|||
app.calls.stack.pop(); |
|||
if (app.calls.stack.length === 0) { |
|||
delete app.calls; |
|||
} |
|||
}; |
|||
|
|||
const publishError = async (app, eventContext, eventNamespace, elapsed, err) => { |
|||
const ctx = cloneDeep(eventContext); |
|||
ctx.error = err; |
|||
ctx.elapsed = elapsed(); |
|||
await app.publish( |
|||
eventNamespace.onError, |
|||
ctx, |
|||
); |
|||
popCallStack(app); |
|||
}; |
|||
|
|||
const publishComplete = async (app, eventContext, eventNamespace, elapsed, result) => { |
|||
const endcontext = cloneDeep(eventContext); |
|||
endcontext.result = result; |
|||
endcontext.elapsed = elapsed(); |
|||
await app.publish( |
|||
eventNamespace.onComplete, |
|||
endcontext, |
|||
); |
|||
popCallStack(app); |
|||
return result; |
|||
}; |
|||
|
|||
export default apiWrapper; |
|||
@ -0,0 +1,30 @@ |
|||
import { |
|||
compileExpression as cExp, |
|||
compileCode as cCode |
|||
} from '@nx-js/compiler-util'; |
|||
|
|||
export const compileCode = code => { |
|||
let func; |
|||
|
|||
try { |
|||
func = cCode(code); |
|||
} catch(e) { |
|||
e.message = `Error compiling code : ${code} : ${e.message}`; |
|||
throw e; |
|||
} |
|||
|
|||
return func; |
|||
} |
|||
|
|||
export const compileExpression = code => { |
|||
let func; |
|||
|
|||
try { |
|||
func = cExp(code); |
|||
} catch(e) { |
|||
e.message = `Error compiling expression : ${code} : ${e.message}`; |
|||
throw e; |
|||
} |
|||
|
|||
return func; |
|||
} |
|||
@ -0,0 +1,34 @@ |
|||
export class BadRequestError extends Error { |
|||
constructor(message) { |
|||
super(message); |
|||
this.httpStatusCode = 400; |
|||
} |
|||
} |
|||
|
|||
export class UnauthorisedError extends Error { |
|||
constructor(message) { |
|||
super(message); |
|||
this.httpStatusCode = 401; |
|||
} |
|||
} |
|||
|
|||
export class ForbiddenError extends Error { |
|||
constructor(message) { |
|||
super(message); |
|||
this.httpStatusCode = 403; |
|||
} |
|||
} |
|||
|
|||
export class NotFoundError extends Error { |
|||
constructor(message) { |
|||
super(message); |
|||
this.httpStatusCode = 404; |
|||
} |
|||
} |
|||
|
|||
export class ConflictError extends Error { |
|||
constructor(message) { |
|||
super(message); |
|||
this.httpStatusCode = 409; |
|||
} |
|||
} |
|||
@ -0,0 +1,93 @@ |
|||
import { union, reduce } from 'lodash/fp'; |
|||
|
|||
const commonPlus = extra => union(['onBegin', 'onComplete', 'onError'])(extra); |
|||
|
|||
const common = () => commonPlus([]); |
|||
|
|||
const _events = { |
|||
recordApi: { |
|||
save: commonPlus([ |
|||
'onInvalid', |
|||
'onRecordUpdated', |
|||
'onRecordCreated']), |
|||
delete: common(), |
|||
getContext: common(), |
|||
getNew: common(), |
|||
load: common(), |
|||
validate: common(), |
|||
uploadFile: common(), |
|||
downloadFile: common(), |
|||
}, |
|||
indexApi: { |
|||
buildIndex: common(), |
|||
listItems: common(), |
|||
delete: common(), |
|||
aggregates: common(), |
|||
}, |
|||
collectionApi: { |
|||
getAllowedRecordTypes: common(), |
|||
initialise: common(), |
|||
delete: common(), |
|||
}, |
|||
authApi: { |
|||
authenticate: common(), |
|||
authenticateTemporaryAccess: common(), |
|||
createTemporaryAccess: common(), |
|||
createUser: common(), |
|||
enableUser: common(), |
|||
disableUser: common(), |
|||
loadAccessLevels: common(), |
|||
getNewAccessLevel: common(), |
|||
getNewUser: common(), |
|||
getNewUserAuth: common(), |
|||
getUsers: common(), |
|||
saveAccessLevels: common(), |
|||
isAuthorized: common(), |
|||
changeMyPassword: common(), |
|||
setPasswordFromTemporaryCode: common(), |
|||
scorePassword: common(), |
|||
isValidPassword: common(), |
|||
validateUser: common(), |
|||
validateAccessLevels: common(), |
|||
setUserAccessLevels: common(), |
|||
}, |
|||
templateApi: { |
|||
saveApplicationHierarchy: common(), |
|||
saveActionsAndTriggers: common(), |
|||
}, |
|||
actionsApi: { |
|||
execute: common(), |
|||
}, |
|||
}; |
|||
|
|||
const _eventsList = []; |
|||
|
|||
const makeEvent = (area, method, name) => `${area}:${method}:${name}`; |
|||
|
|||
for (const areaKey in _events) { |
|||
for (const methodKey in _events[areaKey]) { |
|||
_events[areaKey][methodKey] = reduce((obj, s) => { |
|||
obj[s] = makeEvent(areaKey, methodKey, s); |
|||
return obj; |
|||
}, |
|||
{})(_events[areaKey][methodKey]); |
|||
} |
|||
} |
|||
|
|||
|
|||
for (const areaKey in _events) { |
|||
for (const methodKey in _events[areaKey]) { |
|||
for (const name in _events[areaKey][methodKey]) { |
|||
_eventsList.push( |
|||
_events[areaKey][methodKey][name], |
|||
); |
|||
} |
|||
} |
|||
} |
|||
|
|||
|
|||
export const events = _events; |
|||
|
|||
export const eventsList = _eventsList; |
|||
|
|||
export default { events: _events, eventsList: _eventsList }; |
|||
@ -0,0 +1,265 @@ |
|||
import { |
|||
isUndefined, isNaN, isNull, |
|||
reduce, constant, head, isEmpty, |
|||
tail, findIndex, startsWith, join, |
|||
dropRight, flow, takeRight, trim, |
|||
split, includes, replace, isArray, |
|||
isString, isInteger, isDate, toNumber, |
|||
} from 'lodash'; |
|||
import { some } from 'lodash/fp'; |
|||
import { events, eventsList } from './events'; |
|||
import { apiWrapper } from './apiWrapper'; |
|||
import { |
|||
getLock, NO_LOCK, |
|||
isNolock |
|||
} from './lock'; |
|||
|
|||
// this is the combinator function
|
|||
export const $$ = (...funcs) => arg => flow(funcs)(arg); |
|||
|
|||
// this is the pipe function
|
|||
export const $ = (arg, funcs) => $$(...funcs)(arg); |
|||
|
|||
export const keySep = '/'; |
|||
const trimKeySep = str => trim(str, keySep); |
|||
const splitByKeySep = str => split(str, keySep); |
|||
export const safeKey = key => replace(`${keySep}${trimKeySep(key)}`, `${keySep}${keySep}`, keySep); |
|||
export const joinKey = (...strs) => { |
|||
const paramsOrArray = strs.length === 1 & isArray(strs[0]) |
|||
? strs[0] : strs; |
|||
return safeKey(join(paramsOrArray, keySep)); |
|||
}; |
|||
export const splitKey = $$(trimKeySep, splitByKeySep); |
|||
export const getDirFomKey = $$(splitKey, dropRight, p => joinKey(...p)); |
|||
export const getFileFromKey = $$(splitKey, takeRight, head); |
|||
|
|||
export const configFolder = `${keySep}.config`; |
|||
export const fieldDefinitions = joinKey(configFolder, 'fields.json'); |
|||
export const templateDefinitions = joinKey(configFolder, 'templates.json'); |
|||
export const appDefinitionFile = joinKey(configFolder, 'appDefinition.json'); |
|||
export const dirIndex = folderPath => joinKey(configFolder, 'dir', ...splitKey(folderPath), 'dir.idx'); |
|||
export const getIndexKeyFromFileKey = $$(getDirFomKey, dirIndex); |
|||
|
|||
export const ifExists = (val, exists, notExists) => (isUndefined(val) |
|||
? isUndefined(notExists) ? (() => { })() : notExists() |
|||
: exists()); |
|||
|
|||
export const getOrDefault = (val, defaultVal) => ifExists(val, () => val, () => defaultVal); |
|||
|
|||
export const not = func => val => !func(val); |
|||
export const isDefined = not(isUndefined); |
|||
export const isNonNull = not(isNull); |
|||
export const isNotNaN = not(isNaN); |
|||
|
|||
export const allTrue = (...funcArgs) => val => reduce(funcArgs, |
|||
(result, conditionFunc) => (isNull(result) || result == true) && conditionFunc(val), |
|||
null); |
|||
|
|||
export const anyTrue = (...funcArgs) => val => reduce(funcArgs, |
|||
(result, conditionFunc) => result == true || conditionFunc(val), |
|||
null); |
|||
|
|||
export const insensitiveEquals = (str1, str2) => str1.trim().toLowerCase() === str2.trim().toLowerCase(); |
|||
|
|||
export const isSomething = allTrue(isDefined, isNonNull, isNotNaN); |
|||
export const isNothing = not(isSomething); |
|||
export const isNothingOrEmpty = v => isNothing(v) || isEmpty(v); |
|||
export const somethingOrGetDefault = getDefaultFunc => val => (isSomething(val) ? val : getDefaultFunc()); |
|||
export const somethingOrDefault = (val, defaultVal) => somethingOrGetDefault(constant(defaultVal))(val); |
|||
|
|||
export const mapIfSomethingOrDefault = (mapFunc, defaultVal) => val => (isSomething(val) ? mapFunc(val) : defaultVal); |
|||
|
|||
export const mapIfSomethingOrBlank = mapFunc => mapIfSomethingOrDefault(mapFunc, ''); |
|||
|
|||
export const none = predicate => collection => !some(predicate)(collection); |
|||
|
|||
export const all = predicate => collection => none(v => !predicate(v))(collection); |
|||
|
|||
export const isNotEmpty = ob => !isEmpty(ob); |
|||
export const isAsync = fn => fn.constructor.name === 'AsyncFunction'; |
|||
export const isNonEmptyArray = allTrue(isArray, isNotEmpty); |
|||
export const isNonEmptyString = allTrue(isString, isNotEmpty); |
|||
export const tryOr = failFunc => (func, ...args) => { |
|||
try { |
|||
return func.apply(null, ...args); |
|||
} catch (_) { |
|||
return failFunc(); |
|||
} |
|||
}; |
|||
|
|||
export const tryAwaitOr = failFunc => async (func, ...args) => { |
|||
try { |
|||
return await func.apply(null, ...args); |
|||
} catch (_) { |
|||
return await failFunc(); |
|||
} |
|||
}; |
|||
|
|||
export const defineError = (func, errorPrefix) => { |
|||
try { |
|||
return func(); |
|||
} catch (err) { |
|||
err.message = `${errorPrefix} : ${err.message}`; |
|||
throw err; |
|||
} |
|||
}; |
|||
|
|||
export const tryOrIgnore = tryOr(() => { }); |
|||
export const tryAwaitOrIgnore = tryAwaitOr(async () => { }); |
|||
export const causesException = (func) => { |
|||
try { |
|||
func(); |
|||
return false; |
|||
} catch (e) { |
|||
return true; |
|||
} |
|||
}; |
|||
|
|||
export const executesWithoutException = func => !causesException(func); |
|||
|
|||
export const handleErrorWith = returnValInError => tryOr(constant(returnValInError)); |
|||
|
|||
export const handleErrorWithUndefined = handleErrorWith(undefined); |
|||
|
|||
export const switchCase = (...cases) => (value) => { |
|||
const nextCase = () => head(cases)[0](value); |
|||
const nextResult = () => head(cases)[1](value); |
|||
|
|||
if (isEmpty(cases)) return; // undefined
|
|||
if (nextCase() === true) return nextResult(); |
|||
return switchCase(...tail(cases))(value); |
|||
}; |
|||
|
|||
export const isValue = val1 => val2 => (val1 === val2); |
|||
export const isOneOf = (...vals) => val => includes(vals, val); |
|||
export const defaultCase = constant(true); |
|||
export const memberMatches = (member, match) => obj => match(obj[member]); |
|||
|
|||
|
|||
export const StartsWith = searchFor => searchIn => startsWith(searchIn, searchFor); |
|||
|
|||
export const contains = val => array => (findIndex(array, v => v === val) > -1); |
|||
|
|||
export const getHashCode = (s) => { |
|||
let hash = 0; let i; let char; let |
|||
l; |
|||
if (s.length == 0) return hash; |
|||
for (i = 0, l = s.length; i < l; i++) { |
|||
char = s.charCodeAt(i); |
|||
hash = ((hash << 5) - hash) + char; |
|||
hash |= 0; // Convert to 32bit integer
|
|||
} |
|||
|
|||
// converting to string, but dont want a "-" prefixed
|
|||
if (hash < 0) { return `n${(hash * -1).toString()}`; } |
|||
return hash.toString(); |
|||
}; |
|||
|
|||
// thanks to https://blog.grossman.io/how-to-write-async-await-without-try-catch-blocks-in-javascript/
|
|||
export const awEx = async (promise) => { |
|||
try { |
|||
const result = await promise; |
|||
return [undefined, result]; |
|||
} catch (error) { |
|||
return [error, undefined]; |
|||
} |
|||
}; |
|||
|
|||
export const isSafeInteger = n => isInteger(n) |
|||
&& n <= Number.MAX_SAFE_INTEGER |
|||
&& n >= 0 - Number.MAX_SAFE_INTEGER; |
|||
|
|||
export const toDateOrNull = s => (isNull(s) ? null |
|||
: isDate(s) ? s : new Date(s)); |
|||
export const toBoolOrNull = s => (isNull(s) ? null |
|||
: s === 'true' || s === true); |
|||
export const toNumberOrNull = s => (isNull(s) ? null |
|||
: toNumber(s)); |
|||
|
|||
export const isArrayOfString = opts => isArray(opts) && all(isString)(opts); |
|||
|
|||
export const pause = async duration => new Promise(res => setTimeout(res, duration)); |
|||
|
|||
export const retry = async (fn, retries, delay, ...args) => { |
|||
try { |
|||
return await fn(...args); |
|||
} catch (err) { |
|||
if (retries > 1) { |
|||
return await pause(delay).then(async () => await retry(fn, (retries - 1), delay, ...args)); |
|||
} |
|||
throw err; |
|||
} |
|||
}; |
|||
|
|||
export { events } from './events'; |
|||
export { apiWrapper, apiWrapperSync } from './apiWrapper'; |
|||
export { |
|||
getLock, NO_LOCK, releaseLock, |
|||
extendLock, isNolock, |
|||
} from './lock'; |
|||
|
|||
export default { |
|||
ifExists, |
|||
getOrDefault, |
|||
isDefined, |
|||
isNonNull, |
|||
isNotNaN, |
|||
allTrue, |
|||
isSomething, |
|||
mapIfSomethingOrDefault, |
|||
mapIfSomethingOrBlank, |
|||
configFolder, |
|||
fieldDefinitions, |
|||
isNothing, |
|||
not, |
|||
switchCase, |
|||
defaultCase, |
|||
StartsWith, |
|||
contains, |
|||
templateDefinitions, |
|||
handleErrorWith, |
|||
handleErrorWithUndefined, |
|||
tryOr, |
|||
tryOrIgnore, |
|||
tryAwaitOr, |
|||
tryAwaitOrIgnore, |
|||
dirIndex, |
|||
keySep, |
|||
$, |
|||
$$, |
|||
getDirFomKey, |
|||
getFileFromKey, |
|||
splitKey, |
|||
somethingOrDefault, |
|||
getIndexKeyFromFileKey, |
|||
joinKey, |
|||
somethingOrGetDefault, |
|||
appDefinitionFile, |
|||
isValue, |
|||
all, |
|||
isOneOf, |
|||
memberMatches, |
|||
defineError, |
|||
anyTrue, |
|||
isNonEmptyArray, |
|||
causesException, |
|||
executesWithoutException, |
|||
none, |
|||
getHashCode, |
|||
awEx, |
|||
apiWrapper, |
|||
events, |
|||
eventsList, |
|||
isNothingOrEmpty, |
|||
isSafeInteger, |
|||
toNumber, |
|||
toDate: toDateOrNull, |
|||
toBool: toBoolOrNull, |
|||
isArrayOfString, |
|||
getLock, |
|||
NO_LOCK, |
|||
isNolock, |
|||
insensitiveEquals, |
|||
pause, |
|||
retry, |
|||
}; |
|||
@ -0,0 +1,99 @@ |
|||
import { split } from 'lodash/fp'; |
|||
import { $ } from './index'; |
|||
|
|||
const lockOverlapMilliseconds = 10; |
|||
|
|||
export const getLock = async (app, lockFile, timeoutMilliseconds, maxLockRetries, retryCount = 0) => { |
|||
try { |
|||
const timeout = (await app.getEpochTime()) |
|||
+ timeoutMilliseconds; |
|||
|
|||
const lock = { |
|||
timeout, |
|||
key: lockFile, |
|||
totalTimeout: timeoutMilliseconds, |
|||
}; |
|||
|
|||
await app.datastore.createFile( |
|||
lockFile, |
|||
getLockFileContent( |
|||
lock.totalTimeout, |
|||
lock.timeout, |
|||
), |
|||
); |
|||
|
|||
return lock; |
|||
} catch (e) { |
|||
if (retryCount == maxLockRetries) { return NO_LOCK; } |
|||
|
|||
const lock = parseLockFileContent( |
|||
lockFile, |
|||
await app.datastore.loadFile(lockFile), |
|||
); |
|||
|
|||
const currentEpochTime = await app.getEpochTime(); |
|||
|
|||
if (currentEpochTime < lock.timeout) { |
|||
return NO_LOCK; |
|||
} |
|||
|
|||
try { |
|||
await app.datastore.deleteFile(lockFile); |
|||
} catch (_) { |
|||
//empty
|
|||
} |
|||
|
|||
await sleepForRetry(); |
|||
|
|||
return await getLock( |
|||
app, lockFile, timeoutMilliseconds, |
|||
maxLockRetries, retryCount + 1, |
|||
); |
|||
} |
|||
}; |
|||
|
|||
export const getLockFileContent = (totalTimeout, epochTime) => `${totalTimeout}:${epochTime.toString()}`; |
|||
|
|||
const parseLockFileContent = (key, content) => $(content, [ |
|||
split(':'), |
|||
parts => ({ |
|||
totalTimeout: new Number(parts[0]), |
|||
timeout: new Number(parts[1]), |
|||
key, |
|||
}), |
|||
]); |
|||
|
|||
export const releaseLock = async (app, lock) => { |
|||
const currentEpochTime = await app.getEpochTime(); |
|||
// only release if not timedout
|
|||
if (currentEpochTime < (lock.timeout - lockOverlapMilliseconds)) { |
|||
try { |
|||
await app.datastore.deleteFile(lock.key); |
|||
} catch (_) { |
|||
//empty
|
|||
} |
|||
} |
|||
}; |
|||
|
|||
export const extendLock = async (app, lock) => { |
|||
const currentEpochTime = await app.getEpochTime(); |
|||
// only release if not timedout
|
|||
if (currentEpochTime < (lock.timeout - lockOverlapMilliseconds)) { |
|||
try { |
|||
lock.timeout = currentEpochTime + lock.timeoutMilliseconds; |
|||
await app.datastore.updateFile( |
|||
lock.key, |
|||
getLockFileContent(lock.totalTimeout, lock.timeout), |
|||
); |
|||
return lock; |
|||
} catch (_) { |
|||
//empty
|
|||
} |
|||
} |
|||
return NO_LOCK; |
|||
}; |
|||
|
|||
export const NO_LOCK = 'no lock'; |
|||
export const isNolock = id => id === NO_LOCK; |
|||
|
|||
const sleepForRetry = () => new Promise(resolve => setTimeout(resolve, lockOverlapMilliseconds)); |
|||
@ -0,0 +1,17 @@ |
|||
import { filter, map } from 'lodash/fp'; |
|||
import { $, isSomething } from './index'; |
|||
|
|||
export const stringNotEmpty = s => isSomething(s) && s.trim().length > 0; |
|||
|
|||
export const makerule = (field, error, isValid) => ({ field, error, isValid }); |
|||
|
|||
export const validationError = (rule, item) => ({ ...rule, item }); |
|||
|
|||
export const applyRuleSet = ruleSet => itemToValidate => $(ruleSet, [ |
|||
map(applyRule(itemToValidate)), |
|||
filter(isSomething), |
|||
]); |
|||
|
|||
export const applyRule = itemTovalidate => rule => (rule.isValid(itemTovalidate) |
|||
? null |
|||
: validationError(rule, itemTovalidate)); |
|||
@ -0,0 +1,113 @@ |
|||
import getRecordApi from "./recordApi"; |
|||
import getCollectionApi from "./collectionApi"; |
|||
import getIndexApi from "./indexApi"; |
|||
import getTemplateApi from "./templateApi"; |
|||
import getAuthApi from "./authApi"; |
|||
import getActionsApi from "./actionsApi"; |
|||
import {setupDatastore, createEventAggregator} from "./appInitialise"; |
|||
import {initialiseActions} from "./actionsApi/initialise" |
|||
import {isSomething} from "./common"; |
|||
import {cleanup} from "./transactions/cleanup"; |
|||
import {generateFullPermissions} from "./authApi/generateFullPermissions"; |
|||
import {getApplicationDefinition} from "./templateApi/getApplicationDefinition"; |
|||
import common from "./common"; |
|||
import {getBehaviourSources} from "./templateApi/getBehaviourSources"; |
|||
import hierarchy from "./templateApi/hierarchy"; |
|||
|
|||
export const getAppApis = async (store, behaviourSources = null, |
|||
cleanupTransactions = null, |
|||
getEpochTime = null, |
|||
crypto = null, |
|||
appDefinition = null) => { |
|||
|
|||
store = setupDatastore(store); |
|||
|
|||
if(!appDefinition) |
|||
appDefinition = await getApplicationDefinition(store)(); |
|||
|
|||
if(!behaviourSources) |
|||
behaviourSources = await getBehaviourSources(store); |
|||
|
|||
const eventAggregator = createEventAggregator(); |
|||
|
|||
const app = { |
|||
datastore:store, |
|||
crypto, |
|||
publish:eventAggregator.publish, |
|||
hierarchy:appDefinition.hierarchy, |
|||
actions:appDefinition.actions |
|||
}; |
|||
|
|||
const templateApi = getTemplateApi(app); |
|||
|
|||
app.cleanupTransactions = isSomething(cleanupTransactions) |
|||
? cleanupTransactions |
|||
: async () => await cleanup(app); |
|||
|
|||
app.getEpochTime = isSomething(getEpochTime) |
|||
? getEpochTime |
|||
: async () => (new Date()).getTime(); |
|||
|
|||
const recordApi = getRecordApi(app); |
|||
const collectionApi = getCollectionApi(app); |
|||
const indexApi = getIndexApi(app); |
|||
const authApi = getAuthApi(app); |
|||
const actionsApi = getActionsApi(app); |
|||
|
|||
const authenticateAs = async (username, password) => { |
|||
app.user = await authApi.authenticate(username, password); |
|||
}; |
|||
|
|||
const withFullAccess = () => { |
|||
app.user = { |
|||
name: "app", |
|||
permissions : generateFullPermissions(app), |
|||
isUser:false, |
|||
temp:false |
|||
} |
|||
}; |
|||
|
|||
const asUser = (user) => { |
|||
app.user = user |
|||
}; |
|||
|
|||
|
|||
|
|||
let apis = { |
|||
recordApi, |
|||
templateApi, |
|||
collectionApi, |
|||
indexApi, |
|||
authApi, |
|||
actionsApi, |
|||
subscribe: eventAggregator.subscribe, |
|||
authenticateAs, |
|||
withFullAccess, |
|||
asUser |
|||
}; |
|||
|
|||
apis.actions = initialiseActions( |
|||
eventAggregator.subscribe, |
|||
behaviourSources, |
|||
appDefinition.actions, |
|||
appDefinition.triggers, |
|||
apis); |
|||
|
|||
|
|||
return apis; |
|||
}; |
|||
|
|||
export {events, eventsList} from "./common/events"; |
|||
export {getTemplateApi} from "./templateApi"; |
|||
export {getRecordApi} from "./recordApi"; |
|||
export {getCollectionApi} from "./collectionApi"; |
|||
export {getAuthApi} from "./authApi"; |
|||
export {getIndexApi} from "./indexApi"; |
|||
export {setupDatastore} from "./appInitialise"; |
|||
export {getActionsApi} from "./actionsApi"; |
|||
export {initialiseData} from "./appInitialise/initialiseData"; |
|||
export {getDatabaseManager} from "./appInitialise/databaseManager"; |
|||
export {hierarchy}; |
|||
export {common}; |
|||
|
|||
export default getAppApis; |
|||
@ -0,0 +1,168 @@ |
|||
import { has, isNumber, isUndefined } from 'lodash/fp'; |
|||
import { compileExpression, compileCode } from '@nx-js/compiler-util'; |
|||
import { |
|||
safeKey, apiWrapper, |
|||
events, isNonEmptyString, |
|||
} from '../common'; |
|||
import { iterateIndex } from '../indexing/read'; |
|||
import { |
|||
getUnshardedIndexDataKey, |
|||
getShardKeysInRange, |
|||
} from '../indexing/sharding'; |
|||
import { |
|||
getExactNodeForPath, isIndex, |
|||
isShardedIndex, |
|||
} from '../templateApi/hierarchy'; |
|||
import { CONTINUE_READING_RECORDS } from '../indexing/serializer'; |
|||
import { permission } from '../authApi/permissions'; |
|||
import { BadRequestError } from '../common/errors'; |
|||
|
|||
export const aggregates = app => async (indexKey, rangeStartParams = null, rangeEndParams = null) => apiWrapper( |
|||
app, |
|||
events.indexApi.aggregates, |
|||
permission.readIndex.isAuthorized(indexKey), |
|||
{ indexKey, rangeStartParams, rangeEndParams }, |
|||
_aggregates, app, indexKey, rangeStartParams, rangeEndParams, |
|||
); |
|||
|
|||
const _aggregates = async (app, indexKey, rangeStartParams, rangeEndParams) => { |
|||
indexKey = safeKey(indexKey); |
|||
const indexNode = getExactNodeForPath(app.hierarchy)(indexKey); |
|||
|
|||
if (!isIndex(indexNode)) { throw new BadRequestError('supplied key is not an index'); } |
|||
|
|||
if (isShardedIndex(indexNode)) { |
|||
const shardKeys = await getShardKeysInRange( |
|||
app, indexKey, rangeStartParams, rangeEndParams, |
|||
); |
|||
let aggregateResult = null; |
|||
for (const k of shardKeys) { |
|||
const shardResult = await getAggregates(app.hierarchy, app.datastore, indexNode, k); |
|||
if (aggregateResult === null) { |
|||
aggregateResult = shardResult; |
|||
} else { |
|||
aggregateResult = mergeShardAggregate( |
|||
aggregateResult, |
|||
shardResult, |
|||
); |
|||
} |
|||
} |
|||
return aggregateResult; |
|||
} |
|||
return await getAggregates( |
|||
app.hierarchy, |
|||
app.datastore, |
|||
indexNode, |
|||
getUnshardedIndexDataKey(indexKey), |
|||
); |
|||
}; |
|||
|
|||
const mergeShardAggregate = (totals, shard) => { |
|||
const mergeGrouping = (tot, shr) => { |
|||
tot.count += shr.count; |
|||
for (const aggName in tot) { |
|||
if (aggName === 'count') continue; |
|||
const totagg = tot[aggName]; |
|||
const shragg = shr[aggName]; |
|||
totagg.sum += shragg.sum; |
|||
totagg.max = totagg.max > shragg.max |
|||
? totagg.max |
|||
: shragg.max; |
|||
totagg.min = totagg.min < shragg.min |
|||
? totagg.min |
|||
: shragg.min; |
|||
totagg.mean = totagg.sum / tot.count; |
|||
} |
|||
return tot; |
|||
}; |
|||
|
|||
for (const aggGroupDef in totals) { |
|||
for (const grouping in shard[aggGroupDef]) { |
|||
const groupingTotal = totals[aggGroupDef][grouping]; |
|||
totals[aggGroupDef][grouping] = isUndefined(groupingTotal) |
|||
? shard[aggGroupDef][grouping] |
|||
: mergeGrouping( |
|||
totals[aggGroupDef][grouping], |
|||
shard[aggGroupDef][grouping], |
|||
); |
|||
} |
|||
} |
|||
|
|||
return totals; |
|||
}; |
|||
|
|||
const getAggregates = async (hierarchy, datastore, index, indexedDataKey) => { |
|||
const aggregateResult = {}; |
|||
const doRead = iterateIndex( |
|||
async item => { |
|||
applyItemToAggregateResult( |
|||
index, aggregateResult, item, |
|||
); |
|||
return CONTINUE_READING_RECORDS; |
|||
}, |
|||
async () => aggregateResult |
|||
); |
|||
|
|||
return await doRead(hierarchy, datastore, index, indexedDataKey); |
|||
}; |
|||
|
|||
|
|||
const applyItemToAggregateResult = (indexNode, result, item) => { |
|||
const getInitialAggregateResult = () => ({ |
|||
sum: 0, mean: null, max: null, min: null, |
|||
}); |
|||
|
|||
const applyAggregateResult = (agg, existing, count) => { |
|||
const value = compileCode(agg.aggregatedValue)({ record: item }); |
|||
|
|||
if (!isNumber(value)) return existing; |
|||
|
|||
existing.sum += value; |
|||
existing.max = value > existing.max || existing.max === null |
|||
? value |
|||
: existing.max; |
|||
existing.min = value < existing.min || existing.min === null |
|||
? value |
|||
: existing.min; |
|||
existing.mean = existing.sum / count; |
|||
return existing; |
|||
}; |
|||
|
|||
for (const aggGroup of indexNode.aggregateGroups) { |
|||
if (!has(aggGroup.name)(result)) { |
|||
result[aggGroup.name] = {}; |
|||
} |
|||
|
|||
const thisGroupResult = result[aggGroup.name]; |
|||
|
|||
if (isNonEmptyString(aggGroup.condition)) { |
|||
if (!compileExpression(aggGroup.condition)({ record: item })) { |
|||
continue; |
|||
} |
|||
} |
|||
|
|||
let group = isNonEmptyString(aggGroup.groupBy) |
|||
? compileCode(aggGroup.groupBy)({ record: item }) |
|||
: 'all'; |
|||
if (!isNonEmptyString(group)) { |
|||
group = '(none)'; |
|||
} |
|||
|
|||
if (!has(group)(thisGroupResult)) { |
|||
thisGroupResult[group] = { count: 0 }; |
|||
for (const agg of aggGroup.aggregates) { |
|||
thisGroupResult[group][agg.name] = getInitialAggregateResult(); |
|||
} |
|||
} |
|||
|
|||
thisGroupResult[group].count++; |
|||
|
|||
for (const agg of aggGroup.aggregates) { |
|||
const existingValues = thisGroupResult[group][agg.name]; |
|||
thisGroupResult[group][agg.name] = applyAggregateResult( |
|||
agg, existingValues, |
|||
thisGroupResult[group].count, |
|||
); |
|||
} |
|||
} |
|||
}; |
|||
@ -0,0 +1,198 @@ |
|||
import { |
|||
find, filter, |
|||
includes, some, |
|||
} from 'lodash/fp'; |
|||
import { getAllIdsIterator } from '../indexing/allIds'; |
|||
import { |
|||
getFlattenedHierarchy, getRecordNodeById, |
|||
getCollectionNodeByKeyOrNodeKey, getNode, isIndex, |
|||
isRecord, isDecendant, getAllowedRecordNodesForIndex, |
|||
fieldReversesReferenceToIndex, |
|||
} from '../templateApi/hierarchy'; |
|||
import { |
|||
joinKey, apiWrapper, events, $, allTrue, |
|||
} from '../common'; |
|||
import { |
|||
createBuildIndexFolder, |
|||
transactionForBuildIndex, |
|||
} from '../transactions/create'; |
|||
import { permission } from '../authApi/permissions'; |
|||
import { BadRequestError } from '../common/errors'; |
|||
|
|||
|
|||
/** rebuilds an index |
|||
* @param {object} app - the application container |
|||
* @param {string} indexNodeKey - node key of the index, which the index belongs to |
|||
*/ |
|||
export const buildIndex = app => async indexNodeKey => apiWrapper( |
|||
app, |
|||
events.indexApi.buildIndex, |
|||
permission.manageIndex.isAuthorized, |
|||
{ indexNodeKey }, |
|||
_buildIndex, app, indexNodeKey, |
|||
); |
|||
|
|||
const _buildIndex = async (app, indexNodeKey) => { |
|||
const indexNode = getNode(app.hierarchy, indexNodeKey); |
|||
|
|||
await createBuildIndexFolder(app.datastore, indexNodeKey); |
|||
|
|||
if (!isIndex(indexNode)) { throw new BadRequestError('BuildIndex: must supply an indexnode'); } |
|||
|
|||
if (indexNode.indexType === 'reference') { |
|||
await buildReverseReferenceIndex( |
|||
app, indexNode, |
|||
); |
|||
} else { |
|||
await buildHeirarchalIndex( |
|||
app, indexNode, |
|||
); |
|||
} |
|||
|
|||
await app.cleanupTransactions(); |
|||
}; |
|||
|
|||
const buildReverseReferenceIndex = async (app, indexNode) => { |
|||
// Iterate through all referencING records,
|
|||
// and update referenced index for each record
|
|||
let recordCount = 0; |
|||
const referencingNodes = $(app.hierarchy, [ |
|||
getFlattenedHierarchy, |
|||
filter(n => isRecord(n) |
|||
&& some(fieldReversesReferenceToIndex(indexNode))(n.fields)), |
|||
]); |
|||
|
|||
const createTransactionsForReferencingNode = async (referencingNode) => { |
|||
const iterateReferencingNodes = await getAllIdsIterator(app)(referencingNode.collectionNodeKey()); |
|||
|
|||
let referencingIdIterator = await iterateReferencingNodes(); |
|||
while (!referencingIdIterator.done) { |
|||
const { result } = referencingIdIterator; |
|||
for (const id of result.ids) { |
|||
const recordKey = joinKey(result.collectionKey, id); |
|||
await transactionForBuildIndex(app, indexNode.nodeKey(), recordKey, recordCount); |
|||
recordCount++; |
|||
} |
|||
referencingIdIterator = await iterateReferencingNodes(); |
|||
} |
|||
}; |
|||
|
|||
for (const referencingNode of referencingNodes) { |
|||
await createTransactionsForReferencingNode(referencingNode); |
|||
} |
|||
}; |
|||
|
|||
const getAllowedParentCollectionNodes = (hierarchy, indexNode) => $(getAllowedRecordNodesForIndex(hierarchy, indexNode), [ |
|||
map(n => n.parent()), |
|||
]); |
|||
|
|||
const buildHeirarchalIndex = async (app, indexNode) => { |
|||
let recordCount = 0; |
|||
|
|||
const createTransactionsForIds = async (collectionKey, ids) => { |
|||
for (const recordId of ids) { |
|||
const recordKey = joinKey(collectionKey, recordId); |
|||
|
|||
const recordNode = getRecordNodeById( |
|||
app.hierarchy, |
|||
recordId, |
|||
); |
|||
|
|||
if (recordNodeApplies(indexNode)(recordNode)) { |
|||
await transactionForBuildIndex( |
|||
app, indexNode.nodeKey(), |
|||
recordKey, recordCount, |
|||
); |
|||
recordCount++; |
|||
} |
|||
} |
|||
}; |
|||
|
|||
|
|||
const collectionRecords = getAllowedRecordNodesForIndex(app.hierarchy, indexNode); |
|||
|
|||
for (const targetCollectionRecordNode of collectionRecords) { |
|||
const allIdsIterator = await getAllIdsIterator(app)(targetCollectionRecordNode.collectionNodeKey()); |
|||
|
|||
let allIds = await allIdsIterator(); |
|||
while (allIds.done === false) { |
|||
await createTransactionsForIds( |
|||
allIds.result.collectionKey, |
|||
allIds.result.ids, |
|||
); |
|||
allIds = await allIdsIterator(); |
|||
} |
|||
} |
|||
|
|||
return recordCount; |
|||
}; |
|||
|
|||
const chooseChildRecordNodeByKey = (collectionNode, recordId) => find(c => recordId.startsWith(c.nodeId))(collectionNode.children); |
|||
|
|||
const recordNodeApplies = indexNode => recordNode => includes(recordNode.nodeId)(indexNode.allowedRecordNodeIds); |
|||
|
|||
const hasApplicableDecendant = (hierarchy, ancestorNode, indexNode) => $(hierarchy, [ |
|||
getFlattenedHierarchy, |
|||
filter( |
|||
allTrue( |
|||
isRecord, |
|||
isDecendant(ancestorNode), |
|||
recordNodeApplies(indexNode), |
|||
), |
|||
), |
|||
]); |
|||
|
|||
const applyAllDecendantRecords = async (app, collection_Key_or_NodeKey, |
|||
indexNode, indexKey, currentIndexedData, |
|||
currentIndexedDataKey, recordCount = 0) => { |
|||
const collectionNode = getCollectionNodeByKeyOrNodeKey( |
|||
app.hierarchy, |
|||
collection_Key_or_NodeKey, |
|||
); |
|||
|
|||
const allIdsIterator = await getAllIdsIterator(app)(collection_Key_or_NodeKey); |
|||
|
|||
|
|||
const createTransactionsForIds = async (collectionKey, allIds) => { |
|||
for (const recordId of allIds) { |
|||
const recordKey = joinKey(collectionKey, recordId); |
|||
|
|||
const recordNode = chooseChildRecordNodeByKey( |
|||
collectionNode, |
|||
recordId, |
|||
); |
|||
|
|||
if (recordNodeApplies(indexNode)(recordNode)) { |
|||
await transactionForBuildIndex( |
|||
app, indexNode.nodeKey(), |
|||
recordKey, recordCount, |
|||
); |
|||
recordCount++; |
|||
} |
|||
|
|||
if (hasApplicableDecendant(app.hierarchy, recordNode, indexNode)) { |
|||
for (const childCollectionNode of recordNode.children) { |
|||
recordCount = await applyAllDecendantRecords( |
|||
app, |
|||
joinKey(recordKey, childCollectionNode.collectionName), |
|||
indexNode, indexKey, currentIndexedData, |
|||
currentIndexedDataKey, recordCount, |
|||
); |
|||
} |
|||
} |
|||
} |
|||
}; |
|||
|
|||
let allIds = await allIdsIterator(); |
|||
while (allIds.done === false) { |
|||
await createTransactionsForIds( |
|||
allIds.result.collectionKey, |
|||
allIds.result.ids, |
|||
); |
|||
allIds = await allIdsIterator(); |
|||
} |
|||
|
|||
return recordCount; |
|||
}; |
|||
|
|||
export default buildIndex; |
|||
@ -0,0 +1,43 @@ |
|||
import { |
|||
tryAwaitOrIgnore, |
|||
} from '../common'; |
|||
import { |
|||
isIndex, isShardedIndex, |
|||
getExactNodeForPath, |
|||
} from '../templateApi/hierarchy'; |
|||
import { |
|||
getAllShardKeys, getShardMapKey, |
|||
getUnshardedIndexDataKey, |
|||
} from '../indexing/sharding'; |
|||
|
|||
export const _deleteIndex = async (app, indexKey, includeFolder) => { |
|||
const indexNode = getExactNodeForPath(app.hierarchy)(indexKey); |
|||
|
|||
if (!isIndex(indexNode)) { throw new Error('Supplied key is not an index'); } |
|||
|
|||
if (isShardedIndex(indexNode)) { |
|||
const shardKeys = await getAllShardKeys(app, indexKey); |
|||
for (const k of shardKeys) { |
|||
await tryAwaitOrIgnore( |
|||
app.datastore.deleteFile(k), |
|||
); |
|||
} |
|||
tryAwaitOrIgnore( |
|||
await app.datastore.deleteFile( |
|||
getShardMapKey(indexKey), |
|||
), |
|||
); |
|||
} else { |
|||
await tryAwaitOrIgnore( |
|||
app.datastore.deleteFile( |
|||
getUnshardedIndexDataKey(indexKey), |
|||
), |
|||
); |
|||
} |
|||
|
|||
if (includeFolder) { |
|||
tryAwaitOrIgnore( |
|||
await app.datastore.deleteFolder(indexKey), |
|||
); |
|||
} |
|||
}; |
|||
@ -0,0 +1,11 @@ |
|||
import { buildIndex } from './buildIndex'; |
|||
import { listItems } from './listItems'; |
|||
import { aggregates } from './aggregates'; |
|||
|
|||
export const getIndexApi = app => ({ |
|||
listItems: listItems(app), |
|||
buildIndex: buildIndex(app), |
|||
aggregates: aggregates(app), |
|||
}); |
|||
|
|||
export default getIndexApi; |
|||
@ -0,0 +1,66 @@ |
|||
import { flatten, merge } from 'lodash/fp'; |
|||
import { |
|||
safeKey, apiWrapper, $, |
|||
events, isNonEmptyString, |
|||
} from '../common'; |
|||
import { readIndex, searchIndex } from '../indexing/read'; |
|||
import { |
|||
getUnshardedIndexDataKey, |
|||
getShardKeysInRange, |
|||
} from '../indexing/sharding'; |
|||
import { |
|||
getExactNodeForPath, isIndex, |
|||
isShardedIndex, |
|||
} from '../templateApi/hierarchy'; |
|||
import { permission } from '../authApi/permissions'; |
|||
|
|||
export const listItems = app => async (indexKey, options) => apiWrapper( |
|||
app, |
|||
events.indexApi.listItems, |
|||
permission.readIndex.isAuthorized(indexKey), |
|||
{ indexKey, options }, |
|||
_listItems, app, indexKey, options, |
|||
); |
|||
|
|||
const defaultOptions = { rangeStartParams: null, rangeEndParams: null, searchPhrase: null }; |
|||
|
|||
const _listItems = async (app, indexKey, options = defaultOptions) => { |
|||
const { searchPhrase, rangeStartParams, rangeEndParams } = $({}, [ |
|||
merge(options), |
|||
merge(defaultOptions), |
|||
]); |
|||
|
|||
const getItems = async key => (isNonEmptyString(searchPhrase) |
|||
? await searchIndex( |
|||
app.hierarchy, |
|||
app.datastore, |
|||
indexNode, |
|||
key, |
|||
searchPhrase, |
|||
) |
|||
: await readIndex( |
|||
app.hierarchy, |
|||
app.datastore, |
|||
indexNode, |
|||
key, |
|||
)); |
|||
|
|||
indexKey = safeKey(indexKey); |
|||
const indexNode = getExactNodeForPath(app.hierarchy)(indexKey); |
|||
|
|||
if (!isIndex(indexNode)) { throw new Error('supplied key is not an index'); } |
|||
|
|||
if (isShardedIndex(indexNode)) { |
|||
const shardKeys = await getShardKeysInRange( |
|||
app, indexKey, rangeStartParams, rangeEndParams, |
|||
); |
|||
const items = []; |
|||
for (const k of shardKeys) { |
|||
items.push(await getItems(k)); |
|||
} |
|||
return flatten(items); |
|||
} |
|||
return await getItems( |
|||
getUnshardedIndexDataKey(indexKey), |
|||
); |
|||
}; |
|||
@ -0,0 +1,229 @@ |
|||
import { |
|||
join, pull, |
|||
map, flatten, orderBy, |
|||
filter, find, |
|||
} from 'lodash/fp'; |
|||
import { |
|||
getParentKey, |
|||
getFlattenedHierarchy, |
|||
getCollectionNodeByKeyOrNodeKey, getNodeForCollectionPath, |
|||
isCollectionRecord, isAncestor, |
|||
} from '../templateApi/hierarchy'; |
|||
import { joinKey, safeKey, $ } from '../common'; |
|||
|
|||
const allIdChars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_-'; |
|||
|
|||
const allIdsStringsForFactor = (collectionNode) => { |
|||
const factor = collectionNode.allidsShardFactor; |
|||
const charRangePerShard = 64 / factor; |
|||
const allIdStrings = []; |
|||
let index = 0; |
|||
let currentIdsShard = ''; |
|||
while (index < 64) { |
|||
currentIdsShard += allIdChars[index]; |
|||
if ((index + 1) % charRangePerShard === 0) { |
|||
allIdStrings.push(currentIdsShard); |
|||
currentIdsShard = ''; |
|||
} |
|||
index++; |
|||
} |
|||
|
|||
return allIdStrings; |
|||
}; |
|||
|
|||
export const getAllIdsShardNames = (appHierarchy, collectionKey) => { |
|||
const collectionRecordNode = getNodeForCollectionPath(appHierarchy)(collectionKey); |
|||
return $(collectionRecordNode, [ |
|||
c => [c.nodeId], |
|||
map(i => map(c => _allIdsShardKey(collectionKey, i, c))(allIdsStringsForFactor(collectionRecordNode))), |
|||
flatten, |
|||
]); |
|||
}; |
|||
|
|||
const _allIdsShardKey = (collectionKey, childNo, shardKey) => joinKey( |
|||
collectionKey, |
|||
'allids', |
|||
childNo, |
|||
shardKey, |
|||
); |
|||
|
|||
export const getAllIdsShardKey = (appHierarchy, collectionKey, recordId) => { |
|||
const indexOfFirstDash = recordId.indexOf('-'); |
|||
|
|||
const collectionNode = getNodeForCollectionPath(appHierarchy)(collectionKey); |
|||
|
|||
const idFirstChar = recordId[indexOfFirstDash + 1]; |
|||
const allIdsShardId = $(collectionNode, [ |
|||
allIdsStringsForFactor, |
|||
find(i => i.includes(idFirstChar)), |
|||
]); |
|||
|
|||
return _allIdsShardKey( |
|||
collectionKey, |
|||
recordId.slice(0, indexOfFirstDash), |
|||
allIdsShardId, |
|||
); |
|||
}; |
|||
|
|||
const getOrCreateShardFile = async (datastore, allIdsKey) => { |
|||
try { |
|||
return await datastore.loadFile(allIdsKey); |
|||
} catch (eLoad) { |
|||
try { |
|||
await datastore.createFile(allIdsKey, ''); |
|||
return ''; |
|||
} catch (eCreate) { |
|||
throw new Error( |
|||
`Error loading, then creating allIds ${allIdsKey |
|||
} : LOAD : ${eLoad.message |
|||
} : CREATE : ${eCreate}`,
|
|||
); |
|||
} |
|||
} |
|||
}; |
|||
|
|||
const getShardFile = async (datastore, allIdsKey) => { |
|||
try { |
|||
return await datastore.loadFile(allIdsKey); |
|||
} catch (eLoad) { |
|||
return ''; |
|||
} |
|||
}; |
|||
|
|||
export const addToAllIds = (appHierarchy, datastore) => async (record) => { |
|||
const allIdsKey = getAllIdsShardKey( |
|||
appHierarchy, |
|||
getParentKey(record.key), |
|||
record.id, |
|||
); |
|||
|
|||
let allIds = await getOrCreateShardFile(datastore, allIdsKey); |
|||
|
|||
allIds += `${allIds.length > 0 ? ',' : ''}${record.id}`; |
|||
|
|||
await datastore.updateFile(allIdsKey, allIds); |
|||
}; |
|||
|
|||
export const getAllIdsIterator = app => async (collection_Key_or_NodeKey) => { |
|||
collection_Key_or_NodeKey = safeKey(collection_Key_or_NodeKey); |
|||
const targetNode = getCollectionNodeByKeyOrNodeKey( |
|||
app.hierarchy, |
|||
collection_Key_or_NodeKey, |
|||
); |
|||
|
|||
const getAllIdsIteratorForCollectionKey = async (collectionKey) => { |
|||
const all_allIdsKeys = getAllIdsShardNames(app.hierarchy, collectionKey); |
|||
let shardIndex = 0; |
|||
|
|||
const allIdsFromShardIterator = async () => { |
|||
if (shardIndex === all_allIdsKeys.length) { return ({ done: true, result: { ids: [], collectionKey } }); } |
|||
|
|||
const shardKey = all_allIdsKeys[shardIndex]; |
|||
|
|||
const allIds = await getAllIdsFromShard(app.datastore, shardKey); |
|||
|
|||
shardIndex++; |
|||
|
|||
return ({ |
|||
result: { |
|||
ids: allIds, |
|||
collectionKey, |
|||
}, |
|||
done: false, |
|||
}); |
|||
}; |
|||
|
|||
return allIdsFromShardIterator; |
|||
}; |
|||
|
|||
const ancestors = $(getFlattenedHierarchy(app.hierarchy), [ |
|||
filter(isCollectionRecord), |
|||
filter(n => isAncestor(targetNode)(n) |
|||
|| n.nodeKey() === targetNode.nodeKey()), |
|||
orderBy([n => n.nodeKey().length], ['asc']), |
|||
]); // parents first
|
|||
|
|||
const traverseForIteraterators = async (parentRecordKey = '', currentNodeIndex = 0) => { |
|||
const currentNode = ancestors[currentNodeIndex]; |
|||
const currentCollectionKey = joinKey( |
|||
parentRecordKey, |
|||
currentNode.collectionName, |
|||
); |
|||
if (currentNode.nodeKey() === targetNode.nodeKey()) { |
|||
return [ |
|||
await getAllIdsIteratorForCollectionKey( |
|||
currentCollectionKey, |
|||
)]; |
|||
} |
|||
const allIterators = []; |
|||
const currentIterator = await getAllIdsIteratorForCollectionKey( |
|||
currentCollectionKey, |
|||
); |
|||
|
|||
let ids = await currentIterator(); |
|||
while (ids.done === false) { |
|||
for (const id of ids.result.ids) { |
|||
allIterators.push( |
|||
await traverseForIteraterators( |
|||
joinKey(currentCollectionKey, id), |
|||
currentNodeIndex + 1, |
|||
), |
|||
); |
|||
} |
|||
|
|||
ids = await currentIterator(); |
|||
} |
|||
|
|||
return flatten(allIterators); |
|||
}; |
|||
|
|||
const iteratorsArray = await traverseForIteraterators(); |
|||
let currentIteratorIndex = 0; |
|||
return async () => { |
|||
if (iteratorsArray.length === 0) { return { done: true, result: [] }; } |
|||
const innerResult = await iteratorsArray[currentIteratorIndex](); |
|||
if (!innerResult.done) { return innerResult; } |
|||
if (currentIteratorIndex == iteratorsArray.length - 1) { |
|||
return { done: true, result: innerResult.result }; |
|||
} |
|||
currentIteratorIndex++; |
|||
return { done: false, result: innerResult.result }; |
|||
}; |
|||
}; |
|||
|
|||
const getAllIdsFromShard = async (datastore, shardKey) => { |
|||
const allIdsStr = await getShardFile(datastore, shardKey); |
|||
|
|||
const allIds = []; |
|||
let currentId = ''; |
|||
for (let i = 0; i < allIdsStr.length; i++) { |
|||
const currentChar = allIdsStr.charAt(i); |
|||
const isLast = (i === allIdsStr.length - 1); |
|||
if (currentChar === ',' || isLast) { |
|||
if (isLast) currentId += currentChar; |
|||
allIds.push(currentId); |
|||
currentId = ''; |
|||
} else { |
|||
currentId += currentChar; |
|||
} |
|||
} |
|||
return allIds; |
|||
}; |
|||
|
|||
export const removeFromAllIds = (appHierarchy, datastore) => async (record) => { |
|||
const shardKey = getAllIdsShardKey( |
|||
appHierarchy, |
|||
getParentKey(record.key), |
|||
record.id, |
|||
); |
|||
const allIds = await getAllIdsFromShard(datastore, shardKey); |
|||
|
|||
const newIds = $(allIds, [ |
|||
pull(record.id), |
|||
join(','), |
|||
]); |
|||
|
|||
await datastore.updateFile(shardKey, newIds); |
|||
}; |
|||
|
|||
export default getAllIdsIterator; |
|||
@ -0,0 +1,79 @@ |
|||
import { ensureShardNameIsInShardMap } from './sharding'; |
|||
import { getIndexWriter } from './serializer'; |
|||
import { isShardedIndex } from '../templateApi/hierarchy'; |
|||
import {promiseWriteableStream} from "./promiseWritableStream"; |
|||
import {promiseReadableStream} from "./promiseReadableStream"; |
|||
|
|||
export const applyToShard = async (hierarchy, store, indexKey, |
|||
indexNode, indexShardKey, recordsToWrite, keysToRemove) => { |
|||
const createIfNotExists = recordsToWrite.length > 0; |
|||
const writer = await getWriter(hierarchy, store, indexKey, indexShardKey, indexNode, createIfNotExists); |
|||
if (writer === SHARD_DELETED) return; |
|||
|
|||
await writer.updateIndex(recordsToWrite, keysToRemove); |
|||
await swapTempFileIn(store, indexShardKey); |
|||
}; |
|||
|
|||
const SHARD_DELETED = 'SHARD_DELETED'; |
|||
const getWriter = async (hierarchy, store, indexKey, indexedDataKey, indexNode, createIfNotExists) => { |
|||
let readableStream = null; |
|||
|
|||
if (isShardedIndex(indexNode)) { |
|||
await ensureShardNameIsInShardMap(store, indexKey, indexedDataKey); |
|||
if(!await store.exists(indexedDataKey)) { |
|||
await store.createFile(indexedDataKey, ""); |
|||
} |
|||
} |
|||
|
|||
try { |
|||
|
|||
readableStream = promiseReadableStream( |
|||
await store.readableFileStream(indexedDataKey) |
|||
); |
|||
|
|||
} catch (e) { |
|||
|
|||
if (await store.exists(indexedDataKey)) { |
|||
throw e; |
|||
} else { |
|||
if (createIfNotExists) { |
|||
await store.createFile(indexedDataKey, ''); |
|||
} else { |
|||
return SHARD_DELETED; |
|||
} |
|||
|
|||
readableStream = promiseReadableStream( |
|||
await store.readableFileStream(indexedDataKey) |
|||
); |
|||
|
|||
} |
|||
} |
|||
|
|||
const writableStream = promiseWriteableStream( |
|||
await store.writableFileStream(indexedDataKey + ".temp") |
|||
); |
|||
|
|||
return getIndexWriter( |
|||
hierarchy, indexNode, |
|||
readableStream, writableStream |
|||
); |
|||
}; |
|||
|
|||
const swapTempFileIn = async (store, indexedDataKey, isRetry = false) => { |
|||
const tempFile = `${indexedDataKey}.temp`; |
|||
try { |
|||
await store.deleteFile(indexedDataKey); |
|||
} catch (e) { |
|||
// ignore failure, incase it has not been created yet
|
|||
} |
|||
try { |
|||
await store.renameFile(tempFile, indexedDataKey); |
|||
} catch (e) { |
|||
// retrying in case delete failure was for some other reason
|
|||
if (!isRetry) { |
|||
await swapTempFileIn(store, indexedDataKey, true); |
|||
} else { |
|||
throw new Error("Failed to swap in index filed: " + e.message); |
|||
} |
|||
} |
|||
}; |
|||
@ -0,0 +1,98 @@ |
|||
import { compileExpression, compileCode } from '@nx-js/compiler-util'; |
|||
import { |
|||
isUndefined, keys, |
|||
cloneDeep, isFunction, |
|||
} from 'lodash'; |
|||
import { defineError } from '../common'; |
|||
|
|||
export const filterEval = 'FILTER_EVALUATE'; |
|||
export const filterCompile = 'FILTER_COMPILE'; |
|||
export const mapEval = 'MAP_EVALUATE'; |
|||
export const mapCompile = 'MAP_COMPILE'; |
|||
export const removeUndeclaredFields = 'REMOVE_UNDECLARED_FIELDS'; |
|||
export const addUnMappedFields = 'ADD_UNMAPPED_FIELDS'; |
|||
export const addTheKey = 'ADD_KEY'; |
|||
|
|||
|
|||
const getEvaluateResult = () => ({ |
|||
isError: false, |
|||
passedFilter: true, |
|||
result: null, |
|||
}); |
|||
|
|||
export const compileFilter = index => compileExpression(index.filter); |
|||
|
|||
export const compileMap = index => compileCode(index.map); |
|||
|
|||
export const passesFilter = (record, index) => { |
|||
const context = { record }; |
|||
if (!index.filter) return true; |
|||
|
|||
const compiledFilter = defineError( |
|||
() => compileFilter(index), |
|||
filterCompile, |
|||
); |
|||
|
|||
return defineError( |
|||
() => compiledFilter(context), |
|||
filterEval, |
|||
); |
|||
}; |
|||
|
|||
export const mapRecord = (record, index) => { |
|||
const recordClone = cloneDeep(record); |
|||
const context = { record: recordClone }; |
|||
|
|||
const map = index.map ? index.map : 'return {...record};'; |
|||
|
|||
const compiledMap = defineError( |
|||
() => compileCode(map), |
|||
mapCompile, |
|||
); |
|||
|
|||
const mapped = defineError( |
|||
() => compiledMap(context), |
|||
mapEval, |
|||
); |
|||
|
|||
const mappedKeys = keys(mapped); |
|||
for (let i = 0; i < mappedKeys.length; i++) { |
|||
const key = mappedKeys[i]; |
|||
mapped[key] = isUndefined(mapped[key]) ? null : mapped[key]; |
|||
if (isFunction(mapped[key])) { |
|||
delete mapped[key]; |
|||
} |
|||
} |
|||
|
|||
mapped.key = record.key; |
|||
mapped.sortKey = index.getSortKey |
|||
? compileCode(index.getSortKey)(context) |
|||
: record.id; |
|||
|
|||
return mapped; |
|||
}; |
|||
|
|||
export const evaluate = record => (index) => { |
|||
const result = getEvaluateResult(); |
|||
|
|||
try { |
|||
result.passedFilter = passesFilter(record, index); |
|||
} catch (err) { |
|||
result.isError = true; |
|||
result.passedFilter = false; |
|||
result.result = err.message; |
|||
} |
|||
|
|||
if (!result.passedFilter) return result; |
|||
|
|||
try { |
|||
result.result = mapRecord(record, index); |
|||
} catch (err) { |
|||
result.isError = true; |
|||
result.result = err.message; |
|||
} |
|||
|
|||
return result; |
|||
}; |
|||
|
|||
export default evaluate; |
|||
@ -0,0 +1,58 @@ |
|||
import { |
|||
has, keys, map, orderBy, |
|||
filter, concat, reverse, |
|||
} from 'lodash/fp'; |
|||
import { getAllowedRecordNodesForIndex } from '../templateApi/hierarchy'; |
|||
import { mapRecord } from './evaluate'; |
|||
import { constructRecord } from '../recordApi/getNew'; |
|||
import { getSampleFieldValue, detectType, all } from '../types'; |
|||
import { $ } from '../common'; |
|||
|
|||
export const generateSchema = (hierarchy, indexNode) => { |
|||
const recordNodes = getAllowedRecordNodesForIndex(hierarchy, indexNode); |
|||
const mappedRecords = $(recordNodes, [ |
|||
map(n => mapRecord(createSampleRecord(n), indexNode)), |
|||
]); |
|||
|
|||
// always has record key and sort key
|
|||
const schema = { |
|||
sortKey: all.string, |
|||
key: all.string, |
|||
}; |
|||
|
|||
const fieldsHas = has(schema); |
|||
const setField = (fieldName, value) => { |
|||
if (value === null || value === undefined) { return; } |
|||
|
|||
const thisType = detectType(value); |
|||
if (fieldsHas(fieldName)) { |
|||
if (schema[fieldName] !== thisType) { |
|||
schema[fieldName] = all.string; |
|||
} |
|||
} else { |
|||
schema[fieldName] = thisType; |
|||
} |
|||
}; |
|||
|
|||
for (const mappedRec of mappedRecords) { |
|||
for (const f in mappedRec) { |
|||
setField(f, mappedRec[f]); |
|||
} |
|||
} |
|||
|
|||
// returing an array of {name, type}
|
|||
return $(schema, [ |
|||
keys, |
|||
map(k => ({ name: k, type: schema[k].name })), |
|||
filter(s => s.name !== 'sortKey'), |
|||
orderBy('name', ['desc']), // reverse aplha
|
|||
concat([{ name: 'sortKey', type: all.string.name }]), // sortKey on end
|
|||
reverse, // sortKey first, then rest are alphabetical
|
|||
]); |
|||
}; |
|||
|
|||
const createSampleRecord = recordNode => constructRecord( |
|||
recordNode, |
|||
getSampleFieldValue, |
|||
recordNode.parent().nodeKey(), |
|||
); |
|||
@ -0,0 +1,22 @@ |
|||
import { isShardedIndex } from '../templateApi/hierarchy'; |
|||
import { joinKey } from '../common'; |
|||
import { getShardMapKey, getUnshardedIndexDataKey, createIndexFile } from './sharding'; |
|||
|
|||
export const initialiseIndex = async (datastore, parentKey, index) => { |
|||
const indexKey = joinKey(parentKey, index.name); |
|||
|
|||
await datastore.createFolder(indexKey); |
|||
|
|||
if (isShardedIndex(index)) { |
|||
await datastore.createFile( |
|||
getShardMapKey(indexKey), |
|||
'[]', |
|||
); |
|||
} else { |
|||
await createIndexFile( |
|||
datastore, |
|||
getUnshardedIndexDataKey(indexKey), |
|||
index, |
|||
); |
|||
} |
|||
}; |
|||
@ -0,0 +1,84 @@ |
|||
// adapted from https://github.com/dex4er/js-promise-readable
|
|||
// thanks :)
|
|||
|
|||
export const promiseReadableStream = stream => { |
|||
|
|||
let _errored; |
|||
|
|||
const _errorHandler = err => { |
|||
_errored = err; |
|||
}; |
|||
|
|||
stream.on("error", _errorHandler); |
|||
|
|||
const read = (size) => { |
|||
|
|||
return new Promise((resolve, reject) => { |
|||
if (_errored) { |
|||
const err = _errored; |
|||
_errored = undefined; |
|||
return reject(err) |
|||
} |
|||
|
|||
if (!stream.readable || stream.closed || stream.destroyed) { |
|||
return resolve(); |
|||
} |
|||
|
|||
const readableHandler = () => { |
|||
const chunk = stream.read(size); |
|||
|
|||
if (chunk) { |
|||
removeListeners(); |
|||
resolve(chunk); |
|||
} |
|||
} |
|||
|
|||
const closeHandler = () => { |
|||
removeListeners(); |
|||
resolve(); |
|||
} |
|||
|
|||
const endHandler = () => { |
|||
removeListeners(); |
|||
resolve(); |
|||
} |
|||
|
|||
const errorHandler = (err) => { |
|||
_errored = undefined; |
|||
removeListeners(); |
|||
reject(err); |
|||
} |
|||
|
|||
const removeListeners = () => { |
|||
stream.removeListener("close", closeHandler); |
|||
stream.removeListener("error", errorHandler); |
|||
stream.removeListener("end", endHandler); |
|||
stream.removeListener("readable", readableHandler); |
|||
} |
|||
|
|||
stream.on("close", closeHandler); |
|||
stream.on("end", endHandler); |
|||
stream.on("error", errorHandler); |
|||
stream.on("readable", readableHandler); |
|||
|
|||
readableHandler(); |
|||
}); |
|||
} |
|||
|
|||
|
|||
const destroy = () => { |
|||
if (stream) { |
|||
if (_errorHandler) { |
|||
stream.removeListener("error", _errorHandler); |
|||
} |
|||
if (typeof stream.destroy === "function") { |
|||
stream.destroy(); |
|||
} |
|||
} |
|||
}; |
|||
|
|||
return {read, destroy, stream}; |
|||
} |
|||
|
|||
export default promiseReadableStream |
|||
|
|||
@ -0,0 +1,120 @@ |
|||
// adapted from https://github.com/dex4er/js-promise-writable
|
|||
// Thank you :)
|
|||
export const promiseWriteableStream = stream => { |
|||
|
|||
let _errored; |
|||
|
|||
const _errorHandler = err => { |
|||
_errored = err; |
|||
}; |
|||
|
|||
stream.on("error", _errorHandler); |
|||
|
|||
const write = chunk => { |
|||
let rejected = false; |
|||
|
|||
return new Promise((resolve, reject) => { |
|||
if (_errored) { |
|||
const err = _errored; |
|||
_errored = undefined; |
|||
return reject(err); |
|||
} |
|||
|
|||
if (!stream.writable || stream.closed || stream.destroyed) { |
|||
return reject(new Error("write after end")); |
|||
} |
|||
|
|||
const writeErrorHandler = err => { |
|||
_errored = undefined; |
|||
rejected = true; |
|||
reject(err); |
|||
} |
|||
|
|||
stream.once("error", writeErrorHandler); |
|||
|
|||
const canWrite = stream.write(chunk); |
|||
|
|||
stream.removeListener("error", writeErrorHandler); |
|||
|
|||
if (canWrite) { |
|||
if (!rejected) { |
|||
resolve(chunk.length); |
|||
} |
|||
} else { |
|||
const errorHandler = err => { |
|||
_errored = undefined; |
|||
removeListeners(); |
|||
reject(err); |
|||
} |
|||
|
|||
const drainHandler = () => { |
|||
removeListeners(); |
|||
resolve(chunk.length); |
|||
} |
|||
|
|||
const closeHandler = () => { |
|||
removeListeners(); |
|||
resolve(chunk.length); |
|||
} |
|||
|
|||
const finishHandler = () => { |
|||
removeListeners(); |
|||
resolve(chunk.length); |
|||
} |
|||
|
|||
const removeListeners = () => { |
|||
stream.removeListener("close", closeHandler); |
|||
stream.removeListener("drain", drainHandler); |
|||
stream.removeListener("error", errorHandler); |
|||
stream.removeListener("finish", finishHandler); |
|||
} |
|||
|
|||
stream.on("close", closeHandler); |
|||
stream.on("drain", drainHandler); |
|||
stream.on("error", errorHandler); |
|||
stream.on("finish", finishHandler); |
|||
} |
|||
}) |
|||
} |
|||
|
|||
const end = () => { |
|||
|
|||
return new Promise((resolve, reject) => { |
|||
if (_errored) { |
|||
const err = _errored; |
|||
_errored = undefined; |
|||
return reject(err); |
|||
} |
|||
|
|||
if (!stream.writable || stream.closed || stream.destroyed) { |
|||
return resolve(); |
|||
} |
|||
|
|||
const finishHandler = () => { |
|||
removeListeners(); |
|||
resolve(); |
|||
} |
|||
|
|||
const errorHandler = (err) => { |
|||
_errored = undefined; |
|||
removeListeners(); |
|||
reject(err); |
|||
} |
|||
|
|||
const removeListeners = () => { |
|||
stream.removeListener("error", errorHandler); |
|||
stream.removeListener("finish", finishHandler); |
|||
} |
|||
|
|||
stream.on("finish", finishHandler); |
|||
stream.on("error", errorHandler); |
|||
|
|||
stream.end(); |
|||
}) |
|||
} |
|||
|
|||
return {write, end}; |
|||
} |
|||
|
|||
export default promiseWriteableStream |
|||
|
|||
@ -0,0 +1,99 @@ |
|||
import lunr from 'lunr'; |
|||
import { |
|||
getHashCode, |
|||
joinKey |
|||
} from '../common'; |
|||
import { |
|||
getActualKeyOfParent, |
|||
isGlobalIndex, |
|||
} from '../templateApi/hierarchy'; |
|||
import {promiseReadableStream} from "./promiseReadableStream"; |
|||
import { createIndexFile } from './sharding'; |
|||
import { generateSchema } from './indexSchemaCreator'; |
|||
import { getIndexReader, CONTINUE_READING_RECORDS } from './serializer'; |
|||
|
|||
export const readIndex = async (hierarchy, datastore, index, indexedDataKey) => { |
|||
const records = []; |
|||
const doRead = iterateIndex( |
|||
async item => { |
|||
records.push(item); |
|||
return CONTINUE_READING_RECORDS; |
|||
}, |
|||
async () => records |
|||
); |
|||
|
|||
return await doRead(hierarchy, datastore, index, indexedDataKey); |
|||
}; |
|||
|
|||
export const searchIndex = async (hierarchy, datastore, index, indexedDataKey, searchPhrase) => { |
|||
const records = []; |
|||
const schema = generateSchema(hierarchy, index); |
|||
const doRead = iterateIndex( |
|||
async item => { |
|||
const idx = lunr(function () { |
|||
this.ref('key'); |
|||
for (const field of schema) { |
|||
this.field(field.name); |
|||
} |
|||
this.add(item); |
|||
}); |
|||
const searchResults = idx.search(searchPhrase); |
|||
if (searchResults.length === 1) { |
|||
item._searchResult = searchResults[0]; |
|||
records.push(item); |
|||
} |
|||
return CONTINUE_READING_RECORDS; |
|||
}, |
|||
async () => records |
|||
); |
|||
|
|||
return await doRead(hierarchy, datastore, index, indexedDataKey); |
|||
}; |
|||
|
|||
export const getIndexedDataKey_fromIndexKey = (indexKey) => |
|||
`${indexKey}${indexKey.endsWith('.csv') ? '' : '.csv'}`; |
|||
|
|||
export const uniqueIndexName = index => `idx_${ |
|||
getHashCode(`${index.filter}${index.map}`) |
|||
}.csv`;
|
|||
|
|||
export const getIndexedDataKey = (decendantKey, indexNode) => { |
|||
if (isGlobalIndex(indexNode)) { return `${indexNode.nodeKey()}.csv`; } |
|||
|
|||
const indexedDataParentKey = getActualKeyOfParent( |
|||
indexNode.parent().nodeKey(), |
|||
decendantKey, |
|||
); |
|||
|
|||
const indexName = indexNode.name |
|||
? `${indexNode.name}.csv` |
|||
: uniqueIndexName(indexNode); |
|||
|
|||
return joinKey( |
|||
indexedDataParentKey, |
|||
indexName, |
|||
); |
|||
}; |
|||
|
|||
export const iterateIndex = (onGetItem, getFinalResult) => async (hierarchy, datastore, index, indexedDataKey) => { |
|||
try { |
|||
const readableStream = promiseReadableStream( |
|||
await datastore.readableFileStream(indexedDataKey) |
|||
); |
|||
|
|||
const read = getIndexReader(hierarchy, index, readableStream); |
|||
await read(onGetItem); |
|||
return getFinalResult(); |
|||
} catch (e) { |
|||
if (await datastore.exists(indexedDataKey)) { |
|||
throw e; |
|||
} else { |
|||
await createIndexFile( |
|||
datastore, |
|||
indexedDataKey, |
|||
index, |
|||
); |
|||
} |
|||
return []; |
|||
} |
|||
}; |
|||
@ -0,0 +1,81 @@ |
|||
import { orderBy } from 'lodash'; |
|||
import { |
|||
reduce, find, includes, flatten, union, |
|||
filter, each, map, |
|||
} from 'lodash/fp'; |
|||
import { |
|||
joinKey, splitKey, isNonEmptyString, |
|||
isNothing, $, isSomething, |
|||
} from '../common'; |
|||
import { |
|||
getFlattenedHierarchy, getNode, getRecordNodeId, |
|||
getExactNodeForPath, recordNodeIdIsAllowed, |
|||
isRecord, isGlobalIndex, |
|||
} from '../templateApi/hierarchy'; |
|||
import { indexTypes } from '../templateApi/indexes'; |
|||
|
|||
export const getRelevantAncestorIndexes = (appHierarchy, record) => { |
|||
const key = record.key; |
|||
const keyParts = splitKey(key); |
|||
const nodeId = getRecordNodeId(key); |
|||
|
|||
const flatHierarchy = orderBy(getFlattenedHierarchy(appHierarchy), |
|||
[node => node.pathRegx().length], |
|||
['desc']); |
|||
|
|||
const makeindexNodeAndKey_ForAncestorIndex = (indexNode, indexKey) => makeIndexNodeAndKey(indexNode, joinKey(indexKey, indexNode.name)); |
|||
|
|||
const traverseAncestorIndexesInPath = () => reduce((acc, part) => { |
|||
const currentIndexKey = joinKey(acc.lastIndexKey, part); |
|||
acc.lastIndexKey = currentIndexKey; |
|||
const testPathRegx = p => new RegExp(`${p.pathRegx()}$`).test(currentIndexKey); |
|||
const nodeMatch = find(testPathRegx)(flatHierarchy); |
|||
|
|||
if (isNothing(nodeMatch)) { return acc; } |
|||
|
|||
if (!isRecord(nodeMatch) |
|||
|| nodeMatch.indexes.length === 0) { return acc; } |
|||
|
|||
const indexes = $(nodeMatch.indexes, [ |
|||
filter(i => i.indexType === indexTypes.ancestor |
|||
&& (i.allowedRecordNodeIds.length === 0 |
|||
|| includes(nodeId)(i.allowedRecordNodeIds))), |
|||
]); |
|||
|
|||
each(v => acc.nodesAndKeys.push( |
|||
makeindexNodeAndKey_ForAncestorIndex(v, currentIndexKey), |
|||
))(indexes); |
|||
|
|||
return acc; |
|||
}, { lastIndexKey: '', nodesAndKeys: [] })(keyParts).nodesAndKeys; |
|||
|
|||
const rootIndexes = $(flatHierarchy, [ |
|||
filter(n => isGlobalIndex(n) && recordNodeIdIsAllowed(n)(nodeId)), |
|||
map(i => makeIndexNodeAndKey(i, i.nodeKey())), |
|||
]); |
|||
|
|||
return union(traverseAncestorIndexesInPath())(rootIndexes); |
|||
}; |
|||
|
|||
export const getRelevantReverseReferenceIndexes = (appHierarchy, record) => $(record.key, [ |
|||
getExactNodeForPath(appHierarchy), |
|||
n => n.fields, |
|||
filter(f => f.type === 'reference' |
|||
&& isSomething(record[f.name]) |
|||
&& isNonEmptyString(record[f.name].key)), |
|||
map(f => $(f.typeOptions.reverseIndexNodeKeys, [ |
|||
map(n => ({ |
|||
recordNode: getNode(appHierarchy, n), |
|||
field: f, |
|||
})), |
|||
])), |
|||
flatten, |
|||
map(n => makeIndexNodeAndKey( |
|||
n.recordNode, |
|||
joinKey(record[n.field.name].key, n.recordNode.name), |
|||
)), |
|||
]); |
|||
|
|||
const makeIndexNodeAndKey = (indexNode, indexKey) => ({ indexNode, indexKey }); |
|||
|
|||
export default getRelevantAncestorIndexes; |
|||
@ -0,0 +1,252 @@ |
|||
import {generateSchema} from "./indexSchemaCreator"; |
|||
import { has, isString, difference, find } from "lodash/fp"; |
|||
import { Buffer } from "safe-buffer"; |
|||
import {StringDecoder} from "string_decoder"; |
|||
import {getType} from "../types"; |
|||
import { isSomething } from "../common"; |
|||
|
|||
export const BUFFER_MAX_BYTES = 524288; // 0.5Mb
|
|||
|
|||
export const CONTINUE_READING_RECORDS = "CONTINUE_READING"; |
|||
export const READ_REMAINING_TEXT = "READ_REMAINING"; |
|||
export const CANCEL_READ = "CANCEL"; |
|||
|
|||
export const getIndexWriter = (hierarchy, indexNode, readableStream, writableStream, end) => { |
|||
const schema = generateSchema(hierarchy, indexNode); |
|||
|
|||
return ({ |
|||
read: read(readableStream, schema), |
|||
updateIndex: updateIndex(readableStream, writableStream, schema, end) |
|||
}); |
|||
}; |
|||
|
|||
export const getIndexReader = (hierarchy, indexNode, readableStream) => |
|||
read( |
|||
readableStream, |
|||
generateSchema(hierarchy, indexNode) |
|||
); |
|||
|
|||
const updateIndex = (readableStream, writableStream, schema) => async (itemsToWrite, keysToRemove) => { |
|||
const write = newOutputWriter(BUFFER_MAX_BYTES, writableStream); |
|||
const writtenItems = []; |
|||
await read(readableStream, schema)( |
|||
async indexedItem => { |
|||
const updated = find(i => indexedItem.key === i.key)(itemsToWrite); |
|||
const removed = find(k => indexedItem.key === k)(keysToRemove); |
|||
|
|||
if(isSomething(removed)) |
|||
return CONTINUE_READING_RECORDS; |
|||
|
|||
if(isSomething(updated)) { |
|||
const serializedItem = serializeItem(schema, updated); |
|||
await write(serializedItem); |
|||
writtenItems.push(updated); |
|||
} else { |
|||
await write( |
|||
serializeItem(schema, indexedItem) |
|||
); |
|||
} |
|||
|
|||
return CONTINUE_READING_RECORDS; |
|||
|
|||
}, |
|||
async text => await write(text) |
|||
); |
|||
|
|||
if(writtenItems.length !== itemsToWrite.length) { |
|||
const toAdd = difference(itemsToWrite, writtenItems); |
|||
for(let added of toAdd) { |
|||
await write( |
|||
serializeItem(schema, added) |
|||
); |
|||
} |
|||
} else if(writtenItems.length === 0) { |
|||
// potentially are no records
|
|||
await write(""); |
|||
} |
|||
|
|||
await write(); |
|||
await writableStream.end(); |
|||
}; |
|||
|
|||
const read = (readableStream, schema) => async (onGetItem, onGetText) => { |
|||
const readInput = newInputReader(readableStream); |
|||
let text = await readInput(); |
|||
let status = CONTINUE_READING_RECORDS; |
|||
while(text.length > 0) { |
|||
|
|||
if(status === READ_REMAINING_TEXT) { |
|||
await onGetText(text); |
|||
continue; |
|||
} |
|||
|
|||
if(status === CANCEL_READ) { |
|||
return; |
|||
} |
|||
|
|||
let rowText = ""; |
|||
let currentCharIndex=0; |
|||
for(let currentChar of text) { |
|||
rowText += currentChar; |
|||
if(currentChar === "\r") { |
|||
status = await onGetItem( |
|||
deserializeRow(schema, rowText) |
|||
); |
|||
rowText = ""; |
|||
if(status === READ_REMAINING_TEXT) { |
|||
break; |
|||
} |
|||
} |
|||
currentCharIndex++; |
|||
} |
|||
|
|||
if(currentCharIndex < text.length -1) { |
|||
await onGetText(text.substring(currentCharIndex + 1)); |
|||
} |
|||
|
|||
text = await readInput(); |
|||
} |
|||
|
|||
await readableStream.destroy(); |
|||
|
|||
}; |
|||
|
|||
const newOutputWriter = (flushBoundary, writableStream) => { |
|||
|
|||
let currentBuffer = null; |
|||
|
|||
return async (text) => { |
|||
|
|||
if(isString(text) && currentBuffer === null) |
|||
currentBuffer = Buffer.from(text, "utf8"); |
|||
else if(isString(text)) |
|||
currentBuffer = Buffer.concat([ |
|||
currentBuffer, |
|||
Buffer.from(text, "utf8") |
|||
]); |
|||
|
|||
if(currentBuffer !== null && |
|||
(currentBuffer.length > flushBoundary |
|||
|| !isString(text))) { |
|||
|
|||
await writableStream.write(currentBuffer); |
|||
currentBuffer = null; |
|||
} |
|||
} |
|||
}; |
|||
|
|||
const newInputReader = (readableStream) => { |
|||
|
|||
const decoder = new StringDecoder('utf8'); |
|||
let remainingBytes = []; |
|||
|
|||
return async () => { |
|||
|
|||
let nextBytesBuffer = await readableStream.read(BUFFER_MAX_BYTES); |
|||
const remainingBuffer = Buffer.from(remainingBytes); |
|||
|
|||
if(!nextBytesBuffer) nextBytesBuffer = Buffer.from([]); |
|||
|
|||
const moreToRead = nextBytesBuffer.length === BUFFER_MAX_BYTES; |
|||
|
|||
const buffer = Buffer.concat( |
|||
[remainingBuffer, nextBytesBuffer], |
|||
remainingBuffer.length + nextBytesBuffer.length); |
|||
|
|||
const text = decoder.write(buffer); |
|||
remainingBytes = decoder.end(buffer); |
|||
|
|||
if(!moreToRead && remainingBytes.length > 0) { |
|||
// if for any reason, we have remaining bytes at the end
|
|||
// of the stream, just discard - dont see why this should
|
|||
// ever happen, but if it does, it could cause a stack overflow
|
|||
remainingBytes = []; |
|||
} |
|||
|
|||
return text; |
|||
}; |
|||
}; |
|||
|
|||
const deserializeRow = (schema, rowText) => { |
|||
let currentPropIndex = 0; |
|||
let currentCharIndex = 0; |
|||
let currentValueText = ""; |
|||
let isEscaped = false; |
|||
const item = {}; |
|||
|
|||
const setCurrentProp = () => { |
|||
const currentProp = schema[currentPropIndex]; |
|||
const type = getType(currentProp.type); |
|||
const value = currentValueText === "" |
|||
? type.getDefaultValue() |
|||
: type.safeParseValue( |
|||
currentValueText); |
|||
item[currentProp.name] = value; |
|||
}; |
|||
|
|||
while(currentPropIndex < schema.length) { |
|||
|
|||
if(currentCharIndex < rowText.length) { |
|||
const currentChar = rowText[currentCharIndex]; |
|||
if(isEscaped) { |
|||
if(currentChar === "r") { |
|||
currentValueText += "\r"; |
|||
} else { |
|||
currentValueText += currentChar; |
|||
} |
|||
isEscaped = false; |
|||
} else { |
|||
if(currentChar === ",") { |
|||
setCurrentProp(); |
|||
currentValueText = ""; |
|||
currentPropIndex++; |
|||
} else if(currentChar === "\\") { |
|||
isEscaped = true; |
|||
} else { |
|||
currentValueText += currentChar; |
|||
} |
|||
} |
|||
currentCharIndex++; |
|||
} else { |
|||
currentValueText = ""; |
|||
setCurrentProp(); |
|||
currentPropIndex++; |
|||
} |
|||
} |
|||
|
|||
return item; |
|||
}; |
|||
|
|||
export const serializeItem = (schema, item) => { |
|||
|
|||
let rowText = "" |
|||
|
|||
for(let prop of schema) { |
|||
const type = getType(prop.type); |
|||
const value = has(prop.name)(item) |
|||
? item[prop.name] |
|||
: type.getDefaultValue() |
|||
|
|||
const valStr = type.stringify(value); |
|||
|
|||
for(let i = 0; i < valStr.length; i++) { |
|||
const currentChar = valStr[i]; |
|||
if(currentChar === "," |
|||
|| currentChar === "\r" |
|||
|| currentChar === "\\") { |
|||
rowText += "\\"; |
|||
} |
|||
|
|||
if(currentChar === "\r") { |
|||
rowText += "r"; |
|||
} else { |
|||
rowText += currentChar; |
|||
} |
|||
} |
|||
|
|||
rowText += ","; |
|||
} |
|||
|
|||
rowText += "\r"; |
|||
return rowText; |
|||
}; |
|||
@ -0,0 +1,124 @@ |
|||
import { compileCode } from '@nx-js/compiler-util'; |
|||
import { |
|||
filter, includes, map, last, |
|||
} from 'lodash/fp'; |
|||
import { |
|||
getActualKeyOfParent, isGlobalIndex, |
|||
getParentKey, isShardedIndex, |
|||
getExactNodeForPath, |
|||
} from '../templateApi/hierarchy'; |
|||
import { |
|||
joinKey, isNonEmptyString, splitKey, $, |
|||
} from '../common'; |
|||
|
|||
export const getIndexedDataKey = (indexNode, indexKey, record) => { |
|||
const getShardName = (indexNode, record) => { |
|||
const shardNameFunc = compileCode(indexNode.getShardName); |
|||
try { |
|||
return shardNameFunc({ record }); |
|||
} catch(e) { |
|||
const errorDetails = `shardCode: ${indexNode.getShardName} :: record: ${JSON.stringify(record)} :: ` |
|||
e.message = "Error running index shardname func: " + errorDetails + e.message; |
|||
throw e; |
|||
} |
|||
}; |
|||
|
|||
const shardName = isNonEmptyString(indexNode.getShardName) |
|||
? `${getShardName(indexNode, record)}.csv` |
|||
: 'index.csv'; |
|||
|
|||
return joinKey(indexKey, shardName); |
|||
}; |
|||
|
|||
export const getShardKeysInRange = async (app, indexKey, startRecord = null, endRecord = null) => { |
|||
const indexNode = getExactNodeForPath(app.hierarchy)(indexKey); |
|||
|
|||
const startShardName = !startRecord |
|||
? null |
|||
: shardNameFromKey( |
|||
getIndexedDataKey( |
|||
indexNode, |
|||
indexKey, |
|||
startRecord, |
|||
), |
|||
); |
|||
|
|||
const endShardName = !endRecord |
|||
? null |
|||
: shardNameFromKey( |
|||
getIndexedDataKey( |
|||
indexNode, |
|||
indexKey, |
|||
endRecord, |
|||
), |
|||
); |
|||
|
|||
return $(await getShardMap(app.datastore, indexKey), [ |
|||
filter(k => (startRecord === null || k >= startShardName) |
|||
&& (endRecord === null || k <= endShardName)), |
|||
map(k => joinKey(indexKey, `${k}.csv`)), |
|||
]); |
|||
}; |
|||
|
|||
export const ensureShardNameIsInShardMap = async (store, indexKey, indexedDataKey) => { |
|||
const map = await getShardMap(store, indexKey); |
|||
const shardName = shardNameFromKey(indexedDataKey); |
|||
if (!includes(shardName)(map)) { |
|||
map.push(shardName); |
|||
await writeShardMap(store, indexKey, map); |
|||
} |
|||
}; |
|||
|
|||
export const getShardMap = async (datastore, indexKey) => { |
|||
const shardMapKey = getShardMapKey(indexKey); |
|||
try { |
|||
return await datastore.loadJson(shardMapKey); |
|||
} catch (_) { |
|||
await datastore.createJson(shardMapKey, []); |
|||
return []; |
|||
} |
|||
}; |
|||
|
|||
export const writeShardMap = async (datastore, indexKey, shardMap) => await datastore.updateJson( |
|||
getShardMapKey(indexKey), |
|||
shardMap, |
|||
); |
|||
|
|||
export const getAllShardKeys = async (app, indexKey) => await getShardKeysInRange(app, indexKey); |
|||
|
|||
export const getShardMapKey = indexKey => joinKey(indexKey, 'shardMap.json'); |
|||
|
|||
export const getUnshardedIndexDataKey = indexKey => joinKey(indexKey, 'index.csv'); |
|||
|
|||
export const getIndexFolderKey = indexKey => indexKey; |
|||
|
|||
export const createIndexFile = async (datastore, indexedDataKey, index) => { |
|||
if (isShardedIndex(index)) { |
|||
const indexKey = getParentKey(indexedDataKey); |
|||
const shardMap = await getShardMap(datastore, indexKey); |
|||
shardMap.push( |
|||
shardNameFromKey(indexedDataKey), |
|||
); |
|||
await writeShardMap(datastore, indexKey, shardMap); |
|||
} |
|||
await datastore.createFile(indexedDataKey, ''); |
|||
}; |
|||
|
|||
export const shardNameFromKey = key => $(key, [ |
|||
splitKey, |
|||
last, |
|||
]).replace('.csv', ''); |
|||
|
|||
export const getIndexKey_BasedOnDecendant = (decendantKey, indexNode) => { |
|||
if (isGlobalIndex(indexNode)) { return `${indexNode.nodeKey()}`; } |
|||
|
|||
const indexedDataParentKey = getActualKeyOfParent( |
|||
indexNode.parent().nodeKey(), |
|||
decendantKey, |
|||
); |
|||
|
|||
return joinKey( |
|||
indexedDataParentKey, |
|||
indexNode.name, |
|||
); |
|||
}; |
|||
@ -0,0 +1,29 @@ |
|||
import { find, take, union } from 'lodash/fp'; |
|||
import { getFlattenedHierarchy } from '../templateApi/hierarchy'; |
|||
import { $, splitKey, joinKey } from '../common'; |
|||
import { NotFoundError } from '../common/errors'; |
|||
|
|||
export const customId = app => (nodeName, id) => { |
|||
const node = $(app.hierarchy, [ |
|||
getFlattenedHierarchy, |
|||
find(n => n.name === nodeName), |
|||
]); |
|||
|
|||
if (!node) throw new NotFoundError(`Cannot find node ${nodeName}`); |
|||
|
|||
return `${node.nodeId}-${id}`; |
|||
}; |
|||
|
|||
export const setCustomId = app => (record, id) => { |
|||
record.id = customId(app)(record.type, id); |
|||
|
|||
const keyParts = splitKey(record.key); |
|||
|
|||
record.key = $(keyParts, [ |
|||
take(keyParts.length - 1), |
|||
union([record.id]), |
|||
joinKey, |
|||
]); |
|||
|
|||
return record; |
|||
}; |
|||
@ -0,0 +1,96 @@ |
|||
import { |
|||
safeKey, apiWrapper, |
|||
events, joinKey, |
|||
} from '../common'; |
|||
import { _load, getRecordFileName } from './load'; |
|||
import { _deleteCollection } from '../collectionApi/delete'; |
|||
import { |
|||
getExactNodeForPath, |
|||
getFlattenedHierarchy, getNode, |
|||
fieldReversesReferenceToNode, |
|||
} from '../templateApi/hierarchy'; |
|||
import { _deleteIndex } from '../indexApi/delete'; |
|||
import { transactionForDeleteRecord } from '../transactions/create'; |
|||
import { removeFromAllIds } from '../indexing/allIds'; |
|||
import { permission } from '../authApi/permissions'; |
|||
|
|||
export const deleteRecord = (app, disableCleanup = false) => async key => apiWrapper( |
|||
app, |
|||
events.recordApi.delete, |
|||
permission.deleteRecord.isAuthorized(key), |
|||
{ key }, |
|||
_deleteRecord, app, key, disableCleanup, |
|||
); |
|||
|
|||
// called deleteRecord because delete is a keyword
|
|||
export const _deleteRecord = async (app, key, disableCleanup) => { |
|||
key = safeKey(key); |
|||
const node = getExactNodeForPath(app.hierarchy)(key); |
|||
|
|||
const record = await _load(app, key); |
|||
await transactionForDeleteRecord(app, record); |
|||
|
|||
for (const collectionRecord of node.children) { |
|||
const collectionKey = joinKey( |
|||
key, collectionRecord.collectionName, |
|||
); |
|||
await _deleteCollection(app, collectionKey, true); |
|||
} |
|||
|
|||
await app.datastore.deleteFile( |
|||
getRecordFileName(key), |
|||
); |
|||
|
|||
await deleteFiles(app, key); |
|||
|
|||
await removeFromAllIds(app.hierarchy, app.datastore)(record); |
|||
|
|||
if (!disableCleanup) { await app.cleanupTransactions(); } |
|||
|
|||
await app.datastore.deleteFolder(key); |
|||
await deleteIndexes(app, key); |
|||
}; |
|||
|
|||
const deleteIndexes = async (app, key) => { |
|||
const node = getExactNodeForPath(app.hierarchy)(key); |
|||
/* const reverseIndexKeys = $(app.hierarchy, [ |
|||
getFlattenedHierarchy, |
|||
map(n => n.fields), |
|||
flatten, |
|||
filter(isSomething), |
|||
filter(fieldReversesReferenceToNode(node)), |
|||
map(f => $(f.typeOptions.reverseIndexNodeKeys, [ |
|||
map(n => getNode( |
|||
app.hierarchy, |
|||
n)) |
|||
]) |
|||
), |
|||
flatten, |
|||
map(n => joinKey(key, n.name)) |
|||
]); |
|||
|
|||
for(let i of reverseIndexKeys) { |
|||
await _deleteIndex(app, i, true); |
|||
} */ |
|||
|
|||
|
|||
for (const index of node.indexes) { |
|||
const indexKey = joinKey(key, index.name); |
|||
await _deleteIndex(app, indexKey, true); |
|||
} |
|||
}; |
|||
|
|||
const deleteFiles = async (app, key) => { |
|||
const filesFolder = joinKey(key, 'files'); |
|||
const allFiles = await app.datastore.getFolderContents( |
|||
filesFolder, |
|||
); |
|||
|
|||
for (const file of allFiles) { |
|||
await app.datastore.deleteFile(file); |
|||
} |
|||
|
|||
await app.datastore.deleteFolder( |
|||
joinKey(key, 'files'), |
|||
); |
|||
}; |
|||
@ -0,0 +1,24 @@ |
|||
import { apiWrapper, events, isNothing } from '../common'; |
|||
import { permission } from '../authApi/permissions'; |
|||
import { safeGetFullFilePath } from './uploadFile'; |
|||
import { BadRequestError } from '../common/errors'; |
|||
|
|||
export const downloadFile = app => async (recordKey, relativePath) => apiWrapper( |
|||
app, |
|||
events.recordApi.uploadFile, |
|||
permission.readRecord.isAuthorized(recordKey), |
|||
{ recordKey, relativePath },//remove dupe key 'recordKey' from object
|
|||
_downloadFile, app, recordKey, relativePath, |
|||
); |
|||
|
|||
|
|||
const _downloadFile = async (app, recordKey, relativePath) => { |
|||
if (isNothing(recordKey)) { throw new BadRequestError('Record Key not supplied'); } |
|||
if (isNothing(relativePath)) { throw new BadRequestError('file path not supplied'); } |
|||
|
|||
return await app.datastore.readableFileStream( |
|||
safeGetFullFilePath( |
|||
recordKey, relativePath, |
|||
), |
|||
); |
|||
}; |
|||
@ -0,0 +1,75 @@ |
|||
import { has, some } from 'lodash'; |
|||
import { map, isString } from 'lodash/fp'; |
|||
import { |
|||
getExactNodeForPath, |
|||
findField, getNode, isGlobalIndex, |
|||
} from '../templateApi/hierarchy'; |
|||
import { listItems } from '../indexApi/listItems'; |
|||
import { |
|||
$, apiWrapperSync, events, |
|||
} from '../common'; |
|||
import { getIndexKey_BasedOnDecendant } from '../indexing/sharding'; |
|||
import { permission } from '../authApi/permissions'; |
|||
|
|||
export const getContext = app => recordKey => apiWrapperSync( |
|||
app, |
|||
events.recordApi.getContext, |
|||
permission.readRecord.isAuthorized(recordKey), |
|||
{ recordKey }, |
|||
_getContext, app, recordKey, |
|||
); |
|||
|
|||
export const _getContext = (app, recordKey) => { |
|||
const recordNode = getExactNodeForPath(app.hierarchy)(recordKey); |
|||
|
|||
const cachedReferenceIndexes = {}; |
|||
|
|||
const lazyLoadReferenceIndex = async (typeOptions) => { |
|||
if (!has(cachedReferenceIndexes, typeOptions.indexNodeKey)) { |
|||
cachedReferenceIndexes[typeOptions.indexNodeKey] = { |
|||
typeOptions, |
|||
data: await readReferenceIndex( |
|||
app, recordKey, typeOptions, |
|||
), |
|||
}; |
|||
} |
|||
|
|||
return cachedReferenceIndexes[typeOptions.indexNodeKey]; |
|||
}; |
|||
|
|||
const getTypeOptions = typeOptions_or_fieldName => (isString(typeOptions_or_fieldName) |
|||
? findField(recordNode, typeOptions_or_fieldName) |
|||
.typeOptions |
|||
: typeOptions_or_fieldName); |
|||
|
|||
return { |
|||
referenceExists: async (typeOptions_or_fieldName, key) => { |
|||
const typeOptions = getTypeOptions(typeOptions_or_fieldName); |
|||
const { data } = await lazyLoadReferenceIndex(typeOptions); |
|||
return some(data, i => i.key === key); |
|||
}, |
|||
referenceOptions: async (typeOptions_or_fieldName) => { |
|||
const typeOptions = getTypeOptions(typeOptions_or_fieldName); |
|||
const { data } = await lazyLoadReferenceIndex(typeOptions); |
|||
return data; |
|||
}, |
|||
recordNode, |
|||
}; |
|||
}; |
|||
|
|||
const readReferenceIndex = async (app, recordKey, typeOptions) => { |
|||
const indexNode = getNode(app.hierarchy, typeOptions.indexNodeKey); |
|||
const indexKey = isGlobalIndex(indexNode) |
|||
? indexNode.nodeKey() |
|||
: getIndexKey_BasedOnDecendant( |
|||
recordKey, indexNode, |
|||
); |
|||
|
|||
const items = await listItems(app)(indexKey); |
|||
return $(items, [ |
|||
map(i => ({ |
|||
key: i.key, |
|||
value: i[typeOptions.displayValue], |
|||
})), |
|||
]); |
|||
}; |
|||
@ -0,0 +1,44 @@ |
|||
import { |
|||
keyBy, mapValues, |
|||
} from 'lodash/fp'; |
|||
import { generate } from 'shortid'; |
|||
import { getNodeForCollectionPath } from '../templateApi/hierarchy'; |
|||
import { getNewFieldValue } from '../types'; |
|||
import { |
|||
$, joinKey, safeKey, apiWrapperSync, events, |
|||
} from '../common'; |
|||
import { permission } from '../authApi/permissions'; |
|||
|
|||
export const getNew = app => (collectionKey, recordTypeName) => { |
|||
const recordNode = getRecordNode(app, collectionKey, recordTypeName); |
|||
return apiWrapperSync( |
|||
app, |
|||
events.recordApi.getNew, |
|||
permission.createRecord.isAuthorized(recordNode.nodeKey()), |
|||
{ collectionKey, recordTypeName }, |
|||
_getNew, recordNode, collectionKey, |
|||
); |
|||
}; |
|||
|
|||
const _getNew = (recordNode, collectionKey) => constructRecord(recordNode, getNewFieldValue, collectionKey); |
|||
|
|||
const getRecordNode = (app, collectionKey) => { |
|||
collectionKey = safeKey(collectionKey); |
|||
return getNodeForCollectionPath(app.hierarchy)(collectionKey); |
|||
}; |
|||
|
|||
export const getNewChild = app => (recordKey, collectionName, recordTypeName) => |
|||
getNew(app)(joinKey(recordKey, collectionName), recordTypeName); |
|||
|
|||
export const constructRecord = (recordNode, getFieldValue, collectionKey) => { |
|||
const record = $(recordNode.fields, [ |
|||
keyBy('name'), |
|||
mapValues(getFieldValue), |
|||
]); |
|||
|
|||
record.id = `${recordNode.nodeId}-${generate()}`; |
|||
record.key = joinKey(collectionKey, record.id); |
|||
record.isNew = true; |
|||
record.type = recordNode.name; |
|||
return record; |
|||
}; |
|||
@ -0,0 +1,28 @@ |
|||
import { getNew, getNewChild } from './getNew'; |
|||
import { load } from './load'; |
|||
import { validate } from './validate'; |
|||
import { getContext } from './getContext'; |
|||
import { save } from './save'; |
|||
import { deleteRecord } from './delete'; |
|||
import { uploadFile } from './uploadFile'; |
|||
import { downloadFile } from './downloadFile'; |
|||
import { customId, setCustomId } from './customId'; |
|||
|
|||
const api = app => ({ |
|||
getNew: getNew(app), |
|||
getNewChild: getNewChild(app), |
|||
save: save(app), |
|||
load: load(app), |
|||
delete: deleteRecord(app, false), |
|||
validate: validate(app), |
|||
getContext: getContext(app), |
|||
uploadFile: uploadFile(app), |
|||
downloadFile: downloadFile(app), |
|||
customId: customId(app), |
|||
setCustomId: setCustomId(app), |
|||
}); |
|||
|
|||
|
|||
export const getRecordApi = app => api(app); |
|||
|
|||
export default getRecordApi; |
|||
@ -0,0 +1,70 @@ |
|||
import { |
|||
keyBy, mapValues, filter, |
|||
map, includes, last, |
|||
} from 'lodash/fp'; |
|||
import { getExactNodeForPath, getNode } from '../templateApi/hierarchy'; |
|||
import { safeParseField } from '../types'; |
|||
import { |
|||
$, splitKey, safeKey, isNonEmptyString, |
|||
apiWrapper, events, joinKey, |
|||
} from '../common'; |
|||
import { mapRecord } from '../indexing/evaluate'; |
|||
import { permission } from '../authApi/permissions'; |
|||
|
|||
export const getRecordFileName = key => joinKey(key, 'record.json'); |
|||
|
|||
export const load = app => async key => apiWrapper( |
|||
app, |
|||
events.recordApi.load, |
|||
permission.readRecord.isAuthorized(key), |
|||
{ key }, |
|||
_load, app, key, |
|||
); |
|||
|
|||
export const _load = async (app, key, keyStack = []) => { |
|||
key = safeKey(key); |
|||
const recordNode = getExactNodeForPath(app.hierarchy)(key); |
|||
const storedData = await app.datastore.loadJson( |
|||
getRecordFileName(key), |
|||
); |
|||
|
|||
const loadedRecord = $(recordNode.fields, [ |
|||
keyBy('name'), |
|||
mapValues(f => safeParseField(f, storedData)), |
|||
]); |
|||
|
|||
const newKeyStack = [...keyStack, key]; |
|||
|
|||
const references = $(recordNode.fields, [ |
|||
filter(f => f.type === 'reference' |
|||
&& isNonEmptyString(loadedRecord[f.name].key) |
|||
&& !includes(loadedRecord[f.name].key)(newKeyStack)), |
|||
map(f => ({ |
|||
promise: _load(app, loadedRecord[f.name].key, newKeyStack), |
|||
index: getNode(app.hierarchy, f.typeOptions.indexNodeKey), |
|||
field: f, |
|||
})), |
|||
]); |
|||
|
|||
if (references.length > 0) { |
|||
const refRecords = await Promise.all( |
|||
map(p => p.promise)(references), |
|||
); |
|||
|
|||
for (const ref of references) { |
|||
loadedRecord[ref.field.name] = mapRecord( |
|||
refRecords[references.indexOf(ref)], |
|||
ref.index, |
|||
); |
|||
} |
|||
} |
|||
|
|||
loadedRecord.transactionId = storedData.transactionId; |
|||
loadedRecord.isNew = false; |
|||
loadedRecord.key = key; |
|||
loadedRecord.id = $(key, [splitKey, last]); |
|||
loadedRecord.type = recordNode.name; |
|||
return loadedRecord; |
|||
}; |
|||
|
|||
export default load; |
|||
@ -0,0 +1,184 @@ |
|||
import { |
|||
cloneDeep, |
|||
flatten, |
|||
map, |
|||
filter, |
|||
} from 'lodash/fp'; |
|||
import { initialiseChildCollections } from '../collectionApi/initialise'; |
|||
import { validate } from './validate'; |
|||
import { _load, getRecordFileName } from './load'; |
|||
import { |
|||
apiWrapper, events, $, joinKey, |
|||
} from '../common'; |
|||
import { |
|||
getFlattenedHierarchy, |
|||
getExactNodeForPath, |
|||
isRecord, |
|||
getNode, |
|||
fieldReversesReferenceToNode, |
|||
} from '../templateApi/hierarchy'; |
|||
import { mapRecord } from '../indexing/evaluate'; |
|||
import { listItems } from '../indexApi/listItems'; |
|||
import { addToAllIds } from '../indexing/allIds'; |
|||
import { |
|||
transactionForCreateRecord, |
|||
transactionForUpdateRecord, |
|||
} from '../transactions/create'; |
|||
import { permission } from '../authApi/permissions'; |
|||
import { initialiseIndex } from '../indexing/initialiseIndex'; |
|||
import { BadRequestError } from '../common/errors'; |
|||
|
|||
export const save = app => async (record, context) => apiWrapper( |
|||
app, |
|||
events.recordApi.save, |
|||
record.isNew |
|||
? permission.createRecord.isAuthorized(record.key) |
|||
: permission.updateRecord.isAuthorized(record.key), { record }, |
|||
_save, app, record, context, false, |
|||
); |
|||
|
|||
|
|||
export const _save = async (app, record, context, skipValidation = false) => { |
|||
const recordClone = cloneDeep(record); |
|||
if (!skipValidation) { |
|||
const validationResult = await validate(app)(recordClone, context); |
|||
if (!validationResult.isValid) { |
|||
await app.publish(events.recordApi.save.onInvalid, { record, validationResult }); |
|||
throw new BadRequestError(`Save : Record Invalid : ${ |
|||
JSON.stringify(validationResult.errors)}`);
|
|||
} |
|||
} |
|||
|
|||
if (recordClone.isNew) { |
|||
await addToAllIds(app.hierarchy, app.datastore)(recordClone); |
|||
const transaction = await transactionForCreateRecord( |
|||
app, recordClone, |
|||
); |
|||
recordClone.transactionId = transaction.id; |
|||
await app.datastore.createFolder(recordClone.key); |
|||
await app.datastore.createFolder( |
|||
joinKey(recordClone.key, 'files'), |
|||
); |
|||
await app.datastore.createJson( |
|||
getRecordFileName(recordClone.key), |
|||
recordClone, |
|||
); |
|||
await initialiseReverseReferenceIndexes(app, record); |
|||
await initialiseAncestorIndexes(app, record); |
|||
await initialiseChildCollections(app, recordClone.key); |
|||
await app.publish(events.recordApi.save.onRecordCreated, { |
|||
record: recordClone, |
|||
}); |
|||
} else { |
|||
const oldRecord = await _load(app, recordClone.key); |
|||
const transaction = await transactionForUpdateRecord( |
|||
app, oldRecord, recordClone, |
|||
); |
|||
recordClone.transactionId = transaction.id; |
|||
await app.datastore.updateJson( |
|||
getRecordFileName(recordClone.key), |
|||
recordClone, |
|||
); |
|||
|
|||
await app.publish(events.recordApi.save.onRecordUpdated, { |
|||
old: oldRecord, |
|||
new: recordClone, |
|||
}); |
|||
} |
|||
|
|||
await app.cleanupTransactions(); |
|||
|
|||
const returnedClone = cloneDeep(recordClone); |
|||
returnedClone.isNew = false; |
|||
return returnedClone; |
|||
}; |
|||
|
|||
const initialiseAncestorIndexes = async (app, record) => { |
|||
const recordNode = getExactNodeForPath(app.hierarchy)(record.key); |
|||
|
|||
for (const index of recordNode.indexes) { |
|||
const indexKey = joinKey(record.key, index.name); |
|||
if (!await app.datastore.exists(indexKey)) { await initialiseIndex(app.datastore, record.key, index); } |
|||
} |
|||
}; |
|||
|
|||
const initialiseReverseReferenceIndexes = async (app, record) => { |
|||
const recordNode = getExactNodeForPath(app.hierarchy)(record.key); |
|||
|
|||
const indexNodes = $(fieldsThatReferenceThisRecord(app, recordNode), [ |
|||
map(f => $(f.typeOptions.reverseIndexNodeKeys, [ |
|||
map(n => getNode( |
|||
app.hierarchy, |
|||
n, |
|||
)), |
|||
])), |
|||
flatten, |
|||
]); |
|||
|
|||
for (const indexNode of indexNodes) { |
|||
await initialiseIndex( |
|||
app.datastore, record.key, indexNode, |
|||
); |
|||
} |
|||
}; |
|||
|
|||
const maintainReferentialIntegrity = async (app, indexingApi, oldRecord, newRecord) => { |
|||
/* |
|||
FOREACH Field that reference this object |
|||
- options Index node that for field |
|||
- has options index changed for referenced record? |
|||
- FOREACH reverse index of field |
|||
- FOREACH referencingRecord in reverse index |
|||
- Is field value still pointing to referencedRecord |
|||
- Update referencingRecord.fieldName to new value |
|||
- Save |
|||
*/ |
|||
const recordNode = getExactNodeForPath(app.hierarchy)(newRecord.key); |
|||
const referenceFields = fieldsThatReferenceThisRecord( |
|||
app, recordNode, |
|||
); |
|||
|
|||
const updates = $(referenceFields, [ |
|||
map(f => ({ |
|||
node: getNode( |
|||
app.hierarchy, f.typeOptions.indexNodeKey, |
|||
), |
|||
field: f, |
|||
})), |
|||
map(n => ({ |
|||
old: mapRecord(oldRecord, n.node), |
|||
new: mapRecord(newRecord, n.node), |
|||
indexNode: n.node, |
|||
field: n.field, |
|||
reverseIndexKeys: $(n.field.typeOptions.reverseIndexNodeKeys, [ |
|||
map(k => joinKey( |
|||
newRecord.key, |
|||
getLastPartInKey(k), |
|||
)), |
|||
]), |
|||
})), |
|||
filter(diff => !isEqual(diff.old)(diff.new)), |
|||
]); |
|||
|
|||
for (const update of updates) { |
|||
for (const reverseIndexKey of update.reverseIndexKeys) { |
|||
const rows = await listItems(app)(reverseIndexKey); |
|||
|
|||
for (const key of map(r => r.key)(rows)) { |
|||
const record = await _load(app, key); |
|||
if (record[update.field.name].key === newRecord.key) { |
|||
record[update.field.name] = update.new; |
|||
await _save(app, indexingApi, record, undefined, true); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
}; |
|||
|
|||
const fieldsThatReferenceThisRecord = (app, recordNode) => $(app.hierarchy, [ |
|||
getFlattenedHierarchy, |
|||
filter(isRecord), |
|||
map(n => n.fields), |
|||
flatten, |
|||
filter(fieldReversesReferenceToNode(recordNode)), |
|||
]); |
|||
@ -0,0 +1,128 @@ |
|||
import { |
|||
includes, filter, |
|||
map, some, |
|||
} from 'lodash/fp'; |
|||
import { generate } from 'shortid'; |
|||
import { _load } from './load'; |
|||
import { |
|||
apiWrapper, events, splitKey, |
|||
$, joinKey, isNothing, tryAwaitOrIgnore, |
|||
} from '../common'; |
|||
import { getExactNodeForPath } from '../templateApi/hierarchy'; |
|||
import { permission } from '../authApi/permissions'; |
|||
import { isLegalFilename } from '../types/file'; |
|||
import { BadRequestError, ForbiddenError } from '../common/errors'; |
|||
|
|||
export const uploadFile = app => async (recordKey, readableStream, relativeFilePath) => apiWrapper( |
|||
app, |
|||
events.recordApi.uploadFile, |
|||
permission.updateRecord.isAuthorized(recordKey), |
|||
{ recordKey, readableStream, relativeFilePath }, |
|||
_uploadFile, app, recordKey, readableStream, relativeFilePath, |
|||
); |
|||
|
|||
const _uploadFile = async (app, recordKey, readableStream, relativeFilePath) => { |
|||
if (isNothing(recordKey)) { throw new BadRequestError('Record Key not supplied'); } |
|||
if (isNothing(relativeFilePath)) { throw new BadRequestError('file path not supplied'); } |
|||
if (!isLegalFilename(relativeFilePath)) { throw new BadRequestError('Illegal filename'); } |
|||
|
|||
const record = await _load(app, recordKey); |
|||
|
|||
const fullFilePath = safeGetFullFilePath( |
|||
recordKey, relativeFilePath, |
|||
); |
|||
|
|||
const tempFilePath = `${fullFilePath}_${generate()}.temp`; |
|||
|
|||
const outputStream = await app.datastore.writableFileStream( |
|||
tempFilePath, |
|||
); |
|||
|
|||
return new Promise((resolve,reject) => { |
|||
readableStream.pipe(outputStream); |
|||
outputStream.on('error', reject); |
|||
outputStream.on('finish', resolve); |
|||
}) |
|||
.then(() => app.datastore.getFileSize(tempFilePath)) |
|||
.then(size => { |
|||
const isExpectedFileSize = checkFileSizeAgainstFields( |
|||
app, record, relativeFilePath, size |
|||
); |
|||
if (!isExpectedFileSize) { throw new BadRequestError(`Fields for ${relativeFilePath} do not have expected size: ${join(',')(incorrectFields)}`); } |
|||
|
|||
}) |
|||
.then(() => tryAwaitOrIgnore(app.datastore.deleteFile, fullFilePath)) |
|||
.then(() => app.datastore.renameFile(tempFilePath, fullFilePath)); |
|||
|
|||
/* |
|||
readableStream.pipe(outputStream); |
|||
|
|||
await new Promise(fulfill => outputStream.on('finish', fulfill)); |
|||
|
|||
const isExpectedFileSize = checkFileSizeAgainstFields( |
|||
app, |
|||
record, relativeFilePath, |
|||
await app.datastore.getFileSize(tempFilePath), |
|||
); |
|||
|
|||
if (!isExpectedFileSize) { |
|||
throw new Error( |
|||
`Fields for ${relativeFilePath} do not have expected size`); |
|||
} |
|||
|
|||
await tryAwaitOrIgnore(app.datastore.deleteFile, fullFilePath); |
|||
|
|||
await app.datastore.renameFile(tempFilePath, fullFilePath); |
|||
*/ |
|||
}; |
|||
|
|||
const checkFileSizeAgainstFields = (app, record, relativeFilePath, expectedSize) => { |
|||
const recordNode = getExactNodeForPath(app.hierarchy)(record.key); |
|||
|
|||
const incorrectFileFields = $(recordNode.fields, [ |
|||
filter(f => f.type === 'file' |
|||
&& record[f.name].relativePath === relativeFilePath |
|||
&& record[f.name].size !== expectedSize), |
|||
map(f => f.name), |
|||
]); |
|||
|
|||
const incorrectFileArrayFields = $(recordNode.fields, [ |
|||
filter(a => a.type === 'array<file>' |
|||
&& $(record[a.name], [ |
|||
some(f => record[f.name].relativePath === relativeFilePath |
|||
&& record[f.name].size !== expectedSize), |
|||
])), |
|||
map(f => f.name), |
|||
]); |
|||
|
|||
const incorrectFields = [ |
|||
...incorrectFileFields, |
|||
...incorrectFileArrayFields, |
|||
]; |
|||
|
|||
if (incorrectFields.length > 0) { |
|||
return false; |
|||
} |
|||
|
|||
return true; |
|||
}; |
|||
|
|||
export const safeGetFullFilePath = (recordKey, relativeFilePath) => { |
|||
const naughtyUser = () => { throw new ForbiddenError('naughty naughty'); }; |
|||
|
|||
if (relativeFilePath.startsWith('..')) naughtyUser(); |
|||
|
|||
const pathParts = splitKey(relativeFilePath); |
|||
|
|||
if (includes('..')(pathParts)) naughtyUser(); |
|||
|
|||
const recordKeyParts = splitKey(recordKey); |
|||
|
|||
const fullPathParts = [ |
|||
...recordKeyParts, |
|||
'files', |
|||
...filter(p => p !== '.')(pathParts), |
|||
]; |
|||
|
|||
return joinKey(fullPathParts); |
|||
}; |
|||
@ -0,0 +1,85 @@ |
|||
import { |
|||
map, reduce, filter, |
|||
isEmpty, flatten, each, |
|||
} from 'lodash/fp'; |
|||
import { compileExpression } from '@nx-js/compiler-util'; |
|||
import _ from 'lodash'; |
|||
import { getExactNodeForPath } from '../templateApi/hierarchy'; |
|||
import { validateFieldParse, validateTypeConstraints } from '../types'; |
|||
import { $, isNothing, isNonEmptyString } from '../common'; |
|||
import { _getContext } from './getContext'; |
|||
|
|||
const fieldParseError = (fieldName, value) => ({ |
|||
fields: [fieldName], |
|||
message: `Could not parse field ${fieldName}:${value}`, |
|||
}); |
|||
|
|||
const validateAllFieldParse = (record, recordNode) => $(recordNode.fields, [ |
|||
map(f => ({ name: f.name, parseResult: validateFieldParse(f, record) })), |
|||
reduce((errors, f) => { |
|||
if (f.parseResult.success) return errors; |
|||
errors.push( |
|||
fieldParseError(f.name, f.parseResult.value), |
|||
); |
|||
return errors; |
|||
}, []), |
|||
]); |
|||
|
|||
const validateAllTypeConstraints = async (record, recordNode, context) => { |
|||
const errors = []; |
|||
for (const field of recordNode.fields) { |
|||
$(await validateTypeConstraints(field, record, context), [ |
|||
filter(isNonEmptyString), |
|||
map(m => ({ message: m, fields: [field.name] })), |
|||
each(e => errors.push(e)), |
|||
]); |
|||
} |
|||
return errors; |
|||
}; |
|||
|
|||
const runRecordValidationRules = (record, recordNode) => { |
|||
const runValidationRule = (rule) => { |
|||
const isValid = compileExpression(rule.expressionWhenValid); |
|||
const expressionContext = { record, _ }; |
|||
return (isValid(expressionContext) |
|||
? { valid: true } |
|||
: ({ |
|||
valid: false, |
|||
fields: rule.invalidFields, |
|||
message: rule.messageWhenInvalid, |
|||
})); |
|||
}; |
|||
|
|||
return $(recordNode.validationRules, [ |
|||
map(runValidationRule), |
|||
flatten, |
|||
filter(r => r.valid === false), |
|||
map(r => ({ fields: r.fields, message: r.message })), |
|||
]); |
|||
}; |
|||
|
|||
export const validate = app => async (record, context) => { |
|||
context = isNothing(context) |
|||
? _getContext(app, record.key) |
|||
: context; |
|||
|
|||
const recordNode = getExactNodeForPath(app.hierarchy)(record.key); |
|||
const fieldParseFails = validateAllFieldParse(record, recordNode); |
|||
|
|||
// non parsing would cause further issues - exit here
|
|||
if (!isEmpty(fieldParseFails)) { return ({ isValid: false, errors: fieldParseFails }); } |
|||
|
|||
const recordValidationRuleFails = runRecordValidationRules(record, recordNode); |
|||
const typeContraintFails = await validateAllTypeConstraints(record, recordNode, context); |
|||
|
|||
if (isEmpty(fieldParseFails) |
|||
&& isEmpty(recordValidationRuleFails) |
|||
&& isEmpty(typeContraintFails)) { |
|||
return ({ isValid: true, errors: [] }); |
|||
} |
|||
|
|||
return ({ |
|||
isValid: false, |
|||
errors: _.union(fieldParseFails, typeContraintFails, recordValidationRuleFails), |
|||
}); |
|||
}; |
|||
@ -0,0 +1,23 @@ |
|||
|
|||
export const createTrigger = () => ({ |
|||
actionName: '', |
|||
eventName: '', |
|||
// function, has access to event context,
|
|||
// returns object that is used as parameter to action
|
|||
// only used if triggered by event
|
|||
optionsCreator: '', |
|||
// action runs if true,
|
|||
// has access to event context
|
|||
condition: '', |
|||
}); |
|||
|
|||
export const createAction = () => ({ |
|||
name: '', |
|||
behaviourSource: '', |
|||
// name of function in actionSource
|
|||
behaviourName: '', |
|||
// parameter passed into behaviour.
|
|||
// any other parms passed at runtime e.g.
|
|||
// by trigger, or manually, will be merged into this
|
|||
initialOptions: {}, |
|||
}); |
|||
@ -0,0 +1,223 @@ |
|||
import { each, constant, find } from 'lodash'; |
|||
import { map, max } from 'lodash/fp'; |
|||
import { |
|||
switchCase, defaultCase, joinKey, |
|||
$, isNothing, isSomething, |
|||
} from '../common'; |
|||
import { |
|||
isIndex, isRoot, isSingleRecord, isCollectionRecord, |
|||
isRecord, isaggregateGroup, |
|||
getFlattenedHierarchy, |
|||
} from './hierarchy'; |
|||
import { all } from '../types'; |
|||
import { BadRequestError } from '../common/errors'; |
|||
|
|||
export const createNodeErrors = { |
|||
indexCannotBeParent: 'Index template cannot be a parent', |
|||
allNonRootNodesMustHaveParent: 'Only the root node may have no parent', |
|||
indexParentMustBeRecordOrRoot: 'An index may only have a record or root as a parent', |
|||
aggregateParentMustBeAnIndex: 'aggregateGroup parent must be an index', |
|||
}; |
|||
|
|||
const pathRegxMaker = node => () => node.nodeKey().replace(/{id}/g, '[a-zA-Z0-9_-]+'); |
|||
|
|||
const nodeKeyMaker = node => () => switchCase( |
|||
|
|||
[n => isRecord(n) && !isSingleRecord(n), |
|||
n => joinKey( |
|||
node.parent().nodeKey(), |
|||
node.collectionName, |
|||
`${n.nodeId}-{id}`, |
|||
)], |
|||
|
|||
[isRoot, |
|||
constant('/')], |
|||
|
|||
[defaultCase, |
|||
n => joinKey(node.parent().nodeKey(), n.name)], |
|||
|
|||
)(node); |
|||
|
|||
|
|||
const validate = parent => (node) => { |
|||
if (isIndex(node) |
|||
&& isSomething(parent) |
|||
&& !isRoot(parent) |
|||
&& !isRecord(parent)) { |
|||
throw new BadRequestError(createNodeErrors.indexParentMustBeRecordOrRoot); |
|||
} |
|||
|
|||
if (isaggregateGroup(node) |
|||
&& isSomething(parent) |
|||
&& !isIndex(parent)) { |
|||
throw new BadRequestError(createNodeErrors.aggregateParentMustBeAnIndex); |
|||
} |
|||
|
|||
if (isNothing(parent) && !isRoot(node)) { throw new BadRequestError(createNodeErrors.allNonRootNodesMustHaveParent); } |
|||
|
|||
return node; |
|||
}; |
|||
|
|||
const construct = parent => (node) => { |
|||
node.nodeKey = nodeKeyMaker(node); |
|||
node.pathRegx = pathRegxMaker(node); |
|||
node.parent = constant(parent); |
|||
node.isRoot = () => isNothing(parent) |
|||
&& node.name === 'root' |
|||
&& node.type === 'root'; |
|||
if (isCollectionRecord(node)) { |
|||
node.collectionNodeKey = () => joinKey( |
|||
parent.nodeKey(), node.collectionName, |
|||
); |
|||
node.collectionPathRegx = () => joinKey( |
|||
parent.pathRegx(), node.collectionName, |
|||
); |
|||
} |
|||
return node; |
|||
}; |
|||
|
|||
const addToParent = (obj) => { |
|||
const parent = obj.parent(); |
|||
if (isSomething(parent)) { |
|||
if (isIndex(obj)) |
|||
// Q: why are indexes not children ?
|
|||
// A: because they cannot have children of their own.
|
|||
{ parent.indexes.push(obj); } else if (isaggregateGroup(obj)) { parent.aggregateGroups.push(obj); } else { parent.children.push(obj); } |
|||
|
|||
if (isRecord(obj)) { |
|||
const defaultIndex = find( |
|||
parent.indexes, |
|||
i => i.name === `${parent.name}_index`, |
|||
); |
|||
if (defaultIndex) { |
|||
defaultIndex.allowedRecordNodeIds.push(obj.nodeId); |
|||
} |
|||
} |
|||
} |
|||
return obj; |
|||
}; |
|||
|
|||
export const constructNode = (parent, obj) => $(obj, [ |
|||
construct(parent), |
|||
validate(parent), |
|||
addToParent, |
|||
]); |
|||
|
|||
const getNodeId = (parentNode) => { |
|||
// this case is handled better elsewhere
|
|||
if (!parentNode) return null; |
|||
const findRoot = n => (isRoot(n) ? n : findRoot(n.parent())); |
|||
const root = findRoot(parentNode); |
|||
|
|||
return ($(root, [ |
|||
getFlattenedHierarchy, |
|||
map(n => n.nodeId), |
|||
max]) + 1); |
|||
}; |
|||
|
|||
export const constructHierarchy = (node, parent) => { |
|||
construct(parent)(node); |
|||
if (node.indexes) { |
|||
each(node.indexes, |
|||
child => constructHierarchy(child, node)); |
|||
} |
|||
if (node.aggregateGroups) { |
|||
each(node.aggregateGroups, |
|||
child => constructHierarchy(child, node)); |
|||
} |
|||
if (node.children && node.children.length > 0) { |
|||
each(node.children, |
|||
child => constructHierarchy(child, node)); |
|||
} |
|||
if (node.fields) { |
|||
each(node.fields, |
|||
f => each(f.typeOptions, (val, key) => { |
|||
const def = all[f.type].optionDefinitions[key]; |
|||
if (!def) { |
|||
// unknown typeOption
|
|||
delete f.typeOptions[key]; |
|||
} else { |
|||
f.typeOptions[key] = def.parse(val); |
|||
} |
|||
})); |
|||
} |
|||
return node; |
|||
}; |
|||
|
|||
|
|||
export const getNewRootLevel = () => construct()({ |
|||
name: 'root', |
|||
type: 'root', |
|||
children: [], |
|||
pathMaps: [], |
|||
indexes: [], |
|||
nodeId: 0, |
|||
}); |
|||
|
|||
const _getNewRecordTemplate = (parent, name, createDefaultIndex, isSingle) => { |
|||
const node = constructNode(parent, { |
|||
name, |
|||
type: 'record', |
|||
fields: [], |
|||
children: [], |
|||
validationRules: [], |
|||
nodeId: getNodeId(parent), |
|||
indexes: [], |
|||
allidsShardFactor: isRecord(parent) ? 1 : 64, |
|||
collectionName: '', |
|||
isSingle, |
|||
}); |
|||
|
|||
if (createDefaultIndex) { |
|||
const defaultIndex = getNewIndexTemplate(parent); |
|||
defaultIndex.name = `${name}_index`; |
|||
defaultIndex.allowedRecordNodeIds.push(node.nodeId); |
|||
} |
|||
|
|||
return node; |
|||
}; |
|||
|
|||
export const getNewRecordTemplate = (parent, name = '', createDefaultIndex = true) => _getNewRecordTemplate(parent, name, createDefaultIndex, false); |
|||
|
|||
export const getNewSingleRecordTemplate = parent => _getNewRecordTemplate(parent, '', false, true); |
|||
|
|||
export const getNewIndexTemplate = (parent, type = 'ancestor') => constructNode(parent, { |
|||
name: '', |
|||
type: 'index', |
|||
map: 'return {...record};', |
|||
filter: '', |
|||
indexType: type, |
|||
getShardName: '', |
|||
getSortKey: 'record.id', |
|||
aggregateGroups: [], |
|||
allowedRecordNodeIds: [], |
|||
nodeId: getNodeId(parent), |
|||
}); |
|||
|
|||
export const getNewAggregateGroupTemplate = index => constructNode(index, { |
|||
name: '', |
|||
type: 'aggregateGroup', |
|||
groupBy: '', |
|||
aggregates: [], |
|||
condition: '', |
|||
nodeId: getNodeId(index), |
|||
}); |
|||
|
|||
export const getNewAggregateTemplate = (set) => { |
|||
const aggregatedValue = { |
|||
name: '', |
|||
aggregatedValue: '', |
|||
}; |
|||
set.aggregates.push(aggregatedValue); |
|||
return aggregatedValue; |
|||
}; |
|||
|
|||
export default { |
|||
getNewRootLevel, |
|||
getNewRecordTemplate, |
|||
getNewIndexTemplate, |
|||
createNodeErrors, |
|||
constructHierarchy, |
|||
getNewAggregateGroupTemplate, |
|||
getNewAggregateTemplate, |
|||
}; |
|||
@ -0,0 +1,12 @@ |
|||
import { |
|||
} from '../templateApi/heirarchy'; |
|||
|
|||
export const canDelete = (app, node) => { |
|||
|
|||
/* |
|||
it must not exist on any index.allowedRecordNodeIds |
|||
it must not exist on and reference type fields |
|||
these rules should apply to any child nodes , which will also be deleted |
|||
*/ |
|||
|
|||
}; |
|||
@ -0,0 +1,87 @@ |
|||
import { |
|||
some, map, filter, keys, includes, |
|||
countBy, flatten, |
|||
} from 'lodash/fp'; |
|||
import { |
|||
isSomething, $, |
|||
isNonEmptyString, |
|||
isNothingOrEmpty, |
|||
isNothing, |
|||
} from '../common'; |
|||
import { all, getDefaultOptions } from '../types'; |
|||
import { applyRuleSet, makerule } from '../common/validationCommon'; |
|||
import { BadRequestError } from '../common/errors'; |
|||
|
|||
export const fieldErrors = { |
|||
AddFieldValidationFailed: 'Add field validation: ', |
|||
}; |
|||
|
|||
export const allowedTypes = () => keys(all); |
|||
|
|||
export const getNewField = type => ({ |
|||
name: '', // how field is referenced internally
|
|||
type, |
|||
typeOptions: getDefaultOptions(type), |
|||
label: '', // how field is displayed
|
|||
getInitialValue: 'default', // function that gets value when initially created
|
|||
getUndefinedValue: 'default', // function that gets value when field undefined on record
|
|||
}); |
|||
|
|||
const fieldRules = allFields => [ |
|||
makerule('name', 'field name is not set', |
|||
f => isNonEmptyString(f.name)), |
|||
makerule('type', 'field type is not set', |
|||
f => isNonEmptyString(f.type)), |
|||
makerule('label', 'field label is not set', |
|||
f => isNonEmptyString(f.label)), |
|||
makerule('getInitialValue', 'getInitialValue function is not set', |
|||
f => isNonEmptyString(f.getInitialValue)), |
|||
makerule('getUndefinedValue', 'getUndefinedValue function is not set', |
|||
f => isNonEmptyString(f.getUndefinedValue)), |
|||
makerule('name', 'field name is duplicated', |
|||
f => isNothingOrEmpty(f.name) |
|||
|| countBy('name')(allFields)[f.name] === 1), |
|||
makerule('type', 'type is unknown', |
|||
f => isNothingOrEmpty(f.type) |
|||
|| some(t => f.type === t)(allowedTypes())), |
|||
]; |
|||
|
|||
const typeOptionsRules = (field) => { |
|||
const type = all[field.type]; |
|||
if (isNothing(type)) return []; |
|||
|
|||
const def = optName => type.optionDefinitions[optName]; |
|||
|
|||
return $(field.typeOptions, [ |
|||
keys, |
|||
filter(o => isSomething(def(o)) |
|||
&& isSomething(def(o).isValid)), |
|||
map(o => makerule( |
|||
`typeOptions.${o}`, |
|||
`${def(o).requirementDescription}`, |
|||
field => def(o).isValid(field.typeOptions[o]), |
|||
)), |
|||
]); |
|||
}; |
|||
|
|||
export const validateField = allFields => (field) => { |
|||
const everySingleField = includes(field)(allFields) ? allFields : [...allFields, field]; |
|||
return applyRuleSet([...fieldRules(everySingleField), ...typeOptionsRules(field)])(field); |
|||
}; |
|||
|
|||
export const validateAllFields = recordNode => $(recordNode.fields, [ |
|||
map(validateField(recordNode.fields)), |
|||
flatten, |
|||
]); |
|||
|
|||
export const addField = (recordTemplate, field) => { |
|||
if (isNothingOrEmpty(field.label)) { |
|||
field.label = field.name; |
|||
} |
|||
const validationMessages = validateField([...recordTemplate.fields, field])(field); |
|||
if (validationMessages.length > 0) { |
|||
const errors = map(m => m.error)(validationMessages); |
|||
throw new BadRequestError(`${fieldErrors.AddFieldValidationFailed} ${errors.join(', ')}`); |
|||
} |
|||
recordTemplate.fields.push(field); |
|||
}; |
|||
@ -0,0 +1,14 @@ |
|||
import { appDefinitionFile } from '../common'; |
|||
import { constructHierarchy } from './createNodes'; |
|||
|
|||
export const getApplicationDefinition = datastore => async () => { |
|||
const exists = await datastore.exists(appDefinitionFile); |
|||
|
|||
if (!exists) throw new Error('Application definition does not exist'); |
|||
|
|||
const appDefinition = await datastore.loadJson(appDefinitionFile); |
|||
appDefinition.hierarchy = constructHierarchy( |
|||
appDefinition.hierarchy, |
|||
); |
|||
return appDefinition; |
|||
}; |
|||
@ -0,0 +1,4 @@ |
|||
|
|||
export const getBehaviourSources = async (datastore) => { |
|||
await datastore.loadFile('/.config/behaviourSources.js'); |
|||
}; |
|||
@ -0,0 +1,234 @@ |
|||
import { |
|||
find, constant, map, last, |
|||
first, split, intersection, take, |
|||
union, includes, filter, some, |
|||
} from 'lodash/fp'; |
|||
import { |
|||
$, switchCase, isNothing, isSomething, |
|||
defaultCase, splitKey, isNonEmptyString, |
|||
joinKey, getHashCode, |
|||
} from '../common'; |
|||
import { indexTypes } from './indexes'; |
|||
|
|||
export const getFlattenedHierarchy = (appHierarchy, useCached = true) => { |
|||
if (isSomething(appHierarchy.getFlattenedHierarchy) && useCached) { return appHierarchy.getFlattenedHierarchy(); } |
|||
|
|||
const flattenHierarchy = (currentNode, flattened) => { |
|||
flattened.push(currentNode); |
|||
if ((!currentNode.children |
|||
|| currentNode.children.length === 0) |
|||
&& (!currentNode.indexes |
|||
|| currentNode.indexes.length === 0) |
|||
&& (!currentNode.aggregateGroups |
|||
|| currentNode.aggregateGroups.length === 0)) { |
|||
return flattened; |
|||
} |
|||
|
|||
const unionIfAny = l2 => l1 => union(l1)(!l2 ? [] : l2); |
|||
|
|||
const children = $([], [ |
|||
unionIfAny(currentNode.children), |
|||
unionIfAny(currentNode.indexes), |
|||
unionIfAny(currentNode.aggregateGroups), |
|||
]); |
|||
|
|||
for (const child of children) { |
|||
flattenHierarchy(child, flattened); |
|||
} |
|||
return flattened; |
|||
}; |
|||
|
|||
appHierarchy.getFlattenedHierarchy = () => flattenHierarchy(appHierarchy, []); |
|||
return appHierarchy.getFlattenedHierarchy(); |
|||
}; |
|||
|
|||
export const getLastPartInKey = key => last(splitKey(key)); |
|||
|
|||
export const getNodesInPath = appHierarchy => key => $(appHierarchy, [ |
|||
getFlattenedHierarchy, |
|||
filter(n => new RegExp(`${n.pathRegx()}`).test(key)), |
|||
]); |
|||
|
|||
export const getExactNodeForPath = appHierarchy => key => $(appHierarchy, [ |
|||
getFlattenedHierarchy, |
|||
find(n => new RegExp(`${n.pathRegx()}$`).test(key)), |
|||
]); |
|||
|
|||
export const getNodeForCollectionPath = appHierarchy => collectionKey => $(appHierarchy, [ |
|||
getFlattenedHierarchy, |
|||
find(n => (isCollectionRecord(n) |
|||
&& new RegExp(`${n.collectionPathRegx()}$`).test(collectionKey))), |
|||
]); |
|||
|
|||
export const hasMatchingAncestor = ancestorPredicate => decendantNode => switchCase( |
|||
|
|||
[node => isNothing(node.parent()), |
|||
constant(false)], |
|||
|
|||
[node => ancestorPredicate(node.parent()), |
|||
constant(true)], |
|||
|
|||
[defaultCase, |
|||
node => hasMatchingAncestor(ancestorPredicate)(node.parent())], |
|||
|
|||
)(decendantNode); |
|||
|
|||
export const getNode = (appHierarchy, nodeKey) => $(appHierarchy, [ |
|||
getFlattenedHierarchy, |
|||
find(n => n.nodeKey() === nodeKey |
|||
|| (isCollectionRecord(n) |
|||
&& n.collectionNodeKey() === nodeKey)), |
|||
]); |
|||
|
|||
export const getCollectionNode = (appHierarchy, nodeKey) => $(appHierarchy, [ |
|||
getFlattenedHierarchy, |
|||
find(n => (isCollectionRecord(n) |
|||
&& n.collectionNodeKey() === nodeKey)), |
|||
]); |
|||
|
|||
export const getNodeByKeyOrNodeKey = (appHierarchy, keyOrNodeKey) => { |
|||
const nodeByKey = getExactNodeForPath(appHierarchy)(keyOrNodeKey); |
|||
return isNothing(nodeByKey) |
|||
? getNode(appHierarchy, keyOrNodeKey) |
|||
: nodeByKey; |
|||
}; |
|||
|
|||
export const getCollectionNodeByKeyOrNodeKey = (appHierarchy, keyOrNodeKey) => { |
|||
const nodeByKey = getNodeForCollectionPath(appHierarchy)(keyOrNodeKey); |
|||
return isNothing(nodeByKey) |
|||
? getCollectionNode(appHierarchy, keyOrNodeKey) |
|||
: nodeByKey; |
|||
}; |
|||
|
|||
export const isNode = (appHierarchy, key) => isSomething(getExactNodeForPath(appHierarchy)(key)); |
|||
|
|||
export const getActualKeyOfParent = (parentNodeKey, actualChildKey) => $(actualChildKey, [ |
|||
splitKey, |
|||
take(splitKey(parentNodeKey).length), |
|||
ks => joinKey(...ks), |
|||
]); |
|||
|
|||
export const getParentKey = (key) => { |
|||
return $(key, [ |
|||
splitKey, |
|||
take(splitKey(key).length - 1), |
|||
joinKey, |
|||
]); |
|||
}; |
|||
|
|||
export const isKeyAncestorOf = ancestorKey => decendantNode => hasMatchingAncestor(p => p.nodeKey() === ancestorKey)(decendantNode); |
|||
|
|||
export const hasNoMatchingAncestors = parentPredicate => node => !hasMatchingAncestor(parentPredicate)(node); |
|||
|
|||
export const findField = (recordNode, fieldName) => find(f => f.name == fieldName)(recordNode.fields); |
|||
|
|||
export const isAncestor = decendant => ancestor => isKeyAncestorOf(ancestor.nodeKey())(decendant); |
|||
|
|||
export const isDecendant = ancestor => decendant => isAncestor(decendant)(ancestor); |
|||
|
|||
export const getRecordNodeId = recordKey => $(recordKey, [ |
|||
splitKey, |
|||
last, |
|||
getRecordNodeIdFromId, |
|||
]); |
|||
|
|||
export const getRecordNodeIdFromId = recordId => $(recordId, [split('-'), first, parseInt]); |
|||
|
|||
export const getRecordNodeById = (hierarchy, recordId) => $(hierarchy, [ |
|||
getFlattenedHierarchy, |
|||
find(n => isRecord(n) |
|||
&& n.nodeId === getRecordNodeIdFromId(recordId)), |
|||
]); |
|||
|
|||
export const recordNodeIdIsAllowed = indexNode => nodeId => indexNode.allowedRecordNodeIds.length === 0 |
|||
|| includes(nodeId)(indexNode.allowedRecordNodeIds); |
|||
|
|||
export const recordNodeIsAllowed = indexNode => recordNode => recordNodeIdIsAllowed(indexNode)(recordNode.nodeId); |
|||
|
|||
export const getAllowedRecordNodesForIndex = (appHierarchy, indexNode) => { |
|||
const recordNodes = $(appHierarchy, [ |
|||
getFlattenedHierarchy, |
|||
filter(isRecord), |
|||
]); |
|||
|
|||
if (isGlobalIndex(indexNode)) { |
|||
return $(recordNodes, [ |
|||
filter(recordNodeIsAllowed(indexNode)), |
|||
]); |
|||
} |
|||
|
|||
if (isAncestorIndex(indexNode)) { |
|||
return $(recordNodes, [ |
|||
filter(isDecendant(indexNode.parent())), |
|||
filter(recordNodeIsAllowed(indexNode)), |
|||
]); |
|||
} |
|||
|
|||
if (isReferenceIndex(indexNode)) { |
|||
return $(recordNodes, [ |
|||
filter(n => some(fieldReversesReferenceToIndex(indexNode))(n.fields)), |
|||
]); |
|||
} |
|||
}; |
|||
|
|||
export const getNodeFromNodeKeyHash = hierarchy => hash => $(hierarchy, [ |
|||
getFlattenedHierarchy, |
|||
find(n => getHashCode(n.nodeKey()) === hash), |
|||
]); |
|||
|
|||
export const isRecord = node => isSomething(node) && node.type === 'record'; |
|||
export const isSingleRecord = node => isRecord(node) && node.isSingle; |
|||
export const isCollectionRecord = node => isRecord(node) && !node.isSingle; |
|||
export const isIndex = node => isSomething(node) && node.type === 'index'; |
|||
export const isaggregateGroup = node => isSomething(node) && node.type === 'aggregateGroup'; |
|||
export const isShardedIndex = node => isIndex(node) && isNonEmptyString(node.getShardName); |
|||
export const isRoot = node => isSomething(node) && node.isRoot(); |
|||
export const isDecendantOfARecord = hasMatchingAncestor(isRecord); |
|||
export const isGlobalIndex = node => isIndex(node) && isRoot(node.parent()); |
|||
export const isReferenceIndex = node => isIndex(node) && node.indexType === indexTypes.reference; |
|||
export const isAncestorIndex = node => isIndex(node) && node.indexType === indexTypes.ancestor; |
|||
|
|||
export const fieldReversesReferenceToNode = node => field => field.type === 'reference' |
|||
&& intersection(field.typeOptions.reverseIndexNodeKeys)(map(i => i.nodeKey())(node.indexes)) |
|||
.length > 0; |
|||
|
|||
export const fieldReversesReferenceToIndex = indexNode => field => field.type === 'reference' |
|||
&& intersection(field.typeOptions.reverseIndexNodeKeys)([indexNode.nodeKey()]) |
|||
.length > 0; |
|||
|
|||
export default { |
|||
getLastPartInKey, |
|||
getNodesInPath, |
|||
getExactNodeForPath, |
|||
hasMatchingAncestor, |
|||
getNode, |
|||
getNodeByKeyOrNodeKey, |
|||
isNode, |
|||
getActualKeyOfParent, |
|||
getParentKey, |
|||
isKeyAncestorOf, |
|||
hasNoMatchingAncestors, |
|||
findField, |
|||
isAncestor, |
|||
isDecendant, |
|||
getRecordNodeId, |
|||
getRecordNodeIdFromId, |
|||
getRecordNodeById, |
|||
recordNodeIdIsAllowed, |
|||
recordNodeIsAllowed, |
|||
getAllowedRecordNodesForIndex, |
|||
getNodeFromNodeKeyHash, |
|||
isRecord, |
|||
isCollectionRecord, |
|||
isIndex, |
|||
isaggregateGroup, |
|||
isShardedIndex, |
|||
isRoot, |
|||
isDecendantOfARecord, |
|||
isGlobalIndex, |
|||
isReferenceIndex, |
|||
isAncestorIndex, |
|||
fieldReversesReferenceToNode, |
|||
fieldReversesReferenceToIndex, |
|||
getFlattenedHierarchy, |
|||
}; |
|||
@ -0,0 +1,64 @@ |
|||
import { |
|||
getNewRootLevel, |
|||
getNewRecordTemplate, getNewIndexTemplate, |
|||
createNodeErrors, constructHierarchy, |
|||
getNewAggregateGroupTemplate, getNewSingleRecordTemplate, |
|||
getNewAggregateTemplate, constructNode, |
|||
} |
|||
from './createNodes'; |
|||
import { |
|||
getNewField, validateField, |
|||
addField, fieldErrors, |
|||
} from './fields'; |
|||
import { |
|||
getNewRecordValidationRule, commonRecordValidationRules, |
|||
addRecordValidationRule, |
|||
} from './recordValidationRules'; |
|||
import { createAction, createTrigger } from './createActions'; |
|||
import { |
|||
validateTriggers, validateTrigger, validateNode, |
|||
validateActions, validateAll, |
|||
} from './validate'; |
|||
import { getApplicationDefinition } from './getApplicationDefinition'; |
|||
import { saveApplicationHierarchy } from './saveApplicationHierarchy'; |
|||
import { saveActionsAndTriggers } from './saveActionsAndTriggers'; |
|||
import { all } from '../types'; |
|||
import { getBehaviourSources } from "./getBehaviourSources"; |
|||
|
|||
const api = app => ({ |
|||
|
|||
getApplicationDefinition: getApplicationDefinition(app.datastore), |
|||
saveApplicationHierarchy: saveApplicationHierarchy(app), |
|||
saveActionsAndTriggers: saveActionsAndTriggers(app), |
|||
getBehaviourSources: () => getBehaviourSources(app.datastore), |
|||
getNewRootLevel, |
|||
constructNode, |
|||
getNewIndexTemplate, |
|||
getNewRecordTemplate, |
|||
getNewField, |
|||
validateField, |
|||
addField, |
|||
fieldErrors, |
|||
getNewRecordValidationRule, |
|||
commonRecordValidationRules, |
|||
addRecordValidationRule, |
|||
createAction, |
|||
createTrigger, |
|||
validateActions, |
|||
validateTrigger, |
|||
getNewAggregateGroupTemplate, |
|||
getNewAggregateTemplate, |
|||
constructHierarchy, |
|||
getNewSingleRecordTemplate, |
|||
allTypes: all, |
|||
validateNode, |
|||
validateAll, |
|||
validateTriggers, |
|||
}); |
|||
|
|||
|
|||
export const getTemplateApi = app => api(app); |
|||
|
|||
export const errors = createNodeErrors; |
|||
|
|||
export default getTemplateApi; |
|||
@ -0,0 +1,38 @@ |
|||
import { |
|||
map, isEmpty, countBy, flatten, includes, |
|||
} from 'lodash/fp'; |
|||
import { join, keys } from 'lodash'; |
|||
import { applyRuleSet, makerule } from '../common/validationCommon'; |
|||
import { compileFilter, compileMap } from '../indexing/evaluate'; |
|||
import { isNonEmptyString, executesWithoutException, $ } from '../common'; |
|||
import { isRecord } from './hierarchy'; |
|||
|
|||
export const indexTypes = { reference: 'reference', ancestor: 'ancestor' }; |
|||
|
|||
export const indexRuleSet = [ |
|||
makerule('map', 'index has no map function', |
|||
index => isNonEmptyString(index.map)), |
|||
makerule('map', "index's map function does not compile", |
|||
index => !isNonEmptyString(index.map) |
|||
|| executesWithoutException(() => compileMap(index))), |
|||
makerule('filter', "index's filter function does not compile", |
|||
index => !isNonEmptyString(index.filter) |
|||
|| executesWithoutException(() => compileFilter(index))), |
|||
makerule('name', 'must declare a name for index', |
|||
index => isNonEmptyString(index.name)), |
|||
makerule('name', 'there is a duplicate named index on this node', |
|||
index => isEmpty(index.name) |
|||
|| countBy('name')(index.parent().indexes)[index.name] === 1), |
|||
makerule('indexType', 'reference index may only exist on a record node', |
|||
index => isRecord(index.parent()) |
|||
|| index.indexType !== indexTypes.reference), |
|||
makerule('indexType', `index type must be one of: ${join(', ', keys(indexTypes))}`, |
|||
index => includes(index.indexType)(keys(indexTypes))), |
|||
]; |
|||
|
|||
export const validateIndex = (index, allReferenceIndexesOnNode) => applyRuleSet(indexRuleSet(allReferenceIndexesOnNode))(index); |
|||
|
|||
export const validateAllIndexes = node => $(node.indexes, [ |
|||
map(i => validateIndex(i, node.indexes)), |
|||
flatten, |
|||
]); |
|||
@ -0,0 +1,37 @@ |
|||
import { isNumber, isBoolean, defaultCase } from 'lodash/fp'; |
|||
import { switchCase } from '../common'; |
|||
|
|||
export const getNewRecordValidationRule = (invalidFields, |
|||
messageWhenInvalid, |
|||
expressionWhenValid) => ({ |
|||
invalidFields, messageWhenInvalid, expressionWhenValid, |
|||
}); |
|||
|
|||
const getStaticValue = switchCase( |
|||
[isNumber, v => v.toString()], |
|||
[isBoolean, v => v.toString()], |
|||
[defaultCase, v => `'${v}'`], |
|||
); |
|||
|
|||
export const commonRecordValidationRules = ({ |
|||
|
|||
fieldNotEmpty: fieldName => getNewRecordValidationRule( |
|||
[fieldName], |
|||
`${fieldName} is empty`, |
|||
`!_.isEmpty(record['${fieldName}'])`, |
|||
), |
|||
|
|||
fieldBetween: (fieldName, min, max) => getNewRecordValidationRule( |
|||
[fieldName], |
|||
`${fieldName} must be between ${min.toString()} and ${max.toString()}`, |
|||
`record['${fieldName}'] >= ${getStaticValue(min)} && record['${fieldName}'] <= ${getStaticValue(max)} `, |
|||
), |
|||
|
|||
fieldGreaterThan: (fieldName, min, max) => getNewRecordValidationRule( |
|||
[fieldName], |
|||
`${fieldName} must be greater than ${min.toString()} and ${max.toString()}`, |
|||
`record['${fieldName}'] >= ${getStaticValue(min)} `, |
|||
), |
|||
}); |
|||
|
|||
export const addRecordValidationRule = recordNode => rule => recordNode.validationRules.push(rule); |
|||
@ -0,0 +1,40 @@ |
|||
import { join } from 'lodash'; |
|||
import { map } from 'lodash/fp'; |
|||
import { appDefinitionFile } from '../common'; |
|||
import { validateTriggers, validateActions } from './validate'; |
|||
import { apiWrapper } from '../common/apiWrapper'; |
|||
import { events } from '../common/events'; |
|||
import { permission } from '../authApi/permissions'; |
|||
import { BadRequestError } from '../common/errors'; |
|||
|
|||
export const saveActionsAndTriggers = app => async (actions, triggers) => apiWrapper( |
|||
app, |
|||
events.templateApi.saveActionsAndTriggers, |
|||
permission.writeTemplates.isAuthorized, |
|||
{ actions, triggers }, |
|||
_saveActionsAndTriggers, app.datastore, actions, triggers, |
|||
); |
|||
|
|||
export const _saveActionsAndTriggers = async (datastore, actions, triggers) => { |
|||
if (await datastore.exists(appDefinitionFile)) { |
|||
const appDefinition = await datastore.loadJson(appDefinitionFile); |
|||
appDefinition.actions = actions; |
|||
appDefinition.triggers = triggers; |
|||
|
|||
const actionValidErrs = map(e => e.error)(validateActions(actions)); |
|||
|
|||
if (actionValidErrs.length > 0) { |
|||
throw new BadRequestError(`Actions are invalid: ${join(actionValidErrs, ', ')}`); |
|||
} |
|||
|
|||
const triggerValidErrs = map(e => e.error)(validateTriggers(triggers, actions)); |
|||
|
|||
if (triggerValidErrs.length > 0) { |
|||
throw new BadRequestError(`Triggers are invalid: ${join(triggerValidErrs, ', ')}`); |
|||
} |
|||
|
|||
await datastore.updateJson(appDefinitionFile, appDefinition); |
|||
} else { |
|||
throw new BadRequestError('Cannot save actions: Application definition does not exist'); |
|||
} |
|||
}; |
|||
@ -0,0 +1,35 @@ |
|||
import { join } from 'lodash'; |
|||
import { permission } from '../authApi/permissions'; |
|||
import { appDefinitionFile } from '../common'; |
|||
import { validateAll } from './validate'; |
|||
import { apiWrapper } from '../common/apiWrapper'; |
|||
import { events } from '../common/events'; |
|||
|
|||
export const saveApplicationHierarchy = app => async hierarchy => apiWrapper( |
|||
app, |
|||
events.templateApi.saveApplicationHierarchy, |
|||
permission.writeTemplates.isAuthorized, |
|||
{ hierarchy }, |
|||
_saveApplicationHierarchy, app.datastore, hierarchy, |
|||
); |
|||
|
|||
|
|||
export const _saveApplicationHierarchy = async (datastore, hierarchy) => { |
|||
const validationErrors = await validateAll(hierarchy); |
|||
if (validationErrors.length > 0) { |
|||
throw new Error(`Hierarchy is invalid: ${join( |
|||
validationErrors.map(e => `${e.item.nodeKey ? e.item.nodeKey() : ''} : ${e.error}`), |
|||
',', |
|||
)}`);
|
|||
} |
|||
|
|||
if (await datastore.exists(appDefinitionFile)) { |
|||
const appDefinition = await datastore.loadJson(appDefinitionFile); |
|||
appDefinition.hierarchy = hierarchy; |
|||
await datastore.updateJson(appDefinitionFile, appDefinition); |
|||
} else { |
|||
await datastore.createFolder('/.config'); |
|||
const appDefinition = { actions: [], triggers: [], hierarchy }; |
|||
await datastore.createJson(appDefinitionFile, appDefinition); |
|||
} |
|||
}; |
|||
@ -0,0 +1,183 @@ |
|||
import { |
|||
filter, union, constant, |
|||
map, flatten, every, uniqBy, |
|||
some, includes, isEmpty, |
|||
} from 'lodash/fp'; |
|||
import { has } from 'lodash'; |
|||
import { compileExpression, compileCode } from '@nx-js/compiler-util'; |
|||
import { |
|||
$, isSomething, switchCase, |
|||
anyTrue, isNonEmptyArray, executesWithoutException, |
|||
isNonEmptyString, defaultCase, |
|||
} from '../common'; |
|||
import { |
|||
isRecord, isRoot, isaggregateGroup, |
|||
isIndex, getFlattenedHierarchy, |
|||
} from './hierarchy'; |
|||
import { eventsList } from '../common/events'; |
|||
import { validateAllFields } from './fields'; |
|||
import { |
|||
applyRuleSet, makerule, stringNotEmpty, |
|||
validationError, |
|||
} from '../common/validationCommon'; |
|||
import { indexRuleSet } from './indexes'; |
|||
import { validateAllAggregates } from './validateAggregate'; |
|||
|
|||
export const ruleSet = (...sets) => constant(flatten([...sets])); |
|||
|
|||
const commonRules = [ |
|||
makerule('name', 'node name is not set', |
|||
node => stringNotEmpty(node.name)), |
|||
makerule('type', 'node type not recognised', |
|||
anyTrue(isRecord, isRoot, isIndex, isaggregateGroup)), |
|||
]; |
|||
|
|||
const recordRules = [ |
|||
makerule('fields', 'no fields have been added to the record', |
|||
node => isNonEmptyArray(node.fields)), |
|||
makerule('validationRules', "validation rule is missing a 'messageWhenValid' member", |
|||
node => every(r => has(r, 'messageWhenInvalid'))(node.validationRules)), |
|||
makerule('validationRules', "validation rule is missing a 'expressionWhenValid' member", |
|||
node => every(r => has(r, 'expressionWhenValid'))(node.validationRules)), |
|||
]; |
|||
|
|||
|
|||
const aggregateGroupRules = [ |
|||
makerule('condition', 'condition does not compile', |
|||
a => isEmpty(a.condition) |
|||
|| executesWithoutException( |
|||
() => compileExpression(a.condition), |
|||
)), |
|||
]; |
|||
|
|||
const getRuleSet = node => switchCase( |
|||
|
|||
[isRecord, ruleSet( |
|||
commonRules, |
|||
recordRules, |
|||
)], |
|||
|
|||
[isIndex, ruleSet( |
|||
commonRules, |
|||
indexRuleSet, |
|||
)], |
|||
|
|||
[isaggregateGroup, ruleSet( |
|||
commonRules, |
|||
aggregateGroupRules, |
|||
)], |
|||
|
|||
[defaultCase, ruleSet(commonRules, [])], |
|||
)(node); |
|||
|
|||
export const validateNode = node => applyRuleSet(getRuleSet(node))(node); |
|||
|
|||
export const validateAll = (appHierarchy) => { |
|||
const flattened = getFlattenedHierarchy( |
|||
appHierarchy, |
|||
); |
|||
|
|||
const duplicateNameRule = makerule( |
|||
'name', 'node names must be unique under shared parent', |
|||
n => filter(f => f.parent() === n.parent() |
|||
&& f.name === n.name)(flattened).length === 1, |
|||
); |
|||
|
|||
const duplicateNodeKeyErrors = $(flattened, [ |
|||
map(n => applyRuleSet([duplicateNameRule])(n)), |
|||
filter(isSomething), |
|||
flatten, |
|||
]); |
|||
|
|||
const fieldErrors = $(flattened, [ |
|||
filter(isRecord), |
|||
map(validateAllFields), |
|||
flatten, |
|||
]); |
|||
|
|||
const aggregateErrors = $(flattened, [ |
|||
filter(isaggregateGroup), |
|||
map(s => validateAllAggregates( |
|||
s.aggregates, |
|||
)), |
|||
flatten, |
|||
]); |
|||
|
|||
return $(flattened, [ |
|||
map(validateNode), |
|||
flatten, |
|||
union(duplicateNodeKeyErrors), |
|||
union(fieldErrors), |
|||
union(aggregateErrors), |
|||
]); |
|||
}; |
|||
|
|||
const actionRules = [ |
|||
makerule('name', 'action must have a name', |
|||
a => isNonEmptyString(a.name)), |
|||
makerule('behaviourName', 'must supply a behaviour name to the action', |
|||
a => isNonEmptyString(a.behaviourName)), |
|||
makerule('behaviourSource', 'must supply a behaviour source for the action', |
|||
a => isNonEmptyString(a.behaviourSource)), |
|||
]; |
|||
|
|||
const duplicateActionRule = makerule('', 'action name must be unique', () => {}); |
|||
|
|||
const validateAction = action => applyRuleSet(actionRules)(action); |
|||
|
|||
|
|||
export const validateActions = (allActions) => { |
|||
const duplicateActions = $(allActions, [ |
|||
filter(a => filter(a2 => a2.name === a.name)(allActions).length > 1), |
|||
map(a => validationError(duplicateActionRule, a)), |
|||
]); |
|||
|
|||
const errors = $(allActions, [ |
|||
map(validateAction), |
|||
flatten, |
|||
union(duplicateActions), |
|||
uniqBy('name'), |
|||
]); |
|||
|
|||
return errors; |
|||
}; |
|||
|
|||
const triggerRules = actions => ([ |
|||
makerule('actionName', 'must specify an action', |
|||
t => isNonEmptyString(t.actionName)), |
|||
makerule('eventName', 'must specify and event', |
|||
t => isNonEmptyString(t.eventName)), |
|||
makerule('actionName', 'specified action not supplied', |
|||
t => !t.actionName |
|||
|| some(a => a.name === t.actionName)(actions)), |
|||
makerule('eventName', 'invalid Event Name', |
|||
t => !t.eventName |
|||
|| includes(t.eventName)(eventsList)), |
|||
makerule('optionsCreator', 'Options Creator does not compile - check your expression', |
|||
(t) => { |
|||
if (!t.optionsCreator) return true; |
|||
try { |
|||
compileCode(t.optionsCreator); |
|||
return true; |
|||
} catch (_) { return false; } |
|||
}), |
|||
makerule('condition', 'Trigger condition does not compile - check your expression', |
|||
(t) => { |
|||
if (!t.condition) return true; |
|||
try { |
|||
compileExpression(t.condition); |
|||
return true; |
|||
} catch (_) { return false; } |
|||
}), |
|||
]); |
|||
|
|||
export const validateTrigger = (trigger, allActions) => { |
|||
const errors = applyRuleSet(triggerRules(allActions))(trigger); |
|||
|
|||
return errors; |
|||
}; |
|||
|
|||
export const validateTriggers = (triggers, allActions) => $(triggers, [ |
|||
map(t => validateTrigger(t, allActions)), |
|||
flatten, |
|||
]); |
|||
@ -0,0 +1,25 @@ |
|||
import { flatten, map } from 'lodash/fp'; |
|||
import { isEmpty } from 'lodash'; |
|||
import { compileCode } from '@nx-js/compiler-util'; |
|||
import { |
|||
isNonEmptyString, |
|||
executesWithoutException, $, |
|||
} from '../common'; |
|||
import { applyRuleSet, makerule } from '../common/validationCommon'; |
|||
|
|||
const aggregateRules = [ |
|||
makerule('name', 'choose a name for the aggregate', |
|||
a => isNonEmptyString(a.name)), |
|||
makerule('aggregatedValue', 'aggregatedValue does not compile', |
|||
a => isEmpty(a.aggregatedValue) |
|||
|| executesWithoutException( |
|||
() => compileCode(a.aggregatedValue), |
|||
)), |
|||
]; |
|||
|
|||
export const validateAggregate = aggregate => applyRuleSet(aggregateRules)(aggregate); |
|||
|
|||
export const validateAllAggregates = all => $(all, [ |
|||
map(validateAggregate), |
|||
flatten, |
|||
]); |
|||
@ -0,0 +1,47 @@ |
|||
import { map } from 'lodash/fp'; |
|||
import { retrieve } from './retrieve'; |
|||
import { executeTransactions } from './execute'; |
|||
import { |
|||
$, joinKey, getLock, isNolock, releaseLock, |
|||
} from '../common'; |
|||
import { |
|||
LOCK_FILE_KEY, TRANSACTIONS_FOLDER, |
|||
timeoutMilliseconds, getTransactionId, |
|||
maxLockRetries, |
|||
} from './transactionsCommon'; |
|||
|
|||
export const cleanup = async (app) => { |
|||
const lock = await getTransactionLock(app); |
|||
if (isNolock(lock)) return; |
|||
|
|||
try { |
|||
const transactions = await retrieve(app); |
|||
if (transactions.length > 0) { |
|||
await executeTransactions(app)(transactions); |
|||
|
|||
const folder = transactions.folderKey |
|||
? transactions.folderKey |
|||
: TRANSACTIONS_FOLDER; |
|||
|
|||
const deleteFiles = $(transactions, [ |
|||
map(t => joinKey( |
|||
folder, |
|||
getTransactionId( |
|||
t.recordId, t.transactionType, |
|||
t.uniqueId, |
|||
), |
|||
)), |
|||
map(app.datastore.deleteFile), |
|||
]); |
|||
|
|||
await Promise.all(deleteFiles); |
|||
} |
|||
} finally { |
|||
await releaseLock(app, lock); |
|||
} |
|||
}; |
|||
|
|||
const getTransactionLock = async app => await getLock( |
|||
app, LOCK_FILE_KEY, |
|||
timeoutMilliseconds, maxLockRetries, |
|||
); |
|||
@ -0,0 +1,69 @@ |
|||
import { generate } from 'shortid'; |
|||
import { joinKey } from '../common'; |
|||
import { getLastPartInKey } from '../templateApi/hierarchy'; |
|||
import { |
|||
IndexNodeKeyFolder, BUILDINDEX_BATCH_COUNT, |
|||
IndexNodeKeyBatchFolder, TRANSACTIONS_FOLDER, getTransactionId, CREATE_RECORD_TRANSACTION, UPDATE_RECORD_TRANSACTION, |
|||
DELETE_RECORD_TRANSACTION, BUILD_INDEX_TRANSACTION, |
|||
} from './transactionsCommon'; |
|||
|
|||
|
|||
export const transactionForCreateRecord = async (app, record) => await transaction( |
|||
app.datastore, CREATE_RECORD_TRANSACTION, |
|||
record.key, { record }, |
|||
getTransactionKey_Records, |
|||
); |
|||
|
|||
export const transactionForUpdateRecord = async (app, oldRecord, newRecord) => await transaction( |
|||
app.datastore, UPDATE_RECORD_TRANSACTION, |
|||
newRecord.key, { oldRecord, record: newRecord }, |
|||
getTransactionKey_Records, |
|||
); |
|||
|
|||
export const transactionForDeleteRecord = async (app, record) => await transaction( |
|||
app.datastore, DELETE_RECORD_TRANSACTION, |
|||
record.key, { record }, |
|||
getTransactionKey_Records, |
|||
); |
|||
|
|||
export const transactionForBuildIndex = async (app, indexNodeKey, recordKey, count) => { |
|||
const transactionFolder = IndexNodeKeyBatchFolder(indexNodeKey, count); |
|||
if (count % BUILDINDEX_BATCH_COUNT === 0) { |
|||
await app.datastore.createFolder(transactionFolder); |
|||
} |
|||
|
|||
return await transaction( |
|||
app.datastore, BUILD_INDEX_TRANSACTION, |
|||
recordKey, { recordKey }, |
|||
id => joinKey(transactionFolder, id), |
|||
); |
|||
}; |
|||
|
|||
export const createBuildIndexFolder = async (datastore, indexNodeKey) => await datastore.createFolder( |
|||
IndexNodeKeyFolder(indexNodeKey), |
|||
); |
|||
|
|||
const getTransactionKey_Records = id => joinKey(TRANSACTIONS_FOLDER, id); |
|||
|
|||
const transaction = async (datastore, transactionType, recordKey, data, getTransactionKey) => { |
|||
const recordId = getLastPartInKey(recordKey); |
|||
const uniqueId = generate(); |
|||
const id = getTransactionId( |
|||
recordId, transactionType, uniqueId, |
|||
); |
|||
|
|||
const key = getTransactionKey(id); |
|||
|
|||
const trans = { |
|||
transactionType, |
|||
recordKey, |
|||
...data, |
|||
id, |
|||
}; |
|||
|
|||
await datastore.createJson( |
|||
key, trans, |
|||
); |
|||
|
|||
return trans; |
|||
}; |
|||
@ -0,0 +1,349 @@ |
|||
import { |
|||
filter, map, isUndefined, includes, |
|||
flatten, intersectionBy, |
|||
isEqual, pull, keys, |
|||
differenceBy, difference, |
|||
} from 'lodash/fp'; |
|||
import { union } from 'lodash'; |
|||
import { |
|||
getRelevantAncestorIndexes, |
|||
getRelevantReverseReferenceIndexes, |
|||
} from '../indexing/relevant'; |
|||
import { evaluate } from '../indexing/evaluate'; |
|||
import { |
|||
$, isSomething, |
|||
isNonEmptyArray, joinKey, |
|||
isNonEmptyString, |
|||
} from '../common'; |
|||
import { getIndexedDataKey } from '../indexing/sharding'; |
|||
import { |
|||
isUpdate, isCreate, |
|||
isDelete, isBuildIndex, |
|||
} from './transactionsCommon'; |
|||
import { applyToShard } from '../indexing/apply'; |
|||
import { |
|||
getActualKeyOfParent, |
|||
isGlobalIndex, fieldReversesReferenceToIndex, isReferenceIndex, |
|||
getExactNodeForPath, |
|||
} from '../templateApi/hierarchy'; |
|||
|
|||
export const executeTransactions = app => async (transactions) => { |
|||
const recordsByShard = mappedRecordsByIndexShard(app.hierarchy, transactions); |
|||
|
|||
for (const shard of keys(recordsByShard)) { |
|||
await applyToShard( |
|||
app.hierarchy, app.datastore, |
|||
recordsByShard[shard].indexKey, |
|||
recordsByShard[shard].indexNode, |
|||
shard, |
|||
recordsByShard[shard].writes, |
|||
recordsByShard[shard].removes, |
|||
); |
|||
} |
|||
}; |
|||
|
|||
const mappedRecordsByIndexShard = (hierarchy, transactions) => { |
|||
const updates = getUpdateTransactionsByShard( |
|||
hierarchy, transactions, |
|||
); |
|||
|
|||
const created = getCreateTransactionsByShard( |
|||
hierarchy, transactions, |
|||
); |
|||
const deletes = getDeleteTransactionsByShard( |
|||
hierarchy, transactions, |
|||
); |
|||
|
|||
const indexBuild = getBuildIndexTransactionsByShard( |
|||
hierarchy, |
|||
transactions, |
|||
); |
|||
|
|||
const toRemove = [ |
|||
...deletes, |
|||
...updates.toRemove, |
|||
]; |
|||
|
|||
const toWrite = [ |
|||
...created, |
|||
...updates.toWrite, |
|||
...indexBuild, |
|||
]; |
|||
|
|||
const transByShard = {}; |
|||
|
|||
const initialiseShard = (t) => { |
|||
if (isUndefined(transByShard[t.indexShardKey])) { |
|||
transByShard[t.indexShardKey] = { |
|||
writes: [], |
|||
removes: [], |
|||
indexKey: t.indexKey, |
|||
indexNodeKey: t.indexNodeKey, |
|||
indexNode: t.indexNode, |
|||
}; |
|||
} |
|||
}; |
|||
|
|||
for (const trans of toWrite) { |
|||
initialiseShard(trans); |
|||
transByShard[trans.indexShardKey].writes.push( |
|||
trans.mappedRecord.result, |
|||
); |
|||
} |
|||
|
|||
for (const trans of toRemove) { |
|||
initialiseShard(trans); |
|||
transByShard[trans.indexShardKey].removes.push( |
|||
trans.mappedRecord.result.key, |
|||
); |
|||
} |
|||
|
|||
return transByShard; |
|||
}; |
|||
|
|||
const getUpdateTransactionsByShard = (hierarchy, transactions) => { |
|||
const updateTransactions = $(transactions, [filter(isUpdate)]); |
|||
|
|||
const evaluateIndex = (record, indexNodeAndPath) => { |
|||
const mappedRecord = evaluate(record)(indexNodeAndPath.indexNode); |
|||
return ({ |
|||
mappedRecord, |
|||
indexNode: indexNodeAndPath.indexNode, |
|||
indexKey: indexNodeAndPath.indexKey, |
|||
indexShardKey: getIndexedDataKey( |
|||
indexNodeAndPath.indexNode, |
|||
indexNodeAndPath.indexKey, |
|||
mappedRecord.result, |
|||
), |
|||
}); |
|||
}; |
|||
|
|||
const getIndexNodesToApply = indexFilter => (t, indexes) => $(indexes, [ |
|||
map(n => ({ |
|||
old: evaluateIndex(t.oldRecord, n), |
|||
new: evaluateIndex(t.record, n), |
|||
})), |
|||
filter(indexFilter), |
|||
]); |
|||
|
|||
const toRemoveFilter = (n, isUnreferenced) => n.old.mappedRecord.passedFilter === true |
|||
&& (n.new.mappedRecord.passedFilter === false |
|||
|| isUnreferenced); |
|||
|
|||
const toAddFilter = (n, isNewlyReferenced) => (n.old.mappedRecord.passedFilter === false |
|||
|| isNewlyReferenced) |
|||
&& n.new.mappedRecord.passedFilter === true; |
|||
|
|||
const toUpdateFilter = n => n.new.mappedRecord.passedFilter === true |
|||
&& n.old.mappedRecord.passedFilter === true |
|||
&& !isEqual(n.old.mappedRecord.result, |
|||
n.new.mappedRecord.result); |
|||
|
|||
const toRemove = []; |
|||
const toWrite = []; |
|||
|
|||
for (const t of updateTransactions) { |
|||
const ancestorIdxs = getRelevantAncestorIndexes( |
|||
hierarchy, t.record, |
|||
); |
|||
|
|||
const referenceChanges = diffReverseRefForUpdate( |
|||
hierarchy, t.oldRecord, t.record, |
|||
); |
|||
|
|||
// old records to remove (filtered out)
|
|||
const filteredOut_toRemove = union( |
|||
getIndexNodesToApply(toRemoveFilter)(t, ancestorIdxs), |
|||
// still referenced - check filter
|
|||
getIndexNodesToApply(toRemoveFilter)(t, referenceChanges.notChanged), |
|||
// un referenced - remove if in there already
|
|||
getIndexNodesToApply(n => toRemoveFilter(n, true))(t, referenceChanges.unReferenced), |
|||
); |
|||
|
|||
// new records to add (filtered in)
|
|||
const filteredIn_toAdd = union( |
|||
getIndexNodesToApply(toAddFilter)(t, ancestorIdxs), |
|||
// newly referenced - check filter
|
|||
getIndexNodesToApply(n => toAddFilter(n, true))(t, referenceChanges.newlyReferenced), |
|||
// reference unchanged - rerun filter in case something else changed
|
|||
getIndexNodesToApply(toAddFilter)(t, referenceChanges.notChanged), |
|||
); |
|||
|
|||
const changed = union( |
|||
getIndexNodesToApply(toUpdateFilter)(t, ancestorIdxs), |
|||
// still referenced - recheck filter
|
|||
getIndexNodesToApply(toUpdateFilter)(t, referenceChanges.notChanged), |
|||
); |
|||
|
|||
const shardKeyChanged = $(changed, [ |
|||
filter(c => c.old.indexShardKey !== c.new.indexShardKey), |
|||
]); |
|||
|
|||
const changedInSameShard = $(shardKeyChanged, [ |
|||
difference(changed), |
|||
]); |
|||
|
|||
for (const res of shardKeyChanged) { |
|||
pull(res)(changed); |
|||
filteredOut_toRemove.push(res); |
|||
filteredIn_toAdd.push(res); |
|||
} |
|||
|
|||
toRemove.push( |
|||
$(filteredOut_toRemove, [ |
|||
map(i => i.old), |
|||
]), |
|||
); |
|||
|
|||
toWrite.push( |
|||
$(filteredIn_toAdd, [ |
|||
map(i => i.new), |
|||
]), |
|||
); |
|||
|
|||
toWrite.push( |
|||
$(changedInSameShard, [ |
|||
map(i => i.new), |
|||
]), |
|||
); |
|||
} |
|||
|
|||
return ({ |
|||
toRemove: flatten(toRemove), |
|||
toWrite: flatten(toWrite), |
|||
}); |
|||
}; |
|||
|
|||
const getBuildIndexTransactionsByShard = (hierarchy, transactions) => { |
|||
const buildTransactions = $(transactions, [filter(isBuildIndex)]); |
|||
if (!isNonEmptyArray(buildTransactions)) return []; |
|||
const indexNode = transactions.indexNode; |
|||
|
|||
const getIndexKeys = (t) => { |
|||
if (isGlobalIndex(indexNode)) { |
|||
return [indexNode.nodeKey()]; |
|||
} |
|||
|
|||
if (isReferenceIndex(indexNode)) { |
|||
const recordNode = getExactNodeForPath(hierarchy)(t.record.key); |
|||
const refFields = $(recordNode.fields, [ |
|||
filter(fieldReversesReferenceToIndex(indexNode)), |
|||
]); |
|||
const indexKeys = []; |
|||
for (const refField of refFields) { |
|||
const refValue = t.record[refField.name]; |
|||
if (isSomething(refValue) |
|||
&& isNonEmptyString(refValue.key)) { |
|||
const indexKey = joinKey( |
|||
refValue.key, |
|||
indexNode.name, |
|||
); |
|||
|
|||
if (!includes(indexKey)(indexKeys)) { indexKeys.push(indexKey); } |
|||
} |
|||
} |
|||
return indexKeys; |
|||
} |
|||
|
|||
return [joinKey( |
|||
getActualKeyOfParent( |
|||
indexNode.parent().nodeKey(), |
|||
t.record.key, |
|||
), |
|||
indexNode.name, |
|||
)]; |
|||
}; |
|||
|
|||
return $(buildTransactions, [ |
|||
map((t) => { |
|||
const mappedRecord = evaluate(t.record)(indexNode); |
|||
if (!mappedRecord.passedFilter) return null; |
|||
const indexKeys = getIndexKeys(t); |
|||
return $(indexKeys, [ |
|||
map(indexKey => ({ |
|||
mappedRecord, |
|||
indexNode, |
|||
indexKey, |
|||
indexShardKey: getIndexedDataKey( |
|||
indexNode, |
|||
indexKey, |
|||
mappedRecord.result, |
|||
), |
|||
})), |
|||
]); |
|||
}), |
|||
flatten, |
|||
filter(isSomething), |
|||
]); |
|||
}; |
|||
|
|||
const get_Create_Delete_TransactionsByShard = pred => (hierarchy, transactions) => { |
|||
const createTransactions = $(transactions, [filter(pred)]); |
|||
|
|||
const getIndexNodesToApply = (t, indexes) => $(indexes, [ |
|||
map((n) => { |
|||
const mappedRecord = evaluate(t.record)(n.indexNode); |
|||
return ({ |
|||
mappedRecord, |
|||
indexNode: n.indexNode, |
|||
indexKey: n.indexKey, |
|||
indexShardKey: getIndexedDataKey( |
|||
n.indexNode, |
|||
n.indexKey, |
|||
mappedRecord.result, |
|||
), |
|||
}); |
|||
}), |
|||
filter(n => n.mappedRecord.passedFilter), |
|||
]); |
|||
|
|||
const allToApply = []; |
|||
|
|||
for (const t of createTransactions) { |
|||
const ancestorIdxs = getRelevantAncestorIndexes(hierarchy, t.record); |
|||
const reverseRef = getRelevantReverseReferenceIndexes(hierarchy, t.record); |
|||
|
|||
allToApply.push( |
|||
getIndexNodesToApply(t, ancestorIdxs), |
|||
); |
|||
allToApply.push( |
|||
getIndexNodesToApply(t, reverseRef), |
|||
); |
|||
} |
|||
|
|||
return flatten(allToApply); |
|||
}; |
|||
|
|||
const getDeleteTransactionsByShard = get_Create_Delete_TransactionsByShard(isDelete); |
|||
|
|||
const getCreateTransactionsByShard = get_Create_Delete_TransactionsByShard(isCreate); |
|||
|
|||
const diffReverseRefForUpdate = (appHierarchy, oldRecord, newRecord) => { |
|||
const oldIndexes = getRelevantReverseReferenceIndexes( |
|||
appHierarchy, oldRecord, |
|||
); |
|||
const newIndexes = getRelevantReverseReferenceIndexes( |
|||
appHierarchy, newRecord, |
|||
); |
|||
|
|||
const unReferenced = differenceBy( |
|||
i => i.indexKey, |
|||
oldIndexes, newIndexes, |
|||
); |
|||
|
|||
const newlyReferenced = differenceBy( |
|||
i => i.indexKey, |
|||
newIndexes, oldIndexes, |
|||
); |
|||
|
|||
const notChanged = intersectionBy( |
|||
i => i.indexKey, |
|||
newIndexes, oldIndexes, |
|||
); |
|||
|
|||
return { |
|||
unReferenced, |
|||
newlyReferenced, |
|||
notChanged, |
|||
}; |
|||
}; |
|||
@ -0,0 +1,206 @@ |
|||
import { |
|||
map, filter, groupBy, split, |
|||
some, find, |
|||
} from 'lodash/fp'; |
|||
import { |
|||
LOCK_FILENAME, TRANSACTIONS_FOLDER, idSep, isUpdate, |
|||
nodeKeyHashFromBuildFolder, isBuildIndexFolder, getTransactionId, |
|||
isDelete, isCreate, |
|||
} from './transactionsCommon'; |
|||
import { |
|||
joinKey, $, none, isSomething, |
|||
} from '../common'; |
|||
import { getLastPartInKey, getNodeFromNodeKeyHash } from '../templateApi/hierarchy'; |
|||
import { _load } from '../recordApi/load'; |
|||
|
|||
export const retrieve = async (app) => { |
|||
const transactionFiles = await app.datastore.getFolderContents( |
|||
TRANSACTIONS_FOLDER, |
|||
); |
|||
|
|||
let transactions = []; |
|||
|
|||
if (some(isBuildIndexFolder)(transactionFiles)) { |
|||
const buildIndexFolder = find(isBuildIndexFolder)(transactionFiles); |
|||
|
|||
transactions = await retrieveBuildIndexTransactions( |
|||
app, |
|||
joinKey(TRANSACTIONS_FOLDER, buildIndexFolder), |
|||
); |
|||
} |
|||
|
|||
if (transactions.length > 0) return transactions; |
|||
|
|||
return await retrieveStandardTransactions( |
|||
app, transactionFiles, |
|||
); |
|||
}; |
|||
|
|||
const retrieveBuildIndexTransactions = async (app, buildIndexFolder) => { |
|||
const childFolders = await app.datastore.getFolderContents(buildIndexFolder); |
|||
if (childFolders.length === 0) { |
|||
// cleanup
|
|||
await app.datastore.deleteFolder(buildIndexFolder); |
|||
return []; |
|||
} |
|||
|
|||
const getTransactionFiles = async (childFolderIndex = 0) => { |
|||
if (childFolderIndex >= childFolders.length) return []; |
|||
|
|||
const childFolderKey = joinKey(buildIndexFolder, childFolders[childFolderIndex]); |
|||
const files = await app.datastore.getFolderContents( |
|||
childFolderKey, |
|||
); |
|||
|
|||
if (files.length === 0) { |
|||
await app.datastore.deleteFolder(childFolderKey); |
|||
return await getTransactionFiles(childFolderIndex + 1); |
|||
} |
|||
|
|||
return { childFolderKey, files }; |
|||
}; |
|||
|
|||
const transactionFiles = await getTransactionFiles(); |
|||
|
|||
if (transactionFiles.files.length === 0) return []; |
|||
|
|||
const transactions = $(transactionFiles.files, [ |
|||
map(parseTransactionId), |
|||
]); |
|||
|
|||
for (const t of transactions) { |
|||
const transactionContent = await app.datastore.loadJson( |
|||
joinKey( |
|||
transactionFiles.childFolderKey, |
|||
t.fullId, |
|||
), |
|||
); |
|||
t.record = await _load(app, transactionContent.recordKey); |
|||
} |
|||
|
|||
transactions.indexNode = $(buildIndexFolder, [ |
|||
getLastPartInKey, |
|||
nodeKeyHashFromBuildFolder, |
|||
getNodeFromNodeKeyHash(app.hierarchy), |
|||
]); |
|||
|
|||
transactions.folderKey = transactionFiles.childFolderKey; |
|||
|
|||
return transactions; |
|||
}; |
|||
|
|||
const retrieveStandardTransactions = async (app, transactionFiles) => { |
|||
const transactionIds = $(transactionFiles, [ |
|||
filter(f => f !== LOCK_FILENAME |
|||
&& !isBuildIndexFolder(f)), |
|||
map(parseTransactionId), |
|||
]); |
|||
|
|||
const transactionIdsByRecord = $(transactionIds, [ |
|||
groupBy('recordId'), |
|||
]); |
|||
|
|||
const dedupedTransactions = []; |
|||
|
|||
const verify = async (t) => { |
|||
if (t.verified === true) return t; |
|||
|
|||
const id = getTransactionId( |
|||
t.recordId, |
|||
t.transactionType, |
|||
t.uniqueId, |
|||
); |
|||
|
|||
const transaction = await app.datastore.loadJson( |
|||
joinKey(TRANSACTIONS_FOLDER, id), |
|||
); |
|||
|
|||
if (isDelete(t)) { |
|||
t.record = transaction.record; |
|||
t.verified = true; |
|||
return t; |
|||
} |
|||
|
|||
const rec = await _load( |
|||
app, |
|||
transaction.recordKey, |
|||
); |
|||
if (rec.transactionId === id) { |
|||
t.record = rec; |
|||
if (transaction.oldRecord) { t.oldRecord = transaction.oldRecord; } |
|||
t.verified = true; |
|||
} else { |
|||
t.verified = false; |
|||
} |
|||
|
|||
return t; |
|||
}; |
|||
|
|||
const pickOne = async (trans, forType) => { |
|||
const transForType = filter(forType)(trans); |
|||
if (transForType.length === 1) { |
|||
const t = await verify(transForType[0]); |
|||
return (t.verified === true ? t : null); |
|||
} |
|||
for (let t of transForType) { |
|||
t = await verify(t); |
|||
if (t.verified === true) { return t; } |
|||
} |
|||
|
|||
return null; |
|||
}; |
|||
|
|||
for (const recordId in transactionIdsByRecord) { |
|||
const transIdsForRecord = transactionIdsByRecord[recordId]; |
|||
if (transIdsForRecord.length === 1) { |
|||
const t = await verify(transIdsForRecord[0]); |
|||
if (t.verified) { dedupedTransactions.push(t); } |
|||
continue; |
|||
} |
|||
if (some(isDelete)(transIdsForRecord)) { |
|||
const t = await verify(find(isDelete)(transIdsForRecord)); |
|||
if (t.verified) { dedupedTransactions.push(t); } |
|||
continue; |
|||
} |
|||
if (some(isUpdate)(transIdsForRecord)) { |
|||
const upd = await pickOne(transIdsForRecord, isUpdate); |
|||
if (isSomething(upd) && upd.verified) { dedupedTransactions.push(upd); } |
|||
continue; |
|||
} |
|||
if (some(isCreate)(transIdsForRecord)) { |
|||
const cre = await pickOne(transIdsForRecord, isCreate); |
|||
if (isSomething(cre)) { dedupedTransactions.push(cre); } |
|||
continue; |
|||
} |
|||
} |
|||
|
|||
const duplicates = $(transactionIds, [ |
|||
filter(t => none(ddt => ddt.uniqueId === t.uniqueId)(dedupedTransactions)), |
|||
]); |
|||
|
|||
|
|||
const deletePromises = map(t => app.datastore.deleteFile( |
|||
joinKey( |
|||
TRANSACTIONS_FOLDER, |
|||
getTransactionId( |
|||
t.recordId, |
|||
t.transactionType, |
|||
t.uniqueId, |
|||
), |
|||
), |
|||
))(duplicates); |
|||
|
|||
await Promise.all(deletePromises); |
|||
|
|||
return dedupedTransactions; |
|||
}; |
|||
|
|||
const parseTransactionId = (id) => { |
|||
const splitId = split(idSep)(id); |
|||
return ({ |
|||
recordId: splitId[0], |
|||
transactionType: splitId[1], |
|||
uniqueId: splitId[2], |
|||
fullId: id, |
|||
}); |
|||
}; |
|||
@ -0,0 +1,48 @@ |
|||
import { |
|||
joinKey, keySep, getHashCode, |
|||
} from '../common'; |
|||
import { getLastPartInKey } from '../templateApi/hierarchy'; |
|||
|
|||
export const TRANSACTIONS_FOLDER = `${keySep}.transactions`; |
|||
export const LOCK_FILENAME = 'lock'; |
|||
export const LOCK_FILE_KEY = joinKey( |
|||
TRANSACTIONS_FOLDER, LOCK_FILENAME, |
|||
); |
|||
export const idSep = '$'; |
|||
|
|||
const isOfType = typ => trans => trans.transactionType === typ; |
|||
|
|||
export const CREATE_RECORD_TRANSACTION = 'create'; |
|||
export const UPDATE_RECORD_TRANSACTION = 'update'; |
|||
export const DELETE_RECORD_TRANSACTION = 'delete'; |
|||
export const BUILD_INDEX_TRANSACTION = 'build'; |
|||
|
|||
export const isUpdate = isOfType(UPDATE_RECORD_TRANSACTION); |
|||
export const isDelete = isOfType(DELETE_RECORD_TRANSACTION); |
|||
export const isCreate = isOfType(CREATE_RECORD_TRANSACTION); |
|||
export const isBuildIndex = isOfType(BUILD_INDEX_TRANSACTION); |
|||
|
|||
export const keyToFolderName = nodeKey => getHashCode(nodeKey); |
|||
|
|||
export const getTransactionId = (recordId, transactionType, uniqueId) => |
|||
`${recordId}${idSep}${transactionType}${idSep}${uniqueId}`; |
|||
|
|||
export const buildIndexFolder = '.BUILD-'; |
|||
export const nodeKeyHashFromBuildFolder = folder => folder.replace(buildIndexFolder, ''); |
|||
|
|||
export const isBuildIndexFolder = key => getLastPartInKey(key).startsWith(buildIndexFolder); |
|||
|
|||
export const IndexNodeKeyFolder = indexNodeKey => joinKey( |
|||
TRANSACTIONS_FOLDER, |
|||
buildIndexFolder + keyToFolderName(indexNodeKey), |
|||
); |
|||
|
|||
export const IndexNodeKeyBatchFolder = (indexNodeKey, count) => |
|||
joinKey(IndexNodeKeyFolder(indexNodeKey), Math.floor(count / BUILDINDEX_BATCH_COUNT).toString()); |
|||
|
|||
export const IndexShardKeyFolder = (indexNodeKey, indexShardKey) => |
|||
joinKey(IndexNodeKeyFolder(indexNodeKey), indexShardKey); |
|||
|
|||
export const BUILDINDEX_BATCH_COUNT = 1000; |
|||
export const timeoutMilliseconds = 30 * 1000; // 30 secs
|
|||
export const maxLockRetries = 1; |
|||
@ -0,0 +1,59 @@ |
|||
import { constant, isArray } from 'lodash'; |
|||
import { map } from 'lodash/fp'; |
|||
import { |
|||
typeFunctions, makerule, |
|||
parsedFailed, getDefaultExport, parsedSuccess, |
|||
} from './typeHelpers'; |
|||
import { |
|||
switchCase, defaultCase, toNumberOrNull, |
|||
$$, isSafeInteger, |
|||
} from '../common'; |
|||
|
|||
const arrayFunctions = () => typeFunctions({ |
|||
default: constant([]), |
|||
}); |
|||
|
|||
const mapToParsedArrary = type => $$( |
|||
map(i => type.safeParseValue(i)), |
|||
parsedSuccess, |
|||
); |
|||
|
|||
const arrayTryParse = type => switchCase( |
|||
[isArray, mapToParsedArrary(type)], |
|||
[defaultCase, parsedFailed], |
|||
); |
|||
|
|||
const typeName = type => `array<${type}>`; |
|||
|
|||
|
|||
const options = { |
|||
maxLength: { |
|||
defaultValue: 10000, |
|||
isValid: isSafeInteger, |
|||
requirementDescription: 'must be a positive integer', |
|||
parse: toNumberOrNull, |
|||
}, |
|||
minLength: { |
|||
defaultValue: 0, |
|||
isValid: n => isSafeInteger(n) && n >= 0, |
|||
requirementDescription: 'must be a positive integer', |
|||
parse: toNumberOrNull, |
|||
}, |
|||
}; |
|||
|
|||
const typeConstraints = [ |
|||
makerule(async (val, opts) => val === null || val.length >= opts.minLength, |
|||
(val, opts) => `must choose ${opts.minLength} or more options`), |
|||
makerule(async (val, opts) => val === null || val.length <= opts.maxLength, |
|||
(val, opts) => `cannot choose more than ${opts.maxLength} options`), |
|||
]; |
|||
|
|||
export default type => getDefaultExport( |
|||
typeName(type.name), |
|||
arrayTryParse(type), |
|||
arrayFunctions(type), |
|||
options, |
|||
typeConstraints, |
|||
[type.sampleValue], |
|||
JSON.stringify, |
|||
); |
|||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue