Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/entities/model-metadata.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,16 @@ export class ModelMetadata extends CommonEntity {
@ManyToOne(() => FieldMetadata, {})
userKeyField: FieldMetadata;

@Column({ name: "cache_enabled", default: false })
cacheEnabled: boolean;

@Column({ name: "cache_strategy", default: "ttl", nullable: true })
cacheStrategy: 'ttl' | 'onReboot';

// -1 = no expiry (cache forever). Only applies to 'ttl' strategy.
@Column({ name: "cache_ttl", default: -1, nullable: true })
cacheTtl: number;

@Column({ default: false })
isSystem: boolean;

Expand Down
1 change: 1 addition & 0 deletions src/guards/authentication.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ export class AuthenticationGuard implements CanActivate {
AUTH_TYPE_KEY,
[context.getHandler(), context.getClass()],
) ?? [AuthenticationGuard.defaultAuthType];

const guards = authTypes.map((type) => this.authTypeGuardMap[type]).flat();
let error = new UnauthorizedException();

Expand Down
17 changes: 17 additions & 0 deletions src/helpers/cache-key.helper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { createHash } from 'crypto';
import { SelectQueryBuilder } from 'typeorm';

/**
* Builds a deterministic cache key from the model name and the fully resolved
* QueryBuilder SQL + bound parameters.
*
* Format: solidx:{modelName}:{sha256(sql::params)}
*
* Full SHA-256 (64 hex chars) is used intentionally — a collision would silently
* serve wrong data, so truncation is not acceptable.
*/
export function buildCacheKey(modelName: string, qb: SelectQueryBuilder<any>): string {
const raw = qb.getSql() + '::' + JSON.stringify(qb.getParameters());
const hash = createHash('sha256').update(raw).digest('hex');
return `solidx:${modelName}:${hash}`;
}
58 changes: 54 additions & 4 deletions src/repository/solid-base.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ import { camelize } from '@angular-devkit/core/src/utils/strings';
import { Logger } from '@nestjs/common';
import { CommonEntity } from 'src/entities/common.entity';
import { ModelMetadata } from 'src/entities/model-metadata.entity';
import { buildCacheKey } from 'src/helpers/cache-key.helper';
import { ActiveUserData } from 'src/interfaces/active-user-data.interface';
import { RequestContextService } from 'src/services/request-context.service';
import { CacheConfig, SolidCacheService } from 'src/services/solid-cache.service';
import {
DataSource,
EntityNotFoundError,
Expand All @@ -22,16 +24,64 @@ import { PickKeysByType } from 'typeorm/common/PickKeysByType';
export class SolidBaseRepository<T extends CommonEntity> extends Repository<T> {
protected readonly logger: Logger;

// undefined = not yet loaded, null = model not found in metadata
private _cacheConfig: CacheConfig | null | undefined = undefined;

constructor(
entity: EntityTarget<T>,
dataSource: DataSource,
protected readonly requestContextService: RequestContextService | null,
protected readonly securityRuleRepository: SecurityRuleRepository | null,
protected readonly solidCacheService?: SolidCacheService,
) {
super(entity, dataSource.createEntityManager());
this.logger = new Logger(this.constructor.name);
}

/**
* Loads cache config for this model once and memoizes it on the repo instance.
* Since repos are singletons, this results in a single DB query per model per
* app lifetime — all subsequent calls are served from the in-process field.
*/
private async getCacheConfig(): Promise<CacheConfig | null> {
if (this._cacheConfig !== undefined) return this._cacheConfig;

const modelName = this.modelSingularName();
const modelMeta = await this.manager.getRepository(ModelMetadata).findOne({
where: { singularName: modelName },
select: ['cacheEnabled', 'cacheStrategy', 'cacheTtl'],
});

this._cacheConfig = modelMeta
? { cacheEnabled: modelMeta.cacheEnabled, cacheStrategy: modelMeta.cacheStrategy, cacheTtl: modelMeta.cacheTtl }
: null;

return this._cacheConfig;
}

/**
* Wraps a QueryBuilder execution with cache read-through.
* If caching is not configured or not enabled for this model, the executor
* is called directly with no overhead.
*/
private async executeWithCache<R>(
qb: SelectQueryBuilder<T>,
executor: () => Promise<R>,
): Promise<R> {
if (!this.solidCacheService) return executor();

const config = await this.getCacheConfig();
if (!config?.cacheEnabled) return executor();

const key = buildCacheKey(this.modelSingularName(), qb);
const cached = await this.solidCacheService.get<R>(key);
if (cached !== null) return cached;

const result = await executor();
await this.solidCacheService.set(key, result, config.cacheStrategy, config.cacheTtl);
return result;
}

modelSingularName(): string {
return camelize(this.metadata.name);
}
Expand Down Expand Up @@ -84,7 +134,7 @@ export class SolidBaseRepository<T extends CommonEntity> extends Repository<T> {
if (options) qb.setFindOptions(options); // <- applies where, relations, select, order, etc.
}

return qb.getOne();
return this.executeWithCache(qb, () => qb.getOne());
}

/**
Expand Down Expand Up @@ -117,7 +167,7 @@ export class SolidBaseRepository<T extends CommonEntity> extends Repository<T> {
qb.setFindOptions(options);
}

return qb.getMany();
return this.executeWithCache(qb, () => qb.getMany());
}

/**
Expand All @@ -132,7 +182,7 @@ export class SolidBaseRepository<T extends CommonEntity> extends Repository<T> {
qb.setFindOptions(options);
}

return qb.getManyAndCount();
return this.executeWithCache(qb, () => qb.getManyAndCount());
}

/**
Expand All @@ -146,7 +196,7 @@ export class SolidBaseRepository<T extends CommonEntity> extends Repository<T> {
qb.setFindOptions(options);
}

return qb.getCount();
return this.executeWithCache(qb, () => qb.getCount());
}

/**
Expand Down
47 changes: 47 additions & 0 deletions src/seeders/seed-data/solid-core-metadata.json
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,53 @@
"relationCascade": "cascade",
"relationModelModuleName": "solid-core",
"isSystem": true
},
{
"name": "cacheEnabled",
"displayName": "Cache Enabled",
"type": "boolean",
"defaultValue": "false",
"required": false,
"unique": false,
"index": false,
"private": false,
"encrypt": false,
"columnName": "cache_enabled",
"isSystem": true
},
{
"name": "cacheStrategy",
"displayName": "Cache Strategy",
"type": "selectionStatic",
"ormType": "varchar",
"length": 64,
"required": false,
"unique": false,
"index": false,
"private": false,
"encrypt": false,
"columnName": "cache_strategy",
"defaultValue": "ttl",
"selectionValueType": "string",
"selectionStaticValues": [
"ttl:TTL",
"onReboot:On Reboot"
],
"isSystem": true
},
{
"name": "cacheTtl",
"displayName": "Cache TTL (seconds)",
"type": "int",
"ormType": "integer",
"defaultValue": "-1",
"required": false,
"unique": false,
"index": false,
"private": false,
"encrypt": false,
"columnName": "cache_ttl",
"isSystem": true
}
]
},
Expand Down
45 changes: 45 additions & 0 deletions src/services/cache-bootstrap.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
import { InjectEntityManager } from '@nestjs/typeorm';
import { ModelMetadata } from 'src/entities/model-metadata.entity';
import { EntityManager } from 'typeorm';
import { SolidCacheService } from './solid-cache.service';

@Injectable()
export class CacheBootstrapService implements OnApplicationBootstrap {
private readonly logger = new Logger(CacheBootstrapService.name);

constructor(
@InjectEntityManager()
private readonly entityManager: EntityManager,
private readonly solidCacheService: SolidCacheService,
) {}

/**
* On every app boot, bust Redis keys for all models configured with the
* 'onReboot' cache strategy. This ensures stale data from the previous
* instance does not survive a restart.
*
* In-memory store is cleared automatically on restart — no action needed there.
*/
async onApplicationBootstrap(): Promise<void> {
try {
const models = await this.entityManager.find(ModelMetadata, {
where: { cacheEnabled: true, cacheStrategy: 'onReboot' },
select: ['singularName'],
});

if (models.length === 0) return;

this.logger.log(`Busting onReboot cache for ${models.length} model(s)...`);

await Promise.all(
models.map(m => this.solidCacheService.bustModel(m.singularName)),
);

this.logger.log('onReboot cache bust complete.');
} catch (err) {
// Never block startup due to cache failures
this.logger.warn(`Cache bootstrap bust failed: ${err.message}`);
}
}
}
33 changes: 29 additions & 4 deletions src/services/crud.service.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { BadRequestException, NotFoundException } from "@nestjs/common";
import { DiscoveryService, ModuleRef } from "@nestjs/core";
import { buildCacheKey } from "src/helpers/cache-key.helper";
import { CacheConfig, SolidCacheService } from "src/services/solid-cache.service";
import { isArray } from "class-validator";
import { CommonEntity, SettingService, SolidBaseRepository, User } from "src";
import { ERROR_MESSAGES } from "src/constants/error-messages";
Expand Down Expand Up @@ -46,6 +48,7 @@ export class CRUDService<T extends CommonEntity> { // Add two generic value i.e
private _crudHelperService: CrudHelperService;
private _discoveryService: DiscoveryService;
private _settingService: SettingService;
private _solidCacheService: SolidCacheService;

constructor(
readonly entityManager: EntityManager,
Expand All @@ -72,6 +75,24 @@ export class CRUDService<T extends CommonEntity> { // Add two generic value i.e
return this._settingService ??= this.moduleRef.get(SettingService, { strict: false });
}

protected get solidCacheService(): SolidCacheService {
return this._solidCacheService ??= this.moduleRef.get(SolidCacheService, { strict: false });
}

private async executeQbWithCache<R>(
qb: SelectQueryBuilder<T>,
executor: () => Promise<R>,
config: CacheConfig,
): Promise<R> {
const key = buildCacheKey(this.modelName, qb);
const cached = await this.solidCacheService.get<R>(key);
if (cached !== null) return cached;

const result = await executor();
await this.solidCacheService.set(key, result, config.cacheStrategy, config.cacheTtl);
return result;
}

async create(createDto: any, files: Express.Multer.File[] = [], solidRequestContext: any = {}): Promise<T> {
// This class will be extended by the generated service class i.e PersonService
// The data required to identify the model and module name will be passed from the generate CrudService subclass
Expand Down Expand Up @@ -466,7 +487,8 @@ export class CRUDService<T extends CommonEntity> { // Add two generic value i.e
let { limit, offset, populateMedia, populateGroup, groupFilter } = basicFilterDto;
const populateUserIdFields = this.crudHelperService.extractUserIdFieldsFromPopulate(basicFilterDto.populate);

const { singularName, internationalisation, draftPublishWorkflow } = await this.loadModel();
const { singularName, internationalisation, draftPublishWorkflow, cacheEnabled, cacheStrategy, cacheTtl } = await this.loadModel();
const cacheConfig: CacheConfig = { cacheEnabled, cacheStrategy, cacheTtl };
// Check wheather user has update permission for model
if (solidRequestContext.activeUser) {
const hasPermission = this.crudHelperService.hasReadPermissionOnModel(solidRequestContext.activeUser, singularName);
Expand Down Expand Up @@ -522,16 +544,19 @@ export class CRUDService<T extends CommonEntity> { // Add two generic value i.e
qb = (internationalisation && draftPublishWorkflow)
? this.crudHelperService.buildFilterQuery(qb, basicFilterDto, alias, internationalisation, draftPublishWorkflow, this.moduleRef)
: this.crudHelperService.buildFilterQuery(qb, basicFilterDto, alias);
const { meta, records } = await this.handleNonGroupFind(qb, populateUserIdFields, populateMedia, offset, limit, alias);
const { meta, records } = await this.handleNonGroupFind(qb, populateUserIdFields, populateMedia, offset, limit, alias, cacheConfig);
return {
meta,
records,
}
}
}

private async handleNonGroupFind(qb: SelectQueryBuilder<T>, populateUserIdFields: UserIdFields[], populateMedia: string[], offset: number, limit: number, alias: string) {
const [entities, count] = await qb.getManyAndCount();
private async handleNonGroupFind(qb: SelectQueryBuilder<T>, populateUserIdFields: UserIdFields[], populateMedia: string[], offset: number, limit: number, alias: string, cacheConfig?: CacheConfig) {
const execute = () => qb.getManyAndCount();
const [entities, count] = cacheConfig?.cacheEnabled
? await this.executeQbWithCache(qb, execute, cacheConfig)
: await execute();

// Populate the entity with the userId fields
if (populateUserIdFields && populateUserIdFields.length > 0) {
Expand Down
Loading