diff --git a/internal/database/querier.go b/internal/database/querier.go index 0720684e..7271861e 100644 --- a/internal/database/querier.go +++ b/internal/database/querier.go @@ -221,6 +221,11 @@ type Querier interface { // Series are grouped PER LIBRARY: the same series name in two libraries // (novels vs manga adaptations, LN splits) stays two entries. Visible // filter enforces per-user library visibility at the source. + // Tiebreaker REQUIRED for LIMIT/OFFSET pagination: bulk imports in one + // transaction share a single NOW() timestamp, leaving many groups tied on + // MAX(created_at). Postgres does not guarantee tie order between + // executions, so page boundaries could repeat or drop series. The GROUP BY + // keys make the sort total and deterministic. GetDistinctSeries(ctx context.Context, arg GetDistinctSeriesParams) ([]GetDistinctSeriesRow, error) GetDistinctSeriesCount(ctx context.Context, arg GetDistinctSeriesCountParams) (int32, error) GetFailedSyncQueueItems(ctx context.Context, limit int32) ([]SyncQueue, error) diff --git a/internal/database/queries.sql.go b/internal/database/queries.sql.go index 17ed040f..222e9822 100644 --- a/internal/database/queries.sql.go +++ b/internal/database/queries.sql.go @@ -3840,7 +3840,7 @@ WHERE ($1::uuid IS NULL OR library_id = $1::uuid) AND missing_scan_count = 0 AND series IS NOT NULL AND series != '' GROUP BY library_id, series -ORDER BY MAX(created_at) DESC +ORDER BY MAX(created_at) DESC, library_id, series LIMIT $4 OFFSET $3 ` @@ -3862,6 +3862,11 @@ type GetDistinctSeriesRow struct { // Series are grouped PER LIBRARY: the same series name in two libraries // (novels vs manga adaptations, LN splits) stays two entries. Visible // filter enforces per-user library visibility at the source. +// Tiebreaker REQUIRED for LIMIT/OFFSET pagination: bulk imports in one +// transaction share a single NOW() timestamp, leaving many groups tied on +// MAX(created_at). Postgres does not guarantee tie order between +// executions, so page boundaries could repeat or drop series. The GROUP BY +// keys make the sort total and deterministic. func (q *Queries) GetDistinctSeries(ctx context.Context, arg GetDistinctSeriesParams) ([]GetDistinctSeriesRow, error) { rows, err := q.db.Query(ctx, GetDistinctSeries, arg.LibraryID, diff --git a/internal/database/queries/queries.sql b/internal/database/queries/queries.sql index 6c66801c..966335e0 100644 --- a/internal/database/queries/queries.sql +++ b/internal/database/queries/queries.sql @@ -2880,7 +2880,12 @@ WHERE (sqlc.narg('library_id')::uuid IS NULL OR library_id = sqlc.narg('library_ AND missing_scan_count = 0 AND series IS NOT NULL AND series != '' GROUP BY library_id, series -ORDER BY MAX(created_at) DESC +-- Tiebreaker REQUIRED for LIMIT/OFFSET pagination: bulk imports in one +-- transaction share a single NOW() timestamp, leaving many groups tied on +-- MAX(created_at). Postgres does not guarantee tie order between +-- executions, so page boundaries could repeat or drop series. The GROUP BY +-- keys make the sort total and deterministic. +ORDER BY MAX(created_at) DESC, library_id, series LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset'); -- name: GetDistinctSeriesCount :one