fix: Author the column default an upsert writes for a null foreign key (#164)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
This commit is contained in:
parent
4bf2359dda
commit
ee000e348c
22 changed files with 21728 additions and 51 deletions
|
|
@ -491,9 +491,15 @@ class OfflineSyncDatabase implements Database {
|
||||||
tx,
|
tx,
|
||||||
projectionUnchanged: projection.projectionUnchanged,
|
projectionUnchanged: projection.projectionUnchanged,
|
||||||
domainBeforeUpsert: projection.domain,
|
domainBeforeUpsert: projection.domain,
|
||||||
upsertRows: updateColumns == null && projection.domain.isNotEmpty
|
upsertInputs: updateColumns == null && projection.domain.isNotEmpty
|
||||||
? prepared.rows
|
? _recorder.upsertInputsByRow(
|
||||||
: const [],
|
suppliedRows: prepared.rows,
|
||||||
|
databaseRows: values,
|
||||||
|
updatedRows: updatedRows,
|
||||||
|
conflictColumns: conflictColumns,
|
||||||
|
)
|
||||||
|
: const {},
|
||||||
|
upsertConflictColumns: conflictColumns.map((c) => c.columnName).toSet(),
|
||||||
);
|
);
|
||||||
if (noReturn) return <T>[];
|
if (noReturn) return <T>[];
|
||||||
_stripStampedRows(result, prepared);
|
_stripStampedRows(result, prepared);
|
||||||
|
|
|
||||||
|
|
@ -782,6 +782,67 @@ class CrdtMutationRecorder {
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/// Associates caller values with the rows accepted by the physical upsert.
|
||||||
|
///
|
||||||
|
/// A unique conflict target can select an existing row with a different ID.
|
||||||
|
/// Match its unchanged conflict columns against the default-resolved inputs;
|
||||||
|
/// returned positions cannot be used because `updateWhere` can skip inputs.
|
||||||
|
Map<MergeRowKey, Map<String, Object?>> upsertInputsByRow<T extends TableRow>({
|
||||||
|
required List<T> suppliedRows,
|
||||||
|
required List<T> databaseRows,
|
||||||
|
required List<T> updatedRows,
|
||||||
|
required List<Column> conflictColumns,
|
||||||
|
}) {
|
||||||
|
if (updatedRows.isEmpty) return const {};
|
||||||
|
|
||||||
|
if (conflictColumns.length == 1 && conflictColumns.single.columnName == 'id') {
|
||||||
|
final inputs = <MergeRowKey, Map<String, Object?>>{};
|
||||||
|
for (final row in suppliedRows) {
|
||||||
|
if (row.id case final UuidValue rowId) {
|
||||||
|
inputs.putIfAbsent(
|
||||||
|
(row.table.tableName, rowId),
|
||||||
|
() => _authoredValuesFromRow(row, null),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return inputs;
|
||||||
|
}
|
||||||
|
|
||||||
|
final inputsByConflictKey = <String, Map<String, Object?>>{};
|
||||||
|
for (final (index, row) in databaseRows.indexed) {
|
||||||
|
final key = _upsertConflictKey(row, conflictColumns);
|
||||||
|
if (key == null) continue;
|
||||||
|
|
||||||
|
// A repeated target may appear after an accepted update makes
|
||||||
|
// `updateWhere` false. Keep the first input, not the later skipped one;
|
||||||
|
// the delegate rejects batches that accept the same target twice.
|
||||||
|
inputsByConflictKey.putIfAbsent(
|
||||||
|
key,
|
||||||
|
() => _authoredValuesFromRow(suppliedRows[index], null),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
for (final row in updatedRows)
|
||||||
|
(row.table.tableName, row.id as UuidValue):
|
||||||
|
?inputsByConflictKey[_upsertConflictKey(row, conflictColumns)],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _upsertConflictKey(TableRow row, List<Column> conflictColumns) {
|
||||||
|
final values = row.toJsonForDatabase() as Map;
|
||||||
|
final parts = <String>[];
|
||||||
|
for (final column in conflictColumns) {
|
||||||
|
final value = values[column.columnName];
|
||||||
|
// A null component makes no unique claim and cannot select an update.
|
||||||
|
if (value == null) return null;
|
||||||
|
|
||||||
|
final part = canonicalProjectionValue(value);
|
||||||
|
// Length prefixes preserve component boundaries even inside text keys.
|
||||||
|
parts.add('${part.length}:$part');
|
||||||
|
}
|
||||||
|
return parts.join();
|
||||||
|
}
|
||||||
|
|
||||||
Future<Map<UuidValue, Map<String, Object?>>> _readPlannedDomainValues(
|
Future<Map<UuidValue, Map<String, Object?>>> _readPlannedDomainValues(
|
||||||
String tableName,
|
String tableName,
|
||||||
Set<UuidValue> rowIds,
|
Set<UuidValue> rowIds,
|
||||||
|
|
@ -944,7 +1005,8 @@ class CrdtMutationRecorder {
|
||||||
bool authoredColumnValues = false,
|
bool authoredColumnValues = false,
|
||||||
Set<MergeRowKey> projectionRows = const {},
|
Set<MergeRowKey> projectionRows = const {},
|
||||||
Map<MergeRowKey, Map<String, Object?>> domainBeforeUpsert = const {},
|
Map<MergeRowKey, Map<String, Object?>> domainBeforeUpsert = const {},
|
||||||
List<TableRow> upsertRows = const [],
|
Map<MergeRowKey, Map<String, Object?>> upsertInputs = const {},
|
||||||
|
Set<String> upsertConflictColumns = const {},
|
||||||
}) async {
|
}) async {
|
||||||
await _foreignKeyProjector.assertVisibleTargets(updatedRows, columns, transaction);
|
await _foreignKeyProjector.assertVisibleTargets(updatedRows, columns, transaction);
|
||||||
|
|
||||||
|
|
@ -953,6 +1015,16 @@ class CrdtMutationRecorder {
|
||||||
rowIds,
|
rowIds,
|
||||||
_,
|
_,
|
||||||
) async {
|
) async {
|
||||||
|
// Serverpod's full-row upsert updates only non-conflict columns. Keep
|
||||||
|
// the caller's null `columns` separate from this physical write set so
|
||||||
|
// unchanged projected values still follow full-row passthrough rules.
|
||||||
|
final writtenColumns =
|
||||||
|
columns ??
|
||||||
|
(upsertConflictColumns.isEmpty
|
||||||
|
? null
|
||||||
|
: updatedRows.first.table.managedColumns
|
||||||
|
.where((c) => !upsertConflictColumns.contains(c.columnName))
|
||||||
|
.toList());
|
||||||
final crdtDataRows = await _context.findRequiredCrdtRows(
|
final crdtDataRows = await _context.findRequiredCrdtRows(
|
||||||
tableName,
|
tableName,
|
||||||
rowIds,
|
rowIds,
|
||||||
|
|
@ -966,24 +1038,13 @@ class CrdtMutationRecorder {
|
||||||
transaction: transaction,
|
transaction: transaction,
|
||||||
)
|
)
|
||||||
: const <MergeFieldKey>{};
|
: const <MergeFieldKey>{};
|
||||||
final upsertInputs = upsertRows.isEmpty || implicitForeignKeyRepairFields.isEmpty
|
// A null foreign key is resolved to its column default before the
|
||||||
? const <MergeRowKey, Map<String, Object?>>{}
|
// physical upsert, so the stored value can equal a projected default the
|
||||||
: {
|
// caller never wrote. Passthrough is about what the caller supplied.
|
||||||
for (final row in upsertRows)
|
Object? suppliedValue(UuidValue rowId, String columnName, Object? stored) {
|
||||||
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)];
|
final input = upsertInputs[(tableName, rowId)];
|
||||||
return input != null &&
|
if (input == null || !input.containsKey(columnName)) return stored;
|
||||||
input[columnName] == null &&
|
return input[columnName];
|
||||||
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
|
||||||
|
|
@ -992,7 +1053,7 @@ class CrdtMutationRecorder {
|
||||||
if (authoredColumnValues)
|
if (authoredColumnValues)
|
||||||
for (final row in updatedRows)
|
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, writtenColumns).entries)
|
||||||
(tableName, row.id as UuidValue, columnName): value
|
(tableName, row.id as UuidValue, columnName): value
|
||||||
else
|
else
|
||||||
// Only rows returned by the physical upsert were accepted by its
|
// Only rows returned by the physical upsert were accepted by its
|
||||||
|
|
@ -1000,22 +1061,21 @@ class CrdtMutationRecorder {
|
||||||
for (final row in updatedRows)
|
for (final row in updatedRows)
|
||||||
if (domainBeforeUpsert.containsKey((tableName, row.id)))
|
if (domainBeforeUpsert.containsKey((tableName, row.id)))
|
||||||
for (final MapEntry(key: columnName, value: value)
|
for (final MapEntry(key: columnName, value: value)
|
||||||
in _authoredValuesFromRow(row, columns).entries)
|
in _authoredValuesFromRow(row, writtenColumns).entries)
|
||||||
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,
|
suppliedValue(row.id as UuidValue, columnName, 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);
|
||||||
await _recordUpdatedFields(
|
await _recordUpdatedFields(
|
||||||
updatedRows,
|
updatedRows,
|
||||||
crdtDataRows,
|
crdtDataRows,
|
||||||
columns,
|
writtenColumns,
|
||||||
transaction,
|
transaction,
|
||||||
skippedFields: implicitForeignKeyRepairFields.difference(
|
skippedFields: implicitForeignKeyRepairFields.difference(
|
||||||
authoredOverlays.keys.toSet(),
|
authoredOverlays.keys.toSet(),
|
||||||
|
|
@ -1026,11 +1086,13 @@ class CrdtMutationRecorder {
|
||||||
updatedRows,
|
updatedRows,
|
||||||
transaction,
|
transaction,
|
||||||
inserting: false,
|
inserting: false,
|
||||||
columns: columns,
|
columns: writtenColumns,
|
||||||
)) {
|
)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final updatedColumnNames = columns?.map((column) => column.columnName).toSet();
|
final updatedColumnNames = writtenColumns
|
||||||
|
?.map((column) => column.columnName)
|
||||||
|
.toSet();
|
||||||
await _maybeProject(
|
await _maybeProject(
|
||||||
tableName,
|
tableName,
|
||||||
rowIds,
|
rowIds,
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,22 @@ void main() {
|
||||||
});
|
});
|
||||||
|
|
||||||
withServerpod(
|
withServerpod(
|
||||||
'PostgreSQL untracked updates with the database interceptor',
|
'[PostgreSQL untracked updates with the database interceptor]',
|
||||||
|
databaseInterceptor: offlineSyncDatabaseInterceptor,
|
||||||
|
serverDirectory: serverDirectory,
|
||||||
|
configOverride: (config) => config.copyWith(
|
||||||
|
apiServer: ServerConfig(
|
||||||
|
port: 0,
|
||||||
|
publicHost: 'localhost',
|
||||||
|
publicPort: 0,
|
||||||
|
publicScheme: 'http',
|
||||||
|
),
|
||||||
|
database: PostgresDatabaseConfig.embedded(
|
||||||
|
dataPath: '${Directory.systemTemp.path}/offline_sync_updates_postgres_$pid',
|
||||||
|
name: 'serverpod_test',
|
||||||
|
maxConnectionCount: 5,
|
||||||
|
),
|
||||||
|
),
|
||||||
(sessionBuilder, _) {
|
(sessionBuilder, _) {
|
||||||
late Session session;
|
late Session session;
|
||||||
|
|
||||||
|
|
@ -65,21 +80,6 @@ void main() {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
databaseInterceptor: offlineSyncDatabaseInterceptor,
|
|
||||||
serverDirectory: serverDirectory,
|
|
||||||
configOverride: (config) => config.copyWith(
|
|
||||||
apiServer: ServerConfig(
|
|
||||||
port: 0,
|
|
||||||
publicHost: 'localhost',
|
|
||||||
publicPort: 0,
|
|
||||||
publicScheme: 'http',
|
|
||||||
),
|
|
||||||
database: PostgresDatabaseConfig.embedded(
|
|
||||||
dataPath: '${Directory.systemTemp.path}/offline_sync_updates_postgres_$pid',
|
|
||||||
name: 'serverpod_test',
|
|
||||||
maxConnectionCount: 5,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,68 @@
|
||||||
|
{
|
||||||
|
"__className__": "serverpod.DatabaseMigration",
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"__className__": "serverpod.DatabaseMigrationAction",
|
||||||
|
"type": "alterTable",
|
||||||
|
"alterTable": {
|
||||||
|
"__className__": "serverpod.TableMigration",
|
||||||
|
"name": "unique_set_default_child",
|
||||||
|
"schema": "public",
|
||||||
|
"addColumns": [],
|
||||||
|
"deleteColumns": [],
|
||||||
|
"modifyColumns": [],
|
||||||
|
"addIndexes": [
|
||||||
|
{
|
||||||
|
"__className__": "serverpod.IndexDefinition",
|
||||||
|
"indexName": "unique_set_default_child_name",
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"__className__": "serverpod.IndexElementDefinition",
|
||||||
|
"type": 0,
|
||||||
|
"definition": "spaceId"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"__className__": "serverpod.IndexElementDefinition",
|
||||||
|
"type": 0,
|
||||||
|
"definition": "name"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "btree",
|
||||||
|
"isUnique": true,
|
||||||
|
"isPrimary": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"deleteIndexes": [],
|
||||||
|
"addForeignKeys": [],
|
||||||
|
"deleteForeignKeys": [],
|
||||||
|
"warnings": [
|
||||||
|
{
|
||||||
|
"__className__": "serverpod.DatabaseMigrationWarning",
|
||||||
|
"type": "uniqueIndexCreated",
|
||||||
|
"message": "Unique index \"unique_set_default_child_name\" is added to table \"unique_set_default_child\". If there are existing rows with duplicate values, this migration will fail.",
|
||||||
|
"table": "unique_set_default_child",
|
||||||
|
"columns": [
|
||||||
|
"spaceId",
|
||||||
|
"name"
|
||||||
|
],
|
||||||
|
"destrucive": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"warnings": [
|
||||||
|
{
|
||||||
|
"__className__": "serverpod.DatabaseMigrationWarning",
|
||||||
|
"type": "uniqueIndexCreated",
|
||||||
|
"message": "Unique index \"unique_set_default_child_name\" is added to table \"unique_set_default_child\". If there are existing rows with duplicate values, this migration will fail.",
|
||||||
|
"table": "unique_set_default_child",
|
||||||
|
"columns": [
|
||||||
|
"spaceId",
|
||||||
|
"name"
|
||||||
|
],
|
||||||
|
"destrucive": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"migrationApiVersion": 1
|
||||||
|
}
|
||||||
|
|
@ -16,6 +16,7 @@ part '20260914144112288/migration.dart';
|
||||||
part '20260914165608984/migration.dart';
|
part '20260914165608984/migration.dart';
|
||||||
part '20260930185557154/migration.dart';
|
part '20260930185557154/migration.dart';
|
||||||
part '20261007215503877/migration.dart';
|
part '20261007215503877/migration.dart';
|
||||||
|
part '20261008234516445/migration.dart';
|
||||||
|
|
||||||
/// Migration registry for the client-side database.
|
/// Migration registry for the client-side database.
|
||||||
class MigrationRegistry {
|
class MigrationRegistry {
|
||||||
|
|
@ -34,6 +35,7 @@ class MigrationRegistry {
|
||||||
_Migration20260914165608984(),
|
_Migration20260914165608984(),
|
||||||
_Migration20260930185557154(),
|
_Migration20260930185557154(),
|
||||||
_Migration20261007215503877(),
|
_Migration20261007215503877(),
|
||||||
|
_Migration20261008234516445(),
|
||||||
];
|
];
|
||||||
|
|
||||||
/// List of all client-side database migration versions.
|
/// List of all client-side database migration versions.
|
||||||
|
|
|
||||||
|
|
@ -2124,6 +2124,23 @@ class Protocol extends _isd.DatabaseSerializationManager {
|
||||||
isUnique: true,
|
isUnique: true,
|
||||||
isPrimary: false,
|
isPrimary: false,
|
||||||
),
|
),
|
||||||
|
_isd.IndexDefinition(
|
||||||
|
indexName: 'unique_set_default_child_name',
|
||||||
|
tableSpace: null,
|
||||||
|
elements: [
|
||||||
|
_isd.IndexElementDefinition(
|
||||||
|
type: _isd.IndexElementDefinitionType.column,
|
||||||
|
definition: 'spaceId',
|
||||||
|
),
|
||||||
|
_isd.IndexElementDefinition(
|
||||||
|
type: _isd.IndexElementDefinitionType.column,
|
||||||
|
definition: 'name',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
type: 'btree',
|
||||||
|
isUnique: true,
|
||||||
|
isPrimary: false,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
managed: true,
|
managed: true,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -2123,6 +2123,23 @@ class Protocol extends _is.DatabaseSerializationManager {
|
||||||
isUnique: true,
|
isUnique: true,
|
||||||
isPrimary: false,
|
isPrimary: false,
|
||||||
),
|
),
|
||||||
|
_isp.IndexDefinition(
|
||||||
|
indexName: 'unique_set_default_child_name',
|
||||||
|
tableSpace: null,
|
||||||
|
elements: [
|
||||||
|
_isp.IndexElementDefinition(
|
||||||
|
type: _isp.IndexElementDefinitionType.column,
|
||||||
|
definition: 'spaceId',
|
||||||
|
),
|
||||||
|
_isp.IndexElementDefinition(
|
||||||
|
type: _isp.IndexElementDefinitionType.column,
|
||||||
|
definition: 'name',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
type: 'btree',
|
||||||
|
isUnique: true,
|
||||||
|
isPrimary: false,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
managed: true,
|
managed: true,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -6,3 +6,7 @@ fields:
|
||||||
# Keep the database default without replacing explicit nulls on model reads.
|
# Keep the database default without replacing explicit nulls on model reads.
|
||||||
parentId: UuidValue?, defaultPersist='550e8400-e29b-41d4-a716-446655440000', unique
|
parentId: UuidValue?, defaultPersist='550e8400-e29b-41d4-a716-446655440000', unique
|
||||||
parent: Town?, relation(field=parentId, onDelete=SetDefault, deferred)
|
parent: Town?, relation(field=parentId, onDelete=SetDefault, deferred)
|
||||||
|
indexes:
|
||||||
|
unique_set_default_child_name:
|
||||||
|
fields: spaceId, name
|
||||||
|
unique: true
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,68 @@
|
||||||
|
{
|
||||||
|
"__className__": "serverpod.DatabaseMigration",
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"__className__": "serverpod.DatabaseMigrationAction",
|
||||||
|
"type": "alterTable",
|
||||||
|
"alterTable": {
|
||||||
|
"__className__": "serverpod.TableMigration",
|
||||||
|
"name": "unique_set_default_child",
|
||||||
|
"schema": "public",
|
||||||
|
"addColumns": [],
|
||||||
|
"deleteColumns": [],
|
||||||
|
"modifyColumns": [],
|
||||||
|
"addIndexes": [
|
||||||
|
{
|
||||||
|
"__className__": "serverpod.IndexDefinition",
|
||||||
|
"indexName": "unique_set_default_child_name",
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"__className__": "serverpod.IndexElementDefinition",
|
||||||
|
"type": 0,
|
||||||
|
"definition": "spaceId"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"__className__": "serverpod.IndexElementDefinition",
|
||||||
|
"type": 0,
|
||||||
|
"definition": "name"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "btree",
|
||||||
|
"isUnique": true,
|
||||||
|
"isPrimary": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"deleteIndexes": [],
|
||||||
|
"addForeignKeys": [],
|
||||||
|
"deleteForeignKeys": [],
|
||||||
|
"warnings": [
|
||||||
|
{
|
||||||
|
"__className__": "serverpod.DatabaseMigrationWarning",
|
||||||
|
"type": "uniqueIndexCreated",
|
||||||
|
"message": "Unique index \"unique_set_default_child_name\" is added to table \"unique_set_default_child\". If there are existing rows with duplicate values, this migration will fail.",
|
||||||
|
"table": "unique_set_default_child",
|
||||||
|
"columns": [
|
||||||
|
"spaceId",
|
||||||
|
"name"
|
||||||
|
],
|
||||||
|
"destrucive": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"warnings": [
|
||||||
|
{
|
||||||
|
"__className__": "serverpod.DatabaseMigrationWarning",
|
||||||
|
"type": "uniqueIndexCreated",
|
||||||
|
"message": "Unique index \"unique_set_default_child_name\" is added to table \"unique_set_default_child\". If there are existing rows with duplicate values, this migration will fail.",
|
||||||
|
"table": "unique_set_default_child",
|
||||||
|
"columns": [
|
||||||
|
"spaceId",
|
||||||
|
"name"
|
||||||
|
],
|
||||||
|
"destrucive": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"migrationApiVersion": 1
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,189 @@
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- ACTION ALTER TABLE
|
||||||
|
--
|
||||||
|
CREATE UNIQUE INDEX "unique_set_default_child_name" ON "unique_set_default_child" ("spaceId", "name");
|
||||||
|
|
||||||
|
--
|
||||||
|
-- STORE COLUMN TYPES FOR MIGRATIONS
|
||||||
|
--
|
||||||
|
DROP TABLE IF EXISTS "serverpod_sqlite_schema";
|
||||||
|
|
||||||
|
CREATE TABLE "serverpod_sqlite_schema" (
|
||||||
|
"table_name" TEXT NOT NULL,
|
||||||
|
"column_name" TEXT NOT NULL,
|
||||||
|
"column_type" TEXT NOT NULL,
|
||||||
|
"column_vector_dimension" INTEGER,
|
||||||
|
PRIMARY KEY ("table_name", "column_name")
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO "serverpod_sqlite_schema" VALUES
|
||||||
|
('address', 'id', 'uuid', NULL),
|
||||||
|
('address', 'inhabitantId', 'uuid', NULL),
|
||||||
|
('city', 'id', 'uuid', NULL),
|
||||||
|
('company', 'id', 'uuid', NULL),
|
||||||
|
('company', 'townId', 'uuid', NULL),
|
||||||
|
('fk_chain_cascade_middle', 'id', 'uuid', NULL),
|
||||||
|
('fk_chain_cascade_middle', 'rootId', 'uuid', NULL),
|
||||||
|
('fk_chain_middle_cascade_child', 'id', 'uuid', NULL),
|
||||||
|
('fk_chain_middle_cascade_child', 'restrictBlockerId', 'uuid', NULL),
|
||||||
|
('fk_chain_middle_set_null_child', 'id', 'uuid', NULL),
|
||||||
|
('fk_chain_middle_set_null_child', 'restrictBlockerId', 'uuid', NULL),
|
||||||
|
('fk_chain_restrict_blocker', 'id', 'uuid', NULL),
|
||||||
|
('fk_chain_restrict_blocker', 'cascadeMiddleId', 'uuid', NULL),
|
||||||
|
('fk_chain_root', 'id', 'uuid', NULL),
|
||||||
|
('fk_chain_set_null_cascade_child', 'id', 'uuid', NULL),
|
||||||
|
('fk_chain_set_null_cascade_child', 'setNullMiddleId', 'uuid', NULL),
|
||||||
|
('fk_chain_set_null_middle', 'id', 'uuid', NULL),
|
||||||
|
('fk_chain_set_null_middle', 'cascadeMiddleId', 'uuid', NULL),
|
||||||
|
('fk_chain_set_null_restrict_child', 'id', 'uuid', NULL),
|
||||||
|
('fk_chain_set_null_restrict_child', 'setNullMiddleId', 'uuid', NULL),
|
||||||
|
('fk_chain_set_null_set_null_child', 'id', 'uuid', NULL),
|
||||||
|
('fk_chain_set_null_set_null_child', 'setNullMiddleId', 'uuid', NULL),
|
||||||
|
('organization', 'id', 'uuid', NULL),
|
||||||
|
('organization', 'cityId', 'uuid', NULL),
|
||||||
|
('person', 'id', 'uuid', NULL),
|
||||||
|
('person', 'organizationId', 'uuid', NULL),
|
||||||
|
('person', 'oldCompanyId', 'uuid', NULL),
|
||||||
|
('person', 'cityId', 'uuid', NULL),
|
||||||
|
('required_cascade_child', 'id', 'uuid', NULL),
|
||||||
|
('required_cascade_child', 'parentId', 'uuid', NULL),
|
||||||
|
('required_no_action_child', 'id', 'uuid', NULL),
|
||||||
|
('required_no_action_child', 'parentId', 'uuid', NULL),
|
||||||
|
('restrict_child', 'id', 'uuid', NULL),
|
||||||
|
('restrict_child', 'parentId', 'uuid', NULL),
|
||||||
|
('shared_child', 'id', 'uuid', NULL),
|
||||||
|
('shared_child', 'parentId', 'uuid', NULL),
|
||||||
|
('shared_parent', 'id', 'uuid', NULL),
|
||||||
|
('town', 'id', 'uuid', NULL),
|
||||||
|
('town', 'cityId', 'uuid', NULL),
|
||||||
|
('town', 'mayorId', 'uuid', NULL),
|
||||||
|
('types', 'id', 'uuid', NULL),
|
||||||
|
('types', 'aBool', 'boolean', NULL),
|
||||||
|
('types', 'aDateTime', 'timestampWithoutTimeZone', NULL),
|
||||||
|
('types', 'optionalUuid', 'uuid', NULL),
|
||||||
|
('types', 'parentId', 'uuid', NULL),
|
||||||
|
('types', 'jsonDocument', 'json', NULL),
|
||||||
|
('types', 'jsonbDocument', 'jsonb', NULL),
|
||||||
|
('types', 'jsonbNumbers', 'jsonb', NULL),
|
||||||
|
('unique', 'id', 'uuid', NULL),
|
||||||
|
('unique_cascade_child', 'id', 'uuid', NULL),
|
||||||
|
('unique_cascade_child', 'parentId', 'uuid', NULL),
|
||||||
|
('unique_cascade_reference', 'id', 'uuid', NULL),
|
||||||
|
('unique_cascade_reference', 'parentId', 'uuid', NULL),
|
||||||
|
('unique_composite', 'id', 'uuid', NULL),
|
||||||
|
('unique_discriminator', 'id', 'uuid', NULL),
|
||||||
|
('unique_fk_pair', 'id', 'uuid', NULL),
|
||||||
|
('unique_fk_pair', 'leftId', 'uuid', NULL),
|
||||||
|
('unique_fk_pair', 'rightId', 'uuid', NULL),
|
||||||
|
('unique_mixed_fk', 'id', 'uuid', NULL),
|
||||||
|
('unique_mixed_fk', 'parentId', 'uuid', NULL),
|
||||||
|
('unique_no_release', 'id', 'uuid', NULL),
|
||||||
|
('unique_nullable', 'id', 'uuid', NULL),
|
||||||
|
('unique_overlapping', 'id', 'uuid', NULL),
|
||||||
|
('unique_set_default_child', 'id', 'uuid', NULL),
|
||||||
|
('unique_set_default_child', 'parentId', 'uuid', NULL),
|
||||||
|
('unique_set_null_child', 'id', 'uuid', NULL),
|
||||||
|
('unique_set_null_child', 'parentId', 'uuid', NULL),
|
||||||
|
('unique_uuid', 'id', 'uuid', NULL),
|
||||||
|
('unique_uuid', 'value', 'uuid', NULL),
|
||||||
|
('serverpod_cloud_storage', 'addedTime', 'timestampWithoutTimeZone', NULL),
|
||||||
|
('serverpod_cloud_storage', 'expiration', 'timestampWithoutTimeZone', NULL),
|
||||||
|
('serverpod_cloud_storage', 'verified', 'boolean', NULL),
|
||||||
|
('serverpod_cloud_storage_direct_download', 'expiration', 'timestampWithoutTimeZone', NULL),
|
||||||
|
('serverpod_cloud_storage_direct_upload', 'expiration', 'timestampWithoutTimeZone', NULL),
|
||||||
|
('serverpod_cloud_storage_direct_upload', 'preventOverwrite', 'boolean', NULL),
|
||||||
|
('serverpod_future_call', 'time', 'timestampWithoutTimeZone', NULL),
|
||||||
|
('serverpod_future_call', 'scheduling', 'json', NULL),
|
||||||
|
('serverpod_future_call_claim', 'lastHeartbeatTime', 'timestampWithoutTimeZone', NULL),
|
||||||
|
('serverpod_health_connection_info', 'timestamp', 'timestampWithoutTimeZone', NULL),
|
||||||
|
('serverpod_health_metric', 'timestamp', 'timestampWithoutTimeZone', NULL),
|
||||||
|
('serverpod_health_metric', 'isHealthy', 'boolean', NULL),
|
||||||
|
('serverpod_log', 'time', 'timestampWithoutTimeZone', NULL),
|
||||||
|
('serverpod_message_log', 'slow', 'boolean', NULL),
|
||||||
|
('serverpod_migrations', 'timestamp', 'timestampWithoutTimeZone', NULL),
|
||||||
|
('serverpod_query_log', 'slow', 'boolean', NULL),
|
||||||
|
('serverpod_runtime_settings', 'logSettings', 'json', NULL),
|
||||||
|
('serverpod_runtime_settings', 'logSettingsOverrides', 'json', NULL),
|
||||||
|
('serverpod_runtime_settings', 'logServiceCalls', 'boolean', NULL),
|
||||||
|
('serverpod_runtime_settings', 'logMalformedCalls', 'boolean', NULL),
|
||||||
|
('serverpod_session_log', 'time', 'timestampWithoutTimeZone', NULL),
|
||||||
|
('serverpod_session_log', 'slow', 'boolean', NULL),
|
||||||
|
('serverpod_session_log', 'isOpen', 'boolean', NULL),
|
||||||
|
('serverpod_session_log', 'touched', 'timestampWithoutTimeZone', NULL),
|
||||||
|
('serverpod_auth_core_jwt_refresh_token', 'id', 'uuid', NULL),
|
||||||
|
('serverpod_auth_core_jwt_refresh_token', 'authUserId', 'uuid', NULL),
|
||||||
|
('serverpod_auth_core_jwt_refresh_token', 'scopeNames', 'json', NULL),
|
||||||
|
('serverpod_auth_core_jwt_refresh_token', 'lastUpdatedAt', 'timestampWithoutTimeZone', NULL),
|
||||||
|
('serverpod_auth_core_jwt_refresh_token', 'createdAt', 'timestampWithoutTimeZone', NULL),
|
||||||
|
('serverpod_auth_core_profile', 'id', 'uuid', NULL),
|
||||||
|
('serverpod_auth_core_profile', 'authUserId', 'uuid', NULL),
|
||||||
|
('serverpod_auth_core_profile', 'createdAt', 'timestampWithoutTimeZone', NULL),
|
||||||
|
('serverpod_auth_core_profile', 'imageId', 'uuid', NULL),
|
||||||
|
('serverpod_auth_core_profile_image', 'id', 'uuid', NULL),
|
||||||
|
('serverpod_auth_core_profile_image', 'userProfileId', 'uuid', NULL),
|
||||||
|
('serverpod_auth_core_profile_image', 'createdAt', 'timestampWithoutTimeZone', NULL),
|
||||||
|
('serverpod_auth_core_session', 'id', 'uuid', NULL),
|
||||||
|
('serverpod_auth_core_session', 'authUserId', 'uuid', NULL),
|
||||||
|
('serverpod_auth_core_session', 'scopeNames', 'json', NULL),
|
||||||
|
('serverpod_auth_core_session', 'createdAt', 'timestampWithoutTimeZone', NULL),
|
||||||
|
('serverpod_auth_core_session', 'lastUsedAt', 'timestampWithoutTimeZone', NULL),
|
||||||
|
('serverpod_auth_core_session', 'expiresAt', 'timestampWithoutTimeZone', NULL),
|
||||||
|
('serverpod_auth_core_user', 'id', 'uuid', NULL),
|
||||||
|
('serverpod_auth_core_user', 'createdAt', 'timestampWithoutTimeZone', NULL),
|
||||||
|
('serverpod_auth_core_user', 'scopeNames', 'json', NULL),
|
||||||
|
('serverpod_auth_core_user', 'blocked', 'boolean', NULL),
|
||||||
|
('crdt_data_attempted_value', 'value', 'jsonb', NULL),
|
||||||
|
('crdt_data_fields', 'hlcDatetime', 'timestampWithoutTimeZone', NULL),
|
||||||
|
('crdt_data_rows', 'hlcDatetime', 'timestampWithoutTimeZone', NULL),
|
||||||
|
('crdt_data_rows', 'uuidRowId', 'uuid', NULL),
|
||||||
|
('crdt_data_tombstone', 'hlcDatetime', 'timestampWithoutTimeZone', NULL),
|
||||||
|
('crdt_nodes', 'uuidNodeId', 'uuid', NULL),
|
||||||
|
('crdt_nodes', 'lastHlc', 'jsonb', NULL),
|
||||||
|
('crdt_schema_columns', 'isNullable', 'boolean', NULL),
|
||||||
|
('offline_sync_integrity_violations', 'uuidRowId', 'uuid', NULL),
|
||||||
|
('offline_sync_integrity_violations', 'ownerSpaceUuid', 'uuid', NULL),
|
||||||
|
('offline_sync_integrity_violations', 'incomingSpaceUuid', 'uuid', NULL),
|
||||||
|
('offline_sync_integrity_violations', 'uuidNodeId', 'uuid', NULL),
|
||||||
|
('offline_sync_integrity_violations', 'hlcDatetime', 'timestampWithoutTimeZone', NULL),
|
||||||
|
('offline_sync_integrity_violations', 'firstSeenAt', 'timestampWithoutTimeZone', NULL),
|
||||||
|
('offline_sync_integrity_violations', 'lastSeenAt', 'timestampWithoutTimeZone', NULL),
|
||||||
|
('offline_sync_space_members', 'userUuid', 'uuid', NULL),
|
||||||
|
('offline_sync_space_nodes', 'lastReceivedHlc', 'jsonb', NULL),
|
||||||
|
('offline_sync_spaces', 'uuidSpaceId', 'uuid', NULL);
|
||||||
|
|
||||||
|
--
|
||||||
|
-- MIGRATION VERSION FOR serverpod_offline_sync_test
|
||||||
|
--
|
||||||
|
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
|
||||||
|
VALUES ('serverpod_offline_sync_test', '20261008234516445', (unixepoch('now', 'subsecond') * 1000))
|
||||||
|
ON CONFLICT ("module")
|
||||||
|
DO UPDATE SET "version" = '20261008234516445', "timestamp" = (unixepoch('now', 'subsecond') * 1000);
|
||||||
|
|
||||||
|
--
|
||||||
|
-- MIGRATION VERSION FOR serverpod
|
||||||
|
--
|
||||||
|
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
|
||||||
|
VALUES ('serverpod', '20260824182259319', (unixepoch('now', 'subsecond') * 1000))
|
||||||
|
ON CONFLICT ("module")
|
||||||
|
DO UPDATE SET "version" = '20260824182259319', "timestamp" = (unixepoch('now', 'subsecond') * 1000);
|
||||||
|
|
||||||
|
--
|
||||||
|
-- MIGRATION VERSION FOR serverpod_auth_core
|
||||||
|
--
|
||||||
|
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
|
||||||
|
VALUES ('serverpod_auth_core', '20260924105232991', (unixepoch('now', 'subsecond') * 1000))
|
||||||
|
ON CONFLICT ("module")
|
||||||
|
DO UPDATE SET "version" = '20260924105232991', "timestamp" = (unixepoch('now', 'subsecond') * 1000);
|
||||||
|
|
||||||
|
--
|
||||||
|
-- MIGRATION VERSION FOR serverpod_offline_sync
|
||||||
|
--
|
||||||
|
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
|
||||||
|
VALUES ('serverpod_offline_sync', '20260914143806119', (unixepoch('now', 'subsecond') * 1000))
|
||||||
|
ON CONFLICT ("module")
|
||||||
|
DO UPDATE SET "version" = '20260914143806119', "timestamp" = (unixepoch('now', 'subsecond') * 1000);
|
||||||
|
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
@ -16,3 +16,4 @@
|
||||||
20260914165608984
|
20260914165608984
|
||||||
20260930185557154
|
20260930185557154
|
||||||
20261007215503877
|
20261007215503877
|
||||||
|
20261008234516445
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,183 @@
|
||||||
|
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 '../integration/test_tools/client_session.dart';
|
||||||
|
import '../integration/test_tools/crdt_probes.dart';
|
||||||
|
import '../integration/test_tools/sync_topology.dart';
|
||||||
|
|
||||||
|
/// Serverpod reads a null insert value as a request for the column default, so
|
||||||
|
/// an upsert supplying null for `unique_set_default_child.parentId` writes
|
||||||
|
/// `550e8400-…`. That is an authored write of the default, not the full-row
|
||||||
|
/// passthrough of `docs/projection-model.md`, which only translates back a
|
||||||
|
/// projected value the caller echoed unchanged.
|
||||||
|
///
|
||||||
|
/// The two coincide exactly when a set-default repair is already displaying the
|
||||||
|
/// default over a hidden parent, which is the state the convergence simulation
|
||||||
|
/// reached at seed 1791399000: `unique_set_default_child.upsert … accepted
|
||||||
|
/// "550e8400-…" but retained "00000000-000b-…"`.
|
||||||
|
void main() {
|
||||||
|
initTestClientSession(createSessionPerTest: false);
|
||||||
|
|
||||||
|
const defaultTownId = UuidValue.raw('550e8400-e29b-41d4-a716-446655440000');
|
||||||
|
|
||||||
|
group(
|
||||||
|
'Given a set-default child displaying the column default for a hidden parent,',
|
||||||
|
() {
|
||||||
|
late SyncNode node;
|
||||||
|
late Town parent;
|
||||||
|
late UniqueSetDefaultChild child;
|
||||||
|
|
||||||
|
/// The clock the column authored its value at, which is the row's
|
||||||
|
/// insertion clock while no field record exists.
|
||||||
|
Future<Hlc> parentIdHlc() async {
|
||||||
|
final field = await CrdtDataField.db.findFirstRow(
|
||||||
|
node.offlineSync,
|
||||||
|
where: (t) =>
|
||||||
|
t.row.uuidRowId.equals(child.id) & t.column.name.equals('parentId'),
|
||||||
|
include: CrdtDataField.include(node: CrdtNode.include()),
|
||||||
|
);
|
||||||
|
return field?.hlc ?? await rowHlc(child.id!, databaseSession: node.offlineSync);
|
||||||
|
}
|
||||||
|
|
||||||
|
setUpAll(() async {
|
||||||
|
node = await syncNode(await createAdditionalTestSession(), testSyncTables);
|
||||||
|
parent = Town(id: const Uuid().v7obj(), name: 'parent');
|
||||||
|
child = UniqueSetDefaultChild(
|
||||||
|
id: const Uuid().v7obj(),
|
||||||
|
name: 'child',
|
||||||
|
parentId: parent.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
await node.offlineSync.db.transactionForUser(testCrdtUserId, (tx) async {
|
||||||
|
await Town.db.insert(node.offlineSync, [
|
||||||
|
parent,
|
||||||
|
Town(id: defaultTownId, name: 'default'),
|
||||||
|
], transaction: tx);
|
||||||
|
await UniqueSetDefaultChild.db.insertRow(
|
||||||
|
node.offlineSync,
|
||||||
|
child,
|
||||||
|
transaction: tx,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// A peer deleted the parent after this replica authored the reference,
|
||||||
|
// so the repair is projected rather than authored locally.
|
||||||
|
final childHlc = await rowHlc(child.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);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'when reading the repaired child, '
|
||||||
|
'then the repair displays the default over the authored parent.',
|
||||||
|
() async {
|
||||||
|
final repaired = await UniqueSetDefaultChild.db.findById(
|
||||||
|
node.offlineSync,
|
||||||
|
child.id!,
|
||||||
|
);
|
||||||
|
final attempt = await attemptedValue(
|
||||||
|
rowId: child.id!,
|
||||||
|
columnName: 'parentId',
|
||||||
|
databaseSession: node.offlineSync,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(repaired!.parentId, defaultTownId);
|
||||||
|
expect(attempt?.value.toString(), parent.id.toString());
|
||||||
|
expect(attempt?.projectionReason, CrdtProjectionReason.foreignKeySetDefault);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
group('when a full-row upsert supplies null for the parent,', () {
|
||||||
|
late Hlc before;
|
||||||
|
late Hlc after;
|
||||||
|
late CrdtDataAttemptedValue? attempt;
|
||||||
|
late UniqueSetDefaultChild? stored;
|
||||||
|
late UniqueSetDefaultChild? restored;
|
||||||
|
late CrdtMergeSet changes;
|
||||||
|
|
||||||
|
setUpAll(() async {
|
||||||
|
before = await parentIdHlc();
|
||||||
|
await node.offlineSync.db.transactionForUser(
|
||||||
|
testCrdtUserId,
|
||||||
|
(tx) => UniqueSetDefaultChild.db.upsertRow(
|
||||||
|
node.offlineSync,
|
||||||
|
UniqueSetDefaultChild(id: child.id, name: 'edited'),
|
||||||
|
conflictColumns: (t) => [t.id],
|
||||||
|
transaction: tx,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
stored = await UniqueSetDefaultChild.db.findById(
|
||||||
|
node.offlineSync,
|
||||||
|
child.id!,
|
||||||
|
);
|
||||||
|
|
||||||
|
attempt = await attemptedValue(
|
||||||
|
rowId: child.id!,
|
||||||
|
columnName: 'parentId',
|
||||||
|
databaseSession: node.offlineSync,
|
||||||
|
);
|
||||||
|
|
||||||
|
after = await parentIdHlc();
|
||||||
|
changes = await node.sync
|
||||||
|
.collectPendingChanges(
|
||||||
|
node.raw,
|
||||||
|
checkpointsBySpaceUuid: {testCrdtUserId: const []},
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
await node.offlineSync.db.transactionForUser(
|
||||||
|
testCrdtUserId,
|
||||||
|
(tx) => Town.db.insertRow(node.offlineSync, parent, transaction: tx),
|
||||||
|
);
|
||||||
|
|
||||||
|
restored = await UniqueSetDefaultChild.db.findById(
|
||||||
|
node.offlineSync,
|
||||||
|
child.id!,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('then the default it wrote becomes the authored value.', () {
|
||||||
|
expect(stored!.name, 'edited');
|
||||||
|
expect(stored!.parentId, defaultTownId);
|
||||||
|
expect(attempt, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('then the write advances the authored clock of the column.', () {
|
||||||
|
expect(after, greaterThan(before));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('then outbound sync exports the default, not the replaced parent.', () {
|
||||||
|
final insert = changes.inserts.singleWhere(
|
||||||
|
(change) => change.uuidRowId == child.id,
|
||||||
|
);
|
||||||
|
expect((insert.data as UniqueSetDefaultChild).parentId, defaultTownId);
|
||||||
|
expect(
|
||||||
|
changes.updates
|
||||||
|
.where(
|
||||||
|
(change) =>
|
||||||
|
change.uuidRowId == child.id && change.columnName == 'parentId',
|
||||||
|
)
|
||||||
|
.map((change) => change.value.toString()),
|
||||||
|
everyElement(defaultTownId.toString()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('then restoring the old parent leaves the written default.', () {
|
||||||
|
expect(restored!.parentId, defaultTownId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -627,14 +627,15 @@ void main() {
|
||||||
expect(rows, hasLength(1));
|
expect(rows, hasLength(1));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('then CRDT fields are tracked for the default updated columns.', () async {
|
test('then the conflict columns keep their original implicit clocks.', () async {
|
||||||
final fields = await CrdtDataField.db.find(
|
final fields = await CrdtDataField.db.find(
|
||||||
session,
|
session,
|
||||||
where: (t) => t.row.uuidRowId.equals(uniqueComposite.id),
|
where: (t) => t.row.uuidRowId.equals(uniqueComposite.id),
|
||||||
include: CrdtDataField.include(column: CrdtSchemaColumn.include()),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(fields.map((e) => e.column!.name).toSet(), {'scope', 'value'});
|
// Both domain columns are conflict keys, so Serverpod's default
|
||||||
|
// update set contains no authored domain write.
|
||||||
|
expect(fields, isEmpty);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ void main() {
|
||||||
});
|
});
|
||||||
|
|
||||||
withServerpod(
|
withServerpod(
|
||||||
'PostgreSQL membership visibility',
|
'[PostgreSQL membership visibility]',
|
||||||
rollbackDatabase: RollbackDatabase.disabled,
|
rollbackDatabase: RollbackDatabase.disabled,
|
||||||
serverDirectory: serverDirectory,
|
serverDirectory: serverDirectory,
|
||||||
configOverride: (config) => config.copyWith(
|
configOverride: (config) => config.copyWith(
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue