feat: Implement merge changes method (#38)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Marcelo Soares <marcelo.me.soares@gmail.com>
This commit is contained in:
parent
0307094d58
commit
d04a56520c
28 changed files with 1752 additions and 105 deletions
|
|
@ -11,7 +11,7 @@ dependencies:
|
||||||
benchmark_harness: ^2.4.0
|
benchmark_harness: ^2.4.0
|
||||||
intl: ^0.20.2
|
intl: ^0.20.2
|
||||||
path: ^1.9.1
|
path: ^1.9.1
|
||||||
serverpod_database: 3.5.0-beta.5
|
serverpod_database: 3.5.0-beta.6
|
||||||
serverpod_offline_sync_server:
|
serverpod_offline_sync_server:
|
||||||
path: ../packages/serverpod_offline_sync_server
|
path: ../packages/serverpod_offline_sync_server
|
||||||
serverpod_offline_sync_test_client:
|
serverpod_offline_sync_test_client:
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
export 'src/crdt/merge.dart';
|
||||||
export 'src/database/database.dart';
|
export 'src/database/database.dart';
|
||||||
export 'src/database/recorder.dart';
|
export 'src/database/recorder.dart';
|
||||||
export 'src/database/schema.dart';
|
export 'src/database/schema.dart';
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
export 'package:serverpod_client/serverpod_client.dart';
|
export 'package:serverpod_client/serverpod_client.dart';
|
||||||
|
|
||||||
export 'crdt.dart';
|
export 'crdt.dart';
|
||||||
|
export 'src/crdt/extensions.dart';
|
||||||
export 'src/protocol/protocol.dart';
|
export 'src/protocol/protocol.dart';
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
import 'package:serverpod_offline_sync_shared/serverpod_offline_sync_shared.dart';
|
||||||
|
|
||||||
|
import '../protocol/protocol.dart';
|
||||||
|
|
||||||
|
/// Extensions for the [CrdtDataField] class.
|
||||||
|
extension CrdtDataFieldExtension on CrdtDataField {
|
||||||
|
/// The HLC represented by this field.
|
||||||
|
Hlc get hlc => node == null
|
||||||
|
? (throw StateError(
|
||||||
|
'Fetch the CRDT field with the node included to get the HLC.',
|
||||||
|
))
|
||||||
|
: toHlcForNode(node!.uuidNodeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extensions for the [CrdtDataRow] class.
|
||||||
|
extension CrdtDataRowExtension on CrdtDataRow {
|
||||||
|
/// The HLC represented by this row.
|
||||||
|
Hlc get hlc => node == null
|
||||||
|
? (throw StateError(
|
||||||
|
'Fetch the CRDT row with the node included to get the HLC.',
|
||||||
|
))
|
||||||
|
: toHlcForNode(node!.uuidNodeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extensions for the [CrdtDataDeleted] class.
|
||||||
|
extension CrdtDataDeletedExtension on CrdtDataDeleted {
|
||||||
|
/// The HLC represented by this tombstone.
|
||||||
|
Hlc get hlc => node == null
|
||||||
|
? (throw StateError(
|
||||||
|
'Fetch the CRDT tombstone with the node included to get the HLC.',
|
||||||
|
))
|
||||||
|
: toHlcForNode(node!.uuidNodeId);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,81 @@
|
||||||
|
import 'package:serverpod_client/serverpod_client.dart';
|
||||||
|
import 'package:serverpod_database/serverpod_database.dart';
|
||||||
|
import 'package:serverpod_offline_sync_shared/serverpod_offline_sync_shared.dart';
|
||||||
|
|
||||||
|
import '../protocol/protocol.dart';
|
||||||
|
|
||||||
|
/// Merge metadata extracted from a [CrdtMergeSet].
|
||||||
|
typedef CrdtMergeMetadataLookup = ({
|
||||||
|
Map<String, Set<UuidValue>> rowIdsByTable,
|
||||||
|
Map<String, Set<String>> columnNamesByTable,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// CRDT merge helpers built on top of the generated Serverpod models.
|
||||||
|
extension CrdtMergeSetExtension on CrdtMergeSet {
|
||||||
|
/// Whether this merge set has no changes.
|
||||||
|
bool get isEmpty => inserts.isEmpty && updates.isEmpty && deletes.isEmpty;
|
||||||
|
|
||||||
|
/// All merge changes in this set.
|
||||||
|
Iterable<CrdtMergeChange> get changes sync* {
|
||||||
|
yield* inserts;
|
||||||
|
yield* updates;
|
||||||
|
yield* deletes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All merge changes in this set sorted by causal order.
|
||||||
|
List<CrdtMergeChange> get causallyOrderedChanges {
|
||||||
|
final operations = changes.toList()
|
||||||
|
..sort((left, right) => left.hlc.compareTo(right.hlc));
|
||||||
|
return operations;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Collects the row and column metadata needed to load merge state.
|
||||||
|
CrdtMergeMetadataLookup collectMetadataLookup({
|
||||||
|
required Map<String, Set<String>> columnNamesByTableName,
|
||||||
|
}) {
|
||||||
|
final rowIdsToLoadByTable = <String, Set<UuidValue>>{};
|
||||||
|
final columnNamesToLoadByTable = <String, Set<String>>{};
|
||||||
|
|
||||||
|
for (final change in changes) {
|
||||||
|
final availableColumnNames = columnNamesByTableName[change.tableName];
|
||||||
|
if (availableColumnNames == null) continue;
|
||||||
|
|
||||||
|
rowIdsToLoadByTable.putIfAbsent(change.tableName, () => {}).add(change.uuidRowId);
|
||||||
|
columnNamesToLoadByTable.putIfAbsent(change.tableName, () => {}).addAll(
|
||||||
|
switch (change) {
|
||||||
|
CrdtMergeInsert() => availableColumnNames.difference({'id'}),
|
||||||
|
CrdtMergeUpdate() => [change.columnName],
|
||||||
|
_ => [],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
rowIdsByTable: rowIdsToLoadByTable,
|
||||||
|
columnNamesByTable: columnNamesToLoadByTable,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience helpers shared by every merge change.
|
||||||
|
extension CrdtMergeChangeExtension on CrdtMergeChange {
|
||||||
|
/// The HLC represented by this change.
|
||||||
|
Hlc get hlc => Hlc(hlcDatetime, hlcCounter, uuidNodeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience helpers for insert changes.
|
||||||
|
extension CrdtMergeInsertExtension on CrdtMergeInsert {
|
||||||
|
/// The database column payload represented by this change.
|
||||||
|
Map<String, Object?> get databaseColumns {
|
||||||
|
final payload = switch (data) {
|
||||||
|
final TableRow row => row.toJsonForDatabase(),
|
||||||
|
final Map<String, dynamic> map => map,
|
||||||
|
_ => throw StateError(
|
||||||
|
'Unsupported merge insert payload type for $tableName: '
|
||||||
|
'${data.runtimeType}. Expected TableRow or Map<String, dynamic>.',
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
return Map<String, Object?>.from(payload as Map<String, dynamic>);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
// TODO: Implement the business layer for the CRDT sync.
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
import 'package:serverpod_database/serverpod_database.dart';
|
import 'package:serverpod_database/serverpod_database.dart';
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
|
import '../crdt/merge.dart';
|
||||||
import '../managers/user.dart';
|
import '../managers/user.dart';
|
||||||
import '../protocol/protocol.dart';
|
import '../protocol/protocol.dart';
|
||||||
import 'recorder.dart';
|
import 'recorder.dart';
|
||||||
|
|
@ -43,6 +44,29 @@ class CrdtDatabase implements Database {
|
||||||
await _recorder.initialize();
|
await _recorder.initialize();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Merges remote CRDT changes into the local database for the given user.
|
||||||
|
///
|
||||||
|
/// When [userId] is omitted, this uses the recorder's persistent user id.
|
||||||
|
/// The merge locks the current user's CRDT tables and executes atomically
|
||||||
|
/// inside [transactionForUser].
|
||||||
|
Future<void> mergeChanges(
|
||||||
|
CrdtMergeSet mergeSet, {
|
||||||
|
UuidValue? userId,
|
||||||
|
}) async {
|
||||||
|
if (mergeSet.isEmpty) return;
|
||||||
|
|
||||||
|
final effectiveUserId =
|
||||||
|
userId ??
|
||||||
|
_recorder.persistentUserId ??
|
||||||
|
(throw StateError(
|
||||||
|
'A user ID is required when merging changes without a persistent user.',
|
||||||
|
));
|
||||||
|
await transactionForUser<void>(effectiveUserId, (tx) async {
|
||||||
|
await _recorder.lockCurrentUser(tx);
|
||||||
|
await _recorder.mergeChanges(mergeSet, tx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
DatabaseAnalyzer get analyzer => _delegate.analyzer;
|
DatabaseAnalyzer get analyzer => _delegate.analyzer;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,599 @@
|
||||||
|
part of 'recorder.dart';
|
||||||
|
|
||||||
|
typedef _MergeRowKey = (String, UuidValue);
|
||||||
|
typedef _MergeFieldKey = (String, UuidValue, String);
|
||||||
|
|
||||||
|
/// Adds merge-specific behavior to [CrdtMutationRecorder].
|
||||||
|
extension CrdtMergeRecorderExtension on CrdtMutationRecorder {
|
||||||
|
/// Locks the current user row so merges can serialize with other work.
|
||||||
|
Future<void> lockCurrentUser(Transaction transaction) async {
|
||||||
|
final user = _getEffectiveUser(transaction);
|
||||||
|
// Use a row lock without fetching the record since the merge path only
|
||||||
|
// needs serialization against concurrent work for the same user.
|
||||||
|
await CrdtUser.db.lockRows(
|
||||||
|
_session,
|
||||||
|
where: (t) => t.id.equals(user.id),
|
||||||
|
transaction: transaction,
|
||||||
|
lockMode: LockMode.forUpdate,
|
||||||
|
lockBehavior: LockBehavior.wait,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Merges remote CRDT changes into the current database.
|
||||||
|
Future<void> mergeChanges(
|
||||||
|
CrdtMergeSet mergeSet,
|
||||||
|
Transaction transaction,
|
||||||
|
) async {
|
||||||
|
if (mergeSet.isEmpty) return;
|
||||||
|
|
||||||
|
final operations = mergeSet.causallyOrderedChanges;
|
||||||
|
final currentUser = _getEffectiveUser(transaction);
|
||||||
|
final remoteNodes = await _findOrCreateNodesForMerge(
|
||||||
|
currentUser.id!,
|
||||||
|
{for (final change in operations) change.uuidNodeId},
|
||||||
|
transaction,
|
||||||
|
);
|
||||||
|
final metadata = await _loadMergeMetadata(mergeSet, transaction);
|
||||||
|
|
||||||
|
for (final operation in operations) {
|
||||||
|
if (!_isCrdtTrackedTableName(operation.tableName)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (operation) {
|
||||||
|
case final CrdtMergeInsert insert:
|
||||||
|
await _applyMergeInsert(
|
||||||
|
insert,
|
||||||
|
remoteNodes,
|
||||||
|
metadata.rows,
|
||||||
|
metadata.fields,
|
||||||
|
metadata.tombstones,
|
||||||
|
transaction,
|
||||||
|
);
|
||||||
|
case final CrdtMergeUpdate update:
|
||||||
|
await _applyMergeUpdate(
|
||||||
|
update,
|
||||||
|
remoteNodes,
|
||||||
|
metadata.rows,
|
||||||
|
metadata.fields,
|
||||||
|
transaction,
|
||||||
|
);
|
||||||
|
case final CrdtMergeDelete delete:
|
||||||
|
await _applyMergeDelete(
|
||||||
|
delete,
|
||||||
|
remoteNodes,
|
||||||
|
metadata.rows,
|
||||||
|
metadata.tombstones,
|
||||||
|
transaction,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_updateHlcFromIncomingOperations(operations, transaction);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _updateHlcFromIncomingOperations(
|
||||||
|
List<CrdtMergeChange> operations,
|
||||||
|
Transaction transaction,
|
||||||
|
) {
|
||||||
|
final maxIncomingHlc = operations.fold<Hlc?>(
|
||||||
|
null,
|
||||||
|
(current, change) =>
|
||||||
|
current == null || change.hlc > current ? change.hlc : current,
|
||||||
|
);
|
||||||
|
if (maxIncomingHlc != null) {
|
||||||
|
final hlcManager = _getHlcManager(transaction);
|
||||||
|
if (maxIncomingHlc.nodeId == hlcManager.uuidNodeId) {
|
||||||
|
if (maxIncomingHlc > hlcManager.lastHlc) {
|
||||||
|
hlcManager.lastHlc = maxIncomingHlc;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
hlcManager.merge(maxIncomingHlc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<UuidValue, CrdtNode>> _findOrCreateNodesForMerge(
|
||||||
|
int userId,
|
||||||
|
Set<UuidValue> nodeIds,
|
||||||
|
Transaction transaction,
|
||||||
|
) async {
|
||||||
|
if (nodeIds.isEmpty) return {};
|
||||||
|
|
||||||
|
var nodes = await CrdtNode.db.find(
|
||||||
|
_session,
|
||||||
|
where: (t) => t.userId.equals(userId) & t.uuidNodeId.inSet(nodeIds),
|
||||||
|
transaction: transaction,
|
||||||
|
);
|
||||||
|
final existingNodeIds = nodes.map((node) => node.uuidNodeId).toSet();
|
||||||
|
final missingNodeIds = nodeIds.difference(existingNodeIds);
|
||||||
|
|
||||||
|
if (missingNodeIds.isNotEmpty) {
|
||||||
|
await CrdtNode.db.insert(
|
||||||
|
_session,
|
||||||
|
[
|
||||||
|
for (final uuidNodeId in missingNodeIds)
|
||||||
|
CrdtNode(
|
||||||
|
userId: userId,
|
||||||
|
uuidNodeId: uuidNodeId,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
transaction: transaction,
|
||||||
|
ignoreConflicts: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
nodes = await CrdtNode.db.find(
|
||||||
|
_session,
|
||||||
|
where: (t) => t.userId.equals(userId) & t.uuidNodeId.inSet(nodeIds),
|
||||||
|
transaction: transaction,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
for (final node in nodes) node.uuidNodeId: node,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<
|
||||||
|
({
|
||||||
|
Map<_MergeRowKey, CrdtDataRow> rows,
|
||||||
|
Map<_MergeFieldKey, CrdtDataField> fields,
|
||||||
|
Map<_MergeRowKey, CrdtDataDeleted> tombstones,
|
||||||
|
})
|
||||||
|
>
|
||||||
|
_loadMergeMetadata(
|
||||||
|
CrdtMergeSet mergeSet,
|
||||||
|
Transaction transaction,
|
||||||
|
) async {
|
||||||
|
final (:rowIdsByTable, :columnNamesByTable) = mergeSet.collectMetadataLookup(
|
||||||
|
columnNamesByTableName: _syncedTableColumnNamesForMerge,
|
||||||
|
);
|
||||||
|
|
||||||
|
final rows = <_MergeRowKey, CrdtDataRow>{};
|
||||||
|
final fields = <_MergeFieldKey, CrdtDataField>{};
|
||||||
|
final tombstones = <_MergeRowKey, CrdtDataDeleted>{};
|
||||||
|
|
||||||
|
for (final MapEntry(key: tableName, value: rowIds) in rowIdsByTable.entries) {
|
||||||
|
final loadedRows = await _findCrdtRows(
|
||||||
|
tableName,
|
||||||
|
rowIds,
|
||||||
|
transaction,
|
||||||
|
include: CrdtDataRow.include(
|
||||||
|
node: CrdtNode.include(),
|
||||||
|
deleted: CrdtDataDeleted.include(node: CrdtNode.include()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (final row in loadedRows) {
|
||||||
|
rows[(tableName, row.uuidRowId)] = row;
|
||||||
|
if (row.deleted != null) {
|
||||||
|
tombstones[(tableName, row.uuidRowId)] = row.deleted!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final columnNames = columnNamesByTable[tableName];
|
||||||
|
if (columnNames == null || columnNames.isEmpty || loadedRows.isEmpty) continue;
|
||||||
|
|
||||||
|
final rowPks = loadedRows.map((row) => row.id).whereType<int>().toSet();
|
||||||
|
if (rowPks.length != loadedRows.length) {
|
||||||
|
throw StateError(
|
||||||
|
'Some merge metadata rows for table $tableName are missing '
|
||||||
|
'persisted identifiers.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final (_, columnsByName) = _schema[tableName]!;
|
||||||
|
final columnIds = <int>{
|
||||||
|
for (final columnName in columnNames)
|
||||||
|
if (columnsByName[columnName] != null) columnsByName[columnName]!.id!,
|
||||||
|
};
|
||||||
|
if (columnIds.isEmpty) continue;
|
||||||
|
|
||||||
|
final loadedFields = await CrdtDataField.db.find(
|
||||||
|
_session,
|
||||||
|
where: (t) => t.rowId.inSet(rowPks) & t.columnId.inSet(columnIds),
|
||||||
|
include: CrdtDataField.include(
|
||||||
|
column: CrdtSchemaColumn.include(),
|
||||||
|
node: CrdtNode.include(),
|
||||||
|
),
|
||||||
|
transaction: transaction,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (final field in loadedFields) {
|
||||||
|
final row = loadedRows.firstWhere((loadedRow) => loadedRow.id == field.rowId);
|
||||||
|
fields[(tableName, row.uuidRowId, field.column!.name)] = field;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
rows: rows,
|
||||||
|
fields: fields,
|
||||||
|
tombstones: tombstones,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _applyMergeInsert(
|
||||||
|
CrdtMergeInsert insert,
|
||||||
|
Map<UuidValue, CrdtNode> remoteNodes,
|
||||||
|
Map<_MergeRowKey, CrdtDataRow> rows,
|
||||||
|
Map<_MergeFieldKey, CrdtDataField> fields,
|
||||||
|
Map<_MergeRowKey, CrdtDataDeleted> tombstones,
|
||||||
|
Transaction transaction,
|
||||||
|
) async {
|
||||||
|
final rowKey = (insert.tableName, insert.uuidRowId);
|
||||||
|
final incomingHlc = insert.hlc;
|
||||||
|
final remoteNode = _requireRemoteNode(remoteNodes, insert.uuidNodeId);
|
||||||
|
|
||||||
|
final currentRow = rows[rowKey];
|
||||||
|
if (currentRow != null && incomingHlc <= currentRow.hlc) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final data = _sanitizeMergeRowData(insert.tableName, insert.databaseColumns);
|
||||||
|
|
||||||
|
if (currentRow == null) {
|
||||||
|
rows[rowKey] = await _applyMergeInsertForMissingRow(
|
||||||
|
insert,
|
||||||
|
remoteNode,
|
||||||
|
incomingHlc,
|
||||||
|
data,
|
||||||
|
transaction,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await _applyMergeInsertForExistingRow(
|
||||||
|
insert,
|
||||||
|
currentRow,
|
||||||
|
remoteNode,
|
||||||
|
incomingHlc,
|
||||||
|
data,
|
||||||
|
fields,
|
||||||
|
tombstones,
|
||||||
|
transaction,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _applyMergeUpdate(
|
||||||
|
CrdtMergeUpdate update,
|
||||||
|
Map<UuidValue, CrdtNode> remoteNodes,
|
||||||
|
Map<_MergeRowKey, CrdtDataRow> rows,
|
||||||
|
Map<_MergeFieldKey, CrdtDataField> fields,
|
||||||
|
Transaction transaction,
|
||||||
|
) async {
|
||||||
|
final rowKey = (update.tableName, update.uuidRowId);
|
||||||
|
final row = rows[rowKey];
|
||||||
|
if (row == null) return;
|
||||||
|
|
||||||
|
final shouldApply = await _shouldMergeFieldMetadataIfNewer(
|
||||||
|
tableName: update.tableName,
|
||||||
|
rowId: update.uuidRowId,
|
||||||
|
columnName: update.columnName,
|
||||||
|
row: row,
|
||||||
|
remoteNode: _requireRemoteNode(remoteNodes, update.uuidNodeId),
|
||||||
|
incomingHlc: update.hlc,
|
||||||
|
fields: fields,
|
||||||
|
transaction: transaction,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!shouldApply) return;
|
||||||
|
await _updateDomainRows(
|
||||||
|
update.tableName,
|
||||||
|
{update.uuidRowId},
|
||||||
|
{update.columnName: update.value},
|
||||||
|
transaction,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _applyMergeDelete(
|
||||||
|
CrdtMergeDelete delete,
|
||||||
|
Map<UuidValue, CrdtNode> remoteNodes,
|
||||||
|
Map<_MergeRowKey, CrdtDataRow> rows,
|
||||||
|
Map<_MergeRowKey, CrdtDataDeleted> tombstones,
|
||||||
|
Transaction transaction,
|
||||||
|
) async {
|
||||||
|
final rowKey = (delete.tableName, delete.uuidRowId);
|
||||||
|
final row = rows[rowKey];
|
||||||
|
if (row == null) return;
|
||||||
|
|
||||||
|
final incomingHlc = delete.hlc;
|
||||||
|
final currentTombstone = tombstones[rowKey];
|
||||||
|
if (incomingHlc <= row.hlc.maxBetween(currentTombstone?.hlc)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tombstones[rowKey] = await _upsertMergeTombstone(
|
||||||
|
row,
|
||||||
|
_requireRemoteNode(remoteNodes, delete.uuidNodeId),
|
||||||
|
incomingHlc,
|
||||||
|
delete.isDeleted,
|
||||||
|
currentTombstone,
|
||||||
|
transaction,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<CrdtDataRow> _upsertMergeRow(
|
||||||
|
String tableName,
|
||||||
|
UuidValue rowId,
|
||||||
|
CrdtNode remoteNode,
|
||||||
|
Hlc incomingHlc,
|
||||||
|
CrdtDataRow? currentRow,
|
||||||
|
Transaction transaction,
|
||||||
|
) async {
|
||||||
|
if (currentRow == null) {
|
||||||
|
final (tableId, _) = _schema[tableName]!;
|
||||||
|
final insertedRow = await CrdtDataRow.db.insertRow(
|
||||||
|
_session,
|
||||||
|
CrdtDataRow(
|
||||||
|
userId: _getHlcManager(transaction).normalizedUserId,
|
||||||
|
tblId: tableId,
|
||||||
|
uuidRowId: rowId,
|
||||||
|
nodeId: remoteNode.id!,
|
||||||
|
hlcDatetime: incomingHlc.datetime,
|
||||||
|
hlcCounter: incomingHlc.counter,
|
||||||
|
),
|
||||||
|
transaction: transaction,
|
||||||
|
);
|
||||||
|
|
||||||
|
return insertedRow.copyWith(node: remoteNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
final updatedRow = currentRow.copyWith(
|
||||||
|
nodeId: remoteNode.id,
|
||||||
|
node: remoteNode,
|
||||||
|
hlcDatetime: incomingHlc.datetime,
|
||||||
|
hlcCounter: incomingHlc.counter,
|
||||||
|
);
|
||||||
|
|
||||||
|
await CrdtDataRow.db.update(
|
||||||
|
_session,
|
||||||
|
[updatedRow],
|
||||||
|
columns: (t) => [t.nodeId, t.hlcDatetime, t.hlcCounter],
|
||||||
|
transaction: transaction,
|
||||||
|
);
|
||||||
|
|
||||||
|
return updatedRow;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<CrdtDataField> _upsertMergeField(
|
||||||
|
CrdtDataRow row,
|
||||||
|
CrdtSchemaColumn column,
|
||||||
|
CrdtNode remoteNode,
|
||||||
|
Hlc incomingHlc,
|
||||||
|
CrdtDataField? currentField,
|
||||||
|
Transaction transaction,
|
||||||
|
) async {
|
||||||
|
if (currentField == null) {
|
||||||
|
final insertedField = await CrdtDataField.db.insertRow(
|
||||||
|
_session,
|
||||||
|
CrdtDataField(
|
||||||
|
rowId: row.id!,
|
||||||
|
columnId: column.id!,
|
||||||
|
nodeId: remoteNode.id!,
|
||||||
|
hlcDatetime: incomingHlc.datetime,
|
||||||
|
hlcCounter: incomingHlc.counter,
|
||||||
|
),
|
||||||
|
transaction: transaction,
|
||||||
|
);
|
||||||
|
|
||||||
|
return insertedField.copyWith(
|
||||||
|
row: row,
|
||||||
|
column: column,
|
||||||
|
node: remoteNode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final updatedField = currentField.copyWith(
|
||||||
|
row: row,
|
||||||
|
column: column,
|
||||||
|
nodeId: remoteNode.id,
|
||||||
|
node: remoteNode,
|
||||||
|
hlcDatetime: incomingHlc.datetime,
|
||||||
|
hlcCounter: incomingHlc.counter,
|
||||||
|
);
|
||||||
|
|
||||||
|
await CrdtDataField.db.update(
|
||||||
|
_session,
|
||||||
|
[updatedField],
|
||||||
|
columns: (t) => [t.nodeId, t.hlcDatetime, t.hlcCounter],
|
||||||
|
transaction: transaction,
|
||||||
|
);
|
||||||
|
return updatedField;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<CrdtDataDeleted> _upsertMergeTombstone(
|
||||||
|
CrdtDataRow row,
|
||||||
|
CrdtNode remoteNode,
|
||||||
|
Hlc incomingHlc,
|
||||||
|
bool isDeleted,
|
||||||
|
CrdtDataDeleted? currentTombstone,
|
||||||
|
Transaction transaction,
|
||||||
|
) async {
|
||||||
|
if (currentTombstone == null) {
|
||||||
|
final insertedTombstone = await CrdtDataDeleted.db.insertRow(
|
||||||
|
_session,
|
||||||
|
CrdtDataDeleted(
|
||||||
|
rowId: row.id!,
|
||||||
|
nodeId: remoteNode.id!,
|
||||||
|
hlcDatetime: incomingHlc.datetime,
|
||||||
|
hlcCounter: incomingHlc.counter,
|
||||||
|
isDeleted: isDeleted,
|
||||||
|
),
|
||||||
|
transaction: transaction,
|
||||||
|
);
|
||||||
|
|
||||||
|
return insertedTombstone.copyWith(
|
||||||
|
row: row,
|
||||||
|
node: remoteNode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final updatedTombstone = currentTombstone.copyWith(
|
||||||
|
row: row,
|
||||||
|
nodeId: remoteNode.id,
|
||||||
|
node: remoteNode,
|
||||||
|
hlcDatetime: incomingHlc.datetime,
|
||||||
|
hlcCounter: incomingHlc.counter,
|
||||||
|
isDeleted: isDeleted,
|
||||||
|
);
|
||||||
|
|
||||||
|
await CrdtDataDeleted.db.update(
|
||||||
|
_session,
|
||||||
|
[updatedTombstone],
|
||||||
|
columns: (t) => [t.nodeId, t.hlcDatetime, t.hlcCounter, t.isDeleted],
|
||||||
|
transaction: transaction,
|
||||||
|
);
|
||||||
|
|
||||||
|
return updatedTombstone;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<CrdtDataRow> _applyMergeInsertForMissingRow(
|
||||||
|
CrdtMergeInsert insert,
|
||||||
|
CrdtNode remoteNode,
|
||||||
|
Hlc incomingHlc,
|
||||||
|
Map<String, Object?> data,
|
||||||
|
Transaction transaction,
|
||||||
|
) async {
|
||||||
|
final insertedCrdtRow = await _upsertMergeRow(
|
||||||
|
insert.tableName,
|
||||||
|
insert.uuidRowId,
|
||||||
|
remoteNode,
|
||||||
|
incomingHlc,
|
||||||
|
null,
|
||||||
|
transaction,
|
||||||
|
);
|
||||||
|
|
||||||
|
final row = _requireMergeInsertTableRow(insert);
|
||||||
|
try {
|
||||||
|
await _db.updateRow(
|
||||||
|
row,
|
||||||
|
columns: _syncTableByName[insert.tableName]!.managedColumns
|
||||||
|
.where((c) => data.containsKey(c.columnName))
|
||||||
|
.toList(),
|
||||||
|
transaction: transaction,
|
||||||
|
);
|
||||||
|
} on DatabaseUpdateRowException {
|
||||||
|
await _db.insertRow(row, transaction: transaction);
|
||||||
|
}
|
||||||
|
|
||||||
|
return insertedCrdtRow;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _applyMergeInsertForExistingRow(
|
||||||
|
CrdtMergeInsert insert,
|
||||||
|
CrdtDataRow currentRow,
|
||||||
|
CrdtNode remoteNode,
|
||||||
|
Hlc incomingHlc,
|
||||||
|
Map<String, Object?> data,
|
||||||
|
Map<_MergeFieldKey, CrdtDataField> fields,
|
||||||
|
Map<_MergeRowKey, CrdtDataDeleted> tombstones,
|
||||||
|
Transaction transaction,
|
||||||
|
) async {
|
||||||
|
final (_, columnsByName) = _schema[insert.tableName]!;
|
||||||
|
final updatedValues = <String, Object?>{};
|
||||||
|
for (final MapEntry(key: columnName, value: schemaColumn)
|
||||||
|
in columnsByName.entries) {
|
||||||
|
if (columnName == 'id') continue;
|
||||||
|
|
||||||
|
final shouldApply = await _shouldMergeFieldMetadataIfNewer(
|
||||||
|
tableName: insert.tableName,
|
||||||
|
rowId: insert.uuidRowId,
|
||||||
|
columnName: columnName,
|
||||||
|
row: currentRow,
|
||||||
|
remoteNode: remoteNode,
|
||||||
|
incomingHlc: incomingHlc,
|
||||||
|
fields: fields,
|
||||||
|
transaction: transaction,
|
||||||
|
schemaColumn: schemaColumn,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!shouldApply) continue;
|
||||||
|
updatedValues[columnName] = data[columnName];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updatedValues.isNotEmpty) {
|
||||||
|
await _updateDomainRows(
|
||||||
|
insert.tableName,
|
||||||
|
{insert.uuidRowId},
|
||||||
|
updatedValues,
|
||||||
|
transaction,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final rowKey = (insert.tableName, insert.uuidRowId);
|
||||||
|
final currentTombstone = tombstones[rowKey];
|
||||||
|
if (currentTombstone == null || incomingHlc > currentTombstone.hlc) {
|
||||||
|
tombstones[rowKey] = await _upsertMergeTombstone(
|
||||||
|
currentRow,
|
||||||
|
remoteNode,
|
||||||
|
incomingHlc,
|
||||||
|
false,
|
||||||
|
currentTombstone,
|
||||||
|
transaction,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _shouldMergeFieldMetadataIfNewer({
|
||||||
|
required String tableName,
|
||||||
|
required UuidValue rowId,
|
||||||
|
required String columnName,
|
||||||
|
required CrdtDataRow row,
|
||||||
|
required CrdtNode remoteNode,
|
||||||
|
required Hlc incomingHlc,
|
||||||
|
required Map<_MergeFieldKey, CrdtDataField> fields,
|
||||||
|
required Transaction transaction,
|
||||||
|
CrdtSchemaColumn? schemaColumn,
|
||||||
|
}) async {
|
||||||
|
final (_, columnsByName) = _schema[tableName]!;
|
||||||
|
final resolvedSchemaColumn = schemaColumn ?? columnsByName[columnName];
|
||||||
|
if (resolvedSchemaColumn == null) return false;
|
||||||
|
|
||||||
|
final fieldKey = (tableName, rowId, columnName);
|
||||||
|
final currentField = fields[fieldKey];
|
||||||
|
final currentHlc = currentField?.hlc ?? row.hlc;
|
||||||
|
if (incomingHlc <= currentHlc) return false;
|
||||||
|
|
||||||
|
fields[fieldKey] = await _upsertMergeField(
|
||||||
|
row,
|
||||||
|
resolvedSchemaColumn,
|
||||||
|
remoteNode,
|
||||||
|
incomingHlc,
|
||||||
|
currentField,
|
||||||
|
transaction,
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
TableRow _requireMergeInsertTableRow(CrdtMergeInsert insert) {
|
||||||
|
final row = insert.data;
|
||||||
|
if (row is TableRow) return row;
|
||||||
|
|
||||||
|
throw StateError(
|
||||||
|
'Unsupported merge insert payload type for "${insert.tableName}": '
|
||||||
|
'${row.runtimeType}. Expected subclass of TableRow.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
CrdtNode _requireRemoteNode(
|
||||||
|
Map<UuidValue, CrdtNode> remoteNodes,
|
||||||
|
UuidValue uuidNodeId,
|
||||||
|
) {
|
||||||
|
return remoteNodes[uuidNodeId] ??
|
||||||
|
(throw StateError(
|
||||||
|
'Remote node $uuidNodeId not found for merge.',
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object?> _sanitizeMergeRowData(
|
||||||
|
String tableName,
|
||||||
|
Map<String, Object?> data,
|
||||||
|
) {
|
||||||
|
final columns = _columnsByTableAndName[tableName];
|
||||||
|
if (columns == null) return {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
for (final MapEntry(key: columnName, value: value) in data.entries)
|
||||||
|
if (columnName != 'id' && columns.containsKey(columnName)) columnName: value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
import 'package:serverpod_database/serverpod_database.dart';
|
import 'package:serverpod_database/serverpod_database.dart';
|
||||||
|
import 'package:serverpod_offline_sync_shared/serverpod_offline_sync_shared.dart';
|
||||||
import 'package:serverpod_serialization/serverpod_serialization.dart';
|
import 'package:serverpod_serialization/serverpod_serialization.dart';
|
||||||
|
|
||||||
|
import '../crdt/extensions.dart';
|
||||||
|
import '../crdt/merge.dart';
|
||||||
import '../managers/hlc.dart';
|
import '../managers/hlc.dart';
|
||||||
import '../managers/user.dart';
|
import '../managers/user.dart';
|
||||||
import '../protocol/protocol.dart';
|
import '../protocol/protocol.dart';
|
||||||
|
|
@ -8,6 +11,8 @@ import 'database.dart';
|
||||||
import 'schema.dart';
|
import 'schema.dart';
|
||||||
import 'session.dart';
|
import 'session.dart';
|
||||||
|
|
||||||
|
part 'merge.dart';
|
||||||
|
|
||||||
typedef _ReferencingForeignKey = ({
|
typedef _ReferencingForeignKey = ({
|
||||||
String childTableName,
|
String childTableName,
|
||||||
String childColumn,
|
String childColumn,
|
||||||
|
|
@ -129,7 +134,14 @@ class CrdtMutationRecorder {
|
||||||
/// The list of tables to sync with CRDT.
|
/// The list of tables to sync with CRDT.
|
||||||
final List<Table> syncTables;
|
final List<Table> syncTables;
|
||||||
|
|
||||||
late final _syncTablesNames = syncTables.map((t) => t.tableName).toSet();
|
late final _syncTableByName = {
|
||||||
|
for (final t in syncTables) t.tableName: t,
|
||||||
|
};
|
||||||
|
|
||||||
|
late final Map<String, Set<String>> _syncedTableColumnNamesForMerge = {
|
||||||
|
for (final MapEntry(key: k, value: cols) in _columnsByTableAndName.entries)
|
||||||
|
if (_syncTableByName.containsKey(k)) k: cols.keys.toSet(),
|
||||||
|
};
|
||||||
|
|
||||||
/// Whether the given table is tracked by CRDT.
|
/// Whether the given table is tracked by CRDT.
|
||||||
bool isCrdtTracked<T extends TableRow>([Table? table]) {
|
bool isCrdtTracked<T extends TableRow>([Table? table]) {
|
||||||
|
|
@ -140,7 +152,7 @@ class CrdtMutationRecorder {
|
||||||
|
|
||||||
/// Whether the given table name is tracked by CRDT.
|
/// Whether the given table name is tracked by CRDT.
|
||||||
bool _isCrdtTrackedTableName(String tableName) {
|
bool _isCrdtTrackedTableName(String tableName) {
|
||||||
return _syncTablesNames.contains(tableName);
|
return _syncTableByName.containsKey(tableName);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Insert the CRDT metadata for the inserted rows.
|
/// Insert the CRDT metadata for the inserted rows.
|
||||||
|
|
@ -729,7 +741,7 @@ class CrdtMutationRecorder {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await _updateDomainRow(tableName, rowId, updates, transaction);
|
await _updateDomainRows(tableName, {rowId}, updates, transaction);
|
||||||
updatedRowIds.add(rowId);
|
updatedRowIds.add(rowId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -779,15 +791,6 @@ WHERE d."${_escapeIdentifier(columnName)}" = ${_sqlLiteral(value)}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _updateDomainRow(
|
|
||||||
String tableName,
|
|
||||||
UuidValue rowId,
|
|
||||||
Map<String, Object?> updates,
|
|
||||||
Transaction transaction,
|
|
||||||
) async {
|
|
||||||
await _updateDomainRows(tableName, {rowId}, updates, transaction);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _updateDomainRows(
|
Future<void> _updateDomainRows(
|
||||||
String tableName,
|
String tableName,
|
||||||
Set<UuidValue> rowIds,
|
Set<UuidValue> rowIds,
|
||||||
|
|
@ -797,9 +800,7 @@ WHERE d."${_escapeIdentifier(columnName)}" = ${_sqlLiteral(value)}
|
||||||
if (rowIds.isEmpty || updates.isEmpty) return;
|
if (rowIds.isEmpty || updates.isEmpty) return;
|
||||||
|
|
||||||
final assignments = updates.entries
|
final assignments = updates.entries
|
||||||
.map(
|
.map((e) => '"${_escapeIdentifier(e.key)}" = ${_sqlLiteral(e.value)}')
|
||||||
(entry) => '"${_escapeIdentifier(entry.key)}" = ${_sqlLiteral(entry.value)}',
|
|
||||||
)
|
|
||||||
.join(', ');
|
.join(', ');
|
||||||
|
|
||||||
await _db.unsafeExecute(
|
await _db.unsafeExecute(
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,10 @@ resolution: workspace
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
meta: ^1.18.0
|
meta: ^1.18.0
|
||||||
serverpod_client: 3.5.0-beta.5
|
serverpod_client: 3.5.0-beta.6
|
||||||
serverpod_database: 3.5.0-beta.5
|
serverpod_database: 3.5.0-beta.6
|
||||||
serverpod_offline_sync_shared: 0.0.1
|
serverpod_offline_sync_shared: 0.0.1
|
||||||
serverpod_serialization: 3.5.0-beta.5
|
serverpod_serialization: 3.5.0-beta.6
|
||||||
uuid: ^4.5.3
|
uuid: ^4.5.3
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
### A remote CRDT change to merge.
|
||||||
|
class: CrdtMergeChange
|
||||||
|
sealed: true
|
||||||
|
extends: BaseHlc
|
||||||
|
fields:
|
||||||
|
### The table receiving the change.
|
||||||
|
tableName: String
|
||||||
|
|
||||||
|
### The domain row identifier receiving the change.
|
||||||
|
uuidRowId: UuidValue
|
||||||
|
|
||||||
|
### The remote node that produced the change.
|
||||||
|
uuidNodeId: UuidValue
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
### A remote tombstone update to merge.
|
||||||
|
class: CrdtMergeDelete
|
||||||
|
extends: CrdtMergeChange
|
||||||
|
fields:
|
||||||
|
### Whether the row is deleted or restored.
|
||||||
|
isDeleted: bool
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
### A remote row insertion to merge.
|
||||||
|
class: CrdtMergeInsert
|
||||||
|
extends: CrdtMergeChange
|
||||||
|
fields:
|
||||||
|
### The serialized domain row payload for the insert.
|
||||||
|
data: dynamic
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
### A set of remote CRDT changes to merge into the local database.
|
||||||
|
class: CrdtMergeSet
|
||||||
|
fields:
|
||||||
|
### Remote row insertions.
|
||||||
|
inserts: List<CrdtMergeInsert>
|
||||||
|
|
||||||
|
### Remote field updates.
|
||||||
|
updates: List<CrdtMergeUpdate>
|
||||||
|
|
||||||
|
### Remote tombstone updates.
|
||||||
|
deletes: List<CrdtMergeDelete>
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
### A remote field update to merge.
|
||||||
|
class: CrdtMergeUpdate
|
||||||
|
extends: CrdtMergeChange
|
||||||
|
fields:
|
||||||
|
### The updated database column name.
|
||||||
|
columnName: String
|
||||||
|
|
||||||
|
### The serialized new value for the column.
|
||||||
|
value: dynamic
|
||||||
|
|
@ -10,11 +10,11 @@ resolution: workspace
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
meta: ^1.16.0
|
meta: ^1.16.0
|
||||||
serverpod: 3.5.0-beta.5
|
serverpod: 3.5.0-beta.6
|
||||||
serverpod_offline_sync_client: 0.0.1
|
serverpod_offline_sync_client: 0.0.1
|
||||||
serverpod_offline_sync_shared: 0.0.1
|
serverpod_offline_sync_shared: 0.0.1
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
lints: '>=3.0.0 <7.0.0'
|
lints: '>=3.0.0 <7.0.0'
|
||||||
serverpod_test: 3.5.0-beta.5
|
serverpod_test: 3.5.0-beta.6
|
||||||
test: ^1.25.5
|
test: ^1.25.5
|
||||||
|
|
|
||||||
|
|
@ -144,6 +144,9 @@ class Hlc implements Comparable<Hlc> {
|
||||||
nodeId ?? this.nodeId,
|
nodeId ?? this.nodeId,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// Returns the maximum HLC between the two given HLCs.
|
||||||
|
Hlc maxBetween(Hlc? other) => other == null || this > other ? this : other;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() =>
|
String toString() =>
|
||||||
'${datetime.toIso8601String()}-'
|
'${datetime.toIso8601String()}-'
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,8 @@ resolution: workspace
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
clock: ^1.1.2
|
clock: ^1.1.2
|
||||||
serverpod_database: 3.5.0-beta.5
|
serverpod_database: 3.5.0-beta.6
|
||||||
serverpod_serialization: 3.5.0-beta.5
|
serverpod_serialization: 3.5.0-beta.6
|
||||||
uuid: ^4.5.3
|
uuid: ^4.5.3
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
|
|
|
||||||
16
pubspec.yaml
16
pubspec.yaml
|
|
@ -14,16 +14,16 @@ workspace:
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
melos: ^7.5.1
|
melos: ^7.5.1
|
||||||
serverpod_cli: 3.5.0-beta.5
|
serverpod_cli: 3.5.0-beta.6
|
||||||
|
|
||||||
dependency_overrides:
|
dependency_overrides:
|
||||||
serverpod: 3.5.0-beta.5
|
serverpod: 3.5.0-beta.6
|
||||||
serverpod_cli: 3.5.0-beta.5
|
serverpod_cli: 3.5.0-beta.6
|
||||||
serverpod_client: 3.5.0-beta.5
|
serverpod_client: 3.5.0-beta.6
|
||||||
serverpod_database: 3.5.0-beta.5
|
serverpod_database: 3.5.0-beta.6
|
||||||
serverpod_shared: 3.5.0-beta.5
|
serverpod_shared: 3.5.0-beta.6
|
||||||
serverpod_serialization: 3.5.0-beta.5
|
serverpod_serialization: 3.5.0-beta.6
|
||||||
serverpod_test: 3.5.0-beta.5
|
serverpod_test: 3.5.0-beta.6
|
||||||
# git:
|
# git:
|
||||||
# url: https://github.com/marcelomendoncasoares/serverpod.git
|
# url: https://github.com/marcelomendoncasoares/serverpod.git
|
||||||
# path: packages/serverpod_test
|
# path: packages/serverpod_test
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ environment:
|
||||||
resolution: workspace
|
resolution: workspace
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
serverpod_client: 3.5.0-beta.5
|
serverpod_client: 3.5.0-beta.6
|
||||||
serverpod_database: 3.5.0-beta.5
|
serverpod_database: 3.5.0-beta.6
|
||||||
serverpod_offline_sync_client:
|
serverpod_offline_sync_client:
|
||||||
path: ../../packages/serverpod_offline_sync_client
|
path: ../../packages/serverpod_offline_sync_client
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue