Skip to content

Commit 793e01e

Browse files
committed
Tests for partial sync operations
1 parent 7ed6945 commit 793e01e

15 files changed

+591
-118
lines changed

packages/powersync_core/lib/src/database/powersync_db_mixin.dart

Lines changed: 55 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import 'dart:async';
2+
import 'dart:convert';
23

34
import 'package:logging/logging.dart';
45
import 'package:meta/meta.dart';
@@ -121,17 +122,56 @@ mixin PowerSyncDatabaseMixin implements SqliteConnection {
121122

122123
Future<void> _updateHasSynced() async {
123124
// Query the database to see if any data has been synced.
124-
final result =
125-
await database.get('SELECT powersync_last_synced_at() as synced_at');
126-
final timestamp = result['synced_at'] as String?;
127-
final hasSynced = timestamp != null;
128-
129-
if (hasSynced != currentStatus.hasSynced) {
130-
final lastSyncedAt =
131-
timestamp == null ? null : DateTime.parse('${timestamp}Z').toLocal();
132-
final status =
133-
SyncStatus(hasSynced: hasSynced, lastSyncedAt: lastSyncedAt);
134-
setStatus(status);
125+
final result = await database.get('''
126+
SELECT CASE
127+
WHEN EXISTS (SELECT 1 FROM sqlite_master WHERE name = 'ps_sync_state')
128+
THEN (SELECT json_group_array(
129+
json_object('prio', priority, 'last_sync', last_synced_at)
130+
) FROM ps_sync_state ORDER BY priority)
131+
ELSE powersync_last_synced_at()
132+
END AS r;
133+
''');
134+
final value = result['r'] as String?;
135+
final hasData = value != null;
136+
137+
DateTime parseDateTime(String sql) {
138+
return DateTime.parse('${sql}Z').toLocal();
139+
}
140+
141+
if (hasData) {
142+
DateTime? lastCompleteSync;
143+
final priorityStatus = <SyncPriorityStatus>[];
144+
var hasSynced = false;
145+
146+
if (value.startsWith('[')) {
147+
for (final entry in jsonDecode(value) as List) {
148+
final priority = entry['prio'] as int;
149+
final lastSyncedAt = parseDateTime(entry['last_sync'] as String);
150+
151+
if (priority == -1) {
152+
hasSynced = true;
153+
lastCompleteSync = lastSyncedAt;
154+
} else {
155+
priorityStatus.add((
156+
hasSynced: true,
157+
lastSyncedAt: lastSyncedAt,
158+
priority: BucketPriority(priority)
159+
));
160+
}
161+
}
162+
} else {
163+
hasSynced = true;
164+
lastCompleteSync = parseDateTime(value);
165+
}
166+
167+
if (hasSynced != currentStatus.hasSynced) {
168+
final status = SyncStatus(
169+
hasSynced: hasSynced,
170+
lastSyncedAt: lastCompleteSync,
171+
statusInPriority: priorityStatus,
172+
);
173+
setStatus(status);
174+
}
135175
}
136176
}
137177

@@ -201,7 +241,10 @@ mixin PowerSyncDatabaseMixin implements SqliteConnection {
201241
await disconnect();
202242
// Now we can close the database
203243
await database.close();
204-
await statusStreamController.close();
244+
245+
// If there are paused subscriptionso n the status stream, don't delay
246+
// closing the database because of that.
247+
unawaited(statusStreamController.close());
205248
}
206249

207250
/// Connect to the PowerSync service, and keep the databases in sync.

packages/powersync_core/lib/src/streaming_sync.dart

Lines changed: 47 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ class StreamingSyncImplementation implements StreamingSync {
119119

120120
// Now close the client in all cases not covered above
121121
_client.close();
122+
_statusStreamController.close();
122123
}
123124

124125
bool get aborted {
@@ -281,7 +282,7 @@ class StreamingSyncImplementation implements StreamingSync {
281282
for (final (i, priority) in existingPriorityState.indexed) {
282283
switch (
283284
BucketPriority.comparator(priority.priority, completed.priority)) {
284-
case < 0:
285+
case > 0:
285286
// Entries from here on have a higher priority than the one that was
286287
// just completed
287288
final copy = existingPriorityState.toList();
@@ -293,7 +294,7 @@ class StreamingSyncImplementation implements StreamingSync {
293294
copy[i] = completed;
294295
_updateStatus(statusInPriority: copy);
295296
return;
296-
case > 0:
297+
case < 0:
297298
continue;
298299
}
299300
}
@@ -537,49 +538,55 @@ class StreamingSyncImplementation implements StreamingSync {
537538
return true;
538539
}
539540

540-
Stream<StreamingSyncLine?> streamingSyncRequest(
541-
StreamingSyncRequest data) async* {
542-
final credentials = await credentialsCallback();
543-
if (credentials == null) {
544-
throw CredentialsException('Not logged in');
545-
}
546-
final uri = credentials.endpointUri('sync/stream');
547-
548-
final request = http.Request('POST', uri);
549-
request.headers['Content-Type'] = 'application/json';
550-
request.headers['Authorization'] = "Token ${credentials.token}";
551-
request.headers.addAll(_userAgentHeaders);
552-
553-
request.body = convert.jsonEncode(data);
554-
555-
http.StreamedResponse res;
556-
try {
557-
// Do not close the client during the request phase - this causes uncaught errors.
558-
_safeToClose = false;
559-
res = await _client.send(request);
560-
} finally {
561-
_safeToClose = true;
562-
}
563-
if (aborted) {
564-
return;
565-
}
541+
Stream<StreamingSyncLine?> streamingSyncRequest(StreamingSyncRequest data) {
542+
Future<http.ByteStream?> setup() async {
543+
final credentials = await credentialsCallback();
544+
if (credentials == null) {
545+
throw CredentialsException('Not logged in');
546+
}
547+
final uri = credentials.endpointUri('sync/stream');
548+
549+
final request = http.Request('POST', uri);
550+
request.headers['Content-Type'] = 'application/json';
551+
request.headers['Authorization'] = "Token ${credentials.token}";
552+
request.headers.addAll(_userAgentHeaders);
553+
554+
request.body = convert.jsonEncode(data);
555+
556+
http.StreamedResponse res;
557+
try {
558+
// Do not close the client during the request phase - this causes uncaught errors.
559+
_safeToClose = false;
560+
res = await _client.send(request);
561+
} finally {
562+
_safeToClose = true;
563+
}
564+
if (aborted) {
565+
return null;
566+
}
566567

567-
if (res.statusCode == 401) {
568-
if (invalidCredentialsCallback != null) {
569-
await invalidCredentialsCallback!();
568+
if (res.statusCode == 401) {
569+
if (invalidCredentialsCallback != null) {
570+
await invalidCredentialsCallback!();
571+
}
570572
}
571-
}
572-
if (res.statusCode != 200) {
573-
throw await SyncResponseException.fromStreamedResponse(res);
573+
if (res.statusCode != 200) {
574+
throw await SyncResponseException.fromStreamedResponse(res);
575+
}
576+
577+
return res.stream;
574578
}
575579

576-
// Note: The response stream is automatically closed when this loop errors
577-
await for (var line in ndjson(res.stream)) {
578-
if (aborted) {
579-
break;
580+
return Stream.fromFuture(setup()).asyncExpand((stream) {
581+
if (stream == null || aborted) {
582+
return const Stream.empty();
583+
} else {
584+
return ndjson(stream)
585+
.map((line) =>
586+
StreamingSyncLine.fromJson(line as Map<String, dynamic>))
587+
.takeWhile((_) => !aborted);
580588
}
581-
yield StreamingSyncLine.fromJson(line as Map<String, dynamic>);
582-
}
589+
});
583590
}
584591

585592
/// Delays the standard `retryDelay` Duration, but exits early if

packages/powersync_core/lib/src/sync_status.dart

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,15 @@ final class SyncStatus {
9999

100100
/// Returns [lastSyncedAt] and [hasSynced] information for a partial sync
101101
/// operation, or `null` if the status for that priority is unknown.
102-
SyncPriorityStatus? statusForPriority(BucketPriority priority) {
102+
///
103+
/// The information returned may be more generic than requested. For instance,
104+
/// a completed sync operation (as expressed by [lastSyncedAt]) also
105+
/// guarantees that every bucket priority was synchronized before that.
106+
/// Similarly, requesting the sync status for priority `1` may return
107+
/// information extracted from the lower priority `2` since each partial sync
108+
/// in priority `2` necessarily includes a consistent view over data in
109+
/// priority `1`.
110+
SyncPriorityStatus statusForPriority(BucketPriority priority) {
103111
assert(statusInPriority.isSortedByCompare(
104112
(e) => e.priority, BucketPriority.comparator));
105113

@@ -112,7 +120,12 @@ final class SyncStatus {
112120
}
113121
}
114122

115-
return null;
123+
// If we have a complete sync, that necessarily includes all priorities.
124+
return (
125+
priority: priority,
126+
hasSynced: hasSynced,
127+
lastSyncedAt: lastSyncedAt
128+
);
116129
}
117130

118131
@override
@@ -154,8 +167,8 @@ extension type const BucketPriority._(int priorityNumber) {
154167
/// priority.
155168
typedef SyncPriorityStatus = ({
156169
BucketPriority priority,
157-
DateTime lastSyncedAt,
158-
bool hasSynced,
170+
DateTime? lastSyncedAt,
171+
bool? hasSynced,
159172
});
160173

161174
/// Stats of the local upload queue.

packages/powersync_core/lib/src/sync_types.dart

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,11 @@ final class Checkpoint extends StreamingSyncLine {
5858
'write_checkpoint': writeCheckpoint,
5959
'buckets': checksums
6060
.where((c) => priority == null || c.priority <= priority)
61-
.map((c) => {'bucket': c.bucket, 'checksum': c.checksum})
61+
.map((c) => {
62+
'bucket': c.bucket,
63+
'checksum': c.checksum,
64+
'priority': c.priority,
65+
})
6266
.toList(growable: false)
6367
};
6468
}

packages/powersync_core/test/connected_test.dart

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import 'package:powersync_core/powersync_core.dart';
99
import 'package:test/test.dart';
1010

1111
import 'server/sync_server/mock_sync_server.dart';
12-
import 'streaming_sync_test.dart';
1312
import 'utils/abstract_test_utils.dart';
1413
import 'utils/test_utils_impl.dart';
1514

packages/powersync_core/test/disconnect_test.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import 'package:powersync_core/powersync_core.dart';
22
import 'package:powersync_core/sqlite_async.dart';
33
import 'package:test/test.dart';
4-
import 'streaming_sync_test.dart';
4+
import 'utils/abstract_test_utils.dart';
55
import 'utils/test_utils_impl.dart';
66
import 'watch_test.dart';
77

0 commit comments

Comments
 (0)