A typed, promise-based ORM for TypeScript. Define models with a factory, chain immutable scopes, plug in any storage via the Connector interface, and run schema migrations against the same connector.
import { defineSchema, Model } from '@next-model/core';
import { SqliteConnector } from '@next-model/sqlite-connector';
const schema = defineSchema({
users: {
columns: {
id: { type: 'integer', primary: true, autoIncrement: true },
firstName: { type: 'string' },
lastName: { type: 'string' },
age: { type: 'integer' },
},
},
});
const connector = new SqliteConnector(':memory:', { schema });
class User extends Model({
connector,
tableName: 'users',
}) {
get name() {
return `${this.firstName} ${this.lastName}`;
}
}
await connector.ensureSchema(); // creates every declared table that doesn't exist
const ada = await User.create({ firstName: 'Ada', lastName: 'Lovelace', age: 36 });
ada.name; // 'Ada Lovelace'Models are TypeScript classes — add getters, methods, and computed properties just like normal. Swap SqliteConnector for any other connector (Postgres, MySQL, MongoDB, Redis, Supabase, MemoryConnector for tests, …) and every Model feature keeps working through the same Connector interface.
When the connector carries an attached schema, connector.ensureSchema() walks every table declared on it and creates any table that doesn't already exist. Calling it again is a no-op: existing tables are skipped, only new ones are created. It returns { created: string[], existing: string[] } so you can log the diff.
const connector = new SqliteConnector('./data/app.sqlite', { schema });
const { created, existing } = await connector.ensureSchema();
// app boot: creates missing tables; subsequent boots: returns { created: [], existing: [...] }Implemented by SqliteConnector, MemoryConnector, and LocalStorageConnector today; other connectors fall back to the explicit createTable(name, builder) loop until they opt in.
// Each chain step returns a new query — the source class is untouched.
const adults = User.filterBy({ $gte: { age: 18 } });
await adults.count(); // number of adults
await adults.orderBy({ key: 'age', dir: 'desc' }).first(); // oldest adult
await adults.orderBy({ age: 'desc' }).first(); // same — conventional shape
await adults.filterBy({ $like: { lastName: 'Love%' } }).all(); // intersected filters
await User.filterBy({ $in: { age: [25, 30, 36] } })
.orderBy({ lastName: 'asc' })
.limitBy(10)
.all();
await User.findOrNull(42); // returns null on miss
await User.find(42); // throws NotFoundError on missOperators available on every connector: $eq · $gt · $gte · $lt · $lte · $in · $notIn · $null · $notNull · $like · $ilike · $between. Compose them with filterBy / orFilterBy / unfiltered / unscoped. Predeclare reusable filters via scopes on the Model({...}) definition.
Each column-style operator accepts both the nested ORM-style form filterBy({ col: { $in: [...] } }) and the top-level form filterBy({ $in: { col: [...] } }) — they produce the same query. Operators and equality keys can also be mixed in a single filter object: filterBy({ workspaceId: 1, $null: 'archivedAt' }) is composed as an AND of every predicate, no .filterBy(...).filterBy(...) chaining required.
orderBy accepts both the strict { key, dir } shape and the conventional { col: 'asc' | 'desc' } shape — connectors normalise either before building SQL, so orderBy({ createdAt: 'desc' }) and orderBy({ key: 'createdAt', dir: SortDirection.Desc }) produce the same query.
import { NextModelProvider, useModel } from '@next-model/react';
export function Root() {
return <NextModelProvider><AdultUsers /></NextModelProvider>;
}
function AdultUsers() {
const { data, isLoading } = useModel(User)
.filterBy({ $gte: { age: 18 } })
.orderBy({ key: 'lastName' })
.watch({ keys: ['users:adults'] });
if (isLoading) return <p>Loading…</p>;
return (
<ul>
{data.map((u) => (
<li key={u.id}>{u.name} · {u.age}</li>
))}
</ul>
);
}useModel(User) returns the same chainable query builder you'd use server-side; .watch() subscribes the component so saves and deletes broadcast back into the live result set. For a one-shot read use .fetch() instead.
This repository is a pnpm workspace; each published package lives under packages/.
| Package | Purpose |
|---|---|
@next-model/core |
Model factory, chainable query DSL, validators, callbacks, soft deletes, associations, in-memory connector. |
@next-model/knex-connector |
SQL connector backed by Knex 3 (sqlite3 / Postgres / MySQL / MariaDB / Oracle / MSSQL). |
@next-model/postgres-connector |
Native PostgreSQL connector using node-postgres directly — no Knex. |
@next-model/sqlite-connector |
Native SQLite connector using better-sqlite3 directly — no Knex. |
@next-model/mysql-connector |
Native MySQL connector using mysql2 directly — no Knex. |
@next-model/mariadb-connector |
Native MariaDB connector. Extends mysql-connector and uses RETURNING *. |
@next-model/redis-connector |
Redis connector — HASH per row + ZSET of ids per table. |
@next-model/valkey-connector |
Valkey connector. Extends redis-connector (Valkey is wire-compatible with Redis). |
@next-model/mongodb-connector |
Native MongoDB connector using the official mongodb driver. |
@next-model/aurora-data-api-connector |
Connector for AWS Aurora Serverless v1 (RDS Data API). |
@next-model/supabase-connector |
Supabase connector via @supabase/supabase-js (PostgREST). Server/browser differ only by the client/key you pass. |
@next-model/local-storage-connector |
Browser localStorage connector. Inherits from MemoryConnector. |
@next-model/migrations |
Connector-agnostic schema migration runner with optional dependency graph. |
@next-model/express-rest-api |
Express 5 REST adapter — eight default CRUD actions with per-action auth + response-mapping hooks. |
@next-model/graphql-api |
GraphQL schema generator — six default CRUD operations with per-operation auth + per-row response mapping. |
@next-model/nextjs-api |
Next.js App Router adapter — { GET, POST, PATCH, DELETE } route-handler exports with the same auth + mapping hook surface. |
@next-model/migrations-generator |
CLI (nm-generate-migration) + library for scaffolding timestamped migration files. |
@next-model/zod |
Bridge zod schemas into toTypedColumns() (for defineSchema), init coercion, and validators — one schema, three consumers. |
@next-model/typebox |
TypeBox variant of @next-model/zod. |
@next-model/arktype |
arktype variant of @next-model/zod. |
End-to-end runnable projects live under demos/, grouped by runtime. node/ is split into server/ (needs Node / a DB / a native dep) and client/ (also runs in a browser).
| Demo | Adapter / connector | Infra |
|---|---|---|
node/client/memory |
@next-model/core (MemoryConnector) |
none |
node/client/local-storage |
@next-model/local-storage-connector |
none |
node/server/sqlite |
@next-model/sqlite-connector |
none (in-memory db) |
node/server/postgres |
@next-model/postgres-connector |
docker compose up -d (postgres:17) |
node/server/mysql |
@next-model/mysql-connector |
docker compose up -d (mysql:8) |
node/server/mariadb |
@next-model/mariadb-connector |
docker compose up -d (mariadb:11) |
node/server/redis |
@next-model/redis-connector |
docker compose up -d (redis:7) |
node/server/valkey |
@next-model/valkey-connector |
docker compose up -d (valkey:8) |
node/server/mongodb |
@next-model/mongodb-connector |
docker compose up -d (mongo:7) |
node/server/knex |
@next-model/knex-connector |
sqlite: none; `docker compose --profile pg |
node/server/aurora-data-api |
@next-model/aurora-data-api-connector via MockDataApiClient |
none |
node/server/supabase |
@next-model/supabase-connector via MockSupabaseClient |
none |
node/server/express-rest-api |
Express 5 + @next-model/express-rest-api + @next-model/sqlite-connector |
none |
node/server/graphql-api |
graphql-http + @next-model/graphql-api + @next-model/sqlite-connector |
none |
react/todo |
React 19 + Vite + @next-model/local-storage-connector |
none (browser) |
nextjs/todo |
Next.js 15 App Router + @next-model/sqlite-connector — server components + server actions |
none |
nextjs/api |
Next.js 15 + @next-model/nextjs-api — REST route handlers |
none |
The demos/README.md covers the running convention in detail.
Migrations need more than createTable / dropTable. Each connector implements alterTable(spec) so existing tables can grow new columns, indexes, foreign keys, and check constraints in place — no destructive recreate.
import { defineAlter } from '@next-model/core';
await connector.alterTable(defineAlter('users', (a) => {
a.addColumn('lastSeenAt', 'datetime', { null: true });
a.renameColumn('first_name', 'firstName');
a.changeColumn('age', 'integer', { null: false });
a.addIndex(['firstName', 'lastName'], { name: 'idx_users_full_name' });
a.addForeignKey('teams', { onDelete: 'cascade' });
a.addCheckConstraint('age >= 0', { name: 'chk_age_non_negative' });
}));The full op set: addColumn / removeColumn / renameColumn / changeColumn, addIndex / removeIndex / renameIndex, addForeignKey / removeForeignKey, addCheckConstraint / removeCheckConstraint, plus addReference / removeReference sugar (column + index + optional FK in one call). Constraints get stable default names (fk_<table>_<refTable>, idx_<table>_<columns>) so the matching remove* ops can target them without bookkeeping.
| Connector | Behaviour |
|---|---|
KnexConnector, PostgresConnector, MysqlConnector, MariaDbConnector, DataApiConnector |
All ops translate to native ALTER TABLE / CREATE INDEX DDL. |
SqliteConnector |
addColumn / removeColumn / renameColumn use native ALTER TABLE (SQLite ≥ 3.35 / 3.25). changeColumn, addForeignKey / removeForeignKey, and addCheckConstraint / removeCheckConstraint use the standard "create new table + copy + drop + rename" recreate dance internally. |
MemoryConnector / LocalStorageConnector |
Column rename / remove rewrites the in-memory rows; addColumn back-fills the default. Index ops are no-ops (no indexing). Foreign keys + check constraints throw UnsupportedOperationError since they cannot be enforced. |
RedisConnector / ValkeyConnector |
removeColumn / renameColumn rewrite hash fields. Other ops are no-ops or throw UnsupportedOperationError (relational constraints aren't enforced). |
MongoDbConnector |
removeColumn / renameColumn use $unset / $rename. addIndex / removeIndex map to createIndex / dropIndex. Foreign keys + check constraints throw UnsupportedOperationError. |
The same spec passed through SchemaCollector.alterTable(...) is mirrored into the schema snapshot, so collector.writeSchema(path) continues to round-trip cleanly through createTable + a series of mutations.
@next-model/migrations accepts both styles of migration. Define a single change(connector) block and the runner records every schema mutation, then replays the inverse on down() automatically:
import type { ChangeMigration } from '@next-model/migrations';
const addEmailToUsers: ChangeMigration = {
version: '20260101120000',
name: 'add_email_to_users',
async change(connector) {
await connector.alterTable(defineAlter('users', (a) => {
a.addColumn('email', 'string', { null: false });
a.addIndex('email', { unique: true, name: 'idx_users_email' });
}));
},
};
await migrator.migrate([addEmailToUsers]);
await migrator.rollback([addEmailToUsers]); // auto-derives removeIndex + removeColumnInversion table:
| Recorded op | Inverse |
|---|---|
createTable(name, ...) |
dropTable(name) |
addColumn(name, type, opts) |
removeColumn(name) |
renameColumn(from, to) |
renameColumn(to, from) |
changeColumn(name, type, opts, previous) |
changeColumn(...) back to previous |
addIndex(cols, { name? }) |
removeIndex(name ?? cols) |
renameIndex(from, to) |
renameIndex(to, from) |
addForeignKey(toTable, opts) |
removeForeignKey(opts.name ?? "fk_<from>_<to>") |
addCheckConstraint(expr, { name }) |
removeCheckConstraint(name) |
Operations that lose information when applied (dropTable, removeColumn, removeIndex, removeForeignKey, removeCheckConstraint, changeColumn without a previous snapshot, addCheckConstraint without an explicit name) raise IrreversibleMigrationError on down(). Write explicit up() / down() for those — both styles can coexist in the same migration list. Inside a change() block you can only call schema-mutating methods (createTable / dropTable / alterTable); use up() / down() for any data-touching work.
This repo ships agent skills under skills/ — one SKILL.md per package plus an overview that helps an AI agent pick the right adapter for a use case. They follow the open agent skills spec and are installable with the skills CLI for Claude Code, Cursor, OpenCode, Codex, and 50+ other coding agents.
# install every skill from this repo (Claude Code, Cursor, OpenCode, …)
npx skills add tamino-martinius/node-next-model
# list before installing
npx skills add tamino-martinius/node-next-model --list
# pick just the ones you need
npx skills add tamino-martinius/node-next-model \
--skill next-model \
--skill next-model-core \
--skill next-model-postgres-connector
# install globally (~/.claude/skills/, ~/.cursor/skills/, …)
npx skills add tamino-martinius/node-next-model -g
# target one agent
npx skills add tamino-martinius/node-next-model -a claude-code -yAvailable skills (next-model is the index; the rest map 1:1 to the packages above):
next-model · next-model-core · next-model-knex-connector · next-model-postgres-connector · next-model-sqlite-connector · next-model-mysql-connector · next-model-mariadb-connector · next-model-redis-connector · next-model-valkey-connector · next-model-mongodb-connector · next-model-aurora-data-api-connector · next-model-supabase-connector · next-model-local-storage-connector · next-model-migrations · next-model-migrations-generator · next-model-express-rest-api · next-model-graphql-api · next-model-nextjs-api · next-model-zod · next-model-typebox · next-model-arktype · next-model-react
- Node.js ≥ 22 (required by every package).
- Pure ESM (
type: "module",exportsfield). Built with TypeScript 6 /module: NodeNext. - Browser support is limited to
@next-model/local-storage-connector.
pnpm install
pnpm -r build
pnpm typecheck
pnpm -r coverageCI matrix: Node 22 + 24 on every push, plus a real-database leg that boots Postgres 17 and MySQL 8 service containers and runs the knex-connector spec against each.
Open an issue or PR on github.com/tamino-martinius/node-next-model. Each package keeps a rolling vNext section in its HISTORY.md; please append a one-line entry under the appropriate subheading when you ship a feature, fix or tooling change.
MIT, copyright 2017–2026 Tamino Martinius.