Skip to content

bug: SQLite migration fails with UNIQUE constraint on __drizzle_migrations.hash due to Drizzle v1 Beta nested folder structure #2

@warclarin

Description

@warclarin

Describe the Bug

When upgrading to Drizzle v1 (Beta) and utilizing its new "Folder V3" architecture alongside tauri-plugin-libsql-api in a Nuxt/Vite environment, the migrate() helper fails with a SQLite error: UNIQUE constraint failed: __drizzle_migrations.hash.

This happens because Drizzle v1 removes the flat file system and journal.json approach, grouping each individual migration inside its own subdirectory where the SQL payload file is strictly named migration.sql. The runner fails to cleanly isolate the unique folder names from the raw file paths.

Environment

  • Framework: Nuxt 4 / Vite
  • Runtime: Tauri
  • Library: tauri-plugin-libsql-api
  • ORM: Drizzle ORM / Drizzle Kit v1.0.0-beta.x (Folder V3 layout)

Steps to Reproduce

  1. Generate migrations using Drizzle v1 Beta, which yields a directory structure like this:
   db/migrations/
   ├── 20260508135710_init/
   │   └── migration.sql
   └── 20260605110000_add_users/
       └── migration.sql
  1. Collect the .sql modules via Vite's import.meta.glob inside a Nuxt plugin:
   const migrations = import.meta.glob<string>('~/db/migrations/**/*.sql', {
     eager: true,
     query: '?raw',
     import: 'default',
   })

   await Database.load('sqlite:app.db')
   await migrate('sqlite:app.db', migrations)
  1. Run the application. The migration runner crashes when attempting to parse the second migration folder:
SQLite failure: UNIQUE constraint failed: __drizzle_migrations.hash

Root Cause & Findings
Under the hood, Vite's import.meta.glob spits out an object where every key maps to the absolute/relative file path, meaning all keys terminate uniformly with /migration.sql:

"/src/db/migrations/20260508135710_init/migration.sql"
"/src/db/migrations/20260605110000_add_users/migration.sql"

The current implementation of migrate() in tauri-plugin-libsql-api appears to strip the preceding path logic and reads only the filename ("migration.sql"). Because it interprets both files as having an identical name string, the internal loop breaks. It doesn't treat them as separate chronological ledger records, attempting to duplicate or rewrite metadata hashes into the __drizzle_migrations tracking table.

Expected Behavior
The migrate() logic should be adapted to handle Drizzle v1 migration folders by using the second-to-last path segment (the parent directory name) or the full relative path string to ensure distinct uniqueness across the ledger.

Temporary Workaround
Manually intercepting the glob keys and mapping them to their containing Drizzle v1 folder names before throwing them into migrate():

import { Database, migrate } from 'tauri-plugin-libsql-api'

export default defineNuxtPlugin(async (_nuxtApp) => {
  const rawMigrations = import.meta.glob<string>('~/db/migrations/**/*.sql', {
    eager: true,
    query: '?raw',
    import: 'default',
  })

  const migrations = Object.keys(rawMigrations).reduce(
    (acc, path) => {
      const segments = path.split('/')
      const folderName = segments[segments.length - 2]

      if (folderName) {
        const uniqueKey = `${folderName}.sql`
        acc[uniqueKey] = rawMigrations[path] as string
      }

      return acc
    },
    {} as Record<string, string>,
  )

  await Database.load('sqlite:app.db')
  await migrate('sqlite:app.db', migrations)
})

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions