test: Make the DST more robust (#116)
This commit is contained in:
parent
2a6d66c79e
commit
574f8d067a
81 changed files with 63267 additions and 720 deletions
|
|
@ -168,6 +168,12 @@ Future<void> main(List<String> args) async {
|
||||||
changeCount: rowCount,
|
changeCount: rowCount,
|
||||||
),
|
),
|
||||||
UniqueMergeBenchmark('merge (unique conflict)', 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)
|
for (final operation in FkChainOperation.values)
|
||||||
FkChainMergeBenchmark(
|
FkChainMergeBenchmark(
|
||||||
'merge (fk chain ${operation.name})',
|
'merge (fk chain ${operation.name})',
|
||||||
|
|
@ -189,6 +195,8 @@ Future<void> main(List<String> args) async {
|
||||||
batchDescription: benchmark.batchDescription,
|
batchDescription: benchmark.batchDescription,
|
||||||
average: mergeResult.averageMicroseconds,
|
average: mergeResult.averageMicroseconds,
|
||||||
averageQueries: mergeResult.averageQueries,
|
averageQueries: mergeResult.averageQueries,
|
||||||
|
averageRowsRead: mergeResult.averageRowsRead,
|
||||||
|
averageRowsReadByType: mergeResult.averageRowsReadByType,
|
||||||
changeCount: benchmark.changesPerBatch,
|
changeCount: benchmark.changesPerBatch,
|
||||||
),
|
),
|
||||||
runningInCI: runningInCI,
|
runningInCI: runningInCI,
|
||||||
|
|
|
||||||
|
|
@ -485,6 +485,7 @@ Future<double> measurePreparedCycles(
|
||||||
int minimumMillis, {
|
int minimumMillis, {
|
||||||
required Future<void> Function() prepare,
|
required Future<void> Function() prepare,
|
||||||
required Future<void> Function() run,
|
required Future<void> Function() run,
|
||||||
|
Future<void> Function()? validate,
|
||||||
}) async {
|
}) async {
|
||||||
final minimumMicros = minimumMillis * 1000;
|
final minimumMicros = minimumMillis * 1000;
|
||||||
final watch = Stopwatch()..start();
|
final watch = Stopwatch()..start();
|
||||||
|
|
@ -495,8 +496,10 @@ Future<double> measurePreparedCycles(
|
||||||
await prepare();
|
await prepare();
|
||||||
final sw = Stopwatch()..start();
|
final sw = Stopwatch()..start();
|
||||||
await run();
|
await run();
|
||||||
|
sw.stop();
|
||||||
totalTimedMicros += sw.elapsedMicroseconds;
|
totalTimedMicros += sw.elapsedMicroseconds;
|
||||||
timedIterations++;
|
timedIterations++;
|
||||||
|
await validate?.call();
|
||||||
}
|
}
|
||||||
|
|
||||||
return totalTimedMicros / timedIterations;
|
return totalTimedMicros / timedIterations;
|
||||||
|
|
|
||||||
|
|
@ -19,10 +19,14 @@ enum MergeOperation { insert, update, delete, mixed }
|
||||||
|
|
||||||
enum FkChainOperation { insert, delete }
|
enum FkChainOperation { insert, delete }
|
||||||
|
|
||||||
|
enum SetDefaultOperation { originalDeleteOrRestore, defaultDeleteOrRestore }
|
||||||
|
|
||||||
/// Measurement of a merge scenario, averaged over the timed merge batches.
|
/// Measurement of a merge scenario, averaged over the timed merge batches.
|
||||||
typedef MergeMeasurement = ({
|
typedef MergeMeasurement = ({
|
||||||
double averageMicroseconds,
|
double averageMicroseconds,
|
||||||
double averageQueries,
|
double averageQueries,
|
||||||
|
double averageRowsRead,
|
||||||
|
Map<String, double> averageRowsReadByType,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// The number of changes of each kind in a mixed batch of [changeCount].
|
/// The number of changes of each kind in a mixed batch of [changeCount].
|
||||||
|
|
@ -69,6 +73,8 @@ abstract class MergeScenarioBenchmark extends AsyncBenchmarkBase {
|
||||||
CrdtMergeSet _mergeSet = [];
|
CrdtMergeSet _mergeSet = [];
|
||||||
|
|
||||||
int _timedQueries = 0;
|
int _timedQueries = 0;
|
||||||
|
int _timedRowsRead = 0;
|
||||||
|
final _timedRowsReadByType = <String, int>{};
|
||||||
int _timedRuns = 0;
|
int _timedRuns = 0;
|
||||||
|
|
||||||
/// Scenario title used in the results header, e.g. `INSERT`.
|
/// 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.
|
/// Seeds state once after the session is created, before the first cycle.
|
||||||
Future<void> onSetup() async {}
|
Future<void> onSetup() async {}
|
||||||
|
|
||||||
|
/// Checks that each measured change took effect, outside the timed merge.
|
||||||
|
Future<void> validateCycle() async {}
|
||||||
|
|
||||||
Hlc _nextRemoteHlc() => _remoteHlc = _remoteHlc.increment();
|
Hlc _nextRemoteHlc() => _remoteHlc = _remoteHlc.increment();
|
||||||
|
|
||||||
int _nextSeq() => _valueSeq++;
|
int _nextSeq() => _valueSeq++;
|
||||||
|
|
@ -149,6 +158,8 @@ abstract class MergeScenarioBenchmark extends AsyncBenchmarkBase {
|
||||||
_valueSeq = 0;
|
_valueSeq = 0;
|
||||||
_mergeSet = [];
|
_mergeSet = [];
|
||||||
_timedQueries = 0;
|
_timedQueries = 0;
|
||||||
|
_timedRowsRead = 0;
|
||||||
|
_timedRowsReadByType.clear();
|
||||||
_timedRuns = 0;
|
_timedRuns = 0;
|
||||||
final dbPath = p.join(
|
final dbPath = p.join(
|
||||||
Directory.systemTemp.path,
|
Directory.systemTemp.path,
|
||||||
|
|
@ -181,17 +192,26 @@ abstract class MergeScenarioBenchmark extends AsyncBenchmarkBase {
|
||||||
_warmupMillis,
|
_warmupMillis,
|
||||||
prepare: prepareCycle,
|
prepare: prepareCycle,
|
||||||
run: run,
|
run: run,
|
||||||
|
validate: validateCycle,
|
||||||
);
|
);
|
||||||
_timedQueries = 0;
|
_timedQueries = 0;
|
||||||
|
_timedRowsRead = 0;
|
||||||
|
_timedRowsReadByType.clear();
|
||||||
_timedRuns = 0;
|
_timedRuns = 0;
|
||||||
final averageMicroseconds = await measurePreparedCycles(
|
final averageMicroseconds = await measurePreparedCycles(
|
||||||
_measurementMillis,
|
_measurementMillis,
|
||||||
prepare: prepareCycle,
|
prepare: prepareCycle,
|
||||||
run: run,
|
run: run,
|
||||||
|
validate: validateCycle,
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
averageMicroseconds: averageMicroseconds,
|
averageMicroseconds: averageMicroseconds,
|
||||||
averageQueries: _timedQueries / _timedRuns,
|
averageQueries: _timedQueries / _timedRuns,
|
||||||
|
averageRowsRead: _timedRowsRead / _timedRuns,
|
||||||
|
averageRowsReadByType: {
|
||||||
|
for (final entry in _timedRowsReadByType.entries)
|
||||||
|
entry.key: entry.value / _timedRuns,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
await teardown();
|
await teardown();
|
||||||
|
|
@ -201,8 +221,20 @@ abstract class MergeScenarioBenchmark extends AsyncBenchmarkBase {
|
||||||
@override
|
@override
|
||||||
Future<void> run() async {
|
Future<void> run() async {
|
||||||
final queriesBefore = _countingDb.queryCount;
|
final queriesBefore = _countingDb.queryCount;
|
||||||
|
final rowsBefore = _countingDb.rowsRead;
|
||||||
|
final rowsByTypeBefore = Map.of(_countingDb.rowsReadByType);
|
||||||
await _crdtSession.db.mergeChanges(_mergeSet, scopeId: _userId);
|
await _crdtSession.db.mergeChanges(_mergeSet, scopeId: _userId);
|
||||||
_timedQueries += _countingDb.queryCount - queriesBefore;
|
_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++;
|
_timedRuns++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -511,3 +543,110 @@ typedef _FkChainFamily = ({
|
||||||
FkChainRestrictBlocker restrictBlocker,
|
FkChainRestrictBlocker restrictBlocker,
|
||||||
List<TableRow<UuidValue?>> rows,
|
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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,18 @@ class QueryCountingDatabase implements Database {
|
||||||
/// Number of database operations delegated so far.
|
/// Number of database operations delegated so far.
|
||||||
int queryCount = 0;
|
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
|
@override
|
||||||
DatabaseAnalyzer get analyzer => _delegate.analyzer;
|
DatabaseAnalyzer get analyzer => _delegate.analyzer;
|
||||||
|
|
||||||
|
|
@ -40,7 +52,7 @@ class QueryCountingDatabase implements Database {
|
||||||
LockBehavior? lockBehavior,
|
LockBehavior? lockBehavior,
|
||||||
}) async {
|
}) async {
|
||||||
queryCount++;
|
queryCount++;
|
||||||
return _delegate.find<T>(
|
final result = await _delegate.find<T>(
|
||||||
where: where,
|
where: where,
|
||||||
limit: limit,
|
limit: limit,
|
||||||
offset: offset,
|
offset: offset,
|
||||||
|
|
@ -51,6 +63,8 @@ class QueryCountingDatabase implements Database {
|
||||||
lockMode: lockMode,
|
lockMode: lockMode,
|
||||||
lockBehavior: lockBehavior,
|
lockBehavior: lockBehavior,
|
||||||
);
|
);
|
||||||
|
_recordRows(T.toString(), result.length);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -62,13 +76,15 @@ class QueryCountingDatabase implements Database {
|
||||||
LockBehavior? lockBehavior,
|
LockBehavior? lockBehavior,
|
||||||
}) async {
|
}) async {
|
||||||
queryCount++;
|
queryCount++;
|
||||||
return _delegate.findById<T>(
|
final result = await _delegate.findById<T>(
|
||||||
id,
|
id,
|
||||||
transaction: transaction,
|
transaction: transaction,
|
||||||
include: include,
|
include: include,
|
||||||
lockMode: lockMode,
|
lockMode: lockMode,
|
||||||
lockBehavior: lockBehavior,
|
lockBehavior: lockBehavior,
|
||||||
);
|
);
|
||||||
|
_recordRows(T.toString(), result == null ? 0 : 1);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -84,7 +100,7 @@ class QueryCountingDatabase implements Database {
|
||||||
LockBehavior? lockBehavior,
|
LockBehavior? lockBehavior,
|
||||||
}) async {
|
}) async {
|
||||||
queryCount++;
|
queryCount++;
|
||||||
return _delegate.findFirstRow<T>(
|
final result = await _delegate.findFirstRow<T>(
|
||||||
where: where,
|
where: where,
|
||||||
offset: offset,
|
offset: offset,
|
||||||
orderBy: orderBy,
|
orderBy: orderBy,
|
||||||
|
|
@ -94,6 +110,8 @@ class QueryCountingDatabase implements Database {
|
||||||
lockMode: lockMode,
|
lockMode: lockMode,
|
||||||
lockBehavior: lockBehavior,
|
lockBehavior: lockBehavior,
|
||||||
);
|
);
|
||||||
|
_recordRows(T.toString(), result == null ? 0 : 1);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -311,12 +329,14 @@ class QueryCountingDatabase implements Database {
|
||||||
QueryParameters? parameters,
|
QueryParameters? parameters,
|
||||||
}) async {
|
}) async {
|
||||||
queryCount++;
|
queryCount++;
|
||||||
return _delegate.unsafeQuery(
|
final result = await _delegate.unsafeQuery(
|
||||||
query,
|
query,
|
||||||
timeoutInSeconds: timeoutInSeconds,
|
timeoutInSeconds: timeoutInSeconds,
|
||||||
transaction: transaction,
|
transaction: transaction,
|
||||||
parameters: parameters,
|
parameters: parameters,
|
||||||
);
|
);
|
||||||
|
_recordRows('sql', result.length);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -342,11 +362,13 @@ class QueryCountingDatabase implements Database {
|
||||||
Transaction? transaction,
|
Transaction? transaction,
|
||||||
}) async {
|
}) async {
|
||||||
queryCount++;
|
queryCount++;
|
||||||
return _delegate.unsafeSimpleQuery(
|
final result = await _delegate.unsafeSimpleQuery(
|
||||||
query,
|
query,
|
||||||
timeoutInSeconds: timeoutInSeconds,
|
timeoutInSeconds: timeoutInSeconds,
|
||||||
transaction: transaction,
|
transaction: transaction,
|
||||||
);
|
);
|
||||||
|
_recordRows('sql', result.length);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
|
||||||
|
|
@ -129,6 +129,13 @@ void printMergeImpact(
|
||||||
' Queries: ${formatter0.format(results.averageQueries)} per batch '
|
' Queries: ${formatter0.format(results.averageQueries)} per batch '
|
||||||
'(${formatter2.format(queriesPerChange)} per change)',
|
'(${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('```');
|
if (runningInCI) print('```');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -200,6 +207,8 @@ class MergeBenchmarkResults {
|
||||||
required this.batchDescription,
|
required this.batchDescription,
|
||||||
required this.average,
|
required this.average,
|
||||||
required this.averageQueries,
|
required this.averageQueries,
|
||||||
|
required this.averageRowsRead,
|
||||||
|
required this.averageRowsReadByType,
|
||||||
required this.changeCount,
|
required this.changeCount,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -211,6 +220,11 @@ class MergeBenchmarkResults {
|
||||||
|
|
||||||
/// Average number of queries issued while merging one batch.
|
/// Average number of queries issued while merging one batch.
|
||||||
final double averageQueries;
|
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;
|
final int changeCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -53,13 +53,27 @@ soft-deleted instead of removed:
|
||||||
FK is nullable, and those child FK fields receive ordinary CRDT field updates.
|
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
|
- `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
|
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
|
- `ON DELETE CASCADE`: visible cascade descendants receive synced
|
||||||
user-delete tombstones. Local cascade descendants are not hidden only as
|
user-delete tombstones. Local cascade descendants are not hidden only as
|
||||||
`foreignKeyCascade` projection rows.
|
`foreignKeyCascade` projection rows.
|
||||||
|
|
||||||
These local side effects are the facts that other replicas merge.
|
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
|
## Merge-Time Action Semantics
|
||||||
|
|
||||||
For each child FK whose attempted value points to a hidden or missing parent:
|
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
|
`B`, and `C` remain visible. The result must not depend on whether cascade or
|
||||||
restrict edges are evaluated first internally.
|
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
|
## Combined Projection Boundary
|
||||||
|
|
||||||
The attempted-value schema, hidden-row lifecycle, exact FK-before-unique
|
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
|
cases are centralized under the
|
||||||
[projection model verification gates](projection-model.md#verification-gates).
|
[projection model verification gates](projection-model.md#verification-gates).
|
||||||
|
|
||||||
## What These Tests Prove
|
## What These Tests Check
|
||||||
|
|
||||||
- Integrity: no surviving visible FK violation.
|
- Integrity: no surviving visible FK violation.
|
||||||
- Convergence: same merged facts produce the same projection on every replica.
|
- Convergence: same merged facts produce the same projection on every replica.
|
||||||
|
|
|
||||||
|
|
@ -343,8 +343,27 @@ class CrdtDatabase implements Database {
|
||||||
|
|
||||||
final result = plannedInsert.rows.isEmpty
|
final result = plannedInsert.rows.isEmpty
|
||||||
? <T>[]
|
? <T>[]
|
||||||
: await _delegate.insert<T>(
|
: await insertWithExplicitNulls<T>(
|
||||||
|
_delegate,
|
||||||
plannedInsert.rows,
|
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,
|
transaction: tx,
|
||||||
ignoreConflicts: ignoreConflicts,
|
ignoreConflicts: ignoreConflicts,
|
||||||
noReturn: skipReturn,
|
noReturn: skipReturn,
|
||||||
|
|
@ -421,7 +440,7 @@ class CrdtDatabase implements Database {
|
||||||
// CRDT metadata needs the affected rows even when the public call uses
|
// CRDT metadata needs the affected rows even when the public call uses
|
||||||
// noReturn, because inserted rows may have database-generated ids.
|
// noReturn, because inserted rows may have database-generated ids.
|
||||||
final result = await _delegate.upsert<T>(
|
final result = await _delegate.upsert<T>(
|
||||||
prepared.rows,
|
_recorder.withForeignKeyInsertDefaults(prepared.rows),
|
||||||
conflictColumns: conflictColumns,
|
conflictColumns: conflictColumns,
|
||||||
updateColumns: updateColumns,
|
updateColumns: updateColumns,
|
||||||
updateWhere: await _whereVisibleWithTombstone<T>(
|
updateWhere: await _whereVisibleWithTombstone<T>(
|
||||||
|
|
|
||||||
|
|
@ -877,7 +877,14 @@ extension CrdtMergeRecorderExtension on CrdtMutationRecorder {
|
||||||
await DatabaseUtil.runInTransactionOrSavepoint(
|
await DatabaseUtil.runInTransactionOrSavepoint(
|
||||||
_db,
|
_db,
|
||||||
transaction,
|
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 {
|
} on DatabaseQueryException {
|
||||||
final owner = await _readDomainRowOwner(
|
final owner = await _readDomainRowOwner(
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,83 @@ extension CrdtTableRowExtension<T extends TableRow> on T {
|
||||||
T copyWithScopeId(int scopeId) => (this as dynamic).copyWith(scopeId: scopeId) as 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.
|
/// Helpers over lists of domain [TableRow]s used by the CRDT recorder.
|
||||||
@internal
|
@internal
|
||||||
extension TableRowListExtension on List<TableRow> {
|
extension TableRowListExtension on List<TableRow> {
|
||||||
|
|
|
||||||
|
|
@ -194,21 +194,36 @@ class CrdtForeignKeyProjector {
|
||||||
transaction,
|
transaction,
|
||||||
);
|
);
|
||||||
case ForeignKeyAction.setDefault:
|
case ForeignKeyAction.setDefault:
|
||||||
final defaultValue = _context.defaultValueForColumn(
|
final edge = _foreignKeys.edgesByChildTable[reference.childTableName]!
|
||||||
reference.childTableName,
|
.firstWhere((edge) => edge.childColumn == reference.childColumn);
|
||||||
reference.childColumn,
|
final repair = await _defaultProjectionValueFromDatabase(
|
||||||
|
edge,
|
||||||
|
transaction,
|
||||||
);
|
);
|
||||||
if (defaultValue == null) {
|
// Soft deletion leaves the physical target in place, so the SQL
|
||||||
throw StateError(
|
// FK cannot reject a hidden or cross-scope default for us. A target
|
||||||
'No default value found for '
|
// in this delete batch is still visible now but cannot be a repair.
|
||||||
'${reference.childTableName}.${reference.childColumn}.',
|
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(
|
await _updateChildColumnTo(
|
||||||
reference.childTableName,
|
reference.childTableName,
|
||||||
childIds,
|
childIds,
|
||||||
reference.childColumn,
|
reference.childColumn,
|
||||||
defaultValue,
|
repair.value,
|
||||||
transaction,
|
transaction,
|
||||||
);
|
);
|
||||||
case ForeignKeyAction.cascade:
|
case ForeignKeyAction.cascade:
|
||||||
|
|
@ -1056,6 +1071,10 @@ class CrdtForeignKeyProjector {
|
||||||
|
|
||||||
/// Loads the rows the seeds can reach instead of every row of their tables.
|
/// 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
|
/// 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
|
/// 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
|
/// seeds plus their foreign key component: children of a loaded row, because
|
||||||
|
|
@ -1102,12 +1121,18 @@ class CrdtForeignKeyProjector {
|
||||||
enqueue(rowKey.$1, [rowKey.$2]);
|
enqueue(rowKey.$1, [rowKey.$2]);
|
||||||
(frontier[rowKey.$1] ??= <UuidValue>{}).add(rowKey.$2);
|
(frontier[rowKey.$1] ??= <UuidValue>{}).add(rowKey.$2);
|
||||||
}
|
}
|
||||||
await _enqueueSetDefaultTargets(
|
final defaultEdges = [
|
||||||
tablesToLoad: tablesToLoad,
|
for (final edge in _foreignKeys.edges)
|
||||||
enqueue: enqueue,
|
if (edge.action == ForeignKeyAction.setDefault &&
|
||||||
transaction: transaction,
|
edge.defaultValue.toUuidValue() != null &&
|
||||||
);
|
tablesToLoad.contains(edge.childTableName))
|
||||||
|
edge,
|
||||||
|
];
|
||||||
|
await _enqueueSetDefaultTargets(defaultEdges, enqueue, transaction);
|
||||||
|
|
||||||
|
final affectedSeeds = {...seedRows, ...unwrittenValues.keys};
|
||||||
|
final expandedDefaults = <ForeignKeyEdge>{};
|
||||||
|
while (true) {
|
||||||
while (queued.isNotEmpty || frontier.isNotEmpty) {
|
while (queued.isNotEmpty || frontier.isNotEmpty) {
|
||||||
final wave = queued;
|
final wave = queued;
|
||||||
queued = <String, Set<UuidValue>>{};
|
queued = <String, Set<UuidValue>>{};
|
||||||
|
|
@ -1143,6 +1168,234 @@ class CrdtForeignKeyProjector {
|
||||||
transaction: transaction,
|
transaction: transaction,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (expandedDefaults.length == defaultEdges.length) break;
|
||||||
|
final defaultsToExpand = _affectedDefaults(
|
||||||
|
defaultEdges: defaultEdges,
|
||||||
|
expandedDefaults: expandedDefaults,
|
||||||
|
affectedSeeds: affectedSeeds,
|
||||||
|
rows: rows,
|
||||||
|
unwrittenValues: unwrittenValues,
|
||||||
|
attemptedValues: attemptedValues,
|
||||||
|
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.
|
/// The values the pass will write but has not written yet, by row.
|
||||||
|
|
@ -1167,35 +1420,6 @@ class CrdtForeignKeyProjector {
|
||||||
return unwrittenValues;
|
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({
|
Future<void> _expandRowClosure({
|
||||||
required Map<String, Set<UuidValue>> loadedNow,
|
required Map<String, Set<UuidValue>> loadedNow,
|
||||||
required Set<String> tablesToLoad,
|
required Set<String> tablesToLoad,
|
||||||
|
|
@ -1317,6 +1541,15 @@ class CrdtForeignKeyProjector {
|
||||||
}
|
}
|
||||||
if (references.isEmpty) return;
|
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') {
|
if (edge.parentColumn == 'id') {
|
||||||
enqueue(edge.parentTableName, references);
|
enqueue(edge.parentTableName, references);
|
||||||
return;
|
return;
|
||||||
|
|
@ -1488,6 +1721,10 @@ class CrdtForeignKeyProjector {
|
||||||
_ForeignKeyProjectionState state,
|
_ForeignKeyProjectionState state,
|
||||||
Set<MergeRowKey> userHidden,
|
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();
|
final acceptedRoots = userHidden.toSet();
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
|
|
@ -1949,8 +2186,10 @@ class CrdtForeignKeyProjector {
|
||||||
final parkUpdates = <MergeRowKey, Map<String, Object?>>{};
|
final parkUpdates = <MergeRowKey, Map<String, Object?>>{};
|
||||||
for (final MapEntry(key: rowKey, value: updates) in changed.entries) {
|
for (final MapEntry(key: rowKey, value: updates) in changed.entries) {
|
||||||
final park = <String, Object?>{};
|
final park = <String, Object?>{};
|
||||||
for (final column in _uniqueResolver.uniqueReleaseColumnsFor(rowKey.$1)) {
|
for (final column in _uniqueResolver.uniqueReleaseColumnsFor(
|
||||||
if (!updates.containsKey(column.columnName)) continue;
|
rowKey.$1,
|
||||||
|
updates.keys.toSet(),
|
||||||
|
)) {
|
||||||
park[column.columnName] = _context.conflictFreeValue(
|
park[column.columnName] = _context.conflictFreeValue(
|
||||||
column,
|
column,
|
||||||
originalDomain[rowKey]?[column.columnName],
|
originalDomain[rowKey]?[column.columnName],
|
||||||
|
|
@ -1958,6 +2197,10 @@ class CrdtForeignKeyProjector {
|
||||||
rowKey.$2,
|
rowKey.$2,
|
||||||
'park',
|
'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;
|
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,
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -427,6 +427,49 @@ WHERE r."scopeId" = $scopeId
|
||||||
return _rowIds(result);
|
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.
|
/// 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
|
/// 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"''';
|
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.
|
/// Whether the row joined by [_visibilityJoin] is visible in this scope.
|
||||||
static final _rowVisible =
|
static final _rowVisible =
|
||||||
'r."id" IS NULL OR r."visibility" <= $crdtRowLastVisibleVisibilityIndex';
|
'r."id" IS NULL OR r."visibility" <= $crdtRowLastVisibleVisibilityIndex';
|
||||||
|
|
@ -753,7 +799,7 @@ WHERE c."id" IN ($whereRowIds)
|
||||||
AND (
|
AND (
|
||||||
p."id" IS NULL OR
|
p."id" IS NULL OR
|
||||||
p."scopeId" <> $scopeId OR
|
p."scopeId" <> $scopeId OR
|
||||||
r."visibility" > $crdtRowLastVisibleVisibilityIndex
|
$_rowHidden
|
||||||
)
|
)
|
||||||
''';
|
''';
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -45,13 +45,18 @@ class CrdtUniqueConflictResolver {
|
||||||
return uniqueColumnNamesFor(tableName).contains(columnName);
|
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
|
/// A column can be indexed more than once, and releasing it is one act, so
|
||||||
/// the columns are deduplicated by name.
|
/// the columns are deduplicated by name.
|
||||||
List<UniqueColumnConflictRelease> uniqueReleaseColumnsFor(String tableName) {
|
List<UniqueColumnConflictRelease> uniqueReleaseColumnsFor(
|
||||||
|
String tableName,
|
||||||
|
Set<String> changedColumns,
|
||||||
|
) {
|
||||||
final byName = <String, UniqueColumnConflictRelease>{};
|
final byName = <String, UniqueColumnConflictRelease>{};
|
||||||
for (final uniqueIndex in uniqueIndexesFor(tableName)) {
|
for (final uniqueIndex in uniqueIndexesFor(tableName)) {
|
||||||
|
if (!uniqueIndex.indexedColumns.any(changedColumns.contains)) continue;
|
||||||
for (final column in uniqueIndex.releaseColumns) {
|
for (final column in uniqueIndex.releaseColumns) {
|
||||||
byName[column.columnName] = column;
|
byName[column.columnName] = column;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -551,6 +551,25 @@ class CrdtMutationRecorder {
|
||||||
as T;
|
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(
|
Future<Map<UuidValue, Map<String, Object?>>> _readPlannedDomainValues(
|
||||||
String tableName,
|
String tableName,
|
||||||
Set<UuidValue> rowIds,
|
Set<UuidValue> rowIds,
|
||||||
|
|
|
||||||
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,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
|
||||||
|
}
|
||||||
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
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue