@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.
- package/ControllerWrapper.js +1 -1
- package/KormError.js +1 -0
- package/README.md +209 -37
- package/RequestValidator.js +1 -1
- package/ai-skills/korm-js.md +265 -0
- package/bin/korm-mcp.js +2 -0
- package/build.js +1 -1
- package/clients/BaseSyncTable.js +1 -0
- package/clients/mysql/BaseUtility.js +1 -1
- package/clients/mysql/CurdTable.js +1 -1
- package/clients/mysql/DataTypeMap.js +1 -1
- package/clients/mysql/HookService.js +1 -1
- package/clients/mysql/QueryBuilder.js +1 -1
- package/clients/mysql/QueryService.js +1 -1
- package/clients/mysql/SyncTable.js +1 -1
- package/clients/pg/BaseUtility.js +1 -1
- package/clients/pg/CurdTable.js +1 -1
- package/clients/pg/DataTypeMap.js +1 -1
- package/clients/pg/HookService.js +1 -1
- package/clients/pg/QueryBuilder.js +1 -1
- package/clients/pg/QueryService.js +1 -1
- package/clients/pg/SyncTable.js +1 -1
- package/clients/sqlite/BaseUtility.js +1 -1
- package/clients/sqlite/CurdTable.js +1 -1
- package/clients/sqlite/HookService.js +1 -1
- package/clients/sqlite/QueryBuilder.js +1 -1
- package/clients/sqlite/QueryService.js +1 -1
- package/clients/sqlite/SyncTable.js +1 -1
- package/columnSchema.js +1 -0
- package/index.d.ts +213 -0
- package/index.js +1 -1
- package/jest.config.js +1 -1
- package/package.json +6 -2
- package/requestSchema.js +1 -0
- package/schemaDescribe.js +1 -0
- package/src/mcp/errors.js +1 -0
- package/src/mcp/schemaIntrospect.js +1 -0
- package/src/mcp/server.js +1 -0
- package/src/mcp/toolGenerator.js +1 -0
- package/TableSchemaSync.js +0 -1
package/ControllerWrapper.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
const mysqlWrapper=require("./clients/mysql"),sqliteWrapper=require("./clients/sqlite"),pgWrapper=require("./clients/pg"),InstanceMapper={mysql2:mysqlWrapper,sqlite:sqliteWrapper,pg:pgWrapper},dbClientMapper={mysql2:"mysql2",mysql:"mysql2",pg:"pg",postgresql:"pg",sqlite:"sqlite",sqlite3:"sqlite"};class ControllerWrapper{static db=null;static dbClient=null;static dbClientClass=null;static schema=null;static resolverPath=null;static dbInstance=null;static debug=!1;requestInstance=null;constructor(){this.requestInstance={}}static initializeKORM({db:
|
|
1
|
+
const mysqlWrapper=require("./clients/mysql"),sqliteWrapper=require("./clients/sqlite"),pgWrapper=require("./clients/pg"),KormError=require("./KormError"),{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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
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
|
package/RequestValidator.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
const{processRequest:processRequest}=require("./ControllerWrapper"),HelperUtility=require("./BaseHelperUtility");class ValidationError extends Error{constructor(e,t,a,r){super(e),this.name="ValidationError",this.field=t,this.value=a,this.rule=r,this.timestamp=(new Date).toISOString()}}class RequestValidator{constructor(){this.rules=new Map,this.customMessages=new Map,this.transformers=new Map,this.customRegex=new Map,this.customCallbacks=new Map,this.helperUtility=new HelperUtility}rule(e,t,a=null){return this.rules.has(e)||this.rules.set(e,[]),this.rules.get(e).push(t),a&&this.customMessages.set(`${e}.${t.type}`,a),this}string(e,t=null){return this.rule(e,{type:"string",validator:e=>"string"==typeof e},t)}number(e,t=null){return this.rule(e,{type:"number",validator:e=>"number"==typeof e&&!isNaN(e)},t)}boolean(e,t=null){return this.rule(e,{type:"boolean",validator:e=>"boolean"==typeof e},t)}required(e,t=null){return this.rule(e,{type:"required",validator:e=>null!=e&&""!==e},t)}email(e,t=null){const a=/^[^\s@]+@[^\s@]+\.[^\s@]+$/;return this.rule(e,{type:"email",validator:e=>a.test(e)},t)}url(e,t=null){return this.rule(e,{type:"url",validator:e=>{try{return new URL(e),!0}catch{return!1}}},t)}minLength(e,t,a=null){return this.rule(e,{type:"minLength",validator:e=>String(e).length>=t,params:{min:t}},a)}maxLength(e,t,a=null){return this.rule(e,{type:"maxLength",validator:e=>String(e).length<=t,params:{max:t}},a)}min(e,t,a=null){return this.rule(e,{type:"min",validator:e=>Number(e)>=t,params:{min:t}},a)}max(e,t,a=null){return this.rule(e,{type:"max",validator:e=>Number(e)<=t,params:{max:t}},a)}enum(e,t,a=null){return this.rule(e,{type:"enum",validator:e=>t.includes(e),params:{allowedValues:t}},a)}regex(e,t,a=null){return this.rule(e,{type:"regex",validator:e=>t.test(e),params:{pattern:t}},a)}uuid(e,t=null){const a=/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;return this.rule(e,{type:"uuid",validator:e=>a.test(e)},t)}date(e,t=null){return this.rule(e,{type:"date",validator:e=>!isNaN(Date.parse(e))},t)}array(e,t=null){return this.rule(e,{type:"array",validator:e=>Array.isArray(e)},t)}object(e,t=null){return this.rule(e,{type:"object",validator:e=>"object"==typeof e&&null!==e&&!Array.isArray(e)},t)}custom(e,t,a=null){return this.rule(e,{type:"custom",validator:t},a)}transform(e,t){return this.transformers.set(e,t),this}message(e,t,a){return this.customMessages.set(`${e}.${t}`,a),this}getDefaultMessage(e,t,a,r={}){return{required:`${e} is required`,string:`${e} must be a string`,number:`${e} must be a number`,boolean:`${e} must be a boolean`,email:`${e} must be a valid email address`,url:`${e} must be a valid URL`,minLength:`${e} must be at least ${r.min} characters long`,maxLength:`${e} must be at most ${r.max} characters long`,min:`${e} must be at least ${r.min}`,max:`${e} must be at most ${r.max}`,enum:`${e} must be one of: ${r.allowedValues?.join(", ")}`,regex:`${e} format is invalid`,uuid:`${e} must be a valid UUID`,date:`${e} must be a valid date`,array:`${e} must be an array`,object:`${e} must be an object`,custom:`${e} validation failed`}[t]||`${e} validation failed`}validateField(e,t){const a=this.rules.get(e)||[],r=[];let s=t;this.transformers.has(e)&&(s=this.transformers.get(e)(t));for(const t of a)try{if(!t.validator(s)){const a=this.customMessages.get(`${e}.${t.type}`)||this.getDefaultMessage(e,t.type,s,t.params);r.push(new ValidationError(a,e,s,t.type))}}catch(a){const i=this.customMessages.get(`${e}.${t.type}`)||this.getDefaultMessage(e,t.type,s,t.params);r.push(new ValidationError(i,e,s,t.type))}return{value:s,errors:r}}validate(e,t={}){const{source:a="body"}=t,r=[],s={};for(const[t,a]of this.rules){const a=e[t],{value:i,errors:n}=this.validateField(t,a);n.length>0?r.push(...n):s[t]=i}if(r.length>0){const e=new Error(`Validation failed for ${a}`);throw e.name="ValidationError",e.errors=r,e.source=a,e}return s}validateParams(e){return this.validate(e,{source:"params"})}validateBody(e){return this.validate(e,{source:"body"})}validateQuery(e){return this.validate(e,{source:"query"})}validateRequest(e){const t={params:{},body:{},query:{}};try{e.params&&(t.params=this.validateParams(e.params))}catch(e){t.params={error:e}}try{e.body&&(t.body=this.validateBody(e.body))}catch(e){t.body={error:e}}try{e.query&&(t.query=this.validateQuery(e.query))}catch(e){t.query={error:e}}return t}static create(){return new RequestValidator}static schema(e){const t=new RequestValidator;for(const[a,r]of Object.entries(e))Array.isArray(r)?r.forEach(e=>{"string"==typeof e?t[e](a):"object"==typeof e&&t.rule(a,e)}):"string"==typeof r?t[r](a):"object"==typeof r&&t.rule(a,r);return t}parseRuleString(e){const t=[],a=e.split("|");for(const e of a){const a=e.trim();if(a)if("required"===a)t.push({type:"required"});else if(a.startsWith("type:")){const e=a.substring(5).replace(/[()]/g,"").split(",");t.push({type:"type",params:e})}else if(a.startsWith("maxLen:")){const e=parseInt(a.substring(7));isNaN(e)||t.push({type:"maxLength",params:{max:e}})}else if(a.startsWith("minLen:")){const e=parseInt(a.substring(7));isNaN(e)||t.push({type:"minLength",params:{min:e}})}else if(a.startsWith("max:")){const e=parseInt(a.substring(4));isNaN(e)||t.push({type:"max",params:{max:e}})}else if(a.startsWith("min:")){const e=parseInt(a.substring(4));isNaN(e)||t.push({type:"min",params:{min:e}})}else if(a.startsWith("in:")){const e=a.substring(3).split(",");t.push({type:"in",params:{values:e}})}else if(a.startsWith("exists:")){const[e,r]=a.substring(7).split(",");e&&r&&t.push({type:"exists",params:{table:e,field:r}})}else if(a.startsWith("regex:")){const e=a.substring(6).replace(/[{}]/g,"");e&&t.push({type:"regex",params:{regexName:e}})}else if(a.startsWith("default:")){const e=a.substring(8);void 0!==e&&t.push({type:"default",params:{value:e}})}else if(a.startsWith("call:")){const e=a.substring(5).replace(/[{}]/g,"");e&&t.push({type:"call",params:{callbackName:e}})}}return t}addRegex(e,t){return this.customRegex.set(e,new RegExp(t)),this}addCallback(e,t){return this.customCallbacks.set(e,t),this}async validateWithRules(e,t,a={}){const{customRegex:r={},customCallbacks:s={}}=a;for(const[e,t]of Object.entries(r))this.addRegex(e,t);for(const[e,t]of Object.entries(s))this.addCallback(e,t);const i=[],n={};for(const[a,r]of Object.entries(t)){const t=this.parseRuleString(r),s=t.find(e=>"default"===e.type);let l=e[a];(a.includes(".")||a.includes("[]"))&&(l=this.helperUtility.dotParse(a,e));let u=l;!s||null!=l&&""!==l||(u=s.params.value);let o=!0;if(t.some(e=>"required"===e.type)||null!=u&&""!==u){for(const e of t){if("default"===e.type)continue;const t=await this.validateRule(a,u,e);if(!t.isValid){i.push(t.error),o=!1;break}}o&&(n[a]=u)}}if(i.length>0){const e=new Error("Validation failed"),t={name:"ValidationError",errors:i,details:i.map(e=>({field:e.field,message:e.message,value:e.value,rule:e.rule}))};throw e.message=t,e}return n}async validateRule(e,t,a){try{let r=!0,s="";switch(a.type){case"required":r=null!=t&&""!==t,s=r?"":`${e} is required`;break;case"type":const i=a.params;r=i.some(e=>{switch(e){case"string":return"string"==typeof t;case"number":return"number"==typeof t&&!isNaN(t);case"boolean":return"boolean"==typeof t;case"array":return Array.isArray(t);case"object":return"object"==typeof t&&null!==t&&!Array.isArray(t);case"longText":return"string"==typeof t&&t.length>255;default:return!1}}),s=r?"":`${e} must be one of: ${i.join(", ")}`;break;case"maxLength":"string"==typeof t?(r=String(t).length<=a.params.max,s=r?"":`${e} must be at most ${a.params.max} characters long`):Array.isArray(t)&&(r=t.length<=a.params.max,s=r?"":`${e} must be at most ${a.params.max} items`);break;case"minLength":"string"==typeof t?(r=String(t).length>=a.params.min,s=r?"":`${e} must be at least ${a.params.min} characters long`):Array.isArray(t)&&(r=t.length>=a.params.min,s=r?"":`${e} must be at least ${a.params.min} items`);break;case"max":r=Number(t)<=a.params.max,s=r?"":`${e} must be at most ${a.params.max}`;break;case"min":r=Number(t)>=a.params.min,s=r?"":`${e} must be at least ${a.params.min}`;break;case"in":try{if(a.params.values){const i=a.params.values;r=i.includes(t),s=r?"":`${e} must be one of: ${i.join(", ")}`}else{const{table:i,field:n}=a.params,l=await processRequest({model:i,action:"get",request:{where:{[n]:t},limit:1}});r=l&&l.data&&l.data.length>0,s=r?"":`${e} must be a valid value from ${i}`}}catch(t){r=!1,s=`${e} database validation error: ${t.message}`}break;case"exists":try{const{table:i,field:n}=a.params,l=await processRequest({model:i,action:"get",request:{where:{[n]:t},limit:1}});r=l&&l.data&&l.data.length>0,s=r?"":`${e} must exist in ${i}`}catch(t){r=!1,s=`${e} database validation error: ${t.message}`}break;case"regex":const n=a.params.regexName,l=this.customRegex.get(n);l?(r=l.test(t),s=r?"":`${e} format is invalid`):(r=!1,s=`${e} regex pattern '${n}' not found`);break;case"call":const u=a.params.callbackName,o=this.customCallbacks.get(u);if(o&&"function"==typeof o)try{r=o(t),s=r?"":`${e} validation failed`}catch(t){r=!1,s=`${e} validation error: ${t.message}`}else r=!1,s=`${e} callback function '${u}' not found`;break;default:r=!1,s=`${e} unknown validation rule: ${a.type}`}return{isValid:r,error:r?null:new ValidationError(s,e,t,a.type)}}catch(r){return{isValid:!1,error:new ValidationError(`${e} validation failed`,e,t,a.type)}}}}async function validate(e,t,a={}){const r=new RequestValidator;let s={};return!e||"object"!=typeof e||e.body||e.params||e.query?(e.body&&(s={...s,...e.body}),e.params&&(s={...s,...e.params}),e.query&&(s={...s,...e.query})):s=e,await r.validateWithRules(s,t,a)}function validateEmail(e){return/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e)}function validatePassword(e){return/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d@$!%*?&]{8,}$/.test(e)}function validatePhone(e){return/^\+?[\d\s-()]{10,15}$/.test(e)}function validatePAN(e){return/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/.test(e)}function validateAadhaar(e){return/^\d{12}$/.test(e)&&!/^0{12}$/.test(e)}function createValidationMiddleware(e,t={}){return async(a,r,s)=>{try{const i=await validate(a,e,t);if(!1===i.success)return r.status(400).json(i);a.validated=i,s()}catch(e){return r.status(500).json({success:!1,reason:"Validation middleware error",error:e.message})}}}module.exports={RequestValidator:RequestValidator,validate:validate,createValidationMiddleware:createValidationMiddleware,validateEmail:validateEmail,validatePassword:validatePassword,validatePhone:validatePhone,validatePAN:validatePAN,validateAadhaar:validateAadhaar};
|
|
1
|
+
const{processRequest:processRequest}=require("./ControllerWrapper"),HelperUtility=require("./BaseHelperUtility"),KormError=require("./KormError");class ValidationError extends Error{constructor(e,t,a,r){super(e),this.name="ValidationError",this.field=t,this.value=a,this.rule=r,this.timestamp=(new Date).toISOString()}}class RequestValidator{constructor(){this.rules=new Map,this.customMessages=new Map,this.transformers=new Map,this.customRegex=new Map,this.customCallbacks=new Map,this.helperUtility=new HelperUtility}rule(e,t,a=null){return this.rules.has(e)||this.rules.set(e,[]),this.rules.get(e).push(t),a&&this.customMessages.set(`${e}.${t.type}`,a),this}string(e,t=null){return this.rule(e,{type:"string",validator:e=>"string"==typeof e},t)}number(e,t=null){return this.rule(e,{type:"number",validator:e=>"number"==typeof e&&!isNaN(e)},t)}boolean(e,t=null){return this.rule(e,{type:"boolean",validator:e=>"boolean"==typeof e},t)}required(e,t=null){return this.rule(e,{type:"required",validator:e=>null!=e&&""!==e},t)}email(e,t=null){const a=/^[^\s@]+@[^\s@]+\.[^\s@]+$/;return this.rule(e,{type:"email",validator:e=>a.test(e)},t)}url(e,t=null){return this.rule(e,{type:"url",validator:e=>{try{return new URL(e),!0}catch{return!1}}},t)}minLength(e,t,a=null){return this.rule(e,{type:"minLength",validator:e=>String(e).length>=t,params:{min:t}},a)}maxLength(e,t,a=null){return this.rule(e,{type:"maxLength",validator:e=>String(e).length<=t,params:{max:t}},a)}min(e,t,a=null){return this.rule(e,{type:"min",validator:e=>Number(e)>=t,params:{min:t}},a)}max(e,t,a=null){return this.rule(e,{type:"max",validator:e=>Number(e)<=t,params:{max:t}},a)}enum(e,t,a=null){return this.rule(e,{type:"enum",validator:e=>t.includes(e),params:{allowedValues:t}},a)}regex(e,t,a=null){return this.rule(e,{type:"regex",validator:e=>t.test(e),params:{pattern:t}},a)}uuid(e,t=null){const a=/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;return this.rule(e,{type:"uuid",validator:e=>a.test(e)},t)}date(e,t=null){return this.rule(e,{type:"date",validator:e=>!isNaN(Date.parse(e))},t)}array(e,t=null){return this.rule(e,{type:"array",validator:e=>Array.isArray(e)},t)}object(e,t=null){return this.rule(e,{type:"object",validator:e=>"object"==typeof e&&null!==e&&!Array.isArray(e)},t)}custom(e,t,a=null){return this.rule(e,{type:"custom",validator:t},a)}transform(e,t){return this.transformers.set(e,t),this}message(e,t,a){return this.customMessages.set(`${e}.${t}`,a),this}getDefaultMessage(e,t,a,r={}){return{required:`${e} is required`,string:`${e} must be a string`,number:`${e} must be a number`,boolean:`${e} must be a boolean`,email:`${e} must be a valid email address`,url:`${e} must be a valid URL`,minLength:`${e} must be at least ${r.min} characters long`,maxLength:`${e} must be at most ${r.max} characters long`,min:`${e} must be at least ${r.min}`,max:`${e} must be at most ${r.max}`,enum:`${e} must be one of: ${r.allowedValues?.join(", ")}`,regex:`${e} format is invalid`,uuid:`${e} must be a valid UUID`,date:`${e} must be a valid date`,array:`${e} must be an array`,object:`${e} must be an object`,custom:`${e} validation failed`}[t]||`${e} validation failed`}validateField(e,t){const a=this.rules.get(e)||[],r=[];let s=t;this.transformers.has(e)&&(s=this.transformers.get(e)(t));for(const t of a)try{if(!t.validator(s)){const a=this.customMessages.get(`${e}.${t.type}`)||this.getDefaultMessage(e,t.type,s,t.params);r.push(new ValidationError(a,e,s,t.type))}}catch(a){const i=this.customMessages.get(`${e}.${t.type}`)||this.getDefaultMessage(e,t.type,s,t.params);r.push(new ValidationError(i,e,s,t.type))}return{value:s,errors:r}}validate(e,t={}){const{source:a="body"}=t,r=[],s={};for(const[t,a]of this.rules){const a=e[t],{value:i,errors:n}=this.validateField(t,a);n.length>0?r.push(...n):s[t]=i}if(r.length>0)throw KormError.validationFailed({errors:r,source:a});return s}validateParams(e){return this.validate(e,{source:"params"})}validateBody(e){return this.validate(e,{source:"body"})}validateQuery(e){return this.validate(e,{source:"query"})}validateRequest(e){const t={params:{},body:{},query:{}};try{e.params&&(t.params=this.validateParams(e.params))}catch(e){t.params={error:e}}try{e.body&&(t.body=this.validateBody(e.body))}catch(e){t.body={error:e}}try{e.query&&(t.query=this.validateQuery(e.query))}catch(e){t.query={error:e}}return t}static create(){return new RequestValidator}static schema(e){const t=new RequestValidator;for(const[a,r]of Object.entries(e))Array.isArray(r)?r.forEach(e=>{"string"==typeof e?t[e](a):"object"==typeof e&&t.rule(a,e)}):"string"==typeof r?t[r](a):"object"==typeof r&&t.rule(a,r);return t}parseRuleString(e){const t=[],a=e.split("|");for(const e of a){const a=e.trim();if(a)if("required"===a)t.push({type:"required"});else if(a.startsWith("type:")){const e=a.substring(5).replace(/[()]/g,"").split(",");t.push({type:"type",params:e})}else if(a.startsWith("maxLen:")){const e=parseInt(a.substring(7));isNaN(e)||t.push({type:"maxLength",params:{max:e}})}else if(a.startsWith("minLen:")){const e=parseInt(a.substring(7));isNaN(e)||t.push({type:"minLength",params:{min:e}})}else if(a.startsWith("max:")){const e=parseInt(a.substring(4));isNaN(e)||t.push({type:"max",params:{max:e}})}else if(a.startsWith("min:")){const e=parseInt(a.substring(4));isNaN(e)||t.push({type:"min",params:{min:e}})}else if(a.startsWith("in:")){const e=a.substring(3).split(",");t.push({type:"in",params:{values:e}})}else if(a.startsWith("exists:")){const[e,r]=a.substring(7).split(",");e&&r&&t.push({type:"exists",params:{table:e,field:r}})}else if(a.startsWith("regex:")){const e=a.substring(6).replace(/[{}]/g,"");e&&t.push({type:"regex",params:{regexName:e}})}else if(a.startsWith("default:")){const e=a.substring(8);void 0!==e&&t.push({type:"default",params:{value:e}})}else if(a.startsWith("call:")){const e=a.substring(5).replace(/[{}]/g,"");e&&t.push({type:"call",params:{callbackName:e}})}}return t}addRegex(e,t){return this.customRegex.set(e,new RegExp(t)),this}addCallback(e,t){return this.customCallbacks.set(e,t),this}async validateWithRules(e,t,a={}){const{customRegex:r={},customCallbacks:s={}}=a;for(const[e,t]of Object.entries(r))this.addRegex(e,t);for(const[e,t]of Object.entries(s))this.addCallback(e,t);const i=[],n={};for(const[a,r]of Object.entries(t)){const t=this.parseRuleString(r),s=t.find(e=>"default"===e.type);let l=e[a];(a.includes(".")||a.includes("[]"))&&(l=this.helperUtility.dotParse(a,e));let u=l;!s||null!=l&&""!==l||(u=s.params.value);let o=!0;if(t.some(e=>"required"===e.type)||null!=u&&""!==u){for(const e of t){if("default"===e.type)continue;const t=await this.validateRule(a,u,e);if(!t.isValid){i.push(t.error),o=!1;break}}o&&(n[a]=u)}}if(i.length>0)throw KormError.validationFailed({errors:i});return n}async validateRule(e,t,a){try{let r=!0,s="";switch(a.type){case"required":r=null!=t&&""!==t,s=r?"":`${e} is required`;break;case"type":const i=a.params;r=i.some(e=>{switch(e){case"string":return"string"==typeof t;case"number":return"number"==typeof t&&!isNaN(t);case"boolean":return"boolean"==typeof t;case"array":return Array.isArray(t);case"object":return"object"==typeof t&&null!==t&&!Array.isArray(t);case"longText":return"string"==typeof t&&t.length>255;default:return!1}}),s=r?"":`${e} must be one of: ${i.join(", ")}`;break;case"maxLength":"string"==typeof t?(r=String(t).length<=a.params.max,s=r?"":`${e} must be at most ${a.params.max} characters long`):Array.isArray(t)&&(r=t.length<=a.params.max,s=r?"":`${e} must be at most ${a.params.max} items`);break;case"minLength":"string"==typeof t?(r=String(t).length>=a.params.min,s=r?"":`${e} must be at least ${a.params.min} characters long`):Array.isArray(t)&&(r=t.length>=a.params.min,s=r?"":`${e} must be at least ${a.params.min} items`);break;case"max":r=Number(t)<=a.params.max,s=r?"":`${e} must be at most ${a.params.max}`;break;case"min":r=Number(t)>=a.params.min,s=r?"":`${e} must be at least ${a.params.min}`;break;case"in":try{if(a.params.values){const i=a.params.values;r=i.includes(t),s=r?"":`${e} must be one of: ${i.join(", ")}`}else{const{table:i,field:n}=a.params,l=await processRequest({model:i,action:"get",request:{where:{[n]:t},limit:1}});r=l&&l.data&&l.data.length>0,s=r?"":`${e} must be a valid value from ${i}`}}catch(t){r=!1,s=`${e} database validation error: ${t.message}`}break;case"exists":try{const{table:i,field:n}=a.params,l=await processRequest({model:i,action:"get",request:{where:{[n]:t},limit:1}});r=l&&l.data&&l.data.length>0,s=r?"":`${e} must exist in ${i}`}catch(t){r=!1,s=`${e} database validation error: ${t.message}`}break;case"regex":const n=a.params.regexName,l=this.customRegex.get(n);l?(r=l.test(t),s=r?"":`${e} format is invalid`):(r=!1,s=`${e} regex pattern '${n}' not found`);break;case"call":const u=a.params.callbackName,o=this.customCallbacks.get(u);if(o&&"function"==typeof o)try{r=o(t),s=r?"":`${e} validation failed`}catch(t){r=!1,s=`${e} validation error: ${t.message}`}else r=!1,s=`${e} callback function '${u}' not found`;break;default:r=!1,s=`${e} unknown validation rule: ${a.type}`}return{isValid:r,error:r?null:new ValidationError(s,e,t,a.type)}}catch(r){return{isValid:!1,error:new ValidationError(`${e} validation failed`,e,t,a.type)}}}}async function validate(e,t,a={}){const r=new RequestValidator;let s={};return!e||"object"!=typeof e||e.body||e.params||e.query?(e.body&&(s={...s,...e.body}),e.params&&(s={...s,...e.params}),e.query&&(s={...s,...e.query})):s=e,await r.validateWithRules(s,t,a)}function validateEmail(e){return/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e)}function validatePassword(e){return/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d@$!%*?&]{8,}$/.test(e)}function validatePhone(e){return/^\+?[\d\s-()]{10,15}$/.test(e)}function validatePAN(e){return/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/.test(e)}function validateAadhaar(e){return/^\d{12}$/.test(e)&&!/^0{12}$/.test(e)}function createValidationMiddleware(e,t={}){return async(a,r,s)=>{try{const i=await validate(a,e,t);if(!1===i.success)return r.status(400).json(i);a.validated=i,s()}catch(e){return r.status(500).json({success:!1,reason:"Validation middleware error",error:e.message})}}}module.exports={RequestValidator:RequestValidator,validate:validate,createValidationMiddleware:createValidationMiddleware,validateEmail:validateEmail,validatePassword:validatePassword,validatePhone:validatePhone,validatePAN:validatePAN,validateAadhaar:validateAadhaar};
|
|
@@ -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.
|
package/bin/korm-mcp.js
ADDED
|
@@ -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};
|