Book a call
  1. Blog
  2. Open Source
  3. I rewrote my own framework and deleted five…

I rewrote my own framework and deleted five of every six files

CalmAPI 1.x made you write six files for one simple module. Here is why I threw that design away, what one resource file does now, and what I am still unsure about in the 2.0 beta.

A dark card listing six faded files, a mint arrow, and a single cream card holding post.resource.js

A few months ago I opened an old CalmAPI 1.x project to add a “product” module. Nothing fancy. A name, a price, an owner. I scaffolded it, looked at the folder, and counted six files: a model, a DTO file with three DTO classes in it, a controller, a service, a router and a settings file.

Then I read them, one after another, and noticed something a bit embarrassing for a framework I wrote myself. They were mostly saying the same thing. “There is a field called price.” The model said it. The insert DTO said it. The update DTO said it. The get DTO decided whether you could see it. Six files of ceremony for one idea.

That afternoon is where CalmAPI 2 started. It is now in beta (2.0.0-beta.2 on npm as I write this), and I want to tell the story honestly, including the parts I am not sure about yet.

What 1.x got right, and what it made you repeat

The one idea I still believe in from 1.x is that the framework code should live in your project, not hidden somewhere in node_modules. You could open it, read it, change it. People liked that, and I liked that.

What it also did was make you repeat yourself. A controller extended CalmController, used autoBind and wrapped things in try/catch. Access was checked in the router with AuthController.checkLogin. Visibility lived in DTO classes. The rules about one record were spread across five places, and if you forgot one of them, nothing complained. I wrote about that kind of drift in what I wish someone told me before my tenth Express API, and honestly, my own framework had the same habit.

One declaration that drives everything

In 2.0 a resource is one file. This is the real post resource you get when you run the starter template, copied straight from a fresh project:

import { resource, f } from '#calm';

export default resource('post', {
  fields: {
    title: f.string().required().max(200).searchable().sortable(),
    slug: f.string().readOnly().unique().filterable(),
    body: f.text().required().max(50_000),
    status: f.enum(['draft', 'published']).default('draft').filterable(),
    tags: f.array(f.string().max(30)).max(10).default([]).filterable(),
    author: f.ref('user').owner(), // set from the session; powers the 'owner' policy
    publishedAt: f.date().readOnly().sortable(),
  },
  policy: {
    list: 'public', // drafts are filtered out in post.service.js scope()
    read: 'public',
    create: 'authenticated',
    update: 'owner | admin',
    delete: 'owner | admin',
  },
});

Each f.*() line drives the Mongoose schema, input validation, what the API returns, which filters and sorts are allowed, and the OpenAPI schema. Mark a field readOnly() and a client sending it gets a 422. Leave a field without filterable() and trying to filter on it is a 400, instead of a quiet scan of data you never meant to expose. f.ref(‘user’).owner() sets the author from the session and makes the ‘owner’ policy mean something.

For a plain CRUD resource, that is the whole thing. When I ran npx calm make resource product name:string+required price:number+sortable in the demo project, it wrote one resource file and one test file. That is where the title comes from: six files became one.

Where custom logic goes now

Real apps are never only CRUD, so there are two optional files. The service, for business logic and hooks. And a routes file, for endpoints that are not create, read, update or delete. Here is part of the generated post service:

export default class PostService extends ResourceService {
  // Every query goes through scope(): visitors see published posts, authors also see
  // their own drafts, admins see everything.
  scope({ user } = {}) {
    if (hasRole(user, 'admin')) return {};
    if (user) return { $or: [{ status: 'published' }, { author: user.id }] };
    return { status: 'published' };
  }

  async beforeCreate(data) {
    return {
      ...data,
      slug: await this.uniqueSlug(data.title),
      publishedAt: data.status === 'published' ? new Date() : undefined,
    };
  }
}

The controller is gone. Custom endpoints in post.routes.js just return a value, and the framework wraps it. Running npx calm routes in the fresh project shows where every route came from:

METHOD  PATH                    POLICY         FROM
GET     /api/posts/featured     public         app/resources/post/post.routes.js
POST    /api/posts/:id/publish  owner | admin  app/resources/post/post.routes.js
GET     /api/posts              public         app/resources/post/post.resource.js
GET     /api/posts/:id          public         app/resources/post/post.resource.js
POST    /api/posts              authenticated  app/resources/post/post.resource.js
PATCH   /api/posts/:id          owner | admin  app/resources/post/post.resource.js
DELETE  /api/posts/:id          owner | admin  app/resources/post/post.resource.js

Every route has a policy next to it. That one table is the thing I always wanted to see in my 1.x projects and never could.

The framework still lives in your repo, but now it can be upgraded

The big weakness of “the code is in your project” in 1.x was that once you had it, you were on your own. Fixes upstream did not reach you unless you copied them by hand.

In 2.0 the framework is copied into a calm/ folder, about 8,600 lines of commented JavaScript on Express 5 and Mongoose. Next to it sits calm.lock, which records the version you started from and a SHA-256 hash of every framework file as it shipped. When you run calm upgrade, it compares three versions of each file: yours, the original, and the new one. Files you never touched are replaced. Files you edited get a three-way merge with your changes kept. Real conflicts get the usual markers. The upgrading docs have the full table.

So you own every line, and you still get fixes. That was the trade I could not make work in 1.x.

The breaking changes I chose on purpose

There is no in-place upgrade from 1.x. I know that hurts, so here is why each break exists:

  • Opaque sessions instead of JWT. Session tokens are stored as hashes in calm_sessions, so you can list them, end one, or end all. Users sign in once after the switch.
  • scrypt instead of bcrypt. It comes from node:crypto, so no extra dependency. The migration guide shows how to keep old bcrypt hashes working until users log in again.
  • Plain envelopes. Success is { data }, lists add meta, errors are { error: { code, message, details?, requestId } }. Creates return 201, deletes 204, and ids are id instead of _id.
  • Explicit policies. Every action needs a rule. No more remembering to add a check in a router.

It is also ESM now, and runs on Express 5. Your frontend will need updating, and the migration guide lists every change your clients will see.

What I am still unsure about

It is a beta, and the README says it plainly: APIs may still change before 2.0. I would rather say that than pretend.

The surface is also big now. Besides resources there is a security audit, devtools, an MCP server for coding agents, and 35 official blocks. Every one of those is something I have to keep honest. I worry a little that a framework that started as “calm” now has a lot of rooms in the house.

And it is MongoDB only. That is a deliberate choice, but I know it rules CalmAPI out for a lot of teams.

What I want to hear: does the one-file resource hold up in your real modules, or do you hit something it cannot express? Which docs page made you stop and scratch your head?

If you are on 1.x, start here

Inside your 1.x project, run npx calmapi@next doctor. It checks the project against the advisories published for 1.x templates and prints the fix for each finding. Apply those to your production app first, then follow the migration guide. The 1.x docs stay online while you move.

To try 2.0 fresh you need Node.js 22.12+ and MongoDB, then npm create calmapi@latest my-api. Everything else is on calmapi.dev.

If something breaks or feels wrong, please open an issue on GitHub. That is the most useful thing anyone can do for a beta. And if you want to see what else our team builds in the open, it is all on our open source page.

Sunil Kr. Samanta

Co-Founder & CTO at Broadifi Tech. Weaving together technology and inner exploration. Built and scaled startups while diving deep into consciousness studies. Equally at home discussing system design or Swami Vivekananda. Currently focused on creating tech that elevates humanity.

Everything by Sunil Kr. Samanta

Start the thread

Your email address will not be published. Required fields are marked *