@desolint/db-factories
v0.0.2
Published
Generic, injectable DB CRUD factories for Mongo (Mongoose) and Prisma. No root export — import only what you need: @desolint/db-factories/config/mongo, /config/prisma, /schema/mongo, /schema/prisma, /services/mongo, /services/prisma, /utils/mongo, /utils/
Downloads
344
Maintainers
Readme
@desolint/db-factories
Generic, injectable DB CRUD factories for Mongo (Mongoose) and Prisma.
No root export. import ... from '@desolint/db-factories' resolves to
nothing — every subpath below is adapter-specific, so the import path itself
is the adapter choice. There's no runtime dispatch, no type field to pass,
no risk of a mongo call silently hitting the prisma branch: you import
/mongo or /prisma, and that's the only adapter that code can ever talk
to.
@desolint/db-factories/config/mongo @desolint/db-factories/config/prisma
@desolint/db-factories/schema/mongo @desolint/db-factories/schema/prisma
@desolint/db-factories/services/mongo @desolint/db-factories/services/prisma
@desolint/db-factories/utils/mongo @desolint/db-factories/utils/prisma
@desolint/db-factories/utils/config/mongo @desolint/db-factories/utils/config/prisma
@desolint/db-factories/testing| Subpath | What it's for |
| ------------------------------ | ------------------------------------------------------------------------------------------- |
| /config/{mongo,prisma} | Open the database connection. Nothing else. |
| /schema/{mongo,prisma} | Define/reference a model. |
| /services/{mongo,prisma} | The actual CRUD calls — create, find, updateOne, etc. |
| /utils/{mongo,prisma} | Standalone helpers you call directly (transactions, id validation, field merging). |
| /utils/config/{mongo,prisma} | Tune how the CRUD factories behave (query scoping, pagination defaults, soft-delete field). |
| /testing | Spin up a real, disposable Mongo for tests. Mongo only. |
Requirements
- Node.js 22 or newer (declared in
engines) - npm 7 or newer
Install
# Mongo consumers
npm install @desolint/db-factories mongoose
# Prisma consumers
npm install @desolint/db-factoriesUnlike most packages here, you must name your adapter explicitly. Both peers are
declared optional in peerDependenciesMeta, and npm does not auto-install optional
peers — that is deliberate, because a Prisma-only consumer should never be made to pull
in Mongoose, and vice versa.
If you use the /testing helpers, add the in-memory Mongo server as a dev dependency:
npm install --save-dev mongodb-memory-serverWhy mongoose is a peer dependency, not a regular one
mongoose holds a global model registry and connection state. If this package
bundled its own copy, models you register in your application would live on a
different instance than the one these factories query — so lookups would fail, or
silently run against a connection you never opened.
Declaring it as a peer means npm reuses the copy your application already has
instead of nesting a second one. You keep control of the version; this package just
states the range it works with (mongoose@^8).
Prisma's generated client (@prisma/client) is entirely consumer-owned — this library
never imports it itself, so it is not a peer at all.
Quick start
Mongo
// config/db.js
import {initializeConnection} from '@desolint/db-factories/config/mongo';
initializeConnection({
connectionString: process.env.DATABASE_URL,
onConnection: () => console.log('Connected to Database'),
onError: (error) => console.error('Error connecting Database', {error}),
});// models/Users.js
import {Schema, model} from '@desolint/db-factories/schema/mongo';
const userSchema = new Schema({
email: {type: String, required: true, unique: true},
password: {type: String, required: true},
});
export default model('User', userSchema);// controllers/users.js
import * as DbFactory from '@desolint/db-factories/services/mongo';
import UsersModel from '../models/Users.js';
const {
doc: user,
success,
error,
} = await DbFactory.create({
model: UsersModel,
data: req.body,
});Prisma
// config/db.js
import {PrismaPg} from '@prisma/adapter-pg';
import {PrismaClient} from '@prisma/client';
import {initializeConnection} from '@desolint/db-factories/config/prisma';
const prismaClient = new PrismaClient({
adapter: new PrismaPg({connectionString: process.env.DATABASE_URL}),
});
initializeConnection({
client: prismaClient,
onConnection: () => console.log('Connected to Database'),
onError: (error) => console.error('Error connecting Database', {error}),
});
export {prismaClient};// controllers/users.js
import {create, findOne} from '@desolint/db-factories/services/prisma';
import {prismaClient} from '../config/db.js';
const {
doc: user,
success,
error,
} = await create({
model: prismaClient.user,
data: req.body,
});/config/mongo
Database connection setup. Nothing else lives here — CRUD-behavior tuning is
/utils/config/mongo, not this.
initializeConnection({ connectionString, options?, onConnection?, onError? })
Connects the shared mongoose singleton and returns it. Fire-and-forget —
onConnection/onError are listeners on mongoose.connection, not a
promise this function returns, so awaiting it doesn't mean the connection
is open yet.
| Param | Type | Required | Notes |
| ------------------ | --------------------------- | -------- | ----------------------------------------------- |
| connectionString | string | yes | Mongo connection URI. |
| options | ConnectOptions (mongoose) | no | Passed straight to mongoose.connect(). |
| onConnection | () => void | no | Fires once, on the connection's 'open' event. |
| onError | (error: Error) => void | no | Fires on the connection's 'error' event. |
Returns: the mongoose singleton itself (Mongoose), so you can reach
mongoose.connection, mongoose.startSession(), etc. off the return value
if you don't want a separate /utils/mongo import.
/config/prisma
initializeConnection({ client, onConnection?, onError? })
Wires up a consumer-owned, already-instantiated Prisma client to the
same connect/onConnection/onError lifecycle as the mongo adapter. Prisma's
generated client is built from your own schema.prisma, so unlike mongo
this can't construct a client itself — you build it, this just connects it.
| Param | Type | Required | Notes |
| -------------- | ------------------------ | -------- | ----------------------------------------- |
| client | PrismaClientLike | yes | Your instantiated PrismaClient. |
| onConnection | () => void | no | Fires after client.$connect() resolves. |
| onError | (error: Error) => void | no | Fires if client.$connect() rejects. |
Returns: the same client you passed in.
/schema/mongo
Everything needed to define/reference a mongo model. The raw mongoose
object itself is deliberately not exported here — that's /config/mongo
and /utils/mongo's job (connecting, sessions), not schema definition's.
| Export | Kind | What it is |
| ------------------ | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Schema | value (class) | Mongoose's schema builder — new Schema({...}). |
| Types | value | Mongoose's Types namespace (Types.ObjectId, etc.). |
| model | value (function) | Registers a schema against the shared mongoose singleton and returns the model — model('User', userSchema). |
| ModelType | type | The type of what model() returns — for annotating, e.g. function foo(m: ModelType<IUser>). Not named Model — deliberately, so it doesn't sit next to the model function differing only by capitalization. |
| IndexOptions | type | Mongoose's index-options type, for schema.index(fields, options). |
| HydratedDocument | type | The type of a document mongoose hands back from a query (has instance methods, etc.), e.g. HydratedDocument<IUser>. |
import {Schema, model} from '@desolint/db-factories/schema/mongo';
import type {
ModelType,
HydratedDocument,
} from '@desolint/db-factories/schema/mongo';
interface IUser {
email: string;
}
const userSchema = new Schema<IUser>({email: {type: String, required: true}});
const UsersModel: ModelType<IUser> = model('User', userSchema);
type UserDoc = HydratedDocument<IUser>;/schema/prisma
Prisma has no schema-authoring API this library could export —
prisma/schema.prisma is the only place a model is actually defined, and
it's yours, compiled by the Prisma CLI into your own generated client. What
this subpath gives you instead is structural typing for that generated
client, so this library's own functions can type-check against it without
ever importing @prisma/client itself.
| Export | Kind | What it is |
| ------------------------- | ---- | ---------------------------------------------------------------------------------------------------------- |
| PrismaClientLike | type | Structural stand-in for your PrismaClient — just $connect/$disconnect/$transaction. |
| PrismaModelDelegate<T> | type | Structural stand-in for one table delegate off your client, e.g. prismaClient.user. |
| PrismaTransactionClient | type | The type of the client a $transaction callback receives (e.g. tx in client.$transaction(tx => ...)). |
import type { PrismaModelDelegate } from '@desolint/db-factories/schema/prisma';
function findAll(model: PrismaModelDelegate<any>) { ... }/services/mongo
Thin re-exports of MongoFactories — every function here forwards straight
to the mongo implementation, no dispatch involved. model is always a
mongoose Model.
| Function | Signature | Returns | What it does |
| ------------------- | --------------------------------------------- | -------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| create | { model, data, session?, options? } | ServiceResult<T \| T[], 'doc'> | Creates one document (data an object) or many (data an array). |
| find | { model, query?, options?, session? } | ServiceResult<unknown[], 'docs'> & { pagination? } | Finds all matching documents. options.pagination = { include, includeCount } to get pagination metadata back. |
| findOne | { model, query?, options?, session? } | ServiceResult<unknown, 'doc'> | Finds a single matching document. |
| findById | { model, _id, session?, options? } | same as findOne | findOne scoped to { _id }. |
| updateOne | { model, query?, data, session?, options? } | MutationResult | Updates one matching document, doesn't return it (isDocumentUpdated). |
| updateMany | { model, query?, data, session?, options? } | MutationResult | Updates every matching document (areDocumentsUpdated). |
| findOneAndUpdate | { model, query?, data, session?, options? } | ServiceResult<unknown, 'doc'> & { isDocumentUpdated? } | Updates one matching document and returns it. |
| findByIdAndUpdate | { model, _id, data, session?, options? } | same as findOneAndUpdate | findOneAndUpdate scoped to { _id }. |
| findAllAndUpdate | { model, query?, data, session?, options? } | ServiceResult<unknown[], 'docs'> | Updates every matching document and returns them all. |
| deleteOne | { model, query?, session?, options? } | MutationResult | Soft-deletes one matching document by default (sets the configured softDeleteField); pass options.hardDelete: true to actually remove it. |
| deleteMany | { model, query?, session?, options? } | MutationResult | Same as deleteOne, for every matching document. |
| findOneAndDelete | { model, query?, session?, options? } | ServiceResult<unknown, 'doc'> & { isDocumentDeleted? } | Deletes one matching document and returns it. |
| findByIdAndDelete | { model, _id, session?, options? } | same as findOneAndDelete | Scoped to { _id }. |
| findAllAndDelete | { model, query?, session?, options? } | ServiceResult<unknown[], 'docs'> | Deletes every matching document and returns them all. |
| countDocuments | { model, query?, options?, session? } | { success, error?, count? } | Counts matching documents (soft-deleted ones excluded by default). |
| aggregate | { model, pipeline, options? } | ServiceResult<unknown[], 'docs'> | Runs a raw aggregation pipeline. Mongo only — see below. |
query is a mongoose FilterQuery. options is IMongoOptions:
interface IMongoOptions {
includeDeleted?: boolean; // include soft-deleted docs
populateFields?: string; // mongoose .populate() path(s)
fieldsInclusion?: {
// projection
include?: string[];
exclude?: string[];
includeSpecificFields?: string[];
};
pagination?: {include?: boolean; includeCount?: boolean};
hardDelete?: boolean; // deleteOne/deleteMany: actually remove, don't soft-delete
}Why aggregate is only here, not in /services/prisma: Mongo's
aggregation pipeline has no Prisma equivalent this library wraps. Rather
than exporting it from both and throwing at runtime for a Prisma consumer,
it simply doesn't exist to import on the Prisma side — you find out at
import time, not at call time.
Also exported: IFieldsInclusion, IMongoOptions, MutationResult,
ServiceResult, ClientSession, FilterQuery (types, for annotating your
own service functions' params/returns).
/services/prisma
Same 15 functions as /services/mongo, minus aggregate, forwarding
straight to PrismaFactories. model is always a Prisma model delegate
(e.g. prismaClient.user).
| Function | Signature | Notes vs. the mongo version |
| ------------------------------------------------------------------------------------------ | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| create | { model, data } | No session/options — Prisma create doesn't need them. |
| find | { model, query?, options?, session? } | session is accepted but ignored (Prisma has no session concept — pass a transaction client as model instead, see /utils/prisma). |
| findOne | { model, query?, options?, session? } | Uses Prisma's findFirst under the hood. |
| findById | { model, _id, options? } | findOne scoped to { _id }. |
| updateOne / updateMany | { model, query?, data, options? } | Same shape as mongo. |
| findOneAndUpdate / findByIdAndUpdate / findAllAndUpdate | same param shape as mongo, no session | Prisma has no atomic "find and update in one call" — these find the match first, then update by its id. Not atomic; wrap in /utils/prisma's wrapWithTransaction if you need atomicity. |
| deleteOne / deleteMany / findOneAndDelete / findByIdAndDelete / findAllAndDelete | same shape as mongo | Soft-delete by default via the configured softDeleteField, same as mongo. |
| countDocuments | { model, query?, options? } | — |
options is IPrismaOptions:
interface IPrismaOptions {
includeDeleted?: boolean;
pagination?: {include?: boolean; includeCount?: boolean};
hardDelete?: boolean;
select?: Record<string, boolean>; // passthrough straight to Prisma's own `select`
}Also exported: IPrismaOptions, MutationResult, ServiceResult (types).
/utils/mongo
Standalone mongo helpers you call directly in your own code — not part of
connecting (/config/mongo) or defining a schema (/schema/mongo).
| Export | What it does |
| --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| startSession() | Mongoose's own startSession — for a consumer that wants to manage a transaction session by hand instead of using wrapWithTransaction. |
| wrapWithTransaction()({ fn }) | Curried helper: start a session, run fn(session), commit on success / abort on throw, always end the session. Pass the returned session into /services/mongo calls' session param to run them inside the transaction. |
| isObjectIdOrHexString(value) | Validates a value looks like a Mongo ObjectId (24-hex-char string or an actual ObjectId) — for your own validators/schemas. |
| mergeAndDeduplicateFields({ fields?, scopedTo? }) | Merges two field-name arrays and dedupes — building block for compound-unique-index field lists (e.g. a soft-delete plugin combining a unique field with tenant-scoping fields). |
import {wrapWithTransaction} from '@desolint/db-factories/utils/mongo';
import * as DbFactory from '@desolint/db-factories/services/mongo';
await wrapWithTransaction()({
fn: async (session) => {
await DbFactory.create({model: UsersModel, data: userData, session});
await DbFactory.create({model: OrganizationsModel, data: orgData, session});
},
});/utils/prisma
| Export | What it does |
| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| wrapWithTransaction({ client })({ fn }) | Curried helper matching mongo's shape, but Prisma's transaction model is a whole different client passed into the callback (tx), not a session object passed alongside the same model reference — use tx.user etc. inside fn, not client.user. |
import {wrapWithTransaction} from '@desolint/db-factories/utils/prisma';
import * as DbFactory from '@desolint/db-factories/services/prisma';
await wrapWithTransaction({client: prismaClient})({
fn: async (tx) => {
await DbFactory.create({model: tx.user, data: userData});
await DbFactory.create({model: tx.organization, data: orgData});
},
});/utils/config/mongo
Global, in-memory tuning for how /services/mongo's CRUD functions
behave — not connection setup (that's /config/mongo). Call once at
app startup; every later /services/mongo call picks it up automatically,
since MongoFactories reads the current config internally on every call.
| Export | What it does |
| --------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| configureMongoFactories({ queryTransformer?, paginationProvider?, softDeleteField? }) | Sets any subset of the three. Unset fields keep whatever they already were. |
| getMongoFactoriesConfig() | Reads the current config. |
| resetMongoFactoriesConfig() | Restores the built-in defaults. Mainly for test isolation (e.g. a beforeEach), so one test's config doesn't leak into the next. |
Defaults: queryTransformer is a no-op (({query}) => query),
paginationProvider returns { page: 1, limit: 20, sort: { createdAt: -1 } },
softDeleteField is 'deletedAt'.
import {configureMongoFactories} from '@desolint/db-factories/utils/config/mongo';
// Scope every query app-wide to the current tenant, without adding
// { organizationId } to every single /services/mongo call by hand.
configureMongoFactories({
queryTransformer: ({query}) => ({
...query,
organizationId: getCurrentTenantId(),
}),
softDeleteField: 'removedAt',
});/utils/config/prisma
Same three functions, Prisma-shaped config, plus one extra field:
| Export | What it does |
| -------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| configurePrismaFactories({ queryTransformer?, paginationProvider?, softDeleteField?, idField? }) | idField — Prisma has no fixed primary-key field name the way Mongo always has _id; defaults to 'id'. |
| getPrismaFactoriesConfig() | — |
| resetPrismaFactoriesConfig() | — |
If you don't need any of this (no soft-delete field rename, no app-wide query scoping, default pagination shape is fine) — you'll never call these, and that's fine. It's dependency injection for the CRUD factories' default behavior, not something every consumer needs to touch.
/testing
Mongo only. Spins up a real, disposable MongoDB (via
mongodb-memory-server) and connects the same shared mongoose singleton
/config/mongo uses — so tests exercise real Mongo behavior instead of a
separate mocked path. mongodb-memory-server is an optional peer
dependency; it's only required if you actually import this subpath.
| Export | What it does |
| ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| connectMockMongoose() | Starts a disposable in-memory MongoDB, connects mongoose to it, waits for the connection to actually open. Returns { mongoServer, mongooseInstance }. |
| disconnectMockMongoose({ mongoServer, mongooseInstance }) | Drops the database, disconnects mongoose, stops the disposable server. |
import {
connectMockMongoose,
disconnectMockMongoose,
} from '@desolint/db-factories/testing';
let mongoServer, mongooseInstance;
beforeAll(async () => {
({mongoServer, mongooseInstance} = await connectMockMongoose());
});
afterAll(async () => {
await disconnectMockMongoose({mongoServer, mongooseInstance});
});Return-shape reference
Every /services/* function returns one of these two shapes:
// Reads (create, find, findOne, findOneAndUpdate, findOneAndDelete, aggregate, ...)
type ServiceResult<T, K extends string> = {
success: boolean;
error?: unknown;
} & Partial<Record<K, T | null>>;
// e.g. { success: true, doc: {...} } or { success: false, error: ... }
// Writes with no document returned (updateOne, updateMany, deleteOne, deleteMany)
interface MutationResult {
success: boolean;
error?: unknown;
responseObj?: unknown;
isDocumentUpdated?: boolean;
areDocumentsUpdated?: boolean;
isDocumentDeleted?: boolean;
areDocumentsDeleted?: boolean;
deleteType?: 'hardDelete' | 'softDelete';
}Every function returns { success: false, error } on failure instead of
throwing — check success before trusting the rest of the shape.
Soft delete
deleteOne/deleteMany/findOneAndDelete/findByIdAndDelete/findAllAndDelete
soft-delete by default: they set the configured softDeleteField (default
'deletedAt') instead of removing the row/document. Every read
(find/findOne/countDocuments/...) implicitly excludes soft-deleted
records unless you pass options.includeDeleted: true. Pass
options.hardDelete: true to a delete call to actually remove the record.
Change the field name via configureMongoFactories/configurePrismaFactories
(/utils/config/{mongo,prisma}).
Development
npm install # install dependencies
npm run build # type-check, then bundle each subpath into dist/
npm test # jest
npm run lint # eslintscripts/build.mjs bundles each subpath into one self-contained JS file plus a
.d.ts, then deletes everything else from dist/ — internal modules
(src/mongodb/*, src/prisma/*, src/shared/*) never ship, so there is nothing for an editor or a
moduleResolution: "node" consumer to resolve beyond the eleven public subpaths
documented above.
License
MIT © Desolint — see LICENSE.
Free to use, modify and redistribute, commercially or otherwise. Provided "as is", without warranty or liability of any kind.
