fix: Allow only one releasable column per unique index (#91)

This commit is contained in:
Marcelo Mendonça Soares 2026-07-03 10:49:23 -03:00 • committed by GitHub
commit af139ea800
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 353 additions and 112 deletions

View file

@ -24,6 +24,7 @@ final demoSyncTables = <Table>[
Types.t,
Unique.t,
UniqueComposite.t,
UniqueDiscriminator.t,
UniqueSetNullChild.t,
UniqueUuid.t,
];

View file

@ -295,6 +295,7 @@ class DemoDebugEndpoint extends Endpoint {
await add<Unique>();
await add<UniqueUuid>();
await add<UniqueComposite>();
await add<UniqueDiscriminator>();
await add<UniqueSetNullChild>();
await add<Types>();
await add<FkChainRoot>();

View file

@ -0,0 +1,9 @@
class: UniqueDiscriminator
table: unique_discriminator
database: all
fields:
id: UuidValue?, defaultPersist=random_v7
### Owner scope of this row. Maintained by the CRDT sync layer.
scopeId: int?, relation(parent=crdt_scopes, onDelete=Cascade)
categoryId: int
name: String, unique(per=[scopeId, categoryId])

View file

@ -0,0 +1,8 @@
class: UniqueNoRelease
table: unique_no_release
database: all
fields:
id: UuidValue?, defaultPersist=random_v7
### Owner scope of this row. Maintained by the CRDT sync layer.
scopeId: int?, relation(parent=crdt_scopes, onDelete=Cascade)
categoryId: int, unique(per=[scopeId])

View file

@ -421,6 +421,94 @@ void main() {
},
);
group(
'Given a remote update that loses a composite unique conflict with a stable discriminator, ',
() {
late UniqueDiscriminator loser;
late CrdtMergeUpdate remoteUpdate;
setUp(() async {
await session.db.transactionForUser(testCrdtUserId, (tx) async {
await UniqueDiscriminator.db.insertRow(
session,
UniqueDiscriminator(
id: const Uuid().v7obj(),
categoryId: 7,
name: 'shared-name',
),
transaction: tx,
);
loser = await UniqueDiscriminator.db.insertRow(
session,
UniqueDiscriminator(
id: const Uuid().v7obj(),
categoryId: 7,
name: 'original-name',
),
transaction: tx,
);
});
final loserCrdtRow = await CrdtDataRow.db.findFirstRow(
session,
where: (t) => t.uuidRowId.equals(loser.id),
include: CrdtDataRow.include(node: CrdtNode.include()),
);
remoteUpdate = CrdtMergeUpdate(
uuidScopeId: testCrdtUserId,
tableName: UniqueDiscriminator.t.tableName,
uuidRowId: loser.id!,
uuidNodeId: const Uuid().v7obj(),
hlcDatetime: loserCrdtRow!.hlc.datetime.advance(),
hlcCounter: loserCrdtRow.hlc.counter,
columnName: UniqueDiscriminator.t.name.columnName,
value: 'shared-name',
);
mergeSet = [remoteUpdate];
});
group('when merging, ', () {
setUp(() async {
await session.db.mergeChanges(
mergeSet,
scopeId: testCrdtUserId,
);
});
test(
'then the losing row keeps the discriminator and receives a conflict-free name.',
() async {
final row = await UniqueDiscriminator.db.findById(session, loser.id!);
expect(row, isNotNull);
expect(row!.categoryId, 7);
expect(row.name, 'shared-name__conflict__${loser.id!.uuid}');
},
);
test(
'then the stable discriminator does not receive CRDT field metadata.',
() async {
final fields = await CrdtDataField.db.find(
session,
where: (t) => t.row.uuidRowId.equals(loser.id),
include: CrdtDataField.include(
column: CrdtSchemaColumn.include(),
node: CrdtNode.include(),
),
);
expect(fields, hasLength(1));
expect(fields.first.column!.name, UniqueDiscriminator.t.name.columnName);
expect(fields.first.hlc, remoteUpdate.hlc);
},
);
});
},
);
group(
'Given a remote update that loses a UUID unique conflict, ',
() {

View file

@ -297,6 +297,70 @@ void main() {
},
);
test(
'Given a CRDT database with a scoped composite unique index that has a stable discriminator and a releasable column, '
'when the database is initialized, '
'then the unique index metadata is accepted.',
() async {
final crdtSession = CrdtDatabaseSession.wraps(
testSession,
syncTables: [UniqueDiscriminator.t],
);
await expectLater(crdtSession.db.initialize(), completes);
},
);
test(
'Given a CRDT schema registry with a scoped unique index that has no releasable non-scope column, '
'when the registry is created, '
'then an error is thrown.',
() {
expect(
() => CrdtSchemaRegistry(
session,
syncTables: [UniqueNoRelease.t],
),
throwsA(
isA<StateError>().having(
(e) => e.message,
'message',
contains(
'CRDT unique conflict resolution requires at least one '
'releasable non-scope column',
),
),
),
);
},
);
test(
'Given a CRDT database with a scoped unique index that has no releasable non-scope column, '
'when the database is initialized, '
'then an error is thrown.',
() async {
final crdtSession = CrdtDatabaseSession.wraps(
testSession,
syncTables: [UniqueNoRelease.t],
);
await expectLater(
crdtSession.db.initialize(),
throwsA(
isA<StateError>().having(
(e) => e.message,
'message',
contains(
'CRDT unique conflict resolution requires at least one '
'releasable non-scope column',
),
),
),
);
},
);
test(
'Given a CRDT schema registry with a synced table that has the scopeId cascade relation to crdt_scopes, '
'when the registry is created, '

View file

@ -98,6 +98,7 @@ Future<void> _initialize() async {
Town.t,
Unique.t,
UniqueComposite.t,
UniqueDiscriminator.t,
UniqueSetNullChild.t,
UniqueUuid.t,
],