From fa3fa5c10060f5ff0c1c01acc075a21576b62d41 Mon Sep 17 00:00:00 2001 From: Maksym Pipkun Date: Fri, 5 Jun 2026 15:08:24 +0300 Subject: [PATCH 1/8] fix: enhance project initialization and menu transformation capabilities --- README.md | 34 +++--- .../createApp/templates/readme.md.hbs | 10 ++ adminforth/commands/createApp/utils.js | 115 +++++++++++++----- .../docs/tutorial/001-gettingStarted.md | 43 +++++-- adminforth/index.ts | 36 ++++-- 5 files changed, 176 insertions(+), 62 deletions(-) diff --git a/README.md b/README.md index d4b6a7248..bff39d687 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/adminforth/commands/createApp/templates/readme.md.hbs b/adminforth/commands/createApp/templates/readme.md.hbs index 355413e45..0945c6696 100644 --- a/adminforth/commands/createApp/templates/readme.md.hbs +++ b/adminforth/commands/createApp/templates/readme.md.hbs @@ -6,11 +6,21 @@ Install dependencies: {{packageManager}} install ``` +{{#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}} Migrate the database: ```bash {{packageManagerRun}} migrate:local ``` +{{/if}} Start the server: diff --git a/adminforth/commands/createApp/utils.js b/adminforth/commands/createApp/utils.js index 558cf3b20..03808ee38 100644 --- a/adminforth/commands/createApp/utils.js +++ b/adminforth/commands/createApp/utils.js @@ -39,6 +39,7 @@ 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'; export function parseArgumentsIntoOptions(rawArgs) { @@ -57,10 +58,69 @@ export function parseArgumentsIntoOptions(rawArgs) { return { appName: args['--app-name'], db: args['--db'], + dbProvided: args['--db'] !== undefined, useNpm: args['--use-npm'], }; } +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; +\`\`\``; + } + + 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.'; + } + + return null; +} + export async function promptForMissingOptions(options) { const questions = []; @@ -78,7 +138,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, }); }; @@ -102,25 +162,8 @@ export async function promptForMissingOptions(options) { db: options.db || answers.db, useNpm: options.useNpm || answers.useNpm, }; - - if ( - resolvedOptions.includePrismaMigrations === undefined && - isPrismaMigrationDbUrl(resolvedOptions.db) - ) { - const prismaAnswer = await inquirer.prompt([{ - type: 'select', - name: 'includePrismaMigrations', - message: 'Include Prisma migrations? >', - choices: [ - { name: 'Yes', value: true }, - { name: 'No', value: false }, - ], - default: true, - }]); - resolvedOptions.includePrismaMigrations = prismaAnswer.includePrismaMigrations; - } else { - resolvedOptions.includePrismaMigrations = Boolean(resolvedOptions.includePrismaMigrations); - } + resolvedOptions.existingDb = options.dbProvided || resolvedOptions.db !== DEFAULT_DB_URL; + resolvedOptions.includePrismaMigrations = !resolvedOptions.existingDb && isPrismaMigrationDbUrl(resolvedOptions.db); return resolvedOptions; } @@ -262,7 +305,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); @@ -287,6 +330,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, }); @@ -310,7 +354,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); @@ -352,7 +396,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', @@ -519,16 +570,19 @@ async function installDependenciesNpm(ctx, cwd) { function generateFinalInstructionsPnpm(skipPrismaSetup, options) { let instruction = '⏭️ Run the following commands to get started:\n'; - if (!skipPrismaSetup) - instruction += ` + 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 using the README instructions')}\n`; + instruction += ` ${chalk.dim('// Start dev server with tsx watch for hot-reloading')} ${chalk.dim('$')}${chalk.cyan(' pnpm dev')}\n @@ -541,16 +595,19 @@ function generateFinalInstructionsPnpm(skipPrismaSetup, options) { function generateFinalInstructionsNpm(skipPrismaSetup, options) { let instruction = '⏭️ Run the following commands to get started:\n'; - if (!skipPrismaSetup) - instruction += ` + 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 using the README instructions')}\n`; + instruction += ` ${chalk.dim('// Start dev server with tsx watch for hot-reloading')} ${chalk.dim('$')}${chalk.cyan(' npm run dev')}\n diff --git a/adminforth/documentation/docs/tutorial/001-gettingStarted.md b/adminforth/documentation/docs/tutorial/001-gettingStarted.md index 842b7c00d..7f55aa9f8 100644 --- a/adminforth/documentation/docs/tutorial/001-gettingStarted.md +++ b/adminforth/documentation/docs/tutorial/001-gettingStarted.md @@ -21,15 +21,38 @@ nvm use 20 ## Creating an AdminForth Project -The recommended way to get started with AdminForth is via the **`create-app`** CLI, which scaffolds a basic fully functional back-office application. Apart boilerplate it creates one resource for users management. +The recommended way to get started with AdminForth is via the **`create-app`** CLI, which scaffolds a basic fully functional back-office application. Apart from boilerplate, it creates one resource for users management. -You can provide options directorly: +There are two common setup paths: + +### Path 1: Existing Database + +Use this path when you already have a database and your own schema or migrations. Pass your database URL with `--db`, or enter it when the CLI asks `Please specify the database URL to use`: + +```bash +npx adminforth create-app --app-name myadmin --db "postgresql://user:password@localhost:5432/dbname" +``` + +When you provide your own database URL, the CLI treats this as your own database. It does not create Prisma schema or Prisma migration scripts for that database. Instead, the generated project README contains the SQL or schema notes for adding the required `adminuser` table with your own migration tool. + +After the project is created, navigate into it and generate resources from your existing tables: + +```bash +cd myadmin +npx adminforth resource +``` + +Resource files are needed for AdminForth to know about your tables and define how to work with them. Use `npx adminforth resource` again when you add new tables or change their schema. + +### Path 2: New Database + +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: ```bash -npx adminforth create-app --app-name myadmin --db "sqlite://.db.sqlite" +npx adminforth create-app --app-name myadmin ``` -Or omit them to be prompted interactively: +Or omit all options to be prompted interactively: ```bash npx adminforth create-app @@ -41,6 +64,8 @@ Once the project is created, navigate into its directory: cd myadmin # or any other name you provided ``` +For the new database path, the CLI can scaffold Prisma files and migration scripts for the default SQLite database. + CLI options: * **`--app-name`** - name for your project. Used in `package.json`, `index.ts` branding, etc. Default value: **`adminforth-app`**. @@ -69,7 +94,7 @@ myadmin/ │ └── tsconfig.json # Tsconfig for Vue project (adds completion for AdminForth core components) ├── resources │ └── adminuser.ts # Example resource file for users management -├── schema.prisma # Prisma schema file for database schema +├── schema.prisma # Prisma schema file, generated only for the new database path ├── index.ts # Main entry point: configures AdminForth & starts the server ├── package.json # Project dependencies ├── pnpm-workspace.yaml @@ -82,15 +107,15 @@ myadmin/ ### Initial Migration & Future Migrations -> ☝️ CLI creates Prisma schema file for managing migrations in relational databases, however you are not forced to use it. Instead you are free to use your favourite or existing migration tool. In this case just ignore generated prisma file, and don't run migration command which will be suggested by CLI. However you have to ensure that your migration tool will generate required table `adminuser` with same fields and types for Admin Users resource to implmenet BackOffice authentication. +For the new database path, the CLI creates Prisma files for managing migrations. Prisma is not required by AdminForth itself, but it is a convenient migration tool for standalone projects that do not have database management yet. CLI will suggest you a command to initialize the database with Prisma: ```bash -pnpm makemigration --name init +pnpm makemigration --name init && pnpm migrate:local ``` -This will create a migration file in `migrations` and apply it to the database. +This will create a migration file and apply it to the database. In future, when you need to add new resources, you need to modify `schema.prisma` (add models, change fields, etc.). After doing any modification you need to create a new migration using next command: @@ -100,6 +125,8 @@ pnpm makemigration --name init ; pnpm migrate:local Other developers need to pull migration and run `pnpm migrate:local` to apply any unapplied migrations. +For the existing database path, use your own migration tool instead. The generated project README shows how to add the required `adminuser` table to your database. + ## Run the Server Now you can run your app: diff --git a/adminforth/index.ts b/adminforth/index.ts index 1a78495d9..e5dd91d9d 100644 --- a/adminforth/index.ts +++ b/adminforth/index.ts @@ -26,6 +26,7 @@ import { UpdateResourceRecordResult, DeleteResourceRecordResult, AdminForthMenuContributionProvider, + AdminForthMenuTransformProvider, } from './types/Back.js'; import { AdminForthFilterOperators, @@ -133,6 +134,7 @@ class AdminForth implements IAdminForth { pluginsById: Record = {}; private menuContributions: AdminForthMenuContribution[] = []; private menuContributionProviders: AdminForthMenuContributionProvider[] = []; + private menuTransformProviders: AdminForthMenuTransformProvider[] = []; configValidator: IConfigValidator; restApi: AdminForthRestAPI; @@ -146,6 +148,10 @@ class AdminForth implements IAdminForth { this.menuContributionProviders.push(provider); } + registerMenuTransformProvider(provider: AdminForthMenuTransformProvider): void { + this.menuTransformProviders.push(provider); + } + getMenuContributions(): AdminForthMenuContribution[] { return [...this.menuContributions]; } @@ -153,6 +159,15 @@ class AdminForth implements IAdminForth { async getMenuWithContributions(adminUser?: AdminUser, menu: AdminForthConfigMenuItem[] = this.config.menu): Promise { const generateItemId = (item: AdminForthConfigMenuItem) => md5hash(`menu-item-${item.label}-${item.resourceId || ''}-${item.path || ''}`); + const cloneMenuItem = (item: AdminForthConfigMenuItem): AdminForthConfigMenuItem => ({ + ...item, + children: item.children?.map(cloneMenuItem), + }); + const resolveMenuItemIds = (items: AdminForthConfigMenuItem[]): AdminForthConfigMenuItem[] => items.map((item) => ({ + ...item, + itemId: item.itemId || generateItemId(item), + children: item.children ? resolveMenuItemIds(item.children) : item.children, + })); const matchesTarget = (item: AdminForthConfigMenuItem, target: AdminForthMenuTarget) => typeof target === 'string' ? item.itemId === target @@ -160,14 +175,7 @@ class AdminForth implements IAdminForth { || (target.resourceId !== undefined && item.resourceId === target.resourceId) || (target.path !== undefined && item.path === target.path); - const resolvedMenu: AdminForthConfigMenuItem[] = menu.map((item) => ({ - ...item, - itemId: item.itemId || generateItemId(item), - children: item.children?.map((child) => ({ - ...child, - itemId: child.itemId || generateItemId(child), - })), - })); + const resolvedMenu: AdminForthConfigMenuItem[] = resolveMenuItemIds(menu); const usedItemIds = new Set(resolvedMenu.map((item) => item.itemId)); const providerContributions = await Promise.all( @@ -203,7 +211,17 @@ class AdminForth implements IAdminForth { } } - return resolvedMenu; + let transformedMenu = resolvedMenu; + + for (const provider of this.menuTransformProviders) { + transformedMenu = resolveMenuItemIds(await provider({ + adminUser, + adminforth: this, + menu: transformedMenu.map(cloneMenuItem), + })); + } + + return transformedMenu; } async refreshMenu(adminUser: AdminUser) { From 9f0e7abebe78c5288b7558012e823eec1f721090 Mon Sep 17 00:00:00 2001 From: Maksym Pipkun Date: Fri, 5 Jun 2026 15:20:37 +0300 Subject: [PATCH 2/8] refactor: remove unused menu transform provider and simplify menu resolution logic --- adminforth/index.ts | 36 +++++++++--------------------------- 1 file changed, 9 insertions(+), 27 deletions(-) diff --git a/adminforth/index.ts b/adminforth/index.ts index e5dd91d9d..1a78495d9 100644 --- a/adminforth/index.ts +++ b/adminforth/index.ts @@ -26,7 +26,6 @@ import { UpdateResourceRecordResult, DeleteResourceRecordResult, AdminForthMenuContributionProvider, - AdminForthMenuTransformProvider, } from './types/Back.js'; import { AdminForthFilterOperators, @@ -134,7 +133,6 @@ class AdminForth implements IAdminForth { pluginsById: Record = {}; private menuContributions: AdminForthMenuContribution[] = []; private menuContributionProviders: AdminForthMenuContributionProvider[] = []; - private menuTransformProviders: AdminForthMenuTransformProvider[] = []; configValidator: IConfigValidator; restApi: AdminForthRestAPI; @@ -148,10 +146,6 @@ class AdminForth implements IAdminForth { this.menuContributionProviders.push(provider); } - registerMenuTransformProvider(provider: AdminForthMenuTransformProvider): void { - this.menuTransformProviders.push(provider); - } - getMenuContributions(): AdminForthMenuContribution[] { return [...this.menuContributions]; } @@ -159,15 +153,6 @@ class AdminForth implements IAdminForth { async getMenuWithContributions(adminUser?: AdminUser, menu: AdminForthConfigMenuItem[] = this.config.menu): Promise { const generateItemId = (item: AdminForthConfigMenuItem) => md5hash(`menu-item-${item.label}-${item.resourceId || ''}-${item.path || ''}`); - const cloneMenuItem = (item: AdminForthConfigMenuItem): AdminForthConfigMenuItem => ({ - ...item, - children: item.children?.map(cloneMenuItem), - }); - const resolveMenuItemIds = (items: AdminForthConfigMenuItem[]): AdminForthConfigMenuItem[] => items.map((item) => ({ - ...item, - itemId: item.itemId || generateItemId(item), - children: item.children ? resolveMenuItemIds(item.children) : item.children, - })); const matchesTarget = (item: AdminForthConfigMenuItem, target: AdminForthMenuTarget) => typeof target === 'string' ? item.itemId === target @@ -175,7 +160,14 @@ class AdminForth implements IAdminForth { || (target.resourceId !== undefined && item.resourceId === target.resourceId) || (target.path !== undefined && item.path === target.path); - const resolvedMenu: AdminForthConfigMenuItem[] = resolveMenuItemIds(menu); + const resolvedMenu: AdminForthConfigMenuItem[] = menu.map((item) => ({ + ...item, + itemId: item.itemId || generateItemId(item), + children: item.children?.map((child) => ({ + ...child, + itemId: child.itemId || generateItemId(child), + })), + })); const usedItemIds = new Set(resolvedMenu.map((item) => item.itemId)); const providerContributions = await Promise.all( @@ -211,17 +203,7 @@ class AdminForth implements IAdminForth { } } - let transformedMenu = resolvedMenu; - - for (const provider of this.menuTransformProviders) { - transformedMenu = resolveMenuItemIds(await provider({ - adminUser, - adminforth: this, - menu: transformedMenu.map(cloneMenuItem), - })); - } - - return transformedMenu; + return resolvedMenu; } async refreshMenu(adminUser: AdminUser) { From 5a5cc98b25589054e136ea2facd73b04cd153b77 Mon Sep 17 00:00:00 2001 From: Maksym Pipkun Date: Fri, 5 Jun 2026 15:25:40 +0300 Subject: [PATCH 3/8] docs: clarify migration instructions and add note on ClickHouse unique constraints --- adminforth/commands/createApp/templates/readme.md.hbs | 3 ++- adminforth/commands/createApp/utils.js | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/adminforth/commands/createApp/templates/readme.md.hbs b/adminforth/commands/createApp/templates/readme.md.hbs index 0945c6696..f921fd4cd 100644 --- a/adminforth/commands/createApp/templates/readme.md.hbs +++ b/adminforth/commands/createApp/templates/readme.md.hbs @@ -15,9 +15,10 @@ The generated app will seed the default `adminforth` / `adminforth` user on firs {{/if}} {{#if prismaDbUrl}} -Migrate the database: +Create the initial migration and apply it to the database: ```bash +{{packageManagerRun}} makemigration{{packageManagerScriptArgSeparator}}--name init {{packageManagerRun}} migrate:local ``` {{/if}} diff --git a/adminforth/commands/createApp/utils.js b/adminforth/commands/createApp/utils.js index 03808ee38..f569b4d89 100644 --- a/adminforth/commands/createApp/utils.js +++ b/adminforth/commands/createApp/utils.js @@ -111,7 +111,9 @@ CREATE TABLE adminuser ( ) 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.`; } if (provider === 'mongodb') { From 809e6fdfbbfeaba5d42dc74a46489716a370f902 Mon Sep 17 00:00:00 2001 From: Maksym Pipkun Date: Thu, 11 Jun 2026 12:03:16 +0300 Subject: [PATCH 4/8] feat: skip Prisma prompt for non-empty databases --- adminforth/commands/createApp/utils.js | 80 ++++++++++++++++++++-- adminforth/dataConnectors/baseConnector.ts | 5 ++ adminforth/dataConnectors/clickhouse.ts | 19 ++++- adminforth/dataConnectors/mongo.ts | 11 ++- adminforth/dataConnectors/mysql.ts | 14 +++- adminforth/dataConnectors/postgres.ts | 15 +++- adminforth/dataConnectors/qdrant.ts | 8 +++ adminforth/dataConnectors/sqlite.ts | 15 +++- adminforth/types/Back.ts | 10 +++ 9 files changed, 168 insertions(+), 9 deletions(-) diff --git a/adminforth/commands/createApp/utils.js b/adminforth/commands/createApp/utils.js index f569b4d89..4a50408fd 100644 --- a/adminforth/commands/createApp/utils.js +++ b/adminforth/commands/createApp/utils.js @@ -40,6 +40,13 @@ 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) { @@ -58,7 +65,6 @@ export function parseArgumentsIntoOptions(rawArgs) { return { appName: args['--app-name'], db: args['--db'], - dbProvided: args['--db'] !== undefined, useNpm: args['--use-npm'], }; } @@ -113,7 +119,7 @@ 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.`; +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.`; } if (provider === 'mongodb') { @@ -164,8 +170,30 @@ export async function promptForMissingOptions(options) { db: options.db || answers.db, useNpm: options.useNpm || answers.useNpm, }; - resolvedOptions.existingDb = options.dbProvided || resolvedOptions.db !== DEFAULT_DB_URL; - resolvedOptions.includePrismaMigrations = !resolvedOptions.existingDb && isPrismaMigrationDbUrl(resolvedOptions.db); + resolvedOptions.databaseCleanState = { blockingObjects: [] }; + resolvedOptions.existingDb = false; + + await inspectDatabaseCleanState(resolvedOptions); + + if ( + resolvedOptions.includePrismaMigrations === undefined && + isPrismaMigrationDbUrl(resolvedOptions.db) && + !resolvedOptions.existingDb + ) { + const prismaAnswer = await inquirer.prompt([{ + type: 'select', + name: 'includePrismaMigrations', + message: 'Include Prisma migrations? >', + choices: [ + { name: 'Yes', value: true }, + { name: 'No', value: false }, + ], + default: true, + }]); + resolvedOptions.includePrismaMigrations = prismaAnswer.includePrismaMigrations; + } else { + resolvedOptions.includePrismaMigrations = Boolean(resolvedOptions.includePrismaMigrations) && !resolvedOptions.existingDb; + } return resolvedOptions; } @@ -234,6 +262,50 @@ function generateDbUrlForAfProd(connectionString) { return connectionString.toString(); } +function getSqliteInspectionUrl(dbUrl, appName) { + const connectionString = parseConnectionString(dbUrl); + const sqliteFile = connectionString.host; + const resolvedSqliteFile = path.isAbsolute(sqliteFile) + ? sqliteFile + : path.join(process.cwd(), appName, sqliteFile); + + if (!fs.existsSync(resolvedSqliteFile)) { + return null; + } + + return `sqlite://${resolvedSqliteFile}`; +} + +async function inspectDatabaseCleanState(options) { + const connectionString = parseConnectionString(options.db); + const provider = detectDbProvider(connectionString.protocol); + let inspectionDbUrl = connectionString.toString(); + + if (provider === 'sqlite') { + const sqliteInspectionUrl = getSqliteInspectionUrl(options.db, options.appName); + if (!sqliteInspectionUrl) { + options.databaseCleanState = { blockingObjects: [] }; + options.existingDb = false; + return; + } + inspectionDbUrl = sqliteInspectionUrl; + } + + const Connector = (await import(DATABASE_CONNECTOR_IMPORTS[provider])).default; + const connector = new Connector(); + await connector.setupClient(inspectionDbUrl); + + try { + options.databaseCleanState = await connector.isDatabaseEmpty(); + } finally { + if (typeof connector.close === 'function') { + await connector.close(); + } + } + + options.existingDb = options.databaseCleanState.blockingObjects.length > 0; +} + function initialChecks(options) { return [ { diff --git a/adminforth/dataConnectors/baseConnector.ts b/adminforth/dataConnectors/baseConnector.ts index e42dcd210..48006dce2 100644 --- a/adminforth/dataConnectors/baseConnector.ts +++ b/adminforth/dataConnectors/baseConnector.ts @@ -4,6 +4,7 @@ import { IAdminForthSort, IAdminForthSingleFilter, IAdminForthAndOrFilter, AdminForthConfig, IAggregationRule, IGroupByRule, IGroupByDateTrunc, + DatabaseCleanState, } from "../types/Back.js"; @@ -696,5 +697,9 @@ export default class AdminForthBaseConnector implements IAdminForthDataSourceCon throw new Error('getAllColumnsInTable() must be implemented in subclass'); } + async isDatabaseEmpty(): Promise { + throw new Error('isDatabaseEmpty() must be implemented in subclass'); + } + } diff --git a/adminforth/dataConnectors/clickhouse.ts b/adminforth/dataConnectors/clickhouse.ts index f714a5b06..3191be5b1 100644 --- a/adminforth/dataConnectors/clickhouse.ts +++ b/adminforth/dataConnectors/clickhouse.ts @@ -1,4 +1,4 @@ -import { IAdminForthDataSourceConnector, IAdminForthSingleFilter, IAdminForthAndOrFilter, AdminForthResource, AdminForthResourceColumn, IAggregationRule, IGroupByRule, IGroupByDateTrunc, IGroupByField } from '../types/Back.js'; +import { IAdminForthDataSourceConnector, IAdminForthSingleFilter, IAdminForthAndOrFilter, AdminForthResource, AdminForthResourceColumn, IAggregationRule, IGroupByRule, IGroupByDateTrunc, IGroupByField, DatabaseCleanState } from '../types/Back.js'; import AdminForthBaseConnector from './baseConnector.js'; import dayjs from 'dayjs'; import utc from 'dayjs/plugin/utc.js'; @@ -87,6 +87,23 @@ class ClickhouseConnector extends AdminForthBaseConnector implements IAdminForth sampleValue: sampleRow[col.name], })); } + + async isDatabaseEmpty(): Promise { + const res = await this.client.query({ + query: ` + SELECT database, name, engine + FROM system.tables + WHERE database = currentDatabase() + AND database NOT IN ('system', 'information_schema', 'INFORMATION_SCHEMA') + AND is_temporary = 0 + `, + format: 'JSONEachRow', + }); + const rows = await res.json(); + return { + blockingObjects: rows.map((row: any) => row.name), + }; + } async discoverFields(resource: AdminForthResource): Promise<{[key: string]: AdminForthResourceColumn}> { const tableName = resource.table; diff --git a/adminforth/dataConnectors/mongo.ts b/adminforth/dataConnectors/mongo.ts index 2ad7f0926..abaecf9f6 100644 --- a/adminforth/dataConnectors/mongo.ts +++ b/adminforth/dataConnectors/mongo.ts @@ -1,7 +1,7 @@ import dayjs from 'dayjs'; import { MongoClient } from 'mongodb'; import { Decimal128, Double } from 'bson'; -import { IAdminForthDataSourceConnector, IAdminForthSingleFilter, IAdminForthAndOrFilter, AdminForthResource, IAggregationRule, IGroupByRule, IGroupByDateTrunc, IGroupByField } from '../types/Back.js'; +import { IAdminForthDataSourceConnector, IAdminForthSingleFilter, IAdminForthAndOrFilter, AdminForthResource, IAggregationRule, IGroupByRule, IGroupByDateTrunc, IGroupByField, DatabaseCleanState } from '../types/Back.js'; import AdminForthBaseConnector from './baseConnector.js'; import { afLogger } from '../modules/logger.js'; import { AdminForthDataTypes, AdminForthFilterOperators, AdminForthSortDirections, } from '../types/Common.js'; @@ -166,6 +166,15 @@ class MongoConnector extends AdminForthBaseConnector implements IAdminForthDataS }; }); } + + async isDatabaseEmpty(): Promise { + const collections = await this.client.db().listCollections({}, { nameOnly: true }).toArray(); + return { + blockingObjects: collections + .filter((collection) => !collection.name.startsWith('system.')) + .map((collection) => collection.name), + }; + } async discoverFields(resource) { diff --git a/adminforth/dataConnectors/mysql.ts b/adminforth/dataConnectors/mysql.ts index 9150d02a6..f081766ea 100644 --- a/adminforth/dataConnectors/mysql.ts +++ b/adminforth/dataConnectors/mysql.ts @@ -1,6 +1,6 @@ import dayjs from 'dayjs'; import utc from 'dayjs/plugin/utc.js'; -import { AdminForthResource, IAdminForthSingleFilter, IAdminForthAndOrFilter, IAdminForthDataSourceConnector, AdminForthConfig, IAggregationRule, IGroupByRule, IGroupByDateTrunc, IGroupByField } from '../types/Back.js'; +import { AdminForthResource, IAdminForthSingleFilter, IAdminForthAndOrFilter, IAdminForthDataSourceConnector, AdminForthConfig, IAggregationRule, IGroupByRule, IGroupByDateTrunc, IGroupByField, DatabaseCleanState } from '../types/Back.js'; import { AdminForthDataTypes, AdminForthFilterOperators, AdminForthSortDirections, } from '../types/Common.js'; import AdminForthBaseConnector from './baseConnector.js'; import mysql from 'mysql2/promise'; @@ -77,6 +77,18 @@ class MysqlConnector extends AdminForthBaseConnector implements IAdminForthDataS })); } + async isDatabaseEmpty(): Promise { + const [rows] = await this.client.execute(` + SELECT table_schema, table_name + FROM information_schema.tables + WHERE table_schema = DATABASE() + AND table_type = 'BASE TABLE' + `); + return { + blockingObjects: rows.map((row: any) => row.TABLE_NAME ?? row.table_name), + }; + } + async hasMySQLCascadeFk(resource: AdminForthResource, config: AdminForthConfig): Promise { const cascadeColumn = resource.columns.find(c => c.foreignResource?.onDelete === 'cascade'); diff --git a/adminforth/dataConnectors/postgres.ts b/adminforth/dataConnectors/postgres.ts index d542dda49..a4fbebd91 100644 --- a/adminforth/dataConnectors/postgres.ts +++ b/adminforth/dataConnectors/postgres.ts @@ -1,5 +1,5 @@ import dayjs from 'dayjs'; -import { AdminForthResource, IAdminForthSingleFilter, IAdminForthAndOrFilter, IAdminForthDataSourceConnector, AdminForthConfig, IAggregationRule, IGroupByRule, IGroupByDateTrunc, IGroupByField } from '../types/Back.js'; +import { AdminForthResource, IAdminForthSingleFilter, IAdminForthAndOrFilter, IAdminForthDataSourceConnector, AdminForthConfig, IAggregationRule, IGroupByRule, IGroupByDateTrunc, IGroupByField, DatabaseCleanState } from '../types/Back.js'; import { AdminForthDataTypes, AdminForthFilterOperators, AdminForthSortDirections, } from '../types/Common.js'; import AdminForthBaseConnector from './baseConnector.js'; import pkg from 'pg'; @@ -89,6 +89,19 @@ class PostgresConnector extends AdminForthBaseConnector implements IAdminForthDa const sampleRow = sampleRowRes.rows[0] ?? {}; return res.rows.map(row => ({ name: row.column_name, sampleValue: sampleRow[row.column_name] })); } + + async isDatabaseEmpty(): Promise { + const res = await this.client.query(` + SELECT table_schema, table_name + FROM information_schema.tables + WHERE table_type = 'BASE TABLE' + AND table_schema NOT IN ('pg_catalog', 'information_schema') + AND table_schema NOT LIKE 'pg_toast%' + `); + return { + blockingObjects: res.rows.map((row) => `${row.table_schema}.${row.table_name}`), + }; + } async checkForeignResourceCascade(resource: AdminForthResource, config: AdminForthConfig, schema = 'public'): Promise { const cascadeColumn = resource.columns.find(c => c.foreignResource?.onDelete === 'cascade'); diff --git a/adminforth/dataConnectors/qdrant.ts b/adminforth/dataConnectors/qdrant.ts index a75d57c80..3e581fba6 100644 --- a/adminforth/dataConnectors/qdrant.ts +++ b/adminforth/dataConnectors/qdrant.ts @@ -9,6 +9,7 @@ import type { IAdminForthDataSourceConnector, IAdminForthSingleFilter, IAdminForthSort, + DatabaseCleanState, } from '../types/Back.js'; import { AdminForthDataTypes, @@ -95,6 +96,13 @@ class QdrantConnector extends AdminForthBaseConnector implements IAdminForthData return (collections.collections ?? []).map((collection: { name: string }) => collection.name); } + async isDatabaseEmpty(): Promise { + const collections = await this.client.getCollections(); + return { + blockingObjects: (collections.collections ?? []).map((collection: { name: string }) => collection.name), + }; + } + // discover fields async discoverFields(resource) { return resource.columns.filter((col) => !col.virtual).reduce((acc, col) => { diff --git a/adminforth/dataConnectors/sqlite.ts b/adminforth/dataConnectors/sqlite.ts index 9dd546763..4b928c743 100644 --- a/adminforth/dataConnectors/sqlite.ts +++ b/adminforth/dataConnectors/sqlite.ts @@ -1,5 +1,5 @@ import betterSqlite3 from 'better-sqlite3'; -import { IAdminForthDataSourceConnector, IAdminForthSingleFilter, IAdminForthAndOrFilter, AdminForthResource, AdminForthResourceColumn, AdminForthConfig, IAggregationRule, IGroupByRule, IGroupByDateTrunc, IGroupByField } from '../types/Back.js'; +import { IAdminForthDataSourceConnector, IAdminForthSingleFilter, IAdminForthAndOrFilter, AdminForthResource, AdminForthResourceColumn, AdminForthConfig, IAggregationRule, IGroupByRule, IGroupByDateTrunc, IGroupByField, DatabaseCleanState } from '../types/Back.js'; import AdminForthBaseConnector from './baseConnector.js'; import dayjs from 'dayjs'; import { AdminForthDataTypes, AdminForthFilterOperators, AdminForthSortDirections } from '../types/Common.js'; @@ -51,6 +51,19 @@ class SQLiteConnector extends AdminForthBaseConnector implements IAdminForthData sampleValue: sampleRow[col.name], })); } + + async isDatabaseEmpty(): Promise { + const stmt = this.client.prepare(` + SELECT name + FROM sqlite_schema + WHERE type = 'table' + AND name NOT LIKE 'sqlite_%' + `); + const rows = stmt.all(); + return { + blockingObjects: rows.map((row) => row.name), + }; + } async hasSQLiteCascadeFk(resource: AdminForthResource, config: AdminForthConfig): Promise { const cascadeColumn = resource.columns?.find(c => c.foreignResource?.onDelete === 'cascade'); diff --git a/adminforth/types/Back.ts b/adminforth/types/Back.ts index 1a43f547a..79246407f 100644 --- a/adminforth/types/Back.ts +++ b/adminforth/types/Back.ts @@ -276,6 +276,10 @@ export interface IAdminForthSort { direction: AdminForthSortDirections } +export type DatabaseCleanState = { + blockingObjects: string[]; +}; + export interface IAdminForthDataSourceConnector { client: any; @@ -297,6 +301,12 @@ export interface IAdminForthDataSourceConnector { * Function to get all columns in table. */ getAllColumnsInTable(tableName: string): Promise>; + + /** + * Function to check whether database has no user data. + */ + isDatabaseEmpty(): Promise; + /** * Optional. * You an redefine this function to define how one record should be fetched from database. From 464884aab9ddfdba1de118d5be35436f090a2bad Mon Sep 17 00:00:00 2001 From: Maksym Pipkun Date: Thu, 11 Jun 2026 12:27:06 +0300 Subject: [PATCH 5/8] refactor: remove unused SQLite inspection logic from database state check --- adminforth/commands/createApp/utils.js | 27 +------------------------- 1 file changed, 1 insertion(+), 26 deletions(-) diff --git a/adminforth/commands/createApp/utils.js b/adminforth/commands/createApp/utils.js index 4a50408fd..65a3d7a25 100644 --- a/adminforth/commands/createApp/utils.js +++ b/adminforth/commands/createApp/utils.js @@ -262,38 +262,13 @@ function generateDbUrlForAfProd(connectionString) { return connectionString.toString(); } -function getSqliteInspectionUrl(dbUrl, appName) { - const connectionString = parseConnectionString(dbUrl); - const sqliteFile = connectionString.host; - const resolvedSqliteFile = path.isAbsolute(sqliteFile) - ? sqliteFile - : path.join(process.cwd(), appName, sqliteFile); - - if (!fs.existsSync(resolvedSqliteFile)) { - return null; - } - - return `sqlite://${resolvedSqliteFile}`; -} - async function inspectDatabaseCleanState(options) { const connectionString = parseConnectionString(options.db); const provider = detectDbProvider(connectionString.protocol); - let inspectionDbUrl = connectionString.toString(); - - if (provider === 'sqlite') { - const sqliteInspectionUrl = getSqliteInspectionUrl(options.db, options.appName); - if (!sqliteInspectionUrl) { - options.databaseCleanState = { blockingObjects: [] }; - options.existingDb = false; - return; - } - inspectionDbUrl = sqliteInspectionUrl; - } const Connector = (await import(DATABASE_CONNECTOR_IMPORTS[provider])).default; const connector = new Connector(); - await connector.setupClient(inspectionDbUrl); + await connector.setupClient(connectionString.toString()); try { options.databaseCleanState = await connector.isDatabaseEmpty(); From 69e3adbab84a0ba691a89f39b5f649dfa574cf11 Mon Sep 17 00:00:00 2001 From: Maksym Pipkun Date: Thu, 11 Jun 2026 13:11:40 +0300 Subject: [PATCH 6/8] fix: update ClickhouseConnector close method to use async/await and adjust imports in utils module --- adminforth/dataConnectors/clickhouse.ts | 4 ++-- adminforth/modules/utils.ts | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/adminforth/dataConnectors/clickhouse.ts b/adminforth/dataConnectors/clickhouse.ts index 3191be5b1..abd9a1cc0 100644 --- a/adminforth/dataConnectors/clickhouse.ts +++ b/adminforth/dataConnectors/clickhouse.ts @@ -679,8 +679,8 @@ class ClickhouseConnector extends AdminForthBaseConnector implements IAdminForth return recordIds.length; } - close() { - this.client.disconnect(); + async close() { + await this.client.close(); } } diff --git a/adminforth/modules/utils.ts b/adminforth/modules/utils.ts index c14e76003..9ff4a5444 100644 --- a/adminforth/modules/utils.ts +++ b/adminforth/modules/utils.ts @@ -3,7 +3,8 @@ import { fileURLToPath } from 'url'; import fs from 'fs'; import Fuse from 'fuse.js'; import crypto from 'crypto'; -import { AdminForthConfig, AdminForthResource, AdminForthResourceColumnInputCommon,Filters, IAdminForth, Predicate } from '../index.js'; +import { Filters } from '../types/Back.js'; +import type { AdminForthResource, IAdminForth } from '../types/Back.js'; import { RateLimiterMemory, RateLimiterAbstract } from "rate-limiter-flexible"; // @ts-ignore-next-line From b9bc59d4d10784df223606b3f78e383bf8c0e8d0 Mon Sep 17 00:00:00 2001 From: Maksym Pipkun Date: Thu, 11 Jun 2026 13:33:43 +0300 Subject: [PATCH 7/8] refactor: update isDatabaseEmpty method to return boolean instead of DatabaseCleanState across data connectors --- adminforth/commands/createApp/utils.js | 24 +++++++++++++------ adminforth/dataConnectors/baseConnector.ts | 3 +-- adminforth/dataConnectors/clickhouse.ts | 14 ++++++----- adminforth/dataConnectors/mongo.ts | 10 +++----- adminforth/dataConnectors/mysql.ts | 9 ++++--- adminforth/dataConnectors/postgres.ts | 9 ++++--- adminforth/dataConnectors/qdrant.ts | 7 ++---- adminforth/dataConnectors/sqlite.ts | 14 +++++------ .../docs/tutorial/001-gettingStarted.md | 2 +- adminforth/types/Back.ts | 6 +---- 10 files changed, 47 insertions(+), 51 deletions(-) diff --git a/adminforth/commands/createApp/utils.js b/adminforth/commands/createApp/utils.js index 65a3d7a25..8c585398f 100644 --- a/adminforth/commands/createApp/utils.js +++ b/adminforth/commands/createApp/utils.js @@ -170,7 +170,6 @@ export async function promptForMissingOptions(options) { db: options.db || answers.db, useNpm: options.useNpm || answers.useNpm, }; - resolvedOptions.databaseCleanState = { blockingObjects: [] }; resolvedOptions.existingDb = false; await inspectDatabaseCleanState(resolvedOptions); @@ -268,17 +267,24 @@ async function inspectDatabaseCleanState(options) { const Connector = (await import(DATABASE_CONNECTOR_IMPORTS[provider])).default; const connector = new Connector(); - await connector.setupClient(connectionString.toString()); try { - options.databaseCleanState = await connector.isDatabaseEmpty(); + 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(); } } - - options.existingDb = options.databaseCleanState.blockingObjects.length > 0; } function initialChecks(options) { @@ -619,6 +625,7 @@ async function installDependenciesNpm(ctx, cwd) { function generateFinalInstructionsPnpm(skipPrismaSetup, options) { let instruction = '⏭️ Run the following commands to get started:\n'; + const provider = detectDbProvider(parseConnectionString(options.db).protocol); instruction += ` ${chalk.dim('// Go to the project directory')} ${chalk.dim('$')}${chalk.cyan(` cd ${options.appName}`)}\n`; @@ -630,7 +637,8 @@ function generateFinalInstructionsPnpm(skipPrismaSetup, options) { if (options.existingDb) instruction += ` - ${chalk.dim('// Create the adminuser table in your database using the README instructions')}\n`; + ${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')} @@ -644,6 +652,7 @@ function generateFinalInstructionsPnpm(skipPrismaSetup, options) { function generateFinalInstructionsNpm(skipPrismaSetup, options) { let instruction = '⏭️ Run the following commands to get started:\n'; + const provider = detectDbProvider(parseConnectionString(options.db).protocol); instruction += ` ${chalk.dim('// Go to the project directory')} ${chalk.dim('$')}${chalk.cyan(` cd ${options.appName}`)}\n`; @@ -655,7 +664,8 @@ function generateFinalInstructionsNpm(skipPrismaSetup, options) { if (options.existingDb) instruction += ` - ${chalk.dim('// Create the adminuser table in your database using the README instructions')}\n`; + ${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')} diff --git a/adminforth/dataConnectors/baseConnector.ts b/adminforth/dataConnectors/baseConnector.ts index 48006dce2..0130a42e8 100644 --- a/adminforth/dataConnectors/baseConnector.ts +++ b/adminforth/dataConnectors/baseConnector.ts @@ -4,7 +4,6 @@ import { IAdminForthSort, IAdminForthSingleFilter, IAdminForthAndOrFilter, AdminForthConfig, IAggregationRule, IGroupByRule, IGroupByDateTrunc, - DatabaseCleanState, } from "../types/Back.js"; @@ -697,7 +696,7 @@ export default class AdminForthBaseConnector implements IAdminForthDataSourceCon throw new Error('getAllColumnsInTable() must be implemented in subclass'); } - async isDatabaseEmpty(): Promise { + async isDatabaseEmpty(): Promise { throw new Error('isDatabaseEmpty() must be implemented in subclass'); } diff --git a/adminforth/dataConnectors/clickhouse.ts b/adminforth/dataConnectors/clickhouse.ts index abd9a1cc0..d16027952 100644 --- a/adminforth/dataConnectors/clickhouse.ts +++ b/adminforth/dataConnectors/clickhouse.ts @@ -1,4 +1,4 @@ -import { IAdminForthDataSourceConnector, IAdminForthSingleFilter, IAdminForthAndOrFilter, AdminForthResource, AdminForthResourceColumn, IAggregationRule, IGroupByRule, IGroupByDateTrunc, IGroupByField, DatabaseCleanState } from '../types/Back.js'; +import { IAdminForthDataSourceConnector, IAdminForthSingleFilter, IAdminForthAndOrFilter, AdminForthResource, AdminForthResourceColumn, IAggregationRule, IGroupByRule, IGroupByDateTrunc, IGroupByField } from '../types/Back.js'; import AdminForthBaseConnector from './baseConnector.js'; import dayjs from 'dayjs'; import utc from 'dayjs/plugin/utc.js'; @@ -88,21 +88,23 @@ class ClickhouseConnector extends AdminForthBaseConnector implements IAdminForth })); } - async isDatabaseEmpty(): Promise { + async isDatabaseEmpty(): Promise { const res = await this.client.query({ query: ` SELECT database, name, engine FROM system.tables - WHERE database = currentDatabase() + 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 { - blockingObjects: rows.map((row: any) => row.name), - }; + return rows.length === 0; } async discoverFields(resource: AdminForthResource): Promise<{[key: string]: AdminForthResourceColumn}> { diff --git a/adminforth/dataConnectors/mongo.ts b/adminforth/dataConnectors/mongo.ts index abaecf9f6..6d6e8a84e 100644 --- a/adminforth/dataConnectors/mongo.ts +++ b/adminforth/dataConnectors/mongo.ts @@ -1,7 +1,7 @@ import dayjs from 'dayjs'; import { MongoClient } from 'mongodb'; import { Decimal128, Double } from 'bson'; -import { IAdminForthDataSourceConnector, IAdminForthSingleFilter, IAdminForthAndOrFilter, AdminForthResource, IAggregationRule, IGroupByRule, IGroupByDateTrunc, IGroupByField, DatabaseCleanState } from '../types/Back.js'; +import { IAdminForthDataSourceConnector, IAdminForthSingleFilter, IAdminForthAndOrFilter, AdminForthResource, IAggregationRule, IGroupByRule, IGroupByDateTrunc, IGroupByField } from '../types/Back.js'; import AdminForthBaseConnector from './baseConnector.js'; import { afLogger } from '../modules/logger.js'; import { AdminForthDataTypes, AdminForthFilterOperators, AdminForthSortDirections, } from '../types/Common.js'; @@ -167,13 +167,9 @@ class MongoConnector extends AdminForthBaseConnector implements IAdminForthDataS }); } - async isDatabaseEmpty(): Promise { + async isDatabaseEmpty(): Promise { const collections = await this.client.db().listCollections({}, { nameOnly: true }).toArray(); - return { - blockingObjects: collections - .filter((collection) => !collection.name.startsWith('system.')) - .map((collection) => collection.name), - }; + return collections.every((collection) => collection.name.startsWith('system.')); } diff --git a/adminforth/dataConnectors/mysql.ts b/adminforth/dataConnectors/mysql.ts index f081766ea..8733a589c 100644 --- a/adminforth/dataConnectors/mysql.ts +++ b/adminforth/dataConnectors/mysql.ts @@ -1,6 +1,6 @@ import dayjs from 'dayjs'; import utc from 'dayjs/plugin/utc.js'; -import { AdminForthResource, IAdminForthSingleFilter, IAdminForthAndOrFilter, IAdminForthDataSourceConnector, AdminForthConfig, IAggregationRule, IGroupByRule, IGroupByDateTrunc, IGroupByField, DatabaseCleanState } from '../types/Back.js'; +import { AdminForthResource, IAdminForthSingleFilter, IAdminForthAndOrFilter, IAdminForthDataSourceConnector, AdminForthConfig, IAggregationRule, IGroupByRule, IGroupByDateTrunc, IGroupByField } from '../types/Back.js'; import { AdminForthDataTypes, AdminForthFilterOperators, AdminForthSortDirections, } from '../types/Common.js'; import AdminForthBaseConnector from './baseConnector.js'; import mysql from 'mysql2/promise'; @@ -77,16 +77,15 @@ class MysqlConnector extends AdminForthBaseConnector implements IAdminForthDataS })); } - async isDatabaseEmpty(): Promise { + async isDatabaseEmpty(): Promise { 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 { - blockingObjects: rows.map((row: any) => row.TABLE_NAME ?? row.table_name), - }; + return rows.length === 0; } async hasMySQLCascadeFk(resource: AdminForthResource, config: AdminForthConfig): Promise { diff --git a/adminforth/dataConnectors/postgres.ts b/adminforth/dataConnectors/postgres.ts index a4fbebd91..edbbb7227 100644 --- a/adminforth/dataConnectors/postgres.ts +++ b/adminforth/dataConnectors/postgres.ts @@ -1,5 +1,5 @@ import dayjs from 'dayjs'; -import { AdminForthResource, IAdminForthSingleFilter, IAdminForthAndOrFilter, IAdminForthDataSourceConnector, AdminForthConfig, IAggregationRule, IGroupByRule, IGroupByDateTrunc, IGroupByField, DatabaseCleanState } from '../types/Back.js'; +import { AdminForthResource, IAdminForthSingleFilter, IAdminForthAndOrFilter, IAdminForthDataSourceConnector, AdminForthConfig, IAggregationRule, IGroupByRule, IGroupByDateTrunc, IGroupByField } from '../types/Back.js'; import { AdminForthDataTypes, AdminForthFilterOperators, AdminForthSortDirections, } from '../types/Common.js'; import AdminForthBaseConnector from './baseConnector.js'; import pkg from 'pg'; @@ -90,17 +90,16 @@ class PostgresConnector extends AdminForthBaseConnector implements IAdminForthDa return res.rows.map(row => ({ name: row.column_name, sampleValue: sampleRow[row.column_name] })); } - async isDatabaseEmpty(): Promise { + async isDatabaseEmpty(): Promise { const res = await this.client.query(` SELECT table_schema, table_name FROM information_schema.tables WHERE table_type = 'BASE TABLE' AND table_schema NOT IN ('pg_catalog', 'information_schema') AND table_schema NOT LIKE 'pg_toast%' + LIMIT 1 `); - return { - blockingObjects: res.rows.map((row) => `${row.table_schema}.${row.table_name}`), - }; + return res.rows.length === 0; } async checkForeignResourceCascade(resource: AdminForthResource, config: AdminForthConfig, schema = 'public'): Promise { diff --git a/adminforth/dataConnectors/qdrant.ts b/adminforth/dataConnectors/qdrant.ts index 3e581fba6..35d4795a5 100644 --- a/adminforth/dataConnectors/qdrant.ts +++ b/adminforth/dataConnectors/qdrant.ts @@ -9,7 +9,6 @@ import type { IAdminForthDataSourceConnector, IAdminForthSingleFilter, IAdminForthSort, - DatabaseCleanState, } from '../types/Back.js'; import { AdminForthDataTypes, @@ -96,11 +95,9 @@ class QdrantConnector extends AdminForthBaseConnector implements IAdminForthData return (collections.collections ?? []).map((collection: { name: string }) => collection.name); } - async isDatabaseEmpty(): Promise { + async isDatabaseEmpty(): Promise { const collections = await this.client.getCollections(); - return { - blockingObjects: (collections.collections ?? []).map((collection: { name: string }) => collection.name), - }; + return (collections.collections ?? []).length === 0; } // discover fields diff --git a/adminforth/dataConnectors/sqlite.ts b/adminforth/dataConnectors/sqlite.ts index 4b928c743..b3753eff7 100644 --- a/adminforth/dataConnectors/sqlite.ts +++ b/adminforth/dataConnectors/sqlite.ts @@ -1,5 +1,5 @@ import betterSqlite3 from 'better-sqlite3'; -import { IAdminForthDataSourceConnector, IAdminForthSingleFilter, IAdminForthAndOrFilter, AdminForthResource, AdminForthResourceColumn, AdminForthConfig, IAggregationRule, IGroupByRule, IGroupByDateTrunc, IGroupByField, DatabaseCleanState } from '../types/Back.js'; +import { IAdminForthDataSourceConnector, IAdminForthSingleFilter, IAdminForthAndOrFilter, AdminForthResource, AdminForthResourceColumn, AdminForthConfig, IAggregationRule, IGroupByRule, IGroupByDateTrunc, IGroupByField } from '../types/Back.js'; import AdminForthBaseConnector from './baseConnector.js'; import dayjs from 'dayjs'; import { AdminForthDataTypes, AdminForthFilterOperators, AdminForthSortDirections } from '../types/Common.js'; @@ -52,17 +52,15 @@ class SQLiteConnector extends AdminForthBaseConnector implements IAdminForthData })); } - async isDatabaseEmpty(): Promise { + async isDatabaseEmpty(): Promise { const stmt = this.client.prepare(` SELECT name FROM sqlite_schema WHERE type = 'table' AND name NOT LIKE 'sqlite_%' + LIMIT 1 `); - const rows = stmt.all(); - return { - blockingObjects: rows.map((row) => row.name), - }; + return !stmt.get(); } async hasSQLiteCascadeFk(resource: AdminForthResource, config: AdminForthConfig): Promise { @@ -553,8 +551,8 @@ class SQLiteConnector extends AdminForthBaseConnector implements IAdminForthData return res.changes ?? 0; } - close() { - this.client.close(); + async close(): Promise { + this.client?.close(); } } diff --git a/adminforth/documentation/docs/tutorial/001-gettingStarted.md b/adminforth/documentation/docs/tutorial/001-gettingStarted.md index 7f55aa9f8..2af4280a1 100644 --- a/adminforth/documentation/docs/tutorial/001-gettingStarted.md +++ b/adminforth/documentation/docs/tutorial/001-gettingStarted.md @@ -21,7 +21,7 @@ nvm use 20 ## Creating an AdminForth Project -The recommended way to get started with AdminForth is via the **`create-app`** CLI, which scaffolds a basic fully functional back-office application. Apart from boilerplate, it creates one resource for users management. +The recommended way to get started with AdminForth is via the **`create-app`** CLI, which scaffolds a basic fully functional back-office application. Apart from boilerplate, it creates one resource for user management. There are two common setup paths: diff --git a/adminforth/types/Back.ts b/adminforth/types/Back.ts index 79246407f..0589c8b8f 100644 --- a/adminforth/types/Back.ts +++ b/adminforth/types/Back.ts @@ -276,10 +276,6 @@ export interface IAdminForthSort { direction: AdminForthSortDirections } -export type DatabaseCleanState = { - blockingObjects: string[]; -}; - export interface IAdminForthDataSourceConnector { client: any; @@ -305,7 +301,7 @@ export interface IAdminForthDataSourceConnector { /** * Function to check whether database has no user data. */ - isDatabaseEmpty(): Promise; + isDatabaseEmpty(): Promise; /** * Optional. From 612c2a17ed6ae700e781fcfcf44f05dc2b31034f Mon Sep 17 00:00:00 2001 From: Maksym Pipkun Date: Fri, 12 Jun 2026 11:12:06 +0300 Subject: [PATCH 8/8] docs: update README and utils to clarify admin user table schema instructions --- .../createApp/templates/readme.md.hbs | 2 +- adminforth/commands/createApp/utils.js | 25 +++++++++++-------- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/adminforth/commands/createApp/templates/readme.md.hbs b/adminforth/commands/createApp/templates/readme.md.hbs index f921fd4cd..2b1c6d495 100644 --- a/adminforth/commands/createApp/templates/readme.md.hbs +++ b/adminforth/commands/createApp/templates/readme.md.hbs @@ -7,7 +7,7 @@ Install dependencies: ``` {{#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: +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. The schema below is only an example: {{{adminUserTableInstructions}}} diff --git a/adminforth/commands/createApp/utils.js b/adminforth/commands/createApp/utils.js index 8c585398f..9f1105a00 100644 --- a/adminforth/commands/createApp/utils.js +++ b/adminforth/commands/createApp/utils.js @@ -47,6 +47,7 @@ const DATABASE_CONNECTOR_IMPORTS = { mongodb: '../../dist/dataConnectors/mongo.js', clickhouse: '../../dist/dataConnectors/clickhouse.js', }; +const ADMINUSER_TABLE_EXAMPLE_NOTE = 'This is only an example schema. We recommend using your favorite migration tool to create and evolve this table, and adding database indexes or constraints only when they match your project requirements.'; export function parseArgumentsIntoOptions(rawArgs) { @@ -74,36 +75,42 @@ function generateAdminUserTableInstructions(provider) { return `\`\`\`sql CREATE TABLE adminuser ( id TEXT PRIMARY KEY, - email TEXT NOT NULL UNIQUE, + email TEXT NOT NULL, password_hash TEXT NOT NULL, role TEXT NOT NULL, created_at TIMESTAMP NOT NULL ); -\`\`\``; +\`\`\` + +${ADMINUSER_TABLE_EXAMPLE_NOTE}`; } if (provider === 'mysql') { return `\`\`\`sql CREATE TABLE adminuser ( id VARCHAR(191) PRIMARY KEY, - email VARCHAR(191) NOT NULL UNIQUE, + email VARCHAR(191) NOT NULL, password_hash TEXT NOT NULL, role VARCHAR(191) NOT NULL, created_at DATETIME NOT NULL ); -\`\`\``; +\`\`\` + +${ADMINUSER_TABLE_EXAMPLE_NOTE}`; } if (provider === 'sqlite') { return `\`\`\`sql CREATE TABLE adminuser ( id TEXT PRIMARY KEY, - email TEXT NOT NULL UNIQUE, + email TEXT NOT NULL, password_hash TEXT NOT NULL, role TEXT NOT NULL, created_at DATETIME NOT NULL ); -\`\`\``; +\`\`\` + +${ADMINUSER_TABLE_EXAMPLE_NOTE}`; } if (provider === 'clickhouse') { @@ -119,11 +126,7 @@ 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.`; - } - - 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.'; +${ADMINUSER_TABLE_EXAMPLE_NOTE}`; } return null;