Skip to content
Open
34 changes: 18 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,35 +37,37 @@ Why AdminForth:

## Project initialisation

To create an AdminForth project, run:
AdminForth supports two setup paths:

```bash
npx adminforth create-app
```
### Path 1: Existing database

During the interactive initialization process, AdminForth will ask you to provide a local database URL.
Use this path when you already have a database and your own schema or migrations. Provide your database URL with `--db`, or enter it when the CLI asks `Please specify the database URL to use`.

### Integrating AdminForth into your existing application
```bash
npx adminforth create-app --app-name myadmin --db "postgresql://user:password@localhost:5432/dbname"
cd myadmin
```

If you want to build an admin panel for an existing project that already has a database with tables, you can provide the connection URL to your existing development database, such as a local or deployed one.
When you provide your own database URL, AdminForth connects to your database but does not create Prisma schema or migrations for it. The generated project README includes the SQL or schema notes needed to add the required `adminuser` table with your own migration tool.

After that, you may want to generate AdminForth resource files from your existing database tables:
After project creation, generate AdminForth resource files from your existing tables:

```bash
npx adminforth resource
```

Resource files are needed for AdminForth to “know” about your tables and define how to work with them.
### Path 2: New database

Use the command above every time you add new tables or change their schema.
Use this path when you want AdminForth to scaffold a standalone app with a new local SQLite database. Omit `--db`, or accept the default `sqlite://.db.sqlite` value in the interactive prompt:

### Starting from scratch

If you do not have a database yet, start an empty local database, for example PostgreSQL in Docker, and provide its URL to the AdminForth CLI.

If the adminforth CLI does not detect any tables, it will suggest adding Prisma as a migration tool. Prisma is not related to AdminForth, but it is one of the most convenient migration tools.
```bash
npx adminforth create-app --app-name myadmin
cd myadmin
pnpm makemigration --name init && pnpm migrate:local
pnpm dev
```

Please follow [getting started](https://adminforth.dev/docs/tutorial/gettingStarted/).
For the new database path, the CLI can scaffold Prisma files and migration scripts for the default `sqlite://.db.sqlite` database. Please follow [getting started](https://adminforth.dev/docs/tutorial/gettingStarted/) for the full guide.

# For AdminForth developers

Expand Down
13 changes: 12 additions & 1 deletion adminforth/commands/createApp/templates/readme.md.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,22 @@ Install dependencies:
{{packageManager}} install
```

Migrate the database:
{{#if adminUserTableInstructions}}
Prepare the admin users table in your existing database before starting the app. AdminForth uses this table for back-office authentication, and your own migration tool should own this schema change:

{{{adminUserTableInstructions}}}

The generated app will seed the default `adminforth` / `adminforth` user on first start if the table is empty.
{{/if}}

{{#if prismaDbUrl}}
Create the initial migration and apply it to the database:

```bash
{{packageManagerRun}} makemigration{{packageManagerScriptArgSeparator}}--name init
{{packageManagerRun}} migrate:local
```
{{/if}}
Comment thread
NoOne7135 marked this conversation as resolved.

Start the server:

Expand Down
140 changes: 128 additions & 12 deletions adminforth/commands/createApp/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,14 @@ function detectAdminforthVersion() {
const adminforthVersion = detectAdminforthVersion();
const SUPPORTED_DB_URL_SCHEMES = ['sqlite://', 'postgresql://', 'mongodb://', 'mysql://', 'clickhouse://'];
const PRISMA_MIGRATION_DB_PROTOCOLS = ['sqlite', 'postgres', 'postgresql', 'mysql'];
const DEFAULT_DB_URL = 'sqlite://.db.sqlite';
const DATABASE_CONNECTOR_IMPORTS = {
sqlite: '../../dist/dataConnectors/sqlite.js',
postgresql: '../../dist/dataConnectors/postgres.js',
mysql: '../../dist/dataConnectors/mysql.js',
mongodb: '../../dist/dataConnectors/mongo.js',
clickhouse: '../../dist/dataConnectors/clickhouse.js',
};


export function parseArgumentsIntoOptions(rawArgs) {
Expand All @@ -61,6 +69,66 @@ export function parseArgumentsIntoOptions(rawArgs) {
};
}

function generateAdminUserTableInstructions(provider) {
if (provider === 'postgresql') {
return `\`\`\`sql
CREATE TABLE adminuser (
id TEXT PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL,
created_at TIMESTAMP NOT NULL
);
\`\`\``;
}

if (provider === 'mysql') {
return `\`\`\`sql
CREATE TABLE adminuser (
id VARCHAR(191) PRIMARY KEY,
email VARCHAR(191) NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role VARCHAR(191) NOT NULL,
created_at DATETIME NOT NULL
);
\`\`\``;
}

if (provider === 'sqlite') {
return `\`\`\`sql
CREATE TABLE adminuser (
id TEXT PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL,
created_at DATETIME NOT NULL
);
\`\`\``;
}

if (provider === 'clickhouse') {
return `\`\`\`sql
CREATE TABLE adminuser (
id String,
email String,
password_hash String,
role String,
created_at DateTime
)
ENGINE = MergeTree()
ORDER BY id;
\`\`\`

ClickHouse does not enforce UNIQUE constraints like PostgreSQL, MySQL, or SQLite. AdminForth authentication expects \`email\` values in \`adminuser\` to be unique, so enforce this in your ingestion/application logic and remove duplicate email rows to avoid ambiguous logins.`;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@NoOne7135 actually I think we don't need it here, also I think we can safely remove all unique constraints form other DBs snippets. This should be specified in AdminForth resource config already - and it takes care about all of these on own soft layer. Hard unique constraints in DB are often more pain because if u have it here, but af layer will set unique to false, users will get direct db errors, instead of clear nice message (+other reasons) - so lets leave this decision for user, if he wants to get this pain - he can add it by himself.
Uniques - often create implicit index (but it is easier to create non unique index = same log2 access performance). Also UNIQUE is good of course for integrity, but adminforth will do it anyway by himself by additional checks.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also - please explain to user that all these snippets are just for example, we recommend him to use his favorite migration tool first. This is just example

}
Comment thread
NoOne7135 marked this conversation as resolved.

if (provider === 'mongodb') {
return 'Create an `adminuser` collection with `id`, `email`, `password_hash`, `role`, and `created_at` fields. Keep `email` unique in your own schema/index setup.';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In mongo user has to do nothing ;)
It is not possible to create collection IMO. Mongo concept is just write to any collection any fields and mongo will create everything. So no additional steps should be needed. At least by mongo design

}

return null;
}

export async function promptForMissingOptions(options) {
const questions = [];

Expand All @@ -78,7 +146,7 @@ export async function promptForMissingOptions(options) {
type: 'input',
name: 'db',
message: 'Please specify the database URL to use >',
default: 'sqlite://.db.sqlite',
default: DEFAULT_DB_URL,
});
};

Expand All @@ -102,10 +170,14 @@ export async function promptForMissingOptions(options) {
db: options.db || answers.db,
useNpm: options.useNpm || answers.useNpm,
};
resolvedOptions.existingDb = false;

await inspectDatabaseCleanState(resolvedOptions);

if (
resolvedOptions.includePrismaMigrations === undefined &&
isPrismaMigrationDbUrl(resolvedOptions.db)
isPrismaMigrationDbUrl(resolvedOptions.db) &&
!resolvedOptions.existingDb
) {
const prismaAnswer = await inquirer.prompt([{
type: 'select',
Expand All @@ -119,7 +191,7 @@ export async function promptForMissingOptions(options) {
}]);
resolvedOptions.includePrismaMigrations = prismaAnswer.includePrismaMigrations;
} else {
resolvedOptions.includePrismaMigrations = Boolean(resolvedOptions.includePrismaMigrations);
resolvedOptions.includePrismaMigrations = Boolean(resolvedOptions.includePrismaMigrations) && !resolvedOptions.existingDb;
}

return resolvedOptions;
Expand Down Expand Up @@ -189,6 +261,32 @@ function generateDbUrlForAfProd(connectionString) {
return connectionString.toString();
}

async function inspectDatabaseCleanState(options) {
const connectionString = parseConnectionString(options.db);
const provider = detectDbProvider(connectionString.protocol);

const Connector = (await import(DATABASE_CONNECTOR_IMPORTS[provider])).default;
const connector = new Connector();

try {
await connector.setupClient(connectionString.toString());
} catch (error) {
if (provider === 'sqlite' && error.message?.includes('directory does not exist')) {
options.existingDb = false;
return;
}
throw error;
}

try {
options.existingDb = !(await connector.isDatabaseEmpty());
} finally {
if (typeof connector.close === 'function') {
await connector.close();
}
}
}

function initialChecks(options) {
return [
{
Expand Down Expand Up @@ -262,7 +360,7 @@ async function scaffoldProject(ctx, options, cwd) {
const prismaDbUrlProd = generateDbUrlForPrismaProd(connectionString);


ctx.skipPrismaSetup = !prismaDbUrl;
ctx.skipPrismaSetup = !options.includePrismaMigrations || !prismaDbUrl;
const appName = options.appName;

const filename = fileURLToPath(import.meta.url);
Expand All @@ -287,6 +385,7 @@ async function scaffoldProject(ctx, options, cwd) {
prismaDbUrlProd,
appName,
provider,
existingDb: options.existingDb,
nodeMajor: parseInt(process.versions.node.split('.')[0], 10),
sqliteFile: connectionString.protocol.startsWith('sqlite') ? connectionString.host : null,
});
Expand All @@ -310,7 +409,7 @@ function getPackageManagerTemplateData(useNpm, nodeMajor) {

async function writeTemplateFiles(dirname, cwd, useNpm, includePrismaMigrations, options) {
const {
dbUrl, prismaDbUrl, appName, provider, nodeMajor,
dbUrl, prismaDbUrl, appName, provider, existingDb, nodeMajor,
dbUrlProd, prismaDbUrlProd, sqliteFile
} = options;
const packageManagerTemplateData = getPackageManagerTemplateData(useNpm, nodeMajor);
Expand Down Expand Up @@ -352,7 +451,14 @@ async function writeTemplateFiles(dirname, cwd, useNpm, includePrismaMigrations,
{
src: 'readme.md.hbs',
dest: 'README.md',
data: { dbUrl, prismaDbUrl: resolvedPrismaDbUrl, appName, sqliteFile },
data: {
dbUrl,
prismaDbUrl: resolvedPrismaDbUrl,
appName,
sqliteFile,
existingDb,
adminUserTableInstructions: existingDb ? generateAdminUserTableInstructions(provider) : null,
},
},
{
src: 'AGENTS.md.hbs',
Expand Down Expand Up @@ -519,16 +625,21 @@ async function installDependenciesNpm(ctx, cwd) {

function generateFinalInstructionsPnpm(skipPrismaSetup, options) {
let instruction = '⏭️ Run the following commands to get started:\n';
if (!skipPrismaSetup)
instruction += `
const provider = detectDbProvider(parseConnectionString(options.db).protocol);
instruction += `
${chalk.dim('// Go to the project directory')}
${chalk.dim('$')}${chalk.cyan(` cd ${options.appName}`)}\n`;

if (options.includePrismaMigrations && !skipPrismaSetup)
if (!skipPrismaSetup)
instruction += `
${chalk.dim('// Generate and apply initial migration')}
${chalk.dim('$')}${chalk.cyan(' pnpm makemigration --name init && pnpm migrate:local')}\n`;

if (options.existingDb)
instruction += `
${chalk.dim('// Create the adminuser table in your database before starting the app')}
${generateAdminUserTableInstructions(provider)}\n`;

instruction += `
${chalk.dim('// Start dev server with tsx watch for hot-reloading')}
${chalk.dim('$')}${chalk.cyan(' pnpm dev')}\n
Expand All @@ -541,16 +652,21 @@ function generateFinalInstructionsPnpm(skipPrismaSetup, options) {

function generateFinalInstructionsNpm(skipPrismaSetup, options) {
let instruction = '⏭️ Run the following commands to get started:\n';
if (!skipPrismaSetup)
instruction += `
const provider = detectDbProvider(parseConnectionString(options.db).protocol);
instruction += `
${chalk.dim('// Go to the project directory')}
${chalk.dim('$')}${chalk.cyan(` cd ${options.appName}`)}\n`;

if (options.includePrismaMigrations && !skipPrismaSetup)
if (!skipPrismaSetup)
instruction += `
${chalk.dim('// Generate and apply initial migration')}
${chalk.dim('$')}${chalk.cyan(' npm run makemigration -- --name init && npm run migrate:local')}\n`;

if (options.existingDb)
instruction += `
${chalk.dim('// Create the adminuser table in your database before starting the app')}
${generateAdminUserTableInstructions(provider)}\n`;

instruction += `
${chalk.dim('// Start dev server with tsx watch for hot-reloading')}
${chalk.dim('$')}${chalk.cyan(' npm run dev')}\n
Expand Down
4 changes: 4 additions & 0 deletions adminforth/dataConnectors/baseConnector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -696,5 +696,9 @@ export default class AdminForthBaseConnector implements IAdminForthDataSourceCon
throw new Error('getAllColumnsInTable() must be implemented in subclass');
}

async isDatabaseEmpty(): Promise<boolean> {
throw new Error('isDatabaseEmpty() must be implemented in subclass');
}


}
23 changes: 21 additions & 2 deletions adminforth/dataConnectors/clickhouse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,25 @@ class ClickhouseConnector extends AdminForthBaseConnector implements IAdminForth
sampleValue: sampleRow[col.name],
}));
}

async isDatabaseEmpty(): Promise<boolean> {
const res = await this.client.query({
query: `
SELECT database, name, engine
FROM system.tables
WHERE database = {database:String}
AND database NOT IN ('system', 'information_schema', 'INFORMATION_SCHEMA')
AND is_temporary = 0
LIMIT 1
`,
format: 'JSONEachRow',
query_params: {
database: this.dbName,
},
});
const rows = await res.json();
return rows.length === 0;
}

async discoverFields(resource: AdminForthResource): Promise<{[key: string]: AdminForthResourceColumn}> {
const tableName = resource.table;
Expand Down Expand Up @@ -662,8 +681,8 @@ class ClickhouseConnector extends AdminForthBaseConnector implements IAdminForth
return recordIds.length;
}

close() {
this.client.disconnect();
async close() {
await this.client.close();
}
}

Expand Down
5 changes: 5 additions & 0 deletions adminforth/dataConnectors/mongo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,11 @@ class MongoConnector extends AdminForthBaseConnector implements IAdminForthDataS
};
});
}

async isDatabaseEmpty(): Promise<boolean> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think prisma for mongo has no sense at all. Never. It works with mongo - yes. But not for migrations - this is for typesafe prisma client which we dont use anyway!
So nothing decide here - simply lets never include prisma for mongo. Thoughts?

const collections = await this.client.db().listCollections({}, { nameOnly: true }).toArray();
return collections.every((collection) => collection.name.startsWith('system.'));
}


async discoverFields(resource) {
Expand Down
11 changes: 11 additions & 0 deletions adminforth/dataConnectors/mysql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,17 @@ class MysqlConnector extends AdminForthBaseConnector implements IAdminForthDataS
}));
}

async isDatabaseEmpty(): Promise<boolean> {
const [rows] = await this.client.execute(`
SELECT table_schema, table_name
FROM information_schema.tables
WHERE table_schema = DATABASE()
AND table_type = 'BASE TABLE'
LIMIT 1
`);
return rows.length === 0;
}

async hasMySQLCascadeFk(resource: AdminForthResource, config: AdminForthConfig): Promise<boolean> {

const cascadeColumn = resource.columns.find(c => c.foreignResource?.onDelete === 'cascade');
Expand Down
Loading