diff --git a/package.json b/package.json index 789969c..32eae3f 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,6 @@ "nuxt-hub": "./src/index.mjs" }, "exports": { - ".": "./src/index.mjs", "./internal": "./src/internal.mjs" }, "files": [ diff --git a/src/commands/database/migrations.mjs b/src/commands/database/migrations.mjs index e07872f..077e9f3 100644 --- a/src/commands/database/migrations.mjs +++ b/src/commands/database/migrations.mjs @@ -2,12 +2,16 @@ import { defineCommand } from 'citty' import create from './migrations/create.mjs' import list from './migrations/list.mjs' import markAllApplied from './migrations/mark-all-applied.mjs' +import { consola } from 'consola' export default defineCommand({ meta: { name: 'migrations', description: 'Database migrations commands.', }, + async setup() { + consola.info('Make sure to run `npx nuxi prepare` before running this command if some migrations are missing.') + }, subCommands: { create, list, diff --git a/src/commands/database/migrations/create.mjs b/src/commands/database/migrations/create.mjs index dcc0b4b..542b745 100644 --- a/src/commands/database/migrations/create.mjs +++ b/src/commands/database/migrations/create.mjs @@ -1,6 +1,8 @@ import { defineCommand } from 'citty' import { consola } from 'consola' -import { useMigrationsStorage, getNextMigrationNumber } from '../../../utils/database.mjs' +import { writeFile, mkdir } from 'node:fs/promises' +import { join } from 'pathe' +import { getNextMigrationNumber } from '../../../utils/database.mjs' export default defineCommand({ meta: { @@ -25,8 +27,10 @@ export default defineCommand({ .replace(/-+/g, '-') // replace multiple dashes with a single dash || 'migration' const migrationName = `${nextMigrationNumber}_${name}.sql` - await useMigrationsStorage().set(migrationName, `-- Migration number: ${nextMigrationNumber} \t ${new Date().toISOString()}\n`) + const userMigrationsDir = join(process.cwd(), 'server/database/migrations') + await mkdir(userMigrationsDir, { recursive: true }) + await writeFile(join(userMigrationsDir, migrationName), `-- Migration number: ${nextMigrationNumber} \t ${new Date().toISOString()}\n`) - consola.success(`Created migration file \`server/migrations/${migrationName}\``) + consola.success(`Created migration file \`server/database/migrations/${migrationName}\``) } }); diff --git a/src/commands/database/migrations/list.mjs b/src/commands/database/migrations/list.mjs index d4a2f8f..b0e3b15 100644 --- a/src/commands/database/migrations/list.mjs +++ b/src/commands/database/migrations/list.mjs @@ -1,8 +1,9 @@ import ora from 'ora' import { defineCommand, runCommand } from 'citty' +import { join, relative } from 'pathe' import { consola } from 'consola' import { colors } from 'consola/utils' -import { fetchUser, fetchProject, projectPath, getProjectEnv, fetchRemoteMigrations, getMigrationFiles } from '../../../utils/index.mjs' +import { fetchUser, fetchProject, projectPath, getProjectEnv, fetchRemoteMigrations, getMigrationsDir, getMigrationFiles } from '../../../utils/index.mjs' import link from '../../link.mjs' import login from '../../login.mjs' @@ -37,7 +38,7 @@ export default defineCommand({ const total = localMigrations.length if (total === 0) { - consola.info('No migrations found in `./server/database/migrations`, please create one first.') + consola.info('No migrations found, please create one first with `nuxthub database migrations create `.') return process.exit(0) } @@ -103,10 +104,11 @@ export default defineCommand({ const formattedPendingMigrations = pendingMigrations.map(fileName => ({ id: null, name: fileName, applied_at: null })) const migrations = remoteMigrations.concat(formattedPendingMigrations) + const migrationsDir = relative(process.cwd(), getMigrationsDir()) for (const { name, applied_at } of migrations) { const appliedAt = applied_at ? new Date(applied_at).toLocaleString() : 'Pending' const color = applied_at ? colors.green : colors.yellow - consola.log(`${color(applied_at ? '✅' : '🕒')} \`./server/database/migrations/${name}.sql\` ${colors.gray(appliedAt)}`) + consola.log(`${color(applied_at ? '✅' : '🕒')} \`${join(migrationsDir, name)}.sql\` ${colors.gray(appliedAt)}`) } process.exit(0) diff --git a/src/commands/database/migrations/mark-all-applied.mjs b/src/commands/database/migrations/mark-all-applied.mjs index 206ef87..8d0c973 100644 --- a/src/commands/database/migrations/mark-all-applied.mjs +++ b/src/commands/database/migrations/mark-all-applied.mjs @@ -39,7 +39,7 @@ export default defineCommand({ const total = localMigrations.length if (total === 0) { - consola.info('No migrations found in `./server/database/migrations`, please create one first.') + consola.info('No migrations found, please create one first with `nuxthub database migrations create `.') return process.exit(0) } diff --git a/src/commands/deploy.mjs b/src/commands/deploy.mjs index 9917ec9..8eeb20c 100644 --- a/src/commands/deploy.mjs +++ b/src/commands/deploy.mjs @@ -221,18 +221,18 @@ export default defineCommand({ spinner.succeed(`Deployed ${colors.blueBright(linkedProject.slug)} to ${deployEnvColored}...`) - // #region Database migrations if (config.database) { - const remoteMigrationsSpinner = ora(`Retrieving migrations on ${deployEnvColored} for ${colors.blueBright(linkedProject.slug)}...`).start() + // #region Database migrations + const remoteMigrationsSpinner = ora(`Retrieving database migrations on ${deployEnvColored} for ${colors.blueBright(linkedProject.slug)}...`).start() await createMigrationsTable({ env: deployEnv }) const remoteMigrations = await fetchRemoteMigrations({ env: deployEnv }).catch((error) => { - remoteMigrationsSpinner.fail(`Could not retrieve migrations on ${deployEnvColored} for ${colors.blueBright(linkedProject.slug)}.`) + remoteMigrationsSpinner.fail(`Could not retrieve database migrations on ${deployEnvColored} for ${colors.blueBright(linkedProject.slug)}.`) consola.error(error.message) process.exit(1) }) - remoteMigrationsSpinner.succeed(`Found ${remoteMigrations.length} migration${remoteMigrations.length === 1 ? '' : 's'} on ${colors.blueBright(linkedProject.slug)}`) + remoteMigrationsSpinner.succeed(`Found ${remoteMigrations.length} database migration${remoteMigrations.length === 1 ? '' : 's'} on ${colors.blueBright(linkedProject.slug)}`) const localMigrations = fileKeys .filter(fileKey => { @@ -246,10 +246,10 @@ export default defineCommand({ .replace('.sql', '') }) const pendingMigrations = localMigrations.filter(localName => !remoteMigrations.find(({ name }) => name === localName)) - if (!pendingMigrations.length) consola.info('No pending migrations to apply.') + if (!pendingMigrations.length) consola.info('No pending database migrations to apply.') for (const migration of pendingMigrations) { - const migrationSpinner = ora(`Applying migration ${colors.blueBright(migration)}...`).start() + const migrationSpinner = ora(`Applying database migration ${colors.blueBright(migration)}...`).start() let query = await storage.getItem(`database/migrations/${migration}.sql`) @@ -261,14 +261,41 @@ export default defineCommand({ try { await queryDatabase({ env: deployEnv, query }) } catch (error) { - migrationSpinner.fail(`Failed to apply migration ${colors.blueBright(migration)}.`) + migrationSpinner.fail(`Failed to apply database migration ${colors.blueBright(migration)}.`) if (error) consola.error(error.response?._data?.message || error.message) break } - migrationSpinner.succeed(`Applied migration ${colors.blueBright(migration)}.`) + migrationSpinner.succeed(`Applied database migration ${colors.blueBright(migration)}.`) } + // #endregion + // #region Database queries + const localQueries = fileKeys + .filter(fileKey => fileKey.startsWith('database:queries:') && fileKey.endsWith('.sql')) + .map(fileKey => fileKey.replace('database:queries:', '').replace('.sql', '')) + + + if (localQueries.length) { + const querySpinner = ora(`Applying ${colors.blueBright(formatNumber(localQueries.length))} database queries...`).start() + for (const queryName of localQueries) { + const query = await storage.getItem(`database/queries/${queryName}.sql`) + + try { + await queryDatabase({ env: deployEnv, query }) + } catch (error) { + querySpinner.fail(`Failed to apply database query ${colors.blueBright(queryName)}.`) + + if (error) consola.error(error.response?._data?.message || error.message) + break + } + + } + querySpinner.succeed(`Applied ${colors.blueBright(formatNumber(localQueries.length))} database queries.`) + } else { + consola.info('No pending database queries to apply.') + } + // #endregion } // Check DNS & ready url for first deployment diff --git a/src/commands/preview.mjs b/src/commands/preview.mjs index ab3ea0c..984f06f 100644 --- a/src/commands/preview.mjs +++ b/src/commands/preview.mjs @@ -42,7 +42,7 @@ export default defineCommand({ fileSideEffects.push(devVarsPath) } - const wrangler = generateWrangler(hubConfig, nitroConfig) + const wrangler = generateWrangler(hubConfig, { preset: nitroConfig.preset }) const wranglerPath = join(distDir, 'wrangler.toml') consola.info(`Generating \`${relative(process.cwd(), wranglerPath)}\`...`) fileSideEffects.push(wranglerPath) diff --git a/src/internal.mjs b/src/internal.mjs index d1f8626..7c174c0 100644 --- a/src/internal.mjs +++ b/src/internal.mjs @@ -1 +1,3 @@ export { getStorage, getPathsToDeploy, getPublicFiles, uploadAssetsToCloudflare, isMetaPath, getFile, isServerPath } from './utils/deploy.mjs'; +export { CreateDatabaseMigrationsTableQuery, ListDatabaseMigrationsQuery } from './utils/database.mjs'; +export { generateWrangler } from './utils/wrangler.mjs'; diff --git a/src/utils/database.mjs b/src/utils/database.mjs index baf572b..ce520c2 100644 --- a/src/utils/database.mjs +++ b/src/utils/database.mjs @@ -1,11 +1,11 @@ import { consola } from 'consola' import { join } from 'pathe' +import { existsSync } from 'node:fs' import { createStorage } from 'unstorage' import fsDriver from 'unstorage/drivers/fs' import { $api } from './data.mjs' import { $fetch } from 'ofetch' - export async function queryDatabase({ env, url, token, query, params }) { if (url) { return queryRemoteDatabase({ url, token, query, params }) @@ -40,6 +40,14 @@ export async function queryRemoteDatabase({ url, token, query, params }) { }) } +let _migrationsDir +export function getMigrationsDir() { + if (!_migrationsDir) { + const cwd = process.cwd() + _migrationsDir = existsSync(join(cwd, '.data/hub/database/migrations')) ? join(cwd, '.data/hub/database/migrations') : join(cwd, 'server/database/migrations') + } + return _migrationsDir +} /** * @type {import('unstorage').Storage} @@ -47,13 +55,11 @@ export async function queryRemoteDatabase({ url, token, query, params }) { let _storage export function useMigrationsStorage() { if (!_storage) { - const cwd = process.cwd() - const migrationsDir = join(cwd, 'server/database/migrations') _storage = createStorage({ driver: fsDriver({ - base: migrationsDir, + base: getMigrationsDir(), ignore: ['.DS_Store'] - }), + }) }) } return _storage @@ -72,24 +78,25 @@ export async function getNextMigrationNumber() { .sort((a, b) => a - b) .pop() ?? 0 - return (lastSequentialMigrationNumber + 1).toString().padStart(4, '0') + return (lastSequentialMigrationNumber + 1).toString().padStart(4, '0') } -const CreateMigrationsTableQuery = `CREATE TABLE IF NOT EXISTS _hub_migrations ( +export const CreateDatabaseMigrationsTableQuery = `CREATE TABLE IF NOT EXISTS _hub_migrations ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE, applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL );` +export const ListDatabaseMigrationsQuery = 'select "id", "name", "applied_at" from "_hub_migrations" order by "_hub_migrations"."id"' + export async function createMigrationsTable({ env, url, token }) { - await queryDatabase({ env, url, token, query: CreateMigrationsTableQuery }) + await queryDatabase({ env, url, token, query: CreateDatabaseMigrationsTableQuery }) } /** * @type {Promise>} */ export async function fetchRemoteMigrations({ env, url, token }) { - const query = 'select "id", "name", "applied_at" from "_hub_migrations" order by "_hub_migrations"."id"' - const res = await queryDatabase({ env, url, token, query }).catch((error) => { + const res = await queryDatabase({ env, url, token, query: ListDatabaseMigrationsQuery }).catch((error) => { if (error.response?._data?.message.includes('no such table')) { return [] } diff --git a/src/utils/wrangler.mjs b/src/utils/wrangler.mjs index 591e417..afeffba 100644 --- a/src/utils/wrangler.mjs +++ b/src/utils/wrangler.mjs @@ -2,15 +2,15 @@ import { stringifyTOML } from 'confbox' // Taken from https://github.com/nuxt-hub/core/blob/main/src/utils/wrangler.ts // With some modifications to fit the needs of this project -export function generateWrangler(hub, nitro) { +export function generateWrangler(hub, { preset } = {}) { const wrangler = {} // Workers specific settings - if (nitro.preset === 'cloudflare-module' || nitro.preset === 'cloudflare-durable') { + if (preset === 'cloudflare-module' || preset === 'cloudflare-durable') { wrangler.name = 'nuxthub-local-preview' wrangler.main = './server/index.mjs' wrangler.assets = { directory: './public/', binding: 'ASSETS' } - if (nitro.preset === 'cloudflare-durable') { + if (preset === 'cloudflare-durable') { wrangler.durable_objects ||= {} wrangler.durable_objects.bindings = [{ name: '$DurableObject', class_name: '$DurableObject' }] wrangler.migrations = [{ tag: 'v1', new_classes: ['$DurableObject'] }]