文档
数据库
SQLite 持久性具有范围连接所有权、类型化查询失败、迁移备份和本地文档存储。
从英文自动翻译。 查看英文原文
持久化模型
@cadcamfun/db 通过以下方式打开 SQLite bun:sqlite 并在 Effect 中公开了一个 Drizzle 数据库 Database 服务。 Sidecar 将其应用程序数据库存储在 <dataDir>/cadcamfun.db。该连接在应用迁移之前启用预写日志记录和外键强制执行。
该数据库包含项目及其相关资源的工作副本。项目版本控制对象、拉取请求和工作流程运行也存储在本地。 AI 提供商凭证使用单独的所有者可读的 JSON 文件;应用程序承载令牌在启动时提供,而不是作为首选项保留。
架构和入口点
packages/db/src/schema.ts 定义表、索引、关系、默认值和推断的选择/插入类型。 src/index.ts 出口 Database, DatabaseError、细雨 Db 类型和模式命名空间。 src/upgrade.ts 拥有迁移规划、备份和事务应用程序; src/migrate.ts 是命令行入口点。生成的 SQL 和迁移日志位于 packages/db/drizzle.
每个打开的连接 packages/db/src/index.ts 将 SQLite 切换到 journal_mode = WAL 并打开 foreign_keys。关闭数据库运行 wal_checkpoint(TRUNCATE) 所以当进程退出时主文件就完成了。如果检查点繁忙或失败,则会记录警告并继续关闭。
存储值
域标识符默认为由表配置的 UUID。 Drizzle 将时间戳列映射到 JavaScript 日期,将 JSON 列映射到声明的记录类型。 HTTP 存储库将日期转换为其有线表示形式并在域边界验证 JSON。存储的 JSON 列上的 TypeScript 类型注释本身并不能证明导入的或较旧的数据与当前域架构匹配。
项目拥有自己的工程图、组件和刀具路径。刀具路径可以参考图纸、材料、刀具和机器配置;他们的 data 现场可携带便携式制造文件。除了整个项目的 Git 历史记录之外,单独的绘图、组件和刀具路径版本表还保留资源版本。
通过服务查询
Database.layer 接受文件路径或 :memory:、一个可选的迁移文件夹和一个可选的备份目录。它获取其 Effect 范围的 SQLite 句柄。呼叫者使用 query(operation, run) 执行同步或异步 Drizzle 工作;外国例外成为 DatabaseError 包含操作名称和原始原因。
import { Database, schema } from "@cadcamfun/db"
import { Effect } from "effect"
const loadProjects = Effect.gen(function* () {
const database = yield* Database
return yield* database.query("projects.list", (db) =>
db.select().from(schema.projectTable).all())
}) 存储库拥有自己的访问规则和行解释;共享查询助手提供失败归因。它不会自动授权查询或围绕任意序列的服务调用创建事务。
升级行为
打开带有迁移文件夹的图层会读取迁移文件并将其名称与 __drizzle_migrations。旧格式的日志通过匹配迁移时间戳和哈希值进行回填。不匹配的日志会使升级失败,而不是猜测迁移身份。
当现有磁盘数据库需要升级时,该服务会使用以下命令创建迁移前 SQLite 备份 VACUUM INTO。它写入部分文件,重命名已完成的快照,并保留最新的五个匹配的迁移前备份。默认备份目录是 backups 在数据库旁边。新数据库和内存数据库不需要此升级前备份。
日志更改和挂起的 SQL 语句在一个即时事务中运行。失败的迁移会回滚事务并导致层构建失败。当数据库初始化失败时,应用程序不应宣布准备就绪。
迁移命令
bun run db:generate
bun run db:migrate 更改后生成迁移 schema.ts,并将生成的 SQL 和日志保存在一起。迁移 CLI 使用 DATABASE_URL 提供时,删除初始 file: 前缀,否则打开 ./data/cadcamfun.db。根包装器运行此脚本 packages/db 作为它的工作目录,所以它的默认目标是 packages/db/data/cadcamfun.db。网络工作室使用 data/cadcamfun.db 在存储库根目录下。打包的 sidecar 使用 CADCAMFUN_DATA_DIR。运行维护时选择显式数据库路径,以便这些入口点不会无意中对不同的文件进行操作。
没有迁移文件夹, Database.layer 打开数据库但不创建应用程序表。需要完整架构的测试为迁移目录提供内存数据库。
数据域
- 身份与协作: 用户、帐户、会话、身份验证器、验证令牌、订阅、组织、成员资格、邀请、对话、消息、通知和推送订阅。
- 设计与制造: 项目、图纸、组件、其版本和注释、材料、工具、机器配置、刀具路径和生产成本记录。
- 申请服务: 文件、库项目、插件注册表/配置、API 密钥、分析和活动记录、市场数据、MCP 配置和本地键值条目。
- 项目历史: Git 存储库、对象和引用、拉取请求和评论以及工作流程运行。
表的存在建立了持久性模型。网络集成和应用程序功能取决于它们的实际处理程序和服务,而不仅仅是表的存在。
本地键值存储
LocalKeyValue 保留 JSON 兼容的应用程序状态。边车 KvStore 支持获取、更新插入、删除和前缀过滤列表。键必须包含非空白文本且最多为 512 个字符。值允许 null、字符串、布尔值、有限数字、数组和普通对象;循环、函数、非有限数和非普通对象将被拒绝。
条目包括更新时间戳。更新插入替换相同键的值。前缀列表按键排序并在内存中进行过滤。使用此存储来获取偏好和辅助上下文;凭证属于他们的专用凭证服务。
关机和恢复
数据库范围终结器请求 PRAGMA wal_checkpoint(TRUNCATE) 在关闭手柄之前。繁忙的检查点或关闭失败会被记录为警告,以便剩余的终结器可以运行。连接所有者(而不是各个 HTTP 处理程序)关闭句柄。
为了获得一致的手动备份,请在复制其数据目录之前彻底停止应用程序,或者在其运行时使用 SQLite 的备份工具。实时数据库可以在其 WAL 文件中拥有状态。要检查迁移前备份,请处理副本并保留原始数据库和备份,直到确认恢复为止。
存储库职责
将 SQLite 连接创建和迁移逻辑保留在此包中。 Sidecar 存储库使用提供的数据库服务并将存储故障映射到其资源的 HTTP 错误。将几何计算保留在核心中,并将秘密保留在凭证或令牌服务中。数据库备份包含项目和帐户数据,并且需要与活动数据目录相同的访问控制。
检查数据库而不更改它
干净关闭后,针对显式数据库路径使用 SQLite CLI。只读模式可避免在路径错误时意外创建新的空文件。这些检查检查结构和一致性,而不会暴露帐户或凭证行。
sqlite3 -readonly /absolute/path/cadcamfun.db 'PRAGMA quick_check;'
sqlite3 -readonly /absolute/path/cadcamfun.db 'PRAGMA foreign_key_check;'
sqlite3 -readonly /absolute/path/cadcamfun.db "SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name;"
sqlite3 -readonly /absolute/path/cadcamfun.db 'SELECT name, applied_at FROM __drizzle_migrations ORDER BY id;' quick_check 通常报告 ok; foreign_key_check 仅返回违规行。一本没有的日记 name/applied_at columns 是一种较旧的格式,应用程序升级代码必须在使用最后一个查询之前协调该格式。在尝试修复之前检查路径和迁移身份。
应用程序在其自己的连接上设置外键强制执行。单独打开的维护连接必须在进行授权写入之前显式启用它; PRAGMA 是特定于连接的。
来源: packages/db/src/index.ts, upgrade.ts, 和 database.test.ts.
在代码中使用一次性数据库
完整的内存中服务范围对于存储库示例和隔离诊断非常有用。范围拥有连接并在效果读取服务之前应用签入的迁移。
import { Database, schema } from "@cadcamfun/db"
import { Effect } from "effect"
const migrationsFolder = new URL("../../packages/db/drizzle", import.meta.url).pathname
const program = Effect.gen(function* () {
const database = yield* Database
return yield* database.query("diagnostic.projects", (db) =>
db.select({ id: schema.projectTable.id }).from(schema.projectTable).all())
}).pipe(Effect.provide(Database.layer({ path: ":memory:", migrationsFolder })))
console.log(await Effect.runPromise(program)) // [] 将此脚本另存为 apps/sidecar/diagnostic.ts 并运行 bun apps/sidecar/diagnostic.ts 从存储库根目录。该包提供数据库和 Effect 依赖项,并且迁移 URL 从该文件解析。丢失或错误的迁移目录并不等同于空的应用程序架构。细雨 query 回调可以运行同步查询或返回承诺;帮助器将失败包装在提供的操作标签下。
来源: packages/db/src/database.test.ts 和 index.ts.
诊断存储故障
- 没有这样的表: 确认确切的数据库文件和迁移文件夹。服务允许在不迁移的情况下打开,但查询应用程序表会失败。
- 外键约束失败: 检查引用的所有者/项目/资源。删除限制是特定于架构的:删除具有所属项目的用户受到限制,而删除用户时会话行会级联。
- 升级期间备份失败: 检查数据库和备份目录中的可用空间和写入权限。备份失败停止迁移;不要删除原始数据库来绕过它。
- 旧迁移日志与本地迁移不匹配: 保留数据库并恢复匹配的迁移集。重命名或删除日志行将隐藏不兼容的升级历史记录。
- 检查点忙于关闭: 在仅复制数据库文件之前检查其他打开的连接。保留 WAL/SHM 同级,直到干净的检查点或一致的 SQLite 备份完成。
迁移备份是具有五个文件保留策略的升级前恢复点,而不是后续编辑的连续备份。根据安装的恢复要求,单独备份完整的数据目录。
来源: packages/db/src/index.ts, upgrade.ts, schema.ts, 和 database.test.ts.
迁徙历史
签入的迁移按文件夹顺序应用。每个文件夹包含一个 migration.sql 和毛毛雨套件 snapshot.json 用于计算下一个差异。日志行存储文件夹名称、哈希值、文件夹时间戳(以毫秒为单位)(created_at),以及应用的 ISO 时间(applied_at).
| 文件夹 | 尺寸 | 它创造了什么 |
|---|---|---|
20261004223029_careful_monster_badoon | 771行 | The baseline schema converted from the legacy Prisma model: identity, organizations, projects, drawings, components, toolpaths, libraries, costs, marketplace, plugins and MCP configuration.表和列名称未更改,因此较旧 dev.db 数据保持可读。 |
20261008030518_redundant_dagger | 73行 | 项目历史: GitRepo, GitObject, GitRef, PullRequest, PullRequestReview, WorkflowRun. |
20261009012351_moaning_loki | 28行 | 几何内核记录: MeshAsset 和 CadOperationLog. |
20261009171602_brown_mad_thinker | 5行 | LocalKeyValue (key 文本主键, value 文本不为空, updatedAt 整数不为空)。 |
文件夹名称来自 drizzle-kit:UTC 时间戳后跟随机名称。一旦文件夹被应用到任何地方,就不要重命名或编辑它;升级规划器通过名称来识别应用的迁移,对于较旧的日志,通过时间戳和哈希来识别。
升级决策流程
| 情况 | 备份 | 结果 |
|---|---|---|
| 新文件,给出的迁移文件夹 | 否(打开前文件不存在) | 所有迁移都在一笔事务中应用 |
| 现有文件、日志是最新的 | 不 | 按原样打开; needed 是假的 |
| 现有文件,待迁移 | 是的,在任何改变之前 | 已应用待定迁移;日记帐行插入 applied_at |
现有文件、日志无 name 柱子 | 是的 | 专栏 name 和 applied_at 添加,回填行,然后应用挂起的迁移 |
| 旧行与本地迁移不匹配 | 不采取备份:计划首先失败 | DatabaseError 与操作 migrate:“旧迁移日志与本地迁移不匹配” |
:memory: | 绝不 | 如果给定文件夹则应用迁移 |
| 没有迁移文件夹 | 绝不 | 不创建表打开 |
回填匹配的工作原理如下:遗留行的 created_at 被截断为整秒并与每个迁移的文件夹时间戳进行比较。如果只有一个迁移匹配,则使用它。否则,哈希会打破平局,并且作为最后的手段,使用具有相同哈希的任何迁移。
备份文件剖析
backups/cadcamfun-20261015-034512.123-6f1c2d4e-8a90-4b7c-9e21-3f5a6b7c8d9e-pre-migrate.db
└ prefix ┘ └ UTC timestamp ┘ └ random UUID ┘ - 前缀是数据库文件名,不带前缀
.db,后跟破折号。 - 时间戳为 UTC,精度为毫秒。如果最新的现有备份具有相同或较晚的时间戳,则新备份会在其之后设置 1 毫秒,因此词汇顺序始终等于创建顺序。
- 快照被写入
<name>.partial和VACUUM INTO,然后重命名。一次崩溃最多留下一个.partial文件,该文件在finally堵塞。 - 成功备份后,仅保留与确切模式匹配的五个最新文件。您以其他名称复制到目录中的文件永远不会被删除。
VACUUM INTO生成一个紧凑的、独立的数据库,没有 WAL 兄弟,因此可以直接打开备份。
DATA="/absolute/path/to/data"
cp "$DATA/cadcamfun.db" "$DATA/cadcamfun.db.broken" # keep the failed state
rm -f "$DATA/cadcamfun.db-wal" "$DATA/cadcamfun.db-shm"
cp "$DATA/backups/cadcamfun-<timestamp>-<uuid>-pre-migrate.db" "$DATA/cadcamfun.db"
sqlite3 -readonly "$DATA/cadcamfun.db" 'PRAGMA quick_check;' # expect: ok 恢复回滚架构和数据
恢复的备份是升级前的状态。升级后所做的编辑不包含在其中,下次启动时将再次尝试相同的迁移。重新启动之前修复故障原因。
核心台形状
这些是桌面编辑器、历史记录和内核最常阅读的表格。列名是 SQL 名称;毛毛雨将它们暴露在相同的键下。
| 桌子 | 专栏 |
|---|---|
Project | id, name, description, createdAt, updatedAt, ownerId, organizationId, isPublic |
Drawing | id, name, description, data, thumbnail, createdAt, updatedAt, projectId |
Component | id, name, description, data, thumbnail, type, isPublic, createdAt, updatedAt, projectId |
Toolpath | id, name, description, data, type, operationType, gcode, thumbnail, isPublic, projectId, createdAt, updatedAt, createdBy, drawingId, materialId, toolId, machineConfigId |
GitRepo | projectId, head, remote, createdAt |
GitObject | projectId, oid, type, body |
GitRef | projectId, name, oid |
PullRequest | id, projectId, number, title, body, head, base, state, authorId, authorKind, mergeCommit, remoteNumber, createdAt, updatedAt |
WorkflowRun | id, projectId, workflow, event, ref, sha, status, events, startedAt, finishedAt |
MeshAsset | id, projectId, kind, inputs, stats, mesh, createdAt |
CadOperationLog | id, projectId, operation, meshAssetId, outcome, durationMs, inputs, error, traceId, spanId, createdAt |
LocalKeyValue | key, value, updatedAt |
删除项目会删除其图纸、组件、刀具路径、历史对象和参考、拉取请求、工作流程运行、网格资产和操作日志。版本和注释行从其父资源级联。一个 CadOperationLog 行在删除其网格资产后仍然存在 meshAssetId 设置为空。到材料、刀具和机器配置的刀具路径链接没有级联,因此当刀具路径指向参考材料时,无法删除它。
全表目录
该架构定义了 60 表。下面的列表是根据 Drizzle 表定义生成的:SQL 名称、导出常量、列数、声明的索引数以及外键及其删除规则。 “无操作”是 SQLite 的默认设置:当引用行存在时删除失败(在应用程序连接上强制使用外键)。
| SQL表 | 出口 | 科尔斯 | idx | 外键 |
|---|---|---|---|---|
Account | accountTable | 13 | 1 | userId → 用户(级联) |
ActivityLog | activityLogTable | 7 | 0 | userId → 用户(无操作) |
AIAnalysisLog | aIAnalysisLogTable | 7 | 0 | userId → 用户(无操作) |
ApiKey | apiKeyTable | 7 | 1 | userId → 用户(级联) |
authenticators | authenticatorTable | 8 | 0 | userId → 用户(级联) |
CadOperationLog | cadOperationLogTable | 11 | 1 | projectId → 项目(级联)meshAssetId → MeshAsset(设置为空) |
Component | componentTable | 10 | 0 | projectId → 项目(级联) |
ComponentComment | componentCommentTable | 6 | 0 | componentId → 组件(级联)userId → 用户(无操作) |
ComponentVersion | componentVersionTable | 6 | 0 | componentId → 组件(级联)userId → 用户(无操作) |
Conversation | conversationTable | 6 | 0 | organizationId → 组织(级联) |
ConversationParticipant | conversationParticipantTable | 5 | 1 | userId → 用户(级联)conversationId → 对话(级联) |
CostSettings | costSettingsTable | 11 | 0 | ownerId → 用户(无操作)organizationId → 组织(无行动) |
Drawing | drawingTable | 8 | 0 | projectId → 项目(级联) |
DrawingVersion | drawingVersionTable | 5 | 0 | drawingId → 绘图(级联) |
FileUpload | fileUploadTable | 12 | 0 | — |
GitObject | gitObjectTable | 4 | 1 | projectId → 项目(级联) |
GitRef | gitRefTable | 3 | 1 | projectId → 项目(级联) |
GitRepo | gitRepoTable | 4 | 0 | projectId → 项目(级联) |
LibraryItem | libraryItemTable | 14 | 0 | ownerId → 用户(无操作)organizationId → 组织(无行动) |
LocalKeyValue | localKeyValueTable | 3 | 0 | — |
MachineConfig | machineConfigTable | 9 | 0 | ownerId → 用户(无操作) |
MarketplaceCategory | marketplaceCategoryTable | 10 | 1 | parentId → 市场类别(无操作) |
MarketplaceCollection | marketplaceCollectionTable | 10 | 1 | sellerId → 市场卖家(无操作) |
MarketplaceCollectionItem | marketplaceCollectionItemTable | 5 | 1 | collectionId → MarketplaceCollection(级联)itemId → 市场项目(级联) |
MarketplaceItem | marketplaceItemTable | 30 | 5 | sellerId → 市场卖家(无操作) |
MarketplaceOrder | marketplaceOrderTable | 23 | 4 | buyerId → 用户(无操作)sellerId → 市场卖家(无操作)itemId → MarketplaceItem(无操作) |
MarketplacePayout | marketplacePayoutTable | 12 | 1 | sellerId → 市场卖家(无操作) |
MarketplaceReview | marketplaceReviewTable | 12 | 2 | itemId → MarketplaceItem(无操作)buyerId → 用户(无操作)orderId → 市场订单(无操作) |
MarketplaceSeller | marketplaceSellerTable | 19 | 0 | userId → 用户(无操作) |
MarketplaceWishlist | marketplaceWishlistTable | 4 | 1 | userId → 用户(级联)itemId → 市场项目(级联) |
Material | materialTable | 9 | 0 | ownerId → 用户(无操作)organizationId → 组织(无行动) |
MaterialCost | materialCostTable | 10 | 0 | materialId → 材料(级联)ownerId → 用户(无操作)organizationId → 组织(无行动) |
MCPServerConfig | mCPServerConfigTable | 11 | 1 | — |
MeshAsset | meshAssetTable | 7 | 1 | projectId → 项目(级联) |
Message | messageTable | 8 | 1 | senderId → 用户(级联)conversationId → 对话(级联)fileId → 文件上传(设置为空) |
Notification | notificationTable | 12 | 0 | userId → 用户(级联)organizationId → 组织(级联)projectId → 项目(级联)componentId → 组件(级联)messageId → 消息(级联) |
OperationCost | operationCostTable | 10 | 0 | ownerId → 用户(无操作)organizationId → 组织(无行动) |
Organization | organizationTable | 5 | 0 | — |
OrganizationInvitation | organizationInvitationTable | 7 | 0 | organizationId → 组织(级联) |
passkey | passkeyTable | 11 | 0 | userId → 用户(级联) |
PluginConfiguration | pluginConfigurationTable | 2 | 0 | pluginId → PluginRegistryEntry(级联) |
PluginRegistryEntry | pluginRegistryEntryTable | 9 | 0 | — |
ProductionCostEstimate | productionCostEstimateTable | 17 | 0 | toolpathId → 刀具路径(级联)operationCostId → 运营成本(不采取行动)ownerId → 用户(无操作)organizationId → 组织(无行动) |
Project | projectTable | 8 | 0 | ownerId → 用户(无操作)organizationId → 组织(无行动) |
PullRequest | pullRequestTable | 14 | 1 | projectId → 项目(级联)authorId → 用户(无操作) |
PullRequestReview | pullRequestReviewTable | 8 | 1 | pullRequestId → PullRequest(级联)reviewerId → 用户(无操作) |
PushSubscription | pushSubscriptionTable | 6 | 0 | userId → 用户(级联) |
Session | sessionTable | 8 | 0 | userId → 用户(级联) |
Subscription | subscriptionTable | 24 | 3 | referenceId → 用户(级联) |
Token | tokenTable | 8 | 2 | userId → 用户(级联) |
Tool | toolTable | 17 | 0 | ownerId → 用户(无操作)organizationId → 组织(无行动) |
Toolpath | toolpathTable | 17 | 0 | projectId → 项目(级联)createdBy → 用户(无操作)drawingId → 绘图(无动作)materialId → 材料(无动作)toolId → 工具(无动作)machineConfigId → MachineConfig(无操作) |
ToolpathComment | toolpathCommentTable | 6 | 0 | toolpathId → 刀具路径(级联)userId → 用户(无操作) |
ToolpathVersion | toolpathVersionTable | 7 | 0 | toolpathId → 刀具路径(级联)userId → 用户(无操作) |
ToolWearCost | toolWearCostTable | 10 | 0 | toolId → 工具(级联)ownerId → 用户(无操作)organizationId → 组织(无行动) |
twoFactor | twoFactorTable | 4 | 0 | userId → 用户(级联) |
User | userTable | 17 | 0 | — |
UserOrganization | userOrganizationTable | 5 | 1 | userId → 用户(级联)organizationId → 组织(级联) |
verification_tokens | verificationTokenTable | 3 | 1 | — |
WorkflowRun | workflowRunTable | 10 | 1 | projectId → 项目(级联) |
安全书写
- 将 Drizzle 事务中的多语句更改包装在一个事务中
query称呼:database.query("op", (db) => db.transaction((tx) => ...))。分离query调用是单独的事务。 - 为每个查询提供一个稳定的、点分的操作名称(
projects.list,toolpaths.update)。这是唯一的上下文DatabaseError除了原因之外,它还出现在日志中。 - 生成 id
newId()(UUID)或让列默认执行此操作;不要根据名称构建 id。 - 读取 JSON 列时验证它们。 SQLite 不会检查声明的 TypeScript 类型。
- 使用
LocalKeyValue通过 sidecar 键值服务而不是直接写入行,因此应用键和值验证。
import { Database, schema } from "@cadcamfun/db"
import { eq } from "drizzle-orm"
import { Effect } from "effect"
const renameProject = (id: string, name: string) => Effect.gen(function* () {
const database = yield* Database
return yield* database.query("projects.rename", (db) =>
db.transaction((tx) => {
const updated = tx.update(schema.projectTable)
.set({ name, updatedAt: new Date() })
.where(eq(schema.projectTable.id, id))
.returning().all()
if (updated.length === 0) throw new Error(`project ${id} not found`)
return updated[0]
}))
}) 要创建具有显式 id 的行,请使用 schema.newId(); @cadcamfun/db 将架构模块重新导出为 schema 命名空间,而不是顶级名称。