diff --git a/pkg/compiler/compiler.go b/pkg/compiler/compiler.go index 1ef321d..010fe75 100644 --- a/pkg/compiler/compiler.go +++ b/pkg/compiler/compiler.go @@ -252,12 +252,19 @@ func Compile(ctx context.Context, st *store.Store, opts ...Option) (*Result, err } deltas := diff.DiffFlat(flat, prev) - cursorHash := diff.CursorHashFlat(flat) // Emit and relations share one transaction so the snapshot, node mutations, // cursor advance, and relation writes commit or roll back together. A relations // failure must not leave HEAD ahead of the error returned to the caller. err = st.Tx(ctx, func(tx *sql.Tx) error { + // Fold the in-transaction HEAD id into the cursor hash so a compile whose + // post-state matches an earlier snapshot doesn't collide on cursor_hash. + prevHeadID, err := st.GetHeadSnapshotIDTx(ctx, tx) + if err != nil { + return fmt.Errorf("failed to fetch: head snapshot id: %w", err) + } + cursorHash := diff.CursorHashForCompile(prevHeadID, flat) + if err := emitter.EmitTx(ctx, st, tx, emitter.WithRoots(roots), emitter.WithDeltas(deltas), diff --git a/pkg/compiler/compiler_test.go b/pkg/compiler/compiler_test.go index 1b0533a..64af005 100644 --- a/pkg/compiler/compiler_test.go +++ b/pkg/compiler/compiler_test.go @@ -338,6 +338,36 @@ func TestCompile_Recompile(t *testing.T) { } } +// Reverting a file to a prior content state lands the compile post-state on an +// earlier snapshot's content; folding the parent snapshot id keeps cursor_hash +// unique so the revert-compile doesn't hit UNIQUE constraint failed. Regression for #244. +func TestCompile_RevertToPriorStateSucceeds(t *testing.T) { + st := testutil.OpenTestDB(t) + ctx := context.Background() + dir := t.TempDir() + + p := writeFile(t, dir, "doc.md", "# Hello\n\nState A.\n") + if _, err := Compile(ctx, st, WithPaths([]string{p}), WithMessage("v1")); err != nil { + t.Fatalf("Compile v1: %v", err) + } + + writeFile(t, dir, "doc.md", "# Hello\n\nState B.\n") + if _, err := Compile(ctx, st, WithPaths([]string{p}), WithMessage("v2")); err != nil { + t.Fatalf("Compile v2: %v", err) + } + + // Revert to the exact content of v1; the post-state matches snapshot 1. + writeFile(t, dir, "doc.md", "# Hello\n\nState A.\n") + if _, err := Compile(ctx, st, WithPaths([]string{p}), WithMessage("v3")); err != nil { + t.Fatalf("Compile v3 (revert to prior state): %v", err) + } + + snaps, _ := st.ListSnapshots(ctx, 10) + if len(snaps) != 3 { + t.Errorf("snapshots = %d, want 3 (each compile lands its own snapshot)", len(snaps)) + } +} + func TestCompile_SingleFileRescanAfterBatch(t *testing.T) { st := testutil.OpenTestDB(t) ctx := context.Background() diff --git a/pkg/diff/hash.go b/pkg/diff/hash.go index 3b54267..8f8b889 100644 --- a/pkg/diff/hash.go +++ b/pkg/diff/hash.go @@ -14,39 +14,19 @@ func CursorHash(roots []*parser.ContextNode) string { } func CursorHashFlat(flat []*parser.ContextNode) string { - pairs := make([]string, len(flat)) - for i, n := range flat { - pairs[i] = n.ID + ":" + n.ContentHash - } - sort.Strings(pairs) - h := xxhash.New() - for _, s := range pairs { - _, _ = h.WriteString(s) - } - - var buf [8]byte - binary.BigEndian.PutUint64(buf[:], h.Sum64()) - return hex.EncodeToString(buf[:]) + writeFlatPairs(h, flat) + return sumHex(h) } -// Hash a delta set when roots alone don't characterize the post-state (e.g., deletions). -func CursorHashForDeltas(deltas []Delta) string { - pairs := make([]string, len(deltas)) - for i, d := range deltas { - pairs[i] = string(d.Op) + ":" + d.NodeID + ":" + d.OldHash + ":" + d.NewHash - } - - sort.Strings(pairs) - +// Hash a compiled post-state against its parent snapshot so a compile that lands +// on a prior content state (edit, compile, revert, compile) still yields a unique +// digest. prevHeadID is the HEAD snapshot id (monotonic, never reused). +func CursorHashForCompile(prevHeadID int64, flat []*parser.ContextNode) string { h := xxhash.New() - for _, p := range pairs { - _, _ = h.WriteString(p) - } - - var buf [8]byte - binary.BigEndian.PutUint64(buf[:], h.Sum64()) - return hex.EncodeToString(buf[:]) + writeID(h, prevHeadID) + writeFlatPairs(h, flat) + return sumHex(h) } // Hash a change against its parent snapshot so reverting to a prior post-state @@ -54,47 +34,54 @@ func CursorHashForDeltas(deltas []Delta) string { // never reused), so the result is unique per snapshot even when the deltas repeat. func CursorHashForChange(prevHeadID int64, deltas []Delta) string { h := xxhash.New() - - var idBuf [8]byte - binary.BigEndian.PutUint64(idBuf[:], uint64(prevHeadID)) - _, _ = h.Write(idBuf[:]) - - pairs := make([]string, len(deltas)) - for i, d := range deltas { - pairs[i] = string(d.Op) + ":" + d.NodeID + ":" + d.OldHash + ":" + d.NewHash - } - - sort.Strings(pairs) - for _, p := range pairs { - _, _ = h.WriteString(p) - } - - var out [8]byte - binary.BigEndian.PutUint64(out[:], h.Sum64()) - return hex.EncodeToString(out[:]) + writeID(h, prevHeadID) + writeDeltaPairs(h, deltas) + return sumHex(h) } func CursorHashForRollback(prevHeadID, targetID int64, deltas []Delta) string { h := xxhash.New() + writeID(h, prevHeadID) + writeID(h, targetID) + writeDeltaPairs(h, deltas) + return sumHex(h) +} - var idBuf [8]byte - binary.BigEndian.PutUint64(idBuf[:], uint64(prevHeadID)) - _, _ = h.Write(idBuf[:]) +// Fold a monotonic snapshot id into the digest. +func writeID(h *xxhash.Digest, id int64) { + var buf [8]byte + binary.BigEndian.PutUint64(buf[:], uint64(id)) + _, _ = h.Write(buf[:]) +} - binary.BigEndian.PutUint64(idBuf[:], uint64(targetID)) - _, _ = h.Write(idBuf[:]) +// Write the sorted id:contentHash pairs that characterize a flat post-state. +func writeFlatPairs(h *xxhash.Digest, flat []*parser.ContextNode) { + pairs := make([]string, len(flat)) + for i, n := range flat { + pairs[i] = n.ID + ":" + n.ContentHash + } + sort.Strings(pairs) + + for _, s := range pairs { + _, _ = h.WriteString(s) + } +} +// Write the sorted op:id:old:new pairs that characterize a delta set. +func writeDeltaPairs(h *xxhash.Digest, deltas []Delta) { pairs := make([]string, len(deltas)) for i, d := range deltas { pairs[i] = string(d.Op) + ":" + d.NodeID + ":" + d.OldHash + ":" + d.NewHash } - sort.Strings(pairs) + for _, p := range pairs { _, _ = h.WriteString(p) } +} - var out [8]byte - binary.BigEndian.PutUint64(out[:], h.Sum64()) - return hex.EncodeToString(out[:]) +func sumHex(h *xxhash.Digest) string { + var buf [8]byte + binary.BigEndian.PutUint64(buf[:], h.Sum64()) + return hex.EncodeToString(buf[:]) } diff --git a/pkg/diff/hash_test.go b/pkg/diff/hash_test.go index ede1eaa..24429c8 100644 --- a/pkg/diff/hash_test.go +++ b/pkg/diff/hash_test.go @@ -91,6 +91,36 @@ func TestCursorHashForChange_OrderStable(t *testing.T) { } } +// A compile that lands on a prior content state (edit→compile→revert→compile) +// reproduces the same flat post-state; folding the parent snapshot id keeps the +// digest unique so it doesn't collide on snapshots.cursor_hash. Regression for #244. +func TestCursorHashForCompile_DistinctAcrossRepeatState(t *testing.T) { + flat := []*parser.ContextNode{{ID: "n1", ContentHash: "aaaa"}} + + a := CursorHashForCompile(2, flat) + b := CursorHashForCompile(4, flat) + if a == b { + t.Errorf("repeated post-state shares hash across parents: %q", a) + } + if len(a) != 16 { + t.Errorf("len = %d, want 16", len(a)) + } +} + +func TestCursorHashForCompile_OrderIndependent(t *testing.T) { + a := CursorHashForCompile(3, []*parser.ContextNode{ + {ID: "n1", ContentHash: "aaaa"}, + {ID: "n2", ContentHash: "bbbb"}, + }) + b := CursorHashForCompile(3, []*parser.ContextNode{ + {ID: "n2", ContentHash: "bbbb"}, + {ID: "n1", ContentHash: "aaaa"}, + }) + if a != b { + t.Errorf("order-dependent: %q vs %q", a, b) + } +} + func TestSnapshotFromNodes(t *testing.T) { roots := []*parser.ContextNode{ { diff --git a/pkg/mcp/tools/forget.go b/pkg/mcp/tools/forget.go index ee2b6fd..bd9e851 100644 --- a/pkg/mcp/tools/forget.go +++ b/pkg/mcp/tools/forget.go @@ -53,10 +53,15 @@ func (d *Deps) HandleForget(ctx context.Context, _ *gomcp.CallToolRequest, input return nil, nil, err } + prevHeadID, err := d.Store.GetHeadSnapshotID(ctx) + if err != nil { + return nil, nil, fmt.Errorf("failed to fetch: head snapshot id: %w", err) + } + if err := emitter.Emit(ctx, d.Store, emitter.WithRoots(roots), emitter.WithDeltas(deltas), - emitter.WithCursorHash(diff.CursorHashForDeltas(deltas)), + emitter.WithCursorHash(diff.CursorHashForChange(prevHeadID, deltas)), emitter.WithMessage("forget:"+mode.String()+":"+input.NodeID), ); err != nil { return nil, nil, fmt.Errorf("failed to forget: %w", err) diff --git a/pkg/mcp/tools/tools_test.go b/pkg/mcp/tools/tools_test.go index 4fa953c..b152ad4 100644 --- a/pkg/mcp/tools/tools_test.go +++ b/pkg/mcp/tools/tools_test.go @@ -1857,6 +1857,32 @@ func TestHandleForget_Reparent_EmitsSingleSnapshot(t *testing.T) { } } +// A re-add-then-forget sequence reproduces an identical delta set (same op, id, +// and old hash); folding the parent snapshot id keeps each forget's cursor_hash +// unique instead of colliding on snapshots.cursor_hash. Regression for #244. +func TestHandleForget_ReAddThenForget(t *testing.T) { + d, st := setup(t) + ctx := context.Background() + + const anchor = "dupanchor001" + for i := 0; i < 2; i++ { + if _, _, err := d.HandleWrite(ctx, &gomcp.CallToolRequest{}, WriteInput{ + Anchor: anchor, Payload: "same payload", + }); err != nil { + t.Fatalf("HandleWrite #%d: %v", i+1, err) + } + if _, _, err := d.HandleForget(ctx, &gomcp.CallToolRequest{}, ForgetInput{NodeID: anchor}); err != nil { + t.Fatalf("HandleForget #%d: %v", i+1, err) + } + } + + snaps, err := st.ListSnapshots(ctx, 100) + must(t, err) + if len(snaps) != 4 { + t.Fatalf("snapshots = %d, want 4 (write+forget twice, each lands its own snapshot)", len(snaps)) + } +} + func TestHandleForget_MissingNode(t *testing.T) { d, _ := setup(t) ctx := context.Background()