slintorm
v1.1.4
Published
Minimal fully typed orm for typescript
Downloads
4,346
Readme
SlintORM
A lightweight, GORM-inspired TypeScript ORM for SQLite, PostgreSQL, MySQL, and MongoDB — zero-config migrations, a full SQL query builder, and everything hangs off your model instances.
No schema DSL to learn. No Prisma Client generation step. No separate import for every query feature. Write a TypeScript interface, annotate a few fields with comments, call migrate(), and start querying.
const Users = await orm.defineModel<User>("users", "User");
const active = await Users.query().where("status", "=", "active").get();That's it. No db.select().from(users).where(...) ceremony, no prisma.user.findMany({ where: { ... } }) client object indirection — just your model, chained.
Why SlintORM?
Most TypeScript ORMs force a tradeoff: Drizzle is fast and type-safe but minimal — no preloads, no built-in migrations, you write SQL-shaped queries by hand. Prisma is full-featured but heavy — a generated client, a separate schema DSL, and raw SQL the moment you need a window function or a real subquery.
SlintORM aims for the GORM sweet spot: automatic migrations, a real query builder with joins/preloads/aggregates/subqueries, and zero boilerplate, while staying TypeScript-native and lightweight.
| Feature | SlintORM | Drizzle | Prisma |
|-----------------------------------|:---:|:---:|:---:|
| Auto table creation & migration | ✅ Automatic, zero-config | ❌ Manual/CLI | ✅ CLI-based |
| Type-safe queries | ✅ | ✅ | ✅ |
| Relationships (1:1, 1:N, N:M) | ✅ Fully supported with preloads | ✅ Via join tables | ✅ Via relations |
| Query builder: joins & HAVING | ✅ Advanced SQL capabilities | ❌ Limited | ⚠️ Client API only; raw SQL for complex cases |
| Aggregates & window functions | ✅ COUNT/SUM/AVG/MIN/MAX + custom window fns | ❌ Limited | ⚠️ Raw SQL required |
| Subquery support | ✅ Built-in | ❌ Limited | ⚠️ Raw SQL only |
| Preload / eager loading | ✅ Nested, batched (no N+1) | ❌ Not supported | ✅ select/include |
| Auto relation-path joins | ✅ relatedTo() BFS-discovers joins | ❌ | ❌ |
| Migration rollback + snapshots | ✅ CLI rollback to batch/name | ⚠️ Manual | ✅ |
| Boilerplate | ✅ Single import, no generated client | ✅ Minimal | ❌ Requires client generation |
| Learning curve | ✅ Very low | ✅ Low | ❌ Medium — schema DSL + client |
| Raw SQL escape hatch | ✅ whereRaw, exec, batch, transaction | ✅ | ⚠️ Available, more friction |
| Ideal use case | Rapid prototyping → production, GORM-style workflows | Type-safe lightweight projects | Large, ecosystem-heavy apps |
Bottom line: if you want migrations that just work, a query builder that can actually do joins and subqueries, and relationship loading without N+1 — without adopting a whole new schema language — SlintORM is built for you.
Installation
npm install slintormDatabase drivers are loaded lazily at runtime. Using only
sqlite? Thenpg,mysql2, andmongodbare never required or installed into your runtime bundle.
Model Interfaces
Models are plain TypeScript interfaces. Metadata lives in comments directly above each field — no decorators, no separate schema file to hand-maintain.
/** Post table */
interface Post {
// @index;
id?: number;
// @length:255;not null;comment:Post title
title: string;
// @nullable;comment:Author user ID
userId?: number;
// @relation manytoone:User;foreignKey:userId;onDelete:SET NULL
user?: User;
// @json;nullable;comment:Extra post data
meta?: Record<string, any>;
createdAt?: string;
updatedAt?: string;
// @softDelete
deletedAt?: string;
// @enum:(draft,published,archived)
status?: "draft" | "published" | "archived";
}
/** User table */
interface User {
// @index;auto;comment:primary key
id?: number;
// @nullable;length:100;comment:First name
firstName?: string;
// @length:100;not null;comment:Last name
name: string;
// @nullable;length:100;comment:Last name
lastname?: string;
// @unique;comment:Email
email?: string;
// @relationship onetomany:Post;foreignKey:userId
posts?: Post[];
// @relationship onetoone:Profile;foreignKey:userId;onDelete:CASCADE
profile?: Profile;
// @relation manytomany:Team;through:team_members;foreignKey:userId;relatedKey:teamId
teams?: Team[];
// @json;nullable;comment:Extra user info
meta?: Record<string, any>;
createdAt?: string;
updatedAt?: string;
// @softDelete
deletedAt?: string;
// @enum:(active,inactive,banned)
status?: "active" | "inactive" | "banned";
}
/** Profile table */
interface Profile {
// @index;auto;comment:primary key
id?: number;
// @relation onetoone:User;foreignKey:userId
user?: User;
userId: number;
// @json;nullable;comment:Extra profile data
meta?: Record<string, any>;
createdAt?: string;
updatedAt?: string;
// @softDelete
deletedAt?: string;
// @enum:(male,female,other)
gender?: "male" | "female" | "other";
}
/** Todo table */
interface Todo {
// @index;auto;comment:primary key
id?: number;
// @length:255;not null
title: string;
// @nullable;length:1000
detail: string;
createdAt?: string;
updatedAt?: string;
// @softDelete
deletedAt?: string;
// @json;nullable
meta?: Record<string, any>;
// @enum:(low,medium,high)
priority?: "low" | "medium" | "high";
}
/** Team table */
interface Team {
// @index;auto
id?: number;
// @length:255;not null
title: string;
// @nullable;length:1000
detail: string;
// @nullable
open?: boolean;
// @nullable
tested?: boolean;
// @json;nullable
meta?: Record<string, any>;
createdAt?: string;
updatedAt?: string;
// @softDelete
deletedAt?: string;
// @enum:(active,archived)
status?: "active" | "archived";
// @relation manytomany:User;through:team_members;foreignKey:teamId;relatedKey:userId
members?: User[];
}Field metadata reference
| Tag | Meaning |
|---|---|
| @index | Create an index on this column |
| @auto | Auto-increment / serial primary key |
| @unique | Unique constraint |
| @nullable | Column allows NULL |
| @not null | Column is required |
| @length:N | VARCHAR length |
| @json | Serialize/deserialize this field as JSON automatically |
| @softDelete | Marks the field (e.g. deletedAt) used for soft deletes; enables withTrashed()/onlyTrashed()/restore() |
| @enum:(a,b,c) | Restrict to a set of string values |
| @default:value | Default value |
| @comment:text | Column comment (where supported) |
| @relation kind:Model;foreignKey:col;onDelete:ACTION | One-to-one / many-to-one relation |
| @relationship kind:Model;foreignKey:col | One-to-many / one-to-one relation (alias form) |
| @relation manytomany:Model;through:pivot;foreignKey:col;relatedKey:col | Many-to-many relation. The pivot table is auto-synthesized at migration time if not declared as its own model. |
Initialization
import ORMManager from "slintorm";
const orm = new ORMManager({
driver: "sqlite", // sqlite | postgres | mysql | mongodb
databaseUrl: "./testx.db",
dir: "src", // folder containing your model interfaces
logs: false,
});
// Generate schema from source files and apply pending migrations
await orm.migrate();
const Users = await orm.defineModel<User>("users", "User");
const Posts = await orm.defineModel<Post>("posts", "Post");
const Todos = await orm.defineModel<Todo>("todos", "Todo");
const db = orm.DB;If you already have a generated schema object, pass it directly as schema to skip reading schema files from disk:
import { schema } from "./schema/generated.js";
const orm = new ORMManager({
driver: "sqlite",
databaseUrl: "./testx.db",
dir: "src",
schema,
});This example uses SQLite — switch to PostgreSQL, MySQL, or MongoDB by changing driver and databaseUrl.
Fully typed db via ModelMap
If you don't want to manually export every model from defineModel(), generate a ModelMap and get a fully typed db store for free:
import { ModelMap } from "./schema/generated.js";
// Note: ModelMap is only available after your first successful migration —
// run `await orm.migrate()` (or `npx slintorm migrate`) at least once first.
const orm = new ORMManager<ModelMap>({
driver: "sqlite",
databaseUrl: "./testx.db",
dir: "src",
logs: false,
modelMap: {} as ModelMap,
});
await orm.migrate();
const db = orm.DB;
// Fully typed, no manual defineModel() exports needed
await db.Profile.insert({ userId: newUser?.id!, meta: { bio: "This is my profile" } });Both approaches — manual
defineModel()exports orModelMap+db— expose identical functionality. Pick whichever fits your project's style.
Define models
const Users = await orm.defineModel<User>("users", "User");
const Posts = await orm.defineModel<Post>("posts", "Post");
const Todos = await orm.defineModel<Todo>("todos", "Todo");Lifecycle hooks
const Teams = await orm.defineModel<Team>("team", "Team", {
onCreateBefore(item) {
console.log("before create Team:", item);
},
onCreateAfter(item) {
console.log("after create:", item);
},
onUpdateAfter(oldData, newData) {
console.log("updated", oldData, "->", newData);
},
});
const newTeam = await db.Team.insert({
title: "Hook Team",
detail: "Hook test",
open: true,
tested: false,
createdAt: new Date().toISOString(),
});
if (newTeam?.id) {
await db.Team.update({ id: newTeam.id }, { tested: true });
}Available hooks: onCreateBefore, onCreateAfter, onUpdateBefore, onUpdateAfter, onDeleteBefore, onDeleteAfter.
Basic CRUD examples
await Todos.insert({
title: "To watch plates",
detail: "Wash all plates",
createdAt: new Date().toISOString(),
});
const allTodos = await Todos.getAll();
const user = await Users.get({ id: 1 });
await Users.update({ id: 1 }, { name: "Amike Catherine" });
const fetchedUser = await Users.get({ id: 1 });
await fetchedUser?.update({ name: "Amike Egwamene" });
await Posts.delete({ id: 3 });Every record returned by .get() / .insert() comes with .update(), .delete(), and .refresh() attached — no need to re-pass the filter:
const user = await Users.get({ id: 1 });
await user?.update({ name: "New Name" });
await user?.delete();Bulk operations
await Users.insertMany([{ name: "Joe" }, { name: "Jane" }]);
await Users.updateMany({ status: "inactive" }, { status: "banned" });
await Users.deleteMany({ status: "banned" });Upsert & findOrCreate
await Users.upsert({ email: "[email protected]" }, { name: "Joe", email: "[email protected]" });
const { record, created } = await Users.findOrCreate(
{ email: "[email protected]" },
{ name: "Joe", email: "[email protected]" }
);Soft delete
await Users.restore({ id: 1 }); // un-delete
await db.User.query().withTrashed().get(); // include deleted rows
await db.User.query().onlyTrashed().get(); // only deleted rowsAggregates
await Users.count({ status: "active" });
await Users.sum("score");
await Users.avg("score", { status: "active" });
await Users.min("score");
await Users.max("score");Validation
await Users.validate(
{ email: "[email protected]" },
{ email: { required: false, email: true } }
);
const errors = Users.check({ email: "[email protected]" }, { email: { email: true } });Query builder examples
const postWithUser = await Posts.query()
.exclude("title")
.preload("user")
.preload("user.posts")
.preload("user.profile")
.preload("user.posts.user")
.exclude("user.lastname")
.first();
const userWithRelations = await Users.query()
.preload("posts")
.preload("profile")
.first("id = 2");Accessing distant relationships with joins
const assessments = await db.Assessment.query()
.join("modules", "modules.id", "=", "assessments.moduleId")
.join("cohorts", "cohorts.trackId", "=", "modules.trackId")
.join("enrollments", "enrollments.cohortId", "=", "cohorts.id")
.whereRaw(`enrollments.userId = ${session.id}`)
.preload("module")
.get();The same query, shortened with relation-path helpers
You don't have to spell out every intermediate join — SlintORM reads your schema's relation metadata and builds the chain for you.
/**
* Traverse a dot-separated relation path and apply all intermediate
* JOIN clauses automatically. Returns `this`, so you can keep chaining
* your own `.where()`, `.get()`, etc.
*/
const assessments = await db.Assessment.query()
.throughRelation("module.cohort.enrollment")
.where("enrollments.userId", "=", session.id)
.preload("module")
.get();
/**
* Combines throughRelation + a final WHERE in one call.
*/
const assessments2 = await db.Assessment.query()
.whereRelated("module.cohort.enrollment", "userId", session.id)
.preload("module")
.get();
/**
* Don't even know the path? relatedTo() BFS-discovers the shortest
* relation chain to the target model automatically. Throws if no
* path exists between the two models.
*/
const assessments3 = await db.Assessment.query()
.relatedTo("Enrollment", "userId", session.id)
.preload("module")
.get();More query builder features
// distinct / group by / having
const grouped = await db.Enrollment.query()
.select("cohortId")
.countAggregate("*")
.groupBy("cohortId")
.having("COUNT(*) > 5")
.get();
// window functions
const ranked = await db.User.query()
.window("ROW_NUMBER()", "PARTITION BY lastname ORDER BY id ASC")
.get();
// pagination with totals
const { data, total, page, lastPage } = await db.User.query()
.where("status", "=", "active")
.getPaginated(1, 20);
// scopes — reusable, composable query fragments
const activeUsers = await db.User.query()
.scope((qb) => qb.where("type", "=", "user"))
.get();See QUERY_BUILDER.md in this repo for the complete method-by-method reference (every where*, join, preload, aggregate, subquery, and soft-delete method with examples).
Transactions & batches
await orm.transaction(async (trx) => {
await trx.exec("INSERT INTO users (name, email) VALUES (?, ?)", ["Joe", "[email protected]"]);
await trx.exec("INSERT INTO profile (userId) VALUES (?)", [1]);
});transaction() wraps the callback in BEGIN/COMMIT, rolling back automatically on error (no-op wrapper on MongoDB, which has no implicit transaction here).
For a flat list of statements without a callback, use batch():
await orm.batch([
{ sql: "INSERT INTO users (name) VALUES (?)", params: ["Joe"] },
{ sql: "INSERT INTO profile (userId) VALUES (?)", params: [1] },
]);Schema generation and migrations
orm.migrate() generates a schema from your source files and updates tables automatically — no separate generate step required in app code.
If you pass schema directly to ORMManager, the ORM uses that schema instead of reading from disk at all.
CLI
For migrations outside your app's runtime — CI/CD, deploy hooks, production cutover — use the bundled CLI:
npx slintorm generate # Scan source files and (re)generate schema/generated.ts
npx slintorm migrate # Apply all pending migrations
npx slintorm rollback # Roll back the last batch
npx slintorm rollback <name> # Roll back to a specific migration name
npx slintorm rollback --to 2 # Roll back to a specific batch number
npx slintorm status # Show applied / pending migrations
npx slintorm fresh # Drop all tables, then re-run all migrations
npx slintorm drop-tracking # Drop the _slint_migrations table (irreversible — production cutover only)
npx slintorm --help # Show helpConfig file (slintorm.config.js in your project root, or a "slintorm" key in package.json):
// slintorm.config.js
export default {
driver: "sqlite", // sqlite | postgres | mysql | mongodb
databaseUrl: "./myapp.db",
dir: "src",
logs: false,
};Each applied migration writes a JSON record to <dir>/schema/migrations/, in addition to the internal _slint_migrations tracking table. rollback drops the rolled-back batch's tables, then automatically rebuilds the target batch's tables from its schema snapshot — skipping anything still present, so re-running rollback is safe.
Notes
fsandpathare only loaded when schema files are read from disk.- Only the selected database driver is required at runtime — others are never imported.
- Use
autoInstallDrivers: falseif you prefer to install the driver manually. - All queries return mapped boolean fields, parsed JSON fields, and respect configured excludes automatically.
- MongoDB support covers CRUD, filtering, and preloads via the same query builder API; DDL/migrations are limited to index creation (
@index/@unique) since MongoDB is schemaless.
Build & test
npm run build
npm testRelationships
- One-to-many:
@relation onetomany:Post;foreignKey:userId - Many-to-one:
@relation manytoone:User;foreignKey:userId - One-to-one:
@relationship onetoone:Profile;foreignKey:userId - Many-to-many:
@relation manytomany:Team;through:team_members;foreignKey:userId;relatedKey:teamId— the pivot table is auto-created if not declared as its own model.
Migrations
The ORM automatically ensures that tables exist and applies schema changes based on your model metadata:
await orm.migrate();For rollback, snapshotting, status, and production cutover, see CLI above.
Why use SlintORM?
Many TypeScript ORMs are either minimal but lacking features (like Drizzle) or extremely heavy (like Prisma). SlintORM balances ease of use, flexibility, and performance — ideal for projects that need quick iteration, full control over queries, and GORM-inspired patterns in TypeScript, from the very first prototype through to production.
SlintORM is best suited for developers who want a GORM-inspired workflow in TypeScript: minimal setup, automatic migrations, and full SQL query control. Drizzle is lightweight and type-safe but lacks advanced query features. Prisma is powerful and production-ready, but heavier, with more boilerplate and tooling setup.
SlintORM fills the niche for quick iteration, flexible queries, and minimal friction — perfect for both learning and production projects.
Further reading
QUERY_BUILDER.md— full query builder &ModelAPIreference, every method with usage examples.llms.txt— condensed reference for AI coding assistants working in a SlintORM codebase.example.ts— a complete, runnable walkthrough exercising most of the library in one file.
