diff --git a/server/internal/repository/dynamodb/event.go b/server/internal/repository/dynamodb/event.go index c53e8ea..5801e30 100644 --- a/server/internal/repository/dynamodb/event.go +++ b/server/internal/repository/dynamodb/event.go @@ -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)) diff --git a/server/internal/repository/testsuite/event_suite.go b/server/internal/repository/testsuite/event_suite.go index 6a4851f..e51579c 100644 --- a/server/internal/repository/testsuite/event_suite.go +++ b/server/internal/repository/testsuite/event_suite.go @@ -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()