adding packages

This commit is contained in:
2026-01-15 14:38:46 -08:00
parent ef86e3ab6a
commit 6869cf47e5
5253 changed files with 726695 additions and 34 deletions
@@ -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.
}