| 1 | package draftstate |
| 2 | |
| 3 | import ( |
| 4 | "database/sql" |
| 5 | "fmt" |
| 6 | "path/filepath" |
| 7 | "testing" |
| 8 | ) |
| 9 | |
| 10 | // Structural schema fixtures complement tagged v4 databases: no public tag in |
| 11 | // 1.38.3–11 emitted schemas 1–3, but the existing migration contract accepts them. |
| 12 | func TestEveryPreviousDraftSchemaPreservesWALAndOpaqueRecords(t *testing.T) { |
| 13 | for version := 1; version <= 3; version++ { |
| 14 | t.Run(fmt.Sprint(version), func(t *testing.T) { |
| 15 | path := filepath.Join(t.TempDir(), "drafts.sqlite") |
| 16 | initial := New(path) |
| 17 | draft, _, err := initial.Open(t.Context(), "workspace", "project", "/synthetic/project", "draft", `{"model":"frozen","futureSetting":{"keep":true}}`) |
| 18 | if err != nil { |
| 19 | t.Fatal(err) |
| 20 | } |
| 21 | const content = `{"text":"unsent","attachments":[{"path":"missing-original-file"}],"futureContent":[1,2,3]}` |
| 22 | draft, err = initial.Save(t.Context(), draft.ID, draft.Revision, content, draft.SettingsJSON, false) |
| 23 | if err != nil { |
| 24 | t.Fatal(err) |
| 25 | } |
| 26 | if _, err := initial.Save(t.Context(), draft.ID, draft.Revision-1, `{"text":"conflict copy"}`, draft.SettingsJSON, false); err == nil { |
| 27 | t.Fatal("fixture must contain a conflict") |
| 28 | } |
| 29 | const request = `{"snapshotVersion":3,"settings":{"model":"frozen"},"futureRequest":true}` |
| 30 | _, _, err = initial.BeginOperation(t.Context(), Operation{ID: "operation", DraftID: draft.ID, WorkspaceID: draft.WorkspaceID, DraftRevision: draft.Revision, SessionID: "session", TopicID: "topic", SubmissionID: "submission", Fingerprint: "fingerprint", RequestJSON: request}) |
| 31 | if err != nil { |
| 32 | t.Fatal(err) |
| 33 | } |
| 34 | if err := initial.SetRestore(t.Context(), draft.ID); err != nil { |
| 35 | t.Fatal(err) |
| 36 | } |
| 37 | if err := initial.Close(); err != nil { |
| 38 | t.Fatal(err) |
| 39 | } |
| 40 | db, err := sql.Open("sqlite", path) |
| 41 | if err != nil { |
| 42 | t.Fatal(err) |
| 43 | } |
| 44 | defer db.Close() |
| 45 | statements := []string{`PRAGMA wal_autocheckpoint=0`, `DROP INDEX operations_by_request`, `ALTER TABLE operations DROP COLUMN request_id`, `ALTER TABLE operations DROP COLUMN source_digest`, `ALTER TABLE operations DROP COLUMN operation_revision`, `ALTER TABLE operations DROP COLUMN execution_json`} |
| 46 | if version == 1 { |
| 47 | statements = append(statements, `ALTER TABLE operations DROP COLUMN topic_id`) |
| 48 | } |
| 49 | statements = append(statements, fmt.Sprintf("PRAGMA user_version=%d", version), `CREATE TABLE future_data(value TEXT)`, `INSERT INTO future_data VALUES('committed only in WAL')`) |
| 50 | for _, statement := range statements { |
| 51 | if _, err := db.Exec(statement); err != nil { |
| 52 | t.Fatal(err) |
| 53 | } |
| 54 | } |
| 55 | for range 2 { |
| 56 | upgraded := New(path) |
| 57 | got, err := upgraded.Restore(t.Context()) |
| 58 | if err != nil || got.ID != draft.ID || got.ContentJSON != content || got.SettingsJSON != draft.SettingsJSON || got.Revision != draft.Revision { |
| 59 | t.Fatalf("draft changed across upgrade: %+v %v", got, err) |
| 60 | } |
| 61 | op, err := upgraded.Operation(t.Context(), "operation") |
| 62 | if err != nil || op.SessionID != "session" || op.SubmissionID != "submission" || op.RequestJSON != request || op.Phase != "reserved" { |
| 63 | t.Fatalf("operation changed across upgrade: %+v %v", op, err) |
| 64 | } |
| 65 | if version > 1 && op.TopicID != "topic" { |
| 66 | t.Fatal("existing topic identity changed") |
| 67 | } |
| 68 | if err := upgraded.Close(); err != nil { |
| 69 | t.Fatal(err) |
| 70 | } |
| 71 | } |
| 72 | backup, err := sql.Open("sqlite", path+".pre-v4.sqlite") |
| 73 | if err != nil { |
| 74 | t.Fatal(err) |
| 75 | } |
| 76 | defer backup.Close() |
| 77 | var backupVersion int |
| 78 | if err := backup.QueryRow(`PRAGMA user_version`).Scan(&backupVersion); err != nil || backupVersion != version { |
| 79 | t.Fatalf("backup version=%d %v", backupVersion, err) |
| 80 | } |
| 81 | for _, reader := range []*sql.DB{db, backup} { |
| 82 | var marker, conflict string |
| 83 | if err := reader.QueryRow(`SELECT value FROM future_data`).Scan(&marker); err != nil || marker != "committed only in WAL" { |
| 84 | t.Fatalf("opaque WAL data lost: %q %v", marker, err) |
| 85 | } |
| 86 | if err := reader.QueryRow(`SELECT content_json FROM conflicts`).Scan(&conflict); err != nil || conflict != `{"text":"conflict copy"}` { |
| 87 | t.Fatalf("conflict copy lost: %q %v", conflict, err) |
| 88 | } |
| 89 | } |
| 90 | }) |
| 91 | } |
| 92 | } |
| 93 |