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)
This commit is contained in:
hieunm-aisa authored and fcy committed 2026-09-26 10:25:37 +08:00
1 parent cefeae6c32
commit a0b9d4272c
2 files changed
+137

No files matched your search

+15
View File
@@ -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,
+122
View File
@@ -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)")
}
}