Complete bd-ar2 P2 tasks: metadata, resurrection, and testing improvements

This commit addresses the remaining P2 tasks from bd-ar2 code review follow-up:

## Completed Tasks

### bd-ar2.4: Improve parent chain resurrection
- Modified `tryResurrectParentWithConn()` to recursively resurrect ancestor chain
- When resurrecting bd-root.1.2, now also resurrects bd-root.1 if missing
- Handles deeply nested hierarchies where intermediate parents are deleted
- All resurrection tests pass including new edge cases

### bd-ar2.5: Add error handling guidance
- Documented metadata update failure strategy in `updateExportMetadata()`
- Explained trade-off: warnings vs errors (safe, prevents data loss)
- Added user-facing message: "Next export may require running 'bd import' first"
- Clarifies that worst case is requiring import before next export

### bd-ar2.6: Document transaction boundaries
- Added comprehensive documentation for atomicity trade-offs
- Explained crash scenarios and recovery (bd import)
- Documented decision to defer defensive checks (Option 4) until needed
- No code changes - current approach is acceptable for now

### bd-ar2.12: Add metadata key validation
- Added keySuffix validation in `updateExportMetadata()` and `hasJSONLChanged()`
- Prevents ':' separator in keySuffix to avoid malformed metadata keys
- Documented metadata key format in function comments
- Single-repo: "last_import_hash", multi-repo: "last_import_hash:<repo_key>"

### bd-ar2.7: Add edge case tests for GetNextChildID resurrection
- TestGetNextChildID_ResurrectParent_NotInJSONL: parent not in history
- TestGetNextChildID_ResurrectParent_NoJSONL: missing JSONL file
- TestGetNextChildID_ResurrectParent_MalformedJSONL: invalid JSON lines
- TestGetNextChildID_ResurrectParentChain: deeply nested missing parents
- All tests pass, resurrection is robust against edge cases

## Files Changed
- cmd/bd/daemon_sync.go: Metadata validation, error handling docs
- cmd/bd/integrity.go: Added strings import, keySuffix validation
- internal/storage/sqlite/hash_ids.go: Improved resurrection comments
- internal/storage/sqlite/resurrection.go: Recursive ancestor resurrection
- internal/storage/sqlite/child_id_test.go: Added 4 new edge case tests

## Testing
All export, sync, metadata, and resurrection tests pass.
Edge cases properly handled: missing JSONL, malformed JSON, deep nesting.

## Remaining Tasks
- bd-ar2.8 (P3): Additional export metadata edge case tests (deferred)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-11-21 11:40:37 -05:00
parent cd8cb8b86a
commit e816e91ecb
5 changed files with 195 additions and 2 deletions

View File

@@ -274,3 +274,144 @@ func TestGetNextChildID_ResurrectParent(t *testing.T) {
t.Errorf("expected resurrected parent title to be preserved, got %s", resurrectedParent.Title)
}
}
// TestGetNextChildID_ResurrectParent_NotInJSONL tests resurrection when parent doesn't exist in JSONL (bd-ar2.7)
func TestGetNextChildID_ResurrectParent_NotInJSONL(t *testing.T) {
tmpDir := t.TempDir()
tmpFile := tmpDir + "/test.db"
defer os.Remove(tmpFile)
store := newTestStore(t, tmpFile)
defer store.Close()
ctx := context.Background()
// Create empty JSONL file (parent not in history)
jsonlPath := tmpDir + "/issues.jsonl"
if err := os.WriteFile(jsonlPath, []byte(""), 0600); err != nil {
t.Fatalf("failed to create JSONL file: %v", err)
}
// Attempt to get child ID for non-existent parent not in JSONL
_, err := store.GetNextChildID(ctx, "bd-notfound")
if err == nil {
t.Errorf("expected error for parent not in JSONL, got nil")
}
expectedErr := "parent issue bd-notfound does not exist and could not be resurrected from JSONL history"
if err != nil && err.Error() != expectedErr {
t.Errorf("unexpected error: got %q, want %q", err.Error(), expectedErr)
}
}
// TestGetNextChildID_ResurrectParent_NoJSONL tests resurrection when JSONL file doesn't exist (bd-ar2.7)
func TestGetNextChildID_ResurrectParent_NoJSONL(t *testing.T) {
tmpDir := t.TempDir()
tmpFile := tmpDir + "/test.db"
defer os.Remove(tmpFile)
store := newTestStore(t, tmpFile)
defer store.Close()
ctx := context.Background()
// No JSONL file created
// Attempt to get child ID for non-existent parent
_, err := store.GetNextChildID(ctx, "bd-missing")
if err == nil {
t.Errorf("expected error for parent with no JSONL, got nil")
}
expectedErr := "parent issue bd-missing does not exist and could not be resurrected from JSONL history"
if err != nil && err.Error() != expectedErr {
t.Errorf("unexpected error: got %q, want %q", err.Error(), expectedErr)
}
}
// TestGetNextChildID_ResurrectParent_MalformedJSONL tests resurrection with invalid JSON lines (bd-ar2.7)
func TestGetNextChildID_ResurrectParent_MalformedJSONL(t *testing.T) {
tmpDir := t.TempDir()
tmpFile := tmpDir + "/test.db"
defer os.Remove(tmpFile)
store := newTestStore(t, tmpFile)
defer store.Close()
ctx := context.Background()
// Create JSONL with malformed lines and one valid parent
jsonlPath := tmpDir + "/issues.jsonl"
jsonlContent := `{invalid json
{"id":"bd-test456","content_hash":"def456","title":"Valid Parent","description":"Should be found","status":"open","priority":1,"type":"epic","created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}
this is not json either
`
if err := os.WriteFile(jsonlPath, []byte(jsonlContent), 0600); err != nil {
t.Fatalf("failed to create JSONL file: %v", err)
}
// Should successfully resurrect despite malformed lines
childID, err := store.GetNextChildID(ctx, "bd-test456")
if err != nil {
t.Fatalf("GetNextChildID should skip malformed lines and resurrect valid parent, got error: %v", err)
}
expectedID := "bd-test456.1"
if childID != expectedID {
t.Errorf("expected child ID %s, got %s", expectedID, childID)
}
}
// TestGetNextChildID_ResurrectParentChain tests resurrection of deeply nested missing parents (bd-ar2.7)
func TestGetNextChildID_ResurrectParentChain(t *testing.T) {
tmpDir := t.TempDir()
tmpFile := tmpDir + "/test.db"
defer os.Remove(tmpFile)
store := newTestStore(t, tmpFile)
defer store.Close()
ctx := context.Background()
// Create root parent only
root := &types.Issue{
ID: "bd-root",
ContentHash: "root123",
Title: "Root Issue",
Description: "Root",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeEpic,
}
if err := store.CreateIssue(ctx, root, "test"); err != nil {
t.Fatalf("failed to create root: %v", err)
}
// Create JSONL with intermediate parents that are deleted
jsonlPath := tmpDir + "/issues.jsonl"
jsonlContent := `{"id":"bd-root","content_hash":"root123","title":"Root Issue","description":"Root","status":"open","priority":1,"type":"epic","created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}
{"id":"bd-root.1","content_hash":"l1abc","title":"Level 1","description":"First level","status":"open","priority":1,"type":"task","created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}
{"id":"bd-root.1.2","content_hash":"l2abc","title":"Level 2","description":"Second level","status":"open","priority":1,"type":"task","created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}
`
if err := os.WriteFile(jsonlPath, []byte(jsonlContent), 0600); err != nil {
t.Fatalf("failed to create JSONL file: %v", err)
}
// Try to create child of bd-root.1.2 (which doesn't exist in DB, but its parent bd-root.1 also doesn't exist)
// With TryResurrectParentChain (bd-ar2.4), this should work
childID, err := store.GetNextChildID(ctx, "bd-root.1.2")
if err != nil {
t.Fatalf("GetNextChildID should resurrect entire parent chain, got error: %v", err)
}
expectedID := "bd-root.1.2.1"
if childID != expectedID {
t.Errorf("expected child ID %s, got %s", expectedID, childID)
}
// Verify both intermediate parents were resurrected
parent1, err := store.GetIssue(ctx, "bd-root.1")
if err != nil {
t.Fatalf("bd-root.1 should have been resurrected: %v", err)
}
if parent1.Status != types.StatusClosed {
t.Errorf("expected resurrected parent to be closed, got %s", parent1.Status)
}
parent2, err := store.GetIssue(ctx, "bd-root.1.2")
if err != nil {
t.Fatalf("bd-root.1.2 should have been resurrected: %v", err)
}
if parent2.Status != types.StatusClosed {
t.Errorf("expected resurrected parent to be closed, got %s", parent2.Status)
}
}

View File

@@ -34,7 +34,11 @@ func (s *SQLiteStorage) GetNextChildID(ctx context.Context, parentID string) (st
return "", fmt.Errorf("failed to check parent existence: %w", err)
}
if count == 0 {
// Try to resurrect parent from JSONL history before failing (bd-dvd fix)
// Try to resurrect parent from JSONL history before failing (bd-dvd fix, bd-ar2.4)
// Note: Using TryResurrectParent instead of TryResurrectParentChain because we're
// already given the direct parent ID. TryResurrectParent will handle the direct parent,
// and if the parent itself has missing ancestors, those should have been resurrected
// when the parent was originally created.
resurrected, resurrectErr := s.TryResurrectParent(ctx, parentID)
if resurrectErr != nil {
return "", fmt.Errorf("failed to resurrect parent %s: %w", parentID, resurrectErr)

View File

@@ -47,7 +47,21 @@ func (s *SQLiteStorage) tryResurrectParentWithConn(ctx context.Context, conn *sq
if count > 0 {
return true, nil // Parent already exists, nothing to do
}
// Before resurrecting this parent, ensure its entire ancestor chain exists (bd-ar2.4)
// This handles deeply nested cases where we're resurrecting bd-root.1.2 and bd-root.1 is also missing
ancestors := extractParentChain(parentID)
for _, ancestor := range ancestors {
// Recursively resurrect each ancestor in the chain
resurrected, err := s.tryResurrectParentWithConn(ctx, conn, ancestor)
if err != nil {
return false, fmt.Errorf("failed to resurrect ancestor %s: %w", ancestor, err)
}
if !resurrected {
return false, nil // Ancestor not found in history, can't continue
}
}
// Parent doesn't exist - try to find it in JSONL history
parentIssue, err := s.findIssueInJSONL(parentID)
if err != nil {