forked from tsai/budibase
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