@sdcorejs/nestjs
v1.0.0
Published
Neutral NestJS framework library: Base classes, multi-tenancy, audit, permission, request context (AsyncLocalStorage), cache, HTTP client, JWT — all domain specifics injected via DI strategies.
Maintainers
Readme
@sdcorejs/nestjs
Neutral NestJS framework library — base classes plus the cross-cutting concerns every multi-tenant service re-implements: multi-tenancy, audit, permission, request context, cache, HTTP client, JWT/Keycloak, Zod validation, BullMQ queue, i18n. Every domain specific is injected via DI strategies — zero hardcoded column names.
Version: 1.0.0 — stable. Public API follows Semantic Versioning.
📖 Full guides + showcase: sdcorejs.github.io/sdcorejs-nestjs
Table of contents
- Installation
- Sub-paths
- Quick start
- Multi-tenancy
- Permissions
- JWT / Keycloak authentication
- Internal (service-to-service) calls
- Request context
- ORM base classes
- Validation (Zod v4)
- Internationalised errors
- Background jobs (BullMQ)
- Features
- Philosophy
- License
Installation
npm install @sdcorejs/nestjsPeer dependencies
Only two — every NestJS app already has them:
| Package | Version |
|---|---|
| @nestjs/common | ^11.0.0 |
| @nestjs/core | ^11.0.0 |
They stay peers so the library shares your app's DI container (one NestJS instance, no duplicated injectors).
Bundled (dependencies)
Installed automatically with the package — you never add these yourself:
| Package | Purpose |
|---|---|
| @nestjs/passport ^11 | Passport integration |
| @nestjs/typeorm ^11 | TypeORM module |
| @nestjs/bullmq ^11 | BullMQ queue module |
| @nestjs/schedule ^6 | @Cron for file cleanup |
| @nestjs/platform-express ^11 | FileInterceptor |
| typeorm ^0.3 | ORM core |
| reflect-metadata ^0.2 | Decorator metadata |
| rxjs ^7.8 | RxJS |
| @sdcorejs/utils ^1.1 | Filter / PagingReq / Order models + ValidationUtilities |
| axios ^1.7 | HTTP client |
| bullmq ^5 | BullMQ core |
| passport ^0.7 | Passport |
| passport-jwt ^4 | JWT passport strategy |
typeormandreflect-metadataare singletons — npm hoists a single copy when your app's versions are compatible (the whole NestJS 11 ecosystem is ontypeorm@^0.3/reflect-metadata@^0.2).
Optional (optionalDependencies)
Auto-installed, but a failed install won't break your project. Skip with --no-optional if unused:
| Package | Version | Enables |
|---|---|---|
| ioredis | ^5 | Redis cache backend (/services) |
| zod | ^4 ⚠️ v4 only | Request validation (/validation) |
| jwks-rsa | ^4 | Keycloak / OIDC JWKS key verification (/auth) |
| jsonwebtoken | ^9 | JWT decode + verify (/auth) |
| aws-sdk | ^2 | S3 storage driver for uploaded files (/features) |
Engines: node >=18.18.
Sub-paths
The package has multiple entry points; import only what you use.
| Import | What's inside |
|---|---|
| @sdcorejs/nestjs | SdCoreModule.forRoot({...}) + ergonomic re-exports of all public symbols |
| @sdcorejs/nestjs/core | ORM base classes (BaseEntity, WithTimestamps, WithAudit, BaseRepository, BaseService, BaseController, @Scoped, @SearchableFields, @Schema, apiError/ApiResponse), request context (ContextService, ContextMiddleware, RequestContext), multi-tenancy (ITenancyStrategy, TENANCY_STRATEGY, buildScopeFilters/buildScopeWhere), and audit (IAuditStrategy, AUDIT_STRATEGY, AuditSubscriber) |
| @sdcorejs/nestjs/auth | JWT / Keycloak strategies (JwtModule, JwtStrategy, KeycloakJwtStrategy, JWT_CONFIG), plus permission enforcement (IPermissionStrategy, AuthGuard, InternalGuard, @HasPermission, @HasAnyPermission, IInternalSecretProvider, IInternalContextEnricher) |
| @sdcorejs/nestjs/services | HTTP client (HttpService, axios-based, context-aware) + cache (CacheService, CacheInterceptor, @Cached — memory and redis backends) |
| @sdcorejs/nestjs/queue | QueueModule, SdWorkerHost (BullMQ + Redis) + re-exported Processor/InjectQueue/Job/Queue |
| @sdcorejs/nestjs/validation | ZodValidationGuard(schema \| map, source), parseZod, query presets (zPaging, zUuid, zBool), ZodIssueDetail (Zod v4) |
| @sdcorejs/nestjs/i18n | II18nResolver, ILanguageResolver, SimpleI18nResolver, DefaultLanguageResolver, SdI18nExceptionFilter, built-in en/vi core.* catalogs, I18nModule |
| @sdcorejs/nestjs/features | Stateful feature modules — ActionHistory, JobScheduler, UploadedFile (entity + service + module each), plus drop-in UploadedFileController / ActionHistoryController |
Quick start
SdCoreModule.forRoot({...}) is the single import that composes every sub-module.
Always-on: context, tenancy, audit, permission, cache, HTTP client.
Opt-in (wired only when the config key is present): jwt, i18n, uploadedFile, actionHistory, jobScheduler, queue.
// app.module.ts
import { Module } from '@nestjs/common';
import { SdCoreModule } from '@sdcorejs/nestjs';
import { TypeOrmModule } from '@nestjs/typeorm';
@Module({
imports: [
SdCoreModule.forRoot({
context: { headers: { tenant: 'x-tenant', userId: 'x-user-id' } },
cache: {},
i18n: {
fallbackLanguage: 'vi',
supportedLanguages: ['vi', 'en'],
catalogs: MY_CATALOGS,
},
permission: { strategy: MyPermissionStrategy },
// Built-in secret provider — reads process.env[envVar] at request time.
// Alternative: pass { key: 'literal-secret' } or omit and provide INTERNAL_SECRET_PROVIDER yourself.
internalSecret: { envVar: 'INTERNAL_SECRET_KEY' },
// tenancy accepts EITHER a strategy class OR inline { resolve, bypass } callbacks:
tenancy: {
bypass: (rc) => rc.custom?.isMaster === true,
resolve: (rc) => ({
tenantCode: rc.tenant,
departmentCode: rc.custom?.departmentCode,
}),
},
// Opt-in features — omit any key to disable:
jwt: { jwks: { allowedIssuers: [process.env.KEYCLOAK_ISSUER!] } },
uploadedFile: { bucket: process.env.S3_BUCKET /* ... */ },
actionHistory: { resolveActor: () => ({ /* ... */ }) },
jobScheduler: {},
queue: { connection: { host: 'localhost', port: 6379 } },
}),
// Register lib entities via autoLoadEntities (UploadedFile, ActionHistory, JobScheduler):
TypeOrmModule.forRoot({ autoLoadEntities: true /* ... */ }),
// your domain modules...
],
})
export class AppModule {}
tenancystrategy vs. callbacks — pass{ strategy: MyTenancyStrategy }to supply a full DI-injected class, or use inline{ resolve, bypass }callbacks for simple cases that need no extra injected services.
Internal secret —
internalSecret: { envVar: 'INTERNAL_SECRET_KEY' }wires the built-inEnvInternalSecretProvider. To rotate secrets, implementIInternalSecretProvideryourself and register it viaproviders: [{ provide: INTERNAL_SECRET_PROVIDER, useClass: ... }].
Feature entities —
UploadedFile,ActionHistory,JobSchedulerexport from@sdcorejs/nestjs/features. Register them with TypeORM viaautoLoadEntities: trueor by listing them explicitly.
Multi-tenancy
Tenancy is enforced by your ITenancyStrategy, injected before every query reaches the database. The library never knows your column names — you mark scoped columns with @Scoped() (decorator uses the property name as the column) and return scope values from the strategy.
1. Mark scoped columns on the entity
import { Entity, Column } from 'typeorm';
import { BaseEntity, WithAudit, Scoped } from '@sdcorejs/nestjs/core';
@Entity()
export class Product extends WithAudit(BaseEntity) {
@Column() name!: string;
@Column() @Scoped() tenantCode!: string;
@Column({ nullable: true }) @Scoped() departmentCode?: string;
}2. Supply the scope via DI
import { Injectable } from '@nestjs/common';
import { ContextService } from '@sdcorejs/nestjs/core';
import type { ITenancyStrategy } from '@sdcorejs/nestjs/core';
import type { RequestContext } from '@sdcorejs/nestjs/core';
@Injectable()
export class AppTenancyStrategy implements ITenancyStrategy {
getCurrentScope(ctx: RequestContext): Record<string, unknown> {
return {
tenantCode: ctx.tenant, // scalar → EQUAL filter
departmentCode: ctx.custom?.['departmentCodes'], // array → IN filter
};
}
shouldBypass(ctx: RequestContext): boolean {
return ctx.custom?.['isInternalCall'] === true; // admin / internal callers see everything
}
}What the library does for you
When a strategy is registered, BaseRepository:
- Reads (
paging,all,search,detail) — injects a scope filter per@Scopedcolumn. A scalar scope value becomesEQUAL; an array becomesIN(multi-department users);null/undefined/ empty array is skipped. - Writes (
create,import) — auto-fills the scoped columns fromgetCurrentScope(). detail(id)is scoped too — fetching a known UUID that belongs to another tenant returnsnull(no cross-tenant id leak).shouldBypass(ctx) === trueskips both filter injection and auto-fill.
With no strategy registered, the repository behaves as if tenancy is disabled — no overhead.
Permissions
Permission codes are resolved by your IPermissionStrategy.load(ctx) once per request and cached. AuthGuard reads the route's @HasPermission / @HasAnyPermission metadata and enforces it.
import { Injectable } from '@nestjs/common';
import type { IPermissionStrategy } from '@sdcorejs/nestjs/auth';
import type { RequestContext } from '@sdcorejs/nestjs/core';
@Injectable()
export class AppPermissionStrategy implements IPermissionStrategy {
constructor(private readonly pages: PagePermissionService) {}
async load(ctx: RequestContext): Promise<string[]> {
return this.pages.codesForUser(ctx.userId);
}
// Optional — override the default `Array.includes` to support wildcards / hierarchy.
check(codes: string[], required: string): boolean {
return codes.some((c) => c === required || (c.endsWith(':*') && required.startsWith(c.slice(0, -1))));
}
}Protect routes with decorators:
import { Controller, Get, UseGuards } from '@nestjs/common';
import { AuthGuard, HasPermission, HasAnyPermission } from '@sdcorejs/nestjs/auth';
@Controller('products')
@UseGuards(AuthGuard)
export class ProductController {
@Get()
@HasPermission('product:read')
list() { /* ... */ }
@Get('export')
@HasAnyPermission('product:export', 'product:admin')
export() { /* ... */ }
}AuthGuard syncs the authenticated user and the resolved permissions into ContextService, so any downstream service can call contextService.hasPermission('product:read') without re-loading.
JWT / Keycloak authentication
AuthGuard extends PassportAuthGuard('jwt'), so you register a passport-jwt strategy via JwtModule (wired automatically by SdCoreModule when the jwt key is set).
Keycloak / OIDC (asymmetric, JWKS)
Set jwt.jwks and SdCoreModule wires KeycloakJwtStrategy. The signing key is fetched per-token from the issuer's JWKS endpoint, so multiple realms / tenants (different iss) work with no shared secret. Requires jwks-rsa@^4 + jsonwebtoken@^9.
SdCoreModule.forRoot({
jwt: {
jwks: {
allowedIssuers: [process.env.KEYCLOAK_ISSUER!], // exact-match list for static, known realms
// jwksUriFromIssuer defaults to `${iss}/protocol/openid-connect/certs` (Keycloak)
},
},
});An issuer policy is required — set at least one of
allowedIssuers,allowedIssuerHosts, orissuerValidator(the strategy throws otherwise; without it the JWKS would be fetched from any token-suppliediss→ spoofing + SSRF). For dynamic multi-realm (realms created at runtime), pin the Keycloak origin instead of listing realms:allowedIssuerHosts: ['https://kc.example.com']accepts any realm under that host and keeps JWKS fetches on that host. UseissuerValidator(iss)for custom rules.
To turn the verified token into your app's user, subclass and override validate(), then register it as the strategy:
import { Inject, Injectable } from '@nestjs/common';
import { KeycloakJwtStrategy, JWT_CONFIG, type JwtConfig, type JwtPayload } from '@sdcorejs/nestjs/auth';
@Injectable()
export class AppJwtStrategy extends KeycloakJwtStrategy {
constructor(@Inject(JWT_CONFIG) cfg: JwtConfig, private readonly users: UserService) {
super(cfg);
}
async validate(payload: JwtPayload) {
return {
id: payload.sub,
email: payload.email,
roles: (payload.realm_access as { roles?: string[] })?.roles ?? [],
};
}
}
// register the subclass:
SdCoreModule.forRoot({
jwt: { jwks: { allowedIssuers: [process.env.KEYCLOAK_ISSUER!] } },
});
// then pass it through JwtModule options when you need constructor deps:
// JwtModule.forRoot(config, { strategy: AppJwtStrategy, imports: [UserModule] })The object returned from validate() becomes req.user and is mirrored into ContextService.user by AuthGuard.
Symmetric secret (HS*)
Omit jwks and pass a secret — SdCoreModule wires the symmetric JwtStrategy:
SdCoreModule.forRoot({ jwt: { secret: process.env.JWT_SECRET! } });Internal (service-to-service) calls
InternalGuard gates internal-only endpoints with a shared secret in the X-Internal-Secret header, compared in constant time. Two DI hooks make it production-ready:
1. Provide the secret — IInternalSecretProvider
import { Injectable } from '@nestjs/common';
import type { IInternalSecretProvider } from '@sdcorejs/nestjs/auth';
@Injectable()
export class AppInternalSecretProvider implements IInternalSecretProvider {
getKey(): string {
return process.env.INTERNAL_SECRET!;
}
// Optional — zero-downtime rotation: return BOTH the outgoing and incoming secret during
// the transition window. When present, the guard accepts a match against ANY key.
getKeys(): string[] {
return [process.env.INTERNAL_SECRET!, process.env.INTERNAL_SECRET_NEXT!].filter(Boolean);
}
}2. Carry trusted context — IInternalContextEnricher (optional)
Internal calls arrive with no authenticated user. The enricher runs only after the secret check passes, so context derived from inbound headers is trusted on verified internal traffic and never on public traffic.
import { Injectable } from '@nestjs/common';
import type { IncomingMessage } from 'node:http';
import { ContextService } from '@sdcorejs/nestjs/core';
import type { IInternalContextEnricher } from '@sdcorejs/nestjs/auth';
@Injectable()
export class AppInternalEnricher implements IInternalContextEnricher {
constructor(private readonly ctx: ContextService) {}
enrich(req: IncomingMessage): void {
const h = req.headers;
this.ctx.set('tenant', h['x-tenant'] as string);
this.ctx.set('userId', h['x-user-id'] as string);
this.ctx.set('custom', { isInternalCall: true, caller: h['x-caller'] });
}
}Apply per route:
import { Controller, Post, UseGuards } from '@nestjs/common';
import { InternalGuard } from '@sdcorejs/nestjs/auth';
@Controller('internal/sync')
@UseGuards(InternalGuard)
export class SyncController { /* ... */ }Register both providers via SdCoreModule.forRoot({ providers: [...] }) (see Quick start). With no secret provider registered, the guard throws 500 at request time (not at boot), keeping the DI graph bootable.
The enricher sets
custom.isInternalCall, which yourITenancyStrategy.shouldBypass()can read to skip tenant filtering on internal calls.
Request context
ContextService is an AsyncLocalStorage-backed singleton — per-request isolation without request-scoped DI. ContextMiddleware populates it from headers.
| Accessor | Source |
|---|---|
| userId | x-user-id header / JWT |
| tenant | x-tenant header |
| lang | accept-language / x-language (raw string; consumer parses to a locale) |
| token, user, permissions | filled by AuthGuard after JWT validation |
| hasPermission(code) | checks the synced permissions set |
| getCustom<T>(key) | reads a consumer value from ctx.custom |
The library keeps only framework-generic keys. Domain values go in ctx.custom, or add typed fields via declaration merging:
declare module '@sdcorejs/nestjs/core' {
interface RequestContext {
departmentCode?: string;
isSystemAdmin?: boolean;
}
}ORM base classes
BaseController → BaseService → BaseRepository, parameterized by entity T and DTO TDto.
// repository.ts
import { Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { BaseRepository } from '@sdcorejs/nestjs/core';
@Injectable()
export class ProductRepository extends BaseRepository<Product> {
constructor(ds: DataSource, /* inject strategies + ContextService via options */) {
super(Product, ds, { /* tenancyStrategy, auditStrategy, contextService */ });
}
}BaseController mounts the standard endpoint set:
| Method | Route | Service call |
|---|---|---|
| POST | /search | search(keyword, filters) |
| POST | /paging | paging(req) — pageSize capped at 200 |
| GET | /:id | detail(id) (tenancy-scoped) |
| DELETE | /:id | delete(id) |
all() (unbounded full-table read), pagingDeleted, soft-delete and restore live on BaseService/BaseRepository but are not exposed by the controller — add an @Get('all') in your subclass for the specific entities where a full read is appropriate. @SearchableFields({ exact, contain, activeColumn }) configures the search endpoint; @Schema adds DTO introspection metadata.
Validation (Zod v4)
Requires
zod@^4. Zod v3 is not supported (issue shape differs).
ZodValidationGuard validates request[source] and replaces the raw input with the coerced value. Set each field's message to an i18n code — the i18n layer localizes it.
import { z } from 'zod';
import { UseGuards } from '@nestjs/common';
import { AuthGuard } from '@sdcorejs/nestjs/auth';
import { ZodValidationGuard, zPaging } from '@sdcorejs/nestjs/validation';
const CreateProduct = z.object({
name: z.string().min(3, 'core.product.name.min'),
price: z.coerce.number().positive('core.product.price.positive'),
});
// single source
@UseGuards(AuthGuard, ZodValidationGuard(CreateProduct))
@Post() create(@Body() dto: z.infer<typeof CreateProduct>) {}
// multiple sources at once — issues from every part merge into one envelope
@UseGuards(AuthGuard, ZodValidationGuard({ body: CreateProduct, query: zPaging }))
@Post('search') search() {}- Guard order: place AFTER
AuthGuardso unauthenticated requests never reach validation. - Query presets (params arrive as strings):
zPaging({ pageNumber, pageSize }matchingBaseRepositorycaps),zUuid(msgCode?),zBool('true'/'1'/'yes'→true). - Issue params: each
ZodIssueDetailcarries{ path, message, code, params? }.paramsholds JSON-safe interpolation vars (minimum,maximum,format,expected, …) so the i18n layer can render "must be at least {minimum}". - Failures throw
BadRequestException(apiError('core.validation.failed', …, { issues })).
Express 5 note: query / params are getter-only, so the guard mutates them in place; body is reassigned.
Internationalised errors
Producers across the library throw i18n codes, not sentences:
import { apiError } from '@sdcorejs/nestjs/core';
throw new BadRequestException(apiError('core.validation.failed', 'Validation failed', { issues }));@sdcorejs/nestjs/i18n closes the loop end-to-end:
SdI18nExceptionFilter— catchesHttpExceptions carrying anapiErrorbody, localizesmessagevia the resolver using the request'sctx.lang, emits the{ error: { code, message, data } }envelope.codeis preserved for client-side handling.SimpleI18nResolver— catalog lookupcatalogs[lang][code] → catalogs[fallback][code] → code, with{var}interpolation fromdata(+ Zod issueparams). For ICU / plurals, implement a customII18nResolver.DefaultLanguageResolver— parses the rawAccept-Languageheader (vi-VN,vi;q=0.9,en;q=0.8) to a supported base code, q-sorted, with fallback.- Built-in catalogs — en + vi messages for every
core.*code the library throws, shipped inCORE_CATALOGS. Merge your app's catalog over them.
Enable via the i18n key (opt-in — omit to leave envelopes untranslated):
SdCoreModule.forRoot({
i18n: {
fallbackLanguage: 'vi',
supportedLanguages: ['en', 'vi'],
catalogs: { // merged OVER built-in core.* (consumer wins)
vi: { 'app.product.name.min': 'Tên phải có ít nhất {minimum} ký tự' },
},
// resolver: MyIcuResolver, // optional: replace SimpleI18nResolver entirely
// useGlobalFilter: false, // optional: skip the global APP_FILTER
},
});ApiResponse.ok(data) / ApiResponse.noContent() wrap successful responses.
Background jobs (BullMQ)
@sdcorejs/nestjs/queue wraps @nestjs/bullmq with one shared Redis connection + production job
defaults (attempts: 3, exponential backoff, bounded removeOnComplete/removeOnFail). Import every
primitive from this one entry — QueueModule, SdWorkerHost, and the re-exported Processor /
InjectQueue / Job / Queue.
// 1. open the connection (or via SdCoreModule.forRoot({ queue: { connection } }))
@Module({ imports: [QueueModule.forRoot({ connection: { host: 'localhost', port: 6379, db: 1 } })] })
export class AppModule {}
// 2. register queues per module
@Module({ imports: [QueueModule.registerQueue('emails')], providers: [EmailsProcessor] })
export class EmailsModule {}
// 3. produce
@Injectable()
export class EmailsService {
constructor(@InjectQueue('emails') private emails: Queue) {}
welcome(userId: string) { return this.emails.add('welcome', { userId }, { delay: 5000 }); }
}
// 4. consume — subclass SdWorkerHost, throw on failure → BullMQ retries with backoff
@Processor('emails', { concurrency: 5 })
export class EmailsProcessor extends SdWorkerHost<{ userId: string }> {
async handle(job: Job<{ userId: string }>) { await sendWelcome(job.data.userId); }
}Don't override
process()or swallow errors —SdWorkerHostre-throws so BullMQ records the failed attempt and appliesattempts+backoff. Use the queue for fan-out work; useJobScheduler.runExclusivewhen N nodes fire the same scheduled task and only one should run it.
Features
Three stateful modules ship from @sdcorejs/nestjs/features. Each is opt-in — wired only when its
key is present in SdCoreModule.forRoot({...}) — and each exports an entity you register with TypeORM
(autoLoadEntities: true or explicit listing). The two HTTP controllers are drop-in but NOT
auto-registered: add them to one of your modules' controllers array so they inherit that module's
route prefix.
Uploaded files
SdCoreModule.forRoot({
uploadedFile: {
// driver auto-detected: 's3' when creds present, else 'local'
bucket: process.env.S3_BUCKET,
accessId: process.env.S3_ACCESS_ID,
accessKey: process.env.S3_ACCESS_KEY,
cdnBaseUrl: process.env.S3_CDN, // builds the returned `cdn` field
folder: 'core', // permanent-file prefix (default 'core')
cleanupAfterDays: 7, // opt-in 03:00 cron purge of never-attached files
},
});UploadedFileService is globally provided — inject it anywhere:
const file = await uploads.upload(buffer, 'invoice.pdf', { module: 'crm', entity: 'order', entityId });
const { stream, fileName } = await uploads.download(file.id);
await uploads.setExtraData<{ ocr: string }>(file.id, { ocr: 'parsed text' });UploadedFile<TExtraData>— generic entity with anextraDatajsonb bag; type it per call.Service —
upload<T>(buffer, fileName?, meta?, extraData?)→ full row;download(id)→{ stream, fileName };findById<T>(id);setExtraData<T>(id, data); plususeFiles/markUsed/delete.Drop-in
UploadedFileController—POST /uploaded-file(multipart fieldfile; optionalmodule/entity/entityId/typequery params) andGET /uploaded-file/:id/download. Guarded byAuthGuard; needs@nestjs/platform-express. Mount it under your prefix:import { UploadedFileController } from '@sdcorejs/nestjs/features'; @Module({ controllers: [UploadedFileController] }) // a module routed under `core` export class CoreModule {} // → POST /core/uploaded-file, GET /core/uploaded-file/:id/downloadcleanupAfterDays— when set (> 0), a fixed@Cron('0 3 * * *')purges never-attached files (isUsed = false) older than N days. RequiresScheduleModule.forRoot()in the host. When thejobSchedulerfeature is also wired, each sweep takes the distributed DB lock so only one instance purges; otherwise it runs directly. Omit (or<= 0) to disable — nothing is deleted.
Action history
Records per-entity change history and reads it back. The acting user is resolved per request from
ContextService (default ctx.userId) or a consumer resolveActor(ctx).
SdCoreModule.forRoot({
actionHistory: { resolveActor: (ctx) => ({ userId: ctx.userId, username: ctx.user?.email }) },
});ActionHistoryService—record(entry)(called automatically byBaseRepositoryCUD whenlogHistoryis enabled) andall(tableId)→ newest-first DTO list.- Drop-in
ActionHistoryController—GET /action-history/:tableId. Guarded byAuthGuard; mount it under your prefix the same way asUploadedFileController(→GET /core/action-history/:tableId).
Job scheduler — distributed cron lock
Across N scaled nodes firing the same scheduled job, runExclusive guarantees a single winner runs it.
import { JobSchedulerService, JobSchedulerType } from '@sdcorejs/nestjs/features';
@Cron('*/5 * * * *')
async syncOrders() {
const { acquired } = await this.jobs.runExclusive(
{ code: 'sync-orders', runKey: thisTickIso, type: JobSchedulerType.SCHEDULE },
() => this.doSync(),
);
// every other node returns { acquired: false } and does nothing
}- Atomic
INSERT ... ON CONFLICT DO NOTHINGclaims the lock. On conflict it re-claims aFAILrun OR aRUNNINGrow whose lease has expired (default 15 min,leaseMs) —SUCCESSstays locked (run-once forINITIALjobs). The winner runsfnand recordsSUCCESS/FAIL; on error the run is markedFAILand re-thrown. - Heartbeat — while
fnruns,runExclusivetouchesmodifiedAtevery 60 s (default,heartbeatMs) so the lock stays inside its lease. Long-running jobs won't be reclaimed by another node. Disable withheartbeatMs: 0only for jobs guaranteed to finish in <leaseMs.
// Long-running import — extend lease + heartbeat interval accordingly.
await this.jobs.runExclusive(
{ code: 'nightly-import', runKey: dayIso, leaseMs: 2 * 60 * 60 * 1000, heartbeatMs: 30_000 },
() => this.doImport(),
);Enable with jobScheduler: {}.
Philosophy
- Fully neutral — no
tenantCode/departmentCodehardcoded; consumer chooses column names via@Scoped()and writes its own strategies. - Strategies are DI tokens, not subclassing — each concern defines an interface + a
*_STRATEGYsymbol + aDefault*no-op fallback. - No prototype pollution — no
String.isUuid()/Array.prototype.distinct(); use the exported helpers. - TypeORM 0.3.x bound — no ORM abstraction; the library leans into TypeORM directly.
- Bilingual errors — throw i18n codes, not sentences.
- TDD, high coverage — every behavior ships with a spec; release is blocked under the coverage threshold.
- Dual ESM + CJS — the
exportsfield maps both formats per sub-path.
See docs/migration-from-core-be.md for porting an existing core-be app.
License
MIT © 2026 Trần Thuận Nghĩa
