server/models/decorators/CounterCache.ts
Rama main · réplica de lectura del repositorio original.
import { isNil } from "es-toolkit/compat";
import type {
IncludeOptions,
InferAttributes,
Transaction,
WhereOptions,
} from "sequelize";
import type { ModelClassGetter } from "sequelize-typescript";
import { CacheHelper } from "@server/utils/CacheHelper";
import { RedisPrefixHelper } from "@server/utils/RedisPrefixHelper";
import { afterCommit } from "@server/storage/transaction";
import type Model from "../base/Model";
type RelationOptions = {
/** Reference name used in cache key. */
as: string;
/** The foreign key to use for the relationship query. */
foreignKey: string;
/** Optional include used in the count query for filtering through associations. */
include?: IncludeOptions[];
/** Optional additional where clause used in the count query. */
where?: WhereOptions;
};
/**
* A decorator that caches the count of a relationship and registers model lifecycle hooks
* to invalidate the cache when models are added or removed from the relationship.
*/
export function CounterCache<
TCreationAttributes extends InferAttributes<Model>,
TModelAttributes extends InferAttributes<Model>,
T extends typeof Model,
>(
classResolver: ModelClassGetter<TCreationAttributes, TModelAttributes>,
options: RelationOptions
) {
return function (target: InstanceType<T>, _propertyKey: string) {
const modelClass = classResolver() as typeof Model;
const modelName = target.constructor.name;
const buildCacheKey = (id: unknown) =>
RedisPrefixHelper.getCounterCacheKey(modelName, options.as, String(id));
const computeCount = (id: unknown) =>
modelClass.count({
where: Object.assign({ [options.foreignKey]: id }, options.where),
include: options.include,
distinct: !!options.include,
});
const invalidate = async (
model: InstanceType<T>,
hookOptions?: { transaction?: Transaction | null }
) => {
const cacheKey = buildCacheKey(
model[options.foreignKey as keyof typeof model]
);
// Defer invalidation until after the transaction commits so that a
// rollback does not leave the cache out of sync, and so that a stale
// pre-commit count is not re-cached by a concurrent reader.
await afterCommit(hookOptions?.transaction, () =>
CacheHelper.removeData(cacheKey)
);
};
// The model class is not added to a Sequelize instance until the database
// module is first imported, which is later than decorator evaluation. Poll
// until the model is ready, then register the hooks. Use unref() so the
// pending immediate does not keep the event loop alive in environments
// (such as tests) where the database is never initialized.
const registerHooks = () => {
if (!modelClass.sequelize) {
setImmediate(registerHooks).unref();
return;
}
modelClass.addHook("afterCreate", invalidate);
modelClass.addHook("afterDestroy", invalidate);
};
setImmediate(registerHooks).unref();
return {
get() {
const cacheKey = buildCacheKey(this.id);
return CacheHelper.getData<number>(cacheKey).then((value) => {
if (!isNil(value)) {
return value;
}
return computeCount(this.id).then((count) => {
void CacheHelper.setData(cacheKey, count);
return count;
});
});
},
// oxlint-disable-next-line @typescript-eslint/no-explicit-any -- TS rejects PropertyDescriptor as legacy decorator return type; descriptor is consumed by Sequelize at runtime.
} as any;
};
}