A merge wrote each inserted row one by one: a savepoint for the domain row, another for its tracker, two statements each, and then read every tracker back to record attempted values. A device's first push of a library of 8,000 cards (24,650 changes) took the server 34 to 37 s, nearly all of it in those round trips. A batch the projection pass leaves out (nothing in it touches a foreign key or a unique index, or its inserts are proven to change no decision) now writes its new rows many to a statement, up to 1,000 at a time, in one savepoint: each table's domain rows in one insert, parents' tables first, then their trackers. A row qualifies when the batch inserts it once and touches it no other way, no tracker exists for it, nothing earlier in the batch names it, and every row of the batch it names was written before it. Its values are planned in the batch's order, as the per-row path plans them. A chunk whose writes fail (a row there already, perhaps another space's) is rolled back, the presence it claimed for its rows is forgotten, and it and every row after it are left to the per-row path, which tells those cases apart. Attempted values read the trackers the merge holds instead of querying them. The proof for merged inserts also accepts a parent of the row's own table inserted earlier in the batch, so a library's nested studysets no longer send its first push through the pass; a chunk ends before a row whose parent of its own table it holds. A row naming itself, or a parent inserted after it, still takes the pass. Measured on PostgreSQL, the 24,650 changes merge in about 5 s instead of 34 to 37 s, and with the studysets nested instead of 41 s. A device on SQLite, whose adapter still inserts row by row, receives the library in 44 s instead of 60 s. The new tests cover a batch spanning chunks, a chunk failing on a row whose tracker was lost and falling back, and a tree of one table's rows inserted parents first (the shortcut, a chunk per level) and children first (the pass). The core, server, integration and simulation suites pass, but for two PostgreSQL tests that can't start their database while another embedded PostgreSQL runs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1430 lines
47 KiB
Dart
1430 lines
47 KiB
Dart
part of 'recorder.dart';
|
|
|
|
/// Adds merge-specific behavior to [CrdtMutationRecorder].
|
|
extension CrdtMergeRecorderExtension on CrdtMutationRecorder {
|
|
/// Merges remote CRDT changes into the current database.
|
|
Future<void> mergeChanges(
|
|
CrdtMergeSet mergeSet,
|
|
Transaction transaction,
|
|
) async {
|
|
if (mergeSet.isEmpty) return;
|
|
|
|
final operations = mergeSet.causallyOrderedChanges;
|
|
// Validate remote facts before touching rows or progress, including stale
|
|
// and repeated deliveries. Generated alternatives are never authored facts.
|
|
for (final operation in operations) {
|
|
if (!_context.isCrdtTrackedTableName(operation.tableName) ||
|
|
!_uniqueResolver.hasReservedUniqueColumns(operation.tableName)) {
|
|
continue;
|
|
}
|
|
switch (operation) {
|
|
case CrdtMergeInsert():
|
|
for (final MapEntry(key: column, value: value)
|
|
in operation.databaseColumns.entries) {
|
|
_uniqueResolver.validateAuthoredValue(operation.tableName, column, value);
|
|
}
|
|
case CrdtMergeUpdate():
|
|
_uniqueResolver.validateAuthoredValue(
|
|
operation.tableName,
|
|
operation.columnName,
|
|
operation.value,
|
|
);
|
|
case CrdtMergeDelete():
|
|
break;
|
|
}
|
|
}
|
|
// Nothing in this batch touches a foreign key or a unique index, so no
|
|
// projection decision can change: skip both passes instead of loading
|
|
// state for tables that cannot produce a candidate or a conflict.
|
|
var mayAffectProjection = _foreignKeyProjector.mergeOperationsMayAffectProjection(
|
|
operations,
|
|
);
|
|
final authoredOverlays = <MergeFieldKey, Object?>{};
|
|
final currentUser = _context.effectiveSpaceFor(transaction);
|
|
final remoteNodes = await _findOrCreateNodesForMerge(
|
|
currentUser.id!,
|
|
{for (final change in operations) change.uuidNodeId},
|
|
transaction,
|
|
);
|
|
final nodesByUuid = remoteNodes.nodesByUuid;
|
|
final context = await _loadMergeContext(mergeSet, transaction);
|
|
// New rows whose parents are there and which nothing refers to yet (a
|
|
// rating, an edit, a tag given, a card added) can't change a projection
|
|
// decision either, though they carry foreign keys: proven so, neither
|
|
// pass runs, and the batch still resolves their parents in one go.
|
|
final unchangedByInserts =
|
|
mayAffectProjection &&
|
|
await _mergeLeavesProjectionUnchanged(operations, context, transaction);
|
|
if (unchangedByInserts) mayAffectProjection = false;
|
|
|
|
// Deletes carry no authored overlay but still change visibility, so the
|
|
// seed is every touched row, not just the ones with overlays.
|
|
final touchedTables = <String>{};
|
|
final touchedRows = <MergeRowKey>{};
|
|
for (final operation in operations) {
|
|
if (!_context.isCrdtTrackedTableName(operation.tableName)) continue;
|
|
touchedTables.add(operation.tableName);
|
|
touchedRows.add((operation.tableName, operation.uuidRowId));
|
|
}
|
|
|
|
final batch = mayAffectProjection || unchangedByInserts
|
|
? await _planBatchInserts(
|
|
operations,
|
|
nodesByUuid,
|
|
context,
|
|
touchedTables,
|
|
touchedRows,
|
|
transaction,
|
|
plan: mayAffectProjection,
|
|
)
|
|
: (plan: null, pending: const <PendingProjectionRow>[]);
|
|
// Every merged insert has to know whether the rows its foreign keys name
|
|
// are there, which is one query per edge unless the batch resolves them
|
|
// together and then keeps the answers current as it writes.
|
|
final presence = batch.pending.isEmpty
|
|
? null
|
|
: await _foreignKeyProjector.resolvePresenceForInserts(
|
|
batch.pending,
|
|
transaction,
|
|
);
|
|
|
|
final writes = _MergeWriteBatch(
|
|
session: _session,
|
|
projector: _foreignKeyProjector,
|
|
fields: context.fields,
|
|
rows: context.rows,
|
|
transaction: transaction,
|
|
);
|
|
final deferredDeletes = <CrdtMergeDelete>[];
|
|
// Without the pass, new rows nothing else in the batch touches are
|
|
// written many to a statement instead of one by one in savepoints.
|
|
final writtenInChunks = mayAffectProjection
|
|
? const <MergeRowKey>{}
|
|
: await _applyNewRowInsertsInChunks(
|
|
operations,
|
|
nodesByUuid,
|
|
context,
|
|
writes,
|
|
presence,
|
|
transaction,
|
|
);
|
|
|
|
await writes.apply(
|
|
operations.where(
|
|
(operation) =>
|
|
_context.isCrdtTrackedTableName(operation.tableName) &&
|
|
!writtenInChunks.contains((operation.tableName, operation.uuidRowId)),
|
|
),
|
|
(operation) async {
|
|
switch (operation) {
|
|
case final CrdtMergeInsert insert:
|
|
await _applyMergeInsert(
|
|
insert,
|
|
nodesByUuid,
|
|
context,
|
|
authoredOverlays,
|
|
batch.plan,
|
|
writes,
|
|
presence,
|
|
transaction,
|
|
);
|
|
case final CrdtMergeUpdate update:
|
|
await _applyMergeUpdate(
|
|
update,
|
|
nodesByUuid,
|
|
context,
|
|
authoredOverlays,
|
|
writes,
|
|
transaction,
|
|
);
|
|
case final CrdtMergeDelete delete:
|
|
// A higher delete/restore generation can have an older HLC than a
|
|
// concurrent reinsertion. Bootstrap must retain it until this batch
|
|
// has inserted the row it belongs to.
|
|
if (!context.rows.containsKey((delete.tableName, delete.uuidRowId))) {
|
|
deferredDeletes.add(delete);
|
|
return;
|
|
}
|
|
await _applyMergeDelete(
|
|
delete,
|
|
nodesByUuid,
|
|
context,
|
|
transaction,
|
|
);
|
|
// A delete can hide the row, and a resurrecting one can bring it
|
|
// back; either way what was known about it no longer holds.
|
|
presence?.forget((delete.tableName, delete.uuidRowId));
|
|
}
|
|
},
|
|
);
|
|
|
|
for (final delete in deferredDeletes) {
|
|
await _applyMergeDelete(delete, nodesByUuid, context, transaction);
|
|
}
|
|
|
|
if (mayAffectProjection) {
|
|
await _foreignKeyProjector.project(
|
|
transaction,
|
|
authoredOverlays: authoredOverlays,
|
|
seedTables: touchedTables,
|
|
seedRows: touchedRows,
|
|
);
|
|
} else {
|
|
// The pass is also what writes an update's value, so its authored
|
|
// values still have to land when it is skipped.
|
|
await _foreignKeyProjector.writeAuthoredValues(
|
|
authoredOverlays,
|
|
transaction,
|
|
);
|
|
}
|
|
await _updateHlcFromIncomingOperations(
|
|
operations,
|
|
remoteNodes,
|
|
transaction,
|
|
);
|
|
}
|
|
|
|
Future<void> _updateHlcFromIncomingOperations(
|
|
List<CrdtMergeChange> operations,
|
|
MergeNodes remoteNodes,
|
|
Transaction transaction,
|
|
) async {
|
|
final maxIncomingHlc = operations.fold<Hlc?>(
|
|
null,
|
|
(current, change) => change.hlc.maxBetween(current),
|
|
);
|
|
final spaceNodesToUpdate = <OfflineSyncSpaceNode>[];
|
|
|
|
if (maxIncomingHlc != null) {
|
|
final hlcManager = _context.hlcManagerFor(transaction);
|
|
if (maxIncomingHlc.nodeId == hlcManager.uuidNodeId) {
|
|
if (maxIncomingHlc > hlcManager.lastHlc) {
|
|
hlcManager.lastHlc = maxIncomingHlc;
|
|
}
|
|
} else {
|
|
hlcManager.merge(maxIncomingHlc);
|
|
}
|
|
|
|
await _context.persistCurrentNodeHlc(hlcManager, transaction);
|
|
}
|
|
|
|
final maxIncomingHlcByNode = <UuidValue, Hlc>{};
|
|
for (final operation in operations) {
|
|
maxIncomingHlcByNode[operation.uuidNodeId] = operation.hlc.maxBetween(
|
|
maxIncomingHlcByNode[operation.uuidNodeId],
|
|
);
|
|
}
|
|
|
|
for (final MapEntry(key: nodeId, value: incomingHlc)
|
|
in maxIncomingHlcByNode.entries) {
|
|
final remoteNode = remoteNodes.spaceNodesByUuid[nodeId];
|
|
if (remoteNode == null) continue;
|
|
final previous = remoteNode.lastReceivedHlc;
|
|
final updatedSpaceNode = remoteNode.copyWith(
|
|
lastReceivedHlc: incomingHlc.maxBetween(
|
|
previous?.nodeId == nodeId ? previous : null,
|
|
),
|
|
);
|
|
if (updatedSpaceNode.lastReceivedHlc == remoteNode.lastReceivedHlc) {
|
|
continue;
|
|
}
|
|
|
|
spaceNodesToUpdate.add(updatedSpaceNode);
|
|
remoteNodes.spaceNodesByUuid[nodeId] = updatedSpaceNode;
|
|
}
|
|
|
|
if (spaceNodesToUpdate.isNotEmpty) {
|
|
await OfflineSyncSpaceNode.db.update(
|
|
_session,
|
|
spaceNodesToUpdate,
|
|
columns: (t) => [t.lastReceivedHlc],
|
|
transaction: transaction,
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<MergeNodes> _findOrCreateNodesForMerge(
|
|
int userId,
|
|
Set<UuidValue> nodeIds,
|
|
Transaction transaction,
|
|
) async {
|
|
if (nodeIds.isEmpty) {
|
|
return (
|
|
nodesByUuid: const <UuidValue, CrdtNode>{},
|
|
spaceNodesByUuid: const <UuidValue, OfflineSyncSpaceNode>{},
|
|
);
|
|
}
|
|
|
|
final nodesByUuid = {
|
|
for (final uuidNodeId in nodeIds)
|
|
uuidNodeId: await _context.findOrCreateNode(uuidNodeId, transaction),
|
|
};
|
|
|
|
final spaceNodesByUuid = {
|
|
for (final entry in nodesByUuid.entries)
|
|
entry.key: await _context.findOrCreateSpaceNode(
|
|
userId,
|
|
entry.value.id!,
|
|
transaction,
|
|
),
|
|
};
|
|
|
|
return (
|
|
nodesByUuid: nodesByUuid,
|
|
spaceNodesByUuid: spaceNodesByUuid,
|
|
);
|
|
}
|
|
|
|
Future<MergeContext> _loadMergeContext(
|
|
CrdtMergeSet mergeSet,
|
|
Transaction transaction,
|
|
) async {
|
|
final (:rowIdsByTable, :columnNamesByTable) = mergeSet.collectMetadataLookup(
|
|
columnNamesByTableName: _context.syncedTableColumnNamesForMerge,
|
|
);
|
|
|
|
final rows = <MergeRowKey, CrdtDataRow>{};
|
|
final fields = <MergeFieldKey, CrdtDataField>{};
|
|
final incomingFieldHlcs = {
|
|
for (final update in mergeSet.whereType<CrdtMergeUpdate>())
|
|
(update.tableName, update.uuidRowId, update.columnName): update.hlc,
|
|
};
|
|
final tombstones = <MergeRowKey, CrdtDataDeleted>{};
|
|
// Owning space per row, snapshotted once so the per-operation ownership
|
|
// checks never re-read the same immutable `spaceId`. Only rows that
|
|
// already have a tracker are pre-read here — those are exactly the rows
|
|
// whose ownership the update/delete/existing-insert paths verify. New
|
|
// rows go through the insert path, which proves ownership at the physical
|
|
// write and refreshes this entry afterwards.
|
|
final domainOwners = <MergeRowKey, DomainRowOwner>{};
|
|
|
|
for (final MapEntry(key: tableName, value: rowIds) in rowIdsByTable.entries) {
|
|
final loadedRows = await _context.findCrdtRows(
|
|
tableName,
|
|
rowIds,
|
|
transaction,
|
|
include: CrdtDataRow.include(
|
|
node: CrdtNode.include(),
|
|
deleted: CrdtDataDeleted.include(node: CrdtNode.include()),
|
|
),
|
|
);
|
|
|
|
for (final row in loadedRows) {
|
|
rows[(tableName, row.uuidRowId)] = row;
|
|
if (row.deleted != null) {
|
|
tombstones[(tableName, row.uuidRowId)] = row.deleted!;
|
|
}
|
|
}
|
|
|
|
final trackedRowIds = loadedRows.map((row) => row.uuidRowId).toSet();
|
|
if (trackedRowIds.isNotEmpty) {
|
|
final ownerValues = await _context.readDomainColumnValues(
|
|
tableName,
|
|
trackedRowIds,
|
|
['spaceId'],
|
|
transaction,
|
|
);
|
|
for (final rowId in trackedRowIds) {
|
|
final values = ownerValues[rowId];
|
|
domainOwners[(tableName, rowId)] = values == null
|
|
? (exists: false, spaceId: null)
|
|
: (exists: true, spaceId: values['spaceId'] as int?);
|
|
}
|
|
}
|
|
|
|
final columnNames = columnNamesByTable[tableName];
|
|
if (columnNames == null || columnNames.isEmpty || loadedRows.isEmpty) continue;
|
|
|
|
final rowPks = loadedRows.map((row) => row.id).whereType<int>().toSet();
|
|
if (rowPks.length != loadedRows.length) {
|
|
throw StateError(
|
|
'Some merge metadata rows for table $tableName are missing '
|
|
'persisted identifiers.',
|
|
);
|
|
}
|
|
|
|
final columnIdsByName = _context.schemaColumnIds(tableName, columnNames);
|
|
if (columnIdsByName.isEmpty) continue;
|
|
|
|
final loadedFields = await CrdtDataField.db.find(
|
|
_session,
|
|
where: (t) =>
|
|
t.rowId.inSet(rowPks) & t.columnId.inSet(columnIdsByName.values.toSet()),
|
|
include: CrdtDataField.include(
|
|
column: CrdtSchemaColumn.include(),
|
|
node: CrdtNode.include(),
|
|
),
|
|
transaction: transaction,
|
|
);
|
|
|
|
for (final field in loadedFields) {
|
|
final row = loadedRows.firstWhere((loadedRow) => loadedRow.id == field.rowId);
|
|
fields[(tableName, row.uuidRowId, field.column!.name)] = field;
|
|
}
|
|
}
|
|
|
|
return (
|
|
rows: rows,
|
|
fields: fields,
|
|
incomingFieldHlcs: incomingFieldHlcs,
|
|
tombstones: tombstones,
|
|
domainOwners: domainOwners,
|
|
);
|
|
}
|
|
|
|
/// Plans every insert in the batch in one projection pass.
|
|
///
|
|
/// The physical write of each insert has to be constraint safe before it
|
|
/// runs, but that plan does not have to be recomputed per insert: one pass
|
|
/// over all of them resolves the batch's claims against each other and
|
|
/// against stored rows. The end-of-batch pass remains authoritative for what
|
|
/// is finally materialized.
|
|
Future<({ProjectionPlan? plan, List<PendingProjectionRow> pending})>
|
|
_planBatchInserts(
|
|
List<CrdtMergeChange> operations,
|
|
Map<UuidValue, CrdtNode> remoteNodes,
|
|
MergeContext context,
|
|
Set<String> seedTables,
|
|
Set<MergeRowKey> seedRows,
|
|
Transaction transaction, {
|
|
// Whether to plan them: without, only the rows are listed, for their
|
|
// parents to be resolved together.
|
|
bool plan = true,
|
|
}) async {
|
|
final pending = <PendingProjectionRow>[];
|
|
final seenRowKeys = <MergeRowKey>{};
|
|
|
|
for (final operation in operations) {
|
|
// An update or a delete only seeds the pass. Operations are causally
|
|
// ordered, so an update newer than its insert is applied after it, and
|
|
// the end-of-batch pass is what resolves the value it claims.
|
|
if (operation is! CrdtMergeInsert) continue;
|
|
if (!_context.isCrdtTrackedTableName(operation.tableName)) continue;
|
|
|
|
// Only rows this merge will create need a pre-write plan, and only the
|
|
// first insert for a row id takes that path.
|
|
final rowKey = (operation.tableName, operation.uuidRowId);
|
|
if (context.rows[rowKey] != null) continue;
|
|
if (!seenRowKeys.add(rowKey)) continue;
|
|
pending.add((
|
|
tableName: operation.tableName,
|
|
rowId: operation.uuidRowId,
|
|
authoredValues: _sanitizeMergeRowData(
|
|
operation.tableName,
|
|
operation.databaseColumns,
|
|
),
|
|
rowHlc: operation.hlc,
|
|
node: _requireRemoteNode(remoteNodes, operation.uuidNodeId),
|
|
hidden: false,
|
|
));
|
|
}
|
|
if (pending.isEmpty || !plan) return (plan: null, pending: pending);
|
|
|
|
return (
|
|
plan: await _foreignKeyProjector.project(
|
|
transaction,
|
|
pendingInserts: pending,
|
|
seedTables: seedTables,
|
|
seedRows: seedRows,
|
|
),
|
|
pending: pending,
|
|
);
|
|
}
|
|
|
|
/// Whether this batch leaves every projection decision as it is though it
|
|
/// touches foreign keys, besides updating columns no projection reads: it
|
|
/// only inserts new rows, each once, proven
|
|
/// ([CrdtForeignKeyProjector.canLeaveProjectionUnchangedByMergedInserts]),
|
|
/// or only deletes rows, none restoring one, proven
|
|
/// ([CrdtForeignKeyProjector.canLeaveProjectionUnchangedByMergedDeletes]).
|
|
/// A batch doing both, an insert onto a row there already, or a restore
|
|
/// takes the full pass.
|
|
Future<bool> _mergeLeavesProjectionUnchanged(
|
|
List<CrdtMergeChange> operations,
|
|
MergeContext context,
|
|
Transaction transaction,
|
|
) async {
|
|
final insertsByTable = <String, Map<UuidValue, Map<String, Object?>>>{};
|
|
final positions = <MergeRowKey, int>{};
|
|
final deletesByTable = <String, Set<UuidValue>>{};
|
|
for (final operation in operations) {
|
|
if (!_context.isCrdtTrackedTableName(operation.tableName)) continue;
|
|
switch (operation) {
|
|
case final CrdtMergeDelete delete:
|
|
// An even flag deletes, an odd one restores.
|
|
if (delete.clFlag.isOdd || !delete.reason.isSynced) return false;
|
|
(deletesByTable[delete.tableName] ??= {}).add(delete.uuidRowId);
|
|
case final CrdtMergeUpdate update:
|
|
if (_foreignKeyProjector.needsProjection(update.tableName, {
|
|
update.columnName,
|
|
})) {
|
|
return false;
|
|
}
|
|
case final CrdtMergeInsert insert:
|
|
if (context.rows[(insert.tableName, insert.uuidRowId)] != null) {
|
|
return false;
|
|
}
|
|
final rows = insertsByTable[insert.tableName] ??= {};
|
|
if (rows.containsKey(insert.uuidRowId)) return false;
|
|
rows[insert.uuidRowId] = _sanitizeMergeRowData(
|
|
insert.tableName,
|
|
insert.databaseColumns,
|
|
);
|
|
positions[(insert.tableName, insert.uuidRowId)] = positions.length;
|
|
}
|
|
}
|
|
if (insertsByTable.isNotEmpty && deletesByTable.isNotEmpty) return false;
|
|
if (deletesByTable.isNotEmpty) {
|
|
return _foreignKeyProjector.canLeaveProjectionUnchangedByMergedDeletes(
|
|
deletesByTable,
|
|
transaction,
|
|
);
|
|
}
|
|
return _foreignKeyProjector.canLeaveProjectionUnchangedByMergedInserts(
|
|
insertsByTable,
|
|
positions,
|
|
transaction,
|
|
);
|
|
}
|
|
|
|
/// Writes the batch's new rows that nothing else in it touches, many to a
|
|
/// statement, and returns their keys for the per-row pass to skip.
|
|
///
|
|
/// Only for a batch the projection pass skips, where each merged insert
|
|
/// writes its row as planned, one by one in two savepoints and two
|
|
/// statements of its own: nearly all the time of a first sync of thousands
|
|
/// of rows. A row qualifies when the batch inserts it once and touches it
|
|
/// no other way, nothing earlier in the batch names it, and each row of
|
|
/// the batch it names qualified before it. Its values are planned in the
|
|
/// batch's order, as the per-row path plans them. A chunk whose writes fail
|
|
/// (a row there already, perhaps another space's) is rolled back and left,
|
|
/// with every row after it, to the per-row path, which tells those apart.
|
|
Future<Set<MergeRowKey>> _applyNewRowInsertsInChunks(
|
|
List<CrdtMergeChange> operations,
|
|
Map<UuidValue, CrdtNode> remoteNodes,
|
|
MergeContext context,
|
|
_MergeWriteBatch writes,
|
|
CrdtForeignKeyPresenceCache? presence,
|
|
Transaction transaction,
|
|
) async {
|
|
final touches = <MergeRowKey, int>{};
|
|
for (final operation in operations) {
|
|
if (!_context.isCrdtTrackedTableName(operation.tableName)) continue;
|
|
final rowKey = (operation.tableName, operation.uuidRowId);
|
|
touches[rowKey] = (touches[rowKey] ?? 0) + 1;
|
|
}
|
|
|
|
final written = <MergeRowKey>{};
|
|
// Rows an insert names, by foreign key or as its set-default fallback: one
|
|
// named before it is written would find the row missing.
|
|
final named = <MergeRowKey>{};
|
|
final mergingSpaceId = _context.hlcManagerFor(transaction).normalizedSpaceId;
|
|
var chunk = _NewRowChunk();
|
|
Future<bool> writeChunk() async {
|
|
if (!await _writeNewRowChunk(chunk, context, writes, transaction)) {
|
|
// Marked visible for their children, but not written after all.
|
|
if (presence != null) chunk.rowKeys.forEach(presence.forget);
|
|
return false;
|
|
}
|
|
written.addAll(chunk.rowKeys);
|
|
chunk = _NewRowChunk();
|
|
return true;
|
|
}
|
|
|
|
for (final operation in operations) {
|
|
if (operation is! CrdtMergeInsert ||
|
|
!_context.isCrdtTrackedTableName(operation.tableName)) {
|
|
continue;
|
|
}
|
|
final rowKey = (operation.tableName, operation.uuidRowId);
|
|
final data = _sanitizeMergeRowData(
|
|
operation.tableName,
|
|
operation.databaseColumns,
|
|
);
|
|
final parents = _rowsNamedByMergeInsert(operation.tableName, data);
|
|
// A parent named by another column could be any row: leave it all.
|
|
if (parents == null) break;
|
|
final qualifies =
|
|
operation.data is TableRow &&
|
|
touches[rowKey] == 1 &&
|
|
context.rows[rowKey] == null &&
|
|
!named.contains(rowKey) &&
|
|
parents.every(
|
|
(parent) =>
|
|
!touches.containsKey(parent) ||
|
|
written.contains(parent) ||
|
|
chunk.holds(parent),
|
|
);
|
|
named.addAll(parents);
|
|
for (final edge
|
|
in _foreignKeys.edgesByChildTable[operation.tableName] ??
|
|
const <ForeignKeyEdge>[]) {
|
|
if (edge.action != ForeignKeyAction.setDefault) continue;
|
|
if (tryUuidValue(edge.defaultValue) case final fallback?) {
|
|
named.add((edge.parentTableName, fallback));
|
|
}
|
|
}
|
|
if (!qualifies) continue;
|
|
// Parents go in before their children: a chunk writes each table after
|
|
// the tables its rows name there, so a row naming one of its own table,
|
|
// or one whose table has to follow its own, waits for the next chunk.
|
|
if (!chunk.canTake(operation.tableName, parents) && !await writeChunk()) {
|
|
return written;
|
|
}
|
|
|
|
final planned = await _planMergeInsertValues(
|
|
operation,
|
|
data,
|
|
null,
|
|
presence,
|
|
transaction,
|
|
);
|
|
final domain = _mergeInsertDomainRow(operation, planned.domain, mergingSpaceId);
|
|
chunk.add(
|
|
(
|
|
tableName: operation.tableName,
|
|
rowId: operation.uuidRowId,
|
|
node: _requireRemoteNode(remoteNodes, operation.uuidNodeId),
|
|
hlc: operation.hlc,
|
|
domainRow: domain.row,
|
|
explicitNulls: domain.explicitNulls,
|
|
attempts: planned.attempts,
|
|
),
|
|
parents,
|
|
);
|
|
// Its children further on find it written, as one by one.
|
|
presence?.markVisible(rowKey);
|
|
if (chunk.length >= _NewRowChunk.maxRows && !await writeChunk()) {
|
|
return written;
|
|
}
|
|
}
|
|
await writeChunk();
|
|
return written;
|
|
}
|
|
|
|
/// Writes [chunk] in one savepoint, domain rows first and their trackers
|
|
/// after them, as the per-row path does, and records what it would.
|
|
/// Rolls the chunk back and returns false when a statement fails.
|
|
Future<bool> _writeNewRowChunk(
|
|
_NewRowChunk chunk,
|
|
MergeContext context,
|
|
_MergeWriteBatch writes,
|
|
Transaction transaction,
|
|
) async {
|
|
if (chunk.isEmpty) return true;
|
|
final Map<MergeRowKey, CrdtDataRow> trackers;
|
|
try {
|
|
trackers = await DatabaseUtil.runInTransactionOrSavepoint(
|
|
_db,
|
|
transaction,
|
|
(savepoint) async {
|
|
for (final tableName in chunk.tablesInWriteOrder) {
|
|
final rows = chunk.rowsByTable[tableName]!;
|
|
await insertWithExplicitNulls<TableRow>(
|
|
_db,
|
|
[for (final row in rows) row.domainRow],
|
|
explicitNulls: {
|
|
for (final row in rows)
|
|
if (row.explicitNulls.isNotEmpty) row.rowId: row.explicitNulls,
|
|
},
|
|
transaction: savepoint,
|
|
noReturn: true,
|
|
);
|
|
}
|
|
final inserted = <MergeRowKey, CrdtDataRow>{};
|
|
for (final MapEntry(key: tableName, value: rows)
|
|
in chunk.rowsByTable.entries) {
|
|
final trackers = await CrdtDataRow.db.insert(
|
|
_session,
|
|
[
|
|
for (final row in rows)
|
|
_newMergeRow(tableName, row.rowId, row.node, row.hlc, savepoint),
|
|
],
|
|
transaction: savepoint,
|
|
);
|
|
for (final tracker in trackers) {
|
|
inserted[(tableName, tracker.uuidRowId)] = tracker;
|
|
}
|
|
}
|
|
return inserted;
|
|
},
|
|
);
|
|
} on DatabaseQueryException {
|
|
return false;
|
|
}
|
|
|
|
final mergingSpaceId = _context.hlcManagerFor(transaction).normalizedSpaceId;
|
|
for (final row in chunk.rows) {
|
|
final rowKey = (row.tableName, row.rowId);
|
|
context.rows[rowKey] = trackers[rowKey]!.copyWith(node: row.node);
|
|
context.domainOwners[rowKey] = (exists: true, spaceId: mergingSpaceId);
|
|
writes.recordInsertAttempts(
|
|
tableName: row.tableName,
|
|
rowId: row.rowId,
|
|
node: row.node,
|
|
attempts: row.attempts,
|
|
);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/// The rows [data], an insert into [tableName], names by foreign key, or
|
|
/// null when one names its row by another column or not by a UUID.
|
|
Set<MergeRowKey>? _rowsNamedByMergeInsert(
|
|
String tableName,
|
|
Map<String, Object?> data,
|
|
) {
|
|
final named = <MergeRowKey>{};
|
|
for (final edge
|
|
in _foreignKeys.edgesByChildTable[tableName] ?? const <ForeignKeyEdge>[]) {
|
|
final value = data[edge.childColumn];
|
|
if (value == null) continue;
|
|
if (edge.parentColumn != 'id') return null;
|
|
final id = tryUuidValue(value);
|
|
if (id == null) return null;
|
|
named.add((edge.parentTableName, id));
|
|
}
|
|
return named;
|
|
}
|
|
|
|
/// The domain row a merged insert writes with its planned values [data],
|
|
/// owned by [spaceId], and the columns whose null it writes though they
|
|
/// have a default.
|
|
({TableRow row, Set<String> explicitNulls}) _mergeInsertDomainRow(
|
|
CrdtMergeInsert insert,
|
|
Map<String, Object?> data,
|
|
int spaceId,
|
|
) {
|
|
final tableRow = _requireMergeInsertTableRow(insert);
|
|
final row = _withPlannedDomainValues(tableRow, data).copyWithSpaceId(spaceId);
|
|
return (
|
|
row: row,
|
|
explicitNulls: {
|
|
for (final column in row.table.crdtSyncableColumns)
|
|
if (column.hasDefault && data[column.columnName] == null) column.columnName,
|
|
},
|
|
);
|
|
}
|
|
|
|
/// The tracker of a row a merge inserts, not written yet.
|
|
CrdtDataRow _newMergeRow(
|
|
String tableName,
|
|
UuidValue rowId,
|
|
CrdtNode remoteNode,
|
|
Hlc incomingHlc,
|
|
Transaction transaction,
|
|
) {
|
|
final (tableId, _) = _context.schema[tableName]!;
|
|
return CrdtDataRow(
|
|
spaceId: _context.hlcManagerFor(transaction).normalizedSpaceId,
|
|
tblId: tableId,
|
|
uuidRowId: rowId,
|
|
nodeId: remoteNode.id!,
|
|
hlcDatetime: incomingHlc.datetime,
|
|
hlcCounter: incomingHlc.counter,
|
|
);
|
|
}
|
|
|
|
Future<void> _applyMergeInsert(
|
|
CrdtMergeInsert insert,
|
|
Map<UuidValue, CrdtNode> remoteNodes,
|
|
MergeContext context,
|
|
Map<MergeFieldKey, Object?> authoredOverlays,
|
|
ProjectionPlan? batchPlan,
|
|
_MergeWriteBatch writes,
|
|
CrdtForeignKeyPresenceCache? presence,
|
|
Transaction transaction,
|
|
) async {
|
|
final rowKey = (insert.tableName, insert.uuidRowId);
|
|
final incomingHlc = insert.hlc;
|
|
final remoteNode = _requireRemoteNode(remoteNodes, insert.uuidNodeId);
|
|
|
|
final currentRow = context.rows[rowKey];
|
|
if (currentRow != null && incomingHlc <= currentRow.hlc) {
|
|
return;
|
|
}
|
|
|
|
// Set inside the savepoint and read after it, so a rolled back insert
|
|
// records nothing.
|
|
ProjectionAttemptsByField? pendingWrites;
|
|
|
|
final data = _sanitizeMergeRowData(insert.tableName, insert.databaseColumns);
|
|
|
|
if (currentRow == null) {
|
|
final mergingSpace = _context.effectiveSpaceFor(transaction);
|
|
final mergingSpaceId = mergingSpace.id!;
|
|
try {
|
|
// Insert-first inside a savepoint: the domain primary key resolves
|
|
// creation races atomically, and `_applyMergeInsertForMissingRow`
|
|
// raises `_ForeignSpaceRowCollision` when the row turns out to be owned
|
|
// by another space. Rolling the savepoint back then leaves no CRDT
|
|
// metadata, released unique values, or foreign key attempts behind.
|
|
context.rows[rowKey] = await DatabaseUtil.runInTransactionOrSavepoint(
|
|
_db,
|
|
transaction,
|
|
(savepoint) async {
|
|
final planned = await _planMergeInsertValues(
|
|
insert,
|
|
data,
|
|
batchPlan,
|
|
presence,
|
|
savepoint,
|
|
);
|
|
final insertedRow = await _applyMergeInsertForMissingRow(
|
|
insert,
|
|
remoteNode,
|
|
incomingHlc,
|
|
planned.domain,
|
|
savepoint,
|
|
);
|
|
pendingWrites = planned.attempts;
|
|
return insertedRow;
|
|
},
|
|
);
|
|
// The row is now owned by the merging space (freshly inserted, or a
|
|
// same-space recovery), so same-merge field updates resolve ownership
|
|
// from the cache instead of re-reading the domain row.
|
|
context.domainOwners[rowKey] = (exists: true, spaceId: mergingSpaceId);
|
|
writes.recordInsertAttempts(
|
|
tableName: insert.tableName,
|
|
rowId: insert.uuidRowId,
|
|
node: remoteNode,
|
|
attempts: pendingWrites ?? const {},
|
|
);
|
|
// Written and visible, so the children arriving after it in this batch
|
|
// can point at it without asking the database again.
|
|
presence?.markVisible(rowKey);
|
|
} on _ForeignSpaceRowCollision catch (collision) {
|
|
await _throwOwnershipCollision(
|
|
operation: OfflineSyncViolationOperation.mergeInsert,
|
|
tableName: insert.tableName,
|
|
rowId: insert.uuidRowId,
|
|
owningSpaceId: collision.owningSpaceId,
|
|
incomingSpace: mergingSpace,
|
|
metadataRowId: null,
|
|
node: remoteNode,
|
|
hlc: insert.hlc,
|
|
transaction: transaction,
|
|
);
|
|
}
|
|
} else {
|
|
await _applyMergeInsertForExistingRow(
|
|
insert,
|
|
currentRow,
|
|
remoteNode,
|
|
incomingHlc,
|
|
data,
|
|
context,
|
|
authoredOverlays,
|
|
transaction,
|
|
);
|
|
// This one can resurrect a tombstoned row, so drop what was known.
|
|
presence?.forget(rowKey);
|
|
}
|
|
}
|
|
|
|
/// The domain values a merged insert can physically write, paired with the
|
|
/// authored values projection displaced.
|
|
///
|
|
/// The batch plan already resolved this row's claims against the rest of the
|
|
/// batch. Foreign key safety is folded in after it, so where safety forces a
|
|
/// null it is also the terminal cause for that column.
|
|
Future<({Map<String, Object?> domain, ProjectionAttemptsByField attempts})>
|
|
_planMergeInsertValues(
|
|
CrdtMergeInsert insert,
|
|
Map<String, Object?> data,
|
|
ProjectionPlan? batchPlan,
|
|
CrdtForeignKeyPresenceCache? presence,
|
|
Transaction transaction,
|
|
) async {
|
|
final fkSafe = await _foreignKeyProjector.safeIncomingData(
|
|
insert.tableName,
|
|
insert.uuidRowId,
|
|
data,
|
|
transaction,
|
|
presence: presence,
|
|
);
|
|
final forcedNullColumns = {
|
|
for (final MapEntry(key: columnName, value: value) in fkSafe.data.entries)
|
|
if (value == null) columnName,
|
|
};
|
|
final plannedDomain = {
|
|
...data,
|
|
...?batchPlan?.domain[(insert.tableName, insert.uuidRowId)],
|
|
for (final columnName in forcedNullColumns) columnName: null,
|
|
};
|
|
|
|
final attempts = <MergeFieldKey, ProjectionAttempt>{};
|
|
for (final MapEntry(key: columnName, value: authored) in data.entries) {
|
|
if (projectionValuesEqual(plannedDomain[columnName], authored)) continue;
|
|
|
|
final fieldKey = (insert.tableName, insert.uuidRowId, columnName);
|
|
final planReason = batchPlan?.reasons[fieldKey];
|
|
final foreignKeyReason = fkSafe.attempts[fieldKey]?.reason;
|
|
final reason = forcedNullColumns.contains(columnName)
|
|
? foreignKeyReason ?? planReason
|
|
: planReason ?? foreignKeyReason;
|
|
if (reason == null) {
|
|
throw StateError(
|
|
'Projection changed ${insert.tableName}.$columnName on row '
|
|
'${insert.uuidRowId} without recording why.',
|
|
);
|
|
}
|
|
attempts[fieldKey] = (value: authored, reason: reason);
|
|
}
|
|
|
|
return (domain: plannedDomain, attempts: attempts);
|
|
}
|
|
|
|
Future<void> _applyMergeUpdate(
|
|
CrdtMergeUpdate update,
|
|
Map<UuidValue, CrdtNode> remoteNodes,
|
|
MergeContext context,
|
|
Map<MergeFieldKey, Object?> authoredOverlays,
|
|
_MergeWriteBatch writes,
|
|
Transaction transaction,
|
|
) async {
|
|
final rowKey = (update.tableName, update.uuidRowId);
|
|
final row = context.rows[rowKey];
|
|
if (row == null) return;
|
|
final remoteNode = _requireRemoteNode(remoteNodes, update.uuidNodeId);
|
|
if (!await _domainRowOwnedByEffectiveSpace(
|
|
operation: OfflineSyncViolationOperation.mergeUpdate,
|
|
tableName: update.tableName,
|
|
rowId: update.uuidRowId,
|
|
context: context,
|
|
transaction: transaction,
|
|
metadataRowId: row.id,
|
|
node: remoteNode,
|
|
hlc: update.hlc,
|
|
)) {
|
|
return;
|
|
}
|
|
|
|
final shouldApply = await _shouldMergeFieldMetadataIfNewer(
|
|
tableName: update.tableName,
|
|
rowId: update.uuidRowId,
|
|
columnName: update.columnName,
|
|
row: row,
|
|
remoteNode: remoteNode,
|
|
incomingHlc: update.hlc,
|
|
fields: context.fields,
|
|
writes: writes,
|
|
transaction: transaction,
|
|
);
|
|
|
|
if (!shouldApply) return;
|
|
|
|
authoredOverlays[(update.tableName, update.uuidRowId, update.columnName)] =
|
|
update.value;
|
|
}
|
|
|
|
Future<void> _applyMergeDelete(
|
|
CrdtMergeDelete delete,
|
|
Map<UuidValue, CrdtNode> remoteNodes,
|
|
MergeContext context,
|
|
Transaction transaction,
|
|
) async {
|
|
final rowKey = (delete.tableName, delete.uuidRowId);
|
|
final row = context.rows[rowKey];
|
|
if (row == null) return;
|
|
if (!delete.reason.isSynced) return;
|
|
final remoteNode = _requireRemoteNode(remoteNodes, delete.uuidNodeId);
|
|
if (!await _domainRowOwnedByEffectiveSpace(
|
|
operation: OfflineSyncViolationOperation.mergeDelete,
|
|
tableName: delete.tableName,
|
|
rowId: delete.uuidRowId,
|
|
context: context,
|
|
transaction: transaction,
|
|
metadataRowId: row.id,
|
|
node: remoteNode,
|
|
hlc: delete.hlc,
|
|
)) {
|
|
return;
|
|
}
|
|
|
|
final currentTombstone = context.tombstones[rowKey];
|
|
final currentClFlag = currentTombstone?.clFlag ?? 1;
|
|
final currentHlc = currentTombstone?.hlc ?? row.hlc;
|
|
if (delete.clFlag < currentClFlag) return;
|
|
if (delete.clFlag == currentClFlag && delete.hlc <= currentHlc) return;
|
|
|
|
context.tombstones[rowKey] = await _upsertMergeTombstone(
|
|
row,
|
|
remoteNode,
|
|
delete.hlc,
|
|
delete.clFlag,
|
|
delete.reason,
|
|
currentTombstone,
|
|
transaction,
|
|
);
|
|
|
|
if (delete.reason.isSynced) {
|
|
await _context.applyRowVisibilityFromUserTombstone(
|
|
row,
|
|
context.tombstones[rowKey]!,
|
|
transaction,
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<CrdtDataRow> _upsertMergeRow(
|
|
String tableName,
|
|
UuidValue rowId,
|
|
CrdtNode remoteNode,
|
|
Hlc incomingHlc,
|
|
CrdtDataRow? currentRow,
|
|
Transaction transaction,
|
|
) async {
|
|
if (currentRow == null) {
|
|
final insertedRow = await CrdtDataRow.db.insertRow(
|
|
_session,
|
|
_newMergeRow(tableName, rowId, remoteNode, incomingHlc, transaction),
|
|
transaction: transaction,
|
|
);
|
|
|
|
return insertedRow.copyWith(node: remoteNode);
|
|
}
|
|
|
|
final updatedRow = currentRow.copyWith(
|
|
nodeId: remoteNode.id,
|
|
node: remoteNode,
|
|
hlcDatetime: incomingHlc.datetime,
|
|
hlcCounter: incomingHlc.counter,
|
|
);
|
|
|
|
await CrdtDataRow.db.update(
|
|
_session,
|
|
[updatedRow],
|
|
columns: (t) => [t.nodeId, t.hlcDatetime, t.hlcCounter],
|
|
transaction: transaction,
|
|
);
|
|
|
|
return updatedRow;
|
|
}
|
|
|
|
Future<CrdtDataField> _upsertMergeField(
|
|
CrdtDataRow row,
|
|
CrdtSchemaColumn column,
|
|
CrdtNode remoteNode,
|
|
Hlc incomingHlc,
|
|
CrdtDataField? currentField,
|
|
Transaction transaction, {
|
|
_MergeWriteBatch? writes,
|
|
}) async {
|
|
// Projection can create field metadata for a row this merge context loaded
|
|
// before the row existed, so an unseen field may still be persisted. Adopt
|
|
// it instead of inserting a second row onto the row/column unique index.
|
|
final existingField =
|
|
currentField ??
|
|
await CrdtDataField.db.findFirstRow(
|
|
_session,
|
|
where: (t) => t.rowId.equals(row.id) & t.columnId.equals(column.id),
|
|
transaction: transaction,
|
|
);
|
|
|
|
if (existingField == null) {
|
|
// Dependent metadata needs the field identity, so new fields cannot
|
|
// wait in the batch of existing field-clock updates.
|
|
final insertedField = await CrdtDataField.db.insertRow(
|
|
_session,
|
|
CrdtDataField(
|
|
rowId: row.id!,
|
|
columnId: column.id!,
|
|
nodeId: remoteNode.id!,
|
|
hlcDatetime: incomingHlc.datetime,
|
|
hlcCounter: incomingHlc.counter,
|
|
),
|
|
transaction: transaction,
|
|
);
|
|
|
|
return insertedField.copyWith(
|
|
row: row,
|
|
column: column,
|
|
node: remoteNode,
|
|
);
|
|
}
|
|
|
|
final updatedField = existingField.copyWith(
|
|
row: row,
|
|
column: column,
|
|
nodeId: remoteNode.id,
|
|
node: remoteNode,
|
|
hlcDatetime: incomingHlc.datetime,
|
|
hlcCounter: incomingHlc.counter,
|
|
);
|
|
|
|
if (writes != null) {
|
|
writes.recordFieldUpdate(updatedField);
|
|
} else {
|
|
await CrdtDataField.db.update(
|
|
_session,
|
|
[updatedField],
|
|
columns: (t) => [t.nodeId, t.hlcDatetime, t.hlcCounter],
|
|
transaction: transaction,
|
|
);
|
|
}
|
|
return updatedField;
|
|
}
|
|
|
|
Future<CrdtDataDeleted> _upsertMergeTombstone(
|
|
CrdtDataRow row,
|
|
CrdtNode remoteNode,
|
|
Hlc incomingHlc,
|
|
int clFlag,
|
|
CrdtDataDeletedReason reason,
|
|
CrdtDataDeleted? currentTombstone,
|
|
Transaction transaction,
|
|
) async {
|
|
if (currentTombstone == null) {
|
|
final insertedTombstone = await CrdtDataDeleted.db.insertRow(
|
|
_session,
|
|
CrdtDataDeleted(
|
|
rowId: row.id!,
|
|
nodeId: remoteNode.id!,
|
|
hlcDatetime: incomingHlc.datetime,
|
|
hlcCounter: incomingHlc.counter,
|
|
clFlag: clFlag,
|
|
reason: reason,
|
|
),
|
|
transaction: transaction,
|
|
);
|
|
|
|
return insertedTombstone.copyWith(
|
|
row: row,
|
|
node: remoteNode,
|
|
);
|
|
}
|
|
|
|
final updatedTombstone = currentTombstone.copyWith(
|
|
row: row,
|
|
nodeId: remoteNode.id,
|
|
node: remoteNode,
|
|
hlcDatetime: incomingHlc.datetime,
|
|
hlcCounter: incomingHlc.counter,
|
|
clFlag: clFlag,
|
|
reason: reason,
|
|
);
|
|
|
|
await CrdtDataDeleted.db.update(
|
|
_session,
|
|
[updatedTombstone],
|
|
columns: (t) => [t.nodeId, t.hlcDatetime, t.hlcCounter, t.clFlag, t.reason],
|
|
transaction: transaction,
|
|
);
|
|
|
|
return updatedTombstone;
|
|
}
|
|
|
|
Future<CrdtDataRow> _applyMergeInsertForMissingRow(
|
|
CrdtMergeInsert insert,
|
|
CrdtNode remoteNode,
|
|
Hlc incomingHlc,
|
|
Map<String, Object?> data,
|
|
Transaction transaction,
|
|
) async {
|
|
final mergingSpaceId = _context.hlcManagerFor(transaction).normalizedSpaceId;
|
|
final (row: spaceScopedRow, :explicitNulls) = _mergeInsertDomainRow(
|
|
insert,
|
|
data,
|
|
mergingSpaceId,
|
|
);
|
|
try {
|
|
// Domain insert first. A CRDT tracker row for this id, written before
|
|
// the domain row, made SQLite reject a later null unique-FK insert.
|
|
await DatabaseUtil.runInTransactionOrSavepoint(
|
|
_db,
|
|
transaction,
|
|
(savepoint) => _db.insertRow(
|
|
withExplicitInsertNulls(spaceScopedRow, explicitNulls),
|
|
transaction: savepoint,
|
|
),
|
|
);
|
|
} on DatabaseQueryException {
|
|
final owner = await _readDomainRowOwner(
|
|
insert.tableName,
|
|
insert.uuidRowId,
|
|
transaction,
|
|
);
|
|
// No row means the violation came from something other than the
|
|
// primary key (e.g. a racing unique value); preserve the failure.
|
|
if (!owner.exists) rethrow;
|
|
if (owner.spaceId != mergingSpaceId) {
|
|
throw _ForeignSpaceRowCollision(owner.spaceId);
|
|
}
|
|
|
|
// Same-space recovery (a tracked row whose metadata was lost, or a
|
|
// race between this space's own merges). spaceId is immutable and rows
|
|
// are only soft-deleted, so ownership observed here cannot change for
|
|
// the rest of the transaction and the update is safe.
|
|
await _db.updateRow(
|
|
spaceScopedRow,
|
|
columns: spaceScopedRow.table.managedColumns
|
|
.where((c) => data.containsKey(c.columnName))
|
|
.toList(),
|
|
transaction: transaction,
|
|
);
|
|
}
|
|
|
|
return _upsertMergeRow(
|
|
insert.tableName,
|
|
insert.uuidRowId,
|
|
remoteNode,
|
|
incomingHlc,
|
|
null,
|
|
transaction,
|
|
);
|
|
}
|
|
|
|
Future<void> _applyMergeInsertForExistingRow(
|
|
CrdtMergeInsert insert,
|
|
CrdtDataRow currentRow,
|
|
CrdtNode remoteNode,
|
|
Hlc incomingHlc,
|
|
Map<String, Object?> data,
|
|
MergeContext context,
|
|
Map<MergeFieldKey, Object?> authoredOverlays,
|
|
Transaction transaction,
|
|
) async {
|
|
if (!await _domainRowOwnedByEffectiveSpace(
|
|
operation: OfflineSyncViolationOperation.mergeInsert,
|
|
tableName: insert.tableName,
|
|
rowId: insert.uuidRowId,
|
|
context: context,
|
|
transaction: transaction,
|
|
metadataRowId: currentRow.id,
|
|
node: remoteNode,
|
|
hlc: insert.hlc,
|
|
)) {
|
|
return;
|
|
}
|
|
|
|
final (_, columnsByName) = _context.schema[insert.tableName]!;
|
|
for (final MapEntry(key: columnName, value: schemaColumn)
|
|
in columnsByName.entries) {
|
|
if (columnName == 'id') continue;
|
|
|
|
final shouldApply = await _shouldMergeFieldMetadataIfNewer(
|
|
tableName: insert.tableName,
|
|
rowId: insert.uuidRowId,
|
|
columnName: columnName,
|
|
row: currentRow,
|
|
remoteNode: remoteNode,
|
|
incomingHlc: incomingHlc,
|
|
fields: context.fields,
|
|
transaction: transaction,
|
|
schemaColumn: schemaColumn,
|
|
);
|
|
|
|
if (!shouldApply) continue;
|
|
authoredOverlays[(insert.tableName, insert.uuidRowId, columnName)] =
|
|
data[columnName];
|
|
}
|
|
|
|
final rowKey = (insert.tableName, insert.uuidRowId);
|
|
final currentTombstone = context.tombstones[rowKey];
|
|
if ((currentTombstone?.clFlag ?? 1) == 1 &&
|
|
incomingHlc > (currentTombstone?.hlc ?? currentRow.hlc)) {
|
|
context.tombstones[rowKey] = await _upsertMergeTombstone(
|
|
currentRow,
|
|
remoteNode,
|
|
incomingHlc,
|
|
1,
|
|
CrdtDataDeletedReason.userInsert,
|
|
currentTombstone,
|
|
transaction,
|
|
);
|
|
await _context.applyRowVisibilityFromUserTombstone(
|
|
currentRow,
|
|
context.tombstones[rowKey]!,
|
|
transaction,
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<bool> _shouldMergeFieldMetadataIfNewer({
|
|
required String tableName,
|
|
required UuidValue rowId,
|
|
required String columnName,
|
|
required CrdtDataRow row,
|
|
required CrdtNode remoteNode,
|
|
required Hlc incomingHlc,
|
|
required Map<MergeFieldKey, CrdtDataField> fields,
|
|
required Transaction transaction,
|
|
CrdtSchemaColumn? schemaColumn,
|
|
_MergeWriteBatch? writes,
|
|
}) async {
|
|
final resolvedSchemaColumn =
|
|
schemaColumn ?? _context.schemaColumn(tableName, columnName);
|
|
if (resolvedSchemaColumn == null) return false;
|
|
|
|
final fieldKey = (tableName, rowId, columnName);
|
|
final currentField = fields[fieldKey];
|
|
final currentHlc = currentField?.hlc ?? row.hlc;
|
|
if (incomingHlc <= currentHlc) return false;
|
|
|
|
fields[fieldKey] = await _upsertMergeField(
|
|
row,
|
|
resolvedSchemaColumn,
|
|
remoteNode,
|
|
incomingHlc,
|
|
currentField,
|
|
transaction,
|
|
writes: writes,
|
|
);
|
|
return true;
|
|
}
|
|
|
|
TableRow _requireMergeInsertTableRow(CrdtMergeInsert insert) {
|
|
final row = insert.data;
|
|
if (row is TableRow) return row;
|
|
|
|
throw StateError(
|
|
'Unsupported merge insert payload type for "${insert.tableName}": '
|
|
'${row.runtimeType}. Expected subclass of TableRow.',
|
|
);
|
|
}
|
|
|
|
CrdtNode _requireRemoteNode(
|
|
Map<UuidValue, CrdtNode> remoteNodes,
|
|
UuidValue uuidNodeId,
|
|
) {
|
|
return remoteNodes[uuidNodeId] ??
|
|
(throw StateError(
|
|
'Remote node $uuidNodeId not found for merge.',
|
|
));
|
|
}
|
|
|
|
Map<String, Object?> _sanitizeMergeRowData(
|
|
String tableName,
|
|
Map<String, Object?> data,
|
|
) {
|
|
final columns = _context.columnsByTableAndName[tableName];
|
|
if (columns == null) return {};
|
|
|
|
return {
|
|
for (final MapEntry(key: columnName, value: value) in data.entries)
|
|
if (columnName != 'id' &&
|
|
columnName != 'spaceId' &&
|
|
columns.containsKey(columnName))
|
|
columnName: columns[columnName]!.columnType == ColumnType.uuid
|
|
? value.toUuidValue()
|
|
: value,
|
|
};
|
|
}
|
|
|
|
Future<DomainRowOwner> _readDomainRowOwner(
|
|
String tableName,
|
|
UuidValue rowId,
|
|
Transaction transaction,
|
|
) async {
|
|
final values = await _context.readDomainColumnValues(
|
|
tableName,
|
|
{rowId},
|
|
['spaceId'],
|
|
transaction,
|
|
);
|
|
final row = values[rowId];
|
|
if (row == null) return (exists: false, spaceId: null);
|
|
return (exists: true, spaceId: row['spaceId'] as int?);
|
|
}
|
|
|
|
/// Returns the owning space of [rowId] from the merge snapshot taken in
|
|
/// [_loadMergeContext], reading the domain row only if it was not captured
|
|
/// there (e.g. a row a concurrent operation added since the snapshot).
|
|
Future<DomainRowOwner> _resolveDomainOwner(
|
|
MergeContext context,
|
|
String tableName,
|
|
UuidValue rowId,
|
|
Transaction transaction,
|
|
) async {
|
|
final key = (tableName, rowId);
|
|
final cached = context.domainOwners[key];
|
|
if (cached != null) return cached;
|
|
final owner = await _readDomainRowOwner(tableName, rowId, transaction);
|
|
context.domainOwners[key] = owner;
|
|
return owner;
|
|
}
|
|
|
|
Future<bool> _domainRowOwnedByEffectiveSpace({
|
|
required OfflineSyncViolationOperation operation,
|
|
required String tableName,
|
|
required UuidValue rowId,
|
|
required MergeContext context,
|
|
required Transaction transaction,
|
|
int? metadataRowId,
|
|
CrdtNode? node,
|
|
Hlc? hlc,
|
|
}) async {
|
|
final owner = await _resolveDomainOwner(context, tableName, rowId, transaction);
|
|
final mergingSpace = _context.effectiveSpaceFor(transaction);
|
|
if (owner.exists && owner.spaceId == mergingSpace.id) return true;
|
|
if (!owner.exists) return false;
|
|
|
|
await _throwOwnershipCollision(
|
|
operation: operation,
|
|
tableName: tableName,
|
|
rowId: rowId,
|
|
owningSpaceId: owner.spaceId,
|
|
incomingSpace: mergingSpace,
|
|
metadataRowId: metadataRowId,
|
|
node: node,
|
|
hlc: hlc,
|
|
transaction: transaction,
|
|
);
|
|
}
|
|
|
|
Future<Never> _throwOwnershipCollision({
|
|
required OfflineSyncViolationOperation operation,
|
|
required String tableName,
|
|
required UuidValue rowId,
|
|
required int? owningSpaceId,
|
|
required OfflineSyncSpace incomingSpace,
|
|
required int? metadataRowId,
|
|
required Transaction transaction,
|
|
CrdtNode? node,
|
|
Hlc? hlc,
|
|
}) async {
|
|
final now = clock.now().toUtc();
|
|
throw OfflineSyncIntegrityViolationException(
|
|
OfflineSyncIntegrityViolation(
|
|
type: OfflineSyncViolationType.ownershipCollision,
|
|
domainTableName: tableName,
|
|
uuidRowId: rowId,
|
|
ownerSpaceUuid: await _spaceUuidForNormalizedId(owningSpaceId, transaction),
|
|
incomingSpaceUuid: incomingSpace.uuidSpaceId,
|
|
operation: operation,
|
|
uuidNodeId: node?.uuidNodeId,
|
|
crdtDataRowId: metadataRowId,
|
|
hlcDatetime: hlc?.datetime,
|
|
hlcCounter: hlc?.counter,
|
|
firstSeenAt: now,
|
|
lastSeenAt: now,
|
|
occurrences: 1,
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<UuidValue?> _spaceUuidForNormalizedId(
|
|
int? spaceId,
|
|
Transaction transaction,
|
|
) async {
|
|
if (spaceId == null) return null;
|
|
|
|
final space = await OfflineSyncSpace.db.findById(
|
|
_session,
|
|
spaceId,
|
|
transaction: transaction,
|
|
);
|
|
return space?.uuidSpaceId;
|
|
}
|
|
}
|
|
|
|
/// Signals that a merge insert lost a primary-key race to a row owned by
|
|
/// another space, so the savepoint around the insert application must be
|
|
/// rolled back before the sync violation is recorded and thrown.
|
|
class _ForeignSpaceRowCollision implements Exception {
|
|
_ForeignSpaceRowCollision(this.owningSpaceId);
|
|
|
|
final int? owningSpaceId;
|
|
}
|