@dreamtree-org/korm-js 1.0.54 → 1.0.55

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 mysqlWrapper=require("./clients/mysql"),sqliteWrapper=require("./clients/sqlite"),pgWrapper=require("./clients/pg"),InstanceMapper={mysql2:mysqlWrapper,sqlite:sqliteWrapper,pg:pgWrapper},dbClientMapper={mysql2:"mysql2",mysql:"mysql2",pg:"pg",postgresql:"pg",sqlite:"sqlite",sqlite3:"sqlite"};class ControllerWrapper{static db=null;static dbClient=null;static dbClientClass=null;static schema=null;static resolverPath=null;static dbInstance=null;static debug=!1;requestInstance=null;constructor(){this.requestInstance={}}static initializeKORM({db:t,dbClient:e,schema:s,resolverPath:n=null,debug:a=!1}){this.db=t,this.dbClient=e,this.schema=s,this.resolverPath=n,this.debug=a;const r=dbClientMapper[e];if(!r)throw new Error(`Database client ${e} not found`);const i=InstanceMapper[r];if(!i)throw new Error(`Database client ${e} not found`);return this.dbClientClass=i,this.dbInstance=new i(this),this}static setSchema(t){this.schema=t;const e=this.dbClientClass;if(!e)throw new Error(`Database client ${this.dbClient} not found`);return this.dbInstance=new e(this),this}static async processRequest(t,e=null,s=null){return await this.dbInstance.processRequest(t,e,s)}static async processRequestWithOthers(t,e=null,s=null){return await this.dbInstance.processRequest(t,e,s)}static async syncDatabase(){return await this.dbInstance.syncDatabase()}static async generateSchema(){return await this.dbInstance.generateSchema()}static loadModelClass(t){return this.dbInstance.hookService.loadModelClass(t)}static getModelInstance(t){return this.dbInstance.hookService.getModelInstance(t)}}module.exports=ControllerWrapper;
1
+ const mysqlWrapper=require("./clients/mysql"),sqliteWrapper=require("./clients/sqlite"),pgWrapper=require("./clients/pg"),KormError=require("./KormError"),{buildModelRequestSchema:buildModelRequestSchema}=require("./requestSchema"),{buildModelDescription:buildModelDescription,SCHEMA_API_VERSION:SCHEMA_API_VERSION}=require("./schemaDescribe"),InstanceMapper={mysql2:mysqlWrapper,sqlite:sqliteWrapper,pg:pgWrapper},dbClientMapper={mysql2:"mysql2",mysql:"mysql2",pg:"pg",postgresql:"pg",sqlite:"sqlite",sqlite3:"sqlite"};class ControllerWrapper{static db=null;static dbClient=null;static dbClientClass=null;static schema=null;static resolverPath=null;static dbInstance=null;static debug=!1;requestInstance=null;constructor(){this.requestInstance={}}static initializeKORM({db:e,dbClient:t,schema:s,resolverPath:r=null,debug:a=!1}){this.db=e,this.dbClient=t,this.schema=s,this.resolverPath=r,this.debug=a;const n=dbClientMapper[t];if(!n)throw new Error(`Database client ${t} not found`);const i=InstanceMapper[n];if(!i)throw new Error(`Database client ${t} not found`);return this.dbClientClass=i,this.dbInstance=new i(this),this}static setSchema(e){this.schema=e;const t=this.dbClientClass;if(!t)throw new Error(`Database client ${this.dbClient} not found`);return this.dbInstance=new t(this),this}static async processRequest(e,t=null,s=null){return await this.dbInstance.processRequest(e,t,s)}static async processRequestWithOthers(e,t=null,s=null){return await this.dbInstance.processRequest(e,t,s)}static async syncDatabase(){return await this.dbInstance.syncDatabase()}static async generateSchema(){return await this.dbInstance.generateSchema()}static loadModelClass(e){return this.dbInstance.hookService.loadModelClass(e)}static getModelInstance(e){return this.dbInstance.hookService.getModelInstance(e)}static getRequestJsonSchema(e){const t=this.schema||{},s=t[e]||Object.values(t).find(t=>t&&t.table===e);if(!s)throw KormError.unknownModel({model:e,available:Object.keys(t)});return buildModelRequestSchema(s,{title:`KormRequest<${e}>`})}static _modelHasSoftDelete(e){try{const t=this.dbInstance?.hookService?.getModelInstance?.(e);return!(!t||!0!==t.hasSoftDelete)}catch{return!1}}static describeModel(e){const t=this.schema||{},s=Object.entries(t).find(([t,s])=>t===e||s&&s.table===e);if(!s)throw KormError.unknownModel({model:e,available:Object.keys(t)});const[r,a]=s;return buildModelDescription(r,a,{softDelete:this._modelHasSoftDelete(r)})}static describeSchema(){const e=this.schema||{},t=Object.entries(e).map(([e,t])=>buildModelDescription(e,t,{softDelete:this._modelHasSoftDelete(e)}));return{schemaApiVersion:SCHEMA_API_VERSION,models:t}}}module.exports=ControllerWrapper;
package/KormError.js ADDED
@@ -0,0 +1 @@
1
+ const CODES=Object.freeze({NO_MATCHING_ROW:"NO_MATCHING_ROW",UNKNOWN_ACTION:"UNKNOWN_ACTION",VALIDATION_FAILED:"VALIDATION_FAILED",UNKNOWN_MODEL:"UNKNOWN_MODEL",NO_CUSTOM_ACTION_HOOK:"NO_CUSTOM_ACTION_HOOK",INTERNAL:"INTERNAL"}),ACTIONS=Object.freeze(["count","sum","list","show","create","update","replace","upsert","sync","delete"]);function levenshtein(e,o){const t=e.length,r=o.length;if(0===t)return r;if(0===r)return t;let n=Array.from({length:r+1},(e,o)=>o),s=new Array(r+1);for(let i=1;i<=t;i++){s[0]=i;for(let t=1;t<=r;t++){const r=e[i-1]===o[t-1]?0:1;s[t]=Math.min(n[t]+1,s[t-1]+1,n[t-1]+r)}[n,s]=[s,n]}return n[r]}function closestAction(e,o=ACTIONS){if(!e)return null;const t=String(e).toLowerCase();let r=null,n=1/0;for(const e of o){const o=levenshtein(t,e);o<n&&(n=o,r=e)}return n<=Math.max(2,Math.ceil(t.length/2))?r:null}class KormError extends Error{constructor(e,{code:o=CODES.INTERNAL,hint:t=null,context:r={},suggestedFixes:n=null}={}){super(e),this.name="KormError",this.code=o,this.hint=t,this.context=r,this.suggestedFixes=n,Error.captureStackTrace&&Error.captureStackTrace(this,KormError)}toJSON(){return{name:this.name,code:this.code,message:this.message,hint:this.hint,context:this.context,suggestedFixes:this.suggestedFixes}}}KormError.noMatchingRow=({action:e,model:o})=>{const t="update"===e?"No row matched the where clause. Use `upsert` to insert-or-update, or `sync` to reconcile.":"No row matched the where clause for this action.";return new KormError(`No matching row for action "${e}" on model "${o}".`,{code:CODES.NO_MATCHING_ROW,hint:t,context:{action:e,model:o}})},KormError.unknownAction=({action:e,model:o,hasCustomHook:t=!1})=>{const r=closestAction(e),n=t?CODES.NO_CUSTOM_ACTION_HOOK:CODES.UNKNOWN_ACTION,s=t?`No custom action hook found for "${o}.${e}".`:`Unknown action "${e}".`,i=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(s,{code:n,hint:i,context:{action:e,model:o,validActions:ACTIONS,closest:r}})},KormError.unknownModel=({model:e,available:o=[]})=>{const t=o.length?`Available models: ${o.join(", ")}.`:"No models are registered in the schema.";return new KormError(`Model "${e}" not found.`,{code:CODES.UNKNOWN_MODEL,hint:t,context:{model:e,available:o}})},KormError.validationFailed=({errors:e=[],source:o=null})=>{const t=e.map(e=>({field:e.field,message:e.message,value:e.value,rule:e.rule})),r=t.map(e=>e.field).filter(Boolean),n=new KormError(`Validation failed${o?` for ${o}`:""}${r.length?`: ${r.join(", ")}`:""}.`,{code:CODES.VALIDATION_FAILED,hint:"Fix the listed fields and resubmit. See context.fields for per-field detail.",context:{source:o,fields:t}});return n.errors=e,n},KormError.CODES=CODES,KormError.ACTIONS=ACTIONS,KormError.closestAction=closestAction,module.exports=KormError;
package/README.md CHANGED
@@ -1568,16 +1568,19 @@ type|modifier1|modifier2|...
1568
1568
  | Modifier | Description | Example |
1569
1569
  | ------------------------- | ------------------ | ---------- | --------------------------- | -------------- |
1570
1570
  | `size:n` | Column size | `varchar | size:255` |
1571
- | `unsigned` | Unsigned integer | `int | unsigned` |
1571
+ | `unsigned| Unsigned integer | `int | unsigned` |
1572
1572
  | `primaryKey` | Primary key column | `bigint | primaryKey` |
1573
1573
  | `autoIncrement` | Auto increment | `bigint | primaryKey | autoIncrement` |
1574
1574
  | `notNull` | Not nullable | `varchar | size:255 | notNull` |
1575
1575
  | `unique` | Unique constraint | `varchar | unique` |
1576
1576
  | `default:value` | Default value | `tinyint | default:1` |
1577
- | `onUpdate:value` | On update value | `timestamp | onUpdate:CURRENT_TIMESTAMP` |
1577
+ | `onUpdate:value| On update value | `timestamp | onUpdate:CURRENT_TIMESTAMP` |
1578
1578
  | `comment:text` | Column comment | `varchar | comment:User email address` |
1579
1579
  | `foreignKey:table:column` | Foreign key | `int | foreignKey:users:id` |
1580
1580
 
1581
+ ¹ **Engine-specific.** `unsigned` is honored on MySQL and SQLite. PostgreSQL has no unsigned integer type and silently drops the modifier.
1582
+ ² **Engine-specific.** `onUpdate` is honored on MySQL (emitted via `ON UPDATE <expr>`). PostgreSQL and SQLite log a one-time warning and ignore it — the modifier cannot be expressed inline on those engines. See [`docs/agents/05-multi-db-parity.md`](docs/agents/05-multi-db-parity.md).
1583
+
1581
1584
  **Special Default Values:**
1582
1585
 
1583
1586
  - `now` or `now()` → `CURRENT_TIMESTAMP`
@@ -1866,6 +1869,38 @@ if (process.env.NODE_ENV === 'development') {
1866
1869
  }
1867
1870
  ```
1868
1871
 
1872
+ ## Inspecting queries (`dryRun`)
1873
+
1874
+ Add `dryRun: true` to any request to get back the SQL it **would** run —
1875
+ without executing anything. Useful for audit pipelines, previewing
1876
+ destructive operations, and letting an AI agent review SQL before
1877
+ committing to it.
1878
+
1879
+ ```javascript
1880
+ const result = await korm.processRequest(
1881
+ { action: 'delete', where: { status: 'archived' }, dryRun: true },
1882
+ 'Post'
1883
+ );
1884
+ // → {
1885
+ // success: true,
1886
+ // dryRun: true,
1887
+ // action: 'delete',
1888
+ // model: 'Post',
1889
+ // sql: 'delete from `posts` where `status` = ?',
1890
+ // bindings: ['archived'],
1891
+ // statements: [{ sql: '...', bindings: ['archived'] }],
1892
+ // }
1893
+ ```
1894
+
1895
+ - Validation still runs (you still get a `KormError` for a bad request).
1896
+ - `before`/`after` model hooks do **not** fire, and the database is
1897
+ untouched.
1898
+ - Bindings are returned as a separate array (not interpolated into
1899
+ `sql`), so you can re-parameterize.
1900
+ - `sync` returns both statements (upsert + delete) in `statements`.
1901
+
1902
+ Full reference: [`docs/agents/06-request-contract.md`](docs/agents/06-request-contract.md) §9.
1903
+
1869
1904
  ## SQL Debugging
1870
1905
 
1871
1906
  Enable SQL debugging to see the exact SQL statements generated by your queries. This is useful for troubleshooting complex queries and understanding how KORM translates your requests.
@@ -2012,54 +2047,191 @@ const korm = initializeKORM({
2012
2047
  });
2013
2048
  ```
2014
2049
 
2050
+ ## Using KORM-JS as an AI tool
2051
+
2052
+ The discovery → request flow for an LLM agent is two calls: **describe** what's available, then build a request constrained by its **JSON Schema**.
2053
+
2054
+ ### 1. Discover the schema — `describeSchema()` / `describeModel(name)`
2055
+
2056
+ Pure-data, JSON-safe introspection (no hooks, credentials, or internals leak). Use it to load context before generating a request.
2057
+
2058
+ ```javascript
2059
+ korm.describeSchema();
2060
+ // → { schemaApiVersion: 1, models: [ { model, table, columns, relations, softDelete, actions }, … ] }
2061
+
2062
+ korm.describeModel('User');
2063
+ // → {
2064
+ // schemaApiVersion: 1,
2065
+ // model: 'User', table: 'users', alias: 'User',
2066
+ // columns: [ { name: 'id', type: 'integer', primaryKey: true, autoIncrement: true, nullable: false }, … ],
2067
+ // relations: [ { name: 'Post', type: 'many', table: 'posts', localKey: 'id', foreignKey: 'user_id' } ],
2068
+ // softDelete: false,
2069
+ // actions: ['list','show','count','sum','create','update','delete','replace','upsert','sync'],
2070
+ // }
2071
+ ```
2072
+
2073
+ Unknown models throw a `KormError` with `code: 'UNKNOWN_MODEL'` (the `context.available` list helps the caller recover).
2074
+
2075
+ ### 2. Constrain the request — `getRequestJsonSchema(modelName)`
2076
+
2077
+ `korm.getRequestJsonSchema(modelName)` returns a draft-2020-12 JSON Schema describing every valid `processRequest` body for that model — an `action`-discriminated `oneOf` with typed `data`, a `select`/`orderBy`/`conflict` constrained to the model's columns, and inline descriptions. Attach it to an OpenAI / Anthropic tool definition, or use it for client-side prevalidation, so the model's output is constrained to a request your app can actually run.
2078
+
2079
+ ```javascript
2080
+ const schema = korm.getRequestJsonSchema('User');
2081
+
2082
+ // OpenAI tool definition
2083
+ const tool = {
2084
+ type: 'function',
2085
+ function: {
2086
+ name: 'query_users',
2087
+ description: 'Query or mutate the User model via KORM-JS.',
2088
+ parameters: schema, // the oneOf-over-actions request schema
2089
+ },
2090
+ };
2091
+
2092
+ // Anthropic tool definition
2093
+ const anthropicTool = {
2094
+ name: 'query_users',
2095
+ description: 'Query or mutate the User model via KORM-JS.',
2096
+ input_schema: schema,
2097
+ };
2098
+ ```
2099
+
2100
+ The schema is derived from the model's column definitions and relations, so it stays in sync with your schema. Unknown models throw a `KormError` with `code: 'UNKNOWN_MODEL'`.
2101
+
2102
+ ## Running as an MCP server
2103
+
2104
+ KORM-JS ships with an optional [Model Context Protocol](https://modelcontextprotocol.io) server, `korm-mcp`. It exposes your KORM-registered tables as typed JSON-in/JSON-out tools that any MCP client (Claude Desktop, Claude Code, Cursor, custom agents) can call — no HTTP layer, no hand-written CRUD.
2105
+
2106
+ ### Install the SDK
2107
+
2108
+ The MCP SDK is an _optional_ dependency. If `npm install @dreamtree-org/korm-js` did not auto-install it (locked-down registry, offline mirror, etc.), pull it in explicitly:
2109
+
2110
+ ```bash
2111
+ npm install @modelcontextprotocol/sdk
2112
+ ```
2113
+
2114
+ ### Write a config
2115
+
2116
+ `korm-mcp.config.js`:
2117
+
2118
+ ```javascript
2119
+ const knex = require('knex');
2120
+ const schema = require('./schema'); // your KORM schema map
2121
+
2122
+ module.exports = {
2123
+ // Same shape as initializeKORM
2124
+ db: knex({ client: 'pg', connection: process.env.DATABASE_URL }),
2125
+ dbClient: 'pg',
2126
+ schema,
2127
+ resolverPath: './models', // optional, for model hooks
2128
+ debug: false,
2129
+
2130
+ mcp: {
2131
+ mode: 'ro', // 'ro' | 'rw' | 'rw-sync'
2132
+ allowlist: ['User', 'Post', 'Comment'], // flat list of model names; '*' allowed only in 'ro'
2133
+ blocklist: [], // applied after allowlist
2134
+ metaTools: true, // korm.list_tables, korm.describe_schema, korm.health
2135
+ allowNestedRequests: false, // gate `other_requests` (off by default)
2136
+ customActions: [], // [{ table, action, schema?, description? }]
2137
+ },
2138
+ };
2139
+ ```
2140
+
2141
+ ### Wire it into your MCP client
2142
+
2143
+ ```json
2144
+ {
2145
+ "mcpServers": {
2146
+ "my-app-db": {
2147
+ "command": "korm-mcp",
2148
+ "args": ["--config", "/abs/path/to/korm-mcp.config.js"]
2149
+ }
2150
+ }
2151
+ }
2152
+ ```
2153
+
2154
+ ### What you get
2155
+
2156
+ For each allowlisted table, the server emits one tool per action permitted by `mcp.mode`. Example for a `User` model:
2157
+
2158
+ | Tool | Available in mode | Maps to |
2159
+ | --------------- | --------------------- | -------------------------------------------- |
2160
+ | `users.list` | `ro`, `rw`, `rw-sync` | `processRequest({ action: 'list', ... })` |
2161
+ | `users.show` | `ro`, `rw`, `rw-sync` | `processRequest({ action: 'show', ... })` |
2162
+ | `users.count` | `ro`, `rw`, `rw-sync` | `processRequest({ action: 'count', ... })` |
2163
+ | `users.sum` | `ro`, `rw`, `rw-sync` | `processRequest({ action: 'sum', ... })` |
2164
+ | `users.create` | `rw`, `rw-sync` | `processRequest({ action: 'create', ... })` |
2165
+ | `users.update` | `rw`, `rw-sync` | `processRequest({ action: 'update', ... })` |
2166
+ | `users.delete` | `rw`, `rw-sync` | `processRequest({ action: 'delete', ... })` |
2167
+ | `users.upsert` | `rw`, `rw-sync` | `processRequest({ action: 'upsert', ... })` |
2168
+ | `users.replace` | `rw`, `rw-sync` | `processRequest({ action: 'replace', ... })` |
2169
+ | `users.sync` | `rw-sync` only | `processRequest({ action: 'sync', ... })` |
2170
+
2171
+ Three meta tools (unless disabled via `mcp.metaTools: false`):
2172
+
2173
+ | Tool | Purpose |
2174
+ | ---------------------- | -------------------------------------------------------------- |
2175
+ | `korm.list_tables` | List the allowlisted tables with column / relation counts. |
2176
+ | `korm.describe_schema` | Return columns + relations for a single allowlisted table. |
2177
+ | `korm.health` | Engine name, library version, allowlist size, `SELECT 1` ping. |
2178
+
2179
+ ### Safety properties
2180
+
2181
+ - **No raw SQL surface.** Tools always go through `processRequest`, which routes user-supplied values through Knex bindings.
2182
+ - **Writes are off by default.** `mcp.mode` defaults to `ro`; opting into `rw` or `rw-sync` is a deliberate config choice that also requires a non-`*` allowlist.
2183
+ - **Nested requests are off by default.** `other_requests` from the LLM is stripped unless you set `mcp.allowNestedRequests: true`.
2184
+ - **Custom action hooks are not auto-exposed.** Add an explicit entry to `mcp.customActions` to make an `on{Action}` hook callable.
2185
+
2186
+ See `docs/agents/11-mcp-server.md` for the full design rationale and the locked decisions behind these defaults.
2187
+
2015
2188
  ## Error Handling
2016
2189
 
2190
+ `processRequest` and `validate` throw a structured **`KormError`** (which
2191
+ extends the native `Error`). Branch on `error.code` rather than
2192
+ string-matching `error.message`. Full reference: [`doc/ERRORS.md`](doc/ERRORS.md).
2193
+
2194
+ | `code` | Meaning |
2195
+ | ----------------------- | -------------------------------------------------------- |
2196
+ | `NO_MATCHING_ROW` | A mutating action matched no row |
2197
+ | `UNKNOWN_ACTION` | Action isn't built-in and has no custom hook |
2198
+ | `NO_CUSTOM_ACTION_HOOK` | Custom action requested, no hook on the model |
2199
+ | `VALIDATION_FAILED` | Input failed validation (`error.context.fields`) |
2200
+ | `UNKNOWN_MODEL` | Model name not in the schema (`error.context.available`) |
2201
+ | `INTERNAL` | Internal invariant / misconfiguration |
2202
+
2017
2203
  ```javascript
2018
- // Global error handler
2019
- app.use((error, req, res, next) => {
2020
- console.error('KORM Error:', error);
2021
-
2022
- res.status(error.status || 500).json({
2023
- success: false,
2024
- message: 'Internal server error',
2025
- error: process.env.NODE_ENV === 'development' ? error.message : 'Something went wrong',
2026
- stack: process.env.NODE_ENV === 'development' ? error.stack : undefined,
2027
- });
2028
- });
2204
+ const { KormError } = require('@dreamtree-org/korm-js');
2029
2205
 
2030
- // Route-specific error handling
2031
2206
  app.post('/api/:model/crud', async (req, res) => {
2032
2207
  try {
2033
- const { model } = req.params;
2034
- const result = await korm.processRequest(req.body, model);
2208
+ const result = await korm.processRequest(req.body, req.params.model);
2035
2209
  res.json(result);
2036
2210
  } catch (error) {
2037
- // Handle validation errors
2038
- if (error.name === 'ValidationError') {
2039
- return res.status(400).json({
2040
- success: false,
2041
- message: 'Validation failed',
2042
- errors: error.message,
2043
- });
2044
- }
2045
-
2046
- // Handle not found errors
2047
- if (error.message.includes('not found')) {
2048
- return res.status(404).json({
2049
- success: false,
2050
- message: error.message,
2051
- });
2211
+ if (error instanceof KormError) {
2212
+ const status =
2213
+ error.code === 'UNKNOWN_MODEL' || error.code === 'NO_MATCHING_ROW'
2214
+ ? 404
2215
+ : error.code === 'VALIDATION_FAILED' ||
2216
+ error.code === 'UNKNOWN_ACTION' ||
2217
+ error.code === 'NO_CUSTOM_ACTION_HOOK'
2218
+ ? 400
2219
+ : 500;
2220
+ // error.toJSON() { name, code, message, hint, context, suggestedFixes }
2221
+ return res.status(status).json({ success: false, error: error.toJSON() });
2052
2222
  }
2053
-
2054
- // Handle other errors
2055
- res.status(400).json({
2056
- success: false,
2057
- message: error.message,
2058
- });
2223
+ res.status(500).json({ success: false, error: 'Internal server error' });
2059
2224
  }
2060
2225
  });
2061
2226
  ```
2062
2227
 
2228
+ > **Migration note.** Validation errors previously surfaced with
2229
+ > `name: 'ValidationError'`. They are now `KormError` with
2230
+ > `code === 'VALIDATION_FAILED'` (the raw field errors remain on
2231
+ > `error.errors` for back-compat; per-field detail is also under
2232
+ > `error.context.fields`). Switch `error.name === 'ValidationError'`
2233
+ > checks to `error.code === 'VALIDATION_FAILED'`.
2234
+
2063
2235
  ## Complete Example Application
2064
2236
 
2065
2237
  ```javascript
@@ -1 +1 @@
1
- const{processRequest:processRequest}=require("./ControllerWrapper"),HelperUtility=require("./BaseHelperUtility");class ValidationError extends Error{constructor(e,t,a,r){super(e),this.name="ValidationError",this.field=t,this.value=a,this.rule=r,this.timestamp=(new Date).toISOString()}}class RequestValidator{constructor(){this.rules=new Map,this.customMessages=new Map,this.transformers=new Map,this.customRegex=new Map,this.customCallbacks=new Map,this.helperUtility=new HelperUtility}rule(e,t,a=null){return this.rules.has(e)||this.rules.set(e,[]),this.rules.get(e).push(t),a&&this.customMessages.set(`${e}.${t.type}`,a),this}string(e,t=null){return this.rule(e,{type:"string",validator:e=>"string"==typeof e},t)}number(e,t=null){return this.rule(e,{type:"number",validator:e=>"number"==typeof e&&!isNaN(e)},t)}boolean(e,t=null){return this.rule(e,{type:"boolean",validator:e=>"boolean"==typeof e},t)}required(e,t=null){return this.rule(e,{type:"required",validator:e=>null!=e&&""!==e},t)}email(e,t=null){const a=/^[^\s@]+@[^\s@]+\.[^\s@]+$/;return this.rule(e,{type:"email",validator:e=>a.test(e)},t)}url(e,t=null){return this.rule(e,{type:"url",validator:e=>{try{return new URL(e),!0}catch{return!1}}},t)}minLength(e,t,a=null){return this.rule(e,{type:"minLength",validator:e=>String(e).length>=t,params:{min:t}},a)}maxLength(e,t,a=null){return this.rule(e,{type:"maxLength",validator:e=>String(e).length<=t,params:{max:t}},a)}min(e,t,a=null){return this.rule(e,{type:"min",validator:e=>Number(e)>=t,params:{min:t}},a)}max(e,t,a=null){return this.rule(e,{type:"max",validator:e=>Number(e)<=t,params:{max:t}},a)}enum(e,t,a=null){return this.rule(e,{type:"enum",validator:e=>t.includes(e),params:{allowedValues:t}},a)}regex(e,t,a=null){return this.rule(e,{type:"regex",validator:e=>t.test(e),params:{pattern:t}},a)}uuid(e,t=null){const a=/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;return this.rule(e,{type:"uuid",validator:e=>a.test(e)},t)}date(e,t=null){return this.rule(e,{type:"date",validator:e=>!isNaN(Date.parse(e))},t)}array(e,t=null){return this.rule(e,{type:"array",validator:e=>Array.isArray(e)},t)}object(e,t=null){return this.rule(e,{type:"object",validator:e=>"object"==typeof e&&null!==e&&!Array.isArray(e)},t)}custom(e,t,a=null){return this.rule(e,{type:"custom",validator:t},a)}transform(e,t){return this.transformers.set(e,t),this}message(e,t,a){return this.customMessages.set(`${e}.${t}`,a),this}getDefaultMessage(e,t,a,r={}){return{required:`${e} is required`,string:`${e} must be a string`,number:`${e} must be a number`,boolean:`${e} must be a boolean`,email:`${e} must be a valid email address`,url:`${e} must be a valid URL`,minLength:`${e} must be at least ${r.min} characters long`,maxLength:`${e} must be at most ${r.max} characters long`,min:`${e} must be at least ${r.min}`,max:`${e} must be at most ${r.max}`,enum:`${e} must be one of: ${r.allowedValues?.join(", ")}`,regex:`${e} format is invalid`,uuid:`${e} must be a valid UUID`,date:`${e} must be a valid date`,array:`${e} must be an array`,object:`${e} must be an object`,custom:`${e} validation failed`}[t]||`${e} validation failed`}validateField(e,t){const a=this.rules.get(e)||[],r=[];let s=t;this.transformers.has(e)&&(s=this.transformers.get(e)(t));for(const t of a)try{if(!t.validator(s)){const a=this.customMessages.get(`${e}.${t.type}`)||this.getDefaultMessage(e,t.type,s,t.params);r.push(new ValidationError(a,e,s,t.type))}}catch(a){const i=this.customMessages.get(`${e}.${t.type}`)||this.getDefaultMessage(e,t.type,s,t.params);r.push(new ValidationError(i,e,s,t.type))}return{value:s,errors:r}}validate(e,t={}){const{source:a="body"}=t,r=[],s={};for(const[t,a]of this.rules){const a=e[t],{value:i,errors:n}=this.validateField(t,a);n.length>0?r.push(...n):s[t]=i}if(r.length>0){const e=new Error(`Validation failed for ${a}`);throw e.name="ValidationError",e.errors=r,e.source=a,e}return s}validateParams(e){return this.validate(e,{source:"params"})}validateBody(e){return this.validate(e,{source:"body"})}validateQuery(e){return this.validate(e,{source:"query"})}validateRequest(e){const t={params:{},body:{},query:{}};try{e.params&&(t.params=this.validateParams(e.params))}catch(e){t.params={error:e}}try{e.body&&(t.body=this.validateBody(e.body))}catch(e){t.body={error:e}}try{e.query&&(t.query=this.validateQuery(e.query))}catch(e){t.query={error:e}}return t}static create(){return new RequestValidator}static schema(e){const t=new RequestValidator;for(const[a,r]of Object.entries(e))Array.isArray(r)?r.forEach(e=>{"string"==typeof e?t[e](a):"object"==typeof e&&t.rule(a,e)}):"string"==typeof r?t[r](a):"object"==typeof r&&t.rule(a,r);return t}parseRuleString(e){const t=[],a=e.split("|");for(const e of a){const a=e.trim();if(a)if("required"===a)t.push({type:"required"});else if(a.startsWith("type:")){const e=a.substring(5).replace(/[()]/g,"").split(",");t.push({type:"type",params:e})}else if(a.startsWith("maxLen:")){const e=parseInt(a.substring(7));isNaN(e)||t.push({type:"maxLength",params:{max:e}})}else if(a.startsWith("minLen:")){const e=parseInt(a.substring(7));isNaN(e)||t.push({type:"minLength",params:{min:e}})}else if(a.startsWith("max:")){const e=parseInt(a.substring(4));isNaN(e)||t.push({type:"max",params:{max:e}})}else if(a.startsWith("min:")){const e=parseInt(a.substring(4));isNaN(e)||t.push({type:"min",params:{min:e}})}else if(a.startsWith("in:")){const e=a.substring(3).split(",");t.push({type:"in",params:{values:e}})}else if(a.startsWith("exists:")){const[e,r]=a.substring(7).split(",");e&&r&&t.push({type:"exists",params:{table:e,field:r}})}else if(a.startsWith("regex:")){const e=a.substring(6).replace(/[{}]/g,"");e&&t.push({type:"regex",params:{regexName:e}})}else if(a.startsWith("default:")){const e=a.substring(8);void 0!==e&&t.push({type:"default",params:{value:e}})}else if(a.startsWith("call:")){const e=a.substring(5).replace(/[{}]/g,"");e&&t.push({type:"call",params:{callbackName:e}})}}return t}addRegex(e,t){return this.customRegex.set(e,new RegExp(t)),this}addCallback(e,t){return this.customCallbacks.set(e,t),this}async validateWithRules(e,t,a={}){const{customRegex:r={},customCallbacks:s={}}=a;for(const[e,t]of Object.entries(r))this.addRegex(e,t);for(const[e,t]of Object.entries(s))this.addCallback(e,t);const i=[],n={};for(const[a,r]of Object.entries(t)){const t=this.parseRuleString(r),s=t.find(e=>"default"===e.type);let l=e[a];(a.includes(".")||a.includes("[]"))&&(l=this.helperUtility.dotParse(a,e));let u=l;!s||null!=l&&""!==l||(u=s.params.value);let o=!0;if(t.some(e=>"required"===e.type)||null!=u&&""!==u){for(const e of t){if("default"===e.type)continue;const t=await this.validateRule(a,u,e);if(!t.isValid){i.push(t.error),o=!1;break}}o&&(n[a]=u)}}if(i.length>0){const e=new Error("Validation failed"),t={name:"ValidationError",errors:i,details:i.map(e=>({field:e.field,message:e.message,value:e.value,rule:e.rule}))};throw e.message=t,e}return n}async validateRule(e,t,a){try{let r=!0,s="";switch(a.type){case"required":r=null!=t&&""!==t,s=r?"":`${e} is required`;break;case"type":const i=a.params;r=i.some(e=>{switch(e){case"string":return"string"==typeof t;case"number":return"number"==typeof t&&!isNaN(t);case"boolean":return"boolean"==typeof t;case"array":return Array.isArray(t);case"object":return"object"==typeof t&&null!==t&&!Array.isArray(t);case"longText":return"string"==typeof t&&t.length>255;default:return!1}}),s=r?"":`${e} must be one of: ${i.join(", ")}`;break;case"maxLength":"string"==typeof t?(r=String(t).length<=a.params.max,s=r?"":`${e} must be at most ${a.params.max} characters long`):Array.isArray(t)&&(r=t.length<=a.params.max,s=r?"":`${e} must be at most ${a.params.max} items`);break;case"minLength":"string"==typeof t?(r=String(t).length>=a.params.min,s=r?"":`${e} must be at least ${a.params.min} characters long`):Array.isArray(t)&&(r=t.length>=a.params.min,s=r?"":`${e} must be at least ${a.params.min} items`);break;case"max":r=Number(t)<=a.params.max,s=r?"":`${e} must be at most ${a.params.max}`;break;case"min":r=Number(t)>=a.params.min,s=r?"":`${e} must be at least ${a.params.min}`;break;case"in":try{if(a.params.values){const i=a.params.values;r=i.includes(t),s=r?"":`${e} must be one of: ${i.join(", ")}`}else{const{table:i,field:n}=a.params,l=await processRequest({model:i,action:"get",request:{where:{[n]:t},limit:1}});r=l&&l.data&&l.data.length>0,s=r?"":`${e} must be a valid value from ${i}`}}catch(t){r=!1,s=`${e} database validation error: ${t.message}`}break;case"exists":try{const{table:i,field:n}=a.params,l=await processRequest({model:i,action:"get",request:{where:{[n]:t},limit:1}});r=l&&l.data&&l.data.length>0,s=r?"":`${e} must exist in ${i}`}catch(t){r=!1,s=`${e} database validation error: ${t.message}`}break;case"regex":const n=a.params.regexName,l=this.customRegex.get(n);l?(r=l.test(t),s=r?"":`${e} format is invalid`):(r=!1,s=`${e} regex pattern '${n}' not found`);break;case"call":const u=a.params.callbackName,o=this.customCallbacks.get(u);if(o&&"function"==typeof o)try{r=o(t),s=r?"":`${e} validation failed`}catch(t){r=!1,s=`${e} validation error: ${t.message}`}else r=!1,s=`${e} callback function '${u}' not found`;break;default:r=!1,s=`${e} unknown validation rule: ${a.type}`}return{isValid:r,error:r?null:new ValidationError(s,e,t,a.type)}}catch(r){return{isValid:!1,error:new ValidationError(`${e} validation failed`,e,t,a.type)}}}}async function validate(e,t,a={}){const r=new RequestValidator;let s={};return!e||"object"!=typeof e||e.body||e.params||e.query?(e.body&&(s={...s,...e.body}),e.params&&(s={...s,...e.params}),e.query&&(s={...s,...e.query})):s=e,await r.validateWithRules(s,t,a)}function validateEmail(e){return/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e)}function validatePassword(e){return/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d@$!%*?&]{8,}$/.test(e)}function validatePhone(e){return/^\+?[\d\s-()]{10,15}$/.test(e)}function validatePAN(e){return/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/.test(e)}function validateAadhaar(e){return/^\d{12}$/.test(e)&&!/^0{12}$/.test(e)}function createValidationMiddleware(e,t={}){return async(a,r,s)=>{try{const i=await validate(a,e,t);if(!1===i.success)return r.status(400).json(i);a.validated=i,s()}catch(e){return r.status(500).json({success:!1,reason:"Validation middleware error",error:e.message})}}}module.exports={RequestValidator:RequestValidator,validate:validate,createValidationMiddleware:createValidationMiddleware,validateEmail:validateEmail,validatePassword:validatePassword,validatePhone:validatePhone,validatePAN:validatePAN,validateAadhaar:validateAadhaar};
1
+ const{processRequest:processRequest}=require("./ControllerWrapper"),HelperUtility=require("./BaseHelperUtility"),KormError=require("./KormError");class ValidationError extends Error{constructor(e,t,a,r){super(e),this.name="ValidationError",this.field=t,this.value=a,this.rule=r,this.timestamp=(new Date).toISOString()}}class RequestValidator{constructor(){this.rules=new Map,this.customMessages=new Map,this.transformers=new Map,this.customRegex=new Map,this.customCallbacks=new Map,this.helperUtility=new HelperUtility}rule(e,t,a=null){return this.rules.has(e)||this.rules.set(e,[]),this.rules.get(e).push(t),a&&this.customMessages.set(`${e}.${t.type}`,a),this}string(e,t=null){return this.rule(e,{type:"string",validator:e=>"string"==typeof e},t)}number(e,t=null){return this.rule(e,{type:"number",validator:e=>"number"==typeof e&&!isNaN(e)},t)}boolean(e,t=null){return this.rule(e,{type:"boolean",validator:e=>"boolean"==typeof e},t)}required(e,t=null){return this.rule(e,{type:"required",validator:e=>null!=e&&""!==e},t)}email(e,t=null){const a=/^[^\s@]+@[^\s@]+\.[^\s@]+$/;return this.rule(e,{type:"email",validator:e=>a.test(e)},t)}url(e,t=null){return this.rule(e,{type:"url",validator:e=>{try{return new URL(e),!0}catch{return!1}}},t)}minLength(e,t,a=null){return this.rule(e,{type:"minLength",validator:e=>String(e).length>=t,params:{min:t}},a)}maxLength(e,t,a=null){return this.rule(e,{type:"maxLength",validator:e=>String(e).length<=t,params:{max:t}},a)}min(e,t,a=null){return this.rule(e,{type:"min",validator:e=>Number(e)>=t,params:{min:t}},a)}max(e,t,a=null){return this.rule(e,{type:"max",validator:e=>Number(e)<=t,params:{max:t}},a)}enum(e,t,a=null){return this.rule(e,{type:"enum",validator:e=>t.includes(e),params:{allowedValues:t}},a)}regex(e,t,a=null){return this.rule(e,{type:"regex",validator:e=>t.test(e),params:{pattern:t}},a)}uuid(e,t=null){const a=/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;return this.rule(e,{type:"uuid",validator:e=>a.test(e)},t)}date(e,t=null){return this.rule(e,{type:"date",validator:e=>!isNaN(Date.parse(e))},t)}array(e,t=null){return this.rule(e,{type:"array",validator:e=>Array.isArray(e)},t)}object(e,t=null){return this.rule(e,{type:"object",validator:e=>"object"==typeof e&&null!==e&&!Array.isArray(e)},t)}custom(e,t,a=null){return this.rule(e,{type:"custom",validator:t},a)}transform(e,t){return this.transformers.set(e,t),this}message(e,t,a){return this.customMessages.set(`${e}.${t}`,a),this}getDefaultMessage(e,t,a,r={}){return{required:`${e} is required`,string:`${e} must be a string`,number:`${e} must be a number`,boolean:`${e} must be a boolean`,email:`${e} must be a valid email address`,url:`${e} must be a valid URL`,minLength:`${e} must be at least ${r.min} characters long`,maxLength:`${e} must be at most ${r.max} characters long`,min:`${e} must be at least ${r.min}`,max:`${e} must be at most ${r.max}`,enum:`${e} must be one of: ${r.allowedValues?.join(", ")}`,regex:`${e} format is invalid`,uuid:`${e} must be a valid UUID`,date:`${e} must be a valid date`,array:`${e} must be an array`,object:`${e} must be an object`,custom:`${e} validation failed`}[t]||`${e} validation failed`}validateField(e,t){const a=this.rules.get(e)||[],r=[];let s=t;this.transformers.has(e)&&(s=this.transformers.get(e)(t));for(const t of a)try{if(!t.validator(s)){const a=this.customMessages.get(`${e}.${t.type}`)||this.getDefaultMessage(e,t.type,s,t.params);r.push(new ValidationError(a,e,s,t.type))}}catch(a){const i=this.customMessages.get(`${e}.${t.type}`)||this.getDefaultMessage(e,t.type,s,t.params);r.push(new ValidationError(i,e,s,t.type))}return{value:s,errors:r}}validate(e,t={}){const{source:a="body"}=t,r=[],s={};for(const[t,a]of this.rules){const a=e[t],{value:i,errors:n}=this.validateField(t,a);n.length>0?r.push(...n):s[t]=i}if(r.length>0)throw KormError.validationFailed({errors:r,source:a});return s}validateParams(e){return this.validate(e,{source:"params"})}validateBody(e){return this.validate(e,{source:"body"})}validateQuery(e){return this.validate(e,{source:"query"})}validateRequest(e){const t={params:{},body:{},query:{}};try{e.params&&(t.params=this.validateParams(e.params))}catch(e){t.params={error:e}}try{e.body&&(t.body=this.validateBody(e.body))}catch(e){t.body={error:e}}try{e.query&&(t.query=this.validateQuery(e.query))}catch(e){t.query={error:e}}return t}static create(){return new RequestValidator}static schema(e){const t=new RequestValidator;for(const[a,r]of Object.entries(e))Array.isArray(r)?r.forEach(e=>{"string"==typeof e?t[e](a):"object"==typeof e&&t.rule(a,e)}):"string"==typeof r?t[r](a):"object"==typeof r&&t.rule(a,r);return t}parseRuleString(e){const t=[],a=e.split("|");for(const e of a){const a=e.trim();if(a)if("required"===a)t.push({type:"required"});else if(a.startsWith("type:")){const e=a.substring(5).replace(/[()]/g,"").split(",");t.push({type:"type",params:e})}else if(a.startsWith("maxLen:")){const e=parseInt(a.substring(7));isNaN(e)||t.push({type:"maxLength",params:{max:e}})}else if(a.startsWith("minLen:")){const e=parseInt(a.substring(7));isNaN(e)||t.push({type:"minLength",params:{min:e}})}else if(a.startsWith("max:")){const e=parseInt(a.substring(4));isNaN(e)||t.push({type:"max",params:{max:e}})}else if(a.startsWith("min:")){const e=parseInt(a.substring(4));isNaN(e)||t.push({type:"min",params:{min:e}})}else if(a.startsWith("in:")){const e=a.substring(3).split(",");t.push({type:"in",params:{values:e}})}else if(a.startsWith("exists:")){const[e,r]=a.substring(7).split(",");e&&r&&t.push({type:"exists",params:{table:e,field:r}})}else if(a.startsWith("regex:")){const e=a.substring(6).replace(/[{}]/g,"");e&&t.push({type:"regex",params:{regexName:e}})}else if(a.startsWith("default:")){const e=a.substring(8);void 0!==e&&t.push({type:"default",params:{value:e}})}else if(a.startsWith("call:")){const e=a.substring(5).replace(/[{}]/g,"");e&&t.push({type:"call",params:{callbackName:e}})}}return t}addRegex(e,t){return this.customRegex.set(e,new RegExp(t)),this}addCallback(e,t){return this.customCallbacks.set(e,t),this}async validateWithRules(e,t,a={}){const{customRegex:r={},customCallbacks:s={}}=a;for(const[e,t]of Object.entries(r))this.addRegex(e,t);for(const[e,t]of Object.entries(s))this.addCallback(e,t);const i=[],n={};for(const[a,r]of Object.entries(t)){const t=this.parseRuleString(r),s=t.find(e=>"default"===e.type);let l=e[a];(a.includes(".")||a.includes("[]"))&&(l=this.helperUtility.dotParse(a,e));let u=l;!s||null!=l&&""!==l||(u=s.params.value);let o=!0;if(t.some(e=>"required"===e.type)||null!=u&&""!==u){for(const e of t){if("default"===e.type)continue;const t=await this.validateRule(a,u,e);if(!t.isValid){i.push(t.error),o=!1;break}}o&&(n[a]=u)}}if(i.length>0)throw KormError.validationFailed({errors:i});return n}async validateRule(e,t,a){try{let r=!0,s="";switch(a.type){case"required":r=null!=t&&""!==t,s=r?"":`${e} is required`;break;case"type":const i=a.params;r=i.some(e=>{switch(e){case"string":return"string"==typeof t;case"number":return"number"==typeof t&&!isNaN(t);case"boolean":return"boolean"==typeof t;case"array":return Array.isArray(t);case"object":return"object"==typeof t&&null!==t&&!Array.isArray(t);case"longText":return"string"==typeof t&&t.length>255;default:return!1}}),s=r?"":`${e} must be one of: ${i.join(", ")}`;break;case"maxLength":"string"==typeof t?(r=String(t).length<=a.params.max,s=r?"":`${e} must be at most ${a.params.max} characters long`):Array.isArray(t)&&(r=t.length<=a.params.max,s=r?"":`${e} must be at most ${a.params.max} items`);break;case"minLength":"string"==typeof t?(r=String(t).length>=a.params.min,s=r?"":`${e} must be at least ${a.params.min} characters long`):Array.isArray(t)&&(r=t.length>=a.params.min,s=r?"":`${e} must be at least ${a.params.min} items`);break;case"max":r=Number(t)<=a.params.max,s=r?"":`${e} must be at most ${a.params.max}`;break;case"min":r=Number(t)>=a.params.min,s=r?"":`${e} must be at least ${a.params.min}`;break;case"in":try{if(a.params.values){const i=a.params.values;r=i.includes(t),s=r?"":`${e} must be one of: ${i.join(", ")}`}else{const{table:i,field:n}=a.params,l=await processRequest({model:i,action:"get",request:{where:{[n]:t},limit:1}});r=l&&l.data&&l.data.length>0,s=r?"":`${e} must be a valid value from ${i}`}}catch(t){r=!1,s=`${e} database validation error: ${t.message}`}break;case"exists":try{const{table:i,field:n}=a.params,l=await processRequest({model:i,action:"get",request:{where:{[n]:t},limit:1}});r=l&&l.data&&l.data.length>0,s=r?"":`${e} must exist in ${i}`}catch(t){r=!1,s=`${e} database validation error: ${t.message}`}break;case"regex":const n=a.params.regexName,l=this.customRegex.get(n);l?(r=l.test(t),s=r?"":`${e} format is invalid`):(r=!1,s=`${e} regex pattern '${n}' not found`);break;case"call":const u=a.params.callbackName,o=this.customCallbacks.get(u);if(o&&"function"==typeof o)try{r=o(t),s=r?"":`${e} validation failed`}catch(t){r=!1,s=`${e} validation error: ${t.message}`}else r=!1,s=`${e} callback function '${u}' not found`;break;default:r=!1,s=`${e} unknown validation rule: ${a.type}`}return{isValid:r,error:r?null:new ValidationError(s,e,t,a.type)}}catch(r){return{isValid:!1,error:new ValidationError(`${e} validation failed`,e,t,a.type)}}}}async function validate(e,t,a={}){const r=new RequestValidator;let s={};return!e||"object"!=typeof e||e.body||e.params||e.query?(e.body&&(s={...s,...e.body}),e.params&&(s={...s,...e.params}),e.query&&(s={...s,...e.query})):s=e,await r.validateWithRules(s,t,a)}function validateEmail(e){return/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e)}function validatePassword(e){return/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d@$!%*?&]{8,}$/.test(e)}function validatePhone(e){return/^\+?[\d\s-()]{10,15}$/.test(e)}function validatePAN(e){return/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/.test(e)}function validateAadhaar(e){return/^\d{12}$/.test(e)&&!/^0{12}$/.test(e)}function createValidationMiddleware(e,t={}){return async(a,r,s)=>{try{const i=await validate(a,e,t);if(!1===i.success)return r.status(400).json(i);a.validated=i,s()}catch(e){return r.status(500).json({success:!1,reason:"Validation middleware error",error:e.message})}}}module.exports={RequestValidator:RequestValidator,validate:validate,createValidationMiddleware:createValidationMiddleware,validateEmail:validateEmail,validatePassword:validatePassword,validatePhone:validatePhone,validatePAN:validatePAN,validateAadhaar:validateAadhaar};
@@ -0,0 +1,265 @@
1
+ # KORM-JS — AI assistant reference
2
+
3
+ > Skill installed by `npx @dreamtree-org/korm-js init --ai <provider>`.
4
+ > Source of truth: [`@dreamtree-org/korm-js`](https://www.npmjs.com/package/@dreamtree-org/korm-js).
5
+ > Re-run the installer to refresh this block when the library updates.
6
+
7
+ ## What KORM-JS is
8
+
9
+ `@dreamtree-org/korm-js` is a **JSON-contract ORM** built on top of Knex. The consumer sends a single JSON request describing the operation; KORM translates it into safe, parameterized SQL across **MySQL, PostgreSQL, and SQLite**. Models and their relations are declared once; CRUD is never hand-written.
10
+
11
+ When helping the user, **always express data access as a KORM request object**, not as raw Knex calls or string SQL.
12
+
13
+ ## Wiring (do not invent alternatives)
14
+
15
+ ```js
16
+ const { initializeKORM } = require('@dreamtree-org/korm-js');
17
+ const knex = require('knex');
18
+
19
+ const db = knex({
20
+ client: 'mysql2', // 'mysql2' | 'pg' | 'sqlite3'
21
+ connection: {
22
+ /* ... */
23
+ },
24
+ });
25
+
26
+ const korm = initializeKORM({
27
+ db,
28
+ dbClient: 'mysql', // 'mysql' | 'pg' | 'sqlite'
29
+ debug: false,
30
+ });
31
+
32
+ const result = await korm.processRequest(requestObject, 'ModelName');
33
+ ```
34
+
35
+ In Express/Next/Fastify the consumer just forwards `req.body` and the model name. KORM does **not** own routing — never suggest an HTTP framework as part of KORM itself.
36
+
37
+ ## Request contract
38
+
39
+ `processRequest(request, modelName)` accepts a JSON object with these top-level fields:
40
+
41
+ | Field | Type | Purpose |
42
+ | ----------------------------------------------- | -------------------------- | -------------------------------------------------------------------------------------------------------- |
43
+ | `action` | string (required) | The operation: `list`, `show`, `create`, `update`, `delete`, `count`, `sum`, `replace`, `upsert`, `sync` |
44
+ | `where` | object \| array | Filter conditions for `list`/`show`/`update`/`delete`/`count`/`sum` |
45
+ | `data` | object \| array | Payload for `create`/`update`/`upsert`/`replace`/`sync` |
46
+ | `select` | array \| string | Columns to return (default: all) |
47
+ | `with` | array of strings | Relations to eager-load (dot-nested allowed: `"Post.Comment"`) |
48
+ | `withWhere` | object | Filters scoped to related rows only — does NOT filter parents |
49
+ | `orderBy` | object \| array \| string | `{column, direction}` / `"column"` / array of either |
50
+ | `limit` | number | Max rows |
51
+ | `offset` / `page` | number | Pagination |
52
+ | `groupBy` | array \| string | GROUP BY columns |
53
+ | `having` | object | Post-group filter |
54
+ | `distinct` | boolean \| array \| string | DISTINCT / DISTINCT ON |
55
+ | `join` / `innerJoin` / `leftJoin` / `rightJoin` | object \| array | Explicit joins (rarely needed — prefer `with`) |
56
+ | `conflict` | array | Conflict columns for `upsert` / `sync` |
57
+ | `other_requests` | object | Nested requests on related models; results returned under `other_responses` |
58
+ | `dryRun` | boolean | If `true`, return the SQL that would run without executing it (see "Inspecting queries" below) |
59
+
60
+ ### Actions
61
+
62
+ | Action | Behavior |
63
+ | --------- | -------------------------------------------------------------------- |
64
+ | `list` | Multi-row read with where/order/limit/offset |
65
+ | `show` | Single-row read |
66
+ | `create` | Insert from `data` (object = 1 row, array = bulk) |
67
+ | `update` | Update rows matching `where` with `data` |
68
+ | `delete` | Delete (soft if the model declares soft-delete; otherwise hard) |
69
+ | `count` | COUNT(\*) of matching rows |
70
+ | `sum` | Sum a column or formula; needs `data.sumColumn` or `data.sumFormula` |
71
+ | `replace` | MySQL-only full row replace (requires PK in `data`) |
72
+ | `upsert` | Insert-or-update keyed by `conflict` columns |
73
+ | `sync` | Upsert matching `data` + delete non-matching within `where` scope |
74
+
75
+ ### `where` operator cheat-sheet
76
+
77
+ Operators are **encoded as string prefixes on the value** (not separate keys):
78
+
79
+ | Operator | Value form | Example | SQL |
80
+ | ------------------- | --------------------------- | --------------------------- | --------------------- |
81
+ | Equals (default) | bare value | `{status: "active"}` | `= ?` |
82
+ | `>=` | `">=N"` | `{age: ">=18"}` | `>= ?` |
83
+ | `<=` | `"<=N"` | `{age: "<=65"}` | `<= ?` |
84
+ | `>` | `">N"` | `{price: ">100"}` | `> ?` |
85
+ | `<` | `"<N"` | `{price: "<500"}` | `< ?` |
86
+ | `!=` | `"!V"` | `{status: "!deleted"}` | `!= ?` |
87
+ | LIKE | `"%V%"` (or `"V%"`, `"%V"`) | `{name: "%john%"}` | `LIKE ?` |
88
+ | IN | `"[]a,b,c"` | `{role: "[]admin,user"}` | `IN (?, ?, ?)` |
89
+ | NOT IN | `"![]a,b"` | `{role: "![]banned"}` | `NOT IN (...)` |
90
+ | BETWEEN | `"><min,max"` | `{age: "><18,65"}` | `BETWEEN ? AND ?` |
91
+ | NOT BETWEEN | `"<>min,max"` | `{score: "<>0,50"}` | `NOT BETWEEN ? AND ?` |
92
+ | IS NULL | `null` | `{deleted_at: null}` | `IS NULL` |
93
+ | OR group | key prefix `"Or:"` | `{"Or:first_name": "John"}` | `OR (...)` |
94
+ | NOT EXISTS relation | `"!RelName": true` | `{"User.!UserRole": true}` | `NOT EXISTS (...)` |
95
+
96
+ Rules:
97
+
98
+ - Non-`Or:`-prefixed keys are ANDed together.
99
+ - Array form `where: [ {a: 1}, {b: 2} ]` is equivalent to object form for ANDs but lets you repeat the same column.
100
+ - `sumFormula` uses `{columnName}` placeholders and accepts only `+ - * / ( )` and decimal literals — **never interpolate user input**.
101
+ - All values flow through Knex bindings. **Do not hand-build SQL strings.**
102
+
103
+ ### Relations (`with`)
104
+
105
+ Relation metadata lives on the model definition (`hasRelations`). The consumer just names them:
106
+
107
+ ```js
108
+ {
109
+ action: "list",
110
+ where: { id: 1 },
111
+ with: ["UserDetail", "Post", "Post.Comment"],
112
+ withWhere: { "Post.status": "published" }
113
+ }
114
+ ```
115
+
116
+ `withWhere` filters child rows but does **not** drop parent rows that have no matching children. To drop parents, filter on the relation in the top-level `where` (e.g. `{"Post.status": "published"}`).
117
+
118
+ Supported relation `type` values when defining a model: `"one"` (belongs-to / one-to-one) and `"many"` (one-to-many or many-to-many via `through`).
119
+
120
+ ### Inspecting queries (`dryRun`)
121
+
122
+ Add `dryRun: true` to any request to get back the SQL it **would** run,
123
+ without executing it. Validation still runs; the database is untouched.
124
+
125
+ ```js
126
+ await korm.processRequest(
127
+ { action: 'delete', where: { status: 'archived' }, dryRun: true },
128
+ 'Post'
129
+ );
130
+ // → { success: true, dryRun: true, action: 'delete', model: 'Post',
131
+ // sql: 'delete from `posts` where `status` = ?', bindings: ['archived'],
132
+ // statements: [{ sql, bindings }] } // `sync` returns 2 statements
133
+ ```
134
+
135
+ Bindings come back as a separate array (never interpolated into `sql`).
136
+
137
+ ### Errors (`KormError`)
138
+
139
+ `processRequest` throws a `KormError` (extends `Error`, so `e.message`
140
+ still works) with a machine-readable `code` you can branch on:
141
+
142
+ | `code` | When |
143
+ | ----------------------- | ---------------------------------------------------- |
144
+ | `NO_MATCHING_ROW` | A mutating action matched no row |
145
+ | `UNKNOWN_ACTION` | Action isn't built-in and has no custom hook |
146
+ | `NO_CUSTOM_ACTION_HOOK` | Custom action requested, no hook on the model |
147
+ | `VALIDATION_FAILED` | Input failed validation (`e.context.fields`) |
148
+ | `UNKNOWN_MODEL` | Model name not in the schema (`e.context.available`) |
149
+ | `INTERNAL` | Internal invariant / misconfiguration |
150
+
151
+ ```js
152
+ const { KormError } = require('@dreamtree-org/korm-js');
153
+ try {
154
+ await korm.processRequest({ action: 'updaet' }, 'User');
155
+ } catch (e) {
156
+ if (e instanceof KormError && e.code === 'UNKNOWN_ACTION') {
157
+ // e.context.closest → "update" (typo suggestion); e.toJSON() for HTTP
158
+ }
159
+ }
160
+ ```
161
+
162
+ ### Discovery + tool schema (`describeSchema` / `getRequestJsonSchema`)
163
+
164
+ Two read-only helpers for agent integration:
165
+
166
+ - `korm.describeSchema()` / `korm.describeModel('User')` — pure-data
167
+ description of tables, typed columns, relations, soft-delete flag, and
168
+ available actions. Use it to discover what's queryable before building
169
+ a request. Throws `KormError` (`code: 'UNKNOWN_MODEL'`) for a bad name.
170
+ - `korm.getRequestJsonSchema('User')` — draft-2020-12 JSON Schema for
171
+ every valid request body for that model (an `action`-discriminated
172
+ `oneOf`). Attach it to an OpenAI/Anthropic tool definition or use it
173
+ for client-side prevalidation:
174
+
175
+ ```js
176
+ const ctx = korm.describeModel('User'); // discovery
177
+ const schema = korm.getRequestJsonSchema('User'); // request contract
178
+ // OpenAI: { type: 'function', function: { name, description, parameters: schema } }
179
+ // Anthropic:{ name, description, input_schema: schema }
180
+ ```
181
+
182
+ ## Canonical examples
183
+
184
+ ### Read with filter + pagination
185
+
186
+ ```js
187
+ await korm.processRequest(
188
+ {
189
+ action: 'list',
190
+ where: { is_active: true, age: '>=18' },
191
+ select: ['id', 'username', 'email'],
192
+ orderBy: { column: 'created_at', direction: 'desc' },
193
+ limit: 20,
194
+ offset: 0,
195
+ },
196
+ 'User'
197
+ );
198
+ ```
199
+
200
+ ### Create
201
+
202
+ ```js
203
+ await korm.processRequest(
204
+ {
205
+ action: 'create',
206
+ data: { username: 'john_doe', email: 'john@example.com', age: 30 },
207
+ },
208
+ 'User'
209
+ );
210
+ ```
211
+
212
+ ### Update by relation
213
+
214
+ ```js
215
+ await korm.processRequest(
216
+ {
217
+ action: 'update',
218
+ where: { 'User.id': 1 },
219
+ data: { status: 'active' },
220
+ with: ['User'],
221
+ },
222
+ 'Profile'
223
+ );
224
+ ```
225
+
226
+ ### Nested eager-load
227
+
228
+ ```js
229
+ await korm.processRequest(
230
+ {
231
+ action: 'list',
232
+ where: { 'User.is_active': true },
233
+ select: ['id', 'title', 'User.username'],
234
+ with: ['User', 'User.UserDetail', 'Comment'],
235
+ withWhere: { 'Comment.is_approved': true },
236
+ limit: 5,
237
+ },
238
+ 'Post'
239
+ );
240
+ ```
241
+
242
+ ### Upsert
243
+
244
+ ```js
245
+ await korm.processRequest(
246
+ {
247
+ action: 'upsert',
248
+ data: { email: 'a@b.com', name: 'Alice' },
249
+ conflict: ['email'],
250
+ },
251
+ 'User'
252
+ );
253
+ ```
254
+
255
+ ## Rules for AI assistants helping consumers
256
+
257
+ 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.
258
+ 2. **Never concatenate user input into SQL.** All filtering goes through `where` operators above.
259
+ 3. **Multi-DB.** Assume the same request runs on MySQL, Postgres, and SQLite. If a feature is engine-specific (e.g. `replace` is MySQL-only), call it out.
260
+ 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.
261
+ 5. **Don't invent fields.** The top-level keys above are the entire contract surface. No `filter`, no `query`, no `params`.
262
+ 6. **Soft delete is per-model.** `delete` becomes a soft-delete only if the model declares it; don't assume.
263
+ 7. **Preview before mutating.** For a risky write, add `dryRun: true` first to inspect the SQL, then re-issue without it.
264
+ 8. **Handle errors by `code`.** Catch `KormError` and branch on `e.code` (table above) rather than string-matching `e.message`.
265
+ 9. **Refresh this doc** by re-running `npx @dreamtree-org/korm-js init --ai <provider>` when the library is upgraded.
@@ -0,0 +1,2 @@
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(){const e=parseArgv(process.argv);let r;e.help&&(printHelp(),process.exit(0));try{r=loadConfig(e.config)}catch(e){process.stderr.write(`korm-mcp: ${e.message}\n`),process.exit(2)}const o=initializeKORM({db:r.db,dbClient:r.dbClient,schema:r.schema,resolverPath:r.resolverPath||null,debug:r.debug||!1}),t=require("../package.json"),n=createServer({controller:o,schema:r.schema,mcpConfig:r.mcp,packageInfo:{name:t.name,version:t.version}});installShutdownHandlers(n);try{await n.start({logger:stderrLogger}),stderrLogger.info(`started; ${n.tools.length} tools exposed (mode=${r.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};