import { DyFM_DBFilterSimple } from '@futdevpro/fsm-dynamo'; import { DyNTS_ScopedConfig_Level } from '../_enums/dynts-scoped-config-level.enum'; import { DyNTS_ScopedConfig } from '../_models/data-models/dynts-scoped-config.data-model'; import { DyNTS_ScopedConfigResolveContext, DyNTS_ScopedConfigResolvedFrom, DyNTS_ScopedConfigResolvedValue, DyNTS_ScopedConfigResolveOptions, DyNTS_ScopedConfigSetOptions, DyNTS_ScopedConfigValue } from '../_models/interfaces/dynts-scoped-config.interface'; import { DyNTS_ScopedConfig_DataService } from './dynts-scoped-config.data-service'; /** * Egy memória-cache bejegyzés (a `resolveAll` egy-Mongo-körös eredménye, rövid TTL-lel). */ interface DyNTS_ScopedConfigCacheEntry { /** A cache-elt aktív rekordok (egy kontextus-szeletre). */ records: DyNTS_ScopedConfig[]; /** Lejárati timestamp (epoch ms). */ expiresAt: number; } /** * `DyNTS_ScopedConfig_ControlService` — a DB-backed, scoped config GENERIKUS feloldó-engine (BFR-AM-004). * * Singleton. Domain-agnosztikus: NINCS beépített kulcs-katalógus / default — a katalógus a FOGYASZTÓ * dolga (a FAM `FAM_Config_ControlService` ennek vékony fogyasztója lesz). A bedrock CSAK a generikus * precedencia-engine + a tárolás + a rövid-TTL cache. * * **Precedencia (a legspecifikusabb nyer):** `scope (leaf→root, a legmélyebb találat) > table > global * > builtinDefault` (utóbbi a hívó által átadott fallback). A `resolve(key, opts)` egy kulcsot old fel; * a `resolveAll(ctx)` az összes konfigurált kulcsot egy Mongo-körrel; a `set(level, key, value, opts)` * archive-old-active + új aktív rekord + cache-invalidálás. * * **FIGYELEM (memory: dynts_dataservice_eager_resolve):** NEM tartunk élő `DyNTS_ScopedConfig_DataService` * mezőt (eager DB-resolve a base-ctor-ban) — minden DB-művelet előtt lazy `new ...DataService`. */ export class DyNTS_ScopedConfig_ControlService { private static _instance: DyNTS_ScopedConfig_ControlService; /** Default issuer a config-műveletekhez. */ private readonly issuer: string = 'DyNTS_ScopedConfig_ControlService'; /** Rövid-TTL memória-cache (kulcs: kontextus-szelet; `set` invalidálja). */ private cache: Map = new Map(); /** A cache TTL ms-ben (~5s; rövid, hogy a hot-path olcsó legyen, de a `set` után friss). */ private cacheTtlMs: number = 5000; static getInstance(): DyNTS_ScopedConfig_ControlService { if (!DyNTS_ScopedConfig_ControlService._instance) { DyNTS_ScopedConfig_ControlService._instance = new DyNTS_ScopedConfig_ControlService(); } return DyNTS_ScopedConfig_ControlService._instance; } // ========================================================================= // RESOLVE — precedencia: scope (leaf→root) > table > global > builtinDefault // ========================================================================= /** * Egy kulcs feloldása a legspecifikusabb beállított értékre. A precedencia: * (1) SCOPE szint a `scopePath` LEVÉBŐL a gyökér felé (az első/legmélyebb találat nyer), * (2) TABLE szint, (3) GLOBAL szint, (4) a hívó által átadott `builtinDefault` (ha van). * * A `scopePath` canonical formában érkezik (a fogyasztó read/write-path-ja oldotta fel). */ async resolve( key: string, options?: DyNTS_ScopedConfigResolveOptions, ): Promise> { const context: DyNTS_ScopedConfigResolveContext = { table: options?.table, scopePath: options?.scopePath, }; const records: DyNTS_ScopedConfig[] = await this.loadEffectiveRecords(context); return this.resolveFromRecords(key, records, context, options?.builtinDefault); } /** * A teljes effektív config egy adott kontextusra (batch). EGY Mongo-kör (cache-elt), majd memóriában * merge: minden KONFIGURÁLT kulcsra a feloldott érték + forrás-szint. A hot-path (read/write) ezt * használja, nem per-key kört. A builtin-only kulcsok NEM jelennek meg (nincs katalógus a bedrock-ban * — azokat a fogyasztó iterálja a saját katalógusából a `resolve`-on át). */ async resolveAll(context?: DyNTS_ScopedConfigResolveContext): Promise<{ [key: string]: DyNTS_ScopedConfigResolvedValue }> { const records: DyNTS_ScopedConfig[] = await this.loadEffectiveRecords(context); const result: { [key: string]: DyNTS_ScopedConfigResolvedValue } = {}; const keys: Set = new Set(records.map((record) => record.key)); for (const key of keys) { result[key] = this.resolveFromRecords(key, records, context); } return result; } /** * A `resolve` mag (memóriában, a betöltött rekord-halmazon). Külön metódus, hogy a `resolveAll` is * ezt használja (egyetlen Mongo-kör után minden kulcsra). A `builtinDefault` a hívó fallback-je. */ private resolveFromRecords( key: string, records: DyNTS_ScopedConfig[], context?: DyNTS_ScopedConfigResolveContext, builtinDefault?: T, ): DyNTS_ScopedConfigResolvedValue { const table: string | undefined = context?.table; const scopePath = context?.scopePath ?? []; // 1. SCOPE szint — a scopePath levéből a gyökér felé (leaf→root); az első találat nyer. if (table && scopePath.length) { for (let i = scopePath.length - 1; i >= 0; i--) { const scopeId: string = scopePath[i].scopeId; const hit: DyNTS_ScopedConfig | undefined = records.find((record) => record.level === DyNTS_ScopedConfig_Level.scope && record.tableScope === table && record.scopeId === scopeId && record.key === key, ); if (hit) { return this.toResolved(hit, 'scope'); } } } // 2. TABLE szint. if (table) { const hit: DyNTS_ScopedConfig | undefined = records.find((record) => record.level === DyNTS_ScopedConfig_Level.table && record.tableScope === table && record.key === key, ); if (hit) { return this.toResolved(hit, 'table'); } } // 3. GLOBAL szint. const globalHit: DyNTS_ScopedConfig | undefined = records.find((record) => record.level === DyNTS_ScopedConfig_Level.global && record.key === key, ); if (globalHit) { return this.toResolved(globalHit, 'global'); } // 4. builtinDefault (a hívó fallback-je; a bedrock NEM tart katalógust). return { value: builtinDefault, resolvedFrom: 'builtin', }; } /** Egy DB-rekordot a feloldott-érték shape-re (a forrás-szinttel + audittal). */ private toResolved( record: DyNTS_ScopedConfig, resolvedFrom: DyNTS_ScopedConfigResolvedFrom, ): DyNTS_ScopedConfigResolvedValue { return { value: record.value as T, resolvedFrom: resolvedFrom, scopeId: record.scopeId, setBy: record.setBy, setByDetail: record.setByDetail, }; } /** * A kontextusra releváns aktív rekordok betöltése EGY Mongo-körrel, cache-elve. A filter a global + * (ha van) table + a scopePath összes scopeId-jára szűkít — a merge memóriában fut. `set` invalidál. */ private async loadEffectiveRecords(context?: DyNTS_ScopedConfigResolveContext): Promise { const cacheKey: string = this.cacheKey(context); const cached: DyNTS_ScopedConfigCacheEntry | undefined = this.cache.get(cacheKey); if (cached && cached.expiresAt > Date.now()) { return cached.records; } const orClauses: DyFM_DBFilterSimple[] = [{ level: DyNTS_ScopedConfig_Level.global }]; if (context?.table) { orClauses.push({ level: DyNTS_ScopedConfig_Level.table, tableScope: context.table }); const scopeIds: string[] = (context.scopePath ?? []).map((ref) => ref.scopeId); if (scopeIds.length) { orClauses.push({ level: DyNTS_ScopedConfig_Level.scope, tableScope: context.table, scopeId: { $in: scopeIds } }); } } const dataService: DyNTS_ScopedConfig_DataService = new DyNTS_ScopedConfig_DataService({ issuer: this.issuer }); const records: DyNTS_ScopedConfig[] = await dataService.findActiveList({ $or: orClauses }); this.cache.set(cacheKey, { records: records, expiresAt: Date.now() + this.cacheTtlMs }); return records; } /** A cache-kulcs egy kontextus-szeletre (table + a scopePath scopeId-lánca). */ private cacheKey(context?: DyNTS_ScopedConfigResolveContext): string { const table: string = context?.table ?? '-'; const scopeIds: string = (context?.scopePath ?? []).map((ref) => ref.scopeId).join('>') || '-'; return `${table}|${scopeIds}`; } /** A teljes cache invalidálása (`set` után). */ invalidateCache(): void { this.cache.clear(); } /** A cache TTL felülírása (ms); a fogyasztó hangolhatja (pl. a saját `reference.cacheTtlMs`-éből). */ setCacheTtlMs(ttlMs: number): void { this.cacheTtlMs = ttlMs; } // ========================================================================= // SET — archive-old-active → write-new → invalidate cache // ========================================================================= /** * Egy config-érték beállítása egy adott szinten (BFR-AM-004). A `set`: * (1) az ugyanazon `(level,tableScope,scopeId,key)` kulcson lévő AKTÍV régi rekordot soft-delete-eli * (archív → history), (2) az új aktív rekordot kiírja (a `value` Mixed), (3) invalidálja a cache-t. * * **Domain-agnosztikus:** NINCS típus-/tartomány-validáció (az a fogyasztó katalógusának dolga); a * bedrock csak a feloldási-kulcs konzisztenciáját biztosítja (table-/scope-szinten a megfelelő * scope-azonosító jelenléte kötelező). */ async set( level: DyNTS_ScopedConfig_Level, key: string, value: unknown, options: DyNTS_ScopedConfigSetOptions = {}, ): Promise { this.assertLevelConsistency(level, key, options); const issuer: string = options.issuer ?? this.issuer; const filter: DyFM_DBFilterSimple = DyNTS_ScopedConfig_DataService.levelKeyFilter(level, key, options); // Régi aktív rekord (ha van) → soft-delete (archív; history-megőrzés). const findService: DyNTS_ScopedConfig_DataService = new DyNTS_ScopedConfig_DataService({ issuer: issuer }); const existing: DyNTS_ScopedConfig = await findService.findActive(filter); if (existing && existing._id) { await findService.deleteData(existing._id); } // Új aktív rekord — a `value` Mixed. const record: DyNTS_ScopedConfig = new DyNTS_ScopedConfig({ level: level, tableScope: options.tableScope, scopeId: options.scopeId, key: key, value: value, setBy: options.setBy ?? 'system', setByDetail: options.setByDetail, note: options.note, }); const writeService: DyNTS_ScopedConfig_DataService = new DyNTS_ScopedConfig_DataService({ data: record, issuer: issuer }); const saved: DyNTS_ScopedConfig = await writeService.saveData(record); this.invalidateCache(); return saved; } /** * A feloldási-kulcs minimál-konzisztenciája (NEM domain-validáció): table-/scope-szinten kötelező a * `tableScope`, scope-szinten a `scopeId`. Ezek nélkül a feloldási-kulcs értelmetlen lenne. */ private assertLevelConsistency(level: DyNTS_ScopedConfig_Level, key: string, options: DyNTS_ScopedConfigSetOptions): void { if ((level === DyNTS_ScopedConfig_Level.table || level === DyNTS_ScopedConfig_Level.scope) && !options.tableScope) { throw new Error(`DyNTS_ScopedConfig: '${key}' '${level}' szintű beállításához kötelező a tableScope.`); } if (level === DyNTS_ScopedConfig_Level.scope && !options.scopeId) { throw new Error(`DyNTS_ScopedConfig: '${key}' 'scope' szintű beállításához kötelező a scopeId.`); } } // ========================================================================= // HISTORY — felülírás-history (soft-delete-elt régi rekordok) // ========================================================================= /** * Egy feloldási-kulcs felülírás-historyja: a soft-delete-elt (archív) régi rekordok az archív * collection-ből. Audit-trail / rollback-alap. (A FOGYASZTÓ presetjei/auditja erre épülhetnek.) */ async getHistory( level: DyNTS_ScopedConfig_Level, key: string, options: DyNTS_ScopedConfigSetOptions = {}, ): Promise { const filter: DyFM_DBFilterSimple = DyNTS_ScopedConfig_DataService.levelKeyFilter(level, key, options); const dataService: DyNTS_ScopedConfig_DataService = new DyNTS_ScopedConfig_DataService({ issuer: options.issuer ?? this.issuer }); return dataService.getArchiveDataService().findDataList(filter, true); } }