feat(sync): add sync mode configuration (hq-ew1mbr.3)

Add configurable sync modes for Dolt storage integration:

Sync modes:
- git-portable (default): Export JSONL on push, import on pull
- realtime: Export JSONL on every database change
- dolt-native: Use Dolt remotes directly (no JSONL)
- belt-and-suspenders: Both Dolt remote AND JSONL backup

Configuration options in .beads/config.yaml:
- sync.mode: Select sync mode
- sync.export_on: push (default) or change
- sync.import_on: pull (default) or change
- conflict.strategy: newest (default), ours, theirs, manual
- federation.remote: Dolt remote URL for dolt-native mode
- federation.sovereignty: T1-T4 data sovereignty tier

The sync command now displays configuration in `bd sync --status`
and uses configured conflict strategy for resolution.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
darcy
2026-01-17 10:52:08 -08:00
committed by Steve Yegge
parent ba0e754dc8
commit 16f8c3d3ae
4 changed files with 728 additions and 9 deletions

View File

@@ -12,6 +12,67 @@ import (
"github.com/steveyegge/beads/internal/debug"
)
// Sync mode constants define how beads syncs with git/remotes.
const (
// SyncModeGitPortable exports JSONL on push, imports on pull (default).
// This is the standard git-based workflow where JSONL is committed.
SyncModeGitPortable = "git-portable"
// SyncModeRealtime exports JSONL on every database change.
// Legacy behavior, more frequent writes but higher I/O.
SyncModeRealtime = "realtime"
// SyncModeDoltNative uses Dolt remotes directly (dolthub://, gs://, s3://).
// No JSONL export needed - Dolt handles sync.
SyncModeDoltNative = "dolt-native"
// SyncModeBeltAndSuspenders uses both Dolt remote AND JSONL backup.
// Maximum redundancy for critical data.
SyncModeBeltAndSuspenders = "belt-and-suspenders"
)
// Sync trigger constants define when sync operations occur.
const (
// SyncTriggerPush triggers sync on git push operations.
SyncTriggerPush = "push"
// SyncTriggerChange triggers sync on every database change.
SyncTriggerChange = "change"
// SyncTriggerPull triggers import on git pull operations.
SyncTriggerPull = "pull"
)
// Conflict strategy constants define how sync conflicts are resolved.
const (
// ConflictStrategyNewest keeps whichever version has the newer updated_at timestamp.
ConflictStrategyNewest = "newest"
// ConflictStrategyOurs keeps the local version on conflict.
ConflictStrategyOurs = "ours"
// ConflictStrategyTheirs keeps the remote version on conflict.
ConflictStrategyTheirs = "theirs"
// ConflictStrategyManual requires manual resolution of conflicts.
ConflictStrategyManual = "manual"
)
// Federation sovereignty tiers define data sovereignty levels.
const (
// SovereigntyT1 - Full sovereignty: data never leaves controlled infrastructure.
SovereigntyT1 = "T1"
// SovereigntyT2 - Regional sovereignty: data stays within region/jurisdiction.
SovereigntyT2 = "T2"
// SovereigntyT3 - Provider sovereignty: data with trusted cloud provider.
SovereigntyT3 = "T3"
// SovereigntyT4 - No restrictions: data can be anywhere (e.g., DoltHub public).
SovereigntyT4 = "T4"
)
var v *viper.Viper
// Initialize sets up the viper configuration singleton
@@ -108,6 +169,19 @@ func Initialize() error {
// Sync configuration defaults (bd-4u8)
v.SetDefault("sync.require_confirmation_on_mass_delete", false)
// Sync mode configuration (hq-ew1mbr.3)
// See docs/CONFIG.md for detailed documentation
v.SetDefault("sync.mode", SyncModeGitPortable) // git-portable | realtime | dolt-native | belt-and-suspenders
v.SetDefault("sync.export_on", SyncTriggerPush) // push | change
v.SetDefault("sync.import_on", SyncTriggerPull) // pull | change
// Conflict resolution configuration
v.SetDefault("conflict.strategy", ConflictStrategyNewest) // newest | ours | theirs | manual
// Federation configuration (optional Dolt remote)
v.SetDefault("federation.remote", "") // e.g., dolthub://org/beads, gs://bucket/beads, s3://bucket/beads
v.SetDefault("federation.sovereignty", "") // T1 | T2 | T3 | T4 (empty = no restriction)
// Push configuration defaults
v.SetDefault("no-push", false)
@@ -530,3 +604,142 @@ func GetIdentity(flagValue string) string {
return "unknown"
}
// SyncConfig holds the sync mode configuration.
type SyncConfig struct {
Mode string // git-portable, realtime, dolt-native, belt-and-suspenders
ExportOn string // push, change
ImportOn string // pull, change
}
// GetSyncConfig returns the current sync configuration.
func GetSyncConfig() SyncConfig {
return SyncConfig{
Mode: GetSyncMode(),
ExportOn: GetString("sync.export_on"),
ImportOn: GetString("sync.import_on"),
}
}
// GetSyncMode returns the configured sync mode.
// Returns git-portable if not configured or invalid.
func GetSyncMode() string {
mode := GetString("sync.mode")
if mode == "" {
return SyncModeGitPortable
}
// Validate mode
switch mode {
case SyncModeGitPortable, SyncModeRealtime, SyncModeDoltNative, SyncModeBeltAndSuspenders:
return mode
default:
return SyncModeGitPortable
}
}
// IsSyncModeValid checks if the given sync mode is valid.
func IsSyncModeValid(mode string) bool {
switch mode {
case SyncModeGitPortable, SyncModeRealtime, SyncModeDoltNative, SyncModeBeltAndSuspenders:
return true
default:
return false
}
}
// ConflictConfig holds the conflict resolution configuration.
type ConflictConfig struct {
Strategy string // newest, ours, theirs, manual
}
// GetConflictConfig returns the current conflict resolution configuration.
func GetConflictConfig() ConflictConfig {
return ConflictConfig{
Strategy: GetConflictStrategy(),
}
}
// GetConflictStrategy returns the configured conflict resolution strategy.
// Returns newest if not configured or invalid.
func GetConflictStrategy() string {
strategy := GetString("conflict.strategy")
if strategy == "" {
return ConflictStrategyNewest
}
// Validate strategy
switch strategy {
case ConflictStrategyNewest, ConflictStrategyOurs, ConflictStrategyTheirs, ConflictStrategyManual:
return strategy
default:
return ConflictStrategyNewest
}
}
// IsConflictStrategyValid checks if the given conflict strategy is valid.
func IsConflictStrategyValid(strategy string) bool {
switch strategy {
case ConflictStrategyNewest, ConflictStrategyOurs, ConflictStrategyTheirs, ConflictStrategyManual:
return true
default:
return false
}
}
// FederationConfig holds the federation (Dolt remote) configuration.
type FederationConfig struct {
Remote string // dolthub://org/beads, gs://bucket/beads, s3://bucket/beads
Sovereignty string // T1, T2, T3, T4
}
// GetFederationConfig returns the current federation configuration.
func GetFederationConfig() FederationConfig {
return FederationConfig{
Remote: GetString("federation.remote"),
Sovereignty: GetSovereignty(),
}
}
// GetSovereignty returns the configured data sovereignty tier.
// Returns empty string if not configured.
func GetSovereignty() string {
sovereignty := GetString("federation.sovereignty")
// Validate sovereignty tier
switch sovereignty {
case SovereigntyT1, SovereigntyT2, SovereigntyT3, SovereigntyT4:
return sovereignty
default:
return ""
}
}
// IsSovereigntyValid checks if the given sovereignty tier is valid.
func IsSovereigntyValid(sovereignty string) bool {
switch sovereignty {
case "", SovereigntyT1, SovereigntyT2, SovereigntyT3, SovereigntyT4:
return true
default:
return false
}
}
// ShouldExportOnChange returns true if sync.export_on is set to "change".
func ShouldExportOnChange() bool {
return GetString("sync.export_on") == SyncTriggerChange
}
// ShouldImportOnChange returns true if sync.import_on is set to "change".
func ShouldImportOnChange() bool {
return GetString("sync.import_on") == SyncTriggerChange
}
// NeedsDoltRemote returns true if the sync mode requires a Dolt remote.
func NeedsDoltRemote() bool {
mode := GetSyncMode()
return mode == SyncModeDoltNative || mode == SyncModeBeltAndSuspenders
}
// NeedsJSONL returns true if the sync mode requires JSONL export.
func NeedsJSONL() bool {
mode := GetSyncMode()
return mode == SyncModeGitPortable || mode == SyncModeRealtime || mode == SyncModeBeltAndSuspenders
}