feat: Add support for the upsert and upsertRow ORM methods (#94)

This commit is contained in:
Marcelo Mendonça Soares 2026-07-04 21:17:08 -03:00 • committed by GitHub
commit 467df68a69
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 796 additions and 19 deletions

View file

@ -30,9 +30,9 @@ Future<void> main(List<String> args) async {
for (final operation in Operation.values) {
final baselineResult = await runWithProgress(
'Running ${operation.name} benchmark (baseline)',
'Running ${operation.label} benchmark (baseline)',
() => TypesTableBenchmark(
'${operation.name} (baseline)',
'${operation.label} (baseline)',
crdtEnabled: false,
operation: operation,
rowCount: rowCount,
@ -41,9 +41,9 @@ Future<void> main(List<String> args) async {
);
final crdtResult = await runWithProgress(
'Running ${operation.name} benchmark (CRDT)',
'Running ${operation.label} benchmark (CRDT)',
() => TypesTableBenchmark(
'${operation.name} (CRDT)',
'${operation.label} (CRDT)',
crdtEnabled: true,
operation: operation,
rowCount: rowCount,
@ -79,7 +79,7 @@ Future<void> main(List<String> args) async {
final baselineDelay = result.baseline.toFormattedDuration();
final crdtDelay = result.crdt.toFormattedDuration();
print(
' - ${result.operation.name.toUpperCase()}: $baselineDelay > $crdtDelay',
' - ${result.operation.label.toUpperCase()}: $baselineDelay > $crdtDelay',
);
}
}

View file

@ -11,7 +11,15 @@ import 'package:serverpod_offline_sync_test_client/serverpod_offline_sync_test_c
import 'tables.dart';
enum Operation { select, insert, update, delete }
enum Operation { select, insert, update, upsertInsert, upsertUpdate, delete }
extension OperationLabel on Operation {
String get label => switch (this) {
Operation.upsertInsert => 'upsert (insert)',
Operation.upsertUpdate => 'upsert (update)',
_ => name,
};
}
enum StorageStage { insert, update, delete }
@ -175,6 +183,74 @@ class TypesTableBenchmark extends AsyncBenchmarkBase {
}
}
/// Upserts a batch of updated copies of the seeded rows plus fresh rows
/// without ids up to [rowCount], so the seeded count set by [_prepareCycle]
/// determines the conflict-update vs insert mix.
Future<void> _upsertTypes() async {
final baseTimestamp = DateTime.now();
final updated = _seededRows.map((row) {
final i = _valueSeq++;
return row.copyWith(
aBool: i.isEven,
aDateTime: baseTimestamp.add(Duration(seconds: i)),
aText: 'Text $i',
anInt: i,
anInt64: BigInt.from(i),
aReal: i.toDouble(),
aBlob: _emptyBlob,
optionalUuid: const Uuid().v7obj(),
);
}).toList();
final freshCount = rowCount - updated.length;
final start = _valueSeq;
_valueSeq += freshCount;
final batch = [
...updated,
for (var j = 0; j < freshCount; j++) _createTypesRow(start + j, baseTimestamp),
];
if (crdtEnabled) {
_seededRows = await _crdtSession!.db.transactionForUser(
_userId,
(tx) async {
return Types.db.upsert(
_crdtSession!,
batch,
conflictColumns: (t) => [t.id],
updateColumns: (t) => [
t.aBool,
t.aDateTime,
t.aText,
t.anInt,
t.anInt64,
t.aReal,
t.aBlob,
t.optionalUuid,
],
transaction: tx,
);
},
);
} else {
_seededRows = await Types.db.upsert(
_plainSession,
batch,
conflictColumns: (t) => [t.id],
updateColumns: (t) => [
t.aBool,
t.aDateTime,
t.aText,
t.anInt,
t.anInt64,
t.aReal,
t.aBlob,
t.optionalUuid,
],
);
}
}
Future<List<Types>> _selectTypes() async {
final rows = await Types.db.find(crdtEnabled ? _crdtSession! : _plainSession);
if (rows.length != rowCount) {
@ -228,6 +304,11 @@ class TypesTableBenchmark extends AsyncBenchmarkBase {
case Operation.update:
_seededRows = await _insertTypes(rowCount);
return;
case Operation.upsertInsert:
return;
case Operation.upsertUpdate:
_seededRows = await _insertTypes(rowCount);
return;
case Operation.delete:
_seededRows = await _insertTypes(rowCount);
return;
@ -305,6 +386,11 @@ class TypesTableBenchmark extends AsyncBenchmarkBase {
await _updateTypesInPlace();
_lastRowsCount = _seededRows.length;
return;
case Operation.upsertInsert:
case Operation.upsertUpdate:
await _upsertTypes();
_lastRowsCount = _seededRows.length;
return;
case Operation.delete:
await _deleteTypes(_seededRows);
_seededRows = [];

View file

@ -11,7 +11,7 @@ void printPerformanceImpact(
}) {
print(
'${runningInCI ? '```' : ''}'
'\n📊 ${results.operation.name.toUpperCase()} performance impact:',
'\n📊 ${results.operation.label.toUpperCase()} performance impact:',
);
final baselineTime = results.baseline;
final crdtTime = results.crdt;
@ -27,7 +27,7 @@ void printPerformanceImpact(
print(' Time: $baselineDelay --> $crdtDelay ($runDelay)');
print(' CRDT overhead: ${formatter2.format(slowdown)}% slower');
if (results.operation != Operation.select) {
print(' Delay per ${results.operation.name}: $runDelayPerOperation');
print(' Delay per ${results.operation.label}: $runDelayPerOperation');
}
if (runningInCI) print('```');
}

View file

@ -398,9 +398,132 @@ class CrdtDatabase implements Database {
Transaction? transaction,
bool noReturn = false,
}) async {
if (rows.isEmpty) return [];
await _ensureInitialized();
// TODO: Implement CRDT upsert.
throw UnimplementedError('CRDT upsert is not implemented.');
if (!_recorder.isCrdtTracked<T>(rows.first.table)) {
return _delegate.upsert<T>(
rows,
conflictColumns: conflictColumns,
updateColumns: updateColumns,
updateWhere: updateWhere,
transaction: transaction,
noReturn: noReturn,
);
}
return DatabaseUtil.runInTransactionOrSavepoint(
_delegate,
transaction,
(tx) async {
final prepared = _prepareRowsForInsert(rows, tx);
// CRDT metadata needs the affected rows even when the public call uses
// noReturn, because inserted rows may have database-generated ids.
final result = await _delegate.upsert<T>(
prepared.rows,
conflictColumns: conflictColumns,
updateColumns: updateColumns,
updateWhere: await _whereVisibleWithTombstone<T>(
updateWhere,
null,
tx,
membershipWide: false,
),
transaction: tx,
);
final reinsertedRows = await _reinsertTombstonedRows(
prepared.rows,
result,
tx,
);
final insertedRows = await _rowsWithoutCrdtMetadata(result, tx);
await _recorder.afterInsert(insertedRows, tx);
final insertedRowIds = {
for (final row in insertedRows)
if (row.id is UuidValue) row.id as UuidValue,
};
final updatedRows = [
for (final row in result)
if (row.id is! UuidValue || !insertedRowIds.contains(row.id)) row,
];
await _recorder.afterUpdate(updatedRows, updateColumns, tx);
if (noReturn) return <T>[];
_stripStampedRows(result, prepared);
for (final row in reinsertedRows) {
final rowId = row.id;
if (rowId == null || !prepared.explicitRowIds.contains(rowId)) {
_stripScopeId(row);
}
}
return [...result, ...reinsertedRows];
},
);
}
/// Reinserts upserted rows whose conflict target is a tombstoned row.
///
/// The visibility filter merged into `updateWhere` keeps the delegate upsert
/// from updating hidden rows, so they come back missing from [result]. From
/// the caller's perspective a deleted row does not exist, so the upsert must
/// behave as an insert: the domain row is overwritten with the incoming
/// values — ignoring `updateColumns` and `updateWhere` — and the tombstone
/// is lifted, mirroring the [insertRow] reinsert path.
Future<List<T>> _reinsertTombstonedRows<T extends TableRow>(
List<T> preparedRows,
List<T> result,
Transaction transaction,
) async {
final returnedIds = <Object>{
for (final row in result)
if (row.id != null) row.id as Object,
};
final rowsToReinsert = <T>[];
for (final row in preparedRows) {
final rowId = row.id;
if (rowId == null || returnedIds.contains(rowId)) continue;
if (await _recorder.isDeleted(row, transaction)) rowsToReinsert.add(row);
}
if (rowsToReinsert.isEmpty) return [];
final reinsertedRows = await _delegate.update<T>(
rowsToReinsert,
transaction: transaction,
);
await _recorder.afterReinsert(reinsertedRows, transaction);
return reinsertedRows;
}
Future<List<T>> _rowsWithoutCrdtMetadata<T extends TableRow>(
List<T> rows,
Transaction transaction,
) async {
if (rows.isEmpty) return [];
final rowIds = {
for (final row in rows)
if (row.id is UuidValue) row.id as UuidValue,
};
if (rowIds.isEmpty) return [];
final tableId = _recorder.tableIdForName(rows.first.table.tableName)!;
final scopeId = _requireEffectiveScope(transaction).id!;
final crdtRows = await CrdtDataRow.db.find(
_delegate.session,
where: (t) =>
t.scopeId.equals(scopeId) &
t.tblId.equals(tableId) &
t.uuidRowId.inSet(rowIds),
transaction: transaction,
);
final existingRowIds = crdtRows.map((row) => row.uuidRowId).toSet();
return [
for (final row in rows)
if (row.id is UuidValue && !existingRowIds.contains(row.id)) row,
];
}
@override
@ -412,17 +535,21 @@ class CrdtDatabase implements Database {
Transaction? transaction,
}) async {
await _ensureInitialized();
if (T == CrdtSyncIntegrityViolation) {
return _delegate.upsertRow<T>(
row,
conflictColumns: conflictColumns,
updateColumns: updateColumns,
updateWhere: updateWhere,
transaction: transaction,
final result = await upsert<T>(
[row],
conflictColumns: conflictColumns,
updateColumns: updateColumns,
updateWhere: updateWhere,
transaction: transaction,
);
if (result.length > 1) {
// FIXME: We can't use the proper `DatabaseUpsertRowException` because
// it is declared as a base type on the `serverpod_database` package.
throw Exception(
'Failed to upsert row, affected number of rows is ${result.length} != 1',
);
}
// TODO: Implement CRDT upsert.
throw UnimplementedError('CRDT upsert is not implemented.');
return result.firstOrNull;
}
@override

View file

@ -0,0 +1,564 @@
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';
void main() {
initTestClientSession();
group('Given an empty person table,', () {
group('when upserting a Person with upsertRow,', () {
late UuidValue personId;
late Person? person;
setUp(() async {
personId = const Uuid().v7obj();
person = await session.db.transactionForUser(
testCrdtUserId,
(tx) => Person.db.upsertRow(
session,
Person(id: personId, name: 'created'),
conflictColumns: (t) => [t.id],
transaction: tx,
),
);
});
test('then the returned row keeps scopeId null.', () async {
expect(person, isNotNull);
expect(person!.scopeId, isNull);
});
test('then the row exists in the person table.', () async {
final row = await Person.db.findById(session, personId);
expect(row?.name, 'created');
});
test('then CRDT metadata is recorded for the row.', () async {
final crdtRow = await CrdtDataRow.db.findFirstRow(
session,
where: (t) => t.uuidRowId.equals(personId),
);
expect(crdtRow, isNotNull);
});
test('then no CRDT field is created for the inserted row.', () async {
final fieldCount = await CrdtDataField.db.count(
session,
where: (t) => t.row.uuidRowId.equals(personId),
);
expect(fieldCount, 0);
});
});
group('when upserting a Person without an id with upsertRow,', () {
late Person? created;
setUp(() async {
created = await session.db.transactionForUser(
testCrdtUserId,
(tx) => Person.db.upsertRow(
session,
Person(name: 'created without id'),
conflictColumns: (t) => [t.id],
transaction: tx,
),
);
});
test('then the returned row has a generated id and null scopeId.', () {
expect(created, isNotNull);
expect(created!.id, isNotNull);
expect(created!.scopeId, isNull);
});
test('then the row exists in the person table.', () async {
final row = await Person.db.findById(session, created!.id!);
expect(row?.name, 'created without id');
});
test('then CRDT metadata is recorded for the row.', () async {
final crdtRow = await CrdtDataRow.db.findFirstRow(
session,
where: (t) => t.uuidRowId.equals(created!.id),
);
expect(crdtRow, isNotNull);
});
test('then no CRDT field is created for the inserted row.', () async {
final fieldCount = await CrdtDataField.db.count(
session,
where: (t) => t.row.uuidRowId.equals(created!.id),
);
expect(fieldCount, 0);
});
});
});
group('Given a person table with an existing row,', () {
late Person person;
late CrdtDataRow insertedCrdtRow;
setUp(() async {
person = await session.db.transactionForUser(
testCrdtUserId,
(tx) => Person.db.insertRow(
session,
Person(name: 'original'),
transaction: tx,
),
);
insertedCrdtRow = (await CrdtDataRow.db.findFirstRow(
session,
where: (t) => t.uuidRowId.equals(person.id),
include: CrdtDataRow.include(node: CrdtNode.include()),
))!;
});
group('when upserting the Person name column with upsertRow,', () {
late Person? updatedPerson;
setUp(() async {
updatedPerson = await session.db.transactionForUser(
testCrdtUserId,
(tx) => Person.db.upsertRow(
session,
person.copyWith(name: 'updated'),
conflictColumns: (t) => [t.id],
updateColumns: (t) => [t.name],
transaction: tx,
),
);
});
test('then the returned row keeps scopeId null.', () async {
expect(updatedPerson, isNotNull);
expect(updatedPerson!.scopeId, isNull);
});
test('then the person row reflects the new values.', () async {
final row = await Person.db.findById(session, person.id!);
expect(row?.name, 'updated');
});
test('then the previously existing CRDT row is not affected.', () async {
final crdtRow = await CrdtDataRow.db.findFirstRow(
session,
where: (t) => t.uuidRowId.equals(person.id),
include: CrdtDataRow.include(node: CrdtNode.include()),
);
expect(crdtRow!.uuidRowId, insertedCrdtRow.uuidRowId);
expect(crdtRow.hlc, insertedCrdtRow.hlc);
});
test('then a CRDT field is created for the name column.', () async {
final field = await CrdtDataField.db.findFirstRow(
session,
where: (t) => t.row.uuidRowId.equals(person.id),
include: CrdtDataField.include(column: CrdtSchemaColumn.include()),
);
expect(field, isNotNull);
expect(field!.column!.name, 'name');
});
});
group('when upserting the Person with a non-matching updateWhere,', () {
late Person? updatedPerson;
setUp(() async {
updatedPerson = await session.db.transactionForUser(
testCrdtUserId,
(tx) => Person.db.upsertRow(
session,
person.copyWith(name: 'skipped'),
conflictColumns: (t) => [t.id],
updateColumns: (t) => [t.name],
updateWhere: (t) => t.name.equals('other'),
transaction: tx,
),
);
});
test('then no row is returned.', () async {
expect(updatedPerson, isNull);
});
test('then the person row is unchanged.', () async {
final row = await Person.db.findById(session, person.id!);
expect(row?.name, 'original');
});
test('then no CRDT field is created for the skipped update.', () async {
final fieldCount = await CrdtDataField.db.count(
session,
where: (t) => t.row.uuidRowId.equals(person.id),
);
expect(fieldCount, 0);
});
});
group('when upserting an existing Person and a new Person without an id,', () {
late List<Person> upserted;
setUp(() async {
upserted = await session.db.transactionForUser(
testCrdtUserId,
(tx) => Person.db.upsert(
session,
[
person.copyWith(name: 'batch updated'),
Person(name: 'batch created'),
],
conflictColumns: (t) => [t.id],
updateColumns: (t) => [t.name],
transaction: tx,
),
);
});
test('then both rows are returned with scopeId null.', () {
expect(upserted, hasLength(2));
expect(upserted.map((e) => e.scopeId), everyElement(isNull));
});
test('then the existing row keeps its id and reflects the new values.', () {
final updated = upserted.singleWhere((e) => e.name == 'batch updated');
expect(updated.id, person.id);
});
test('then the created row has a generated id and is persisted.', () async {
final created = upserted.singleWhere((e) => e.name == 'batch created');
expect(created.id, isNotNull);
final row = await Person.db.findById(session, created.id!);
expect(row?.name, 'batch created');
});
test('then CRDT row metadata is recorded for both affected rows.', () async {
final created = upserted.singleWhere((e) => e.name == 'batch created');
final crdtRows = await CrdtDataRow.db.find(
session,
where: (t) => t.uuidRowId.inSet({person.id!, created.id!}),
);
expect(crdtRows.map((e) => e.uuidRowId).toSet(), {
person.id,
created.id,
});
});
test(
'then CRDT fields are recorded only for the existing updated row.',
() async {
final created = upserted.singleWhere((e) => e.name == 'batch created');
final fields = await CrdtDataField.db.find(
session,
where: (t) => t.row.uuidRowId.inSet({person.id!, created.id!}),
include: CrdtDataField.include(
column: CrdtSchemaColumn.include(),
row: CrdtDataRow.include(),
),
);
expect(fields.map((e) => e.row!.uuidRowId).toSet(), {
person.id,
});
expect(fields.map((e) => e.column!.name).toSet(), {'name'});
},
);
});
group('when upserting an existing Person and a new Person with noReturn,', () {
late Person? createdPerson;
late List<Person> upserted;
setUp(() async {
upserted = await session.db.transactionForUser(
testCrdtUserId,
(tx) => Person.db.upsert(
session,
[
person.copyWith(name: 'batch updated'),
Person(name: 'batch created'),
],
conflictColumns: (t) => [t.id],
updateColumns: (t) => [t.name],
transaction: tx,
noReturn: true,
),
);
createdPerson = await Person.db.findFirstRow(
session,
where: (t) => t.name.equals('batch created'),
);
});
test('then an empty list is returned.', () async {
expect(upserted, isEmpty);
});
test('then the existing person row reflects the new values.', () async {
final row = await Person.db.findById(session, person.id!);
expect(row?.name, 'batch updated');
});
test('then the new person row exists in the person table.', () async {
expect(createdPerson, isNotNull);
expect(createdPerson!.id, isNotNull);
});
test('then CRDT row metadata is recorded for both affected rows.', () async {
final crdtRows = await CrdtDataRow.db.find(
session,
where: (t) => t.uuidRowId.inSet({person.id!, createdPerson!.id!}),
);
expect(crdtRows.map((e) => e.uuidRowId).toSet(), {
person.id,
createdPerson!.id,
});
});
test(
'then CRDT fields are recorded only for the existing updated row.',
() async {
final fields = await CrdtDataField.db.find(
session,
where: (t) => t.row.uuidRowId.inSet({person.id!, createdPerson!.id!}),
include: CrdtDataField.include(
column: CrdtSchemaColumn.include(),
row: CrdtDataRow.include(),
),
);
expect(fields.map((e) => e.row!.uuidRowId).toSet(), {
person.id,
});
expect(fields.map((e) => e.column!.name).toSet(), {'name'});
},
);
});
});
group('Given a person table with a deleted row,', () {
late Person person;
late CrdtDataRow insertedCrdtRow;
setUp(() async {
person = await session.db.transactionForUser(
testCrdtUserId,
(tx) => Person.db.insertRow(
session,
Person(name: 'original'),
transaction: tx,
),
);
insertedCrdtRow = (await CrdtDataRow.db.findFirstRow(
session,
where: (t) => t.uuidRowId.equals(person.id),
include: CrdtDataRow.include(node: CrdtNode.include()),
))!;
await session.db.transactionForUser(
testCrdtUserId,
(tx) => Person.db.deleteRow(session, person, transaction: tx),
);
});
group('when upserting the Person with upsertRow,', () {
late Person? reinserted;
setUp(() async {
reinserted = await session.db.transactionForUser(
testCrdtUserId,
(tx) => Person.db.upsertRow(
session,
person.copyWith(name: 'reinserted'),
conflictColumns: (t) => [t.id],
updateColumns: (t) => [t.name],
transaction: tx,
),
);
});
test('then the row is returned with the new values and null scopeId.', () {
expect(reinserted, isNotNull);
expect(reinserted!.name, 'reinserted');
expect(reinserted!.scopeId, isNull);
});
test('then the row is visible again.', () async {
final row = await Person.db.findById(session, person.id!);
expect(row?.name, 'reinserted');
});
test('then the CRDT metadata row is updated.', () async {
final crdtRow = await CrdtDataRow.db.findFirstRow(
session,
where: (t) => t.uuidRowId.equals(person.id),
include: CrdtDataRow.include(node: CrdtNode.include()),
);
expect(crdtRow!.hlc, greaterThan(insertedCrdtRow.hlc));
});
});
group('when upserting the Person with a non-matching updateWhere,', () {
late Person? reinserted;
setUp(() async {
reinserted = await session.db.transactionForUser(
testCrdtUserId,
(tx) => Person.db.upsertRow(
session,
person.copyWith(name: 'reinserted'),
conflictColumns: (t) => [t.id],
updateColumns: (t) => [t.name],
updateWhere: (t) => t.name.equals('other'),
transaction: tx,
),
);
});
test(
'then the row is reinserted anyway, as it does not exist for the caller.',
() async {
expect(reinserted, isNotNull);
expect(reinserted!.name, 'reinserted');
final row = await Person.db.findById(session, person.id!);
expect(row?.name, 'reinserted');
},
);
});
});
group('Given a person table with a live and a deleted row,', () {
late Person deletedPerson;
late Person livePerson;
setUp(() async {
deletedPerson = await session.db.transactionForUser(
testCrdtUserId,
(tx) => Person.db.insertRow(
session,
Person(name: 'deleted'),
transaction: tx,
),
);
livePerson = await session.db.transactionForUser(
testCrdtUserId,
(tx) => Person.db.insertRow(
session,
Person(name: 'live'),
transaction: tx,
),
);
await session.db.transactionForUser(
testCrdtUserId,
(tx) => Person.db.deleteRow(session, deletedPerson, transaction: tx),
);
});
group('when upserting the live, the deleted, and a new row in one batch,', () {
late List<Person> upserted;
setUp(() async {
upserted = await session.db.transactionForUser(
testCrdtUserId,
(tx) => Person.db.upsert(
session,
[
deletedPerson.copyWith(name: 'batch reinserted'),
livePerson.copyWith(name: 'batch updated'),
Person(name: 'batch created'),
],
conflictColumns: (t) => [t.id],
updateColumns: (t) => [t.name],
transaction: tx,
),
);
});
test('then all three rows are returned with scopeId null.', () {
expect(upserted, hasLength(3));
expect(upserted.map((e) => e.scopeId), everyElement(isNull));
expect(upserted.map((e) => e.name).toSet(), {
'batch reinserted',
'batch updated',
'batch created',
});
});
test('then the deleted row is visible again with the new values.', () async {
final row = await Person.db.findById(session, deletedPerson.id!);
expect(row?.name, 'batch reinserted');
});
test('then the live and new rows are persisted with the new values.', () async {
final updated = await Person.db.findById(session, livePerson.id!);
expect(updated?.name, 'batch updated');
final created = upserted.singleWhere((e) => e.name == 'batch created');
final row = await Person.db.findById(session, created.id!);
expect(row, isNotNull);
});
});
});
group('Given a unique composite table with an existing row,', () {
late UniqueComposite uniqueComposite;
setUp(() async {
uniqueComposite = await session.db.transactionForUser(
testCrdtUserId,
(tx) => UniqueComposite.db.insertRow(
session,
UniqueComposite(scope: 'domain', value: 'key'),
transaction: tx,
),
);
});
group('when upserting another row with the same scoped unique key,', () {
late UniqueComposite? upserted;
setUp(() async {
upserted = await session.db.transactionForUser(
testCrdtUserId,
(tx) => UniqueComposite.db.upsertRow(
session,
UniqueComposite(scope: 'domain', value: 'key'),
conflictColumns: (t) => [t.scopeId, t.scope, t.value],
transaction: tx,
),
);
});
test('then the existing row is returned.', () async {
expect(upserted, isNotNull);
expect(upserted!.id, uniqueComposite.id);
expect(upserted!.scopeId, isNull);
});
test('then no duplicate row is inserted.', () async {
final rows = await UniqueComposite.db.find(
session,
where: (t) => t.scope.equals('domain') & t.value.equals('key'),
);
expect(rows, hasLength(1));
});
test('then CRDT fields are tracked for the default updated columns.', () async {
final fields = await CrdtDataField.db.find(
session,
where: (t) => t.row.uuidRowId.equals(uniqueComposite.id),
include: CrdtDataField.include(column: CrdtSchemaColumn.include()),
);
expect(fields.map((e) => e.column!.name).toSet(), {'scope', 'value'});
});
});
});
}