Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
b5450353b9 |
|||
|
0345f56787 |
7 changed files with 292 additions and 6 deletions
|
|
@ -120,6 +120,7 @@ class OfflineSyncDatabase implements Database {
|
|||
UuidValue? userId,
|
||||
bool once = false,
|
||||
OfflineSyncOnMergeSuccess? onMergeSuccess,
|
||||
OfflineSyncOnInboundMerge? onInboundMerge,
|
||||
}) async* {
|
||||
await _ensureInitialized();
|
||||
final effectiveUserId = await _requireUserId(userId);
|
||||
|
|
@ -129,6 +130,7 @@ class OfflineSyncDatabase implements Database {
|
|||
inbound: inbound,
|
||||
once: once,
|
||||
onMergeSuccess: onMergeSuccess,
|
||||
onInboundMerge: onInboundMerge,
|
||||
mode: mode,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,15 @@ String domainColumnPredicate(
|
|||
String alias = 'd',
|
||||
}) => '$alias."${columnName.escapeIdentifier()}" = ${value.sqlLiteral()}';
|
||||
|
||||
/// SQL predicate matching rows where [columnName] holds one of [values],
|
||||
/// which must not be empty.
|
||||
@internal
|
||||
String domainColumnInPredicate(
|
||||
String columnName,
|
||||
Iterable<Object?> values, {
|
||||
String alias = 'd',
|
||||
}) => '$alias."${columnName.escapeIdentifier()}" IN (${values.sqlLiteralList()})';
|
||||
|
||||
/// SQL predicate matching rows where [columnName] differs from [value].
|
||||
@internal
|
||||
String domainColumnNotPredicate(
|
||||
|
|
|
|||
|
|
@ -159,6 +159,24 @@ class CrdtForeignKeyProjector {
|
|||
_foreignKeys.referencingKeysByParentTable[parentTableName] ??
|
||||
const <ReferencingForeignKey>[];
|
||||
for (final reference in foreignKeys) {
|
||||
// A cascade by id needs no per-parent answer: ask for every visible
|
||||
// child of the whole batch at once instead of one query per parent.
|
||||
if (reference.action == ForeignKeyAction.cascade &&
|
||||
reference.parentColumn == 'id') {
|
||||
final childIds = await _context.findVisibleDomainRowIdsWhere(
|
||||
tableName: reference.childTableName,
|
||||
predicates: [
|
||||
domainColumnInPredicate(reference.childColumn, parentIds),
|
||||
],
|
||||
transaction: transaction,
|
||||
);
|
||||
if (childIds.isNotEmpty) {
|
||||
cascadeDeletes
|
||||
.putIfAbsent(reference.childTableName, () => {})
|
||||
.addAll(childIds);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
final parentValuesById = reference.parentColumn == 'id'
|
||||
? null
|
||||
: await _context.readDomainColumnValues(
|
||||
|
|
@ -738,6 +756,54 @@ class CrdtForeignKeyProjector {
|
|||
);
|
||||
}
|
||||
|
||||
/// Proves that a local delete of [tableName] rows leaves nothing for the
|
||||
/// projection pass to decide: the delete's own cascade
|
||||
/// (`applyDeleteActions`, recursively) already hid everything it hides.
|
||||
///
|
||||
/// Holds when, in every table the delete can cascade into, every incoming
|
||||
/// edge cascades by id (no SET NULL / SET DEFAULT repairs), no outgoing edge
|
||||
/// restricts (the deleted row could be the blocker of a withheld parent
|
||||
/// delete), no table has a unique index (a released claim could restore a
|
||||
/// contestant) or a SET DEFAULT dependency, and no attempted value is
|
||||
/// pending in the tables the pass would load. Otherwise the full pass runs.
|
||||
Future<bool> canSkipProjectionAfterLocalDelete(
|
||||
String tableName,
|
||||
Transaction transaction,
|
||||
) async {
|
||||
final cascadeTables = <String>{};
|
||||
final queue = [tableName];
|
||||
while (queue.isNotEmpty) {
|
||||
final table = queue.removeLast();
|
||||
if (!cascadeTables.add(table)) continue;
|
||||
for (final edge
|
||||
in _foreignKeys.edgesByParentTable[table] ?? const <ForeignKeyEdge>[]) {
|
||||
if (edge.action != ForeignKeyAction.cascade || edge.parentColumn != 'id') {
|
||||
return false;
|
||||
}
|
||||
queue.add(edge.childTableName);
|
||||
}
|
||||
}
|
||||
for (final table in cascadeTables) {
|
||||
if (_uniqueResolver.tableHasUniqueIndexes(table)) return false;
|
||||
if (_foreignKeys.tablesWithDefaultDependencies.contains(table)) return false;
|
||||
for (final edge
|
||||
in _foreignKeys.edgesByChildTable[table] ?? const <ForeignKeyEdge>[]) {
|
||||
if (edge.action == ForeignKeyAction.restrict ||
|
||||
edge.action == ForeignKeyAction.noAction) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
final reachable = _foreignKeys
|
||||
.connectedTables({tableName})
|
||||
.where(_context.syncTableByName.containsKey)
|
||||
.toSet();
|
||||
return !await _ProjectionReader(
|
||||
_context,
|
||||
transaction,
|
||||
).hasAttemptedClaims(reachable);
|
||||
}
|
||||
|
||||
/// Whether a mutation of [tableName] may require FK or unique projection.
|
||||
bool needsProjection(String tableName, Set<String>? columnNames) {
|
||||
if (_foreignKeys.columnsMayAffectForeignKeys(tableName, columnNames)) {
|
||||
|
|
|
|||
|
|
@ -1232,12 +1232,19 @@ class CrdtMutationRecorder {
|
|||
null,
|
||||
CrdtDataDeletedReason.userDelete,
|
||||
);
|
||||
await _maybeProject(
|
||||
// The delete's own cascade (applyDeleteActions above) usually is the
|
||||
// whole projection; only run the full pass when it can decide more.
|
||||
if (!await _foreignKeyProjector.canSkipProjectionAfterLocalDelete(
|
||||
tableName,
|
||||
deletedRows.uuidRowIds,
|
||||
null,
|
||||
transaction,
|
||||
);
|
||||
)) {
|
||||
await _maybeProject(
|
||||
tableName,
|
||||
deletedRows.uuidRowIds,
|
||||
null,
|
||||
transaction,
|
||||
);
|
||||
}
|
||||
await _context.persistCurrentNodeHlc(
|
||||
_context.hlcManagerFor(transaction),
|
||||
transaction,
|
||||
|
|
|
|||
|
|
@ -43,8 +43,14 @@ class OfflineSyncClient {
|
|||
Future<void> syncOnce(
|
||||
DatabaseSession session, {
|
||||
OfflineSyncOnMergeSuccess? onMergeSuccess,
|
||||
OfflineSyncOnInboundMerge? onInboundMerge,
|
||||
}) async {
|
||||
await _startSyncSession(session, once: true, onMergeSuccess: onMergeSuccess).done;
|
||||
await _startSyncSession(
|
||||
session,
|
||||
once: true,
|
||||
onMergeSuccess: onMergeSuccess,
|
||||
onInboundMerge: onInboundMerge,
|
||||
).done;
|
||||
}
|
||||
|
||||
/// Keeps synchronizing [session] with the remote peer until cancelled through
|
||||
|
|
@ -54,12 +60,19 @@ class OfflineSyncClient {
|
|||
OfflineSyncSubscription syncContinuously(
|
||||
DatabaseSession session, {
|
||||
OfflineSyncOnMergeSuccess? onMergeSuccess,
|
||||
}) => _startSyncSession(session, once: false, onMergeSuccess: onMergeSuccess);
|
||||
OfflineSyncOnInboundMerge? onInboundMerge,
|
||||
}) => _startSyncSession(
|
||||
session,
|
||||
once: false,
|
||||
onMergeSuccess: onMergeSuccess,
|
||||
onInboundMerge: onInboundMerge,
|
||||
);
|
||||
|
||||
OfflineSyncSubscription _startSyncSession(
|
||||
DatabaseSession session, {
|
||||
required bool once,
|
||||
OfflineSyncOnMergeSuccess? onMergeSuccess,
|
||||
OfflineSyncOnInboundMerge? onInboundMerge,
|
||||
}) {
|
||||
// The stream will be closed using [outboundChanges.closeOrSkip].
|
||||
// ignore: close_sinks
|
||||
|
|
@ -78,6 +91,7 @@ class OfflineSyncClient {
|
|||
inbound: remoteStream,
|
||||
once: once,
|
||||
onMergeSuccess: onMergeSuccess,
|
||||
onInboundMerge: onInboundMerge,
|
||||
mode: OfflineSyncPeerMode.follower,
|
||||
)
|
||||
.listen(
|
||||
|
|
|
|||
|
|
@ -27,6 +27,12 @@ export 'space_state.dart' show OfflineSyncPeerMode;
|
|||
typedef OfflineSyncOnMergeSuccess =
|
||||
FutureOr<void> Function(UuidValue spaceUuid, Hlc syncedHlc);
|
||||
|
||||
/// Callback function for when changes from the remote peer were merged into
|
||||
/// a space. Unlike [OfflineSyncOnMergeSuccess], it isn't called for a space
|
||||
/// this peer only sent changes of, so a listener re-reads its data only
|
||||
/// when there is something new to read.
|
||||
typedef OfflineSyncOnInboundMerge = FutureOr<void> Function(UuidValue spaceUuid);
|
||||
|
||||
/// A tuple representing the ownership of a domain row.
|
||||
typedef DomainRowOwner = ({bool exists, int? spaceId});
|
||||
|
||||
|
|
@ -275,6 +281,7 @@ class OfflineSyncEngine {
|
|||
required OfflineSyncPeerMode mode,
|
||||
bool once = false,
|
||||
OfflineSyncOnMergeSuccess? onMergeSuccess,
|
||||
OfflineSyncOnInboundMerge? onInboundMerge,
|
||||
}) async* {
|
||||
// Idle timeouts are a continuous-only affordance: they let an idle cycle
|
||||
// settle into an empty batch without closing the stream. A `once` session
|
||||
|
|
@ -369,6 +376,7 @@ class OfflineSyncEngine {
|
|||
batch,
|
||||
outboundSpaces,
|
||||
onMergeSuccess,
|
||||
onInboundMerge,
|
||||
);
|
||||
|
||||
if (once) {
|
||||
|
|
@ -429,6 +437,7 @@ class OfflineSyncEngine {
|
|||
OfflineSyncCycleBatch batch,
|
||||
Set<UuidValue> outboundSpaces,
|
||||
OfflineSyncOnMergeSuccess? onMergeSuccess,
|
||||
OfflineSyncOnInboundMerge? onInboundMerge,
|
||||
) async {
|
||||
if (batch.spaceSet != null) {
|
||||
await spaces.adoptPeerGrants(batch.spaceSet!.spaces);
|
||||
|
|
@ -462,6 +471,9 @@ class OfflineSyncEngine {
|
|||
mergeSet: entry.value,
|
||||
);
|
||||
await _reportMerge(onMergeSuccess, spaces, spaceId, receivedHlc);
|
||||
if (entry.value.isNotEmpty) {
|
||||
await onInboundMerge?.call(spaceId);
|
||||
}
|
||||
}
|
||||
for (final spaceId in outboundSpaces.difference(mergedSpaces)) {
|
||||
await _reportMerge(onMergeSuccess, spaces, spaceId, null);
|
||||
|
|
|
|||
|
|
@ -157,6 +157,182 @@ void main() {
|
|||
expect(tombstone.hlc, remoteParentDelete.hlc);
|
||||
},
|
||||
);
|
||||
|
||||
group('and the restrict child is then deleted locally,', () {
|
||||
setUp(() async {
|
||||
await session.db.transactionForUser(testCrdtUserId, (tx) async {
|
||||
await RestrictChild.db.deleteRow(session, child, transaction: tx);
|
||||
});
|
||||
});
|
||||
|
||||
test('then the merged parent delete takes effect.', () async {
|
||||
expect(await Person.db.findById(session, parent.id!), isNull);
|
||||
expect(await RestrictChild.db.findById(session, child.id!), isNull);
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
group(
|
||||
'Given a set-null middle with two cascade children,',
|
||||
() {
|
||||
late FkChainSetNullMiddle middle;
|
||||
late FkChainSetNullCascadeChild deleted;
|
||||
late FkChainSetNullCascadeChild sibling;
|
||||
|
||||
setUp(() async {
|
||||
await session.db.transactionForUser(testCrdtUserId, (tx) async {
|
||||
middle = await FkChainSetNullMiddle.db.insertRow(
|
||||
session,
|
||||
FkChainSetNullMiddle(id: const Uuid().v7obj(), name: 'middle'),
|
||||
transaction: tx,
|
||||
);
|
||||
deleted = await FkChainSetNullCascadeChild.db.insertRow(
|
||||
session,
|
||||
FkChainSetNullCascadeChild(
|
||||
id: const Uuid().v7obj(),
|
||||
name: 'deleted child',
|
||||
setNullMiddleId: middle.id,
|
||||
),
|
||||
transaction: tx,
|
||||
);
|
||||
sibling = await FkChainSetNullCascadeChild.db.insertRow(
|
||||
session,
|
||||
FkChainSetNullCascadeChild(
|
||||
id: const Uuid().v7obj(),
|
||||
name: 'sibling',
|
||||
setNullMiddleId: middle.id,
|
||||
),
|
||||
transaction: tx,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('when one cascade child is deleted locally,', () {
|
||||
setUp(() async {
|
||||
await session.db.transactionForUser(testCrdtUserId, (tx) async {
|
||||
await FkChainSetNullCascadeChild.db.deleteRow(
|
||||
session,
|
||||
deleted,
|
||||
transaction: tx,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('then only that child is hidden, by a user delete.', () async {
|
||||
expect(
|
||||
await FkChainSetNullCascadeChild.db.findById(session, deleted.id!),
|
||||
isNull,
|
||||
);
|
||||
final visibleSibling = await FkChainSetNullCascadeChild.db.findById(
|
||||
session,
|
||||
sibling.id!,
|
||||
);
|
||||
expect(visibleSibling, isNotNull);
|
||||
expect(visibleSibling!.setNullMiddleId, middle.id);
|
||||
expect(
|
||||
await FkChainSetNullMiddle.db.findById(session, middle.id!),
|
||||
isNotNull,
|
||||
);
|
||||
final tombstone = await CrdtDataDeleted.db.findFirstRow(
|
||||
session,
|
||||
where: (t) => t.row.uuidRowId.equals(deleted.id),
|
||||
);
|
||||
expect(tombstone, isNotNull);
|
||||
expect(tombstone!.isDeleted, isTrue);
|
||||
expect(tombstone.reason, CrdtDataDeletedReason.userDelete);
|
||||
});
|
||||
|
||||
group('and the middle is then deleted by a merged remote delete,', () {
|
||||
setUp(() async {
|
||||
await session.db.mergeChanges(
|
||||
[
|
||||
_deleteChange(
|
||||
tableName: FkChainSetNullMiddle.t.tableName,
|
||||
rowId: middle.id!,
|
||||
after: await rowHlc(middle.id!),
|
||||
),
|
||||
],
|
||||
spaceId: testCrdtUserId,
|
||||
);
|
||||
});
|
||||
|
||||
test('then the remaining child is hidden with it.', () async {
|
||||
expect(
|
||||
await FkChainSetNullMiddle.db.findById(session, middle.id!),
|
||||
isNull,
|
||||
);
|
||||
expect(
|
||||
await FkChainSetNullCascadeChild.db.findById(session, sibling.id!),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
group(
|
||||
'Given a set-null middle kept by its restrict child against a merged delete,',
|
||||
() {
|
||||
late FkChainSetNullMiddle middle;
|
||||
late FkChainSetNullRestrictChild blocker;
|
||||
|
||||
setUp(() async {
|
||||
await session.db.transactionForUser(testCrdtUserId, (tx) async {
|
||||
middle = await FkChainSetNullMiddle.db.insertRow(
|
||||
session,
|
||||
FkChainSetNullMiddle(id: const Uuid().v7obj(), name: 'kept middle'),
|
||||
transaction: tx,
|
||||
);
|
||||
blocker = await FkChainSetNullRestrictChild.db.insertRow(
|
||||
session,
|
||||
FkChainSetNullRestrictChild(
|
||||
id: const Uuid().v7obj(),
|
||||
name: 'blocker',
|
||||
setNullMiddleId: middle.id,
|
||||
),
|
||||
transaction: tx,
|
||||
);
|
||||
});
|
||||
await session.db.mergeChanges(
|
||||
[
|
||||
_deleteChange(
|
||||
tableName: FkChainSetNullMiddle.t.tableName,
|
||||
rowId: middle.id!,
|
||||
after: await rowHlc(middle.id!),
|
||||
),
|
||||
],
|
||||
spaceId: testCrdtUserId,
|
||||
);
|
||||
expect(
|
||||
await FkChainSetNullMiddle.db.findById(session, middle.id!),
|
||||
isNotNull,
|
||||
);
|
||||
});
|
||||
|
||||
group('when the restrict child is deleted locally,', () {
|
||||
setUp(() async {
|
||||
await session.db.transactionForUser(testCrdtUserId, (tx) async {
|
||||
await FkChainSetNullRestrictChild.db.deleteRow(
|
||||
session,
|
||||
blocker,
|
||||
transaction: tx,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('then the merged delete of the middle takes effect.', () async {
|
||||
expect(
|
||||
await FkChainSetNullMiddle.db.findById(session, middle.id!),
|
||||
isNull,
|
||||
);
|
||||
expect(
|
||||
await FkChainSetNullRestrictChild.db.findById(session, blocker.id!),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue