npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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 slintorm

Database drivers are loaded lazily at runtime. Using only sqlite? Then pg, mysql2, and mongodb are 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 or ModelMap + 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 rows

Aggregates

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 help

Config 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

  • fs and path are only loaded when schema files are read from disk.
  • Only the selected database driver is required at runtime — others are never imported.
  • Use autoInstallDrivers: false if 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 test

Relationships

  • 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 & ModelAPI reference, 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.