fix: Repair foreign keys on columns that also carry a unique claim (#151)
This commit is contained in:
parent
f3c462b5d3
commit
51ce999af0
6 changed files with 435 additions and 12 deletions
|
|
@ -477,6 +477,9 @@ class OfflineSyncDatabase implements Database {
|
||||||
tx,
|
tx,
|
||||||
projectionUnchanged: projection.projectionUnchanged,
|
projectionUnchanged: projection.projectionUnchanged,
|
||||||
domainBeforeUpsert: projection.domain,
|
domainBeforeUpsert: projection.domain,
|
||||||
|
upsertRows: updateColumns == null && projection.domain.isNotEmpty
|
||||||
|
? prepared.rows
|
||||||
|
: const [],
|
||||||
);
|
);
|
||||||
if (noReturn) return <T>[];
|
if (noReturn) return <T>[];
|
||||||
_stripStampedRows(result, prepared);
|
_stripStampedRows(result, prepared);
|
||||||
|
|
|
||||||
|
|
@ -2120,12 +2120,24 @@ class CrdtForeignKeyProjector {
|
||||||
if (state.pendingInsertKeys.contains(row.key)) continue;
|
if (state.pendingInsertKeys.contains(row.key)) continue;
|
||||||
for (final column in _uniqueResolver.uniqueColumnNamesFor(row.key.$1)) {
|
for (final column in _uniqueResolver.uniqueColumnNamesFor(row.key.$1)) {
|
||||||
final key = (row.key.$1, row.key.$2, column);
|
final key = (row.key.$1, row.key.$2, column);
|
||||||
row.values[column] = canonicalDomainValue(
|
final localValue = canonicalDomainValue(
|
||||||
authoredOverlays.containsKey(key)
|
authoredOverlays.containsKey(key)
|
||||||
? authoredOverlays[key]
|
? authoredOverlays[key]
|
||||||
: state.originalDomain[row.key]?[column],
|
: state.originalDomain[row.key]?[column],
|
||||||
_context.columnsByTableAndName[row.key.$1]?[column],
|
_context.columnsByTableAndName[row.key.$1]?[column],
|
||||||
);
|
);
|
||||||
|
// An authored unique claim still needs its FK repair when a local
|
||||||
|
// edit makes the parent hidden. Only a released value may replace it.
|
||||||
|
if (foreignKeys.reasons.containsKey(key) &&
|
||||||
|
!authoredOverlays.containsKey(key) &&
|
||||||
|
projectionValuesEqual(localValue, authoredByField[key])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!projectionValuesEqual(localValue, row.values[column])) {
|
||||||
|
// Retaining a unique release supersedes the FK candidate's reason.
|
||||||
|
foreignKeys.reasons.remove(key);
|
||||||
|
}
|
||||||
|
row.values[column] = localValue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -851,6 +851,7 @@ class CrdtMutationRecorder {
|
||||||
bool projectionUnchanged = false,
|
bool projectionUnchanged = false,
|
||||||
bool authoredColumnValues = false,
|
bool authoredColumnValues = false,
|
||||||
Map<MergeRowKey, Map<String, Object?>> domainBeforeUpsert = const {},
|
Map<MergeRowKey, Map<String, Object?>> domainBeforeUpsert = const {},
|
||||||
|
List<TableRow> upsertRows = const [],
|
||||||
}) async {
|
}) async {
|
||||||
await _foreignKeyProjector.assertVisibleTargets(updatedRows, columns, transaction);
|
await _foreignKeyProjector.assertVisibleTargets(updatedRows, columns, transaction);
|
||||||
|
|
||||||
|
|
@ -865,6 +866,33 @@ class CrdtMutationRecorder {
|
||||||
'updated',
|
'updated',
|
||||||
transaction,
|
transaction,
|
||||||
);
|
);
|
||||||
|
final implicitForeignKeyRepairFields = columns == null
|
||||||
|
? await _foreignKeyProjector.findImplicitRepairFields(
|
||||||
|
tableName: tableName,
|
||||||
|
rowIds: rowIds,
|
||||||
|
transaction: transaction,
|
||||||
|
)
|
||||||
|
: const <MergeFieldKey>{};
|
||||||
|
final upsertInputs = upsertRows.isEmpty || implicitForeignKeyRepairFields.isEmpty
|
||||||
|
? const <MergeRowKey, Map<String, Object?>>{}
|
||||||
|
: {
|
||||||
|
for (final row in upsertRows)
|
||||||
|
if (row.id case final UuidValue rowId)
|
||||||
|
(row.table.tableName, rowId):
|
||||||
|
row.toJsonForDatabase() as Map<String, dynamic>,
|
||||||
|
};
|
||||||
|
// Insert defaults must not author over an echoed null that is holding
|
||||||
|
// a projected FK claim.
|
||||||
|
bool echoesProjectedNull(UuidValue rowId, String columnName) {
|
||||||
|
if (!implicitForeignKeyRepairFields.contains((tableName, rowId, columnName))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
final input = upsertInputs[(tableName, rowId)];
|
||||||
|
return input != null &&
|
||||||
|
input[columnName] == null &&
|
||||||
|
domainBeforeUpsert[(tableName, rowId)]?[columnName] == null;
|
||||||
|
}
|
||||||
|
|
||||||
// Explicit columns author even an unchanged null; full-row passthrough
|
// Explicit columns author even an unchanged null; full-row passthrough
|
||||||
// keeps the claim behind an unchanged displayed alternative.
|
// keeps the claim behind an unchanged displayed alternative.
|
||||||
final authoredOverlays = <MergeFieldKey, Object?>{
|
final authoredOverlays = <MergeFieldKey, Object?>{
|
||||||
|
|
@ -883,20 +911,14 @@ class CrdtMutationRecorder {
|
||||||
if ((domainBeforeUpsert[(tableName, row.id)]?.containsKey(columnName) ??
|
if ((domainBeforeUpsert[(tableName, row.id)]?.containsKey(columnName) ??
|
||||||
false) &&
|
false) &&
|
||||||
(columns != null ||
|
(columns != null ||
|
||||||
!projectionValuesEqual(
|
(!projectionValuesEqual(
|
||||||
domainBeforeUpsert[(tableName, row.id)]![columnName],
|
domainBeforeUpsert[(tableName, row.id)]![columnName],
|
||||||
value,
|
value,
|
||||||
)))
|
) &&
|
||||||
|
!echoesProjectedNull(row.id as UuidValue, columnName))))
|
||||||
(tableName, row.id as UuidValue, columnName): value,
|
(tableName, row.id as UuidValue, columnName): value,
|
||||||
};
|
};
|
||||||
_uniqueResolver.validateAuthoredFields(authoredOverlays);
|
_uniqueResolver.validateAuthoredFields(authoredOverlays);
|
||||||
final implicitForeignKeyRepairFields = columns == null
|
|
||||||
? await _foreignKeyProjector.findImplicitRepairFields(
|
|
||||||
tableName: tableName,
|
|
||||||
rowIds: rowIds,
|
|
||||||
transaction: transaction,
|
|
||||||
)
|
|
||||||
: const <MergeFieldKey>{};
|
|
||||||
await _recordUpdatedFields(
|
await _recordUpdatedFields(
|
||||||
updatedRows,
|
updatedRows,
|
||||||
crdtDataRows,
|
crdtDataRows,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,119 @@
|
||||||
|
import 'package:serverpod_offline_sync_server/serverpod_offline_sync_server.dart';
|
||||||
|
import 'package:serverpod_offline_sync_test_client/serverpod_offline_sync_test_client.dart';
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
import '../test_tools/client_session.dart';
|
||||||
|
import '../test_tools/crdt_probes.dart';
|
||||||
|
import '../test_tools/sync_topology.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
initTestClientSession(createSessionPerTest: false);
|
||||||
|
|
||||||
|
group(
|
||||||
|
'Given a parent deletion blocked by a restrict child and a unique SET NULL child,',
|
||||||
|
() {
|
||||||
|
late SyncNode node;
|
||||||
|
late Person parent;
|
||||||
|
late RestrictChild blocker;
|
||||||
|
late UniqueSetNullChild child;
|
||||||
|
late Hlc childHlc;
|
||||||
|
|
||||||
|
setUpAll(() async {
|
||||||
|
node = await syncNode(await createAdditionalTestSession(), testSyncTables);
|
||||||
|
parent = Person(id: const Uuid().v7obj(), name: 'parent');
|
||||||
|
blocker = RestrictChild(
|
||||||
|
id: const Uuid().v7obj(),
|
||||||
|
name: 'blocker',
|
||||||
|
parentId: parent.id,
|
||||||
|
);
|
||||||
|
child = UniqueSetNullChild(
|
||||||
|
id: const Uuid().v7obj(),
|
||||||
|
name: 'child',
|
||||||
|
parentId: parent.id,
|
||||||
|
);
|
||||||
|
await node.offlineSync.db.transactionForUser(testCrdtUserId, (tx) async {
|
||||||
|
await Person.db.insertRow(node.offlineSync, parent, transaction: tx);
|
||||||
|
await RestrictChild.db.insertRow(node.offlineSync, blocker, transaction: tx);
|
||||||
|
await UniqueSetNullChild.db.insertRow(
|
||||||
|
node.offlineSync,
|
||||||
|
child,
|
||||||
|
transaction: tx,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
childHlc = await rowHlc(child.id!, databaseSession: node.offlineSync);
|
||||||
|
await node.offlineSync.db.mergeChanges([
|
||||||
|
CrdtMergeDelete(
|
||||||
|
uuidSpaceId: testCrdtUserId,
|
||||||
|
tableName: Person.t.tableName,
|
||||||
|
uuidRowId: parent.id!,
|
||||||
|
uuidNodeId: const Uuid().v7obj(),
|
||||||
|
hlcDatetime: childHlc.datetime.add(const Duration(milliseconds: 1)),
|
||||||
|
hlcCounter: 0,
|
||||||
|
clFlag: 2,
|
||||||
|
reason: CrdtDataDeletedReason.userDelete,
|
||||||
|
),
|
||||||
|
], spaceId: testCrdtUserId);
|
||||||
|
});
|
||||||
|
|
||||||
|
group('when the restrict child is detached locally,', () {
|
||||||
|
late Person? visibleParent;
|
||||||
|
late UniqueSetNullChild? visibleChild;
|
||||||
|
late CrdtDataAttemptedValue? attempt;
|
||||||
|
late CrdtMergeSet changes;
|
||||||
|
|
||||||
|
setUpAll(() async {
|
||||||
|
await node.offlineSync.db.transactionForUser(testCrdtUserId, (tx) async {
|
||||||
|
await RestrictChild.db.updateRow(
|
||||||
|
node.offlineSync,
|
||||||
|
blocker.copyWith(parentId: null),
|
||||||
|
columns: (t) => [t.parentId],
|
||||||
|
transaction: tx,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
visibleParent = await Person.db.findById(node.offlineSync, parent.id!);
|
||||||
|
visibleChild = await UniqueSetNullChild.db.findById(
|
||||||
|
node.offlineSync,
|
||||||
|
child.id!,
|
||||||
|
);
|
||||||
|
attempt = await attemptedValue(
|
||||||
|
rowId: child.id!,
|
||||||
|
columnName: 'parentId',
|
||||||
|
databaseSession: node.offlineSync,
|
||||||
|
);
|
||||||
|
changes = await node.sync
|
||||||
|
.collectPendingChanges(
|
||||||
|
node.raw,
|
||||||
|
checkpointsBySpaceUuid: {testCrdtUserId: const []},
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'then the visible child is repaired to null while its parent is hidden.',
|
||||||
|
() {
|
||||||
|
expect(visibleParent, isNull);
|
||||||
|
expect(visibleChild, isNotNull);
|
||||||
|
expect(visibleChild!.parentId, isNull);
|
||||||
|
expect(attempt?.projectionReason, CrdtProjectionReason.foreignKeySetNull);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'then the original reference survives without an authored repair clock.',
|
||||||
|
() {
|
||||||
|
expect(attempt?.value.toString(), parent.id.toString());
|
||||||
|
final insert = changes.inserts.singleWhere(
|
||||||
|
(change) => change.uuidRowId == child.id,
|
||||||
|
);
|
||||||
|
expect((insert.data as UniqueSetNullChild).parentId, parent.id);
|
||||||
|
expect(insert.hlc, childHlc);
|
||||||
|
expect(
|
||||||
|
changes.updates.where((change) => change.uuidRowId == child.id),
|
||||||
|
isEmpty,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,135 @@
|
||||||
|
import 'package:serverpod_offline_sync_server/serverpod_offline_sync_server.dart';
|
||||||
|
import 'package:serverpod_offline_sync_test_client/serverpod_offline_sync_test_client.dart';
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
import '../test_tools/client_session.dart';
|
||||||
|
import '../test_tools/crdt_probes.dart';
|
||||||
|
import '../test_tools/sync_topology.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
initTestClientSession(createSessionPerTest: false);
|
||||||
|
|
||||||
|
group(
|
||||||
|
'Given a SET DEFAULT repair released to null because another child owns the default,',
|
||||||
|
() {
|
||||||
|
late SyncNode node;
|
||||||
|
late Town parent;
|
||||||
|
late Town defaultTown;
|
||||||
|
late Address address;
|
||||||
|
late UniqueSetDefaultChild loser;
|
||||||
|
late UniqueSetDefaultChild winner;
|
||||||
|
late Hlc childHlc;
|
||||||
|
|
||||||
|
setUpAll(() async {
|
||||||
|
node = await syncNode(await createAdditionalTestSession(), testSyncTables);
|
||||||
|
final mayor = Person(id: const Uuid().v7obj(), name: 'mayor');
|
||||||
|
parent = Town(id: const Uuid().v7obj(), name: 'parent', mayorId: mayor.id);
|
||||||
|
defaultTown = Town(
|
||||||
|
id: const UuidValue.raw('550e8400-e29b-41d4-a716-446655440000'),
|
||||||
|
name: 'default',
|
||||||
|
);
|
||||||
|
address = Address(
|
||||||
|
id: const Uuid().v7obj(),
|
||||||
|
street: 'street',
|
||||||
|
inhabitantId: mayor.id,
|
||||||
|
);
|
||||||
|
winner = UniqueSetDefaultChild(
|
||||||
|
id: const Uuid().v7obj(),
|
||||||
|
name: 'winner',
|
||||||
|
parentId: defaultTown.id,
|
||||||
|
);
|
||||||
|
loser = UniqueSetDefaultChild(
|
||||||
|
id: const Uuid().v7obj(),
|
||||||
|
name: 'loser',
|
||||||
|
parentId: parent.id,
|
||||||
|
);
|
||||||
|
await node.offlineSync.db.transactionForUser(testCrdtUserId, (tx) async {
|
||||||
|
await Person.db.insertRow(node.offlineSync, mayor, transaction: tx);
|
||||||
|
await Town.db.insert(node.offlineSync, [
|
||||||
|
parent,
|
||||||
|
defaultTown,
|
||||||
|
], transaction: tx);
|
||||||
|
await Address.db.insertRow(node.offlineSync, address, transaction: tx);
|
||||||
|
await UniqueSetDefaultChild.db.insertRow(
|
||||||
|
node.offlineSync,
|
||||||
|
winner,
|
||||||
|
transaction: tx,
|
||||||
|
);
|
||||||
|
await UniqueSetDefaultChild.db.insertRow(
|
||||||
|
node.offlineSync,
|
||||||
|
loser,
|
||||||
|
transaction: tx,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
childHlc = await rowHlc(loser.id!, databaseSession: node.offlineSync);
|
||||||
|
await node.offlineSync.db.mergeChanges([
|
||||||
|
CrdtMergeDelete(
|
||||||
|
uuidSpaceId: testCrdtUserId,
|
||||||
|
tableName: Town.t.tableName,
|
||||||
|
uuidRowId: parent.id!,
|
||||||
|
uuidNodeId: const Uuid().v7obj(),
|
||||||
|
hlcDatetime: childHlc.datetime.add(const Duration(milliseconds: 1)),
|
||||||
|
hlcCounter: 0,
|
||||||
|
clFlag: 2,
|
||||||
|
reason: CrdtDataDeletedReason.userDelete,
|
||||||
|
),
|
||||||
|
], spaceId: testCrdtUserId);
|
||||||
|
});
|
||||||
|
|
||||||
|
group('when an address is detached from the deleted town mayor locally,', () {
|
||||||
|
late UniqueSetDefaultChild? stored;
|
||||||
|
late UniqueSetDefaultChild? owner;
|
||||||
|
late Town? visibleParent;
|
||||||
|
late Town? visibleDefault;
|
||||||
|
late CrdtDataAttemptedValue? attempt;
|
||||||
|
late CrdtMergeSet changes;
|
||||||
|
|
||||||
|
setUpAll(() async {
|
||||||
|
await node.offlineSync.db.transactionForUser(testCrdtUserId, (tx) async {
|
||||||
|
await Address.db.updateRow(
|
||||||
|
node.offlineSync,
|
||||||
|
address.copyWith(inhabitantId: null),
|
||||||
|
columns: (t) => [t.inhabitantId],
|
||||||
|
transaction: tx,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
stored = await UniqueSetDefaultChild.db.findById(node.offlineSync, loser.id!);
|
||||||
|
owner = await UniqueSetDefaultChild.db.findById(node.offlineSync, winner.id!);
|
||||||
|
visibleParent = await Town.db.findById(node.offlineSync, parent.id!);
|
||||||
|
visibleDefault = await Town.db.findById(node.offlineSync, defaultTown.id!);
|
||||||
|
attempt = await attemptedValue(
|
||||||
|
rowId: loser.id!,
|
||||||
|
columnName: 'parentId',
|
||||||
|
databaseSession: node.offlineSync,
|
||||||
|
);
|
||||||
|
changes = await node.sync
|
||||||
|
.collectPendingChanges(
|
||||||
|
node.raw,
|
||||||
|
checkpointsBySpaceUuid: {testCrdtUserId: const []},
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('then the null release retains its terminal unique-conflict reason.', () {
|
||||||
|
expect(visibleParent, isNull);
|
||||||
|
expect(visibleDefault, isNotNull);
|
||||||
|
expect(owner!.parentId, defaultTown.id);
|
||||||
|
expect(stored, isNotNull);
|
||||||
|
expect(stored!.parentId, isNull);
|
||||||
|
expect(attempt?.projectionReason, CrdtProjectionReason.uniqueConflict);
|
||||||
|
|
||||||
|
expect(attempt?.value.toString(), parent.id.toString());
|
||||||
|
final insert = changes.inserts.singleWhere(
|
||||||
|
(change) => change.uuidRowId == loser.id,
|
||||||
|
);
|
||||||
|
expect((insert.data as UniqueSetDefaultChild).parentId, parent.id);
|
||||||
|
expect(insert.hlc, childHlc);
|
||||||
|
expect(
|
||||||
|
changes.updates.where((change) => change.uuidRowId == loser.id),
|
||||||
|
isEmpty,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,132 @@
|
||||||
|
import 'package:serverpod_offline_sync_server/serverpod_offline_sync_server.dart';
|
||||||
|
import 'package:serverpod_offline_sync_test_client/serverpod_offline_sync_test_client.dart';
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
import '../test_tools/client_session.dart';
|
||||||
|
import '../test_tools/crdt_probes.dart';
|
||||||
|
import '../test_tools/sync_topology.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
initTestClientSession(createSessionPerTest: false);
|
||||||
|
|
||||||
|
group('Given a unique SET DEFAULT loser displaying null for its authored parent,', () {
|
||||||
|
late SyncNode node;
|
||||||
|
late Town parent;
|
||||||
|
late UniqueSetDefaultChild winner;
|
||||||
|
late UniqueSetDefaultChild loser;
|
||||||
|
|
||||||
|
setUpAll(() async {
|
||||||
|
node = await syncNode(await createAdditionalTestSession(), testSyncTables);
|
||||||
|
parent = Town(id: const Uuid().v7obj(), name: 'parent');
|
||||||
|
winner = UniqueSetDefaultChild(
|
||||||
|
id: const Uuid().v7obj(),
|
||||||
|
name: 'winner',
|
||||||
|
parentId: parent.id,
|
||||||
|
);
|
||||||
|
loser = UniqueSetDefaultChild(
|
||||||
|
id: const Uuid().v7obj(),
|
||||||
|
name: 'loser',
|
||||||
|
parentId: parent.id,
|
||||||
|
);
|
||||||
|
await node.offlineSync.db.transactionForUser(testCrdtUserId, (tx) async {
|
||||||
|
await Town.db.insert(node.offlineSync, [
|
||||||
|
parent,
|
||||||
|
Town(
|
||||||
|
id: const UuidValue.raw('550e8400-e29b-41d4-a716-446655440000'),
|
||||||
|
name: 'default',
|
||||||
|
),
|
||||||
|
], transaction: tx);
|
||||||
|
await UniqueSetDefaultChild.db.insertRow(
|
||||||
|
node.offlineSync,
|
||||||
|
winner,
|
||||||
|
transaction: tx,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
final winnerHlc = await rowHlc(winner.id!, databaseSession: node.offlineSync);
|
||||||
|
await node.offlineSync.db.mergeChanges([
|
||||||
|
CrdtMergeInsert(
|
||||||
|
uuidSpaceId: testCrdtUserId,
|
||||||
|
tableName: UniqueSetDefaultChild.t.tableName,
|
||||||
|
uuidRowId: loser.id!,
|
||||||
|
uuidNodeId: const Uuid().v7obj(),
|
||||||
|
hlcDatetime: winnerHlc.datetime.add(const Duration(milliseconds: 1)),
|
||||||
|
hlcCounter: 0,
|
||||||
|
data: loser,
|
||||||
|
),
|
||||||
|
], spaceId: testCrdtUserId);
|
||||||
|
loser = (await UniqueSetDefaultChild.db.findById(node.offlineSync, loser.id!))!;
|
||||||
|
});
|
||||||
|
|
||||||
|
group(
|
||||||
|
'when a full-row upsert echoes the released null and the winner is later deleted,',
|
||||||
|
() {
|
||||||
|
late UniqueSetDefaultChild? stored;
|
||||||
|
late CrdtDataAttemptedValue? attempt;
|
||||||
|
late CrdtMergeSet changes;
|
||||||
|
late UniqueSetDefaultChild? restored;
|
||||||
|
|
||||||
|
setUpAll(() async {
|
||||||
|
await node.offlineSync.db.transactionForUser(testCrdtUserId, (tx) async {
|
||||||
|
await UniqueSetDefaultChild.db.upsertRow(
|
||||||
|
node.offlineSync,
|
||||||
|
loser.copyWith(name: 'edited'),
|
||||||
|
conflictColumns: (t) => [t.id],
|
||||||
|
transaction: tx,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
stored = await UniqueSetDefaultChild.db.findById(node.offlineSync, loser.id!);
|
||||||
|
attempt = await attemptedValue(
|
||||||
|
rowId: loser.id!,
|
||||||
|
columnName: 'parentId',
|
||||||
|
databaseSession: node.offlineSync,
|
||||||
|
);
|
||||||
|
changes = await node.sync
|
||||||
|
.collectPendingChanges(
|
||||||
|
node.raw,
|
||||||
|
checkpointsBySpaceUuid: {testCrdtUserId: const []},
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
await node.offlineSync.db.transactionForUser(testCrdtUserId, (tx) async {
|
||||||
|
await UniqueSetDefaultChild.db.deleteRow(
|
||||||
|
node.offlineSync,
|
||||||
|
winner,
|
||||||
|
transaction: tx,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
restored = await UniqueSetDefaultChild.db.findById(
|
||||||
|
node.offlineSync,
|
||||||
|
loser.id!,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('then the authored parent survives behind the unique release.', () {
|
||||||
|
expect(loser.parentId, isNull);
|
||||||
|
expect(stored!.name, 'edited');
|
||||||
|
expect(stored!.parentId, isNull);
|
||||||
|
expect(attempt?.value.toString(), parent.id.toString());
|
||||||
|
expect(attempt?.projectionReason, CrdtProjectionReason.uniqueConflict);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('then outbound sync never authors the column default.', () {
|
||||||
|
final insert = changes.inserts.singleWhere(
|
||||||
|
(change) => change.uuidRowId == loser.id,
|
||||||
|
);
|
||||||
|
expect((insert.data as UniqueSetDefaultChild).parentId, parent.id);
|
||||||
|
expect(
|
||||||
|
changes.updates
|
||||||
|
.where(
|
||||||
|
(change) =>
|
||||||
|
change.uuidRowId == loser.id && change.columnName == 'parentId',
|
||||||
|
)
|
||||||
|
.every((change) => change.value.toString() == parent.id.toString()),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('then the original authored parent is reclaimed.', () {
|
||||||
|
expect(restored!.parentId, parent.id);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue