npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@flusys/nestjs-shared

v9.2.2

Published

Common shared utilities for Flusys NestJS applications

Readme

@flusys/nestjs-shared

Shared infrastructure for all FLUSYS NestJS packages — base CRUD classes, JWT guard, permission guard, response DTOs, hybrid cache, structured logging, and soft-delete entities.

npm version License: MIT


Installation

npm install @flusys/nestjs-shared @flusys/nestjs-core
npm install typeorm @nestjs/passport @nestjs/jwt passport-jwt winston

1. Module Registration

Register once in your root AppModule:

import { Module, MiddlewareConsumer, NestModule } from '@nestjs/common';
import { CacheModule, UtilsModule } from '@flusys/nestjs-shared/modules';
import { LoggerMiddleware } from '@flusys/nestjs-shared/middlewares';
import { GlobalExceptionFilter } from '@flusys/nestjs-shared/filters';
import { ResponseMetaInterceptor } from '@flusys/nestjs-shared/interceptors';

@Module({
  imports: [
    CacheModule.forRoot(true), // true = global; provides 'CACHE_INSTANCE' token
    UtilsModule, // provides UtilsService
  ],
})
export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer.apply(LoggerMiddleware).forRoutes('*');
  }
}

// In bootstrap()
app.useGlobalFilters(new GlobalExceptionFilter());
app.useGlobalInterceptors(new ResponseMetaInterceptor());

2. Entity Base Class

Every entity must extend Identity:

import { Entity, Column } from 'typeorm';
import { Identity } from '@flusys/nestjs-shared/entities';

@Entity('products')
export class Product extends Identity {
  @Column({ length: 255 })
  name: string;
  // inherited: id (uuid), createdAt, updatedAt, deletedAt,
  //            createdById, updatedById, deletedById
}

3. Generic CRUD — createApiController + ApiService

Service (REQUEST-scoped, DataSource Provider pattern)

The constructor takes the event entity name, the cache, UtilsService, the logger name, whether reads are cacheable, the event module name, the DataSource provider and the entity. All of them are required, so a service cannot silently skip one.

import { Inject, Injectable, Scope } from '@nestjs/common';
import { ApiService, HybridCache } from '@flusys/nestjs-shared/classes';
import { UtilsService } from '@flusys/nestjs-shared/modules';

@Injectable({ scope: Scope.REQUEST }) // required
export class ProductService extends ApiService<
  CreateProductDto,
  UpdateProductDto,
  IProduct,
  Product
> {
  constructor(
    @Inject('CACHE_INSTANCE') protected override cacheManager: HybridCache,
    @Inject(UtilsService) protected override utilsService: UtilsService,
    @Inject(ProductDataSourceProvider) dataSourceProvider: ProductDataSourceProvider,
  ) {
    super(
      PRODUCT_EVENT_ENTITIES.PRODUCT,
      cacheManager,
      utilsService,
      ProductService.name,
      true,
      PRODUCT_EVENT_MODULE,
      dataSourceProvider,
      Product,
    );
  }
}

A service whose table depends on runtime configuration passes the choice in the same argument. Read the injected config through the constructor parameter, not this - this is not available until super() returns:

  constructor(
    @Inject('CACHE_INSTANCE') protected override cacheManager: HybridCache,
    @Inject(UtilsService) protected override utilsService: UtilsService,
    @Inject(ProductConfigService) private readonly config: ProductConfigService,
    @Inject(ProductDataSourceProvider) dataSourceProvider: ProductDataSourceProvider,
  ) {
    super(
      PRODUCT_EVENT_ENTITIES.PRODUCT,
      cacheManager,
      utilsService,
      ProductService.name,
      true,
      PRODUCT_EVENT_MODULE,
      dataSourceProvider,
      config.isCompanyFeatureEnabled() ? ProductWithCompany : Product,
    );
  }

Services are Scope.REQUEST, so the configuration is read once per request either way. The repository itself is still created lazily, on first use.

Controller (factory function)

import { Controller, Inject } from '@nestjs/common';
import { createApiController } from '@flusys/nestjs-shared/classes';

@Controller('administration/products')
export class ProductController extends createApiController<
  CreateProductDto,
  UpdateProductDto,
  ProductResponseDto,
  IProduct,
  ProductService
>(CreateProductDto, UpdateProductDto, ProductResponseDto, {
  entityName: 'product',
  security: {
    insert: { level: 'permission', permissions: ['product.create'] },
    insertMany: { level: 'permission', permissions: ['product.create'] },
    getAll: { level: 'permission', permissions: ['product.read'] },
    getById: { level: 'permission', permissions: ['product.read'] },
    update: { level: 'permission', permissions: ['product.update'] },
    updateMany: { level: 'permission', permissions: ['product.update'] },
    delete: { level: 'permission', permissions: ['product.delete'] },
    bulkUpsert: { level: 'permission', permissions: ['product.create'] },
    getByIds: { level: 'permission', permissions: ['product.read'] },
    getByFilter: { level: 'permission', permissions: ['product.read'] },
  },
}) {
  constructor(@Inject(ProductService) public override service: ProductService) {
    super(service);
  }
}

Generated POST-only endpoints: /insert, /insert-many, /get-all, /get/:id, /get-by-ids, /get-by-filter, /update, /update-many, /bulk-upsert, /delete.

Security levels: 'public' (no auth), 'jwt' (token only), 'permission' (token + action check).

An endpoint left out of a per-endpoint security map is never public: insertMany / updateMany take the security of insert / update, getByIds / getByFilter that of getById (else getAll), and bulkUpsert requires both insert and update (their permissions combined with AND). Anything still unset - including a controller with no security option at all - requires a JWT. List an endpoint as 'public' explicitly to open it.

PermissionGuard fails closed on an empty requirement: @RequirePermission() with no codes, '', [] or { permissions: [] } is a 403 for every user (and a 401 without one), never a pass.

IdempotencyInterceptor (on insert / insert-many) replays the stored response for a repeated X-Idempotency-Key only to the same tenant, user and endpoint, and releases the key when the request fails, so a retry after an error is processed again rather than answered with 409.

Bodies of insert / insert-many / update / update-many / bulk-upsert are validated against the concrete Create/Update DTO by DtoBodyValidationPipe (@flusys/nestjs-shared/pipes, same DEFAULT_VALIDATION_OPTIONS as the global pipe: whitelist + forbidNonWhitelisted, no implicit conversion), because a generic @Body() dto: T reflects as Object and the global pipe skips it. Audit fields are never taken from the client: SetCreatedByOnBody / SetUpdateByOnBody / SetDeletedByOnBody drop any client-sent createdById / updatedById / deletedById and stamp the authenticated user's id (nothing without a user), and the pipe keeps only the audit key the endpoint's interceptor stamps. A slug the DTO does not declare is the Slug interceptor's and is kept.

Company scoping

applyCompanyFilter(query, { isCompanyFeatureEnabled, entityAlias }, user), buildCompanyWhereCondition(where, enabled, user) and validateCompanyOwnership(entity, user, enabled, name) (@flusys/nestjs-shared/utils) keep a user to their own company when the company feature is on. A logged-in user without a companyId matches nothing (and may not use another company's row) - never every company's data. Passing no user at all (null / undefined) is a system call and is left unscoped.

Savepoints in a shared transaction

withSavepoint(queryRunner, fn) (@flusys/nestjs-shared/utils) runs fn inside a savepoint of an already-open transaction: a failure rolls back only what fn did and is rethrown, so the caller can swallow it and keep using the transaction. Use it for any step that may fail without failing the whole unit of work - PostgreSQL aborts the entire transaction after one failed statement otherwise.

try {
  await withSavepoint(queryRunner, () => service.insert(dto, user, { queryRunner }));
} catch {
  // skipped; the transaction is still usable
}

4. Guards and Decorators

import { JwtAuthGuard } from '@flusys/nestjs-shared/guards';
import { PermissionGuard } from '@flusys/nestjs-shared/guards';
import {
  CurrentUser,
  Public,
  RequirePermission,
  RequireAnyPermission,
} from '@flusys/nestjs-shared/decorators';
import { ILoggedUserInfo } from '@flusys/nestjs-shared/interfaces';

@UseGuards(JwtAuthGuard, PermissionGuard)
@RequirePermission('product.create')
@Post('insert')
async insert(@Body() dto: CreateProductDto, @CurrentUser() user: ILoggedUserInfo) { }

// Mark endpoint public (no JWT required)
@Public()
@Post('public-list')
async publicList() { }

ILoggedUserInfo fields: id, email, name?, phone?, profilePictureId?, companyId?, branchId?.

matchesPermission(granted, required) (@flusys/nestjs-shared/utils) is the one wildcard matcher (*, prefix.*) behind PermissionGuard and SharedPermissionCacheService.matchPermission. For a check inside a handler, SharedPermissionCacheService.assertPermission(user, code) throws InsufficientPermissionsException (403) unless the user holds the code in their company/branch scope, and PermissionSystemUnavailableException when no cache is wired up - never re-implement that check. PERMISSION_RESOLVER / IPermissionResolver (@flusys/nestjs-shared/interfaces) is the provider interface for reading a user's codes in any company/branch scope, including one not loaded into the cache yet; nestjs-iam implements it, feature packages inject it @Optional().

evaluatePermissionLogic(logic, granted) evaluates an AND / OR ILogicNode tree with the same matcher and returns { passed, missingPermissions, operator } (a group without children fails closed); permissionLogicCodes(logic) lists the codes a tree names. PermissionGuard uses it for @RequirePermissionLogic, and SharedPermissionCacheService.assertPermissionLogic(user, logic) is the throwing form for code outside a guard (403 naming what is missing, 500 when no cache is wired up) - used by nestjs-entity-builder for flow URLs with a permission rule.

permissionLogicProblem(value) validates a stored rule ('invalid' | 'empty' | 'too_many' | null; at most MAX_PERMISSION_LOGIC_DEPTH (5) group levels and MAX_PERMISSION_LOGIC_CODES (50) codes) - use it for any DTO or config that accepts an ILogicNode. loadPermissionCodes(scope, { resolver, cache }) reads a user's codes from PERMISSION_RESOLVER when wired, else from a configured SharedPermissionCacheService, else throws PermissionSystemUnavailableException - never re-implement that fallback.

5. Response DTOs

All controllers return one of these shapes:

import {
  SingleResponseDto, // insert, update, getById
  ListResponseDto, // getAll, getByIds
  BulkResponseDto, // insertMany, updateMany, bulkUpsert
  MessageResponseDto, // delete
} from '@flusys/nestjs-shared/dtos';

// All shapes include: success, message, messageKey (for i18n), _meta (requestId, timestamp, duration)
// ListResponseDto / BulkResponseDto also include: meta (total, page, pageSize, count, totalPages)

6. Exceptions

import {
  NotFoundException,
  ConflictException,
  UnauthorizedException,
  ForbiddenException,
  ValidationException,
  InternalServerException,
} from '@flusys/nestjs-shared/exceptions';

throw new NotFoundException({
  message: 'User not found',
  messageKey: USER_MESSAGES.NOT_FOUND,
});
throw new ConflictException({
  message: `User already assigned to this ${typeName}. Please refresh and try again.`,
  messageKey: USER_PERMISSION_MESSAGES.ALREADY_ASSIGNED,
  messageVariables: { type: typeName },
});
throw new ValidationException([{ field: 'email', message: 'Invalid format' }]);

All exceptions produce: { success: false, message, messageKey,messageVariables:{} code, _meta }.

Keyed diagnostics - when a service returns a list of problems (plan warnings, validation findings) instead of throwing, each item is a translatable reference, not text. IMessageRef (@flusys/nestjs-shared/interfaces) is { messageKey, messageVariables? }, built with messageRef(key, variables?) (@flusys/nestjs-shared/utils). A variable may itself be an IMessageRef (the frontend translates it first). IValidationError (errors[] of an exception) may carry messageKey/messageVariables instead of message.

7. Hybrid Cache

CacheModule.forRoot(isGlobal = true, memoryTtl = 60_000, memorySize = 5000) provides one HybridCache under CACHE_INSTANCE. The store is chosen by USE_CACHE_LABEL:

| USE_CACHE_LABEL | Data store | Stamps / claims | Use when | | ------------------ | ------------------------------------------ | --------------------------- | ------------------------------------------ | | memory (default) | in-process LRU | in-process | one instance only - nothing is shared | | redis | Redis (REDIS_URL) | Redis (SET NX) | several instances | | hybrid | in-process L1 + Redis L2 | Redis (SET NX) | several instances, hot immutable reads |

memory mode is single-instance only: invalidations, claims and one-time values live in the process, so a second instance never sees them. Run more than one instance with redis or hybrid.

API

| Method | Meaning | | --------------------------------------- | ----------------------------------------------------------------------------------------------------------- | | get(key, { skipMemory? }) | Read; L1 first in hybrid unless skipMemory. Only undefined is a miss. | | set(key, value, ttl?, { skipMemory? })| Write with ttl ms, else the default (memoryTtl) - on every layer, Redis included, so nothing lives forever. | | del(key) | Delete from every layer (other instances' L1 copies are not reached). | | getStamp(name) / bumpStamp(name) | Version stamp: created at random when absent, moved by bumpStamp atomically for every instance. | | claim(name, ttl) / releaseClaim(name) | Atomic first-wins lock (Redis SET NX PX); true only for the first caller while it lives. | | take(key, ttl) | Atomic read-and-delete of a one-time value: exactly one caller across all instances receives it. |

Rules

  1. L1 values must be immutable for their key. In hybrid mode the in-process layer of other instances is never invalidated, so del or an overwrite does not reach them. A key whose value changes in place - sessions, revocation markers, one-time state, counters, flags - must use { skipMemory: true } on both get and set.
  2. One-time values use take, never get + del (two concurrent requests would both read the value). The claim behind take is per value, so a fresh value stored under the same key (a resent code) can be taken again.
  3. Locks and de-duplication use claim and release it when done; the ttl bounds how long a crashed instance holds it.
  4. Invalidate by stamp, after commit. Embed getStamp() in data keys and bumpStamp() to invalidate - never track key lists. Bump after the transaction commits: a bump inside it lets a concurrent reader re-cache old rows under the new stamp.
@Injectable()
export class MyService {
  constructor(@Inject(CACHE_INSTANCE) private readonly cache: HybridCache) {}

  async consumeResetToken(tokenHash: string) {
    return this.cache.take<IResetState>(`reset:${tokenHash}`, 15 * 60_000);
  }

  async runOnce(jobId: string, work: () => Promise<void>) {
    if (!(await this.cache.claim(`job:${jobId}`, 60_000))) return;
    try {
      await work();
    } finally {
      await this.cache.releaseClaim(`job:${jobId}`);
    }
  }
}

ApiService caching

With isCacheable = true, findById and getAll are cached under [tenant_<id>_]entity_<name>_<id_x|all>_<stamp>_<sha256(query)>:

  • The stamp (one per entity in cacheDependencies(), joined) is read before the query, so a write that commits mid-read moves the stamp and the stale result lands under a key no later read asks for.
  • Cache failures on the read path (stamp, get, set) are logged and the read goes to the database.
  • insert / insertMany / update / updateMany / bulkUpsert / delete call invalidateCache() after commit - whether the service is cacheable or not, because another service may cache the same entity. A failed invalidation is logged, never fails the committed write.
  • after*Operation hooks run before commit - never invalidate in them.
// A cached read that joins or embeds another entity must depend on it
protected override cacheDependencies(): string[] {
  return [this.entityName, TASK_MANAGER_EVENT_ENTITIES.TASK];
}

// A service that writes another entity directly (repository, query runner, cascade) bumps it after commit
await this.utilsService.clearCache(OTHER_ENTITY_NAME, this.cacheManager, this.cacheTenant());

UtilsService: getCacheStamp(cache, entityNames, tenantId?), getCacheKey(entityName, params, entityId, tenantId, stamp), clearCache(entityName, cache, tenantId?) (bumps the stamp; logs instead of throwing).

A cached result must depend only on the SQL it ran: anything else (current time, config, the user beyond what the query filters on, feature flags) belongs in the key or the read must not be cached.

Multi-tenant keys: one cache serves every tenant, so ApiService prefixes its keys and stamps with the current tenant (cacheTenant()), taken from a tenant-aware DataSource provider (isMultiTenant() / getCurrentTenantId(), which MultiTenantDataSourceService implements). A single database is never prefixed. Code that calls UtilsService itself must pass the same tenant: cacheTenantId(dataSourceProvider) from @flusys/nestjs-shared/utils, or this.cacheTenant() inside an ApiService.

Permission cache

A user's codes are cached under [tenant:<id>:]<format>:v:<user stamp> - the scope key from buildPermissionCacheKey (user, or company + branch with enableCompanyFeature) plus one stamp per user. resolvePermissionCacheKey(cache, options, config) gives the key (resolve it before reading grants from the database, write under that same key); invalidatePermissionCache(cache, userId, config) bumps the stamp and drops every company / branch scope of the user on every instance. The tenant prefix is added only when config.multiTenant is set - a single database ignores a tenant header.

SharedPermissionCacheService.getPermissions reads that key and, on a miss (first request, expiry, invalidation), falls back to PERMISSION_RESOLVER when it is provided (nestjs-iam); without one, a miss is "no permissions". setPermissions writes the current key; clearPermissions bumps the user stamp.

Idempotency

IdempotencyInterceptor keys X-Idempotency-Key by tenant, user and endpoint. A stored response is replayed (read with skipMemory); otherwise the request claims the key (409 system.duplicate.request while another request holds it), stores its response for 24 h with skipMemory, and releases the claim on success and on error so a failed request can be retried.

8. Hybrid Event Bus

The event counterpart of the hybrid cache: one wrapper, transport chosen by configuration.

| USE_EVENT_LABEL | Behaviour | Extra install | | ----------------- | ------------------------------------------------------ | ---------------- | | memory | In-process only (default) | none | | rabbitmq | Topic exchange, cross-service | npm i amqplib | | kafka | Single topic, cross-service | npm i kafkajs | | hybrid | Both layers, duplicate delivery suppressed by event id | broker of choice |

Registering

EventBusModule is optional - register it only in an app that actually publishes or consumes events.

import { EventBusModule } from '@flusys/nestjs-shared/modules';

@Module({
  imports: [EventBusModule.forRoot({ serviceName: 'flusysnest' })],
})
export class AppModule {}

Leave it out and nothing breaks: no package injects the bus as a hard dependency, and publishDomainEvent - including the calls ApiService makes for you - finds no bus and returns without doing anything. There is no placeholder registration to add just to keep DI happy. forRoot({ enabled: false }) is for the other case: the app supports events but this deployment has them switched off, and you want that stated at startup.

The one thing an unregistered app gives up is consumption - @OnDomainEvent methods are bound at bootstrap by an explorer that ships inside the module, so without it handlers never run and nothing warns about it.

Running against a real broker

Both brokers need their driver installed - npm i amqplib or npm i kafkajs. The transports load it at runtime, so the package builds and boots without either.

  • One delivery per service, not per replica. RabbitMQ binds a single queue named <serviceName>.events and Kafka joins one consumer group named <serviceName>-events, so two replicas of the same service compete for each event and only one of them handles it. Give a service that must see every event on every replica its own queue / groupId.
  • A publisher consumes its own events. In rabbitmq and kafka mode the local handlers run off the broker round trip, which is what makes a single code path work across services. In hybrid mode the in-process copy arrives first and the broker copy is dropped by event id, so a handler still runs exactly once.
  • RabbitMQ reconnects on its own. A dropped connection and a channel killed by a protocol error both schedule one reconnect (reconnectDelay, 5s by default), which re-binds every pattern onto the fresh channel. While it is down, publishes are dropped rather than queued - events must never fail the operation that produced them.
  • Kafka's consumer is built on the first bind, not at connect. A missing topic or an unavailable group coordinator therefore fails consumption alone and leaves the producer working, instead of taking the whole transport down to in-process delivery.

Publishing

Every service extending ApiService publishes automatically once its module enables events — no per-service code. Turn it on in the module's own config:

StorageModule.forRoot({
  config: {
    ...dbConfig,
    events: { enabled: true },
  },
});

Every events option

| Option | Type | Default | Effect | | ----------------- | --------------------------- | ------- | ---------------------------------------------------------------------- | | enabled | boolean | false | Master switch for the module. Everything else is ignored when off. | | actions | string[] | all | Allowlist of entity.action or bare action entries. Omit to publish all. | | includePayload | boolean | true | Attach the saved rows as payload.data. Ids and metadata always ride. |

Two rules decide what actually reaches a consumer:

  1. actions is one allowlist covering CRUD and domain actions together. Listing only ['logged-in'] silences that module's created/updated/deleted too - name everything you want kept, or omit actions entirely.
  2. An entry is either entity.action, which covers that one entity, or a bare action, which covers every entity of the module. So role.updated keeps role updates and drops action.updated, while updated keeps every entity's updates. Entries are matched as routing patterns, so role.* and *.purged work as well.

There is no separate entity filter: an enabled module publishes under every entity it has unless an entry narrows it, so a package gaining one is picked up with no config change.

Mixing the two forms narrows one entity without touching the others:

import { IAM_EVENT_ACTIONS } from '@flusys/nestjs-iam/config';

IAMModule.forRoot({
  config: {
    ...dbConfig,
    events: {
      enabled: true,
      actions: [
        'created',
        'deleted',
        'role.updated',
        `user_role.${IAM_EVENT_ACTIONS.PERMISSIONS_ASSIGNED}`,
      ],
    },
  },
});

iam.role.updated publishes; iam.action.updated does not; both still publish created and deleted.

Event names are always <module>.<entity>.<action>. CRUD lifecycle actions are created, updated, deleted, restored, purged. Events are published after the transaction commits and never fail the write that produced them.

The module's own events block is the single authority on whether it publishes: passing an eventBus explicitly chooses which bus to publish on, it never overrides the switch, so enabled: false silences the module everywhere. delete and restore carry ids only - there is no row left to attach.

Payloads are walked before they leave the process: credential-like fields become [REDACTED] (matched case and separator blind - apiKey, api_key, X-API-KEY, private-key alike; @LogAction({ includeParams: true }) uses the same redaction), a relation that loops back becomes [CIRCULAR], anything past 8 levels deep becomes [TRUNCATED], and a Buffer becomes [BINARY]. Saved TypeORM rows with bidirectional relations are therefore safe to publish. The config blob on a storageConfig or emailConfig row is redacted whole - S3, Azure, SFTP and SMTP credentials live in it - so those rows publish their id, name and provider and nothing else. Redaction is a package default, not something an application configures, so it cannot be switched off by a config edit.

Registering each module

Every feature module takes the same events block in its own config, and every one of them also has a forRootAsync variant that returns the identical config. The blocks below spell out all options with that module's real entity and action names, so each is copy-pasteable. Write the CRUD half as literals - created, updated, deleted, restored, purged never change - and take every other action from its package's own *_EVENT_ACTIONS constant, so a rename there fails the build instead of silently muting the event. Each package exports its entity names the same way (STORAGE_EVENT_ENTITIES).

In an application, keep the per-module blocks in one map rather than scattering them across registrations - flusysnest/src/config/modules.config.ts is the reference:

const domainEventsEnabled = envConfig.getBoolean('ENABLE_DOMAIN_EVENTS', false);

export const moduleEventsConfig: Record<FeatureModuleName, IModuleEventsConfig> = {
  storage: {
    enabled: domainEventsEnabled,
    actions: [
      'file_manager.created',
      'file_manager.updated',
      'file_manager.deleted',
      'file_manager.restored',
      'file_manager.purged',
      `file_manager.${STORAGE_EVENT_ACTIONS.UPLOADED}`,
      `file_manager.${STORAGE_EVENT_ACTIONS.REMOVED}`,
      'folder.created',
      // ...one line per entity and action the module can publish
    ],
  },
  // ...one entry per feature module
};

export function getBaseModuleConfig(moduleName: FeatureModuleName) {
  return { ...getDbConfig(), events: moduleEventsConfig[moduleName] };
}

AuthModule.forRoot({ config: { ...authConfig, ...getBaseModuleConfig('auth') } });

Spelling every entity.action pair out in full - CRUD lifecycle ones included, rather than spreading a shared array or leaning on bare actions - is the same as omitting the option, and makes the file a readable inventory of what each module publishes: delete one line to stop publishing that one pair. The trade-off is that a package gaining an entity or an action stays silent until it is listed, so keep a test asserting the list covers each package's own *_EVENT_ENTITIES and *_EVENT_ACTIONS constants. One env flag still arms every module, but each keeps its own block, so narrowing one module never touches the others.

auth

import { AUTH_EVENT_ACTIONS } from '@flusys/nestjs-auth/config';

AuthModule.forRoot({
  config: {
    ...dbConfig,
    events: {
      enabled: true,
      actions: ['appuser.created', `session.${AUTH_EVENT_ACTIONS.LOGGED_IN}`],
      includePayload: false,
    },
  },
});

| Entity | Actions it can publish | | ------------------------- | ------------------------------------------------------------------------------------------------------- | | appuser | CRUD + registered, password-changed, password-reset-requested, password-reset, email-verified | | company | CRUD | | company_branch | CRUD | | session | logged-in, logged-out, token-refreshed, company-switched | | user_company_permission | permission-granted, permission-revoked |

iam

import { IAM_EVENT_ACTIONS } from '@flusys/nestjs-iam/config';

IAMModule.forRoot({
  config: {
    ...dbConfig,
    events: {
      enabled: true,
      actions: [
        'role.created',
        'role.deleted',
        `user_role.${IAM_EVENT_ACTIONS.PERMISSIONS_ASSIGNED}`,
      ],
    },
  },
});

| Entity | Actions it can publish | | ----------------------------------------------------------- | ---------------------- | | action, role | CRUD | | user_action, role_action, company_action, user_role | permissions-assigned |

storage

import { STORAGE_EVENT_ACTIONS } from '@flusys/nestjs-storage/config';

StorageModule.forRoot({
  config: {
    ...dbConfig,
    events: {
      enabled: true,
      actions: ['folder.created', 'folder.deleted', `file_manager.${STORAGE_EVENT_ACTIONS.UPLOADED}`],
    },
  },
});

| Entity | Actions it can publish | | --------------- | ---------------------------- | | file_manager | CRUD + uploaded, removed | | folder | CRUD | | storageConfig | CRUD |

Uploads and removals report provider keys in payload.ids, with location, storageConfigId, totalSize and count in payload.metadata.

form-builder

import { FORM_BUILDER_EVENT_ACTIONS } from '@flusys/nestjs-form-builder/config';

FormBuilderModule.forRoot({
  config: {
    ...dbConfig,
    events: {
      enabled: true,
      actions: [`form_result.${FORM_BUILDER_EVENT_ACTIONS.SUBMITTED}`],
      includePayload: false,
    },
  },
});

| Entity | Actions it can publish | | ------------- | --------------------------------- | | form | CRUD | | form_result | CRUD + submitted, draft-saved |

Submissions carry formId, isDraft and schemaVersion in payload.metadata, so an audit consumer can work with includePayload: false and never see answer data.

email

import { EMAIL_EVENT_ACTIONS } from '@flusys/nestjs-email/config';

EmailModule.forRoot({
  config: {
    ...dbConfig,
    events: {
      enabled: true,
      actions: [
        `email_message.${EMAIL_EVENT_ACTIONS.SENT}`,
        `email_message.${EMAIL_EVENT_ACTIONS.FAILED}`,
      ],
      includePayload: false,
    },
  },
});

| Entity | Actions it can publish | | --------------- | ---------------------- | | emailConfig | CRUD | | emailTemplate | CRUD | | email_message | sent, failed |

event-manager

import { EVENT_MANAGER_EVENT_ACTIONS } from '@flusys/nestjs-event-manager/config';

EventManagerModule.forRoot({
  config: {
    ...dbConfig,
    events: {
      enabled: true,
      actions: [
        'event.created',
        `event.${EVENT_MANAGER_EVENT_ACTIONS.PARTICIPANT_STATUS_CHANGED}`,
      ],
    },
  },
});

| Entity | Actions it can publish | | ------- | ----------------------------------- | | event | CRUD + participant-status-changed |

The participant event is keyed by the event id, with participantId, participantUserId and status in payload.metadata.

notification

import { NOTIFICATION_EVENT_ACTIONS } from '@flusys/nestjs-notification/config';

NotificationModule.forRoot({
  config: {
    ...dbConfig,
    events: {
      enabled: true,
      actions: [
        `notification.${NOTIFICATION_EVENT_ACTIONS.SENT}`,
        `notification.${NOTIFICATION_EVENT_ACTIONS.READ}`,
      ],
      includePayload: false,
    },
  },
});

| Entity | Actions it can publish | | -------------- | --------------------------------- | | notification | CRUD + sent, read, all-read |

Subscribing to notification.** while the notification module itself reacts to other modules' events is the usual way to fan a domain event out to users.

localization

import { LOCALIZATION_EVENT_ACTIONS } from '@flusys/nestjs-localization/config';

LocalizationModule.forRoot({
  config: {
    ...dbConfig,
    defaultLanguageCode: 'en',
    events: {
      enabled: true,
      actions: [`translation.${LOCALIZATION_EVENT_ACTIONS.BULK_UPDATED}`],
      includePayload: false,
    },
  },
});

| Entity | Actions it can publish | | ---------------- | ---------------------- | | language | CRUD | | translationKey | CRUD | | translation | CRUD + bulk-updated |

bulk-updated carries every written id plus count in payload.metadata, and is not published when there was nothing to write - useful for cache busting a translation CDN.

task-manager

import { TASK_MANAGER_EVENT_ACTIONS } from '@flusys/nestjs-task-manager/config';

TaskManagerModule.forRoot({
  config: {
    ...dbConfig,
    events: {
      enabled: true,
      actions: [`task.${TASK_MANAGER_EVENT_ACTIONS.MOVED}`, `task.${TASK_MANAGER_EVENT_ACTIONS.ASSIGNED}`],
    },
  },
});

| Entity | Actions it can publish | | ------------------------------------------ | -------------------------- | | task | CRUD + moved, assigned | | task_list | CRUD + reordered | | task_board, task_label, task_comment | CRUD |

moved carries fromListId/toListId; assigned carries the assignee ids. Domain events carry a payload.metadata descriptor that is attached even when includePayload is off.

Publishing your own

import { publishDomainEvent } from '@flusys/nestjs-shared/classes';

await publishDomainEvent({
  module: 'billing',
  entity: 'invoice',
  action: 'settled',
  ids: [invoice.id],
  metadata: { amount: invoice.total },
  user,
});

Inside a service that already extends ApiService, call this.publishDomainAction(action, { ... }) - it fills in the module and entity for you.

Consuming

import { OnDomainEvent } from '@flusys/nestjs-shared/decorators';
import { DomainEvent } from '@flusys/nestjs-shared/interfaces';

@Injectable()
export class AuditConsumer {
  @OnDomainEvent('storage.*.created', 'auth.appuser.deleted')
  async handle(event: DomainEvent) {
    // event.payload.ids / .data / .metadata, event.actor, event.action
  }
}

DomainEvent is the standard envelope shape - it covers both CRUD lifecycle and domain actions, so one type fits every handler. * matches exactly one segment; ** (or #) matches the rest. Handlers are discovered at bootstrap on any singleton provider or controller registered with Nest - a REQUEST-scoped provider has no instance at that point, so put consumers on singletons.

Database work in a handler. Every handler runs inside the request context of the event's actor - tenant, company, user and request id restored from event.actor. When MultiTenantDataSourceService has no HTTP request, it takes the tenant from that context, so a handler reaches the right tenant database by resolving any request-scoped service through ModuleRef - no tenant plumbing:

@Injectable()
export class InvoiceConsumer {
  constructor(@Inject(ModuleRef) private readonly moduleRef: ModuleRef) {}

  @OnDomainEvent('form-builder.form_result.submitted')
  async handle(event: DomainEvent) {
    const results = await this.moduleRef.resolve(FormResultService, ContextIdFactory.create(), { strict: false });
    // ...any read or write here hits event.actor.tenantId's database
  }
}

Events a handler publishes carry the same actor, so a chain of handlers stays on one tenant. The same fallback serves any service resolved through ModuleRef during an HTTP request.

Injecting EVENT_BUS_INSTANCE gives direct publish / subscribe access - mark it @Optional(), so the service still resolves in an app that never registers EventBusModule.

9. Branch Hierarchy

Branches nest through CompanyBranch.parentId (always inside one company). Two layers, so any package can use them without importing nestjs-auth:

Pure helpers (@flusys/nestjs-shared/utils) on a flat { id, parentId } list. They tolerate bad data: repeated ids keep their first entry, empty ids are ignored, a branch that is its own parent is a root, and parent cycles never loop.

| Function | Returns | | --- | --- | | collectBranchDescendantIds(nodes, ids, { includeSelf?, maxDepth? }) | every branch under ids, level by level; with includeSelf the given ids come first | | collectBranchAncestorIds(nodes, id, { includeSelf?, maxDepth? }) | the parent chain, nearest parent first, stopping at a parent not in nodes | | isBranchWithin(nodes, id, ancestorId) | id is ancestorId or anywhere under it | | getBranchPath(nodes, id) | the nodes from the top-most known ancestor down to id | | normalizeBranchIds(ids) | each id once, in order, without empty values | | applyBranchFilter(query, { entityAlias, columnName?, includeUnassigned? }, ids) | adds alias.branchId IN (:...ids); includeUnassigned also keeps rows with no branch; no ids matches nothing (or only unassigned rows) |

maxDepth: 1 limits a walk to direct children or the direct parent.

BRANCH_HIERARCHY_RESOLVER (@flusys/nestjs-shared/interfaces), implemented by nestjs-auth's BranchHierarchyResolverAdapter when the company feature is on. Inject it @Optional():

| Method | Use it for | | --- | --- | | getDescendantIds(ids, { includeSelf? }) | structure only (every sub-branch, grants ignored) - e.g. admin screens; never to scope a user's data | | getAncestorIds(id, { includeSelf? }) | breadcrumbs, inherited settings | | isWithinBranch(id, ancestorId) | "does this record's branch sit under mine?" | | getGrantedBranchIds(userId, companyId?) | the branches the user is granted - exactly what company select offers | | getGrantedDescendantIds(userId, ids, { includeSelf? }) | the current branch sees its granted sub-branches' data; a branch dropdown filter that also matches granted sub-branches | | hasBranchAccess(userId, branchId) | the user is granted that branch itself (a parent grant does not count) |

Results only hold existing, not soft-deleted branches (inactive ones are included), and empty, malformed or unknown ids match nothing. A soft-deleted branch cuts the chain, so what sits below it is no longer under its old ancestors. A user's granted branches are exactly what company select offers (COMPANY_ACCESS_RESOLVER, whose getUserAccess(userId) lists each as { id, companyId, parentId, name }). Grants are explicit: a grant on a parent never reaches a child that is not granted itself, in either direction - with Parent and Level 2 granted but not Level 1, getGrantedDescendantIds(userId, parent, { includeSelf: true }) is [parent, level2].

constructor(@Optional() @Inject(BRANCH_HIERARCHY_RESOLVER) private readonly branches?: IBranchHierarchyResolver) {}

async listForCurrentBranch(query: SelectQueryBuilder<Invoice>, user: ILoggedUserInfo) {
  if (!this.branches || !user.branchId) return query;
  const ids = await this.branches.getGrantedDescendantIds(user.id, user.branchId, { includeSelf: true });
  return applyBranchFilter(query, { entityAlias: 'invoice', columnName: 'branch_id' }, ids);
}

10. Expressions

A JSON expression tree any package can store and evaluate (@flusys/nestjs-shared/interfaces for the types, /utils for the engine). Pure functions, no Nest dependencies; every slot that takes a value takes another expression.

| type | Shape | Result | | --- | --- | --- | | literal | { value } | the value | | field_ref | { ref } | ctx.resolveRef(ref) | | arithmetic | { op, operands[] } | sum, subtract, multiply, divide, modulo, power, average, min, max; blank operands count as 0 | | concat | { parts[], separator? } | joined text; with a separator, blank parts are skipped | | template | { template } | "{{ ref }}" placeholders filled through resolveRef | | function | { name, args[] } | see EXPRESSION_FUNCTIONS: text (incl. REGEX_EXTRACT / REGEX_REPLACE / REGEX_TEST), number, date (UTC) and COALESCE | | if | { condition, whenTrue, whenFalse? } | condition is an AND / OR group of { left, comparison, right? }, groups nest |

import { evaluateExpression, assertValidExpression, isExpressionError } from '@flusys/nestjs-shared/utils';

assertValidExpression(expr, { isKnownRef: (ref) => fieldIds.has(ref) }); // on save
const value = evaluateExpression(expr, { resolveRef: (ref) => data[ref] });
  • Errors are ExpressionError { code, variables } (plain, not HTTP) - map code to your own message key; check with isExpressionError(), not instanceof.
  • Limits (EXPRESSION_LIMITS): depth 16, 500 nodes, 4000-char templates, 100k-char text results.
  • Regex runs on RE2 via safeRegexProblem() / compileSafeRegex() (SAFE_REGEX_LIMITS): linear time, pattern length / counted-repeat caps. Never new RegExp on author input.

License

MIT © FLUSYS