diff --git a/packages/roles/definitions.d.ts b/packages/roles/definitions.d.ts index d8f4441022..c15c8c9652 100644 --- a/packages/roles/definitions.d.ts +++ b/packages/roles/definitions.d.ts @@ -4,14 +4,14 @@ // Definitions: https://github.com/DefinitelyTyped/DefinitelyTyped // Minimum TypeScript Version: 4.1 -import { Mongo } from 'meteor/mongo' +import { Mongo } from "meteor/mongo"; /** * Provides functions related to user authorization. Compatible with built-in Meteor accounts packages. * * @module Roles */ -declare namespace Roles { +export declare namespace Roles { /** * Constant used to reference the special 'global' group that * can be used to apply blanket permissions across all groups. @@ -27,7 +27,7 @@ declare namespace Roles { * @static * @final */ - var GLOBAL_GROUP: string + var GLOBAL_GROUP: string; /** * Subscription handle for the currently logged in user's permissions. @@ -42,7 +42,7 @@ declare namespace Roles { * * @for Roles */ - var subscription: Subscription + var subscription: Subscription; /** * Add users to roles. @@ -68,12 +68,12 @@ declare namespace Roles { users: string | string[] | Meteor.User | Meteor.User[], roles: string | string[], options?: string | { scope?: string; ifExists?: boolean } - ): void + ): void; function addUsersToRolesAsync( users: string | string[] | Meteor.User | Meteor.User[], roles: string | string[], options?: string | { scope?: string; ifExists?: boolean } - ): Promise + ): Promise; /** * Create a new role. @@ -84,8 +84,14 @@ declare namespace Roles { * - `unlessExists`: if `true`, exception will not be thrown in the role already exists * @return {String} ID of the new role or null. */ - function createRole(roleName: string, options?: { unlessExists: boolean }): string - function createRoleAsync(roleName: string, options?: { unlessExists: boolean }): Promise + function createRole( + roleName: string, + options?: { unlessExists: boolean } + ): string; + function createRoleAsync( + roleName: string, + options?: { unlessExists: boolean } + ): Promise; /** * Delete an existing role. @@ -95,8 +101,8 @@ declare namespace Roles { * @method deleteRole * @param {String} roleName Name of role. */ - function deleteRole(roleName: string): void - function deleteRoleAsync(roleName: string): Promise + function deleteRole(roleName: string): void; + function deleteRoleAsync(roleName: string): Promise; /** * Rename an existing role. @@ -105,8 +111,8 @@ declare namespace Roles { * @param {String} oldName Old name of a role. * @param {String} newName New name of a role. */ - function renameRole(oldName: string, newName: string): void - function renameRoleAsync(oldName: string, newName: string): Promise + function renameRole(oldName: string, newName: string): void; + function renameRoleAsync(oldName: string, newName: string): Promise; /** * Add role parent to roles. @@ -118,8 +124,14 @@ declare namespace Roles { * @param {Array|String} rolesNames Name(s) of role(s). * @param {String} parentName Name of parent role. */ - function addRolesToParent(rolesNames: string | string[], parentName: string): void - function addRolesToParentAsync(rolesNames: string | string[], parentName: string): Promise + function addRolesToParent( + rolesNames: string | string[], + parentName: string + ): void; + function addRolesToParentAsync( + rolesNames: string | string[], + parentName: string + ): Promise; /** * Remove role parent from roles. @@ -131,8 +143,14 @@ declare namespace Roles { * @param {Array|String} rolesNames Name(s) of role(s). * @param {String} parentName Name of parent role. */ - function removeRolesFromParent(rolesNames: string | string[], parentName: string): void - function removeRolesFromParentAsync(rolesNames: string | string[], parentName: string): Promise + function removeRolesFromParent( + rolesNames: string | string[], + parentName: string + ): void; + function removeRolesFromParentAsync( + rolesNames: string | string[], + parentName: string + ): Promise; /** * Retrieve cursor of all existing roles. @@ -142,7 +160,7 @@ declare namespace Roles { * through to `Meteor.roles.find(query, options)`. * @return {Cursor} Cursor of existing roles. */ - function getAllRoles(queryOptions?: QueryOptions): Mongo.Cursor + function getAllRoles(queryOptions?: QueryOptions): Mongo.Cursor; /** * Retrieve users groups, if any @@ -153,8 +171,14 @@ declare namespace Roles { * * @return {Array} Array of user's groups, unsorted. Roles.GLOBAL_GROUP will be omitted */ - function getGroupsForUser(user: string | Meteor.User, role?: string): string[] - function getGroupsForUserAsync(user: string | Meteor.User, role?: string): Promise + function getGroupsForUser( + user: string | Meteor.User, + role?: string + ): string[]; + function getGroupsForUserAsync( + user: string | Meteor.User, + role?: string + ): Promise; /** * Retrieve users scopes, if any. @@ -165,8 +189,14 @@ declare namespace Roles { * * @return {Array} Array of user's scopes, unsorted. */ - function getScopesForUser(user: string | Meteor.User, roles?: string | string[]): string[] - function getScopesForUserAsync(user: string | Meteor.User, roles?: string | string[]): Promise + function getScopesForUser( + user: string | Meteor.User, + roles?: string | string[] + ): string[]; + function getScopesForUserAsync( + user: string | Meteor.User, + roles?: string | string[] + ): Promise; /** * Rename a scope. @@ -177,8 +207,8 @@ declare namespace Roles { * @param {String} oldName Old name of a scope. * @param {String} newName New name of a scope. */ - function renameScope(oldName: string, newName: string): void - function renameScopeAsync(oldName: string, newName: string): Promise + function renameScope(oldName: string, newName: string): void; + function renameScopeAsync(oldName: string, newName: string): Promise; /** * Remove a scope. @@ -189,8 +219,8 @@ declare namespace Roles { * @param {String} name The name of a scope. * */ - function removeScope(name: String): void - function removeScopeAsync(name: String): Promise + function removeScope(name: String): void; + function removeScopeAsync(name: String): Promise; /** * Find out if a role is an ancestor of another role. @@ -202,8 +232,11 @@ declare namespace Roles { * @param {String} childRoleName The role you expect to be among the children of parentRoleName. * @return {Boolean} */ - function isParentOf(parentRoleName: string, childRoleName: string): boolean - function isParentOfAsync(parentRoleName: string, childRoleName: string): Promise + function isParentOf(parentRoleName: string, childRoleName: string): boolean; + function isParentOfAsync( + parentRoleName: string, + childRoleName: string + ): Promise; /** * Retrieve user's roles. @@ -222,20 +255,30 @@ declare namespace Roles { * Alternatively, it can be a scope name string. * @return {Array} Array of user's roles, unsorted. */ - function getRolesForUser(user: string | Meteor.User, options?: string | { - scope?: string; - anyScope?: boolean; - onlyScoped?: boolean; - onlyAssigned?: boolean; - fullObjects?: boolean - }): string[] - function getRolesForUserAsync(user: string | Meteor.User, options?: string | { - scope?: string; - anyScope?: boolean; - onlyScoped?: boolean; - onlyAssigned?: boolean; - fullObjects?: boolean - }): Promise + function getRolesForUser( + user: string | Meteor.User, + options?: + | string + | { + scope?: string; + anyScope?: boolean; + onlyScoped?: boolean; + onlyAssigned?: boolean; + fullObjects?: boolean; + } + ): string[]; + function getRolesForUserAsync( + user: string | Meteor.User, + options?: + | string + | { + scope?: string; + anyScope?: boolean; + onlyScoped?: boolean; + onlyAssigned?: boolean; + fullObjects?: boolean; + } + ): Promise; /** * Retrieve all assignments of a user which are for the target role. @@ -257,11 +300,16 @@ declare namespace Roles { * Alternatively, it can be a scope name string. * @return {Cursor} Cursor of user assignments for roles. */ - function getUserAssignmentsForRole(roles: string | string[], options?: string | { - scope?: string - anyScope?: boolean - queryOptions?: QueryOptions - }): Mongo.Cursor + function getUserAssignmentsForRole( + roles: string | string[], + options?: + | string + | { + scope?: string; + anyScope?: boolean; + queryOptions?: QueryOptions; + } + ): Mongo.Cursor; /** * Retrieve all users who are in target role. @@ -288,14 +336,28 @@ declare namespace Roles { */ function getUsersInRole( roles: string | string[], - options?: string | { scope?: string; anyScope?: boolean; onlyScoped?: boolean; queryOptions?: QueryOptions }, + options?: + | string + | { + scope?: string; + anyScope?: boolean; + onlyScoped?: boolean; + queryOptions?: QueryOptions; + }, queryOptions?: QueryOptions - ): Mongo.Cursor + ): Mongo.Cursor; function getUsersInRoleAsync( roles: string | string[], - options?: string | { scope?: string; anyScope?: boolean; onlyScoped?: boolean; queryOptions?: QueryOptions }, + options?: + | string + | { + scope?: string; + anyScope?: boolean; + onlyScoped?: boolean; + queryOptions?: QueryOptions; + }, queryOptions?: QueryOptions - ): Promise> + ): Promise>; /** * Remove users from assigned roles. @@ -318,12 +380,12 @@ declare namespace Roles { users: string | string[] | Meteor.User | Meteor.User[], roles?: string | string[], options?: string | { scope?: string; anyScope?: boolean } - ): void + ): void; function removeUsersFromRolesAsync( users: string | string[] | Meteor.User | Meteor.User[], roles?: string | string[], options?: string | { scope?: string; anyScope?: boolean } - ): Promise + ): Promise; /** * Set users' roles. @@ -349,13 +411,17 @@ declare namespace Roles { function setUserRoles( users: string | string[] | Meteor.User | Meteor.User[], roles: string | string[], - options?: string | { scope?: string; anyScope?: boolean; ifExists?: boolean } - ): void + options?: + | string + | { scope?: string; anyScope?: boolean; ifExists?: boolean } + ): void; function setUserRolesAsync( users: string | string[] | Meteor.User | Meteor.User[], roles: string | string[], - options?: string | { scope?: string; anyScope?: boolean; ifExists?: boolean } - ): Promise + options?: + | string + | { scope?: string; anyScope?: boolean; ifExists?: boolean } + ): Promise; /** * Check if user has specified roles. @@ -389,55 +455,55 @@ declare namespace Roles { user: string | string[] | Meteor.User | Meteor.User[], roles: string | string[], options?: string | { scope?: string; anyScope?: boolean } - ): boolean + ): boolean; function userIsInRoleAsync( user: string | string[] | Meteor.User | Meteor.User[], roles: string | string[], options?: string | { scope?: string; anyScope?: boolean } - ): Promise + ): Promise; // The schema for the roles collection interface Role { - _id: string - name: string - children: { _id: string }[] + _id: string; + name: string; + children: { _id: string }[]; } // The schema for the role-assignment collection interface RoleAssignment { - _id: string + _id: string; user: { - _id: string - } + _id: string; + }; role: { - _id: string - } + _id: string; + }; inheritedRoles?: { - _id: string - }[] - scope?: string + _id: string; + }[]; + scope?: string; } interface QueryOptions { - sort?: Mongo.SortSpecifier | undefined - skip?: number | undefined - limit?: number | undefined - fields?: Mongo.FieldSpecifier | undefined - projection?: Mongo.FieldSpecifier | undefined - reactive?: boolean | undefined - transform?: Function | undefined + sort?: Mongo.SortSpecifier | undefined; + skip?: number | undefined; + limit?: number | undefined; + fields?: Mongo.FieldSpecifier | undefined; + projection?: Mongo.FieldSpecifier | undefined; + reactive?: boolean | undefined; + transform?: Function | undefined; } - } // module // Exported collections -declare type RolesCollection = Mongo.Collection -declare type RoleAssignmentsCollection = Mongo.Collection +export declare type RolesCollection = Mongo.Collection; +export declare type RoleAssignmentsCollection = + Mongo.Collection; // Additions to the Meteor object -declare module 'meteor/meteor' { - namespace Meteor { - const roles: Mongo.Collection - const roleAssignment: Mongo.Collection +declare module "meteor/meteor" { + export namespace Meteor { + const roles: Mongo.Collection; + const roleAssignment: Mongo.Collection; } } diff --git a/packages/roles/package-types.json b/packages/roles/package-types.json new file mode 100644 index 0000000000..7838991b97 --- /dev/null +++ b/packages/roles/package-types.json @@ -0,0 +1,3 @@ +{ + "typesEntry": "definitions.d.ts" +} diff --git a/packages/roles/package.js b/packages/roles/package.js index 2ff58f3dee..e3a7080835 100644 --- a/packages/roles/package.js +++ b/packages/roles/package.js @@ -15,8 +15,6 @@ Package.onUse(function (api) { both ); - api.use("zodern:types@1.0.13"); - api.use(["blaze@2.9.0 || 3.0.0"], "client", { weak: true }); api.export(["Roles", "RolesCollection", "RoleAssignmentCollection"]); @@ -25,17 +23,15 @@ Package.onUse(function (api) { api.addFiles("roles_common_async.js", both); api.addFiles("roles_server.js", "server"); api.addFiles(["client/debug.js", "client/uiHelpers.js"], "client"); + + api.addAssets("definitions.d.ts", "server"); + api.addAssets("package-types.json", "server"); }); Package.onTest(function (api) { const both = ["client", "server"]; - api.use([ - "tinytest", - "ecmascript", - "mongo", - "roles" - ], both); + api.use(["tinytest", "ecmascript", "mongo", "roles"], both); api.addFiles("tests/serverAsync.js", "server"); api.addFiles("tests/client.js", "client"); diff --git a/packages/roles/roles_common_async.js b/packages/roles/roles_common_async.js index 11079194d5..a78281db83 100644 --- a/packages/roles/roles_common_async.js +++ b/packages/roles/roles_common_async.js @@ -24,7 +24,6 @@ import { Mongo } from 'meteor/mongo' * - `scope`: scope name * - `inheritedRoles`: A list of all the roles objects inherited by the assigned role. * - * @module Roles */ export const RolesCollection = new Mongo.Collection('roles') @@ -39,7 +38,7 @@ if (!Meteor.roleAssignment) { } /** - * @class Roles + * class Roles */ if (typeof Roles === 'undefined') { Roles = {} // eslint-disable-line no-global-assign @@ -60,6 +59,10 @@ const asyncSome = async (arr, predicate) => { return false } +/** + * @namespace Roles + * @summary The namespace for all Roles types and methods. + */ Object.assign(Roles, { /** * Used as a global group (now scope) name. Not used anymore. @@ -71,14 +74,13 @@ Object.assign(Roles, { GLOBAL_GROUP: null, /** - * Create a new role. - * - * @method createRoleAsync + * @summary Create a new role. + * @memberof Roles + * @locus Anywhere * @param {String} roleName Name of role. * @param {Object} [options] Options: - * - `unlessExists`: if `true`, exception will not be thrown in the role already exists - * @return {Promise} ID of the new role or null. - * @static + * - `unlessExists`: if `true`, exception will not be thrown in the role already exists + * @return {Promise} ID of the new role or null */ createRoleAsync: async function (roleName, options) { Roles._checkRoleName(roleName) @@ -116,14 +118,12 @@ Object.assign(Roles, { }, /** - * Delete an existing role. - * + * @summary Delete an existing role. * If the role is set for any user, it is automatically unset. - * - * @method deleteRoleAsync + * @memberof Roles + * @locus Anywhere * @param {String} roleName Name of role. * @returns {Promise} - * @static */ deleteRoleAsync: async function (roleName) { let roles @@ -182,13 +182,12 @@ Object.assign(Roles, { }, /** - * Rename an existing role. - * - * @method renameRoleAsync + * @summary Rename an existing role. + * @memberof Roles + * @locus Anywhere * @param {String} oldName Old name of a role. * @param {String} newName New name of a role. * @returns {Promise} - * @static */ renameRoleAsync: async function (oldName, newName) { let count @@ -254,16 +253,12 @@ Object.assign(Roles, { }, /** - * Add role parent to roles. - * - * Previous parents are kept (role can have multiple parents). For users which have the - * parent role set, new subroles are added automatically. - * - * @method addRolesToParentAsync + * @summary Add role parent to roles. + * @memberof Roles + * @locus Anywhere * @param {Array|String} rolesNames Name(s) of role(s). * @param {String} parentName Name of parent role. * @returns {Promise} - * @static */ addRolesToParentAsync: async function (rolesNames, parentName) { // ensure arrays @@ -339,16 +334,14 @@ Object.assign(Roles, { }, /** - * Remove role parent from roles. - * + * @summary Remove role parent from roles. * Other parents are kept (role can have multiple parents). For users which have the * parent role set, removed subrole is removed automatically. - * - * @method removeRolesFromParentAsync - * @param {Array|String} rolesNames Name(s) of role(s). - * @param {String} parentName Name of parent role. - * @returns {Promise} - * @static + * @memberof Roles + * @locus Anywhere + @param {Array|String} rolesNames Name(s) of role(s). + @param {String} parentName Name of parent role. + @returns {Promise} */ removeRolesFromParentAsync: async function (rolesNames, parentName) { // ensure arrays @@ -431,26 +424,16 @@ Object.assign(Roles, { }, /** - * Add users to roles. - * + * @summary Add users to roles. * Adds roles to existing roles for each user. - * - * @example - * Roles.addUsersToRolesAsync(userId, 'admin') - * Roles.addUsersToRolesAsync(userId, ['view-secrets'], 'example.com') - * Roles.addUsersToRolesAsync([user1, user2], ['user','editor']) - * Roles.addUsersToRolesAsync([user1, user2], ['glorious-admin', 'perform-action'], 'example.org') - * - * @method addUsersToRolesAsync + * @memberof Roles + * @locus Anywhere * @param {Array|String} users User ID(s) or object(s) with an `_id` field. * @param {Array|String} roles Name(s) of roles to add users to. Roles have to exist. * @param {Object|String} [options] Options: * - `scope`: name of the scope, or `null` for the global role * - `ifExists`: if `true`, do not throw an exception if the role does not exist * @returns {Promise} - * - * Alternatively, it can be a scope name string. - * @static */ addUsersToRolesAsync: async function (users, roles, options) { let id @@ -487,17 +470,10 @@ Object.assign(Roles, { }, /** - * Set users' roles. - * + * @summary Set users' roles. * Replaces all existing roles with a new set of roles. - * - * @example - * await Roles.setUserRolesAsync(userId, 'admin') - * await Roles.setUserRolesAsync(userId, ['view-secrets'], 'example.com') - * await Roles.setUserRolesAsync([user1, user2], ['user','editor']) - * await Roles.setUserRolesAsync([user1, user2], ['glorious-admin', 'perform-action'], 'example.org') - * - * @method setUserRolesAsync + * @memberof Roles + * @locus Anywhere * @param {Array|String} users User ID(s) or object(s) with an `_id` field. * @param {Array|String} roles Name(s) of roles to add users to. Roles have to exist. * @param {Object|String} [options] Options: @@ -505,9 +481,6 @@ Object.assign(Roles, { * - `anyScope`: if `true`, remove all roles the user has, of any scope, if `false`, only the one in the same scope * - `ifExists`: if `true`, do not throw an exception if the role does not exist * @returns {Promise} - * - * Alternatively, it can be a scope name string. - * @static */ setUserRolesAsync: async function (users, roles, options) { let id @@ -714,23 +687,15 @@ Object.assign(Roles, { }, /** - * Remove users from assigned roles. - * - * @example - * await Roles.removeUsersFromRolesAsync(userId, 'admin') - * await Roles.removeUsersFromRolesAsync([userId, user2], ['editor']) - * await Roles.removeUsersFromRolesAsync(userId, ['user'], 'group1') - * - * @method removeUsersFromRolesAsync + * @summary Remove users from assigned roles. + * @memberof Roles + * @locus Anywhere * @param {Array|String} users User ID(s) or object(s) with an `_id` field. * @param {Array|String} roles Name(s) of roles to remove users from. Roles have to exist. * @param {Object|String} [options] Options: * - `scope`: name of the scope, or `null` for the global role * - `anyScope`: if set, role can be in any scope (`scope` option is ignored) * @returns {Promise} - * - * Alternatively, it can be a scope name string. - * @static */ removeUsersFromRolesAsync: async function (users, roles, options) { if (!users) throw new Error("Missing 'users' param.") @@ -792,21 +757,9 @@ Object.assign(Roles, { }, /** - * Check if user has specified roles. - * - * @example - * // global roles - * await Roles.userIsInRoleAsync(user, 'admin') - * await Roles.userIsInRoleAsync(user, ['admin','editor']) - * await Roles.userIsInRoleAsync(userId, 'admin') - * await Roles.userIsInRoleAsync(userId, ['admin','editor']) - * - * // scope roles (global roles are still checked) - * await Roles.userIsInRoleAsync(user, 'admin', 'group1') - * await Roles.userIsInRoleAsync(userId, ['admin','editor'], 'group1') - * await Roles.userIsInRoleAsync(userId, ['admin','editor'], {scope: 'group1'}) - * - * @method userIsInRoleAsync + * @summary Check if user has specified roles. + * @memberof Roles + * @locus Anywhere * @param {String|Object} user User ID or an actual user object. * @param {Array|String} roles Name of role or an array of roles to check against. If array, * will return `true` if user is in _any_ role. @@ -818,7 +771,6 @@ Object.assign(Roles, { * * Alternatively, it can be a scope name string. * @return {Promise} `true` if user is in _any_ of the target roles - * @static */ userIsInRoleAsync: async function (user, roles, options) { let id @@ -869,9 +821,9 @@ Object.assign(Roles, { }, /** - * Retrieve user's roles. - * - * @method getRolesForUserAsync + * @summary Retrieve user's roles. + * @memberof Roles + * @locus Anywhere * @param {String|Object} user User ID or an actual user object. * @param {Object|String} [options] Options: * - `scope`: name of scope to provide roles for; if not specified, global roles are returned @@ -884,7 +836,6 @@ Object.assign(Roles, { * * Alternatively, it can be a scope name string. * @return {Promise} Array of user's roles, unsorted. - * @static */ getRolesForUserAsync: async function (user, options) { let id @@ -954,13 +905,133 @@ Object.assign(Roles, { }, /** - * Retrieve cursor of all existing roles. - * - * @method getAllRoles - * @param {Object} [queryOptions] Options which are passed directly - * through to `RolesCollection.find(query, options)`. + * @summary Retrieve users scopes, if any. + * @memberof Roles + * @locus Anywhere + * @param {String|Object} user User ID or an actual user object. + * @param {Array|String} [roles] Name of roles to restrict scopes to. + * @return {Promise} Array of user's scopes, unsorted. + */ + getScopesForUserAsync: async function (user, roles) { + let id + + if (roles && !Array.isArray(roles)) roles = [roles] + + if (user && typeof user === 'object') { + id = user._id + } else { + id = user + } + + if (!id) return [] + + const selector = { + 'user._id': id, + scope: { $ne: null } + } + + if (roles) { + selector['inheritedRoles._id'] = { $in: roles } + } + + const scopes = ( + await Meteor.roleAssignment + .find(selector, { fields: { scope: 1 } }) + .fetchAsync() + ).map((obi) => obi.scope) + + return [...new Set(scopes)] + }, + + /** + * @summary Rename a scope. + * @memberof Roles + * @locus Anywhere + * @param {String} oldName Old name of a scope. + * @param {String} newName New name of a scope. + * @returns {Promise} + */ + renameScopeAsync: async function (oldName, newName) { + let count + + Roles._checkScopeName(oldName) + Roles._checkScopeName(newName) + + if (oldName === newName) return + + do { + count = await Meteor.roleAssignment.updateAsync( + { + scope: oldName + }, + { + $set: { + scope: newName + } + }, + { multi: true } + ) + } while (count > 0) + }, + + /** + * @summary Remove a scope and all its role assignments. + * @memberof Roles + * @locus Anywhere + * @param {String} name The name of a scope. + * @returns {Promise} + */ + removeScopeAsync: async function (name) { + Roles._checkScopeName(name) + + await Meteor.roleAssignment.removeAsync({ scope: name }) + }, + + /** + * @summary Find out if a role is an ancestor of another role. + * @memberof Roles + * @locus Anywhere + * @param {String} parentRoleName The role you want to research. + * @param {String} childRoleName The role you expect to be among the children of parentRoleName. + * @returns {Promise} True if parent role is ancestor of child role + */ + isParentOfAsync: async function (parentRoleName, childRoleName) { + if (parentRoleName === childRoleName) { + return true + } + + if (parentRoleName == null || childRoleName == null) { + return false + } + + Roles._checkRoleName(parentRoleName) + Roles._checkRoleName(childRoleName) + + let rolesToCheck = [parentRoleName] + while (rolesToCheck.length !== 0) { + const roleName = rolesToCheck.pop() + + if (roleName === childRoleName) { + return true + } + + const role = await Meteor.roles.findOneAsync({ _id: roleName }) + + // This should not happen, but this is a problem to address at some other time. + if (!role) continue + + rolesToCheck = rolesToCheck.concat(role.children.map((r) => r._id)) + } + + return false + }, + + /** + * @summary Retrieve cursor of all existing roles. + * @memberof Roles + * @locus Anywhere + * @param {Object} [queryOptions] Options which are passed directly through to `Meteor.roles.find(query, options)`. * @return {Cursor} Cursor of existing roles. - * @static */ getAllRoles: function (queryOptions) { queryOptions = queryOptions || { sort: { _id: 1 } } @@ -969,28 +1040,16 @@ Object.assign(Roles, { }, /** - * Retrieve all users who are in target role. - * - * Options: - * - * @method getUsersInRoleAsync - * @param {Array|String} roles Name of role or an array of roles. If array, users - * returned will have at least one of the roles - * specified but need not have _all_ roles. - * Roles do not have to exist. + * @summary Retrieve all users who are in target role. + * @memberof Roles + * @locus Anywhere + * @param {Array|String} roles Name of role or an array of roles. * @param {Object|String} [options] Options: - * - `scope`: name of the scope to restrict roles to; user's global - * roles will also be checked - * - `anyScope`: if set, role can be in any scope (`scope` option is ignored) + * - `scope`: name of the scope to restrict roles to + * - `anyScope`: if set, role can be in any scope * - `onlyScoped`: if set, only roles in the specified scope are returned - * - `queryOptions`: options which are passed directly - * through to `Meteor.users.find(query, options)` - * - * Alternatively, it can be a scope name string. - * @param {Object} [queryOptions] Options which are passed directly - * through to `Meteor.users.find(query, options)` + * - `queryOptions`: options which are passed directly through to `Meteor.users.find(query, options)` * @return {Promise} Cursor of users in roles. - * @static */ getUsersInRoleAsync: async function (roles, options, queryOptions) { const ids = ( @@ -1004,25 +1063,15 @@ Object.assign(Roles, { }, /** - * Retrieve all assignments of a user which are for the target role. - * - * Options: - * - * @method getUserAssignmentsForRole - * @param {Array|String} roles Name of role or an array of roles. If array, users - * returned will have at least one of the roles - * specified but need not have _all_ roles. - * Roles do not have to exist. + * @summary Retrieve all assignments of a user which are for the target role. + * @memberof Roles + * @locus Anywhere + * @param {Array|String} roles Name of role or an array of roles. * @param {Object|String} [options] Options: - * - `scope`: name of the scope to restrict roles to; user's global - * roles will also be checked - * - `anyScope`: if set, role can be in any scope (`scope` option is ignored) - * - `queryOptions`: options which are passed directly - * through to `RoleAssignmentCollection.find(query, options)` - - * Alternatively, it can be a scope name string. + * - `scope`: name of the scope to restrict roles to + * - `anyScope`: if set, role can be in any scope + * - `queryOptions`: options which are passed directly through to `RoleAssignmentCollection.find(query, options)` * @return {Cursor} Cursor of user assignments for roles. - * @static */ getUserAssignmentsForRole: function (roles, options) { options = Roles._normalizeOptions(options) @@ -1114,157 +1163,6 @@ Object.assign(Roles, { return await Roles.getScopesForUser(...args) }, - /** - * Retrieve users scopes, if any. - * - * @method getScopesForUserAsync - * @param {String|Object} user User ID or an actual user object. - * @param {Array|String} [roles] Name of roles to restrict scopes to. - * - * @return {Promise} Array of user's scopes, unsorted. - * @static - */ - getScopesForUserAsync: async function (user, roles) { - let id - - if (roles && !Array.isArray(roles)) roles = [roles] - - if (user && typeof user === 'object') { - id = user._id - } else { - id = user - } - - if (!id) return [] - - const selector = { - 'user._id': id, - scope: { $ne: null } - } - - if (roles) { - selector['inheritedRoles._id'] = { $in: roles } - } - - const scopes = ( - await Meteor.roleAssignment - .find(selector, { fields: { scope: 1 } }) - .fetchAsync() - ).map((obi) => obi.scope) - - return [...new Set(scopes)] - }, - - /** - * Rename a scope. - * - * Roles assigned with a given scope are changed to be under the new scope. - * - * @method renameScopeAsync - * @param {String} oldName Old name of a scope. - * @param {String} newName New name of a scope. - * @returns {Promise} - * @static - */ - renameScopeAsync: async function (oldName, newName) { - let count - - Roles._checkScopeName(oldName) - Roles._checkScopeName(newName) - - if (oldName === newName) return - - do { - count = await Meteor.roleAssignment.updateAsync( - { - scope: oldName - }, - { - $set: { - scope: newName - } - }, - { multi: true } - ) - } while (count > 0) - }, - - /** - * Remove a scope. - * - * Roles assigned with a given scope are removed. - * - * @method removeScopeAsync - * @param {String} name The name of a scope. - * @returns {Promise} - * @static - */ - removeScopeAsync: async function (name) { - Roles._checkScopeName(name) - - await Meteor.roleAssignment.removeAsync({ scope: name }) - }, - - /** - * Throw an exception if `roleName` is an invalid role name. - * - * @method _checkRoleName - * @param {String} roleName A role name to match against. - * @private - * @static - */ - _checkRoleName: function (roleName) { - if ( - !roleName || - typeof roleName !== 'string' || - roleName.trim() !== roleName - ) { - throw new Error(`Invalid role name '${roleName}'.`) - } - }, - - /** - * Find out if a role is an ancestor of another role. - * - * WARNING: If you check this on the client, please make sure all roles are published. - * - * @method isParentOfAsync - * @param {String} parentRoleName The role you want to research. - * @param {String} childRoleName The role you expect to be among the children of parentRoleName. - * @returns {Promise} - * @static - */ - isParentOfAsync: async function (parentRoleName, childRoleName) { - if (parentRoleName === childRoleName) { - return true - } - - if (parentRoleName == null || childRoleName == null) { - return false - } - - Roles._checkRoleName(parentRoleName) - Roles._checkRoleName(childRoleName) - - let rolesToCheck = [parentRoleName] - while (rolesToCheck.length !== 0) { - const roleName = rolesToCheck.pop() - - if (roleName === childRoleName) { - return true - } - - const role = await Meteor.roles.findOneAsync({ _id: roleName }) - - // This should not happen, but this is a problem to address at some other time. - if (!role) continue - - rolesToCheck = rolesToCheck.concat(role.children.map((r) => r._id)) - } - - return false - }, - /** * Normalize options. * diff --git a/packages/roles/tests/serverAsync.js b/packages/roles/tests/serverAsync.js index 1d207adeea..1e844550fc 100644 --- a/packages/roles/tests/serverAsync.js +++ b/packages/roles/tests/serverAsync.js @@ -57,23 +57,23 @@ const sameMembers = (test, value, expected) => { ); }; -const sameDeepMembers = (test, value, expected) => { - // Helper to sort object keys recursively - const sortObjectKeys = (obj) => { - if (Array.isArray(obj)) { - return obj.map(sortObjectKeys); - } - if (obj && typeof obj === "object") { - return Object.keys(obj) - .sort() - .reduce((sorted, key) => { - sorted[key] = sortObjectKeys(obj[key]); - return sorted; - }, {}); - } - return obj; - }; +// Helper to sort object keys recursively +const sortObjectKeys = (obj) => { + if (Array.isArray(obj)) { + return obj.map(sortObjectKeys); + } + if (obj && typeof obj === "object") { + return Object.keys(obj) + .sort() + .reduce((sorted, key) => { + sorted[key] = sortObjectKeys(obj[key]); + return sorted; + }, {}); + } + return obj; +}; +const sameDeepMembers = (test, value, expected) => { const sortedValue = sortObjectKeys(value); const sortedExpected = sortObjectKeys(expected); @@ -84,6 +84,16 @@ const sameDeepMembers = (test, value, expected) => { ); }; +const sameDeepUnorderedMembers = (test, value, expected) => { + const sortAndStringify = (arr) => { + return JSON.stringify(arr.map(item => JSON.stringify(sortObjectKeys(item))).sort()); + }; + const sortedValue = sortAndStringify(value); + const sortedExpected = sortAndStringify(expected); + + test.equal(sortedValue, sortedExpected, 'Arrays should have the same elements, regardless of order'); +}; + const hasProp = (target, prop) => Object.hasOwnProperty.call(target, prop); let users = {}; @@ -1885,7 +1895,7 @@ Tinytest.addAsync("roles -keep assigned roles", async function (test) { anyScope: true, fullObjects: true, }); - sameDeepMembers( + sameDeepUnorderedMembers( test, rolesForUser.map((obj) => { delete obj._id; @@ -1914,7 +1924,7 @@ Tinytest.addAsync("roles -keep assigned roles", async function (test) { anyScope: true, fullObjects: true, }); - sameDeepMembers( + sameDeepUnorderedMembers( test, rolesForUser2.map((obj) => { delete obj._id; @@ -1949,7 +1959,7 @@ Tinytest.addAsync("roles -keep assigned roles", async function (test) { anyScope: true, fullObjects: true, }); - sameDeepMembers( + sameDeepUnorderedMembers( test, rolesForUser3.map((obj) => { delete obj._id; @@ -1973,7 +1983,7 @@ Tinytest.addAsync("roles -keep assigned roles", async function (test) { anyScope: true, fullObjects: true, }); - sameDeepMembers( + sameDeepUnorderedMembers( test, rolesForUser4.map((obj) => { delete obj._id; @@ -2063,7 +2073,7 @@ Tinytest.addAsync( anyScope: true, fullObjects: true, }); - sameDeepMembers( + sameDeepUnorderedMembers( test, usersRoles.map((obj) => { delete obj._id; @@ -2109,7 +2119,7 @@ Tinytest.addAsync( anyScope: true, fullObjects: true, }); - sameDeepMembers( + sameDeepUnorderedMembers( test, usersRoles2.map((obj) => { delete obj._id; @@ -2153,7 +2163,7 @@ Tinytest.addAsync( anyScope: true, fullObjects: true, }); - sameDeepMembers( + sameDeepUnorderedMembers( test, usersRoles3.map((obj) => { delete obj._id; @@ -2203,7 +2213,7 @@ Tinytest.addAsync( anyScope: true, fullObjects: true, }); - sameDeepMembers( + sameDeepUnorderedMembers( test, usersRoles4.map((obj) => { delete obj._id; @@ -2255,7 +2265,7 @@ Tinytest.addAsync( anyScope: true, fullObjects: true, }); - sameDeepMembers( + sameDeepUnorderedMembers( test, usersRoles5.map((obj) => { delete obj._id; @@ -2308,7 +2318,7 @@ Tinytest.addAsync( anyScope: true, fullObjects: true, }); - sameDeepMembers( + sameDeepUnorderedMembers( test, usersRoles6.map((obj) => { delete obj._id; diff --git a/v3-docs/docs/.vitepress/config.mts b/v3-docs/docs/.vitepress/config.mts index a004c2867b..d2a7dcb97b 100644 --- a/v3-docs/docs/.vitepress/config.mts +++ b/v3-docs/docs/.vitepress/config.mts @@ -322,6 +322,10 @@ export default defineConfig({ text: "hot-module-replacement", link: "/packages/hot-module-replacement", }, + { + text: "roles", + link: "/packages/roles", + }, { text: "less", link: "/packages/less", @@ -346,6 +350,10 @@ export default defineConfig({ text: "random", link: "/packages/random", }, + { + text: "react-meteor-data", + link: "/packages/react-meteor-data", + }, { text: "server-render", link: "/packages/server-render", @@ -377,6 +385,17 @@ export default defineConfig({ ], collapsed: true, }, + { + text: "Community Packages", + link: "/community-packages/index", + items: [ + { + text: "Meteor RPC", + link: "/community-packages/meteor-rpc", + }, + ], + collapsed: true, + }, { text: "Troubleshooting", items: [ @@ -385,10 +404,6 @@ export default defineConfig({ link: "/troubleshooting/expired-certificate", }, { text: "Windows", link: "/troubleshooting/windows" }, - { - text: "Known issues in 2.13", - link: "/troubleshooting/known-issues", - }, ], collapsed: true, }, diff --git a/v3-docs/docs/.vitepress/theme/redirects/redirects.json b/v3-docs/docs/.vitepress/theme/redirects/redirects.json index f6dc86f381..d57349da0c 100644 --- a/v3-docs/docs/.vitepress/theme/redirects/redirects.json +++ b/v3-docs/docs/.vitepress/theme/redirects/redirects.json @@ -1,4 +1,5 @@ { "meteor_call": "meteor-call", - "ddp_connect": "DDP-connect" + "ddp_connect": "DDP-connect", + "changelog": "history" } diff --git a/v3-docs/docs/.vitepress/theme/redirects/script.js b/v3-docs/docs/.vitepress/theme/redirects/script.js index 0e7268824c..6721d68b7d 100644 --- a/v3-docs/docs/.vitepress/theme/redirects/script.js +++ b/v3-docs/docs/.vitepress/theme/redirects/script.js @@ -1,18 +1,26 @@ -// import json from './redirects.json'; +import redirects from "./redirects.json"; /** * * @param {string} path */ export const redirect = (path) => { - let shouldRedirect = false; - console.log(path) + const lastPath = path.split("/").pop().split(".")[0]; + if (redirects[lastPath]) { + return { + path: path.replace(lastPath, redirects[lastPath]), + shouldRedirect: true, + }; + } + if (path.includes("_")) { - shouldRedirect = true; - path = path.replace("_", "-"); + return { + path: path.replace("_", "-"), + shouldRedirect: true, + }; } return { path, - shouldRedirect + shouldRedirect: false, }; }; diff --git a/v3-docs/docs/about/install.md b/v3-docs/docs/about/install.md index dd8331d501..1d685e9ec3 100644 --- a/v3-docs/docs/about/install.md +++ b/v3-docs/docs/about/install.md @@ -13,7 +13,7 @@ npx meteor - Meteor currently supports **OS X, Windows, and Linux**. Only 64-bit is supported. - Meteor supports Windows 7 / Windows Server 2008 R2 and up. - Apple M1 is natively supported from Meteor 2.5.1 onward (for older versions, rosetta terminal is required). -- If you are on a Mac M1 (Arm64 version) you need to have Rosetta 2 installed, as Meteor uses it for running MongoDB. Check how to install it [here](https://osxdaily.com/2020/12/04/how-install-rosetta-2-apple-silicon-mac/). +- If you are using Meteor <= 3.0.4 and you are on a Mac M1 (Arm64 version) you need to have Rosetta 2 installed, as Meteor uses it for running MongoDB. Check how to install it [here](https://osxdaily.com/2020/12/04/how-install-rosetta-2-apple-silicon-mac/). *No longer needed in Meteor 3.1*. - Disabling antivirus (Windows Defender, etc.) will improve performance. - For compatibility, Linux binaries are built with CentOS 6.4 i386/amd64. diff --git a/v3-docs/docs/api/packages-listing.md b/v3-docs/docs/api/packages-listing.md index 40d21881ff..c4624c061e 100644 --- a/v3-docs/docs/api/packages-listing.md +++ b/v3-docs/docs/api/packages-listing.md @@ -45,7 +45,6 @@ ### [callback-hook](https://github.com/meteor/meteor/tree/devel/packages/callback-hook) {#callback-hook} ### [check](https://github.com/meteor/meteor/tree/devel/packages/check) {#check} ### [constraint-solver](https://github.com/meteor/meteor/tree/devel/packages/constraint-solver) {#constraint-solver} -### [context](https://github.com/meteor/meteor/tree/devel/packages/context) {#context} ### [core-runtime](https://github.com/meteor/meteor/tree/devel/packages/core-runtime) {#core-runtime} ### [crosswalk](https://github.com/meteor/meteor/tree/devel/packages/crosswalk) {#crosswalk} ### [ddp](https://github.com/meteor/meteor/tree/devel/packages/ddp) {#ddp} diff --git a/v3-docs/docs/community-packages/index.md b/v3-docs/docs/community-packages/index.md new file mode 100644 index 0000000000..ab375c1c93 --- /dev/null +++ b/v3-docs/docs/community-packages/index.md @@ -0,0 +1,29 @@ +# Community Packages + +::: tip +The Meteor community is making great efforts to migrate popular packages to Meteor 3.0. + +A [spreadsheet maintained](https://docs.google.com/spreadsheets/u/0/d/1JbUZmJab3owZ9LV71Ubto32YX_QWQljRypJTOQupxL8/htmlview) by [@harryadel](https://github.com/harryadel) tracks the status of your favorite packages and offers opportunities to help. +::: + +There are some very popular community packages that do not have a documentation website or only have a readme file. +This section tries to list and add some information about usage, configuration, and examples for these packages. + +> Think this section as an `Awesome List`, similar to [`awesome-node`](https://github.com/sindresorhus/awesome-nodejs) or [`awesome-react`](https://github.com/enaqx/awesome-react) + +Many packages have been consolidated into the [Meteor Community organization](https://github.com/Meteor-Community-Packages). Others are maintained by individual developers or companies. + +If you use or have a package that you think would be useful to add to this list, please open a pull request. + +Please bear in mind if you are adding a package to this list, try providing as much information as possible, including: + +- `Who maintains the package` – how to get in touch to submit issues or questions +- `Why is this package for?` +- `API` +- `examples/guide` + +## List of Community Packages + +#### Method/Subscription helpers + +- [`meteor-rpc`](./meteor-rpc.md), Meteor Methods Evolved with type checking and runtime validation diff --git a/v3-docs/docs/community-packages/meteor-rpc.md b/v3-docs/docs/community-packages/meteor-rpc.md new file mode 100644 index 0000000000..683a98be16 --- /dev/null +++ b/v3-docs/docs/community-packages/meteor-rpc.md @@ -0,0 +1,608 @@ +# Meteor-RPC + +- `Who maintains the package` – [Grubba27](https://github.com/Grubba27), you can get in touch via [X](https://twitter.com/gab_grubba) + +[[toc]] + +## What is this package? + +_Inspired on [zodern:relay](https://github.com/zodern/meteor-relay) and on [tRPC](https://trpc.io/)_ + +This package provides functions for building E2E type-safe RPCs focused on React front ends. + +## How to download it? + +::: warning + +This package works only with Meteor 2.8 or higher. + +If you are not sure about the version of Meteor you are using, you can check it by running the following command in your terminal within your project: + +```bash +meteor --version +``` + +::: + +```bash +meteor npm i meteor-rpc @tanstack/react-query zod +``` + +::: warning + +Before continuing the installation, make sure you have `react-query` all set in your project; for more info, follow their [quick start guide](https://tanstack.com/query/latest/docs/framework/react/quick-start). + +::: + +## How to use it? + +There are a few concepts that are important while using this package: + +- This package is built on top of [`Meteor.methods`](../api/meteor.md#method-apis-methods) and [`Meteor.publish`](../api/meteor.md#publish-and-subscribe-pubsub) but with types and runtime validation, their understanding is important to use this package. +- Every method and publication uses `Zod` to validate the arguments, so you can be sure that the data you are receiving is what you expect. + +::: tip +If you are accepting any type of data, you can use `z.any()` as the schema or `z.void` when there is no argument +::: + +### `createModule` + +This function is used to create a module that will be used to call our methods and publications + +`subModule` without a namespace: `createModule()` is used to create the `main` server module, the one that will be exported to be used in the client.` + +`subModule` with a namespace: `createModule("namespace")` is used to create a submodule that will be added to the main module. + +> Remember to use `build` at the end of module creation to ensure that the module will be created. + +Example: + +::: code-group + +```typescript [server/main.ts] +import { createModule } from "meteor-rpc"; +import { Chat } from "./chat"; + +const server = createModule() // server has no namespace + .addMethod("bar", z.string(), (arg) => "bar" as const) + .addSubmodule(Chat) + .build(); + +export type Server = typeof server; +``` + +```typescript [server/chat.ts] +import { createModule } from "meteor-rpc"; +import { ChatCollection } from "/imports/api/chat"; +import { z } from "zod"; + +export const Chat = createModule("chat") + .addMethod("createChat", z.void(), async () => { + return ChatCollection.insertAsync({ createdAt: new Date(), messages: [] }); + }) + .buildSubmodule(); +``` + +```typescript [client/main.ts] +import { createClient } from "meteor-rpc"; +// you must import the type of the server +import type { Server } from "/imports/api/server"; + +const api = createClient(); +const bar: "bar" = await api.bar("some string"); +// ?^ 'bar' +const newChatId = await api.chat.createChat(); // with intellisense +``` + +::: + +### `module.addMethod` + +Type: + +```ts +addMethod( + name: string, + schema: ZodSchema, + handler: (args: ZodTypeInput) => T, + config?: Config, T> +) +``` + +This is the equivalent of `Meteor.methods` but with types and runtime validation. + +::: code-group + +```typescript [server/with-meteor-rpc.ts] +import { createModule } from "meteor-rpc"; +import { z } from "zod"; + +const server = createModule() + .addMethod("foo", z.string(), (arg) => "foo" as const) + .build(); +``` + +```typescript [server/without-meteor-rpc.ts] +import { Meteor } from "meteor/meteor"; +import { z } from "zod"; + +Meteor.methods({ + foo(arg: string) { + z.string().parse(arg); + return "foo"; + }, +}); +``` + +::: + +### `module.addPublication` + +Type: + +```typescript +addPublication( + name: string, + schema: ZodSchema, + handler: (args: ZodTypeInput) => Cursor +) +``` + +This is the equivalent of `Meteor.publish` but with types and runtime validation. + +::: code-group + +```typescript [server/with-meteor-rpc.ts] +import { createModule } from "meteor-rpc"; +import { ChatCollection } from "/imports/api/chat"; +import { z } from "zod"; + +const server = createModule() + .addPublication("chatRooms", z.void(), () => { + return ChatCollection.find(); + }) + .build(); +``` + +```typescript [server/without-meteor-rpc.ts] +import { Meteor } from "meteor/meteor"; +import { ChatCollection } from "/imports/api/chat"; + +Meteor.publish("chatRooms", function () { + return ChatCollection.find(); +}); +``` + +::: + +### `module.addSubmodule` + +This is used to add a submodule to the main module, adding namespaces for your methods and publications and making it easier to organize your code. + +> Remember to use `submodule.buildSubmodule` when creating a submodule + +::: code-group + +```typescript [server/chat.ts] +import { ChatCollection } from "/imports/api/chat"; +import { createModule } from "meteor-rpc"; + +export const chatModule = createModule("chat") + .addMethod("createChat", z.void(), async () => { + return ChatCollection.insertAsync({ createdAt: new Date(), messages: [] }); + }) + .buildSubmodule(); // <-- This is important so that this module can be added as a submodule +``` + +```typescript [server/chat.ts] +import { createModule } from "meteor-rpc"; +import { chatModule } from "./server/chat"; + +const server = createModule() + .addMethod("bar", z.string(), (arg) => "bar" as const) + .addSubmodule(chatModule) + .build(); + +server.chat; // <-- this is the namespace for the chat module +server.chat.createChat(); // <-- this is the method from the chat module and it gets autocompleted +``` + +::: + +### `module.addMiddlewares` + +Type: + +```typescript +type Middleware = (raw: unknown, parsed: unknown) => void; + +addMiddlewares(middlewares: Middleware[]) +``` + +This is used to add middleware to the module; it should be used to add side effects logic to the methods and publications, which is ideal for logging or rate limiting. + +The middleware ordering is last in, first out. Check the example below: + +::: code-group + +```typescript [server/chat.ts] +import { ChatCollection } from "/imports/api/chat"; +import { createModule } from "meteor-rpc"; + +export const chatModule = createModule("chat") + .addMiddlewares([ + (raw, parsed) => { + console.log("runs first"); + }, + ]) + .addMethod("createChat", z.void(), async () => { + return ChatCollection.insertAsync({ createdAt: new Date(), messages: [] }); + }) + .buildSubmodule(); +``` + +```typescript [server/main.ts] +import { createModule } from "meteor-rpc"; +import { chatModule } from "./server/chat"; + +const server = createModule() + .addMiddlewares([ + (raw, parsed) => { + console.log("runs second"); + }, + ]) + .addMethod("bar", z.string(), (arg) => "bar" as const) + .addSubmodule(chatModule) + .build(); +``` + +```typescript [client/main.ts] +import { createClient } from "meteor-rpc"; +import type { Server } from "/imports/api/server"; // you must import the type + +const api = createClient(); +await api.chat.createChat(); // logs "runs first" then "runs second" +await api.bar("str"); // logs "runs second" +``` + +::: + +### `module.build` + +This is used to build the module, it should be used at the end of the module creation to ensure that the exported type is correct. + +::: code-group + +```typescript [correct.ts] +// ✅ it has the build method +import { createModule } from "meteor-rpc"; +import { z } from "zod"; +const server = createModule() + .addMethod("bar", z.string(), (arg) => "bar" as const) + .build(); + +export type Server = typeof server; +``` + +```typescript [incorrect.ts] +// ❌ it is missing the build method +import { createModule } from "meteor-rpc"; +import { z } from "zod"; +const server = createModule().addMethod( + "bar", + z.string(), + (arg) => "bar" as const +); + +export type Server = typeof server; +``` + +::: + +### `module.buildSubmodule` + +This is used to build the submodule, it should be used at the end of the submodule creation and imported in the main module in the [`addSubmodule`](./meteor-rpc.md#module-addsubmodule) method. + +::: code-group + +```typescript [correct.ts] +import { createModule } from "meteor-rpc"; +import { z } from "zod"; + +export const chatModule = createModule("chat") + .addMethod("createChat", z.void(), async () => { + return "chat" as const; + }) + // ✅ it has the buildSubmodule method + .buildSubmodule(); +``` + +```typescript [incorrect.ts] +import { createModule } from "meteor-rpc"; +import { z } from "zod"; + +export const otherSubmodule = createModule("other") + .addMethod("otherMethod", z.void(), async () => { + return "other" as const; + }) + // ❌ it is missing the buildSubmodule method + .build(); + +export const otherSubmodule = createModule("other").addMethod( + "otherMethod", + z.void(), + async () => { + return "other" as const; + } +); // ❌ it is missing the buildSubmodule method +``` + +```typescript [server/main.ts] +import { createModule } from "meteor-rpc"; +import { chatModule } from "./server/chat"; + +const server = createModule() + .addMethod("bar", z.string(), (arg) => "bar" as const) + .addSubmodule(chatModule) + .build(); +``` + +::: + +## Using in the client + +When using in the client, you _have_ to use the `createModule` and `build` methods to create a module that will be used in the client +and be sure that you are exporting the type of the module + +_You should only create one client in your application_ + +You can have something like `api.ts` that will export the client and the type of the client + +::: code-group + +```typescript [server/main.ts] +import { createModule } from "meteor-rpc"; + +const server = createModule() + .addMethod("bar", z.string(), (arg) => "bar" as const) + .build(); + +export type Server = typeof server; +``` + +```typescript [client/main.ts] +// you must import the type +import type { Server } from "/imports/api/server"; +const app = createClient(); + +await app.bar("str"); // it will return "bar" +``` + +::: + +## React focused API + +Our package has a React-focused API that uses `react-query` to handle the data fetching and mutations. + +### `method.useMutation` + +It uses the [`useMutation`](https://tanstack.com/query/latest/docs/framework/react/reference/useMutation#usemutation) from react-query to create a mutation that will call the method + +::: code-group + +```typescript [server/main.ts] +import { createModule } from "meteor-rpc"; + +const server = createModule() + .addMethod("bar", z.string(), (arg) => { + console.log("Server received", arg); + return "bar" as const; + }) + .build(); + +export type Server = typeof server; +``` + +```tsx [client.ts] +// you must import the type +import type { Server } from "/imports/api/server"; +const app = createClient(); + +export const Component = () => { + const { mutate, isLoading, isError, error, data } = app.bar.useMutation(); + + return ( + + ); +}; +``` + +::: + +### `method.useQuery` + +It uses the [`useQuery`](https://tanstack.com/query/latest/docs/framework/react/reference/useSuspenseQuery#usesuspensequery) from react-query to create a query that will call the method, it uses `suspense` to handle loading states + +::: code-group + +```typescript [server/main.ts] +import { createModule } from "meteor-rpc"; + +const server = createModule() + .addMethod("bar", z.string(), (arg) => "bar" as const) + .build(); + +export type Server = typeof server; +``` + +```tsx [client.ts] +// you must import the type of the server +import type { Server } from "/imports/api/server"; +const app = createClient(); + +export const Component = () => { + const { data } = app.bar.useQuery("str"); // this will trigger suspense + + return
{data}
; +}; +``` + +::: + +### `publication.useSubscription` + +Subscriptions on the client have `useSubscription` method that can be used as a hook to subscribe to a publication. It uses `suspense` to handle loading states + +::: code-group + +```typescript [server/main.ts] +// server/main.ts +import { createModule } from "meteor-rpc"; +import { ChatCollection } from "/imports/api/chat"; +import { z } from "zod"; + +const server = createModule() + .addPublication("chatRooms", z.void(), () => { + return ChatCollection.find(); + }) + .build(); + +export type Server = typeof server; +``` + +```tsx [client.ts] +import type { Server } from "/imports/api/server"; // you must import the type +const app = createClient(); + +export const Component = () => { + // it will trigger suspense and `rooms` is reactive in this context. + // When there is a change in the collection it will rerender + const { data: rooms, collection: chatCollection } = + api.chatRooms.usePublication(); + + return ( +
+ {rooms.map((room) => ( +
{room.name}
+ ))} +
+ ); +}; +``` + +::: + +## Examples + +Currently, we have: + +- [chat-app](https://github.com/Grubba27/testing-meteor-rpc) that uses this package to create a chat-app +- [askme](https://github.com/fredmaiaarantes/askme) that uses this package to create a Q&A app, you can check it live [here](https://askmeaquestion.meteorapp.com/) + +## Advanced usage + +You can take advantage of the hooks to add custom logic to your methods, checking the raw and parsed data and the result of the method, +If the method fails, you can also check the error. + +::: code-group + +```typescript [on-method-after-creation.ts] +import { createModule } from "meteor-rpc"; +import { z } from "zod"; + +const server = createModule() + .addMethod("bar", z.string(), (arg) => "bar" as const) + .build(); + +// you can add hooks after the method has been created +server.bar.addBeforeResolveHook((raw, parsed) => { + console.log("before resolve", raw, parsed); +}); + +server.bar.addAfterResolveHook((raw, parsed, result) => { + console.log("after resolve", raw, parsed, result); +}); + +server.bar.addErrorResolveHook((err, raw, parsed) => { + console.log("on error", err, raw, parsed); +}); + +export type Server = typeof server; +``` + +```typescript [on-method-creation.ts] +import { createModule } from "meteor-rpc"; +import { z } from "zod"; + +const server = createModule() + // Or you can add hooks when creating the method + .addMethod("bar", z.any(), () => "str", { + hooks: { + onBeforeResolve: [ + (raw, parsed) => { + console.log("before resolve", raw, parsed); + }, + ], + onAfterResolve: [ + (raw, parsed, result) => { + console.log("after resolve", raw, parsed, result); + }, + ], + onErrorResolve: [ + (err, raw, parsed) => { + console.log("on error", err, raw, parsed); + }, + ], + }, + }) + .build(); + +export type Server = typeof server; +``` + +::: + +## Known issues + +if you are getting a similar error like this one: + +```text + +=> Started MongoDB. +Typescript processing requested for web.browser using Typescript 5.7.2 +Creating new Typescript watcher for /app +Starting compilation in watch mode... +Compiling server/chat/model.ts +Compiling server/chat/module.ts +Compiling server/main.ts +Writing .meteor/local/plugin-cache/refapp_meteor-typescript/0.5.6/v2cache/buildfile.tsbuildinfo +Compilation finished in 0.3 seconds. 3 files were (re)compiled. +did not find /app/.meteor/local/plugin-cache/refapp_meteor-typescript/0.5.6/v2cache/out/client/main.js +did not find /app/.meteor/local/plugin-cache/refapp_meteor-typescript/0.5.6/v2cache/out/client/main.js +Nothing emitted for client/main.tsx +node:internal/crypto/hash:115 + throw new ERR_INVALID_ARG_TYPE( + ^ + +TypeError [ERR_INVALID_ARG_TYPE]: The "data" argument must be of type string or an instance of Buffer, TypedArray, or DataView. Received null + at Hash.update (node:internal/crypto/hash:115:11) + at /Users/user/.meteor/packages/meteor-tool/.3.0.4.1tddsze.as7rh++os.osx.arm64+web.browser+web.browser.legacy+web.cordova/mt-os.osx.arm64/tools/fs/tools/fs/watch.ts:329:28 + at Array.forEach () + at /Users/user/.meteor/packages/meteor-tool/.3.0.4.1tddsze.as7rh++os.osx.arm64+web.browser+web.browser.legacy+web.cordova/mt-os.osx.arm64/tools/fs/tools/fs/watch.ts:329:8 + at JsOutputResource._get (/tools/isobuild/compiler-plugin.js:1002:19) { + code: 'ERR_INVALID_ARG_TYPE' +} + +Node.js v20.18.0 +``` + +Please check if you are using `refapp:meteor-typescript` package, if so, you can remove it and use the `typescript` package instead. +The `refapp:meteor-typescript` package is currently incompatible with the `meteor-rpc` package. + +If it is still not working, please open an issue in the [repo](https://github.com/Grubba27/meteor-rpc) diff --git a/v3-docs/docs/packages/community-packages.md b/v3-docs/docs/packages/community-packages.md deleted file mode 100644 index 626a7e871e..0000000000 --- a/v3-docs/docs/packages/community-packages.md +++ /dev/null @@ -1,17 +0,0 @@ - - -[//]: # (Do not edit this file by hand.) - -[//]: # (This is a generated file.) - -[//]: # (If you want to change something in this file) - -[//]: # (go to meteor/docs/generators/packages-listing) - -# Community Packages - -The Meteor community is making great efforts to migrate popular packages to Meteor 3.0. - -Many packages have been consolidated into the [Meteor Community organization](https://github.com/Meteor-Community-Packages). Others are maintained by individual developers or companies. - -A [spreadsheet maintained](https://docs.google.com/spreadsheets/u/0/d/1JbUZmJab3owZ9LV71Ubto32YX_QWQljRypJTOQupxL8/htmlview) by [@harryadel](https://github.com/harryadel) tracks the status of your favorite packages and offers opportunities to help. diff --git a/v3-docs/docs/packages/react-meteor-data.md b/v3-docs/docs/packages/react-meteor-data.md new file mode 100644 index 0000000000..50717c82b1 --- /dev/null +++ b/v3-docs/docs/packages/react-meteor-data.md @@ -0,0 +1,491 @@ +# react-meteor-data + +This package provides an integration between React and [`Tracker`](https://atmospherejs.com/meteor/tracker), Meteor's reactive data system. + +## Table of Contents + +[[toc]] + +## Install + +::: tip + +This package is included with `meteor create` on react options. No need to install it manually. + +::: + +To install the package, use `meteor add`: + +```bash +meteor add react-meteor-data +``` + +You'll also need to install `react` if you have not already: + +```bash +meteor npm install react +``` + +### Changelog + +[check recent changes here](https://github.com/meteor/react-packages/blob/master/packages/react-meteor-data/CHANGELOG.md) + +## Usage + +This package provides two ways to use Tracker reactive data in your React components: + +- a hook: `useTracker` (v2 only, requires React `^16.8`) +- a higher-order component (HOC): `withTracker` (v1 and v2). + +The `useTracker` hook, introduced in version 2.0.0, embraces the [benefits of hooks](https://reactjs.org/docs/hooks-faq.html). Like all React hooks, it can only be used in function components, not in class components. + +The `withTracker` HOC can be used with all components, function or class based. + +It is not necessary to rewrite existing applications to use the `useTracker` hook instead of the existing `withTracker` HOC. + +### `useTracker(reactiveFn)` + +You can use the `useTracker` hook to get the value of a Tracker reactive function in your React "function components." The reactive function will get re-run whenever its reactive inputs change, and the component will re-render with the new value. + +`useTracker` manages its own state, and causes re-renders when necessary. There is no need to call React state setters from inside your `reactiveFn`. Instead, return the values from your `reactiveFn` and assign those to variables directly. When the `reactiveFn` updates, the variables will be updated, and the React component will re-render. + +Arguments: + +- `reactiveFn`: A Tracker reactive function (receives the current computation). + +The basic way to use `useTracker` is to simply pass it a reactive function, with no further fuss. This is the preferred configuration in many cases. + +#### `useTracker(reactiveFn, deps)` + +You can pass an optional deps array as a second value. When provided, the computation will be retained, and reactive updates after the first run will run asynchronously from the react render execution frame. This array typically includes all variables from the outer scope "captured" in the closure passed as the 1st argument. For example, the value of a prop used in a subscription or a minimongo query; see example below. + +This should be considered a low level optimization step for cases where your computations are somewhat long running - like a complex minimongo query. In many cases it's safe and even preferred to omit deps and allow the computation to run synchronously with render. + +Arguments: + +- `reactiveFn` +- `deps`: An optional array of "dependencies" of the reactive function. This is very similar to how the `deps` argument for [React's built-in `useEffect`, `useCallback` or `useMemo` hooks](https://reactjs.org/docs/hooks-reference.html) work. + +```jsx +import { useTracker } from 'meteor/react-meteor-data'; + +// React function component. +function Foo({ listId }) { + // This computation uses no value from the outer scope, + // and thus does not needs to pass a 'deps' argument. + // However, we can optimize the use of the computation + // by providing an empty deps array. With it, the + // computation will be retained instead of torn down and + // rebuilt on every render. useTracker will produce the + // same results either way. + const currentUser = useTracker(() => Meteor.user(), []); + + // The following two computations both depend on the + // listId prop. When deps are specified, the computation + // will be retained. + const listLoading = useTracker(() => { + // Note that this subscription will get cleaned up + // when your component is unmounted or deps change. + const handle = Meteor.subscribe('todoList', listId); + return !handle.ready(); + }, [listId]); + const tasks = useTracker(() => Tasks.find({ listId }).fetch(), [listId]); + + return ( +

Hello {currentUser.username}

+ {listLoading ? ( +
Loading
+ ) : ( +
+ Here is the Todo list {listId}: +
    + {tasks.map(task => ( +
  • {task.label}
  • + ))} +
+
+ )} + ); +} +``` + +**Note:** the [eslint-plugin-react-hooks](https://www.npmjs.com/package/eslint-plugin-react-hooks) package provides ESLint hints to help detect missing values in the `deps` argument of React built-in hooks. It can be configured to also validate the `deps` argument of the `useTracker` hook or some other hooks, with the following `eslintrc` config: + +```json +"react-hooks/exhaustive-deps": ["warn", { "additionalHooks": "useTracker|useSomeOtherHook|..." }] +``` + +#### `useTracker(reactiveFn, deps, skipUpdate)` or `useTracker(reactiveFn, skipUpdate)` + +You may optionally pass a function as a second or third argument. The `skipUpdate` function can evaluate the return value of `reactiveFn` for changes, and control re-renders in sensitive cases. _Note:_ This is not meant to be used with a deep compare (even fast-deep-equals), as in many cases that may actually lead to worse performance than allowing React to do it's thing. But as an example, you could use this to compare an `updatedAt` field between updates, or a subset of specific fields, if you aren't using the entire document in a subscription. As always with any optimization, measure first, then optimize second. Make sure you really need this before implementing it. + +Arguments: + +- `reactiveFn` +- `deps?` - optional - you may omit this, or pass a "falsy" value. +- `skipUpdate` - A function which receives two arguments: `(prev, next) => (prev === next)`. `prev` and `next` will match the type or data shape as that returned by `reactiveFn`. Note: A return value of `true` means the update will be "skipped". `false` means re-render will occur as normal. So the function should be looking for equivalence. + +```jsx +import { useTracker } from 'meteor/react-meteor-data'; + +// React function component. +function Foo({ listId }) { + const tasks = useTracker( + () => Tasks.find({ listId }).fetch(), [listId], + (prev, next) => { + // prev and next will match the type returned by the reactiveFn + return prev.every((doc, i) => ( + doc._id === next[i] && doc.updatedAt === next[i] + )) && prev.length === next.length; + } + ); + + return ( +

Hello {currentUser.username}

+
+ Here is the Todo list {listId}: +
    + {tasks.map(task => ( +
  • {task.label}
  • + ))} +
+
+ ); +} +``` + +### `withTracker(reactiveFn)` + +You can use the `withTracker` HOC to wrap your components and pass them additional props values from a Tracker reactive function. The reactive function will get re-run whenever its reactive inputs change, and the wrapped component will re-render with the new values for the additional props. + +Arguments: + +- `reactiveFn`: a Tracker reactive function, getting the props as a parameter, and returning an object of additional props to pass to the wrapped component. + +```jsx +import { withTracker } from 'meteor/react-meteor-data'; + +// React component (function or class). +function Foo({ listId, currentUser, listLoading, tasks }) { + return ( +

Hello {currentUser.username}

+ {listLoading ? +
Loading
: +
+ Here is the Todo list {listId}: +
    {tasks.map(task =>
  • {task.label}
  • )}
+ { + // Do all your reactive data access in this function. + // Note that this subscription will get cleaned up when your component is unmounted + const handle = Meteor.subscribe('todoList', listId); + + return { + currentUser: Meteor.user(), + listLoading: !handle.ready(), + tasks: Tasks.find({ listId }).fetch(), + }; +})(Foo); +``` + +The returned component will, when rendered, render `Foo` (the "lower-order" component) with its provided props in addition to the result of the reactive function. So `Foo` will receive `{ listId }` (provided by its parent) as well as `{ currentUser, listLoading, tasks }` (added by the `withTracker` HOC). + +For more information, see the [React article](http://guide.meteor.com/react.html) in the Meteor Guide. + +### `withTracker({ reactiveFn, pure, skipUpdate })` + +The `withTracker` HOC can receive a config object instead of a simple reactive function. + +- `getMeteorData` - The `reactiveFn`. +- `pure` - `true` by default. Causes the resulting Container to be wrapped with React's `memo()`. +- `skipUpdate` - A function which receives two arguments: `(prev, next) => (prev === next)`. `prev` and `next` will match the type or data shape as that returned by `reactiveFn`. Note: A return value of `true` means the update will be "skipped". `false` means re-render will occur as normal. So the function should be looking for equivalence. + +```jsx +import { withTracker } from 'meteor/react-meteor-data'; + +// React component (function or class). +function Foo({ listId, currentUser, listLoading, tasks }) { + return ( +

Hello {currentUser.username}

+ {listLoading ? +
Loading
: +
+ Here is the Todo list {listId}: +
    {tasks.map(task =>
  • {task.label}
  • )}
+ ( + doc._id === next[i] && doc.updatedAt === next[i] + )) + && prev.tasks.length === next.tasks.length + ); + } +})(Foo); +``` + +### `useSubscribe(subName, ...args)` + +`useSubscribe` is a convenient short hand for setting up a subscription. It is particularly useful when working with `useFind`, which should NOT be used for setting up subscriptions. At its core, it is a very simple wrapper around `useTracker` (with no deps) to create the subscription in a safe way, and allows you to avoid some of the ceremony around defining a factory and defining deps. Just pass the name of your subscription, and your arguments. + +`useSubscribe` returns an `isLoading` function. You can call `isLoading()` to react to changes in the subscription's loading state. The `isLoading` function will both return the loading state of the subscription, and set up a reactivity for the loading state change. If you don't call this function, no re-render will occur when the loading state changes. + +```jsx +// Note: isLoading is a function! +const isLoading = useSubscribe("posts", groupId); +const posts = useFind(() => Posts.find({ groupId }), [groupId]); + +if (isLoading()) { + return ; +} else { + return ( +
    + {posts.map((post) => ( +
  • {post.title}
  • + ))} +
+ ); +} +``` + +If you want to conditionally subscribe, you can set the `name` field (the first argument) to a falsy value to bypass the subscription. + +```jsx +const needsData = false; +const isLoading = useSubscribe(needsData ? "my-pub" : null); + +// When a subscription is not used, isLoading() will always return false +``` + +### `useFind(cursorFactory, deps)` + +The `useFind` hook can substantially speed up the rendering (and rerendering) of lists coming from mongo queries (subscriptions). It does this by controlling document object references. By providing a highly tailored cursor management within the hook, using the `Cursor.observe` API, `useFind` carefully updates only the object references changed during a DDP update. This approach allows a tighter use of core React tools and philosophies to turbo charge your list renders. It is a very different approach from the more general purpose `useTracker`, and it requires a bit more set up. A notable difference is that you should NOT call `.fetch()`. `useFind` requires its factory to return a `Mongo.Cursor` object. You may also return `null`, if you want to conditionally set up the Cursor. + +Here is an example in code: + +```jsx +import React, { memo } from "react"; +import { useFind } from "meteor/react-meteor-data"; +import TestDocs from "/imports/api/collections/TestDocs"; + +// Memoize the list item +const ListItem = memo(({ doc }) => { + return ( +
  • + {doc.id},{doc.updated} +
  • + ); +}); + +const Test = () => { + const docs = useFind(() => TestDocs.find(), []); + return ( +
      + {docs.map((doc) => ( + + ))} +
    + ); +}; + +// Later on, update a single document - notice only that single component is updated in the DOM +TestDocs.update({ id: 2 }, { $inc: { someProp: 1 } }); +``` + +If you want to conditionally call the find method based on some props configuration or anything else, return `null` from the factory. + +```jsx +const docs = useFind(() => { + if (props.skip) { + return null; + } + return TestDocs.find(); +}, []); +``` + +### Concurrent Mode, Suspense and Error Boundaries + +There are some additional considerations to keep in mind when using Concurrent Mode, Suspense and Error Boundaries, as +each of these can cause React to cancel and discard (toss) a render, including the result of the first run of your +reactive function. One of the things React developers often stress is that we should not create "side-effects" directly +in the render method or in functional components. There are a number of good reasons for this, including allowing the +React runtime to cancel renders. Limiting the use of side-effects allows features such as concurrent mode, suspense and +error boundaries to work deterministically, without leaking memory or creating rogue processes. Care should be taken to +avoid side effects in your reactive function for these reasons. (Note: this caution does not apply to Meteor specific +side-effects like subscriptions, since those will be automatically cleaned up when `useTracker`'s computation is +disposed.) + +Ideally, side-effects such as creating a Meteor computation would be done in `useEffect`. However, this is problematic +for Meteor, which mixes an initial data query with setting up the computation to watch those data sources all in one +initial run. If we wait to do that in `useEffect`, we'll end up rendering a minimum of 2 times (and using hacks for the +first one) for every component which uses `useTracker` or `withTracker`, or not running at all in the initial render and +still requiring a minimum of 2 renders, and complicating the API. + +To work around this and keep things running fast, we are creating the computation in the render method directly, and +doing a number of checks later in `useEffect` to make sure we keep that computation fresh and everything up to date, +while also making sure to clean things up if we detect the render has been tossed. For the most part, this should all be +transparent. + +The important thing to understand is that your reactive function can be initially called more than once for a single +render, because sometimes the work will be tossed. Additionally, `useTracker` will not call your reactive function +reactively until the render is committed (until `useEffect` runs). If you have a particularly fast changing data source, +this is worth understanding. With this very short possible suspension, there are checks in place to make sure the +eventual result is always up to date with the current state of the reactive function. Once the render is "committed", +and the component mounted, the computation is kept running, and everything will run as expected. + +## Suspendable version of hooks + +### `useTracker` + +This is a version of `useTracker` that can be used with React Suspense. + +For its first argument, a key is necessary, witch is used to identify the computation and to avoid recreating it when the +component is re-rendered. + +Its second argument is a function that can be async and reactive, +this argument works similar to the original `useTracker` that does not suspend. + +For its _optional_ third argument, the dependency array, works similar to the `useTracker` that does not suspend, +you pass in an array of variables that this tracking function depends upon. + +For its _optional_ fourth argument, the options object, works similar to the `useTracker` that does not suspend, +you pass in a function for when should skip the update. + +```jsx +import { useTracker } from "meteor/react-meteor-data/suspense"; +import { useSubscribe } from "meteor/react-meteor-data/suspense"; + +function Tasks() { + // this component will suspend + useSubscribe("tasks"); + const { username } = useTracker("user", () => Meteor.user()); // Meteor.user() is async meteor 3.0 + const tasksByUser = useTracker( + "tasksByUser", + () => + TasksCollection.find( + { username }, + { sort: { createdAt: -1 } } + ).fetchAsync() // async call + ); + + // render the tasks +} +``` + +### Maintaining the reactive context + +To maintain a reactive context using the new Meteor Async methods, we are using the new `Tracker.withComputation` API to maintain the reactive context of an +async call, this is needed because otherwise it would be only called once, and the computation would never run again, +this way, every time we have a new Link being added, this useTracker is ran. + +```jsx +// needs Tracker.withComputation because otherwise it would be only called once, and the computation would never run again +const docs = useTracker("name", async (c) => { + const placeholders = await fetch( + "https://jsonplaceholder.typicode.com/todos" + ).then((x) => x.json()); + console.log(placeholders); + return await Tracker.withComputation(c, () => + LinksCollection.find().fetchAsync() + ); +}); +``` + +A rule of thumb is that if you are using a reactive function for example `find` + `fetchAsync`, it is nice to wrap it +inside `Tracker.withComputation` to make sure that the computation is kept alive, if you are just calling that function +that is not necessary, like the one bellow, will be always reactive. + +```jsx +const docs = useTracker("name", () => LinksCollection.find().fetchAsync()); +``` + +### `useSubscribe` + +This is a version of `useSubscribe` that can be used with React Suspense. +It is similar to `useSubscribe`, it throws a promise and suspends the rendering until the promise is resolved. +It does not return a Meteor Handle to control the subscription + +```jsx +import { useTracker } from "meteor/react-meteor-data/suspense"; +import { useSubscribe } from "meteor/react-meteor-data/suspense"; + +function Tasks() { + // this component will suspend + useSubscribe("tasks"); + const { username } = useTracker("user", () => Meteor.user()); // Meteor.user() is async meteor 3.0 + const tasksByUser = useTracker( + "tasksByUser", + () => + TasksCollection.find( + { username }, + { sort: { createdAt: -1 } } + ).fetchAsync() // async call + ); + + // render the tasks +} +``` + +### `useFind` + +This is a version of `useFind` that can be used with React Suspense. +It has a few differences from the `useFind` without suspense, it throws a promise and suspends the rendering until the promise is resolved. +It returns the result and it is reactive. +You should pass as the first parameter the collection where is being searched upon and as the second parameter an array with the arguments, +the same arguments that you would pass to the `find` method of the collection, third parameter is optional, and it is dependency array object. +It's meant for the SSR, you don't have to use it if you're not interested in SSR. + +```jsx +import { useFind } from "meteor/react-meteor-data/suspense"; +import { useSubscribe } from "meteor/react-meteor-data/suspense"; + +function Tasks() { + // this component will suspend + useSubscribe("tasks"); + const tasksByUser = useFind(TasksCollection, [ + {}, + { sort: { createdAt: -1 } }, + ]); + + // render the tasks +} +``` + +## Version compatibility notes + +- `react-meteor-data` v2.x : + + - `useTracker` hook + `withTracker` HOC + - Requires React `^16.8`. + - Implementation is compatible with "React Suspense", concurrent mode and error boundaries. + - The `withTracker` HOC is strictly backwards-compatible with the one provided in v1.x, the major version number is only motivated by the bump of React version requirement. Provided a compatible React version, existing Meteor apps leveraging the `withTracker` HOC can freely upgrade from v1.x to v2.x, and gain compatibility with future React versions. + - The previously deprecated `createContainer` has been removed. + +- `react-meteor-data` v0.x : + - `withTracker` HOC (+ `createContainer`, kept for backwards compatibility with early v0.x releases) + - Requires React `^15.3` or `^16.0`. + - Implementation relies on React lifecycle methods (`componentWillMount` / `componentWillUpdate`) that are [marked for deprecation in future React versions](https://reactjs.org/blog/2018/03/29/react-v-16-3.html#component-lifecycle-changes) ("React Suspense"). diff --git a/v3-docs/docs/packages/roles.md b/v3-docs/docs/packages/roles.md new file mode 100644 index 0000000000..4461c2dbec --- /dev/null +++ b/v3-docs/docs/packages/roles.md @@ -0,0 +1,480 @@ +# Roles + +Authorization package for Meteor - compatible with built-in accounts package. + +> Available since Meteor 3.1.0 (previously alanning:roles) + +## Installation + +To add roles to your application, run this command in your terminal: + +```bash +meteor add roles +``` + +## Overview + +The roles package lets you attach roles to users and then check against those roles when deciding whether to grant access to Meteor methods or publish data. The core concept is simple - you create role assignments for users and then verify those roles later. This package provides helper methods to make the process of adding, removing, and verifying roles easier. + +## Concepts + +### Roles vs Permissions + +Although named "roles", you can define your **roles**, **scopes** or **permissions** however you like. They are essentially tags assigned to users that you can check later. + +You can have traditional roles like `admin` or `webmaster`, or more granular permissions like `view-secrets`, `users.view`, or `users.manage`. Often, more granular permissions are better as they handle edge cases without creating many higher-level roles. + +### Role Hierarchy + +Roles can be organized in a hierarchy: + +- Roles can have multiple parents and children (subroles) +- If a parent role is assigned to a user, all its descendant roles also apply +- This allows creating "super roles" that aggregate permissions + +Example hierarchy setup: + +```js +import { Roles } from "meteor/roles"; + +// Create base roles +await Roles.createRoleAsync("user"); +await Roles.createRoleAsync("admin"); + +// Create permission roles +await Roles.createRoleAsync("USERS_VIEW"); +await Roles.createRoleAsync("POST_EDIT"); + +// Set up hierarchy +await Roles.addRolesToParentAsync("USERS_VIEW", "admin"); +await Roles.addRolesToParentAsync("POST_EDIT", "admin"); +await Roles.addRolesToParentAsync("POST_EDIT", "user"); +``` + +### Scopes + +Scopes allow users to have independent sets of roles. Use cases include: + +- Different communities within your app +- Multiple tenants in a multi-tenant application +- Different resource groups + +Users can have both scoped roles and global roles: + +- Global roles apply across all scopes +- Scoped roles only apply within their specific scope +- Scopes are independent of each other + +Example using scopes: + +```js +// Assign scoped roles +await Roles.addUsersToRolesAsync(userId, ["manage-team"], "team-a"); +await Roles.addUsersToRolesAsync(userId, ["player"], "team-b"); + +// Check scoped roles +await Roles.userIsInRoleAsync(userId, "manage-team", "team-a"); // true +await Roles.userIsInRoleAsync(userId, "manage-team", "team-b"); // false + +// Assign global role +await Roles.addUsersToRolesAsync(userId, "super-admin", null); + +// Global roles work in all scopes +await Roles.userIsInRoleAsync(userId, ["manage-team", "super-admin"], "team-b"); // true +``` + +## Role Management + + + +Example: + +```js +import { Roles } from "meteor/roles"; + +// Create a new role +await Roles.createRoleAsync("admin"); + +// Create if doesn't exist +await Roles.createRoleAsync("editor", { unlessExists: true }); +``` + +### Modifying Roles + + + +Example: + +```js +// Make 'editor' a child role of 'admin' +await Roles.addRolesToParentAsync("editor", "admin"); + +// Add multiple child roles +await Roles.addRolesToParentAsync(["editor", "moderator"], "admin"); +``` + + + +Example: + +```js +// Remove 'editor' as child role of 'admin' +await Roles.removeRolesFromParentAsync("editor", "admin"); +``` + + + +Example: + +```js +// Delete role and all its assignments +await Roles.deleteRoleAsync("temp-role"); +``` + + + +Example: + +```js +// Rename an existing role +await Roles.renameRoleAsync("editor", "content-editor"); +``` + +### Assigning Roles + + + +Example: + +```js +// Add global roles +await Roles.addUsersToRolesAsync(userId, ["admin", "editor"]); + +// Add scoped roles +await Roles.addUsersToRolesAsync(userId, ["manager"], "department-a"); + +// Add roles to multiple users +await Roles.addUsersToRolesAsync([user1Id, user2Id], ["user"]); +``` + + + +Example: + +```js +// Replace user's global roles +await Roles.setUserRolesAsync(userId, ["editor"]); + +// Replace scoped roles +await Roles.setUserRolesAsync(userId, ["viewer"], "project-x"); + +// Clear all roles in scope +await Roles.setUserRolesAsync(userId, [], "project-x"); +``` + + + +Example: + +```js +// Remove global roles +await Roles.removeUsersFromRolesAsync(userId, ["admin"]); + +// Remove scoped roles +await Roles.removeUsersFromRolesAsync(userId, ["manager"], "department-a"); + +// Remove roles from multiple users +await Roles.removeUsersFromRolesAsync([user1Id, user2Id], ["temp-role"]); +``` + + + +Example: + +```js +// Rename a scope +await Roles.renameScopeAsync("department-1", "marketing"); +``` + + + +Example: + +```js +// Remove a scope and all its role assignments +await Roles.removeScopeAsync("old-department"); +``` + + + +Example: + +```js +// Get all roles sorted by name +const roles = Roles.getAllRoles({ sort: { _id: 1 } }); + +// Get roles with custom query +const customRoles = Roles.getAllRoles({ + fields: { _id: 1, children: 1 }, + sort: { _id: -1 }, +}); +``` + + + +Example: + +```js +// Find all admin users +const adminUsers = await Roles.getUsersInRoleAsync("admin"); + +// Find users with specific roles in a scope +const scopedUsers = await Roles.getUsersInRoleAsync( + ["editor", "writer"], + "blog" +); + +// Find users with custom options +const users = await Roles.getUsersInRoleAsync("manager", { + scope: "department-a", + queryOptions: { + sort: { createdAt: -1 }, + limit: 10, + }, +}); +``` + +## Checking Roles + + + +Example: + +```js +// Check global role +const isAdmin = await Roles.userIsInRoleAsync(userId, "admin"); + +// Check any of multiple roles +const canEdit = await Roles.userIsInRoleAsync(userId, ["editor", "admin"]); + +// Check scoped role +const isManager = await Roles.userIsInRoleAsync( + userId, + "manager", + "department-a" +); + +// Check role in any scope +const hasRole = await Roles.userIsInRoleAsync(userId, "viewer", { + anyScope: true, +}); +``` + + + +Example: + +```js +// Get user's global roles +const globalRoles = await Roles.getRolesForUserAsync(userId); + +// Get scoped roles +const deptRoles = await Roles.getRolesForUserAsync(userId, "department-a"); + +// Get all roles including inherited +const allRoles = await Roles.getRolesForUserAsync(userId, { + anyScope: true, + fullObjects: true, +}); +``` + + + +Example: + +```js +// Check if admin is a parent of editor +const isParent = await Roles.isParentOfAsync("admin", "editor"); + +// Can be used to check inheritance chains +const hasPermission = await Roles.isParentOfAsync("super-admin", "post-edit"); +``` + + + +Example: + +```js +// Get all scopes for user +const allScopes = await Roles.getScopesForUserAsync(userId); + +// Get scopes where user has specific roles +const editorScopes = await Roles.getScopesForUserAsync(userId, ["editor"]); +``` + +## Publishing Roles + +Role assignments need to be published to be available on the client. Example publication: + +```js +// Publish user's own roles +Meteor.publish(null, function () { + if (this.userId) { + return Meteor.roleAssignment.find({ "user._id": this.userId }); + } + this.ready(); +}); + +// Publish roles for specific scope +Meteor.publish("scopeRoles", function (scope) { + if (this.userId) { + return Meteor.roleAssignment.find({ scope: scope }); + } + this.ready(); +}); +``` + +## Client only APIs + +On the client alongside the async methods, you can use the `sync` versions of the functions: + +- `Roles.userIsInRole(userId, roles, scope)` +- `Roles.getRolesForUser(userId, scope)` +- `Roles.getScopesForUser(userId)` +- `Roles.isParentOf(parent, child)` +- `Roles.getUsersInRole(role, scope)` +- `Roles.getAllRoles(options)` +- `Roles.createRole(role, options)` +- `Roles.addUsersToRoles(userId, roles, scope)` +- `Roles.setUserRoles(userId, roles, scope)` +- `Roles.removeUsersFromRoles(userId, roles, scope)` +- `Roles.addRolesToParent(child, parent)` +- `Roles.removeRolesFromParent(child, parent)` +- `Roles.deleteRole(role)` +- `Roles.renameRole(oldRole, newRole)` +- `Roles.renameScope(oldScope, newScope)` +- `Roles.removeScope(scope)` + +## Using with Templates + +The roles package automatically provides an `isInRole` helper for templates: + +```handlebars +{{#if isInRole "admin"}} +
    + +
    +{{/if}} + +{{#if isInRole "editor,writer" "blog"}} +
    + +
    +{{/if}} +``` + +## Migration to Core Version + +If you are currently using the `alanning:roles` package, follow these steps to migrate to the core version: + +1. Make sure you are on version 3.6 of `alanning:roles` first +2. Run any pending migrations from previous versions +3. Switch all server-side role operations to use the async versions of the functions: + - createRoleAsync + - deleteRoleAsync + - addUsersToRolesAsync + - setUserRolesAsync + - removeUsersFromRolesAsync + - etc. +4. Remove `alanning:roles` package: + ```bash + meteor remove alanning:roles + ``` +5. Add the core roles package: + ```bash + meteor add roles + ``` +6. Update imports to use the new package: + ```js + import { Roles } from "meteor/roles"; + ``` + +The sync versions of these functions are still available on the client. + +## Security Considerations + +1. Client-side role checks are for convenience only - always verify permissions on the server +2. Publish only the role data that users need +3. Use scopes to properly isolate role assignments +4. Validate role names and scopes to prevent injection attacks +5. Consider using more granular permissions over broad role assignments + +## Example Usage + +### Method Security + +```js +// server/methods.js +Meteor.methods({ + deletePost: async function (postId) { + check(postId, String); + + const canDelete = await Roles.userIsInRoleAsync( + this.userId, + ["admin", "moderator"], + "posts" + ); + + if (!canDelete) { + throw new Meteor.Error("unauthorized", "Not authorized to delete posts"); + } + + Posts.remove(postId); + }, +}); +``` + +### Publication Security + +```js +// server/publications.js +Meteor.publish("secretDocuments", async function (scope) { + check(scope, String); + + const canView = await Roles.userIsInRoleAsync( + this.userId, + ["view-secrets", "admin"], + scope + ); + + if (canView) { + return SecretDocs.find({ scope: scope }); + } + + this.ready(); +}); +``` + +### User Management + +```js +// server/users.js +Meteor.methods({ + promoteToEditor: async function (userId, scope) { + check(userId, String); + check(scope, String); + + const canPromote = await Roles.userIsInRoleAsync( + this.userId, + "admin", + scope + ); + + if (!canPromote) { + throw new Meteor.Error("unauthorized"); + } + + await Roles.addUsersToRolesAsync(userId, ["editor"], scope); + }, +}); +``` diff --git a/v3-docs/docs/troubleshooting/known-issues.md b/v3-docs/docs/troubleshooting/known-issues.md deleted file mode 100644 index f533d80a76..0000000000 --- a/v3-docs/docs/troubleshooting/known-issues.md +++ /dev/null @@ -1,39 +0,0 @@ - -# Known issues in 2.13 - -Troubleshooting in Meteor 2.13 - -## Cannot extract version of meteor tool {#cannot-extract-meteor-tool} - - -For some users, the `meteor update` to version 2.13 command may fail with the following error or similar: - -```shell -Error: incorrect data check - at Zlib.zlibOnError [as onerror] (zlib.js:187:17) - => awaited here: - ... - at /tools/cli/main.js:1165:7 { - errno: -3, - code: 'Z_DATA_ERROR' - } - -``` - -## The issue {#the-issue} - -It seems related to [our first ESM version of Node.js v14.21.4](https://github.com/meteor/node-v14-esm) and the `zlib` package. -We have been able to reproduce this issue only in Mac Intel. - -You can follow along with the [GitHub issue](https://github.com/meteor/meteor/issues/12731) for updates. - -## Solution {#solution} - -The solution for this issue is running the following command in your terminal: - -```shell - -curl https://install.meteor.com/\?release\=2.13.3 | sh - -``` -