Skip to main content

Data Layer

Two adapters, one store

graph LR
Widget --> LocalStore[(Drift LocalStore)]
LocalStore --> KxApi
KxApi --> FixtureAdapter[FixtureHttpClientAdapter\nassets/fixtures/app_state.json]
KxApi --> DioAdapter[Dio + KxApi (HTTP)]
DioAdapter --> Backend[(FastAPI /api/v1)]

DataSource.fixture | remote is chosen once at bootstrap; only the KxApi binding swaps — Drift, repositories and widgets stay identical.

Drift schema

lib/core/database/kx_database.dart defines:

TableKeyIndexes
Bookingsid PK
InventoryReportsid PKbookingId FK
Inspectionsid PKbookingId
Tasksid PKbookingId, syncState (`synced
Chargesid PKbookingId, inspectionId
ChargePhotosid PKchargeId FK
ContestDraftschargeId PK
AttachmentBlobsid PK
OutboxRowsid PKcreatedAt
SyncMetasingletonstateEpoch, storeRevision, streamEpoch, streamCursor

kx_database.g.dart (525 kB) is generated; DataClassName('BookingRow') etc. control naming. FKs carry KeyAction.cascade so deleting a booking drops its reports/inspections/tasks/charges.

LocalStore façade

lib/core/database/local_store.dart (1.1 kLOC) exposes:

  • Stream<List<BookingRow>> bookingsStream(), hubSnapshotStream(bookingId), chargesStream(bookingId), inspectionsStream, tasksStream, notificationsStream
  • Future<void> applySnapshot(SyncSnapshotData + SyncMeta) — transactional replace of Bookings/...
  • Future<void> applyRemoteChanges(List<StoredEvent> batch) — incremental patch from SSE
  • Future<void> enqueueOutbox(OutboxRow) + drain() hooks

domain_mapper.dart (21 kLOC) maps between Drift companions and domain types with strict validation mirroring StrictModel.

Fixture bundle

lib/core/database/fixture_bundle.dart loads assets/fixtures/app_state.json (canonical HubSnapshot-like aggregate) + manifest.json (per-file SHA-256) + media/ + vectors/. verify_fixture_manifest.py-equivalent logic runs at build: mismatch aborts gen-l10n/build_runner.

KxApi

lib/core/network/kx_api.dart:

abstract class KxApi {
Future<Envelope<HealthData>> health();
Future<Envelope<HubSnapshot>> hub(String bookingId);
Future<Envelope<List<Charge>>> charges({String? bookingId, List<String>? status});
Future<Envelope<Charge>> charge(String id);
Future<Envelope<Charge>> accept({required String chargeId, required String expectedStateEpoch, required int expectedVersion, required String idempotencyKey});
Future<Envelope<Charge>> contest({required String chargeId, required ContestMetadata metadata, required List<AttachmentBlob> attachments, required String idempotencyKey});
Stream<SseFrame> events({String? lastEventId});
}

Impls:

  • Fixture adapter (fixture_http_client_adapter.dart) — replays bundle bytes, fabricates stateEpoch/storeRevision deterministically, writes to Drift directly.
  • Dio adapter — real HTTP; failure_mapper.dart maps DioException → typed KxFailure; api_models.dart is json_serializable wire DTOs.

Both sides attach Idempotency-Key: uuid for commands; exception_mapper.dart normalizes contract errors to union.

Persistence demos

  • Contest draft autosave: ContestCubit debounces reason edits and upserts ContestDrafts(chargeId, reason, updatedAt) so reload restores the field.
  • Optimistic Accept/Contest: LocalStore immediately flips status + version; on 409 epoch_mismatch the optimistic layer rolls back and SyncService fetches fresh snapshot.
  • Attachment bytes: AttachmentBlobs holds staged bytes until Outbox drains via POST .../contest.

Config seam

storage_namespace.dart:StorageNamespace.fixture | remote(Uri) fingerprints apiBaseUrl so a fixture run and a remote(http://127.0.0.1:8000) run never pollute each other's Drift file. database_connection.dart picks file name from it.

Testing

  • test/core/database/local_store_test.dart asserts streams emit after applySnapshot; domain_mapper_test round-trips each entity.
  • Repository contract tests run against both adapters with same expectations.