Files
npmextra/ts/npmextra.classes.appdata.ts

431 lines
13 KiB
TypeScript
Raw Permalink Normal View History

2023-08-24 10:39:47 +02:00
import * as plugins from './npmextra.plugins.js';
import { KeyValueStore } from './npmextra.classes.keyvaluestore.js';
// ============================================================================
// Singleton Qenv Provider
// ============================================================================
let sharedQenv: plugins.qenv.Qenv | undefined;
function getQenv(): plugins.qenv.Qenv {
if (!sharedQenv) {
sharedQenv = new plugins.qenv.Qenv(
process.cwd(),
plugins.path.join(process.cwd(), '.nogit')
);
}
return sharedQenv;
}
// ============================================================================
// Type Converters - Centralized conversion logic
// ============================================================================
function toBoolean(value: unknown): boolean {
if (typeof value === 'boolean') return value;
if (value == null) return false;
const s = String(value).toLowerCase();
return s === 'true';
}
function toJson<T = any>(value: unknown): T | undefined {
if (value == null) return undefined;
if (typeof value === 'string') {
try {
return JSON.parse(value);
} catch {
return undefined;
}
}
return value as T;
}
function fromBase64(value: unknown): string | undefined {
if (value == null) return undefined;
try {
return Buffer.from(String(value), 'base64').toString('utf8');
} catch {
return String(value);
}
}
function toNumber(value: unknown): number | undefined {
if (value == null) return undefined;
const num = Number(value);
return Number.isNaN(num) ? undefined : num;
}
function toString(value: unknown): string | undefined {
if (value == null) return undefined;
return String(value);
}
// ============================================================================
// Declarative Pipeline Architecture
// ============================================================================
type Transform = 'boolean' | 'json' | 'base64' | 'number';
type MappingSpec = {
source:
| { type: 'env'; key: string }
| { type: 'hard'; value: string };
transforms: Transform[];
};
// Transform registry for extensibility
const transformRegistry: Record<string, (v: unknown) => unknown> = {
boolean: toBoolean,
json: toJson,
base64: fromBase64,
number: toNumber,
};
/**
* Parse a mapping string into a declarative spec
*/
function parseMappingSpec(input: string): MappingSpec {
const transforms: Transform[] = [];
let remaining = input;
// Check for hardcoded prefixes with type conversion
if (remaining.startsWith('hard_boolean:')) {
return {
source: { type: 'hard', value: remaining.slice(13) },
transforms: ['boolean']
};
}
if (remaining.startsWith('hard_json:')) {
return {
source: { type: 'hard', value: remaining.slice(10) },
transforms: ['json']
};
}
if (remaining.startsWith('hard_base64:')) {
return {
source: { type: 'hard', value: remaining.slice(12) },
transforms: ['base64']
};
}
// Check for generic hard: prefix
if (remaining.startsWith('hard:')) {
remaining = remaining.slice(5);
// Check for legacy suffixes on hardcoded values
if (remaining.endsWith('_JSON')) {
transforms.push('json');
remaining = remaining.slice(0, -5);
} else if (remaining.endsWith('_BASE64')) {
transforms.push('base64');
remaining = remaining.slice(0, -7);
}
return {
source: { type: 'hard', value: remaining },
transforms
};
}
// Check for env var prefixes
if (remaining.startsWith('boolean:')) {
transforms.push('boolean');
remaining = remaining.slice(8);
} else if (remaining.startsWith('json:')) {
transforms.push('json');
remaining = remaining.slice(5);
} else if (remaining.startsWith('base64:')) {
transforms.push('base64');
remaining = remaining.slice(7);
}
// Check for legacy suffixes on env vars
if (remaining.endsWith('_JSON')) {
transforms.push('json');
remaining = remaining.slice(0, -5);
} else if (remaining.endsWith('_BASE64')) {
transforms.push('base64');
remaining = remaining.slice(0, -7);
}
return {
source: { type: 'env', key: remaining },
transforms
};
}
/**
* Resolve the source value (env var or hardcoded)
*/
async function resolveSource(source: MappingSpec['source']): Promise<unknown> {
if (source.type === 'hard') {
return source.value;
}
// source.type === 'env'
return await getQenv().getEnvVarOnDemand(source.key);
}
/**
* Apply transformations in sequence
*/
function applyTransforms(value: unknown, transforms: Transform[]): unknown {
return transforms.reduce((acc, transform) => {
const fn = transformRegistry[transform];
return fn ? fn(acc) : acc;
}, value);
}
/**
* Process a mapping value through the complete pipeline
*/
async function processMappingValue(mappingString: string): Promise<unknown> {
const spec = parseMappingSpec(mappingString);
const rawValue = await resolveSource(spec.source);
if (rawValue === undefined || rawValue === null) {
return undefined;
}
return applyTransforms(rawValue, spec.transforms);
}
/**
* Recursively evaluate mapping values (strings or nested objects)
*/
async function evaluateMappingValue(mappingValue: any): Promise<any> {
if (typeof mappingValue === 'string') {
return processMappingValue(mappingValue);
}
if (mappingValue && typeof mappingValue === 'object' && !Array.isArray(mappingValue)) {
const result: any = {};
for (const [key, value] of Object.entries(mappingValue)) {
result[key] = await evaluateMappingValue(value);
}
return result;
}
return undefined;
}
// ============================================================================
// AppData Interface and Class
// ============================================================================
2024-06-12 20:04:04 +02:00
export interface IAppDataOptions<T = any> {
2024-02-07 21:44:00 +01:00
dirPath?: string;
2024-06-12 20:04:04 +02:00
requiredKeys?: Array<keyof T>;
2024-02-09 15:57:32 +01:00
2024-06-19 15:07:49 +02:00
/**
* Whether keys should be persisted on disk or not
*/
ephemeral?: boolean;
/**
* @deprecated Use 'ephemeral' instead
2024-06-19 15:07:49 +02:00
*/
ephermal?: boolean;
2024-02-09 15:57:32 +01:00
/**
* kvStoreKey: 'MY_ENV_VAR'
*/
2024-06-19 19:03:26 +02:00
envMapping?: plugins.tsclass.typeFest.PartialDeep<T>;
overwriteObject?: plugins.tsclass.typeFest.PartialDeep<T>;
2024-02-07 21:44:00 +01:00
}
2024-02-13 00:48:44 +01:00
export class AppData<T = any> {
2024-01-25 13:57:55 +01:00
/**
* creates appdata. If no pathArg is given, data will be stored here:
* ${PWD}/.nogit/appdata
* @param pathArg
2024-02-09 15:57:32 +01:00
* @returns
2024-01-25 13:57:55 +01:00
*/
2024-06-19 19:03:26 +02:00
public static async createAndInit<T = any>(
optionsArg: IAppDataOptions<T> = {},
2024-06-19 19:03:26 +02:00
): Promise<AppData<T>> {
2024-02-13 00:48:44 +01:00
const appData = new AppData<T>(optionsArg);
2024-01-25 13:57:55 +01:00
await appData.readyDeferred.promise;
2023-08-26 09:56:20 +02:00
return appData;
}
/**
* Static helper to get an environment variable as a boolean
* @param envVarName The name of the environment variable
* @returns boolean value (true if env var is "true", false otherwise)
*/
public static async valueAsBoolean(envVarName: string): Promise<boolean> {
const value = await getQenv().getEnvVarOnDemand(envVarName);
return toBoolean(value);
}
/**
* Static helper to get an environment variable as parsed JSON
* @param envVarName The name of the environment variable
* @returns Parsed JSON object/array
*/
public static async valueAsJson<R = any>(envVarName: string): Promise<R | undefined> {
const value = await getQenv().getEnvVarOnDemand(envVarName);
return toJson<R>(value);
}
/**
* Static helper to get an environment variable as base64 decoded string
* @param envVarName The name of the environment variable
* @returns Decoded string
*/
public static async valueAsBase64(envVarName: string): Promise<string | undefined> {
const value = await getQenv().getEnvVarOnDemand(envVarName);
return fromBase64(value);
}
/**
* Static helper to get an environment variable as a string
* @param envVarName The name of the environment variable
* @returns String value
*/
public static async valueAsString(envVarName: string): Promise<string | undefined> {
const value = await getQenv().getEnvVarOnDemand(envVarName);
return toString(value);
}
/**
* Static helper to get an environment variable as a number
* @param envVarName The name of the environment variable
* @returns Number value
*/
public static async valueAsNumber(envVarName: string): Promise<number | undefined> {
const value = await getQenv().getEnvVarOnDemand(envVarName);
return toNumber(value);
}
2023-08-26 09:56:20 +02:00
// instance
2024-06-12 20:04:04 +02:00
public readyDeferred = plugins.smartpromise.defer<void>();
public options: IAppDataOptions<T>;
2024-02-13 00:50:50 +01:00
private kvStore: KeyValueStore<T>;
2024-06-12 20:04:04 +02:00
constructor(optionsArg: IAppDataOptions<T> = {}) {
2024-02-07 21:44:00 +01:00
this.options = optionsArg;
2024-01-27 19:03:06 +01:00
this.init();
2023-08-24 10:39:47 +02:00
}
2024-01-25 13:57:55 +01:00
/**
* inits app data
*/
private async init() {
console.log('🚀 Initializing AppData...');
// Handle backward compatibility for typo
const isEphemeral = this.options.ephemeral ?? this.options.ephermal ?? false;
if (this.options.ephermal && !this.options.ephemeral) {
console.warn('⚠️ Option "ephermal" is deprecated, use "ephemeral" instead.');
}
if (this.options.dirPath) {
console.log(` 📁 Using custom directory: ${this.options.dirPath}`);
} else if (isEphemeral) {
console.log(` 💨 Using ephemeral storage (in-memory only)`);
2023-08-24 10:39:47 +02:00
} else {
const appDataDir = '/app/data';
const dataDir = '/data';
2023-08-24 22:59:43 +02:00
const nogitAppData = '.nogit/appdata';
2023-08-24 10:39:47 +02:00
const appDataExists = plugins.smartfile.fs.isDirectory(appDataDir);
const dataExists = plugins.smartfile.fs.isDirectory(dataDir);
if (appDataExists) {
2024-02-07 21:44:00 +01:00
this.options.dirPath = appDataDir;
console.log(` 📁 Auto-selected container directory: ${appDataDir}`);
2023-08-24 22:59:43 +02:00
} else if (dataExists) {
2024-02-07 21:44:00 +01:00
this.options.dirPath = dataDir;
console.log(` 📁 Auto-selected data directory: ${dataDir}`);
2023-08-24 22:59:43 +02:00
} else {
await plugins.smartfile.fs.ensureDir(nogitAppData);
2024-02-07 21:44:00 +01:00
this.options.dirPath = nogitAppData;
console.log(` 📁 Auto-selected local directory: ${nogitAppData}`);
2023-08-24 10:39:47 +02:00
}
}
2024-06-12 20:04:04 +02:00
this.kvStore = new KeyValueStore<T>({
typeArg: isEphemeral ? 'ephemeral' : 'custom',
2024-02-12 19:16:43 +01:00
identityArg: 'appkv',
customPath: this.options.dirPath,
2024-06-19 19:03:26 +02:00
mandatoryKeys: this.options.requiredKeys as Array<keyof T>,
2024-02-12 19:16:43 +01:00
});
2024-02-09 15:57:32 +01:00
if (this.options.envMapping) {
console.log(`📦 Processing envMapping for AppData...`);
const totalKeys = Object.keys(this.options.envMapping).length;
let processedCount = 0;
// Process each top-level key in envMapping
for (const key in this.options.envMapping) {
try {
const mappingSpec = this.options.envMapping[key];
console.log(` → Processing key "${key}" with spec:`, typeof mappingSpec === 'string' ? mappingSpec : 'nested object');
const evaluated = await evaluateMappingValue(mappingSpec);
if (evaluated !== undefined) {
await this.kvStore.writeKey(key as keyof T, evaluated);
processedCount++;
const valueType = Array.isArray(evaluated) ? 'array' : typeof evaluated;
console.log(` ✅ Successfully processed key "${key}" (type: ${valueType})`);
2024-02-12 20:09:26 +01:00
} else {
console.log(` ⚠️ Key "${key}" evaluated to undefined, skipping`);
2024-02-12 18:40:01 +01:00
}
} catch (err) {
console.error(` ❌ Failed to evaluate envMapping for key "${key}":`, err);
2024-02-09 15:57:32 +01:00
}
}
console.log(`📊 EnvMapping complete: ${processedCount}/${totalKeys} keys successfully processed`);
}
// Apply overwrite object after env mapping
if (this.options.overwriteObject) {
const overwriteKeys = Object.keys(this.options.overwriteObject);
console.log(`🔄 Applying overwriteObject with ${overwriteKeys.length} key(s)...`);
for (const key of overwriteKeys) {
const value = this.options.overwriteObject[key];
const valueType = Array.isArray(value) ? 'array' : typeof value;
console.log(` 🔧 Overwriting key "${key}" with ${valueType} value`);
await this.kvStore.writeKey(
key as keyof T,
value,
);
}
console.log(`✅ OverwriteObject complete: ${overwriteKeys.length} key(s) overwritten`);
2024-02-09 15:57:32 +01:00
}
2023-08-24 10:39:47 +02:00
this.readyDeferred.resolve();
console.log('✨ AppData initialization complete!');
2023-08-24 10:39:47 +02:00
}
/**
2024-06-12 20:04:04 +02:00
* returns a kvstore that resides in appdata
2023-08-24 10:39:47 +02:00
*/
2024-06-12 20:04:04 +02:00
public async getKvStore(): Promise<KeyValueStore<T>> {
2023-08-24 10:39:47 +02:00
await this.readyDeferred.promise;
return this.kvStore;
}
2024-02-09 11:52:30 +01:00
2024-06-12 20:04:04 +02:00
public async logMissingKeys(): Promise<Array<keyof T>> {
2024-02-09 11:52:30 +01:00
const kvStore = await this.getKvStore();
2024-04-14 02:10:29 +02:00
const missingMandatoryKeys = await kvStore.getMissingMandatoryKeys();
2024-02-09 11:52:30 +01:00
if (missingMandatoryKeys.length > 0) {
2024-02-09 15:57:32 +01:00
console.log(
`The following mandatory keys are missing in the appdata:\n -> ${missingMandatoryKeys.join(
',\n -> ',
)}`,
2024-02-09 15:57:32 +01:00
);
2024-02-09 11:52:30 +01:00
} else {
console.log('All mandatory keys are present in the appdata');
}
2024-02-13 02:04:04 +01:00
return missingMandatoryKeys;
2024-02-09 11:52:30 +01:00
}
2024-02-10 04:54:00 +01:00
public async waitForAndGetKey<K extends keyof T>(
keyArg: K,
): Promise<T[K] | undefined> {
2024-02-10 04:54:00 +01:00
await this.readyDeferred.promise;
2024-02-10 04:55:50 +01:00
await this.kvStore.waitForKeysPresent([keyArg]);
2024-06-12 20:04:04 +02:00
return this.kvStore.readKey(keyArg);
2024-02-10 04:54:00 +01:00
}
2024-06-19 19:03:26 +02:00
}