fix: Author column values written by set-based updates (#147)

This commit is contained in:
Marcelo Mendonça Soares 2026-09-17 16:18:18 -03:00 • committed by GitHub
commit 811145eda6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 195 additions and 12 deletions

View file

@ -804,7 +804,12 @@ class OfflineSyncDatabase implements Database {
final columns = columnValues.map((e) => e.column).toList(); final columns = columnValues.map((e) => e.column).toList();
_recorder.validateAuthoredRows(result, columns); _recorder.validateAuthoredRows(result, columns);
await _recorder.afterUpdate(result, columns, tx); await _recorder.afterUpdate(
result,
columns,
tx,
authoredColumnValues: true,
);
if (noReturn) return <T>[]; if (noReturn) return <T>[];
result.forEach(_stripSpaceId); result.forEach(_stripSpaceId);
return result; return result;

View file

@ -837,11 +837,19 @@ class CrdtMutationRecorder {
} }
/// Records CRDT field metadata for updated rows. /// Records CRDT field metadata for updated rows.
///
/// [authoredColumnValues] marks a write that reached the domain without a
/// planning pass, which is the set-based `updateWhere`. Every accepted column
/// value it carries is authored, so it overlays the attempted value a
/// projection is holding for the same field. Without the overlay the pass
/// below would read that stale attempt as the authored value and restore it
/// over the write.
Future<void> afterUpdate<T extends TableRow>( Future<void> afterUpdate<T extends TableRow>(
List<T> updatedRows, List<T> updatedRows,
List<Column>? columns, List<Column>? columns,
Transaction transaction, { Transaction transaction, {
bool projectionUnchanged = false, bool projectionUnchanged = false,
bool authoredColumnValues = false,
Map<MergeRowKey, Map<String, Object?>> domainBeforeUpsert = const {}, Map<MergeRowKey, Map<String, Object?>> domainBeforeUpsert = const {},
}) async { }) async {
await _foreignKeyProjector.assertVisibleTargets(updatedRows, columns, transaction); await _foreignKeyProjector.assertVisibleTargets(updatedRows, columns, transaction);
@ -857,22 +865,29 @@ class CrdtMutationRecorder {
'updated', 'updated',
transaction, transaction,
); );
// Only rows returned by the physical upsert were accepted by updateWhere.
// 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?>{
for (final row in updatedRows) if (authoredColumnValues)
if (domainBeforeUpsert.containsKey((tableName, row.id))) for (final row in updatedRows)
for (final MapEntry(key: columnName, value: value) for (final MapEntry(key: columnName, value: value)
in _authoredValuesFromRow(row, columns).entries) in _authoredValuesFromRow(row, columns).entries)
if ((domainBeforeUpsert[(tableName, row.id)]?.containsKey(columnName) ?? (tableName, row.id as UuidValue, columnName): value
false) && else
(columns != null || // Only rows returned by the physical upsert were accepted by its
!projectionValuesEqual( // `updateWhere` predicate.
domainBeforeUpsert[(tableName, row.id)]![columnName], for (final row in updatedRows)
value, if (domainBeforeUpsert.containsKey((tableName, row.id)))
))) for (final MapEntry(key: columnName, value: value)
(tableName, row.id as UuidValue, columnName): value, in _authoredValuesFromRow(row, columns).entries)
if ((domainBeforeUpsert[(tableName, row.id)]?.containsKey(columnName) ??
false) &&
(columns != null ||
!projectionValuesEqual(
domainBeforeUpsert[(tableName, row.id)]![columnName],
value,
)))
(tableName, row.id as UuidValue, columnName): value,
}; };
_uniqueResolver.validateAuthoredFields(authoredOverlays); _uniqueResolver.validateAuthoredFields(authoredOverlays);
final implicitForeignKeyRepairFields = columns == null final implicitForeignKeyRepairFields = columns == null

View file

@ -463,6 +463,84 @@ void main() {
}); });
}); });
group('Given a merged town with a withheld mayor,', () {
late SyncNode node;
late Person mayor;
late Town town;
setUpAll(() async {
node = await syncNode(await createAdditionalTestSession(), testSyncTables);
mayor = Person(id: const Uuid().v7obj(), name: 'mayor');
town = Town(id: const Uuid().v7obj(), name: 'town', mayorId: mayor.id);
final hlc = Hlc(DateTime.now().toUtc(), 0, const Uuid().v7obj());
await node.offlineSync.db.mergeChanges([
CrdtMergeInsert(
uuidSpaceId: testCrdtUserId,
tableName: Town.t.tableName,
uuidRowId: town.id!,
uuidNodeId: hlc.nodeId,
hlcDatetime: hlc.datetime,
hlcCounter: hlc.counter,
data: town,
),
], spaceId: testCrdtUserId);
});
group('when a set-based write clears the mayor,', () {
setUpAll(() async {
// A narrowed write authors the column it names, so it replaces the claim
// the projection is holding rather than passing over it. The column
// already displays null, which is exactly when a write that did not
// author would leave the withheld mayor to reappear later.
await node.offlineSync.db.transactionForUser(
testCrdtUserId,
(tx) => Town.db.updateWhere(
node.offlineSync,
columnValues: (t) => [t.mayorId(null)],
where: (t) => t.id.equals(town.id),
transaction: tx,
),
);
});
test('then the town no longer holds a withheld mayor.', () async {
expect(
await attemptedValue(
rowId: town.id!,
columnName: 'mayorId',
databaseSession: node.offlineSync,
),
isNull,
);
});
});
group(
'when a set-based write clears the mayor and the missing person is inserted locally,',
() {
setUpAll(() async {
await node.offlineSync.db.transactionForUser(
testCrdtUserId,
(tx) => Town.db.updateWhere(
node.offlineSync,
columnValues: (t) => [t.mayorId(null)],
where: (t) => t.id.equals(town.id),
transaction: tx,
),
);
await node.offlineSync.db.transactionForUser(
testCrdtUserId,
(tx) => Person.db.insertRow(node.offlineSync, mayor, transaction: tx),
);
});
test('then the cleared mayor stays cleared.', () async {
expect((await Town.db.findById(node.offlineSync, town.id!))!.mayorId, isNull);
});
},
);
});
group( group(
'Given two merged unique children waiting for unavailable parents and a missing fallback,', 'Given two merged unique children waiting for unavailable parents and a missing fallback,',
() { () {

View file

@ -231,6 +231,91 @@ void main() {
expect((await Unique.db.findById(session, winner.id!))!.name, 'shared-name'); expect((await Unique.db.findById(session, winner.id!))!.name, 'shared-name');
}, },
); );
test(
'when a set-based write gives the loser a free name, '
'then the write is authored and the released claim is not restored.',
() async {
await session.db.transactionForUser(
testCrdtUserId,
(tx) => Unique.db.updateWhere(
session,
columnValues: (t) => [t.name('renamed')],
where: (t) => t.id.equals(loser.id),
transaction: tx,
),
);
expect((await Unique.db.findById(session, loser.id!))!.name, 'renamed');
expect(
await attemptedValue(
rowId: loser.id!,
columnName: Unique.t.name.columnName,
),
isNull,
);
expect((await Unique.db.findById(session, winner.id!))!.name, 'shared-name');
},
);
},
);
group(
'Given a nullable unique loser whose claim was released to null, ',
() {
late UniqueNullable winner;
late UniqueNullable loser;
setUp(() async {
winner = await session.db.transactionForUser(
testCrdtUserId,
(tx) => UniqueNullable.db.insertRow(
session,
UniqueNullable(id: const Uuid().v7obj(), value: 1),
transaction: tx,
),
);
loser = UniqueNullable(id: const Uuid().v7obj(), value: 1);
await mergeIndependentInsert(
session,
loser,
space: testCrdtUserId,
tables: [UniqueNullable.t],
);
});
test(
'when a set-based write authors null on both rows, '
'then the loser keeps the written null rather than reclaiming its value.',
() async {
// The loser displays the null its release wrote, so writing null is
// an unchanged domain value. A narrowed write authors it anyway:
// otherwise the freed claim behind it would win the reprojection the
// winner's own null triggers, and reappear as a value nobody wrote.
await session.db.transactionForUser(
testCrdtUserId,
(tx) => UniqueNullable.db.updateWhere(
session,
columnValues: (t) => [t.value(null)],
where: (t) => t.id.inSet(<UuidValue>{winner.id!, loser.id!}),
transaction: tx,
),
);
expect((await UniqueNullable.db.findById(session, loser.id!))!.value, isNull);
expect(
await attemptedValue(
rowId: loser.id!,
columnName: UniqueNullable.t.value.columnName,
),
isNull,
);
expect(
(await UniqueNullable.db.findById(session, winner.id!))!.value,
isNull,
);
},
);
}, },
); );