Skip to content
Draft
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
114 changes: 114 additions & 0 deletions .journal/2026-06-25.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
# Journal - 2026-06-25

---

## 2026-06-25 01:11:00 - Drizzle Folder V3 Migration Hash Fix

### Core Decision/Topic

Fixed `migrate()` so Drizzle v1/RC Folder V3 migrations no longer collide in `__drizzle_migrations.hash` when every SQL file is named `migration.sql`.

### Problem

Drizzle v1/RC changed migration output from flat SQL files:

```text
drizzle/0000_init.sql
drizzle/0001_add_column.sql
```

to folder-based migrations:

```text
drizzle/20260605110000_add_users/migration.sql
```

The old parser used only the basename of the path as the migration hash. With Folder V3, every migration basename is `migration.sql`, so the second migration fails with:

```text
UNIQUE constraint failed: __drizzle_migrations.hash
```

Worse, the first failed run can leave `hash = 'migration.sql'` in the ledger. On the next app launch, the old code may treat later migrations as already applied and skip real schema changes.

### Options Considered

1. **Use full paths as hashes**
- Pros: Always unique.
- Cons: Can vary by import style (`./`, `/src`, alias paths, Windows separators), making hashes less stable across bundler changes.

2. **Use Folder V3 parent directory name** (CHOSEN)
- Pros: Matches Drizzle's stable migration identity; keeps old flat filenames unchanged.
- Cons: Needs an explicit parser for folder migrations.

3. **Only document a workaround**
- Pros: No code change.
- Cons: Leaves users with a sharp migration bug and polluted ledgers.

### Final Decision & Rationale

Use the parent folder name plus `.sql` as the stable hash for Folder V3 migrations:

```text
drizzle/20260605110000_add_users/migration.sql
-> 20260605110000_add_users.sql
```

Flat migrations keep their existing hash:

```text
drizzle/0000_init.sql
-> 0000_init.sql
```

This preserves backward compatibility while making Drizzle v1/RC folder migrations unique and deterministic.

### Key Changes Made

- `guest-js/migrate.ts`
- Added `parseMigrationPath()` to normalize `/` and `\` separators.
- Detects Folder V3 paths whose basename is `migration.sql`.
- Adds internal `isFolderV3` metadata so legacy recovery only applies to Folder V3 migrations.
- If the ledger already contains old `migration.sql`, treats the first Folder V3 migration as already applied and continues with later migrations.

- `test/migrate.test.ts`
- Added Bun TypeScript regression tests for:
- Folder V3 migrations getting unique parent-folder hashes.
- Flat migration filenames staying unchanged.
- Legacy `migration.sql` recovery applying later Folder V3 migrations.

- `package.json`, `pnpm-lock.yaml`, `test/tsconfig.json`, `tsconfig.json`
- Added `@types/bun` for `bun:test` type declarations.
- Added `typecheck:test`.
- Kept Bun types scoped to `test/tsconfig.json`.
- Added root `"types": []` so Bun/Node globals do not leak into browser-facing `guest-js`.
- Changed `pretest` to `rollup -c` instead of nested `pnpm build`.

- `README.md`
- Updated migration examples to use `./drizzle/**/*.sql`.
- Documented support for both flat migrations and Drizzle v1/RC Folder V3.
- Documented stable migration ids and legacy `migration.sql` recognition.

### Testing & Verification

Ran and passed:

```bash
./node_modules/.bin/tsc --noEmit
./node_modules/.bin/tsc --noEmit -p test/tsconfig.json
./node_modules/.bin/rollup -c
bun test test/migrate.test.ts
npm test
pnpm test
```

The original red test reproduced `UNIQUE constraint failed: __drizzle_migrations.hash` before the parser change.

### Future Considerations

1. **Legacy recovery assumption**: `migration.sql` is treated as the first Folder V3 migration having already run. This is pragmatic, but databases with unusual partial failure states may still need manual repair.
2. **No ledger rewrite**: The fix does not rewrite existing `migration.sql` rows; it only recognizes them at runtime.
3. **Checksum validation**: The migration runner still tracks only stable ids, not SQL content checksums.
4. **Published artifacts**: `dist-js/` is generated but ignored by git in this repo, so release/publish must still run the build step.

---
14 changes: 8 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,16 +245,17 @@ export default defineConfig({

```bash
npx drizzle-kit generate
# creates drizzle/0000_init.sql, drizzle/0001_add_column.sql, etc.
# creates either flat SQL files or Drizzle v1/RC migration folders
```

**4. Run migrations on startup**:

```typescript
import { Database, migrate } from "tauri-plugin-libsql-api";

// Vite bundles these SQL files into the app at build time
const migrations = import.meta.glob<string>("./drizzle/*.sql", {
// Vite bundles these SQL files into the app at build time.
// The glob supports both flat files and Drizzle v1/RC folder migrations.
const migrations = import.meta.glob<string>("./drizzle/**/*.sql", {
eager: true,
query: "?raw",
import: "default",
Expand All @@ -271,9 +272,10 @@ const db = drizzle(createDrizzleProxy("sqlite:myapp.db"), { schema });
### How `migrate()` works

- Creates a `__drizzle_migrations` tracking table if it doesn't exist
- Parses migration filenames by their numeric prefix (`0000_`, `0001_`, etc.)
- Parses flat migration filenames and Drizzle v1/RC folder names by their numeric prefix (`0000_`, `0001_`, `20260605110000_`, etc.)
- Applies only pending migrations in order
- Records each applied migration by filename
- Records each applied migration by a stable id: the filename for flat migrations, or the parent folder name plus `.sql` for Drizzle v1/RC folder migrations
- Recognizes the legacy `migration.sql` marker left by older versions and continues with later Drizzle v1/RC folder migrations

### Adding schema changes

Expand Down Expand Up @@ -396,7 +398,7 @@ await db.close();
```typescript
import { migrate } from "tauri-plugin-libsql-api";

const migrations = import.meta.glob<string>("./drizzle/*.sql", {
const migrations = import.meta.glob<string>("./drizzle/**/*.sql", {
eager: true,
query: "?raw",
import: "default",
Expand Down
51 changes: 46 additions & 5 deletions guest-js/migrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,19 +27,53 @@ interface ParsedMigration {
filename: string
sql: string
index: number
isFolderV3: boolean
}

function parseMigrationPath(path: string): Omit<ParsedMigration, 'sql'> | null {
const segments = path.split(/[\\/]+/).filter(Boolean)
const fileName = segments[segments.length - 1]

if (!fileName) {
return null
}

if (fileName === 'migration.sql' && segments.length >= 2) {
const folderName = segments[segments.length - 2]
const match = folderName.match(/^(\d+)[_\-].*$/)

if (match) {
return {
filename: `${folderName}.sql`,
index: parseInt(match[1], 10),
isFolderV3: true,
}
}
}

const match = fileName.match(/^(\d+)[_\-].*\.sql$/)
if (!match) {
return null
}

return {
filename: fileName,
index: parseInt(match[1], 10),
isFolderV3: false,
}
}

function parseMigrations(files: MigrationFiles): ParsedMigration[] {
const migrations: ParsedMigration[] = []

for (const [path, sql] of Object.entries(files)) {
// Match drizzle-kit naming: 0000_xxx.sql, 0001_xxx.sql, etc.
const match = path.match(/(\d+)[_\-].*\.sql$/)
if (match && sql) {
const parsedPath = parseMigrationPath(path)
if (parsedPath && sql) {
migrations.push({
filename: path.split('/').pop()!,
filename: parsedPath.filename,
sql: sql as string,
index: parseInt(match[1], 10),
index: parsedPath.index,
isFolderV3: parsedPath.isFolderV3,
})
}
}
Expand Down Expand Up @@ -104,12 +138,19 @@ export async function migrate(

// Parse and sort migration files
const migrations = parseMigrations(migrationFiles)
const legacyFolderV3Migration = appliedSet.has('migration.sql')
? migrations.find((migration) => migration.isFolderV3)
: undefined

for (const migration of migrations) {
if (appliedSet.has(migration.filename)) {
continue
}

if (migration.filename === legacyFolderV3Migration?.filename) {
continue
}

// Split on semicolons to get individual statements.
// Note: this is a naive split — semicolons inside string literals will
// cause incorrect splits. drizzle-kit generated SQL does not produce this.
Expand Down
7 changes: 5 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "tauri-plugin-libsql-api",
"version": "0.1.0",
"version": "0.1.1-beta.0",
"author": "HuakunShen",
"repository": {
"type": "git",
Expand All @@ -25,13 +25,16 @@
"scripts": {
"build": "rollup -c",
"prepublishOnly": "pnpm build",
"pretest": "pnpm build"
"pretest": "rollup -c",
"test": "bun test test/*.test.ts",
"typecheck:test": "tsc --noEmit -p test/tsconfig.json"
},
"dependencies": {
"@tauri-apps/api": "^2.0.0"
},
"devDependencies": {
"@rollup/plugin-typescript": "^12.0.0",
"@types/bun": "^1.3.14",
"rollup": "^4.9.6",
"tslib": "^2.6.2",
"typescript": "^5.3.3"
Expand Down
Loading