---
description: Add cursor-based pagination for list endpoints with stable ordering (project)
---
# Add Cursor-Based Pagination Skill
Implement cursor-based pagination for list endpoints in NovaTune, providing stable results during data changes.
## Overview
Cursor-based pagination advantages:
- **Stable results**: Works correctly when items are added/deleted during navigation
- **Efficient queries**: Uses indexed seek instead of offset skip
- **Scalable**: Performance doesn't degrade with large offsets
## Steps
### 1. Create Cursor Model
Location: `src/NovaTuneApp/NovaTuneApp.ApiService/Models/Pagination/`
```csharp
using System.Text;
using System.Text.Json;
namespace NovaTuneApp.ApiService.Models.Pagination;
///
/// Cursor for stable pagination through sorted results.
///
/// Value of the sort field at cursor position
/// ULID for tie-breaking (provides chronological ordering)
/// When cursor was created (for expiry)
public record PaginationCursor(
string SortValue,
string Id,
DateTimeOffset Timestamp)
{
///
/// Encodes cursor as base64 URL-safe string.
///
public string Encode()
{
var json = JsonSerializer.Serialize(this);
var bytes = Encoding.UTF8.GetBytes(json);
return Convert.ToBase64String(bytes)
.Replace('+', '-')
.Replace('/', '_')
.TrimEnd('=');
}
///
/// Decodes cursor from base64 URL-safe string.
///
public static PaginationCursor? Decode(string? encoded)
{
if (string.IsNullOrEmpty(encoded))
return null;
try
{
// Restore base64 padding
var padded = encoded
.Replace('-', '+')
.Replace('_', '/');
switch (padded.Length % 4)
{
case 2: padded += "=="; break;
case 3: padded += "="; break;
}
var bytes = Convert.FromBase64String(padded);
var json = Encoding.UTF8.GetString(bytes);
return JsonSerializer.Deserialize(json);
}
catch
{
return null;
}
}
///
/// Checks if cursor has expired.
///
public bool IsExpired(TimeSpan maxAge) =>
DateTimeOffset.UtcNow - Timestamp > maxAge;
}
```
### 2. Create Paged Result Model
Location: `src/NovaTuneApp/NovaTuneApp.ApiService/Models/Pagination/PagedResult.cs`
```csharp
namespace NovaTuneApp.ApiService.Models.Pagination;
///
/// Paginated result with cursor-based navigation.
///
/// Item type
/// Items in current page
/// Cursor for next page (null if no more pages)
/// Approximate total count
/// Whether more items exist
public record PagedResult(
IReadOnlyList Items,
string? NextCursor,
int TotalCount,
bool HasMore);
```
### 3. Create Query Parameters Model
Location: `src/NovaTuneApp/NovaTuneApp.ApiService/Models/Pagination/PaginatedQueryParams.cs`
```csharp
using Microsoft.AspNetCore.Mvc;
namespace NovaTuneApp.ApiService.Models.Pagination;
///
/// Base query parameters for paginated list endpoints.
///
public record PaginatedQueryParams(
[FromQuery] string? SortBy,
[FromQuery] string? SortOrder,
[FromQuery] string? Cursor,
[FromQuery] int? Limit);
///
/// Query parameters for track list endpoint.
///
public record TrackListQueryParams(
[FromQuery] string? Search,
[FromQuery] TrackStatus? Status,
[FromQuery] string? SortBy,
[FromQuery] string? SortOrder,
[FromQuery] string? Cursor,
[FromQuery] int? Limit,
[FromQuery] bool? IncludeDeleted) : PaginatedQueryParams(SortBy, SortOrder, Cursor, Limit);
```
### 4. Create Pagination Extension Methods
Location: `src/NovaTuneApp/NovaTuneApp.ApiService/Extensions/PaginationExtensions.cs`
```csharp
using System.Linq.Expressions;
using Raven.Client.Documents;
using Raven.Client.Documents.Linq;
using NovaTuneApp.ApiService.Models.Pagination;
namespace NovaTuneApp.ApiService.Extensions;
public static class PaginationExtensions
{
///
/// Applies cursor-based pagination to a RavenDB query.
///
/// Entity type
/// RavenDB query
/// Decoded cursor (null for first page)
/// Field to sort by
/// Sort direction
/// Page size
/// Function to extract sort value from entity
/// Function to extract ID from entity
public static async Task> ToCursorPagedAsync(
this IRavenQueryable query,
PaginationCursor? cursor,
Expression> sortField,
bool sortDescending,
int limit,
Func getSortValue,
Func getId,
Func mapper,
CancellationToken ct = default)
{
// Apply cursor filter if present
if (cursor is not null)
{
query = ApplyCursorFilter(query, cursor, sortField, sortDescending);
}
// Apply sorting
query = sortDescending
? query.OrderByDescending(sortField).ThenByDescending(x => x)
: query.OrderBy(sortField).ThenBy(x => x);
// Fetch one extra to determine HasMore
var items = await query
.Take(limit + 1)
.ToListAsync(ct);
var hasMore = items.Count > limit;
if (hasMore)
items = items.Take(limit).ToList();
// Build next cursor from last item
string? nextCursor = null;
if (hasMore && items.Count > 0)
{
var lastItem = items[^1];
var newCursor = new PaginationCursor(
getSortValue(lastItem),
getId(lastItem),
DateTimeOffset.UtcNow);
nextCursor = newCursor.Encode();
}
// Get approximate total count (cached, not per-request)
var totalCount = await query.CountAsync(ct);
var results = items.Select(mapper).ToList();
return new PagedResult(
results,
nextCursor,
totalCount,
hasMore);
}
private static IRavenQueryable ApplyCursorFilter(
IRavenQueryable query,
PaginationCursor cursor,
Expression> sortField,
bool sortDescending)
{
// This is a simplified example - actual implementation would need
// to build the expression dynamically based on the sort field
// For production, consider using a library like LinqKit or
// building expressions manually
// The filter logic:
// For descending: (sortValue < cursorValue) OR (sortValue == cursorValue AND id < cursorId)
// For ascending: (sortValue > cursorValue) OR (sortValue == cursorValue AND id > cursorId)
return query; // Placeholder - implement dynamic expression building
}
}
```
### 5. Implement in Service Layer
Example for TrackManagementService:
```csharp
public async Task> ListTracksAsync(
string userId,
TrackListQuery query,
CancellationToken ct = default)
{
// Validate and constrain limit
var limit = Math.Clamp(query.Limit, 1, _options.Value.MaxPageSize);
// Decode cursor
var cursor = PaginationCursor.Decode(query.Cursor);
if (cursor?.IsExpired(TimeSpan.FromHours(24)) == true)
{
throw new InvalidCursorException("Cursor has expired");
}
// Build base query
var baseQuery = _session
.Query