diff --git a/README.md b/README.md index a24165c..12ff8b9 100644 --- a/README.md +++ b/README.md @@ -312,8 +312,14 @@ completing. ```bash wl sync # pull upstream changes into your fork wl sync --dry-run # preview what would change +wl sync --upgrade # allow major schema version upgrades ``` +Schema version changes are detected automatically during sync: + +- **MINOR** bumps (e.g. 1.1 → 1.2) are applied automatically +- **MAJOR** bumps (e.g. 1.x → 2.x) are blocked until you pass `--upgrade` + ## Diagnostics ```bash @@ -497,7 +503,7 @@ and the SPA from a single process with no external dependencies. | `wl update ` | Update an open item | `--title`, `--priority`, `--effort`, `--type`, `--tags`, `--project` | | `wl unclaim ` | Release back to open | `--no-push` | | `wl delete ` | Withdraw an open item | `--no-push` | -| `wl sync` | Pull upstream into fork | `--dry-run` | +| `wl sync` | Pull upstream into fork | `--dry-run`, `--upgrade` | | `wl review [branch]` | List or diff PR-mode branches | `--stat`, `--md`, `--json`, `--create-pr` | | `wl approve ` | Approve a PR-mode branch | `--comment` | | `wl request-changes ` | Request changes on a branch | `--comment` (required) | diff --git a/cmd/wl/cmd_sync.go b/cmd/wl/cmd_sync.go index 4f4278f..ea322db 100644 --- a/cmd/wl/cmd_sync.go +++ b/cmd/wl/cmd_sync.go @@ -5,6 +5,8 @@ import ( "io" "os/exec" + "github.com/gastownhall/wasteland/internal/backend" + "github.com/gastownhall/wasteland/internal/commons" "github.com/gastownhall/wasteland/internal/federation" "github.com/gastownhall/wasteland/internal/style" "github.com/spf13/cobra" @@ -12,6 +14,7 @@ import ( func newSyncCmd(stdout, stderr io.Writer) *cobra.Command { var dryRun bool + var upgrade bool cmd := &cobra.Command{ Use: "sync", @@ -22,20 +25,26 @@ func newSyncCmd(stdout, stderr io.Writer) *cobra.Command { If you have a local fork of wl-commons (created by wl join), this pulls the latest changes from upstream. +Schema version changes are detected automatically: + - MINOR version bumps (e.g. 1.1 → 1.2) are applied automatically + - MAJOR version bumps (e.g. 1.x → 2.x) require --upgrade to proceed + EXAMPLES: wl sync # Pull upstream changes - wl sync --dry-run # Show what would change`, + wl sync --dry-run # Show what would change + wl sync --upgrade # Allow major schema version upgrades`, RunE: func(cmd *cobra.Command, _ []string) error { - return runSync(cmd, stdout, stderr, dryRun) + return runSync(cmd, stdout, stderr, dryRun, upgrade) }, } cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Show what would change without pulling") + cmd.Flags().BoolVar(&upgrade, "upgrade", false, "Allow major schema version upgrades") return cmd } -func runSync(cmd *cobra.Command, stdout, stderr io.Writer, dryRun bool) error { +func runSync(cmd *cobra.Command, stdout, stderr io.Writer, dryRun, upgrade bool) error { cfg, err := resolveWasteland(cmd) if err != nil { return hintWrap(err) @@ -60,21 +69,27 @@ func runSync(cmd *cobra.Command, stdout, stderr io.Writer, dryRun bool) error { fmt.Fprintf(stdout, "Local fork: %s\n", style.Dim.Render(forkDir)) + // Fetch upstream before version check (non-destructive). + fetchCmd := exec.Command(doltPath, "fetch", "upstream") + fetchCmd.Dir = forkDir + fetchCmd.Stderr = stderr + if err := fetchCmd.Run(); err != nil { + return fmt.Errorf("fetching upstream: %w", err) + } + + // Check schema versions. + db := backend.NewLocalDB(forkDir, cfg.Mode) + versionMsg, err := checkSchemaVersion(db, stdout, upgrade, dryRun) + if err != nil { + return err + } + if dryRun { fmt.Fprintf(stdout, "\n%s Dry run — checking upstream for changes...\n", style.Bold.Render("~")) - fetchCmd := exec.Command(doltPath, "fetch", "upstream") - fetchCmd.Dir = forkDir - fetchCmd.Stderr = stderr - if err := fetchCmd.Run(); err != nil { - return fmt.Errorf("fetching upstream: %w", err) - } - diffCmd := exec.Command(doltPath, "diff", "--stat", "HEAD", "upstream/main") diffCmd.Dir = forkDir diffCmd.Stderr = stderr - // dolt diff exits non-zero when differences exist, so ignore the - // error when stdout captured output (meaning changes were found). diffOut, _ := diffCmd.Output() if len(diffOut) > 0 { fmt.Fprint(stdout, string(diffOut)) @@ -95,6 +110,9 @@ func runSync(cmd *cobra.Command, stdout, stderr io.Writer, dryRun bool) error { } fmt.Fprintf(stdout, "\n%s Synced with upstream\n", style.Bold.Render("✓")) + if versionMsg != "" { + fmt.Fprintf(stdout, " %s\n", versionMsg) + } updateSyncTimestamp(cfg) // Show summary @@ -120,3 +138,54 @@ func runSync(cmd *cobra.Command, stdout, stderr io.Writer, dryRun bool) error { return nil } + +// checkSchemaVersion compares local and upstream schema versions and returns +// a message to display after sync. Returns an error if a MAJOR bump is +// detected and neither --upgrade nor --dry-run was passed. +// In dry-run mode, major bumps produce a warning but don't block. +func checkSchemaVersion(db commons.DB, stdout io.Writer, upgrade, dryRun bool) (string, error) { + localVer, err := commons.ReadSchemaVersion(db, "") + if err != nil { + // Non-fatal: if we can't read the version, proceed with sync. + return "", nil + } + + upstreamVer, err := commons.ReadSchemaVersion(db, "upstream/main") + if err != nil { + return "", nil + } + + // If either version is missing, proceed without gating. + if localVer.IsZero() || upstreamVer.IsZero() { + return "", nil + } + + delta := commons.CompareVersions(localVer, upstreamVer) + + switch delta { + case commons.VersionSame: + return "", nil + + case commons.VersionMinor: + return fmt.Sprintf("Schema updated: %s → %s (backwards-compatible)", localVer, upstreamVer), nil + + case commons.VersionMajor: + if upgrade { + return fmt.Sprintf("Schema upgraded: %s → %s (major version)", localVer, upstreamVer), nil + } + fmt.Fprintf(stdout, "\n %s Schema upgrade required: %s → %s\n\n", + style.Warning.Render(style.IconWarn), localVer, upstreamVer) + fmt.Fprintf(stdout, " This is a MAJOR version change that may affect your local data.\n") + fmt.Fprintf(stdout, " Review the changelog before upgrading.\n\n") + fmt.Fprintf(stdout, " To proceed: wl sync --upgrade\n") + if dryRun { + return fmt.Sprintf("Schema upgrade required: %s → %s (major version)", localVer, upstreamVer), nil + } + return "", fmt.Errorf("major schema upgrade required (use --upgrade to proceed)") + + case commons.VersionAhead: + return fmt.Sprintf("Note: local schema (%s) is ahead of upstream (%s)", localVer, upstreamVer), nil + } + + return "", nil +} diff --git a/internal/commons/schema.go b/internal/commons/schema.go new file mode 100644 index 0000000..3519791 --- /dev/null +++ b/internal/commons/schema.go @@ -0,0 +1,105 @@ +package commons + +import ( + "fmt" + "strconv" + "strings" +) + +// SchemaVersion represents a MAJOR.MINOR version pair from _meta.schema_version. +type SchemaVersion struct { + Major int + Minor int + Raw string +} + +// String returns the canonical "MAJOR.MINOR" representation. +func (v SchemaVersion) String() string { + return fmt.Sprintf("%d.%d", v.Major, v.Minor) +} + +// IsZero returns true if the version was not set (e.g. missing _meta table). +func (v SchemaVersion) IsZero() bool { + return v.Raw == "" +} + +// ParseSchemaVersion parses a version string like "1.2" into a SchemaVersion. +func ParseSchemaVersion(s string) (SchemaVersion, error) { + s = strings.TrimSpace(s) + if s == "" { + return SchemaVersion{}, fmt.Errorf("empty schema version") + } + + parts := strings.SplitN(s, ".", 2) + if len(parts) != 2 { + return SchemaVersion{}, fmt.Errorf("invalid schema version %q: expected MAJOR.MINOR", s) + } + + major, err := strconv.Atoi(parts[0]) + if err != nil { + return SchemaVersion{}, fmt.Errorf("invalid schema version %q: bad MAJOR: %w", s, err) + } + + minor, err := strconv.Atoi(parts[1]) + if err != nil { + return SchemaVersion{}, fmt.Errorf("invalid schema version %q: bad MINOR: %w", s, err) + } + + return SchemaVersion{Major: major, Minor: minor, Raw: s}, nil +} + +// VersionDelta describes the relationship between local and upstream schema versions. +type VersionDelta int + +const ( + // VersionSame means versions are identical. + VersionSame VersionDelta = iota + // VersionMinor means upstream has a higher MINOR (backwards-compatible). + VersionMinor + // VersionMajor means upstream has a higher MAJOR (potentially breaking). + VersionMajor + // VersionAhead means local is ahead of upstream (unusual). + VersionAhead +) + +// CompareVersions returns the delta between local and upstream versions. +func CompareVersions(local, upstream SchemaVersion) VersionDelta { + if local.Major == upstream.Major && local.Minor == upstream.Minor { + return VersionSame + } + if upstream.Major > local.Major { + return VersionMajor + } + if upstream.Major < local.Major { + return VersionAhead + } + // Same major + if upstream.Minor > local.Minor { + return VersionMinor + } + return VersionAhead +} + +// ReadSchemaVersion queries _meta.schema_version from a dolt database at the given ref. +// ref="" queries local HEAD, "upstream/main" queries the upstream ref. +// Returns a zero SchemaVersion if _meta doesn't exist or has no schema_version row. +func ReadSchemaVersion(db DB, ref string) (SchemaVersion, error) { + query := "SELECT value FROM _meta WHERE `key` = 'schema_version'" + out, err := db.Query(query, ref) + if err != nil { + // Table might not exist in very old databases. + return SchemaVersion{}, nil + } + + lines := strings.Split(strings.TrimSpace(out), "\n") + if len(lines) < 2 { + return SchemaVersion{}, nil + } + + raw := strings.TrimSpace(lines[1]) + if raw == "" { + return SchemaVersion{}, nil + } + + return ParseSchemaVersion(raw) +} diff --git a/internal/commons/schema_test.go b/internal/commons/schema_test.go new file mode 100644 index 0000000..c52f9c9 --- /dev/null +++ b/internal/commons/schema_test.go @@ -0,0 +1,99 @@ +package commons + +import ( + "fmt" + "testing" +) + +func TestParseSchemaVersion(t *testing.T) { + tests := []struct { + input string + want SchemaVersion + wantErr bool + }{ + {"1.2", SchemaVersion{1, 2, "1.2"}, false}, + {"2.0", SchemaVersion{2, 0, "2.0"}, false}, + {"0.1", SchemaVersion{0, 1, "0.1"}, false}, + {"10.20", SchemaVersion{10, 20, "10.20"}, false}, + {"", SchemaVersion{}, true}, + {"abc", SchemaVersion{}, true}, + {"1", SchemaVersion{}, true}, + {"1.2.3", SchemaVersion{1, 2, "1.2.3"}, true}, // we only want MAJOR.MINOR + {"a.b", SchemaVersion{}, true}, + {"1.b", SchemaVersion{}, true}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got, err := ParseSchemaVersion(tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("ParseSchemaVersion(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr) + return + } + if err != nil { + return + } + if got.Major != tt.want.Major || got.Minor != tt.want.Minor { + t.Errorf("ParseSchemaVersion(%q) = %v, want %v", tt.input, got, tt.want) + } + }) + } +} + +func TestParseSchemaVersion_123(t *testing.T) { + // "1.2.3" should fail since SplitN with n=2 produces ["1", "2.3"] + // and "2.3" is not a valid integer for MINOR. + _, err := ParseSchemaVersion("1.2.3") + if err == nil { + t.Error("expected error for 1.2.3 but got nil") + } +} + +func TestCompareVersions(t *testing.T) { + tests := []struct { + name string + local SchemaVersion + upstream SchemaVersion + want VersionDelta + }{ + {"same", sv(1, 2), sv(1, 2), VersionSame}, + {"minor bump", sv(1, 1), sv(1, 2), VersionMinor}, + {"major bump", sv(1, 2), sv(2, 0), VersionMajor}, + {"local ahead minor", sv(1, 3), sv(1, 2), VersionAhead}, + {"local ahead major", sv(2, 0), sv(1, 5), VersionAhead}, + {"major bump with minor", sv(1, 5), sv(2, 3), VersionMajor}, + {"zero to one", sv(0, 1), sv(1, 0), VersionMajor}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := CompareVersions(tt.local, tt.upstream) + if got != tt.want { + t.Errorf("CompareVersions(%v, %v) = %d, want %d", tt.local, tt.upstream, got, tt.want) + } + }) + } +} + +func TestSchemaVersionString(t *testing.T) { + v := SchemaVersion{Major: 1, Minor: 2, Raw: "1.2"} + if s := v.String(); s != "1.2" { + t.Errorf("String() = %q, want %q", s, "1.2") + } +} + +func TestSchemaVersionIsZero(t *testing.T) { + zero := SchemaVersion{} + if !zero.IsZero() { + t.Error("expected zero value to be IsZero()") + } + nonZero := SchemaVersion{Major: 1, Minor: 0, Raw: "1.0"} + if nonZero.IsZero() { + t.Error("expected non-zero value to not be IsZero()") + } +} + +// sv is a test helper to create a SchemaVersion. +func sv(major, minor int) SchemaVersion { + return SchemaVersion{Major: major, Minor: minor, Raw: fmt.Sprintf("%d.%d", major, minor)} +} diff --git a/test/integration/commons_test.go b/test/integration/commons_test.go index 0e378cf..406f693 100644 --- a/test/integration/commons_test.go +++ b/test/integration/commons_test.go @@ -301,7 +301,7 @@ func TestCommonsData_MetaVersion(t *testing.T) { } version := rows[1][0] - if version != "1.1" { - t.Errorf("schema_version = %q, want %q", version, "1.1") + if version != "1.2" { + t.Errorf("schema_version = %q, want %q", version, "1.2") } } diff --git a/test/integration/offline/sync_test.go b/test/integration/offline/sync_test.go index c1c8735..0e63b69 100644 --- a/test/integration/offline/sync_test.go +++ b/test/integration/offline/sync_test.go @@ -55,6 +55,156 @@ CALL DOLT_COMMIT('-m', 'Add new upstream data'); } } +func TestSyncSameVersion(t *testing.T) { + for _, backend := range backends { + t.Run(string(backend), func(t *testing.T) { + env := newTestEnv(t, backend) + env.createUpstreamStoreWithData(t, upstreamOrg, upstreamDB) + env.joinWasteland(t, upstream, forkOrg) + + // Push new data without changing the schema version. + newDataSQL := `INSERT INTO wanted (id, title, status, type, priority, effort_level, created_at, updated_at) +VALUES ('w-same0001', 'Same version item', 'open', 'feature', 2, 'medium', NOW(), NOW()); +CALL DOLT_ADD('-A'); +CALL DOLT_COMMIT('-m', 'Add data without version change'); +` + env.pushToUpstreamStore(t, upstreamOrg, upstreamDB, newDataSQL) + + // Sync should succeed without any schema version message. + stdout, stderr, err := runWL(t, env, "sync") + if err != nil { + t.Fatalf("wl sync failed: %v\nstdout: %s\nstderr: %s", err, stdout, stderr) + } + + if strings.Contains(stdout, "Schema updated") || strings.Contains(stdout, "Schema upgraded") { + t.Errorf("expected no schema version message when versions match, got: %s", stdout) + } + + // Data should have synced. + cfg := env.loadConfig(t, upstream) + forkDir := cfg["local_dir"].(string) + raw := doltSQL(t, forkDir, "SELECT COUNT(*) FROM wanted WHERE id='w-same0001'") + rows := parseCSV(t, raw) + if len(rows) < 2 || rows[1][0] != "1" { + t.Errorf("fork should have new data after sync; got count=%s", rows[1][0]) + } + }) + } +} + +func TestSyncLocalAhead(t *testing.T) { + for _, backend := range backends { + t.Run(string(backend), func(t *testing.T) { + env := newTestEnv(t, backend) + env.createUpstreamStoreWithData(t, upstreamOrg, upstreamDB) + env.joinWasteland(t, upstream, forkOrg) + + // Bump the LOCAL schema version ahead of upstream (simulates + // a newer version of the program having run locally). + cfg := env.loadConfig(t, upstream) + forkDir := cfg["local_dir"].(string) + doltSQLScript(t, env, forkDir, + "UPDATE _meta SET value = '2.0' WHERE `key` = 'schema_version';\n"+ + "CALL DOLT_ADD('-A');\n"+ + "CALL DOLT_COMMIT('-m', 'Local bump to 2.0');\n") + + // Sync should succeed (local ahead is not an error) and note it. + stdout, stderr, err := runWL(t, env, "sync") + if err != nil { + t.Fatalf("wl sync failed: %v\nstdout: %s\nstderr: %s", err, stdout, stderr) + } + + if !strings.Contains(stdout, "ahead") { + t.Errorf("expected 'ahead' note in output, got: %s", stdout) + } + }) + } +} + +func TestSyncMinorVersionBump(t *testing.T) { + for _, backend := range backends { + t.Run(string(backend), func(t *testing.T) { + env := newTestEnv(t, backend) + env.createUpstreamStoreWithData(t, upstreamOrg, upstreamDB) + env.joinWasteland(t, upstream, forkOrg) + + // Bump upstream schema version from 1.2 → 1.3 (minor). + bumpSQL := `UPDATE _meta SET value = '1.3' WHERE ` + "`key`" + ` = 'schema_version'; +CALL DOLT_ADD('-A'); +CALL DOLT_COMMIT('-m', 'Bump schema to 1.3'); +` + env.pushToUpstreamStore(t, upstreamOrg, upstreamDB, bumpSQL) + + // Sync should succeed and mention the schema update. + stdout, stderr, err := runWL(t, env, "sync") + if err != nil { + t.Fatalf("wl sync failed: %v\nstdout: %s\nstderr: %s", err, stdout, stderr) + } + + if !strings.Contains(stdout, "Schema updated") { + t.Errorf("expected 'Schema updated' in output, got: %s", stdout) + } + if !strings.Contains(stdout, "backwards-compatible") { + t.Errorf("expected 'backwards-compatible' in output, got: %s", stdout) + } + }) + } +} + +func TestSyncMajorVersionBlocked(t *testing.T) { + for _, backend := range backends { + t.Run(string(backend), func(t *testing.T) { + env := newTestEnv(t, backend) + env.createUpstreamStoreWithData(t, upstreamOrg, upstreamDB) + env.joinWasteland(t, upstream, forkOrg) + + // Bump upstream schema version from 1.2 → 2.0 (major). + bumpSQL := `UPDATE _meta SET value = '2.0' WHERE ` + "`key`" + ` = 'schema_version'; +CALL DOLT_ADD('-A'); +CALL DOLT_COMMIT('-m', 'Bump schema to 2.0'); +` + env.pushToUpstreamStore(t, upstreamOrg, upstreamDB, bumpSQL) + + // Sync without --upgrade should fail. + stdout, _, err := runWL(t, env, "sync") + if err == nil { + t.Fatal("expected wl sync to fail on major version bump without --upgrade") + } + + if !strings.Contains(stdout, "upgrade") { + t.Errorf("expected upgrade instructions in output, got: %s", stdout) + } + }) + } +} + +func TestSyncMajorVersionWithUpgrade(t *testing.T) { + for _, backend := range backends { + t.Run(string(backend), func(t *testing.T) { + env := newTestEnv(t, backend) + env.createUpstreamStoreWithData(t, upstreamOrg, upstreamDB) + env.joinWasteland(t, upstream, forkOrg) + + // Bump upstream schema version from 1.2 → 2.0 (major). + bumpSQL := `UPDATE _meta SET value = '2.0' WHERE ` + "`key`" + ` = 'schema_version'; +CALL DOLT_ADD('-A'); +CALL DOLT_COMMIT('-m', 'Bump schema to 2.0'); +` + env.pushToUpstreamStore(t, upstreamOrg, upstreamDB, bumpSQL) + + // Sync with --upgrade should succeed. + stdout, stderr, err := runWL(t, env, "sync", "--upgrade") + if err != nil { + t.Fatalf("wl sync --upgrade failed: %v\nstdout: %s\nstderr: %s", err, stdout, stderr) + } + + if !strings.Contains(stdout, "Schema upgraded") { + t.Errorf("expected 'Schema upgraded' in output, got: %s", stdout) + } + }) + } +} + func TestSyncDryRun(t *testing.T) { for _, backend := range backends { t.Run(string(backend), func(t *testing.T) { @@ -93,3 +243,118 @@ CALL DOLT_COMMIT('-m', 'Add dry run data'); }) } } + +func TestSyncDryRunMajorVersion(t *testing.T) { + for _, backend := range backends { + t.Run(string(backend), func(t *testing.T) { + env := newTestEnv(t, backend) + env.createUpstreamStoreWithData(t, upstreamOrg, upstreamDB) + env.joinWasteland(t, upstream, forkOrg) + + cfg := env.loadConfig(t, upstream) + forkDir := cfg["local_dir"].(string) + + // Bump upstream schema version from 1.2 → 2.0 (major). + bumpSQL := `UPDATE _meta SET value = '2.0' WHERE ` + "`key`" + ` = 'schema_version'; +CALL DOLT_ADD('-A'); +CALL DOLT_COMMIT('-m', 'Bump schema to 2.0'); +` + env.pushToUpstreamStore(t, upstreamOrg, upstreamDB, bumpSQL) + + // Dry-run should NOT block on major version — it should warn + // and still show the diff so the user can evaluate. + stdout, stderr, err := runWL(t, env, "sync", "--dry-run") + if err != nil { + t.Fatalf("wl sync --dry-run should not fail on major bump: %v\nstdout: %s\nstderr: %s", err, stdout, stderr) + } + + if !strings.Contains(stdout, "upgrade") { + t.Errorf("expected upgrade warning in dry-run output, got: %s", stdout) + } + if !strings.Contains(stdout, "Dry run") { + t.Errorf("expected 'Dry run' in output, got: %s", stdout) + } + + // Fork should NOT have pulled the data (dry-run). + raw := doltSQL(t, forkDir, "SELECT value FROM _meta WHERE `key` = 'schema_version'") + rows := parseCSV(t, raw) + if len(rows) >= 2 && rows[1][0] != "1.2" { + t.Errorf("fork schema_version should still be 1.2 after dry-run, got %s", rows[1][0]) + } + }) + } +} + +func TestSyncMissingSchemaVersion(t *testing.T) { + for _, backend := range backends { + t.Run(string(backend), func(t *testing.T) { + env := newTestEnv(t, backend) + env.createUpstreamStoreWithData(t, upstreamOrg, upstreamDB) + env.joinWasteland(t, upstream, forkOrg) + + cfg := env.loadConfig(t, upstream) + forkDir := cfg["local_dir"].(string) + + // Delete the schema_version row from the local fork to simulate + // a pre-versioned database. + doltSQLScript(t, env, forkDir, + "DELETE FROM _meta WHERE `key` = 'schema_version';\n"+ + "CALL DOLT_ADD('-A');\n"+ + "CALL DOLT_COMMIT('-m', 'Remove schema_version (pre-versioned db)');\n") + + // Push new data upstream (version stays at 1.2 there). + newDataSQL := `INSERT INTO wanted (id, title, status, type, priority, effort_level, created_at, updated_at) +VALUES ('w-nover001', 'No-version item', 'open', 'feature', 2, 'medium', NOW(), NOW()); +CALL DOLT_ADD('-A'); +CALL DOLT_COMMIT('-m', 'Add data to versioned upstream'); +` + env.pushToUpstreamStore(t, upstreamOrg, upstreamDB, newDataSQL) + + // Sync should succeed without gating — missing version is non-fatal. + stdout, stderr, err := runWL(t, env, "sync") + if err != nil { + t.Fatalf("wl sync failed with missing schema_version: %v\nstdout: %s\nstderr: %s", err, stdout, stderr) + } + + // No schema messages expected. + if strings.Contains(stdout, "Schema updated") || strings.Contains(stdout, "Schema upgraded") { + t.Errorf("expected no schema message with missing version, got: %s", stdout) + } + + // Data should have synced through. + raw := doltSQL(t, forkDir, "SELECT COUNT(*) FROM wanted WHERE id='w-nover001'") + rows := parseCSV(t, raw) + if len(rows) < 2 || rows[1][0] != "1" { + t.Errorf("fork should have new data after sync; got count=%s", rows[1][0]) + } + }) + } +} + +func TestSyncCorruptSchemaVersion(t *testing.T) { + for _, backend := range backends { + t.Run(string(backend), func(t *testing.T) { + env := newTestEnv(t, backend) + env.createUpstreamStoreWithData(t, upstreamOrg, upstreamDB) + env.joinWasteland(t, upstream, forkOrg) + + // Corrupt the upstream schema_version to an unparseable value. + corruptSQL := `UPDATE _meta SET value = 'abc' WHERE ` + "`key`" + ` = 'schema_version'; +CALL DOLT_ADD('-A'); +CALL DOLT_COMMIT('-m', 'Corrupt schema_version'); +` + env.pushToUpstreamStore(t, upstreamOrg, upstreamDB, corruptSQL) + + // Sync should succeed — corrupt version is non-fatal, sync proceeds. + stdout, stderr, err := runWL(t, env, "sync") + if err != nil { + t.Fatalf("wl sync should not fail on corrupt schema_version: %v\nstdout: %s\nstderr: %s", err, stdout, stderr) + } + + // Should not crash or mention schema changes. + if strings.Contains(stdout, "Schema updated") || strings.Contains(stdout, "Schema upgraded") { + t.Errorf("expected no schema message with corrupt version, got: %s", stdout) + } + }) + } +}