alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
1,439 lines (1,438 loc) • 47 kB
JavaScript
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