Skip to content
This repository was archived by the owner on Sep 10, 2026. It is now read-only.
Merged
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
49 changes: 49 additions & 0 deletions .changeset/opaque-pagination-cursors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
---
"@eigeninteractive/server": minor
---

Pagination cursors are now opaque tokens, and empty query parameters are treated as absent.

Every paged list could silently return nothing. A client that sent an optional
query parameter it had no value for produced `?cursor`, which arrives as the
empty string, and `z.coerce.number()` is `Number()`, so `""` became `0`. Zero is
a structurally valid integer cursor meaning "strictly older than the beginning of
time", so the request did not fail: the lobby, both `games/mine` buckets, the
friends list and every history screen returned `200` with an empty array. Nothing
was logged, because nothing had gone wrong as far as any layer could tell.

Three changes, each closing a different part of it:

- **`cursor` is now an opaque string** rather than a bare epoch-millisecond
integer, and paged responses carry a **`nextCursor`** that is null exactly when
the list is exhausted. There is no byte string that accidentally decodes to the
beginning of time, so the failure above cannot recur by construction. A cursor
that does not decode is a `400` with the new `invalidCursor` code.
- **The cursor carries the row id alongside the sort value**, so pages no longer
drop a row when two games share a timestamp - a limitation the previous
implementation documented but did not fix. The comparison is a SQLite row value
(`(sortKey, id) < (?, ?)`), which is planned against the same index.
- **Query parameters are parsed, never coerced.** This is the origin of the whole
failure, and it took four correct layers to become invisible. `Number(null)` is
`0`, so `z.coerce.number().int().min(0)` genuinely accepts null; the emitted
schema honestly reported `["integer", "null"]`; `openapi-generator` correctly
dropped its `if (x != null)` guard, because the API had declared null welcome;
dio rendered that null as a bare `?to=`; and the server coerced `""` back to
`0`. No library was misbehaving. Integer query parameters now parse from a
strict pattern, so they reject null and empty alike, which means the generated
client omits an absent parameter on its own and a malformed one is a loud `400`
rather than a plausible, wrong `200`. `?pool=` and `?to=` were failing the same
way and are fixed by the same change.

A contract test now asserts that no query parameter in the published document
is nullable, which is the tell that was invisible in review: `.min(0)` was
nullable and `.min(1)` was not.

Because `nextCursor` is now an answer rather than something a client infers from
a short page, callers no longer need to know how any list is sorted, and a final
page that happens to be exactly full no longer triggers one pointless request.

Breaking for direct API consumers: `cursor` is a string, and the four paged
responses (`Lobby`, `MyGames`, `PlayerGames`, `FriendsGames`) gained a required
`nextCursor` field. The generated Dart client and `eigen_flutter` are updated to
match.
1 change: 1 addition & 0 deletions clients/dart/doc/FriendsGames.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import 'package:eigen_api/api.dart';
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**games** | [**List&lt;GameSummary&gt;**](GameSummary.md) | |
**nextCursor** | **String** | Pass as `cursor` to fetch the next page. Null when there are no more results. |

[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

Expand Down
8 changes: 4 additions & 4 deletions clients/dart/doc/GamesApi.md
Original file line number Diff line number Diff line change
Expand Up @@ -375,7 +375,7 @@ import 'package:eigen_api/api.dart';

final api = EigenApi().getGamesApi();
final int limit = 56; // int |
final int cursor = 56; // int |
final String cursor = cursor_example; // String |

try {
final response = api.getLobby(limit, cursor);
Expand All @@ -390,7 +390,7 @@ try {
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**limit** | **int**| | [optional] [default to 20]
**cursor** | **int**| | [optional]
**cursor** | **String**| | [optional]

### Return type

Expand Down Expand Up @@ -419,7 +419,7 @@ import 'package:eigen_api/api.dart';
final api = EigenApi().getGamesApi();
final String bucket = bucket_example; // String |
final int limit = 56; // int |
final int cursor = 56; // int |
final String cursor = cursor_example; // String |

try {
final response = api.getMyGames(bucket, limit, cursor);
Expand All @@ -435,7 +435,7 @@ Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**bucket** | **String**| | [optional] [default to 'active']
**limit** | **int**| | [optional] [default to 20]
**cursor** | **int**| | [optional]
**cursor** | **String**| | [optional]

### Return type

Expand Down
1 change: 1 addition & 0 deletions clients/dart/doc/Lobby.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import 'package:eigen_api/api.dart';
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**games** | [**List&lt;GameSummary&gt;**](GameSummary.md) | |
**nextCursor** | **String** | Pass as `cursor` to fetch the next page. Null when there are no more results. |

[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

Expand Down
1 change: 1 addition & 0 deletions clients/dart/doc/MyGames.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import 'package:eigen_api/api.dart';
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**games** | [**List&lt;GameSummary&gt;**](GameSummary.md) | |
**nextCursor** | **String** | Pass as `cursor` to fetch the next page. Null when there are no more results. |

[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

Expand Down
1 change: 1 addition & 0 deletions clients/dart/doc/PlayerGames.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import 'package:eigen_api/api.dart';
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**games** | [**List&lt;GameSummary&gt;**](GameSummary.md) | |
**nextCursor** | **String** | Pass as `cursor` to fetch the next page. Null when there are no more results. |

[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

Expand Down
4 changes: 2 additions & 2 deletions clients/dart/doc/PlayersApi.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import 'package:eigen_api/api.dart';
final api = EigenApi().getPlayersApi();
final String playerId = playerId_example; // String |
final int limit = 56; // int |
final int cursor = 56; // int |
final String cursor = cursor_example; // String |

try {
final response = api.getPlayerGames(playerId, limit, cursor);
Expand All @@ -42,7 +42,7 @@ Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**playerId** | **String**| |
**limit** | **int**| | [optional] [default to 20]
**cursor** | **int**| | [optional]
**cursor** | **String**| | [optional]

### Return type

Expand Down
4 changes: 2 additions & 2 deletions clients/dart/doc/SocialApi.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ import 'package:eigen_api/api.dart';

final api = EigenApi().getSocialApi();
final int limit = 56; // int |
final int cursor = 56; // int |
final String cursor = cursor_example; // String |

try {
final response = api.getFriendsGames(limit, cursor);
Expand All @@ -126,7 +126,7 @@ try {
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**limit** | **int**| | [optional] [default to 20]
**cursor** | **int**| | [optional]
**cursor** | **String**| | [optional]

### Return type

Expand Down
13 changes: 8 additions & 5 deletions clients/dart/lib/src/api/games_api.dart
Original file line number Diff line number Diff line change
Expand Up @@ -554,7 +554,10 @@ class GamesApi {
validateStatus: validateStatus,
);

final _queryParameters = <String, dynamic>{r'from': from, r'to': to};
final _queryParameters = <String, dynamic>{
if (from != null) r'from': from,
if (to != null) r'to': to,
};

final _response = await _dio.request<Object>(
_path,
Expand Down Expand Up @@ -771,7 +774,7 @@ class GamesApi {
/// Throws [DioException] if API call or serialization fails
Future<Response<Lobby>> getLobby({
int? limit = 20,
int? cursor,
String? cursor,
CancelToken? cancelToken,
Map<String, dynamic>? headers,
Map<String, dynamic>? extra,
Expand All @@ -794,7 +797,7 @@ class GamesApi {

final _queryParameters = <String, dynamic>{
if (limit != null) r'limit': limit,
r'cursor': cursor,
if (cursor != null) r'cursor': cursor,
};

final _response = await _dio.request<Object>(
Expand Down Expand Up @@ -854,7 +857,7 @@ class GamesApi {
Future<Response<MyGames>> getMyGames({
String? bucket = 'active',
int? limit = 20,
int? cursor,
String? cursor,
CancelToken? cancelToken,
Map<String, dynamic>? headers,
Map<String, dynamic>? extra,
Expand All @@ -878,7 +881,7 @@ class GamesApi {
final _queryParameters = <String, dynamic>{
if (bucket != null) r'bucket': bucket,
if (limit != null) r'limit': limit,
r'cursor': cursor,
if (cursor != null) r'cursor': cursor,
};

final _response = await _dio.request<Object>(
Expand Down
4 changes: 2 additions & 2 deletions clients/dart/lib/src/api/players_api.dart
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ class PlayersApi {
Future<Response<PlayerGames>> getPlayerGames({
required String playerId,
int? limit = 20,
int? cursor,
String? cursor,
CancelToken? cancelToken,
Map<String, dynamic>? headers,
Map<String, dynamic>? extra,
Expand Down Expand Up @@ -66,7 +66,7 @@ class PlayersApi {

final _queryParameters = <String, dynamic>{
if (limit != null) r'limit': limit,
r'cursor': cursor,
if (cursor != null) r'cursor': cursor,
};

final _response = await _dio.request<Object>(
Expand Down
4 changes: 2 additions & 2 deletions clients/dart/lib/src/api/social_api.dart
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ class SocialApi {
/// Throws [DioException] if API call or serialization fails
Future<Response<FriendsGames>> getFriendsGames({
int? limit = 20,
int? cursor,
String? cursor,
CancelToken? cancelToken,
Map<String, dynamic>? headers,
Map<String, dynamic>? extra,
Expand All @@ -166,7 +166,7 @@ class SocialApi {

final _queryParameters = <String, dynamic>{
if (limit != null) r'limit': limit,
r'cursor': cursor,
if (cursor != null) r'cursor': cursor,
};

final _response = await _dio.request<Object>(
Expand Down
4 changes: 4 additions & 0 deletions clients/dart/lib/src/model/error_code.dart
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,10 @@ enum ErrorCode {
@JsonValue(r'rateLimited')
rateLimited(r'rateLimited'),

/// A stable machine code identifying why a request failed.
@JsonValue(r'invalidCursor')
invalidCursor(r'invalidCursor'),

/// A stable machine code identifying why a request failed.
@JsonValue(r'unknown_default_open_api')
unknownDefaultOpenApi(r'unknown_default_open_api');
Expand Down
1 change: 1 addition & 0 deletions clients/dart/lib/src/model/error_response.g.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 11 additions & 3 deletions clients/dart/lib/src/model/friends_games.dart
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,25 @@ part 'friends_games.g.dart';
)
class FriendsGames {
/// Returns a new [FriendsGames] instance.
FriendsGames({required this.games});
FriendsGames({required this.games, required this.nextCursor});

@JsonKey(name: r'games', required: true, includeIfNull: false)
final List<GameSummary> games;

/// Pass as `cursor` to fetch the next page. Null when there are no more results.
@JsonKey(name: r'nextCursor', required: true, includeIfNull: true)
final String? nextCursor;

@override
bool operator ==(Object other) =>
identical(this, other) || other is FriendsGames && other.games == games;
identical(this, other) ||
other is FriendsGames &&
other.games == games &&
other.nextCursor == nextCursor;

@override
int get hashCode => games.hashCode;
int get hashCode =>
games.hashCode + (nextCursor == null ? 0 : nextCursor.hashCode);

factory FriendsGames.fromJson(Map<String, dynamic> json) =>
_$FriendsGamesFromJson(json);
Expand Down
8 changes: 6 additions & 2 deletions clients/dart/lib/src/model/friends_games.g.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 9 additions & 3 deletions clients/dart/lib/src/model/lobby.dart
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,23 @@ part 'lobby.g.dart';
)
class Lobby {
/// Returns a new [Lobby] instance.
Lobby({required this.games});
Lobby({required this.games, required this.nextCursor});

@JsonKey(name: r'games', required: true, includeIfNull: false)
final List<GameSummary> games;

/// Pass as `cursor` to fetch the next page. Null when there are no more results.
@JsonKey(name: r'nextCursor', required: true, includeIfNull: true)
final String? nextCursor;

@override
bool operator ==(Object other) =>
identical(this, other) || other is Lobby && other.games == games;
identical(this, other) ||
other is Lobby && other.games == games && other.nextCursor == nextCursor;

@override
int get hashCode => games.hashCode;
int get hashCode =>
games.hashCode + (nextCursor == null ? 0 : nextCursor.hashCode);

factory Lobby.fromJson(Map<String, dynamic> json) => _$LobbyFromJson(json);

Expand Down
4 changes: 3 additions & 1 deletion clients/dart/lib/src/model/lobby.g.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 11 additions & 3 deletions clients/dart/lib/src/model/my_games.dart
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,25 @@ part 'my_games.g.dart';
)
class MyGames {
/// Returns a new [MyGames] instance.
MyGames({required this.games});
MyGames({required this.games, required this.nextCursor});

@JsonKey(name: r'games', required: true, includeIfNull: false)
final List<GameSummary> games;

/// Pass as `cursor` to fetch the next page. Null when there are no more results.
@JsonKey(name: r'nextCursor', required: true, includeIfNull: true)
final String? nextCursor;

@override
bool operator ==(Object other) =>
identical(this, other) || other is MyGames && other.games == games;
identical(this, other) ||
other is MyGames &&
other.games == games &&
other.nextCursor == nextCursor;

@override
int get hashCode => games.hashCode;
int get hashCode =>
games.hashCode + (nextCursor == null ? 0 : nextCursor.hashCode);

factory MyGames.fromJson(Map<String, dynamic> json) =>
_$MyGamesFromJson(json);
Expand Down
Loading