@dreamtree-org/korm-js 1.0.59 → 1.1.0

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"),KormError=require("./KormError"),AuthorizationService=require("./AuthorizationService"),{buildModelRequestSchema:buildModelRequestSchema}=require("./requestSchema"),{buildModelDescription:buildModelDescription,SCHEMA_API_VERSION:SCHEMA_API_VERSION}=require("./schemaDescribe"),InstanceMapper={mysql2:mysqlWrapper,sqlite:sqliteWrapper,pg:pgWrapper},dbClientMapper={mysql2:"mysql2",mysql:"mysql2",pg:"pg",postgresql:"pg",sqlite:"sqlite",sqlite3:"sqlite"};class ControllerWrapper{static db=null;static dbClient=null;static dbClientClass=null;static schema=null;static resolverPath=null;static dbInstance=null;static debug=!1;static _authz=new AuthorizationService;requestInstance=null;constructor(){this.requestInstance={}}static initializeKORM({db:e,dbClient:t,schema:s,resolverPath:i=null,debug:r=!1}){this.db=e,this.dbClient=t,this.schema=s,this.resolverPath=i,this.debug=r;const a=dbClientMapper[t];if(!a)throw new Error(`Database client ${t} not found`);const n=InstanceMapper[a];if(!n)throw new Error(`Database client ${t} not found`);return this.dbClientClass=n,this.dbInstance=new n(this),this}static setSchema(e){this.schema=e;const t=this.dbClientClass;if(!t)throw new Error(`Database client ${this.dbClient} not found`);return this.dbInstance=new t(this),this}static _resolveModelName(e){const t=this.schema||{};if(t[e])return e;return Object.keys(t).find(s=>t[s]&&t[s].table===e)||e}static authorize(e,t,s){return this._authz.registerAuthorize(this._resolveModelName(e),t,s),this}static scope(e,t){return this._authz.registerScope(this._resolveModelName(e),t),this}static resetAuthorization(){return this._authz.reset(),this}static async processRequest(e,t=null,s=null){let i=e;if(this._authz.hasRules()&&t){const r=this._resolveModelName(t),a=e&&e.action||"list";this._authz.enforce(r,a,e,s),i=this._authz.applyScope(r,a,e||{},s)}return await this.dbInstance.processRequest(i,t,s)}static async processRequestWithOthers(e,t=null,s=null){return await this.dbInstance.processRequest(e,t,s)}static async syncDatabase(){return await this.dbInstance.syncDatabase()}static async generateSchema(){return await this.dbInstance.generateSchema()}static loadModelClass(e){return this.dbInstance.hookService.loadModelClass(e)}static getModelInstance(e){return this.dbInstance.hookService.getModelInstance(e)}static getRequestJsonSchema(e){const t=this.schema||{},s=t[e]||Object.values(t).find(t=>t&&t.table===e);if(!s)throw KormError.unknownModel({model:e,available:Object.keys(t)});return buildModelRequestSchema(s,{title:`KormRequest<${e}>`})}static _modelHasSoftDelete(e){try{const t=this.dbInstance?.hookService?.getModelInstance?.(e);return!(!t||!0!==t.hasSoftDelete)}catch{return!1}}static describeModel(e,t=null){const s=this.schema||{},i=Object.entries(s).find(([t,s])=>t===e||s&&s.table===e);if(!i)throw KormError.unknownModel({model:e,available:Object.keys(s)});const[r,a]=i,n=buildModelDescription(r,a,{softDelete:this._modelHasSoftDelete(r)});return null!=t&&this._authz.hasRules()&&(n.actions=this._authz.availableActions(r,n.actions,t)),n}static describeSchema(){const e=this.schema||{},t=Object.entries(e).map(([e,t])=>buildModelDescription(e,t,{softDelete:this._modelHasSoftDelete(e)}));return{schemaApiVersion:SCHEMA_API_VERSION,models:t}}}module.exports=ControllerWrapper;
1
+ const mysqlWrapper=require("./clients/mysql"),sqliteWrapper=require("./clients/sqlite"),pgWrapper=require("./clients/pg"),KormError=require("./KormError"),AuthorizationService=require("./AuthorizationService"),{buildModelRequestSchema:buildModelRequestSchema}=require("./requestSchema"),{buildModelDescription:buildModelDescription,SCHEMA_API_VERSION:SCHEMA_API_VERSION}=require("./schemaDescribe"),InstanceMapper={mysql2:mysqlWrapper,sqlite:sqliteWrapper,pg:pgWrapper},dbClientMapper={mysql2:"mysql2",mysql:"mysql2",pg:"pg",postgresql:"pg",sqlite:"sqlite",sqlite3:"sqlite"};class ControllerWrapper{static db=null;static dbClient=null;static dbClientClass=null;static schema=null;static resolverPath=null;static dbInstance=null;static debug=!1;static _authz=new AuthorizationService;requestInstance=null;constructor(){this.requestInstance={}}static initializeKORM({db:e,dbClient:t,schema:s,resolverPath:i=null,debug:r=!1}){this.db=e,this.dbClient=t,this.schema=s,this.resolverPath=i,this.debug=r;const a=dbClientMapper[t];if(!a)throw new Error(`Database client ${t} not found`);const n=InstanceMapper[a];if(!n)throw new Error(`Database client ${t} not found`);return this.dbClientClass=n,this.dbInstance=new n(this),this}static setSchema(e){this.schema=e;const t=this.dbClientClass;if(!t)throw new Error(`Database client ${this.dbClient} not found`);return this.dbInstance=new t(this),this}static _resolveModelName(e){const t=this.schema||{};if(t[e])return e;return Object.keys(t).find(s=>t[s]&&t[s].table===e)||e}static authorize(e,t,s){return this._authz.registerAuthorize(this._resolveModelName(e),t,s),this}static scope(e,t){return this._authz.registerScope(this._resolveModelName(e),t),this}static resetAuthorization(){return this._authz.reset(),this}static async processRequest(e,t=null,s=null){let i=e;if(this._authz.hasRules()&&t){const r=this._resolveModelName(t),a=e&&e.action||"list";this._authz.enforce(r,a,e,s),i=this._authz.applyScope(r,a,e||{},s)}return await this.dbInstance.processRequest(i,t,s)}static async processRequestWithOthers(e,t=null,s=null){return await this.dbInstance.processRequest(e,t,s)}static async syncDatabase(e={}){return await this.dbInstance.syncDatabase(e)}static async generateSchema(){return await this.dbInstance.generateSchema()}static loadModelClass(e){return this.dbInstance.hookService.loadModelClass(e)}static getModelInstance(e){return this.dbInstance.hookService.getModelInstance(e)}static getRequestJsonSchema(e){const t=this.schema||{},s=t[e]||Object.values(t).find(t=>t&&t.table===e);if(!s)throw KormError.unknownModel({model:e,available:Object.keys(t)});return buildModelRequestSchema(s,{title:`KormRequest<${e}>`})}static _modelHasSoftDelete(e){try{const t=this.dbInstance?.hookService?.getModelInstance?.(e);return!(!t||!0!==t.hasSoftDelete)}catch{return!1}}static describeModel(e,t=null){const s=this.schema||{},i=Object.entries(s).find(([t,s])=>t===e||s&&s.table===e);if(!i)throw KormError.unknownModel({model:e,available:Object.keys(s)});const[r,a]=i,n=buildModelDescription(r,a,{softDelete:this._modelHasSoftDelete(r)});return null!=t&&this._authz.hasRules()&&(n.actions=this._authz.availableActions(r,n.actions,t)),n}static describeSchema(){const e=this.schema||{},t=Object.entries(e).map(([e,t])=>buildModelDescription(e,t,{softDelete:this._modelHasSoftDelete(e)}));return{schemaApiVersion:SCHEMA_API_VERSION,models:t}}}module.exports=ControllerWrapper;
package/KormError.js CHANGED
@@ -1 +1 @@
1
- const CODES=Object.freeze({NO_MATCHING_ROW:"NO_MATCHING_ROW",UNKNOWN_ACTION:"UNKNOWN_ACTION",VALIDATION_FAILED:"VALIDATION_FAILED",UNKNOWN_MODEL:"UNKNOWN_MODEL",NO_CUSTOM_ACTION_HOOK:"NO_CUSTOM_ACTION_HOOK",FORBIDDEN:"FORBIDDEN",INTERNAL:"INTERNAL"}),ACTIONS=Object.freeze(["count","sum","list","show","create","update","replace","upsert","sync","delete"]);function levenshtein(e,o){const t=e.length,r=o.length;if(0===t)return r;if(0===r)return t;let n=Array.from({length:r+1},(e,o)=>o),i=new Array(r+1);for(let s=1;s<=t;s++){i[0]=s;for(let t=1;t<=r;t++){const r=e[s-1]===o[t-1]?0:1;i[t]=Math.min(n[t]+1,i[t-1]+1,n[t-1]+r)}[n,i]=[i,n]}return n[r]}function closestAction(e,o=ACTIONS){if(!e)return null;const t=String(e).toLowerCase();let r=null,n=1/0;for(const e of o){const o=levenshtein(t,e);o<n&&(n=o,r=e)}return n<=Math.max(2,Math.ceil(t.length/2))?r:null}class KormError extends Error{constructor(e,{code:o=CODES.INTERNAL,hint:t=null,context:r={},suggestedFixes:n=null}={}){super(e),this.name="KormError",this.code=o,this.hint=t,this.context=r,this.suggestedFixes=n,Error.captureStackTrace&&Error.captureStackTrace(this,KormError)}toJSON(){return{name:this.name,code:this.code,message:this.message,hint:this.hint,context:this.context,suggestedFixes:this.suggestedFixes}}}KormError.noMatchingRow=({action:e,model:o})=>{const t="update"===e?"No row matched the where clause. Use `upsert` to insert-or-update, or `sync` to reconcile.":"No row matched the where clause for this action.";return new KormError(`No matching row for action "${e}" on model "${o}".`,{code:CODES.NO_MATCHING_ROW,hint:t,context:{action:e,model:o}})},KormError.unknownAction=({action:e,model:o,hasCustomHook:t=!1})=>{const r=closestAction(e),n=t?CODES.NO_CUSTOM_ACTION_HOOK:CODES.UNKNOWN_ACTION,i=t?`No custom action hook found for "${o}.${e}".`:`Unknown action "${e}".`,s=r?`Did you mean "${r}"? Valid actions: ${ACTIONS.join(", ")}.`:`Valid actions: ${ACTIONS.join(", ")}. Custom actions require an on<Action>Action hook on the model.`;return new KormError(i,{code:n,hint:s,context:{action:e,model:o,validActions:ACTIONS,closest:r}})},KormError.unknownModel=({model:e,available:o=[]})=>{const t=o.length?`Available models: ${o.join(", ")}.`:"No models are registered in the schema.";return new KormError(`Model "${e}" not found.`,{code:CODES.UNKNOWN_MODEL,hint:t,context:{model:e,available:o}})},KormError.forbidden=({model:e,action:o,hint:t=null,context:r={}}={})=>new KormError(`Action "${o}" on model "${e}" is not permitted in this context.`,{code:CODES.FORBIDDEN,hint:t||"A registered authorize() predicate denied this request for the current context.",context:{action:o,model:e,...r}}),KormError.validationFailed=({errors:e=[],source:o=null})=>{const t=e.map(e=>({field:e.field,message:e.message,value:e.value,rule:e.rule})),r=t.map(e=>e.field).filter(Boolean),n=new KormError(`Validation failed${o?` for ${o}`:""}${r.length?`: ${r.join(", ")}`:""}.`,{code:CODES.VALIDATION_FAILED,hint:"Fix the listed fields and resubmit. See context.fields for per-field detail.",context:{source:o,fields:t}});return n.errors=e,n},KormError.CODES=CODES,KormError.ACTIONS=ACTIONS,KormError.closestAction=closestAction,module.exports=KormError;
1
+ const CODES=Object.freeze({NO_MATCHING_ROW:"NO_MATCHING_ROW",UNKNOWN_ACTION:"UNKNOWN_ACTION",VALIDATION_FAILED:"VALIDATION_FAILED",UNKNOWN_MODEL:"UNKNOWN_MODEL",NO_CUSTOM_ACTION_HOOK:"NO_CUSTOM_ACTION_HOOK",FORBIDDEN:"FORBIDDEN",SYNC_FK_ORPHAN:"SYNC_FK_ORPHAN",INTERNAL:"INTERNAL"}),ACTIONS=Object.freeze(["count","sum","list","show","create","update","replace","upsert","sync","delete"]);function levenshtein(e,t){const o=e.length,r=t.length;if(0===o)return r;if(0===r)return o;let n=Array.from({length:r+1},(e,t)=>t),i=new Array(r+1);for(let s=1;s<=o;s++){i[0]=s;for(let o=1;o<=r;o++){const r=e[s-1]===t[o-1]?0:1;i[o]=Math.min(n[o]+1,i[o-1]+1,n[o-1]+r)}[n,i]=[i,n]}return n[r]}function closestAction(e,t=ACTIONS){if(!e)return null;const o=String(e).toLowerCase();let r=null,n=1/0;for(const e of t){const t=levenshtein(o,e);t<n&&(n=t,r=e)}return n<=Math.max(2,Math.ceil(o.length/2))?r:null}class KormError extends Error{constructor(e,{code:t=CODES.INTERNAL,hint:o=null,context:r={},suggestedFixes:n=null}={}){super(e),this.name="KormError",this.code=t,this.hint=o,this.context=r,this.suggestedFixes=n,Error.captureStackTrace&&Error.captureStackTrace(this,KormError)}toJSON(){return{name:this.name,code:this.code,message:this.message,hint:this.hint,context:this.context,suggestedFixes:this.suggestedFixes}}}KormError.noMatchingRow=({action:e,model:t})=>{const o="update"===e?"No row matched the where clause. Use `upsert` to insert-or-update, or `sync` to reconcile.":"No row matched the where clause for this action.";return new KormError(`No matching row for action "${e}" on model "${t}".`,{code:CODES.NO_MATCHING_ROW,hint:o,context:{action:e,model:t}})},KormError.unknownAction=({action:e,model:t,hasCustomHook:o=!1})=>{const r=closestAction(e),n=o?CODES.NO_CUSTOM_ACTION_HOOK:CODES.UNKNOWN_ACTION,i=o?`No custom action hook found for "${t}.${e}".`:`Unknown action "${e}".`,s=r?`Did you mean "${r}"? Valid actions: ${ACTIONS.join(", ")}.`:`Valid actions: ${ACTIONS.join(", ")}. Custom actions require an on<Action>Action hook on the model.`;return new KormError(i,{code:n,hint:s,context:{action:e,model:t,validActions:ACTIONS,closest:r}})},KormError.unknownModel=({model:e,available:t=[]})=>{const o=t.length?`Available models: ${t.join(", ")}.`:"No models are registered in the schema.";return new KormError(`Model "${e}" not found.`,{code:CODES.UNKNOWN_MODEL,hint:o,context:{model:e,available:t}})},KormError.forbidden=({model:e,action:t,hint:o=null,context:r={}}={})=>new KormError(`Action "${t}" on model "${e}" is not permitted in this context.`,{code:CODES.FORBIDDEN,hint:o||"A registered authorize() predicate denied this request for the current context.",context:{action:t,model:e,...r}}),KormError.validationFailed=({errors:e=[],source:t=null})=>{const o=e.map(e=>({field:e.field,message:e.message,value:e.value,rule:e.rule})),r=o.map(e=>e.field).filter(Boolean),n=new KormError(`Validation failed${t?` for ${t}`:""}${r.length?`: ${r.join(", ")}`:""}.`,{code:CODES.VALIDATION_FAILED,hint:"Fix the listed fields and resubmit. See context.fields for per-field detail.",context:{source:t,fields:o}});return n.errors=e,n},KormError.foreignKeyOrphans=({table:e,offenders:t=[]})=>{const o=t.map(t=>{const o=`${t.rowsAffected} existing row${1===t.rowsAffected?"":"s"}`,r=JSON.stringify(t.backfillValue);return`"${e}.${t.column}" → "${t.parentTable}.${t.parentColumn}": ${o} would be back-filled with ${r}, which does not exist in "${t.parentTable}"`}).join("; ");return new KormError(`Cannot add foreign key on "${e}": existing rows would be orphaned (${o}).`,{code:CODES.SYNC_FK_ORPHAN,hint:"Adding this foreign-key column to a populated table would leave child rows pointing at a parent that does not exist, so the database would reject the constraint. Resolve the data first: make the column nullable (existing rows back-fill NULL, which is FK-safe), give it a default that exists in the parent, populate the column for existing rows before syncing, insert the missing parent rows, or clear the child table. No data was changed — korm aborted before altering the table.",context:{table:e,offenders:t}})},KormError.CODES=CODES,KormError.ACTIONS=ACTIONS,KormError.closestAction=closestAction,module.exports=KormError;
package/README.md CHANGED
@@ -20,7 +20,7 @@ Have an idea, a bug, or just want to say hi? Reach out anytime — feedback from
20
20
 
21
21
  ## 💚 Sponsor & Support
22
22
 
23
- KORM-JS is **free and open source**, built in the open and powered entirely by passion and late nights. Every feature you rely on — every multi-engine query, relation-aware join, and line of zero-boilerplate CRUD — represents hours of careful craft so that *your* backend can ship faster and stay safer.
23
+ KORM-JS is **free and open source**, built in the open and powered entirely by passion and late nights. Every feature you rely on — every multi-engine query, relation-aware join, and line of zero-boilerplate CRUD — represents hours of careful craft so that _your_ backend can ship faster and stay safer.
24
24
 
25
25
  If this library has saved you time, sparked an idea, or spared you a pile of hand-written SQL, please consider supporting its continued development. **Your sponsorship keeps the updates coming, the bugs squashed, and the docs sharp — for the whole community.** Even the smallest contribution is a huge encouragement. 🙏
26
26
 
@@ -463,10 +463,10 @@ Replaces an entire row, keyed on the model's primary key (include the PK in
463
463
  `data`). Supported on **all three engines**, but the semantics differ by what
464
464
  each engine can express:
465
465
 
466
- | Engine | Statement | Semantics |
467
- | --- | --- | --- |
468
- | MySQL | `REPLACE INTO` | True delete + insert. Columns omitted from `data` reset to their column **DEFAULT** (or `NULL`). |
469
- | SQLite | `INSERT OR REPLACE` | True delete + insert (same as MySQL). |
466
+ | Engine | Statement | Semantics |
467
+ | -------- | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
468
+ | MySQL | `REPLACE INTO` | True delete + insert. Columns omitted from `data` reset to their column **DEFAULT** (or `NULL`). |
469
+ | SQLite | `INSERT OR REPLACE` | True delete + insert (same as MySQL). |
470
470
  | Postgres | `INSERT … ON CONFLICT (pk) DO UPDATE` | **Merge**, not a true replace: columns omitted from `data` keep their existing values (Postgres has no native `REPLACE`). |
471
471
 
472
472
  > ⚠️ **Cross-engine caveat:** on Postgres, `replace` is emulated and behaves
@@ -1643,18 +1643,18 @@ type|modifier1|modifier2|...
1643
1643
 
1644
1644
  **Column Modifiers:**
1645
1645
 
1646
- | Modifier | Description | Example |
1647
- | ------------------------- | ------------------ | ---------- | --------------------------- | -------------- |
1648
- | `size:n` | Column size | `varchar | size:255` |
1649
- | `unsigned`¹ | Unsigned integer | `int | unsigned` |
1650
- | `primaryKey` | Primary key column | `bigint | primaryKey` |
1651
- | `autoIncrement` | Auto increment | `bigint | primaryKey | autoIncrement` |
1652
- | `notNull` | Not nullable | `varchar | size:255 | notNull` |
1653
- | `unique` | Unique constraint | `varchar | unique` |
1654
- | `default:value` | Default value | `tinyint | default:1` |
1655
- | `onUpdate:value`² | On update value | `timestamp | onUpdate:CURRENT_TIMESTAMP` |
1656
- | `comment:text` | Column comment | `varchar | comment:User email address` |
1657
- | `foreignKey:table:column`³| Foreign key | `int | foreignKey:users:id` |
1646
+ | Modifier | Description | Example |
1647
+ | -------------------------- | ------------------ | ---------- | --------------------------- | -------------- |
1648
+ | `size:n` | Column size | `varchar | size:255` |
1649
+ | `unsigned`¹ | Unsigned integer | `int | unsigned` |
1650
+ | `primaryKey` | Primary key column | `bigint | primaryKey` |
1651
+ | `autoIncrement` | Auto increment | `bigint | primaryKey | autoIncrement` |
1652
+ | `notNull` | Not nullable | `varchar | size:255 | notNull` |
1653
+ | `unique` | Unique constraint | `varchar | unique` |
1654
+ | `default:value` | Default value | `tinyint | default:1` |
1655
+ | `onUpdate:value`² | On update value | `timestamp | onUpdate:CURRENT_TIMESTAMP` |
1656
+ | `comment:text` | Column comment | `varchar | comment:User email address` |
1657
+ | `foreignKey:table:column | Foreign key | `int | foreignKey:users:id` |
1658
1658
 
1659
1659
  ¹ **Engine-specific.** `unsigned` is honored on MySQL and SQLite. PostgreSQL has no unsigned integer type and silently drops the modifier.
1660
1660
  ² **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).
@@ -1662,6 +1662,42 @@ type|modifier1|modifier2|...
1662
1662
 
1663
1663
  > **Dependency ordering.** `syncDatabase()` creates tables in foreign-key dependency order (parents before the children that reference them), so you can declare your models in **any order** — a child listed before its parent still syncs cleanly (MySQL/PostgreSQL otherwise reject the forward FK reference). FKs to tables outside the schema impose no ordering; a genuine circular FK dependency falls back to declaration order with a warning.
1664
1664
 
1665
+ > **Orphan-row guard.** Adding a `notNull` foreign-key column to a table that **already has rows** makes the database back-fill those rows (with `0` for a numeric column, or the column default). If that value has no matching parent row, MySQL/PostgreSQL reject the new constraint with a cryptic driver error (MySQL `errno 1452`, `ER_NO_REFERENCED_ROW_2`). `syncDatabase()` now detects this **before** altering the table and throws a clear `KormError` (`code: 'SYNC_FK_ORPHAN'`) naming the child column, the parent it points at, the affected row count, and the offending back-fill value — **no data is mutated**. Resolve it by making the column `nullable` (existing rows back-fill `NULL`, which is FK-safe), giving it a `default` that exists in the parent, populating the column for existing rows before syncing, or inserting the missing parent rows.
1666
+
1667
+ #### `syncDatabase(options)` — additive-only, resilient, dry-run
1668
+
1669
+ `syncDatabase()` accepts an optional `options` object that controls how it reconciles drift between your schema and the live database. The defaults are **safe**: it never drops data and never half-migrates silently.
1670
+
1671
+ | Option | Default | Effect |
1672
+ | ----------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1673
+ | `dropColumns` | `false` | When `false` (default), a column that exists in the database but is **absent from the schema** is **left in place** (reported as drift, never dropped). Set `true` to drop such columns. |
1674
+ | `prune` | — | Alias for `dropColumns`. |
1675
+ | `continueOnError` | `false` | When `false` (default), the first failing statement throws and aborts the run (fail-fast). When `true`, a failing statement is **skipped and collected**, and the rest of the schema still applies. |
1676
+ | `dryRun` | `false` | When `true`, walks the same diff **without executing anything** and returns the planned operations (destructive drops flagged). The database is not touched. |
1677
+
1678
+ **Why the additive-only default matters.** Previously `syncDatabase()` dropped any column not declared in the schema — silently destroying data for drifted/legacy columns, and aborting the entire multi-table sync if the drop was blocked (e.g. the column was referenced by a foreign key). The default is now **additive** (create tables, add columns); destructive drops are opt-in.
1679
+
1680
+ **Return value.** A default sync resolves to `undefined` (unchanged). When you pass `dryRun: true` **or** `continueOnError: true`, it resolves to a **report**:
1681
+
1682
+ ```javascript
1683
+ // Preview what a sync would do — change nothing
1684
+ const plan = await korm.syncDatabase({ dryRun: true });
1685
+ // {
1686
+ // dryRun: true,
1687
+ // plan: [{ table: 'users', operation: 'add', column: 'bio', destructive: false }, …],
1688
+ // skippedDrops: [{ table: 'products', column: 'warehouse' }], // drift left in place
1689
+ // applied: [], errors: [],
1690
+ // }
1691
+
1692
+ // Drop drifted columns too
1693
+ await korm.syncDatabase({ dropColumns: true });
1694
+
1695
+ // Keep going past failures, then inspect what was skipped
1696
+ const report = await korm.syncDatabase({ continueOnError: true });
1697
+ // report.errors → [{ table, operation, column, message }]
1698
+ // report.applied → [{ table, operation, column }]
1699
+ ```
1700
+
1665
1701
  **Special Default Values:**
1666
1702
 
1667
1703
  - `now` or `now()` → `CURRENT_TIMESTAMP`
@@ -1727,7 +1763,9 @@ const result = await korm.processRequestWithOthers(requestBody, modelName, conte
1727
1763
  // Set schema manually
1728
1764
  korm.setSchema(schemaObject);
1729
1765
 
1730
- // Sync database with schema (creates/updates tables)
1766
+ // Sync database with schema (additive-only by default: creates tables, adds
1767
+ // columns; never drops drifted columns unless you opt in). Options:
1768
+ // { dropColumns | prune, continueOnError, dryRun } — see "syncDatabase(options)".
1731
1769
  await korm.syncDatabase();
1732
1770
 
1733
1771
  // Generate schema from existing database
@@ -1766,18 +1804,18 @@ const modelInstance = korm.getModelInstance(modelDef);
1766
1804
 
1767
1805
  ### ProcessRequest Actions Summary
1768
1806
 
1769
- | Action | Description | Required Fields |
1770
- | --------- | ------------------------ | ---------------------------------------------------------------- |
1771
- | `list` | Get multiple records | None (optional: `where`, `select`, `orderBy`, `limit`, `offset`) |
1772
- | `show` | Get single record | `where` |
1773
- | `create` | Create new record | `data` |
1774
- | `update` | Update record(s) | `where`, `data` |
1775
- | `delete` | Delete record(s) | `where` |
1776
- | `count` | Count records | None (optional: `where`) |
1777
- | `sum` | Sum column or expression | `data.sumColumn` or `data.sumFormula` (optional: `where`) |
1778
- | `replace` | Replace full row by PK (all engines; pg = merge, see §8) | `data` (optional: `conflict`) |
1779
- | `upsert` | Insert or update | `data`, `conflict` |
1780
- | `sync` | Upsert + delete | `data`, `conflict`, `where` |
1807
+ | Action | Description | Required Fields |
1808
+ | --------- | -------------------------------------------------------- | ---------------------------------------------------------------- |
1809
+ | `list` | Get multiple records | None (optional: `where`, `select`, `orderBy`, `limit`, `offset`) |
1810
+ | `show` | Get single record | `where` |
1811
+ | `create` | Create new record | `data` |
1812
+ | `update` | Update record(s) | `where`, `data` |
1813
+ | `delete` | Delete record(s) | `where` |
1814
+ | `count` | Count records | None (optional: `where`) |
1815
+ | `sum` | Sum column or expression | `data.sumColumn` or `data.sumFormula` (optional: `where`) |
1816
+ | `replace` | Replace full row by PK (all engines; pg = merge, see §8) | `data` (optional: `conflict`) |
1817
+ | `upsert` | Insert or update | `data`, `conflict` |
1818
+ | `sync` | Upsert + delete | `data`, `conflict`, `where` |
1781
1819
 
1782
1820
  ### Validation Rules
1783
1821
 
@@ -2324,14 +2362,16 @@ app.post('/api/:model', (req, res) =>
2324
2362
  extends the native `Error`). Branch on `error.code` rather than
2325
2363
  string-matching `error.message`. Full reference: [`doc/ERRORS.md`](doc/ERRORS.md).
2326
2364
 
2327
- | `code` | Meaning |
2328
- | ----------------------- | -------------------------------------------------------- |
2329
- | `NO_MATCHING_ROW` | A mutating action matched no row |
2330
- | `UNKNOWN_ACTION` | Action isn't built-in and has no custom hook |
2331
- | `NO_CUSTOM_ACTION_HOOK` | Custom action requested, no hook on the model |
2332
- | `VALIDATION_FAILED` | Input failed validation (`error.context.fields`) |
2333
- | `UNKNOWN_MODEL` | Model name not in the schema (`error.context.available`) |
2334
- | `INTERNAL` | Internal invariant / misconfiguration |
2365
+ | `code` | Meaning |
2366
+ | ----------------------- | ---------------------------------------------------------------- |
2367
+ | `NO_MATCHING_ROW` | A mutating action matched no row |
2368
+ | `UNKNOWN_ACTION` | Action isn't built-in and has no custom hook |
2369
+ | `NO_CUSTOM_ACTION_HOOK` | Custom action requested, no hook on the model |
2370
+ | `VALIDATION_FAILED` | Input failed validation (`error.context.fields`) |
2371
+ | `UNKNOWN_MODEL` | Model name not in the schema (`error.context.available`) |
2372
+ | `FORBIDDEN` | An `authorize()` predicate denied the request |
2373
+ | `SYNC_FK_ORPHAN` | Sync would orphan rows with a new FK (`error.context.offenders`) |
2374
+ | `INTERNAL` | Internal invariant / misconfiguration |
2335
2375
 
2336
2376
  ```javascript
2337
2377
  const { KormError } = require('@dreamtree-org/korm-js');
@@ -38,39 +38,39 @@ In Express/Next/Fastify the consumer just forwards `req.body` and the model name
38
38
 
39
39
  `processRequest(request, modelName)` accepts a JSON object with these top-level fields:
40
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 |
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
49
  | `orderBy` | object \| array \| string | `{column, direction}` / `"column"` / array of either. Object MUST use the `column` key — `{created_at:"desc"}` is rejected (`VALIDATION_FAILED`) |
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) |
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
59
 
60
60
  ### Actions
61
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` |
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
71
  | `replace` | Full-row replace by PK, all engines (MySQL/SQLite = delete+insert; pg = ON CONFLICT merge — omitted cols retained). Optional `conflict` |
72
- | `upsert` | Insert-or-update keyed by `conflict` columns |
73
- | `sync` | Upsert matching `data` + delete non-matching within `where` scope |
72
+ | `upsert` | Insert-or-update keyed by `conflict` columns |
73
+ | `sync` | Upsert matching `data` + delete non-matching within `where` scope |
74
74
 
75
75
  ### `where` operator cheat-sheet
76
76
 
@@ -139,15 +139,16 @@ Bindings come back as a separate array (never interpolated into `sql`).
139
139
  `processRequest` throws a `KormError` (extends `Error`, so `e.message`
140
140
  still works) with a machine-readable `code` you can branch on:
141
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
- | `FORBIDDEN` | A registered `authorize()` predicate denied the request (`e.context.model`/`action`) |
150
- | `INTERNAL` | Internal invariant / misconfiguration |
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
+ | `FORBIDDEN` | A registered `authorize()` predicate denied the request (`e.context.model`/`action`) |
150
+ | `SYNC_FK_ORPHAN` | `syncDatabase()` would add a foreign key that orphans existing rows (`e.context.offenders`); no data is mutated |
151
+ | `INTERNAL` | Internal invariant / misconfiguration |
151
152
 
152
153
  ```js
153
154
  const { KormError } = require('@dreamtree-org/korm-js');
@@ -1 +1 @@
1
- const logger=require("../Logger"),ENGINE_WARNINGS=new Set;function warnOnce(e,t){ENGINE_WARNINGS.has(e)||(ENGINE_WARNINGS.add(e),logger.warn(t))}const BASE_TYPE_DISPATCHER={VARCHAR:(e,t,n)=>e.string(t,n.size||255),CHAR:(e,t,n)=>e.string(t,n.size||255),TEXT:(e,t)=>e.text(t),MEDIUMTEXT:(e,t)=>e.text(t),LONGTEXT:(e,t)=>e.text(t),INT:(e,t)=>e.integer(t),INTEGER:(e,t)=>e.integer(t),MEDIUMINT:(e,t)=>e.integer(t),SMALLINT:(e,t)=>e.integer(t),BIGINT:(e,t)=>e.bigInteger(t),TINYINT:(e,t,n)=>e.tinyint?e.tinyint(t):e.specificType(t,n.size?`TINYINT(${n.size})`:"TINYINT"),BOOLEAN:(e,t)=>e.boolean(t),BOOL:(e,t)=>e.boolean(t),DATE:(e,t)=>e.date(t),DATETIME:(e,t)=>e.dateTime(t),TIMESTAMP:(e,t)=>e.timestamp(t),TIME:(e,t)=>e.time(t),JSON:(e,t)=>e.json(t),FLOAT:(e,t)=>e.float(t),DOUBLE:(e,t)=>e.double?e.double(t):e.float(t),REAL:(e,t)=>e.double?e.double(t):e.float(t),DECIMAL:(e,t)=>e.decimal(t),NUMERIC:(e,t)=>e.decimal(t),BINARY:(e,t)=>e.binary(t),VARBINARY:(e,t)=>e.binary(t),BLOB:(e,t)=>e.binary(t),UUID:(e,t)=>e.uuid?e.uuid(t):e.string(t,36)},COLUMN_STRING_SUFFIXES=[e=>e.size?`|size:${e.size}`:"",e=>e.isUnsigned?"|unsigned":"",e=>e.primary?"|primaryKey":"",e=>e.autoIncrement?"|autoIncrement":"",e=>e.nullable?"":"|notNull",e=>e.unique?"|unique":"",e=>null!=e.default&&""!==e.default?`|default:${e.default}`:"",e=>e.onUpdate?`|onUpdate:${e.onUpdate}`:"",e=>e.comment?`|comment:${e.comment}`:"",e=>e.hasForeignKey&&e.foreignMapTables?.[0]?`|foreignKey:${e.foreignMapTables[0].table}:${e.foreignMapTables[0].column}`:""];class BaseSyncTable{constructor(e,t,n=null){this.db=e,this.utils=t,this.controllerWrapper=n}_getClientName(){throw new Error("_getClientName must be overridden by engine subclass")}async existsTable(e){return this.db.schema.hasTable(e)}async syncTable(e){if(await this.existsTable(e.table)){const t=await this.getAlterations(e);await this.alterTable(e.table,t)}else await this.createTable(e);await this._applyExtras(e)}async syncDatabase(){if(!this.controllerWrapper?.schema)throw new Error("controllerWrapper.schema not set.");const e=this.controllerWrapper.schema;for(const t of this._orderTablesByDependency(e))await this.syncTable(e[t]),await this.syncSeedData(e[t],t);logger.info("Database synced by SyncTable...")}_orderTablesByDependency(e){const t=Object.keys(e),n=this._buildFkDependencyMap(e,t),r=this._topoSort(t,n);if(r.length<t.length){const e=new Set(r),n=t.filter(t=>!e.has(t));logger.warn("syncDatabase: circular foreign-key dependency among",n,"— creating in declaration order; FK constraints may need a second pass."),r.push(...n)}return r}_buildFkDependencyMap(e,t){const n=new Map;for(const r of t){n.set(r,r);const t=e[r]?.table;t&&n.set(t,r)}const r=new Map;for(const s of t){const t=new Set;for(const r of this._foreignTargetsOf(e[s])){const e=n.get(r);e&&e!==s&&t.add(e)}r.set(s,t)}return r}_topoSort(e,t){const n=[],r=new Set;let s=!0;for(;n.length<e.length&&s;){s=!1;for(const a of e)!r.has(a)&&this._depsSatisfied(t.get(a),r)&&(n.push(a),r.add(a),s=!0)}return n}_depsSatisfied(e,t){for(const n of e)if(!t.has(n))return!1;return!0}_foreignTargetsOf(e){const t=[];for(const[n,r]of Object.entries(e?.columns||{})){const e="string"==typeof r?this.utils.formatColumnSchema(n,r):r;if(e?.hasForeignKey&&Array.isArray(e.foreignMapTables))for(const n of e.foreignMapTables)n?.table&&t.push(n.table)}return t}async syncSeedData(e,t){if(!e.seed||!Array.isArray(e.seed)||0===e.seed.length)return;const n=await this.db(e.table).count("* as n").first();Number(n?.n)>0?logger.info("Seed data already synced for",t):(await this.db(e.table).insert(e.seed),logger.info("Seed data synced for",t))}async generateSchema(){const e=await this._listTables(),t={},n=this._getHelperUtility();for(const r of e){const e=n?n.modelName(r):r;t[e]={table:r,alias:e,modelName:e,columns:this.getColumnString(await this.getCurrentColumns(r)),seed:[],hasRelations:await this._getRelations(r),indexes:[]}}return t}async createTable(e){await this.db.schema.createTable(e.table,t=>{for(const[n,r]of Object.entries(e.columns))this._applyColumnToBuilder(t,this._resolveColumnFrm(n,r))})}async alterTable(e,t){if(!t||"object"!=typeof t)throw new Error("alterations must be an object");(t.add?.length||0)+(t.drop?.length||0)+(t.modify?.length||0)>0?await this.db.schema.alterTable(e,e=>{for(const n of t.add||[])this._applyColumnToBuilder(e,n);for(const n of t.drop||[])e.dropColumn(n.name);for(const n of t.modify||[]){const t=this._applyColumnToBuilder(e,n);t&&"function"==typeof t.alter&&t.alter()}}):logger.info("No alterations to apply for",e)}async dropTable(e){await this.db.schema.dropTableIfExists(e)}async getCurrentColumns(e){const t=await this.db(e).columnInfo(),n={};for(const[e,r]of Object.entries(t))n[e]=this._formatColumnInfo(e,r);return n}_formatColumnInfo(e,t){const n=String(t.type||"").toLowerCase(),r=n.match(/^([a-z_]+)(?:\((\d+)(?:,\s*\d+)?\))?/);return{name:e,type:(r?r[1]:n).toUpperCase(),size:(r&&r[2]?Number(r[2]):t.maxLength||null)||null,nullable:!1!==t.nullable,default:this._parseDefault(t.defaultValue),primary:!1,unique:!1,autoIncrement:!1,isUnsigned:!1,hasForeignKey:!1,foreignMapTables:[],onUpdate:null,comment:""}}_parseDefault(e){if(null==e)return null;const t=String(e).trim();return""===t?null:t.replace(/^'+|'+$/g,"")}hasColumnChanged(e,t){return!1}async getAlterations(e){const t={add:[],drop:[],modify:[]},n=await this.getCurrentColumns(e.table);for(const[r,s]of Object.entries(e.columns)){const e=this._resolveColumnFrm(r,s),a=n[r];a?this.hasColumnChanged(a,e)&&t.modify.push(e):t.add.push(e)}for(const r of Object.keys(n))e.columns[r]||t.drop.push({name:r});return t}getColumnString(e){return Object.keys(e).reduce((t,n)=>{const r=e[n],s=String(r.type||"").toLowerCase();return t[n]=COLUMN_STRING_SUFFIXES.reduce((e,t)=>e+t(r),s),t},{})}_resolveColumnFrm(e,t){const n="string"==typeof t?this.utils.formatColumnSchema(e,t):t;return this._alignForeignKeyType(n)}_alignForeignKeyType(e){if(!e?.hasForeignKey)return e;const t=e.foreignMapTables?.[0];if(!t?.table)return e;const n=this._resolveParentColumnFrm(t);return n?{...e,...this._matchedForeignKeyType(n)}:e}_resolveParentColumnFrm(e){const t=this._findSchemaColumns(e.table);if(!t)return null;const n=t[e.column||"id"];return null==n?null:"string"==typeof n?this.utils.formatColumnSchema(e.column||"id",n):n}_findSchemaColumns(e){const t=this.controllerWrapper?.schema;if(!t)return null;for(const n of Object.keys(t)){const r=t[n];if(r&&(r.table===e||n===e))return r.columns||null}return null}_matchedForeignKeyType(e){return e.autoIncrement&&e.primary?{type:"BIGINT",size:null,columnType:"BIGINT",isUnsigned:!0}:{type:e.type,size:e.size,columnType:e.columnType,isUnsigned:e.isUnsigned}}_applyColumnToBuilder(e,t){if(t.autoIncrement&&t.primary)return this._buildIncrementsColumn(e,t);const n=this._typeBuilder(e,t.name,t);return this._applyColumnModifiers(n,t),n}_buildIncrementsColumn(e,t){const n=e.increments(t.name);return t.comment&&n.comment(t.comment),n}_applyColumnModifiers(e,t){if(this._applyConstraintModifiers(e,t),this._applyNullabilityAndDefault(e,t),t.comment&&e.comment(t.comment),t.hasForeignKey&&t.foreignMapTables?.[0]){const n=t.foreignMapTables[0];e.references(n.column||"id").inTable(n.table)}}_applyConstraintModifiers(e,t){t.primary&&e.primary(),t.unique&&e.unique(),t.isUnsigned&&this._supportsUnsigned()&&e.unsigned()}_applyNullabilityAndDefault(e,t){t.nullable?e.nullable():e.notNullable(),null!=t.default&&""!==t.default&&e.defaultTo(this._renderDefault(t.default))}_typeBuilder(e,t,n){const r=String(n.type||"").toUpperCase(),s=this._typeDispatcher()[r];return s?s(e,t,n):e.specificType(t,n.columnType||(n.size?`${r}(${n.size})`:r))}_typeDispatcher(){return BASE_TYPE_DISPATCHER}_renderDefault(e){const t=String(e).trim();return"CURRENT_TIMESTAMP"===t.toUpperCase()||"NOW()"===t.toUpperCase()?this.db.fn.now():/^-?\d+(\.\d+)?$/.test(t)?Number(t):"true"===t||"false"===t?"true"===t:t}_supportsUnsigned(){return!0}_getHelperUtility(){try{return new(require(`./${this._getClientName()}/HelperUtility`))}catch{return null}}async _applyExtras(e){}async _getRelations(e){return{}}async _listTables(){throw new Error("_listTables must be overridden by engine subclass")}_warnOnUnsupportedModifier(e,t,n){warnOnce(`${this._getClientName()}.${e}`,`[${this._getClientName()}] '${e}' modifier is not supported on this engine (seen on ${t}.${n}). See docs/agents/05-multi-db-parity.md.`)}}module.exports=BaseSyncTable;
1
+ const logger=require("../Logger"),KormError=require("../KormError"),SyncRunner=require("./SyncRunner"),ENGINE_WARNINGS=new Set;function warnOnce(e,t){ENGINE_WARNINGS.has(e)||(ENGINE_WARNINGS.add(e),logger.warn(t))}const BASE_TYPE_DISPATCHER={VARCHAR:(e,t,n)=>e.string(t,n.size||255),CHAR:(e,t,n)=>e.string(t,n.size||255),TEXT:(e,t)=>e.text(t),MEDIUMTEXT:(e,t)=>e.text(t),LONGTEXT:(e,t)=>e.text(t),INT:(e,t)=>e.integer(t),INTEGER:(e,t)=>e.integer(t),MEDIUMINT:(e,t)=>e.integer(t),SMALLINT:(e,t)=>e.integer(t),BIGINT:(e,t)=>e.bigInteger(t),TINYINT:(e,t,n)=>e.tinyint?e.tinyint(t):e.specificType(t,n.size?`TINYINT(${n.size})`:"TINYINT"),BOOLEAN:(e,t)=>e.boolean(t),BOOL:(e,t)=>e.boolean(t),DATE:(e,t)=>e.date(t),DATETIME:(e,t)=>e.dateTime(t),TIMESTAMP:(e,t)=>e.timestamp(t),TIME:(e,t)=>e.time(t),JSON:(e,t)=>e.json(t),FLOAT:(e,t)=>e.float(t),DOUBLE:(e,t)=>e.double?e.double(t):e.float(t),REAL:(e,t)=>e.double?e.double(t):e.float(t),DECIMAL:(e,t)=>e.decimal(t),NUMERIC:(e,t)=>e.decimal(t),BINARY:(e,t)=>e.binary(t),VARBINARY:(e,t)=>e.binary(t),BLOB:(e,t)=>e.binary(t),UUID:(e,t)=>e.uuid?e.uuid(t):e.string(t,36)},NUMERIC_TYPES=new Set(["INT","INTEGER","MEDIUMINT","SMALLINT","BIGINT","TINYINT","FLOAT","DOUBLE","REAL","DECIMAL","NUMERIC"]),COLUMN_STRING_SUFFIXES=[e=>e.size?`|size:${e.size}`:"",e=>e.isUnsigned?"|unsigned":"",e=>e.primary?"|primaryKey":"",e=>e.autoIncrement?"|autoIncrement":"",e=>e.nullable?"":"|notNull",e=>e.unique?"|unique":"",e=>null!=e.default&&""!==e.default?`|default:${e.default}`:"",e=>e.onUpdate?`|onUpdate:${e.onUpdate}`:"",e=>e.comment?`|comment:${e.comment}`:"",e=>e.hasForeignKey&&e.foreignMapTables?.[0]?`|foreignKey:${e.foreignMapTables[0].table}:${e.foreignMapTables[0].column}`:""];class BaseSyncTable{constructor(e,t,n=null){this.db=e,this.utils=t,this.controllerWrapper=n}_getClientName(){throw new Error("_getClientName must be overridden by engine subclass")}async existsTable(e){return this.db.schema.hasTable(e)}async syncTable(e,t={}){return new SyncRunner(this,t).runTable(e)}async syncDatabase(e={}){if(!this.controllerWrapper?.schema)throw new Error("controllerWrapper.schema not set.");return new SyncRunner(this,e).runDatabase(this.controllerWrapper.schema)}_orderTablesByDependency(e){const t=Object.keys(e),n=this._buildFkDependencyMap(e,t),r=this._topoSort(t,n);if(r.length<t.length){const e=new Set(r),n=t.filter(t=>!e.has(t));logger.warn("syncDatabase: circular foreign-key dependency among",n,"— creating in declaration order; FK constraints may need a second pass."),r.push(...n)}return r}_buildFkDependencyMap(e,t){const n=new Map;for(const r of t){n.set(r,r);const t=e[r]?.table;t&&n.set(t,r)}const r=new Map;for(const a of t){const t=new Set;for(const r of this._foreignTargetsOf(e[a])){const e=n.get(r);e&&e!==a&&t.add(e)}r.set(a,t)}return r}_topoSort(e,t){const n=[],r=new Set;let a=!0;for(;n.length<e.length&&a;){a=!1;for(const s of e)!r.has(s)&&this._depsSatisfied(t.get(s),r)&&(n.push(s),r.add(s),a=!0)}return n}_depsSatisfied(e,t){for(const n of e)if(!t.has(n))return!1;return!0}_foreignTargetsOf(e){const t=[];for(const[n,r]of Object.entries(e?.columns||{})){const e="string"==typeof r?this.utils.formatColumnSchema(n,r):r;if(e?.hasForeignKey&&Array.isArray(e.foreignMapTables))for(const n of e.foreignMapTables)n?.table&&t.push(n.table)}return t}async syncSeedData(e,t){if(!e.seed||!Array.isArray(e.seed)||0===e.seed.length)return;const n=await this.db(e.table).count("* as n").first();Number(n?.n)>0?logger.info("Seed data already synced for",t):(await this.db(e.table).insert(e.seed),logger.info("Seed data synced for",t))}async generateSchema(){const e=await this._listTables(),t={},n=this._getHelperUtility();for(const r of e){const e=n?n.modelName(r):r;t[e]={table:r,alias:e,modelName:e,columns:this.getColumnString(await this.getCurrentColumns(r)),seed:[],hasRelations:await this._getRelations(r),indexes:[]}}return t}async createTable(e){await this.db.schema.createTable(e.table,t=>{for(const[n,r]of Object.entries(e.columns))this._applyColumnToBuilder(t,this._resolveColumnFrm(n,r))})}async alterTable(e,t){if(!t||"object"!=typeof t)throw new Error("alterations must be an object");(t.add?.length||0)+(t.drop?.length||0)+(t.modify?.length||0)>0?await this.db.schema.alterTable(e,e=>{for(const n of t.add||[])this._applyColumnToBuilder(e,n);for(const n of t.drop||[])e.dropColumn(n.name);for(const n of t.modify||[]){const t=this._applyColumnToBuilder(e,n);t&&"function"==typeof t.alter&&t.alter()}}):logger.info("No alterations to apply for",e)}async dropTable(e){await this.db.schema.dropTableIfExists(e)}async _assertNoOrphanForeignKeys(e,t){const n=[];for(const r of t?.add||[]){const t=await this._detectOrphanForNewFkColumn(e,r);t&&n.push(t)}if(n.length)throw KormError.foreignKeyOrphans({table:e,offenders:n})}async _detectOrphanForNewFkColumn(e,t){const n=this._fkOrphanProbe(t);if(!n)return null;const r=await this._tableRowCount(e);return 0===r?null:await this.existsTable(n.parentTable)?await this._parentHasValue(n.parentTable,n.parentColumn,n.backfillValue)?null:{column:t.name,parentTable:n.parentTable,parentColumn:n.parentColumn,rowsAffected:r,backfillValue:n.backfillValue}:null}_fkOrphanProbe(e){if(!e?.hasForeignKey)return null;const t=e.foreignMapTables?.[0];if(!t?.table)return null;const n=null!=e.default&&""!==e.default;return e.nullable&&!n?null:{parentTable:t.table,parentColumn:t.column||"id",backfillValue:n?e.default:this._impliedBackfillValue(e)}}_impliedBackfillValue(e){const t=String(e.type||"").toUpperCase(),n=String(e.columnType||"").toUpperCase().match(/^[A-Z]+/);return NUMERIC_TYPES.has(t)||n&&NUMERIC_TYPES.has(n[0])?0:""}async _tableRowCount(e){const t=await this.db(e).count("* as n").first();return Number(t?.n)||0}async _parentHasValue(e,t,n){return!!await this.db(e).where(t,n).first()}async getCurrentColumns(e){const t=await this.db(e).columnInfo(),n={};for(const[e,r]of Object.entries(t))n[e]=this._formatColumnInfo(e,r);return n}_formatColumnInfo(e,t){const n=String(t.type||"").toLowerCase(),r=n.match(/^([a-z_]+)(?:\((\d+)(?:,\s*\d+)?\))?/);return{name:e,type:(r?r[1]:n).toUpperCase(),size:(r&&r[2]?Number(r[2]):t.maxLength||null)||null,nullable:!1!==t.nullable,default:this._parseDefault(t.defaultValue),primary:!1,unique:!1,autoIncrement:!1,isUnsigned:!1,hasForeignKey:!1,foreignMapTables:[],onUpdate:null,comment:""}}_parseDefault(e){if(null==e)return null;const t=String(e).trim();return""===t?null:t.replace(/^'+|'+$/g,"")}hasColumnChanged(e,t){return!1}async getAlterations(e){const t={add:[],drop:[],modify:[]},n=await this.getCurrentColumns(e.table);for(const[r,a]of Object.entries(e.columns)){const e=this._resolveColumnFrm(r,a),s=n[r];s?this.hasColumnChanged(s,e)&&t.modify.push(e):t.add.push(e)}for(const r of Object.keys(n))e.columns[r]||t.drop.push({name:r});return t}getColumnString(e){return Object.keys(e).reduce((t,n)=>{const r=e[n],a=String(r.type||"").toLowerCase();return t[n]=COLUMN_STRING_SUFFIXES.reduce((e,t)=>e+t(r),a),t},{})}_resolveColumnFrm(e,t){const n="string"==typeof t?this.utils.formatColumnSchema(e,t):t;return this._alignForeignKeyType(n)}_alignForeignKeyType(e){if(!e?.hasForeignKey)return e;const t=e.foreignMapTables?.[0];if(!t?.table)return e;const n=this._resolveParentColumnFrm(t);return n?{...e,...this._matchedForeignKeyType(n)}:e}_resolveParentColumnFrm(e){const t=this._findSchemaColumns(e.table);if(!t)return null;const n=t[e.column||"id"];return null==n?null:"string"==typeof n?this.utils.formatColumnSchema(e.column||"id",n):n}_findSchemaColumns(e){const t=this.controllerWrapper?.schema;if(!t)return null;for(const n of Object.keys(t)){const r=t[n];if(r&&(r.table===e||n===e))return r.columns||null}return null}_matchedForeignKeyType(e){return e.autoIncrement&&e.primary?{type:"BIGINT",size:null,columnType:"BIGINT",isUnsigned:!0}:{type:e.type,size:e.size,columnType:e.columnType,isUnsigned:e.isUnsigned}}_applyColumnToBuilder(e,t){if(t.autoIncrement&&t.primary)return this._buildIncrementsColumn(e,t);const n=this._typeBuilder(e,t.name,t);return this._applyColumnModifiers(n,t),n}_buildIncrementsColumn(e,t){const n=e.increments(t.name);return t.comment&&n.comment(t.comment),n}_applyColumnModifiers(e,t){if(this._applyConstraintModifiers(e,t),this._applyNullabilityAndDefault(e,t),t.comment&&e.comment(t.comment),t.hasForeignKey&&t.foreignMapTables?.[0]){const n=t.foreignMapTables[0];e.references(n.column||"id").inTable(n.table)}}_applyConstraintModifiers(e,t){t.primary&&e.primary(),t.unique&&e.unique(),t.isUnsigned&&this._supportsUnsigned()&&e.unsigned()}_applyNullabilityAndDefault(e,t){t.nullable?e.nullable():e.notNullable(),null!=t.default&&""!==t.default&&e.defaultTo(this._renderDefault(t.default))}_typeBuilder(e,t,n){const r=String(n.type||"").toUpperCase(),a=this._typeDispatcher()[r];return a?a(e,t,n):e.specificType(t,n.columnType||(n.size?`${r}(${n.size})`:r))}_typeDispatcher(){return BASE_TYPE_DISPATCHER}_renderDefault(e){const t=String(e).trim();return"CURRENT_TIMESTAMP"===t.toUpperCase()||"NOW()"===t.toUpperCase()?this.db.fn.now():/^-?\d+(\.\d+)?$/.test(t)?Number(t):"true"===t||"false"===t?"true"===t:t}_supportsUnsigned(){return!0}_getHelperUtility(){try{return new(require(`./${this._getClientName()}/HelperUtility`))}catch{return null}}async _applyExtras(e){}async _getRelations(e){return{}}async _listTables(){throw new Error("_listTables must be overridden by engine subclass")}_warnOnUnsupportedModifier(e,t,n){warnOnce(`${this._getClientName()}.${e}`,`[${this._getClientName()}] '${e}' modifier is not supported on this engine (seen on ${t}.${n}). See docs/agents/05-multi-db-parity.md.`)}}module.exports=BaseSyncTable;
@@ -0,0 +1 @@
1
+ const logger=require("../Logger");function normalizeOptions(t={}){return{dropColumns:!!(null!=t.dropColumns?t.dropColumns:t.prune),continueOnError:!!t.continueOnError,dryRun:!!t.dryRun}}class SyncRunner{constructor(t,o={}){this.sync=t,this.opts=normalizeOptions(o),this.report={dryRun:this.opts.dryRun,applied:[],plan:[],skippedDrops:[],errors:[]}}result(){return this.opts.dryRun||this.opts.continueOnError?this.report:void 0}async runDatabase(t){for(const o of this.sync._orderTablesByDependency(t))await this._syncTable(t[o]);return this.opts.dryRun||logger.info("Database synced by SyncTable..."),this.result()}async runTable(t){return await this._syncTable(t),this.result()}async _syncTable(t){const o=t.table;(await this.sync.existsTable(o)?await this._alter(t,o):await this._create(t,o))&&!this.opts.dryRun&&await this._extrasAndSeed(t,o)}async _create(t,o){if(this.opts.dryRun)return this.report.plan.push({table:o,operation:"createTable",column:null,destructive:!1}),!0;const r=await this._attempt({table:o,operation:"createTable",column:null},()=>this.sync.createTable(t));return r&&this.report.applied.push({table:o,operation:"createTable",column:null}),r}async _alter(t,o){const r=await this.sync.getAlterations(t);if(this._recordDrops(o,r),this.opts.dryRun)return this._planAlter(o,r),!0;const n={add:await this._screenOrphans(o,r.add||[]),drop:this.opts.dropColumns&&r.drop||[],modify:r.modify||[]};return await this._applyWork(o,n),!0}_recordDrops(t,o){if(!this.opts.dropColumns)for(const r of o.drop||[])this.report.skippedDrops.push({table:t,column:r.name}),logger.warn(`syncDatabase: column "${t}.${r.name}" exists in the database but not in the schema; left in place (additive-only). Pass { dropColumns: true } to drop it.`)}_planAlter(t,o){for(const r of o.add||[])this.report.plan.push({table:t,operation:"add",column:r.name,destructive:!1});for(const r of o.modify||[])this.report.plan.push({table:t,operation:"modify",column:r.name,destructive:!1});if(this.opts.dropColumns)for(const r of o.drop||[])this.report.plan.push({table:t,operation:"drop",column:r.name,destructive:!0})}async _screenOrphans(t,o){if(!this.opts.continueOnError)return await this.sync._assertNoOrphanForeignKeys(t,{add:o}),o;const r=[];for(const n of o){const o=await this.sync._detectOrphanForNewFkColumn(t,n);o?this.report.errors.push({table:t,operation:"add",column:n.name,message:`adding FK column would orphan ${o.rowsAffected} row(s) with no matching ${o.parentTable}.${o.parentColumn}`}):r.push(n)}return r}async _applyWork(t,o){const r=this._ops(o);if(0!==r.length)if(this.opts.continueOnError)for(const o of r){await this._attempt({table:t,operation:o.operation,column:o.column},()=>this.sync.alterTable(t,this._single(o)))&&this.report.applied.push({table:t,operation:o.operation,column:o.column})}else{await this.sync.alterTable(t,o);for(const o of r)this.report.applied.push({table:t,operation:o.operation,column:o.column})}}_ops(t){const o=[];for(const r of t.add||[])o.push({operation:"add",column:r.name,frm:r});for(const r of t.drop||[])o.push({operation:"drop",column:r.name,name:r.name});for(const r of t.modify||[])o.push({operation:"modify",column:r.name,frm:r});return o}_single(t){return"add"===t.operation?{add:[t.frm],drop:[],modify:[]}:"drop"===t.operation?{add:[],drop:[{name:t.name}],modify:[]}:{add:[],drop:[],modify:[t.frm]}}async _extrasAndSeed(t,o){await this._attempt({table:o,operation:"extras",column:null},()=>this.sync._applyExtras(t)),await this._attempt({table:o,operation:"seed",column:null},()=>this.sync.syncSeedData(t,o))}async _attempt(t,o){try{return await o(),!0}catch(o){if(!this.opts.continueOnError)throw o;return this.report.errors.push({...t,message:o.message}),!1}}}module.exports=SyncRunner;
@@ -1 +1 @@
1
- const BaseWrapper=require("../BaseWrapper"),BaseUtility=require("./BaseUtility"),SyncTable=require("./SyncTable"),CurdTable=require("./CurdTable");class MySQLWrapper extends BaseWrapper{constructor(e){super(e),this.db=e.db,this.utils=new BaseUtility,this.sync=new SyncTable(this.db,this.utils,e),this.curd=new CurdTable(this.db,this.utils,e)}async syncDatabase(){return this.sync.syncDatabase()}async processRequest(...e){return this.curd.processRequest(...e)}async generateSchema(){return this.sync.generateSchema()}}module.exports=MySQLWrapper;
1
+ const BaseWrapper=require("../BaseWrapper"),BaseUtility=require("./BaseUtility"),SyncTable=require("./SyncTable"),CurdTable=require("./CurdTable");class MySQLWrapper extends BaseWrapper{constructor(e){super(e),this.db=e.db,this.utils=new BaseUtility,this.sync=new SyncTable(this.db,this.utils,e),this.curd=new CurdTable(this.db,this.utils,e)}async syncDatabase(e={}){return this.sync.syncDatabase(e)}async processRequest(...e){return this.curd.processRequest(...e)}async generateSchema(){return this.sync.generateSchema()}}module.exports=MySQLWrapper;
@@ -1 +1 @@
1
- const BaseWrapper=require("../BaseWrapper"),BaseUtility=require("./BaseUtility"),SyncTable=require("./SyncTable"),CurdTable=require("./CurdTable");class PostgresWrapper extends BaseWrapper{constructor(e){super(e),this.db=e.db,this.utils=new BaseUtility,this.sync=new SyncTable(this.db,this.utils,e),this.curd=new CurdTable(this.db,this.utils,e)}async syncDatabase(){return this.sync.syncDatabase()}async processRequest(...e){return this.curd.processRequest(...e)}async generateSchema(){return this.sync.generateSchema()}}module.exports=PostgresWrapper;
1
+ const BaseWrapper=require("../BaseWrapper"),BaseUtility=require("./BaseUtility"),SyncTable=require("./SyncTable"),CurdTable=require("./CurdTable");class PostgresWrapper extends BaseWrapper{constructor(e){super(e),this.db=e.db,this.utils=new BaseUtility,this.sync=new SyncTable(this.db,this.utils,e),this.curd=new CurdTable(this.db,this.utils,e)}async syncDatabase(e={}){return this.sync.syncDatabase(e)}async processRequest(...e){return this.curd.processRequest(...e)}async generateSchema(){return this.sync.generateSchema()}}module.exports=PostgresWrapper;
@@ -1 +1 @@
1
- const BaseWrapper=require("../BaseWrapper"),BaseUtility=require("./BaseUtility"),SyncTable=require("./SyncTable"),CurdTable=require("./CurdTable");class SQLiteWrapper extends BaseWrapper{constructor(e){super(e),this.db=e.db,this.utils=new BaseUtility,this.sync=new SyncTable(this.db,this.utils,e),this.curd=new CurdTable(this.db,this.utils,e)}async syncDatabase(){return this.sync.syncDatabase()}async processRequest(...e){return this.curd.processRequest(...e)}async generateSchema(){return this.sync.generateSchema()}}module.exports=SQLiteWrapper;
1
+ const BaseWrapper=require("../BaseWrapper"),BaseUtility=require("./BaseUtility"),SyncTable=require("./SyncTable"),CurdTable=require("./CurdTable");class SQLiteWrapper extends BaseWrapper{constructor(e){super(e),this.db=e.db,this.utils=new BaseUtility,this.sync=new SyncTable(this.db,this.utils,e),this.curd=new CurdTable(this.db,this.utils,e)}async syncDatabase(e={}){return this.sync.syncDatabase(e)}async processRequest(...e){return this.curd.processRequest(...e)}async generateSchema(){return this.sync.generateSchema()}}module.exports=SQLiteWrapper;
package/index.d.ts CHANGED
@@ -345,7 +345,11 @@ export class KormError extends Error {
345
345
  static ACTIONS: readonly string[];
346
346
  static closestAction(input: string, candidates?: string[]): string | null;
347
347
  static noMatchingRow(opts: { action: string; model: string }): KormError;
348
- static unknownAction(opts: { action: string; model?: string; hasCustomHook?: boolean }): KormError;
348
+ static unknownAction(opts: {
349
+ action: string;
350
+ model?: string;
351
+ hasCustomHook?: boolean;
352
+ }): KormError;
349
353
  static unknownModel(opts: { model: string; available?: string[] }): KormError;
350
354
  static validationFailed(opts: { errors?: any[]; source?: string | null }): KormError;
351
355
  static forbidden(opts: {
@@ -416,9 +420,5 @@ export interface CreateMcpServerOptions {
416
420
 
417
421
  export const mcp: {
418
422
  createServer(opts: CreateMcpServerOptions): McpServer;
419
- generateTools(opts: {
420
- controller: any;
421
- schema: any;
422
- mcpConfig: McpConfig;
423
- }): McpTool[];
423
+ generateTools(opts: { controller: any; schema: any; mcpConfig: McpConfig }): McpTool[];
424
424
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dreamtree-org/korm-js",
3
- "version": "1.0.59",
3
+ "version": "1.1.0",
4
4
  "description": "Knowledge Object-Relational Mapping - A powerful, modular ORM system for Node.js with dynamic database operations, complex queries, relationships, and nested requests",
5
5
  "author": {
6
6
  "name": "Partha Preetham Krishna",