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
20 changes: 17 additions & 3 deletions server/internal/repository/dynamodb/event.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,14 +135,28 @@ func (r *EventRepository) FindBySessionID(ctx context.Context, sessionID string)
events[i] = r.itemToEvent(&item)
}

// Sort by created_at since sort_key is now uuid-based and doesn't preserve chronological order
sort.Slice(events, func(i, j int) bool {
return events[i].CreatedAt.Before(events[j].CreatedAt)
// sort_key is uuid-based, so chronological order must be reconstructed here.
// Match the other backends: sort by payload.timestamp, not created_at.
sort.SliceStable(events, func(i, j int) bool {
return getTimestampFromPayload(events[i]).Before(getTimestampFromPayload(events[j]))
})

return events, nil
}

func getTimestampFromPayload(e *domain.Event) time.Time {
if ts, ok := e.Payload["timestamp"].(string); ok {
if parsed, err := time.Parse(time.RFC3339Nano, ts); err == nil {
return parsed
}
// Try parsing without timezone
if parsed, err := time.Parse("2006-01-02T15:04:05.000Z", ts); err == nil {
return parsed
}
}
return e.CreatedAt
}

func (r *EventRepository) CountBySessionID(ctx context.Context, sessionID string) (int, error) {
keyCond := expression.Key("session_id").Equal(expression.Value(sessionID))

Expand Down
49 changes: 49 additions & 0 deletions server/internal/repository/testsuite/event_suite.go
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,55 @@ func (s *EventRepositorySuite) TestFindBySessionID_ChronologicalOrder() {
s.WithinDuration(baseTime.Add(500*time.Millisecond), events[4].CreatedAt, time.Microsecond, "Last event should have latest timestamp")
}

// TestFindBySessionID_OrdersByPayloadTimestamp ensures events are returned ordered by
// payload.timestamp (event time), not created_at — guarding the cross-backend contract.
func (s *EventRepositorySuite) TestFindBySessionID_OrdersByPayloadTimestamp() {
ctx := context.Background()

sessionID := s.createTestSession("event-payload-ts-order")
if sessionID == "" {
s.T().Skip("SessionRepo not available, skipping test")
}

// Second precision + UTC "Z" keeps the timestamp string lexically ordered,
// which Postgres relies on (ORDER BY payload->>'timestamp'), while Go-based
// backends parse it with RFC3339Nano. Fixed width avoids fractional-trim pitfalls.
baseTime := time.Now().UTC().Truncate(time.Second)

const n = 5
expectedTimestamps := make([]string, n)
for k := 0; k < n; k++ {
// payload.timestamp ascends with k; created_at descends with k (reverse order).
ts := baseTime.Add(time.Duration(k) * time.Second).Format(time.RFC3339Nano)
expectedTimestamps[k] = ts
event := &domain.Event{
SessionID: sessionID,
UUID: uuid.New().String(),
EventType: "message",
Payload: map[string]interface{}{
"timestamp": ts,
"marker": k,
},
CreatedAt: baseTime.Add(time.Duration(n-k) * time.Hour),
}
err := s.Repo.Create(ctx, event)
s.Require().NoError(err)
}

events, err := s.Repo.FindBySessionID(ctx, sessionID)
s.Require().NoError(err)
s.Require().Len(events, n)

// Returned order must follow payload.timestamp ascending, i.e. the insertion (created_at)
// order reversed. If a backend sorted by created_at, this would come back descending and fail.
for i := 0; i < n; i++ {
ts, ok := events[i].Payload["timestamp"].(string)
s.Require().True(ok, "event[%d] should carry a string payload.timestamp", i)
s.Equal(expectedTimestamps[i], ts,
"events should be ordered by payload.timestamp ascending (position %d)", i)
}
}

func (s *EventRepositorySuite) TestFindBySessionID_Empty() {
ctx := context.Background()

Expand Down