@adinet/indigodb
v3.1.0
Published
Lightweight ORM for PostgreSQL and MongoDB with real-time updates over WebSockets
Maintainers
Readme
IndigoDB
IndigoDB is a lightweight ORM for Node.js that works against either PostgreSQL or MongoDB, with real-time change notifications pushed to clients over a built-in WebSocket server. It's inspired by Firebase's real-time database behavior: define a model, run CRUD, and connected clients are notified of every insert/update/delete automatically.
v2+ is a full rewrite with a new instance-based API, opt-in real-time, typed models, and a pluggable adapter architecture. See the Migration guide if you are upgrading from v1.
Features
- Dual database support — one API over PostgreSQL and MongoDB, swapped by config.
- Rich query engine — Mongo-style operators (
$gt,$in,$like,$or, ...), pagination, projection, bulk operations, and araw()escape hatch. - Relations —
hasMany/belongsTowith batched eager loading viainclude. - Transactions —
db.transaction()with automatic commit/rollback on both backends. - Migrations —
indigodb-migrateCLI + programmaticMigrationRunner. - Real-time updates (opt-in) — Postgres triggers +
LISTEN/NOTIFYand MongoDB change streams are fanned out to WebSocket clients, with filtered subscriptions, pluggable auth, and a dependency-free frontend client. - Schema features —
required,default, indexes, automatic timestamps, lifecycle hooks. - Fully typed —
defineModel<T>()returns a typedModel<T>; noanyleaking into your code. - Safe by default — table/column identifiers are validated (anti SQL-injection) and all values are parameterized.
- Explicit lifecycle —
connect()/close()cleanly open and release every resource (pool, listener, change streams, WebSocket server). - Injectable logger — the library is silent unless you pass a
Logger.
Installation
npm install @adinet/indigodbpg, mongodb, and ws are regular runtime dependencies — no extra peer installs required.
Quick start
import { IndigoDB, DataTypes } from "@adinet/indigodb";
interface User {
id: number;
name: string;
email: string;
}
const db = new IndigoDB({
database: {
type: "postgresql",
host: "localhost",
port: 5432,
user: "db_user",
password: "db_password",
database: "db_name",
},
realtime: { enabled: true, port: 8080 }, // omit to skip the WebSocket server
});
await db.connect();
const Users = await db.defineModel<User>("users", {
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
name: { type: DataTypes.STRING },
email: { type: DataTypes.STRING, unique: true },
});
const user = await Users.create({ name: "John Doe", email: "[email protected]" });
const all = await Users.findAll();
const byId = await Users.findById(user.id);
await Users.update(user.id, { name: "Jane Doe" });
await Users.delete(user.id);
// In-process subscription to every change (works even without WebSockets):
db.on("change", (event) => {
console.log(event); // { model, operation: "INSERT" | "UPDATE" | "DELETE", data }
});
await db.close(); // releases the pool, listener, change streams and WS serverMongoDB
const db = new IndigoDB({
database: {
type: "mongodb",
connectionString: "mongodb://localhost:27017/mydb",
},
realtime: { enabled: true, port: 8080 },
});MongoDB documents use _id as the primary key automatically; string ids that look like an ObjectId are converted for you. Change streams require the MongoDB deployment to be a replica set.
Real-time frontend integration
Plain WebSocket
Connect to the WebSocket server and listen for updates:
const socket = new WebSocket("ws://localhost:8080");
socket.onmessage = (event) => {
const { event: name, data } = JSON.parse(event.data);
// name === "databaseUpdate"
// data === { model, operation, data }
console.log("Real-time update:", data);
};The payload shape is identical whether the change originated in PostgreSQL or MongoDB, so a single frontend handler covers both backends. By default a client receives every change event across every model.
Filtered subscriptions
Send a subscribe message right after connecting to narrow what you receive — by model, by a Where filter (same operator syntax as Querying), or both. A later subscribe message replaces the previous filter:
socket.onopen = () => {
socket.send(JSON.stringify({
type: "subscribe",
models: ["orders"],
where: { status: "urgent" },
}));
};@adinet/indigodb/client
A small, dependency-free client (works in any environment with a global WebSocket — browsers, or Node 22+) handles connecting, sending the subscribe filter, re-subscribing after a reconnect, and exponential backoff:
import { RealtimeClient } from "@adinet/indigodb/client";
const client = new RealtimeClient({
url: "ws://localhost:8080",
models: ["orders"],
where: { status: "urgent" },
});
client.connect();
const unsubscribe = client.on((event) => {
console.log(event.model, event.operation, event.data);
});WebSocket authentication
Pass realtime.authenticate to validate connections (token in a header, query string, cookie — whatever your app uses) before they're accepted; refusing a connection closes the socket with code 4001:
const db = new IndigoDB({
database: { /* ... */ },
realtime: {
enabled: true,
authenticate: (request) => {
const token = new URL(request.url ?? "", "http://x").searchParams.get("token");
return token === process.env.REALTIME_TOKEN;
},
},
});API reference
new IndigoDB(config)
| Config field | Description |
| --- | --- |
| database | Discriminated by type. PostgreSQL: { type: "postgresql", host, port, user, password, database } (or connectionString). MongoDB: { type: "mongodb", connectionString, database? }. |
| realtime | Optional. { enabled: boolean, port?: number, authenticate? } — defaults to port 8080. When omitted or enabled: false, no WebSocket server is started. authenticate(request) (optional) runs per connection; return false to refuse it. |
| logger | Optional Logger. Defaults to a no-op; pass consoleLogger (exported) or your own. |
Methods
connect(): Promise<void>— connects the adapter (fails fast on bad credentials) and starts the WebSocket server if real-time is enabled.defineModel<T>(name, schema, options?): Promise<Model<T>>— creates the table/collection (indexes, Postgres triggers) and resolves once it is ready. Async — alwaysawaitit.options.timestamps: trueadds managedcreatedAt/updatedAtcolumns.on("change", listener)— subscribe toChangeEvents in-process (inherited fromEventEmitter).transaction<R>(fn): Promise<R>— see Transactions.close(): Promise<void>— stops the WebSocket server, closes change streams, and drains the connection pool / client.
Model methods
| Method | Description |
| --- | --- |
| create(data) | Insert a record; returns the created row. |
| createMany(data[]) | Bulk insert (single multi-row INSERT / insertMany); returns the created rows. |
| findAll(where?, options?) | Query with operators, orderBy, limit, offset, select, include (see Relations). |
| findOne(where?, options?) | First match or null. |
| findById(id) | Return a record by its primary key, or null. |
| count(where?) | Number of matching records. |
| exists(where?) | true if at least one record matches. |
| update(id, data) | Update by primary key; returns the updated row or null. |
| updateMany(where, data) | Bulk update; returns the number of affected records. |
| delete(id) | Delete by primary key; returns the deleted row or null. |
| deleteMany(where) | Bulk delete; returns the number of removed records. |
The primary key is taken from the column marked primaryKey: true in the schema (PostgreSQL), or defaults to _id (MongoDB).
Schema features
Beyond type / primaryKey / autoIncrement / unique, columns support:
const Accounts = await db.defineModel<Account>(
"accounts",
{
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
email: { type: DataTypes.STRING, required: true, unique: true },
plan: { type: DataTypes.STRING, default: "free" }, // static value...
apiKey: { type: DataTypes.STRING, default: () => crypto.randomUUID() }, // ...or a factory
status: { type: DataTypes.STRING, index: true }, // non-unique index
},
{ timestamps: true } // adds managed createdAt / updatedAt columns
);required— rejectscreate()/createMany()calls missing the column (after defaults are applied) with aValidationError.default— a static value or a zero-argument factory invoked per row when the column is omitted.index— creates a non-unique index;unique(already available) creates a unique index on both backends.timestamps(model option) —createdAtis stamped oncreate();updatedAtis stamped oncreate()and refreshed on everyupdate()/updateMany().references—{ model, column? }, a foreign key hint (see Relations).
Relations
const Users = await db.defineModel<User>("users", {
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
name: { type: DataTypes.STRING },
});
// Define the target of a reference BEFORE the model that references it —
// PostgreSQL needs the referenced table to already exist.
const Posts = await db.defineModel<Post>("posts", {
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
title: { type: DataTypes.STRING },
userId: { type: DataTypes.INTEGER, references: { model: "users" } },
});
Users.hasMany(Posts, { foreignKey: "userId", as: "posts" });
Posts.belongsTo(Users, { foreignKey: "userId", as: "author" });
const usersWithPosts = await Users.findAll({}, { include: ["posts"] });
console.log(usersWithPosts[0].posts); // Post[]
const postsWithAuthor = await Posts.findAll({}, { include: ["author"] });
console.log(postsWithAuthor[0].author); // User | nullreferences(PostgreSQL) adds aREFERENCESconstraint at table-creation time. On MongoDB it's documentation only — there's no native FK enforcement.includeruns one batched query per association (via$inagainst the target model, reusing its ownfindAll()) — never one query per row, and identical on both backends since no JOIN/$lookupis involved. Including an association that wasn't registered withhasMany/belongsTothrowsConfigurationError.
Lifecycle hooks
Accounts.hooks.beforeCreate((data) => ({ email: (data.email as string).toLowerCase() }));
Accounts.hooks.afterCreate((account) => sendWelcomeEmail(account.email));
Accounts.hooks.beforeDelete((id) => auditLog.record("account.delete", id));Available hooks: beforeCreate, afterCreate, beforeUpdate, afterUpdate, beforeDelete, afterDelete. A before* hook may return a partial payload that gets merged into the pending data (returning nothing leaves it unchanged); after* hooks are for side effects and run with the persisted record. Hooks run for single-row create() / update() / delete() only — bulk operations (createMany / updateMany / deleteMany) skip them by default, so a bulk call never silently re-fetches every affected row.
Transactions
await db.transaction(async (tx) => {
const accounts = tx.getModel(Accounts);
const transfers = tx.getModel(Transfers);
const from = await accounts.update(fromId, { balance: fromBalance - amount });
const to = await accounts.update(toId, { balance: toBalance + amount });
await transfers.create({ fromId, toId, amount });
if (from!.balance < 0) throw new Error("insufficient funds"); // rolls everything back
});tx.getModel(model) exchanges an already-defineModel'd instance for a clone bound to the transaction — same schema and the same hooks registry, so hooks registered on the original model still fire. Every query made through the clone runs inside the transaction; throwing anywhere in the callback rolls it back and rejects db.transaction() with that error, otherwise it commits automatically.
- PostgreSQL: a dedicated pooled connection running
BEGIN/COMMIT/ROLLBACK. - MongoDB: a
ClientSession(requires a replica set — same constraint as change streams).
Querying
Filters use Mongo-style operators on both backends — compiled to parameterized SQL on PostgreSQL, passed (almost) natively to MongoDB. A plain value still means equality:
const results = await Users.findAll(
{
age: { $gte: 18, $lt: 65 }, // comparisons
name: { $like: "A%" }, // SQL LIKE (regex-safe on MongoDB)
role: { $in: ["admin", "editor"] }, // membership
deletedAt: { $null: true }, // IS NULL
$or: [{ plan: "pro" }, { credits: { $gt: 0 } }],
},
{
orderBy: { name: "asc" },
limit: 20,
offset: 40,
select: ["id", "name", "email"],
}
);Operators: $eq, $ne, $gt, $gte, $lt, $lte, $in, $nin, $like, $null, plus $or / $and combinators. Every column name in a filter is validated against the model schema, and every value is parameterized — unknown columns or malformed operators throw QueryError / UnknownColumnError before touching the database.
Raw escape hatch
When the query engine isn't enough:
// PostgreSQL — parameterized SQL:
const { rows } = (await db.raw(
"SELECT name, COUNT(*) FROM users GROUP BY name HAVING COUNT(*) > $1",
[1]
)) as { rows: unknown[] };
// MongoDB — a command document:
await db.raw({ ping: 1 });Supported data types
| IndigoDB type | PostgreSQL | MongoDB |
| --- | --- | --- |
| INTEGER | INTEGER | Number |
| BIGINT | BIGINT | Number (JS precision limit applies — see below) |
| FLOAT | REAL | Number |
| DOUBLE | DOUBLE PRECISION | Number |
| DECIMAL | NUMERIC (or NUMERIC(precision, scale)) | String (exact precision — see below) |
| STRING | VARCHAR(255) (or VARCHAR(length)) | String |
| TEXT | TEXT | String |
| BOOLEAN | BOOLEAN | Boolean |
| DATE | TIMESTAMP | Date |
| DATEONLY | DATE | Date |
| UUID | UUID | String |
| ENUM | TEXT + CHECK constraint | String, validated in the ORM |
| BINARY | BYTEA | Buffer, passed through unchanged |
| JSON | JSONB | Object |
Some types take extra options on the column definition:
const Accounts = await db.defineModel<Account>("accounts", {
id: { type: DataTypes.UUID, primaryKey: true, default: () => crypto.randomUUID() },
code: { type: DataTypes.STRING, length: 12 }, // VARCHAR(12) instead of the default 255
balance: { type: DataTypes.DECIMAL, precision: 12, scale: 2 }, // NUMERIC(12, 2)
status: { type: DataTypes.ENUM, values: ["active", "suspended", "closed"] },
});DECIMALis returned as astringon both backends (PostgreSQL's driver already does this forNUMERIC; MongoDB has no fixed-point type, so IndigoDB stores it as a string too) — a JSNumberwould silently round money-style values. Parse with a decimal library if you need arithmetic.BIGINTis still coerced throughNumber, so values beyondNumber.MAX_SAFE_INTEGER(2^53) lose precision — fine for most IDs/counters, but avoid it for values that can legitimately exceed that range.ENUMrequiresvalues: string[]; anything outside that list is rejected with aValidationErroroncreate()/update()(and the bulk variants) on both backends — PostgreSQL additionally enforces it at the database level with aCHECKconstraint.- Misconfigured columns (
ENUMwithoutvalues,length/precision/scaleused on the wrong type, a non-positivelength) throwConfigurationErroratdefineModel()time.
Architecture
IndigoDB is intentionally small and built around a few classic patterns so new backends and transports are easy to add:
- Adapter —
DatabaseAdapterhas one implementation per backend (PostgresAdapter,MongoAdapter).IndigoDBnever containsif (type === ...)CRUD branches. - Template Method —
BaseModel<T>defines the CRUD contract and centralizes identifier/schema validation and primary-key resolution; each backend model fills in the specifics. - Observer — adapters emit a uniform
ChangeEvent;IndigoDBre-emits it and forwards it to the real-time gateway. - Strategy —
RealtimeGatewayabstracts the transport;WebSocketGatewayis the default, and real-time is fully optional.
adapter.emitChange() ──▶ IndigoDB.emit("change") ──▶ your listener
└────────────▶ gateway.broadcast() ──▶ WebSocket clients- PostgreSQL detects changes with a per-table trigger that calls
pg_notifyon theindigodb_changeschannel; a dedicatedLISTENclient (separate from the queryPool) receives them. - MongoDB detects changes with a
collection.watch()change stream (requires a replica set).
Schema migrations
CREATE TABLE IF NOT EXISTS (run by defineModel) never alters an existing table, so schema changes on a live database need real migrations. IndigoDB ships a small runner plus a CLI:
npx indigodb-migrate create "add users table" # scaffolds migrations/<timestamp>_add_users_table.js
npx indigodb-migrate up # applies every pending migration
npx indigodb-migrate down # reverts the most recently applied one
npx indigodb-migrate status # { applied, pending }The CLI reads indigodb.config.js (or --config <path>) from the working directory:
// indigodb.config.js
module.exports = {
database: { type: "postgresql", host: "localhost", database: "myapp" },
migrationsDir: "./migrations", // optional, defaults to "./migrations"
};A migration file exports up/down functions that receive a MigrationContext — the same raw() escape hatch as db.raw():
// migrations/1700000000000_add_users_table.js
module.exports = {
async up(ctx) {
await ctx.raw("CREATE TABLE users (id SERIAL PRIMARY KEY, email VARCHAR(255) UNIQUE)");
},
async down(ctx) {
await ctx.raw("DROP TABLE users");
},
};Applied migrations are tracked in a history table/collection (default name indigodb_migrations) defined the same way any other model is — no backend-specific bookkeeping. You can also drive it programmatically:
import { MigrationRunner } from "@adinet/indigodb";
const runner = new MigrationRunner(db, { directory: "./migrations" });
await runner.up();Testing
The default suite is fully mocked and needs no database:
npm testOpt-in integration tests run against live databases (PostgreSQL, and MongoDB as a replica set). Copy .env.example to .env, fill in your connection details, then:
npm run test:integrationCI runs the unit suite on Node 18/20/22 and the full integration suite against real Postgres and Mongo (single-node replica set) containers on every PR.
Development
npm run lint # ESLint + Prettier check
npm run format # Prettier write
npm run docs # Generate API docs (typedoc) into docs/Migration from v1
v2 is a breaking change. Key differences:
| v1 | v2 |
| --- | --- |
| import { initialize, defineModel } from "indigodb" (hidden singleton) | import { IndigoDB } from "@adinet/indigodb"; const db = new IndigoDB(config) |
| initialize({ databaseType, host, ... }) | new IndigoDB({ database: { type, host, ... } }) + await db.connect() |
| defineModel() was synchronous and returned any | await db.defineModel<T>() returns a typed Model<T> |
| WebSocket server always started | realtime is opt-in |
| Postgres records required a hardcoded _id column | primary key comes from the primaryKey: true column in your schema |
| No way to shut down (tests hung) | await db.close() releases everything |
Roadmap
See ROADMAP.md for the full gap analysis and release plan: schema features + hooks (v2.2), transactions (v2.3), migrations (v2.4), advanced real-time (v2.5), and relations (v3.0).
Contributing
- Fork the repository.
- Create a branch (
feature/my-feature). - Commit your changes.
- Push and open a pull request.
License
MIT — see LICENSE.
