New package - jam:method - An easy way to create Meteor methods

jam:method is an easy way to create Meteor methods with Optimistic UI. It’s built with Meteor 3.0 in mind. It’s meant to be a drop in replacement for Validated Method and comes with additional features:

  • Before and after hooks
  • Global before and after hooks for all methods
  • Pipe a series of functions
  • Authed by default (can be overriden)
  • Easily configure a rate limit
  • Optionally run a method on the server only
  • Attach the methods to Collections (optional)
  • Validate with one of the supported schema packages – jam:easy-schema, check, zod, simpl-schema – or a custom validation function
  • No need to use .call to invoke the method as with Validated Methods

It’s customizable so you can configure to your liking.

If you end up taking it for a spin, let me know! If you have ideas on how to make it better, feel free to post below, send me a DM, or open up a Github discussion.

11 Likes

This one is also cool, is mixin feature missing? I couldn’t see it.

1 Like

It doesn’t currently have mixins, but based on the listed community mixins, I think a lot of these use cases are covered by either the defaults or .pipe. How are you using mixins currently?

One thought I had was if there are pretty typical ways they are used, then maybe there’s a potentially better solution. Wouldn’t be difficult to add either way if they are the best solution and people really like using them.

export const DataOwnerMixin = function (methodOptions) {
  const runFunction = methodOptions.run;

  methodOptions.run = function () {
    const args = Array.prototype.slice.call(arguments, 1);

    if (isDefined(args.storeId)) {
      const store = storeRepository.findOne(args.storeId);

      if (isDefined(store.franchiseId)) {
        if (store.franchiseId !== Profile.franchiseId) {
          throw new Meteor.Error(ERROR.UNAUTHENTICATED, `The data can only be accessed from its owner franchise`);
        }
      } else if (store.storeId !== Profile.storeId) {
        throw new Meteor.Error(ERROR.UNAUTHENTICATED, `The data can only be accessed from its owner store`);
      }
    }

    if (isDefined(args.profileId)) {
      const profile = profileRepository.findOne(args.profileId);

      if (isDefined(profile.franchiseId)) {
        if (profile.franchiseId !== Profile.franchiseId) {
          throw new Meteor.Error(ERROR.UNAUTHENTICATED, `The data can only be accessed from its owner franchise`);
        }
      } else if (profile.storeId !== Profile.storeId) {
        throw new Meteor.Error(ERROR.UNAUTHENTICATED, `The data can only be accessed from its owner store`);
      }
    }

    //
    // more if blocks
    //

    return runFunction.call(this, ...arguments);
  };

  return methodOptions;
};

This is a mixin to check if the current user is an owner of the data. There are many complex mixins in my project

Interesting. What’s the benefit of using a mixin here instead of a function inside the run block, e.g.

// ... rest of method ... //
run(args) {
 checkOwnership(args)
 
 // ownership verified, execute other stuff here
}

Mixins are functions that are running before the run function. It’s like a decorator. You can easily understand what will run before run function.

Mixins, run (execute), pipeline… all of them are layers of a method. Thus, separating them will improve the code quality

How do you feel about using .pipe for understanding what will run before, e.g.

export const update = createMethod({
  name: 'docs.update',
  schema: Docs.schema
}).pipe(
  checkOwnership,
  updateDoc
);

Alternatively, I could add explicit before and after properties. I actually started with this but then it seemed like .pipe accomplished the same and was maybe a bit clearer.

export const update = createMethod({
  name: 'docs.update',
  schema: Docs.schema,
  before: checkOwnership // would also accept an array of functions,
  run(args) {
    // do stuff
  }
})

Let me know what you think. In either case your ownership logic would get simpler:


function checkOwnership(args) {
  if (args.storeId) {
    const store = storeRepository.findOne(args.storeId);

    if (store.franchiseId) {
      if (store.franchiseId !== Profile.franchiseId) {
        throw new Meteor.Error(ERROR.UNAUTHENTICATED, `The data can only be accessed from its owner franchise`);
      }
    } else if (store.storeId !== Profile.storeId) {
      throw new Meteor.Error(ERROR.UNAUTHENTICATED, `The data can only be accessed from its owner store`);
    }
  }

  //
  // more if blocks
  //

  return args;
};

I thought pipes are things that would run after methods. before: [] and after: [] could be better. I’m not sure about naming. the result of the run function should be accessible in the after methods.

.pipe will simply execute the functions in the order you specify. Each one’s output will serve as the input for the next. When you use it, you wouldn’t specify a run function.

I published an update to jam:method that adds before and after hooks to an individual method. You can read more here. Here’s a quick example of using before to check permission:

export const markDone = createMethod({
  name: 'todos.markDone',
  schema: Todos.schema,
  before: checkOwnership, // can also accept an array of functions
  async run({ _id, done }) {
    return await Todos.updateAsync(_id, {$set: {done}});
  }
});
async function checkOwnership({ _id }) { // the original input passed into the method is available here. destructuring for _id since that's all we need for this function
  const todo = await Todos.findOneAsync(_id);
  if (todo.authorId !== Meteor.userId()) { // can use this.userId instead of Meteor.userId() if you prefer
    throw new Meteor.Error('not-authorized')
  }

  return true; // any code executed as a before function will automatically return the original input passed into the method so that they are available in the run function
}

If you’re currently using Validated Method and relying on mixins to do things like permission checking before the run function, you can use before with jam:method and simplify your code. :slight_smile:

2 Likes

Great work buddy :clap: :clap:

1 Like

It looks like there’s some API overlap with zodern:relay, but I’m assuming the tradeoff here is that jam:method can of course be used for optimistic UI with minimongo, where as zodern:relay does some magical code splitting behind the scenes so the client-side only gets a typesafe wrapper around Meteor.callAsync.

Regarding jam:easy-schema, is it worth name-spacing the monkey patched method attachSchema a bit by calling it attachEasySchema to avoid a collision?

Likewise, does the schema you pass into a jam:method constructor have to use <Collection>.schema, or can you call it like this?

import { createMethod } from 'meteor/jam:method'; // can import { Methods } from 'meteor/jam:method' instead and use Methods.create if you prefer

export const create = createMethod({
  name: 'todos.create',
  schema: { text: String },
  run({ text }) {
    const todo = {
      text,
      done: false,
      createdAt: new Date(),
      authorId: Meteor.userId(), // can also use this.userId instead of Meteor.userId()
    }
    const todoId = Todos.insert(todo);
    return todoId;
  }
});

I like the idea of having a “standard” based on Meteor check for validation, separate from solutions like Zod, Typebox etc (personally I am using Typebox in a meteor project at the moment, though we barely take advantage of Meteor’s features in that project until 3.0 settles a bit and I know what will be “safe” to double down on).

It looks like easy-schema can use “raw” schemas like { text: String } but it does some processing each time you pass in a “raw” schema, to make the schema into the right “shape”. So then I wonder if it’s worth exposing shapeSchema somehow to let people “compile” the schemas e.g. as a module export.

(I have more thoughts regarding easy-schema than jam:method at the moment, apologies!)

1 Like

Thanks for the ideas and feedback. I really appreciate it!

It looks like there’s some API overlap with zodern:relay

Yep, I took inspiration from zodern:relay, Validated Method, my own experiences using basic Meteor.methods with check, and reading a bunch of old discussions on ways to make methods better.

I like writing isomorphically and taking advantage of Optimistic UI — one of Meteor’s best features imo. So jam:method seeks to be a worthy Validated Method successor.

I think zodern does great work and like using the zodern:melte package and Monti APM. I truly appreciate all of their contributions to Meteor core as well. I think zodern:relay is a really nice package. When I tried it out, there were some things I wish it had which inspired jam:method. I also wasn’t sure I wanted to be locked into zod and the other tradeoffs it makes.

Regarding jam:easy-schema, is it worth name-spacing the monkey patched method attachSchema a bit by calling it attachEasySchema to avoid a collision?

I think the only package that uses attachSchema is aldeed:collection2. If you switch to jam:easy-schema, you won’t need aldeed:collection2 because it has that functionality built in. I wanted to have a familiar behavior for people who choose to switch. I suppose I’d be surprised if there would be a collision — seems like you’d either use jam:easy-schema or something else entirely.

does the schema you pass into a jam:method constructor have to use <Collection>.schema, or can you call it like this?

Currently, only a schema created with jam:easy-schema can be passed into schema using <Collection>.schema. One thing that might not be obvious is that jam:easy-schema smartly validates the data passed in so you don’t need to worry about passing in a subset of the schema. Just pass in the one schema you attach to the collection and it takes care of the rest.

My plan is to support other schemas. For example, I’ll likely add support for a zod schema. It’d be great to hear what other schemas people would like to have supported.

You bring up an interesting idea that I hadn’t considered before. I could enable what you describe but the schema object would need to be compatible with Meteor’s check. If you, or others reading this, like that idea, let me know. But at that point I wonder why not just use jam:easy-schema which does that for you with a bunch of other benefits and a nicer syntax. :slightly_smiling_face:

Currently, if you want to use Meteor’s check, or any other validator function from a package of your choice, you can use the validate function instead of schema. Here’s an example with check:


import { createMethod } from 'meteor/jam:method'; // can import { Methods } from 'meteor/jam:method' instead and use Methods.create if you prefer
import { check } from 'meteor/check'

export const create = createMethod({
  name: 'todos.create',
  validate(args) { 
    check(args, {text: String}) 
  },
  run({ text }) {
    const todo = {
      text,
      done: false,
      createdAt: new Date(),
      authorId: Meteor.userId(), // can also use this.userId instead of Meteor.userId()
    }
    const todoId = Todos.insert(todo);
    return todoId;
  }
});

I like the idea of having a “standard” based on Meteor check for validation, separate from solutions like Zod, Typebox etc

:100:. I like the simplicity of check which inspired jam:easy-schema.

it does some processing each time you pass in a “raw” schema

Not exactly. The processing happens once when you use attachSchema and then you retrieve it with <Collection>.schema.

I wonder if it’s worth exposing shapeSchema somehow to let people “compile” the schemas e.g. as a module export.

Sounds interesting. How would you imagine using this? What would be the benefits?

(I have more thoughts regarding easy-schema than jam:method at the moment, apologies!)

All good! Thanks again for sharing your thoughts.

2 Likes

I wonder if it’s worth exposing shapeSchema somehow to let people “compile” the schemas e.g. as a module export.

Sounds interesting. How would you imagine using this? What would be the benefits?

Hmm, I think I misunderstood a bit about how the schemas were prepared from being a “POJO” to something that easy-schema actually uses (which I assumed shapeSchema was doing). I’m not sure how much overhead that introduces each time so I figure you probably only want to do it once since validation is quite expensive each time.

I think perhaps my ideal is something like this:

E.g.:

// don't mind the arbitrary data modelling choices...
let TodoSchema = {
  text: String,
  isCompleted: Boolean,
  completedAt: Date,
  createdAt: Date,
}

export const create = createMethod({
  name: 'todos.create',
  schema: TodoSchema, // No intermediate steps required
  // ...
}

// Then somewhere else
import { check } from 'meteor/check' // or 'meteor/jam:easy-schema';

check(someRandomData, TodoSchema);
// someRandomData is now seen as type { text: string; ... }

This has the fewest things for people to learn, and in a project I noticed a junior creating their own custom schemas in this way (using plain Meteor Check). It’s much cleaner looking than Zod, Typebox, Yup etc (albeit less powerful).

Assuming shapeSchema has some sort of a cost, and is used to prepare the POJO to another format which can be used by createMethod etc, then probably you’d have to do it this way if you wanted to avoid redoing work each time:

// don't mind the arbitrary data modelling choices...
import { createSchema } from  'meteor/jam:easy-schema';
let TodoSchema = createSchema({
  text: String,
  isCompleted: Boolean,
  completedAt: Date,
  createdAt: Date,
})

export const create = createMethod({
  name: 'todos.create',
  schema: TodoSchema, // Intermediate step performed AOT by createSchema
  // ...
}

// Then somewhere else
import { check } from  'meteor/jam:easy-schema'; // 'meteor/check' might not work I guess now

check(someRandomData, TodoSchema);
// someRandomData is now seen as type { text: string; ... }

I wanted to have a familiar behavior for people who choose to switch. I suppose I’d be surprised if there would be a collision — seems like you’d either use jam:easy-schema or something else entirely.

I think this is a tricky one in my head, because people might want to gradually migrate between the two. But I have to admit I’ve been lazy and can’t remember simple-schema’s API and how much overlap it has… maybe a migration is as simple as “simplifying” (heh) your simple-schema schemas (heh x2) and then quietly migrate behind the scenes just by changing packages?

Again, perhaps I’m not the target audience because I’ve been avoiding this by using Typebox.

shapeSchema does perform a conversion but it only happens once – when you attach the schema to the collection, not at the time of validation. Its cost is negligible.

Regarding your ideal, maybe a more detailed example would be helpful to illustrate. I think jam:easy-schema is closer to your ideal. :slight_smile:

Your example:

Note that currently, jam:method doesn’t support passing in a schema like this, but it could.

import { Match } from 'meteor/check';
import { Todos } from './collection';
import { createMethod } from 'meteor/jam:method';
import { pick } from 'somewhere';

const TodoSchema = {
  _id: Match.Maybe(String),
  text: String,
  isCompleted: Boolean,
  completedAt: Date,
  createdAt: Date,
  note: Match.Maybe(String) // let's say you want to add an optional note to each todo
}

// passing in TodoSchema here would mean you'll need to pass in all the properties of TodoSchema as defined above into your method which probably won't be the case. 
// You could use a pick() function to pick the properties you'll pass in.
// Alternatively you could define a "mini" schema for each method based on what you pass in but that kind of defeats the point of having one TodoSchema.
export const create = createMethod({
  name: 'todos.create',
  schema: pick(TodoSchema, ['text']), // Meteor's check would be used automatically when the method is invoked. This doesn't happen currently but I could update jam:method to support it.
  run({ text }) {
    // ... //
  }
}

export const setCompleted = createMethod({
  name: 'todos.setCompleted',
  schema: pick(TodoSchema, ['_id', 'isCompleted']),
  run({ _id, isCompleted }) {
    // ... //
  }
}

With jam:easy-schema:

import { Todos } from './collection';
import { Optional } from 'meteor/jam:easy-schema';
import { createMethod } from 'meteor/jam:method';

// you'd most likely put the schema in it's own file or where you define the collection but putting here for illustration purposes.
const schema = {
  _id: String,
  text: String,
  isCompleted: Boolean,
  completedAt: Date,
  createdAt: Date,
  note: Optional(String) // let's say you want to add an optional note to each todo
}

// you'd have attachSchema where you define the collection but I have it here for illustration purposes
Todos.attachSchema(schema); // shapeSchema does its conversion as part of attachSchema

// all Todos methods will validate against the one Todos.schema
// jam:easy-schema automatically picks the properties that you pass in
export const create = createMethod({
  name: 'todos.create',
  schema: Todos.schema, // jam:easy-schema's check will be used automatically when the Method is invoked
  run({ text }) {
    // ... //
  }
}

export const setCompleted = createMethod({
  name: 'todos.setCompleted',
  schema: Todos.schema,
  run({ _id, isCompleted }) {
    // ... //
  }
}

Also note that jam:easy-schema:

  1. Automatically validates on the server for write operations (for inserts / upserts data will be validated against the full schema). This is configurable so you can turn it off if you like.
  2. Automatically generates a Mongo-compatible JSON Schema and attaches it to your db collection. This is configurable so you can turn it off if you like.
1 Like

Great work here @jam. We’re keeping tabs and debating if it might replace our existing validated methods. The before hook indeed would fit nicely with how we handle permissions. Just a few thoughts/questions.

I think the only package that uses attachSchema is aldeed:collection2 . If you switch to jam:easy-schema , you won’t need aldeed:collection2 because it has that functionality built in.

I had a look and didn’t see any mention of autovalues ? Other than the verification on insert/update that’s probably the most vital feature of collection2 for us. I’m sort of against them (for similar reasons I don’t like collection hooks) but for the basic createdAt, updatedAt, creatorId, updaterId it’s really useful.

I see your schema definition is basically tailored to working with check. It’s cool that we then get the param validation for free essentially as long as the params are included in the schema. I also really like that you’ve started taking advantage of the native mongo validation and can convert to jsonschema easily. I’m wondering if you’ve thought about form libraries? Those of us in Blaze land are tied to Autoform which is heavily tied to simpleSchema. Would it be easy to create a SS compatible schema to keep those library happy or easier to modify AF to work with ES ?

Also, in terms of being able to adopt a package like this a large part of it would be migrating existing schemas. You linked to some code from the latest version of SS which converts SS to JsonSchema. Do you think it would be easy to take that and modify it to create EasySchemas?
An alternate path would be if ES accepted it’s own format and also JsonSchema - that would be really cool and then I could see a relatively (famous last words :see_no_evil: ) simple migration path.

1 Like

Thanks Mark!

Quick note for others reading this thread: you can use jam:method without using jam:easy-schema. So if you’re happy with SimpleSchema or another validation package, you can use it with jam:method. We’re kind of discussing both in this thread, which I think is fine, but others might get confused. If you don’t like co-mingling the convo, there is a jam:easy-schema forum thread. :slight_smile:

Great to hear!

I don’t have plans to support autovalues in jam:easy-schema at this time. Like you said, I’m sort of against them. Having said that, can you give an example of how you use them and why you find them useful?

I haven’t really. My initial thought: I think these are so tied to the view layer you’re using that I think it’d be better to have someone write a package for their view layer that takes an Easy Schema as an input to magically generate a form.

In general, I think migrating from SimpleSchema to Easy Schema should hopefully be relatively straightforward. It could also make your schemas a lot more straightforward to grok. Though it will depend on how many features of SimpleSchema you rely on. Easy Schema intentionally doesn’t have all the SimpleSchema features.

Hmm, not sure I’m following :slight_smile:. Which code are you referring to?

Easy Schema does a conversion to Mongo JSON Schema automatically so hopefully your use case is covered. Can you give an example of what you’re looking for?

I made an update to support more schema packages. :tada:

The schema property in jam:method now supports:

Here’s a quick example of each:

// jam:easy-schema
const create = createMethod({
  name: 'todos.create',
  schema: Todos.schema,
  run({ text }) {
    // ... //
  }
});
// check
const create = createMethod({
  name: 'todos.create',
  schema: {text: String},
  run({ text }) {
    // ... //
  }
});
// zod
const create = createMethod({
  name: 'todos.create',
  schema: z.object({text: z.string()}),
  run({ text }) {
    // ... //
  }
});
// simpl-schema
const create = createMethod({
  name: 'todos.create',
  schema: new SimpleSchema({text: String}),
  run({ text }) {
    // ... //
  }
});


If you’re using a different schema package, fear not, you can use validate to pass in a custom validation function instead:

// import your schema from somewhere 
// import your validator function from the package of your choice

const create = createMethod({
  name: 'todos.create',
  validate(args) {
    validator(args, schema), 
  },
  run({ text }) {
    // ... //
  }
});


If you have ideas for other improvements you’d like to see in jam:method, let me know!

7 Likes

@jam This is really awesome feature :clap: Great work brother!

@jkuester is there a way to create “bridges” uniforms style to create forms in autoform?

1 Like

when I write meteor add jam:method, I get this error Error: Found multiple packages matching name: jam:method. Do you know anything else about this issue @jam