Skip to content

feat(migrations): support new .data/hub/migrations #42

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Jan 17, 2025
Merged
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
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
"nuxt-hub": "./src/index.mjs"
},
"exports": {
".": "./src/index.mjs",
"./internal": "./src/internal.mjs"
},
"files": [
Expand Down
4 changes: 4 additions & 0 deletions src/commands/database/migrations.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
10 changes: 7 additions & 3 deletions src/commands/database/migrations/create.mjs
Original file line number Diff line number Diff line change
@@ -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: {
Expand All @@ -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}\``)
}
});
8 changes: 5 additions & 3 deletions src/commands/database/migrations/list.mjs
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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 <name>`.')
return process.exit(0)
}

Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion src/commands/database/migrations/mark-all-applied.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>`.')
return process.exit(0)
}

Expand Down
43 changes: 35 additions & 8 deletions src/commands/deploy.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand All @@ -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`)

Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/commands/preview.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions src/internal.mjs
Original file line number Diff line number Diff line change
@@ -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';
27 changes: 17 additions & 10 deletions src/utils/database.mjs
Original file line number Diff line number Diff line change
@@ -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 })
Expand Down Expand Up @@ -40,20 +40,26 @@ 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}
*/
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
Expand All @@ -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<Array<{ id: number, name: string, applied_at: string }>>}
*/
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 []
}
Expand Down
6 changes: 3 additions & 3 deletions src/utils/wrangler.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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'] }]
Expand Down
Loading