@mostajs/orm
v2.0.0
Published
Hibernate-inspired multi-dialect ORM for Node.js/TypeScript — One API, 13 databases, zero lock-in
Maintainers
Readme
@mostajs/orm
Plug & Play ORM to Drive 13 Databases at Once
Hibernate-inspired multi-dialect ORM for Node.js & TypeScript — one API, 13 databases, zero lock-in, bundler-friendly.
📦 npm · https://www.npmjs.com/package/@mostajs/orm 🐙 GitHub · https://github.com/apolocine/mosta-orm 📚 Docs · (coming soon) 🚀 Product Hunt · (launch link to be added)
Why @mostajs/orm ?
- 🎯 One API, 13 dialects. Switch from PostgreSQL to MongoDB to SQLite without rewriting a single repository call.
- 🪶 Zero lock-in. Native drivers, no proprietary query DSL — your SQL/NoSQL stays portable.
- 🧬 Hibernate / JPA semantics.
@OneToMany, cascade types,SAVEPOINT, schema strategies (validate/update/create/create-drop) — concepts battle-tested for 25 years, ported to TypeScript. - 🌉 Drop-in Prisma replacement.
@mostajs/orm-bridgelets you keep your Prisma code while running on any of 13 databases. - 🔁 Cross-dialect replication built-in.
@mostajs/replicator— CDC + master/slave + failover across SQL ↔ MongoDB. - 🧪 Bundler-friendly. Tree-shakable ESM, no
eval, works with esbuild / Vite / Next.js / Bun out of the box.
60-second demo
npm install @mostajs/orm better-sqlite3import { getDialect } from '@mostajs/orm'
import { UserSchema } from './schemas/user.schema'
const db = await getDialect({ dialect: 'sqlite', uri: ':memory:' }, [UserSchema])
const userRepo = db.repo<typeof UserSchema>('User')
await userRepo.create({ email: '[email protected]', name: 'Alice' })
const alice = await userRepo.findOne({ email: '[email protected]' })Want PostgreSQL instead ? Change one line :
const db = await getDialect({ dialect: 'postgres', uri: process.env.DATABASE_URL }, [UserSchema])That's it. Same repo.create(), same repo.findOne(), same TypeScript types — different dialect.
How it compares
| | @mostajs/orm | Prisma | Drizzle | TypeORM |
|---|:---:|:---:|:---:|:---:|
| SQL dialects | 9 (PG, MySQL, MariaDB, SQLite, MSSQL, Oracle, DB2, HANA, Cockroach…) | 5 | 5 | 8 |
| NoSQL dialects | MongoDB native | ❌ | ❌ | ❌ |
| Same API across SQL & NoSQL | ✅ | ❌ | ❌ | ❌ |
| Cross-dialect replication | ✅ (via @mostajs/replicator) | ❌ | ❌ | ❌ |
| Schema-as-code (no DSL) | ✅ TypeScript objects | DSL .prisma | TS objects | Decorators |
| Code generation step | ❌ (zero codegen) | ✅ required | ❌ | ❌ |
| Drop-in Prisma replacement | ✅ (via @mostajs/orm-bridge) | — | ❌ | ❌ |
| Migration from Prisma | ✅ (automated CLI) | — | manual | manual |
| Hibernate / JPA semantics | ✅ | ❌ | ❌ | partial |
| License | AGPL-3.0 (+ commercial) | Apache-2.0 | Apache-2.0 | MIT |
Numbers as of v1.13.1 — see
@mostajs/orm-clifor the automated Prisma → @mostajs migration tool.
The relation lookup problem (and how @mostajs/[email protected] solves it)
The problem nobody talks about
Every ORM that auto-populates relations on read creates the same silent bug class :
// `reg.project` is a M2O relation to Project
const reg = await regRepo.findById(regId)
// Eager mode (Hibernate ≤ JPA 2.x default, @mostajs/orm < 2.0) :
reg.project === project.id // false — object vs string, ALWAYS
reg.project.id === project.id // true if eager — explodes if lazy
// Lazy mode (Prisma, Drizzle, TypeORM 0.3+, SQLAlchemy default) :
reg.project === project.id // true if same id — but reg.project.name throws3 surfaces affected :
- Direct comparison
entity.relation === id— JS has no operator overloading. - Property access
entity.relation.someField— assumes populated. - Reuse in lookup
findById(entity.relation)— assumes string id.
The default eager/lazy choice forces every consumer to be defensive everywhere.
How each ORM addresses it
| ORM | Default fetch | Polymorphic key lookup | Helper to normalize id | Documented as problem |
|---|---|---|---|---|
| Hibernate | EAGER (historic JPA, anti-pattern) | EntityManager.find(Class, key) accepts EmbeddedId | overridable equals(Object o) in Java | Yes — Vlad Mihalcea has 50+ blog posts on it |
| Prisma | LAZY (opt-in include) | findUnique({ where }) accepts any unique field | ❌ none — where: { id } required | Partially — findUnique is the only escape |
| Drizzle | LAZY (opt-in with) | query.x.findFirst({ where: eq(...) }) — verbose | ❌ none | No — relation queries are explicit only |
| TypeORM ≥ 0.3 | LAZY (opt-in relations: ['rel']) | findOneBy({ id }) only | ❌ none | No |
| MikroORM | LAZY (opt-in populate) | findOne({ id }) only | Reference<T> proxy (use .id accessor) | Yes — Reference helper documented |
| SQLAlchemy | LAZY (opt-in joinedload) | session.get(Cls, ident) accepts tuple for composite | ❌ none in Python (no == overloading either) | Yes — community workarounds |
| @mostajs/[email protected] | LAZY (opt-in fetch:'eager') | findById() polymorphe (string, {id}, natural key single or composite) | extractRelId(value) helper | Yes — explicitly documented + ORMConceptValidator R019/R021 |
The @mostajs/[email protected] 3-layer solution
Layer 1 — lazy by default
Aligns with Prisma / Drizzle / TypeORM 0.3+ / SQLAlchemy / MikroORM. Eliminates 90% of accidental N+1 queries and type confusion.
// Default — no surprise :
const reg = await regRepo.findById(regId)
typeof reg.project // 'string' — the FK idLayer 2 — Polymorphic findById with schema introspection
Inspired by JPA EntityManager.find(Class, Object) and Prisma findUnique({ where }).
// String PK (legacy) — unchanged :
await projRepo.findById('abc-123')
// Object with `id` — natural for code that passes populated entities :
await projRepo.findById({ id: 'abc-123' })
// Natural key — schema unique index detected automatically :
await projRepo.findById({ slug: 'my-project' })
// Composite natural key :
await membershipRepo.findById({ tenantId: 't1', slug: 'admin' })If the input is an object that matches neither id nor a unique index,
OrmIntrospectionError is thrown with the available fields and candidate
unique indexes listed — actionable error message.
Layer 3 — extractRelId() helper for direct comparisons
JS has no operator overloading. obj === string is always false. So we provide
the explicit normalizer :
import { extractRelId } from '@mostajs/orm'
// Safe under both lazy AND eager :
if (extractRelId(reg.project) === project.id) {
// ...
}extractRelId(value) returns :
valueitself if string/number stringifiedvalue.idstringified if object with id''for null / undefined / object without id
What @mostajs/orm does that no other JS/TS ORM does
| Capability | @mostajs/orm 2.0 | Others |
|---|:---:|:---:|
| Lazy default (state of the art) | ✅ | Prisma, Drizzle, TypeORM 0.3+, MikroORM, SQLAlchemy ✅ |
| Opt-in eager via schema flag | ✅ fetch:'eager' | TypeORM via eager: true. Others : per-query only. |
| findById accepts string OR {id} OR natural key OR composite | ✅ | None (Prisma findUnique is closest but doesn't accept {id} object pass-through) |
| Public extractRelId helper for === comparisons | ✅ | None (MikroORM Reference is closest but requires API discipline) |
| Validator rule auto-detects the trap (R021-DIRECT-RELATION-COMPARISON) | ✅ via ORMConceptValidator | None |
| Auto-fix the trap (injects extractRelId import + wraps comparison) | ✅ | None |
Benchmark — same code in 3 ORMs (eager opt-in scenario)
Scenario : compare a registration's project FK to a known project id, then re-fetch the project. Eager loading enabled for performance reasons.
// Prisma (eager not native — must include + spread) :
const reg = await prisma.registration.findUnique({
where: { id }, include: { project: true }
})
if (reg.projectId === project.id) { /* hand-extract from FK field */ }
// re-fetch project : await prisma.project.findUnique({ where: { id: reg.projectId } })
// TypeORM 0.3+ (eager: true in @ManyToOne) :
const reg = await regRepo.findOne({ where: { id }, relations: ['project'] })
if (reg.project.id === project.id) { /* explicit .id needed */ }
// re-fetch project : redundant — reg.project is already loaded
// @mostajs/orm 2.0 (fetch:'eager' in schema) :
const reg = await regRepo.findById(id)
if (extractRelId(reg.project) === project.id) { /* safe under any default */ }
// re-fetch project : await projRepo.findById(reg.project) ← introspection, worksBoth findById(reg.project) and extractRelId(reg.project) work identically
whether the relation is lazy (string) or eager (object). Consumer code
does NOT need to know the fetch mode.
→ @mostajs/orm is the only JS/TS ORM where you can flip lazy ↔ eager via a
schema flag without rewriting consumer code.
Star · Sponsor · Contribute
If @mostajs/orm saves you days of glue code, please :
- ⭐ Star the repo — visibility helps me keep maintaining it.
- 💖 Sponsor development → github.com/sponsors/apolocine
- 🐛 Report issues / submit PRs — every contribution counts.
- ✉️ Commercial license & support : [email protected]
Databases
SQLite · PostgreSQL · MySQL · MariaDB · MongoDB · Oracle · SQL Server · CockroachDB · DB2 · SAP HANA · HSQLDB · Spanner · Sybase
Demos
1. Initialize the app
2. Initialize the database
3. Configure the app
4. Setup replication
5. Cross-dialect CDC rules & live sync
6. Frontend CRUD app
7. Prisma project (before migration)
8. Prisma → @mostajs/orm migration (bootstrap)
Install
npm install @mostajs/orm
# + the driver for your dialect :
npm install better-sqlite3 # or: pg, mysql2, mongoose, oracledb, mssql, ibm_db, mariadb, @sap/hana-client, @google-cloud/spannerDefine a schema
import type { EntitySchema } from '@mostajs/orm'
export const UserSchema: EntitySchema = {
name: 'User',
collection: 'users',
timestamps: true,
fields: {
email: { type: 'string', required: true, unique: true },
name: { type: 'string', required: true },
},
relations: {
roles: { target: 'Role', type: 'many-to-many', through: 'user_roles' },
},
indexes: [{ fields: { email: 'asc' }, unique: true }],
}Unique keys
A field can be marked unique, or several fields can be combined into a composite unique constraint via indexes.
export const MemberSchema: EntitySchema = {
name: 'Member',
collection: 'members',
fields: {
email: { type: 'string', required: true, unique: true }, // single unique
tenantId: { type: 'string', required: true },
slug: { type: 'string', required: true },
},
indexes: [
{ fields: { tenantId: 'asc', slug: 'asc' }, unique: true }, // composite unique
],
}Both shapes enforce a DDL UNIQUE constraint on SQL dialects and a unique index on MongoDB. Lookup works the same way :
await repo.findOne({ email: '[email protected]' }) // single unique
await repo.findOne({ tenantId: 't1', slug: 'admin' }) // composite uniqueConnect & CRUD
import { registerSchemas, getDialect, BaseRepository } from '@mostajs/orm'
registerSchemas([UserSchema])
const dialect = await getDialect() // reads DB_DIALECT + SGBD_URI from env
const repo = new BaseRepository(UserSchema, dialect)
await repo.create({ email: '[email protected]', name: 'Admin' })
await repo.findOne({ email: '[email protected]' })
await repo.findAll({ status: 'active' }, { sort: { name: 1 }, limit: 10 })
await repo.update(id, { name: 'Updated' })
await repo.delete(id)
await repo.findByIdWithRelations(id, ['roles'])
await repo.upsert({ email: '[email protected]' }, { name: 'Upserted' })
await repo.count({ status: 'active' })Query options
await repo.findAll(
{ status: 'active', role: { $in: ['admin', 'editor'] } }, // filter
{
sort: { createdAt: -1, name: 1 }, // multi-field sort
skip: 20, limit: 10, // pagination
select: ['id', 'email', 'name'], // projection
},
)$in, $nin, $gt, $gte, $lt, $lte, $ne, $regex, $exists are all
supported uniformly across SQL & NoSQL dialects.
Soft delete (native)
Set softDelete: true on a schema and @mostajs/orm handles everything :
export const PostSchema: EntitySchema = {
name: 'Post',
collection: 'posts',
timestamps: true,
softDelete: true, // ← that's it
fields: { title: { type: 'string' }, body: { type: 'string' } },
}await postRepo.delete(id) // sets deletedAt + isDeleted=true
await postRepo.findAll({}) // excludes soft-deleted rows
await postRepo.findAll({}, { includeDeleted: true }) // include them
await postRepo.restore(id) // un-delete (clears deletedAt)
await postRepo.purge(id) // hard-delete, row removed for goodWorks identically across SQL (column deletedAt timestamp NULL) and MongoDB
(field deletedAt indexed). No more home-rolled deleted flag mismatches —
the R003-SOFT-DELETE-INCONSISTENT validator rule will flag manual patterns
(and auto-migrate them via --fix R003).
Transactions
Group multiple operations into a single atomic unit. SQL dialects (PostgreSQL, MySQL/MariaDB, SQLite, SQL Server, Oracle, DB2, CockroachDB, HANA, Sybase, HSQLDB, Spanner) wrap the callback in BEGIN / COMMIT / ROLLBACK. If any operation throws, every write inside the block is rolled back.
import { getDialect } from '@mostajs/orm'
const dialect = await getDialect()
await dialect.$transaction(async (tx) => {
await tx.create('accounts', { id: 'a', balance: 100 })
await tx.update('accounts', { id: 'b' }, { $inc: { balance: -50 } })
await tx.update('accounts', { id: 'a' }, { $inc: { balance: 50 } })
// throw here → both updates are rolled back, `accounts.a` row is removed
})Isolation : default per dialect (SQL → READ COMMITTED, SQLite → DEFERRED). Pass { isolation: 'SERIALIZABLE' } as 2nd argument to override (SQL only).
All SQL dialects listed above support ACID natively — PostgreSQL, MySQL/MariaDB, SQL Server, Oracle, DB2, SQLite, CockroachDB, HANA, Sybase, HSQLDB, Spanner. No configuration required beyond the usual connection.
MongoDB is the only exception : multi-document ACID transactions require a replica set (a single-node mongod --replSet rs0 is enough for dev — this is a MongoDB server requirement, not a limitation of this library). On a standalone server, $transaction runs the callback without wrapping — safe for read-heavy flows, non-atomic for writes.
Manual transactions — beginTx / commitTx / rollbackTx (v1.11+)
When the $transaction(cb) callback pattern is too restrictive (transaction spans several unrelated functions, commit depends on an external event), use the manual API :
const tx = await dialect.beginTx()
try {
await dialect.create(UserSchema, { email: '[email protected]', name: 'A' })
await someExternalCheck() // could be async, could take seconds
if (ok) await dialect.commitTx(tx)
else await dialect.rollbackTx(tx)
} catch (e) {
await dialect.rollbackTx(tx)
throw e
}Nested transactions — SAVEPOINTs are used automatically :
const outer = await dialect.beginTx() // → BEGIN
await dialect.create(UserSchema, { email: '[email protected]', name: 'Outer' })
const inner = await dialect.beginTx() // → SAVEPOINT mosta_sp_2_xxxx
await dialect.create(UserSchema, { email: '[email protected]', name: 'Inner' })
await dialect.rollbackTx(inner) // → ROLLBACK TO SAVEPOINT
// (inner row gone, outer untouched)
await dialect.commitTx(outer) // → COMMIT
// (outer row persisted)Depth unbounded as long as the engine supports SAVEPOINT (every SQL dialect above except Spanner). MSSQL / Sybase use SAVE TRANSACTION / ROLLBACK TRANSACTION internally — transparent to the API. commitTx / rollbackTx enforce LIFO order (out-of-order commit throws, out-of-order rollback is silent).
Environment
DB_DIALECT=postgres
SGBD_URI=postgresql://user:pass@localhost:5432/mydb
DB_SCHEMA_STRATEGY=update # validate | update | create | create-drop | none
DB_SHOW_SQL=trueThe naming mirrors Hibernate's hibernate.hbm2ddl.auto / hibernate.show_sql
properties (see Hibernate User Guide § schema strategies). Values have identical semantics : validate /
update / create / create-drop / none.
The dialect matching DB_DIALECT is lazy-loaded at runtime (v1.9.3+). Only the driver you actually use is evaluated — no other dialect module enters your bundle. This is what makes @mostajs/orm safe to pull into a Next.js / Vite / SvelteKit project without bundler workarounds.
Profile cascade with MOSTA_ENV (v1.13+)
Powered by @mostajs/config.
Keep one .env file with profile-prefixed overrides and switch via a
single MOSTA_ENV variable — exactly like Spring Boot profiles
(spring.profiles.active=test loading application-test.properties).
# .env — committed (non-secret) defaults
MOSTA_ENV=TEST
# Base defaults (used when no profile, or as fallback)
DB_DIALECT=sqlite
SGBD_URI=./data.sqlite
# Profile overrides
TEST_DB_DIALECT=sqlite
TEST_SGBD_URI=./test.sqlite
TEST_DB_SCHEMA_STRATEGY=create-drop
DEV_DB_DIALECT=postgres
DEV_SGBD_URI=postgres://localhost:5432/devdb
DEV_DB_SCHEMA_STRATEGY=update
PROD_DB_DIALECT=mongodb
PROD_SGBD_URI=${SCALEWAY_MONGO_URI} # secret injected by orchestrator
PROD_DB_SCHEMA_STRATEGY=validateResolution cascade (first non-empty wins) :
${MOSTA_ENV}_${KEY}— profile-prefixed${KEY}— plainfallbackargumentundefined— no crash, caller decides whether that's fatal
Silent fallback is guaranteed : a missing profile override never throws, it
just falls through to the plain variable or to the default. Empty strings
(TEST_DB_DIALECT=) are treated as "not set" so they don't silently leak a
blank value.
For generic use outside @mostajs/orm, import directly from the config
package :
import { getEnv, getEnvBool, getEnvNumber, getCurrentProfile } from '@mostajs/config'
const url = getEnv('REDIS_URL', 'redis://localhost:6379')
console.log(`Profile : ${getCurrentProfile() ?? 'none'}`)Switch databases with one env var
DB_DIALECT=sqlite SGBD_URI=./data.sqlite
DB_DIALECT=postgres SGBD_URI=postgres://...
DB_DIALECT=mongodb SGBD_URI=mongodb://...
# same code in both casesSubpaths
| Subpath | When to use |
|---|---|
| @mostajs/orm | The core ORM API : getDialect, registerSchemas, BaseRepository, EntityService, schema types, diffSchemas, errors. |
| @mostajs/orm/bridge | JDBC bridge (v1.9.4+) : JdbcNormalizer, BridgeManager, JDBC_REGISTRY, jar upload. Pulled out of the root to keep child_process / fs spawn out of client bundles. |
| @mostajs/orm/register | Zero-code registration side-effect for dynamic schema loading. |
| @mostajs/orm/validator | v1.14+ — ORMConceptValidator : algorithmic linter for EntitySchema sets. Detects 18 conceptual anomalies (empty relations, FK naming inconsistency, soft-delete patterns, dead code, missing audit, unbounded blobs…). See below. |
🔍 ORMConceptValidator (v1.14+)
Algorithmic linter for your ORM schemas — detects 18 conceptual
anomalies before they bite in production. Zero IA, zero heuristics
flou, fully generic (no hardcoded entity name — KNOWN_ENTITY_REFS
is derived at runtime from the schemas you pass).
Real-world impact (v1.17.0) : applied to iquesta (21 schemas, 70 findings) —
--fix R001,R001B,R002,R003 auto-corrected all 16 structural anomalies in
seconds. Cross-projects calibration over 17 mostajs/* + apolocine codebases :
247 findings identified, −23 after applying C1+C2 to iquesta alone.
Quick start
# CLI — point it at your schemas directory
npx mostajs-orm-validator ./schemas
# With cross-file rules (R005, R007, R008, R011, R012, R014, R015) :
npx mostajs-orm-validator ./schemas --src ./lib
# In a CI pipeline :
npx mostajs-orm-validator ./schemas --src ./lib --ci --max-warnings 0Or programmatically :
import { validateSchemas, formatText } from '@mostajs/orm/validator'
import * as schemas from './schemas'
const report = await validateSchemas(Object.values(schemas), {
sourceRoot: './lib',
})
console.log(formatText(report))
console.log(`${report.findings.length} findings`)What it detects (18 rules)
| ID | Severity | Detection |
|---|---|---|
| R001-EMPTY-RELATIONS | warning | String field named like another entity (e.g. project, respondent) but relations: {} empty → loses ORM cascade & FK validation |
| R002-FK-NAMING-INCONSISTENT | warning | Mix of conventions in same set (parentId vs project, questionId vs section) — flags the minority |
| R003-SOFT-DELETE-INCONSISTENT | warning/info | Multiple soft-delete patterns concurrent (deleted/cancelled/archived) OR manual deleted/deletedAt while softDelete: true is available natively |
| R004-DUPLICATE-ENTITY-SHAPE | info | Pair of schemas with Jaccard on field names ≥ threshold (default 0.7) — possible legacy |
| R004B-LEGACY-ENTITY | info/warning | Name overlap (substring ≥ 4 chars or Jaro-Winkler ≥ 0.75) — flags the smaller schema. Bumps to warning if legacy/deprecated comment found in sources |
| R005-ANY-TYPED-REPO | warning | BaseRepository<any> in source files — typing lost. Needs --src |
| R006-JSON-AS-RELATION | info | *sJson field containing list of FK slugs/ids — should be normalized into junction table |
| R007-REDUNDANT-DERIVED-FIELD | info | Persisted field duplicate of a pure function of its id (e.g. blobPath derivable from archiveBlobPath(id)). Needs --src |
| R008-BEST-EFFORT-FK-RESOLVER | warning | best-effort/TODO V2/HACK comment + ?? null fallback → root cause hidden. Needs --src |
| R009-MISSING-LOOKUP-INDEX | info/hint | unique field without dedicated index, OR FK string without index for inverse lookups |
| R010-MISSING-AUDIT-TABLE | hint | No schema resembling AuditLog (actor + action + timestamp) — sensitive actions untraceable |
| R011-LEGACY-DEAD-CODE | info | TS source file never imported (entry points like page.tsx/route.ts excluded). Needs --src |
| R012-DUPLICATE-IMPLEMENTATION | info | Pair of source files exporting overlapping function signatures (Jaccard ≥ 0.85). Needs --src |
| R013-MISSING-CASCADE | warning | many-to-one relation without explicit onDelete → orphans on parent delete |
| R014-REPO-FACTORY-BOILERPLATE | info | ≥ 5 get*Repo() helpers in same file — suggest factory. Needs --src |
| R015-FLAT-LIB-STRUCTURE | hint | Directory with > 25 flat files — suggest sub-directory organisation. Needs --src |
| R016-AUDIT-EMAIL-AS-STRING | info | createdBy/validatedBy/etc. typed string instead of FK User → loses ref. integrity if email changes |
| R017-UNBOUNDED-BLOB-FIELD | hint | *Json/*Payload/*Blob/*Manifest without documented size limit |
| R018-EXTERNAL-SCHEMA-OVERSCOPED | info | (stub V2 — full impl in V3 with ts-morph) External schema with many unused fields |
Output formats
# Console output (TTY-aware ANSI colors)
npx mostajs-orm-validator ./schemas
# JSON (for CI / diff)
npx mostajs-orm-validator ./schemas --format json --out report.json
# Markdown (human-readable report)
npx mostajs-orm-validator ./schemas --format markdown --out REPORT.mdExample output :
✗ Section.project R001-EMPTY-RELATIONS warning
Field 'Section.project' looks like an FK to 'Project' but no ORM
relation declared.
Suggestion:
relations: {
project: { type: 'many-to-one', target: 'Project',
required: true, onDelete: 'cascade' },
},Configuration
All thresholds and patterns are configurable — no hardcoded business
strings. Pass a config to validateSchemas :
const report = await validateSchemas(schemas, {
sourceRoot: './lib',
ignore: ['R015', 'R017'], // skip these rules entirely
rules: { R001: 'error' }, // override severity (e.g. block CI on R001)
softDeletePatterns: [
{ flag: 'deleted', timestamp: 'deletedAt' },
{ flag: 'cancelled', timestamp: 'cancelledAt' },
{ flag: 'archived', timestamp: 'archivedAt' },
// add your project-specific patterns here
],
auditByFields: ['createdBy', 'validatedBy', 'reviewedBy'],
thresholds: {
duplicateEntityJaccard: 0.7, // R004
duplicateImplJaroWinkler: 0.85, // R012
flatLibMaxFiles: 25, // R015
},
})CI integration
// package.json
{
"scripts": {
"lint:schemas": "mostajs-orm-validator ./schemas --src ./lib --ci --max-warnings 0"
}
}The --ci flag exits with code 1 if the number of error + warning
findings exceeds --max-warnings (default 0). Bind it to your
pre-commit hook or GitHub Actions to block regressions.
Auto-fix (v1.15+ — V3-A) — --fix workflow
The validator can apply the fix it suggests, in-place via ts-morph, for a subset of rules :
| Rule | Auto-fix action |
|---|---|
| R001-EMPTY-RELATIONS | Move the FK string field out of fields: {} and add a matching many-to-one entry to relations: {} with onDelete: 'cascade'. |
| R001B-FIELD-RELATION-DUPLICATE | Remove the redundant field when both field (string) and relations.field (many-to-one) exist for the same FK — leftover of a partial earlier fix. (v1.17+) |
| R002-FK-NAMING-INCONSISTENT | Rename the field in the schema to match the majority convention (parentId → parent). Cross-file consumer rename is left to the dev (use your IDE rename refactor). |
| R003-SOFT-DELETE-INCONSISTENT | Add softDelete: true and remove manual deleted + deletedAt fields. (v1.17+) |
| R016-AUDIT-EMAIL-AS-STRING | Convert the string field (createdBy, updatedBy…) into a many-to-one relation to User with onDelete: 'set-null'. |
# Dry-run — show diffs without writing
npx mostajs-orm-validator ./schemas --fix-dry-run
# Apply — writes <file>.bak backups by default
npx mostajs-orm-validator ./schemas --fix
# Apply only a subset of rules
npx mostajs-orm-validator ./schemas --fix --fix-rules R001,R003
# Without .bak files (CI / git-tracked workflow)
npx mostajs-orm-validator ./schemas --fix --no-backup
# Roll back the last --fix run (restores every <file>.bak)
npx mostajs-orm-validator ./schemas --rollback-fixWorkflow recommended :
- Commit your current state (so you have a clean diff baseline).
--fix-dry-runfirst — review the proposed diffs.--fixonce you're confident.- Run your test suite. If something broke,
--rollback-fixrestores the.bakfiles. Iterate with--fix-rulesto apply only what works. git diff+ commit.
In-process API
import {
validateSchemas,
applyFixes,
rollbackFixes,
formatText, formatJson, formatMarkdown,
} from '@mostajs/orm/validator'
const report = await validateSchemas(schemas, { sourceRoot: './lib' })
console.log(formatJson(report, true)) // pretty JSON for CI artifact
const fixResults = await applyFixes(report, {
sourceRoot: './schemas',
dryRun: false,
rules: ['R001', 'R001B', 'R003'], // narrow the scope
backup: true,
})
console.log(`Applied ${fixResults.filter(r => r.applied).length} fixes`)
// Tests fail ? roll back :
rollbackFixes('./schemas') // restores all .bak files and deletes themResilience features (v1.17+)
- Cascade ts-morph mitigation : when two fixes target the same file
(e.g.
registration.schema.tswith bothRegistrationSchemaandAttendanceSchema), the fixer reloads theSourceFilebetween fixes viaremoveSourceFile + createSourceFileto avoid "node forgotten" crashes. - Text fallback : if ts-morph
.remove()crashes on an end-of-line comment, the fixer falls back to a robust regex that removes the field cleanly (e.g.project: { type: 'string' }, // FK Project). - Try / catch around each fix : a crash on one finding never aborts the
rest of the batch. Failures are reported in
skippedwith areason.
VSCode extension (v0.2.0)
A VSCode extension wraps the validator so you get inline squiggles on
your *.schema.ts files plus Code Actions (quick-fix UI) for the
auto-fixable rules :
# Install from VSIX (until Marketplace publish)
code --install-extension mostajs-orm-vscode-0.2.0.vsixFeatures :
- In-process import of
@mostajs/orm/validator(no CLI spawn → faster). - Debounced 300ms re-lint on save / edit.
- Hover : full finding details + suggestion preview.
- Code Action ("💡 lightbulb") : applies the corresponding
--fixrule in-place. The.bakdiscipline applies — undo via--rollback-fixor VSCode "Undo Local Changes".
Source : mostajs/mosta-orm-vscode.
Generic by design
The validator is fully generic — no hardcoded entity name, no
project-specific assumption. The set of "known entities"
(KNOWN_ENTITY_REFS) is derived at runtime from the schemas you pass.
Same binary detects the same anti-patterns in any consumer codebase.
TypeScript schemas
The CLI loads .ts/.tsx/.js/.mjs files directly via jiti
— no pre-compile step required. TypeScript paths aliases are resolved
automatically.
Recipes (cookbook)
Pagination + total count
const [rows, total] = await Promise.all([
postRepo.findAll({ author: userId }, { sort: { createdAt: -1 }, skip: 20, limit: 10 }),
postRepo.count({ author: userId }),
])Composite unique upsert (idempotent seed)
// Reuses the {tenantId, slug} composite unique to avoid race conditions
await memberRepo.upsert(
{ tenantId: 't1', slug: 'admin' },
{ tenantId: 't1', slug: 'admin', email: '[email protected]', role: 'owner' },
)Many-to-many through junction (read + insert)
// Schema : User.roles = many-to-many → Role through 'user_roles'
const user = await userRepo.findByIdWithRelations(userId, ['roles'])
console.log(user.roles) // [{ name: 'admin' }, …]
// Add a role link (writes into the junction table) :
await dialect.linkRelation('User', userId, 'roles', roleId)
await dialect.unlinkRelation('User', userId, 'roles', roleId)Cross-dialect bootstrap (one codebase, two environments)
// boot.ts — runs identically against SQLite (dev) and PostgreSQL (prod)
import { registerSchemas, getDialect } from '@mostajs/orm'
import { schemas } from './schemas'
export async function boot() {
registerSchemas(schemas)
const dialect = await getDialect() // picks env at runtime
await dialect.initSchema(schemas) // DDL per strategy
return dialect
}
// dev : DB_DIALECT=sqlite SGBD_URI=./data.sqlite npm run boot
// prod: DB_DIALECT=postgres SGBD_URI=$DATABASE_URL npm run bootTransaction with isolation upgrade
await dialect.$transaction(
async (tx) => {
const acct = await tx.findOne('accounts', { id: 'a' })
if (acct.balance < 100) throw new Error('insufficient')
await tx.update('accounts', { id: 'a' }, { $inc: { balance: -100 } })
await tx.create('ledger', { type: 'debit', amount: 100, accountId: 'a' })
},
{ isolation: 'SERIALIZABLE' }, // upgrade isolation
)Lint your schemas in a pre-commit hook
# .husky/pre-commit
#!/bin/sh
npx mostajs-orm-validator ./schemas --src ./lib --ci --max-warnings 0Soft-delete migration (legacy to native)
# Detects manual deleted/deletedAt pattern, suggests softDelete: true natif
npx mostajs-orm-validator ./schemas --fix-dry-run --fix-rules R003
# Apply when satisfied :
npx mostajs-orm-validator ./schemas --fix --fix-rules R003The validator will :
- Add
softDelete: trueto the schema object. - Remove the manual
deletedanddeletedAtfields fromfields: {}. - Leave a
.bakbackup so you can--rollback-fixif your runtime code relied on the manual fields directly.
EntityService (for @mostajs/net)
import { EntityService } from '@mostajs/orm'
const service = new EntityService(dialect)
const res = await service.execute({
op: 'findAll',
entity: 'User',
filter: { status: 'active' },
relations: ['roles'],
options: { limit: 10 },
})Operations : findAll, findOne, findById, create, update, delete, deleteMany, count, search, aggregate, upsert, updateMany, addToSet, pull, increment.
Schema management
await dialect.initSchema(getAllSchemas()) // create / update DDL per strategy
await dialect.truncateTable?.('users')
await dialect.truncateAll?.(getAllSchemas())
await dialect.dropTable?.('users')
await dialect.dropSchema?.(getAllSchemas())
await dialect.dropAllTables?.()Dialect-level guarantees (v1.13+)
Two classes of correctness fixes ship with 1.13, both driven by real
production pain encountered during @mostajs/replicator runs.
SQL dialects (AbstractSqlDialect)
- FK columns preserve falsy-but-valid values (
0,false). The previous short-circuitdata[name] || nullsilently replaced legitimate zero IDs and booleanfalsewithnull, breaking FK writes whose source-side PK happened to be0. The insert/update path now usesvalue === '' ? null : (value ?? null)— empty strings still null-out (SQL foreign-key constraints reject them on most dialects) but numeric zero,falseand any non-empty value round-trip intact. - One-to-one relations get a column-level
UNIQUEconstraint. Emitted both atCREATE TABLEtime and atALTER TABLE ADDtime when growing an existing schema. Matches the JPA / Hibernate semantics where an@OneToOneFK must be injective (otherwise the "one" side of the relation is not actually single-valued).
Mongo dialect
- FK fields accept UUID strings in addition to native
ObjectId.buildMongooseSchemanow declares FK refs asSchema.Types.Mixedrather thanSchema.Types.ObjectId. Replicated documents originating from a SQL dialect (SQLite / Postgres / … using UUID primary keys) are no longer rejected by Mongoose path validation. A native Mongo app writing properObjectIdrefs keeps working unchanged. findAll()/findOne()fall back to{ id: fkValue }whenpopulate()returnsnull. When a UUID-string FK cannot be resolved through the default Mongoose_idlookup (which expects matching type), the dialect keeps a rawlean()query alongside the populated one and patches the missing refs post-hoc by a directfindOne({ id: fk })on the target collection. Transparent to the caller, prevented silent data loss during cross-dialect reads.
Together, these four items unblock bidirectional SQL ↔ Mongo sync through @mostajs/replicator.
Multiple simultaneous connections
import { createIsolatedDialect, registerNamedConnection, getNamedConnection } from '@mostajs/orm'
const oracle = await createIsolatedDialect({ dialect: 'oracle', uri: '...' }, [UserSchema])
const mongo = await createIsolatedDialect({ dialect: 'mongodb', uri: '...' }, [AuditLogSchema])
registerNamedConnection('audit', mongo)
// Later, anywhere in the codebase :
const conn = getNamedConnection('audit')Ecosystem
| Package | Description |
|---|---|
| @mostajs/orm-bridge | Keep your Prisma code, run it on any of the 13 databases (createPrismaLikeDb() is a drop-in replacement for new PrismaClient()). |
| @mostajs/orm-cli | npx @mostajs/orm-cli — interactive CLI : convert schemas, init databases, scaffold services, replicator + monitor, seeding, bootstrap Prisma migration. |
| @mostajs/orm-adapter | Convert Prisma / JSON Schema / OpenAPI / native .mjs to EntitySchema[] (bidirectional). |
| @mostajs/replicator | Cross-dialect replication : CQRS master/slave, CDC rules (snapshot + incremental), wildcard *, failover (promoteToMaster). As of @mostajs/orm v1.13, Mongo FK columns accept UUID strings coming from SQL dialects (populate falls back to { id: uuid } lookup). |
| @mostajs/orm-copy-data | Cross-dialect data copy : 1 source (DB / CSV / JSON / SQL dump) → N destinations. Backup, migration, seeding. CLI (mostajs-copy) + API. Cron-ready. |
| @mostajs/replica-monitor | Live web dashboard — replicas status, CDC stats, activity stream. Zero DB connections (reads tree + stats files). |
| @mostajs/media | Screen capture + video editor (split, speed, stickers, subtitles) + server-side ffmpeg export + project persistence (ORM + SQLite). |
| @mostajs/config | Env loader with MOSTA_ENV profile cascade (Spring-Boot-style). Used by orm/auth/payment/music. |
Design inspirations
@mostajs/orm draws from three decades of mature ORM engineering in the
Java ecosystem, adapted to the TypeScript / Node.js runtime :
| Borrowed concept | Source | @mostajs/orm equivalent |
|---|---|---|
| SessionFactory / EntityManagerFactory | Hibernate · JPA | getDialect() returning a cached singleton |
| Entity metadata (annotations / XML) | Hibernate, JPA @Entity | EntitySchema — declarative TypeScript schema |
| @OneToMany / @ManyToOne / @OneToOne / @ManyToMany | JPA | relations: { ..., type: 'one-to-many' \| ... } |
| @JoinColumn, @JoinTable | JPA | joinColumn, through |
| CascadeType / FetchType | JPA | cascade, fetch in RelationDef |
| Cascade types (PERSIST, REMOVE, ALL) | JPA | cascade: ['persist', 'remove', 'all'] |
| Schema-generation strategies (validate, update, create, create-drop) | Hibernate hibernate.hbm2ddl.auto | DB_SCHEMA_STRATEGY (same names, same semantics) |
| Show-SQL / format-SQL / highlight-SQL | Hibernate (hibernate.show_sql, hibernate.format_sql) | DB_SHOW_SQL, DB_FORMAT_SQL, DB_HIGHLIGHT_SQL |
| SAVEPOINT for nested transactions | SQL standard, JPA spec | beginTx() inside beginTx() emits SAVEPOINT |
| Repository pattern | Spring Data | BaseRepository<T> with typed CRUD |
| Profile-based configuration | Spring Boot profiles (spring.profiles.active=test) | MOSTA_ENV=TEST + TEST_KEY=value cascade (via @mostajs/config) |
| Environment-aware externalized config | Spring Boot application-${profile}.properties | One .env with ${PROFILE}_${KEY} overrides |
Why borrow from the Java ecosystem ?
Hibernate (2001), JPA (2006, JSR 220), Spring Data (2008), Spring Boot (2014)
have collectively survived two decades of production workloads. Their
vocabulary and semantics are industry defaults : developers who have
worked with any of them recognize @OneToMany, CascadeType.ALL,
spring.profiles.active, hibernate.hbm2ddl.auto=update, SAVEPOINT,
etc. immediately. Reusing those names in @mostajs/orm cuts the learning
curve and avoids inventing a parallel dialect.
Further reading :
- Hibernate ORM — https://hibernate.org/orm/documentation/
- Jakarta Persistence (JPA) — https://jakarta.ee/specifications/persistence/
- Spring Framework — https://spring.io/projects/spring-framework
- Spring Data — https://spring.io/projects/spring-data
- Spring Boot profiles — https://docs.spring.io/spring-boot/reference/features/profiles.html
- Spring Boot externalized configuration — https://docs.spring.io/spring-boot/reference/features/external-config.html
License
AGPL-3.0-or-later + commercial license available.
For closed-source commercial use : [email protected]
Author
Dr Hamid MADANI [email protected]
