2026-05-07 00:42:23 -03:00
|
|
|
part of 'recorder.dart';
|
|
|
|
|
|
2026-09-04 15:37:09 -03:00
|
|
|
/// Field metadata for merged inserts, waiting to be written as one batch.
|
|
|
|
|
///
|
|
|
|
|
/// Every query behind [CrdtForeignKeyProjector.recordInsertAttempts] already
|
|
|
|
|
/// takes a set of row ids, so a batch of inserts into one table costs the same
|
|
|
|
|
/// as one insert. Rows are grouped by node as well as table because the merge
|
|
|
|
|
/// cache stores each field with the node that authored it.
|
|
|
|
|
class _PendingInsertAttempts {
|
|
|
|
|
final Map<(String, int), ({Set<UuidValue> rowIds, CrdtNode node})> _byGroup = {};
|
|
|
|
|
final Map<String, ProjectionAttemptsByField> _attemptsByTable = {};
|
|
|
|
|
final Set<MergeRowKey> _rows = {};
|
|
|
|
|
|
|
|
|
|
bool get isEmpty => _byGroup.isEmpty;
|
|
|
|
|
|
|
|
|
|
/// Whether metadata for [rowKey] is still unwritten.
|
|
|
|
|
bool holds(MergeRowKey rowKey) => _rows.contains(rowKey);
|
|
|
|
|
|
|
|
|
|
void add({
|
|
|
|
|
required String tableName,
|
|
|
|
|
required UuidValue rowId,
|
|
|
|
|
required CrdtNode node,
|
|
|
|
|
required ProjectionAttemptsByField attempts,
|
|
|
|
|
}) {
|
|
|
|
|
_byGroup
|
|
|
|
|
.putIfAbsent(
|
|
|
|
|
(tableName, node.id!),
|
|
|
|
|
() => (rowIds: <UuidValue>{}, node: node),
|
|
|
|
|
)
|
|
|
|
|
.rowIds
|
|
|
|
|
.add(rowId);
|
|
|
|
|
_rows.add((tableName, rowId));
|
|
|
|
|
if (attempts.isEmpty) return;
|
|
|
|
|
_attemptsByTable
|
|
|
|
|
.putIfAbsent(tableName, () => <MergeFieldKey, ProjectionAttempt>{})
|
|
|
|
|
.addAll(attempts);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Empties the collector and returns what it held.
|
|
|
|
|
List<
|
|
|
|
|
({
|
|
|
|
|
String tableName,
|
|
|
|
|
Set<UuidValue> rowIds,
|
|
|
|
|
CrdtNode node,
|
|
|
|
|
ProjectionAttemptsByField attempts,
|
|
|
|
|
})
|
|
|
|
|
>
|
|
|
|
|
take() {
|
|
|
|
|
final groups = [
|
|
|
|
|
for (final MapEntry(key: key, value: group) in _byGroup.entries)
|
|
|
|
|
(
|
|
|
|
|
tableName: key.$1,
|
|
|
|
|
rowIds: group.rowIds,
|
|
|
|
|
node: group.node,
|
|
|
|
|
attempts:
|
|
|
|
|
_attemptsByTable[key.$1] ?? const <MergeFieldKey, ProjectionAttempt>{},
|
|
|
|
|
),
|
|
|
|
|
];
|
|
|
|
|
_byGroup.clear();
|
|
|
|
|
_attemptsByTable.clear();
|
|
|
|
|
_rows.clear();
|
|
|
|
|
return groups;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-07 00:42:23 -03:00
|
|
|
/// Adds merge-specific behavior to [CrdtMutationRecorder].
|
|
|
|
|
extension CrdtMergeRecorderExtension on CrdtMutationRecorder {
|
|
|
|
|
/// Locks the current user row so merges can serialize with other work.
|
|
|
|
|
Future<void> lockCurrentUser(Transaction transaction) async {
|
2026-09-14 13:39:07 -03:00
|
|
|
final user = _context.effectiveSpaceFor(transaction);
|
2026-05-07 00:42:23 -03:00
|
|
|
// Use a row lock without fetching the record since the merge path only
|
|
|
|
|
// needs serialization against concurrent work for the same user.
|
2026-09-14 13:39:07 -03:00
|
|
|
await OfflineSyncSpace.db.lockRows(
|
2026-05-07 00:42:23 -03:00
|
|
|
_session,
|
|
|
|
|
where: (t) => t.id.equals(user.id),
|
|
|
|
|
transaction: transaction,
|
|
|
|
|
lockMode: LockMode.forUpdate,
|
|
|
|
|
lockBehavior: LockBehavior.wait,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Merges remote CRDT changes into the current database.
|
|
|
|
|
Future<void> mergeChanges(
|
|
|
|
|
CrdtMergeSet mergeSet,
|
|
|
|
|
Transaction transaction,
|
|
|
|
|
) async {
|
|
|
|
|
if (mergeSet.isEmpty) return;
|
|
|
|
|
|
|
|
|
|
final operations = mergeSet.causallyOrderedChanges;
|
2026-09-04 15:37:09 -03:00
|
|
|
// 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.
|
|
|
|
|
final mayAffectProjection = _foreignKeyProjector.mergeOperationsMayAffectProjection(
|
|
|
|
|
operations,
|
|
|
|
|
);
|
|
|
|
|
final authoredOverlays = <MergeFieldKey, Object?>{};
|
2026-09-14 13:39:07 -03:00
|
|
|
final currentUser = _context.effectiveSpaceFor(transaction);
|
2026-05-07 00:42:23 -03:00
|
|
|
final remoteNodes = await _findOrCreateNodesForMerge(
|
|
|
|
|
currentUser.id!,
|
|
|
|
|
{for (final change in operations) change.uuidNodeId},
|
|
|
|
|
transaction,
|
|
|
|
|
);
|
2026-07-03 01:25:15 -03:00
|
|
|
final nodesByUuid = remoteNodes.nodesByUuid;
|
2026-06-06 00:55:10 -03:00
|
|
|
final context = await _loadMergeContext(mergeSet, transaction);
|
2026-05-07 00:42:23 -03:00
|
|
|
|
2026-09-04 15:37:09 -03:00
|
|
|
// 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
|
|
|
|
|
? await _planBatchInserts(
|
|
|
|
|
operations,
|
|
|
|
|
nodesByUuid,
|
|
|
|
|
context,
|
|
|
|
|
touchedTables,
|
|
|
|
|
touchedRows,
|
|
|
|
|
transaction,
|
|
|
|
|
)
|
|
|
|
|
: (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,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Field metadata for merged inserts is recorded per table and node rather
|
|
|
|
|
// than per row: every query behind it already takes a set of row ids.
|
|
|
|
|
final pendingAttempts = _PendingInsertAttempts();
|
|
|
|
|
|
2026-05-07 00:42:23 -03:00
|
|
|
for (final operation in operations) {
|
2026-08-07 13:01:29 -03:00
|
|
|
if (!_context.isCrdtTrackedTableName(operation.tableName)) {
|
2026-05-07 00:42:23 -03:00
|
|
|
continue;
|
|
|
|
|
}
|
2026-09-04 15:37:09 -03:00
|
|
|
// A later operation on a row resolves its field metadata through the
|
|
|
|
|
// merge cache, so anything still pending for that row is written first.
|
|
|
|
|
if (pendingAttempts.holds((operation.tableName, operation.uuidRowId))) {
|
|
|
|
|
await _flushInsertAttempts(pendingAttempts, context, transaction);
|
|
|
|
|
}
|
2026-05-07 00:42:23 -03:00
|
|
|
|
|
|
|
|
switch (operation) {
|
|
|
|
|
case final CrdtMergeInsert insert:
|
|
|
|
|
await _applyMergeInsert(
|
|
|
|
|
insert,
|
2026-07-03 01:25:15 -03:00
|
|
|
nodesByUuid,
|
2026-06-06 00:55:10 -03:00
|
|
|
context,
|
2026-09-04 15:37:09 -03:00
|
|
|
authoredOverlays,
|
|
|
|
|
batch.plan,
|
|
|
|
|
pendingAttempts,
|
|
|
|
|
presence,
|
2026-05-07 00:42:23 -03:00
|
|
|
transaction,
|
|
|
|
|
);
|
|
|
|
|
case final CrdtMergeUpdate update:
|
|
|
|
|
await _applyMergeUpdate(
|
|
|
|
|
update,
|
2026-07-03 01:25:15 -03:00
|
|
|
nodesByUuid,
|
2026-06-06 00:55:10 -03:00
|
|
|
context,
|
2026-09-04 15:37:09 -03:00
|
|
|
authoredOverlays,
|
2026-05-07 00:42:23 -03:00
|
|
|
transaction,
|
|
|
|
|
);
|
|
|
|
|
case final CrdtMergeDelete delete:
|
|
|
|
|
await _applyMergeDelete(
|
|
|
|
|
delete,
|
2026-07-03 01:25:15 -03:00
|
|
|
nodesByUuid,
|
2026-06-06 00:55:10 -03:00
|
|
|
context,
|
2026-05-07 00:42:23 -03:00
|
|
|
transaction,
|
|
|
|
|
);
|
2026-09-04 15:37:09 -03:00
|
|
|
// 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));
|
2026-05-07 00:42:23 -03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-04 15:37:09 -03:00
|
|
|
await _flushInsertAttempts(pendingAttempts, 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,
|
|
|
|
|
);
|
2026-06-10 17:31:05 -03:00
|
|
|
}
|
2026-05-29 01:54:23 -03:00
|
|
|
await _updateHlcFromIncomingOperations(
|
|
|
|
|
operations,
|
|
|
|
|
remoteNodes,
|
|
|
|
|
transaction,
|
|
|
|
|
);
|
2026-05-07 00:42:23 -03:00
|
|
|
}
|
|
|
|
|
|
2026-05-29 01:54:23 -03:00
|
|
|
Future<void> _updateHlcFromIncomingOperations(
|
2026-05-07 00:42:23 -03:00
|
|
|
List<CrdtMergeChange> operations,
|
2026-08-07 13:01:29 -03:00
|
|
|
MergeNodes remoteNodes,
|
2026-05-07 00:42:23 -03:00
|
|
|
Transaction transaction,
|
2026-05-29 01:54:23 -03:00
|
|
|
) async {
|
2026-05-07 00:42:23 -03:00
|
|
|
final maxIncomingHlc = operations.fold<Hlc?>(
|
|
|
|
|
null,
|
2026-05-29 01:54:23 -03:00
|
|
|
(current, change) => change.hlc.maxBetween(current),
|
2026-05-07 00:42:23 -03:00
|
|
|
);
|
2026-09-14 13:39:07 -03:00
|
|
|
final spaceNodesToUpdate = <OfflineSyncSpaceNode>[];
|
2026-05-29 01:54:23 -03:00
|
|
|
|
2026-05-07 00:42:23 -03:00
|
|
|
if (maxIncomingHlc != null) {
|
2026-08-07 13:01:29 -03:00
|
|
|
final hlcManager = _context.hlcManagerFor(transaction);
|
2026-05-07 00:42:23 -03:00
|
|
|
if (maxIncomingHlc.nodeId == hlcManager.uuidNodeId) {
|
|
|
|
|
if (maxIncomingHlc > hlcManager.lastHlc) {
|
|
|
|
|
hlcManager.lastHlc = maxIncomingHlc;
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
hlcManager.merge(maxIncomingHlc);
|
|
|
|
|
}
|
2026-05-29 01:54:23 -03:00
|
|
|
|
2026-08-07 13:01:29 -03:00
|
|
|
await _context.persistCurrentNodeHlc(hlcManager, transaction);
|
2026-05-29 01:54:23 -03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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) {
|
2026-09-14 13:39:07 -03:00
|
|
|
final remoteNode = remoteNodes.spaceNodesByUuid[nodeId];
|
2026-05-29 01:54:23 -03:00
|
|
|
if (remoteNode == null) continue;
|
2026-09-14 13:39:07 -03:00
|
|
|
final updatedSpaceNode = remoteNode.copyWith(
|
2026-05-29 01:54:23 -03:00
|
|
|
lastReceivedHlc: incomingHlc.maxBetween(remoteNode.lastReceivedHlc),
|
|
|
|
|
);
|
2026-09-14 13:39:07 -03:00
|
|
|
if (updatedSpaceNode.lastReceivedHlc == remoteNode.lastReceivedHlc) {
|
2026-07-03 01:25:15 -03:00
|
|
|
continue;
|
|
|
|
|
}
|
2026-05-29 01:54:23 -03:00
|
|
|
|
2026-09-14 13:39:07 -03:00
|
|
|
spaceNodesToUpdate.add(updatedSpaceNode);
|
|
|
|
|
remoteNodes.spaceNodesByUuid[nodeId] = updatedSpaceNode;
|
2026-05-29 01:54:23 -03:00
|
|
|
}
|
|
|
|
|
|
2026-09-14 13:39:07 -03:00
|
|
|
if (spaceNodesToUpdate.isNotEmpty) {
|
|
|
|
|
await OfflineSyncSpaceNode.db.update(
|
2026-05-29 01:54:23 -03:00
|
|
|
_session,
|
2026-09-14 13:39:07 -03:00
|
|
|
spaceNodesToUpdate,
|
2026-05-29 01:54:23 -03:00
|
|
|
columns: (t) => [t.lastReceivedHlc],
|
|
|
|
|
transaction: transaction,
|
|
|
|
|
);
|
2026-05-07 00:42:23 -03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-07 13:01:29 -03:00
|
|
|
Future<MergeNodes> _findOrCreateNodesForMerge(
|
2026-05-07 00:42:23 -03:00
|
|
|
int userId,
|
|
|
|
|
Set<UuidValue> nodeIds,
|
|
|
|
|
Transaction transaction,
|
|
|
|
|
) async {
|
2026-07-03 01:25:15 -03:00
|
|
|
if (nodeIds.isEmpty) {
|
|
|
|
|
return (
|
|
|
|
|
nodesByUuid: const <UuidValue, CrdtNode>{},
|
2026-09-14 13:39:07 -03:00
|
|
|
spaceNodesByUuid: const <UuidValue, OfflineSyncSpaceNode>{},
|
2026-05-07 00:42:23 -03:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-03 01:25:15 -03:00
|
|
|
final nodesByUuid = {
|
|
|
|
|
for (final uuidNodeId in nodeIds)
|
2026-08-07 13:01:29 -03:00
|
|
|
uuidNodeId: await _context.findOrCreateNode(uuidNodeId, transaction),
|
2026-05-07 00:42:23 -03:00
|
|
|
};
|
2026-07-03 01:25:15 -03:00
|
|
|
|
2026-09-14 13:39:07 -03:00
|
|
|
final spaceNodesByUuid = {
|
2026-07-03 01:25:15 -03:00
|
|
|
for (final entry in nodesByUuid.entries)
|
2026-09-14 13:39:07 -03:00
|
|
|
entry.key: await _context.findOrCreateSpaceNode(
|
2026-07-03 01:25:15 -03:00
|
|
|
userId,
|
|
|
|
|
entry.value.id!,
|
|
|
|
|
transaction,
|
|
|
|
|
),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
nodesByUuid: nodesByUuid,
|
2026-09-14 13:39:07 -03:00
|
|
|
spaceNodesByUuid: spaceNodesByUuid,
|
2026-07-03 01:25:15 -03:00
|
|
|
);
|
2026-05-07 00:42:23 -03:00
|
|
|
}
|
|
|
|
|
|
2026-08-07 13:01:29 -03:00
|
|
|
Future<MergeContext> _loadMergeContext(
|
2026-05-07 00:42:23 -03:00
|
|
|
CrdtMergeSet mergeSet,
|
|
|
|
|
Transaction transaction,
|
|
|
|
|
) async {
|
|
|
|
|
final (:rowIdsByTable, :columnNamesByTable) = mergeSet.collectMetadataLookup(
|
2026-08-07 13:01:29 -03:00
|
|
|
columnNamesByTableName: _context.syncedTableColumnNamesForMerge,
|
2026-05-07 00:42:23 -03:00
|
|
|
);
|
|
|
|
|
|
2026-08-07 13:01:29 -03:00
|
|
|
final rows = <MergeRowKey, CrdtDataRow>{};
|
|
|
|
|
final fields = <MergeFieldKey, CrdtDataField>{};
|
2026-06-06 00:55:10 -03:00
|
|
|
final incomingFieldHlcs = {
|
|
|
|
|
for (final update in mergeSet.whereType<CrdtMergeUpdate>())
|
|
|
|
|
(update.tableName, update.uuidRowId, update.columnName): update.hlc,
|
|
|
|
|
};
|
2026-08-07 13:01:29 -03:00
|
|
|
final tombstones = <MergeRowKey, CrdtDataDeleted>{};
|
2026-09-14 13:39:07 -03:00
|
|
|
// Owning space per row, snapshotted once so the per-operation ownership
|
|
|
|
|
// checks never re-read the same immutable `spaceId`. Only rows that
|
2026-06-18 01:05:24 -03:00
|
|
|
// 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.
|
2026-08-07 13:01:29 -03:00
|
|
|
final domainOwners = <MergeRowKey, DomainRowOwner>{};
|
2026-05-07 00:42:23 -03:00
|
|
|
|
|
|
|
|
for (final MapEntry(key: tableName, value: rowIds) in rowIdsByTable.entries) {
|
2026-08-07 13:01:29 -03:00
|
|
|
final loadedRows = await _context.findCrdtRows(
|
2026-05-07 00:42:23 -03:00
|
|
|
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!;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-18 01:05:24 -03:00
|
|
|
final trackedRowIds = loadedRows.map((row) => row.uuidRowId).toSet();
|
|
|
|
|
if (trackedRowIds.isNotEmpty) {
|
2026-08-07 13:01:29 -03:00
|
|
|
final ownerValues = await _context.readDomainColumnValues(
|
2026-06-18 01:05:24 -03:00
|
|
|
tableName,
|
|
|
|
|
trackedRowIds,
|
2026-09-14 13:39:07 -03:00
|
|
|
['spaceId'],
|
2026-06-18 01:05:24 -03:00
|
|
|
transaction,
|
|
|
|
|
);
|
|
|
|
|
for (final rowId in trackedRowIds) {
|
|
|
|
|
final values = ownerValues[rowId];
|
|
|
|
|
domainOwners[(tableName, rowId)] = values == null
|
2026-09-14 13:39:07 -03:00
|
|
|
? (exists: false, spaceId: null)
|
|
|
|
|
: (exists: true, spaceId: values['spaceId'] as int?);
|
2026-06-18 01:05:24 -03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-07 00:42:23 -03:00
|
|
|
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.',
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-07 13:01:29 -03:00
|
|
|
final columnIdsByName = _context.schemaColumnIds(tableName, columnNames);
|
2026-06-10 17:31:05 -03:00
|
|
|
if (columnIdsByName.isEmpty) continue;
|
2026-05-07 00:42:23 -03:00
|
|
|
|
|
|
|
|
final loadedFields = await CrdtDataField.db.find(
|
|
|
|
|
_session,
|
2026-06-10 17:31:05 -03:00
|
|
|
where: (t) =>
|
|
|
|
|
t.rowId.inSet(rowPks) & t.columnId.inSet(columnIdsByName.values.toSet()),
|
2026-05-07 00:42:23 -03:00
|
|
|
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,
|
2026-06-06 00:55:10 -03:00
|
|
|
incomingFieldHlcs: incomingFieldHlcs,
|
2026-05-07 00:42:23 -03:00
|
|
|
tombstones: tombstones,
|
2026-06-18 01:05:24 -03:00
|
|
|
domainOwners: domainOwners,
|
2026-05-07 00:42:23 -03:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-04 15:37:09 -03:00
|
|
|
/// 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,
|
|
|
|
|
) 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) return (plan: null, pending: pending);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
plan: await _foreignKeyProjector.project(
|
|
|
|
|
transaction,
|
|
|
|
|
pendingInserts: pending,
|
|
|
|
|
seedTables: seedTables,
|
|
|
|
|
seedRows: seedRows,
|
|
|
|
|
),
|
|
|
|
|
pending: pending,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-07 00:42:23 -03:00
|
|
|
Future<void> _applyMergeInsert(
|
|
|
|
|
CrdtMergeInsert insert,
|
|
|
|
|
Map<UuidValue, CrdtNode> remoteNodes,
|
2026-08-07 13:01:29 -03:00
|
|
|
MergeContext context,
|
2026-09-04 15:37:09 -03:00
|
|
|
Map<MergeFieldKey, Object?> authoredOverlays,
|
|
|
|
|
ProjectionPlan? batchPlan,
|
|
|
|
|
_PendingInsertAttempts pendingAttempts,
|
|
|
|
|
CrdtForeignKeyPresenceCache? presence,
|
2026-05-07 00:42:23 -03:00
|
|
|
Transaction transaction,
|
|
|
|
|
) async {
|
|
|
|
|
final rowKey = (insert.tableName, insert.uuidRowId);
|
|
|
|
|
final incomingHlc = insert.hlc;
|
|
|
|
|
final remoteNode = _requireRemoteNode(remoteNodes, insert.uuidNodeId);
|
|
|
|
|
|
2026-06-06 00:55:10 -03:00
|
|
|
final currentRow = context.rows[rowKey];
|
2026-05-07 00:42:23 -03:00
|
|
|
if (currentRow != null && incomingHlc <= currentRow.hlc) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-04 15:37:09 -03:00
|
|
|
// Set inside the savepoint and read after it, so a rolled back insert
|
|
|
|
|
// records nothing.
|
|
|
|
|
ProjectionAttemptsByField? pendingWrites;
|
|
|
|
|
|
2026-05-07 00:42:23 -03:00
|
|
|
final data = _sanitizeMergeRowData(insert.tableName, insert.databaseColumns);
|
|
|
|
|
|
|
|
|
|
if (currentRow == null) {
|
2026-09-14 13:39:07 -03:00
|
|
|
final mergingSpace = _context.effectiveSpaceFor(transaction);
|
|
|
|
|
final mergingSpaceId = mergingSpace.id!;
|
2026-06-18 01:05:24 -03:00
|
|
|
try {
|
|
|
|
|
// Insert-first inside a savepoint: the domain primary key resolves
|
|
|
|
|
// creation races atomically, and `_applyMergeInsertForMissingRow`
|
2026-09-14 13:39:07 -03:00
|
|
|
// raises `_ForeignSpaceRowCollision` when the row turns out to be owned
|
|
|
|
|
// by another space. Rolling the savepoint back then leaves no CRDT
|
2026-06-18 01:05:24 -03:00
|
|
|
// metadata, released unique values, or foreign key attempts behind.
|
|
|
|
|
context.rows[rowKey] = await DatabaseUtil.runInTransactionOrSavepoint(
|
|
|
|
|
_db,
|
|
|
|
|
transaction,
|
|
|
|
|
(savepoint) async {
|
2026-09-04 15:37:09 -03:00
|
|
|
final planned = await _planMergeInsertValues(
|
2026-08-07 13:01:29 -03:00
|
|
|
insert,
|
|
|
|
|
data,
|
2026-09-04 15:37:09 -03:00
|
|
|
batchPlan,
|
|
|
|
|
presence,
|
2026-06-18 01:05:24 -03:00
|
|
|
savepoint,
|
|
|
|
|
);
|
|
|
|
|
final insertedRow = await _applyMergeInsertForMissingRow(
|
|
|
|
|
insert,
|
|
|
|
|
remoteNode,
|
|
|
|
|
incomingHlc,
|
2026-09-04 15:37:09 -03:00
|
|
|
planned.domain,
|
2026-06-18 01:05:24 -03:00
|
|
|
savepoint,
|
|
|
|
|
);
|
2026-09-04 15:37:09 -03:00
|
|
|
pendingWrites = planned.attempts;
|
2026-06-18 01:05:24 -03:00
|
|
|
return insertedRow;
|
|
|
|
|
},
|
|
|
|
|
);
|
2026-09-14 13:39:07 -03:00
|
|
|
// The row is now owned by the merging space (freshly inserted, or a
|
|
|
|
|
// same-space recovery), so same-merge field updates resolve ownership
|
2026-06-18 01:05:24 -03:00
|
|
|
// from the cache instead of re-reading the domain row.
|
2026-09-14 13:39:07 -03:00
|
|
|
context.domainOwners[rowKey] = (exists: true, spaceId: mergingSpaceId);
|
2026-09-04 15:37:09 -03:00
|
|
|
pendingAttempts.add(
|
|
|
|
|
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);
|
2026-09-14 13:39:07 -03:00
|
|
|
} on _ForeignSpaceRowCollision catch (collision) {
|
2026-06-18 01:05:24 -03:00
|
|
|
await _throwOwnershipCollision(
|
2026-09-14 13:39:07 -03:00
|
|
|
operation: OfflineSyncViolationOperation.mergeInsert,
|
2026-06-18 01:05:24 -03:00
|
|
|
tableName: insert.tableName,
|
|
|
|
|
rowId: insert.uuidRowId,
|
2026-09-14 13:39:07 -03:00
|
|
|
owningSpaceId: collision.owningSpaceId,
|
|
|
|
|
incomingSpace: mergingSpace,
|
2026-06-18 01:05:24 -03:00
|
|
|
metadataRowId: null,
|
|
|
|
|
node: remoteNode,
|
|
|
|
|
hlc: insert.hlc,
|
|
|
|
|
transaction: transaction,
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-05-07 00:42:23 -03:00
|
|
|
} else {
|
|
|
|
|
await _applyMergeInsertForExistingRow(
|
|
|
|
|
insert,
|
|
|
|
|
currentRow,
|
|
|
|
|
remoteNode,
|
|
|
|
|
incomingHlc,
|
|
|
|
|
data,
|
2026-06-06 00:55:10 -03:00
|
|
|
context,
|
2026-09-04 15:37:09 -03:00
|
|
|
authoredOverlays,
|
2026-05-07 00:42:23 -03:00
|
|
|
transaction,
|
|
|
|
|
);
|
2026-09-04 15:37:09 -03:00
|
|
|
// 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);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Writes the field metadata collected for merged inserts, one call per
|
|
|
|
|
/// table and node.
|
|
|
|
|
///
|
|
|
|
|
/// Grouped by node because the merge cache pairs every field it stores with
|
|
|
|
|
/// the node that authored it, which is how the cached field resolves its own
|
|
|
|
|
/// clock.
|
|
|
|
|
Future<void> _flushInsertAttempts(
|
|
|
|
|
_PendingInsertAttempts pending,
|
|
|
|
|
MergeContext context,
|
|
|
|
|
Transaction transaction,
|
|
|
|
|
) async {
|
|
|
|
|
if (pending.isEmpty) return;
|
|
|
|
|
|
|
|
|
|
final groups = pending.take();
|
|
|
|
|
for (final group in groups) {
|
|
|
|
|
await _foreignKeyProjector.recordInsertAttempts(
|
|
|
|
|
group.tableName,
|
|
|
|
|
group.rowIds,
|
|
|
|
|
transaction,
|
|
|
|
|
group.attempts,
|
|
|
|
|
mergeCache: (fields: context.fields, node: group.node),
|
|
|
|
|
);
|
2026-05-07 00:42:23 -03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> _applyMergeUpdate(
|
|
|
|
|
CrdtMergeUpdate update,
|
|
|
|
|
Map<UuidValue, CrdtNode> remoteNodes,
|
2026-08-07 13:01:29 -03:00
|
|
|
MergeContext context,
|
2026-09-04 15:37:09 -03:00
|
|
|
Map<MergeFieldKey, Object?> authoredOverlays,
|
2026-05-07 00:42:23 -03:00
|
|
|
Transaction transaction,
|
|
|
|
|
) async {
|
|
|
|
|
final rowKey = (update.tableName, update.uuidRowId);
|
2026-06-06 00:55:10 -03:00
|
|
|
final row = context.rows[rowKey];
|
2026-05-07 00:42:23 -03:00
|
|
|
if (row == null) return;
|
2026-06-18 01:05:24 -03:00
|
|
|
final remoteNode = _requireRemoteNode(remoteNodes, update.uuidNodeId);
|
2026-09-14 13:39:07 -03:00
|
|
|
if (!await _domainRowOwnedByEffectiveSpace(
|
|
|
|
|
operation: OfflineSyncViolationOperation.mergeUpdate,
|
2026-06-18 01:05:24 -03:00
|
|
|
tableName: update.tableName,
|
|
|
|
|
rowId: update.uuidRowId,
|
|
|
|
|
context: context,
|
|
|
|
|
transaction: transaction,
|
|
|
|
|
metadataRowId: row.id,
|
|
|
|
|
node: remoteNode,
|
|
|
|
|
hlc: update.hlc,
|
|
|
|
|
)) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-05-07 00:42:23 -03:00
|
|
|
|
|
|
|
|
final shouldApply = await _shouldMergeFieldMetadataIfNewer(
|
|
|
|
|
tableName: update.tableName,
|
|
|
|
|
rowId: update.uuidRowId,
|
|
|
|
|
columnName: update.columnName,
|
|
|
|
|
row: row,
|
2026-06-18 01:05:24 -03:00
|
|
|
remoteNode: remoteNode,
|
2026-05-07 00:42:23 -03:00
|
|
|
incomingHlc: update.hlc,
|
2026-06-06 00:55:10 -03:00
|
|
|
fields: context.fields,
|
2026-05-07 00:42:23 -03:00
|
|
|
transaction: transaction,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (!shouldApply) return;
|
2026-06-06 00:55:10 -03:00
|
|
|
|
2026-09-04 15:37:09 -03:00
|
|
|
authoredOverlays[(update.tableName, update.uuidRowId, update.columnName)] =
|
|
|
|
|
update.value;
|
2026-05-07 00:42:23 -03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> _applyMergeDelete(
|
|
|
|
|
CrdtMergeDelete delete,
|
|
|
|
|
Map<UuidValue, CrdtNode> remoteNodes,
|
2026-08-07 13:01:29 -03:00
|
|
|
MergeContext context,
|
2026-05-07 00:42:23 -03:00
|
|
|
Transaction transaction,
|
|
|
|
|
) async {
|
|
|
|
|
final rowKey = (delete.tableName, delete.uuidRowId);
|
2026-06-06 00:55:10 -03:00
|
|
|
final row = context.rows[rowKey];
|
2026-05-07 00:42:23 -03:00
|
|
|
if (row == null) return;
|
2026-06-10 17:31:05 -03:00
|
|
|
if (!delete.reason.isSynced) return;
|
2026-06-18 01:05:24 -03:00
|
|
|
final remoteNode = _requireRemoteNode(remoteNodes, delete.uuidNodeId);
|
2026-09-14 13:39:07 -03:00
|
|
|
if (!await _domainRowOwnedByEffectiveSpace(
|
|
|
|
|
operation: OfflineSyncViolationOperation.mergeDelete,
|
2026-06-18 01:05:24 -03:00
|
|
|
tableName: delete.tableName,
|
|
|
|
|
rowId: delete.uuidRowId,
|
|
|
|
|
context: context,
|
|
|
|
|
transaction: transaction,
|
|
|
|
|
metadataRowId: row.id,
|
|
|
|
|
node: remoteNode,
|
|
|
|
|
hlc: delete.hlc,
|
|
|
|
|
)) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-05-07 00:42:23 -03:00
|
|
|
|
2026-06-06 00:55:10 -03:00
|
|
|
final currentTombstone = context.tombstones[rowKey];
|
2026-05-31 14:17:59 -03:00
|
|
|
final currentClFlag = currentTombstone?.clFlag ?? 1;
|
|
|
|
|
final currentHlc = currentTombstone?.hlc ?? row.hlc;
|
|
|
|
|
if (delete.clFlag < currentClFlag) return;
|
|
|
|
|
if (delete.clFlag == currentClFlag && delete.hlc <= currentHlc) return;
|
2026-05-07 00:42:23 -03:00
|
|
|
|
2026-06-06 00:55:10 -03:00
|
|
|
context.tombstones[rowKey] = await _upsertMergeTombstone(
|
2026-05-07 00:42:23 -03:00
|
|
|
row,
|
2026-06-18 01:05:24 -03:00
|
|
|
remoteNode,
|
2026-05-31 14:17:59 -03:00
|
|
|
delete.hlc,
|
|
|
|
|
delete.clFlag,
|
|
|
|
|
delete.reason,
|
2026-05-07 00:42:23 -03:00
|
|
|
currentTombstone,
|
|
|
|
|
transaction,
|
|
|
|
|
);
|
2026-06-10 17:31:05 -03:00
|
|
|
|
|
|
|
|
if (delete.reason.isSynced) {
|
2026-08-07 13:01:29 -03:00
|
|
|
await _context.applyRowVisibilityFromUserTombstone(
|
2026-06-10 17:31:05 -03:00
|
|
|
row,
|
|
|
|
|
context.tombstones[rowKey]!,
|
|
|
|
|
transaction,
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-05-07 00:42:23 -03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<CrdtDataRow> _upsertMergeRow(
|
|
|
|
|
String tableName,
|
|
|
|
|
UuidValue rowId,
|
|
|
|
|
CrdtNode remoteNode,
|
|
|
|
|
Hlc incomingHlc,
|
|
|
|
|
CrdtDataRow? currentRow,
|
|
|
|
|
Transaction transaction,
|
|
|
|
|
) async {
|
|
|
|
|
if (currentRow == null) {
|
2026-08-07 13:01:29 -03:00
|
|
|
final (tableId, _) = _context.schema[tableName]!;
|
2026-05-07 00:42:23 -03:00
|
|
|
final insertedRow = await CrdtDataRow.db.insertRow(
|
|
|
|
|
_session,
|
|
|
|
|
CrdtDataRow(
|
2026-09-14 13:39:07 -03:00
|
|
|
spaceId: _context.hlcManagerFor(transaction).normalizedSpaceId,
|
2026-05-07 00:42:23 -03:00
|
|
|
tblId: tableId,
|
|
|
|
|
uuidRowId: rowId,
|
|
|
|
|
nodeId: remoteNode.id!,
|
|
|
|
|
hlcDatetime: incomingHlc.datetime,
|
|
|
|
|
hlcCounter: incomingHlc.counter,
|
|
|
|
|
),
|
|
|
|
|
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,
|
|
|
|
|
) async {
|
2026-09-04 15:37:09 -03:00
|
|
|
// 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) {
|
2026-05-07 00:42:23 -03:00
|
|
|
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,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-04 15:37:09 -03:00
|
|
|
final updatedField = existingField.copyWith(
|
2026-05-07 00:42:23 -03:00
|
|
|
row: row,
|
|
|
|
|
column: column,
|
|
|
|
|
nodeId: remoteNode.id,
|
|
|
|
|
node: remoteNode,
|
|
|
|
|
hlcDatetime: incomingHlc.datetime,
|
|
|
|
|
hlcCounter: incomingHlc.counter,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
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,
|
2026-05-31 14:17:59 -03:00
|
|
|
int clFlag,
|
|
|
|
|
CrdtDataDeletedReason reason,
|
2026-05-07 00:42:23 -03:00
|
|
|
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,
|
2026-05-31 14:17:59 -03:00
|
|
|
clFlag: clFlag,
|
|
|
|
|
reason: reason,
|
2026-05-07 00:42:23 -03:00
|
|
|
),
|
|
|
|
|
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,
|
2026-05-31 14:17:59 -03:00
|
|
|
clFlag: clFlag,
|
|
|
|
|
reason: reason,
|
2026-05-07 00:42:23 -03:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await CrdtDataDeleted.db.update(
|
|
|
|
|
_session,
|
|
|
|
|
[updatedTombstone],
|
2026-05-31 14:17:59 -03:00
|
|
|
columns: (t) => [t.nodeId, t.hlcDatetime, t.hlcCounter, t.clFlag, t.reason],
|
2026-05-07 00:42:23 -03:00
|
|
|
transaction: transaction,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return updatedTombstone;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<CrdtDataRow> _applyMergeInsertForMissingRow(
|
|
|
|
|
CrdtMergeInsert insert,
|
|
|
|
|
CrdtNode remoteNode,
|
|
|
|
|
Hlc incomingHlc,
|
|
|
|
|
Map<String, Object?> data,
|
|
|
|
|
Transaction transaction,
|
|
|
|
|
) async {
|
2026-06-06 00:55:10 -03:00
|
|
|
final tableRow = _requireMergeInsertTableRow(insert);
|
2026-09-04 15:37:09 -03:00
|
|
|
final patchedRow = _withPlannedDomainValues(tableRow, data);
|
2026-09-14 13:39:07 -03:00
|
|
|
final mergingSpaceId = _context.hlcManagerFor(transaction).normalizedSpaceId;
|
|
|
|
|
final spaceScopedRow = patchedRow.copyWithSpaceId(mergingSpaceId);
|
2026-05-07 00:42:23 -03:00
|
|
|
try {
|
2026-09-04 15:37:09 -03:00
|
|
|
// Domain insert first. A CRDT tracker row for this id, written before
|
|
|
|
|
// the domain row, made SQLite reject a later null unique-FK insert.
|
2026-06-18 01:05:24 -03:00
|
|
|
await DatabaseUtil.runInTransactionOrSavepoint(
|
|
|
|
|
_db,
|
|
|
|
|
transaction,
|
2026-09-14 10:26:56 -03:00
|
|
|
(savepoint) => _db.insertRow(
|
2026-09-14 13:39:07 -03:00
|
|
|
withExplicitInsertNulls(spaceScopedRow, {
|
|
|
|
|
for (final column in spaceScopedRow.table.crdtSyncableColumns)
|
2026-09-14 10:26:56 -03:00
|
|
|
if (column.hasDefault && data[column.columnName] == null)
|
|
|
|
|
column.columnName,
|
|
|
|
|
}),
|
|
|
|
|
transaction: savepoint,
|
|
|
|
|
),
|
2026-06-18 01:05:24 -03:00
|
|
|
);
|
|
|
|
|
} 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;
|
2026-09-14 13:39:07 -03:00
|
|
|
if (owner.spaceId != mergingSpaceId) {
|
|
|
|
|
throw _ForeignSpaceRowCollision(owner.spaceId);
|
2026-06-18 01:05:24 -03:00
|
|
|
}
|
|
|
|
|
|
2026-09-14 13:39:07 -03:00
|
|
|
// Same-space recovery (a tracked row whose metadata was lost, or a
|
|
|
|
|
// race between this space's own merges). spaceId is immutable and rows
|
2026-06-18 01:05:24 -03:00
|
|
|
// are only soft-deleted, so ownership observed here cannot change for
|
|
|
|
|
// the rest of the transaction and the update is safe.
|
2026-05-07 00:42:23 -03:00
|
|
|
await _db.updateRow(
|
2026-09-14 13:39:07 -03:00
|
|
|
spaceScopedRow,
|
|
|
|
|
columns: spaceScopedRow.table.managedColumns
|
2026-05-07 00:42:23 -03:00
|
|
|
.where((c) => data.containsKey(c.columnName))
|
|
|
|
|
.toList(),
|
|
|
|
|
transaction: transaction,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-04 15:37:09 -03:00
|
|
|
return _upsertMergeRow(
|
|
|
|
|
insert.tableName,
|
|
|
|
|
insert.uuidRowId,
|
|
|
|
|
remoteNode,
|
|
|
|
|
incomingHlc,
|
|
|
|
|
null,
|
|
|
|
|
transaction,
|
|
|
|
|
);
|
2026-05-07 00:42:23 -03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> _applyMergeInsertForExistingRow(
|
|
|
|
|
CrdtMergeInsert insert,
|
|
|
|
|
CrdtDataRow currentRow,
|
|
|
|
|
CrdtNode remoteNode,
|
|
|
|
|
Hlc incomingHlc,
|
|
|
|
|
Map<String, Object?> data,
|
2026-08-07 13:01:29 -03:00
|
|
|
MergeContext context,
|
2026-09-04 15:37:09 -03:00
|
|
|
Map<MergeFieldKey, Object?> authoredOverlays,
|
2026-05-07 00:42:23 -03:00
|
|
|
Transaction transaction,
|
|
|
|
|
) async {
|
2026-09-14 13:39:07 -03:00
|
|
|
if (!await _domainRowOwnedByEffectiveSpace(
|
|
|
|
|
operation: OfflineSyncViolationOperation.mergeInsert,
|
2026-06-18 01:05:24 -03:00
|
|
|
tableName: insert.tableName,
|
|
|
|
|
rowId: insert.uuidRowId,
|
|
|
|
|
context: context,
|
|
|
|
|
transaction: transaction,
|
|
|
|
|
metadataRowId: currentRow.id,
|
|
|
|
|
node: remoteNode,
|
|
|
|
|
hlc: insert.hlc,
|
|
|
|
|
)) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-07 13:01:29 -03:00
|
|
|
final (_, columnsByName) = _context.schema[insert.tableName]!;
|
2026-05-07 00:42:23 -03:00
|
|
|
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,
|
2026-06-06 00:55:10 -03:00
|
|
|
fields: context.fields,
|
2026-05-07 00:42:23 -03:00
|
|
|
transaction: transaction,
|
|
|
|
|
schemaColumn: schemaColumn,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (!shouldApply) continue;
|
2026-09-04 15:37:09 -03:00
|
|
|
authoredOverlays[(insert.tableName, insert.uuidRowId, columnName)] =
|
|
|
|
|
data[columnName];
|
2026-05-07 00:42:23 -03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
final rowKey = (insert.tableName, insert.uuidRowId);
|
2026-06-06 00:55:10 -03:00
|
|
|
final currentTombstone = context.tombstones[rowKey];
|
2026-05-31 14:17:59 -03:00
|
|
|
if ((currentTombstone?.clFlag ?? 1) == 1 &&
|
|
|
|
|
incomingHlc > (currentTombstone?.hlc ?? currentRow.hlc)) {
|
2026-06-06 00:55:10 -03:00
|
|
|
context.tombstones[rowKey] = await _upsertMergeTombstone(
|
2026-05-07 00:42:23 -03:00
|
|
|
currentRow,
|
|
|
|
|
remoteNode,
|
|
|
|
|
incomingHlc,
|
2026-05-31 14:17:59 -03:00
|
|
|
1,
|
|
|
|
|
CrdtDataDeletedReason.userInsert,
|
2026-05-07 00:42:23 -03:00
|
|
|
currentTombstone,
|
|
|
|
|
transaction,
|
|
|
|
|
);
|
2026-08-07 13:01:29 -03:00
|
|
|
await _context.applyRowVisibilityFromUserTombstone(
|
2026-06-10 17:31:05 -03:00
|
|
|
currentRow,
|
|
|
|
|
context.tombstones[rowKey]!,
|
|
|
|
|
transaction,
|
|
|
|
|
);
|
2026-05-07 00:42:23 -03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<bool> _shouldMergeFieldMetadataIfNewer({
|
|
|
|
|
required String tableName,
|
|
|
|
|
required UuidValue rowId,
|
|
|
|
|
required String columnName,
|
|
|
|
|
required CrdtDataRow row,
|
|
|
|
|
required CrdtNode remoteNode,
|
|
|
|
|
required Hlc incomingHlc,
|
2026-08-07 13:01:29 -03:00
|
|
|
required Map<MergeFieldKey, CrdtDataField> fields,
|
2026-05-07 00:42:23 -03:00
|
|
|
required Transaction transaction,
|
|
|
|
|
CrdtSchemaColumn? schemaColumn,
|
|
|
|
|
}) async {
|
2026-06-10 17:31:05 -03:00
|
|
|
final resolvedSchemaColumn =
|
2026-08-07 13:01:29 -03:00
|
|
|
schemaColumn ?? _context.schemaColumn(tableName, columnName);
|
2026-05-07 00:42:23 -03:00
|
|
|
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,
|
|
|
|
|
);
|
|
|
|
|
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,
|
|
|
|
|
) {
|
2026-08-07 13:01:29 -03:00
|
|
|
final columns = _context.columnsByTableAndName[tableName];
|
2026-05-07 00:42:23 -03:00
|
|
|
if (columns == null) return {};
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
for (final MapEntry(key: columnName, value: value) in data.entries)
|
2026-06-18 01:05:24 -03:00
|
|
|
if (columnName != 'id' &&
|
2026-09-14 13:39:07 -03:00
|
|
|
columnName != 'spaceId' &&
|
2026-06-18 01:05:24 -03:00
|
|
|
columns.containsKey(columnName))
|
2026-06-10 17:31:05 -03:00
|
|
|
columnName: columns[columnName]!.columnType == ColumnType.uuid
|
2026-08-07 13:01:29 -03:00
|
|
|
? value.toUuidValue()
|
2026-06-10 17:31:05 -03:00
|
|
|
: value,
|
2026-05-07 00:42:23 -03:00
|
|
|
};
|
|
|
|
|
}
|
2026-06-18 01:05:24 -03:00
|
|
|
|
2026-08-07 13:01:29 -03:00
|
|
|
Future<DomainRowOwner> _readDomainRowOwner(
|
2026-06-18 01:05:24 -03:00
|
|
|
String tableName,
|
|
|
|
|
UuidValue rowId,
|
|
|
|
|
Transaction transaction,
|
|
|
|
|
) async {
|
2026-08-07 13:01:29 -03:00
|
|
|
final values = await _context.readDomainColumnValues(
|
2026-06-18 01:05:24 -03:00
|
|
|
tableName,
|
|
|
|
|
{rowId},
|
2026-09-14 13:39:07 -03:00
|
|
|
['spaceId'],
|
2026-06-18 01:05:24 -03:00
|
|
|
transaction,
|
|
|
|
|
);
|
|
|
|
|
final row = values[rowId];
|
2026-09-14 13:39:07 -03:00
|
|
|
if (row == null) return (exists: false, spaceId: null);
|
|
|
|
|
return (exists: true, spaceId: row['spaceId'] as int?);
|
2026-06-18 01:05:24 -03:00
|
|
|
}
|
|
|
|
|
|
2026-09-14 13:39:07 -03:00
|
|
|
/// Returns the owning space of [rowId] from the merge snapshot taken in
|
2026-06-18 01:05:24 -03:00
|
|
|
/// [_loadMergeContext], reading the domain row only if it was not captured
|
|
|
|
|
/// there (e.g. a row a concurrent operation added since the snapshot).
|
2026-08-07 13:01:29 -03:00
|
|
|
Future<DomainRowOwner> _resolveDomainOwner(
|
|
|
|
|
MergeContext context,
|
2026-06-18 01:05:24 -03:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-14 13:39:07 -03:00
|
|
|
Future<bool> _domainRowOwnedByEffectiveSpace({
|
|
|
|
|
required OfflineSyncViolationOperation operation,
|
2026-06-18 01:05:24 -03:00
|
|
|
required String tableName,
|
|
|
|
|
required UuidValue rowId,
|
2026-08-07 13:01:29 -03:00
|
|
|
required MergeContext context,
|
2026-06-18 01:05:24 -03:00
|
|
|
required Transaction transaction,
|
|
|
|
|
int? metadataRowId,
|
|
|
|
|
CrdtNode? node,
|
|
|
|
|
Hlc? hlc,
|
|
|
|
|
}) async {
|
|
|
|
|
final owner = await _resolveDomainOwner(context, tableName, rowId, transaction);
|
2026-09-14 13:39:07 -03:00
|
|
|
final mergingSpace = _context.effectiveSpaceFor(transaction);
|
|
|
|
|
if (owner.exists && owner.spaceId == mergingSpace.id) return true;
|
2026-06-18 01:05:24 -03:00
|
|
|
if (!owner.exists) return false;
|
|
|
|
|
|
|
|
|
|
await _throwOwnershipCollision(
|
|
|
|
|
operation: operation,
|
|
|
|
|
tableName: tableName,
|
|
|
|
|
rowId: rowId,
|
2026-09-14 13:39:07 -03:00
|
|
|
owningSpaceId: owner.spaceId,
|
|
|
|
|
incomingSpace: mergingSpace,
|
2026-06-18 01:05:24 -03:00
|
|
|
metadataRowId: metadataRowId,
|
|
|
|
|
node: node,
|
|
|
|
|
hlc: hlc,
|
|
|
|
|
transaction: transaction,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<Never> _throwOwnershipCollision({
|
2026-09-14 13:39:07 -03:00
|
|
|
required OfflineSyncViolationOperation operation,
|
2026-06-18 01:05:24 -03:00
|
|
|
required String tableName,
|
|
|
|
|
required UuidValue rowId,
|
2026-09-14 13:39:07 -03:00
|
|
|
required int? owningSpaceId,
|
|
|
|
|
required OfflineSyncSpace incomingSpace,
|
2026-06-18 01:05:24 -03:00
|
|
|
required int? metadataRowId,
|
|
|
|
|
required Transaction transaction,
|
|
|
|
|
CrdtNode? node,
|
|
|
|
|
Hlc? hlc,
|
|
|
|
|
}) async {
|
2026-09-04 18:09:20 +02:00
|
|
|
final now = clock.now().toUtc();
|
2026-09-14 13:39:07 -03:00
|
|
|
throw OfflineSyncIntegrityViolationException(
|
|
|
|
|
OfflineSyncIntegrityViolation(
|
|
|
|
|
type: OfflineSyncViolationType.ownershipCollision,
|
2026-06-18 01:05:24 -03:00
|
|
|
domainTableName: tableName,
|
|
|
|
|
uuidRowId: rowId,
|
2026-09-14 13:39:07 -03:00
|
|
|
ownerSpaceUuid: await _spaceUuidForNormalizedId(owningSpaceId, transaction),
|
|
|
|
|
incomingSpaceUuid: incomingSpace.uuidSpaceId,
|
2026-06-18 01:05:24 -03:00
|
|
|
operation: operation,
|
|
|
|
|
uuidNodeId: node?.uuidNodeId,
|
|
|
|
|
crdtDataRowId: metadataRowId,
|
|
|
|
|
hlcDatetime: hlc?.datetime,
|
|
|
|
|
hlcCounter: hlc?.counter,
|
|
|
|
|
firstSeenAt: now,
|
|
|
|
|
lastSeenAt: now,
|
|
|
|
|
occurrences: 1,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-14 13:39:07 -03:00
|
|
|
Future<UuidValue?> _spaceUuidForNormalizedId(
|
|
|
|
|
int? spaceId,
|
2026-06-18 01:05:24 -03:00
|
|
|
Transaction transaction,
|
|
|
|
|
) async {
|
2026-09-14 13:39:07 -03:00
|
|
|
if (spaceId == null) return null;
|
2026-06-18 01:05:24 -03:00
|
|
|
|
2026-09-14 13:39:07 -03:00
|
|
|
final space = await OfflineSyncSpace.db.findById(
|
2026-06-18 01:05:24 -03:00
|
|
|
_session,
|
2026-09-14 13:39:07 -03:00
|
|
|
spaceId,
|
2026-06-18 01:05:24 -03:00
|
|
|
transaction: transaction,
|
|
|
|
|
);
|
2026-09-14 13:39:07 -03:00
|
|
|
return space?.uuidSpaceId;
|
2026-06-18 01:05:24 -03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Signals that a merge insert lost a primary-key race to a row owned by
|
2026-09-14 13:39:07 -03:00
|
|
|
/// another space, so the savepoint around the insert application must be
|
2026-06-18 01:05:24 -03:00
|
|
|
/// rolled back before the sync violation is recorded and thrown.
|
2026-09-14 13:39:07 -03:00
|
|
|
class _ForeignSpaceRowCollision implements Exception {
|
|
|
|
|
_ForeignSpaceRowCollision(this.owningSpaceId);
|
2026-06-18 01:05:24 -03:00
|
|
|
|
2026-09-14 13:39:07 -03:00
|
|
|
final int? owningSpaceId;
|
2026-05-07 00:42:23 -03:00
|
|
|
}
|