• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

wger-project / flutter / 30466069382

29 Jul 2026 03:28PM UTC coverage: 55.621%. First build
30466069382

Pull #1292

github

rolandgeider
Cleanup
Pull Request #1292: Improve sync logging and reporting

120 of 177 new or added lines in 8 files covered. (67.8%)

12558 of 22578 relevant lines covered (55.62%)

6.79 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

25.4
/lib/database/powersync/powersync.dart
1
/*
2
 * This file is part of wger Workout Manager <https://github.com/wger-project>.
3
 * Copyright (c)  2026 wger Team
4
 *
5
 * wger Workout Manager is free software: you can redistribute it and/or modify
6
 * it under the terms of the GNU Affero General Public License as published by
7
 * the Free Software Foundation, either version 3 of the License, or
8
 * (at your option) any later version.
9
 *
10
 * This program is distributed in the hope that it will be useful,
11
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13
 * GNU Affero General Public License for more details.
14
 *
15
 * You should have received a copy of the GNU Affero General Public License
16
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
17
 */
18

19
import 'dart:io';
20

21
import 'package:flutter/foundation.dart';
22
import 'package:flutter_riverpod/flutter_riverpod.dart';
23
import 'package:http/http.dart' as http;
24
import 'package:logging/logging.dart';
25
import 'package:path/path.dart';
26
import 'package:path_provider/path_provider.dart';
27
import 'package:powersync/powersync.dart';
28
import 'package:riverpod_annotation/riverpod_annotation.dart';
29
import 'package:stream_transform/stream_transform.dart';
30
import 'package:wger/core/network/auth_http_client.dart';
31
import 'package:wger/core/network/network_provider.dart';
32
import 'package:wger/core/network/wger_base.dart';
33
import 'package:wger/powersync/api_client.dart';
34
import 'package:wger/powersync/connector.dart';
35
import 'package:wger/powersync/schema.dart';
36
import 'package:wger/powersync/sync_watchdog.dart';
37

38
part 'powersync.g.dart';
39

40
final _logger = Logger('powersync');
×
41

42
PowerSyncDatabase? _builtInstance;
43

44
/// The PowerSync database once [powerSyncInstanceProvider] has finished
45
/// building, otherwise null.
46
///
47
/// Synchronous, build-free accessor for callers that should act on the DB only
48
/// when it already exists (the auth notifier's session-reset paths). Awaiting
49
/// [powerSyncInstanceProvider] through `ref` would instead be async and would
50
/// build the DB on demand, the opposite of the no-op-when-absent behaviour
51
/// those paths need.
52
PowerSyncDatabase? get builtPowerSyncInstance => _builtInstance;
12✔
53

54
/// Watches the sync status for a stream that keeps reconnecting without ever
55
/// delivering data (e.g. blocked by a VPN or firewall) and flags it, see
56
/// [SyncStreamWatchdog]. Fed from the DB's status stream by
57
/// [powerSyncInstanceProvider].
58
final syncWatchdogProvider = Provider<SyncStreamWatchdog>((ref) {
12✔
59
  final watchdog = SyncStreamWatchdog();
×
60
  ref.onDispose(watchdog.dispose);
×
61
  return watchdog;
62
});
63

64
@Riverpod(keepAlive: true)
9✔
65
Future<PowerSyncDatabase> powerSyncInstance(Ref ref) async {
66
  final db = PowerSyncDatabase(
×
67
    schema: schema,
9✔
68
    path: await _getDatabasePath(),
9✔
69
    logger: attachedLogger,
×
70
  );
71
  await db.initialize();
×
72
  await _createRawTables(db);
×
73
  _builtInstance = db;
74

75
  final client = ref.read(authenticatedHttpClientProvider);
×
76
  final watchdog = ref.read(syncWatchdogProvider);
×
77
  final statusSubscription = db.statusStream.listen(watchdog.onStatus);
×
78

79
  // Connect to the sync service only while the device is online. PowerSync's
80
  // own retry loop would otherwise log a credential error on every iteration
81
  // against an unreachable backend
82
  void syncConnection(bool isOnline) {
×
83
    if (isOnline) {
84
      final serverUrl = ref.read(wgerBaseProvider).serverUrl;
×
85
      if (serverUrl != null) {
86
        connectPowerSync(db, serverUrl, client);
×
87
      }
88
    } else {
89
      db.disconnect();
×
90
      // Deliberate disconnect: an offline device is not a blocked stream.
91
      watchdog.reset();
×
92
    }
93
  }
94

95
  syncConnection(ref.read(networkStatusProvider));
×
96
  ref.listen(networkStatusProvider, (_, isOnline) => syncConnection(isOnline));
×
97

98
  ref.onDispose(() {
×
99
    _builtInstance = null;
100
    statusSubscription.cancel();
×
101
    watchdog.reset();
×
102
    db.close();
×
103
  });
104

105
  return db;
106
}
107

108
/// Materialise the native SQLite tables for the raw entries declared in
109
/// [schema]. PowerSync only manages the JSON view tables itself, so any
110
/// `RawTable` entry must have its `CREATE TABLE`/`CREATE INDEX` statements
111
/// applied by us.
112
///
113
/// Skips all work when the raw tables already exist, so warm restarts don't
114
/// take a write lock on the DB.
115
Future<void> _createRawTables(PowerSyncDatabase db) async {
×
116
  // Not STRICT: keep SQLite's type affinity behaviour so PowerSync's
117
  // inferred inserts can bind values as they arrive from the JSON wire
118
  // protocol without us having to coerce types up front.
119
  //
120
  // `id` is INTEGER, not the PowerSync-conventional TEXT, so it matches the
121
  // Drift `IntColumn id` and the integer `exercise_id` FK: the catalogue join
122
  // is then native INTEGER == INTEGER instead of relying on TEXT-vs-INTEGER
123
  // affinity coercion. PowerSync's string oplog id coerces to INTEGER on insert
124
  // (safe: Django exercise PKs are always numeric).
125
  const rawTables = ['exercises_exercise', 'exercises_translation'];
126
  final existing = await db.getAll(
×
127
    'SELECT name FROM sqlite_master '
128
    'WHERE type = ? AND name IN (${rawTables.map((_) => '?').join(', ')})',
×
129
    ['table', ...rawTables],
×
130
  );
131

132
  if (existing.length == rawTables.length) {
×
133
    return;
134
  }
135

136
  await db.writeTransaction((tx) async {
×
137
    await tx.execute('''
×
138
      CREATE TABLE IF NOT EXISTS exercises_exercise(
139
        id INTEGER NOT NULL PRIMARY KEY,
140
        uuid TEXT,
141
        category_id INTEGER,
142
        variation_group TEXT,
143
        created TEXT,
144
        last_update TEXT
145
      )
146
    ''');
147
    await tx.execute(
×
148
      'CREATE INDEX IF NOT EXISTS exercises_exercise__category '
149
      'ON exercises_exercise(category_id)',
150
    );
151
    await tx.execute(
×
152
      'CREATE INDEX IF NOT EXISTS exercises_exercise__variation '
153
      'ON exercises_exercise(variation_group)',
154
    );
155

156
    await tx.execute('''
×
157
      CREATE TABLE IF NOT EXISTS exercises_translation(
158
        id INTEGER NOT NULL PRIMARY KEY,
159
        uuid TEXT,
160
        language_id INTEGER,
161
        exercise_id INTEGER,
162
        description TEXT,
163
        name TEXT,
164
        created TEXT,
165
        last_update TEXT
166
      )
167
    ''');
168
    await tx.execute(
×
169
      'CREATE INDEX IF NOT EXISTS exercises_translation__language '
170
      'ON exercises_translation(language_id)',
171
    );
172
    await tx.execute(
×
173
      'CREATE INDEX IF NOT EXISTS exercises_translation__exercise '
174
      'ON exercises_translation(exercise_id)',
175
    );
176
  });
177
}
178

179
/// Creates a fresh [DjangoConnector] for [baseUrl] and connects [db] to it.
180
/// Used both at initial creation and after a logout/login cycle to pick up
181
/// the new user's server URL / credentials. [client] is the authenticated
182
/// HTTP client (see [authenticatedHttpClientProvider]); the connector reuses
183
/// it for its REST calls so the same `Authorization` injection and
184
/// pre-emptive refresh apply.
185
void connectPowerSync(PowerSyncDatabase db, String baseUrl, http.Client client) {
×
186
  db.connect(
×
187
    connector: DjangoConnector(
×
188
      baseUrl: baseUrl,
189
      apiClient: ApiClient(baseUrl, client: client),
×
190
      client: client,
191
    ),
192
  );
193
}
194

195
/// Number of local changes still waiting in the upload queue. Emits again
196
/// whenever the queue changes, so consumers can show a live count.
197
final pendingUploadCountProvider = StreamProvider.autoDispose<int>((ref) async* {
12✔
NEW
198
  final db = await ref.watch(powerSyncInstanceProvider.future);
×
199
  yield* db
NEW
200
      .watch('SELECT count(*) AS count FROM ps_crud', triggerOnTables: ['ps_crud'])
×
NEW
201
      .map((rows) => rows.first['count'] as int);
×
202
});
203

204
final _syncStatusInternal = StreamProvider<SyncStatus?>((ref) {
×
205
  return Stream.fromFuture(
×
206
    ref.watch(powerSyncInstanceProvider.future),
×
207
  ).asyncExpand<SyncStatus?>((db) => db.statusStream).startWith(null);
×
208
});
209

210
final syncStatus = Provider((ref) {
12✔
211
  // ignore: invalid_use_of_internal_member
212
  return ref.watch(_syncStatusInternal).value ?? const SyncStatus.uninitialized();
×
213
});
214

215
Future<String> _getDatabasePath() async {
13✔
216
  const dbFilename = 'powersync-wger.db';
217

218
  // getApplicationSupportDirectory is not supported on Web
219
  if (kIsWeb) {
220
    return dbFilename;
221
  }
222
  final dir = await getApplicationSupportDirectory();
13✔
223
  return join(dir.path, dbFilename);
8✔
224
}
225

226
/// Deletes the on-disk PowerSync SQLite files (main DB plus WAL/SHM/journal
227
/// sidecars). Used to purge a previous user's data on login as a different
228
/// user when [powerSyncInstanceProvider] has not been built yet, so the
229
/// usual `disconnectAndClear()` route is unavailable.
230
///
231
/// Best-effort: missing files are treated as success, individual delete
232
/// failures are logged and swallowed so a single locked sidecar doesn't
233
/// block the rest. On web the database is backed by IndexedDB rather than
234
/// a real file, so this is a no-op there.
235
Future<void> deletePowerSyncDatabaseFile() async {
4✔
236
  if (kIsWeb) {
237
    _logger.warning('deletePowerSyncDatabaseFile: not supported on web, skipping');
×
238
    return;
239
  }
240
  final path = await _getDatabasePath();
4✔
241
  for (final suffix in ['', '-wal', '-shm', '-journal']) {
8✔
242
    final file = File('$path$suffix');
8✔
243
    try {
244
      if (file.existsSync()) {
4✔
245
        await file.delete();
1✔
246
      }
247
    } catch (e, s) {
248
      _logger.warning('deletePowerSyncDatabaseFile: failed to delete ${file.path}', e, s);
×
249
    }
250
  }
251
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc