From a0b9d4272c5d91c393f552142aab2f2807a046ea Mon Sep 17 00:00:00 2001 From: hieunm-aisa Date: Tue, 15 Sep 2026 02:01:48 +0700 Subject: [PATCH] fix(vault): deduplicate wikilinks before batch INSERT to prevent SQLSTATE 21000 (#1565) When a document contains multiple [[target]] wikilinks pointing to the same destination, SyncDocLinks produced duplicate (FromDocID, ToDocID, LinkType) entries. The batch INSERT ... ON CONFLICT DO UPDATE in CreateLinks then failed because PostgreSQL rejects a single statement updating the same row twice. Deduplicate by (FromDocID, ToDocID, LinkType) in SyncDocLinks before calling CreateLinks, concatenating contexts with " | " separator so no surrounding text is lost. Fixes #1473 (cherry picked from commit 549c81fd2406875be34cec3d7274125238e7d4a0) --- internal/vault/links.go | 15 +++++ internal/vault/links_test.go | 122 +++++++++++++++++++++++++++++++++++ 2 files changed, 137 insertions(+) create mode 100644 internal/vault/links_test.go diff --git a/internal/vault/links.go b/internal/vault/links.go index 10bc9794..3ff08be5 100644 --- a/internal/vault/links.go +++ b/internal/vault/links.go @@ -101,6 +101,15 @@ func SyncDocLinks(ctx context.Context, vs store.VaultStore, doc *store.VaultDocu } // Resolve all wikilinks, then batch-create links in a single call. + // Deduplicate by (FromDocID, ToDocID, LinkType) to prevent PostgreSQL + // "ON CONFLICT DO UPDATE command cannot affect row a second time" (SQLSTATE 21000) + // when the same target appears multiple times in the document. + type linkKey struct { + fromDocID string + toDocID string + linkType string + } + seen := make(map[linkKey]int) // key → index in links slice var links []store.VaultLink for _, m := range matches { target, err := ResolveWikilinkTarget(ctx, vs, m.Target, tenantID, agentID) @@ -112,6 +121,12 @@ func SyncDocLinks(ctx context.Context, vs store.VaultStore, doc *store.VaultDocu slog.Debug("vault.link_unresolved", "target", m.Target) continue } + k := linkKey{fromDocID: doc.ID, toDocID: target.ID, linkType: "wikilink"} + if idx, ok := seen[k]; ok { + links[idx].Context += " | " + m.Context + continue + } + seen[k] = len(links) links = append(links, store.VaultLink{ FromDocID: doc.ID, ToDocID: target.ID, diff --git a/internal/vault/links_test.go b/internal/vault/links_test.go new file mode 100644 index 00000000..33b0566f --- /dev/null +++ b/internal/vault/links_test.go @@ -0,0 +1,122 @@ +package vault + +import ( + "context" + "sync" + "testing" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +type fakeVaultStoreLinks struct { + store.VaultStore + mu sync.Mutex + docsByPath map[string]*store.VaultDocument + created []store.VaultLink + deletedType bool +} + +func (f *fakeVaultStoreLinks) GetDocument(_ context.Context, _, _, path string) (*store.VaultDocument, error) { + f.mu.Lock() + defer f.mu.Unlock() + if doc, ok := f.docsByPath[path]; ok { + return doc, nil + } + return nil, nil +} + +func (f *fakeVaultStoreLinks) GetDocumentByBasename(_ context.Context, _, _, basename string) (*store.VaultDocument, error) { + f.mu.Lock() + defer f.mu.Unlock() + for _, doc := range f.docsByPath { + if doc.Path == basename || doc.Title == basename { + return doc, nil + } + } + return nil, nil +} + +func (f *fakeVaultStoreLinks) DeleteDocLinksByType(_ context.Context, _, _, _ string) error { + f.mu.Lock() + defer f.mu.Unlock() + f.deletedType = true + return nil +} + +func (f *fakeVaultStoreLinks) CreateLinks(_ context.Context, links []store.VaultLink) error { + f.mu.Lock() + defer f.mu.Unlock() + f.created = append(f.created, links...) + return nil +} + +func TestSyncDocLinks_DeduplicatesSameTarget(t *testing.T) { + targetDoc := &store.VaultDocument{ID: "target-id-1", Title: "products", Path: "products.md"} + sourceDoc := &store.VaultDocument{ID: "source-id-1", Title: "notes", Path: "notes.md"} + + fake := &fakeVaultStoreLinks{ + docsByPath: map[string]*store.VaultDocument{ + "products": targetDoc, + "products.md": targetDoc, + }, + } + + content := "First mention [[products]] here and later [[products]] again." + + err := SyncDocLinks(context.Background(), fake, sourceDoc, content, "tenant-1", "agent-1") + if err != nil { + t.Fatalf("SyncDocLinks() error = %v", err) + } + + if len(fake.created) != 1 { + t.Fatalf("expected 1 deduplicated link, got %d", len(fake.created)) + } + + link := fake.created[0] + if link.FromDocID != sourceDoc.ID { + t.Errorf("FromDocID = %q, want %q", link.FromDocID, sourceDoc.ID) + } + if link.ToDocID != targetDoc.ID { + t.Errorf("ToDocID = %q, want %q", link.ToDocID, targetDoc.ID) + } + if link.LinkType != "wikilink" { + t.Errorf("LinkType = %q, want %q", link.LinkType, "wikilink") + } +} + +func TestSyncDocLinks_PreservesDistinctTargets(t *testing.T) { + targetA := &store.VaultDocument{ID: "target-a", Title: "alpha", Path: "alpha.md"} + targetB := &store.VaultDocument{ID: "target-b", Title: "beta", Path: "beta.md"} + sourceDoc := &store.VaultDocument{ID: "source-id-2", Title: "notes", Path: "notes.md"} + + fake := &fakeVaultStoreLinks{ + docsByPath: map[string]*store.VaultDocument{ + "alpha": targetA, + "alpha.md": targetA, + "beta": targetB, + "beta.md": targetB, + }, + } + + content := "See [[alpha]] and [[beta]] and [[alpha]] again." + + err := SyncDocLinks(context.Background(), fake, sourceDoc, content, "tenant-1", "agent-1") + if err != nil { + t.Fatalf("SyncDocLinks() error = %v", err) + } + + if len(fake.created) != 2 { + t.Fatalf("expected 2 links (alpha + beta), got %d", len(fake.created)) + } + + targetIDs := map[string]bool{} + for _, l := range fake.created { + targetIDs[l.ToDocID] = true + } + if !targetIDs["target-a"] { + t.Error("missing link to target-a (alpha)") + } + if !targetIDs["target-b"] { + t.Error("missing link to target-b (beta)") + } +}