从MySQL到Turso:Nuxt4项目数据库迁移全流程

一、背景

去年开发的《个人数据管理中台》项目最初选定的数据库是 Sqlite(轻便的单文件数据库,感觉特别适合个人桌面端项目使用),后面改用 MySQL 主要是因为Sqlite不支持数据库文件加密,并且过去使用PHP开发全栈时有一定的Mysql使用经验。

当时放弃Sqlite主要原因就是没法进行数据库加密

转折点来自上个月,在搜索其他问题时看到一个支持加密的Sqlite变种数据库——Turso。在之前的文章中已经通过 Drizzle ORM 在Nuxt中成功集成了Turso本地数据库。在开始前,我查阅了 Prisma 的官方文档,确认 Prisma 支持连接Turso本地数据库。

Prisma支持链接Turso

另外在迁移过程中,我还遇到了一个核心问题:Prisma ORM默认不兼容Turso的加密机制,导致数据库通过适配器可以连接成功,而数据库迁移、数据结构变更无法使用,间接导致了无法初始化Turso本地加密数据库。经过多次尝试与代码调试,以及参考之前 Drizzle ORM 的迁移经历,我终于找到了解决方案。本文详细记录了整个迁移流程,从迁移前准备、核心步骤,到表结构迁移与后续表结构变动时的处理方式。希望能给有同样集成Turso的开发者提供一些参考。

二、预期效果

由于Turso加密后的数据库无法使用常规的数据库工具读取,开发过程中又不免需要查库调试,所以我打算配置两套环境:

  • Dev环境:未使用encryptionKey配置的标准数据库(SQLite),可以通过常规数据库工具进行访问。
  • Prod环境:使用encryptionKey配置的加密数据库(Turso),只能通过Prisma适配器或者Turso Cli工具访问。

在此基础上,解决 Prisma 针对 Turso 不支持数据迁移、数据导入的问题,并最终完成 Dev、Prod 环境的历史数据同步以及未来开发过程中的数据结构同步需求。

三、迁移前准备

  1. 由于之前验证 Drizzle ORM 集成时用得是新建的Nuxt4项目,所以本次迁移前我先把项目依赖更新到最新;
  2. 安装 Prisma 适配器@prisma/adapter-libsql和 Turso 本地客户端@libsql/client
1
2
npm install @prisma/adapter-libsql
npm install @libsql/client
  1. 安装dotenv-cli适配自定义脚本及多环境开发(如dev环境关闭加密数据库功能,prod环境启用加密数据库);
1
npm install --save-dev dotenv-cli

有人可能会疑惑:Nuxt 本身已经内置了 dotenv,并且会自动读取 .env 中的环境变量,为什么我们还要额外安装一次 dotenv

原因很简单:后续编写自定义 Node.js 脚本时,仍需要独立读取本地配置文件,这一步安装是为了给这些脚本使用。

最后,必要得依赖项如下:

package.json
1
2
3
4
5
6
7
8
9
10
11
12
{
"dependencies": {
"@libsql/client": "^0.17.2",
"@prisma/adapter-libsql": "^7.6.0",
"@prisma/client": "^7.6.0"
},
"devDependencies": {
"dotenv-cli": "^11.0.0",
"nuxt": "^4.4.2",
"prisma": "^7.6.0"
}
}

四、开始迁移

(1)修改 Prisma配置

从 v7 版本开始,需要创建 prisma.config.ts来进行 Prisma 配置,先创建配置文件:(为了便于管理,我把 Prisma 的文件都放置在./prisma/目录下)

prisma.config.ts
1
2
3
4
5
6
7
8
9
10
11
12
import "dotenv/config";
import { defineConfig } from "prisma/config";

export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
},
datasource: {
url: process.env["DATABASE_URL"],
},
});

修改.env.*环境变量文件:

.env
1
2
3
4
5
6
7
# Prisma配置
## 数据库路径
DATABASE_URL=file:./prisma/database/dev.db
## 数据库访问Token
TURSO_AUTH_TOKEN=
## Turso 加密配置
TURSO_ENCRYPTION_KEY=

修改 schema.prisma文件,将数据库驱动调整为sqlite

schema.prisma
1
2
3
4
5
6
7
8
generator client {
provider = "prisma-client" // 从 prisma-client-js 改为 prisma-client
output = "../prisma/generate"
}

datasource db {
provider = "sqlite" // 从 mysql 改为 sqlite
}

(2)根据 schema.prisma 文件生成 Turso 数据库文件

调整package.json,增加数据库迁移命令:

package.json
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
"scripts": {
"dev:migrate": "npx prisma migrate dev",
"prod:migrate": "dotenv -e .env.production -- node prisma/migrate.ts"
},
"dependencies": {
"@libsql/client": "^0.17.2",
"@prisma/adapter-libsql": "^7.6.0",
"@prisma/client": "^7.6.0"
},
"devDependencies": {
"dotenv-cli": "^11.0.0",
"nuxt": "^4.4.2",
"prisma": "^7.6.0"
}
}

现在开发环境因为是未加密的 Sqlite 数据库,所以 Prisma 可以自己执行迁移过程;而生产环境则需要我们自己处理迁移过程了。

处理加密数据库的表结构迁移过程和之前处理 Drizzle ORM 的方式有所不同,我改用 Prisma 官方推荐的 prisma migrate diff 命令来处理迁移过程。生产环境的迁移思路如下:

  1. 通过prisma migrate diff --script > migration.sql生产迁移脚本
  2. 使用@libsql/client连接加密数据库
  3. 执行生成的migration.sql数据库脚本
prisma/migrate.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import { createClient } from '@libsql/client';
import { execSync } from 'child_process';
import fs from 'fs';

async function runMigration() {
const encryptionKey = process.env.TURSO_ENCRYPTION_KEY;
const targetDbUrl = process.env.DATABASE_URL!;
const schemaPath = "./prisma/schema.prisma";

// 比较 Schema 和当前的加密数据库文件,生成增量 SQL
// 注意:如果是第一次运行,--from-empty 会生成全量建表语句
const command = `npx prisma migrate diff --from-empty --to-schema ${schemaPath} --script > migration.sql`;

try {
execSync(command);
const sql = fs.readFileSync('migration.sql', 'utf8');
const client = createClient({ url: targetDbUrl, encryptionKey: encryptionKey });

// 先创建元数据表
await client.execute(createMigrationsTableSql);
await client.close();
} catch (e) {
console.error("迁移失败:", e);
}
}

runMigration();

注意:

默认生成的表结构迁移脚本中,不包含_prisma_migrations表结构,需要手动编写脚本将其插入!

(3)同步Mysql数据库记录至开发环境

这里为了方便和保证数据完整性,我直接用专业数据库工具 Navicat 的数据传输功能来同步数据。

顶部工具栏 - 工具 - 数据传输

在左侧选好源数据库,右侧选择目标数据库,然后点击右下方的 下一步 即可。

通过数据传输功能进行数据同步

在package.json中补充启动命令,并验证开发环境运行正常。

package.json
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
{
"scripts": {
"dev:migrate": "npx prisma migrate dev",
"prod:migrate": "dotenv -e .env.production -- node prisma/migrate.ts",

"dev:serve": "nuxt dev --host",
"prod:serve": "nuxt dev --host --dotenv .env.production"
},
"dependencies": {
"@libsql/client": "^0.17.2",
"@prisma/adapter-libsql": "^7.6.0",
"@prisma/client": "^7.6.0"
},
"devDependencies": {
"dotenv-cli": "^11.0.0",
"nuxt": "^4.4.2",
"prisma": "^7.6.0"
}
}

(4) 同步开发环境数据至生产环境

因为生产环境的数据库是加密后的 Turso 数据库,而 Navicat 到目前为止并不支持 Turso,所以只能自己编写脚本进行数据同步工作,这一步新增了5条命令:

package.json
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
{
"scripts": {
"dev:migrate": "npx prisma migrate dev",
"dev:export": "dotenv -e .env -- node prisma/export.ts",
"dev:import": "dotenv -e .env -- node prisma/import.ts",
"prod:migrate": "dotenv -e .env.production -- node prisma/migrate.ts",
"prod:export": "dotenv -e .env.production -- node prisma/export.ts",
"prod:import": "dotenv -e .env.production -- node prisma/import.ts",
"prod:sync": "dotenv -e .env.production -- node prisma/sync.ts"
},
"dependencies": {
"@libsql/client": "^0.17.2",
"@prisma/adapter-libsql": "^7.6.0",
"@prisma/client": "^7.6.0"
},
"devDependencies": {
"dotenv-cli": "^11.0.0",
"nuxt": "^4.4.2",
"prisma": "^7.6.0"
}
}

prisma/export.ts负责导出数据库中的数据至*.sql文件,用于其他环境的导入工作。

prisma/export.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
import { createClient } from '@libsql/client';
import * as fs from 'fs';
import * as path from 'path';

async function runExport() {
const dbUrl =process.env.DATABASE_URL!;
const authToken = process.env.TURSO_AUTH_TOKEN;
const encryptionKey = process.env.TURSO_ENCRYPTION_KEY;

const dbName = path.basename(dbUrl)
const outputFile = `./prisma/backup/backup_${dbName}_${new Date().getTime()}.sql`;
const client = createClient({ url: dbUrl, authToken, encryptionKey });

try {
// 1. 获取所有元数据(表、索引、视图等)
const masterResult = await client.execute(
"SELECT type, name, sql FROM sqlite_master WHERE name NOT LIKE 'sqlite_%' AND sql IS NOT NULL"
);
let sqlDump = `-- Database Dump (${dbName})\n-- Generated at: ${new Date().toLocaleString()}\n`;
sqlDump += "PRAGMA foreign_keys=OFF;\n";

// 2. 分类处理:先处理表,再处理索引和视图
for (const row of masterResult.rows) {
const type = row.type as string;
const name = row.name as string;
const sql = row.sql as string;

// 写入清理语句和建表/建索引语句
sqlDump += `\n-- ${type.toUpperCase()}: ${name}\n`;
sqlDump += `DROP ${type.toUpperCase()} IF EXISTS "${name}";\n`;
sqlDump += `${sql};\n`;

// 3. 如果是表,则导出其中的数据记录
if (type === 'table') {
const dataResult = await client.execute(`SELECT * FROM "${name}"`);

for (const dataRow of dataResult.rows) {
const keys = Object.keys(dataRow);
const columns = keys.map(k => `"${k}"`).join(', ');

const values = Object.values(dataRow).map(v => {
if (v === null) return 'NULL';
if (typeof v === 'string') {
return "'" + v.replace(/'/g, "''") + "'";
}
return v;
}).join(', ');

sqlDump += `INSERT INTO "${name}" (${columns}) VALUES (${values});\n`;
}
}
}
sqlDump += "\nPRAGMA foreign_keys=ON;";

fs.writeFileSync(outputFile, sqlDump);
} catch (error: any) {
console.error("导出失败:", error.message);
} finally {
client.close();
}
}

runExport();

prisma/import.ts执行的命令形式为npm run dev:import <file_path>,负责导入指定路径下的*.sql文件至数据库。

prisma/import.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import { createClient } from '@libsql/client';
import * as fs from 'fs';
import * as path from 'path';
import * as readline from 'readline';

async function runImport() {
// 1. 获取参数: npm run dev:import <file_path>
const filePath = process.argv[2];

const dbUrl =process.env.DATABASE_URL!;
const authToken = process.env.TURSO_AUTH_TOKEN;
const encryptionKey = process.env.TURSO_ENCRYPTION_KEY;
const dbName = path.basename(dbUrl)
const client = createClient({ url: dbUrl, authToken, encryptionKey });

try {
const sqlContent = fs.readFileSync(filePath, 'utf8');

// 暂时关闭外键检查,防止 DROP TABLE 时因关联关系报错
await client.execute("PRAGMA foreign_keys = OFF;");
await client.executeMultiple(sqlContent);
// 恢复外键检查
await client.execute("PRAGMA foreign_keys = ON;");
} catch (error: any) {
console.error(`导入失败: ${error.message}`);
} finally {
client.close();
}
}

runImport();

最后,我根据编写的三个命令组合实现了两种同步方式:

  1. 全量同步:通过环境:export环境:import命令,导入导出整个数据库表结构和记录,并进行完整的重建写入,适用于表结构发生变动后的同步数据场景。
  2. 增量同步:通过prod:sync命令,遍历开发环境的所有数据表,对生产环境的每张表的记录进行插入或覆盖,适用于表结构未发生变动,仅同步数据的场景。

以上只是简略演示,主包后面还做了不少优化,如增加了额外的边界检查导入导出前备份、二次确认等策略,大家根据自己的需求去完善即可。

后续数据验证的过程这里就不放了,主包是写了一个脚本依次读取全部表的全部数据,对比两个数据库下的记录内容是否一致(因为主包的现有数据量不大,所以这样操作还是比较方便和简单的)。

五、开发时的表结构变动

还有一种常见的场景是:第一版项目开发完成,Dev、Prod环境都产生了一定数据。而第二版代码开发过程中,存在新增表、删除表、变更表结构的情况,在第二版开发完成后,需要将最新结构同步到Prod环境。

这与之前执行的prisma/migrate.ts不同,通过prisma migrate diff --from-empty生产的是全量建表结构,仅适用于首次建库时的表结构同步,后续迁移需要另行它法。于是我又添加了一个prod:migrate-sync命令。

package.json
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
{
"scripts": {
"dev:migrate": "npx prisma migrate dev",
"dev:export": "dotenv -e .env -- node prisma/export.ts",
"dev:import": "dotenv -e .env -- node prisma/import.ts",
"prod:migrate": "dotenv -e .env.production -- node prisma/migrate.ts",
"prod:migrate-sync": "dotenv -e .env.production -- node prisma/migrate-sync.ts",
"prod:export": "dotenv -e .env.production -- node prisma/export.ts",
"prod:import": "dotenv -e .env.production -- node prisma/import.ts",
"prod:sync": "dotenv -e .env.production -- node prisma/sync.ts"
},
"dependencies": {
"@libsql/client": "^0.17.2",
"@prisma/adapter-libsql": "^7.6.0",
"@prisma/client": "^7.6.0"
},
"devDependencies": {
"dotenv-cli": "^11.0.0",
"nuxt": "^4.4.2",
"prisma": "^7.6.0"
}
}

主要的思路就是:读取 Prisma 在数据库中创建的_prisma_migrations表记录(其中记录了运行的数据库迁移内容),再与本地的migrations文件夹下的数据库迁移sql脚本对比,将未运行过的迁移文件执行到 Prod 环境数据库中。

prisma/migrate-sync.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
import { createClient } from '@libsql/client';
import fs from 'fs';
import path from 'path';
import crypto from 'crypto';
import { v4 as uuidv4 } from 'uuid';

function calculateChecksum(filePath: string): string {
const fileBuffer = fs.readFileSync(filePath);
return crypto.createHash('sha256').update(fileBuffer).digest('hex');
}

async function syncMigrations() {
const encryptionKey = process.env.TURSO_ENCRYPTION_KEY;
const authToken = process.env.TURSO_AUTH_TOKEN;
const dbUrl = process.env.DATABASE_URL!;
const migrationsPath = path.join(process.cwd(), 'prisma', 'migrations');

const client = createClient({ url: dbUrl, authToken, encryptionKey });

try {
// 获取 PRD 库中已经应用的迁移记录
const appliedMigrationsResult = await client.execute(
"SELECT migration_name FROM _prisma_migrations WHERE finished_at IS NOT NULL"
);
const appliedNames = new Set(appliedMigrationsResult.rows.map(r => r.migration_name as string));

// 读取本地所有的迁移文件夹
const localMigrations = fs.readdirSync(migrationsPath)
.filter(f => fs.statSync(path.join(migrationsPath, f)).isDirectory())
.sort(); // 确保按时间戳顺序排列

// 找出尚未应用的迁移
const pendingMigrations = localMigrations.filter(name => !appliedNames.has(name));

if (pendingMigrations.length === 0) {
return;
}

for (const migrationName of pendingMigrations) {
const sqlPath = path.join(migrationsPath, migrationName, 'migration.sql');
const sql = fs.readFileSync(sqlPath, 'utf8');

// 计算文件的 SHA256 Checksum
const checksum = calculateChecksum(sqlPath);
// 生成标准的 UUID
const migrationId = uuidv4();
// 执行迁移 SQL
await client.executeMultiple(sql);

// 手动在 _prisma_migrations 表中插入记录
await client.execute({
sql: `INSERT INTO _prisma_migrations
(id, checksum, finished_at, migration_name, logs, rolled_back_at, started_at, applied_steps_count)
VALUES (?, ?, datetime('now'), ?, NULL, NULL, datetime('now'), 1)`,
args: [
migrationId,
checksum,
migrationName
]
});
}
} catch (e: any) {
console.error("迁移失败:", e.message);
process.exit(1);
} finally {
client.close();
}
}

syncMigrations();

六、结语

现在项目的数据库已经完全迁移到 SQLite 和 Turso 下了,主包不用再起一台 docker 跑 mysql数据库了,本地开发与调试十分方便,后续还可以考虑结合 Tauri / Electron 等打成桌面端软件,搭配加密的 Turso 数据库就可以安心把数据存在本地了。

从MySQL到Turso:Nuxt4项目数据库迁移全流程

作者:有点东西

链接: https://www.youdiandongxi.com/article/prisma-migrate-mysql-to-turso.html

协议:本文采用 CC BY-NC-SA 4.0 隐私协议,转载请注明出处!

评论区