Files
beads/cmd/bd/export_mtime_test.go
Charles P. Cross 04a1996fd9 fix: Fix daemon export leaving JSONL newer than database (issues #301, #321)
After daemon auto-export, JSONL mtime could be newer than database mtime
due to SQLite WAL mode not updating beads.db until checkpoint. This caused
validatePreExport to incorrectly block subsequent exports with "JSONL is
newer than database" error, leading to daemon shutdown.

Solution: Call TouchDatabaseFile after all export operations to ensure
database mtime >= JSONL mtime. This prevents false positives in validation
2025-11-19 05:06:12 -05:00

243 lines
6.9 KiB
Go

package main
import (
"context"
"os"
"path/filepath"
"testing"
"time"
"github.com/steveyegge/beads/internal/storage/sqlite"
"github.com/steveyegge/beads/internal/types"
)
// TestExportUpdatesDatabaseMtime verifies that export updates database mtime
// to be >= JSONL mtime, fixing issues #278, #301, #321
func TestExportUpdatesDatabaseMtime(t *testing.T) {
if testing.Short() {
t.Skip("skipping slow test in short mode")
}
tmpDir := t.TempDir()
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.Mkdir(beadsDir, 0750); err != nil {
t.Fatal(err)
}
dbPath := filepath.Join(beadsDir, "beads.db")
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
// Create and populate database
store, err := sqlite.New(dbPath)
if err != nil {
t.Fatalf("Failed to create store: %v", err)
}
defer store.Close()
ctx := context.Background()
// Initialize database with issue_prefix
if err := store.SetConfig(ctx, "issue_prefix", "test"); err != nil {
t.Fatalf("Failed to set issue_prefix: %v", err)
}
// Create a test issue
issue := &types.Issue{
ID: "test-1",
Title: "Test Issue",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeTask,
}
if err := store.CreateIssue(ctx, issue, "test-actor"); err != nil {
t.Fatalf("Failed to create issue: %v", err)
}
// Wait a bit to ensure mtime difference
time.Sleep(1 * time.Second)
// Export to JSONL (simulates daemon export)
if err := exportToJSONLWithStore(ctx, store, jsonlPath); err != nil {
t.Fatalf("Export failed: %v", err)
}
// Get JSONL mtime
jsonlInfo, err := os.Stat(jsonlPath)
if err != nil {
t.Fatalf("Failed to stat JSONL after export: %v", err)
}
// WITHOUT the fix, JSONL would be newer than DB here
// Simulating the old buggy behavior before calling TouchDatabaseFile
dbInfoAfterExport, err := os.Stat(dbPath)
if err != nil {
t.Fatalf("Failed to stat database after export: %v", err)
}
// In old buggy behavior, JSONL mtime > DB mtime
t.Logf("Before TouchDatabaseFile: DB mtime=%v, JSONL mtime=%v",
dbInfoAfterExport.ModTime(), jsonlInfo.ModTime())
// Now apply the fix
if err := TouchDatabaseFile(dbPath, jsonlPath); err != nil {
t.Fatalf("TouchDatabaseFile failed: %v", err)
}
// Get final database mtime
dbInfoAfterTouch, err := os.Stat(dbPath)
if err != nil {
t.Fatalf("Failed to stat database after touch: %v", err)
}
t.Logf("After TouchDatabaseFile: DB mtime=%v, JSONL mtime=%v",
dbInfoAfterTouch.ModTime(), jsonlInfo.ModTime())
// VERIFY: Database mtime should be >= JSONL mtime
if dbInfoAfterTouch.ModTime().Before(jsonlInfo.ModTime()) {
t.Errorf("Database mtime should be >= JSONL mtime after export")
t.Errorf("DB mtime: %v, JSONL mtime: %v",
dbInfoAfterTouch.ModTime(), jsonlInfo.ModTime())
}
// VERIFY: validatePreExport should now pass (not block on next export)
if err := validatePreExport(ctx, store, jsonlPath); err != nil {
t.Errorf("validatePreExport should pass after TouchDatabaseFile, but got error: %v", err)
}
}
// TestDaemonExportScenario simulates the full daemon auto-export workflow
// that was causing issue #278 (daemon shutting down after export)
func TestDaemonExportScenario(t *testing.T) {
if testing.Short() {
t.Skip("skipping slow test in short mode")
}
tmpDir := t.TempDir()
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.Mkdir(beadsDir, 0750); err != nil {
t.Fatal(err)
}
dbPath := filepath.Join(beadsDir, "beads.db")
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
// Create and populate database
store, err := sqlite.New(dbPath)
if err != nil {
t.Fatalf("Failed to create store: %v", err)
}
defer store.Close()
ctx := context.Background()
// Initialize database with issue_prefix
if err := store.SetConfig(ctx, "issue_prefix", "bd"); err != nil {
t.Fatalf("Failed to set issue_prefix: %v", err)
}
// Step 1: User creates an issue (e.g., bd close bd-123)
now := time.Now()
issue := &types.Issue{
ID: "bd-123",
Title: "User created issue",
Status: types.StatusClosed,
Priority: 1,
IssueType: types.TypeTask,
ClosedAt: &now,
}
if err := store.CreateIssue(ctx, issue, "test-user"); err != nil {
t.Fatalf("Failed to create issue: %v", err)
}
// Database is now newer than JSONL (JSONL doesn't exist yet)
time.Sleep(1 * time.Second)
// Step 2: Daemon auto-exports after delay (30s-4min in real scenario)
// This simulates the daemon's export cycle
if err := exportToJSONLWithStore(ctx, store, jsonlPath); err != nil {
t.Fatalf("Daemon export failed: %v", err)
}
// THIS IS THE FIX: daemon now calls TouchDatabaseFile after export
if err := TouchDatabaseFile(dbPath, jsonlPath); err != nil {
t.Fatalf("TouchDatabaseFile failed: %v", err)
}
// Step 3: User runs bd sync shortly after
// WITHOUT the fix, this would fail with "JSONL is newer than database"
// WITH the fix, this should succeed
if err := validatePreExport(ctx, store, jsonlPath); err != nil {
t.Errorf("Daemon export scenario failed: validatePreExport blocked after daemon export")
t.Errorf("This is the bug from issue #278/#301/#321: %v", err)
}
// Verify we can export again (simulates bd sync)
jsonlPathTemp := jsonlPath + ".sync"
if err := exportToJSONLWithStore(ctx, store, jsonlPathTemp); err != nil {
t.Errorf("Second export (bd sync) failed: %v", err)
}
os.Remove(jsonlPathTemp)
}
// TestMultipleExportCycles verifies repeated export cycles don't cause issues
func TestMultipleExportCycles(t *testing.T) {
if testing.Short() {
t.Skip("skipping slow test in short mode")
}
tmpDir := t.TempDir()
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.Mkdir(beadsDir, 0750); err != nil {
t.Fatal(err)
}
dbPath := filepath.Join(beadsDir, "beads.db")
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
// Create and populate database
store, err := sqlite.New(dbPath)
if err != nil {
t.Fatalf("Failed to create store: %v", err)
}
defer store.Close()
ctx := context.Background()
// Initialize database with issue_prefix
if err := store.SetConfig(ctx, "issue_prefix", "test"); err != nil {
t.Fatalf("Failed to set issue_prefix: %v", err)
}
// Run multiple export cycles
for i := 0; i < 5; i++ {
// Add an issue
issue := &types.Issue{
ID: "test-" + string(rune('a'+i)),
Title: "Test Issue " + string(rune('A'+i)),
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeTask,
}
if err := store.CreateIssue(ctx, issue, "test-actor"); err != nil {
t.Fatalf("Cycle %d: Failed to create issue: %v", i, err)
}
time.Sleep(100 * time.Millisecond)
// Export (with fix)
if err := exportToJSONLWithStore(ctx, store, jsonlPath); err != nil {
t.Fatalf("Cycle %d: Export failed: %v", i, err)
}
// Apply fix
if err := TouchDatabaseFile(dbPath, jsonlPath); err != nil {
t.Fatalf("Cycle %d: TouchDatabaseFile failed: %v", i, err)
}
// Verify validation passes
if err := validatePreExport(ctx, store, jsonlPath); err != nil {
t.Errorf("Cycle %d: validatePreExport failed: %v", i, err)
}
}
}