@dreamtree-org/korm-js 1.1.3 → 1.2.1

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.
@@ -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};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}resolveDefaultOrderBy(e){const r=e&&Array.isArray(e.columns)?e.columns:[],t=r.find(e=>e&&e.primary&&e.name);if(t)return{column:t.name,direction:"asc"};return r.some(e=>e&&"id"===e.name)||0===r.length?{column:"id",direction:"asc"}:null}}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){if(!isNaN(e)&&""!==e){const r=Number(e);if(String(r)===e)return r}return"true"===e.toLowerCase()||"false"!==e.toLowerCase()&&("null"===e.toLowerCase()?null: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}resolveDefaultOrderBy(e){const r=e&&Array.isArray(e.columns)?e.columns:[],t=r.find(e=>e&&e.primary&&e.name);if(t)return{column:t.name,direction:"asc"};return r.some(e=>e&&"id"===e.name)||0===r.length?{column:"id",direction:"asc"}:null}}module.exports=BaseHelperUtility;
@@ -0,0 +1 @@
1
+ const TRANSIENT_CODES=new Set(["PROTOCOL_CONNECTION_LOST","ER_CON_COUNT_ERROR","ECONNRESET","EPIPE","ETIMEDOUT","08000","08003","08006","57P01","57P03","SQLITE_BUSY"]),TRANSIENT_PATTERNS=[/connection lost/i,/connection terminated/i,/server closed the connection/i,/client has encountered a connection error/i,/read econnreset/i,/database is locked/i],READ_ACTIONS=new Set(["list","show","count","sum"]),DEFAULT_RETRY=Object.freeze({attempts:2,backoffMs:100,writes:!1}),DEFAULT_FAN_OUT_CONCURRENCY=5;function isTransientConnectionError(e){if(!e)return!1;const t=e.code||e.original&&e.original.code;if(t&&TRANSIENT_CODES.has(String(t)))return!0;const n=String(e.message||"");return TRANSIENT_PATTERNS.some(e=>e.test(n))}function collectActions(e,t=[],n=0){if(!e||"object"!=typeof e||n>10)return t;t.push(String(e.action||"list").toLowerCase());const o=e.other_requests;if(!o||"object"!=typeof o)return t;for(const e of Object.values(o)){const o=Array.isArray(e)?e:[e];for(const e of o)collectActions(e,t,n+1)}return t}function isReplaySafe(e,t){return!(!e||!e.dryRun)||(!!t||collectActions(e).every(e=>READ_ACTIONS.has(e)))}function normalizeRetryOptions(e){if(!1===e||null===e)return null;if(void 0===e||!0===e)return{...DEFAULT_RETRY};if("object"!=typeof e)return{...DEFAULT_RETRY};const t=Number.isInteger(e.attempts)?e.attempts:DEFAULT_RETRY.attempts;return t<1?null:{attempts:t,backoffMs:Number.isFinite(e.backoffMs)?e.backoffMs:DEFAULT_RETRY.backoffMs,writes:!0===e.writes}}function sleep(e){return!e||e<=0?Promise.resolve():new Promise(t=>setTimeout(t,e))}async function withRetry(e,t={}){const n=Number.isInteger(t.attempts)?Math.max(1,t.attempts):1,o=Number.isFinite(t.backoffMs)?t.backoffMs:0;let r;for(let i=1;i<=n;i++)try{return await e()}catch(e){if(r=e,i>=n||!isTransientConnectionError(e))throw e;"function"==typeof t.onRetry&&t.onRetry(e,i),await sleep(o*i)}throw r}function getPoolConfig(e){try{const t=e&&e.client;if(!t)return null;const n=t.pool,o=t.config&&t.config.pool||{},r=e=>n&&void 0!==n[e]?n[e]:o[e];return{min:r("min"),max:r("max"),idleTimeoutMillis:r("idleTimeoutMillis")}}catch(e){return null}}function getDialect(e){try{const t=e&&e.client;return String(t&&(t.dialect||t.driverName)||"")}catch(e){return""}}const MAX_SAFE_IDLE_MS=6e5,HIGH_POOL_MAX=20;function inspectPoolConfig(e){const t=getDialect(e);if(!t||t.includes("sqlite"))return[];const n=getPoolConfig(e);if(!n)return[];const o=[];return"number"==typeof n.min&&n.min>0&&o.push(`pool.min is ${n.min}: those connections are never reaped and will sit idle until the server closes them, after which the next query gets a dead socket. Set pool.min to 0.`),"number"==typeof n.idleTimeoutMillis&&n.idleTimeoutMillis>6e5&&o.push(`pool.idleTimeoutMillis is ${n.idleTimeoutMillis}ms: keep it well below the server's idle timeout (MySQL wait_timeout) so korm reaps a socket before the server does.`),"number"==typeof n.max&&n.max>20&&o.push(`pool.max is ${n.max}: total connections are (processes x pool.max) and must stay under the server's max_connections.`),o}function recommendedPoolConfig(e=""){return String(e).includes("sqlite")?{pool:{min:0,max:1,idleTimeoutMillis:3e4}}:{pool:{min:0,max:10,idleTimeoutMillis:3e4,reapIntervalMillis:1e3,acquireTimeoutMillis:3e4,createTimeoutMillis:3e4,propagateCreateError:!1}}}function resolveFanOutConcurrency(e,t){const n=Number.isInteger(t)&&t>0?t:5,o=getPoolConfig(e),r=o&&"number"==typeof o.max?o.max:null;return!r||r<1?n:Math.max(1,Math.min(n,r-1))}async function mapWithConcurrency(e,t,n){const o=Array.from(e),r=Math.max(1,Number.isInteger(t)?t:1),i=new Array(o.length);let s=0;const c=Array.from({length:Math.min(r,o.length)},async()=>{for(;s<o.length;){const e=s++;i[e]=await n(o[e],e)}});return await Promise.all(c),i}async function pingConnection(e){if(!e||"function"!=typeof e.raw)return{ok:!1,error:"db not present or not a Knex instance"};try{return await e.raw("SELECT 1"),{ok:!0}}catch(e){return{ok:!1,error:e.message}}}async function destroyConnection(e){if(!e||"function"!=typeof e.destroy)return{ok:!1,error:"db has no destroy()"};try{return await e.destroy(),{ok:!0}}catch(e){return{ok:!1,error:e.message}}}module.exports={isTransientConnectionError:isTransientConnectionError,isReplaySafe:isReplaySafe,collectActions:collectActions,normalizeRetryOptions:normalizeRetryOptions,withRetry:withRetry,getPoolConfig:getPoolConfig,getDialect:getDialect,inspectPoolConfig:inspectPoolConfig,recommendedPoolConfig:recommendedPoolConfig,resolveFanOutConcurrency:resolveFanOutConcurrency,mapWithConcurrency:mapWithConcurrency,pingConnection:pingConnection,destroyConnection:destroyConnection,READ_ACTIONS:READ_ACTIONS,DEFAULT_RETRY:DEFAULT_RETRY,DEFAULT_FAN_OUT_CONCURRENCY:5};
@@ -1 +1 @@
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{db=null;dbClient=null;dbClientClass=null;schema=null;resolverPath=null;dbInstance=null;debug=!1;_authz=new AuthorizationService;requestInstance=null;constructor({db:e,dbClient:t,schema:s,resolverPath:r=null,debug:n=!1}={}){this.requestInstance={},this.db=e,this.dbClient=t,this.schema=s,this.resolverPath=r,this.debug=n;const i=dbClientMapper[t];if(!i)throw new Error(`Database client ${t} not found`);const l=InstanceMapper[i];if(!l)throw new Error(`Database client ${t} not found`);this.dbClientClass=l,this.dbInstance=new l(this)}static initializeKORM(e){return new ControllerWrapper(e)}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}_resolveModelName(e){const t=this.schema||{};if(t[e])return e;return Object.keys(t).find(s=>t[s]&&t[s].table===e)||e}authorize(e,t,s){return this._authz.registerAuthorize(this._resolveModelName(e),t,s),this}scope(e,t){return this._authz.registerScope(this._resolveModelName(e),t),this}resetAuthorization(){return this._authz.reset(),this}async processRequest(e,t=null,s=null){let r=e;if(this._authz.hasRules()&&t){const n=this._resolveModelName(t),i=e&&e.action||"list";this._authz.enforce(n,i,e,s),r=this._authz.applyScope(n,i,e||{},s)}return await this.dbInstance.processRequest(r,t,s)}async processRequestWithOthers(e,t=null,s=null){return await this.dbInstance.processRequest(e,t,s)}async syncDatabase(e={}){return await this.dbInstance.syncDatabase(e)}async generateSchema(){return await this.dbInstance.generateSchema()}loadModelClass(e){return this.dbInstance.hookService.loadModelClass(e)}getModelInstance(e){return this.dbInstance.hookService.getModelInstance(e)}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}>`})}_modelHasSoftDelete(e){try{const t=this.dbInstance?.hookService?.getModelInstance?.(e);return!(!t||!0!==t.hasSoftDelete)}catch{return!1}}describeModel(e,t=null){const s=this.schema||{},r=Object.entries(s).find(([t,s])=>t===e||s&&s.table===e);if(!r)throw KormError.unknownModel({model:e,available:Object.keys(s)});const[n,i]=r,l=buildModelDescription(n,i,{softDelete:this._modelHasSoftDelete(n)});return null!=t&&this._authz.hasRules()&&(l.actions=this._authz.availableActions(n,l.actions,t)),l}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;
1
+ const fs=require("fs"),https=require("https"),http=require("http"),path=require("path"),{pathToFileURL:pathToFileURL}=require("url"),mysqlWrapper=require("./clients/mysql"),sqliteWrapper=require("./clients/sqlite"),pgWrapper=require("./clients/pg"),KormError=require("./KormError"),logger=require("./Logger"),AuthorizationService=require("./AuthorizationService"),{normalizeRetryOptions:normalizeRetryOptions,withRetry:withRetry,isReplaySafe:isReplaySafe,inspectPoolConfig:inspectPoolConfig,resolveFanOutConcurrency:resolveFanOutConcurrency,pingConnection:pingConnection,destroyConnection:destroyConnection}=require("./ConnectionResilience"),{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"};let auditedPools=new WeakSet,distinctPoolCount=0,poolLeakWarned=!1;const DISTINCT_POOL_WARN_THRESHOLD=5;class ControllerWrapper{db=null;dbClient=null;dbClientClass=null;schema=null;resolverPath=null;dbInstance=null;debug=!1;_authz=new AuthorizationService;requestInstance=null;_retry=null;_destroyed=!1;fanOutConcurrency=null;constructor({db:e,dbClient:t,schema:r,resolverPath:s=null,debug:n=!1,retry:o,fanOutConcurrency:i}={}){this.requestInstance={},this.db=e,this.dbClient=t,this.schema=r,this.resolverPath=s,this.debug=n;const a=dbClientMapper[t];if(!a)throw new Error(`Database client ${t} not found`);const l=InstanceMapper[a];if(!l)throw new Error(`Database client ${t} not found`);this.dbClientClass=l,this._retry=normalizeRetryOptions(o),this.fanOutConcurrency=resolveFanOutConcurrency(e,i),this.dbInstance=new l(this),ControllerWrapper._auditConnectionSetup(e)}static _auditConnectionSetup(e){if(e&&"object"==typeof e&&!auditedPools.has(e)){auditedPools.add(e);for(const t of inspectPoolConfig(e))logger.warn(`connection pool: ${t}`);distinctPoolCount+=1,distinctPoolCount>5&&!poolLeakWarned&&(poolLeakWarned=!0,logger.warn(`${distinctPoolCount} distinct Knex instances have been passed to initializeKORM() in this process — each one is a separate connection pool. Build the Knex instance once at module scope and reuse it; creating it per request leaks a pool per request. See the README section "Connection management & pooling".`))}}static _resetConnectionWarnings(){auditedPools=new WeakSet,distinctPoolCount=0,poolLeakWarned=!1}static _fetchSchemaFromUrl(e){return new Promise((t,r)=>{const s=e.startsWith("https")?https:http,n=t=>r(new KormError(t,{code:KormError.CODES.INTERNAL,context:{schemaSource:e}}));s.get(e,s=>{if(s.statusCode<200||s.statusCode>=300)return void r(new KormError(`Schema URL returned ${s.statusCode}: ${e}`,{code:KormError.CODES.INTERNAL,context:{schemaSource:e,statusCode:s.statusCode}}));const o=[];s.on("data",e=>o.push(e)),s.on("end",()=>{try{t(JSON.parse(Buffer.concat(o).toString("utf8")))}catch(t){n(`Schema URL returned invalid JSON: ${e}`)}})}).on("error",t=>n(`Schema URL fetch failed (${t.message}): ${e}`))})}static async _loadSchema(e){if("string"!=typeof e)return e;if(/^https?:\/\//i.test(e))return ControllerWrapper._fetchSchemaFromUrl(e);const t=path.extname(e).toLowerCase();if(".js"===t)return require(path.resolve(e));if(".mjs"===t)return import(pathToFileURL(path.resolve(e)).href);const r=fs.readFileSync(path.resolve(e),"utf8");return JSON.parse(r)}static async initializeKORM(e){const t={...e};return"string"==typeof e.schema&&(t.schema=await ControllerWrapper._loadSchema(e.schema)),new ControllerWrapper(t)}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}_resolveModelName(e){const t=this.schema||{};if(t[e])return e;return Object.keys(t).find(r=>t[r]&&t[r].table===e)||e}authorize(e,t,r){return this._authz.registerAuthorize(this._resolveModelName(e),t,r),this}scope(e,t){return this._authz.registerScope(this._resolveModelName(e),t),this}resetAuthorization(){return this._authz.reset(),this}async processRequest(e,t=null,r=null){if(this._destroyed)throw new KormError("KORM instance has been destroyed; its connection pool is closed.",{code:KormError.CODES.INTERNAL});let s=e;if(this._authz.hasRules()&&t){const n=this._resolveModelName(t),o=e&&e.action||"list";this._authz.enforce(n,o,e,r),s=this._authz.applyScope(n,o,e||{},r)}const n=()=>this.dbInstance.processRequest(s,t,r);return this._retry&&isReplaySafe(s,this._retry.writes)?await withRetry(n,{attempts:this._retry.attempts,backoffMs:this._retry.backoffMs,onRetry:(e,t)=>logger.warn(`transient connection error on attempt ${t} (${e.message}) — retrying`)}):await n()}async processRequestWithOthers(e,t=null,r=null){return await this.processRequest(e,t,r)}async ping(){return await pingConnection(this.db)}async destroy(){return this._destroyed?{ok:!0}:(this._destroyed=!0,await destroyConnection(this.db))}get isDestroyed(){return this._destroyed}async syncDatabase(e={}){return await this.dbInstance.syncDatabase(e)}async generateSchema(){return await this.dbInstance.generateSchema()}loadModelClass(e){return this.dbInstance.hookService.loadModelClass(e)}getModelInstance(e){return this.dbInstance.hookService.getModelInstance(e)}getRequestJsonSchema(e){const t=this.schema||{},r=t[e]||Object.values(t).find(t=>t&&t.table===e);if(!r)throw KormError.unknownModel({model:e,available:Object.keys(t)});return buildModelRequestSchema(r,{title:`KormRequest<${e}>`})}_modelHasSoftDelete(e){try{const t=this.dbInstance?.hookService?.getModelInstance?.(e);return!(!t||!0!==t.hasSoftDelete)}catch{return!1}}describeModel(e,t=null){const r=this.schema||{},s=Object.entries(r).find(([t,r])=>t===e||r&&r.table===e);if(!s)throw KormError.unknownModel({model:e,available:Object.keys(r)});const[n,o]=s,i=buildModelDescription(n,o,{softDelete:this._modelHasSoftDelete(n)});return null!=t&&this._authz.hasRules()&&(i.actions=this._authz.availableActions(n,i.actions,t)),i}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/Emitter.js CHANGED
@@ -1 +1 @@
1
- class Emitter{constructor(){this.events={}}on(e,t){this.events[e]||(this.events[e]=[]),this.events[e].push({listener:t,once:!1})}onOnce(e,t){this.events[e]||(this.events[e]=[]),this.events[e].push({listener:t,once:!0})}offAll(e){this.events[e]&&(this.events[e]=[])}onSchedule(e,t,s){this.events[e]||(this.events[e]=[]),this.events[e].push({listener:t,schedule:s})}emit(e,...t){return new Promise((s,n)=>{this.executeEvent(e,{resolve:s,reject:n},...t)})}executeEvent(e,t={},...s){try{if(!this.events[e])return;this.events[e].forEach(n=>{const{listener:i,once:h,schedule:r}=n,o=()=>{const e=i(...s);t.resolve(e)};if(r){const e=new Date;let t,s=-1;r instanceof Date?(t=r,s=t-e):r instanceof Number?s=r:r instanceof String&&(t=new Date(r),s=t-e),s>0?setTimeout(()=>{o()},s):o()}else o();h&&this.off(e,i)})}catch(e){t.reject(e)}}off(e,t){this.events[e]&&(this.events[e]=this.events[e].filter(e=>{const{listener:s}=e;return s!==t}))}}module.exports=Emitter;
1
+ class Emitter{constructor(){this.events={}}on(e,t){this.events[e]||(this.events[e]=[]),this.events[e].push({listener:t,once:!1})}onOnce(e,t){this.events[e]||(this.events[e]=[]),this.events[e].push({listener:t,once:!0})}offAll(e){this.events[e]&&(this.events[e]=[])}onSchedule(e,t,s){this.events[e]||(this.events[e]=[]),this.events[e].push({listener:t,schedule:s})}emit(e,...t){return new Promise((s,n)=>{this.executeEvent(e,{resolve:s,reject:n},...t)})}executeEvent(e,t={},...s){try{if(!this.events[e])return;this.events[e].forEach(n=>{const{listener:i,once:r,schedule:h}=n,o=()=>{const e=i(...s);t.resolve(e)};if(h){const e=new Date;let t,s=-1;h instanceof Date?(t=h,s=t-e):"number"==typeof h||h instanceof Number?s=h:("string"==typeof h||h instanceof String)&&(t=new Date(h),s=t-e),s>0?setTimeout(()=>{o()},s):o()}else o();r&&this.off(e,i)})}catch(e){t.reject(e)}}off(e,t){this.events[e]&&(this.events[e]=this.events[e].filter(e=>{const{listener:s}=e;return s!==t}))}}module.exports=Emitter;
package/KormError.js CHANGED
@@ -1 +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",SYNC_FK_ORPHAN:"SYNC_FK_ORPHAN",INTERNAL:"INTERNAL"}),ACTIONS=Object.freeze(["count","sum","list","show","create","update","replace","upsert","sync","delete"]);function levenshtein(e,t){const o=e.length,r=t.length;if(0===o)return r;if(0===r)return o;let n=Array.from({length:r+1},(e,t)=>t),i=new Array(r+1);for(let s=1;s<=o;s++){i[0]=s;for(let o=1;o<=r;o++){const r=e[s-1]===t[o-1]?0:1;i[o]=Math.min(n[o]+1,i[o-1]+1,n[o-1]+r)}[n,i]=[i,n]}return n[r]}function closestAction(e,t=ACTIONS){if(!e)return null;const o=String(e).toLowerCase();let r=null,n=1/0;for(const e of t){const t=levenshtein(o,e);t<n&&(n=t,r=e)}return n<=Math.max(2,Math.ceil(o.length/2))?r:null}class KormError extends Error{constructor(e,{code:t=CODES.INTERNAL,hint:o=null,context:r={},suggestedFixes:n=null}={}){super(e),this.name="KormError",this.code=t,this.hint=o,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:t})=>{const o="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 "${t}".`,{code:CODES.NO_MATCHING_ROW,hint:o,context:{action:e,model:t}})},KormError.unknownAction=({action:e,model:t,hasCustomHook:o=!1})=>{const r=closestAction(e),n=o?CODES.NO_CUSTOM_ACTION_HOOK:CODES.UNKNOWN_ACTION,i=o?`No custom action hook found for "${t}.${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:t,validActions:ACTIONS,closest:r}})},KormError.unknownModel=({model:e,available:t=[]})=>{const o=t.length?`Available models: ${t.join(", ")}.`:"No models are registered in the schema.";return new KormError(`Model "${e}" not found.`,{code:CODES.UNKNOWN_MODEL,hint:o,context:{model:e,available:t}})},KormError.forbidden=({model:e,action:t,hint:o=null,context:r={}}={})=>new KormError(`Action "${t}" on model "${e}" is not permitted in this context.`,{code:CODES.FORBIDDEN,hint:o||"A registered authorize() predicate denied this request for the current context.",context:{action:t,model:e,...r}}),KormError.validationFailed=({errors:e=[],source:t=null})=>{const o=e.map(e=>({field:e.field,message:e.message,value:e.value,rule:e.rule})),r=o.map(e=>e.field).filter(Boolean),n=new KormError(`Validation failed${t?` for ${t}`:""}${r.length?`: ${r.join(", ")}`:""}.`,{code:CODES.VALIDATION_FAILED,hint:"Fix the listed fields and resubmit. See context.fields for per-field detail.",context:{source:t,fields:o}});return n.errors=e,n},KormError.foreignKeyOrphans=({table:e,offenders:t=[]})=>{const o=t.map(t=>{const o=`${t.rowsAffected} existing row${1===t.rowsAffected?"":"s"}`,r=JSON.stringify(t.backfillValue);return`"${e}.${t.column}" → "${t.parentTable}.${t.parentColumn}": ${o} would be back-filled with ${r}, which does not exist in "${t.parentTable}"`}).join("; ");return new KormError(`Cannot add foreign key on "${e}": existing rows would be orphaned (${o}).`,{code:CODES.SYNC_FK_ORPHAN,hint:"Adding this foreign-key column to a populated table would leave child rows pointing at a parent that does not exist, so the database would reject the constraint. Resolve the data first: make the column nullable (existing rows back-fill NULL, which is FK-safe), give it a default that exists in the parent, populate the column for existing rows before syncing, insert the missing parent rows, or clear the child table. No data was changed — korm aborted before altering the table.",context:{table:e,offenders:t}})},KormError.CODES=CODES,KormError.ACTIONS=ACTIONS,KormError.closestAction=closestAction,module.exports=KormError;
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",SYNC_FK_ORPHAN:"SYNC_FK_ORPHAN",INTERNAL:"INTERNAL"}),ACTIONS=Object.freeze(["count","sum","list","show","create","update","replace","upsert","sync","delete","restore"]);function levenshtein(e,t){const o=e.length,r=t.length;if(0===o)return r;if(0===r)return o;let n=Array.from({length:r+1},(e,t)=>t),i=new Array(r+1);for(let s=1;s<=o;s++){i[0]=s;for(let o=1;o<=r;o++){const r=e[s-1]===t[o-1]?0:1;i[o]=Math.min(n[o]+1,i[o-1]+1,n[o-1]+r)}[n,i]=[i,n]}return n[r]}function closestAction(e,t=ACTIONS){if(!e)return null;const o=String(e).toLowerCase();let r=null,n=1/0;for(const e of t){const t=levenshtein(o,e);t<n&&(n=t,r=e)}return n<=Math.max(2,Math.ceil(o.length/2))?r:null}class KormError extends Error{constructor(e,{code:t=CODES.INTERNAL,hint:o=null,context:r={},suggestedFixes:n=null}={}){super(e),this.name="KormError",this.code=t,this.hint=o,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:t})=>{const o="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 "${t}".`,{code:CODES.NO_MATCHING_ROW,hint:o,context:{action:e,model:t}})},KormError.unknownAction=({action:e,model:t,hasCustomHook:o=!1})=>{const r=closestAction(e),n=o?CODES.NO_CUSTOM_ACTION_HOOK:CODES.UNKNOWN_ACTION,i=o?`No custom action hook found for "${t}.${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:t,validActions:ACTIONS,closest:r}})},KormError.unknownModel=({model:e,available:t=[]})=>{const o=t.length?`Available models: ${t.join(", ")}.`:"No models are registered in the schema.";return new KormError(`Model "${e}" not found.`,{code:CODES.UNKNOWN_MODEL,hint:o,context:{model:e,available:t}})},KormError.forbidden=({model:e,action:t,hint:o=null,context:r={}}={})=>new KormError(`Action "${t}" on model "${e}" is not permitted in this context.`,{code:CODES.FORBIDDEN,hint:o||"A registered authorize() predicate denied this request for the current context.",context:{action:t,model:e,...r}}),KormError.validationFailed=({errors:e=[],source:t=null})=>{const o=e.map(e=>({field:e.field,message:e.message,value:e.value,rule:e.rule})),r=o.map(e=>e.field).filter(Boolean),n=new KormError(`Validation failed${t?` for ${t}`:""}${r.length?`: ${r.join(", ")}`:""}.`,{code:CODES.VALIDATION_FAILED,hint:"Fix the listed fields and resubmit. See context.fields for per-field detail.",context:{source:t,fields:o}});return n.errors=e,n},KormError.foreignKeyOrphans=({table:e,offenders:t=[]})=>{const o=t.map(t=>{const o=`${t.rowsAffected} existing row${1===t.rowsAffected?"":"s"}`,r=JSON.stringify(t.backfillValue);return`"${e}.${t.column}" → "${t.parentTable}.${t.parentColumn}": ${o} would be back-filled with ${r}, which does not exist in "${t.parentTable}"`}).join("; ");return new KormError(`Cannot add foreign key on "${e}": existing rows would be orphaned (${o}).`,{code:CODES.SYNC_FK_ORPHAN,hint:"Adding this foreign-key column to a populated table would leave child rows pointing at a parent that does not exist, so the database would reject the constraint. Resolve the data first: make the column nullable (existing rows back-fill NULL, which is FK-safe), give it a default that exists in the parent, populate the column for existing rows before syncing, insert the missing parent rows, or clear the child table. No data was changed — korm aborted before altering the table.",context:{table:e,offenders:t}})},KormError.CODES=CODES,KormError.ACTIONS=ACTIONS,KormError.closestAction=closestAction,module.exports=KormError;
package/README.md CHANGED
@@ -108,13 +108,20 @@ Block-insert targets (`CLAUDE.md`, `AGENTS.md`, `GEMINI.md`, `.github/copilot-in
108
108
  ```javascript
109
109
  require('dotenv').config();
110
110
  const express = require('express');
111
- const { initializeKORM, validate, helperUtility } = require('@dreamtree-org/korm-js');
111
+ const {
112
+ initializeKORM,
113
+ validate,
114
+ helperUtility,
115
+ recommendedPoolConfig,
116
+ } = require('@dreamtree-org/korm-js');
112
117
  const knex = require('knex');
113
118
 
114
119
  const app = express();
115
120
  app.use(express.json());
116
121
 
117
- // MySQL database configuration
122
+ // MySQL database configuration.
123
+ // Build this ONCE at module scope — never inside a request handler.
124
+ // See "Connection management & pooling" for why the pool block matters.
118
125
  const db = knex({
119
126
  client: 'mysql2',
120
127
  connection: {
@@ -123,7 +130,10 @@ const db = knex({
123
130
  password: process.env.DB_PASS || 'password',
124
131
  database: process.env.DB_NAME || 'my_database',
125
132
  port: process.env.DB_PORT || 3306,
133
+ enableKeepAlive: true,
134
+ keepAliveInitialDelay: 10000,
126
135
  },
136
+ ...recommendedPoolConfig(),
127
137
  });
128
138
 
129
139
  // Initialize KORM with MySQL
@@ -710,6 +720,7 @@ POST /api/Users/crud
710
720
  | `!=` | Not equal | `!value` | `"status": "!deleted"` |
711
721
  | `like` | Pattern matching (auto) | `%value%` | `"name": "%john%"` |
712
722
  | `in` | Value in list | `[]val1,val2` | `"status": "[]active,pending"` |
723
+ | `in` (bare) | Bare array → IN | `[val1,val2]` | `"id": [1,2,3]` |
713
724
  | `notIn` | Value not in list | `![]val1,val2` | `"role": "![]banned,suspended"` |
714
725
  | `between` | Range (inclusive) | `><min,max` | `"age": "><18,65"` |
715
726
  | `notBetween` | Outside range | `<>min,max` | `"score": "<>0,50"` |
@@ -735,6 +746,11 @@ POST /api/Users/crud
735
746
  // AND role IN ('admin', 'moderator', 'editor') AND score BETWEEN 50 AND 100
736
747
  ```
737
748
 
749
+ All where operators work identically on `list`, `show`, `count`, `sum`,
750
+ `update`, `delete`, `restore`, and `sync` — same grammar everywhere.
751
+ `update` / `delete` / `restore` require a non-empty `where` clause
752
+ (preventing accidental full-table mutations).
753
+
738
754
  ### Sorting (orderBy)
739
755
 
740
756
  ```javascript
@@ -1332,7 +1348,8 @@ POST /api/Users/crud
1332
1348
  "where": { "id": 1 }
1333
1349
  }
1334
1350
 
1335
- // List operation automatically filters out soft-deleted records
1351
+ // List, show, count, and sum operations automatically filter out soft-deleted
1352
+ // records (deleted_at IS NULL)
1336
1353
  POST /api/Users/crud
1337
1354
  {
1338
1355
  "action": "list",
@@ -1340,8 +1357,16 @@ POST /api/Users/crud
1340
1357
  // Automatically adds: deleted_at IS NULL
1341
1358
  }
1342
1359
 
1360
+ // Restore a soft-deleted row back to active state
1361
+ POST /api/Users/crud
1362
+ {
1363
+ "action": "restore",
1364
+ "where": { "id": 1 }
1365
+ // Sets deleted_at to NULL and returns the restored row(s)
1366
+ }
1367
+
1343
1368
  // To see soft-deleted records, you need to query directly
1344
- // (soft delete only affects list and delete operations)
1369
+ // (soft delete affects list, show, count, sum, and delete operations)
1345
1370
  ```
1346
1371
 
1347
1372
  ## Model Hooks
@@ -1605,6 +1630,11 @@ POST /api/Users/crud
1605
1630
  }
1606
1631
  ```
1607
1632
 
1633
+ **Limits & safety:** `other_requests` enforces a maximum nesting depth of 10
1634
+ and a fan-out cap of 50 child requests to prevent request amplification.
1635
+ Nested requests also respect `authorize()` / `scope()` rules registered
1636
+ on the parent KORM instance.
1637
+
1608
1638
  ## Schema Structure
1609
1639
 
1610
1640
  ### Example Schema (Auto-generated from MySQL)
@@ -1753,14 +1783,26 @@ Seed data is automatically inserted when `syncDatabase()` is called and the tabl
1753
1783
  ```javascript
1754
1784
  const { initializeKORM } = require('@dreamtree-org/korm-js');
1755
1785
 
1756
- const korm = initializeKORM({
1786
+ // Schema as an inline object (existing behaviour):
1787
+ const korm = await initializeKORM({
1757
1788
  db: db, // Knex database instance
1758
1789
  dbClient: 'mysql', // 'mysql', 'mysql2', 'pg', 'postgresql', 'sqlite', 'sqlite3'
1759
- schema: null, // Optional: initial schema object
1790
+ schema: null, // Optional: schema object, or a string (file path / URL — see below)
1760
1791
  resolverPath: null, // Optional: path to models directory (default: process.cwd())
1761
1792
  debug: false, // Optional: enable SQL debugging (default: false)
1762
1793
  });
1763
1794
 
1795
+ // Schema auto-resolved from a string (path or URL):
1796
+ // "./schema.json" → read & JSON.parse
1797
+ // "./schema.js" → require() (CJS: module.exports = {…})
1798
+ // "./schema.mjs" → dynamic import() (ESM: export default {…})
1799
+ // "https://api.example.com/schema" → fetch & JSON.parse
1800
+ const korm2 = await initializeKORM({
1801
+ db, dbClient: 'sqlite',
1802
+ schema: './schema.json',
1803
+ });
1804
+ ```
1805
+
1764
1806
  // Process any CRUD request (automatically handles other_requests if present)
1765
1807
  const result = await korm.processRequest(requestBody, modelName, context);
1766
1808
 
@@ -1789,7 +1831,7 @@ const modelInstance = korm.getModelInstance(modelDef);
1789
1831
 
1790
1832
  | Parameter | Type | Description |
1791
1833
  | ---------------- | -------------------- | ------------------------------------------------------------------------------------ |
1792
- | `action` | string | Action to perform (list, show, create, update, delete, count, replace, upsert, sync) |
1834
+ | `action` | string | Action to perform (list, show, create, update, delete, count, replace, upsert, sync, restore) |
1793
1835
  | `where` | object/array | Filter conditions |
1794
1836
  | `data` | object/array | Data for create/update operations |
1795
1837
  | `select` | array/string | Columns to select |
@@ -1822,7 +1864,8 @@ const modelInstance = korm.getModelInstance(modelDef);
1822
1864
  | `sum` | Sum column or expression | `data.sumColumn` or `data.sumFormula` (optional: `where`) |
1823
1865
  | `replace` | Replace full row by PK (all engines; pg = merge, see §8) | `data` (optional: `conflict`) |
1824
1866
  | `upsert` | Insert or update | `data`, `conflict` |
1825
- | `sync` | Upsert + delete | `data`, `conflict`, `where` |
1867
+ | `sync` | Upsert + delete | `data`, `conflict`, `where` |
1868
+ | `restore` | Restore soft-deleted row(s) | `where` (model must have soft-delete enabled) |
1826
1869
 
1827
1870
  ### Validation Rules
1828
1871
 
@@ -2106,6 +2149,187 @@ const korm = initializeKORM({
2106
2149
 
2107
2150
  **Note:** The `sqlDebug` field is only included in responses when `debug: true`. In production, set `debug: false` to exclude SQL statements from responses.
2108
2151
 
2152
+ ## Connection management & pooling
2153
+
2154
+ KORM does not create or own database connections. You build one Knex instance
2155
+ and hand it to `initializeKORM({ db })`; KORM borrows a connection from that
2156
+ pool for each query and returns it. Everything below is about keeping that pool
2157
+ healthy — misconfiguring it is the most common production problem in KORM apps.
2158
+
2159
+ ### The three failure modes
2160
+
2161
+ | Symptom | Cause |
2162
+ | --- | --- |
2163
+ | `PROTOCOL_CONNECTION_LOST`, `ECONNRESET`, `Connection lost: The server closed the connection` | A pooled socket sat idle past the server's `wait_timeout`, the server closed it, and the pool handed the dead socket to the next query. |
2164
+ | `Knex: Timeout acquiring a connection. The pool is probably full.` | Every connection in the pool is checked out — usually a fan-out or a slow query holding connections. |
2165
+ | `Too many connections` (`ER_CON_COUNT_ERROR`) | `processes × pool.max` exceeded the server's `max_connections`. |
2166
+
2167
+ They compound: stale sockets cause failed requests, failed requests get retried,
2168
+ retries fill the pool, and a full pool across many processes exhausts the server.
2169
+
2170
+ ### Recommended configuration
2171
+
2172
+ ```js
2173
+ const knex = require('knex');
2174
+ const { initializeKORM, recommendedPoolConfig } = require('@dreamtree-org/korm-js');
2175
+
2176
+ const db = knex({
2177
+ client: 'mysql2',
2178
+ connection: {
2179
+ host: process.env.DB_HOST,
2180
+ user: process.env.DB_USER,
2181
+ password: process.env.DB_PASS,
2182
+ database: process.env.DB_NAME,
2183
+ port: Number(process.env.DB_PORT) || 3306,
2184
+ enableKeepAlive: true,
2185
+ keepAliveInitialDelay: 10000,
2186
+ connectTimeout: 10000,
2187
+ },
2188
+ ...recommendedPoolConfig(),
2189
+ });
2190
+ ```
2191
+
2192
+ `recommendedPoolConfig()` expands to:
2193
+
2194
+ ```js
2195
+ {
2196
+ pool: {
2197
+ min: 0,
2198
+ max: 10,
2199
+ idleTimeoutMillis: 30000,
2200
+ reapIntervalMillis: 1000,
2201
+ acquireTimeoutMillis: 30000,
2202
+ createTimeoutMillis: 30000,
2203
+ propagateCreateError: false,
2204
+ },
2205
+ }
2206
+ ```
2207
+
2208
+ **`min: 0` is the important one.** Knex's pool only reaps idle connections
2209
+ _down to_ `min`, so any non-zero minimum guarantees connections that sit idle
2210
+ forever — exactly the long-`Sleep` rows you see in `SHOW PROCESSLIST`, and
2211
+ exactly the ones the server eventually kills out from under you. With `min: 0`
2212
+ and `idleTimeoutMillis` well below the server's `wait_timeout`, KORM always
2213
+ closes a connection before the server does.
2214
+
2215
+ Size `max` against your server: **`processes × pool.max` must stay under
2216
+ `max_connections`**. Count every replica, container, and PM2 cluster worker.
2217
+
2218
+ `recommendedPoolConfig('sqlite3')` returns a single-connection pool instead,
2219
+ since SQLite is a file handle rather than a network service.
2220
+
2221
+ ### One pool per process
2222
+
2223
+ ```js
2224
+ // db.js — module scope, evaluated once
2225
+ const db = knex({ ... });
2226
+ const kormPromise = initializeKORM({ db, dbClient: 'mysql', schema });
2227
+
2228
+ module.exports = { db, getKorm: () => kormPromise };
2229
+ ```
2230
+
2231
+ Never call `knex()` or `initializeKORM()` inside a request handler — each call
2232
+ opens a whole new pool that is never closed. On Next.js, guard the singleton
2233
+ against hot reload:
2234
+
2235
+ ```js
2236
+ const globalForKnex = globalThis;
2237
+ const db = globalForKnex.__kormKnex || (globalForKnex.__kormKnex = knex({ ... }));
2238
+ ```
2239
+
2240
+ KORM warns at startup (through its logger, at `warn` level) when it is handed a
2241
+ pool shape known to go stale, and once when it has been handed more than five
2242
+ distinct Knex instances in one process — the signature of a per-request leak.
2243
+
2244
+ ### Health checks and shutdown
2245
+
2246
+ ```js
2247
+ const korm = await initializeKORM({ db, dbClient: 'mysql', schema });
2248
+
2249
+ // Liveness probe. Never throws — resolves { ok: false, error } on failure.
2250
+ app.get('/healthz', async (req, res) => {
2251
+ const { ok, error } = await korm.ping();
2252
+ res.status(ok ? 200 : 503).json({ db: ok, error });
2253
+ });
2254
+
2255
+ // Drain the pool on shutdown instead of leaving sockets for the server to
2256
+ // time out. Idempotent and non-throwing.
2257
+ for (const signal of ['SIGTERM', 'SIGINT']) {
2258
+ process.on(signal, async () => {
2259
+ await korm.destroy();
2260
+ process.exit(0);
2261
+ });
2262
+ }
2263
+ ```
2264
+
2265
+ After `destroy()`, `korm.isDestroyed` is `true` and further `processRequest`
2266
+ calls throw a `KormError` instead of crashing inside the closed pool.
2267
+
2268
+ ### Automatic retry on dropped connections
2269
+
2270
+ When a query fails because the connection was dropped rather than because the
2271
+ query was wrong, KORM replays it on a fresh connection.
2272
+
2273
+ ```js
2274
+ const korm = await initializeKORM({
2275
+ db,
2276
+ dbClient: 'mysql',
2277
+ schema,
2278
+ retry: { attempts: 2, backoffMs: 100, writes: false }, // this is the default
2279
+ });
2280
+ ```
2281
+
2282
+ | Option | Default | Meaning |
2283
+ | --- | --- | --- |
2284
+ | `attempts` | `2` | Total attempts, including the first. |
2285
+ | `backoffMs` | `100` | Linear backoff — attempt _N_ waits _N_ × `backoffMs`. |
2286
+ | `writes` | `false` | Whether write actions may be replayed. |
2287
+
2288
+ Pass `retry: false` to disable it entirely.
2289
+
2290
+ **Reads are replayed; writes are not.** A `create` that actually reached the
2291
+ database before the socket died would be inserted twice by a blind replay, so
2292
+ writes are retried only when you explicitly set `writes: true`. `dryRun`
2293
+ requests are always safe to replay because they execute nothing. A request whose
2294
+ nested `other_requests` contain a write is treated as a write.
2295
+
2296
+ Only genuine connection failures are retried — `ECONNRESET`, `EPIPE`,
2297
+ `ETIMEDOUT`, `PROTOCOL_CONNECTION_LOST`, PostgreSQL `57P01`/`08006`, SQLite
2298
+ `SQLITE_BUSY`. A real query error (bad column, constraint violation) is thrown
2299
+ immediately and never retried. Pool-acquire timeouts are deliberately _not_
2300
+ retried: replaying against an exhausted pool makes the exhaustion worse.
2301
+
2302
+ ### Fan-out and the pool
2303
+
2304
+ Each entry in [`other_requests`](#nested-requests) runs as its own query and
2305
+ checks out its own connection. KORM caps how many run at once at
2306
+ `min(5, pool.max - 1)`, leaving a connection spare for the parent query, which
2307
+ may still be holding a transaction. Override it with `fanOutConcurrency`:
2308
+
2309
+ ```js
2310
+ initializeKORM({ db, dbClient: 'mysql', schema, fanOutConcurrency: 3 });
2311
+ ```
2312
+
2313
+ Raising this above `pool.max - 1` is how a nested request turns into
2314
+ `Timeout acquiring a connection`.
2315
+
2316
+ ### Diagnosing a live server
2317
+
2318
+ ```sql
2319
+ -- MySQL: who is holding connections, and for how long?
2320
+ SELECT host, db, COUNT(*) AS conns, MAX(time) AS idle_seconds
2321
+ FROM information_schema.processlist
2322
+ WHERE command = 'Sleep'
2323
+ GROUP BY host, db
2324
+ ORDER BY conns DESC;
2325
+
2326
+ SELECT @@max_connections, @@wait_timeout, @@interactive_timeout;
2327
+ ```
2328
+
2329
+ Many same-age idle connections from one host means `pool.min > 0`, or several
2330
+ pools inside one deployment. `idle_seconds` climbing toward `wait_timeout` is
2331
+ the staleness window you are about to fall into.
2332
+
2109
2333
  ## Database Support
2110
2334
 
2111
2335
  ### MySQL (Current Guide)
@@ -1 +1 @@
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};
1
+ const HelperUtility=require("./BaseHelperUtility"),KormError=require("./KormError");class ValidationError extends Error{constructor(t,e,a,r){super(t),this.name="ValidationError",this.field=e,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(t,e,a=null){return this.rules.has(t)||this.rules.set(t,[]),this.rules.get(t).push(e),a&&this.customMessages.set(`${t}.${e.type}`,a),this}string(t,e=null){return this.rule(t,{type:"string",validator:t=>"string"==typeof t},e)}number(t,e=null){return this.rule(t,{type:"number",validator:t=>"number"==typeof t&&!isNaN(t)},e)}boolean(t,e=null){return this.rule(t,{type:"boolean",validator:t=>"boolean"==typeof t},e)}required(t,e=null){return this.rule(t,{type:"required",validator:t=>null!=t&&""!==t},e)}email(t,e=null){const a=/^[^\s@]+@[^\s@]+\.[^\s@]+$/;return this.rule(t,{type:"email",validator:t=>a.test(t)},e)}url(t,e=null){return this.rule(t,{type:"url",validator:t=>{try{return new URL(t),!0}catch{return!1}}},e)}minLength(t,e,a=null){return this.rule(t,{type:"minLength",validator:t=>String(t).length>=e,params:{min:e}},a)}maxLength(t,e,a=null){return this.rule(t,{type:"maxLength",validator:t=>String(t).length<=e,params:{max:e}},a)}min(t,e,a=null){return this.rule(t,{type:"min",validator:t=>Number(t)>=e,params:{min:e}},a)}max(t,e,a=null){return this.rule(t,{type:"max",validator:t=>Number(t)<=e,params:{max:e}},a)}enum(t,e,a=null){return this.rule(t,{type:"enum",validator:t=>e.includes(t),params:{allowedValues:e}},a)}regex(t,e,a=null){return this.rule(t,{type:"regex",validator:t=>e.test(t),params:{pattern:e}},a)}uuid(t,e=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(t,{type:"uuid",validator:t=>a.test(t)},e)}date(t,e=null){return this.rule(t,{type:"date",validator:t=>!isNaN(Date.parse(t))},e)}array(t,e=null){return this.rule(t,{type:"array",validator:t=>Array.isArray(t)},e)}object(t,e=null){return this.rule(t,{type:"object",validator:t=>"object"==typeof t&&null!==t&&!Array.isArray(t)},e)}custom(t,e,a=null){return this.rule(t,{type:"custom",validator:e},a)}transform(t,e){return this.transformers.set(t,e),this}message(t,e,a){return this.customMessages.set(`${t}.${e}`,a),this}getDefaultMessage(t,e,a,r={}){return{required:`${t} is required`,string:`${t} must be a string`,number:`${t} must be a number`,boolean:`${t} must be a boolean`,email:`${t} must be a valid email address`,url:`${t} must be a valid URL`,minLength:`${t} must be at least ${r.min} characters long`,maxLength:`${t} must be at most ${r.max} characters long`,min:`${t} must be at least ${r.min}`,max:`${t} must be at most ${r.max}`,enum:`${t} must be one of: ${r.allowedValues?.join(", ")}`,regex:`${t} format is invalid`,uuid:`${t} must be a valid UUID`,date:`${t} must be a valid date`,array:`${t} must be an array`,object:`${t} must be an object`,custom:`${t} validation failed`}[e]||`${t} validation failed`}validateField(t,e){const a=this.rules.get(t)||[],r=[];let s=e;this.transformers.has(t)&&(s=this.transformers.get(t)(e));for(const e of a)try{if(!e.validator(s)){const a=this.customMessages.get(`${t}.${e.type}`)||this.getDefaultMessage(t,e.type,s,e.params);r.push(new ValidationError(a,t,s,e.type))}}catch(a){const i=this.customMessages.get(`${t}.${e.type}`)||this.getDefaultMessage(t,e.type,s,e.params);r.push(new ValidationError(i,t,s,e.type))}return{value:s,errors:r}}validate(t,e={}){const{source:a="body"}=e,r=[],s={};for(const[e,a]of this.rules){const a=t[e],{value:i,errors:n}=this.validateField(e,a);n.length>0?r.push(...n):s[e]=i}if(r.length>0)throw KormError.validationFailed({errors:r,source:a});return s}validateParams(t){return this.validate(t,{source:"params"})}validateBody(t){return this.validate(t,{source:"body"})}validateQuery(t){return this.validate(t,{source:"query"})}validateRequest(t){const e={params:{},body:{},query:{}};try{t.params&&(e.params=this.validateParams(t.params))}catch(t){e.params={error:t}}try{t.body&&(e.body=this.validateBody(t.body))}catch(t){e.body={error:t}}try{t.query&&(e.query=this.validateQuery(t.query))}catch(t){e.query={error:t}}return e}static create(){return new RequestValidator}static schema(t){const e=new RequestValidator;for(const[a,r]of Object.entries(t))Array.isArray(r)?r.forEach(t=>{"string"==typeof t?e[t](a):"object"==typeof t&&e.rule(a,t)}):"string"==typeof r?e[r](a):"object"==typeof r&&e.rule(a,r);return e}parseRuleString(t){const e=[],a=t.split("|");for(const t of a){const a=t.trim();if(a)if("required"===a)e.push({type:"required"});else if(a.startsWith("type:")){const t=a.substring(5).replace(/[()]/g,"").split(",");e.push({type:"type",params:t})}else if(a.startsWith("maxLen:")){const t=parseInt(a.substring(7));isNaN(t)||e.push({type:"maxLength",params:{max:t}})}else if(a.startsWith("minLen:")){const t=parseInt(a.substring(7));isNaN(t)||e.push({type:"minLength",params:{min:t}})}else if(a.startsWith("max:")){const t=parseInt(a.substring(4));isNaN(t)||e.push({type:"max",params:{max:t}})}else if(a.startsWith("min:")){const t=parseInt(a.substring(4));isNaN(t)||e.push({type:"min",params:{min:t}})}else if(a.startsWith("in:")){const t=a.substring(3).split(",");e.push({type:"in",params:{values:t}})}else if(a.startsWith("exists:")){const[t,r]=a.substring(7).split(",");t&&r&&e.push({type:"exists",params:{table:t,field:r}})}else if(a.startsWith("regex:")){const t=a.substring(6).replace(/[{}]/g,"");t&&e.push({type:"regex",params:{regexName:t}})}else if(a.startsWith("default:")){const t=a.substring(8);void 0!==t&&e.push({type:"default",params:{value:t}})}else if(a.startsWith("call:")){const t=a.substring(5).replace(/[{}]/g,"");t&&e.push({type:"call",params:{callbackName:t}})}}return e}addRegex(t,e){return this.customRegex.set(t,new RegExp(e)),this}addCallback(t,e){return this.customCallbacks.set(t,e),this}async validateWithRules(t,e,a={}){const{customRegex:r={},customCallbacks:s={}}=a;this._options=a;for(const[t,e]of Object.entries(r))this.addRegex(t,e);for(const[t,e]of Object.entries(s))this.addCallback(t,e);const i=[],n={};for(const[a,r]of Object.entries(e)){const e=this.parseRuleString(r),s=e.find(t=>"default"===t.type);let l=t[a];(a.includes(".")||a.includes("[]"))&&(l=this.helperUtility.dotParse(a,t));let o=l;!s||null!=l&&""!==l||(o=s.params.value);let u=!0;if(e.some(t=>"required"===t.type)||null!=o&&""!==o){for(const t of e){if("default"===t.type)continue;const e=await this.validateRule(a,o,t);if(!e.isValid){i.push(e.error),u=!1;break}}u&&(n[a]=o)}}if(i.length>0)throw KormError.validationFailed({errors:i});return n}async validateRule(t,e,a){try{let r=!0,s="";switch(a.type){case"required":r=null!=e&&""!==e,s=r?"":`${t} is required`;break;case"type":const i=a.params;r=i.some(t=>{switch(t){case"string":return"string"==typeof e;case"number":return"number"==typeof e&&!isNaN(e);case"boolean":return"boolean"==typeof e;case"array":return Array.isArray(e);case"object":return"object"==typeof e&&null!==e&&!Array.isArray(e);case"longText":return"string"==typeof e&&e.length>255;default:return!1}}),s=r?"":`${t} must be one of: ${i.join(", ")}`;break;case"maxLength":"string"==typeof e?(r=String(e).length<=a.params.max,s=r?"":`${t} must be at most ${a.params.max} characters long`):Array.isArray(e)&&(r=e.length<=a.params.max,s=r?"":`${t} must be at most ${a.params.max} items`);break;case"minLength":"string"==typeof e?(r=String(e).length>=a.params.min,s=r?"":`${t} must be at least ${a.params.min} characters long`):Array.isArray(e)&&(r=e.length>=a.params.min,s=r?"":`${t} must be at least ${a.params.min} items`);break;case"max":r=Number(e)<=a.params.max,s=r?"":`${t} must be at most ${a.params.max}`;break;case"min":r=Number(e)>=a.params.min,s=r?"":`${t} must be at least ${a.params.min}`;break;case"in":try{if(a.params.values){const i=a.params.values;r=i.includes(e),s=r?"":`${t} must be one of: ${i.join(", ")}`}else if(this._options?.dbQuery){const{table:i,field:n}=a.params,l=await this._options.dbQuery({action:"list",where:{[n]:e},limit:1},i);r=l&&l.data&&l.data.length>0,s=r?"":`${t} must be a valid value from ${i}`}}catch(e){if(e&&"VALIDATION_FAILED"===e.code)throw e;r=!1,s=`${t} database validation error: ${e.message}`}break;case"exists":try{if(this._options?.dbQuery){const{table:i,field:n}=a.params,l=await this._options.dbQuery({action:"list",where:{[n]:e},limit:1},i);r=l&&l.data&&l.data.length>0,s=r?"":`${t} must exist in ${i}`}}catch(e){if(e&&"VALIDATION_FAILED"===e.code)throw e;r=!1,s=`${t} database validation error: ${e.message}`}break;case"regex":const n=a.params.regexName,l=this.customRegex.get(n);l?(r=l.test(e),s=r?"":`${t} format is invalid`):(r=!1,s=`${t} regex pattern '${n}' not found`);break;case"call":const o=a.params.callbackName,u=this.customCallbacks.get(o);if(u&&"function"==typeof u)try{r=u(e),s=r?"":`${t} validation failed`}catch(e){r=!1,s=`${t} validation error: ${e.message}`}else r=!1,s=`${t} callback function '${o}' not found`;break;default:r=!1,s=`${t} unknown validation rule: ${a.type}`}return{isValid:r,error:r?null:new ValidationError(s,t,e,a.type)}}catch(r){return{isValid:!1,error:new ValidationError(`${t} validation failed`,t,e,a.type)}}}}async function validate(t,e,a={}){const r=new RequestValidator;let s={};return!t||"object"!=typeof t||t.body||t.params||t.query?(t.body&&(s={...s,...t.body}),t.params&&(s={...s,...t.params}),t.query&&(s={...s,...t.query})):s=t,await r.validateWithRules(s,e,a)}function validateEmail(t){return/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(t)}function validatePassword(t){return/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d@$!%*?&]{8,}$/.test(t)}function validatePhone(t){return/^\+?[\d\s-()]{10,15}$/.test(t)}function validatePAN(t){return/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/.test(t)}function validateAadhaar(t){return/^\d{12}$/.test(t)&&!/^0{12}$/.test(t)}function createValidationMiddleware(t,e={}){return async(a,r,s)=>{try{const i=await validate(a,t,e);if(!1===i.success)return r.status(400).json(i);a.validated=i,s()}catch(t){return t&&"VALIDATION_FAILED"===t.code?r.status(400).json({success:!1,reason:"Validation failed",error:t.message,context:t.context}):r.status(500).json({success:!1,reason:"Validation middleware error",error:t.message})}}}module.exports={RequestValidator:RequestValidator,validate:validate,createValidationMiddleware:createValidationMiddleware,validateEmail:validateEmail,validatePassword:validatePassword,validatePhone:validatePhone,validatePAN:validatePAN,validateAadhaar:validateAadhaar};
@@ -13,22 +13,39 @@ When helping the user, **always express data access as a KORM request object**,
13
13
  ## Wiring (do not invent alternatives)
14
14
 
15
15
  ```js
16
- const { initializeKORM } = require('@dreamtree-org/korm-js');
16
+ const { initializeKORM, recommendedPoolConfig } = require('@dreamtree-org/korm-js');
17
17
  const knex = require('knex');
18
18
 
19
+ // Build this ONCE at module scope. Never inside a request handler —
20
+ // each call opens a connection pool that is never closed.
19
21
  const db = knex({
20
22
  client: 'mysql2', // 'mysql2' | 'pg' | 'sqlite3'
21
23
  connection: {
22
24
  /* ... */
25
+ enableKeepAlive: true, // mysql2
26
+ keepAliveInitialDelay: 10000,
23
27
  },
28
+ ...recommendedPoolConfig(), // pool.min 0 + a 30s idle timeout — see below
24
29
  });
25
30
 
26
- const korm = initializeKORM({
31
+ const korm = await initializeKORM({
27
32
  db,
28
33
  dbClient: 'mysql', // 'mysql' | 'pg' | 'sqlite'
29
34
  debug: false,
35
+ schema: null, // optional: schema object, file path, or URL
36
+ resolverPath: null, // optional: path to models directory
37
+ retry: undefined, // optional: { attempts, backoffMs, writes } | false
38
+ fanOutConcurrency: undefined, // optional: cap on parallel other_requests
30
39
  });
31
40
 
41
+ // `schema` accepts four forms — auto-detected:
42
+ // object → used as-is
43
+ // ".json" → readFileSync + JSON.parse
44
+ // ".js" → require() (CJS: module.exports = {…})
45
+ // ".mjs" → dynamic import() (ESM: export default {…})
46
+ // "http(s)"→ fetch + JSON.parse
47
+ // Invalid sources throw KormError.
48
+
32
49
  const result = await korm.processRequest(requestObject, 'ModelName');
33
50
  ```
34
51
 
@@ -40,7 +57,7 @@ In Express/Next/Fastify the consumer just forwards `req.body` and the model name
40
57
 
41
58
  | Field | Type | Purpose |
42
59
  | ----------------------------------------------- | -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
43
- | `action` | string (required) | The operation: `list`, `show`, `create`, `update`, `delete`, `count`, `sum`, `replace`, `upsert`, `sync` |
60
+ | `action` | string (required) | The operation: `list`, `show`, `create`, `update`, `delete`, `count`, `sum`, `replace`, `upsert`, `sync`, `restore` |
44
61
  | `where` | object \| array | Filter conditions for `list`/`show`/`update`/`delete`/`count`/`sum` |
45
62
  | `data` | object \| array | Payload for `create`/`update`/`upsert`/`replace`/`sync` |
46
63
  | `select` | array \| string | Columns to return (default: all) |
@@ -71,6 +88,7 @@ In Express/Next/Fastify the consumer just forwards `req.body` and the model name
71
88
  | `replace` | Full-row replace by PK, all engines (MySQL/SQLite = delete+insert; pg = ON CONFLICT merge — omitted cols retained). Optional `conflict` |
72
89
  | `upsert` | Insert-or-update keyed by `conflict` columns |
73
90
  | `sync` | Upsert matching `data` + delete non-matching within `where` scope |
91
+ | `restore` | Re-activate soft-deleted rows (sets `deleted_at = NULL`); requires the model to have soft-delete enabled. Throws `UNKNOWN_ACTION` otherwise. |
74
92
 
75
93
  ### `where` operator cheat-sheet
76
94
 
@@ -86,6 +104,7 @@ Operators are **encoded as string prefixes on the value** (not separate keys):
86
104
  | `!=` | `"!V"` | `{status: "!deleted"}` | `!= ?` |
87
105
  | LIKE | `"%V%"` (or `"V%"`, `"%V"`) | `{name: "%john%"}` | `LIKE ?` |
88
106
  | IN | `"[]a,b,c"` | `{role: "[]admin,user"}` | `IN (?, ?, ?)` |
107
+ | IN (bare array) | `[1,2,3]` (JS array) | `{id: [1,2,3]}` | `IN (?, ?, ?)` |
89
108
  | NOT IN | `"![]a,b"` | `{role: "![]banned"}` | `NOT IN (...)` |
90
109
  | BETWEEN | `"><min,max"` | `{age: "><18,65"}` | `BETWEEN ? AND ?` |
91
110
  | NOT BETWEEN | `"<>min,max"` | `{score: "<>0,50"}` | `NOT BETWEEN ? AND ?` |
@@ -99,6 +118,8 @@ Rules:
99
118
  - Array form `where: [ {a: 1}, {b: 2} ]` is equivalent to object form for ANDs but lets you repeat the same column.
100
119
  - `sumFormula` uses `{columnName}` placeholders and accepts only `+ - * / ( )` and decimal literals — **never interpolate user input**.
101
120
  - All values flow through Knex bindings. **Do not hand-build SQL strings.**
121
+ - All operators work on read AND write actions (`update`, `delete`, `restore`, `sync`) — same grammar everywhere.
122
+ - `update` / `delete` / `restore` require a non-empty `where` clause — empty `where` throws `VALIDATION_FAILED`.
102
123
 
103
124
  ### Relations (`with`)
104
125
 
@@ -256,6 +277,42 @@ await korm.processRequest(
256
277
  );
257
278
  ```
258
279
 
280
+ ## Connection management
281
+
282
+ KORM does not create connections — the consumer builds one Knex instance and
283
+ hands it over. Getting that wrong is the most common production failure, so
284
+ treat these as hard rules.
285
+
286
+ **Pool config.** Always include `...recommendedPoolConfig()`, which sets
287
+ `pool: { min: 0, max: 10, idleTimeoutMillis: 30000, ... }`. `min: 0` is the
288
+ critical value: Knex only reaps idle connections _down to_ `min`, so any
289
+ non-zero minimum leaves sockets idle until the database server closes them —
290
+ after which the next query gets `PROTOCOL_CONNECTION_LOST` / `ECONNRESET`.
291
+ `processes × pool.max` must stay under the server's `max_connections`.
292
+
293
+ **One pool per process.** `knex()` and `initializeKORM()` go at module scope,
294
+ never in a request handler. On Next.js, guard the singleton on `globalThis` so
295
+ hot reload does not rebuild it.
296
+
297
+ **Lifecycle methods on the instance:**
298
+
299
+ | Method | Returns | Use |
300
+ | --- | --- | --- |
301
+ | `korm.ping()` | `{ ok, error? }` — never throws | `/healthz` probe, idle keepalive |
302
+ | `korm.destroy()` | `{ ok, error? }` — idempotent | SIGTERM/SIGINT handler |
303
+ | `korm.isDestroyed` | `boolean` | after `destroy()`, `processRequest` throws |
304
+
305
+ **Retry.** KORM replays a request once on a dropped connection
306
+ (`ECONNRESET`, `PROTOCOL_CONNECTION_LOST`, pg `57P01`/`08006`, `SQLITE_BUSY`).
307
+ Reads and `dryRun` are replayed; **writes are not**, because a `create` that
308
+ landed before the socket died would be duplicated. Opt in with
309
+ `retry: { writes: true }`; disable with `retry: false`. Real query errors are
310
+ never retried.
311
+
312
+ **Fan-out.** Sibling `other_requests` run at most `min(5, pool.max - 1)` at a
313
+ time so a nested request cannot exhaust the pool. Override with
314
+ `fanOutConcurrency`.
315
+
259
316
  ## Rules for AI assistants helping consumers
260
317
 
261
318
  1. **Use the JSON contract.** When the user asks for a query, return a KORM request object plus the `processRequest` call — not raw Knex chains.
@@ -263,7 +320,8 @@ await korm.processRequest(
263
320
  3. **Multi-DB.** Assume the same request runs on MySQL, Postgres, and SQLite. If a feature's _semantics_ differ by engine, call it out — e.g. `replace` is a true delete+insert on MySQL/SQLite but a merge on Postgres (omitted columns are retained); prefer `upsert` for portable insert-or-update.
264
321
  4. **Don't invent operators.** If the user needs something not in the operator table, use `where` with relation traversal, `having`, or `groupBy` — or tell the user the contract doesn't support it.
265
322
  5. **Don't invent fields.** The top-level keys above are the entire contract surface. No `filter`, no `query`, no `params`.
266
- 6. **Soft delete is per-model.** `delete` becomes a soft-delete only if the model declares it; don't assume.
323
+ 6. **Soft delete is per-model.** `delete` becomes a soft-delete only if the model declares it; don't assume. Soft-deleted rows are automatically excluded from `list`, `show`, `count`, and `sum`. Use `restore` to re-activate a soft-deleted row (sets `deleted_at = NULL`).
267
324
  7. **Preview before mutating.** For a risky write, add `dryRun: true` first to inspect the SQL, then re-issue without it.
268
325
  8. **Handle errors by `code`.** Catch `KormError` and branch on `e.code` (table above) rather than string-matching `e.message`.
269
- 9. **Refresh this doc** by re-running `npx @dreamtree-org/korm-js init --ai <provider>` when the library is upgraded.
326
+ 9. **Never open a pool per request.** `knex()` / `initializeKORM()` belong at module scope. If the user reports `Too many connections`, `Timeout acquiring a connection`, or `ECONNRESET`, check for a per-request pool and for `pool.min > 0` before anything else.
327
+ 10. **Refresh this doc** by re-running `npx @dreamtree-org/korm-js init --ai <provider>` when the library is upgraded.
package/bin/korm-mcp.js CHANGED
@@ -1,2 +1,2 @@
1
1
  #!/usr/bin/env node
2
- "use strict";const path=require("path"),{initializeKORM:initializeKORM}=require("../index"),{createServer:createServer}=require("../src/mcp/server"),{McpConfigError:McpConfigError}=require("../src/mcp/errors");function parseArgv(e){const r={config:null,help:!1};for(let o=2;o<e.length;o++){const t=e[o];"--help"===t||"-h"===t?r.help=!0:"--config"===t||"-c"===t?r.config=e[++o]:t.startsWith("--config=")?r.config=t.slice(9):(process.stderr.write(`korm-mcp: unknown argument "${t}"\n`),r.help=!0)}return r}function printHelp(){process.stderr.write("korm-mcp — Model Context Protocol server for @dreamtree-org/korm-js\n\nUsage:\n korm-mcp --config <path-to-config.js>\n\nOptions:\n -c, --config <path> Path to a Node CJS module exporting { db, dbClient, schema, mcp }.\n -h, --help Show this help.\n\nSee docs/agents/11-mcp-server.md for the config shape.\n")}function requireConfigModule(e){try{const r=require(e);return r&&r.default?r.default:r}catch(r){throw new McpConfigError(`Failed to load config at ${e}: ${r.message}`)}}function assertConfigFields(e,r){if(!e||"object"!=typeof e)throw new McpConfigError(`Config at ${r} must export an object (got ${typeof e}).`);const o=["db","dbClient","schema"];for(const r of o)if(!e[r])throw new McpConfigError(`Config: \`${r}\` is required.`);if(!e.mcp||"object"!=typeof e.mcp)throw new McpConfigError("Config: `mcp` object is required (see spec §6).")}function loadConfig(e){if(!e)throw new McpConfigError("--config is required. See `korm-mcp --help`.");const r=path.resolve(process.cwd(),e),o=requireConfigModule(r);return assertConfigFields(o,r),o}const stderrLogger={info:(...e)=>process.stderr.write(`[korm-mcp] ${e.join(" ")}\n`),error:(...e)=>process.stderr.write(`[korm-mcp:error] ${e.join(" ")}\n`)};function installShutdownHandlers(e){const r=async r=>{stderrLogger.info(`received ${r}, shutting down`);try{await e.stop()}catch(e){stderrLogger.error(`stop error: ${e.message}`)}process.exit(0)};process.on("SIGINT",()=>r("SIGINT")),process.on("SIGTERM",()=>r("SIGTERM"))}async function main(e=process.argv){const r=parseArgv(e);let o;r.help&&(printHelp(),process.exit(0));try{o=loadConfig(r.config)}catch(e){process.stderr.write(`korm-mcp: ${e.message}\n`),process.exit(2)}const t=initializeKORM({db:o.db,dbClient:o.dbClient,schema:o.schema,resolverPath:o.resolverPath||null,debug:o.debug||!1}),n=require("../package.json"),s=createServer({controller:t,schema:o.schema,mcpConfig:o.mcp,packageInfo:{name:n.name,version:n.version}});installShutdownHandlers(s);try{await s.start({logger:stderrLogger}),stderrLogger.info(`started; ${s.tools.length} tools exposed (mode=${o.mcp.mode||"ro"})`)}catch(e){stderrLogger.error(`failed to start: ${e.message}`),process.exit(1)}}require.main===module&&main().catch(e=>{process.stderr.write(`korm-mcp: fatal: ${e.message}\n`),process.exit(1)}),module.exports={parseArgv:parseArgv,loadConfig:loadConfig,main:main};
2
+ "use strict";const path=require("path"),{initializeKORM:initializeKORM}=require("../index"),{createServer:createServer}=require("../src/mcp/server"),{McpConfigError:McpConfigError}=require("../src/mcp/errors");function parseArgv(e){const r={config:null,help:!1};for(let o=2;o<e.length;o++){const t=e[o];"--help"===t||"-h"===t?r.help=!0:"--config"===t||"-c"===t?r.config=e[++o]:t.startsWith("--config=")?r.config=t.slice(9):(process.stderr.write(`korm-mcp: unknown argument "${t}"\n`),r.help=!0)}return r}function printHelp(){process.stderr.write("korm-mcp — Model Context Protocol server for @dreamtree-org/korm-js\n\nUsage:\n korm-mcp --config <path-to-config.js>\n\nOptions:\n -c, --config <path> Path to a Node CJS module exporting { db, dbClient, schema, mcp }.\n -h, --help Show this help.\n\nSee docs/agents/11-mcp-server.md for the config shape.\n")}function requireConfigModule(e){try{const r=require(e);return r&&r.default?r.default:r}catch(r){throw new McpConfigError(`Failed to load config at ${e}: ${r.message}`)}}function assertConfigFields(e,r){if(!e||"object"!=typeof e)throw new McpConfigError(`Config at ${r} must export an object (got ${typeof e}).`);const o=["db","dbClient","schema"];for(const r of o)if(!e[r])throw new McpConfigError(`Config: \`${r}\` is required.`);if(!e.mcp||"object"!=typeof e.mcp)throw new McpConfigError("Config: `mcp` object is required (see spec §6).")}function loadConfig(e){if(!e)throw new McpConfigError("--config is required. See `korm-mcp --help`.");const r=path.resolve(process.cwd(),e),o=requireConfigModule(r);return assertConfigFields(o,r),o}const stderrLogger={info:(...e)=>process.stderr.write(`[korm-mcp] ${e.join(" ")}\n`),error:(...e)=>process.stderr.write(`[korm-mcp:error] ${e.join(" ")}\n`)};function installShutdownHandlers(e,r){const o=async o=>{stderrLogger.info(`received ${o}, shutting down`);try{await e.stop()}catch(e){stderrLogger.error(`stop error: ${e.message}`)}if(r&&"function"==typeof r.destroy){const e=await r.destroy();e.ok||stderrLogger.error(`db close error: ${e.error}`)}process.exit(0)};process.on("SIGINT",()=>o("SIGINT")),process.on("SIGTERM",()=>o("SIGTERM"))}async function main(e=process.argv){const r=parseArgv(e);let o;r.help&&(printHelp(),process.exit(0));try{o=loadConfig(r.config)}catch(e){process.stderr.write(`korm-mcp: ${e.message}\n`),process.exit(2)}const t=await initializeKORM({db:o.db,dbClient:o.dbClient,schema:o.schema,resolverPath:o.resolverPath||null,debug:o.debug||!1}),n=require("../package.json"),s=createServer({controller:t,schema:o.schema,mcpConfig:o.mcp,packageInfo:{name:n.name,version:n.version}});installShutdownHandlers(s,t);try{await s.start({logger:stderrLogger}),stderrLogger.info(`started; ${s.tools.length} tools exposed (mode=${o.mcp.mode||"ro"})`)}catch(e){stderrLogger.error(`failed to start: ${e.message}`),process.exit(1)}}require.main===module&&main().catch(e=>{process.stderr.write(`korm-mcp: fatal: ${e.message}\n`),process.exit(1)}),module.exports={parseArgv:parseArgv,loadConfig:loadConfig,main:main};
@@ -1 +1 @@
1
- const logger=require("../Logger");function normalizeOptions(t={}){return{dropColumns:!!(null!=t.dropColumns?t.dropColumns:t.prune),continueOnError:!!t.continueOnError,dryRun:!!t.dryRun}}class SyncRunner{constructor(t,o={}){this.sync=t,this.opts=normalizeOptions(o),this.report={dryRun:this.opts.dryRun,applied:[],plan:[],skippedDrops:[],errors:[]}}result(){return this.opts.dryRun||this.opts.continueOnError?this.report:void 0}async runDatabase(t){for(const o of this.sync._orderTablesByDependency(t))await this._syncTable(t[o]);return this.opts.dryRun||logger.info("Database synced by SyncTable..."),this.result()}async runTable(t){return await this._syncTable(t),this.result()}async _syncTable(t){const o=t.table;(await this.sync.existsTable(o)?await this._alter(t,o):await this._create(t,o))&&!this.opts.dryRun&&await this._extrasAndSeed(t,o)}async _create(t,o){if(this.opts.dryRun)return this.report.plan.push({table:o,operation:"createTable",column:null,destructive:!1}),!0;const r=await this._attempt({table:o,operation:"createTable",column:null},()=>this.sync.createTable(t));return r&&this.report.applied.push({table:o,operation:"createTable",column:null}),r}async _alter(t,o){const r=await this.sync.getAlterations(t);if(this._recordDrops(o,r),this.opts.dryRun)return this._planAlter(o,r),!0;const n={add:await this._screenOrphans(o,r.add||[]),drop:this.opts.dropColumns&&r.drop||[],modify:r.modify||[]};return await this._applyWork(o,n),!0}_recordDrops(t,o){if(!this.opts.dropColumns)for(const r of o.drop||[])this.report.skippedDrops.push({table:t,column:r.name}),logger.warn(`syncDatabase: column "${t}.${r.name}" exists in the database but not in the schema; left in place (additive-only). Pass { dropColumns: true } to drop it.`)}_planAlter(t,o){for(const r of o.add||[])this.report.plan.push({table:t,operation:"add",column:r.name,destructive:!1});for(const r of o.modify||[])this.report.plan.push({table:t,operation:"modify",column:r.name,destructive:!1});if(this.opts.dropColumns)for(const r of o.drop||[])this.report.plan.push({table:t,operation:"drop",column:r.name,destructive:!0})}async _screenOrphans(t,o){if(!this.opts.continueOnError)return await this.sync._assertNoOrphanForeignKeys(t,{add:o}),o;const r=[];for(const n of o){const o=await this.sync._detectOrphanForNewFkColumn(t,n);o?this.report.errors.push({table:t,operation:"add",column:n.name,message:`adding FK column would orphan ${o.rowsAffected} row(s) with no matching ${o.parentTable}.${o.parentColumn}`}):r.push(n)}return r}async _applyWork(t,o){const r=this._ops(o);if(0!==r.length)if(this.opts.continueOnError)for(const o of r){await this._attempt({table:t,operation:o.operation,column:o.column},()=>this.sync.alterTable(t,this._single(o)))&&this.report.applied.push({table:t,operation:o.operation,column:o.column})}else{await this.sync.alterTable(t,o);for(const o of r)this.report.applied.push({table:t,operation:o.operation,column:o.column})}}_ops(t){const o=[];for(const r of t.add||[])o.push({operation:"add",column:r.name,frm:r});for(const r of t.drop||[])o.push({operation:"drop",column:r.name,name:r.name});for(const r of t.modify||[])o.push({operation:"modify",column:r.name,frm:r});return o}_single(t){return"add"===t.operation?{add:[t.frm],drop:[],modify:[]}:"drop"===t.operation?{add:[],drop:[{name:t.name}],modify:[]}:{add:[],drop:[],modify:[t.frm]}}async _extrasAndSeed(t,o){await this._attempt({table:o,operation:"extras",column:null},()=>this.sync._applyExtras(t)),await this._attempt({table:o,operation:"seed",column:null},()=>this.sync.syncSeedData(t,o))}async _attempt(t,o){try{return await o(),!0}catch(o){if(!this.opts.continueOnError)throw o;return this.report.errors.push({...t,message:o.message}),!1}}}module.exports=SyncRunner;
1
+ const logger=require("../Logger");function normalizeOptions(t={}){return{dropColumns:!!(null!=t.dropColumns?t.dropColumns:t.prune),continueOnError:!!t.continueOnError,dryRun:!!t.dryRun}}class SyncRunner{constructor(t,o={}){this.sync=t,this.opts=normalizeOptions(o),this.report={dryRun:this.opts.dryRun,applied:[],plan:[],skippedDrops:[],errors:[]}}result(){return this.opts.dryRun||this.opts.continueOnError?this.report:void 0}async runDatabase(t){const o={};for(const e of Object.keys(t)){const r=t[e];if(r&&r.table){o[r.table]||(o[r.table]=new Set);for(const t of Object.keys(r.columns||{}))o[r.table].add(t)}}for(const e of this.sync._orderTablesByDependency(t))await this._syncTable(t[e],o);return this.opts.dryRun||logger.info("Database synced by SyncTable..."),this.result()}async runTable(t){return await this._syncTable(t,{}),this.result()}async _syncTable(t,o){const e=t.table;(await this.sync.existsTable(e)?await this._alter(t,e,o):await this._create(t,e))&&!this.opts.dryRun&&await this._extrasAndSeed(t,e)}async _create(t,o){if(this.opts.dryRun)return this.report.plan.push({table:o,operation:"createTable",column:null,destructive:!1}),!0;const e=await this._attempt({table:o,operation:"createTable",column:null},()=>this.sync.createTable(t));return e&&this.report.applied.push({table:o,operation:"createTable",column:null}),e}async _alter(t,o,e={}){const r=await this.sync.getAlterations(t),n=e[o]||new Set;if(n.size>0&&r.drop&&(r.drop=r.drop.filter(t=>!n.has(t.name))),this._recordDrops(o,r),this.opts.dryRun)return this._planAlter(o,r),!0;const s={add:await this._screenOrphans(o,r.add||[]),drop:this.opts.dropColumns&&r.drop||[],modify:r.modify||[]};return await this._applyWork(o,s),!0}_recordDrops(t,o){if(!this.opts.dropColumns)for(const e of o.drop||[])this.report.skippedDrops.push({table:t,column:e.name}),logger.warn(`syncDatabase: column "${t}.${e.name}" exists in the database but not in the schema; left in place (additive-only). Pass { dropColumns: true } to drop it.`)}_planAlter(t,o){for(const e of o.add||[])this.report.plan.push({table:t,operation:"add",column:e.name,destructive:!1});for(const e of o.modify||[])this.report.plan.push({table:t,operation:"modify",column:e.name,destructive:!1});if(this.opts.dropColumns)for(const e of o.drop||[])this.report.plan.push({table:t,operation:"drop",column:e.name,destructive:!0})}async _screenOrphans(t,o){if(!this.opts.continueOnError)return await this.sync._assertNoOrphanForeignKeys(t,{add:o}),o;const e=[];for(const r of o){const o=await this.sync._detectOrphanForNewFkColumn(t,r);o?this.report.errors.push({table:t,operation:"add",column:r.name,message:`adding FK column would orphan ${o.rowsAffected} row(s) with no matching ${o.parentTable}.${o.parentColumn}`}):e.push(r)}return e}async _applyWork(t,o){const e=this._ops(o);if(0!==e.length)if(this.opts.continueOnError)for(const o of e){await this._attempt({table:t,operation:o.operation,column:o.column},()=>this.sync.alterTable(t,this._single(o)))&&this.report.applied.push({table:t,operation:o.operation,column:o.column})}else{await this.sync.alterTable(t,o);for(const o of e)this.report.applied.push({table:t,operation:o.operation,column:o.column})}}_ops(t){const o=[];for(const e of t.add||[])o.push({operation:"add",column:e.name,frm:e});for(const e of t.drop||[])o.push({operation:"drop",column:e.name,name:e.name});for(const e of t.modify||[])o.push({operation:"modify",column:e.name,frm:e});return o}_single(t){return"add"===t.operation?{add:[t.frm],drop:[],modify:[]}:"drop"===t.operation?{add:[],drop:[{name:t.name}],modify:[]}:{add:[],drop:[],modify:[t.frm]}}async _extrasAndSeed(t,o){await this._attempt({table:o,operation:"extras",column:null},()=>this.sync._applyExtras(t)),await this._attempt({table:o,operation:"seed",column:null},()=>this.sync.syncSeedData(t,o))}async _attempt(t,o){try{return await o(),!0}catch(o){if(!this.opts.continueOnError)throw o;return this.report.errors.push({...t,message:o.message}),!1}}}module.exports=SyncRunner;
@@ -1 +1 @@
1
- const QueryService=require("./QueryService"),HookService=require("./HookService"),KormError=require("../../KormError");class CurdTable{constructor(e,r,t=null){if(this.db=e,this.utils=r,this.controllerWrapper=t,this.hookService=new HookService(e,r,t),this.queryService=new QueryService(e,r,t),!this.queryService)throw new KormError("CurdTable requires queryService (execute*Query / getQuery).",{code:KormError.CODES.INTERNAL})}async processRequest(e,r=null,t={}){const o=this.controllerWrapper,s=this.utils.getModel(this.controllerWrapper,r),c=e?.action||"list";let i=null;const a={model:s,action:c,request:e,ctx:t,controller:o};if(this.hookService?.executeValidatorHook&&await this.hookService.executeValidatorHook({...a}),e?.dryRun)return this.buildDryRunResult(s,e,c);switch(this.hookService?.executeBeforeHook&&(e.beforeActionData=await this.hookService.executeBeforeHook({...a})),c){case"count":i=await this.queryService.executeCountQuery(s,e);break;case"sum":i=await this.queryService.executeSumQuery(s,e);break;case"list":i=await this.hookService.executeHasSoftDeleteHook(s)?await this.queryService.getSoftDeleteQuery(s,e):await this.queryService.getQuery(s,e);break;case"show":i=await this.queryService.executeShowQuery(s,e);break;case"create":i=await this.queryService.executeCreateQuery(s,e);break;case"update":{const r=await this.queryService.executeUpdateQuery(s,e);if(!r)throw KormError.noMatchingRow({action:c,model:s.name});i={message:"Record updated successfully",data:r,success:!0};break}case"replace":if(i=await this.queryService.executeReplaceQuery(s,e),!i)throw KormError.noMatchingRow({action:c,model:s.name});i={message:"Record replaced successfully",data:i,success:!0};break;case"upsert":if(i=await this.queryService.executeUpsertQuery(s,e),!i)throw KormError.noMatchingRow({action:c,model:s.name});i={message:"Record upserted successfully",data:i,success:!0};break;case"sync":if(i=await this.queryService.executeSyncQuery(s,e),!i)throw KormError.noMatchingRow({action:c,model:s.name});i={message:"Record synced successfully",data:i,success:!0};break;case"delete":{let r=null;if(r=await this.hookService.executeHasSoftDeleteHook(s)?await this.queryService.executeSoftDeleteQuery(s,e):await this.queryService.executeDeleteQuery(s,e),!r)throw KormError.noMatchingRow({action:c,model:s.name});i={message:"Record deleted successfully",data:r,success:!0};break}default:if(!this.hookService?.executeCustomAction)throw KormError.unknownAction({action:c,model:s?.name});i=await this.hookService.executeCustomAction({...a})}if(this.hookService?.executeAfterHook&&(i=await this.hookService.executeAfterHook({...a,data:i})),e?.other_requests&&"object"==typeof e.other_requests){const r={},o=Object.entries(e.other_requests);for(const[e,s]of o)Array.isArray(s)?r[e]=await Promise.all(s.map(r=>this.processRequest(r,e,t))):r[e]=await this.processRequest(s,e,t);i.other_responses=r}return i}async buildDryRunResult(e,r,t){if(!["list","show","count","sum","create","update","replace","upsert","sync","delete"].includes(t))throw KormError.unknownAction({action:t,model:e?.name});let o=t,s=r;const c=await this.hookService.executeHasSoftDeleteHook(e);return c&&"delete"===t?o="softDelete":c&&"list"===t&&(s={...r,where:{...r.where||{},deleted_at:null}}),this.queryService.buildDryRun(e,s,o)}}module.exports=CurdTable;
1
+ const QueryService=require("./QueryService"),HookService=require("./HookService"),KormError=require("../../KormError"),{mapWithConcurrency:mapWithConcurrency,resolveFanOutConcurrency:resolveFanOutConcurrency}=require("../../ConnectionResilience");class CurdTable{constructor(e,r,t=null){if(this.db=e,this.utils=r,this.controllerWrapper=t,this.hookService=new HookService(e,r,t),this.queryService=new QueryService(e,r,t),!this.queryService)throw new KormError("CurdTable requires queryService (execute*Query / getQuery).",{code:KormError.CODES.INTERNAL})}async _injectSoftDeleteWhere(e,r,t="deleted_at"){return this.hookService?.executeHasSoftDeleteHook&&await this.hookService.executeHasSoftDeleteHook(e)&&(r={...r,where:{...r.where||{},[t]:null}}),r}async processRequest(e,r=null,t={},o=0){if(o>10)throw new KormError("other_requests nesting depth exceeded maximum (10)",{code:KormError.CODES.INTERNAL,context:{depth:o}});const c=this.controllerWrapper,s=this.utils.getModel(this.controllerWrapper,r),i=e?.action||"list";let a=null;if(new Set(["update","delete","softDelete"]).has(i)){const r=e?.where;if(!r||"object"==typeof r&&0===Object.keys(r).length)throw KormError.validationFailed({errors:[{field:"where",message:`where is required for action "${i}"`}],source:i})}const u={model:s,action:i,request:e,ctx:t,controller:c};if(this.hookService?.executeValidatorHook&&await this.hookService.executeValidatorHook({...u}),e?.dryRun)return this.buildDryRunResult(s,e,i);switch(this.hookService?.executeBeforeHook&&(e.beforeActionData=await this.hookService.executeBeforeHook({...u})),i){case"count":e=await this._injectSoftDeleteWhere(s,e),a=await this.queryService.executeCountQuery(s,e);break;case"sum":e=await this._injectSoftDeleteWhere(s,e),a=await this.queryService.executeSumQuery(s,e);break;case"list":a=await this.hookService.executeHasSoftDeleteHook(s)?await this.queryService.getSoftDeleteQuery(s,e):await this.queryService.getQuery(s,e);break;case"show":e=await this._injectSoftDeleteWhere(s,e),a=await this.queryService.executeShowQuery(s,e);break;case"create":a=await this.queryService.executeCreateQuery(s,e);break;case"update":{const r=await this.queryService.executeUpdateQuery(s,e);if(!(r&&r.length>0))throw KormError.noMatchingRow({action:i,model:s.name});a={message:"Record updated successfully",data:r,success:!0};break}case"replace":if(a=await this.queryService.executeReplaceQuery(s,e),!a)throw KormError.noMatchingRow({action:i,model:s.name});a={message:"Record replaced successfully",data:a,success:!0};break;case"upsert":if(a=await this.queryService.executeUpsertQuery(s,e),!a)throw KormError.noMatchingRow({action:i,model:s.name});a={message:"Record upserted successfully",data:a,success:!0};break;case"sync":if(a=await this.queryService.executeSyncQuery(s,e),!a)throw KormError.noMatchingRow({action:i,model:s.name});a={message:"Record synced successfully",data:a,success:!0};break;case"delete":{let r=null;if(r=await this.hookService.executeHasSoftDeleteHook(s)?await this.queryService.executeSoftDeleteQuery(s,e):await this.queryService.executeDeleteQuery(s,e),!r)throw KormError.noMatchingRow({action:i,model:s.name});a={message:"Record deleted successfully",data:r,success:!0};break}case"restore":{if(!await(this.hookService?.executeHasSoftDeleteHook?.(s)))throw KormError.unknownAction({action:i,model:s.name});const r=await this.queryService.executeRestoreQuery(s,e);if(!(r&&r.length>0))throw KormError.noMatchingRow({action:i,model:s.name});a={message:"Record restored successfully",data:r,success:!0};break}default:if(!this.hookService?.executeCustomAction)throw KormError.unknownAction({action:i,model:s?.name});a=await this.hookService.executeCustomAction({...u})}if(this.hookService?.executeAfterHook&&(a=await this.hookService.executeAfterHook({...u,data:a})),"object"==typeof a&&null!==a&&e?.other_requests&&"object"==typeof e.other_requests){const r={},c=Object.entries(e.other_requests),s=50;let i=0;for(const[,e]of c)i+=Array.isArray(e)?e.length:1;if(i>s)throw new KormError(`other_requests fan-out exceeded maximum (${s})`,{code:KormError.CODES.INTERNAL,context:{fanOut:i}});for(const[e,s]of c)if(this.controllerWrapper?._authz?.hasRules()&&this.controllerWrapper._authz.enforce(e,s?.action||"list",s,t),Array.isArray(s)){const c=resolveFanOutConcurrency(this.db,this.controllerWrapper?.fanOutConcurrency);r[e]=await mapWithConcurrency(s,c,r=>this.processRequest(r,e,t,o+1))}else r[e]=await this.processRequest(s,e,t,o+1);a.other_responses=r}return a}async buildDryRunResult(e,r,t){if(!["list","show","count","sum","create","update","replace","upsert","sync","delete","restore"].includes(t))throw KormError.unknownAction({action:t,model:e?.name});let o=t,c=r;const s=await this.hookService.executeHasSoftDeleteHook(e);if(s&&"delete"===t)o="softDelete";else if(s&&"list"===t)c={...r,where:{...r.where||{},deleted_at:null}};else if("restore"===t&&!s)throw KormError.unknownAction({action:t,model:e?.name});return this.queryService.buildDryRun(e,c,o)}}module.exports=CurdTable;
@@ -1 +1 @@
1
- const HelperUtility=require("./HelperUtility"),logger=require("../../Logger"),KormError=require("../../KormError");class QueryBuilder{constructor(e,t,r=null){this.db=e,this.utils=t,this.controllerWrapper=r,this.helperUtility=new HelperUtility}getHookService(){return this.controllerWrapper.hookService}getQueryBuilder(e,t=null){let r=this.db(e.table);return t&&(r=t),r._getMyModel=()=>e,r}parseValue(e){return this.helperUtility.parseValue(e)}parseWhereValue(e){return this.helperUtility.parseWhereValue(e)}parseWhereColumn(e){return this.helperUtility.parseWhereColumn(e)}_applyOrWhereCondition(e,t,r,o){if(null!==o)switch(r){case"between":e.orWhereBetween(t,o);break;case"notBetween":e.orWhereNotBetween(t,o);break;case"in":e.orWhereIn(t,o);break;case"notIn":e.orWhereNotIn(t,o);break;case"like":e.orWhereRaw("?? LIKE ?",[t,o]);break;default:Array.isArray(o)?e.orWhereIn(t,o):e.orWhere(t,r,o)}else e.orWhereNull(t)}_applyAndWhereCondition(e,t,r,o){if(null!==o)switch(r){case"between":e.whereBetween(t,o);break;case"notBetween":e.whereNotBetween(t,o);break;case"in":e.whereIn(t,o);break;case"notIn":e.whereNotIn(t,o);break;case"like":e.whereRaw("?? LIKE ?",[t,o]);break;default:Array.isArray(o)?e.whereIn(t,o):e.where(t,r,o)}else e.whereNull(t)}buildWithTree(e,t=null){return this.helperUtility.dotWalkTree(e,{resolver:({current:e,part:r,source:o})=>t?t({current:e,part:r,source:o}):{}})}_getWithWhereForRelation(e,t){if(!e||"object"!=typeof e)return{direct:{},nested:{}};const r=`${t}.`,o={},i={};for(const[t,s]of Object.entries(e))if(t.startsWith(r)){const e=t.slice(r.length);e.includes(".")?i[e]=s:o[e]=s}return{direct:o,nested:i}}_applyWithWhereConditions(e,t){for(const[r,o]of Object.entries(t)){const{joinType:t="AND",column:i}=this.parseWhereColumn(r),{operator:s,value:n}=this.parseWhereValue(o);"AND"===t?this._applyAndWhereCondition(e,i,s,n):this._applyOrWhereCondition(e,i,s,n)}}async fetchRelatedRows(e,t,r={}){if(e.through){const o=await this.db(e.through).whereIn(e.throughLocalKey,t),i=this.db(e.table).whereIn(e.foreignKey,o.map(t=>t[e.throughForeignKey]));return Object.keys(r).length>0&&this._applyWithWhereConditions(i,r),i}{const o=this.db(e.table).whereIn(e.foreignKey,t);return Object.keys(r).length>0&&this._applyWithWhereConditions(o,r),o}}async fetchAndAttachRelated(e){const{parentRows:t,relName:r,model:o,withTree:i,relation:s,withWhere:n={}}=e;if(!s){const e=this.getHookService().getModelInstance(o),s=`get${r.charAt(0).toUpperCase()+r.slice(1)}Relation`;if("function"==typeof e[s]){const{direct:l,nested:a}=this._getWithWhereForRelation(n,r),h={rows:t,relName:r,model:o,withTree:i,controller:this.controllerWrapper,relation:o.hasRelations[r],qb:this,db:this.db,withWhere:l,nestedWithWhere:a};await e[s](h)}else logger.warn(`Method ${s} not found in model ${o.name}`);return t}const l=t.map(e=>e[s.localKey]);let a=[];const h="one"===s?.type,{direct:c,nested:p}=this._getWithWhereForRelation(n,r);let u={...c};try{const e=this.utils.getModel(this.controllerWrapper,r);if(this.controllerWrapper?.hookService){await this.controllerWrapper.hookService.executeHasSoftDeleteHook(e)&&(u={...u,deleted_at:null})}}catch(e){}a=await this.fetchRelatedRows(s,l,u);const y=new Map;for(const e of a){const t=e[s.foreignKey];y.has(t)||y.set(t,[]),y.get(t).push(e)}for(const e of t){const t=e[s.localKey];h&&1==y.get(t)?.length?e[r]=y.get(t)[0]:e[r]=y.get(t)||[]}const d=Object.keys(i);for(const e of d){const o=i[e],s=t.filter(e=>h?e[r]:e[r].length>0).map(e=>e[r]).reduce((e,t)=>e.concat(t),[]),n=this.utils.getModel(this.controllerWrapper,r);await this.fetchAndAttachRelated({parentRows:s,relName:e,model:n,withTree:o,relation:n.hasRelations[e],withWhere:p})}return t}filterWhere(e,t=""){return t?Object.keys(e).filter(e=>e.includes(t)).reduce((r,o)=>(r[o.replace(t,"")]=e[o],r),{}):Object.keys(e).filter(e=>!e.includes(".")).reduce((t,r)=>(t[r]=e[r],t),{})}buildSelectQuery(e,t){const{where:r={},with:o,select:i,orderBy:s,limit:n=10,offset:l=0,page:a,groupBy:h,having:c,distinct:p,join:u,leftJoin:y,rightJoin:d,innerJoin:f}=t,g=this.getQueryBuilder(e);i&&(Array.isArray(i)||"string"==typeof i)?g.select(i):g.select("*"),p&&(Array.isArray(p)||"string"==typeof p?g.distinct(p):g.distinct()),u&&this._applyJoins(g,u,"join"),y&&this._applyJoins(g,y,"leftJoin"),d&&this._applyJoins(g,d,"rightJoin"),f&&this._applyJoins(g,f,"innerJoin"),this._applyWhereClause(g,r,o),h&&g.groupBy(h),c&&this._applyHavingClause(g,c);const W=void 0===s?this.helperUtility.resolveDefaultOrderBy(e):s;W&&this._applyOrderBy(g,W);let w=l;return a&&n>0&&(w=(Math.max(1,parseInt(a))-1)*n),n>0&&(g.limit(n),w>0&&g.offset(w)),g}async getQuery(e,t){try{const{where:r={},with:o,withWhere:i,limit:s=10,offset:n=0,page:l,join:a,leftJoin:h,rightJoin:c,innerJoin:p}=t,u=this.buildSelectQuery(e,t);let y=!1;const d=s;let f=n,g=1,W=0;l&&s>0&&(g=Math.max(1,parseInt(l)),f=(g-1)*s);const w=[],m=u.toSQL();w.push(m.sql);const b=await u;if(o&&o.length>0){const t=this.buildWithTree(o);for(const r of Object.keys(t))await this.fetchAndAttachRelated({parentRows:b,relName:r,model:e,withTree:t[r],relation:e.hasRelations[r],withWhere:i||{}})}let _=null;if(s>0)try{const t=this.getQueryBuilder(e);r&&Object.keys(r).length>0&&this._applyWhereClause(t,r),a&&this._applyJoins(t,a,"join"),h&&this._applyJoins(t,h,"leftJoin"),c&&this._applyJoins(t,c,"rightJoin"),p&&this._applyJoins(t,p,"innerJoin");const o=t.count("* as cnt");w.push(o.toSQL().sql);_=(await o.first()).cnt}catch(e){logger.warn("Failed to get total count:",e.message),_=b.length}s>0&&null!==_&&(W=Math.ceil(_/s),y=g<W);const j=y?g+1:null,A=g>1?g-1:null;return{data:b,totalCount:_,...this.controllerWrapper.debug?{sqlDebug:w}:{},...s>0?{pagination:{page:g,limit:d,offset:f,totalPages:W,hasNext:y,hasPrev:g>1,nextPage:j,prevPage:A}}:{}}}catch(t){throw logger.error("QueryService.getQuery error:",t),new Error(`Failed to execute query: ${t.message} on model ${e.name}`)}}getSumQuery(e,t){const{where:r={},join:o,leftJoin:i,rightJoin:s,innerJoin:n}=t,l=t.data||t,a=l.sumColumn,h=l.sumFormula;if(!a&&!h)throw new Error("Sum action requires either data.sumColumn or data.sumFormula");const c=e=>"`"+String(e).replace(/[`\\]/g,"")+"`";let p;if(a){const e=String(a).trim().replace(/[^a-zA-Z0-9_]/g,"");if(!e)throw new Error("data.sumColumn must be a valid column name");p="SUM("+c(e)+")"}else{const e=String(h).trim();if(!/\{[a-zA-Z0-9_]+\}/.test(e))throw new Error("data.sumFormula must contain at least one {columnName}");let t=0;for(const r of e)if("("===r)t++;else if(")"===r&&(t--,t<0))break;if(0!==t)throw new Error("data.sumFormula has unbalanced or misordered parentheses ( and )");const r=e.replace(/\{[a-zA-Z0-9_]+\}/g,"@");if(!/^[\s0-9.+*\/\-()@]+$/.test(r))throw new Error("data.sumFormula may only use BODMAS: numbers, + - * /, parentheses, and {columnName}");p="SUM("+e.replace(/\{([a-zA-Z0-9_]+)\}/g,(e,t)=>c(t))+")"}const u=this.getQueryBuilder(e);return o&&this._applyJoins(u,o,"join"),i&&this._applyJoins(u,i,"leftJoin"),s&&this._applyJoins(u,s,"rightJoin"),n&&this._applyJoins(u,n,"innerJoin"),this._applyWhereClause(u,r,[]),u.select(this.db.raw(p+" as sum")),u}_applyWhereWithArray(e,t,r){for(const o of t)this._applyWhereClause(e,o,r)}_applyWhereClause(e,t,r=[]){if(Array.isArray(t))this._applyWhereWithArray(e,t,r);else{if(t&&Object.keys(t).length>0){let r=this.helperUtility.getDotWalkQuery(t);logger.debug({filteredWhere:r}),r=this.helperUtility.objectFilter(r,(e,t)=>!e.startsWith("!")&&("__exists__"!==e&&("object"!=typeof t||null===t)));for(const[t,o]of Object.entries(r)){const{joinType:r="AND",column:i}=this.parseWhereColumn(t),{operator:s,value:n}=this.parseWhereValue(o);"AND"===r?this._applyAndWhereCondition(e,i,s,n):this._applyOrWhereCondition(e,i,s,n)}}this._applyNestedWhere(e,t,r)}}_getNestedWhereConditions(e,t){if(!e||"object"!=typeof e)return{};const r=`${t}.`,o={};for(const[i,s]of Object.entries(e))if(i.startsWith(r)){o[i.slice(r.length)]=s}else i===t&&!0===s&&(o.__exists__=!0);return o}_getTopLevelRelationsFromWhere(e){if(!e||"object"!=typeof e)return[];const t=new Set;for(const r of Object.keys(e))if(r.includes(".")){const e=r.split(".")[0];t.add(e)}else r.startsWith("!")&&t.add(r);return[...t]}_applyNestedWhere(e,t,r){const o=this,i=e._getMyModel(),s=this._getTopLevelRelationsFromWhere(t);if(0!==s.length)for(const r of s){const s=r.startsWith("!"),n=s?r.slice(1):r,l=this._getNestedWhereConditions(t,r);if(0===Object.keys(l).length)continue;const a=i.hasRelations?.[n];if(!a){logger.warn(`Relation ${n} not found in model ${i.modelName}`);continue}let h;try{h=this.utils.getModel(this.controllerWrapper,a.table||n)}catch(e){try{h=this.utils.getModel(this.controllerWrapper,n)}catch(e){logger.warn(`Model for relation ${n} (table: ${a.table}) not found`);continue}}e[s?"whereNotExists":"whereExists"](function(){const e=o.getQueryBuilder(h,this.select("*").from(a.table));e.whereRaw("??.?? = ??.??",[a.table,a.foreignKey,i.table,a.localKey]);if(!(!0===l.__exists__&&1===Object.keys(l).length)){const t={...l};delete t.__exists__,o._applyWhereClause(e,t,[])}})}}_applyJoins(e,t,r){const o=Array.isArray(t)?t:[t];for(const t of o)"string"==typeof t?e[r](t):"object"==typeof t&&(t.table&&t.on?e[r](t.table,t.on):t.table&&t.first&&t.operator&&t.second&&e[r](t.table,t.first,t.operator,t.second))}_applyWithWhere(e,t){try{if(Array.isArray(t))for(const r of t)"string"==typeof r?e.withWhere(r):"object"==typeof r&&e.withWhere(r.column,r.operator,r.value);else if("object"==typeof t)for(const[r,o]of Object.entries(t))e.withWhere(r,o)}catch(e){logger.warn("Failed to apply withWhere:",e.message)}}_applyHavingClause(e,t){for(const[r,o]of Object.entries(t))"object"==typeof o&&o.operator?e.having(r,o.operator,o.value):e.having(r,o)}_applyOrderBy(e,t){if(Array.isArray(t))for(const r of t)this._applyOrderByEntry(e,r);else this._applyOrderByEntry(e,t)}_applyOrderByEntry(e,t){if("string"!=typeof t){if(t&&"object"==typeof t){if(!t.column)throw KormError.validationFailed({errors:[{field:"orderBy",message:`orderBy object must include a "column" key (got keys: ${Object.keys(t).join(", ")||"none"})`,value:t,rule:"required"}],source:"orderBy"});e.orderBy(t.column,t.direction||"asc")}}else e.orderBy(t)}}module.exports=QueryBuilder;
1
+ const HelperUtility=require("./HelperUtility"),logger=require("../../Logger"),KormError=require("../../KormError");class QueryBuilder{constructor(e,t,r=null){this.db=e,this.utils=t,this.controllerWrapper=r,this.helperUtility=new HelperUtility}getHookService(){return this.controllerWrapper.hookService}getQueryBuilder(e,t=null){let r=this.db(e.table);return t&&(r=t),r._getMyModel=()=>e,r}parseValue(e){return this.helperUtility.parseValue(e)}parseWhereValue(e){return this.helperUtility.parseWhereValue(e)}parseWhereColumn(e){return this.helperUtility.parseWhereColumn(e)}_applyOrWhereCondition(e,t,r,o){if(null!==o)switch(r){case"between":e.orWhereBetween(t,o);break;case"notBetween":e.orWhereNotBetween(t,o);break;case"in":e.orWhereIn(t,o);break;case"notIn":e.orWhereNotIn(t,o);break;case"like":e.orWhereRaw("?? LIKE ?",[t,o]);break;default:Array.isArray(o)?e.orWhereIn(t,o):e.orWhere(t,r,o)}else e.orWhereNull(t)}_applyAndWhereCondition(e,t,r,o){if(null!==o)switch(r){case"between":e.whereBetween(t,o);break;case"notBetween":e.whereNotBetween(t,o);break;case"in":e.whereIn(t,o);break;case"notIn":e.whereNotIn(t,o);break;case"like":e.whereRaw("?? LIKE ?",[t,o]);break;default:Array.isArray(o)?e.whereIn(t,o):e.where(t,r,o)}else e.whereNull(t)}buildWithTree(e,t=null){return this.helperUtility.dotWalkTree(e,{resolver:({current:e,part:r,source:o})=>t?t({current:e,part:r,source:o}):{}})}_getWithWhereForRelation(e,t){if(!e||"object"!=typeof e)return{direct:{},nested:{}};const r=`${t}.`,o={},i={};for(const[t,s]of Object.entries(e))if(t.startsWith(r)){const e=t.slice(r.length);e.includes(".")?i[e]=s:o[e]=s}return{direct:o,nested:i}}_applyWithWhereConditions(e,t){for(const[r,o]of Object.entries(t)){const{joinType:t="AND",column:i}=this.parseWhereColumn(r),{operator:s,value:n}=this.parseWhereValue(o);"AND"===t?this._applyAndWhereCondition(e,i,s,n):this._applyOrWhereCondition(e,i,s,n)}}async fetchRelatedRows(e,t,r={}){if(e.through){const o=await this.db(e.through).whereIn(e.throughLocalKey,t),i=this.db(e.table).whereIn(e.foreignKey,o.map(t=>t[e.throughForeignKey]));return Object.keys(r).length>0&&this._applyWithWhereConditions(i,r),i}{const o=this.db(e.table).whereIn(e.foreignKey,t);return Object.keys(r).length>0&&this._applyWithWhereConditions(o,r),o}}async fetchAndAttachRelated(e){const{parentRows:t,relName:r,model:o,withTree:i,relation:s,withWhere:n={}}=e;if(!s){const e=this.getHookService().getModelInstance(o),s=`get${r.charAt(0).toUpperCase()+r.slice(1)}Relation`;if("function"==typeof e[s]){const{direct:a,nested:l}=this._getWithWhereForRelation(n,r),h={rows:t,relName:r,model:o,withTree:i,controller:this.controllerWrapper,relation:o.hasRelations[r],qb:this,db:this.db,withWhere:a,nestedWithWhere:l};await e[s](h)}else logger.warn(`Method ${s} not found in model ${o.name}`);return t}const a=t.map(e=>e[s.localKey]);let l=[];const h="one"===s?.type,{direct:c,nested:u}=this._getWithWhereForRelation(n,r);let p={...c};try{const e=this.utils.getModel(this.controllerWrapper,r);if(this.controllerWrapper?.hookService){await this.controllerWrapper.hookService.executeHasSoftDeleteHook(e)&&(p={...p,deleted_at:null})}}catch(e){}l=await this.fetchRelatedRows(s,a,p);const d=new Map;for(const e of l){const t=e[s.foreignKey];d.has(t)||d.set(t,[]),d.get(t).push(e)}for(const e of t){const t=e[s.localKey];h&&1==d.get(t)?.length?e[r]=d.get(t)[0]:e[r]=d.get(t)||[]}const y=Object.keys(i);for(const e of y){const o=i[e],s=t.filter(e=>h?e[r]:e[r].length>0).map(e=>e[r]).reduce((e,t)=>e.concat(t),[]),n=this.utils.getModel(this.controllerWrapper,r);await this.fetchAndAttachRelated({parentRows:s,relName:e,model:n,withTree:o,relation:n.hasRelations[e],withWhere:u})}return t}filterWhere(e,t=""){return t?Object.keys(e).filter(e=>e.includes(t)).reduce((r,o)=>(r[o.replace(t,"")]=e[o],r),{}):Object.keys(e).filter(e=>!e.includes(".")).reduce((t,r)=>(t[r]=e[r],t),{})}buildSelectQuery(e,t){const{where:r={},with:o,select:i,orderBy:s,limit:n=10,offset:a=0,page:l,groupBy:h,having:c,distinct:u,join:p,leftJoin:d,rightJoin:y,innerJoin:f}=t,g=this.getQueryBuilder(e);i&&(Array.isArray(i)||"string"==typeof i)?g.select(i):g.select("*"),u&&(Array.isArray(u)||"string"==typeof u?g.distinct(u):g.distinct()),p&&this._applyJoins(g,p,"join"),d&&this._applyJoins(g,d,"leftJoin"),y&&this._applyJoins(g,y,"rightJoin"),f&&this._applyJoins(g,f,"innerJoin"),this._applyWhereClause(g,r,o),h&&g.groupBy(h),c&&this._applyHavingClause(g,c);const m=void 0===s?this.helperUtility.resolveDefaultOrderBy(e):s;m&&this._applyOrderBy(g,m);let W=a;return l&&n>0&&(W=(Math.max(1,parseInt(l))-1)*n),n>0&&(g.limit(n),W>0&&g.offset(W)),g}async getQuery(e,t){try{const{where:r={},with:o,withWhere:i,limit:s=10,offset:n=0,page:a,join:l,leftJoin:h,rightJoin:c,innerJoin:u,groupBy:p,having:d,distinct:y}=t,f=this.buildSelectQuery(e,t);let g=!1;const m=s;let W=n,b=1,_=0;a&&s>0?(b=Math.max(1,parseInt(a)),W=(b-1)*s):W>0&&s>0&&(b=Math.floor(W/s)+1);const w=[],A=f.toSQL();w.push(A.sql);const j=await f;if(o&&o.length>0){const t=this.buildWithTree(o);for(const r of Object.keys(t))await this.fetchAndAttachRelated({parentRows:j,relName:r,model:e,withTree:t[r],relation:e.hasRelations[r],withWhere:i||{}})}let k=null;if(s>0)try{const t=this.getQueryBuilder(e);let o;r&&Object.keys(r).length>0&&this._applyWhereClause(t,r),l&&this._applyJoins(t,l,"join"),h&&this._applyJoins(t,h,"leftJoin"),c&&this._applyJoins(t,c,"rightJoin"),u&&this._applyJoins(t,u,"innerJoin"),y&&(Array.isArray(y)||"string"==typeof y?t.distinct(y):t.distinct()),p&&t.groupBy(p),d&&this._applyHavingClause(t,d),o=p||y||d?this.db.count("* as cnt").from(t.as("__count_sub")):t.count("* as cnt"),w.push(o.toSQL().sql);const i=await o.first();k=Number(i.cnt)||0}catch(e){logger.warn("Failed to get total count:",e.message),k=j.length}s>0&&null!==k&&(_=Math.ceil(k/s),g=b<_);const v=g?b+1:null,J=b>1?b-1:null;return{data:j,totalCount:k,...this.controllerWrapper.debug?{sqlDebug:w}:{},...s>0?{pagination:{page:b,limit:m,offset:W,totalPages:_,hasNext:g,hasPrev:b>1,nextPage:v,prevPage:J}}:{}}}catch(t){if(t instanceof KormError)throw t;throw logger.error("QueryService.getQuery error:",t),new Error(`Failed to execute query: ${t.message} on model ${e.name}`)}}getSumQuery(e,t){const{where:r={},join:o,leftJoin:i,rightJoin:s,innerJoin:n}=t,a=t.data||t,l=a.sumColumn,h=a.sumFormula;if(!l&&!h)throw KormError.validationFailed({errors:[{field:"data",message:"Sum action requires either data.sumColumn or data.sumFormula"}],source:"sum"});const c=e=>"`"+String(e).replace(/[`\\]/g,"")+"`";let u;if(l){const e=String(l).trim().replace(/[^a-zA-Z0-9_]/g,"");if(!e)throw KormError.validationFailed({errors:[{field:"data.sumColumn",message:"data.sumColumn must be a valid column name"}],source:"sum"});u="SUM("+c(e)+")"}else{const e=String(h).trim();if(!/\{[a-zA-Z0-9_]+\}/.test(e))throw KormError.validationFailed({errors:[{field:"data.sumFormula",message:"data.sumFormula must contain at least one {columnName}"}],source:"sum"});let t=0;for(const r of e)if("("===r)t++;else if(")"===r&&(t--,t<0))break;if(0!==t)throw KormError.validationFailed({errors:[{field:"data.sumFormula",message:"data.sumFormula has unbalanced or misordered parentheses ( and )"}],source:"sum"});const r=e.replace(/\{[a-zA-Z0-9_]+\}/g,"@");if(!/^[\s0-9.+*\/\-()@]+$/.test(r))throw KormError.validationFailed({errors:[{field:"data.sumFormula",message:"data.sumFormula may only use BODMAS: numbers, + - * /, parentheses, and {columnName}"}],source:"sum"});u="SUM("+e.replace(/\{([a-zA-Z0-9_]+)\}/g,(e,t)=>c(t))+")"}const p=this.getQueryBuilder(e);return o&&this._applyJoins(p,o,"join"),i&&this._applyJoins(p,i,"leftJoin"),s&&this._applyJoins(p,s,"rightJoin"),n&&this._applyJoins(p,n,"innerJoin"),this._applyWhereClause(p,r,[]),p.select(this.db.raw(u+" as sum")),p}_applyWhereWithArray(e,t,r){for(const o of t)this._applyWhereClause(e,o,r)}_applyWhereClause(e,t,r=[]){if(Array.isArray(t))this._applyWhereWithArray(e,t,r);else{if(t&&Object.keys(t).length>0){let r=this.helperUtility.getDotWalkQuery(t);logger.debug({filteredWhere:r}),r=this.helperUtility.objectFilter(r,(e,t)=>!e.startsWith("!")&&("__exists__"!==e&&("object"!=typeof t||null===t||Array.isArray(t))));for(const[t,o]of Object.entries(r)){const{joinType:r="AND",column:i}=this.parseWhereColumn(t),{operator:s,value:n}=this.parseWhereValue(o);"AND"===r?this._applyAndWhereCondition(e,i,s,n):this._applyOrWhereCondition(e,i,s,n)}}this._applyNestedWhere(e,t,r)}}_getNestedWhereConditions(e,t){if(!e||"object"!=typeof e)return{};const r=`${t}.`,o={};for(const[i,s]of Object.entries(e))if(i.startsWith(r)){o[i.slice(r.length)]=s}else i===t&&!0===s&&(o.__exists__=!0);return o}_getTopLevelRelationsFromWhere(e){if(!e||"object"!=typeof e)return[];const t=new Set;for(const r of Object.keys(e))if(r.includes(".")){const e=r.split(".")[0];t.add(e)}else r.startsWith("!")&&t.add(r);return[...t]}_applyNestedWhere(e,t,r){const o=this,i=e._getMyModel(),s=this._getTopLevelRelationsFromWhere(t);if(0!==s.length)for(const r of s){const s=r.startsWith("!"),n=s?r.slice(1):r,a=this._getNestedWhereConditions(t,r);if(0===Object.keys(a).length)continue;const l=i.hasRelations?.[n];if(!l){logger.warn(`Relation ${n} not found in model ${i.modelName}`);continue}let h;try{h=this.utils.getModel(this.controllerWrapper,l.table||n)}catch(e){try{h=this.utils.getModel(this.controllerWrapper,n)}catch(e){logger.warn(`Model for relation ${n} (table: ${l.table}) not found`);continue}}e[s?"whereNotExists":"whereExists"](function(){const e=o.getQueryBuilder(h,this.select("*").from(l.table));e.whereRaw("??.?? = ??.??",[l.table,l.foreignKey,i.table,l.localKey]);if(!(!0===a.__exists__&&1===Object.keys(a).length)){const t={...a};delete t.__exists__,o._applyWhereClause(e,t,[])}})}}_applyJoins(e,t,r){const o=Array.isArray(t)?t:[t];for(const t of o)"string"==typeof t?e[r](t):"object"==typeof t&&(t.table&&t.on?e[r](t.table,t.on):t.table&&t.first&&t.operator&&t.second&&e[r](t.table,t.first,t.operator,t.second))}_applyHavingClause(e,t){for(const[r,o]of Object.entries(t))"object"==typeof o&&o.operator?e.having(r,o.operator,o.value):e.having(r,"=",o)}_applyOrderBy(e,t){if(Array.isArray(t))for(const r of t)this._applyOrderByEntry(e,r);else this._applyOrderByEntry(e,t)}_applyOrderByEntry(e,t){if("string"!=typeof t){if(t&&"object"==typeof t){if(!t.column)throw KormError.validationFailed({errors:[{field:"orderBy",message:`orderBy object must include a "column" key (got keys: ${Object.keys(t).join(", ")||"none"})`,value:t,rule:"required"}],source:"orderBy"});e.orderBy(t.column,t.direction||"asc")}}else e.orderBy(t)}}module.exports=QueryBuilder;
@@ -1 +1 @@
1
- const QueryBuilder=require("./QueryBuilder");class QueryService{constructor(e,t,r=null){this.db=e,this.utils=t,this.controllerWrapper=r,this.queryBuilder=new QueryBuilder(e,t,r)}async getQuery(e,t){return await this.queryBuilder.getQuery(e,t)}async getSoftDeleteQuery(e,t){return await this.queryBuilder.getQuery(e,{...t,where:{...t.where||{},deleted_at:null}})}async executeShowQuery(e,t){const r=await this.getQuery(e,{...t,limit:1,offset:0});return r.data.length>0?r.data[0]:null}_buildCountQuery(e,t={}){const r=this.queryBuilder.getQueryBuilder(e),{join:u,leftJoin:i,rightJoin:a,innerJoin:n,where:s={}}=t;return u&&this.queryBuilder._applyJoins(r,u,"join"),i&&this.queryBuilder._applyJoins(r,i,"leftJoin"),a&&this.queryBuilder._applyJoins(r,a,"rightJoin"),n&&this.queryBuilder._applyJoins(r,n,"innerJoin"),this.queryBuilder._applyWhereClause(r,s,[]),r.count()}async executeCountQuery(e,t){const r=await this._buildCountQuery(e,t||{}),u=r&&r[0]?Object.values(r[0])[0]:0,i=Number(u);return Number.isNaN(i)?0:i}async executeSumQuery(e,t){const r=await this.queryBuilder.getSumQuery(e,t).first(),u=r&&null!=r.sum?r.sum:0;return Number(u)}async executeCreateQuery(e,t){const r=await this.db(e.table).insert(t.data),u=Array.isArray(r)?r[0]:r;if(null==u)return[];const i=e.columns&&e.columns.find(e=>e.primary),a=i&&i.name?i.name:"id",n=await this.db(e.table).where(a,u).select("*");return Array.isArray(n)?n:[n]}async executeUpdateQuery(e,t){return await this.db.transaction(async r=>(await r(e.table).where(t.where).update(t.data),await r(e.table).where(t.where).first()))}async executeDeleteQuery(e,t){return await this.db(e.table).where(t.where).delete()}async executeSoftDeleteQuery(e,t){await this.db(e.table).where(t.where).update({deleted_at:new Date});const r=await this.db(e.table).where(t.where).select("*");return Array.isArray(r)?r:[r]}async executeUpsertQuery(e,t){return await this.db(e.table).insert(t.data).onConflict(t.conflict).merge(t.data)}_buildReplaceQuery(e,t){const{sql:r,bindings:u}=this.db(e.table).insert(t.data).toSQL();return this.db.raw(r.replace(/^\s*insert/i,"REPLACE"),u)}async executeReplaceQuery(e,t){return await this._buildReplaceQuery(e,t),t.data}buildDryRun(e,t,r){const u=this._dryRunBuilders(e,t,r).map(e=>{const t=e.toSQL();return{sql:t.sql,bindings:t.bindings}});return{success:!0,dryRun:!0,action:r,model:e.name,sql:u[0]?u[0].sql:null,bindings:u[0]?u[0].bindings:[],statements:u}}_dryRunBuilders(e,t,r){const u=e.table,i=t.where||{};switch(r){case"list":return[this.queryBuilder.buildSelectQuery(e,t)];case"show":return[this.queryBuilder.buildSelectQuery(e,{...t,limit:1,offset:0})];case"count":return[this._buildCountQuery(e,t)];case"sum":return[this.queryBuilder.getSumQuery(e,t)];case"create":return[this.db(u).insert(t.data).returning("*")];case"update":return[this.db(u).where(i).update(t.data).returning("*")];case"softDelete":return[this.db(u).where(i).update({deleted_at:new Date}).returning("*")];case"delete":return[this.db(u).where(i).delete()];case"replace":return[this._buildReplaceQuery(e,t)];case"upsert":return[this.db(u).insert(t.data).onConflict(t.conflict).merge(t.data)];case"sync":return[this.db(u).insert(t.data).onConflict(t.conflict).merge(t.data),this.db(u).where(i).delete()];default:return[]}}async executeSyncQuery(e,t){return this.db.transaction(async r=>({insertOrUpdateQuery:await r(e.table).insert(t.data).onConflict(t.conflict).merge(t.data),deleteQuery:await r(e.table).where(t.where).delete()}))}}module.exports=QueryService;
1
+ const QueryBuilder=require("./QueryBuilder");class QueryService{constructor(e,r,t=null){this.db=e,this.utils=r,this.controllerWrapper=t,this.queryBuilder=new QueryBuilder(e,r,t)}async getQuery(e,r){return await this.queryBuilder.getQuery(e,r)}async getSoftDeleteQuery(e,r){return await this.queryBuilder.getQuery(e,{...r,where:{...r.where||{},deleted_at:null}})}async executeShowQuery(e,r){const t=await this.getQuery(e,{...r,limit:1,offset:0});return t.data.length>0?t.data[0]:null}_buildCountQuery(e,r={}){const t=this.queryBuilder.getQueryBuilder(e),{join:u,leftJoin:i,rightJoin:s,innerJoin:a,where:l={}}=r;return u&&this.queryBuilder._applyJoins(t,u,"join"),i&&this.queryBuilder._applyJoins(t,i,"leftJoin"),s&&this.queryBuilder._applyJoins(t,s,"rightJoin"),a&&this.queryBuilder._applyJoins(t,a,"innerJoin"),this.queryBuilder._applyWhereClause(t,l,[]),t.count()}async executeCountQuery(e,r){const t=await this._buildCountQuery(e,r||{}),u=t&&t[0]?Object.values(t[0])[0]:0,i=Number(u);return Number.isNaN(i)?0:i}async executeSumQuery(e,r){const t=await this.queryBuilder.getSumQuery(e,r).first(),u=t&&null!=t.sum?t.sum:0;return Number(u)}async executeCreateQuery(e,r){const t=r.data,u=Array.isArray(t)?t:[t],i=e.columns&&e.columns.find(e=>e.primary),s=i&&i.name?i.name:"id";return await this.db.transaction(async r=>{const i=await r(e.table).insert(t),a=Array.isArray(i)?i[0]:i;if(null!=a&&0!==a){const t=await r(e.table).where(s,">=",a).limit(u.length).orderBy(s).select("*");return Array.isArray(t)?t:[t]}const l=u.map(e=>e[s]).filter(e=>null!=e);if(l.length>0){const t=await r(e.table).whereIn(s,l).select("*");return Array.isArray(t)?t:[t]}return u})}async executeUpdateQuery(e,r){return await this.db.transaction(async t=>{const u=this.queryBuilder.getQueryBuilder(e,t(e.table));this.queryBuilder._applyWhereClause(u,r.where||{},[]),await u.update(r.data);const i=this.queryBuilder.getQueryBuilder(e,t(e.table));return this.queryBuilder._applyWhereClause(i,r.where||{},[]),await i.select("*")})}async executeDeleteQuery(e,r){const t=this.queryBuilder.getQueryBuilder(e);return this.queryBuilder._applyWhereClause(t,r.where||{},[]),await t.delete()}async executeSoftDeleteQuery(e,r){const t=this.queryBuilder.getQueryBuilder(e);this.queryBuilder._applyWhereClause(t,r.where||{},[]),await t.update({deleted_at:new Date});const u=this.queryBuilder.getQueryBuilder(e);this.queryBuilder._applyWhereClause(u,r.where||{},[]);const i=await u.select("*");return Array.isArray(i)?i:[i]}async executeRestoreQuery(e,r){const t=this.queryBuilder.getQueryBuilder(e);this.queryBuilder._applyWhereClause(t,r.where||{},[]),await t.update({deleted_at:null});const u=this.queryBuilder.getQueryBuilder(e);this.queryBuilder._applyWhereClause(u,r.where||{},[]);const i=await u.select("*");return Array.isArray(i)?i:[i]}async executeUpsertQuery(e,r){return await this.db(e.table).insert(r.data).onConflict(r.conflict).merge(r.data)}_buildReplaceQuery(e,r){const{sql:t,bindings:u}=this.db(e.table).insert(r.data).toSQL();return this.db.raw(t.replace(/^\s*insert/i,"REPLACE"),u)}async executeReplaceQuery(e,r){return await this._buildReplaceQuery(e,r),r.data}buildDryRun(e,r,t){const u=this._dryRunBuilders(e,r,t).map(e=>{const r=e.toSQL();return{sql:r.sql,bindings:r.bindings}});return{success:!0,dryRun:!0,action:t,model:e.name,sql:u[0]?u[0].sql:null,bindings:u[0]?u[0].bindings:[],statements:u}}_dryRunBuilders(e,r,t){const u=e.table,i=r.where||{};switch(t){case"list":return[this.queryBuilder.buildSelectQuery(e,r)];case"show":return[this.queryBuilder.buildSelectQuery(e,{...r,limit:1,offset:0})];case"count":return[this._buildCountQuery(e,r)];case"sum":return[this.queryBuilder.getSumQuery(e,r)];case"create":return[this.db(u).insert(r.data)];case"update":{const t=this.queryBuilder.getQueryBuilder(e);this.queryBuilder._applyWhereClause(t,i,[]);const s=this.queryBuilder.getQueryBuilder(e,this.db(u));return this.queryBuilder._applyWhereClause(s,i,[]),[t.update(r.data),s.select("*")]}case"softDelete":{const r=this.queryBuilder.getQueryBuilder(e,this.db(u));this.queryBuilder._applyWhereClause(r,i,[]);const t=this.queryBuilder.getQueryBuilder(e,this.db(u));return this.queryBuilder._applyWhereClause(t,i,[]),[r.update({deleted_at:new Date}),t.select("*")]}case"delete":{const r=this.queryBuilder.getQueryBuilder(e);return this.queryBuilder._applyWhereClause(r,i,[]),[r.delete()]}case"replace":return[this._buildReplaceQuery(e,r)];case"upsert":return[this.db(u).insert(r.data).onConflict(r.conflict).merge(r.data)];case"sync":{const t=this.queryBuilder.getQueryBuilder(e);return this.queryBuilder._applyWhereClause(t,i,[]),[this.db(u).insert(r.data).onConflict(r.conflict).merge(r.data),t.delete()]}case"restore":{const r=this.queryBuilder.getQueryBuilder(e);return this.queryBuilder._applyWhereClause(r,i,[]),[r.update({deleted_at:null}).returning("*")]}default:return[]}}async executeSyncQuery(e,r){return this.db.transaction(async t=>{const u=await t(e.table).insert(r.data).onConflict(r.conflict).merge(r.data),i=this.queryBuilder.getQueryBuilder(e,t(e.table));this.queryBuilder._applyWhereClause(i,r.where||{},[]);return{insertOrUpdateQuery:u,deleteQuery:await i.delete()}})}}module.exports=QueryService;
@@ -1 +1 @@
1
- const QueryService=require("./QueryService"),HookService=require("./HookService"),KormError=require("../../KormError");class CurdTable{constructor(e,r,t=null){if(this.db=e,this.utils=r,this.controllerWrapper=t,this.hookService=new HookService(e,r,t),this.queryService=new QueryService(e,r,t),!this.queryService)throw new KormError("CurdTable requires queryService (execute*Query / getQuery).",{code:KormError.CODES.INTERNAL})}async processRequest(e,r=null,t=null){const o=this.controllerWrapper,s=this.utils.getModel(this.controllerWrapper,r),c=e?.action||"list";let i=null;const a={model:s,action:c,request:e,ctx:t,controller:o};if(this.hookService?.executeValidatorHook&&await this.hookService.executeValidatorHook({...a}),e?.dryRun)return this.buildDryRunResult(s,e,c);switch(this.hookService?.executeBeforeHook&&(e.beforeActionData=await this.hookService.executeBeforeHook({...a})),c){case"count":i=await this.queryService.executeCountQuery(s,e);break;case"sum":i=await this.queryService.executeSumQuery(s,e);break;case"list":i=await this.hookService.executeHasSoftDeleteHook(s)?await this.queryService.getSoftDeleteQuery(s,e):await this.queryService.getQuery(s,e);break;case"show":i=await this.queryService.executeShowQuery(s,e);break;case"create":i=await this.queryService.executeCreateQuery(s,e);break;case"update":{const r=await this.queryService.executeUpdateQuery(s,e);if(!r)throw KormError.noMatchingRow({action:c,model:s.name});i={message:"Record updated successfully",data:r,success:!0};break}case"replace":if(i=await this.queryService.executeReplaceQuery(s,e),!i)throw KormError.noMatchingRow({action:c,model:s.name});i={message:"Record replaced successfully",data:i,success:!0};break;case"upsert":if(i=await this.queryService.executeUpsertQuery(s,e),!i)throw KormError.noMatchingRow({action:c,model:s.name});i={message:"Record upserted successfully",data:i,success:!0};break;case"sync":if(i=await this.queryService.executeSyncQuery(s,e),!i)throw KormError.noMatchingRow({action:c,model:s.name});i={message:"Record synced successfully",data:i,success:!0};break;case"delete":{let r=null;if(r=await this.hookService.executeHasSoftDeleteHook(s)?await this.queryService.executeSoftDeleteQuery(s,e):await this.queryService.executeDeleteQuery(s,e),!r)throw KormError.noMatchingRow({action:c,model:s.name});i={message:"Record deleted successfully",data:r,success:!0};break}default:if(!this.hookService?.executeCustomAction)throw KormError.unknownAction({action:c,model:s?.name});i=await this.hookService.executeCustomAction({...a})}if(this.hookService?.executeAfterHook&&(i=await this.hookService.executeAfterHook({...a,data:i})),e?.other_requests&&"object"==typeof e.other_requests){const r={},o=Object.entries(e.other_requests);for(const[e,s]of o)Array.isArray(s)?r[e]=await Promise.all(s.map(r=>this.processRequest(r,e,t))):r[e]=await this.processRequest(s,e,t);i.other_responses=r}return i}async buildDryRunResult(e,r,t){if(!["list","show","count","sum","create","update","replace","upsert","sync","delete"].includes(t))throw KormError.unknownAction({action:t,model:e?.name});let o=t,s=r;const c=await this.hookService.executeHasSoftDeleteHook(e);return c&&"delete"===t?o="softDelete":c&&"list"===t&&(s={...r,where:{...r.where||{},deleted_at:null}}),this.queryService.buildDryRun(e,s,o)}}module.exports=CurdTable;
1
+ const QueryService=require("./QueryService"),HookService=require("./HookService"),KormError=require("../../KormError"),{mapWithConcurrency:mapWithConcurrency,resolveFanOutConcurrency:resolveFanOutConcurrency}=require("../../ConnectionResilience");class CurdTable{constructor(e,r,t=null){if(this.db=e,this.utils=r,this.controllerWrapper=t,this.hookService=new HookService(e,r,t),this.queryService=new QueryService(e,r,t),!this.queryService)throw new KormError("CurdTable requires queryService (execute*Query / getQuery).",{code:KormError.CODES.INTERNAL})}async _injectSoftDeleteWhere(e,r,t="deleted_at"){return this.hookService?.executeHasSoftDeleteHook&&await this.hookService.executeHasSoftDeleteHook(e)&&(r={...r,where:{...r.where||{},[t]:null}}),r}async processRequest(e,r=null,t={},o=0){if(o>10)throw new KormError("other_requests nesting depth exceeded maximum (10)",{code:KormError.CODES.INTERNAL,context:{depth:o}});const c=this.controllerWrapper,s=this.utils.getModel(this.controllerWrapper,r),i=e?.action||"list";let a=null;if(new Set(["update","delete","softDelete"]).has(i)){const r=e?.where;if(!r||"object"==typeof r&&0===Object.keys(r).length)throw KormError.validationFailed({errors:[{field:"where",message:`where is required for action "${i}"`}],source:i})}const u={model:s,action:i,request:e,ctx:t,controller:c};if(this.hookService?.executeValidatorHook&&await this.hookService.executeValidatorHook({...u}),e?.dryRun)return this.buildDryRunResult(s,e,i);switch(this.hookService?.executeBeforeHook&&(e.beforeActionData=await this.hookService.executeBeforeHook({...u})),i){case"count":e=await this._injectSoftDeleteWhere(s,e),a=await this.queryService.executeCountQuery(s,e);break;case"sum":e=await this._injectSoftDeleteWhere(s,e),a=await this.queryService.executeSumQuery(s,e);break;case"list":a=await this.hookService.executeHasSoftDeleteHook(s)?await this.queryService.getSoftDeleteQuery(s,e):await this.queryService.getQuery(s,e);break;case"show":e=await this._injectSoftDeleteWhere(s,e),a=await this.queryService.executeShowQuery(s,e);break;case"create":a=await this.queryService.executeCreateQuery(s,e);break;case"update":{const r=await this.queryService.executeUpdateQuery(s,e);if(!(r&&r.length>0))throw KormError.noMatchingRow({action:i,model:s.name});a={message:"Record updated successfully",data:r,success:!0};break}case"replace":if(a=await this.queryService.executeReplaceQuery(s,e),!a)throw KormError.noMatchingRow({action:i,model:s.name});a={message:"Record replaced successfully",data:a,success:!0};break;case"upsert":if(a=await this.queryService.executeUpsertQuery(s,e),!a)throw KormError.noMatchingRow({action:i,model:s.name});a={message:"Record upserted successfully",data:a,success:!0};break;case"sync":if(a=await this.queryService.executeSyncQuery(s,e),!a)throw KormError.noMatchingRow({action:i,model:s.name});a={message:"Record synced successfully",data:a,success:!0};break;case"delete":{let r=null;if(r=await this.hookService.executeHasSoftDeleteHook(s)?await this.queryService.executeSoftDeleteQuery(s,e):await this.queryService.executeDeleteQuery(s,e),!r)throw KormError.noMatchingRow({action:i,model:s.name});a={message:"Record deleted successfully",data:r,success:!0};break}case"restore":{if(!await(this.hookService?.executeHasSoftDeleteHook?.(s)))throw KormError.unknownAction({action:i,model:s.name});const r=await this.queryService.executeRestoreQuery(s,e);if(!(r&&r.length>0))throw KormError.noMatchingRow({action:i,model:s.name});a={message:"Record restored successfully",data:r,success:!0};break}default:if(!this.hookService?.executeCustomAction)throw KormError.unknownAction({action:i,model:s?.name});a=await this.hookService.executeCustomAction({...u})}if(this.hookService?.executeAfterHook&&(a=await this.hookService.executeAfterHook({...u,data:a})),"object"==typeof a&&null!==a&&e?.other_requests&&"object"==typeof e.other_requests){const r={},c=Object.entries(e.other_requests),s=50;let i=0;for(const[,e]of c)i+=Array.isArray(e)?e.length:1;if(i>s)throw new KormError(`other_requests fan-out exceeded maximum (${s})`,{code:KormError.CODES.INTERNAL,context:{fanOut:i}});for(const[e,s]of c)if(this.controllerWrapper?._authz?.hasRules()&&this.controllerWrapper._authz.enforce(e,s?.action||"list",s,t),Array.isArray(s)){const c=resolveFanOutConcurrency(this.db,this.controllerWrapper?.fanOutConcurrency);r[e]=await mapWithConcurrency(s,c,r=>this.processRequest(r,e,t,o+1))}else r[e]=await this.processRequest(s,e,t,o+1);a.other_responses=r}return a}async buildDryRunResult(e,r,t){if(!["list","show","count","sum","create","update","replace","upsert","sync","delete","restore"].includes(t))throw KormError.unknownAction({action:t,model:e?.name});let o=t,c=r;const s=await this.hookService.executeHasSoftDeleteHook(e);if(s&&"delete"===t)o="softDelete";else if(s&&"list"===t)c={...r,where:{...r.where||{},deleted_at:null}};else if("restore"===t&&!s)throw KormError.unknownAction({action:t,model:e?.name});return this.queryService.buildDryRun(e,c,o)}}module.exports=CurdTable;
@@ -1 +1 @@
1
- const HelperUtility=require("./HelperUtility"),logger=require("../../Logger"),KormError=require("../../KormError");class QueryBuilder{constructor(e,t,r=null){this.db=e,this.utils=t,this.controllerWrapper=r,this.helperUtility=new HelperUtility}getHookService(){return this.controllerWrapper.hookService}getQueryBuilder(e,t=null){let r=this.db(e.table);return t&&(r=t),r._getMyModel=()=>e,r}parseValue(e){return this.helperUtility.parseValue(e)}parseWhereValue(e){return this.helperUtility.parseWhereValue(e)}parseWhereColumn(e){return this.helperUtility.parseWhereColumn(e)}_applyOrWhereCondition(e,t,r,o){if(null!==o)switch(r){case"between":e.orWhereBetween(t,o);break;case"notBetween":e.orWhereNotBetween(t,o);break;case"in":e.orWhereIn(t,o);break;case"notIn":e.orWhereNotIn(t,o);break;case"like":e.orWhere(t,"like",o);break;default:Array.isArray(o)?e.orWhereIn(t,o):e.orWhere(t,r,o)}else e.orWhereNull(t)}_applyAndWhereCondition(e,t,r,o){if(null!==o)switch(r){case"between":e.whereBetween(t,o);break;case"notBetween":e.whereNotBetween(t,o);break;case"in":e.whereIn(t,o);break;case"notIn":e.whereNotIn(t,o);break;case"like":e.where(t,"like",o);break;default:Array.isArray(o)?e.whereIn(t,o):e.where(t,r,o)}else e.whereNull(t)}buildWithTree(e,t=null){return this.helperUtility.dotWalkTree(e,{resolver:({current:e,part:r,source:o})=>t?t({current:e,part:r,source:o}):{}})}_getWithWhereForRelation(e,t){if(!e||"object"!=typeof e)return{direct:{},nested:{}};const r=`${t}.`,o={},i={};for(const[t,s]of Object.entries(e))if(t.startsWith(r)){const e=t.slice(r.length);e.includes(".")?i[e]=s:o[e]=s}return{direct:o,nested:i}}_applyWithWhereConditions(e,t){for(const[r,o]of Object.entries(t)){const{joinType:t="AND",column:i}=this.parseWhereColumn(r),{operator:s,value:n}=this.parseWhereValue(o);"AND"===t?this._applyAndWhereCondition(e,i,s,n):this._applyOrWhereCondition(e,i,s,n)}}async fetchRelatedRows(e,t,r={}){if(e.through){const o=await this.db(e.through).whereIn(e.throughLocalKey,t),i=this.db(e.table).whereIn(e.foreignKey,o.map(t=>t[e.throughForeignKey]));return Object.keys(r).length>0&&this._applyWithWhereConditions(i,r),i}{const o=this.db(e.table).whereIn(e.foreignKey,t);return Object.keys(r).length>0&&this._applyWithWhereConditions(o,r),o}}async fetchAndAttachRelated(e){const{parentRows:t,relName:r,model:o,withTree:i,relation:s,withWhere:n={}}=e;if(!s){const e=this.getHookService().getModelInstance(o),s=`get${r.charAt(0).toUpperCase()+r.slice(1)}Relation`;if("function"==typeof e[s]){const{direct:l,nested:a}=this._getWithWhereForRelation(n,r),h={rows:t,relName:r,model:o,withTree:i,controller:this.controllerWrapper,relation:o.hasRelations[r],qb:this,db:this.db,withWhere:l,nestedWithWhere:a};await e[s](h)}else logger.warn(`Method ${s} not found in model ${o.name}`);return t}const l=t.map(e=>e[s.localKey]),a="one"===s?.type;let h=[];const{direct:c,nested:p}=this._getWithWhereForRelation(n,r);let u={...c};try{const e=this.utils.getModel(this.controllerWrapper,r);if(this.controllerWrapper?.hookService){await this.controllerWrapper.hookService.executeHasSoftDeleteHook(e)&&(u={...u,deleted_at:null})}}catch(e){}h=await this.fetchRelatedRows(s,l,u);const y=new Map;for(const e of h){const t=e[s.foreignKey];y.has(t)||y.set(t,[]),y.get(t).push(e)}for(const e of t){const t=e[s.localKey];a&&1==y.get(t)?.length?e[r]=y.get(t)[0]:e[r]=y.get(t)||[]}const d=Object.keys(i);for(const e of d){const o=i[e],s=t.filter(e=>a?e[r]:e[r].length>0).map(e=>e[r]).reduce((e,t)=>e.concat(t),[]),n=this.utils.getModel(this.controllerWrapper,r);await this.fetchAndAttachRelated({parentRows:s,relName:e,model:n,withTree:o,relation:n.hasRelations[e],withWhere:p})}return t}filterWhere(e,t=""){return t?Object.keys(e).filter(e=>e.includes(t)).reduce((r,o)=>(r[o.replace(t,"")]=e[o],r),{}):Object.keys(e).filter(e=>!e.includes(".")).reduce((t,r)=>(t[r]=e[r],t),{})}buildSelectQuery(e,t){const{where:r={},with:o,select:i,orderBy:s,limit:n=10,offset:l=0,page:a,groupBy:h,having:c,distinct:p,join:u,leftJoin:y,rightJoin:d,innerJoin:f}=t,g=this.getQueryBuilder(e);i&&(Array.isArray(i)||"string"==typeof i)?g.select(i):g.select("*"),p&&(Array.isArray(p)||"string"==typeof p?g.distinct(p):g.distinct()),u&&this._applyJoins(g,u,"join"),y&&this._applyJoins(g,y,"leftJoin"),d&&this._applyJoins(g,d,"rightJoin"),f&&this._applyJoins(g,f,"innerJoin"),this._applyWhereClause(g,r,o),h&&g.groupBy(h),c&&this._applyHavingClause(g,c);const W=void 0===s?this.helperUtility.resolveDefaultOrderBy(e):s;W&&this._applyOrderBy(g,W);let w=l;return a&&n>0&&(w=(Math.max(1,parseInt(a))-1)*n),n>0&&(g.limit(n),w>0&&g.offset(w)),g}async getQuery(e,t){try{const{where:r={},with:o,withWhere:i,limit:s=10,offset:n=0,page:l,join:a,leftJoin:h,rightJoin:c,innerJoin:p}=t,u=this.buildSelectQuery(e,t);let y=!1;const d=s;let f=n,g=1,W=0;l&&s>0&&(g=Math.max(1,parseInt(l)),f=(g-1)*s);const w=[],m=u.toSQL();w.push(m.sql);const b=await u;if(o&&o.length>0){const t=this.buildWithTree(o);for(const r of Object.keys(t))await this.fetchAndAttachRelated({parentRows:b,relName:r,model:e,withTree:t[r],relation:e.hasRelations[r],withWhere:i||{}})}let _=null;if(s>0)try{const t=this.getQueryBuilder(e);r&&Object.keys(r).length>0&&this._applyWhereClause(t,r),a&&this._applyJoins(t,a,"join"),h&&this._applyJoins(t,h,"leftJoin"),c&&this._applyJoins(t,c,"rightJoin"),p&&this._applyJoins(t,p,"innerJoin");const o=t.count("* as cnt");w.push(o.toSQL().sql);_=(await o.first()).cnt}catch(e){logger.warn("Failed to get total count:",e.message),_=b.length}s>0&&null!==_&&(W=Math.ceil(_/s),y=g<W);const j=y?g+1:null,k=g>1?g-1:null;return{data:b,totalCount:_,...this.controllerWrapper.debug?{sqlDebug:w}:{},...s>0?{pagination:{page:g,limit:d,offset:f,totalPages:W,hasNext:y,hasPrev:g>1,nextPage:j,prevPage:k}}:{}}}catch(t){throw logger.error("QueryService.getQuery error:",t),new Error(`Failed to execute query: ${t.message} on model ${e.name}`)}}getSumQuery(e,t){const{where:r={},join:o,leftJoin:i,rightJoin:s,innerJoin:n}=t,l=t.data||t,a=l.sumColumn,h=l.sumFormula;if(!a&&!h)throw new Error("Sum action requires either data.sumColumn or data.sumFormula");const c=e=>'"'+String(e).replace(/["\\]/g,"")+'"';let p;if(a){const e=String(a).trim().replace(/[^a-zA-Z0-9_]/g,"");if(!e)throw new Error("data.sumColumn must be a valid column name");p="SUM("+c(e)+")"}else{const e=String(h).trim();if(!/\{[a-zA-Z0-9_]+\}/.test(e))throw new Error("data.sumFormula must contain at least one {columnName}");let t=0;for(const r of e)if("("===r)t++;else if(")"===r&&(t--,t<0))break;if(0!==t)throw new Error("data.sumFormula has unbalanced or misordered parentheses ( and )");const r=e.replace(/\{[a-zA-Z0-9_]+\}/g,"@");if(!/^[\s0-9.+*\/\-()@]+$/.test(r))throw new Error("data.sumFormula may only use BODMAS: numbers, + - * /, parentheses, and {columnName}");p="SUM("+e.replace(/\{([a-zA-Z0-9_]+)\}/g,(e,t)=>c(t))+")"}const u=this.getQueryBuilder(e);return o&&this._applyJoins(u,o,"join"),i&&this._applyJoins(u,i,"leftJoin"),s&&this._applyJoins(u,s,"rightJoin"),n&&this._applyJoins(u,n,"innerJoin"),this._applyWhereClause(u,r,[]),u.select(this.db.raw(p+" as sum")),u}_applyWhereWithArray(e,t,r){for(const o of t)this._applyWhereClause(e,o,r)}_applyWhereClause(e,t,r=[]){if(Array.isArray(t))this._applyWhereWithArray(e,t,r);else{if(t&&Object.keys(t).length>0){let r=this.helperUtility.getDotWalkQuery(t);logger.debug({filteredWhere:r}),r=this.helperUtility.objectFilter(r,(e,t)=>!e.startsWith("!")&&("__exists__"!==e&&("object"!=typeof t||null===t)));for(const[t,o]of Object.entries(r)){const{joinType:r="AND",column:i}=this.parseWhereColumn(t),{operator:s,value:n}=this.parseWhereValue(o);"AND"===r?this._applyAndWhereCondition(e,i,s,n):this._applyOrWhereCondition(e,i,s,n)}}this._applyNestedWhere(e,t,r)}}_getNestedWhereConditions(e,t){if(!e||"object"!=typeof e)return{};const r=`${t}.`,o={};for(const[i,s]of Object.entries(e))if(i.startsWith(r)){o[i.slice(r.length)]=s}else i===t&&!0===s&&(o.__exists__=!0);return o}_getTopLevelRelationsFromWhere(e){if(!e||"object"!=typeof e)return[];const t=new Set;for(const r of Object.keys(e))if(r.includes(".")){const e=r.split(".")[0];t.add(e)}else r.startsWith("!")&&t.add(r);return[...t]}_applyNestedWhere(e,t,r){const o=this,i=e._getMyModel(),s=this._getTopLevelRelationsFromWhere(t);if(0!==s.length)for(const r of s){const s=r.startsWith("!"),n=s?r.slice(1):r,l=this._getNestedWhereConditions(t,r);if(0===Object.keys(l).length)continue;const a=i.hasRelations?.[n];if(!a){logger.warn(`Relation ${n} not found in model ${i.modelName}`);continue}let h;try{h=this.utils.getModel(this.controllerWrapper,a.table||n)}catch(e){try{h=this.utils.getModel(this.controllerWrapper,n)}catch(e){logger.warn(`Model for relation ${n} (table: ${a.table}) not found`);continue}}e[s?"whereNotExists":"whereExists"](function(){const e=o.getQueryBuilder(h,this.select("*").from(a.table));e.whereRaw("??.?? = ??.??",[a.table,a.foreignKey,i.table,a.localKey]);if(!(!0===l.__exists__&&1===Object.keys(l).length)){const t={...l};delete t.__exists__,o._applyWhereClause(e,t,[])}})}}_applyJoins(e,t,r){const o=Array.isArray(t)?t:[t];for(const t of o)"string"==typeof t?e[r](t):"object"==typeof t&&(t.table&&t.on?e[r](t.table,t.on):t.table&&t.first&&t.operator&&t.second&&e[r](t.table,t.first,t.operator,t.second))}_applyWithWhere(e,t){try{if(Array.isArray(t))for(const r of t)"string"==typeof r?e.withWhere(r):"object"==typeof r&&e.withWhere(r.column,r.operator,r.value);else if("object"==typeof t)for(const[r,o]of Object.entries(t))e.withWhere(r,o)}catch(e){logger.warn("Failed to apply withWhere:",e.message)}}_applyHavingClause(e,t){for(const[r,o]of Object.entries(t))"object"==typeof o&&o.operator?e.having(r,o.operator,o.value):e.having(r,o)}_applyOrderBy(e,t){if(Array.isArray(t))for(const r of t)this._applyOrderByEntry(e,r);else this._applyOrderByEntry(e,t)}_applyOrderByEntry(e,t){if("string"!=typeof t){if(t&&"object"==typeof t){if(!t.column)throw KormError.validationFailed({errors:[{field:"orderBy",message:`orderBy object must include a "column" key (got keys: ${Object.keys(t).join(", ")||"none"})`,value:t,rule:"required"}],source:"orderBy"});e.orderBy(t.column,t.direction||"asc")}}else e.orderBy(t)}}module.exports=QueryBuilder;
1
+ const HelperUtility=require("./HelperUtility"),logger=require("../../Logger"),KormError=require("../../KormError");class QueryBuilder{constructor(e,t,r=null){this.db=e,this.utils=t,this.controllerWrapper=r,this.helperUtility=new HelperUtility}getHookService(){return this.controllerWrapper.hookService}getQueryBuilder(e,t=null){let r=this.db(e.table);return t&&(r=t),r._getMyModel=()=>e,r}parseValue(e){return this.helperUtility.parseValue(e)}parseWhereValue(e){return this.helperUtility.parseWhereValue(e)}parseWhereColumn(e){return this.helperUtility.parseWhereColumn(e)}_applyOrWhereCondition(e,t,r,o){if(null!==o)switch(r){case"between":e.orWhereBetween(t,o);break;case"notBetween":e.orWhereNotBetween(t,o);break;case"in":e.orWhereIn(t,o);break;case"notIn":e.orWhereNotIn(t,o);break;case"like":e.orWhere(t,"ilike",o);break;default:Array.isArray(o)?e.orWhereIn(t,o):e.orWhere(t,r,o)}else e.orWhereNull(t)}_applyAndWhereCondition(e,t,r,o){if(null!==o)switch(r){case"between":e.whereBetween(t,o);break;case"notBetween":e.whereNotBetween(t,o);break;case"in":e.whereIn(t,o);break;case"notIn":e.whereNotIn(t,o);break;case"like":e.where(t,"ilike",o);break;default:Array.isArray(o)?e.whereIn(t,o):e.where(t,r,o)}else e.whereNull(t)}buildWithTree(e,t=null){return this.helperUtility.dotWalkTree(e,{resolver:({current:e,part:r,source:o})=>t?t({current:e,part:r,source:o}):{}})}_getWithWhereForRelation(e,t){if(!e||"object"!=typeof e)return{direct:{},nested:{}};const r=`${t}.`,o={},i={};for(const[t,s]of Object.entries(e))if(t.startsWith(r)){const e=t.slice(r.length);e.includes(".")?i[e]=s:o[e]=s}return{direct:o,nested:i}}_applyWithWhereConditions(e,t){for(const[r,o]of Object.entries(t)){const{joinType:t="AND",column:i}=this.parseWhereColumn(r),{operator:s,value:n}=this.parseWhereValue(o);"AND"===t?this._applyAndWhereCondition(e,i,s,n):this._applyOrWhereCondition(e,i,s,n)}}async fetchRelatedRows(e,t,r={}){if(e.through){const o=await this.db(e.through).whereIn(e.throughLocalKey,t),i=this.db(e.table).whereIn(e.foreignKey,o.map(t=>t[e.throughForeignKey]));return Object.keys(r).length>0&&this._applyWithWhereConditions(i,r),i}{const o=this.db(e.table).whereIn(e.foreignKey,t);return Object.keys(r).length>0&&this._applyWithWhereConditions(o,r),o}}async fetchAndAttachRelated(e){const{parentRows:t,relName:r,model:o,withTree:i,relation:s,withWhere:n={}}=e;if(!s){const e=this.getHookService().getModelInstance(o),s=`get${r.charAt(0).toUpperCase()+r.slice(1)}Relation`;if("function"==typeof e[s]){const{direct:a,nested:l}=this._getWithWhereForRelation(n,r),h={rows:t,relName:r,model:o,withTree:i,controller:this.controllerWrapper,relation:o.hasRelations[r],qb:this,db:this.db,withWhere:a,nestedWithWhere:l};await e[s](h)}else logger.warn(`Method ${s} not found in model ${o.name}`);return t}const a=t.map(e=>e[s.localKey]),l="one"===s?.type;let h=[];const{direct:c,nested:u}=this._getWithWhereForRelation(n,r);let p={...c};try{const e=this.utils.getModel(this.controllerWrapper,r);if(this.controllerWrapper?.hookService){await this.controllerWrapper.hookService.executeHasSoftDeleteHook(e)&&(p={...p,deleted_at:null})}}catch(e){}h=await this.fetchRelatedRows(s,a,p);const d=new Map;for(const e of h){const t=e[s.foreignKey];d.has(t)||d.set(t,[]),d.get(t).push(e)}for(const e of t){const t=e[s.localKey];l&&1==d.get(t)?.length?e[r]=d.get(t)[0]:e[r]=d.get(t)||[]}const y=Object.keys(i);for(const e of y){const o=i[e],s=t.filter(e=>l?e[r]:e[r].length>0).map(e=>e[r]).reduce((e,t)=>e.concat(t),[]),n=this.utils.getModel(this.controllerWrapper,r);await this.fetchAndAttachRelated({parentRows:s,relName:e,model:n,withTree:o,relation:n.hasRelations[e],withWhere:u})}return t}filterWhere(e,t=""){return t?Object.keys(e).filter(e=>e.includes(t)).reduce((r,o)=>(r[o.replace(t,"")]=e[o],r),{}):Object.keys(e).filter(e=>!e.includes(".")).reduce((t,r)=>(t[r]=e[r],t),{})}buildSelectQuery(e,t){const{where:r={},with:o,select:i,orderBy:s,limit:n=10,offset:a=0,page:l,groupBy:h,having:c,distinct:u,join:p,leftJoin:d,rightJoin:y,innerJoin:f}=t,g=this.getQueryBuilder(e);i&&(Array.isArray(i)||"string"==typeof i)?g.select(i):g.select("*"),u&&(Array.isArray(u)||"string"==typeof u?g.distinct(u):g.distinct()),p&&this._applyJoins(g,p,"join"),d&&this._applyJoins(g,d,"leftJoin"),y&&this._applyJoins(g,y,"rightJoin"),f&&this._applyJoins(g,f,"innerJoin"),this._applyWhereClause(g,r,o),h&&g.groupBy(h),c&&this._applyHavingClause(g,c);const m=void 0===s?this.helperUtility.resolveDefaultOrderBy(e):s;m&&this._applyOrderBy(g,m);let W=a;return l&&n>0&&(W=(Math.max(1,parseInt(l))-1)*n),n>0&&(g.limit(n),W>0&&g.offset(W)),g}async getQuery(e,t){try{const{where:r={},with:o,withWhere:i,limit:s=10,offset:n=0,page:a,join:l,leftJoin:h,rightJoin:c,innerJoin:u,groupBy:p,having:d,distinct:y}=t,f=this.buildSelectQuery(e,t);let g=!1;const m=s;let W=n,b=1,_=0;a&&s>0?(b=Math.max(1,parseInt(a)),W=(b-1)*s):W>0&&s>0&&(b=Math.floor(W/s)+1);const w=[],A=f.toSQL();w.push(A.sql);const k=await f;if(o&&o.length>0){const t=this.buildWithTree(o);for(const r of Object.keys(t))await this.fetchAndAttachRelated({parentRows:k,relName:r,model:e,withTree:t[r],relation:e.hasRelations[r],withWhere:i||{}})}let j=null;if(s>0)try{const t=this.getQueryBuilder(e);let o;r&&Object.keys(r).length>0&&this._applyWhereClause(t,r),l&&this._applyJoins(t,l,"join"),h&&this._applyJoins(t,h,"leftJoin"),c&&this._applyJoins(t,c,"rightJoin"),u&&this._applyJoins(t,u,"innerJoin"),y&&(Array.isArray(y)||"string"==typeof y?t.distinct(y):t.distinct()),p&&t.groupBy(p),d&&this._applyHavingClause(t,d),o=p||y||d?this.db.count("* as cnt").from(t.as("__count_sub")):t.count("* as cnt"),w.push(o.toSQL().sql);const i=await o.first();j=Number(i.cnt)||0}catch(e){logger.warn("Failed to get total count:",e.message),j=k.length}s>0&&null!==j&&(_=Math.ceil(j/s),g=b<_);const v=g?b+1:null,J=b>1?b-1:null;return{data:k,totalCount:j,...this.controllerWrapper.debug?{sqlDebug:w}:{},...s>0?{pagination:{page:b,limit:m,offset:W,totalPages:_,hasNext:g,hasPrev:b>1,nextPage:v,prevPage:J}}:{}}}catch(t){if(t instanceof KormError)throw t;throw logger.error("QueryService.getQuery error:",t),new Error(`Failed to execute query: ${t.message} on model ${e.name}`)}}getSumQuery(e,t){const{where:r={},join:o,leftJoin:i,rightJoin:s,innerJoin:n}=t,a=t.data||t,l=a.sumColumn,h=a.sumFormula;if(!l&&!h)throw KormError.validationFailed({errors:[{field:"data",message:"Sum action requires either data.sumColumn or data.sumFormula"}],source:"sum"});const c=e=>'"'+String(e).replace(/["\\]/g,"")+'"';let u;if(l){const e=String(l).trim().replace(/[^a-zA-Z0-9_]/g,"");if(!e)throw KormError.validationFailed({errors:[{field:"data.sumColumn",message:"data.sumColumn must be a valid column name"}],source:"sum"});u="SUM("+c(e)+")"}else{const e=String(h).trim();if(!/\{[a-zA-Z0-9_]+\}/.test(e))throw KormError.validationFailed({errors:[{field:"data.sumFormula",message:"data.sumFormula must contain at least one {columnName}"}],source:"sum"});let t=0;for(const r of e)if("("===r)t++;else if(")"===r&&(t--,t<0))break;if(0!==t)throw KormError.validationFailed({errors:[{field:"data.sumFormula",message:"data.sumFormula has unbalanced or misordered parentheses ( and )"}],source:"sum"});const r=e.replace(/\{[a-zA-Z0-9_]+\}/g,"@");if(!/^[\s0-9.+*\/\-()@]+$/.test(r))throw KormError.validationFailed({errors:[{field:"data.sumFormula",message:"data.sumFormula may only use BODMAS: numbers, + - * /, parentheses, and {columnName}"}],source:"sum"});u="SUM("+e.replace(/\{([a-zA-Z0-9_]+)\}/g,(e,t)=>c(t))+")"}const p=this.getQueryBuilder(e);return o&&this._applyJoins(p,o,"join"),i&&this._applyJoins(p,i,"leftJoin"),s&&this._applyJoins(p,s,"rightJoin"),n&&this._applyJoins(p,n,"innerJoin"),this._applyWhereClause(p,r,[]),p.select(this.db.raw(u+" as sum")),p}_applyWhereWithArray(e,t,r){for(const o of t)this._applyWhereClause(e,o,r)}_applyWhereClause(e,t,r=[]){if(Array.isArray(t))this._applyWhereWithArray(e,t,r);else{if(t&&Object.keys(t).length>0){let r=this.helperUtility.getDotWalkQuery(t);r=this.helperUtility.objectFilter(r,(e,t)=>!e.startsWith("!")&&("__exists__"!==e&&("object"!=typeof t||null===t||Array.isArray(t))));for(const[t,o]of Object.entries(r)){const{joinType:r="AND",column:i}=this.parseWhereColumn(t),{operator:s,value:n}=this.parseWhereValue(o);"AND"===r?this._applyAndWhereCondition(e,i,s,n):this._applyOrWhereCondition(e,i,s,n)}}this._applyNestedWhere(e,t,r)}}_getNestedWhereConditions(e,t){if(!e||"object"!=typeof e)return{};const r=`${t}.`,o={};for(const[i,s]of Object.entries(e))if(i.startsWith(r)){o[i.slice(r.length)]=s}else i===t&&!0===s&&(o.__exists__=!0);return o}_getTopLevelRelationsFromWhere(e){if(!e||"object"!=typeof e)return[];const t=new Set;for(const r of Object.keys(e))if(r.includes(".")){const e=r.split(".")[0];t.add(e)}else r.startsWith("!")&&t.add(r);return[...t]}_applyNestedWhere(e,t,r){const o=this,i=e._getMyModel(),s=this._getTopLevelRelationsFromWhere(t);if(0!==s.length)for(const r of s){const s=r.startsWith("!"),n=s?r.slice(1):r,a=this._getNestedWhereConditions(t,r);if(0===Object.keys(a).length)continue;const l=i.hasRelations?.[n];if(!l){logger.warn(`Relation ${n} not found in model ${i.modelName}`);continue}let h;try{h=this.utils.getModel(this.controllerWrapper,l.table||n)}catch(e){try{h=this.utils.getModel(this.controllerWrapper,n)}catch(e){logger.warn(`Model for relation ${n} (table: ${l.table}) not found`);continue}}e[s?"whereNotExists":"whereExists"](function(){const e=o.getQueryBuilder(h,this.select("*").from(l.table));e.whereRaw("??.?? = ??.??",[l.table,l.foreignKey,i.table,l.localKey]);if(!(!0===a.__exists__&&1===Object.keys(a).length)){const t={...a};delete t.__exists__,o._applyWhereClause(e,t,[])}})}}_applyJoins(e,t,r){const o=Array.isArray(t)?t:[t];for(const t of o)"string"==typeof t?e[r](t):"object"==typeof t&&(t.table&&t.on?e[r](t.table,t.on):t.table&&t.first&&t.operator&&t.second&&e[r](t.table,t.first,t.operator,t.second))}_applyHavingClause(e,t){for(const[r,o]of Object.entries(t))"object"==typeof o&&o.operator?e.having(r,o.operator,o.value):e.having(r,"=",o)}_applyOrderBy(e,t){if(Array.isArray(t))for(const r of t)this._applyOrderByEntry(e,r);else this._applyOrderByEntry(e,t)}_applyOrderByEntry(e,t){if("string"!=typeof t){if(t&&"object"==typeof t){if(!t.column)throw KormError.validationFailed({errors:[{field:"orderBy",message:`orderBy object must include a "column" key (got keys: ${Object.keys(t).join(", ")||"none"})`,value:t,rule:"required"}],source:"orderBy"});e.orderBy(t.column,t.direction||"asc")}}else e.orderBy(t)}}module.exports=QueryBuilder;
@@ -1 +1 @@
1
- const QueryBuilder=require("./QueryBuilder"),KormError=require("../../KormError");class QueryService{constructor(e,r,t=null){this.db=e,this.utils=r,this.controllerWrapper=t,this.queryBuilder=new QueryBuilder(e,r,t)}async getQuery(e,r){return await this.queryBuilder.getQuery(e,r)}async getSoftDeleteQuery(e,r){return await this.queryBuilder.getQuery(e,{...r,where:{...r.where||{},deleted_at:null}})}async executeShowQuery(e,r){const t=await this.getQuery(e,{...r,limit:1,offset:0});return t.data.length>0?t.data[0]:null}_buildCountQuery(e,r={}){const t=this.queryBuilder.getQueryBuilder(e),{join:u,leftJoin:i,rightJoin:n,innerJoin:a,where:s={}}=r;return u&&this.queryBuilder._applyJoins(t,u,"join"),i&&this.queryBuilder._applyJoins(t,i,"leftJoin"),n&&this.queryBuilder._applyJoins(t,n,"rightJoin"),a&&this.queryBuilder._applyJoins(t,a,"innerJoin"),this.queryBuilder._applyWhereClause(t,s,[]),t.count()}async executeCountQuery(e,r){const t=await this._buildCountQuery(e,r||{}),u=t&&t[0]?Object.values(t[0])[0]:0,i=Number(u);return Number.isNaN(i)?0:i}async executeSumQuery(e,r){const t=await this.queryBuilder.getSumQuery(e,r).first(),u=t&&null!=t.sum?t.sum:0;return Number(u)}async executeCreateQuery(e,r){return await this.db(e.table).insert(r.data).returning("*")}async executeUpdateQuery(e,r){return await this.db(e.table).where(r.where).update(r.data).returning("*")}async executeDeleteQuery(e,r){return await this.db(e.table).where(r.where).delete()}async executeSoftDeleteQuery(e,r){return await this.db(e.table).where(r.where).update({deleted_at:new Date}).returning("*")}async executeUpsertQuery(e,r){return await this.db(e.table).insert(r.data).onConflict(r.conflict).merge(r.data)}_primaryKeyColumns(e){return(Array.isArray(e.columns)?e.columns:[]).filter(e=>e&&e.primary).map(e=>e.name)}_buildReplaceQuery(e,r){const t=r.conflict||this._primaryKeyColumns(e);if(!t||0===t.length)throw KormError.validationFailed({errors:[{field:"conflict",message:"replace on Postgres needs a primary key on the model or an explicit `conflict` field",rule:"required"}],source:"replace"});return this.db(e.table).insert(r.data).onConflict(t).merge(r.data)}async executeReplaceQuery(e,r){return await this._buildReplaceQuery(e,r)}buildDryRun(e,r,t){const u=this._dryRunBuilders(e,r,t).map(e=>{const r=e.toSQL();return{sql:r.sql,bindings:r.bindings}});return{success:!0,dryRun:!0,action:t,model:e.name,sql:u[0]?u[0].sql:null,bindings:u[0]?u[0].bindings:[],statements:u}}_dryRunBuilders(e,r,t){const u=e.table,i=r.where||{};switch(t){case"list":return[this.queryBuilder.buildSelectQuery(e,r)];case"show":return[this.queryBuilder.buildSelectQuery(e,{...r,limit:1,offset:0})];case"count":return[this._buildCountQuery(e,r)];case"sum":return[this.queryBuilder.getSumQuery(e,r)];case"create":return[this.db(u).insert(r.data).returning("*")];case"update":return[this.db(u).where(i).update(r.data).returning("*")];case"softDelete":return[this.db(u).where(i).update({deleted_at:new Date}).returning("*")];case"delete":return[this.db(u).where(i).delete()];case"replace":return[this._buildReplaceQuery(e,r)];case"upsert":return[this.db(u).insert(r.data).onConflict(r.conflict).merge(r.data)];case"sync":return[this.db(u).insert(r.data).onConflict(r.conflict).merge(r.data),this.db(u).where(i).delete()];default:return[]}}async executeSyncQuery(e,r){return this.db.transaction(async t=>({insertOrUpdateQuery:await t(e.table).insert(r.data).onConflict(r.conflict).merge(r.data),deleteQuery:await t(e.table).where(r.where).delete()}))}}module.exports=QueryService;
1
+ const QueryBuilder=require("./QueryBuilder"),KormError=require("../../KormError");class QueryService{constructor(e,r,t=null){this.db=e,this.utils=r,this.controllerWrapper=t,this.queryBuilder=new QueryBuilder(e,r,t)}async getQuery(e,r){return await this.queryBuilder.getQuery(e,r)}async getSoftDeleteQuery(e,r){return await this.queryBuilder.getQuery(e,{...r,where:{...r.where||{},deleted_at:null}})}async executeShowQuery(e,r){const t=await this.getQuery(e,{...r,limit:1,offset:0});return t.data.length>0?t.data[0]:null}_buildCountQuery(e,r={}){const t=this.queryBuilder.getQueryBuilder(e),{join:u,leftJoin:i,rightJoin:l,innerJoin:n,where:a={}}=r;return u&&this.queryBuilder._applyJoins(t,u,"join"),i&&this.queryBuilder._applyJoins(t,i,"leftJoin"),l&&this.queryBuilder._applyJoins(t,l,"rightJoin"),n&&this.queryBuilder._applyJoins(t,n,"innerJoin"),this.queryBuilder._applyWhereClause(t,a,[]),t.count()}async executeCountQuery(e,r){const t=await this._buildCountQuery(e,r||{}),u=t&&t[0]?Object.values(t[0])[0]:0,i=Number(u);return Number.isNaN(i)?0:i}async executeSumQuery(e,r){const t=await this.queryBuilder.getSumQuery(e,r).first(),u=t&&null!=t.sum?t.sum:0;return Number(u)}async executeCreateQuery(e,r){return await this.db(e.table).insert(r.data).returning("*")}async executeUpdateQuery(e,r){const t=this.queryBuilder.getQueryBuilder(e);return this.queryBuilder._applyWhereClause(t,r.where||{},[]),await t.update(r.data).returning("*")}async executeDeleteQuery(e,r){const t=this.queryBuilder.getQueryBuilder(e);return this.queryBuilder._applyWhereClause(t,r.where||{},[]),await t.delete()}async executeSoftDeleteQuery(e,r){const t=this.queryBuilder.getQueryBuilder(e);return this.queryBuilder._applyWhereClause(t,r.where||{},[]),await t.update({deleted_at:new Date}).returning("*")}async executeRestoreQuery(e,r){const t=this.queryBuilder.getQueryBuilder(e);return this.queryBuilder._applyWhereClause(t,r.where||{},[]),await t.update({deleted_at:null}).returning("*")}async executeUpsertQuery(e,r){return await this.db(e.table).insert(r.data).onConflict(r.conflict).merge(r.data)}_primaryKeyColumns(e){return(Array.isArray(e.columns)?e.columns:[]).filter(e=>e&&e.primary).map(e=>e.name)}_buildReplaceQuery(e,r){const t=r.conflict||this._primaryKeyColumns(e);if(!t||0===t.length)throw KormError.validationFailed({errors:[{field:"conflict",message:"replace on Postgres needs a primary key on the model or an explicit `conflict` field",rule:"required"}],source:"replace"});return this.db(e.table).insert(r.data).onConflict(t).merge(r.data)}async executeReplaceQuery(e,r){return await this._buildReplaceQuery(e,r),r.data}buildDryRun(e,r,t){const u=this._dryRunBuilders(e,r,t).map(e=>{const r=e.toSQL();return{sql:r.sql,bindings:r.bindings}});return{success:!0,dryRun:!0,action:t,model:e.name,sql:u[0]?u[0].sql:null,bindings:u[0]?u[0].bindings:[],statements:u}}_dryRunBuilders(e,r,t){const u=e.table,i=r.where||{};switch(t){case"list":return[this.queryBuilder.buildSelectQuery(e,r)];case"show":return[this.queryBuilder.buildSelectQuery(e,{...r,limit:1,offset:0})];case"count":return[this._buildCountQuery(e,r)];case"sum":return[this.queryBuilder.getSumQuery(e,r)];case"create":return[this.db(u).insert(r.data).returning("*")];case"update":{const t=this.queryBuilder.getQueryBuilder(e);return this.queryBuilder._applyWhereClause(t,i,[]),[t.update(r.data).returning("*")]}case"softDelete":{const r=this.queryBuilder.getQueryBuilder(e);return this.queryBuilder._applyWhereClause(r,i,[]),[r.update({deleted_at:new Date}).returning("*")]}case"delete":{const r=this.queryBuilder.getQueryBuilder(e);return this.queryBuilder._applyWhereClause(r,i,[]),[r.delete()]}case"replace":return[this._buildReplaceQuery(e,r)];case"upsert":return[this.db(u).insert(r.data).onConflict(r.conflict).merge(r.data)];case"sync":{const t=this.queryBuilder.getQueryBuilder(e);return this.queryBuilder._applyWhereClause(t,i,[]),[this.db(u).insert(r.data).onConflict(r.conflict).merge(r.data),t.delete()]}case"restore":{const r=this.queryBuilder.getQueryBuilder(e);return this.queryBuilder._applyWhereClause(r,i,[]),[r.update({deleted_at:null}).returning("*")]}default:return[]}}async executeSyncQuery(e,r){return this.db.transaction(async t=>{const u=await t(e.table).insert(r.data).onConflict(r.conflict).merge(r.data),i=this.queryBuilder.getQueryBuilder(e,t(e.table));this.queryBuilder._applyWhereClause(i,r.where||{},[]);return{insertOrUpdateQuery:u,deleteQuery:await i.delete()}})}}module.exports=QueryService;
@@ -1 +1 @@
1
- const QueryService=require("./QueryService"),HookService=require("./HookService"),KormError=require("../../KormError");class CurdTable{constructor(e,r,t=null){if(this.db=e,this.utils=r,this.controllerWrapper=t,this.hookService=new HookService(e,r,t),this.queryService=new QueryService(e,r,t),!this.queryService)throw new KormError("CurdTable requires queryService (execute*Query / getQuery).",{code:KormError.CODES.INTERNAL})}async processRequest(e,r=null,t={}){const o=this.controllerWrapper,s=this.utils.getModel(this.controllerWrapper,r),c=e?.action||"list";let i=null;const a={model:s,action:c,request:e,ctx:t,controller:o};if(this.hookService?.executeValidatorHook&&await this.hookService.executeValidatorHook({...a}),e?.dryRun)return this.buildDryRunResult(s,e,c);switch(this.hookService?.executeBeforeHook&&(e.beforeActionData=await this.hookService.executeBeforeHook({...a})),c){case"count":i=await this.queryService.executeCountQuery(s,e);break;case"sum":i=await this.queryService.executeSumQuery(s,e);break;case"list":i=await this.hookService.executeHasSoftDeleteHook(s)?await this.queryService.getSoftDeleteQuery(s,e):await this.queryService.getQuery(s,e);break;case"show":i=await this.queryService.executeShowQuery(s,e);break;case"create":i=await this.queryService.executeCreateQuery(s,e);break;case"update":{const r=await this.queryService.executeUpdateQuery(s,e);if(!r)throw KormError.noMatchingRow({action:c,model:s.name});i={message:"Record updated successfully",data:r,success:!0};break}case"replace":if(i=await this.queryService.executeReplaceQuery(s,e),!i)throw KormError.noMatchingRow({action:c,model:s.name});i={message:"Record replaced successfully",data:i,success:!0};break;case"upsert":if(i=await this.queryService.executeUpsertQuery(s,e),!i)throw KormError.noMatchingRow({action:c,model:s.name});i={message:"Record upserted successfully",data:i,success:!0};break;case"sync":if(i=await this.queryService.executeSyncQuery(s,e),!i)throw KormError.noMatchingRow({action:c,model:s.name});i={message:"Record synced successfully",data:i,success:!0};break;case"delete":{let r=null;if(r=await this.hookService.executeHasSoftDeleteHook(s)?await this.queryService.executeSoftDeleteQuery(s,e):await this.queryService.executeDeleteQuery(s,e),!r)throw KormError.noMatchingRow({action:c,model:s.name});i={message:"Record deleted successfully",data:r,success:!0};break}default:if(!this.hookService?.executeCustomAction)throw KormError.unknownAction({action:c,model:s?.name});i=await this.hookService.executeCustomAction({...a})}if(this.hookService?.executeAfterHook&&(i=await this.hookService.executeAfterHook({...a,data:i})),e?.other_requests&&"object"==typeof e.other_requests){const r={},o=Object.entries(e.other_requests);for(const[e,s]of o)Array.isArray(s)?r[e]=await Promise.all(s.map(r=>this.processRequest(r,e,t))):r[e]=await this.processRequest(s,e,t);i.other_responses=r}return i}async buildDryRunResult(e,r,t){if(!["list","show","count","sum","create","update","replace","upsert","sync","delete"].includes(t))throw KormError.unknownAction({action:t,model:e?.name});let o=t,s=r;const c=await this.hookService.executeHasSoftDeleteHook(e);return c&&"delete"===t?o="softDelete":c&&"list"===t&&(s={...r,where:{...r.where||{},deleted_at:null}}),this.queryService.buildDryRun(e,s,o)}}module.exports=CurdTable;
1
+ const QueryService=require("./QueryService"),HookService=require("./HookService"),KormError=require("../../KormError"),{mapWithConcurrency:mapWithConcurrency,resolveFanOutConcurrency:resolveFanOutConcurrency}=require("../../ConnectionResilience");class CurdTable{constructor(e,r,t=null){if(this.db=e,this.utils=r,this.controllerWrapper=t,this.hookService=new HookService(e,r,t),this.queryService=new QueryService(e,r,t),!this.queryService)throw new KormError("CurdTable requires queryService (execute*Query / getQuery).",{code:KormError.CODES.INTERNAL})}async _injectSoftDeleteWhere(e,r,t="deleted_at"){return this.hookService?.executeHasSoftDeleteHook&&await this.hookService.executeHasSoftDeleteHook(e)&&(r={...r,where:{...r.where||{},[t]:null}}),r}async processRequest(e,r=null,t={},o=0){if(o>10)throw new KormError("other_requests nesting depth exceeded maximum (10)",{code:KormError.CODES.INTERNAL,context:{depth:o}});const c=this.controllerWrapper,s=this.utils.getModel(this.controllerWrapper,r),i=e?.action||"list";let a=null;if(new Set(["update","delete","softDelete"]).has(i)){const r=e?.where;if(!r||"object"==typeof r&&0===Object.keys(r).length)throw KormError.validationFailed({errors:[{field:"where",message:`where is required for action "${i}"`}],source:i})}const u={model:s,action:i,request:e,ctx:t,controller:c};if(this.hookService?.executeValidatorHook&&await this.hookService.executeValidatorHook({...u}),e?.dryRun)return this.buildDryRunResult(s,e,i);switch(this.hookService?.executeBeforeHook&&(e.beforeActionData=await this.hookService.executeBeforeHook({...u})),i){case"count":e=await this._injectSoftDeleteWhere(s,e),a=await this.queryService.executeCountQuery(s,e);break;case"sum":e=await this._injectSoftDeleteWhere(s,e),a=await this.queryService.executeSumQuery(s,e);break;case"list":a=await this.hookService.executeHasSoftDeleteHook(s)?await this.queryService.getSoftDeleteQuery(s,e):await this.queryService.getQuery(s,e);break;case"show":e=await this._injectSoftDeleteWhere(s,e),a=await this.queryService.executeShowQuery(s,e);break;case"create":a=await this.queryService.executeCreateQuery(s,e);break;case"update":{const r=await this.queryService.executeUpdateQuery(s,e);if(!(r&&r.length>0))throw KormError.noMatchingRow({action:i,model:s.name});a={message:"Record updated successfully",data:r,success:!0};break}case"replace":if(a=await this.queryService.executeReplaceQuery(s,e),!a)throw KormError.noMatchingRow({action:i,model:s.name});a={message:"Record replaced successfully",data:a,success:!0};break;case"upsert":if(a=await this.queryService.executeUpsertQuery(s,e),!a)throw KormError.noMatchingRow({action:i,model:s.name});a={message:"Record upserted successfully",data:a,success:!0};break;case"sync":if(a=await this.queryService.executeSyncQuery(s,e),!a)throw KormError.noMatchingRow({action:i,model:s.name});a={message:"Record synced successfully",data:a,success:!0};break;case"delete":{let r=null;if(r=await this.hookService.executeHasSoftDeleteHook(s)?await this.queryService.executeSoftDeleteQuery(s,e):await this.queryService.executeDeleteQuery(s,e),!r)throw KormError.noMatchingRow({action:i,model:s.name});a={message:"Record deleted successfully",data:r,success:!0};break}case"restore":{if(!await(this.hookService?.executeHasSoftDeleteHook?.(s)))throw KormError.unknownAction({action:i,model:s.name});const r=await this.queryService.executeRestoreQuery(s,e);if(!(r&&r.length>0))throw KormError.noMatchingRow({action:i,model:s.name});a={message:"Record restored successfully",data:r,success:!0};break}default:if(!this.hookService?.executeCustomAction)throw KormError.unknownAction({action:i,model:s?.name});a=await this.hookService.executeCustomAction({...u})}if(this.hookService?.executeAfterHook&&(a=await this.hookService.executeAfterHook({...u,data:a})),"object"==typeof a&&null!==a&&e?.other_requests&&"object"==typeof e.other_requests){const r={},c=Object.entries(e.other_requests),s=50;let i=0;for(const[,e]of c)i+=Array.isArray(e)?e.length:1;if(i>s)throw new KormError(`other_requests fan-out exceeded maximum (${s})`,{code:KormError.CODES.INTERNAL,context:{fanOut:i}});for(const[e,s]of c)if(this.controllerWrapper?._authz?.hasRules()&&this.controllerWrapper._authz.enforce(e,s?.action||"list",s,t),Array.isArray(s)){const c=resolveFanOutConcurrency(this.db,this.controllerWrapper?.fanOutConcurrency);r[e]=await mapWithConcurrency(s,c,r=>this.processRequest(r,e,t,o+1))}else r[e]=await this.processRequest(s,e,t,o+1);a.other_responses=r}return a}async buildDryRunResult(e,r,t){if(!["list","show","count","sum","create","update","replace","upsert","sync","delete","restore"].includes(t))throw KormError.unknownAction({action:t,model:e?.name});let o=t,c=r;const s=await this.hookService.executeHasSoftDeleteHook(e);if(s&&"delete"===t)o="softDelete";else if(s&&"list"===t)c={...r,where:{...r.where||{},deleted_at:null}};else if("restore"===t&&!s)throw KormError.unknownAction({action:t,model:e?.name});return this.queryService.buildDryRun(e,c,o)}}module.exports=CurdTable;
@@ -1 +1 @@
1
- const HelperUtility=require("./HelperUtility"),logger=require("../../Logger"),KormError=require("../../KormError");class QueryBuilder{constructor(e,t,r=null){this.db=e,this.utils=t,this.controllerWrapper=r,this.helperUtility=new HelperUtility}getHookService(){return this.controllerWrapper.hookService}getQueryBuilder(e,t=null){let r=this.db(e.table);return t&&(r=t),r._getMyModel=()=>e,r}parseValue(e){return this.helperUtility.parseValue(e)}parseWhereValue(e){return this.helperUtility.parseWhereValue(e)}parseWhereColumn(e){return this.helperUtility.parseWhereColumn(e)}_applyOrWhereCondition(e,t,r,o){if(null!==o)switch(r){case"between":e.orWhereBetween(t,o);break;case"notBetween":e.orWhereNotBetween(t,o);break;case"in":e.orWhereIn(t,o);break;case"notIn":e.orWhereNotIn(t,o);break;case"like":e.orWhere(t,"like",o);break;default:Array.isArray(o)?e.orWhereIn(t,o):e.orWhere(t,r,o)}else e.orWhereNull(t)}_applyAndWhereCondition(e,t,r,o){if(null!==o)switch(r){case"between":e.whereBetween(t,o);break;case"notBetween":e.whereNotBetween(t,o);break;case"in":e.whereIn(t,o);break;case"notIn":e.whereNotIn(t,o);break;case"like":e.where(t,"like",o);break;default:Array.isArray(o)?e.whereIn(t,o):e.where(t,r,o)}else e.whereNull(t)}buildWithTree(e,t=null){return this.helperUtility.dotWalkTree(e,{resolver:({current:e,part:r,source:o})=>t?t({current:e,part:r,source:o}):{}})}_getWithWhereForRelation(e,t){if(!e||"object"!=typeof e)return{direct:{},nested:{}};const r=`${t}.`,o={},i={};for(const[t,s]of Object.entries(e))if(t.startsWith(r)){const e=t.slice(r.length);e.includes(".")?i[e]=s:o[e]=s}return{direct:o,nested:i}}_applyWithWhereConditions(e,t){for(const[r,o]of Object.entries(t)){const{joinType:t="AND",column:i}=this.parseWhereColumn(r),{operator:s,value:n}=this.parseWhereValue(o);"AND"===t?this._applyAndWhereCondition(e,i,s,n):this._applyOrWhereCondition(e,i,s,n)}}async fetchRelatedRows(e,t,r={}){if(e.through){const o=await this.db(e.through).whereIn(e.throughLocalKey,t),i=this.db(e.table).whereIn(e.foreignKey,o.map(t=>t[e.throughForeignKey]));return Object.keys(r).length>0&&this._applyWithWhereConditions(i,r),i}{const o=this.db(e.table).whereIn(e.foreignKey,t);return Object.keys(r).length>0&&this._applyWithWhereConditions(o,r),o}}async fetchAndAttachRelated(e){const{parentRows:t,relName:r,model:o,withTree:i,relation:s,withWhere:n={}}=e;if(!s){const e=this.getHookService().getModelInstance(o),s=`get${r.charAt(0).toUpperCase()+r.slice(1)}Relation`;if("function"==typeof e[s]){const{direct:l,nested:a}=this._getWithWhereForRelation(n,r),h={rows:t,relName:r,model:o,withTree:i,controller:this.controllerWrapper,relation:o.hasRelations[r],qb:this,db:this.db,withWhere:l,nestedWithWhere:a};await e[s](h)}else logger.warn(`Method ${s} not found in model ${o.name}`);return t}const l=t.map(e=>e[s.localKey]),a="one"===s?.type;let h=[];const{direct:c,nested:p}=this._getWithWhereForRelation(n,r);let u={...c};try{const e=this.utils.getModel(this.controllerWrapper,r);if(this.controllerWrapper?.hookService){await this.controllerWrapper.hookService.executeHasSoftDeleteHook(e)&&(u={...u,deleted_at:null})}}catch(e){}h=await this.fetchRelatedRows(s,l,u);const y=new Map;for(const e of h){const t=e[s.foreignKey];y.has(t)||y.set(t,[]),y.get(t).push(e)}for(const e of t){const t=e[s.localKey];a&&1==y.get(t)?.length?e[r]=y.get(t)[0]:e[r]=y.get(t)||[]}const d=Object.keys(i);for(const e of d){const o=i[e],s=t.filter(e=>a?e[r]:e[r].length>0).map(e=>e[r]).reduce((e,t)=>e.concat(t),[]),n=this.utils.getModel(this.controllerWrapper,r);await this.fetchAndAttachRelated({parentRows:s,relName:e,model:n,withTree:o,relation:n.hasRelations[e],withWhere:p})}return t}filterWhere(e,t=""){return t?Object.keys(e).filter(e=>e.includes(t)).reduce((r,o)=>(r[o.replace(t,"")]=e[o],r),{}):Object.keys(e).filter(e=>!e.includes(".")).reduce((t,r)=>(t[r]=e[r],t),{})}buildSelectQuery(e,t){const{where:r={},with:o,select:i,orderBy:s,limit:n=10,offset:l=0,page:a,groupBy:h,having:c,distinct:p,join:u,leftJoin:y,rightJoin:d,innerJoin:f}=t,g=this.getQueryBuilder(e);i&&(Array.isArray(i)||"string"==typeof i)?g.select(i):g.select("*"),p&&(Array.isArray(p)||"string"==typeof p?g.distinct(p):g.distinct()),u&&this._applyJoins(g,u,"join"),y&&this._applyJoins(g,y,"leftJoin"),d&&this._applyJoins(g,d,"rightJoin"),f&&this._applyJoins(g,f,"innerJoin"),this._applyWhereClause(g,r,o),h&&g.groupBy(h),c&&this._applyHavingClause(g,c);const W=void 0===s?this.helperUtility.resolveDefaultOrderBy(e):s;W&&this._applyOrderBy(g,W);let w=l;return a&&n>0&&(w=(Math.max(1,parseInt(a))-1)*n),n>0&&(g.limit(n),w>0&&g.offset(w)),g}async getQuery(e,t){try{const{where:r={},with:o,withWhere:i,limit:s=10,offset:n=0,page:l,join:a,leftJoin:h,rightJoin:c,innerJoin:p}=t,u=this.buildSelectQuery(e,t);let y=!1;const d=s;let f=n,g=1,W=0;l&&s>0&&(g=Math.max(1,parseInt(l)),f=(g-1)*s);const w=[],m=u.toSQL();w.push(m.sql);const b=await u;if(o&&o.length>0){const t=this.buildWithTree(o);for(const r of Object.keys(t))await this.fetchAndAttachRelated({parentRows:b,relName:r,model:e,withTree:t[r],relation:e.hasRelations[r],withWhere:i||{}})}let _=null;if(s>0)try{const t=this.getQueryBuilder(e);r&&Object.keys(r).length>0&&this._applyWhereClause(t,r),a&&this._applyJoins(t,a,"join"),h&&this._applyJoins(t,h,"leftJoin"),c&&this._applyJoins(t,c,"rightJoin"),p&&this._applyJoins(t,p,"innerJoin");const o=t.count("* as cnt");w.push(o.toSQL().sql);_=(await o.first()).cnt}catch(e){logger.warn("Failed to get total count:",e.message),_=b.length}s>0&&null!==_&&(W=Math.ceil(_/s),y=g<W);const j=y?g+1:null,k=g>1?g-1:null;return{data:b,totalCount:_,...this.controllerWrapper.debug?{sqlDebug:w}:{},...s>0?{pagination:{page:g,limit:d,offset:f,totalPages:W,hasNext:y,hasPrev:g>1,nextPage:j,prevPage:k}}:{}}}catch(t){throw logger.error("QueryService.getQuery error:",t),new Error(`Failed to execute query: ${t.message} on model ${e.name}`)}}getSumQuery(e,t){const{where:r={},join:o,leftJoin:i,rightJoin:s,innerJoin:n}=t,l=t.data||t,a=l.sumColumn,h=l.sumFormula;if(!a&&!h)throw new Error("Sum action requires either data.sumColumn or data.sumFormula");const c=e=>'"'+String(e).replace(/["\\]/g,"")+'"';let p;if(a){const e=String(a).trim().replace(/[^a-zA-Z0-9_]/g,"");if(!e)throw new Error("data.sumColumn must be a valid column name");p="SUM("+c(e)+")"}else{const e=String(h).trim();if(!/\{[a-zA-Z0-9_]+\}/.test(e))throw new Error("data.sumFormula must contain at least one {columnName}");let t=0;for(const r of e)if("("===r)t++;else if(")"===r&&(t--,t<0))break;if(0!==t)throw new Error("data.sumFormula has unbalanced or misordered parentheses ( and )");const r=e.replace(/\{[a-zA-Z0-9_]+\}/g,"@");if(!/^[\s0-9.+*\/\-()@]+$/.test(r))throw new Error("data.sumFormula may only use BODMAS: numbers, + - * /, parentheses, and {columnName}");p="SUM("+e.replace(/\{([a-zA-Z0-9_]+)\}/g,(e,t)=>c(t))+")"}const u=this.getQueryBuilder(e);return o&&this._applyJoins(u,o,"join"),i&&this._applyJoins(u,i,"leftJoin"),s&&this._applyJoins(u,s,"rightJoin"),n&&this._applyJoins(u,n,"innerJoin"),this._applyWhereClause(u,r,[]),u.select(this.db.raw(p+" as sum")),u}_applyWhereWithArray(e,t,r){for(const o of t)this._applyWhereClause(e,o,r)}_applyWhereClause(e,t,r=[]){if(Array.isArray(t))this._applyWhereWithArray(e,t,r);else{if(t&&Object.keys(t).length>0){let r=this.helperUtility.getDotWalkQuery(t);r=this.helperUtility.objectFilter(r,(e,t)=>!e.startsWith("!")&&("__exists__"!==e&&("object"!=typeof t||null===t)));for(const[t,o]of Object.entries(r)){const{joinType:r="AND",column:i}=this.parseWhereColumn(t),{operator:s,value:n}=this.parseWhereValue(o);"AND"===r?this._applyAndWhereCondition(e,i,s,n):this._applyOrWhereCondition(e,i,s,n)}}this._applyNestedWhere(e,t,r)}}_getNestedWhereConditions(e,t){if(!e||"object"!=typeof e)return{};const r=`${t}.`,o={};for(const[i,s]of Object.entries(e))if(i.startsWith(r)){o[i.slice(r.length)]=s}else i===t&&!0===s&&(o.__exists__=!0);return o}_getTopLevelRelationsFromWhere(e){if(!e||"object"!=typeof e)return[];const t=new Set;for(const r of Object.keys(e))if(r.includes(".")){const e=r.split(".")[0];t.add(e)}else r.startsWith("!")&&t.add(r);return[...t]}_applyNestedWhere(e,t,r){const o=this,i=e._getMyModel(),s=this._getTopLevelRelationsFromWhere(t);if(0!==s.length)for(const r of s){const s=r.startsWith("!"),n=s?r.slice(1):r,l=this._getNestedWhereConditions(t,r);if(0===Object.keys(l).length)continue;const a=i.hasRelations?.[n];if(!a){logger.warn(`Relation ${n} not found in model ${i.modelName}`);continue}let h;try{h=this.utils.getModel(this.controllerWrapper,a.table||n)}catch(e){try{h=this.utils.getModel(this.controllerWrapper,n)}catch(e){logger.warn(`Model for relation ${n} (table: ${a.table}) not found`);continue}}e[s?"whereNotExists":"whereExists"](function(){const e=o.getQueryBuilder(h,this.select("*").from(a.table));e.whereRaw("??.?? = ??.??",[a.table,a.foreignKey,i.table,a.localKey]);if(!(!0===l.__exists__&&1===Object.keys(l).length)){const t={...l};delete t.__exists__,o._applyWhereClause(e,t,[])}})}}_applyJoins(e,t,r){const o=Array.isArray(t)?t:[t];for(const t of o)"string"==typeof t?e[r](t):"object"==typeof t&&(t.table&&t.on?e[r](t.table,t.on):t.table&&t.first&&t.operator&&t.second&&e[r](t.table,t.first,t.operator,t.second))}_applyWithWhere(e,t){try{if(Array.isArray(t))for(const r of t)"string"==typeof r?e.withWhere(r):"object"==typeof r&&e.withWhere(r.column,r.operator,r.value);else if("object"==typeof t)for(const[r,o]of Object.entries(t))e.withWhere(r,o)}catch(e){logger.warn("Failed to apply withWhere:",e.message)}}_applyHavingClause(e,t){for(const[r,o]of Object.entries(t))"object"==typeof o&&o.operator?e.having(r,o.operator,o.value):e.having(r,o)}_applyOrderBy(e,t){if(Array.isArray(t))for(const r of t)this._applyOrderByEntry(e,r);else this._applyOrderByEntry(e,t)}_applyOrderByEntry(e,t){if("string"!=typeof t){if(t&&"object"==typeof t){if(!t.column)throw KormError.validationFailed({errors:[{field:"orderBy",message:`orderBy object must include a "column" key (got keys: ${Object.keys(t).join(", ")||"none"})`,value:t,rule:"required"}],source:"orderBy"});e.orderBy(t.column,t.direction||"asc")}}else e.orderBy(t)}}module.exports=QueryBuilder;
1
+ const HelperUtility=require("./HelperUtility"),logger=require("../../Logger"),KormError=require("../../KormError");class QueryBuilder{constructor(e,t,r=null){this.db=e,this.utils=t,this.controllerWrapper=r,this.helperUtility=new HelperUtility}getHookService(){return this.controllerWrapper.hookService}getQueryBuilder(e,t=null){let r=this.db(e.table);return t&&(r=t),r._getMyModel=()=>e,r}parseValue(e){return this.helperUtility.parseValue(e)}parseWhereValue(e){return this.helperUtility.parseWhereValue(e)}parseWhereColumn(e){return this.helperUtility.parseWhereColumn(e)}_applyOrWhereCondition(e,t,r,o){if(null!==o)switch(r){case"between":e.orWhereBetween(t,o);break;case"notBetween":e.orWhereNotBetween(t,o);break;case"in":e.orWhereIn(t,o);break;case"notIn":e.orWhereNotIn(t,o);break;case"like":e.orWhere(t,"like",o);break;default:Array.isArray(o)?e.orWhereIn(t,o):e.orWhere(t,r,o)}else e.orWhereNull(t)}_applyAndWhereCondition(e,t,r,o){if(null!==o)switch(r){case"between":e.whereBetween(t,o);break;case"notBetween":e.whereNotBetween(t,o);break;case"in":e.whereIn(t,o);break;case"notIn":e.whereNotIn(t,o);break;case"like":e.where(t,"like",o);break;default:Array.isArray(o)?e.whereIn(t,o):e.where(t,r,o)}else e.whereNull(t)}buildWithTree(e,t=null){return this.helperUtility.dotWalkTree(e,{resolver:({current:e,part:r,source:o})=>t?t({current:e,part:r,source:o}):{}})}_getWithWhereForRelation(e,t){if(!e||"object"!=typeof e)return{direct:{},nested:{}};const r=`${t}.`,o={},i={};for(const[t,s]of Object.entries(e))if(t.startsWith(r)){const e=t.slice(r.length);e.includes(".")?i[e]=s:o[e]=s}return{direct:o,nested:i}}_applyWithWhereConditions(e,t){for(const[r,o]of Object.entries(t)){const{joinType:t="AND",column:i}=this.parseWhereColumn(r),{operator:s,value:n}=this.parseWhereValue(o);"AND"===t?this._applyAndWhereCondition(e,i,s,n):this._applyOrWhereCondition(e,i,s,n)}}async fetchRelatedRows(e,t,r={}){if(e.through){const o=await this.db(e.through).whereIn(e.throughLocalKey,t),i=this.db(e.table).whereIn(e.foreignKey,o.map(t=>t[e.throughForeignKey]));return Object.keys(r).length>0&&this._applyWithWhereConditions(i,r),i}{const o=this.db(e.table).whereIn(e.foreignKey,t);return Object.keys(r).length>0&&this._applyWithWhereConditions(o,r),o}}async fetchAndAttachRelated(e){const{parentRows:t,relName:r,model:o,withTree:i,relation:s,withWhere:n={}}=e;if(!s){const e=this.getHookService().getModelInstance(o),s=`get${r.charAt(0).toUpperCase()+r.slice(1)}Relation`;if("function"==typeof e[s]){const{direct:a,nested:l}=this._getWithWhereForRelation(n,r),h={rows:t,relName:r,model:o,withTree:i,controller:this.controllerWrapper,relation:o.hasRelations[r],qb:this,db:this.db,withWhere:a,nestedWithWhere:l};await e[s](h)}else logger.warn(`Method ${s} not found in model ${o.name}`);return t}const a=t.map(e=>e[s.localKey]),l="one"===s?.type;let h=[];const{direct:c,nested:u}=this._getWithWhereForRelation(n,r);let p={...c};try{const e=this.utils.getModel(this.controllerWrapper,r);if(this.controllerWrapper?.hookService){await this.controllerWrapper.hookService.executeHasSoftDeleteHook(e)&&(p={...p,deleted_at:null})}}catch(e){}h=await this.fetchRelatedRows(s,a,p);const d=new Map;for(const e of h){const t=e[s.foreignKey];d.has(t)||d.set(t,[]),d.get(t).push(e)}for(const e of t){const t=e[s.localKey];l&&1==d.get(t)?.length?e[r]=d.get(t)[0]:e[r]=d.get(t)||[]}const y=Object.keys(i);for(const e of y){const o=i[e],s=t.filter(e=>l?e[r]:e[r].length>0).map(e=>e[r]).reduce((e,t)=>e.concat(t),[]),n=this.utils.getModel(this.controllerWrapper,r);await this.fetchAndAttachRelated({parentRows:s,relName:e,model:n,withTree:o,relation:n.hasRelations[e],withWhere:u})}return t}filterWhere(e,t=""){return t?Object.keys(e).filter(e=>e.includes(t)).reduce((r,o)=>(r[o.replace(t,"")]=e[o],r),{}):Object.keys(e).filter(e=>!e.includes(".")).reduce((t,r)=>(t[r]=e[r],t),{})}buildSelectQuery(e,t){const{where:r={},with:o,select:i,orderBy:s,limit:n=10,offset:a=0,page:l,groupBy:h,having:c,distinct:u,join:p,leftJoin:d,rightJoin:y,innerJoin:f}=t,g=this.getQueryBuilder(e);i&&(Array.isArray(i)||"string"==typeof i)?g.select(i):g.select("*"),u&&(Array.isArray(u)||"string"==typeof u?g.distinct(u):g.distinct()),p&&this._applyJoins(g,p,"join"),d&&this._applyJoins(g,d,"leftJoin"),y&&this._applyJoins(g,y,"rightJoin"),f&&this._applyJoins(g,f,"innerJoin"),this._applyWhereClause(g,r,o),h&&g.groupBy(h),c&&this._applyHavingClause(g,c);const m=void 0===s?this.helperUtility.resolveDefaultOrderBy(e):s;m&&this._applyOrderBy(g,m);let W=a;return l&&n>0&&(W=(Math.max(1,parseInt(l))-1)*n),n>0&&(g.limit(n),W>0&&g.offset(W)),g}async getQuery(e,t){try{const{where:r={},with:o,withWhere:i,limit:s=10,offset:n=0,page:a,join:l,leftJoin:h,rightJoin:c,innerJoin:u,groupBy:p,having:d,distinct:y}=t,f=this.buildSelectQuery(e,t);let g=!1;const m=s;let W=n,b=1,_=0;a&&s>0?(b=Math.max(1,parseInt(a)),W=(b-1)*s):W>0&&s>0&&(b=Math.floor(W/s)+1);const w=[],A=f.toSQL();w.push(A.sql);const k=await f;if(o&&o.length>0){const t=this.buildWithTree(o);for(const r of Object.keys(t))await this.fetchAndAttachRelated({parentRows:k,relName:r,model:e,withTree:t[r],relation:e.hasRelations[r],withWhere:i||{}})}let j=null;if(s>0)try{const t=this.getQueryBuilder(e);let o;r&&Object.keys(r).length>0&&this._applyWhereClause(t,r),l&&this._applyJoins(t,l,"join"),h&&this._applyJoins(t,h,"leftJoin"),c&&this._applyJoins(t,c,"rightJoin"),u&&this._applyJoins(t,u,"innerJoin"),y&&(Array.isArray(y)||"string"==typeof y?t.distinct(y):t.distinct()),p&&t.groupBy(p),d&&this._applyHavingClause(t,d),o=p||y||d?this.db.count("* as cnt").from(t.as("__count_sub")):t.count("* as cnt"),w.push(o.toSQL().sql);const i=await o.first();j=Number(i.cnt)||0}catch(e){logger.warn("Failed to get total count:",e.message),j=k.length}s>0&&null!==j&&(_=Math.ceil(j/s),g=b<_);const v=g?b+1:null,J=b>1?b-1:null;return{data:k,totalCount:j,...this.controllerWrapper.debug?{sqlDebug:w}:{},...s>0?{pagination:{page:b,limit:m,offset:W,totalPages:_,hasNext:g,hasPrev:b>1,nextPage:v,prevPage:J}}:{}}}catch(t){if(t instanceof KormError)throw t;throw logger.error("QueryService.getQuery error:",t),new Error(`Failed to execute query: ${t.message} on model ${e.name}`)}}getSumQuery(e,t){const{where:r={},join:o,leftJoin:i,rightJoin:s,innerJoin:n}=t,a=t.data||t,l=a.sumColumn,h=a.sumFormula;if(!l&&!h)throw KormError.validationFailed({errors:[{field:"data",message:"Sum action requires either data.sumColumn or data.sumFormula"}],source:"sum"});const c=e=>'"'+String(e).replace(/["\\]/g,"")+'"';let u;if(l){const e=String(l).trim().replace(/[^a-zA-Z0-9_]/g,"");if(!e)throw KormError.validationFailed({errors:[{field:"data.sumColumn",message:"data.sumColumn must be a valid column name"}],source:"sum"});u="SUM("+c(e)+")"}else{const e=String(h).trim();if(!/\{[a-zA-Z0-9_]+\}/.test(e))throw KormError.validationFailed({errors:[{field:"data.sumFormula",message:"data.sumFormula must contain at least one {columnName}"}],source:"sum"});let t=0;for(const r of e)if("("===r)t++;else if(")"===r&&(t--,t<0))break;if(0!==t)throw KormError.validationFailed({errors:[{field:"data.sumFormula",message:"data.sumFormula has unbalanced or misordered parentheses ( and )"}],source:"sum"});const r=e.replace(/\{[a-zA-Z0-9_]+\}/g,"@");if(!/^[\s0-9.+*\/\-()@]+$/.test(r))throw KormError.validationFailed({errors:[{field:"data.sumFormula",message:"data.sumFormula may only use BODMAS: numbers, + - * /, parentheses, and {columnName}"}],source:"sum"});u="SUM("+e.replace(/\{([a-zA-Z0-9_]+)\}/g,(e,t)=>c(t))+")"}const p=this.getQueryBuilder(e);return o&&this._applyJoins(p,o,"join"),i&&this._applyJoins(p,i,"leftJoin"),s&&this._applyJoins(p,s,"rightJoin"),n&&this._applyJoins(p,n,"innerJoin"),this._applyWhereClause(p,r,[]),p.select(this.db.raw(u+" as sum")),p}_applyWhereWithArray(e,t,r){for(const o of t)this._applyWhereClause(e,o,r)}_applyWhereClause(e,t,r=[]){if(Array.isArray(t))this._applyWhereWithArray(e,t,r);else{if(t&&Object.keys(t).length>0){let r=this.helperUtility.getDotWalkQuery(t);r=this.helperUtility.objectFilter(r,(e,t)=>!e.startsWith("!")&&("__exists__"!==e&&("object"!=typeof t||null===t||Array.isArray(t))));for(const[t,o]of Object.entries(r)){const{joinType:r="AND",column:i}=this.parseWhereColumn(t),{operator:s,value:n}=this.parseWhereValue(o);"AND"===r?this._applyAndWhereCondition(e,i,s,n):this._applyOrWhereCondition(e,i,s,n)}}this._applyNestedWhere(e,t,r)}}_getNestedWhereConditions(e,t){if(!e||"object"!=typeof e)return{};const r=`${t}.`,o={};for(const[i,s]of Object.entries(e))if(i.startsWith(r)){o[i.slice(r.length)]=s}else i===t&&!0===s&&(o.__exists__=!0);return o}_getTopLevelRelationsFromWhere(e){if(!e||"object"!=typeof e)return[];const t=new Set;for(const r of Object.keys(e))if(r.includes(".")){const e=r.split(".")[0];t.add(e)}else r.startsWith("!")&&t.add(r);return[...t]}_applyNestedWhere(e,t,r){const o=this,i=e._getMyModel(),s=this._getTopLevelRelationsFromWhere(t);if(0!==s.length)for(const r of s){const s=r.startsWith("!"),n=s?r.slice(1):r,a=this._getNestedWhereConditions(t,r);if(0===Object.keys(a).length)continue;const l=i.hasRelations?.[n];if(!l){logger.warn(`Relation ${n} not found in model ${i.modelName}`);continue}let h;try{h=this.utils.getModel(this.controllerWrapper,l.table||n)}catch(e){try{h=this.utils.getModel(this.controllerWrapper,n)}catch(e){logger.warn(`Model for relation ${n} (table: ${l.table}) not found`);continue}}e[s?"whereNotExists":"whereExists"](function(){const e=o.getQueryBuilder(h,this.select("*").from(l.table));e.whereRaw("??.?? = ??.??",[l.table,l.foreignKey,i.table,l.localKey]);if(!(!0===a.__exists__&&1===Object.keys(a).length)){const t={...a};delete t.__exists__,o._applyWhereClause(e,t,[])}})}}_applyJoins(e,t,r){const o=Array.isArray(t)?t:[t];for(const t of o)"string"==typeof t?e[r](t):"object"==typeof t&&(t.table&&t.on?e[r](t.table,t.on):t.table&&t.first&&t.operator&&t.second&&e[r](t.table,t.first,t.operator,t.second))}_applyHavingClause(e,t){for(const[r,o]of Object.entries(t))"object"==typeof o&&o.operator?e.having(r,o.operator,o.value):e.having(r,"=",o)}_applyOrderBy(e,t){if(Array.isArray(t))for(const r of t)this._applyOrderByEntry(e,r);else this._applyOrderByEntry(e,t)}_applyOrderByEntry(e,t){if("string"!=typeof t){if(t&&"object"==typeof t){if(!t.column)throw KormError.validationFailed({errors:[{field:"orderBy",message:`orderBy object must include a "column" key (got keys: ${Object.keys(t).join(", ")||"none"})`,value:t,rule:"required"}],source:"orderBy"});e.orderBy(t.column,t.direction||"asc")}}else e.orderBy(t)}}module.exports=QueryBuilder;
@@ -1 +1 @@
1
- const QueryBuilder=require("./QueryBuilder");class QueryService{constructor(e,t,r=null){this.db=e,this.utils=t,this.controllerWrapper=r,this.queryBuilder=new QueryBuilder(e,t,r)}async getQuery(e,t){return await this.queryBuilder.getQuery(e,t)}async getSoftDeleteQuery(e,t){return await this.queryBuilder.getQuery(e,{...t,where:{...t.where||{},deleted_at:null}})}async executeShowQuery(e,t){const r=await this.getQuery(e,{...t,limit:1,offset:0});return r.data.length>0?r.data[0]:null}_buildCountQuery(e,t={}){const r=this.queryBuilder.getQueryBuilder(e),{join:u,leftJoin:i,rightJoin:n,innerJoin:a,where:s={}}=t;return u&&this.queryBuilder._applyJoins(r,u,"join"),i&&this.queryBuilder._applyJoins(r,i,"leftJoin"),n&&this.queryBuilder._applyJoins(r,n,"rightJoin"),a&&this.queryBuilder._applyJoins(r,a,"innerJoin"),this.queryBuilder._applyWhereClause(r,s,[]),r.count()}async executeCountQuery(e,t){const r=await this._buildCountQuery(e,t||{}),u=r&&r[0]?Object.values(r[0])[0]:0,i=Number(u);return Number.isNaN(i)?0:i}async executeSumQuery(e,t){const r=await this.queryBuilder.getSumQuery(e,t).first(),u=r&&null!=r.sum?r.sum:0;return Number(u)}async executeCreateQuery(e,t){return await this.db(e.table).insert(t.data).returning("*")}async executeUpdateQuery(e,t){return await this.db(e.table).where(t.where).update(t.data).returning("*")}async executeDeleteQuery(e,t){return await this.db(e.table).where(t.where).delete()}async executeSoftDeleteQuery(e,t){return await this.db(e.table).where(t.where).update({deleted_at:new Date}).returning("*")}async executeUpsertQuery(e,t){return await this.db(e.table).insert(t.data).onConflict(t.conflict).merge(t.data)}_buildReplaceQuery(e,t){const{sql:r,bindings:u}=this.db(e.table).insert(t.data).toSQL();return this.db.raw(r.replace(/^\s*insert/i,"INSERT OR REPLACE"),u)}async executeReplaceQuery(e,t){return await this._buildReplaceQuery(e,t),t.data}buildDryRun(e,t,r){const u=this._dryRunBuilders(e,t,r).map(e=>{const t=e.toSQL();return{sql:t.sql,bindings:t.bindings}});return{success:!0,dryRun:!0,action:r,model:e.name,sql:u[0]?u[0].sql:null,bindings:u[0]?u[0].bindings:[],statements:u}}_dryRunBuilders(e,t,r){const u=e.table,i=t.where||{};switch(r){case"list":return[this.queryBuilder.buildSelectQuery(e,t)];case"show":return[this.queryBuilder.buildSelectQuery(e,{...t,limit:1,offset:0})];case"count":return[this._buildCountQuery(e,t)];case"sum":return[this.queryBuilder.getSumQuery(e,t)];case"create":return[this.db(u).insert(t.data).returning("*")];case"update":return[this.db(u).where(i).update(t.data).returning("*")];case"softDelete":return[this.db(u).where(i).update({deleted_at:new Date}).returning("*")];case"delete":return[this.db(u).where(i).delete()];case"replace":return[this._buildReplaceQuery(e,t)];case"upsert":return[this.db(u).insert(t.data).onConflict(t.conflict).merge(t.data)];case"sync":return[this.db(u).insert(t.data).onConflict(t.conflict).merge(t.data),this.db(u).where(i).delete()];default:return[]}}async executeSyncQuery(e,t){return this.db.transaction(async r=>({insertOrUpdateQuery:await r(e.table).insert(t.data).onConflict(t.conflict).merge(t.data),deleteQuery:await r(e.table).where(t.where).delete()}))}}module.exports=QueryService;
1
+ const QueryBuilder=require("./QueryBuilder");class QueryService{constructor(e,r,t=null){this.db=e,this.utils=r,this.controllerWrapper=t,this.queryBuilder=new QueryBuilder(e,r,t)}async getQuery(e,r){return await this.queryBuilder.getQuery(e,r)}async getSoftDeleteQuery(e,r){return await this.queryBuilder.getQuery(e,{...r,where:{...r.where||{},deleted_at:null}})}async executeShowQuery(e,r){const t=await this.getQuery(e,{...r,limit:1,offset:0});return t.data.length>0?t.data[0]:null}_buildCountQuery(e,r={}){const t=this.queryBuilder.getQueryBuilder(e),{join:u,leftJoin:i,rightJoin:s,innerJoin:n,where:a={}}=r;return u&&this.queryBuilder._applyJoins(t,u,"join"),i&&this.queryBuilder._applyJoins(t,i,"leftJoin"),s&&this.queryBuilder._applyJoins(t,s,"rightJoin"),n&&this.queryBuilder._applyJoins(t,n,"innerJoin"),this.queryBuilder._applyWhereClause(t,a,[]),t.count()}async executeCountQuery(e,r){const t=await this._buildCountQuery(e,r||{}),u=t&&t[0]?Object.values(t[0])[0]:0,i=Number(u);return Number.isNaN(i)?0:i}async executeSumQuery(e,r){const t=await this.queryBuilder.getSumQuery(e,r).first(),u=t&&null!=t.sum?t.sum:0;return Number(u)}async executeCreateQuery(e,r){return await this.db(e.table).insert(r.data).returning("*")}async executeUpdateQuery(e,r){const t=this.queryBuilder.getQueryBuilder(e);return this.queryBuilder._applyWhereClause(t,r.where||{},[]),await t.update(r.data).returning("*")}async executeDeleteQuery(e,r){const t=this.queryBuilder.getQueryBuilder(e);return this.queryBuilder._applyWhereClause(t,r.where||{},[]),await t.delete()}async executeSoftDeleteQuery(e,r){const t=this.queryBuilder.getQueryBuilder(e);return this.queryBuilder._applyWhereClause(t,r.where||{},[]),await t.update({deleted_at:new Date}).returning("*")}async executeRestoreQuery(e,r){const t=this.queryBuilder.getQueryBuilder(e);return this.queryBuilder._applyWhereClause(t,r.where||{},[]),await t.update({deleted_at:null}).returning("*")}async executeUpsertQuery(e,r){return await this.db(e.table).insert(r.data).onConflict(r.conflict).merge(r.data)}_buildReplaceQuery(e,r){const{sql:t,bindings:u}=this.db(e.table).insert(r.data).toSQL();return this.db.raw(t.replace(/^\s*insert/i,"INSERT OR REPLACE"),u)}async executeReplaceQuery(e,r){return await this._buildReplaceQuery(e,r),r.data}buildDryRun(e,r,t){const u=this._dryRunBuilders(e,r,t).map(e=>{const r=e.toSQL();return{sql:r.sql,bindings:r.bindings}});return{success:!0,dryRun:!0,action:t,model:e.name,sql:u[0]?u[0].sql:null,bindings:u[0]?u[0].bindings:[],statements:u}}_dryRunBuilders(e,r,t){const u=e.table,i=r.where||{};switch(t){case"list":return[this.queryBuilder.buildSelectQuery(e,r)];case"show":return[this.queryBuilder.buildSelectQuery(e,{...r,limit:1,offset:0})];case"count":return[this._buildCountQuery(e,r)];case"sum":return[this.queryBuilder.getSumQuery(e,r)];case"create":return[this.db(u).insert(r.data).returning("*")];case"update":{const t=this.queryBuilder.getQueryBuilder(e);return this.queryBuilder._applyWhereClause(t,i,[]),[t.update(r.data).returning("*")]}case"softDelete":{const r=this.queryBuilder.getQueryBuilder(e);return this.queryBuilder._applyWhereClause(r,i,[]),[r.update({deleted_at:new Date}).returning("*")]}case"delete":{const r=this.queryBuilder.getQueryBuilder(e);return this.queryBuilder._applyWhereClause(r,i,[]),[r.delete()]}case"replace":return[this._buildReplaceQuery(e,r)];case"upsert":return[this.db(u).insert(r.data).onConflict(r.conflict).merge(r.data)];case"sync":{const t=this.queryBuilder.getQueryBuilder(e);return this.queryBuilder._applyWhereClause(t,i,[]),[this.db(u).insert(r.data).onConflict(r.conflict).merge(r.data),t.delete()]}case"restore":{const r=this.queryBuilder.getQueryBuilder(e);return this.queryBuilder._applyWhereClause(r,i,[]),[r.update({deleted_at:null}).returning("*")]}default:return[]}}async executeSyncQuery(e,r){return this.db.transaction(async t=>{const u=await t(e.table).insert(r.data).onConflict(r.conflict).merge(r.data),i=this.queryBuilder.getQueryBuilder(e,t(e.table));this.queryBuilder._applyWhereClause(i,r.where||{},[]);return{insertOrUpdateQuery:u,deleteQuery:await i.delete()}})}}module.exports=QueryService;
@@ -1 +1 @@
1
- const BaseSyncTable=require("../BaseSyncTable"),logger=require("../../Logger");class SQLiteSyncTable extends BaseSyncTable{_getClientName(){return"sqlite"}async _listTables(){return await this.db("sqlite_master").where({type:"table"}).whereNot("name","like","sqlite_%").pluck("name")}_supportsUnsigned(){return!1}async _applyExtras(e){for(const[t,s]of Object.entries(e.columns)){("string"==typeof s?this.utils.formatColumnSchema(t,s):s).onUpdate&&this._warnOnUnsupportedModifier("onUpdate",e.table,t)}}async _getRelations(e){return{}}async getTablesOfDatabase(){return this._listTables()}async executeSql(e,t=[]){return logger.debug("executeSql (legacy)",{sql:e,params:t}),this.db.raw(e,t)}}module.exports=SQLiteSyncTable;
1
+ const BaseSyncTable=require("../BaseSyncTable"),logger=require("../../Logger");class SQLiteSyncTable extends BaseSyncTable{_getClientName(){return"sqlite"}async _listTables(){return await this.db("sqlite_master").where({type:"table"}).whereNot("name","like","sqlite_%").pluck("name")}_supportsUnsigned(){return!1}async _applyExtras(e){for(const[t,a]of Object.entries(e.columns)){("string"==typeof a?this.utils.formatColumnSchema(t,a):a).onUpdate&&this._warnOnUnsupportedModifier("onUpdate",e.table,t)}}async _getRelations(e){const t=await this.db.raw("PRAGMA foreign_key_list(??)",[e]),a={};for(const e of t)a[e.from]={one:{table:e.table,column:e.to}};return a}async getTablesOfDatabase(){return this._listTables()}async executeSql(e,t=[]){return logger.debug("executeSql (legacy)",{sql:e,params:t}),this.db.raw(e,t)}}module.exports=SQLiteSyncTable;
package/index.d.ts CHANGED
@@ -4,6 +4,16 @@ export interface InitializeOptions {
4
4
  schema?: any;
5
5
  resolverPath?: string;
6
6
  debug?: boolean;
7
+ /**
8
+ * Retry policy for transient connection errors. Defaults to
9
+ * `{ attempts: 2, backoffMs: 100, writes: false }`. Pass `false` to disable.
10
+ */
11
+ retry?: RetryOptions | false;
12
+ /**
13
+ * Max sibling `other_requests` executed concurrently. Defaults to
14
+ * `min(5, pool.max - 1)` so a fan-out cannot exhaust the pool.
15
+ */
16
+ fanOutConcurrency?: number;
7
17
  }
8
18
 
9
19
  /**
@@ -284,8 +294,41 @@ export interface KormInstance {
284
294
 
285
295
  loadModelClass?(name: string): any;
286
296
  getModelInstance?(name: string): any;
297
+
298
+ // ---- Connection lifecycle ---------------------------------------------
299
+
300
+ /**
301
+ * Liveness probe against the pool (`SELECT 1`). Never throws — resolves
302
+ * `{ ok: false, error }` on failure. Use it for a `/healthz` endpoint or an
303
+ * idle keepalive heartbeat.
304
+ */
305
+ ping(): Promise<{ ok: boolean; error?: string }>;
306
+ /**
307
+ * Close the underlying Knex pool. Idempotent and non-throwing. Call from a
308
+ * SIGTERM/SIGINT handler so the process drains its connections instead of
309
+ * leaving sockets for the database server to time out.
310
+ */
311
+ destroy(): Promise<{ ok: boolean; error?: string }>;
312
+ /** True once `destroy()` has run; further `processRequest` calls throw. */
313
+ readonly isDestroyed: boolean;
287
314
  }
288
315
 
316
+ /** Retry policy for transient connection errors (ECONNRESET, connection lost). */
317
+ export interface RetryOptions {
318
+ /** Total attempts including the first. Default 2. */
319
+ attempts?: number;
320
+ /** Linear backoff base in ms (attempt N waits N x backoffMs). Default 100. */
321
+ backoffMs?: number;
322
+ /**
323
+ * Replay write actions too. Off by default: a `create` that landed before
324
+ * the socket died would be duplicated by a blind replay.
325
+ */
326
+ writes?: boolean;
327
+ }
328
+
329
+ /** Knex pool settings recommended for a long-running server process. */
330
+ export function recommendedPoolConfig(engine?: string): { pool: Record<string, any> };
331
+
289
332
  /** Authorization context passed as the 3rd arg to processRequest. */
290
333
  export type AuthContext = Record<string, any>;
291
334
 
package/index.js CHANGED
@@ -1 +1 @@
1
- const ControllerWrapper=require("./ControllerWrapper"),BaseHelperUtility=require("./BaseHelperUtility"),RequestValidator=require("./RequestValidator"),Emitter=require("./Emitter"),logger=require("./Logger"),KormError=require("./KormError"),{createServer:createMcpServer}=require("./src/mcp/server"),{generateTools:generateMcpTools}=require("./src/mcp/toolGenerator");module.exports={LibClasses:{Emitter:Emitter,KormError:KormError},KormError:KormError,initializeKORM(){return ControllerWrapper.initializeKORM(...arguments)},helperUtility:new BaseHelperUtility,emitter:new Emitter,logger:logger,validate:RequestValidator.validate,lib:{createValidationMiddleware:RequestValidator.createValidationMiddleware,validateEmail:RequestValidator.validateEmail,validatePassword:RequestValidator.validatePassword,validatePhone:RequestValidator.validatePhone,validatePAN:RequestValidator.validatePAN,validateAadhaar:RequestValidator.validateAadhaar},mcp:{createServer:createMcpServer,generateTools:generateMcpTools}};
1
+ const ControllerWrapper=require("./ControllerWrapper"),BaseHelperUtility=require("./BaseHelperUtility"),RequestValidator=require("./RequestValidator"),Emitter=require("./Emitter"),logger=require("./Logger"),KormError=require("./KormError"),{createServer:createMcpServer}=require("./src/mcp/server"),{generateTools:generateMcpTools}=require("./src/mcp/toolGenerator"),{recommendedPoolConfig:recommendedPoolConfig}=require("./ConnectionResilience");module.exports={LibClasses:{Emitter:Emitter,KormError:KormError},KormError:KormError,initializeKORM(){return ControllerWrapper.initializeKORM(...arguments)},recommendedPoolConfig:recommendedPoolConfig,helperUtility:new BaseHelperUtility,emitter:new Emitter,logger:logger,validate:RequestValidator.validate,lib:{createValidationMiddleware:RequestValidator.createValidationMiddleware,validateEmail:RequestValidator.validateEmail,validatePassword:RequestValidator.validatePassword,validatePhone:RequestValidator.validatePhone,validatePAN:RequestValidator.validatePAN,validateAadhaar:RequestValidator.validateAadhaar},mcp:{createServer:createMcpServer,generateTools:generateMcpTools}};
package/jest.config.js CHANGED
@@ -1 +1 @@
1
- module.exports={testEnvironment:"node",testMatch:["**/test/**/*.test.js","**/test/**/*.spec.js","**/__tests__/**/*.js"],testPathIgnorePatterns:["/node_modules/"],collectCoverage:!1,coverageDirectory:"coverage",coverageReporters:["text","lcov","html"],collectCoverageFrom:["clients/sqlite/**/*.js","clients/Base*.js","ControllerWrapper.js","AuthorizationService.js","RequestValidator.js","BaseHelperUtility.js","Logger.js","Emitter.js","index.js","cli.js","helpers/**/*.js","src/mcp/**/*.js","bin/korm-mcp.js","!**/node_modules/**","!**/test/**","!**/coverage/**","!**/dist/**"],coverageThreshold:{global:{statements:90,branches:85,functions:90,lines:90},"clients/sqlite/CurdTable.js":{statements:95,branches:90},"RequestValidator.js":{statements:95,branches:88},"clients/sqlite/QueryBuilder.js":{statements:93,branches:83}},testTimeout:1e4,moduleNameMapper:{"^@modelcontextprotocol/sdk/(.*)$":"<rootDir>/node_modules/@modelcontextprotocol/sdk/dist/cjs/$1"},clearMocks:!0,verbose:!0};
1
+ module.exports={testEnvironment:"node",testMatch:["**/test/**/*.test.js","**/test/**/*.spec.js","**/__tests__/**/*.js"],testPathIgnorePatterns:["/node_modules/"],collectCoverage:!1,coverageDirectory:"coverage",coverageReporters:["text","lcov","html"],collectCoverageFrom:["clients/sqlite/**/*.js","clients/Base*.js","ControllerWrapper.js","ConnectionResilience.js","AuthorizationService.js","RequestValidator.js","BaseHelperUtility.js","Logger.js","Emitter.js","index.js","cli.js","helpers/**/*.js","src/mcp/**/*.js","bin/korm-mcp.js","!**/node_modules/**","!**/test/**","!**/coverage/**","!**/dist/**"],coverageThreshold:{global:{statements:90,branches:85,functions:90,lines:90},"clients/sqlite/CurdTable.js":{statements:99,branches:95},"RequestValidator.js":{statements:99,branches:88},"clients/sqlite/QueryBuilder.js":{statements:95,branches:87}},testTimeout:1e4,moduleNameMapper:{"^@modelcontextprotocol/sdk/(.*)$":"<rootDir>/node_modules/@modelcontextprotocol/sdk/dist/cjs/$1"},clearMocks:!0,verbose:!0};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dreamtree-org/korm-js",
3
- "version": "1.1.3",
3
+ "version": "1.2.1",
4
4
  "description": "Knowledge Object-Relational Mapping - A powerful, modular ORM system for Node.js with dynamic database operations, complex queries, relationships, and nested requests",
5
5
  "author": {
6
6
  "name": "Partha Preetham Krishna",
package/requestSchema.js CHANGED
@@ -1 +1 @@
1
- "use strict";const{parseColumnDef:parseColumnDef,isWritableOnCreate:isWritableOnCreate,isRequiredOnCreate:isRequiredOnCreate,applyNullability:applyNullability}=require("./columnSchema"),READ_ACTIONS=new Set(["list","show","count","sum"]),WRITE_ACTIONS=new Set(["create","update","delete","replace","upsert","sync"]),KNOWN_ACTIONS=new Set([...READ_ACTIONS,...WRITE_ACTIONS]);function buildColumnsMap(e){const t={},r=e&&e.columns||{};for(const[e,i]of Object.entries(r))t[e]=parseColumnDef(i);return t}function buildDataSchemaForCreate(e){const t={},r=[];for(const[i,o]of Object.entries(e))isWritableOnCreate(o)&&(t[i]=applyNullability(o.jsonSchema,o),isRequiredOnCreate(o)&&r.push(i));const i={type:"object",properties:t,additionalProperties:!1};return r.length&&(i.required=r),i}function buildDataSchemaForUpdate(e){const t={};for(const[r,i]of Object.entries(e))isWritableOnCreate(i)&&(t[r]=applyNullability(i.jsonSchema,i));return{type:"object",properties:t,additionalProperties:!1}}function buildWhereSchema(e){const t={};for(const r of Object.keys(e))t[r]={};return{type:"object",properties:t,additionalProperties:!0}}function buildSelectSchema(e){const t=Object.keys(e);return{oneOf:[{type:"string"},{type:"array",items:t.length?{type:"string",enum:t}:{type:"string"}}]}}function buildOrderBySchema(e){const t=Object.keys(e),r={type:"object",properties:{column:t.length?{type:"string",enum:t}:{type:"string"},order:{type:"string",enum:["asc","desc","ASC","DESC"]}},required:["column"],additionalProperties:!1};return{oneOf:[{type:"string"},{type:"array",items:{type:"string"}},r,{type:"array",items:r}]}}function buildWithSchema(e){const t=Object.keys(e||{}),r={type:"string"};return t.length&&(r.description=`Top-level relations available: ${t.join(", ")}. Use dot-paths for deeper traversal, e.g. "${t[0]}.NestedRel".`),{type:"array",items:r}}function buildSumDataSchema(e){const t=Object.keys(e);return{type:"object",properties:{sumColumn:t.length?{type:"string",enum:t}:{type:"string"},sumFormula:{type:"string",description:"Arithmetic expression over column placeholders. Allowed chars: digits, . + - * / ( ), {column} placeholders, whitespace. See docs/agents/06-request-contract.md §5."}},additionalProperties:!1}}function buildConflictSchema(e){const t=Object.keys(e);return{type:"array",items:t.length?{type:"string",enum:t}:{type:"string"},minItems:1}}function commonReadSelectors(e,t){return{where:buildWhereSchema(e),select:buildSelectSchema(e),orderBy:buildOrderBySchema(e),limit:{type:"integer",minimum:1},offset:{type:"integer",minimum:0},page:{type:"integer",minimum:1},with:buildWithSchema(t),withWhere:buildWhereSchema(e),groupBy:{oneOf:[{type:"string"},{type:"array",items:{type:"string"}}]},having:buildWhereSchema(e),distinct:{oneOf:[{type:"boolean"},{type:"string"},{type:"array",items:{type:"string"}}]}}}const ACTION_BUILDERS={list:(e,t)=>({type:"object",properties:commonReadSelectors(e,t),additionalProperties:!1}),show:(e,t)=>({type:"object",properties:{where:buildWhereSchema(e),select:buildSelectSchema(e),with:buildWithSchema(t),withWhere:buildWhereSchema(e)},required:["where"],additionalProperties:!1}),count:e=>({type:"object",properties:{where:buildWhereSchema(e),distinct:{oneOf:[{type:"boolean"},{type:"string"},{type:"array",items:{type:"string"}}]}},additionalProperties:!1}),sum:e=>({type:"object",properties:{data:buildSumDataSchema(e),where:buildWhereSchema(e),groupBy:{oneOf:[{type:"string"},{type:"array",items:{type:"string"}}]}},required:["data"],additionalProperties:!1}),create(e){const t=buildDataSchemaForCreate(e);return{type:"object",properties:{data:{oneOf:[t,{type:"array",items:t,minItems:1}]}},required:["data"],additionalProperties:!1}},update:e=>({type:"object",properties:{where:buildWhereSchema(e),data:buildDataSchemaForUpdate(e)},required:["where","data"],additionalProperties:!1}),delete:e=>({type:"object",properties:{where:buildWhereSchema(e)},required:["where"],additionalProperties:!1}),replace(e){const t=buildDataSchemaForCreate(e);return{type:"object",properties:{data:{oneOf:[t,{type:"array",items:t,minItems:1}]}},required:["data"],additionalProperties:!1}},upsert(e){const t=buildDataSchemaForCreate(e);return{type:"object",properties:{data:{oneOf:[t,{type:"array",items:t,minItems:1}]},conflict:buildConflictSchema(e)},required:["data","conflict"],additionalProperties:!1}},sync(e){const t=buildDataSchemaForCreate(e);return{type:"object",properties:{data:{oneOf:[t,{type:"array",items:t,minItems:1}]},where:buildWhereSchema(e),conflict:buildConflictSchema(e)},required:["data","where","conflict"],additionalProperties:!1}}};function buildRequestSchema({action:e,model:t}){if(!t||"object"!=typeof t)throw new TypeError("buildRequestSchema: `model` is required");if(!KNOWN_ACTIONS.has(e))throw new RangeError(`buildRequestSchema: unknown action "${e}". Known: ${[...KNOWN_ACTIONS].join(", ")}`);const r=buildColumnsMap(t),i=t.hasRelations||{};return ACTION_BUILDERS[e](r,i)}function modelTitle(e){return e.modelName||e.alias||e.table||"Model"}function buildModelRequestSchema(e,t={}){if(!e||"object"!=typeof e)throw new TypeError("buildModelRequestSchema: `model` is required");const r=modelTitle(e),i=[...KNOWN_ACTIONS].map(t=>{const r=buildRequestSchema({action:t,model:e});return{type:"object",title:t,properties:{action:{type:"string",const:t,description:`The "${t}" operation.`},...r.properties,dryRun:{type:"boolean",description:"If true, return the SQL that would run without executing it."}},required:["action",...r.required||[]],additionalProperties:!1}});return{$schema:"https://json-schema.org/draft/2020-12/schema",title:t.title||`KormRequest<${r}>`,description:`Valid processRequest(body, "${r}") shapes. Exactly one action branch applies.`,oneOf:i}}module.exports={buildRequestSchema:buildRequestSchema,buildModelRequestSchema:buildModelRequestSchema,buildColumnsMap:buildColumnsMap,READ_ACTIONS:READ_ACTIONS,WRITE_ACTIONS:WRITE_ACTIONS,KNOWN_ACTIONS:KNOWN_ACTIONS};
1
+ "use strict";const{parseColumnDef:parseColumnDef,isWritableOnCreate:isWritableOnCreate,isRequiredOnCreate:isRequiredOnCreate,applyNullability:applyNullability}=require("./columnSchema"),READ_ACTIONS=new Set(["list","show","count","sum"]),WRITE_ACTIONS=new Set(["create","update","delete","replace","upsert","sync","restore"]),KNOWN_ACTIONS=new Set([...READ_ACTIONS,...WRITE_ACTIONS]);function buildColumnsMap(e){const t={},r=e&&e.columns||{};for(const[e,i]of Object.entries(r))t[e]=parseColumnDef(i);return t}function buildDataSchemaForCreate(e){const t={},r=[];for(const[i,o]of Object.entries(e))isWritableOnCreate(o)&&(t[i]=applyNullability(o.jsonSchema,o),isRequiredOnCreate(o)&&r.push(i));const i={type:"object",properties:t,additionalProperties:!1};return r.length&&(i.required=r),i}function buildDataSchemaForUpdate(e){const t={};for(const[r,i]of Object.entries(e))isWritableOnCreate(i)&&(t[r]=applyNullability(i.jsonSchema,i));return{type:"object",properties:t,additionalProperties:!1}}function buildWhereSchema(e){const t={};for(const r of Object.keys(e))t[r]={};return{type:"object",properties:t,additionalProperties:!0}}function buildSelectSchema(e){const t=Object.keys(e);return{oneOf:[{type:"string"},{type:"array",items:t.length?{type:"string",enum:t}:{type:"string"}}]}}function buildOrderBySchema(e){const t=Object.keys(e),r={type:"object",properties:{column:t.length?{type:"string",enum:t}:{type:"string"},order:{type:"string",enum:["asc","desc","ASC","DESC"]}},required:["column"],additionalProperties:!1};return{oneOf:[{type:"string"},{type:"array",items:{type:"string"}},r,{type:"array",items:r}]}}function buildWithSchema(e){const t=Object.keys(e||{}),r={type:"string"};return t.length&&(r.description=`Top-level relations available: ${t.join(", ")}. Use dot-paths for deeper traversal, e.g. "${t[0]}.NestedRel".`),{type:"array",items:r}}function buildSumDataSchema(e){const t=Object.keys(e);return{type:"object",properties:{sumColumn:t.length?{type:"string",enum:t}:{type:"string"},sumFormula:{type:"string",description:"Arithmetic expression over column placeholders. Allowed chars: digits, . + - * / ( ), {column} placeholders, whitespace. See docs/agents/06-request-contract.md §5."}},additionalProperties:!1}}function buildConflictSchema(e){const t=Object.keys(e);return{type:"array",items:t.length?{type:"string",enum:t}:{type:"string"},minItems:1}}function commonReadSelectors(e,t){return{where:buildWhereSchema(e),select:buildSelectSchema(e),orderBy:buildOrderBySchema(e),limit:{type:"integer",minimum:1},offset:{type:"integer",minimum:0},page:{type:"integer",minimum:1},with:buildWithSchema(t),withWhere:buildWhereSchema(e),groupBy:{oneOf:[{type:"string"},{type:"array",items:{type:"string"}}]},having:buildWhereSchema(e),distinct:{oneOf:[{type:"boolean"},{type:"string"},{type:"array",items:{type:"string"}}]}}}const ACTION_BUILDERS={list:(e,t)=>({type:"object",properties:commonReadSelectors(e,t),additionalProperties:!1}),show:(e,t)=>({type:"object",properties:{where:buildWhereSchema(e),select:buildSelectSchema(e),with:buildWithSchema(t),withWhere:buildWhereSchema(e)},required:["where"],additionalProperties:!1}),count:e=>({type:"object",properties:{where:buildWhereSchema(e),distinct:{oneOf:[{type:"boolean"},{type:"string"},{type:"array",items:{type:"string"}}]}},additionalProperties:!1}),sum:e=>({type:"object",properties:{data:buildSumDataSchema(e),where:buildWhereSchema(e),groupBy:{oneOf:[{type:"string"},{type:"array",items:{type:"string"}}]}},required:["data"],additionalProperties:!1}),create(e){const t=buildDataSchemaForCreate(e);return{type:"object",properties:{data:{oneOf:[t,{type:"array",items:t,minItems:1}]}},required:["data"],additionalProperties:!1}},update:e=>({type:"object",properties:{where:buildWhereSchema(e),data:buildDataSchemaForUpdate(e)},required:["where","data"],additionalProperties:!1}),delete:e=>({type:"object",properties:{where:buildWhereSchema(e)},required:["where"],additionalProperties:!1}),replace(e){const t=buildDataSchemaForCreate(e);return{type:"object",properties:{data:{oneOf:[t,{type:"array",items:t,minItems:1}]}},required:["data"],additionalProperties:!1}},upsert(e){const t=buildDataSchemaForCreate(e);return{type:"object",properties:{data:{oneOf:[t,{type:"array",items:t,minItems:1}]},conflict:buildConflictSchema(e)},required:["data","conflict"],additionalProperties:!1}},sync(e){const t=buildDataSchemaForCreate(e);return{type:"object",properties:{data:{oneOf:[t,{type:"array",items:t,minItems:1}]},where:buildWhereSchema(e),conflict:buildConflictSchema(e)},required:["data","where","conflict"],additionalProperties:!1}},restore:e=>({type:"object",properties:{where:buildWhereSchema(e)},required:["where"],additionalProperties:!1})};function buildRequestSchema({action:e,model:t}){if(!t||"object"!=typeof t)throw new TypeError("buildRequestSchema: `model` is required");if(!KNOWN_ACTIONS.has(e))throw new RangeError(`buildRequestSchema: unknown action "${e}". Known: ${[...KNOWN_ACTIONS].join(", ")}`);const r=buildColumnsMap(t),i=t.hasRelations||{};return ACTION_BUILDERS[e](r,i)}function modelTitle(e){return e.modelName||e.alias||e.table||"Model"}function buildModelRequestSchema(e,t={}){if(!e||"object"!=typeof e)throw new TypeError("buildModelRequestSchema: `model` is required");const r=modelTitle(e),i=[...KNOWN_ACTIONS].map(t=>{const r=buildRequestSchema({action:t,model:e});return{type:"object",title:t,properties:{action:{type:"string",const:t,description:`The "${t}" operation.`},...r.properties,dryRun:{type:"boolean",description:"If true, return the SQL that would run without executing it."}},required:["action",...r.required||[]],additionalProperties:!1}});return{$schema:"https://json-schema.org/draft/2020-12/schema",title:t.title||`KormRequest<${r}>`,description:`Valid processRequest(body, "${r}") shapes. Exactly one action branch applies.`,oneOf:i}}module.exports={buildRequestSchema:buildRequestSchema,buildModelRequestSchema:buildModelRequestSchema,buildColumnsMap:buildColumnsMap,READ_ACTIONS:READ_ACTIONS,WRITE_ACTIONS:WRITE_ACTIONS,KNOWN_ACTIONS:KNOWN_ACTIONS};
@@ -1 +1 @@
1
- "use strict";const{toolErrorResult:toolErrorResult,toolSuccessResult:toolSuccessResult}=require("./errors"),{resolveTables:resolveTables}=require("./toolGenerator");function describeColumns(e){const o={};for(const[t,r]of Object.entries(e.columns||{}))o[t]=r;return o}function buildListTablesTool({schema:e,visibleTables:o}){return{name:"korm.list_tables",description:"List the tables (models) currently exposed by this MCP server, with their column counts.",inputSchema:{type:"object",properties:{},additionalProperties:!1},handler:async()=>{try{const t=o().map(o=>{const t=e[o];return{modelName:o,table:t.table,columnCount:Object.keys(t.columns||{}).length,relationCount:Object.keys(t.hasRelations||{}).length}});return toolSuccessResult({tables:t})}catch(e){return toolErrorResult(e)}}}}function buildDescribeSchemaTool({schema:e,visibleTables:o}){return{name:"korm.describe_schema",description:"Describe the columns and relations of a single allowlisted table.",inputSchema:{type:"object",properties:{table:{type:"string",description:'Model name (matches the key in the KORM schema, e.g. "User" — not the SQL table name).'}},required:["table"],additionalProperties:!1},handler:async t=>{try{const r=t&&t.table,s=o();if(!s.includes(r))return toolErrorResult(new Error(`Table "${r}" is not exposed by this MCP server. Available: ${s.join(", ")||"(none)"}.`));const n=e[r];return toolSuccessResult({modelName:r,table:n.table,columns:describeColumns(n),relations:n.hasRelations||{}})}catch(e){return toolErrorResult(e)}}}}async function pingDb(e){if(!e||!e.db||"function"!=typeof e.db.raw)return{ok:!1,error:"controller.db not present or not a Knex instance"};try{return await e.db.raw("SELECT 1"),{ok:!0}}catch(e){return{ok:!1,error:e.message}}}function buildHealthTool({controller:e,mcpConfig:o,packageInfo:t,visibleTables:r}){return{name:"korm.health",description:"Report MCP server health: engine, library version, allowlist size, DB ping.",inputSchema:{type:"object",properties:{},additionalProperties:!1},handler:async()=>{try{const s=e&&(e.dbClient||e.engine)||"unknown",n=r(),l=await pingDb(e);return toolSuccessResult({status:l.ok?"ok":"degraded",engine:s,version:t.version||"unknown",mode:o.mode,allowedTableCount:n.length,dbPing:l.ok,...l.error?{dbError:l.error}:{}})}catch(e){return toolErrorResult(e)}}}}function buildMetaTools({controller:e,schema:o,mcpConfig:t,packageInfo:r={}}){if(!1===t.metaTools)return[];const s=()=>resolveTables(o,t);return[buildListTablesTool({schema:o,visibleTables:s}),buildDescribeSchemaTool({schema:o,visibleTables:s}),buildHealthTool({controller:e,mcpConfig:t,packageInfo:r,visibleTables:s})]}module.exports={buildMetaTools:buildMetaTools};
1
+ "use strict";const{toolErrorResult:toolErrorResult,toolSuccessResult:toolSuccessResult}=require("./errors"),{resolveTables:resolveTables}=require("./toolGenerator"),{pingConnection:pingConnection}=require("../../ConnectionResilience");function describeColumns(e){const o={};for(const[t,r]of Object.entries(e.columns||{}))o[t]=r;return o}function buildListTablesTool({schema:e,visibleTables:o}){return{name:"korm.list_tables",description:"List the tables (models) currently exposed by this MCP server, with their column counts.",inputSchema:{type:"object",properties:{},additionalProperties:!1},handler:async()=>{try{const t=o().map(o=>{const t=e[o];return{modelName:o,table:t.table,columnCount:Object.keys(t.columns||{}).length,relationCount:Object.keys(t.hasRelations||{}).length}});return toolSuccessResult({tables:t})}catch(e){return toolErrorResult(e)}}}}function buildDescribeSchemaTool({schema:e,visibleTables:o}){return{name:"korm.describe_schema",description:"Describe the columns and relations of a single allowlisted table.",inputSchema:{type:"object",properties:{table:{type:"string",description:'Model name (matches the key in the KORM schema, e.g. "User" — not the SQL table name).'}},required:["table"],additionalProperties:!1},handler:async t=>{try{const r=t&&t.table,n=o();if(!n.includes(r))return toolErrorResult(new Error(`Table "${r}" is not exposed by this MCP server. Available: ${n.join(", ")||"(none)"}.`));const s=e[r];return toolSuccessResult({modelName:r,table:s.table,columns:describeColumns(s),relations:s.hasRelations||{}})}catch(e){return toolErrorResult(e)}}}}async function pingDb(e){return await pingConnection(e&&e.db)}function buildHealthTool({controller:e,mcpConfig:o,packageInfo:t,visibleTables:r}){return{name:"korm.health",description:"Report MCP server health: engine, library version, allowlist size, DB ping.",inputSchema:{type:"object",properties:{},additionalProperties:!1},handler:async()=>{try{const n=e&&(e.dbClient||e.engine)||"unknown",s=r(),l=await pingDb(e);return toolSuccessResult({status:l.ok?"ok":"degraded",engine:n,version:t.version||"unknown",mode:o.mode,allowedTableCount:s.length,dbPing:l.ok,...l.error?{dbError:l.error}:{}})}catch(e){return toolErrorResult(e)}}}}function buildMetaTools({controller:e,schema:o,mcpConfig:t,packageInfo:r={}}){if(!1===t.metaTools)return[];const n=()=>resolveTables(o,t);return[buildListTablesTool({schema:o,visibleTables:n}),buildDescribeSchemaTool({schema:o,visibleTables:n}),buildHealthTool({controller:e,mcpConfig:t,packageInfo:r,visibleTables:n})]}module.exports={buildMetaTools:buildMetaTools};