refactor!: Rename sync APIs and ownership spaces (#128)
This commit is contained in:
parent
4331daf661
commit
b89725f6ea
8 changed files with 43650 additions and 9417 deletions
|
|
@ -13,8 +13,8 @@ UI code. Every replica follows the same four steps:
|
|||
|
||||
1. **Open a local database.** `client.createSession(path)` opens a Serverpod
|
||||
client database (a local SQLite file) and runs the client migrations.
|
||||
2. **Wrap it for CRDT sync.** `CrdtDatabaseSession.wraps(raw, syncTables: …,
|
||||
persistentUserId: …)`, then `crdtSession.db.initialize()` to establish this
|
||||
2. **Wrap it for CRDT sync.** `OfflineSyncDatabaseSession.wraps(raw, syncTables: …,
|
||||
persistentUserId: …)`, then `offlineSyncSession.db.initialize()` to establish this
|
||||
device's CRDT node so it can take part in sync.
|
||||
3. **Read and write generated models** against that session, e.g.
|
||||
`Person.db.insertRow(session, person)` or `session.db.find<Person>()`. (The
|
||||
|
|
@ -23,8 +23,8 @@ UI code. Every replica follows the same four steps:
|
|||
is demo-only; the `seed*` methods in `lib/src/demo_controller.dart` show the
|
||||
plain form.)
|
||||
4. **Synchronize through a Serverpod client.**
|
||||
`client.crdt.syncOnce(session, onMergeSuccess: …)` for a one-shot push/pull,
|
||||
or `client.crdt.syncContinuously(session, …)` to stream until cancelled.
|
||||
`client.offlineSync.syncOnce(session, onMergeSuccess: …)` for a one-shot push/pull,
|
||||
or `client.offlineSync.syncContinuously(session, …)` to stream until cancelled.
|
||||
|
||||
Connectivity is just *which client you sync through*: the demo swaps
|
||||
`Client.httpClientOverride` between a real transport and a failing one to
|
||||
|
|
@ -40,7 +40,7 @@ view of the synced schema), `demo_view.dart` / `sheets.dart` (widgets), and
|
|||
|
||||
Each demo user owns two replicas (`<user>-a.db`, `<user>-b.db`) shown side by
|
||||
side. A replica is a local SQLite store with its own CRDT node id but the same
|
||||
sync scope; replicas share nothing locally and only converge by syncing through
|
||||
sync space; replicas share nothing locally and only converge by syncing through
|
||||
the server — like two phones on one account (see the "i" button in the toolbar).
|
||||
|
||||
The layout is a **scenario rail**, **Replica A**, **Replica B**, and a **Server**
|
||||
|
|
@ -53,7 +53,7 @@ panel, with a global toolbar on top and a status line at the bottom.
|
|||
- **Show hidden rows** reveals locally hidden / soft-deleted (tombstoned) rows,
|
||||
shown struck-through and muted alongside the visible "user view".
|
||||
- **Refresh** reloads all three panels; **Reset all** wipes both replicas and the
|
||||
server scope at once.
|
||||
server space at once.
|
||||
- The **i** button explains replica isolation.
|
||||
- The app bar carries a light/dark toggle and an avatar menu for switching
|
||||
between or creating demo users.
|
||||
|
|
@ -74,13 +74,13 @@ panel, with a global toolbar on top and a status line at the bottom.
|
|||
|
||||
**Server panel**
|
||||
|
||||
- Shows the server's merged truth for the current scope, fetched through the
|
||||
`demoDebug.fetchScopeSnapshot` endpoint (reads run in the caller's scope via
|
||||
- Shows the server's merged truth for the current space, fetched through the
|
||||
`demoDebug.fetchSpaceSnapshot` endpoint (reads run in the caller's space via
|
||||
`transactionForUser`). It refreshes after each sync and via its own refresh
|
||||
button.
|
||||
- **Reset server scope** hard-clears every row and CRDT metadata record in the
|
||||
scope; the seed buttons write a graph straight into the scope via
|
||||
`demoDebug.seedScope`, so you can reset a replica and sync to exercise the
|
||||
- **Reset server space** hard-clears every row and CRDT metadata record in the
|
||||
space; the seed buttons write a graph straight into the space via
|
||||
`demoDebug.seedSpace`, so you can reset a replica and sync to exercise the
|
||||
fetch-from-scratch flow.
|
||||
|
||||
**Scenario rail**
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ class ReplicaState extends ChangeNotifier {
|
|||
SyncPhase phase = SyncPhase.idle;
|
||||
String? lastSyncedLabel;
|
||||
String? error;
|
||||
offline.CrdtSyncSession? stream;
|
||||
offline.OfflineSyncSubscription? stream;
|
||||
bool busy = false;
|
||||
bool _disposed = false;
|
||||
|
||||
|
|
@ -141,7 +141,7 @@ class DemoController extends ChangeNotifier {
|
|||
final user = selectedUser?.username ?? '<user>';
|
||||
return 'Replica A and B are independent local SQLite stores '
|
||||
'($user-a.db, $user-b.db), each with its own CRDT node id but the '
|
||||
'same sync scope. They share nothing locally and only exchange data '
|
||||
'same sync space. They share nothing locally and only exchange data '
|
||||
'by syncing through the server — exactly like two phones on one '
|
||||
'account.';
|
||||
}
|
||||
|
|
@ -286,8 +286,8 @@ class DemoController extends ChangeNotifier {
|
|||
try {
|
||||
await session.syncOnce(
|
||||
client,
|
||||
onMergeSuccess: (scopeUuid, hlc) =>
|
||||
_handleReplicaMerge(slot, scopeUuid, hlc),
|
||||
onMergeSuccess: (spaceUuid, hlc) =>
|
||||
_handleReplicaMerge(slot, spaceUuid, hlc),
|
||||
);
|
||||
state.phase = SyncPhase.idle;
|
||||
state.lastSyncedLabel = 'synced ${_timeLabel()}';
|
||||
|
|
@ -313,8 +313,8 @@ class DemoController extends ChangeNotifier {
|
|||
() async {
|
||||
final stream = session.syncContinuously(
|
||||
client,
|
||||
onMergeSuccess: (scopeUuid, hlc) =>
|
||||
unawaited(_handleReplicaMerge(slot, scopeUuid, hlc)),
|
||||
onMergeSuccess: (spaceUuid, hlc) =>
|
||||
unawaited(_handleReplicaMerge(slot, spaceUuid, hlc)),
|
||||
);
|
||||
state.stream = stream;
|
||||
state.phase = SyncPhase.streaming;
|
||||
|
|
@ -383,7 +383,7 @@ class DemoController extends ChangeNotifier {
|
|||
server.loading = true;
|
||||
if (notify) server.changed();
|
||||
try {
|
||||
final rows = await client.demoDebug.fetchScopeSnapshot(
|
||||
final rows = await client.demoDebug.fetchSpaceSnapshot(
|
||||
includeHidden: showHidden,
|
||||
);
|
||||
// The server returns a flat list of row models. To flag CRDT-hidden rows
|
||||
|
|
@ -391,7 +391,7 @@ class DemoController extends ChangeNotifier {
|
|||
// used to do internally); skipped entirely when hidden rows aren't shown.
|
||||
final hiddenIds = <String>{};
|
||||
if (showHidden) {
|
||||
final visible = await client.demoDebug.fetchScopeSnapshot(
|
||||
final visible = await client.demoDebug.fetchSpaceSnapshot(
|
||||
includeHidden: false,
|
||||
);
|
||||
final visibleIds = {
|
||||
|
|
@ -422,10 +422,10 @@ class DemoController extends ChangeNotifier {
|
|||
|
||||
Future<void> _handleReplicaMerge(
|
||||
ReplicaSlot slot,
|
||||
offline.UuidValue scopeUuid,
|
||||
offline.UuidValue spaceUuid,
|
||||
Object hlc,
|
||||
) async {
|
||||
replicas[slot]!.lastSyncedLabel = 'merged $scopeUuid at $hlc';
|
||||
replicas[slot]!.lastSyncedLabel = 'merged $spaceUuid at $hlc';
|
||||
await _refreshReplica(slot, notify: false);
|
||||
await _fetchServer(notify: false);
|
||||
replicas[slot]!.changed();
|
||||
|
|
@ -469,7 +469,7 @@ class DemoController extends ChangeNotifier {
|
|||
);
|
||||
}
|
||||
|
||||
/// Wipes both replicas and the server scope at once, concurrently.
|
||||
/// Wipes both replicas and the server space at once, concurrently.
|
||||
Future<void> resetAll() async {
|
||||
await _stopAllStreams();
|
||||
await Future.wait([
|
||||
|
|
@ -479,15 +479,15 @@ class DemoController extends ChangeNotifier {
|
|||
]);
|
||||
}
|
||||
|
||||
/// Hard-clears the caller's server scope, including synced rows and CRDT
|
||||
/// Hard-clears the caller's server space, including synced rows and CRDT
|
||||
/// metadata.
|
||||
Future<void> resetServer() async {
|
||||
if (!online) return;
|
||||
await _runServer('Reset the server scope.', () async {
|
||||
await _runServer('Reset the server space.', () async {
|
||||
await _stopAllStreams();
|
||||
_notifyReplicas();
|
||||
try {
|
||||
await client.demoDebug.resetScope();
|
||||
await client.demoDebug.resetSpace();
|
||||
} catch (error) {
|
||||
server.error = 'reset failed: $error';
|
||||
rethrow;
|
||||
|
|
@ -795,9 +795,9 @@ class DemoController extends ChangeNotifier {
|
|||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
await _runServer('Seeded the server scope ($kind).', () async {
|
||||
await _runServer('Seeded the server space ($kind).', () async {
|
||||
try {
|
||||
await client.demoDebug.seedScope(kind, text);
|
||||
await client.demoDebug.seedSpace(kind, text);
|
||||
} catch (error) {
|
||||
server.error = 'seed failed: $error';
|
||||
rethrow;
|
||||
|
|
@ -912,7 +912,7 @@ class DemoController extends ChangeNotifier {
|
|||
/// since a plain findById only returns visible rows.
|
||||
Future<TableRow<protocol.UuidValue?>?> _findHiddenRow(
|
||||
TableOps ops,
|
||||
offline.CrdtDatabaseSession crdt,
|
||||
offline.OfflineSyncDatabaseSession crdt,
|
||||
protocol.UuidValue id,
|
||||
) async {
|
||||
final all = await ops.findAll(crdt, includeHidden: true);
|
||||
|
|
@ -1249,8 +1249,8 @@ class RowDetail {
|
|||
final Map<String, dynamic> fields;
|
||||
final List<EditableField> editable;
|
||||
|
||||
/// The CRDT scope id stored on the row, if any.
|
||||
String? get scopeId => fields['scopeId']?.toString();
|
||||
/// The CRDT space id stored on the row, if any.
|
||||
String? get spaceId => fields['spaceId']?.toString();
|
||||
}
|
||||
|
||||
final _tableDefinitionsByName = <String, TableDefinition>{
|
||||
|
|
@ -1309,7 +1309,7 @@ bool _isPublicField(String key) {
|
|||
}
|
||||
|
||||
bool _isEditableField(String key) {
|
||||
return _isPublicField(key) && key != 'id' && key != 'scopeId';
|
||||
return _isPublicField(key) && key != 'id' && key != 'spaceId';
|
||||
}
|
||||
|
||||
bool _isEditableColumn(ColumnDefinition column) {
|
||||
|
|
|
|||
|
|
@ -115,7 +115,7 @@ class _Toolbar extends StatelessWidget {
|
|||
),
|
||||
Tooltip(
|
||||
tooltip: (context) => const TooltipContainer(
|
||||
child: Text('Wipe both replicas and the server scope at once'),
|
||||
child: Text('Wipe both replicas and the server space at once'),
|
||||
),
|
||||
child: OutlineButton(
|
||||
onPressed: controller.anyBusy
|
||||
|
|
@ -643,7 +643,7 @@ class _ServerPanel extends StatelessWidget {
|
|||
const Gap(4),
|
||||
Tooltip(
|
||||
tooltip: (context) => const TooltipContainer(
|
||||
child: Text('Reset server scope: clear all rows'),
|
||||
child: Text('Reset server space: clear all rows'),
|
||||
),
|
||||
child: IconButton.ghost(
|
||||
icon: const Icon(Icons.delete_outline, size: 16),
|
||||
|
|
@ -706,7 +706,7 @@ class _ServerSeedButton extends StatelessWidget {
|
|||
Widget build(BuildContext context) {
|
||||
return Tooltip(
|
||||
tooltip: (context) =>
|
||||
const TooltipContainer(child: Text('Seed the server scope directly')),
|
||||
const TooltipContainer(child: Text('Seed the server space directly')),
|
||||
child: IconButton.ghost(
|
||||
icon: const Icon(Icons.add, size: 16),
|
||||
onPressed: enabled ? () => _open(context) : null,
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@ class ScenarioCounter {
|
|||
}
|
||||
}
|
||||
|
||||
/// Normalizes a demo username so the same person maps to one scope.
|
||||
/// Normalizes a demo username so the same person maps to one space.
|
||||
String normalizeUsername(String username) {
|
||||
return username.trim().toLowerCase();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,13 +14,13 @@ import 'package:serverpod_offline_sync_test_client/serverpod_offline_sync_test_c
|
|||
/// understand the package. The four steps are:
|
||||
///
|
||||
/// 1. [open] opens a local SQLite database ([Client.createSession])
|
||||
/// and wraps it in a [CrdtDatabaseSession]. Use
|
||||
/// and wraps it in a [OfflineSyncDatabaseSession]. Use
|
||||
/// [openOrReset] when the file may predate the shipped
|
||||
/// schema. The rest of the app reads and writes generated models through
|
||||
/// [session].
|
||||
/// 2. CRUD happens against [session] with the generated model APIs (see
|
||||
/// the `seed*` methods in `DemoController` for the plain, recommended form,
|
||||
/// e.g. `Person.db.insertRow(crdtSession, person)`).
|
||||
/// e.g. `Person.db.insertRow(offlineSyncSession, person)`).
|
||||
/// 3. [syncOnce] / [syncContinuously] push local changes and merge remote
|
||||
/// ones through a Serverpod [Client].
|
||||
/// 4. [reset] / [close] tear the replica down.
|
||||
|
|
@ -37,7 +37,7 @@ class OfflineReplica {
|
|||
/// 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
|
||||
/// the `includeHiddenRows` where-clause.
|
||||
final CrdtDatabaseSession session;
|
||||
final OfflineSyncDatabaseSession session;
|
||||
|
||||
/// The signed-in user every local write on this replica is attributed to.
|
||||
final UuidValue persistentUserId;
|
||||
|
|
@ -45,7 +45,7 @@ class OfflineReplica {
|
|||
/// Opens [databasePath] through [client] and wraps it for CRDT sync.
|
||||
///
|
||||
/// [Client.createSession] opens the local client database (running
|
||||
/// client migrations); [CrdtDatabaseSession.wraps] layers CRDT
|
||||
/// client migrations); [OfflineSyncDatabaseSession.wraps] layers CRDT
|
||||
/// tracking over it; and `db.initialize()` establishes this device's CRDT
|
||||
/// node so it can take part in sync.
|
||||
static Future<OfflineReplica> open({
|
||||
|
|
@ -57,13 +57,13 @@ class OfflineReplica {
|
|||
databasePath,
|
||||
isDebugMode: kDebugMode,
|
||||
);
|
||||
final crdtSession = CrdtDatabaseSession.wraps(
|
||||
final offlineSyncSession = OfflineSyncDatabaseSession.wraps(
|
||||
rawSession,
|
||||
syncTables: syncTables,
|
||||
persistentUserId: persistentUserId,
|
||||
);
|
||||
await crdtSession.db.initialize();
|
||||
return OfflineReplica._(rawSession, crdtSession, persistentUserId);
|
||||
await offlineSyncSession.db.initialize();
|
||||
return OfflineReplica._(rawSession, offlineSyncSession, persistentUserId);
|
||||
}
|
||||
|
||||
/// Like [open], but deletes [databasePath] and retries once when opening
|
||||
|
|
@ -108,50 +108,50 @@ class OfflineReplica {
|
|||
/// network. The replica's local database is the same either way.
|
||||
Future<void> syncOnce(
|
||||
Client client, {
|
||||
CrdtSyncOnMergeSuccess? onMergeSuccess,
|
||||
OfflineSyncOnMergeSuccess? onMergeSuccess,
|
||||
}) {
|
||||
return client.crdt.syncOnce(session, onMergeSuccess: onMergeSuccess);
|
||||
return client.offlineSync.syncOnce(session, onMergeSuccess: onMergeSuccess);
|
||||
}
|
||||
|
||||
/// Streams changes through [client] until the returned session is cancelled
|
||||
/// (via [CrdtSyncSession.cancel]) or the remote stream closes.
|
||||
CrdtSyncSession syncContinuously(
|
||||
/// (via [OfflineSyncSubscription.cancel]) or the remote stream closes.
|
||||
OfflineSyncSubscription syncContinuously(
|
||||
Client client, {
|
||||
CrdtSyncOnMergeSuccess? onMergeSuccess,
|
||||
OfflineSyncOnMergeSuccess? onMergeSuccess,
|
||||
}) {
|
||||
return client.crdt.syncContinuously(
|
||||
return client.offlineSync.syncContinuously(
|
||||
session,
|
||||
onMergeSuccess: onMergeSuccess,
|
||||
);
|
||||
}
|
||||
|
||||
/// Wipes this replica back to an empty device by deleting its local CRDT
|
||||
/// scope row. Every synced table cascades on `scopeId` -> `crdt_scopes`, so
|
||||
/// space row. Every synced table cascades on `spaceId` -> `offline_sync_spaces`, so
|
||||
/// the domain rows and CRDT metadata go with it — no need to drop and recreate
|
||||
/// the database file. A fresh scope/node is established lazily on the next
|
||||
/// the database file. A fresh space/node is established lazily on the next
|
||||
/// write.
|
||||
///
|
||||
/// The delete runs with `defer_foreign_keys` on: the scopeId cascade fans out
|
||||
/// The delete runs with `defer_foreign_keys` on: the spaceId cascade fans out
|
||||
/// across the CRDT metadata diamond (`crdt_data_rows`/`crdt_data_fields`/
|
||||
/// `crdt_data_tombstone` reference `crdt_nodes` with NO ACTION while both
|
||||
/// sides cascade off `crdt_scopes`), and SQLite's cascade order can
|
||||
/// sides cascade off `offline_sync_spaces`), and SQLite's cascade order can
|
||||
/// transiently violate those immediate checks. Deferring them to commit lets
|
||||
/// the whole cascade complete first.
|
||||
Future<void> reset() async {
|
||||
final scope = await CrdtScope.db.findFirstRow(
|
||||
final space = await OfflineSyncSpace.db.findFirstRow(
|
||||
session,
|
||||
where: (t) => t.uuidScopeId.equals(persistentUserId),
|
||||
where: (t) => t.uuidSpaceId.equals(persistentUserId),
|
||||
);
|
||||
final scopeId = scope?.id;
|
||||
if (scopeId != null) {
|
||||
final spaceId = space?.id;
|
||||
if (spaceId != null) {
|
||||
await session.db.transaction((transaction) async {
|
||||
await session.db.unsafeExecute(
|
||||
'PRAGMA defer_foreign_keys = ON',
|
||||
transaction: transaction,
|
||||
);
|
||||
await CrdtScope.db.deleteWhere(
|
||||
await OfflineSyncSpace.db.deleteWhere(
|
||||
session,
|
||||
where: (t) => t.id.equals(scopeId),
|
||||
where: (t) => t.id.equals(spaceId),
|
||||
transaction: transaction,
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -184,5 +184,5 @@ class RelationshipCatalog {
|
|||
}
|
||||
|
||||
static bool _isSystemColumn(String name) =>
|
||||
name == 'id' || name == 'scopeId' || name.startsWith('_');
|
||||
name == 'id' || name == 'spaceId' || name.startsWith('_');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -225,7 +225,7 @@ class _RowDetailSheetState extends State<_RowDetailSheet> {
|
|||
Text(detail.slot.label).small().muted(),
|
||||
const Gap(12),
|
||||
_kv(context, 'Row UUID', detail.uuid),
|
||||
_kv(context, 'Scope id', detail.scopeId ?? '—'),
|
||||
_kv(context, 'Space id', detail.spaceId ?? '—'),
|
||||
_kv(
|
||||
context,
|
||||
'Visibility',
|
||||
|
|
|
|||
|
|
@ -125,7 +125,7 @@ class DemoSnapshot {
|
|||
/// classifies each row's visibility.
|
||||
static Future<DemoSnapshot> load(
|
||||
RelationshipCatalog catalog,
|
||||
offline.CrdtDatabaseSession crdt,
|
||||
offline.OfflineSyncDatabaseSession crdt,
|
||||
) async {
|
||||
final rows = <RowView>[];
|
||||
for (final table in catalog.tables) {
|
||||
|
|
@ -153,7 +153,7 @@ class DemoSnapshot {
|
|||
}
|
||||
|
||||
/// Builds a snapshot from the server's merged truth. [serverRows] is the flat
|
||||
/// `List<dynamic>` returned by `fetchScopeSnapshot`: each element is a typed
|
||||
/// `List<dynamic>` returned by `fetchSpaceSnapshot`: each element is a typed
|
||||
/// domain model, deserialized by Serverpod from its `__className__` tag, so
|
||||
/// the table name and JSON come straight off the model. Rows whose id is in
|
||||
/// [hiddenIds] are flagged hidden; there is no local CRDT metadata to show.
|
||||
|
|
|
|||
Loading…
Reference in a new issue