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

wger-project / flutter / 30367266598

28 Jul 2026 02:12PM UTC coverage: 55.599%. Remained the same
30367266598

Pull #1281

github

rolandgeider
Merge remote-tracking branch 'origin/master' into dependabot/pub/powersync-2.3.2
Pull Request #1281: Bump powersync from 2.3.1 to 2.3.2

0 of 1 new or added line in 1 file covered. (0.0%)

12492 of 22468 relevant lines covered (55.6%)

6.8 hits per line

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

22.03
/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;
11✔
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) {
×
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
final _syncStatusInternal = StreamProvider<SyncStatus?>((ref) {
×
196
  return Stream.fromFuture(
×
197
    ref.watch(powerSyncInstanceProvider.future),
×
198
  ).asyncExpand<SyncStatus?>((db) => db.statusStream).startWith(null);
×
199
});
200

201
final syncStatus = Provider((ref) {
×
202
  // ignore: invalid_use_of_internal_member
NEW
203
  return ref.watch(_syncStatusInternal).value ?? const SyncStatus.uninitialized();
×
204
});
205

206
Future<String> _getDatabasePath() async {
13✔
207
  const dbFilename = 'powersync-wger.db';
208

209
  // getApplicationSupportDirectory is not supported on Web
210
  if (kIsWeb) {
211
    return dbFilename;
212
  }
213
  final dir = await getApplicationSupportDirectory();
13✔
214
  return join(dir.path, dbFilename);
8✔
215
}
216

217
/// Deletes the on-disk PowerSync SQLite files (main DB plus WAL/SHM/journal
218
/// sidecars). Used to purge a previous user's data on login as a different
219
/// user when [powerSyncInstanceProvider] has not been built yet, so the
220
/// usual `disconnectAndClear()` route is unavailable.
221
///
222
/// Best-effort: missing files are treated as success, individual delete
223
/// failures are logged and swallowed so a single locked sidecar doesn't
224
/// block the rest. On web the database is backed by IndexedDB rather than
225
/// a real file, so this is a no-op there.
226
Future<void> deletePowerSyncDatabaseFile() async {
4✔
227
  if (kIsWeb) {
228
    _logger.warning('deletePowerSyncDatabaseFile: not supported on web, skipping');
×
229
    return;
230
  }
231
  final path = await _getDatabasePath();
4✔
232
  for (final suffix in ['', '-wal', '-shm', '-journal']) {
8✔
233
    final file = File('$path$suffix');
8✔
234
    try {
235
      if (file.existsSync()) {
4✔
236
        await file.delete();
1✔
237
      }
238
    } catch (e, s) {
239
      _logger.warning('deletePowerSyncDatabaseFile: failed to delete ${file.path}', e, s);
×
240
    }
241
  }
242
}
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