fix: Close generated sync sessions and expose the shared demo (#135)

This commit is contained in:
Marcelo Mendonça Soares 2026-09-16 11:40:42 -03:00 • committed by GitHub
commit 0f4b8116f4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 266 additions and 35 deletions

View file

@ -1,6 +1,7 @@
## Unreleased ## Unreleased
- fix: Keep non-synced `updateRow` calls in the test harness transaction. - fix: Keep non-synced `updateRow` calls in the test harness transaction.
- fix: Allow generated client sync sessions to close their SQLite connections.
- fix: Resolve shared-package models and enums during projection and sync. - fix: Resolve shared-package models and enums during projection and sync.
- fix: Preserve boolean types when syncing SQLite column updates. - fix: Preserve boolean types when syncing SQLite column updates.
- fix: Preserve JSON and JSONB field types and values through inserts, updates, - fix: Preserve JSON and JSONB field types and values through inserts, updates,

View file

@ -175,6 +175,14 @@ await client.offlineSync.syncOnce(session);
final syncSession = client.offlineSync.syncContinuously(session); final syncSession = client.offlineSync.syncContinuously(session);
``` ```
When disposing a client replica, cancel its sync subscription before closing
the local database:
```dart
await syncSession.cancel();
await session.close();
```
## Usage ## Usage
Once wired up, use normal generated-model CRUD against the `session` instance, Once wired up, use normal generated-model CRUD against the `session` instance,

View file

@ -9,23 +9,25 @@ them converge.
If you are here to learn the package, read **`lib/src/offline_replica.dart`** If you are here to learn the package, read **`lib/src/offline_replica.dart`**
first — it is the entire integration in ~60 lines, deliberately free of demo and first — it is the entire integration in ~60 lines, deliberately free of demo and
UI code. Every replica follows the same four steps: UI code. Every replica follows the same three steps:
1. **Open a local database.** `client.createSession(path)` opens a Serverpod 1. **Open a sync session.** The generated
client database (a local SQLite file) and runs the client migrations. `client.createSyncSession(path, isDebugMode: kDebugMode, persistentUserId: …)`
2. **Wrap it for CRDT sync.** `OfflineSyncDatabaseSession.wraps(raw, syncTables: …, opens the local SQLite database, runs client migrations, wraps the generated
persistentUserId: …)`, then `offlineSyncSession.db.initialize()` to establish this sync tables, and initializes CRDT tracking.
device's CRDT node so it can take part in sync. 2. **Read and write generated models** against that session, e.g.
3. **Read and write generated models** against that session, e.g.
`Person.db.insertRow(session, person)` or `session.db.find<Person>()`. (The `Person.db.insertRow(session, person)` or `session.db.find<Person>()`. (The
demo also routes these through a generic `TableOps` registry so its demo also routes these through a generic `TableOps` registry so its
metadata-driven UI can touch any table without a per-type switch — that part metadata-driven UI can touch any table without a per-type switch — that part
is demo-only; the `seed*` methods in `lib/src/demo_controller.dart` show the is demo-only; the `seed*` methods in `lib/src/demo_controller.dart` show the
plain form.) plain form.)
4. **Synchronize through a Serverpod client.** 3. **Synchronize through a Serverpod client.**
`client.offlineSync.syncOnce(session, onMergeSuccess: …)` for a one-shot push/pull, `client.offlineSync.syncOnce(session, onMergeSuccess: …)` for a one-shot push/pull,
or `client.offlineSync.syncContinuously(session, …)` to stream until cancelled. or `client.offlineSync.syncContinuously(session, …)` to stream until cancelled.
When disposing a replica, cancel its sync subscription and call `session.close()`
to release the local database connection.
Connectivity is just *which client you sync through*: the demo swaps Connectivity is just *which client you sync through*: the demo swaps
`Client.httpClientOverride` between a real transport and a failing one to `Client.httpClientOverride` between a real transport and a failing one to
simulate going offline — the local database is untouched either way. simulate going offline — the local database is untouched either way.

View file

@ -191,10 +191,7 @@ class DemoController extends ChangeNotifier {
void dispose() { void dispose() {
_disposed = true; _disposed = true;
stopScenarioAutoPlay(); stopScenarioAutoPlay();
unawaited(_stopAllStreams()); unawaited(_closeSessions());
for (final replica in _sessions.values) {
unawaited(replica.close());
}
for (final state in replicas.values) { for (final state in replicas.values) {
state.dispose(); state.dispose();
} }
@ -202,6 +199,14 @@ class DemoController extends ChangeNotifier {
super.dispose(); super.dispose();
} }
Future<void> _closeSessions() async {
try {
await _stopAllStreams();
} finally {
await Future.wait(_sessions.values.map((replica) => replica.close()));
}
}
@override @override
void notifyListeners() { void notifyListeners() {
if (_disposed) return; if (_disposed) return;

View file

@ -2,8 +2,6 @@ import 'dart:io';
import 'package:flutter/foundation.dart' show debugPrint, kDebugMode; import 'package:flutter/foundation.dart' show debugPrint, kDebugMode;
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:serverpod_database/serverpod_database.dart'
show ClientDatabaseSession;
import 'package:serverpod_offline_sync_client/serverpod_offline_sync_client.dart'; import 'package:serverpod_offline_sync_client/serverpod_offline_sync_client.dart';
import 'package:serverpod_offline_sync_test_client/serverpod_offline_sync_test_client.dart'; import 'package:serverpod_offline_sync_test_client/serverpod_offline_sync_test_client.dart';
@ -13,8 +11,8 @@ import 'package:serverpod_offline_sync_test_client/serverpod_offline_sync_test_c
/// This class is the entire surface a real integration needs — read it first to /// This class is the entire surface a real integration needs — read it first to
/// understand the package. The four steps are: /// understand the package. The four steps are:
/// ///
/// 1. [open] opens a local SQLite database ([Client.createSession]) /// 1. [open] uses [Client.createSyncSession] to open and initialize a local
/// and wraps it in a [OfflineSyncDatabaseSession]. Use /// SQLite database as an [OfflineSyncDatabaseSession]. Use
/// [openOrReset] when the file may predate the shipped /// [openOrReset] when the file may predate the shipped
/// schema. The rest of the app reads and writes generated models through /// schema. The rest of the app reads and writes generated models through
/// [session]. /// [session].
@ -23,16 +21,13 @@ import 'package:serverpod_offline_sync_test_client/serverpod_offline_sync_test_c
/// e.g. `Person.db.insertRow(offlineSyncSession, person)`). /// e.g. `Person.db.insertRow(offlineSyncSession, person)`).
/// 3. [syncOnce] / [syncContinuously] push local changes and merge remote /// 3. [syncOnce] / [syncContinuously] push local changes and merge remote
/// ones through a Serverpod [Client]. /// ones through a Serverpod [Client].
/// 4. [reset] / [close] tear the replica down. /// 4. [reset] clears local data; [close] releases the database connection.
/// ///
/// It deliberately holds **no UI state**: the demo's `DemoController` layers /// It deliberately holds **no UI state**: the demo's `DemoController` layers
/// busy/error/projection bookkeeping on top of these calls so that this file /// busy/error/projection bookkeeping on top of these calls so that this file
/// stays a clean reference for the package itself. /// stays a clean reference for the package itself.
class OfflineReplica { class OfflineReplica {
OfflineReplica._(this._rawSession, this.session, this.persistentUserId); OfflineReplica._(this.session, this.persistentUserId);
/// Owns the underlying SQLite connection; closed by [close].
final ClientDatabaseSession _rawSession;
/// CRDT-aware session used for every model read and write. By default it only /// CRDT-aware session used for every model read and write. By default it only
/// exposes visible (non-tombstoned) rows; queries opt into hidden rows with /// exposes visible (non-tombstoned) rows; queries opt into hidden rows with
@ -44,26 +39,19 @@ class OfflineReplica {
/// Opens [databasePath] through [client] and wraps it for CRDT sync. /// Opens [databasePath] through [client] and wraps it for CRDT sync.
/// ///
/// [Client.createSession] opens the local client database (running /// [Client.createSyncSession] runs client migrations, wraps the database for
/// client migrations); [OfflineSyncDatabaseSession.wraps] layers CRDT /// the generated sync tables, and initializes CRDT tracking for this user.
/// tracking over it; and `db.initialize()` establishes this device's CRDT
/// node so it can take part in sync.
static Future<OfflineReplica> open({ static Future<OfflineReplica> open({
required Client client, required Client client,
required String databasePath, required String databasePath,
required UuidValue persistentUserId, required UuidValue persistentUserId,
}) async { }) async {
final rawSession = await client.createSession( final session = await client.createSyncSession(
databasePath, databasePath,
isDebugMode: kDebugMode, isDebugMode: kDebugMode,
);
final offlineSyncSession = OfflineSyncDatabaseSession.wraps(
rawSession,
syncTables: syncTables,
persistentUserId: persistentUserId, persistentUserId: persistentUserId,
); );
await offlineSyncSession.db.initialize(); return OfflineReplica._(session, persistentUserId);
return OfflineReplica._(rawSession, offlineSyncSession, persistentUserId);
} }
/// Like [open], but deletes [databasePath] and retries once when opening /// Like [open], but deletes [databasePath] and retries once when opening
@ -160,5 +148,5 @@ class OfflineReplica {
} }
/// Closes the underlying database connection. /// Closes the underlying database connection.
Future<void> close() => _rawSession.close(); Future<void> close() => session.close();
} }

View file

@ -0,0 +1,12 @@
# Example
The [Offline Sync Demo](https://github.com/marcelomendoncasoares/serverpod_offline_sync/tree/main/example/offline_sync_demo)
is the shared example app for `serverpod_offline_sync`,
`serverpod_offline_sync_client`, and `serverpod_offline_sync_server`.
The desktop Flutter app shows two independent local replicas alongside the
server's merged data, so you can edit offline, sync each replica, and watch them
converge. See the demo's README for setup and running instructions.
For the core client integration, start with
[`offline_replica.dart`](https://github.com/marcelomendoncasoares/serverpod_offline_sync/blob/main/example/offline_sync_demo/lib/src/offline_replica.dart).

View file

@ -66,9 +66,32 @@ class OfflineSyncDatabaseSession implements DatabaseSession {
syncBatchSize: syncBatchSize, syncBatchSize: syncBatchSize,
continuousSyncInterval: continuousSyncInterval, continuousSyncInterval: continuousSyncInterval,
persistentUserId: persistentUserId, persistentUserId: persistentUserId,
); ).._wrappedSession = session;
final OfflineSyncDatabase _db; final OfflineSyncDatabase _db;
DatabaseSession? _wrappedSession;
Future<void>? _closeFuture;
/// Closes the underlying client database.
///
/// Supported for sessions created by [OfflineSyncDatabaseSession.wraps] around
/// a [ClientDatabaseSession], including nested sync-session wrappers and the
/// sessions returned by the generated client's `createSyncSession` method.
/// Repeated or concurrent calls share the same close operation.
///
/// Throws [UnsupportedError] for sessions constructed directly from a
/// [Database] or wrapping a non-client session. Those connections must be
/// closed through their owner, such as the server.
Future<void> close() => _closeFuture ??= switch (_wrappedSession) {
final ClientDatabaseSession session => session.close(),
final OfflineSyncDatabaseSession session => session.close(),
_ => Future<void>.error(
UnsupportedError(
'Only sync sessions wrapping a ClientDatabaseSession can be closed. '
'Close the underlying database through its owner.',
),
),
};
@override @override
OfflineSyncDatabase get db => _db; OfflineSyncDatabase get db => _db;

View file

@ -0,0 +1 @@
../../serverpod_offline_sync/example/README.md

View file

@ -0,0 +1 @@
../../serverpod_offline_sync/example/README.md

View file

@ -0,0 +1,175 @@
import 'dart:io';
import 'package:path/path.dart' as p;
import 'package:serverpod_database/serverpod_database.dart' show ClientDatabaseSession;
import 'package:serverpod_offline_sync_client/serverpod_offline_sync_client.dart';
import 'package:serverpod_offline_sync_test_client/serverpod_offline_sync_test_client.dart';
import 'package:test/test.dart';
void main() {
late Directory directory;
late Client client;
final userId = UuidValue.fromString('00000000-0000-4000-8000-000000000001');
setUpAll(() async {
directory = await Directory.systemTemp.createTemp('sync_session_lifecycle_');
client = Client('http://localhost:8080/');
});
tearDownAll(() async {
client.close();
await directory.delete(recursive: true);
});
group('Given a generated sync session with a persisted Person,', () {
late String path;
late OfflineSyncDatabaseSession session;
late Person person;
late UuidValue nodeId;
setUpAll(() async {
path = p.join(directory.path, 'reopened.db');
session = await client.createSyncSession(path, persistentUserId: userId);
addTearDown(session.close);
person = await Person.db.insertRow(session, Person(name: 'Alice'));
nodeId = await session.db.currentNodeId();
});
group('when it is closed and the database is reopened,', () {
late OfflineSyncDatabaseSession reopened;
setUpAll(() async {
await session.close();
reopened = await client.createSyncSession(path, persistentUserId: userId);
addTearDown(reopened.close);
});
test('then the closed session rejects database queries.', () async {
await expectLater(session.db.testConnection(), throwsStateError);
});
test('then the stored Person is available in the reopened session.', () async {
final loaded = await Person.db.findById(reopened, person.id!);
expect(loaded?.name, 'Alice');
expect(loaded?.spaceId, person.spaceId);
});
test('then the replica keeps its CRDT node identity.', () async {
expect(await reopened.db.currentNodeId(), nodeId);
});
});
});
group('Given a generated sync session,', () {
late OfflineSyncDatabaseSession session;
setUpAll(() async {
session = await client.createSyncSession(
p.join(directory.path, 'repeated.db'),
persistentUserId: userId,
);
addTearDown(session.close);
});
group('when it is closed concurrently and closed again afterwards,', () {
setUpAll(() async {
await Future.wait([session.close(), session.close()]);
await session.close();
});
test('then every close completes and the database stays closed.', () async {
await expectLater(session.db.testConnection(), throwsStateError);
});
});
});
group('Given two generated sync sessions for different replica files,', () {
late OfflineSyncDatabaseSession first;
late OfflineSyncDatabaseSession second;
setUpAll(() async {
first = await client.createSyncSession(
p.join(directory.path, 'first.db'),
persistentUserId: userId,
);
addTearDown(first.close);
second = await client.createSyncSession(
p.join(directory.path, 'second.db'),
persistentUserId: userId,
);
addTearDown(second.close);
});
group('when one session is closed and a Person is written to the other,', () {
late Person person;
setUpAll(() async {
await first.close();
person = await Person.db.insertRow(second, Person(name: 'Bob'));
});
test('then the other replica can still read the persisted Person.', () async {
expect((await Person.db.findById(second, person.id!))?.name, 'Bob');
});
});
});
group('Given a generated sync session wrapped in another sync session,', () {
late OfflineSyncDatabaseSession session;
late OfflineSyncDatabaseSession outer;
setUpAll(() async {
session = await client.createSyncSession(
p.join(directory.path, 'nested.db'),
persistentUserId: userId,
);
addTearDown(session.close);
outer = OfflineSyncDatabaseSession.wraps(session, syncTables: syncTables);
});
group('when the outer session is closed,', () {
setUpAll(() async {
await outer.close();
});
test('then the underlying client database is closed.', () async {
await expectLater(session.db.testConnection(), throwsStateError);
});
});
});
group(
'Given a sync session constructed directly from an externally owned database,',
() {
late ClientDatabaseSession owner;
late OfflineSyncDatabaseSession session;
setUpAll(() async {
owner = await client.createSession(p.join(directory.path, 'external.db'));
addTearDown(owner.close);
session = OfflineSyncDatabaseSession(owner.db, syncTables: syncTables);
});
group('when closing the sync session is attempted,', () {
Object? closeError;
setUpAll(() async {
try {
await session.close();
} on Object catch (error) {
closeError = error;
}
});
test(
'then it rejects the operation and leaves the owner connection open.',
() async {
expect(closeError, isUnsupportedError);
expect(await owner.db.testConnection(), isTrue);
},
);
});
},
);
}

View file

@ -40,6 +40,21 @@ void main() {
expect(session.offlineSync, isNotNull); expect(session.offlineSync, isNotNull);
}, },
); );
test(
'when closing a sync wrapper around a server session is attempted, '
'then it rejects the operation and leaves the server connection open.',
() async {
final wrapped = OfflineSyncDatabaseSession.wraps(
session,
syncTables: syncTables,
);
await expectLater(wrapped.close(), throwsUnsupportedError);
expect(await session.db.testConnection(), isTrue);
},
);
}, },
); );