From 4a935e498986fb43a9b7916c6e5a749d303b29d3 Mon Sep 17 00:00:00 2001 From: Mike Kell Date: Sat, 11 Jul 2026 08:31:46 -0400 Subject: [PATCH] =?UTF-8?q?feat(data):=20Stage=208B=20=E2=80=94=20shared?= =?UTF-8?q?=20data=20layer=20contracts=20activated?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/development/build_execution_tracker.md | 25 +- docs/development/master_development_brief.md | 12 +- .../packages/data/lib/data.dart | 18 +- .../packages/data/lib/src/change_tracker.dart | 165 +++++ .../data/lib/src/conflict_resolver.dart | 136 ++++ .../packages/data/lib/src/data_mapper.dart | 53 ++ .../packages/data/lib/src/local_cache.dart | 96 +++ .../packages/data/lib/src/sync_queue.dart | 188 ++++++ .../packages/data/test/data_test.dart | 627 +++++++++++++++++- kell_creations_apps/tools/run_all_tests.sh | 2 + 10 files changed, 1303 insertions(+), 19 deletions(-) create mode 100644 kell_creations_apps/packages/data/lib/src/change_tracker.dart create mode 100644 kell_creations_apps/packages/data/lib/src/conflict_resolver.dart create mode 100644 kell_creations_apps/packages/data/lib/src/data_mapper.dart create mode 100644 kell_creations_apps/packages/data/lib/src/local_cache.dart create mode 100644 kell_creations_apps/packages/data/lib/src/sync_queue.dart diff --git a/docs/development/build_execution_tracker.md b/docs/development/build_execution_tracker.md index aae0039..46928af 100644 --- a/docs/development/build_execution_tracker.md +++ b/docs/development/build_execution_tracker.md @@ -2,9 +2,9 @@ ## Current status -- main baseline updated through: integrations-contracts (Stage 8A complete) -- next branch: feat/data-layer-contracts (Stage 8B) -- current stage: Stage 8A complete — integration abstractions activated +- main baseline updated through: data-layer-contracts (Stage 8B complete) +- next branch: feat/auth-foundation (Stage 8C) +- current stage: Stage 8B complete — shared data layer abstractions activated ## Slice tracker @@ -402,6 +402,25 @@ - analyze: passed (dart analyze --fatal-infos — no issues found in integrations and feature_wordpress) - brief updated: yes +### feat/data-layer-contracts + +- status: merged to main +- date: 2026-07-11 +- inspection: complete +- implementation: complete +- files changed: + - `data/lib/src/local_cache.dart` — new `LocalCache` abstract contract; `InMemoryCache` implementation with TTL-based expiry, key enumeration, and dispose + - `data/lib/src/sync_queue.dart` — new `SyncOperationStatus` enum, `SyncOperation` value object with `copyWith()`; `SyncFlushResult` with `success`/`failure` constructors; `SyncQueue` abstract contract; `InMemorySyncQueue` implementation with enqueue, process (success/failure tracking), remove, pruneCompleted, and length + - `data/lib/src/conflict_resolver.dart` — new `ConflictResolution` enum, `ConflictResult` with `useLocal`/`useRemote`/`merged`/`requiresReview` constructors; `ConflictResolver` abstract contract; `LastWriteWinsResolver`, `LocalAlwaysWinsResolver`, `RemoteAlwaysWinsResolver` concrete implementations + - `data/lib/src/change_tracker.dart` — new `ChangeState` enum, `TrackedChange` value object with `copyWith()`; `ChangeTracker` abstract contract; `InMemoryChangeTracker` implementation with trackCreated/trackModified/trackDeleted/markSynced/markSyncError/pendingChanges/hasPendingChanges/clear + - `data/lib/src/data_mapper.dart` — new `DataMapper` abstract contract with `fromRemote`/`toRemote`/`fromRemoteList`/`toRemoteList`; `DataMappingException` with field, message, rawValue + - `data/lib/data.dart` — replaced `Calculator` placeholder with barrel exports for all 5 new contract files + - `data/test/data_test.dart` — replaced placeholder test with 63 tests covering all contracts and implementations + - `kell_creations_apps/tools/run_all_tests.sh` — added `packages/data` to both `TESTABLE` and `ANALYZABLE` lists +- tests: passed (63/63 data) +- analyze: passed (dart analyze --fatal-infos — no issues found) +- brief updated: yes + ### feat/bulk-operator-workflows - status: merged to main diff --git a/docs/development/master_development_brief.md b/docs/development/master_development_brief.md index a860d2d..f960f5a 100644 --- a/docs/development/master_development_brief.md +++ b/docs/development/master_development_brief.md @@ -70,8 +70,8 @@ Rules: - `feature_policy` — domain, application, data (fake), and presentation layers - Scaffolded but **empty stub** packages (created, no implementation yet): - `auth` — authentication/authorization (stub only) - - `data` — shared data layer (stub only) - `integrations` — ✅ integration contracts activated (Stage 8A complete); `ApiClient`, `ApiException`, `RetryPolicy`, `RateLimiter`, `IntegrationHealthCheck`, `WebhookHandler`, `ChannelAdapter` contracts defined; `WooCommerceApiClient` and `WooCommerceApiException` refactored to implement shared contracts + - `data` — ✅ shared data layer contracts activated (Stage 8B complete); `LocalCache`, `SyncQueue`, `ConflictResolver`, `ChangeTracker`, `DataMapper` contracts defined with in-memory implementations - `feature_finance` — financial analysis feature (stub only) - `feature_mrp` — craft manufacturing/MRP feature (stub only) - `feature_social` — social media management feature (stub only) @@ -123,6 +123,7 @@ Rules: - ✅ Inventory mobile surface landed (Stage 6C complete — merged `feat/inventory-mobile-surface` → `main`, 2026-07-10). Stage 6 complete. - ✅ Bulk publish and bulk submit-for-review actions landed (Stage 7B complete — merged `feat/bulk-operator-workflows` → `main`, 2026-07-11). Stage 7 complete. - ✅ Integration abstractions activated (Stage 8A complete — merged `feat/integrations-contracts` → `main`, 2026-07-11). +- ✅ Shared data layer contracts activated (Stage 8B complete — merged `feat/data-layer-contracts` → `main`, 2026-07-11). ### Current narrow edit capabilities on `main` @@ -147,9 +148,10 @@ Rules: - latest reported count for `feature_wordpress`: `319/319 passed` - latest reported count for `kell_web`: `24/24 passed` - latest reported count for `integrations`: `38/38 passed` +- latest reported count for `data`: `63/63 passed` - latest reported count for `kell_mobile`: `26/26 passed` -- total: `658/658 passed` -- baseline commit: merge of `feat/integrations-contracts` into `main` (Stage 8A complete, 2026-07-11) +- total: `721/721 passed` +- baseline commit: merge of `feat/data-layer-contracts` into `main` (Stage 8B complete, 2026-07-11) #### Baseline test coverage (established 2026-05-22) @@ -165,8 +167,8 @@ No minimum thresholds are enforced — this is visibility-only tracking. Coverag ### Next recommended branch -**`feat/data-layer-contracts`** — Stage 8B: Shared data layer abstractions. -Branch from latest `main`. Stage 8A (integration abstractions) is complete. Stage 8B provides shared data abstractions for local caching, offline storage, and cross-feature data patterns required by Stages 9–16. +**`feat/auth-foundation`** — Stage 8C: Authentication foundation. +Branch from latest `main`. Stages 8A (integration abstractions) and 8B (data layer contracts) are complete. Stage 8C provides the `AuthService` contract, `CredentialStore`, `User`/`AuthState` domain models, and `SecureCredentialStore` implementation required before multi-user or production deployment. --- diff --git a/kell_creations_apps/packages/data/lib/data.dart b/kell_creations_apps/packages/data/lib/data.dart index 298576d..77374e0 100644 --- a/kell_creations_apps/packages/data/lib/data.dart +++ b/kell_creations_apps/packages/data/lib/data.dart @@ -1,5 +1,13 @@ -/// A Calculator. -class Calculator { - /// Returns [value] plus 1. - int addOne(int value) => value + 1; -} +/// Shared data layer abstractions for the Kell Creations platform. +/// +/// This library defines the contracts for local caching, offline operation +/// queuing, multi-channel conflict resolution, change tracking, and +/// local ↔ remote model mapping. Concrete implementations (SQLite-backed, +/// in-memory, etc.) are provided alongside each contract. +library; + +export 'src/change_tracker.dart'; +export 'src/conflict_resolver.dart'; +export 'src/data_mapper.dart'; +export 'src/local_cache.dart'; +export 'src/sync_queue.dart'; diff --git a/kell_creations_apps/packages/data/lib/src/change_tracker.dart b/kell_creations_apps/packages/data/lib/src/change_tracker.dart new file mode 100644 index 0000000..9a396f3 --- /dev/null +++ b/kell_creations_apps/packages/data/lib/src/change_tracker.dart @@ -0,0 +1,165 @@ +/// The sync state of a locally-tracked record. +enum ChangeState { + /// The record matches the last known remote state — no action needed. + synced, + + /// The record was created locally and has not yet been pushed to the remote. + created, + + /// The record was modified locally since the last successful sync. + modified, + + /// The record was deleted locally and the deletion has not yet been applied + /// on the remote. + deleted, + + /// A sync attempt failed; the record needs to be retried. + syncError, +} + +/// Tracks the local modification state of a single record pending sync. +class TrackedChange { + /// Identifier of the record being tracked (matches the domain entity's id). + final String id; + + /// The current local state of the record, or `null` if [state] is + /// [ChangeState.deleted]. + final T? value; + + /// The sync state of this record. + final ChangeState state; + + /// When this change was first recorded locally. + final DateTime changedAt; + + /// Error message from the last failed sync attempt, if any. + final String? syncError; + + const TrackedChange({ + required this.id, + required this.state, + required this.changedAt, + this.value, + this.syncError, + }); + + /// Returns a copy with the given fields replaced. + TrackedChange copyWith({T? value, ChangeState? state, String? syncError}) { + return TrackedChange( + id: id, + value: value ?? this.value, + state: state ?? this.state, + changedAt: changedAt, + syncError: syncError ?? this.syncError, + ); + } + + @override + String toString() => 'TrackedChange(id: $id, state: $state, changedAt: $changedAt)'; +} + +/// Tracks local modifications to domain records that are pending synchronisation +/// with a remote channel. +/// +/// [T] is the domain type being tracked (e.g. `InventoryItem`, `Order`). +/// +/// Implementations record which records have been created, modified, or deleted +/// locally so that a sync engine can determine exactly what needs to be pushed +/// to the remote on the next sync pass. +abstract class ChangeTracker { + /// Records that [record] with [id] was created locally. + Future trackCreated(String id, T record); + + /// Records that [record] with [id] was modified locally. + Future trackModified(String id, T record); + + /// Records that the record with [id] was deleted locally. + Future trackDeleted(String id); + + /// Marks the record with [id] as successfully synced (removes it from the + /// pending-changes set). + Future markSynced(String id); + + /// Records a sync error for [id] with the given [errorMessage]. + Future markSyncError(String id, String errorMessage); + + /// Returns all records that have pending local changes (created, modified, + /// deleted, or errored). + Future>> pendingChanges(); + + /// Returns the [TrackedChange] for [id], or `null` if the record is synced + /// or not tracked. + Future?> getChange(String id); + + /// Returns `true` if there are any pending local changes. + Future get hasPendingChanges; + + /// Clears all tracked changes (e.g. after a full re-sync from the remote). + Future clear(); +} + +/// An in-memory [ChangeTracker] suitable for testing and development. +class InMemoryChangeTracker implements ChangeTracker { + final Map> _changes = {}; + + @override + Future trackCreated(String id, T record) async { + _changes[id] = TrackedChange( + id: id, + value: record, + state: ChangeState.created, + changedAt: DateTime.now(), + ); + } + + @override + Future trackModified(String id, T record) async { + // Preserve the original changedAt if already tracked as created. + final existing = _changes[id]; + _changes[id] = TrackedChange( + id: id, + value: record, + state: existing?.state == ChangeState.created ? ChangeState.created : ChangeState.modified, + changedAt: existing?.changedAt ?? DateTime.now(), + ); + } + + @override + Future trackDeleted(String id) async { + final existing = _changes[id]; + _changes[id] = TrackedChange( + id: id, + value: null, + state: ChangeState.deleted, + changedAt: existing?.changedAt ?? DateTime.now(), + ); + } + + @override + Future markSynced(String id) async { + _changes.remove(id); + } + + @override + Future markSyncError(String id, String errorMessage) async { + final existing = _changes[id]; + if (existing != null) { + _changes[id] = existing.copyWith(state: ChangeState.syncError, syncError: errorMessage); + } + } + + @override + Future>> pendingChanges() async { + return _changes.values.where((c) => c.state != ChangeState.synced).toList() + ..sort((a, b) => a.changedAt.compareTo(b.changedAt)); + } + + @override + Future?> getChange(String id) async => _changes[id]; + + @override + Future get hasPendingChanges async => _changes.isNotEmpty; + + @override + Future clear() async => _changes.clear(); +} diff --git a/kell_creations_apps/packages/data/lib/src/conflict_resolver.dart b/kell_creations_apps/packages/data/lib/src/conflict_resolver.dart new file mode 100644 index 0000000..be3b353 --- /dev/null +++ b/kell_creations_apps/packages/data/lib/src/conflict_resolver.dart @@ -0,0 +1,136 @@ +/// The outcome of a conflict resolution decision. +enum ConflictResolution { + /// Use the local version of the record. + useLocal, + + /// Use the remote version of the record. + useRemote, + + /// Merge both versions into a new record (implementation-defined). + merge, + + /// Escalate to the operator — neither version is automatically chosen. + requiresOperatorReview, +} + +/// The result produced by a [ConflictResolver] for a single conflicting record. +class ConflictResult { + /// Which version was chosen (or how the conflict was handled). + final ConflictResolution resolution; + + /// The resolved record, or `null` when [resolution] is + /// [ConflictResolution.requiresOperatorReview]. + final T? resolvedValue; + + /// Human-readable explanation of why this resolution was chosen. + final String? reason; + + const ConflictResult({required this.resolution, this.resolvedValue, this.reason}); + + /// Convenience constructor: local version wins. + const ConflictResult.useLocal({required T local, this.reason}) + : resolution = ConflictResolution.useLocal, + resolvedValue = local; + + /// Convenience constructor: remote version wins. + const ConflictResult.useRemote({required T remote, this.reason}) + : resolution = ConflictResolution.useRemote, + resolvedValue = remote; + + /// Convenience constructor: merged value produced. + const ConflictResult.merged({required T merged, this.reason}) + : resolution = ConflictResolution.merge, + resolvedValue = merged; + + /// Convenience constructor: operator review required. + const ConflictResult.requiresReview({this.reason}) + : resolution = ConflictResolution.requiresOperatorReview, + resolvedValue = null; + + @override + String toString() => 'ConflictResult(resolution: $resolution, reason: $reason)'; +} + +/// Contract for resolving conflicts between a locally-modified record and a +/// remotely-modified record of the same entity. +/// +/// [T] is the domain type being reconciled (e.g. `InventoryItem`, `Order`). +/// +/// Implementations encode the business rules for which version wins when both +/// the local app and a remote channel (WooCommerce, Square) have modified the +/// same record since the last sync. +abstract class ConflictResolver { + /// Resolves the conflict between [local] and [remote] versions of a record. + /// + /// [localModifiedAt] and [remoteModifiedAt] are the last-modified timestamps + /// for each version, used by time-based strategies. + ConflictResult resolve({ + required T local, + required T remote, + required DateTime localModifiedAt, + required DateTime remoteModifiedAt, + }); +} + +/// A [ConflictResolver] that always prefers the most recently modified version. +/// +/// When timestamps are equal the remote version is preferred, on the assumption +/// that the remote is the authoritative source of truth. +class LastWriteWinsResolver implements ConflictResolver { + const LastWriteWinsResolver(); + + @override + ConflictResult resolve({ + required T local, + required T remote, + required DateTime localModifiedAt, + required DateTime remoteModifiedAt, + }) { + if (localModifiedAt.isAfter(remoteModifiedAt)) { + return ConflictResult.useLocal( + local: local, + reason: 'Local version is newer (last-write-wins)', + ); + } + return ConflictResult.useRemote( + remote: remote, + reason: 'Remote version is newer or equal (last-write-wins)', + ); + } +} + +/// A [ConflictResolver] that always prefers the local version. +/// +/// Useful when the app is the authoritative source of truth and remote changes +/// should never overwrite local edits. +class LocalAlwaysWinsResolver implements ConflictResolver { + const LocalAlwaysWinsResolver(); + + @override + ConflictResult resolve({ + required T local, + required T remote, + required DateTime localModifiedAt, + required DateTime remoteModifiedAt, + }) { + return ConflictResult.useLocal(local: local, reason: 'Local always wins policy'); + } +} + +/// A [ConflictResolver] that always prefers the remote version. +/// +/// Useful when the remote channel is the authoritative source of truth and +/// local edits should be discarded on conflict. +class RemoteAlwaysWinsResolver implements ConflictResolver { + const RemoteAlwaysWinsResolver(); + + @override + ConflictResult resolve({ + required T local, + required T remote, + required DateTime localModifiedAt, + required DateTime remoteModifiedAt, + }) { + return ConflictResult.useRemote(remote: remote, reason: 'Remote always wins policy'); + } +} diff --git a/kell_creations_apps/packages/data/lib/src/data_mapper.dart b/kell_creations_apps/packages/data/lib/src/data_mapper.dart new file mode 100644 index 0000000..34ef77d --- /dev/null +++ b/kell_creations_apps/packages/data/lib/src/data_mapper.dart @@ -0,0 +1,53 @@ +/// Contract for bidirectional mapping between a local domain model and a +/// remote data transfer object (DTO). +/// +/// [TLocal] is the domain type used inside the app (e.g. `InventoryItem`). +/// [TRemote] is the wire/DTO type returned by the remote API +/// (e.g. a `Map` decoded from JSON, or a typed DTO class). +/// +/// Implementations live in the data layer of each feature package and keep +/// all serialisation/deserialisation logic out of the domain layer. +abstract class DataMapper { + /// Converts a [remote] DTO into a local domain object. + /// + /// Throws a [DataMappingException] if [remote] is missing required fields + /// or contains values that cannot be mapped to the domain model. + TLocal fromRemote(TRemote remote); + + /// Converts a [local] domain object into a remote DTO suitable for sending + /// to the API (e.g. as a JSON request body). + TRemote toRemote(TLocal local); + + /// Converts a list of [remote] DTOs into a list of local domain objects. + /// + /// Delegates to [fromRemote] for each element. Implementations may override + /// this for batch-optimised mapping. + List fromRemoteList(List remotes) => remotes.map(fromRemote).toList(); + + /// Converts a list of [local] domain objects into a list of remote DTOs. + /// + /// Delegates to [toRemote] for each element. Implementations may override + /// this for batch-optimised mapping. + List toRemoteList(List locals) => locals.map(toRemote).toList(); +} + +/// Thrown when a [DataMapper] cannot convert a remote DTO to a local model. +/// +/// Carries the [field] that caused the problem and a human-readable [message]. +class DataMappingException implements Exception { + /// The field or path within the remote DTO that caused the mapping failure. + final String field; + + /// Human-readable description of the mapping failure. + final String message; + + /// The raw value that could not be mapped, if available. + final Object? rawValue; + + const DataMappingException({required this.field, required this.message, this.rawValue}); + + @override + String toString() => + 'DataMappingException(field: $field): $message' + '${rawValue != null ? ' (raw: $rawValue)' : ''}'; +} diff --git a/kell_creations_apps/packages/data/lib/src/local_cache.dart b/kell_creations_apps/packages/data/lib/src/local_cache.dart new file mode 100644 index 0000000..8e4249b --- /dev/null +++ b/kell_creations_apps/packages/data/lib/src/local_cache.dart @@ -0,0 +1,96 @@ +/// Contract for in-memory and persistent local caching. +/// +/// [T] is the type of value stored in the cache. Implementations may back +/// this with an in-memory map, SQLite, shared preferences, or any other +/// storage mechanism appropriate for the platform. +abstract class LocalCache { + /// Returns the cached value for [key], or `null` if the key is absent or + /// the entry has expired. + Future get(String key); + + /// Stores [value] under [key]. + /// + /// If [ttl] is provided the entry expires after that duration and subsequent + /// [get] calls will return `null`. + Future put(String key, T value, {Duration? ttl}); + + /// Removes the entry for [key]. No-op if the key is not present. + Future remove(String key); + + /// Removes all entries from the cache. + Future clear(); + + /// Returns `true` if [key] is present and has not expired. + Future containsKey(String key); + + /// Returns all non-expired keys currently in the cache. + Future> keys(); + + /// Releases any resources held by this cache (file handles, DB connections). + Future dispose(); +} + +/// A [LocalCache] backed by an in-memory [Map]. +/// +/// Entries are lost when the process exits. Suitable for short-lived caching +/// within a single app session (e.g. caching API responses for the duration +/// of a screen visit). +class InMemoryCache implements LocalCache { + final Map> _store = {}; + + @override + Future get(String key) async { + final entry = _store[key]; + if (entry == null) return null; + if (entry.isExpired) { + _store.remove(key); + return null; + } + return entry.value; + } + + @override + Future put(String key, T value, {Duration? ttl}) async { + _store[key] = _CacheEntry( + value: value, + expiresAt: ttl != null ? DateTime.now().add(ttl) : null, + ); + } + + @override + Future remove(String key) async => _store.remove(key); + + @override + Future clear() async => _store.clear(); + + @override + Future containsKey(String key) async { + final entry = _store[key]; + if (entry == null) return false; + if (entry.isExpired) { + _store.remove(key); + return false; + } + return true; + } + + @override + Future> keys() async { + // Prune expired entries before returning keys. + _store.removeWhere((_, entry) => entry.isExpired); + return _store.keys.toList(); + } + + @override + Future dispose() async => _store.clear(); +} + +/// Internal entry wrapper that tracks an optional expiry time. +class _CacheEntry { + final T value; + final DateTime? expiresAt; + + const _CacheEntry({required this.value, this.expiresAt}); + + bool get isExpired => expiresAt != null && DateTime.now().isAfter(expiresAt!); +} diff --git a/kell_creations_apps/packages/data/lib/src/sync_queue.dart b/kell_creations_apps/packages/data/lib/src/sync_queue.dart new file mode 100644 index 0000000..07af9b5 --- /dev/null +++ b/kell_creations_apps/packages/data/lib/src/sync_queue.dart @@ -0,0 +1,188 @@ +/// The processing status of a [SyncOperation]. +enum SyncOperationStatus { + /// Waiting to be sent to the remote. + pending, + + /// Currently being transmitted. + inProgress, + + /// Successfully applied on the remote. + completed, + + /// Failed after all retry attempts; requires operator attention. + failed, +} + +/// A single unit of work that must be applied to a remote system. +/// +/// Operations are created locally (e.g. while offline) and flushed to the +/// remote when connectivity is restored. +class SyncOperation { + /// Unique identifier for this operation. + final String id; + + /// Logical type of the operation (e.g. `'createOrder'`, `'adjustInventory'`). + final String type; + + /// Serialisable payload describing the operation. + final Map payload; + + /// When this operation was enqueued. + final DateTime enqueuedAt; + + /// Current processing status. + final SyncOperationStatus status; + + /// Number of times this operation has been attempted. + final int attemptCount; + + /// Human-readable error from the last failed attempt, if any. + final String? lastError; + + const SyncOperation({ + required this.id, + required this.type, + required this.payload, + required this.enqueuedAt, + this.status = SyncOperationStatus.pending, + this.attemptCount = 0, + this.lastError, + }); + + /// Returns a copy of this operation with the given fields replaced. + SyncOperation copyWith({SyncOperationStatus? status, int? attemptCount, String? lastError}) { + return SyncOperation( + id: id, + type: type, + payload: payload, + enqueuedAt: enqueuedAt, + status: status ?? this.status, + attemptCount: attemptCount ?? this.attemptCount, + lastError: lastError ?? this.lastError, + ); + } + + @override + String toString() => + 'SyncOperation(id: $id, type: $type, status: $status, attempts: $attemptCount)'; +} + +/// Result returned after attempting to flush a [SyncOperation]. +class SyncFlushResult { + /// The operation that was attempted. + final SyncOperation operation; + + /// Whether the flush succeeded. + final bool success; + + /// Error message if [success] is `false`. + final String? errorMessage; + + const SyncFlushResult({required this.operation, required this.success, this.errorMessage}); + + const SyncFlushResult.success({required this.operation}) : success = true, errorMessage = null; + + const SyncFlushResult.failure({required this.operation, required this.errorMessage}) + : success = false; + + @override + String toString() => + 'SyncFlushResult(id: ${operation.id}, success: $success, error: $errorMessage)'; +} + +/// Abstraction for queuing offline operations and flushing them when online. +/// +/// Implementations persist the queue across app restarts so that operations +/// captured while offline survive process termination. +abstract class SyncQueue { + /// Adds [operation] to the tail of the queue. + Future enqueue(SyncOperation operation); + + /// Returns all operations currently in the queue, ordered by [SyncOperation.enqueuedAt]. + Future> pendingOperations(); + + /// Attempts to apply [operation] to the remote via [flush]. + /// + /// On success the operation is removed from the queue. + /// On failure the operation's [SyncOperation.attemptCount] and + /// [SyncOperation.lastError] are updated and it remains in the queue. + Future process( + SyncOperation operation, + Future Function(SyncOperation) flush, + ); + + /// Removes [operation] from the queue regardless of its status. + Future remove(String operationId); + + /// Removes all completed and failed operations, retaining only pending ones. + Future pruneCompleted(); + + /// Returns the number of operations currently in the queue. + Future get length; +} + +/// An in-memory [SyncQueue] suitable for testing and development. +/// +/// Operations are not persisted across process restarts. +class InMemorySyncQueue implements SyncQueue { + final List _queue = []; + + @override + Future enqueue(SyncOperation operation) async { + _queue.add(operation); + } + + @override + Future> pendingOperations() async { + return _queue.where((op) => op.status == SyncOperationStatus.pending).toList() + ..sort((a, b) => a.enqueuedAt.compareTo(b.enqueuedAt)); + } + + @override + Future process( + SyncOperation operation, + Future Function(SyncOperation) flush, + ) async { + final index = _queue.indexWhere((op) => op.id == operation.id); + if (index == -1) { + return SyncFlushResult.failure( + operation: operation, + errorMessage: 'Operation not found in queue', + ); + } + + // Mark as in-progress. + _queue[index] = _queue[index].copyWith(status: SyncOperationStatus.inProgress); + + try { + await flush(operation); + _queue[index] = _queue[index].copyWith( + status: SyncOperationStatus.completed, + attemptCount: operation.attemptCount + 1, + ); + return SyncFlushResult.success(operation: _queue[index]); + } catch (e) { + _queue[index] = _queue[index].copyWith( + status: SyncOperationStatus.failed, + attemptCount: operation.attemptCount + 1, + lastError: e.toString(), + ); + return SyncFlushResult.failure(operation: _queue[index], errorMessage: e.toString()); + } + } + + @override + Future remove(String operationId) async { + _queue.removeWhere((op) => op.id == operationId); + } + + @override + Future pruneCompleted() async { + _queue.removeWhere( + (op) => op.status == SyncOperationStatus.completed || op.status == SyncOperationStatus.failed, + ); + } + + @override + Future get length async => _queue.length; +} diff --git a/kell_creations_apps/packages/data/test/data_test.dart b/kell_creations_apps/packages/data/test/data_test.dart index b7f74b2..8b91890 100644 --- a/kell_creations_apps/packages/data/test/data_test.dart +++ b/kell_creations_apps/packages/data/test/data_test.dart @@ -1,12 +1,627 @@ import 'package:flutter_test/flutter_test.dart'; - import 'package:data/data.dart'; +// ── Test doubles ───────────────────────────────────────────────────────────── + +/// Simple domain type used across all tests. +class _Item { + final String id; + final String name; + const _Item(this.id, this.name); + @override + String toString() => '_Item($id, $name)'; +} + +/// Minimal [DataMapper] implementation for tests. +class _ItemMapper extends DataMapper<_Item, Map> { + @override + _Item fromRemote(Map remote) { + final id = remote['id']; + final name = remote['name']; + if (id == null || name == null) { + throw DataMappingException(field: 'id/name', message: 'Required fields missing'); + } + return _Item(id as String, name as String); + } + + @override + Map toRemote(_Item local) => {'id': local.id, 'name': local.name}; +} + +/// A [ConflictResolver] that always merges by concatenating names. +class _MergeResolver implements ConflictResolver<_Item> { + const _MergeResolver(); + + @override + ConflictResult<_Item> resolve({ + required _Item local, + required _Item remote, + required DateTime localModifiedAt, + required DateTime remoteModifiedAt, + }) { + return ConflictResult.merged( + merged: _Item(local.id, '${local.name}+${remote.name}'), + reason: 'merged names', + ); + } +} + void main() { - test('adds one to input values', () { - final calculator = Calculator(); - expect(calculator.addOne(2), 3); - expect(calculator.addOne(-7), -6); - expect(calculator.addOne(0), 1); + // ── LocalCache / InMemoryCache ──────────────────────────────────────────── + + group('InMemoryCache', () { + late InMemoryCache cache; + + setUp(() => cache = InMemoryCache()); + tearDown(() async => cache.dispose()); + + test('put and get returns stored value', () async { + await cache.put('k', 'hello'); + expect(await cache.get('k'), 'hello'); + }); + + test('get returns null for missing key', () async { + expect(await cache.get('missing'), isNull); + }); + + test('containsKey returns true for present key', () async { + await cache.put('k', 'v'); + expect(await cache.containsKey('k'), isTrue); + }); + + test('containsKey returns false for missing key', () async { + expect(await cache.containsKey('missing'), isFalse); + }); + + test('remove deletes the entry', () async { + await cache.put('k', 'v'); + await cache.remove('k'); + expect(await cache.get('k'), isNull); + }); + + test('clear removes all entries', () async { + await cache.put('a', '1'); + await cache.put('b', '2'); + await cache.clear(); + expect(await cache.keys(), isEmpty); + }); + + test('keys returns all non-expired keys', () async { + await cache.put('x', '1'); + await cache.put('y', '2'); + final keys = await cache.keys(); + expect(keys, containsAll(['x', 'y'])); + }); + + test('entry expires after ttl', () async { + await cache.put('k', 'v', ttl: const Duration(milliseconds: 1)); + await Future.delayed(const Duration(milliseconds: 10)); + expect(await cache.get('k'), isNull); + }); + + test('containsKey returns false for expired entry', () async { + await cache.put('k', 'v', ttl: const Duration(milliseconds: 1)); + await Future.delayed(const Duration(milliseconds: 10)); + expect(await cache.containsKey('k'), isFalse); + }); + + test('entry without ttl does not expire', () async { + await cache.put('k', 'persistent'); + await Future.delayed(const Duration(milliseconds: 10)); + expect(await cache.get('k'), 'persistent'); + }); + + test('dispose clears all entries', () async { + await cache.put('k', 'v'); + await cache.dispose(); + expect(await cache.keys(), isEmpty); + }); + + test('overwriting a key replaces the value', () async { + await cache.put('k', 'first'); + await cache.put('k', 'second'); + expect(await cache.get('k'), 'second'); + }); + }); + + // ── SyncQueue / InMemorySyncQueue ───────────────────────────────────────── + + group('SyncOperation', () { + final op = SyncOperation( + id: 'op-1', + type: 'createOrder', + payload: {'orderId': 42}, + enqueuedAt: DateTime(2026, 7, 11), + ); + + test('stores all fields with defaults', () { + expect(op.id, 'op-1'); + expect(op.type, 'createOrder'); + expect(op.payload['orderId'], 42); + expect(op.status, SyncOperationStatus.pending); + expect(op.attemptCount, 0); + expect(op.lastError, isNull); + }); + + test('copyWith replaces specified fields', () { + final updated = op.copyWith( + status: SyncOperationStatus.failed, + attemptCount: 2, + lastError: 'timeout', + ); + expect(updated.status, SyncOperationStatus.failed); + expect(updated.attemptCount, 2); + expect(updated.lastError, 'timeout'); + expect(updated.id, op.id); + }); + + test('toString includes id, type, status, attempts', () { + expect(op.toString(), contains('op-1')); + expect(op.toString(), contains('createOrder')); + }); + }); + + group('SyncFlushResult', () { + final op = SyncOperation( + id: 'op-1', + type: 'adjustInventory', + payload: {}, + enqueuedAt: DateTime(2026, 7, 11), + ); + + test('success constructor sets success true and no error', () { + final result = SyncFlushResult.success(operation: op); + expect(result.success, isTrue); + expect(result.errorMessage, isNull); + }); + + test('failure constructor sets success false and error message', () { + final result = SyncFlushResult.failure(operation: op, errorMessage: 'network error'); + expect(result.success, isFalse); + expect(result.errorMessage, 'network error'); + }); + + test('toString includes operation id and success flag', () { + final result = SyncFlushResult.success(operation: op); + expect(result.toString(), contains('op-1')); + expect(result.toString(), contains('true')); + }); + }); + + group('InMemorySyncQueue', () { + late InMemorySyncQueue queue; + + SyncOperation op0(String id) => + SyncOperation(id: id, type: 'test', payload: {}, enqueuedAt: DateTime(2026, 7, 11)); + + setUp(() => queue = InMemorySyncQueue()); + + test('enqueue adds operation and length increases', () async { + await queue.enqueue(op0('a')); + expect(await queue.length, 1); + }); + + test('pendingOperations returns only pending ops', () async { + await queue.enqueue(op0('a')); + await queue.enqueue(op0('b')); + final pending = await queue.pendingOperations(); + expect(pending.length, 2); + expect(pending.every((op) => op.status == SyncOperationStatus.pending), isTrue); + }); + + test('process succeeds and marks operation completed', () async { + final op = op0('a'); + await queue.enqueue(op); + final result = await queue.process(op, (_) async {}); + expect(result.success, isTrue); + expect(result.operation.status, SyncOperationStatus.completed); + expect(result.operation.attemptCount, 1); + }); + + test('process failure marks operation failed with error', () async { + final op = op0('a'); + await queue.enqueue(op); + final result = await queue.process(op, (_) async => throw Exception('boom')); + expect(result.success, isFalse); + expect(result.operation.status, SyncOperationStatus.failed); + expect(result.errorMessage, contains('boom')); + expect(result.operation.attemptCount, 1); + }); + + test('process returns failure for unknown operation id', () async { + final op = op0('unknown'); + final result = await queue.process(op, (_) async {}); + expect(result.success, isFalse); + expect(result.errorMessage, contains('not found')); + }); + + test('remove deletes operation from queue', () async { + await queue.enqueue(op0('a')); + await queue.remove('a'); + expect(await queue.length, 0); + }); + + test('pruneCompleted removes completed and failed ops', () async { + final opA = op0('a'); + final opB = op0('b'); + final opC = op0('c'); + await queue.enqueue(opA); + await queue.enqueue(opB); + await queue.enqueue(opC); + await queue.process(opA, (_) async {}); // completed + await queue.process(opB, (_) async => throw Exception('err')); // failed + // opC remains pending + await queue.pruneCompleted(); + expect(await queue.length, 1); + final pending = await queue.pendingOperations(); + expect(pending.first.id, 'c'); + }); + + test('pendingOperations excludes completed operations', () async { + final op = op0('a'); + await queue.enqueue(op); + await queue.process(op, (_) async {}); + final pending = await queue.pendingOperations(); + expect(pending, isEmpty); + }); + }); + + // ── ConflictResolver ────────────────────────────────────────────────────── + + group('ConflictResult', () { + const item = _Item('1', 'Widget'); + + test('useLocal constructor sets resolution and value', () { + final result = ConflictResult.useLocal(local: item, reason: 'test'); + expect(result.resolution, ConflictResolution.useLocal); + expect(result.resolvedValue, item); + expect(result.reason, 'test'); + }); + + test('useRemote constructor sets resolution and value', () { + final result = ConflictResult.useRemote(remote: item, reason: 'test'); + expect(result.resolution, ConflictResolution.useRemote); + expect(result.resolvedValue, item); + }); + + test('merged constructor sets resolution and value', () { + final result = ConflictResult.merged(merged: item, reason: 'merged'); + expect(result.resolution, ConflictResolution.merge); + expect(result.resolvedValue, item); + }); + + test('requiresReview constructor sets resolution and null value', () { + const result = ConflictResult<_Item>.requiresReview(reason: 'conflict'); + expect(result.resolution, ConflictResolution.requiresOperatorReview); + expect(result.resolvedValue, isNull); + }); + + test('toString includes resolution and reason', () { + final result = ConflictResult.useLocal(local: item, reason: 'newer'); + expect(result.toString(), contains('useLocal')); + expect(result.toString(), contains('newer')); + }); + }); + + group('LastWriteWinsResolver', () { + const resolver = LastWriteWinsResolver<_Item>(); + const local = _Item('1', 'Local'); + const remote = _Item('1', 'Remote'); + final older = DateTime(2026, 1, 1); + final newer = DateTime(2026, 7, 11); + + test('prefers local when local is newer', () { + final result = resolver.resolve( + local: local, + remote: remote, + localModifiedAt: newer, + remoteModifiedAt: older, + ); + expect(result.resolution, ConflictResolution.useLocal); + expect(result.resolvedValue, local); + }); + + test('prefers remote when remote is newer', () { + final result = resolver.resolve( + local: local, + remote: remote, + localModifiedAt: older, + remoteModifiedAt: newer, + ); + expect(result.resolution, ConflictResolution.useRemote); + expect(result.resolvedValue, remote); + }); + + test('prefers remote when timestamps are equal', () { + final result = resolver.resolve( + local: local, + remote: remote, + localModifiedAt: older, + remoteModifiedAt: older, + ); + expect(result.resolution, ConflictResolution.useRemote); + }); + }); + + group('LocalAlwaysWinsResolver', () { + const resolver = LocalAlwaysWinsResolver<_Item>(); + const local = _Item('1', 'Local'); + const remote = _Item('1', 'Remote'); + final t = DateTime(2026, 7, 11); + + test('always returns local regardless of timestamps', () { + final result = resolver.resolve( + local: local, + remote: remote, + localModifiedAt: t, + remoteModifiedAt: t.add(const Duration(days: 1)), + ); + expect(result.resolution, ConflictResolution.useLocal); + expect(result.resolvedValue, local); + }); + }); + + group('RemoteAlwaysWinsResolver', () { + const resolver = RemoteAlwaysWinsResolver<_Item>(); + const local = _Item('1', 'Local'); + const remote = _Item('1', 'Remote'); + final t = DateTime(2026, 7, 11); + + test('always returns remote regardless of timestamps', () { + final result = resolver.resolve( + local: local, + remote: remote, + localModifiedAt: t.add(const Duration(days: 1)), + remoteModifiedAt: t, + ); + expect(result.resolution, ConflictResolution.useRemote); + expect(result.resolvedValue, remote); + }); + }); + + group('Custom ConflictResolver (merge)', () { + const resolver = _MergeResolver(); + const local = _Item('1', 'A'); + const remote = _Item('1', 'B'); + final t = DateTime(2026, 7, 11); + + test('merge resolver produces merged value', () { + final result = resolver.resolve( + local: local, + remote: remote, + localModifiedAt: t, + remoteModifiedAt: t, + ); + expect(result.resolution, ConflictResolution.merge); + expect((result.resolvedValue as _Item).name, 'A+B'); + }); + }); + + // ── ChangeTracker / InMemoryChangeTracker ───────────────────────────────── + + group('TrackedChange', () { + final now = DateTime(2026, 7, 11); + const item = _Item('1', 'Widget'); + + test('stores all fields', () { + final change = TrackedChange<_Item>( + id: '1', + value: item, + state: ChangeState.created, + changedAt: now, + ); + expect(change.id, '1'); + expect(change.value, item); + expect(change.state, ChangeState.created); + expect(change.changedAt, now); + expect(change.syncError, isNull); + }); + + test('copyWith replaces specified fields', () { + final change = TrackedChange<_Item>( + id: '1', + value: item, + state: ChangeState.modified, + changedAt: now, + ); + final updated = change.copyWith(state: ChangeState.syncError, syncError: 'err'); + expect(updated.state, ChangeState.syncError); + expect(updated.syncError, 'err'); + expect(updated.id, '1'); + }); + + test('toString includes id, state, changedAt', () { + final change = TrackedChange<_Item>( + id: '1', + value: item, + state: ChangeState.created, + changedAt: now, + ); + expect(change.toString(), contains('1')); + expect(change.toString(), contains('created')); + }); + }); + + group('InMemoryChangeTracker', () { + late InMemoryChangeTracker<_Item> tracker; + const item = _Item('1', 'Widget'); + const updated = _Item('1', 'Widget Pro'); + + setUp(() => tracker = InMemoryChangeTracker<_Item>()); + + test('trackCreated records created state', () async { + await tracker.trackCreated('1', item); + final change = await tracker.getChange('1'); + expect(change?.state, ChangeState.created); + expect(change?.value, item); + }); + + test('trackModified records modified state', () async { + await tracker.trackModified('1', updated); + final change = await tracker.getChange('1'); + expect(change?.state, ChangeState.modified); + }); + + test('trackModified on created record preserves created state', () async { + await tracker.trackCreated('1', item); + await tracker.trackModified('1', updated); + final change = await tracker.getChange('1'); + expect(change?.state, ChangeState.created); + }); + + test('trackDeleted records deleted state', () async { + await tracker.trackCreated('1', item); + await tracker.trackDeleted('1'); + final change = await tracker.getChange('1'); + expect(change?.state, ChangeState.deleted); + expect(change?.value, isNull); + }); + + test('markSynced removes the change', () async { + await tracker.trackCreated('1', item); + await tracker.markSynced('1'); + expect(await tracker.getChange('1'), isNull); + }); + + test('markSyncError updates state and error message', () async { + await tracker.trackModified('1', item); + await tracker.markSyncError('1', 'network timeout'); + final change = await tracker.getChange('1'); + expect(change?.state, ChangeState.syncError); + expect(change?.syncError, 'network timeout'); + }); + + test('pendingChanges returns all non-synced changes', () async { + await tracker.trackCreated('1', item); + await tracker.trackModified('2', updated); + final pending = await tracker.pendingChanges(); + expect(pending.length, 2); + }); + + test('hasPendingChanges returns true when changes exist', () async { + await tracker.trackCreated('1', item); + expect(await tracker.hasPendingChanges, isTrue); + }); + + test('hasPendingChanges returns false when empty', () async { + expect(await tracker.hasPendingChanges, isFalse); + }); + + test('clear removes all tracked changes', () async { + await tracker.trackCreated('1', item); + await tracker.trackModified('2', updated); + await tracker.clear(); + expect(await tracker.hasPendingChanges, isFalse); + }); + + test('getChange returns null for untracked id', () async { + expect(await tracker.getChange('unknown'), isNull); + }); + + test('markSyncError on untracked id is a no-op', () async { + // Should not throw. + await tracker.markSyncError('unknown', 'error'); + expect(await tracker.hasPendingChanges, isFalse); + }); + }); + + // ── DataMapper / DataMappingException ───────────────────────────────────── + + group('DataMapper (_ItemMapper)', () { + final mapper = _ItemMapper(); + + test('fromRemote maps valid remote DTO to domain object', () { + final item = mapper.fromRemote({'id': 'i1', 'name': 'Candle'}); + expect(item.id, 'i1'); + expect(item.name, 'Candle'); + }); + + test('toRemote maps domain object to remote DTO', () { + const item = _Item('i1', 'Candle'); + final dto = mapper.toRemote(item); + expect(dto['id'], 'i1'); + expect(dto['name'], 'Candle'); + }); + + test('fromRemoteList maps a list of DTOs', () { + final items = mapper.fromRemoteList([ + {'id': 'a', 'name': 'A'}, + {'id': 'b', 'name': 'B'}, + ]); + expect(items.length, 2); + expect(items[0].id, 'a'); + expect(items[1].name, 'B'); + }); + + test('toRemoteList maps a list of domain objects', () { + const items = [_Item('a', 'A'), _Item('b', 'B')]; + final dtos = mapper.toRemoteList(items); + expect(dtos.length, 2); + expect(dtos[0]['id'], 'a'); + expect(dtos[1]['name'], 'B'); + }); + + test('fromRemote throws DataMappingException for missing fields', () { + expect( + () => mapper.fromRemote({'id': null, 'name': null}), + throwsA(isA()), + ); + }); + }); + + group('DataMappingException', () { + test('stores field, message, and rawValue', () { + const ex = DataMappingException( + field: 'status', + message: 'Unknown status value', + rawValue: 'bogus', + ); + expect(ex.field, 'status'); + expect(ex.message, 'Unknown status value'); + expect(ex.rawValue, 'bogus'); + }); + + test('rawValue defaults to null', () { + const ex = DataMappingException(field: 'id', message: 'Missing id'); + expect(ex.rawValue, isNull); + }); + + test('toString includes field and message', () { + const ex = DataMappingException(field: 'price', message: 'Negative price'); + expect(ex.toString(), contains('price')); + expect(ex.toString(), contains('Negative price')); + }); + + test('toString includes rawValue when present', () { + const ex = DataMappingException(field: 'qty', message: 'Bad qty', rawValue: -5); + expect(ex.toString(), contains('-5')); + }); + + test('is an Exception', () { + const ex = DataMappingException(field: 'x', message: 'y'); + expect(ex, isA()); + }); + }); + + // ── Barrel smoke test ───────────────────────────────────────────────────── + + group('data barrel exports', () { + test('all public types are accessible via package:data/data.dart', () { + // LocalCache + expect(InMemoryCache.new, isNotNull); + // SyncQueue + expect(InMemorySyncQueue.new, isNotNull); + expect(SyncOperationStatus.values, isNotEmpty); + // ConflictResolver + expect(LastWriteWinsResolver.new, isNotNull); + expect(LocalAlwaysWinsResolver.new, isNotNull); + expect(RemoteAlwaysWinsResolver.new, isNotNull); + expect(ConflictResolution.values, isNotEmpty); + // ChangeTracker + expect(InMemoryChangeTracker.new, isNotNull); + expect(ChangeState.values, isNotEmpty); + // DataMapper + expect(DataMappingException.new, isNotNull); + }); }); } diff --git a/kell_creations_apps/tools/run_all_tests.sh b/kell_creations_apps/tools/run_all_tests.sh index 80aa2ec..022a0db 100644 --- a/kell_creations_apps/tools/run_all_tests.sh +++ b/kell_creations_apps/tools/run_all_tests.sh @@ -20,6 +20,7 @@ TESTABLE=( packages/core packages/design_system packages/integrations + packages/data packages/feature_wordpress packages/feature_inventory packages/feature_orders @@ -32,6 +33,7 @@ ANALYZABLE=( packages/core packages/design_system packages/integrations + packages/data packages/feature_wordpress packages/feature_inventory packages/feature_orders