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
243 lines
6.9 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|