@voilajsx/appkit
Version:
Minimal and framework agnostic Node.js toolkit designed for AI agentic backend development
271 lines • 9.57 kB
JavaScript
/**
* Simplified defaults and validation for AppKit Database
* Environment-driven configuration with smart detection
*
* Required Environment Variables:
* - DATABASE_URL: Database connection string
*
* Optional Environment Variables:
* - VOILA_DB_TENANT: Enable tenant mode (auto/true/false)
* - ORG_{NAME}: Organization-specific database URLs
*
* @module @voilajsx/appkit/database
* @file src/database/defaults.js
*
* @llm-rule WHEN: App startup - need to validate database environment configuration
* @llm-rule AVOID: Calling multiple times - expensive validation, use once at startup
* @llm-rule NOTE: All tenant tables MUST have tenant_id text field (nullable)
*/
export function validateTenantId(tenantId) {
return typeof tenantId === 'string' && /^[a-zA-Z0-9_-]+$/.test(tenantId) && tenantId.length <= 63;
}
export function validateOrgId(orgId) {
return typeof orgId === 'string' && /^[a-zA-Z0-9_-]+$/.test(orgId) && orgId.length <= 63;
}
export function validateDatabaseUrl(url) {
if (!url || typeof url !== 'string')
return false;
if (!url.includes('://'))
return false;
if (url.includes('..') || url.includes('<') || url.includes('>'))
return false;
return [
'postgresql://', 'postgres://', 'mysql://',
'mongodb://', 'mongodb+srv://', 'sqlite://'
].some(protocol => url.startsWith(protocol));
}
class DatabaseError extends Error {
statusCode;
details; // or use a more specific type like `Record<string, any>`
constructor(message, statusCode = 500, details = null) {
super(message);
this.name = 'DatabaseError';
this.statusCode = statusCode;
this.details = details;
if (Error.captureStackTrace) {
Error.captureStackTrace(this, DatabaseError);
}
}
}
export function createDatabaseError(message, statusCode = 500, details = null) {
return new DatabaseError(message, statusCode, details);
}
export function detectProvider(url) {
if (!url)
return 'unknown';
if (url.includes('postgresql://') || url.includes('postgres://'))
return 'postgresql';
if (url.includes('mysql://'))
return 'mysql';
if (url.includes('mongodb://') || url.includes('mongodb+srv://'))
return 'mongodb';
if (url.includes('sqlite://'))
return 'sqlite';
return 'unknown';
}
export function detectAdapter(url) {
const provider = detectProvider(url);
if (provider === 'mongodb')
return 'mongoose';
return 'prisma';
}
export function sanitizeDatabaseName(name) {
if (!name || typeof name !== 'string') {
throw createDatabaseError('Database name must be a non-empty string', 400);
}
return name.toLowerCase().replace(/[^a-z0-9_-]/g, '_');
}
export function getOrgEnvironmentVars() {
const orgVars = {};
Object.keys(process.env).forEach(key => {
if (key.startsWith('ORG_') && key !== 'ORG_') {
const orgId = key.replace('ORG_', '').toLowerCase();
const url = process.env[key];
if (url && validateDatabaseUrl(url)) {
orgVars[orgId] = url;
}
else {
console.warn(`Invalid database URL for organization '${orgId}': ${url}`);
}
}
});
return orgVars;
}
export function validateEnvironment() {
const errors = [];
const warnings = [];
const config = {
valid: true,
errors,
warnings,
hasDatabase: false,
hasTenants: false,
hasOrgs: false,
orgCount: 0,
};
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
errors.push('DATABASE_URL environment variable is required');
}
else if (!validateDatabaseUrl(databaseUrl)) {
errors.push(`Invalid DATABASE_URL format: ${databaseUrl}`);
}
else {
config.hasDatabase = true;
}
const tenantMode = process.env.VOILA_DB_TENANT;
if (tenantMode) {
const mode = tenantMode.toLowerCase();
if (['true', 'auto'].includes(mode)) {
config.hasTenants = true;
}
else if (mode !== 'false') {
warnings.push(`Unknown VOILA_DB_TENANT value: ${tenantMode}. Use 'auto', 'true', or 'false'`);
}
}
const orgVars = getOrgEnvironmentVars();
config.orgCount = Object.keys(orgVars).length;
config.hasOrgs = config.orgCount > 0;
Object.keys(orgVars).forEach(orgId => {
if (!validateOrgId(orgId)) {
warnings.push(`Invalid organization ID format: ${orgId}`);
}
});
const nodeEnv = process.env.NODE_ENV;
if (!nodeEnv) {
warnings.push('NODE_ENV not set. Defaulting to development mode');
}
else if (!['development', 'production', 'test'].includes(nodeEnv)) {
warnings.push(`Unusual NODE_ENV value: ${nodeEnv}`);
}
if (config.hasTenants && !config.hasDatabase) {
errors.push('Tenant mode enabled but no valid DATABASE_URL found');
}
if (config.hasOrgs && config.orgCount > 10) {
warnings.push(`Large number of organizations configured (${config.orgCount}). Consider using dynamic URL resolution`);
}
config.valid = errors.length === 0;
return config;
}
export function getSmartDefaults() {
const isDevelopment = process.env.NODE_ENV === 'development';
const isProduction = process.env.NODE_ENV === 'production';
const validation = validateEnvironment();
if (isDevelopment && validation.warnings.length > 0) {
console.warn('⚠️ [AppKit] Database configuration warnings:');
validation.warnings.forEach((w) => console.warn(` ${w}`));
}
if (!validation.valid) {
throw createDatabaseError(`Database configuration errors:\n${validation.errors.join('\n')}`, 500, { validation });
}
return {
database: {
url: process.env.DATABASE_URL || '',
provider: detectProvider(process.env.DATABASE_URL || ''),
adapter: detectAdapter(process.env.DATABASE_URL || ''),
},
tenant: {
enabled: validation.hasTenants,
mode: process.env.VOILA_DB_TENANT?.toLowerCase() || 'false',
fieldName: 'tenant_id',
},
org: {
enabled: validation.hasOrgs,
count: validation.orgCount,
urls: getOrgEnvironmentVars(),
},
environment: {
isDevelopment,
isProduction,
nodeEnv: process.env.NODE_ENV || 'development',
},
validation,
};
}
export async function validateSchema(client, requiredField = 'tenant_id') {
if (process.env.NODE_ENV !== 'development')
return { valid: true, warnings: [] };
const warnings = [];
try {
if (client.$queryRaw) {
const models = Object.keys(client).filter(key => typeof client[key] === 'object' && typeof client[key].findFirst === 'function');
for (const model of models) {
try {
await client[model].findFirst({ where: { [requiredField]: null }, take: 1 });
}
catch (e) {
if (e.message.includes(requiredField)) {
warnings.push(`Prisma model '${model}' missing field '${requiredField}'`);
}
}
}
}
else if (client.models) {
const models = Object.keys(client.models);
for (const modelName of models) {
const model = client.models[modelName];
if (!model.schema.paths[requiredField]) {
warnings.push(`Mongoose model '${modelName}' missing field '${requiredField}'`);
}
}
}
return { valid: warnings.length === 0, warnings };
}
catch (error) {
return { valid: false, warnings: [`Schema validation failed: ${error.message}`] };
}
}
export function formatBytes(bytes) {
if (bytes === 0)
return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
}
export function maskUrl(url) {
if (!url || typeof url !== 'string')
return '[invalid-url]';
try {
return url.replace(/:\/\/[^@]*@/, '://***:***@');
}
catch {
return '[masked-url]';
}
}
export function getConfigSummary() {
const config = getSmartDefaults();
return {
database: {
provider: config.database.provider,
adapter: config.database.adapter,
url: maskUrl(config.database.url || ''),
},
tenant: {
enabled: config.tenant.enabled,
mode: config.tenant.mode,
},
org: {
enabled: config.org.enabled,
count: config.org.count,
organizations: Object.keys(config.org.urls),
},
environment: config.environment.nodeEnv,
validation: {
valid: config.validation.valid,
warningCount: config.validation.warnings.length,
errorCount: config.validation.errors.length,
},
};
}
let cachedValidation = null;
export function getCachedValidation() {
if (!cachedValidation) {
cachedValidation = validateEnvironment();
}
return cachedValidation;
}
export function clearValidationCache() {
cachedValidation = null;
}
//# sourceMappingURL=defaults.js.map