Skip to content

Commit

Permalink
feat(database): add support for multiple database migrations director…
Browse files Browse the repository at this point in the history
…ies (#423)
  • Loading branch information
atinux authored Jan 17, 2025
1 parent 859a5f8 commit f845211
Show file tree
Hide file tree
Showing 17 changed files with 350 additions and 91 deletions.
99 changes: 83 additions & 16 deletions docs/content/1.docs/2.features/database.md
Original file line number Diff line number Diff line change
Expand Up @@ -241,11 +241,63 @@ This method can have poorer performance (prepared statements can be reused in so

## Database Migrations

Database migrations provide version control for your database schema. They track changes and ensure consistent schema evolution across all environments through incremental updates.
Database migrations provide version control for your database schema. They track changes and ensure consistent schema evolution across all environments through incremental updates. NuxtHub supports SQL migration files (`.sql`).

### Migrations Directories

NuxtHub scans the `server/database/migrations` directory for migrations **for each [Nuxt layer](https://nuxt.com/docs/getting-started/layers)**.

If you need to scan additional migrations directories, you can specify them in your `nuxt.config.ts` file.

```ts [nuxt.config.ts]
export default defineNuxtConfig({
hub: {
// Array of additional migration directories to scan
databaseMigrationsDirs: [
'my-module/db-migrations/'
]
}
})
```
::note
NuxtHub will scan both `server/database/migrations` and `my-module/db-migrations` directories for `.sql` files.
::

If you want more control to the migrations directories or you are working on a [Nuxt module](https://nuxt.com/docs/guide/going-further/modules), you can use the `hub:database:migrations:dirs` hook:

::code-group
```ts [modules/auth/index.ts]
import { createResolver, defineNuxtModule } from 'nuxt/kit'

export default defineNuxtModule({
meta: {
name: 'my-auth-module'
},
setup(options, nuxt) {
const { resolve } = createResolver(import.meta.url)

nuxt.hook('hub:database:migrations:dirs', (dirs) => {
dirs.push(resolve('db-migrations'))
})
}
})
```
```sql [modules/auth/db-migrations/0001_create-users.sql]
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL
);
```
::

::tip
All migrations files are copied to the `.data/hub/database/migrations` directory when you run Nuxt. This consolidated view helps you track all migrations and enables you to use `npx nuxthub database migrations <command>` commands.
::

### Automatic Application

SQL migrations in `server/database/migrations/*.sql` are automatically applied when you:
All `.sql` files in the database migrations directories are automatically applied when you:
- Start the development server (`npx nuxt dev` or [`npx nuxt dev --remote`](/docs/getting-started/remote-storage))
- Preview builds locally ([`npx nuxthub preview`](/changelog/nuxthub-preview))
- Deploy via [`npx nuxthub deploy`](/docs/getting-started/deploy#nuxthub-cli) or [Cloudflare Pages CI](/docs/getting-started/deploy#cloudflare-pages-ci)
Expand Down Expand Up @@ -275,7 +327,6 @@ Migration files are created in `server/database/migrations/`.

After creation, add your SQL queries to modify the database schema.


::note{to="/docs/recipes/drizzle#npm-run-dbgenerate"}
With [Drizzle ORM](/docs/recipes/drizzle), migrations are automatically created when you run `npx drizzle-kit generate`.
::
Expand Down Expand Up @@ -327,23 +378,39 @@ NUXT_HUB_PROJECT_URL=<url> NUXT_HUB_PROJECT_SECRET_KEY=<secret> nuxthub database
```
::

### Migrating from Drizzle ORM
### Post-Migration Queries

Since NuxtHub doesn't recognize previously applied Drizzle ORM migrations (stored in `__drizzle_migrations`), it will attempt to rerun all migrations in `server/database/migrations/*.sql`. To prevent this:
::important
This feature is for advanced use cases. As the queries are run after the migrations process (see [Automatic Application](#automatic-application)), you want to make sure your queries are idempotent.
::

1. Mark existing migrations as applied in each environment:
Sometimes you need to run additional queries after migrations are applied without tracking them in the migrations table.

```bash [Terminal]
# Local environment
npx nuxthub database migrations mark-all-applied
NuxtHub provides the `hub:database:queries:paths` hook for this purpose:

# Preview environment
npx nuxthub database migrations mark-all-applied --preview
::code-group
```ts [modules/admin/index.ts]
import { createResolver, defineNuxtModule } from 'nuxt/kit'

# Production environment
npx nuxthub database migrations mark-all-applied --production
```
export default defineNuxtModule({
meta: {
name: 'my-auth-module'
},
setup(options, nuxt) {
const { resolve } = createResolver(import.meta.url)

2. Remove `server/plugins/database.ts` as it's no longer needed.
nuxt.hook('hub:database:queries:paths', (queries) => {
// Add SQL files to run after migrations
queries.push(resolve('./db-queries/seed-admin.sql'))
})
}
})
```
```sql [modules/admin/db-queries/seed-admin.sql]
INSERT OR IGNORE INTO admin_users (id, email, password) VALUES (1, '[email protected]', 'admin');
```
::

That's it! You can keep using `npx drizzle-kit generate` to generate migrations when updating your Drizzle ORM schema.
::note
These queries run after all migrations are applied but are not tracked in the `_hub_migrations` table. Use this for operations that should run when deploying your project.
::
5 changes: 5 additions & 0 deletions docs/nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ export default defineNuxtConfig({
devtools: {
enabled: true
},
content: {
highlight: {
langs: ['sql']
}
},
routeRules: {
'/': { prerender: true },
'/api/search.json': { prerender: true },
Expand Down
2 changes: 1 addition & 1 deletion docs/pages/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ onMounted(() => {
/>
</UAvatarGroup>
<span class="text-sm text-gray-500 dark:text-gray-400">
Used and loved by <span class="font-medium dark:text-white text-gray-900">7K+ developers and teams</span>.
Used and loved by <span class="font-medium dark:text-white text-gray-900">8K+ developers and teams</span>.
</span>
</div>
<UDivider type="dashed" class="w-24" />
Expand Down
1 change: 1 addition & 0 deletions playground/layers/auth/nuxt.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export default defineNuxtConfig({})
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL,
password TEXT NOT NULL
);
5 changes: 5 additions & 0 deletions playground/modules/cms/db-migrations/0001_create-pages.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
CREATE TABLE IF NOT EXISTS pages (
id INTEGER PRIMARY KEY,
title TEXT NOT NULL,
content TEXT NOT NULL
);
14 changes: 14 additions & 0 deletions playground/modules/cms/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { createResolver, defineNuxtModule } from 'nuxt/kit'

export default defineNuxtModule({
meta: {
name: 'my-auth-module'
},
setup(options, nuxt) {
const { resolve } = createResolver(import.meta.url)

nuxt.hook('hub:database:migrations:dirs', (dirs) => {
dirs.push(resolve('db-migrations'))
})
}
})
11 changes: 11 additions & 0 deletions playground/nuxt.config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
// import { encodeHost } from 'ufo'
import { createResolver } from 'nuxt/kit'
import module from '../src/module'

const resolver = createResolver(import.meta.url)

export default defineNuxtConfig({
modules: [
'@nuxt/ui',
Expand Down Expand Up @@ -55,6 +58,14 @@ export default defineNuxtConfig({
}
// projectUrl: ({ branch }) => branch === 'main' ? 'https://playground.nuxt.dev' : `https://${encodeHost(branch).replace(/\//g, '-')}.playground-to39.pages.dev`
},
hooks: {
'hub:database:migrations:dirs': (dirs) => {
dirs.push('my-module/database/migrations')
},
'hub:database:queries:paths': (queries) => {
queries.push(resolver.resolve('server/database/queries/admin.sql'))
}
},

basicAuth: {
enabled: process.env.NODE_ENV === 'production',
Expand Down
6 changes: 6 additions & 0 deletions playground/server/database/queries/admin.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
CREATE TABLE IF NOT EXISTS admin_users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
password TEXT NOT NULL
);
INSERT OR IGNORE INTO admin_users (id, email, password) VALUES (1, '[email protected]', 'admin');
33 changes: 28 additions & 5 deletions src/features.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { execSync } from 'node:child_process'
import { pathToFileURL } from 'node:url'
import { mkdir } from 'node:fs/promises'
import { isWindows } from 'std-env'
import type { Nuxt } from '@nuxt/schema'
import { join } from 'pathe'
Expand All @@ -9,6 +10,7 @@ import { defu } from 'defu'
import { $fetch } from 'ofetch'
import { addDevToolsCustomTabs } from './utils/devtools'
import { getCloudflareAccessHeaders } from './runtime/utils/cloudflareAccess'
import { copyDatabaseMigrationsToHubDir, copyDatabaseQueriesToHubDir } from './runtime/database/server/utils/migrations/helpers'

const log = logger.withTag('nuxt:hub')
const { resolve, resolvePath } = createResolver(import.meta.url)
Expand Down Expand Up @@ -57,11 +59,25 @@ export interface HubConfig {
} & Record<string, boolean>
}

migrationsPath?: string
dir?: string
databaseMigrationsDirs?: string[]
databaseQueriesPaths?: string[]
openAPIRoute?: string
}

export function setupBase(nuxt: Nuxt, hub: HubConfig) {
export async function setupBase(nuxt: Nuxt, hub: HubConfig) {
// Create the hub.dir directory
hub.dir = join(nuxt.options.rootDir, hub.dir!)
try {
await mkdir(hub.dir, { recursive: true })
} catch (e: any) {
if (e.errno === -17) {
// File already exists
} else {
throw e
}
}

// Add Server scanning
addServerScanDir(resolve('./runtime/base/server'))
addServerImportsDir([resolve('./runtime/base/server/utils'), resolve('./runtime/base/server/utils/migrations')])
Expand Down Expand Up @@ -196,12 +212,19 @@ export async function setupCache(nuxt: Nuxt) {
addServerScanDir(resolve('./runtime/cache/server'))
}

export function setupDatabase(nuxt: Nuxt, hub: HubConfig) {
// Keep track of the path to migrations
hub.migrationsPath = join(nuxt.options.rootDir, 'server/database/migrations')
export async function setupDatabase(nuxt: Nuxt, hub: HubConfig) {
// Add Server scanning
addServerScanDir(resolve('./runtime/database/server'))
addServerImportsDir(resolve('./runtime/database/server/utils'))
nuxt.hook('modules:done', async () => {
// Call hub:database:migrations:dirs hook
await nuxt.callHook('hub:database:migrations:dirs', hub.databaseMigrationsDirs!)
// Copy all migrations files to the hub.dir directory
await copyDatabaseMigrationsToHubDir(hub)
// Call hub:database:migrations:queries hook
await nuxt.callHook('hub:database:queries:paths', hub.databaseQueriesPaths!)
await copyDatabaseQueriesToHubDir(hub)
})
}

export function setupKV(_nuxt: Nuxt) {
Expand Down
25 changes: 9 additions & 16 deletions src/module.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { mkdir, writeFile, readFile } from 'node:fs/promises'
import { writeFile, readFile } from 'node:fs/promises'
import { argv } from 'node:process'
import { defineNuxtModule, createResolver, logger, installModule, addServerHandler, addServerPlugin } from '@nuxt/kit'
import { join } from 'pathe'
Expand Down Expand Up @@ -38,6 +38,7 @@ export default defineNuxtModule<ModuleOptions>({
let remoteArg = parseArgs(argv, { remote: { type: 'string' } }).remote as string
remoteArg = (remoteArg === '' ? 'true' : remoteArg)
const runtimeConfig = nuxt.options.runtimeConfig
const databaseMigrationsDirs = nuxt.options._layers?.map(layer => join(layer.config.serverDir!, 'database/migrations')).filter(Boolean)
const hub = defu(runtimeConfig.hub || {}, options, {
// Self-hosted project
projectUrl: process.env.NUXT_HUB_PROJECT_URL || '',
Expand All @@ -60,6 +61,9 @@ export default defineNuxtModule<ModuleOptions>({
database: false,
kv: false,
vectorize: {},
// Database Migrations
databaseMigrationsDirs,
databaseQueriesPaths: [],
// Other options
version,
env: process.env.NUXT_HUB_ENV || 'production',
Expand Down Expand Up @@ -102,14 +106,14 @@ export default defineNuxtModule<ModuleOptions>({
})
}

setupBase(nuxt, hub as HubConfig)
await setupBase(nuxt, hub as HubConfig)
hub.openapi && setupOpenAPI(nuxt, hub as HubConfig)
hub.ai && await setupAI(nuxt, hub as HubConfig)
hub.analytics && setupAnalytics(nuxt)
hub.blob && setupBlob(nuxt)
hub.browser && await setupBrowser(nuxt)
hub.cache && await setupCache(nuxt)
hub.database && setupDatabase(nuxt, hub as HubConfig)
hub.database && await setupDatabase(nuxt, hub as HubConfig)
hub.kv && setupKV(nuxt)
Object.keys(hub.vectorize!).length && setupVectorize(nuxt, hub as HubConfig)

Expand Down Expand Up @@ -196,17 +200,6 @@ export default defineNuxtModule<ModuleOptions>({
log.info(`Using local storage from \`${hub.dir}\``)
}

// Create the hub.dir directory
const hubDir = join(rootDir, hub.dir)
try {
await mkdir(hubDir, { recursive: true })
} catch (e: any) {
if (e.errno === -17) {
// File already exists
} else {
throw e
}
}
const workspaceDir = await findWorkspaceDir(rootDir)
// Add it to .gitignore
const gitignorePath = join(workspaceDir, '.gitignore')
Expand All @@ -219,11 +212,11 @@ export default defineNuxtModule<ModuleOptions>({
// const needWrangler = Boolean(hub.analytics || hub.blob || hub.database || hub.kv || Object.keys(hub.bindings.hyperdrive).length > 0)
if (needWrangler) {
// Generate the wrangler.toml file
const wranglerPath = join(hubDir, './wrangler.toml')
const wranglerPath = join(hub.dir, './wrangler.toml')
await writeFile(wranglerPath, generateWrangler(nuxt, hub as HubConfig), 'utf-8')
// @ts-expect-error cloudflareDev is not typed here
nuxt.options.nitro.cloudflareDev = {
persistDir: hubDir,
persistDir: hub.dir,
configPath: wranglerPath,
silent: true
}
Expand Down
10 changes: 6 additions & 4 deletions src/runtime/database/server/plugins/migrations.dev.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { applyRemoteMigrations } from '../utils/migrations/remote'
import { applyRemoteDatabaseMigrations, applyRemoteDatabaseQueries } from '../utils/migrations/remote'
import { hubHooks } from '../../../base/server/utils/hooks'
import { applyMigrations } from '../utils/migrations/migrations'
import { applyDatabaseMigrations, applyDatabaseQueries } from '../utils/migrations/migrations'
import { useRuntimeConfig, defineNitroPlugin } from '#imports'

export default defineNitroPlugin(async () => {
Expand All @@ -11,9 +11,11 @@ export default defineNitroPlugin(async () => {

hubHooks.hookOnce('bindings:ready', async () => {
if (hub.remote && hub.projectKey) { // linked to a NuxtHub project
await applyRemoteMigrations(hub)
await applyRemoteDatabaseMigrations(hub)
await applyRemoteDatabaseQueries(hub)
} else { // local dev & self hosted
await applyMigrations(hub)
await applyDatabaseMigrations(hub)
await applyDatabaseQueries(hub)
}

await hubHooks.callHookParallel('database:migrations:done')
Expand Down
Loading

0 comments on commit f845211

Please sign in to comment.