add-domain-field
GitHub指导如何在Tauri Rust应用中为领域模型添加新字段,涵盖类型定义、数据库仓库五处手动SQL修改、迁移脚本编写及前后端同步。
触发场景
安装
npx skills add openfootmanager/openfootmanager --skill add-domain-field -g -y
SKILL.md
Frontmatter
{
"name": "add-domain-field",
"description": "Add a field to a domain type so it survives save\/load. Covers serde(default) for backward compatibility, the five positional edit sites in the db repositories, the SQL migration and MIGRATION_COUNT bump, the round-trip test, and the frontend type plus translated label.",
"when_to_use": "Adding a field to Team, Player, Staff, Manager, League, or any other type in the domain crate; or when a new field works in memory but is empty after saving and reloading.",
"allowed-tools": "Read, Edit, Write, Grep, Glob, Bash(cargo test*), Bash(cargo clippy*), Bash(cargo fmt*)",
"argument-hint": "[type and field, e.g. Team.youth_budget]"
}
Adding a domain field
The trap: #[serde(default)] makes old saves load, but it does nothing for SQLite. The
repositories in src-tauri/crates/db/src/repositories/ use hand-written positional SQL, so a
field that isn't in the column lists is silently dropped on every save/load round trip — with no
error, no warning, and a passing test suite.
Do all six steps.
1. The domain type
src-tauri/crates/domain/src/ — team.rs, player.rs, staff.rs, manager.rs, league.rs, …
pub struct Team {
// …
#[serde(default)]
pub youth_budget: i64,
}
Use #[serde(default = "default_youth_budget")] with a function when zero, empty, or None is
the wrong value for an old save. Think about what a save written before this feature existed
should look like once loaded — that is what the default has to produce.
Domain types hold data, not logic. Behaviour goes in ofm_core.
2. The repository — five edit sites
Take Team as the worked example; team_repo.rs is the reference for the rest.
Open src-tauri/crates/db/src/repositories/team_repo.rs and edit all five:
-
save_team'sINSERT OR REPLACE INTO teams (…)column list — add the column. -
Its
VALUES (?1, ?2, …, ?N)run — add one more placeholder. The count must match the column list exactly or the insert fails at runtime. -
The
params![…]list — add the value, in the same position as the column. -
row_to_team's positional mapping —row.get(N)?. Appending at the end is safest; inserting in the middle shifts every index after it, and nothing will tell you if you miss one. -
Both
SELECTcolumn lists —load_all_teamsandload_team. Two separate strings that must stay in sync. For a column added by a migration, wrap it asCOALESCE(youth_budget, <default>)so rows written before the migration read back as the default — the existing JSON columns (media_json,player_roles_json, …) show the pattern.<default>must be the same value your serde default produces. These are two independent fallbacks for the same field: serde covers a save loaded from JSON,COALESCEcovers a row written before the migration. If step 1 used#[serde(default = "default_youth_budget")]returning500_000, thenCOALESCE(youth_budget, 0)makes the same save load differently depending on which path it came through. Either match the two, or give the column a non-null SQLDEFAULTin the migration and drop theCOALESCE.
Other repositories in that directory (player_repo.rs, staff_repo.rs, competition_repo.rs, …)
follow the same five-site shape.
3. The migration
- Add
src-tauri/crates/db/src/sql/vNNN_<short_description>.sql, numbered after the current highest file. - Register it in
src-tauri/crates/db/src/migrations.rs(all_migrations()). - Bump
MIGRATION_COUNTat the top of that file — the migration tests assert on it.
Migrations are append-only and must be idempotent; migrations.rs has a test that re-applies them
(test_migrations_are_idempotent). Use ALTER TABLE … ADD COLUMN … DEFAULT …; never rewrite or
renumber an existing migration, because shipped saves have already run it.
4. Prove it round-trips
This is the step that catches the dropped-column bug, so write it before the repository edits.
In src-tauri/crates/db/, extend the nearest existing round-trip test: build a value with the new
field set to something non-default, save it, load it back, assert the field survived. A test
that uses the default value passes even when the column is missing entirely.
cargo test --manifest-path src-tauri/Cargo.toml -p db
cargo test --manifest-path src-tauri/Cargo.toml --workspace
5. The rest of the backend
- Game logic for the field goes in
ofm_core, notdomain. - If the field should reach the match engine, do not import
domainintoengine. Add the field to the engine's own mirror type and extend the conversion inofm_core/turn/. - If an AI agent should be able to read or set it, see
/add-mcp-tool.
6. The frontend
- Add the field to the matching TypeScript type (
src/store/types.tsor the relevant service types). Optional (?) if old saves may not have it. - Any label, unit, or tooltip for it is a user-facing string →
/add-ui-string, all 11 locales.
Checklist
- Field added to the
domaintype with#[serde(default)]or an explicit default fn - Default value is correct for a save written before the field existed
- Repository INSERT column list updated
-
VALUES (?1…?N)placeholder count updated to match -
params![…]updated, same order -
row_to_*positionalrow.get(N)?updated - Both SELECT lists updated, with
COALESCEfor the migrated column -
sql/vNNN_*.sqladded and registered inmigrations.rs -
MIGRATION_COUNTbumped - Round-trip test with a non-default value, written first
-
cargo test --workspaceandcargo clippy --workspace --all-targetsgreen - Frontend type updated; any new label translated into all 11 locales
版本历史
- 9d401d6 当前 2026-07-30 22:45


