Add peer-to-peer synchronization for Dolt-backed beads databases: - New FederatedStorage interface with PushTo, PullFrom, Fetch, ListRemotes, RemoveRemote, and SyncStatus methods - DoltStore implementation using DOLT_PUSH, DOLT_PULL, DOLT_FETCH - Full bd federation command with subcommands: - sync: bidirectional sync with conflict resolution (--strategy ours|theirs) - status: show ahead/behind counts and conflict state - add-peer/remove-peer/list-peers: manage federation remotes - Comprehensive tests for all federation APIs Closes: bd-wkumz.4 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
456 lines
13 KiB
Go
456 lines
13 KiB
Go
//go:build integration
|
|
// +build integration
|
|
|
|
package dolt
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/steveyegge/beads/internal/types"
|
|
)
|
|
|
|
// Federation Prototype Tests
|
|
//
|
|
// These tests validate the Dolt APIs needed for federation between towns.
|
|
// Production federation uses hosted Dolt remotes (DoltHub, S3, etc.), not file://.
|
|
//
|
|
// What we can test locally:
|
|
// 1. Database isolation between towns (separate Dolt databases)
|
|
// 2. Version control APIs (commit, branch, merge)
|
|
// 3. Remote configuration APIs (AddRemote)
|
|
// 4. History and diff queries
|
|
//
|
|
// What requires hosted infrastructure:
|
|
// 1. Actual push/pull between towns (needs DoltHub or dolt sql-server)
|
|
// 2. Cross-town sync via DOLT_FETCH/DOLT_PUSH
|
|
// 3. Federation message exchange
|
|
//
|
|
// See ~/hop/docs/architecture/FEDERATION.md for full federation spec.
|
|
|
|
// TestFederationDatabaseIsolation verifies that two DoltStores have isolated databases
|
|
func TestFederationDatabaseIsolation(t *testing.T) {
|
|
skipIfNoDolt(t)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
|
defer cancel()
|
|
|
|
baseDir, err := os.MkdirTemp("", "federation-isolation-*")
|
|
if err != nil {
|
|
t.Fatalf("failed to create base dir: %v", err)
|
|
}
|
|
defer os.RemoveAll(baseDir)
|
|
|
|
// Setup Town Alpha
|
|
alphaDir := filepath.Join(baseDir, "town-alpha")
|
|
alphaStore, alphaCleanup := setupFederationStore(t, ctx, alphaDir, "alpha")
|
|
defer alphaCleanup()
|
|
|
|
// Setup Town Beta
|
|
betaDir := filepath.Join(baseDir, "town-beta")
|
|
betaStore, betaCleanup := setupFederationStore(t, ctx, betaDir, "beta")
|
|
defer betaCleanup()
|
|
|
|
t.Logf("Alpha path: %s", alphaStore.Path())
|
|
t.Logf("Beta path: %s", betaStore.Path())
|
|
|
|
// Create issue in Alpha
|
|
alphaIssue := &types.Issue{
|
|
ID: "alpha-001",
|
|
Title: "Work item from Town Alpha",
|
|
Description: "This issue exists only in Town Alpha",
|
|
IssueType: types.TypeTask,
|
|
Status: types.StatusOpen,
|
|
Priority: 1,
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
if err := alphaStore.CreateIssue(ctx, alphaIssue, "federation-test"); err != nil {
|
|
t.Fatalf("failed to create issue in alpha: %v", err)
|
|
}
|
|
if err := alphaStore.Commit(ctx, "Create alpha-001"); err != nil {
|
|
t.Fatalf("failed to commit in alpha: %v", err)
|
|
}
|
|
|
|
// Create different issue in Beta
|
|
betaIssue := &types.Issue{
|
|
ID: "beta-001",
|
|
Title: "Work item from Town Beta",
|
|
Description: "This issue exists only in Town Beta",
|
|
IssueType: types.TypeTask,
|
|
Status: types.StatusOpen,
|
|
Priority: 2,
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
if err := betaStore.CreateIssue(ctx, betaIssue, "federation-test"); err != nil {
|
|
t.Fatalf("failed to create issue in beta: %v", err)
|
|
}
|
|
if err := betaStore.Commit(ctx, "Create beta-001"); err != nil {
|
|
t.Fatalf("failed to commit in beta: %v", err)
|
|
}
|
|
|
|
// Verify isolation: Alpha should NOT see Beta's issue
|
|
issueFromAlpha, err := alphaStore.GetIssue(ctx, "beta-001")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if issueFromAlpha != nil {
|
|
t.Fatalf("isolation violated: alpha found beta-001")
|
|
}
|
|
t.Log("✓ Alpha cannot see beta-001")
|
|
|
|
// Verify isolation: Beta should NOT see Alpha's issue
|
|
issueFromBeta, err := betaStore.GetIssue(ctx, "alpha-001")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if issueFromBeta != nil {
|
|
t.Fatalf("isolation violated: beta found alpha-001")
|
|
}
|
|
t.Log("✓ Beta cannot see alpha-001")
|
|
|
|
// Verify each town sees its own issue
|
|
alphaCheck, _ := alphaStore.GetIssue(ctx, "alpha-001")
|
|
if alphaCheck == nil {
|
|
t.Fatal("alpha should see its own issue")
|
|
}
|
|
t.Logf("✓ Alpha sees alpha-001: %q", alphaCheck.Title)
|
|
|
|
betaCheck, _ := betaStore.GetIssue(ctx, "beta-001")
|
|
if betaCheck == nil {
|
|
t.Fatal("beta should see its own issue")
|
|
}
|
|
t.Logf("✓ Beta sees beta-001: %q", betaCheck.Title)
|
|
}
|
|
|
|
// TestFederationVersionControlAPIs tests the Dolt version control operations
|
|
// needed for federation (branch, commit, merge)
|
|
func TestFederationVersionControlAPIs(t *testing.T) {
|
|
skipIfNoDolt(t)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
|
defer cancel()
|
|
|
|
store, cleanup := setupTestStore(t)
|
|
defer cleanup()
|
|
|
|
// Create initial issue
|
|
issue := &types.Issue{
|
|
ID: "vc-001",
|
|
Title: "Version control test",
|
|
IssueType: types.TypeTask,
|
|
Status: types.StatusOpen,
|
|
Priority: 1,
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
if err := store.CreateIssue(ctx, issue, "test"); err != nil {
|
|
t.Fatalf("failed to create issue: %v", err)
|
|
}
|
|
if err := store.Commit(ctx, "Initial issue"); err != nil {
|
|
t.Fatalf("failed to commit: %v", err)
|
|
}
|
|
|
|
// Test branch creation
|
|
if err := store.Branch(ctx, "feature-branch"); err != nil {
|
|
t.Fatalf("failed to create branch: %v", err)
|
|
}
|
|
t.Log("✓ Created feature-branch")
|
|
|
|
// Test checkout
|
|
if err := store.Checkout(ctx, "feature-branch"); err != nil {
|
|
t.Fatalf("failed to checkout: %v", err)
|
|
}
|
|
|
|
// Verify current branch
|
|
branch, err := store.CurrentBranch(ctx)
|
|
if err != nil {
|
|
t.Fatalf("failed to get current branch: %v", err)
|
|
}
|
|
if branch != "feature-branch" {
|
|
t.Errorf("expected feature-branch, got %s", branch)
|
|
}
|
|
t.Logf("✓ Checked out to %s", branch)
|
|
|
|
// Make change on feature branch
|
|
updates := map[string]interface{}{
|
|
"title": "Updated on feature branch",
|
|
}
|
|
if err := store.UpdateIssue(ctx, "vc-001", updates, "test"); err != nil {
|
|
t.Fatalf("failed to update: %v", err)
|
|
}
|
|
if err := store.Commit(ctx, "Feature update"); err != nil {
|
|
t.Fatalf("failed to commit: %v", err)
|
|
}
|
|
|
|
// Switch back to main
|
|
if err := store.Checkout(ctx, "main"); err != nil {
|
|
t.Fatalf("failed to checkout main: %v", err)
|
|
}
|
|
|
|
// Verify main still has original title
|
|
mainIssue, _ := store.GetIssue(ctx, "vc-001")
|
|
if mainIssue.Title != "Version control test" {
|
|
t.Errorf("main should have original title, got %q", mainIssue.Title)
|
|
}
|
|
t.Log("✓ Main branch unchanged")
|
|
|
|
// Merge feature branch
|
|
conflicts, err := store.Merge(ctx, "feature-branch")
|
|
if err != nil {
|
|
t.Fatalf("failed to merge: %v", err)
|
|
}
|
|
if len(conflicts) > 0 {
|
|
t.Logf("Merge produced %d conflicts", len(conflicts))
|
|
}
|
|
t.Log("✓ Merged feature-branch into main")
|
|
|
|
// Verify merge result
|
|
mergedIssue, _ := store.GetIssue(ctx, "vc-001")
|
|
if mergedIssue.Title != "Updated on feature branch" {
|
|
t.Errorf("expected merged title, got %q", mergedIssue.Title)
|
|
}
|
|
t.Logf("✓ Merge applied: title now %q", mergedIssue.Title)
|
|
|
|
// Test branch listing
|
|
branches, err := store.ListBranches(ctx)
|
|
if err != nil {
|
|
t.Fatalf("failed to list branches: %v", err)
|
|
}
|
|
t.Logf("✓ Branches: %v", branches)
|
|
}
|
|
|
|
// TestFederationRemoteConfiguration tests AddRemote API
|
|
// Note: This only tests configuration, not actual push/pull which requires a running remote
|
|
func TestFederationRemoteConfiguration(t *testing.T) {
|
|
skipIfNoDolt(t)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
|
defer cancel()
|
|
|
|
store, cleanup := setupTestStore(t)
|
|
defer cleanup()
|
|
|
|
// Add a remote (configuration only - won't actually connect)
|
|
// Production would use: dolthub://org/repo, s3://bucket/path, etc.
|
|
err := store.AddRemote(ctx, "origin", "dolthub://example/beads")
|
|
if err != nil {
|
|
// AddRemote may fail if remote can't be validated, which is expected
|
|
t.Logf("AddRemote result: %v (expected for unreachable remote)", err)
|
|
} else {
|
|
t.Log("✓ Added remote 'origin'")
|
|
}
|
|
|
|
// Add federation peer remote
|
|
err = store.AddRemote(ctx, "town-beta", "dolthub://acme/town-beta-beads")
|
|
if err != nil {
|
|
t.Logf("AddRemote town-beta result: %v", err)
|
|
} else {
|
|
t.Log("✓ Added remote 'town-beta'")
|
|
}
|
|
}
|
|
|
|
// TestFederationHistoryQueries tests history queries needed for CV and audit
|
|
func TestFederationHistoryQueries(t *testing.T) {
|
|
skipIfNoDolt(t)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
|
defer cancel()
|
|
|
|
store, cleanup := setupTestStore(t)
|
|
defer cleanup()
|
|
|
|
// Create issue
|
|
issue := &types.Issue{
|
|
ID: "hist-001",
|
|
Title: "History test - v1",
|
|
IssueType: types.TypeTask,
|
|
Status: types.StatusOpen,
|
|
Priority: 1,
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
if err := store.CreateIssue(ctx, issue, "test"); err != nil {
|
|
t.Fatalf("failed to create: %v", err)
|
|
}
|
|
if err := store.Commit(ctx, "Create hist-001 v1"); err != nil {
|
|
t.Fatalf("failed to commit: %v", err)
|
|
}
|
|
|
|
// Update multiple times
|
|
for i := 2; i <= 3; i++ {
|
|
updates := map[string]interface{}{
|
|
"title": "History test - v" + string(rune('0'+i)),
|
|
}
|
|
if err := store.UpdateIssue(ctx, "hist-001", updates, "test"); err != nil {
|
|
t.Fatalf("failed to update v%d: %v", i, err)
|
|
}
|
|
if err := store.Commit(ctx, "Update to v"+string(rune('0'+i))); err != nil {
|
|
t.Fatalf("failed to commit v%d: %v", i, err)
|
|
}
|
|
}
|
|
|
|
// Query history
|
|
history, err := store.History(ctx, "hist-001")
|
|
if err != nil {
|
|
t.Fatalf("failed to get history: %v", err)
|
|
}
|
|
t.Logf("✓ Found %d history entries for hist-001", len(history))
|
|
for i, entry := range history {
|
|
t.Logf(" [%d] %s: %s", i, entry.CommitHash[:8], entry.Issue.Title)
|
|
}
|
|
|
|
// Get current commit
|
|
hash, err := store.GetCurrentCommit(ctx)
|
|
if err != nil {
|
|
t.Fatalf("failed to get current commit: %v", err)
|
|
}
|
|
t.Logf("✓ Current commit: %s", hash[:12])
|
|
|
|
// Query recent log
|
|
log, err := store.Log(ctx, 5)
|
|
if err != nil {
|
|
t.Fatalf("failed to get log: %v", err)
|
|
}
|
|
t.Logf("✓ Recent commits:")
|
|
for _, c := range log {
|
|
t.Logf(" %s: %s", c.Hash[:8], c.Message)
|
|
}
|
|
}
|
|
|
|
// TestFederationListRemotes tests the ListRemotes API
|
|
func TestFederationListRemotes(t *testing.T) {
|
|
skipIfNoDolt(t)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
|
defer cancel()
|
|
|
|
store, cleanup := setupTestStore(t)
|
|
defer cleanup()
|
|
|
|
// Initially no remotes (except possibly origin if Dolt adds one by default)
|
|
remotes, err := store.ListRemotes(ctx)
|
|
if err != nil {
|
|
t.Fatalf("failed to list remotes: %v", err)
|
|
}
|
|
t.Logf("Initial remotes: %d", len(remotes))
|
|
|
|
// Add a test remote
|
|
err = store.AddRemote(ctx, "test-peer", "file:///tmp/nonexistent")
|
|
if err != nil {
|
|
t.Logf("AddRemote returned: %v (may be expected)", err)
|
|
}
|
|
|
|
// List again
|
|
remotes, err = store.ListRemotes(ctx)
|
|
if err != nil {
|
|
t.Fatalf("failed to list remotes after add: %v", err)
|
|
}
|
|
|
|
// Should have at least one remote now
|
|
t.Logf("Remotes after add: %v", remotes)
|
|
for _, r := range remotes {
|
|
t.Logf(" %s: %s", r.Name, r.URL)
|
|
}
|
|
}
|
|
|
|
// TestFederationSyncStatus tests the SyncStatus API
|
|
func TestFederationSyncStatus(t *testing.T) {
|
|
skipIfNoDolt(t)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
|
defer cancel()
|
|
|
|
store, cleanup := setupTestStore(t)
|
|
defer cleanup()
|
|
|
|
// Get status for a nonexistent peer (should not error, just return partial data)
|
|
status, err := store.SyncStatus(ctx, "nonexistent-peer")
|
|
if err != nil {
|
|
t.Fatalf("SyncStatus failed: %v", err)
|
|
}
|
|
|
|
t.Logf("Status for nonexistent peer:")
|
|
t.Logf(" Peer: %s", status.Peer)
|
|
t.Logf(" LocalAhead: %d", status.LocalAhead)
|
|
t.Logf(" LocalBehind: %d", status.LocalBehind)
|
|
t.Logf(" HasConflicts: %v", status.HasConflicts)
|
|
|
|
// LocalAhead/Behind should be -1 (unknown) for nonexistent peer
|
|
if status.LocalAhead != -1 || status.LocalBehind != -1 {
|
|
t.Logf("Note: Status returned values for nonexistent peer (may be expected behavior)")
|
|
}
|
|
}
|
|
|
|
// TestFederationPushPullMethods tests PushTo and PullFrom
|
|
func TestFederationPushPullMethods(t *testing.T) {
|
|
skipIfNoDolt(t)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
|
defer cancel()
|
|
|
|
store, cleanup := setupTestStore(t)
|
|
defer cleanup()
|
|
|
|
// These should fail gracefully since no remote exists
|
|
err := store.PushTo(ctx, "nonexistent")
|
|
if err == nil {
|
|
t.Log("PushTo to nonexistent peer succeeded (unexpected)")
|
|
} else {
|
|
t.Logf("✓ PushTo correctly failed: %v", err)
|
|
}
|
|
|
|
conflicts, err := store.PullFrom(ctx, "nonexistent")
|
|
if err == nil {
|
|
t.Logf("PullFrom from nonexistent peer succeeded with %d conflicts", len(conflicts))
|
|
} else {
|
|
t.Logf("✓ PullFrom correctly failed: %v", err)
|
|
}
|
|
|
|
err = store.Fetch(ctx, "nonexistent")
|
|
if err == nil {
|
|
t.Log("Fetch from nonexistent peer succeeded (unexpected)")
|
|
} else {
|
|
t.Logf("✓ Fetch correctly failed: %v", err)
|
|
}
|
|
}
|
|
|
|
// setupFederationStore creates a Dolt store for federation testing
|
|
func setupFederationStore(t *testing.T, ctx context.Context, path, prefix string) (*DoltStore, func()) {
|
|
t.Helper()
|
|
|
|
cfg := &Config{
|
|
Path: path,
|
|
CommitterName: "town-" + prefix,
|
|
CommitterEmail: prefix + "@federation.test",
|
|
Database: "beads",
|
|
}
|
|
|
|
store, err := New(ctx, cfg)
|
|
if err != nil {
|
|
t.Fatalf("failed to create %s store: %v", prefix, err)
|
|
}
|
|
|
|
// Set up issue prefix
|
|
if err := store.SetConfig(ctx, "issue_prefix", prefix); err != nil {
|
|
store.Close()
|
|
t.Fatalf("failed to set prefix for %s: %v", prefix, err)
|
|
}
|
|
|
|
// Initial commit to establish main branch
|
|
if err := store.Commit(ctx, "Initialize "+prefix+" town"); err != nil {
|
|
// Ignore if nothing to commit
|
|
t.Logf("Initial commit for %s: %v", prefix, err)
|
|
}
|
|
|
|
cleanup := func() {
|
|
store.Close()
|
|
}
|
|
|
|
return store, cleanup
|
|
}
|