Compare commits

..

2 Commits

Author SHA1 Message Date
Mike Kell 0ef6ecceb0 Merge feat/data-layer-contracts into main (Stage 8B complete)
Publish Docs / publish-docs (push) Successful in 36s Details
2026-07-11 08:32:04 -04:00
Mike Kell 4a935e4989 feat(data): Stage 8B — shared data layer contracts activated 2026-07-11 08:31:46 -04:00
10 changed files with 1303 additions and 19 deletions

View File

@ -2,9 +2,9 @@
## Current status ## Current status
- main baseline updated through: integrations-contracts (Stage 8A complete) - main baseline updated through: data-layer-contracts (Stage 8B complete)
- next branch: feat/data-layer-contracts (Stage 8B) - next branch: feat/auth-foundation (Stage 8C)
- current stage: Stage 8A complete — integration abstractions activated - current stage: Stage 8B complete — shared data layer abstractions activated
## Slice tracker ## Slice tracker
@ -402,6 +402,25 @@
- analyze: passed (dart analyze --fatal-infos — no issues found in integrations and feature_wordpress) - analyze: passed (dart analyze --fatal-infos — no issues found in integrations and feature_wordpress)
- brief updated: yes - 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<T>` abstract contract; `InMemoryCache<T>` 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<T>` with `useLocal`/`useRemote`/`merged`/`requiresReview` constructors; `ConflictResolver<T>` abstract contract; `LastWriteWinsResolver<T>`, `LocalAlwaysWinsResolver<T>`, `RemoteAlwaysWinsResolver<T>` concrete implementations
- `data/lib/src/change_tracker.dart` — new `ChangeState` enum, `TrackedChange<T>` value object with `copyWith()`; `ChangeTracker<T>` abstract contract; `InMemoryChangeTracker<T>` implementation with trackCreated/trackModified/trackDeleted/markSynced/markSyncError/pendingChanges/hasPendingChanges/clear
- `data/lib/src/data_mapper.dart` — new `DataMapper<TLocal, TRemote>` 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 ### feat/bulk-operator-workflows
- status: merged to main - status: merged to main

View File

@ -70,8 +70,8 @@ Rules:
- `feature_policy` — domain, application, data (fake), and presentation layers - `feature_policy` — domain, application, data (fake), and presentation layers
- Scaffolded but **empty stub** packages (created, no implementation yet): - Scaffolded but **empty stub** packages (created, no implementation yet):
- `auth` — authentication/authorization (stub only) - `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 - `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<T>`, `SyncQueue`, `ConflictResolver<T>`, `ChangeTracker<T>`, `DataMapper<TLocal, TRemote>` contracts defined with in-memory implementations
- `feature_finance` — financial analysis feature (stub only) - `feature_finance` — financial analysis feature (stub only)
- `feature_mrp` — craft manufacturing/MRP feature (stub only) - `feature_mrp` — craft manufacturing/MRP feature (stub only)
- `feature_social` — social media management 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. - ✅ 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. - ✅ 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). - ✅ 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` ### Current narrow edit capabilities on `main`
@ -147,9 +148,10 @@ Rules:
- latest reported count for `feature_wordpress`: `319/319 passed` - latest reported count for `feature_wordpress`: `319/319 passed`
- latest reported count for `kell_web`: `24/24 passed` - latest reported count for `kell_web`: `24/24 passed`
- latest reported count for `integrations`: `38/38 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` - latest reported count for `kell_mobile`: `26/26 passed`
- total: `658/658 passed` - total: `721/721 passed`
- baseline commit: merge of `feat/integrations-contracts` into `main` (Stage 8A complete, 2026-07-11) - baseline commit: merge of `feat/data-layer-contracts` into `main` (Stage 8B complete, 2026-07-11)
#### Baseline test coverage (established 2026-05-22) #### 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 ### Next recommended branch
**`feat/data-layer-contracts`** — Stage 8B: Shared data layer abstractions. **`feat/auth-foundation`** — Stage 8C: Authentication foundation.
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 916. 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.
--- ---

View File

@ -1,5 +1,13 @@
/// A Calculator. /// Shared data layer abstractions for the Kell Creations platform.
class Calculator { ///
/// Returns [value] plus 1. /// This library defines the contracts for local caching, offline operation
int addOne(int value) => value + 1; /// 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';

View File

@ -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<T> {
/// 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<T> copyWith({T? value, ChangeState? state, String? syncError}) {
return TrackedChange<T>(
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<T> {
/// Records that [record] with [id] was created locally.
Future<void> trackCreated(String id, T record);
/// Records that [record] with [id] was modified locally.
Future<void> trackModified(String id, T record);
/// Records that the record with [id] was deleted locally.
Future<void> trackDeleted(String id);
/// Marks the record with [id] as successfully synced (removes it from the
/// pending-changes set).
Future<void> markSynced(String id);
/// Records a sync error for [id] with the given [errorMessage].
Future<void> markSyncError(String id, String errorMessage);
/// Returns all records that have pending local changes (created, modified,
/// deleted, or errored).
Future<List<TrackedChange<T>>> pendingChanges();
/// Returns the [TrackedChange] for [id], or `null` if the record is synced
/// or not tracked.
Future<TrackedChange<T>?> getChange(String id);
/// Returns `true` if there are any pending local changes.
Future<bool> get hasPendingChanges;
/// Clears all tracked changes (e.g. after a full re-sync from the remote).
Future<void> clear();
}
/// An in-memory [ChangeTracker] suitable for testing and development.
class InMemoryChangeTracker<T> implements ChangeTracker<T> {
final Map<String, TrackedChange<T>> _changes = {};
@override
Future<void> trackCreated(String id, T record) async {
_changes[id] = TrackedChange<T>(
id: id,
value: record,
state: ChangeState.created,
changedAt: DateTime.now(),
);
}
@override
Future<void> trackModified(String id, T record) async {
// Preserve the original changedAt if already tracked as created.
final existing = _changes[id];
_changes[id] = TrackedChange<T>(
id: id,
value: record,
state: existing?.state == ChangeState.created ? ChangeState.created : ChangeState.modified,
changedAt: existing?.changedAt ?? DateTime.now(),
);
}
@override
Future<void> trackDeleted(String id) async {
final existing = _changes[id];
_changes[id] = TrackedChange<T>(
id: id,
value: null,
state: ChangeState.deleted,
changedAt: existing?.changedAt ?? DateTime.now(),
);
}
@override
Future<void> markSynced(String id) async {
_changes.remove(id);
}
@override
Future<void> markSyncError(String id, String errorMessage) async {
final existing = _changes[id];
if (existing != null) {
_changes[id] = existing.copyWith(state: ChangeState.syncError, syncError: errorMessage);
}
}
@override
Future<List<TrackedChange<T>>> pendingChanges() async {
return _changes.values.where((c) => c.state != ChangeState.synced).toList()
..sort((a, b) => a.changedAt.compareTo(b.changedAt));
}
@override
Future<TrackedChange<T>?> getChange(String id) async => _changes[id];
@override
Future<bool> get hasPendingChanges async => _changes.isNotEmpty;
@override
Future<void> clear() async => _changes.clear();
}

View File

@ -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<T> {
/// 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<T> {
/// 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<T> 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<T> implements ConflictResolver<T> {
const LastWriteWinsResolver();
@override
ConflictResult<T> 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<T> implements ConflictResolver<T> {
const LocalAlwaysWinsResolver();
@override
ConflictResult<T> 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<T> implements ConflictResolver<T> {
const RemoteAlwaysWinsResolver();
@override
ConflictResult<T> resolve({
required T local,
required T remote,
required DateTime localModifiedAt,
required DateTime remoteModifiedAt,
}) {
return ConflictResult.useRemote(remote: remote, reason: 'Remote always wins policy');
}
}

View File

@ -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<String, dynamic>` 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<TLocal, TRemote> {
/// 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<TLocal> fromRemoteList(List<TRemote> 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<TRemote> toRemoteList(List<TLocal> 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)' : ''}';
}

View File

@ -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<T> {
/// Returns the cached value for [key], or `null` if the key is absent or
/// the entry has expired.
Future<T?> 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<void> put(String key, T value, {Duration? ttl});
/// Removes the entry for [key]. No-op if the key is not present.
Future<void> remove(String key);
/// Removes all entries from the cache.
Future<void> clear();
/// Returns `true` if [key] is present and has not expired.
Future<bool> containsKey(String key);
/// Returns all non-expired keys currently in the cache.
Future<List<String>> keys();
/// Releases any resources held by this cache (file handles, DB connections).
Future<void> 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<T> implements LocalCache<T> {
final Map<String, _CacheEntry<T>> _store = {};
@override
Future<T?> 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<void> put(String key, T value, {Duration? ttl}) async {
_store[key] = _CacheEntry(
value: value,
expiresAt: ttl != null ? DateTime.now().add(ttl) : null,
);
}
@override
Future<void> remove(String key) async => _store.remove(key);
@override
Future<void> clear() async => _store.clear();
@override
Future<bool> 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<List<String>> keys() async {
// Prune expired entries before returning keys.
_store.removeWhere((_, entry) => entry.isExpired);
return _store.keys.toList();
}
@override
Future<void> dispose() async => _store.clear();
}
/// Internal entry wrapper that tracks an optional expiry time.
class _CacheEntry<T> {
final T value;
final DateTime? expiresAt;
const _CacheEntry({required this.value, this.expiresAt});
bool get isExpired => expiresAt != null && DateTime.now().isAfter(expiresAt!);
}

View File

@ -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<String, dynamic> 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<void> enqueue(SyncOperation operation);
/// Returns all operations currently in the queue, ordered by [SyncOperation.enqueuedAt].
Future<List<SyncOperation>> 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<SyncFlushResult> process(
SyncOperation operation,
Future<void> Function(SyncOperation) flush,
);
/// Removes [operation] from the queue regardless of its status.
Future<void> remove(String operationId);
/// Removes all completed and failed operations, retaining only pending ones.
Future<void> pruneCompleted();
/// Returns the number of operations currently in the queue.
Future<int> get length;
}
/// An in-memory [SyncQueue] suitable for testing and development.
///
/// Operations are not persisted across process restarts.
class InMemorySyncQueue implements SyncQueue {
final List<SyncOperation> _queue = [];
@override
Future<void> enqueue(SyncOperation operation) async {
_queue.add(operation);
}
@override
Future<List<SyncOperation>> pendingOperations() async {
return _queue.where((op) => op.status == SyncOperationStatus.pending).toList()
..sort((a, b) => a.enqueuedAt.compareTo(b.enqueuedAt));
}
@override
Future<SyncFlushResult> process(
SyncOperation operation,
Future<void> 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<void> remove(String operationId) async {
_queue.removeWhere((op) => op.id == operationId);
}
@override
Future<void> pruneCompleted() async {
_queue.removeWhere(
(op) => op.status == SyncOperationStatus.completed || op.status == SyncOperationStatus.failed,
);
}
@override
Future<int> get length async => _queue.length;
}

View File

@ -1,12 +1,627 @@
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:data/data.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<String, dynamic>> {
@override
_Item fromRemote(Map<String, dynamic> 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<String, dynamic> 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() { void main() {
test('adds one to input values', () { // LocalCache / InMemoryCache
final calculator = Calculator();
expect(calculator.addOne(2), 3); group('InMemoryCache', () {
expect(calculator.addOne(-7), -6); late InMemoryCache<String> cache;
expect(calculator.addOne(0), 1);
setUp(() => cache = InMemoryCache<String>());
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<void>.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<void>.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<void>.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<DataMappingException>()),
);
});
});
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<Exception>());
});
});
// Barrel smoke test
group('data barrel exports', () {
test('all public types are accessible via package:data/data.dart', () {
// LocalCache
expect(InMemoryCache<String>.new, isNotNull);
// SyncQueue
expect(InMemorySyncQueue.new, isNotNull);
expect(SyncOperationStatus.values, isNotEmpty);
// ConflictResolver
expect(LastWriteWinsResolver<String>.new, isNotNull);
expect(LocalAlwaysWinsResolver<String>.new, isNotNull);
expect(RemoteAlwaysWinsResolver<String>.new, isNotNull);
expect(ConflictResolution.values, isNotEmpty);
// ChangeTracker
expect(InMemoryChangeTracker<String>.new, isNotNull);
expect(ChangeState.values, isNotEmpty);
// DataMapper
expect(DataMappingException.new, isNotNull);
});
}); });
} }

View File

@ -20,6 +20,7 @@ TESTABLE=(
packages/core packages/core
packages/design_system packages/design_system
packages/integrations packages/integrations
packages/data
packages/feature_wordpress packages/feature_wordpress
packages/feature_inventory packages/feature_inventory
packages/feature_orders packages/feature_orders
@ -32,6 +33,7 @@ ANALYZABLE=(
packages/core packages/core
packages/design_system packages/design_system
packages/integrations packages/integrations
packages/data
packages/feature_wordpress packages/feature_wordpress
packages/feature_inventory packages/feature_inventory
packages/feature_orders packages/feature_orders