From 8369cab85c9671ead09201885be0dc30f32dfdab Mon Sep 17 00:00:00 2001 From: jim800121chen Date: Thu, 16 Jul 2026 04:05:53 +0800 Subject: [PATCH] =?UTF-8?q?feat(db):=20migration=200005=20agents=20?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=EF=BC=88=E5=80=8B=E4=BA=BA=E8=A8=AD=E5=82=99?= =?UTF-8?q?=E7=AE=A1=E7=90=86=20A'=20=E8=B5=B0=E5=90=91=E7=AC=AC=E4=BA=8C?= =?UTF-8?q?=E9=9A=8E=E6=AE=B5=E5=9C=B0=E5=9F=BA=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 agents 表 + devices 加 4 欄(agent_id/agent_local_device_id/ registered_at/is_representative)+ 2 index + 純 SQL data migration - data migration 採 R-A:agent.id := device.id 決定性推導、冪等、 soft-deleted device 排除(WHERE deleted_at IS NULL) - 守住 ADR-018 走向 A':不碰 session_tokens FK、不改現有 partial unique index uq_devices_owner_serial_active、對 0001-0003 零破壞 - migrate_0005_db_test.go 6 個 dbtest case(apply/data migration/ rollback 對稱/re-apply 冪等/session_tokens 零影響/既有 device 讀寫回歸) - 修 TestMigrate_UpDownUp 編號 gap 假設(0001/2/3/5 無 0004): assert.Equal(topVer-1) → assert.Less(downVer, topVer) - .gitignore 加 .logs/ + **/.logs/(本機執行 log per-branch 不進 git) Reviewer 通過(0 Critical/0 Major/3 Minor/4 Sug)。全 db package 41 dbtest 130 綠、build/vet/test 綠、gitleaks 0。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 4 + .../internal/db/db_integration_test.go | 12 +- .../internal/db/migrate_0005_db_test.go | 321 ++++++++++++++++++ .../migrations/0005_create_agents.down.sql | 22 ++ .../migrations/0005_create_agents.up.sql | 71 ++++ 5 files changed, 426 insertions(+), 4 deletions(-) create mode 100644 visionA-backend/internal/db/migrate_0005_db_test.go create mode 100644 visionA-backend/migrations/0005_create_agents.down.sql create mode 100644 visionA-backend/migrations/0005_create_agents.up.sql diff --git a/.gitignore b/.gitignore index 7ea2e2b..d7e9e6f 100644 --- a/.gitignore +++ b/.gitignore @@ -73,6 +73,10 @@ graphify-out/ .autoflow-logs/ **/.autoflow-logs/ +# 本機執行 log(如 local-tool/server/scripts/.logs/,per-branch、不進 git) +.logs/ +**/.logs/ + .autoflow/ .autoflow/CLAUDE.md.backup.* .autoflow/.backups/ diff --git a/visionA-backend/internal/db/db_integration_test.go b/visionA-backend/internal/db/db_integration_test.go index 0adad0a..e5086f0 100644 --- a/visionA-backend/internal/db/db_integration_test.go +++ b/visionA-backend/internal/db/db_integration_test.go @@ -58,9 +58,13 @@ func TestMigrate_Idempotent(t *testing.T) { // TestMigrate_UpDownUp 驗證 down 後 up 仍可回到原狀態(雙向 migration 正確)。 // -// 注意:Migrator.Down() 實作為 m.Steps(-1),是「回退一個版本」而非「回滾全部」。 -// 因此本測試驗的是版本層級的可逆性:down 後版本 -1、再 up 後版本回到原值且 not dirty。 -// 不依賴特定 migration 建立的表名 / 版本號,未來再加 0004/0005 也不會壞。 +// 注意:Migrator.Down() 實作為 m.Steps(-1),是「回退一個 migration」而非「回滾全部」。 +// 因此本測試驗的是版本層級的可逆性:down 後版本變小、再 up 後版本回到原值且 not dirty。 +// +// 不假設 migration 連續編號:golang-migrate 的 version = migration 檔的數字前綴, +// Steps(-1) 是回退「前一個實際存在的 migration」,不是 version-1。編號有 gap +// (如 0001/0002/0003/0005,無 0004)時,從 5 down 一步會回到 3 而非 4。 +// 故 down 後只斷言「版本嚴格變小且 not dirty」,不寫死差值。 func TestMigrate_UpDownUp(t *testing.T) { tdb := testsupport.SetupTestDB(t) @@ -80,7 +84,7 @@ func TestMigrate_UpDownUp(t *testing.T) { downVer, dirty, err := mg.Version() require.NoError(t, err) assert.False(t, dirty, "down 後不應 dirty") - assert.Equal(t, topVer-1, downVer, "down 一步後版本應 -1") + assert.Less(t, downVer, topVer, "down 一步後版本應嚴格變小(不假設連續編號,回退到前一個實際存在的 migration)") // 再 up 一次應回到原始最新版本(雙向可逆)。 require.NoError(t, db.RunMigrations(tdb.Cfg, discardLog())) diff --git a/visionA-backend/internal/db/migrate_0005_db_test.go b/visionA-backend/internal/db/migrate_0005_db_test.go new file mode 100644 index 0000000..97a9a64 --- /dev/null +++ b/visionA-backend/internal/db/migrate_0005_db_test.go @@ -0,0 +1,321 @@ +//go:build dbtest + +// Migration 0005(agents 模型)的真 DB 整合測試(走向 A' 第二階段地基)。 +// +// build tag `dbtest`:需要 Docker daemon / testcontainers。預設 `go test ./...` 不編譯本檔。 +// 執行: +// +// go test -tags=dbtest ./internal/db/... +// # 無本機 Docker 時,在 130 補跑: +// DOCKER_HOST=tcp://192.168.0.130:2375 TESTCONTAINERS_RYUK_DISABLED=true \ +// go test -tags=dbtest ./internal/db/... +// +// 對齊 migration-0005-spec.md §6.2 六個驗證要點: +// 1. apply:agents 表存在、devices 有 4 新欄、2 新 index 存在。 +// 2. data migration 正確:N 筆未刪除 device + M 筆 soft-deleted → agents 恰 N 筆、 +// 每筆未刪除 device 的 agent_id=自己 id 且 is_representative=true、soft-deleted 的 agent_id 仍 NULL。 +// 3. rollback 對稱:down 後 agents 表消失、devices 回 13 欄、session_tokens 完全未變。 +// 4. re-apply 冪等:up→down→up 不報錯、結果一致。 +// 5. session_tokens 零影響:遷移前後 session_tokens 的 row 與 device_id 綁定完全不變。 +// 6. 既有 device 讀寫回歸:0005 後既有 device 讀寫(舊 13 欄)仍正常運作。 +package db_test + +import ( + "context" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "visiona-backend/internal/db" + "visiona-backend/internal/db/testsupport" +) + +// colExists 回傳指定 table 是否有指定欄位(information_schema)。 +func colExists(t *testing.T, tdb *testsupport.TestDB, table, column string) bool { + t.Helper() + var exists bool + err := tdb.Pool.QueryRow(context.Background(), + `SELECT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = $1 AND column_name = $2 + )`, table, column).Scan(&exists) + require.NoError(t, err, "check column %s.%s", table, column) + return exists +} + +// indexExists 回傳指定 index 是否存在(pg_indexes)。 +func indexExists(t *testing.T, tdb *testsupport.TestDB, name string) bool { + t.Helper() + var exists bool + err := tdb.Pool.QueryRow(context.Background(), + `SELECT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = $1)`, name).Scan(&exists) + require.NoError(t, err, "check index %s", name) + return exists +} + +// tableExists 回傳指定 table 是否存在(information_schema)。 +func tableExists(t *testing.T, tdb *testsupport.TestDB, table string) bool { + t.Helper() + var exists bool + err := tdb.Pool.QueryRow(context.Background(), + `SELECT EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_name = $1 AND table_schema = 'public' + )`, table).Scan(&exists) + require.NoError(t, err, "check table %s", table) + return exists +} + +// deviceColCount 回傳 devices 表目前的欄位數(用於驗證 up 後 17 欄 / down 後回 13 欄)。 +func deviceColCount(t *testing.T, tdb *testsupport.TestDB) int { + t.Helper() + var n int + err := tdb.Pool.QueryRow(context.Background(), + `SELECT count(*) FROM information_schema.columns + WHERE table_name = 'devices' AND table_schema = 'public'`).Scan(&n) + require.NoError(t, err, "count devices columns") + return n +} + +// insertRawDevice 直接寫入一筆 devices,可控制 serial_number / paired_at / deleted_at。 +// 回傳寫入的 device id。data migration 測試需要精確控制 soft-delete 與 serial。 +// id 為空時自動產生 UUID(不依賴欄位 DEFAULT,以便回傳明確 id 供斷言)。 +func insertRawDevice(t *testing.T, tdb *testsupport.TestDB, id, ownerID, serial string, softDeleted bool) string { + t.Helper() + ctx := context.Background() + if id == "" { + id = uuid.NewString() + } + var serialArg any + if serial == "" { + serialArg = nil + } else { + serialArg = serial + } + deletedExpr := "NULL" + if softDeleted { + deletedExpr = "now()" + } + err := tdb.Pool.QueryRow(ctx, + `INSERT INTO devices (id, owner_user_id, name, serial_number, paired_at, deleted_at) + VALUES ($1, $2, $3, $4, now(), `+deletedExpr+`) + RETURNING id`, + id, ownerID, "raw-device", serialArg).Scan(&id) + require.NoError(t, err, "insert raw device fixture") + return id +} + +// TestMigrate0005_Apply 驗證 up 後:agents 表存在、devices 有 4 新欄、2 新 index 存在(§6.2-1)。 +func TestMigrate0005_Apply(t *testing.T) { + tdb := testsupport.SetupTestDB(t) // 已 up 到最新(含 0005) + + require.True(t, tableExists(t, tdb, "agents"), "agents 表應存在") + + for _, col := range []string{"agent_id", "agent_local_device_id", "registered_at", "is_representative"} { + assert.True(t, colExists(t, tdb, "devices", col), "devices 應有新欄 %s", col) + } + + for _, idx := range []string{"idx_agents_owner_active", "idx_devices_agent_active", "idx_devices_registered"} { + assert.True(t, indexExists(t, tdb, idx), "index %s 應存在", idx) + } + + // is_representative 應有 NOT NULL DEFAULT false(既有/新 device 加欄後預設 false)。 + assert.Equal(t, 17, deviceColCount(t, tdb), "up 後 devices 應為 13+4=17 欄") +} + +// TestMigrate0005_DataMigration 驗證舊資料遷移正確性(§6.2-2)。 +// +// 關鍵:SetupTestDB 一開機就跑到最新(含 0005),此時沒有「遷移前的舊 device」可觀察。 +// 故本測試手動 down 一步回到 0004、塞入 N 筆未刪除 + M 筆 soft-deleted device,再 up 觀察遷移結果。 +func TestMigrate0005_DataMigration(t *testing.T) { + tdb := testsupport.SetupTestDB(t) + ctx := context.Background() + + mg, err := db.NewMigrator(tdb.Cfg, discardLog()) + require.NoError(t, err) + defer mg.Close() + + // 回退 0005,回到 0004 後狀態(devices 尚無 agent_id 等新欄)。 + require.NoError(t, mg.Down(), "down 一步回到 0004") + require.False(t, colExists(t, tdb, "devices", "agent_id"), "down 後不應有 agent_id 欄") + + owner := tdb.InsertUser(t, "", "") + + // 塞 3 筆未刪除 device(serial=NULL,模擬連線佔位)+ 2 筆 soft-deleted device。 + activeIDs := []string{ + insertRawDevice(t, tdb, "", owner, "", false), + insertRawDevice(t, tdb, "", owner, "", false), + insertRawDevice(t, tdb, "", owner, "", false), + } + deletedIDs := []string{ + insertRawDevice(t, tdb, "", owner, "DEL-1", true), + insertRawDevice(t, tdb, "", owner, "DEL-2", true), + } + + // 重新 up:跑 0005 的 data migration。 + require.NoError(t, db.RunMigrations(tdb.Cfg, discardLog()), "re-up 0005") + + // agents 表應恰 3 筆(= 未刪除 device 數,soft-deleted 不建 agent)。 + assert.Equal(t, len(activeIDs), tdb.CountRows(t, "agents"), "agents 應等於未刪除 device 數") + + // 每筆未刪除 device:agent_id = 自己 id、is_representative = true、且該 agent 存在。 + for _, id := range activeIDs { + var agentID string + var isRep bool + err = tdb.Pool.QueryRow(ctx, + `SELECT agent_id, is_representative FROM devices WHERE id = $1`, id).Scan(&agentID, &isRep) + require.NoError(t, err) + assert.Equal(t, id, agentID, "未刪除 device 的 agent_id 應等於自己 id") + assert.True(t, isRep, "未刪除 device 應標記 is_representative=true") + + var agentOwner string + err = tdb.Pool.QueryRow(ctx, + `SELECT owner_user_id FROM agents WHERE id = $1`, id).Scan(&agentOwner) + require.NoError(t, err, "應存在 id=%s 的 agent", id) + assert.Equal(t, owner, agentOwner, "agent 應繼承 device 的 owner") + } + + // soft-deleted device:agent_id 仍 NULL、is_representative 仍 false(DEFAULT)。 + for _, id := range deletedIDs { + var agentID *string + var isRep bool + err = tdb.Pool.QueryRow(ctx, + `SELECT agent_id, is_representative FROM devices WHERE id = $1`, id).Scan(&agentID, &isRep) + require.NoError(t, err) + assert.Nil(t, agentID, "soft-deleted device 的 agent_id 應維持 NULL") + assert.False(t, isRep, "soft-deleted device 的 is_representative 應維持 false") + } +} + +// TestMigrate0005_RollbackSymmetry 驗證 down 對稱:agents 消失、devices 回 13 欄(§6.2-3)。 +func TestMigrate0005_RollbackSymmetry(t *testing.T) { + tdb := testsupport.SetupTestDB(t) + + mg, err := db.NewMigrator(tdb.Cfg, discardLog()) + require.NoError(t, err) + defer mg.Close() + + require.True(t, tableExists(t, tdb, "agents"), "down 前 agents 應存在") + require.Equal(t, 17, deviceColCount(t, tdb), "down 前 devices 應 17 欄") + + require.NoError(t, mg.Down(), "down 一步(回退 0005)") + + assert.False(t, tableExists(t, tdb, "agents"), "down 後 agents 表應消失") + assert.Equal(t, 13, deviceColCount(t, tdb), "down 後 devices 應回到 0002 的 13 欄") + for _, col := range []string{"agent_id", "agent_local_device_id", "registered_at", "is_representative"} { + assert.False(t, colExists(t, tdb, "devices", col), "down 後 devices 不應有 %s", col) + } + for _, idx := range []string{"idx_agents_owner_active", "idx_devices_agent_active", "idx_devices_registered"} { + assert.False(t, indexExists(t, tdb, idx), "down 後 index %s 應消失", idx) + } +} + +// TestMigrate0005_ReApplyIdempotent 驗證 up→down→up 不報錯、結果一致(§6.2-4)。 +func TestMigrate0005_ReApplyIdempotent(t *testing.T) { + tdb := testsupport.SetupTestDB(t) + + mg, err := db.NewMigrator(tdb.Cfg, discardLog()) + require.NoError(t, err) + defer mg.Close() + + topVer, dirty, err := mg.Version() + require.NoError(t, err) + require.False(t, dirty) + + require.NoError(t, mg.Down(), "down 一步") + require.NoError(t, db.RunMigrations(tdb.Cfg, discardLog()), "重新 up") + + ver, dirty, err := mg.Version() + require.NoError(t, err) + assert.False(t, dirty, "up→down→up 後不應 dirty") + assert.Equal(t, topVer, ver, "up→down→up 後版本應回到最新") + assert.True(t, tableExists(t, tdb, "agents"), "重新 up 後 agents 表應再次存在") +} + +// TestMigrate0005_SessionTokensUntouched 驗證 0005 對 session_tokens 零影響(§6.2-3、§6.2-5)。 +// +// 在 0005 已 apply 的狀態下塞一筆 session_token(綁 representative device), +// 記下 (token_hash, device_id),down 一步再 up,驗證 row 與綁定完全不變、且 session_tokens +// schema(device_id NOT NULL FK)在 0005 進出後一字不動。 +func TestMigrate0005_SessionTokensUntouched(t *testing.T) { + tdb := testsupport.SetupTestDB(t) + ctx := context.Background() + + owner := tdb.InsertUser(t, "", "") + deviceID := tdb.InsertDevice(t, "", owner) + + const tokenHash = "sha256-0005-untouched-fixture" + _, err := tdb.Pool.Exec(ctx, + `INSERT INTO session_tokens (token_hash, user_id, device_id) VALUES ($1, $2, $3)`, + tokenHash, owner, deviceID) + require.NoError(t, err, "insert session token fixture") + + // session_tokens.device_id 必須是 NOT NULL(0005 不得動它)。 + var isNullable string + err = tdb.Pool.QueryRow(ctx, + `SELECT is_nullable FROM information_schema.columns + WHERE table_name = 'session_tokens' AND column_name = 'device_id'`).Scan(&isNullable) + require.NoError(t, err) + assert.Equal(t, "NO", isNullable, "session_tokens.device_id 應維持 NOT NULL") + + // down 一步 + 重新 up,模擬 0005 進出。 + mg, err := db.NewMigrator(tdb.Cfg, discardLog()) + require.NoError(t, err) + defer mg.Close() + require.NoError(t, mg.Down(), "down 一步") + require.NoError(t, db.RunMigrations(tdb.Cfg, discardLog()), "重新 up") + + // row 與綁定完全不變。 + var gotDevice string + err = tdb.Pool.QueryRow(ctx, + `SELECT device_id FROM session_tokens WHERE token_hash = $1`, tokenHash).Scan(&gotDevice) + require.NoError(t, err, "session_token row 應在 0005 進出後仍存在") + assert.Equal(t, deviceID, gotDevice, "session_token 的 device_id 綁定不應改變") + assert.Equal(t, 1, tdb.CountRows(t, "session_tokens"), "session_tokens 應仍恰 1 筆") + + // device_id FK 仍在(NOT NULL),插入 NULL device_id 應失敗。 + _, err = tdb.Pool.Exec(ctx, + `INSERT INTO session_tokens (token_hash, user_id, device_id) VALUES ($1, $2, NULL)`, + "sha256-null-device", owner) + assert.Error(t, err, "session_tokens.device_id NOT NULL 應仍生效") +} + +// TestMigrate0005_ExistingDeviceReadWrite 驗證 0005 後既有 device 讀寫(舊 13 欄)仍正常(§6.2-6)。 +// +// 對齊「deviceColumns/scanDevice 未含新欄 = 既有讀寫不受影響」的相容性分析: +// 用只涉及舊欄的 SELECT / UPDATE 走一遍,確認 0005 加欄後仍運作。 +func TestMigrate0005_ExistingDeviceReadWrite(t *testing.T) { + tdb := testsupport.SetupTestDB(t) + ctx := context.Background() + + owner := tdb.InsertUser(t, "", "") + deviceID := tdb.InsertDevice(t, "", owner) + + // 舊欄 SELECT(模擬 deviceColumns,未含新 4 欄)仍可讀。 + var name, remoteStatus, status string + err := tdb.Pool.QueryRow(ctx, + `SELECT name, remote_status, status FROM devices WHERE id = $1`, deviceID).Scan(&name, &remoteStatus, &status) + require.NoError(t, err, "既有 device 舊欄 SELECT 應正常") + assert.Equal(t, "fixture-device", name) + + // 舊欄 UPDATE(雙狀態)仍可寫,且新欄維持預設(agent_id NULL / is_representative false)。 + _, err = tdb.Pool.Exec(ctx, + `UPDATE devices SET remote_status = 'online', status = 'online', updated_at = now() WHERE id = $1`, deviceID) + require.NoError(t, err, "既有 device 舊欄 UPDATE 應正常") + + var agentID *string + var isRep bool + err = tdb.Pool.QueryRow(ctx, + `SELECT agent_id, is_representative FROM devices WHERE id = $1`, deviceID).Scan(&agentID, &isRep) + require.NoError(t, err) + assert.Nil(t, agentID, "0005 後新插入的 device agent_id 應為 NULL(非遷移對象)") + assert.False(t, isRep, "新插入的 device is_representative 應為 DEFAULT false") + + // devices.owner_user_id FK 仍生效。 + _, err = tdb.Pool.Exec(ctx, + `INSERT INTO devices (owner_user_id, name) VALUES ($1, $2)`, + "99999999-9999-9999-9999-999999999999", "bad-owner") + assert.Error(t, err, "devices.owner_user_id FK 應仍生效") +} diff --git a/visionA-backend/migrations/0005_create_agents.down.sql b/visionA-backend/migrations/0005_create_agents.down.sql new file mode 100644 index 0000000..5c88d2d --- /dev/null +++ b/visionA-backend/migrations/0005_create_agents.down.sql @@ -0,0 +1,22 @@ +-- 0005_create_agents.down.sql +-- +-- 反向 0005:移除 devices 新欄位與 index、DROP agents 表。 +-- 對稱性:agents 表整個 DROP,故 (3) data migration 建的 agent row 無殘留、無需個別還原。 +-- devices 的 agent_id 欄 DROP 後,data migration 寫進去的值一併消失,回到 0004 後狀態。 +-- +-- 順序:先 DROP devices 上參照 agents 的 FK 欄(agent_id),才能 DROP agents 表 +-- (否則 agents 被 devices.agent_id FK 參照、DROP TABLE agents 會失敗)。 + +-- devices 新增 index(隨欄位存在,先於欄位 DROP;IF EXISTS 保冪等)。 +DROP INDEX IF EXISTS idx_devices_registered; +DROP INDEX IF EXISTS idx_devices_agent_active; + +-- devices 新增欄位(agent_id 內含 FK→agents,須先於 DROP TABLE agents 移除)。 +ALTER TABLE devices DROP COLUMN IF EXISTS is_representative; +ALTER TABLE devices DROP COLUMN IF EXISTS registered_at; +ALTER TABLE devices DROP COLUMN IF EXISTS agent_local_device_id; +ALTER TABLE devices DROP COLUMN IF EXISTS agent_id; + +-- agents 表(index idx_agents_owner_active 隨 table DROP 自動移除;顯式 DROP INDEX 保冪等)。 +DROP INDEX IF EXISTS idx_agents_owner_active; +DROP TABLE IF EXISTS agents; diff --git a/visionA-backend/migrations/0005_create_agents.up.sql b/visionA-backend/migrations/0005_create_agents.up.sql new file mode 100644 index 0000000..158e4a2 --- /dev/null +++ b/visionA-backend/migrations/0005_create_agents.up.sql @@ -0,0 +1,71 @@ +-- 0005_create_agents.up.sql +-- +-- 個人設備管理走向 A'(ADR-018 Accepted):新增 agents 實體(一條已配對的 tunnel 連線 +-- = 一個 agent);devices 語意轉為「一顆實體 USB」,掛 agent_id。 +-- 對齊 .autoflow/04-architecture/personal-device-mgmt-data-model.md §2.3 / §2.6 +-- 與 docs/autoflow/04-architecture/adr/adr-018-agent-device-model.md §5.1–§5.4, +-- 精確 SQL 規格見 .autoflow/04-architecture/migration-0005-spec.md。 +-- +-- 環境事實(與 0001–0003 相同,已驗證):PostgreSQL 14.23,gen_random_uuid() 內建可直接用。 +-- +-- 不碰的東西(走向 A' 核心,避開破壞性 migration): +-- - session_tokens.device_id 的 FK / NOT NULL:物理 schema 一字不動(改綁 representative +-- device 是純語意變更,不需 DDL)。 +-- - uq_devices_owner_serial_active:現有 partial unique 已是 task 6 去重正確基礎,不動。 + +-- ── (1) agents 表 ──────────────────────────────────────────────────────────── +CREATE TABLE agents ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + owner_user_id UUID NOT NULL REFERENCES users(id), + name TEXT NOT NULL DEFAULT 'local-agent', + platform TEXT, -- darwin/windows/linux(agent 上報,可空) + agent_version TEXT, -- agent 版本(可空) + last_paired_at TIMESTAMPTZ, -- 最近一次配對完成時間 + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ +); + +-- owner-scoped active index(List agents by owner;沿用 0002 partial 慣例)。 +CREATE INDEX idx_agents_owner_active ON agents (owner_user_id) WHERE deleted_at IS NULL; + +-- ── (2) devices 加欄位(語意轉為「實體 USB」)──────────────────────────────── +-- 全部 nullable(或有 DEFAULT),對既有 row 零破壞:既有 device 加欄後 agent_id=NULL、 +-- registered_at=NULL、is_representative=false,語意 = 「尚未歸入 agent 的舊資料」, +-- 由 (3) data migration 補齊。 +ALTER TABLE devices ADD COLUMN agent_id UUID REFERENCES agents(id); -- NULL=代表 device/舊資料 +ALTER TABLE devices ADD COLUMN agent_local_device_id TEXT; -- local agent 合成 id(kl520-0),路由除錯輔助 +ALTER TABLE devices ADD COLUMN registered_at TIMESTAMPTZ; -- NULL=未註冊(註冊軸) +ALTER TABLE devices ADD COLUMN is_representative BOOLEAN NOT NULL DEFAULT false;-- true=agent 佔位/代表 device(非真 USB) + +-- agent_id 反查(List devices by agent;只索引未刪除)。 +CREATE INDEX idx_devices_agent_active ON devices (agent_id) WHERE deleted_at IS NULL; +-- 註冊軸 filter(task 3 黃色「未註冊」/ task 4 filter「只看未註冊」;只索引未刪除)。 +CREATE INDEX idx_devices_registered ON devices (registered_at) WHERE deleted_at IS NULL; + +-- ── (3) data migration:既有 device(皆 serial=NULL 的連線佔位)→ 1:1 建 agent + 標 representative ── +-- 純 SQL 兩句對應(本 runner 是 golang-migrate iofs,只跑 SQL,無 Go 逐筆 hook)。 +-- +-- 決定性推導方案(agent.id := device.id):讓每筆現有 device 產生的 agent 用「與 device 相同 +-- 的 UUID」。agents 與 devices 主鍵空間獨立(不同表),沿用同一 UUID 值不衝突;且讓 3b 的 +-- 回填變成「device.agent_id := device.id」的自我對應,一句 UPDATE 完成、無關聯歧義、天然冪等。 +-- 取捨與替代方案見 migration-0005-spec.md §2.1 / §5 R-A(使用者已裁決採此方案)。 + +-- 3a. 對每筆現有未刪除 device,各建一筆 agent(id 沿用來源 device.id)。 +INSERT INTO agents (id, owner_user_id, name, last_paired_at, created_at, updated_at) + SELECT d.id, d.owner_user_id, 'local-agent', d.paired_at, d.created_at, now() + FROM devices d + WHERE d.deleted_at IS NULL; + +-- 3b. 回填:每筆現有 device 綁到「用它自己 id 建出來的 agent」,並標記為 representative。 +-- (現有 device 本來就是「連線佔位」而非真 USB,serial 皆 NULL,故全部 is_representative=true。) +UPDATE devices d + SET agent_id = d.id, -- 對應 3a:agent.id 就是 device.id + is_representative = true, + updated_at = now() + WHERE d.deleted_at IS NULL + AND d.agent_id IS NULL; -- 冪等保護:已綁過的不重綁 + +-- 註:已 soft-deleted 的舊 device 不建 agent、不回填(它們已退出使用;未來若復活走正常 exchange 流程)。 +-- 真實 USB device 在本次遷移後「還不存在」——等 agent 下次 exchange 帶 serial 上來(WP-0) +-- 或 task 5 註冊時才建立,這符合「舊資料無序號」的事實(ADR-018 §4.3)。