From e2afcb8ca445734154ef2d43faa97a082384312d Mon Sep 17 00:00:00 2001 From: blak0p Date: Sun, 20 Sep 2026 12:52:16 +0200 Subject: [PATCH] feat(store): add literal find and replace updates --- internal/store/store.go | 71 +++++++++++++-- internal/store/store_test.go | 168 +++++++++++++++++++++++++++++++++++ internal/sync/sync_test.go | 12 +-- 3 files changed, 240 insertions(+), 11 deletions(-) diff --git a/internal/store/store.go b/internal/store/store.go index 67738d9df..4b9a5c066 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -69,11 +69,13 @@ var ( // ErrProjectOwnershipAmbiguous is returned when an unowned session cannot // adopt a write's project because it already parents records owned by a // different one. Guessing there would split a record from its session. - ErrProjectOwnershipAmbiguous = errors.New("session project ownership is ambiguous") - ErrObservationProjectImmutable = errors.New("observation project cannot be reassigned") - ErrObservationTitleRequired = errors.New("observation title is required") - ErrObservationContentRequired = errors.New("observation content is required") - ErrPromptContentRequired = errors.New("prompt content is required") + ErrProjectOwnershipAmbiguous = errors.New("session project ownership is ambiguous") + ErrObservationProjectImmutable = errors.New("observation project cannot be reassigned") + ErrObservationTitleRequired = errors.New("observation title is required") + ErrObservationContentRequired = errors.New("observation content is required") + ErrObservationFindReplaceInvalid = errors.New("find and replace must be provided together and cannot be combined with content") + ErrObservationFindReplaceTooLarge = errors.New("find or replace exceeds the observation content limit") + ErrPromptContentRequired = errors.New("prompt content is required") ) // Sentinel errors for relation sync apply path (Phase 2). @@ -279,6 +281,8 @@ type UpdateObservationParams struct { Type *string `json:"type,omitempty"` Title *string `json:"title,omitempty"` Content *string `json:"content,omitempty"` + Find *string `json:"find,omitempty"` + Replace *string `json:"replace,omitempty"` Project *string `json:"project,omitempty"` Scope *string `json:"scope,omitempty"` TopicKey *string `json:"topic_key,omitempty"` @@ -3721,6 +3725,46 @@ func truncateContent(content string, max int) string { return content[:end] + "... [truncated]" } +// boundedLiteralReplace applies literal global replacement without allocating an +// unbounded expanded result. It retains one byte beyond the storage limit so +// truncateContent can preserve its existing UTF-8 boundary behavior. +func boundedLiteralReplace(content, find, replace string, max int) string { + if find == "" { + return content + } + + limit := max + 1 + var result strings.Builder + if len(content) < limit { + result.Grow(len(content)) + } else { + result.Grow(limit) + } + write := func(part string) bool { + remaining := limit - result.Len() + if len(part) <= remaining { + result.WriteString(part) + return false + } + result.WriteString(part[:remaining]) + return true + } + + for { + index := strings.Index(content, find) + if index < 0 { + if write(content) { + return truncateContent(result.String(), max) + } + return result.String() + } + if write(content[:index]) || write(replace) { + return truncateContent(result.String(), max) + } + content = content[index+len(find):] + } +} + func (s *Store) RecentPrompts(project string, limit int) ([]Prompt, error) { // Normalize project filter for case-insensitive matching project, _ = NormalizeProject(project) @@ -4029,6 +4073,12 @@ func (s *Store) UpdateObservation(id int64, p UpdateObservationParams) (*Observa // Admission runs before the transaction so a rejected update opens no // transaction, touches no row and enqueues no sync mutation. The title is // checked post-strip so redaction cannot smuggle an empty one through. + if (p.Find == nil) != (p.Replace == nil) || (p.Content != nil && p.Find != nil) { + return nil, ErrObservationFindReplaceInvalid + } + if p.Find != nil && (len(*p.Find) > s.cfg.MaxObservationLength || len(*p.Replace) > s.cfg.MaxObservationLength) { + return nil, ErrObservationFindReplaceTooLarge + } if p.Title != nil { if err := ValidateObservationTitle(stripPrivateTags(*p.Title)); err != nil { return nil, err @@ -4064,6 +4114,17 @@ func (s *Store) UpdateObservation(id int64, p UpdateObservationParams) (*Observa if p.Content != nil { content, _ = s.prepareStoredContent(*p.Content) } + if p.Find != nil { + // Redact a complete private tag in the bounded replacement input before + // expansion. Otherwise a bounded result could cut its closing tag and + // prevent prepareStoredContent from recognizing the private content. + replace := privateTagRegex.ReplaceAllString(*p.Replace, "[REDACTED]") + replaced := boundedLiteralReplace(content, *p.Find, replace, s.cfg.MaxObservationLength) + content, _ = s.prepareStoredContent(replaced) + if content == "" { + return ErrObservationContentRequired + } + } if p.Project != nil { requestedProject, _ := NormalizeProject(*p.Project) if requestedProject != project { diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 9a62e5cc1..cdfe645d3 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -15837,6 +15837,174 @@ func TestUpdateObservationRejectsBlankTitleWithoutSideEffects(t *testing.T) { } } +func TestUpdateObservationFindReplace(t *testing.T) { + s := newTestStore(t) + enrollTestProject(t, s, "engram") + if err := s.CreateSession("s-update-find-replace", "engram", t.TempDir()); err != nil { + t.Fatalf("create session: %v", err) + } + id, err := s.AddObservation(AddObservationParams{ + SessionID: "s-update-find-replace", + Type: "note", + Title: "Original title", + Content: "a.b a.b", + Project: "engram", + Scope: "project", + }) + if err != nil { + t.Fatalf("add observation: %v", err) + } + + load := func() *Observation { + t.Helper() + obs, err := s.GetObservation(id) + if err != nil { + t.Fatalf("get observation: %v", err) + } + return obs + } + mutationCount := func(syncID string) int { + t.Helper() + var count int + if err := s.db.QueryRow(`SELECT COUNT(*) FROM sync_mutations WHERE entity = ? AND entity_key = ?`, SyncEntityObservation, syncID).Scan(&count); err != nil { + t.Fatalf("count mutations: %v", err) + } + return count + } + normalizedHash := func() string { + t.Helper() + var hash string + if err := s.db.QueryRow(`SELECT normalized_hash FROM observations WHERE id = ?`, id).Scan(&hash); err != nil { + t.Fatalf("load normalized hash: %v", err) + } + return hash + } + assertUnchanged := func(t *testing.T, before *Observation, mutations int) { + t.Helper() + after := load() + if after.Content != before.Content || after.RevisionCount != before.RevisionCount { + t.Fatalf("rejected update changed observation: before=%#v after=%#v", before, after) + } + if got := mutationCount(before.SyncID); got != mutations { + t.Fatalf("rejected update enqueued a mutation: got %d, want %d", got, mutations) + } + } + + t.Run("requires paired parameters and forbids direct content", func(t *testing.T) { + for _, update := range []UpdateObservationParams{ + {Find: ptr("a")}, + {Replace: ptr("b")}, + {Content: ptr("direct"), Find: ptr("a"), Replace: ptr("b")}, + } { + before := load() + mutations := mutationCount(before.SyncID) + if _, err := s.UpdateObservation(id, update); !errors.Is(err, ErrObservationFindReplaceInvalid) { + t.Fatalf("expected ErrObservationFindReplaceInvalid, got %v", err) + } + assertUnchanged(t, before, mutations) + } + }) + + t.Run("replaces literal matches globally and redacts private replacement", func(t *testing.T) { + updated, err := s.UpdateObservation(id, UpdateObservationParams{Find: ptr("a.b"), Replace: ptr("secret")}) + if err != nil { + t.Fatalf("replace observation: %v", err) + } + if updated.Content != "[REDACTED] [REDACTED]" { + t.Fatalf("content = %q, want literal global replacement with redaction", updated.Content) + } + if got := normalizedHash(); got != hashNormalized(updated.Content) { + t.Fatalf("normalized hash = %q, want hash of replacement content", got) + } + }) + + t.Run("empty and non-matching find preserve bytes but still revise and sync", func(t *testing.T) { + for _, find := range []string{"", "missing"} { + before := load() + beforeHash := normalizedHash() + mutations := mutationCount(before.SyncID) + updated, err := s.UpdateObservation(id, UpdateObservationParams{Find: &find, Replace: ptr("replacement")}) + if err != nil { + t.Fatalf("replace with find %q: %v", find, err) + } + if updated.Content != before.Content || normalizedHash() != beforeHash { + t.Fatalf("no-match content or hash changed: before=%q after=%q", before.Content, updated.Content) + } + if updated.RevisionCount != before.RevisionCount+1 { + t.Fatalf("revision = %d, want %d", updated.RevisionCount, before.RevisionCount+1) + } + if got := mutationCount(before.SyncID); got != mutations+1 { + t.Fatalf("mutations = %d, want %d", got, mutations+1) + } + } + }) + + t.Run("rejects replacement that becomes empty without side effects", func(t *testing.T) { + find, replace := "[REDACTED] [REDACTED]", "" + before := load() + mutations := mutationCount(before.SyncID) + if _, err := s.UpdateObservation(id, UpdateObservationParams{Find: &find, Replace: &replace}); !errors.Is(err, ErrObservationContentRequired) { + t.Fatalf("expected ErrObservationContentRequired, got %v", err) + } + assertUnchanged(t, before, mutations) + }) + + t.Run("truncates expanded output at UTF-8 boundaries", func(t *testing.T) { + s.cfg.MaxObservationLength = 10 + find, replace := "[REDACTED]", "界界" + updated, err := s.UpdateObservation(id, UpdateObservationParams{Find: &find, Replace: &replace}) + if err != nil { + t.Fatalf("replace observation: %v", err) + } + if !utf8.ValidString(updated.Content) || updated.Content != "界界 界... [truncated]" { + t.Fatalf("content = %q, want UTF-8-safe truncated replacement", updated.Content) + } + }) + + t.Run("bounds oversized replacement input before mutation", func(t *testing.T) { + find, replace := "界", strings.Repeat("x", s.cfg.MaxObservationLength+1) + before := load() + mutations := mutationCount(before.SyncID) + if _, err := s.UpdateObservation(id, UpdateObservationParams{Find: &find, Replace: &replace}); !errors.Is(err, ErrObservationFindReplaceTooLarge) { + t.Fatalf("expected ErrObservationFindReplaceTooLarge, got %v", err) + } + assertUnchanged(t, before, mutations) + }) +} + +func TestUpdateObservationFindReplaceRedactsBeforeTruncatingPrivateTag(t *testing.T) { + s := newTestStore(t) + s.cfg.MaxObservationLength = 25 + enrollTestProject(t, s, "engram") + if err := s.CreateSession("s-update-find-replace-private-truncation", "engram", t.TempDir()); err != nil { + t.Fatalf("create session: %v", err) + } + id, err := s.AddObservation(AddObservationParams{ + SessionID: "s-update-find-replace-private-truncation", + Type: "note", + Title: "Original title", + Content: "12345replace me", + Project: "engram", + Scope: "project", + }) + if err != nil { + t.Fatalf("add observation: %v", err) + } + + updated, err := s.UpdateObservation(id, UpdateObservationParams{ + Find: ptr("replace me"), + Replace: ptr("secret"), + }) + if err != nil { + t.Fatalf("replace observation: %v", err) + } + if updated.Content != "12345[REDACTED]" { + t.Fatalf("content = %q, want redacted replacement before truncation", updated.Content) + } +} + +func ptr(s string) *string { return &s } + func TestUpdateObservationAcceptsPrivateTagOnlyTitle(t *testing.T) { s := newTestStore(t) enrollTestProject(t, s, "engram") diff --git a/internal/sync/sync_test.go b/internal/sync/sync_test.go index 83b725b3b..abcaa3d98 100644 --- a/internal/sync/sync_test.go +++ b/internal/sync/sync_test.go @@ -3641,7 +3641,7 @@ func TestCloudImportAppliesMutationReconciliationForUpdatesAndDeletes(t *testing SessionID: "sess-a", Type: "decision", Title: "v1", - Content: "original", + Content: "original old", Project: "proj-a", Scope: "project", }) @@ -3661,9 +3661,9 @@ func TestCloudImportAppliesMutationReconciliationForUpdatesAndDeletes(t *testing t.Fatal("expected first cloud export to write initial snapshot") } - updatedTitle := "v2" - if _, err := src.UpdateObservation(obsID, store.UpdateObservationParams{Title: &updatedTitle}); err != nil { - t.Fatalf("update observation: %v", err) + updatedTitle, find, replace := "v2", "old", "new" + if _, err := src.UpdateObservation(obsID, store.UpdateObservationParams{Title: &updatedTitle, Find: &find, Replace: &replace}); err != nil { + t.Fatalf("find-and-replace update observation: %v", err) } if err := src.DeletePrompt(promptID); err != nil { t.Fatalf("delete prompt: %v", err) @@ -3692,8 +3692,8 @@ func TestCloudImportAppliesMutationReconciliationForUpdatesAndDeletes(t *testing if err != nil { t.Fatalf("search updated observation: %v", err) } - if len(found) == 0 || found[0].Title != "v2" { - t.Fatalf("expected updated observation title after pull reconciliation, got %+v", found) + if len(found) == 0 || found[0].Title != "v2" || found[0].Content != "original new" { + t.Fatalf("expected find-and-replace update after pull reconciliation, got %+v", found) } prompts, err := dst.RecentPrompts("proj-a", 10)