diff --git a/.beads/beads.jsonl b/.beads/beads.jsonl index d17a6ff0..993c7626 100644 --- a/.beads/beads.jsonl +++ b/.beads/beads.jsonl @@ -9,7 +9,7 @@ {"id":"bd-106","title":"Add MCP server functions for repair commands","description":"Expose new repair commands via MCP server for agent access:\n\nFunctions to add:\n- beads_repair_deps()\n- beads_detect_pollution()\n- beads_validate()\n- beads_resolve_conflicts() (when implemented)\n\nUpdate integrations/beads-mcp/src/beads_mcp/server.py\n\nSee repair_commands.md lines 803-884 for design.","status":"open","priority":2,"issue_type":"task","created_at":"2025-10-28T19:38:02.227921-07:00","updated_at":"2025-10-28T19:38:02.227921-07:00"} {"id":"bd-107","title":"Add TestNWayCollision for 5+ clones","description":"## Overview\nAdd comprehensive tests for N-way (5+) collision resolution to verify the solution scales beyond 3 clones.\n\n## Purpose\nWhile TestThreeCloneCollision validates the basic N-way case, we need to verify:\n1. Solution scales to arbitrary N\n2. Performance is acceptable with more clones\n3. Convergence time is bounded\n4. No edge cases in larger collision groups\n\n## Implementation Tasks\n\n### 1. Create TestFiveCloneCollision\nFile: beads_twoclone_test.go (or new beads_nway_test.go)\n\n```go\nfunc TestFiveCloneCollision(t *testing.T) {\n // Test with 5 clones creating same ID with different content\n // Verify all 5 clones converge after sync rounds\n \n t.Run(\"SequentialSync\", func(t *testing.T) {\n testNCloneCollision(t, 5, \"A\", \"B\", \"C\", \"D\", \"E\")\n })\n \n t.Run(\"ReverseSync\", func(t *testing.T) {\n testNCloneCollision(t, 5, \"E\", \"D\", \"C\", \"B\", \"A\")\n })\n \n t.Run(\"RandomSync\", func(t *testing.T) {\n testNCloneCollision(t, 5, \"C\", \"A\", \"E\", \"B\", \"D\")\n })\n}\n```\n\n### 2. Implement generalized testNCloneCollision\nGeneralize the 3-clone test to handle arbitrary N:\n\n```go\nfunc testNCloneCollision(t *testing.T, numClones int, syncOrder ...string) {\n t.Helper()\n \n if len(syncOrder) != numClones {\n t.Fatalf(\"syncOrder length (%d) must match numClones (%d)\", \n len(syncOrder), numClones)\n }\n \n tmpDir := t.TempDir()\n \n // Setup remote and N clones\n remoteDir := setupBareRepo(t, tmpDir)\n cloneDirs := make(map[string]string)\n \n for i := 0; i \u003c numClones; i++ {\n name := string(rune('A' + i))\n cloneDirs[name] = setupClone(t, tmpDir, remoteDir, name)\n }\n \n // Each clone creates issue with same ID but different content\n for name, dir := range cloneDirs {\n createIssue(t, dir, fmt.Sprintf(\"Issue from clone %s\", name))\n }\n \n // Sync in specified order\n for _, name := range syncOrder {\n syncClone(t, cloneDirs[name], name)\n }\n \n // Final pull for convergence\n for name, dir := range cloneDirs {\n finalPull(t, dir, name)\n }\n \n // Verify all clones have all N issues\n expectedTitles := make(map[string]bool)\n for i := 0; i \u003c numClones; i++ {\n name := string(rune('A' + i))\n expectedTitles[fmt.Sprintf(\"Issue from clone %s\", name)] = true\n }\n \n for name, dir := range cloneDirs {\n titles := getTitles(t, dir)\n if !compareTitleSets(titles, expectedTitles) {\n t.Errorf(\"Clone %s missing issues: expected %v, got %v\", \n name, expectedTitles, titles)\n }\n }\n \n t.Log(\"✓ All\", numClones, \"clones converged successfully\")\n}\n```\n\n### 3. Add performance benchmarks\nTest convergence time and memory usage:\n\n```go\nfunc BenchmarkNWayCollision(b *testing.B) {\n for _, n := range []int{3, 5, 10, 20} {\n b.Run(fmt.Sprintf(\"N=%d\", n), func(b *testing.B) {\n for i := 0; i \u003c b.N; i++ {\n // Run N-way collision and measure time\n testNCloneCollisionBench(b, n)\n }\n })\n }\n}\n```\n\n### 4. Add convergence time tests\nVerify bounded convergence:\n\n```go\nfunc TestConvergenceTime(t *testing.T) {\n // Test that convergence happens within expected rounds\n // For N clones, should converge in at most N-1 sync rounds\n \n for n := 3; n \u003c= 10; n++ {\n t.Run(fmt.Sprintf(\"N=%d\", n), func(t *testing.T) {\n rounds := measureConvergenceRounds(t, n)\n maxExpected := n - 1\n if rounds \u003e maxExpected {\n t.Errorf(\"Convergence took %d rounds, expected ≤ %d\", \n rounds, maxExpected)\n }\n })\n }\n}\n```\n\n### 5. Add edge case tests\nTest boundary conditions:\n- All N clones have identical content (dedup works)\n- N-1 clones have same content, 1 differs\n- All N clones have unique content\n- Mix of collisions and non-collisions\n\n## Acceptance Criteria\n- TestFiveCloneCollision passes with all sync orders\n- All 5 clones converge to identical content\n- Performance is acceptable (\u003c 5 seconds for 5 clones)\n- Convergence time is bounded (≤ N-1 rounds)\n- Edge cases handled correctly\n- Benchmarks show scalability to 10+ clones\n\n## Files to Create/Modify\n- beads_twoclone_test.go or beads_nway_test.go\n- Add helper functions for N-clone setup\n\n## Testing Strategy\n\n### Test Matrix\n| N Clones | Sync Orders | Expected Result |\n|----------|-------------|-----------------|\n| 3 | A→B→C | Pass |\n| 3 | C→B→A | Pass |\n| 5 | A→B→C→D→E | Pass |\n| 5 | E→D→C→B→A | Pass |\n| 5 | Random | Pass |\n| 10 | Sequential | Pass |\n\n### Performance Targets\n- 3 clones: \u003c 2 seconds\n- 5 clones: \u003c 5 seconds\n- 10 clones: \u003c 15 seconds\n\n## Dependencies\n- Requires bd-95, bd-96, bd-97, bd-98 to be completed\n- TestThreeCloneCollision must pass first\n\n## Success Metrics\n- All tests pass for N ∈ {3, 5, 10}\n- Convergence time scales linearly (O(N))\n- Memory usage reasonable (\u003c 100MB for 10 clones)\n- No data corruption or loss in any scenario","status":"closed","priority":2,"issue_type":"task","created_at":"2025-10-28T20:41:26.718542-07:00","updated_at":"2025-10-28T22:06:04.398141-07:00","closed_at":"2025-10-28T22:06:04.398141-07:00"} {"id":"bd-108","title":"Fix multi-round convergence for N-way collisions","description":"## Problem\n\nN-way collision resolution is working (IDs get remapped correctly), but clones don't fully converge after a single final pull. Each clone is missing some issues that other clones have.\n\nFrom TestFiveCloneCollision results:\n- Clone A has: A, B\n- Clone B has: A, B \n- Clone C has: A, B, C\n- Clone D has: A, B, C, D\n- Clone E has: A, B, C, E\n\n**Expected**: All clones should have A, B, C, D, E after final pull.\n\n## Root Cause\n\nThe current sync workflow does:\n1. Each clone syncs in order (resolving collisions locally)\n2. Final pull to get all changes\n\nBut the final pull itself may need import with collision resolution, which creates new commits. These new commits aren't propagated to other clones, so they remain incomplete.\n\n## Proposed Solution\n\n**Option 1: Multi-round final sync**\n- After final pull, do additional sync rounds until all clones converge\n- Check convergence by comparing issue counts or content hashes\n- Maximum N rounds for N clones\n\n**Option 2: Iterative pull-import-push**\n- Each clone: pull → import with --resolve-collisions → push\n- Repeat until no new changes\n- Guaranteed convergence but may create commit spam\n\n**Option 3: Fix auto-import to be truly idempotent**\n- Ensure importing same JSONL multiple times produces no new commits\n- May require smarter content-based deduplication\n\n## Acceptance Criteria\n\n- TestFiveCloneCollision passes without t.Skip\n- All N clones have all N issues after convergence\n- Convergence happens in bounded rounds (≤ N)\n- No data loss or duplication\n- Works for arbitrary N (tested with 5, 10 clones)\n\n## Impact\n\nThis is the final blocker for bd-94 epic completion.","status":"open","priority":1,"issue_type":"task","created_at":"2025-10-28T21:22:21.486109-07:00","updated_at":"2025-10-28T21:22:21.486109-07:00","dependencies":[{"issue_id":"bd-108","depends_on_id":"bd-94","type":"parent-child","created_at":"2025-10-28T21:22:21.48794-07:00","created_by":"daemon"}]} -{"id":"bd-109","title":"Add transaction + retry logic for N-way collision resolution","description":"## Problem\nCurrent N-way collision resolution fails on UNIQUE constraint violations during convergence rounds when 5+ clones sync. The RemapCollisions function is non-atomic and performs operations sequentially:\n1. Delete old issues (CASCADE deletes dependencies)\n2. Create remapped issues (can fail with UNIQUE constraint)\n3. Recreate dependencies\n4. Update text references\n\nFailure at step 2 leaves database in inconsistent state.\n\n## Solution\nWrap collision resolution in database transaction with retry logic:\n- Make entire RemapCollisions operation atomic\n- Retry up to 3 times on UNIQUE constraint failures\n- Re-sync counters between retries\n- Add better error messages for debugging\n\n## Implementation\nLocation: internal/storage/sqlite/collision.go:342 (RemapCollisions function)\n\n```go\n// Retry up to 3 times on UNIQUE constraint failures\nfor attempt := 0; attempt \u003c 3; attempt++ {\n err := s.db.ExecInTransaction(func(tx *sql.Tx) error {\n // All collision resolution operations\n })\n if !isUniqueConstraintError(err) {\n return err\n }\n s.SyncAllCounters(ctx)\n}\n```\n\n## Success Criteria\n- 5-clone collision test passes reliably\n- No partial state on UNIQUE constraint errors\n- Automatic recovery from transient ID conflicts\n\n## References\n- See beads_nway_test.go:124 for the KNOWN LIMITATION comment\n- Related to bd-25 (transaction support)","status":"in_progress","priority":1,"issue_type":"task","created_at":"2025-10-29T10:22:32.716678-07:00","updated_at":"2025-10-29T10:36:49.103175-07:00"} +{"id":"bd-109","title":"Add transaction + retry logic for N-way collision resolution","description":"## Problem\nCurrent N-way collision resolution fails on UNIQUE constraint violations during convergence rounds when 5+ clones sync. The RemapCollisions function is non-atomic and performs operations sequentially:\n1. Delete old issues (CASCADE deletes dependencies)\n2. Create remapped issues (can fail with UNIQUE constraint)\n3. Recreate dependencies\n4. Update text references\n\nFailure at step 2 leaves database in inconsistent state.\n\n## Solution\nWrap collision resolution in database transaction with retry logic:\n- Make entire RemapCollisions operation atomic\n- Retry up to 3 times on UNIQUE constraint failures\n- Re-sync counters between retries\n- Add better error messages for debugging\n\n## Implementation\nLocation: internal/storage/sqlite/collision.go:342 (RemapCollisions function)\n\n```go\n// Retry up to 3 times on UNIQUE constraint failures\nfor attempt := 0; attempt \u003c 3; attempt++ {\n err := s.db.ExecInTransaction(func(tx *sql.Tx) error {\n // All collision resolution operations\n })\n if !isUniqueConstraintError(err) {\n return err\n }\n s.SyncAllCounters(ctx)\n}\n```\n\n## Success Criteria\n- 5-clone collision test passes reliably\n- No partial state on UNIQUE constraint errors\n- Automatic recovery from transient ID conflicts\n\n## References\n- See beads_nway_test.go:124 for the KNOWN LIMITATION comment\n- Related to bd-25 (transaction support)","notes":"## Progress Made\n\n1. Added `ExecInTransaction` helper to SQLiteStorage for atomic database operations\n2. Added `IsUniqueConstraintError` function to detect UNIQUE constraint violations\n3. Wrapped `RemapCollisions` with retry logic (up to 3 attempts) with counter sync between retries\n4. Enhanced `handleRename` to detect and handle race conditions where target ID already exists\n5. Added defensive checks for when old ID has been deleted by another clone\n\n## Test Results\n\nThe changes improve N-way collision handling but don't fully solve the problem:\n- Original error: `UNIQUE constraint failed: issues.id` during first convergence round\n- With changes: Test proceeds further but encounters different collision scenarios\n- New error: `target ID already exists with different content` in later convergence rounds\n\n## Root Cause Analysis\n\nThe issue is more complex than initially thought. In N-way scenarios:\n1. Clone A remaps bd-1 → test-2 → test-4\n2. Clone B remaps bd-1 → test-3 → test-4 \n3. Both try to create test-4, but with different intermediate states\n4. This creates legitimate content collisions that require additional resolution\n\n## Next Steps \n\nThe full solution requires:\n1. Making remapping fully deterministic across clones (same input → same remapped ID)\n2. OR making `handleRename` more tolerant of mid-flight collisions\n3. OR implementing full transaction support for multi-step collision resolution (bd-25)\n\nThe retry logic added here provides a foundation but isn't sufficient for complex N-way scenarios.","status":"in_progress","priority":1,"issue_type":"task","created_at":"2025-10-29T10:22:32.716678-07:00","updated_at":"2025-10-29T10:44:04.690646-07:00","dependencies":[{"issue_id":"bd-109","depends_on_id":"bd-108","type":"related","created_at":"2025-10-29T10:44:44.14653-07:00","created_by":"daemon"}]} {"id":"bd-11","title":"Update AGENTS.md and README.md with \"bd daemons\" documentation","description":"Document the new \"bd daemons\" command and all subcommands in AGENTS.md and README.md. Include examples and troubleshooting guidance.","status":"open","priority":2,"issue_type":"task","created_at":"2025-10-26T19:41:11.099254-07:00","updated_at":"2025-10-27T22:22:23.815967-07:00"} {"id":"bd-110","title":"Implement clone-scoped ID allocation to prevent N-way collisions","description":"## Problem\nCurrent ID allocation uses per-clone atomic counters (issue_counters table) that sync based on local database state. In N-way collision scenarios:\n- Clone B sees {test-1} locally, allocates test-2\n- Clone D sees {test-1, test-2, test-3} locally, allocates test-4\n- When same content gets assigned test-2 and test-4, convergence fails\n\nRoot cause: Each clone independently allocates IDs without global coordination, leading to overlapping assignments for the same content.\n\n## Solution\nAdd clone UUID to ID allocation to make every ID globally unique:\n\n**Current format:** `test-1`, `test-2`, `test-3`\n**New format:** `test-1-a7b3`, `test-2-a7b3`, `test-3-c4d9`\n\nWhere suffix is first 4 chars of clone UUID.\n\n## Implementation\n\n### 1. Add clone_identity table\n```sql\nCREATE TABLE clone_identity (\n clone_uuid TEXT PRIMARY KEY,\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP\n);\n```\n\n### 2. Modify getNextIDForPrefix()\n```go\nfunc (s *SQLiteStorage) getNextIDForPrefix(ctx context.Context, prefix string) (string, error) {\n cloneUUID := s.getOrCreateCloneUUID(ctx)\n shortUUID := cloneUUID[:4]\n \n nextNum := s.getNextCounterForPrefix(ctx, prefix)\n return fmt.Sprintf(\"%s-%d-%s\", prefix, nextNum, shortUUID), nil\n}\n```\n\n### 3. Update ID parsing logic\nAll places that parse IDs (utils.ExtractIssueNumber, etc.) need to handle new format.\n\n### 4. Migration strategy\n- Existing IDs remain unchanged (no suffix)\n- New IDs get clone suffix automatically\n- Display layer can hide suffix in UI: `bd-42-a7b3` → `#42`\n\n## Benefits\n- **Zero collision risk**: Same content in different clones gets different IDs\n- **Maintains readability**: Still sequential numbering within clone\n- **No coordination needed**: Works offline, no central authority\n- **Scales to 100+ clones**: 4-char hex = 65,536 unique clones\n\n## Concerns\n- ID format change may break existing integrations\n- Need migration path for existing databases\n- Display logic needs update to hide/show suffixes appropriately\n\n## Success Criteria\n- 10+ clone collision test passes without failures\n- Existing issues continue to work (backward compatibility)\n- Documentation updated with new ID format\n- Migration guide for v1.x → v2.x\n\n## Timeline\nMedium-term (v1.1-v1.2), 2-3 weeks implementation\n\n## References\n- Related to bd-109 (immediate fix)\n- See beads_nway_test.go for failing N-way tests","status":"open","priority":2,"issue_type":"feature","created_at":"2025-10-29T10:22:52.260524-07:00","updated_at":"2025-10-29T10:22:52.260524-07:00"} {"id":"bd-111","title":"Investigate jujutsu VCS as potential solution for conflict-free merging","description":"## Context\nCurrent N-way collision resolution struggles with Git line-based merge model. When 5+ clones create issues with same ID, Git merge conflicts require manual resolution, and our collision resolver can fail during convergence rounds.\n\n## Research Question\nCould jujutsu (jj) provide better conflict handling for JSONL files?\n\n## Jujutsu Overview\n- Next-gen VCS built on libgit2\n- Designed to handle conflicts as first-class citizens\n- Supports conflict-free replicated data types (CRDTs) in some scenarios\n- Better handling of concurrent edits\n- Can work with Git repos (compatible with existing infrastructure)\n\n## Investigation Tasks\n1. JSONL Merge Behavior - How does jj handle line-by-line JSONL conflicts?\n2. Integration Feasibility - Can beads use jj as backend while maintaining Git compatibility?\n3. Conflict Resolution Model - Does jj conflict model map to our collision resolution?\n4. Operational Transform Support - Does jj implement operational transforms?\n\n## Deliverables\n1. Technical report on jj merge algorithm for JSONL\n2. Proof-of-concept: 5-clone collision test using jj instead of Git\n3. Performance comparison: Git vs jj for beads workload\n4. Recommendation: Adopt, experiment further, or abandon\n\n## References\n- https://github.com/martinvonz/jj\n- Related to bd-109, bd-110","status":"open","priority":2,"issue_type":"task","created_at":"2025-10-29T10:23:33.496347-07:00","updated_at":"2025-10-29T10:23:33.496347-07:00"} diff --git a/internal/importer/importer.go b/internal/importer/importer.go index 55768ba8..27b000af 100644 --- a/internal/importer/importer.go +++ b/internal/importer/importer.go @@ -285,6 +285,38 @@ func buildIDMap(issues []*types.Issue) map[string]*types.Issue { // handleRename handles content match with different IDs (rename detected) func handleRename(ctx context.Context, s *sqlite.SQLiteStorage, existing *types.Issue, incoming *types.Issue) error { + // Check if target ID already exists with the same content (race condition) + // This can happen when multiple clones import the same rename simultaneously + targetIssue, err := s.GetIssue(ctx, incoming.ID) + if err == nil && targetIssue != nil { + // Target ID exists - check if it has the same content + if targetIssue.ComputeContentHash() == incoming.ComputeContentHash() { + // Same content - check if old ID still exists and delete it + existingCheck, checkErr := s.GetIssue(ctx, existing.ID) + if checkErr == nil && existingCheck != nil { + if err := s.DeleteIssue(ctx, existing.ID); err != nil { + return fmt.Errorf("failed to delete old ID %s: %w", existing.ID, err) + } + } + // The rename is already complete in the database + return nil + } + // Different content - this is an unexpected collision + return fmt.Errorf("target ID %s already exists with different content", incoming.ID) + } + + // Check if old ID still exists (it might have been deleted by another clone) + existingCheck, checkErr := s.GetIssue(ctx, existing.ID) + if checkErr != nil || existingCheck == nil { + // Old ID doesn't exist - the rename must have been completed by another clone + // Verify that target exists with correct content + targetCheck, targetErr := s.GetIssue(ctx, incoming.ID) + if targetErr == nil && targetCheck != nil && targetCheck.ComputeContentHash() == incoming.ComputeContentHash() { + return nil + } + return fmt.Errorf("old ID %s doesn't exist and target ID %s is not as expected", existing.ID, incoming.ID) + } + // Delete old ID if err := s.DeleteIssue(ctx, existing.ID); err != nil { return fmt.Errorf("failed to delete old ID %s: %w", existing.ID, err) @@ -292,6 +324,15 @@ func handleRename(ctx context.Context, s *sqlite.SQLiteStorage, existing *types. // Create with new ID if err := s.CreateIssue(ctx, incoming, "import-rename"); err != nil { + // If UNIQUE constraint error, it's likely another clone created it concurrently + if sqlite.IsUniqueConstraintError(err) { + // Check if target exists with same content + targetIssue, getErr := s.GetIssue(ctx, incoming.ID) + if getErr == nil && targetIssue != nil && targetIssue.ComputeContentHash() == incoming.ComputeContentHash() { + // Same content - rename already complete, this is OK + return nil + } + } return fmt.Errorf("failed to create renamed issue %s: %w", incoming.ID, err) } diff --git a/internal/storage/sqlite/collision.go b/internal/storage/sqlite/collision.go index 62858c47..b869e7c0 100644 --- a/internal/storage/sqlite/collision.go +++ b/internal/storage/sqlite/collision.go @@ -335,11 +335,37 @@ func deduplicateIncomingIssues(issues []*types.Issue) []*types.Issue { // // This ensures deterministic, symmetric collision resolution across all clones. // -// NOTE: This function is not atomic - it performs multiple separate database operations. -// If an error occurs partway through, some issues may be created without their references -// being updated. This is a known limitation that requires storage layer refactoring to fix. -// See issue bd-25 for transaction support. -func RemapCollisions(ctx context.Context, s *SQLiteStorage, collisions []*CollisionDetail, _ []*types.Issue) (map[string]string, error) { +// The function automatically retries up to 3 times on UNIQUE constraint failures, +// syncing counters between retries to handle concurrent ID allocation. +func RemapCollisions(ctx context.Context, s *SQLiteStorage, collisions []*CollisionDetail, incomingIssues []*types.Issue) (map[string]string, error) { + const maxRetries = 3 + var lastErr error + + for attempt := 0; attempt < maxRetries; attempt++ { + idMapping, err := remapCollisionsOnce(ctx, s, collisions, incomingIssues) + if err == nil { + return idMapping, nil + } + + lastErr = err + + if !isUniqueConstraintError(err) { + return nil, err + } + + if attempt < maxRetries-1 { + if syncErr := s.SyncAllCounters(ctx); syncErr != nil { + return nil, fmt.Errorf("retry %d: UNIQUE constraint error, counter sync failed: %w (original error: %v)", attempt+1, syncErr, err) + } + } + } + + return nil, fmt.Errorf("failed after %d retries due to UNIQUE constraint violations: %w", maxRetries, lastErr) +} + +// remapCollisionsOnce performs a single attempt at collision resolution. +// This is the actual implementation that RemapCollisions wraps with retry logic. +func remapCollisionsOnce(ctx context.Context, s *SQLiteStorage, collisions []*CollisionDetail, _ []*types.Issue) (map[string]string, error) { idMapping := make(map[string]string) // Sync counters before remapping to avoid ID collisions diff --git a/internal/storage/sqlite/util.go b/internal/storage/sqlite/util.go index c1b07cec..46807dee 100644 --- a/internal/storage/sqlite/util.go +++ b/internal/storage/sqlite/util.go @@ -3,6 +3,8 @@ package sqlite import ( "context" "database/sql" + "fmt" + "strings" ) // QueryContext exposes the underlying database QueryContext method for advanced queries @@ -16,3 +18,37 @@ func (s *SQLiteStorage) QueryContext(ctx context.Context, query string, args ... func (s *SQLiteStorage) BeginTx(ctx context.Context) (*sql.Tx, error) { return s.db.BeginTx(ctx, nil) } + +// ExecInTransaction executes a function within a database transaction. +// If the function returns an error, the transaction is rolled back. +// Otherwise, the transaction is committed. +func (s *SQLiteStorage) ExecInTransaction(ctx context.Context, fn func(*sql.Tx) error) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("failed to begin transaction: %w", err) + } + defer func() { _ = tx.Rollback() }() + + if err := fn(tx); err != nil { + return err + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("failed to commit transaction: %w", err) + } + + return nil +} + +// IsUniqueConstraintError checks if an error is a UNIQUE constraint violation +func IsUniqueConstraintError(err error) bool { + if err == nil { + return false + } + return strings.Contains(err.Error(), "UNIQUE constraint failed") +} + +// isUniqueConstraintError is an alias for IsUniqueConstraintError for internal use +func isUniqueConstraintError(err error) bool { + return IsUniqueConstraintError(err) +}