diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..dd2458c --- /dev/null +++ b/.editorconfig @@ -0,0 +1,13 @@ +# Root editor/formatter config. end_of_line=lf makes dotnet-format agree with +# .gitattributes (eol=lf) — without this, format-on-Windows wants CRLF while git +# stores LF, and the pre-commit/CI format gates flip-flop forever. +root = true + +[*] +end_of_line = lf +insert_final_newline = true +charset = utf-8 + +[*.cs] +indent_style = space +indent_size = 4 diff --git a/src/InboxIntel.Application/DTOs/SearchDtos.cs b/src/InboxIntel.Application/DTOs/SearchDtos.cs index 9d00d9b..e9322a1 100644 --- a/src/InboxIntel.Application/DTOs/SearchDtos.cs +++ b/src/InboxIntel.Application/DTOs/SearchDtos.cs @@ -23,4 +23,9 @@ public record SearchRequestDto( bool? IsTrashed = null, string? GmailLabel = null, // e.g. "SENT", "DRAFT", "SPAM" string? Category = null, // EmailCategory name, e.g. "Finance" - long? MinSizeBytes = null); + long? MinSizeBytes = null, + // Keyset cursor for the date-ordered browse path (RECOMMENDATIONS #8): pass the last + // row's SentAtUtc+Id to fetch the next window without OFFSET (O(pageSize), not O(page)). + // When set, TotalCount is not recomputed (-1). Additive; offset paging still works. + DateTimeOffset? AfterSentAtUtc = null, + Guid? AfterId = null); diff --git a/src/InboxIntel.Infrastructure/Migrations/20260630140733_InitialCreate.cs b/src/InboxIntel.Infrastructure/Migrations/20260630140733_InitialCreate.cs index 0246dcd..9c93dc7 100644 --- a/src/InboxIntel.Infrastructure/Migrations/20260630140733_InitialCreate.cs +++ b/src/InboxIntel.Infrastructure/Migrations/20260630140733_InitialCreate.cs @@ -1,4 +1,4 @@ -using System; +using System; using Microsoft.EntityFrameworkCore.Migrations; using NpgsqlTypes; diff --git a/src/InboxIntel.Infrastructure/Migrations/20260630200946_AddUserDigestFields.cs b/src/InboxIntel.Infrastructure/Migrations/20260630200946_AddUserDigestFields.cs index 7c6a216..32b5930 100644 --- a/src/InboxIntel.Infrastructure/Migrations/20260630200946_AddUserDigestFields.cs +++ b/src/InboxIntel.Infrastructure/Migrations/20260630200946_AddUserDigestFields.cs @@ -1,4 +1,4 @@ -using System; +using System; using Microsoft.EntityFrameworkCore.Migrations; #nullable disable diff --git a/src/InboxIntel.Infrastructure/Migrations/20260630201607_AddUnsubscribeConfidence.cs b/src/InboxIntel.Infrastructure/Migrations/20260630201607_AddUnsubscribeConfidence.cs index 24a13e3..6edc6f1 100644 --- a/src/InboxIntel.Infrastructure/Migrations/20260630201607_AddUnsubscribeConfidence.cs +++ b/src/InboxIntel.Infrastructure/Migrations/20260630201607_AddUnsubscribeConfidence.cs @@ -1,4 +1,4 @@ -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations; #nullable disable diff --git a/src/InboxIntel.Infrastructure/Migrations/20260701192100_WeightSearchVectorSubjectBody.cs b/src/InboxIntel.Infrastructure/Migrations/20260701192100_WeightSearchVectorSubjectBody.cs index f1d22c0..dcd8bc4 100644 --- a/src/InboxIntel.Infrastructure/Migrations/20260701192100_WeightSearchVectorSubjectBody.cs +++ b/src/InboxIntel.Infrastructure/Migrations/20260701192100_WeightSearchVectorSubjectBody.cs @@ -1,4 +1,4 @@ -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations; using NpgsqlTypes; #nullable disable diff --git a/src/InboxIntel.Infrastructure/Migrations/20260701203001_EnablePgTrgm.cs b/src/InboxIntel.Infrastructure/Migrations/20260701203001_EnablePgTrgm.cs index eb140e6..78c2934 100644 --- a/src/InboxIntel.Infrastructure/Migrations/20260701203001_EnablePgTrgm.cs +++ b/src/InboxIntel.Infrastructure/Migrations/20260701203001_EnablePgTrgm.cs @@ -1,4 +1,4 @@ -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations; #nullable disable diff --git a/src/InboxIntel.Infrastructure/Migrations/20260701235112_TrigramIndexesSenderDomain.cs b/src/InboxIntel.Infrastructure/Migrations/20260701235112_TrigramIndexesSenderDomain.cs index a58e968..b59e9ea 100644 --- a/src/InboxIntel.Infrastructure/Migrations/20260701235112_TrigramIndexesSenderDomain.cs +++ b/src/InboxIntel.Infrastructure/Migrations/20260701235112_TrigramIndexesSenderDomain.cs @@ -1,4 +1,4 @@ -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations; #nullable disable diff --git a/src/InboxIntel.Infrastructure/Migrations/20260702002304_AddEmbeddingColumn.cs b/src/InboxIntel.Infrastructure/Migrations/20260702002304_AddEmbeddingColumn.cs index 99573e4..b681500 100644 --- a/src/InboxIntel.Infrastructure/Migrations/20260702002304_AddEmbeddingColumn.cs +++ b/src/InboxIntel.Infrastructure/Migrations/20260702002304_AddEmbeddingColumn.cs @@ -1,4 +1,4 @@ -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations; using Pgvector; #nullable disable diff --git a/src/InboxIntel.Infrastructure/Search/SearchService.cs b/src/InboxIntel.Infrastructure/Search/SearchService.cs index c93b8f7..0b8ba4b 100644 --- a/src/InboxIntel.Infrastructure/Search/SearchService.cs +++ b/src/InboxIntel.Infrastructure/Search/SearchService.cs @@ -114,9 +114,23 @@ public class SearchService : ISearchService ranked = matched.OrderByDescending(e => e.SearchVector!.RankCoverDensity(EF.Functions.WebSearchToTsQuery("english", term))) .ThenByDescending(e => e.SentAtUtc); else - ranked = matched.OrderByDescending(e => e.SentAtUtc); + ranked = matched.OrderByDescending(e => e.SentAtUtc).ThenByDescending(e => e.Id); - var paged = ranked.Skip((r.Page - 1) * r.PageSize).Take(r.PageSize); + // Keyset (cursor) pagination for the browse path: O(pageSize) regardless of depth, + // vs OFFSET's O(page*pageSize). The (SentAtUtc, Id) pair with the Id tie-break above + // makes the ordering total, so windows never duplicate or skip rows. + IQueryable paged; + if (!hasFreeTextQuery && r is { AfterSentAtUtc: { } afterAt, AfterId: { } afterId }) + { + paged = ranked + .Where(e => e.SentAtUtc < afterAt || (e.SentAtUtc == afterAt && e.Id.CompareTo(afterId) < 0)) + .Take(r.PageSize); + total = -1; // not recomputed on cursor windows (that's the point) + } + else + { + paged = ranked.Skip((r.Page - 1) * r.PageSize).Take(r.PageSize); + } // "Why this matched" ts_headline only for EXACT free-text hits (fuzzy/browse get no // highlight — a fuzzy hit has no literal match to headline). Unconditional projections diff --git a/tests/InboxIntel.IntegrationTests/KeysetPaginationTests.cs b/tests/InboxIntel.IntegrationTests/KeysetPaginationTests.cs new file mode 100644 index 0000000..fcf7941 --- /dev/null +++ b/tests/InboxIntel.IntegrationTests/KeysetPaginationTests.cs @@ -0,0 +1,57 @@ +using FluentAssertions; +using InboxIntel.Application.Abstractions; +using InboxIntel.Application.DTOs; +using InboxIntel.Domain.Entities; +using InboxIntel.Infrastructure.Persistence; +using InboxIntel.Infrastructure.Search; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace InboxIntel.IntegrationTests; + +/// +/// RECOMMENDATIONS #8: keyset (cursor) pagination for the browse path — the window after a +/// (SentAtUtc, Id) cursor returns the next rows with no duplicates/skips and no OFFSET scan. +/// +public class KeysetPaginationTests +{ + private sealed class FakeCurrentUser : ICurrentUser + { + public Guid UserId { get; set; } + public bool IsAuthenticated => UserId != Guid.Empty; + } + + [Fact] + public async Task Cursor_window_continues_exactly_after_the_previous_page() + { + var user = Guid.NewGuid(); + var opts = new DbContextOptionsBuilder() + .UseInMemoryDatabase(nameof(Cursor_window_continues_exactly_after_the_previous_page)).Options; + + var baseline = DateTimeOffset.UtcNow; + using (var seed = new AppDbContext(opts, new FakeCurrentUser())) + { + var sender = new Sender { UserId = user, Address = "s@x.x" }; + seed.Senders.Add(sender); + for (var i = 0; i < 5; i++) + seed.Emails.Add(new Email { UserId = user, GmailMessageId = $"m{i}", Sender = sender, SentAtUtc = baseline.AddMinutes(-i) }); + await seed.SaveChangesAsync(); + } + + using var ctx = new AppDbContext(opts, new FakeCurrentUser { UserId = user }); + var svc = new SearchService(ctx); + + // First window via offset (page 1, size 2): m0, m1 (newest first). + var page1 = await svc.SearchAsync(user, new SearchRequestDto(null, null, null, null, null, null, null, false, 1, 2)); + page1.Items.Select(i => i.GmailMessageId).Should().Equal("m0", "m1"); + + // Next window via cursor from the last row of page 1. + var last = page1.Items[^1]; + var page2 = await svc.SearchAsync(user, new SearchRequestDto( + null, null, null, null, null, null, null, false, 1, 2, + AfterSentAtUtc: last.SentAtUtc, AfterId: last.Id)); + + page2.Items.Select(i => i.GmailMessageId).Should().Equal("m2", "m3"); // no dupes, no skips + page2.TotalCount.Should().Be(-1, "cursor windows skip the COUNT — that's the perf win"); + } +}