test: Make the DST more robust (#116)

This commit is contained in:
Marcelo Mendonça Soares 2026-09-14 10:26:56 -03:00 • committed by GitHub
commit 574f8d067a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
81 changed files with 63267 additions and 720 deletions

View file

@ -168,6 +168,12 @@ Future<void> main(List<String> args) async {
changeCount: rowCount,
),
UniqueMergeBenchmark('merge (unique conflict)', changeCount: rowCount),
for (final operation in SetDefaultOperation.values)
SetDefaultMergeBenchmark(
'merge (set default ${operation.name})',
operation: operation,
pairCount: rowCount < 1 ? 1 : rowCount,
),
for (final operation in FkChainOperation.values)
FkChainMergeBenchmark(
'merge (fk chain ${operation.name})',
@ -189,6 +195,8 @@ Future<void> main(List<String> args) async {
batchDescription: benchmark.batchDescription,
average: mergeResult.averageMicroseconds,
averageQueries: mergeResult.averageQueries,
averageRowsRead: mergeResult.averageRowsRead,
averageRowsReadByType: mergeResult.averageRowsReadByType,
changeCount: benchmark.changesPerBatch,
),
runningInCI: runningInCI,

View file

@ -485,6 +485,7 @@ Future<double> measurePreparedCycles(
int minimumMillis, {
required Future<void> Function() prepare,
required Future<void> Function() run,
Future<void> Function()? validate,
}) async {
final minimumMicros = minimumMillis * 1000;
final watch = Stopwatch()..start();
@ -495,8 +496,10 @@ Future<double> measurePreparedCycles(
await prepare();
final sw = Stopwatch()..start();
await run();
sw.stop();
totalTimedMicros += sw.elapsedMicroseconds;
timedIterations++;
await validate?.call();
}
return totalTimedMicros / timedIterations;

View file

@ -19,10 +19,14 @@ enum MergeOperation { insert, update, delete, mixed }
enum FkChainOperation { insert, delete }
enum SetDefaultOperation { originalDeleteOrRestore, defaultDeleteOrRestore }
/// Measurement of a merge scenario, averaged over the timed merge batches.
typedef MergeMeasurement = ({
double averageMicroseconds,
double averageQueries,
double averageRowsRead,
Map<String, double> averageRowsReadByType,
});
/// The number of changes of each kind in a mixed batch of [changeCount].
@ -69,6 +73,8 @@ abstract class MergeScenarioBenchmark extends AsyncBenchmarkBase {
CrdtMergeSet _mergeSet = [];
int _timedQueries = 0;
int _timedRowsRead = 0;
final _timedRowsReadByType = <String, int>{};
int _timedRuns = 0;
/// Scenario title used in the results header, e.g. `INSERT`.
@ -86,6 +92,9 @@ abstract class MergeScenarioBenchmark extends AsyncBenchmarkBase {
/// Seeds state once after the session is created, before the first cycle.
Future<void> onSetup() async {}
/// Checks that each measured change took effect, outside the timed merge.
Future<void> validateCycle() async {}
Hlc _nextRemoteHlc() => _remoteHlc = _remoteHlc.increment();
int _nextSeq() => _valueSeq++;
@ -149,6 +158,8 @@ abstract class MergeScenarioBenchmark extends AsyncBenchmarkBase {
_valueSeq = 0;
_mergeSet = [];
_timedQueries = 0;
_timedRowsRead = 0;
_timedRowsReadByType.clear();
_timedRuns = 0;
final dbPath = p.join(
Directory.systemTemp.path,
@ -181,17 +192,26 @@ abstract class MergeScenarioBenchmark extends AsyncBenchmarkBase {
_warmupMillis,
prepare: prepareCycle,
run: run,
validate: validateCycle,
);
_timedQueries = 0;
_timedRowsRead = 0;
_timedRowsReadByType.clear();
_timedRuns = 0;
final averageMicroseconds = await measurePreparedCycles(
_measurementMillis,
prepare: prepareCycle,
run: run,
validate: validateCycle,
);
return (
averageMicroseconds: averageMicroseconds,
averageQueries: _timedQueries / _timedRuns,
averageRowsRead: _timedRowsRead / _timedRuns,
averageRowsReadByType: {
for (final entry in _timedRowsReadByType.entries)
entry.key: entry.value / _timedRuns,
},
);
} finally {
await teardown();
@ -201,8 +221,20 @@ abstract class MergeScenarioBenchmark extends AsyncBenchmarkBase {
@override
Future<void> run() async {
final queriesBefore = _countingDb.queryCount;
final rowsBefore = _countingDb.rowsRead;
final rowsByTypeBefore = Map.of(_countingDb.rowsReadByType);
await _crdtSession.db.mergeChanges(_mergeSet, scopeId: _userId);
_timedQueries += _countingDb.queryCount - queriesBefore;
_timedRowsRead += _countingDb.rowsRead - rowsBefore;
for (final entry in _countingDb.rowsReadByType.entries) {
final count = entry.value - (rowsByTypeBefore[entry.key] ?? 0);
if (count == 0) continue;
_timedRowsReadByType.update(
entry.key,
(total) => total + count,
ifAbsent: () => count,
);
}
_timedRuns++;
}
@ -511,3 +543,110 @@ typedef _FkChainFamily = ({
FkChainRestrictBlocker restrictBlocker,
List<TableRow<UuidValue?>> rows,
});
/// Measures SET DEFAULT projection with many independent town/company pairs.
/// Compares deleting/restoring an original parent with changing the shared
/// default target, which also requires looking up implicit dependencies.
class SetDefaultMergeBenchmark extends MergeScenarioBenchmark {
SetDefaultMergeBenchmark(
super.name, {
required this.operation,
required this.pairCount,
}) {
if (pairCount < 1) {
throw ArgumentError.value(pairCount, 'pairCount', 'Must be >= 1');
}
}
static const _defaultTownId = UuidValue.raw('550e8400-e29b-41d4-a716-446655440000');
final SetDefaultOperation operation;
final int pairCount;
late List<Town> _towns;
late List<Company> _companies;
late Town _defaultTown;
var _cycle = 0;
var _visibilityFlag = 1;
@override
String get resultTitle =>
'SET DEFAULT ${switch (operation) {
SetDefaultOperation.originalDeleteOrRestore => 'ORIGINAL DELETE / RESTORE',
SetDefaultOperation.defaultDeleteOrRestore => 'DEFAULT DELETE / RESTORE',
}}';
@override
int get changesPerBatch => 1;
@override
String get batchDescription =>
'${formatter0.format(pairCount)} town/company pairs sharing one default';
@override
Future<void> onSetup() async {
_cycle = 0;
_visibilityFlag = 1;
_defaultTown = Town(id: _defaultTownId, name: 'default');
_towns = [
for (var i = 0; i < pairCount; i++)
Town(id: const Uuid().v7obj(), name: 'town $i'),
];
_companies = [
for (var i = 0; i < pairCount; i++)
Company(id: const Uuid().v7obj(), name: 'company $i', townId: _towns[i].id),
];
await mergeSeedRows([
_defaultTown,
..._towns,
..._companies,
]);
}
bool get _alternate => _cycle.isOdd;
Town get _changedTown => switch (operation) {
SetDefaultOperation.originalDeleteOrRestore => _towns.first,
SetDefaultOperation.defaultDeleteOrRestore => _defaultTown,
};
UuidValue get _expectedTownId =>
operation == SetDefaultOperation.originalDeleteOrRestore && _alternate
? _defaultTownId
: _towns.first.id!;
@override
Future<void> prepareCycle() async {
_cycle++;
_mergeSet = [_visibilityChange(_changedTown)];
}
CrdtMergeDelete _visibilityChange(Town town) {
final hlc = _nextRemoteHlc();
// HLC advancement alone cannot supersede a higher visibility flag. Advance
// both on every delete/restore so no measured cycle becomes a stale no-op.
return CrdtMergeDelete(
uuidScopeId: _userId,
tableName: Town.t.tableName,
uuidRowId: town.id!,
uuidNodeId: hlc.nodeId,
hlcDatetime: hlc.datetime,
hlcCounter: hlc.counter,
clFlag: ++_visibilityFlag,
reason: _alternate
? CrdtDataDeletedReason.userDelete
: CrdtDataDeletedReason.userReinsert,
);
}
@override
Future<void> validateCycle() async {
final company = await Company.db.findById(_crdtSession, _companies.first.id!);
if (company == null || company.townId != _expectedTownId) {
throw StateError('$name cycle $_cycle did not project the company as expected.');
}
final visibleTown = await Town.db.findById(_crdtSession, _changedTown.id!);
if ((visibleTown == null) != _alternate) {
throw StateError('$name cycle $_cycle did not apply its visibility change.');
}
}
}

View file

@ -16,6 +16,18 @@ class QueryCountingDatabase implements Database {
/// Number of database operations delegated so far.
int queryCount = 0;
/// Top-level rows returned by read APIs, including CRDT metadata and repeated
/// reads. This is result volume, not SQLite page reads or query-plan scans.
int rowsRead = 0;
/// ORM result rows grouped by model type (raw SQL results use `sql`).
final Map<String, int> rowsReadByType = {};
void _recordRows(String type, int count) {
rowsRead += count;
rowsReadByType.update(type, (value) => value + count, ifAbsent: () => count);
}
@override
DatabaseAnalyzer get analyzer => _delegate.analyzer;
@ -40,7 +52,7 @@ class QueryCountingDatabase implements Database {
LockBehavior? lockBehavior,
}) async {
queryCount++;
return _delegate.find<T>(
final result = await _delegate.find<T>(
where: where,
limit: limit,
offset: offset,
@ -51,6 +63,8 @@ class QueryCountingDatabase implements Database {
lockMode: lockMode,
lockBehavior: lockBehavior,
);
_recordRows(T.toString(), result.length);
return result;
}
@override
@ -62,13 +76,15 @@ class QueryCountingDatabase implements Database {
LockBehavior? lockBehavior,
}) async {
queryCount++;
return _delegate.findById<T>(
final result = await _delegate.findById<T>(
id,
transaction: transaction,
include: include,
lockMode: lockMode,
lockBehavior: lockBehavior,
);
_recordRows(T.toString(), result == null ? 0 : 1);
return result;
}
@override
@ -84,7 +100,7 @@ class QueryCountingDatabase implements Database {
LockBehavior? lockBehavior,
}) async {
queryCount++;
return _delegate.findFirstRow<T>(
final result = await _delegate.findFirstRow<T>(
where: where,
offset: offset,
orderBy: orderBy,
@ -94,6 +110,8 @@ class QueryCountingDatabase implements Database {
lockMode: lockMode,
lockBehavior: lockBehavior,
);
_recordRows(T.toString(), result == null ? 0 : 1);
return result;
}
@override
@ -311,12 +329,14 @@ class QueryCountingDatabase implements Database {
QueryParameters? parameters,
}) async {
queryCount++;
return _delegate.unsafeQuery(
final result = await _delegate.unsafeQuery(
query,
timeoutInSeconds: timeoutInSeconds,
transaction: transaction,
parameters: parameters,
);
_recordRows('sql', result.length);
return result;
}
@override
@ -342,11 +362,13 @@ class QueryCountingDatabase implements Database {
Transaction? transaction,
}) async {
queryCount++;
return _delegate.unsafeSimpleQuery(
final result = await _delegate.unsafeSimpleQuery(
query,
timeoutInSeconds: timeoutInSeconds,
transaction: transaction,
);
_recordRows('sql', result.length);
return result;
}
@override

View file

@ -129,6 +129,13 @@ void printMergeImpact(
' Queries: ${formatter0.format(results.averageQueries)} per batch '
'(${formatter2.format(queriesPerChange)} per change)',
);
print(' Rows returned: ${formatter2.format(results.averageRowsRead)} per batch');
final types = results.averageRowsReadByType.keys.toList()..sort();
print(
' Rows by type: ${[
for (final type in types) '$type: ${formatter2.format(results.averageRowsReadByType[type])}',
].join(', ')}',
);
if (runningInCI) print('```');
}
@ -200,6 +207,8 @@ class MergeBenchmarkResults {
required this.batchDescription,
required this.average,
required this.averageQueries,
required this.averageRowsRead,
required this.averageRowsReadByType,
required this.changeCount,
});
@ -211,6 +220,11 @@ class MergeBenchmarkResults {
/// Average number of queries issued while merging one batch.
final double averageQueries;
/// Top-level rows returned by reads during one merge batch, including repeats.
/// These are result counts, not physical database page reads or scanned rows.
final double averageRowsRead;
final Map<String, double> averageRowsReadByType;
final int changeCount;
}

View file

@ -53,13 +53,27 @@ soft-deleted instead of removed:
FK is nullable, and those child FK fields receive ordinary CRDT field updates.
- `ON DELETE SET DEFAULT`: visible child FK columns are updated to the column
default when the default is legal, and those child FK fields receive ordinary
CRDT field updates.
CRDT field updates. A nullable null default is legal; a non-null default must
remain visible in the same scope after the delete. A default in the same
delete batch is unavailable. If a visible child needs this repair, an invalid
default rejects the transaction, including any earlier repairs or tombstones
in that transaction. An unused invalid default does not itself block a delete.
- `ON DELETE CASCADE`: visible cascade descendants receive synced
user-delete tombstones. Local cascade descendants are not hidden only as
`foreignKeyCascade` projection rows.
These local side effects are the facts that other replicas merge.
Nullable fields must preserve explicit nulls when models are serialized and read
back. For a nullable FK with a non-null database default, use Serverpod's
`defaultPersist` declaration: the combined `default` also supplies a constructor
fallback that replaces null on model reads. Materialized inserts retain nulls
chosen by projection instead of applying the database default again. Local
upserts apply a fixed UUID FK default to null input fields. This path does not
distinguish an omitted field from an explicitly supplied null; preserving a
projection-selected null does not imply that an upsert can author null on such
a column.
## Merge-Time Action Semantics
For each child FK whose attempted value points to a hidden or missing parent:
@ -84,6 +98,57 @@ would cascade-delete `B`, but `B` has a visible `RESTRICT` child `C`, then `A`,
`B`, and `C` remain visible. The result must not depend on whether cascade or
restrict edges are evaluated first internally.
Hidden children do not block parent deletions. They still participate in FK
projection: when no legal repair exists and the authored parent remains
physically present in the same scope, the FK candidate retains the authored
reference, even if that parent is hidden. This recomputes from authored values;
it does not preserve an earlier projected fallback. Unique projection can then
release that candidate, as described in the combined projection model below.
Default targets do not have to be permanent. Projection starts from all winning
authored delete tombstones in the affected closure and computes their candidate
cascade closures. It evaluates repairs against that candidate visibility,
withdraws blocked deletion roots together, and repeats until no root is
withdrawn. A withdrawn root is not reintroduced during that pass. This
conservative policy terminates because the set of accepted roots only shrinks;
it need not accept the largest possible set of deletions. Every new pass starts
from the authored tombstones again, not the previous projection's accepted deletions.
For a child authored as `C -> A` with default `D`, merging an independently
authored deletion of `A` projects `C -> D` while `D` survives. If a deletion of
`D` is then merged and has no other blockers, `A` becomes visible again and
`C -> A` is restored. Merging a restoration of `D` can make `A`'s deletion take
effect again. Throughout this example the winning authored child reference
remains `C -> A`; the derived repairs do not advance its HLC. A local deletion
of `A` with this visible child instead authors `C -> D`, so it is a different
set of facts and does not have this restoration behavior.
A default is a schema dependency even while no child currently references it.
Projection loads defaults as evaluation context, but does not treat that alone
as a change to every consumer. It follows authored FK references (including new
overlays) to determine which defaults are affected, excluding relationships that
exist only because an earlier projection rewrote a child onto its fallback. A
live standalone default with no authored delete cannot change visibility merely
because a child's FK is updated. For defaults referenced by primary key, that
common case skips the dependency walk.
When a default is changed directly, or affected through a possible deletion,
cascade, or missing parent, the loader queries sparse dependency seeds in the
current scope: authored tombstones and hidden rows in the original-parent and
cascade-ancestor tables, hidden children, and children with FK overrides. The
ordinary reference walk then loads their children, parents, and unique claimants.
Blocked deletions are included even when the original parent is still visible
and the child has no override. Dependency loading repeats until no new default
edge needs expansion. Deletion arbitration then runs on the loaded closure,
starting from authored tombstones.
This keeps ordinary merges on the existing row-closure path; there is no
component-wide domain-row load. Default-specific invalidation can still visit
many rows when many deletes, hidden rows, or repair claims depend on that default.
The sparse metadata lookup itself may scan rows in the relevant scoped metadata
tables, but it runs only for affected defaults. Full rebuilds still load the
complete requested components.
## Combined Projection Boundary
The attempted-value schema, hidden-row lifecycle, exact FK-before-unique
@ -183,7 +248,7 @@ The combined FK/unique, hidden-row restoration, batching, and unrelated-update
cases are centralized under the
[projection model verification gates](projection-model.md#verification-gates).
## What These Tests Prove
## What These Tests Check
- Integrity: no surviving visible FK violation.
- Convergence: same merged facts produce the same projection on every replica.

View file

@ -343,8 +343,27 @@ class CrdtDatabase implements Database {
final result = plannedInsert.rows.isEmpty
? <T>[]
: await _delegate.insert<T>(
: await insertWithExplicitNulls<T>(
_delegate,
plannedInsert.rows,
explicitNulls: {
for (final row in plannedInsert.rows)
if ({
for (final column in row.table.crdtSyncableColumns)
if (column.hasDefault &&
row.id != null &&
plannedInsert.attempts.containsKey((
row.table.tableName,
row.id as UuidValue,
column.columnName,
)) &&
(row.toJsonForDatabase() as Map)[column.columnName] ==
null)
column.columnName,
}
case final columns when columns.isNotEmpty)
row.id as UuidValue: columns,
},
transaction: tx,
ignoreConflicts: ignoreConflicts,
noReturn: skipReturn,
@ -421,7 +440,7 @@ class CrdtDatabase implements Database {
// 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,
_recorder.withForeignKeyInsertDefaults(prepared.rows),
conflictColumns: conflictColumns,
updateColumns: updateColumns,
updateWhere: await _whereVisibleWithTombstone<T>(

View file

@ -877,7 +877,14 @@ extension CrdtMergeRecorderExtension on CrdtMutationRecorder {
await DatabaseUtil.runInTransactionOrSavepoint(
_db,
transaction,
(savepoint) => _db.insertRow(scopedRow, transaction: savepoint),
(savepoint) => _db.insertRow(
withExplicitInsertNulls(scopedRow, {
for (final column in scopedRow.table.crdtSyncableColumns)
if (column.hasDefault && data[column.columnName] == null)
column.columnName,
}),
transaction: savepoint,
),
);
} on DatabaseQueryException {
final owner = await _readDomainRowOwner(

View file

@ -112,6 +112,83 @@ extension CrdtTableRowExtension<T extends TableRow> on T {
T copyWithScopeId(int scopeId) => (this as dynamic).copyWith(scopeId: scopeId) as T;
}
/// Adapts a materialized domain row for an ORM insert. A null chosen by
/// projection is a stored value, even when the column has a database default.
/// Serverpod otherwise treats null insert values as requests for that default.
@internal
TableRow<UuidValue?> withExplicitInsertNulls(
TableRow row,
Set<String> columns,
) => columns.isEmpty ? row as TableRow<UuidValue?> : _ExplicitNullRow(row, columns);
/// Keeps per-row default semantics when a batch contains projected nulls.
/// PostgreSQL builds a batch using the first row's table, so rows with different
/// null overrides must be written separately within the caller's transaction.
@internal
Future<List<T>> insertWithExplicitNulls<T extends TableRow>(
Database database,
List<T> rows, {
required Map<UuidValue, Set<String>> explicitNulls,
required Transaction transaction,
bool ignoreConflicts = false,
bool noReturn = false,
}) async {
if (explicitNulls.isEmpty) {
return database.insert<T>(
rows,
transaction: transaction,
ignoreConflicts: ignoreConflicts,
noReturn: noReturn,
);
}
return [
for (final row in rows)
...(await database.insert<TableRow<UuidValue?>>(
[withExplicitInsertNulls(row, explicitNulls[row.id] ?? const {})],
transaction: transaction,
ignoreConflicts: ignoreConflicts,
noReturn: noReturn,
)).cast<T>(),
];
}
class _ExplicitNullRow implements TableRow<UuidValue?> {
_ExplicitNullRow(this.row, Set<String> columns)
: table = _ExplicitNullTable(row.table, columns);
final TableRow row;
@override
UuidValue? get id => row.id as UuidValue?;
@override
final Table<UuidValue?> table;
@override
dynamic toJson() => row.toJson();
}
class _ExplicitNullTable extends Table<UuidValue?> {
_ExplicitNullTable(Table source, Set<String> explicitNulls)
: super(tableName: source.tableName) {
columns = [
for (final column in source.columns)
if (explicitNulls.contains(column.columnName))
_ExplicitNullColumn(column)
else
column,
];
}
@override
late final List<Column> columns;
}
class _ExplicitNullColumn extends Column<Object?> {
_ExplicitNullColumn(Column source)
: super(source.columnName, source.table, fieldName: source.fieldName);
}
/// Helpers over lists of domain [TableRow]s used by the CRDT recorder.
@internal
extension TableRowListExtension on List<TableRow> {

View file

@ -194,21 +194,36 @@ class CrdtForeignKeyProjector {
transaction,
);
case ForeignKeyAction.setDefault:
final defaultValue = _context.defaultValueForColumn(
reference.childTableName,
reference.childColumn,
final edge = _foreignKeys.edgesByChildTable[reference.childTableName]!
.firstWhere((edge) => edge.childColumn == reference.childColumn);
final repair = await _defaultProjectionValueFromDatabase(
edge,
transaction,
);
if (defaultValue == null) {
throw StateError(
'No default value found for '
'${reference.childTableName}.${reference.childColumn}.',
// Soft deletion leaves the physical target in place, so the SQL
// FK cannot reject a hidden or cross-scope default for us. A target
// in this delete batch is still visible now but cannot be a repair.
final deletesDefault =
repair.value != null &&
(reference.parentColumn == 'id'
? parentIds.contains(repair.value)
: parentValuesById!.values.any(
(values) =>
values[reference.parentColumn].toUuidValue() ==
repair.value,
));
if (!repair.valid || deletesDefault) {
throw Exception(
'Cannot delete $parentTableName row because '
'${reference.childTableName}.${reference.childColumn} '
'has no legal set-default target.',
);
}
await _updateChildColumnTo(
reference.childTableName,
childIds,
reference.childColumn,
defaultValue,
repair.value,
transaction,
);
case ForeignKeyAction.cascade:
@ -1056,6 +1071,10 @@ class CrdtForeignKeyProjector {
/// Loads the rows the seeds can reach instead of every row of their tables.
///
/// Defaults are loaded as evaluation context. Their implicit dependents join
/// only when the default itself or its possible visibility is affected, not
/// merely because an unrelated child needs to inspect its fallback.
///
/// Cascade, restrict and repair all travel along foreign key edges, and a
/// row reached by no edge from a seed cannot change, so the closure is the
/// seeds plus their foreign key component: children of a loaded row, because
@ -1102,49 +1121,283 @@ class CrdtForeignKeyProjector {
enqueue(rowKey.$1, [rowKey.$2]);
(frontier[rowKey.$1] ??= <UuidValue>{}).add(rowKey.$2);
}
await _enqueueSetDefaultTargets(
tablesToLoad: tablesToLoad,
enqueue: enqueue,
transaction: transaction,
);
final defaultEdges = [
for (final edge in _foreignKeys.edges)
if (edge.action == ForeignKeyAction.setDefault &&
edge.defaultValue.toUuidValue() != null &&
tablesToLoad.contains(edge.childTableName))
edge,
];
await _enqueueSetDefaultTargets(defaultEdges, enqueue, transaction);
while (queued.isNotEmpty || frontier.isNotEmpty) {
final wave = queued;
queued = <String, Set<UuidValue>>{};
final affectedSeeds = {...seedRows, ...unwrittenValues.keys};
final expandedDefaults = <ForeignKeyEdge>{};
while (true) {
while (queued.isNotEmpty || frontier.isNotEmpty) {
final wave = queued;
queued = <String, Set<UuidValue>>{};
final loadedNow = <String, Set<UuidValue>>{};
for (final MapEntry(key: tableName, value: ids) in wave.entries) {
final loaded = await _loadTableRowsInto(
tableName: tableName,
rowIds: ids,
columnNames: columnsByTable[tableName] ?? const <String>{},
final loadedNow = <String, Set<UuidValue>>{};
for (final MapEntry(key: tableName, value: ids) in wave.entries) {
final loaded = await _loadTableRowsInto(
tableName: tableName,
rowIds: ids,
columnNames: columnsByTable[tableName] ?? const <String>{},
rows: rows,
fieldIds: fieldIds,
attemptedValues: attemptedValues,
fieldHlcs: fieldHlcs,
transaction: transaction,
);
if (loaded.isNotEmpty) loadedNow[tableName] = loaded;
}
for (final MapEntry(key: tableName, value: ids) in frontier.entries) {
(loadedNow[tableName] ??= <UuidValue>{}).addAll(ids);
}
frontier.clear();
if (loadedNow.isEmpty) break;
await _expandRowClosure(
loadedNow: loadedNow,
tablesToLoad: tablesToLoad,
rows: rows,
fieldIds: fieldIds,
unwrittenValues: unwrittenValues,
attemptedValues: attemptedValues,
fieldHlcs: fieldHlcs,
enqueue: enqueue,
transaction: transaction,
);
if (loaded.isNotEmpty) loadedNow[tableName] = loaded;
}
for (final MapEntry(key: tableName, value: ids) in frontier.entries) {
(loadedNow[tableName] ??= <UuidValue>{}).addAll(ids);
}
frontier.clear();
if (loadedNow.isEmpty) break;
await _expandRowClosure(
loadedNow: loadedNow,
tablesToLoad: tablesToLoad,
if (expandedDefaults.length == defaultEdges.length) break;
final defaultsToExpand = _affectedDefaults(
defaultEdges: defaultEdges,
expandedDefaults: expandedDefaults,
affectedSeeds: affectedSeeds,
rows: rows,
unwrittenValues: unwrittenValues,
attemptedValues: attemptedValues,
enqueue: enqueue,
transaction: transaction,
pendingInserts: pendingInserts,
);
if (defaultsToExpand.isEmpty) break;
for (final edge in defaultsToExpand) {
expandedDefaults.add(edge);
final dependents = await _findSetDefaultDependents(edge, transaction);
affectedSeeds.addAll(dependents);
for (final key in dependents) {
enqueue(key.$1, [key.$2]);
}
}
}
}
Future<void> _enqueueSetDefaultTargets(
List<ForeignKeyEdge> defaultEdges,
void Function(String, Iterable<UuidValue>) enqueue,
Transaction transaction,
) async {
for (final edge in defaultEdges) {
await _enqueueParentRowsByValue(
edge,
{edge.defaultValue.toUuidValue()!},
enqueue,
transaction,
);
}
}
/// A fallback matters to children whose authored parent can be hidden or
/// missing, or whose FK already has a projection override. Seed those sparse
/// states and their cascade roots, then use the normal indexed reference walk.
Future<Set<MergeRowKey>> _findSetDefaultDependents(
ForeignKeyEdge edge,
Transaction transaction,
) async {
final ancestors = <String>{edge.parentTableName};
final pendingTables = [edge.parentTableName];
while (pendingTables.isNotEmpty) {
final table = pendingTables.removeLast();
for (final parentEdge
in _foreignKeys.edgesByChildTable[table] ?? <ForeignKeyEdge>[]) {
if (parentEdge.action == ForeignKeyAction.cascade &&
ancestors.add(parentEdge.parentTableName)) {
pendingTables.add(parentEdge.parentTableName);
}
}
}
return _context.findSetDefaultDependencyRows(
ancestorTableNames: ancestors,
childTableName: edge.childTableName,
childColumn: edge.childColumn,
transaction: transaction,
);
}
/// Defaults whose implicit outgoing dependencies may have changed.
///
/// A projected C -> D is not an authored edge: if C still wants A, restoring
/// A does not change D's deletion blockers. Following that projected edge
/// here would invalidate every unrelated default consumer on ordinary merges.
/// The stored authored reference and any new overlay do both participate.
Set<ForeignKeyEdge> _affectedDefaults({
required List<ForeignKeyEdge> defaultEdges,
required Set<ForeignKeyEdge> expandedDefaults,
required Set<MergeRowKey> affectedSeeds,
required Map<MergeRowKey, _ProjectedForeignKeyRow> rows,
required Map<MergeRowKey, Map<String, Object?>> unwrittenValues,
required Map<MergeFieldKey, CrdtDataAttemptedValue> attemptedValues,
required List<PendingProjectionRow> pendingInserts,
}) {
Set<UuidValue> references(
MergeRowKey key,
String column, {
bool includeProjected = false,
}) {
if (column == 'id') return {key.$2};
final attempted = attemptedValues[(key.$1, key.$2, column)];
return {
if (attempted != null) ?tryUuidValue(attempted.value),
if (attempted == null || includeProjected)
?tryUuidValue(rows[key]?.values[column]),
?tryUuidValue(unwrittenValues[key]?[column]),
};
}
// A live default with no authored parents cannot change visibility unless
// it is changed directly or already has a delete tombstone to arbitrate.
// Most defaults are standalone rows, so ordinary FK writes avoid even the
// in-memory dependency walk. A missing default also stays missing until an
// insert of that key seeds the pass. Non-PK references use the general walk
// because a different row can gain or lose the referenced value.
final mayChangeDefault = defaultEdges.any((edge) {
if (expandedDefaults.contains(edge)) return false;
if (edge.parentColumn != 'id') return true;
final key = (edge.parentTableName, edge.defaultValue.toUuidValue()!);
if (affectedSeeds.contains(key)) return true;
final row = rows[key];
if (row == null) return false;
if (row.crdtRow.isHidden || (row.crdtRow.deleted?.isDeleted ?? false)) {
return true;
}
return (_foreignKeys.edgesByChildTable[key.$1] ?? <ForeignKeyEdge>[]).any(
(parentEdge) => references(key, parentEdge.childColumn).isNotEmpty,
);
});
if (!mayChangeDefault) return {};
final graph = _buildDefaultDependencyGraph(
rows: rows,
unwrittenValues: unwrittenValues,
pendingInserts: pendingInserts,
references: references,
);
// This is deliberately an overapproximation: a restrict or nullable edge
// may keep its child visible. The important fast path is a live default
// with no possible deletion/missing-parent cause, which cannot change its
// visibility merely because an authored child reference changes.
final hiddenCandidates = _reachableRows(
graph.possiblyHidden,
graph.childrenByParent,
);
final affected = _reachableRows(affectedSeeds, graph.neighbors);
return {
for (final edge in defaultEdges)
if (!expandedDefaults.contains(edge))
for (final key in affected)
if (key.$1 == edge.parentTableName &&
references(
key,
edge.parentColumn,
includeProjected: true,
).contains(edge.defaultValue.toUuidValue()) &&
(affectedSeeds.contains(key) || hiddenCandidates.contains(key)))
edge,
};
}
/// Builds authored dependency edges; projected values only identify parents.
/// Keep this distinct from the stored-value walk in [_expandRowClosure].
_DefaultDependencyGraph _buildDefaultDependencyGraph({
required Map<MergeRowKey, _ProjectedForeignKeyRow> rows,
required Map<MergeRowKey, Map<String, Object?>> unwrittenValues,
required List<PendingProjectionRow> pendingInserts,
required Set<UuidValue> Function(
MergeRowKey key,
String column, {
bool includeProjected,
})
references,
}) {
final keys = {...rows.keys, ...unwrittenValues.keys};
final keysByTable = <String, List<MergeRowKey>>{};
for (final key in keys) {
keysByTable.putIfAbsent(key.$1, () => []).add(key);
}
final neighbors = <MergeRowKey, Set<MergeRowKey>>{};
final childrenByParent = <MergeRowKey, Set<MergeRowKey>>{};
final possiblyHidden = <MergeRowKey>{
for (final row in rows.values)
if (row.crdtRow.isHidden || (row.crdtRow.deleted?.isDeleted ?? false)) row.key,
for (final pending in pendingInserts)
if (pending.hidden) (pending.tableName, pending.rowId),
};
for (final edge in _foreignKeys.edges) {
final children = keysByTable[edge.childTableName];
if (children == null) continue;
final parents = <UuidValue, Set<MergeRowKey>>{};
for (final key in keysByTable[edge.parentTableName] ?? <MergeRowKey>[]) {
for (final value in references(
key,
edge.parentColumn,
includeProjected: true,
)) {
parents.putIfAbsent(value, () => {}).add(key);
}
}
for (final child in children) {
for (final reference in references(child, edge.childColumn)) {
final targets = parents[reference];
if (targets == null) {
// Missing parents can hide a default too, without a tombstone.
possiblyHidden.add(child);
continue;
}
for (final parent in targets) {
neighbors.putIfAbsent(child, () => {}).add(parent);
neighbors.putIfAbsent(parent, () => {}).add(child);
childrenByParent.putIfAbsent(parent, () => {}).add(child);
if (edge.parentColumn != 'id' &&
(unwrittenValues[parent]?.containsKey(edge.parentColumn) ?? false)) {
// Changing a referenced unique value can remove its old target.
possiblyHidden.add(child);
}
}
}
}
}
return (
neighbors: neighbors,
childrenByParent: childrenByParent,
possiblyHidden: possiblyHidden,
);
}
static Set<MergeRowKey> _reachableRows(
Set<MergeRowKey> seeds,
Map<MergeRowKey, Set<MergeRowKey>> graph,
) {
final reached = {...seeds};
final queue = seeds.toList();
for (var index = 0; index < queue.length; index++) {
for (final key in graph[queue[index]] ?? <MergeRowKey>{}) {
if (reached.add(key)) queue.add(key);
}
}
return reached;
}
/// The values the pass will write but has not written yet, by row.
///
/// A pending insert has no row yet, and an overlay's new value is applied
@ -1167,35 +1420,6 @@ class CrdtForeignKeyProjector {
return unwrittenValues;
}
/// Enqueues the rows a set-default repair would fall back to.
///
/// A set-default edge names its target in the schema, so no row points at it
/// until the repair runs and no edge query would reach it.
Future<void> _enqueueSetDefaultTargets({
required Set<String> tablesToLoad,
required void Function(String tableName, Iterable<UuidValue> ids) enqueue,
required Transaction transaction,
}) async {
for (final edge in _foreignKeys.edges) {
if (!tablesToLoad.contains(edge.childTableName)) continue;
final defaultValue = edge.defaultValue.toUuidValue();
if (defaultValue == null) continue;
if (edge.parentColumn == 'id') {
enqueue(edge.parentTableName, [defaultValue]);
continue;
}
enqueue(
edge.parentTableName,
await _context.findDomainRowIdsWhereColumnIn(
tableName: edge.parentTableName,
columnName: edge.parentColumn,
values: {defaultValue},
transaction: transaction,
),
);
}
}
Future<void> _expandRowClosure({
required Map<String, Set<UuidValue>> loadedNow,
required Set<String> tablesToLoad,
@ -1317,6 +1541,15 @@ class CrdtForeignKeyProjector {
}
if (references.isEmpty) return;
await _enqueueParentRowsByValue(edge, references, enqueue, transaction);
}
Future<void> _enqueueParentRowsByValue(
ForeignKeyEdge edge,
Set<UuidValue> references,
void Function(String, Iterable<UuidValue>) enqueue,
Transaction transaction,
) async {
if (edge.parentColumn == 'id') {
enqueue(edge.parentTableName, references);
return;
@ -1488,6 +1721,10 @@ class CrdtForeignKeyProjector {
_ForeignKeyProjectionState state,
Set<MergeRowKey> userHidden,
) {
// Restart from authored deletions, never the previous projected winners.
// Each round withdraws all blocked roots together and never reintroduces
// them within this pass. This terminates even with circular dependencies;
// a later pass with the same facts makes exactly the same withdrawals.
final acceptedRoots = userHidden.toSet();
while (true) {
@ -1949,8 +2186,10 @@ class CrdtForeignKeyProjector {
final parkUpdates = <MergeRowKey, Map<String, Object?>>{};
for (final MapEntry(key: rowKey, value: updates) in changed.entries) {
final park = <String, Object?>{};
for (final column in _uniqueResolver.uniqueReleaseColumnsFor(rowKey.$1)) {
if (!updates.containsKey(column.columnName)) continue;
for (final column in _uniqueResolver.uniqueReleaseColumnsFor(
rowKey.$1,
updates.keys.toSet(),
)) {
park[column.columnName] = _context.conflictFreeValue(
column,
originalDomain[rowKey]?[column.columnName],
@ -1958,6 +2197,10 @@ class CrdtForeignKeyProjector {
rowKey.$2,
'park',
);
// A discriminator-only change still moves the unique tuple. Restore
// its temporarily parked release column even if its final value did
// not change.
updates[column.columnName] = finalDomain[rowKey]![column.columnName];
}
if (park.isNotEmpty) parkUpdates[rowKey] = park;
}
@ -2346,3 +2589,9 @@ class CrdtForeignKeyProjector {
}
}
}
typedef _DefaultDependencyGraph = ({
Map<MergeRowKey, Set<MergeRowKey>> neighbors,
Map<MergeRowKey, Set<MergeRowKey>> childrenByParent,
Set<MergeRowKey> possiblyHidden,
});

View file

@ -427,6 +427,49 @@ WHERE r."scopeId" = $scopeId
return _rowIds(result);
}
/// Sparse roots and children that may depend on a changed SET DEFAULT target.
///
/// A blocked original-parent deletion has no child override, so include
/// authored tombstones even when projection currently keeps the row visible.
/// Cascade ancestors find those blocked indirectly; hidden rows and existing
/// overrides also cover missing-parent repair and unique FK projection.
Future<Set<MergeRowKey>> findSetDefaultDependencyRows({
required Set<String> ancestorTableNames,
required String childTableName,
required String childColumn,
required Transaction transaction,
}) async {
final tablesById = {
for (final name in {...ancestorTableNames, childTableName})
schema[name]!.$1: name,
};
final (childTableId, columns) = schema[childTableName]!;
final columnId = columns[childColumn]!.id!;
final scopeId = hlcManagerFor(transaction).normalizedScopeId;
final result = await database.unsafeQuery(
'''SELECT r."tblId", r."uuidRowId"
FROM "crdt_data_rows" r
LEFT JOIN "crdt_data_tombstone" d ON d."rowId" = r."id"
WHERE r."scopeId" = $scopeId
AND r."tblId" IN (${tablesById.keys.join(', ')})
AND ($_rowHidden OR d."clFlag" % 2 = 0)
UNION
SELECT r."tblId", r."uuidRowId"
FROM "crdt_data_attempted_value" a
JOIN "crdt_data_fields" f ON f."id" = a."fieldId"
JOIN "crdt_data_rows" r ON r."id" = f."rowId"
WHERE r."scopeId" = $scopeId
AND r."tblId" = $childTableId
AND f."columnId" = $columnId
''',
transaction: transaction,
);
return {
for (final row in result)
(tablesById[row[0]]!, UuidValueJsonExtension.fromJson(row[1])),
};
}
/// Row ids in [tableName] whose columns hold one of the listed values.
///
/// A composite unique index is matched column by column, so the result is a
@ -490,6 +533,9 @@ LEFT JOIN "crdt_data_rows" r
ON r."scopeId" = $scopeId AND r."tblId" = $tableId AND r."uuidRowId" = d."id"''';
}
/// Whether tracked row alias `r` is hidden; shared by metadata/domain lookups.
static final _rowHidden = 'r."visibility" > $crdtRowLastVisibleVisibilityIndex';
/// Whether the row joined by [_visibilityJoin] is visible in this scope.
static final _rowVisible =
'r."id" IS NULL OR r."visibility" <= $crdtRowLastVisibleVisibilityIndex';
@ -753,7 +799,7 @@ WHERE c."id" IN ($whereRowIds)
AND (
p."id" IS NULL OR
p."scopeId" <> $scopeId OR
r."visibility" > $crdtRowLastVisibleVisibilityIndex
$_rowHidden
)
''';
});

View file

@ -45,13 +45,18 @@ class CrdtUniqueConflictResolver {
return uniqueColumnNamesFor(tableName).contains(columnName);
}
/// Releasable unique columns for [tableName], used by two-phase parking.
/// Releasable columns of indexes affected by [changedColumns], used by
/// two-phase parking even when only a fixed discriminator changes.
///
/// A column can be indexed more than once, and releasing it is one act, so
/// the columns are deduplicated by name.
List<UniqueColumnConflictRelease> uniqueReleaseColumnsFor(String tableName) {
List<UniqueColumnConflictRelease> uniqueReleaseColumnsFor(
String tableName,
Set<String> changedColumns,
) {
final byName = <String, UniqueColumnConflictRelease>{};
for (final uniqueIndex in uniqueIndexesFor(tableName)) {
if (!uniqueIndex.indexedColumns.any(changedColumns.contains)) continue;
for (final column in uniqueIndex.releaseColumns) {
byName[column.columnName] = column;
}

View file

@ -551,6 +551,25 @@ class CrdtMutationRecorder {
as T;
}
/// Supplies fixed UUID FK defaults before building a local upsert. SQLite's
/// upsert builder cannot emit DEFAULT inside VALUES. Resolve the same schema
/// default that an insert would use, leaving other fields to the ORM.
List<T> withForeignKeyInsertDefaults<T extends TableRow>(List<T> rows) => [
for (final row in rows)
_withPlannedDomainValues(row, {
for (final edge
in _foreignKeys.edgesByChildTable[row.table.tableName] ??
const <ForeignKeyEdge>[])
if ((row.toJsonForDatabase() as Map)[edge.childColumn] == null)
if (_context.defaultValueForColumn(
row.table.tableName,
edge.childColumn,
)
case final UuidValue value)
edge.childColumn: value,
}),
];
Future<Map<UuidValue, Map<String, Object?>>> _readPlannedDomainValues(
String tableName,
Set<UuidValue> rowIds,

View file

@ -0,0 +1,430 @@
{
"__className__": "serverpod.DatabaseMigration",
"actions": [
{
"__className__": "serverpod.DatabaseMigrationAction",
"type": "createTable",
"createTable": {
"__className__": "serverpod.TableDefinition",
"name": "unique_fk_pair",
"dartName": "UniqueFkPair",
"module": "serverpod_offline_sync_test",
"schema": "public",
"columns": [
{
"__className__": "serverpod.ColumnDefinition",
"name": "id",
"fieldName": "id",
"columnType": 7,
"isNullable": false,
"columnDefault": "random_v7",
"dartType": "UuidValue?"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "scopeId",
"fieldName": "scopeId",
"columnType": 6,
"isNullable": true,
"dartType": "int?"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "name",
"fieldName": "name",
"columnType": 0,
"isNullable": false,
"dartType": "String"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "leftId",
"fieldName": "leftId",
"columnType": 7,
"isNullable": true,
"dartType": "UuidValue?"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "rightId",
"fieldName": "rightId",
"columnType": 7,
"isNullable": true,
"dartType": "UuidValue?"
}
],
"foreignKeys": [
{
"__className__": "serverpod.ForeignKeyDefinition",
"constraintName": "unique_fk_pair_fk_0",
"columns": [
"scopeId"
],
"referenceTable": "crdt_scopes",
"referenceTableSchema": "public",
"referenceColumns": [
"id"
],
"onUpdate": 3,
"onDelete": 4
},
{
"__className__": "serverpod.ForeignKeyDefinition",
"constraintName": "unique_fk_pair_fk_1",
"columns": [
"leftId"
],
"referenceTable": "person",
"referenceTableSchema": "public",
"referenceColumns": [
"id"
],
"onUpdate": 3,
"onDelete": 3,
"deferrable": "initiallyDeferred"
},
{
"__className__": "serverpod.ForeignKeyDefinition",
"constraintName": "unique_fk_pair_fk_2",
"columns": [
"rightId"
],
"referenceTable": "person",
"referenceTableSchema": "public",
"referenceColumns": [
"id"
],
"onUpdate": 3,
"onDelete": 0,
"deferrable": "initiallyDeferred"
}
],
"indexes": [
{
"__className__": "serverpod.IndexDefinition",
"indexName": "unique_fk_pair_parents",
"elements": [
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "leftId"
},
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "rightId"
}
],
"type": "btree",
"isUnique": true,
"isPrimary": false
}
],
"managed": true
}
},
{
"__className__": "serverpod.DatabaseMigrationAction",
"type": "createTable",
"createTable": {
"__className__": "serverpod.TableDefinition",
"name": "unique_mixed_fk",
"dartName": "UniqueMixedFk",
"module": "serverpod_offline_sync_test",
"schema": "public",
"columns": [
{
"__className__": "serverpod.ColumnDefinition",
"name": "id",
"fieldName": "id",
"columnType": 7,
"isNullable": false,
"columnDefault": "random_v7",
"dartType": "UuidValue?"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "scopeId",
"fieldName": "scopeId",
"columnType": 6,
"isNullable": true,
"dartType": "int?"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "name",
"fieldName": "name",
"columnType": 0,
"isNullable": false,
"dartType": "String"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "parentId",
"fieldName": "parentId",
"columnType": 7,
"isNullable": true,
"dartType": "UuidValue?"
}
],
"foreignKeys": [
{
"__className__": "serverpod.ForeignKeyDefinition",
"constraintName": "unique_mixed_fk_fk_0",
"columns": [
"scopeId"
],
"referenceTable": "crdt_scopes",
"referenceTableSchema": "public",
"referenceColumns": [
"id"
],
"onUpdate": 3,
"onDelete": 4
},
{
"__className__": "serverpod.ForeignKeyDefinition",
"constraintName": "unique_mixed_fk_fk_1",
"columns": [
"parentId"
],
"referenceTable": "person",
"referenceTableSchema": "public",
"referenceColumns": [
"id"
],
"onUpdate": 3,
"onDelete": 4,
"deferrable": "initiallyDeferred"
}
],
"indexes": [
{
"__className__": "serverpod.IndexDefinition",
"indexName": "unique_mixed_fk_name_parent",
"elements": [
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "scopeId"
},
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "name"
},
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "parentId"
}
],
"type": "btree",
"isUnique": true,
"isPrimary": false
}
],
"managed": true
}
},
{
"__className__": "serverpod.DatabaseMigrationAction",
"type": "createTable",
"createTable": {
"__className__": "serverpod.TableDefinition",
"name": "unique_nullable",
"dartName": "UniqueNullable",
"module": "serverpod_offline_sync_test",
"schema": "public",
"columns": [
{
"__className__": "serverpod.ColumnDefinition",
"name": "id",
"fieldName": "id",
"columnType": 7,
"isNullable": false,
"columnDefault": "random_v7",
"dartType": "UuidValue?"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "scopeId",
"fieldName": "scopeId",
"columnType": 6,
"isNullable": true,
"dartType": "int?"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "value",
"fieldName": "value",
"columnType": 6,
"isNullable": true,
"dartType": "int?"
}
],
"foreignKeys": [
{
"__className__": "serverpod.ForeignKeyDefinition",
"constraintName": "unique_nullable_fk_0",
"columns": [
"scopeId"
],
"referenceTable": "crdt_scopes",
"referenceTableSchema": "public",
"referenceColumns": [
"id"
],
"onUpdate": 3,
"onDelete": 4
}
],
"indexes": [
{
"__className__": "serverpod.IndexDefinition",
"indexName": "unique_nullable__scopeId__value__unique_idx",
"elements": [
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "scopeId"
},
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "value"
}
],
"type": "btree",
"isUnique": true,
"isPrimary": false
}
],
"managed": true
}
},
{
"__className__": "serverpod.DatabaseMigrationAction",
"type": "createTable",
"createTable": {
"__className__": "serverpod.TableDefinition",
"name": "unique_overlapping",
"dartName": "UniqueOverlapping",
"module": "serverpod_offline_sync_test",
"schema": "public",
"columns": [
{
"__className__": "serverpod.ColumnDefinition",
"name": "id",
"fieldName": "id",
"columnType": 7,
"isNullable": false,
"columnDefault": "random_v7",
"dartType": "UuidValue?"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "scopeId",
"fieldName": "scopeId",
"columnType": 6,
"isNullable": true,
"dartType": "int?"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "first",
"fieldName": "first",
"columnType": 0,
"isNullable": false,
"dartType": "String"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "second",
"fieldName": "second",
"columnType": 0,
"isNullable": false,
"dartType": "String"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "third",
"fieldName": "third",
"columnType": 0,
"isNullable": false,
"dartType": "String"
}
],
"foreignKeys": [
{
"__className__": "serverpod.ForeignKeyDefinition",
"constraintName": "unique_overlapping_fk_0",
"columns": [
"scopeId"
],
"referenceTable": "crdt_scopes",
"referenceTableSchema": "public",
"referenceColumns": [
"id"
],
"onUpdate": 3,
"onDelete": 4
}
],
"indexes": [
{
"__className__": "serverpod.IndexDefinition",
"indexName": "unique_overlapping_first_second",
"elements": [
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "scopeId"
},
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "first"
},
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "second"
}
],
"type": "btree",
"isUnique": true,
"isPrimary": false
},
{
"__className__": "serverpod.IndexDefinition",
"indexName": "unique_overlapping_second_third",
"elements": [
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "scopeId"
},
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "second"
},
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "third"
}
],
"type": "btree",
"isUnique": true,
"isPrimary": false
}
],
"managed": true
}
}
],
"warnings": [],
"migrationApiVersion": 1
}

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