Files
pad/internal/store/activity_seq_chain.go
T
xarmian 0cc033edd8 feat(items): content-anchored patch writes on an item with no live editor (PLAN-3578 step 1) (#2055)
POST /workspaces/{ws}/items/{ref}/patch {edits:[{old,new,line_hint?}],
expected_seq?} replaces exact text in the body. It runs through updateItem,
so it takes every PATCH guard; the body is computed inside the store
transaction from the row it replaces. Each old must occur exactly once;
edits apply in order, all or nothing; every patch writes a version. The
answer carries a unified diff of what changed.

expected_seq does not refuse: a moved seq is applied over when the
anchors still match, and answered rebased with the intervening writes,
read from a seq chain stamped into item activity entries ({prev_seq, seq,
version_id}). complete is decided at runtime, so any write that left no
stamp reads incomplete; a guard test classifies every seq-moving store
statement. Refusals write nothing: patch_anchor_missing,
patch_anchor_ambiguous, patch_room_open (an open editor; step 2).

CLI: pad item patch, pad item show --lines. MCP 0.87 (additive):
pad_item.patch, get lines, three allow-listed codes. The timeline treats
the chain and patch keys as bookkeeping, not a change.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-11 06:20:09 -04:00

127 lines
4.1 KiB
Go

package store
import (
"encoding/json"
"strconv"
"github.com/PerpetualSoftware/pad/internal/models"
)
// Activity metadata keys that link an item's writes into a chain
// (PLAN-3578). An entry stamped {seq: S, prev_seq: P} records a write that
// moved the item from P to S. ActivitySeqChainBroken marks a merged entry
// whose runs were not contiguous, so the chain cannot pass through it.
const (
ActivityMetaSeq = "seq"
ActivityMetaPrevSeq = "prev_seq"
ActivityMetaVersionID = "version_id"
ActivitySeqChainBroken = "seq_chain_broken"
itemSeqChainActivityWindow = 500
)
// ItemSeqChain walks the item's stamped activity entries from toSeq back to
// fromSeq and returns them newest first, at most max. Complete is true only
// when the walk reaches fromSeq exactly without a gap.
func (s *Store) ItemSeqChain(itemID string, fromSeq, toSeq int64, max int) (*models.PatchIntervening, error) {
out := &models.PatchIntervening{FromSeq: fromSeq, ToSeq: toSeq, Changes: []models.PatchInterveningChange{}}
if toSeq <= fromSeq {
out.Complete = toSeq == fromSeq
return out, nil
}
acts, err := s.ListDocumentActivity(itemID, models.ActivityListParams{Limit: itemSeqChainActivityWindow})
if err != nil {
return nil, err
}
bySeq := map[int64]models.PatchInterveningChange{}
broken := map[int64]bool{}
for _, a := range acts {
seq, prev, vid, isBroken, ok := seqStamp(a.Metadata)
if !ok {
continue
}
if _, seen := bySeq[seq]; seen {
continue // newest first: the first one listed wins
}
bySeq[seq] = models.PatchInterveningChange{
ActivityID: a.ID, Seq: seq, PrevSeq: prev, Action: a.Action,
Actor: a.Actor, Source: a.Source, At: a.CreatedAt, VersionID: vid,
}
broken[seq] = isBroken
}
for cursor := toSeq; ; {
if cursor == fromSeq {
out.Complete = true
return out, nil
}
c, ok := bySeq[cursor]
// PrevSeq strictly below cursor keeps the walk finite on a malformed
// entry. A walk that steps past fromSeq needs no check of its own:
// seqs only fall, so it never meets fromSeq and ends at a missing
// entry, incomplete.
if !ok || broken[cursor] || c.PrevSeq >= cursor {
return out, nil
}
if len(out.Changes) < max {
out.Changes = append(out.Changes, c)
} else {
out.Truncated = true
}
cursor = c.PrevSeq
}
}
// seqStamp reads an entry's chain stamp. Values are strings (the handlers'
// metadata maps are string-valued) or numbers; ok is false without both.
func seqStamp(meta string) (seq, prev int64, versionID string, broken, ok bool) {
var m map[string]interface{}
if json.Unmarshal([]byte(meta), &m) != nil || m == nil {
return 0, 0, "", false, false
}
seq, ok1 := metaInt(m[ActivityMetaSeq])
prev, ok2 := metaInt(m[ActivityMetaPrevSeq])
if !ok1 || !ok2 {
return 0, 0, "", false, false
}
versionID, _ = m[ActivityMetaVersionID].(string)
b, _ := m[ActivitySeqChainBroken].(string)
return seq, prev, versionID, b == "true", true
}
func metaInt(v interface{}) (int64, bool) {
switch x := v.(type) {
case string:
n, err := strconv.ParseInt(x, 10, 64)
return n, err == nil
case float64:
return int64(x), true
}
return 0, false
}
// mergeSeqChain fixes the chain keys of a debounced merge: the merged entry
// spans the first run's prev_seq to the latest seq, which is true only when
// the runs were contiguous (the existing seq is the incoming prev_seq).
// Otherwise a write between them recorded no stamp here, and the merged
// entry is marked broken, so the chain can never read through the gap.
func mergeSeqChain(existMap, newMap map[string]interface{}) {
es, eok := metaInt(existMap[ActivityMetaSeq])
ep, epok := metaInt(existMap[ActivityMetaPrevSeq])
_, nok := metaInt(newMap[ActivityMetaSeq])
np, npok := metaInt(newMap[ActivityMetaPrevSeq])
if !nok || !npok {
// The incoming write carried no stamp: the merged entry cannot
// vouch for the span it now covers.
if eok {
existMap[ActivitySeqChainBroken] = "true"
}
return
}
if !eok || !epok || es != np {
existMap[ActivitySeqChainBroken] = "true"
return
}
// Contiguous: keep the first run's prev_seq (the overlay set the
// incoming one).
existMap[ActivityMetaPrevSeq] = strconv.FormatInt(ep, 10)
}