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

@@ -241,7 +241,27 @@ func getRepoKeyForPath(jsonlPath string) string {
// updateExportMetadata updates last_import_hash and related metadata after a successful export.
// This prevents "JSONL content has changed since last import" errors on subsequent exports (bd-ymj fix).
// In multi-repo mode, keySuffix should be the stable repo identifier (e.g., ".", "../frontend").
//
// Metadata key format (bd-ar2.12):
// - Single-repo mode: "last_import_hash", "last_import_time", "last_import_mtime"
// - Multi-repo mode: "last_import_hash:<repo_key>", "last_import_time:<repo_key>", etc.
// where <repo_key> is a stable repo identifier like "." or "../frontend"
//
// Transaction boundaries (bd-ar2.6):
// This function does NOT provide atomicity between JSONL write, metadata updates, and DB mtime.
// If a crash occurs between these operations, metadata may be inconsistent. However, this is
// acceptable because:
// 1. The worst case is "JSONL content has changed" error on next export
// 2. User can fix by running 'bd import' (safe, no data loss)
// 3. Current approach is simple and doesn't require complex WAL or format changes
// Future: Consider Option 4 (defensive checks on startup) if this becomes a common issue.
func updateExportMetadata(ctx context.Context, store storage.Storage, jsonlPath string, log daemonLogger, keySuffix string) {
// Validate keySuffix doesn't contain the separator character (bd-ar2.12)
if keySuffix != "" && strings.Contains(keySuffix, ":") {
log.log("Error: invalid keySuffix contains ':' separator: %s", keySuffix)
return
}
currentHash, err := computeJSONLHash(jsonlPath)
if err != nil {
log.log("Warning: failed to compute JSONL hash for metadata update: %v", err)
@@ -258,8 +278,14 @@ func updateExportMetadata(ctx context.Context, store storage.Storage, jsonlPath
mtimeKey += ":" + keySuffix
}
// Note: Metadata update failures are treated as warnings, not errors (bd-ar2.5).
// This is acceptable because the worst case is the next export will require
// an import first, which is safe and prevents data loss.
// Alternative: Make this critical and fail the export if metadata updates fail,
// but this makes exports more fragile and doesn't prevent data corruption.
if err := store.SetMetadata(ctx, hashKey, currentHash); err != nil {
log.log("Warning: failed to update %s: %v", hashKey, err)
log.log("Next export may require running 'bd import' first")
}
exportTime := time.Now().Format(time.RFC3339)

View File

@@ -12,6 +12,7 @@ import (
"os"
"path/filepath"
"sort"
"strings"
"github.com/steveyegge/beads/internal/storage"
"github.com/steveyegge/beads/internal/types"
@@ -96,7 +97,14 @@ func computeJSONLHash(jsonlPath string) (string, error) {
// unchanged) while still catching git operations that restore old content with new mtimes.
//
// In multi-repo mode, keySuffix should be the stable repo identifier (e.g., ".", "../frontend").
// The keySuffix must not contain the ':' separator character (bd-ar2.12).
func hasJSONLChanged(ctx context.Context, store storage.Storage, jsonlPath string, keySuffix string) bool {
// Validate keySuffix doesn't contain the separator character (bd-ar2.12)
if keySuffix != "" && strings.Contains(keySuffix, ":") {
// Invalid keySuffix - treat as changed to trigger proper error handling
return true
}
// Build metadata keys with optional suffix for per-repo tracking (bd-ar2.10, bd-ar2.11)
hashKey := "last_import_hash"
mtimeKey := "last_import_mtime"