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
18 changes: 18 additions & 0 deletions .github/workflows/checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,24 @@ jobs:
channel: stable
cache: true

# The example's web build is finished by Workbox, which generates its
# service worker; the repository root holds that one Node tool.
- uses: pnpm/action-setup@v6
if: needs.scope.outputs.full == 'true' && matrix.shard == 'flutter'
with:
version: 11.20.0

- uses: actions/setup-node@v7
if: needs.scope.outputs.full == 'true' && matrix.shard == 'flutter'
with:
node-version-file: server/.nvmrc
cache: pnpm
cache-dependency-path: pnpm-lock.yaml

- name: Install Workbox
if: needs.scope.outputs.full == 'true' && matrix.shard == 'flutter'
run: pnpm install --frozen-lockfile

- name: Check ${{ matrix.shard }}
if: needs.scope.outputs.full == 'true'
run: ./tool/check.sh ${{ matrix.shard }}
Expand Down
6 changes: 6 additions & 0 deletions dart/eigen_client/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
# Changelog
## [Unreleased]
### Changed
- A sync request is answered by a completed pass that began pulling after it was made, instead of joining a pass in flight: passes never overlap, a burst of triggers costs one pass, a request made mid-pull still gets its own, and a failed pass answers nothing. `SyncPass` takes a `SyncLock` so processes sharing a replica, such as browser tabs, share passes too, and `run` answers null when an earlier pass already covered the request.
- `AccountReplica.applySync` takes `pulledAt`, and `lastSyncedAt` is the moment the last completed pass began pulling, written only by the page that completes it. `AccountReplica.lastSyncedAt()` reads it.

## [0.5.0] - 2026-09-16
### Added
- A replica of the server's read model on the device, in Drift. `AccountReplica` and `PublicReplica` answer every list, profile, rating, friend and replay as a live query; `SyncPass` fills them from one `GET /me/sync` and uploads the games this device decided; `replicatedSessions` opens a game from the replica and writes each session it applies back.
Expand Down Expand Up @@ -38,6 +43,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.5.0...HEAD
[0.5.0]: https://github.com/eigeninteractive/eigen-platform/compare/eigen_client-v0.4.0...eigen_client-v0.5.0
[0.4.0]: https://github.com/eigeninteractive/eigen-platform/compare/eigen_client-v0.3.0...eigen_client-v0.4.0
[0.3.0]: https://github.com/eigeninteractive/eigen-platform/compare/eigen_client-v0.2.0...eigen_client-v0.3.0
Expand Down
36 changes: 22 additions & 14 deletions dart/eigen_client/lib/src/replica/account_replica.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import 'replica_database.dart';

/// Where an account's history stands on this device.
typedef HistoryState = ({
/// When a sync last completed, or null before the first one.
/// How current the replica is: when the last completed sync began pulling,
/// or null before the first one.
DateTime? lastSyncedAt,

/// Whether older history remains on the server that the device does not
Expand Down Expand Up @@ -66,15 +67,8 @@ final class AccountReplica {
(_db.select(
_db.accounts,
)..where((row) => row.id.equals(accountId))).watchSingleOrNull().map(
(row) => (
lastSyncedAt: row?.lastSyncedAt == null
? null
: DateTime.fromMillisecondsSinceEpoch(
row!.lastSyncedAt!,
isUtc: true,
),
hasOlder: row?.historyFloor != null,
),
(row) =>
(lastSyncedAt: _syncedAt(row), hasOlder: row?.historyFloor != null),
);

/// Every game the account holds a seat in that has not ended, the account's
Expand Down Expand Up @@ -255,6 +249,10 @@ final class AccountReplica {
/// known yet.
Future<String?> historyFloor() async => (await _account())?.historyFloor;

/// How current the replica is: when the last completed sync began pulling,
/// or null when the account has never synced on this device.
Future<DateTime?> lastSyncedAt() async => _syncedAt(await _account());

// ── Writes ────────────────────────────────────────────────────────────────

/// Applies one sync response in one transaction.
Expand All @@ -264,13 +262,18 @@ final class AccountReplica {
/// game absent from it has ended or been left; but an ended game can arrive on
/// a later page of the same pass, so [endedSeen] is given only with the last
/// page, carrying every finished game the pass received, and only then are
/// absent games removed.
/// absent games removed. That last page is also what completes the pass, so
/// it alone records [pulledAt], the moment the pass sent its first request,
/// as how current the replica now is.
Future<void> applySync(
AccountSync sync, {
required bool first,
required Set<String>? endedSeen,
required DateTime now,
required DateTime pulledAt,
}) => _db.transaction(() async {
final syncedAt = endedSeen == null
? const Value<int?>.absent()
: Value<int?>(pulledAt.toUtc().millisecondsSinceEpoch);
final profile = sync.account;
await _db
.into(_db.accounts)
Expand All @@ -281,7 +284,7 @@ final class AccountReplica {
createdAt: profile.createdAt,
finishedCursor: Value(sync.finishedCursor),
historyFloor: Value(sync.historyFloor),
lastSyncedAt: Value(now.toUtc().millisecondsSinceEpoch),
lastSyncedAt: syncedAt,
),
onConflict: DoUpdate(
(_) => AccountsCompanion(
Expand All @@ -293,7 +296,7 @@ final class AccountReplica {
historyFloor: first
? Value(sync.historyFloor)
: const Value.absent(),
lastSyncedAt: Value(now.toUtc().millisecondsSinceEpoch),
lastSyncedAt: syncedAt,
),
),
);
Expand Down Expand Up @@ -586,6 +589,11 @@ final class AccountReplica {

// ── Internals ─────────────────────────────────────────────────────────────

static DateTime? _syncedAt(AccountRow? row) => switch (row?.lastSyncedAt) {
final millis? => DateTime.fromMillisecondsSinceEpoch(millis, isUtc: true),
null => null,
};

Future<AccountRow?> _account() => (_db.select(
_db.accounts,
)..where((row) => row.id.equals(accountId))).getSingleOrNull();
Expand Down
3 changes: 2 additions & 1 deletion dart/eigen_client/lib/src/replica/replica_database.g.dart

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

62 changes: 48 additions & 14 deletions dart/eigen_client/lib/src/replica/sync_pass.dart
Original file line number Diff line number Diff line change
Expand Up @@ -25,45 +25,76 @@ final class SyncReport {
bool get pulled => error == null;
}

/// Keeps a pass's work from overlapping another pass over the same replica.
///
/// Runs [body] once no other body given to it is running. A replica only one
/// process opens needs nothing more than [SyncPass]'s own default; a replica
/// several processes share, such as browser tabs over one database, passes a
/// lock they all hold in common.
typedef SyncLock = Future<void> Function(Future<void> Function() body);

/// Brings one account's replica up to date with the server (decision 0013).
///
/// One pass, two steps, in this order: upload the games this device decides,
/// then pull the account. The order only saves a pass: a local game the upload
/// just finished arrives in the same pull that follows, where the reverse order
/// would leave it for the next trigger.
///
/// Nothing here decides *when* a pass runs; that is the caller's, and it is
/// always an event (app start, resume, reconnecting, pull-to-refresh, a push, a
/// local game finishing), never a timer. Single-flight: a trigger arriving
/// mid-pass joins the pass already running.
/// Nothing here decides *when* a pass is asked for; that is the caller's, and
/// it is always an event (app start, coming back to the app, reconnecting,
/// pull-to-refresh, a push, a local game finishing), never a timer. What this
/// decides is whether the request still needs a pass of its own (decision
/// 0014): passes never overlap, and a request is already answered once a pass
/// that began pulling after it was made has completed. So a burst of triggers,
/// from one process or from several sharing the replica, costs one pass rather
/// than one each.
final class SyncPass {
SyncPass({
required this._replica,
required this._public,
required this._account,
required this._games,
required this._localGames,
SyncLock? lock,
this._clock = DateTime.now,
this._keptReplays = defaultKeptReplays,
});
}) : _lock = lock ?? _serial();

final AccountReplica _replica;
final PublicReplica _public;
final AccountRepository _account;
final GameRepository _games;
final LocalGameSync _localGames;
final SyncLock _lock;
final DateTime Function() _clock;
final int _keptReplays;

Future<SyncReport>? _inFlight;
/// Runs a pass for a reason arising now, unless one that began pulling since
/// then has completed by the time this one could start.
///
/// Answers with the pass's report, or null when an earlier-started request's
/// pass already brought the replica past this moment. A request arriving
/// mid-pass waits for that pass rather than joining it, because a pull that
/// began before the request may have missed what prompted it.
Future<SyncReport?> run() async {
final requestedAt = _clock();
SyncReport? report;
await _lock(() async {
final syncedAt = await _replica.lastSyncedAt();
if (syncedAt != null && !syncedAt.isBefore(requestedAt)) return;
report = await _run();
});
return report;
}

/// Runs a pass, or joins the one already running.
Future<SyncReport> run() {
final running = _inFlight;
if (running != null) return running;
final pass = _run();
_inFlight = pass;
return pass.whenComplete(() => _inFlight = null);
/// A lock only this process holds: each body starts once the last has ended.
static SyncLock _serial() {
var tail = Future<void>.value();
return (body) {
final next = tail.then((_) => body());
tail = next.then((_) {}, onError: (Object _) {});
return next;
};
}

Future<SyncReport> _run() async {
Expand All @@ -79,6 +110,9 @@ final class SyncPass {
}

Future<void> _pull() async {
// What the replica reflects once this pull completes: the server as of the
// moment its first request went out, not when the last page landed.
final pulledAt = _clock();
var cursor = await _replica.finishedCursor();
var response = await _account.sync(finishedAfter: cursor);
if (cursor != null &&
Expand All @@ -99,7 +133,7 @@ final class SyncPass {
response,
first: first,
endedSeen: last ? ended : null,
now: _clock(),
pulledAt: pulledAt,
);
if (last) return;
response = await _account.sync(finishedAfter: response.finishedCursor);
Expand Down
3 changes: 2 additions & 1 deletion dart/eigen_client/lib/src/replica/tables.dart
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ class Accounts extends Table {
/// Where older history continues, or null when none remains to fetch.
TextColumn get historyFloor => text().nullable()();

/// When a sync last completed, in epoch milliseconds.
/// How current the replica is, in epoch milliseconds: when the last completed
/// sync began pulling.
IntColumn get lastSyncedAt => integer().nullable()();

@override
Expand Down
51 changes: 39 additions & 12 deletions dart/eigen_client/test/replica/account_replica_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ void main() {
),
first: true,
endedSeen: const {},
now: _now,
pulledAt: _now,
);

final profile = (await replica.watchProfile().first)!;
Expand All @@ -56,13 +56,13 @@ void main() {
accountSync(friends: [friendJson(them)]),
first: true,
endedSeen: const {},
now: _now,
pulledAt: _now,
);
await replica.applySync(
accountSync(),
first: false,
endedSeen: const {},
now: _now,
pulledAt: _now,
);
expect(await replica.watchFriends().first, isEmpty);
},
Expand All @@ -83,7 +83,7 @@ void main() {
),
first: true,
endedSeen: const {},
now: _now,
pulledAt: _now,
);
final active = await replica.watchActiveGames().first;
expect(active.map((game) => game.id), ['my-turn', 'waiting-on-them']);
Expand All @@ -96,33 +96,60 @@ void main() {
accountSync(active: [summaryJson(id: 'left', seq: 2)]),
first: true,
endedSeen: const {},
now: _now,
pulledAt: _now,
);

// A middle page: the game may yet arrive as ended on a later one.
await replica.applySync(
accountSync(hasMore: true),
first: false,
endedSeen: null,
now: _now,
pulledAt: _now,
);
expect(await replica.watchActiveGames().first, hasLength(1));

await replica.applySync(
accountSync(),
first: false,
endedSeen: const {},
now: _now,
pulledAt: _now,
);
expect(await replica.watchActiveGames().first, isEmpty);
});

test('is only as current as its last completed pass', () async {
final earlier = _now.subtract(const Duration(minutes: 5));
await replica.applySync(
accountSync(),
first: true,
endedSeen: const {},
pulledAt: earlier,
);

// A pass still paging has not completed, however recent its first page.
await replica.applySync(
accountSync(hasMore: true),
first: false,
endedSeen: null,
pulledAt: _now,
);
expect(await replica.lastSyncedAt(), earlier);

await replica.applySync(
accountSync(),
first: false,
endedSeen: const {},
pulledAt: _now,
);
expect(await replica.lastSyncedAt(), _now);
});

test('an ended game moves from in play to history', () async {
await replica.applySync(
accountSync(active: [summaryJson(id: 'g', seq: 2)]),
first: true,
endedSeen: const {},
now: _now,
pulledAt: _now,
);
await replica.applySync(
accountSync(
Expand Down Expand Up @@ -151,7 +178,7 @@ void main() {
),
first: false,
endedSeen: const {'g'},
now: _now,
pulledAt: _now,
);
expect(await replica.watchActiveGames().first, isEmpty);
final finished = await replica.watchFinishedGames(limit: 10).first;
Expand All @@ -167,15 +194,15 @@ void main() {
accountSync(floor: 'older', cursor: 3),
first: true,
endedSeen: const {},
now: _now,
pulledAt: _now,
);
expect((await replica.watchHistory().first).hasOlder, isTrue);

await replica.applySync(
accountSync(cursor: 4),
first: false,
endedSeen: const {},
now: _now,
pulledAt: _now,
);
expect(await replica.historyFloor(), 'older');

Expand Down Expand Up @@ -304,7 +331,7 @@ void main() {
accountSync(active: [summaryJson(id: 'online', seq: 2)], cursor: 5),
first: true,
endedSeen: const {},
now: _now,
pulledAt: _now,
);
final engine = await LocalGameEngine.create(
userId: me,
Expand Down
Loading
Loading