adding packages
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
import 'dart:async';
|
||||
|
||||
// Mocking the result enum to avoid depending on connectivity_plus directly in the harness
|
||||
// if the package is not yet added to dependencies (it wasn't in the list I saw).
|
||||
// But usually one would import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
// Since I haven't added connectivity_plus to the package dependencies (I only added http, etc),
|
||||
// I will define a compatible enum here. If the user app uses connectivity_plus, they can map it.
|
||||
|
||||
enum ConnectivityResult { wifi, mobile, none, ethernet, bluetooth, other, vpn }
|
||||
|
||||
/// A mock adapter for simulating OS connectivity changes.
|
||||
class MockConnectivity {
|
||||
final _controller = StreamController<ConnectivityResult>.broadcast();
|
||||
|
||||
Stream<ConnectivityResult> get onConnectivityChanged => _controller.stream;
|
||||
|
||||
ConnectivityResult _current = ConnectivityResult.wifi;
|
||||
ConnectivityResult get current => _current;
|
||||
|
||||
/// Simulates going offline (Airplane Mode).
|
||||
void goOffline() {
|
||||
_current = ConnectivityResult.none;
|
||||
_controller.add(_current);
|
||||
}
|
||||
|
||||
/// Simulates connecting to WiFi.
|
||||
void goWifi() {
|
||||
_current = ConnectivityResult.wifi;
|
||||
_controller.add(_current);
|
||||
}
|
||||
|
||||
/// Simulates connecting to Mobile Data.
|
||||
void goMobile() {
|
||||
_current = ConnectivityResult.mobile;
|
||||
_controller.add(_current);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_controller.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
/// Manages a local PocketBase process for testing.
|
||||
class PocketBaseController {
|
||||
final Logger _logger =
|
||||
Logger('PocketBaseController'); // Added logger instance
|
||||
Process? _process;
|
||||
final int port;
|
||||
final String host;
|
||||
final String executablePath;
|
||||
Directory? _dataDir;
|
||||
|
||||
final bool managed;
|
||||
|
||||
PocketBaseController({
|
||||
this.port = 8090,
|
||||
this.host = '127.0.0.1',
|
||||
this.executablePath = 'pocketbase', // Assumes in PATH
|
||||
this.managed = true,
|
||||
String? dataDir,
|
||||
}) : _fixedDataDir = dataDir;
|
||||
|
||||
final String? _fixedDataDir;
|
||||
|
||||
String get baseUrl => 'http://$host:$port';
|
||||
|
||||
/// Starts the PocketBase server.
|
||||
Future<void> start({bool verbose = false}) async {
|
||||
if (!managed) {
|
||||
_logger
|
||||
.info('PocketBase is unmanaged. Assuming it is running at $baseUrl');
|
||||
return;
|
||||
}
|
||||
|
||||
if (_process != null) {
|
||||
throw StateError('PocketBase is already running');
|
||||
}
|
||||
|
||||
// Determine data directory
|
||||
if (_fixedDataDir != null) {
|
||||
_dataDir = Directory(_fixedDataDir);
|
||||
if (!await _dataDir!.exists()) {
|
||||
await _dataDir!.create(recursive: true);
|
||||
}
|
||||
_logger.info('Using fixed data directory: ${_dataDir!.path}');
|
||||
} else {
|
||||
// Create a temporary directory for data
|
||||
_dataDir = await Directory.systemTemp.createTemp('pb_test_data_');
|
||||
_logger.info('Created temp data directory: ${_dataDir!.path}');
|
||||
}
|
||||
|
||||
final args = [
|
||||
'serve',
|
||||
'--http=$host:$port',
|
||||
'--dir=${_dataDir!.path}',
|
||||
];
|
||||
|
||||
// Using simple process start
|
||||
_process = await Process.start(
|
||||
executablePath,
|
||||
args,
|
||||
mode: verbose ? ProcessStartMode.inheritStdio : ProcessStartMode.normal,
|
||||
);
|
||||
|
||||
if (!verbose) {
|
||||
// Drain stdout/stderr so buffer doesn't fill up
|
||||
_process!.stdout.listen((_) {});
|
||||
_process!.stderr.listen((_) {});
|
||||
}
|
||||
|
||||
// Wait for it to be ready?
|
||||
// A simple poll loop or just wait a second.
|
||||
// PocketBase starts very fast.
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
|
||||
// Verify it's running by hitting health check?
|
||||
// PocketBase doesn't have a standardized /health endpoint in default setup but the root /api/ works.
|
||||
}
|
||||
|
||||
/// Stops the server and cleans up data.
|
||||
Future<void> stop() async {
|
||||
if (!managed) return;
|
||||
|
||||
_process?.kill(ProcessSignal.sigterm);
|
||||
await _process?.exitCode;
|
||||
_process = null;
|
||||
|
||||
// Only clean up if we created a TEMP directory (i.e. no fixed dir provided)
|
||||
if (_fixedDataDir == null && _dataDir != null && await _dataDir!.exists()) {
|
||||
await _dataDir!.delete(recursive: true);
|
||||
_dataDir = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Initializes the server with an admin account and schema.
|
||||
Future<void> initialize({
|
||||
required String adminEmail,
|
||||
required String adminPass,
|
||||
String? schemaPath,
|
||||
}) async {
|
||||
if (!managed) {
|
||||
_logger.info(
|
||||
'Unmanaged mode: Skipping initialization (superuser/schema). Assuming pre-configured.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (managed) {
|
||||
// 1. Create Superuser using CLI (Only works if we own the dir)
|
||||
_logger.info('Creating superuser: $adminEmail');
|
||||
final args = [
|
||||
'superuser',
|
||||
'create',
|
||||
adminEmail,
|
||||
adminPass,
|
||||
'--dir=${_dataDir!.path}',
|
||||
];
|
||||
|
||||
final p = await Process.run(executablePath, args);
|
||||
if (p.exitCode != 0) {
|
||||
_logger.warning('Failed to creating superuser via CLI: ${p.stderr}');
|
||||
} else {
|
||||
_logger.info('Superuser created.');
|
||||
}
|
||||
} else {
|
||||
_logger.info(
|
||||
'Skipping CLI superuser creation (unmanaged mode). Assuming user exists.');
|
||||
}
|
||||
|
||||
// 2. Import Collections (Works via API regardless of managing process)
|
||||
if (schemaPath != null) {
|
||||
_logger.info('Importing schema from $schemaPath');
|
||||
final schemaFile = File(schemaPath);
|
||||
if (await schemaFile.exists()) {
|
||||
try {
|
||||
// Auth endpoint for superusers (v0.23+)
|
||||
// Fallback to old admins if needed, but let's try the new one first or assuming v0.23 based on schema.
|
||||
var authUrl =
|
||||
'$baseUrl/api/collections/_superusers/auth-with-password';
|
||||
|
||||
final authBody = jsonEncode({
|
||||
'identity': adminEmail,
|
||||
'password': adminPass,
|
||||
});
|
||||
|
||||
var authReq = await http.post(
|
||||
Uri.parse(authUrl),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: authBody,
|
||||
);
|
||||
|
||||
// Fallback for older PB versions if 404
|
||||
if (authReq.statusCode == 404) {
|
||||
_logger.info('Legacy admin auth endpoint fallback...');
|
||||
authUrl = '$baseUrl/api/admins/auth-with-password';
|
||||
authReq = await http.post(
|
||||
Uri.parse(authUrl),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: authBody,
|
||||
);
|
||||
}
|
||||
|
||||
if (authReq.statusCode == 200) {
|
||||
final token = jsonDecode(authReq.body)['token'];
|
||||
|
||||
// New API does not support bulk import. We must create collections one by one.
|
||||
final schemaJson = await schemaFile.readAsString();
|
||||
final List<dynamic> collections = jsonDecode(schemaJson);
|
||||
|
||||
_logger.info('Found ${collections.length} collections to import.');
|
||||
|
||||
for (final col in collections) {
|
||||
final name = col['name'];
|
||||
final type = col['type'];
|
||||
// Preserve ID if possible? PB auto-gens ID usually but can accept ID.
|
||||
final _ = col['id'];
|
||||
|
||||
// Skip system collections that already exist (usually)
|
||||
// In v0.23+, _superusers, _externalAuths, _mfas, _otps, _authOrigins are system.
|
||||
if (col['system'] == true || name.startsWith('_')) {
|
||||
_logger.info('Skipping system collection: $name');
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.info('Creating collection: $name ($type)');
|
||||
|
||||
final createUrl = '$baseUrl/api/collections';
|
||||
final createBody = jsonEncode(col);
|
||||
|
||||
final createReq = await http.post(
|
||||
Uri.parse(createUrl),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': token,
|
||||
},
|
||||
body: createBody,
|
||||
);
|
||||
|
||||
if (createReq.statusCode == 200) {
|
||||
_logger.info('Created collection $name');
|
||||
} else if (createReq.statusCode == 400) {
|
||||
// Check if loop? or already exists?
|
||||
// "Collection name ... already exists"
|
||||
final msg = createReq.body;
|
||||
if (msg.contains('exists')) {
|
||||
_logger.info(
|
||||
'Collection $name already exists. Skipping or Updating?');
|
||||
// Ideally we update: PUT /api/collections/{id_or_name}
|
||||
// But for now, skip.
|
||||
} else {
|
||||
_logger.warning('Failed to create collection $name: $msg');
|
||||
}
|
||||
} else {
|
||||
_logger.warning(
|
||||
'Failed to create collection $name: ${createReq.statusCode} ${createReq.body}');
|
||||
}
|
||||
}
|
||||
_logger.info('Schema import process finished.');
|
||||
} else {
|
||||
_logger.warning('Failed to login as admin: ${authReq.body}');
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.warning('Error importing schema: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Restarts the server (simulating a crash/restart).
|
||||
Future<void> restart() async {
|
||||
// Keep data dir!
|
||||
final savedDir = _dataDir;
|
||||
|
||||
_process?.kill(ProcessSignal.sigterm);
|
||||
await _process?.exitCode;
|
||||
_process = null;
|
||||
|
||||
// Restart with SAME data dir
|
||||
if (savedDir == null) throw StateError("Cannot restart, never started");
|
||||
|
||||
final args = [
|
||||
'serve',
|
||||
'--http=$host:$port',
|
||||
'--dir=${savedDir.path}',
|
||||
];
|
||||
|
||||
_process = await Process.start(executablePath, args);
|
||||
_process!.stdout.listen((_) {});
|
||||
_process!.stderr.listen((_) {});
|
||||
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
/// Abstract Base Action
|
||||
abstract class SimulationAction {
|
||||
Future<void> execute(dynamic context);
|
||||
String describe();
|
||||
}
|
||||
|
||||
/// Creates a new item in the local sync manager
|
||||
class CreateAction extends SimulationAction {
|
||||
final String id;
|
||||
final Map<String, dynamic> data;
|
||||
|
||||
CreateAction(this.id, this.data);
|
||||
|
||||
@override
|
||||
Future<void> execute(context) async {
|
||||
// context is TestContext { manager, logger }
|
||||
await context.manager.create(id, {'id': id, ...data});
|
||||
}
|
||||
|
||||
@override
|
||||
String describe() => 'Create(id: $id, data: $data)';
|
||||
}
|
||||
|
||||
/// Updates an item
|
||||
class UpdateAction extends SimulationAction {
|
||||
final String id;
|
||||
final Map<String, dynamic> data;
|
||||
|
||||
UpdateAction(this.id, this.data);
|
||||
|
||||
@override
|
||||
Future<void> execute(context) async {
|
||||
// We pass the full record data to update() usually, or partial?
|
||||
// SyncManager.update(id, T item). It expects the full item T.
|
||||
// But our T is Map<String, dynamic>.
|
||||
// So if we pass partial, it might overwrite others with null if not careful.
|
||||
// BUT, our generator should probably provide full data or we merge here?
|
||||
// Let's assume the generator provides the fields intended to be updated.
|
||||
// Wait, if SyncManager replaces the record, we need the OLD data to merge.
|
||||
// The test context doesn't expose read access easily unless we use repository.
|
||||
// Let's assume the generator tracks the "current state" of the item to produce valid full updates?
|
||||
// OR: usage of update() in SyncManager:
|
||||
// "await repository.save(record.copyWith(data: item...))"
|
||||
// It REPLACES data. So we need to provide the merged state.
|
||||
|
||||
// For simulation simplicity, we can fetch current from repo, merge, and save.
|
||||
final current = await context.repository.get(id);
|
||||
if (current != null) {
|
||||
final merged = Map<String, dynamic>.from(current.data);
|
||||
merged.addAll(data);
|
||||
await context.manager.update(id, merged);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
String describe() => 'Update(id: $id, changes: $data)';
|
||||
}
|
||||
|
||||
/// Deletes an item
|
||||
class DeleteAction extends SimulationAction {
|
||||
final String id;
|
||||
|
||||
DeleteAction(this.id);
|
||||
|
||||
@override
|
||||
Future<void> execute(context) async {
|
||||
await context.manager.delete(id);
|
||||
}
|
||||
|
||||
@override
|
||||
String describe() => 'Delete(id: $id)';
|
||||
}
|
||||
|
||||
/// Forces a Sync
|
||||
class SyncAction extends SimulationAction {
|
||||
@override
|
||||
Future<void> execute(context) async {
|
||||
try {
|
||||
await context.manager.sync();
|
||||
} catch (e) {
|
||||
// Sync might fail if network is down
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
String describe() => 'Sync()';
|
||||
}
|
||||
|
||||
/// Goes Offline (Airplane Mode + Cable Cut)
|
||||
class GoOfflineAction extends SimulationAction {
|
||||
@override
|
||||
Future<void> execute(context) async {
|
||||
await context.harness.goOffline();
|
||||
}
|
||||
|
||||
@override
|
||||
String describe() => 'GoOffline()';
|
||||
}
|
||||
|
||||
/// Goes Online
|
||||
class GoOnlineAction extends SimulationAction {
|
||||
@override
|
||||
Future<void> execute(context) async {
|
||||
await context.harness.goOnline();
|
||||
}
|
||||
|
||||
@override
|
||||
String describe() => 'GoOnline()';
|
||||
}
|
||||
|
||||
/// Adds Latency to the connection
|
||||
class AddLatencyAction extends SimulationAction {
|
||||
@override
|
||||
Future<void> execute(context) async {
|
||||
await context.harness.injectLatency();
|
||||
}
|
||||
|
||||
@override
|
||||
String describe() => 'AddLatency(1000ms)';
|
||||
}
|
||||
|
||||
/// Removes Latency (Clears Faults)
|
||||
class RemoveLatencyAction extends SimulationAction {
|
||||
@override
|
||||
Future<void> execute(context) async {
|
||||
await context.harness.clearNetworkFaults();
|
||||
}
|
||||
|
||||
@override
|
||||
String describe() => 'RemoveLatency()';
|
||||
}
|
||||
|
||||
/// Restarts the PocketBase Server
|
||||
class RestartServerAction extends SimulationAction {
|
||||
@override
|
||||
Future<void> execute(context) async {
|
||||
await context.harness.pocketbase.restart();
|
||||
}
|
||||
|
||||
@override
|
||||
String describe() => 'RestartServer()';
|
||||
}
|
||||
|
||||
/// Simulates a change happening on the server (Concurrent Modification)
|
||||
class RemoteUpdateAction extends SimulationAction {
|
||||
final String id;
|
||||
final Map<String, dynamic> changes;
|
||||
|
||||
RemoteUpdateAction(this.id, this.changes);
|
||||
|
||||
@override
|
||||
Future<void> execute(context) async {
|
||||
// We use the manager's PB instance which is authenticated.
|
||||
// This simulates "User updated record on another device".
|
||||
// Bypassing the sync manager to touch the server directly.
|
||||
try {
|
||||
await context.manager.pb.collection('notes').update(id, body: changes);
|
||||
} catch (e) {
|
||||
// Ignore errors (e.g. record deleted on server already, or network down)
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
String describe() => 'RemoteUpdate(id: $id, changes: "$changes")';
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:logging/logging.dart';
|
||||
// Import to use in harness if needed, or generic.
|
||||
import 'pocketbase_controller.dart';
|
||||
import 'toxiproxy_client.dart';
|
||||
import 'mock_connectivity.dart';
|
||||
|
||||
/// The Hypervisor that orchestrates the simulation.
|
||||
class SimulationHarness {
|
||||
final ToxiproxyController toxiproxy;
|
||||
final PocketBaseController pocketbase;
|
||||
final MockConnectivity connectivity;
|
||||
final Logger _logger = Logger('SimulationHarness');
|
||||
|
||||
Process? _toxiProcess;
|
||||
final String? toxiproxyBinary;
|
||||
|
||||
SimulationHarness({
|
||||
ToxiproxyController? toxiproxy,
|
||||
PocketBaseController? pocketbase,
|
||||
MockConnectivity? connectivity,
|
||||
this.toxiproxyBinary,
|
||||
}) : toxiproxy = toxiproxy ?? ToxiproxyController(),
|
||||
pocketbase = pocketbase ?? PocketBaseController(),
|
||||
connectivity = connectivity ?? MockConnectivity();
|
||||
|
||||
String get proxyUrl =>
|
||||
'http://localhost:8080'; // The address the app connects to
|
||||
|
||||
/// Sets up the infrastructure: Starts PB, Creates Proxy.
|
||||
Future<void> setUp() async {
|
||||
_logger.info('Setting up simulation environment...');
|
||||
|
||||
// 0. Start Toxiproxy Server if binary provided
|
||||
if (toxiproxyBinary != null) {
|
||||
_logger.info('Starting Toxiproxy server from $toxiproxyBinary');
|
||||
_toxiProcess = await Process.start(toxiproxyBinary!, []);
|
||||
|
||||
// Pipe output to see if it fails to start (e.g. port binding)
|
||||
_toxiProcess!.stdout.transform(utf8.decoder).listen((data) {
|
||||
// _logger.fine('Toxiproxy(out): $data');
|
||||
print('Toxiproxy: $data');
|
||||
});
|
||||
_toxiProcess!.stderr.transform(utf8.decoder).listen((data) {
|
||||
_logger.warning('Toxiproxy(err): $data');
|
||||
print('Toxiproxy(err): $data');
|
||||
});
|
||||
|
||||
// Wait for it to boot
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
}
|
||||
|
||||
// 1. Start PocketBase
|
||||
await pocketbase.start();
|
||||
|
||||
// Initialize DB (Create Admin + Import Collections)
|
||||
await pocketbase.initialize(
|
||||
adminEmail: 'rody.davis.jr@gmail.com',
|
||||
adminPass: 'razroq-hedne5-cafdaT',
|
||||
schemaPath: './example/pb_collections.json',
|
||||
);
|
||||
|
||||
_logger.info('PocketBase started at ${pocketbase.baseUrl}');
|
||||
|
||||
// 2. Setup Toxiproxy
|
||||
// We want localhost:8080 (Proxy) -> localhost:PbPort (Upstream)
|
||||
// If running binary locally, PB is also local (localhost).
|
||||
// So upstream is 'localhost:${pocketbase.port}'.
|
||||
|
||||
// Docker logic fallback
|
||||
// final upstream = 'host.docker.internal:${pocketbase.port}';
|
||||
|
||||
// Local binary logic
|
||||
final upstream = 'localhost:${pocketbase.port}';
|
||||
|
||||
try {
|
||||
// Clean start
|
||||
_logger.info('Resetting Toxiproxy...');
|
||||
await toxiproxy.reset();
|
||||
_logger.info('Toxiproxy reset. Creating proxy pb_api...');
|
||||
await toxiproxy.createProxy('pb_api', '0.0.0.0:8080', upstream);
|
||||
_logger.info('Proxy setup: localhost:8080 -> $upstream');
|
||||
} catch (e) {
|
||||
_logger.warning(
|
||||
'Failed to setup toxiproxy. make sure it is running at ${toxiproxy.host}:${toxiproxy.port}',
|
||||
e);
|
||||
// We might throw here if strict
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Tears down the infrastructure.
|
||||
Future<void> tearDown() async {
|
||||
_logger.info('Tearing down simulation...');
|
||||
await pocketbase.stop();
|
||||
try {
|
||||
await toxiproxy.deleteProxy('pb_api');
|
||||
await toxiproxy.reset(); // Clean up toxics
|
||||
} catch (_) {}
|
||||
|
||||
if (_toxiProcess != null) {
|
||||
_toxiProcess!.kill();
|
||||
_toxiProcess = null;
|
||||
}
|
||||
|
||||
connectivity.dispose();
|
||||
}
|
||||
|
||||
// --- Chaos Helpers ---
|
||||
|
||||
Future<void> goOffline() async {
|
||||
_logger.info('Simulating OFFLINE');
|
||||
connectivity.goOffline(); // OS says offline
|
||||
await toxiproxy.disable('pb_api'); // Cable cut
|
||||
}
|
||||
|
||||
Future<void> goOnline() async {
|
||||
_logger.info('Simulating ONLINE');
|
||||
await toxiproxy.enable('pb_api');
|
||||
connectivity.goWifi();
|
||||
}
|
||||
|
||||
Future<void> injectLatency({int latencyMs = 1000, int jitterMs = 500}) async {
|
||||
await toxiproxy.addToxic(
|
||||
'pb_api', Toxic.latency(latency: latencyMs, jitter: jitterMs));
|
||||
}
|
||||
|
||||
Future<void> injectSlowNetwork() async {
|
||||
// Edge network: High latency, low bandwidth
|
||||
await toxiproxy.addToxic(
|
||||
'pb_api', Toxic.latency(latency: 2000, jitter: 1000));
|
||||
await toxiproxy.addToxic('pb_api', Toxic.bandwidth(rate: 10)); // 10KB/s
|
||||
}
|
||||
|
||||
Future<void> clearNetworkFaults() async {
|
||||
await toxiproxy.deleteToxics('pb_api');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import 'dart:convert';
|
||||
import 'package:diff_match_patch/diff_match_patch.dart';
|
||||
import 'package:pocketbase/pocketbase.dart';
|
||||
import 'package:pocketbase_sync/pocketbase_sync.dart';
|
||||
|
||||
/// Verifies consistency between Local DB and Remote PocketBase.
|
||||
class StateVerifier {
|
||||
final PocketBase pb;
|
||||
final String collection;
|
||||
final SyncRepository repository; // We use raw repository accessor
|
||||
|
||||
StateVerifier({
|
||||
required this.pb,
|
||||
required this.collection,
|
||||
required this.repository,
|
||||
});
|
||||
|
||||
/// Compares local and remote state and returns a list of differences.
|
||||
/// Returns empty list if states are identical (converged).
|
||||
Future<List<String>> verifyConvergence() async {
|
||||
final diffs = <String>[];
|
||||
|
||||
// 1. Fetch All Remote
|
||||
// 1. Fetch All Remote
|
||||
print('Verifier: Fetching remote records...');
|
||||
final remoteRecords =
|
||||
await pb.collection(collection).getFullList(sort: 'id');
|
||||
print('Verifier: Fetched ${remoteRecords.length} remote records.');
|
||||
final remoteMap = {for (var r in remoteRecords) r.id: r.data};
|
||||
|
||||
// 2. Fetch All Local
|
||||
// 2. Fetch All Local
|
||||
print('Verifier: Fetching local records...');
|
||||
final localRecords = await repository.getAll();
|
||||
print('Verifier: Fetched ${localRecords.length} local records.');
|
||||
// Filter out deleted items that are correctly marked as deleted
|
||||
// (In a converged state, if it's deleted locally and synced, it should be gone from server or match server tombstone if server keeps them?
|
||||
// PB doesn't keep tombstones by default unless we use a "deleted" column.
|
||||
// The sync manager deletes from PB. So remote should NOT have it.
|
||||
// Local might have it as isDeleted=true.
|
||||
// So:
|
||||
// - If Record is in Remote: Local must have it, isDeleted=false, content match.
|
||||
// - If Record is NOT in Remote: Local must NOT have it OR (Local has it AND isDeleted=true).
|
||||
|
||||
final localMap = {for (var r in localRecords) r.id: r};
|
||||
|
||||
// Check Remote against Local
|
||||
for (var id in remoteMap.keys) {
|
||||
final _ = remoteMap[id]!; // remoteData
|
||||
final localRecord = localMap[id];
|
||||
|
||||
if (localRecord == null) {
|
||||
diffs
|
||||
.add('Missing Local: Record $id exists on server but not locally.');
|
||||
} else if (localRecord.isDeleted) {
|
||||
// If it's deleted locally but exists on server, sync failed to push delete?
|
||||
// OR we haven't synced yet.
|
||||
diffs.add(
|
||||
'Zombie: Record $id is marked deleted locally but exists on server.');
|
||||
} else {
|
||||
// Compare Content
|
||||
// We need to compare JSON.
|
||||
// SyncRepository stores T data. We need to convert T to Map?
|
||||
// Wait, SyncRecord<T> stores T. PocketBase returns Map.
|
||||
// We probably need a way to compare T to Map.
|
||||
// The SyncManager has `toJson`. The verifier might need it too.
|
||||
// But `repository` is generic.
|
||||
// This verifier needs to know how to serialize local data.
|
||||
// Ideally we pass `toJson` to Verifier or use `SyncManager` which has it.
|
||||
// For this generic impl, let's assume T is Map<String, dynamic> or we pass a serializer.
|
||||
}
|
||||
}
|
||||
|
||||
// Check Local against Remote
|
||||
for (var id in localMap.keys) {
|
||||
final local = localMap[id]!;
|
||||
if (!remoteMap.containsKey(id)) {
|
||||
if (!local.isDeleted) {
|
||||
// Exists locally (alive) but not on server.
|
||||
// Could be: Not yet pushed.
|
||||
diffs.add(
|
||||
'Missing Remote: Record $id exists locally but not on server.');
|
||||
} else {
|
||||
// Deleted locally and not on server. This is Good.
|
||||
// (Assuming we don't keep tombstones forever)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return diffs;
|
||||
}
|
||||
|
||||
/// Performs a deep diff of two JSON objects.
|
||||
List<Diff> diffJson(Map<String, dynamic> local, Map<String, dynamic> remote) {
|
||||
final dmp = DiffMatchPatch();
|
||||
// Sort keys for deterministic stringify
|
||||
final localStr = jsonEncode(local); // jsonEncode doesn't guarantee order?
|
||||
// Actually standard jsonEncode is not canonical.
|
||||
// But for simple verification, maybe enough if keys are standard.
|
||||
|
||||
// Better: Compare keys and values manually.
|
||||
return dmp.diff(localStr, jsonEncode(remote));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
/// Controls the Toxiproxy daemon via its HTTP API.
|
||||
class ToxiproxyController {
|
||||
final String host;
|
||||
final int port;
|
||||
final Duration timeout;
|
||||
|
||||
ToxiproxyController({
|
||||
this.host = 'localhost',
|
||||
this.port = 8474,
|
||||
this.timeout = const Duration(seconds: 5),
|
||||
});
|
||||
|
||||
String get _apiBase => 'http://$host:$port';
|
||||
|
||||
/// Creates a new proxy.
|
||||
Future<void> createProxy(String name, String listen, String upstream) async {
|
||||
try {
|
||||
final response = await http
|
||||
.post(
|
||||
Uri.parse('$_apiBase/proxies'),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({
|
||||
'name': name,
|
||||
'listen': listen,
|
||||
'upstream': upstream,
|
||||
'enabled': true,
|
||||
}),
|
||||
)
|
||||
.timeout(timeout);
|
||||
|
||||
if (response.statusCode != 201 && response.statusCode != 200) {
|
||||
throw HttpException('Failed to create proxy: ${response.body}');
|
||||
}
|
||||
} on TimeoutException {
|
||||
throw HttpException('Timeout creating proxy at $_apiBase');
|
||||
}
|
||||
}
|
||||
|
||||
/// Deletes a proxy.
|
||||
Future<void> deleteProxy(String name) async {
|
||||
try {
|
||||
final response = await http
|
||||
.delete(Uri.parse('$_apiBase/proxies/$name'))
|
||||
.timeout(timeout);
|
||||
if (response.statusCode != 204 && response.statusCode != 404) {
|
||||
throw HttpException('Failed to delete proxy: ${response.body}');
|
||||
}
|
||||
} on TimeoutException {
|
||||
throw HttpException('Timeout deleting proxy at $_apiBase');
|
||||
}
|
||||
}
|
||||
|
||||
/// Clears all proxies and toxics.
|
||||
Future<void> reset() async {
|
||||
try {
|
||||
final response =
|
||||
await http.post(Uri.parse('$_apiBase/reset')).timeout(timeout);
|
||||
if (response.statusCode != 204) {
|
||||
throw HttpException('Failed to reset toxiproxy: ${response.body}');
|
||||
}
|
||||
} on TimeoutException {
|
||||
throw HttpException('Timeout resetting toxiproxy at $_apiBase');
|
||||
}
|
||||
}
|
||||
|
||||
/// Disables a proxy (Simulates connection cut).
|
||||
Future<void> disable(String name) async {
|
||||
await _updateState(name, false);
|
||||
}
|
||||
|
||||
/// Enables a proxy (Simulates connection restore).
|
||||
Future<void> enable(String name) async {
|
||||
await _updateState(name, true);
|
||||
}
|
||||
|
||||
Future<void> _updateState(String name, bool enabled) async {
|
||||
try {
|
||||
final response = await http
|
||||
.post(
|
||||
Uri.parse('$_apiBase/proxies/$name'),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({'enabled': enabled}),
|
||||
)
|
||||
.timeout(timeout);
|
||||
if (response.statusCode != 200) {
|
||||
throw HttpException('Failed to update proxy state: ${response.body}');
|
||||
}
|
||||
} on TimeoutException {
|
||||
throw HttpException('Timeout updating proxy state at $_apiBase');
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds a toxic (latency, jitter, etc) to a proxy.
|
||||
Future<void> addToxic(String proxyName, Toxic toxic) async {
|
||||
try {
|
||||
final response = await http
|
||||
.post(
|
||||
Uri.parse('$_apiBase/proxies/$proxyName/toxics'),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode(toxic.toJson()),
|
||||
)
|
||||
.timeout(timeout);
|
||||
if (response.statusCode != 200 &&
|
||||
response.statusCode != 201 &&
|
||||
response.statusCode != 409) {
|
||||
throw HttpException('Failed to add toxic: ${response.body}');
|
||||
}
|
||||
} on TimeoutException {
|
||||
throw HttpException('Timeout adding toxic at $_apiBase');
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes all toxics from a proxy.
|
||||
Future<void> deleteToxics(String proxyName) async {
|
||||
// ...
|
||||
}
|
||||
}
|
||||
|
||||
class Toxic {
|
||||
final String type;
|
||||
final Map<String, dynamic> attributes;
|
||||
final String? name; // Optional, Toxiproxy generates one if not provided.
|
||||
final double toxicity;
|
||||
|
||||
Toxic({
|
||||
required this.type,
|
||||
this.attributes = const {},
|
||||
this.name,
|
||||
this.toxicity = 1.0,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final map = {
|
||||
'type': type,
|
||||
'attributes': attributes,
|
||||
'toxicity': toxicity,
|
||||
};
|
||||
if (name != null) map['name'] = name!;
|
||||
return map;
|
||||
}
|
||||
|
||||
static Toxic latency({int latency = 1000, int jitter = 0}) {
|
||||
return Toxic(
|
||||
type: 'latency',
|
||||
attributes: {
|
||||
'latency': latency,
|
||||
'jitter': jitter,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
static Toxic bandwidth({required int rate}) {
|
||||
return Toxic(
|
||||
type: 'bandwidth',
|
||||
attributes: {
|
||||
'rate': rate, // KBs
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
static Toxic slowClose({required int delay}) {
|
||||
return Toxic(
|
||||
type: 'slow_close',
|
||||
attributes: {
|
||||
'delay': delay,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Add more as needed: limit_data, slicer, timeout.
|
||||
}
|
||||
Reference in New Issue
Block a user