Skip to content

Commit 2866d6c

Browse files
Copilotgraknol
andauthored
Restrict column updates to LWW-only for server-origin rows with origin tracking and generator safety (#40)
* Initial plan * Implement LWW column restriction with system_is_local_origin tracking Co-authored-by: graknol <1364029+graknol@users.noreply.github.com> * Add documentation and improve bulkLoad handling for system_is_local_origin Co-authored-by: graknol <1364029+graknol@users.noreply.github.com> * Improve isLocalOrigin documentation for null safety Co-authored-by: graknol <1364029+graknol@users.noreply.github.com> * Modify generator to only create setters for LWW columns Co-authored-by: graknol <1364029+graknol@users.noreply.github.com> * Update documentation to reflect LWW-only setter generation Co-authored-by: graknol <1364029+graknol@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: graknol <1364029+graknol@users.noreply.github.com>
1 parent 68fdfd5 commit 2866d6c

File tree

15 files changed

+450
-44
lines changed

15 files changed

+450
-44
lines changed

declarative_sqlite/lib/declarative_sqlite.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export 'src/streaming/query_stream_manager.dart';
4040
// Synchronization
4141
export 'src/sync/dirty_row.dart';
4242
export 'src/sync/dirty_row_store.dart';
43+
export 'src/sync/sqlite_dirty_row_store.dart';
4344
export 'src/scheduling/task_scheduler.dart';
4445
export 'src/sync/hlc.dart';
4546
export 'src/files/file_repository.dart';

declarative_sqlite/lib/src/builders/schema_builder.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ class SchemaBuilder {
103103
builder.text('table_name').notNull('default');
104104
builder.guid('row_id').notNull('00000000-0000-0000-0000-000000000000');
105105
builder.text('hlc').notNull(Hlc.min.toString());
106+
builder.integer('is_full_row').notNull(1); // 1 = full row, 0 = LWW columns only
106107
builder.key(['table_name', 'row_id']).primary();
107108
return builder.build();
108109
}

declarative_sqlite/lib/src/builders/table_builder.dart

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,15 @@ class TableBuilder {
8686
isParent: false,
8787
isLww: false,
8888
),
89+
DbColumn(
90+
name: 'system_is_local_origin',
91+
logicalType: 'integer',
92+
type: 'INTEGER', // Boolean as 0/1
93+
isNotNull: true,
94+
defaultValue: '1', // Default to local origin for new rows
95+
isParent: false,
96+
isLww: false,
97+
),
8998
];
9099

91100
return DbTable(

declarative_sqlite/lib/src/db_record.dart

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,13 @@ abstract class DbRecord {
7777
return value != null ? Hlc.parse(value as String) : null;
7878
}
7979

80+
/// Gets whether this record was created locally (true) or came from server (false)
81+
/// Returns true if system_is_local_origin is 1 or true, false otherwise (including null)
82+
bool get isLocalOrigin {
83+
final value = getRawValue('system_is_local_origin');
84+
return value == 1 || value == true;
85+
}
86+
8087
/// Gets a raw value from the data map without type conversion
8188
Object? getRawValue(String columnName) {
8289
return _data[columnName];
@@ -129,6 +136,15 @@ abstract class DbRecord {
129136
'Column $columnName does not exist in update table $_updateTableName',
130137
);
131138
}
139+
140+
// Restrict updates to LWW columns only for rows that came from server
141+
if (!isLocalOrigin && !column.isLww && !columnName.startsWith('system_')) {
142+
throw StateError(
143+
'Column $columnName is not marked as LWW and cannot be updated on rows that originated from server. '
144+
'Only LWW columns can be updated on existing server rows. '
145+
'This row is marked as non-local origin (system_is_local_origin = ${getRawValue('system_is_local_origin')})',
146+
);
147+
}
132148
}
133149

134150
final column = _getColumn(columnName);

declarative_sqlite/lib/src/declarative_database.dart

Lines changed: 52 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -739,7 +739,7 @@ class DeclarativeDatabase {
739739
final serializedValues = _serializeValuesForTable(tableName, values);
740740

741741
final systemId = await _insert(tableName, serializedValues, now);
742-
await dirtyRowStore?.add(tableName, systemId, now);
742+
await dirtyRowStore?.add(tableName, systemId, now, true); // Full row for new inserts
743743

744744
// Notify streaming queries of the change
745745
await _streamManager.notifyTableChanged(tableName);
@@ -766,6 +766,44 @@ class DeclarativeDatabase {
766766
if (valuesToInsert['system_created_at'] == null) {
767767
valuesToInsert['system_created_at'] = hlc.toString();
768768
}
769+
// Mark as local origin (created by client)
770+
if (valuesToInsert['system_is_local_origin'] == null) {
771+
valuesToInsert['system_is_local_origin'] = 1;
772+
}
773+
774+
for (final col in tableDef.columns) {
775+
if (col.isLww) {
776+
valuesToInsert['${col.name}__hlc'] = hlc.toString();
777+
}
778+
}
779+
780+
await _db.insert(tableName, valuesToInsert);
781+
782+
return valuesToInsert['system_id']! as String;
783+
}
784+
785+
/// Internal method for inserting rows from server during bulkLoad
786+
/// Marks the row as non-local origin (came from server)
787+
Future<String> _insertFromServer(
788+
String tableName, Map<String, Object?> values, Hlc hlc) async {
789+
final tableDef = _getTableDefinition(tableName);
790+
791+
// Convert FilesetField values to database strings
792+
final convertedValues = _convertFilesetFieldsToValues(tableName, values);
793+
794+
// Apply default values for missing columns
795+
final valuesToInsert = _applyDefaultValues(tableName, convertedValues);
796+
797+
// Add system columns
798+
valuesToInsert['system_version'] = hlc.toString();
799+
if (valuesToInsert['system_id'] == null) {
800+
valuesToInsert['system_id'] = Uuid().v4();
801+
}
802+
if (valuesToInsert['system_created_at'] == null) {
803+
valuesToInsert['system_created_at'] = hlc.toString();
804+
}
805+
// Mark as server origin (not created locally)
806+
valuesToInsert['system_is_local_origin'] = 0;
769807

770808
for (final col in tableDef.columns) {
771809
if (col.isLww) {
@@ -793,7 +831,7 @@ class DeclarativeDatabase {
793831
return await DbExceptionWrapper.wrapUpdate(() async {
794832
final rowsToUpdate = await query(
795833
(q) {
796-
q.from(tableName).select('system_id');
834+
q.from(tableName).select('system_id, system_is_local_origin');
797835
if (where != null) {
798836
q.where(RawSqlWhereClause(where, whereArgs));
799837
}
@@ -814,8 +852,9 @@ class DeclarativeDatabase {
814852

815853
if (result > 0) {
816854
for (final row in rowsToUpdate) {
855+
final isLocalOrigin = row.getValue<int>('system_is_local_origin') == 1;
817856
await dirtyRowStore?.add(
818-
tableName, row.getValue<String>('system_id')!, now);
857+
tableName, row.getValue<String>('system_id')!, now, isLocalOrigin);
819858
}
820859
// Notify streaming queries of the change
821860
await _streamManager.notifyTableChanged(tableName);
@@ -906,7 +945,7 @@ class DeclarativeDatabase {
906945

907946
final rowsToDelete = await query(
908947
(q) {
909-
q.from(tableName).select('system_id');
948+
q.from(tableName).select('system_id, system_is_local_origin');
910949
if (where != null) {
911950
q.where(RawSqlWhereClause(where, whereArgs));
912951
}
@@ -922,8 +961,9 @@ class DeclarativeDatabase {
922961
if (result > 0) {
923962
final now = hlcClock.now();
924963
for (final row in rowsToDelete) {
964+
final isLocalOrigin = row.getValue<int>('system_is_local_origin') == 1;
925965
await dirtyRowStore?.add(
926-
tableName, row.getValue<String>('system_id')!, now);
966+
tableName, row.getValue<String>('system_id')!, now, isLocalOrigin);
927967
}
928968
// Notify streaming queries of the change
929969
await _streamManager.notifyTableChanged(tableName);
@@ -1040,9 +1080,11 @@ class DeclarativeDatabase {
10401080
/// - If a local row with the same `system_id` exists, it's an UPDATE.
10411081
/// - LWW columns are only updated if the incoming HLC is newer.
10421082
/// - Regular columns are always updated.
1043-
/// - If no local row exists, it's an INSERT.
1083+
/// - The `system_is_local_origin` flag is preserved (not overwritten).
1084+
/// - If no local row exists, it's an INSERT marked as server origin.
10441085
///
1045-
/// Rows processed by this method are NOT marked as dirty.
1086+
/// Rows processed by this method are NOT marked as dirty, as they represent
1087+
/// data coming from the server rather than local changes to be synchronized.
10461088
Future<void> bulkLoad(
10471089
String tableName, List<Map<String, Object?>> rows) async {
10481090
final tableDef = _getTableDefinition(tableName);
@@ -1071,7 +1113,7 @@ class DeclarativeDatabase {
10711113

10721114
for (final entry in row.entries) {
10731115
final colName = entry.key;
1074-
if (pkColumns.contains(colName) || colName.endsWith('__hlc')) {
1116+
if (pkColumns.contains(colName) || colName.endsWith('__hlc') || colName == 'system_is_local_origin') {
10751117
continue;
10761118
}
10771119

@@ -1111,8 +1153,8 @@ class DeclarativeDatabase {
11111153
);
11121154
}
11131155
} else {
1114-
// INSERT logic
1115-
await _insert(tableName, row, hlcClock.now());
1156+
// INSERT logic - mark as server origin
1157+
await _insertFromServer(tableName, row, hlcClock.now());
11161158
}
11171159
}
11181160

declarative_sqlite/lib/src/sync/dirty_row.dart

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,15 @@ class DirtyRow extends Equatable {
55
final String tableName;
66
final String rowId;
77
final Hlc hlc;
8+
final bool isFullRow; // true if full row should be sent, false if only LWW columns
89

910
@override
10-
List<Object?> get props => [tableName, rowId, hlc];
11+
List<Object?> get props => [tableName, rowId, hlc, isFullRow];
1112

1213
const DirtyRow({
1314
required this.tableName,
1415
required this.rowId,
1516
required this.hlc,
17+
required this.isFullRow,
1618
});
1719
}

declarative_sqlite/lib/src/sync/dirty_row_store.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ abstract class DirtyRowStore {
88
Future<void> init(DatabaseExecutor db);
99

1010
/// Adds an operation to the store.
11-
Future<void> add(String tableName, String rowId, Hlc hlc);
11+
Future<void> add(String tableName, String rowId, Hlc hlc, bool isFullRow);
1212

1313
/// Retrieves all pending operations from the store.
1414
Future<List<DirtyRow>> getAll();

declarative_sqlite/lib/src/sync/sqlite_dirty_row_store.dart

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,24 +16,25 @@ class SqliteDirtyRowStore implements DirtyRowStore {
1616
}
1717

1818
@override
19-
Future<void> add(String tableName, String rowId, Hlc hlc) async {
19+
Future<void> add(String tableName, String rowId, Hlc hlc, bool isFullRow) async {
2020
await _db.rawInsert('''
21-
INSERT OR REPLACE INTO $_tableName (table_name, row_id, hlc)
22-
VALUES (?, ?, ?)
23-
''', [tableName, rowId, hlc.toString()]);
21+
INSERT OR REPLACE INTO $_tableName (table_name, row_id, hlc, is_full_row)
22+
VALUES (?, ?, ?, ?)
23+
''', [tableName, rowId, hlc.toString(), isFullRow ? 1 : 0]);
2424
}
2525

2626
@override
2727
Future<List<DirtyRow>> getAll() async {
2828
final results = await _db.query(
2929
_tableName,
30-
columns: ['table_name', 'row_id', 'hlc'],
30+
columns: ['table_name', 'row_id', 'hlc', 'is_full_row'],
3131
);
3232
return results.map((row) {
3333
return DirtyRow(
3434
tableName: row['table_name'] as String,
3535
rowId: row['row_id'] as String,
3636
hlc: Hlc.parse(row['hlc'] as String),
37+
isFullRow: (row['is_full_row'] as int) == 1,
3738
);
3839
}).toList();
3940
}
@@ -48,11 +49,12 @@ class SqliteDirtyRowStore implements DirtyRowStore {
4849
for (final operation in operations) {
4950
await _db.delete(
5051
_tableName,
51-
where: 'table_name = ? AND row_id = ? AND hlc = ?',
52+
where: 'table_name = ? AND row_id = ? AND hlc = ? AND is_full_row = ?',
5253
whereArgs: [
5354
operation.tableName,
5455
operation.rowId,
55-
operation.hlc.toString()
56+
operation.hlc.toString(),
57+
operation.isFullRow ? 1 : 0,
5658
],
5759
);
5860
}

0 commit comments

Comments
 (0)