Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions src/adminDataSource.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { DataSource } from 'typeorm';
import config from './config';
import { getEntities } from './entities/entities';
import { redisConfig } from './redis';
import { logger } from './utils/logger';

/**
* AdminDataSource - Dedicated DataSource for AdminJS
*
* This DataSource ALWAYS uses the master database for both reads and writes.
* This ensures AdminJS operations work correctly without trying to write to read replicas.
*
* IMPORTANT: Do NOT change defaultMode to 'slave' for this DataSource
*/
export class AdminDataSource {
private static datasource: DataSource;

static async initialize() {
if (!AdminDataSource.datasource) {
const entities = getEntities();
const poolSize = Number(process.env.TYPEORM_DATABASE_POOL_SIZE) || 10;

// AdminJS always uses master - no read replica routing
AdminDataSource.datasource = new DataSource({
name: 'admin', // Unique name for AdminJS DataSource
schema: 'public',
type: 'postgres',
// Single master connection - no replication for AdminJS
host: config.get('TYPEORM_DATABASE_HOST') as string,
port: config.get('TYPEORM_DATABASE_PORT') as number,
database: config.get('TYPEORM_DATABASE_NAME') as string,
username: config.get('TYPEORM_DATABASE_USER') as string,
password: config.get('TYPEORM_DATABASE_PASSWORD') as string,

entities,
synchronize: false, // Never auto-sync in admin
dropSchema: false,
logger: 'advanced-console',
logging: ['error', 'warn'],
cache: {
type: 'redis',
options: {
...redisConfig,
db: 2, // Different Redis DB for admin cache
},
},
poolSize: Math.max(5, Math.floor(poolSize / 2)), // Smaller pool for admin
extra: {
maxWaitingClients: 5,
evictionRunIntervalMillis: 1000,
idleTimeoutMillis: 1000,
},
});

await AdminDataSource.datasource.initialize();
logger.info('✅ AdminDataSource initialized (Master only)');
}
}

static getDataSource() {
if (!AdminDataSource.datasource) {
throw new Error(
'AdminDataSource not initialized. Call initialize() first.',
);
}
return AdminDataSource.datasource;
}

static async close() {
if (AdminDataSource.datasource?.isInitialized) {
await AdminDataSource.datasource.destroy();
logger.info('AdminDataSource closed');
}
}
}
16 changes: 15 additions & 1 deletion src/orm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import config from './config';
import { CronJob } from './entities/CronJob';
import { getEntities } from './entities/entities';
import { redisConfig } from './redis';
import { logger } from './utils/logger';

export class AppDataSource {
private static datasource: DataSource;
Expand All @@ -29,7 +30,7 @@ export class AppDataSource {
schema: 'public',
type: 'postgres',
replication: {
defaultMode: 'master',
defaultMode: slaves.length > 0 ? 'slave' : 'master',
master: {
database: config.get('TYPEORM_DATABASE_NAME') as string,
username: config.get('TYPEORM_DATABASE_USER') as string,
Expand Down Expand Up @@ -60,6 +61,19 @@ export class AppDataSource {
},
});
await AppDataSource.datasource.initialize();

// Log replication configuration
if (slaves.length > 0) {
logger.info(
`✅ AppDataSource initialized with ${slaves.length} read replica(s)`,
);
logger.info(` Master: ${config.get('TYPEORM_DATABASE_HOST')}`);
slaves.forEach((slave, idx) => {
logger.info(` Replica ${idx + 1}: ${slave.host}`);
});
} else {
logger.info('✅ AppDataSource initialized (No replicas configured)');
}
}
}

Expand Down
67 changes: 46 additions & 21 deletions src/server/adminJs/adminJs.ts
Original file line number Diff line number Diff line change
@@ -1,40 +1,41 @@
import adminJs, { ActionContext, AdminJSOptions } from 'adminjs';
import adminJsExpress from '@adminjs/express';
import { Database, Resource } from '@adminjs/typeorm';
import adminJs, { ActionContext, AdminJSOptions } from 'adminjs';
import { IncomingMessage } from 'connect';
import { User } from '../../entities/user';
import { AdminDataSource } from '../../adminDataSource';
import config from '../../config';
import { User } from '../../entities/user';
import { redis } from '../../redis';
import { logger } from '../../utils/logger';
import { findUserById } from '../../repositories/userRepository';
import { fetchAdminAndValidatePassword } from '../../services/userService';
import { campaignsTab } from './tabs/campaignsTab';
import { logger } from '../../utils/logger';
import { AnchorContractAddressTab } from './tabs/anchorContractAddressTab';
import { broadcastNotificationTab } from './tabs/broadcastNotificationTab';
import { mainCategoryTab } from './tabs/mainCategoryTab';
import { campaignsTab } from './tabs/campaignsTab';
import { categoryTab } from './tabs/categoryTab';
import { projectsTab } from './tabs/projectsTab';
import { donationTab } from './tabs/donationTab';
import { featuredUpdateTab } from './tabs/featuredUpdateTab';
import { globalConfigurationTab } from './tabs/globalConfigurationTab';
import { mainCategoryTab } from './tabs/mainCategoryTab';
import { organizationsTab } from './tabs/organizationsTab';
import { usersTab } from './tabs/usersTab';
import { projectAddressTab } from './tabs/projectAddressTab';
import { ProjectFraudTab } from './tabs/projectFraudTab';
import { projectQfRoundsTab } from './tabs/projectQfRoundsTab';
import { projectSocialMediaTab } from './tabs/projectSocialMediaTab';
import { projectsTab } from './tabs/projectsTab';
import { projectStatusHistoryTab } from './tabs/projectStatusHistoryTab';
import { projectStatusReasonTab } from './tabs/projectStatusReasonTab';
import { projectAddressTab } from './tabs/projectAddressTab';
import { projectStatusTab } from './tabs/projectStatusTab';
import { projectUpdateTab } from './tabs/projectUpdateTab';
import { thirdPartProjectImportTab } from './tabs/thirdPartProjectImportTab';
import { featuredUpdateTab } from './tabs/featuredUpdateTab';
import { generateTokenTab } from './tabs/tokenTab';
import { donationTab } from './tabs/donationTab';
import { projectVerificationTab } from './tabs/projectVerificationTab';
import { qfRoundTab } from './tabs/qfRoundTab';
import { qfRoundHistoryTab } from './tabs/qfRoundHistoryTab';
import { SybilTab } from './tabs/sybilTab';
import { ProjectFraudTab } from './tabs/projectFraudTab';
import { qfRoundTab } from './tabs/qfRoundTab';
import { RecurringDonationTab } from './tabs/recurringDonationTab';
import { AnchorContractAddressTab } from './tabs/anchorContractAddressTab';
import { projectSocialMediaTab } from './tabs/projectSocialMediaTab';
import { SwapTransactionTab } from './tabs/swapTransactionTab';
import { projectQfRoundsTab } from './tabs/projectQfRoundsTab';
import { globalConfigurationTab } from './tabs/globalConfigurationTab';
import { SybilTab } from './tabs/sybilTab';
import { thirdPartProjectImportTab } from './tabs/thirdPartProjectImportTab';
import { generateTokenTab } from './tabs/tokenTab';
import { usersTab } from './tabs/usersTab';

// use redis for session data instead of in-memory storage
// eslint-disable-next-line @typescript-eslint/no-var-requires
Expand Down Expand Up @@ -132,6 +133,8 @@ export const getCurrentAdminJsSession = async (request: IncomingMessage) => {
type AdminJsResources = AdminJSOptions['resources'];

const getResources = async (): Promise<AdminJsResources> => {
const adminDataSource = AdminDataSource.getDataSource();

const resources: AdminJsResources = [
projectVerificationTab,
projectQfRoundsTab,
Expand Down Expand Up @@ -162,6 +165,28 @@ const getResources = async (): Promise<AdminJsResources> => {
globalConfigurationTab,
];

// Ensure all resources use the AdminJS-specific DataSource
const resourcesWithAdminDataSource: AdminJsResources = resources.map(
(res: any) => {
const resourceDef = res?.resource ?? res;
// If resource is an Entity (constructor function), wrap it with a TypeORM Resource bound to dataSource
if (typeof resourceDef === 'function') {
return {
...res,
resource: new (Resource as any)(resourceDef, adminDataSource),
};
}
// If resource already an object descriptor, construct a TypeORM Resource using provided model
if (resourceDef && typeof resourceDef === 'object' && resourceDef.model) {
return {
...res,
resource: new (Resource as any)(resourceDef.model, adminDataSource),
};
}
return res;
},
);

const loggingHook = async (response, request, context) => {
const { action, currentAdmin, resource } = context;
const { method, params } = request;
Expand All @@ -180,7 +205,7 @@ const getResources = async (): Promise<AdminJsResources> => {
return response;
};
// Add logging hook to all resources
resources.forEach(resource => {
(resourcesWithAdminDataSource as any[]).forEach(resource => {
const options = resource.options || {};
const actions = options.actions || {};
const resourceActionList = Object.keys(actions);
Expand All @@ -203,7 +228,7 @@ const getResources = async (): Promise<AdminJsResources> => {
resource.options = options;
});

return resources;
return resourcesWithAdminDataSource;
};

const getadminJsInstance = async () => {
Expand Down
4 changes: 4 additions & 0 deletions src/server/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
import { logger } from '../utils/logger';
import { adminJsRootPath, getAdminJsRouter } from './adminJs/adminJs';
// import { apiGivRouter } from '../routers/apiGivRoutes';
import { AdminDataSource } from '../adminDataSource';
import { AppDataSource, CronDataSource } from '../orm';
import {
dropDbCronExtension,
Expand Down Expand Up @@ -103,6 +104,9 @@ export async function bootstrap() {
await CronDataSource.initialize();
logger.debug('bootstrap() after CronDataSource.initialize()', new Date());

// Initialize dedicated AdminJS DataSource (master-only)
await AdminDataSource.initialize();

Container.set(DataSource, AppDataSource.getDataSource());

await setDatabaseParameters(AppDataSource.getDataSource());
Expand Down
Loading
Loading