@dreamtree-org/korm-js 1.0.54 → 1.0.56
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AuthorizationService.js +1 -0
- package/BaseHelperUtility.js +1 -1
- package/ControllerWrapper.js +1 -1
- package/KormError.js +1 -0
- package/README.md +331 -42
- package/RequestValidator.js +1 -1
- package/ai-skills/korm-js.md +268 -0
- package/bin/korm-mcp.js +2 -0
- package/build.js +1 -1
- package/cli.js +1 -1
- package/clients/BaseSyncTable.js +1 -0
- package/clients/mysql/BaseUtility.js +1 -1
- package/clients/mysql/CurdTable.js +1 -1
- package/clients/mysql/DataTypeMap.js +1 -1
- package/clients/mysql/HookService.js +1 -1
- package/clients/mysql/QueryBuilder.js +1 -1
- package/clients/mysql/QueryService.js +1 -1
- package/clients/mysql/SyncTable.js +1 -1
- package/clients/pg/BaseUtility.js +1 -1
- package/clients/pg/CurdTable.js +1 -1
- package/clients/pg/DataTypeMap.js +1 -1
- package/clients/pg/HookService.js +1 -1
- package/clients/pg/QueryBuilder.js +1 -1
- package/clients/pg/QueryService.js +1 -1
- package/clients/pg/SyncTable.js +1 -1
- package/clients/sqlite/BaseUtility.js +1 -1
- package/clients/sqlite/CurdTable.js +1 -1
- package/clients/sqlite/HookService.js +1 -1
- package/clients/sqlite/QueryBuilder.js +1 -1
- package/clients/sqlite/QueryService.js +1 -1
- package/clients/sqlite/SyncTable.js +1 -1
- package/columnSchema.js +1 -0
- package/index.d.ts +424 -0
- package/index.js +1 -1
- package/jest.config.engine.js +1 -0
- package/jest.config.js +1 -1
- package/package.json +7 -2
- package/requestSchema.js +1 -0
- package/schemaDescribe.js +1 -0
- package/src/mcp/errors.js +1 -0
- package/src/mcp/schemaIntrospect.js +1 -0
- package/src/mcp/server.js +1 -0
- package/src/mcp/toolGenerator.js +1 -0
- package/TableSchemaSync.js +0 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
const KormError=require("./KormError"),WHERE_SCOPED_ACTIONS=new Set(["list","show","count","sum","update","delete"]),DATA_SCOPED_ACTIONS=new Set(["create","replace","upsert","sync"]);function mergeWhere(e,r){return Array.isArray(e)?[...e,{...r}]:{...e||{},...r}}function stampData(e,r){return Array.isArray(e)?e.map(e=>({...e&&"object"==typeof e?e:{},...r})):e&&"object"==typeof e?{...e,...r}:e}class AuthorizationService{constructor(){this.authorizers=new Map,this.scopers=new Map}reset(){this.authorizers.clear(),this.scopers.clear()}hasRules(){return this.authorizers.size>0||this.scopers.size>0}registerAuthorize(e,r,t){if("function"!=typeof t)throw new KormError("authorize(model, action, predicate): predicate must be a function.",{code:KormError.CODES.INTERNAL,context:{model:e,action:r}});this.authorizers.set(`${e}.${r}`,t)}registerScope(e,r){if("function"!=typeof r)throw new KormError("scope(model, fn): fn must be a function.",{code:KormError.CODES.INTERNAL,context:{model:e}});const t=this.scopers.get(e)||[];t.push(r),this.scopers.set(e,t)}_predicateFor(e,r){return this.authorizers.get(`${e}.${r}`)||this.authorizers.get(`${e}.*`)||null}isAllowed(e,r,t,o){const s=this._predicateFor(e,r);if(!s)return!0;try{return!!s(t,o)}catch{return!1}}enforce(e,r,t,o){if(!this.isAllowed(e,r,t,o))throw KormError.forbidden({model:e,action:r})}applyScope(e,r,t,o){const s=this.scopers.get(e);if(!s||0===s.length)return t;const i={};for(const e of s){const r=e(t,o);r&&"object"==typeof r&&Object.assign(i,r)}if(0===Object.keys(i).length)return t;const n={...t};return(WHERE_SCOPED_ACTIONS.has(r)||"sync"===r)&&(n.where=mergeWhere(t.where,i)),DATA_SCOPED_ACTIONS.has(r)&&(n.data=stampData(t.data,i)),n}availableActions(e,r,t){return r.filter(r=>this.isAllowed(e,r,{action:r},t))}}module.exports=AuthorizationService,module.exports.WHERE_SCOPED_ACTIONS=WHERE_SCOPED_ACTIONS,module.exports.DATA_SCOPED_ACTIONS=DATA_SCOPED_ACTIONS;
|
package/BaseHelperUtility.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
const Files=require("./helpers/files");class BaseHelperUtility{file=new Files;capitalize(e){return e.charAt(0).toUpperCase()+e.slice(1)}snakeCase(e){return e.replace(/([A-Z])/g,"_$1").toLowerCase()}camelCase(e){return e.replace(/([-_][a-z])/gi,e=>e.toUpperCase().replace("-","").replace("_",""))}kebabCase(e){return e.replace(/([A-Z])/g,"-$1").toLowerCase()}pascalCase(e){return e.replace(/([-_][a-z])/gi,e=>e.toUpperCase().replace("-","").replace("_",""))}pluralize(e){if(e.match(/[^aeiou]y$/i))return e.slice(0,-1)+"ies";if(e.match(/(s|x|z|ch|sh)$/i))return e+"es";if(e.match(/(f|fe)$/i)){if(e.endsWith("fe"))return e.slice(0,-2)+"ves";if(e.endsWith("f"))return e.slice(0,-1)+"ves"}return e.match(/(o)$/i)?e.length>1&&"aeiou".includes(e[e.length-2].toLowerCase())?e+"s":e+"es":e.match(/us$/i)?e.slice(0,-2)+"i":e.match(/is$/i)?e.slice(0,-2)+"es":e.match(/on$/i)||e.match(/um$/i)?e.slice(0,-2)+"a":e.match(/a$/i)?e+"s":e.match(/eau$/i)?e+"x":e.match(/ix$/i)||e.match(/ex$/i)?e.slice(0,-2)+"ices":e.match(/s$/i)?e:e+"s"}singularize(e){return String(e||"").trim().replace(/([b-df-hj-np-tv-z])ies$/i,"$1y").replace(/eaux$/i,"eau").replace(/oes$/i,"o").replace(/(xes|zes|ches|shes|sses)$/i,e=>e.slice(0,-2)).replace(/(us)es$/i,"$1").replace(/ves$/i,"f").replace(/([^s])s$/i,"$1")}modelName(e){let r=this.singularize(e);return r=this.camelCase(r),r=this.capitalize(r),r}dotParse(e,r,t=null){return this.dotWalk(e,{source:r,defaultValue:t})}dotWalk(e,r={}){const{source:t={},defaultValue:s=null,resolver:a=({current:e,part:r,source:t})=>e[r]}=r,i=e.split(".");let l=t;for(const e of i){const r=e.match(/^(\w+)\[(.*?)\]$/);if(r){const e=r[1],i=r[2]||0;if(l=l[e]||[],l=a({current:l,part:i,source:t}),void 0===l)return s}else{if(void 0===l[e])return s;if(l=a({current:l,part:e,source:t}),void 0===l)return s}}return l}dotWalkTree(e,r={}){const{resolver:t=({current:e,part:r,source:t})=>{}}=r,s={};for(const r of e){const e=r.split("."),a=e.length;let i=s,l=0;for(const n of e){const e=l===a-1;i[n]=i[n]||t({current:i,part:n,source:s,path:r,isLastPart:e}),i=i[n],l++}}return s}parseValue(e){return isNaN(e)||""===e?"true"===e.toLowerCase()||"false"!==e.toLowerCase()&&("null"===e.toLowerCase()?null:e):Number(e)}parseWhereValue(e){if("string"!=typeof e)return{operator:"=",value:e};if(e.startsWith("!")){const r=e.substring(1);return{operator:"!=",value:this.parseValue(r)}}if(e.includes("%"))return{operator:"like",value:e};const
|
|
1
|
+
const Files=require("./helpers/files");class BaseHelperUtility{file=new Files;capitalize(e){return e.charAt(0).toUpperCase()+e.slice(1)}snakeCase(e){return e.replace(/([A-Z])/g,"_$1").toLowerCase()}camelCase(e){return e.replace(/([-_][a-z])/gi,e=>e.toUpperCase().replace("-","").replace("_",""))}kebabCase(e){return e.replace(/([A-Z])/g,"-$1").toLowerCase()}pascalCase(e){return e.replace(/([-_][a-z])/gi,e=>e.toUpperCase().replace("-","").replace("_",""))}pluralize(e){if(e.match(/[^aeiou]y$/i))return e.slice(0,-1)+"ies";if(e.match(/(s|x|z|ch|sh)$/i))return e+"es";if(e.match(/(f|fe)$/i)){if(e.endsWith("fe"))return e.slice(0,-2)+"ves";if(e.endsWith("f"))return e.slice(0,-1)+"ves"}return e.match(/(o)$/i)?e.length>1&&"aeiou".includes(e[e.length-2].toLowerCase())?e+"s":e+"es":e.match(/us$/i)?e.slice(0,-2)+"i":e.match(/is$/i)?e.slice(0,-2)+"es":e.match(/on$/i)||e.match(/um$/i)?e.slice(0,-2)+"a":e.match(/a$/i)?e+"s":e.match(/eau$/i)?e+"x":e.match(/ix$/i)||e.match(/ex$/i)?e.slice(0,-2)+"ices":e.match(/s$/i)?e:e+"s"}singularize(e){return String(e||"").trim().replace(/([b-df-hj-np-tv-z])ies$/i,"$1y").replace(/eaux$/i,"eau").replace(/oes$/i,"o").replace(/(xes|zes|ches|shes|sses)$/i,e=>e.slice(0,-2)).replace(/(us)es$/i,"$1").replace(/ves$/i,"f").replace(/([^s])s$/i,"$1")}modelName(e){let r=this.singularize(e);return r=this.camelCase(r),r=this.capitalize(r),r}dotParse(e,r,t=null){return this.dotWalk(e,{source:r,defaultValue:t})}dotWalk(e,r={}){const{source:t={},defaultValue:s=null,resolver:a=({current:e,part:r,source:t})=>e[r]}=r,i=e.split(".");let l=t;for(const e of i){const r=e.match(/^(\w+)\[(.*?)\]$/);if(r){const e=r[1],i=r[2]||0;if(l=l[e]||[],l=a({current:l,part:i,source:t}),void 0===l)return s}else{if(void 0===l[e])return s;if(l=a({current:l,part:e,source:t}),void 0===l)return s}}return l}dotWalkTree(e,r={}){const{resolver:t=({current:e,part:r,source:t})=>{}}=r,s={};for(const r of e){const e=r.split("."),a=e.length;let i=s,l=0;for(const n of e){const e=l===a-1;i[n]=i[n]||t({current:i,part:n,source:s,path:r,isLastPart:e}),i=i[n],l++}}return s}parseValue(e){return isNaN(e)||""===e?"true"===e.toLowerCase()||"false"!==e.toLowerCase()&&("null"===e.toLowerCase()?null:e):Number(e)}parseWhereValue(e){if("string"!=typeof e)return{operator:"=",value:e};const r=e.match(/^(!?)\[\]?(.+)$/);if(r){const[,e,t]=r;return{operator:e?"notIn":"in",value:t.split(",").map(e=>this.parseValue(e.trim()))}}if(e.startsWith("!")){const r=e.substring(1);return{operator:"!=",value:this.parseValue(r)}}if(e.includes("%"))return{operator:"like",value:e};const t=e.match(/^(><|<>)(.+)$/);if(t){const[,e,r]=t,s=r.split(",").map(e=>this.parseValue(e.trim()));if("><"===e){if(2===s.length)return{operator:"between",value:s}}else if("<>"===e&&2===s.length)return{operator:"notBetween",value:s}}const s=e.match(/^(>=|<=|<|>)(.+)$/);if(s){const[,e,r]=s;return{operator:e,value:this.parseValue(r)}}return{operator:"=",value:this.parseValue(e)}}parseWhereColumn(e){const r={joinType:"AND",column:e};return e.startsWith("Or:")&&(r.joinType="OR",r.column=e.substring(3)),r}objectFilter(e,r){return Object.keys(e).filter(t=>r(t,e[t])).reduce((r,t)=>(r[t]=e[t],r),{})}pluckDotWalkKey(e,r=1,t="."){const s=e.split(t);return[...s].splice(-1*s.length+r).join(t)}getDotWalkQuery(e,r=""){let t=this.dotWalkTree(Object.keys(e).filter(e=>e.includes(r)),{resolver:({current:r,part:t,source:s,path:a,isLastPart:i})=>i?e[a]:{}});return r&&(t=t[r]),t}setNested(e,r,t){const s={name:r,value:t},a=s.name.split(".");let i=e,l=i,n="";return a.length>1?(a.forEach(e=>{i=l,l[e]=l[e]||{},l=l[e],n=e}),i[n]=s.value):(n=s.name,i=e[n],e[n]=s.value),e}}module.exports=BaseHelperUtility;
|
package/ControllerWrapper.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
const mysqlWrapper=require("./clients/mysql"),sqliteWrapper=require("./clients/sqlite"),pgWrapper=require("./clients/pg"),InstanceMapper={mysql2:mysqlWrapper,sqlite:sqliteWrapper,pg:pgWrapper},dbClientMapper={mysql2:"mysql2",mysql:"mysql2",pg:"pg",postgresql:"pg",sqlite:"sqlite",sqlite3:"sqlite"};class ControllerWrapper{static db=null;static dbClient=null;static dbClientClass=null;static schema=null;static resolverPath=null;static dbInstance=null;static debug=!1;requestInstance=null;constructor(){this.requestInstance={}}static initializeKORM({db:
|
|
1
|
+
const mysqlWrapper=require("./clients/mysql"),sqliteWrapper=require("./clients/sqlite"),pgWrapper=require("./clients/pg"),KormError=require("./KormError"),AuthorizationService=require("./AuthorizationService"),{buildModelRequestSchema:buildModelRequestSchema}=require("./requestSchema"),{buildModelDescription:buildModelDescription,SCHEMA_API_VERSION:SCHEMA_API_VERSION}=require("./schemaDescribe"),InstanceMapper={mysql2:mysqlWrapper,sqlite:sqliteWrapper,pg:pgWrapper},dbClientMapper={mysql2:"mysql2",mysql:"mysql2",pg:"pg",postgresql:"pg",sqlite:"sqlite",sqlite3:"sqlite"};class ControllerWrapper{static db=null;static dbClient=null;static dbClientClass=null;static schema=null;static resolverPath=null;static dbInstance=null;static debug=!1;static _authz=new AuthorizationService;requestInstance=null;constructor(){this.requestInstance={}}static initializeKORM({db:e,dbClient:t,schema:s,resolverPath:i=null,debug:r=!1}){this.db=e,this.dbClient=t,this.schema=s,this.resolverPath=i,this.debug=r;const a=dbClientMapper[t];if(!a)throw new Error(`Database client ${t} not found`);const n=InstanceMapper[a];if(!n)throw new Error(`Database client ${t} not found`);return this.dbClientClass=n,this.dbInstance=new n(this),this}static setSchema(e){this.schema=e;const t=this.dbClientClass;if(!t)throw new Error(`Database client ${this.dbClient} not found`);return this.dbInstance=new t(this),this}static _resolveModelName(e){const t=this.schema||{};if(t[e])return e;return Object.keys(t).find(s=>t[s]&&t[s].table===e)||e}static authorize(e,t,s){return this._authz.registerAuthorize(this._resolveModelName(e),t,s),this}static scope(e,t){return this._authz.registerScope(this._resolveModelName(e),t),this}static resetAuthorization(){return this._authz.reset(),this}static async processRequest(e,t=null,s=null){let i=e;if(this._authz.hasRules()&&t){const r=this._resolveModelName(t),a=e&&e.action||"list";this._authz.enforce(r,a,e,s),i=this._authz.applyScope(r,a,e||{},s)}return await this.dbInstance.processRequest(i,t,s)}static async processRequestWithOthers(e,t=null,s=null){return await this.dbInstance.processRequest(e,t,s)}static async syncDatabase(){return await this.dbInstance.syncDatabase()}static async generateSchema(){return await this.dbInstance.generateSchema()}static loadModelClass(e){return this.dbInstance.hookService.loadModelClass(e)}static getModelInstance(e){return this.dbInstance.hookService.getModelInstance(e)}static getRequestJsonSchema(e){const t=this.schema||{},s=t[e]||Object.values(t).find(t=>t&&t.table===e);if(!s)throw KormError.unknownModel({model:e,available:Object.keys(t)});return buildModelRequestSchema(s,{title:`KormRequest<${e}>`})}static _modelHasSoftDelete(e){try{const t=this.dbInstance?.hookService?.getModelInstance?.(e);return!(!t||!0!==t.hasSoftDelete)}catch{return!1}}static describeModel(e,t=null){const s=this.schema||{},i=Object.entries(s).find(([t,s])=>t===e||s&&s.table===e);if(!i)throw KormError.unknownModel({model:e,available:Object.keys(s)});const[r,a]=i,n=buildModelDescription(r,a,{softDelete:this._modelHasSoftDelete(r)});return null!=t&&this._authz.hasRules()&&(n.actions=this._authz.availableActions(r,n.actions,t)),n}static describeSchema(){const e=this.schema||{},t=Object.entries(e).map(([e,t])=>buildModelDescription(e,t,{softDelete:this._modelHasSoftDelete(e)}));return{schemaApiVersion:SCHEMA_API_VERSION,models:t}}}module.exports=ControllerWrapper;
|
package/KormError.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
const CODES=Object.freeze({NO_MATCHING_ROW:"NO_MATCHING_ROW",UNKNOWN_ACTION:"UNKNOWN_ACTION",VALIDATION_FAILED:"VALIDATION_FAILED",UNKNOWN_MODEL:"UNKNOWN_MODEL",NO_CUSTOM_ACTION_HOOK:"NO_CUSTOM_ACTION_HOOK",FORBIDDEN:"FORBIDDEN",INTERNAL:"INTERNAL"}),ACTIONS=Object.freeze(["count","sum","list","show","create","update","replace","upsert","sync","delete"]);function levenshtein(e,o){const t=e.length,r=o.length;if(0===t)return r;if(0===r)return t;let n=Array.from({length:r+1},(e,o)=>o),i=new Array(r+1);for(let s=1;s<=t;s++){i[0]=s;for(let t=1;t<=r;t++){const r=e[s-1]===o[t-1]?0:1;i[t]=Math.min(n[t]+1,i[t-1]+1,n[t-1]+r)}[n,i]=[i,n]}return n[r]}function closestAction(e,o=ACTIONS){if(!e)return null;const t=String(e).toLowerCase();let r=null,n=1/0;for(const e of o){const o=levenshtein(t,e);o<n&&(n=o,r=e)}return n<=Math.max(2,Math.ceil(t.length/2))?r:null}class KormError extends Error{constructor(e,{code:o=CODES.INTERNAL,hint:t=null,context:r={},suggestedFixes:n=null}={}){super(e),this.name="KormError",this.code=o,this.hint=t,this.context=r,this.suggestedFixes=n,Error.captureStackTrace&&Error.captureStackTrace(this,KormError)}toJSON(){return{name:this.name,code:this.code,message:this.message,hint:this.hint,context:this.context,suggestedFixes:this.suggestedFixes}}}KormError.noMatchingRow=({action:e,model:o})=>{const t="update"===e?"No row matched the where clause. Use `upsert` to insert-or-update, or `sync` to reconcile.":"No row matched the where clause for this action.";return new KormError(`No matching row for action "${e}" on model "${o}".`,{code:CODES.NO_MATCHING_ROW,hint:t,context:{action:e,model:o}})},KormError.unknownAction=({action:e,model:o,hasCustomHook:t=!1})=>{const r=closestAction(e),n=t?CODES.NO_CUSTOM_ACTION_HOOK:CODES.UNKNOWN_ACTION,i=t?`No custom action hook found for "${o}.${e}".`:`Unknown action "${e}".`,s=r?`Did you mean "${r}"? Valid actions: ${ACTIONS.join(", ")}.`:`Valid actions: ${ACTIONS.join(", ")}. Custom actions require an on<Action>Action hook on the model.`;return new KormError(i,{code:n,hint:s,context:{action:e,model:o,validActions:ACTIONS,closest:r}})},KormError.unknownModel=({model:e,available:o=[]})=>{const t=o.length?`Available models: ${o.join(", ")}.`:"No models are registered in the schema.";return new KormError(`Model "${e}" not found.`,{code:CODES.UNKNOWN_MODEL,hint:t,context:{model:e,available:o}})},KormError.forbidden=({model:e,action:o,hint:t=null,context:r={}}={})=>new KormError(`Action "${o}" on model "${e}" is not permitted in this context.`,{code:CODES.FORBIDDEN,hint:t||"A registered authorize() predicate denied this request for the current context.",context:{action:o,model:e,...r}}),KormError.validationFailed=({errors:e=[],source:o=null})=>{const t=e.map(e=>({field:e.field,message:e.message,value:e.value,rule:e.rule})),r=t.map(e=>e.field).filter(Boolean),n=new KormError(`Validation failed${o?` for ${o}`:""}${r.length?`: ${r.join(", ")}`:""}.`,{code:CODES.VALIDATION_FAILED,hint:"Fix the listed fields and resubmit. See context.fields for per-field detail.",context:{source:o,fields:t}});return n.errors=e,n},KormError.CODES=CODES,KormError.ACTIONS=ACTIONS,KormError.closestAction=closestAction,module.exports=KormError;
|
package/README.md
CHANGED
|
@@ -5,6 +5,48 @@
|
|
|
5
5
|
[](https://badge.fury.io/js/@dreamtree-org%2Fkorm-js)
|
|
6
6
|
[](https://opensource.org/licenses/MIT)
|
|
7
7
|
|
|
8
|
+
## About the Company
|
|
9
|
+
|
|
10
|
+
KORM-JS is built and maintained under **[Dreamtree Global](http://dreamtreeglobal.com/)** — a team crafting thoughtful, developer-first software for the modern web. We believe great products start with a great foundation, and that's exactly what this library aims to be: a polished, open, and dependable data layer for teams everywhere.
|
|
11
|
+
|
|
12
|
+
🌐 **Website:** [dreamtreeglobal.com](http://dreamtreeglobal.com/)
|
|
13
|
+
|
|
14
|
+
## Author & Maintainer
|
|
15
|
+
|
|
16
|
+
**Partha Preetham Krishna M L**
|
|
17
|
+
📧 [preetham.krishna.dev@gmail.com](mailto:preetham.krishna.dev@gmail.com)
|
|
18
|
+
|
|
19
|
+
Have an idea, a bug, or just want to say hi? Reach out anytime — feedback from developers like you is what keeps this library growing.
|
|
20
|
+
|
|
21
|
+
## 💚 Sponsor & Support
|
|
22
|
+
|
|
23
|
+
KORM-JS is **free and open source**, built in the open and powered entirely by passion and late nights. Every feature you rely on — every multi-engine query, relation-aware join, and line of zero-boilerplate CRUD — represents hours of careful craft so that *your* backend can ship faster and stay safer.
|
|
24
|
+
|
|
25
|
+
If this library has saved you time, sparked an idea, or spared you a pile of hand-written SQL, please consider supporting its continued development. **Your sponsorship keeps the updates coming, the bugs squashed, and the docs sharp — for the whole community.** Even the smallest contribution is a huge encouragement. 🙏
|
|
26
|
+
|
|
27
|
+
### ☕ Make a one-tap donation via UPI
|
|
28
|
+
|
|
29
|
+
**📷 Scan the QR with any UPI app** (Google Pay, PhonePe, Paytm, BHIM, …) to pay instantly:
|
|
30
|
+
|
|
31
|
+
<p>
|
|
32
|
+
<img src="https://unpkg.com/@dreamtree-org/twreact-ui@latest/doc/assets/upi-qr.png" alt="Scan to pay Dreamtree Global via UPI" width="220" height="220" />
|
|
33
|
+
</p>
|
|
34
|
+
|
|
35
|
+
> **UPI ID:** `dhrugantha.llp@kotak` • **Payee:** Dreamtree Global
|
|
36
|
+
>
|
|
37
|
+
> 📱 On mobile, you can also tap the button below — it opens your UPI app directly. On desktop, scan the QR above or copy the UPI ID into your payment app.
|
|
38
|
+
|
|
39
|
+
[](upi://pay?pa=dhrugantha.llp@kotak&pn=Dreamtree%20Global&cu=INR&tn=Support%20KORM-JS)
|
|
40
|
+
|
|
41
|
+
### 🤝 Become a Sponsor
|
|
42
|
+
|
|
43
|
+
Want to back the project long-term or as a company? You can sponsor the package directly:
|
|
44
|
+
|
|
45
|
+
- 📦 **npm:** [`@dreamtree-org/korm-js`](https://www.npmjs.com/package/@dreamtree-org/korm-js) — star, share, and sponsor the package
|
|
46
|
+
- ✉️ **Corporate sponsorships & partnerships:** [preetham.krishna.dev@gmail.com](mailto:preetham.krishna.dev@gmail.com)
|
|
47
|
+
|
|
48
|
+
Every star ⭐, share, and contribution helps more than you know. Thank you for being part of the journey!
|
|
49
|
+
|
|
8
50
|
## Features
|
|
9
51
|
|
|
10
52
|
- 🚀 **Multi-Database Support**: MySQL, PostgreSQL, SQLite
|
|
@@ -22,6 +64,7 @@
|
|
|
22
64
|
- 📝 **Logger Utility**: Configurable logging with multiple log levels
|
|
23
65
|
- 🔎 **SQL Debugging**: Debug mode with SQL statement output for troubleshooting
|
|
24
66
|
- 🚫 **NOT EXISTS Queries**: Support for checking absence of related records with `!` prefix
|
|
67
|
+
- 🤖 **AI-native**: a bundled [MCP server](#running-as-an-mcp-server) serves your tables to coding agents as typed JSON tools, plus an [`init --ai`](#ai-assistant-skills-init---ai) skill installer for Claude, Cursor, Copilot, and more
|
|
25
68
|
|
|
26
69
|
## Installation
|
|
27
70
|
|
|
@@ -405,8 +448,25 @@ POST /api/Employee/crud
|
|
|
405
448
|
|
|
406
449
|
### 8. Replace Operation
|
|
407
450
|
|
|
451
|
+
Replaces an entire row, keyed on the model's primary key (include the PK in
|
|
452
|
+
`data`). Supported on **all three engines**, but the semantics differ by what
|
|
453
|
+
each engine can express:
|
|
454
|
+
|
|
455
|
+
| Engine | Statement | Semantics |
|
|
456
|
+
| --- | --- | --- |
|
|
457
|
+
| MySQL | `REPLACE INTO` | True delete + insert. Columns omitted from `data` reset to their column **DEFAULT** (or `NULL`). |
|
|
458
|
+
| SQLite | `INSERT OR REPLACE` | True delete + insert (same as MySQL). |
|
|
459
|
+
| Postgres | `INSERT … ON CONFLICT (pk) DO UPDATE` | **Merge**, not a true replace: columns omitted from `data` keep their existing values (Postgres has no native `REPLACE`). |
|
|
460
|
+
|
|
461
|
+
> ⚠️ **Cross-engine caveat:** on Postgres, `replace` is emulated and behaves
|
|
462
|
+
> like a merge — fields you omit are **retained**, whereas MySQL/SQLite reset
|
|
463
|
+
> them. If you need portable insert-or-update semantics, prefer `upsert`. The
|
|
464
|
+
> conflict target defaults to the model's primary key; pass an explicit
|
|
465
|
+
> `conflict: ['col', …]` to override (required on Postgres if the model has no
|
|
466
|
+
> primary key).
|
|
467
|
+
|
|
408
468
|
```javascript
|
|
409
|
-
// Replace record
|
|
469
|
+
// Replace record — replaces the entire row by primary key
|
|
410
470
|
POST /api/Users/crud
|
|
411
471
|
{
|
|
412
472
|
"action": "replace",
|
|
@@ -1568,15 +1628,19 @@ type|modifier1|modifier2|...
|
|
|
1568
1628
|
| Modifier | Description | Example |
|
|
1569
1629
|
| ------------------------- | ------------------ | ---------- | --------------------------- | -------------- |
|
|
1570
1630
|
| `size:n` | Column size | `varchar | size:255` |
|
|
1571
|
-
| `unsigned
|
|
1631
|
+
| `unsigned`¹ | Unsigned integer | `int | unsigned` |
|
|
1572
1632
|
| `primaryKey` | Primary key column | `bigint | primaryKey` |
|
|
1573
1633
|
| `autoIncrement` | Auto increment | `bigint | primaryKey | autoIncrement` |
|
|
1574
1634
|
| `notNull` | Not nullable | `varchar | size:255 | notNull` |
|
|
1575
1635
|
| `unique` | Unique constraint | `varchar | unique` |
|
|
1576
1636
|
| `default:value` | Default value | `tinyint | default:1` |
|
|
1577
|
-
| `onUpdate:value
|
|
1637
|
+
| `onUpdate:value`² | On update value | `timestamp | onUpdate:CURRENT_TIMESTAMP` |
|
|
1578
1638
|
| `comment:text` | Column comment | `varchar | comment:User email address` |
|
|
1579
|
-
| `foreignKey:table:column
|
|
1639
|
+
| `foreignKey:table:column`³| Foreign key | `int | foreignKey:users:id` |
|
|
1640
|
+
|
|
1641
|
+
¹ **Engine-specific.** `unsigned` is honored on MySQL and SQLite. PostgreSQL has no unsigned integer type and silently drops the modifier.
|
|
1642
|
+
² **Engine-specific.** `onUpdate` is honored on MySQL (emitted via `ON UPDATE <expr>`). PostgreSQL and SQLite log a one-time warning and ignore it — the modifier cannot be expressed inline on those engines. See [`docs/agents/05-multi-db-parity.md`](docs/agents/05-multi-db-parity.md).
|
|
1643
|
+
³ **Auto type-matching.** When the referenced table is part of the same schema, `syncDatabase()` automatically widens the foreign-key column's type to match the referenced primary key, so the FK constraint is always type-compatible. In particular, an `autoIncrement` primary key is emitted as `BIGINT UNSIGNED` (MySQL) / `BIGSERIAL` (PostgreSQL) / `INTEGER` (SQLite) via Knex's `.increments()`, so a child column written as `int|foreignKey:…` is created as `BIGINT` — you do **not** need to hand-match the width. Foreign keys to tables outside the schema (pre-existing/third-party) are created exactly as declared.
|
|
1580
1644
|
|
|
1581
1645
|
**Special Default Values:**
|
|
1582
1646
|
|
|
@@ -1691,7 +1755,7 @@ const modelInstance = korm.getModelInstance(modelDef);
|
|
|
1691
1755
|
| `delete` | Delete record(s) | `where` |
|
|
1692
1756
|
| `count` | Count records | None (optional: `where`) |
|
|
1693
1757
|
| `sum` | Sum column or expression | `data.sumColumn` or `data.sumFormula` (optional: `where`) |
|
|
1694
|
-
| `replace` | Replace
|
|
1758
|
+
| `replace` | Replace full row by PK (all engines; pg = merge, see §8) | `data` (optional: `conflict`) |
|
|
1695
1759
|
| `upsert` | Insert or update | `data`, `conflict` |
|
|
1696
1760
|
| `sync` | Upsert + delete | `data`, `conflict`, `where` |
|
|
1697
1761
|
|
|
@@ -1866,6 +1930,38 @@ if (process.env.NODE_ENV === 'development') {
|
|
|
1866
1930
|
}
|
|
1867
1931
|
```
|
|
1868
1932
|
|
|
1933
|
+
## Inspecting queries (`dryRun`)
|
|
1934
|
+
|
|
1935
|
+
Add `dryRun: true` to any request to get back the SQL it **would** run —
|
|
1936
|
+
without executing anything. Useful for audit pipelines, previewing
|
|
1937
|
+
destructive operations, and letting an AI agent review SQL before
|
|
1938
|
+
committing to it.
|
|
1939
|
+
|
|
1940
|
+
```javascript
|
|
1941
|
+
const result = await korm.processRequest(
|
|
1942
|
+
{ action: 'delete', where: { status: 'archived' }, dryRun: true },
|
|
1943
|
+
'Post'
|
|
1944
|
+
);
|
|
1945
|
+
// → {
|
|
1946
|
+
// success: true,
|
|
1947
|
+
// dryRun: true,
|
|
1948
|
+
// action: 'delete',
|
|
1949
|
+
// model: 'Post',
|
|
1950
|
+
// sql: 'delete from `posts` where `status` = ?',
|
|
1951
|
+
// bindings: ['archived'],
|
|
1952
|
+
// statements: [{ sql: '...', bindings: ['archived'] }],
|
|
1953
|
+
// }
|
|
1954
|
+
```
|
|
1955
|
+
|
|
1956
|
+
- Validation still runs (you still get a `KormError` for a bad request).
|
|
1957
|
+
- `before`/`after` model hooks do **not** fire, and the database is
|
|
1958
|
+
untouched.
|
|
1959
|
+
- Bindings are returned as a separate array (not interpolated into
|
|
1960
|
+
`sql`), so you can re-parameterize.
|
|
1961
|
+
- `sync` returns both statements (upsert + delete) in `statements`.
|
|
1962
|
+
|
|
1963
|
+
Full reference: [`docs/agents/06-request-contract.md`](docs/agents/06-request-contract.md) §9.
|
|
1964
|
+
|
|
1869
1965
|
## SQL Debugging
|
|
1870
1966
|
|
|
1871
1967
|
Enable SQL debugging to see the exact SQL statements generated by your queries. This is useful for troubleshooting complex queries and understanding how KORM translates your requests.
|
|
@@ -2012,54 +2108,243 @@ const korm = initializeKORM({
|
|
|
2012
2108
|
});
|
|
2013
2109
|
```
|
|
2014
2110
|
|
|
2111
|
+
## Using KORM-JS as an AI tool
|
|
2112
|
+
|
|
2113
|
+
The discovery → request flow for an LLM agent is two calls: **describe** what's available, then build a request constrained by its **JSON Schema**.
|
|
2114
|
+
|
|
2115
|
+
### 1. Discover the schema — `describeSchema()` / `describeModel(name)`
|
|
2116
|
+
|
|
2117
|
+
Pure-data, JSON-safe introspection (no hooks, credentials, or internals leak). Use it to load context before generating a request.
|
|
2118
|
+
|
|
2119
|
+
```javascript
|
|
2120
|
+
korm.describeSchema();
|
|
2121
|
+
// → { schemaApiVersion: 1, models: [ { model, table, columns, relations, softDelete, actions }, … ] }
|
|
2122
|
+
|
|
2123
|
+
korm.describeModel('User');
|
|
2124
|
+
// → {
|
|
2125
|
+
// schemaApiVersion: 1,
|
|
2126
|
+
// model: 'User', table: 'users', alias: 'User',
|
|
2127
|
+
// columns: [ { name: 'id', type: 'integer', primaryKey: true, autoIncrement: true, nullable: false }, … ],
|
|
2128
|
+
// relations: [ { name: 'Post', type: 'many', table: 'posts', localKey: 'id', foreignKey: 'user_id' } ],
|
|
2129
|
+
// softDelete: false,
|
|
2130
|
+
// actions: ['list','show','count','sum','create','update','delete','replace','upsert','sync'],
|
|
2131
|
+
// }
|
|
2132
|
+
```
|
|
2133
|
+
|
|
2134
|
+
Unknown models throw a `KormError` with `code: 'UNKNOWN_MODEL'` (the `context.available` list helps the caller recover).
|
|
2135
|
+
|
|
2136
|
+
### 2. Constrain the request — `getRequestJsonSchema(modelName)`
|
|
2137
|
+
|
|
2138
|
+
`korm.getRequestJsonSchema(modelName)` returns a draft-2020-12 JSON Schema describing every valid `processRequest` body for that model — an `action`-discriminated `oneOf` with typed `data`, a `select`/`orderBy`/`conflict` constrained to the model's columns, and inline descriptions. Attach it to an OpenAI / Anthropic tool definition, or use it for client-side prevalidation, so the model's output is constrained to a request your app can actually run.
|
|
2139
|
+
|
|
2140
|
+
```javascript
|
|
2141
|
+
const schema = korm.getRequestJsonSchema('User');
|
|
2142
|
+
|
|
2143
|
+
// OpenAI tool definition
|
|
2144
|
+
const tool = {
|
|
2145
|
+
type: 'function',
|
|
2146
|
+
function: {
|
|
2147
|
+
name: 'query_users',
|
|
2148
|
+
description: 'Query or mutate the User model via KORM-JS.',
|
|
2149
|
+
parameters: schema, // the oneOf-over-actions request schema
|
|
2150
|
+
},
|
|
2151
|
+
};
|
|
2152
|
+
|
|
2153
|
+
// Anthropic tool definition
|
|
2154
|
+
const anthropicTool = {
|
|
2155
|
+
name: 'query_users',
|
|
2156
|
+
description: 'Query or mutate the User model via KORM-JS.',
|
|
2157
|
+
input_schema: schema,
|
|
2158
|
+
};
|
|
2159
|
+
```
|
|
2160
|
+
|
|
2161
|
+
The schema is derived from the model's column definitions and relations, so it stays in sync with your schema. Unknown models throw a `KormError` with `code: 'UNKNOWN_MODEL'`.
|
|
2162
|
+
|
|
2163
|
+
## Running as an MCP server
|
|
2164
|
+
|
|
2165
|
+
KORM-JS ships with an optional [Model Context Protocol](https://modelcontextprotocol.io) server. It exposes your KORM-registered tables as typed JSON-in/JSON-out tools that any MCP client (Claude Desktop, Claude Code, Cursor, custom agents) can call — no HTTP layer, no hand-written CRUD.
|
|
2166
|
+
|
|
2167
|
+
Two equivalent ways to launch it (both take the same `--config`):
|
|
2168
|
+
|
|
2169
|
+
```bash
|
|
2170
|
+
korm-mcp --config ./korm-mcp.config.js # the dedicated bin
|
|
2171
|
+
npx -y @dreamtree-org/korm-js mcp --config ./korm-mcp.config.js # the `mcp` subcommand (zero install)
|
|
2172
|
+
```
|
|
2173
|
+
|
|
2174
|
+
### Install the SDK
|
|
2175
|
+
|
|
2176
|
+
The MCP SDK is an _optional_ dependency. If `npm install @dreamtree-org/korm-js` did not auto-install it (locked-down registry, offline mirror, etc.), pull it in explicitly:
|
|
2177
|
+
|
|
2178
|
+
```bash
|
|
2179
|
+
npm install @modelcontextprotocol/sdk
|
|
2180
|
+
```
|
|
2181
|
+
|
|
2182
|
+
### Write a config
|
|
2183
|
+
|
|
2184
|
+
`korm-mcp.config.js`:
|
|
2185
|
+
|
|
2186
|
+
```javascript
|
|
2187
|
+
const knex = require('knex');
|
|
2188
|
+
const schema = require('./schema'); // your KORM schema map
|
|
2189
|
+
|
|
2190
|
+
module.exports = {
|
|
2191
|
+
// Same shape as initializeKORM
|
|
2192
|
+
db: knex({ client: 'pg', connection: process.env.DATABASE_URL }),
|
|
2193
|
+
dbClient: 'pg',
|
|
2194
|
+
schema,
|
|
2195
|
+
resolverPath: './models', // optional, for model hooks
|
|
2196
|
+
debug: false,
|
|
2197
|
+
|
|
2198
|
+
mcp: {
|
|
2199
|
+
mode: 'ro', // 'ro' | 'rw' | 'rw-sync'
|
|
2200
|
+
allowlist: ['User', 'Post', 'Comment'], // flat list of model names; '*' allowed only in 'ro'
|
|
2201
|
+
blocklist: [], // applied after allowlist
|
|
2202
|
+
metaTools: true, // korm.list_tables, korm.describe_schema, korm.health
|
|
2203
|
+
allowNestedRequests: false, // gate `other_requests` (off by default)
|
|
2204
|
+
customActions: [], // [{ table, action, schema?, description? }]
|
|
2205
|
+
},
|
|
2206
|
+
};
|
|
2207
|
+
```
|
|
2208
|
+
|
|
2209
|
+
### Wire it into your MCP client
|
|
2210
|
+
|
|
2211
|
+
```json
|
|
2212
|
+
{
|
|
2213
|
+
"mcpServers": {
|
|
2214
|
+
"my-app-db": {
|
|
2215
|
+
"command": "korm-mcp",
|
|
2216
|
+
"args": ["--config", "/abs/path/to/korm-mcp.config.js"]
|
|
2217
|
+
}
|
|
2218
|
+
}
|
|
2219
|
+
}
|
|
2220
|
+
```
|
|
2221
|
+
|
|
2222
|
+
Or, with no global install, use the `mcp` subcommand via `npx`:
|
|
2223
|
+
|
|
2224
|
+
```json
|
|
2225
|
+
{
|
|
2226
|
+
"mcpServers": {
|
|
2227
|
+
"my-app-db": {
|
|
2228
|
+
"command": "npx",
|
|
2229
|
+
"args": ["-y", "@dreamtree-org/korm-js", "mcp", "--config", "/abs/path/to/korm-mcp.config.js"]
|
|
2230
|
+
}
|
|
2231
|
+
}
|
|
2232
|
+
}
|
|
2233
|
+
```
|
|
2234
|
+
|
|
2235
|
+
### What you get
|
|
2236
|
+
|
|
2237
|
+
For each allowlisted table, the server emits one tool per action permitted by `mcp.mode`. Example for a `User` model:
|
|
2238
|
+
|
|
2239
|
+
| Tool | Available in mode | Maps to |
|
|
2240
|
+
| --------------- | --------------------- | -------------------------------------------- |
|
|
2241
|
+
| `users.list` | `ro`, `rw`, `rw-sync` | `processRequest({ action: 'list', ... })` |
|
|
2242
|
+
| `users.show` | `ro`, `rw`, `rw-sync` | `processRequest({ action: 'show', ... })` |
|
|
2243
|
+
| `users.count` | `ro`, `rw`, `rw-sync` | `processRequest({ action: 'count', ... })` |
|
|
2244
|
+
| `users.sum` | `ro`, `rw`, `rw-sync` | `processRequest({ action: 'sum', ... })` |
|
|
2245
|
+
| `users.create` | `rw`, `rw-sync` | `processRequest({ action: 'create', ... })` |
|
|
2246
|
+
| `users.update` | `rw`, `rw-sync` | `processRequest({ action: 'update', ... })` |
|
|
2247
|
+
| `users.delete` | `rw`, `rw-sync` | `processRequest({ action: 'delete', ... })` |
|
|
2248
|
+
| `users.upsert` | `rw`, `rw-sync` | `processRequest({ action: 'upsert', ... })` |
|
|
2249
|
+
| `users.replace` | `rw`, `rw-sync` | `processRequest({ action: 'replace', ... })` |
|
|
2250
|
+
| `users.sync` | `rw-sync` only | `processRequest({ action: 'sync', ... })` |
|
|
2251
|
+
|
|
2252
|
+
Three meta tools (unless disabled via `mcp.metaTools: false`):
|
|
2253
|
+
|
|
2254
|
+
| Tool | Purpose |
|
|
2255
|
+
| ---------------------- | -------------------------------------------------------------- |
|
|
2256
|
+
| `korm.list_tables` | List the allowlisted tables with column / relation counts. |
|
|
2257
|
+
| `korm.describe_schema` | Return columns + relations for a single allowlisted table. |
|
|
2258
|
+
| `korm.health` | Engine name, library version, allowlist size, `SELECT 1` ping. |
|
|
2259
|
+
|
|
2260
|
+
### Safety properties
|
|
2261
|
+
|
|
2262
|
+
- **No raw SQL surface.** Tools always go through `processRequest`, which routes user-supplied values through Knex bindings.
|
|
2263
|
+
- **Writes are off by default.** `mcp.mode` defaults to `ro`; opting into `rw` or `rw-sync` is a deliberate config choice that also requires a non-`*` allowlist.
|
|
2264
|
+
- **Nested requests are off by default.** `other_requests` from the LLM is stripped unless you set `mcp.allowNestedRequests: true`.
|
|
2265
|
+
- **Custom action hooks are not auto-exposed.** Add an explicit entry to `mcp.customActions` to make an `on{Action}` hook callable.
|
|
2266
|
+
|
|
2267
|
+
See `docs/agents/11-mcp-server.md` for the full design rationale and the locked decisions behind these defaults.
|
|
2268
|
+
|
|
2269
|
+
## Authorization (multi-tenancy & RBAC)
|
|
2270
|
+
|
|
2271
|
+
KORM-JS enforces authorization **at the contract layer**, in front of every engine, via two opt-in registrations. The `ctx` you already pass as the third argument to `processRequest(body, model, ctx)` is what predicates and scopes receive — wire it from your auth middleware.
|
|
2272
|
+
|
|
2273
|
+
```js
|
|
2274
|
+
const korm = initializeKORM({ db, dbClient, schema });
|
|
2275
|
+
|
|
2276
|
+
// 1) Per-(model, action) permission predicate. Return falsy → the request
|
|
2277
|
+
// throws KormError({ code: 'FORBIDDEN' }) before any SQL runs (dryRun too).
|
|
2278
|
+
korm.authorize('User', 'delete', (req, ctx) => ctx.user.role === 'admin');
|
|
2279
|
+
korm.authorize('Invoice', '*', (req, ctx) => Boolean(ctx.tenantId)); // '*' = every action
|
|
2280
|
+
|
|
2281
|
+
// 2) Row scope. Merged into `where` for reads / update / delete / count / sum,
|
|
2282
|
+
// and stamped into each inserted `data` row for create / replace / upsert /
|
|
2283
|
+
// sync — so a tenant can neither read nor create rows outside its scope.
|
|
2284
|
+
korm.scope('Invoice', (req, ctx) => ({ tenantId: ctx.tenantId }));
|
|
2285
|
+
```
|
|
2286
|
+
|
|
2287
|
+
```js
|
|
2288
|
+
// In your route handler — ctx carries the authenticated principal:
|
|
2289
|
+
app.post('/api/:model', (req, res) =>
|
|
2290
|
+
korm.processRequest(req.body, req.params.model, { user: req.user, tenantId: req.user.tenantId })
|
|
2291
|
+
);
|
|
2292
|
+
```
|
|
2293
|
+
|
|
2294
|
+
- **Opt-in & non-breaking:** with no `authorize()`/`scope()` registered, requests pass through unchanged.
|
|
2295
|
+
- **Scope wins:** a client cannot widen its scope via `where`, nor create a row in another tenant via `data` — the scope value overrides any conflicting client value.
|
|
2296
|
+
- **Capability discovery:** `describeModel(name, ctx)` returns `actions` filtered to those permitted for `ctx`, so an agent sees what it may call before generating a request.
|
|
2297
|
+
- **Denied requests** throw `KormError({ code: 'FORBIDDEN', context: { model, action } })` (see below). A throwing predicate fails closed (denies).
|
|
2298
|
+
|
|
2299
|
+
> Scoping applies to every read/write action — including **`count`** and **`sum`** (they honor `where`) — so a tenant's totals reflect only its own rows.
|
|
2300
|
+
|
|
2015
2301
|
## Error Handling
|
|
2016
2302
|
|
|
2303
|
+
`processRequest` and `validate` throw a structured **`KormError`** (which
|
|
2304
|
+
extends the native `Error`). Branch on `error.code` rather than
|
|
2305
|
+
string-matching `error.message`. Full reference: [`doc/ERRORS.md`](doc/ERRORS.md).
|
|
2306
|
+
|
|
2307
|
+
| `code` | Meaning |
|
|
2308
|
+
| ----------------------- | -------------------------------------------------------- |
|
|
2309
|
+
| `NO_MATCHING_ROW` | A mutating action matched no row |
|
|
2310
|
+
| `UNKNOWN_ACTION` | Action isn't built-in and has no custom hook |
|
|
2311
|
+
| `NO_CUSTOM_ACTION_HOOK` | Custom action requested, no hook on the model |
|
|
2312
|
+
| `VALIDATION_FAILED` | Input failed validation (`error.context.fields`) |
|
|
2313
|
+
| `UNKNOWN_MODEL` | Model name not in the schema (`error.context.available`) |
|
|
2314
|
+
| `INTERNAL` | Internal invariant / misconfiguration |
|
|
2315
|
+
|
|
2017
2316
|
```javascript
|
|
2018
|
-
|
|
2019
|
-
app.use((error, req, res, next) => {
|
|
2020
|
-
console.error('KORM Error:', error);
|
|
2021
|
-
|
|
2022
|
-
res.status(error.status || 500).json({
|
|
2023
|
-
success: false,
|
|
2024
|
-
message: 'Internal server error',
|
|
2025
|
-
error: process.env.NODE_ENV === 'development' ? error.message : 'Something went wrong',
|
|
2026
|
-
stack: process.env.NODE_ENV === 'development' ? error.stack : undefined,
|
|
2027
|
-
});
|
|
2028
|
-
});
|
|
2317
|
+
const { KormError } = require('@dreamtree-org/korm-js');
|
|
2029
2318
|
|
|
2030
|
-
// Route-specific error handling
|
|
2031
2319
|
app.post('/api/:model/crud', async (req, res) => {
|
|
2032
2320
|
try {
|
|
2033
|
-
const
|
|
2034
|
-
const result = await korm.processRequest(req.body, model);
|
|
2321
|
+
const result = await korm.processRequest(req.body, req.params.model);
|
|
2035
2322
|
res.json(result);
|
|
2036
2323
|
} catch (error) {
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2324
|
+
if (error instanceof KormError) {
|
|
2325
|
+
const status =
|
|
2326
|
+
error.code === 'UNKNOWN_MODEL' || error.code === 'NO_MATCHING_ROW'
|
|
2327
|
+
? 404
|
|
2328
|
+
: error.code === 'VALIDATION_FAILED' ||
|
|
2329
|
+
error.code === 'UNKNOWN_ACTION' ||
|
|
2330
|
+
error.code === 'NO_CUSTOM_ACTION_HOOK'
|
|
2331
|
+
? 400
|
|
2332
|
+
: 500;
|
|
2333
|
+
// error.toJSON() → { name, code, message, hint, context, suggestedFixes }
|
|
2334
|
+
return res.status(status).json({ success: false, error: error.toJSON() });
|
|
2044
2335
|
}
|
|
2045
|
-
|
|
2046
|
-
// Handle not found errors
|
|
2047
|
-
if (error.message.includes('not found')) {
|
|
2048
|
-
return res.status(404).json({
|
|
2049
|
-
success: false,
|
|
2050
|
-
message: error.message,
|
|
2051
|
-
});
|
|
2052
|
-
}
|
|
2053
|
-
|
|
2054
|
-
// Handle other errors
|
|
2055
|
-
res.status(400).json({
|
|
2056
|
-
success: false,
|
|
2057
|
-
message: error.message,
|
|
2058
|
-
});
|
|
2336
|
+
res.status(500).json({ success: false, error: 'Internal server error' });
|
|
2059
2337
|
}
|
|
2060
2338
|
});
|
|
2061
2339
|
```
|
|
2062
2340
|
|
|
2341
|
+
> **Migration note.** Validation errors previously surfaced with
|
|
2342
|
+
> `name: 'ValidationError'`. They are now `KormError` with
|
|
2343
|
+
> `code === 'VALIDATION_FAILED'` (the raw field errors remain on
|
|
2344
|
+
> `error.errors` for back-compat; per-field detail is also under
|
|
2345
|
+
> `error.context.fields`). Switch `error.name === 'ValidationError'`
|
|
2346
|
+
> checks to `error.code === 'VALIDATION_FAILED'`.
|
|
2347
|
+
|
|
2063
2348
|
## Complete Example Application
|
|
2064
2349
|
|
|
2065
2350
|
```javascript
|
|
@@ -2149,8 +2434,12 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
|
|
|
2149
2434
|
|
|
2150
2435
|
## Support
|
|
2151
2436
|
|
|
2152
|
-
|
|
2437
|
+
- 🐛 [Issue Tracker](https://github.com/DreamtreeTech/korm-js/issues)
|
|
2438
|
+
- 💬 [Discussions](https://github.com/DreamtreeTech/korm-js/discussions)
|
|
2439
|
+
- 📦 [npm package](https://www.npmjs.com/package/@dreamtree-org/korm-js)
|
|
2440
|
+
- 🌐 [Dreamtree Global](http://dreamtreeglobal.com/)
|
|
2441
|
+
- 📧 [preetham.krishna.dev@gmail.com](mailto:preetham.krishna.dev@gmail.com)
|
|
2153
2442
|
|
|
2154
2443
|
---
|
|
2155
2444
|
|
|
2156
|
-
**Made with ❤️ by [Partha Preetham Krishna](https://www.linkedin.com/in/partha-preetham-krishna-68ba00197/)**
|
|
2445
|
+
**Made with ❤️ by [Partha Preetham Krishna](https://www.linkedin.com/in/partha-preetham-krishna-68ba00197/)** under [Dreamtree Global](http://dreamtreeglobal.com/)
|
package/RequestValidator.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
const{processRequest:processRequest}=require("./ControllerWrapper"),HelperUtility=require("./BaseHelperUtility");class ValidationError extends Error{constructor(e,t,a,r){super(e),this.name="ValidationError",this.field=t,this.value=a,this.rule=r,this.timestamp=(new Date).toISOString()}}class RequestValidator{constructor(){this.rules=new Map,this.customMessages=new Map,this.transformers=new Map,this.customRegex=new Map,this.customCallbacks=new Map,this.helperUtility=new HelperUtility}rule(e,t,a=null){return this.rules.has(e)||this.rules.set(e,[]),this.rules.get(e).push(t),a&&this.customMessages.set(`${e}.${t.type}`,a),this}string(e,t=null){return this.rule(e,{type:"string",validator:e=>"string"==typeof e},t)}number(e,t=null){return this.rule(e,{type:"number",validator:e=>"number"==typeof e&&!isNaN(e)},t)}boolean(e,t=null){return this.rule(e,{type:"boolean",validator:e=>"boolean"==typeof e},t)}required(e,t=null){return this.rule(e,{type:"required",validator:e=>null!=e&&""!==e},t)}email(e,t=null){const a=/^[^\s@]+@[^\s@]+\.[^\s@]+$/;return this.rule(e,{type:"email",validator:e=>a.test(e)},t)}url(e,t=null){return this.rule(e,{type:"url",validator:e=>{try{return new URL(e),!0}catch{return!1}}},t)}minLength(e,t,a=null){return this.rule(e,{type:"minLength",validator:e=>String(e).length>=t,params:{min:t}},a)}maxLength(e,t,a=null){return this.rule(e,{type:"maxLength",validator:e=>String(e).length<=t,params:{max:t}},a)}min(e,t,a=null){return this.rule(e,{type:"min",validator:e=>Number(e)>=t,params:{min:t}},a)}max(e,t,a=null){return this.rule(e,{type:"max",validator:e=>Number(e)<=t,params:{max:t}},a)}enum(e,t,a=null){return this.rule(e,{type:"enum",validator:e=>t.includes(e),params:{allowedValues:t}},a)}regex(e,t,a=null){return this.rule(e,{type:"regex",validator:e=>t.test(e),params:{pattern:t}},a)}uuid(e,t=null){const a=/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;return this.rule(e,{type:"uuid",validator:e=>a.test(e)},t)}date(e,t=null){return this.rule(e,{type:"date",validator:e=>!isNaN(Date.parse(e))},t)}array(e,t=null){return this.rule(e,{type:"array",validator:e=>Array.isArray(e)},t)}object(e,t=null){return this.rule(e,{type:"object",validator:e=>"object"==typeof e&&null!==e&&!Array.isArray(e)},t)}custom(e,t,a=null){return this.rule(e,{type:"custom",validator:t},a)}transform(e,t){return this.transformers.set(e,t),this}message(e,t,a){return this.customMessages.set(`${e}.${t}`,a),this}getDefaultMessage(e,t,a,r={}){return{required:`${e} is required`,string:`${e} must be a string`,number:`${e} must be a number`,boolean:`${e} must be a boolean`,email:`${e} must be a valid email address`,url:`${e} must be a valid URL`,minLength:`${e} must be at least ${r.min} characters long`,maxLength:`${e} must be at most ${r.max} characters long`,min:`${e} must be at least ${r.min}`,max:`${e} must be at most ${r.max}`,enum:`${e} must be one of: ${r.allowedValues?.join(", ")}`,regex:`${e} format is invalid`,uuid:`${e} must be a valid UUID`,date:`${e} must be a valid date`,array:`${e} must be an array`,object:`${e} must be an object`,custom:`${e} validation failed`}[t]||`${e} validation failed`}validateField(e,t){const a=this.rules.get(e)||[],r=[];let s=t;this.transformers.has(e)&&(s=this.transformers.get(e)(t));for(const t of a)try{if(!t.validator(s)){const a=this.customMessages.get(`${e}.${t.type}`)||this.getDefaultMessage(e,t.type,s,t.params);r.push(new ValidationError(a,e,s,t.type))}}catch(a){const i=this.customMessages.get(`${e}.${t.type}`)||this.getDefaultMessage(e,t.type,s,t.params);r.push(new ValidationError(i,e,s,t.type))}return{value:s,errors:r}}validate(e,t={}){const{source:a="body"}=t,r=[],s={};for(const[t,a]of this.rules){const a=e[t],{value:i,errors:n}=this.validateField(t,a);n.length>0?r.push(...n):s[t]=i}if(r.length>0){const e=new Error(`Validation failed for ${a}`);throw e.name="ValidationError",e.errors=r,e.source=a,e}return s}validateParams(e){return this.validate(e,{source:"params"})}validateBody(e){return this.validate(e,{source:"body"})}validateQuery(e){return this.validate(e,{source:"query"})}validateRequest(e){const t={params:{},body:{},query:{}};try{e.params&&(t.params=this.validateParams(e.params))}catch(e){t.params={error:e}}try{e.body&&(t.body=this.validateBody(e.body))}catch(e){t.body={error:e}}try{e.query&&(t.query=this.validateQuery(e.query))}catch(e){t.query={error:e}}return t}static create(){return new RequestValidator}static schema(e){const t=new RequestValidator;for(const[a,r]of Object.entries(e))Array.isArray(r)?r.forEach(e=>{"string"==typeof e?t[e](a):"object"==typeof e&&t.rule(a,e)}):"string"==typeof r?t[r](a):"object"==typeof r&&t.rule(a,r);return t}parseRuleString(e){const t=[],a=e.split("|");for(const e of a){const a=e.trim();if(a)if("required"===a)t.push({type:"required"});else if(a.startsWith("type:")){const e=a.substring(5).replace(/[()]/g,"").split(",");t.push({type:"type",params:e})}else if(a.startsWith("maxLen:")){const e=parseInt(a.substring(7));isNaN(e)||t.push({type:"maxLength",params:{max:e}})}else if(a.startsWith("minLen:")){const e=parseInt(a.substring(7));isNaN(e)||t.push({type:"minLength",params:{min:e}})}else if(a.startsWith("max:")){const e=parseInt(a.substring(4));isNaN(e)||t.push({type:"max",params:{max:e}})}else if(a.startsWith("min:")){const e=parseInt(a.substring(4));isNaN(e)||t.push({type:"min",params:{min:e}})}else if(a.startsWith("in:")){const e=a.substring(3).split(",");t.push({type:"in",params:{values:e}})}else if(a.startsWith("exists:")){const[e,r]=a.substring(7).split(",");e&&r&&t.push({type:"exists",params:{table:e,field:r}})}else if(a.startsWith("regex:")){const e=a.substring(6).replace(/[{}]/g,"");e&&t.push({type:"regex",params:{regexName:e}})}else if(a.startsWith("default:")){const e=a.substring(8);void 0!==e&&t.push({type:"default",params:{value:e}})}else if(a.startsWith("call:")){const e=a.substring(5).replace(/[{}]/g,"");e&&t.push({type:"call",params:{callbackName:e}})}}return t}addRegex(e,t){return this.customRegex.set(e,new RegExp(t)),this}addCallback(e,t){return this.customCallbacks.set(e,t),this}async validateWithRules(e,t,a={}){const{customRegex:r={},customCallbacks:s={}}=a;for(const[e,t]of Object.entries(r))this.addRegex(e,t);for(const[e,t]of Object.entries(s))this.addCallback(e,t);const i=[],n={};for(const[a,r]of Object.entries(t)){const t=this.parseRuleString(r),s=t.find(e=>"default"===e.type);let l=e[a];(a.includes(".")||a.includes("[]"))&&(l=this.helperUtility.dotParse(a,e));let u=l;!s||null!=l&&""!==l||(u=s.params.value);let o=!0;if(t.some(e=>"required"===e.type)||null!=u&&""!==u){for(const e of t){if("default"===e.type)continue;const t=await this.validateRule(a,u,e);if(!t.isValid){i.push(t.error),o=!1;break}}o&&(n[a]=u)}}if(i.length>0){const e=new Error("Validation failed"),t={name:"ValidationError",errors:i,details:i.map(e=>({field:e.field,message:e.message,value:e.value,rule:e.rule}))};throw e.message=t,e}return n}async validateRule(e,t,a){try{let r=!0,s="";switch(a.type){case"required":r=null!=t&&""!==t,s=r?"":`${e} is required`;break;case"type":const i=a.params;r=i.some(e=>{switch(e){case"string":return"string"==typeof t;case"number":return"number"==typeof t&&!isNaN(t);case"boolean":return"boolean"==typeof t;case"array":return Array.isArray(t);case"object":return"object"==typeof t&&null!==t&&!Array.isArray(t);case"longText":return"string"==typeof t&&t.length>255;default:return!1}}),s=r?"":`${e} must be one of: ${i.join(", ")}`;break;case"maxLength":"string"==typeof t?(r=String(t).length<=a.params.max,s=r?"":`${e} must be at most ${a.params.max} characters long`):Array.isArray(t)&&(r=t.length<=a.params.max,s=r?"":`${e} must be at most ${a.params.max} items`);break;case"minLength":"string"==typeof t?(r=String(t).length>=a.params.min,s=r?"":`${e} must be at least ${a.params.min} characters long`):Array.isArray(t)&&(r=t.length>=a.params.min,s=r?"":`${e} must be at least ${a.params.min} items`);break;case"max":r=Number(t)<=a.params.max,s=r?"":`${e} must be at most ${a.params.max}`;break;case"min":r=Number(t)>=a.params.min,s=r?"":`${e} must be at least ${a.params.min}`;break;case"in":try{if(a.params.values){const i=a.params.values;r=i.includes(t),s=r?"":`${e} must be one of: ${i.join(", ")}`}else{const{table:i,field:n}=a.params,l=await processRequest({model:i,action:"get",request:{where:{[n]:t},limit:1}});r=l&&l.data&&l.data.length>0,s=r?"":`${e} must be a valid value from ${i}`}}catch(t){r=!1,s=`${e} database validation error: ${t.message}`}break;case"exists":try{const{table:i,field:n}=a.params,l=await processRequest({model:i,action:"get",request:{where:{[n]:t},limit:1}});r=l&&l.data&&l.data.length>0,s=r?"":`${e} must exist in ${i}`}catch(t){r=!1,s=`${e} database validation error: ${t.message}`}break;case"regex":const n=a.params.regexName,l=this.customRegex.get(n);l?(r=l.test(t),s=r?"":`${e} format is invalid`):(r=!1,s=`${e} regex pattern '${n}' not found`);break;case"call":const u=a.params.callbackName,o=this.customCallbacks.get(u);if(o&&"function"==typeof o)try{r=o(t),s=r?"":`${e} validation failed`}catch(t){r=!1,s=`${e} validation error: ${t.message}`}else r=!1,s=`${e} callback function '${u}' not found`;break;default:r=!1,s=`${e} unknown validation rule: ${a.type}`}return{isValid:r,error:r?null:new ValidationError(s,e,t,a.type)}}catch(r){return{isValid:!1,error:new ValidationError(`${e} validation failed`,e,t,a.type)}}}}async function validate(e,t,a={}){const r=new RequestValidator;let s={};return!e||"object"!=typeof e||e.body||e.params||e.query?(e.body&&(s={...s,...e.body}),e.params&&(s={...s,...e.params}),e.query&&(s={...s,...e.query})):s=e,await r.validateWithRules(s,t,a)}function validateEmail(e){return/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e)}function validatePassword(e){return/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d@$!%*?&]{8,}$/.test(e)}function validatePhone(e){return/^\+?[\d\s-()]{10,15}$/.test(e)}function validatePAN(e){return/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/.test(e)}function validateAadhaar(e){return/^\d{12}$/.test(e)&&!/^0{12}$/.test(e)}function createValidationMiddleware(e,t={}){return async(a,r,s)=>{try{const i=await validate(a,e,t);if(!1===i.success)return r.status(400).json(i);a.validated=i,s()}catch(e){return r.status(500).json({success:!1,reason:"Validation middleware error",error:e.message})}}}module.exports={RequestValidator:RequestValidator,validate:validate,createValidationMiddleware:createValidationMiddleware,validateEmail:validateEmail,validatePassword:validatePassword,validatePhone:validatePhone,validatePAN:validatePAN,validateAadhaar:validateAadhaar};
|
|
1
|
+
const{processRequest:processRequest}=require("./ControllerWrapper"),HelperUtility=require("./BaseHelperUtility"),KormError=require("./KormError");class ValidationError extends Error{constructor(e,t,a,r){super(e),this.name="ValidationError",this.field=t,this.value=a,this.rule=r,this.timestamp=(new Date).toISOString()}}class RequestValidator{constructor(){this.rules=new Map,this.customMessages=new Map,this.transformers=new Map,this.customRegex=new Map,this.customCallbacks=new Map,this.helperUtility=new HelperUtility}rule(e,t,a=null){return this.rules.has(e)||this.rules.set(e,[]),this.rules.get(e).push(t),a&&this.customMessages.set(`${e}.${t.type}`,a),this}string(e,t=null){return this.rule(e,{type:"string",validator:e=>"string"==typeof e},t)}number(e,t=null){return this.rule(e,{type:"number",validator:e=>"number"==typeof e&&!isNaN(e)},t)}boolean(e,t=null){return this.rule(e,{type:"boolean",validator:e=>"boolean"==typeof e},t)}required(e,t=null){return this.rule(e,{type:"required",validator:e=>null!=e&&""!==e},t)}email(e,t=null){const a=/^[^\s@]+@[^\s@]+\.[^\s@]+$/;return this.rule(e,{type:"email",validator:e=>a.test(e)},t)}url(e,t=null){return this.rule(e,{type:"url",validator:e=>{try{return new URL(e),!0}catch{return!1}}},t)}minLength(e,t,a=null){return this.rule(e,{type:"minLength",validator:e=>String(e).length>=t,params:{min:t}},a)}maxLength(e,t,a=null){return this.rule(e,{type:"maxLength",validator:e=>String(e).length<=t,params:{max:t}},a)}min(e,t,a=null){return this.rule(e,{type:"min",validator:e=>Number(e)>=t,params:{min:t}},a)}max(e,t,a=null){return this.rule(e,{type:"max",validator:e=>Number(e)<=t,params:{max:t}},a)}enum(e,t,a=null){return this.rule(e,{type:"enum",validator:e=>t.includes(e),params:{allowedValues:t}},a)}regex(e,t,a=null){return this.rule(e,{type:"regex",validator:e=>t.test(e),params:{pattern:t}},a)}uuid(e,t=null){const a=/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;return this.rule(e,{type:"uuid",validator:e=>a.test(e)},t)}date(e,t=null){return this.rule(e,{type:"date",validator:e=>!isNaN(Date.parse(e))},t)}array(e,t=null){return this.rule(e,{type:"array",validator:e=>Array.isArray(e)},t)}object(e,t=null){return this.rule(e,{type:"object",validator:e=>"object"==typeof e&&null!==e&&!Array.isArray(e)},t)}custom(e,t,a=null){return this.rule(e,{type:"custom",validator:t},a)}transform(e,t){return this.transformers.set(e,t),this}message(e,t,a){return this.customMessages.set(`${e}.${t}`,a),this}getDefaultMessage(e,t,a,r={}){return{required:`${e} is required`,string:`${e} must be a string`,number:`${e} must be a number`,boolean:`${e} must be a boolean`,email:`${e} must be a valid email address`,url:`${e} must be a valid URL`,minLength:`${e} must be at least ${r.min} characters long`,maxLength:`${e} must be at most ${r.max} characters long`,min:`${e} must be at least ${r.min}`,max:`${e} must be at most ${r.max}`,enum:`${e} must be one of: ${r.allowedValues?.join(", ")}`,regex:`${e} format is invalid`,uuid:`${e} must be a valid UUID`,date:`${e} must be a valid date`,array:`${e} must be an array`,object:`${e} must be an object`,custom:`${e} validation failed`}[t]||`${e} validation failed`}validateField(e,t){const a=this.rules.get(e)||[],r=[];let s=t;this.transformers.has(e)&&(s=this.transformers.get(e)(t));for(const t of a)try{if(!t.validator(s)){const a=this.customMessages.get(`${e}.${t.type}`)||this.getDefaultMessage(e,t.type,s,t.params);r.push(new ValidationError(a,e,s,t.type))}}catch(a){const i=this.customMessages.get(`${e}.${t.type}`)||this.getDefaultMessage(e,t.type,s,t.params);r.push(new ValidationError(i,e,s,t.type))}return{value:s,errors:r}}validate(e,t={}){const{source:a="body"}=t,r=[],s={};for(const[t,a]of this.rules){const a=e[t],{value:i,errors:n}=this.validateField(t,a);n.length>0?r.push(...n):s[t]=i}if(r.length>0)throw KormError.validationFailed({errors:r,source:a});return s}validateParams(e){return this.validate(e,{source:"params"})}validateBody(e){return this.validate(e,{source:"body"})}validateQuery(e){return this.validate(e,{source:"query"})}validateRequest(e){const t={params:{},body:{},query:{}};try{e.params&&(t.params=this.validateParams(e.params))}catch(e){t.params={error:e}}try{e.body&&(t.body=this.validateBody(e.body))}catch(e){t.body={error:e}}try{e.query&&(t.query=this.validateQuery(e.query))}catch(e){t.query={error:e}}return t}static create(){return new RequestValidator}static schema(e){const t=new RequestValidator;for(const[a,r]of Object.entries(e))Array.isArray(r)?r.forEach(e=>{"string"==typeof e?t[e](a):"object"==typeof e&&t.rule(a,e)}):"string"==typeof r?t[r](a):"object"==typeof r&&t.rule(a,r);return t}parseRuleString(e){const t=[],a=e.split("|");for(const e of a){const a=e.trim();if(a)if("required"===a)t.push({type:"required"});else if(a.startsWith("type:")){const e=a.substring(5).replace(/[()]/g,"").split(",");t.push({type:"type",params:e})}else if(a.startsWith("maxLen:")){const e=parseInt(a.substring(7));isNaN(e)||t.push({type:"maxLength",params:{max:e}})}else if(a.startsWith("minLen:")){const e=parseInt(a.substring(7));isNaN(e)||t.push({type:"minLength",params:{min:e}})}else if(a.startsWith("max:")){const e=parseInt(a.substring(4));isNaN(e)||t.push({type:"max",params:{max:e}})}else if(a.startsWith("min:")){const e=parseInt(a.substring(4));isNaN(e)||t.push({type:"min",params:{min:e}})}else if(a.startsWith("in:")){const e=a.substring(3).split(",");t.push({type:"in",params:{values:e}})}else if(a.startsWith("exists:")){const[e,r]=a.substring(7).split(",");e&&r&&t.push({type:"exists",params:{table:e,field:r}})}else if(a.startsWith("regex:")){const e=a.substring(6).replace(/[{}]/g,"");e&&t.push({type:"regex",params:{regexName:e}})}else if(a.startsWith("default:")){const e=a.substring(8);void 0!==e&&t.push({type:"default",params:{value:e}})}else if(a.startsWith("call:")){const e=a.substring(5).replace(/[{}]/g,"");e&&t.push({type:"call",params:{callbackName:e}})}}return t}addRegex(e,t){return this.customRegex.set(e,new RegExp(t)),this}addCallback(e,t){return this.customCallbacks.set(e,t),this}async validateWithRules(e,t,a={}){const{customRegex:r={},customCallbacks:s={}}=a;for(const[e,t]of Object.entries(r))this.addRegex(e,t);for(const[e,t]of Object.entries(s))this.addCallback(e,t);const i=[],n={};for(const[a,r]of Object.entries(t)){const t=this.parseRuleString(r),s=t.find(e=>"default"===e.type);let l=e[a];(a.includes(".")||a.includes("[]"))&&(l=this.helperUtility.dotParse(a,e));let u=l;!s||null!=l&&""!==l||(u=s.params.value);let o=!0;if(t.some(e=>"required"===e.type)||null!=u&&""!==u){for(const e of t){if("default"===e.type)continue;const t=await this.validateRule(a,u,e);if(!t.isValid){i.push(t.error),o=!1;break}}o&&(n[a]=u)}}if(i.length>0)throw KormError.validationFailed({errors:i});return n}async validateRule(e,t,a){try{let r=!0,s="";switch(a.type){case"required":r=null!=t&&""!==t,s=r?"":`${e} is required`;break;case"type":const i=a.params;r=i.some(e=>{switch(e){case"string":return"string"==typeof t;case"number":return"number"==typeof t&&!isNaN(t);case"boolean":return"boolean"==typeof t;case"array":return Array.isArray(t);case"object":return"object"==typeof t&&null!==t&&!Array.isArray(t);case"longText":return"string"==typeof t&&t.length>255;default:return!1}}),s=r?"":`${e} must be one of: ${i.join(", ")}`;break;case"maxLength":"string"==typeof t?(r=String(t).length<=a.params.max,s=r?"":`${e} must be at most ${a.params.max} characters long`):Array.isArray(t)&&(r=t.length<=a.params.max,s=r?"":`${e} must be at most ${a.params.max} items`);break;case"minLength":"string"==typeof t?(r=String(t).length>=a.params.min,s=r?"":`${e} must be at least ${a.params.min} characters long`):Array.isArray(t)&&(r=t.length>=a.params.min,s=r?"":`${e} must be at least ${a.params.min} items`);break;case"max":r=Number(t)<=a.params.max,s=r?"":`${e} must be at most ${a.params.max}`;break;case"min":r=Number(t)>=a.params.min,s=r?"":`${e} must be at least ${a.params.min}`;break;case"in":try{if(a.params.values){const i=a.params.values;r=i.includes(t),s=r?"":`${e} must be one of: ${i.join(", ")}`}else{const{table:i,field:n}=a.params,l=await processRequest({model:i,action:"get",request:{where:{[n]:t},limit:1}});r=l&&l.data&&l.data.length>0,s=r?"":`${e} must be a valid value from ${i}`}}catch(t){r=!1,s=`${e} database validation error: ${t.message}`}break;case"exists":try{const{table:i,field:n}=a.params,l=await processRequest({model:i,action:"get",request:{where:{[n]:t},limit:1}});r=l&&l.data&&l.data.length>0,s=r?"":`${e} must exist in ${i}`}catch(t){r=!1,s=`${e} database validation error: ${t.message}`}break;case"regex":const n=a.params.regexName,l=this.customRegex.get(n);l?(r=l.test(t),s=r?"":`${e} format is invalid`):(r=!1,s=`${e} regex pattern '${n}' not found`);break;case"call":const u=a.params.callbackName,o=this.customCallbacks.get(u);if(o&&"function"==typeof o)try{r=o(t),s=r?"":`${e} validation failed`}catch(t){r=!1,s=`${e} validation error: ${t.message}`}else r=!1,s=`${e} callback function '${u}' not found`;break;default:r=!1,s=`${e} unknown validation rule: ${a.type}`}return{isValid:r,error:r?null:new ValidationError(s,e,t,a.type)}}catch(r){return{isValid:!1,error:new ValidationError(`${e} validation failed`,e,t,a.type)}}}}async function validate(e,t,a={}){const r=new RequestValidator;let s={};return!e||"object"!=typeof e||e.body||e.params||e.query?(e.body&&(s={...s,...e.body}),e.params&&(s={...s,...e.params}),e.query&&(s={...s,...e.query})):s=e,await r.validateWithRules(s,t,a)}function validateEmail(e){return/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e)}function validatePassword(e){return/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d@$!%*?&]{8,}$/.test(e)}function validatePhone(e){return/^\+?[\d\s-()]{10,15}$/.test(e)}function validatePAN(e){return/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/.test(e)}function validateAadhaar(e){return/^\d{12}$/.test(e)&&!/^0{12}$/.test(e)}function createValidationMiddleware(e,t={}){return async(a,r,s)=>{try{const i=await validate(a,e,t);if(!1===i.success)return r.status(400).json(i);a.validated=i,s()}catch(e){return r.status(500).json({success:!1,reason:"Validation middleware error",error:e.message})}}}module.exports={RequestValidator:RequestValidator,validate:validate,createValidationMiddleware:createValidationMiddleware,validateEmail:validateEmail,validatePassword:validatePassword,validatePhone:validatePhone,validatePAN:validatePAN,validateAadhaar:validateAadhaar};
|