Skip to content

MartinDrost/fundering

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

205 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

A pragmatic service framework for structured and reactive data handling using Mongoose.

Fun · deer · ing — the Dutch word for foundation

NPM Version NPM Downloads Minified bundle size Minified + GZipped bundle size

Table of contents

Description

Fundering is a thin service layer on top of Mongoose. It gives you a single place to keep the rules that should apply to every read and write of a model — authorization, censoring, validation, type casting, side effects — and exposes a small, composable set of CRUD methods that respect those rules by default.

The library does not replace Mongoose; it builds on top of the schemas and models you already have. Where Mongoose ships primitives (queries, middleware, virtuals), fundering ships a service abstraction with opinionated defaults:

  • CrudService<T> — an abstract class with create / find / count / upsert / merge / replace / delete methods that compile to a single MongoDB aggregation per call.
  • Reactive hooksonAuthorization, onCensor, preSave, postSave, preDelete, postDelete, postCount — declared as interface methods on your service class.
  • Cross-service awareness — services register themselves in a shared map, so authorization and censoring also apply when a related model is reached through a populated virtual.
  • Dynamic population — populate by string or object, with deep nesting and per-level matching, sorting and limits.
  • Type-cast conditions — query payloads coming from HTTP boundaries are cast against your Mongoose schema, so string _id values become ObjectIds, "true" becomes true, and so on.

Installation

npm install fundering mongoose

Fundering declares mongoose (>= 8.9.5) as a peer dependency, so the version your application uses is the version fundering will use.

Getting started

Extend CrudService and pass your Mongoose model to the constructor. The service registers itself for cross-service authorization and casting and immediately exposes the full CRUD surface.

import { CrudService } from 'fundering';
import { model } from 'mongoose';
import { IUser } from './user.interface';
import { userSchema } from './user.schema';

export class UsersService extends CrudService<IUser> {
  constructor() {
    super(model('User', userSchema));
  }
}

const users = new UsersService();
const all = await users.find({});
const one = await users.findById('507f1f77bcf86cd799439011');

Core concepts

CrudService<ModelType>

The abstract class your services extend. Construction registers the service against the model's name in a static serviceMap and installs document hooks (save, deleteOne) on the model's prototype. Each model is wired exactly once, so creating multiple CrudService instances against the same model is safe.

IQueryOptions<ModelType>

The single options object passed to every read and write method. Its shape is:

Field Type Purpose
match Conditions<ModelType> Extra $match conditions appended via $and — can only further restrict results.
sort string[] Sort fields. Prefix with - for descending (e.g. ['-createdAt', 'name']).
skip number Documents to skip.
limit number Maximum documents to return.
select string[] Field projection, supports dot notation.
populate (string | IPopulateOptions)[] Virtuals to populate; see Population.
addFields Record<string, any> Extra $addFields stage available to subsequent stages.
distinct string | string[] SQL-style distinct on one or more fields.
random boolean Use $sample instead of $sort. Overrules sort.
pipelines Record<string, any>[] Trusted-only. Extra aggregation stages appended after the main pipeline.
disableAuthorization boolean Trusted-only. Skips the onAuthorization hook for the current call.
session ClientSession Mongoose transaction session.
maxTimeMS number Per-query timeout in milliseconds (default 10000).

The two fields marked trusted-only are gated by the TRUSTED_QUERY_OPTIONS symbol; spreading raw HTTP input into options can never enable them. See Security: trusted query options.

The [key: string]: any index signature lets you carry context (e.g. an authenticated user) on the same options object that hooks receive.

Service map

Each CrudService adds itself to CrudService.serviceMap keyed by the Mongoose model name. Fundering uses this map to walk schema virtuals: when you query, populate or sort by a referenced model, the related service's onAuthorization and onCensor hooks are applied automatically.

Querying

The find methods compile the conditions and IQueryOptions into a single aggregation pipeline. The pipeline is, in order:

  1. $match for onAuthorization ($expr, unless disableAuthorization is honored).
  2. $set + $unset for onCensor (static and conditional unsets).
  3. Recursive $lookup stages for any virtuals referenced in match, sort or addFields.
  4. The user's $addFields.
  5. $match for the merged conditions (conditions + options.match).
  6. $sort or $sample (random).
  7. $skip and $limit.
  8. $project to remove temporary virtual lookups.
  9. $project for select.
  10. Recursive $lookup stages for populate.
  11. Any trusted user-supplied pipelines.

Methods

service.find(conditions, options?);          // Document<ModelType>[]
service.findOne(conditions, options?);       // Document<ModelType> | null
service.findById(id, options?);              // Document<ModelType> | null
service.findByIds(ids, options?);            // Document<ModelType>[]
service.count(conditions, options?);         // number
service.query<R>(conditions, options?);      // R[] -- raw aggregation cursors, no hydration

find* results are hydrated as Mongoose documents, with related virtuals (looked up through populate) hydrated against their owning service. query returns the raw aggregation output and is the right tool when you only need projected scalars.

Querying through references

Because populated relations participate in the same pipeline, you can match and sort across them:

class UsersService extends CrudService<IUser> {
  constructor() { super(model('User', userSchema)); }

  getPopulatedGroups() {
    // populate the user's group virtual
    return this.find({}, { populate: ['group'] });
  }

  getByGroupName(name: string) {
    // match on a populated relation's field, sorted by the relation's createdAt
    return this.find(
      { 'group.name': name },
      { sort: ['-group.createdAt'] }
    );
  }

  getFiveUniqueNames(options?: IQueryOptions<IUser>) {
    // safe to spread caller-supplied options -- match/addFields/populate
    // can only further restrict, never bypass auth
    return this.find({}, {
      ...options,
      distinct: 'firstName',
      random: true,
      limit: 5,
    });
  }
}

count

count runs the same pipeline as find (so authorization and censoring still apply) but appends $count and clamps the result to a maximum scan of 10,000 documents. The postCount hook fires with the resulting number.

Creating, updating and deleting

Method Behaviour
create(payload, options?) Inserts one document. Triggers preSave / postSave.
createMany(payloads, options?) Inserts many in a transaction. Rolls back on failure.
upsertModel(payload, options?) Updates by _id / id, inserts otherwise.
upsertModels(payloads, options?) Bulk upsert in a transaction.
upsert(conditions, payload, options?) Replace by conditions; create if no match.
replaceModel(payload, options?) Replace by _id / id. Throws if no match.
replaceModels(payloads, options?) Bulk replace in a transaction.
replace(conditions, payload, options?, mergeCallback?) Replace all matches. Optional mergeCallback to customise per-document merging.
mergeModel(payload, options?) Deep-merge by _id / id. Throws if no match.
mergeModels(payloads, options?) Bulk merge in a transaction.
merge(conditions, payload, options?) Deep-merge all matches.
deleteById(id, options?) Delete by id. Triggers preDelete / postDelete.
delete(conditions, options?) Delete all matches sequentially so hooks run per-document.

Bulk methods that mutate (createMany, upsertModels, replaceModels, mergeModels) start a transaction when one isn't already provided in options.session. Any thrown error aborts the transaction and bubbles up. This means MongoDB must be running as a replica set (or via Atlas) for bulk methods to work.

deepMerge skips __proto__, constructor and prototype keys to prevent prototype pollution from untrusted payloads.

Per-call context

The options object passed in is dereferenced (cloned at the top level) and stored on the document's $locals.options before save / delete. Hook implementations therefore receive the same options reference the caller passed, including any custom fields you added (like { user }). This is the canonical way to thread request context through to authorization and middleware.

Hooks

Implement the matching interface on your service class. The presence of the method is enough — fundering picks it up via getHook / callHook. Hooks may be sync or async.

IOnAuthorization

class UsersService extends CrudService<IUser> implements IOnAuthorization<IUser> {
  async onAuthorization(options: IQueryOptions<IUser>): Promise<Expression> {
    // restrict every find to the caller's own document
    return { $eq: ['$_id', options.user?._id] };
  }
}

The expression is appended as { $match: { $expr: ... } } to every read pipeline, including reads triggered by populating a virtual that points to this service. Returning {} (or omitting the implementation) means "no extra restrictions". The IQueryOptions argument lets you build context-sensitive rules without globals.

IOnCensor

Returns the field paths to remove from results, evaluated server-side via $unset. Censored fields are also removed from populated relations.

class UsersService extends CrudService<IUser> implements IOnCensor<IUser> {
  onCensor(options: IQueryOptions<IUser>) {
    const isAdmin = options.user?.role === 'admin';
    return [
      'passwordHash',                              // always censored
      ['email', { $ne: ['$_id', options.user?._id] }], // censored unless self
      ...(isAdmin ? [] : ['internalNotes']),
    ];
  }
}

The $unset runs before addFields and match, so the user can't materialise censored values back via either field.

IPreSave / IPostSave

class UsersService extends CrudService<IUser> implements IPreSave<IUser> {
  async preSave(payload: Document<IUser>, options: IQueryOptions<IUser>) {
    if (payload.isModified('password')) {
      payload.password = await encrypt(payload.password);
    }
  }
}

postSave additionally receives a prevState parameter — the document as it was before the save — for diffing, audit logs and the like. Fundering only refetches the previous state when a postSave hook is actually defined, so there's no cost when you don't use it.

IPreDelete / IPostDelete

class UsersService extends CrudService<IUser> implements IPreDelete<IUser> {
  async preDelete(existing: Document<IUser>, options: IQueryOptions<IUser>) {
    if (existing.isProtected) {
      throw new Error('Cannot delete protected user');
    }
  }
}

IPostCount

Fires after each count call with the resulting number; useful for analytics or cache priming.

Population

Populate using either string paths or fully specified objects:

// shorthand: populate two virtuals at the root level
service.find({}, { populate: ['group', 'avatar'] });

// deep population via dot notation
service.find({}, { populate: ['group.owner'] });

// object form: per-level match, sort, limit, select, projection, recursion
service.find({}, {
  populate: [{
    path: 'comments',
    sort: ['-createdAt'],
    limit: 10,
    populate: [{ path: 'author', select: ['name'] }],
  }],
});

Population only resolves virtuals declared on the schema, and the related service's onAuthorization + onCensor are applied at every level.

Type casting

Conditions passed to find, findOne, count, etc. are cast against the Mongoose schema. This is what makes it safe to forward query strings from an HTTP layer:

  • stringObjectId for objectid schema fields where the value is a valid hex.
  • stringnumber, boolean, Date for the matching primitive types.
  • Operator-aware: $exists casts to boolean, $size to number, $in/$nin/$all cast each element.
  • Recurses through $and, $or, $not, $nor.

A request like { _id: { $in: ["507f...", "507e..."] } } arriving as JSON will be cast to real ObjectId instances before the aggregation runs.

Security: trusted query options

Two fields on IQueryOptions can change the security posture of a query:

  • disableAuthorization — skip the onAuthorization hook for this call.
  • pipelines — append arbitrary aggregation stages after the authorization $match, which can therefore override it.

Both fields are gated by the TRUSTED_QUERY_OPTIONS symbol. The library only honors them when the options object carries this symbol as a property. The symbol cannot be expressed in JSON or URL-encoded form, so spreading req.body or req.query directly into options can never enable either field — the values are silently ignored.

import { TRUSTED_QUERY_OPTIONS } from 'fundering';

// internal admin tooling: explicitly opt in
service.find(conditions, {
  [TRUSTED_QUERY_OPTIONS]: true,
  disableAuthorization: true,
});

service.find(conditions, {
  [TRUSTED_QUERY_OPTIONS]: true,
  pipelines: [{ $group: { _id: '$tenantId', total: { $sum: 1 } } }],
});

Library-internal call sites that legitimately need to bypass the gate (such as the post-create refetch in create, and the count pipeline in count) attach the marker to the options they construct — never to the caller-supplied options.

The other "extension" fields — match, addFields, populate — are designed to be safe to spread from untrusted input:

  • match is appended via $and, so it can only further restrict results.
  • addFields runs after the censor $unset, so it cannot resurrect censored fields.
  • populate only resolves schema-declared virtuals and the populated service's onAuthorization and onCensor still apply.

Using fundering with NestJS

Pair fundering with nest-utilities for a CRUD-style controller layer that maps HTTP query strings to IQueryOptions and exposes consistent endpoints with minimal boilerplate.

Documentation and wiki

Deeper guides live in the project wiki:

Issues and feature requests: https://github.com/MartinDrost/fundering/issues.

License

ISC.


Made by Martin Drost — Buy me a ☕

About

A ground layer for your service architecture which helps you keep your application structured by offering helper methods and reactive hooks.

Resources

Stars

Watchers

Forks

Contributors