alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
240 lines (213 loc) • 6.15 kB
text/typescript
import {
$inject,
createPrimitive,
KIND,
Primitive,
type Static,
type TObject,
} from "alepha";
import type { UserAccount } from "alepha/security";
import { ParameterProvider } from "../services/ParameterProvider.ts";
/**
* Creates a versioned parameter primitive for managing application settings.
*
* Provides type-safe, versioned configuration with:
* - Schema validation with auto-migration detection
* - Default values for initial state
* - Status derived from activationDate (no stored status)
* - Database persistence with full version history
* - Cross-instance notification via topic
* - Tree view support via dot-notation naming (e.g., "app.features.flags")
* - Async `.get()` with lazy loading (works in Node and Cloudflare Workers)
*
* @example
* ```ts
* class AppConfig {
* features = $parameter({
* name: "app.features.flags",
* schema: z.object({
* enableBeta: z.boolean(),
* maxUploadSize: z.number()
* }),
* default: { enableBeta: false, maxUploadSize: 10485760 }
* });
*
* async checkBeta() {
* const config = await this.features.get();
* return config.enableBeta;
* }
*
* async enableBeta() {
* await this.features.set({ enableBeta: true, maxUploadSize: 20971520 });
* }
* }
* ```
*/
export interface ParameterPrimitiveOptions<T extends TObject> {
/**
* Parameter name using dot notation for tree hierarchy.
* Examples: "app.features", "app.pricing.tiers", "system.limits"
*/
name?: string;
/**
* Human-readable description of the parameter.
*/
description?: string;
/**
* TypeBox schema defining the parameter structure.
*/
schema: T;
/**
* Default value used when no parameter exists in database.
*/
default: Static<T>;
/**
* Optional migration function for schema changes.
* Receives the raw DB value and returns a transformed value matching the new schema.
* Runs before validation — if the result is valid, it's used directly.
* If not provided or returns an invalid value, falls through to merge/default cascade.
*/
migrate?: (old: unknown) => Static<T>;
}
export class ParameterPrimitive<T extends TObject> extends Primitive<
ParameterPrimitiveOptions<T>
> {
protected readonly provider = $inject(ParameterProvider);
/**
* Parameter name (uses property key if not specified).
*/
public get name(): string {
return (
this.options.name ||
`${this.config.service.name}.${this.config.propertyKey}`
);
}
/**
* The TypeBox schema for this parameter.
*/
public get schema(): T {
return this.options.schema;
}
/**
* Get the cached current content, falling back to default.
* Synchronous access for admin API.
*/
public get cachedCurrentContent(): Static<T> {
return this.provider.getCachedCurrentContent(this.name) as Static<T>;
}
/**
* Whether the parameter is using its default value (no DB value loaded).
*/
public get isUsingDefault(): boolean {
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.
*/
public get(): Promise<Static<T>> {
return this.provider.get(this.name) as Promise<Static<T>>;
}
/**
* Load current and next values from database.
*/
public async load(): Promise<void> {
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.)
*/
public async set(
value: Static<T>,
options: SetParameterOptions = {},
): Promise<void> {
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.
*/
public sub(fn: (curr: Static<T>) => void): () => void {
return this.provider.sub(this.name, fn as (v: unknown) => void);
}
/**
* Reload parameter from database.
* Called when sync notification received or for manual refresh.
*/
public async reload(): Promise<void> {
await this.provider.load(this.name);
}
/**
* Get version history for this parameter.
*/
public async getHistory(options?: { limit?: number; offset?: number }) {
return this.provider.getHistory(this.name, options);
}
/**
* Get a specific version of this parameter.
*/
public async getVersion(version: number) {
return this.provider.getVersion(this.name, version);
}
/**
* Delete all versions of this parameter.
*/
public async delete(): Promise<void> {
await this.provider.delete(this.name);
}
/**
* Rollback to a specific version.
*/
public async rollback(
version: number,
options?: SetParameterOptions,
): Promise<void> {
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.
*/
protected onInit(): void {
this.provider.register(this);
}
}
export const $parameter = <T extends TObject>(
options: ParameterPrimitiveOptions<T>,
) => {
return createPrimitive(ParameterPrimitive<T>, options);
};
$parameter[KIND] = ParameterPrimitive;
export interface SetParameterOptions {
/**
* User making the change (for audit trail).
*/
user?: Pick<UserAccount, "id" | "email" | "name">;
/**
* When this parameter should become active.
* Default is immediate (now).
*/
activationDate?: Date;
/**
* Description of the change.
*/
changeDescription?: string;
/**
* Tags for filtering/categorization.
*/
tags?: string[];
}