fix: Rebuild foreign key and unique projection from authored facts (#115)

This commit is contained in:
Marcelo Mendonça Soares 2026-09-04 15:37:09 -03:00 • committed by GitHub
commit 519a8efca0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 49203 additions and 3053 deletions

View file

@ -52,16 +52,6 @@ extension CrdtDataDeletedExtension on CrdtDataDeleted {
: toHlcForNode(node!.uuidNodeId);
}
/// Extensions for the [CrdtDataForeignKey] class.
extension CrdtDataForeignKeyExtension on CrdtDataForeignKey {
/// Whether an active projection override exists for this FK field.
///
/// Derived from [CrdtDataForeignKey.overrideReason]: an override is active if
/// and only if [overrideReason] is non-null. This is the authoritative test
/// for whether the domain row's FK value differs from the attempted value.
bool get hasOverride => overrideReason != null;
}
/// Extensions for the [CrdtDataDeletedReason] enum.
extension CrdtDataDeletedReasonExtension on CrdtDataDeletedReason {
/// Whether this tombstone reason is synced across replicas. All reasons that

View file

@ -7,6 +7,7 @@ import 'package:uuid/uuid.dart';
import '../database/database.dart';
import '../database/merge_utils/database_helpers.dart';
import '../database/recorder.dart';
import '../database/unique_index_utils.dart';
import '../generated/protocol.dart';
import '../hlc/hlc.dart';
import '../managers/scope.dart';
@ -135,7 +136,7 @@ class CrdtSync {
/// column payloads are resolved incrementally as each change is yielded.
///
/// Foreign-key columns with an active projection override are sent with their
/// durable [CrdtDataForeignKey.attemptedValue], not the visible value stored
/// durable [CrdtDataAttemptedValue.value], not the visible value stored
/// in the domain table. Peers need the attempted fact to converge; local FK
/// projection materializes only the safe visible value into domain tables.
///
@ -571,7 +572,7 @@ class CrdtSync {
),
);
final foreignKeyAttemptFieldsByRowId = await _loadProjectedForeignKeyAttemptFields(
final attemptedValueFieldsByRowId = await _loadAttemptedValueFields(
session,
rows,
);
@ -593,7 +594,7 @@ class CrdtSync {
row.uuidRowId,
table,
dartName,
foreignKeyAttemptFieldsByRowId[row.id!],
attemptedValueFieldsByRowId[row.id!],
scopeId,
ownerCache,
);
@ -649,7 +650,7 @@ class CrdtSync {
row: CrdtDataRow.include(tbl: CrdtSchemaTable.include()),
column: CrdtSchemaColumn.include(),
node: CrdtNode.include(),
foreignKey: CrdtDataForeignKey.include(),
attemptedValue: CrdtDataAttemptedValue.include(),
),
);
@ -670,7 +671,7 @@ class CrdtSync {
tableName,
field.row!.uuidRowId,
columnName,
field.foreignKey,
field.attemptedValue,
scopeId,
ownerCache,
);
@ -811,8 +812,8 @@ class CrdtSync {
/// Loads a domain row for outbound insert sync.
///
/// Reads the materialized row from the domain table, then swaps any FK columns
/// with an active override back to [CrdtDataForeignKey.attemptedValue] before
/// Reads the materialized row from the domain table, then swaps any columns
/// with an active [CrdtDataAttemptedValue] back to the authored value before
/// deserializing. The domain table and projected columns are only known at
/// runtime, so a generated repository cannot express this query. Keeping one
/// targeted SQL projection also avoids fetching and serializing unrelated
@ -824,7 +825,7 @@ class CrdtSync {
UuidValue rowId,
Table table,
String dartName,
List<CrdtDataField>? foreignKeyAttemptFields,
List<CrdtDataField>? attemptedValueFields,
int scopeId,
DomainRowOwnerCache ownerCache,
) async {
@ -848,7 +849,7 @@ class CrdtSync {
final columnMap = result.first.toColumnMap()
// Domain columns hold visible/materialized FK values; restore attempted
// values for override columns before building the outbound merge payload.
..applyProjectedForeignKeyAttempts(foreignKeyAttemptFields)
..applyAuthoredAttemptedValues(attemptedValueFields)
// scopeId is local ownership metadata; it is never emitted on the wire.
..remove('scopeId');
@ -861,21 +862,20 @@ class CrdtSync {
/// Resolves a column value for outbound update sync.
///
/// When [projection] has an active override, returns the attempted value
/// from [CrdtDataForeignKey.attemptedValue] instead of the materialized
/// domain column value. Non-FK columns and FK columns without an override
/// are read directly from the domain table. Both the table and column are
/// When [attempted] is present, returns its authored value instead of the
/// materialized domain column value. Columns without an attempted row are
/// read directly from the domain table. Both the table and column are
/// runtime schema values, so this cannot use a statically typed repository.
Future<({bool exists, int? ownerScopeId, dynamic value})> _fetchOwnedColumnValue(
DatabaseSession session,
String tableName,
UuidValue rowId,
String columnName,
CrdtDataForeignKey? projection,
CrdtDataAttemptedValue? attempted,
int scopeId,
DomainRowOwnerCache ownerCache,
) async {
if (projection != null && projection.hasOverride) {
if (attempted != null) {
final owner = await _readDomainRowOwner(session, tableName, rowId, ownerCache);
if (!owner.exists || owner.scopeId != scopeId) {
return (exists: owner.exists, ownerScopeId: owner.scopeId, value: null);
@ -883,7 +883,7 @@ class CrdtSync {
return (
exists: true,
ownerScopeId: scopeId,
value: _decodeColumnValue(tableName, columnName, projection.attemptedValue),
value: canonicalDomainValue(attempted.value),
);
}
@ -997,12 +997,12 @@ class CrdtSync {
return scope?.uuidScopeId;
}
/// Loads FK projection metadata for outbound insert sync.
/// Loads attempted-value metadata for outbound insert sync.
///
/// Returns only fields whose [CrdtDataForeignKey.overrideReason] is non-null,
/// keyed by CRDT row id. These are the columns whose domain-table value
/// differs from the durable attempted FK fact.
Future<Map<int, List<CrdtDataField>>> _loadProjectedForeignKeyAttemptFields(
/// Returns fields that currently have a [CrdtDataAttemptedValue] row, keyed
/// by CRDT row id. These are the columns whose domain-table value differs
/// from the durable authored fact.
Future<Map<int, List<CrdtDataField>>> _loadAttemptedValueFields(
DatabaseSession session,
List<CrdtDataRow> rows,
) async {
@ -1011,10 +1011,10 @@ class CrdtSync {
final fields = await CrdtDataField.db.find(
session,
where: (t) => t.rowId.inSet(rowIds) & t.foreignKey.overrideReason.notEquals(null),
where: (t) => t.rowId.inSet(rowIds) & t.attemptedValue.id.notEquals(null),
include: CrdtDataField.include(
column: CrdtSchemaColumn.include(),
foreignKey: CrdtDataForeignKey.include(),
attemptedValue: CrdtDataAttemptedValue.include(),
),
);
@ -1065,11 +1065,13 @@ class CrdtSync {
.map((table) {
final definition = tableDefinitionsByName[table.tableName];
final columns = [
for (final column in definition?.columns ?? const <ColumnDefinition>[])
if (column.name != 'scopeId') column.name,
if (definition == null)
for (final column in table.columns)
if (column.columnName != 'scopeId') column.columnName,
if (definition != null)
for (final column in definition.columns)
if (column.name != 'scopeId')
_canonicalColumnIdentity(definition, column)
else
for (final column in table.columns)
if (column.columnName != 'scopeId') column.columnName,
]..sort();
final foreignKeys = _canonicalForeignKeys(definition);
final uniqueIndexes = _canonicalUniqueIndexes(definition);
@ -1081,6 +1083,15 @@ class CrdtSync {
.join(';');
}
static String _canonicalColumnIdentity(
TableDefinition table,
ColumnDefinition column,
) {
final releaseKind = crdtUniqueConflictReleaseKindForColumn(table, column);
return '${column.name}:${column.columnType.name}:${column.dartType}:'
'${column.isNullable}:${releaseKind?.name ?? '-'}';
}
static List<String> _canonicalForeignKeys(TableDefinition? definition) {
if (definition == null) return const [];
final entries = <String>[
@ -1145,19 +1156,18 @@ Expression _afterAnyScopeCheckpointFilter(
}
extension on Map<String, dynamic> {
/// Replaces materialized FK column values with attempted values for sync.
/// Replaces materialized column values with authored values for sync.
///
/// After local FK projection (for example `SET NULL` or `SET DEFAULT`), the
/// domain table stores the safe visible value while
/// [CrdtDataForeignKey.attemptedValue] preserves what was actually tried.
/// After local projection, the domain table stores the safe visible value
/// while [CrdtDataAttemptedValue.value] preserves what was actually tried.
/// Outbound sync must send the attempted value so peers can apply their own
/// projection from the same fact.
void applyProjectedForeignKeyAttempts(List<CrdtDataField>? foreignKeyAttemptFields) {
if (foreignKeyAttemptFields == null) return;
for (final field in foreignKeyAttemptFields) {
final projection = field.foreignKey;
if (projection == null || !projection.hasOverride) continue;
this[field.column!.name] = projection.attemptedValue;
void applyAuthoredAttemptedValues(List<CrdtDataField>? attemptedValueFields) {
if (attemptedValueFields == null) return;
for (final field in attemptedValueFields) {
final attempted = field.attemptedValue;
if (attempted == null) continue;
this[field.column!.name] = canonicalDomainValue(attempted.value);
}
}
}

View file

@ -0,0 +1,18 @@
### Authored field value retained while the domain column holds a projection.
###
### Sparse one-to-one dependent of CrdtDataField. Present only while the
### materialized domain value differs from the authored value. `value` is a
### durable authored fact; `projectionReason` is disposable diagnostic state.
class: CrdtDataAttemptedValue
table: crdt_data_attempted_value
database: all
fields:
### The field whose authored value this row preserves.
fieldId: int, unique
field: CrdtDataField?, relation(name=field_attempted_value, field=fieldId, onDelete=Cascade)
### The authored value, using Serverpod's dynamic `{className, data}` envelope.
value: dynamic, serializationDataType=jsonb
### Terminal projector that selected the current domain value.
projectionReason: CrdtProjectionReason

View file

@ -16,9 +16,9 @@ fields:
### The node that updated the field.
node: CrdtNode?, relation
### The foreign key information for the field, if this field holds a
### reference to another row.
foreignKey: CrdtDataForeignKey?, relation(optional, name=field_foreign_key)
### Authored value retained while a projector materializes a different domain
### value for this field.
attemptedValue: CrdtDataAttemptedValue?, relation(optional, name=field_attempted_value)
indexes:
crdt_data_fields_row_column_idx:

View file

@ -1,40 +0,0 @@
### CRDT field foreign key value and projection table.
###
### This table stores the user/attempted foreign key value for a CRDT field and
### the materialized foreign key projection for that value. CrdtDataField stores
### only HLC metadata, so FK columns store their durable attempted value here.
###
### Repairs must be recomputable from merged CRDT row, field, and tombstone
### facts plus this attempted FK value. The projection columns record what was
### materialized into the domain row; they do not decide business semantics or
### create user field updates during merge.
class: CrdtDataForeignKey
table: crdt_data_foreign_key
database: all
fields:
### The field that the foreign key belongs to.
field: CrdtDataField?, relation(name=field_foreign_key, field=fieldId, onDelete=Cascade)
fieldId: int
### The user-attempted foreign key value that the CRDT field currently carries.
###
### This may be null for nullable foreign keys. This is durable FK field-value
### storage for CRDT columns whose CrdtDataField row stores only HLC metadata.
attemptedValue: UuidValue?, required
### The foreign key value that is currently visible in the actual data row
### when an override is active (overrideReason is non-null).
visibleValue: UuidValue?
### The authoritative indicator and reason for an active projection override.
###
### This is the single source of truth for whether an override is active:
### non-null means an override is active; null means no override. It also
### names the cause of the override. The FK resolver recomputes it from merged
### row, field, and tombstone facts.
overrideReason: CrdtForeignKeyOverrideReason?
indexes:
crdt_data_foreign_key_field_idx:
fields: fieldId
unique: true

View file

@ -1,15 +0,0 @@
### Why a foreign key projection override is active.
enum: CrdtForeignKeyOverrideReason
serialized: byIndex
values:
### The attempted parent target is hidden or missing and the FK projection
### set the column to null.
- setNull
### The attempted parent target is hidden or missing and the FK projection
### set the column to the default value.
- setDefault
### The attempted parent target is hidden or missing and the FK projection
### cannot repair the value (restrict, no action, cascade, or the column is
### non-nullable), so the row is hidden by projection while the attempt is
### preserved.
- missingParent

View file

@ -0,0 +1,22 @@
### Why a domain column currently differs from its authored value.
###
### Diagnostic-only derived state. Projection decisions never read this enum;
### the planner recomputes from authored facts and schema, then rewrites the
### reason with the final domain value. Because it is singular, it records the
### terminal stage that selected that value.
enum: CrdtProjectionReason
serialized: byIndex
values:
### The attempted parent target is hidden or missing and FK projection set
### the column to null.
- foreignKeySetNull
### The attempted parent target is hidden or missing and FK projection set
### the column to the default value.
- foreignKeySetDefault
### The attempted parent target is hidden or missing and FK projection cannot
### repair the value, so the row is hidden while the attempt is preserved.
- foreignKeyMissingParent
### A visible unique-index loser received a deterministic conflict-free value.
- uniqueConflict
### A hidden row was released from a unique index so visible rows can claim it.
- hiddenUniqueRelease

View file

@ -333,6 +333,7 @@ class CrdtDatabase implements Database {
for (final row in prepared.rows)
if (!deletedRowIds.contains(row.id)) row,
];
final plannedInsert = await _recorder.planLocalInserts(rowsToInsert, tx);
// Tracked rows can only skip RETURNING when the prepared rows are
// exactly what gets inserted: every id is caller-provided and a
@ -340,17 +341,21 @@ class CrdtDatabase implements Database {
final skipReturn =
noReturn && !ignoreConflicts && !rows.any((row) => row.id == null);
final result = rowsToInsert.isEmpty
final result = plannedInsert.rows.isEmpty
? <T>[]
: await _delegate.insert<T>(
rowsToInsert,
plannedInsert.rows,
transaction: tx,
ignoreConflicts: ignoreConflicts,
noReturn: skipReturn,
);
final insertedRows = skipReturn ? rowsToInsert : result;
await _recorder.afterInsert<T>(insertedRows, tx);
final insertedRows = skipReturn ? plannedInsert.rows : result;
await _recorder.afterInsert<T>(
insertedRows,
tx,
attempts: plannedInsert.attempts,
);
final reinsertedRows = await _reinsertTombstonedRows(
prepared.rows,
@ -405,6 +410,13 @@ class CrdtDatabase implements Database {
transaction,
(tx) async {
final prepared = _prepareRowsForInsert(rows, tx);
if (rows.isNotEmpty) {
final rowIds = {
for (final row in rows)
if (row.id case final UuidValue rowId) rowId,
};
await _recorder.projectCurrent(rows.first.table.tableName, rowIds, tx);
}
// CRDT metadata needs the affected rows even when the public call uses
// noReturn, because inserted rows may have database-generated ids.
@ -482,8 +494,13 @@ class CrdtDatabase implements Database {
];
if (rowsToReinsert.isEmpty) return [];
final reinsertedRows = await _delegate.update<T>(
final plannedReinserts = await _recorder.planLocalUpdates(
rowsToReinsert,
null,
transaction,
);
final reinsertedRows = await _delegate.update<T>(
plannedReinserts,
transaction: transaction,
);
await _recorder.afterReinsert(reinsertedRows, transaction);
@ -585,8 +602,9 @@ class CrdtDatabase implements Database {
_delegate,
transaction,
(tx) async {
final plannedUpdates = await _recorder.planLocalUpdates(rows, columns, tx);
final updatedRows = [
for (final row in rows)
for (final row in plannedUpdates)
await _updateRowWithoutRecording(
row,
stripScopeId: _shouldStripReturnedScopeId(row, tx),
@ -612,9 +630,10 @@ class CrdtDatabase implements Database {
_delegate,
transaction,
(tx) async {
final plannedUpdates = await _recorder.planLocalUpdates([row], columns, tx);
final stripScopeId = _shouldStripReturnedScopeId(row, tx);
final updatedRow = await _updateRowWithoutRecording(
row,
plannedUpdates.single,
stripScopeId: stripScopeId,
transaction: tx,
columns: columns,

View file

@ -1,5 +1,68 @@
part of 'recorder.dart';
/// 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;
}
}
/// Adds merge-specific behavior to [CrdtMutationRecorder].
extension CrdtMergeRecorderExtension on CrdtMutationRecorder {
/// Locks the current user row so merges can serialize with other work.
@ -24,8 +87,13 @@ extension CrdtMergeRecorderExtension on CrdtMutationRecorder {
if (mergeSet.isEmpty) return;
final operations = mergeSet.causallyOrderedChanges;
final shouldProjectForeignKeys = _foreignKeyProjector
.mergeOperationsMayAffectForeignKeys(operations);
// 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?>{};
final currentUser = _context.effectiveScopeFor(transaction);
final remoteNodes = await _findOrCreateNodesForMerge(
currentUser.id!,
@ -35,10 +103,49 @@ extension CrdtMergeRecorderExtension on CrdtMutationRecorder {
final nodesByUuid = remoteNodes.nodesByUuid;
final context = await _loadMergeContext(mergeSet, transaction);
// 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();
for (final operation in operations) {
if (!_context.isCrdtTrackedTableName(operation.tableName)) {
continue;
}
// 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);
}
switch (operation) {
case final CrdtMergeInsert insert:
@ -46,6 +153,10 @@ extension CrdtMergeRecorderExtension on CrdtMutationRecorder {
insert,
nodesByUuid,
context,
authoredOverlays,
batch.plan,
pendingAttempts,
presence,
transaction,
);
case final CrdtMergeUpdate update:
@ -53,6 +164,7 @@ extension CrdtMergeRecorderExtension on CrdtMutationRecorder {
update,
nodesByUuid,
context,
authoredOverlays,
transaction,
);
case final CrdtMergeDelete delete:
@ -62,11 +174,28 @@ extension CrdtMergeRecorderExtension on CrdtMutationRecorder {
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));
}
}
if (shouldProjectForeignKeys) {
await _foreignKeyProjector.project(transaction);
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,
);
}
await _updateHlcFromIncomingOperations(
operations,
@ -260,10 +389,70 @@ extension CrdtMergeRecorderExtension on CrdtMutationRecorder {
);
}
/// 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,
);
}
Future<void> _applyMergeInsert(
CrdtMergeInsert insert,
Map<UuidValue, CrdtNode> remoteNodes,
MergeContext context,
Map<MergeFieldKey, Object?> authoredOverlays,
ProjectionPlan? batchPlan,
_PendingInsertAttempts pendingAttempts,
CrdtForeignKeyPresenceCache? presence,
Transaction transaction,
) async {
final rowKey = (insert.tableName, insert.uuidRowId);
@ -275,6 +464,10 @@ extension CrdtMergeRecorderExtension on CrdtMutationRecorder {
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) {
@ -290,34 +483,21 @@ extension CrdtMergeRecorderExtension on CrdtMutationRecorder {
_db,
transaction,
(savepoint) async {
final resolvedInsert = await _uniqueResolver.resolveForIncomingInsert(
final planned = await _planMergeInsertValues(
insert,
data,
context,
batchPlan,
presence,
savepoint,
);
final safeInsert = await _foreignKeyProjector.safeIncomingData(
insert.tableName,
insert.uuidRowId,
resolvedInsert.data,
savepoint,
);
final insertedRow = await _applyMergeInsertForMissingRow(
insert,
remoteNode,
incomingHlc,
safeInsert.data,
planned.domain,
savepoint,
);
await _foreignKeyProjector.recordInsertAttempts(
insert.tableName,
{insert.uuidRowId},
savepoint,
safeInsert.attempts,
mergeCache: (fields: context.fields, node: remoteNode),
);
pendingWrites = planned.attempts;
return insertedRow;
},
);
@ -325,6 +505,15 @@ extension CrdtMergeRecorderExtension on CrdtMutationRecorder {
// same-scope recovery), so same-merge field updates resolve ownership
// from the cache instead of re-reading the domain row.
context.domainOwners[rowKey] = (exists: true, scopeId: mergingScopeId);
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);
} on _ForeignScopeRowCollision catch (collision) {
await _throwOwnershipCollision(
operation: CrdtSyncViolationOperation.mergeInsert,
@ -346,8 +535,89 @@ extension CrdtMergeRecorderExtension on CrdtMutationRecorder {
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);
}
/// 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),
);
}
}
@ -355,6 +625,7 @@ extension CrdtMergeRecorderExtension on CrdtMutationRecorder {
CrdtMergeUpdate update,
Map<UuidValue, CrdtNode> remoteNodes,
MergeContext context,
Map<MergeFieldKey, Object?> authoredOverlays,
Transaction transaction,
) async {
final rowKey = (update.tableName, update.uuidRowId);
@ -387,31 +658,8 @@ extension CrdtMergeRecorderExtension on CrdtMutationRecorder {
if (!shouldApply) return;
final resolvedUpdates = await _uniqueResolver.resolveForIncomingUpdate(
update,
row,
context,
transaction,
);
final safeUpdate = await _foreignKeyProjector.safeIncomingData(
update.tableName,
update.uuidRowId,
resolvedUpdates,
transaction,
);
await _context.updateDomainRows(
update.tableName,
{update.uuidRowId},
safeUpdate.data,
transaction,
);
await _foreignKeyProjector.recordAttemptsForRows(
update.tableName,
{update.uuidRowId},
resolvedUpdates.keys.toSet(),
transaction,
attemptedValues: safeUpdate.attempts,
);
authoredOverlays[(update.tableName, update.uuidRowId, update.columnName)] =
update.value;
}
Future<void> _applyMergeDelete(
@ -514,7 +762,18 @@ extension CrdtMergeRecorderExtension on CrdtMutationRecorder {
CrdtDataField? currentField,
Transaction transaction,
) async {
if (currentField == null) {
// 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) {
final insertedField = await CrdtDataField.db.insertRow(
_session,
CrdtDataField(
@ -534,7 +793,7 @@ extension CrdtMergeRecorderExtension on CrdtMutationRecorder {
);
}
final updatedField = currentField.copyWith(
final updatedField = existingField.copyWith(
row: row,
column: column,
nodeId: remoteNode.id,
@ -608,23 +867,13 @@ extension CrdtMergeRecorderExtension on CrdtMutationRecorder {
Map<String, Object?> data,
Transaction transaction,
) async {
final insertedCrdtRow = await _upsertMergeRow(
insert.tableName,
insert.uuidRowId,
remoteNode,
incomingHlc,
null,
transaction,
);
final tableRow = _requireMergeInsertTableRow(insert);
final patchedRow = _db.serializationManager.patchTableRow(tableRow, data);
final patchedRow = _withPlannedDomainValues(tableRow, data);
final mergingScopeId = _context.hlcManagerFor(transaction).normalizedScopeId;
final scopedRow = patchedRow.copyWithScopeId(mergingScopeId);
try {
// Insert first: the domain primary key resolves creation races
// atomically. The inner savepoint keeps a violation from poisoning the
// surrounding transaction so the recovery update below can still run.
// 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,
@ -656,7 +905,14 @@ extension CrdtMergeRecorderExtension on CrdtMutationRecorder {
);
}
return insertedCrdtRow;
return _upsertMergeRow(
insert.tableName,
insert.uuidRowId,
remoteNode,
incomingHlc,
null,
transaction,
);
}
Future<void> _applyMergeInsertForExistingRow(
@ -666,6 +922,7 @@ extension CrdtMergeRecorderExtension on CrdtMutationRecorder {
Hlc incomingHlc,
Map<String, Object?> data,
MergeContext context,
Map<MergeFieldKey, Object?> authoredOverlays,
Transaction transaction,
) async {
if (!await _domainRowOwnedByEffectiveScope(
@ -682,7 +939,6 @@ extension CrdtMergeRecorderExtension on CrdtMutationRecorder {
}
final (_, columnsByName) = _context.schema[insert.tableName]!;
final updatedValues = <String, Object?>{};
for (final MapEntry(key: columnName, value: schemaColumn)
in columnsByName.entries) {
if (columnName == 'id') continue;
@ -700,40 +956,8 @@ extension CrdtMergeRecorderExtension on CrdtMutationRecorder {
);
if (!shouldApply) continue;
updatedValues[columnName] = data[columnName];
}
var updatesToApply = updatedValues;
if (updatesToApply.isNotEmpty) {
updatesToApply = await _uniqueResolver.resolveForIncomingUpdates(
tableName: insert.tableName,
row: currentRow,
updates: updatesToApply,
context: context,
transaction: transaction,
);
}
if (updatesToApply.isNotEmpty) {
final safeUpdate = await _foreignKeyProjector.safeIncomingData(
insert.tableName,
insert.uuidRowId,
updatesToApply,
transaction,
);
await _context.updateDomainRows(
insert.tableName,
{insert.uuidRowId},
safeUpdate.data,
transaction,
);
await _foreignKeyProjector.recordAttemptsForRows(
insert.tableName,
{insert.uuidRowId},
updatesToApply.keys.toSet(),
transaction,
attemptedValues: safeUpdate.attempts,
);
authoredOverlays[(insert.tableName, insert.uuidRowId, columnName)] =
data[columnName];
}
final rowKey = (insert.tableName, insert.uuidRowId);
@ -941,16 +1165,3 @@ class _ForeignScopeRowCollision implements Exception {
final int? owningScopeId;
}
extension on DatabaseSerializationManager {
/// Patches a [TableRow] with the given data.
T patchTableRow<T extends TableRow>(T row, Map<String, Object?> data) {
final rowJson = Map<String, dynamic>.from(row.toJson() as Map);
for (final column in row.table.crdtSyncableColumns) {
if (data.containsKey(column.columnName)) {
rowJson[column.fieldName] = data[column.columnName];
}
}
return deserialize<T>(rowJson);
}
}

View file

@ -1,7 +1,11 @@
import 'dart:typed_data';
import 'package:meta/meta.dart';
import 'package:serverpod_database/serverpod_database.dart';
import 'package:serverpod_serialization/serverpod_serialization.dart';
import 'types.dart';
/// Extensions on [String] used for SQL construction.
@internal
extension SqlStringExtension on String {
@ -46,12 +50,53 @@ extension UuidValueExtension on Object? {
UuidValue? toUuidValue() {
final value = this;
if (value == null) return null;
if (value is UuidValue) return value;
if (value is String) return UuidValue.withValidation(value);
return UuidValueJsonExtension.fromJson(value);
}
}
/// Orders row keys by table name, then row id, for deterministic planning.
@internal
int compareMergeRowKeys(MergeRowKey left, MergeRowKey right) {
final tableComparison = left.$1.compareTo(right.$1);
if (tableComparison != 0) return tableComparison;
return left.$2.uuid.compareTo(right.$2.uuid);
}
/// Canonicalizes a domain/attempted value so UUID blobs round-trip as
/// [UuidValue] rather than as SQLite byte lists.
///
/// Only values already shaped like a UUID are converted. Anything else is
/// returned unchanged, so an ordinary string or number never pays for a thrown
/// [FormatException] on the projection and outbound paths.
@internal
Object? canonicalDomainValue(Object? value) {
if (value is UuidValue) return value;
if (value is Uint8List) {
return value.length == 16 ? UuidValue.fromByteList(value) : value;
}
if (value is String) {
return _looksLikeUuid(value) ? UuidValue.withValidation(value) : value;
}
return value;
}
/// Whether [value] has the 8-4-4-4-12 shape of a canonical UUID string.
bool _looksLikeUuid(String value) {
if (value.length != 36) return false;
for (var index = 0; index < 36; index++) {
final code = value.codeUnitAt(index);
if (index == 8 || index == 13 || index == 18 || index == 23) {
if (code != 0x2d) return false;
continue;
}
final isDigit = code >= 0x30 && code <= 0x39;
final isLower = code >= 0x61 && code <= 0x66;
final isUpper = code >= 0x41 && code <= 0x46;
if (!isDigit && !isLower && !isUpper) return false;
}
return true;
}
/// Extensions on [UuidValue?] used for UUID comparison.
@internal
extension UuidValueComparisonExtension on UuidValue? {

View file

@ -132,6 +132,29 @@ class CrdtForeignKeyGraph {
);
}
/// Tables joined to [seedTables] by any chain of foreign keys.
///
/// Projection resolves cascade and repair to a fixed point over the foreign
/// key graph, so a row can only be affected by a change in a table its own
/// table can reach. Unique conflicts are resolved within one table and never
/// cross this boundary, so loading the seeds' connected components is enough
/// to reproduce a full-graph pass.
Set<String> connectedTables(Iterable<String> seedTables) {
final reached = <String>{};
final queue = [...seedTables];
while (queue.isNotEmpty) {
final tableName = queue.removeLast();
if (!reached.add(tableName)) continue;
for (final edge in edgesByChildTable[tableName] ?? const <ForeignKeyEdge>[]) {
queue.add(edge.parentTableName);
}
for (final edge in edgesByParentTable[tableName] ?? const <ForeignKeyEdge>[]) {
queue.add(edge.childTableName);
}
}
return reached;
}
/// The child column names of foreign keys outgoing from [tableName].
///
/// When [columnNames] is provided, only edges whose child column is in the set

View file

@ -1,5 +1,5 @@
import 'package:meta/meta.dart';
import 'package:serverpod_database/serverpod_database.dart';
import 'package:serverpod_database/serverpod_database.dart' hide Protocol;
import 'package:serverpod_serialization/serverpod_serialization.dart';
import '../../crdt/extensions.dart';
@ -361,56 +361,189 @@ class CrdtRecorderContext {
);
}
/// Row ids in [tableName] whose [columnName] holds one of [values].
///
/// Unlike [findVisibleReferencingRowIds] this keeps hidden rows: projection
/// has to see a hidden child before it can decide whether the child comes
/// back with its parent.
Future<Set<UuidValue>> findDomainRowIdsWhereColumnIn({
required String tableName,
required String columnName,
required Set<Object?> values,
required Transaction transaction,
}) => findDomainRowIdsWhereColumnsIn(
tableName: tableName,
valuesByColumn: {columnName: values},
transaction: transaction,
);
/// Row ids of [tableName] owed one of [values] on one of [columnNames].
///
/// A row that does not hold its authored value keeps it in a sparse attempted
/// value, so this is how a pass finds the rows waiting for a claim or a
/// parent it is about to free. The comparison literal is built by the same
/// encoders the write path uses — the dynamic envelope from the protocol, then
/// the structured-column encoder — which is what keeps it dialect correct:
/// `jsonb(...)` against SQLite's binary JSONB, a jsonb literal on Postgres.
Future<Set<UuidValue>> findRowIdsHoldingAttemptedValues({
required String tableName,
required Set<String> columnNames,
required Set<Object?> values,
required Transaction transaction,
}) async {
if (columnNames.isEmpty || values.isEmpty) return const {};
final (tableId, columns) = schema[tableName]!;
final columnIds = {
for (final columnName in columnNames) ?columns[columnName]?.id,
};
if (columnIds.isEmpty) return const {};
final encodedValues = {
for (final value in values)
if (value != null)
ValueEncoder.instance.encodeColumnValue(
CrdtDataAttemptedValue.t.value,
Protocol().dynamicFieldToJson(value),
),
};
if (encodedValues.isEmpty) return const {};
final scopeId = hlcManagerFor(transaction).normalizedScopeId;
final result = await database.unsafeQuery(
'''
SELECT DISTINCT r."uuidRowId"
FROM "crdt_data_attempted_value" a
JOIN "crdt_data_fields" f ON f."id" = a."fieldId"
JOIN "crdt_data_rows" r ON r."id" = f."rowId"
WHERE r."scopeId" = $scopeId
AND r."tblId" = $tableId
AND f."columnId" IN (${columnIds.join(', ')})
AND a."value" IN (${encodedValues.join(', ')})
''',
transaction: transaction,
);
return _rowIds(result);
}
/// Row ids in [tableName] whose columns hold one of the listed values.
///
/// A composite unique index is matched column by column, so the result is a
/// superset of the exact tuples. Loading a few extra rows is what a whole
/// table load did anyway, and the query stays a plain indexed lookup.
Future<Set<UuidValue>> findDomainRowIdsWhereColumnsIn({
required String tableName,
required Map<String, Set<Object?>> valuesByColumn,
required Transaction transaction,
}) async {
if (valuesByColumn.isEmpty) return const {};
if (valuesByColumn.values.any((values) => values.isEmpty)) return const {};
final predicates = valuesByColumn.entries
.map(
(entry) =>
'"${entry.key.escapeIdentifier()}" IN (${entry.value.sqlLiteralList()})',
)
.join(') AND (');
final result = await database.unsafeQuery(
'''
SELECT "id"
FROM "${tableName.escapeIdentifier()}"
WHERE ($predicates)
''',
transaction: transaction,
);
return _rowIds(result);
}
Future<Set<UuidValue>> findVisibleDomainRowIdsWhere({
required String tableName,
required List<String> predicates,
required Transaction transaction,
}) async {
final (tableId, _) = schema[tableName]!;
final scopeId = hlcManagerFor(transaction).normalizedScopeId;
final result = await database.unsafeQuery(
'''
SELECT d."id"
FROM "${tableName.escapeIdentifier()}" d
LEFT JOIN "crdt_data_rows" r
ON r."scopeId" = $scopeId AND r."tblId" = $tableId AND r."uuidRowId" = d."id"
${_visibilityJoin(tableName, transaction)}
WHERE (${predicates.join(') AND (')})
AND (r."id" IS NULL OR r."visibility" <= $crdtRowLastVisibleVisibilityIndex)
AND ($_rowVisible)
''',
transaction: transaction,
);
return {
for (final row in result) UuidValueJsonExtension.fromJson(row.first),
};
return _rowIds(result);
}
/// The `FROM` and `JOIN` pairing domain rows of [tableName] with their CRDT
/// metadata, as the aliases `d` and `r`.
///
/// An untracked row has no metadata, so `r` is null for it; [_rowVisible]
/// reads that pair as one boolean.
String _visibilityJoin(String tableName, Transaction transaction) {
final (tableId, _) = schema[tableName]!;
final scopeId = hlcManagerFor(transaction).normalizedScopeId;
return '''
FROM "${tableName.escapeIdentifier()}" d
LEFT JOIN "crdt_data_rows" r
ON r."scopeId" = $scopeId AND r."tblId" = $tableId AND r."uuidRowId" = d."id"''';
}
/// Whether the row joined by [_visibilityJoin] is visible in this scope.
static final _rowVisible =
'r."id" IS NULL OR r."visibility" <= $crdtRowLastVisibleVisibilityIndex';
/// Presence of the [value] target in [parentTableName].
///
/// A foreign key can only reference a unique column, so at most one row
/// answers; a value with no row at all is [ForeignKeyTargetPresence.absent].
Future<ForeignKeyTargetPresence> lookupForeignKeyTargetPresence({
required String parentTableName,
required String parentColumn,
required UuidValue value,
required Transaction transaction,
}) async {
final (tableId, _) = schema[parentTableName]!;
final presences = await lookupForeignKeyTargetPresences(
parentTableName: parentTableName,
parentColumn: parentColumn,
values: {value},
transaction: transaction,
);
return presences[value] ?? ForeignKeyTargetPresence.absent;
}
/// Presence of every [values] target in [parentTableName], by value.
///
/// Resolving a whole merge batch at once is what keeps the single-value form
/// off the insert path, where it asked once per foreign key of every row.
/// Values with no row are absent from the result rather than reported.
Future<Map<UuidValue, ForeignKeyTargetPresence>> lookupForeignKeyTargetPresences({
required String parentTableName,
required String parentColumn,
required Set<UuidValue> values,
required Transaction transaction,
}) async {
if (values.isEmpty) return const {};
final scopeId = hlcManagerFor(transaction).normalizedScopeId;
final result = await database.unsafeQuery(
'''
SELECT CASE
WHEN r."id" IS NULL OR r."visibility" <= $crdtRowLastVisibleVisibilityIndex THEN 1
ELSE 0
END AS visible
FROM "${parentTableName.escapeIdentifier()}" d
LEFT JOIN "crdt_data_rows" r
ON r."scopeId" = $scopeId AND r."tblId" = $tableId AND r."uuidRowId" = d."id"
SELECT d."${parentColumn.escapeIdentifier()}",
CASE WHEN $_rowVisible THEN 1 ELSE 0 END AS visible
${_visibilityJoin(parentTableName, transaction)}
WHERE (${domainColumnPredicate('scopeId', scopeId)})
AND (${domainColumnPredicate(parentColumn, value)})
LIMIT 1
AND (d."${parentColumn.escapeIdentifier()}" IN (${values.sqlLiteralList()}))
''',
transaction: transaction,
);
if (result.isEmpty) return ForeignKeyTargetPresence.absent;
return (result.first[0] as num) == 1
? ForeignKeyTargetPresence.visible
: ForeignKeyTargetPresence.hidden;
return {
for (final row in result)
UuidValueJsonExtension.fromJson(row.first): (row[1] as num) == 1
? ForeignKeyTargetPresence.visible
: ForeignKeyTargetPresence.hidden,
};
}
Future<void> updateDomainRows(
@ -435,6 +568,11 @@ WHERE "id" IN (${rowIds.sqlLiteralList()})
);
}
/// The `id` column of every row of a result whose first column is a row id.
Set<UuidValue> _rowIds(DatabaseResult result) => {
for (final row in result) UuidValueJsonExtension.fromJson(row.first),
};
Future<Map<UuidValue, Map<String, Object?>>> readDomainColumnValues(
String tableName,
Set<UuidValue> rowIds,

View file

@ -41,3 +41,26 @@ typedef MergeNodes = ({
Map<UuidValue, CrdtNode> nodesByUuid,
Map<UuidValue, CrdtScopeNode> scopeNodesByUuid,
});
/// A row that will be written after projection, included in unique/FK planning.
@internal
typedef PendingProjectionRow = ({
String tableName,
UuidValue rowId,
Map<String, Object?> authoredValues,
Hlc rowHlc,
CrdtNode node,
bool hidden,
});
/// The outcome of one projection pass.
///
/// `domain` holds the final materialized value per row and column; `reasons`
/// holds the terminal projector that selected a value that differs from the
/// authored one, so callers that persist attempted values after their own
/// write record the real cause instead of guessing.
@internal
typedef ProjectionPlan = ({
Map<MergeRowKey, Map<String, Object?>> domain,
Map<MergeFieldKey, CrdtProjectionReason> reasons,
});

View file

@ -1,9 +1,7 @@
import 'package:meta/meta.dart';
import 'package:serverpod_database/serverpod_database.dart';
import 'package:serverpod_serialization/serverpod_serialization.dart';
import '../../crdt/extensions.dart';
import '../../crdt/merge.dart';
import '../../generated/protocol.dart';
import '../../hlc/hlc.dart';
import '../unique_index_utils.dart';
@ -11,434 +9,265 @@ import 'database_helpers.dart';
import 'recorder_context.dart';
import 'types.dart';
typedef _UniqueConflict = ({
CrdtDataRow row,
UniqueIndexConflictRelease uniqueIndex,
});
/// Unique-index stage of the fact-plus-projection planner.
///
/// Groups by authored / FK-safe claim values rather than previously released
/// domain values. Hidden rows are released unconditionally and never join
/// winner selection.
@internal
/// Resolves unique constraint conflicts that appear while merging CRDT
/// changes or soft-deleting rows.
class CrdtUniqueConflictResolver {
/// Creates a resolver over the given recorder context.
CrdtUniqueConflictResolver(this._context);
final CrdtRecorderContext _context;
/// Resolves unique conflicts for an incoming insert.
Future<({Map<String, Object?> data, bool changed})> resolveForIncomingInsert(
CrdtMergeInsert insert,
Map<String, Object?> data,
MergeContext context,
Transaction transaction,
) async {
final resolvedData = Map<String, Object?>.from(data);
var changed = false;
final conflicts = await _findVisibleUniqueConflicts(
tableName: insert.tableName,
rowId: insert.uuidRowId,
values: data,
transaction: transaction,
);
/// Whether [tableName] has any synchronized unique index.
bool tableHasUniqueIndexes(String tableName) =>
uniqueIndexesFor(tableName).isNotEmpty;
for (final conflict in conflicts) {
final incomingClaimHlc = _incomingInsertUniqueClaim(
insert: insert,
uniqueIndex: conflict.uniqueIndex,
context: context,
);
final conflictClaimHlc = await _uniqueIndexHlc(
tableName: insert.tableName,
row: conflict.row,
uniqueIndex: conflict.uniqueIndex,
fields: context.fields,
transaction: transaction,
);
if (incomingClaimHlc > conflictClaimHlc) {
resolvedData.addAll(
_uniqueConflictFreeValuesForData(
insert.tableName,
insert.uuidRowId,
resolvedData,
conflict.uniqueIndex,
),
);
changed = true;
} else {
await _releaseUniqueConflictForRow(
tableName: insert.tableName,
rowId: conflict.row.uuidRowId,
uniqueIndex: conflict.uniqueIndex,
transaction: transaction,
);
}
}
return (data: resolvedData, changed: changed);
}
Hlc _incomingInsertUniqueClaim({
required CrdtMergeInsert insert,
required UniqueIndexConflictRelease uniqueIndex,
required MergeContext context,
}) {
var uniqueHlc = insert.hlc;
for (final columnName in uniqueIndex.indexedColumns) {
uniqueHlc = uniqueHlc.maxBetween(
context.incomingFieldHlcs[(
insert.tableName,
insert.uuidRowId,
columnName,
)],
);
}
return uniqueHlc;
}
/// Resolves unique conflicts for a single incoming field update.
Future<Map<String, Object?>> resolveForIncomingUpdate(
CrdtMergeUpdate update,
CrdtDataRow row,
MergeContext context,
Transaction transaction,
) {
return resolveForIncomingUpdates(
tableName: update.tableName,
row: row,
updates: {update.columnName: update.value},
context: context,
transaction: transaction,
);
}
/// Resolves unique conflicts for one or more incoming field updates.
Future<Map<String, Object?>> resolveForIncomingUpdates({
required String tableName,
required CrdtDataRow row,
required Map<String, Object?> updates,
required MergeContext context,
required Transaction transaction,
}) async {
if (updates.keys.every(
(columnName) => !_isUniqueIndexedColumn(tableName, columnName),
)) {
return updates;
}
final tableDefinition = _context.tableDefinitionsByName[tableName];
if (tableDefinition == null) {
return updates;
}
final uniqueColumnNamesToRead = {
for (final uniqueIndex in _context.uniqueIndexesForTable(tableDefinition))
...uniqueIndex.indexedColumns,
};
if (uniqueColumnNamesToRead.isEmpty) {
return updates;
}
final currentValues = await _context.readDomainColumnValues(
tableName,
{row.uuidRowId},
uniqueColumnNamesToRead.toList(),
transaction,
);
final values = {
...?currentValues[row.uuidRowId],
...updates,
};
final conflicts = await _findVisibleUniqueConflicts(
tableName: tableName,
rowId: row.uuidRowId,
values: values,
transaction: transaction,
);
final resolvedUpdates = Map<String, Object?>.from(updates);
for (final conflict in conflicts) {
final rowClaimHlc = await _uniqueIndexHlc(
tableName: tableName,
row: row,
uniqueIndex: conflict.uniqueIndex,
fields: context.fields,
transaction: transaction,
);
final conflictClaimHlc = await _uniqueIndexHlc(
tableName: tableName,
row: conflict.row,
uniqueIndex: conflict.uniqueIndex,
fields: context.fields,
transaction: transaction,
);
if (rowClaimHlc > conflictClaimHlc) {
final releasedValues = _uniqueConflictFreeValuesForData(
tableName,
row.uuidRowId,
values,
conflict.uniqueIndex,
);
for (final columnName in conflict.uniqueIndex.releaseColumnNames) {
if (releasedValues.containsKey(columnName)) {
resolvedUpdates[columnName] = releasedValues[columnName];
values[columnName] = releasedValues[columnName];
}
}
} else {
await _releaseUniqueConflictForRow(
tableName: tableName,
rowId: conflict.row.uuidRowId,
uniqueIndex: conflict.uniqueIndex,
transaction: transaction,
);
}
}
return resolvedUpdates;
}
Map<String, Object?> _uniqueConflictFreeValuesForData(
String tableName,
UuidValue rowId,
Map<String, Object?> data,
UniqueIndexConflictRelease uniqueIndex,
) {
final tableDefinition = _context.tableDefinitionsByName[tableName];
if (tableDefinition == null) return data;
final released = Map<String, Object?>.from(data);
final values = {
for (final columnName in uniqueIndex.indexedColumns)
columnName: released[columnName],
};
if (values.values.any((value) => value == null)) return released;
for (final column in uniqueIndex.releaseColumns) {
released[column.columnName] = _context.conflictFreeValue(
column,
values[column.columnName],
tableName,
rowId,
'conflict',
);
}
return released;
}
Future<void> _releaseUniqueConflictForRow({
required String tableName,
required UuidValue rowId,
required UniqueIndexConflictRelease uniqueIndex,
required Transaction transaction,
}) async {
final valuesByRowId = await _context.readDomainColumnValues(
tableName,
{rowId},
uniqueIndex.indexedColumns,
transaction,
);
final values = valuesByRowId[rowId];
if (values == null || values.values.any((value) => value == null)) return;
final releasedValues = _uniqueConflictFreeValuesForData(
tableName,
rowId,
values,
uniqueIndex,
);
await _context.updateDomainRows(tableName, {rowId}, releasedValues, transaction);
}
Future<List<_UniqueConflict>> _findVisibleUniqueConflicts({
required String tableName,
required UuidValue rowId,
required Map<String, Object?> values,
required Transaction transaction,
}) async {
/// Unique indexes declared for [tableName].
List<UniqueIndexConflictRelease> uniqueIndexesFor(String tableName) {
final tableDefinition = _context.tableDefinitionsByName[tableName];
if (tableDefinition == null) return const [];
final uniqueIndexes = _context.uniqueIndexesForTable(tableDefinition);
if (uniqueIndexes.isEmpty) return const [];
final uniqueIndexesByConflictId = <UuidValue, Set<UniqueIndexConflictRelease>>{};
for (final uniqueIndex in uniqueIndexes) {
final columnValues = {
for (final columnName in uniqueIndex.indexedColumns)
if (values.containsKey(columnName)) columnName: values[columnName],
};
if (uniqueIndex.scoped) {
columnValues['scopeId'] = _context.hlcManagerFor(transaction).normalizedScopeId;
}
final expectedValueCount =
uniqueIndex.indexedColumns.length + (uniqueIndex.scoped ? 1 : 0);
if (columnValues.length != expectedValueCount) continue;
if (columnValues.values.any((value) => value == null)) continue;
final uniqueConflictIds = await _findVisibleRowsByUniqueValues(
tableName: tableName,
rowId: rowId,
values: columnValues,
transaction: transaction,
);
for (final conflictId in uniqueConflictIds) {
uniqueIndexesByConflictId.putIfAbsent(conflictId, () => {}).add(uniqueIndex);
}
}
if (uniqueIndexesByConflictId.isEmpty) return const [];
final conflictRows = await _context.findCrdtRows(
tableName,
uniqueIndexesByConflictId.keys.toSet(),
transaction,
include: CrdtDataRow.include(
node: CrdtNode.include(),
deleted: CrdtDataDeleted.include(node: CrdtNode.include()),
),
);
return [
for (final conflictRow in conflictRows)
for (final uniqueIndex in uniqueIndexesByConflictId[conflictRow.uuidRowId]!)
(
row: conflictRow,
uniqueIndex: uniqueIndex,
),
];
return _context.uniqueIndexesForTable(tableDefinition);
}
/// Returns the HLC for this row's current claim on [uniqueIndex].
///
/// Composite indexes use the newest field HLC among their columns; rows with
/// no field metadata fall back to the row insert HLC. Missing field metadata
/// is loaded lazily because conflicting rows may not be part of the preloaded
/// merge context.
Future<Hlc> _uniqueIndexHlc({
required String tableName,
required CrdtDataRow row,
required UniqueIndexConflictRelease uniqueIndex,
required Map<MergeFieldKey, CrdtDataField> fields,
required Transaction transaction,
}) async {
final uniqueColumnNames = {
...uniqueIndex.indexedColumns,
/// Unique-indexed column names for [tableName], excluding `scopeId`.
Set<String> uniqueColumnNamesFor(String tableName) {
return {
for (final uniqueIndex in uniqueIndexesFor(tableName))
...uniqueIndex.indexedColumns,
};
final missingColumnIds = <int>{};
final maxFieldHlc = <Hlc>[];
for (final columnName in uniqueColumnNames) {
final field = fields[(tableName, row.uuidRowId, columnName)];
if (field == null) {
final columnId = _context.schemaColumn(tableName, columnName)?.id;
if (columnId != null) missingColumnIds.add(columnId);
continue;
}
maxFieldHlc.add(field.hlc);
}
if (missingColumnIds.isNotEmpty && row.id != null) {
final loadedFields = await CrdtDataField.db.find(
_context.databaseSession,
where: (t) => t.rowId.equals(row.id) & t.columnId.inSet(missingColumnIds),
include: CrdtDataField.include(
column: CrdtSchemaColumn.include(),
node: CrdtNode.include(),
),
transaction: transaction,
);
for (final field in loadedFields) {
final columnName = field.column?.name;
if (columnName == null) continue;
fields[(tableName, row.uuidRowId, columnName)] = field;
maxFieldHlc.add(field.hlc);
}
}
if (maxFieldHlc.isEmpty) return row.hlc;
return maxFieldHlc.reduce((left, right) => left.maxBetween(right));
}
Future<Set<UuidValue>> _findVisibleRowsByUniqueValues({
required String tableName,
required UuidValue rowId,
required Map<String, Object?> values,
required Transaction transaction,
}) async {
return _context.findVisibleDomainRowIdsWhere(
tableName: tableName,
predicates: [
for (final MapEntry(key: columnName, value: value) in values.entries)
domainColumnPredicate(columnName, value),
domainColumnNotPredicate('id', rowId),
],
transaction: transaction,
);
}
/// Whether [columnName] participates in any unique index of [tableName].
///
/// Foreign key projection needs this to know when materializing a value onto
/// a hidden row would occupy the physical unique index.
bool isUniqueIndexedColumn(String tableName, String columnName) =>
_isUniqueIndexedColumn(tableName, columnName);
bool _isUniqueIndexedColumn(String tableName, String columnName) {
final tableDefinition = _context.tableDefinitionsByName[tableName];
if (tableDefinition == null) return false;
return _context
.uniqueIndexesForTable(tableDefinition)
.any((i) => i.indexedColumns.contains(columnName));
bool isUniqueIndexedColumn(String tableName, String columnName) {
return uniqueColumnNamesFor(tableName).contains(columnName);
}
Future<void> releaseOnDelete(
String tableName,
Set<UuidValue> rowIds,
Transaction transaction,
) async {
final tableDefinition = _context.tableDefinitionsByName[tableName];
if (tableDefinition == null) return;
/// Releasable unique columns for [tableName], used by two-phase parking.
///
/// A column can be indexed more than once, and releasing it is one act, so
/// the columns are deduplicated by name.
List<UniqueColumnConflictRelease> uniqueReleaseColumnsFor(String tableName) {
final byName = <String, UniqueColumnConflictRelease>{};
for (final uniqueIndex in uniqueIndexesFor(tableName)) {
for (final column in uniqueIndex.releaseColumns) {
byName[column.columnName] = column;
}
}
return byName.values.toList();
}
final uniqueIndexes = _context.uniqueIndexesForTable(tableDefinition);
for (final uniqueIndex in uniqueIndexes) {
final releaseColumnNames = uniqueIndex.releaseColumnNames.toList();
/// Mutates [valuesByRow] to unique-final domain values and returns the
/// terminal unique reasons for fields this stage changed.
///
/// [claimByField] is the unique grouping key: FK-safe candidate for FK
/// fields, otherwise the authored value. Grouping never uses a previously
/// released materialized value.
Map<MergeFieldKey, CrdtProjectionReason> planUniqueProjection({
required Map<MergeRowKey, Map<String, Object?>> valuesByRow,
required Map<MergeRowKey, CrdtDataRow> crdtRows,
required Set<MergeRowKey> hidden,
required Map<MergeFieldKey, Object?> authoredByField,
required Map<MergeFieldKey, Object?> claimByField,
required Map<MergeFieldKey, Hlc> fieldHlcs,
}) {
final reasons = <MergeFieldKey, CrdtProjectionReason>{};
final tableNames = {for (final rowKey in valuesByRow.keys) rowKey.$1};
final valuesByRowId = await _context.readDomainColumnValues(
tableName,
rowIds,
uniqueIndex.indexedColumns,
transaction,
);
final updatedRowIds = <UuidValue>{};
for (final tableName in tableNames) {
final tableDefinition = _context.tableDefinitionsByName[tableName];
if (tableDefinition == null) continue;
final uniqueIndexes = _context.uniqueIndexesForTable(tableDefinition);
if (uniqueIndexes.isEmpty) continue;
for (final MapEntry(key: rowId, value: values) in valuesByRowId.entries) {
if (values.values.any((value) => value == null)) continue;
final tableRowKeys = [
for (final rowKey in valuesByRow.keys)
if (rowKey.$1 == tableName) rowKey,
]..sort(compareMergeRowKeys);
final updates = <String, Object?>{};
for (final column in uniqueIndex.releaseColumns) {
updates[column.columnName] = _context.conflictFreeValue(
column,
values[column.columnName],
tableDefinition.name,
rowId,
'deleted',
for (final uniqueIndex in uniqueIndexes) {
for (final rowKey in tableRowKeys) {
if (!hidden.contains(rowKey)) continue;
if (_claimTuple(rowKey, uniqueIndex, claimByField) == null) continue;
_releaseRow(
rowKey: rowKey,
uniqueIndex: uniqueIndex,
authoredByField: authoredByField,
valuesByRow: valuesByRow,
reasons: reasons,
releaseSuffix: 'hidden',
reason: CrdtProjectionReason.hiddenUniqueRelease,
);
}
await _context.updateDomainRows(tableName, {rowId}, updates, transaction);
updatedRowIds.add(rowId);
}
final groups = <String, List<MergeRowKey>>{};
for (final rowKey in tableRowKeys) {
if (hidden.contains(rowKey)) continue;
final claim = _claimTuple(rowKey, uniqueIndex, claimByField);
if (claim == null) continue;
groups.putIfAbsent(claim, () => []).add(rowKey);
}
if (updatedRowIds.isNotEmpty) {
await _context.recordFieldsUpdatedByTable(
tableName,
updatedRowIds,
releaseColumnNames,
transaction,
);
for (final group in groups.values) {
group.sort(
(left, right) => _compareUniqueClaims(
left: left,
right: right,
uniqueIndex: uniqueIndex,
crdtRows: crdtRows,
fieldHlcs: fieldHlcs,
),
);
final winner = group.first;
_materializeCanonicalClaim(
rowKey: winner,
uniqueIndex: uniqueIndex,
claimByField: claimByField,
valuesByRow: valuesByRow,
);
for (final loser in group.skip(1)) {
_releaseRow(
rowKey: loser,
uniqueIndex: uniqueIndex,
authoredByField: authoredByField,
valuesByRow: valuesByRow,
reasons: reasons,
releaseSuffix: 'conflict',
reason: CrdtProjectionReason.uniqueConflict,
);
}
}
}
}
return reasons;
}
String? _claimTuple(
MergeRowKey rowKey,
UniqueIndexConflictRelease uniqueIndex,
Map<MergeFieldKey, Object?> claimByField,
) {
final parts = <String>[];
for (final columnName in uniqueIndex.indexedColumns) {
final value = claimByField[(rowKey.$1, rowKey.$2, columnName)];
if (value == null) return null;
parts.add(canonicalProjectionValue(value));
}
return parts.join('\x1f');
}
void _materializeCanonicalClaim({
required MergeRowKey rowKey,
required UniqueIndexConflictRelease uniqueIndex,
required Map<MergeFieldKey, Object?> claimByField,
required Map<MergeRowKey, Map<String, Object?>> valuesByRow,
}) {
final values = valuesByRow[rowKey];
if (values == null) return;
for (final columnName in uniqueIndex.indexedColumns) {
values[columnName] = claimByField[(rowKey.$1, rowKey.$2, columnName)];
}
}
void _releaseRow({
required MergeRowKey rowKey,
required UniqueIndexConflictRelease uniqueIndex,
required Map<MergeFieldKey, Object?> authoredByField,
required Map<MergeRowKey, Map<String, Object?>> valuesByRow,
required Map<MergeFieldKey, CrdtProjectionReason> reasons,
required String releaseSuffix,
required CrdtProjectionReason reason,
}) {
final values = valuesByRow[rowKey];
if (values == null) return;
for (final column in uniqueIndex.releaseColumns) {
final authored = authoredByField[(rowKey.$1, rowKey.$2, column.columnName)];
final released = _context.conflictFreeValue(
column,
authored ?? values[column.columnName],
rowKey.$1,
rowKey.$2,
releaseSuffix,
);
values[column.columnName] = released;
if (!projectionValuesEqual(released, authored)) {
reasons[(rowKey.$1, rowKey.$2, column.columnName)] = reason;
}
}
}
int _compareUniqueClaims({
required MergeRowKey left,
required MergeRowKey right,
required UniqueIndexConflictRelease uniqueIndex,
required Map<MergeRowKey, CrdtDataRow> crdtRows,
required Map<MergeFieldKey, Hlc> fieldHlcs,
}) {
final leftHlc = _uniqueIndexHlc(
rowKey: left,
uniqueIndex: uniqueIndex,
crdtRows: crdtRows,
fieldHlcs: fieldHlcs,
);
final rightHlc = _uniqueIndexHlc(
rowKey: right,
uniqueIndex: uniqueIndex,
crdtRows: crdtRows,
fieldHlcs: fieldHlcs,
);
final comparison = leftHlc.compareTo(rightHlc);
if (comparison != 0) return comparison;
return left.$2.uuid.compareTo(right.$2.uuid);
}
Hlc _uniqueIndexHlc({
required MergeRowKey rowKey,
required UniqueIndexConflictRelease uniqueIndex,
required Map<MergeRowKey, CrdtDataRow> crdtRows,
required Map<MergeFieldKey, Hlc> fieldHlcs,
}) {
final fieldClaimHlcs = [
for (final columnName in uniqueIndex.indexedColumns)
?fieldHlcs[(rowKey.$1, rowKey.$2, columnName)],
];
if (fieldClaimHlcs.isNotEmpty) {
return fieldClaimHlcs.reduce((left, right) => left.maxBetween(right));
}
return crdtRows[rowKey]!.hlc;
}
}
/// Canonical token for unique-claim grouping and equality.
@internal
String canonicalProjectionValue(Object? value) {
if (value == null) return '';
final uuid = tryUuidValue(value);
if (uuid != null) return uuid.uuid;
return value.toString();
}
/// Whether two projection values represent the same domain/authored fact.
@internal
bool projectionValuesEqual(Object? left, Object? right) {
if (left == null || right == null) return left == right;
final leftUuid = tryUuidValue(left);
final rightUuid = tryUuidValue(right);
if (leftUuid != null && rightUuid != null) {
return leftUuid.sameUuidValue(rightUuid);
}
return left == right;
}
/// Parses [value] as a UUID when it is one, otherwise null.
@internal
UuidValue? tryUuidValue(Object? value) {
try {
return value.toUuidValue();
} on Object {
return null;
}
}

View file

@ -50,6 +50,12 @@ class CrdtDatabaseContext {
_CrdtSchema? _schema;
Future<_CrdtSchema>? _schemaFuture;
var _registryChanged = false;
/// Whether schema reconciliation inserted, updated, or deleted registry rows
/// during this context's first [initialize].
@internal
bool get registryChanged => _registryChanged;
/// Initializes shared schema rows and caches their generated identifiers.
Future<void> initialize(DatabaseSession session) async {
@ -73,6 +79,7 @@ class CrdtDatabaseContext {
tableDefinitions: _tableDefinitions,
);
final (tableRows, columnRows) = await schemaRegistry.syncAndGetSchema();
_registryChanged = schemaRegistry.registryChanged;
final columnsByTableId = <int, Map<String, CrdtSchemaColumn>>{};
for (final column in columnRows) {
@ -257,12 +264,33 @@ class CrdtMutationRecorder {
Future<void> _initializeOnce() async {
await _databaseContext.initialize(_session);
if (_databaseContext.registryChanged) {
await _rebuildProjectionsForAllScopes();
}
if (persistentUserId != null) {
await _context.scopeManager.getOrCreate(persistentUserId!);
}
_isInitialized = true;
}
Future<void> _rebuildProjectionsForAllScopes() async {
final scopes = await CrdtScope.db.find(
_session,
include: CrdtScope.include(currentNode: CrdtNode.include()),
);
for (final scope in scopes) {
if (scope.currentNode == null || scope.currentNodeId == null) continue;
await _db.transaction((tx) async {
scopeForTransaction[tx] = scope;
try {
await _foreignKeyProjector.project(tx);
} finally {
scopeForTransaction.remove(tx);
}
});
}
}
/// The user ID to use for all CRDT operations. This should only be used for
/// databases operating on the client side, where all data is for the same user.
/// Otherwise, the user ID must be passed through the transaction.
@ -328,11 +356,224 @@ class CrdtMutationRecorder {
});
}
/// Recomputes FK/unique projection for currently stored rows.
///
/// Used before upsert so hidden unique claims are released before the
/// physical write can occupy the same index.
Future<void> projectCurrent(
String tableName,
Set<UuidValue> rowIds,
Transaction transaction,
) {
return _foreignKeyProjector.project(
transaction,
seedTables: {tableName},
seedRows: {for (final rowId in rowIds) (tableName, rowId)},
);
}
/// Plans FK/unique projection for rows that are about to be inserted.
///
/// Releases hidden unique claims first and returns copies whose unique/FK
/// columns already hold the planned domain values, so the physical write
/// cannot violate an immediate unique index. Authored values that differ
/// from the planned domain are returned so they can be stored as attempted
/// values after the insert.
Future<({List<T> rows, ProjectionAttemptsByField attempts})>
planLocalInserts<T extends TableRow>(
List<T> rows,
Transaction transaction,
) async {
const unplanned = <MergeFieldKey, ProjectionAttempt>{};
final tableName = rows.firstOrNull?.table.tableName;
if (tableName == null ||
!_context.isCrdtTrackedTableName(tableName) ||
!_foreignKeyProjector.needsProjection(tableName, null)) {
return (rows: rows, attempts: unplanned);
}
final hlcManager = _context.hlcManagerFor(transaction);
final pendingHlc = hlcManager.peekNext();
final node = hlcManager.getNode();
// A row with no id yet takes a database-generated one, so there is nothing
// to plan against: no stored row can be contesting a claim it cannot name.
final pending = <PendingProjectionRow>[
for (final row in rows)
if (row.id case final UuidValue rowId)
(
tableName: tableName,
rowId: rowId,
authoredValues: _authoredValuesFromRow(row, null),
rowHlc: pendingHlc,
node: node,
hidden: false,
),
];
if (pending.isEmpty) return (rows: rows, attempts: unplanned);
final planned = await _foreignKeyProjector.project(
transaction,
pendingInserts: pending,
seedTables: {tableName},
seedRows: {for (final row in pending) (tableName, row.rowId)},
);
final attempts = <MergeFieldKey, ProjectionAttempt>{};
for (final pendingRow in pending) {
final plannedDomain =
planned.domain[(pendingRow.tableName, pendingRow.rowId)] ??
pendingRow.authoredValues;
for (final MapEntry(key: columnName, value: authored)
in pendingRow.authoredValues.entries) {
if (projectionValuesEqual(plannedDomain[columnName], authored)) continue;
final fieldKey = (pendingRow.tableName, pendingRow.rowId, columnName);
final reason = planned.reasons[fieldKey];
// No reason means projection did not choose this value. The planner
// also returns the stored row for an id the insert will not create
// (`ignoreConflicts`), and that difference is not an authored attempt.
if (reason == null) continue;
attempts[fieldKey] = (value: authored, reason: reason);
}
}
return (
rows: [
for (final row in rows)
if (row.id case final UuidValue rowId)
_withPlannedDomainValues(row, planned.domain[(tableName, rowId)])
else
row,
],
attempts: attempts,
);
}
/// Plans FK/unique projection for rows that are about to be updated.
Future<List<T>> planLocalUpdates<T extends TableRow>(
List<T> rows,
List<Column>? columns,
Transaction transaction,
) async {
if (rows.isEmpty) return rows;
final tableName = rows.first.table.tableName;
if (!_context.isCrdtTrackedTableName(tableName)) return rows;
final columnNames = columns?.map((column) => column.columnName).toSet();
if (!_foreignKeyProjector.needsProjection(tableName, columnNames)) {
return rows;
}
final authored = {
for (final row in rows)
if (row.id is UuidValue)
for (final MapEntry(key: columnName, value: value) in _authoredValuesFromRow(
row,
columns,
).entries)
(tableName, row.id as UuidValue, columnName): canonicalDomainValue(
value,
),
};
var overlays = authored;
if (columns == null) {
final currentDomain = await _readPlannedDomainValues(
tableName,
{
for (final row in rows)
if (row.id is UuidValue) row.id as UuidValue,
},
{for (final fieldKey in authored.keys) fieldKey.$3},
transaction,
);
overlays = {
for (final MapEntry(key: fieldKey, value: value) in authored.entries)
if (!projectionValuesEqual(
currentDomain[fieldKey.$2]?[fieldKey.$3],
value,
))
fieldKey: value,
};
}
final planned = await _foreignKeyProjector.project(
transaction,
authoredOverlays: overlays,
seedTables: {tableName},
seedRows: {
for (final row in rows)
if (row.id case final UuidValue rowId) (tableName, rowId),
},
);
return [
for (final row in rows)
_withPlannedDomainValues(
row,
planned.domain[(tableName, row.id as UuidValue)],
),
];
}
Map<String, Object?> _authoredValuesFromRow<T extends TableRow>(
T row,
List<Column>? columns,
) {
final json = row.toJsonForDatabase() as Map<String, dynamic>;
final columnNames = [
for (final column in (columns ?? row.table.managedColumns))
if (column.columnName != 'id' && column.columnName != 'scopeId')
column.columnName,
];
return {
for (final columnName in columnNames) columnName: json[columnName],
};
}
T _withPlannedDomainValues<T extends TableRow>(
T row,
Map<String, Object?>? planned,
) {
if (planned == null || planned.isEmpty) return row;
// Merge inserts are typed as [TableRow], and generated models are often a
// private `_*Impl` subclass. Read `__className__` from the payload so
// explicit nulls (e.g. a missing-parent FK) are applied.
final data = Map<String, dynamic>.from(row.toJson() as Map);
for (final column in row.table.crdtSyncableColumns) {
if (planned.containsKey(column.columnName)) {
data[column.fieldName] = planned[column.columnName];
}
}
final className = data.remove('__className__') as String?;
if (className == null) {
throw StateError(
'Cannot apply planned domain values: ${row.runtimeType} has no class name.',
);
}
return _session.db.serializationManager.deserializeByClassName({
'className': className,
'data': data,
})
as T;
}
Future<Map<UuidValue, Map<String, Object?>>> _readPlannedDomainValues(
String tableName,
Set<UuidValue> rowIds,
Set<String> columnNames,
Transaction transaction,
) {
if (rowIds.isEmpty || columnNames.isEmpty) {
return Future.value(const {});
}
return _context.readDomainColumnValues(
tableName,
rowIds,
columnNames.toList(),
transaction,
);
}
/// Insert the CRDT metadata for the inserted rows.
Future<void> afterInsert<T extends TableRow>(
List<T> insertedRows,
Transaction transaction,
) async {
Transaction transaction, {
ProjectionAttemptsByField attempts = const {},
}) async {
await _forTrackedRows(insertedRows, transaction, (
tableName,
rowIds,
@ -351,9 +592,9 @@ class CrdtMutationRecorder {
tableName,
rowIds,
transaction,
{},
attempts,
);
await _maybeProjectForeignKeys(tableName, null, transaction);
await _maybeProject(tableName, rowIds, null, transaction);
});
}
@ -381,14 +622,25 @@ class CrdtMutationRecorder {
CrdtDataDeletedReason.userReinsert,
transaction,
);
await _recordUpdatedFields(reinsertedRows, crdtDataRows, null, transaction);
await _foreignKeyProjector.recordAttemptsForRows(
tableName,
rowIds,
// Every field to skip carries an attempted value: a repaired foreign key
// and a restored authored value are both subsets of that set, over a
// subset of its columns.
final skippedFields = await _foreignKeyProjector.findActiveAttemptedFields(
tableName: tableName,
rowIds: rowIds,
transaction: transaction,
);
// Reinsert is a full-row passthrough. Projected unique/FK columns must
// keep their original claim HLC so restoration can reclaim or lose
// without authoring a newer unique claim.
await _recordUpdatedFields(
reinsertedRows,
crdtDataRows,
null,
transaction,
skippedFields: skippedFields,
);
await _maybeProjectForeignKeys(tableName, null, transaction);
await _maybeProject(tableName, rowIds, null, transaction);
});
}
@ -426,28 +678,27 @@ class CrdtMutationRecorder {
skippedFields: implicitForeignKeyRepairFields,
);
final updatedColumnNames = columns?.map((column) => column.columnName).toSet();
await _foreignKeyProjector.recordAttemptsForRows(
await _maybeProject(
tableName,
rowIds,
updatedColumnNames,
transaction,
skippedFields: implicitForeignKeyRepairFields,
);
await _maybeProjectForeignKeys(
tableName,
updatedColumnNames,
transaction,
);
});
}
Future<void> _maybeProjectForeignKeys(
Future<void> _maybeProject(
String tableName,
Set<UuidValue> rowIds,
Set<String>? columnNames,
Transaction transaction,
) async {
if (_foreignKeys.columnsMayAffectForeignKeys(tableName, columnNames)) {
await _foreignKeyProjector.project(transaction);
if (_foreignKeyProjector.needsProjection(tableName, columnNames)) {
await _foreignKeyProjector.project(
transaction,
seedTables: {tableName},
seedRows: {for (final rowId in rowIds) (tableName, rowId)},
);
}
}
@ -559,7 +810,12 @@ class CrdtMutationRecorder {
null,
CrdtDataDeletedReason.userDelete,
);
await _maybeProjectForeignKeys(tableName, null, transaction);
await _maybeProject(
tableName,
deletedRows.uuidRowIds,
null,
transaction,
);
}
Future<void> _softDeleteRowsByTable(
@ -599,7 +855,6 @@ class CrdtMutationRecorder {
transaction,
processing,
);
await _uniqueResolver.releaseOnDelete(tableName, visibleRowIds, transaction);
await _context.markCrdtRowsDeleted(visibleCrdtRows, true, reason, transaction);
for (final MapEntry(key: childTableName, value: childIds)
in cascadeDeletes.entries) {

View file

@ -5,9 +5,31 @@ import '../generated/protocol.dart';
import 'merge_utils/database_helpers.dart';
import 'unique_index_utils.dart';
/// Thrown when CRDT schema reconciliation cannot be applied safely.
///
/// The registry and projection state are left unchanged. Each message names
/// the affected table and column and tells the developer how to convert data
/// and attest the new type identity on [CrdtSchemaColumn].
class CrdtSchemaReconciliationException implements Exception {
/// Creates a [CrdtSchemaReconciliationException] from one or more failures.
CrdtSchemaReconciliationException(this.failures)
: assert(failures.isNotEmpty, 'At least one failure is required.');
/// Actionable failure messages, one per blocked change.
final List<String> failures;
@override
String toString() => 'CrdtSchemaReconciliationException: ${failures.join(' ')}';
}
/// Manages the CRDT schema for a database.
class CrdtSchemaRegistry {
/// Creates a new instance of [CrdtSchemaRegistry].
///
/// [tableDefinitions] replaces the ambient schema rather than overriding
/// parts of it, so it must describe every table in [syncTables]: persisted
/// type identity needs a `ColumnDefinition` for every synced column. Passing
/// null resolves the definitions from the session's serialization manager.
CrdtSchemaRegistry(
this._session, {
required this.syncTables,
@ -37,12 +59,24 @@ class CrdtSchemaRegistry {
);
}
final tableDefinitionsByName = {
_tableDefinitionsByName = {
for (final tableDefinition
in tableDefinitions ??
_session.db.serializationManager.getTargetTableDefinitions())
tableDefinition.name: tableDefinition,
};
final tablesMissingDefinitions = [
for (final table in syncTables)
if (!_tableDefinitionsByName.containsKey(table.tableName)) table.tableName,
];
if (tablesMissingDefinitions.isNotEmpty) {
throw StateError(
'CRDT requires a TableDefinition for every synced table, but '
'${tablesMissingDefinitions.length} table(s) have none: '
'${tablesMissingDefinitions.map((name) => '"$name"').join(', ')}.',
);
}
final tableDefinitionsByName = _tableDefinitionsByName;
final globalUniqueIndexes = _globalUniqueIndexViolations(
syncTables,
tableDefinitionsByName,
@ -71,6 +105,19 @@ class CrdtSchemaRegistry {
);
}
final jsonUniqueIndexes = crdtJsonUniqueIndexViolations(
syncTables,
tableDefinitionsByName,
);
if (jsonUniqueIndexes.isNotEmpty) {
throw StateError(
'CRDT cannot synchronize unique indexes that include json or jsonb '
'columns, because sync does not define canonical cross-dialect JSON '
'equality: ${jsonUniqueIndexes.join(', ')}. Remove those columns from '
'the unique index.',
);
}
final nonReleasableUniqueIndexes = crdtNonReleasableUniqueIndexViolations(
syncTables,
tableDefinitionsByName,
@ -142,6 +189,14 @@ class CrdtSchemaRegistry {
/// The list of tables to sync with CRDT.
final List<Table> syncTables;
/// Whether the last [syncAndGetSchema] call inserted, updated, or deleted
/// registry rows.
bool get registryChanged => _registryChanged;
var _registryChanged = false;
/// Serverpod table definitions by table name.
late final Map<String, TableDefinition> _tableDefinitionsByName;
late final _columnsPerTableName = {
for (final table in syncTables)
table.tableName: [
@ -150,94 +205,423 @@ class CrdtSchemaRegistry {
],
};
/// The target column definitions of every synced table, keyed by table and
/// column name.
late final _columnDefinitionsPerTableName = {
for (final table in syncTables)
table.tableName: {
for (final column
in _tableDefinitionsByName[table.tableName]?.columns ??
const <ColumnDefinition>[])
if (column.name != 'scopeId') column.name: column,
},
};
/// Ensures that the CRDT schema is created for the database.
///
/// Reconciliation plans every addition, drop, possible rename, and type or
/// policy change before mutating. Failures throw
/// [CrdtSchemaReconciliationException] without writing registry rows.
Future<(List<CrdtSchemaTable>, List<CrdtSchemaColumn>)> syncAndGetSchema() async {
return _session.db.transaction((transaction) async {
final tableRows = await _syncTableSchemas(transaction);
final columnRows = await _syncColumnSchemas(tableRows, transaction);
return (tableRows, columnRows);
final existingTables = await CrdtSchemaTable.db.find(
_session,
transaction: transaction,
);
final existingColumns = await CrdtSchemaColumn.db.find(
_session,
transaction: transaction,
);
final plan = await _planReconciliation(
existingTables: existingTables,
existingColumns: existingColumns,
transaction: transaction,
);
if (plan.failures.isNotEmpty) {
throw CrdtSchemaReconciliationException(plan.failures);
}
return _applyReconciliation(
plan,
existingTables: existingTables,
existingColumns: existingColumns,
transaction: transaction,
);
});
}
/// Syncs the table schemas to the database.
///
/// New tables are inserted and no longer present tables are deleted. Table
/// renames are not taken into account. The returned list contains the
/// [CrdtSchemaTable]s with their IDs.
Future<List<CrdtSchemaTable>> _syncTableSchemas(Transaction transaction) async {
await CrdtSchemaTable.db.insert(
_session,
_columnsPerTableName.keys.map((name) => CrdtSchemaTable(name: name)).toList(),
transaction: transaction,
ignoreConflicts: true,
);
final foundTableRows = await CrdtSchemaTable.db.find(
_session,
transaction: transaction,
);
final deletedTableRows = [
for (final (ix, table) in foundTableRows.indexed.toList().reversed)
if (!_columnsPerTableName.containsKey(table.name)) foundTableRows.removeAt(ix),
Future<_SchemaReconciliationPlan> _planReconciliation({
required List<CrdtSchemaTable> existingTables,
required List<CrdtSchemaColumn> existingColumns,
required Transaction transaction,
}) async {
final existingTablesByName = {
for (final table in existingTables) table.name: table,
};
final targetTableNames = _columnsPerTableName.keys.toSet();
final tablesToInsert = [
for (final name in targetTableNames)
if (!existingTablesByName.containsKey(name)) name,
]..sort();
final tablesToDelete = [
for (final table in existingTables)
if (!targetTableNames.contains(table.name)) table,
];
await CrdtSchemaTable.db.delete(
_session,
deletedTableRows,
transaction: transaction,
);
final columnsByTableId = <int, Map<String, CrdtSchemaColumn>>{};
for (final column in existingColumns) {
columnsByTableId.putIfAbsent(column.tblId, () => {})[column.name] = column;
}
return foundTableRows;
final columnsToInsert = <_PendingSchemaColumn>[];
final columnsToUpdate = <CrdtSchemaColumn>[];
final columnsToDelete = <CrdtSchemaColumn>[];
final failures = <String>[];
for (final tableName in targetTableNames) {
final existingTable = existingTablesByName[tableName];
final existingByName = existingTable == null
? const <String, CrdtSchemaColumn>{}
: columnsByTableId[existingTable.id!] ?? const <String, CrdtSchemaColumn>{};
final targetColumnNames = _columnsPerTableName[tableName]!;
final addedColumnNames = [
for (final columnName in targetColumnNames)
if (!existingByName.containsKey(columnName)) columnName,
]..sort();
final droppedColumns = [
for (final column in existingByName.values)
if (!targetColumnNames.contains(column.name)) column,
];
if (addedColumnNames.isNotEmpty && droppedColumns.isNotEmpty) {
final failure = await _describeAmbiguousColumnDrop(
tableName: tableName,
addedColumnNames: addedColumnNames,
droppedColumns: droppedColumns,
transaction: transaction,
);
if (failure != null) failures.add(failure);
}
for (final columnName in addedColumnNames) {
columnsToInsert.add(
(
tableName: tableName,
tblId: existingTable?.id,
columnName: columnName,
),
);
}
columnsToDelete.addAll(droppedColumns);
for (final columnName in targetColumnNames) {
final stored = existingByName[columnName];
if (stored == null) continue;
final definition = _requiredColumnDefinition(tableName, columnName);
if (_typeIdentityMatches(stored, definition)) continue;
if (stored.id != null &&
await _hasAttemptedValueRows(stored.id!, transaction)) {
failures.add(
_describeUnconvertedTypeChange(tableName, columnName, stored, definition),
);
continue;
}
columnsToUpdate.add(
stored.copyWith(
columnType: definition.columnType.name,
dartType: definition.dartType ?? '',
isNullable: definition.isNullable,
),
);
}
}
if (tablesToInsert.isNotEmpty && tablesToDelete.isNotEmpty) {
final failure = await _describeAmbiguousTableDrop(
tablesToInsert: tablesToInsert,
tablesToDelete: tablesToDelete,
transaction: transaction,
);
if (failure != null) failures.add(failure);
}
return _SchemaReconciliationPlan(
tablesToInsert: tablesToInsert,
tablesToDelete: tablesToDelete,
columnsToInsert: columnsToInsert,
columnsToUpdate: columnsToUpdate,
columnsToDelete: columnsToDelete,
failures: failures,
);
}
/// Syncs the column schemas to the database.
/// Why an add-and-drop in the same table cannot be told apart from a rename,
/// or null when the dropped columns carry no CRDT field metadata.
///
/// New columns are inserted and no longer present columns are deleted. Column
/// renames are not taken into account yet. The returned list contains the
/// [CrdtSchemaColumn]s with their IDs.
Future<List<CrdtSchemaColumn>> _syncColumnSchemas(
List<CrdtSchemaTable> tableRows,
Transaction transaction,
) async {
await CrdtSchemaColumn.db.insert(
_session,
[
for (final table in tableRows)
for (final column in _columnsPerTableName[table.name]!)
CrdtSchemaColumn(tblId: table.id!, name: column),
],
transaction: transaction,
ignoreConflicts: true,
);
/// A rename reaches reconciliation as one column disappearing and another
/// appearing, and adopting the wrong one would silently reassign history.
Future<String?> _describeAmbiguousColumnDrop({
required String tableName,
required List<String> addedColumnNames,
required List<CrdtSchemaColumn> droppedColumns,
required Transaction transaction,
}) async {
final droppedWithMetadata = <String>[];
for (final column in droppedColumns) {
if (column.id != null && await _hasFieldMetadata(column.id!, transaction)) {
droppedWithMetadata.add('$tableName.${column.name}');
}
}
if (droppedWithMetadata.isEmpty) return null;
final columnsPerTableId = {
for (final tableRow in tableRows)
tableRow.id!: _columnsPerTableName[tableRow.name]!,
return 'Cannot reconcile $tableName: columns '
'${droppedWithMetadata.join(', ')} would be dropped while '
'${addedColumnNames.map((name) => '$tableName.$name').join(', ')} '
'would be added, and the dropped columns still have CRDT field '
'metadata. Update CrdtSchemaColumn.name in a migration to rename, '
'or explicitly delete the old schema column (cascading field '
'metadata) for a real drop, then initialize again.';
}
/// The table-level counterpart of [_describeAmbiguousColumnDrop].
Future<String?> _describeAmbiguousTableDrop({
required List<String> tablesToInsert,
required List<CrdtSchemaTable> tablesToDelete,
required Transaction transaction,
}) async {
final droppedTablesWithRows = <String>[];
for (final table in tablesToDelete) {
if (table.id != null && await _hasDataRows(table.id!, transaction)) {
droppedTablesWithRows.add(table.name);
}
}
if (droppedTablesWithRows.isEmpty) return null;
return 'Cannot reconcile schema: tables ${droppedTablesWithRows.join(', ')} '
'would be dropped while ${tablesToInsert.join(', ')} would be added, '
'and the dropped tables still have CRDT row metadata. Update '
'CrdtSchemaTable.name in a migration to rename, or explicitly delete '
'the old schema table (cascading metadata) for a real drop, then '
'initialize again.';
}
/// Why a column's type identity cannot move while attempted values still
/// hold envelopes written against the old one.
String _describeUnconvertedTypeChange(
String tableName,
String columnName,
CrdtSchemaColumn stored,
ColumnDefinition definition,
) {
final target = _formatTypeIdentityFromDefinition(definition);
return 'Cannot change $tableName.$columnName type identity from '
'${_formatTypeIdentity(stored)} to $target, because attempted-value '
'rows still exist. Convert the domain column and every affected '
'attempted-value envelope to the target types, then attest completion '
'by updating CrdtSchemaColumn for $tableName.$columnName to $target as '
'the final step of the same migration.';
}
Future<(List<CrdtSchemaTable>, List<CrdtSchemaColumn>)> _applyReconciliation(
_SchemaReconciliationPlan plan, {
required List<CrdtSchemaTable> existingTables,
required List<CrdtSchemaColumn> existingColumns,
required Transaction transaction,
}) async {
_registryChanged = plan.hasMutations;
// The registry is small but this runs on the path into the app, so the
// result is folded from the rows already read plus what the writes return
// rather than reading both tables a second time.
final insertedTables = plan.tablesToInsert.isEmpty
? const <CrdtSchemaTable>[]
: await CrdtSchemaTable.db.insert(
_session,
[for (final name in plan.tablesToInsert) CrdtSchemaTable(name: name)],
transaction: transaction,
);
if (plan.columnsToDelete.isNotEmpty) {
await CrdtSchemaColumn.db.delete(
_session,
plan.columnsToDelete,
transaction: transaction,
);
}
if (plan.tablesToDelete.isNotEmpty) {
await CrdtSchemaTable.db.delete(
_session,
plan.tablesToDelete,
transaction: transaction,
);
}
// Deleted ids are only removed from rows read before the delete: the
// database can hand a freed id straight back to a row inserted after it.
final deletedTableIds = {for (final table in plan.tablesToDelete) table.id};
final tableRows = [
for (final table in existingTables)
if (!deletedTableIds.contains(table.id) &&
_columnsPerTableName.containsKey(table.name))
table,
for (final table in insertedTables)
if (_columnsPerTableName.containsKey(table.name)) table,
];
final tableIdByName = {for (final table in tableRows) table.name: table.id!};
final tableNameById = {for (final table in tableRows) table.id!: table.name};
final insertedColumns = plan.columnsToInsert.isEmpty
? const <CrdtSchemaColumn>[]
: await CrdtSchemaColumn.db.insert(
_session,
[
for (final pending in plan.columnsToInsert)
_newSchemaColumn(
tableName: pending.tableName,
tblId: pending.tblId ?? tableIdByName[pending.tableName]!,
columnName: pending.columnName,
),
],
transaction: transaction,
);
if (plan.columnsToUpdate.isNotEmpty) {
await CrdtSchemaColumn.db.update(
_session,
plan.columnsToUpdate,
columns: (t) => [t.columnType, t.dartType, t.isNullable],
transaction: transaction,
);
}
final deletedColumnIds = {for (final column in plan.columnsToDelete) column.id};
final updatedColumnsById = {
for (final column in plan.columnsToUpdate) column.id: column,
};
bool isTargetColumn(CrdtSchemaColumn column) {
final tableName = tableNameById[column.tblId];
if (tableName == null) return false;
return (_columnsPerTableName[tableName] ?? const <String>[]).contains(
column.name,
);
}
final foundColumnRows = await CrdtSchemaColumn.db.find(
_session,
transaction: transaction,
);
final deletedColumnRows = [
for (final (ix, column) in foundColumnRows.indexed.toList().reversed)
if (!columnsPerTableId.containsKey(column.tblId) ||
!columnsPerTableId[column.tblId]!.contains(column.name))
foundColumnRows.removeAt(ix),
final columnRows = [
for (final stored in existingColumns)
if (!deletedColumnIds.contains(stored.id))
if (updatedColumnsById[stored.id] ?? stored case final column)
if (isTargetColumn(column)) column,
for (final column in insertedColumns)
if (isTargetColumn(column)) column,
];
await CrdtSchemaColumn.db.delete(
return (tableRows, columnRows);
}
CrdtSchemaColumn _newSchemaColumn({
required String tableName,
required int tblId,
required String columnName,
}) {
final definition = _requiredColumnDefinition(tableName, columnName);
return CrdtSchemaColumn(
tblId: tblId,
name: columnName,
columnType: definition.columnType.name,
dartType: definition.dartType ?? '',
isNullable: definition.isNullable,
);
}
ColumnDefinition _requiredColumnDefinition(String tableName, String columnName) {
final definition = _columnDefinitionsPerTableName[tableName]?[columnName];
if (definition == null) {
throw StateError(
'No column definition found for $tableName.$columnName.',
);
}
return definition;
}
bool _typeIdentityMatches(CrdtSchemaColumn stored, ColumnDefinition target) {
return stored.columnType == target.columnType.name &&
stored.dartType == (target.dartType ?? '') &&
stored.isNullable == target.isNullable;
}
/// The stored and target forms of a type identity are formatted alike so a
/// failure message reads as one comparison.
String _formatTypeIdentity(CrdtSchemaColumn stored) =>
_typeIdentity(stored.columnType, stored.dartType, stored.isNullable);
String _formatTypeIdentityFromDefinition(ColumnDefinition target) => _typeIdentity(
target.columnType.name,
target.dartType ?? '',
target.isNullable,
);
String _typeIdentity(String columnType, String dartType, bool isNullable) =>
'columnType=$columnType dartType=$dartType isNullable=$isNullable';
Future<bool> _hasAttemptedValueRows(int columnId, Transaction transaction) async {
final row = await CrdtDataAttemptedValue.db.findFirstRow(
_session,
deletedColumnRows,
where: (t) => t.field.columnId.equals(columnId),
transaction: transaction,
);
return row != null;
}
return foundColumnRows;
Future<bool> _hasFieldMetadata(int columnId, Transaction transaction) async {
final row = await CrdtDataField.db.findFirstRow(
_session,
where: (t) => t.columnId.equals(columnId),
transaction: transaction,
);
return row != null;
}
Future<bool> _hasDataRows(int tableId, Transaction transaction) async {
final row = await CrdtDataRow.db.findFirstRow(
_session,
where: (t) => t.tblId.equals(tableId),
transaction: transaction,
);
return row != null;
}
}
class _SchemaReconciliationPlan {
_SchemaReconciliationPlan({
required this.tablesToInsert,
required this.tablesToDelete,
required this.columnsToInsert,
required this.columnsToUpdate,
required this.columnsToDelete,
required this.failures,
});
final List<String> tablesToInsert;
final List<CrdtSchemaTable> tablesToDelete;
final List<_PendingSchemaColumn> columnsToInsert;
final List<CrdtSchemaColumn> columnsToUpdate;
final List<CrdtSchemaColumn> columnsToDelete;
final List<String> failures;
bool get hasMutations =>
tablesToInsert.isNotEmpty ||
tablesToDelete.isNotEmpty ||
columnsToInsert.isNotEmpty ||
columnsToUpdate.isNotEmpty ||
columnsToDelete.isNotEmpty;
}
typedef _PendingSchemaColumn = ({
String tableName,
int? tblId,
String columnName,
});
List<String> _globalUniqueIndexViolations(
List<Table> syncTables,
Map<String, TableDefinition> tableDefinitionsByName,

View file

@ -56,14 +56,6 @@ UniqueIndexConflictRelease uniqueIndexConflictReleaseForIndex(
);
}
@internal
extension UniqueIndexConflictReleaseExtension on UniqueIndexConflictRelease {
/// Column names released when tombstoning rows covered by this index.
Set<String> get releaseColumnNames => {
for (final column in releaseColumns) column.columnName,
};
}
@internal
bool isCrdtAllowedForeignKeyOnlyUniqueIndex(
TableDefinition tableDefinition,
@ -192,11 +184,19 @@ enum CrdtUniqueConflictReleaseKind {
syntheticUuid,
}
/// Whether [column] is a JSON-backed domain column that cannot participate in
/// a synchronized unique claim.
@internal
bool isCrdtUnsupportedJsonUniqueColumn(ColumnDefinition column) {
return column.columnType == ColumnType.json || column.columnType == ColumnType.jsonb;
}
@internal
CrdtUniqueConflictReleaseKind? crdtUniqueConflictReleaseKindForColumn(
TableDefinition table,
ColumnDefinition column,
) {
if (isCrdtUnsupportedJsonUniqueColumn(column)) return null;
if (column.isNullable) return CrdtUniqueConflictReleaseKind.setNull;
if (column.columnType == ColumnType.text) {
return CrdtUniqueConflictReleaseKind.textSuffix;
@ -208,6 +208,43 @@ CrdtUniqueConflictReleaseKind? crdtUniqueConflictReleaseKindForColumn(
return null;
}
/// Unique indexes that include a json or jsonb domain column.
///
/// Sync has no canonical cross-dialect JSON equality, so neither a nullable
/// JSON releasable candidate nor a non-nullable JSON fixed component is
/// supported.
@internal
List<String> crdtJsonUniqueIndexViolations(
List<Table> syncTables,
Map<String, TableDefinition> tableDefinitionsByName,
) {
final syncTableNames = {for (final table in syncTables) table.tableName};
return [
for (final tableName in syncTableNames)
if (tableDefinitionsByName[tableName] case final table?)
for (final index in crdtSyncableUniqueIndexesForTable(
table,
syncTableNames,
))
if (_hasJsonUniqueColumn(table, index)) '$tableName.${index.indexName}',
];
}
bool _hasJsonUniqueColumn(TableDefinition table, IndexDefinition index) {
final columnsByName = {
for (final column in table.columns) column.name: column,
};
return index.elements.any(
(element) =>
element.type == IndexElementDefinitionType.column &&
element.definition != 'scopeId' &&
switch (columnsByName[element.definition]) {
final column? => isCrdtUnsupportedJsonUniqueColumn(column),
null => false,
},
);
}
@internal
List<String> crdtNonReleasableUniqueIndexViolations(
List<Table> syncTables,

View file

@ -0,0 +1,739 @@
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
/* To generate run: "serverpod generate" */
// ignore_for_file: implementation_imports
// ignore_for_file: library_private_types_in_public_api
// ignore_for_file: non_constant_identifier_names
// ignore_for_file: public_member_api_docs
// ignore_for_file: type_literal_in_constant_pattern
// ignore_for_file: use_super_parameters
// ignore_for_file: invalid_use_of_internal_member
// ignore_for_file: dead_code, no_leading_underscores_for_library_prefixes
// ignore_for_file: unnecessary_null_comparison
import 'package:serverpod_database/serverpod_database.dart' as _isd;
import 'package:serverpod_offline_sync/serverpod_offline_sync.dart'
as _icw2tu00;
import 'package:serverpod_serialization/serverpod_serialization.dart' as _iss;
/// Authored field value retained while the domain column holds a projection.
///
/// Sparse one-to-one dependent of CrdtDataField. Present only while the
/// materialized domain value differs from the authored value. `value` is a
/// durable authored fact; `projectionReason` is disposable diagnostic state.
abstract class CrdtDataAttemptedValue
implements _isd.TableRow<int?>, _iss.ProtocolSerialization {
CrdtDataAttemptedValue._({
this.id,
required this.fieldId,
this.field,
required this.value,
required this.projectionReason,
});
factory CrdtDataAttemptedValue({
int? id,
required int fieldId,
_icw2tu00.CrdtDataField? field,
required dynamic value,
required _icw2tu00.CrdtProjectionReason projectionReason,
}) = _CrdtDataAttemptedValueImpl;
factory CrdtDataAttemptedValue.fromJson(
Map<String, dynamic> jsonSerialization,
) {
return CrdtDataAttemptedValue(
id: jsonSerialization['id'] as int?,
fieldId: jsonSerialization['fieldId'] as int,
field: jsonSerialization['field'] == null
? null
: _icw2tu00.Protocol().deserialize<_icw2tu00.CrdtDataField>(
jsonSerialization['field'],
),
value: _icw2tu00.Protocol().deserializeDynamicFieldValue(
jsonSerialization['value'],
),
projectionReason: _icw2tu00.CrdtProjectionReason.fromJson(
(jsonSerialization['projectionReason'] as int),
),
);
}
static final t = CrdtDataAttemptedValueTable();
static const db = CrdtDataAttemptedValueRepository._();
@override
int? id;
/// The field whose authored value this row preserves.
int fieldId;
_icw2tu00.CrdtDataField? field;
/// The authored value, using Serverpod's dynamic `{className, data}` envelope.
dynamic value;
/// Terminal projector that selected the current domain value.
_icw2tu00.CrdtProjectionReason projectionReason;
@override
_isd.Table<int?> get table => t;
/// Returns a shallow copy of this [CrdtDataAttemptedValue]
/// with some or all fields replaced by the given arguments.
@_iss.useResult
CrdtDataAttemptedValue copyWith({
int? id,
int? fieldId,
_icw2tu00.CrdtDataField? field,
dynamic value,
_icw2tu00.CrdtProjectionReason? projectionReason,
});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'serverpod_offline_sync.CrdtDataAttemptedValue',
if (id != null) 'id': id,
'fieldId': fieldId,
if (field != null) 'field': field?.toJson(),
'value': _icw2tu00.Protocol().dynamicFieldToJson(value),
'projectionReason': projectionReason.toJson(),
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {
'__className__': 'serverpod_offline_sync.CrdtDataAttemptedValue',
if (id != null) 'id': id,
'fieldId': fieldId,
if (field != null) 'field': field?.toJsonForProtocol(),
'value': _icw2tu00.Protocol().dynamicFieldToJson(
value,
forProtocol: true,
),
'projectionReason': projectionReason.toJson(),
};
}
static CrdtDataAttemptedValueInclude include({
_icw2tu00.CrdtDataFieldInclude? field,
}) {
return CrdtDataAttemptedValueInclude._(field: field);
}
static CrdtDataAttemptedValueIncludeList includeList({
_isd.WhereExpressionBuilder<CrdtDataAttemptedValueTable>? where,
int? limit,
int? offset,
_isd.OrderByBuilder<CrdtDataAttemptedValueTable>? orderBy,
_isd.OrderByListBuilder<CrdtDataAttemptedValueTable>? orderByList,
CrdtDataAttemptedValueInclude? include,
}) {
return CrdtDataAttemptedValueIncludeList._(
where: where,
limit: limit,
offset: offset,
orderBy: orderBy?.call(CrdtDataAttemptedValue.t),
orderByList: orderByList?.call(CrdtDataAttemptedValue.t),
include: include,
);
}
@override
String toString() {
return _iss.SerializationManager.encode(this);
}
}
class _Undefined {}
class _CrdtDataAttemptedValueImpl extends CrdtDataAttemptedValue {
_CrdtDataAttemptedValueImpl({
int? id,
required int fieldId,
_icw2tu00.CrdtDataField? field,
required dynamic value,
required _icw2tu00.CrdtProjectionReason projectionReason,
}) : super._(
id: id,
fieldId: fieldId,
field: field,
value: value,
projectionReason: projectionReason,
);
/// Returns a shallow copy of this [CrdtDataAttemptedValue]
/// with some or all fields replaced by the given arguments.
@_iss.useResult
@override
CrdtDataAttemptedValue copyWith({
Object? id = _Undefined,
int? fieldId,
Object? field = _Undefined,
Object? value = _Undefined,
_icw2tu00.CrdtProjectionReason? projectionReason,
}) {
return CrdtDataAttemptedValue(
id: id is int? ? id : this.id,
fieldId: fieldId ?? this.fieldId,
field: field is _icw2tu00.CrdtDataField? ? field : this.field?.copyWith(),
value: value != _Undefined ? value : this.value,
projectionReason: projectionReason ?? this.projectionReason,
);
}
}
class CrdtDataAttemptedValueUpdateTable
extends _isd.UpdateTable<CrdtDataAttemptedValueTable> {
CrdtDataAttemptedValueUpdateTable(super.table);
_isd.ColumnValue<int, int> fieldId(int value) => _isd.ColumnValue(
table.fieldId,
value,
);
_isd.ColumnValue<dynamic, dynamic> value(dynamic value) => _isd.ColumnValue(
table.value,
value,
);
_isd.ColumnValue<
_icw2tu00.CrdtProjectionReason,
_icw2tu00.CrdtProjectionReason
>
projectionReason(_icw2tu00.CrdtProjectionReason value) => _isd.ColumnValue(
table.projectionReason,
value,
);
}
class CrdtDataAttemptedValueTable extends _isd.Table<int?> {
CrdtDataAttemptedValueTable({super.tableRelation})
: super(tableName: 'crdt_data_attempted_value') {
updateTable = CrdtDataAttemptedValueUpdateTable(this);
fieldId = _isd.ColumnInt(
'fieldId',
this,
);
value = _isd.ColumnStructured<dynamic>(
'value',
this,
);
projectionReason = _isd.ColumnEnum(
'projectionReason',
this,
_isd.EnumSerialization.byIndex,
);
}
late final CrdtDataAttemptedValueUpdateTable updateTable;
/// The field whose authored value this row preserves.
late final _isd.ColumnInt fieldId;
_icw2tu00.CrdtDataFieldTable? _field;
/// The authored value, using Serverpod's dynamic `{className, data}` envelope.
late final _isd.ColumnStructured<dynamic> value;
/// Terminal projector that selected the current domain value.
late final _isd.ColumnEnum<_icw2tu00.CrdtProjectionReason> projectionReason;
_icw2tu00.CrdtDataFieldTable get field {
if (_field != null) return _field!;
_field = _isd.createRelationTable(
relationFieldName: 'field',
field: CrdtDataAttemptedValue.t.fieldId,
foreignField: _icw2tu00.CrdtDataField.t.id,
tableRelation: tableRelation,
createTable: (foreignTableRelation) =>
_icw2tu00.CrdtDataFieldTable(tableRelation: foreignTableRelation),
);
return _field!;
}
@override
List<_isd.Column> get columns => [
id,
fieldId,
value,
projectionReason,
];
@override
_isd.Table? getRelationTable(String relationField) {
if (relationField == 'field') {
return field;
}
return null;
}
}
class CrdtDataAttemptedValueInclude extends _isd.IncludeObject {
CrdtDataAttemptedValueInclude._({_icw2tu00.CrdtDataFieldInclude? field}) {
_field = field;
}
_icw2tu00.CrdtDataFieldInclude? _field;
@override
Map<String, _isd.Include?> get includes => {'field': _field};
@override
_isd.Table<int?> get table => CrdtDataAttemptedValue.t;
}
class CrdtDataAttemptedValueIncludeList extends _isd.IncludeList {
CrdtDataAttemptedValueIncludeList._({
_isd.WhereExpressionBuilder<CrdtDataAttemptedValueTable>? where,
super.limit,
super.offset,
super.orderBy,
super.orderByList,
super.include,
}) {
super.where = where?.call(CrdtDataAttemptedValue.t);
}
@override
Map<String, _isd.Include?> get includes => include?.includes ?? {};
@override
_isd.Table<int?> get table => CrdtDataAttemptedValue.t;
}
class CrdtDataAttemptedValueRepository {
const CrdtDataAttemptedValueRepository._();
final attachRow = const CrdtDataAttemptedValueAttachRowRepository._();
/// Returns a list of [CrdtDataAttemptedValue]s matching the given query parameters.
///
/// Use [where] to specify which items to include in the return value.
/// If none is specified, all items will be returned.
///
/// To specify the order of the items use [orderBy] or [orderByList]
/// when sorting by multiple columns.
///
/// The maximum number of items can be set by [limit]. If no limit is set,
/// all items matching the query will be returned.
///
/// [offset] defines how many items to skip, after which [limit] (or all)
/// items are read from the database.
///
/// ```dart
/// var persons = await Persons.db.find(
/// session,
/// where: (t) => t.lastName.equals('Jones'),
/// orderBy: (t) => t.firstName,
/// limit: 100,
/// );
/// ```
Future<List<CrdtDataAttemptedValue>> find(
_isd.DatabaseSession session, {
_isd.WhereExpressionBuilder<CrdtDataAttemptedValueTable>? where,
int? limit,
int? offset,
_isd.OrderByBuilder<CrdtDataAttemptedValueTable>? orderBy,
_isd.OrderByListBuilder<CrdtDataAttemptedValueTable>? orderByList,
_isd.Transaction? transaction,
CrdtDataAttemptedValueInclude? include,
_isd.LockMode? lockMode,
_isd.LockBehavior? lockBehavior,
}) async {
return session.db.find<CrdtDataAttemptedValue>(
where: where?.call(CrdtDataAttemptedValue.t),
orderBy: orderBy?.call(CrdtDataAttemptedValue.t),
orderByList: orderByList?.call(CrdtDataAttemptedValue.t),
limit: limit,
offset: offset,
transaction: transaction,
include: include,
lockMode: lockMode,
lockBehavior: lockBehavior,
);
}
/// Returns the first matching [CrdtDataAttemptedValue] matching the given query parameters.
///
/// Use [where] to specify which items to include in the return value.
/// If none is specified, all items will be returned.
///
/// To specify the order use [orderBy] or [orderByList]
/// when sorting by multiple columns.
///
/// [offset] defines how many items to skip, after which the next one will be picked.
///
/// ```dart
/// var youngestPerson = await Persons.db.findFirstRow(
/// session,
/// where: (t) => t.lastName.equals('Jones'),
/// orderBy: (t) => t.age,
/// );
/// ```
Future<CrdtDataAttemptedValue?> findFirstRow(
_isd.DatabaseSession session, {
_isd.WhereExpressionBuilder<CrdtDataAttemptedValueTable>? where,
int? offset,
_isd.OrderByBuilder<CrdtDataAttemptedValueTable>? orderBy,
_isd.OrderByListBuilder<CrdtDataAttemptedValueTable>? orderByList,
_isd.Transaction? transaction,
CrdtDataAttemptedValueInclude? include,
_isd.LockMode? lockMode,
_isd.LockBehavior? lockBehavior,
}) async {
return session.db.findFirstRow<CrdtDataAttemptedValue>(
where: where?.call(CrdtDataAttemptedValue.t),
orderBy: orderBy?.call(CrdtDataAttemptedValue.t),
orderByList: orderByList?.call(CrdtDataAttemptedValue.t),
offset: offset,
transaction: transaction,
include: include,
lockMode: lockMode,
lockBehavior: lockBehavior,
);
}
/// Finds a single [CrdtDataAttemptedValue] by its [id] or null if no such row exists.
Future<CrdtDataAttemptedValue?> findById(
_isd.DatabaseSession session,
int id, {
_isd.Transaction? transaction,
CrdtDataAttemptedValueInclude? include,
_isd.LockMode? lockMode,
_isd.LockBehavior? lockBehavior,
}) async {
return session.db.findById<CrdtDataAttemptedValue>(
id,
transaction: transaction,
include: include,
lockMode: lockMode,
lockBehavior: lockBehavior,
);
}
/// Inserts all [CrdtDataAttemptedValue]s in the list and returns the inserted rows.
///
/// The returned [CrdtDataAttemptedValue]s will have their `id` fields set.
///
/// This is an atomic operation, meaning that if one of the rows fails to
/// insert, none of the rows will be inserted.
///
/// If [ignoreConflicts] is set to `true`, rows that conflict with existing
/// rows are silently skipped, and only the successfully inserted rows are
/// returned.
///
/// If [noReturn] is set to `true`, the inserted rows are not read back from
/// the database and an empty list is returned. This avoids the overhead of
/// transferring and deserializing the rows when the result is not needed.
Future<List<CrdtDataAttemptedValue>> insert(
_isd.DatabaseSession session,
List<CrdtDataAttemptedValue> rows, {
_isd.Transaction? transaction,
bool ignoreConflicts = false,
bool noReturn = false,
}) async {
return session.db.insert<CrdtDataAttemptedValue>(
rows,
transaction: transaction,
ignoreConflicts: ignoreConflicts,
noReturn: noReturn,
);
}
/// Inserts a single [CrdtDataAttemptedValue] and returns the inserted row.
///
/// The returned [CrdtDataAttemptedValue] will have its `id` field set.
Future<CrdtDataAttemptedValue> insertRow(
_isd.DatabaseSession session,
CrdtDataAttemptedValue row, {
_isd.Transaction? transaction,
}) async {
return session.db.insertRow<CrdtDataAttemptedValue>(
row,
transaction: transaction,
);
}
/// Upserts all [CrdtDataAttemptedValue]s in the list and returns the resulting rows.
///
/// If a row conflicts on the given [conflictColumns], the existing row is
/// updated with the new values. Otherwise, a new row is inserted.
///
/// If [updateColumns] is provided, only those columns will be updated on
/// conflict. If null, all non-conflict, non-id columns are updated.
///
/// If [updateWhere] is provided, the update only applies to rows matching the
/// given expression. Conflicting rows that don't match are skipped and not
/// returned, so the resulting list may be shorter than [rows].
///
/// The returned [CrdtDataAttemptedValue]s will have their `id` fields set.
///
/// This is an atomic operation, meaning that if one of the rows fails,
/// none of the rows will be affected.
///
/// If [noReturn] is set to `true`, the resulting rows are not read back from
/// the database and an empty list is returned. This avoids the overhead of
/// transferring and deserializing the rows when the result is not needed.
Future<List<CrdtDataAttemptedValue>> upsert(
_isd.DatabaseSession session,
List<CrdtDataAttemptedValue> rows, {
required _isd.ColumnSelections<CrdtDataAttemptedValueTable> conflictColumns,
_isd.ColumnSelections<CrdtDataAttemptedValueTable>? updateColumns,
_isd.WhereExpressionBuilder<CrdtDataAttemptedValueTable>? updateWhere,
_isd.Transaction? transaction,
bool noReturn = false,
}) async {
return session.db.upsert<CrdtDataAttemptedValue>(
rows,
conflictColumns: conflictColumns(CrdtDataAttemptedValue.t),
updateColumns: updateColumns?.call(CrdtDataAttemptedValue.t),
updateWhere: updateWhere?.call(CrdtDataAttemptedValue.t),
transaction: transaction,
noReturn: noReturn,
);
}
/// Upserts a single [CrdtDataAttemptedValue] and returns the resulting row.
///
/// If the row conflicts on the given [conflictColumns], the existing row is
/// updated. Otherwise, a new row is inserted.
///
/// If [updateColumns] is provided, only those columns will be updated on
/// conflict. If null, all non-conflict, non-id columns are updated.
///
/// If [updateWhere] is provided, the update only applies when the existing
/// row matches the expression. Returns `null` if no row was affected — for
/// example when [updateWhere] does not match the conflicting row.
///
/// The returned [CrdtDataAttemptedValue] will have its `id` field set.
Future<CrdtDataAttemptedValue?> upsertRow(
_isd.DatabaseSession session,
CrdtDataAttemptedValue row, {
required _isd.ColumnSelections<CrdtDataAttemptedValueTable> conflictColumns,
_isd.ColumnSelections<CrdtDataAttemptedValueTable>? updateColumns,
_isd.WhereExpressionBuilder<CrdtDataAttemptedValueTable>? updateWhere,
_isd.Transaction? transaction,
}) async {
return session.db.upsertRow<CrdtDataAttemptedValue>(
row,
conflictColumns: conflictColumns(CrdtDataAttemptedValue.t),
updateColumns: updateColumns?.call(CrdtDataAttemptedValue.t),
updateWhere: updateWhere?.call(CrdtDataAttemptedValue.t),
transaction: transaction,
);
}
/// Updates all [CrdtDataAttemptedValue]s in the list and returns the updated rows. If
/// [columns] is provided, only those columns will be updated. Defaults to
/// all columns.
/// This is an atomic operation, meaning that if one of the rows fails to
/// update, none of the rows will be updated.
///
/// If [noReturn] is set to `true`, the updated rows are not read back from
/// the database and an empty list is returned. This avoids the overhead of
/// transferring and deserializing the rows when the result is not needed.
Future<List<CrdtDataAttemptedValue>> update(
_isd.DatabaseSession session,
List<CrdtDataAttemptedValue> rows, {
_isd.ColumnSelections<CrdtDataAttemptedValueTable>? columns,
_isd.Transaction? transaction,
bool noReturn = false,
}) async {
return session.db.update<CrdtDataAttemptedValue>(
rows,
columns: columns?.call(CrdtDataAttemptedValue.t),
transaction: transaction,
noReturn: noReturn,
);
}
/// Updates a single [CrdtDataAttemptedValue]. The row needs to have its id set.
/// Optionally, a list of [columns] can be provided to only update those
/// columns. Defaults to all columns.
Future<CrdtDataAttemptedValue> updateRow(
_isd.DatabaseSession session,
CrdtDataAttemptedValue row, {
_isd.ColumnSelections<CrdtDataAttemptedValueTable>? columns,
_isd.Transaction? transaction,
}) async {
return session.db.updateRow<CrdtDataAttemptedValue>(
row,
columns: columns?.call(CrdtDataAttemptedValue.t),
transaction: transaction,
);
}
/// Updates a single [CrdtDataAttemptedValue] by its [id] with the specified [columnValues].
/// Returns the updated row or null if no row with the given id exists.
Future<CrdtDataAttemptedValue?> updateById(
_isd.DatabaseSession session,
int id, {
required _isd.ColumnValueListBuilder<CrdtDataAttemptedValueUpdateTable>
columnValues,
_isd.Transaction? transaction,
}) async {
return session.db.updateById<CrdtDataAttemptedValue>(
id,
columnValues: columnValues(CrdtDataAttemptedValue.t.updateTable),
transaction: transaction,
);
}
/// Updates all [CrdtDataAttemptedValue]s matching the [where] expression with the specified [columnValues].
/// Returns the list of updated rows.
///
/// If [noReturn] is set to `true`, the updated rows are not read back from
/// the database and an empty list is returned. This avoids the overhead of
/// transferring and deserializing the rows when the result is not needed.
Future<List<CrdtDataAttemptedValue>> updateWhere(
_isd.DatabaseSession session, {
required _isd.ColumnValueListBuilder<CrdtDataAttemptedValueUpdateTable>
columnValues,
required _isd.WhereExpressionBuilder<CrdtDataAttemptedValueTable> where,
int? limit,
int? offset,
_isd.OrderByBuilder<CrdtDataAttemptedValueTable>? orderBy,
_isd.OrderByListBuilder<CrdtDataAttemptedValueTable>? orderByList,
_isd.Transaction? transaction,
bool noReturn = false,
}) async {
return session.db.updateWhere<CrdtDataAttemptedValue>(
columnValues: columnValues(CrdtDataAttemptedValue.t.updateTable),
where: where(CrdtDataAttemptedValue.t),
limit: limit,
offset: offset,
orderBy: orderBy?.call(CrdtDataAttemptedValue.t),
orderByList: orderByList?.call(CrdtDataAttemptedValue.t),
transaction: transaction,
noReturn: noReturn,
);
}
/// Deletes all [CrdtDataAttemptedValue]s in the list and returns the deleted rows.
///
/// To specify the order of the returned rows use [orderBy] or [orderByList]
/// when sorting by multiple columns.
///
/// This is an atomic operation, meaning that if one of the rows fail to
/// be deleted, none of the rows will be deleted.
///
/// If [noReturn] is set to `true`, the deleted rows are not read back from
/// the database and an empty list is returned. This avoids the overhead of
/// transferring and deserializing the rows when the result is not needed.
Future<List<CrdtDataAttemptedValue>> delete(
_isd.DatabaseSession session,
List<CrdtDataAttemptedValue> rows, {
_isd.OrderByBuilder<CrdtDataAttemptedValueTable>? orderBy,
_isd.OrderByListBuilder<CrdtDataAttemptedValueTable>? orderByList,
_isd.Transaction? transaction,
bool noReturn = false,
}) async {
return session.db.delete<CrdtDataAttemptedValue>(
rows,
orderBy: orderBy?.call(CrdtDataAttemptedValue.t),
orderByList: orderByList?.call(CrdtDataAttemptedValue.t),
transaction: transaction,
noReturn: noReturn,
);
}
/// Deletes a single [CrdtDataAttemptedValue].
Future<CrdtDataAttemptedValue> deleteRow(
_isd.DatabaseSession session,
CrdtDataAttemptedValue row, {
_isd.Transaction? transaction,
}) async {
return session.db.deleteRow<CrdtDataAttemptedValue>(
row,
transaction: transaction,
);
}
/// Deletes all rows matching the [where] expression.
///
/// To specify the order of the returned rows use [orderBy] or [orderByList]
/// when sorting by multiple columns.
///
/// If [noReturn] is set to `true`, the deleted rows are not read back from
/// the database and an empty list is returned. This avoids the overhead of
/// transferring and deserializing the rows when the result is not needed.
Future<List<CrdtDataAttemptedValue>> deleteWhere(
_isd.DatabaseSession session, {
required _isd.WhereExpressionBuilder<CrdtDataAttemptedValueTable> where,
_isd.OrderByBuilder<CrdtDataAttemptedValueTable>? orderBy,
_isd.OrderByListBuilder<CrdtDataAttemptedValueTable>? orderByList,
_isd.Transaction? transaction,
bool noReturn = false,
}) async {
return session.db.deleteWhere<CrdtDataAttemptedValue>(
where: where(CrdtDataAttemptedValue.t),
orderBy: orderBy?.call(CrdtDataAttemptedValue.t),
orderByList: orderByList?.call(CrdtDataAttemptedValue.t),
transaction: transaction,
noReturn: noReturn,
);
}
/// Counts the number of rows matching the [where] expression. If omitted,
/// will return the count of all rows in the table.
Future<int> count(
_isd.DatabaseSession session, {
_isd.WhereExpressionBuilder<CrdtDataAttemptedValueTable>? where,
int? limit,
_isd.Transaction? transaction,
}) async {
return session.db.count<CrdtDataAttemptedValue>(
where: where?.call(CrdtDataAttemptedValue.t),
limit: limit,
transaction: transaction,
);
}
/// Acquires row-level locks on [CrdtDataAttemptedValue] rows matching the [where] expression.
Future<void> lockRows(
_isd.DatabaseSession session, {
required _isd.WhereExpressionBuilder<CrdtDataAttemptedValueTable> where,
required _isd.LockMode lockMode,
required _isd.Transaction transaction,
_isd.LockBehavior lockBehavior = _isd.LockBehavior.wait,
}) async {
return session.db.lockRows<CrdtDataAttemptedValue>(
where: where(CrdtDataAttemptedValue.t),
lockMode: lockMode,
lockBehavior: lockBehavior,
transaction: transaction,
);
}
}
class CrdtDataAttemptedValueAttachRowRepository {
const CrdtDataAttemptedValueAttachRowRepository._();
/// Creates a relation between the given [CrdtDataAttemptedValue] and [CrdtDataField]
/// by setting the [CrdtDataAttemptedValue]'s foreign key `fieldId` to refer to the [CrdtDataField].
Future<void> field(
_isd.DatabaseSession session,
CrdtDataAttemptedValue crdtDataAttemptedValue,
_icw2tu00.CrdtDataField field, {
_isd.Transaction? transaction,
}) async {
if (crdtDataAttemptedValue.id == null) {
throw ArgumentError.notNull('crdtDataAttemptedValue.id');
}
if (field.id == null) {
throw ArgumentError.notNull('field.id');
}
var $crdtDataAttemptedValue = crdtDataAttemptedValue.copyWith(
fieldId: field.id,
);
await session.db.updateRow<CrdtDataAttemptedValue>(
$crdtDataAttemptedValue,
columns: [CrdtDataAttemptedValue.t.fieldId],
transaction: transaction,
);
}
}

View file

@ -32,7 +32,7 @@ abstract class CrdtDataField extends _icw2tu00.BaseHlc
this.column,
required this.nodeId,
this.node,
this.foreignKey,
this.attemptedValue,
});
factory CrdtDataField({
@ -45,7 +45,7 @@ abstract class CrdtDataField extends _icw2tu00.BaseHlc
_icw2tu00.CrdtSchemaColumn? column,
required int nodeId,
_icw2tu00.CrdtNode? node,
_icw2tu00.CrdtDataForeignKey? foreignKey,
_icw2tu00.CrdtDataAttemptedValue? attemptedValue,
}) = _CrdtDataFieldImpl;
factory CrdtDataField.fromJson(Map<String, dynamic> jsonSerialization) {
@ -73,10 +73,10 @@ abstract class CrdtDataField extends _icw2tu00.BaseHlc
: _icw2tu00.Protocol().deserialize<_icw2tu00.CrdtNode>(
jsonSerialization['node'],
),
foreignKey: jsonSerialization['foreignKey'] == null
attemptedValue: jsonSerialization['attemptedValue'] == null
? null
: _icw2tu00.Protocol().deserialize<_icw2tu00.CrdtDataForeignKey>(
jsonSerialization['foreignKey'],
: _icw2tu00.Protocol().deserialize<_icw2tu00.CrdtDataAttemptedValue>(
jsonSerialization['attemptedValue'],
),
);
}
@ -103,9 +103,9 @@ abstract class CrdtDataField extends _icw2tu00.BaseHlc
/// The node that updated the field.
_icw2tu00.CrdtNode? node;
/// The foreign key information for the field, if this field holds a
/// reference to another row.
_icw2tu00.CrdtDataForeignKey? foreignKey;
/// Authored value retained while a projector materializes a different domain
/// value for this field.
_icw2tu00.CrdtDataAttemptedValue? attemptedValue;
@override
_isd.Table<int?> get table => t;
@ -124,7 +124,7 @@ abstract class CrdtDataField extends _icw2tu00.BaseHlc
_icw2tu00.CrdtSchemaColumn? column,
int? nodeId,
_icw2tu00.CrdtNode? node,
_icw2tu00.CrdtDataForeignKey? foreignKey,
_icw2tu00.CrdtDataAttemptedValue? attemptedValue,
});
@override
Map<String, dynamic> toJson() {
@ -139,7 +139,7 @@ abstract class CrdtDataField extends _icw2tu00.BaseHlc
if (column != null) 'column': column?.toJson(),
'nodeId': nodeId,
if (node != null) 'node': node?.toJson(),
if (foreignKey != null) 'foreignKey': foreignKey?.toJson(),
if (attemptedValue != null) 'attemptedValue': attemptedValue?.toJson(),
};
}
@ -156,7 +156,8 @@ abstract class CrdtDataField extends _icw2tu00.BaseHlc
if (column != null) 'column': column?.toJsonForProtocol(),
'nodeId': nodeId,
if (node != null) 'node': node?.toJsonForProtocol(),
if (foreignKey != null) 'foreignKey': foreignKey?.toJsonForProtocol(),
if (attemptedValue != null)
'attemptedValue': attemptedValue?.toJsonForProtocol(),
};
}
@ -164,13 +165,13 @@ abstract class CrdtDataField extends _icw2tu00.BaseHlc
_icw2tu00.CrdtDataRowInclude? row,
_icw2tu00.CrdtSchemaColumnInclude? column,
_icw2tu00.CrdtNodeInclude? node,
_icw2tu00.CrdtDataForeignKeyInclude? foreignKey,
_icw2tu00.CrdtDataAttemptedValueInclude? attemptedValue,
}) {
return CrdtDataFieldInclude._(
row: row,
column: column,
node: node,
foreignKey: foreignKey,
attemptedValue: attemptedValue,
);
}
@ -211,7 +212,7 @@ class _CrdtDataFieldImpl extends CrdtDataField {
_icw2tu00.CrdtSchemaColumn? column,
required int nodeId,
_icw2tu00.CrdtNode? node,
_icw2tu00.CrdtDataForeignKey? foreignKey,
_icw2tu00.CrdtDataAttemptedValue? attemptedValue,
}) : super._(
id: id,
hlcDatetime: hlcDatetime,
@ -222,7 +223,7 @@ class _CrdtDataFieldImpl extends CrdtDataField {
column: column,
nodeId: nodeId,
node: node,
foreignKey: foreignKey,
attemptedValue: attemptedValue,
);
/// Returns a shallow copy of this [CrdtDataField]
@ -239,7 +240,7 @@ class _CrdtDataFieldImpl extends CrdtDataField {
Object? column = _Undefined,
int? nodeId,
Object? node = _Undefined,
Object? foreignKey = _Undefined,
Object? attemptedValue = _Undefined,
}) {
return CrdtDataField(
id: id is int? ? id : this.id,
@ -253,9 +254,9 @@ class _CrdtDataFieldImpl extends CrdtDataField {
: this.column?.copyWith(),
nodeId: nodeId ?? this.nodeId,
node: node is _icw2tu00.CrdtNode? ? node : this.node?.copyWith(),
foreignKey: foreignKey is _icw2tu00.CrdtDataForeignKey?
? foreignKey
: this.foreignKey?.copyWith(),
attemptedValue: attemptedValue is _icw2tu00.CrdtDataAttemptedValue?
? attemptedValue
: this.attemptedValue?.copyWith(),
);
}
}
@ -339,9 +340,9 @@ class CrdtDataFieldTable extends _isd.Table<int?> {
/// The node that updated the field.
_icw2tu00.CrdtNodeTable? _node;
/// The foreign key information for the field, if this field holds a
/// reference to another row.
_icw2tu00.CrdtDataForeignKeyTable? _foreignKey;
/// Authored value retained while a projector materializes a different domain
/// value for this field.
_icw2tu00.CrdtDataAttemptedValueTable? _attemptedValue;
_icw2tu00.CrdtDataRowTable get row {
if (_row != null) return _row!;
@ -382,18 +383,19 @@ class CrdtDataFieldTable extends _isd.Table<int?> {
return _node!;
}
_icw2tu00.CrdtDataForeignKeyTable get foreignKey {
if (_foreignKey != null) return _foreignKey!;
_foreignKey = _isd.createRelationTable(
relationFieldName: 'foreignKey',
_icw2tu00.CrdtDataAttemptedValueTable get attemptedValue {
if (_attemptedValue != null) return _attemptedValue!;
_attemptedValue = _isd.createRelationTable(
relationFieldName: 'attemptedValue',
field: CrdtDataField.t.id,
foreignField: _icw2tu00.CrdtDataForeignKey.t.fieldId,
foreignField: _icw2tu00.CrdtDataAttemptedValue.t.fieldId,
tableRelation: tableRelation,
createTable: (foreignTableRelation) => _icw2tu00.CrdtDataForeignKeyTable(
tableRelation: foreignTableRelation,
),
createTable: (foreignTableRelation) =>
_icw2tu00.CrdtDataAttemptedValueTable(
tableRelation: foreignTableRelation,
),
);
return _foreignKey!;
return _attemptedValue!;
}
@override
@ -417,8 +419,8 @@ class CrdtDataFieldTable extends _isd.Table<int?> {
if (relationField == 'node') {
return node;
}
if (relationField == 'foreignKey') {
return foreignKey;
if (relationField == 'attemptedValue') {
return attemptedValue;
}
return null;
}
@ -429,12 +431,12 @@ class CrdtDataFieldInclude extends _isd.IncludeObject {
_icw2tu00.CrdtDataRowInclude? row,
_icw2tu00.CrdtSchemaColumnInclude? column,
_icw2tu00.CrdtNodeInclude? node,
_icw2tu00.CrdtDataForeignKeyInclude? foreignKey,
_icw2tu00.CrdtDataAttemptedValueInclude? attemptedValue,
}) {
_row = row;
_column = column;
_node = node;
_foreignKey = foreignKey;
_attemptedValue = attemptedValue;
}
_icw2tu00.CrdtDataRowInclude? _row;
@ -443,14 +445,14 @@ class CrdtDataFieldInclude extends _isd.IncludeObject {
_icw2tu00.CrdtNodeInclude? _node;
_icw2tu00.CrdtDataForeignKeyInclude? _foreignKey;
_icw2tu00.CrdtDataAttemptedValueInclude? _attemptedValue;
@override
Map<String, _isd.Include?> get includes => {
'row': _row,
'column': _column,
'node': _node,
'foreignKey': _foreignKey,
'attemptedValue': _attemptedValue,
};
@override
@ -481,8 +483,6 @@ class CrdtDataFieldRepository {
final attachRow = const CrdtDataFieldAttachRowRepository._();
final detachRow = const CrdtDataFieldDetachRowRepository._();
/// Returns a list of [CrdtDataField]s matching the given query parameters.
///
/// Use [where] to specify which items to include in the return value.
@ -953,59 +953,25 @@ class CrdtDataFieldAttachRowRepository {
);
}
/// Creates a relation between the given [CrdtDataField] and [CrdtDataForeignKey]
/// by setting the [CrdtDataField]'s foreign key `id` to refer to the [CrdtDataForeignKey].
Future<void> foreignKey(
/// Creates a relation between the given [CrdtDataField] and [CrdtDataAttemptedValue]
/// by setting the [CrdtDataField]'s foreign key `id` to refer to the [CrdtDataAttemptedValue].
Future<void> attemptedValue(
_isd.DatabaseSession session,
CrdtDataField crdtDataField,
_icw2tu00.CrdtDataForeignKey foreignKey, {
_icw2tu00.CrdtDataAttemptedValue attemptedValue, {
_isd.Transaction? transaction,
}) async {
if (foreignKey.id == null) {
throw ArgumentError.notNull('foreignKey.id');
if (attemptedValue.id == null) {
throw ArgumentError.notNull('attemptedValue.id');
}
if (crdtDataField.id == null) {
throw ArgumentError.notNull('crdtDataField.id');
}
var $foreignKey = foreignKey.copyWith(fieldId: crdtDataField.id);
await session.db.updateRow<_icw2tu00.CrdtDataForeignKey>(
$foreignKey,
columns: [_icw2tu00.CrdtDataForeignKey.t.fieldId],
transaction: transaction,
);
}
}
class CrdtDataFieldDetachRowRepository {
const CrdtDataFieldDetachRowRepository._();
/// Detaches the relation between this [CrdtDataField] and the [CrdtDataForeignKey] set in `foreignKey`
/// by setting the [CrdtDataField]'s foreign key `id` to `null`.
///
/// This removes the association between the two models without deleting
/// the related record.
Future<void> foreignKey(
_isd.DatabaseSession session,
CrdtDataField crdtDataField, {
_isd.Transaction? transaction,
}) async {
var $foreignKey = crdtDataField.foreignKey;
if ($foreignKey == null) {
throw ArgumentError.notNull('crdtDataField.foreignKey');
}
if ($foreignKey.id == null) {
throw ArgumentError.notNull('crdtDataField.foreignKey.id');
}
if (crdtDataField.id == null) {
throw ArgumentError.notNull('crdtDataField.id');
}
var $$foreignKey = $foreignKey.copyWith(fieldId: null);
await session.db.updateRow<_icw2tu00.CrdtDataForeignKey>(
$$foreignKey,
columns: [_icw2tu00.CrdtDataForeignKey.t.fieldId],
var $attemptedValue = attemptedValue.copyWith(fieldId: crdtDataField.id);
await session.db.updateRow<_icw2tu00.CrdtDataAttemptedValue>(
$attemptedValue,
columns: [_icw2tu00.CrdtDataAttemptedValue.t.fieldId],
transaction: transaction,
);
}

Some files were not shown because too many files have changed in this diff Show more