Package Exports
- @mostajs/orm
- @mostajs/orm/bridge
- @mostajs/orm/register
- @mostajs/orm/validator
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)
๐ Runnable samples ยท @mostajs/orm-samples โ copy-paste install per feature, covering 100% of this package's public API.
npx @mostajs/orm-samples list # browse available samples
npx @mostajs/orm-samples scaffold 01-quickstart-sqlite ~/my-app
cd ~/my-app && ./01-quickstart-sqlite.sh # runnable in 30 secondsWhy @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: 'alice@example.com', name: 'Alice' })
const alice = await userRepo.findOne({ email: 'alice@example.com' })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/orm@2.0 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/orm@2.1 |
LAZY (opt-in fetch:'eager') |
findById() polymorphe (string, {id}, natural key single or composite) |
extractRelId(value) helper |
Yes โ explicitly documented + ORMConceptValidator R019/R020/R021 (livrรฉs en 2.1.0) |
The @mostajs/orm@2.0 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, livrรฉ 2.1.0) | โ via ORMConceptValidator | None |
Auto-fix the trap (injects extractRelId import + wraps comparison) |
โ V2 โ diff suggรฉrรฉ fourni, application manuelle ou via plugin IDE | 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 : drmdh@msn.com
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: 'a@b.com' }) // 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: 'a@b.com', name: 'Admin' })
await repo.findOne({ email: 'a@b.com' })
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: 'a@b.com' }, { 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: 'a@b.c', 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: 'o@x.io', name: 'Outer' })
const inner = await dialect.beginTx() // โ SAVEPOINT mosta_sp_2_xxxx
await dialect.create(UserSchema, { email: 'i@x.io', 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: 'admin@t1.io', 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 : drmdh@msn.com
Author
Dr Hamid MADANI drmdh@msn.com