Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions backend/api/apitest/apitest.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ func (b *ChangeBuilder) SetCapability(cpbID string) *ChangeBuilder {
return b
}

// SetMessage sets the optional human-readable publish message on the request.
func (b *ChangeBuilder) SetMessage(message string) *ChangeBuilder {
b.req.Message = message
return b
}

// SetMetadata adds a SetMetadata change to the request.
func (b *ChangeBuilder) SetMetadata(key, value string) *ChangeBuilder {
b.req.Changes = append(b.req.Changes, &documents.DocumentChange{
Expand Down
2 changes: 2 additions & 0 deletions backend/api/documents/v3alpha/dochistory.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ func (srv *Server) ListDocumentChanges(ctx context.Context, in *documents.ListDo
Author: change.Signer.String(),
Deps: colx.SliceMap(change.Deps, cid.Cid.String),
CreateTime: timestamppb.New(change.Ts),
Message: change.Message,
})
}

Expand Down Expand Up @@ -144,5 +145,6 @@ func (srv *Server) GetDocumentChange(ctx context.Context, in *documents.GetDocum
Author: change.Signer.String(),
Deps: colx.SliceMap(change.Deps, cid.Cid.String),
CreateTime: timestamppb.New(change.Ts),
Message: change.Message,
}, nil
}
8 changes: 5 additions & 3 deletions backend/api/documents/v3alpha/docmodel/crdt.go
Original file line number Diff line number Diff line change
Expand Up @@ -590,8 +590,10 @@ func addUnique(in []int, v int) []int {
return slices.Insert(in, targetIndex, v)
}

// prepareChange to be applied later.
func (e *docCRDT) prepareChange(ts time.Time, signer core.Signer, body blob.ChangeBody) (hb blob.Encoded[*blob.Change], err error) {
// prepareChange to be applied later. The message argument is an optional
// human-readable description (like a git commit message) embedded in the
// signed Change blob; pass an empty string to omit it.
func (e *docCRDT) prepareChange(ts time.Time, signer core.Signer, body blob.ChangeBody, message string) (hb blob.Encoded[*blob.Change], err error) {
var genesis cid.Cid
if len(e.cids) > 0 {
genesis = e.cids[0]
Expand All @@ -611,7 +613,7 @@ func (e *docCRDT) prepareChange(ts time.Time, signer core.Signer, body blob.Chan
}
slices.SortFunc(deps, func(a, b cid.Cid) int { return strings.Compare(a.KeyString(), b.KeyString()) })

hb, err = blob.NewChange(signer, genesis, deps, depth, body, ts)
hb, err = blob.NewChange(signer, genesis, deps, depth, body, ts, message)
if err != nil {
return hb, err
}
Expand Down
17 changes: 10 additions & 7 deletions backend/api/documents/v3alpha/docmodel/docmodel.go
Original file line number Diff line number Diff line change
Expand Up @@ -340,22 +340,25 @@ func (dm *Document) ensureTreeMutation() (*blockTreeMutation, error) {
return dm.mut, nil
}

// SignChange creates a change.
// SignChange creates a change with an optional human-readable message
// (similar to a git commit message). Pass an empty string to omit the message.
// After this the Document instance must be discarded. The change must be applied to a different state.
func (dm *Document) SignChange(kp *core.KeyPair) (hb blob.Encoded[*blob.Change], err error) {
return dm.SignChangeAt(kp, dm.crdt.clock.MustNow())
func (dm *Document) SignChange(kp *core.KeyPair, message string) (hb blob.Encoded[*blob.Change], err error) {
return dm.SignChangeAt(kp, dm.crdt.clock.MustNow(), message)
}

// SignChangeAt creates a change at the given timestamp, ignoring the internal clock.
// The timestamp must still satisfy the causality rules, i.e. be strictly greater than any previously observed timestamp.
func (dm *Document) SignChangeAt(kp *core.KeyPair, at time.Time) (hb blob.Encoded[*blob.Change], err error) {
return dm.CreateChange(kp, at)
// The message argument is optional (pass an empty string to omit).
func (dm *Document) SignChangeAt(kp *core.KeyPair, at time.Time, message string) (hb blob.Encoded[*blob.Change], err error) {
return dm.CreateChange(kp, at, message)
}

// CreateChange creates a Change blob with the given signer and principal.
// This allows using NopSigner for client-side signing scenarios where the signer doesn't actually sign.
// The message argument is optional and embedded in the signed Change blob.
// After this the Document instance must be discarded.
func (dm *Document) CreateChange(signer core.Signer, at time.Time) (hb blob.Encoded[*blob.Change], err error) {
func (dm *Document) CreateChange(signer core.Signer, at time.Time, message string) (hb blob.Encoded[*blob.Change], err error) {
if dm.done {
return hb, fmt.Errorf("using already committed mutation")
}
Expand All @@ -369,7 +372,7 @@ func (dm *Document) CreateChange(signer core.Signer, at time.Time) (hb blob.Enco

at = at.Round(dm.crdt.clock.Precision)

hb, err = dm.crdt.prepareChange(at, signer, ops)
hb, err = dm.crdt.prepareChange(at, signer, ops, message)
if err != nil {
return hb, err
}
Expand Down
12 changes: 6 additions & 6 deletions backend/api/documents/v3alpha/docmodel/docmodel_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ func TestDocmodelSmoke(t *testing.T) {
must.Do(doc.MoveBlock("b2", "", "b1"))
must.Do(doc.MoveBlock("b3", "", "b2"))
must.Do(doc.MoveBlock("b1.1", "b1", ""))
c1 := must.Do2(doc.SignChange(alice))
c1 := must.Do2(doc.SignChange(alice, ""))

want := &blob.Change{
Body: blob.ChangeBody{
Expand Down Expand Up @@ -54,7 +54,7 @@ func TestDocmodelSmoke(t *testing.T) {
must.Do(doc.DeleteBlock("b1.1"))
must.Do(doc.MoveBlock("b4", "", ""))
must.Do(doc.DeleteBlock("b3"))
c2 := must.Do2(doc.SignChange(alice))
c2 := must.Do2(doc.SignChange(alice, ""))

{
doc := must.Do2(New("mydoc", cclock.New()))
Expand Down Expand Up @@ -94,7 +94,7 @@ func TestBug_RedundantReplaces(t *testing.T) {
Text: "3",
}))

c1, err := doc.SignChange(alice)
c1, err := doc.SignChange(alice, "")
require.NoError(t, err)

var c2 blob.Encoded[*blob.Change]
Expand All @@ -120,7 +120,7 @@ func TestBug_RedundantReplaces(t *testing.T) {
Text: "3",
}))

c2, err = doc.SignChange(alice)
c2, err = doc.SignChange(alice, "")
require.NoError(t, err)
}

Expand Down Expand Up @@ -176,7 +176,7 @@ func TestBug_BlockReordering(t *testing.T) {
Text: "5",
}))

c1, err := doc.SignChange(alice)
c1, err := doc.SignChange(alice, "")
require.NoError(t, err)

var c2 blob.Encoded[*blob.Change]
Expand Down Expand Up @@ -219,7 +219,7 @@ func TestBug_BlockReordering(t *testing.T) {

must.Do(doc.DeleteBlock("mMa"))

c2, err = doc.SignChange(alice)
c2, err = doc.SignChange(alice, "")
require.NoError(t, err)
}

Expand Down
12 changes: 6 additions & 6 deletions backend/api/documents/v3alpha/documents.go
Original file line number Diff line number Diff line change
Expand Up @@ -222,12 +222,12 @@ func (srv *Server) CreateDocumentChange(ctx context.Context, in *documents.Creat

var docChange blob.Encoded[*blob.Change]
if in.Timestamp != nil {
docChange, err = doc.SignChangeAt(kp, in.Timestamp.AsTime())
docChange, err = doc.SignChangeAt(kp, in.Timestamp.AsTime(), in.Message)
if err != nil {
return nil, fmt.Errorf("failed to create document change with the provided timestamp: %w", err)
}
} else {
docChange, err = doc.SignChange(kp)
docChange, err = doc.SignChange(kp, in.Message)
if err != nil {
return nil, fmt.Errorf("failed to create document change: %w", err)
}
Expand Down Expand Up @@ -274,15 +274,15 @@ func (srv *Server) PrepareChange(ctx context.Context, in *documents.PrepareChang
BaseVersion: in.BaseVersion,
Changes: in.Changes,
Capability: in.Capability,
Visibility: in.Visibility,
})
if err != nil {
return nil, err
}

// Use NopSigner to create the Change without actually signing it.
// The client will sign it themselves.
change, err := doc.CreateChange(blob.NewNopSigner(nil), time.Now())
// The client will sign it themselves. The optional message is embedded in
// the unsigned bytes so the client doesn't need to know about it.
change, err := doc.CreateChange(blob.NewNopSigner(nil), time.Now(), in.Message)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -1710,7 +1710,7 @@ func (srv *Server) getRef(ctx context.Context, c cid.Cid) (hb blob.WithCID[*blob
}

func (srv *Server) ensureProfileGenesis(ctx context.Context, kp *core.KeyPair) error {
ebc, err := blob.NewChange(kp, cid.Undef, nil, 0, blob.ChangeBody{}, blob.ZeroUnixTime())
ebc, err := blob.NewChange(kp, cid.Undef, nil, 0, blob.ChangeBody{}, blob.ZeroUnixTime(), "")
if err != nil {
return err
}
Expand Down
2 changes: 1 addition & 1 deletion backend/blob/blob_capability_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ func TestOutOfOrderCapability(t *testing.T) {
Ops: []OpMap{
must.Do2(NewOpSetKey("name", "Hello")),
},
}, clock.MustNow())
}, clock.MustNow(), "")
require.NoError(t, err)

ref, err := NewRef(bob, 0, change.CID, alice.Principal(), "", []cid.Cid{change.CID}, clock.MustNow(), VisibilityPublic)
Expand Down
18 changes: 15 additions & 3 deletions backend/blob/blob_change.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ type Change struct {
Deps []cid.Cid `refmt:"deps,omitempty"`
Depth int `refmt:"depth,omitempty"`
Body ChangeBody `refmt:"body,omitempty"`
// Message is an optional human-readable description of this change,
// similar to a git commit message. Set by the publisher and preserved
// permanently as part of the signed Change blob.
Message string `refmt:"message,omitempty"`
}

// ChangeBody is the body of a Change.
Expand All @@ -44,8 +48,10 @@ type ChangeBody struct {
Ops []OpMap `refmt:"ops,omitempty"`
}

// NewChange creates a new Change.
func NewChange(signer core.Signer, genesis cid.Cid, deps []cid.Cid, depth int, body ChangeBody, ts time.Time) (eb Encoded[*Change], err error) {
// NewChange creates a new Change. The message argument is an optional
// human-readable description (like a git commit message) embedded into the
// signed blob; pass an empty string to omit it.
func NewChange(signer core.Signer, genesis cid.Cid, deps []cid.Cid, depth int, body ChangeBody, ts time.Time, message string) (eb Encoded[*Change], err error) {
if !slices.IsSortedFunc(deps, func(a, b cid.Cid) int {
return cmp.Compare(a.KeyString(), b.KeyString())
}) {
Expand All @@ -62,6 +68,7 @@ func NewChange(signer core.Signer, genesis cid.Cid, deps []cid.Cid, depth int, b
Deps: deps,
Depth: depth,
Body: body,
Message: message,
}

if err := Sign(signer, cc, &cc.BaseBlob.Sig); err != nil {
Expand Down Expand Up @@ -526,7 +533,11 @@ func indexChange(ictx *indexingCtx, id int64, eb Encoded[*Change]) error {
}
}

if extra.Title != "" || len(extra.Metadata) > 0 {
if v.Message != "" {
extra.Message = v.Message
}

if extra.Title != "" || len(extra.Metadata) > 0 || extra.Message != "" {
sb.ExtraAttrs = extra
}

Expand All @@ -544,6 +555,7 @@ func indexChange(ictx *indexingCtx, id int64, eb Encoded[*Change]) error {
type changeIndexedAttrs struct {
Title string `json:"title"` // Deprecated. TODO(burdiyan): remove this in favor of metadata.
Metadata map[string]any `json:"metadata,omitempty"`
Message string `json:"message,omitempty"`
}

type decodedBlob[T any] struct {
Expand Down
38 changes: 38 additions & 0 deletions backend/blob/blob_change_test.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
package blob

import (
"seed/backend/core/coretest"
"seed/backend/util/cclock"
"seed/backend/util/must"
"testing"

"github.com/ipfs/go-cid"
cbornode "github.com/ipfs/go-ipld-cbor"
"github.com/stretchr/testify/require"
)
Expand Down Expand Up @@ -55,3 +59,37 @@ func TestBlockEncoding_FieldInlining(t *testing.T) {
var blk2 Block
require.NoError(t, cbornode.DecodeInto(raw, &blk2), "round-trip decoding failed")
}

// TestChangeMessageRoundTrip verifies that the optional Message field
// survives a CBOR encode/decode cycle and is included in the indexed
// extra_attrs so the API can surface it without re-decoding the blob.
func TestChangeMessageRoundTrip(t *testing.T) {
alice := coretest.NewTester("alice").Account
clock := cclock.New()

msg := "Initial publish: import seed sources"
c, err := NewChange(alice, cid.Undef, nil, 0, ChangeBody{
Ops: []OpMap{
must.Do2(NewOpSetKey("title", "Hello")),
},
}, clock.MustNow(), msg)
require.NoError(t, err)

var decoded Change
require.NoError(t, cbornode.DecodeInto(c.Data, &decoded))
require.Equal(t, msg, decoded.Message, "message field must survive CBOR round-trip")

// Also check that an empty message is omitted from the encoding,
// preserving backward compatibility for changes without a message.
c2, err := NewChange(alice, cid.Undef, nil, 0, ChangeBody{
Ops: []OpMap{
must.Do2(NewOpSetKey("title", "Hello")),
},
}, clock.MustNow(), "")
require.NoError(t, err)

var raw map[string]any
require.NoError(t, cbornode.DecodeInto(c2.Data, &raw))
_, hasMessage := raw["message"]
require.False(t, hasMessage, "empty message must be omitted from CBOR encoding")
}
4 changes: 2 additions & 2 deletions backend/blob/blob_ref_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ func TestRefCausality(t *testing.T) {
Ops: []OpMap{
must.Do2(NewOpSetKey("title", "Initial Document")),
},
}, clock.MustNow())
}, clock.MustNow(), "")
require.NoError(t, err)

time.Sleep(time.Millisecond)
Expand All @@ -38,7 +38,7 @@ func TestRefCausality(t *testing.T) {
Ops: []OpMap{
must.Do2(NewOpSetKey("content", "Updated content")),
},
}, clock.MustNow())
}, clock.MustNow(), "")
require.NoError(t, err)

time.Sleep(time.Millisecond)
Expand Down
4 changes: 2 additions & 2 deletions backend/blob/index_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ func TestWriterCheck_DelegatedSessionKeys(t *testing.T) {
Ops: []OpMap{
must.Do2(NewOpSetKey("title", "Session key edit")),
},
}, clock.MustNow())
}, clock.MustNow(), "")
require.NoError(t, err)

ref, err := NewRef(bobSession, 0, change.CID, alice.Principal(), "/delegated-session", []cid.Cid{change.CID}, clock.MustNow(), VisibilityPublic)
Expand Down Expand Up @@ -120,7 +120,7 @@ func TestWriterCheck_OwnersSessionKey(t *testing.T) {
Ops: []OpMap{
must.Do2(NewOpSetKey("title", "Owner session key edit")),
},
}, clock.MustNow())
}, clock.MustNow(), "")
require.NoError(t, err)

ref, err := NewRef(aliceSession, 0, change.CID, alice.Principal(), "/owner-session", []cid.Cid{change.CID}, clock.MustNow(), VisibilityPublic)
Expand Down
10 changes: 5 additions & 5 deletions backend/blob/index_visibility_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,35 +93,35 @@ func TestRefVisibilityPropagationSingleRef(t *testing.T) {
Ops: []OpMap{
must.Do2(NewOpSetKey("title", "Genesis")),
},
}, clock.MustNow())
}, clock.MustNow(), "")
require.NoError(t, err)

c2, err := NewChange(alice.Account, c1.CID, []cid.Cid{c1.CID}, 1, ChangeBody{
Ops: []OpMap{
must.Do2(NewOpSetKey("title", "First update")),
},
}, clock.MustNow())
}, clock.MustNow(), "")
require.NoError(t, err)

c3, err := NewChange(alice.Account, c1.CID, []cid.Cid{c2.CID}, 2, ChangeBody{
Ops: []OpMap{
must.Do2(NewOpSetKey("title", "Side branch")),
},
}, clock.MustNow())
}, clock.MustNow(), "")
require.NoError(t, err)

c4, err := NewChange(alice.Account, c1.CID, []cid.Cid{c2.CID}, 2, ChangeBody{
Ops: []OpMap{
must.Do2(NewOpSetKey("content", "Merge head")),
},
}, clock.MustNow())
}, clock.MustNow(), "")
require.NoError(t, err)

cStar, err := NewChange(alice.Account, c1.CID, []cid.Cid{c4.CID}, 3, ChangeBody{
Ops: []OpMap{
must.Do2(NewOpSetKey("content", "Out of closure")),
},
}, clock.MustNow())
}, clock.MustNow(), "")
require.NoError(t, err)

ref, err := NewRef(alice.Account, 0, c1.CID, alice.Account.Principal(), "/test-doc", []cid.Cid{c4.CID, c3.CID}, clock.MustNow(), VisibilityPrivate)
Expand Down
Loading
Loading