Skip to content

feat: Implement Message Updates, Deletes, and Appends#697

Open
lmars wants to merge 4 commits intomainfrom
message-updates
Open

feat: Implement Message Updates, Deletes, and Appends#697
lmars wants to merge 4 commits intomainfrom
message-updates

Conversation

@lmars
Copy link
Copy Markdown
Member

@lmars lmars commented Feb 15, 2026

This implements message updates, deletes, and appends as proposed in #696.

Summary by CodeRabbit

  • New Features

    • Full message lifecycle: publish (sync/async) with per-message results/serials, update, delete, append; fetch a message by serial and view its version history
    • Default protocol bumped to v5 for message operations; stats remain on v2
    • Test sandbox supports mutable messages
  • Tests

    • New integration/unit tests and end-to-end examples covering publish/update/append/delete/versioning and async flows

@lmars lmars requested a review from sacOO7 February 15, 2026 16:28
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Feb 15, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds message versioning and actions (create/update/delete/append), publish-with-result APIs for REST and Realtime, serial-aware ACK delivery via an ackCallback wrapper, default protocol bumped to v5, per-message retrieval and version history, supporting decoders, and tests/examples for message lifecycle flows.

Changes

Cohort / File(s) Summary
Core message types & tests
ably/proto_message.go, ably/proto_message_operations_test.go
Add MessageAction, MessageVersion, PublishResult, UpdateDeleteResult, update option helpers, and JSON/MessagePack encoding/decoding; unit tests for encoding, Message/Version marshaling, and serial validation.
Protocol & wire changes
ably/proto_http.go, ably/proto_protocol_message.go, ably/paginated_result.go
Default protocol version constant changed to v5; add protocolPublishResult type and Res field on protocol message; paginatedRequest gains optional header http.Header.
REST channel & client
ably/rest_channel.go, ably/rest_client.go
Add PublishWithResult/PublishMultipleWithResult, Update/Delete/Append, GetMessage, GetMessageVersions, fullMessageDecoder, validateMessageSerial, new getWithHeader and patch helpers, and conditional protocol header handling for Stats.
Realtime channel & connection
ably/realtime_channel.go, ably/realtime_conn.go
Add context-aware and async PublishWithResult/PublishMultipleWithResult and Update/Delete/Append APIs; introduce serial-aware ACK path (sendWithSerialCallback); change Connection.send signature to accept *ackCallback.
Callback mechanism & state
ably/state.go, ably/realtime_presence.go, ably/export_test.go
Introduce ackCallback wrapper (supports error-only and serial-aware callbacks); update pendingEmitter, msgQueue, enqueue/flush/fail/ack flows to use *ackCallback and ackCallback.call(...).
Experimental objects & mocks
ably/realtime_experimental_objects.go, ably/realtime_experimental_objects_test.go
Update channel send signature and channel mock to accept *ackCallback; tests adapted to use callback.call(...).
Integration, examples & sandbox
ably/message_updates_integration_test.go, ably/example_message_updates_test.go, ablytest/sandbox.go, ably/state_test.go
Add extensive integration tests and examples exercising publish, update, append, delete, and versions across REST and Realtime; add pendingEmitter ACK mapping unit tests; enable MutableMessages in sandbox defaults.
Small export/test tweak
ably/export_test.go
Adjust AckAll dispatch to invoke callback via v.callback.call(nil, nil) instead of v.onAck(nil).

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant REST_Channel as "REST Channel"
    participant Server
    Client->>REST_Channel: PublishWithResult(name, data)
    REST_Channel->>Server: POST /messages (Action=CREATE)
    Server-->>REST_Channel: {serials: ["s1"]}
    REST_Channel-->>Client: PublishResult{Serial:"s1"}

    Client->>REST_Channel: UpdateMessage(msg{Serial:"s1"}, opts)
    REST_Channel->>REST_Channel: validate serial, build Version
    REST_Channel->>Server: PATCH /messages/s1 (Action=UPDATE, Version)
    Server-->>REST_Channel: {versionSerial: "v2"}
    REST_Channel-->>Client: UpdateDeleteResult{VersionSerial:"v2"}
Loading
sequenceDiagram
    participant Client
    participant Realtime_Channel as "Realtime Channel"
    participant Connection
    participant Server
    Client->>Realtime_Channel: PublishWithResultAsync(name, data, cb)
    Realtime_Channel->>Realtime_Channel: create ackCallback wrapper
    Realtime_Channel->>Connection: send(msg, callback)
    Connection->>Server: send PUBLISH
    Server-->>Connection: ACK {serials: ["s1"]}
    Connection->>Realtime_Channel: callback.call([{serials:["s1"]}], nil)
    Realtime_Channel-->>Client: cb(PublishResult{Serial:"s1"}, nil)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Poem

🐇 I hopped through bytes and wires tonight,

serials glimmered, shining bright,
Update, Append, a versioned spree,
REST and Realtime sing to me,
A little rabbit's protocol delight.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 38.46% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: Implement Message Updates, Deletes, and Appends' accurately and concisely summarizes the main feature work: adding message update, delete, and append functionality across REST and realtime channels.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch message-updates

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions github-actions bot temporarily deployed to staging/pull/697/godoc February 15, 2026 16:29 Inactive
@github-actions github-actions bot temporarily deployed to staging/pull/697/features February 15, 2026 16:29 Inactive
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
ably/paginated_result.go (1)

189-194: ⚠️ Potential issue | 🟠 Major

Bug: copyHeader assigns the original slice instead of the copy.

Line 192 creates a copy d of the header values slice, but line 193 assigns the original v to dest[k], making the copy dead code. The destination header will share the same backing array as the source.

🐛 Proposed fix
 func copyHeader(dest, src http.Header) {
 	for k, v := range src {
 		d := make([]string, len(v))
 		copy(d, v)
-		dest[k] = v
+		dest[k] = d
 	}
 }
ably/rest_channel.go (1)

112-119: ⚠️ Potential issue | 🟡 Minor

publishMultiple mutates caller's message slice elements in place.

The *m, err = (*m).withEncodedData(cipher) dereferences and overwrites the pointed-to Message value. Since messages is []*Message, this modifies the caller's original messages (e.g., overwriting Data with encoded bytes and setting Encoding). This could surprise callers who reuse message objects. The same pattern exists in the UpdateMessage/DeleteMessage/AppendMessage methods, but those copy first (updateMsg := *msg), which is safer.

🤖 Fix all issues with AI agents
In `@ably/message_updates_integration_test.go`:
- Around line 315-329: The async callback passed to channel.AppendMessageAsync
currently calls t.Logf from a goroutine (in the loop over tokens) which can
panic if the test ends; change the callback to never call testing.T methods and
instead send results over the existing completed channel (e.g., send an index or
an error value/struct) so the main test goroutine can observe failures; update
the completion loop that reads from completed to detect and fail the test via
require/ t.Fatalf when an error result is received, and remove the t.Logf call
inside the callback (refer to the loop variable tokens, the
channel.AppendMessageAsync callback, and the completed channel when making the
change).

In `@ably/state.go`:
- Line 138: The Ack method on pendingEmitter has an unused conn *Connection
parameter; remove the parameter from the function signature (func (q
*pendingEmitter) Ack(msg *protocolMessage, errInfo *ErrorInfo)) and update all
callers (e.g., the calls from realtime_conn.go that currently pass a Connection)
to stop passing conn. Ensure you update any interface definitions or
implementations that referenced pendingEmitter.Ack to match the new signature
and run build/tests to catch remaining references.
🧹 Nitpick comments (6)
ably/rest_client.go (1)

812-819: Header ordering: custom headers copied before Accept is set.

Custom headers from r.header are copied at line 813 before Accept is unconditionally set at line 815. If a caller ever passes a custom Accept header, it would be silently overwritten. Currently this isn't an issue (only protocol version is overridden), but the conditional guard applied to ablyProtocolVersionHeader (line 817) is not applied to Accept.

Consider applying the same "set only if absent" pattern to Accept for consistency, or document that certain headers cannot be overridden.

ably/proto_message.go (1)

34-106: DRY: action↔numeric mapping is duplicated four times.

The same switch logic for converting between MessageAction and its numeric wire representation is repeated in MarshalJSON, UnmarshalJSON, CodecEncodeSelf, and CodecDecodeSelf. Consider extracting helpers like messageActionToNum and numToMessageAction to centralize the mapping.

Also, the default branch silently maps unknown actions to MessageActionCreate (0) during both marshaling and unmarshaling. This could mask bugs if an invalid action is accidentally used. Consider returning an error for unknown values instead.

♻️ Proposed helper extraction
+var messageActionToNum = map[MessageAction]int{
+	MessageActionCreate: 0,
+	MessageActionUpdate: 1,
+	MessageActionDelete: 2,
+	MessageActionAppend: 5,
+}
+
+var numToMessageAction = map[int]MessageAction{
+	0: MessageActionCreate,
+	1: MessageActionUpdate,
+	2: MessageActionDelete,
+	5: MessageActionAppend,
+}
+
 func (a MessageAction) MarshalJSON() ([]byte, error) {
-	var num int
-	switch a {
-	case MessageActionCreate:
-		num = 0
-	case MessageActionUpdate:
-		num = 1
-	case MessageActionDelete:
-		num = 2
-	case MessageActionAppend:
-		num = 5
-	default:
-		num = 0
+	num, ok := messageActionToNum[a]
+	if !ok {
+		return nil, fmt.Errorf("unknown MessageAction: %q", a)
 	}
 	return json.Marshal(num)
 }

Apply the same pattern to UnmarshalJSON, CodecEncodeSelf, and CodecDecodeSelf.

ably/realtime_channel.go (1)

852-1046: Significant DRY opportunity: UpdateMessageAsync, DeleteMessageAsync, and AppendMessageAsync are nearly identical.

The three async methods differ only in the MessageAction constant assigned. The blocking variants follow the same boilerplate pattern too. Consider extracting a shared helper to reduce ~180 lines of near-duplicate code.

♻️ Sketch of a shared helper
+func (c *RealtimeChannel) messageOperationAsync(msg *Message, action MessageAction, onAck func(*UpdateResult, error), options ...UpdateOption) error {
+	if err := validateMessageSerial(msg); err != nil {
+		return err
+	}
+	var opts updateOptions
+	for _, o := range options {
+		o(&opts)
+	}
+	version := &MessageVersion{
+		Description: opts.description,
+		ClientID:    opts.clientID,
+		Metadata:    opts.metadata,
+	}
+	opMsg := *msg
+	opMsg.Action = action
+	opMsg.Version = version
+	protoMsg := &protocolMessage{
+		Action:   actionMessage,
+		Channel:  c.Name,
+		Messages: []*Message{&opMsg},
+	}
+	return c.sendWithSerialCallback(protoMsg, func(serials []string, err error) {
+		if err != nil {
+			onAck(nil, err)
+			return
+		}
+		result := &UpdateResult{}
+		if len(serials) > 0 {
+			result.VersionSerial = serials[0]
+		}
+		onAck(result, nil)
+	})
+}
+
+func (c *RealtimeChannel) messageOperation(ctx context.Context, msg *Message, action MessageAction, options ...UpdateOption) (*UpdateResult, error) {
+	type resultOrError struct {
+		result *UpdateResult
+		err    error
+	}
+	listen := make(chan resultOrError, 1)
+	if err := c.messageOperationAsync(msg, action, func(result *UpdateResult, err error) {
+		listen <- resultOrError{result, err}
+	}, options...); err != nil {
+		return nil, err
+	}
+	select {
+	case <-ctx.Done():
+		return nil, ctx.Err()
+	case result := <-listen:
+		return result.result, result.err
+	}
+}

Then each public method becomes a one-liner:

func (c *RealtimeChannel) UpdateMessage(ctx context.Context, msg *Message, options ...UpdateOption) (*UpdateResult, error) {
	return c.messageOperation(ctx, msg, MessageActionUpdate, options...)
}
ably/message_updates_integration_test.go (1)

205-209: Assertion on version ordering may be fragile.

The test asserts that versions[0] is MessageActionCreate and versions[1]/versions[2] are MessageActionUpdate. This depends on the server returning versions in a specific order. If the API's default ordering changes, this test will break. Consider documenting or parameterizing the expected direction.

ably/rest_channel.go (2)

208-209: Remove debug log from production code.

This Debugf line logs response details including serials. While it's at debug level, it appears to be a development artifact (// Debug: log response). Consider removing it or ensuring it's intentional.

Proposed fix
-	// Debug: log response
-	c.log().Debugf("PublishMultipleWithResult response: serials=%v, count=%d", response.Serials, len(response.Serials))
-
 	// Build results from serials

221-363: Significant code duplication across UpdateMessage, DeleteMessage, and AppendMessage.

These three methods are nearly identical, differing only in the Action field (MessageActionUpdate, MessageActionDelete, MessageActionAppend). Consider extracting a shared helper.

Proposed refactor
+func (c *RESTChannel) performMessageAction(ctx context.Context, action MessageAction, msg *Message, options ...UpdateOption) (*UpdateResult, error) {
+	if err := validateMessageSerial(msg); err != nil {
+		return nil, err
+	}
+
+	var opts updateOptions
+	for _, o := range options {
+		o(&opts)
+	}
+
+	version := &MessageVersion{
+		Description: opts.description,
+		ClientID:    opts.clientID,
+		Metadata:    opts.metadata,
+	}
+
+	actionMsg := *msg
+	actionMsg.Action = action
+	actionMsg.Version = version
+
+	cipher, _ := c.options.GetCipher()
+	var err error
+	actionMsg, err = actionMsg.withEncodedData(cipher)
+	if err != nil {
+		return nil, fmt.Errorf("encoding data for message: %w", err)
+	}
+
+	var response publishResponse
+	res, err := c.client.post(ctx, c.baseURL+"/messages", []*Message{&actionMsg}, &response)
+	if err != nil {
+		return nil, err
+	}
+	defer res.Body.Close()
+
+	result := &UpdateResult{}
+	if len(response.Serials) > 0 {
+		result.VersionSerial = response.Serials[0]
+	}
+	return result, nil
+}
+
 func (c *RESTChannel) UpdateMessage(ctx context.Context, msg *Message, options ...UpdateOption) (*UpdateResult, error) {
-	// ... ~45 lines of duplicated code
+	return c.performMessageAction(ctx, MessageActionUpdate, msg, options...)
 }
 
 func (c *RESTChannel) DeleteMessage(ctx context.Context, msg *Message, options ...UpdateOption) (*UpdateResult, error) {
-	// ... ~45 lines of duplicated code
+	return c.performMessageAction(ctx, MessageActionDelete, msg, options...)
 }
 
 func (c *RESTChannel) AppendMessage(ctx context.Context, msg *Message, options ...UpdateOption) (*UpdateResult, error) {
-	// ... ~45 lines of duplicated code
+	return c.performMessageAction(ctx, MessageActionAppend, msg, options...)
 }

@github-actions github-actions bot temporarily deployed to staging/pull/697/features February 15, 2026 16:48 Inactive
@github-actions github-actions bot temporarily deployed to staging/pull/697/godoc February 15, 2026 16:48 Inactive
@paddybyers
Copy link
Copy Markdown
Member

It would have been nice as an experiment to derive the tests from https://github.com/ably/uts/blob/uts-experiments/test/completion-status.md

@github-actions github-actions bot temporarily deployed to staging/pull/697/features February 15, 2026 19:53 Inactive
@github-actions github-actions bot temporarily deployed to staging/pull/697/godoc February 15, 2026 19:53 Inactive
@lmars
Copy link
Copy Markdown
Member Author

lmars commented Feb 15, 2026

@paddybyers yes apologies I did start with that but got a bit bogged down so I thought I'd just get this implemented (because I want it for load testing), and then follow up with you on the UTS stuff (we can try it out for annotations/summaries, which I haven't added here).

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (3)
ably/state_test.go (3)

20-161: Tests cover the happy path well. Consider adding edge-case coverage.

The three sub-tests correctly verify per-message serial routing for the straightforward ACK case. The test logic and assertions are sound.

A few additional scenarios would strengthen confidence in the Ack implementation:

  1. Implicit NACK (serialShift > 0): Enqueue messages starting at serial N, send an ACK starting at serial N+1 — verify the first message gets errImplictNACK and a nil result, while the rest get their correct serials.
  2. NACK with errInfo: Call Ack with a non-nil errInfo and verify the error propagates to all callbacks.
  3. Nil callback safety: Enqueue a message with a nil *ackCallback and verify Ack doesn't panic.
  4. onAck (non-serial) path: Enqueue with an ackCallback that only sets onAck, verify it receives the error correctly.

21-23: Optional: Extract repeated logger setup into a test helper.

testLogger := logger{l: &stdLogger{log.New(io.Discard, "", 0)}} is duplicated in all three sub-tests. A small helper or a setup at the top of TestPendingEmitter_AckResult would reduce repetition.

♻️ Suggested refactor
 func TestPendingEmitter_AckResult(t *testing.T) {
+	testLogger := logger{l: &stdLogger{log.New(io.Discard, "", 0)}}
+
 	t.Run("two messages with single serial each", func(t *testing.T) {
-		testLogger := logger{l: &stdLogger{log.New(io.Discard, "", 0)}}
 		emitter := newPendingEmitter(testLogger)

34-48: Optional: Assert that err is nil in callbacks for ACK (non-NACK) scenarios.

The callbacks capture serials but silently discard err. Adding assert.NoError(t, err) inside each callback would catch unexpected error propagation.

♻️ Example for callback1
 		callback1 := &ackCallback{
-			onAckWithSerials: func(serials []string, err error) {
+			onAckWithSerials: func(serials []string, err error) {
+				assert.NoError(t, err)
 				msg1Serials = serials
 			},
 		}

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR implements message updates, deletes, and appends functionality for the ably-go SDK, enabling full message lifecycle management including publishing with result tracking, updating, deleting, and appending messages. The implementation follows the design specified in issue #696 and updates the protocol version from 2 to 5 to support message serials and version information.

Changes:

  • Adds message operations API (PublishWithResult, UpdateMessage, DeleteMessage, AppendMessage) for both REST and Realtime channels with sync and async variants
  • Introduces new message-related types: MessageAction enum, MessageVersion, PublishResult, UpdateResult, and UpdateOption functional options
  • Updates protocol version to v5 by default, with special handling to keep Stats on v2 for backward compatibility

Reviewed changes

Copilot reviewed 18 out of 18 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
ablytest/sandbox.go Adds MutableMessages flag to namespace configuration for testing message operations
ably/state_test.go Adds comprehensive unit tests for ACK result distribution across multiple messages
ably/state.go Refactors callback handling to support serial extraction via new ackCallback struct
ably/rest_client.go Implements protocol version override for Stats requests to maintain v2 compatibility
ably/rest_channel.go Adds REST message operation methods (PublishWithResult, UpdateMessage, DeleteMessage, AppendMessage, GetMessage, GetMessageVersions)
ably/realtime_presence.go Updates presence code to use new ackCallback pattern
ably/realtime_experimental_objects_test.go Updates experimental objects tests for new callback signature
ably/realtime_experimental_objects.go Updates experimental objects to use new ackCallback pattern
ably/realtime_conn.go Updates connection send() to use ackCallback with nil safety checks
ably/realtime_channel.go Adds Realtime message operation methods with both blocking and async variants
ably/proto_protocol_message.go Adds Res field to protocolMessage for ACK serial responses
ably/proto_message_operations_test.go Adds unit tests for MessageAction encoding/decoding and validation
ably/proto_message.go Adds MessageAction, MessageVersion, PublishResult, UpdateResult types and Message struct fields
ably/proto_http.go Updates default protocol version to v5 with documentation
ably/paginated_result.go Adds header field to paginatedRequest for custom HTTP headers
ably/message_updates_integration_test.go Adds comprehensive integration tests for message operations
ably/export_test.go Updates test helper to use new callback pattern
ably/example_message_updates_test.go Adds usage examples for new message operation APIs

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@sacOO7
Copy link
Copy Markdown
Collaborator

sacOO7 commented Feb 27, 2026

I reviewed current ably-go PR with respect to

  1. Specification: PR #406 — Protocol v5 spec for publish result, realtime update/delete, append
  2. Reference Implementation: ably-java PRs #1179, #1182, #1183
  3. Design Document: Issue #696

Key Spec Points: RSL15 (REST update/delete/append), RTL32 (Realtime update/delete/append), TM5 (MessageAction enum), TM2s (version initialization), PBR2a (PublishResult), UDR2a (UpdateDeleteResult), MOP2 (MessageOperation), TR4s (ACK res field)

1. Critical Issues (Missing or Incorrect Spec Implementation)

1.1 ❌ REST Update/Delete/Append Uses POST Instead of PATCH (RSL15b)

Spec Requirement (RSL15b):

The SDK must send a PATCH to /channels/{channelName}/messages/{serial}. The request body is a Message object (encoded per RSL4) with whatever fields were in the user-supplied Message.

ably-go Implementation (rest_channel.go):

// UpdateMessage uses POST instead of PATCH, wrong URL, and sends array instead of single Message
res, err := c.client.post(ctx, c.baseURL+"/messages", []*Message{&updateMsg}, &response)

ably-java Implementation (MessageEditsMixin.java):

scheduler.patch(basePath + "/messages/" + HttpUtils.encodeURIComponent(message.serial), ...)

Issues:

  • ably-go uses POST to /channels/{name}/messages instead of PATCH to /channels/{name}/messages/{serial}
  • The REST client (rest_client.go) has no patch() method — only post() and get() exist
  • The URL path is wrong: should be /messages/{serial} not just /messages
  • The request body should be a single Message object, not an array []*Message

Fix Required:

  1. Add a patch() method to the REST client
  2. Change all three REST methods (UpdateMessage, DeleteMessage, AppendMessage) to use PATCH with the correct URL pattern /channels/{name}/messages/{serial}
  3. Send a single Message object as the body, not an array

1.2 ❌ REST Response Parsing Uses Wrong Type (RSL15e)

Spec Requirement (RSL15e):

On success, returns an UpdateDeleteResult object. The API response body will contain a superset of the fields of an UpdateDeleteResult.

ably-go Implementation (rest_channel.go):

var response publishResponse  // Uses publishResponse (serials array) instead of UpdateDeleteResult

ably-java Implementation (MessageEditsMixin.java):

HttpCore.BodyHandler<UpdateDeleteResult> bodyHandler = UpdateDeleteResult.getBodyHandler();

Issue: The ably-go REST update/delete/append methods parse the response as a publishResponse (which has a serials array) and then extract VersionSerial from response.Serials[0]. The server returns an UpdateDeleteResult with a versionSerial field for PATCH requests. The current code would only work if the server returns the same format for POST as for PATCH, which is not guaranteed per the spec.

Fix Required: Parse the response as an UpdateDeleteResult struct with a versionSerial field, matching the spec and ably-java.

1.3 ⚠️ PublishResult Struct Doesn't Match Spec Wire Format (PBR2a)

Spec Requirement (PBR2a):

serials array of String? — an array of message serials corresponding 1:1 to the messages that were published. A serial may be null if the message was discarded due to a configured conflation rule.

ably-go Implementation (proto_message.go):

type PublishResult struct {
    Serial string // Single serial, not an array
}

ably-java Implementation (PublishResult.java):

public final @Nullable String[] serials;

Issue: The spec defines PublishResult as containing a serials array of nullable strings. The ably-go implementation uses a single Serial string field. The design document (issue #696) intentionally chose this for ergonomics (returning one PublishResult per message), which is a valid alternative per RSL1n1/RTL6j1 — these spec points explicitly allow SDKs where adding a response value would be a breaking API change to implement alternatives.

Recommendation: The current approach of returning []PublishResult (one per message, each with a single Serial) is a reasonable Go-idiomatic alternative. Consider adding a comment referencing RSL1n1/RTL6j1 to document this intentional deviation. Also consider using *string for nullable semantics (see Enhancement 4.2).

1.4 ❌ MessageAction Uses String Type Instead of Integer Enum (TM5)

Spec Requirement (TM5):

Message Action enum has the following values in order from zero: MESSAGE_CREATE, MESSAGE_UPDATE, MESSAGE_DELETE, META, MESSAGE_SUMMARY, MESSAGE_APPEND

ably-go Implementation (proto_message.go):

type MessageAction string

const (
    MessageActionCreate MessageAction = "message.create"
    MessageActionUpdate MessageAction = "message.update"
    MessageActionDelete MessageAction = "message.delete"
    MessageActionAppend MessageAction = "message.append"
)

ably-java Implementation (MessageAction.java):

public enum MessageAction {
    MESSAGE_CREATE, // 0
    MESSAGE_UPDATE, // 1
    MESSAGE_DELETE, // 2
    META, // 3
    MESSAGE_SUMMARY, // 4
    MESSAGE_APPEND; // 5
}

Issues:

  1. Missing META (3) and MESSAGE_SUMMARY (4) enum values — the spec defines 6 values, ably-go only has 4
  2. The Go type uses string as the underlying type with dotted names ("message.create") instead of integer-based enum values. While custom JSON/MsgPack marshaling (MarshalJSON, UnmarshalJSON, CodecEncodeSelf, CodecDecodeSelf) correctly converts to/from integers on the wire, the string representation doesn't match the spec's naming convention (MESSAGE_CREATE vs "message.create")
  3. Unknown values in UnmarshalJSON and CodecDecodeSelf default to MessageActionCreate instead of preserving the original value or returning an error — this means receiving a META (3) or MESSAGE_SUMMARY (4) message would incorrectly be treated as MESSAGE_CREATE

Fix Required: Add META and MESSAGE_SUMMARY action values. Consider using integer-based enum (like iota) for the underlying type to better match the spec.

1.5 ⚠️ MessageOperation Not Implemented as Separate Type (MOP2)

Spec Requirement (MOP2):

class MessageOperation: // MOP*
  clientId?: String // MOP2a
  description?: String // MOP2b
  metadata?: Dict<String, String> // MOP2c

ably-go Implementation: Uses functional options pattern (proto_message.go):

type UpdateOption func(*updateOptions)
type updateOptions struct {
    description string
    clientID    string
    metadata    map[string]string
}

ably-java Implementation (MessageOperation.java):

public class MessageOperation {
    public String clientId;
    public String description;
    public Map<String, String> metadata;
}

Issue: The spec defines MessageOperation as a public class/struct that users pass directly. The ably-go implementation uses functional options (UpdateOption) instead. While functional options are idiomatic Go, the spec explicitly defines MessageOperation as a second argument type. The ably-java implementation follows the spec directly.

Recommendation: Consider exposing a MessageOperation struct publicly alongside the functional options for spec compliance. The functional options can still be used as a convenience wrapper. This is a design choice rather than a correctness issue.

1.6 ❌ REST Update/Delete/Append Missing params Support (RSL15a, RSL15f)

Spec Requirement (RSL15a):

Take a first argument of a Message object, an optional second argument of a MessageOperation object, and an optional third argument of Dict<string, stringifiable> publish params

Spec Requirement (RSL15f):

Any params provided in the third argument must be sent in the querystring, with values stringified

ably-go Implementation (rest_channel.go):

func (c *RESTChannel) UpdateMessage(ctx context.Context, msg *Message, options ...UpdateOption) (*UpdateResult, error) {
    // No params argument at all
}

ably-java Implementation (MessageEditsMixin.java):

final Param[] params = clientOptions.addRequestIds ? Param.array(Crypto.generateRandomRequestId()) : null;

Issue: The REST methods for update/delete/append don't accept a params argument as specified by RSL15a and RSL15f. The spec requires an optional third argument of Dict<string, stringifiable> publish params that should be sent in the querystring.

Fix Required: Add params support to UpdateMessage(), DeleteMessage(), and AppendMessage().

1.7 ⚠️ Realtime Update/Delete/Append Missing params Support (RTL32a, RTL32e)

Spec Requirement (RTL32a):

Take a first argument of a Message object (which must contain a populated serial field), an optional second argument of a MessageOperation object, and an optional third argument of Dict<string, stringifiable> publish params

Spec Requirement (RTL32e):

Any params provided in the third argument must be sent in the TR4q ProtocolMessage.params field, as a Dict<String, String>

ably-go Implementation (realtime_channel.go):

func (c *RealtimeChannel) UpdateMessageAsync(msg *Message, onAck func(*UpdateResult, error), options ...UpdateOption) error {
    // No params argument, no ProtocolMessage.Params population
}

Issue: The realtime methods for update/delete/append don't accept a params argument. Per RTL32a, the spec requires an optional third argument of Dict<string, stringifiable> publish params. Per RTL32e, these params should be sent in the ProtocolMessage.params field.

Note: The API docstring in the spec says "Optional parameters (ignored for realtime)" for the params field, but the normative spec text at RTL32a and RTL32e explicitly requires params support. The ably-java PR #1183 also does NOT implement params for realtime update/delete/append in its updateDeleteImpl method. This suggests params for realtime may be lower priority.

Fix Required: Add params support to realtime update/delete/append methods and populate protocolMessage.Params, or document the intentional omission if aligning with ably-java.

1.8 ❌ UpdateDeleteResult Struct Name Mismatch (UDR1)

Spec Requirement (UDR1):

Contains the result of an update or delete message operation

Spec Requirement (UDR2a):

versionSerial String? — the new version serial string of the updated or deleted message.

ably-go Implementation (proto_message.go):

type UpdateResult struct {
    VersionSerial string
}

Issue: The struct is named UpdateResult instead of UpdateDeleteResult as specified by UDR1. This naming mismatch affects API discoverability and spec compliance.

Fix Required: Rename UpdateResult to UpdateDeleteResult to match the spec. This also requires updating all references in rest_channel.go, realtime_channel.go, and test files.

1.9 ❌ Missing version Object Initialization on Received Messages (TM2s, TM2s1, TM2s2)

Spec Requirement (TM2s):

If a message received from Ably (whether over realtime or REST) does not contain a version object the SDK must initialize a version object and set a subset of fields as specified below.

  • TM2s1: If version.serial is not received over the wire, it must be set to the serial (TM2r), if it is set.
  • TM2s2: If version.timestamp is not received over the wire, it must be set to the timestamp (TM2f), if it is set.

ably-go Implementation (proto_message.go):

func (p *protocolMessage) updateInnerMessageEmptyFields(m *Message, index int) {
    if empty(m.ID) {
        m.ID = fmt.Sprintf("%s:%d", p.ID, index)
    }
    if empty(m.ConnectionID) {
        m.ConnectionID = p.ConnectionID
    }
    if m.Timestamp == 0 {
        m.Timestamp = p.Timestamp
    }
    // No version initialization!
}

Issue: The updateInnerMessageEmptyFields() method does not initialize the version object when it's missing from received messages. Per TM2s, the SDK must:

  1. Create a version object if none exists
  2. Set version.serial to message.serial if version.serial is empty (TM2s1)
  3. Set version.timestamp to message.timestamp if version.timestamp is empty (TM2s2)

This is directly relevant to message update/delete/append because consumers rely on version.serial to identify message versions.

Fix Required: Add version initialization logic to updateInnerMessageEmptyFields():

// TM2s: Initialize version object if not present
if m.Version == nil {
    m.Version = &MessageVersion{}
}
// TM2s1: Default version.serial from message.serial
if empty(m.Version.Serial) && !empty(m.Serial) {
    m.Version.Serial = m.Serial
}
// TM2s2: Default version.timestamp from message.timestamp
if m.Version.Timestamp == 0 && m.Timestamp != 0 {
    m.Version.Timestamp = m.Timestamp
}

Full review document ->
ably_go_message_feature_review.md

@github-actions github-actions bot temporarily deployed to staging/pull/697/features March 1, 2026 05:49 Inactive
@github-actions github-actions bot temporarily deployed to staging/pull/697/godoc March 1, 2026 05:49 Inactive
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (3)
ably/rest_channel.go (2)

208-210: Remove debug artifact logging raw serial arrays.

This debug line looks temporary and can add noisy logs with internal identifiers.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ably/rest_channel.go` around lines 208 - 210, Remove the temporary debug log
that prints raw serial arrays in the PublishMultipleWithResult flow: delete the
c.log().Debugf("PublishMultipleWithResult response: serials=%v, count=%d",
response.Serials, len(response.Serials)) line in rest_channel.go (inside the
PublishMultipleWithResult handling) or replace it with a non-sensitive summary
(e.g., logging only the count or a masked identifier) to avoid leaking internal
identifiers.

68-70: Preserve nullable serial semantics in publish responses.

[]string cannot represent null serial entries, so you lose the distinction between “discarded/superseded” and empty string. Consider using nullable elements in the wire struct (and document the public ergonomic mapping if you keep string outwardly).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ably/rest_channel.go` around lines 68 - 70, The publishResponse struct
currently declares Serials as []string which cannot represent null elements;
change Serials to a nullable-element slice (e.g., []*string) in the wire struct
publishResponse to preserve null vs empty-string semantics when
marshaling/unmarshaling, and update any serializers/deserializers and tests that
touch publishResponse to handle pointers; if the public API must remain
[]string, add a clear conversion layer (documented) that maps nil pointers to a
sentinel or explicitly documents the loss of null information.
ably/realtime_channel.go (1)

788-794: Extract repeated clientId compatibility validation into a helper.

This same check already exists in PublishMultipleAsync; consolidating it avoids drift between result/non-result publish paths.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ably/realtime_channel.go` around lines 788 - 794, Extract the repeated
clientId compatibility check into a shared helper (e.g.,
validateClientIDCompatibility) that accepts the slice of messages and the
Auth/client used to obtain clientIDForCheck; move the loop and the existing
error construction into that helper (use client.Auth.clientIDForCheck() and
preserve the exact error text), then replace the inlined loop in the current
function and in PublishMultipleAsync with a call to
validateClientIDCompatibility and return its error if non-nil.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@ably/proto_message_operations_test.go`:
- Around line 43-44: The test currently asserts that an unknown action code
("999") maps to MessageActionCreate which codifies a fallback and hides
unsupported actions; update the assertions that reference "999" and
MessageActionCreate (and the duplicate occurrences later) so they no longer
expect Create — instead assert that parsing/validation returns an explicit error
or an Unknown/invalid action result (e.g., an error value or a dedicated
MessageActionUnknown) so unknown/new action codes are surfaced rather than
silently mapped to MessageActionCreate; update any helper parsing/assertion
logic used by the test to check for the error/unknown outcome.

In `@ably/realtime_channel.go`:
- Around line 859-881: The protocolMessage being built for realtime
update/delete/append never sets Params so operation-level params are dropped;
add a Params field to updateOptions (e.g., updateOptions.params) if it doesn't
exist, populate it via the provided option functions, and then set
protoMsg.Params = opts.params when constructing protocolMessage (the code that
creates version := &MessageVersion{...}, opMsg := *msg, and protoMsg :=
&protocolMessage{...}). Ensure option setters wire into updateOptions.params so
operation params flow into ProtocolMessage.params.

In `@ably/rest_channel.go`:
- Around line 254-267: The code is using the publish contract (posting an array
to "/messages" and reading publishResponse.Serials) instead of the
operation-specific request/response for update/delete/append; change the call in
the method that builds opMsg so it posts to the operation-specific path and body
(use the operation request contract expected by the server rather than
c.client.post(..., c.baseURL+"/messages", []*Message{&opMsg}, ...)), swap the
response type from publishResponse to the operation result schema (e.g.,
UpdateResponse/OperationResult type) and decode that response, then set
result.VersionSerial from the operation response's version field (e.g.,
response.VersionSerial or response.Version) instead of reading
publishResponse.Serials; keep using c.client.post and res.Body.Close but ensure
URL, request body shape, and response decoding match the operation contract.

---

Nitpick comments:
In `@ably/realtime_channel.go`:
- Around line 788-794: Extract the repeated clientId compatibility check into a
shared helper (e.g., validateClientIDCompatibility) that accepts the slice of
messages and the Auth/client used to obtain clientIDForCheck; move the loop and
the existing error construction into that helper (use
client.Auth.clientIDForCheck() and preserve the exact error text), then replace
the inlined loop in the current function and in PublishMultipleAsync with a call
to validateClientIDCompatibility and return its error if non-nil.

In `@ably/rest_channel.go`:
- Around line 208-210: Remove the temporary debug log that prints raw serial
arrays in the PublishMultipleWithResult flow: delete the
c.log().Debugf("PublishMultipleWithResult response: serials=%v, count=%d",
response.Serials, len(response.Serials)) line in rest_channel.go (inside the
PublishMultipleWithResult handling) or replace it with a non-sensitive summary
(e.g., logging only the count or a masked identifier) to avoid leaking internal
identifiers.
- Around line 68-70: The publishResponse struct currently declares Serials as
[]string which cannot represent null elements; change Serials to a
nullable-element slice (e.g., []*string) in the wire struct publishResponse to
preserve null vs empty-string semantics when marshaling/unmarshaling, and update
any serializers/deserializers and tests that touch publishResponse to handle
pointers; if the public API must remain []string, add a clear conversion layer
(documented) that maps nil pointers to a sentinel or explicitly documents the
loss of null information.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Disabled knowledge base sources:

  • Jira integration is disabled

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 9bff8d9 and 6040888.

📒 Files selected for processing (4)
  • ably/proto_message_operations_test.go
  • ably/realtime_channel.go
  • ably/realtime_conn.go
  • ably/rest_channel.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • ably/realtime_conn.go

@lmars
Copy link
Copy Markdown
Member Author

lmars commented Mar 1, 2026

@sacOO7 thanks for taking the time to review this with the agents, I see you've pushed some changes but let's please discuss this first because quite a few of the issues the agents have identified here aren't accurate.

For example the agents are encouraging the use of PATCH /channels/<channel>/messages/<serial> but in v5 we're migrating to use POST /channels/<channel>/messages with the action field set which aligns better with realtime updates/deletes/appends, and this is reflected in ably-js which this PR is based on (see ably/ably-js#2127).

I think this highlights two issues with how the agents have reviewed this PR:

  • ably-js is our reference implementation (i.e. it's where we ship changes first before other SDKs), not ably-java
  • the spec, and I assume ably-java too, is lagging behind ably-js

Let's discuss this as a team, because I think if we're using agents to check that SDK implementations are correct, I think we should be comparing them with the reference implementation (i.e. ably-js), rather than a spec + ably-java.

@github-actions github-actions bot temporarily deployed to staging/pull/697/features March 24, 2026 14:16 Inactive
@github-actions github-actions bot temporarily deployed to staging/pull/697/godoc March 24, 2026 14:16 Inactive
@lmars
Copy link
Copy Markdown
Member Author

lmars commented Mar 24, 2026

@sacOO7 I realise I was incorrect, the points raised are valid. I've updated the design doc in #696, and pushed b3275a9 to address the issues raised:

  • MessageAction: renamed constants to spec naming (MESSAGE_CREATE etc.), added missing MessageActionUnknown/MessageActionMeta/MessageActionMessageSummary, unknown wire values now decode to MessageActionUnknown instead of silently falling back to MessageActionCreate, and extracted shared encodeMessageAction/decodeMessageAction helpers to eliminate duplicated switch statements.
  • PublishResult.Serial and UpdateDeleteResult.VersionSerial are now *string for correct nullable semantics (a nil value means discarded by conflation / superseded). UpdateResult renamed to UpdateDeleteResult (UDR1).
  • updateOptions now holds a version *MessageVersion (built lazily by functional options) and a params map[string]string field. Added the missing UpdateWithParams option.
  • REST operations now use PATCH /channels/{name}/messages/{serial} with a single Message body and parse an UpdateDeleteResult response — previously they were incorrectly using POST /messages with an array (RSL15b/RSL15e). A patch() method was added to the REST client.
  • Realtime operations now propagate opts.params into protoMsg.Params (RTL32e).
  • TM2s: updateInnerMessageEmptyFields now initialises a version object on received messages that lack one, backfilling serial (TM2s1) and timestamp (TM2s2).
  • Removed the stray debug log from PublishMultipleWithResult.

PTAL.

@lmars lmars force-pushed the message-updates branch from 5ff7349 to b3275a9 Compare March 24, 2026 14:20
@github-actions github-actions bot temporarily deployed to staging/pull/697/godoc March 24, 2026 14:21 Inactive
@github-actions github-actions bot temporarily deployed to staging/pull/697/features March 24, 2026 14:21 Inactive
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@ably/message_updates_integration_test.go`:
- Around line 175-221: The test assumes GetMessageVersions returns versions
oldest-first but the API defaults to newest-first; update the call to
channel.GetMessageVersions(*publishResult.Serial,
ably.HistoryWithDirection(ably.Forwards)) so versions are returned
chronologically, or alternatively invert the final assertions to expect
newest-first order (use ably.MessageActionUpdate for versions[0] and
ably.MessageActionCreate for the last) in the test function that calls
GetMessageVersions and asserts MessageActionCreate/MessageActionUpdate.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: fac6f24e-5447-498c-abb9-df4c6b7c7f36

📥 Commits

Reviewing files that changed from the base of the PR and between 6040888 and b3275a9.

📒 Files selected for processing (7)
  • ably/example_message_updates_test.go
  • ably/message_updates_integration_test.go
  • ably/proto_message.go
  • ably/proto_message_operations_test.go
  • ably/realtime_channel.go
  • ably/rest_channel.go
  • ably/rest_client.go
✅ Files skipped from review due to trivial changes (1)
  • ably/example_message_updates_test.go
🚧 Files skipped from review as they are similar to previous changes (3)
  • ably/proto_message_operations_test.go
  • ably/proto_message.go
  • ably/rest_channel.go

Comment on lines +175 to +221
t.Run("GetMessageVersions", func(t *testing.T) {
channel := client.Channels.Get("mutable:test_get_message_versions")

t.Run("retrieves all versions after updates", func(t *testing.T) {
// Publish initial message
publishResult, err := channel.PublishWithResult(ctx, "test-event", "version 1")
require.NoError(t, err)
require.NotNil(t, publishResult.Serial)

// Update the message twice
msg := &ably.Message{
Serial: *publishResult.Serial,
Data: "version 2",
}
_, err = channel.UpdateMessage(ctx, msg, ably.UpdateWithDescription("First update"))
require.NoError(t, err)

msg.Data = "version 3"
_, err = channel.UpdateMessage(ctx, msg, ably.UpdateWithDescription("Second update"))
require.NoError(t, err)

// GetMessageVersions is eventually consistent - retry until all versions are available
var versions []*ably.Message
require.Eventually(t, func() bool {
page, err := channel.GetMessageVersions(*publishResult.Serial, nil).Pages(ctx)
if err != nil {
return false
}

// Must call Next() to decode the response body into items
if !page.Next(ctx) {
return false
}

versions = page.Items()

// Should have exactly 3 versions: original publish + 2 updates
return len(versions) == 3
}, 10*time.Second, 200*time.Millisecond, "All three message versions should be retrievable")

// Verify we have exactly 3 versions in the correct order
require.Equal(t, 3, len(versions))
assert.Equal(t, ably.MessageActionCreate, versions[0].Action, "First version should be MESSAGE_CREATE")
assert.Equal(t, ably.MessageActionUpdate, versions[1].Action, "Second version should be MESSAGE_UPDATE")
assert.Equal(t, ably.MessageActionUpdate, versions[2].Action, "Third version should be MESSAGE_UPDATE")
})
})
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if there's any documentation or spec reference about version ordering
rg -n -i 'GetMessageVersions|version.*order|chronological' --type=go -C2

Repository: ably/ably-go

Length of output: 2700


🏁 Script executed:

#!/bin/bash
# Check the HistoryRequest implementation and how it's used
rg -A10 'type HistoryRequest' --type=go

# Also look for any paginate or iteration logic that might indicate ordering
rg -n 'Messages.*serial|sort.*version' --type=go -i

Repository: ably/ably-go

Length of output: 2936


🏁 Script executed:

#!/bin/bash
# Check if there are other tests with message versions to understand the expected order
rg -B5 -A10 'MessageActionCreate|MessageActionUpdate' ably/message_updates_integration_test.go

Repository: ably/ably-go

Length of output: 805


🏁 Script executed:

#!/bin/bash
# Look for any API documentation or specification comments
find . -name "*.md" -o -name "*.txt" | xargs grep -l "GetMessageVersions\|message versions\|version.*order" 2>/dev/null | head -5

Repository: ably/ably-go

Length of output: 38


🏁 Script executed:

#!/bin/bash
# Look at how the endpoint is called and if there are any query parameters
rg -B5 -A15 'func.*GetMessageVersions' --type=go ably/rest_channel.go

# Check if there are any sort order parameters
rg -n 'direction|sort|order' --type=go -i | grep -i 'version\|history' | head -10

Repository: ably/ably-go

Length of output: 1554


🏁 Script executed:

#!/bin/bash
# Look for any API specification files or constants that define ordering
find . -type f \( -name "*.go" -o -name "*.md" -o -name "*.txt" -o -name "*.json" \) | xargs grep -l "message.*version.*order\|direction.*asc\|direction.*desc" 2>/dev/null | head -5

Repository: ably/ably-go

Length of output: 38


🏁 Script executed:

#!/bin/bash
# Check if MessagesPaginatedResult has any ordering logic
rg -B5 -A20 'type MessagesPaginatedResult' --type=go

Repository: ably/ably-go

Length of output: 1456


🏁 Script executed:

#!/bin/bash
# Look at the Direction type and constants
rg -B5 -A10 'type Direction|const.*Direction' --type=go | head -50

# Check the spec test to see what ordering is expected
rg -B10 -A20 'TestHistory_Direction_RSL2b2' --type=go ably/rest_channel_spec_integration_test.go

Repository: ably/ably-go

Length of output: 1381


🏁 Script executed:

#!/bin/bash
# Look for any documentation about default ordering in history/versions
rg -B3 -A3 'default.*direction|oldest|newest' --type=go -i | head -30

Repository: ably/ably-go

Length of output: 1607


The test relies on undocumented ordering assumptions that may be incorrect.

GetMessageVersions defaults to backwards direction (newest-first per RSL2b2), but the assertions at lines 217-219 assume chronological order (oldest-first). Either pass ably.HistoryWithDirection(ably.Forwards) when calling GetMessageVersions(), or reverse the assertion order to match the default backwards ordering:

assert.Equal(t, ably.MessageActionUpdate, versions[0].Action, "First version should be MESSAGE_UPDATE (newest)")
assert.Equal(t, ably.MessageActionUpdate, versions[1].Action, "Second version should be MESSAGE_UPDATE")
assert.Equal(t, ably.MessageActionCreate, versions[2].Action, "Third version should be MESSAGE_CREATE (oldest)")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ably/message_updates_integration_test.go` around lines 175 - 221, The test
assumes GetMessageVersions returns versions oldest-first but the API defaults to
newest-first; update the call to
channel.GetMessageVersions(*publishResult.Serial,
ably.HistoryWithDirection(ably.Forwards)) so versions are returned
chronologically, or alternatively invert the final assertions to expect
newest-first order (use ably.MessageActionUpdate for versions[0] and
ably.MessageActionCreate for the last) in the test function that calls
GetMessageVersions and asserts MessageActionCreate/MessageActionUpdate.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Whilst it is not documented, message versions are always returned in this order, there is no support for retrieving message versions in any other order.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@lmars, thanks for the clarification! I apologize for the incorrect assumption — I mistakenly conflated GetMessageVersions with the general history API's default backwards direction. The test's assertions are correct as-is.


✏️ Learnings added
Learnt from: lmars
Repo: ably/ably-go PR: 697
File: ably/message_updates_integration_test.go:175-221
Timestamp: 2026-03-24T14:38:14.657Z
Learning: In ably/ably-go, `GetMessageVersions` (in `ably/rest_channel.go` and `ably/realtime_channel.go`) always returns message versions in chronological order (oldest-first). Unlike the history API which defaults to newest-first (backwards), `GetMessageVersions` has a fixed ordering with no support for changing direction. Test assertions should therefore expect MESSAGE_CREATE first, followed by subsequent MESSAGE_UPDATE entries in order.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 18 out of 18 changed files in this pull request and generated 6 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

if err != nil {
panic(err)
}

Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same issue here: *updateResult.VersionSerial is dereferenced without checking for nil, even though VersionSerial is documented as optional. Add a nil check (and consider documenting why it may be nil) so the example remains safe to copy/paste.

Suggested change
// VersionSerial is optional and may be nil if the server did not return it.
if updateResult.VersionSerial == nil {
fmt.Println("Message updated, but version serial is not available")
return
}

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nil check added in 5e2fef5.

Action: actionMessage,
Channel: c.Name,
Messages: []*Message{&opMsg},
Params: opts.params,
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

opts.params is a map[string]string (see updateOptions), but protocolMessage.Params is of type channelParams (a distinct named type). The struct literal assignment Params: opts.params will not compile. Convert explicitly (e.g., Params: channelParams(opts.params)) or change updateOptions.params to channelParams and adapt UpdateWithParams accordingly.

Suggested change
Params: opts.params,
Params: channelParams(opts.params),

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

channelParams is a type alias for map[string]string so the two can be used interchangeably, and map[string]string makes it more obvious what these params are, so keeping as is.

Comment on lines +113 to +114
version *MessageVersion // unexported, built lazily from options
params map[string]string // URL query parameters (RSL15f) / ProtocolMessage params (RTL32e)
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updateOptions.params is declared as map[string]string, but protocolMessage.Params expects channelParams (a named map[string]string type). This causes type-mismatch at call sites (e.g., protocolMessage{Params: opts.params}) unless every use does an explicit conversion. Consider changing params to type channelParams (and convert in UpdateWithParams) to keep types consistent.

Suggested change
version *MessageVersion // unexported, built lazily from options
params map[string]string // URL query parameters (RSL15f) / ProtocolMessage params (RTL32e)
version *MessageVersion // unexported, built lazily from options
params channelParams // URL query parameters (RSL15f) / ProtocolMessage params (RTL32e)

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

channelParams is a type alias for map[string]string so the two can be used interchangeably, and map[string]string makes it more obvious what these params are, so keeping as is.

var err error
*t.dst, err = t.dst.withDecodedData(cipher)
if err != nil {
t.c.log().Errorf("Couldn't fully decode message data from channel %q: %w", t.c.Name, err)
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

%w is only valid with fmt.Errorf; in Errorf/Sprintf-style logging it will render as %!w(...) and lose the intended error formatting. Use %v (or %s if appropriate) for the error argument in this log line.

Suggested change
t.c.log().Errorf("Couldn't fully decode message data from channel %q: %w", t.c.Name, err)
t.c.log().Errorf("Couldn't fully decode message data from channel %q: %v", t.c.Name, err)

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 35143c8.

path string
rawPath string
params url.Values
header http.Header // Optional custom headers
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

paginatedRequest gained a header http.Header field but it isn't referenced anywhere (pagination uses the query func directly). If custom headers are no longer needed for this approach (since Stats overrides req.query), consider removing this field to avoid dead/unused API surface, or wire it into newPaginatedRequest/query so it has an effect.

Suggested change
header http.Header // Optional custom headers

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dropped in 5657d34.

Comment on lines +19 to +26
// Publish a message and get its serial
result, err := channel.PublishWithResult(context.Background(), "event-name", "message data")
if err != nil {
panic(err)
}

fmt.Printf("Message published with serial: %s\n", *result.Serial)
}
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These examples dereference *result.Serial / *updateResult.VersionSerial directly, but both fields are documented as potentially nil (e.g., when a message is discarded by conflation or superseded). For safety (and to avoid encouraging panics in copied example code), add a nil check and/or print an alternate message when the pointer is nil.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nil check added in 5e2fef5.

}
}

type ackCallback struct {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
type ackCallback struct {
type msgAckCallback struct {

So, we can use ackCallback as a variable name for msgAckCallback interface

msg *protocolMessage
onAck func(err error)
msg *protocolMessage
callback *ackCallback
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
callback *ackCallback
ackCallback *msgAckCallback

if enqueued := c.maybeEnqueue(msg, onAck); enqueued {
// performMessageOperationAsync is a shared helper for UpdateMessageAsync, DeleteMessageAsync, and AppendMessageAsync.
// It validates the message serial, applies update options, sets the action, and sends the protocol message.
func (c *RealtimeChannel) performMessageOperationAsync(msg *Message, action MessageAction, onAck func(*UpdateDeleteResult, error), options ...UpdateOption) error {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As per spec, we also need clientId validation RTL6g3

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

4 participants