@voilajsx/appkit
Version:
Minimal and framework agnostic Node.js toolkit designed for AI agentic backend development
480 lines • 18.4 kB
JavaScript
/**
* Simplified Mongoose adapter with app discovery and tenant middleware
* @module @voilajsx/appkit/database
* @file src/database/adapters/mongoose.ts
*
* @llm-rule WHEN: Using Mongoose ODM with MongoDB databases in VoilaJSX framework
* @llm-rule AVOID: Using with SQL databases - use prisma adapter instead
* @llm-rule NOTE: Auto-discovers apps from /apps directory structure, applies tenant filtering
*/
import fs from 'fs';
import path from 'path';
import { createDatabaseError } from '../defaults.js';
/**
* Simplified Mongoose adapter with VoilaJSX app discovery
*/
export class MongooseAdapter {
options;
connections;
discoveredApps;
isDevelopment;
mongoose;
constructor(options = {}) {
this.options = options;
this.connections = new Map();
this.discoveredApps = null;
this.mongoose = null;
this.isDevelopment = process.env.NODE_ENV === 'development';
if (this.isDevelopment) {
console.log('⚡ [AppKit] Mongoose adapter initialized with app discovery');
}
}
/**
* Creates Mongoose connection with app discovery and automatic connection management
*/
async createClient(config) {
if (!this.mongoose) {
try {
this.mongoose = (await import('mongoose')).default;
}
catch (error) {
throw createDatabaseError('Mongoose not found. Install with: npm install mongoose', 500);
}
}
const { url, maxPoolSize = 10, timeout = 10000, connectionOptions = {} } = config;
const appName = config.appName || await this._detectCurrentApp();
const clientKey = `${appName}_${url}_${maxPoolSize}_${timeout}`;
if (!this.connections.has(clientKey)) {
try {
const connection = await this.mongoose.createConnection(url, {
useNewUrlParser: true,
useUnifiedTopology: true,
maxPoolSize,
serverSelectionTimeoutMS: timeout,
bufferCommands: false,
bufferMaxEntries: 0,
...connectionOptions,
});
// Setup connection event handlers
this._setupConnectionEvents(connection, url);
// Load app-specific models
await this._loadModelsForApp(connection, appName);
// Add metadata
connection._appKit = true;
connection._appName = appName;
connection._url = url;
this.connections.set(clientKey, connection);
if (this.isDevelopment) {
console.log(`✅ [AppKit] Created Mongoose connection for app: ${appName}`);
}
}
catch (error) {
throw createDatabaseError(`Failed to create Mongoose connection for app '${appName}': ${error.message}`, 500);
}
}
return this.connections.get(clientKey);
}
/**
* Apply tenant filtering middleware to Mongoose connection
*/
async applyTenantMiddleware(connection, tenantId, options = {}) {
const tenantField = options.fieldName || 'tenant_id';
// Store original model function
const originalModel = connection.model.bind(connection);
// Override model function to add middleware
connection.model = function (name, schema, collection) {
if (schema && !schema._tenantMiddlewareApplied) {
// Mark schema as having middleware applied
schema._tenantMiddlewareApplied = true;
// Add tenant field to schema if not exists
if (!schema.paths[tenantField]) {
schema.add({
[tenantField]: {
type: String,
required: false,
index: true,
},
});
}
// Pre-save middleware - add tenant ID
schema.pre('save', function () {
if (!this[tenantField]) {
this[tenantField] = tenantId;
}
});
// Pre-insertMany middleware - add tenant ID to all documents
schema.pre('insertMany', function (next, docs) {
if (Array.isArray(docs)) {
docs.forEach((doc) => {
if (!doc[tenantField]) {
doc[tenantField] = tenantId;
}
});
}
next();
});
// Query middleware - add tenant filter
const queryMethods = [
'find',
'findOne',
'findOneAndUpdate',
'findOneAndDelete',
'findOneAndRemove',
'count',
'countDocuments',
'distinct',
'estimatedDocumentCount',
];
queryMethods.forEach((method) => {
schema.pre(method, function () {
const filter = this.getFilter();
if (!filter[tenantField]) {
this.where({ [tenantField]: tenantId });
}
});
});
// Update middleware - add tenant filter and data
const updateMethods = ['updateOne', 'updateMany', 'replaceOne'];
updateMethods.forEach((method) => {
schema.pre(method, function () {
const filter = this.getFilter();
if (!filter[tenantField]) {
this.where({ [tenantField]: tenantId });
}
// Also add tenant ID to update data if not present
const update = this.getUpdate();
if (update && typeof update === 'object' && !update[tenantField]) {
if (update.$set) {
update.$set[tenantField] = tenantId;
}
else {
update[tenantField] = tenantId;
}
}
});
});
// Delete middleware - add tenant filter
const deleteMethods = ['deleteOne', 'deleteMany', 'remove'];
deleteMethods.forEach((method) => {
schema.pre(method, function () {
const filter = this.getFilter();
if (!filter[tenantField]) {
this.where({ [tenantField]: tenantId });
}
});
});
// Aggregate middleware - add tenant match stage
schema.pre('aggregate', function () {
const pipeline = this.pipeline();
// Check if tenant filter already exists in pipeline
const hasMatch = pipeline.some((stage) => stage.$match && stage.$match[tenantField]);
if (!hasMatch) {
// Add tenant filter as first stage
pipeline.unshift({ $match: { [tenantField]: tenantId } });
}
});
}
return originalModel(name, schema, collection);
};
// Mark connection as tenant-filtered
connection._tenantId = tenantId;
connection._tenantFiltered = true;
return connection;
}
/**
* Auto-discover VoilaJSX apps with Mongoose models
*/
async discoverApps() {
if (this.discoveredApps)
return this.discoveredApps;
// Look for apps directory
const appsDir = this._findAppsDirectory();
if (!appsDir) {
if (this.isDevelopment) {
console.warn('⚠️ [AppKit] No /apps directory found, using single app mode');
}
this.discoveredApps = [];
return [];
}
const apps = [];
try {
const appFolders = fs
.readdirSync(appsDir, { withFileTypes: true })
.filter((dirent) => dirent.isDirectory())
.map((dirent) => dirent.name);
for (const appName of appFolders) {
// VoilaJSX standard: apps/{appName}/models or apps/{appName}/src/models
const possibleModelPaths = [
path.join(appsDir, appName, 'models'),
path.join(appsDir, appName, 'src/models'),
path.join(appsDir, appName, 'lib/models'),
];
let modelsPath = null;
for (const modelPath of possibleModelPaths) {
if (fs.existsSync(modelPath)) {
modelsPath = modelPath;
break;
}
}
if (modelsPath) {
apps.push({
name: appName,
modelsPath: path.resolve(modelsPath),
});
if (this.isDevelopment) {
console.log(`✅ [AppKit] Found Mongoose models for app: ${appName}`);
}
}
else if (this.isDevelopment) {
console.log(`⚠️ [AppKit] No Mongoose models found for app: ${appName}`);
console.log(` Expected: ${possibleModelPaths.join(' or ')}`);
}
}
this.discoveredApps = apps;
}
catch (error) {
console.error('❌ [AppKit] Error discovering apps:', error.message);
this.discoveredApps = [];
return [];
}
if (this.isDevelopment) {
console.log(`🔍 [AppKit] Discovered ${apps.length} apps with Mongoose models`);
}
return apps;
}
/**
* Check if tenant registry collection exists
*/
async hasTenantRegistry(connection) {
try {
const collections = await connection.db
.listCollections({
name: 'tenant_registry',
})
.toArray();
return collections.length > 0;
}
catch (error) {
return false;
}
}
/**
* Create tenant registry entry
*/
async createTenantRegistryEntry(connection, tenantId) {
try {
const collection = connection.db.collection('tenant_registry');
await collection.updateOne({ tenant_id: tenantId }, {
$set: {
tenant_id: tenantId,
created_at: new Date(),
updated_at: new Date(),
},
}, { upsert: true });
}
catch (error) {
if (this.isDevelopment) {
console.debug('Failed to create tenant registry entry:', error.message);
}
}
}
/**
* Delete tenant registry entry
*/
async deleteTenantRegistryEntry(connection, tenantId) {
try {
const collection = connection.db.collection('tenant_registry');
await collection.deleteOne({ tenant_id: tenantId });
}
catch (error) {
if (this.isDevelopment) {
console.debug('Failed to delete tenant registry entry:', error.message);
}
}
}
/**
* Check if tenant exists in registry
*/
async tenantExistsInRegistry(connection, tenantId) {
try {
const collection = connection.db.collection('tenant_registry');
const doc = await collection.findOne({ tenant_id: tenantId });
return !!doc;
}
catch (error) {
return false;
}
}
/**
* Get all tenants from registry
*/
async getTenantsFromRegistry(connection) {
try {
const collection = connection.db.collection('tenant_registry');
const docs = await collection
.find({}, {
projection: { tenant_id: 1, _id: 0 },
})
.sort({ tenant_id: 1 })
.toArray();
return docs.map((doc) => doc.tenant_id);
}
catch (error) {
return [];
}
}
/**
* Disconnect all cached connections
*/
async disconnect() {
const disconnectPromises = [];
for (const [key, connection] of this.connections) {
disconnectPromises.push(connection
.close()
.catch((error) => console.warn(`Error disconnecting Mongoose connection ${key}:`, error.message)));
}
await Promise.all(disconnectPromises);
this.connections.clear();
if (this.isDevelopment) {
console.log('👋 [AppKit] Mongoose adapter disconnected');
}
}
// Private helper methods
/**
* Detect current app from file path (VoilaJSX structure)
*/
async _detectCurrentApp() {
try {
// Get the calling file from stack trace
const stack = new Error().stack;
if (!stack)
return 'main';
const stackLines = stack.split('\n');
// Look for the first file in /apps/ directory
for (let i = 1; i < Math.min(stackLines.length, 10); i++) {
const line = stackLines[i];
if (line.includes('file://') && line.includes('/apps/')) {
const fileMatch = line.match(/\/apps\/([^\/]+)\//);
if (fileMatch) {
return fileMatch[1]; // Return app name
}
}
}
// Fallback: check current working directory
const cwd = process.cwd();
const appsMatch = cwd.match(/\/apps\/([^\/]+)/);
if (appsMatch) {
return appsMatch[1];
}
return 'main';
}
catch (error) {
if (this.isDevelopment) {
console.warn('Failed to detect current app:', error.message);
}
return 'main';
}
}
/**
* Find apps directory in project structure
*/
_findAppsDirectory() {
// Check environment variable first
if (process.env.VOILA_APPS_DIR && fs.existsSync(process.env.VOILA_APPS_DIR)) {
return process.env.VOILA_APPS_DIR;
}
// Search upwards from current directory
let currentDir = process.cwd();
for (let i = 0; i < 5; i++) {
const appsPath = path.join(currentDir, 'apps');
if (fs.existsSync(appsPath)) {
return appsPath;
}
const parentDir = path.dirname(currentDir);
if (parentDir === currentDir)
break; // Reached root
currentDir = parentDir;
}
return null;
}
/**
* Load models for specific app
*/
async _loadModelsForApp(connection, appName) {
// First try discovered apps
const apps = await this.discoverApps();
const app = apps.find((a) => a.name === appName);
if (app) {
try {
// Load all model files from the models directory
const modelFiles = fs
.readdirSync(app.modelsPath)
.filter((file) => file.endsWith('.js') || file.endsWith('.ts'))
.filter((file) => !file.endsWith('.d.ts'));
for (const modelFile of modelFiles) {
try {
const modelPath = path.join(app.modelsPath, modelFile);
const module = await import(`file://${modelPath}`);
// Call model registration function if it exists
if (typeof module.default === 'function') {
await module.default(connection);
}
else if (typeof module.registerModels === 'function') {
await module.registerModels(connection);
}
}
catch (error) {
if (this.isDevelopment) {
console.warn(`Failed to load model ${modelFile} for app ${appName}:`, error.message);
}
}
}
if (this.isDevelopment) {
console.log(`✅ [AppKit] Loaded ${modelFiles.length} models for app: ${appName}`);
}
}
catch (error) {
if (this.isDevelopment) {
console.warn(`Failed to load models for app ${appName}:`, error.message);
}
}
}
}
/**
* Setup connection event handlers
*/
_setupConnectionEvents(connection, url) {
connection.on('connected', () => {
if (this.isDevelopment) {
console.debug(`MongoDB connected: ${this._maskUrl(url)}`);
}
});
connection.on('error', (error) => {
console.error(`MongoDB connection error: ${error.message}`);
});
connection.on('disconnected', () => {
if (this.isDevelopment) {
console.debug(`MongoDB disconnected: ${this._maskUrl(url)}`);
}
});
connection.on('reconnected', () => {
if (this.isDevelopment) {
console.debug(`MongoDB reconnected: ${this._maskUrl(url)}`);
}
});
}
/**
* Mask URL for logging (hide credentials)
*/
_maskUrl(url) {
if (!url)
return '[no-url]';
try {
return url.replace(/:\/\/[^@]*@/, '://***:***@');
}
catch {
return '[masked-url]';
}
}
}
//# sourceMappingURL=mongoose.js.map