Feature modules
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:tsimport { 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
}How the lock works
INSERT ... ON CONFLICT DO NOTHING RETURNING id— wins if no row exists forlockKey.- On conflict, re-claims:
- A
FAILrow — transient failure can be retried on the next fire. - A
RUNNINGrow whosemodifiedAtis older thanleaseMs— the node that held it crashed. SUCCESSrows stay locked permanently (run-once semantics forINITIALjobs).
- A
- The winner runs
fn, recordsSUCCESSorFAIL, and returns the result.
Heartbeat — preventing false stale-lease reclaims
runExclusive bumps modifiedAt on the lock row every heartbeatMs (default 60 s) while fn is running. This keeps the row inside its lease window so a live-but-slow job is never reclaimed by another node. Only disable heartbeating (heartbeatMs: 0) for jobs guaranteed to finish in less than leaseMs.
JobAcquireOptions
| Option | Type | Default | Description |
|---|---|---|---|
code | string | required | Stable job identifier |
runKey | string | — | Per-tick discriminator for SCHEDULE jobs (e.g. ISO timestamp truncated to the cron period) |
type | JobSchedulerType | SCHEDULE | SCHEDULE (recurring) or INITIAL (run-once) |
leaseMs | number | 900_000 (15 min) | Stale-lock window. Set above worst-case job runtime + heartbeat interval. |
heartbeatMs | number | 60_000 (60 s) | How often to touch modifiedAt. Set below leaseMs / 2. Set 0 to disable. |
// Long-running nightly import — widen the lease, tighten the heartbeat.
await this.jobs.runExclusive(
{
code: 'nightly-import',
runKey: dayIso,
type: JobSchedulerType.SCHEDULE,
leaseMs: 2 * 60 * 60 * 1000, // 2 hours
heartbeatMs: 30_000, // touch every 30 s
},
() => this.doImport(),
);Enable with jobScheduler: {}.
Register the entity
JobScheduler must be registered with TypeORM — either via autoLoadEntities: true or explicit listing. ScheduleModule.forRoot() is needed when the uploadedFile.cleanupAfterDays cleanup cron is also active.