axiom-database-migration by charleswiltgen/axiom
npx skills add https://github.com/charleswiltgen/axiom --skill axiom-database-migration为包含用户数据的生产环境应用提供安全的数据库模式演进方案。核心原则 迁移脚本在发布后不可更改。确保迁移是增量的、幂等的,并经过充分测试。
以下是开发者实际会提出的问题,本技能旨在解答:
→ 本技能涵盖添加列的安全增量模式,包括幂等性检查,确保不丢失现有数据
→ 本技能解释为何 NOT NULL 列在存在现有行时会失败,并展示安全模式(先设为可空,后续再回填数据)
→ 本技能演示安全模式:添加新列 → 迁移数据 → 弃用旧列(切勿删除)
→ 本技能涵盖安全的外键模式:添加列 → 填充数据 → 添加索引(并解释 SQLite 的限制)
→ 本技能解释迁移脚本在发布后不可更改的原则;展示如何创建新迁移来修复问题,而不是修改旧的迁移脚本
❌ 切勿对包含用户数据的表使用 DROP TABLE ❌ 切勿修改已发布的迁移脚本(应创建新的迁移) ❌ 切勿通过重建表来更改模式(会导致数据丢失) ❌ 切勿添加没有 DEFAULT 值的 NOT NULL 列 ❌ 切勿删除列(SQLite 不支持安全的 DROP COLUMN 操作)
广告位招租
在这里展示您的产品或服务
触达数万 AI 开发者,精准高效
// ✅ 安全模式
func migration00X_AddNewColumn() throws {
try database.write { db in
// 1. 检查列是否存在(幂等性)
let hasColumn = try db.columns(in: "tableName")
.contains { $0.name == "newColumn" }
if !hasColumn {
// 2. 添加为可空列(适用于现有行)
try db.execute(sql: """
ALTER TABLE tableName
ADD COLUMN newColumn TEXT
""")
}
}
}
// ✅ 带默认值的安全模式
func migration00X_AddColumnWithDefault() throws {
try database.write { db in
let hasColumn = try db.columns(in: "tracks")
.contains { $0.name == "playCount" }
if !hasColumn {
try db.execute(sql: """
ALTER TABLE tracks
ADD COLUMN playCount INTEGER DEFAULT 0
""")
}
}
}
模式:添加新列 → 迁移数据 → 弃用旧列(切勿删除)
// ✅ 更改列类型的安全模式
func migration00X_ChangeColumnType() throws {
try database.write { db in
// 步骤 1:添加新类型的新列
try db.execute(sql: """
ALTER TABLE users
ADD COLUMN age_new INTEGER
""")
// 步骤 2:迁移现有数据
try db.execute(sql: """
UPDATE users
SET age_new = CAST(age_old AS INTEGER)
WHERE age_old IS NOT NULL
""")
// 步骤 3:应用代码后续使用 age_new
// (切勿删除 age_old 列 - 只需停止使用它)
}
}
// ✅ 外键的安全模式
func migration00X_AddForeignKey() throws {
try database.write { db in
// 步骤 1:添加新列(初始设为可空)
try db.execute(sql: """
ALTER TABLE tracks
ADD COLUMN album_id TEXT
""")
// 步骤 2:填充数据
try db.execute(sql: """
UPDATE tracks
SET album_id = (
SELECT id FROM albums
WHERE albums.title = tracks.album_name
)
""")
// 步骤 3:添加索引(提升查询性能)
try db.execute(sql: """
CREATE INDEX IF NOT EXISTS idx_tracks_album_id
ON tracks(album_id)
""")
// 注意:SQLite 不允许向现有表添加外键约束
// 外键关系在应用层强制执行
}
}
模式:拆分为多个迁移
// 迁移 1:添加新结构
func migration010_AddNewTable() throws {
try database.write { db in
try db.execute(sql: """
CREATE TABLE IF NOT EXISTS new_structure (
id TEXT PRIMARY KEY,
data TEXT
)
""")
}
}
// 迁移 2:复制数据
func migration011_MigrateData() throws {
try database.write { db in
try db.execute(sql: """
INSERT INTO new_structure (id, data)
SELECT id, data FROM old_structure
""")
}
}
// 迁移 3:添加索引
func migration012_AddIndexes() throws {
try database.write { db in
try db.execute(sql: """
CREATE INDEX IF NOT EXISTS idx_new_structure_data
ON new_structure(data)
""")
}
}
// 旧结构保留(在代码中标记为弃用)
// 测试 1:迁移路径(关键 - 测试数据保留)
@Test func migrationFromV1ToV2Succeeds() async throws {
let db = try Database(inMemory: true)
// 模拟 v1 模式
try db.write { db in
try db.execute(sql: "CREATE TABLE tableName (id TEXT PRIMARY KEY)")
try db.execute(sql: "INSERT INTO tableName (id) VALUES ('test1')")
}
// 运行 v2 迁移
try db.runMigrations()
// 验证数据保留 + 新列存在
try db.read { db in
let count = try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM tableName")
#expect(count == 1) // 数据保留
let columns = try db.columns(in: "tableName").map { $0.name }
#expect(columns.contains("newColumn")) // 新列存在
}
}
测试 2 全新安装(运行所有迁移,验证最终模式)
@Test func freshInstallCreatesCorrectSchema() async throws {
let db = try Database(inMemory: true)
// 运行所有迁移
try db.runMigrations()
// 验证最终模式
try db.read { db in
let tables = try db.tables()
#expect(tables.contains("tableName"))
let columns = try db.columns(in: "tableName").map { $0.name }
#expect(columns.contains("id"))
#expect(columns.contains("newColumn"))
}
}
测试 3 幂等性(运行迁移两次,不应抛出异常)
@Test func migrationsAreIdempotent() async throws {
let db = try Database(inMemory: true)
// 运行迁移两次
try db.runMigrations()
try db.runMigrations() // 不应抛出异常
// 验证仍正确
try db.read { db in
let count = try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM tableName")
#expect(count == 0) // 无重复数据
}
}
What are you trying to do?
├─ Add new column?
│ └─ ALTER TABLE ADD COLUMN (nullable) → Done
├─ Add column with default?
│ └─ ALTER TABLE ADD COLUMN ... DEFAULT value → Done
├─ Change column type?
│ └─ Add new column → Migrate data → Deprecate old → Done
├─ Delete column?
│ └─ Mark as deprecated in code → Never delete from schema → Done
├─ Rename column?
│ └─ Add new column → Migrate data → Deprecate old → Done
├─ Add foreign key?
│ └─ Add column → Populate data → Add index → Done
└─ Complex refactor?
└─ Break into multiple migrations → Test each step → Done
| 错误 | 修复方案 |
|---|---|
FOREIGN KEY constraint failed | 检查父行是否存在,或临时禁用外键 |
no such column: columnName | 添加迁移以创建列 |
cannot add NOT NULL column | 先使用可空列,在单独的迁移中回填数据 |
table tableName already exists | 添加 IF NOT EXISTS 子句 |
duplicate column name | 添加前检查列是否存在(幂等性) |
❌ 添加 NOT NULL 列但未提供 DEFAULT 值
// ❌ 在现有数据上失败
ALTER TABLE albums ADD COLUMN rating INTEGER NOT NULL
✅ 正确做法:先添加为可空列
ALTER TABLE albums ADD COLUMN rating INTEGER // 允许 NULL
// 如需回填,在单独的迁移中进行
UPDATE albums SET rating = 0 WHERE rating IS NULL
❌ 忘记检查是否存在 — 始终添加 IF NOT EXISTS 或手动检查
❌ 修改已发布的迁移脚本 — 应创建新迁移
❌ 未测试迁移路径 — 始终测试从先前版本的升级
var migrator = DatabaseMigrator()
// 迁移 1
migrator.registerMigration("v1") { db in
try db.execute(sql: """
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
name TEXT NOT NULL
)
""")
}
// 迁移 2
migrator.registerMigration("v2") { db in
let hasColumn = try db.columns(in: "users")
.contains { $0.name == "email" }
if !hasColumn {
try db.execute(sql: """
ALTER TABLE users
ADD COLUMN email TEXT
""")
}
}
// 应用迁移
try migrator.migrate(dbQueue)
// 检查哪些迁移已应用
let appliedMigrations = try dbQueue.read { db in
try migrator.appliedMigrations(db)
}
print("已应用的迁移:\(appliedMigrations)")
// 检查是否需要迁移
let hasBeenMigrated = try dbQueue.read { db in
try migrator.hasBeenMigrated(db)
}
对于 SwiftData (iOS 17+),使用 VersionedSchema 和 SchemaMigrationPlan:
// 定义模式版本
enum MyAppSchemaV1: VersionedSchema {
static var versionIdentifier = Schema.Version(1, 0, 0)
static var models: [any PersistentModel.Type] {
[Track.self, Album.self]
}
}
enum MyAppSchemaV2: VersionedSchema {
static var versionIdentifier = Schema.Version(2, 0, 0)
static var models: [any PersistentModel.Type] {
[Track.self, Album.self, Playlist.self] // 添加了 Playlist
}
}
// 定义迁移计划
enum MyAppMigrationPlan: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] {
[MyAppSchemaV1.self, MyAppSchemaV2.self]
}
static var stages: [MigrationStage] {
[migrateV1toV2]
}
static let migrateV1toV2 = MigrationStage.custom(
fromVersion: MyAppSchemaV1.self,
toVersion: MyAppSchemaV2.self,
willMigrate: nil,
didMigrate: { context in
// 自定义迁移逻辑在此处
}
)
}
之前 开发者添加 NOT NULL 列 → 50% 用户的迁移失败 → 紧急回滚 → 数据不一致
之后 开发者添加可空列 → 测试两条路径 → 顺利部署 → 在 v2 中回填数据
关键见解 迁移在生产环境中无法回滚。通过充分测试,确保第一次就正确。
tvOS 迁移可能针对全新数据库运行。 系统在存储压力下会删除本地存储,因此你的应用可能在完全没有数据库的情况下启动。迁移必须优雅地处理这种情况——它们实际上同时成为“创建”和“升级”操作。
关键影响:
完整 tvOS 存储约束请参见 axiom-tvos。
最后更新:2025-11-28 框架:SQLite, GRDB, SwiftData 状态:用于安全模式演进的生产就绪模式
每周安装次数
97
代码仓库
GitHub 星标数
610
首次出现
2026年1月21日
安全审计
已安装于
opencode80
claude-code76
codex74
gemini-cli74
cursor73
github-copilot70
Safe database schema evolution for production apps with user data. Core principle Migrations are immutable after shipping. Make them additive, idempotent, and thoroughly tested.
These are real questions developers ask that this skill is designed to answer:
→ The skill covers safe additive patterns for adding columns without losing existing data, including idempotency checks
→ The skill explains why NOT NULL columns fail with existing rows, and shows the safe pattern (nullable first, backfill later)
→ The skill demonstrates the safe pattern: add new column → migrate data → deprecate old (NEVER delete)
→ The skill covers safe foreign key patterns: add column → populate data → add index (SQLite limitations explained)
→ The skill explains migrations are immutable after shipping; shows how to create a new migration to fix the issue rather than modifying the old one
❌ NEVER use DROP TABLE with user data ❌ NEVER modify shipped migrations (create new one instead) ❌ NEVER recreate tables to change schema (loses data) ❌ NEVER add NOT NULL column without DEFAULT value ❌ NEVER delete columns (SQLite doesn't support DROP COLUMN safely)
// ✅ Safe pattern
func migration00X_AddNewColumn() throws {
try database.write { db in
// 1. Check if column exists (idempotency)
let hasColumn = try db.columns(in: "tableName")
.contains { $0.name == "newColumn" }
if !hasColumn {
// 2. Add as nullable (works with existing rows)
try db.execute(sql: """
ALTER TABLE tableName
ADD COLUMN newColumn TEXT
""")
}
}
}
// ✅ Safe pattern with default
func migration00X_AddColumnWithDefault() throws {
try database.write { db in
let hasColumn = try db.columns(in: "tracks")
.contains { $0.name == "playCount" }
if !hasColumn {
try db.execute(sql: """
ALTER TABLE tracks
ADD COLUMN playCount INTEGER DEFAULT 0
""")
}
}
}
Pattern : Add new column → migrate data → deprecate old (NEVER delete)
// ✅ Safe pattern for type change
func migration00X_ChangeColumnType() throws {
try database.write { db in
// Step 1: Add new column with new type
try db.execute(sql: """
ALTER TABLE users
ADD COLUMN age_new INTEGER
""")
// Step 2: Migrate existing data
try db.execute(sql: """
UPDATE users
SET age_new = CAST(age_old AS INTEGER)
WHERE age_old IS NOT NULL
""")
// Step 3: Application code uses age_new going forward
// (Never delete age_old column - just stop using it)
}
}
// ✅ Safe pattern for foreign keys
func migration00X_AddForeignKey() throws {
try database.write { db in
// Step 1: Add new column (nullable initially)
try db.execute(sql: """
ALTER TABLE tracks
ADD COLUMN album_id TEXT
""")
// Step 2: Populate the data
try db.execute(sql: """
UPDATE tracks
SET album_id = (
SELECT id FROM albums
WHERE albums.title = tracks.album_name
)
""")
// Step 3: Add index (helps query performance)
try db.execute(sql: """
CREATE INDEX IF NOT EXISTS idx_tracks_album_id
ON tracks(album_id)
""")
// Note: SQLite doesn't allow adding FK constraints to existing tables
// The foreign key relationship is enforced at the application level
}
}
Pattern : Break into multiple migrations
// Migration 1: Add new structure
func migration010_AddNewTable() throws {
try database.write { db in
try db.execute(sql: """
CREATE TABLE IF NOT EXISTS new_structure (
id TEXT PRIMARY KEY,
data TEXT
)
""")
}
}
// Migration 2: Copy data
func migration011_MigrateData() throws {
try database.write { db in
try db.execute(sql: """
INSERT INTO new_structure (id, data)
SELECT id, data FROM old_structure
""")
}
}
// Migration 3: Add indexes
func migration012_AddIndexes() throws {
try database.write { db in
try db.execute(sql: """
CREATE INDEX IF NOT EXISTS idx_new_structure_data
ON new_structure(data)
""")
}
}
// Old structure stays around (deprecated in code)
// Test 1: Migration path (CRITICAL - tests data preservation)
@Test func migrationFromV1ToV2Succeeds() async throws {
let db = try Database(inMemory: true)
// Simulate v1 schema
try db.write { db in
try db.execute(sql: "CREATE TABLE tableName (id TEXT PRIMARY KEY)")
try db.execute(sql: "INSERT INTO tableName (id) VALUES ('test1')")
}
// Run v2 migration
try db.runMigrations()
// Verify data survived + new column exists
try db.read { db in
let count = try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM tableName")
#expect(count == 1) // Data preserved
let columns = try db.columns(in: "tableName").map { $0.name }
#expect(columns.contains("newColumn")) // New column exists
}
}
Test 2 Fresh install (run all migrations, verify final schema)
@Test func freshInstallCreatesCorrectSchema() async throws {
let db = try Database(inMemory: true)
// Run all migrations
try db.runMigrations()
// Verify final schema
try db.read { db in
let tables = try db.tables()
#expect(tables.contains("tableName"))
let columns = try db.columns(in: "tableName").map { $0.name }
#expect(columns.contains("id"))
#expect(columns.contains("newColumn"))
}
}
Test 3 Idempotency (run migrations twice, should not throw)
@Test func migrationsAreIdempotent() async throws {
let db = try Database(inMemory: true)
// Run migrations twice
try db.runMigrations()
try db.runMigrations() // Should not throw
// Verify still correct
try db.read { db in
let count = try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM tableName")
#expect(count == 0) // No duplicate data
}
}
What are you trying to do?
├─ Add new column?
│ └─ ALTER TABLE ADD COLUMN (nullable) → Done
├─ Add column with default?
│ └─ ALTER TABLE ADD COLUMN ... DEFAULT value → Done
├─ Change column type?
│ └─ Add new column → Migrate data → Deprecate old → Done
├─ Delete column?
│ └─ Mark as deprecated in code → Never delete from schema → Done
├─ Rename column?
│ └─ Add new column → Migrate data → Deprecate old → Done
├─ Add foreign key?
│ └─ Add column → Populate data → Add index → Done
└─ Complex refactor?
└─ Break into multiple migrations → Test each step → Done
| Error | Fix |
|---|---|
FOREIGN KEY constraint failed | Check parent row exists, or disable FK temporarily |
no such column: columnName | Add migration to create column |
cannot add NOT NULL column | Use nullable column first, backfill in separate migration |
table tableName already exists | Add IF NOT EXISTS clause |
duplicate column name | Check if column exists before adding (idempotency) |
❌ Adding NOT NULL without DEFAULT
// ❌ Fails on existing data
ALTER TABLE albums ADD COLUMN rating INTEGER NOT NULL
✅ Correct: Add as nullable first
ALTER TABLE albums ADD COLUMN rating INTEGER // NULL allowed
// Backfill in separate migration if needed
UPDATE albums SET rating = 0 WHERE rating IS NULL
❌ Forgetting to check for existence — Always add IF NOT EXISTS or manual check
❌ Modifying shipped migrations — Create new migration instead
❌ Not testing migration path — Always test upgrade from previous version
var migrator = DatabaseMigrator()
// Migration 1
migrator.registerMigration("v1") { db in
try db.execute(sql: """
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
name TEXT NOT NULL
)
""")
}
// Migration 2
migrator.registerMigration("v2") { db in
let hasColumn = try db.columns(in: "users")
.contains { $0.name == "email" }
if !hasColumn {
try db.execute(sql: """
ALTER TABLE users
ADD COLUMN email TEXT
""")
}
}
// Apply migrations
try migrator.migrate(dbQueue)
// Check which migrations have been applied
let appliedMigrations = try dbQueue.read { db in
try migrator.appliedMigrations(db)
}
print("Applied migrations: \(appliedMigrations)")
// Check if migrations are needed
let hasBeenMigrated = try dbQueue.read { db in
try migrator.hasBeenMigrated(db)
}
For SwiftData (iOS 17+), use VersionedSchema and SchemaMigrationPlan:
// Define schema versions
enum MyAppSchemaV1: VersionedSchema {
static var versionIdentifier = Schema.Version(1, 0, 0)
static var models: [any PersistentModel.Type] {
[Track.self, Album.self]
}
}
enum MyAppSchemaV2: VersionedSchema {
static var versionIdentifier = Schema.Version(2, 0, 0)
static var models: [any PersistentModel.Type] {
[Track.self, Album.self, Playlist.self] // Added Playlist
}
}
// Define migration plan
enum MyAppMigrationPlan: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] {
[MyAppSchemaV1.self, MyAppSchemaV2.self]
}
static var stages: [MigrationStage] {
[migrateV1toV2]
}
static let migrateV1toV2 = MigrationStage.custom(
fromVersion: MyAppSchemaV1.self,
toVersion: MyAppSchemaV2.self,
willMigrate: nil,
didMigrate: { context in
// Custom migration logic here
}
)
}
Before Developer adds NOT NULL column → migration fails for 50% of users → emergency rollback → data inconsistency
After Developer adds nullable column → tests both paths → smooth deployment → backfills data in v2
Key insight Migrations can't be rolled back in production. Get them right the first time through thorough testing.
tvOS migrations may run against a fresh database. The system deletes local storage under pressure, so your app may launch with no database at all. Migrations must handle this gracefully — they effectively become both "create" and "upgrade" operations.
Key implications :
See axiom-tvos for full tvOS storage constraints.
Last Updated : 2025-11-28 Frameworks : SQLite, GRDB, SwiftData Status : Production-ready patterns for safe schema evolution
Weekly Installs
97
Repository
GitHub Stars
610
First Seen
Jan 21, 2026
Security Audits
Gen Agent Trust HubPassSocketPassSnykPass
Installed on
opencode80
claude-code76
codex74
gemini-cli74
cursor73
github-copilot70
Azure RBAC 权限管理工具:查找最小角色、创建自定义角色与自动化分配
135,700 周安装
知乎内容转换器 | 一键将文章优化为知乎爆款格式,提升专业感与互动性
174 周安装
minimax-web-search:基于MiniMax MCP服务器的网络搜索工具安装与使用指南
176 周安装
OMERO Python API 集成指南:显微镜图像管理与分析自动化
172 周安装
datamol:Python化学信息学库,简化RDKit分子操作,支持并行计算与云存储
173 周安装
mviz:从JSON/Markdown快速生成数据可视化图表与仪表板 | 简化ECharts开发
177 周安装
macOS辅助功能自动化开发指南:AXUIElement API、TCC权限管理与安全实践
173 周安装