part of 'recorder.dart'; /// Adds merge-specific behavior to [CrdtMutationRecorder]. extension CrdtMergeRecorderExtension on CrdtMutationRecorder { /// Merges remote CRDT changes into the current database. Future 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 = {}; 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 = {}; final touchedRows = {}; 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 []); // 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 = []; // 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 {} : 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 _updateHlcFromIncomingOperations( List operations, MergeNodes remoteNodes, Transaction transaction, ) async { final maxIncomingHlc = operations.fold( null, (current, change) => change.hlc.maxBetween(current), ); final spaceNodesToUpdate = []; 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 = {}; 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 _findOrCreateNodesForMerge( int userId, Set nodeIds, Transaction transaction, ) async { if (nodeIds.isEmpty) { return ( nodesByUuid: const {}, spaceNodesByUuid: const {}, ); } 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 _loadMergeContext( CrdtMergeSet mergeSet, Transaction transaction, ) async { final (:rowIdsByTable, :columnNamesByTable) = mergeSet.collectMetadataLookup( columnNamesByTableName: _context.syncedTableColumnNamesForMerge, ); final rows = {}; final fields = {}; final incomingFieldHlcs = { for (final update in mergeSet.whereType()) (update.tableName, update.uuidRowId, update.columnName): update.hlc, }; final tombstones = {}; // 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 = {}; 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().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 pending})> _planBatchInserts( List operations, Map remoteNodes, MergeContext context, Set seedTables, Set 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 = []; final seenRowKeys = {}; 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 _mergeLeavesProjectionUnchanged( List operations, MergeContext context, Transaction transaction, ) async { final insertsByTable = >>{}; final positions = {}; final deletesByTable = >{}; 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> _applyNewRowInsertsInChunks( List operations, Map remoteNodes, MergeContext context, _MergeWriteBatch writes, CrdtForeignKeyPresenceCache? presence, Transaction transaction, ) async { final touches = {}; 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 = {}; // 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 = {}; final mergingSpaceId = _context.hlcManagerFor(transaction).normalizedSpaceId; var chunk = _NewRowChunk(); Future 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 []) { 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 _writeNewRowChunk( _NewRowChunk chunk, MergeContext context, _MergeWriteBatch writes, Transaction transaction, ) async { if (chunk.isEmpty) return true; final Map trackers; try { trackers = await DatabaseUtil.runInTransactionOrSavepoint( _db, transaction, (savepoint) async { for (final tableName in chunk.tablesInWriteOrder) { final rows = chunk.rowsByTable[tableName]!; await insertWithExplicitNulls( _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 = {}; 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? _rowsNamedByMergeInsert( String tableName, Map data, ) { final named = {}; for (final edge in _foreignKeys.edgesByChildTable[tableName] ?? const []) { 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 explicitNulls}) _mergeInsertDomainRow( CrdtMergeInsert insert, Map 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 _applyMergeInsert( CrdtMergeInsert insert, Map remoteNodes, MergeContext context, Map 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 domain, ProjectionAttemptsByField attempts})> _planMergeInsertValues( CrdtMergeInsert insert, Map 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 = {}; 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 _applyMergeUpdate( CrdtMergeUpdate update, Map remoteNodes, MergeContext context, Map 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 _applyMergeDelete( CrdtMergeDelete delete, Map 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 _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 _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 _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 _applyMergeInsertForMissingRow( CrdtMergeInsert insert, CrdtNode remoteNode, Hlc incomingHlc, Map 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 _applyMergeInsertForExistingRow( CrdtMergeInsert insert, CrdtDataRow currentRow, CrdtNode remoteNode, Hlc incomingHlc, Map data, MergeContext context, Map 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 _shouldMergeFieldMetadataIfNewer({ required String tableName, required UuidValue rowId, required String columnName, required CrdtDataRow row, required CrdtNode remoteNode, required Hlc incomingHlc, required Map 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 remoteNodes, UuidValue uuidNodeId, ) { return remoteNodes[uuidNodeId] ?? (throw StateError( 'Remote node $uuidNodeId not found for merge.', )); } Map _sanitizeMergeRowData( String tableName, Map 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 _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 _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 _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 _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 _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; }