@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.
Files changed (44) hide show
  1. package/AuthorizationService.js +1 -0
  2. package/BaseHelperUtility.js +1 -1
  3. package/ControllerWrapper.js +1 -1
  4. package/KormError.js +1 -0
  5. package/README.md +331 -42
  6. package/RequestValidator.js +1 -1
  7. package/ai-skills/korm-js.md +268 -0
  8. package/bin/korm-mcp.js +2 -0
  9. package/build.js +1 -1
  10. package/cli.js +1 -1
  11. package/clients/BaseSyncTable.js +1 -0
  12. package/clients/mysql/BaseUtility.js +1 -1
  13. package/clients/mysql/CurdTable.js +1 -1
  14. package/clients/mysql/DataTypeMap.js +1 -1
  15. package/clients/mysql/HookService.js +1 -1
  16. package/clients/mysql/QueryBuilder.js +1 -1
  17. package/clients/mysql/QueryService.js +1 -1
  18. package/clients/mysql/SyncTable.js +1 -1
  19. package/clients/pg/BaseUtility.js +1 -1
  20. package/clients/pg/CurdTable.js +1 -1
  21. package/clients/pg/DataTypeMap.js +1 -1
  22. package/clients/pg/HookService.js +1 -1
  23. package/clients/pg/QueryBuilder.js +1 -1
  24. package/clients/pg/QueryService.js +1 -1
  25. package/clients/pg/SyncTable.js +1 -1
  26. package/clients/sqlite/BaseUtility.js +1 -1
  27. package/clients/sqlite/CurdTable.js +1 -1
  28. package/clients/sqlite/HookService.js +1 -1
  29. package/clients/sqlite/QueryBuilder.js +1 -1
  30. package/clients/sqlite/QueryService.js +1 -1
  31. package/clients/sqlite/SyncTable.js +1 -1
  32. package/columnSchema.js +1 -0
  33. package/index.d.ts +424 -0
  34. package/index.js +1 -1
  35. package/jest.config.engine.js +1 -0
  36. package/jest.config.js +1 -1
  37. package/package.json +7 -2
  38. package/requestSchema.js +1 -0
  39. package/schemaDescribe.js +1 -0
  40. package/src/mcp/errors.js +1 -0
  41. package/src/mcp/schemaIntrospect.js +1 -0
  42. package/src/mcp/server.js +1 -0
  43. package/src/mcp/toolGenerator.js +1 -0
  44. 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;
@@ -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 r=e.match(/^(><|<>)(.+)$/);if(r){const[,e,t]=r,s=t.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 t=e.match(/^(>=|<=|<|>)(.+)$/);if(t){const[,e,r]=t;return{operator:e,value:this.parseValue(r)}}const s=e.match(/^(!?)\[\]?(.+)$/);if(s){const[,e,r]=s;return{operator:e?"notIn":"in",value:r.split(",").map(e=>this.parseValue(e.trim()))}}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;
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;
@@ -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:t,dbClient:e,schema:s,resolverPath:n=null,debug:a=!1}){this.db=t,this.dbClient=e,this.schema=s,this.resolverPath=n,this.debug=a;const r=dbClientMapper[e];if(!r)throw new Error(`Database client ${e} not found`);const i=InstanceMapper[r];if(!i)throw new Error(`Database client ${e} not found`);return this.dbClientClass=i,this.dbInstance=new i(this),this}static setSchema(t){this.schema=t;const e=this.dbClientClass;if(!e)throw new Error(`Database client ${this.dbClient} not found`);return this.dbInstance=new e(this),this}static async processRequest(t,e=null,s=null){return await this.dbInstance.processRequest(t,e,s)}static async processRequestWithOthers(t,e=null,s=null){return await this.dbInstance.processRequest(t,e,s)}static async syncDatabase(){return await this.dbInstance.syncDatabase()}static async generateSchema(){return await this.dbInstance.generateSchema()}static loadModelClass(t){return this.dbInstance.hookService.loadModelClass(t)}static getModelInstance(t){return this.dbInstance.hookService.getModelInstance(t)}}module.exports=ControllerWrapper;
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
  [![npm version](https://badge.fury.io/js/@dreamtree-org%2Fkorm-js.svg)](https://badge.fury.io/js/@dreamtree-org%2Fkorm-js)
6
6
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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` &nbsp;•&nbsp; **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
+ [![Pay via UPI](https://img.shields.io/badge/Pay-via%20UPI-22c55e?style=for-the-badge&logo=googlepay&logoColor=white)](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 (MySQL specific - replaces entire row)
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` | Unsigned integer | `int | 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` | On update value | `timestamp | onUpdate:CURRENT_TIMESTAMP` |
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` | Foreign key | `int | foreignKey:users:id` |
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 record (MySQL) | `data` |
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
- // Global error handler
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 { model } = req.params;
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
- // Handle validation errors
2038
- if (error.name === 'ValidationError') {
2039
- return res.status(400).json({
2040
- success: false,
2041
- message: 'Validation failed',
2042
- errors: error.message,
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
- If you have any questions or need help, please open an issue on GitHub or contact us at [partha.preetham.krishna@gmail.com](mailto:partha-preetham.krishna@gmail.com).
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/)
@@ -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};