fix: Rebuild foreign key and unique projection from authored facts (#115)
This commit is contained in:
parent
c78af247fe
commit
519a8efca0
27 changed files with 49203 additions and 3053 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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? {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
739
packages/serverpod_offline_sync/lib/src/generated/data/attempted_value.dart
generated
Normal file
739
packages/serverpod_offline_sync/lib/src/generated/data/attempted_value.dart
generated
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
Loading…
Reference in a new issue