feat: Require scopeId cascade FK relation to crdt_scopes for synced tables (#79)

Co-authored-by: Marcelo Soares <marcelo.me.soares@gmail.com>
This commit is contained in:
Copilot 2026-06-21 10:02:08 -03:00 • committed by GitHub
commit 5642d9b94d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 393 additions and 35 deletions

View file

@ -24,7 +24,7 @@ The ownership link is:
fields: fields:
id: UuidValue?, defaultPersist=random_v7 id: UuidValue?, defaultPersist=random_v7
### Owner scope of this row. Maintained by the CRDT sync layer. ### Owner scope of this row. Maintained by the CRDT sync layer.
scopeId: int?, relation(optional, parent=crdt_scopes, onDelete=Cascade) scopeId: int?, relation(parent=crdt_scopes, onDelete=Cascade)
``` ```
`scopeId == null` remains valid persisted state, but it means the row is `scopeId == null` remains valid persisted state, but it means the row is
@ -262,7 +262,7 @@ The error message should explain the allowed alternatives:
The implementation issue should add schema/initialize tests for: The implementation issue should add schema/initialize tests for:
- accepting `scopeId: int?, relation(optional, parent=crdt_scopes, - accepting `scopeId: int?, relation(parent=crdt_scopes,
onDelete=Cascade)` on every synced table; onDelete=Cascade)` on every synced table;
- rejecting `sync -> non-sync` restricted reference-table FKs with - rejecting `sync -> non-sync` restricted reference-table FKs with
`onDelete=Restrict` while the reference-data sync phase is absent; `onDelete=Restrict` while the reference-data sync phase is absent;

View file

@ -32,14 +32,8 @@ class CrdtSchemaRegistry {
'CRDT can only synchronize tables with a nullable int scopeId column, ' 'CRDT can only synchronize tables with a nullable int scopeId column, '
'but ${tablesWithoutScopeId.length} table(s) are missing it or declare ' 'but ${tablesWithoutScopeId.length} table(s) are missing it or declare '
'it with the wrong type: ' 'it with the wrong type: '
'${tablesWithoutScopeId.map((t) => '"${t.tableName}"').join(', ')}\n\n' '${tablesWithoutScopeId.map((t) => '"${t.tableName}"').join(', ')}'
'Add this field to every synced model:\n' '$_scopeIdFieldHelp',
'fields:\n'
' id: UuidValue?, defaultPersist=random_v7\n'
' ### Owner scope of this row. Maintained by the CRDT sync layer.\n'
' scopeId: int?\n\n'
'If the column is declared with scope=serverOnly, remove the scope; '
'the column must exist on every database.',
); );
} }
@ -63,8 +57,34 @@ class CrdtSchemaRegistry {
'whose target tables are also synchronized.', 'whose target tables are also synchronized.',
); );
} }
final tablesMissingScopeIdRelation = _missingCrdtScopeRelations(
syncTables,
tableDefinitionsByName,
);
if (tablesMissingScopeIdRelation.isNotEmpty) {
throw StateError(
'CRDT synced tables must declare scopeId as a cascade relation to '
'crdt_scopes, but ${tablesMissingScopeIdRelation.length} table(s) are '
'missing this relation: '
'${tablesMissingScopeIdRelation.map((t) => '"$t"').join(', ')}'
'$_scopeIdFieldHelp',
);
}
} }
/// Help text appended to schema validation errors that describes the required
/// `scopeId` ownership field on every synced model.
static const _scopeIdFieldHelp =
'\n\n'
'Add this field to every synced model:\n'
'fields:\n'
' id: UuidValue?, defaultPersist=random_v7\n'
' ### Owner scope of this row. Maintained by the CRDT sync layer.\n'
' scopeId: int?, relation(parent=crdt_scopes, onDelete=Cascade)\n\n'
'If the column is declared with scope=serverOnly, remove the scope; '
'the column must exist on every database.';
final DatabaseSession _session; final DatabaseSession _session;
/// The list of tables to sync with CRDT. /// The list of tables to sync with CRDT.
@ -210,3 +230,27 @@ bool _isForbiddenGlobalUniqueIndex(
syncTableNames, syncTableNames,
); );
} }
List<String> _missingCrdtScopeRelations(
List<Table> syncTables,
Map<String, TableDefinition> tableDefinitionsByName,
) {
return [
for (final table in syncTables)
if (tableDefinitionsByName.containsKey(table.tableName) &&
!_hasCrdtScopeRelation(tableDefinitionsByName[table.tableName]!))
table.tableName,
];
}
bool _hasCrdtScopeRelation(TableDefinition tableDefinition) {
return tableDefinition.foreignKeys.any(
(fk) =>
fk.columns.length == 1 &&
fk.columns.single == 'scopeId' &&
fk.referenceTable == 'crdt_scopes' &&
fk.referenceColumns.length == 1 &&
fk.referenceColumns.single == 'id' &&
fk.onDelete == ForeignKeyAction.cascade,
);
}

View file

@ -4,7 +4,7 @@ database: all
fields: fields:
id: UuidValue?, defaultPersist=random_v7 id: UuidValue?, defaultPersist=random_v7
### Owner scope of this row. Maintained by the CRDT sync layer. ### Owner scope of this row. Maintained by the CRDT sync layer.
scopeId: int? scopeId: int?, relation(parent=crdt_scopes, onDelete=Cascade)
street: String street: String
inhabitantId: UuidValue?, unique inhabitantId: UuidValue?, unique
inhabitant: Person?, relation(name=person_address, field=inhabitantId, onDelete=Restrict) inhabitant: Person?, relation(name=person_address, field=inhabitantId, onDelete=Restrict)

View file

@ -4,7 +4,7 @@ database: all
fields: fields:
id: UuidValue?, defaultPersist=random_v7 id: UuidValue?, defaultPersist=random_v7
### Owner scope of this row. Maintained by the CRDT sync layer. ### Owner scope of this row. Maintained by the CRDT sync layer.
scopeId: int? scopeId: int?, relation(parent=crdt_scopes, onDelete=Cascade)
name: String name: String
citizens: List<Person>?, relation citizens: List<Person>?, relation
organizations: List<Organization>?, relation(name=city_org) organizations: List<Organization>?, relation(name=city_org)

View file

@ -4,7 +4,7 @@ database: all
fields: fields:
id: UuidValue?, defaultPersist=random_v7 id: UuidValue?, defaultPersist=random_v7
### Owner scope of this row. Maintained by the CRDT sync layer. ### Owner scope of this row. Maintained by the CRDT sync layer.
scopeId: int? scopeId: int?, relation(parent=crdt_scopes, onDelete=Cascade)
name: String name: String
town: Town?, relation(field=townId, onDelete=SetDefault) town: Town?, relation(field=townId, onDelete=SetDefault)
townId: UuidValue, default='550e8400-e29b-41d4-a716-446655440000' townId: UuidValue, default='550e8400-e29b-41d4-a716-446655440000'

View file

@ -4,7 +4,7 @@ database: all
fields: fields:
id: UuidValue?, defaultPersist=random_v7 id: UuidValue?, defaultPersist=random_v7
### Owner scope of this row. Maintained by the CRDT sync layer. ### Owner scope of this row. Maintained by the CRDT sync layer.
scopeId: int? scopeId: int?, relation(parent=crdt_scopes, onDelete=Cascade)
name: String name: String
root: FkChainRoot?, relation(field=rootId, onDelete=Cascade) root: FkChainRoot?, relation(field=rootId, onDelete=Cascade)
rootId: UuidValue? rootId: UuidValue?

View file

@ -4,7 +4,7 @@ database: all
fields: fields:
id: UuidValue?, defaultPersist=random_v7 id: UuidValue?, defaultPersist=random_v7
### Owner scope of this row. Maintained by the CRDT sync layer. ### Owner scope of this row. Maintained by the CRDT sync layer.
scopeId: int? scopeId: int?, relation(parent=crdt_scopes, onDelete=Cascade)
name: String name: String
restrictBlocker: FkChainRestrictBlocker?, relation(field=restrictBlockerId, onDelete=Cascade) restrictBlocker: FkChainRestrictBlocker?, relation(field=restrictBlockerId, onDelete=Cascade)
restrictBlockerId: UuidValue? restrictBlockerId: UuidValue?

View file

@ -4,7 +4,7 @@ database: all
fields: fields:
id: UuidValue?, defaultPersist=random_v7 id: UuidValue?, defaultPersist=random_v7
### Owner scope of this row. Maintained by the CRDT sync layer. ### Owner scope of this row. Maintained by the CRDT sync layer.
scopeId: int? scopeId: int?, relation(parent=crdt_scopes, onDelete=Cascade)
name: String name: String
restrictBlocker: FkChainRestrictBlocker?, relation(field=restrictBlockerId, onDelete=SetNull) restrictBlocker: FkChainRestrictBlocker?, relation(field=restrictBlockerId, onDelete=SetNull)
restrictBlockerId: UuidValue? restrictBlockerId: UuidValue?

View file

@ -4,7 +4,7 @@ database: all
fields: fields:
id: UuidValue?, defaultPersist=random_v7 id: UuidValue?, defaultPersist=random_v7
### Owner scope of this row. Maintained by the CRDT sync layer. ### Owner scope of this row. Maintained by the CRDT sync layer.
scopeId: int? scopeId: int?, relation(parent=crdt_scopes, onDelete=Cascade)
name: String name: String
cascadeMiddle: FkChainCascadeMiddle?, relation(field=cascadeMiddleId, onDelete=Restrict) cascadeMiddle: FkChainCascadeMiddle?, relation(field=cascadeMiddleId, onDelete=Restrict)
cascadeMiddleId: UuidValue? cascadeMiddleId: UuidValue?

View file

@ -4,5 +4,5 @@ database: all
fields: fields:
id: UuidValue?, defaultPersist=random_v7 id: UuidValue?, defaultPersist=random_v7
### Owner scope of this row. Maintained by the CRDT sync layer. ### Owner scope of this row. Maintained by the CRDT sync layer.
scopeId: int? scopeId: int?, relation(parent=crdt_scopes, onDelete=Cascade)
name: String name: String

View file

@ -4,7 +4,7 @@ database: all
fields: fields:
id: UuidValue?, defaultPersist=random_v7 id: UuidValue?, defaultPersist=random_v7
### Owner scope of this row. Maintained by the CRDT sync layer. ### Owner scope of this row. Maintained by the CRDT sync layer.
scopeId: int? scopeId: int?, relation(parent=crdt_scopes, onDelete=Cascade)
name: String name: String
setNullMiddle: FkChainSetNullMiddle?, relation(field=setNullMiddleId, onDelete=Cascade) setNullMiddle: FkChainSetNullMiddle?, relation(field=setNullMiddleId, onDelete=Cascade)
setNullMiddleId: UuidValue? setNullMiddleId: UuidValue?

View file

@ -4,7 +4,7 @@ database: all
fields: fields:
id: UuidValue?, defaultPersist=random_v7 id: UuidValue?, defaultPersist=random_v7
### Owner scope of this row. Maintained by the CRDT sync layer. ### Owner scope of this row. Maintained by the CRDT sync layer.
scopeId: int? scopeId: int?, relation(parent=crdt_scopes, onDelete=Cascade)
name: String name: String
cascadeMiddle: FkChainCascadeMiddle?, relation(field=cascadeMiddleId, onDelete=SetNull) cascadeMiddle: FkChainCascadeMiddle?, relation(field=cascadeMiddleId, onDelete=SetNull)
cascadeMiddleId: UuidValue? cascadeMiddleId: UuidValue?

View file

@ -4,7 +4,7 @@ database: all
fields: fields:
id: UuidValue?, defaultPersist=random_v7 id: UuidValue?, defaultPersist=random_v7
### Owner scope of this row. Maintained by the CRDT sync layer. ### Owner scope of this row. Maintained by the CRDT sync layer.
scopeId: int? scopeId: int?, relation(parent=crdt_scopes, onDelete=Cascade)
name: String name: String
setNullMiddle: FkChainSetNullMiddle?, relation(field=setNullMiddleId, onDelete=Restrict) setNullMiddle: FkChainSetNullMiddle?, relation(field=setNullMiddleId, onDelete=Restrict)
setNullMiddleId: UuidValue? setNullMiddleId: UuidValue?

View file

@ -4,7 +4,7 @@ database: all
fields: fields:
id: UuidValue?, defaultPersist=random_v7 id: UuidValue?, defaultPersist=random_v7
### Owner scope of this row. Maintained by the CRDT sync layer. ### Owner scope of this row. Maintained by the CRDT sync layer.
scopeId: int? scopeId: int?, relation(parent=crdt_scopes, onDelete=Cascade)
name: String name: String
setNullMiddle: FkChainSetNullMiddle?, relation(field=setNullMiddleId, onDelete=SetNull) setNullMiddle: FkChainSetNullMiddle?, relation(field=setNullMiddleId, onDelete=SetNull)
setNullMiddleId: UuidValue? setNullMiddleId: UuidValue?

View file

@ -4,7 +4,7 @@ database: all
fields: fields:
id: UuidValue?, defaultPersist=random_v7 id: UuidValue?, defaultPersist=random_v7
### Owner scope of this row. Maintained by the CRDT sync layer. ### Owner scope of this row. Maintained by the CRDT sync layer.
scopeId: int? scopeId: int?, relation(parent=crdt_scopes, onDelete=Cascade)
name: String name: String
people: List<Person>?, relation(name=org_person) people: List<Person>?, relation(name=org_person)
city: City?, relation(name=city_org, optional, onDelete=Cascade) city: City?, relation(name=city_org, optional, onDelete=Cascade)

View file

@ -4,7 +4,7 @@ database: all
fields: fields:
id: UuidValue?, defaultPersist=random_v7 id: UuidValue?, defaultPersist=random_v7
### Owner scope of this row. Maintained by the CRDT sync layer. ### Owner scope of this row. Maintained by the CRDT sync layer.
scopeId: int? scopeId: int?, relation(parent=crdt_scopes, onDelete=Cascade)
name: String name: String
surname: String? surname: String?
address: Address?, relation(name=person_address) address: Address?, relation(name=person_address)

View file

@ -4,7 +4,7 @@ database: all
fields: fields:
id: UuidValue?, defaultPersist=random_v7 id: UuidValue?, defaultPersist=random_v7
### Owner scope of this row. Maintained by the CRDT sync layer. ### Owner scope of this row. Maintained by the CRDT sync layer.
scopeId: int? scopeId: int?, relation(parent=crdt_scopes, onDelete=Cascade)
name: String name: String
parent: Person?, relation(field=parentId, onDelete=SetNull) parent: Person?, relation(field=parentId, onDelete=SetNull)
parentId: UuidValue parentId: UuidValue

View file

@ -4,7 +4,7 @@ database: all
fields: fields:
id: UuidValue?, defaultPersist=random_v7 id: UuidValue?, defaultPersist=random_v7
### Owner scope of this row. Maintained by the CRDT sync layer. ### Owner scope of this row. Maintained by the CRDT sync layer.
scopeId: int? scopeId: int?, relation(parent=crdt_scopes, onDelete=Cascade)
name: String name: String
parent: Person?, relation(field=parentId, onDelete=Restrict) parent: Person?, relation(field=parentId, onDelete=Restrict)
parentId: UuidValue? parentId: UuidValue?

View file

@ -4,7 +4,7 @@ database: all
fields: fields:
id: UuidValue?, defaultPersist=random_v7 id: UuidValue?, defaultPersist=random_v7
### Owner scope of this row. Maintained by the CRDT sync layer. ### Owner scope of this row. Maintained by the CRDT sync layer.
scopeId: int? scopeId: int?, relation(parent=crdt_scopes, onDelete=Cascade)
name: String name: String
city: City?, relation(optional, onDelete=Cascade) city: City?, relation(optional, onDelete=Cascade)
mayor: Person?, relation(optional, onDelete=SetNull) mayor: Person?, relation(optional, onDelete=SetNull)

View file

@ -5,7 +5,7 @@ database: all
fields: fields:
id: UuidValue?, defaultPersist=random_v7 id: UuidValue?, defaultPersist=random_v7
### Owner scope of this row. Maintained by the CRDT sync layer. ### Owner scope of this row. Maintained by the CRDT sync layer.
scopeId: int? scopeId: int?, relation(parent=crdt_scopes, onDelete=Cascade)
aBool: bool aBool: bool
aDateTime: DateTime aDateTime: DateTime
aText: String aText: String

Some files were not shown because too many files have changed in this diff Show more