Files
2026-moving-helper/docs/design/step-1-alembic-foundation.md
T
tliu93 8b8bd9f38f
test / pytest (push) Successful in 1m34s
Add Alembic migration foundation
2026-06-01 16:02:43 +02:00

120 lines
9.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 步骤 1 · Alembic 迁移地基 / Step 1 · Migration Foundation
> **可独立执行 / Self-contained.** 完整背景见设计文档 [`llm-integration-design.md`](./llm-integration-design.md) §3;跨步骤约定见 [`implementation-plan.md`](./implementation-plan.md)。
> **前置 / Prerequisite** 无(第一步)/ none.
> **产出 / Output** 一个可独立合入的 PR**不改任何业务 schema**。A mergeable PR with **zero business-schema change**.
---
## 目标 / Goal
引入 Alembic 并**安全接管现有生产库**,schema 一点不改,所有现有测试保持绿。**迁移与应用启动分离**:启动只做只读校验 + fail-close,实际迁移由独立、幂等命令 `python -m app.migrate` 执行。
Introduce Alembic and **safely adopt the existing prod DB** with zero schema change; all tests stay green. **Migration is separated from startup**: boot only verifies (read-only) and fails closed; the actual migrating is done by a separate idempotent command `python -m app.migrate`.
---
## 必要背景 / Essential Context(仅凭本文件即可执行 / enough to execute from this file
- **当前没有 Alembic。** 唯一的"迁移"是 `app/db.py::_sync_sqlite_image_columns()`(启动时缺图片列就 `ALTER TABLE ADD COLUMN`)。
No Alembic today; the only "migration" is the hand-rolled image-column sync in `app/db.py`.
- `app/db.py::init_db()` 在 FastAPI lifespan 启动时被 `create_app()` 调用,现在执行 `Base.metadata.create_all()` + `_sync_sqlite_image_columns()`。**本步把它改成只读校验**(不再在启动时建表/迁移)。相关符号:`Base``engine``SessionLocal``configure_database()`
`init_db()` runs at lifespan startup and currently does `create_all()` + the image-column sync. **This step turns it into a read-only check** (no table creation/migration at boot).
- `tests/conftest.py``client` fixture`configure_database(tmp_url)``create_app()`(触发 `init_db`)。每个测试用临时 SQLite,互不污染。
- models 在 `app/models.py``Box` / `Item` / `SubItem` 三张表;每张含 `image_blob`(BLOB) / `image_mime_type` / `image_width` / `image_height`,以及 `created_at` / `updated_at`
- DB URL 来自 `app/config.py::get_settings().database_url`(默认 `sqlite:///./data/app.db`)。
- **生产库**是当年 `create_all` 建的、**已装上千件数据、没有 `alembic_version` 表**。
### 铁律 / The Invariant(不可违背 / non-negotiable
- 所有数据库最终收敛到同一个 `head`。All DBs converge to the same `head`.
- **V1 baseline 必须严格等于"今天的真实 schema"**(三张表 + 现有图片列 + 索引),**不多一列**。新东西放后续 revision。
The V1 baseline must equal **today's actual schema exactly** — nothing more.
- 以下动作**由迁移命令执行,不在应用启动时** / done by the **migration command**, not at boot
- 老库且与 baseline 一致:`stamp V1`(只写版本号,**不建表、不碰数据**)→ `upgrade head`
Existing DB matching baseline: `stamp V1` (no DDL, no data change) → `upgrade head`.
- 老库但与 baseline 不一致:**fail-close,不做任何改动**。Mismatched existing DB → fail closed.
- 新库:跑 `V1`(真正建表)→ `upgrade head`。Fresh DB: run `V1``upgrade head`.
---
## 任务 / Tasks
- [ ] `requirements.txt` 增加 `alembic`(钉一个明确版本 / pin a version)。
- [ ] 初始化 Alembic 工程:`alembic.ini` + `migrations/`(含 `env.py``versions/`)。
- [ ] 配置 `migrations/env.py`
- `target_metadata = app.db.Base.metadata`(确保导入 `app.models` 以注册三张表)。
- `sqlalchemy.url` **从 `app.config.get_settings().database_url` 动态读取**,不写死在 `alembic.ini`
- 对 SQLite 设 `render_as_batch=True`(为未来改列/删列预留 batch 能力)。
- [ ] 生成 **V1 baseline 迁移**=当前 models 的完整建表(`boxes`/`items`/`subitems`,含图片列与索引)。做法:对**空库** `--autogenerate`
Author V1 by autogenerating against an **empty** DB.
- [ ] **验证 baseline**:对一份**生产库副本**跑 `alembic check`,确认**无差异**(印证可安全 `stamp`;SQLite 偶有类型亲和/索引命名假差异,人眼复核)。
Verify with `alembic check` against a **copy of the prod DB** → expect no diff.
- [ ] 新增 `app/migrate.py`,承担两个职责 / two responsibilities
- **(A) 迁移命令入口 `python -m app.migrate`(幂等 / idempotent**:编程方式构造 Alembic `Config``script_location` → 打包进镜像的 `migrations/``sqlalchemy.url` = 解析出的 URL),用 SQLAlchemy inspector 分情况:
- 空库 / empty → `command.upgrade(cfg, "head")`
- 老库且与 **baseline(V1)** 一致 → `command.stamp(cfg, "<V1 rev>")``command.upgrade(cfg, "head")`
- 老库但与 baseline 不一致 → **fail-close**:非零退出 + 清晰日志 + **不做任何改动**
- 已在 `head` → 空操作、退出 0
- `<V1 rev>`**baseline 这个具体 revision**`down_revision=None` 的那条),不是 `head`
- "与 baseline 一致"的判定**对照 baseline(V1) 的预期 schema**(不是 head);SQLite 假差异需容忍或允许人工确认覆盖。
- **(B) 启动校验 `verify_schema_is_current(url)`(只读 / read-only**:比较 DB 当前 revision 与 `head`;不一致返回失败/抛错,**绝不改动 DB**。
- [ ]`app/db.py::init_db()`:改为调 `verify_schema_is_current(resolved_url)` —— **一致才放行;不一致 fail-close**(清晰日志,提示先跑 `python -m app.migrate`)。不再在启动时建表/迁移。**删除** `_sync_sqlite_image_columns()`。保留 `configure_database()` / engine 装配。
`init_db()` now only verifies and **fails closed** on mismatch (pointing the user to `python -m app.migrate`); remove `_sync_sqlite_image_columns()`.
- [ ] `tests/conftest.py`fixture 改为**先跑迁移命令**把临时库带到 `head`,再 `create_app()`(这样启动校验通过)。
Fixture runs the migration first, then `create_app()`.
- [ ] `Dockerfile`:加 `COPY alembic.ini .``COPY migrations ./migrations`(否则容器内无迁移脚本)。
- [ ] CI(可选 / optional):`.github/workflows/test.yml` 加一步 `alembic check`,防止 model 与迁移漂移。
- [ ] Compose `db-migration` 闸门(可后续 / can be deferred):加一个一次性服务跑 `python -m app.migrate``web``depends_on: condition: service_completed_successfully` 等它成功(见设计 §3.5)。
Add a one-shot `db-migration` service gating `web` (design §3.5); may be deferred.
---
## 涉及文件 / Files
`requirements.txt``alembic.ini`(新)、`migrations/**`(新)、`app/migrate.py`(新)、`app/db.py``tests/conftest.py``Dockerfile`、(可选)`.github/workflows/test.yml`、(可后续)`docker-compose.yml`
---
## 测试 / Tests
- [ ] 现有 ~83 个测试全绿(fixture 先跑迁移、再起 App,启动校验通过)。
All existing ~83 tests pass (fixture migrates first, then starts the app).
- [ ] **认领老库(2a**:构造"有 `boxes` 数据、无 `alembic_version`"的库(可先用 `create_all` 造)→ 跑迁移命令 → 断言数据保留、版本到达 `head`、未重复建表报错。
Adoption (2a): migrate an un-stamped populated DB → data preserved, version at `head`.
- [ ] **全新库**:空 URL → 跑迁移命令 → 三张表存在、版本到 `head`
Fresh DB: empty URL → migrate → tables exist, version at `head`.
- [ ] **fail-close(启动)**DB 未到 `head``create_app()` / `init_db()` 启动应 fail-close(抛错/拒绝服务)、不改动 DB。
Startup fails closed when the DB is not at `head`; DB unchanged.
- [ ] **fail-close2b**:构造与 baseline 不一致的老库 → 跑迁移命令 → 断言非零退出、DB 不变。
Migration command fails closed on a 2b mismatch; DB unchanged.
---
## 验收 / Acceptance
- 迁移命令:空库建到 `head`;老库一致则认领并到 `head`;老库不一致则 **fail-close 不改动**;已在 `head` 则幂等空操作。
Migration command: empty→head; matching existing→adopt+head; mismatch→fail closed; already-at-head→no-op.
- 启动校验:DB 未到 `head` 时**拒绝启动**并输出清晰日志;到 `head` 才正常起。
Startup refuses to boot (clear log) unless the DB is at `head`.
- 模拟老库认领后**数据无损**。Adopted existing-like DB keeps data intact.
- 全部测试绿;schema 与本步骤前**逐列一致**(本步不改业务 schema)。
All tests green; schema identical to before (no business-schema change).
---
## 风险与缓解 / Risks & Mitigations
- **baseline 与现状有偏差 → `stamp` 失真。** 缓解:`alembic check` 对生产副本校验 + 人眼复核 SQLite 假差异。
Baseline drift → `alembic check` against a prod copy + manual eyeball.
- **2b 一致性比对假阳性 → 合法老库被误 fail-close。** 缓解:比对基准用 baseline(V1) 而非 head;容忍已知 SQLite 噪声,或提供"人工确认覆盖"的开关。
2b false positives wrongly fail a legit DB → compare against baseline (not head); tolerate known SQLite noise or offer a manual-confirm override.
- **容器内找不到迁移脚本。** 缓解:确认 `Dockerfile``COPY` `alembic.ini``migrations/``script_location` 用绝对/相对镜像 WORKDIR(`/app`) 正确解析。
Migrations missing in image → ensure they're `COPY`-ed and `script_location` resolves under `/app`.
---
## 相关约定 / Conventions(详见 implementation-plan.md
- 不主动 push/commit,除非业主要求。Don't push/commit unless asked.
- 实现与设计若有偏差 → 回写设计文档 §3 与仓库简报 `../repository-brief.md` §10。