所有文章

2026-08-30

· Dylan Yu
SQLiteforeign keysdatabase designschema

SQLite 外键和关系:实用指南(含坑)

SQLite 默认不强制外键,大多数教程跳过了真正让你踩坑的部分。这里讲外键在 SQLite 里怎么工作、PRAGMA 干什么、不用 ALTER TABLE 怎么建模关系、以及如何在管理面板里可视化它们。

SQLite 是世界上部署最广的数据库引擎。每个手机、每个浏览器、每台 macOS 都有,你桌面上大概一半的应用现在也在用。但它的外键故事真的很怪——怪到我见过有经验的工程师为此浪费一下午。

简短版:外键在 SQLite 里存在,但默认关闭。你必须在每个连接上打开,否则它们静默什么都不做。而一旦你打开,你会发现 ALTER TABLE 限制太多,以至于修正一个搞错的关系是个多步骤的折磨,大多数教程根本不提。

网上大多数「SQLite 外键」文章覆盖快乐路径:建两个带 REFERENCES 子句的表、插一行、继续。那大概是你实际需要知道的 10%。这篇文章是另外 90%——PRAGMA 行为、ORM 配置、ALTER TABLE 问题,以及当你不能改 schema 但仍需要建模关系时怎么办。

开始。

外键在 SQLite 里怎么工作(让人意外的默认)

SQLite 从 3.6.19 版本(2009 年发布)就支持外键。不是打错——外键已经可用超过十五年。问题是它们为了向后兼容默认禁用,而启用方式不是 schema 属性或数据库设置。它是连接上的运行时标志。

这就是让人绊倒的部分。你可以写完全正确的 REFERENCES 子句、建表、插入违反约束的行,SQLite 会乐意接受。没错误。没警告。约束只是……装饰性的。

看看长什么样。先是 schema:

CREATE TABLE users (
    id INTEGER PRIMARY KEY,
    email TEXT NOT NULL UNIQUE
);

CREATE TABLE orders (
    id INTEGER PRIMARY KEY,
    user_id INTEGER NOT NULL,
    total REAL NOT NULL,
    FOREIGN KEY (user_id) REFERENCES users(id)
);

那个 FOREIGN KEY (user_id) REFERENCES users(id) 是正确的 SQL。它说:orders 里每个 user_id 必须指向 users 里真实的 id。现在看插入违反它的行会怎样:

INSERT INTO users (id, email) VALUES (1, 'alice@example.com');

-- 这引用了 user_id 999,不存在。
INSERT INTO orders (id, user_id, total) VALUES (1, 999, 49.99);

-- 结果:插入成功。没错误。

如果你从 PostgreSQL 或 MySQL 过来,这是你盯着屏幕的时刻。约束就在 schema 里。为什么没触发?

因为外键强制关闭了。你用 PRAGMA 打开:

PRAGMA foreign_keys = ON;

-- 再试同样的插入:
INSERT INTO orders (id, user_id, total) VALUES (2, 999, 49.99);

-- 结果:Error: FOREIGN KEY constraint failed

这就是全部机制。一行,你的约束开始工作。问题是:这行放哪,怎么确保它一直在?这就烦了。

怎么检查 FK 现在是否开着

做任何其他事之前,学这一个查询:

PRAGMA foreign_keys;
-- 返回 0(关)或 1(开)

在你在调试的任何连接上跑它。如果你看到孤行并奇怪为什么约束没抓住它们,这几乎总是答案——做插入的连接 FK 关着。

PRAGMA 的坑

让 PRAGMA 方式真正危险的是:它是按连接的,不是按数据库的。 在一个连接上设 PRAGMA foreign_keys = ON 不影响任何其他连接,也不持久。开一个新连接——即使到同一个文件——FK 又关了。

这意味着设置必须在每次开连接时由开连接的代码应用。如果忘了,你得到静默约束违反。没有错误日志、没有警告、什么都没有。数据就进去了。

实践中,这意味着配置在你应用代码或 ORM 设置里,不在 schema 里。而每个流行 ORM 处理方式不同,这本身就是 bug 来源。

Prisma

Prisma 连 SQLite 时默认启用外键。你不用做任何事——Prisma 引擎在连接设置里跑 PRAGMA foreign_keys = ON;。这是对的默认,是 Prisma 的 SQLite 支持中少数开箱即用的东西之一。

如果你通过 $executeRaw / $queryRaw 用原始查询,同样的连接池适用,FK 仍开着。好。

SQLAlchemy

SQLAlchemy 为 SQLite 默认启用外键。这坑了很多人,因为 SQLAlchemy 乐意让你在模型里定义 ForeignKey 列然后静默不强制。

你必须用事件监听器显式启用:

from sqlalchemy import event
from sqlalchemy.engine import Engine

@event.listens_for(Engine, "connect")
def _enable_sqlite_fk(dbapi_connection, connection_record):
    cursor = dbapi_connection.cursor()
    cursor.execute("PRAGMA foreign_keys=ON")
    cursor.close()

那个监听器在每个新连接上触发,正是你要的。如果忘了这个,你的 ForeignKey 声明是文档,不是约束。

Drizzle

Drizzle 的 Node SQLite 驱动(底层 better-sqlite3)也默认关 FK。你在构造客户端时启用:

import { drizzle } from 'drizzle-orm/better-sqlite3';
import Database from 'better-sqlite3';

const sqlite = new Database('app.db');
sqlite.pragma('journal_mode = WAL');
sqlite.pragma('foreign_keys = ON');  // <-- 这行

export const db = drizzle(sqlite);

注意 better-sqlite3.pragma() 调用适用于那个连接。如果你用连接池(better-sqlite3 罕见,因为它是同步单连接),你需要按连接应用。

通用规则

不管你用什么栈,规则一样:找到创建连接的地方,在那设 PRAGMA。 如果你的 ORM 没清楚文档化这个,搜 issues 标签——几乎总有一串困惑用户在上线后发现 FK 关着的讨论。

还有一个 wrinkles:PRAGMA 不能在事务里改。PRAGMA foreign_keys = ON 在事务里是 no-op。它必须在任何 BEGIN 之前设。大多数 ORM 正确处理这个,因为它们在连接打开时、用户代码跑之前设,但如果你手动管理连接,记住这点。

SQLite 外键支持什么(和不支持什么)

一旦 FK 真正开着,SQLite 的支持比人们以为的更完整。常见引用动作都能用。我们带例子过一遍。

ON DELETE CASCADE

当父行删除时,所有子行自动删除。

CREATE TABLE users (
    id INTEGER PRIMARY KEY,
    email TEXT NOT NULL UNIQUE
);

CREATE TABLE orders (
    id INTEGER PRIMARY KEY,
    user_id INTEGER NOT NULL,
    total REAL NOT NULL,
    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);

INSERT INTO users (id, email) VALUES (1, 'alice@example.com');
INSERT INTO orders (id, user_id, total) VALUES (1, 1, 49.99);
INSERT INTO orders (id, user_id, total) VALUES (2, 1, 12.50);

DELETE FROM users WHERE id = 1;

-- 两行 orders 现在都没了。不用手动清理。

这是你用得最多的。对「子记录没父就没意义」是对的正确默认。

ON UPDATE CASCADE

当父的主键改变时,子的外键列自动更新匹配。

CREATE TABLE accounts (
    id INTEGER PRIMARY KEY,
    name TEXT NOT NULL
);

CREATE TABLE transactions (
    id INTEGER PRIMARY KEY,
    account_id INTEGER NOT NULL,
    amount REAL NOT NULL,
    FOREIGN KEY (account_id) REFERENCES accounts(id) ON UPDATE CASCADE
);

INSERT INTO accounts (id, name) VALUES (1, 'Checking');
INSERT INTO transactions (id, account_id, amount) VALUES (1, 1, 100.00);

UPDATE accounts SET id = 100 WHERE id = 1;

-- transactions.account_id 现在是 100,自动的。

如果你重新编号 ID 有用。较少需要,因为大多数 schema 用不可变代理键,但它在那。

SET NULL

当父删除时,子的 FK 列设为 NULL(列必须可空)。

CREATE TABLE projects (
    id INTEGER PRIMARY KEY,
    name TEXT NOT NULL
);

CREATE TABLE tasks (
    id INTEGER PRIMARY KEY,
    project_id INTEGER,  -- 可空
    title TEXT NOT NULL,
    FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE SET NULL
);

INSERT INTO projects (id, name) VALUES (1, 'Migration');
INSERT INTO tasks (id, project_id, title) VALUES (1, 1, 'Write schema');

DELETE FROM projects WHERE id = 1;

-- tasks 行还在,project_id 现在是 NULL。

当子记录没父仍有意义时——没项目的任务、没父帖的评论——这是对的选择。

RESTRICT 和 NO ACTION

RESTRICT 阻止删除父如果有子存在,且立即——不延迟,即使在事务里。

CREATE TABLE invoices (
    id INTEGER PRIMARY KEY,
    total REAL NOT NULL
);

CREATE TABLE invoice_lines (
    id INTEGER PRIMARY KEY,
    invoice_id INTEGER NOT NULL,
    FOREIGN KEY (invoice_id) REFERENCES invoices(id) ON DELETE RESTRICT
);

NO ACTION 是默认。在 SQLite 里,NO ACTIONRESTRICT 一个微妙区别:NO ACTION 在语句末检查约束(所以你可以在复杂语句里重排删除),而 RESTRICT 立即检查。实践中你几乎从注意不到区别,但如果你要「快速失败,无例外」,用 RESTRICT

不支持的

几件事值得知道:

  • 没有部分外键。 你不能有只在某条件为真时适用的 FK(如「仅当 status = 'active' 时强制这个」)。约束对列是全有或全无。
  • 没有基于表达式的外键。 FK 必须引用实际列,不是表达式。
  • 延迟约束支持但很少用。 你可以声明 FK 为 DEFERRABLE INITIALLY DEFERRED,意味着检查推迟到 COMMIT。这对循环引用(A 引用 B,B 引用 A)有用,你需要在一个事务里插两行。它能用,但我在真实 schema 里几乎从不需要——你通常可以用可空列打破循环。
  • 父列必须是主键或有 UNIQUE 索引。 SQLite 在这里比某些数据库严格——你不能引用任意列。它必须唯一。

对绝大多数 schema,支持的子集足够了。咬人的空白不是功能列表——是 ALTER TABLE 问题,接下来讲。

ALTER TABLE 问题

这里 SQLite 的外键故事变得真正痛苦。

SQLite 的 ALTER TABLE 出了名地有限。你能做的完整列表:

  • ALTER TABLE ... RENAME TO ... — 重命名表
  • ALTER TABLE ... RENAME COLUMN ... TO ... — 重命名列
  • ALTER TABLE ... ADD COLUMN ... — 加列
  • ALTER TABLE ... DROP COLUMN ... — 删列(3.35.0 加的)

就这样。值得注意的是缺失的:

  • 你不能给现有表加外键约束。
  • 你不能修改现有列的类型或约束。
  • 你不能给现有列加 NOT NULL 约束。
  • 你不能改列的默认值。

所以如果你建表时没 FK 后来发现需要,没有 ALTER TABLE orders ADD FOREIGN KEY ... 语句。它不存在。官方 SQLite 文档描述了变通方法,是个 12 步过程,我完整展示给你看让你理解为什么人们避免它。

12 步重建

模式是:用你想要的 schema 建新表、复制数据、删旧表、重命名新表、重建依赖旧表的索引、触发器或视图。SQL 如下:

-- 1. 迁移期间关掉 FK 强制(必须,因为
--    SQLite 不让你在 FK 开着时改被 FK 引用的表)。
PRAGMA foreign_keys = OFF;

-- 2. 开始事务。
BEGIN TRANSACTION;

-- 3. 用你想要的 FK 约束建新表。
CREATE TABLE orders_new (
    id INTEGER PRIMARY KEY,
    user_id INTEGER NOT NULL,
    total REAL NOT NULL,
    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);

-- 4. 复制数据,先过滤掉孤行。
INSERT INTO orders_new (id, user_id, total)
SELECT id, user_id, total FROM orders
WHERE user_id IN (SELECT id FROM users);

-- 5. 删旧表。
DROP TABLE orders;

-- 6. 重命名新表为原名。
ALTER TABLE orders_new RENAME TO orders;

-- 7. 重建旧表上的索引。
CREATE INDEX idx_orders_user_id ON orders(user_id);

-- 8. 重建触发器(如果有的话)。
-- CREATE TRIGGER ... (省略)

-- 9. 重建引用该表的视图(如果有的话)。
-- CREATE VIEW ... (省略)

-- 10. 跑外键检查确认一切一致。
PRAGMA foreign_key_check;

-- 11. 提交。
COMMIT;

-- 12. 把 FK 强制打开。
PRAGMA foreign_keys = ON;

这是官方流程。能用。它也是,客观地说,很多——而且容易漏步骤。忘了重建索引查询变慢。忘了 foreign_key_check 你上线孤行。忘了把 FK 打开你回到静默违反问题。

这是人们对 SQLite 关系沮丧的真正原因:不是因为功能缺失,而是因为事后修正关系是手动、易错的过程。 在 Postgres 里你跑 ALTER TABLE orders ADD CONSTRAINT ... FOREIGN KEY ... 一行搞定。在 SQLite 里你在重建表。

那你实际怎么办?有三个现实选项,取舍不同。

不用 ALTER TABLE 建模关系

选项 1:12 步重建

这是我上面展示的。它是「正确」答案,意义上它产生带真正强制 FK 约束的 schema。如果你在意引用完整性在数据库层强制——你通常应该——这是路径。

什么时候用:

  • 你拥有 schema 且能接受短暂写锁。
  • 表不是巨大(复制百万行花时间,虽然 SQLite 快)。
  • 你想要约束即使被原始 SQL 或其他碰 DB 的工具强制。

什么时候避免:

  • 你不能承受停机,即使几秒。
  • 表巨大,复制太久。
  • 你不拥有 schema(是第三方应用的数据库)。

实用提示:整个脚本化,先在数据库副本上测试,提交前跑 PRAGMA foreign_key_check。如果返回行,你有孤行,需要在迁移安全前决定怎么处理。

选项 2:让 ORM 在应用层处理

大多数 ORM 让你在模型定义里声明关系,即使数据库没有 FK 约束。ORM 在应用代码里强制关系——当你做 user.orders,它跑 SELECT * FROM orders WHERE user_id = ?,当你创建订单,它确保 user_id 设了。

在 Prisma:

model User {
  id     Int     @id @default(autoincrement())
  email  String  @unique
  orders Order[]
}

model Order {
  id      Int  @id @default(autoincrement())
  user_id Int
  user    User @relation(fields: [user_id], references: [id])
  total   Float
}

在 SQLAlchemy:

class User(Base):
    __tablename__ = "users"
    id = Column(Integer, primary_key=True)
    orders = relationship("Order", back_populates="user")

class Order(Base):
    __tablename__ = "orders"
    id = Column(Integer, primary_key=True)
    user_id = Column(Integer)  # ORM 级关系不需要 ForeignKey()
    user = relationship("User", back_populates="orders")

取舍:关系只在你通过 ORM 时存在。 如果有人跑原始 SQL,或不同工具连数据库,或后台任务直接插行,没什么阻止孤 user_id 值。你信任所有写都通过你的应用。对很多小项目那没问题。对有多个写入者或外部工具碰 DB 的,这是漏的保证。

选项 3:在管理工具的 UI 层定义关系

这是我最终用得最多的方式,值得解释因为它解决特定问题:你想使用关系(浏览 join 数据、从父导航到子、建有用的管理界面)而不修改底层 schema。

思路是关系定义在数据库之上的层——在你用来看数据的工具里——而不是在 schema 本身。你的应用代码完全不受影响。schema 保持原样。但当你管理数据时,你得到从「真正」关系期望的 join 视图和导航。

这正是 BaseVolt 做的。它是本地优先桌面应用(macOS 和 Windows),作为 SQLite、PostgreSQL、MySQL 和 Cloudflare D1 的管理面板。你指向数据库,对任意两个表你可以在 UI 里定义关系——选父表、子表、连接列——BaseVolt 为浏览、join 和导航目的把它们当链接的。不用 ALTER TABLE。不用 schema 迁移。不改你应用看到的。

这对 SQLite 特别有用,因为替代是上面的 12 步重建。如果你只想使用关系而不重写 schema,在 UI 层定义它工作量少得多且零风险。

有免费版(最多 2 个数据源),Pro $99/年。demo.basevolt.app 有在线 demo,装之前想看关系 UI 可以看看。因为 BaseVolt 有内置 MCP 服务器,你可以让 Claude 或 Cursor 指向你的数据库,请它帮忙做 schema 工作——包括建议哪里应该有关系。

在管理面板里可视化关系

我来走一遍在 BaseVolt 里定义关系实际长什么样,因为工作流是让它有用的东西。

第 1 步:连接数据库

打开 BaseVolt,点「Add Data Source」,指向你的 SQLite .db 文件。它直接读 schema——不用连接字符串、不用迁移、不用配置。十几个表的数据库,几秒。

第 2 步:选要链接的两个表

在关系视图里,你选父表(如 users)和子表(如 orders)。BaseVolt 显示每个表的列。你选连接字段——users.idorders.user_id——定义关系。就这样。不对你的数据库跑 SQL 来创建这个。定义存在 BaseVolt 的配置里,不在你的 schema 里。

第 3 步:浏览 join 视图

一旦关系定义了,几件事自动发生:

  • 看一行 users 时,你看到显示所有相关 orders 行的链接区域。点进去编辑任何一个。
  • 看一行 orders 时,你看到回到父 user 的链接。
  • 你可以跨两个表建筛选视图而不用写 JOIN

这是文字难传达但用起来明显的部分:你不再想「跑查询看相关数据」,开始想「点那个东西」。对在 sqlite3 CLI 里反复做 SELECT * FROM orders WHERE user_id = 5 的人,这是有意义的生活质量改变。

第 4 步:让 AI 帮忙

因为 BaseVolt 暴露 MCP 服务器,你可以连 Claude 或 Cursor 用自然语言问:「哪些订单没有匹配的用户?」或「建议这些表之间的关系。」AI 可以通过 MCP 服务器检查 schema 和数据,提出你可能漏掉的关系。不是魔法——只是 schema 内省已经在那,MCP 服务器让它对模型可用。

关键点是这些都不碰你的 schema。如果你后来决定关系错了,在 UI 里删了重定义。不用迁移、不用重建、对生产数据零风险。

常见模式

具体说说你会反复建模的三种关系模式,每个带 SQL 和在管理工具里怎么显示。

一对多

最常见模式。一个用户有多个订单。一个项目有多个任务。一个作者有多个帖子。

CREATE TABLE users (
    id INTEGER PRIMARY KEY,
    email TEXT NOT NULL UNIQUE
);

CREATE TABLE orders (
    id INTEGER PRIMARY KEY,
    user_id INTEGER NOT NULL,
    total REAL NOT NULL,
    created_at TEXT NOT NULL DEFAULT (datetime('now')),
    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);

FK 在「多」的一边。在 BaseVolt 里,这显示为:看用户时下面显示其订单;看订单时显示到用户的链接。任一方向一键。

多对多(连接表)

多个学生注册多门课程。你需要第三个表——连接表——每个配对一行。

CREATE TABLE students (
    id INTEGER PRIMARY KEY,
    name TEXT NOT NULL
);

CREATE TABLE courses (
    id INTEGER PRIMARY KEY,
    title TEXT NOT NULL
);

CREATE TABLE enrollments (
    student_id INTEGER NOT NULL,
    course_id INTEGER NOT NULL,
    enrolled_at TEXT NOT NULL DEFAULT (datetime('now')),
    PRIMARY KEY (student_id, course_id),
    FOREIGN KEY (student_id) REFERENCES students(id) ON DELETE CASCADE,
    FOREIGN KEY (course_id) REFERENCES courses(id) ON DELETE CASCADE
);

几点注意:

  • 连接表的主键是两个 FK 列的复合。这防止重复注册。
  • 两个 FK 都用 ON DELETE CASCADE,所以删学生或课程自动清理注册。
  • 连接表可以持有额外数据(这里 enrolled_at)。

在管理工具里,这是两个关系:studentsenrollmentscoursesenrollments。看学生的课程,你导航 student → enrollments → course。BaseVolt 把这当两跳,这是建模它的诚实方式——关系数据库里没有「直接」多对多,只有连接表。

自引用

一个类别有父类别。一个员工有经理,经理也是员工。一条评论有父评论。

CREATE TABLE categories (
    id INTEGER PRIMARY KEY,
    name TEXT NOT NULL,
    parent_id INTEGER,
    FOREIGN KEY (parent_id) REFERENCES categories(id) ON DELETE SET NULL
);

FK 从表指向回自己。parent_id 可空所以根类别可以没父。ON DELETE SET NULL 意味着删类别不删其子——只是把它们孤儿到顶层。如果你想删除沿树级联,用 ON DELETE CASCADE 代替,但小心:那一条语句删整个子树。

自引用关系是在 UI 层定义特别好的情况,因为「父」和「子」是同一个表。在 BaseVolt 里你从 categoriescategoriesidparent_id 上定义关系,然后看类别时内联显示其父和子。

调试 FK 问题

即使 FK 开着,也会出问题。你继承有孤行的数据库。迁移没完全成功。后台任务在 FK 关时插了坏数据。SQLite 给你两个 PRAGMA 来搞清楚发生了什么。

PRAGMA foreign_key_check;

这扫描整个数据库找违反外键约束的行并返回。当你怀疑时跑:

PRAGMA foreign_key_check;
-- 返回行如:
-- orders|42|users|1
-- 意思:表 'orders',rowid 42,违反到 'users' 的 FK,约束 #1

每行告诉你子表、违规行的 rowid、父表、哪个 FK 约束(按索引)被违反。从那你可以 SELECT * FROM orders WHERE rowid = 42 看实际行并决定怎么处理——修 user_id、删行、或插入缺失的父。

这也是 12 步重建末尾、提交前应该跑的。如果返回空,你的迁移一致。

PRAGMA foreign_key_list(table);

这显示特定表上定义的外键约束:

PRAGMA foreign_key_list(orders);
-- 每个 FK 返回一行,显示:
-- id | seq | table  | from     | to | on_update | on_delete | match
-- 0  | 0   | users  | user_id  | id | NO ACTION | CASCADE   | NONE

当你忘了表有什么约束时有用——尤其不是你设计的数据库。fromto 列告诉你哪个本地列指向哪个父列,on_delete / on_update 告诉你动作。

手动找孤行

如果你想为特定关系找孤行而不扫描整个 DB:

SELECT o.*
FROM orders o
LEFT JOIN users u ON o.user_id = u.id
WHERE u.id IS NULL;

这是经典的「找没父的子」查询。如果你只关心一个关系,它比 foreign_key_check 快,且给你完整子行而不只是 rowid。

调试工作流

当关系感觉不对时,这是我会按的顺序:

  1. 在做写入的连接上跑 PRAGMA foreign_keys;。如果是 0,那就是答案。
  2. PRAGMA foreign_key_check; 看损害。
  3. 对每个违规,决定:修子、删子、或插入缺失的父。
  4. PRAGMA foreign_key_list(child_table); 确认约束是你以为的。
  5. 修连接设置让 FK 到处开着,从现在起。

最常见的根本原因,远超其他,是第 1 步。我估计 80% 的「我的 SQLite 外键不工作」问题就是连接 PRAGMA 关着。

总结

SQLite 的外键支持没问题。造成问题的不是缺失功能——是默认值和人体工程学。具体:

  • FK 默认关。 在每个连接上设 PRAGMA foreign_keys = ON,在创建连接的代码里。调试时用 PRAGMA foreign_keys; 验证。
  • PRAGMA 按连接。 你的 ORM 文档会告诉你它怎么处理。如果不,假设它不,加事件钩子。
  • ALTER TABLE 不能加 FK。 修正缺失的关系意味着 12 步表重建,或在应用/UI 层定义关系代替。
  • 支持的引用动作对实际使用足够。 CASCADE、SET NULL、RESTRICT、NO ACTION 都能用。延迟约束如果需要存在。
  • 调试是两个 PRAGMA。 foreign_key_checkforeign_key_list 会告诉你需要的一切。

如果你只想在 SQLite 里使用关系而不重写 schema——浏览 join 数据、在父子行间导航、建管理界面——在 UI 层定义它们而不是跟 ALTER TABLE 斗。这正是 BaseVolt 的用途:指向你的数据库、可视化定义关系、得到可用管理面板而不碰你的 schema 或应用代码。

basevolt.app 试试 — 不用注册,不用信用卡。demo.basevolt.app 有在线 demo,想先看看可以逛逛。

如果这对你有用,我写关于数据库、本地优先软件、和构建开发者工具的不光鲜部分。在 X 上找我。

BasevoltBasevolt

试试 Basevolt —— 免费的本地优先数据库管理面板,支持 PostgreSQL、MySQL、SQLite 和 Cloudflare D1。

免费下载 Basevolt