UNPKG

alepha

Version:

Easy-to-use modern TypeScript framework for building many kind of applications.

1,144 lines (1,031 loc) 35.9 kB
import { $hook, $inject, Alepha, AlephaError, SchemaValidator, type Static, type TObject, z, } from "alepha"; import { CryptoProvider } from "alepha/crypto"; import { DateTimeProvider } from "alepha/datetime"; import { LockProvider } from "alepha/lock"; import { $logger } from "alepha/logger"; import { $repository, RepositoryProvider } from "alepha/orm"; import { currentTenantAtom, currentUserAtom } from "alepha/security"; import { $topic } from "alepha/topic"; import { type Parameter, parameters } from "../entities/parameters.ts"; import type { ParameterPrimitive } from "../primitives/$parameter.ts"; import type { ParameterStatus } from "../schemas/parameterStatusSchema.ts"; import type { ParameterTreeNode } from "../schemas/parameterTreeNodeSchema.ts"; /** * Payload for parameter change events across instances. */ export interface ParameterChangePayload { name: string; instanceId: string; } /** * A parameter with a calculated status field. */ export type ParameterWithStatus = Parameter & { status: ParameterStatus }; /** * ParameterProvider manages versioned parameter persistence, caching, * migration, and synchronization. * * Features: * - Stores all parameter versions in the database * - Derives status from activationDate at query time (no stored status) * - Provides cross-instance notification via topic * - Supports schema migrations via hash comparison * - Manages per-parameter caching, loading, and subscriber notification */ export class ParameterProvider { protected readonly log = $logger(); protected readonly alepha = $inject(Alepha); protected readonly dateTimeProvider = $inject(DateTimeProvider); protected readonly crypto = $inject(CryptoProvider); protected readonly lockProvider = $inject(LockProvider); protected readonly schemaValidator = $inject(SchemaValidator); protected readonly repositoryProvider = $inject(RepositoryProvider); protected readonly repo = $repository(parameters); /** * Unique identifier for this instance (to avoid self-updates). */ protected get instanceId(): string { this._instanceId ??= this.crypto.randomUUID(); return this._instanceId; } protected _instanceId: string | undefined; /** * Resolve the active tenant for cache keying — MIRRORS the Repository's * `resolveOrganizationValue` (tenant atom → user org) so the in-memory * value caches partition exactly the way the DB rows do. Returns a sentinel * for the org-less (single-tenant / no-request) case. * * Why this matters: the DB table is now org-scoped, but these process-global * Maps are keyed by parameter NAME — without folding the org into the key, a * pooled multi-tenant worker would hand org A's cached `club.settings` to a * request for org B. (Cross-instance topic sync + the `ready` preload run * with no request atom → the sentinel key; they refresh only org-less rows, * leaving per-org caches to lazy-load + the immediate local update on `set`. * That is bounded staleness, never a cross-tenant read.) */ protected orgKey(): string { const tenant = this.alepha.store.get(currentTenantAtom); if (tenant?.id) return tenant.id; const user = this.alepha.store.get(currentUserAtom); return user?.organization ?? "~global"; } /** Per-org cache key for the value caches (`${org}:${name}`). */ protected cacheKey(name: string): string { return `${this.orgKey()}:${name}`; } /** * In-memory cache of registered parameter primitives. Keyed by NAME only — * the `$parameter` definition (schema + default) is identical for every * tenant; only the stored VALUE is per-org (see the value caches below). */ protected readonly primitives = new Map<string, ParameterPrimitive<any>>(); /** * In-memory cached current content per parameter. */ protected readonly cachedCurrent = new Map<string, unknown>(); /** * In-memory cached next version info per parameter. */ protected readonly cachedNext = new Map< string, { content: unknown; activationDate: string } >(); /** * Set of parameter names that have completed initial load. */ protected readonly loaded = new Set<string>(); /** * Epoch millis of the last successful load (or local set) per cache key. * Drives the serverless revalidation TTL (see `revalidateAfterMs`). */ protected readonly loadedAt = new Map<string, number>(); /** * How long a cached value may serve before `get()` re-reads the DB. * * The in-memory caches are PER ISOLATE. On serverless runtimes * (Cloudflare Workers) many isolates serve the same app and the * cross-instance `syncTopic` rides the queue provider — in-memory by * default, so a `set()` handled by one isolate never reaches the others: * without a TTL they serve the stale value until they are recycled. * * Defaults: 30 s on serverless, 0 (= never revalidate, the historical * behaviour) elsewhere. Override with the `PARAMETERS_CACHE_TTL_MS` env. */ protected get revalidateAfterMs(): number { const raw = this.alepha.env.PARAMETERS_CACHE_TTL_MS as | string | number | undefined; if (raw !== undefined && raw !== "") return Number(raw); return this.alepha.isServerless() ? 30_000 : 0; } /** * Shared promises for deduplicating concurrent load() calls. */ protected readonly loadPromises = new Map<string, Promise<void>>(); /** * Generation counter per parameter — incremented on each doLoad call. * Used to discard results from superseded loads. */ protected readonly loadGeneration = new Map<string, number>(); /** * Subscriber callbacks per parameter name. */ protected readonly subscribers = new Map< string, Array<(v: unknown) => void> >(); /** * Set of parameter names that have already been checked for migration. */ protected readonly migrationChecked = new Set<string>(); /** * Computed schema hashes per parameter name. */ protected readonly schemaHashes = new Map<string, string>(); /** * Pre-load all registered parameters on ready (non-serverless only). */ protected readonly onReady = $hook({ on: "ready", handler: async () => { if (this.alepha.isServerless()) { return; } for (const name of this.primitives.keys()) { await this.migrateWithLock(name); } }, }); /** * Topic for cross-instance change notification. * Payload is minimal — receivers call load() to fetch fresh data. */ public readonly syncTopic = $topic({ name: "parameter:sync", schema: { payload: z.object({ name: z.text(), instanceId: z.text(), }), }, handler: async ({ payload }) => { await this.handleChangeNotification(payload as ParameterChangePayload); }, }); // --------------------------------------------------------------------------- // Registration // --------------------------------------------------------------------------- /** * Register a parameter primitive with the provider. * Computes and stores the schema hash. */ public register(param: ParameterPrimitive<any>): void { this.primitives.set(param.name, param); this.schemaHashes.set(param.name, this.calculateSchemaHash(param.schema)); } // --------------------------------------------------------------------------- // Public API used by $parameter primitive (thin delegates) // --------------------------------------------------------------------------- /** * Get the current parameter value asynchronously. * Lazy-loads from database on first call. * Checks if a cached next version has become current. */ public async get(name: string): Promise<unknown> { const ck = this.cacheKey(name); if (!this.loaded.has(ck) || this.isStale(ck)) { if (!this.loadPromises.has(ck)) { this.loadPromises.set(ck, this.doLoad(name)); } await this.loadPromises.get(ck); } // Check if cached next has become current const cachedNext = this.cachedNext.get(ck); if (cachedNext) { const now = this.dateTimeProvider.now().toDate(); if (new Date(cachedNext.activationDate) <= now) { this.cachedCurrent.set(ck, cachedNext.content); this.cachedNext.delete(ck); this.reloadNextInBackground(name); } } const param = this.primitives.get(name); return this.cachedCurrent.has(ck) ? this.cachedCurrent.get(ck) : param?.options.default; } /** * Set a new parameter value. */ public async set( name: string, value: unknown, options: SaveParameterOptions = {}, ): Promise<void> { const schemaHash = this.schemaHashes.get(name) ?? ""; await this.save(name, value as Record<string, unknown>, schemaHash, { activationDate: options.activationDate, changeDescription: options.changeDescription, tags: options.tags, creatorId: options.creatorId, creatorName: options.creatorName, }); // Update local cache const ck = this.cacheKey(name); const now = this.dateTimeProvider.now().toDate(); if (!options.activationDate || options.activationDate <= now) { const prev = this.cachedCurrent.get(ck); this.cachedCurrent.set(ck, value); if (JSON.stringify(prev) !== JSON.stringify(value)) { this.notifySubscribers(name); } } else { this.cachedNext.set(ck, { content: value, activationDate: options.activationDate.toISOString(), }); } // The writer is by definition fresh — restart its revalidation window. this.loadedAt.set(ck, this.dateTimeProvider.nowMillis()); } /** Whether the cached value outlived the revalidation TTL. */ protected isStale(ck: string): boolean { const ttl = this.revalidateAfterMs; if (ttl <= 0) return false; const at = this.loadedAt.get(ck); return at === undefined ? true : this.dateTimeProvider.nowMillis() - at > ttl; } /** * Subscribe to parameter changes. * Returns an unsubscribe function. */ public sub(name: string, fn: (v: unknown) => void): () => void { const ck = this.cacheKey(name); if (!this.subscribers.has(ck)) { this.subscribers.set(ck, []); } this.subscribers.get(ck)!.push(fn); return () => { const subs = this.subscribers.get(ck); if (subs) { const idx = subs.indexOf(fn); if (idx >= 0) { subs.splice(idx, 1); } } }; } /** * Load current and next values from database. * Deduplicates concurrent calls via shared promise. */ public async load(name: string): Promise<void> { const ck = this.cacheKey(name); this.loadPromises.set(ck, this.doLoad(name)); await this.loadPromises.get(ck); } /** * Get the cached current content, falling back to default. * Synchronous access for admin API. */ public getCachedCurrentContent(name: string): unknown { const ck = this.cacheKey(name); if (this.cachedCurrent.has(ck)) { return this.cachedCurrent.get(ck); } const param = this.primitives.get(name); return param?.options.default; } /** * Whether the parameter is using its default value (no DB value loaded). */ public isUsingDefault(name: string): boolean { return !this.cachedCurrent.has(this.cacheKey(name)); } // --------------------------------------------------------------------------- // Database operations // --------------------------------------------------------------------------- /** * Load the current and next parameter values from database. * Current: latest version with activationDate <= now. * Next: earliest version with activationDate > now. */ public async loadCurrentAndNext( name: string, ): Promise<{ current: Parameter | null; next: Parameter | null; now: Date }> { const now = this.dateTimeProvider.now().toDate(); const nowIso = now.toISOString(); const [currentRows, nextRows] = await Promise.all([ this.repo.findMany({ where: { name, activationDate: { lte: nowIso } }, orderBy: { column: "activationDate", direction: "desc" }, limit: 2, }), this.repo.findMany({ where: { name, activationDate: { gt: nowIso } }, orderBy: { column: "activationDate", direction: "asc" }, limit: 1, }), ]); // Secondary sort by version descending for same-millisecond ties const current = currentRows.length > 1 && new Date(currentRows[0].activationDate).getTime() === new Date(currentRows[1].activationDate).getTime() ? [...currentRows].sort((a, b) => b.version - a.version)[0] : (currentRows[0] ?? null); return { current: current ?? null, next: nextRows[0] ?? null, now, }; } /** * Calculate statuses for a list of parameter versions. * Derives status from activationDate relative to now: * - The latest version with activationDate <= now is "current" * - The earliest version with activationDate > now is "next" * - Other future versions are "future" * - Other past versions are "expired" */ public calculateStatuses<T extends Parameter>( versions: T[], now?: Date, ): Array<T & { status: ParameterStatus }> { const effectiveNow = now ?? this.dateTimeProvider.now().toDate(); // Sort by activationDate ascending, then version ascending for ties const sorted = [...versions].sort((a, b) => { const timeDiff = new Date(a.activationDate).getTime() - new Date(b.activationDate).getTime(); if (timeDiff !== 0) return timeDiff; return a.version - b.version; }); // Find which should be current (latest activated) const pastVersions = sorted.filter( (v) => new Date(v.activationDate) <= effectiveNow, ); const futureVersions = sorted.filter( (v) => new Date(v.activationDate) > effectiveNow, ); const currentVersion = pastVersions[pastVersions.length - 1]; const nextVersion = futureVersions[0]; return sorted.map((v) => { let status: ParameterStatus; if (currentVersion && v.id === currentVersion.id) { status = "current"; } else if (nextVersion && v.id === nextVersion.id) { status = "next"; } else if (new Date(v.activationDate) > effectiveNow) { status = "future"; } else { status = "expired"; } return { ...v, status }; }); } /** * Save a new parameter version. * * @param name - Parameter name (e.g., "app.features.flags") * @param content - New parameter content * @param schemaHash - Hash of the schema for migration detection * @param options - Additional options (activation date, creator info, etc.) */ public async save<T extends TObject>( name: string, content: Static<T>, schemaHash: string, options: SaveParameterOptions = {}, ): Promise<ParameterWithStatus> { // Resolve empty schema hash from registered primitive if (!schemaHash) { schemaHash = this.schemaHashes.get(name) ?? ""; } // Validate content against the registered schema when the schema hash // matches. A mismatched hash means the content is from a different // schema version (e.g., migration seed) and should not be validated // against the current schema. const param = this.primitives.get(name); if (param && schemaHash === this.schemaHashes.get(name)) { content = this.alepha.codec.validate(param.schema, content) as Static<T>; } const now = this.dateTimeProvider.now().toDate(); const activationDate = options.activationDate ?? now; const isImmediate = activationDate <= now; // Get all versions to determine next version number and previous content const versions = await this.repo.findMany({ where: { name }, orderBy: { column: "version", direction: "desc" }, }); const latestVersion = versions[0]; const newVersion = (latestVersion?.version ?? 0) + 1; // Find previous content from the latest activated version const currentVersion = versions.find( (v) => new Date(v.activationDate) <= now, ); const previousContent = currentVersion?.content; // Check for schema migration let migrationLog: string | undefined; if (latestVersion && latestVersion.schemaHash !== schemaHash) { migrationLog = `Schema changed from ${latestVersion.schemaHash} to ${schemaHash} at version ${newVersion}`; this.log.info("Parameter schema migration detected", { name, migrationLog, }); } // Insert new version const inserted = await this.repo.create({ name, content: content as Record<string, unknown>, schemaHash, activationDate: activationDate.toISOString(), version: newVersion, changeDescription: options.changeDescription, tags: options.tags, creatorId: options.creatorId, creatorName: options.creatorName, previousContent: previousContent as Record<string, unknown> | undefined, migrationLog, }); // Calculate status from existing versions + the newly inserted row const withStatuses = this.calculateStatuses([...versions, inserted]); const insertedWithStatus = withStatuses.find((v) => v.id === inserted.id)!; // Publish change notification if activation is immediate if (isImmediate) { await this.publishChange(name); } this.log.info("Parameter saved", { name, version: newVersion, status: insertedWithStatus.status, }); return insertedWithStatus; } /** * Best-effort left join embedding the creating user on each version, so the * admin UI can render a human-readable identifier (live, not snapshotted) * instead of the bare `creatorId`. Joins `parameters.creatorId` → `users.id`. * * The `users` entity is resolved from the repository registry at runtime * rather than imported: that keeps the parameters module free of any * dependency on the users module (no import, no circular-import risk). When * the users module is not registered the join is skipped and `creator` comes * back undefined. */ protected resolveCreatorJoin() { const usersEntity = this.repositoryProvider .getRepositories() .find((repo) => repo.entity.name === "users")?.entity; if (!usersEntity) { return undefined; } return { creator: { join: usersEntity, on: ["creatorId", usersEntity.cols.id] as [ "creatorId", { name: string }, ], }, }; } /** * Get all versions of a parameter, each with the creating user embedded * (best-effort, see {@link resolveCreatorJoin}). */ public async getHistory( name: string, options?: { limit?: number; offset?: number }, ): Promise<Parameter[]> { const withCreator = this.resolveCreatorJoin(); return this.repo.findMany({ where: { name }, orderBy: { column: "version", direction: "desc" }, limit: options?.limit, offset: options?.offset, ...(withCreator ? { with: withCreator } : {}), }); } /** * Delete all versions of a parameter. */ public async delete(name: string): Promise<void> { await this.repo.deleteMany({ name: { eq: name } }); this.evictCaches(name); this.log.info("Parameter deleted", { name }); } /** * Delete all versions of many parameters by name in one repository call. */ public async deleteMany(names: string[]): Promise<string[]> { if (names.length === 0) return []; await this.repo.deleteMany({ name: { inArray: names } }); for (const name of names) { this.evictCaches(name); } this.log.info("Parameters deleted", { count: names.length }); return names; } /** Drop every per-org value cache entry for `name` in the active org. */ protected evictCaches(name: string): void { const ck = this.cacheKey(name); this.cachedCurrent.delete(ck); this.cachedNext.delete(ck); this.loaded.delete(ck); this.loadPromises.delete(ck); this.loadGeneration.delete(ck); this.migrationChecked.delete(ck); } /** * Get a specific version of a parameter. */ public async getVersion( name: string, version: number, ): Promise<Parameter | null> { const versions = await this.repo.findMany({ where: { name, version }, }); return versions[0] ?? null; } /** * Rollback to a previous version by creating a new version with old content. */ public async rollback( name: string, targetVersion: number, options: SaveParameterOptions = {}, ): Promise<ParameterWithStatus> { const target = await this.getVersion(name, targetVersion); if (!target) { throw new AlephaError( `Parameter version not found: ${name}@${targetVersion}`, ); } return this.save( name, target.content as Static<TObject>, target.schemaHash, { ...options, changeDescription: options.changeDescription ?? `Rollback to version ${targetVersion}`, }, ); } /** * Get current parameter value with fallback to default from registered primitive. * Returns the in-memory current value which may be the default if never saved. */ public getCurrentValue( name: string, ): { content: unknown; isDefault: boolean } | null { if (!this.primitives.has(name)) { return null; } return { content: this.getCachedCurrentContent(name), isDefault: this.isUsingDefault(name), }; } /** * Get parameter info including current value with default fallback. * * When no version exists in the DB yet but a primitive is registered, this * lazily materializes v1 from the primitive's `default`. After this call, * the parameter has a concrete current row admins can edit / roll back / * compare against, instead of running on phantom defaults-from-code state. * Idempotent: subsequent calls return the same row without re-creating. */ public async getCurrentWithDefault(name: string): Promise<{ current: ParameterWithStatus | null; next: ParameterWithStatus | null; defaultValue: unknown | null; currentValue: unknown | null; schema: Record<string, unknown> | null; }> { let { current, next } = await this.loadCurrentAndNext(name); // Get default and current from registered primitive const param = this.primitives.get(name); // No version in DB yet but the primitive is registered: seed v1 from // the compiled defaults. Always-on parameters are then a tangible row // the admin UI can edit (and that history can grow from). Unregistered // names (orphans from a removed `$parameter`) are not seeded. if (!current && param) { try { current = await this.save( name, param.options.default as Static<TObject>, this.schemaHashes.get(name) ?? "", { changeDescription: "Auto-seeded from compiled defaults" }, ); } catch (err) { // A concurrent caller may have raced us to insert v1; fall back to // re-reading instead of bubbling up a unique-constraint error. this.log.warn("Auto-seed of parameter failed, retrying read", { name, error: (err as Error).message, }); ({ current, next } = await this.loadCurrentAndNext(name)); } } const defaultValue = param?.options.default ?? null; const currentValue = this.getCachedCurrentContent(name) ?? null; // Serialize the Zod schema to JSON Schema for transport. `param.schema` is a // live `ZodObject` instance, not a plain record, so it fails the response's // `z.json()` validation ("expected record, received ZodObject") and the // admin UI (which round-trips via `jsonSchemaToZod`) can't rebuild a form // from it. Typebox schemas were already JSON Schema, hence this step was // implicit before the Zod migration. const schema = param?.schema ? z.toJSONSchema(param.schema) : null; return { current: current ? { ...current, status: "current" as const } : null, next: next ? { ...next, status: "next" as const } : null, defaultValue, currentValue, schema, }; } /** * Get all unique parameter names (for tree view). */ public async getParameterNames(): Promise<string[]> { const results = await this.repo.findMany({ columns: ["name"], distinct: ["name"], orderBy: { column: "name", direction: "asc" }, }); return results.map((r) => r.name); } /** * Build a tree structure from parameter names for UI. * Includes both database parameters and registered (but not yet saved) parameters. */ public async getParameterTree(): Promise<ParameterTreeNode[]> { const dbNames = await this.getParameterNames(); const registeredNames = Array.from(this.primitives.keys()); const allNames = [...new Set([...dbNames, ...registeredNames])].sort(); return this.buildTree(allNames); } // --------------------------------------------------------------------------- // Internal: loading, migration, notification // --------------------------------------------------------------------------- /** * Internal load implementation. * Fetches current and next from database, updates cache. */ protected async doLoad(name: string): Promise<void> { // Snapshot the org-scoped cache key at call time: the whole load runs in // one request context, so the atom (hence `ck`) is stable here. const ck = this.cacheKey(name); const gen = (this.loadGeneration.get(ck) ?? 0) + 1; this.loadGeneration.set(ck, gen); const { current, next } = await this.loadCurrentAndNext(name); const schemaHash = this.schemaHashes.get(name) ?? ""; // Superseded by a newer load — discard results if (this.loadGeneration.get(ck) !== gen) return; // Check if migration is needed if (current && !this.migrationChecked.has(ck)) { this.migrationChecked.add(ck); const migration = this.migrateValue( name, current.content, current.schemaHash, ); if (migration) { this.log.info("Auto-migrating parameter", { name, description: migration.description, }); await this.save( name, migration.value as Record<string, unknown>, schemaHash, { changeDescription: migration.description, }, ); // Reload after migration to get the new current const updated = await this.loadCurrentAndNext(name); if (updated.current) { this.cachedCurrent.set(ck, updated.current.content); } else { this.cachedCurrent.delete(ck); } if (updated.next) { this.cachedNext.set(ck, { content: updated.next.content, activationDate: updated.next.activationDate, }); } else { this.cachedNext.delete(ck); } this.loaded.add(ck); this.loadedAt.set(ck, this.dateTimeProvider.nowMillis()); this.loadPromises.delete(ck); this.notifySubscribers(name); return; } } const prev = this.cachedCurrent.get(ck); const hadPrev = this.cachedCurrent.has(ck); if (current) { this.cachedCurrent.set(ck, current.content); } else { this.cachedCurrent.delete(ck); } if (next) { this.cachedNext.set(ck, { content: next.content, activationDate: next.activationDate, }); } else { this.cachedNext.delete(ck); } this.loaded.add(ck); this.loadedAt.set(ck, this.dateTimeProvider.nowMillis()); this.loadPromises.delete(ck); if ( hadPrev && JSON.stringify(prev) !== JSON.stringify(this.cachedCurrent.get(ck)) ) { this.notifySubscribers(name); } } /** * Run migration with a distributed lock (Node.js only). * Ensures only one instance performs the migration while others wait. */ protected async migrateWithLock(name: string): Promise<void> { const lockKey = `parameter:migrate:${name}`; const lockId = this.crypto.randomUUID(); const value = await this.lockProvider.set(lockKey, lockId, true, 30_000); if (value === lockId) { // We got the lock — run migration try { await this.doLoad(name); } finally { await this.lockProvider.del(lockKey); } } else { // Another instance holds the lock — wait then load await this.waitForLock(lockKey); await this.doLoad(name); } } /** * Poll until a lock is released (or TTL expires). * Uses a probe-only SET NX with minimal TTL to detect release * without holding the lock longer than necessary. */ protected async waitForLock(lockKey: string): Promise<void> { const maxWait = 30_000; const probeId = this.crypto.randomUUID(); const start = this.dateTimeProvider.nowMillis(); while (this.dateTimeProvider.nowMillis() - start < maxWait) { await this.dateTimeProvider.wait(500); const value = await this.lockProvider.set(lockKey, probeId, true, 500); if (value === probeId) { await this.lockProvider.del(lockKey); return; } } } /** * Attempt to migrate a DB value to the current schema. * Returns the migrated value if successful, or null if no migration needed. * * Cascade: * 1. Run user migrate() if provided * 2. Validate result against schema * 3. If invalid, shallow merge DB value with defaults * 4. Validate merged result * 5. If still invalid, use defaults */ protected migrateValue( name: string, dbValue: unknown, dbSchemaHash: string, ): { value: unknown; description: string } | null { const schemaHash = this.schemaHashes.get(name) ?? ""; const param = this.primitives.get(name); if (!param) { return null; } // No migration if schema hash matches if (dbSchemaHash === schemaHash) { return null; } const schema = param.schema; const defaults = param.options.default; // Step 1: Try user-provided migrate function if (param.options.migrate) { try { const migrated = param.options.migrate(dbValue); if (this.isValid(schema, migrated)) { if (JSON.stringify(migrated) === JSON.stringify(dbValue)) { return null; } return { value: migrated, description: "Auto-migrated: user migration function", }; } this.log.warn( "Parameter migrate() returned invalid value, falling through to merge", { name }, ); } catch (err) { this.log.warn("Parameter migrate() threw, falling through to merge", { name, error: err, }); } } // Step 2: Strip unknown keys and check if DB value is valid for new schema const schemaKeys = new Set(Object.keys(schema.properties)); const stripped = this.pickSchemaKeys( dbValue as Record<string, unknown>, schemaKeys, ); if (this.isValid(schema, stripped)) { if (JSON.stringify(stripped) === JSON.stringify(dbValue)) { return null; } return { value: stripped, description: "Auto-migrated: stripped unknown fields", }; } // Step 3: Shallow merge DB value with defaults, keeping only schema keys const merged = this.pickSchemaKeys( Object.assign( {}, defaults as Record<string, unknown>, dbValue as Record<string, unknown>, ), schemaKeys, ); if (this.isValid(schema, merged)) { return { value: merged, description: "Auto-migrated: merged with defaults", }; } // Step 4: Full reset to defaults return { value: defaults, description: "Auto-migrated: reset to defaults (schema incompatible)", }; } /** * Reload next version info in background (non-blocking). */ protected reloadNextInBackground(name: string): void { const ck = this.cacheKey(name); this.loadCurrentAndNext(name) .then(({ next }) => { if (next) { this.cachedNext.set(ck, { content: next.content, activationDate: next.activationDate, }); } else { this.cachedNext.delete(ck); } }) .catch((err) => { this.log.warn("Failed to reload next parameter version", { name, error: err, }); }); } /** * Notify all subscribers of a value change. */ protected notifySubscribers(name: string): void { const ck = this.cacheKey(name); const subs = this.subscribers.get(ck); if (!subs) return; const param = this.primitives.get(name); const value = this.cachedCurrent.has(ck) ? this.cachedCurrent.get(ck) : param?.options.default; for (const fn of subs) { fn(value); } } /** * Probe whether a value matches the schema, without throwing or mutating. */ protected isValid(schema: TObject, value: unknown): boolean { try { this.schemaValidator.validate(schema, value, { trim: false, nullToUndefined: false, deleteUndefined: false, }); return true; } catch { return false; } } /** * Return a new object containing only keys present in the schema. */ protected pickSchemaKeys( obj: Record<string, unknown>, schemaKeys: Set<string>, ): Record<string, unknown> { const result: Record<string, unknown> = {}; for (const key of schemaKeys) { if (key in obj) { result[key] = obj[key]; } } return result; } /** * Calculate a hash of the schema for migration detection. * Uses CryptoProvider for proper SHA-256 hashing. */ protected calculateSchemaHash(schema: TObject): string { return this.crypto.hash(JSON.stringify(schema)); } /** * Publish change notification to other instances. */ protected async publishChange(name: string): Promise<void> { await this.syncTopic.publish({ name, instanceId: this.instanceId, }); } /** * Handle incoming change notification from other instances. * Reloads the parameter from DB. */ protected async handleChangeNotification( payload: ParameterChangePayload, ): Promise<void> { // Ignore messages from self if (payload.instanceId === this.instanceId) { return; } if (!this.primitives.has(payload.name)) { return; } await this.load(payload.name); } /** * Build tree structure from dot-notation names. */ protected buildTree(names: string[]): ParameterTreeNode[] { const root: ParameterTreeNode[] = []; for (const name of names) { const parts = name.split("."); let currentLevel = root; for (let i = 0; i < parts.length; i++) { const part = parts[i]; const isLeaf = i === parts.length - 1; const path = parts.slice(0, i + 1).join("."); let existing = currentLevel.find((n) => n.name === part); if (!existing) { existing = { name: part, path, isLeaf, children: [], }; currentLevel.push(existing); } if (isLeaf) { existing.isLeaf = true; } currentLevel = existing.children; } } return root; } } export interface SaveParameterOptions { activationDate?: Date; changeDescription?: string; tags?: string[]; creatorId?: string; creatorName?: string; }