Skip to content
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
9 changes: 4 additions & 5 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,10 @@ version: 2
# reference -- peers at `typedoc: 0.28.x` exactly, so it could not follow even if
# the prerelease were acceptable in a docs build.
#
# `/server` is not blocked. Its `tsdown` build uses rolldown-plugin-dts, which
# supports TypeScript 6 and selects the native TypeScript 7 declaration generator
# when TS 7 is installed. It is held only to keep one TypeScript version across
# the repository while `/web` is pinned, which is a preference and can be dropped
# whenever that stops being worth it.
# `/server` is also blocked: TypeScript 7.0.2 lacks the classic compiler API
# used by rolldown-plugin-dts in tsdown 0.23.2. Its native declaration generator
# requires TypeScript 7.1+, currently prerelease. Keep the stable 6.0.3 compiler
# until both declaration generation and the documentation pipeline support 7.
#
# Routine minor and patch updates are grouped to keep maintenance cheap. Major
# updates stay in focused pull requests so an incompatible upgrade cannot block
Expand Down
8 changes: 4 additions & 4 deletions .github/workflows/checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ jobs:
- uses: pnpm/action-setup@v6
if: needs.scope.outputs.full == 'true'
with:
version: 11.20.0
version: 12.15.0

- uses: actions/setup-node@v7
if: needs.scope.outputs.full == 'true'
Expand Down Expand Up @@ -157,7 +157,7 @@ jobs:
- uses: pnpm/action-setup@v6
if: needs.scope.outputs.full == 'true' && matrix.shard == 'flutter'
with:
version: 11.20.0
version: 12.15.0

- uses: actions/setup-node@v7
if: needs.scope.outputs.full == 'true' && matrix.shard == 'flutter'
Expand Down Expand Up @@ -187,7 +187,7 @@ jobs:

- uses: pnpm/action-setup@v6
with:
version: 11.20.0
version: 12.15.0

- uses: actions/setup-node@v7
with:
Expand Down Expand Up @@ -223,7 +223,7 @@ jobs:
- uses: pnpm/action-setup@v6
if: needs.scope.outputs.full == 'true'
with:
version: 11.20.0
version: 12.15.0

- uses: actions/setup-node@v7
if: needs.scope.outputs.full == 'true'
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ jobs:

- uses: pnpm/action-setup@v6
with:
version: 11.20.0
version: 12.15.0

- uses: actions/setup-node@v7
with:
Expand Down Expand Up @@ -208,7 +208,7 @@ jobs:

- uses: pnpm/action-setup@v6
with:
version: 11.20.0
version: 12.15.0

# Do not set registry-url. npm trusted publishing exchanges this job's
# OIDC identity; an npmrc token placeholder can mask that exchange.
Expand Down
9 changes: 9 additions & 0 deletions dart/eigen_client/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
# Changelog
## [Unreleased]
### Changed
- GameRepository.sessions no longer accepts an inject stream: repository HTTP responses enter the session coordinator automatically. GameSocket.connect surfaces GameConnectionException and stops on permanent failures.

### Fixed
- Automatically serialize authoritative HTTP sessions with socket snapshots; surface connection failures with one retry owner and restore same-sequence reconnects.
- Notify database watchers when deleting an account by using typed Drift deletes, while preserving other accounts on the device.

## [0.8.0] - 2026-10-12
### Changed
- Requires `eigen_api` 0.12.0, the client generated from engine 0.12.0.
Expand Down Expand Up @@ -51,6 +59,7 @@ by a later active snapshot.
## [0.1.0] - 2026-08-21
- Initial pure Dart client and domain package.

[Unreleased]: https://github.com/eigeninteractive/eigen-platform/compare/eigen_client-v0.8.0...HEAD
[0.8.0]: https://github.com/eigeninteractive/eigen-platform/compare/eigen_client-v0.7.0...eigen_client-v0.8.0
[0.7.0]: https://github.com/eigeninteractive/eigen-platform/compare/eigen_client-v0.6.0...eigen_client-v0.7.0
[0.6.0]: https://github.com/eigeninteractive/eigen-platform/compare/eigen_client-v0.5.0...eigen_client-v0.6.0
Expand Down
11 changes: 9 additions & 2 deletions dart/eigen_client/lib/src/api/engine_call.dart
Original file line number Diff line number Diff line change
Expand Up @@ -81,10 +81,17 @@ EngineException _engineExceptionFrom(Response<dynamic> response) {
name: 'eigen.compatibility',
);
}
return EngineException(parsed.error, code: parsed.code);
return EngineException(
parsed.error,
code: parsed.code,
statusCode: response.statusCode,
);
} on CheckedFromJsonException catch (_) {
// Fall through to the status line.
}
}
return EngineException('Request failed (status ${response.statusCode})');
return EngineException(
'Request failed (status ${response.statusCode})',
statusCode: response.statusCode,
);
}
5 changes: 4 additions & 1 deletion dart/eigen_client/lib/src/api/engine_exception.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ import 'package:eigen_api/eigen_api.dart';
/// Dispatch on [code], never on [message]: the message is display copy the
/// server may reword at any time, while the code is a stable machine value.
class EngineException implements Exception {
const EngineException(this.message, {this.code});
const EngineException(this.message, {this.code, this.statusCode});

/// HTTP status when this failure came from a server response.
final int? statusCode;

/// The server's human-readable text. Used as display copy only when [code] is
/// null; a coded failure is phrased by `humanize` instead.
Expand Down
67 changes: 54 additions & 13 deletions dart/eigen_client/lib/src/api/game_socket.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,43 @@ import 'dart:async';
import 'dart:convert';
import 'dart:developer' as developer;

import 'package:dio/dio.dart';
import 'package:eigen_api/eigen_api.dart';
import 'package:web_socket_channel/web_socket_channel.dart';

import 'engine_exception.dart';

/// A visible failure of the live feed, retaining the underlying diagnostic.
///
/// [retrying] means this subscription owns automatic recovery. Otherwise the
/// caller must explicitly reconnect after fixing the cause or signing in.
class GameConnectionException implements Exception {
const GameConnectionException(this.cause, {required this.retrying});

final Object cause;
final bool retrying;

@override
String toString() => 'Game connection failed: $cause';
}

bool _canReconnect(Object error) => switch (error) {
EngineException(:final statusCode) =>
statusCode == 408 ||
statusCode == 502 ||
statusCode == 503 ||
statusCode == 504,
DioException(:final type, response: null) => switch (type) {
DioExceptionType.connectionTimeout ||
DioExceptionType.sendTimeout ||
DioExceptionType.receiveTimeout ||
DioExceptionType.connectionError => true,
_ => false,
},
WebSocketChannelException() => true,
_ => false,
};

/// Builds the browser-compatible authenticated socket URI.
///
/// WebSocket browser APIs cannot attach an Authorization header to the HTTP
Expand Down Expand Up @@ -52,8 +86,8 @@ typedef GameSocketEvent = Session;

/// Opens a game's socket and keeps it open for the screen's lifetime.
///
/// Reconnects on drop with capped exponential backoff, emitting
/// [GameSocketConnected] each time so the caller can recover whatever it missed
/// Reconnects on transient failures with capped exponential backoff. The first
/// snapshot on each connection lets the caller recover whatever it missed
/// while disconnected. It deliberately owns *only* the connection: ordering,
/// gap detection, and recovery are the repository's, because recovering a gap
/// means an HTTP range fetch this layer knows nothing about.
Expand All @@ -74,13 +108,12 @@ class GameSocket {
final Duration _maxBackoff;

/// Connects to [gameId]'s socket, reconnecting until the subscription is
/// cancelled.
/// cancelled or a failure requires user/operator intervention.
///
/// The stream never completes on its own and never surfaces a connection
/// failure as an error: a dropped socket is an expected condition on mobile,
/// and the recovery for it is the reconnect this already performs. Errors
/// that are *not* recoverable that way, such as a malformed message, are logged and
/// skipped rather than tearing down a working connection.
/// Failures emit [GameConnectionException]. Network failures and temporary
/// gateway/service failures retry; authentication, permissions, rate limits,
/// server bugs and malformed snapshots stop. Other layers must not add a
/// second retry loop. A fresh subscription is an explicit retry.
///
/// A reconnect needs no announcement, because the server's first message on
/// the new connection is the current snapshot. When nothing was missed it
Expand All @@ -95,19 +128,27 @@ class GameSocket {
final ticket = await _ticketProvider(gameId);
channel = WebSocketChannel.connect(_socketUri(gameId, ticket));
await channel.ready;
backoff = _initialBackoff;

await for (final message in channel.stream) {
final event = _decode(message);
if (event != null) yield event;
if (event != null) {
backoff = _initialBackoff;
yield event;
}
}
throw WebSocketChannelException('The game connection closed');
} catch (error, stack) {
final retrying = _canReconnect(error);
developer.log(
'game socket for $gameId dropped; reconnecting',
'game socket for $gameId failed; retrying=$retrying',
name: 'eigen.socket',
error: error,
stackTrace: stack,
);
yield* Stream<GameSocketEvent>.error(
GameConnectionException(error, retrying: retrying),
stack,
);
if (!retrying) return;
} finally {
await channel?.sink.close();
}
Expand Down Expand Up @@ -143,7 +184,7 @@ class GameSocket {
error: error,
stackTrace: stack,
);
return null;
rethrow;
}
}
}
19 changes: 12 additions & 7 deletions dart/eigen_client/lib/src/replica/account_replica.dart
Original file line number Diff line number Diff line change
Expand Up @@ -563,19 +563,24 @@ final class AccountReplica {
/// Deletes everything the device holds for the account, its local games
/// included: what deleting the account does.
Future<void> deleteAll() => _db.transaction(() async {
for (final table in <TableInfo<Table, Object?>>[
await (_db.delete(
_db.participants,
)..where((row) => row.accountId.equals(accountId))).go();
await (_db.delete(
_db.ratingHistory,
)..where((row) => row.accountId.equals(accountId))).go();
await (_db.delete(
_db.relationships,
)..where((row) => row.accountId.equals(accountId))).go();
await (_db.delete(
_db.frames,
)..where((row) => row.accountId.equals(accountId))).go();
await (_db.delete(
_db.localGames,
)..where((row) => row.accountId.equals(accountId))).go();
await (_db.delete(
_db.transitions,
]) {
await _db.customStatement(
'DELETE FROM ${table.actualTableName} WHERE account_id = ?',
[accountId],
);
}
)..where((row) => row.accountId.equals(accountId))).go();
await (_db.delete(
_db.games,
)..where((row) => row.accountId.equals(accountId))).go();
Expand Down
Loading
Loading