fix: Return the userId to the CRDT data table

This commit is contained in:
Marcelo Soares 2026-01-22 00:51:41 -03:00
commit 0a3fb46163
No known key found for this signature in database
GPG key ID: CB2F09CE66E99EAD
6 changed files with 63 additions and 26 deletions

View file

@ -3,15 +3,31 @@ import 'package:crdt/crdt.dart';
/// A [Hlc] instance that is used to generate unique timestamps for CRDT operations. /// A [Hlc] instance that is used to generate unique timestamps for CRDT operations.
/// Will keep track of the HLC during the lifetime of the application. /// Will keep track of the HLC during the lifetime of the application.
class StatefulHlc { class StatefulHlc {
StatefulHlc._(this.nodeId) : lastHlc = Hlc.now(nodeId); StatefulHlc._(this.userId, this.nodeId) {
lastHlc = _lastUserIdHlc[userId]?.apply(nodeId: nodeId) ?? Hlc.zero(nodeId);
}
static final Map<String, Hlc> _lastUserIdHlc = {};
static final Map<String, StatefulHlc> _instances = {}; static final Map<String, StatefulHlc> _instances = {};
/// Returns a cached instance of [StatefulHlc] for the provided [nodeId]. /// Initialize the HLC for the provided [userId].
static StatefulHlc cached(String nodeId) { ///
return _instances.putIfAbsent(nodeId, () => StatefulHlc._(nodeId)); /// This method allows a pre-initialization of the HLC for the node from the
/// last stored HLC for the user - regardless of the node ID. If no [initialHlc]
/// is provided, a zero HLC will be used.
static void initialize(String userId, Hlc initialHlc) {
if (_lastUserIdHlc.containsKey(userId)) return;
_lastUserIdHlc[userId] = initialHlc;
} }
/// Returns a cached instance of [StatefulHlc] for the provided [nodeId].
static StatefulHlc cached(String userId, String nodeId) {
return _instances.putIfAbsent(nodeId, () => StatefulHlc._(userId, nodeId));
}
/// The user ID for the CRDT system.
final String userId;
/// The node ID for the CRDT system. /// The node ID for the CRDT system.
final String nodeId; final String nodeId;

View file

@ -79,8 +79,8 @@ class OfflineSyncMigrator extends Migrator {
final crdtTable = crdtDb.crdtDataTable; final crdtTable = crdtDb.crdtDataTable;
await database.customStatement( await database.customStatement(
'ALTER TABLE ${crdtTable.actualTableName} ' 'ALTER TABLE ${crdtTable.actualTableName} '
'PARTITION BY RANGE (${crdtTable.tblName.name});', // TODO: Fix this partitioning, since RANGE can not be used for discrete values.
// 'PARTITION BY RANGE (${crdtTable.userId.name}, ${crdtTable.tblName.name});', 'PARTITION BY RANGE (${crdtTable.userId.name}, ${crdtTable.tblName.name});',
); );
} }

View file

@ -2,6 +2,7 @@ import 'package:drift/native.dart';
import '/src/hlc/stateful.dart'; import '/src/hlc/stateful.dart';
import '/src/triggers.dart'; import '/src/triggers.dart';
import 'tables/data.dart';
/// The database setup function to register the HLC function. /// The database setup function to register the HLC function.
/// ///
@ -32,6 +33,18 @@ DatabaseSetup get registerHlcFunction {
deterministic: false, deterministic: false,
directOnly: false, directOnly: false,
); );
// Load the last HLC for the user from the control table.
try {
database.select(CrdtDataTable.getLastHlcTimestampQuery()).forEach((row) {
final userId = row.values.first! as String;
final lastHlcTimestamp = hlcConverter.fromSql(row.values.last! as String);
StatefulHlc.initialize(userId, lastHlcTimestamp);
});
} on Exception catch (_) {
// Ignore errors, as the HLC will be initialized to zero.
// This is expected to happen on a new database.
}
}; };
} }

View file

@ -17,15 +17,14 @@ export '../hlc/converter.dart';
/// the client will have the `user_id` column with a single value, while the server /// the client will have the `user_id` column with a single value, while the server
/// will have all users' data in the same table. To avoid performance issues, on a /// will have all users' data in the same table. To avoid performance issues, on a
/// Postgres server, the `user_id` and `table_name` columns are partition keys. /// Postgres server, the `user_id` and `table_name` columns are partition keys.
///
@TableIndex( @TableIndex(
name: 'idx_crdt_data_tbl_name_column_name_row_id', name: 'idx_crdt_data_tbl_name_column_name_row_id',
columns: {#tblName, #columnName, #rowId}, columns: {#userId, #tblName, #columnName, #rowId},
unique: true, unique: true,
) )
@TableIndex( @TableIndex(
name: 'idx_crdt_data_hlc_timestamp', name: 'idx_crdt_data_hlc_timestamp',
columns: {#hlcTimestamp}, columns: {#userId, #hlcTimestamp},
) )
@DataClassName('CrdtDataEntry') @DataClassName('CrdtDataEntry')
class CrdtDataTable extends Table { class CrdtDataTable extends Table {
@ -35,14 +34,8 @@ class CrdtDataTable extends Table {
@override @override
bool get isStrict => true; bool get isStrict => true;
// MAYBE: It seems it is not possible to have the userId column here without /// Identifier for the user or client that owns the data.
// creating hard requirements on how each table should relate to the userId. late final userId = text()();
// Either all tables would have to have this column, or users would have to
// supply a query to join the userId with the changed table. Either way, it
// makes the module a lot less plug-and-play than it should, so it might be
// better to not have it here and accept the query performance penalty.
// /// Identifier for the user or client that owns the data.
// late final userId = text()();
/// Name of the table that data belongs to. /// Name of the table that data belongs to.
late final tblName = text().named('table_name')(); late final tblName = text().named('table_name')();
@ -60,5 +53,17 @@ class CrdtDataTable extends Table {
late final rawValue = sqliteAny().nullable()(); late final rawValue = sqliteAny().nullable()();
@override @override
Set<Column> get primaryKey => {/*userId,*/ tblName, columnName, rowId}; Set<Column> get primaryKey => {userId, tblName, columnName, rowId};
/// Query to get the last HLC timestamp for each user.
///
/// Declared here to ensure the query is synchronized with the table.
static String getLastHlcTimestampQuery() {
return '''
SELECT user_id,
max(hlc_timestamp) as last_hlc_timestamp
FROM __crdt_data
GROUP BY 1
''';
}
} }

View file

@ -95,13 +95,14 @@ abstract class OfflineSyncTriggers {
${_crdtDataAllColumns.join(', ')} ${_crdtDataAllColumns.join(', ')}
) )
SELECT SELECT
'${migrator.userId}' AS "user_id",
'$tableName' AS "table_name", '$tableName' AS "table_name",
column_name, column_name,
$uniqueRowId AS "row_id", $uniqueRowId AS "row_id",
"hlc_timestamp", "hlc_timestamp",
raw_value raw_value
FROM ( FROM (
SELECT $nextHlcFunction('${migrator.nodeId}') AS "hlc_timestamp" SELECT $nextHlcFunction('${migrator.userId}', '${migrator.nodeId}') AS "hlc_timestamp"
), ( ), (
${columnInserts.join('\n UNION ALL\n')} ${columnInserts.join('\n UNION ALL\n')}
)$whereClause;'''; )$whereClause;''';

View file

@ -38,16 +38,17 @@ CREATE TRIGGER IF NOT EXISTS __crdt__todos__insert
AFTER INSERT ON "todos" AFTER INSERT ON "todos"
BEGIN BEGIN
INSERT OR REPLACE INTO __crdt_data ( INSERT OR REPLACE INTO __crdt_data (
"table_name", "column_name", "row_id", "hlc_timestamp", "raw_value" "user_id", "table_name", "column_name", "row_id", "hlc_timestamp", "raw_value"
) )
SELECT SELECT
'${migrator.userId}' AS "user_id",
'todos' AS "table_name", 'todos' AS "table_name",
column_name, column_name,
NEW."id" AS "row_id", NEW."id" AS "row_id",
"hlc_timestamp", "hlc_timestamp",
raw_value raw_value
FROM ( FROM (
SELECT $nextHlcFunction('${migrator.nodeId}') AS "hlc_timestamp" SELECT $nextHlcFunction('${migrator.userId}', '${migrator.nodeId}') AS "hlc_timestamp"
), ( ), (
SELECT SELECT
'id' AS "column_name", 'id' AS "column_name",
@ -88,17 +89,17 @@ CREATE TRIGGER IF NOT EXISTS __crdt__todos__update
AFTER UPDATE ON "todos" AFTER UPDATE ON "todos"
BEGIN BEGIN
INSERT OR REPLACE INTO __crdt_data ( INSERT OR REPLACE INTO __crdt_data (
"table_name", "column_name", "row_id", "hlc_timestamp", "raw_value" "user_id", "table_name", "column_name", "row_id", "hlc_timestamp", "raw_value"
) )
SELECT SELECT
'${migrator.userId}' AS "user_id",
'todos' AS "table_name", 'todos' AS "table_name",
column_name, column_name,
OLD."id" AS "row_id", OLD."id" AS "row_id",
"hlc_timestamp", "hlc_timestamp",
raw_value, raw_value
value_changed
FROM ( FROM (
SELECT $nextHlcFunction('${migrator.nodeId}') AS "hlc_timestamp" SELECT $nextHlcFunction('${migrator.userId}', '${migrator.nodeId}') AS "hlc_timestamp"
), ( ), (
SELECT SELECT
'id' AS "column_name", 'id' AS "column_name",
@ -146,16 +147,17 @@ CREATE TRIGGER IF NOT EXISTS __crdt__todos__delete
AFTER DELETE ON "todos" AFTER DELETE ON "todos"
BEGIN BEGIN
INSERT OR REPLACE INTO __crdt_data ( INSERT OR REPLACE INTO __crdt_data (
"table_name", "column_name", "row_id", "hlc_timestamp", "raw_value" "user_id", "table_name", "column_name", "row_id", "hlc_timestamp", "raw_value"
) )
SELECT SELECT
'${migrator.userId}' AS "user_id",
'todos' AS "table_name", 'todos' AS "table_name",
column_name, column_name,
OLD."id" AS "row_id", OLD."id" AS "row_id",
"hlc_timestamp", "hlc_timestamp",
raw_value raw_value
FROM ( FROM (
SELECT $nextHlcFunction('${migrator.nodeId}') AS "hlc_timestamp" SELECT $nextHlcFunction('${migrator.userId}', '${migrator.nodeId}') AS "hlc_timestamp"
), ( ), (
SELECT SELECT
'__crdt_is_deleted' AS "column_name", '__crdt_is_deleted' AS "column_name",