UNPKG

alepha

Version:

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

1,439 lines (1,438 loc) 47 kB
import { $hook, $inject, $module, Alepha, AlephaError, KIND, Primitive, SchemaValidator, createPrimitive, z } from "alepha"; import { $audit } from "alepha/api/audits"; import { $secure, currentTenantAtom, currentUserAtom } from "alepha/security"; import { $action, okSchema } from "alepha/server"; import { $entity, $repository, RepositoryProvider, db } from "alepha/orm"; import { CryptoProvider } from "alepha/crypto"; import { DateTimeProvider } from "alepha/datetime"; import { LockProvider } from "alepha/lock"; import { $logger } from "alepha/logger"; import { $topic } from "alepha/topic"; //#region ../../src/api/parameters/audits/ParameterAudits.ts /** * Runtime parameter (config) audit events. * * Holds the `parameter` audit type. Using `$audit` pulls in the audits module * automatically — the parameters module does not need to import it. Register * as a variant and log via `parameterAudits.parameter.log("rollback", …)`. */ var ParameterAudits = class { parameter = $audit({ type: "parameter", description: "Runtime parameter changes (create, rollback, activate, delete).", actions: [ "create", "rollback", "activate", "delete" ] }); }; //#endregion //#region ../../src/api/parameters/entities/parameters.ts /** * Configuration parameter entity for versioned configuration management. * * Stores all versions of configuration parameters with: * - Status derived from activationDate at query time * - Schema versioning for migrations * - Activation scheduling * - Audit trail (creator info) */ const parameters = $entity({ name: "parameters", schema: z.object({ id: db.primaryKey(z.uuid()), createdAt: db.createdAt(), updatedAt: db.updatedAt(), /** * Tenant scope. NULLABLE on purpose (backward compatible): single-tenant * apps keep writing org-less (NULL) rows with the historic global * semantics. In a MULTI-TENANT process (e.g. one pooled worker serving * many orgs), the active tenant is stamped by the Repository * (`stampOrganization`) and reads/writes auto-filter by it — so one org's * `app.settings`/`portal.site`/… can never be read or overwritten by * another. The provider's in-memory value caches are keyed by org too. */ organizationId: db.organization(), /** * Configuration name using dot notation for tree hierarchy. * Examples: "app.features", "app.pricing.tiers", "system.limits" */ name: z.text(), /** * The configuration content as JSON. */ content: z.json(), /** * Schema version hash for detecting schema changes. * Used for auto-migration when schema evolves. */ schemaHash: z.text(), /** * When this version should become active. * Default is immediate (now). */ activationDate: z.datetime(), /** * Version number for this configuration. * Auto-incremented per config name. */ version: z.integer(), /** * Optional description of changes in this version. */ changeDescription: z.text().optional(), /** * Optional tags for filtering/categorization. */ tags: z.array(z.text()).optional(), /** * Creator user ID (if available). */ creatorId: z.uuid().optional(), /** * Creator display name for audit trail. */ creatorName: z.text().optional(), /** * Previous content before this change (for rollback reference). */ previousContent: z.json().optional(), /** * Migration log if schema changed. */ migrationLog: z.text().optional() }), indexes: [ { columns: [ "organizationId", "name", "activationDate" ] }, { columns: [ "organizationId", "name", "version" ], unique: true }, { columns: ["activationDate"] } ] }); //#endregion //#region ../../src/api/parameters/schemas/activateParameterBodySchema.ts /** * Activate parameter body schema. * * Creator fields are omitted; the controller captures the authenticated user * server-side. */ const activateParameterBodySchema = parameters.schema.pick({ version: true }); //#endregion //#region ../../src/api/parameters/schemas/createParameterVersionBodySchema.ts /** * Create parameter version body schema. * Uses z.pick to derive from entity, with required fields made non-optional. * * Creator fields are intentionally omitted: the controller captures the * authenticated user server-side, so they cannot be spoofed by the client. */ const createParameterVersionBodySchema = parameters.schema.pick({ content: true, schemaHash: true, changeDescription: true, tags: true }).extend({ activationDate: z.datetime().describe("When to activate (default: now)").optional() }); //#endregion //#region ../../src/api/parameters/schemas/parameterCreatorSummarySchema.ts /** * Slim view of a parameter version's creator, embedded by the admin history * endpoint via a best-effort left join (`parameters.creatorId` → `users.id`) * so the UI can render a human-readable identifier (and link) instead of a * bare UUID. * * Optional end-to-end: the join only runs when the `users` entity is * registered in the running app (see `ParameterProvider.resolveCreatorJoin`), * and a version whose creator was deleted — or who lives in a non-default * realm — comes back with `creator` undefined. Callers fall back to the raw * `creatorId`. */ const parameterCreatorSummarySchema = z.object({ id: z.uuid(), email: z.string().meta({ format: "email" }).optional(), username: z.shortText({ minLength: 3, maxLength: 30 }).optional(), firstName: z.string().optional(), lastName: z.string().optional() }); //#endregion //#region ../../src/api/parameters/schemas/parameterStatusSchema.ts /** * Parameter status enum schema. */ const parameterStatusSchema = z.enum([ "expired", "current", "next", "future" ]); //#endregion //#region ../../src/api/parameters/schemas/parameterResponseSchema.ts /** * Parameter response schema for API responses. * Extends the entity schema with a calculated status field. * Status is derived from activationDate at query time, not stored. * * `creator` is embedded on read via a best-effort left join on `creatorId` * (see `parameterCreatorSummarySchema`); it is not a stored column. */ const parameterResponseSchema = parameters.schema.extend({ status: parameterStatusSchema, creator: parameterCreatorSummarySchema.optional() }); //#endregion //#region ../../src/api/parameters/schemas/parameterCurrentResponseSchema.ts /** * Current parameter response schema. * Includes current version, next scheduled version, and defaults. */ const parameterCurrentResponseSchema = z.object({ current: parameterResponseSchema.optional(), next: parameterResponseSchema.optional(), defaultValue: z.json().optional(), currentValue: z.json().optional(), schema: z.json().optional() }); //#endregion //#region ../../src/api/parameters/schemas/parameterHistoryResponseSchema.ts /** * Parameter history response schema. */ const parameterHistoryResponseSchema = z.object({ versions: z.array(parameterResponseSchema) }); //#endregion //#region ../../src/api/parameters/schemas/parameterNameParamSchema.ts /** * Parameter name param schema. * Uses z.pick from entity for consistency. */ const parameterNameParamSchema = parameters.schema.pick({ name: true }); //#endregion //#region ../../src/api/parameters/schemas/parameterNamesResponseSchema.ts /** * Parameter names list response schema. */ const parameterNamesResponseSchema = z.object({ names: z.array(z.text()) }); //#endregion //#region ../../src/api/parameters/schemas/parameterTreeNodeSchema.ts /** * Tree node schema for parameter tree navigation. */ const parameterTreeNodeSchema = z.object({ name: z.text(), path: z.text(), isLeaf: z.boolean(), children: z.array(z.any()) }); //#endregion //#region ../../src/api/parameters/schemas/parameterVersionParamSchema.ts /** * Parameter name and version param schema. * Uses z.pick from entity for consistency. */ const parameterVersionParamSchema = parameters.schema.pick({ name: true, version: true }); //#endregion //#region ../../src/api/parameters/schemas/parameterVersionResponseSchema.ts /** * Parameter version response schema. */ const parameterVersionResponseSchema = z.object({ parameter: parameterResponseSchema.optional() }); //#endregion //#region ../../src/api/parameters/schemas/rollbackParameterBodySchema.ts /** * Rollback parameter body schema. * * Creator fields are omitted; the controller captures the authenticated user * server-side. */ const rollbackParameterBodySchema = parameters.schema.pick({ changeDescription: true }).extend({ targetVersion: z.integer().describe("Version number to rollback to") }); //#endregion //#region ../../src/api/parameters/services/ParameterProvider.ts /** * 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 */ var ParameterProvider = class { log = $logger(); alepha = $inject(Alepha); dateTimeProvider = $inject(DateTimeProvider); crypto = $inject(CryptoProvider); lockProvider = $inject(LockProvider); schemaValidator = $inject(SchemaValidator); repositoryProvider = $inject(RepositoryProvider); repo = $repository(parameters); /** * Unique identifier for this instance (to avoid self-updates). */ get instanceId() { this._instanceId ??= this.crypto.randomUUID(); return this._instanceId; } _instanceId; /** * 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.) */ orgKey() { const tenant = this.alepha.store.get(currentTenantAtom); if (tenant?.id) return tenant.id; return this.alepha.store.get(currentUserAtom)?.organization ?? "~global"; } /** Per-org cache key for the value caches (`${org}:${name}`). */ cacheKey(name) { 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). */ primitives = /* @__PURE__ */ new Map(); /** * In-memory cached current content per parameter. */ cachedCurrent = /* @__PURE__ */ new Map(); /** * In-memory cached next version info per parameter. */ cachedNext = /* @__PURE__ */ new Map(); /** * Set of parameter names that have completed initial load. */ loaded = /* @__PURE__ */ new Set(); /** * Epoch millis of the last successful load (or local set) per cache key. * Drives the serverless revalidation TTL (see `revalidateAfterMs`). */ loadedAt = /* @__PURE__ */ new Map(); /** * 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. */ get revalidateAfterMs() { const raw = this.alepha.env.PARAMETERS_CACHE_TTL_MS; if (raw !== void 0 && raw !== "") return Number(raw); return this.alepha.isServerless() ? 3e4 : 0; } /** * Shared promises for deduplicating concurrent load() calls. */ loadPromises = /* @__PURE__ */ new Map(); /** * Generation counter per parameter — incremented on each doLoad call. * Used to discard results from superseded loads. */ loadGeneration = /* @__PURE__ */ new Map(); /** * Subscriber callbacks per parameter name. */ subscribers = /* @__PURE__ */ new Map(); /** * Set of parameter names that have already been checked for migration. */ migrationChecked = /* @__PURE__ */ new Set(); /** * Computed schema hashes per parameter name. */ schemaHashes = /* @__PURE__ */ new Map(); /** * Pre-load all registered parameters on ready (non-serverless only). */ 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. */ syncTopic = $topic({ name: "parameter:sync", schema: { payload: z.object({ name: z.text(), instanceId: z.text() }) }, handler: async ({ payload }) => { await this.handleChangeNotification(payload); } }); /** * Register a parameter primitive with the provider. * Computes and stores the schema hash. */ register(param) { this.primitives.set(param.name, param); this.schemaHashes.set(param.name, this.calculateSchemaHash(param.schema)); } /** * Get the current parameter value asynchronously. * Lazy-loads from database on first call. * Checks if a cached next version has become current. */ async get(name) { 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); } 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. */ async set(name, value, options = {}) { const schemaHash = this.schemaHashes.get(name) ?? ""; await this.save(name, value, schemaHash, { activationDate: options.activationDate, changeDescription: options.changeDescription, tags: options.tags, creatorId: options.creatorId, creatorName: options.creatorName }); 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() }); this.loadedAt.set(ck, this.dateTimeProvider.nowMillis()); } /** Whether the cached value outlived the revalidation TTL. */ isStale(ck) { const ttl = this.revalidateAfterMs; if (ttl <= 0) return false; const at = this.loadedAt.get(ck); return at === void 0 ? true : this.dateTimeProvider.nowMillis() - at > ttl; } /** * Subscribe to parameter changes. * Returns an unsubscribe function. */ sub(name, fn) { 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. */ async load(name) { 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. */ getCachedCurrentContent(name) { const ck = this.cacheKey(name); if (this.cachedCurrent.has(ck)) return this.cachedCurrent.get(ck); return this.primitives.get(name)?.options.default; } /** * Whether the parameter is using its default value (no DB value loaded). */ isUsingDefault(name) { return !this.cachedCurrent.has(this.cacheKey(name)); } /** * Load the current and next parameter values from database. * Current: latest version with activationDate <= now. * Next: earliest version with activationDate > now. */ async loadCurrentAndNext(name) { 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 })]); return { 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) ?? 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" */ calculateStatuses(versions, now) { const effectiveNow = now ?? this.dateTimeProvider.now().toDate(); 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; }); 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; 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.) */ async save(name, content, schemaHash, options = {}) { if (!schemaHash) schemaHash = this.schemaHashes.get(name) ?? ""; const param = this.primitives.get(name); if (param && schemaHash === this.schemaHashes.get(name)) content = this.alepha.codec.validate(param.schema, content); const now = this.dateTimeProvider.now().toDate(); const activationDate = options.activationDate ?? now; const isImmediate = activationDate <= now; const versions = await this.repo.findMany({ where: { name }, orderBy: { column: "version", direction: "desc" } }); const latestVersion = versions[0]; const newVersion = (latestVersion?.version ?? 0) + 1; const previousContent = versions.find((v) => new Date(v.activationDate) <= now)?.content; let migrationLog; 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 }); } const inserted = await this.repo.create({ name, content, schemaHash, activationDate: activationDate.toISOString(), version: newVersion, changeDescription: options.changeDescription, tags: options.tags, creatorId: options.creatorId, creatorName: options.creatorName, previousContent, migrationLog }); const insertedWithStatus = this.calculateStatuses([...versions, inserted]).find((v) => v.id === inserted.id); 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. */ resolveCreatorJoin() { const usersEntity = this.repositoryProvider.getRepositories().find((repo) => repo.entity.name === "users")?.entity; if (!usersEntity) return; return { creator: { join: usersEntity, on: ["creatorId", usersEntity.cols.id] } }; } /** * Get all versions of a parameter, each with the creating user embedded * (best-effort, see {@link resolveCreatorJoin}). */ async getHistory(name, options) { 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. */ async delete(name) { 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. */ async deleteMany(names) { 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. */ evictCaches(name) { 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. */ async getVersion(name, version) { return (await this.repo.findMany({ where: { name, version } }))[0] ?? null; } /** * Rollback to a previous version by creating a new version with old content. */ async rollback(name, targetVersion, options = {}) { const target = await this.getVersion(name, targetVersion); if (!target) throw new AlephaError(`Parameter version not found: ${name}@${targetVersion}`); return this.save(name, target.content, 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. */ getCurrentValue(name) { 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. */ async getCurrentWithDefault(name) { let { current, next } = await this.loadCurrentAndNext(name); const param = this.primitives.get(name); if (!current && param) try { current = await this.save(name, param.options.default, this.schemaHashes.get(name) ?? "", { changeDescription: "Auto-seeded from compiled defaults" }); } catch (err) { this.log.warn("Auto-seed of parameter failed, retrying read", { name, error: err.message }); ({current, next} = await this.loadCurrentAndNext(name)); } const defaultValue = param?.options.default ?? null; const currentValue = this.getCachedCurrentContent(name) ?? null; const schema = param?.schema ? z.toJSONSchema(param.schema) : null; return { current: current ? { ...current, status: "current" } : null, next: next ? { ...next, status: "next" } : null, defaultValue, currentValue, schema }; } /** * Get all unique parameter names (for tree view). */ async getParameterNames() { return (await this.repo.findMany({ columns: ["name"], distinct: ["name"], orderBy: { column: "name", direction: "asc" } })).map((r) => r.name); } /** * Build a tree structure from parameter names for UI. * Includes both database parameters and registered (but not yet saved) parameters. */ async getParameterTree() { const dbNames = await this.getParameterNames(); const registeredNames = Array.from(this.primitives.keys()); const allNames = [.../* @__PURE__ */ new Set([...dbNames, ...registeredNames])].sort(); return this.buildTree(allNames); } /** * Internal load implementation. * Fetches current and next from database, updates cache. */ async doLoad(name) { 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) ?? ""; if (this.loadGeneration.get(ck) !== gen) return; 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, schemaHash, { changeDescription: migration.description }); 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. */ async migrateWithLock(name) { const lockKey = `parameter:migrate:${name}`; const lockId = this.crypto.randomUUID(); if (await this.lockProvider.set(lockKey, lockId, true, 3e4) === lockId) try { await this.doLoad(name); } finally { await this.lockProvider.del(lockKey); } else { 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. */ async waitForLock(lockKey) { const maxWait = 3e4; const probeId = this.crypto.randomUUID(); const start = this.dateTimeProvider.nowMillis(); while (this.dateTimeProvider.nowMillis() - start < maxWait) { await this.dateTimeProvider.wait(500); if (await this.lockProvider.set(lockKey, probeId, true, 500) === 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 */ migrateValue(name, dbValue, dbSchemaHash) { const schemaHash = this.schemaHashes.get(name) ?? ""; const param = this.primitives.get(name); if (!param) return null; if (dbSchemaHash === schemaHash) return null; const schema = param.schema; const defaults = param.options.default; 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 }); } const schemaKeys = new Set(Object.keys(schema.properties)); const stripped = this.pickSchemaKeys(dbValue, schemaKeys); if (this.isValid(schema, stripped)) { if (JSON.stringify(stripped) === JSON.stringify(dbValue)) return null; return { value: stripped, description: "Auto-migrated: stripped unknown fields" }; } const merged = this.pickSchemaKeys(Object.assign({}, defaults, dbValue), schemaKeys); if (this.isValid(schema, merged)) return { value: merged, description: "Auto-migrated: merged with defaults" }; return { value: defaults, description: "Auto-migrated: reset to defaults (schema incompatible)" }; } /** * Reload next version info in background (non-blocking). */ reloadNextInBackground(name) { 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. */ notifySubscribers(name) { 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. */ isValid(schema, value) { 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. */ pickSchemaKeys(obj, schemaKeys) { const result = {}; 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. */ calculateSchemaHash(schema) { return this.crypto.hash(JSON.stringify(schema)); } /** * Publish change notification to other instances. */ async publishChange(name) { await this.syncTopic.publish({ name, instanceId: this.instanceId }); } /** * Handle incoming change notification from other instances. * Reloads the parameter from DB. */ async handleChangeNotification(payload) { 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. */ buildTree(names) { const root = []; 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; } }; //#endregion //#region ../../src/api/parameters/controllers/AdminParameterController.ts /** * REST API controller for versioned parameter management. * * Provides endpoints for: * - Listing all parameters (tree view support) * - Getting parameter history (all versions with calculated status) * - Getting current/next parameter values * - Creating new parameter versions (immediate or scheduled) * - Rolling back to previous versions * - Activating scheduled versions immediately */ var AdminParameterController = class { url = "/parameters"; group = "admin:parameters"; provider = $inject(ParameterProvider); alepha = $inject(Alepha); /** * Get tree structure of all parameter names. * Useful for admin UI navigation. */ getParameterTree = $action({ group: this.group, use: [$secure({ permissions: ["admin:parameter:read"] })], description: "Get tree structure of all parameter names for navigation.", path: "/parameters/tree", method: "GET", schema: { response: z.array(parameterTreeNodeSchema) }, handler: async () => { return this.provider.getParameterTree(); } }); /** * List all unique parameter names. */ listParameterNames = $action({ group: this.group, use: [$secure({ permissions: ["admin:parameter:read"] })], description: "List all unique parameter names.", path: "/parameters", method: "GET", schema: { response: parameterNamesResponseSchema }, handler: async () => { return { names: await this.provider.getParameterNames() }; } }); /** * Get version history for a specific parameter. * Returns all versions with calculated status. */ getHistory = $action({ group: this.group, use: [$secure({ permissions: ["admin:parameter:read"] })], description: "Get all versions of a specific parameter.", path: "/parameters/:name/history", method: "GET", schema: { params: parameterNameParamSchema, query: z.object({ limit: z.integer().min(1).max(100).optional(), offset: z.integer().min(0).optional() }), response: parameterHistoryResponseSchema }, handler: async ({ params, query }) => { const rawVersions = await this.provider.getHistory(params.name, { limit: query.limit, offset: query.offset }); return { versions: this.provider.calculateStatuses(rawVersions) }; } }); /** * Get current and next values for a parameter. * Includes defaultValue and currentValue from the registered primitive * even if no versions exist in the database yet. */ getCurrent = $action({ group: this.group, use: [$secure({ permissions: ["admin:parameter:read"] })], description: "Get current and next scheduled values for a parameter.", path: "/parameters/:name", method: "GET", schema: { params: parameterNameParamSchema, response: parameterCurrentResponseSchema }, handler: async ({ params }) => { const result = await this.provider.getCurrentWithDefault(params.name); return { current: result.current ?? void 0, next: result.next ?? void 0, defaultValue: result.defaultValue ?? void 0, currentValue: result.currentValue ?? void 0, schema: result.schema ?? void 0 }; } }); /** * Get a specific version of a parameter. */ getVersion = $action({ group: this.group, use: [$secure({ permissions: ["admin:parameter:read"] })], description: "Get a specific version of a parameter.", path: "/parameters/:name/versions/:version", method: "GET", schema: { params: parameterVersionParamSchema, response: parameterVersionResponseSchema }, handler: async ({ params }) => { const version = await this.provider.getVersion(params.name, params.version); if (!version) return { parameter: void 0 }; const [withStatus] = this.provider.calculateStatuses([version]); return { parameter: withStatus }; } }); /** * Create a new parameter version. */ createVersion = $action({ group: this.group, use: [$secure({ permissions: ["admin:parameter:create"] })], description: "Create a new version of a parameter (immediate or scheduled).", path: "/parameters/:name", method: "POST", schema: { params: parameterNameParamSchema, body: createParameterVersionBodySchema, response: parameterResponseSchema }, handler: async ({ params, body, user }) => { const result = await this.provider.save(params.name, body.content, body.schemaHash, { activationDate: body.activationDate ? new Date(body.activationDate) : void 0, changeDescription: body.changeDescription, tags: body.tags, creatorId: user?.id, creatorName: this.creatorNameOf(user) }); await this.audit("create", params.name, { version: result.version, scheduled: result.status !== "current" }); return result; } }); /** * Rollback to a previous version. */ rollback = $action({ group: this.group, use: [$secure({ permissions: ["admin:parameter:rollback"] })], description: "Rollback a parameter to a previous version (creates new version with old content).", path: "/parameters/:name/rollback", method: "POST", schema: { params: parameterNameParamSchema, body: rollbackParameterBodySchema, response: parameterResponseSchema }, handler: async ({ params, body, user }) => { const result = await this.provider.rollback(params.name, body.targetVersion, { changeDescription: body.changeDescription, creatorId: user?.id, creatorName: this.creatorNameOf(user) }); await this.audit("rollback", params.name, { version: result.version, targetVersion: body.targetVersion }); return result; } }); /** * Activate a scheduled version immediately. * Creates a new version with the same content but immediate activation. */ activateNow = $action({ group: this.group, use: [$secure({ permissions: ["admin:parameter:activate"] })], description: "Activate a future/next parameter version immediately.", path: "/parameters/:name/activate", method: "POST", schema: { params: parameterNameParamSchema, body: activateParameterBodySchema, response: parameterResponseSchema }, handler: async ({ params, body, user }) => { const allVersions = await this.provider.getHistory(params.name); const target = this.provider.calculateStatuses(allVersions).find((v) => v.version === body.version); if (!target) throw new AlephaError(`Version ${body.version} not found for parameter ${params.name}`); if (target.status === "current") return target; if (target.status === "expired") throw new AlephaError("Cannot activate an expired version. Use rollback instead."); const result = await this.provider.save(params.name, target.content, target.schemaHash, { changeDescription: `Early activation of version ${body.version}`, creatorId: user?.id, creatorName: this.creatorNameOf(user) }); await this.audit("activate", params.name, { version: result.version, activatedFrom: body.version }); return result; } }); /** * Delete all versions of a parameter. */ deleteParameter = $action({ group: this.group, use: [$secure({ permissions: ["admin:parameter:delete"] })], description: "Delete all versions of a parameter.", path: "/parameters/:name", method: "DELETE", schema: { params: parameterNameParamSchema, response: okSchema }, handler: async ({ params }) => { await this.provider.delete(params.name); await this.audit("delete", params.name); return { ok: true }; } }); /** * Delete many parameters (all versions of each) in one request. */ deleteParameters = $action({ group: this.group, use: [$secure({ permissions: ["admin:parameter:delete"] })], description: "Delete all versions of many parameters by name.", path: "/parameters/delete", method: "POST", schema: { body: z.object({ names: z.array(z.string().min(1)).min(1).max(1e3) }), response: z.object({ deleted: z.array(z.string()) }) }, handler: async ({ body }) => { const deleted = await this.provider.deleteMany(body.names); for (const name of deleted) await this.audit("delete", name); return { deleted }; } }); /** * Derive a human-readable creator name from the authenticated session token. * * The name is snapshotted onto the version at write time rather than joined * from the users table on read: this keeps the parameters module free of any * dependency on the users module (no import, no circular-import risk). The * trade-off is that the stored name does not follow later user renames. * * Precedence: `username` → `email`. The token's `name` is intentionally * skipped: the security layer fills it with a placeholder ("Anonymous User") * when the issuer supplies no name, which would otherwise be snapshotted. */ creatorNameOf(user) { return user?.username ?? user?.email; } /** * Record a parameter mutation via the `ParameterAudits` holder — best-effort. * * `ParameterAudits` is resolved lazily from the container; when it is not * registered, auditing is silently skipped. Actor (userId / email / realm), * IP, and request id are auto-filled by `AuditService` from the request * context. Failures never break the underlying operation. */ async audit(action, name, metadata) { if (!this.alepha.has(ParameterAudits)) return; try { await this.alepha.inject(ParameterAudits).parameter.log(action, { resourceType: "parameter", resourceId: name, metadata }); } catch {} } }; //#endregion //#region ../../src/api/parameters/primitives/$parameter.ts var ParameterPrimitive = class extends Primitive { provider = $inject(ParameterProvider); /** * Parameter name (uses property key if not specified). */ get name() { return this.options.name || `${this.config.service.name}.${this.config.propertyKey}`; } /** * The TypeBox schema for this parameter. */ get schema() { return this.options.schema; } /** * Get the cached current content, falling back to default. * Synchronous access for admin API. */ get cachedCurrentContent() { return this.provider.getCachedCurrentContent(this.name); } /** * Whether the parameter is using its default value (no DB value loaded). */ get isUsingDefault() { return this.provider.isUsingDefault(this.name); } /** * Get the current parameter value asynchronously. * Lazy-loads from database on first call. * Checks if a cached next version has become current. */ get() { return this.provider.get(this.name); } /** * Load current and next values from database. */ async load() { await this.provider.load(this.name); } /** * Set a new parameter value. * * @param value - The new parameter value * @param options - Optional settings (activation date, creator info, etc.) */ async set(value, options = {}) { await this.provider.set(this.name, value, { activationDate: options.activationDate, changeDescription: options.changeDescription, tags: options.tags, creatorId: options.user?.id, creatorName: options.user?.name ?? options.user?.email }); } /** * Subscribe to parameter changes. * Returns an unsubscribe function. */ sub(fn) { return this.provider.sub(this.name, fn); } /** * Reload parameter from database. * Called when sync notification received or for manual refresh. */ async reload() { await this.provider.load(this.name); } /** * Get version history for this parameter. */ async getHistory(options) { return this.provider.getHistory(this.name, options); } /** * Get a specific version of this parameter. */ async getVersion(version) { return this.provider.getVersion(this.name, version); } /** * Delete all versions of this parameter. */ async delete() { await this.provider.delete(this.name); } /** * Rollback to a specific version. */ async rollback(version, options) { await this.provider.rollback(this.name, version, { changeDescription: options?.changeDescription, creatorId: options?.user?.id, creatorName: options?.user?.name ?? options?.user?.email }); await this.provider.load(this.name); } /** * Called after primitive creation to register with provider. */ onInit() { this.provider.register(this); } }; const $parameter = (options) => { return createPrimitive(ParameterPrimitive, options); }; $parameter[KIND] = ParameterPrimitive; //#endregion //#region ../../src/api/parameters/index.ts /** * Application parameter management. * * **Features:** * - Versioned parameter definitions * - Status derived from activationDate at query time * - Schema validation with migration detection * - Cross-instance notification via pub/sub * - Async `.get()` with lazy loading (works in Node and Cloudflare Workers) * * @module alepha.api.parameters */ const AlephaApiParameters = $module({ name: "alepha.api.parameters", primitives: [$parameter], services: [ParameterProvider, AdminParameterController], variants: [ParameterAudits] }); //#endregion export { $parameter, AdminParameterController, AlephaApiParameters, ParameterAudits, ParameterPrimitive, ParameterProvider, activateParameterBodySchema, createParameterVersionBodySchema, parameterCurrentResponseSchema, parameterHistoryResponseSchema, parameterNameParamSchema, parameterNamesResponseSchema, parameterResponseSchema, parameterStatusSchema, parameterTreeNodeSchema, parameterVersionParamSchema, parameterVersionResponseSchema, parameters, rollbackParameterBodySchema }; //# sourceMappingURL=index.js.map