Services and hooks

Every resource has a service: an instance of ResourceService that does all reads and writes. CRUD routes, custom routes, blocks and jobs go through it, so logic you put here applies everywhere.

Create app/resources/<name>/<name>.service.js (or pass --service to calm make resource) and override only what you need: hooks for small tweaks, whole methods for full control.

JavaScript
// app/resources/post/post.service.js (from the api template)
import { ResourceService, hasRole } from '#calm';

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,
    };
  }

  async beforeUpdate(data, existing) {
    if (data.status === 'published' && existing.status !== 'published') data.publishedAt = new Date();
    return data;
  }

  /** Used by POST /api/posts/:id/publish (post.routes.js). */
  async publish(id, ctx) {
    return this.update(id, { status: 'published' }, ctx);
  }

  async uniqueSlug(title) {
    const base = title.toLowerCase().normalize('NFKD').replace(/[^\w\s-]/g, '').trim().replace(/[\s_-]+/g, '-').slice(0, 80) || 'post';
    let slug = base;
    for (let i = 2; await this.model.exists({ slug }); i++) slug = `${base}-${i}`;
    return slug;
  }
}

The class must export default and extend ResourceService, otherwise the app refuses to boot with a clear message.

Methods#

All methods take the request context ctx as their last argument. Pass it along: it carries the user and the owner scope.

MethodReturns
list({ filter, sort, skip, limit, populate }, ctx){ items, total }
get(id, { populate }, ctx)the record, or throws notFound()
find(filter, ctx)an array of records
findOne(filter, ctx)a record or null
create(data, ctx)the created record
update(id, data, ctx)the updated record (throws notFound() if it is not visible)
remove(id, ctx)the deleted record (throws notFound() if it is not visible)

Records are plain objects (Mongoose lean()) with _id. Every service method (list, get, find, findOne, create, update, remove) and every hook works with the whole record, hidden() and writeOnly() fields included. Password hashes are never included. Return records from a resource route and they are serialized for you (see Custom routes).

Services trust their input: they do not run field validation or policies. The CRUD routes validate the body before calling create and update. When you call a service from your own route, validate with the route's input option first.

Useful properties: this.model (the Mongoose model), this.def (the resource definition), this.app, this.log, this.events, this.name.

Hooks#

HookCalledReturn
scope(ctx)before every querya Mongo filter ({} for no restriction)
beforeCreate(data, ctx)after validation, before insert. The owner field is already set.the data to insert
afterCreate(doc, ctx)after insertnothing
beforeUpdate(data, existing, ctx)before the update, with the current recordthe changes to apply
afterUpdate(doc, previous, ctx)after the updatenothing
beforeDelete(doc, ctx)before delete. Throw to prevent it.nothing
afterDelete(doc, ctx)after deletenothing

before* hooks are the place to set hidden() and readOnly() fields, which clients cannot send:

JavaScript
export default class ProductService extends ResourceService {
  async beforeCreate(data) {
    return { ...data, cost: data.price / 2 };           // cost is .hidden()
  }

  async beforeDelete(doc) {
    const orders = await this.service('order').find({ product: doc._id });
    if (orders.length) throw conflict('This product has orders and cannot be deleted');
  }
}

(conflict is imported from #calm, like every error helper.)

scope()#

scope(ctx) returns a filter that is added to every query the service makes: lists, reads, updates, deletes, and the record lookup that authorizes /:id routes. A record outside the scope does not exist for that user: it is a 404.

JavaScript
// Multi-tenancy: everyone only ever sees their organization's data.
scope({ user } = {}) {
  return user ? { org: user.org } : { _id: null }; // signed out: nothing
}

scope() also applies to calls without a user, which are treated like anonymous visitors. For trusted internal work (jobs, scripts, migrations) pass { system: true } as the context: scope() is skipped and the call sees every record.

JavaScript
await ctx.app.service('post').update(id, { status: 'archived' }, { system: true });

Service methods return whole records, hidden fields included (password hashes excepted): they are server-side data. Routes serialize them with the resource rules before anything is sent.

filterFor()#

this.filterFor(ctx, extra) builds the full filter for a request: your scope(), the owner filter when the policy is 'owner', and extra. Use it whenever you query this.model yourself, so custom queries obey the same rules:

JavaScript
async countPublished(ctx) {
  return this.model.countDocuments(this.filterFor(ctx, { status: 'published' }));
}

Mongoose casts ids in find and countDocuments, but not in aggregate. In an aggregation pipeline, convert ids yourself. Mongoose's strictQuery is on, so conditions on fields the resource does not declare are dropped from filters rather than matching nothing.

Calling other services#

JavaScript
// inside a service
const author = await this.service('user').get(post.author, {}, ctx);

// inside a route handler
const categories = await ctx.services.category.find({}, ctx);
const sub = ctx.services['product-category'];   // or ctx.services.productCategory

Go through services rather than importing another resource's model: you keep its scope, hooks and events.

Overriding whole methods#

When hooks are not enough, override the method and call super:

JavaScript
async list(query, ctx) {
  const result = await super.list(query, ctx);
  this.log.debug('Listed products', { total: result.total });
  return result;
}

Events#

After each write, the service emits an event on the app's in-process bus:

EventPayload
<resource>.created{ doc, resource, user }
<resource>.updated{ doc, previous, resource, user }
<resource>.deleted{ doc, resource, user }
auth.registered, auth.login, auth.password_changed, auth.password_reset_requested{ user }
auth.password_reset{ userId }
security.login_failed, security.account_locked, security.forbidden...Depends on the event: failed sign-ins, lockouts, bad tokens, rejected writes, 403s, rate limits (see Security events)

user is the signed-in user who made the change, or null. Blocks add their own (job.completed, job.failed, <resource>.restored).

Listeners run after the write, asynchronously. They never break a request: an exception is logged. Patterns are an exact name, 'post.*' or '*'. Register listeners in a small plugin in app/blocks/ (any folder there with an index.js is loaded at boot):

JavaScript
// app/blocks/notifications/index.js
import { definePlugin } from '#calm';

export default definePlugin({
  name: 'notifications',
  setup(calm) {
    calm.on('post.created', async ({ doc, user }) => {
      calm.log.info(`New post "${doc.title}" by ${user?.name ?? 'system'}`);
    });
  },
});

app.events.on() returns a function that removes the listener, which is handy in tests. For work that must survive a restart or be retried, use the jobs block, whose jobs can be triggered by these events.