/
mentoster
/
vk-stilus
Обзор
Документация
Войти
/
mentoster
/
vk-stilus
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
3
CI/CD
Аналитика
Безопасность
master
src/features/feature-controller.ts
264 строки
11 KB
mentoster
refactor(runtime): add explicit feature reconciliation
09 авг 2026, 10:46
09 авг 2026, 10:46
8048542
Код
Авторство
О чём код?
import { DisposalRegistry } from '../core/disposal-registry.js'; import { verifyInvariant } from '../core/invariant.js'; import { err, ok } from '../core/result.js'; import { FeatureEffectReporter } from './feature-effect-reporter.js'; import { reconcileFeature } from './feature-reconciliation.js'; import type { FeatureHookResult, FeatureLifecycleState, FeatureReconcileReason, FeatureResult, FeatureSnapshot, FeatureTransition, } from './feature-types.js'; const FEATURE_CONTEXT = Object.freeze({ owner: 'FeatureController' }); const FEATURE_ID_PATTERN = /^[a-z][a-z0-9-]{1,63}$/; /** @assertion-exception Pure feature error factory owns no lifecycle state. */ function featureError(code: string, id: string, state: FeatureLifecycleState) { return Object.freeze({ code, id, state }); } /** * @classdesc Base lifecycle contract for one isolated feature with bounded disposable ownership. * @responsibility Enforce initialize/activate/reconcile/deactivate/dispose transitions and own one DisposalRegistry. * @nonresponsibility Implement feature DOM behavior, storage, bridge operations, or registry ordering. * @trustBoundary Validates subclass hook Results before committing lifecycle state. * @lifecycle New/disposed -> initialized -> active -> repeated serialized reconcile -> inactive -> disposed. * @invariant Feature ID is stable and owned resources are empty after disposal. * @resourceBudget Inherits per-feature listener/timer/observer/other caps from DisposalRegistry. * @sideEffects Invokes subclass hooks and registered resource disposers. */ export class FeatureController { readonly id: string; state: FeatureLifecycleState; resources: DisposalRegistry; context: unknown; lastError: unknown; effects: FeatureEffectReporter; reconciling: boolean; constructor(options: Readonly<{ id: string }>) { this.id = options.id; this.state = 'new'; this.resources = new DisposalRegistry(this.id); this.context = null; this.lastError = null; this.effects = new FeatureEffectReporter(this.id); this.reconciling = false; const idMatches = FEATURE_ID_PATTERN.test(this.id); const resourceCount = this.resources.counts().total; const idValid = verifyInvariant(idMatches, 'feature.construct.id', FEATURE_CONTEXT); const resourceEmpty = verifyInvariant( resourceCount === 0, 'feature.construct.resources', FEATURE_CONTEXT, ); if (!idValid.ok || !resourceEmpty.ok) throw new TypeError('FeatureController id is invalid'); } /** @assertion-exception Default subclass hook intentionally has no behavior. */ onInitialize(_context: unknown): FeatureHookResult { return ok(undefined); } /** @assertion-exception Default subclass hook intentionally has no behavior. */ onActivate(_context: unknown): FeatureHookResult { return ok(undefined); } /** @assertion-exception Default reconcile is a no-op for features with no current host state to reapply. */ onReconcile(_context: unknown, _reason: FeatureReconcileReason): FeatureHookResult { return ok(undefined); } /** @assertion-exception Default subclass hook intentionally has no behavior. */ onDeactivate(_reason: string): FeatureHookResult { return ok(undefined); } /** @assertion-exception Default subclass hook intentionally has no behavior. */ onDispose(): FeatureHookResult { return ok(undefined); } async initialize(context: unknown): Promise<FeatureResult<FeatureTransition>> { if (this.state !== 'new' && this.state !== 'disposed') { return err(featureError('FEATURE_INITIALIZE_STATE_INVALID', this.id, this.state)); } if (this.state === 'disposed') { this.resources = new DisposalRegistry(this.id); this.effects.reset(); this.reconciling = false; } const initialized = await this.onInitialize(context); if (!initialized.ok) { this.lastError = initialized.error; this.effects.recordLifecycle('initialize', false, initialized.error); return initialized; } this.context = context; this.lastError = null; this.state = 'initialized'; this.effects.recordLifecycle('initialize', true); const resourceState = this.resources.counts(); const stateValid = verifyInvariant( this.state === 'initialized', 'feature.initialize.state', FEATURE_CONTEXT, ); const resourcesOpen = verifyInvariant( resourceState.disposed === false, 'feature.initialize.resources-open', FEATURE_CONTEXT, ); if (!stateValid.ok) return err(stateValid.error); return resourcesOpen.ok ? ok(Object.freeze({ changed: true })) : err(resourcesOpen.error); } async activate(context: unknown): Promise<FeatureResult<FeatureTransition>> { if (this.state === 'active') return ok(Object.freeze({ changed: false })); if (this.state !== 'initialized' && this.state !== 'inactive') { return err(featureError('FEATURE_ACTIVATE_STATE_INVALID', this.id, this.state)); } const activated = await this.onActivate(context); if (!activated.ok) { this.lastError = activated.error; this.recordApplyEffect(context, false, activated.error); return activated; } this.recordApplyEffect(context, true); this.context = context; this.lastError = null; this.state = 'active'; const resourceState = this.resources.counts(); const stateValid = verifyInvariant( this.state === 'active', 'feature.activate.state', FEATURE_CONTEXT, ); const resourcesOpen = verifyInvariant( resourceState.disposed === false, 'feature.activate.resources-open', FEATURE_CONTEXT, ); if (!stateValid.ok) return err(stateValid.error); return resourcesOpen.ok ? ok(Object.freeze({ changed: true })) : err(resourcesOpen.error); } /** * Reapply current host state without releasing activation-owned resources. * @assertion-exception Lifecycle orchestration delegates active-state/resource invariants to reconcileFeature(). * @param context Caller-owned current settings/page context. * @param reason Finite internal reason for this reconciliation wave. * @returns Checked transition; failure preserves active state and prior published context. * @sideEffects Invokes the subclass reconcile hook and updates bounded health metadata. */ /** @assertion-exception Lifecycle invariants are owned and checked by reconcileFeature(). */ async reconcile( context: unknown, reason: FeatureReconcileReason, ): Promise<FeatureResult<FeatureTransition>> { return reconcileFeature(this, context, reason); } async deactivate(reason: string): Promise<FeatureResult<FeatureTransition>> { if (this.reconciling) return err(featureError('FEATURE_RECONCILE_BUSY', this.id, this.state)); if (this.state === 'initialized' || this.state === 'inactive') { return ok(Object.freeze({ changed: false })); } if (this.state !== 'active') { return err(featureError('FEATURE_DEACTIVATE_STATE_INVALID', this.id, this.state)); } const deactivated = await this.onDeactivate(reason); if (!deactivated.ok) { this.lastError = deactivated.error; this.effects.recordLifecycle(reason, false, deactivated.error); return deactivated; } this.state = 'inactive'; this.effects.recordLifecycle(reason, true); const idMatches = FEATURE_ID_PATTERN.test(this.id); const stateValid = verifyInvariant( this.state === 'inactive', 'feature.deactivate.state', FEATURE_CONTEXT, ); const idStable = verifyInvariant(idMatches, 'feature.deactivate.id', FEATURE_CONTEXT); if (!stateValid.ok) return err(stateValid.error); return idStable.ok ? ok(Object.freeze({ changed: true })) : err(idStable.error); } /** @assertion-exception Disposal pre-step delegates transition assertions to deactivate(). */ async deactivateForDispose(): Promise<FeatureResult<FeatureTransition>> { return this.state === 'active' ? this.deactivate('dispose') : ok(Object.freeze({ changed: false })); } /** @assertion-exception Resource release delegates invariants to DisposalRegistry.disposeAll(). */ releaseResources(): FeatureResult<void> { return this.resources.counts().disposed ? ok(undefined) : this.resources.disposeAll(); } async dispose(): Promise<FeatureResult<FeatureTransition>> { if (this.state === 'disposed') return ok(Object.freeze({ changed: false })); const deactivated = await this.deactivateForDispose(); if (!deactivated.ok) return deactivated; const disposed = await this.onDispose(); if (!disposed.ok) { this.effects.recordLifecycle('dispose', false, disposed.error); return disposed; } const released = this.releaseResources(); if (!released.ok) return released; this.context = null; this.state = 'disposed'; this.effects.recordLifecycle('dispose', true); const resourceCount = this.resources.counts().total; const stateValid = verifyInvariant( this.state === 'disposed', 'feature.dispose.state', FEATURE_CONTEXT, ); const resourcesEmpty = verifyInvariant( resourceCount === 0, 'feature.dispose.resources-empty', FEATURE_CONTEXT, ); if (!stateValid.ok) return err(stateValid.error); return resourcesEmpty.ok ? ok(Object.freeze({ changed: true })) : err(resourcesEmpty.error); } /** @assertion-exception Pure immutable diagnostic snapshot owns no lifecycle transition. */ snapshot(): Readonly<FeatureSnapshot> { const resourceCounts = this.resources.counts(); return Object.freeze({ id: this.id, state: this.state, resourceCount: resourceCounts.total, lastError: this.lastError, health: this.effects.snapshot(this.state, resourceCounts), }); } /** @assertion-exception Pure reason projection reads one internal bootstrap-context field only. */ effectReason(context: unknown, fallback: string): string { if (context === null || typeof context !== 'object' || Array.isArray(context)) return fallback; const reason: unknown = Reflect.get(context, 'reason') as unknown; return typeof reason === 'string' ? reason : fallback; } /** @assertion-exception Diagnostic effect failure is checked but never changes product activation semantics. */ recordApplyEffect(context: unknown, success: boolean, failure: unknown = null): void { const recorded = this.effects.recordApply(this.effectReason(context, 'activate'), success, failure); if (!recorded.ok) this.effects.recordLifecycle('activate', false, recorded.error); } /** Record selector/target counts without retaining target identity or content. */ protected recordTargets(hits: number, misses: number): FeatureResult<void> { return this.effects.recordTargets(hits, misses); } }