refactor: Replace drift by serverpod as core engine (#33)
This commit is contained in:
parent
cbc472523b
commit
3545bbba1d
8 changed files with 6196 additions and 671 deletions
13
benchmark/analysis_options.yaml
Normal file
13
benchmark/analysis_options.yaml
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
include: ../analysis_options.yaml
|
||||
|
||||
linter:
|
||||
rules:
|
||||
- public_member_api_docs: false
|
||||
|
||||
analyzer:
|
||||
language:
|
||||
strict-casts: true
|
||||
strict-inference: true
|
||||
|
||||
formatter:
|
||||
trailing_commas: preserve
|
||||
21
benchmark/pubspec.yaml
Normal file
21
benchmark/pubspec.yaml
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
name: serverpod_offline_sync_benchmark
|
||||
description: Local CRDT vs baseline performance impact measurement.
|
||||
publish_to: none
|
||||
|
||||
environment:
|
||||
sdk: '^3.10.3'
|
||||
|
||||
resolution: workspace
|
||||
|
||||
dependencies:
|
||||
benchmark_harness: ^2.4.0
|
||||
intl: ^0.20.2
|
||||
path: ^1.9.1
|
||||
serverpod_database: 3.5.0-beta.5
|
||||
serverpod_offline_sync_server:
|
||||
path: ../packages/serverpod_offline_sync_server
|
||||
serverpod_offline_sync_test_client:
|
||||
path: ../test/serverpod_offline_sync_test_client
|
||||
|
||||
dev_dependencies:
|
||||
lints: '>=3.0.0 <7.0.0'
|
||||
100
benchmark/run.dart
Normal file
100
benchmark/run.dart
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import 'utils/benchmark.dart';
|
||||
import 'utils/conversion.dart';
|
||||
import 'utils/results.dart';
|
||||
import 'utils/runner.dart';
|
||||
|
||||
Future<void> main(List<String> args) async {
|
||||
final runningInCI = args.contains('--ci');
|
||||
String? rowCountArg;
|
||||
for (final arg in args) {
|
||||
if (arg.startsWith('--rows=')) {
|
||||
rowCountArg = arg.substring(7);
|
||||
}
|
||||
}
|
||||
final rowCount = rowCountArg == null ? 1000 : int.parse(rowCountArg);
|
||||
final rowsString = formatter0.format(rowCount);
|
||||
|
||||
if (!runningInCI) print('\n${'=' * 60}');
|
||||
print('## Performance Benchmark ($rowsString rows)\n');
|
||||
print(
|
||||
[
|
||||
'This benchmark measures the performance impact of using CRDT ',
|
||||
'metadata synchronization on database operations.',
|
||||
].join(runningInCI ? '' : '\n'),
|
||||
);
|
||||
if (!runningInCI) print('-' * 60);
|
||||
|
||||
final benchmarkResults = <BenchmarkResults>[];
|
||||
|
||||
for (final operation in Operation.values) {
|
||||
final baselineResult = await runWithProgress(
|
||||
'Running ${operation.name} benchmark (baseline)',
|
||||
() => TypesTableBenchmark(
|
||||
'${operation.name} (baseline)',
|
||||
crdtEnabled: false,
|
||||
operation: operation,
|
||||
rowCount: rowCount,
|
||||
).report(),
|
||||
skipProgress: runningInCI,
|
||||
);
|
||||
|
||||
final crdtResult = await runWithProgress(
|
||||
'Running ${operation.name} benchmark (CRDT)',
|
||||
() => TypesTableBenchmark(
|
||||
'${operation.name} (CRDT)',
|
||||
crdtEnabled: true,
|
||||
operation: operation,
|
||||
rowCount: rowCount,
|
||||
).report(),
|
||||
validator: (result) => result.$1 > baselineResult.$1,
|
||||
skipProgress: runningInCI,
|
||||
);
|
||||
|
||||
benchmarkResults.add(
|
||||
BenchmarkResults(
|
||||
operation: operation,
|
||||
baseline: baselineResult,
|
||||
crdt: crdtResult,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final spuriousBenchmarks = benchmarkResults.where(
|
||||
(result) => result.baseline.$1 > result.crdt.$1,
|
||||
);
|
||||
|
||||
if (spuriousBenchmarks.isNotEmpty) {
|
||||
final leadingText = runningInCI ? '\n> ' : '\n';
|
||||
print(
|
||||
[
|
||||
'$leadingText❌ Baseline time is greater than CRDT time. This is likely due ',
|
||||
'to running with a very low number of rows ($rowsString). This is not ',
|
||||
' a valid benchmark and the time comparison should be ignored.',
|
||||
].join(runningInCI ? '' : '\n'),
|
||||
);
|
||||
|
||||
for (final result in spuriousBenchmarks) {
|
||||
final baselineDelay = result.baseline.$1.toFormattedDuration();
|
||||
final crdtDelay = result.crdt.$1.toFormattedDuration();
|
||||
print(
|
||||
' - ${result.operation.name.toUpperCase()}: $baselineDelay > $crdtDelay',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (final result in benchmarkResults) {
|
||||
printPerformanceImpact(
|
||||
result,
|
||||
rowCount: rowCount,
|
||||
runningInCI: runningInCI,
|
||||
);
|
||||
}
|
||||
printStorageImpact(
|
||||
benchmarkResults.first,
|
||||
rowCount: rowCount,
|
||||
runningInCI: runningInCI,
|
||||
);
|
||||
|
||||
print('\n${'-' * 60}');
|
||||
print('✅ Benchmark complete!\n');
|
||||
}
|
||||
295
benchmark/utils/benchmark.dart
Normal file
295
benchmark/utils/benchmark.dart
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
// Benchmark harness types — internal to the runner CLI.
|
||||
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:benchmark_harness/benchmark_harness.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:serverpod_database/serverpod_database.dart';
|
||||
import 'package:serverpod_offline_sync_server/serverpod_offline_sync_server.dart';
|
||||
import 'package:serverpod_offline_sync_test_client/serverpod_offline_sync_test_client.dart';
|
||||
|
||||
import 'tables.dart';
|
||||
|
||||
enum Operation { insert, update, delete }
|
||||
|
||||
/// Benchmarks plain SQLite writes vs CRDT-wrapped writes using the wide [Types]
|
||||
/// row (same shape idea as legacy Drift `TableWithEveryColumnType`).
|
||||
class TypesTableBenchmark extends AsyncBenchmarkBase {
|
||||
TypesTableBenchmark(
|
||||
super.name, {
|
||||
required this.crdtEnabled,
|
||||
required this.operation,
|
||||
required this.rowCount,
|
||||
});
|
||||
|
||||
/// Minimal blob column payload (shared; column stays non-null with zero bytes).
|
||||
static final ByteData _emptyBlob = ByteData(0);
|
||||
|
||||
final bool crdtEnabled;
|
||||
final Operation operation;
|
||||
final int rowCount;
|
||||
|
||||
static const _clientUrl = 'http://localhost:8081/';
|
||||
|
||||
late final File _dbFile;
|
||||
late final ClientDatabaseSession _plainSession;
|
||||
CrdtDatabaseSession? _crdtSession;
|
||||
|
||||
final UuidValue _userId = const Uuid().v7obj();
|
||||
|
||||
int _valueSeq = 0;
|
||||
|
||||
int _lastDatabaseSize = 0;
|
||||
int _lastRowsCount = 0;
|
||||
|
||||
List<Types> _seededRows = [];
|
||||
|
||||
Types _createTypesRow(int i, DateTime baseTimestamp) {
|
||||
return Types(
|
||||
aBool: i.isEven,
|
||||
aDateTime: baseTimestamp.add(Duration(seconds: i)),
|
||||
aText: 'Text $i',
|
||||
anInt: i,
|
||||
anInt64: BigInt.from(i),
|
||||
aReal: i.toDouble(),
|
||||
aBlob: _emptyBlob,
|
||||
anEnum: null,
|
||||
optionalText: null,
|
||||
optionalUuid: const Uuid().v7obj(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<Types>> _insertTypes(int count) async {
|
||||
final baseTimestamp = DateTime.now();
|
||||
final start = _valueSeq;
|
||||
_valueSeq += count;
|
||||
final rows = List.generate(
|
||||
count,
|
||||
(j) => _createTypesRow(start + j, baseTimestamp),
|
||||
);
|
||||
if (crdtEnabled) {
|
||||
return _crdtSession!.db.transactionForUser(_userId, (tx) async {
|
||||
return Types.db.insert(_crdtSession!, rows, transaction: tx);
|
||||
});
|
||||
}
|
||||
return Types.db.insert(_plainSession, rows);
|
||||
}
|
||||
|
||||
Future<void> _deleteTypes(List<Types> rows) async {
|
||||
if (rows.isEmpty) {
|
||||
return;
|
||||
}
|
||||
if (crdtEnabled) {
|
||||
await _crdtSession!.db.transactionForUser(_userId, (tx) async {
|
||||
await Types.db.delete(_crdtSession!, rows, transaction: tx);
|
||||
});
|
||||
} else {
|
||||
await Types.db.delete(_plainSession, rows);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _updateTypesInPlace() async {
|
||||
final baseTimestamp = DateTime.now();
|
||||
final updated = _seededRows.map((row) {
|
||||
final i = _valueSeq++;
|
||||
return row.copyWith(
|
||||
aBool: i.isEven,
|
||||
aDateTime: baseTimestamp.add(Duration(seconds: i)),
|
||||
aText: 'Text $i',
|
||||
anInt: i,
|
||||
anInt64: BigInt.from(i),
|
||||
aReal: i.toDouble(),
|
||||
aBlob: _emptyBlob,
|
||||
optionalUuid: const Uuid().v7obj(),
|
||||
);
|
||||
}).toList();
|
||||
if (crdtEnabled) {
|
||||
_seededRows = await _crdtSession!.db.transactionForUser(
|
||||
_userId,
|
||||
(tx) async {
|
||||
return Types.db.update(
|
||||
_crdtSession!,
|
||||
updated,
|
||||
columns: (t) => [
|
||||
t.aBool,
|
||||
t.aDateTime,
|
||||
t.aText,
|
||||
t.anInt,
|
||||
t.anInt64,
|
||||
t.aReal,
|
||||
t.aBlob,
|
||||
t.optionalUuid,
|
||||
],
|
||||
transaction: tx,
|
||||
);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
_seededRows = await Types.db.update(
|
||||
_plainSession,
|
||||
updated,
|
||||
columns: (t) => [
|
||||
t.aBool,
|
||||
t.aDateTime,
|
||||
t.aText,
|
||||
t.anInt,
|
||||
t.anInt64,
|
||||
t.aReal,
|
||||
t.aBlob,
|
||||
t.optionalUuid,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _bootstrapDatabase() async {
|
||||
await clearUserTables(_plainSession);
|
||||
CrdtUserManager.clearCache();
|
||||
HlcManager.reset();
|
||||
final wrapped = CrdtDatabaseSession.wraps(
|
||||
_plainSession,
|
||||
syncTables: benchmarkSyncTables,
|
||||
);
|
||||
await wrapped.db.initialize();
|
||||
_crdtSession = crdtEnabled ? wrapped : null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setup() async {
|
||||
_valueSeq = 0;
|
||||
final dbPath = p.join(
|
||||
Directory.systemTemp.path,
|
||||
'offline_sync_benchmark_$name.db',
|
||||
);
|
||||
_dbFile = File(dbPath);
|
||||
if (_dbFile.existsSync()) {
|
||||
_dbFile.deleteSync();
|
||||
}
|
||||
|
||||
_plainSession = await Client(_clientUrl).createSession(
|
||||
_dbFile.path,
|
||||
isDebugMode: true,
|
||||
);
|
||||
|
||||
await _bootstrapDatabase();
|
||||
|
||||
switch (operation) {
|
||||
case Operation.insert:
|
||||
_seededRows = [];
|
||||
case Operation.update:
|
||||
_seededRows = await _insertTypes(rowCount);
|
||||
case Operation.delete:
|
||||
_seededRows = [];
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> warmup() async {}
|
||||
|
||||
@override
|
||||
Future<void> run() async {
|
||||
switch (operation) {
|
||||
case Operation.insert:
|
||||
await _insertTypes(rowCount);
|
||||
case Operation.update:
|
||||
await _updateTypesInPlace();
|
||||
case Operation.delete:
|
||||
await _deleteTypes(_seededRows);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _prepareDeleteCycle() async {
|
||||
await _bootstrapDatabase();
|
||||
_seededRows = await _insertTypes(rowCount);
|
||||
}
|
||||
|
||||
Future<void> _timedDeleteBatch() async {
|
||||
await _deleteTypes(_seededRows);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<double> measure() async {
|
||||
if (operation == Operation.delete) {
|
||||
await setup();
|
||||
try {
|
||||
await AsyncBenchmarkBase.measureFor(() async {}, 100);
|
||||
const minimumMicros = 2000 * 1000;
|
||||
final watch = Stopwatch()..start();
|
||||
var totalTimedMicros = 0.0;
|
||||
var timedIterations = 0;
|
||||
while (watch.elapsedMicroseconds < minimumMicros) {
|
||||
await _prepareDeleteCycle();
|
||||
final sw = Stopwatch()..start();
|
||||
await _timedDeleteBatch();
|
||||
totalTimedMicros += sw.elapsedMicroseconds;
|
||||
timedIterations++;
|
||||
}
|
||||
return totalTimedMicros / timedIterations;
|
||||
} finally {
|
||||
await teardown();
|
||||
}
|
||||
}
|
||||
return super.measure();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<(double, int)> report() async {
|
||||
final averageMicroseconds = await measure();
|
||||
return (averageMicroseconds, measureStorage());
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> teardown() async {
|
||||
try {
|
||||
await _plainSession.db.unsafeExecute('PRAGMA wal_checkpoint(TRUNCATE)');
|
||||
} on Exception {
|
||||
// WAL checkpoint is best-effort (e.g. non-SQLite tests).
|
||||
}
|
||||
_lastDatabaseSize = _sqliteDbFootprintBytes(_dbFile);
|
||||
_lastRowsCount = await Types.db.count(_plainSession);
|
||||
await _plainSession.close();
|
||||
if (_dbFile.existsSync()) {
|
||||
_dbFile.deleteSync();
|
||||
}
|
||||
for (final companion in [
|
||||
File('${_dbFile.path}-wal'),
|
||||
File('${_dbFile.path}-shm'),
|
||||
]) {
|
||||
if (companion.existsSync()) {
|
||||
companion.deleteSync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int measureStorage() {
|
||||
if (operation == Operation.delete) {
|
||||
return _lastDatabaseSize;
|
||||
}
|
||||
|
||||
if (_lastDatabaseSize == 0) {
|
||||
throw Exception('Last database size is not set.');
|
||||
}
|
||||
if (_lastRowsCount < rowCount) {
|
||||
throw Exception(
|
||||
'Rows count is $_lastRowsCount but expected at least $rowCount.',
|
||||
);
|
||||
}
|
||||
return _lastDatabaseSize ~/ (_lastRowsCount / rowCount);
|
||||
}
|
||||
}
|
||||
|
||||
/// Bytes used by SQLite for [mainDb], including WAL sidecars (often omitted by
|
||||
/// `mainDb.lengthSync()` alone).
|
||||
int _sqliteDbFootprintBytes(File mainDb) {
|
||||
var total = mainDb.existsSync() ? mainDb.lengthSync() : 0;
|
||||
final wal = File('${mainDb.path}-wal');
|
||||
final shm = File('${mainDb.path}-shm');
|
||||
if (wal.existsSync()) {
|
||||
total += wal.lengthSync();
|
||||
}
|
||||
if (shm.existsSync()) {
|
||||
total += shm.lengthSync();
|
||||
}
|
||||
return total;
|
||||
}
|
||||
53
benchmark/utils/conversion.dart
Normal file
53
benchmark/utils/conversion.dart
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
// Formatter helpers for CLI output only.
|
||||
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
final NumberFormat formatter0 = NumberFormat.decimalPatternDigits(
|
||||
decimalDigits: 0,
|
||||
);
|
||||
final NumberFormat formatter2 = NumberFormat.decimalPatternDigits(
|
||||
decimalDigits: 2,
|
||||
);
|
||||
|
||||
extension FormattedUnits on num {
|
||||
String toFormattedDuration() {
|
||||
var time = this;
|
||||
var unit = 1;
|
||||
|
||||
while (time.abs() > 1000 && unit < Duration.microsecondsPerSecond) {
|
||||
time /= 1000;
|
||||
unit *= 1000;
|
||||
}
|
||||
while (unit > Duration.microsecondsPerMinute && time.abs() > 60) {
|
||||
time /= 60;
|
||||
unit *= 60;
|
||||
}
|
||||
return '${formatter2.format(time)} ${switch (unit) {
|
||||
1 => 'μs',
|
||||
Duration.millisecondsPerSecond => 'ms',
|
||||
Duration.microsecondsPerSecond => 's',
|
||||
Duration.microsecondsPerMinute => 'min',
|
||||
Duration.microsecondsPerHour => 'h',
|
||||
_ => throw Exception('Unexpected time unit: $unit'),
|
||||
}}';
|
||||
}
|
||||
|
||||
String toFormattedStorageSize() {
|
||||
var storageIncrease = toDouble();
|
||||
var storageUnit = 1;
|
||||
|
||||
while (storageIncrease > 1024) {
|
||||
storageIncrease /= 1024;
|
||||
storageUnit *= 1024;
|
||||
}
|
||||
|
||||
final storageUnitString = switch (storageUnit) {
|
||||
1 => 'B',
|
||||
1024 => 'KB',
|
||||
const (1024 * 1024) => 'MB',
|
||||
_ => throw Exception('Unexpected storage unit: $storageUnit'),
|
||||
};
|
||||
|
||||
return '${formatter2.format(storageIncrease)} $storageUnitString';
|
||||
}
|
||||
}
|
||||
70
benchmark/utils/results.dart
Normal file
70
benchmark/utils/results.dart
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
// Pretty-printing benchmark comparison output.
|
||||
|
||||
import 'benchmark.dart';
|
||||
import 'conversion.dart';
|
||||
|
||||
void printPerformanceImpact(
|
||||
BenchmarkResults benchmarkResults, {
|
||||
required int rowCount,
|
||||
bool runningInCI = false,
|
||||
}) {
|
||||
print(
|
||||
'${runningInCI ? '```' : ''}'
|
||||
'\n📊 ${benchmarkResults.operation.name.toUpperCase()} performance impact:',
|
||||
);
|
||||
final (baselineTime, _) = benchmarkResults.baseline;
|
||||
final (crdtTime, _) = benchmarkResults.crdt;
|
||||
|
||||
final runDelayUs = crdtTime - baselineTime;
|
||||
final slowdown = runDelayUs / baselineTime * 100;
|
||||
|
||||
final baselineDelay = baselineTime.toFormattedDuration();
|
||||
final crdtDelay = crdtTime.toFormattedDuration();
|
||||
final runDelay = runDelayUs.toFormattedDuration();
|
||||
final runDelayPerOperation = (runDelayUs / rowCount).toFormattedDuration();
|
||||
|
||||
print(' Time: $baselineDelay --> $crdtDelay ($runDelay)');
|
||||
print(' CRDT overhead: ${formatter2.format(slowdown)}% slower');
|
||||
print(
|
||||
' Delay per ${benchmarkResults.operation.name}: $runDelayPerOperation',
|
||||
);
|
||||
if (runningInCI) print('```');
|
||||
}
|
||||
|
||||
void printStorageImpact(
|
||||
BenchmarkResults benchmarkResults, {
|
||||
required int rowCount,
|
||||
bool runningInCI = false,
|
||||
}) {
|
||||
print('${runningInCI ? '```' : ''}\n💽 Storage impact:');
|
||||
final (_, baselineSize) = benchmarkResults.baseline;
|
||||
final (_, crdtSize) = benchmarkResults.crdt;
|
||||
|
||||
final storageIncrease = crdtSize - baselineSize;
|
||||
final storageIncPercent = storageIncrease / baselineSize * 100;
|
||||
final extraStoragePerRow = storageIncrease / rowCount;
|
||||
final storageIncreaseVolume = storageIncPercent > 100
|
||||
? '(x${formatter2.format(storageIncPercent / 100)})'
|
||||
: '(+${(crdtSize - baselineSize).toFormattedStorageSize()})';
|
||||
print(
|
||||
' Storage size: ${baselineSize.toFormattedStorageSize()} '
|
||||
'--> ${crdtSize.toFormattedStorageSize()} $storageIncreaseVolume',
|
||||
);
|
||||
print(' CRDT overhead: ${formatter2.format(storageIncPercent)}% increase');
|
||||
print(
|
||||
' Extra storage per row: ${extraStoragePerRow.toFormattedStorageSize()}',
|
||||
);
|
||||
if (runningInCI) print('```');
|
||||
}
|
||||
|
||||
class BenchmarkResults {
|
||||
const BenchmarkResults({
|
||||
required this.operation,
|
||||
required this.baseline,
|
||||
required this.crdt,
|
||||
});
|
||||
|
||||
final Operation operation;
|
||||
final (double, int) baseline;
|
||||
final (double, int) crdt;
|
||||
}
|
||||
20
benchmark/utils/runner.dart
Normal file
20
benchmark/utils/runner.dart
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
// Progress-style logging for the benchmark CLI.
|
||||
|
||||
Future<T> runWithProgress<T>(
|
||||
String message,
|
||||
Future<T> Function() runner, {
|
||||
bool Function(T result)? validator,
|
||||
bool skipProgress = false,
|
||||
}) async {
|
||||
if (!skipProgress) {
|
||||
print('$message…');
|
||||
}
|
||||
final result = await runner();
|
||||
final ok = validator?.call(result) ?? true;
|
||||
if (!skipProgress) {
|
||||
print(
|
||||
ok ? ' done.' : ' failed validation (CRDT not slower than baseline).',
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
31
benchmark/utils/tables.dart
Normal file
31
benchmark/utils/tables.dart
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
// Keeps CRDT scope aligned with integration tests plus the benchmark table.
|
||||
|
||||
import 'package:serverpod_database/serverpod_database.dart';
|
||||
import 'package:serverpod_offline_sync_test_client/serverpod_offline_sync_test_client.dart';
|
||||
|
||||
/// [Types] is benchmark-only; the rest mirrors `client_session.dart` sync scope.
|
||||
List<Table> get benchmarkSyncTables => [
|
||||
Address.t,
|
||||
City.t,
|
||||
Company.t,
|
||||
Organization.t,
|
||||
Person.t,
|
||||
Town.t,
|
||||
Types.t,
|
||||
Unique.t,
|
||||
UniqueUuid.t,
|
||||
];
|
||||
|
||||
Future<void> clearUserTables(DatabaseSession session) async {
|
||||
await session.db.unsafeExecute('PRAGMA foreign_keys = OFF');
|
||||
final result = await session.db.unsafeQuery('''
|
||||
SELECT name
|
||||
FROM sqlite_master
|
||||
WHERE (type = 'table') AND (name NOT LIKE 'serverpod_%')
|
||||
''');
|
||||
for (final row in result) {
|
||||
final tableName = row[0] as String;
|
||||
await session.db.unsafeExecute('DELETE FROM "$tableName"');
|
||||
}
|
||||
await session.db.unsafeExecute('PRAGMA foreign_keys = ON');
|
||||
}
|
||||
Loading…
Reference in a new issue