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 addmeta, errors are{ error: { code, message, details?, requestId } }. Creates return 201, deletes 204, and ids areidinstead 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.

